shuishen
11 hours ago 2ae460fc4a4c2419cf44329783d49a739e2a04ea
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, createChangeRunFromUploads, uploadChangeFile, type ChangeCase, type ChangeFeature } from "@/api/artifacts";
import { artifactUrl, createChangeRunFromUploads, createChangeScanFromUploads, getChangeScanJob, loadChangeParameterScans, promoteChangeScan, uploadChangeFile, type ChangeCase, type ChangeFeature, type ChangeParameterScan, type ChangeScanResult } from "@/api/artifacts";
import ArtifactState from "@/components/ArtifactState.vue";
import { useArtifactStore } from "@/stores/artifacts";
@@ -17,11 +17,45 @@
const running = ref(false);
const runError = ref<string | null>(null);
const searchText = ref("");
const parameterScans = ref<ChangeParameterScan[]>([]);
const selectedScanId = ref("");
const selectedScanResultId = ref("");
const scanBeforeFile = ref<File | null>(null);
const scanAfterFile = ref<File | null>(null);
const scanThresholds = ref([0.3, 0.4, 0.5]);
const scanMinimumAreas = ref<number[]>([64, 256, 686]);
const effectiveScanMinimumAreas = computed(() => scanMinimumAreas.value.length ? scanMinimumAreas.value : [256]);
const scanProcessingMode = ref<"auto" | "image" | "geotiff">("auto");
const scanMaxDimension = ref(0);
const scanRunning = ref(false);
const scanJobMessage = ref<string | null>(null);
const scanJobError = ref<string | null>(null);
const promoting = ref(false);
const currentCase = computed<ChangeCase | undefined>(() => store.changeCases[caseId.value] ?? Object.values(store.changeCases)[0]);
const caseOptions = computed(() => Object.values(store.changeCases).map((item) => ({ value: item.id, label: item.label })));
const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
const changePercent = computed(() => ((currentCase.value?.run.changed_pixel_ratio ?? 0) * 100).toFixed(3));
const validPercent = computed(() => ((currentCase.value?.run.valid_pixel_ratio ?? 0) * 100).toFixed(2));
const selectedScan = computed(() => parameterScans.value.find((item) => item.id === selectedScanId.value) ?? parameterScans.value[0]);
const scanResultOptions = computed(() => selectedScan.value?.results.map((item) => ({ value: item.id, label: item.label })) ?? []);
const selectedScanResult = computed<ChangeScanResult | undefined>(() => selectedScan.value?.results.find((item) => item.id === selectedScanResultId.value) ?? selectedScan.value?.results.find((item) => item.threshold === 0.4 && item.minimumAreaPixels === 256) ?? selectedScan.value?.results[0]);
function selectScan(id: string) {
  selectedScanId.value = id;
  const scan = parameterScans.value.find((item) => item.id === id);
  selectedScanResultId.value = scan?.results.find((item) => item.threshold === 0.4 && item.minimumAreaPixels === 256)?.id ?? scan?.results[0]?.id ?? "";
}
function selectScanBefore(file: File) { scanBeforeFile.value = file; return false; }
function selectScanAfter(file: File) { scanAfterFile.value = file; return false; }
function updateScanMode(value: string | null | undefined) {
  if (value !== "auto" && value !== "image" && value !== "geotiff") return;
  scanProcessingMode.value = value;
  if (value === "geotiff") scanMaxDimension.value = 0;
  if (value === "image" && scanMaxDimension.value === 0) scanMaxDimension.value = 1024;
}
function updateScanDimension(value: number | null | undefined) {
  const parsed = Number(value);
  scanMaxDimension.value = [0, 1024, 1536, 2048, 3072].includes(parsed) ? parsed : 0;
}
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;
@@ -62,8 +96,42 @@
  } catch (error) { runError.value = error instanceof Error ? error.message : "变化检测运行失败"; }
  finally { running.value = false; }
}
async function submitParameterScan() {
  if (!scanBeforeFile.value || !scanAfterFile.value) { scanJobError.value = "请分别选择扫描的第一期和第二期影像。"; return; }
  if (!scanThresholds.value.length) { scanJobError.value = "请至少选择一个扫描阈值。"; return; }
  scanRunning.value = true; scanJobError.value = null; scanJobMessage.value = "正在上传影像并排队...";
  try {
    const [before, after] = await Promise.all([uploadChangeFile(scanBeforeFile.value, "before"), uploadChangeFile(scanAfterFile.value, "after")]);
    const { job: created } = await createChangeScanFromUploads({ before, after }, scanThresholds.value, effectiveScanMinimumAreas.value, scanMaxDimension.value, scanProcessingMode.value);
    let job = created;
    while (job.status === "queued" || job.status === "running") {
      scanJobMessage.value = job.phase === "parameter-scan" ? "模型推理完成,正在扫描参数组合..." : job.phase === "vectorization" ? "参数组合完成,正在生成完整 GeoJSON..." : "正在执行 ChangeStar 推理...";
      await new Promise((resolve) => window.setTimeout(resolve, 2000));
      job = await getChangeScanJob(created.id);
    }
    if (job.status === "failed") throw new Error(job.error || "参数扫描失败。");
    parameterScans.value = await loadChangeParameterScans();
    selectScan(job.scanId || created.id);
    scanBeforeFile.value = null; scanAfterFile.value = null; scanJobMessage.value = "参数扫描完成,可在下方切换组合查看结果。";
  } catch (error) { scanJobError.value = error instanceof Error ? error.message : "参数扫描失败。"; scanJobMessage.value = null; }
  finally { scanRunning.value = false; }
}
async function promoteSelectedScan() {
  if (!selectedScan.value || !selectedScanResult.value) return;
  promoting.value = true; scanJobError.value = null;
  try {
    const { run } = await promoteChangeScan(selectedScan.value.id, selectedScanResult.value.id);
    await store.loadChange(true);
    caseId.value = run.id;
    scanJobMessage.value = "已转为正式案例,可在案例库中查看。";
  } catch (error) { scanJobError.value = error instanceof Error ? error.message : "转为正式案例失败。"; }
  finally { promoting.value = false; }
}
onMounted(async () => { await store.loadChange(); caseId.value = Object.keys(store.changeCases)[0] ?? ""; });
onMounted(async () => {
  await Promise.all([store.loadChange(), loadChangeParameterScans().then((items) => { parameterScans.value = items; if (items[0]) selectScan(items[0].id); }).catch(() => { parameterScans.value = []; })]);
  caseId.value = Object.keys(store.changeCases)[0] ?? "";
});
</script>
<template>
@@ -82,6 +150,23 @@
    <a-button type="primary" :loading="running" :disabled="!beforeFile || !afterFile" @click="submitRun"><PlayCircleOutlined />开始检测</a-button>
  </section>
  <section class="surface-section parameter-scan-workspace">
    <div class="section-heading"><div><h2>自己上传并执行参数扫描</h2><p>上传一组两期影像,先执行一次模型推理,再对同一份变化概率结果扫描多个阈值和最小面积。</p></div><a-tag color="blue">新建扫描</a-tag></div>
    <div class="change-upload-grid"><div><label>扫描第一期影像</label><a-upload accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="selectScanBefore"><a-button><UploadOutlined />选择第一期</a-button></a-upload><span>{{ scanBeforeFile?.name || "未选择" }}</span></div><div><label>扫描第二期影像</label><a-upload accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="selectScanAfter"><a-button><UploadOutlined />选择第二期</a-button></a-upload><span>{{ scanAfterFile?.name || "未选择" }}</span></div></div>
    <div class="scan-parameter-grid"><div><label>扫描阈值</label><a-select mode="multiple" :value="scanThresholds" :max-tag-count="3" @update:value="scanThresholds = $event" style="width: 100%"><a-select-option :value="0.3">0.30</a-select-option><a-select-option :value="0.4">0.40</a-select-option><a-select-option :value="0.5">0.50</a-select-option><a-select-option :value="0.6">0.60</a-select-option><a-select-option :value="0.7">0.70</a-select-option></a-select></div><div><label>最小连通区域(可选)</label><a-select mode="multiple" placeholder="留空自动使用 256 px" :value="scanMinimumAreas" :max-tag-count="3" @update:value="scanMinimumAreas = ($event ?? [])" style="width: 100%"><a-select-option :value="16">16 px</a-select-option><a-select-option :value="64">64 px</a-select-option><a-select-option :value="256">256 px</a-select-option><a-select-option :value="686">686 px</a-select-option><a-select-option :value="1024">1024 px</a-select-option></a-select><span class="scan-field-note">普通图片按 px 过滤小噪声;留空时扫描 256 px。</span></div></div>
    <div class="scan-parameter-grid"><div><label>处理模式</label><a-select :value="scanProcessingMode" @update:value="updateScanMode" style="width: 100%"><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></div><div><label>处理分辨率</label><a-select :value="scanMaxDimension" @update:value="updateScanDimension" style="width: 100%"><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></div></div>
    <a-alert v-if="scanThresholds.length * effectiveScanMinimumAreas.length > 24" type="warning" show-icon message="最多选择 24 个参数组合,请减少阈值或最小面积选项。" /><a-alert v-if="scanJobError" type="error" show-icon :message="scanJobError" /><a-alert v-if="scanJobMessage" type="info" show-icon :message="scanJobMessage" /><a-button type="primary" :loading="scanRunning" :disabled="!scanBeforeFile || !scanAfterFile || !scanThresholds.length || scanThresholds.length * effectiveScanMinimumAreas.length > 24" @click="submitParameterScan"><PlayCircleOutlined />开始参数扫描</a-button>
  </section>
  <section v-if="selectedScan && selectedScanResult" class="surface-section parameter-scan-workspace">
    <div class="section-heading"><div><h2>低成本参数扫描</h2><p>复用已有变化概率结果,仅调整阈值和最小连通区域;不会修改原始影像或重新运行模型。</p></div><a-tag color="blue">扫描结果</a-tag></div>
    <div class="scan-controls"><a-select :value="selectedScanId" @update:value="selectScan" style="min-width: 280px"><a-select-option v-for="scan in parameterScans" :key="scan.id" :value="scan.id">{{ scan.label }}</a-select-option></a-select><a-select :value="selectedScanResultId" @update:value="selectedScanResultId = $event" style="min-width: 260px"><a-select-option v-for="item in scanResultOptions" :key="item.value" :value="item.value">{{ item.label }}</a-select-option></a-select></div>
    <div class="scan-preview-grid"><figure v-if="selectedScan.contactSheet"><figcaption>全部组合总览</figcaption><a-image :src="artifactUrl(selectedScan.contactSheet)" /></figure><figure><figcaption>{{ selectedScanResult.label }} · 变化叠加</figcaption><a-image :src="artifactUrl(selectedScanResult.overlay)" /></figure></div>
    <div class="scan-metrics"><a-statistic title="变化像素" :value="selectedScanResult.changedPixels" /><a-statistic title="变化比例" :value="(selectedScanResult.changedPixelRatio * 100).toFixed(3)" suffix="%" /><a-statistic title="清理后区域" :value="selectedScanResult.cleanedComponents" /><a-statistic title="规则四边形" :value="selectedScanResult.rectangleFeatureCount ?? selectedScanResult.fullVectorFeatureCount ?? selectedScanResult.vectorFeatureCount" /></div>
    <a-space wrap><a-button type="primary" :loading="promoting" @click="promoteSelectedScan"><PlayCircleOutlined />将当前组合转为正式案例</a-button><a-button v-if="selectedScanResult.rectangleVector" :href="artifactUrl(selectedScanResult.rectangleVector)" download><DownloadOutlined />下载规则四边形</a-button><a-button v-if="selectedScanResult.rectangleVectorWgs84" :href="artifactUrl(selectedScanResult.rectangleVectorWgs84)" download><DownloadOutlined />下载经纬度 GeoJSON</a-button><a-button v-if="selectedScanResult.vector" :href="artifactUrl(selectedScanResult.vector)" download><FileOutlined />下载原始图斑</a-button><a-button :href="artifactUrl(selectedScanResult.regions)" download><FileOutlined />下载区域统计</a-button></a-space>
    <a-alert type="info" show-icon :message="`当前组合:阈值 ${selectedScanResult.threshold.toFixed(2)},最小面积 ${selectedScanResult.minimumAreaPixels} px。建议先与人工框选区域对照,再决定是否用于正式运行。`" />
  </section>
  <template v-if="currentCase">
    <a-row :gutter="[18, 18]" class="change-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">{{ item.label }}</a-list-item></template></a-list></section></a-col>
@@ -92,13 +177,25 @@
    <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>
    <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 type="primary" v-if="currentCase.run.artifacts.rectangle_vector" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.rectangle_vector}`)" download><DownloadOutlined />规则四边形</a-button><a-button v-if="currentCase.run.artifacts.rectangle_vector_wgs84" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.rectangle_vector_wgs84}`)" download><DownloadOutlined />经纬度 GeoJSON</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 建筑变化权重的验证分布内;结果只能用于工作流与人工复核,不能直接形成工程结论。" />
  </template>
</template>
<style scoped>
.change-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
.parameter-scan-workspace { display: grid; gap: 16px; margin-bottom: 24px; }
.scan-controls { display: flex; flex-wrap: wrap; gap: 12px; }
.scan-parameter-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.scan-parameter-grid > div { display: grid; gap: 8px; }
.scan-parameter-grid label { color: var(--wb-muted); font-size: 12px; font-weight: 700; }
.scan-field-note { color: var(--wb-muted); font-size: 12px; }
.scan-preview-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.scan-preview-grid figure { min-width: 0; margin: 0; }
.scan-preview-grid figcaption { margin-bottom: 7px; color: #425148; font-size: 12px; font-weight: 700; }
.scan-preview-grid :deep(.ant-image), .scan-preview-grid :deep(img) { width: 100%; }
.scan-preview-grid :deep(img) { height: 330px; object-fit: contain; background: #202b25; }
.scan-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; padding: 14px; background: #f7f9f7; border: 1px solid var(--wb-border); }
.change-upload-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.change-upload-grid > div { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 8px 12px; padding: 14px; background: #f7f9f7; border: 1px solid var(--wb-border); }
.change-upload-grid label { grid-column: 1 / -1; color: var(--wb-muted); font-size: 12px; }
@@ -125,6 +222,6 @@
.change-files { margin-bottom: 16px; }
.change-limit { margin-bottom: 24px; }
@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: 760px) { .change-upload-grid, .change-comparison, .scan-preview-grid, .scan-parameter-grid { grid-template-columns: 1fr; } .change-comparison :deep(img), .scan-preview-grid :deep(img) { height: 280px; } .change-vector > img { height: 340px; } .scan-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 480px) { .threshold-inputs { grid-template-columns: 1fr; gap: 4px; } .threshold-inputs :deep(.ant-input-number) { width: 100%; } }
</style>