罗广辉
14 hours ago 7cc239cee1a9af4e2e8a0f3d5b7a00a074b17214
feat: 复用检测
3 files modified
43 ■■■■■ changed files
PROJECT_CONTEXT.md 1 ●●●● patch | view | raw | blame | history
apps/workbench-console/README.md 2 ●●● patch | view | raw | blame | history
apps/workbench-console/src/components/AnomalyDetectionPanel.vue 40 ●●●●● patch | view | raw | blame | history
PROJECT_CONTEXT.md
@@ -294,4 +294,5 @@
- `scripts/serve_workbench_console.py` 仅监听 `127.0.0.1` 等回环地址,支持轨迹与目标检测的受限 JSON/Base64 上传 API;每次生成唯一运行目录,不覆盖原始数据或既有输出。
- 轨迹上传入口接受 XLSX、KMZ、禁飞区 GeoJSON 和可选适飞区 Gzip,预处理器已支持缺少适飞区时继续生成可运行 case。
- 目标检测页面默认并列展示原图与标注图;案例数据从输出目录扫描,历史多模型检测结果可以在同一案例库中选择。
- 异常检测案例库支持把历史案例的原始参考图、待检测图和运行参数回填到新运行表单,供用户调整后创建新的检测运行;回填操作不自动运行,也不覆盖历史案例。
- 轨迹汇总的 `normal`、`stop`、`route_deviation`、`restricted_zone`、`gathering` 标签在 UI 显示中文,原始英文值保留在 CSV/GeoJSON。
apps/workbench-console/README.md
@@ -69,6 +69,6 @@
- 语义分割上传最多 6 张 `JPG/JPEG/PNG/TIF/TIFF`,任务预设从能力目录动态读取;当前只有通用颜色流程可运行,排洪沟、边坡、竖井和尾矿库任务在模型与真值齐备前保持禁用。结果展示原图、栅格叠加、GeoJSON 矢量预览、类别比例和可下载工件。
- 空间测量上传最多 4 份 `PNG/TIF/TIFF` 单波段标签栅格,展示彩色栅格、GeoAI 矢量对象、计数、面积、周长和对象明细;只有有效投影 CRS 才使用地图单位。
- 变化检测上传一对 `JPG/JPEG/PNG/TIF/TIFF`,每次运行可选择自动识别、普通图片或 GeoTIFF 地理参考模式;阈值支持 `0.01~0.99`(默认 `0.5`),分辨率支持 GeoTIFF 原始分辨率/自动以及 `1024/1536/2048/3072` 档位。GeoTIFF 模式保留 CRS 和地图坐标,普通图片模式输出像素坐标。单文件最大 `1024 MB`,单次 Base64 JSON 请求最大 `3072 MB`。
- 异常检测分别上传 1~6 张正常参考影像和 1~6 张待检测影像,支持 `JPG/JPEG/PNG/TIF/TIFF`。3 张以上固定机位且全部尺寸一致时自动启用同位置规则和通用局部外观/结构变化通道,否则回退为全局规则;局部通道不使用颜色或物品类别规则。结果页显示实际选择的模式。大文件使用二进制流,后台任务完成后进入案例库;结果展示参考输入、待检测原图、规则/Isolation Forest 对比、热力图、候选区字段与下载工件。
- 异常检测分别上传 1~6 张正常参考影像和 1~6 张待检测影像,支持 `JPG/JPEG/PNG/TIF/TIFF`。3 张以上固定机位且全部尺寸一致时自动启用同位置规则和通用局部外观/结构变化通道,否则回退为全局规则;局部通道不使用颜色或物品类别规则。结果页显示实际选择的模式。大文件使用二进制流,后台任务完成后进入案例库;结果展示参考输入、待检测原图、规则/Isolation Forest 对比、热力图、候选区字段与下载工件。案例列表右侧的“复用输入”会把该案例的原始参考图、待检测图和四项可编辑参数回填到新运行表单,不自动启动检测,也不修改历史案例。
- 本地 API 还包括 `GET/POST /api/change-detection/runs` 和 `PUT /api/change-detection/uploads/<upload-id>`;变化检测前端使用二进制流上传,服务端按原字节暂存并复制到独立 raw、processed 和 output 目录,不修改原始 TIFF,也不覆盖既有运行。
- 本地 API 为 `GET/POST /api/trajectory/runs`、`GET/POST /api/object-detection/runs`、`GET/POST /api/semantic-mapping/runs`、`GET/POST /api/spatial-measurement/runs`、`GET/POST /api/anomaly-detection/runs` 和只读的 `GET /api/semantic-mapping/tasks`;异常检测另有二进制上传和后台任务状态端点。服务仅监听回环地址,端口必须是 `6xxx`,只调用固定虚拟环境和能力脚本,不接受任意命令或任意路径。
apps/workbench-console/src/components/AnomalyDetectionPanel.vue
@@ -17,6 +17,7 @@
const randomState = ref(42);
const showRunForm = ref(false);
const running = ref(false);
const reusingCaseId = ref("");
const runStage = ref("");
const runError = ref<string | null>(null);
const searchText = ref("");
@@ -48,6 +49,40 @@
}
function syncSelection() { selectedName.value = currentCase.value?.run.images[0]?.file ?? ""; }
const delay = (milliseconds: number) => new Promise((resolve) => globalThis.setTimeout(resolve, milliseconds));
async function loadCaseFile(root: string, name: string) {
  const response = await fetch(artifactUrl(`${root}/${name}`), { cache: "no-store" });
  if (!response.ok) throw new Error(`无法读取案例原图 ${name}(HTTP ${response.status})`);
  const blob = await response.blob();
  return new File([blob], name, { type: blob.type || "application/octet-stream" });
}
async function reuseCaseInputs(id: string) {
  const source = store.anomalyCases[id];
  if (!source || reusingCaseId.value) return;
  reusingCaseId.value = id;
  runError.value = null;
  try {
    const [references, inputs] = await Promise.all([
      Promise.all(source.run.reference_images.map((item) => loadCaseFile(source.referenceRoot, item.file))),
      Promise.all(source.run.images.map((item) => loadCaseFile(source.inputRoot, item.file)))
    ]);
    referenceFiles.value = references;
    inputFiles.value = inputs;
    tileSize.value = source.run.parameters.tile_size;
    stride.value = source.run.parameters.stride;
    thresholdQuantile.value = source.run.parameters.threshold_quantile;
    randomState.value = source.run.parameters.random_state;
    caseId.value = id;
    syncSelection();
    showRunForm.value = true;
  } catch (error) {
    showRunForm.value = true;
    runError.value = error instanceof Error ? error.message : "案例输入恢复失败";
  } finally {
    reusingCaseId.value = "";
  }
}
async function submitRun() {
  if (!referenceFiles.value.length || !inputFiles.value.length) { runError.value = "请至少选择一张正常参考影像和一张待检测影像。"; return; }
@@ -113,7 +148,7 @@
  <template v-if="currentCase && selectedImage">
    <a-row :gutter="[18, 18]" class="anomaly-workspace">
      <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col>
      <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><a-button class="reuse-case-button" type="link" size="small" :loading="reusingCaseId === item.value" :disabled="Boolean(reusingCaseId) || running" @click.stop="reuseCaseInputs(item.value)">复用输入</a-button></a-list-item></template></a-list></section></a-col>
      <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="currentCase.run.images.map((item) => ({ value: item.file, label: item.file }))" /><span>{{ selectedImage.width }} × {{ selectedImage.height }} · {{ selectedImage.tile_count }} 窗口</span></div><div class="comparison-grid anomaly-primary"><figure><figcaption>待检测原图</figcaption><a-image :src="artifactUrl(`${currentCase.inputRoot}/${selectedImage.file}`)" /></figure><figure><figcaption>两方法对比结果</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.overlay_file}`)" /></figure></div><div class="anomaly-legend"><span class="rule">仅规则</span><span class="isolation">仅 Isolation Forest</span><span class="agreement">两方法一致</span></div></section></a-col>
    </a-row>
@@ -146,6 +181,9 @@
.anomaly-workspace > :deep(.ant-col), .anomaly-secondary > :deep(.ant-col) { display: flex; }
.anomaly-workspace .surface-section, .anomaly-secondary .surface-section { width: 100%; }
.anomaly-workspace .run-library { align-self: flex-start; max-height: clamp(430px, calc(100vh - 260px), 660px); }
.run-item { display: flex !important; align-items: center; gap: 8px; }
.run-item-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reuse-case-button { flex: 0 0 auto; padding-inline: 4px; }
.anomaly-primary :deep(.ant-image), .anomaly-primary :deep(img) { width: 100%; }
.anomaly-primary :deep(img) { height: 390px; object-fit: contain; background: #18221d; }
.anomaly-secondary :deep(.ant-image), .anomaly-secondary :deep(img) { width: 100%; }