shuishen
8 hours ago 385be2eca72eb3833efa4be0a0088b34e764788a
apps/workbench-console/src/components/PointCloudPanel.vue
@@ -1,9 +1,11 @@
<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 { DeleteOutlined, DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, PlusOutlined, 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 { 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";
@@ -26,20 +28,45 @@
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;
@@ -69,9 +96,11 @@
  if (job.status === "complete") return "重建完成,结果已加入案例库。";
  return job.error || "照片重建失败。";
});
function executionLabel(job: { device: string; environment?: string; torchVersion?: string }) {
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";
  return [device, job.environment, job.torchVersion ? `PyTorch ${job.torchVersion}` : ""].filter(Boolean).join(" / ");
  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[][][] {
@@ -91,12 +120,23 @@
});
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; }
@@ -109,8 +149,10 @@
  if (response.ok) vectorFeatures.value = ((await response.json()) as { features?: GeoFeature[] }).features ?? [];
}
async function refreshAnnotationData() {
  annotationSources.value = await loadPointCloudAnnotationSources();
  annotations.value = await loadPointCloudAnnotations();
  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() {
@@ -138,8 +180,90 @@
  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, "auto"); trainingJob.value = job; stopTrainingPolling(); void pollTrainingJob(); }
  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; }
@@ -163,10 +287,70 @@
  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);
    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; }
@@ -225,9 +409,10 @@
}
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(); });
onBeforeUnmount(() => { stopPhotoPolling(); stopTrainingPolling(); stopInferencePolling(); });
onMounted(async () => { await store.loadPointCloud(); selectWorkflowCase(); await loadVector(); await refreshAnnotationData(); await refreshSemanticModels(); await restoreAutoAnnotation(); });
onBeforeUnmount(() => { stopPhotoPolling(); stopAnnotationSourcePolling(); stopTrainingPolling(); stopInferencePolling(); stopAutoAnnotationPolling(); });
</script>
<template>
@@ -238,6 +423,34 @@
    <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>
@@ -247,7 +460,7 @@
  </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-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>
@@ -259,35 +472,63 @@
    <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" />
  <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()">{{ item.label }}</a-list-item></template></a-list></section></a-col>
      <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()">{{ item.label }}</a-list-item></template></a-list></section></a-col>
        <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>仅保存你刷选确认的真值点;规则候选颜色不会写入训练标签。任务会自动探测并优先使用本机可用 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-space>
      <a-alert type="info" show-icon :message="`标注底图:${annotationSource.sourceKind}`" description="为保持浏览器可交互,当前显示的是从原始 LAS 按体素确定性抽取的 40 万个 RGB/XYZ 点,不是语义规则分类颜色,也不是把 1,047 万原始点全部装入浏览器。" />
      <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" />
      <PointCloudAnnotationViewer :source="annotationSource.url" :disabled="annotationSaving" @save="saveAnnotation" />
      <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-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)">自动选择 GPU / 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>
      <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>
@@ -304,7 +545,7 @@
  </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="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>
@@ -329,9 +570,20 @@
.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>