<script setup lang="ts">
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { DeleteOutlined, DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons-vue";
|
|
import { acceptPointCloudAutoAnnotation, artifactUrl, createDensePointCloudClassification, createPhotoReconstructionRun, createPointCloudAnnotation, createPointCloudAnnotationClass, createPointCloudAnnotationSourceRun, createPointCloudAutoAnnotation, createPointCloudModelInference, createPointCloudRun, createPointCloudTrainingRun, deletePointCloudAnnotation, deletePointCloudAnnotationClass, deletePointCloudAnnotationSource, loadLatestPointCloudAutoAnnotation, loadPhotoReconstructionJob, loadPointCloudAnnotationClasses, loadPointCloudAnnotationSourceDeletionPlan, loadPointCloudAnnotationSourceJob, loadPointCloudAnnotationSources, loadPointCloudAnnotations, loadPointCloudAutoAnnotationReview, loadPointCloudModelInferenceJob, loadPointCloudSemanticModels, loadPointCloudTrainingJob, savePointCloudAutoAnnotationReview, uploadPhotoReconstructionFile, uploadPointCloudFile, type ComputeDevice, type GeoFeature, type PhotoReconstructionJob, type PointCloudAnnotation, type PointCloudAnnotationClass, type PointCloudAnnotationSource, type PointCloudAnnotationSourceDeletionPlan, type PointCloudAnnotationSourceJob, type PointCloudCase, type PointCloudInferenceJob, type PointCloudSemanticModel, type PointCloudTrainingJob } from "@/api/artifacts";
|
import ArtifactState from "@/components/ArtifactState.vue";
|
import RunDeletionControl from "@/components/RunDeletionControl.vue";
|
import SemanticModelDeletionControl from "@/components/SemanticModelDeletionControl.vue";
|
import SparsePointCloudViewer from "@/components/SparsePointCloudViewer.vue";
|
import PointCloudAnnotationViewer from "@/components/PointCloudAnnotationViewer.vue";
|
import TexturedMeshViewer from "@/components/TexturedMeshViewer.vue";
|
import { useArtifactStore } from "@/stores/artifacts";
|
|
const store = useArtifactStore();
|
const caseId = ref("");
|
const selectedName = ref("");
|
const files = ref<File[]>([]);
|
const photoFiles = ref<File[]>([]);
|
const showRunForm = ref(false);
|
const running = ref(false);
|
const photoSubmitting = ref(false);
|
const photoJob = ref<PhotoReconstructionJob | null>(null);
|
const usePhotoPositionPriors = ref(false);
|
const workflow = ref<"photo" | "geometry" | "semantic" | "model">("photo");
|
const runError = ref<string | null>(null);
|
const searchText = ref("");
|
const vectorFeatures = ref<GeoFeature[]>([]);
|
const densePreviewMode = ref<"textured" | "geometry" | "point_colors">("textured");
|
let photoPollTimer: ReturnType<typeof setTimeout> | undefined;
|
const annotationSources = ref<PointCloudAnnotationSource[]>([]);
|
const annotationClasses = ref<PointCloudAnnotationClass[]>([]);
|
const annotations = ref<PointCloudAnnotation[]>([]);
|
const annotationSourceId = ref("");
|
const annotationSaving = ref(false);
|
const annotationNotice = ref<{ type: "info" | "success" | "error"; message: string } | null>(null);
|
const annotationDeletingId = ref("");
|
const annotationSourceModalOpen = ref(false);
|
const annotationSourceFile = ref<File | null>(null);
|
const annotationSourceSubmitting = ref(false);
|
const annotationSourceJob = ref<PointCloudAnnotationSourceJob | null>(null);
|
let annotationSourcePollTimer: ReturnType<typeof setTimeout> | undefined;
|
const annotationSourceRemovalPlan = ref<PointCloudAnnotationSourceDeletionPlan | null>(null);
|
const annotationSourceRemovalOpen = ref(false);
|
const annotationSourceRemovalLoading = ref(false);
|
const annotationSourceRemoving = ref(false);
|
const annotationClassModalOpen = ref(false);
|
const annotationClassSubmitting = ref(false);
|
const annotationClassKey = ref("");
|
const annotationClassLabel = ref("");
|
const annotationClassColor = ref("#3289c7");
|
const annotationClassDeletingCode = ref<number | null>(null);
|
const trainingJob = ref<PointCloudTrainingJob | null>(null);
|
let trainingPollTimer: ReturnType<typeof setTimeout> | undefined;
|
const semanticModels = ref<PointCloudSemanticModel[]>([]);
|
const selectedSemanticModelId = ref("");
|
const modelDevice = ref<ComputeDevice>("auto");
|
const inferenceFile = ref<File | null>(null);
|
const inferenceSubmitting = ref(false);
|
const inferenceJob = ref<PointCloudInferenceJob | null>(null);
|
const inferenceSummary = ref<{ class_counts?: Record<string, number>; input_points?: number; preview_points?: number } | null>(null);
|
let inferencePollTimer: ReturnType<typeof setTimeout> | undefined;
|
const autoAnnotationThreshold = ref(0.95);
|
const autoAnnotationSubmitting = ref(false);
|
const autoAnnotationAccepting = ref(false);
|
const autoAnnotationJob = ref<PointCloudInferenceJob | null>(null);
|
const autoAnnotationSummary = ref<{ automatic_annotation?: { candidate_count?: number; candidate_confidence?: number; candidate_class_counts?: Record<string, number> }; class_counts?: Record<string, number>; input_points?: number } | null>(null);
|
const autoAnnotationReviewChanges = ref<Array<[number, number]>>([]);
|
const autoAnnotationReviewSaving = ref(false);
|
let autoAnnotationPollTimer: ReturnType<typeof setTimeout> | undefined;
|
|
const workflowCases = computed(() => Object.values(store.pointCloudCases).filter((item) => {
|
const run = item.run;
|
if (workflow.value === "photo") return Boolean(run.photo_reconstruction || run.dense_photo_reconstruction);
|
if (workflow.value === "semantic" || workflow.value === "model") return (run.point_clouds ?? []).some((cloud) => Boolean(cloud.semantic_preview_point_cloud));
|
return Boolean(run.point_clouds?.length);
|
}));
|
const currentCase = computed<PointCloudCase | undefined>(() => workflowCases.value.find((item) => item.id === caseId.value) ?? workflowCases.value[0]);
|
const selectedCloud = computed(() => (currentCase.value?.run.point_clouds ?? []).find((item) => item.file === selectedName.value) ?? currentCase.value?.run.point_clouds?.[0]);
|
const photoRun = computed(() => currentCase.value?.run.photo_reconstruction);
|
const densePhotoRun = computed(() => currentCase.value?.run.dense_photo_reconstruction);
|
const densePreviewSource = computed(() => {
|
const dense = densePhotoRun.value;
|
if (!dense) return "";
|
if (densePreviewMode.value === "geometry" && dense.geometry_preview_model_file) return dense.geometry_preview_model_file;
|
if (densePreviewMode.value === "point_colors" && dense.point_color_preview_model_file) return dense.point_color_preview_model_file;
|
return dense.textured_model_file;
|
});
|
const caseOptions = computed(() => workflowCases.value.map((item) => ({ value: item.id, label: item.label })));
|
const filteredCases = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase())));
|
const photoJobMessage = computed(() => {
|
const job = photoJob.value;
|
if (!job) return "";
|
if (job.status === "queued") return `已排队:${job.inputImages} 张照片等待 CPU 资源。`;
|
if (job.stage === "sparse_sfm") return "正在进行 CPU 稀疏相机建模。";
|
if (job.stage === "dense_mvs") return "正在进行 CPU 稠密点云、网格与纹理重建,通常需要几十分钟。";
|
if (job.status === "complete") return "重建完成,结果已加入案例库。";
|
return job.error || "照片重建失败。";
|
});
|
const photoJobProgress = computed(() => photoJob.value?.progress ?? { percent: 0, stage: "queued", message: "照片已保存,正在等待 CPU 重建资源。", inputImages: photoJob.value?.inputImages ?? 0, estimate: true });
|
function executionLabel(job: { device: string; requestedDevice?: ComputeDevice; environment?: string; torchVersion?: string; fallbackUsed?: boolean; fallbackReason?: string | null }) {
|
const device = job.device === "cuda" ? "GPU CUDA" : "CPU";
|
const selection = job.requestedDevice ? `请求 ${job.requestedDevice} -> ${device}` : device;
|
return [selection, job.environment, job.torchVersion ? `PyTorch ${job.torchVersion}` : "", job.fallbackUsed && job.fallbackReason ? `回退:${job.fallbackReason}` : ""].filter(Boolean).join(" / ");
|
}
|
|
function rings(feature: GeoFeature): number[][][] {
|
const geometry = feature.geometry;
|
if (!geometry?.coordinates) return [];
|
if (geometry.type === "Polygon") return geometry.coordinates as number[][][];
|
if (geometry.type === "MultiPolygon") return (geometry.coordinates as number[][][][]).flat();
|
return [];
|
}
|
const allRings = computed(() => vectorFeatures.value.flatMap(rings));
|
const vectorViewBox = computed(() => {
|
const points = allRings.value.flat();
|
if (!points.length) return "0 0 1 1";
|
const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]);
|
const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys);
|
return `${minX} ${minY} ${Math.max(maxX - minX, 1)} ${Math.max(maxY - minY, 1)}`;
|
});
|
const vectorPaths = computed(() => allRings.value.map((ring) => ring.map((point, index) => `${index ? "L" : "M"}${point[0]},${point[1]}`).join(" ") + " Z"));
|
const annotationSource = computed(() => annotationSources.value.find((item) => item.id === annotationSourceId.value));
|
const annotationSourceDescription = computed(() => annotationSource.value?.sourceKind.includes("多视角照片特征融合")
|
? `当前显示 ${annotationSource.value.pointCount.toLocaleString()} 个原始 LAS RGB/XYZ 采样点;点序与同目录照片特征 NPZ 严格一致。覆盖率伪彩没有作为标注底图或训练颜色。`
|
: annotationSource.value?.sourceHasRgb === false
|
? `当前显示 ${annotationSource.value.pointCount.toLocaleString()} 个 XYZ 点的中性预览。源 PLY 没有可读取的逐点 RGB,因而不能用于当前 RGB 语义模型训练。`
|
: `当前直接显示该数据源全部 ${annotationSource.value?.pointCount.toLocaleString() ?? 0} 个 RGB/XYZ 点,不是语义规则分类颜色。大规模点云会相应占用更多浏览器内存与显存。`);
|
const selectedSemanticModel = computed(() => semanticModels.value.find((item) => item.id === selectedSemanticModelId.value));
|
const semanticModelOptions = computed(() => semanticModels.value.map((model) => ({ value: model.id, label: `${model.label} | ${Object.values(model.classes).map((item) => item.label).join(" / ")}` })));
|
function annotationSourceLabel(sourceId: string) { return annotationSources.value.find((item) => item.id === sourceId)?.label ?? sourceId; }
|
function annotationClassDisplayLabel(code: string | number) { return annotationClasses.value.find((item) => item.code === Number(code))?.label ?? `类别 ${code}`; }
|
function annotationTrainerLabel(annotation: PointCloudAnnotation) { return annotationSources.value.find((item) => item.id === annotation.sourceId)?.sourceKind.includes("多视角照片特征融合") ? "多视角特征训练" : "自动选择 GPU / CPU 训练"; }
|
const selectedSourceAnnotations = computed(() => annotations.value.filter((item) => item.sourceId === annotationSourceId.value));
|
const latestSourceAnnotation = computed(() => selectedSourceAnnotations.value[0]);
|
|
function syncSelection() { selectedName.value = currentCase.value?.run.point_clouds?.[0]?.file ?? ""; }
|
function selectWorkflowCase() { caseId.value = workflowCases.value[0]?.id ?? ""; syncSelection(); }
|
async function removePointCloudRun() { await store.loadPointCloud(true); selectWorkflowCase(); }
|
async function removeSemanticModel() { await refreshSemanticModels(); autoAnnotationJob.value = null; autoAnnotationSummary.value = null; inferenceJob.value = null; inferenceSummary.value = null; }
|
function beforeUpload(file: File) { files.value = [...files.value, file]; return false; }
|
function removeFile(file: { name: string }) { files.value = files.value.filter((item) => item.name !== file.name); }
|
function beforePhotoUpload(file: File) { photoFiles.value = [...photoFiles.value, file]; return false; }
|
function removePhotoFile(file: { name: string }) { photoFiles.value = photoFiles.value.filter((item) => item.name !== file.name); }
|
function stopPhotoPolling() { if (photoPollTimer) clearTimeout(photoPollTimer); photoPollTimer = undefined; }
|
async function loadVector() {
|
vectorFeatures.value = [];
|
if (!currentCase.value || !selectedCloud.value) return;
|
const response = await fetch(artifactUrl(`${currentCase.value.artifactRoot}/${selectedCloud.value.vector_file}`), { cache: "no-store" });
|
if (response.ok) vectorFeatures.value = ((await response.json()) as { features?: GeoFeature[] }).features ?? [];
|
}
|
async function refreshAnnotationData() {
|
const [sources, classes, revisions] = await Promise.all([loadPointCloudAnnotationSources(), loadPointCloudAnnotationClasses(), loadPointCloudAnnotations()]);
|
annotationSources.value = sources;
|
annotationClasses.value = classes;
|
annotations.value = revisions;
|
if (!annotationSourceId.value || !annotationSources.value.some((item) => item.id === annotationSourceId.value)) annotationSourceId.value = annotationSources.value[0]?.id ?? "";
|
}
|
async function refreshSemanticModels() {
|
semanticModels.value = await loadPointCloudSemanticModels();
|
if (!selectedSemanticModelId.value || !semanticModels.value.some((item) => item.id === selectedSemanticModelId.value)) selectedSemanticModelId.value = semanticModels.value[0]?.id ?? "";
|
}
|
function stopTrainingPolling() { if (trainingPollTimer) clearTimeout(trainingPollTimer); trainingPollTimer = undefined; }
|
async function pollTrainingJob() {
|
if (!trainingJob.value) return;
|
try {
|
trainingJob.value = await loadPointCloudTrainingJob(trainingJob.value.id);
|
if (!['complete', 'failed'].includes(trainingJob.value.status)) trainingPollTimer = setTimeout(() => { void pollTrainingJob(); }, 2_500);
|
} catch (error) { runError.value = error instanceof Error ? error.message : "无法读取训练任务状态。"; }
|
}
|
async function saveAnnotation(labels: Array<[number, number]>) {
|
if (!annotationSourceId.value) return;
|
annotationSaving.value = true; annotationNotice.value = { type: "info", message: `正在保存 ${labels.length.toLocaleString()} 个确认点...` };
|
try { const { annotation } = await createPointCloudAnnotation(annotationSourceId.value, labels); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: `已保存 ${annotation.id}:${annotation.labelCount.toLocaleString()} 个确认点。` }; }
|
catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "保存标注失败。" }; }
|
finally { annotationSaving.value = false; }
|
}
|
async function deleteAnnotation(annotationId: string) {
|
annotationDeletingId.value = annotationId;
|
try { await deletePointCloudAnnotation(annotationId); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: `已删除标注版本 ${annotationId}。` }; }
|
catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "删除标注版本失败。" }; }
|
finally { annotationDeletingId.value = ""; }
|
}
|
function beforeAnnotationSourceUpload(file: File) { annotationSourceFile.value = file; return false; }
|
function removeAnnotationSourceUpload() { annotationSourceFile.value = null; }
|
function stopAnnotationSourcePolling() { if (annotationSourcePollTimer) clearTimeout(annotationSourcePollTimer); annotationSourcePollTimer = undefined; }
|
async function pollAnnotationSourceJob() {
|
if (!annotationSourceJob.value) return;
|
try {
|
annotationSourceJob.value = await loadPointCloudAnnotationSourceJob(annotationSourceJob.value.id);
|
if (annotationSourceJob.value.status === "complete") {
|
await store.loadPointCloud(true);
|
await refreshAnnotationData();
|
const source = annotationSources.value.find((item) => item.runId === annotationSourceJob.value?.runId);
|
if (source) annotationSourceId.value = source.id;
|
annotationSourceModalOpen.value = false;
|
annotationNotice.value = { type: "success", message: `已生成标注源:${annotationSourceJob.value.inputName}` };
|
return;
|
}
|
if (annotationSourceJob.value.status !== "failed") annotationSourcePollTimer = setTimeout(() => { void pollAnnotationSourceJob(); }, 2_500);
|
} catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取标注源任务状态。" }; }
|
}
|
async function createAnnotationSource() {
|
if (!annotationSourceFile.value) return;
|
annotationSourceSubmitting.value = true;
|
try {
|
const upload = await uploadPointCloudFile(annotationSourceFile.value);
|
const { job } = await createPointCloudAnnotationSourceRun(upload);
|
annotationSourceFile.value = null;
|
annotationSourceJob.value = job;
|
annotationNotice.value = { type: "info", message: `正在处理 ${job.inputName},完成后会自动加入标注源。` };
|
stopAnnotationSourcePolling();
|
void pollAnnotationSourceJob();
|
} catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "新增标注源失败。" }; }
|
finally { annotationSourceSubmitting.value = false; }
|
}
|
async function openAnnotationSourceRemoval() {
|
if (!annotationSourceId.value) return;
|
annotationSourceRemovalLoading.value = true;
|
annotationSourceRemovalPlan.value = null;
|
try {
|
annotationSourceRemovalPlan.value = await loadPointCloudAnnotationSourceDeletionPlan(annotationSourceId.value);
|
annotationSourceRemovalOpen.value = true;
|
} catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取数据链路移除范围。" }; }
|
finally { annotationSourceRemovalLoading.value = false; }
|
}
|
async function removeAnnotationSourceChain() {
|
if (!annotationSourceRemovalPlan.value) return;
|
annotationSourceRemoving.value = true;
|
try {
|
const { removed } = await deletePointCloudAnnotationSource(annotationSourceRemovalPlan.value.sourceId);
|
annotationSourceRemovalOpen.value = false;
|
annotationSourceRemovalPlan.value = null;
|
annotationSourceId.value = "";
|
await store.loadPointCloud(true);
|
await refreshAnnotationData();
|
await refreshSemanticModels();
|
const detail = removed.removed;
|
annotationNotice.value = { type: "success", message: `已移除完整数据链路:${detail.outputDirectories} 个结果目录、${detail.rawDirectories + detail.processedDirectories} 个上传/处理目录、${detail.annotationRevisions} 个标注版本、${detail.trainingRuns} 个训练结果和 ${detail.inferenceRuns} 个推理结果。` };
|
} catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "移除数据链路失败。" }; }
|
finally { annotationSourceRemoving.value = false; }
|
}
|
function annotationColor(rgb: number[]) { return `#${rgb.map((item) => item.toString(16).padStart(2, "0")).join("")}`; }
|
function hexColorToRgb(value: string) {
|
const match = /^#([0-9a-f]{6})$/i.exec(value);
|
return match ? [Number.parseInt(match[1].slice(0, 2), 16), Number.parseInt(match[1].slice(2, 4), 16), Number.parseInt(match[1].slice(4, 6), 16)] : null;
|
}
|
async function createAnnotationClass() {
|
const color = hexColorToRgb(annotationClassColor.value);
|
if (!color) { annotationNotice.value = { type: "error", message: "请选择有效的 RGB 颜色。" }; return; }
|
annotationClassSubmitting.value = true;
|
try {
|
await createPointCloudAnnotationClass({ key: annotationClassKey.value.trim(), label: annotationClassLabel.value.trim(), color });
|
await refreshAnnotationData();
|
annotationClassKey.value = ""; annotationClassLabel.value = ""; annotationClassColor.value = "#3289c7";
|
annotationNotice.value = { type: "success", message: "已新增标签分类。" };
|
} catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "新增标签分类失败。" }; }
|
finally { annotationClassSubmitting.value = false; }
|
}
|
async function deleteAnnotationClass(code: number) {
|
annotationClassDeletingCode.value = code;
|
try { await deletePointCloudAnnotationClass(code); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: "已删除未使用的自定义标签分类。" }; }
|
catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "删除标签分类失败。" }; }
|
finally { annotationClassDeletingCode.value = null; }
|
}
|
async function startTraining(annotationId: string) {
|
try { const { job } = await createPointCloudTrainingRun(annotationId, modelDevice.value); trainingJob.value = job; stopTrainingPolling(); void pollTrainingJob(); }
|
catch (error) { runError.value = error instanceof Error ? error.message : "启动训练失败。"; }
|
}
|
function beforeInferenceUpload(file: File) { inferenceFile.value = file; return false; }
|
function removeInferenceUpload() { inferenceFile.value = null; }
|
function stopInferencePolling() { if (inferencePollTimer) clearTimeout(inferencePollTimer); inferencePollTimer = undefined; }
|
async function loadInferenceSummary(job: PointCloudInferenceJob) {
|
if (!job.summary) return;
|
const response = await fetch(artifactUrl(job.summary), { cache: "no-store" });
|
if (response.ok) inferenceSummary.value = await response.json() as { class_counts?: Record<string, number>; input_points?: number; preview_points?: number };
|
}
|
async function pollInferenceJob() {
|
if (!inferenceJob.value) return;
|
try {
|
inferenceJob.value = await loadPointCloudModelInferenceJob(inferenceJob.value.id);
|
if (inferenceJob.value.status === "complete") { await loadInferenceSummary(inferenceJob.value); return; }
|
if (inferenceJob.value.status !== "failed") inferencePollTimer = setTimeout(() => { void pollInferenceJob(); }, 2_500);
|
} catch (error) { runError.value = error instanceof Error ? error.message : "无法读取模型应用任务状态。"; }
|
}
|
async function applySemanticModel() {
|
if (!selectedSemanticModelId.value || !inferenceFile.value) return;
|
inferenceSubmitting.value = true; runError.value = null; inferenceJob.value = null; inferenceSummary.value = null;
|
try {
|
const upload = await uploadPointCloudFile(inferenceFile.value);
|
const { job } = await createPointCloudModelInference(selectedSemanticModelId.value, upload, modelDevice.value);
|
inferenceFile.value = null; inferenceJob.value = job; stopInferencePolling(); void pollInferenceJob();
|
} catch (error) { runError.value = error instanceof Error ? error.message : "应用训练模型失败。"; }
|
finally { inferenceSubmitting.value = false; }
|
}
|
function stopAutoAnnotationPolling() { if (autoAnnotationPollTimer) clearTimeout(autoAnnotationPollTimer); autoAnnotationPollTimer = undefined; }
|
async function loadAutoAnnotationSummary(job: PointCloudInferenceJob) {
|
if (!job.summary) return;
|
const response = await fetch(artifactUrl(job.summary), { cache: "no-store" });
|
if (response.ok) autoAnnotationSummary.value = await response.json() as typeof autoAnnotationSummary.value;
|
}
|
async function loadAutoAnnotationReview(job: PointCloudInferenceJob) {
|
const review = await loadPointCloudAutoAnnotationReview(job.runId);
|
autoAnnotationReviewChanges.value = review?.corrections ?? [];
|
}
|
async function restoreAutoAnnotation() {
|
if (!annotationSourceId.value || !selectedSemanticModelId.value) return;
|
try {
|
const job = await loadLatestPointCloudAutoAnnotation(annotationSourceId.value, selectedSemanticModelId.value);
|
if (!job) return;
|
autoAnnotationJob.value = job;
|
await loadAutoAnnotationSummary(job);
|
await loadAutoAnnotationReview(job);
|
} catch (error) {
|
annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法恢复已完成的自动标注结果。" };
|
}
|
}
|
async function saveAutoAnnotationReview(changes: Array<[number, number]>) {
|
if (!autoAnnotationJob.value || !annotationSourceId.value) return;
|
autoAnnotationReviewSaving.value = true;
|
try {
|
const { review } = await savePointCloudAutoAnnotationReview(autoAnnotationJob.value.runId, annotationSourceId.value, changes);
|
autoAnnotationReviewChanges.value = changes;
|
annotationNotice.value = { type: "success", message: `已保存 ${review.correctionCount.toLocaleString()} 个候选审阅修正;尚未合并进训练标注。` };
|
} catch (error) {
|
annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "保存候选审阅修正失败。" };
|
} finally { autoAnnotationReviewSaving.value = false; }
|
}
|
async function pollAutoAnnotationJob() {
|
if (!autoAnnotationJob.value) return;
|
try {
|
autoAnnotationJob.value = await loadPointCloudModelInferenceJob(autoAnnotationJob.value.id);
|
if (autoAnnotationJob.value.status === "complete") { await loadAutoAnnotationSummary(autoAnnotationJob.value); return; }
|
if (autoAnnotationJob.value.status !== "failed") autoAnnotationPollTimer = setTimeout(() => { void pollAutoAnnotationJob(); }, 2_500);
|
} catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取自动标注任务状态。" }; }
|
}
|
async function startAutoAnnotation() {
|
if (!annotationSourceId.value || !selectedSemanticModelId.value) return;
|
autoAnnotationSubmitting.value = true; autoAnnotationJob.value = null; autoAnnotationSummary.value = null;
|
try {
|
const { job } = await createPointCloudAutoAnnotation(selectedSemanticModelId.value, annotationSourceId.value, autoAnnotationThreshold.value, modelDevice.value);
|
autoAnnotationJob.value = job; annotationNotice.value = { type: "info", message: "自动标注候选正在生成;不会覆盖现有人工标注。" }; stopAutoAnnotationPolling(); void pollAutoAnnotationJob();
|
} catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "启动自动标注失败。" }; }
|
finally { autoAnnotationSubmitting.value = false; }
|
}
|
async function acceptAutoAnnotation() {
|
if (!autoAnnotationJob.value || !annotationSourceId.value || autoAnnotationJob.value.status !== "complete") return;
|
autoAnnotationAccepting.value = true;
|
try {
|
const { annotation } = await acceptPointCloudAutoAnnotation(autoAnnotationJob.value.runId, annotationSourceId.value, latestSourceAnnotation.value?.id);
|
await refreshAnnotationData();
|
annotationNotice.value = { type: "success", message: `已确认合并 ${annotation.labelCount.toLocaleString()} 个候选/人工标签,人工标签优先。可使用新版本重新训练。` };
|
} catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "确认自动标注候选失败。" }; }
|
finally { autoAnnotationAccepting.value = false; }
|
}
|
async function submitRun() {
|
if (!files.value.length) { runError.value = "请选择至少一份 PLY、PCD、XYZ、LAS 或 LAZ 点云。"; return; }
|
running.value = true; runError.value = null;
|
try {
|
const pointClouds = [];
|
for (const file of files.value) pointClouds.push(await uploadPointCloudFile(file));
|
const { run } = await createPointCloudRun(pointClouds);
|
await store.loadPointCloud(true); caseId.value = run.id; syncSelection(); files.value = []; showRunForm.value = false;
|
} catch (error) { runError.value = error instanceof Error ? error.message : "三维点云运行失败"; }
|
finally { running.value = false; }
|
}
|
async function classifyDenseResult() {
|
if (!currentCase.value) return;
|
running.value = true; runError.value = null;
|
try {
|
const { run } = await createDensePointCloudClassification(currentCase.value.id);
|
await store.loadPointCloud(true); workflow.value = "semantic"; caseId.value = run.id; syncSelection();
|
} catch (error) { runError.value = error instanceof Error ? error.message : "稠密点云分类失败"; }
|
finally { running.value = false; }
|
}
|
async function pollPhotoReconstructionJob() {
|
const current = photoJob.value;
|
if (!current) return;
|
try {
|
const job = await loadPhotoReconstructionJob(current.id);
|
photoJob.value = job;
|
if (job.status === "complete") {
|
await store.loadPointCloud(true);
|
caseId.value = job.run?.id ?? job.runId;
|
syncSelection();
|
return;
|
}
|
if (job.status !== "failed") photoPollTimer = setTimeout(() => { void pollPhotoReconstructionJob(); }, 3_000);
|
} catch (error) {
|
runError.value = error instanceof Error ? error.message : "无法读取照片重建任务状态。";
|
}
|
}
|
async function submitPhotoReconstruction() {
|
if (photoFiles.value.length < 3) { runError.value = "请至少选择 3 张同一架次、同一相机的 JPG/JPEG 照片。"; return; }
|
photoSubmitting.value = true;
|
runError.value = null;
|
photoJob.value = null;
|
try {
|
const uploads = [];
|
for (const file of photoFiles.value) uploads.push(await uploadPhotoReconstructionFile(file));
|
const { job } = await createPhotoReconstructionRun(uploads, usePhotoPositionPriors.value);
|
photoFiles.value = [];
|
photoJob.value = job;
|
showRunForm.value = false;
|
stopPhotoPolling();
|
void pollPhotoReconstructionJob();
|
} catch (error) {
|
runError.value = error instanceof Error ? error.message : "照片重建任务提交失败。";
|
} finally { photoSubmitting.value = false; }
|
}
|
|
watch([caseId, selectedName], loadVector);
|
watch([annotationSourceId, selectedSemanticModelId], () => { autoAnnotationJob.value = null; autoAnnotationSummary.value = null; autoAnnotationReviewChanges.value = []; void restoreAutoAnnotation(); });
|
watch(workflow, () => { searchText.value = ""; selectWorkflowCase(); });
|
onMounted(async () => { await store.loadPointCloud(); selectWorkflowCase(); await loadVector(); await refreshAnnotationData(); await refreshSemanticModels(); await restoreAutoAnnotation(); });
|
onBeforeUnmount(() => { stopPhotoPolling(); stopAnnotationSourcePolling(); stopTrainingPolling(); stopInferencePolling(); stopAutoAnnotationPolling(); });
|
</script>
|
|
<template>
|
<ArtifactState :loading="store.loading" :error="store.error" />
|
<a-tabs v-model:active-key="workflow" class="pointcloud-workflow-tabs">
|
<a-tab-pane key="photo" tab="照片三维重建" />
|
<a-tab-pane key="geometry" tab="点云几何处理" />
|
<a-tab-pane key="semantic" tab="点云语义分类" />
|
<a-tab-pane key="model" tab="标注、训练与模型应用" />
|
</a-tabs>
|
<a-modal v-model:open="annotationSourceModalOpen" title="新增标注源" :confirm-loading="annotationSourceSubmitting" :ok-button-props="{ disabled: !annotationSourceFile }" ok-text="上传并生成预览" @ok="createAnnotationSource">
|
<p>上传 PLY、PCD、XYZ、LAS 或 LAZ。系统会按原始字节保存文件,在后台生成独立 RGB/XYZ 标注预览,不会改写已有数据源。</p>
|
<a-upload accept=".ply,.pcd,.xyz,.xyzn,.xyzrgb,.las,.laz" :file-list="annotationSourceFile ? [{ uid: annotationSourceFile.name, name: annotationSourceFile.name, status: 'done' as const }] : []" :before-upload="beforeAnnotationSourceUpload" @remove="removeAnnotationSourceUpload"><a-button><UploadOutlined />选择点云文件</a-button></a-upload>
|
</a-modal>
|
<a-modal v-model:open="annotationClassModalOpen" title="管理标签分类" :footer="null" width="680px">
|
<p>标签编码自动分配到 LAS 兼容的 1-255 范围。已保存标注使用的自定义类别不能删除,避免影响历史版本和训练。</p>
|
<a-space wrap class="annotation-class-create">
|
<a-input v-model:value="annotationClassLabel" placeholder="中文名称,例如:变压器" />
|
<a-input v-model:value="annotationClassKey" placeholder="英文 key,例如:transformer" />
|
<input v-model="annotationClassColor" aria-label="标签颜色" type="color" class="annotation-color-input" />
|
<a-button type="primary" :loading="annotationClassSubmitting" :disabled="!annotationClassLabel.trim() || !annotationClassKey.trim()" @click="createAnnotationClass"><PlusOutlined />新增分类</a-button>
|
</a-space>
|
<a-list size="small" bordered :data-source="annotationClasses" class="annotation-class-list"><template #renderItem="{ item }"><a-list-item><a-space><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" /> <span>{{ item.label }}</span><a-typography-text type="secondary">{{ item.key }} / {{ item.code }}</a-typography-text></a-space><template #actions><a-popconfirm v-if="!item.builtIn" title="删除后无法恢复这个未使用的分类,确认删除?" ok-text="删除" cancel-text="取消" @confirm="deleteAnnotationClass(item.code)"><a-button size="small" danger :loading="annotationClassDeletingCode === item.code">删除</a-button></a-popconfirm><a-tag v-else>内置</a-tag></template></a-list-item></template></a-list>
|
</a-modal>
|
<a-modal v-model:open="annotationSourceRemovalOpen" title="移除完整数据链路" ok-text="移除全部关联数据" ok-type="danger" cancel-text="取消" :confirm-loading="annotationSourceRemoving" @ok="removeAnnotationSourceChain">
|
<a-alert type="error" show-icon message="此操作不可恢复" description="将删除本地工作台中该数据源的全部关联副本、结果和依赖产物;不会删除 baseData 或其他外部输入。" />
|
<a-descriptions v-if="annotationSourceRemovalPlan" class="annotation-removal-summary" size="small" :column="1" bordered>
|
<a-descriptions-item label="数据源">{{ annotationSourceRemovalPlan.label }}</a-descriptions-item>
|
<a-descriptions-item label="生成结果目录">{{ annotationSourceRemovalPlan.outputDirectories }}</a-descriptions-item>
|
<a-descriptions-item label="原始上传副本">{{ annotationSourceRemovalPlan.rawDirectories }}</a-descriptions-item>
|
<a-descriptions-item label="处理副本">{{ annotationSourceRemovalPlan.processedDirectories }}</a-descriptions-item>
|
<a-descriptions-item label="标注版本">{{ annotationSourceRemovalPlan.annotationRevisions }}</a-descriptions-item>
|
<a-descriptions-item label="训练结果">{{ annotationSourceRemovalPlan.trainingRuns }}</a-descriptions-item>
|
<a-descriptions-item label="模型推理结果">{{ annotationSourceRemovalPlan.inferenceRuns }}</a-descriptions-item>
|
<a-descriptions-item v-if="annotationSourceRemovalPlan.siblingSources > 1" label="同一运行的其他标注源">{{ annotationSourceRemovalPlan.siblingSources - 1 }} 个,也会一并移除</a-descriptions-item>
|
</a-descriptions>
|
<p v-if="annotationSourceRemovalPlan?.preservesExternalInputs" class="annotation-removal-note">该来源没有由控制台保存的原始上传副本;仅移除当前工作台生成的结果和关联产物,外部输入保持不变。</p>
|
</a-modal>
|
<section class="workspace-command pointcloud-command">
|
<div v-if="workflow === 'photo'"><h2>新建照片三维重建</h2><p>上传同一架次、同一相机的 JPG/JPEG 序列,在 CPU 上生成稠密点云、网格与纹理预览。</p></div>
|
<div v-else-if="workflow === 'geometry'"><h2>新建点云几何处理</h2><p>上传已有点云或重建导出物,生成 DSM、高出地物足迹、栅格和几何结果。</p></div>
|
<div v-else-if="workflow === 'semantic'"><h2>新建点云语义分类</h2><p>上传 LAS/LAZ/PLY/PCD/XYZ,输出地面、植被、构筑物、电线候选、杆塔候选和未知类别;每类均需人工核验。</p></div>
|
<div v-else><h2>标注、训练与模型应用</h2><p>从已有 RGB 点云标注真值,训练本机模型,并将训练模型应用到新的带 RGB 点云。</p></div>
|
<a-button v-if="workflow !== 'model'" type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button>
|
</section>
|
<section v-if="showRunForm && workflow !== 'model'" class="surface-section pointcloud-run-form">
|
<template v-if="workflow === 'photo'">
|
<a-alert type="info" show-icon message="单次选择 3 至 1000 张同一架次、同一相机的 JPG/JPEG。原图按字节保存,后台异步执行稀疏 SfM、CPU 稠密点云、网格和纹理;大批量照片可能需要数小时或更久。有每张照片 GPS/RTK 时再打开下方先验。" />
|
<a-upload multiple accept=".jpg,.jpeg" :file-list="photoFiles.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforePhotoUpload" @remove="removePhotoFile"><a-button><UploadOutlined />选择重建照片</a-button></a-upload>
|
<a-switch v-model:checked="usePhotoPositionPriors" checked-children="GPS/RTK 先验" un-checked-children="通用配对" />
|
</template>
|
<template v-else>
|
<a-alert type="info" show-icon :message="workflow === 'semantic' ? '单次最多 2 份 PLY/PCD/XYZ/LAS/LAZ。大文件按二进制上传并保留原始字节;语义输出为可解释 CPU 规则候选,不是训练模型结论。' : '单次最多 2 份 PLY/PCD/XYZ/LAS/LAZ。源文件会按字节保存到独立运行目录,并输出 DSM、高出地物栅格和 GeoAI 足迹矢量。'" />
|
<a-upload multiple accept=".ply,.pcd,.xyz,.xyzn,.xyzrgb,.las,.laz" :file-list="files.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforeUpload" @remove="removeFile"><a-button><UploadOutlined />选择点云</a-button></a-upload>
|
</template>
|
<a-alert v-if="runError" type="error" show-icon :message="runError" />
|
<a-button v-if="workflow === 'photo'" type="primary" :loading="photoSubmitting" :disabled="photoFiles.length < 3" @click="submitPhotoReconstruction"><PlayCircleOutlined />开始照片重建</a-button>
|
<a-button v-else type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />{{ workflow === 'semantic' ? '开始语义分类' : '开始点云几何处理' }}</a-button>
|
</section>
|
<a-alert v-if="photoJob" class="pointcloud-photo-job" :type="photoJob.status === 'failed' ? 'error' : photoJob.status === 'complete' ? 'success' : 'info'" show-icon :message="photoJobMessage">
|
<template #description><div class="photo-job-progress"><a-progress :percent="photoJobProgress.percent" :status="photoJob.status === 'failed' ? 'exception' : photoJob.status === 'complete' ? 'success' : 'active'" /><span>{{ photoJobProgress.message }}</span><small>阶段里程碑进度,不代表剩余时间;原生 COLMAP/OpenMVS 在单个计算节点内不提供可靠的细粒度百分比。</small></div></template>
|
</a-alert>
|
|
<template v-if="currentCase && selectedCloud">
|
<a-row v-if="workflow === 'geometry'" :gutter="[18, 18]" class="pointcloud-workspace">
|
<a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></a-list-item></template></a-list></section></a-col>
|
<a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="(currentCase.run.point_clouds ?? []).map((item) => ({ value: item.file, label: item.file }))" /><span>{{ selectedCloud.raster_width }} x {{ selectedCloud.raster_height }} 本地坐标栅格</span></div><div class="comparison-grid pointcloud-images"><figure><figcaption>DSM 与高出地物栅格</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.preview_file}`)" /></figure><figure><figcaption>高出地物足迹矢量</figcaption><svg class="pointcloud-vector" :viewBox="vectorViewBox" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg></figure></div></section></a-col>
|
</a-row>
|
<section v-if="workflow === 'semantic' && selectedCloud.semantic_preview_point_cloud" class="result-band">
|
<a-row :gutter="[18, 18]" class="pointcloud-workspace">
|
<a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>语义分类案例</h2><a-input-search v-model:value="searchText" placeholder="搜索分类案例" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></a-list-item></template></a-list></section></a-col>
|
<a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>语义分类结果</h2><p>{{ selectedCloud.semantic_method }}。颜色:棕色地面、绿色植被、橙色构筑物、紫色杆塔候选、黄色电线候选、灰色未知。</p></div><a-tag color="gold">人工核验候选</a-tag></div><SparsePointCloudViewer :source="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_preview_point_cloud}`)" /><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item v-for="(count, key) in selectedCloud.semantic_class_counts" :key="key" :label="String(key)">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions></section></a-col>
|
</a-row>
|
<section class="surface-section result-band"><div class="section-heading"><div><h2>分类结果与下载</h2><p>规则分类用于人工复核,不是资产台账或巡检结论。</p></div></div><a-space wrap><a-button v-if="selectedCloud.semantic_preview_point_cloud" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_preview_point_cloud}`)" target="_blank"><DownloadOutlined />语义预览 PLY</a-button><a-button v-if="selectedCloud.semantic_classified_las" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_classified_las}`)" target="_blank"><DownloadOutlined />语义分类 LAS</a-button><a-button v-if="selectedCloud.semantic_summary_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_summary_file}`)" target="_blank"><FileOutlined />语义统计 CSV</a-button><a-button v-if="selectedCloud.semantic_vector_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_vector_file}`)" target="_blank"><FileOutlined />语义候选 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
|
</section>
|
<section v-if="workflow === 'model' && annotationSource" class="surface-section result-band">
|
<div class="section-heading"><div><h2>人工标注与监督训练</h2><p>仅保存你刷选确认的真值点;规则候选颜色不会写入训练标签。融合标注使用原始 RGB/XYZ 与照片特征的局部注意力基线,当前不冒充为官方 Point Transformer V3。任务会自动探测并优先使用本机可用 GPU,未通过检测时回退 CPU。</p></div><a-tag color="blue">自动 GPU / CPU</a-tag></div>
|
<a-space wrap class="annotation-source-row"><span>标注源</span><a-select v-model:value="annotationSourceId" :options="annotationSources.map((item) => ({ value: item.id, label: item.label }))" /><a-tooltip title="移除选中数据源的完整数据链路"><a-button danger :loading="annotationSourceRemovalLoading" :disabled="!annotationSourceId" aria-label="移除完整数据链路" @click="openAnnotationSourceRemoval"><DeleteOutlined /></a-button></a-tooltip><span>计算设备</span><a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" /></a-space>
|
<a-space wrap class="annotation-management-row">
|
<a-button @click="annotationSourceModalOpen = true"><PlusOutlined />新增标注源</a-button>
|
<a-button @click="annotationClassModalOpen = true">管理标签分类</a-button>
|
<a-tag v-for="item in annotationClasses" :key="item.code"><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" />{{ item.label }}</a-tag>
|
</a-space>
|
<a-alert type="info" show-icon :message="`标注底图:${annotationSource.sourceKind}`" :description="annotationSourceDescription" />
|
<a-alert v-if="annotationNotice" :type="annotationNotice.type" show-icon :message="annotationNotice.message" />
|
<a-alert v-if="annotationSourceJob" :type="annotationSourceJob.status === 'failed' ? 'error' : annotationSourceJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`标注源处理任务:${annotationSourceJob.status}`" :description="annotationSourceJob.error || `正在处理 ${annotationSourceJob.inputName};原始文件会保留,完成后生成独立可标注预览。`" />
|
<PointCloudAnnotationViewer :source="annotationSource.url" :source-id="annotationSource.id" :classes="annotationClasses" :disabled="annotationSaving" @save="saveAnnotation" />
|
<section class="model-inference-result auto-annotation-workspace">
|
<div class="section-heading"><div><h3>自动标注当前源</h3><p>直接对当前标注源生成模型候选,无需再次上传点云。仅保留达到置信度阈值的预测;生成完成后必须确认合并,人工标签始终优先。</p></div><a-tag color="gold">候选待确认</a-tag></div>
|
<a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚无可用于自动标注的已完成模型" description="先从一个人工标注版本启动训练,训练完成后模型会自动出现在此处。" />
|
<a-space v-else wrap class="model-inference-controls">
|
<a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" />
|
<SemanticModelDeletionControl :model-id="selectedSemanticModelId" :label="selectedSemanticModel?.label ?? selectedSemanticModelId" :disabled="!selectedSemanticModelId" @removed="removeSemanticModel" />
|
<label>最低置信度 <a-input-number v-model:value="autoAnnotationThreshold" :min="0.5" :max="0.999" :step="0.01" :precision="3" /></label>
|
<a-button type="primary" :loading="autoAnnotationSubmitting" :disabled="!selectedSemanticModelId" @click="startAutoAnnotation"><PlayCircleOutlined />自动标注当前源</a-button>
|
</a-space>
|
<a-alert v-if="selectedSemanticModel" class="model-inference-model" type="info" show-icon :message="`模型测试 F1:${Object.entries(selectedSemanticModel.testF1).map(([key, score]) => `${key} ${score.toFixed(3)}`).join(';') || '未记录可用测试指标'}`" description="指标仅覆盖已有人工标注的空间分块。类别在测试分块中没有样本时,F1 不可用于判断该类别的实际效果。" />
|
<a-alert v-if="autoAnnotationJob" :type="autoAnnotationJob.status === 'failed' ? 'error' : autoAnnotationJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`自动标注任务:${autoAnnotationJob.status}(${executionLabel(autoAnnotationJob)})`" :description="autoAnnotationJob.error || (autoAnnotationJob.status === 'complete' ? `已生成高置信度候选,尚未写入人工训练标注。` : `正在对当前 ${annotationSource.pointCount.toLocaleString()} 点源进行后台推理。`)" />
|
<template v-if="autoAnnotationJob?.status === 'complete'">
|
<a-descriptions v-if="autoAnnotationSummary" size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item label="输入点数">{{ autoAnnotationSummary.input_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="候选阈值">{{ autoAnnotationSummary.automatic_annotation?.candidate_confidence }}</a-descriptions-item><a-descriptions-item label="高置信候选">{{ autoAnnotationSummary.automatic_annotation?.candidate_count?.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in autoAnnotationSummary.class_counts" :key="String(code)" :label="`${annotationClassDisplayLabel(code)}:预测总数`">{{ count.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in autoAnnotationSummary.automatic_annotation?.candidate_class_counts" :key="`candidate-${String(code)}`" :label="`${annotationClassDisplayLabel(code)}:高置信候选`">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions>
|
<section v-if="autoAnnotationJob.preview" class="auto-annotation-preview"><div class="section-heading"><div><h3>候选审阅与修正</h3><p>选择“拒绝候选”后用笔刷或框选剔除错分点;选择正确类别可改类。保存的修正独立于人工标注,最终合并时人工标注仍优先。</p></div><a-tag color="gold">未合并</a-tag></div><a-space wrap class="annotation-management-row"><a-tag v-for="item in annotationClasses.filter((item) => autoAnnotationSummary?.class_counts?.[String(item.code)] !== undefined)" :key="`legend-${item.code}`"><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" />{{ item.label }}</a-tag></a-space><PointCloudAnnotationViewer :source="artifactUrl(autoAnnotationJob.preview)" :source-id="annotationSource.id" :classes="annotationClasses" :review-mode="true" :initial-review-changes="autoAnnotationReviewChanges" :disabled="autoAnnotationReviewSaving" @review-save="saveAutoAnnotationReview" /></section>
|
<a-space wrap><a-button v-if="autoAnnotationJob.preview" type="link" :href="artifactUrl(autoAnnotationJob.preview)" target="_blank"><DownloadOutlined />预测预览 PLY</a-button><a-button v-if="autoAnnotationJob.candidateFile" type="link" :href="artifactUrl(autoAnnotationJob.candidateFile)" target="_blank"><FileOutlined />候选与置信度 JSON</a-button><a-popconfirm title="确认后会创建新的标注版本,并把高置信候选与当前人工标注合并;人工标签优先。" ok-text="确认合并" cancel-text="取消" @confirm="acceptAutoAnnotation"><a-button type="primary" :loading="autoAnnotationAccepting">确认合并候选</a-button></a-popconfirm></a-space>
|
</template>
|
</section>
|
<a-divider />
|
<div class="section-heading"><div><h3>当前标注源的已保存版本</h3><p>切换标注源后只显示对应版本。至少两个类别、每类 500 个用户确认点后可启动训练;融合样本还要求每个类别覆盖训练、验证和测试 XY 区域,避免相邻线路或塔体点泄漏到测试集。</p></div></div>
|
<a-alert v-if="!selectedSourceAnnotations.length" type="warning" show-icon message="当前融合样本尚未保存标注版本" description="先选择类别,用笔刷或框选标出确认点,然后点击点云面板右下角的“保存标注版本”。保存成功后,此处会出现“多视角特征训练”按钮。" />
|
<a-list v-else size="small" :data-source="selectedSourceAnnotations"><template #renderItem="{ item }"><a-list-item><a-space wrap><span>{{ item.id }}</span><span>{{ annotationSourceLabel(item.sourceId) }}</span><span>{{ item.labelCount.toLocaleString() }} 点</span><a-button size="small" type="primary" @click="startTraining(item.id)">{{ annotationTrainerLabel(item) }}</a-button><a-popconfirm title="删除后不能恢复该标注版本,确认删除?" ok-text="删除" cancel-text="取消" @confirm="deleteAnnotation(item.id)"><a-tooltip title="删除标注版本"><a-button size="small" danger :loading="annotationDeletingId === item.id" aria-label="删除标注版本"><DeleteOutlined /></a-button></a-tooltip></a-popconfirm></a-space></a-list-item></template></a-list>
|
<a-alert v-if="trainingJob" :type="trainingJob.status === 'failed' ? 'error' : trainingJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`训练任务:${trainingJob.status}(${executionLabel(trainingJob)})`" :description="trainingJob.error || (trainingJob.preview ? `已生成预测预览:${trainingJob.preview}` : `后台${executionLabel(trainingJob)}训练中,可继续浏览案例。`)" />
|
<a-divider />
|
<div class="section-heading"><div><h3>应用训练模型</h3><p>选择本机已完成的模型,上传一份新的带 RGB 点云,自动优先使用可用 GPU 生成预测候选。XYZ-only 输入会明确拒绝,不会伪造颜色特征。</p></div><a-tag color="blue">自动 GPU / CPU</a-tag></div>
|
<a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚未发现可用训练模型。先完成并保留一次监督训练。" />
|
<a-space v-else wrap class="model-inference-controls">
|
<a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" />
|
<SemanticModelDeletionControl :model-id="selectedSemanticModelId" :label="selectedSemanticModel?.label ?? selectedSemanticModelId" :disabled="!selectedSemanticModelId" @removed="removeSemanticModel" />
|
<a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" />
|
<a-upload accept=".ply,.pcd,.las,.laz" :file-list="inferenceFile ? [{ uid: inferenceFile.name, name: inferenceFile.name, status: 'done' as const }] : []" :before-upload="beforeInferenceUpload" @remove="removeInferenceUpload"><a-button><UploadOutlined />选择待预测点云</a-button></a-upload>
|
<a-button type="primary" :loading="inferenceSubmitting" :disabled="!selectedSemanticModelId || !inferenceFile" @click="applySemanticModel"><PlayCircleOutlined />应用模型</a-button>
|
</a-space>
|
<a-alert v-if="selectedSemanticModel" class="model-inference-model" type="info" show-icon :message="`测试 F1:${Object.entries(selectedSemanticModel.testF1).map(([key, score]) => `${key} ${score.toFixed(3)}`).join(';') || '该模型未记录可用测试指标'}`" description="测试指标仅来自该标注源的空间分块,不能代表新场景准确率;当前杆塔类别仍有较高误报风险,必须人工核验。" />
|
<a-alert v-if="inferenceJob" :type="inferenceJob.status === 'failed' ? 'error' : inferenceJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`模型应用任务:${inferenceJob.status}(${executionLabel(inferenceJob)})`" :description="inferenceJob.error || (inferenceJob.status === 'complete' ? `已完成 ${inferenceJob.inputName} 的分类候选。` : `后台${executionLabel(inferenceJob)}推理中,可继续浏览案例。`)" />
|
<section v-if="inferenceJob?.status === 'complete' && inferenceJob.preview" class="model-inference-result">
|
<SparsePointCloudViewer :source="artifactUrl(inferenceJob.preview)" />
|
<a-descriptions v-if="inferenceSummary" size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item label="输入点数">{{ inferenceSummary.input_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="预览点数">{{ inferenceSummary.preview_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in inferenceSummary.class_counts" :key="String(code)" :label="String(code)">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions>
|
<a-space wrap><a-button type="link" :href="artifactUrl(inferenceJob.preview)" target="_blank"><DownloadOutlined />预测预览 PLY</a-button><a-button v-if="inferenceJob.classifiedLas" type="link" :href="artifactUrl(inferenceJob.classifiedLas)" target="_blank"><DownloadOutlined />分类 LAS</a-button><a-button v-if="inferenceJob.classCounts" type="link" :href="artifactUrl(inferenceJob.classCounts)" target="_blank"><FileOutlined />类别统计 CSV</a-button><a-button v-if="inferenceJob.summary" type="link" :href="artifactUrl(inferenceJob.summary)" target="_blank"><FileOutlined />预测摘要 JSON</a-button><a-button v-if="inferenceJob.metadata" type="link" :href="artifactUrl(inferenceJob.metadata)" target="_blank"><FileOutlined />运行元数据</a-button><a-button v-if="selectedSemanticModel" type="link" :href="artifactUrl(selectedSemanticModel.model)" target="_blank"><DownloadOutlined />模型权重</a-button></a-space>
|
</section>
|
</section>
|
<a-row v-if="workflow === 'geometry'" :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><div class="section-heading"><div><h2>点云与几何结果</h2><p>坐标为点云本地坐标;不是经纬度或测绘精度结果。</p></div><a-tag color="orange">{{ selectedCloud.elevated_footprint_count }} 个足迹</a-tag></div><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="原始点">{{ selectedCloud.original_points }}</a-descriptions-item><a-descriptions-item label="降采样点">{{ selectedCloud.downsampled_points }}</a-descriptions-item><a-descriptions-item label="地面内点">{{ selectedCloud.ground_inliers }}</a-descriptions-item><a-descriptions-item label="高出点">{{ selectedCloud.elevated_points }} ({{ (selectedCloud.elevated_point_ratio * 100).toFixed(1) }}%)</a-descriptions-item><a-descriptions-item label="网格三角形">{{ selectedCloud.mesh_triangles }}</a-descriptions-item><a-descriptions-item label="矢量化">{{ selectedCloud.vectorizer }}</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>固定处理参数</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="体素尺寸">{{ currentCase.run.thresholds.voxel_size_local_units }}</a-descriptions-item><a-descriptions-item label="地面 RANSAC 距离">{{ currentCase.run.thresholds.ground_plane_distance }}</a-descriptions-item><a-descriptions-item label="高出地物阈值">{{ currentCase.run.thresholds.elevated_height }}</a-descriptions-item><a-descriptions-item label="耗时">{{ selectedCloud.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col></a-row>
|
<section v-if="workflow === 'geometry'" class="surface-section result-band"><div class="section-heading"><div><h2>几何处理下载</h2><p>{{ currentCase.note }}</p></div></div><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 5 }"><a-descriptions-item label="能力边界">{{ currentCase.run.classification }}</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="Open3D">{{ currentCase.run.versions.open3d }}</a-descriptions-item><a-descriptions-item label="GeoAI">{{ currentCase.run.versions['geoai-py'] }}</a-descriptions-item><a-descriptions-item label="处理点云">{{ currentCase.run.processed_point_clouds }}</a-descriptions-item></a-descriptions><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.classified_point_cloud}`)" target="_blank"><DownloadOutlined />几何分类点云 PLY</a-button><a-button v-if="selectedCloud.mesh_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.mesh_file}`)" target="_blank"><FileOutlined />近似网格 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.dsm_file}`)" target="_blank"><FileImageOutlined />DSM GeoTIFF</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.vector_file}`)" target="_blank"><FileOutlined />高出地物 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.summary_file}`)" target="_blank"><FileOutlined />汇总 CSV</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
|
</template>
|
<template v-else-if="currentCase && densePhotoRun">
|
<a-row :gutter="[18, 18]" class="pointcloud-workspace">
|
<a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></a-list-item></template></a-list></section></a-col>
|
<a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>CPU 稠密重建与纹理网格</h2><p>拖动旋转、滚轮缩放。模型为局部 SfM 坐标,未经过控制点测绘配准。</p></div><a-space><a-tag color="green">CPU MVS</a-tag><a-button type="primary" size="small" :loading="running" @click="classifyDenseResult"><PlayCircleOutlined />对此稠密点云分类</a-button></a-space></div><a-segmented v-if="densePhotoRun.geometry_preview_model_file" v-model:value="densePreviewMode" :options="[{ label: '可靠纹理', value: 'textured' }, { label: '点云颜色', value: 'point_colors', disabled: !densePhotoRun.point_color_preview_model_file }, { label: '完整几何', value: 'geometry' }]" /><TexturedMeshViewer :source="artifactUrl(`${currentCase.artifactRoot}/${densePreviewSource}`)" :surface="densePreviewMode" /></section></a-col>
|
</a-row>
|
<a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><h2>重建统计</h2><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="输入照片">{{ densePhotoRun.input_images }}</a-descriptions-item><a-descriptions-item label="注册照片">{{ densePhotoRun.registered_images }}</a-descriptions-item><a-descriptions-item label="稀疏点">{{ densePhotoRun.sparse_points.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="稠密点">{{ densePhotoRun.dense_points.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="原始网格顶点">{{ densePhotoRun.mesh_vertices.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="原始网格面">{{ densePhotoRun.mesh_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.preview_mesh_faces" label="可靠纹理面">{{ densePhotoRun.preview_mesh_faces.toLocaleString() }} ({{ ((densePhotoRun.preview_mesh_face_ratio ?? 0) * 100).toFixed(1) }}%)</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.point_color_preview_faces" label="点云颜色面">{{ densePhotoRun.point_color_preview_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.geometry_preview_faces" label="完整几何面">{{ densePhotoRun.geometry_preview_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.elapsed_seconds !== null" label="耗时">{{ densePhotoRun.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>结果边界</h2><p>可靠纹理只显示有一致照片证据的面,孔洞代表纹理覆盖不足。点云颜色将最近融合点的真实 RGB 投影到完整网格,显示连续表面但不等同逐面照片纹理。完整几何使用中性材质,不补造颜色或纹理;它用于核验几何连续性。</p></section></a-col></a-row>
|
<section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.textured_model_file}`)" target="_blank"><DownloadOutlined />可靠纹理 GLB</a-button><a-button v-if="densePhotoRun.point_color_preview_model_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.point_color_preview_model_file}`)" target="_blank"><DownloadOutlined />点云颜色 GLB</a-button><a-button v-if="densePhotoRun.geometry_preview_model_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.geometry_preview_model_file}`)" target="_blank"><DownloadOutlined />完整几何 GLB</a-button><a-button v-if="densePhotoRun.texture_preview_filter_report" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.texture_preview_filter_report}`)" target="_blank"><FileOutlined />纹理过滤报告</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.dense_point_cloud_file}`)" target="_blank"><FileOutlined />稠密点云 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.mesh_file}`)" target="_blank"><FileOutlined />原始网格 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${densePhotoRun.texture_file}`)" target="_blank"><FileImageOutlined />纹理图</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
|
</template>
|
<template v-else-if="currentCase && photoRun">
|
<a-row :gutter="[18, 18]" class="pointcloud-workspace">
|
<a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col>
|
<a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>影像稀疏重建点云</h2><p>顶点颜色点云。鼠标拖动旋转,滚轮缩放;坐标没有绝对尺度或方向。</p></div><a-tag color="blue">CPU SfM</a-tag></div><SparsePointCloudViewer :source="artifactUrl(`${currentCase.artifactRoot}/${photoRun.point_cloud_file}`)" /></section></a-col>
|
</a-row>
|
<a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><h2>重建统计</h2><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="输入照片">{{ photoRun.input_images }}</a-descriptions-item><a-descriptions-item label="注册照片">{{ photoRun.registered_images }}</a-descriptions-item><a-descriptions-item label="稀疏点">{{ photoRun.sparse_points }}</a-descriptions-item><a-descriptions-item label="平均重投影误差">{{ photoRun.mean_reprojection_error_pixels }} px</a-descriptions-item><a-descriptions-item label="相机组">{{ photoRun.camera_count }}</a-descriptions-item><a-descriptions-item label="耗时">{{ photoRun.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>坐标与限制</h2><p>本次仅完成稀疏 SfM。当前机器无 CUDA,不能运行 pycolmap 的稠密 PatchMatch;GPS 保留在输入清单中,但未用于宣称测绘级地理配准。</p></section></a-col></a-row>
|
<section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.point_cloud_file}`)" target="_blank"><DownloadOutlined />稀疏点云 PLY</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.camera_pose_file}`)" target="_blank"><FileOutlined />相机位姿 CSV</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${photoRun.input_manifest_file}`)" target="_blank"><FileOutlined />输入 GPS 清单</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section>
|
</template>
|
</template>
|
|
<style scoped>
|
.pointcloud-run-form { display: grid; gap: 16px; margin-bottom: 24px; }
|
.pointcloud-workflow-tabs { margin-bottom: 16px; }
|
.pointcloud-photo-job { margin-bottom: 24px; }
|
.pointcloud-workspace, .result-band { margin-bottom: 24px; }
|
.pointcloud-images figure { min-width: 0; }
|
.pointcloud-images :deep(.ant-image), .pointcloud-images :deep(img) { width: 100%; height: 360px; object-fit: contain; background: #171e1a; }
|
.pointcloud-vector { width: 100%; height: 360px; border: 1px solid #d9d9d9; background: #f7f8f9; }
|
.pointcloud-vector path { fill: rgba(230, 90, 45, 0.24); stroke: #bf4c20; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
|
.photo-job-progress { display: grid; gap: 6px; margin-top: 8px; }
|
.photo-job-progress small { color: #6a7885; }
|
.model-inference-controls { display: flex; margin-bottom: 12px; }
|
.model-select { min-width: min(100%, 440px); }
|
.model-inference-model { margin-bottom: 12px; }
|
.model-inference-result { display: grid; gap: 16px; margin-top: 16px; }
|
.auto-annotation-preview { display: grid; gap: 12px; }
|
.annotation-management-row { display: flex; margin: 12px 0; }
|
.annotation-class-create { display: flex; margin: 16px 0; }
|
.annotation-class-create :deep(.ant-input) { width: 190px; }
|
.annotation-class-list { max-height: 340px; overflow: auto; }
|
.annotation-class-swatch { display: inline-block; width: 14px; height: 14px; border: 1px solid rgba(0, 0, 0, 0.22); vertical-align: -2px; }
|
.annotation-color-input { width: 34px; height: 32px; padding: 2px; border: 1px solid #d9d9d9; background: #fff; }
|
.annotation-removal-summary { margin-top: 16px; }
|
.annotation-removal-note { margin: 12px 0 0; color: #6a7885; }
|
@media (max-width: 1199px) { .pointcloud-images :deep(img), .pointcloud-vector { height: 300px; } }
|
</style>
|