罗广辉
15 hours ago 7cc239cee1a9af4e2e8a0f3d5b7a00a074b17214
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
<script setup lang="ts">
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 ArtifactState from "@/components/ArtifactState.vue";
import { useArtifactStore } from "@/stores/artifacts";
 
const store = useArtifactStore();
const caseId = ref("");
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);
const searchText = ref("");
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));
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") {
  if (period === "before") beforeFile.value = file; else afterFile.value = file;
  return false;
}
const selectBefore = (file: File) => beforeUpload(file, "before");
const selectAfter = (file: File) => beforeUpload(file, "after");
function featureRings(feature: ChangeFeature): number[][][] {
  if (feature.geometry.type === "Polygon") return feature.geometry.coordinates as number[][][];
  if (feature.geometry.type === "MultiPolygon") return (feature.geometry.coordinates as number[][][][]).flat();
  return [];
}
const vectorPaths = computed(() => {
  const height = currentCase.value?.run.input_shape[0] ?? 1;
  return (currentCase.value?.features ?? []).flatMap(featureRings).map((ring) => ring.map((point, index) => `${index ? "L" : "M"}${point[0]},${height - point[1]}`).join(" ") + " Z");
});
async function submitRun() {
  if (!beforeFile.value || !afterFile.value) { runError.value = "请分别选择第一期和第二期影像。"; return; }
  running.value = true; runError.value = null;
  try {
    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; }
}
 
onMounted(async () => { await store.loadChange(); caseId.value = Object.keys(store.changeCases)[0] ?? ""; });
</script>
 
<template>
  <ArtifactState :loading="store.loading" :error="store.error" />
  <section class="workspace-command change-command"><div><h2>新建变化检测运行</h2><p>上传同一场景的两期影像,在本机 CPU 上生成变化栅格和像素坐标图斑。</p></div><a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button></section>
  <section v-if="showRunForm" class="surface-section change-run-form">
    <a-alert type="warning" show-icon message="普通 JPG 会先做特征配准;工程验收应使用同 CRS、同 GSD 的正射 GeoTIFF。" />
    <div class="change-upload-grid">
      <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>
 
  <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>
      <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>双期与栅格结果</h2><p>{{ currentCase.run.input_shape[1] }} x {{ currentCase.run.input_shape[0] }} 像素,处理尺寸 {{ currentCase.run.processed_shape[1] }} x {{ currentCase.run.processed_shape[0] }}</p></div><a-tag color="green">CPU {{ currentCase.run.elapsed_seconds }} 秒</a-tag></div><div class="change-comparison"><figure><figcaption>第一期</figcaption><a-image :src="artifactUrl(currentCase.beforeImage)" /></figure><figure><figcaption>第二期</figcaption><a-image :src="artifactUrl(currentCase.afterImage)" /></figure><figure><figcaption>变化栅格叠加</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.overlay}`)" /></figure></div></section></a-col>
    </a-row>
 
    <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.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 建筑变化权重的验证分布内;结果只能用于工作流与人工复核,不能直接形成工程结论。" />
  </template>
</template>
 
<style scoped>
.change-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
.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; }
.change-upload-grid span { min-width: 0; overflow: hidden; color: var(--wb-text); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.threshold-control { display: grid; gap: 8px; padding: 12px 14px; background: #f7f9f7; border: 1px solid var(--wb-border); }
.threshold-control label { color: var(--wb-muted); font-size: 12px; font-weight: 700; }
.threshold-inputs { display: grid; grid-template-columns: minmax(0, 1fr) 110px; align-items: center; gap: 16px; }
.threshold-control > span { color: var(--wb-muted); font-size: 12px; }
.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; }
.change-comparison :deep(.ant-image), .change-comparison :deep(img) { width: 100%; }
.change-comparison :deep(img) { height: 310px; object-fit: contain; background: #202b25; }
.change-vector { position: relative; min-height: 360px; overflow: hidden; background: #202b25; }
.change-vector > img { display: block; width: 100%; height: 430px; object-fit: contain; }
.change-vector > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.change-vector path { fill: rgba(232, 61, 49, 0.23); stroke: #e83d31; stroke-width: 7; vector-effect: non-scaling-stroke; }
.change-vector :deep(.ant-empty) { position: absolute; inset: 0; display: grid; align-content: center; margin: 0; background: rgba(32, 43, 37, 0.72); }
.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; } .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>