shuishen
6 days ago faf5be476037d3baf4da3515789906a176f9b040
feat(pointcloud): auto-select verified GPU runtime
9 files modified
241 ■■■■ changed files
PROJECT_CONTEXT.md 26 ●●●● patch | view | raw | blame | history
apps/workbench-console/README.md 17 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/api/artifacts.ts 6 ●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/PointCloudPanel.vue 16 ●●●●● patch | view | raw | blame | history
capabilities/05-3d-pointcloud/README.md 23 ●●●● patch | view | raw | blame | history
capabilities/05-3d-pointcloud/apply_pointcloud_semantic_model.py 7 ●●●●● patch | view | raw | blame | history
capabilities/05-3d-pointcloud/train_pointcloud_semantic_model.py 49 ●●●●● patch | view | raw | blame | history
scripts/serve_workbench_console.py 65 ●●●● patch | view | raw | blame | history
tests/test_serve_workbench_console.py 32 ●●●●● patch | view | raw | blame | history
PROJECT_CONTEXT.md
@@ -1,6 +1,6 @@
# GeoAI Workbench Current Context
Last updated: 2026-08-24
Last updated: 2026-08-25
This file is the current project snapshot for new Codex tasks. Keep it concise and replace stale facts instead of appending a conversation diary.
@@ -18,7 +18,7 @@
- The user is a beginner and can invest about 7-8 hours per day in running Demos and giving visual feedback.
- Codex is expected to handle environment setup, code, model selection, debugging, verification, and iteration.
- The machine runs Windows and PowerShell with 64 GB RAM and an AMD RX 590 GME 8 GB GPU. There is no NVIDIA CUDA, so current Demos use CPU inference.
- The machine runs Windows and PowerShell with 64 GB RAM and an NVIDIA GeForce RTX 3050 Laptop GPU (8 GB VRAM, compute capability 8.6). Driver 572.70 supports CUDA 12.8. `nvcc` is not installed; prebuilt CUDA PyTorch works, but CUDA COLMAP/OpenMVS compilation remains a separate task.
- Use Python 3.12 for capability environments. The system also has Python 3.13, which must not replace or contaminate project environments.
- `geoai-py` is MIT-licensed, but every dependency, dataset, and model weight needs a separate commercial-license check.
- Current JPEG samples have no usable georeferencing. Their detections use pixel coordinates; accurate GeoJSON requires a georeferenced GeoTIFF.
@@ -329,10 +329,13 @@
- Boundary: B. `geoai-py` remains limited to elevated-raster vectorization;
  Open3D, NumPy, scikit-learn and PyTorch implement annotation/training.
- Environment: `.venvs/05-3d-pointcloud` has Python 3.12, `torch 2.13.0+cpu`
  and `scikit-learn 1.9.0`; CUDA is unavailable locally. The shared-MLP model
  supports `auto`, `cpu`, and `cuda`, making a future GPU server an acceleration
  deployment rather than a different workflow.
- Environments: retained `.venvs/05-3d-pointcloud` has Python 3.12,
  `torch 2.13.0+cpu`, and `scikit-learn 1.9.0`. New
  `.venvs/05-3d-pointcloud-gpu` retains compatible packages through `.pth` and
  adds `torch 2.11.0+cu128` / `torchvision 0.26.0+cu128`; CUDA tensor and model
  probes pass on the RTX 3050. The console chooses only this fixed GPU
  interpreter after a CUDA probe, otherwise falls back to its fixed CPU
  interpreter. It records actual device, environment and torch version in jobs.
- Truth contract: generated annotation PLYs preserve observed RGB/XYZ. Rule
  colours are never labels. Separate revisions record source SHA-256, artifact,
  indices, class codes and class counts below `shared/outputs/05-3d-pointcloud/annotations/`.
@@ -347,6 +350,14 @@
  unchanged `baseData/las/part_01.las` in 73.586 seconds and generated a 400,000
  point RGB annotation source. Its 3,183 pole/tower and 8,162 power-line rule
  candidates are not truth.
- GPU verification 2026-08-25: annotation `annotation-20260824-023335-48e3bc`
  trained as `semantic-model-gpu-best-validation-20260825` on CUDA in 3.961 s.
  The best checkpoint was epoch 10 selected only by validation macro F1. Its
  held-out spatial-test F1 is vegetation 0.979, pole/tower 0.457, power line
  0.736 (macro 0.724), compared with the retained CPU baseline 0.979, 0.348,
  0.815 (macro 0.714). CUDA inference of 400,000 RGB points completed in 1.554 s
  and recorded actual `cuda` processing metadata. This does not prove field-wide
  accuracy; power-line performance declined and all outputs remain review candidates.
- Console: restarted at `http://127.0.0.1:6188`; root HTTP and annotation-source
  API checks passed. The annotation viewer now uses sRGB-correct anti-aliased
  circular points and separates browse left-drag rotation from brush/rectangle
@@ -361,7 +372,8 @@
- Applied-model workflow: the supervised area now discovers only complete local
  `training-runs/*/{model.pt,metrics.json}` records. A user selects a model,
  uploads one RGB PLY/PCD/LAS/LAZ input, and the local-only server copies bytes
  to a new raw/processed inference run before launching the fixed CPU script.
  to a new raw/processed inference run before launching the fixed GPU interpreter
  when its CUDA probe passes, otherwise the fixed CPU interpreter.
  It exposes a polling job, in-page PLY prediction preview, classified LAS,
  class-count CSV, summary JSON, metadata, and weight download. Browser paths,
  arbitrary model paths, and XYZ-only inputs are rejected. Outputs are review
apps/workbench-console/README.md
@@ -153,17 +153,20 @@
currently displayed orientation.
An incorrect revision can be removed after confirmation, but the endpoint only
removes that revision directory and cannot remove LAS/PLY sources, semantic
results, or training outputs. A local CPU training job requires two classes with
at least 500 confirmed points each, then writes a portable model, metrics, and
predicted PLY asynchronously. Sources, point indices, class codes, Python
interpreter and command are all server-fixed; the same model pipeline can later
use CUDA without data or code changes.
results, or training outputs. A local training job requires two classes with at
least 500 confirmed points each, then writes a portable model, metrics, and
predicted PLY asynchronously. The server probes only the fixed
`05-3d-pointcloud-gpu` interpreter; when CUDA is available it uses that
environment, otherwise it uses the retained fixed CPU interpreter. Sources,
point indices, class codes, Python interpreters and commands are all server-fixed.
Each job reports the selected device, environment, and PyTorch version.
The supervised area also exposes **Apply trained model**. It discovers only
complete local training directories containing both `model.pt` and `metrics.json`.
Select a discovered model, upload one new RGB PLY/PCD/LAS/LAZ point cloud, and
the server copies its bytes into new `raw` and `processed` inference directories
before starting a background CPU job. The API accepts neither a browser path nor
before starting a background job that automatically selects the verified GPU or
CPU environment. The API accepts neither a browser path nor
an arbitrary model path. Completion shows the classified PLY preview directly in
the workspace and provides downloads for classified LAS, class-count CSV,
prediction JSON, metadata, and model weight. XYZ-only input fails explicitly:
@@ -181,7 +184,7 @@
3. **Point-cloud semantic classification**: classification cases, coloured
   point preview, semantic LAS/CSV/GeoJSON and review-only rule outputs.
4. **Annotation, training, and model application**: RGB annotation source,
   annotation revisions, CPU training, training metrics, and applying a trained
   annotation revisions, automatic GPU/CPU training, training metrics, and applying a trained
   model to a new RGB cloud.
Each workspace filters the case library to its own purpose. A point-cloud run
apps/workbench-console/src/api/artifacts.ts
@@ -51,9 +51,9 @@
export interface PhotoReconstructionJob { id: string; runId: string; inputImages: number; usePositionPriors: boolean; status: "queued" | "running" | "complete" | "failed"; stage: "queued" | "sparse_sfm" | "dense_mvs" | "complete" | "failed"; createdAt: string; startedAt?: string; finishedAt?: string; error?: string; run?: PointCloudDefinition; }
export interface PointCloudAnnotationSource { id: string; runId: string; label: string; artifactRoot: string; file: string; url: string; sha256: string; pointCount: number; sourceKind: string; }
export interface PointCloudAnnotation { id: string; sourceId: string; createdAt: string; labelCount: number; classCounts: Record<string, number>; path: string; }
export interface PointCloudTrainingJob { id: string; annotationId: string; status: "queued" | "running" | "complete" | "failed"; stage: string; device: string; createdAt: string; error?: string; artifactRoot?: string; metrics?: string; model?: string; preview?: string; }
export interface PointCloudTrainingJob { id: string; annotationId: string; status: "queued" | "running" | "complete" | "failed"; stage: string; requestedDevice?: string; device: string; environment?: string; torchVersion?: string; createdAt: string; error?: string; artifactRoot?: string; metrics?: string; model?: string; preview?: string; }
export interface PointCloudSemanticModel { id: string; label: string; artifactRoot: string; model: string; metrics: string; createdAt: string; classes: Record<string, { key: string; label: string; color: number[] }>; testF1: Record<string, number>; }
export interface PointCloudInferenceJob { id: string; runId: string; modelId: string; inputName: string; status: "queued" | "running" | "complete" | "failed"; stage: string; device: string; createdAt: string; error?: string; artifactRoot?: string; metadata?: string; preview?: string; classifiedLas?: string; classCounts?: string; summary?: string; }
export interface PointCloudInferenceJob { id: string; runId: string; modelId: string; inputName: string; status: "queued" | "running" | "complete" | "failed"; stage: string; requestedDevice?: string; device: string; environment?: string; torchVersion?: string; createdAt: string; error?: string; artifactRoot?: string; metadata?: string; preview?: string; classifiedLas?: string; classCounts?: string; summary?: string; }
export const artifactUrl = (path: string) => `/${path.replace(/\\/g, "/").split("/").map(encodeURIComponent).join("/")}`;
@@ -172,7 +172,7 @@
export async function loadPointCloudAnnotations() { return (await getJson<{ annotations: PointCloudAnnotation[] }>("api/3d-pointcloud/annotations")).annotations; }
export async function createPointCloudAnnotation(sourceId: string, labels: Array<[number, number]>) { return postRun<{ annotation: PointCloudAnnotation }>("api/3d-pointcloud/annotations", { sourceId, labels }); }
export async function deletePointCloudAnnotation(annotationId: string) { return responseJson<{ deletedId: string }>(await fetch(artifactUrl(`api/3d-pointcloud/annotations/${encodeURIComponent(annotationId)}`), { method: "DELETE" })); }
export async function createPointCloudTrainingRun(annotationId: string, device: "auto" | "cpu" | "cuda" = "cpu") { return postRun<{ job: PointCloudTrainingJob }>("api/3d-pointcloud/training-runs", { annotationId, device }); }
export async function createPointCloudTrainingRun(annotationId: string, device: "auto" | "cpu" | "cuda" = "auto") { return postRun<{ job: PointCloudTrainingJob }>("api/3d-pointcloud/training-runs", { annotationId, device }); }
export async function loadPointCloudTrainingJob(jobId: string) { return (await getJson<{ job: PointCloudTrainingJob }>(`api/3d-pointcloud/training-jobs/${jobId}`)).job; }
export async function loadPointCloudSemanticModels() { return (await getJson<{ models: PointCloudSemanticModel[] }>("api/3d-pointcloud/semantic-models")).models; }
export async function createPointCloudModelInference(modelId: string, pointCloud: PointCloudUploadRef) { return postRun<{ job: PointCloudInferenceJob }>("api/3d-pointcloud/model-inference-runs", { modelId, pointCloud }); }
apps/workbench-console/src/components/PointCloudPanel.vue
@@ -69,6 +69,10 @@
  if (job.status === "complete") return "重建完成,结果已加入案例库。";
  return job.error || "照片重建失败。";
});
function executionLabel(job: { device: string; environment?: string; torchVersion?: string }) {
  const device = job.device === "cuda" ? "GPU CUDA" : "CPU";
  return [device, job.environment, job.torchVersion ? `PyTorch ${job.torchVersion}` : ""].filter(Boolean).join(" / ");
}
function rings(feature: GeoFeature): number[][][] {
  const geometry = feature.geometry;
@@ -135,7 +139,7 @@
  finally { annotationDeletingId.value = ""; }
}
async function startTraining(annotationId: string) {
  try { const { job } = await createPointCloudTrainingRun(annotationId, "cpu"); trainingJob.value = job; stopTrainingPolling(); void pollTrainingJob(); }
  try { const { job } = await createPointCloudTrainingRun(annotationId, "auto"); trainingJob.value = job; stopTrainingPolling(); void pollTrainingJob(); }
  catch (error) { runError.value = error instanceof Error ? error.message : "启动训练失败。"; }
}
function beforeInferenceUpload(file: File) { inferenceFile.value = file; return false; }
@@ -270,17 +274,17 @@
      <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>
      <div class="section-heading"><div><h2>人工标注与监督训练</h2><p>仅保存你刷选确认的真值点;规则候选颜色不会写入训练标签。任务会自动探测并优先使用本机可用 GPU,未通过检测时回退 CPU。</p></div><a-tag color="blue">自动 GPU / CPU</a-tag></div>
      <a-space wrap class="annotation-source-row"><span>标注源</span><a-select v-model:value="annotationSourceId" :options="annotationSources.map((item) => ({ value: item.id, label: item.label }))" /></a-space>
      <a-alert type="info" show-icon :message="`标注底图:${annotationSource.sourceKind}`" description="为保持浏览器可交互,当前显示的是从原始 LAS 按体素确定性抽取的 40 万个 RGB/XYZ 点,不是语义规则分类颜色,也不是把 1,047 万原始点全部装入浏览器。" />
      <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-list size="small" :data-source="annotations"><template #renderItem="{ item }"><a-list-item><a-space wrap><span>{{ item.id }}</span><span>{{ annotationSourceLabel(item.sourceId) }}</span><span>{{ item.labelCount.toLocaleString() }} 点</span><a-button size="small" type="primary" @click="startTraining(item.id)">自动选择 GPU / CPU 训练</a-button><a-popconfirm title="删除后不能恢复该标注版本,确认删除?" ok-text="删除" cancel-text="取消" @confirm="deleteAnnotation(item.id)"><a-tooltip title="删除标注版本"><a-button size="small" danger :loading="annotationDeletingId === item.id" aria-label="删除标注版本"><DeleteOutlined /></a-button></a-tooltip></a-popconfirm></a-space></a-list-item></template></a-list>
      <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 点云,在本机 CPU 上生成预测候选。XYZ-only 输入会明确拒绝,不会伪造颜色特征。</p></div><a-tag color="blue">本机 CPU</a-tag></div>
      <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" />
@@ -288,7 +292,7 @@
        <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 推理中,可继续浏览案例。')" />
      <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>
capabilities/05-3d-pointcloud/README.md
@@ -348,7 +348,7 @@
- WebODM is AGPL-3.0. NodeODX/ODX, OpenSfM/OpenDroneMap, their images, and any
  cloud processing service require separate license and data-handling review.
## Human annotation and supervised CPU training
## Human annotation and supervised GPU/CPU training
The workbench now provides a separate human annotation and supervised training
area. A semantic run writes a 400,000-point `*.semantic-annotation-source.ply`
@@ -371,10 +371,12 @@
 source run/checksum, point indices, class codes and time.
`train_pointcloud_semantic_model.py` trains a compact PointNet-style shared MLP
from user-confirmed labels only. It runs locally with `--device cpu` and later
uses the same code/data with `--device cuda`. Training requires two or more
from user-confirmed labels only. It accepts `--device auto`, `cpu`, or `cuda`;
the local console probes only its fixed GPU environment, prioritises CUDA when
the probe passes, and otherwise retains the CPU environment. Training requires two or more
classes and at least 500 confirmed points per class, partitions XY blocks into
train/validation/test, and writes `model.pt`, `metrics.json`, a predicted PLY,
train/validation/test, selects the retained checkpoint using validation macro
F1 only, and writes `model.pt`, `metrics.json`, a predicted PLY,
per-class precision/recall/F1 and a confusion matrix. Metrics apply only to the
labelled source blocks and are not field-wide accuracy claims.
@@ -402,12 +404,12 @@
export rather than a placeholder, manifest, or partially downloaded tile.
```powershell
.\.venvs\05-3d-pointcloud\Scripts\python.exe `
.\.venvs\05-3d-pointcloud-gpu\Scripts\python.exe `
  .\capabilities\05-3d-pointcloud\apply_pointcloud_semantic_model.py `
  --model .\shared\outputs\05-3d-pointcloud\training-runs\<model-id>\model.pt `
  --input .\path\to\new-rgb-cloud.las `
  --output .\shared\outputs\05-3d-pointcloud\model-inference-runs\<new-run-id> `
  --device cpu
  --device auto
```
Each new output directory contains `predicted-semantic-preview.ply` (a
@@ -424,3 +426,12 @@
download. This verifies the workflow, not field accuracy. Its labelled spatial
test F1 is vegetation 0.979, power line 0.815, and pole/tower 0.348; the latter
has substantial false-positive risk and all output remains review candidates.
GPU verification on 2026-08-25 used the RTX 3050 Laptop GPU through
`.venvs/05-3d-pointcloud-gpu` (`torch 2.11.0+cu128`). The fixed annotation
revision trained in 3.961 seconds and retained epoch 10 by validation macro F1.
Its separate spatial-test F1 was vegetation 0.979, pole/tower 0.457, and power
line 0.736 (macro 0.724). CUDA inference on the 400,000-point RGB source took
1.554 seconds and records `processing.device: "cuda"` in `run_metadata.json`.
This is a local labelled-block comparison only; it does not establish field-wide
accuracy or authorise asset/inspection conclusions.
capabilities/05-3d-pointcloud/apply_pointcloud_semantic_model.py
@@ -124,7 +124,7 @@
    parser.add_argument("--model", type=Path, required=True)
    parser.add_argument("--input", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--device", choices={"cpu", "cuda"}, default="cpu")
    parser.add_argument("--device", choices={"auto", "cpu", "cuda"}, default="auto")
    parser.add_argument("--batch-size", type=int, default=4096)
    args = parser.parse_args()
    if args.device == "cuda" and not torch.cuda.is_available():
@@ -137,7 +137,8 @@
        raise SystemExit("Output directory must be new or empty.")
    started = time.perf_counter()
    device = torch.device(args.device)
    device_name = "cuda" if args.device == "cuda" or (args.device == "auto" and torch.cuda.is_available()) else "cpu"
    device = torch.device(device_name)
    model, class_codes = load_model(args.model, device)
    xyz, rgb, source_las = load_cloud(args.input)
    center = xyz.mean(axis=0)
@@ -185,7 +186,7 @@
    summary = {"class_codes": class_codes, "class_counts": {str(code): counts[code] for code in class_codes}, "input_points": int(len(xyz)), "preview_points": int(len(preview_selection)), "preview_sampling": "deterministic class-aware cap; smaller predicted classes retained before the remaining budget is sampled", "input_has_rgb": True}
    summary_path = args.output / "prediction-summary.json"
    summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
    metadata = {"capability": "05-3d-pointcloud", "classification": "B", "created_at": datetime.now(UTC).isoformat(), "model": {"path": str(args.model), "sha256": sha256(args.model), "architecture": "PointWiseNet shared MLP"}, "input": {"path": str(args.input), "sha256": sha256(args.input), "bytes": args.input.stat().st_size, "points": int(len(xyz)), "has_rgb": True}, "classes": {str(code): CLASS_SCHEMA[code] for code in class_codes}, "prediction": summary, "processing": {"device": args.device, "batch_size": args.batch_size, "normalization": {"method": "source-local per input", "xyz_center": center.tolist(), "xyz_scale": scale}}, "versions": {"python": sys.version.split()[0], "torch": torch.__version__, "open3d": o3d.__version__, "laspy": laspy.__version__}, "artifacts": {"preview": preview_path.name, "classified_las": classified_las.name, "class_counts": csv_path.name, "summary": summary_path.name}, "elapsed_seconds": round(time.perf_counter() - started, 3), "limitations": ["Predictions are model candidates, not asset inventory or inspection conclusions.", "This model requires observed RGB; it cannot infer labels for XYZ-only point clouds.", "Model metrics apply only to the labelled source spatial blocks. The current pole/tower class has high false-positive risk and requires review.", "New inputs are normalized with their own XYZ centre and scale to match the training feature definition; this preserves their coordinates but does not prove cross-site generalization."]}
    metadata = {"capability": "05-3d-pointcloud", "classification": "B", "created_at": datetime.now(UTC).isoformat(), "model": {"path": str(args.model), "sha256": sha256(args.model), "architecture": "PointWiseNet shared MLP"}, "input": {"path": str(args.input), "sha256": sha256(args.input), "bytes": args.input.stat().st_size, "points": int(len(xyz)), "has_rgb": True}, "classes": {str(code): CLASS_SCHEMA[code] for code in class_codes}, "prediction": summary, "processing": {"requested_device": args.device, "device": device_name, "batch_size": args.batch_size, "normalization": {"method": "source-local per input", "xyz_center": center.tolist(), "xyz_scale": scale}}, "versions": {"python": sys.version.split()[0], "torch": torch.__version__, "open3d": o3d.__version__, "laspy": laspy.__version__}, "artifacts": {"preview": preview_path.name, "classified_las": classified_las.name, "class_counts": csv_path.name, "summary": summary_path.name}, "elapsed_seconds": round(time.perf_counter() - started, 3), "limitations": ["Predictions are model candidates, not asset inventory or inspection conclusions.", "This model requires observed RGB; it cannot infer labels for XYZ-only point clouds.", "Model metrics apply only to the labelled source spatial blocks. The current pole/tower class has high false-positive risk and requires review.", "New inputs are normalized with their own XYZ centre and scale to match the training feature definition; this preserves their coordinates but does not prove cross-site generalization."]}
    (args.output / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
    print(json.dumps(metadata, ensure_ascii=False))
    return 0
capabilities/05-3d-pointcloud/train_pointcloud_semantic_model.py
@@ -7,6 +7,7 @@
from __future__ import annotations
import argparse
import copy
import hashlib
import json
import time
@@ -97,6 +98,7 @@
    parser.add_argument("--device", choices={"auto", "cpu", "cuda"}, default="auto")
    parser.add_argument("--epochs", type=int, default=40)
    parser.add_argument("--batch-size", type=int, default=4096)
    parser.add_argument("--seed", type=int, default=42)
    args = parser.parse_args()
    if args.output.exists() and any(args.output.iterdir()):
        raise SystemExit("Output directory must be new or empty.")
@@ -135,15 +137,32 @@
    if args.device == "cuda" and not torch.cuda.is_available():
        raise SystemExit("CUDA was requested but is unavailable.")
    device = torch.device(device_name)
    torch.manual_seed(42)
    torch.manual_seed(args.seed)
    if device_name == "cuda":
        torch.cuda.manual_seed_all(args.seed)
        torch.backends.cudnn.benchmark = False
        torch.backends.cudnn.deterministic = True
    model = PointWiseNet(len(class_codes)).to(device)
    optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=1e-4)
    weight = torch.tensor([len(train_idx) / max(1, sum(label_by_index[int(i)] == group for i in train_idx)) for group in range(len(class_codes))], dtype=torch.float32, device=device)
    criterion = torch.nn.CrossEntropyLoss(weight=weight)
    train_labels = np.asarray([label_by_index[int(index)] for index in train_idx], dtype=np.int64)
    validation_true = np.asarray([target[np.searchsorted(indices, index)] for index in validation_idx], dtype=np.int64)
    test_true = np.asarray([target[np.searchsorted(indices, index)] for index in test_idx], dtype=np.int64)
    def predict(indices_to_predict: np.ndarray) -> np.ndarray:
        model.eval(); parts: list[np.ndarray] = []
        with torch.no_grad():
            for start in range(0, len(indices_to_predict), args.batch_size):
                logits = model(torch.from_numpy(features[indices_to_predict[start:start + args.batch_size]]).to(device))
                parts.append(logits.argmax(dim=1).cpu().numpy())
        return np.concatenate(parts)
    started = time.perf_counter()
    for _ in range(args.epochs):
        order = np.random.default_rng(42).permutation(len(train_idx))
    best_epoch = 0
    best_validation_macro_f1 = -1.0
    best_state: dict[str, torch.Tensor] | None = None
    for epoch in range(1, args.epochs + 1):
        order = np.random.default_rng(args.seed).permutation(len(train_idx))
        model.train()
        for start in range(0, len(order), args.batch_size):
            subset = train_idx[order[start:start + args.batch_size]]
@@ -152,15 +171,15 @@
            optimizer.zero_grad(set_to_none=True)
            criterion(model(x), y).backward()
            optimizer.step()
    def predict(indices_to_predict: np.ndarray) -> np.ndarray:
        model.eval(); parts: list[np.ndarray] = []
        with torch.no_grad():
            for start in range(0, len(indices_to_predict), args.batch_size):
                logits = model(torch.from_numpy(features[indices_to_predict[start:start + args.batch_size]]).to(device))
                parts.append(logits.argmax(dim=1).cpu().numpy())
        return np.concatenate(parts)
    validation_true = np.asarray([target[np.searchsorted(indices, index)] for index in validation_idx], dtype=np.int64)
    test_true = np.asarray([target[np.searchsorted(indices, index)] for index in test_idx], dtype=np.int64)
        validation_metrics = metrics(validation_true, np.asarray([class_codes[value] for value in predict(validation_idx)], dtype=np.int64), class_codes)
        macro_f1 = float(validation_metrics["report"]["macro avg"]["f1-score"])
        if macro_f1 > best_validation_macro_f1:
            best_epoch = epoch
            best_validation_macro_f1 = macro_f1
            best_state = copy.deepcopy(model.state_dict())
    if best_state is None:
        raise RuntimeError("Training did not produce a validation checkpoint.")
    model.load_state_dict(best_state)
    validation_pred = np.asarray([class_codes[value] for value in predict(validation_idx)], dtype=np.int64)
    test_pred = np.asarray([class_codes[value] for value in predict(test_idx)], dtype=np.int64)
    all_pred = np.asarray([class_codes[value] for value in predict(np.arange(len(features), dtype=np.int64))], dtype=np.uint8)
@@ -177,9 +196,9 @@
        "label_counts": {str(code): count for code, count in per_class.items()},
        "split_counts": {"train": int(len(train_idx)), "validation": int(len(validation_idx)), "test": int(len(test_idx))},
        "validation": metrics(validation_true, validation_pred, class_codes), "test": metrics(test_true, test_pred, class_codes),
        "normalizer": normalizer, "epochs": args.epochs, "batch_size": args.batch_size,
        "normalizer": normalizer, "epochs": args.epochs, "best_epoch": best_epoch, "best_validation_macro_f1": best_validation_macro_f1, "batch_size": args.batch_size, "seed": args.seed,
        "elapsed_seconds": round(time.perf_counter() - started, 3),
        "limitations": ["Metrics cover only human-confirmed points in this annotation revision.", "Spatial blocks reduce leakage but one small source cannot establish field-wide generalization.", "Rule candidate colours were not used as labels or input features."],
        "limitations": ["Metrics cover only human-confirmed points in this annotation revision.", "The retained checkpoint is selected by validation macro F1; the test split remains separate from that selection.", "Spatial blocks reduce leakage but one small source cannot establish field-wide generalization.", "Rule candidate colours were not used as labels or input features."],
    }
    torch.save({"state_dict": model.cpu().state_dict(), "class_codes": class_codes, "normalizer": normalizer, "schema_version": 1}, args.output / "model.pt")
    (args.output / "metrics.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
scripts/serve_workbench_console.py
@@ -76,6 +76,8 @@
POINTCLOUD_INFERENCE_JOBS_LOCK = threading.Lock()
MAX_ANNOTATION_LABELS = 400_000
POINTCLOUD_CLASS_CODES = {1, 2, 5, 6, 15, 16}
POINTCLOUD_CPU_ENVIRONMENT = "05-3d-pointcloud"
POINTCLOUD_GPU_ENVIRONMENT = "05-3d-pointcloud-gpu"
class ApiError(ValueError):
@@ -128,6 +130,39 @@
        for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()
def pointcloud_execution_environment(root: Path, requested_device: str = "auto") -> dict[str, str]:
    """Select only a fixed point-cloud interpreter after a short CUDA probe.
    The console never accepts a browser-supplied Python path.  A failed or
    unavailable GPU environment is an expected condition for ``auto`` and
    falls back to the retained CPU environment.
    """
    if requested_device not in {"auto", "cpu", "cuda"}:
        raise ApiError("Point-cloud device must be auto, cpu, or cuda.")
    cpu_python = root / ".venvs" / POINTCLOUD_CPU_ENVIRONMENT / "Scripts" / "python.exe"
    gpu_python = root / ".venvs" / POINTCLOUD_GPU_ENVIRONMENT / "Scripts" / "python.exe"
    if requested_device != "cpu" and gpu_python.is_file():
        try:
            probe = subprocess.run(
                [str(gpu_python), "-c", "import json, torch; print(json.dumps({'cuda': bool(torch.cuda.is_available()), 'torch': torch.__version__}))"],
                cwd=root,
                capture_output=True,
                text=True,
                timeout=20,
                check=False,
            )
            payload = json.loads(probe.stdout.strip().splitlines()[-1]) if probe.returncode == 0 and probe.stdout.strip() else {}
            if payload.get("cuda") is True and isinstance(payload.get("torch"), str):
                return {"python": str(gpu_python), "device": "cuda", "environment": POINTCLOUD_GPU_ENVIRONMENT, "torchVersion": payload["torch"]}
        except (OSError, subprocess.SubprocessError, json.JSONDecodeError, IndexError):
            pass
    if requested_device == "cuda":
        raise ApiError("CUDA was requested, but the fixed point-cloud GPU environment is unavailable.")
    if not cpu_python.is_file():
        raise ApiError("3D point-cloud CPU virtual environment is unavailable. Run the capability setup first.")
    return {"python": str(cpu_python), "device": "cpu", "environment": POINTCLOUD_CPU_ENVIRONMENT, "torchVersion": "unknown"}
def trajectory_runs(root: Path) -> list[dict[str, Any]]:
@@ -441,11 +476,10 @@
        return dict(value) if value else None
def execute_pointcloud_training_job(root: Path, job_id: str, annotation: Path, output: Path, device: str) -> None:
def execute_pointcloud_training_job(root: Path, job_id: str, annotation: Path, output: Path, execution: dict[str, str]) -> None:
    with POINTCLOUD_TRAINING_JOBS_LOCK:
        POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "running", "stage": "training", "startedAt": datetime.now(UTC).isoformat()})
    python = root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
    command = [str(python), str(root / "capabilities" / "05-3d-pointcloud" / "train_pointcloud_semantic_model.py"), "--annotation", str(annotation), "--output", str(output), "--device", device]
    command = [execution["python"], str(root / "capabilities" / "05-3d-pointcloud" / "train_pointcloud_semantic_model.py"), "--annotation", str(annotation), "--output", str(output), "--device", execution["device"]]
    try:
        with RUN_LOCK:
            completed = subprocess.run(command, cwd=root, capture_output=True, text=True, timeout=14_400, check=False)
@@ -521,11 +555,10 @@
        raise ApiError("LAS/LAZ 文件没有点记录。请选择包含实际 RGB 点位的完整点云文件,而不是空分块。")
def execute_pointcloud_inference_job(root: Path, job_id: str, model: Path, source: Path, output: Path) -> None:
def execute_pointcloud_inference_job(root: Path, job_id: str, model: Path, source: Path, output: Path, execution: dict[str, str]) -> None:
    with POINTCLOUD_INFERENCE_JOBS_LOCK:
        POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "running", "stage": "inference", "startedAt": datetime.now(UTC).isoformat()})
    python = root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
    command = [str(python), str(root / "capabilities" / "05-3d-pointcloud" / "apply_pointcloud_semantic_model.py"), "--model", str(model), "--input", str(source), "--output", str(output), "--device", "cpu"]
    command = [execution["python"], str(root / "capabilities" / "05-3d-pointcloud" / "apply_pointcloud_semantic_model.py"), "--model", str(model), "--input", str(source), "--output", str(output), "--device", execution["device"]]
    try:
        with RUN_LOCK:
            completed = subprocess.run(command, cwd=root, capture_output=True, text=True, timeout=14_400, check=False)
@@ -1643,9 +1676,9 @@
            source_bytes[name] = raw_path.stat().st_size
            shutil.copyfile(raw_path, processed_root / name)
        output = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "runs" / run_id
        python = self.root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
        python = self.root / ".venvs" / POINTCLOUD_CPU_ENVIRONMENT / "Scripts" / "python.exe"
        if not python.is_file():
            raise ApiError("3D point-cloud virtual environment is unavailable. Run the capability setup first.")
            raise ApiError("3D point-cloud CPU virtual environment is unavailable. Run the capability setup first.")
        command = [str(python), str(self.root / "capabilities" / "05-3d-pointcloud" / "run_pointcloud_understanding.py"), "--input", str(processed_root), "--output", str(output), "--ground-up-axis", "z"]
        with RUN_LOCK:
            self.run_command(command, 900)
@@ -1710,15 +1743,13 @@
        record = load_json(annotation)
        if record.get("schema_version") != 1:
            raise ApiError("The selected annotation revision is unavailable.")
        python = self.root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
        if not python.is_file():
            raise ApiError("3D point-cloud virtual environment is unavailable. Run the capability setup first.")
        execution = pointcloud_execution_environment(self.root, device)
        job_id = uuid4().hex
        output = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "training-runs" / make_run_id("semantic-model")
        job = {"id": job_id, "annotationId": annotation_id, "status": "queued", "stage": "queued", "device": device, "createdAt": datetime.now(UTC).isoformat()}
        job = {"id": job_id, "annotationId": annotation_id, "status": "queued", "stage": "queued", "requestedDevice": device, "device": execution["device"], "environment": execution["environment"], "torchVersion": execution["torchVersion"], "createdAt": datetime.now(UTC).isoformat()}
        with POINTCLOUD_TRAINING_JOBS_LOCK:
            POINTCLOUD_TRAINING_JOBS[job_id] = job
        thread = threading.Thread(target=execute_pointcloud_training_job, args=(self.root, job_id, annotation, output, device), daemon=True, name=f"pointcloud-training-{job_id[:8]}")
        thread = threading.Thread(target=execute_pointcloud_training_job, args=(self.root, job_id, annotation, output, execution), daemon=True, name=f"pointcloud-training-{job_id[:8]}")
        thread.start()
        return dict(job)
@@ -1735,9 +1766,7 @@
        if Path(name).suffix.lower() not in suffixes:
            raise ApiError("Model inference requires a PLY, PCD, XYZ, LAS, or LAZ point cloud.")
        validate_pointcloud_model_input(staged_path)
        python = self.root / ".venvs" / "05-3d-pointcloud" / "Scripts" / "python.exe"
        if not python.is_file():
            raise ApiError("3D point-cloud virtual environment is unavailable. Run the capability setup first.")
        execution = pointcloud_execution_environment(self.root)
        run_id = make_run_id("semantic-inference")
        raw_root = self.root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "model-inference-runs" / run_id
        processed_root = self.root / "shared" / "data" / "processed" / "05-3d-pointcloud" / "model-inference-runs" / run_id
@@ -1760,10 +1789,10 @@
        except ValueError as exc:
            raise ApiError("Selected model is outside the allowed training output directory.") from exc
        job_id = uuid4().hex
        job = {"id": job_id, "runId": run_id, "modelId": model_id, "inputName": name, "status": "queued", "stage": "queued", "device": "cpu", "createdAt": datetime.now(UTC).isoformat(), "sourceSha256": actual_sha256, "rawInput": relative_path(self.root, raw_path), "processedInput": relative_path(self.root, processed_path)}
        job = {"id": job_id, "runId": run_id, "modelId": model_id, "inputName": name, "status": "queued", "stage": "queued", "requestedDevice": "auto", "device": execution["device"], "environment": execution["environment"], "torchVersion": execution["torchVersion"], "createdAt": datetime.now(UTC).isoformat(), "sourceSha256": actual_sha256, "rawInput": relative_path(self.root, raw_path), "processedInput": relative_path(self.root, processed_path)}
        with POINTCLOUD_INFERENCE_JOBS_LOCK:
            POINTCLOUD_INFERENCE_JOBS[job_id] = job
        thread = threading.Thread(target=execute_pointcloud_inference_job, args=(self.root, job_id, model_path, processed_path, output), daemon=True, name=f"pointcloud-inference-{job_id[:8]}")
        thread = threading.Thread(target=execute_pointcloud_inference_job, args=(self.root, job_id, model_path, processed_path, output, execution), daemon=True, name=f"pointcloud-inference-{job_id[:8]}")
        thread.start()
        return dict(job)
tests/test_serve_workbench_console.py
@@ -2,10 +2,12 @@
import importlib.util
import io
import subprocess
import sys
import tempfile
import unittest
import json
from unittest import mock
from http import HTTPStatus
from urllib.parse import quote
from pathlib import Path
@@ -207,6 +209,36 @@
        with self.assertRaisesRegex(MODULE.ApiError, "Processing failed"):
            handler.run_command([sys.executable, "-c", "raise SystemExit(2)"], timeout=10)
    def test_pointcloud_execution_uses_fixed_gpu_environment_when_cuda_probe_succeeds(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            root = Path(temp_dir)
            gpu_python = root / ".venvs" / MODULE.POINTCLOUD_GPU_ENVIRONMENT / "Scripts" / "python.exe"
            gpu_python.parent.mkdir(parents=True)
            gpu_python.write_bytes(b"fixed-interpreter")
            with mock.patch.object(MODULE.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, '{"cuda": true, "torch": "2.11.0+cu128"}\n', "")):
                execution = MODULE.pointcloud_execution_environment(root)
            self.assertEqual(execution["device"], "cuda")
            self.assertEqual(execution["environment"], MODULE.POINTCLOUD_GPU_ENVIRONMENT)
            self.assertEqual(execution["torchVersion"], "2.11.0+cu128")
            self.assertEqual(Path(execution["python"]), gpu_python)
    def test_pointcloud_execution_falls_back_to_fixed_cpu_environment(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            root = Path(temp_dir)
            cpu_python = root / ".venvs" / MODULE.POINTCLOUD_CPU_ENVIRONMENT / "Scripts" / "python.exe"
            gpu_python = root / ".venvs" / MODULE.POINTCLOUD_GPU_ENVIRONMENT / "Scripts" / "python.exe"
            cpu_python.parent.mkdir(parents=True)
            gpu_python.parent.mkdir(parents=True)
            cpu_python.write_bytes(b"fixed-cpu-interpreter")
            gpu_python.write_bytes(b"fixed-gpu-interpreter")
            with mock.patch.object(MODULE.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, '{"cuda": false, "torch": "2.11.0+cu128"}\n', "")):
                execution = MODULE.pointcloud_execution_environment(root)
            self.assertEqual(execution["device"], "cpu")
            self.assertEqual(execution["environment"], MODULE.POINTCLOUD_CPU_ENVIRONMENT)
            with mock.patch.object(MODULE.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, '{"cuda": false, "torch": "2.11.0+cu128"}\n', "")):
                with self.assertRaisesRegex(MODULE.ApiError, "CUDA was requested"):
                    MODULE.pointcloud_execution_environment(root, "cuda")
    def test_semantic_validation_run_is_discovered(self) -> None:
        runs = MODULE.semantic_runs(ROOT)
        self.assertTrue(any(item["id"] == "validation-20260817" for item in runs))