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