shuishen
13 hours ago fbb068ec702338d609c1ca6eddbdb9f182d8f211
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
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { DeleteOutlined, DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue";
 
import { artifactUrl, createDensePointCloudClassification, createPhotoReconstructionRun, createPointCloudAnnotation, createPointCloudModelInference, createPointCloudRun, createPointCloudTrainingRun, deletePointCloudAnnotation, loadPhotoReconstructionJob, loadPointCloudAnnotationSources, loadPointCloudAnnotations, loadPointCloudModelInferenceJob, loadPointCloudSemanticModels, loadPointCloudTrainingJob, uploadPhotoReconstructionFile, uploadPointCloudFile, type GeoFeature, type PhotoReconstructionJob, type PointCloudAnnotation, type PointCloudAnnotationSource, type PointCloudCase, type PointCloudInferenceJob, type PointCloudSemanticModel, type PointCloudTrainingJob } from "@/api/artifacts";
import ArtifactState from "@/components/ArtifactState.vue";
import SparsePointCloudViewer from "@/components/SparsePointCloudViewer.vue";
import PointCloudAnnotationViewer from "@/components/PointCloudAnnotationViewer.vue";
import TexturedMeshViewer from "@/components/TexturedMeshViewer.vue";
import { useArtifactStore } from "@/stores/artifacts";
 
const store = useArtifactStore();
const caseId = ref("");
const selectedName = ref("");
const files = ref<File[]>([]);
const photoFiles = ref<File[]>([]);
const showRunForm = ref(false);
const running = ref(false);
const photoSubmitting = ref(false);
const photoJob = ref<PhotoReconstructionJob | null>(null);
const usePhotoPositionPriors = ref(false);
const workflow = ref<"photo" | "geometry" | "semantic" | "model">("photo");
const runError = ref<string | null>(null);
const searchText = ref("");
const vectorFeatures = ref<GeoFeature[]>([]);
const densePreviewMode = ref<"textured" | "geometry" | "point_colors">("textured");
let photoPollTimer: ReturnType<typeof setTimeout> | undefined;
const annotationSources = ref<PointCloudAnnotationSource[]>([]);
const annotations = ref<PointCloudAnnotation[]>([]);
const annotationSourceId = ref("");
const annotationSaving = ref(false);
const annotationNotice = ref<{ type: "info" | "success" | "error"; message: string } | null>(null);
const annotationDeletingId = ref("");
const trainingJob = ref<PointCloudTrainingJob | null>(null);
let trainingPollTimer: ReturnType<typeof setTimeout> | undefined;
const semanticModels = ref<PointCloudSemanticModel[]>([]);
const selectedSemanticModelId = ref("");
const inferenceFile = ref<File | null>(null);
const inferenceSubmitting = ref(false);
const inferenceJob = ref<PointCloudInferenceJob | null>(null);
const inferenceSummary = ref<{ class_counts?: Record<string, number>; input_points?: number; preview_points?: number } | null>(null);
let inferencePollTimer: ReturnType<typeof setTimeout> | undefined;
 
const workflowCases = computed(() => Object.values(store.pointCloudCases).filter((item) => {
  const run = item.run;
  if (workflow.value === "photo") return Boolean(run.photo_reconstruction || run.dense_photo_reconstruction);
  if (workflow.value === "semantic" || workflow.value === "model") return (run.point_clouds ?? []).some((cloud) => Boolean(cloud.semantic_preview_point_cloud));
  return Boolean(run.point_clouds?.length);
}));
const currentCase = computed<PointCloudCase | undefined>(() => workflowCases.value.find((item) => item.id === caseId.value) ?? workflowCases.value[0]);
const selectedCloud = computed(() => (currentCase.value?.run.point_clouds ?? []).find((item) => item.file === selectedName.value) ?? currentCase.value?.run.point_clouds?.[0]);
const photoRun = computed(() => currentCase.value?.run.photo_reconstruction);
const densePhotoRun = computed(() => currentCase.value?.run.dense_photo_reconstruction);
const densePreviewSource = computed(() => {
  const dense = densePhotoRun.value;
  if (!dense) return "";
  if (densePreviewMode.value === "geometry" && dense.geometry_preview_model_file) return dense.geometry_preview_model_file;
  if (densePreviewMode.value === "point_colors" && dense.point_color_preview_model_file) return dense.point_color_preview_model_file;
  return dense.textured_model_file;
});
const caseOptions = computed(() => workflowCases.value.map((item) => ({ value: item.id, label: item.label })));
const filteredCases = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
const photoJobMessage = computed(() => {
  const job = photoJob.value;
  if (!job) return "";
  if (job.status === "queued") return `已排队:${job.inputImages} 张照片等待 CPU 资源。`;
  if (job.stage === "sparse_sfm") return "正在进行 CPU 稀疏相机建模。";
  if (job.stage === "dense_mvs") return "正在进行 CPU 稠密点云、网格与纹理重建,通常需要几十分钟。";
  if (job.status === "complete") return "重建完成,结果已加入案例库。";
  return job.error || "照片重建失败。";
});
 
function rings(feature: GeoFeature): number[][][] {
  const geometry = feature.geometry;
  if (!geometry?.coordinates) return [];
  if (geometry.type === "Polygon") return geometry.coordinates as number[][][];
  if (geometry.type === "MultiPolygon") return (geometry.coordinates as number[][][][]).flat();
  return [];
}
const allRings = computed(() => vectorFeatures.value.flatMap(rings));
const vectorViewBox = computed(() => {
  const points = allRings.value.flat();
  if (!points.length) return "0 0 1 1";
  const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]);
  const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys);
  return `${minX} ${minY} ${Math.max(maxX - minX, 1)} ${Math.max(maxY - minY, 1)}`;
});
const vectorPaths = computed(() => allRings.value.map((ring) => ring.map((point, index) => `${index ? "L" : "M"}${point[0]},${point[1]}`).join(" ") + " Z"));
const annotationSource = computed(() => annotationSources.value.find((item) => item.id === annotationSourceId.value));
const selectedSemanticModel = computed(() => semanticModels.value.find((item) => item.id === selectedSemanticModelId.value));
const semanticModelOptions = computed(() => semanticModels.value.map((model) => ({ value: model.id, label: `${model.label} | ${Object.values(model.classes).map((item) => item.label).join(" / ")}` })));
function annotationSourceLabel(sourceId: string) { return annotationSources.value.find((item) => item.id === sourceId)?.label ?? sourceId; }
 
function syncSelection() { selectedName.value = currentCase.value?.run.point_clouds?.[0]?.file ?? ""; }
function selectWorkflowCase() { caseId.value = workflowCases.value[0]?.id ?? ""; syncSelection(); }
function beforeUpload(file: File) { files.value = [...files.value, file]; return false; }
function removeFile(file: { name: string }) { files.value = files.value.filter((item) => item.name !== file.name); }
function beforePhotoUpload(file: File) { photoFiles.value = [...photoFiles.value, file]; return false; }
function removePhotoFile(file: { name: string }) { photoFiles.value = photoFiles.value.filter((item) => item.name !== file.name); }
function stopPhotoPolling() { if (photoPollTimer) clearTimeout(photoPollTimer); photoPollTimer = undefined; }
async function loadVector() {
  vectorFeatures.value = [];
  if (!currentCase.value || !selectedCloud.value) return;
  const response = await fetch(artifactUrl(`${currentCase.value.artifactRoot}/${selectedCloud.value.vector_file}`), { cache: "no-store" });
  if (response.ok) vectorFeatures.value = ((await response.json()) as { features?: GeoFeature[] }).features ?? [];
}
async function refreshAnnotationData() {
  annotationSources.value = await loadPointCloudAnnotationSources();
  annotations.value = await loadPointCloudAnnotations();
  if (!annotationSourceId.value || !annotationSources.value.some((item) => item.id === annotationSourceId.value)) annotationSourceId.value = annotationSources.value[0]?.id ?? "";
}
async function refreshSemanticModels() {
  semanticModels.value = await loadPointCloudSemanticModels();
  if (!selectedSemanticModelId.value || !semanticModels.value.some((item) => item.id === selectedSemanticModelId.value)) selectedSemanticModelId.value = semanticModels.value[0]?.id ?? "";
}
function stopTrainingPolling() { if (trainingPollTimer) clearTimeout(trainingPollTimer); trainingPollTimer = undefined; }
async function pollTrainingJob() {
  if (!trainingJob.value) return;
  try {
    trainingJob.value = await loadPointCloudTrainingJob(trainingJob.value.id);
    if (!['complete', 'failed'].includes(trainingJob.value.status)) trainingPollTimer = setTimeout(() => { void pollTrainingJob(); }, 2_500);
  } catch (error) { runError.value = error instanceof Error ? error.message : "无法读取训练任务状态。"; }
}
async function saveAnnotation(labels: Array<[number, number]>) {
  if (!annotationSourceId.value) return;
  annotationSaving.value = true; annotationNotice.value = { type: "info", message: `正在保存 ${labels.length.toLocaleString()} 个确认点...` };
  try { const { annotation } = await createPointCloudAnnotation(annotationSourceId.value, labels); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: `已保存 ${annotation.id}:${annotation.labelCount.toLocaleString()} 个确认点。` }; }
  catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "保存标注失败。" }; }
  finally { annotationSaving.value = false; }
}
async function deleteAnnotation(annotationId: string) {
  annotationDeletingId.value = annotationId;
  try { await deletePointCloudAnnotation(annotationId); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: `已删除标注版本 ${annotationId}。` }; }
  catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "删除标注版本失败。" }; }
  finally { annotationDeletingId.value = ""; }
}
async function startTraining(annotationId: string) {
  try { const { job } = await createPointCloudTrainingRun(annotationId, "cpu"); trainingJob.value = job; stopTrainingPolling(); void pollTrainingJob(); }
  catch (error) { runError.value = error instanceof Error ? error.message : "启动训练失败。"; }
}
function beforeInferenceUpload(file: File) { inferenceFile.value = file; return false; }
function removeInferenceUpload() { inferenceFile.value = null; }
function stopInferencePolling() { if (inferencePollTimer) clearTimeout(inferencePollTimer); inferencePollTimer = undefined; }
async function loadInferenceSummary(job: PointCloudInferenceJob) {
  if (!job.summary) return;
  const response = await fetch(artifactUrl(job.summary), { cache: "no-store" });
  if (response.ok) inferenceSummary.value = await response.json() as { class_counts?: Record<string, number>; input_points?: number; preview_points?: number };
}
async function pollInferenceJob() {
  if (!inferenceJob.value) return;
  try {
    inferenceJob.value = await loadPointCloudModelInferenceJob(inferenceJob.value.id);
    if (inferenceJob.value.status === "complete") { await loadInferenceSummary(inferenceJob.value); return; }
    if (inferenceJob.value.status !== "failed") inferencePollTimer = setTimeout(() => { void pollInferenceJob(); }, 2_500);
  } catch (error) { runError.value = error instanceof Error ? error.message : "无法读取模型应用任务状态。"; }
}
async function applySemanticModel() {
  if (!selectedSemanticModelId.value || !inferenceFile.value) return;
  inferenceSubmitting.value = true; runError.value = null; inferenceJob.value = null; inferenceSummary.value = null;
  try {
    const upload = await uploadPointCloudFile(inferenceFile.value);
    const { job } = await createPointCloudModelInference(selectedSemanticModelId.value, upload);
    inferenceFile.value = null; inferenceJob.value = job; stopInferencePolling(); void pollInferenceJob();
  } catch (error) { runError.value = error instanceof Error ? error.message : "应用训练模型失败。"; }
  finally { inferenceSubmitting.value = false; }
}
async function submitRun() {
  if (!files.value.length) { runError.value = "请选择至少一份 PLY、PCD、XYZ、LAS 或 LAZ 点云。"; return; }
  running.value = true; runError.value = null;
  try {
    const pointClouds = [];
    for (const file of files.value) pointClouds.push(await uploadPointCloudFile(file));
    const { run } = await createPointCloudRun(pointClouds);
    await store.loadPointCloud(true); caseId.value = run.id; syncSelection(); files.value = []; showRunForm.value = false;
  } catch (error) { runError.value = error instanceof Error ? error.message : "三维点云运行失败"; }
  finally { running.value = false; }
}
async function classifyDenseResult() {
  if (!currentCase.value) return;
  running.value = true; runError.value = null;
  try {
    const { run } = await createDensePointCloudClassification(currentCase.value.id);
    await store.loadPointCloud(true); workflow.value = "semantic"; caseId.value = run.id; syncSelection();
  } catch (error) { runError.value = error instanceof Error ? error.message : "稠密点云分类失败"; }
  finally { running.value = false; }
}
async function pollPhotoReconstructionJob() {
  const current = photoJob.value;
  if (!current) return;
  try {
    const job = await loadPhotoReconstructionJob(current.id);
    photoJob.value = job;
    if (job.status === "complete") {
      await store.loadPointCloud(true);
      caseId.value = job.run?.id ?? job.runId;
      syncSelection();
      return;
    }
    if (job.status !== "failed") photoPollTimer = setTimeout(() => { void pollPhotoReconstructionJob(); }, 3_000);
  } catch (error) {
    runError.value = error instanceof Error ? error.message : "无法读取照片重建任务状态。";
  }
}
async function submitPhotoReconstruction() {
  if (photoFiles.value.length < 3) { runError.value = "请至少选择 3 张同一架次、同一相机的 JPG/JPEG 照片。"; return; }
  photoSubmitting.value = true;
  runError.value = null;
  photoJob.value = null;
  try {
    const uploads = [];
    for (const file of photoFiles.value) uploads.push(await uploadPhotoReconstructionFile(file));
    const { job } = await createPhotoReconstructionRun(uploads, usePhotoPositionPriors.value);
    photoFiles.value = [];
    photoJob.value = job;
    showRunForm.value = false;
    stopPhotoPolling();
    void pollPhotoReconstructionJob();
  } catch (error) {
    runError.value = error instanceof Error ? error.message : "照片重建任务提交失败。";
  } finally { photoSubmitting.value = false; }
}
 
watch([caseId, selectedName], loadVector);
watch(workflow, () => { searchText.value = ""; selectWorkflowCase(); });
onMounted(async () => { await store.loadPointCloud(); selectWorkflowCase(); await loadVector(); await refreshAnnotationData(); await refreshSemanticModels(); });
onBeforeUnmount(() => { stopPhotoPolling(); stopTrainingPolling(); stopInferencePolling(); });
</script>
 
<template>
  <ArtifactState :loading="store.loading" :error="store.error" />
  <a-tabs v-model:active-key="workflow" class="pointcloud-workflow-tabs">
    <a-tab-pane key="photo" tab="照片三维重建" />
    <a-tab-pane key="geometry" tab="点云几何处理" />
    <a-tab-pane key="semantic" tab="点云语义分类" />
    <a-tab-pane key="model" tab="标注、训练与模型应用" />
  </a-tabs>
  <section class="workspace-command pointcloud-command">
    <div v-if="workflow === 'photo'"><h2>新建照片三维重建</h2><p>上传同一架次、同一相机的 JPG/JPEG 序列,在 CPU 上生成稠密点云、网格与纹理预览。</p></div>
    <div v-else-if="workflow === 'geometry'"><h2>新建点云几何处理</h2><p>上传已有点云或重建导出物,生成 DSM、高出地物足迹、栅格和几何结果。</p></div>
    <div v-else-if="workflow === 'semantic'"><h2>新建点云语义分类</h2><p>上传 LAS/LAZ/PLY/PCD/XYZ,输出地面、植被、构筑物、电线候选、杆塔候选和未知类别;每类均需人工核验。</p></div>
    <div v-else><h2>标注、训练与模型应用</h2><p>从已有 RGB 点云标注真值,训练本机模型,并将训练模型应用到新的带 RGB 点云。</p></div>
    <a-button v-if="workflow !== 'model'" type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button>
  </section>
  <section v-if="showRunForm && workflow !== 'model'" class="surface-section pointcloud-run-form">
    <template v-if="workflow === 'photo'">
      <a-alert type="info" show-icon message="单次选择 3 至 30 张同一架次、同一相机的 JPG/JPEG。原图按字节保存,后台依次执行稀疏 SfM、CPU 稠密点云、网格和纹理;完整任务通常需要几十分钟。有每张照片 GPS/RTK 时再打开下方先验。" />
      <a-upload multiple accept=".jpg,.jpeg" :file-list="photoFiles.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforePhotoUpload" @remove="removePhotoFile"><a-button><UploadOutlined />选择重建照片</a-button></a-upload>
      <a-switch v-model:checked="usePhotoPositionPriors" checked-children="GPS/RTK 先验" un-checked-children="通用配对" />
    </template>
    <template v-else>
      <a-alert type="info" show-icon :message="workflow === 'semantic' ? '单次最多 2 份 PLY/PCD/XYZ/LAS/LAZ。大文件按二进制上传并保留原始字节;语义输出为可解释 CPU 规则候选,不是训练模型结论。' : '单次最多 2 份 PLY/PCD/XYZ/LAS/LAZ。源文件会按字节保存到独立运行目录,并输出 DSM、高出地物栅格和 GeoAI 足迹矢量。'" />
      <a-upload multiple accept=".ply,.pcd,.xyz,.xyzn,.xyzrgb,.las,.laz" :file-list="files.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforeUpload" @remove="removeFile"><a-button><UploadOutlined />选择点云</a-button></a-upload>
    </template>
    <a-alert v-if="runError" type="error" show-icon :message="runError" />
    <a-button v-if="workflow === 'photo'" type="primary" :loading="photoSubmitting" :disabled="photoFiles.length < 3" @click="submitPhotoReconstruction"><PlayCircleOutlined />开始照片重建</a-button>
    <a-button v-else type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />{{ workflow === 'semantic' ? '开始语义分类' : '开始点云几何处理' }}</a-button>
  </section>
  <a-alert v-if="photoJob" class="pointcloud-photo-job" :type="photoJob.status === 'failed' ? 'error' : photoJob.status === 'complete' ? 'success' : 'info'" show-icon :message="photoJobMessage" />
 
  <template v-if="currentCase && selectedCloud">
    <a-row v-if="workflow === 'geometry'" :gutter="[18, 18]" class="pointcloud-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="filteredCases"><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="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="(currentCase.run.point_clouds ?? []).map((item) => ({ value: item.file, label: item.file }))" /><span>{{ selectedCloud.raster_width }} x {{ selectedCloud.raster_height }} 本地坐标栅格</span></div><div class="comparison-grid pointcloud-images"><figure><figcaption>DSM 与高出地物栅格</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.preview_file}`)" /></figure><figure><figcaption>高出地物足迹矢量</figcaption><svg class="pointcloud-vector" :viewBox="vectorViewBox" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg></figure></div></section></a-col>
    </a-row>
    <section v-if="workflow === 'semantic' && selectedCloud.semantic_preview_point_cloud" class="result-band">
      <a-row :gutter="[18, 18]" class="pointcloud-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="filteredCases"><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="19"><section class="surface-section"><div class="section-heading"><div><h2>语义分类结果</h2><p>{{ selectedCloud.semantic_method }}。颜色:棕色地面、绿色植被、橙色构筑物、紫色杆塔候选、黄色电线候选、灰色未知。</p></div><a-tag color="gold">人工核验候选</a-tag></div><SparsePointCloudViewer :source="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_preview_point_cloud}`)" /><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item v-for="(count, key) in selectedCloud.semantic_class_counts" :key="key" :label="String(key)">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions></section></a-col>
      </a-row>
      <section class="surface-section result-band"><div class="section-heading"><div><h2>分类结果与下载</h2><p>规则分类用于人工复核,不是资产台账或巡检结论。</p></div></div><a-space wrap><a-button v-if="selectedCloud.semantic_preview_point_cloud" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_preview_point_cloud}`)" target="_blank"><DownloadOutlined />语义预览 PLY</a-button><a-button v-if="selectedCloud.semantic_classified_las" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_classified_las}`)" target="_blank"><DownloadOutlined />语义分类 LAS</a-button><a-button v-if="selectedCloud.semantic_summary_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_summary_file}`)" target="_blank"><FileOutlined />语义统计 CSV</a-button><a-button v-if="selectedCloud.semantic_vector_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_vector_file}`)" target="_blank"><FileOutlined />语义候选 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
    </section>
    <section v-if="workflow === 'model' && annotationSource" class="surface-section result-band">
      <div class="section-heading"><div><h2>人工标注与监督训练</h2><p>仅保存你刷选确认的真值点;规则候选颜色不会写入训练标签。当前训练完全使用本机 CPU,迁移服务器后可原样改为 CUDA。</p></div><a-tag color="blue">本机 CPU</a-tag></div>
      <a-space wrap class="annotation-source-row"><span>标注源</span><a-select v-model:value="annotationSourceId" :options="annotationSources.map((item) => ({ value: item.id, label: item.label }))" /></a-space>
      <a-alert type="info" show-icon :message="`标注底图:${annotationSource.sourceKind}`" description="为保持浏览器可交互,当前显示的是从原始 LAS 按体素确定性抽取的 40 万个 RGB/XYZ 点,不是语义规则分类颜色,也不是把 1,047 万原始点全部装入浏览器。" />
      <a-alert v-if="annotationNotice" :type="annotationNotice.type" show-icon :message="annotationNotice.message" />
      <PointCloudAnnotationViewer :source="annotationSource.url" :disabled="annotationSaving" @save="saveAnnotation" />
      <a-divider />
      <div class="section-heading"><div><h3>已保存标注版本</h3><p>显示所有标注源的版本,可删除标错版本。至少两个类别、每类 500 个用户确认点后可启动训练;系统按 XY 空间块划分训练、验证和测试,避免相邻线路或塔体点泄漏到测试集。</p></div></div>
      <a-list size="small" :data-source="annotations"><template #renderItem="{ item }"><a-list-item><a-space wrap><span>{{ item.id }}</span><span>{{ annotationSourceLabel(item.sourceId) }}</span><span>{{ item.labelCount.toLocaleString() }} 点</span><a-button size="small" type="primary" @click="startTraining(item.id)">本机 CPU 训练</a-button><a-popconfirm title="删除后不能恢复该标注版本,确认删除?" ok-text="删除" cancel-text="取消" @confirm="deleteAnnotation(item.id)"><a-tooltip title="删除标注版本"><a-button size="small" danger :loading="annotationDeletingId === item.id" aria-label="删除标注版本"><DeleteOutlined /></a-button></a-tooltip></a-popconfirm></a-space></a-list-item></template></a-list>
      <a-alert v-if="trainingJob" :type="trainingJob.status === 'failed' ? 'error' : trainingJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`训练任务:${trainingJob.status}`" :description="trainingJob.error || (trainingJob.preview ? `已生成预测预览:${trainingJob.preview}` : '后台训练中,可继续浏览案例。')" />
      <a-divider />
      <div class="section-heading"><div><h3>应用训练模型</h3><p>选择本机已完成的模型,上传一份新的带 RGB 点云,在本机 CPU 上生成预测候选。XYZ-only 输入会明确拒绝,不会伪造颜色特征。</p></div><a-tag color="blue">本机 CPU</a-tag></div>
      <a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚未发现可用训练模型。先完成并保留一次监督训练。" />
      <a-space v-else wrap class="model-inference-controls">
        <a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" />
        <a-upload accept=".ply,.pcd,.las,.laz" :file-list="inferenceFile ? [{ uid: inferenceFile.name, name: inferenceFile.name, status: 'done' as const }] : []" :before-upload="beforeInferenceUpload" @remove="removeInferenceUpload"><a-button><UploadOutlined />选择待预测点云</a-button></a-upload>
        <a-button type="primary" :loading="inferenceSubmitting" :disabled="!selectedSemanticModelId || !inferenceFile" @click="applySemanticModel"><PlayCircleOutlined />应用模型</a-button>
      </a-space>
      <a-alert v-if="selectedSemanticModel" class="model-inference-model" type="info" show-icon :message="`测试 F1:${Object.entries(selectedSemanticModel.testF1).map(([key, score]) => `${key} ${score.toFixed(3)}`).join(';') || '该模型未记录可用测试指标'}`" description="测试指标仅来自该标注源的空间分块,不能代表新场景准确率;当前杆塔类别仍有较高误报风险,必须人工核验。" />
      <a-alert v-if="inferenceJob" :type="inferenceJob.status === 'failed' ? 'error' : inferenceJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`模型应用任务:${inferenceJob.status}`" :description="inferenceJob.error || (inferenceJob.status === 'complete' ? `已完成 ${inferenceJob.inputName} 的分类候选。` : '后台 CPU 推理中,可继续浏览案例。')" />
      <section v-if="inferenceJob?.status === 'complete' && inferenceJob.preview" class="model-inference-result">
        <SparsePointCloudViewer :source="artifactUrl(inferenceJob.preview)" />
        <a-descriptions v-if="inferenceSummary" size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item label="输入点数">{{ inferenceSummary.input_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="预览点数">{{ inferenceSummary.preview_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in inferenceSummary.class_counts" :key="String(code)" :label="String(code)">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions>
        <a-space wrap><a-button type="link" :href="artifactUrl(inferenceJob.preview)" target="_blank"><DownloadOutlined />预测预览 PLY</a-button><a-button v-if="inferenceJob.classifiedLas" type="link" :href="artifactUrl(inferenceJob.classifiedLas)" target="_blank"><DownloadOutlined />分类 LAS</a-button><a-button v-if="inferenceJob.classCounts" type="link" :href="artifactUrl(inferenceJob.classCounts)" target="_blank"><FileOutlined />类别统计 CSV</a-button><a-button v-if="inferenceJob.summary" type="link" :href="artifactUrl(inferenceJob.summary)" target="_blank"><FileOutlined />预测摘要 JSON</a-button><a-button v-if="inferenceJob.metadata" type="link" :href="artifactUrl(inferenceJob.metadata)" target="_blank"><FileOutlined />运行元数据</a-button><a-button v-if="selectedSemanticModel" type="link" :href="artifactUrl(selectedSemanticModel.model)" target="_blank"><DownloadOutlined />模型权重</a-button></a-space>
      </section>
    </section>
    <a-row v-if="workflow === 'geometry'" :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><div class="section-heading"><div><h2>点云与几何结果</h2><p>坐标为点云本地坐标;不是经纬度或测绘精度结果。</p></div><a-tag color="orange">{{ selectedCloud.elevated_footprint_count }} 个足迹</a-tag></div><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="原始点">{{ selectedCloud.original_points }}</a-descriptions-item><a-descriptions-item label="降采样点">{{ selectedCloud.downsampled_points }}</a-descriptions-item><a-descriptions-item label="地面内点">{{ selectedCloud.ground_inliers }}</a-descriptions-item><a-descriptions-item label="高出点">{{ selectedCloud.elevated_points }} ({{ (selectedCloud.elevated_point_ratio * 100).toFixed(1) }}%)</a-descriptions-item><a-descriptions-item label="网格三角形">{{ selectedCloud.mesh_triangles }}</a-descriptions-item><a-descriptions-item label="矢量化">{{ selectedCloud.vectorizer }}</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>固定处理参数</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="体素尺寸">{{ currentCase.run.thresholds.voxel_size_local_units }}</a-descriptions-item><a-descriptions-item label="地面 RANSAC 距离">{{ currentCase.run.thresholds.ground_plane_distance }}</a-descriptions-item><a-descriptions-item label="高出地物阈值">{{ currentCase.run.thresholds.elevated_height }}</a-descriptions-item><a-descriptions-item label="耗时">{{ selectedCloud.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col></a-row>
    <section v-if="workflow === 'geometry'" class="surface-section result-band"><div class="section-heading"><div><h2>几何处理下载</h2><p>{{ currentCase.note }}</p></div></div><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 5 }"><a-descriptions-item label="能力边界">{{ currentCase.run.classification }}</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="Open3D">{{ currentCase.run.versions.open3d }}</a-descriptions-item><a-descriptions-item label="GeoAI">{{ currentCase.run.versions['geoai-py'] }}</a-descriptions-item><a-descriptions-item label="处理点云">{{ currentCase.run.processed_point_clouds }}</a-descriptions-item></a-descriptions><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.classified_point_cloud}`)" target="_blank"><DownloadOutlined />几何分类点云 PLY</a-button><a-button v-if="selectedCloud.mesh_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.mesh_file}`)" target="_blank"><FileOutlined />近似网格 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.dsm_file}`)" target="_blank"><FileImageOutlined />DSM GeoTIFF</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.vector_file}`)" target="_blank"><FileOutlined />高出地物 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.summary_file}`)" target="_blank"><FileOutlined />汇总 CSV</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
  </template>
  <template v-else-if="currentCase && densePhotoRun">
    <a-row :gutter="[18, 18]" class="pointcloud-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="filteredCases"><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="19"><section class="surface-section"><div class="section-heading"><div><h2>CPU 稠密重建与纹理网格</h2><p>拖动旋转、滚轮缩放。模型为局部 SfM 坐标,未经过控制点测绘配准。</p></div><a-space><a-tag color="green">CPU MVS</a-tag><a-button type="primary" size="small" :loading="running" @click="classifyDenseResult"><PlayCircleOutlined />对此稠密点云分类</a-button></a-space></div><a-segmented v-if="densePhotoRun.geometry_preview_model_file" v-model:value="densePreviewMode" :options="[{ label: '可靠纹理', value: 'textured' }, { label: '点云颜色', value: 'point_colors', disabled: !densePhotoRun.point_color_preview_model_file }, { label: '完整几何', value: 'geometry' }]" /><TexturedMeshViewer :source="artifactUrl(`${currentCase.artifactRoot}/${densePreviewSource}`)" :surface="densePreviewMode" /></section></a-col>
    </a-row>
    <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><h2>重建统计</h2><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="输入照片">{{ densePhotoRun.input_images }}</a-descriptions-item><a-descriptions-item label="注册照片">{{ densePhotoRun.registered_images }}</a-descriptions-item><a-descriptions-item label="稀疏点">{{ densePhotoRun.sparse_points.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="稠密点">{{ densePhotoRun.dense_points.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="原始网格顶点">{{ densePhotoRun.mesh_vertices.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="原始网格面">{{ densePhotoRun.mesh_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.preview_mesh_faces" label="可靠纹理面">{{ densePhotoRun.preview_mesh_faces.toLocaleString() }} ({{ ((densePhotoRun.preview_mesh_face_ratio ?? 0) * 100).toFixed(1) }}%)</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.point_color_preview_faces" label="点云颜色面">{{ densePhotoRun.point_color_preview_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.geometry_preview_faces" label="完整几何面">{{ densePhotoRun.geometry_preview_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.elapsed_seconds !== null" label="耗时">{{ densePhotoRun.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>结果边界</h2><p>可靠纹理只显示有一致照片证据的面,孔洞代表纹理覆盖不足。点云颜色将最近融合点的真实 RGB 投影到完整网格,显示连续表面但不等同逐面照片纹理。完整几何使用中性材质,不补造颜色或纹理;它用于核验几何连续性。</p></section></a-col></a-row>
    <section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.textured_model_file}`)" target="_blank"><DownloadOutlined />可靠纹理 GLB</a-button><a-button v-if="densePhotoRun.point_color_preview_model_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.point_color_preview_model_file}`)" target="_blank"><DownloadOutlined />点云颜色 GLB</a-button><a-button v-if="densePhotoRun.geometry_preview_model_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.geometry_preview_model_file}`)" target="_blank"><DownloadOutlined />完整几何 GLB</a-button><a-button v-if="densePhotoRun.texture_preview_filter_report" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.texture_preview_filter_report}`)" target="_blank"><FileOutlined />纹理过滤报告</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.dense_point_cloud_file}`)" target="_blank"><FileOutlined />稠密点云 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.mesh_file}`)" target="_blank"><FileOutlined />原始网格 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.texture_file}`)" target="_blank"><FileImageOutlined />纹理图</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
  </template>
  <template v-else-if="currentCase && photoRun">
    <a-row :gutter="[18, 18]" class="pointcloud-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="filteredCases"><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="19"><section class="surface-section"><div class="section-heading"><div><h2>影像稀疏重建点云</h2><p>顶点颜色点云。鼠标拖动旋转,滚轮缩放;坐标没有绝对尺度或方向。</p></div><a-tag color="blue">CPU SfM</a-tag></div><SparsePointCloudViewer :source="artifactUrl(`${currentCase.artifactRoot}/${photoRun.point_cloud_file}`)" /></section></a-col>
    </a-row>
    <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><h2>重建统计</h2><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="输入照片">{{ photoRun.input_images }}</a-descriptions-item><a-descriptions-item label="注册照片">{{ photoRun.registered_images }}</a-descriptions-item><a-descriptions-item label="稀疏点">{{ photoRun.sparse_points }}</a-descriptions-item><a-descriptions-item label="平均重投影误差">{{ photoRun.mean_reprojection_error_pixels }} px</a-descriptions-item><a-descriptions-item label="相机组">{{ photoRun.camera_count }}</a-descriptions-item><a-descriptions-item label="耗时">{{ photoRun.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>坐标与限制</h2><p>本次仅完成稀疏 SfM。当前机器无 CUDA,不能运行 pycolmap 的稠密 PatchMatch;GPS 保留在输入清单中,但未用于宣称测绘级地理配准。</p></section></a-col></a-row>
    <section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.point_cloud_file}`)" target="_blank"><DownloadOutlined />稀疏点云 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.camera_pose_file}`)" target="_blank"><FileOutlined />相机位姿 CSV</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.input_manifest_file}`)" target="_blank"><FileOutlined />输入 GPS 清单</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
  </template>
</template>
 
<style scoped>
.pointcloud-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
.pointcloud-workflow-tabs { margin-bottom: 16px; }
.pointcloud-photo-job { margin-bottom: 24px; }
.pointcloud-workspace, .result-band { margin-bottom: 24px; }
.pointcloud-images figure { min-width: 0; }
.pointcloud-images :deep(.ant-image), .pointcloud-images :deep(img) { width: 100%; height: 360px; object-fit: contain; background: #171e1a; }
.pointcloud-vector { width: 100%; height: 360px; border: 1px solid #d9d9d9; background: #f7f8f9; }
.pointcloud-vector path { fill: rgba(230, 90, 45, 0.24); stroke: #bf4c20; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.model-inference-controls { display: flex; margin-bottom: 12px; }
.model-select { min-width: min(100%, 440px); }
.model-inference-model { margin-bottom: 12px; }
.model-inference-result { display: grid; gap: 16px; margin-top: 16px; }
@media (max-width: 1199px) { .pointcloud-images :deep(img), .pointcloud-vector { height: 300px; } }
</style>