shuishen
10 hours ago 01776511b66bfd87b4f8ef57d3fefc1d99e1e8f6
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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
 
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";
 
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 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;
}
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; }
}
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 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>
  <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>
 
  <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>
      <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 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; }
.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, .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>