feat(pointcloud): auto-select verified GPU runtime
| | |
| | | # 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. |
| | | |
| | |
| | | |
| | | - 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. |
| | |
| | | |
| | | - 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/`. |
| | |
| | | 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 |
| | |
| | | - 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 |
| | |
| | | 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: |
| | |
| | | 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 |
| | |
| | | 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("/")}`; |
| | | |
| | |
| | | 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 }); } |
| | |
| | | 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; |
| | |
| | | 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; } |
| | |
| | | <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" /> |
| | |
| | | <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> |
| | |
| | | - 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` |
| | |
| | | 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. |
| | | |
| | |
| | | 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 |
| | |
| | | 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. |
| | |
| | | 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(): |
| | |
| | | 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) |
| | |
| | | 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 |
| | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import copy |
| | | import hashlib |
| | | import json |
| | | import time |
| | |
| | | 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.") |
| | |
| | | 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]] |
| | |
| | | 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) |
| | |
| | | "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") |
| | |
| | | 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): |
| | |
| | | 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]]: |
| | |
| | | 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) |
| | |
| | | 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) |
| | |
| | | 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) |
| | |
| | | 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) |
| | | |
| | |
| | | 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 |
| | |
| | | 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) |
| | | |
| | |
| | | |
| | | 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 |
| | |
| | | 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)) |