From d857b98578eb377da7cc36f653455fc5f916a9cd Mon Sep 17 00:00:00 2001
From: shuishen <1109946754@qq.com>
Date: Tue, 18 Aug 2026 17:25:06 +0800
Subject: [PATCH] feat:变化检测优化

---
 apps/workbench-console/src/components/ChangeDetectionPanel.vue |   25 ++++++++++++++++++++-----
 1 files changed, 20 insertions(+), 5 deletions(-)

diff --git a/apps/workbench-console/src/components/ChangeDetectionPanel.vue b/apps/workbench-console/src/components/ChangeDetectionPanel.vue
index 03b5c9d..d2d1a95 100644
--- a/apps/workbench-console/src/components/ChangeDetectionPanel.vue
+++ b/apps/workbench-console/src/components/ChangeDetectionPanel.vue
@@ -2,7 +2,7 @@
 import { computed, onMounted, ref } from "vue";
 import { DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
 
-import { artifactUrl, createChangeRun, readFileAsPayload, type ChangeCase, type ChangeFeature } from "@/api/artifacts";
+import { artifactUrl, createChangeRunFromUploads, uploadChangeFile, type ChangeCase, type ChangeFeature } from "@/api/artifacts";
 import ArtifactState from "@/components/ArtifactState.vue";
 import { useArtifactStore } from "@/stores/artifacts";
 
@@ -11,6 +11,8 @@
 const beforeFile = ref<File | null>(null);
 const afterFile = ref<File | null>(null);
 const threshold = ref(0.5);
+const processingMode = ref<"auto" | "image" | "geotiff">("auto");
+const maxDimension = ref(0);
 const showRunForm = ref(false);
 const running = ref(false);
 const runError = ref<string | null>(null);
@@ -23,6 +25,16 @@
 function updateThreshold(value: number | null | undefined) {
   const parsed = Number(value);
   threshold.value = Number.isFinite(parsed) ? Math.min(0.99, Math.max(0.01, parsed)) : 0.5;
+}
+function updateMaxDimension(value: number | null | undefined) {
+  const parsed = Number(value);
+  maxDimension.value = [0, 1024, 1536, 2048, 3072].includes(parsed) ? parsed : 0;
+}
+function updateProcessingMode(value: string | null | undefined) {
+  if (value !== "auto" && value !== "image" && value !== "geotiff") return;
+  processingMode.value = value;
+  if (value === "geotiff") maxDimension.value = 0;
+  if (value === "image" && maxDimension.value === 0) maxDimension.value = 1024;
 }
 
 function beforeUpload(file: File, period: "before" | "after") {
@@ -44,8 +56,8 @@
   if (!beforeFile.value || !afterFile.value) { runError.value = "请分别选择第一期和第二期影像。"; return; }
   running.value = true; runError.value = null;
   try {
-    const [before, after] = await Promise.all([readFileAsPayload(beforeFile.value), readFileAsPayload(afterFile.value)]);
-    const { run } = await createChangeRun({ before, after }, threshold.value);
+    const [before, after] = await Promise.all([uploadChangeFile(beforeFile.value, "before"), uploadChangeFile(afterFile.value, "after")]);
+    const { run } = await createChangeRunFromUploads({ before, after }, threshold.value, maxDimension.value, processingMode.value);
     await store.loadChange(true); caseId.value = run.id; beforeFile.value = null; afterFile.value = null; showRunForm.value = false;
   } catch (error) { runError.value = error instanceof Error ? error.message : "变化检测运行失败"; }
   finally { running.value = false; }
@@ -63,7 +75,9 @@
       <div><label>第一期影像</label><a-upload accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="selectBefore"><a-button><UploadOutlined />选择第一期</a-button></a-upload><span>{{ beforeFile?.name || "未选择" }}</span></div>
       <div><label>第二期影像</label><a-upload accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="selectAfter"><a-button><UploadOutlined />选择第二期</a-button></a-upload><span>{{ afterFile?.name || "未选择" }}</span></div>
     </div>
+    <div class="resolution-control"><label for="change-mode">处理模式</label><a-select id="change-mode" :value="processingMode" @update:value="updateProcessingMode"><a-select-option value="auto">自动识别</a-select-option><a-select-option value="image">普通图片</a-select-option><a-select-option value="geotiff">GeoTIFF 地理参考</a-select-option></a-select><span>自动模式会识别带 CRS 的 GeoTIFF;普通图片输出像素坐标,GeoTIFF 模式保留空间参考。</span></div>
     <div class="threshold-control"><label for="change-threshold">变化阈值</label><div class="threshold-inputs"><a-slider id="change-threshold" :value="threshold" @update:value="updateThreshold" :min="0.01" :max="0.99" :step="0.01" /><a-input-number :value="threshold" @update:value="updateThreshold" :min="0.01" :max="0.99" :step="0.01" :precision="2" /></div><span>本次运行使用 {{ threshold.toFixed(2) }};默认值为 0.50</span></div>
+    <div class="resolution-control"><label for="change-resolution">处理分辨率</label><a-select id="change-resolution" :value="maxDimension" @update:value="updateMaxDimension"><a-select-option :value="0">GeoTIFF 原始分辨率 / 自动</a-select-option><a-select-option :value="1024">快速预览 · 1024 px</a-select-option><a-select-option :value="1536">标准 · 1536 px</a-select-option><a-select-option :value="2048">小目标优先 · 2048 px</a-select-option><a-select-option :value="3072">高细节 · 3072 px</a-select-option></a-select><span v-if="maxDimension === 0">GeoTIFF 保持原始像素尺寸;普通图片自动使用 1024 px。</span><span v-else>本次运行使用 {{ maxDimension }} px 长边上限;分辨率越高,CPU 耗时和内存占用越大。</span></div>
     <a-alert v-if="runError" type="error" show-icon :message="runError" />
     <a-button type="primary" :loading="running" :disabled="!beforeFile || !afterFile" @click="submitRun"><PlayCircleOutlined />开始检测</a-button>
   </section>
@@ -76,7 +90,7 @@
 
     <a-row :gutter="[18, 18]" class="metric-row change-metrics"><a-col :xs="12" :lg="6"><a-statistic title="变化像素" :value="currentCase.run.changed_pixels" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="变化比例" :value="changePercent" suffix="%" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="变化图斑" :value="currentCase.run.vector_feature_count" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="配准有效区" :value="validPercent" suffix="%" /></a-col></a-row>
 
-    <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="14"><section class="surface-section"><div class="section-heading"><div><h2>矢量图斑</h2><p>像素坐标结果;红线叠加已换算为图像左上角显示坐标。</p></div><a-tag>{{ currentCase.run.vector_feature_count }} 个</a-tag></div><div class="change-vector"><img :src="artifactUrl(currentCase.afterImage)" alt="第二期影像" /><svg v-if="vectorPaths.length" :viewBox="`0 0 ${currentCase.run.input_shape[1]} ${currentCase.run.input_shape[0]}`" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg><a-empty v-else description="当前阈值下没有变化图斑" /></div></section></a-col><a-col :xs="24" :xl="10"><section class="surface-section"><h2>图斑明细</h2><a-table :data-source="currentCase.features.map((item) => item.properties)" :pagination="false" row-key="feature_id" size="small" :scroll="{ x: 480 }"><a-table-column title="编号" data-index="feature_id" key="feature_id" /><a-table-column title="面积 (px)" data-index="area_pixels" key="area_pixels" align="right" /><a-table-column title="平均概率" data-index="mean_probability" key="mean_probability" align="right" /><a-table-column title="最大概率" data-index="max_probability" key="max_probability" align="right" /></a-table><a-divider /><a-descriptions size="small" :column="1"><a-descriptions-item label="模型">{{ currentCase.run.model }}</a-descriptions-item><a-descriptions-item label="阈值">{{ currentCase.run.thresholds.change_probability }}</a-descriptions-item><a-descriptions-item label="配准">{{ currentCase.run.registration.method }} / {{ currentCase.run.registration.inliers }} 内点</a-descriptions-item><a-descriptions-item label="坐标">无 CRS,像素坐标</a-descriptions-item></a-descriptions></section></a-col></a-row>
+    <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="14"><section class="surface-section"><div class="section-heading"><div><h2>矢量图斑</h2><p>像素坐标结果;红线叠加已换算为图像左上角显示坐标。</p></div><a-tag>{{ currentCase.run.vector_feature_count }} 个</a-tag></div><div class="change-vector"><img :src="artifactUrl(currentCase.afterImage)" alt="第二期影像" /><svg v-if="vectorPaths.length" :viewBox="`0 0 ${currentCase.run.input_shape[1]} ${currentCase.run.input_shape[0]}`" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg><a-empty v-else description="当前阈值下没有变化图斑" /></div></section></a-col><a-col :xs="24" :xl="10"><section class="surface-section"><h2>图斑明细</h2><a-table :data-source="currentCase.features.map((item) => item.properties)" :pagination="false" row-key="feature_id" size="small" :scroll="{ x: 480 }"><a-table-column title="编号" data-index="feature_id" key="feature_id" /><a-table-column title="面积 (px)" data-index="area_pixels" key="area_pixels" align="right" /><a-table-column title="平均概率" data-index="mean_probability" key="mean_probability" align="right" /><a-table-column title="最大概率" data-index="max_probability" key="max_probability" align="right" /></a-table><a-divider /><a-descriptions size="small" :column="1"><a-descriptions-item label="模型">{{ currentCase.run.model }}</a-descriptions-item><a-descriptions-item label="阈值">{{ currentCase.run.thresholds.change_probability }}</a-descriptions-item><a-descriptions-item label="模式">{{ currentCase.run.processing_mode === "geotiff" ? "GeoTIFF 地理参考" : "普通图片像素坐标" }}</a-descriptions-item><a-descriptions-item label="配准">{{ currentCase.run.registration.method }} / {{ currentCase.run.registration.inliers }} 内点</a-descriptions-item><a-descriptions-item label="坐标">{{ currentCase.run.georeferenced ? `${currentCase.run.crs} 地图坐标` : "无 CRS,像素坐标" }}</a-descriptions-item></a-descriptions></section></a-col></a-row>
 
     <section class="surface-section result-files change-files"><a-space wrap><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.probability_raster}`)" download><DownloadOutlined />概率 GeoTIFF</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.mask_raster}`)" download><FileImageOutlined />变化栅格</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.vector}`)" download><FileOutlined />变化 GeoJSON</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" download><FileOutlined />运行元数据</a-button></a-space></section>
     <a-alert class="change-limit" type="warning" show-icon message="当前近景边坡样本不在 ChangeStar 建筑变化权重的验证分布内;结果只能用于工作流与人工复核,不能直接形成工程结论。" />
@@ -96,6 +110,7 @@
 .change-workspace { align-items: stretch; margin-bottom: 24px; }
 .change-workspace > :deep(.ant-col) { display: flex; }
 .change-workspace .surface-section { width: 100%; }
+.change-workspace .run-library { align-self: flex-start; max-height: clamp(420px, calc(100vh - 260px), 620px); }
 .change-comparison { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
 .change-comparison figure { min-width: 0; margin: 0; }
 .change-comparison figcaption { margin-bottom: 7px; color: #425148; font-size: 12px; font-weight: 700; }
@@ -109,7 +124,7 @@
 .change-vector :deep(.ant-empty-description) { color: #f7faf8; }
 .change-files { margin-bottom: 16px; }
 .change-limit { margin-bottom: 24px; }
-@media (max-width: 1199px) { .change-workspace > :deep(.ant-col) { display: block; } }
+@media (max-width: 1199px) { .change-workspace > :deep(.ant-col) { display: block; } .change-workspace .run-library { max-height: none; } }
 @media (max-width: 760px) { .change-upload-grid, .change-comparison { grid-template-columns: 1fr; } .change-comparison :deep(img) { height: 280px; } .change-vector > img { height: 340px; } }
 @media (max-width: 480px) { .threshold-inputs { grid-template-columns: 1fr; gap: 4px; } .threshold-inputs :deep(.ant-input-number) { width: 100%; } }
 </style>

--
Gitblit v1.9.3