shuishen
8 hours ago 385be2eca72eb3833efa4be0a0088b34e764788a
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
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { DeleteOutlined, DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons-vue";
 
import { acceptPointCloudAutoAnnotation, artifactUrl, createDensePointCloudClassification, createPhotoReconstructionRun, createPointCloudAnnotation, createPointCloudAnnotationClass, createPointCloudAnnotationSourceRun, createPointCloudAutoAnnotation, createPointCloudModelInference, createPointCloudRun, createPointCloudTrainingRun, deletePointCloudAnnotation, deletePointCloudAnnotationClass, deletePointCloudAnnotationSource, loadLatestPointCloudAutoAnnotation, loadPhotoReconstructionJob, loadPointCloudAnnotationClasses, loadPointCloudAnnotationSourceDeletionPlan, loadPointCloudAnnotationSourceJob, loadPointCloudAnnotationSources, loadPointCloudAnnotations, loadPointCloudAutoAnnotationReview, loadPointCloudModelInferenceJob, loadPointCloudSemanticModels, loadPointCloudTrainingJob, savePointCloudAutoAnnotationReview, uploadPhotoReconstructionFile, uploadPointCloudFile, type ComputeDevice, type GeoFeature, type PhotoReconstructionJob, type PointCloudAnnotation, type PointCloudAnnotationClass, type PointCloudAnnotationSource, type PointCloudAnnotationSourceDeletionPlan, type PointCloudAnnotationSourceJob, type PointCloudCase, type PointCloudInferenceJob, type PointCloudSemanticModel, type PointCloudTrainingJob } from "@/api/artifacts";
import ArtifactState from "@/components/ArtifactState.vue";
import RunDeletionControl from "@/components/RunDeletionControl.vue";
import SemanticModelDeletionControl from "@/components/SemanticModelDeletionControl.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 annotationClasses = ref<PointCloudAnnotationClass[]>([]);
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 annotationSourceModalOpen = ref(false);
const annotationSourceFile = ref<File | null>(null);
const annotationSourceSubmitting = ref(false);
const annotationSourceJob = ref<PointCloudAnnotationSourceJob | null>(null);
let annotationSourcePollTimer: ReturnType<typeof setTimeout> | undefined;
const annotationSourceRemovalPlan = ref<PointCloudAnnotationSourceDeletionPlan | null>(null);
const annotationSourceRemovalOpen = ref(false);
const annotationSourceRemovalLoading = ref(false);
const annotationSourceRemoving = ref(false);
const annotationClassModalOpen = ref(false);
const annotationClassSubmitting = ref(false);
const annotationClassKey = ref("");
const annotationClassLabel = ref("");
const annotationClassColor = ref("#3289c7");
const annotationClassDeletingCode = ref<number | null>(null);
const trainingJob = ref<PointCloudTrainingJob | null>(null);
let trainingPollTimer: ReturnType<typeof setTimeout> | undefined;
const semanticModels = ref<PointCloudSemanticModel[]>([]);
const selectedSemanticModelId = ref("");
const modelDevice = ref<ComputeDevice>("auto");
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 autoAnnotationThreshold = ref(0.95);
const autoAnnotationSubmitting = ref(false);
const autoAnnotationAccepting = ref(false);
const autoAnnotationJob = ref<PointCloudInferenceJob | null>(null);
const autoAnnotationSummary = ref<{ automatic_annotation?: { candidate_count?: number; candidate_confidence?: number; candidate_class_counts?: Record<string, number> }; class_counts?: Record<string, number>; input_points?: number } | null>(null);
const autoAnnotationReviewChanges = ref<Array<[number, number]>>([]);
const autoAnnotationReviewSaving = ref(false);
let autoAnnotationPollTimer: 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 || "照片重建失败。";
});
const photoJobProgress = computed(() => photoJob.value?.progress ?? { percent: 0, stage: "queued", message: "照片已保存,正在等待 CPU 重建资源。", inputImages: photoJob.value?.inputImages ?? 0, estimate: true });
function executionLabel(job: { device: string; requestedDevice?: ComputeDevice; environment?: string; torchVersion?: string; fallbackUsed?: boolean; fallbackReason?: string | null }) {
  const device = job.device === "cuda" ? "GPU CUDA" : "CPU";
  const selection = job.requestedDevice ? `请求 ${job.requestedDevice} -> ${device}` : device;
  return [selection, job.environment, job.torchVersion ? `PyTorch ${job.torchVersion}` : "", job.fallbackUsed && job.fallbackReason ? `回退:${job.fallbackReason}` : ""].filter(Boolean).join(" / ");
}
 
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 annotationSourceDescription = computed(() => annotationSource.value?.sourceKind.includes("多视角照片特征融合")
  ? `当前显示 ${annotationSource.value.pointCount.toLocaleString()} 个原始 LAS RGB/XYZ 采样点;点序与同目录照片特征 NPZ 严格一致。覆盖率伪彩没有作为标注底图或训练颜色。`
  : annotationSource.value?.sourceHasRgb === false
    ? `当前显示 ${annotationSource.value.pointCount.toLocaleString()} 个 XYZ 点的中性预览。源 PLY 没有可读取的逐点 RGB,因而不能用于当前 RGB 语义模型训练。`
    : `当前直接显示该数据源全部 ${annotationSource.value?.pointCount.toLocaleString() ?? 0} 个 RGB/XYZ 点,不是语义规则分类颜色。大规模点云会相应占用更多浏览器内存与显存。`);
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 annotationClassDisplayLabel(code: string | number) { return annotationClasses.value.find((item) => item.code === Number(code))?.label ?? `类别 ${code}`; }
function annotationTrainerLabel(annotation: PointCloudAnnotation) { return annotationSources.value.find((item) => item.id === annotation.sourceId)?.sourceKind.includes("多视角照片特征融合") ? "多视角特征训练" : "自动选择 GPU / CPU 训练"; }
const selectedSourceAnnotations = computed(() => annotations.value.filter((item) => item.sourceId === annotationSourceId.value));
const latestSourceAnnotation = computed(() => selectedSourceAnnotations.value[0]);
 
function syncSelection() { selectedName.value = currentCase.value?.run.point_clouds?.[0]?.file ?? ""; }
function selectWorkflowCase() { caseId.value = workflowCases.value[0]?.id ?? ""; syncSelection(); }
async function removePointCloudRun() { await store.loadPointCloud(true); selectWorkflowCase(); }
async function removeSemanticModel() { await refreshSemanticModels(); autoAnnotationJob.value = null; autoAnnotationSummary.value = null; inferenceJob.value = null; inferenceSummary.value = null; }
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() {
  const [sources, classes, revisions] = await Promise.all([loadPointCloudAnnotationSources(), loadPointCloudAnnotationClasses(), loadPointCloudAnnotations()]);
  annotationSources.value = sources;
  annotationClasses.value = classes;
  annotations.value = revisions;
  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 = ""; }
}
function beforeAnnotationSourceUpload(file: File) { annotationSourceFile.value = file; return false; }
function removeAnnotationSourceUpload() { annotationSourceFile.value = null; }
function stopAnnotationSourcePolling() { if (annotationSourcePollTimer) clearTimeout(annotationSourcePollTimer); annotationSourcePollTimer = undefined; }
async function pollAnnotationSourceJob() {
  if (!annotationSourceJob.value) return;
  try {
    annotationSourceJob.value = await loadPointCloudAnnotationSourceJob(annotationSourceJob.value.id);
    if (annotationSourceJob.value.status === "complete") {
      await store.loadPointCloud(true);
      await refreshAnnotationData();
      const source = annotationSources.value.find((item) => item.runId === annotationSourceJob.value?.runId);
      if (source) annotationSourceId.value = source.id;
      annotationSourceModalOpen.value = false;
      annotationNotice.value = { type: "success", message: `已生成标注源:${annotationSourceJob.value.inputName}` };
      return;
    }
    if (annotationSourceJob.value.status !== "failed") annotationSourcePollTimer = setTimeout(() => { void pollAnnotationSourceJob(); }, 2_500);
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取标注源任务状态。" }; }
}
async function createAnnotationSource() {
  if (!annotationSourceFile.value) return;
  annotationSourceSubmitting.value = true;
  try {
    const upload = await uploadPointCloudFile(annotationSourceFile.value);
    const { job } = await createPointCloudAnnotationSourceRun(upload);
    annotationSourceFile.value = null;
    annotationSourceJob.value = job;
    annotationNotice.value = { type: "info", message: `正在处理 ${job.inputName},完成后会自动加入标注源。` };
    stopAnnotationSourcePolling();
    void pollAnnotationSourceJob();
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "新增标注源失败。" }; }
  finally { annotationSourceSubmitting.value = false; }
}
async function openAnnotationSourceRemoval() {
  if (!annotationSourceId.value) return;
  annotationSourceRemovalLoading.value = true;
  annotationSourceRemovalPlan.value = null;
  try {
    annotationSourceRemovalPlan.value = await loadPointCloudAnnotationSourceDeletionPlan(annotationSourceId.value);
    annotationSourceRemovalOpen.value = true;
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取数据链路移除范围。" }; }
  finally { annotationSourceRemovalLoading.value = false; }
}
async function removeAnnotationSourceChain() {
  if (!annotationSourceRemovalPlan.value) return;
  annotationSourceRemoving.value = true;
  try {
    const { removed } = await deletePointCloudAnnotationSource(annotationSourceRemovalPlan.value.sourceId);
    annotationSourceRemovalOpen.value = false;
    annotationSourceRemovalPlan.value = null;
    annotationSourceId.value = "";
    await store.loadPointCloud(true);
    await refreshAnnotationData();
    await refreshSemanticModels();
    const detail = removed.removed;
    annotationNotice.value = { type: "success", message: `已移除完整数据链路:${detail.outputDirectories} 个结果目录、${detail.rawDirectories + detail.processedDirectories} 个上传/处理目录、${detail.annotationRevisions} 个标注版本、${detail.trainingRuns} 个训练结果和 ${detail.inferenceRuns} 个推理结果。` };
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "移除数据链路失败。" }; }
  finally { annotationSourceRemoving.value = false; }
}
function annotationColor(rgb: number[]) { return `#${rgb.map((item) => item.toString(16).padStart(2, "0")).join("")}`; }
function hexColorToRgb(value: string) {
  const match = /^#([0-9a-f]{6})$/i.exec(value);
  return match ? [Number.parseInt(match[1].slice(0, 2), 16), Number.parseInt(match[1].slice(2, 4), 16), Number.parseInt(match[1].slice(4, 6), 16)] : null;
}
async function createAnnotationClass() {
  const color = hexColorToRgb(annotationClassColor.value);
  if (!color) { annotationNotice.value = { type: "error", message: "请选择有效的 RGB 颜色。" }; return; }
  annotationClassSubmitting.value = true;
  try {
    await createPointCloudAnnotationClass({ key: annotationClassKey.value.trim(), label: annotationClassLabel.value.trim(), color });
    await refreshAnnotationData();
    annotationClassKey.value = ""; annotationClassLabel.value = ""; annotationClassColor.value = "#3289c7";
    annotationNotice.value = { type: "success", message: "已新增标签分类。" };
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "新增标签分类失败。" }; }
  finally { annotationClassSubmitting.value = false; }
}
async function deleteAnnotationClass(code: number) {
  annotationClassDeletingCode.value = code;
  try { await deletePointCloudAnnotationClass(code); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: "已删除未使用的自定义标签分类。" }; }
  catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "删除标签分类失败。" }; }
  finally { annotationClassDeletingCode.value = null; }
}
async function startTraining(annotationId: string) {
  try { const { job } = await createPointCloudTrainingRun(annotationId, modelDevice.value); 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, modelDevice.value);
    inferenceFile.value = null; inferenceJob.value = job; stopInferencePolling(); void pollInferenceJob();
  } catch (error) { runError.value = error instanceof Error ? error.message : "应用训练模型失败。"; }
  finally { inferenceSubmitting.value = false; }
}
function stopAutoAnnotationPolling() { if (autoAnnotationPollTimer) clearTimeout(autoAnnotationPollTimer); autoAnnotationPollTimer = undefined; }
async function loadAutoAnnotationSummary(job: PointCloudInferenceJob) {
  if (!job.summary) return;
  const response = await fetch(artifactUrl(job.summary), { cache: "no-store" });
  if (response.ok) autoAnnotationSummary.value = await response.json() as typeof autoAnnotationSummary.value;
}
async function loadAutoAnnotationReview(job: PointCloudInferenceJob) {
  const review = await loadPointCloudAutoAnnotationReview(job.runId);
  autoAnnotationReviewChanges.value = review?.corrections ?? [];
}
async function restoreAutoAnnotation() {
  if (!annotationSourceId.value || !selectedSemanticModelId.value) return;
  try {
    const job = await loadLatestPointCloudAutoAnnotation(annotationSourceId.value, selectedSemanticModelId.value);
    if (!job) return;
    autoAnnotationJob.value = job;
    await loadAutoAnnotationSummary(job);
    await loadAutoAnnotationReview(job);
  } catch (error) {
    annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法恢复已完成的自动标注结果。" };
  }
}
async function saveAutoAnnotationReview(changes: Array<[number, number]>) {
  if (!autoAnnotationJob.value || !annotationSourceId.value) return;
  autoAnnotationReviewSaving.value = true;
  try {
    const { review } = await savePointCloudAutoAnnotationReview(autoAnnotationJob.value.runId, annotationSourceId.value, changes);
    autoAnnotationReviewChanges.value = changes;
    annotationNotice.value = { type: "success", message: `已保存 ${review.correctionCount.toLocaleString()} 个候选审阅修正;尚未合并进训练标注。` };
  } catch (error) {
    annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "保存候选审阅修正失败。" };
  } finally { autoAnnotationReviewSaving.value = false; }
}
async function pollAutoAnnotationJob() {
  if (!autoAnnotationJob.value) return;
  try {
    autoAnnotationJob.value = await loadPointCloudModelInferenceJob(autoAnnotationJob.value.id);
    if (autoAnnotationJob.value.status === "complete") { await loadAutoAnnotationSummary(autoAnnotationJob.value); return; }
    if (autoAnnotationJob.value.status !== "failed") autoAnnotationPollTimer = setTimeout(() => { void pollAutoAnnotationJob(); }, 2_500);
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取自动标注任务状态。" }; }
}
async function startAutoAnnotation() {
  if (!annotationSourceId.value || !selectedSemanticModelId.value) return;
  autoAnnotationSubmitting.value = true; autoAnnotationJob.value = null; autoAnnotationSummary.value = null;
  try {
    const { job } = await createPointCloudAutoAnnotation(selectedSemanticModelId.value, annotationSourceId.value, autoAnnotationThreshold.value, modelDevice.value);
    autoAnnotationJob.value = job; annotationNotice.value = { type: "info", message: "自动标注候选正在生成;不会覆盖现有人工标注。" }; stopAutoAnnotationPolling(); void pollAutoAnnotationJob();
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "启动自动标注失败。" }; }
  finally { autoAnnotationSubmitting.value = false; }
}
async function acceptAutoAnnotation() {
  if (!autoAnnotationJob.value || !annotationSourceId.value || autoAnnotationJob.value.status !== "complete") return;
  autoAnnotationAccepting.value = true;
  try {
    const { annotation } = await acceptPointCloudAutoAnnotation(autoAnnotationJob.value.runId, annotationSourceId.value, latestSourceAnnotation.value?.id);
    await refreshAnnotationData();
    annotationNotice.value = { type: "success", message: `已确认合并 ${annotation.labelCount.toLocaleString()} 个候选/人工标签,人工标签优先。可使用新版本重新训练。` };
  } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "确认自动标注候选失败。" }; }
  finally { autoAnnotationAccepting.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([annotationSourceId, selectedSemanticModelId], () => { autoAnnotationJob.value = null; autoAnnotationSummary.value = null; autoAnnotationReviewChanges.value = []; void restoreAutoAnnotation(); });
watch(workflow, () => { searchText.value = ""; selectWorkflowCase(); });
onMounted(async () => { await store.loadPointCloud(); selectWorkflowCase(); await loadVector(); await refreshAnnotationData(); await refreshSemanticModels(); await restoreAutoAnnotation(); });
onBeforeUnmount(() => { stopPhotoPolling(); stopAnnotationSourcePolling(); stopTrainingPolling(); stopInferencePolling(); stopAutoAnnotationPolling(); });
</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>
  <a-modal v-model:open="annotationSourceModalOpen" title="新增标注源" :confirm-loading="annotationSourceSubmitting" :ok-button-props="{ disabled: !annotationSourceFile }" ok-text="上传并生成预览" @ok="createAnnotationSource">
    <p>上传 PLY、PCD、XYZ、LAS 或 LAZ。系统会按原始字节保存文件,在后台生成独立 RGB/XYZ 标注预览,不会改写已有数据源。</p>
    <a-upload accept=".ply,.pcd,.xyz,.xyzn,.xyzrgb,.las,.laz" :file-list="annotationSourceFile ? [{ uid: annotationSourceFile.name, name: annotationSourceFile.name, status: 'done' as const }] : []" :before-upload="beforeAnnotationSourceUpload" @remove="removeAnnotationSourceUpload"><a-button><UploadOutlined />选择点云文件</a-button></a-upload>
  </a-modal>
  <a-modal v-model:open="annotationClassModalOpen" title="管理标签分类" :footer="null" width="680px">
    <p>标签编码自动分配到 LAS 兼容的 1-255 范围。已保存标注使用的自定义类别不能删除,避免影响历史版本和训练。</p>
    <a-space wrap class="annotation-class-create">
      <a-input v-model:value="annotationClassLabel" placeholder="中文名称,例如:变压器" />
      <a-input v-model:value="annotationClassKey" placeholder="英文 key,例如:transformer" />
      <input v-model="annotationClassColor" aria-label="标签颜色" type="color" class="annotation-color-input" />
      <a-button type="primary" :loading="annotationClassSubmitting" :disabled="!annotationClassLabel.trim() || !annotationClassKey.trim()" @click="createAnnotationClass"><PlusOutlined />新增分类</a-button>
    </a-space>
    <a-list size="small" bordered :data-source="annotationClasses" class="annotation-class-list"><template #renderItem="{ item }"><a-list-item><a-space><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" /> <span>{{ item.label }}</span><a-typography-text type="secondary">{{ item.key }} / {{ item.code }}</a-typography-text></a-space><template #actions><a-popconfirm v-if="!item.builtIn" title="删除后无法恢复这个未使用的分类,确认删除?" ok-text="删除" cancel-text="取消" @confirm="deleteAnnotationClass(item.code)"><a-button size="small" danger :loading="annotationClassDeletingCode === item.code">删除</a-button></a-popconfirm><a-tag v-else>内置</a-tag></template></a-list-item></template></a-list>
  </a-modal>
  <a-modal v-model:open="annotationSourceRemovalOpen" title="移除完整数据链路" ok-text="移除全部关联数据" ok-type="danger" cancel-text="取消" :confirm-loading="annotationSourceRemoving" @ok="removeAnnotationSourceChain">
    <a-alert type="error" show-icon message="此操作不可恢复" description="将删除本地工作台中该数据源的全部关联副本、结果和依赖产物;不会删除 baseData 或其他外部输入。" />
    <a-descriptions v-if="annotationSourceRemovalPlan" class="annotation-removal-summary" size="small" :column="1" bordered>
      <a-descriptions-item label="数据源">{{ annotationSourceRemovalPlan.label }}</a-descriptions-item>
      <a-descriptions-item label="生成结果目录">{{ annotationSourceRemovalPlan.outputDirectories }}</a-descriptions-item>
      <a-descriptions-item label="原始上传副本">{{ annotationSourceRemovalPlan.rawDirectories }}</a-descriptions-item>
      <a-descriptions-item label="处理副本">{{ annotationSourceRemovalPlan.processedDirectories }}</a-descriptions-item>
      <a-descriptions-item label="标注版本">{{ annotationSourceRemovalPlan.annotationRevisions }}</a-descriptions-item>
      <a-descriptions-item label="训练结果">{{ annotationSourceRemovalPlan.trainingRuns }}</a-descriptions-item>
      <a-descriptions-item label="模型推理结果">{{ annotationSourceRemovalPlan.inferenceRuns }}</a-descriptions-item>
      <a-descriptions-item v-if="annotationSourceRemovalPlan.siblingSources > 1" label="同一运行的其他标注源">{{ annotationSourceRemovalPlan.siblingSources - 1 }} 个,也会一并移除</a-descriptions-item>
    </a-descriptions>
    <p v-if="annotationSourceRemovalPlan?.preservesExternalInputs" class="annotation-removal-note">该来源没有由控制台保存的原始上传副本;仅移除当前工作台生成的结果和关联产物,外部输入保持不变。</p>
  </a-modal>
  <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 至 1000 张同一架次、同一相机的 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 #description><div class="photo-job-progress"><a-progress :percent="photoJobProgress.percent" :status="photoJob.status === 'failed' ? 'exception' : photoJob.status === 'complete' ? 'success' : 'active'" /><span>{{ photoJobProgress.message }}</span><small>阶段里程碑进度,不代表剩余时间;原生 COLMAP/OpenMVS 在单个计算节点内不提供可靠的细粒度百分比。</small></div></template>
  </a-alert>
 
  <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()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></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()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></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>仅保存你刷选确认的真值点;规则候选颜色不会写入训练标签。融合标注使用原始 RGB/XYZ 与照片特征的局部注意力基线,当前不冒充为官方 Point Transformer V3。任务会自动探测并优先使用本机可用 GPU,未通过检测时回退 CPU。</p></div><a-tag color="blue">自动 GPU / 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-tooltip title="移除选中数据源的完整数据链路"><a-button danger :loading="annotationSourceRemovalLoading" :disabled="!annotationSourceId" aria-label="移除完整数据链路" @click="openAnnotationSourceRemoval"><DeleteOutlined /></a-button></a-tooltip><span>计算设备</span><a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" /></a-space>
      <a-space wrap class="annotation-management-row">
        <a-button @click="annotationSourceModalOpen = true"><PlusOutlined />新增标注源</a-button>
        <a-button @click="annotationClassModalOpen = true">管理标签分类</a-button>
        <a-tag v-for="item in annotationClasses" :key="item.code"><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" />{{ item.label }}</a-tag>
      </a-space>
      <a-alert type="info" show-icon :message="`标注底图:${annotationSource.sourceKind}`" :description="annotationSourceDescription" />
      <a-alert v-if="annotationNotice" :type="annotationNotice.type" show-icon :message="annotationNotice.message" />
      <a-alert v-if="annotationSourceJob" :type="annotationSourceJob.status === 'failed' ? 'error' : annotationSourceJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`标注源处理任务:${annotationSourceJob.status}`" :description="annotationSourceJob.error || `正在处理 ${annotationSourceJob.inputName};原始文件会保留,完成后生成独立可标注预览。`" />
      <PointCloudAnnotationViewer :source="annotationSource.url" :source-id="annotationSource.id" :classes="annotationClasses" :disabled="annotationSaving" @save="saveAnnotation" />
      <section class="model-inference-result auto-annotation-workspace">
        <div class="section-heading"><div><h3>自动标注当前源</h3><p>直接对当前标注源生成模型候选,无需再次上传点云。仅保留达到置信度阈值的预测;生成完成后必须确认合并,人工标签始终优先。</p></div><a-tag color="gold">候选待确认</a-tag></div>
        <a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚无可用于自动标注的已完成模型" description="先从一个人工标注版本启动训练,训练完成后模型会自动出现在此处。" />
        <a-space v-else wrap class="model-inference-controls">
          <a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" />
          <SemanticModelDeletionControl :model-id="selectedSemanticModelId" :label="selectedSemanticModel?.label ?? selectedSemanticModelId" :disabled="!selectedSemanticModelId" @removed="removeSemanticModel" />
          <label>最低置信度 <a-input-number v-model:value="autoAnnotationThreshold" :min="0.5" :max="0.999" :step="0.01" :precision="3" /></label>
          <a-button type="primary" :loading="autoAnnotationSubmitting" :disabled="!selectedSemanticModelId" @click="startAutoAnnotation"><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="指标仅覆盖已有人工标注的空间分块。类别在测试分块中没有样本时,F1 不可用于判断该类别的实际效果。" />
        <a-alert v-if="autoAnnotationJob" :type="autoAnnotationJob.status === 'failed' ? 'error' : autoAnnotationJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`自动标注任务:${autoAnnotationJob.status}(${executionLabel(autoAnnotationJob)})`" :description="autoAnnotationJob.error || (autoAnnotationJob.status === 'complete' ? `已生成高置信度候选,尚未写入人工训练标注。` : `正在对当前 ${annotationSource.pointCount.toLocaleString()} 点源进行后台推理。`)" />
        <template v-if="autoAnnotationJob?.status === 'complete'">
          <a-descriptions v-if="autoAnnotationSummary" size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item label="输入点数">{{ autoAnnotationSummary.input_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="候选阈值">{{ autoAnnotationSummary.automatic_annotation?.candidate_confidence }}</a-descriptions-item><a-descriptions-item label="高置信候选">{{ autoAnnotationSummary.automatic_annotation?.candidate_count?.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in autoAnnotationSummary.class_counts" :key="String(code)" :label="`${annotationClassDisplayLabel(code)}:预测总数`">{{ count.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in autoAnnotationSummary.automatic_annotation?.candidate_class_counts" :key="`candidate-${String(code)}`" :label="`${annotationClassDisplayLabel(code)}:高置信候选`">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions>
          <section v-if="autoAnnotationJob.preview" class="auto-annotation-preview"><div class="section-heading"><div><h3>候选审阅与修正</h3><p>选择“拒绝候选”后用笔刷或框选剔除错分点;选择正确类别可改类。保存的修正独立于人工标注,最终合并时人工标注仍优先。</p></div><a-tag color="gold">未合并</a-tag></div><a-space wrap class="annotation-management-row"><a-tag v-for="item in annotationClasses.filter((item) => autoAnnotationSummary?.class_counts?.[String(item.code)] !== undefined)" :key="`legend-${item.code}`"><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" />{{ item.label }}</a-tag></a-space><PointCloudAnnotationViewer :source="artifactUrl(autoAnnotationJob.preview)" :source-id="annotationSource.id" :classes="annotationClasses" :review-mode="true" :initial-review-changes="autoAnnotationReviewChanges" :disabled="autoAnnotationReviewSaving" @review-save="saveAutoAnnotationReview" /></section>
          <a-space wrap><a-button v-if="autoAnnotationJob.preview" type="link" :href="artifactUrl(autoAnnotationJob.preview)" target="_blank"><DownloadOutlined />预测预览 PLY</a-button><a-button v-if="autoAnnotationJob.candidateFile" type="link" :href="artifactUrl(autoAnnotationJob.candidateFile)" target="_blank"><FileOutlined />候选与置信度 JSON</a-button><a-popconfirm title="确认后会创建新的标注版本,并把高置信候选与当前人工标注合并;人工标签优先。" ok-text="确认合并" cancel-text="取消" @confirm="acceptAutoAnnotation"><a-button type="primary" :loading="autoAnnotationAccepting">确认合并候选</a-button></a-popconfirm></a-space>
        </template>
      </section>
      <a-divider />
      <div class="section-heading"><div><h3>当前标注源的已保存版本</h3><p>切换标注源后只显示对应版本。至少两个类别、每类 500 个用户确认点后可启动训练;融合样本还要求每个类别覆盖训练、验证和测试 XY 区域,避免相邻线路或塔体点泄漏到测试集。</p></div></div>
      <a-alert v-if="!selectedSourceAnnotations.length" type="warning" show-icon message="当前融合样本尚未保存标注版本" description="先选择类别,用笔刷或框选标出确认点,然后点击点云面板右下角的“保存标注版本”。保存成功后,此处会出现“多视角特征训练”按钮。" />
      <a-list v-else size="small" :data-source="selectedSourceAnnotations"><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)">{{ annotationTrainerLabel(item) }}</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}(${executionLabel(trainingJob)})`" :description="trainingJob.error || (trainingJob.preview ? `已生成预测预览:${trainingJob.preview}` : `后台${executionLabel(trainingJob)}训练中,可继续浏览案例。`)" />
      <a-divider />
      <div class="section-heading"><div><h3>应用训练模型</h3><p>选择本机已完成的模型,上传一份新的带 RGB 点云,自动优先使用可用 GPU 生成预测候选。XYZ-only 输入会明确拒绝,不会伪造颜色特征。</p></div><a-tag color="blue">自动 GPU / 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" />
        <SemanticModelDeletionControl :model-id="selectedSemanticModelId" :label="selectedSemanticModel?.label ?? selectedSemanticModelId" :disabled="!selectedSemanticModelId" @removed="removeSemanticModel" />
        <a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" />
        <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}(${executionLabel(inferenceJob)})`" :description="inferenceJob.error || (inferenceJob.status === 'complete' ? `已完成 ${inferenceJob.inputName} 的分类候选。` : `后台${executionLabel(inferenceJob)}推理中,可继续浏览案例。`)" />
      <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()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></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; }
.photo-job-progress { display: grid; gap: 6px; margin-top: 8px; }
.photo-job-progress small { color: #6a7885; }
.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; }
.auto-annotation-preview { display: grid; gap: 12px; }
.annotation-management-row { display: flex; margin: 12px 0; }
.annotation-class-create { display: flex; margin: 16px 0; }
.annotation-class-create :deep(.ant-input) { width: 190px; }
.annotation-class-list { max-height: 340px; overflow: auto; }
.annotation-class-swatch { display: inline-block; width: 14px; height: 14px; border: 1px solid rgba(0, 0, 0, 0.22); vertical-align: -2px; }
.annotation-color-input { width: 34px; height: 32px; padding: 2px; border: 1px solid #d9d9d9; background: #fff; }
.annotation-removal-summary { margin-top: 16px; }
.annotation-removal-note { margin: 12px 0 0; color: #6a7885; }
@media (max-width: 1199px) { .pointcloud-images :deep(img), .pointcloud-vector { height: 300px; } }
</style>