<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, createTexturedMeshAnnotationSourceRun, createTexturedMeshModelInference, deletePointCloudAnnotation, deletePointCloudAnnotationClass, deletePointCloudAnnotationSource, loadLatestPointCloudAutoAnnotation, loadPhotoReconstructionJob, loadPointCloudAnnotationClasses, loadPointCloudAnnotationSourceDeletionPlan, loadPointCloudAnnotationSourceJob, loadPointCloudAnnotationSources, loadPointCloudAnnotations, loadPointCloudArtifactHealth, loadPointCloudAutoAnnotationMergePreview, loadPointCloudAutoAnnotationReview, loadPointCloudModelInferenceJob, loadPointCloudSemanticModels, loadPointCloudTrainingJob, savePointCloudAutoAnnotationReview, updatePointCloudAnnotationClassColor, uploadPhotoReconstructionFile, uploadPointCloudFile, uploadTexturedMeshTextureFile, type ComputeDevice, type GeoFeature, type PhotoReconstructionJob, type PointCloudAnnotation, type PointCloudAnnotationClass, type PointCloudAnnotationSource, type PointCloudAnnotationSourceDeletionPlan, type PointCloudAnnotationSourceJob, type PointCloudArtifactHealth, type PointCloudAutoAnnotationMergePreview, 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 PointCloudModelCard from "@/components/PointCloudModelCard.vue";
|
import PointCloudSemanticNavigation, { type PointCloudSemanticSection } from "@/components/PointCloudSemanticNavigation.vue";
|
import PointCloudTaskList from "@/components/PointCloudTaskList.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">("photo");
|
const semanticSection = ref<PointCloudSemanticSection>("classification");
|
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 annotationSourceMode = ref<"pointcloud" | "texturedMesh">("pointcloud");
|
const annotationTextureFiles = ref<File[]>([]);
|
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 annotationClassColorDrafts = ref<Record<number, string>>({});
|
const annotationClassColorSavingCode = ref<number | null>(null);
|
const trainingJob = ref<PointCloudTrainingJob | null>(null);
|
let trainingPollTimer: ReturnType<typeof setTimeout> | undefined;
|
const concentratedAnnotationIds = ref<string[]>([]);
|
const semanticModels = ref<PointCloudSemanticModel[]>([]);
|
const artifactHealth = ref<PointCloudArtifactHealth | null>(null);
|
const selectedSemanticModelId = ref("");
|
const modelDevice = ref<ComputeDevice>("auto");
|
const semanticClassificationRoute = ref<"rule" | "model">("rule");
|
const inferenceFile = ref<File | null>(null);
|
const inferenceInputModalOpen = ref(false);
|
const inferenceInputMode = ref<"pointcloud" | "texturedMesh">("pointcloud");
|
const inferenceTextureFiles = ref<File[]>([]);
|
const inferenceLaunchContext = ref<"semantic">("semantic");
|
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 autoAnnotationReviewSchema = ref<PointCloudAnnotationClass[] | null>(null);
|
const autoAnnotationMergePreview = ref<PointCloudAutoAnnotationMergePreview | null>(null);
|
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") 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 isMultiviewAnnotationSource(sourceId: string) {
|
const artifactRoot = annotationSources.value.find((item) => item.id === sourceId)?.artifactRoot ?? "";
|
const segments = artifactRoot.split("/");
|
return (segments[segments.length - 1] ?? "").startsWith("multiview-feature-");
|
}
|
const concentratedTrainingOptions = computed(() => {
|
const selectedSources = new Map(annotations.value.filter((item) => concentratedAnnotationIds.value.includes(item.id)).map((item) => [item.sourceId, item.id]));
|
return annotations.value
|
.filter((item) => annotationSources.value.some((source) => source.id === item.sourceId && source.sourceHasRgb !== false) && !isMultiviewAnnotationSource(item.sourceId))
|
.map((item) => ({
|
value: item.id,
|
label: `${annotationSourceLabel(item.sourceId)} | ${item.labelCount.toLocaleString()} 点`,
|
disabled: Boolean(selectedSources.get(item.sourceId) && selectedSources.get(item.sourceId) !== item.id),
|
}));
|
});
|
const concentratedTrainingReady = computed(() => {
|
const selected = annotations.value.filter((item) => concentratedAnnotationIds.value.includes(item.id));
|
return selected.length >= 2 && new Set(selected.map((item) => item.sourceId)).size === selected.length;
|
});
|
const activeSemanticTaskCount = computed(() => [annotationSourceJob.value, trainingJob.value, inferenceJob.value, autoAnnotationJob.value].filter((job) => job && ["queued", "running"].includes(job.status)).length);
|
|
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 startSemanticCaseReview() {
|
if (!currentCase.value) return;
|
if (!selectedCloud.value?.semantic_annotation_source_point_cloud) {
|
runError.value = "\u8be5\u5386\u53f2\u6a21\u578b\u5206\u7c7b\u6848\u4f8b\u5c1a\u672a\u751f\u6210\u4eba\u5de5\u590d\u6838\u7528 RGB/XYZ PLY\u3002\u8bf7\u5148\u5237\u65b0\u6848\u4f8b\uff1b\u82e5\u4ecd\u4e0d\u53ef\u7528\uff0c\u8bf7\u8054\u7cfb\u672c\u5730\u7ba1\u7406\u5458\u8865\u751f\u6210\u8be5\u590d\u6838\u6e90\u3002";
|
return;
|
}
|
const sourceId = `${currentCase.value.id}:${selectedCloud.value.semantic_annotation_source_point_cloud}`;
|
try {
|
await refreshAnnotationData();
|
if (!annotationSources.value.some((item) => item.id === sourceId)) throw new Error("该模型分类结果未提供可复核的 RGB LAS 标注源。");
|
annotationSourceId.value = sourceId;
|
workflow.value = "semantic";
|
semanticSection.value = "annotation";
|
} catch (error) { runError.value = error instanceof Error ? error.message : "无法将模型分类结果送入人工复核。"; }
|
}
|
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 vectorFile = selectedCloud.value.semantic_vector_file || selectedCloud.value.vector_file;
|
if (!vectorFile || !vectorFile.toLowerCase().endsWith(".geojson")) return;
|
const response = await fetch(artifactUrl(`${currentCase.value.artifactRoot}/${vectorFile}`), { cache: "no-store" });
|
if (!response.ok) throw new Error(`\u65e0\u6cd5\u8bfb\u53d6\u70b9\u4e91\u77e2\u91cf\u7ed3\u679c (${response.status})`);
|
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() {
|
const [models, health] = await Promise.all([loadPointCloudSemanticModels(), loadPointCloudArtifactHealth()]);
|
semanticModels.value = models;
|
artifactHealth.value = health;
|
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 (trainingJob.value.status === "complete") {
|
await refreshSemanticModels();
|
return;
|
}
|
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 beforeAnnotationTextureUpload(file: File) { annotationTextureFiles.value = [...annotationTextureFiles.value, file]; return false; }
|
function removeAnnotationTextureUpload(file: { name: string }) { const index = annotationTextureFiles.value.findIndex((item) => item.name === file.name); if (index >= 0) annotationTextureFiles.value.splice(index, 1); }
|
const annotationSourceReady = computed(() => Boolean(annotationSourceFile.value) && (annotationSourceMode.value === "pointcloud" || annotationTextureFiles.value.length > 0));
|
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();
|
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 } = annotationSourceMode.value === "texturedMesh"
|
? await createTexturedMeshAnnotationSourceRun(upload, await Promise.all(annotationTextureFiles.value.map(uploadTexturedMeshTextureFile)))
|
: await createPointCloudAnnotationSourceRun(upload);
|
annotationSourceFile.value = null;
|
annotationTextureFiles.value = [];
|
annotationSourceJob.value = job;
|
annotationNotice.value = { type: "info", message: job.textureCount ? `正在烘焙 ${job.inputName} 的 ${job.textureCount} 张纹理图,完成后会自动加入标注源。` : `正在处理 ${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;
|
}
|
function openAnnotationClassModal() {
|
annotationClassColorDrafts.value = Object.fromEntries(annotationClasses.value.map((item) => [item.code, annotationColor(item.color)]));
|
annotationClassModalOpen.value = true;
|
}
|
async function updateAnnotationClassColor(code: number) {
|
const color = hexColorToRgb(annotationClassColorDrafts.value[code] ?? "");
|
if (!color) { annotationNotice.value = { type: "error", message: "请选择有效的 RGB 颜色。" }; return; }
|
annotationClassColorSavingCode.value = code;
|
try {
|
await updatePointCloudAnnotationClassColor(code, color);
|
await refreshAnnotationData();
|
annotationClassColorDrafts.value = Object.fromEntries(annotationClasses.value.map((item) => [item.code, annotationColor(item.color)]));
|
annotationNotice.value = { type: "success", message: "已更新标签显示颜色;类别编码、历史标注和模型不受影响。" };
|
} catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "更新标签颜色失败。" }; }
|
finally { annotationClassColorSavingCode.value = 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();
|
annotationClassColorDrafts.value = Object.fromEntries(annotationClasses.value.map((item) => [item.code, annotationColor(item.color)]));
|
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(annotationIds: string[]) {
|
try { const { job } = await createPointCloudTrainingRun(annotationIds, modelDevice.value); trainingJob.value = job; stopTrainingPolling(); void pollTrainingJob(); }
|
catch (error) { runError.value = error instanceof Error ? error.message : "启动训练失败。"; }
|
}
|
async function startConcentratedTraining() {
|
if (!concentratedTrainingReady.value) return;
|
await startTraining([...concentratedAnnotationIds.value]);
|
}
|
function beforeInferenceUpload(file: File) { inferenceFile.value = file; return false; }
|
function removeInferenceUpload() { inferenceFile.value = null; }
|
function beforeInferenceTextureUpload(file: File) { inferenceTextureFiles.value = [...inferenceTextureFiles.value, file]; return false; }
|
function removeInferenceTextureUpload(file: { name: string }) { const index = inferenceTextureFiles.value.findIndex((item) => item.name === file.name); if (index >= 0) inferenceTextureFiles.value.splice(index, 1); }
|
const inferenceInputReady = computed(() => Boolean(inferenceFile.value) && (inferenceInputMode.value === "pointcloud" || inferenceTextureFiles.value.length > 0));
|
function openModelInference() { inferenceLaunchContext.value = "semantic"; inferenceInputModalOpen.value = true; }
|
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);
|
await store.loadPointCloud(true);
|
if (inferenceLaunchContext.value === "semantic") { workflow.value = "semantic"; caseId.value = inferenceJob.value.runId; syncSelection(); showRunForm.value = false; }
|
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 || !inferenceInputReady.value) return;
|
inferenceSubmitting.value = true; runError.value = null; inferenceJob.value = null; inferenceSummary.value = null;
|
try {
|
const upload = await uploadPointCloudFile(inferenceFile.value);
|
const { job } = inferenceInputMode.value === "texturedMesh"
|
? await createTexturedMeshModelInference(selectedSemanticModelId.value, upload, await Promise.all(inferenceTextureFiles.value.map(uploadTexturedMeshTextureFile)), modelDevice.value)
|
: await createPointCloudModelInference(selectedSemanticModelId.value, upload, modelDevice.value);
|
inferenceFile.value = null; inferenceTextureFiles.value = []; inferenceInputModalOpen.value = false; 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 loadAutoAnnotationReviewSchema(job: PointCloudInferenceJob) {
|
if (!job.metadata) { autoAnnotationReviewSchema.value = null; return; }
|
const response = await fetch(artifactUrl(job.metadata), { cache: "no-store" });
|
if (!response.ok) { autoAnnotationReviewSchema.value = null; return; }
|
const metadata = await response.json() as { classes?: Record<string, { code?: number; key?: string; label?: string; color?: number[] }> };
|
const values = Object.entries(metadata.classes ?? {}).flatMap(([code, item]) => {
|
const numericCode = item.code ?? Number(code);
|
return Number.isInteger(numericCode) && typeof item.key === "string" && typeof item.label === "string" && Array.isArray(item.color) && item.color.length === 3
|
? [{ code: numericCode, key: item.key, label: item.label, color: item.color, builtIn: annotationClasses.value.some((current) => current.code === numericCode && current.builtIn) }]
|
: [];
|
});
|
autoAnnotationReviewSchema.value = values.length ? values : null;
|
}
|
async function loadAutoAnnotationReview(job: PointCloudInferenceJob) {
|
const review = await loadPointCloudAutoAnnotationReview(job.runId);
|
autoAnnotationReviewChanges.value = review?.corrections ?? [];
|
await loadAutoAnnotationReviewSchema(job);
|
await loadAutoAnnotationMergePreview(job);
|
}
|
async function loadAutoAnnotationMergePreview(job: PointCloudInferenceJob) {
|
if (!annotationSourceId.value) return;
|
autoAnnotationMergePreview.value = await loadPointCloudAutoAnnotationMergePreview(job.runId, annotationSourceId.value, latestSourceAnnotation.value?.id);
|
}
|
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;
|
await loadAutoAnnotationMergePreview(autoAnnotationJob.value);
|
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); await loadAutoAnnotationReview(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; autoAnnotationMergePreview.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(annotationSourceMode, () => { annotationSourceFile.value = null; annotationTextureFiles.value = []; });
|
watch(inferenceInputMode, () => { inferenceFile.value = null; inferenceTextureFiles.value = []; });
|
watch(semanticClassificationRoute, () => { files.value = []; runError.value = null; });
|
watch([annotationSourceId, selectedSemanticModelId], () => { autoAnnotationJob.value = null; autoAnnotationSummary.value = null; autoAnnotationMergePreview.value = null; autoAnnotationReviewChanges.value = []; void restoreAutoAnnotation(); });
|
watch(workflow, (next, previous) => { searchText.value = ""; if (next === "semantic" && previous !== "semantic") semanticSection.value = "classification"; selectWorkflowCase(); });
|
watch(semanticSection, (section) => { if (section !== "classification") showRunForm.value = false; });
|
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-tabs>
|
<a-modal v-model:open="annotationSourceModalOpen" title="新增标注源" :confirm-loading="annotationSourceSubmitting" :ok-button-props="{ disabled: !annotationSourceReady }" ok-text="上传并生成预览" @ok="createAnnotationSource">
|
<a-radio-group v-model:value="annotationSourceMode" button-style="solid">
|
<a-radio-button value="pointcloud">普通点云</a-radio-button>
|
<a-radio-button value="texturedMesh">纹理网格</a-radio-button>
|
</a-radio-group>
|
<template v-if="annotationSourceMode === 'pointcloud'">
|
<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>
|
</template>
|
<template v-else>
|
<p>适用于 PLY 内已声明 UV 和 TextureFile 的纹理三角网格。请选择同一导出目录中的一个 PLY 与全部 JPG/PNG 纹理图;系统逐项核对文件名后,按三角面中心采样纹理颜色生成独立 RGB/XYZ 标注源。</p>
|
<p>普通航拍照片、没有相机位姿的照片不能用于此入口。生成点不是原始顶点,不会制造语义真值。</p>
|
<a-upload accept=".ply" :file-list="annotationSourceFile ? [{ uid: annotationSourceFile.name, name: annotationSourceFile.name, status: 'done' as const }] : []" :before-upload="beforeAnnotationSourceUpload" @remove="removeAnnotationSourceUpload"><a-button><FileOutlined />选择纹理网格 PLY</a-button></a-upload>
|
<a-upload multiple accept=".jpg,.jpeg,.png" :file-list="annotationTextureFiles.map((file, index) => ({ uid: `${file.name}-${index}`, name: file.name, status: 'done' as const }))" :before-upload="beforeAnnotationTextureUpload" @remove="removeAnnotationTextureUpload"><a-button><FileImageOutlined />选择全部纹理图</a-button></a-upload>
|
</template>
|
</a-modal>
|
<a-modal v-model:open="inferenceInputModalOpen" title="选择待预测输入" :confirm-loading="inferenceSubmitting" :ok-button-props="{ disabled: !selectedSemanticModelId || !inferenceInputReady }" ok-text="开始应用模型" @ok="applySemanticModel">
|
<a-radio-group v-model:value="inferenceInputMode" button-style="solid">
|
<a-radio-button value="pointcloud">普通点云</a-radio-button>
|
<a-radio-button value="texturedMesh">纹理网格</a-radio-button>
|
</a-radio-group>
|
<template v-if="inferenceInputMode === 'pointcloud'">
|
<p>选择已有真实逐点 RGB 的 PLY、PCD、LAS 或 LAZ 点云。XYZ-only 输入会被明确拒绝,不会伪造颜色特征。</p>
|
<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>
|
</template>
|
<template v-else>
|
<p>选择一个在 PLY 内声明 UV 与 TextureFile 的纹理三角网格,以及同一导出目录下全部 JPG/JPEG/PNG 纹理图。系统会严格核对文件名,先在 CPU 烘焙为 RGB/XYZ 面中心点云,再调用训练模型。</p>
|
<p>烘焙点不是原始网格顶点;普通航拍照片或没有匹配 TextureFile 的图片不能用于此入口。</p>
|
<a-upload accept=".ply" :file-list="inferenceFile ? [{ uid: inferenceFile.name, name: inferenceFile.name, status: 'done' as const }] : []" :before-upload="beforeInferenceUpload" @remove="removeInferenceUpload"><a-button><FileOutlined />选择纹理网格 PLY</a-button></a-upload>
|
<a-upload multiple accept=".jpg,.jpeg,.png" :file-list="inferenceTextureFiles.map((file, index) => ({ uid: `${file.name}-${index}`, name: file.name, status: 'done' as const }))" :before-upload="beforeInferenceTextureUpload" @remove="removeInferenceTextureUpload"><a-button><FileImageOutlined />选择全部纹理图片</a-button></a-upload>
|
</template>
|
</a-modal>
|
<a-modal v-model:open="annotationClassModalOpen" title="管理标签分类" :footer="null" width="760px" wrap-class-name="annotation-class-modal">
|
<a-alert class="annotation-class-note" type="info" show-icon message="颜色只用于工作台显示" description="修改颜色不会改变类别编码、已保存标注、训练模型、历史预测文件或自动标注候选。" />
|
<section class="annotation-class-section">
|
<div class="annotation-class-section-heading"><h3>新增分类</h3><span>系统会分配未占用的 LAS 兼容编码</span></div>
|
<div class="annotation-class-create">
|
<a-input v-model:value="annotationClassLabel" placeholder="中文名称,例如:变压器" />
|
<a-input v-model:value="annotationClassKey" placeholder="英文 key,例如:transformer" />
|
<label class="annotation-color-field"><span>显示颜色</span><input v-model="annotationClassColor" aria-label="新分类颜色" type="color" class="annotation-color-input" /></label>
|
<a-button type="primary" :loading="annotationClassSubmitting" :disabled="!annotationClassLabel.trim() || !annotationClassKey.trim()" @click="createAnnotationClass"><PlusOutlined />新增分类</a-button>
|
</div>
|
</section>
|
<section class="annotation-class-section annotation-class-existing">
|
<div class="annotation-class-section-heading"><h3>现有分类</h3><span>内置类别不可删除,但可调整显示颜色</span></div>
|
<a-list size="small" bordered :data-source="annotationClasses" class="annotation-class-list"><template #renderItem="{ item }"><a-list-item><div class="annotation-class-row"><div class="annotation-class-identity"><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" /><div><strong>{{ item.label }}</strong><a-typography-text type="secondary">{{ item.key }} / {{ item.code }}</a-typography-text></div></div><div class="annotation-class-row-actions"><label class="annotation-color-field"><span>显示颜色</span><input v-model="annotationClassColorDrafts[item.code]" :aria-label="`${item.label} 显示颜色`" type="color" class="annotation-color-input" /></label><a-button size="small" :loading="annotationClassColorSavingCode === item.code" @click="updateAnnotationClassColor(item.code)">保存颜色</a-button><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></div></div></a-list-item></template></a-list>
|
</section>
|
</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>{{ { classification: '点云语义分类', annotation: '标注工作台', auto_annotation: '自动标注复核', training: '训练数据集', models: '模型中心', tasks: '任务与记录' }[semanticSection] }}</h2><p>{{ semanticSection === 'classification' ? '先选择规则初筛或已训练模型;两条路线都输出待人工复核的分类候选。' : '点云分类、人工复核、训练数据和模型应用在独立步骤中完成,避免混杂操作。' }}</p></div>
|
<a-button v-if="workflow !== 'semantic' || semanticSection === 'classification'" type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button>
|
</section>
|
<section v-if="showRunForm && (workflow !== 'semantic' || semanticSection === 'classification')" 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-if="workflow === 'semantic'">
|
<a-radio-group v-model:value="semanticClassificationRoute" button-style="solid">
|
<a-radio-button value="rule">规则初筛</a-radio-button>
|
<a-radio-button value="model">已训练模型</a-radio-button>
|
</a-radio-group>
|
<template v-if="semanticClassificationRoute === 'rule'">
|
<a-alert type="info" show-icon message="规则初筛适用于尚未积累足够人工标注时。单次最多 2 份 PLY/PCD/XYZ/LAS/LAZ,输出仅为需要人工复核的规则候选。" />
|
<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>
|
<template v-else>
|
<a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚无可用训练模型" description="先在“标注、训练与模型应用”中保存人工标注版本并完成监督训练;规则初筛仍可作为当前数据的起点。" />
|
<template v-else>
|
<a-alert type="info" show-icon message="已训练模型分类会使用历史人工确认样本,对新点云生成待复核候选;误检、漏检和新场景样本应回流标注与集中训练。" />
|
<a-space wrap class="model-inference-controls">
|
<a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" />
|
<a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" />
|
<a-button type="primary" :disabled="!selectedSemanticModelId" @click="openModelInference"><UploadOutlined />选择预测输入</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="测试指标只代表训练时保留的空间分块,不能替代新场景人工复核。" />
|
</template>
|
</template>
|
</template>
|
<template v-else>
|
<a-alert type="info" show-icon message="单次最多 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-if="workflow === 'semantic' && semanticClassificationRoute === 'rule'" type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />开始规则初筛</a-button>
|
<a-button v-else-if="workflow === 'geometry'" type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />开始点云几何处理</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'" class="semantic-workspace-shell">
|
<aside class="semantic-workspace-nav"><PointCloudSemanticNavigation :active="semanticSection" :annotation-count="annotations.length" :model-count="semanticModels.length" :active-task-count="activeSemanticTaskCount" @change="semanticSection = $event" /></aside>
|
<main class="semantic-workspace-content">
|
<section v-if="semanticSection === 'classification' && 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>{{ currentCase.note }}</p></div></div><a-space wrap><a-button v-if="selectedCloud.semantic_method?.startsWith('已训练模型')" type="primary" @click="startSemanticCaseReview"><PlayCircleOutlined />开始人工复核</a-button><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="semanticSection === 'tasks'" class="surface-section">
|
<div class="section-heading"><div><h2>任务与记录</h2><p>查看本机任务状态、失败信息和模型/推理/标注产物校验结果。</p></div><a-tag color="blue">本机任务</a-tag></div>
|
<PointCloudTaskList />
|
<a-divider />
|
<a-descriptions v-if="artifactHealth" size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="通过校验">{{ artifactHealth.healthy }}</a-descriptions-item><a-descriptions-item label="异常产物">{{ artifactHealth.invalid }}</a-descriptions-item></a-descriptions>
|
<a-alert v-for="item in artifactHealth?.records.filter((record) => record.status === 'invalid')" :key="`${item.kind}-${item.id}`" type="error" show-icon :message="`${item.kind} / ${item.id}`" :description="[...item.missing, ...item.errors].join(';')" />
|
<a-empty v-if="artifactHealth && !artifactHealth.invalid" description="当前发现的点云产物均通过校验" />
|
</section>
|
<section v-if="semanticSection === 'models' || (['annotation', 'auto_annotation', 'training'].includes(semanticSection) && annotationSource)" class="surface-section result-band">
|
<div v-if="semanticSection === 'models'" class="section-heading"><div><h2>模型中心</h2><p>选择本机已完成的模型,核验其训练指标与产物状态,再用于新的 RGB 点云或纹理网格预测。模型输出始终需要人工复核。</p></div><a-tag color="blue">自动 GPU / CPU</a-tag></div>
|
<div v-else 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>
|
<template v-if="['annotation', 'auto_annotation', 'training'].includes(semanticSection)">
|
<div class="annotation-source-row">
|
<div class="annotation-source-field annotation-source-field-primary"><span>标注源</span><a-select v-model:value="annotationSourceId" :options="annotationSources.map((item) => ({ value: item.id, label: item.label }))" /></div>
|
<a-tooltip title="移除选中数据源的完整数据链路"><a-button danger :loading="annotationSourceRemovalLoading" :disabled="!annotationSourceId" aria-label="移除完整数据链路" @click="openAnnotationSourceRemoval"><DeleteOutlined /></a-button></a-tooltip>
|
<div class="annotation-source-field"><span>计算设备</span><a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" /></div>
|
</div>
|
</template>
|
<template v-if="semanticSection === 'annotation'">
|
<div class="annotation-workbench-controls">
|
<div class="annotation-management-actions"><a-button @click="annotationSourceModalOpen = true"><PlusOutlined />新增标注源</a-button><a-button @click="openAnnotationClassModal">管理标签分类</a-button></div>
|
<div class="annotation-class-legend"><span>标注分类</span><a-tag v-for="item in annotationClasses" :key="item.code"><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" />{{ item.label }}</a-tag></div>
|
</div>
|
<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" />
|
</template>
|
<template v-if="semanticSection === 'auto_annotation'">
|
<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>
|
<PointCloudModelCard :model="selectedSemanticModel" :health="artifactHealth" />
|
<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>
|
<a-alert v-if="autoAnnotationMergePreview" type="info" show-icon :message="`合并预览:最终将保存 ${autoAnnotationMergePreview.finalLabelCount.toLocaleString()} 个标签点`" :description="`高置信候选 ${autoAnnotationMergePreview.highConfidenceCandidateCount.toLocaleString()};已拒绝 ${autoAnnotationMergePreview.rejectedCandidateCount.toLocaleString()};已改类 ${autoAnnotationMergePreview.reclassifiedCandidateCount.toLocaleString()};人工标签覆盖 ${autoAnnotationMergePreview.baseHumanOverrideCount.toLocaleString()}。最终类别:${Object.entries(autoAnnotationMergePreview.classCounts).map(([code, count]) => `${annotationClassDisplayLabel(code)} ${count.toLocaleString()}`).join(';') || '无'}。`" />
|
<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-schema="autoAnnotationReviewSchema" :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>
|
</template>
|
<template v-if="semanticSection === 'training'">
|
<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-divider />
|
<div class="section-heading"><div><h3>集中训练数据集</h3><p>每个 RGB/XYZ 标注源选择一个不可变版本。系统按来源分别进行空间 XY 划分,再汇总训练,避免同一来源的相邻点泄漏到验证或测试。多视角照片特征版本仍使用独立训练流程,不能与普通 RGB/XYZ 源混合。</p></div></div>
|
<template v-if="concentratedTrainingOptions.length >= 2">
|
<div class="concentrated-training-controls"><a-select v-model:value="concentratedAnnotationIds" mode="multiple" :options="concentratedTrainingOptions" placeholder="选择至少两个不同标注源的已保存版本" /><a-button type="primary" :disabled="!concentratedTrainingReady" @click="startConcentratedTraining"><PlayCircleOutlined />集中训练</a-button></div>
|
<a-alert type="info" show-icon message="训练集校验" :description="concentratedTrainingReady ? `已选择 ${concentratedAnnotationIds.length} 个不同数据源版本。训练前仍会校验来源校验和、RGB 特征、类别映射、每类至少 500 点以及全部类别覆盖训练/验证/测试。` : '请选择至少两个不同 RGB/XYZ 标注源的已保存版本;同一数据源只能选择一个版本。'" />
|
</template>
|
<a-alert v-else type="warning" show-icon message="集中训练暂不可用" :description="`当前只有 ${concentratedTrainingOptions.length} 个具备已保存版本的 RGB/XYZ 标注源;至少需要两个不同源。先新增并保存另一个 RGB/XYZ 标注源的人工标注版本。`" />
|
<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)}训练中,可继续浏览案例。`)" />
|
</template>
|
<template v-if="semanticSection === 'models'">
|
<a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚未发现可用训练模型。先完成并保留一次监督训练。" />
|
<template v-else>
|
<section class="model-application-band">
|
<div class="model-band-heading"><div><h3>选择模型与预测输入</h3><p>支持普通 RGB 点云,或 PLY 加其全部纹理图片的纹理网格;纹理网格会先转换为 RGB 面中心点云。</p></div></div>
|
<div class="model-inference-controls">
|
<div class="model-control-field"><span>已训练模型</span><a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" /></div>
|
<a-tooltip title="移除选中模型及其本机推理产物"><SemanticModelDeletionControl :model-id="selectedSemanticModelId" :label="selectedSemanticModel?.label ?? selectedSemanticModelId" :disabled="!selectedSemanticModelId" @removed="removeSemanticModel" /></a-tooltip>
|
<div class="model-control-field"><span>计算设备</span><a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" /></div>
|
<a-button type="primary" :disabled="!selectedSemanticModelId" @click="openModelInference"><UploadOutlined />选择预测输入</a-button>
|
</div>
|
</section>
|
<section class="model-details-band">
|
<div class="model-band-heading"><div><h3>当前模型信息</h3><p>测试指标来自训练时保留的空间分块,不能代表新场景准确率。</p></div></div>
|
<PointCloudModelCard :model="selectedSemanticModel" :health="artifactHealth" />
|
</section>
|
</template>
|
<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>
|
</template>
|
</section>
|
<section v-else-if="['annotation', 'auto_annotation', 'training'].includes(semanticSection)" class="surface-section"><a-empty description="请先新增或选择一个可用标注源" /><a-button type="primary" @click="annotationSourceModalOpen = true"><PlusOutlined />新增标注源</a-button></section>
|
</main>
|
</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; }
|
.semantic-workspace-shell { display: grid; grid-template-columns: 236px minmax(0, 1fr); align-items: start; gap: 18px; margin-bottom: 24px; }
|
.semantic-workspace-nav { position: sticky; top: 16px; }
|
.semantic-workspace-content { min-width: 0; }
|
.semantic-workspace-content > .surface-section, .semantic-workspace-content > .result-band { margin-bottom: 0; }
|
.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: grid; grid-template-columns: minmax(340px, 1fr) auto minmax(240px, 300px) auto; align-items: end; gap: 12px; padding: 12px; border: 1px solid #d8e5df; background: #f7faf8; }
|
.model-control-field { display: grid; gap: 6px; min-width: 0; color: #53675e; font-size: 12px; font-weight: 600; }
|
.model-control-field :deep(.ant-select) { width: 100%; }
|
.model-application-band { margin-top: 16px; }
|
.model-details-band { margin-top: 16px; padding-top: 16px; border-top: 1px solid #e4ece8; }
|
.model-band-heading { margin-bottom: 10px; }
|
.model-band-heading h3 { margin: 0; color: #263a32; font-size: 15px; }
|
.model-band-heading p { margin: 4px 0 0; color: #687b73; font-size: 13px; line-height: 1.5; }
|
.model-select { min-width: min(100%, 440px); }
|
.concentrated-training-controls { display: grid; grid-template-columns: minmax(0, 560px) max-content; align-items: start; gap: 8px; margin-bottom: 12px; }
|
.concentrated-training-controls :deep(.ant-select) { width: 100%; }
|
.model-inference-model { margin-bottom: 12px; }
|
.model-inference-result { display: grid; gap: 16px; margin-top: 16px; }
|
.auto-annotation-preview { display: grid; gap: 12px; }
|
.annotation-source-row { display: grid; grid-template-columns: minmax(340px, 1fr) auto minmax(240px, 300px); align-items: end; gap: 12px; margin: 16px 0; padding: 12px; border: 1px solid #d8e5df; background: #f7faf8; }
|
.annotation-source-field { display: grid; gap: 6px; min-width: 0; color: #53675e; font-size: 12px; font-weight: 600; }
|
.annotation-source-field :deep(.ant-select) { width: 100%; }
|
.annotation-source-row > :deep(.ant-btn) { margin-bottom: 0; }
|
.annotation-workbench-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin: 0 0 16px; padding: 12px; border: 1px solid #d8e5df; background: #fff; }
|
.annotation-management-actions, .annotation-class-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
.annotation-class-legend { flex: 1 1 420px; }
|
.annotation-class-legend > span { color: #53675e; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
.annotation-management-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0; }
|
.annotation-class-note { margin-bottom: 16px; }
|
.annotation-class-section { padding: 16px; border: 1px solid #d8e5df; background: #f8fbf9; }
|
.annotation-class-section + .annotation-class-section { margin-top: 16px; }
|
.annotation-class-section-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 12px; margin-bottom: 12px; }
|
.annotation-class-section-heading h3 { margin: 0; font-size: 14px; color: #1d392d; }
|
.annotation-class-section-heading span { color: #687a71; font-size: 12px; }
|
.annotation-class-create { display: grid; grid-template-columns: minmax(150px, 1fr) minmax(160px, 1fr) max-content max-content; align-items: end; gap: 12px; }
|
.annotation-color-field { display: grid; gap: 5px; color: #53675e; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
.annotation-class-existing { background: #fff; }
|
.annotation-class-list { max-height: 360px; overflow: auto; background: #fff; }
|
.annotation-class-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; }
|
.annotation-class-identity, .annotation-class-row-actions { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
.annotation-class-identity > div { display: grid; gap: 2px; min-width: 0; }
|
.annotation-class-identity strong { color: #263d32; }
|
.annotation-class-row-actions { flex-wrap: wrap; justify-content: flex-end; }
|
.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) { .semantic-workspace-shell { grid-template-columns: 1fr; } .semantic-workspace-nav { position: static; } .pointcloud-images :deep(img), .pointcloud-vector { height: 300px; } }
|
@media (max-width: 980px) { .model-inference-controls { grid-template-columns: minmax(0, 1fr) auto; } .model-inference-controls .model-control-field:last-of-type { grid-column: 1 / -1; } }
|
@media (max-width: 780px) { .annotation-source-row { grid-template-columns: minmax(0, 1fr) auto; } .annotation-source-row .annotation-source-field:last-child { grid-column: 1 / -1; } }
|
@media (max-width: 640px) { .concentrated-training-controls { grid-template-columns: minmax(0, 1fr); } .annotation-source-row, .model-inference-controls, .annotation-class-create { grid-template-columns: minmax(0, 1fr); } .annotation-source-row > :deep(.ant-btn), .model-inference-controls > :deep(.ant-btn) { justify-self: start; } .annotation-source-row .annotation-source-field:last-child, .model-inference-controls .model-control-field:last-of-type { grid-column: auto; } .annotation-workbench-controls { align-items: stretch; } .annotation-management-actions, .annotation-class-legend, .annotation-class-row, .annotation-class-row-actions { align-items: flex-start; } .annotation-class-row { flex-direction: column; } .annotation-class-row-actions { justify-content: flex-start; } }
|
</style>
|