feat(pointcloud): complete annotation and result lifecycle workflows
26 files modified
13 files added
| | |
| | | 1. Read the root documentation, `PROJECT_CONTEXT.md`, and the target capability README. |
| | | 2. Classify the capability as A, B, or C and identify the exact role of `geoai-py`. |
| | | 3. Define a runnable Demo with real input, inspectable output, structured metadata, and measurable acceptance criteria. |
| | | 4. Inspect hardware and existing environments before installing dependencies. Default to CPU because no NVIDIA CUDA is available. |
| | | 4. Inspect hardware and existing environments before installing dependencies. Keep a CPU-compatible route for every supported workflow. Use CUDA only for model or vision computation that benefits from it and only after the fixed capability environment passes a probe; file I/O, data preparation, geometry/export steps, and the console remain CPU unless separately validated. |
| | | 5. Validate one normal and one difficult representative sample before running a full directory. |
| | | 6. Visually inspect generated images, maps, documents, or spreadsheets. |
| | | 7. Record model, package, dataset, and weight licenses independently before suggesting commercial use. |
| | |
| | | |
| | | - Prefer existing project patterns and `geoai-py` APIs over new abstractions. |
| | | - Do not start, control, or use a browser to inspect local-console visual output. For UI, map, image, or 3D-model visual acceptance, state the exact view or interaction that needs checking and ask the user to provide screenshots; use non-browser build, HTTP, artifact, and automated tests independently. |
| | | - Run `npm run build` only after Vue/TypeScript/CSS or build-time client configuration changes. Do not rebuild for data processing, result discovery, or server-only changes. |
| | | - Preserve geospatial coordinates only when the input has a valid CRS and transform. Ordinary JPEG results remain pixel coordinates. |
| | | - Report false positives, misses, runtime, and unsupported classes honestly. Do not hide limitations by lowering thresholds. |
| | | - Do not batch-run large datasets until a representative validation indicates that the approach is suitable. |
| | | - Do not delete or overwrite source imagery, model weights, results, or unrelated user changes. |
| | | - For a console result-removal feature, derive an explicit deletion plan on the server from a discovered, console-owned artifact. Require confirmation before removal; never accept a browser-supplied path, and preserve `baseData`, external inputs, validation/baseline assets, and unrelated runs. |
| | | - Do not commit unless the user explicitly asks. |
| | |
| | | # GeoAI Workbench Current Context |
| | | |
| | | Last updated: 2026-08-25 |
| | | Last updated: 2026-08-28 |
| | | |
| | | This file is the current project snapshot for new Codex tasks. Keep it concise and replace stale facts instead of appending a conversation diary. |
| | | |
| | |
| | | - Start command: `py -3.12 .\scripts\serve_workbench_console.py` |
| | | - URL: `http://127.0.0.1:6173` (built console); Vite development is `http://127.0.0.1:6174/apps/workbench-console/`. Only `6xxx` ports are accepted. |
| | | - Scope: independent local experiment workbench for this repository. It has no code, account, or product API link to the two drone-product repositories. The map client directly requests public ArcGIS tiles and, only when configured, TianDiTu tiles. |
| | | - Current workflow: `00-change-detection`, `01-object-detection`, `02-semantic-mapping`, `04-spatial-measurement`, `09-anomaly-detection`, and `15-trajectory-analysis` support new local runs, searchable case libraries, and visual result workspaces. Object detection and change detection probe only their fixed CUDA interpreters and select CUDA when available, otherwise retain their fixed CPU fallback. Anomaly detection accepts separate normal-reference and inspection groups through bounded binary uploads, runs asynchronously, and shows references, input/output comparison, heatmaps, candidates and downloads. Change detection shows two-date imagery, a ChangeStar raster overlay, pixel-coordinate GeoJSON, per-feature probability stats and downloads. Its scan workspace accepts new user-uploaded pairs with selectable threshold/minimum-area combinations, asynchronous execution, per-combination overlays/statistics and GeoJSON, without treating scan inference as a formal model run. Spatial measurement shows raster/vector results, per-object metrics and downloads. Change detection and anomaly detection upload large raster/image files as raw binary streams and copy bytes into new run directories before fixed capability scripts run; source files are not rewritten. The server creates a new run ID for every submission, only accepts allowlisted input types and enabled tasks, and calls fixed virtual-environment scripts. |
| | | - Current workflow: `00-change-detection`, `01-object-detection`, `02-semantic-mapping`, `04-spatial-measurement`, `09-anomaly-detection`, and `15-trajectory-analysis` support new local runs, searchable case libraries, and visual result workspaces. Object detection, change detection, and point-cloud supervised model work use one fixed-environment `auto`/`cpu`/`cuda` policy: auto selects verified CUDA or records a CPU fallback reason; explicit CUDA fails rather than silently using CPU; explicit CPU never probes the GPU. New metadata records requested/actual device, environment, PyTorch version and fallback evidence. Photo reconstruction, point-cloud geometry/rule classification, file conversion, exports and the console remain CPU; current photo-MVS CUDA tools are not console-selectable. Anomaly detection accepts separate normal-reference and inspection groups through bounded binary uploads, runs asynchronously, and shows references, input/output comparison, heatmaps, candidates and downloads. Change detection shows two-date imagery, a ChangeStar raster overlay, pixel-coordinate GeoJSON, per-feature probability stats and downloads. Its scan workspace accepts new user-uploaded pairs with selectable threshold/minimum-area combinations, asynchronous execution, per-combination overlays/statistics and GeoJSON, without treating scan inference as a formal model run. Spatial measurement shows raster/vector results, per-object metrics and downloads. Change detection and anomaly detection upload large raster/image files as raw binary streams and copy bytes into new run directories before fixed capability scripts run; source files are not rewritten. The server creates a new run ID for every submission, only accepts allowlisted input types and enabled tasks, and calls fixed virtual-environment scripts. |
| | | - Result removal: every connected case library exposes a complete-result removal control. It fetches a server-derived plan and requires confirmation before deleting only a discovered console-owned `shared/outputs/<capability>/runs/<run-id>` output plus its fixed raw/processed copies and known dependent artifacts. `baseData`, external inputs, sibling runs, baselines, and validation assets remain protected. Point-cloud trained-model removal is separate and additionally clears only that model's discovered inference/automatic-annotation outputs while retaining human annotation revisions. |
| | | - Execution policy: every supported workflow retains a CPU-compatible path. The RTX 3050 is used only for verified PyTorch model/vision training or inference through fixed environments; `auto` records CUDA selection or CPU fallback, explicit `cuda` fails when unavailable, and file I/O, preprocessing, geometry/export, and the console remain CPU unless separately validated. |
| | | - Console acceptance and build policy: Codex does not drive a browser for local-console visual acceptance. It asks the user for targeted screenshots and independently runs build, HTTP, artifact, and automated checks. Run `npm run build` only after frontend source or build-time client configuration changes, not after server-only work or new data/results. |
| | | - File exposure: the static handler permits only console assets, `shared/outputs`, and source images required for result comparison. Upload APIs write new raw and processed run directories but do not expose the rest of the repository. |
| | | - Styling: `apps/workbench-console/src/styles.css` is organized into design variables, application shell, shared workbench components, capability workspaces, maps, and responsive rules. New capability pages must use a scoped workspace class and shared spacing variables instead of global override patches. |
| | | |
| | |
| | | | `07-risk-rule-engine` | Runnable CPU Demo verified | C capability: deterministic spatial zone-intersection and attribute-threshold scoring, not a built-in `geoai-py` function. It consumes CRS-aware GeoJSON inputs and produces an inspectable risk GeoTIFF, preview PNG, per-object scoring GeoJSON/CSV, summary and full run metadata. | |
| | | | `09-anomaly-detection` | Runnable CPU CLI Demo verified | B capability: interpretable image features plus robust rules and Isolation Forest score visual outliers; `geoai-py` supplies `masks_to_vector`. Results are review candidates, not business-event conclusions. | |
| | | | `15-trajectory-analysis` | Runnable CPU Demo verified | C capability consuming timestamped tracks: spatial metrics, DBSCAN and explicit behavior rules; not a built-in `geoai-py` function. | |
| | | | `05-3d-pointcloud` | Runnable CPU Demo verified; semantic classification baseline added | B capability: pycolmap CPU SfM plus OpenMVS CPU MVS reconstructs dense point clouds, textured meshes and browser-viewable GLB from a coherent photo sequence; Open3D plus `geoai.masks_to_vector` produces DSM/elevated-surface GeoJSON. A separate explainable CPU RGB/local-geometry baseline produces semantic review candidates, not trained-model labels. | |
| | | | `05-3d-pointcloud` | Runnable CPU Demo verified; multimodal fusion sample and semantic baseline added | B capability: pycolmap CPU SfM plus OpenMVS CPU MVS reconstructs dense point clouds, textured meshes and browser-viewable GLB from a coherent photo sequence; Open3D plus `geoai.masks_to_vector` produces DSM/elevated-surface GeoJSON. A bounded CPU photo/pose/RGB-LAS preflight and full-photo multi-view feature sample have verified the supplied 550-image ODM export can proceed to reviewed-label and spatial-split preparation, while the separate explainable RGB/local-geometry baseline produces semantic review candidates, not trained-model labels. | |
| | | | `03`, `06`, `08`, `10` through `14`, `16` through `18` | Directory and initial README only | No verified local Demo yet. Start each one through `$geoai-capability-builder`. | |
| | | |
| | | ## Three-dimensional Reconstruction And Point-cloud Snapshot |
| | | |
| | | - Scripts: `capabilities/05-3d-pointcloud/run_pointcloud_understanding.py`, `run_photo_reconstruction.py`, read-only DJI RTK/LRF diagnostic `diagnose_photo_pose_alignment.py`, and `prepare_odx_reconstruction_inputs.py`; fixture generator: `generate_validation_inputs.py`. |
| | | - Scripts: `capabilities/05-3d-pointcloud/run_pointcloud_understanding.py`, `run_photo_reconstruction.py`, `prepare_multimodal_pointcloud_dataset.py`, `diagnose_multimodal_projection.py`, `prepare_multiview_point_features.py`, read-only DJI RTK/LRF diagnostic `diagnose_photo_pose_alignment.py`, and `prepare_odx_reconstruction_inputs.py`; fixture generator: `generate_validation_inputs.py`. |
| | | - Boundary: B. `geoai-py 0.42.0` has no point-cloud/SfM/MVS API in the installed package. pycolmap 4.1.1 performs CPU SIFT matching and incremental sparse SfM. Open3D 0.19.0 performs existing-point-cloud I/O, voxel filtering, RANSAC dominant-ground fitting and alpha-shape mesh construction. `geoai.masks_to_vector` receives the 0/255 elevated-surface raster; Rasterio is used as a documented completeness repair when GeoAI contour output does not cover every retained raster region. |
| | | - Input/output contract: an existing PLY/PCD/XYZ/LAS/LAZ produces classified/mesh PLY, DSM and height GeoTIFFs, elevated-surface raster/preview, elevated-footprint GeoJSON, CSV and metadata. A coherent JPG/JPEG sequence produces a COLMAP sparse model, coloured PLY, PCA preview PNG, local camera poses CSV, EXIF GPS manifest and metadata with input SHA-256; the verified OpenMVS CPU branch additionally produces dense PLY, mesh PLY, texture PNGs, source GLB, a reliable-texture preview GLB, a complete-geometry neutral GLB, and a point-colour complete-geometry GLB. The reliable preview vertically flips atlas pixels for browser sampling and removes faces whose four interior samples hit black atlas background or OpenMVS's orange `RGB(255,127,39)` no-coverage colour; its JSON report records every kept/rejected count. The geometry preview retains all reconstructed mesh faces without inventing texture; the point-colour preview uses nearest fused RGB values and is not a per-face photo-texture claim. |
| | | - Environment: `.venvs/05-3d-pointcloud` is a dedicated Python 3.12 environment that exposes the verified package directory from `04-spatial-measurement` through a local `.pth` file and adds Open3D 0.19.0, Laspy 2.7.0, Trimesh 4.12.2, pycolmap 4.1.1 and Matplotlib 3.11.1. It uses CPU only; `pip check` passes. |
| | | - Device policy: photo reconstruction has a complete CPU route and defaults to CPU on every computer. GPU is limited to optional dense depth estimation/fusion; image preparation, sparse SfM, meshing, texturing, GLB export, metadata, and the local console remain CPU. PyTorch CUDA works on the RTX 3050 Laptop GPU, but the tested official CUDA OpenMVS binary failed the matched six-photo validation during depth maps with CUDA error 801 and official CUDA COLMAP failed during device/context selection. CUDA MVS is therefore neither selectable in the console nor considered available; CPU is the supported route. The CLI's `--dense-device cuda` trial requires native CUDA/GPU log evidence, records requested/actual device without a silent CPU fallback, and now returns the native OpenMVS log on failure for later server or driver/toolchain verification. |
| | | - Validation: normal known-geometry PLY (9,600 source points) completed in 4.177 seconds, retained 7,644 downsampled points, 2,901 elevated points, 52 footprint regions and a 15,110-triangle mesh. Difficult sparse/slope/noise PLY completed in 3.831 seconds with 73 fragmented elevated footprints, explicitly demonstrating isolated-high-point false positives. Both PNG previews and GeoJSON outputs were inspected. |
| | | - Real input: PCL tutorial `table_scene_lms400.pcd` is staged unchanged at `shared/data/raw/05-3d-pointcloud/pcl-table-scene-20260821/`, SHA-256 `E285D415641E0D9DE695B611DB874CC8FE995E8089B77A50D6056D24D8CBCC58`. Its 460,400 input points completed in 4.833 seconds with processing-copy `--ground-up-axis auto`: 41,143 downsampled points, 19,281 elevated points, 193 footprints and 7,090 mesh triangles. Its source-local vertical was not Z, so a 61.1666-degree dominant-plane rotation is recorded. This validates PCD handling and axis alignment, not drone reconstruction accuracy or semantic class accuracy. |
| | | - Photo validation: the user-provided 18 DJI H30T side-view JPGs were staged unchanged under `shared/data/raw/05-3d-pointcloud/h30t-sideview-20260820-v2/`. The preserved original 18-photo high-resolution baseline has 3,379,097 dense points and 799,632 faces but failed the LRF diagnostic (13.3814 m median 3D error; 11.9794 m horizontal; -4.8131 m signed vertical). The current ODX-aligned local configuration uses COLMAP `OPENCV` intrinsics, RTK/GPS spatial matching (four neighbours), 2,000 px feature extraction, 18,000 features and a 0.05 m position prior. Normal and difficult six-photo validations each registered 6/6 images with 0.7581 px and 0.7596 px mean reprojection error. The full 18-photo sparse model registered 18/18 images, has 23,084 sparse points and 0.8410 px mean reprojection error. Its 2,400 px OpenMVS CPU run completed in 2,390.086 seconds, yielding 3,756,551 dense points and a 799,645-face mesh. The reliable-texture preview retains 293,614 faces (36.72%) after black/orange-atlas rejection; the point-colour and neutral complete-geometry previews retain all 799,645 faces. The LRF diagnostic now reports 0.4038 m median 3D nearest-point distance, 0.3073 m horizontal error and 0.0709 m signed vertical error. These target observations are diagnostic only, not independent surveyed check points. A two-photo low-baseline trial failed cleanly, documenting the need for sufficient baseline. |
| | | - WebODM reference: `reference_project/WebODM-master` confirms that its task/UI service delegates reconstruction to NodeODX and that the engine supports ODX `geo.txt`, GCPs, and trusted `cameras.json`/fixed-camera-parameter workflows. The local NodeODX submodule is not populated, Docker is absent, and neither NodeODX nor NodeODM is installed, so WebODM was not executed. The new read-only preparation run `shared/outputs/05-3d-pointcloud/odx-input-h30t-sideview-20260822-v2/` validated all 18 DJI images and writes an `EPSG:4326` ODX `geo.txt`, camera-position GeoJSON, source-hash manifest, parameter advice, and compatibility report. It mirrors ODX's DJI orientation normalization and per-photo RTK accuracy inflation; global ODX `gps-accuracy` must remain unset because it would overwrite those per-photo values. The package intentionally does not treat LRF targets as GCPs or fabricate a `cameras.json` calibration. |
| | | - Console: `GET/POST /api/3d-pointcloud/runs` remains enabled on the independent local console at `http://127.0.0.1:6188`. The top workflow tabs separate `照片三维重建` from `既有点云理解`: photos accept 3-30 JPG/JPEG files through bounded binary uploads, preserve raw bytes in a new run directory, use an asynchronous fixed-script CPU SfM/MVS job and poll queued/sparse/dense/completed/failed status; the GPS/RTK prior toggle selects the verified spatial-neighbour configuration only for photos with valid position metadata. Existing point-cloud uploads accept at most two allowlisted PLY/PCD/XYZ/LAS/LAZ files and use only the fixed Open3D script to produce raster/vector outputs. The case library discovers `ODX 对齐 CPU 高分重建(H30T 18 图)` from output metadata. Dense CPU-MVS results have reliable-texture, point-colour and complete-geometry modes, a wireframe toggle, local-`+Z` top-down button, filter report, dense PLY and raw mesh downloads. The geometry mode is deliberately neutral and retains every mesh face; point-colour uses fused-point RGB; texture remains evidence-only. The console only needs `npm run build` after Vue/TypeScript source changes, not after user uploads, reconstruction runs or automatic case discovery. |
| | | - Multimodal real-data preflight: `baseData/las/Task-of-2026-08-22T084705607Z-georeferenced_model.las` is a 2.58 GB, 75,870,130-point RGB LAS with `EPSG:32650`; its 550 photos, one Brown `cameras.json` calibration and 550 `shots.geojson` records have exact filename correspondence. The preflight at `shared/outputs/05-3d-pointcloud/multimodal-preflight-20260825-550-photos/` transformed every shot WGS84 geometry to `EPSG:32650` and measured 0.0 m maximum XY residual against the reported camera centre. A deterministic 120,000-point CPU LAS sample projected inside all 12 distributed calibrated photo frames (minimum 17,319; median 34,403 points), but its 1,500-point red overlays were too sparse for visual acceptance. The replacement diagnostic at `shared/outputs/05-3d-pointcloud/multimodal-projection-diagnostic-20260826-v2/` uses a one-million-point z-buffer RGB render, photo/point blend, point/edge overlay, DJI XMP gimbal-direction check, and inverse-rotation negative control. It auto-detected 8-bit RGB values stored in 16-bit LAS fields. Current ODM rotation passed: median RGB MAE 30.501 vs 60.612 inverse, 48.5% median improvement over shuffled photo locations, and 0.647-degree median gimbal-axis difference. This admits the next photo-feature/visibility stage but is not sub-pixel calibration, semantic truth, occlusion handling, or Point Transformer V3 accuracy evidence. |
| | | - Multi-view fusion sample: `shared/outputs/05-3d-pointcloud/multiview-feature-validation-20260826/` verified a 50,000-point/12-photo CPU route with 96.254% visible points and median four valid views. The full-photo sampled run at `shared/outputs/05-3d-pointcloud/multiview-feature-550-photo-sample-20260826/` then aggregated original XYZ/RGB with eight local photo descriptors (RGB, hue sin/cos, saturation, gradient, texture) over 550 photos after two-pixel closest-depth z-buffer filtering. It completed in 899.880 seconds CPU: all 50,000 sampled points were visible with at least two/three views, 99.978% had at least ten, median 154 and mean 147.2697 observations, and every feature value was finite. Its NPZ stores per-point feature mean/stddev and count but no semantic label. A new 50,000-point `multiview-annotation-source.ply` preserves original LAS RGB and has exact vertex-to-NPZ ordering, point-count and SHA-256 evidence. The console reveals it as a manual label source only after validating the fixed PLY/NPZ names, checksums and both array lengths; coverage pseudo-colours are excluded. `train_multiview_point_transformer.py` now provides a CPU-compatible 23-feature, two-stage local point-attention baseline with 16 neighbours and spatial XY train/validation/test blocks. It requires at least two classes, 500 reviewed points per class, and every class in every split; it is not official Point Transformer V3/Pointcept, whose `spconv` dependency stack is not installed or validated. A CPU synthetic 1,201-point acceptance case completed in 0.212 s and emitted a model, preview and metrics. The real 50,000-point sample remains unlabelled; field accuracy and PTv3 accuracy are not established. |
| | | - Console: `GET/POST /api/3d-pointcloud/runs` remains enabled on the independent local console at `http://127.0.0.1:6188`. The top workflow tabs separate `照片三维重建` from `既有点云理解`: photos accept 3-1000 JPG/JPEG files through bounded binary uploads, preserve raw bytes in a new run directory, use an asynchronous fixed-script CPU SfM/MVS job and poll queued/sparse/dense/completed/failed status; large photo sets can take hours or longer. The GPS/RTK prior toggle selects the verified spatial-neighbour configuration only for photos with valid position metadata. Existing point-cloud uploads accept at most two allowlisted PLY/PCD/XYZ/LAS/LAZ files and use only the fixed Open3D script to produce raster/vector outputs. The case library discovers `ODX 对齐 CPU 高分重建(H30T 18 图)` from output metadata. Dense CPU-MVS results have reliable-texture, point-colour and complete-geometry modes, a wireframe toggle, local-`+Z` top-down button, filter report, dense PLY and raw mesh downloads. The geometry mode is deliberately neutral and retains every mesh face; point-colour uses fused-point RGB; texture remains evidence-only. The console only needs `npm run build` after Vue/TypeScript source changes, not after user uploads, reconstruction runs or automatic case discovery. |
| | | - Photo jobs publish actual fixed-script workflow milestones to the console: feature extraction, matching, sparse mapping, RGB preparation, undistortion, dense fusion, mesh, texture and packaging. The displayed percentage is a weighted completed-stage indicator, not a remaining-time estimate, because COLMAP and OpenMVS do not expose reliable live percentages within each native stage. |
| | | - The fixed CPU photo-reconstruction timeout is 24 hours for large sequences. It remains a safety ceiling, not a predicted completion time. |
| | | - Limitations and next decision: this is a dense/textured local model that materially improves the DJI LRF diagnostic, but it is not a survey-grade result or independent check-point validation. pycolmap dense PatchMatch still requires CUDA; the verified dense route is OpenMVS CPU. It makes no DSM, orthomosaic, GIS-coordinate, semantic-class or engineering-measurement claim. Further accuracy work needs DJI/H30T intrinsics plus GNSS antenna-to-camera lever-arm/boresight calibration, or surveyed GCPs and separate check points. A CUDA/cloud ODX/WebODM workflow is a separate option only when it consumes the prepared `geo.txt`, trusted calibration/control, and reports independent checkpoint residuals. Existing-point-cloud ground/elevated is geometric rather than building/tree/equipment classification; its alpha-shape mesh is not watertight or accuracy-validated. OpenMVS and WebODM are AGPL-3.0-only, so commercial use needs a separate license assessment; NodeODX/ODX, DJI imagery, and all source data licenses remain separate. |
| | | |
| | | ### Point-cloud semantic classification update |
| | |
| | | - 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/`. |
| | | - Annotation workspace: users can add one local PLY/PCD/XYZ/LAS/LAZ source from |
| | | the console. The bounded binary upload retains immutable raw bytes and a |
| | | background CPU job creates a separate annotation preview before source |
| | | discovery. The local `annotation-classes.json` taxonomy starts with six |
| | | review classes and supports custom Chinese label/English key/RGB entries with |
| | | unused LAS-compatible codes. Every annotation revision snapshots its active |
| | | class definitions; used custom codes cannot be removed. This is annotation |
| | | metadata only, not an automatic semantic-model capability or labelled truth. |
| | | - Source lifecycle: the annotation workspace has a server-calculated **Remove |
| | | complete data chain** action. It requires confirmation and removes only the |
| | | fixed local output/raw/processed source chain and dependent annotations, |
| | | training models, and inference outputs. It never accepts a browser path or |
| | | deletes `baseData`/external inputs; a derived source without a console raw |
| | | copy reports that its external input is retained. |
| | | A mistaken revision can be deleted after confirmation; the deletion endpoint |
| | | is constrained to that revision directory and never removes source clouds, |
| | | semantic outputs or training outputs. |
| | |
| | | API checks passed. The annotation viewer now uses sRGB-correct anti-aliased |
| | | circular points and separates browse left-drag rotation from brush/rectangle |
| | | left-drag labels; right-drag rotates, middle-drag pans and the wheel zooms. |
| | | Annotation sources use direct full-point display: the viewer has no adaptive |
| | | local-detail layer, and every point in the selected source is available for |
| | | browsing and manual annotation. Point indices remain stable for saved revisions |
| | | and training. Large sources use correspondingly more browser memory and GPU |
| | | resources. |
| | | New annotation uploads now use a separate CPU RGB/XYZ preview preparation |
| | | path, so their extent does not enter the DSM/raster/vector pipeline. The |
| | | output retains every readable finite source point. Textured PLY meshes without readable |
| | | vertex RGB are retained as neutral geometry-review sources but cannot train |
| | | the current RGB semantic model. |
| | | The model workspace now provides automatic annotation of the current local RGB |
| | | source without a second upload. It runs a selected completed model |
| | | asynchronously, records source/model checksums plus per-point confidence in a |
| | | thresholded candidate artifact, and requires an explicit confirmation before |
| | | merging candidates into a new revision. Existing human labels take priority on |
| | | overlap. A 50,000-point GPU validation completed in 3.740 seconds and retained |
| | | 44,736 candidates at a 0.95 threshold; this verifies the route, not model |
| | | accuracy on the full source. |
| | | A textured-mesh RGB baking route now accepts a ZIP containing exactly one PLY |
| | | mesh and its referenced texture images, samples face-centre UV texture colour |
| | | into a new RGB/XYZ PLY, and exposes a 50,000-point validation source before a |
| | | larger preview is made. Real `Model_0.zip` validation had one |
| | | 7,430,655-vertex / 14,820,237-face mesh and 141 texture images; it baked |
| | | 50,000 RGB samples in 174.921 CPU seconds, using 140 textures, with 80.17% |
| | | non-grey colours. A full direct-display source completed in 275.120 seconds |
| | | with all 14,820,237 textured mesh faces represented as RGB face-centre points |
| | | (222,303,737-byte PLY). User visual review of UV alignment remains required. |
| | | Middle-button auto-scroll is suppressed and a rotate-centre tool places the |
| | | orbit target at a clicked visible point without writing a label. |
| | | A model-orientation popover provides independent local X/Y/Z display rotation |
| | | from 0-360 degrees about the model centre; it leaves source coordinates and |
| | | stored annotation indices unchanged while selection follows the display transform. |
| | | Sparse SfM, semantic, supervised-prediction, and mesh preview viewers share |
| | | that orientation control, click-to-set orbit centre, top-down, reset, and |
| | | middle-button pan without browser auto-scroll. These are display-only |
| | | transforms and do not change source or downloadable artifacts. |
| | | Console/unit/type checks passed. Visual annotation review awaits a user |
| | | screenshot under the no-browser-inspection rule. |
| | | - Applied-model workflow: the supervised area now discovers only complete local |
| | |
| | | # GeoAI Workbench |
| | | |
| | | ## Working conventions |
| | | |
| | | - Use Python 3.12 in project environments. Every supported workflow must retain |
| | | a CPU-compatible route. CUDA is reserved for verified model or vision |
| | | computation through a fixed environment; preparation, file I/O, geometry, |
| | | export, and the console remain CPU unless separately validated. |
| | | - The local experiment console is loopback-only on a `6xxx` port and remains |
| | | independent from drone products. Do not use browser automation for visual |
| | | acceptance; request a targeted user screenshot and run non-browser checks. |
| | | - Run the console frontend build only after Vue/TypeScript/CSS or build-time |
| | | client configuration changes. Console-owned result removal must present a |
| | | server-derived plan and confirmation, and must preserve `baseData`, external |
| | | inputs, baselines, validation assets, and unrelated runs. |
| | | |
| | | 本工作区以 [opengeos/geoai](https://github.com/opengeos/geoai) 的 `geoai-py` |
| | | 为 GeoAI 工作流基础,围绕无人机和遥感数据做实验与产品验证。这里的“GeoAI |
| | | 能力”分为三层:`geoai-py` 直接提供的功能、基于它的底层地理/AI生态组合出的 |
| | |
| | | |
| | | Vite 开发服务固定使用 `http://127.0.0.1:6174/apps/workbench-console/`,会通过本机代理读取 `6173` 的结果工件。 |
| | | |
| | | ## GPU execution |
| | | ## CPU and GPU execution |
| | | |
| | | Object detection and ChangeStar change detection retain their CPU environments |
| | | and add separately verified CUDA environments. The server probes only the fixed |
| | | `01-object-detection-cuda` or `00-change-detection-cuda` interpreter before a |
| | | run; a successful probe selects CUDA, otherwise the corresponding fixed CPU |
| | | interpreter is used. User requests cannot supply an interpreter, device path, |
| | | or command. Each capability writes the requested and actual device to its run |
| | | metadata. This console remains loopback-only and independent of drone products. |
| | | Object detection, ChangeStar change detection, and point-cloud supervised |
| | | training/inference expose `auto`, `cpu`, and `cuda` choices. The local service |
| | | always invokes only fixed allowlisted virtual environments. `auto` probes the |
| | | capability's fixed CUDA interpreter, chooses it only when PyTorch confirms CUDA, |
| | | and otherwise uses its retained CPU environment. `cpu` does not probe the GPU. |
| | | `cuda` fails explicitly when the probe fails; it never silently becomes a CPU |
| | | run. New `run_metadata.json` files record requested/actual device, environment, |
| | | PyTorch version, fallback state, and fallback reason. Photo 3D reconstruction, |
| | | point-cloud geometry, semantic rule classification, file conversion, exports, |
| | | and the console itself remain CPU workflows. The current photo-MVS CUDA tools |
| | | are not validated and are not exposed as a console option. |
| | | |
| | | User requests cannot supply an interpreter, device path, or command. This |
| | | console remains loopback-only and independent of drone products. |
| | | |
| | | ## 地图底图 |
| | | |
| | |
| | | rendered as DSM or GeoJSON without valid georeferencing. |
| | | |
| | | The top-level `05-3d-pointcloud` workflow switch keeps incompatible inputs |
| | | separate. `照片三维重建` accepts 3-30 JPG/JPEG files from one coherent camera |
| | | separate. `照片三维重建` accepts 3-1000 JPG/JPEG files from one coherent camera |
| | | sequence through bounded binary uploads, preserves the raw bytes, then runs a |
| | | background CPU SfM/MVS job and polls `queued`, `sparse_sfm`, `dense_mvs`, |
| | | `complete`, or `failed` status. Its GPS/RTK-prior option uses the verified |
| | |
| | | GeoAI footprint vector workflow. Creating a run or discovering a new case |
| | | does not require rebuilding the frontend; `npm run build` is only required |
| | | after changing the Vue/TypeScript console source. |
| | | |
| | | The reconstruction job response also reports actual completed workflow |
| | | milestones from its fixed scripts: feature extraction, matching, sparse mapping, |
| | | RGB preparation, undistortion, dense fusion, meshing, texture export, and final |
| | | artifact packaging. The displayed percentage is a weighted stage milestone, not |
| | | a remaining-time estimate: COLMAP and OpenMVS do not expose reliable live |
| | | sub-stage percentages. |
| | | |
| | | The fixed CPU photo-reconstruction timeout is 24 hours for large sequences. It |
| | | is a hard safety limit, not a predicted completion time. |
| | | |
| | | The same workspace also exposes point-cloud semantic classification: |
| | | PLY/PCD/XYZ/LAS/LAZ files are sent through bounded binary streaming, retained |
| | |
| | | also expose an action that classifies only their fixed local `dense.ply`; the |
| | | server never accepts a browser-supplied path. |
| | | |
| | | Semantic cases with a generated RGB annotation source provide a separate manual |
| | | annotation and supervised-training area. Brush and rectangle selections become |
| | | Semantic cases with a generated RGB annotation source, plus complete |
| | | `multiview-feature-*` directories whose ordered original-RGB PLY/NPZ checksum |
| | | contract passes, provide a separate manual annotation and supervised-training |
| | | area. The fusion source is labelled as a photo-feature review sample and is not |
| | | fed into the existing PointNet-style trainer as if it were Point Transformer V3. |
| | | After sufficient reviewed labels, its `多视角特征训练` action uses a separate |
| | | CPU/CUDA local-attention baseline over the fixed same-order feature NPZ. It |
| | | requires each reviewed class to span XY training, validation, and test blocks. |
| | | This is not the uninstalled official Pointcept/Point Transformer V3 stack and |
| | | is not exposed in the generic arbitrary-cloud model-application workflow. |
| | | Brush and rectangle selections become |
| | | separate annotation revisions rather than altering source clouds or rule results. |
| | | The annotation workspace can also add an independent local PLY/PCD/XYZ/LAS/LAZ |
| | | source through its bounded binary upload route. The server preserves its raw |
| | | bytes, starts a background CPU preview job, and adds the derived RGB/XYZ preview |
| | | to the source list only after successful processing. The taxonomy is local |
| | | annotation metadata: users can add a Chinese label, lowercase English key and |
| | | RGB colour, and the server assigns an unused LAS-compatible code. Saved |
| | | revisions snapshot their class metadata; a custom class referenced by a saved |
| | | revision cannot be deleted. These controls do not manufacture semantic truth or |
| | | replace reviewed labels required for supervised training. |
| | | For a source that is no longer needed, **Remove complete data chain** first |
| | | fetches a server-calculated deletion plan, then requires an irreversible |
| | | confirmation. It removes only fixed local point-cloud raw/processed/output |
| | | directories plus discovered annotation, training, and inference dependents. |
| | | `baseData` and external source inputs are excluded from every removal plan. |
| | | The annotation point viewer uses sRGB-correct anti-aliased circular points. |
| | | Browse mode keeps left-drag rotation; brush/rectangle mode reserves left-drag |
| | | for labels, with right-drag rotation, middle-drag panning and wheel zoom. |
| | |
| | | display rotations about the model centre. It never changes the original point |
| | | cloud or stored annotation indices; picking and rectangle selection follow the |
| | | currently displayed orientation. |
| | | The viewer loads and renders every point contained in the selected annotation |
| | | source directly. It does not switch to a zoom-dependent local detail layer, and |
| | | point indices stay stable for browsing, manual labels, saved revisions, and |
| | | training. Large sources consume correspondingly more browser memory and GPU |
| | | resources. |
| | | New annotation PLY/PCD/XYZ/LAS/LAZ uploads run a dedicated preview-only CPU |
| | | step. It preserves every readable finite XYZ and available vertex RGB point |
| | | and does not run the DSM, raster/vector, mesh, or rule-semantic pipeline. A |
| | | source without vertex RGB remains viewable with a neutral preview but cannot be |
| | | sent to the current RGB semantic-model trainer. |
| | | Textured-mesh ZIP bundles are prepared separately: the PLY's face UVs and its |
| | | referenced JPG/PNG tiles are baked into sampled face-centre RGB/XYZ points. A |
| | | small visual-validation source is exposed in the normal annotation-source list |
| | | before a larger training source is materialized. |
| | | |
| | | Sparse point-cloud, semantic-classification, supervised-prediction, and mesh |
| | | previews use the same viewer controls: local X/Y/Z orientation, click-to-set |
| | | orbit centre, top-down view, and reset. These controls only transform the |
| | | centred display wrapper; source PLY/GLB files and downloadable outputs remain |
| | | unchanged. |
| | | 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 training job requires two classes with at |
| | |
| | | candidate, especially for the current pole/tower class, not asset inventory or |
| | | an inspection conclusion. |
| | | |
| | | For an already selected annotation source, **Automatic annotation current |
| | | source** uses the selected local model without a second upload. Its asynchronous |
| | | candidate artifact records source/model checksums, class predictions, and |
| | | per-point confidence after a user-selected threshold. The completed run exposes an in-console, class-coloured prediction preview and predicted/high-confidence per-class counts for review. Brush/rectangle review can reject candidates or correct their classes, saving an independent correction draft. It remains separate from |
| | | human truth until **Confirm merge candidates** is pressed; the merge applies candidates, review corrections, then existing human labels as the highest priority, and creates a new annotation revision for |
| | | the next training cycle. |
| | | |
| | | The 3D point-cloud capability is presented as four focused workspaces rather |
| | | than one long mixed result page: |
| | | |
| | |
| | | families, but the pages never render the unrelated result bands together. The |
| | | model workspace deliberately uses its generated annotation sources and trained |
| | | models rather than a general case library. |
| | | |
| | | ## Result removal |
| | | |
| | | Every connected capability case library provides a complete-result removal |
| | | control. It first reads a server-calculated deletion plan, then requires a |
| | | second irreversible confirmation. A removable console run is restricted to the |
| | | fixed `shared/outputs/<capability>/runs/<run-id>/` layout and removes only that |
| | | output, its console-upload raw copy, processed copy, and known dependent |
| | | artifacts. `baseData`, external source inputs, sibling runs, and project |
| | | baseline/validation cases are never removed. Baseline and validation cases show |
| | | their protected status instead of accepting deletion. |
| | | |
| | | The point-cloud training-model selector has a separate removal control. It |
| | | removes the selected locally trained model plus its discovered inference and |
| | | automatic-annotation outputs, while preserving annotation revisions, `baseData`, |
| | | and external input files. |
| | | |
| | | ## Maintenance conventions |
| | | |
| | | Run `npm run build` after changing Vue, TypeScript, CSS, or build-time client |
| | | configuration. A new result, a data-processing run, or a server-only API change |
| | | does not require a frontend rebuild. |
| | | |
| | | The local console is not visually inspected through browser automation. For a |
| | | visual UI, map, image, or 3D acceptance check, the requested interaction is |
| | | specified to the user and the user provides the screenshot; build, HTTP, |
| | | artifact, and automated checks remain independent evidence. |
| | |
| | | export interface Detection { class_id: number; class_name: string; confidence: number; bbox_xyxy: number[]; } |
| | | export interface GeoFeature { type?: string; properties?: Record<string, unknown>; geometry?: { type?: string; coordinates?: unknown }; } |
| | | export interface DetectionImage { file: string; annotated_file: string; width: number; height: number; detections: Detection[]; } |
| | | export interface DetectionRun { detection_count: number; processed_images: number; elapsed_seconds: number; device: string; model: string; confidence: number; input_dir: string; notes: string[]; created_at?: string; } |
| | | export type ComputeDevice = "auto" | "cpu" | "cuda"; |
| | | export interface ExecutionMetadata { requested_device: ComputeDevice; actual_device: "cpu" | "cuda"; environment: string; torch_version: string; fallback_used: boolean; fallback_reason: string | null; } |
| | | export interface DetectionRun { detection_count: number; processed_images: number; elapsed_seconds: number; device: string; requested_device?: ComputeDevice; execution?: ExecutionMetadata; model: string; confidence: number; input_dir: string; notes: string[]; created_at?: string; } |
| | | export interface DetectionCase { id: string; label: string; note: string; artifactRoot: string; inputRoot: string; createdAt: string; run: DetectionRun; images: DetectionImage[]; } |
| | | export interface SemanticImage { file: string; width: number; height: number; mask_file: string; overlay_file: string; raster_file: string; vector_file: string; class_pixel_counts: Record<string, number>; georeferenced: boolean; vectorizer: string; elapsed_seconds: number; } |
| | | export interface SemanticRun { capability: string; classification: string; task_id?: string; task_name?: string; created_at?: string; geoai_version: string; method: string; model: string; device: string; thresholds: Record<string, number>; input_count: number; processed_images: number; elapsed_seconds: number; images: SemanticImage[]; classes: Array<{ id: number; key: string; label: string; color: number[] }>; limitations: string[]; input_dir?: string; raw_input_dir?: string; } |
| | |
| | | export interface AnomalyRun { capability: string; classification: string; created_at?: string; versions: Record<string, string>; method: string; model: string; device: string; reference_count: number; reference_tile_count: number; input_count: number; reference_images: Array<{ file: string; tile_count: number }>; feature_names: string[]; parameters: { tile_size: number; stride: number; threshold_quantile: number; random_state: number; spatial_mode_requested?: "auto" | "global" | "aligned"; rule_mode_selected?: "global" | "aligned" }; thresholds: { rule_score: number; isolation_score: number }; images: AnomalyImage[]; elapsed_seconds: number; limitations: string[]; } |
| | | export interface AnomalyCase { id: string; label: string; note: string; artifactRoot: string; inputRoot: string; referenceRoot: string; createdAt: string; run: AnomalyRun; candidates: Record<string, AnomalyCandidate[]>; } |
| | | export interface ChangeFeature { type: "Feature"; properties: { feature_id?: number; area_pixels?: number; mean_probability?: number; max_probability?: number; bounds_pixel?: number[]; confidence?: number; class?: number }; geometry: { type: string; coordinates: unknown }; } |
| | | export interface ChangeRun { schema_version: number; capability: string; classification: string; geoai_version: string; method: string; model: string; device: string; processing_mode?: "image" | "geotiff"; requested_processing_mode?: "auto" | "image" | "geotiff"; thresholds: Record<string, number>; tile_size: number; overlap: number; max_dimension?: number; effective_max_dimension?: number; input_files: string[]; input_shape: number[]; processed_shape: number[]; registration: { method: string; matches: number; inliers: number; inlier_ratio: number; valid_ratio: number }; valid_pixel_ratio: number; raw_changed_pixels: number; changed_pixels: number; changed_pixel_ratio: number; vector_feature_count: number; rectangle_feature_count?: number; generic_difference?: { candidate_pixels: number; candidate_pixel_ratio: number; components_after_filter: number }; georeferenced: boolean; coordinate_basis: string; crs: string | null; elapsed_seconds: number; limitations: string[]; artifacts: { probability_raster: string; raw_mask_raster: string; mask_raster: string; generic_difference_mask?: string; overlay: string; model_overlay?: string; before_processed_preview?: string; after_registered_preview?: string; vector: string; rectangle_vector?: string; rectangle_vector_wgs84?: string | null; features: string }; created_at?: string; } |
| | | export interface ChangeRun { schema_version: number; capability: string; classification: string; geoai_version: string; method: string; model: string; device: string; requested_device?: ComputeDevice; execution?: ExecutionMetadata; processing_mode?: "image" | "geotiff"; requested_processing_mode?: "auto" | "image" | "geotiff"; thresholds: Record<string, number>; tile_size: number; overlap: number; max_dimension?: number; effective_max_dimension?: number; input_files: string[]; input_shape: number[]; processed_shape: number[]; registration: { method: string; matches: number; inliers: number; inlier_ratio: number; valid_ratio: number }; valid_pixel_ratio: number; raw_changed_pixels: number; changed_pixels: number; changed_pixel_ratio: number; vector_feature_count: number; rectangle_feature_count?: number; generic_difference?: { candidate_pixels: number; candidate_pixel_ratio: number; components_after_filter: number }; georeferenced: boolean; coordinate_basis: string; crs: string | null; elapsed_seconds: number; limitations: string[]; artifacts: { probability_raster: string; raw_mask_raster: string; mask_raster: string; generic_difference_mask?: string; overlay: string; model_overlay?: string; before_processed_preview?: string; after_registered_preview?: string; vector: string; rectangle_vector?: string; rectangle_vector_wgs84?: string | null; features: string }; created_at?: string; } |
| | | export interface ChangeCase { id: string; label: string; note: string; artifactRoot: string; beforeImage: string; afterImage: string; rawAfterImage?: string; registeredBeforeImage?: string; registeredAfterImage?: string; createdAt: string; run: ChangeRun; features: ChangeFeature[]; } |
| | | export interface ChangeScanResult { id: string; label: string; threshold: number; minimumAreaPixels: number; cleanedComponents: number; changedPixels: number; changedPixelRatio: number; vectorFeatureCount: number; fullVectorFeatureCount?: number | null; rectangleFeatureCount?: number | null; overlay: string; mask: string; regions: string; vector?: string | null; rectangleVector?: string | null; rectangleVectorWgs84?: string | null; } |
| | | export interface ChangeParameterScan { id: string; label: string; note: string; artifactRoot: string; sourceRun?: string; userSubmitted?: boolean; contactSheet?: string | null; results: ChangeScanResult[]; } |
| | | export interface ChangeScanJob { id: string; status: "queued" | "running" | "completed" | "failed"; phase?: "inference" | "parameter-scan" | "vectorization" | "done" | "error"; scanId?: string; error?: string; thresholds?: number[]; minimumAreas?: number[]; } |
| | | export interface ChangeScanJob { id: string; status: "queued" | "running" | "completed" | "failed"; phase?: "inference" | "parameter-scan" | "vectorization" | "done" | "error"; scanId?: string; error?: string; thresholds?: number[]; minimumAreas?: number[]; requestedDevice?: ComputeDevice; device?: "cpu" | "cuda"; environment?: string; torchVersion?: string; fallbackUsed?: boolean; fallbackReason?: string | null; } |
| | | export interface TrajectoryEvent { event_id: string; event_type: string; track_ids: string[]; start_time: string; end_time: string; duration_seconds: number; } |
| | | export interface TrajectoryCaseRun { case_id: string; input_count: number; track_count: number; event_count: number; dropped_duplicate_observations: number; elapsed_seconds: number; device: string; input: string; thresholds: Record<string, number>; created_at?: string; } |
| | | export interface TrajectorySummary { track_id: string; entity_type: string; point_count: string; distance_m: string; average_speed_mps: string; behavior_labels: string; } |
| | |
| | | export interface AnomalyJob { id: string; runId: string; status: "queued" | "running" | "complete" | "failed"; createdAt: string; finishedAt?: string; error?: string; run?: AnomalyDefinition; } |
| | | export interface PhotoReconstructionUploadRef { uploadId: string; role: "photo"; name: string; size: number; sha256: string; } |
| | | export interface PointCloudUploadRef { uploadId: string; role: "pointcloud"; name: string; size: number; sha256: string; } |
| | | 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 PhotoReconstructionProgress { percent: number; stage: string; message: string; inputImages: number; updatedAt?: string; estimate: boolean; } |
| | | 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; progress?: PhotoReconstructionProgress; run?: PointCloudDefinition; } |
| | | export interface PointCloudAnnotationSource { id: string; runId: string; label: string; artifactRoot: string; file: string; url: string; sha256: string; pointCount: number; sourceKind: string; sourceHasRgb?: boolean; detailAvailable?: boolean; detailFile?: string | null; } |
| | | export interface PointCloudAnnotationSourceDeletionPlan { sourceId: string; label: string; sourceKind: string; runId: string; artifactRoot: string; outputDirectories: number; rawDirectories: number; processedDirectories: number; annotationRevisions: number; trainingRuns: number; inferenceRuns: number; siblingSources: number; removesOriginalUpload: boolean; preservesExternalInputs: boolean; } |
| | | export interface PointCloudAnnotationSourceRemoval { sourceId: string; runId: string; removed: Pick<PointCloudAnnotationSourceDeletionPlan, "outputDirectories" | "rawDirectories" | "processedDirectories" | "annotationRevisions" | "trainingRuns" | "inferenceRuns" | "siblingSources">; preservedExternalInputs: boolean; } |
| | | export interface PointCloudAnnotationClass { code: number; key: string; label: string; color: number[]; builtIn: boolean; } |
| | | 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; requestedDevice?: string; device: string; environment?: string; torchVersion?: string; createdAt: string; error?: string; artifactRoot?: string; metrics?: string; model?: string; preview?: string; } |
| | | export interface PointCloudAnnotationSourceJob { id: string; runId: string; inputName: string; status: "queued" | "running" | "complete" | "failed"; stage: string; createdAt: string; error?: string; } |
| | | export interface PointCloudTrainingJob { id: string; annotationId: string; trainer?: "rgb_xyz_baseline" | "multiview_local_attention_baseline"; status: "queued" | "running" | "complete" | "failed"; stage: string; requestedDevice?: ComputeDevice; device: string; environment?: string; torchVersion?: string; fallbackUsed?: boolean; fallbackReason?: string | null; 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; 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 interface PointCloudInferenceJob { id: string; runId: string; modelId: string; sourceId?: string; inputName: string; candidateConfidence?: number; status: "queued" | "running" | "complete" | "failed"; stage: string; requestedDevice?: ComputeDevice; device: string; environment?: string; torchVersion?: string; fallbackUsed?: boolean; fallbackReason?: string | null; createdAt: string; error?: string; artifactRoot?: string; metadata?: string; preview?: string; classifiedLas?: string; classCounts?: string; summary?: string; candidateFile?: string | null; } |
| | | export interface PointCloudAutoAnnotationReview { runId: string; sourceId: string; correctionCount: number; savedAt: string; } |
| | | export interface RunDeletionPlan { capability: string; runId: string; label: string; removable: boolean; reason?: string | null; outputDirectories: string[]; rawDirectories: string[]; processedDirectories: string[]; dependentDirectories: string[]; preservesExternalInputs: boolean; } |
| | | export interface PointCloudSemanticModelDeletionPlan { modelId: string; label: string; removable: boolean; trainingDirectories: string[]; inferenceDirectories: string[]; autoAnnotationDirectories: string[]; rawDirectories: string[]; processedDirectories: string[]; preservesAnnotationRevisions: boolean; preservesExternalInputs: boolean; } |
| | | |
| | | export const artifactUrl = (path: string) => `/${path.replace(/\\/g, "/").split("/").map(encodeURIComponent).join("/")}`; |
| | | |
| | |
| | | |
| | | async function postRun<T>(path: string, body: object): Promise<T> { return responseJson<T>(await fetch(artifactUrl(path), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })); } |
| | | export async function createTrajectoryRun(files: { flight: UploadFilePayload; route: UploadFilePayload; restricted: UploadFilePayload; flyable?: UploadFilePayload }) { return postRun<{ run: TrajectoryDefinition }>("api/trajectory/runs", { files }); } |
| | | export async function createDetectionRun(images: UploadFilePayload[]) { return postRun<{ run: DetectionDefinition }>("api/object-detection/runs", { images }); } |
| | | export async function createDetectionRun(images: UploadFilePayload[], device: ComputeDevice = "auto") { return postRun<{ run: DetectionDefinition }>("api/object-detection/runs", { images, device }); } |
| | | export async function createSemanticRun(images: UploadFilePayload[], taskId: string) { return postRun<{ run: SemanticDefinition }>("api/semantic-mapping/runs", { images, taskId }); } |
| | | export async function createMeasurementRun(rasters: UploadFilePayload[]) { return postRun<{ run: MeasurementDefinition }>("api/spatial-measurement/runs", { rasters }); } |
| | | export async function createPointCloudRun(pointClouds: Array<UploadFilePayload | PointCloudUploadRef>) { return postRun<{ run: PointCloudDefinition }>("api/3d-pointcloud/runs", { pointClouds }); } |
| | |
| | | export async function createPhotoReconstructionRun(photos: PhotoReconstructionUploadRef[], usePositionPriors: boolean) { return postRun<{ job: PhotoReconstructionJob }>("api/3d-pointcloud/photo-reconstruction-runs", { photos, usePositionPriors }); } |
| | | export async function loadPhotoReconstructionJob(jobId: string) { return (await getJson<{ job: PhotoReconstructionJob }>(`api/3d-pointcloud/photo-reconstruction-jobs/${jobId}`)).job; } |
| | | export async function loadPointCloudAnnotationSources() { return (await getJson<{ sources: PointCloudAnnotationSource[] }>("api/3d-pointcloud/annotation-sources")).sources; } |
| | | export async function loadPointCloudAnnotationSourceDeletionPlan(sourceId: string) { return (await getJson<{ plan: PointCloudAnnotationSourceDeletionPlan }>(`api/3d-pointcloud/annotation-source-deletion-plans/${sourceId}`)).plan; } |
| | | export async function deletePointCloudAnnotationSource(sourceId: string) { return responseJson<{ removed: PointCloudAnnotationSourceRemoval }>(await fetch(artifactUrl(`api/3d-pointcloud/annotation-sources/${sourceId}`), { method: "DELETE" })); } |
| | | export async function loadPointCloudAnnotationClasses() { return (await getJson<{ classes: PointCloudAnnotationClass[] }>("api/3d-pointcloud/annotation-classes")).classes; } |
| | | export async function loadPointCloudAnnotations() { return (await getJson<{ annotations: PointCloudAnnotation[] }>("api/3d-pointcloud/annotations")).annotations; } |
| | | export async function createPointCloudAnnotationClass(value: { key: string; label: string; color: number[] }) { return postRun<{ class: PointCloudAnnotationClass }>("api/3d-pointcloud/annotation-classes", value); } |
| | | export async function deletePointCloudAnnotationClass(code: number) { return responseJson<{ deletedCode: number }>(await fetch(artifactUrl(`api/3d-pointcloud/annotation-classes/${code}`), { method: "DELETE" })); } |
| | | export async function createPointCloudAnnotationSourceRun(pointCloud: PointCloudUploadRef) { return postRun<{ job: PointCloudAnnotationSourceJob }>("api/3d-pointcloud/annotation-source-runs", { pointCloud }); } |
| | | export async function loadPointCloudAnnotationSourceJob(jobId: string) { return (await getJson<{ job: PointCloudAnnotationSourceJob }>(`api/3d-pointcloud/annotation-source-jobs/${jobId}`)).job; } |
| | | 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" = "auto") { return postRun<{ job: PointCloudTrainingJob }>("api/3d-pointcloud/training-runs", { annotationId, device }); } |
| | | export async function createPointCloudTrainingRun(annotationId: string, device: ComputeDevice = "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 }); } |
| | | export async function loadPointCloudSemanticModelDeletionPlan(modelId: string) { return (await getJson<{ plan: PointCloudSemanticModelDeletionPlan }>(`api/3d-pointcloud/semantic-model-deletion-plans/${encodeURIComponent(modelId)}`)).plan; } |
| | | export async function deletePointCloudSemanticModel(modelId: string) { return responseJson<{ removed: { modelId: string; removedDirectories: string[] } }>(await fetch(artifactUrl(`api/3d-pointcloud/semantic-models/${encodeURIComponent(modelId)}`), { method: "DELETE" })); } |
| | | export async function createPointCloudModelInference(modelId: string, pointCloud: PointCloudUploadRef, device: ComputeDevice = "auto") { return postRun<{ job: PointCloudInferenceJob }>("api/3d-pointcloud/model-inference-runs", { modelId, pointCloud, device }); } |
| | | export async function createPointCloudAutoAnnotation(modelId: string, sourceId: string, candidateConfidence = 0.95, device: ComputeDevice = "auto") { return postRun<{ job: PointCloudInferenceJob }>("api/3d-pointcloud/auto-annotation-runs", { modelId, sourceId, candidateConfidence, device }); } |
| | | export async function loadLatestPointCloudAutoAnnotation(sourceId: string, modelId: string) { const query = new URLSearchParams({ sourceId, modelId }); return (await responseJson<{ job: PointCloudInferenceJob | null }>(await fetch(`/api/3d-pointcloud/auto-annotation-runs/latest?${query.toString()}`, { cache: "no-store" }))).job; } |
| | | export async function loadPointCloudAutoAnnotationReview(runId: string) { return (await getJson<{ review: { corrections?: Array<[number, number]> } | null }>(`api/3d-pointcloud/auto-annotation-review-drafts/${encodeURIComponent(runId)}`)).review; } |
| | | export async function savePointCloudAutoAnnotationReview(runId: string, sourceId: string, corrections: Array<[number, number]>) { return postRun<{ review: PointCloudAutoAnnotationReview }>("api/3d-pointcloud/auto-annotation-review-drafts", { runId, sourceId, corrections }); } |
| | | export async function acceptPointCloudAutoAnnotation(runId: string, sourceId: string, baseAnnotationId?: string) { return postRun<{ annotation: PointCloudAnnotation }>("api/3d-pointcloud/auto-annotation-acceptances", { runId, sourceId, baseAnnotationId }); } |
| | | export async function loadPointCloudModelInferenceJob(jobId: string) { return (await getJson<{ job: PointCloudInferenceJob }>(`api/3d-pointcloud/model-inference-jobs/${jobId}`)).job; } |
| | | export async function createRiskRuleRun(files: { observations: UploadFilePayload; zones: UploadFilePayload; rules: UploadFilePayload }) { return postRun<{ run: RiskRuleDefinition }>("api/risk-rule-engine/runs", { files }); } |
| | | export async function createChangeRun(files: { before: UploadFilePayload; after: UploadFilePayload }, threshold = 0.5, maxDimension = 0, processingMode: "auto" | "image" | "geotiff" = "auto") { return postRun<{ run: ChangeDefinition }>("api/change-detection/runs", { files, threshold, maxDimension, processingMode }); } |
| | | export async function createChangeRun(files: { before: UploadFilePayload; after: UploadFilePayload }, threshold = 0.5, maxDimension = 0, processingMode: "auto" | "image" | "geotiff" = "auto", device: ComputeDevice = "auto") { return postRun<{ run: ChangeDefinition }>("api/change-detection/runs", { files, threshold, maxDimension, processingMode, device }); } |
| | | export async function uploadChangeFile(file: File, role: "before" | "after"): Promise<ChangeUploadRef> { |
| | | const uploadId = globalThis.crypto.randomUUID().replaceAll("-", ""); |
| | | const query = new URLSearchParams({ role }); |
| | |
| | | const response = await fetch(`/api/change-detection/uploads/${uploadId}?${query.toString()}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream", "X-Upload-Name": encodeURIComponent(file.name) }, body: file }); |
| | | return responseJson<ChangeUploadRef>(response); |
| | | } |
| | | export async function createChangeRunFromUploads(uploads: { before: ChangeUploadRef; after: ChangeUploadRef }, threshold = 0.5, maxDimension = 0, processingMode: "auto" | "image" | "geotiff" = "auto") { return postRun<{ run: ChangeDefinition }>("api/change-detection/runs", { uploads, threshold, maxDimension, processingMode }); } |
| | | export async function createChangeScanFromUploads(uploads: { before: ChangeUploadRef; after: ChangeUploadRef }, thresholds: number[], minimumAreas: number[], maxDimension = 0, processingMode: "auto" | "image" | "geotiff" = "auto") { return postRun<{ job: ChangeScanJob }>("api/change-detection/scans", { uploads, thresholds, minimumAreas, maxDimension, processingMode }); } |
| | | export async function createChangeRunFromUploads(uploads: { before: ChangeUploadRef; after: ChangeUploadRef }, threshold = 0.5, maxDimension = 0, processingMode: "auto" | "image" | "geotiff" = "auto", device: ComputeDevice = "auto") { return postRun<{ run: ChangeDefinition }>("api/change-detection/runs", { uploads, threshold, maxDimension, processingMode, device }); } |
| | | export async function createChangeScanFromUploads(uploads: { before: ChangeUploadRef; after: ChangeUploadRef }, thresholds: number[], minimumAreas: number[], maxDimension = 0, processingMode: "auto" | "image" | "geotiff" = "auto", device: ComputeDevice = "auto") { return postRun<{ job: ChangeScanJob }>("api/change-detection/scans", { uploads, thresholds, minimumAreas, maxDimension, processingMode, device }); } |
| | | export async function getChangeScanJob(jobId: string) { return (await getJson<{ job: ChangeScanJob }>(`api/change-detection/scan-jobs/${jobId}`)).job; } |
| | | export async function promoteChangeScan(scanId: string, resultId: string) { return postRun<{ run: ChangeDefinition }>(`api/change-detection/scans/${encodeURIComponent(scanId)}/promote`, { resultId }); } |
| | | export async function uploadAnomalyFile(file: File, role: "reference" | "input"): Promise<AnomalyUploadRef> { |
| | |
| | | } |
| | | export async function createAnomalyRun(uploads: { reference: AnomalyUploadRef[]; input: AnomalyUploadRef[] }, parameters: { tileSize: number; stride: number; thresholdQuantile: number; randomState: number }) { return postRun<{ job: AnomalyJob }>("api/anomaly-detection/runs", { uploads, ...parameters }); } |
| | | export async function loadAnomalyJob(jobId: string) { return (await getJson<{ job: AnomalyJob }>(`api/anomaly-detection/jobs/${jobId}`)).job; } |
| | | export async function loadRunDeletionPlan(capability: string, runId: string) { return (await getJson<{ plan: RunDeletionPlan }>(`api/runs/${encodeURIComponent(capability)}/${encodeURIComponent(runId)}/deletion-plan`)).plan; } |
| | | export async function deleteRun(capability: string, runId: string) { return responseJson<{ removed: { capability: string; runId: string; removedDirectories: string[] } }>(await fetch(artifactUrl(`api/runs/${encodeURIComponent(capability)}/${encodeURIComponent(runId)}`), { method: "DELETE" })); } |
| | | export function readFileAsPayload(file: File): Promise<UploadFilePayload> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error(`无法读取 ${file.name}`)); reader.onload = () => { const value = String(reader.result ?? ""); resolve({ name: file.name, content: value.slice(value.indexOf(",") + 1) }); }; reader.readAsDataURL(file); }); } |
| | |
| | | |
| | | import { artifactUrl, createAnomalyRun, loadAnomalyJob, uploadAnomalyFile, type AnomalyCase } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import RunDeletionControl from "@/components/RunDeletionControl.vue"; |
| | | import { useArtifactStore } from "@/stores/artifacts"; |
| | | |
| | | const store = useArtifactStore(); |
| | |
| | | list.value = list.value.filter((item) => item.name !== name); |
| | | } |
| | | function syncSelection() { selectedName.value = currentCase.value?.run.images[0]?.file ?? ""; } |
| | | async function removeRun() { await store.loadAnomaly(true); caseId.value = Object.keys(store.anomalyCases)[0] ?? ""; syncSelection(); } |
| | | const delay = (milliseconds: number) => new Promise((resolve) => globalThis.setTimeout(resolve, milliseconds)); |
| | | |
| | | async function loadCaseFile(root: string, name: string) { |
| | |
| | | |
| | | <template v-if="currentCase && selectedImage"> |
| | | <a-row :gutter="[18, 18]" class="anomaly-workspace"> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><a-button class="reuse-case-button" type="link" size="small" :loading="reusingCaseId === item.value" :disabled="Boolean(reusingCaseId) || running" @click.stop="reuseCaseInputs(item.value)">复用输入</a-button></a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><a-space :size="0"><a-button class="reuse-case-button" type="link" size="small" :loading="reusingCaseId === item.value" :disabled="Boolean(reusingCaseId) || running" @click.stop="reuseCaseInputs(item.value)">复用输入</a-button><RunDeletionControl capability="09-anomaly-detection" :run-id="item.value" :label="item.label" @removed="removeRun" /></a-space></a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="currentCase.run.images.map((item) => ({ value: item.file, label: item.file }))" /><span>{{ selectedImage.width }} × {{ selectedImage.height }} · {{ selectedImage.tile_count }} 窗口</span></div><div class="comparison-grid anomaly-primary"><figure><figcaption>待检测原图</figcaption><a-image :src="artifactUrl(`${currentCase.inputRoot}/${selectedImage.file}`)" /></figure><figure><figcaption>两方法对比结果</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.overlay_file}`)" /></figure></div><div class="anomaly-legend"><span class="rule">仅规则</span><span class="isolation">仅 Isolation Forest</span><span class="agreement">两方法一致</span></div></section></a-col> |
| | | </a-row> |
| | | |
| | |
| | | import { computed, onMounted, ref } from "vue"; |
| | | import { DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue"; |
| | | |
| | | import { artifactUrl, createChangeRunFromUploads, createChangeScanFromUploads, getChangeScanJob, loadChangeParameterScans, promoteChangeScan, uploadChangeFile, type ChangeCase, type ChangeFeature, type ChangeParameterScan, type ChangeScanResult } from "@/api/artifacts"; |
| | | import { artifactUrl, createChangeRunFromUploads, createChangeScanFromUploads, getChangeScanJob, loadChangeParameterScans, promoteChangeScan, uploadChangeFile, type ChangeCase, type ChangeFeature, type ChangeParameterScan, type ChangeScanResult, type ComputeDevice } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import RunDeletionControl from "@/components/RunDeletionControl.vue"; |
| | | import { useArtifactStore } from "@/stores/artifacts"; |
| | | |
| | | const store = useArtifactStore(); |
| | |
| | | const threshold = ref(0.5); |
| | | const processingMode = ref<"auto" | "image" | "geotiff">("auto"); |
| | | const maxDimension = ref(0); |
| | | const device = ref<ComputeDevice>("auto"); |
| | | const showRunForm = ref(false); |
| | | const running = ref(false); |
| | | const runError = ref<string | null>(null); |
| | |
| | | const currentCase = computed<ChangeCase | undefined>(() => store.changeCases[caseId.value] ?? Object.values(store.changeCases)[0]); |
| | | const caseOptions = computed(() => Object.values(store.changeCases).map((item) => ({ value: item.id, label: item.label }))); |
| | | const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase()))); |
| | | async function removeRun() { await store.loadChange(true); caseId.value = Object.keys(store.changeCases)[0] ?? ""; } |
| | | const changePercent = computed(() => ((currentCase.value?.run.changed_pixel_ratio ?? 0) * 100).toFixed(3)); |
| | | const validPercent = computed(() => ((currentCase.value?.run.valid_pixel_ratio ?? 0) * 100).toFixed(2)); |
| | | const selectedScan = computed(() => parameterScans.value.find((item) => item.id === selectedScanId.value) ?? parameterScans.value[0]); |
| | |
| | | running.value = true; runError.value = null; |
| | | try { |
| | | const [before, after] = await Promise.all([uploadChangeFile(beforeFile.value, "before"), uploadChangeFile(afterFile.value, "after")]); |
| | | const { run } = await createChangeRunFromUploads({ before, after }, threshold.value, maxDimension.value, processingMode.value); |
| | | const { run } = await createChangeRunFromUploads({ before, after }, threshold.value, maxDimension.value, processingMode.value, device.value); |
| | | await store.loadChange(true); caseId.value = run.id; beforeFile.value = null; afterFile.value = null; showRunForm.value = false; |
| | | } catch (error) { runError.value = error instanceof Error ? error.message : "变化检测运行失败"; } |
| | | finally { running.value = false; } |
| | |
| | | scanRunning.value = true; scanJobError.value = null; scanJobMessage.value = "正在上传影像并排队..."; |
| | | try { |
| | | const [before, after] = await Promise.all([uploadChangeFile(scanBeforeFile.value, "before"), uploadChangeFile(scanAfterFile.value, "after")]); |
| | | const { job: created } = await createChangeScanFromUploads({ before, after }, scanThresholds.value, effectiveScanMinimumAreas.value, scanMaxDimension.value, scanProcessingMode.value); |
| | | const { job: created } = await createChangeScanFromUploads({ before, after }, scanThresholds.value, effectiveScanMinimumAreas.value, scanMaxDimension.value, scanProcessingMode.value, device.value); |
| | | let job = created; |
| | | while (job.status === "queued" || job.status === "running") { |
| | | scanJobMessage.value = job.phase === "parameter-scan" ? "模型推理完成,正在扫描参数组合..." : job.phase === "vectorization" ? "参数组合完成,正在生成完整 GeoJSON..." : "正在执行 ChangeStar 推理..."; |
| | |
| | | <div class="resolution-control"><label for="change-mode">处理模式</label><a-select id="change-mode" :value="processingMode" @update:value="updateProcessingMode"><a-select-option value="auto">自动识别</a-select-option><a-select-option value="image">普通图片</a-select-option><a-select-option value="geotiff">GeoTIFF 地理参考</a-select-option></a-select><span>自动模式会识别带 CRS 的 GeoTIFF;普通图片输出像素坐标,GeoTIFF 模式保留空间参考。</span></div> |
| | | <div class="threshold-control"><label for="change-threshold">变化阈值</label><div class="threshold-inputs"><a-slider id="change-threshold" :value="threshold" @update:value="updateThreshold" :min="0.01" :max="0.99" :step="0.01" /><a-input-number :value="threshold" @update:value="updateThreshold" :min="0.01" :max="0.99" :step="0.01" :precision="2" /></div><span>本次运行使用 {{ threshold.toFixed(2) }};默认值为 0.50</span></div> |
| | | <div class="resolution-control"><label for="change-resolution">处理分辨率</label><a-select id="change-resolution" :value="maxDimension" @update:value="updateMaxDimension"><a-select-option :value="0">GeoTIFF 原始分辨率 / 自动</a-select-option><a-select-option :value="1024">快速预览 · 1024 px</a-select-option><a-select-option :value="1536">标准 · 1536 px</a-select-option><a-select-option :value="2048">小目标优先 · 2048 px</a-select-option><a-select-option :value="3072">高细节 · 3072 px</a-select-option></a-select><span v-if="maxDimension === 0">GeoTIFF 保持原始像素尺寸;普通图片自动使用 1024 px。</span><span v-else>本次运行使用 {{ maxDimension }} px 长边上限;分辨率越高,耗时和显存/内存占用越大。</span></div> |
| | | <div class="resolution-control"><label for="change-device">计算设备</label><a-select id="change-device" v-model:value="device"><a-select-option value="auto">自动(可用 GPU 否则 CPU)</a-select-option><a-select-option value="cpu">CPU</a-select-option><a-select-option value="cuda">GPU CUDA</a-select-option></a-select><span>模型推理可使用 GPU;参数扫描会沿用此选择。显式 CUDA 探测失败时不回退。</span></div> |
| | | <a-alert v-if="runError" type="error" show-icon :message="runError" /> |
| | | <a-button type="primary" :loading="running" :disabled="!beforeFile || !afterFile" @click="submitRun"><PlayCircleOutlined />开始检测</a-button> |
| | | </section> |
| | |
| | | |
| | | <template v-if="currentCase"> |
| | | <a-row :gutter="[18, 18]" class="change-workspace"> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value">{{ item.label }}</a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="00-change-detection" :run-id="item.value" :label="item.label" @removed="removeRun" /></a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>双期与栅格结果</h2><p>{{ currentCase.run.input_shape[1] }} x {{ currentCase.run.input_shape[0] }} 像素,处理尺寸 {{ currentCase.run.processed_shape[1] }} x {{ currentCase.run.processed_shape[0] }}</p></div><a-tag color="green">{{ currentCase.run.device }} {{ currentCase.run.elapsed_seconds }} 秒</a-tag></div><div class="change-comparison"><figure><figcaption>第一期原图</figcaption><a-image :src="artifactUrl(currentCase.beforeImage)" /></figure><figure><figcaption>第二期原图</figcaption><a-image :src="artifactUrl(currentCase.rawAfterImage || currentCase.afterImage)" /></figure><figure><figcaption>第二期配准图</figcaption><a-image :src="artifactUrl(currentCase.registeredAfterImage || currentCase.afterImage)" /></figure><figure><figcaption>变化叠加:红色 = ChangeStar,青色 = 视觉差异候选</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.overlay}`)" /></figure></div></section></a-col> |
| | | </a-row> |
| | | |
| | | <a-row :gutter="[18, 18]" class="metric-row change-metrics"><a-col :xs="12" :lg="6"><a-statistic title="变化像素" :value="currentCase.run.changed_pixels" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="变化比例" :value="changePercent" suffix="%" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="变化图斑" :value="currentCase.run.vector_feature_count" /></a-col><a-col :xs="12" :lg="6"><a-statistic title="配准有效区" :value="validPercent" suffix="%" /></a-col></a-row> |
| | | |
| | | <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="14"><section class="surface-section"><div class="section-heading"><div><h2>矢量图斑</h2><p>像素坐标结果;红线叠加已换算为图像左上角显示坐标。</p></div><a-tag>{{ currentCase.run.vector_feature_count }} 个</a-tag></div><div class="change-vector"><img :src="artifactUrl(currentCase.afterImage)" alt="第二期影像" /><svg v-if="vectorPaths.length" :viewBox="`0 0 ${currentCase.run.input_shape[1]} ${currentCase.run.input_shape[0]}`" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg><a-empty v-else description="当前阈值下没有变化图斑" /></div></section></a-col><a-col :xs="24" :xl="10"><section class="surface-section"><h2>图斑明细</h2><a-table :data-source="currentCase.features.map((item) => item.properties)" :pagination="false" row-key="feature_id" size="small" :scroll="{ x: 480 }"><a-table-column title="编号" data-index="feature_id" key="feature_id" /><a-table-column title="面积 (px)" data-index="area_pixels" key="area_pixels" align="right" /><a-table-column title="平均概率" data-index="mean_probability" key="mean_probability" align="right" /><a-table-column title="最大概率" data-index="max_probability" key="max_probability" align="right" /></a-table><a-divider /><a-descriptions size="small" :column="1"><a-descriptions-item label="模型">{{ currentCase.run.model }}</a-descriptions-item><a-descriptions-item label="阈值">{{ currentCase.run.thresholds.change_probability }}</a-descriptions-item><a-descriptions-item label="模式">{{ currentCase.run.processing_mode === "geotiff" ? "GeoTIFF 地理参考" : "普通图片像素坐标" }}</a-descriptions-item><a-descriptions-item label="配准">{{ currentCase.run.registration.method }} / {{ currentCase.run.registration.inliers }} 内点</a-descriptions-item><a-descriptions-item label="坐标">{{ currentCase.run.georeferenced ? `${currentCase.run.crs} 地图坐标` : "无 CRS,像素坐标" }}</a-descriptions-item></a-descriptions></section></a-col></a-row> |
| | | <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="14"><section class="surface-section"><div class="section-heading"><div><h2>矢量图斑</h2><p>像素坐标结果;红线叠加已换算为图像左上角显示坐标。</p></div><a-tag>{{ currentCase.run.vector_feature_count }} 个</a-tag></div><div class="change-vector"><img :src="artifactUrl(currentCase.afterImage)" alt="第二期影像" /><svg v-if="vectorPaths.length" :viewBox="`0 0 ${currentCase.run.input_shape[1]} ${currentCase.run.input_shape[0]}`" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg><a-empty v-else description="当前阈值下没有变化图斑" /></div></section></a-col><a-col :xs="24" :xl="10"><section class="surface-section"><h2>图斑明细</h2><a-table :data-source="currentCase.features.map((item) => item.properties)" :pagination="false" row-key="feature_id" size="small" :scroll="{ x: 480 }"><a-table-column title="编号" data-index="feature_id" key="feature_id" /><a-table-column title="面积 (px)" data-index="area_pixels" key="area_pixels" align="right" /><a-table-column title="平均概率" data-index="mean_probability" key="mean_probability" align="right" /><a-table-column title="最大概率" data-index="max_probability" key="max_probability" align="right" /></a-table><a-divider /><a-descriptions size="small" :column="1"><a-descriptions-item label="模型">{{ currentCase.run.model }}</a-descriptions-item><a-descriptions-item label="请求 / 实际设备">{{ currentCase.run.requested_device ?? "既有记录" }} / {{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item v-if="currentCase.run.execution?.fallback_used" label="CPU 回退原因">{{ currentCase.run.execution.fallback_reason }}</a-descriptions-item><a-descriptions-item label="阈值">{{ currentCase.run.thresholds.change_probability }}</a-descriptions-item><a-descriptions-item label="模式">{{ currentCase.run.processing_mode === "geotiff" ? "GeoTIFF 地理参考" : "普通图片像素坐标" }}</a-descriptions-item><a-descriptions-item label="配准">{{ currentCase.run.registration.method }} / {{ currentCase.run.registration.inliers }} 内点</a-descriptions-item><a-descriptions-item label="坐标">{{ currentCase.run.georeferenced ? `${currentCase.run.crs} 地图坐标` : "无 CRS,像素坐标" }}</a-descriptions-item></a-descriptions></section></a-col></a-row> |
| | | |
| | | <section class="surface-section result-files change-files"><a-space wrap><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.probability_raster}`)" download><DownloadOutlined />概率 GeoTIFF</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.mask_raster}`)" download><FileImageOutlined />ChangeStar 变化栅格</a-button><a-button v-if="currentCase.run.artifacts.generic_difference_mask" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.generic_difference_mask}`)" download><FileImageOutlined />视觉差异候选栅格</a-button><a-button type="primary" v-if="currentCase.run.artifacts.rectangle_vector" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.rectangle_vector}`)" download><DownloadOutlined />规则四边形</a-button><a-button v-if="currentCase.run.artifacts.rectangle_vector_wgs84" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.rectangle_vector_wgs84}`)" download><DownloadOutlined />经纬度 GeoJSON</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.vector}`)" download><FileOutlined />原始图斑 GeoJSON</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" download><FileOutlined />运行元数据</a-button></a-space></section> |
| | | <a-alert class="change-limit" type="warning" show-icon message="当前近景边坡样本不在 ChangeStar 建筑变化权重的验证分布内;结果只能用于工作流与人工复核,不能直接形成工程结论。" /> |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, ref } from "vue"; |
| | | import { FileImageOutlined, InfoCircleOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue"; |
| | | import { createDetectionRun, artifactUrl, readFileAsPayload } from "@/api/artifacts"; |
| | | import { createDetectionRun, artifactUrl, readFileAsPayload, type ComputeDevice } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import RunDeletionControl from "@/components/RunDeletionControl.vue"; |
| | | import { useArtifactStore } from "@/stores/artifacts"; |
| | | |
| | | const store = useArtifactStore(); |
| | |
| | | const selectedName = ref(""); |
| | | const showRunForm = ref(false); |
| | | const files = ref<File[]>([]); |
| | | const device = ref<ComputeDevice>("auto"); |
| | | const running = ref(false); |
| | | const runError = ref<string | null>(null); |
| | | const searchText = ref(""); |
| | |
| | | const caseOptions = computed(() => Object.values(store.detectionCases).map((item) => ({ value: item.id, label: item.label }))); |
| | | const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase()))); |
| | | function syncSelection() { const candidate = currentCase.value?.images.reduce((best, item) => item.detections.length > (best?.detections.length ?? -1) ? item : best, undefined as typeof currentCase.value.images[number] | undefined); selectedName.value = candidate?.file ?? ""; } |
| | | async function removeRun() { await store.loadDetection(true); caseId.value = Object.keys(store.detectionCases)[0] ?? ""; syncSelection(); } |
| | | function beforeUpload(file: File) { files.value = [...files.value, file]; return false; } |
| | | function removeFile(file: { name: string }) { files.value = files.value.filter((item) => item.name !== file.name); } |
| | | async function submitRun() { if (!files.value.length) { runError.value = "请至少选择一张 JPG、JPEG 或 PNG 图像。"; return; } running.value = true; runError.value = null; try { const images = await Promise.all(files.value.map(readFileAsPayload)); const { run } = await createDetectionRun(images); await store.loadDetection(true); caseId.value = run.id; syncSelection(); files.value = []; showRunForm.value = false; } catch (error) { runError.value = error instanceof Error ? error.message : "目标检测运行失败"; } finally { running.value = false; } } |
| | | async function submitRun() { if (!files.value.length) { runError.value = "请至少选择一张 JPG、JPEG 或 PNG 图像。"; return; } running.value = true; runError.value = null; try { const images = await Promise.all(files.value.map(readFileAsPayload)); const { run } = await createDetectionRun(images, device.value); await store.loadDetection(true); caseId.value = run.id; syncSelection(); files.value = []; showRunForm.value = false; } catch (error) { runError.value = error instanceof Error ? error.message : "目标检测运行失败"; } finally { running.value = false; } } |
| | | onMounted(async () => { await store.loadDetection(); caseId.value = Object.keys(store.detectionCases)[0] ?? ""; syncSelection(); }); |
| | | </script> |
| | | |
| | |
| | | <div><h2>新建检测运行</h2><p>上传少量代表性影像,任务会自动优先使用本机可用 GPU,未通过检测时回退 CPU,并生成独立结果。</p></div> |
| | | <a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button> |
| | | </section> |
| | | <section v-if="showRunForm" class="surface-section run-form"><a-alert type="info" show-icon message="单次最多 12 张图像;当前基线识别人员和常见车辆,树木不在有效类别内。" /><a-upload class="run-upload" multiple accept=".jpg,.jpeg,.png" :file-list="files.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforeUpload" @remove="removeFile"><a-button><UploadOutlined />选择图像</a-button></a-upload><a-alert v-if="runError" type="error" show-icon :message="runError" /><a-button type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />开始检测</a-button></section> |
| | | <section v-if="showRunForm" class="surface-section run-form"><a-alert type="info" show-icon message="单次最多 12 张图像;当前基线识别人员和常见车辆,树木不在有效类别内。" /><div class="resolution-control"><label for="detection-device">计算设备</label><a-select id="detection-device" v-model:value="device"><a-select-option value="auto">自动(可用 GPU 否则 CPU)</a-select-option><a-select-option value="cpu">CPU</a-select-option><a-select-option value="cuda">GPU CUDA</a-select-option></a-select><span>GPU 仅用于模型推理;显式选择 CUDA 时,探测失败会直接提示。</span></div><a-upload class="run-upload" multiple accept=".jpg,.jpeg,.png" :file-list="files.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforeUpload" @remove="removeFile"><a-button><UploadOutlined />选择图像</a-button></a-upload><a-alert v-if="runError" type="error" show-icon :message="runError" /><a-button type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />开始检测</a-button></section> |
| | | <template v-if="currentCase && selectedImage"> |
| | | <div class="detection-workspace"> |
| | | <a-row :gutter="[18, 18]"><a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col><a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="currentCase.images.map((item) => ({ value: item.file, label: `${item.file} · ${item.detections.length} 个候选` }))" /><span class="toolbar-note">{{ currentCase.note }}</span></div><div class="comparison-grid"><figure><figcaption>原始影像</figcaption><a-image class="detection-image" :src="artifactUrl(`${currentCase.inputRoot}/${selectedImage.file}`)" :alt="`${selectedImage.file} 原始影像`" /></figure><figure><figcaption>标注结果</figcaption><a-image class="detection-image" :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.annotated_file}`)" :alt="`${selectedImage.file} 标注结果`" /></figure></div><p class="image-caption">{{ selectedImage.width }} x {{ selectedImage.height }} 像素 · {{ selectedImage.detections.length }} 个候选</p></section></a-col></a-row> |
| | | <a-row :gutter="[18, 18]"><a-col :xs="24" :xl="8"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="处理影像">{{ currentCase.run.processed_images }} 张</a-descriptions-item><a-descriptions-item label="检测候选">{{ currentCase.run.detection_count }} 个</a-descriptions-item><a-descriptions-item label="耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="模型">{{ currentCase.run.model }}</a-descriptions-item></a-descriptions><a-divider /><a-space direction="vertical"><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/detections.json`)" target="_blank"><FileImageOutlined />检测 JSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><InfoCircleOutlined />运行元数据</a-button></a-space></section></a-col><a-col :xs="24" :xl="16"><section class="surface-section"><div class="section-heading"><div><h2>当前影像检测明细</h2><p>边界框为 JPEG 像素坐标,不含可用地理坐标。</p></div></div><a-table :data-source="selectedImage.detections" :pagination="false" row-key="bbox_xyxy" size="small" :scroll="{ x: 680 }"><a-table-column title="类别" data-index="class_name" key="class_name" /><a-table-column title="置信度" key="confidence" align="right"><template #default="{ record }">{{ (record.confidence * 100).toFixed(1) }}%</template></a-table-column><a-table-column title="像素框 x1, y1, x2, y2" key="bbox"><template #default="{ record }">{{ record.bbox_xyxy.map((value: number) => value.toFixed(1)).join(', ') }}</template></a-table-column><template #emptyText>该影像没有达到当前阈值的候选目标。</template></a-table></section></a-col></a-row> |
| | | <a-row :gutter="[18, 18]"><a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="01-object-detection" :run-id="item.value" :label="item.label" @removed="removeRun" /></a-list-item></template></a-list></section></a-col><a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="currentCase.images.map((item) => ({ value: item.file, label: `${item.file} · ${item.detections.length} 个候选` }))" /><span class="toolbar-note">{{ currentCase.note }}</span></div><div class="comparison-grid"><figure><figcaption>原始影像</figcaption><a-image class="detection-image" :src="artifactUrl(`${currentCase.inputRoot}/${selectedImage.file}`)" :alt="`${selectedImage.file} 原始影像`" /></figure><figure><figcaption>标注结果</figcaption><a-image class="detection-image" :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.annotated_file}`)" :alt="`${selectedImage.file} 标注结果`" /></figure></div><p class="image-caption">{{ selectedImage.width }} x {{ selectedImage.height }} 像素 · {{ selectedImage.detections.length }} 个候选</p></section></a-col></a-row> |
| | | <a-row :gutter="[18, 18]"><a-col :xs="24" :xl="8"><section class="surface-section"><h2>本次运行</h2><a-descriptions size="small" :column="1"><a-descriptions-item label="处理影像">{{ currentCase.run.processed_images }} 张</a-descriptions-item><a-descriptions-item label="检测候选">{{ currentCase.run.detection_count }} 个</a-descriptions-item><a-descriptions-item label="耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item><a-descriptions-item label="请求 / 实际设备">{{ currentCase.run.requested_device ?? "既有记录" }} / {{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item v-if="currentCase.run.execution?.fallback_used" label="CPU 回退原因">{{ currentCase.run.execution.fallback_reason }}</a-descriptions-item><a-descriptions-item label="模型">{{ currentCase.run.model }}</a-descriptions-item></a-descriptions><a-divider /><a-space direction="vertical"><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/detections.json`)" target="_blank"><FileImageOutlined />检测 JSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><InfoCircleOutlined />运行元数据</a-button></a-space></section></a-col><a-col :xs="24" :xl="16"><section class="surface-section"><div class="section-heading"><div><h2>当前影像检测明细</h2><p>边界框为 JPEG 像素坐标,不含可用地理坐标。</p></div></div><a-table :data-source="selectedImage.detections" :pagination="false" row-key="bbox_xyxy" size="small" :scroll="{ x: 680 }"><a-table-column title="类别" data-index="class_name" key="class_name" /><a-table-column title="置信度" key="confidence" align="right"><template #default="{ record }">{{ (record.confidence * 100).toFixed(1) }}%</template></a-table-column><a-table-column title="像素框 x1, y1, x2, y2" key="bbox"><template #default="{ record }">{{ record.bbox_xyxy.map((value: number) => value.toFixed(1)).join(', ') }}</template></a-table-column><template #emptyText>该影像没有达到当前阈值的候选目标。</template></a-table></section></a-col></a-row> |
| | | </div> |
| | | </template> |
| | | </template> |
| | |
| | | import * as THREE from "three"; |
| | | import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; |
| | | |
| | | const props = defineProps<{ source: string; disabled?: boolean }>(); |
| | | const emit = defineEmits<{ save: [labels: Array<[number, number]>] }>(); |
| | | type AnnotationClass = { code: number; key: string; label: string; color: number[] }; |
| | | const props = defineProps<{ source: string; sourceId: string; detailAvailable?: boolean; classes: AnnotationClass[]; disabled?: boolean; reviewMode?: boolean; initialReviewChanges?: Array<[number, number]> }>(); |
| | | const emit = defineEmits<{ save: [labels: Array<[number, number]>]; reviewSave: [changes: Array<[number, number]>] }>(); |
| | | type Reader = { name: string; bytes: number; read: (view: DataView, offset: number) => number }; |
| | | type InteractionMode = "navigate" | "brush" | "rectangle"; |
| | | |
| | | const schema = [ |
| | | { code: 2, label: "地面", rgb: [151, 111, 51] }, { code: 5, label: "植被", rgb: [59, 163, 87] }, |
| | | { code: 6, label: "建筑物", rgb: [224, 115, 55] }, { code: 15, label: "杆塔", rgb: [149, 89, 210] }, |
| | | { code: 16, label: "电线", rgb: [231, 196, 61] }, { code: 1, label: "其他/未知", rgb: [128, 128, 128] }, |
| | | ]; |
| | | const readers: Record<string, Omit<Reader, "name">> = { |
| | | char: { bytes: 1, read: (view, offset) => view.getInt8(offset) }, int8: { bytes: 1, read: (view, offset) => view.getInt8(offset) }, uchar: { bytes: 1, read: (view, offset) => view.getUint8(offset) }, uint8: { bytes: 1, read: (view, offset) => view.getUint8(offset) }, |
| | | short: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) }, int16: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) }, ushort: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) }, uint16: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) }, |
| | | int: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) }, int32: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) }, uint: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) }, uint32: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) }, |
| | | float: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) }, float32: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) }, double: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) }, float64: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) }, |
| | | }; |
| | | const host = ref<HTMLDivElement | null>(null); const loading = ref(true); const error = ref<string | null>(null); const pointCount = ref(0); const brushRadius = ref(1.5); const classCode = ref(16); const pointSize = ref(3); const labelledCount = ref(0); const interactionMode = ref<InteractionMode>("navigate"); const pivotPicking = ref(false); const orientationOpen = ref(false); const orientation = reactive({ x: 0, y: 0, z: 0 }); const selectionBox = ref<{ left: number; top: number; width: number; height: number } | null>(null); |
| | | let renderer: THREE.WebGLRenderer | null = null; let scene: THREE.Scene | null = null; let camera: THREE.PerspectiveCamera | null = null; let controls: OrbitControls | null = null; let model: THREE.Group | null = null; let cloud: THREE.Points<THREE.BufferGeometry, THREE.PointsMaterial> | null = null; let circleTexture: THREE.CanvasTexture | null = null; let resizeObserver: ResizeObserver | null = null; let animation = 0; let requestId = 0; let activePointer: number | null = null; let rectangleStart: { x: number; y: number } | null = null; let lastBrushPosition: { x: number; y: number } | null = null; |
| | | let positions = new Float32Array(); let baseColors = new Float32Array(); let shownColors = new Float32Array(); let labels = new Int16Array(); let history: Array<Array<[number, number]>> = []; |
| | | const classOptions = computed(() => schema.map((item) => ({ value: item.code, label: item.label }))); |
| | | const host = ref<HTMLDivElement | null>(null); const loading = ref(true); const error = ref<string | null>(null); const pointCount = ref(0); const brushRadius = ref(1.5); const classCode = ref(16); const pointSize = ref(3); const labelledCount = ref(0); const interactionMode = ref<InteractionMode>("navigate"); const pivotPicking = ref(false); const orientationOpen = ref(false); const orientation = reactive({ x: 0, y: 0, z: 0 }); const selectionBox = ref<{ left: number; top: number; width: number; height: number } | null>(null); const detailStatus = ref(""); const detailPointCount = ref(0); |
| | | let renderer: THREE.WebGLRenderer | null = null; let scene: THREE.Scene | null = null; let camera: THREE.PerspectiveCamera | null = null; let controls: OrbitControls | null = null; let model: THREE.Group | null = null; let cloud: THREE.Points<THREE.BufferGeometry, THREE.PointsMaterial> | null = null; let detailCloud: THREE.Points<THREE.BufferGeometry, THREE.PointsMaterial> | null = null; let circleTexture: THREE.CanvasTexture | null = null; let resizeObserver: ResizeObserver | null = null; let animation = 0; let requestId = 0; let detailRequestId = 0; let detailTimer = 0; let detailAbort: AbortController | null = null; let overviewCenter = new THREE.Vector3(); let overviewRadius = 0; let detailKey = ""; let activePointer: number | null = null; let rectangleStart: { x: number; y: number } | null = null; let lastBrushPosition: { x: number; y: number } | null = null; |
| | | let positions = new Float32Array(); let baseColors = new Float32Array(); let shownColors = new Float32Array(); let labels = new Int16Array(); let initialLabels = new Int16Array(); let history: Array<Array<[number, number]>> = []; const reviewChanges = new Map<number, number>(); |
| | | const classOptions = computed(() => [...(props.reviewMode ? [{ value: 0, label: "拒绝候选" }] : []), ...props.classes.map((item) => ({ value: item.code, label: item.label }))]); |
| | | const interactionOptions = [{ label: "浏览", value: "navigate" }, { label: "笔刷", value: "brush" }, { label: "框选", value: "rectangle" }]; |
| | | const orientationAxes = [{ key: "x" as const, label: "X" }, { key: "y" as const, label: "Y" }, { key: "z" as const, label: "Z" }]; |
| | | const srgbToLinear = (value: number) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; |
| | |
| | | function frame() { if (renderer && scene && camera && controls) { controls.update(); renderer.render(scene, camera); } animation = requestAnimationFrame(frame); } |
| | | function modelCentre() { if (!cloud) return null; cloud.geometry.computeBoundingSphere(); const sphere = cloud.geometry.boundingSphere; return sphere ? cloud.localToWorld(sphere.center.clone()) : null; } |
| | | function reset() { if (!cloud || !camera || !controls) return; cloud.geometry.computeBoundingSphere(); const sphere = cloud.geometry.boundingSphere; const centre = modelCentre(); if (!sphere || !centre) return; const radius = Math.max(sphere.radius, 0.01); controls.target.copy(centre); controls.minDistance = radius * 0.08; controls.maxDistance = radius * 8; camera.position.copy(centre).add(new THREE.Vector3(radius * 1.25, radius * 0.85, radius * 1.65)); camera.near = Math.max(radius / 1_000, 0.01); camera.far = radius * 20; camera.updateProjectionMatrix(); controls.update(); } |
| | | function topDown() { if (!cloud || !camera || !controls) return; cloud.geometry.computeBoundingSphere(); const sphere = cloud.geometry.boundingSphere; const centre = modelCentre(); if (!sphere || !centre) return; const distance = Math.max(camera.position.distanceTo(controls.target), sphere.radius * 1.5); controls.target.copy(centre); camera.up.set(0, 1, 0); camera.position.copy(centre).add(new THREE.Vector3(0, 0, distance)); camera.updateProjectionMatrix(); controls.update(); } |
| | | function topDown() { if (!cloud || !camera || !controls || !model) return; cloud.geometry.computeBoundingSphere(); const sphere = cloud.geometry.boundingSphere; const centre = modelCentre(); if (!sphere || !centre) return; const distance = Math.max(camera.position.distanceTo(controls.target), sphere.radius * 1.5); controls.target.copy(centre); camera.up.set(0, 1, 0); camera.position.copy(centre).add(new THREE.Vector3(0, 0, 1).applyQuaternion(model.quaternion).multiplyScalar(distance)); camera.updateProjectionMatrix(); controls.update(); } |
| | | function applyOrientation() { if (model) model.rotation.set(THREE.MathUtils.degToRad(orientation.x), THREE.MathUtils.degToRad(orientation.y), THREE.MathUtils.degToRad(orientation.z), "XYZ"); } |
| | | function resetOrientation() { orientation.x = 0; orientation.y = 0; orientation.z = 0; } |
| | | function setPointSize() { if (cloud) cloud.material.size = pointSize.value; } |
| | | function recolor(indices: number[]) { if (!cloud) return; for (const index of indices) { const item = schema.find((value) => value.code === labels[index]); for (let channel = 0; channel < 3; channel += 1) shownColors[index * 3 + channel] = item ? srgbToLinear(item.rgb[channel] / 255) : baseColors[index * 3 + channel]; } (cloud.geometry.getAttribute("color") as THREE.BufferAttribute).needsUpdate = true; labelledCount.value = labels.reduce((total, value) => total + (value ? 1 : 0), 0); } |
| | | function applyClass(indices: number[]) { const changes: Array<[number, number]> = []; for (const index of indices) if (labels[index] !== classCode.value) { changes.push([index, labels[index]]); labels[index] = classCode.value; } if (changes.length) { history.push(changes); recolor(changes.map(([index]) => index)); } } |
| | | function setPointSize() { if (cloud) cloud.material.size = pointSize.value; if (detailCloud) detailCloud.material.size = pointSize.value; } |
| | | function disposeDetail() { |
| | | detailAbort?.abort(); detailAbort = null; detailRequestId += 1; detailKey = ""; |
| | | if (detailCloud && model) model.remove(detailCloud); |
| | | detailCloud?.geometry.dispose(); detailCloud?.material.dispose(); detailCloud = null; |
| | | if (cloud) cloud.visible = true; |
| | | detailPointCount.value = 0; detailStatus.value = ""; |
| | | } |
| | | function detailRequest() { |
| | | if (!props.detailAvailable || !camera || !controls || !model || !cloud || !overviewRadius) return null; |
| | | const distance = camera.position.distanceTo(controls.target); |
| | | if (distance >= overviewRadius * 1.1) return null; |
| | | const radius = THREE.MathUtils.clamp(distance * 0.55, overviewRadius * 0.01, overviewRadius * 0.25); |
| | | const center = model.worldToLocal(controls.target.clone()).add(overviewCenter); |
| | | const step = Math.max(radius * 0.4, 0.001); |
| | | const key = [...center.toArray().map((value) => Math.round(value / step)), Math.round(radius / step)].join(":"); |
| | | return { center, radius, key }; |
| | | } |
| | | async function loadDetail() { |
| | | const request = detailRequest(); |
| | | if (!request) { disposeDetail(); return; } |
| | | if (request.key === detailKey && detailCloud) return; |
| | | detailAbort?.abort(); const controller = new AbortController(); detailAbort = controller; |
| | | const active = ++detailRequestId; detailStatus.value = "正在加载局部高密度点..."; |
| | | try { |
| | | const query = new URLSearchParams({ sourceId: props.sourceId, x: request.center.x.toString(), y: request.center.y.toString(), z: request.center.z.toString(), radius: request.radius.toString() }); |
| | | const response = await fetch(`/api/3d-pointcloud/annotation-detail?${query.toString()}`, { cache: "no-store", signal: controller.signal }); |
| | | if (!response.ok) { const payload = await response.json().catch(() => ({})) as { error?: string }; throw new Error(payload.error || `局部点云读取失败 (${response.status})。`); } |
| | | const parsed = parse(await response.arrayBuffer()); |
| | | if (active !== detailRequestId || !model || !cloud) return; |
| | | if (detailCloud) { model.remove(detailCloud); detailCloud.geometry.dispose(); detailCloud.material.dispose(); } |
| | | const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(parsed.xyz, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(parsed.rgb, 3)); |
| | | detailCloud = new THREE.Points(geometry, new THREE.PointsMaterial({ size: pointSize.value, vertexColors: true, sizeAttenuation: false, map: circleTexture ?? undefined, transparent: true, opacity: 0.96, alphaTest: 0.12, depthWrite: true })); |
| | | detailCloud.position.copy(overviewCenter).multiplyScalar(-1); model.add(detailCloud); cloud.visible = false; |
| | | detailKey = request.key; detailPointCount.value = parsed.xyz.length / 3; detailStatus.value = `局部高密度显示 ${detailPointCount.value.toLocaleString()} 点`; |
| | | } catch (caught) { |
| | | if (active !== detailRequestId || (caught instanceof DOMException && caught.name === "AbortError")) return; |
| | | if (!detailCloud && cloud) cloud.visible = true; |
| | | detailStatus.value = caught instanceof Error ? caught.message : "局部高密度点加载失败。"; |
| | | } |
| | | } |
| | | function scheduleDetail() { window.clearTimeout(detailTimer); detailTimer = window.setTimeout(() => { void loadDetail(); }, 550); } |
| | | function recolor(indices: number[]) { if (!cloud) return; for (const index of indices) { const item = props.classes.find((value) => value.code === labels[index]); for (let channel = 0; channel < 3; channel += 1) shownColors[index * 3 + channel] = item ? srgbToLinear(item.color[channel] / 255) : props.reviewMode ? 0.28 : baseColors[index * 3 + channel]; } (cloud.geometry.getAttribute("color") as THREE.BufferAttribute).needsUpdate = true; } |
| | | function updateReviewChange(index: number) { if (!props.reviewMode) return; if (labels[index] === initialLabels[index]) reviewChanges.delete(index); else reviewChanges.set(index, labels[index]); labelledCount.value = reviewChanges.size; } |
| | | function applyClass(indices: number[]) { const changes: Array<[number, number]> = []; for (const index of indices) if (labels[index] !== classCode.value) { changes.push([index, labels[index]]); labels[index] = classCode.value; updateReviewChange(index); } if (changes.length) { history.push(changes); recolor(changes.map(([index]) => index)); if (!props.reviewMode) labelledCount.value = labels.reduce((total, value) => total + (value ? 1 : 0), 0); } } |
| | | function brush(event: PointerEvent) { if (props.disabled || !renderer || !camera || !cloud || loading.value) return; const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); const raycaster = new THREE.Raycaster(); raycaster.params.Points.threshold = Math.max(0.35, brushRadius.value * 0.5); raycaster.setFromCamera(mouse, camera); const hit = raycaster.intersectObject(cloud, false)[0]; if (hit?.index === undefined) return; const center = new THREE.Vector3().fromBufferAttribute(cloud.geometry.getAttribute("position") as THREE.BufferAttribute, hit.index); const radiusSquared = brushRadius.value ** 2; const selected: number[] = []; for (let index = 0; index < labels.length; index += 1) { const dx = positions[index * 3] - center.x; const dy = positions[index * 3 + 1] - center.y; const dz = positions[index * 3 + 2] - center.z; if (dx * dx + dy * dy + dz * dz <= radiusSquared) selected.push(index); } applyClass(selected); } |
| | | function canvasPosition(event: PointerEvent) { const rect = renderer!.domElement.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top, width: rect.width, height: rect.height }; } |
| | | function pickPivot(event: PointerEvent) { if (!renderer || !camera || !cloud || !controls) return; const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); const raycaster = new THREE.Raycaster(); raycaster.params.Points.threshold = Math.max(0.35, brushRadius.value * 0.5); raycaster.setFromCamera(mouse, camera); const hit = raycaster.intersectObject(cloud, false)[0]; if (hit) controls.target.copy(hit.point); pivotPicking.value = false; } |
| | | function pickPivot(event: PointerEvent) { if (!renderer || !camera || !cloud || !controls) return; const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); const raycaster = new THREE.Raycaster(); raycaster.params.Points.threshold = Math.max(0.35, brushRadius.value * 0.5); raycaster.setFromCamera(mouse, camera); const hit = raycaster.intersectObject(detailCloud ?? cloud, false)[0]; if (hit) controls.target.copy(hit.point); pivotPicking.value = false; } |
| | | function togglePivotPicking() { pivotPicking.value = !pivotPicking.value; } |
| | | function preventMiddleAutoScroll(event: MouseEvent) { if (event.button === 1) event.preventDefault(); } |
| | | function preventContextMenu(event: MouseEvent) { event.preventDefault(); } |
| | | function finishPointer(event: PointerEvent) { if (!renderer || activePointer !== event.pointerId) return; activePointer = null; rectangleStart = null; lastBrushPosition = null; selectionBox.value = null; if (controls) controls.enabled = true; if (renderer.domElement.hasPointerCapture(event.pointerId)) renderer.domElement.releasePointerCapture(event.pointerId); } |
| | | function selectionStart(event: PointerEvent) { if (pivotPicking.value && event.button === 0) { event.preventDefault(); event.stopImmediatePropagation(); pickPivot(event); return; } if (interactionMode.value === "navigate" || event.button !== 0 || props.disabled || !renderer || !controls) return; event.preventDefault(); event.stopImmediatePropagation(); activePointer = event.pointerId; controls.enabled = false; renderer.domElement.setPointerCapture(event.pointerId); const point = canvasPosition(event); if (interactionMode.value === "brush") { lastBrushPosition = { x: point.x, y: point.y }; brush(event); return; } rectangleStart = { x: point.x, y: point.y }; selectionBox.value = { left: point.x, top: point.y, width: 0, height: 0 }; } |
| | | function selectionStart(event: PointerEvent) { if (pivotPicking.value && event.button === 0) { event.preventDefault(); event.stopImmediatePropagation(); pickPivot(event); return; } if (interactionMode.value === "navigate" || event.button !== 0 || props.disabled || detailCloud || !renderer || !controls) return; event.preventDefault(); event.stopImmediatePropagation(); activePointer = event.pointerId; controls.enabled = false; renderer.domElement.setPointerCapture(event.pointerId); const point = canvasPosition(event); if (interactionMode.value === "brush") { lastBrushPosition = { x: point.x, y: point.y }; brush(event); return; } rectangleStart = { x: point.x, y: point.y }; selectionBox.value = { left: point.x, top: point.y, width: 0, height: 0 }; } |
| | | function selectionMove(event: PointerEvent) { if (!renderer || activePointer !== event.pointerId) return; event.preventDefault(); event.stopImmediatePropagation(); const point = canvasPosition(event); if (interactionMode.value === "brush") { if (!lastBrushPosition || Math.hypot(point.x - lastBrushPosition.x, point.y - lastBrushPosition.y) >= 10) { lastBrushPosition = { x: point.x, y: point.y }; brush(event); } return; } if (!rectangleStart) return; selectionBox.value = { left: Math.min(rectangleStart.x, point.x), top: Math.min(rectangleStart.y, point.y), width: Math.abs(point.x - rectangleStart.x), height: Math.abs(point.y - rectangleStart.y) }; } |
| | | function selectionEnd(event: PointerEvent) { if (!renderer || !camera || activePointer !== event.pointerId) return; event.preventDefault(); event.stopImmediatePropagation(); if (interactionMode.value === "brush") { brush(event); finishPointer(event); return; } if (!rectangleStart) { finishPointer(event); return; } const point = canvasPosition(event); const left = Math.min(rectangleStart.x, point.x); const right = Math.max(rectangleStart.x, point.x); const top = Math.min(rectangleStart.y, point.y); const bottom = Math.max(rectangleStart.y, point.y); const selected: number[] = []; const position = new THREE.Vector3(); for (let index = 0; index < labels.length; index += 1) { position.set(positions[index * 3], positions[index * 3 + 1], positions[index * 3 + 2]); cloud!.localToWorld(position).project(camera); if (position.z < -1 || position.z > 1) continue; const x = (position.x + 1) * 0.5 * point.width; const y = (1 - position.y) * 0.5 * point.height; if (x >= left && x <= right && y >= top && y <= bottom) selected.push(index); } finishPointer(event); applyClass(selected); } |
| | | function undo() { const changes = history.pop(); if (!changes) return; for (const [index, old] of changes) labels[index] = old; recolor(changes.map(([index]) => index)); } |
| | | function clear() { const indices = Array.from({ length: labels.length }, (_, index) => index).filter((index) => labels[index]); labels.fill(0); history = []; recolor(indices); } |
| | | function save() { emit("save", Array.from(labels.entries()).filter(([, code]) => code > 0).map(([index, code]) => [index, code])); } |
| | | async function load(source: string) { const active = ++requestId; loading.value = true; error.value = null; try { const response = await fetch(source, { cache: "no-store" }); if (!response.ok) throw new Error(`点云读取失败 (${response.status})。`); const parsed = parse(await response.arrayBuffer()); if (active !== requestId || !scene) return; if (model) scene.remove(model); cloud?.geometry.dispose(); cloud?.material.dispose(); positions = parsed.xyz; baseColors = parsed.rgb; shownColors = parsed.rgb.slice(); labels = new Int16Array(positions.length / 3); history = []; const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(shownColors, 3)); geometry.computeBoundingSphere(); const centre = geometry.boundingSphere?.center.clone() ?? new THREE.Vector3(); cloud = new THREE.Points(geometry, new THREE.PointsMaterial({ size: pointSize.value, vertexColors: true, sizeAttenuation: false, map: circleTexture ?? undefined, transparent: true, opacity: 0.9, alphaTest: 0.12, depthWrite: true })); model = new THREE.Group(); model.position.copy(centre); cloud.position.copy(centre).multiplyScalar(-1); model.add(cloud); scene.add(model); applyOrientation(); pointCount.value = labels.length; labelledCount.value = 0; reset(); } catch (caught) { if (active === requestId) error.value = caught instanceof Error ? caught.message : "点云无法读取。"; } finally { if (active === requestId) loading.value = false; } } |
| | | onMounted(() => { if (!host.value) return; scene = new THREE.Scene(); scene.background = new THREE.Color("#101821"); camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1_000); renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; circleTexture = createCircleTexture(); host.value.appendChild(renderer.domElement); renderer.domElement.addEventListener("mousedown", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("auxclick", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("pointerdown", selectionStart); renderer.domElement.addEventListener("pointermove", selectionMove); renderer.domElement.addEventListener("pointerup", selectionEnd); renderer.domElement.addEventListener("pointercancel", finishPointer); renderer.domElement.addEventListener("contextmenu", preventContextMenu); controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.12; controls.rotateSpeed = 0.55; controls.zoomSpeed = 0.8; controls.panSpeed = 0.75; controls.screenSpacePanning = true; controls.minPolarAngle = 0.04; controls.maxPolarAngle = Math.PI - 0.04; controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE }; resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host.value); resize(); frame(); void load(props.source); }); |
| | | watch(() => props.source, (source) => { void load(source); }); watch(pointSize, setPointSize); watch(orientation, applyOrientation, { deep: true }); |
| | | onBeforeUnmount(() => { cancelAnimationFrame(animation); resizeObserver?.disconnect(); renderer?.domElement.removeEventListener("mousedown", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("auxclick", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("pointerdown", selectionStart); renderer?.domElement.removeEventListener("pointermove", selectionMove); renderer?.domElement.removeEventListener("pointerup", selectionEnd); renderer?.domElement.removeEventListener("pointercancel", finishPointer); renderer?.domElement.removeEventListener("contextmenu", preventContextMenu); controls?.dispose(); cloud?.geometry.dispose(); cloud?.material.dispose(); circleTexture?.dispose(); renderer?.dispose(); renderer?.domElement.remove(); }); |
| | | function undo() { const changes = history.pop(); if (!changes) return; for (const [index, old] of changes) { labels[index] = old; updateReviewChange(index); } recolor(changes.map(([index]) => index)); if (!props.reviewMode) labelledCount.value = labels.reduce((total, value) => total + (value ? 1 : 0), 0); } |
| | | function clear() { if (props.reviewMode) { const indices = [...reviewChanges.keys()]; for (const index of indices) labels[index] = initialLabels[index]; reviewChanges.clear(); history = []; labelledCount.value = 0; recolor(indices); return; } const indices = Array.from({ length: labels.length }, (_, index) => index).filter((index) => labels[index]); labels.fill(0); history = []; recolor(indices); labelledCount.value = 0; } |
| | | function save() { if (props.reviewMode) { emit("reviewSave", [...reviewChanges.entries()].sort(([left], [right]) => left - right)); return; } emit("save", Array.from(labels.entries()).filter(([, code]) => code > 0).map(([index, code]) => [index, code])); } |
| | | function labelFromColor(index: number) { for (const item of props.classes) { const expected = item.color.map((value) => srgbToLinear(value / 255)); if (Math.abs(baseColors[index * 3] - expected[0]) < 0.005 && Math.abs(baseColors[index * 3 + 1] - expected[1]) < 0.005 && Math.abs(baseColors[index * 3 + 2] - expected[2]) < 0.005) return item.code; } return 0; } |
| | | function initializeReview() { if (!props.reviewMode || loading.value || !labels.length || initialLabels.length === labels.length) return; for (let index = 0; index < labels.length; index += 1) labels[index] = labelFromColor(index); initialLabels = labels.slice(); reviewChanges.clear(); for (const item of props.initialReviewChanges ?? []) if (item[0] >= 0 && item[0] < labels.length && item[1] >= 0 && props.classes.some((value) => value.code === item[1])) { labels[item[0]] = item[1]; updateReviewChange(item[0]); } labelledCount.value = reviewChanges.size; } |
| | | async function load(source: string) { const active = ++requestId; loading.value = true; error.value = null; disposeDetail(); try { const response = await fetch(source, { cache: "no-store" }); if (!response.ok) throw new Error(`点云读取失败 (${response.status})。`); const parsed = parse(await response.arrayBuffer()); if (active !== requestId || !scene) return; if (model) scene.remove(model); cloud?.geometry.dispose(); cloud?.material.dispose(); positions = parsed.xyz; baseColors = parsed.rgb; shownColors = parsed.rgb.slice(); labels = new Int16Array(positions.length / 3); history = []; const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(shownColors, 3)); geometry.computeBoundingSphere(); const centre = geometry.boundingSphere?.center.clone() ?? new THREE.Vector3(); overviewCenter = centre.clone(); overviewRadius = Math.max(geometry.boundingSphere?.radius ?? 0, 0.01); cloud = new THREE.Points(geometry, new THREE.PointsMaterial({ size: pointSize.value, vertexColors: true, sizeAttenuation: false, map: circleTexture ?? undefined, transparent: true, opacity: 0.9, alphaTest: 0.12, depthWrite: true })); model = new THREE.Group(); model.position.copy(centre); cloud.position.copy(centre).multiplyScalar(-1); model.add(cloud); scene.add(model); applyOrientation(); pointCount.value = labels.length; labelledCount.value = 0; reset(); } catch (caught) { if (active === requestId) error.value = caught instanceof Error ? caught.message : "点云无法读取。"; } finally { if (active === requestId) loading.value = false; } } |
| | | onMounted(() => { if (!host.value) return; scene = new THREE.Scene(); scene.background = new THREE.Color("#101821"); camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1_000); renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; circleTexture = createCircleTexture(); host.value.appendChild(renderer.domElement); renderer.domElement.addEventListener("mousedown", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("auxclick", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("pointerdown", selectionStart); renderer.domElement.addEventListener("pointermove", selectionMove); renderer.domElement.addEventListener("pointerup", selectionEnd); renderer.domElement.addEventListener("pointercancel", finishPointer); renderer.domElement.addEventListener("contextmenu", preventContextMenu); controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.12; controls.rotateSpeed = 0.55; controls.zoomSpeed = 0.8; controls.panSpeed = 0.75; controls.screenSpacePanning = true; controls.minPolarAngle = 0.04; controls.maxPolarAngle = Math.PI - 0.04; controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE }; controls.addEventListener("end", scheduleDetail); resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host.value); resize(); frame(); void load(props.source); }); |
| | | watch(() => props.source, (source) => { initialLabels = new Int16Array(); void load(source); }); |
| | | watch(() => props.classes, (classes) => { if (!classes.some((item) => item.code === classCode.value)) classCode.value = classes[0]?.code ?? 0; }, { immediate: true }); |
| | | watch(pointSize, setPointSize); watch(orientation, applyOrientation, { deep: true }); watch(loading, initializeReview); |
| | | watch(() => props.initialReviewChanges, (values) => { if (!props.reviewMode || !initialLabels.length) return; const changed = new Set<number>(reviewChanges.keys()); for (const index of changed) labels[index] = initialLabels[index]; reviewChanges.clear(); for (const item of values ?? []) if (item[0] >= 0 && item[0] < labels.length && item[1] >= 0 && props.classes.some((value) => value.code === item[1])) { labels[item[0]] = item[1]; updateReviewChange(item[0]); changed.add(item[0]); } recolor([...changed]); }, { deep: true }); |
| | | onBeforeUnmount(() => { cancelAnimationFrame(animation); window.clearTimeout(detailTimer); disposeDetail(); resizeObserver?.disconnect(); renderer?.domElement.removeEventListener("mousedown", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("auxclick", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("pointerdown", selectionStart); renderer?.domElement.removeEventListener("pointermove", selectionMove); renderer?.domElement.removeEventListener("pointerup", selectionEnd); renderer?.domElement.removeEventListener("pointercancel", finishPointer); renderer?.domElement.removeEventListener("contextmenu", preventContextMenu); controls?.removeEventListener("end", scheduleDetail); controls?.dispose(); cloud?.geometry.dispose(); cloud?.material.dispose(); circleTexture?.dispose(); renderer?.dispose(); renderer?.domElement.remove(); }); |
| | | </script> |
| | | |
| | | <template> |
| | | <div v-if="detailStatus" class="detail-status">{{ detailStatus }}<span v-if="detailPointCount">;细节层仅用于查看,标注请缩小回概览</span></div> |
| | | <div class="annotation-viewer"><div ref="host" class="canvas-host" :class="{ 'pivot-picking': pivotPicking }" /><div v-if="selectionBox" class="selection-box" :style="{ left: `${selectionBox.left}px`, top: `${selectionBox.top}px`, width: `${selectionBox.width}px`, height: `${selectionBox.height}px` }" /><div class="annotation-toolbar"><a-select v-model:value="classCode" :options="classOptions" :disabled="disabled" /><a-segmented v-model:value="interactionMode" :options="interactionOptions" :disabled="disabled" /><label v-if="interactionMode === 'brush'">刷选半径<a-slider v-model:value="brushRadius" :min="0.2" :max="8" :step="0.1" :disabled="disabled" /></label><label>点大小<a-slider v-model:value="pointSize" :min="1" :max="7" :step="0.2" /></label><span>{{ labelledCount.toLocaleString() }} / {{ pointCount.toLocaleString() }} 已标注</span><a-popover v-model:open="orientationOpen" title="模型朝向" trigger="click"><template #content><div class="orientation-controls"><label v-for="axis in orientationAxes" :key="axis.key">{{ axis.label }}<a-slider v-model:value="orientation[axis.key]" :min="0" :max="360" :step="1" /><a-input-number v-model:value="orientation[axis.key]" :min="0" :max="360" :precision="0" addon-after="°" /></label><a-button size="small" @click="resetOrientation">重置</a-button></div></template><a-tooltip title="模型朝向"><a-button type="text"><RotateRightOutlined /></a-button></a-tooltip></a-popover><a-tooltip title="设置旋转中心"><a-button :type="pivotPicking ? 'primary' : 'text'" @click="togglePivotPicking"><AimOutlined /></a-button></a-tooltip><a-tooltip title="俯视"><a-button type="text" @click="topDown"><BorderTopOutlined /></a-button></a-tooltip><a-tooltip title="复位视角"><a-button type="text" @click="reset"><ReloadOutlined /></a-button></a-tooltip><a-tooltip title="撤销上一次标注"><a-button type="text" :disabled="disabled || !history.length" @click="undo"><UndoOutlined /></a-button></a-tooltip><a-button size="small" :disabled="disabled || !labelledCount" @click="clear">清空</a-button><a-button size="small" type="primary" :loading="disabled" :disabled="disabled || !labelledCount" @click="save">保存标注版本</a-button></div><div v-if="loading || error" class="viewer-state"><a-spin v-if="loading" /><span v-else>{{ error }}</span></div></div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .annotation-viewer { position: relative; height: 620px; overflow: hidden; background: #101821; border: 1px solid #273543; } .canvas-host { width: 100%; height: 100%; } .canvas-host.pivot-picking { cursor: crosshair; } .canvas-host :deep(canvas) { display: block; width: 100%; height: 100%; touch-action: none; } |
| | | .selection-box { position: absolute; z-index: 2; pointer-events: none; border: 1px solid #62b7e6; background: rgba(98, 183, 230, .15); } |
| | | .detail-status { padding: 7px 10px; color: #dceff9; background: #182936; font-size: 13px; } |
| | | .annotation-toolbar { position: absolute; right: 12px; bottom: 12px; display: flex; align-items: center; gap: 10px; padding: 8px 10px; color: #e8f1f5; background: rgba(12, 20, 29, .9); font-size: 13px; } .annotation-toolbar label { display: flex; align-items: center; gap: 6px; white-space: nowrap; } .annotation-toolbar :deep(.ant-slider) { width: 82px; margin: 0; } .annotation-toolbar :deep(.ant-btn) { color: #e8f1f5; } .annotation-toolbar :deep(.ant-select) { width: 110px; } |
| | | .orientation-controls { display: grid; gap: 8px; min-width: 250px; } .orientation-controls label { display: grid; grid-template-columns: 16px 1fr 84px; align-items: center; gap: 8px; } .orientation-controls :deep(.ant-slider) { margin: 0; } .orientation-controls :deep(.ant-input-number-group-wrapper) { width: 84px; } |
| | | .viewer-state { position: absolute; inset: 0; display: grid; place-items: center; color: #e8f1f5; background: rgba(12, 20, 29, .65); } @media (max-width: 767px) { .annotation-viewer { height: 480px; } .annotation-toolbar { left: 8px; right: 8px; bottom: 8px; flex-wrap: wrap; } } |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; |
| | | import { DeleteOutlined, DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue"; |
| | | import { DeleteOutlined, DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons-vue"; |
| | | |
| | | import { artifactUrl, createDensePointCloudClassification, createPhotoReconstructionRun, createPointCloudAnnotation, createPointCloudModelInference, createPointCloudRun, createPointCloudTrainingRun, deletePointCloudAnnotation, loadPhotoReconstructionJob, loadPointCloudAnnotationSources, loadPointCloudAnnotations, loadPointCloudModelInferenceJob, loadPointCloudSemanticModels, loadPointCloudTrainingJob, uploadPhotoReconstructionFile, uploadPointCloudFile, type GeoFeature, type PhotoReconstructionJob, type PointCloudAnnotation, type PointCloudAnnotationSource, type PointCloudCase, type PointCloudInferenceJob, type PointCloudSemanticModel, type PointCloudTrainingJob } from "@/api/artifacts"; |
| | | import { acceptPointCloudAutoAnnotation, artifactUrl, createDensePointCloudClassification, createPhotoReconstructionRun, createPointCloudAnnotation, createPointCloudAnnotationClass, createPointCloudAnnotationSourceRun, createPointCloudAutoAnnotation, createPointCloudModelInference, createPointCloudRun, createPointCloudTrainingRun, deletePointCloudAnnotation, deletePointCloudAnnotationClass, deletePointCloudAnnotationSource, loadLatestPointCloudAutoAnnotation, loadPhotoReconstructionJob, loadPointCloudAnnotationClasses, loadPointCloudAnnotationSourceDeletionPlan, loadPointCloudAnnotationSourceJob, loadPointCloudAnnotationSources, loadPointCloudAnnotations, loadPointCloudAutoAnnotationReview, loadPointCloudModelInferenceJob, loadPointCloudSemanticModels, loadPointCloudTrainingJob, savePointCloudAutoAnnotationReview, uploadPhotoReconstructionFile, uploadPointCloudFile, type ComputeDevice, type GeoFeature, type PhotoReconstructionJob, type PointCloudAnnotation, type PointCloudAnnotationClass, type PointCloudAnnotationSource, type PointCloudAnnotationSourceDeletionPlan, type PointCloudAnnotationSourceJob, type PointCloudCase, type PointCloudInferenceJob, type PointCloudSemanticModel, type PointCloudTrainingJob } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import RunDeletionControl from "@/components/RunDeletionControl.vue"; |
| | | import SemanticModelDeletionControl from "@/components/SemanticModelDeletionControl.vue"; |
| | | import SparsePointCloudViewer from "@/components/SparsePointCloudViewer.vue"; |
| | | import PointCloudAnnotationViewer from "@/components/PointCloudAnnotationViewer.vue"; |
| | | import TexturedMeshViewer from "@/components/TexturedMeshViewer.vue"; |
| | |
| | | const densePreviewMode = ref<"textured" | "geometry" | "point_colors">("textured"); |
| | | let photoPollTimer: ReturnType<typeof setTimeout> | undefined; |
| | | const annotationSources = ref<PointCloudAnnotationSource[]>([]); |
| | | const annotationClasses = ref<PointCloudAnnotationClass[]>([]); |
| | | const annotations = ref<PointCloudAnnotation[]>([]); |
| | | const annotationSourceId = ref(""); |
| | | const annotationSaving = ref(false); |
| | | const annotationNotice = ref<{ type: "info" | "success" | "error"; message: string } | null>(null); |
| | | const annotationDeletingId = ref(""); |
| | | const annotationSourceModalOpen = ref(false); |
| | | const annotationSourceFile = ref<File | null>(null); |
| | | const annotationSourceSubmitting = ref(false); |
| | | const annotationSourceJob = ref<PointCloudAnnotationSourceJob | null>(null); |
| | | let annotationSourcePollTimer: ReturnType<typeof setTimeout> | undefined; |
| | | const annotationSourceRemovalPlan = ref<PointCloudAnnotationSourceDeletionPlan | null>(null); |
| | | const annotationSourceRemovalOpen = ref(false); |
| | | const annotationSourceRemovalLoading = ref(false); |
| | | const annotationSourceRemoving = ref(false); |
| | | const annotationClassModalOpen = ref(false); |
| | | const annotationClassSubmitting = ref(false); |
| | | const annotationClassKey = ref(""); |
| | | const annotationClassLabel = ref(""); |
| | | const annotationClassColor = ref("#3289c7"); |
| | | const annotationClassDeletingCode = ref<number | null>(null); |
| | | const trainingJob = ref<PointCloudTrainingJob | null>(null); |
| | | let trainingPollTimer: ReturnType<typeof setTimeout> | undefined; |
| | | const semanticModels = ref<PointCloudSemanticModel[]>([]); |
| | | const selectedSemanticModelId = ref(""); |
| | | const modelDevice = ref<ComputeDevice>("auto"); |
| | | const inferenceFile = ref<File | null>(null); |
| | | const inferenceSubmitting = ref(false); |
| | | const inferenceJob = ref<PointCloudInferenceJob | null>(null); |
| | | const inferenceSummary = ref<{ class_counts?: Record<string, number>; input_points?: number; preview_points?: number } | null>(null); |
| | | let inferencePollTimer: ReturnType<typeof setTimeout> | undefined; |
| | | const autoAnnotationThreshold = ref(0.95); |
| | | const autoAnnotationSubmitting = ref(false); |
| | | const autoAnnotationAccepting = ref(false); |
| | | const autoAnnotationJob = ref<PointCloudInferenceJob | null>(null); |
| | | const autoAnnotationSummary = ref<{ automatic_annotation?: { candidate_count?: number; candidate_confidence?: number; candidate_class_counts?: Record<string, number> }; class_counts?: Record<string, number>; input_points?: number } | null>(null); |
| | | const autoAnnotationReviewChanges = ref<Array<[number, number]>>([]); |
| | | const autoAnnotationReviewSaving = ref(false); |
| | | let autoAnnotationPollTimer: ReturnType<typeof setTimeout> | undefined; |
| | | |
| | | const workflowCases = computed(() => Object.values(store.pointCloudCases).filter((item) => { |
| | | const run = item.run; |
| | |
| | | if (job.status === "complete") return "重建完成,结果已加入案例库。"; |
| | | return job.error || "照片重建失败。"; |
| | | }); |
| | | function executionLabel(job: { device: string; environment?: string; torchVersion?: string }) { |
| | | const photoJobProgress = computed(() => photoJob.value?.progress ?? { percent: 0, stage: "queued", message: "照片已保存,正在等待 CPU 重建资源。", inputImages: photoJob.value?.inputImages ?? 0, estimate: true }); |
| | | function executionLabel(job: { device: string; requestedDevice?: ComputeDevice; environment?: string; torchVersion?: string; fallbackUsed?: boolean; fallbackReason?: string | null }) { |
| | | const device = job.device === "cuda" ? "GPU CUDA" : "CPU"; |
| | | return [device, job.environment, job.torchVersion ? `PyTorch ${job.torchVersion}` : ""].filter(Boolean).join(" / "); |
| | | const selection = job.requestedDevice ? `请求 ${job.requestedDevice} -> ${device}` : device; |
| | | return [selection, job.environment, job.torchVersion ? `PyTorch ${job.torchVersion}` : "", job.fallbackUsed && job.fallbackReason ? `回退:${job.fallbackReason}` : ""].filter(Boolean).join(" / "); |
| | | } |
| | | |
| | | function rings(feature: GeoFeature): number[][][] { |
| | |
| | | }); |
| | | const vectorPaths = computed(() => allRings.value.map((ring) => ring.map((point, index) => `${index ? "L" : "M"}${point[0]},${point[1]}`).join(" ") + " Z")); |
| | | const annotationSource = computed(() => annotationSources.value.find((item) => item.id === annotationSourceId.value)); |
| | | const annotationSourceDescription = computed(() => annotationSource.value?.sourceKind.includes("多视角照片特征融合") |
| | | ? `当前显示 ${annotationSource.value.pointCount.toLocaleString()} 个原始 LAS RGB/XYZ 采样点;点序与同目录照片特征 NPZ 严格一致。覆盖率伪彩没有作为标注底图或训练颜色。` |
| | | : annotationSource.value?.sourceHasRgb === false |
| | | ? `当前显示 ${annotationSource.value.pointCount.toLocaleString()} 个 XYZ 点的中性预览。源 PLY 没有可读取的逐点 RGB,因而不能用于当前 RGB 语义模型训练。` |
| | | : `当前直接显示该数据源全部 ${annotationSource.value?.pointCount.toLocaleString() ?? 0} 个 RGB/XYZ 点,不是语义规则分类颜色。大规模点云会相应占用更多浏览器内存与显存。`); |
| | | const selectedSemanticModel = computed(() => semanticModels.value.find((item) => item.id === selectedSemanticModelId.value)); |
| | | const semanticModelOptions = computed(() => semanticModels.value.map((model) => ({ value: model.id, label: `${model.label} | ${Object.values(model.classes).map((item) => item.label).join(" / ")}` }))); |
| | | function annotationSourceLabel(sourceId: string) { return annotationSources.value.find((item) => item.id === sourceId)?.label ?? sourceId; } |
| | | function annotationClassDisplayLabel(code: string | number) { return annotationClasses.value.find((item) => item.code === Number(code))?.label ?? `类别 ${code}`; } |
| | | function annotationTrainerLabel(annotation: PointCloudAnnotation) { return annotationSources.value.find((item) => item.id === annotation.sourceId)?.sourceKind.includes("多视角照片特征融合") ? "多视角特征训练" : "自动选择 GPU / CPU 训练"; } |
| | | const selectedSourceAnnotations = computed(() => annotations.value.filter((item) => item.sourceId === annotationSourceId.value)); |
| | | const latestSourceAnnotation = computed(() => selectedSourceAnnotations.value[0]); |
| | | |
| | | function syncSelection() { selectedName.value = currentCase.value?.run.point_clouds?.[0]?.file ?? ""; } |
| | | function selectWorkflowCase() { caseId.value = workflowCases.value[0]?.id ?? ""; syncSelection(); } |
| | | async function removePointCloudRun() { await store.loadPointCloud(true); selectWorkflowCase(); } |
| | | async function removeSemanticModel() { await refreshSemanticModels(); autoAnnotationJob.value = null; autoAnnotationSummary.value = null; inferenceJob.value = null; inferenceSummary.value = null; } |
| | | function beforeUpload(file: File) { files.value = [...files.value, file]; return false; } |
| | | function removeFile(file: { name: string }) { files.value = files.value.filter((item) => item.name !== file.name); } |
| | | function beforePhotoUpload(file: File) { photoFiles.value = [...photoFiles.value, file]; return false; } |
| | |
| | | if (response.ok) vectorFeatures.value = ((await response.json()) as { features?: GeoFeature[] }).features ?? []; |
| | | } |
| | | async function refreshAnnotationData() { |
| | | annotationSources.value = await loadPointCloudAnnotationSources(); |
| | | annotations.value = await loadPointCloudAnnotations(); |
| | | const [sources, classes, revisions] = await Promise.all([loadPointCloudAnnotationSources(), loadPointCloudAnnotationClasses(), loadPointCloudAnnotations()]); |
| | | annotationSources.value = sources; |
| | | annotationClasses.value = classes; |
| | | annotations.value = revisions; |
| | | if (!annotationSourceId.value || !annotationSources.value.some((item) => item.id === annotationSourceId.value)) annotationSourceId.value = annotationSources.value[0]?.id ?? ""; |
| | | } |
| | | async function refreshSemanticModels() { |
| | |
| | | catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "删除标注版本失败。" }; } |
| | | finally { annotationDeletingId.value = ""; } |
| | | } |
| | | function beforeAnnotationSourceUpload(file: File) { annotationSourceFile.value = file; return false; } |
| | | function removeAnnotationSourceUpload() { annotationSourceFile.value = null; } |
| | | function stopAnnotationSourcePolling() { if (annotationSourcePollTimer) clearTimeout(annotationSourcePollTimer); annotationSourcePollTimer = undefined; } |
| | | async function pollAnnotationSourceJob() { |
| | | if (!annotationSourceJob.value) return; |
| | | try { |
| | | annotationSourceJob.value = await loadPointCloudAnnotationSourceJob(annotationSourceJob.value.id); |
| | | if (annotationSourceJob.value.status === "complete") { |
| | | await store.loadPointCloud(true); |
| | | await refreshAnnotationData(); |
| | | const source = annotationSources.value.find((item) => item.runId === annotationSourceJob.value?.runId); |
| | | if (source) annotationSourceId.value = source.id; |
| | | annotationSourceModalOpen.value = false; |
| | | annotationNotice.value = { type: "success", message: `已生成标注源:${annotationSourceJob.value.inputName}` }; |
| | | return; |
| | | } |
| | | if (annotationSourceJob.value.status !== "failed") annotationSourcePollTimer = setTimeout(() => { void pollAnnotationSourceJob(); }, 2_500); |
| | | } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取标注源任务状态。" }; } |
| | | } |
| | | async function createAnnotationSource() { |
| | | if (!annotationSourceFile.value) return; |
| | | annotationSourceSubmitting.value = true; |
| | | try { |
| | | const upload = await uploadPointCloudFile(annotationSourceFile.value); |
| | | const { job } = await createPointCloudAnnotationSourceRun(upload); |
| | | annotationSourceFile.value = null; |
| | | annotationSourceJob.value = job; |
| | | annotationNotice.value = { type: "info", message: `正在处理 ${job.inputName},完成后会自动加入标注源。` }; |
| | | stopAnnotationSourcePolling(); |
| | | void pollAnnotationSourceJob(); |
| | | } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "新增标注源失败。" }; } |
| | | finally { annotationSourceSubmitting.value = false; } |
| | | } |
| | | async function openAnnotationSourceRemoval() { |
| | | if (!annotationSourceId.value) return; |
| | | annotationSourceRemovalLoading.value = true; |
| | | annotationSourceRemovalPlan.value = null; |
| | | try { |
| | | annotationSourceRemovalPlan.value = await loadPointCloudAnnotationSourceDeletionPlan(annotationSourceId.value); |
| | | annotationSourceRemovalOpen.value = true; |
| | | } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取数据链路移除范围。" }; } |
| | | finally { annotationSourceRemovalLoading.value = false; } |
| | | } |
| | | async function removeAnnotationSourceChain() { |
| | | if (!annotationSourceRemovalPlan.value) return; |
| | | annotationSourceRemoving.value = true; |
| | | try { |
| | | const { removed } = await deletePointCloudAnnotationSource(annotationSourceRemovalPlan.value.sourceId); |
| | | annotationSourceRemovalOpen.value = false; |
| | | annotationSourceRemovalPlan.value = null; |
| | | annotationSourceId.value = ""; |
| | | await store.loadPointCloud(true); |
| | | await refreshAnnotationData(); |
| | | await refreshSemanticModels(); |
| | | const detail = removed.removed; |
| | | annotationNotice.value = { type: "success", message: `已移除完整数据链路:${detail.outputDirectories} 个结果目录、${detail.rawDirectories + detail.processedDirectories} 个上传/处理目录、${detail.annotationRevisions} 个标注版本、${detail.trainingRuns} 个训练结果和 ${detail.inferenceRuns} 个推理结果。` }; |
| | | } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "移除数据链路失败。" }; } |
| | | finally { annotationSourceRemoving.value = false; } |
| | | } |
| | | function annotationColor(rgb: number[]) { return `#${rgb.map((item) => item.toString(16).padStart(2, "0")).join("")}`; } |
| | | function hexColorToRgb(value: string) { |
| | | const match = /^#([0-9a-f]{6})$/i.exec(value); |
| | | return match ? [Number.parseInt(match[1].slice(0, 2), 16), Number.parseInt(match[1].slice(2, 4), 16), Number.parseInt(match[1].slice(4, 6), 16)] : null; |
| | | } |
| | | async function createAnnotationClass() { |
| | | const color = hexColorToRgb(annotationClassColor.value); |
| | | if (!color) { annotationNotice.value = { type: "error", message: "请选择有效的 RGB 颜色。" }; return; } |
| | | annotationClassSubmitting.value = true; |
| | | try { |
| | | await createPointCloudAnnotationClass({ key: annotationClassKey.value.trim(), label: annotationClassLabel.value.trim(), color }); |
| | | await refreshAnnotationData(); |
| | | annotationClassKey.value = ""; annotationClassLabel.value = ""; annotationClassColor.value = "#3289c7"; |
| | | annotationNotice.value = { type: "success", message: "已新增标签分类。" }; |
| | | } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "新增标签分类失败。" }; } |
| | | finally { annotationClassSubmitting.value = false; } |
| | | } |
| | | async function deleteAnnotationClass(code: number) { |
| | | annotationClassDeletingCode.value = code; |
| | | try { await deletePointCloudAnnotationClass(code); await refreshAnnotationData(); annotationNotice.value = { type: "success", message: "已删除未使用的自定义标签分类。" }; } |
| | | catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "删除标签分类失败。" }; } |
| | | finally { annotationClassDeletingCode.value = null; } |
| | | } |
| | | async function startTraining(annotationId: string) { |
| | | try { const { job } = await createPointCloudTrainingRun(annotationId, "auto"); trainingJob.value = job; stopTrainingPolling(); void pollTrainingJob(); } |
| | | try { const { job } = await createPointCloudTrainingRun(annotationId, modelDevice.value); trainingJob.value = job; stopTrainingPolling(); void pollTrainingJob(); } |
| | | catch (error) { runError.value = error instanceof Error ? error.message : "启动训练失败。"; } |
| | | } |
| | | function beforeInferenceUpload(file: File) { inferenceFile.value = file; return false; } |
| | |
| | | inferenceSubmitting.value = true; runError.value = null; inferenceJob.value = null; inferenceSummary.value = null; |
| | | try { |
| | | const upload = await uploadPointCloudFile(inferenceFile.value); |
| | | const { job } = await createPointCloudModelInference(selectedSemanticModelId.value, upload); |
| | | const { job } = await createPointCloudModelInference(selectedSemanticModelId.value, upload, modelDevice.value); |
| | | inferenceFile.value = null; inferenceJob.value = job; stopInferencePolling(); void pollInferenceJob(); |
| | | } catch (error) { runError.value = error instanceof Error ? error.message : "应用训练模型失败。"; } |
| | | finally { inferenceSubmitting.value = false; } |
| | | } |
| | | function stopAutoAnnotationPolling() { if (autoAnnotationPollTimer) clearTimeout(autoAnnotationPollTimer); autoAnnotationPollTimer = undefined; } |
| | | async function loadAutoAnnotationSummary(job: PointCloudInferenceJob) { |
| | | if (!job.summary) return; |
| | | const response = await fetch(artifactUrl(job.summary), { cache: "no-store" }); |
| | | if (response.ok) autoAnnotationSummary.value = await response.json() as typeof autoAnnotationSummary.value; |
| | | } |
| | | async function loadAutoAnnotationReview(job: PointCloudInferenceJob) { |
| | | const review = await loadPointCloudAutoAnnotationReview(job.runId); |
| | | autoAnnotationReviewChanges.value = review?.corrections ?? []; |
| | | } |
| | | async function restoreAutoAnnotation() { |
| | | if (!annotationSourceId.value || !selectedSemanticModelId.value) return; |
| | | try { |
| | | const job = await loadLatestPointCloudAutoAnnotation(annotationSourceId.value, selectedSemanticModelId.value); |
| | | if (!job) return; |
| | | autoAnnotationJob.value = job; |
| | | await loadAutoAnnotationSummary(job); |
| | | await loadAutoAnnotationReview(job); |
| | | } catch (error) { |
| | | annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法恢复已完成的自动标注结果。" }; |
| | | } |
| | | } |
| | | async function saveAutoAnnotationReview(changes: Array<[number, number]>) { |
| | | if (!autoAnnotationJob.value || !annotationSourceId.value) return; |
| | | autoAnnotationReviewSaving.value = true; |
| | | try { |
| | | const { review } = await savePointCloudAutoAnnotationReview(autoAnnotationJob.value.runId, annotationSourceId.value, changes); |
| | | autoAnnotationReviewChanges.value = changes; |
| | | annotationNotice.value = { type: "success", message: `已保存 ${review.correctionCount.toLocaleString()} 个候选审阅修正;尚未合并进训练标注。` }; |
| | | } catch (error) { |
| | | annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "保存候选审阅修正失败。" }; |
| | | } finally { autoAnnotationReviewSaving.value = false; } |
| | | } |
| | | async function pollAutoAnnotationJob() { |
| | | if (!autoAnnotationJob.value) return; |
| | | try { |
| | | autoAnnotationJob.value = await loadPointCloudModelInferenceJob(autoAnnotationJob.value.id); |
| | | if (autoAnnotationJob.value.status === "complete") { await loadAutoAnnotationSummary(autoAnnotationJob.value); return; } |
| | | if (autoAnnotationJob.value.status !== "failed") autoAnnotationPollTimer = setTimeout(() => { void pollAutoAnnotationJob(); }, 2_500); |
| | | } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "无法读取自动标注任务状态。" }; } |
| | | } |
| | | async function startAutoAnnotation() { |
| | | if (!annotationSourceId.value || !selectedSemanticModelId.value) return; |
| | | autoAnnotationSubmitting.value = true; autoAnnotationJob.value = null; autoAnnotationSummary.value = null; |
| | | try { |
| | | const { job } = await createPointCloudAutoAnnotation(selectedSemanticModelId.value, annotationSourceId.value, autoAnnotationThreshold.value, modelDevice.value); |
| | | autoAnnotationJob.value = job; annotationNotice.value = { type: "info", message: "自动标注候选正在生成;不会覆盖现有人工标注。" }; stopAutoAnnotationPolling(); void pollAutoAnnotationJob(); |
| | | } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "启动自动标注失败。" }; } |
| | | finally { autoAnnotationSubmitting.value = false; } |
| | | } |
| | | async function acceptAutoAnnotation() { |
| | | if (!autoAnnotationJob.value || !annotationSourceId.value || autoAnnotationJob.value.status !== "complete") return; |
| | | autoAnnotationAccepting.value = true; |
| | | try { |
| | | const { annotation } = await acceptPointCloudAutoAnnotation(autoAnnotationJob.value.runId, annotationSourceId.value, latestSourceAnnotation.value?.id); |
| | | await refreshAnnotationData(); |
| | | annotationNotice.value = { type: "success", message: `已确认合并 ${annotation.labelCount.toLocaleString()} 个候选/人工标签,人工标签优先。可使用新版本重新训练。` }; |
| | | } catch (error) { annotationNotice.value = { type: "error", message: error instanceof Error ? error.message : "确认自动标注候选失败。" }; } |
| | | finally { autoAnnotationAccepting.value = false; } |
| | | } |
| | | async function submitRun() { |
| | | if (!files.value.length) { runError.value = "请选择至少一份 PLY、PCD、XYZ、LAS 或 LAZ 点云。"; return; } |
| | |
| | | } |
| | | |
| | | watch([caseId, selectedName], loadVector); |
| | | watch([annotationSourceId, selectedSemanticModelId], () => { autoAnnotationJob.value = null; autoAnnotationSummary.value = null; autoAnnotationReviewChanges.value = []; void restoreAutoAnnotation(); }); |
| | | watch(workflow, () => { searchText.value = ""; selectWorkflowCase(); }); |
| | | onMounted(async () => { await store.loadPointCloud(); selectWorkflowCase(); await loadVector(); await refreshAnnotationData(); await refreshSemanticModels(); }); |
| | | onBeforeUnmount(() => { stopPhotoPolling(); stopTrainingPolling(); stopInferencePolling(); }); |
| | | onMounted(async () => { await store.loadPointCloud(); selectWorkflowCase(); await loadVector(); await refreshAnnotationData(); await refreshSemanticModels(); await restoreAutoAnnotation(); }); |
| | | onBeforeUnmount(() => { stopPhotoPolling(); stopAnnotationSourcePolling(); stopTrainingPolling(); stopInferencePolling(); stopAutoAnnotationPolling(); }); |
| | | </script> |
| | | |
| | | <template> |
| | |
| | | <a-tab-pane key="semantic" tab="点云语义分类" /> |
| | | <a-tab-pane key="model" tab="标注、训练与模型应用" /> |
| | | </a-tabs> |
| | | <a-modal v-model:open="annotationSourceModalOpen" title="新增标注源" :confirm-loading="annotationSourceSubmitting" :ok-button-props="{ disabled: !annotationSourceFile }" ok-text="上传并生成预览" @ok="createAnnotationSource"> |
| | | <p>上传 PLY、PCD、XYZ、LAS 或 LAZ。系统会按原始字节保存文件,在后台生成独立 RGB/XYZ 标注预览,不会改写已有数据源。</p> |
| | | <a-upload accept=".ply,.pcd,.xyz,.xyzn,.xyzrgb,.las,.laz" :file-list="annotationSourceFile ? [{ uid: annotationSourceFile.name, name: annotationSourceFile.name, status: 'done' as const }] : []" :before-upload="beforeAnnotationSourceUpload" @remove="removeAnnotationSourceUpload"><a-button><UploadOutlined />选择点云文件</a-button></a-upload> |
| | | </a-modal> |
| | | <a-modal v-model:open="annotationClassModalOpen" title="管理标签分类" :footer="null" width="680px"> |
| | | <p>标签编码自动分配到 LAS 兼容的 1-255 范围。已保存标注使用的自定义类别不能删除,避免影响历史版本和训练。</p> |
| | | <a-space wrap class="annotation-class-create"> |
| | | <a-input v-model:value="annotationClassLabel" placeholder="中文名称,例如:变压器" /> |
| | | <a-input v-model:value="annotationClassKey" placeholder="英文 key,例如:transformer" /> |
| | | <input v-model="annotationClassColor" aria-label="标签颜色" type="color" class="annotation-color-input" /> |
| | | <a-button type="primary" :loading="annotationClassSubmitting" :disabled="!annotationClassLabel.trim() || !annotationClassKey.trim()" @click="createAnnotationClass"><PlusOutlined />新增分类</a-button> |
| | | </a-space> |
| | | <a-list size="small" bordered :data-source="annotationClasses" class="annotation-class-list"><template #renderItem="{ item }"><a-list-item><a-space><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" /> <span>{{ item.label }}</span><a-typography-text type="secondary">{{ item.key }} / {{ item.code }}</a-typography-text></a-space><template #actions><a-popconfirm v-if="!item.builtIn" title="删除后无法恢复这个未使用的分类,确认删除?" ok-text="删除" cancel-text="取消" @confirm="deleteAnnotationClass(item.code)"><a-button size="small" danger :loading="annotationClassDeletingCode === item.code">删除</a-button></a-popconfirm><a-tag v-else>内置</a-tag></template></a-list-item></template></a-list> |
| | | </a-modal> |
| | | <a-modal v-model:open="annotationSourceRemovalOpen" title="移除完整数据链路" ok-text="移除全部关联数据" ok-type="danger" cancel-text="取消" :confirm-loading="annotationSourceRemoving" @ok="removeAnnotationSourceChain"> |
| | | <a-alert type="error" show-icon message="此操作不可恢复" description="将删除本地工作台中该数据源的全部关联副本、结果和依赖产物;不会删除 baseData 或其他外部输入。" /> |
| | | <a-descriptions v-if="annotationSourceRemovalPlan" class="annotation-removal-summary" size="small" :column="1" bordered> |
| | | <a-descriptions-item label="数据源">{{ annotationSourceRemovalPlan.label }}</a-descriptions-item> |
| | | <a-descriptions-item label="生成结果目录">{{ annotationSourceRemovalPlan.outputDirectories }}</a-descriptions-item> |
| | | <a-descriptions-item label="原始上传副本">{{ annotationSourceRemovalPlan.rawDirectories }}</a-descriptions-item> |
| | | <a-descriptions-item label="处理副本">{{ annotationSourceRemovalPlan.processedDirectories }}</a-descriptions-item> |
| | | <a-descriptions-item label="标注版本">{{ annotationSourceRemovalPlan.annotationRevisions }}</a-descriptions-item> |
| | | <a-descriptions-item label="训练结果">{{ annotationSourceRemovalPlan.trainingRuns }}</a-descriptions-item> |
| | | <a-descriptions-item label="模型推理结果">{{ annotationSourceRemovalPlan.inferenceRuns }}</a-descriptions-item> |
| | | <a-descriptions-item v-if="annotationSourceRemovalPlan.siblingSources > 1" label="同一运行的其他标注源">{{ annotationSourceRemovalPlan.siblingSources - 1 }} 个,也会一并移除</a-descriptions-item> |
| | | </a-descriptions> |
| | | <p v-if="annotationSourceRemovalPlan?.preservesExternalInputs" class="annotation-removal-note">该来源没有由控制台保存的原始上传副本;仅移除当前工作台生成的结果和关联产物,外部输入保持不变。</p> |
| | | </a-modal> |
| | | <section class="workspace-command pointcloud-command"> |
| | | <div v-if="workflow === 'photo'"><h2>新建照片三维重建</h2><p>上传同一架次、同一相机的 JPG/JPEG 序列,在 CPU 上生成稠密点云、网格与纹理预览。</p></div> |
| | | <div v-else-if="workflow === 'geometry'"><h2>新建点云几何处理</h2><p>上传已有点云或重建导出物,生成 DSM、高出地物足迹、栅格和几何结果。</p></div> |
| | |
| | | </section> |
| | | <section v-if="showRunForm && workflow !== 'model'" class="surface-section pointcloud-run-form"> |
| | | <template v-if="workflow === 'photo'"> |
| | | <a-alert type="info" show-icon message="单次选择 3 至 30 张同一架次、同一相机的 JPG/JPEG。原图按字节保存,后台依次执行稀疏 SfM、CPU 稠密点云、网格和纹理;完整任务通常需要几十分钟。有每张照片 GPS/RTK 时再打开下方先验。" /> |
| | | <a-alert type="info" show-icon message="单次选择 3 至 1000 张同一架次、同一相机的 JPG/JPEG。原图按字节保存,后台异步执行稀疏 SfM、CPU 稠密点云、网格和纹理;大批量照片可能需要数小时或更久。有每张照片 GPS/RTK 时再打开下方先验。" /> |
| | | <a-upload multiple accept=".jpg,.jpeg" :file-list="photoFiles.map((file) => ({ uid: file.name, name: file.name, status: 'done' as const }))" :before-upload="beforePhotoUpload" @remove="removePhotoFile"><a-button><UploadOutlined />选择重建照片</a-button></a-upload> |
| | | <a-switch v-model:checked="usePhotoPositionPriors" checked-children="GPS/RTK 先验" un-checked-children="通用配对" /> |
| | | </template> |
| | |
| | | <a-button v-if="workflow === 'photo'" type="primary" :loading="photoSubmitting" :disabled="photoFiles.length < 3" @click="submitPhotoReconstruction"><PlayCircleOutlined />开始照片重建</a-button> |
| | | <a-button v-else type="primary" :loading="running" :disabled="!files.length" @click="submitRun"><PlayCircleOutlined />{{ workflow === 'semantic' ? '开始语义分类' : '开始点云几何处理' }}</a-button> |
| | | </section> |
| | | <a-alert v-if="photoJob" class="pointcloud-photo-job" :type="photoJob.status === 'failed' ? 'error' : photoJob.status === 'complete' ? 'success' : 'info'" show-icon :message="photoJobMessage" /> |
| | | <a-alert v-if="photoJob" class="pointcloud-photo-job" :type="photoJob.status === 'failed' ? 'error' : photoJob.status === 'complete' ? 'success' : 'info'" show-icon :message="photoJobMessage"> |
| | | <template #description><div class="photo-job-progress"><a-progress :percent="photoJobProgress.percent" :status="photoJob.status === 'failed' ? 'exception' : photoJob.status === 'complete' ? 'success' : 'active'" /><span>{{ photoJobProgress.message }}</span><small>阶段里程碑进度,不代表剩余时间;原生 COLMAP/OpenMVS 在单个计算节点内不提供可靠的细粒度百分比。</small></div></template> |
| | | </a-alert> |
| | | |
| | | <template v-if="currentCase && selectedCloud"> |
| | | <a-row v-if="workflow === 'geometry'" :gutter="[18, 18]" class="pointcloud-workspace"> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="(currentCase.run.point_clouds ?? []).map((item) => ({ value: item.file, label: item.file }))" /><span>{{ selectedCloud.raster_width }} x {{ selectedCloud.raster_height }} 本地坐标栅格</span></div><div class="comparison-grid pointcloud-images"><figure><figcaption>DSM 与高出地物栅格</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.preview_file}`)" /></figure><figure><figcaption>高出地物足迹矢量</figcaption><svg class="pointcloud-vector" :viewBox="vectorViewBox" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg></figure></div></section></a-col> |
| | | </a-row> |
| | | <section v-if="workflow === 'semantic' && selectedCloud.semantic_preview_point_cloud" class="result-band"> |
| | | <a-row :gutter="[18, 18]" class="pointcloud-workspace"> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>语义分类案例</h2><a-input-search v-model:value="searchText" placeholder="搜索分类案例" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>语义分类案例</h2><a-input-search v-model:value="searchText" placeholder="搜索分类案例" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>语义分类结果</h2><p>{{ selectedCloud.semantic_method }}。颜色:棕色地面、绿色植被、橙色构筑物、紫色杆塔候选、黄色电线候选、灰色未知。</p></div><a-tag color="gold">人工核验候选</a-tag></div><SparsePointCloudViewer :source="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_preview_point_cloud}`)" /><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item v-for="(count, key) in selectedCloud.semantic_class_counts" :key="key" :label="String(key)">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions></section></a-col> |
| | | </a-row> |
| | | <section class="surface-section result-band"><div class="section-heading"><div><h2>分类结果与下载</h2><p>规则分类用于人工复核,不是资产台账或巡检结论。</p></div></div><a-space wrap><a-button v-if="selectedCloud.semantic_preview_point_cloud" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_preview_point_cloud}`)" target="_blank"><DownloadOutlined />语义预览 PLY</a-button><a-button v-if="selectedCloud.semantic_classified_las" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_classified_las}`)" target="_blank"><DownloadOutlined />语义分类 LAS</a-button><a-button v-if="selectedCloud.semantic_summary_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_summary_file}`)" target="_blank"><FileOutlined />语义统计 CSV</a-button><a-button v-if="selectedCloud.semantic_vector_file" type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedCloud.semantic_vector_file}`)" target="_blank"><FileOutlined />语义候选 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section> |
| | | </section> |
| | | <section v-if="workflow === 'model' && annotationSource" class="surface-section result-band"> |
| | | <div class="section-heading"><div><h2>人工标注与监督训练</h2><p>仅保存你刷选确认的真值点;规则候选颜色不会写入训练标签。任务会自动探测并优先使用本机可用 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 万原始点全部装入浏览器。" /> |
| | | <div class="section-heading"><div><h2>人工标注与监督训练</h2><p>仅保存你刷选确认的真值点;规则候选颜色不会写入训练标签。融合标注使用原始 RGB/XYZ 与照片特征的局部注意力基线,当前不冒充为官方 Point Transformer V3。任务会自动探测并优先使用本机可用 GPU,未通过检测时回退 CPU。</p></div><a-tag color="blue">自动 GPU / CPU</a-tag></div> |
| | | <a-space wrap class="annotation-source-row"><span>标注源</span><a-select v-model:value="annotationSourceId" :options="annotationSources.map((item) => ({ value: item.id, label: item.label }))" /><a-tooltip title="移除选中数据源的完整数据链路"><a-button danger :loading="annotationSourceRemovalLoading" :disabled="!annotationSourceId" aria-label="移除完整数据链路" @click="openAnnotationSourceRemoval"><DeleteOutlined /></a-button></a-tooltip><span>计算设备</span><a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" /></a-space> |
| | | <a-space wrap class="annotation-management-row"> |
| | | <a-button @click="annotationSourceModalOpen = true"><PlusOutlined />新增标注源</a-button> |
| | | <a-button @click="annotationClassModalOpen = true">管理标签分类</a-button> |
| | | <a-tag v-for="item in annotationClasses" :key="item.code"><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" />{{ item.label }}</a-tag> |
| | | </a-space> |
| | | <a-alert type="info" show-icon :message="`标注底图:${annotationSource.sourceKind}`" :description="annotationSourceDescription" /> |
| | | <a-alert v-if="annotationNotice" :type="annotationNotice.type" show-icon :message="annotationNotice.message" /> |
| | | <PointCloudAnnotationViewer :source="annotationSource.url" :disabled="annotationSaving" @save="saveAnnotation" /> |
| | | <a-alert v-if="annotationSourceJob" :type="annotationSourceJob.status === 'failed' ? 'error' : annotationSourceJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`标注源处理任务:${annotationSourceJob.status}`" :description="annotationSourceJob.error || `正在处理 ${annotationSourceJob.inputName};原始文件会保留,完成后生成独立可标注预览。`" /> |
| | | <PointCloudAnnotationViewer :source="annotationSource.url" :source-id="annotationSource.id" :classes="annotationClasses" :disabled="annotationSaving" @save="saveAnnotation" /> |
| | | <section class="model-inference-result auto-annotation-workspace"> |
| | | <div class="section-heading"><div><h3>自动标注当前源</h3><p>直接对当前标注源生成模型候选,无需再次上传点云。仅保留达到置信度阈值的预测;生成完成后必须确认合并,人工标签始终优先。</p></div><a-tag color="gold">候选待确认</a-tag></div> |
| | | <a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚无可用于自动标注的已完成模型" description="先从一个人工标注版本启动训练,训练完成后模型会自动出现在此处。" /> |
| | | <a-space v-else wrap class="model-inference-controls"> |
| | | <a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" /> |
| | | <SemanticModelDeletionControl :model-id="selectedSemanticModelId" :label="selectedSemanticModel?.label ?? selectedSemanticModelId" :disabled="!selectedSemanticModelId" @removed="removeSemanticModel" /> |
| | | <label>最低置信度 <a-input-number v-model:value="autoAnnotationThreshold" :min="0.5" :max="0.999" :step="0.01" :precision="3" /></label> |
| | | <a-button type="primary" :loading="autoAnnotationSubmitting" :disabled="!selectedSemanticModelId" @click="startAutoAnnotation"><PlayCircleOutlined />自动标注当前源</a-button> |
| | | </a-space> |
| | | <a-alert v-if="selectedSemanticModel" class="model-inference-model" type="info" show-icon :message="`模型测试 F1:${Object.entries(selectedSemanticModel.testF1).map(([key, score]) => `${key} ${score.toFixed(3)}`).join(';') || '未记录可用测试指标'}`" description="指标仅覆盖已有人工标注的空间分块。类别在测试分块中没有样本时,F1 不可用于判断该类别的实际效果。" /> |
| | | <a-alert v-if="autoAnnotationJob" :type="autoAnnotationJob.status === 'failed' ? 'error' : autoAnnotationJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`自动标注任务:${autoAnnotationJob.status}(${executionLabel(autoAnnotationJob)})`" :description="autoAnnotationJob.error || (autoAnnotationJob.status === 'complete' ? `已生成高置信度候选,尚未写入人工训练标注。` : `正在对当前 ${annotationSource.pointCount.toLocaleString()} 点源进行后台推理。`)" /> |
| | | <template v-if="autoAnnotationJob?.status === 'complete'"> |
| | | <a-descriptions v-if="autoAnnotationSummary" size="small" :column="{ xs: 1, sm: 2, lg: 3 }"><a-descriptions-item label="输入点数">{{ autoAnnotationSummary.input_points?.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="候选阈值">{{ autoAnnotationSummary.automatic_annotation?.candidate_confidence }}</a-descriptions-item><a-descriptions-item label="高置信候选">{{ autoAnnotationSummary.automatic_annotation?.candidate_count?.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in autoAnnotationSummary.class_counts" :key="String(code)" :label="`${annotationClassDisplayLabel(code)}:预测总数`">{{ count.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-for="(count, code) in autoAnnotationSummary.automatic_annotation?.candidate_class_counts" :key="`candidate-${String(code)}`" :label="`${annotationClassDisplayLabel(code)}:高置信候选`">{{ count.toLocaleString() }}</a-descriptions-item></a-descriptions> |
| | | <section v-if="autoAnnotationJob.preview" class="auto-annotation-preview"><div class="section-heading"><div><h3>候选审阅与修正</h3><p>选择“拒绝候选”后用笔刷或框选剔除错分点;选择正确类别可改类。保存的修正独立于人工标注,最终合并时人工标注仍优先。</p></div><a-tag color="gold">未合并</a-tag></div><a-space wrap class="annotation-management-row"><a-tag v-for="item in annotationClasses.filter((item) => autoAnnotationSummary?.class_counts?.[String(item.code)] !== undefined)" :key="`legend-${item.code}`"><span class="annotation-class-swatch" :style="{ background: annotationColor(item.color) }" />{{ item.label }}</a-tag></a-space><PointCloudAnnotationViewer :source="artifactUrl(autoAnnotationJob.preview)" :source-id="annotationSource.id" :classes="annotationClasses" :review-mode="true" :initial-review-changes="autoAnnotationReviewChanges" :disabled="autoAnnotationReviewSaving" @review-save="saveAutoAnnotationReview" /></section> |
| | | <a-space wrap><a-button v-if="autoAnnotationJob.preview" type="link" :href="artifactUrl(autoAnnotationJob.preview)" target="_blank"><DownloadOutlined />预测预览 PLY</a-button><a-button v-if="autoAnnotationJob.candidateFile" type="link" :href="artifactUrl(autoAnnotationJob.candidateFile)" target="_blank"><FileOutlined />候选与置信度 JSON</a-button><a-popconfirm title="确认后会创建新的标注版本,并把高置信候选与当前人工标注合并;人工标签优先。" ok-text="确认合并" cancel-text="取消" @confirm="acceptAutoAnnotation"><a-button type="primary" :loading="autoAnnotationAccepting">确认合并候选</a-button></a-popconfirm></a-space> |
| | | </template> |
| | | </section> |
| | | <a-divider /> |
| | | <div class="section-heading"><div><h3>已保存标注版本</h3><p>显示所有标注源的版本,可删除标错版本。至少两个类别、每类 500 个用户确认点后可启动训练;系统按 XY 空间块划分训练、验证和测试,避免相邻线路或塔体点泄漏到测试集。</p></div></div> |
| | | <a-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> |
| | | <div class="section-heading"><div><h3>当前标注源的已保存版本</h3><p>切换标注源后只显示对应版本。至少两个类别、每类 500 个用户确认点后可启动训练;融合样本还要求每个类别覆盖训练、验证和测试 XY 区域,避免相邻线路或塔体点泄漏到测试集。</p></div></div> |
| | | <a-alert v-if="!selectedSourceAnnotations.length" type="warning" show-icon message="当前融合样本尚未保存标注版本" description="先选择类别,用笔刷或框选标出确认点,然后点击点云面板右下角的“保存标注版本”。保存成功后,此处会出现“多视角特征训练”按钮。" /> |
| | | <a-list v-else size="small" :data-source="selectedSourceAnnotations"><template #renderItem="{ item }"><a-list-item><a-space wrap><span>{{ item.id }}</span><span>{{ annotationSourceLabel(item.sourceId) }}</span><span>{{ item.labelCount.toLocaleString() }} 点</span><a-button size="small" type="primary" @click="startTraining(item.id)">{{ annotationTrainerLabel(item) }}</a-button><a-popconfirm title="删除后不能恢复该标注版本,确认删除?" ok-text="删除" cancel-text="取消" @confirm="deleteAnnotation(item.id)"><a-tooltip title="删除标注版本"><a-button size="small" danger :loading="annotationDeletingId === item.id" aria-label="删除标注版本"><DeleteOutlined /></a-button></a-tooltip></a-popconfirm></a-space></a-list-item></template></a-list> |
| | | <a-alert v-if="trainingJob" :type="trainingJob.status === 'failed' ? 'error' : trainingJob.status === 'complete' ? 'success' : 'info'" show-icon :message="`训练任务:${trainingJob.status}(${executionLabel(trainingJob)})`" :description="trainingJob.error || (trainingJob.preview ? `已生成预测预览:${trainingJob.preview}` : `后台${executionLabel(trainingJob)}训练中,可继续浏览案例。`)" /> |
| | | <a-divider /> |
| | | <div class="section-heading"><div><h3>应用训练模型</h3><p>选择本机已完成的模型,上传一份新的带 RGB 点云,自动优先使用可用 GPU 生成预测候选。XYZ-only 输入会明确拒绝,不会伪造颜色特征。</p></div><a-tag color="blue">自动 GPU / CPU</a-tag></div> |
| | | <a-alert v-if="!semanticModels.length" type="warning" show-icon message="尚未发现可用训练模型。先完成并保留一次监督训练。" /> |
| | | <a-space v-else wrap class="model-inference-controls"> |
| | | <a-select v-model:value="selectedSemanticModelId" :options="semanticModelOptions" class="model-select" /> |
| | | <SemanticModelDeletionControl :model-id="selectedSemanticModelId" :label="selectedSemanticModel?.label ?? selectedSemanticModelId" :disabled="!selectedSemanticModelId" @removed="removeSemanticModel" /> |
| | | <a-select v-model:value="modelDevice" :options="[{ value: 'auto', label: '自动(可用 GPU 否则 CPU)' }, { value: 'cpu', label: 'CPU' }, { value: 'cuda', label: 'GPU CUDA' }]" /> |
| | | <a-upload accept=".ply,.pcd,.las,.laz" :file-list="inferenceFile ? [{ uid: inferenceFile.name, name: inferenceFile.name, status: 'done' as const }] : []" :before-upload="beforeInferenceUpload" @remove="removeInferenceUpload"><a-button><UploadOutlined />选择待预测点云</a-button></a-upload> |
| | | <a-button type="primary" :loading="inferenceSubmitting" :disabled="!selectedSemanticModelId || !inferenceFile" @click="applySemanticModel"><PlayCircleOutlined />应用模型</a-button> |
| | | </a-space> |
| | |
| | | </template> |
| | | <template v-else-if="currentCase && densePhotoRun"> |
| | | <a-row :gutter="[18, 18]" class="pointcloud-workspace"> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCases"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="05-3d-pointcloud" :run-id="item.value" :label="item.label" @removed="removePointCloudRun" /></a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-heading"><div><h2>CPU 稠密重建与纹理网格</h2><p>拖动旋转、滚轮缩放。模型为局部 SfM 坐标,未经过控制点测绘配准。</p></div><a-space><a-tag color="green">CPU MVS</a-tag><a-button type="primary" size="small" :loading="running" @click="classifyDenseResult"><PlayCircleOutlined />对此稠密点云分类</a-button></a-space></div><a-segmented v-if="densePhotoRun.geometry_preview_model_file" v-model:value="densePreviewMode" :options="[{ label: '可靠纹理', value: 'textured' }, { label: '点云颜色', value: 'point_colors', disabled: !densePhotoRun.point_color_preview_model_file }, { label: '完整几何', value: 'geometry' }]" /><TexturedMeshViewer :source="artifactUrl(`${currentCase.artifactRoot}/${densePreviewSource}`)" :surface="densePreviewMode" /></section></a-col> |
| | | </a-row> |
| | | <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><h2>重建统计</h2><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="输入照片">{{ densePhotoRun.input_images }}</a-descriptions-item><a-descriptions-item label="注册照片">{{ densePhotoRun.registered_images }}</a-descriptions-item><a-descriptions-item label="稀疏点">{{ densePhotoRun.sparse_points.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="稠密点">{{ densePhotoRun.dense_points.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="原始网格顶点">{{ densePhotoRun.mesh_vertices.toLocaleString() }}</a-descriptions-item><a-descriptions-item label="原始网格面">{{ densePhotoRun.mesh_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.preview_mesh_faces" label="可靠纹理面">{{ densePhotoRun.preview_mesh_faces.toLocaleString() }} ({{ ((densePhotoRun.preview_mesh_face_ratio ?? 0) * 100).toFixed(1) }}%)</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.point_color_preview_faces" label="点云颜色面">{{ densePhotoRun.point_color_preview_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.geometry_preview_faces" label="完整几何面">{{ densePhotoRun.geometry_preview_faces.toLocaleString() }}</a-descriptions-item><a-descriptions-item v-if="densePhotoRun.elapsed_seconds !== null" label="耗时">{{ densePhotoRun.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>结果边界</h2><p>可靠纹理只显示有一致照片证据的面,孔洞代表纹理覆盖不足。点云颜色将最近融合点的真实 RGB 投影到完整网格,显示连续表面但不等同逐面照片纹理。完整几何使用中性材质,不补造颜色或纹理;它用于核验几何连续性。</p></section></a-col></a-row> |
| | |
| | | .pointcloud-images :deep(.ant-image), .pointcloud-images :deep(img) { width: 100%; height: 360px; object-fit: contain; background: #171e1a; } |
| | | .pointcloud-vector { width: 100%; height: 360px; border: 1px solid #d9d9d9; background: #f7f8f9; } |
| | | .pointcloud-vector path { fill: rgba(230, 90, 45, 0.24); stroke: #bf4c20; stroke-width: 1.5; vector-effect: non-scaling-stroke; } |
| | | .photo-job-progress { display: grid; gap: 6px; margin-top: 8px; } |
| | | .photo-job-progress small { color: #6a7885; } |
| | | .model-inference-controls { display: flex; margin-bottom: 12px; } |
| | | .model-select { min-width: min(100%, 440px); } |
| | | .model-inference-model { margin-bottom: 12px; } |
| | | .model-inference-result { display: grid; gap: 16px; margin-top: 16px; } |
| | | .auto-annotation-preview { display: grid; gap: 12px; } |
| | | .annotation-management-row { display: flex; margin: 12px 0; } |
| | | .annotation-class-create { display: flex; margin: 16px 0; } |
| | | .annotation-class-create :deep(.ant-input) { width: 190px; } |
| | | .annotation-class-list { max-height: 340px; overflow: auto; } |
| | | .annotation-class-swatch { display: inline-block; width: 14px; height: 14px; border: 1px solid rgba(0, 0, 0, 0.22); vertical-align: -2px; } |
| | | .annotation-color-input { width: 34px; height: 32px; padding: 2px; border: 1px solid #d9d9d9; background: #fff; } |
| | | .annotation-removal-summary { margin-top: 16px; } |
| | | .annotation-removal-note { margin: 12px 0 0; color: #6a7885; } |
| | | @media (max-width: 1199px) { .pointcloud-images :deep(img), .pointcloud-vector { height: 300px; } } |
| | | </style> |
| | |
| | | |
| | | import { artifactUrl, createRiskRuleRun, readFileAsPayload, type RiskRuleCase, type RiskScoreFeature } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import RunDeletionControl from "@/components/RunDeletionControl.vue"; |
| | | import { useArtifactStore } from "@/stores/artifacts"; |
| | | |
| | | const store = useArtifactStore(); |
| | |
| | | const currentCase = computed<RiskRuleCase | undefined>(() => store.riskRuleCases[caseId.value] ?? Object.values(store.riskRuleCases)[0]); |
| | | const caseOptions = computed(() => Object.values(store.riskRuleCases).map((item) => ({ value: item.id, label: item.label }))); |
| | | const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase()))); |
| | | async function removeRun() { await store.loadRiskRule(true); caseId.value = Object.keys(store.riskRuleCases)[0] ?? ""; } |
| | | const levelColor = (level: string) => ({ low: "green", medium: "gold", high: "orange", critical: "red" }[level] ?? "default"); |
| | | const vectorViewBox = computed(() => { |
| | | const bounds = currentCase.value?.run.raster.bounds ?? [0, 0, 1, 1]; |
| | |
| | | |
| | | <template v-if="currentCase"> |
| | | <a-row :gutter="[18, 18]" class="risk-workspace"> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value">{{ item.label }}</a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="07-risk-rule-engine" :run-id="item.value" :label="item.label" @removed="removeRun" /></a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><div><h2>栅格与矢量结果</h2><span class="toolbar-note">{{ currentCase.run.crs }} · {{ currentCase.run.raster.width }} x {{ currentCase.run.raster.height }} · {{ currentCase.run.parameters.raster_resolution }} m 像元</span></div></div><div class="comparison-grid risk-images"><figure><figcaption>风险评分栅格</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.risk_preview}`)" /></figure><figure><figcaption>对象评分矢量</figcaption><svg class="risk-vector" :viewBox="vectorViewBox" preserveAspectRatio="xMidYMid meet"><g transform="scale(1,-1)"><circle v-for="feature in points" :key="feature.properties.object_id" :cx="feature.geometry.coordinates[0]" :cy="feature.geometry.coordinates[1]" :r="Math.max(currentCase.run.parameters.raster_resolution * 1.25, 6)" :class="`risk-${feature.properties.risk_level}`"><title>{{ feature.properties.object_id }}: {{ feature.properties.risk_score }}</title></circle></g></svg></figure></div></section></a-col> |
| | | </a-row> |
| | | |
| New file |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, ref } from "vue"; |
| | | import { DeleteOutlined } from "@ant-design/icons-vue"; |
| | | import { deleteRun, loadRunDeletionPlan, type RunDeletionPlan } from "@/api/artifacts"; |
| | | |
| | | const props = defineProps<{ capability: string; runId: string; label: string }>(); |
| | | const emit = defineEmits<{ removed: [] }>(); |
| | | const visible = ref(false); |
| | | const loadingPlan = ref(false); |
| | | const deleting = ref(false); |
| | | const error = ref<string | null>(null); |
| | | const plan = ref<RunDeletionPlan | null>(null); |
| | | const directoryCount = computed(() => { |
| | | const value = plan.value; |
| | | return value ? value.outputDirectories.length + value.rawDirectories.length + value.processedDirectories.length + value.dependentDirectories.length : 0; |
| | | }); |
| | | |
| | | async function open() { |
| | | loadingPlan.value = true; |
| | | error.value = null; |
| | | plan.value = null; |
| | | visible.value = true; |
| | | try { plan.value = await loadRunDeletionPlan(props.capability, props.runId); } |
| | | catch (reason) { error.value = reason instanceof Error ? reason.message : "无法读取删除范围。"; } |
| | | finally { loadingPlan.value = false; } |
| | | } |
| | | async function confirm() { |
| | | if (!plan.value?.removable) return; |
| | | deleting.value = true; |
| | | error.value = null; |
| | | try { await deleteRun(props.capability, props.runId); visible.value = false; emit("removed"); } |
| | | catch (reason) { error.value = reason instanceof Error ? reason.message : "删除失败。"; } |
| | | finally { deleting.value = false; } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <a-tooltip title="删除该结果及其控制台关联副本"> |
| | | <a-button type="text" danger size="small" aria-label="删除完整结果" @click.stop="open"><DeleteOutlined /></a-button> |
| | | </a-tooltip> |
| | | <a-modal v-model:open="visible" :title="`移除完整结果:${label}`" :confirm-loading="deleting" :ok-button-props="{ danger: true, disabled: loadingPlan || !plan?.removable }" :ok-text="plan?.removable ? '移除全部关联数据' : '不可删除'" cancel-text="取消" @ok="confirm"> |
| | | <a-spin :spinning="loadingPlan"> |
| | | <a-alert v-if="error" type="error" show-icon :message="error" /> |
| | | <a-alert v-else-if="plan && !plan.removable" type="info" show-icon message="该结果不能通过控制台删除" :description="plan.reason" /> |
| | | <template v-else-if="plan"> |
| | | <a-alert type="warning" show-icon message="此操作不可恢复" description="仅删除本地控制台生成的结果和上传副本;baseData 与外部原始输入不会删除。" /> |
| | | <a-descriptions size="small" :column="1" class="deletion-summary"> |
| | | <a-descriptions-item label="结果目录">{{ plan.outputDirectories.length }}</a-descriptions-item> |
| | | <a-descriptions-item label="原始上传副本">{{ plan.rawDirectories.length }}</a-descriptions-item> |
| | | <a-descriptions-item label="处理副本">{{ plan.processedDirectories.length }}</a-descriptions-item> |
| | | <a-descriptions-item label="关联结果">{{ plan.dependentDirectories.length }}</a-descriptions-item> |
| | | <a-descriptions-item label="目录总数">{{ directoryCount }}</a-descriptions-item> |
| | | </a-descriptions> |
| | | </template> |
| | | </a-spin> |
| | | </a-modal> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .deletion-summary { margin-top: 16px; } |
| | | </style> |
| | |
| | | |
| | | import { artifactUrl, createSemanticRun, loadSemanticTasks, readFileAsPayload, type SemanticTask } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import RunDeletionControl from "@/components/RunDeletionControl.vue"; |
| | | import { useArtifactStore } from "@/stores/artifacts"; |
| | | |
| | | interface GeoFeature { properties?: Record<string, unknown>; geometry?: { type: string; coordinates: unknown }; } |
| | |
| | | const vectorPaths = computed(() => allRings.value.map((ring) => ring.map((point, index) => `${index ? "L" : "M"}${point[0]},${point[1]}`).join(" ") + " Z")); |
| | | |
| | | function syncSelection() { selectedName.value = currentCase.value?.run.images[0]?.file ?? ""; } |
| | | async function removeRun() { await store.loadSemantic(true); caseId.value = Object.keys(store.semanticCases)[0] ?? ""; syncSelection(); } |
| | | function beforeUpload(file: File) { files.value = [...files.value, file]; return false; } |
| | | function removeFile(file: { name: string }) { files.value = files.value.filter((item) => item.name !== file.name); } |
| | | async function loadVector() { |
| | |
| | | |
| | | <template v-if="currentCase && selectedImage"> |
| | | <a-row :gutter="[18, 18]" class="semantic-workspace"> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="02-semantic-mapping" :run-id="item.value" :label="item.label" @removed="removeRun" /></a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="currentCase.run.images.map((item) => ({ value: item.file, label: item.file }))" /><span>{{ selectedImage.width }} x {{ selectedImage.height }} 像素</span></div><div class="comparison-grid semantic-images"><figure><figcaption>原始影像</figcaption><a-image :src="artifactUrl(`${currentCase.rawInputRoot}/${selectedImage.file}`)" /></figure><figure><figcaption>栅格分割叠加</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.overlay_file}`)" /></figure></div></section></a-col> |
| | | </a-row> |
| | | |
| New file |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, ref } from "vue"; |
| | | import { DeleteOutlined } from "@ant-design/icons-vue"; |
| | | import { deletePointCloudSemanticModel, loadPointCloudSemanticModelDeletionPlan, type PointCloudSemanticModelDeletionPlan } from "@/api/artifacts"; |
| | | |
| | | const props = defineProps<{ modelId: string; label: string; disabled?: boolean }>(); |
| | | const emit = defineEmits<{ removed: [] }>(); |
| | | const visible = ref(false); |
| | | const loadingPlan = ref(false); |
| | | const deleting = ref(false); |
| | | const error = ref<string | null>(null); |
| | | const plan = ref<PointCloudSemanticModelDeletionPlan | null>(null); |
| | | const dependentCount = computed(() => (plan.value?.inferenceDirectories.length ?? 0) + (plan.value?.autoAnnotationDirectories.length ?? 0)); |
| | | |
| | | async function open() { |
| | | if (props.disabled) return; |
| | | visible.value = true; loadingPlan.value = true; error.value = null; plan.value = null; |
| | | try { plan.value = await loadPointCloudSemanticModelDeletionPlan(props.modelId); } |
| | | catch (reason) { error.value = reason instanceof Error ? reason.message : "无法读取模型删除范围。"; } |
| | | finally { loadingPlan.value = false; } |
| | | } |
| | | async function confirm() { |
| | | if (!plan.value?.removable) return; |
| | | deleting.value = true; error.value = null; |
| | | try { await deletePointCloudSemanticModel(props.modelId); visible.value = false; emit("removed"); } |
| | | catch (reason) { error.value = reason instanceof Error ? reason.message : "删除模型失败。"; } |
| | | finally { deleting.value = false; } |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <a-tooltip title="删除训练模型及其推理、自动标注产物"> |
| | | <a-button type="text" danger size="small" :disabled="disabled" aria-label="删除训练模型" @click="open"><DeleteOutlined /></a-button> |
| | | </a-tooltip> |
| | | <a-modal v-model:open="visible" :title="`移除训练模型:${label}`" :confirm-loading="deleting" :ok-button-props="{ danger: true, disabled: loadingPlan || !plan?.removable }" ok-text="移除模型及关联产物" cancel-text="取消" @ok="confirm"> |
| | | <a-spin :spinning="loadingPlan"> |
| | | <a-alert v-if="error" type="error" show-icon :message="error" /> |
| | | <template v-else-if="plan"> |
| | | <a-alert type="warning" show-icon message="此操作不可恢复" description="会删除模型、由它产生的推理和自动标注产物;不会删除人工标注版本、baseData 或外部输入。" /> |
| | | <a-descriptions size="small" :column="1" class="deletion-summary"> |
| | | <a-descriptions-item label="训练模型目录">{{ plan.trainingDirectories.length }}</a-descriptions-item> |
| | | <a-descriptions-item label="模型推理结果">{{ plan.inferenceDirectories.length }}</a-descriptions-item> |
| | | <a-descriptions-item label="自动标注结果">{{ plan.autoAnnotationDirectories.length }}</a-descriptions-item> |
| | | <a-descriptions-item label="关联结果总数">{{ dependentCount }}</a-descriptions-item> |
| | | </a-descriptions> |
| | | </template> |
| | | </a-spin> |
| | | </a-modal> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .deletion-summary { margin-top: 16px; } |
| | | </style> |
| | |
| | | <script setup lang="ts"> |
| | | import { onBeforeUnmount, onMounted, ref, watch } from "vue"; |
| | | import { BorderTopOutlined, ReloadOutlined } from "@ant-design/icons-vue"; |
| | | import { onBeforeUnmount, onMounted, reactive, ref, watch } from "vue"; |
| | | import { AimOutlined, BorderTopOutlined, ReloadOutlined, RotateRightOutlined } from "@ant-design/icons-vue"; |
| | | import * as THREE from "three"; |
| | | import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; |
| | | |
| | | const props = defineProps<{ source: string }>(); |
| | | |
| | | type PropertyReader = { name: string; bytes: number; read: (view: DataView, offset: number) => number }; |
| | | |
| | | const host = ref<HTMLDivElement | null>(null); |
| | | const loading = ref(true); |
| | | const error = ref<string | null>(null); |
| | | const pointCount = ref(0); |
| | | const pointSize = ref(2.4); |
| | | const pivotPicking = ref(false); |
| | | const orientationOpen = ref(false); |
| | | const orientation = reactive({ x: 0, y: 0, z: 0 }); |
| | | const orientationAxes = [{ key: "x" as const, label: "X" }, { key: "y" as const, label: "Y" }, { key: "z" as const, label: "Z" }]; |
| | | |
| | | let renderer: THREE.WebGLRenderer | null = null; |
| | | let scene: THREE.Scene | null = null; |
| | | let camera: THREE.PerspectiveCamera | null = null; |
| | | let controls: OrbitControls | null = null; |
| | | let points: THREE.Points<THREE.BufferGeometry, THREE.PointsMaterial> | null = null; |
| | | let model: THREE.Group | null = null; |
| | | let resizeObserver: ResizeObserver | null = null; |
| | | let animationFrame = 0; |
| | | let requestId = 0; |
| | | |
| | | const readers: Record<string, Omit<PropertyReader, "name">> = { |
| | | char: { bytes: 1, read: (view, offset) => view.getInt8(offset) }, |
| | | int8: { bytes: 1, read: (view, offset) => view.getInt8(offset) }, |
| | | uchar: { bytes: 1, read: (view, offset) => view.getUint8(offset) }, |
| | | uint8: { bytes: 1, read: (view, offset) => view.getUint8(offset) }, |
| | | short: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) }, |
| | | int16: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) }, |
| | | ushort: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) }, |
| | | uint16: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) }, |
| | | int: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) }, |
| | | int32: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) }, |
| | | uint: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) }, |
| | | uint32: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) }, |
| | | float: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) }, |
| | | float32: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) }, |
| | | double: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) }, |
| | | float64: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) }, |
| | | char: { bytes: 1, read: (view, offset) => view.getInt8(offset) }, int8: { bytes: 1, read: (view, offset) => view.getInt8(offset) }, |
| | | uchar: { bytes: 1, read: (view, offset) => view.getUint8(offset) }, uint8: { bytes: 1, read: (view, offset) => view.getUint8(offset) }, |
| | | short: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) }, int16: { bytes: 2, read: (view, offset) => view.getInt16(offset, true) }, |
| | | ushort: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) }, uint16: { bytes: 2, read: (view, offset) => view.getUint16(offset, true) }, |
| | | int: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) }, int32: { bytes: 4, read: (view, offset) => view.getInt32(offset, true) }, |
| | | uint: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) }, uint32: { bytes: 4, read: (view, offset) => view.getUint32(offset, true) }, |
| | | float: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) }, float32: { bytes: 4, read: (view, offset) => view.getFloat32(offset, true) }, |
| | | double: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) }, float64: { bytes: 8, read: (view, offset) => view.getFloat64(offset, true) }, |
| | | }; |
| | | |
| | | function headerEnd(bytes: Uint8Array): number { |
| | | const marker = [101, 110, 100, 95, 104, 101, 97, 100, 101, 114]; // end_header |
| | | const marker = [101, 110, 100, 95, 104, 101, 97, 100, 101, 114]; |
| | | for (let start = 0; start <= bytes.length - marker.length; start += 1) { |
| | | if (marker.every((value, index) => bytes[start + index] === value)) { |
| | | let end = start + marker.length; |
| | |
| | | if (bytes[end] === 10) return end + 1; |
| | | } |
| | | } |
| | | throw new Error("PLY 文件缺少 end_header。"); |
| | | throw new Error("PLY file is missing end_header."); |
| | | } |
| | | |
| | | function parsePly(buffer: ArrayBuffer): { positions: Float32Array; colors: Float32Array } { |
| | | const bytes = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 64 * 1024)); |
| | | const end = headerEnd(bytes); |
| | | const header = new TextDecoder("ascii").decode(new Uint8Array(buffer, 0, end)); |
| | | const lines = header.split(/\r?\n/); |
| | | if (!lines.some((line) => line.trim() === "format binary_little_endian 1.0")) { |
| | | throw new Error("当前仅支持 binary_little_endian PLY。"); |
| | | } |
| | | const vertexLine = lines.find((line) => line.startsWith("element vertex ")); |
| | | const count = Number(vertexLine?.split(/\s+/)[2]); |
| | | if (!Number.isSafeInteger(count) || count < 1) throw new Error("PLY 顶点数量无效。"); |
| | | const end = headerEnd(new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 64 * 1024))); |
| | | const lines = new TextDecoder("ascii").decode(new Uint8Array(buffer, 0, end)).split(/\r?\n/); |
| | | if (!lines.some((line) => line.trim() === "format binary_little_endian 1.0")) throw new Error("Only binary_little_endian PLY is supported."); |
| | | const count = Number(lines.find((line) => line.startsWith("element vertex "))?.split(/\s+/)[2]); |
| | | if (!Number.isSafeInteger(count) || count < 1) throw new Error("Invalid PLY vertex count."); |
| | | const properties: PropertyReader[] = []; |
| | | let inVertex = false; |
| | | for (const line of lines) { |
| | | if (line.startsWith("element ")) { |
| | | inVertex = line.startsWith("element vertex "); |
| | | continue; |
| | | } |
| | | if (line.startsWith("element ")) { inVertex = line.startsWith("element vertex "); continue; } |
| | | if (!inVertex || !line.startsWith("property ")) continue; |
| | | const [, type, name] = line.trim().split(/\s+/); |
| | | const reader = readers[type]; |
| | | if (!reader || !name) throw new Error(`不支持的顶点属性类型:${type ?? "未知"}。`); |
| | | properties.push({ name, ...reader }); |
| | | if (!readers[type] || !name) throw new Error(`Unsupported PLY vertex property: ${type ?? "unknown"}.`); |
| | | properties.push({ name, ...readers[type] }); |
| | | } |
| | | const stride = properties.reduce((total, property) => total + property.bytes, 0); |
| | | if (end + stride * count > buffer.byteLength) throw new Error("PLY 顶点数据不完整。"); |
| | | const offsets = Object.fromEntries(properties.reduce<[string, number][]>((items, property) => { |
| | | const offset = items.length ? items[items.length - 1][1] + properties[items.length - 1].bytes : 0; |
| | | items.push([property.name, offset]); |
| | | return items; |
| | | }, [])); |
| | | const required = ["x", "y", "z"]; |
| | | if (required.some((name) => offsets[name] === undefined)) throw new Error("PLY 缺少 x/y/z 顶点坐标。"); |
| | | const propertyByName = Object.fromEntries(properties.map((property) => [property.name, property])); |
| | | const offsets: Record<string, number> = {}; |
| | | let stride = 0; |
| | | for (const property of properties) { offsets[property.name] = stride; stride += property.bytes; } |
| | | if (["x", "y", "z"].some((name) => offsets[name] === undefined) || end + stride * count > buffer.byteLength) throw new Error("PLY coordinate data is incomplete."); |
| | | const byName = Object.fromEntries(properties.map((property) => [property.name, property])); |
| | | const view = new DataView(buffer, end); |
| | | const positions = new Float32Array(count * 3); |
| | | const colors = new Float32Array(count * 3); |
| | | const hasColor = ["red", "green", "blue"].every((name) => offsets[name] !== undefined); |
| | | for (let index = 0; index < count; index += 1) { |
| | | const base = index * stride; |
| | | for (const [axis, offset] of [["x", 0], ["y", 1], ["z", 2]] as const) { |
| | | positions[index * 3 + offset] = propertyByName[axis].read(view, base + offsets[axis]); |
| | | } |
| | | for (const [channel, offset] of [["red", 0], ["green", 1], ["blue", 2]] as const) { |
| | | colors[index * 3 + offset] = hasColor ? propertyByName[channel].read(view, base + offsets[channel]) / 255 : 0.72; |
| | | } |
| | | for (const [axis, offset] of [["x", 0], ["y", 1], ["z", 2]] as const) positions[index * 3 + offset] = byName[axis].read(view, base + offsets[axis]); |
| | | for (const [channel, offset] of [["red", 0], ["green", 1], ["blue", 2]] as const) colors[index * 3 + offset] = hasColor ? byName[channel].read(view, base + offsets[channel]) / 255 : 0.72; |
| | | } |
| | | return { positions, colors }; |
| | | } |
| | | |
| | | function resize() { |
| | | if (!host.value || !renderer || !camera) return; |
| | | const width = Math.max(host.value.clientWidth, 1); |
| | | const height = Math.max(host.value.clientHeight, 1); |
| | | renderer.setSize(width, height, false); |
| | | camera.aspect = width / height; |
| | | camera.updateProjectionMatrix(); |
| | | } |
| | | |
| | | function frame() { |
| | | if (renderer && scene && camera && controls) { |
| | | controls.update(); |
| | | renderer.render(scene, camera); |
| | | } |
| | | animationFrame = requestAnimationFrame(frame); |
| | | } |
| | | function resize() { if (!host.value || !renderer || !camera) return; const width = Math.max(host.value.clientWidth, 1); const height = Math.max(host.value.clientHeight, 1); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); } |
| | | function frame() { if (renderer && scene && camera && controls) { controls.update(); renderer.render(scene, camera); } animationFrame = requestAnimationFrame(frame); } |
| | | function modelCentre() { return model ? model.localToWorld(new THREE.Vector3()) : null; } |
| | | function modelRadius() { points?.geometry.computeBoundingSphere(); return Math.max(points?.geometry.boundingSphere?.radius ?? 0.01, 0.01); } |
| | | |
| | | function resetView() { |
| | | if (!camera || !controls || !points) return; |
| | | const sphere = new THREE.Sphere(); |
| | | points.geometry.computeBoundingSphere(); |
| | | if (!points.geometry.boundingSphere) return; |
| | | sphere.copy(points.geometry.boundingSphere); |
| | | const radius = Math.max(sphere.radius, 0.01); |
| | | controls.target.copy(sphere.center); |
| | | camera.position.copy(sphere.center).add(new THREE.Vector3(radius * 1.25, radius * 0.85, radius * 1.65)); |
| | | camera.near = radius / 100; |
| | | camera.far = radius * 100; |
| | | camera.updateProjectionMatrix(); |
| | | controls.update(); |
| | | const centre = modelCentre(); if (!centre) return; |
| | | const radius = modelRadius(); |
| | | controls.target.copy(centre); controls.minDistance = radius * 0.08; controls.maxDistance = radius * 8; |
| | | camera.position.copy(centre).add(new THREE.Vector3(radius * 1.25, radius * 0.85, radius * 1.65)); |
| | | camera.near = Math.max(radius / 1_000, 0.01); camera.far = radius * 100; camera.updateProjectionMatrix(); controls.update(); |
| | | } |
| | | |
| | | function topDownView() { |
| | | if (!camera || !controls || !points) return; |
| | | points.geometry.computeBoundingSphere(); |
| | | if (!points.geometry.boundingSphere) return; |
| | | const sphere = points.geometry.boundingSphere; |
| | | const radius = Math.max(sphere.radius, 0.01); |
| | | const distance = Math.max(camera.position.distanceTo(controls.target), radius * 1.5); |
| | | controls.target.copy(sphere.center); |
| | | camera.up.set(0, 1, 0); |
| | | camera.position.copy(sphere.center).add(new THREE.Vector3(0, 0, distance)); |
| | | camera.near = radius / 100; |
| | | camera.far = radius * 100; |
| | | camera.updateProjectionMatrix(); |
| | | controls.update(); |
| | | if (!camera || !controls || !model) return; |
| | | const centre = modelCentre(); if (!centre) return; |
| | | const radius = modelRadius(); const distance = Math.max(camera.position.distanceTo(controls.target), radius * 1.5); |
| | | controls.target.copy(centre); camera.up.set(0, 1, 0); |
| | | camera.position.copy(centre).add(new THREE.Vector3(0, 0, 1).applyQuaternion(model.quaternion).multiplyScalar(distance)); |
| | | camera.near = Math.max(radius / 1_000, 0.01); camera.far = radius * 100; camera.updateProjectionMatrix(); controls.update(); |
| | | } |
| | | |
| | | function applyPointSize() { |
| | | if (points) points.material.size = pointSize.value; |
| | | function applyPointSize() { if (points) points.material.size = pointSize.value; } |
| | | function applyOrientation() { if (model) model.rotation.set(THREE.MathUtils.degToRad(orientation.x), THREE.MathUtils.degToRad(orientation.y), THREE.MathUtils.degToRad(orientation.z), "XYZ"); } |
| | | function resetOrientation() { orientation.x = 0; orientation.y = 0; orientation.z = 0; } |
| | | function togglePivotPicking() { pivotPicking.value = !pivotPicking.value; } |
| | | function pickPivot(event: PointerEvent) { |
| | | if (!renderer || !camera || !points || !controls) return; |
| | | const rect = renderer.domElement.getBoundingClientRect(); const mouse = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); |
| | | const raycaster = new THREE.Raycaster(); raycaster.params.Points.threshold = Math.max(0.35, pointSize.value * 0.5); raycaster.setFromCamera(mouse, camera); |
| | | const hit = raycaster.intersectObject(points, false)[0]; if (hit) controls.target.copy(hit.point); pivotPicking.value = false; |
| | | } |
| | | function handleCanvasPointerDown(event: PointerEvent) { if (!pivotPicking.value || event.button !== 0) return; event.preventDefault(); event.stopImmediatePropagation(); pickPivot(event); } |
| | | function preventMiddleAutoScroll(event: MouseEvent) { if (event.button === 1) event.preventDefault(); } |
| | | function preventContextMenu(event: MouseEvent) { event.preventDefault(); } |
| | | |
| | | async function loadSource(source: string) { |
| | | const activeId = ++requestId; |
| | | loading.value = true; |
| | | error.value = null; |
| | | const activeId = ++requestId; loading.value = true; error.value = null; pivotPicking.value = false; resetOrientation(); |
| | | try { |
| | | const response = await fetch(source, { cache: "no-store" }); |
| | | if (!response.ok) throw new Error(`点云读取失败 (${response.status})。`); |
| | | const parsed = parsePly(await response.arrayBuffer()); |
| | | if (activeId !== requestId || !scene) return; |
| | | points?.geometry.dispose(); |
| | | points?.material.dispose(); |
| | | const geometry = new THREE.BufferGeometry(); |
| | | geometry.setAttribute("position", new THREE.BufferAttribute(parsed.positions, 3)); |
| | | geometry.setAttribute("color", new THREE.BufferAttribute(parsed.colors, 3)); |
| | | const material = new THREE.PointsMaterial({ size: pointSize.value, vertexColors: true, sizeAttenuation: false }); |
| | | points = new THREE.Points(geometry, material); |
| | | scene.add(points); |
| | | pointCount.value = parsed.positions.length / 3; |
| | | resetView(); |
| | | } catch (caught) { |
| | | if (activeId === requestId) error.value = caught instanceof Error ? caught.message : "点云无法读取。"; |
| | | } finally { |
| | | if (activeId === requestId) loading.value = false; |
| | | } |
| | | const response = await fetch(source, { cache: "no-store" }); if (!response.ok) throw new Error(`Point cloud request failed (${response.status}).`); |
| | | const parsed = parsePly(await response.arrayBuffer()); if (activeId !== requestId || !scene) return; |
| | | if (model) scene.remove(model); points?.geometry.dispose(); points?.material.dispose(); |
| | | const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(parsed.positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(parsed.colors, 3)); geometry.computeBoundingSphere(); |
| | | points = new THREE.Points(geometry, new THREE.PointsMaterial({ size: pointSize.value, vertexColors: true, sizeAttenuation: false })); |
| | | const centre = geometry.boundingSphere?.center.clone() ?? new THREE.Vector3(); model = new THREE.Group(); model.position.copy(centre); points.position.copy(centre).multiplyScalar(-1); model.add(points); scene.add(model); |
| | | applyOrientation(); pointCount.value = parsed.positions.length / 3; resetView(); |
| | | } catch (caught) { if (activeId === requestId) error.value = caught instanceof Error ? caught.message : "Point cloud could not be loaded."; } |
| | | finally { if (activeId === requestId) loading.value = false; } |
| | | } |
| | | |
| | | onMounted(() => { |
| | | if (!host.value) return; |
| | | scene = new THREE.Scene(); |
| | | scene.background = new THREE.Color("#101821"); |
| | | camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1_000); |
| | | renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); |
| | | renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| | | host.value.appendChild(renderer.domElement); |
| | | controls = new OrbitControls(camera, renderer.domElement); |
| | | controls.enableDamping = true; |
| | | controls.dampingFactor = 0.08; |
| | | controls.screenSpacePanning = true; |
| | | resizeObserver = new ResizeObserver(resize); |
| | | resizeObserver.observe(host.value); |
| | | resize(); |
| | | frame(); |
| | | void loadSource(props.source); |
| | | scene = new THREE.Scene(); scene.background = new THREE.Color("#101821"); camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1_000); |
| | | renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; host.value.appendChild(renderer.domElement); |
| | | renderer.domElement.addEventListener("mousedown", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("auxclick", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("pointerdown", handleCanvasPointerDown); renderer.domElement.addEventListener("contextmenu", preventContextMenu); |
| | | controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.12; controls.rotateSpeed = 0.55; controls.zoomSpeed = 0.8; controls.panSpeed = 0.75; controls.screenSpacePanning = true; controls.minPolarAngle = 0.04; controls.maxPolarAngle = Math.PI - 0.04; controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE }; |
| | | resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host.value); resize(); frame(); void loadSource(props.source); |
| | | }); |
| | | |
| | | watch(() => props.source, (source) => { void loadSource(source); }); |
| | | watch(pointSize, applyPointSize); |
| | | watch(orientation, applyOrientation, { deep: true }); |
| | | |
| | | onBeforeUnmount(() => { |
| | | cancelAnimationFrame(animationFrame); |
| | | resizeObserver?.disconnect(); |
| | | controls?.dispose(); |
| | | points?.geometry.dispose(); |
| | | points?.material.dispose(); |
| | | renderer?.dispose(); |
| | | renderer?.domElement.remove(); |
| | | cancelAnimationFrame(animationFrame); resizeObserver?.disconnect(); renderer?.domElement.removeEventListener("mousedown", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("auxclick", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("pointerdown", handleCanvasPointerDown); renderer?.domElement.removeEventListener("contextmenu", preventContextMenu); controls?.dispose(); points?.geometry.dispose(); points?.material.dispose(); renderer?.dispose(); renderer?.domElement.remove(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="sparse-point-viewer"> |
| | | <div ref="host" class="canvas-host" aria-label="稀疏点云交互视图" /> |
| | | <div ref="host" class="canvas-host" :class="{ 'pivot-picking': pivotPicking }" aria-label="Point cloud interactive view" /> |
| | | <div class="viewer-toolbar"> |
| | | <span>{{ pointCount.toLocaleString() }} 点</span> |
| | | <label>点大小 <a-slider v-model:value="pointSize" :min="1" :max="8" :step="0.2" /></label> |
| | | <a-tooltip title="俯视模型(沿本地 Z 轴)"><a-button type="text" aria-label="俯视模型" @click="topDownView"><BorderTopOutlined /></a-button></a-tooltip> |
| | | <a-popover v-model:open="orientationOpen" title="模型朝向" trigger="click"><template #content><div class="orientation-controls"><label v-for="axis in orientationAxes" :key="axis.key">{{ axis.label }}<a-slider v-model:value="orientation[axis.key]" :min="0" :max="360" :step="1" /><a-input-number v-model:value="orientation[axis.key]" :min="0" :max="360" :precision="0" addon-after="度" /></label><a-button size="small" @click="resetOrientation">重置</a-button></div></template><a-tooltip title="模型朝向"><a-button type="text" aria-label="模型朝向"><RotateRightOutlined /></a-button></a-tooltip></a-popover> |
| | | <a-tooltip title="设置旋转中心后,在点云上点击可见点"><a-button :type="pivotPicking ? 'primary' : 'text'" aria-label="设置旋转中心" @click="togglePivotPicking"><AimOutlined /></a-button></a-tooltip> |
| | | <a-tooltip title="俯视模型"><a-button type="text" aria-label="俯视模型" @click="topDownView"><BorderTopOutlined /></a-button></a-tooltip> |
| | | <a-tooltip title="复位视角"><a-button type="text" aria-label="复位视角" @click="resetView"><ReloadOutlined /></a-button></a-tooltip> |
| | | </div> |
| | | <div v-if="loading || error" class="viewer-state"><a-spin v-if="loading" /><span v-else>{{ error }}</span></div> |
| | |
| | | <style scoped> |
| | | .sparse-point-viewer { position: relative; height: 560px; overflow: hidden; background: #101821; border: 1px solid #273543; } |
| | | .canvas-host { width: 100%; height: 100%; } |
| | | .canvas-host :deep(canvas) { display: block; width: 100%; height: 100%; } |
| | | .canvas-host.pivot-picking { cursor: crosshair; } |
| | | .canvas-host :deep(canvas) { display: block; width: 100%; height: 100%; touch-action: none; } |
| | | .viewer-toolbar { position: absolute; right: 12px; bottom: 12px; display: flex; align-items: center; gap: 12px; padding: 8px 10px; color: #e8f1f5; background: rgba(12, 20, 29, 0.84); font-size: 13px; } |
| | | .viewer-toolbar label { display: flex; align-items: center; gap: 8px; white-space: nowrap; } |
| | | .viewer-toolbar :deep(.ant-slider) { width: 100px; margin: 0; } |
| | | .viewer-toolbar :deep(.ant-btn) { color: #e8f1f5; } |
| | | .orientation-controls { display: grid; gap: 8px; min-width: 250px; } |
| | | .orientation-controls label { display: grid; grid-template-columns: 16px 1fr 84px; align-items: center; gap: 8px; } |
| | | .orientation-controls :deep(.ant-slider) { margin: 0; } |
| | | .orientation-controls :deep(.ant-input-number-group-wrapper) { width: 84px; } |
| | | .viewer-state { position: absolute; inset: 0; display: grid; place-items: center; color: #e8f1f5; background: rgba(12, 20, 29, 0.65); } |
| | | @media (max-width: 767px) { .sparse-point-viewer { height: 420px; } .viewer-toolbar { left: 8px; right: 8px; bottom: 8px; gap: 8px; } .viewer-toolbar :deep(.ant-slider) { width: min(92px, 24vw); } } |
| | | @media (max-width: 767px) { .sparse-point-viewer { height: 420px; } .viewer-toolbar { left: 8px; right: 8px; bottom: 8px; gap: 8px; flex-wrap: wrap; } .viewer-toolbar :deep(.ant-slider) { width: min(92px, 24vw); } } |
| | | </style> |
| | |
| | | |
| | | import { artifactUrl, createMeasurementRun, readFileAsPayload, type MeasurementCase } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import RunDeletionControl from "@/components/RunDeletionControl.vue"; |
| | | import { useArtifactStore } from "@/stores/artifacts"; |
| | | |
| | | interface GeoFeature { properties?: Record<string, unknown>; geometry?: { type: string; coordinates: unknown }; } |
| | |
| | | const vectorPaths = computed(() => allRings.value.map((ring) => ring.map((point, index) => `${index ? "L" : "M"}${point[0]},${point[1]}`).join(" ") + " Z")); |
| | | |
| | | function syncSelection() { selectedName.value = currentCase.value?.run.images[0]?.file ?? ""; } |
| | | async function removeRun() { await store.loadMeasurement(true); caseId.value = Object.keys(store.measurementCases)[0] ?? ""; syncSelection(); } |
| | | function beforeUpload(file: File) { files.value = [...files.value, file]; return false; } |
| | | function removeFile(file: { name: string }) { files.value = files.value.filter((item) => item.name !== file.name); } |
| | | async function loadVector() { |
| | |
| | | |
| | | <template v-if="currentCase && selectedImage"> |
| | | <a-row :gutter="[18, 18]" class="measurement-workspace"> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()">{{ item.label }}</a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value; syncSelection()"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="04-spatial-measurement" :run-id="item.value" :label="item.label" @removed="removeRun" /></a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="19"><section class="surface-section"><div class="section-toolbar"><a-select v-model:value="selectedName" :options="currentCase.run.images.map((item) => ({ value: item.file, label: item.file }))" /><span>{{ selectedImage.width }} x {{ selectedImage.height }} 栅格</span></div><div class="comparison-grid measurement-images"><figure><figcaption>栅格测量预览</figcaption><a-image :src="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.preview_file}`)" /></figure><figure><figcaption>矢量对象范围</figcaption><svg class="vector-preview" :viewBox="vectorViewBox" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg></figure></div></section></a-col> |
| | | </a-row> |
| | | |
| | |
| | | <script setup lang="ts"> |
| | | import { onBeforeUnmount, onMounted, ref, watch } from "vue"; |
| | | import { BorderTopOutlined, ReloadOutlined } from "@ant-design/icons-vue"; |
| | | import { onBeforeUnmount, onMounted, reactive, ref, watch } from "vue"; |
| | | import { AimOutlined, BorderTopOutlined, ReloadOutlined, RotateRightOutlined } from "@ant-design/icons-vue"; |
| | | import * as THREE from "three"; |
| | | import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; |
| | | import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; |
| | |
| | | const error = ref<string | null>(null); |
| | | const triangleCount = ref(0); |
| | | const wireframe = ref(false); |
| | | const pivotPicking = ref(false); |
| | | const orientationOpen = ref(false); |
| | | const orientation = reactive({ x: 0, y: 0, z: 0 }); |
| | | const orientationAxes = [{ key: "x" as const, label: "X" }, { key: "y" as const, label: "Y" }, { key: "z" as const, label: "Z" }]; |
| | | |
| | | let renderer: THREE.WebGLRenderer | null = null; |
| | | let scene: THREE.Scene | null = null; |
| | |
| | | let animationFrame = 0; |
| | | let requestId = 0; |
| | | |
| | | function resize() { |
| | | if (!host.value || !renderer || !camera) return; |
| | | const width = Math.max(host.value.clientWidth, 1); |
| | | const height = Math.max(host.value.clientHeight, 1); |
| | | renderer.setSize(width, height, false); |
| | | camera.aspect = width / height; |
| | | camera.updateProjectionMatrix(); |
| | | } |
| | | |
| | | function frame() { |
| | | if (renderer && scene && camera && controls) { |
| | | controls.update(); |
| | | renderer.render(scene, camera); |
| | | } |
| | | animationFrame = requestAnimationFrame(frame); |
| | | } |
| | | function resize() { if (!host.value || !renderer || !camera) return; const width = Math.max(host.value.clientWidth, 1); const height = Math.max(host.value.clientHeight, 1); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); } |
| | | function frame() { if (renderer && scene && camera && controls) { controls.update(); renderer.render(scene, camera); } animationFrame = requestAnimationFrame(frame); } |
| | | function modelCentre() { return model ? model.localToWorld(new THREE.Vector3()) : null; } |
| | | function modelSphere() { return model ? new THREE.Box3().setFromObject(model).getBoundingSphere(new THREE.Sphere()) : null; } |
| | | |
| | | function resetView() { |
| | | if (!camera || !controls || !model) return; |
| | | const bounds = new THREE.Box3().setFromObject(model); |
| | | const sphere = bounds.getBoundingSphere(new THREE.Sphere()); |
| | | const sphere = modelSphere(); const centre = modelCentre(); if (!sphere || !centre) return; |
| | | const radius = Math.max(sphere.radius, 0.01); |
| | | controls.target.copy(sphere.center); |
| | | camera.position.copy(sphere.center).add(new THREE.Vector3(radius * 1.2, radius * 0.8, radius * 1.65)); |
| | | camera.near = radius / 100; |
| | | camera.far = radius * 100; |
| | | camera.updateProjectionMatrix(); |
| | | controls.update(); |
| | | controls.target.copy(centre); controls.minDistance = radius * 0.08; controls.maxDistance = radius * 8; |
| | | camera.position.copy(centre).add(new THREE.Vector3(radius * 1.2, radius * 0.8, radius * 1.65)); |
| | | camera.near = Math.max(radius / 1_000, 0.01); camera.far = radius * 100; camera.updateProjectionMatrix(); controls.update(); |
| | | } |
| | | |
| | | function topDownView() { |
| | | if (!camera || !controls || !model) return; |
| | | const bounds = new THREE.Box3().setFromObject(model); |
| | | const sphere = bounds.getBoundingSphere(new THREE.Sphere()); |
| | | const radius = Math.max(sphere.radius, 0.01); |
| | | const distance = Math.max(camera.position.distanceTo(controls.target), radius * 1.5); |
| | | controls.target.copy(sphere.center); |
| | | camera.up.set(0, 1, 0); |
| | | camera.position.copy(sphere.center).add(new THREE.Vector3(0, 0, distance)); |
| | | camera.near = radius / 100; |
| | | camera.far = radius * 100; |
| | | camera.updateProjectionMatrix(); |
| | | controls.update(); |
| | | const sphere = modelSphere(); const centre = modelCentre(); if (!sphere || !centre) return; |
| | | const radius = Math.max(sphere.radius, 0.01); const distance = Math.max(camera.position.distanceTo(controls.target), radius * 1.5); |
| | | controls.target.copy(centre); camera.up.set(0, 1, 0); |
| | | camera.position.copy(centre).add(new THREE.Vector3(0, 0, 1).applyQuaternion(model.quaternion).multiplyScalar(distance)); |
| | | camera.near = Math.max(radius / 1_000, 0.01); camera.far = radius * 100; camera.updateProjectionMatrix(); controls.update(); |
| | | } |
| | | |
| | | function disposeModel() { |
| | |
| | | node.geometry.dispose(); |
| | | const materials = Array.isArray(node.material) ? node.material : [node.material]; |
| | | materials.forEach((material) => { |
| | | Object.values(material).forEach((value) => { |
| | | if (value instanceof THREE.Texture) value.dispose(); |
| | | }); |
| | | Object.values(material).forEach((value) => { if (value instanceof THREE.Texture) value.dispose(); }); |
| | | material.dispose(); |
| | | }); |
| | | }); |
| | |
| | | }); |
| | | } |
| | | |
| | | async function loadSource(source: string) { |
| | | const activeId = ++requestId; |
| | | loading.value = true; |
| | | error.value = null; |
| | | try { |
| | | const gltf = await new GLTFLoader().loadAsync(source); |
| | | if (activeId !== requestId || !scene) return; |
| | | disposeModel(); |
| | | model = gltf.scene; |
| | | preparePreviewMaterials(model); |
| | | triangleCount.value = 0; |
| | | model.traverse((node) => { |
| | | if (node instanceof THREE.Mesh) triangleCount.value += node.geometry.index ? node.geometry.index.count / 3 : node.geometry.attributes.position.count / 3; |
| | | }); |
| | | scene.add(model); |
| | | applyWireframe(); |
| | | resetView(); |
| | | } catch (caught) { |
| | | if (activeId === requestId) error.value = caught instanceof Error ? caught.message : "纹理模型无法读取。"; |
| | | } finally { |
| | | if (activeId === requestId) loading.value = false; |
| | | function applyOrientation() { if (model) model.rotation.set(THREE.MathUtils.degToRad(orientation.x), THREE.MathUtils.degToRad(orientation.y), THREE.MathUtils.degToRad(orientation.z), "XYZ"); } |
| | | function resetOrientation() { orientation.x = 0; orientation.y = 0; orientation.z = 0; } |
| | | function togglePivotPicking() { pivotPicking.value = !pivotPicking.value; } |
| | | function pickPivot(event: PointerEvent) { |
| | | if (!renderer || !camera || !model || !controls) return; |
| | | const rect = renderer.domElement.getBoundingClientRect(); |
| | | const mouse = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1); |
| | | const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mouse, camera); |
| | | const hit = raycaster.intersectObject(model, true)[0]; if (hit) controls.target.copy(hit.point); pivotPicking.value = false; |
| | | } |
| | | function handleCanvasPointerDown(event: PointerEvent) { if (!pivotPicking.value || event.button !== 0) return; event.preventDefault(); event.stopImmediatePropagation(); pickPivot(event); } |
| | | function preventMiddleAutoScroll(event: MouseEvent) { if (event.button === 1) event.preventDefault(); } |
| | | function preventContextMenu(event: MouseEvent) { event.preventDefault(); } |
| | | |
| | | async function loadSource(source: string) { |
| | | const activeId = ++requestId; loading.value = true; error.value = null; pivotPicking.value = false; resetOrientation(); |
| | | try { |
| | | const gltf = await new GLTFLoader().loadAsync(source); if (activeId !== requestId || !scene) return; |
| | | disposeModel(); |
| | | const content = gltf.scene; |
| | | const centre = new THREE.Box3().setFromObject(content).getCenter(new THREE.Vector3()); |
| | | content.position.sub(centre); |
| | | model = new THREE.Group(); model.position.copy(centre); model.add(content); |
| | | preparePreviewMaterials(content); |
| | | triangleCount.value = 0; |
| | | model.traverse((node) => { if (node instanceof THREE.Mesh) triangleCount.value += node.geometry.index ? node.geometry.index.count / 3 : node.geometry.attributes.position.count / 3; }); |
| | | scene.add(model); applyOrientation(); applyWireframe(); resetView(); |
| | | } catch (caught) { if (activeId === requestId) error.value = caught instanceof Error ? caught.message : "Mesh preview could not be loaded."; } |
| | | finally { if (activeId === requestId) loading.value = false; } |
| | | } |
| | | |
| | | onMounted(() => { |
| | | if (!host.value) return; |
| | | scene = new THREE.Scene(); |
| | | scene.background = new THREE.Color("#101821"); |
| | | scene.add(new THREE.HemisphereLight("#dce9ff", "#273137", 2.1)); |
| | | const keyLight = new THREE.DirectionalLight("#ffffff", 2.4); |
| | | keyLight.position.set(4, 6, 5); |
| | | scene.add(keyLight); |
| | | camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1_000); |
| | | renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); |
| | | renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| | | renderer.outputColorSpace = THREE.SRGBColorSpace; |
| | | host.value.appendChild(renderer.domElement); |
| | | controls = new OrbitControls(camera, renderer.domElement); |
| | | controls.enableDamping = true; |
| | | controls.dampingFactor = 0.08; |
| | | controls.screenSpacePanning = true; |
| | | resizeObserver = new ResizeObserver(resize); |
| | | resizeObserver.observe(host.value); |
| | | resize(); |
| | | frame(); |
| | | void loadSource(props.source); |
| | | scene = new THREE.Scene(); scene.background = new THREE.Color("#101821"); scene.add(new THREE.HemisphereLight("#dce9ff", "#273137", 2.1)); |
| | | const keyLight = new THREE.DirectionalLight("#ffffff", 2.4); keyLight.position.set(4, 6, 5); scene.add(keyLight); |
| | | camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1_000); renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; host.value.appendChild(renderer.domElement); |
| | | renderer.domElement.addEventListener("mousedown", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("auxclick", preventMiddleAutoScroll, { passive: false }); renderer.domElement.addEventListener("pointerdown", handleCanvasPointerDown); renderer.domElement.addEventListener("contextmenu", preventContextMenu); |
| | | controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.12; controls.rotateSpeed = 0.55; controls.zoomSpeed = 0.8; controls.panSpeed = 0.75; controls.screenSpacePanning = true; controls.minPolarAngle = 0.04; controls.maxPolarAngle = Math.PI - 0.04; controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE }; |
| | | resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host.value); resize(); frame(); void loadSource(props.source); |
| | | }); |
| | | |
| | | watch(() => props.source, (source) => { void loadSource(source); }); |
| | | watch(() => props.surface, () => { if (props.source) void loadSource(props.source); }); |
| | | watch(wireframe, applyWireframe); |
| | | watch(orientation, applyOrientation, { deep: true }); |
| | | |
| | | onBeforeUnmount(() => { |
| | | cancelAnimationFrame(animationFrame); |
| | | resizeObserver?.disconnect(); |
| | | disposeModel(); |
| | | controls?.dispose(); |
| | | renderer?.dispose(); |
| | | renderer?.domElement.remove(); |
| | | cancelAnimationFrame(animationFrame); resizeObserver?.disconnect(); renderer?.domElement.removeEventListener("mousedown", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("auxclick", preventMiddleAutoScroll); renderer?.domElement.removeEventListener("pointerdown", handleCanvasPointerDown); renderer?.domElement.removeEventListener("contextmenu", preventContextMenu); disposeModel(); controls?.dispose(); renderer?.dispose(); renderer?.domElement.remove(); |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <div class="textured-mesh-viewer"> |
| | | <div ref="host" class="canvas-host" aria-label="纹理网格交互视图" /> |
| | | <div ref="host" class="canvas-host" :class="{ 'pivot-picking': pivotPicking }" aria-label="Mesh interactive view" /> |
| | | <div class="viewer-toolbar"> |
| | | <span>{{ Math.round(triangleCount).toLocaleString() }} 面</span> |
| | | <a-checkbox v-model:checked="wireframe">线框</a-checkbox> |
| | | <a-tooltip title="俯视模型(沿本地 Z 轴)"><a-button type="text" aria-label="俯视模型" @click="topDownView"><BorderTopOutlined /></a-button></a-tooltip> |
| | | <a-popover v-model:open="orientationOpen" title="模型朝向" trigger="click"><template #content><div class="orientation-controls"><label v-for="axis in orientationAxes" :key="axis.key">{{ axis.label }}<a-slider v-model:value="orientation[axis.key]" :min="0" :max="360" :step="1" /><a-input-number v-model:value="orientation[axis.key]" :min="0" :max="360" :precision="0" addon-after="度" /></label><a-button size="small" @click="resetOrientation">重置</a-button></div></template><a-tooltip title="模型朝向"><a-button type="text" aria-label="模型朝向"><RotateRightOutlined /></a-button></a-tooltip></a-popover> |
| | | <a-tooltip title="设置旋转中心后,在模型上点击"><a-button :type="pivotPicking ? 'primary' : 'text'" aria-label="设置旋转中心" @click="togglePivotPicking"><AimOutlined /></a-button></a-tooltip> |
| | | <a-tooltip title="俯视模型"><a-button type="text" aria-label="俯视模型" @click="topDownView"><BorderTopOutlined /></a-button></a-tooltip> |
| | | <a-tooltip title="复位视角"><a-button type="text" aria-label="复位视角" @click="resetView"><ReloadOutlined /></a-button></a-tooltip> |
| | | </div> |
| | | <div v-if="loading || error" class="viewer-state" :class="{ error }">{{ error || "正在加载纹理模型..." }}</div> |
| | | <div v-if="loading || error" class="viewer-state" :class="{ error }">{{ error || "正在加载网格模型..." }}</div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .textured-mesh-viewer { position: relative; height: 500px; overflow: hidden; border: 1px solid #243342; background: #101821; } |
| | | .canvas-host { width: 100%; height: 100%; } |
| | | .canvas-host.pivot-picking { cursor: crosshair; } |
| | | .canvas-host :deep(canvas) { display: block; width: 100%; height: 100%; touch-action: none; } |
| | | .viewer-toolbar { position: absolute; right: 12px; top: 12px; display: flex; align-items: center; gap: 8px; padding: 5px 8px; color: #eef5f8; background: rgba(9, 18, 26, 0.78); } |
| | | .viewer-toolbar :deep(.ant-checkbox-wrapper) { color: #eef5f8; } |
| | | .viewer-toolbar :deep(.ant-btn) { color: #eef5f8; } |
| | | .viewer-toolbar :deep(.ant-checkbox-wrapper), .viewer-toolbar :deep(.ant-btn) { color: #eef5f8; } |
| | | .orientation-controls { display: grid; gap: 8px; min-width: 250px; } |
| | | .orientation-controls label { display: grid; grid-template-columns: 16px 1fr 84px; align-items: center; gap: 8px; } |
| | | .orientation-controls :deep(.ant-slider) { margin: 0; } |
| | | .orientation-controls :deep(.ant-input-number-group-wrapper) { width: 84px; } |
| | | .viewer-state { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; color: #eef5f8; background: rgba(16, 24, 33, 0.7); text-align: center; } |
| | | .viewer-state.error { color: #ffd7cf; } |
| | | @media (max-width: 767px) { .textured-mesh-viewer { height: 360px; } } |
| | | @media (max-width: 767px) { .textured-mesh-viewer { height: 360px; } .viewer-toolbar { left: 8px; right: 8px; flex-wrap: wrap; } } |
| | | </style> |
| | |
| | | |
| | | import { artifactUrl, createTrajectoryRun, readFileAsPayload } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import RunDeletionControl from "@/components/RunDeletionControl.vue"; |
| | | import { useArtifactStore } from "@/stores/artifacts"; |
| | | |
| | | const TrajectoryMap = defineAsyncComponent(() => import("@/components/TrajectoryMap.vue")); |
| | |
| | | const currentCase = computed(() => store.trajectoryCases[caseId.value] ?? Object.values(store.trajectoryCases)[0]); |
| | | const caseOptions = computed(() => Object.values(store.trajectoryCases).map((item) => ({ label: item.label, value: item.id }))); |
| | | const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase()))); |
| | | async function removeRun() { await store.loadTrajectory(true); caseId.value = Object.keys(store.trajectoryCases)[0] ?? ""; } |
| | | const eventNames: Record<string, string> = { normal: "正常", stop: "停留", route_deviation: "偏航", restricted_zone: "进入禁入区", gathering: "聚集" }; |
| | | const currentRules = computed(() => currentCase.value?.run.thresholds ?? {}); |
| | | const outputFiles = computed(() => { |
| | |
| | | |
| | | <div v-if="currentCase" class="trajectory-workspace"> |
| | | <a-row :gutter="[18, 18]" class="workspace-top-row"> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value">{{ item.label }}</a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="5"><section class="surface-section run-library"><h2>案例库</h2><a-input-search v-model:value="searchText" placeholder="搜索运行" allow-clear /><a-list size="small" :data-source="filteredCaseOptions"><template #renderItem="{ item }"><a-list-item class="run-item" :class="{ active: item.value === currentCase.id }" @click="caseId = item.value"><span class="run-item-label">{{ item.label }}</span><RunDeletionControl capability="15-trajectory-analysis" :run-id="item.value" :label="item.label" @removed="removeRun" /></a-list-item></template></a-list></section></a-col> |
| | | <a-col :xs="24" :xl="19"><section class="surface-section map-panel"><div class="section-toolbar"><span class="toolbar-note">{{ currentCase.note }}</span></div><TrajectoryMap :artifact-root="currentCase.artifactRoot" :show-spatial-context="currentCase.showSpatialContext" :show-flyable-zones="currentCase.showFlyableZones" /></section></a-col> |
| | | </a-row> |
| | | |
| | |
| | | .run-library .ant-input-search { margin-bottom: var(--wb-space-1); } |
| | | .run-library .ant-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; } |
| | | .run-library .ant-list-item { min-height: 34px; } |
| | | .run-item { display: block !important; padding: 7px 9px !important; color: #425148; cursor: pointer; border: 0 !important; } |
| | | .run-item { display: flex !important; align-items: center; gap: 4px; padding: 7px 9px !important; color: #425148; cursor: pointer; border: 0 !important; } |
| | | .run-item-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| | | .run-item.active { color: var(--wb-green); background: #e6f2ea; font-weight: 700; } |
| | | |
| | | /* Workspace row layout */ |
| | |
| | | export, or image sequence is copied unchanged into a raw run directory before a |
| | | console run is processed. |
| | | |
| | | The local console accepts 3-1000 JPG/JPEG photos per reconstruction run. The |
| | | same-flight and same-camera requirement still applies. CPU reconstruction is an |
| | | asynchronous job: a large sequence can take hours or longer, depending on image |
| | | resolution, overlap, and available storage; uploads and source images remain |
| | | byte-for-byte preserved. |
| | | |
| | | The console shows completed reconstruction milestones from the fixed sparse and |
| | | dense scripts, including feature extraction, matching, sparse mapping, RGB |
| | | preparation, undistortion, dense fusion, meshing, texture export, and final |
| | | artifact packaging. Its percentage is a weighted workflow-stage milestone, not |
| | | a remaining-time estimate, because COLMAP and OpenMVS do not provide reliable |
| | | live progress inside their individual native processing steps. |
| | | |
| | | The console permits this asynchronous CPU job to run for up to 24 hours before |
| | | reporting a timeout. This is a safety ceiling, not a runtime estimate. |
| | | |
| | | ```powershell |
| | | $py = .\.venvs\05-3d-pointcloud\Scripts\python.exe |
| | | & $py .\capabilities\05-3d-pointcloud\run_pointcloud_understanding.py ` |
| | |
| | | --threads 12 --max-resolution 2400 --dense-resolution-level 0 ` |
| | | --dense-number-views 8 --dense-number-views-fuse 2 --target-faces 800000 |
| | | ``` |
| | | |
| | | ### CPU and GPU compatibility |
| | | |
| | | Photo reconstruction does **not** require a GPU. The CPU route above is the |
| | | complete, verified baseline: image preparation, sparse SfM, dense MVS, meshing, |
| | | texturing, GLB export, and metadata all run locally on CPU. It is slower for |
| | | large or high-resolution photo sets, but it is the default route for any |
| | | computer and remains the console's only selectable reconstruction route. |
| | | |
| | | CUDA is an optional acceleration experiment for the OpenMVS dense depth/fusion |
| | | stage only. It does not move EXIF processing, image conversion, sparse SfM, |
| | | meshing, texturing, exports, or the local console to the GPU. Use it only with a |
| | | separately installed, compatible CUDA OpenMVS binary and an empty output |
| | | directory: |
| | | |
| | | ```powershell |
| | | & $py .\capabilities\05-3d-pointcloud\run_cpu_dense_reconstruction.py ` |
| | | --input <coherent-photo-directory> ` |
| | | --sparse-model <verified-sparse-model-directory> ` |
| | | --output <new-output-directory> ` |
| | | --openmvs-bin <cuda-openmvs-binary-directory> ` |
| | | --dense-device cuda |
| | | ``` |
| | | |
| | | CUDA is accepted only when the native OpenMVS log explicitly reports CUDA/GPU |
| | | execution; the run never silently falls back to CPU. `run_metadata.json` records |
| | | the requested and actual dense-MVS device. On this workstation, PyTorch CUDA is |
| | | available, but both tested official CUDA MVS binaries failed on the six-photo |
| | | validation set before depth-map output (OpenMVS: CUDA error 801; COLMAP: CUDA |
| | | device/context failure). Consequently CUDA MVS is not exposed in the console or |
| | | claimed as usable here. CPU MVS remains the supported path until a compatible |
| | | driver/toolchain combination or a validated server environment is available. |
| | | |
| | | The separate supervised point-cloud semantic-model training and inference flow |
| | | does support `auto`, `cpu`, and `cuda` in the local console. This applies only |
| | | to PyTorch model computation, not to photo reconstruction, point-cloud file I/O, |
| | | DSM/vector generation, or export. `auto` falls back to CPU with a recorded |
| | | reason; an explicit CUDA request fails when the fixed CUDA environment cannot |
| | | be verified. |
| | | |
| | | WebODM/ODX-compatible RTK input preparation (metadata only; it neither starts |
| | | WebODM nor reconstructs a model): |
| | |
| | | metric-accurate. Obtain registered source data and manually reviewed truth before |
| | | operational or commercial use. |
| | | |
| | | ## Photo, pose, and point-cloud multimodal preflight |
| | | |
| | | The planned `same-scene photo features + RGB/XYZ point cloud + Point Transformer |
| | | V3` route begins with a bounded, CPU-only compatibility check. It is B work: |
| | | Laspy, PyProj, OpenCV, Pillow, and NumPy verify ODM/OpenSfM camera data and |
| | | point-to-image projection. `geoai-py` has no built-in API for this workflow. |
| | | |
| | | The preflight requires a coloured LAS/LAZ with a declared CRS, original photos, |
| | | ODM/OpenSfM `cameras.json`, and `shots.geojson`. It validates exact filename |
| | | matching, normalises ODM's optional `v2` camera-name prefix, transforms every |
| | | shot GeoJSON WGS84 point into the LAS CRS, then compares it to the reported |
| | | camera-centre translation. It samples the LAS with bounded memory and projects |
| | | those points through the Brown camera model and OpenSfM Rodrigues pose. Source |
| | | photos, LAS data, and camera files remain unchanged. |
| | | |
| | | ```powershell |
| | | $py = .\.venvs\05-3d-pointcloud\Scripts\python.exe |
| | | & $py .\capabilities\05-3d-pointcloud\prepare_multimodal_pointcloud_dataset.py ` |
| | | --images .\baseData\img ` |
| | | --las .\baseData\las\Task-of-2026-08-22T084705607Z-georeferenced_model.las ` |
| | | --cameras .\baseData\camera\Task-of-2026-08-22T084705607Z-cameras.json ` |
| | | --shots .\baseData\camera\Task-of-2026-08-22T084705607Z-shots.geojson ` |
| | | --output .\shared\outputs\05-3d-pointcloud\multimodal-preflight-<new-run-id> |
| | | ``` |
| | | |
| | | The output contains `input_inventory.json`, `pose_projection_report.json`, |
| | | 12 representative `projection-overlays/*.jpg`, and `run_metadata.json`. The |
| | | acceptance gate requires no missing photo/shot pairs, a maximum WGS84-to-LAS |
| | | camera-centre XY residual at or below 0.25 m, and at least 50 sampled LAS points |
| | | inside every representative calibrated image frame. It proves data availability |
| | | only. A sparse red-point overlay is not an alignment acceptance artifact: it can |
| | | be in-frame while remaining visually unreadable or offset. |
| | | |
| | | Real preflight on 2026-08-25 used 550 original 4032 x 3024 DJI M4TD images, |
| | | the 75,870,130-point RGB `EPSG:32650` LAS export, one Brown calibration, and |
| | | 550 shots. All filenames matched. The maximum 550-shot WGS84-to-UTM camera |
| | | centre XY residual was 0.0 m. From a deterministic 120,000-point bounded LAS |
| | | sample, all 12 distributed diagnostic photos passed the in-frame check (minimum |
| | | 17,319; median 34,403 sampled points). The artefacts are in |
| | | `shared/outputs/05-3d-pointcloud/multimodal-preflight-20260825-550-photos/`. |
| | | This is not yet a feature fusion, semantic label, occlusion, or accuracy result. |
| | | |
| | | The second diagnostic uses the same pose data but renders a deterministic |
| | | one-million-point RGB LAS sample through a closest-point z-buffer. It compares |
| | | the current ODM pose convention with an inverse-rotation negative control, |
| | | checks the derived viewing axis against the DJI XMP gimbal yaw/pitch, and writes |
| | | three inspectable PNGs per representative image: point-only RGB, |
| | | photo/point-RGB blend, and point/edge overlay. It automatically detects whether |
| | | LAS RGB values are effectively 8-bit or 16-bit; LAS dimensions alone are not a |
| | | reliable colour-scale indicator. |
| | | |
| | | ```powershell |
| | | & $py .\capabilities\05-3d-pointcloud\diagnose_multimodal_projection.py ` |
| | | --images .\baseData\img ` |
| | | --las .\baseData\las\Task-of-2026-08-22T084705607Z-georeferenced_model.las ` |
| | | --cameras .\baseData\camera\Task-of-2026-08-22T084705607Z-cameras.json ` |
| | | --shots .\baseData\camera\Task-of-2026-08-22T084705607Z-shots.geojson ` |
| | | --output .\shared\outputs\05-3d-pointcloud\multimodal-projection-diagnostic-<new-run-id> |
| | | ``` |
| | | |
| | | On 2026-08-26, three distributed images passed this stronger check. The current |
| | | rotation has median RGB MAE 30.501, versus 60.612 for inverse rotation, and a |
| | | 48.5% median improvement over shuffled pixel locations. Its median DJI gimbal |
| | | view-axis difference is 0.647 degrees. The screenshot image `0450` has RGB MAE |
| | | 23.641, correlation 0.7529, and 60.53% improvement over shuffled locations; |
| | | its inverse rotation correlation is 0.0306. The result is stored at |
| | | `shared/outputs/05-3d-pointcloud/multimodal-projection-diagnostic-20260826-v2/`. |
| | | This accepts the current pose convention for photo-feature projection, but does |
| | | not claim sub-pixel calibration, complete visibility/occlusion filtering, or |
| | | semantic model accuracy. |
| | | |
| | | ## Multi-view visibility and feature fusion |
| | | |
| | | `prepare_multiview_point_features.py` is the next CPU-only data-preparation |
| | | step for the planned multimodal semantic workflow. It selects a bounded LAS |
| | | sample, projects each point into a selected set of verified ODM photos, and |
| | | uses a two-pixel closest-depth z-buffer to reject points hidden behind another |
| | | point in the same image cell. For each remaining observation it gathers eight |
| | | deterministic visual descriptors: photo RGB, hue sine/cosine, saturation, |
| | | gradient magnitude, and local-intensity standard deviation. Each point stores |
| | | the multi-view mean and standard deviation separately from its original |
| | | `XYZ + LAS RGB`, plus its valid-view count. |
| | | |
| | | ```powershell |
| | | & $py .\capabilities\05-3d-pointcloud\prepare_multiview_point_features.py ` |
| | | --images .\baseData\img ` |
| | | --las .\baseData\las\Task-of-2026-08-22T084705607Z-georeferenced_model.las ` |
| | | --cameras .\baseData\camera\Task-of-2026-08-22T084705607Z-cameras.json ` |
| | | --shots .\baseData\camera\Task-of-2026-08-22T084705607Z-shots.geojson ` |
| | | --output .\shared\outputs\05-3d-pointcloud\multiview-feature-<new-run-id> ` |
| | | --max-points 50000 --max-shots 550 --z-buffer-cell-size 2 |
| | | ``` |
| | | |
| | | The run creates `multiview-point-features.npz`, a coverage-coloured PLY, an |
| | | ordered original-RGB `multiview-annotation-source.ply`, per-photo visibility |
| | | CSV, coverage JSON, and metadata. The compressed NPZ has |
| | | `xyz`, `las_rgb`, `photo_feature_mean`, `photo_feature_stddev`, |
| | | `visible_view_count`, and ordered `feature_names` arrays. It intentionally has |
| | | no semantic class field or pseudo-label. |
| | | |
| | | The annotation PLY never uses the coverage pseudo-colours. Its vertex `i` is |
| | | the same `xyz[i]` / `las_rgb[i]` row in the NPZ, and `run_metadata.json` records |
| | | both SHA-256 values and the point count. The local console exposes it only when |
| | | the PLY header, both NPZ array lengths, declared file names, and checksums all |
| | | match. This prevents a display preview or an incomplete fusion directory from |
| | | becoming a label source. To add this source once to an older completed fusion |
| | | run without recomputing photo features: |
| | | |
| | | ```powershell |
| | | & $py .\capabilities\05-3d-pointcloud\prepare_multiview_point_features.py ` |
| | | --output .\shared\outputs\05-3d-pointcloud\multiview-feature-<completed-run-id> ` |
| | | --annotation-source-only |
| | | ``` |
| | | |
| | | Real CPU verification on 2026-08-26 processed 50,000 deterministic LAS sample |
| | | points against all 550 photos in 899.880 seconds. Every point had an occlusion- |
| | | filtered observation; all had at least two and at least three valid views, and |
| | | 99.978% had at least ten. The mean and median valid-view counts were 147.2697 |
| | | and 154 respectively, with 235 maximum. All visual-feature arrays were finite; |
| | | the feature standard-deviation mean was 0.140754 and p95 was 0.320565. The |
| | | result is in `shared/outputs/05-3d-pointcloud/multiview-feature-550-photo-sample-20260826/`. |
| | | |
| | | This validates the fusion format and observation coverage only. The z-buffer |
| | | is not a full physical visibility model, and these descriptors are not a |
| | | pretrained photo encoder. A chunked full 75,870,130-point preparation should |
| | | only start after reviewed semantic labels and the spatial training/validation/ |
| | | test split are agreed; then the deterministic descriptors can be replaced or |
| | | augmented with a GPU photo encoder before Point Transformer V3 training. |
| | | |
| | | ## Semantic point-cloud classification baseline |
| | | |
| | | The existing-point-cloud route now writes a separate CPU semantic-classification |
| | |
| | | Classes are `ground`, `vegetation`, `building_structure`, `power_line`, |
| | | `pole_tower`, and `other_unknown`. The last three names describe review outputs: |
| | | the first version is deliberately a transparent rule baseline rather than a |
| | | trained semantic model. It exports a colour-coded preview PLY (capped at 400,000 |
| | | points for the browser), semantic LAS class codes, class GeoTIFF/PNG, candidate |
| | | trained semantic model. It exports a colour-coded preview PLY containing the |
| | | complete processed point set, semantic LAS class codes, class GeoTIFF/PNG, candidate |
| | | GeoJSON, class-count CSV, and `run_metadata.json`. |
| | | |
| | | Real LAS check: the unchanged user input `baseData/las/part_01.las` contains |
| | |
| | | ## 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` |
| | | area. A semantic run writes a complete processed `*.semantic-annotation-source.ply` |
| | | that preserves observed RGB and coordinates. Rule-coloured previews are never |
| | | used as model inputs or ground truth. Brush and rectangle selections are saved |
| | | as separate revisions under `shared/outputs/05-3d-pointcloud/annotations/<annotation-id>/`. |
| | |
| | | circular point sprite. It separates navigation from annotation: browse mode |
| | | uses left-drag rotation, while brush/rectangle mode reserves left-drag for |
| | | labels and retains right-drag rotation, middle-drag panning and wheel zoom. |
| | | The console displays every point in the selected annotation source directly. |
| | | It does not replace the view with a zoom-dependent local layer, so point indices |
| | | remain stable for browsing, brush/rectangle labels, saved revisions, and |
| | | training. Large sources require correspondingly more browser memory and GPU |
| | | resources. |
| | | |
| | | ### Automatic annotation review loop |
| | | |
| | | After a completed local model is selected, **Automatic annotation current |
| | | source** runs that fixed local model against the selected source without a second |
| | | upload. It writes an immutable candidate record with the source/model SHA-256, |
| | | per-point confidence, per-class candidate counts and a user-selected confidence threshold. The console renders a separate class-coloured prediction viewer for review alongside the RGB annotation source. In that viewer, brush/rectangle review can reject a candidate or correct it to another class; its saved correction draft remains separate from human truth. **Confirm merge candidates** applies candidate labels, then the saved review draft, then existing human labels (highest priority), and creates a new |
| | | annotation revision only after explicit confirmation; existing human labels win |
| | | when they overlap model candidates. That new revision can be used for the next |
| | | training run. This is an assisted-labelling workflow, not a claim that the model |
| | | has created ground truth. The candidate preview and confidence JSON must be |
| | | reviewed before acceptance, especially for classes with weak or absent spatial |
| | | test evidence. |
| | | New annotation uploads use a separate CPU preview preparation step rather than |
| | | the DSM/footprint pipeline: it only reads XYZ and available vertex RGB, then |
| | | writes every readable finite XYZ point and available vertex RGB value. It therefore does |
| | | not create rasters, vectors, or meshes and is unaffected by a source cloud's |
| | | spatial extent. A textured-mesh PLY without readable per-vertex RGB is shown |
| | | with neutral points for geometry review, but is explicitly ineligible for the |
| | | current RGB semantic-model trainer. |
| | | |
| | | ### Textured mesh RGB baking |
| | | |
| | | Some PLY files are textured triangle meshes rather than RGB point clouds. They |
| | | store only vertex XYZ plus per-face UV coordinates and `TextureFile` image |
| | | references, so a point viewer cannot recover colour from the PLY alone. The |
| | | CPU-only `bake_textured_mesh_pointcloud.py` reads one ZIP bundle containing the |
| | | PLY and all referenced JPG/PNG texture tiles, samples each selected triangle's |
| | | UV centroid with a vertical texture-axis correction, and writes a binary |
| | | `XYZ + RGB` PLY. Its points represent sampled triangle-face centres, not the |
| | | original vertices. This is B work using NumPy/Pillow/PLY parsing, not a native |
| | | `geoai-py` point-cloud API. |
| | | |
| | | ```powershell |
| | | & .\.venvs\05-3d-pointcloud\Scripts\python.exe ` |
| | | .\capabilities\05-3d-pointcloud\bake_textured_mesh_pointcloud.py ` |
| | | --input E:\DJI\PLY\南航\PLY\Model_0.zip ` |
| | | --output .\shared\outputs\05-3d-pointcloud\texture-baked-<run-id> ` |
| | | ``` |
| | | |
| | | The representative `Model_0.zip` check on 2026-08-28 contained one 7,430,655 |
| | | vertex / 14,820,237 face PLY and 141 texture images. The 50,000-face sample |
| | | used 140 referenced texture images and completed CPU baking in 174.921 seconds. |
| | | Its RGB standard deviation was `[62.5, 58.0, 65.0]` and 80.17% of points had |
| | | materially non-grey channel differences, confirming that texture RGB rather |
| | | than placeholder grey was written. Visual UV alignment still needs user review |
| | | before a complete annotation/training source is generated. |
| | | Middle-button auto-scroll is suppressed within the viewer. The rotate-centre |
| | | tool sets the camera target to a clicked visible point without creating a label. |
| | | The model-orientation control independently rotates the displayed model about |
| | | its centre on local X/Y/Z axes from 0 through 360 degrees. It is a viewer-only |
| | | transform: source coordinates, semantic outputs and saved point indices remain |
| | | unchanged, while point picking and rectangle selection use the transformed view. |
| | | |
| | | The sparse SfM, semantic-classification, supervised-prediction, and mesh |
| | | preview panels share these display-only X/Y/Z orientation controls, a click-to- |
| | | set orbit centre, top-down view, and view reset. They do not alter source PLY, |
| | | GLB, LAS, semantic output, annotation index, or downloaded artifact bytes. |
| | | A saved revision is never edited in place. If it contains an error, the console |
| | | can remove that selected revision after confirmation; it cannot delete source |
| | | LAS/PLY data, semantic results, or training outputs. Each revision records |
| | | source run/checksum, point indices, class codes and time. |
| | | |
| | | The **Annotation, training, and model application** workspace also provides |
| | | **Add annotation source** and **Manage annotation classes**. Add source accepts |
| | | one local PLY/PCD/XYZ/LAS/LAZ through the bounded binary upload endpoint. It |
| | | preserves the exact original bytes under the point-cloud raw-data layout, then |
| | | runs a background CPU job to create a separate RGB/XYZ annotation preview; no |
| | | existing source is overwritten. The local taxonomy starts with ground, |
| | | vegetation, building, pole/tower, power line, and other/unknown. A user can add |
| | | a Chinese display name, lowercase English key, and RGB colour; the console |
| | | assigns a distinct LAS-compatible class code from 1-255. Each saved annotation |
| | | snapshots its complete class definitions. A custom class used by any saved |
| | | revision cannot be deleted, so historical labels and trained model metadata |
| | | remain interpretable. Adding a source or category creates no semantic truth by |
| | | itself: reviewed spatially distributed labels are still required for training. |
| | | |
| | | The selected annotation source also has **Remove complete data chain**. Before |
| | | the irreversible confirmation, the local server calculates and displays the |
| | | actual number of generated output directories, raw-upload copies, processed |
| | | copies, annotation revisions, dependent training results, and model-inference |
| | | results. Confirming removes those local workbench artifacts in dependency order. |
| | | It never accepts a browser-supplied filesystem path and never removes |
| | | `baseData` imagery, LAS, camera files, or any other external input. When an |
| | | older derived source has no console-owned raw copy, only its generated output |
| | | chain is removed and the confirmation says that the external input is retained. |
| | | |
| | | The model workspace also discovers a completed `multiview-feature-*` fusion |
| | | directory as `多视角照片特征融合样本` when its ordered original-RGB PLY, NPZ and |
| | | checksum contract pass. Its 50,000-point current sample is a human-review entry |
| | | until it has reviewed labels. Its automatic training entry selects |
| | | `train_multiview_point_transformer.py`, which reads only the checksum-linked |
| | | same-order NPZ and uses 23 inputs per point: normalized XYZ, LAS RGB, eight |
| | | photo-feature means, eight photo-feature standard deviations, and normalized |
| | | visible-view count. It constructs 16 local geometric neighbours and trains a |
| | | two-stage local point-attention baseline with complete XY-block train, |
| | | validation, and test separation. It writes `model.pt`, `metrics.json`, |
| | | `run_metadata.json`, and a colour-coded `predicted-semantic-preview.ply`. |
| | | |
| | | This model is explicitly **not** official Point Transformer V3 or Pointcept. |
| | | The Pointcept/PTv3 dependency stack (`spconv` and related CUDA extensions) is |
| | | not installed or validated on this machine, so the workbench does not make that |
| | | claim. The baseline is CPU-compatible and can use CUDA only after the fixed |
| | | PyTorch CUDA environment passes its probe. It cannot classify an arbitrary |
| | | uploaded point cloud because that input would lack matching camera poses and |
| | | multi-view photo descriptors. |
| | | |
| | | Training requires at least two reviewed classes with 500 points per class, and |
| | | each class must appear in training, validation, and test XY blocks. For the |
| | | current sample, label vegetation, pole/tower, and power line in multiple |
| | | separated areas before clicking `多视角特征训练`. The retained checkpoint is |
| | | selected by validation macro F1; metrics apply only to reviewed source blocks |
| | | and are not field-wide accuracy or inspection evidence. |
| | | |
| | | `train_pointcloud_semantic_model.py` trains a compact PointNet-style shared MLP |
| | | from user-confirmed labels only. It accepts `--device auto`, `cpu`, or `cuda`; |
| | |
| | | 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. |
| | | |
| | | ## Console result lifecycle |
| | | |
| | | The local console treats every console-owned point-cloud case as a complete |
| | | result chain. Before removal, it obtains a server-calculated plan and requires a |
| | | second confirmation. A removable case is limited to |
| | | `shared/outputs/05-3d-pointcloud/runs/<run-id>/` and its fixed console raw and |
| | | processed copies; `baseData`, external inputs, validation/baseline assets, and |
| | | unrelated runs are preserved. The point-cloud training-model selector separately |
| | | removes the chosen local training directory and only its discovered model |
| | | inference and automatic-annotation outputs. It preserves annotation revisions |
| | | so reviewed labels are never discarded merely because a model is removed. |
| | |
| | | from train_pointcloud_semantic_model import CLASS_SCHEMA, PointWiseNet |
| | | |
| | | |
| | | PREVIEW_POINT_LIMIT = 400_000 |
| | | |
| | | |
| | | def sha256(path: Path) -> str: |
| | | digest = hashlib.sha256() |
| | | with path.open("rb") as stream: |
| | |
| | | return xyz, np.clip(rgb, 0.0, 1.0), None |
| | | |
| | | |
| | | def preview_indices(predictions: np.ndarray, limit: int) -> np.ndarray: |
| | | """Deterministically retain every small predicted class before filling the budget.""" |
| | | count = len(predictions) |
| | | if count <= limit: |
| | | return np.arange(count, dtype=np.int64) |
| | | rng = np.random.default_rng(42) |
| | | selected: list[np.ndarray] = [] |
| | | remaining = limit |
| | | groups = sorted((np.flatnonzero(predictions == code) for code in np.unique(predictions)), key=len) |
| | | for group in groups: |
| | | keep = min(len(group), max(1, min(50_000, remaining // max(1, len(groups) - len(selected))))) |
| | | selected.append(group if keep == len(group) else np.sort(rng.choice(group, size=keep, replace=False))) |
| | | remaining -= keep |
| | | chosen = np.concatenate(selected) |
| | | if len(chosen) < limit: |
| | | mask = np.ones(count, dtype=bool) |
| | | mask[chosen] = False |
| | | fill = rng.choice(np.flatnonzero(mask), size=limit - len(chosen), replace=False) |
| | | chosen = np.concatenate((chosen, fill)) |
| | | if len(chosen) > limit: |
| | | chosen = rng.choice(chosen, size=limit, replace=False) |
| | | return np.sort(chosen.astype(np.int64)) |
| | | |
| | | |
| | | def load_model(path: Path, device: torch.device) -> tuple[PointWiseNet, list[int]]: |
| | | def load_model(path: Path, device: torch.device) -> tuple[PointWiseNet, list[int], dict[int, dict[str, Any]]]: |
| | | payload = torch.load(path, map_location=device, weights_only=False) |
| | | if not isinstance(payload, dict) or payload.get("schema_version") != 1: |
| | | if not isinstance(payload, dict) or payload.get("schema_version") not in {1, 2}: |
| | | raise ValueError("Unsupported model schema.") |
| | | codes = payload.get("class_codes") |
| | | if not isinstance(codes, list) or len(codes) < 2 or any(not isinstance(code, int) or code not in CLASS_SCHEMA for code in codes): |
| | | if not isinstance(codes, list) or len(codes) < 2 or any(not isinstance(code, int) or not 1 <= code <= 255 for code in codes): |
| | | raise ValueError("Model class schema is invalid.") |
| | | saved_classes = payload.get("classes") |
| | | classes: dict[int, dict[str, Any]] = {} |
| | | for code in codes: |
| | | raw = saved_classes.get(str(code)) if isinstance(saved_classes, dict) else CLASS_SCHEMA.get(code) |
| | | if not isinstance(raw, dict): |
| | | raise ValueError("Model is missing class metadata for a predicted class.") |
| | | key, label, color = raw.get("key"), raw.get("label"), raw.get("color") |
| | | if not isinstance(key, str) or not isinstance(label, str) or not isinstance(color, list) or len(color) != 3 or not all(isinstance(item, int) and 0 <= item <= 255 for item in color): |
| | | raise ValueError("Model class metadata is invalid.") |
| | | classes[code] = {"code": code, "key": key, "label": label, "color": color} |
| | | model = PointWiseNet(len(codes)).to(device) |
| | | try: |
| | | model.load_state_dict(payload["state_dict"], strict=True) |
| | | except (KeyError, RuntimeError) as exc: |
| | | raise ValueError("Model weights do not match the supported PointWiseNet architecture.") from exc |
| | | model.eval() |
| | | return model, codes |
| | | return model, codes, classes |
| | | |
| | | |
| | | def main() -> int: |
| | |
| | | parser.add_argument("--output", type=Path, required=True) |
| | | parser.add_argument("--device", choices={"auto", "cpu", "cuda"}, default="auto") |
| | | parser.add_argument("--batch-size", type=int, default=4096) |
| | | parser.add_argument("--annotation-source-id", type=str, default="") |
| | | parser.add_argument("--candidate-confidence", type=float, default=0.95) |
| | | args = parser.parse_args() |
| | | if args.device == "cuda" and not torch.cuda.is_available(): |
| | | raise SystemExit("CUDA was requested but is unavailable.") |
| | | if args.batch_size < 1 or args.batch_size > 262_144: |
| | | raise SystemExit("Batch size must be between 1 and 262144.") |
| | | if not 0.5 <= args.candidate_confidence < 1.0: |
| | | raise SystemExit("candidate-confidence must be between 0.5 and 1.0.") |
| | | if not args.model.is_file() or not args.input.is_file(): |
| | | raise SystemExit("Model or input point cloud is unavailable.") |
| | | if args.output.exists() and any(args.output.iterdir()): |
| | |
| | | started = time.perf_counter() |
| | | 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) |
| | | model, class_codes, class_schema = load_model(args.model, device) |
| | | xyz, rgb, source_las = load_cloud(args.input) |
| | | center = xyz.mean(axis=0) |
| | | scale = float(max(np.abs(xyz - center).max(), 1e-6)) |
| | | features = np.column_stack(((xyz - center) / scale, rgb)).astype(np.float32) |
| | | pieces: list[np.ndarray] = [] |
| | | prediction_pieces: list[np.ndarray] = [] |
| | | confidence_pieces: list[np.ndarray] = [] |
| | | with torch.no_grad(): |
| | | for start in range(0, len(features), args.batch_size): |
| | | values = torch.from_numpy(features[start:start + args.batch_size]).to(device) |
| | | pieces.append(model(values).argmax(dim=1).cpu().numpy()) |
| | | predictions = np.asarray([class_codes[index] for index in np.concatenate(pieces)], dtype=np.uint8) |
| | | probabilities = torch.softmax(model(values), dim=1) |
| | | confidence, predicted = probabilities.max(dim=1) |
| | | prediction_pieces.append(predicted.cpu().numpy()) |
| | | confidence_pieces.append(confidence.cpu().numpy()) |
| | | predictions = np.asarray([class_codes[index] for index in np.concatenate(prediction_pieces)], dtype=np.uint8) |
| | | confidence = np.concatenate(confidence_pieces).astype(np.float32, copy=False) |
| | | counts = {int(code): int((predictions == code).sum()) for code in class_codes} |
| | | |
| | | args.output.mkdir(parents=True, exist_ok=True) |
| | | preview_selection = preview_indices(predictions, PREVIEW_POINT_LIMIT) |
| | | preview_selection = np.arange(len(predictions), dtype=np.int64) |
| | | preview = o3d.geometry.PointCloud() |
| | | preview.points = o3d.utility.Vector3dVector(xyz[preview_selection]) |
| | | preview.colors = o3d.utility.Vector3dVector(np.asarray([CLASS_SCHEMA[int(code)]["color"] for code in predictions[preview_selection]], dtype=np.float64) / 255.0) |
| | | preview.colors = o3d.utility.Vector3dVector(np.asarray([class_schema[int(code)]["color"] for code in predictions[preview_selection]], dtype=np.float64) / 255.0) |
| | | preview_path = args.output / "predicted-semantic-preview.ply" |
| | | if not o3d.io.write_point_cloud(str(preview_path), preview, write_ascii=False): |
| | | raise RuntimeError("Could not write predicted PLY preview.") |
| | |
| | | writer = csv.DictWriter(stream, fieldnames=["class_code", "class_key", "class_label", "point_count"]) |
| | | writer.writeheader() |
| | | for code in class_codes: |
| | | writer.writerow({"class_code": code, "class_key": CLASS_SCHEMA[code]["key"], "class_label": CLASS_SCHEMA[code]["label"], "point_count": counts[code]}) |
| | | 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} |
| | | writer.writerow({"class_code": code, "class_key": class_schema[code]["key"], "class_label": class_schema[code]["label"], "point_count": counts[code]}) |
| | | 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": "complete prediction point set without display sampling", "input_has_rgb": True} |
| | | candidate_path: Path | None = None |
| | | if args.annotation_source_id: |
| | | selected = np.flatnonzero(confidence >= args.candidate_confidence) |
| | | candidate_counts = { |
| | | int(code): int(np.count_nonzero(predictions[selected] == code)) |
| | | for code in class_codes |
| | | } |
| | | candidate_path = args.output / "automatic-annotation-candidates.json" |
| | | candidate_path.write_text(json.dumps({ |
| | | "schema_version": 1, |
| | | "source_id": args.annotation_source_id, |
| | | "source_sha256": sha256(args.input), |
| | | "model_sha256": sha256(args.model), |
| | | "candidate_confidence": args.candidate_confidence, |
| | | "input_points": int(len(xyz)), |
| | | "candidate_count": int(len(selected)), |
| | | "candidate_class_counts": {str(code): candidate_counts[code] for code in class_codes}, |
| | | "labels": [[int(index), int(predictions[index]), round(float(confidence[index]), 6)] for index in selected], |
| | | }, ensure_ascii=False), encoding="utf-8") |
| | | summary["automatic_annotation"] = { |
| | | "candidate_confidence": args.candidate_confidence, |
| | | "candidate_count": int(len(selected)), |
| | | "candidate_class_counts": {str(code): candidate_counts[code] for code in class_codes}, |
| | | "candidate_file": candidate_path.name, |
| | | } |
| | | 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": {"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."]} |
| | | artifacts = {"preview": preview_path.name, "classified_las": classified_las.name, "class_counts": csv_path.name, "summary": summary_path.name} |
| | | if candidate_path: |
| | | artifacts["automatic_candidates"] = candidate_path.name |
| | | 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": artifacts, "elapsed_seconds": round(time.perf_counter() - started, 3), "limitations": ["Predictions are model candidates, not asset inventory or inspection conclusions.", "Automatic candidates retain only points at or above the recorded confidence threshold; user confirmation is required before merging them into a training revision.", "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 |
| New file |
| | |
| | | """Bake a textured PLY mesh in a ZIP bundle into a bounded XYZ/RGB point cloud.""" |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import hashlib |
| | | import json |
| | | import struct |
| | | import time |
| | | import zipfile |
| | | from collections import Counter |
| | | from datetime import UTC, datetime |
| | | from pathlib import Path |
| | | from typing import BinaryIO |
| | | |
| | | import numpy as np |
| | | from PIL import Image |
| | | |
| | | |
| | | def sha256(path: Path) -> str: |
| | | digest = hashlib.sha256() |
| | | with path.open("rb") as stream: |
| | | for chunk in iter(lambda: stream.read(1024 * 1024), b""): |
| | | digest.update(chunk) |
| | | return digest.hexdigest() |
| | | |
| | | |
| | | def read_header(stream: BinaryIO) -> tuple[dict[str, int], list[str]]: |
| | | lines: list[str] = [] |
| | | while len(lines) < 10_000: |
| | | raw = stream.readline(16_384) |
| | | if not raw: |
| | | raise ValueError("PLY header is incomplete.") |
| | | line = raw.decode("ascii").strip() |
| | | lines.append(line) |
| | | if line == "end_header": |
| | | break |
| | | else: |
| | | raise ValueError("PLY header exceeds its limit.") |
| | | if not lines or lines[0] != "ply" or "format binary_little_endian 1.0" not in lines: |
| | | raise ValueError("Only binary_little_endian PLY texture meshes are supported.") |
| | | counts: dict[str, int] = {} |
| | | textures: list[str] = [] |
| | | for line in lines: |
| | | parts = line.split() |
| | | if len(parts) == 3 and parts[:2] == ["element", "vertex"] and parts[2].isdigit(): |
| | | counts["vertex"] = int(parts[2]) |
| | | elif len(parts) == 3 and parts[:2] == ["element", "face"] and parts[2].isdigit(): |
| | | counts["face"] = int(parts[2]) |
| | | elif len(parts) == 3 and parts[:2] == ["comment", "TextureFile"]: |
| | | textures.append(parts[2]) |
| | | if counts.get("vertex", 0) < 3 or counts.get("face", 0) < 1 or not textures: |
| | | raise ValueError("PLY must contain vertices, faces, and TextureFile comments.") |
| | | return counts, textures |
| | | |
| | | |
| | | def read_exact(stream: BinaryIO, size: int) -> bytes: |
| | | data = stream.read(size) |
| | | if len(data) != size: |
| | | raise ValueError("PLY data is truncated.") |
| | | return data |
| | | |
| | | |
| | | def read_sampled_faces(stream: BinaryIO, face_count: int, max_points: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: |
| | | selected = np.unique(np.linspace(0, face_count - 1, min(face_count, max_points), dtype=np.int64)) |
| | | indices = np.empty((len(selected), 3), dtype=np.uint32) |
| | | uvs = np.empty((len(selected), 6), dtype=np.float32) |
| | | texture_ids = np.empty(len(selected), dtype=np.int32) |
| | | next_selected = 0 |
| | | for face_index in range(face_count): |
| | | vertex_count = read_exact(stream, 1)[0] |
| | | vertex_bytes = read_exact(stream, vertex_count * 4) |
| | | uv_count = read_exact(stream, 1)[0] |
| | | uv_bytes = read_exact(stream, uv_count * 4) |
| | | texture_id = struct.unpack("<i", read_exact(stream, 4))[0] |
| | | if next_selected >= len(selected) or face_index != selected[next_selected]: |
| | | continue |
| | | if vertex_count != 3 or uv_count != 6: |
| | | raise ValueError("Only triangular textured PLY faces with six UV values are supported.") |
| | | indices[next_selected] = np.frombuffer(vertex_bytes, dtype="<u4", count=3) |
| | | uvs[next_selected] = np.frombuffer(uv_bytes, dtype="<f4", count=6) |
| | | texture_ids[next_selected] = texture_id |
| | | next_selected += 1 |
| | | return indices, uvs, texture_ids |
| | | |
| | | |
| | | def texture_entry_map(archive: zipfile.ZipFile, ply_entry: str) -> dict[str, zipfile.ZipInfo]: |
| | | parent = ply_entry.rsplit("/", 1)[0] if "/" in ply_entry else "" |
| | | result: dict[str, zipfile.ZipInfo] = {} |
| | | for entry in archive.infolist(): |
| | | if entry.is_dir() or Path(entry.filename).suffix.lower() not in {".jpg", ".jpeg", ".png"}: |
| | | continue |
| | | relative = entry.filename[len(parent) + 1:] if parent and entry.filename.startswith(f"{parent}/") else entry.filename |
| | | result[relative] = entry |
| | | return result |
| | | |
| | | |
| | | def sample_texture_rgb(archive: zipfile.ZipFile, entry: zipfile.ZipInfo, uv: np.ndarray) -> np.ndarray: |
| | | with archive.open(entry) as source, Image.open(source) as image: |
| | | image = image.convert("RGB") |
| | | width, height = image.size |
| | | pixels = np.asarray(image) |
| | | u = np.clip(uv[:, 0], 0.0, 1.0) |
| | | v = np.clip(uv[:, 1], 0.0, 1.0) |
| | | x = np.rint(u * (width - 1)).astype(np.int64) |
| | | y = np.rint((1.0 - v) * (height - 1)).astype(np.int64) |
| | | return pixels[y, x] |
| | | |
| | | |
| | | def write_ply(path: Path, xyz: np.ndarray, rgb: np.ndarray) -> None: |
| | | header = ( |
| | | "ply\nformat binary_little_endian 1.0\n" |
| | | f"element vertex {len(xyz)}\n" |
| | | "property float x\nproperty float y\nproperty float z\n" |
| | | "property uchar red\nproperty uchar green\nproperty uchar blue\n" |
| | | "end_header\n" |
| | | ).encode("ascii") |
| | | values = np.empty(len(xyz), dtype=np.dtype([ |
| | | ("x", "<f4"), ("y", "<f4"), ("z", "<f4"), |
| | | ("red", "u1"), ("green", "u1"), ("blue", "u1"), |
| | | ])) |
| | | values["x"], values["y"], values["z"] = xyz.astype(np.float32, copy=False).T |
| | | values["red"], values["green"], values["blue"] = rgb.T |
| | | with path.open("wb") as stream: |
| | | stream.write(header) |
| | | stream.write(values.tobytes()) |
| | | |
| | | |
| | | def bake(zip_path: Path, output: Path) -> dict[str, object]: |
| | | if not zip_path.is_file() or zip_path.suffix.lower() != ".zip": |
| | | raise ValueError("Input must be an available ZIP texture-mesh bundle.") |
| | | if output.exists() and any(output.iterdir()): |
| | | raise ValueError("Output directory is not empty; use a new output directory.") |
| | | output.mkdir(parents=True, exist_ok=True) |
| | | started = time.perf_counter() |
| | | with zipfile.ZipFile(zip_path) as archive: |
| | | ply_entries = [entry.filename for entry in archive.infolist() if not entry.is_dir() and Path(entry.filename).suffix.lower() == ".ply"] |
| | | if len(ply_entries) != 1: |
| | | raise ValueError("Texture-mesh ZIP must contain exactly one PLY file.") |
| | | ply_entry = ply_entries[0] |
| | | with archive.open(ply_entry) as stream: |
| | | counts, texture_names = read_header(stream) |
| | | vertex_bytes = read_exact(stream, counts["vertex"] * 12) |
| | | vertices = np.frombuffer(vertex_bytes, dtype=np.dtype([("x", "<f4"), ("y", "<f4"), ("z", "<f4")])).copy() |
| | | xyz = np.column_stack((vertices["x"], vertices["y"], vertices["z"])).astype(np.float32, copy=False) |
| | | face_indices, face_uvs, texture_ids = read_sampled_faces(stream, counts["face"], counts["face"]) |
| | | if np.any(face_indices >= len(xyz)): |
| | | raise ValueError("PLY face references a vertex outside the vertex array.") |
| | | if np.any(texture_ids < 0) or np.any(texture_ids >= len(texture_names)): |
| | | raise ValueError("PLY face references an unavailable TextureFile index.") |
| | | entries = texture_entry_map(archive, ply_entry) |
| | | requested = [texture_names[int(index)] for index in np.unique(texture_ids)] |
| | | missing = sorted(name for name in requested if name not in entries) |
| | | if missing: |
| | | raise ValueError(f"Texture bundle is missing {len(missing)} referenced image(s), for example: {missing[0]}") |
| | | points = xyz[face_indices].mean(axis=1) |
| | | uv_centres = face_uvs.reshape((-1, 3, 2)).mean(axis=1) |
| | | colors = np.empty((len(points), 3), dtype=np.uint8) |
| | | for texture_id in np.unique(texture_ids): |
| | | mask = texture_ids == texture_id |
| | | colors[mask] = sample_texture_rgb(archive, entries[texture_names[int(texture_id)]], uv_centres[mask]) |
| | | preview_path = output / "texture-baked-annotation-source.ply" |
| | | write_ply(preview_path, points, colors) |
| | | metadata = { |
| | | "capability": "05-3d-pointcloud", |
| | | "classification": "B", |
| | | "created_at": datetime.now(UTC).isoformat(), |
| | | "annotation_source_job": True, |
| | | "annotation_source": { |
| | | "schema_version": 1, |
| | | "file": preview_path.name, |
| | | "point_count": int(len(points)), |
| | | "sha256": sha256(preview_path), |
| | | "kind": "complete textured-mesh face-centre RGB/XYZ point cloud baked from PLY UV texture bundle", |
| | | }, |
| | | "input": {"file": zip_path.name, "bytes": zip_path.stat().st_size, "has_rgb": True, "mesh_vertices": int(len(xyz)), "mesh_faces": int(counts["face"])}, |
| | | "texture_bake": {"ply_entry": ply_entry, "texture_files_declared": len(texture_names), "texture_files_used": len(requested), "sampled_faces": int(len(points)), "sampling": "every triangular face contributes one face-centre point; UV centroid sampled with V-axis flip"}, |
| | | "method": "PLY face UV/texture-index sampling with Pillow, NumPy, and binary PLY RGB export", |
| | | "device": "CPU", |
| | | "elapsed_seconds": round(time.perf_counter() - started, 3), |
| | | "limitations": [ |
| | | "Each output point is a triangle-face centre, not an original mesh vertex.", |
| | | "UV seams, occluded texture regions, colour exposure differences, and texture projection errors can remain in the baked RGB.", |
| | | "This prepares RGB features for manual annotation/training; it does not create ground-truth semantic labels or prove classifier accuracy.", |
| | | "Every mesh face is represented, so large meshes require substantial browser memory and GPU resources for direct display.", |
| | | ], |
| | | } |
| | | (output / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return metadata |
| | | |
| | | |
| | | def main() -> int: |
| | | parser = argparse.ArgumentParser(description=__doc__) |
| | | parser.add_argument("--input", type=Path, required=True, help="ZIP containing one textured PLY and its image tiles") |
| | | parser.add_argument("--output", type=Path, required=True) |
| | | args = parser.parse_args() |
| | | try: |
| | | print(json.dumps(bake(args.input, args.output), ensure_ascii=False, indent=2)) |
| | | return 0 |
| | | except (OSError, ValueError, zipfile.BadZipFile) as exc: |
| | | print(str(exc), file=__import__("sys").stderr) |
| | | return 2 |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | raise SystemExit(main()) |
| New file |
| | |
| | | """Assess photo-to-LAS point projection with visual and numeric evidence. |
| | | |
| | | The input ``shots.geojson`` is an ODM report export. Its translation is a |
| | | georeferenced camera centre, while the rotation convention must be checked |
| | | against the photo's DJI gimbal metadata and point-cloud RGB before it is used |
| | | for multimodal learning. |
| | | """ |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import json |
| | | import re |
| | | import time |
| | | from datetime import UTC, datetime |
| | | from pathlib import Path |
| | | from typing import Any |
| | | |
| | | import cv2 |
| | | import numpy as np |
| | | from PIL import Image |
| | | |
| | | from prepare_multimodal_pointcloud_dataset import ( |
| | | Shot, |
| | | load_calibrations, |
| | | load_shots, |
| | | project_points, |
| | | sample_las, |
| | | selected_shots, |
| | | ) |
| | | |
| | | |
| | | GIMBAL_YAW = re.compile(r'drone-dji:GimbalYawDegree="([+-]?\d+(?:\.\d+)?)"') |
| | | GIMBAL_PITCH = re.compile(r'drone-dji:GimbalPitchDegree="([+-]?\d+(?:\.\d+)?)"') |
| | | |
| | | |
| | | def optical_axis_world(shot: Shot, inverse_rotation: bool = False) -> np.ndarray: |
| | | rotation, _ = cv2.Rodrigues(shot.rotation.reshape(3, 1)) |
| | | direction = (rotation if inverse_rotation else rotation.T) @ np.array([0.0, 0.0, 1.0]) |
| | | return direction / np.linalg.norm(direction) |
| | | |
| | | |
| | | def dji_gimbal_axis(image_path: Path) -> np.ndarray | None: |
| | | data = image_path.read_bytes() |
| | | start, end = data.find(b"<x:xmpmeta"), data.find(b"</x:xmpmeta>") |
| | | if start < 0 or end < 0: |
| | | return None |
| | | text = data[start:end + len(b"</x:xmpmeta>")].decode("utf-8", "ignore") |
| | | yaw, pitch = GIMBAL_YAW.search(text), GIMBAL_PITCH.search(text) |
| | | if not yaw or not pitch: |
| | | return None |
| | | yaw_radians = np.deg2rad(float(yaw.group(1))) |
| | | pitch_radians = np.deg2rad(float(pitch.group(1))) |
| | | axis = np.array([ |
| | | np.cos(pitch_radians) * np.sin(yaw_radians), |
| | | np.cos(pitch_radians) * np.cos(yaw_radians), |
| | | np.sin(pitch_radians), |
| | | ]) |
| | | return axis / np.linalg.norm(axis) |
| | | |
| | | |
| | | def project_with_rotation(points: np.ndarray, shot: Shot, inverse_rotation: bool) -> tuple[np.ndarray, np.ndarray, np.ndarray]: |
| | | if not inverse_rotation: |
| | | pixels, inside = project_points(points, shot) |
| | | rotation, _ = cv2.Rodrigues(shot.rotation.reshape(3, 1)) |
| | | depth = (rotation @ (points - shot.centre).T).T[:, 2] |
| | | return pixels, inside, depth |
| | | rotation, _ = cv2.Rodrigues((-shot.rotation).reshape(3, 1)) |
| | | camera_points = (rotation @ (points - shot.centre).T).T |
| | | depth = camera_points[:, 2] |
| | | pixels = np.full((len(points), 2), np.nan, dtype=np.float64) |
| | | forward = depth > 0.05 |
| | | if forward.any(): |
| | | normalised = camera_points[forward, :2] / depth[forward, None] |
| | | x, y = normalised.T |
| | | c = shot.calibration |
| | | r2 = x * x + y * y |
| | | radial = 1.0 + c.k1 * r2 + c.k2 * r2 * r2 + c.k3 * r2 * r2 * r2 |
| | | xd = x * radial + 2.0 * c.p1 * x * y + c.p2 * (r2 + 2.0 * x * x) |
| | | yd = y * radial + c.p1 * (r2 + 2.0 * y * y) + 2.0 * c.p2 * x * y |
| | | scale = float(max(c.width, c.height)) |
| | | pixels[forward, 0] = c.width / 2.0 + scale * (c.focal_x * xd + c.c_x) |
| | | pixels[forward, 1] = c.height / 2.0 + scale * (c.focal_y * yd + c.c_y) |
| | | inside = forward & (pixels[:, 0] >= 0) & (pixels[:, 0] < shot.calibration.width) & (pixels[:, 1] >= 0) & (pixels[:, 1] < shot.calibration.height) |
| | | return pixels, inside, depth |
| | | |
| | | |
| | | def z_buffer_render(pixels: np.ndarray, depths: np.ndarray, inside: np.ndarray, colors: np.ndarray, width: int, height: int) -> tuple[np.ndarray, np.ndarray]: |
| | | """Return closest-point RGB and coverage mask in photo pixel coordinates.""" |
| | | coordinates = np.rint(pixels[inside]).astype(np.int64) |
| | | coordinates[:, 0] = np.clip(coordinates[:, 0], 0, width - 1) |
| | | coordinates[:, 1] = np.clip(coordinates[:, 1], 0, height - 1) |
| | | selected_depth = depths[inside] |
| | | selected_colors = np.clip(colors[inside] * 255.0, 0, 255).astype(np.uint8) |
| | | flat = coordinates[:, 1] * width + coordinates[:, 0] |
| | | # First per flat pixel after sorting by flat then depth is the visible point. |
| | | order = np.lexsort((selected_depth, flat)) |
| | | sorted_flat = flat[order] |
| | | first = np.r_[True, sorted_flat[1:] != sorted_flat[:-1]] |
| | | order = order[first] |
| | | render = np.zeros((height, width, 3), dtype=np.uint8) |
| | | mask = np.zeros((height, width), dtype=np.uint8) |
| | | render[coordinates[order, 1], coordinates[order, 0]] = selected_colors[order] |
| | | mask[coordinates[order, 1], coordinates[order, 0]] = 255 |
| | | return render, mask |
| | | |
| | | |
| | | def colour_agreement(photo: np.ndarray, render: np.ndarray, mask: np.ndarray, seed: int = 20260826) -> dict[str, float | int]: |
| | | valid = mask.astype(bool) |
| | | observed = photo[valid].astype(np.float32) |
| | | projected = render[valid].astype(np.float32) |
| | | direct_mae = float(np.abs(observed - projected).mean()) |
| | | rng = np.random.default_rng(seed) |
| | | shuffled_mae = float(np.abs(observed[rng.permutation(len(observed))] - projected).mean()) |
| | | correlation = float(np.corrcoef(observed.reshape(-1), projected.reshape(-1))[0, 1]) |
| | | return { |
| | | "visible_pixel_count": int(valid.sum()), |
| | | "rgb_mean_absolute_error": round(direct_mae, 3), |
| | | "shuffled_rgb_mean_absolute_error": round(shuffled_mae, 3), |
| | | "mae_improvement_over_shuffled_percent": round(100.0 * (shuffled_mae - direct_mae) / max(shuffled_mae, 1e-6), 2), |
| | | "rgb_correlation": round(correlation, 4), |
| | | } |
| | | |
| | | |
| | | def write_visuals(photo: np.ndarray, render: np.ndarray, mask: np.ndarray, output_stem: Path) -> dict[str, str]: |
| | | # A 3 x 3 splat makes isolated points inspectable; it remains a point render, |
| | | # not an invented texture or resampled photo. |
| | | splat_mask = cv2.dilate(mask, np.ones((3, 3), dtype=np.uint8), iterations=1).astype(bool) |
| | | splat = cv2.dilate(render, np.ones((3, 3), dtype=np.uint8), iterations=1) |
| | | blend = photo.copy() |
| | | blend[splat_mask] = np.round(0.45 * photo[splat_mask] + 0.55 * splat[splat_mask]).astype(np.uint8) |
| | | edge = cv2.Canny(cv2.cvtColor(photo, cv2.COLOR_RGB2GRAY), 70, 150) |
| | | overlay = photo.copy() |
| | | overlay[splat_mask] = np.array([255, 45, 45], dtype=np.uint8) |
| | | overlay[edge > 0] = np.round(0.58 * overlay[edge > 0] + 0.42 * np.array([255, 255, 255])).astype(np.uint8) |
| | | output_stem.parent.mkdir(parents=True, exist_ok=True) |
| | | rgb_path = output_stem.with_suffix(".point-rgb.png") |
| | | blend_path = output_stem.with_suffix(".point-rgb-blend.png") |
| | | overlay_path = output_stem.with_suffix(".edge-overlay.png") |
| | | Image.fromarray(render).save(rgb_path) |
| | | Image.fromarray(blend).save(blend_path) |
| | | Image.fromarray(overlay).save(overlay_path) |
| | | return {"point_rgb": rgb_path.name, "blend": blend_path.name, "edge_overlay": overlay_path.name} |
| | | |
| | | |
| | | def diagnose(images: Path, las: Path, cameras: Path, shots_path: Path, output: Path, sample_points: int, diagnostic_shots: int) -> dict[str, Any]: |
| | | if output.exists() and any(output.iterdir()): |
| | | raise ValueError("Output directory must be new or empty.") |
| | | output.mkdir(parents=True, exist_ok=True) |
| | | calibrations = load_calibrations(cameras) |
| | | shots = load_shots(shots_path, calibrations) |
| | | points, colors, sampling = sample_las(las, sample_points) |
| | | records: list[dict[str, Any]] = [] |
| | | for shot in selected_shots(shots, diagnostic_shots): |
| | | image_path = images / shot.filename |
| | | with Image.open(image_path) as source: |
| | | photo = np.asarray(source.convert("RGB")) |
| | | pixels, inside, depths = project_with_rotation(points, shot, inverse_rotation=False) |
| | | render, mask = z_buffer_render(pixels, depths, inside, colors, shot.calibration.width, shot.calibration.height) |
| | | current_score = colour_agreement(photo, render, mask) |
| | | inverse_pixels, inverse_inside, inverse_depths = project_with_rotation(points, shot, inverse_rotation=True) |
| | | inverse_render, inverse_mask = z_buffer_render(inverse_pixels, inverse_depths, inverse_inside, colors, shot.calibration.width, shot.calibration.height) |
| | | inverse_score = colour_agreement(photo, inverse_render, inverse_mask) |
| | | expected_axis = dji_gimbal_axis(image_path) |
| | | derived_axis = optical_axis_world(shot) |
| | | direction_error = None if expected_axis is None else round(float(np.degrees(np.arccos(np.clip(np.dot(expected_axis, derived_axis), -1.0, 1.0)))), 3) |
| | | visuals = write_visuals(photo, render, mask, output / Path(shot.filename).stem) |
| | | records.append({ |
| | | "filename": shot.filename, |
| | | "inside_sample_points": int(inside.sum()), |
| | | "current_rotation": current_score, |
| | | "inverse_rotation": inverse_score, |
| | | "current_rotation_gimbal_axis_error_degrees": direction_error, |
| | | "visuals": visuals, |
| | | }) |
| | | improvements = [item["current_rotation"]["mae_improvement_over_shuffled_percent"] for item in records] |
| | | current_mae = [item["current_rotation"]["rgb_mean_absolute_error"] for item in records] |
| | | inverse_mae = [item["inverse_rotation"]["rgb_mean_absolute_error"] for item in records] |
| | | axis_errors = [item["current_rotation_gimbal_axis_error_degrees"] for item in records if item["current_rotation_gimbal_axis_error_degrees"] is not None] |
| | | result = { |
| | | "created_at": datetime.now(UTC).isoformat(), |
| | | "method": "ODM shot-rotation convention is checked through DJI gimbal optical axes and by photo/RGB-LAS colour agreement; the inverse rotation is a negative control.", |
| | | "sample": sampling, |
| | | "shots": records, |
| | | "summary": { |
| | | "median_current_rgb_mae": round(float(np.median(current_mae)), 3), |
| | | "median_inverse_rgb_mae": round(float(np.median(inverse_mae)), 3), |
| | | "median_current_improvement_over_shuffled_percent": round(float(np.median(improvements)), 2), |
| | | "median_current_gimbal_axis_error_degrees": None if not axis_errors else round(float(np.median(axis_errors)), 3), |
| | | "accepted": bool(np.median(improvements) >= 5.0 and np.median(current_mae) < np.median(inverse_mae) and (not axis_errors or np.median(axis_errors) <= 5.0)), |
| | | "acceptance": "Current pose must improve photo-versus-point RGB agreement over shuffled locations by at least 5%, beat inverse rotation median error, and agree with available DJI gimbal viewing axes within 5 degrees. Passing still does not establish sub-pixel calibration or occlusion correctness.", |
| | | }, |
| | | } |
| | | (output / "projection_diagnostic.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return result |
| | | |
| | | |
| | | def main() -> int: |
| | | parser = argparse.ArgumentParser(description="Generate decisive ODM shot/LAS projection diagnostics.") |
| | | parser.add_argument("--images", type=Path, required=True) |
| | | parser.add_argument("--las", type=Path, required=True) |
| | | parser.add_argument("--cameras", type=Path, required=True) |
| | | parser.add_argument("--shots", type=Path, required=True) |
| | | parser.add_argument("--output", type=Path, required=True) |
| | | parser.add_argument("--sample-points", type=int, default=1_000_000) |
| | | parser.add_argument("--diagnostic-shots", type=int, default=3) |
| | | args = parser.parse_args() |
| | | started = time.perf_counter() |
| | | result = diagnose(args.images, args.las, args.cameras, args.shots, args.output, args.sample_points, args.diagnostic_shots) |
| | | result["elapsed_seconds"] = round(time.perf_counter() - started, 3) |
| | | (args.output / "projection_diagnostic.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | print(json.dumps(result["summary"], ensure_ascii=False, indent=2)) |
| | | return 0 |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | raise SystemExit(main()) |
| New file |
| | |
| | | """Export a bounded RGB point-cloud detail window as binary PLY for the local console.""" |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import sys |
| | | from pathlib import Path |
| | | |
| | | import laspy |
| | | import numpy as np |
| | | |
| | | |
| | | MAX_POINTS = 1_200_000 |
| | | |
| | | |
| | | def parse_args() -> argparse.Namespace: |
| | | parser = argparse.ArgumentParser(description=__doc__) |
| | | parser.add_argument("--input", type=Path, required=True) |
| | | parser.add_argument("--center", nargs=3, type=float, required=True, metavar=("X", "Y", "Z")) |
| | | parser.add_argument("--radius", type=float, required=True) |
| | | parser.add_argument("--max-points", type=int, default=MAX_POINTS) |
| | | return parser.parse_args() |
| | | |
| | | |
| | | def read_binary_rgb_ply(path: Path) -> tuple[np.ndarray, np.ndarray]: |
| | | """Read the fixed XYZ/RGB binary PLY contract emitted by local preview jobs.""" |
| | | with path.open("rb") as stream: |
| | | head = stream.read(65_536) |
| | | marker = b"end_header\n" |
| | | header_end = head.find(marker) |
| | | if header_end < 0: |
| | | marker = b"end_header\r\n" |
| | | header_end = head.find(marker) |
| | | if header_end < 0: |
| | | raise ValueError("Detail PLY header exceeds its limit or is incomplete.") |
| | | header = head[:header_end + len(marker)] |
| | | try: |
| | | lines = header.decode("ascii").splitlines() |
| | | except UnicodeDecodeError as exc: |
| | | raise ValueError("Detail PLY header is not ASCII.") from exc |
| | | if "format binary_little_endian 1.0" not in lines: |
| | | raise ValueError("Detail PLY must be binary_little_endian.") |
| | | count_line = next((line for line in lines if line.startswith("element vertex ")), None) |
| | | if not count_line or not count_line.split()[-1].isdigit(): |
| | | raise ValueError("Detail PLY vertex count is invalid.") |
| | | count = int(count_line.split()[-1]) |
| | | expected_properties = [ |
| | | "property float x", "property float y", "property float z", |
| | | "property uchar red", "property uchar green", "property uchar blue", |
| | | ] |
| | | if any(property_name not in lines for property_name in expected_properties): |
| | | raise ValueError("Detail PLY needs float XYZ and uchar RGB properties.") |
| | | dtype = np.dtype([("x", "<f4"), ("y", "<f4"), ("z", "<f4"), ("red", "u1"), ("green", "u1"), ("blue", "u1")]) |
| | | if path.stat().st_size < len(header) + count * dtype.itemsize: |
| | | raise ValueError("Detail PLY data is truncated.") |
| | | records = np.memmap(path, dtype=dtype, mode="r", offset=len(header), shape=(count,)) |
| | | xyz = np.column_stack((records["x"], records["y"], records["z"])).astype(np.float32, copy=False) |
| | | rgb = np.column_stack((records["red"], records["green"], records["blue"])).astype(np.uint8, copy=False) |
| | | return xyz, rgb |
| | | |
| | | |
| | | def export_detail(input_path: Path, center: np.ndarray, radius: float, max_points: int) -> bytes: |
| | | if input_path.suffix.lower() not in {".las", ".laz", ".ply"}: |
| | | raise ValueError("Detail source must be LAS, LAZ, or the local binary RGB PLY contract.") |
| | | if not input_path.is_file(): |
| | | raise ValueError("Detail source is unavailable.") |
| | | if not np.isfinite(center).all() or not np.isfinite(radius) or radius <= 0: |
| | | raise ValueError("Detail window is invalid.") |
| | | if not 1 <= max_points <= MAX_POINTS: |
| | | raise ValueError(f"max-points must be between 1 and {MAX_POINTS}.") |
| | | |
| | | if input_path.suffix.lower() in {".las", ".laz"}: |
| | | cloud = laspy.read(input_path) |
| | | dimensions = set(cloud.point_format.dimension_names) |
| | | if not {"red", "green", "blue"}.issubset(dimensions): |
| | | raise ValueError("Detail source has no RGB attributes.") |
| | | xyz = np.column_stack((cloud.x, cloud.y, cloud.z)).astype(np.float32, copy=False) |
| | | rgb_source = np.column_stack((cloud.red, cloud.green, cloud.blue)).astype(np.float64, copy=False) |
| | | divisor = 65_535.0 if np.nanmax(rgb_source, initial=0.0) > 255.0 else 255.0 |
| | | else: |
| | | xyz, rgb_source = read_binary_rgb_ply(input_path) |
| | | divisor = 1.0 |
| | | inside = np.all(np.abs(xyz - center.astype(np.float32)) <= radius, axis=1) |
| | | indices = np.flatnonzero(inside) |
| | | if not len(indices): |
| | | raise ValueError("No point falls inside the current detail window.") |
| | | if len(indices) > max_points: |
| | | # Evenly spaced original LAS rows are deterministic and avoid a random visual jump. |
| | | indices = indices[np.linspace(0, len(indices) - 1, max_points, dtype=np.int64)] |
| | | xyz = xyz[indices] |
| | | rgb = np.clip(np.rint(rgb_source[indices] / divisor * 255.0), 0, 255).astype(np.uint8) |
| | | |
| | | header = ( |
| | | "ply\nformat binary_little_endian 1.0\n" |
| | | f"element vertex {len(xyz)}\n" |
| | | "property float x\nproperty float y\nproperty float z\n" |
| | | "property uchar red\nproperty uchar green\nproperty uchar blue\n" |
| | | "end_header\n" |
| | | ).encode("ascii") |
| | | vertices = np.empty(len(xyz), dtype=np.dtype([ |
| | | ("x", "<f4"), ("y", "<f4"), ("z", "<f4"), |
| | | ("red", "u1"), ("green", "u1"), ("blue", "u1"), |
| | | ])) |
| | | vertices["x"], vertices["y"], vertices["z"] = xyz.T |
| | | vertices["red"], vertices["green"], vertices["blue"] = rgb.T |
| | | return header + vertices.tobytes() |
| | | |
| | | |
| | | def main() -> int: |
| | | args = parse_args() |
| | | try: |
| | | sys.stdout.buffer.write(export_detail(args.input, np.asarray(args.center, dtype=np.float64), args.radius, args.max_points)) |
| | | return 0 |
| | | except (OSError, ValueError, laspy.LaspyException) as exc: |
| | | print(str(exc), file=sys.stderr) |
| | | return 2 |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | raise SystemExit(main()) |
| New file |
| | |
| | | """Prepare a bounded RGB/XYZ PLY preview for manual point-cloud annotation. |
| | | |
| | | This deliberately does not create raster, vector, mesh, or semantic outputs. |
| | | Those products require a spatial cell size and are unrelated to a manual |
| | | annotation source. |
| | | """ |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import hashlib |
| | | import json |
| | | import time |
| | | from datetime import UTC, datetime |
| | | from pathlib import Path |
| | | |
| | | import laspy |
| | | import numpy as np |
| | | import open3d as o3d |
| | | |
| | | |
| | | SUPPORTED_SUFFIXES = {".ply", ".pcd", ".xyz", ".xyzn", ".xyzrgb", ".las", ".laz"} |
| | | |
| | | |
| | | def file_sha256(path: Path) -> str: |
| | | digest = hashlib.sha256() |
| | | with path.open("rb") as stream: |
| | | for chunk in iter(lambda: stream.read(1024 * 1024), b""): |
| | | digest.update(chunk) |
| | | return digest.hexdigest() |
| | | |
| | | |
| | | def read_cloud(path: Path) -> tuple[np.ndarray, np.ndarray, bool]: |
| | | if path.suffix.lower() in {".las", ".laz"}: |
| | | source = laspy.read(path) |
| | | dimensions = set(source.point_format.dimension_names) |
| | | xyz = np.column_stack((source.x, source.y, source.z)).astype(np.float64) |
| | | has_rgb = {"red", "green", "blue"}.issubset(dimensions) |
| | | if has_rgb: |
| | | raw_rgb = np.column_stack((source.red, source.green, source.blue)).astype(np.float64) |
| | | divisor = 65_535.0 if float(np.nanpercentile(raw_rgb, 99.5)) > 255.0 else 255.0 |
| | | rgb = np.clip(raw_rgb / divisor, 0.0, 1.0) |
| | | else: |
| | | rgb = np.full((len(xyz), 3), 0.72, dtype=np.float64) |
| | | else: |
| | | cloud = o3d.io.read_point_cloud(str(path)) |
| | | if cloud.is_empty(): |
| | | raise ValueError("Point cloud has no readable XYZ vertices.") |
| | | xyz = np.asarray(cloud.points, dtype=np.float64) |
| | | has_rgb = cloud.has_colors() |
| | | rgb = np.asarray(cloud.colors, dtype=np.float64) if has_rgb else np.full((len(xyz), 3), 0.72, dtype=np.float64) |
| | | valid = np.isfinite(xyz).all(axis=1) & np.isfinite(rgb).all(axis=1) |
| | | xyz, rgb = xyz[valid], np.clip(rgb[valid], 0.0, 1.0) |
| | | if len(xyz) < 50: |
| | | raise ValueError("Point cloud needs at least 50 finite RGB/XYZ points.") |
| | | return xyz, rgb, has_rgb |
| | | |
| | | |
| | | def main() -> int: |
| | | parser = argparse.ArgumentParser(description=__doc__) |
| | | parser.add_argument("--input", type=Path, required=True) |
| | | parser.add_argument("--output", type=Path, required=True) |
| | | args = parser.parse_args() |
| | | if args.input.suffix.lower() not in SUPPORTED_SUFFIXES: |
| | | raise SystemExit(f"Unsupported point-cloud type: {args.input.suffix or '(none)'}.") |
| | | if not args.input.is_file(): |
| | | raise SystemExit("Input point-cloud file is unavailable.") |
| | | if args.output.exists() and any(args.output.iterdir()): |
| | | raise SystemExit("Output directory is not empty; use a new annotation-source run directory.") |
| | | args.output.mkdir(parents=True, exist_ok=True) |
| | | |
| | | started = time.perf_counter() |
| | | xyz, rgb, has_rgb = read_cloud(args.input) |
| | | preview = o3d.geometry.PointCloud() |
| | | preview.points = o3d.utility.Vector3dVector(xyz) |
| | | preview.colors = o3d.utility.Vector3dVector(rgb) |
| | | preview_name = f"{args.input.stem}.annotation-source.ply" |
| | | preview_path = args.output / preview_name |
| | | if not o3d.io.write_point_cloud(str(preview_path), preview, write_ascii=False): |
| | | raise SystemExit("Could not write annotation preview PLY.") |
| | | metadata = { |
| | | "capability": "05-3d-pointcloud", |
| | | "classification": "B", |
| | | "created_at": datetime.now(UTC).isoformat(), |
| | | "annotation_source_job": True, |
| | | "annotation_source": { |
| | | "schema_version": 1, |
| | | "file": preview_name, |
| | | "point_count": int(len(xyz)), |
| | | "sha256": file_sha256(preview_path), |
| | | "kind": "complete readable RGB/XYZ point cloud; no raster, vector, mesh, or semantic labels", |
| | | }, |
| | | "input": {"file": args.input.name, "points": int(len(xyz)), "has_rgb": has_rgb}, |
| | | "method": "Open3D/Laspy complete RGB-XYZ point-cloud read without display sampling", |
| | | "device": "CPU", |
| | | "elapsed_seconds": round(time.perf_counter() - started, 3), |
| | | "limitations": [ |
| | | "This output is a manual annotation preview only; it is not a DSM, DEM, mesh, vector layer, or semantic classification.", |
| | | "The output retains every readable finite source point. Large point clouds require correspondingly more browser memory and GPU resources during direct rendering.", |
| | | ] + ([] if has_rgb else ["The source has no readable vertex RGB values. The neutral preview is viewable and labelable for geometry review, but it cannot be used for the RGB semantic-model trainer."]), |
| | | } |
| | | (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 |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | raise SystemExit(main()) |
| New file |
| | |
| | | """Validate and prepare aligned photo, camera-pose, and coloured LAS inputs. |
| | | |
| | | This is a bounded CPU preflight for the planned photo-feature + RGB/XYZ point |
| | | cloud workflow. It deliberately samples the LAS instead of creating a full |
| | | multi-view feature tensor, which would require a successful pose check first. |
| | | """ |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import hashlib |
| | | import json |
| | | import math |
| | | import time |
| | | from dataclasses import dataclass |
| | | from datetime import UTC, datetime |
| | | from importlib import metadata as importlib_metadata |
| | | from pathlib import Path |
| | | from typing import Any |
| | | |
| | | import cv2 |
| | | import laspy |
| | | import numpy as np |
| | | from PIL import Image, ImageDraw |
| | | from pyproj import Transformer |
| | | |
| | | |
| | | def canonical_camera_name(value: str) -> str: |
| | | """Normalise ODM's optional camera-version prefix for a safe lookup.""" |
| | | tokens = value.lower().split() |
| | | if tokens and tokens[0].startswith("v") and tokens[0][1:].isdigit(): |
| | | tokens = tokens[1:] |
| | | return " ".join(tokens) |
| | | |
| | | |
| | | @dataclass(frozen=True) |
| | | class Calibration: |
| | | name: str |
| | | width: int |
| | | height: int |
| | | focal_x: float |
| | | focal_y: float |
| | | c_x: float |
| | | c_y: float |
| | | k1: float |
| | | k2: float |
| | | p1: float |
| | | p2: float |
| | | k3: float |
| | | |
| | | |
| | | @dataclass(frozen=True) |
| | | class Shot: |
| | | filename: str |
| | | calibration: Calibration |
| | | centre: np.ndarray |
| | | rotation: np.ndarray |
| | | longitude: float |
| | | latitude: float |
| | | altitude: float |
| | | |
| | | |
| | | def load_calibrations(path: Path) -> dict[str, Calibration]: |
| | | raw = json.loads(path.read_text(encoding="utf-8")) |
| | | if not isinstance(raw, dict) or not raw: |
| | | raise ValueError("cameras.json must contain at least one calibration object.") |
| | | calibrations: dict[str, Calibration] = {} |
| | | required = {"width", "height", "focal_x", "focal_y", "c_x", "c_y", "k1", "k2", "p1", "p2", "k3"} |
| | | for name, item in raw.items(): |
| | | missing = sorted(required - set(item)) |
| | | if missing: |
| | | raise ValueError(f"Calibration {name!r} is missing fields: {', '.join(missing)}") |
| | | key = canonical_camera_name(name) |
| | | if key in calibrations: |
| | | raise ValueError(f"Duplicate canonical camera calibration name: {key}") |
| | | calibrations[key] = Calibration(name=name, **{field: item[field] for field in required}) |
| | | return calibrations |
| | | |
| | | |
| | | def load_shots(path: Path, calibrations: dict[str, Calibration]) -> list[Shot]: |
| | | raw = json.loads(path.read_text(encoding="utf-8")) |
| | | features = raw.get("features") if isinstance(raw, dict) else None |
| | | if not isinstance(features, list) or not features: |
| | | raise ValueError("shots.geojson must be a non-empty FeatureCollection.") |
| | | shots: list[Shot] = [] |
| | | names: set[str] = set() |
| | | for feature in features: |
| | | props = feature.get("properties") or {} |
| | | geometry = feature.get("geometry") or {} |
| | | filename = props.get("filename") |
| | | camera_key = canonical_camera_name(str(props.get("camera", ""))) |
| | | centre = np.asarray(props.get("translation"), dtype=np.float64) |
| | | rotation = np.asarray(props.get("rotation"), dtype=np.float64) |
| | | coordinates = geometry.get("coordinates") |
| | | if not isinstance(filename, str) or not filename: |
| | | raise ValueError("Each shot must provide a filename.") |
| | | if filename in names: |
| | | raise ValueError(f"Duplicate shot filename: {filename}") |
| | | names.add(filename) |
| | | if camera_key not in calibrations: |
| | | raise ValueError(f"Shot {filename} references unknown camera {props.get('camera')!r}.") |
| | | if centre.shape != (3,) or rotation.shape != (3,) or not np.isfinite(centre).all() or not np.isfinite(rotation).all(): |
| | | raise ValueError(f"Shot {filename} has invalid translation or rotation.") |
| | | if not isinstance(coordinates, list) or len(coordinates) < 3: |
| | | raise ValueError(f"Shot {filename} has no WGS84 point geometry.") |
| | | shots.append(Shot( |
| | | filename=filename, |
| | | calibration=calibrations[camera_key], |
| | | centre=centre, |
| | | rotation=rotation, |
| | | longitude=float(coordinates[0]), |
| | | latitude=float(coordinates[1]), |
| | | altitude=float(coordinates[2]), |
| | | )) |
| | | return shots |
| | | |
| | | |
| | | def inspect_las(path: Path) -> dict[str, Any]: |
| | | with laspy.open(path) as source: |
| | | header = source.header |
| | | dimensions = set(header.point_format.dimension_names) |
| | | has_rgb = {"red", "green", "blue"}.issubset(dimensions) |
| | | crs = header.parse_crs() |
| | | if not has_rgb: |
| | | raise ValueError("The planned RGB/XYZ workflow requires LAS RGB dimensions.") |
| | | if crs is None: |
| | | raise ValueError("The LAS file has no declared CRS; camera-pose alignment cannot be verified.") |
| | | return { |
| | | "path": str(path), |
| | | "bytes": path.stat().st_size, |
| | | "sha256": sha256_file(path), |
| | | "point_count": int(header.point_count), |
| | | "point_format": int(header.point_format.id), |
| | | "version": str(header.version), |
| | | "dimensions": list(header.point_format.dimension_names), |
| | | "has_rgb": has_rgb, |
| | | "crs": crs.to_string(), |
| | | "epsg": crs.to_epsg(), |
| | | "scales": [float(value) for value in header.scales], |
| | | "offsets": [float(value) for value in header.offsets], |
| | | "bounds": {"minimum": [float(value) for value in header.mins], "maximum": [float(value) for value in header.maxs]}, |
| | | } |
| | | |
| | | |
| | | def sha256_file(path: Path, block_size: int = 1 << 20) -> str: |
| | | digest = hashlib.sha256() |
| | | with path.open("rb") as source: |
| | | for block in iter(lambda: source.read(block_size), b""): |
| | | digest.update(block) |
| | | return digest.hexdigest() |
| | | |
| | | |
| | | def normalise_las_rgb(values: np.ndarray) -> np.ndarray: |
| | | """Respect LAS files that store 8-bit RGB in 16-bit dimensions.""" |
| | | rgb = np.asarray(values, dtype=np.float32) |
| | | if rgb.size == 0: |
| | | return rgb.reshape((-1, 3)) |
| | | divisor = 65_535.0 if float(np.nanpercentile(rgb, 99.5)) > 255.0 else 255.0 |
| | | return np.clip(rgb / divisor, 0.0, 1.0) |
| | | |
| | | |
| | | def sample_las(path: Path, max_points: int, seed: int = 20260825, chunk_size: int = 1_000_000) -> tuple[np.ndarray, np.ndarray, dict[str, Any]]: |
| | | """Uniformly sample each LAS chunk, with bounded RAM and deterministic RNG.""" |
| | | if max_points < 10_000: |
| | | raise ValueError("max_points must be at least 10000 for a meaningful pose projection check.") |
| | | with laspy.open(path) as source: |
| | | total = int(source.header.point_count) |
| | | chunk_count = max(1, math.ceil(total / chunk_size)) |
| | | per_chunk = max(1, math.ceil(max_points / chunk_count)) |
| | | rng = np.random.default_rng(seed) |
| | | points: list[np.ndarray] = [] |
| | | colors: list[np.ndarray] = [] |
| | | for chunk in source.chunk_iterator(chunk_size): |
| | | count = len(chunk) |
| | | take = min(count, per_chunk) |
| | | indices = np.arange(count) if take == count else rng.choice(count, size=take, replace=False) |
| | | points.append(np.column_stack((chunk.x[indices], chunk.y[indices], chunk.z[indices])).astype(np.float64)) |
| | | rgb = np.column_stack((chunk.red[indices], chunk.green[indices], chunk.blue[indices])) |
| | | colors.append(normalise_las_rgb(rgb)) |
| | | sampled_points = np.concatenate(points) |
| | | sampled_colors = np.concatenate(colors) |
| | | if len(sampled_points) > max_points: |
| | | chosen = np.random.default_rng(seed + 1).choice(len(sampled_points), size=max_points, replace=False) |
| | | sampled_points, sampled_colors = sampled_points[chosen], sampled_colors[chosen] |
| | | return sampled_points, sampled_colors, {"sampled_points": int(len(sampled_points)), "sampling": "deterministic equal-per-LAS-chunk random subset", "seed": seed, "chunk_size": chunk_size} |
| | | |
| | | |
| | | def project_points(points: np.ndarray, shot: Shot) -> tuple[np.ndarray, np.ndarray]: |
| | | """Project world points using OpenSfM/ODM Brown calibration and Rodrigues pose.""" |
| | | rotation_matrix, _ = cv2.Rodrigues(shot.rotation.reshape(3, 1)) |
| | | camera_points = (rotation_matrix @ (points - shot.centre).T).T |
| | | forward = camera_points[:, 2] > 0.05 |
| | | pixels = np.full((len(points), 2), np.nan, dtype=np.float64) |
| | | if not forward.any(): |
| | | return pixels, forward |
| | | normalised = camera_points[forward, :2] / camera_points[forward, 2:3] |
| | | x, y = normalised.T |
| | | r2 = x * x + y * y |
| | | c = shot.calibration |
| | | radial = 1.0 + c.k1 * r2 + c.k2 * r2 * r2 + c.k3 * r2 * r2 * r2 |
| | | xd = x * radial + 2.0 * c.p1 * x * y + c.p2 * (r2 + 2.0 * x * x) |
| | | yd = y * radial + c.p1 * (r2 + 2.0 * y * y) + 2.0 * c.p2 * x * y |
| | | scale = float(max(c.width, c.height)) |
| | | pixels[forward, 0] = c.width / 2.0 + scale * (c.focal_x * xd + c.c_x) |
| | | pixels[forward, 1] = c.height / 2.0 + scale * (c.focal_y * yd + c.c_y) |
| | | inside = forward & (pixels[:, 0] >= 0) & (pixels[:, 0] < c.width) & (pixels[:, 1] >= 0) & (pixels[:, 1] < c.height) |
| | | return pixels, inside |
| | | |
| | | |
| | | def selected_shots(shots: list[Shot], count: int) -> list[Shot]: |
| | | if count < 2: |
| | | raise ValueError("At least two representative shots are required.") |
| | | indices = np.linspace(0, len(shots) - 1, min(count, len(shots)), dtype=int) |
| | | return [shots[index] for index in sorted(set(indices.tolist()))] |
| | | |
| | | |
| | | def make_overlay(image_path: Path, pixels: np.ndarray, output_path: Path, limit: int = 1_500) -> int: |
| | | with Image.open(image_path) as source: |
| | | image = source.convert("RGB") |
| | | valid = np.isfinite(pixels).all(axis=1) |
| | | coordinates = pixels[valid] |
| | | if len(coordinates) > limit: |
| | | indices = np.linspace(0, len(coordinates) - 1, limit, dtype=int) |
| | | coordinates = coordinates[indices] |
| | | draw = ImageDraw.Draw(image) |
| | | for x, y in coordinates: |
| | | draw.ellipse((int(x) - 2, int(y) - 2, int(x) + 2, int(y) + 2), outline=(255, 48, 48), width=1) |
| | | output_path.parent.mkdir(parents=True, exist_ok=True) |
| | | image.save(output_path, quality=92) |
| | | return int(len(coordinates)) |
| | | |
| | | |
| | | def validate_pose_coordinates(shots: list[Shot], epsg: int | None) -> dict[str, Any]: |
| | | if epsg is None: |
| | | raise ValueError("LAS CRS has no EPSG code.") |
| | | transformer = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True) |
| | | residuals = [] |
| | | for shot in shots: |
| | | x, y = transformer.transform(shot.longitude, shot.latitude) |
| | | residuals.append(float(np.linalg.norm(np.array([x, y]) - shot.centre[:2]))) |
| | | return { |
| | | "method": "WGS84 shot geometry transformed into LAS CRS and compared with shot translation XY", |
| | | "sample_count": len(shots), |
| | | "median_xy_residual_metres": round(float(np.median(residuals)), 5), |
| | | "max_xy_residual_metres": round(float(np.max(residuals)), 5), |
| | | "accepted": bool(float(np.max(residuals)) <= 0.25), |
| | | "acceptance_limit_metres": 0.25, |
| | | } |
| | | |
| | | |
| | | def run_preflight(image_dir: Path, las_path: Path, cameras_path: Path, shots_path: Path, output_dir: Path, max_points: int, diagnostic_shots: int) -> dict[str, Any]: |
| | | started = time.perf_counter() |
| | | if output_dir.exists() and any(output_dir.iterdir()): |
| | | raise ValueError("Output directory must be new or empty.") |
| | | output_dir.mkdir(parents=True, exist_ok=True) |
| | | calibrations = load_calibrations(cameras_path) |
| | | shots = load_shots(shots_path, calibrations) |
| | | images = {path.name for path in image_dir.iterdir() if path.is_file() and path.suffix.lower() in {".jpg", ".jpeg"}} |
| | | shot_names = {shot.filename for shot in shots} |
| | | missing_images = sorted(shot_names - images) |
| | | extra_images = sorted(images - shot_names) |
| | | if missing_images: |
| | | raise ValueError(f"{len(missing_images)} shots have no matching photo, for example {missing_images[:3]}") |
| | | las = inspect_las(las_path) |
| | | pose_coordinates = validate_pose_coordinates(shots, las["epsg"]) |
| | | if not pose_coordinates["accepted"]: |
| | | raise ValueError("Shot WGS84 coordinates do not agree with LAS CRS coordinates; projection is unsafe.") |
| | | points, colors, sample = sample_las(las_path, max_points=max_points) |
| | | diagnostics: list[dict[str, Any]] = [] |
| | | overlays_dir = output_dir / "projection-overlays" |
| | | for shot in selected_shots(shots, diagnostic_shots): |
| | | pixels, inside = project_points(points, shot) |
| | | overlay_points = make_overlay(image_dir / shot.filename, pixels[inside], overlays_dir / f"{Path(shot.filename).stem}.projection.jpg") |
| | | diagnostics.append({ |
| | | "filename": shot.filename, |
| | | "camera": shot.calibration.name, |
| | | "camera_centre_epsg": [round(float(value), 4) for value in shot.centre], |
| | | "sample_points_in_frame": int(inside.sum()), |
| | | "overlay_points": overlay_points, |
| | | "projection_overlay": str((overlays_dir / f"{Path(shot.filename).stem}.projection.jpg").relative_to(output_dir)), |
| | | }) |
| | | visible_counts = np.asarray([item["sample_points_in_frame"] for item in diagnostics], dtype=int) |
| | | projection = { |
| | | "pose_interpretation": "shots.properties.translation is camera centre; shots.properties.rotation is OpenSfM Rodrigues world-to-camera rotation; Brown intrinsics are read from cameras.json", |
| | | "diagnostic_shot_count": len(diagnostics), |
| | | "sample_point_count": int(len(points)), |
| | | "per_shot": diagnostics, |
| | | "minimum_visible_points": int(visible_counts.min()), |
| | | "median_visible_points": float(np.median(visible_counts)), |
| | | "shots_with_at_least_50_visible_points": int((visible_counts >= 50).sum()), |
| | | "accepted": bool((visible_counts >= 50).all()), |
| | | "acceptance": "Every representative photo must receive at least 50 sampled LAS points inside the calibrated image frame. This checks pose/intrinsic convention and coverage, not occlusion or pixel-perfect alignment.", |
| | | } |
| | | inventory = { |
| | | "images": {"directory": str(image_dir), "count": len(images), "matching_shots": len(shot_names & images), "missing_for_shots": missing_images, "unmatched_images": extra_images[:50], "unmatched_image_count": len(extra_images)}, |
| | | "cameras": {"path": str(cameras_path), "count": len(calibrations), "names": [item.name for item in calibrations.values()]}, |
| | | "shots": {"path": str(shots_path), "count": len(shots), "camera_names": sorted({shot.calibration.name for shot in shots})}, |
| | | "las": las, |
| | | } |
| | | (output_dir / "input_inventory.json").write_text(json.dumps(inventory, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | (output_dir / "pose_projection_report.json").write_text(json.dumps({"coordinate_check": pose_coordinates, "projection_check": projection, "sample": sample}, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | metadata = { |
| | | "capability": "05-3d-pointcloud", |
| | | "classification": "B", |
| | | "created_at": datetime.now(UTC).isoformat(), |
| | | "method": "CPU-only LAS/camera/shot compatibility verification and bounded point-to-image projection preflight", |
| | | "model": "none; Point Transformer V3 is intentionally not trained before this preflight passes", |
| | | "device": "cpu", |
| | | "inputs": {"image_count": len(images), "shot_count": len(shots), "las_points": las["point_count"], "las_crs": las["crs"]}, |
| | | "checks": {"image_shot_match": not missing_images, "coordinate_check": pose_coordinates, "projection_check": projection}, |
| | | "output_files": ["input_inventory.json", "pose_projection_report.json", "projection-overlays/"], |
| | | "elapsed_seconds": round(time.perf_counter() - started, 3), |
| | | "limitations": [ |
| | | "This is a sampled geometry/intrinsics preflight. It does not yet establish per-pixel reprojection accuracy, visibility/occlusion, image feature quality, semantic labels, or model accuracy.", |
| | | "No training label is generated from rule candidates. Point Transformer V3 training starts only after reviewed labels and feature-coverage acceptance are available.", |
| | | "Projection overlays show sampled points that are geometrically inside an image frame; they are diagnostic artifacts and not a browser-viewer quality claim.", |
| | | ], |
| | | } |
| | | (output_dir / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return metadata |
| | | |
| | | |
| | | def main() -> int: |
| | | parser = argparse.ArgumentParser(description="Validate photo/LAS/camera-pose alignment before multimodal point-cloud training.") |
| | | parser.add_argument("--images", type=Path, required=True) |
| | | parser.add_argument("--las", type=Path, required=True) |
| | | parser.add_argument("--cameras", type=Path, required=True) |
| | | parser.add_argument("--shots", type=Path, required=True) |
| | | parser.add_argument("--output", type=Path, required=True) |
| | | parser.add_argument("--max-points", type=int, default=120_000) |
| | | parser.add_argument("--diagnostic-shots", type=int, default=12) |
| | | args = parser.parse_args() |
| | | result = run_preflight(args.images, args.las, args.cameras, args.shots, args.output, args.max_points, args.diagnostic_shots) |
| | | print(json.dumps(result, ensure_ascii=False, indent=2)) |
| | | return 0 |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | raise SystemExit(main()) |
| New file |
| | |
| | | """Build bounded multi-view visual features for RGB/XYZ point-cloud learning. |
| | | |
| | | This CPU Demo uses verified ODM camera poses to project a sampled coloured LAS |
| | | cloud into representative photos. A two-pixel z-buffer retains only the |
| | | nearest point per image cell, then aggregates observed local photo texture per |
| | | point. It is training-data preparation, not semantic annotation or inference. |
| | | """ |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import csv |
| | | import hashlib |
| | | import json |
| | | import time |
| | | from datetime import UTC, datetime |
| | | from pathlib import Path |
| | | from typing import Any |
| | | |
| | | import cv2 |
| | | import numpy as np |
| | | import open3d as o3d |
| | | |
| | | from diagnose_multimodal_projection import project_with_rotation |
| | | from prepare_multimodal_pointcloud_dataset import load_calibrations, load_shots, sample_las, selected_shots |
| | | |
| | | |
| | | FEATURE_NAMES = ( |
| | | "photo_r", |
| | | "photo_g", |
| | | "photo_b", |
| | | "hue_sin", |
| | | "hue_cos", |
| | | "saturation", |
| | | "gradient_magnitude", |
| | | "local_intensity_stddev", |
| | | ) |
| | | |
| | | |
| | | def z_buffer_visible_indices(pixels: np.ndarray, depths: np.ndarray, inside: np.ndarray, width: int, cell_size: int = 2) -> np.ndarray: |
| | | """Keep the closest point in each small image cell as an occlusion proxy.""" |
| | | if cell_size < 1: |
| | | raise ValueError("cell_size must be positive.") |
| | | candidates = np.flatnonzero(inside) |
| | | if not len(candidates): |
| | | return candidates |
| | | rounded = np.rint(pixels[candidates]).astype(np.int64) |
| | | columns = np.clip(rounded[:, 0] // cell_size, 0, (width - 1) // cell_size) |
| | | rows = np.clip(rounded[:, 1] // cell_size, 0, (int(np.nanmax(rounded[:, 1])) + cell_size) // cell_size) |
| | | cell_width = (width + cell_size - 1) // cell_size |
| | | flat = rows * cell_width + columns |
| | | order = np.lexsort((depths[candidates], flat)) |
| | | ordered_flat = flat[order] |
| | | nearest = np.r_[True, ordered_flat[1:] != ordered_flat[:-1]] |
| | | return candidates[order[nearest]] |
| | | |
| | | |
| | | def photo_features(photo: np.ndarray) -> np.ndarray: |
| | | """Derive deterministic local visual descriptors at each RGB pixel.""" |
| | | rgb = np.asarray(photo, dtype=np.uint8) |
| | | hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV).astype(np.float32) |
| | | gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0 |
| | | gradient_x = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3) |
| | | gradient_y = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3) |
| | | gradient = np.clip(np.hypot(gradient_x, gradient_y), 0.0, 1.0) |
| | | local_mean = cv2.boxFilter(gray, cv2.CV_32F, (5, 5), normalize=True) |
| | | local_square_mean = cv2.boxFilter(gray * gray, cv2.CV_32F, (5, 5), normalize=True) |
| | | local_stddev = np.sqrt(np.maximum(local_square_mean - local_mean * local_mean, 0.0)) |
| | | hue = hsv[..., 0] * (2.0 * np.pi / 180.0) |
| | | return np.dstack(( |
| | | rgb.astype(np.float32) / 255.0, |
| | | ((np.sin(hue) + 1.0) / 2.0).astype(np.float32), |
| | | ((np.cos(hue) + 1.0) / 2.0).astype(np.float32), |
| | | (hsv[..., 1] / 255.0).astype(np.float32), |
| | | gradient.astype(np.float32), |
| | | local_stddev.astype(np.float32), |
| | | )) |
| | | |
| | | |
| | | def coverage_colour(counts: np.ndarray) -> np.ndarray: |
| | | ratio = np.clip(np.log1p(counts) / max(float(np.log1p(counts.max())), 1.0), 0.0, 1.0) |
| | | return np.column_stack((ratio, 0.2 + 0.7 * ratio, 1.0 - 0.7 * ratio)) |
| | | |
| | | |
| | | def write_preview(path: Path, points: np.ndarray, counts: np.ndarray, maximum_points: int = 400_000) -> int: |
| | | visible = np.flatnonzero(counts > 0) |
| | | if not len(visible): |
| | | raise ValueError("No sampled point is visible after z-buffer filtering.") |
| | | chosen = visible if len(visible) <= maximum_points else visible[np.linspace(0, len(visible) - 1, maximum_points, dtype=int)] |
| | | cloud = o3d.geometry.PointCloud() |
| | | cloud.points = o3d.utility.Vector3dVector(points[chosen]) |
| | | cloud.colors = o3d.utility.Vector3dVector(coverage_colour(counts[chosen])) |
| | | if not o3d.io.write_point_cloud(str(path), cloud, write_ascii=False): |
| | | raise RuntimeError(f"Cannot write preview PLY: {path}") |
| | | return int(len(chosen)) |
| | | |
| | | |
| | | def write_annotation_source(path: Path, points: np.ndarray, las_rgb: np.ndarray) -> int: |
| | | """Write the complete ordered RGB cloud used for human point labels. |
| | | |
| | | This deliberately differs from the coverage preview: its colour is the |
| | | observed LAS RGB, and row ``i`` is the same point as ``xyz[i]`` in the |
| | | compressed feature dataset. That makes a saved browser point index usable |
| | | by a later multimodal training loader without transferring display colours |
| | | into the model input. |
| | | """ |
| | | if path.exists(): |
| | | raise ValueError(f"Annotation source already exists: {path}") |
| | | if points.ndim != 2 or points.shape[1] != 3 or las_rgb.shape != points.shape: |
| | | raise ValueError("Annotation source requires matching Nx3 XYZ and RGB arrays.") |
| | | if not np.isfinite(points).all() or not np.isfinite(las_rgb).all(): |
| | | raise ValueError("Annotation source requires finite XYZ and RGB values.") |
| | | cloud = o3d.geometry.PointCloud() |
| | | cloud.points = o3d.utility.Vector3dVector(points.astype(np.float64, copy=False)) |
| | | cloud.colors = o3d.utility.Vector3dVector(np.clip(las_rgb, 0.0, 1.0).astype(np.float64, copy=False)) |
| | | if not o3d.io.write_point_cloud(str(path), cloud, write_ascii=False): |
| | | raise RuntimeError(f"Cannot write annotation source PLY: {path}") |
| | | return int(len(points)) |
| | | |
| | | |
| | | def file_sha256(path: Path) -> str: |
| | | digest = hashlib.sha256() |
| | | with path.open("rb") as stream: |
| | | for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""): |
| | | digest.update(chunk) |
| | | return digest.hexdigest() |
| | | |
| | | |
| | | def annotation_source_contract(annotation_path: Path, feature_path: Path, point_count: int) -> dict[str, Any]: |
| | | return { |
| | | "schema_version": 1, |
| | | "kind": "multiview_photo_feature_fusion", |
| | | "point_cloud": annotation_path.name, |
| | | "feature_dataset": feature_path.name, |
| | | "point_count": int(point_count), |
| | | "point_order": "PLY vertex i is exactly the same XYZ/LAS-RGB point as feature dataset xyz[i] and las_rgb[i].", |
| | | "point_cloud_sha256": file_sha256(annotation_path), |
| | | "feature_dataset_sha256": file_sha256(feature_path), |
| | | } |
| | | |
| | | |
| | | def add_annotation_source_to_metadata(output: Path, annotation_path: Path, feature_path: Path, points: np.ndarray) -> dict[str, Any]: |
| | | """Record immutable links needed by the console's annotation-source gate.""" |
| | | metadata_path = output / "run_metadata.json" |
| | | try: |
| | | metadata = json.loads(metadata_path.read_text(encoding="utf-8")) |
| | | except (OSError, json.JSONDecodeError) as exc: |
| | | raise ValueError("Cannot add an annotation source without valid run_metadata.json.") from exc |
| | | if not isinstance(metadata, dict) or metadata.get("capability") != "05-3d-pointcloud": |
| | | raise ValueError("Annotation source metadata must belong to capability 05-3d-pointcloud.") |
| | | artifacts = metadata.setdefault("artifacts", {}) |
| | | if not isinstance(artifacts, dict): |
| | | raise ValueError("run_metadata artifacts must be an object.") |
| | | artifacts["feature_dataset"] = feature_path.name |
| | | artifacts["annotation_source"] = annotation_path.name |
| | | metadata["annotation_source"] = annotation_source_contract(annotation_path, feature_path, len(points)) |
| | | metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return metadata |
| | | |
| | | |
| | | def add_annotation_source_from_existing_dataset(output: Path) -> dict[str, Any]: |
| | | """Create the safe annotation PLY once for a completed feature-preparation run.""" |
| | | feature_path = output / "multiview-point-features.npz" |
| | | annotation_path = output / "multiview-annotation-source.ply" |
| | | if not feature_path.is_file(): |
| | | raise ValueError("Feature dataset is unavailable; cannot create an annotation source.") |
| | | with np.load(feature_path, allow_pickle=False) as dataset: |
| | | if "xyz" not in dataset or "las_rgb" not in dataset: |
| | | raise ValueError("Feature dataset must contain xyz and las_rgb arrays.") |
| | | points = np.asarray(dataset["xyz"], dtype=np.float64) |
| | | las_rgb = np.asarray(dataset["las_rgb"], dtype=np.float32) |
| | | count = write_annotation_source(annotation_path, points, las_rgb) |
| | | metadata = add_annotation_source_to_metadata(output, annotation_path, feature_path, points) |
| | | return {"point_count": count, "annotation_source": metadata["annotation_source"]} |
| | | |
| | | |
| | | def prepare_features(images: Path, las: Path, cameras: Path, shots_path: Path, output: Path, max_points: int, max_shots: int, z_buffer_cell_size: int) -> dict[str, Any]: |
| | | if output.exists() and any(output.iterdir()): |
| | | raise ValueError("Output directory must be new or empty.") |
| | | if max_shots < 2: |
| | | raise ValueError("max_shots must be at least 2.") |
| | | output.mkdir(parents=True, exist_ok=True) |
| | | calibrations = load_calibrations(cameras) |
| | | all_shots = load_shots(shots_path, calibrations) |
| | | shots = selected_shots(all_shots, max_shots) |
| | | points, las_rgb, sampling = sample_las(las, max_points) |
| | | feature_sum = np.zeros((len(points), len(FEATURE_NAMES)), dtype=np.float32) |
| | | feature_square_sum = np.zeros_like(feature_sum) |
| | | observation_counts = np.zeros(len(points), dtype=np.uint16) |
| | | shot_records: list[dict[str, Any]] = [] |
| | | for position, shot in enumerate(shots, start=1): |
| | | image_path = images / shot.filename |
| | | image_bgr = cv2.imread(str(image_path), cv2.IMREAD_COLOR) |
| | | if image_bgr is None: |
| | | raise ValueError(f"Cannot read photo for shot: {shot.filename}") |
| | | photo = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) |
| | | if photo.shape[1] != shot.calibration.width or photo.shape[0] != shot.calibration.height: |
| | | raise ValueError(f"Photo size does not match calibration: {shot.filename}") |
| | | pixels, inside, depths = project_with_rotation(points, shot, inverse_rotation=False) |
| | | visible = z_buffer_visible_indices(pixels, depths, inside, shot.calibration.width, z_buffer_cell_size) |
| | | rounded = np.rint(pixels[visible]).astype(np.int64) |
| | | rounded[:, 0] = np.clip(rounded[:, 0], 0, shot.calibration.width - 1) |
| | | rounded[:, 1] = np.clip(rounded[:, 1], 0, shot.calibration.height - 1) |
| | | descriptors = photo_features(photo)[rounded[:, 1], rounded[:, 0]] |
| | | feature_sum[visible] += descriptors |
| | | feature_square_sum[visible] += descriptors * descriptors |
| | | observation_counts[visible] += 1 |
| | | shot_records.append({ |
| | | "sequence_index": position, |
| | | "filename": shot.filename, |
| | | "in_frame_sample_points": int(inside.sum()), |
| | | "z_buffer_visible_points": int(len(visible)), |
| | | "visible_ratio_of_sample": round(float(len(visible) / len(points)), 6), |
| | | }) |
| | | valid = observation_counts > 0 |
| | | mean = np.zeros_like(feature_sum) |
| | | stddev = np.zeros_like(feature_sum) |
| | | mean[valid] = feature_sum[valid] / observation_counts[valid, None] |
| | | stddev[valid] = np.sqrt(np.maximum(feature_square_sum[valid] / observation_counts[valid, None] - mean[valid] * mean[valid], 0.0)) |
| | | fused_path = output / "multiview-point-features.npz" |
| | | np.savez_compressed( |
| | | fused_path, |
| | | xyz=points.astype(np.float64), |
| | | las_rgb=las_rgb.astype(np.float32), |
| | | photo_feature_mean=mean.astype(np.float32), |
| | | photo_feature_stddev=stddev.astype(np.float32), |
| | | visible_view_count=observation_counts, |
| | | feature_names=np.asarray(FEATURE_NAMES), |
| | | ) |
| | | preview_path = output / "multiview-coverage-preview.ply" |
| | | preview_points = write_preview(preview_path, points, observation_counts) |
| | | annotation_path = output / "multiview-annotation-source.ply" |
| | | annotation_points = write_annotation_source(annotation_path, points, las_rgb) |
| | | with (output / "per-shot-coverage.csv").open("w", newline="", encoding="utf-8-sig") as destination: |
| | | writer = csv.DictWriter(destination, fieldnames=list(shot_records[0])) |
| | | writer.writeheader() |
| | | writer.writerows(shot_records) |
| | | distribution = {str(int(value)): int((observation_counts == value).sum()) for value in np.unique(observation_counts)} |
| | | coverage = { |
| | | "sampled_points": int(len(points)), |
| | | "visible_points": int(valid.sum()), |
| | | "visible_point_ratio": round(float(valid.mean()), 6), |
| | | "mean_valid_views_per_visible_point": round(float(observation_counts[valid].mean()), 4), |
| | | "median_valid_views_per_visible_point": float(np.median(observation_counts[valid])), |
| | | "maximum_valid_views_per_point": int(observation_counts.max()), |
| | | "view_count_distribution": distribution, |
| | | "feature_dimensions": len(FEATURE_NAMES), |
| | | } |
| | | (output / "coverage-summary.json").write_text(json.dumps(coverage, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | result = { |
| | | "capability": "05-3d-pointcloud", |
| | | "classification": "B", |
| | | "created_at": datetime.now(UTC).isoformat(), |
| | | "method": "CPU Brown-camera projection, two-pixel closest-depth visibility filtering, and multi-view local RGB/HSV/gradient/texture feature aggregation", |
| | | "model": "none; deterministic visual-texture feature baseline before learned photo encoder and Point Transformer V3 training", |
| | | "device": "cpu", |
| | | "inputs": {"sampled_las_points": int(len(points)), "selected_shots": len(shots), "available_shots": len(all_shots), "z_buffer_cell_size_pixels": z_buffer_cell_size}, |
| | | "feature_contract": {"las_features": ["x", "y", "z", "las_r", "las_g", "las_b"], "photo_feature_names": list(FEATURE_NAMES), "aggregation": "per-point mean and standard deviation over nearest-depth visible photo observations"}, |
| | | "coverage": coverage, |
| | | "artifacts": {"feature_dataset": fused_path.name, "coverage_preview": preview_path.name, "annotation_source": annotation_path.name, "per_shot_coverage": "per-shot-coverage.csv", "coverage_summary": "coverage-summary.json"}, |
| | | "annotation_source": annotation_source_contract(annotation_path, fused_path, annotation_points), |
| | | "limitations": [ |
| | | "The two-pixel z-buffer is an image-space occlusion proxy. It cannot fully model transparent vegetation, mixed pixels, rolling shutter, or all geometric self-occlusion.", |
| | | "These are deterministic local visual descriptors, not a pretrained semantic image encoder. They establish a reproducible fusion contract before a learned feature-model decision.", |
| | | "This artifact contains no semantic labels. It cannot train or validate Point Transformer V3 until human-reviewed labels are linked to the same point indices.", |
| | | "A sampled run validates the fusion route only. A full 75-million-point export requires chunked production preparation after coverage and label criteria are accepted.", |
| | | ], |
| | | } |
| | | (output / "run_metadata.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return result |
| | | |
| | | |
| | | def main() -> int: |
| | | parser = argparse.ArgumentParser(description="Prepare bounded multi-view photo features for an RGB LAS point cloud.") |
| | | parser.add_argument("--images", type=Path) |
| | | parser.add_argument("--las", type=Path) |
| | | parser.add_argument("--cameras", type=Path) |
| | | parser.add_argument("--shots", type=Path) |
| | | parser.add_argument("--output", type=Path, required=True) |
| | | parser.add_argument("--max-points", type=int, default=50_000) |
| | | parser.add_argument("--max-shots", type=int, default=12) |
| | | parser.add_argument("--z-buffer-cell-size", type=int, default=2) |
| | | parser.add_argument("--annotation-source-only", action="store_true", help="Add the ordered original-RGB annotation source to one existing feature dataset.") |
| | | args = parser.parse_args() |
| | | started = time.perf_counter() |
| | | if args.annotation_source_only: |
| | | result = add_annotation_source_from_existing_dataset(args.output) |
| | | result["elapsed_seconds"] = round(time.perf_counter() - started, 3) |
| | | print(json.dumps(result, ensure_ascii=False, indent=2)) |
| | | return 0 |
| | | if not all((args.images, args.las, args.cameras, args.shots)): |
| | | parser.error("--images, --las, --cameras, and --shots are required unless --annotation-source-only is used.") |
| | | result = prepare_features(args.images, args.las, args.cameras, args.shots, args.output, args.max_points, args.max_shots, args.z_buffer_cell_size) |
| | | result["elapsed_seconds"] = round(time.perf_counter() - started, 3) |
| | | (args.output / "run_metadata.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | print(json.dumps({"coverage": result["coverage"], "elapsed_seconds": result["elapsed_seconds"]}, ensure_ascii=False, indent=2)) |
| | | return 0 |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | raise SystemExit(main()) |
| | |
| | | import hashlib |
| | | import io |
| | | import json |
| | | import re |
| | | import struct |
| | | import subprocess |
| | | import time |
| | |
| | | |
| | | |
| | | IMAGE_SUFFIXES = {".jpg", ".jpeg"} |
| | | DENSE_DEVICES = {"cpu", "cuda"} |
| | | |
| | | |
| | | def write_progress(path: Path | None, percent: int, stage: str, message: str, image_count: int) -> None: |
| | | """Publish completed milestones for the local asynchronous console.""" |
| | | if path is None: |
| | | return |
| | | path.parent.mkdir(parents=True, exist_ok=True) |
| | | temporary = path.with_suffix(f"{path.suffix}.tmp") |
| | | temporary.write_text(json.dumps({"percent": percent, "stage": stage, "message": message, "inputImages": image_count, "updatedAt": datetime.now(UTC).isoformat(), "estimate": True}, ensure_ascii=False), encoding="utf-8") |
| | | temporary.replace(path) |
| | | |
| | | |
| | | def sha256(path: Path) -> str: |
| | |
| | | return images |
| | | |
| | | |
| | | def run(command: list[str], cwd: Path) -> None: |
| | | def run(command: list[str], cwd: Path) -> str: |
| | | completed = subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False) |
| | | (cwd / f"{Path(command[0]).stem}.stdout.log").write_text(completed.stdout, encoding="utf-8") |
| | | (cwd / f"{Path(command[0]).stem}.stderr.log").write_text(completed.stderr, encoding="utf-8") |
| | | if completed.returncode: |
| | | message = (completed.stderr or completed.stdout or "OpenMVS command failed.").strip() |
| | | raise RuntimeError(f"{Path(command[0]).name} failed: {message[-1000:]}") |
| | | return f"{completed.stdout}\n{completed.stderr}" |
| | | |
| | | |
| | | def verify_cuda_dense_log(output: str) -> None: |
| | | """Require OpenMVS to identify CUDA/GPU before labelling a run as GPU MVS.""" |
| | | if not re.search(r"\b(?:CUDA|GPU)\b", output, flags=re.IGNORECASE): |
| | | raise RuntimeError("The CUDA OpenMVS densification log did not report CUDA/GPU execution.") |
| | | |
| | | |
| | | def openmvs_tool_log(cwd: Path, tool_name: str) -> str: |
| | | """OpenMVS writes its own timestamped process log instead of stdout.""" |
| | | candidates = sorted(cwd.glob(f"{tool_name}-*.log"), key=lambda item: item.stat().st_mtime_ns) |
| | | return candidates[-1].read_text(encoding="utf-8", errors="replace") if candidates else "" |
| | | |
| | | |
| | | def with_openmvs_log(error: RuntimeError, cwd: Path, tool_name: str) -> RuntimeError: |
| | | """Attach the native tool log, where OpenMVS records CUDA failures.""" |
| | | tool_log = openmvs_tool_log(cwd, tool_name).strip() |
| | | if not tool_log: |
| | | return error |
| | | return RuntimeError(f"{error}\n{tool_name} native log:\n{tool_log[-1000:]}") |
| | | |
| | | |
| | | def ply_vertex_count(path: Path) -> int: |
| | |
| | | setattr(args, field, getattr(args, field).resolve()) |
| | | |
| | | def main() -> int: |
| | | parser = argparse.ArgumentParser(description="Create an OpenMVS dense point cloud, mesh and texture using only CPU.") |
| | | parser = argparse.ArgumentParser(description="Create an OpenMVS dense point cloud, mesh and texture with CPU stages and an optional CUDA MVS stage.") |
| | | parser.add_argument("--input", type=Path, required=True, help="Original coherent JPG/JPEG sequence.") |
| | | parser.add_argument("--sparse-model", type=Path, required=True, help="COLMAP binary sparse model directory.") |
| | | parser.add_argument("--output", type=Path, required=True, help="New or empty result directory.") |
| | |
| | | parser.add_argument("--mesh-close-holes", type=int, default=0) |
| | | parser.add_argument("--mesh-smooth", type=int, default=0) |
| | | parser.add_argument("--texture-outlier-threshold", type=float, default=0.0) |
| | | parser.add_argument("--dense-device", choices=sorted(DENSE_DEVICES), default="cpu", help="Use CUDA only for OpenMVS DensifyPointCloud; preparation, meshing and texturing remain CPU stages.") |
| | | parser.add_argument("--progress-file", type=Path, help="Optional JSON progress file for the local asynchronous console.") |
| | | args = parser.parse_args() |
| | | resolve_external_tool_paths(args) |
| | | if args.threads < 1 or args.max_resolution < 640 or args.dense_resolution_level < 0 or args.dense_min_resolution < 1 or args.dense_number_views < 2 or args.dense_number_views_fuse < 2 or args.dense_number_views_fuse > args.dense_number_views or args.target_faces < 10000 or args.mesh_close_holes < 0 or args.mesh_smooth < 0 or args.texture_outlier_threshold < 0: |
| | |
| | | if len(reconstruction.images) < 3: |
| | | raise SystemExit("Sparse model has fewer than three registered images.") |
| | | args.output.mkdir(parents=True, exist_ok=True) |
| | | write_progress(args.progress_file, 58, "dense_prepare", "正在准备 RGB 处理副本和去畸变影像。", len(images)) |
| | | started = time.perf_counter() |
| | | processed = args.output / "processed_rgb_images" |
| | | processed.mkdir() |
| | |
| | | image.convert("RGB").save(processed / source.name, quality=100, subsampling=0, optimize=False) |
| | | manifest.append({"image": source.name, "source_bytes": source.stat().st_size, "source_sha256": sha256(source), "source_size": list(image.size), "source_mode": image.mode}) |
| | | (processed / "conversion_manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | write_progress(args.progress_file, 64, "dense_undistort", "正在去畸变并转换 OpenMVS 场景。", len(images)) |
| | | undistorted = args.output / "undistorted_rgb" |
| | | pycolmap.undistort_images(undistorted, args.sparse_model, processed, num_threads=args.threads, jpeg_quality=95) |
| | | scene = args.output / "scene.mvs" |
| | | run([str(tools["InterfaceCOLMAP"]), "--input-file", str(undistorted), "--image-folder", str(undistorted / "images"), "--output-file", str(scene), "--max-threads", str(args.threads)], args.output) |
| | | dense = args.output / "dense.mvs" |
| | | run([str(tools["DensifyPointCloud"]), "--input-file", str(scene), "--output-file", str(dense), "--max-threads", str(args.threads), "--resolution-level", str(args.dense_resolution_level), "--max-resolution", str(args.max_resolution), "--min-resolution", str(args.dense_min_resolution), "--number-views", str(args.dense_number_views), "--number-views-fuse", str(args.dense_number_views_fuse), "--iters", "3", "--geometric-iters", "2", "--postprocess-dmaps", "5", "--filter-point-cloud", "1", "--tower-mode", "0"], args.output) |
| | | write_progress(args.progress_file, 68, "dense_fusion", "正在进行 OpenMVS 深度估计和点云融合。", len(images)) |
| | | try: |
| | | dense_log = run([str(tools["DensifyPointCloud"]), "--input-file", str(scene), "--output-file", str(dense), "--max-threads", str(args.threads), "--resolution-level", str(args.dense_resolution_level), "--max-resolution", str(args.max_resolution), "--min-resolution", str(args.dense_min_resolution), "--number-views", str(args.dense_number_views), "--number-views-fuse", str(args.dense_number_views_fuse), "--iters", "3", "--geometric-iters", "2", "--postprocess-dmaps", "5", "--filter-point-cloud", "1", "--tower-mode", "0"], args.output) |
| | | except RuntimeError as error: |
| | | raise with_openmvs_log(error, args.output, "DensifyPointCloud") from error |
| | | dense_log += openmvs_tool_log(args.output, "DensifyPointCloud") |
| | | if args.dense_device == "cuda": |
| | | verify_cuda_dense_log(dense_log) |
| | | mesh = args.output / "mesh.mvs" |
| | | write_progress(args.progress_file, 83, "mesh", "稠密点云已完成,正在重建三角网格。", len(images)) |
| | | run([str(tools["ReconstructMesh"]), "--input-file", str(dense), "--output-file", str(mesh), "--max-threads", str(args.threads), "--target-face-num", str(args.target_faces), "--remove-spurious", "20", "--remove-spikes", "1", "--close-holes", str(args.mesh_close_holes), "--smooth", str(args.mesh_smooth)], args.output) |
| | | textured = args.output / "textured.mvs" |
| | | write_progress(args.progress_file, 90, "texture", "网格已完成,正在生成纹理和浏览器预览。", len(images)) |
| | | run([str(tools["TextureMesh"]), "--input-file", str(dense), "--mesh-file", str(args.output / "mesh.ply"), "--output-file", str(textured), "--export-type", "glb", "--max-threads", str(args.threads), "--resolution-level", "0", "--min-resolution", "640", "--max-texture-size", "4096", "--close-holes", "0", "--outlier-threshold", str(args.texture_outlier_threshold)], args.output) |
| | | textured_scene = trimesh.load(args.output / "textured.glb", force="scene") |
| | | (args.output / "textured_embedded.glb").write_bytes(textured_scene.export(file_type="glb")) |
| | | geometry_preview_faces = build_geometry_preview_glb(args.output / "mesh.ply", args.output / "geometry_preview.glb") |
| | | point_color_preview_faces = build_point_color_preview_glb(args.output / "dense.ply", args.output / "mesh.ply", args.output / "point_color_preview.glb") |
| | | preview_report = build_filtered_preview_glb(args.output / "textured.glb", args.output / "textured_preview_filtered.glb", args.output / "textured_preview_filter_report.json") |
| | | write_progress(args.progress_file, 98, "export", "正在整理点云、网格、纹理和元数据。", len(images)) |
| | | geometry = list(textured_scene.geometry.values()) |
| | | texture_files = sorted(path.name for path in args.output.glob("textured_*.png")) |
| | | metadata = { |
| | | "capability": "05-3d-pointcloud", "classification": "B", "created_at": datetime.now(UTC).isoformat(), |
| | | "method": "COLMAP CPU sparse SfM, RGB processing-copy undistortion, OpenMVS CPU PatchMatch depth fusion, mesh reconstruction, and texture atlas export", |
| | | "model": "none (classical multi-view stereo CPU baseline)", "device": f"CPU (OpenMVS max_threads={args.threads})", |
| | | "thresholds": {"max_threads": args.threads, "dense_resolution_level": args.dense_resolution_level, "dense_max_resolution_px": args.max_resolution, "dense_min_resolution_px": args.dense_min_resolution, "dense_number_views": args.dense_number_views, "dense_fusion_min_views": args.dense_number_views_fuse, "mesh_target_faces": args.target_faces, "mesh_close_holes": args.mesh_close_holes, "mesh_smooth": args.mesh_smooth, "texture_outlier_threshold": args.texture_outlier_threshold}, |
| | | "method": f"COLMAP CPU sparse SfM, RGB processing-copy undistortion, OpenMVS {'CUDA' if args.dense_device == 'cuda' else 'CPU'} PatchMatch depth fusion, CPU mesh reconstruction, and CPU texture atlas export", |
| | | "model": "none (classical multi-view stereo)", "device": f"CPU preparation/mesh/texture + {'CUDA' if args.dense_device == 'cuda' else 'CPU'} OpenMVS densification (max_threads={args.threads})", |
| | | "execution_stages": {"sparse_model_input": "CPU", "image_conversion_and_undistortion": "CPU", "dense_mvs": "CUDA" if args.dense_device == "cuda" else "CPU", "mesh_reconstruction": "CPU", "texture_export": "CPU"}, |
| | | "device_selection": {"requested_dense_device": args.dense_device, "actual_dense_device": args.dense_device, "fallback_used": False, "policy": "CPU is the complete default path. CUDA is accepted only after OpenMVS reports CUDA/GPU execution; this script never silently changes a requested CUDA run to CPU."}, |
| | | "thresholds": {"max_threads": args.threads, "dense_device": args.dense_device, "dense_resolution_level": args.dense_resolution_level, "dense_max_resolution_px": args.max_resolution, "dense_min_resolution_px": args.dense_min_resolution, "dense_number_views": args.dense_number_views, "dense_fusion_min_views": args.dense_number_views_fuse, "mesh_target_faces": args.target_faces, "mesh_close_holes": args.mesh_close_holes, "mesh_smooth": args.mesh_smooth, "texture_outlier_threshold": args.texture_outlier_threshold}, |
| | | "dense_photo_reconstruction": {"input_images": len(images), "registered_images": len(reconstruction.images), "sparse_points": len(reconstruction.points3D), "dense_points": ply_vertex_count(args.output / "dense.ply"), "dense_point_cloud_file": "dense.ply", "mesh_file": "mesh.ply", "textured_model_file": "textured_preview_filtered.glb", "geometry_preview_model_file": "geometry_preview.glb", "geometry_preview_faces": geometry_preview_faces, "point_color_preview_model_file": "point_color_preview.glb", "point_color_preview_faces": point_color_preview_faces, "source_textured_model_file": "textured.glb", "embedded_source_textured_model_file": "textured_embedded.glb", "texture_preview_filter_report": "textured_preview_filter_report.json", "texture_file": texture_files[0] if texture_files else None, "texture_files": texture_files, "mesh_vertices": sum(len(item.vertices) for item in geometry), "mesh_faces": sum(len(item.faces) for item in geometry), "preview_mesh_faces": preview_report["kept_faces"], "preview_mesh_face_ratio": preview_report["kept_face_ratio"], "coordinate_basis": "local_sfm_coordinates_arbitrary_scale_and_orientation", "processed_rgb_conversion_manifest": "processed_rgb_images/conversion_manifest.json"}, |
| | | "elapsed_seconds": round(time.perf_counter() - started, 3), |
| | | "limitations": ["CPU MVS baseline only; review visual quality before raising resolution or treating the output as operational.", "Source imagery remains unchanged; processing copies have no survey-grade coordinate claim.", "OpenMVS is AGPL-3.0-only and needs a separate commercial license assessment."], |
| | | "limitations": ["CUDA mode accelerates only OpenMVS depth estimation and fusion; feature preparation, meshing and texture export remain CPU stages.", "Review visual quality before raising resolution or treating the output as operational.", "Source imagery remains unchanged; processing copies have no survey-grade coordinate claim.", "OpenMVS is AGPL-3.0-only and needs a separate commercial license assessment."], |
| | | } |
| | | (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, indent=2)) |
| | |
| | | CAMERA_MODELS = {"SIMPLE_RADIAL", "OPENCV"} |
| | | |
| | | |
| | | def write_progress(path: Path | None, percent: int, stage: str, message: str, image_count: int) -> None: |
| | | """Publish completed milestones for the local asynchronous console.""" |
| | | if path is None: |
| | | return |
| | | path.parent.mkdir(parents=True, exist_ok=True) |
| | | temporary = path.with_suffix(f"{path.suffix}.tmp") |
| | | temporary.write_text(json.dumps({"percent": percent, "stage": stage, "message": message, "inputImages": image_count, "updatedAt": datetime.now(UTC).isoformat(), "estimate": True}, ensure_ascii=False), encoding="utf-8") |
| | | temporary.replace(path) |
| | | |
| | | |
| | | def collect_images(input_path: Path) -> list[Path]: |
| | | candidates = [input_path] if input_path.is_file() else sorted(input_path.iterdir()) if input_path.is_dir() else [] |
| | | images = [path for path in candidates if path.is_file() and path.suffix.lower() in SUPPORTED_IMAGE_SUFFIXES] |
| | |
| | | matching_mode: str = "sequential", |
| | | matching_neighbors: int = 10, |
| | | camera_model: str = "SIMPLE_RADIAL", |
| | | progress_file: Path | None = None, |
| | | ) -> dict[str, Any]: |
| | | if max_image_size < 640 or max_features < 1024 or initial_min_triangulation_angle <= 0 or prior_position_loss_scale_m <= 0 or (focal_length_px is not None and focal_length_px <= 0): |
| | | raise ValueError("max image size must be at least 640, max features at least 1024, and triangulation/prior-position scales must be positive.") |
| | |
| | | raise ValueError("Output directory is not empty; use a new output directory.") |
| | | images = collect_images(input_path) |
| | | output_dir.mkdir(parents=True, exist_ok=True) |
| | | write_progress(progress_file, 5, "sparse_prepare", "已验证照片,准备提取特征。", len(images)) |
| | | started = time.perf_counter() |
| | | database_path = output_dir / "database.db" |
| | | model_root = output_dir / "sparse_model" |
| | |
| | | |
| | | image_root = images[0].parent |
| | | image_names = [image.name for image in images] |
| | | write_progress(progress_file, 10, "sparse_features", "正在提取 CPU SIFT 特征。", len(images)) |
| | | pycolmap.extract_features( |
| | | database_path, |
| | | image_root, |
| | |
| | | database.close() |
| | | if (use_position_priors or matching_mode == "spatial") and pose_prior_count != len(images): |
| | | raise ValueError(f"RTK/GPS position priors were requested, but only {pose_prior_count}/{len(images)} source images provide valid positions.") |
| | | write_progress(progress_file, 25, "sparse_matching", "正在进行图像特征配对和几何验证。", len(images)) |
| | | match_images(database_path, len(images), matching_options, matching_mode, matching_neighbors) |
| | | mapping_options = pycolmap.IncrementalPipelineOptions() |
| | | mapping_options.min_model_size = min(3, len(images)) |
| | |
| | | mapping_options.prior_position_loss_scale = prior_position_loss_scale_m |
| | | mapping_options.ba_refine_focal_length = not lock_focal_length_prior |
| | | mapping_options.mapper.abs_pose_refine_focal_length = not lock_focal_length_prior |
| | | write_progress(progress_file, 40, "sparse_mapping", "正在增量建图和相机位姿优化。", len(images)) |
| | | models = pycolmap.incremental_mapping(database_path, image_root, model_root, mapping_options) |
| | | if not models: |
| | | raise ValueError("SfM could not register a reconstruction model. Check image overlap, blur, and repeated texture.") |
| | |
| | | selected_path = model_root / str(selected_model) |
| | | selected_path.mkdir(parents=True, exist_ok=True) |
| | | reconstruction.write(selected_path) |
| | | write_progress(progress_file, 52, "sparse_export", "稀疏模型已完成,正在导出点云和相机位姿。", len(images)) |
| | | point_cloud_path = output_dir / "sparse_points.ply" |
| | | reconstruction.export_PLY(point_cloud_path) |
| | | preview_file = _write_sparse_preview(reconstruction, output_dir) |
| | |
| | | parser.add_argument("--matching-mode", choices=sorted(MATCHING_MODES), default="sequential", help="Image pairing strategy. Spatial matching uses source GPS/RTK position priors.") |
| | | parser.add_argument("--matching-neighbors", type=int, default=10, help="Nearest images per photo for sequential or spatial matching.") |
| | | parser.add_argument("--camera-model", choices=sorted(CAMERA_MODELS), default="SIMPLE_RADIAL", help="COLMAP camera model. OPENCV is the closest local counterpart to ODX's brown projection model.") |
| | | parser.add_argument("--progress-file", type=Path, help="Optional JSON progress file for the local asynchronous console.") |
| | | args = parser.parse_args() |
| | | try: |
| | | result = reconstruct_photos(args.input, args.output, args.max_image_size, args.max_features, args.initial_min_triangulation_angle, args.use_position_priors, args.prior_position_loss_scale_m, args.lock_focal_length_prior, args.focal_length_px, args.matching_mode, args.matching_neighbors, args.camera_model) |
| | | result = reconstruct_photos(args.input, args.output, args.max_image_size, args.max_features, args.initial_min_triangulation_angle, args.use_position_priors, args.prior_position_loss_scale_m, args.lock_focal_length_prior, args.focal_length_px, args.matching_mode, args.matching_neighbors, args.camera_model, args.progress_file) |
| | | except (ValueError, RuntimeError) as error: |
| | | raise SystemExit(str(error)) from error |
| | | metadata = { |
| | |
| | | semantic_colors = np.asarray([np.asarray(SEMANTIC_CLASSES[int(code)]["color"], dtype=float) / 255.0 for code in semantic_classes]) |
| | | downsampled.colors = o3d.utility.Vector3dVector(semantic_colors) |
| | | o3d.io.write_point_cloud(str(classified), downsampled, write_ascii=False) |
| | | # Retain sparse candidate classes in the browser preview. Uniform sampling |
| | | # made thin conductors disappear even when they were correctly labelled. |
| | | candidate_indices = np.flatnonzero(np.isin(semantic_classes, [15, 16])) |
| | | remaining = max(0, min(len(values), 400_000) - len(candidate_indices)) |
| | | other_indices = np.flatnonzero(~np.isin(semantic_classes, [15, 16])) |
| | | sampled_other = other_indices[np.linspace(0, len(other_indices) - 1, min(remaining, len(other_indices)), dtype=int)] if len(other_indices) and remaining else np.array([], dtype=int) |
| | | preview_indices = np.sort(np.concatenate((candidate_indices, sampled_other))) |
| | | # The console now renders the entire processed point set, so sparse |
| | | # conductors and all other classes are retained without preview sampling. |
| | | preview_indices = np.arange(len(values), dtype=np.int64) |
| | | preview_cloud = downsampled.select_by_index(preview_indices.tolist()) |
| | | o3d.io.write_point_cloud(str(semantic_preview_cloud), preview_cloud, write_ascii=False) |
| | | # Annotation must retain observed RGB. The semantic preview uses rule |
| | |
| | | "classified_point_cloud": classified.name, |
| | | "semantic_preview_point_cloud": semantic_preview_cloud.name, |
| | | "semantic_annotation_source_point_cloud": annotation_source_cloud.name, |
| | | "semantic_annotation_source_kind": "deterministic 400000-point RGB/XYZ subset from original input after voxel sampling; no semantic display colours", |
| | | "semantic_annotation_source_kind": "complete processed RGB/XYZ point set after voxel sampling; no semantic display colours", |
| | | "semantic_preview_points": int(len(preview_indices)), |
| | | "semantic_classified_las": semantic_las.name, |
| | | "semantic_raster_file": semantic_raster_path.name, |
| | |
| | | |
| | | |
| | | class ReliableTexturePreviewTests(unittest.TestCase): |
| | | def test_cuda_dense_log_requires_explicit_cuda_or_gpu_evidence(self) -> None: |
| | | MODULE.verify_cuda_dense_log("OpenMVS CUDA device 0 initialized") |
| | | MODULE.verify_cuda_dense_log("GPU depth maps are enabled") |
| | | with self.assertRaisesRegex(RuntimeError, "did not report"): |
| | | MODULE.verify_cuda_dense_log("OpenMVS x64 v2.4.0") |
| | | |
| | | def test_openmvs_tool_log_uses_the_latest_timestamped_log(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temporary: |
| | | directory = Path(temporary) |
| | | (directory / "DensifyPointCloud-older.log").write_text("CPU", encoding="utf-8") |
| | | (directory / "DensifyPointCloud-newer.log").write_text("CUDA device 0", encoding="utf-8") |
| | | self.assertEqual(MODULE.openmvs_tool_log(directory, "DensifyPointCloud"), "CUDA device 0") |
| | | |
| | | def test_openmvs_failure_includes_native_log(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temporary: |
| | | directory = Path(temporary) |
| | | (directory / "DensifyPointCloud-native.log").write_text("CUDA error (code 801)", encoding="utf-8") |
| | | error = MODULE.with_openmvs_log(RuntimeError("DensifyPointCloud.exe failed"), directory, "DensifyPointCloud") |
| | | self.assertIn("CUDA error (code 801)", str(error)) |
| | | |
| | | def test_external_tool_paths_are_absolute(self) -> None: |
| | | args = Namespace(input=Path("images"), sparse_model=Path("sparse"), output=Path("output"), openmvs_bin=Path("tools")) |
| | | MODULE.resolve_external_tool_paths(args) |
| New file |
| | |
| | | from __future__ import annotations |
| | | |
| | | import sys |
| | | import unittest |
| | | from pathlib import Path |
| | | |
| | | import numpy as np |
| | | |
| | | sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
| | | from diagnose_multimodal_projection import z_buffer_render # noqa: E402 |
| | | |
| | | |
| | | class ProjectionDiagnosticTests(unittest.TestCase): |
| | | def test_z_buffer_keeps_nearest_colour_for_one_pixel(self) -> None: |
| | | pixels = np.array([[2.0, 3.0], [2.0, 3.0], [1.0, 1.0]]) |
| | | depth = np.array([8.0, 2.0, 3.0]) |
| | | colors = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) |
| | | render, mask = z_buffer_render(pixels, depth, np.array([True, True, True]), colors, 6, 6) |
| | | np.testing.assert_array_equal(render[3, 2], [0, 255, 0]) |
| | | self.assertEqual(int(mask.sum() / 255), 2) |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | unittest.main() |
| New file |
| | |
| | | from __future__ import annotations |
| | | |
| | | import sys |
| | | import unittest |
| | | from pathlib import Path |
| | | |
| | | import numpy as np |
| | | |
| | | sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
| | | from prepare_multimodal_pointcloud_dataset import Calibration, Shot, canonical_camera_name, normalise_las_rgb, project_points # noqa: E402 |
| | | |
| | | |
| | | class MultimodalPreparationTests(unittest.TestCase): |
| | | def test_camera_name_normalises_odm_version_prefix(self) -> None: |
| | | self.assertEqual(canonical_camera_name("v2 DJI M4TD 4032 3024 Brown 0.6666"), "dji m4td 4032 3024 brown 0.6666") |
| | | |
| | | def test_project_points_uses_camera_centre_and_positive_depth(self) -> None: |
| | | calibration = Calibration("test", 100, 100, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) |
| | | shot = Shot("test.jpg", calibration, np.zeros(3), np.zeros(3), 0.0, 0.0, 0.0) |
| | | pixels, inside = project_points(np.array([[0.0, 0.0, 2.0], [3.0, 0.0, 2.0], [0.0, 0.0, -2.0]]), shot) |
| | | self.assertTrue(inside[0]) |
| | | self.assertFalse(inside[1]) |
| | | self.assertFalse(inside[2]) |
| | | np.testing.assert_allclose(pixels[0], [50.0, 50.0]) |
| | | |
| | | def test_las_rgb_normalisation_detects_8_bit_values_in_16_bit_dimensions(self) -> None: |
| | | values = np.array([[0, 127, 255], [255, 64, 32]], dtype=np.uint16) |
| | | np.testing.assert_allclose(normalise_las_rgb(values), [[0.0, 127 / 255, 1.0], [1.0, 64 / 255, 32 / 255]]) |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | unittest.main() |
| New file |
| | |
| | | from __future__ import annotations |
| | | |
| | | import sys |
| | | import json |
| | | import tempfile |
| | | import unittest |
| | | from pathlib import Path |
| | | |
| | | import numpy as np |
| | | import open3d as o3d |
| | | |
| | | sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
| | | from prepare_multiview_point_features import FEATURE_NAMES, add_annotation_source_from_existing_dataset, photo_features, z_buffer_visible_indices # noqa: E402 |
| | | |
| | | |
| | | class MultiviewPointFeatureTests(unittest.TestCase): |
| | | def test_z_buffer_keeps_nearest_point_per_two_pixel_cell(self) -> None: |
| | | pixels = np.array([[2.0, 2.0], [3.0, 3.0], [8.0, 4.0]]) |
| | | depths = np.array([8.0, 2.0, 3.0]) |
| | | visible = z_buffer_visible_indices(pixels, depths, np.array([True, True, True]), width=10, cell_size=2) |
| | | np.testing.assert_array_equal(visible, [1, 2]) |
| | | |
| | | def test_photo_features_are_bounded_and_have_stable_contract(self) -> None: |
| | | photo = np.array([[[0, 0, 0], [255, 0, 0]], [[0, 255, 0], [0, 0, 255]]], dtype=np.uint8) |
| | | values = photo_features(photo) |
| | | self.assertEqual(values.shape, (2, 2, len(FEATURE_NAMES))) |
| | | self.assertTrue(np.isfinite(values).all()) |
| | | self.assertTrue(((values >= 0.0) & (values <= 1.0)).all()) |
| | | |
| | | def test_annotation_source_preserves_dataset_order_and_original_rgb(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | output = Path(temp_dir) |
| | | xyz = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float64) |
| | | rgb = np.array([[0.1, 0.2, 0.3], [0.8, 0.7, 0.6]], dtype=np.float32) |
| | | np.savez_compressed(output / "multiview-point-features.npz", xyz=xyz, las_rgb=rgb) |
| | | (output / "run_metadata.json").write_text(json.dumps({"capability": "05-3d-pointcloud", "artifacts": {}}), encoding="utf-8") |
| | | |
| | | result = add_annotation_source_from_existing_dataset(output) |
| | | source = output / "multiview-annotation-source.ply" |
| | | cloud = o3d.io.read_point_cloud(str(source)) |
| | | metadata = json.loads((output / "run_metadata.json").read_text(encoding="utf-8")) |
| | | |
| | | self.assertEqual(result["point_count"], 2) |
| | | np.testing.assert_allclose(np.asarray(cloud.points), xyz) |
| | | np.testing.assert_allclose(np.asarray(cloud.colors), rgb, atol=1 / 255) |
| | | self.assertEqual(metadata["annotation_source"]["point_count"], 2) |
| | | self.assertEqual(metadata["annotation_source"]["point_cloud"], source.name) |
| | | self.assertEqual(metadata["annotation_source"]["feature_dataset"], "multiview-point-features.npz") |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | unittest.main() |
| New file |
| | |
| | | from __future__ import annotations |
| | | |
| | | import json |
| | | import sys |
| | | import tempfile |
| | | import unittest |
| | | from pathlib import Path |
| | | from unittest import mock |
| | | |
| | | import numpy as np |
| | | import open3d as o3d |
| | | |
| | | sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
| | | from train_multiview_point_transformer import ( # noqa: E402 |
| | | MultiviewLocalPointTransformer, |
| | | load_fusion_features, |
| | | main, |
| | | sha256, |
| | | spatial_split, |
| | | ) |
| | | |
| | | |
| | | class MultiviewPointTransformerTests(unittest.TestCase): |
| | | def make_fixture(self, root: Path) -> Path: |
| | | artifact = root / "multiview-feature-fixture" |
| | | artifact.mkdir() |
| | | # Blocks 0/6/3 deterministically map to train/validation/test. |
| | | xyz, labels = [], [] |
| | | for code, offset in ((5, 0.0), (17, 0.2)): |
| | | for block in (0, 3, 6): |
| | | for sample in range(200): |
| | | xyz.append([block * 100.0 + 10.0 + (sample % 10) * 0.01 + offset, (sample // 10) * 0.01, float(code) / 16.0]) |
| | | labels.append(code) |
| | | xyz_array = np.asarray(xyz, dtype=np.float64) |
| | | rgb = np.column_stack((np.asarray(labels) == 5, np.asarray(labels) == 17, np.full(len(labels), 0.3))).astype(np.float32) |
| | | # This unlabeled extent point fixes the eight XY blocks at 100-unit width. |
| | | xyz_array = np.vstack((xyz_array, [[810.0, 0.0, 0.0]])) |
| | | rgb = np.vstack((rgb, [[0.2, 0.2, 0.2]])).astype(np.float32) |
| | | cloud = o3d.geometry.PointCloud() |
| | | cloud.points = o3d.utility.Vector3dVector(xyz_array) |
| | | cloud.colors = o3d.utility.Vector3dVector(rgb) |
| | | source = artifact / "multiview-annotation-source.ply" |
| | | self.assertTrue(o3d.io.write_point_cloud(str(source), cloud, write_ascii=False)) |
| | | feature_mean = np.column_stack((rgb, np.zeros((len(xyz_array), 5), dtype=np.float32))) |
| | | feature_stddev = np.full((len(xyz_array), 8), 0.05, dtype=np.float32) |
| | | dataset = artifact / "multiview-point-features.npz" |
| | | np.savez_compressed(dataset, xyz=xyz_array, las_rgb=rgb, photo_feature_mean=feature_mean, photo_feature_stddev=feature_stddev, visible_view_count=np.full(len(xyz_array), 12, dtype=np.uint16), feature_names=np.asarray(("photo_r", "photo_g", "photo_b", "hue_sin", "hue_cos", "saturation", "gradient_magnitude", "local_intensity_stddev"))) |
| | | (artifact / "run_metadata.json").write_text(json.dumps({"annotation_source": {"kind": "multiview_photo_feature_fusion", "point_cloud": source.name, "feature_dataset": dataset.name, "point_count": len(xyz_array), "point_cloud_sha256": sha256(source), "feature_dataset_sha256": sha256(dataset)}}), encoding="utf-8") |
| | | annotation = root / "annotation.json" |
| | | annotation.write_text(json.dumps({"schema_version": 1, "source_path": str(source), "source_sha256": sha256(source), "labels": [[index, code] for index, code in enumerate(labels)], "class_schema": {"5": {"code": 5, "key": "vegetation", "label": "植被", "color": [59, 163, 87]}, "17": {"code": 17, "key": "transformer", "label": "变压器", "color": [30, 144, 255]}}}), encoding="utf-8") |
| | | return annotation |
| | | |
| | | def test_loads_verified_features_and_local_attention_contract(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | annotation = self.make_fixture(Path(temp_dir)) |
| | | xyz, features, contract = load_fusion_features(annotation) |
| | | self.assertEqual(xyz.shape, (1201, 3)) |
| | | self.assertEqual(features.shape, (1201, 23)) |
| | | self.assertEqual(contract["point_count"], 1201) |
| | | train, validation, test = spatial_split(xyz, np.arange(len(xyz))) |
| | | self.assertTrue(len(train) and len(validation) and len(test)) |
| | | model = MultiviewLocalPointTransformer(features.shape[1], 2) |
| | | logits = model(__import__("torch").from_numpy(features[:4, None, :].repeat(4, axis=1)), __import__("torch").zeros((4, 4, 3))) |
| | | self.assertEqual(tuple(logits.shape), (4, 2)) |
| | | |
| | | def test_cli_writes_multiview_model_and_metrics(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | annotation = self.make_fixture(root) |
| | | output = root / "output" |
| | | with mock.patch.object(sys, "argv", ["trainer", "--annotation", str(annotation), "--output", str(output), "--device", "cpu", "--epochs", "1", "--batch-size", "256", "--neighbours", "4"]): |
| | | self.assertEqual(main(), 0) |
| | | metrics = json.loads((output / "metrics.json").read_text(encoding="utf-8")) |
| | | self.assertTrue((output / "model.pt").is_file()) |
| | | self.assertTrue((output / "predicted-semantic-preview.ply").is_file()) |
| | | self.assertEqual(metrics["model_input_kind"], "multiview_photo_feature_fusion") |
| | | self.assertEqual(metrics["classes"]["17"]["key"], "transformer") |
| | | self.assertIn("not official Point Transformer V3", metrics["model"]) |
| | | |
| | | def test_rejects_changed_feature_dataset(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | annotation = self.make_fixture(Path(temp_dir)) |
| | | dataset = annotation.parent / "multiview-feature-fixture" / "multiview-point-features.npz" |
| | | dataset.write_bytes(b"changed") |
| | | with self.assertRaisesRegex(ValueError, "checksum changed"): |
| | | load_fusion_features(annotation) |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | unittest.main() |
| New file |
| | |
| | | """Train a bounded multi-view point-cloud attention baseline from reviewed labels. |
| | | |
| | | This is deliberately not branded as Point Transformer V3. It is a CPU/CUDA |
| | | compatible local point-attention baseline that consumes the verified feature |
| | | contract produced by ``prepare_multiview_point_features.py``. The architecture |
| | | and its serialized input schema are kept separate so an official Pointcept/PTv3 |
| | | trainer can later consume the same immutable annotation and feature dataset. |
| | | """ |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import copy |
| | | import hashlib |
| | | import json |
| | | import time |
| | | from datetime import UTC, datetime |
| | | from pathlib import Path |
| | | from typing import Any |
| | | |
| | | import numpy as np |
| | | import open3d as o3d |
| | | import torch |
| | | from sklearn.metrics import classification_report, confusion_matrix |
| | | from sklearn.neighbors import NearestNeighbors |
| | | |
| | | |
| | | CLASS_SCHEMA = { |
| | | 1: {"key": "other_unknown", "label": "Other / unknown", "color": [128, 128, 128]}, |
| | | 2: {"key": "ground", "label": "Ground", "color": [151, 111, 51]}, |
| | | 5: {"key": "vegetation", "label": "Vegetation", "color": [59, 163, 87]}, |
| | | 6: {"key": "building_structure", "label": "Building / structure", "color": [224, 115, 55]}, |
| | | 15: {"key": "pole_tower", "label": "Pole / tower", "color": [149, 89, 210]}, |
| | | 16: {"key": "power_line", "label": "Power line", "color": [231, 196, 61]}, |
| | | } |
| | | MIN_POINTS_PER_CLASS = 500 |
| | | PHOTO_FEATURE_NAMES = ( |
| | | "photo_r", "photo_g", "photo_b", "hue_sin", "hue_cos", "saturation", |
| | | "gradient_magnitude", "local_intensity_stddev", |
| | | ) |
| | | |
| | | |
| | | def annotation_schema(record: dict[str, Any]) -> dict[int, dict[str, Any]]: |
| | | """Read the revision's immutable taxonomy snapshot; retain legacy defaults.""" |
| | | schema = {code: {"code": code, **value} for code, value in CLASS_SCHEMA.items()} |
| | | values = record.get("class_schema") |
| | | if not isinstance(values, dict): |
| | | return schema |
| | | for raw_code, raw_value in values.items(): |
| | | try: |
| | | code = int(raw_code) |
| | | except (TypeError, ValueError): |
| | | raise SystemExit("Annotation class schema has an invalid class code.") |
| | | value = raw_value if isinstance(raw_value, dict) else {} |
| | | inherited = schema.get(code, {}) |
| | | key, label, color = value.get("key", inherited.get("key")), value.get("label", inherited.get("label")), value.get("color", inherited.get("color")) |
| | | if not 1 <= code <= 255 or not isinstance(key, str) or not isinstance(label, str) or not isinstance(color, list) or len(color) != 3 or not all(isinstance(item, int) and 0 <= item <= 255 for item in color): |
| | | raise SystemExit("Annotation class schema is incomplete for a labelled class.") |
| | | schema[code] = {"code": code, "key": key, "label": label, "color": color} |
| | | return schema |
| | | |
| | | |
| | | def sha256(path: Path) -> str: |
| | | digest = hashlib.sha256() |
| | | with path.open("rb") as stream: |
| | | for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""): |
| | | digest.update(chunk) |
| | | return digest.hexdigest() |
| | | |
| | | |
| | | def load_fusion_features(annotation: Path) -> tuple[np.ndarray, np.ndarray, dict[str, Any]]: |
| | | """Load only a checksum-linked, fixed-order fusion NPZ beside the annotation PLY.""" |
| | | record = json.loads(annotation.read_text(encoding="utf-8")) |
| | | if record.get("schema_version") != 1 or not isinstance(record.get("labels"), list): |
| | | raise ValueError("Unsupported multimodal annotation schema.") |
| | | source = Path(str(record.get("source_path") or "")) |
| | | if not source.is_file() or sha256(source) != record.get("source_sha256"): |
| | | raise ValueError("Annotation source is unavailable or its checksum changed.") |
| | | metadata = json.loads((source.parent / "run_metadata.json").read_text(encoding="utf-8")) |
| | | contract = metadata.get("annotation_source") |
| | | if not isinstance(contract, dict) or contract.get("kind") != "multiview_photo_feature_fusion": |
| | | raise ValueError("Annotation source is not a verified multi-view fusion sample.") |
| | | if contract.get("point_cloud") != source.name or contract.get("point_cloud_sha256") != record.get("source_sha256"): |
| | | raise ValueError("Annotation source does not match the fusion metadata contract.") |
| | | dataset = source.parent / str(contract.get("feature_dataset") or "") |
| | | if dataset.name != "multiview-point-features.npz" or not dataset.is_file() or sha256(dataset) != contract.get("feature_dataset_sha256"): |
| | | raise ValueError("The linked multi-view feature dataset is unavailable or its checksum changed.") |
| | | with np.load(dataset, allow_pickle=False) as values: |
| | | required = {"xyz", "las_rgb", "photo_feature_mean", "photo_feature_stddev", "visible_view_count", "feature_names"} |
| | | if not required.issubset(values.files): |
| | | raise ValueError("Multi-view feature dataset is missing required arrays.") |
| | | xyz = np.asarray(values["xyz"], dtype=np.float32) |
| | | rgb = np.asarray(values["las_rgb"], dtype=np.float32) |
| | | photo_mean = np.asarray(values["photo_feature_mean"], dtype=np.float32) |
| | | photo_stddev = np.asarray(values["photo_feature_stddev"], dtype=np.float32) |
| | | view_count = np.asarray(values["visible_view_count"], dtype=np.float32).reshape(-1, 1) |
| | | names = tuple(str(value) for value in values["feature_names"].tolist()) |
| | | count = int(contract.get("point_count") or 0) |
| | | if count < 1 or xyz.shape != (count, 3) or rgb.shape != (count, 3) or photo_mean.shape != (count, len(PHOTO_FEATURE_NAMES)) or photo_stddev.shape != photo_mean.shape or view_count.shape != (count, 1): |
| | | raise ValueError("Multi-view arrays do not match the declared point ordering contract.") |
| | | if names != PHOTO_FEATURE_NAMES or not all(np.isfinite(item).all() for item in (xyz, rgb, photo_mean, photo_stddev, view_count)): |
| | | raise ValueError("Multi-view feature arrays have an unsupported schema or non-finite values.") |
| | | center = xyz.mean(axis=0) |
| | | scale = float(max(np.abs(xyz - center).max(), 1e-6)) |
| | | maximum_views = float(max(view_count.max(), 1.0)) |
| | | features = np.column_stack(((xyz - center) / scale, np.clip(rgb, 0.0, 1.0), photo_mean, photo_stddev, np.log1p(view_count) / np.log1p(maximum_views))).astype(np.float32) |
| | | return xyz, features, { |
| | | "source": str(source), |
| | | "source_sha256": str(record["source_sha256"]), |
| | | "feature_dataset": str(dataset), |
| | | "feature_dataset_sha256": str(contract["feature_dataset_sha256"]), |
| | | "point_count": count, |
| | | "feature_names": ["x_normalized", "y_normalized", "z_normalized", "las_r", "las_g", "las_b", *[f"{name}_mean" for name in PHOTO_FEATURE_NAMES], *[f"{name}_stddev" for name in PHOTO_FEATURE_NAMES], "visible_view_count_log_normalized"], |
| | | "normalizer": {"xyz_center": center.tolist(), "xyz_scale": scale, "maximum_visible_views": maximum_views}, |
| | | "labels": record["labels"], |
| | | } |
| | | |
| | | |
| | | def spatial_split(xyz: np.ndarray, indices: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: |
| | | """Assign complete XY blocks to train/validation/test, avoiding local leakage.""" |
| | | block_size = max(float(np.ptp(xyz[:, 0])) / 8.0, float(np.ptp(xyz[:, 1])) / 8.0, 1.0) |
| | | blocks = np.floor(xyz[indices, :2] / block_size).astype(np.int64) |
| | | hashes = (blocks[:, 0] * 73_856_093 + blocks[:, 1] * 19_349_663) % 10 |
| | | return indices[hashes < 7], indices[(hashes >= 7) & (hashes < 9)], indices[hashes >= 9] |
| | | |
| | | |
| | | class LocalPointAttention(torch.nn.Module): |
| | | def __init__(self, dimension: int) -> None: |
| | | super().__init__() |
| | | self.query = torch.nn.Linear(dimension, dimension, bias=False) |
| | | self.key = torch.nn.Linear(dimension, dimension, bias=False) |
| | | self.value = torch.nn.Linear(dimension, dimension, bias=False) |
| | | self.position = torch.nn.Sequential(torch.nn.Linear(3, dimension), torch.nn.ReLU(), torch.nn.Linear(dimension, dimension)) |
| | | self.output = torch.nn.Sequential(torch.nn.Linear(dimension, dimension), torch.nn.ReLU(), torch.nn.Linear(dimension, dimension)) |
| | | self.norm = torch.nn.LayerNorm(dimension) |
| | | |
| | | def forward(self, tokens: torch.Tensor, relative_xyz: torch.Tensor) -> torch.Tensor: |
| | | # tokens is BxKxD; index zero is the labelled/predicted centre point. |
| | | centre = tokens[:, :1] |
| | | position = self.position(relative_xyz) |
| | | query = self.query(centre) |
| | | keys = self.key(tokens) + position |
| | | logits = (query * keys).sum(dim=-1) / (tokens.shape[-1] ** 0.5) |
| | | weights = torch.softmax(logits, dim=1).unsqueeze(-1) |
| | | attended = (weights * (self.value(tokens) + position)).sum(dim=1) |
| | | return self.norm(centre[:, 0] + self.output(attended)) |
| | | |
| | | |
| | | class MultiviewLocalPointTransformer(torch.nn.Module): |
| | | """Two bounded local-attention stages over RGB/XYZ/photo-feature neighbours.""" |
| | | def __init__(self, input_dimension: int, class_count: int, dimension: int = 96) -> None: |
| | | super().__init__() |
| | | self.input = torch.nn.Sequential(torch.nn.Linear(input_dimension, dimension), torch.nn.ReLU(), torch.nn.LayerNorm(dimension)) |
| | | self.attention_one = LocalPointAttention(dimension) |
| | | self.attention_two = LocalPointAttention(dimension) |
| | | self.classifier = torch.nn.Sequential(torch.nn.Linear(dimension, dimension), torch.nn.ReLU(), torch.nn.Dropout(0.15), torch.nn.Linear(dimension, class_count)) |
| | | |
| | | def forward(self, neighbourhood_features: torch.Tensor, relative_xyz: torch.Tensor) -> torch.Tensor: |
| | | tokens = self.input(neighbourhood_features) |
| | | first = self.attention_one(tokens, relative_xyz) |
| | | tokens = tokens.clone() |
| | | tokens[:, 0] = first |
| | | second = self.attention_two(tokens, relative_xyz) |
| | | return self.classifier(second) |
| | | |
| | | |
| | | def classification_metrics(y_true: np.ndarray, y_pred: np.ndarray, classes: list[int], schema: dict[int, dict[str, Any]]) -> dict[str, Any]: |
| | | names = [schema[code]["key"] for code in classes] |
| | | return {"class_codes": classes, "class_keys": names, "confusion_matrix": confusion_matrix(y_true, y_pred, labels=classes).tolist(), "report": classification_report(y_true, y_pred, labels=classes, target_names=names, output_dict=True, zero_division=0), "sample_count": int(len(y_true))} |
| | | |
| | | |
| | | def main() -> int: |
| | | parser = argparse.ArgumentParser(description="Train a multi-view local-attention point-cloud baseline from reviewed labels.") |
| | | parser.add_argument("--annotation", type=Path, required=True) |
| | | parser.add_argument("--output", type=Path, required=True) |
| | | 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=512) |
| | | parser.add_argument("--neighbours", type=int, default=16) |
| | | 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.") |
| | | record = json.loads(args.annotation.read_text(encoding="utf-8")) |
| | | class_schema = annotation_schema(record) |
| | | xyz, features, provenance = load_fusion_features(args.annotation) |
| | | assigned: dict[int, int] = {} |
| | | for item in provenance.pop("labels"): |
| | | if not isinstance(item, list) or len(item) != 2 or not all(isinstance(value, int) for value in item): |
| | | raise SystemExit("Annotation contains an invalid point label.") |
| | | index, code = item |
| | | if not 0 <= index < len(xyz) or code not in class_schema: |
| | | raise SystemExit("Annotation contains an out-of-range point label.") |
| | | assigned[index] = code |
| | | indices = np.asarray(sorted(assigned), dtype=np.int64) |
| | | target = np.asarray([assigned[int(index)] for index in indices], dtype=np.int64) |
| | | classes = sorted(set(target.tolist())) |
| | | counts = {code: int((target == code).sum()) for code in classes} |
| | | if len(classes) < 2 or any(value < MIN_POINTS_PER_CLASS for value in counts.values()): |
| | | raise SystemExit(f"Need at least two classes and {MIN_POINTS_PER_CLASS} confirmed points per class; current={counts}.") |
| | | train, validation, test = spatial_split(xyz, indices) |
| | | splits = {"train": train, "validation": validation, "test": test} |
| | | if any(not len(value) for value in splits.values()) or any(set(target[np.searchsorted(indices, value)].tolist()) != set(classes) for value in splits.values()): |
| | | raise SystemExit("Each labelled class must cover train, validation, and test XY blocks; label more than one spatial area per class.") |
| | | if args.neighbours < 2 or args.neighbours > len(xyz): |
| | | raise SystemExit("neighbours must be between 2 and the fusion point count.") |
| | | neighbour_index = NearestNeighbors(n_neighbors=args.neighbours, algorithm="auto", n_jobs=-1).fit(xyz).kneighbors(xyz, return_distance=False).astype(np.int64) |
| | | relative_xyz = (xyz[neighbour_index] - xyz[:, None, :]).astype(np.float32) |
| | | device_name = "cuda" if args.device == "cuda" or (args.device == "auto" and torch.cuda.is_available()) else "cpu" |
| | | 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(args.seed) |
| | | model = MultiviewLocalPointTransformer(features.shape[1], len(classes)).to(device) |
| | | optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=1e-4) |
| | | code_to_class = {code: value for value, code in enumerate(classes)} |
| | | label_by_index = {int(index): code_to_class[int(code)] for index, code in zip(indices, target, strict=True)} |
| | | weights = torch.tensor([len(train) / max(1, sum(label_by_index[int(index)] == label for index in train)) for label in range(len(classes))], dtype=torch.float32, device=device) |
| | | criterion = torch.nn.CrossEntropyLoss(weight=weights) |
| | | |
| | | def predict(requested: np.ndarray) -> np.ndarray: |
| | | model.eval(); output: list[np.ndarray] = [] |
| | | with torch.no_grad(): |
| | | for start in range(0, len(requested), args.batch_size): |
| | | chosen = requested[start:start + args.batch_size] |
| | | neighbours = neighbour_index[chosen] |
| | | logits = model(torch.from_numpy(features[neighbours]).to(device), torch.from_numpy(relative_xyz[chosen]).to(device)) |
| | | output.append(logits.argmax(dim=1).cpu().numpy()) |
| | | return np.concatenate(output) |
| | | |
| | | started = time.perf_counter(); best_state: dict[str, torch.Tensor] | None = None; best_epoch = 0; best_f1 = -1.0 |
| | | validation_truth = target[np.searchsorted(indices, validation)] |
| | | for epoch in range(1, args.epochs + 1): |
| | | order = np.random.default_rng(args.seed + epoch).permutation(train) |
| | | model.train() |
| | | for start in range(0, len(order), args.batch_size): |
| | | chosen = order[start:start + args.batch_size] |
| | | optimizer.zero_grad(set_to_none=True) |
| | | logits = model(torch.from_numpy(features[neighbour_index[chosen]]).to(device), torch.from_numpy(relative_xyz[chosen]).to(device)) |
| | | labels = torch.from_numpy(np.asarray([label_by_index[int(index)] for index in chosen], dtype=np.int64)).to(device) |
| | | criterion(logits, labels).backward(); optimizer.step() |
| | | validation_prediction = np.asarray([classes[value] for value in predict(validation)], dtype=np.int64) |
| | | value = float(classification_metrics(validation_truth, validation_prediction, classes, class_schema)["report"]["macro avg"]["f1-score"]) |
| | | if value > best_f1: |
| | | best_state = copy.deepcopy(model.state_dict()); best_epoch = epoch; best_f1 = value |
| | | if best_state is None: |
| | | raise RuntimeError("Training did not produce a validation checkpoint.") |
| | | model.load_state_dict(best_state) |
| | | validation_prediction = np.asarray([classes[value] for value in predict(validation)], dtype=np.int64) |
| | | test_truth = target[np.searchsorted(indices, test)] |
| | | test_prediction = np.asarray([classes[value] for value in predict(test)], dtype=np.int64) |
| | | all_prediction = np.asarray([classes[value] for value in predict(np.arange(len(xyz), dtype=np.int64))], dtype=np.uint8) |
| | | args.output.mkdir(parents=True, exist_ok=True) |
| | | cloud = o3d.geometry.PointCloud(); cloud.points = o3d.utility.Vector3dVector(xyz) |
| | | cloud.colors = o3d.utility.Vector3dVector(np.asarray([class_schema[int(code)]["color"] for code in all_prediction], dtype=np.float64) / 255.0) |
| | | o3d.io.write_point_cloud(str(args.output / "predicted-semantic-preview.ply"), cloud, write_ascii=False) |
| | | payload = { |
| | | "capability": "05-3d-pointcloud", "classification": "B", "created_at": datetime.now(UTC).isoformat(), "model_kind": "multiview_local_point_attention_baseline", "model": "MultiviewLocalPointTransformer (two-stage local attention; not official Point Transformer V3)", "model_input_kind": "multiview_photo_feature_fusion", "device": device_name, "torch_version": torch.__version__, "classes": {str(code): class_schema[code] for code in classes}, "label_counts": {str(code): value for code, value in counts.items()}, "split_counts": {key: int(len(value)) for key, value in splits.items()}, "feature_contract": provenance, "parameters": {"epochs": args.epochs, "batch_size": args.batch_size, "neighbours": args.neighbours, "seed": args.seed}, "best_epoch": best_epoch, "best_validation_macro_f1": best_f1, "validation": classification_metrics(validation_truth, validation_prediction, classes, class_schema), "test": classification_metrics(test_truth, test_prediction, classes, class_schema), "elapsed_seconds": round(time.perf_counter() - started, 3), "limitations": ["This is a bounded local-attention baseline, not the official Point Transformer V3 implementation.", "Metrics cover only reviewed labels in this source's held-out XY blocks and do not prove field-wide accuracy.", "The model cannot classify a separately uploaded cloud without its matching multi-view photo feature dataset and camera alignment.", "Do not use predictions as asset inventory, clearance, or inspection conclusions without review."], |
| | | } |
| | | torch.save({"schema_version": 2, "model_kind": payload["model_kind"], "model_input_kind": payload["model_input_kind"], "state_dict": model.cpu().state_dict(), "class_codes": classes, "classes": {str(code): class_schema[code] for code in classes}, "input_dimension": features.shape[1], "neighbours": args.neighbours, "feature_names": provenance["feature_names"], "normalizer": provenance["normalizer"]}, args.output / "model.pt") |
| | | (args.output / "metrics.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | (args.output / "run_metadata.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | print(json.dumps({"model_kind": payload["model_kind"], "best_validation_macro_f1": best_f1, "elapsed_seconds": payload["elapsed_seconds"]}, ensure_ascii=False)) |
| | | return 0 |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | raise SystemExit(main()) |
| | |
| | | return train, validation, test |
| | | |
| | | |
| | | def metrics(y_true: np.ndarray, y_pred: np.ndarray, classes: list[int]) -> dict[str, Any]: |
| | | names = [CLASS_SCHEMA[code]["key"] for code in classes] |
| | | def annotation_schema(annotation: dict[str, Any]) -> dict[int, dict[str, Any]]: |
| | | """Use the immutable revision snapshot, while accepting old six-class revisions.""" |
| | | schema = {code: {"code": code, **value} for code, value in CLASS_SCHEMA.items()} |
| | | values = annotation.get("class_schema") |
| | | if not isinstance(values, dict): |
| | | return schema |
| | | for raw_code, raw_value in values.items(): |
| | | try: |
| | | code = int(raw_code) |
| | | except (TypeError, ValueError): |
| | | raise SystemExit("Annotation class schema has an invalid class code.") |
| | | if not 1 <= code <= 255: |
| | | raise SystemExit("Annotation class schema has a non-LAS-compatible class code.") |
| | | value = raw_value if isinstance(raw_value, dict) else {} |
| | | inherited = schema.get(code, {}) |
| | | key = value.get("key", inherited.get("key")) |
| | | label = value.get("label", inherited.get("label")) |
| | | color = value.get("color", inherited.get("color")) |
| | | if not isinstance(key, str) or not isinstance(label, str) or not isinstance(color, list) or len(color) != 3 or not all(isinstance(item, int) and 0 <= item <= 255 for item in color): |
| | | raise SystemExit("Annotation class schema is incomplete for a labelled class.") |
| | | schema[code] = {"code": code, "key": key, "label": label, "color": color} |
| | | return schema |
| | | |
| | | |
| | | def metrics(y_true: np.ndarray, y_pred: np.ndarray, classes: list[int], schema: dict[int, dict[str, Any]]) -> dict[str, Any]: |
| | | names = [schema[code]["key"] for code in classes] |
| | | return { |
| | | "class_codes": classes, |
| | | "class_keys": names, |
| | |
| | | annotation = json.loads(args.annotation.read_text(encoding="utf-8")) |
| | | if annotation.get("schema_version") != 1: |
| | | raise SystemExit("Unsupported annotation schema.") |
| | | class_schema = annotation_schema(annotation) |
| | | source = Path(str(annotation.get("source_path") or "")) |
| | | if not source.is_file() or sha256(source) != annotation.get("source_sha256"): |
| | | raise SystemExit("Annotation source is unavailable or its checksum changed.") |
| | |
| | | if not isinstance(item, list) or len(item) != 2 or not all(isinstance(value, int) for value in item): |
| | | raise SystemExit("Annotation contains an invalid point label.") |
| | | index, code = item |
| | | if not 0 <= index < len(features) or code not in CLASS_SCHEMA: |
| | | if not 0 <= index < len(features) or code not in class_schema: |
| | | raise SystemExit("Annotation contains an out-of-range point label.") |
| | | assigned[index] = code |
| | | indices = np.asarray(sorted(assigned), dtype=np.int64) |
| | |
| | | optimizer.zero_grad(set_to_none=True) |
| | | criterion(model(x), y).backward() |
| | | optimizer.step() |
| | | validation_metrics = metrics(validation_true, np.asarray([class_codes[value] for value in predict(validation_idx)], dtype=np.int64), class_codes) |
| | | validation_metrics = metrics(validation_true, np.asarray([class_codes[value] for value in predict(validation_idx)], dtype=np.int64), class_codes, class_schema) |
| | | macro_f1 = float(validation_metrics["report"]["macro avg"]["f1-score"]) |
| | | if macro_f1 > best_validation_macro_f1: |
| | | best_epoch = epoch |
| | |
| | | args.output.mkdir(parents=True, exist_ok=True) |
| | | predicted = o3d.geometry.PointCloud() |
| | | predicted.points = o3d.utility.Vector3dVector(xyz) |
| | | predicted.colors = o3d.utility.Vector3dVector(np.asarray([CLASS_SCHEMA[int(code)]["color"] for code in all_pred], dtype=np.float64) / 255.0) |
| | | predicted.colors = o3d.utility.Vector3dVector(np.asarray([class_schema[int(code)]["color"] for code in all_pred], dtype=np.float64) / 255.0) |
| | | o3d.io.write_point_cloud(str(args.output / "predicted-semantic-preview.ply"), predicted, write_ascii=False) |
| | | payload = { |
| | | "capability": "05-3d-pointcloud", "classification": "B", "created_at": datetime.now(UTC).isoformat(), |
| | | "model": "PointWiseNet shared MLP (human-confirmed point labels)", "device": device_name, |
| | | "torch_version": torch.__version__, "annotation": str(args.annotation), "source": str(source), |
| | | "source_sha256": annotation["source_sha256"], "classes": {str(code): CLASS_SCHEMA[code] for code in class_codes}, |
| | | "source_sha256": annotation["source_sha256"], "classes": {str(code): class_schema[code] for code in class_codes}, |
| | | "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), |
| | | "validation": metrics(validation_true, validation_pred, class_codes, class_schema), "test": metrics(test_true, test_pred, class_codes, class_schema), |
| | | "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.", "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") |
| | | torch.save({"state_dict": model.cpu().state_dict(), "class_codes": class_codes, "classes": {str(code): class_schema[code] for code in class_codes}, "normalizer": normalizer, "schema_version": 2}, args.output / "model.pt") |
| | | (args.output / "metrics.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | print(json.dumps(payload, ensure_ascii=False)) |
| | | return 0 |
| | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import ast |
| | | import base64 |
| | | import binascii |
| | | import hashlib |
| | | import json |
| | | import math |
| | | import os |
| | | import re |
| | | import shutil |
| | | import subprocess |
| | | import threading |
| | | import zipfile |
| | | from datetime import UTC, datetime |
| | | from http import HTTPStatus |
| | | from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer |
| | |
| | | MAX_SEGMENTATION_IMAGES_PER_RUN = 6 |
| | | MAX_MEASUREMENT_RASTERS_PER_RUN = 4 |
| | | MAX_POINTCLOUDS_PER_RUN = 2 |
| | | MAX_PHOTO_RECONSTRUCTION_IMAGES_PER_RUN = 30 |
| | | PHOTO_RECONSTRUCTION_TIMEOUT = 7200 |
| | | MAX_PHOTO_RECONSTRUCTION_IMAGES_PER_RUN = 1_000 |
| | | PHOTO_RECONSTRUCTION_TIMEOUT = 86_400 |
| | | RISK_RULE_REQUIRED_FILES = {"observations", "zones", "rules"} |
| | | MAX_ANOMALY_IMAGES_PER_ROLE = 6 |
| | | CHANGE_THRESHOLD_DEFAULT = 0.5 |
| | |
| | | POINTCLOUD_TRAINING_JOBS_LOCK = threading.Lock() |
| | | POINTCLOUD_INFERENCE_JOBS: dict[str, dict[str, Any]] = {} |
| | | POINTCLOUD_INFERENCE_JOBS_LOCK = threading.Lock() |
| | | MAX_ANNOTATION_LABELS = 400_000 |
| | | POINTCLOUD_CLASS_CODES = {1, 2, 5, 6, 15, 16} |
| | | POINTCLOUD_ANNOTATION_SOURCE_JOBS: dict[str, dict[str, Any]] = {} |
| | | POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK = threading.Lock() |
| | | POINTCLOUD_DETAIL_REQUEST_LOCK = threading.Lock() |
| | | MAX_POINTCLOUD_DETAIL_RADIUS = 10_000_000.0 |
| | | DEFAULT_POINTCLOUD_ANNOTATION_CLASSES = ( |
| | | {"code": 1, "key": "other_unknown", "label": "其他/未知", "color": [128, 128, 128], "builtIn": True}, |
| | | {"code": 2, "key": "ground", "label": "地面", "color": [151, 111, 51], "builtIn": True}, |
| | | {"code": 5, "key": "vegetation", "label": "植被", "color": [59, 163, 87], "builtIn": True}, |
| | | {"code": 6, "key": "building_structure", "label": "建筑物", "color": [224, 115, 55], "builtIn": True}, |
| | | {"code": 15, "key": "pole_tower", "label": "杆塔", "color": [149, 89, 210], "builtIn": True}, |
| | | {"code": 16, "key": "power_line", "label": "电线", "color": [231, 196, 61], "builtIn": True}, |
| | | ) |
| | | POINTCLOUD_CLASS_CODES = {item["code"] for item in DEFAULT_POINTCLOUD_ANNOTATION_CLASSES} |
| | | ANNOTATION_CLASS_KEY = re.compile(r"^[a-z][a-z0-9_]{0,47}$") |
| | | POINTCLOUD_CPU_ENVIRONMENT = "05-3d-pointcloud" |
| | | POINTCLOUD_GPU_ENVIRONMENT = "05-3d-pointcloud-gpu" |
| | | OBJECT_DETECTION_CPU_ENVIRONMENT = "01-object-detection" |
| | | OBJECT_DETECTION_GPU_ENVIRONMENT = "01-object-detection-cuda" |
| | | CHANGE_DETECTION_CPU_ENVIRONMENT = "00-change-detection" |
| | | CHANGE_DETECTION_GPU_ENVIRONMENT = "00-change-detection-cuda" |
| | | COMPUTE_DEVICES = {"auto", "cpu", "cuda"} |
| | | |
| | | |
| | | class ApiError(ValueError): |
| | |
| | | |
| | | |
| | | def relative_path(root: Path, path: Path) -> str: |
| | | return path.relative_to(root).as_posix() |
| | | # Windows may present a temporary root with its 8.3 spelling while an |
| | | # input directory has already been resolved to its long spelling. |
| | | return path.resolve().relative_to(root.resolve()).as_posix() |
| | | |
| | | |
| | | def load_json(path: Path) -> dict[str, Any]: |
| | |
| | | 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. |
| | | def select_execution_environment(root: Path, requested_device: str, cpu_environment: str, gpu_environment: str, label: str) -> dict[str, Any]: |
| | | """Choose an allowlisted CPU/CUDA interpreter and retain the selection evidence.""" |
| | | if requested_device not in COMPUTE_DEVICES: |
| | | raise ApiError(f"{label} device must be auto, cpu, or cuda.") |
| | | cpu_python = root / ".venvs" / cpu_environment / "Scripts" / "python.exe" |
| | | gpu_python = root / ".venvs" / gpu_environment / "Scripts" / "python.exe" |
| | | if requested_device == "cpu": |
| | | if not cpu_python.is_file(): |
| | | raise ApiError(f"{label} CPU virtual environment is unavailable. Run the capability setup first.") |
| | | return {"python": str(cpu_python), "device": "cpu", "environment": cpu_environment, "torchVersion": "unknown", "requestedDevice": "cpu", "fallbackUsed": False, "fallbackReason": None} |
| | | |
| | | 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(): |
| | | probe_reason = "Fixed CUDA virtual environment is unavailable." |
| | | if 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__}))"], |
| | |
| | | timeout=20, |
| | | check=False, |
| | | ) |
| | | payload = json.loads(probe.stdout.strip().splitlines()[-1]) if probe.returncode == 0 and probe.stdout.strip() else {} |
| | | if probe.returncode == 0 and probe.stdout.strip(): |
| | | payload = json.loads(probe.stdout.strip().splitlines()[-1]) |
| | | 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"]} |
| | | return {"python": str(gpu_python), "device": "cuda", "environment": gpu_environment, "torchVersion": payload["torch"], "requestedDevice": requested_device, "fallbackUsed": False, "fallbackReason": None} |
| | | probe_reason = "CUDA probe reports that PyTorch cannot use CUDA." |
| | | else: |
| | | probe_reason = "CUDA probe process did not complete successfully." |
| | | except (OSError, subprocess.SubprocessError, json.JSONDecodeError, IndexError): |
| | | pass |
| | | probe_reason = "CUDA probe could not return a valid result." |
| | | if requested_device == "cuda": |
| | | raise ApiError("CUDA was requested, but the fixed point-cloud GPU environment is unavailable.") |
| | | raise ApiError(f"CUDA was requested for {label}, but it is unavailable: {probe_reason}") |
| | | 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"} |
| | | raise ApiError(f"{label} CPU virtual environment is unavailable. Run the capability setup first.") |
| | | return {"python": str(cpu_python), "device": "cpu", "environment": cpu_environment, "torchVersion": "unknown", "requestedDevice": "auto", "fallbackUsed": True, "fallbackReason": probe_reason} |
| | | |
| | | |
| | | def object_detection_execution_environment(root: Path) -> dict[str, str]: |
| | | """Choose the fixed object-detection CUDA environment only after probing it.""" |
| | | cpu_python = root / ".venvs" / OBJECT_DETECTION_CPU_ENVIRONMENT / "Scripts" / "python.exe" |
| | | gpu_python = root / ".venvs" / OBJECT_DETECTION_GPU_ENVIRONMENT / "Scripts" / "python.exe" |
| | | if 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": OBJECT_DETECTION_GPU_ENVIRONMENT, "torchVersion": payload["torch"]} |
| | | except (OSError, subprocess.SubprocessError, json.JSONDecodeError, IndexError): |
| | | pass |
| | | if not cpu_python.is_file(): |
| | | raise ApiError("Object-detection CPU virtual environment is unavailable. Run the capability setup first.") |
| | | return {"python": str(cpu_python), "device": "cpu", "environment": OBJECT_DETECTION_CPU_ENVIRONMENT, "torchVersion": "unknown"} |
| | | def pointcloud_execution_environment(root: Path, requested_device: str = "auto") -> dict[str, Any]: |
| | | return select_execution_environment(root, requested_device, POINTCLOUD_CPU_ENVIRONMENT, POINTCLOUD_GPU_ENVIRONMENT, "Point-cloud") |
| | | |
| | | |
| | | def change_detection_execution_environment(root: Path) -> dict[str, str]: |
| | | """Choose the fixed ChangeStar CUDA environment only after probing it.""" |
| | | cpu_python = root / ".venvs" / CHANGE_DETECTION_CPU_ENVIRONMENT / "Scripts" / "python.exe" |
| | | gpu_python = root / ".venvs" / CHANGE_DETECTION_GPU_ENVIRONMENT / "Scripts" / "python.exe" |
| | | if 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": CHANGE_DETECTION_GPU_ENVIRONMENT, "torchVersion": payload["torch"]} |
| | | except (OSError, subprocess.SubprocessError, json.JSONDecodeError, IndexError): |
| | | pass |
| | | if not cpu_python.is_file(): |
| | | raise ApiError("Change-detection CPU virtual environment is unavailable. Run the capability setup first.") |
| | | return {"python": str(cpu_python), "device": "cpu", "environment": CHANGE_DETECTION_CPU_ENVIRONMENT, "torchVersion": "unknown"} |
| | | def object_detection_execution_environment(root: Path, requested_device: str = "auto") -> dict[str, Any]: |
| | | return select_execution_environment(root, requested_device, OBJECT_DETECTION_CPU_ENVIRONMENT, OBJECT_DETECTION_GPU_ENVIRONMENT, "Object-detection") |
| | | |
| | | |
| | | def change_detection_execution_environment(root: Path, requested_device: str = "auto") -> dict[str, Any]: |
| | | return select_execution_environment(root, requested_device, CHANGE_DETECTION_CPU_ENVIRONMENT, CHANGE_DETECTION_GPU_ENVIRONMENT, "Change-detection") |
| | | |
| | | |
| | | def record_execution_metadata(path: Path, execution: dict[str, Any]) -> None: |
| | | """Preserve requested device, actual device and an automatic CPU fallback reason.""" |
| | | metadata = load_json(path) |
| | | metadata["requested_device"] = execution["requestedDevice"] |
| | | metadata["device"] = execution["device"] |
| | | metadata["execution"] = { |
| | | "requested_device": execution["requestedDevice"], |
| | | "actual_device": execution["device"], |
| | | "environment": execution["environment"], |
| | | "torch_version": execution["torchVersion"], |
| | | "fallback_used": execution["fallbackUsed"], |
| | | "fallback_reason": execution["fallbackReason"], |
| | | } |
| | | path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | |
| | | |
| | | def trajectory_runs(root: Path) -> list[dict[str, Any]]: |
| | |
| | | "artifactRoot": case["artifactRoot"], "file": name, "url": f"/{case['artifactRoot']}/{name}", |
| | | "sha256": file_sha256(path), "pointCount": int(cloud.get("semantic_preview_points") or 0), |
| | | "sourceKind": str(cloud.get("semantic_annotation_source_kind") or "generated point-cloud preview"), |
| | | "detailAvailable": False, "detailFile": None, |
| | | }) |
| | | sources.extend(standalone_pointcloud_annotation_sources(root)) |
| | | sources.extend(multiview_pointcloud_annotation_sources(root)) |
| | | return sources |
| | | |
| | | |
| | | def standalone_pointcloud_annotation_sources(root: Path) -> list[dict[str, Any]]: |
| | | """Discover preview-only annotation uploads without pretending they are geometry runs.""" |
| | | output_root = root / "shared" / "outputs" / "05-3d-pointcloud" |
| | | records: list[dict[str, Any]] = [] |
| | | metadata_paths = [ |
| | | *output_root.glob("runs/annotation-source-*/run_metadata.json"), |
| | | *output_root.glob("texture-baked-*/run_metadata.json"), |
| | | ] |
| | | for metadata_path in metadata_paths: |
| | | artifact = metadata_path.parent |
| | | metadata = load_json(metadata_path) |
| | | contract = metadata.get("annotation_source") |
| | | if metadata.get("capability") != "05-3d-pointcloud" or not metadata.get("annotation_source_job") or not isinstance(contract, dict): |
| | | continue |
| | | name, count, checksum = contract.get("file"), contract.get("point_count"), contract.get("sha256") |
| | | if not isinstance(name, str) or Path(name).name != name or not isinstance(count, int) or count < 1 or not isinstance(checksum, str): |
| | | continue |
| | | path = artifact / name |
| | | if not path.is_file() or path.suffix.lower() != ".ply" or file_sha256(path) != checksum or ply_vertex_count(path) != count: |
| | | continue |
| | | input_data = metadata.get("input") if isinstance(metadata.get("input"), dict) else {} |
| | | input_name = str(input_data.get("file") or name) |
| | | records.append({ |
| | | "id": f"{artifact.name}:{name}", "runId": artifact.name, "label": f"{artifact.name} / {input_name}", |
| | | "artifactRoot": relative_path(root, artifact), "file": name, "url": f"/{relative_path(root, artifact)}/{name}", |
| | | "sha256": checksum, "pointCount": count, |
| | | "sourceKind": str(contract.get("kind") or "generated RGB/XYZ annotation preview"), |
| | | "sourceHasRgb": bool(input_data.get("has_rgb")), |
| | | "detailAvailable": False, "detailFile": None, |
| | | }) |
| | | return sorted(records, key=lambda item: (item["runId"], item["id"]), reverse=True) |
| | | |
| | | |
| | | def pointcloud_annotation_source(root: Path, source_id: str) -> dict[str, Any]: |
| | | if not isinstance(source_id, str) or not source_id or "/" in source_id or len(source_id) > 300: |
| | | raise ApiError("Invalid annotation source id.") |
| | | source = next((item for item in pointcloud_annotation_sources(root) if item["id"] == source_id), None) |
| | | if not source: |
| | | raise ApiError("The selected generated annotation source is unavailable.") |
| | | return source |
| | | |
| | | |
| | | def pointcloud_annotation_detail( |
| | | root: Path, |
| | | source_id: str, |
| | | center: tuple[float, float, float], |
| | | radius: float, |
| | | ) -> bytes: |
| | | """Read one bounded, server-selected RGB detail window as a binary PLY.""" |
| | | if not all(math.isfinite(value) and abs(value) <= 1_000_000_000.0 for value in center): |
| | | raise ApiError("Detail centre must contain finite local coordinates.") |
| | | if not math.isfinite(radius) or not 0 < radius <= MAX_POINTCLOUD_DETAIL_RADIUS: |
| | | raise ApiError(f"Detail radius must be between 0 and {MAX_POINTCLOUD_DETAIL_RADIUS:g}.") |
| | | source = pointcloud_annotation_source(root, source_id) |
| | | detail_name = source.get("detailFile") |
| | | if not source.get("detailAvailable") or not isinstance(detail_name, str) or Path(detail_name).name != detail_name: |
| | | raise ApiError("This annotation source has no local RGB detail layer.") |
| | | artifact = (root / str(source["artifactRoot"])).resolve() |
| | | output_root = (root / "shared" / "outputs" / "05-3d-pointcloud").resolve() |
| | | detail_path = (artifact / detail_name).resolve() |
| | | try: |
| | | artifact.relative_to(output_root) |
| | | detail_path.relative_to(artifact) |
| | | except ValueError as exc: |
| | | raise ApiError("The requested detail layer is outside the local point-cloud outputs.") from exc |
| | | if not detail_path.is_file() or detail_path.suffix.lower() not in {".las", ".laz", ".ply"}: |
| | | raise ApiError("The local RGB detail layer is unavailable.") |
| | | python = root / ".venvs" / POINTCLOUD_CPU_ENVIRONMENT / "Scripts" / "python.exe" |
| | | exporter = root / "capabilities" / "05-3d-pointcloud" / "export_pointcloud_detail.py" |
| | | if not python.is_file() or not exporter.is_file(): |
| | | raise ApiError("Point-cloud detail exporter is unavailable. Run the capability setup first.") |
| | | command = [ |
| | | str(python), str(exporter), "--input", str(detail_path), "--center", |
| | | *(f"{value:.12g}" for value in center), "--radius", f"{radius:.12g}", |
| | | "--max-points", "1200000", |
| | | ] |
| | | if not POINTCLOUD_DETAIL_REQUEST_LOCK.acquire(timeout=1): |
| | | raise ApiError("A point-cloud detail request is already running. Stop moving briefly and retry.") |
| | | try: |
| | | completed = subprocess.run(command, capture_output=True, timeout=120, check=False) |
| | | except subprocess.TimeoutExpired as exc: |
| | | raise ApiError("The local point-cloud detail request exceeded 120 seconds.") from exc |
| | | finally: |
| | | POINTCLOUD_DETAIL_REQUEST_LOCK.release() |
| | | if completed.returncode != 0: |
| | | message = completed.stderr.decode("utf-8", errors="replace").strip() |
| | | raise ApiError(message[:400] or "The local point-cloud detail exporter failed.") |
| | | payload = completed.stdout |
| | | if not payload.startswith(b"ply\nformat binary_little_endian 1.0\n") or len(payload) > 24 * 1024 * 1024: |
| | | raise ApiError("The local point-cloud detail response is invalid or exceeds its size limit.") |
| | | return payload |
| | | |
| | | |
| | | def ply_vertex_count(path: Path) -> int | None: |
| | | """Read only the bounded PLY header; the point body can be hundreds of MB.""" |
| | | try: |
| | | with path.open("rb") as stream: |
| | | header = bytearray() |
| | | while len(header) < 65_536: |
| | | line = stream.readline(4_096) |
| | | if not line: |
| | | return None |
| | | header.extend(line) |
| | | if line.rstrip(b"\r\n") == b"end_header": |
| | | break |
| | | else: |
| | | return None |
| | | vertex_count: int | None = None |
| | | for line in header.decode("ascii").splitlines(): |
| | | fields = line.split() |
| | | if len(fields) == 3 and fields[:2] == ["element", "vertex"] and fields[2].isdigit(): |
| | | vertex_count = int(fields[2]) |
| | | return vertex_count |
| | | except (OSError, UnicodeDecodeError): |
| | | return None |
| | | |
| | | |
| | | def npz_array_row_count(path: Path, array_name: str) -> int | None: |
| | | """Validate an NPZ array shape without importing a ML environment in the console.""" |
| | | try: |
| | | with zipfile.ZipFile(path) as archive: |
| | | with archive.open(f"{array_name}.npy") as stream: |
| | | magic = stream.read(6) |
| | | version = stream.read(2) |
| | | if magic != b"\x93NUMPY" or len(version) != 2: |
| | | return None |
| | | header_size = 2 if version[0] == 1 else 4 |
| | | header_length = int.from_bytes(stream.read(header_size), "little") |
| | | if header_length < 1 or header_length > 16_384: |
| | | return None |
| | | header = ast.literal_eval(stream.read(header_length).decode("latin1")) |
| | | shape = header.get("shape") if isinstance(header, dict) else None |
| | | if not isinstance(shape, tuple) or len(shape) != 2 or not all(isinstance(value, int) and value >= 0 for value in shape): |
| | | return None |
| | | return int(shape[0]) |
| | | except (OSError, KeyError, ValueError, SyntaxError, zipfile.BadZipFile): |
| | | return None |
| | | |
| | | |
| | | def multiview_pointcloud_annotation_sources(root: Path) -> list[dict[str, Any]]: |
| | | """Discover only complete, order-verified multi-view fusion annotation sources.""" |
| | | output_root = root / "shared" / "outputs" / "05-3d-pointcloud" |
| | | records: list[dict[str, Any]] = [] |
| | | for metadata_path in output_root.glob("multiview-feature-*/run_metadata.json"): |
| | | artifact = metadata_path.parent |
| | | metadata = load_json(metadata_path) |
| | | contract = metadata.get("annotation_source") |
| | | artifacts = metadata.get("artifacts") |
| | | if metadata.get("capability") != "05-3d-pointcloud" or not isinstance(contract, dict) or not isinstance(artifacts, dict): |
| | | continue |
| | | if contract.get("schema_version") != 1 or contract.get("kind") != "multiview_photo_feature_fusion": |
| | | continue |
| | | point_cloud = contract.get("point_cloud") |
| | | feature_dataset = contract.get("feature_dataset") |
| | | point_count = contract.get("point_count") |
| | | if ( |
| | | not isinstance(point_cloud, str) |
| | | or not isinstance(feature_dataset, str) |
| | | or Path(point_cloud).name != point_cloud |
| | | or Path(feature_dataset).name != feature_dataset |
| | | or point_cloud != "multiview-annotation-source.ply" |
| | | or feature_dataset != "multiview-point-features.npz" |
| | | or artifacts.get("annotation_source") != point_cloud |
| | | or artifacts.get("feature_dataset") != feature_dataset |
| | | or not isinstance(point_count, int) |
| | | or point_count < 1 |
| | | ): |
| | | continue |
| | | cloud_path = artifact / point_cloud |
| | | dataset_path = artifact / feature_dataset |
| | | if not cloud_path.is_file() or not dataset_path.is_file(): |
| | | continue |
| | | if contract.get("point_cloud_sha256") != file_sha256(cloud_path) or contract.get("feature_dataset_sha256") != file_sha256(dataset_path): |
| | | continue |
| | | if ply_vertex_count(cloud_path) != point_count or npz_array_row_count(dataset_path, "xyz") != point_count or npz_array_row_count(dataset_path, "las_rgb") != point_count: |
| | | continue |
| | | records.append({ |
| | | "id": f"{artifact.name}:{point_cloud}", |
| | | "runId": artifact.name, |
| | | "label": f"多视角照片特征融合样本 / {artifact.name}", |
| | | "artifactRoot": relative_path(root, artifact), |
| | | "file": point_cloud, |
| | | "url": f"/{relative_path(root, artifact)}/{point_cloud}", |
| | | "sha256": str(contract["point_cloud_sha256"]), |
| | | "pointCount": point_count, |
| | | "sourceKind": "多视角照片特征融合样本(原始 LAS RGB / XYZ;与特征数据同序)", |
| | | }) |
| | | return sorted(records, key=lambda item: (item["runId"], item["id"]), reverse=True) |
| | | |
| | | |
| | | def annotation_classes_path(root: Path) -> Path: |
| | | return root / "shared" / "outputs" / "05-3d-pointcloud" / "annotation-classes.json" |
| | | |
| | | |
| | | def annotation_classes(root: Path) -> list[dict[str, Any]]: |
| | | """Return the local editable taxonomy, with stable defaults for old workspaces.""" |
| | | saved = load_json(annotation_classes_path(root)).get("classes") |
| | | if not isinstance(saved, list): |
| | | return [dict(item) for item in DEFAULT_POINTCLOUD_ANNOTATION_CLASSES] |
| | | try: |
| | | return validate_annotation_classes(saved, allow_builtin=True) |
| | | except ApiError: |
| | | # A corrupt local taxonomy must not prevent existing annotations from opening. |
| | | return [dict(item) for item in DEFAULT_POINTCLOUD_ANNOTATION_CLASSES] |
| | | |
| | | |
| | | def validate_annotation_classes(values: list[Any], *, allow_builtin: bool) -> list[dict[str, Any]]: |
| | | if not values or len(values) > 64: |
| | | raise ApiError("The annotation taxonomy must contain 1 to 64 classes.") |
| | | normalized: list[dict[str, Any]] = [] |
| | | codes: set[int] = set() |
| | | keys: set[str] = set() |
| | | labels: set[str] = set() |
| | | built_in_codes = {item["code"] for item in DEFAULT_POINTCLOUD_ANNOTATION_CLASSES} |
| | | for item in values: |
| | | if not isinstance(item, dict): |
| | | raise ApiError("Each annotation class must be an object.") |
| | | code, key, label, color = item.get("code"), item.get("key"), item.get("label"), item.get("color") |
| | | if not isinstance(code, int) or not 1 <= code <= 255: |
| | | raise ApiError("Annotation class codes must be integers from 1 to 255 for LAS compatibility.") |
| | | if not isinstance(key, str) or not ANNOTATION_CLASS_KEY.fullmatch(key): |
| | | raise ApiError("Annotation class keys must use lowercase English letters, numbers, and underscores.") |
| | | if not isinstance(label, str) or not 1 <= len(label.strip()) <= 40: |
| | | raise ApiError("Annotation class labels must contain 1 to 40 characters.") |
| | | if not isinstance(color, list) or len(color) != 3 or not all(isinstance(value, int) and 0 <= value <= 255 for value in color): |
| | | raise ApiError("Annotation class colors must be three RGB integers from 0 to 255.") |
| | | if code in codes or key in keys or label.strip() in labels: |
| | | raise ApiError("Annotation class code, key, and label must each be unique.") |
| | | if code in built_in_codes and not allow_builtin: |
| | | raise ApiError("Built-in annotation classes cannot be replaced.") |
| | | codes.add(code); keys.add(key); labels.add(label.strip()) |
| | | normalized.append({"code": code, "key": key, "label": label.strip(), "color": color, "builtIn": code in built_in_codes}) |
| | | return sorted(normalized, key=lambda item: item["code"]) |
| | | |
| | | |
| | | def write_annotation_classes(root: Path, classes: list[dict[str, Any]]) -> None: |
| | | path = annotation_classes_path(root) |
| | | path.parent.mkdir(parents=True, exist_ok=True) |
| | | path.write_text(json.dumps({"schema_version": 1, "classes": classes}, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | |
| | | |
| | | def annotation_class_codes_in_use(root: Path) -> set[int]: |
| | | used: set[int] = set() |
| | | for path in (root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations").glob("*/annotation.json"): |
| | | record = load_json(path) |
| | | for label in record.get("labels", []): |
| | | if isinstance(label, list) and len(label) == 2 and isinstance(label[1], int): |
| | | used.add(label[1]) |
| | | return used |
| | | |
| | | |
| | | def pointcloud_annotations(root: Path) -> list[dict[str, Any]]: |
| | |
| | | return sorted(records, key=lambda item: (str(item["createdAt"]), str(item["id"])), reverse=True) |
| | | |
| | | |
| | | def pointcloud_annotation_source_deletion_plan(root: Path, source_id: str) -> dict[str, Any]: |
| | | """Describe every generated artifact that will be removed for one source. |
| | | |
| | | Source paths are discovered server-side. Files outside the established |
| | | point-cloud output/raw/processed layouts, including ``baseData``, are never |
| | | part of this plan. |
| | | """ |
| | | source = next((item for item in pointcloud_annotation_sources(root) if item["id"] == source_id), None) |
| | | if not source: |
| | | raise ApiError("The selected annotation source is unavailable.") |
| | | output_root = (root / "shared" / "outputs" / "05-3d-pointcloud").resolve() |
| | | artifact = (root / str(source["artifactRoot"])).resolve() |
| | | try: |
| | | artifact.relative_to(output_root) |
| | | except ValueError as exc: |
| | | raise ApiError("The selected annotation source is outside the allowed output directory.") from exc |
| | | siblings = [item for item in pointcloud_annotation_sources(root) if item["runId"] == str(source["runId"])] |
| | | source_ids = {item["id"] for item in siblings} |
| | | annotation_root = output_root / "annotations" |
| | | annotations: list[Path] = [] |
| | | for path in annotation_root.glob("*/annotation.json"): |
| | | if load_json(path).get("source_id") in source_ids: |
| | | annotations.append(path.parent) |
| | | annotation_paths = {path / "annotation.json" for path in annotations} |
| | | training_root = output_root / "training-runs" |
| | | training: list[Path] = [] |
| | | for metrics_path in training_root.glob("*/metrics.json"): |
| | | value = load_json(metrics_path).get("annotation") |
| | | if not isinstance(value, str): |
| | | continue |
| | | try: |
| | | if Path(value).resolve() in annotation_paths: |
| | | training.append(metrics_path.parent) |
| | | except OSError: |
| | | continue |
| | | training_models = {path / "model.pt" for path in training} |
| | | inference_root = output_root / "model-inference-runs" |
| | | inference: list[Path] = [] |
| | | for metadata_path in inference_root.glob("*/run_metadata.json"): |
| | | model = load_json(metadata_path).get("model") |
| | | model_path = model.get("path") if isinstance(model, dict) else None |
| | | if not isinstance(model_path, str): |
| | | continue |
| | | try: |
| | | if Path(model_path).resolve() in training_models: |
| | | inference.append(metadata_path.parent) |
| | | except OSError: |
| | | continue |
| | | run_id = str(source["runId"]) |
| | | raw_root = root / "shared" / "data" / "raw" / "05-3d-pointcloud" |
| | | processed_root = root / "shared" / "data" / "processed" / "05-3d-pointcloud" |
| | | raw_candidates = [raw_root / "annotation-source-runs" / run_id, raw_root / "runs" / run_id] |
| | | processed_candidates = [processed_root / "annotation-source-runs" / run_id, processed_root / "runs" / run_id] |
| | | raw = [path for path in raw_candidates if path.is_dir()] |
| | | processed = [path for path in processed_candidates if path.is_dir()] |
| | | metadata = load_json(artifact / "run_metadata.json") |
| | | return { |
| | | "sourceId": source_id, |
| | | "label": source["label"], |
| | | "sourceKind": source["sourceKind"], |
| | | "runId": run_id, |
| | | "artifactRoot": str(source["artifactRoot"]), |
| | | "outputDirectories": 1, |
| | | "rawDirectories": len(raw), |
| | | "processedDirectories": len(processed), |
| | | "annotationRevisions": len(annotations), |
| | | "trainingRuns": len(training), |
| | | "inferenceRuns": len(inference), |
| | | "siblingSources": len(siblings), |
| | | "removesOriginalUpload": bool(raw), |
| | | "preservesExternalInputs": not bool(raw) and not bool(metadata.get("annotation_source_job")), |
| | | } |
| | | |
| | | |
| | | def assert_removable_pointcloud_directory(root: Path, path: Path, allowed_root: Path) -> None: |
| | | resolved = path.resolve() |
| | | allowed = allowed_root.resolve() |
| | | try: |
| | | relative = resolved.relative_to(allowed) |
| | | except ValueError as exc: |
| | | raise ApiError("A deletion target is outside the allowed point-cloud workspace.") from exc |
| | | if not relative.parts or not resolved.is_dir(): |
| | | raise ApiError("A deletion target is invalid.") |
| | | |
| | | |
| | | def active_pointcloud_source_dependencies(run_id: str, annotation_ids: set[str], training_ids: set[str]) -> bool: |
| | | active = {"queued", "running"} |
| | | with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK: |
| | | if any(job.get("runId") == run_id and job.get("status") in active for job in POINTCLOUD_ANNOTATION_SOURCE_JOBS.values()): |
| | | return True |
| | | with POINTCLOUD_TRAINING_JOBS_LOCK: |
| | | if any(job.get("annotationId") in annotation_ids for job in POINTCLOUD_TRAINING_JOBS.values() if job.get("status") in active): |
| | | return True |
| | | with POINTCLOUD_INFERENCE_JOBS_LOCK: |
| | | if any(job.get("modelId") in training_ids and job.get("status") in active for job in POINTCLOUD_INFERENCE_JOBS.values()): |
| | | return True |
| | | return False |
| | | |
| | | |
| | | def pointcloud_annotation_source_job(job_id: str) -> dict[str, Any] | None: |
| | | with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK: |
| | | value = POINTCLOUD_ANNOTATION_SOURCE_JOBS.get(job_id) |
| | | return dict(value) if value else None |
| | | |
| | | |
| | | def execute_pointcloud_annotation_source_job( |
| | | root: Path, |
| | | job_id: str, |
| | | processed_path: Path, |
| | | output: Path, |
| | | source_sha256: str, |
| | | source_bytes: int, |
| | | ) -> None: |
| | | with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK: |
| | | POINTCLOUD_ANNOTATION_SOURCE_JOBS[job_id].update({"status": "running", "stage": "preparing_annotation_preview", "startedAt": datetime.now(UTC).isoformat()}) |
| | | command = [ |
| | | str(root / ".venvs" / POINTCLOUD_CPU_ENVIRONMENT / "Scripts" / "python.exe"), |
| | | str(root / "capabilities" / "05-3d-pointcloud" / "prepare_annotation_source.py"), |
| | | "--input", str(processed_path), "--output", str(output), |
| | | ] |
| | | try: |
| | | with RUN_LOCK: |
| | | completed = subprocess.run(command, cwd=root, capture_output=True, text=True, timeout=900, check=False) |
| | | if completed.returncode: |
| | | message = (completed.stderr or completed.stdout or "Unknown script error.").strip().splitlines()[-1] |
| | | raise RuntimeError(f"Processing failed: {message[:600]}") |
| | | metadata_path = output / "run_metadata.json" |
| | | if not metadata_path.is_file(): |
| | | raise RuntimeError("Point-cloud processing finished without result metadata.") |
| | | metadata = load_json(metadata_path) |
| | | run_id = pointcloud_annotation_source_job(job_id)["runId"] |
| | | metadata["input_dir"] = relative_path(root, processed_path.parent) |
| | | metadata["raw_input_dir"] = relative_path(root, root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "annotation-source-runs" / run_id) |
| | | metadata["source_sha256"] = {processed_path.name: source_sha256} |
| | | metadata["source_bytes"] = {processed_path.name: source_bytes} |
| | | metadata["annotation_source_job"] = True |
| | | metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | source = next((item for item in standalone_pointcloud_annotation_sources(root) if item["runId"] == run_id), None) |
| | | if not source: |
| | | raise RuntimeError("Annotation preview finished without a discoverable annotation source.") |
| | | with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK: |
| | | POINTCLOUD_ANNOTATION_SOURCE_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "source": source}) |
| | | except Exception as exc: |
| | | with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK: |
| | | POINTCLOUD_ANNOTATION_SOURCE_JOBS[job_id].update({"status": "failed", "stage": "failed", "completedAt": datetime.now(UTC).isoformat(), "error": str(exc)[:700]}) |
| | | |
| | | |
| | | def pointcloud_training_job(job_id: str) -> dict[str, Any] | None: |
| | | with POINTCLOUD_TRAINING_JOBS_LOCK: |
| | | value = POINTCLOUD_TRAINING_JOBS.get(job_id) |
| | | return dict(value) if value else None |
| | | |
| | | |
| | | def execute_pointcloud_training_job(root: Path, job_id: str, annotation: Path, output: Path, execution: dict[str, str]) -> None: |
| | | def execute_pointcloud_training_job(root: Path, job_id: str, annotation: Path, output: Path, execution: dict[str, str], trainer: str = "rgb_xyz_baseline") -> None: |
| | | with POINTCLOUD_TRAINING_JOBS_LOCK: |
| | | POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "running", "stage": "training", "startedAt": datetime.now(UTC).isoformat()}) |
| | | command = [execution["python"], str(root / "capabilities" / "05-3d-pointcloud" / "train_pointcloud_semantic_model.py"), "--annotation", str(annotation), "--output", str(output), "--device", execution["device"]] |
| | | scripts = { |
| | | "rgb_xyz_baseline": "train_pointcloud_semantic_model.py", |
| | | "multiview_local_attention_baseline": "train_multiview_point_transformer.py", |
| | | } |
| | | script = scripts.get(trainer) |
| | | if not script: |
| | | raise ApiError("Unsupported point-cloud training workflow.") |
| | | command = [execution["python"], str(root / "capabilities" / "05-3d-pointcloud" / script), "--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) |
| | |
| | | preview = output / "predicted-semantic-preview.ply" |
| | | if not all(path.is_file() for path in (metrics, model, preview)): |
| | | raise ApiError("Training finished without model, metrics, and predicted preview artifacts.") |
| | | metadata = output / "run_metadata.json" |
| | | if metadata.is_file(): |
| | | record_execution_metadata(metadata, execution) |
| | | with POINTCLOUD_TRAINING_JOBS_LOCK: |
| | | POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "artifactRoot": relative_path(root, output), "metrics": relative_path(root, metrics), "model": relative_path(root, model), "preview": relative_path(root, preview)}) |
| | | POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "artifactRoot": relative_path(root, output), "metrics": relative_path(root, metrics), "model": relative_path(root, model), "preview": relative_path(root, preview), "trainer": trainer}) |
| | | except Exception as exc: |
| | | with POINTCLOUD_TRAINING_JOBS_LOCK: |
| | | POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "failed", "stage": "failed", "completedAt": datetime.now(UTC).isoformat(), "error": str(exc)[:700]}) |
| | |
| | | metrics_path = model_path.with_name("metrics.json") |
| | | metrics = load_json(metrics_path) |
| | | classes = metrics.get("classes") |
| | | if metrics.get("capability") != "05-3d-pointcloud" or metrics.get("classification") != "B" or not isinstance(classes, dict): |
| | | if metrics.get("capability") != "05-3d-pointcloud" or metrics.get("classification") != "B" or metrics.get("model_input_kind", "rgb_xyz") != "rgb_xyz" or not isinstance(classes, dict): |
| | | continue |
| | | class_codes = sorted(str(code) for code in classes if str(code).isdigit()) |
| | | if len(class_codes) < 2: |
| | |
| | | with POINTCLOUD_INFERENCE_JOBS_LOCK: |
| | | value = POINTCLOUD_INFERENCE_JOBS.get(job_id) |
| | | return dict(value) if value else None |
| | | |
| | | |
| | | def latest_pointcloud_auto_annotation_job(root: Path, source_id: str, model_id: str) -> dict[str, Any] | None: |
| | | """Recover a completed local automatic-annotation run after a server restart.""" |
| | | source = next((item for item in pointcloud_annotation_sources(root) if item["id"] == source_id), None) |
| | | model = next((item for item in pointcloud_semantic_models(root) if item["id"] == model_id), None) |
| | | if not source or not model: |
| | | raise ApiError("The selected annotation source or trained model is unavailable.") |
| | | output_root = root / "shared" / "outputs" / "05-3d-pointcloud" / "auto-annotation-runs" |
| | | records: list[dict[str, Any]] = [] |
| | | model_sha256 = file_sha256(root / str(model["model"])) |
| | | for metadata_path in output_root.glob("*/run_metadata.json"): |
| | | artifact = metadata_path.parent |
| | | metadata = load_json(metadata_path) |
| | | prediction = metadata.get("prediction") |
| | | input_record = metadata.get("input") |
| | | model_record = metadata.get("model") |
| | | processing = metadata.get("processing") |
| | | automatic = prediction.get("automatic_annotation") if isinstance(prediction, dict) else None |
| | | preview = artifact / "predicted-semantic-preview.ply" |
| | | classified_las = artifact / "predicted-semantic-classified.las" |
| | | class_counts = artifact / "class-counts.csv" |
| | | summary = artifact / "prediction-summary.json" |
| | | candidates = artifact / "automatic-annotation-candidates.json" |
| | | if ( |
| | | metadata.get("capability") != "05-3d-pointcloud" |
| | | or not isinstance(input_record, dict) |
| | | or not isinstance(model_record, dict) |
| | | or not isinstance(processing, dict) |
| | | or not isinstance(automatic, dict) |
| | | or input_record.get("sha256") != source.get("sha256") |
| | | or model_record.get("sha256") != model_sha256 |
| | | or not all(path.is_file() for path in (preview, classified_las, class_counts, summary, candidates)) |
| | | ): |
| | | continue |
| | | confidence = automatic.get("candidate_confidence") |
| | | if not isinstance(confidence, (int, float)): |
| | | continue |
| | | records.append({ |
| | | "id": f"recovered-{artifact.name}", |
| | | "runId": artifact.name, |
| | | "modelId": model_id, |
| | | "sourceId": source_id, |
| | | "inputName": str(source["file"]), |
| | | "candidateConfidence": float(confidence), |
| | | "status": "complete", |
| | | "stage": "complete", |
| | | "requestedDevice": processing.get("requested_device"), |
| | | "device": processing.get("device", "cpu"), |
| | | "environment": processing.get("environment"), |
| | | "torchVersion": metadata.get("versions", {}).get("torch") if isinstance(metadata.get("versions"), dict) else None, |
| | | "fallbackUsed": processing.get("fallback_used", False), |
| | | "fallbackReason": processing.get("fallback_reason"), |
| | | "createdAt": str(metadata.get("created_at") or ""), |
| | | "artifactRoot": relative_path(root, artifact), |
| | | "metadata": relative_path(root, metadata_path), |
| | | "preview": relative_path(root, preview), |
| | | "classifiedLas": relative_path(root, classified_las), |
| | | "classCounts": relative_path(root, class_counts), |
| | | "summary": relative_path(root, summary), |
| | | "candidateFile": relative_path(root, candidates), |
| | | }) |
| | | return max(records, key=lambda item: (item["createdAt"], item["runId"])) if records else None |
| | | |
| | | |
| | | def validate_pointcloud_model_input(path: Path) -> None: |
| | |
| | | raise ApiError("LAS/LAZ 文件没有点记录。请选择包含实际 RGB 点位的完整点云文件,而不是空分块。") |
| | | |
| | | |
| | | def execute_pointcloud_inference_job(root: Path, job_id: str, model: Path, source: Path, output: Path, execution: dict[str, str]) -> None: |
| | | def execute_pointcloud_inference_job(root: Path, job_id: str, model: Path, source: Path, output: Path, execution: dict[str, str], annotation_source_id: str | None = None, candidate_confidence: float | None = None) -> None: |
| | | with POINTCLOUD_INFERENCE_JOBS_LOCK: |
| | | POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "running", "stage": "inference", "startedAt": datetime.now(UTC).isoformat()}) |
| | | 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"]] |
| | | if annotation_source_id: |
| | | command.extend(["--annotation-source-id", annotation_source_id, "--candidate-confidence", f"{candidate_confidence or 0.95:.6f}"]) |
| | | try: |
| | | with RUN_LOCK: |
| | | completed = subprocess.run(command, cwd=root, capture_output=True, text=True, timeout=14_400, check=False) |
| | |
| | | summary = output / "prediction-summary.json" |
| | | if not all(path.is_file() for path in (metadata, preview, classified_las, counts, summary)): |
| | | raise ApiError("Model inference finished without all expected prediction artifacts.") |
| | | candidates = output / "automatic-annotation-candidates.json" |
| | | if annotation_source_id and not candidates.is_file(): |
| | | raise ApiError("Automatic annotation finished without the expected candidate artifact.") |
| | | record_execution_metadata(metadata, execution) |
| | | with POINTCLOUD_INFERENCE_JOBS_LOCK: |
| | | POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "artifactRoot": relative_path(root, output), "metadata": relative_path(root, metadata), "preview": relative_path(root, preview), "classifiedLas": relative_path(root, classified_las), "classCounts": relative_path(root, counts), "summary": relative_path(root, summary)}) |
| | | POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "artifactRoot": relative_path(root, output), "metadata": relative_path(root, metadata), "preview": relative_path(root, preview), "classifiedLas": relative_path(root, classified_las), "classCounts": relative_path(root, counts), "summary": relative_path(root, summary), "candidateFile": relative_path(root, candidates) if annotation_source_id else None}) |
| | | except Exception as exc: |
| | | with POINTCLOUD_INFERENCE_JOBS_LOCK: |
| | | POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "failed", "stage": "failed", "completedAt": datetime.now(UTC).isoformat(), "error": str(exc)[:700]}) |
| | |
| | | return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True) |
| | | |
| | | |
| | | # A console run is removable only when it was created in the fixed ``runs`` |
| | | # layout. Discovery also exposes baseline and validation artifacts, but those |
| | | # are project evidence rather than disposable console-owned copies. |
| | | RUN_DELETION_CAPABILITIES: dict[str, tuple[str, Any]] = { |
| | | "00-change-detection": ("00-change-detection", change_runs), |
| | | "01-object-detection": ("01-object-detection", detection_runs), |
| | | "02-semantic-mapping": ("02-semantic-mapping", semantic_runs), |
| | | "04-spatial-measurement": ("04-spatial-measurement", measurement_runs), |
| | | "05-3d-pointcloud": ("05-3d-pointcloud", pointcloud_runs), |
| | | "07-risk-rule-engine": ("07-risk-rule-engine", risk_rule_runs), |
| | | "09-anomaly-detection": ("09-anomaly-detection", anomaly_runs), |
| | | "15-trajectory-analysis": ("15-trajectory-analysis", trajectory_runs), |
| | | } |
| | | |
| | | |
| | | def valid_run_deletion_id(run_id: str) -> bool: |
| | | return bool(run_id) and len(run_id) <= 160 and "/" not in run_id and "\\" not in run_id and not SAFE_FILE_NAME.search(run_id) |
| | | |
| | | |
| | | def existing_directory(path: Path, parent: Path) -> Path | None: |
| | | """Return an existing direct child directory, never a caller-supplied path.""" |
| | | try: |
| | | resolved_parent = parent.resolve() |
| | | resolved = path.resolve() |
| | | resolved.relative_to(resolved_parent) |
| | | except (OSError, ValueError): |
| | | return None |
| | | return resolved if resolved.is_dir() else None |
| | | |
| | | |
| | | def run_deletion_plan(root: Path, capability: str, run_id: str) -> dict[str, Any]: |
| | | if capability not in RUN_DELETION_CAPABILITIES: |
| | | raise ApiError("This capability does not expose removable console runs.") |
| | | if not valid_run_deletion_id(run_id): |
| | | raise ApiError("Invalid run id.") |
| | | capability_dir, discover = RUN_DELETION_CAPABILITIES[capability] |
| | | record = next((item for item in discover(root) if item.get("id") == run_id), None) |
| | | if not record: |
| | | raise ApiError("The selected result is unavailable.") |
| | | |
| | | output_root = (root / "shared" / "outputs" / capability_dir).resolve() |
| | | expected_output = (output_root / "runs" / run_id).resolve() |
| | | artifact_value = record.get("artifactRoot") |
| | | try: |
| | | artifact = (root / str(artifact_value)).resolve() |
| | | except OSError as exc: |
| | | raise ApiError("The selected result has an invalid artifact location.") from exc |
| | | if artifact != expected_output: |
| | | return { |
| | | "capability": capability, |
| | | "runId": run_id, |
| | | "label": str(record.get("label") or run_id), |
| | | "removable": False, |
| | | "reason": "This is a built-in baseline, validation artifact, or external result. It was not created in the console-owned run layout.", |
| | | "outputDirectories": [], |
| | | "rawDirectories": [], |
| | | "processedDirectories": [], |
| | | "dependentDirectories": [], |
| | | "preservesExternalInputs": True, |
| | | } |
| | | |
| | | output_directories = [expected_output] if existing_directory(expected_output, output_root / "runs") else [] |
| | | raw_root = (root / "shared" / "data" / "raw" / capability_dir).resolve() |
| | | processed_root = (root / "shared" / "data" / "processed" / capability_dir).resolve() |
| | | raw_candidates = [raw_root / "runs" / run_id] |
| | | processed_candidates = [processed_root / run_id, processed_root / "runs" / run_id] |
| | | raw_directories = [path for candidate in raw_candidates if (path := existing_directory(candidate, raw_root))] |
| | | processed_directories = [path for candidate in processed_candidates if (path := existing_directory(candidate, processed_root))] |
| | | dependent_directories: list[Path] = [] |
| | | if capability == "00-change-detection": |
| | | scan_root = output_root / "parameter-scans" / run_id |
| | | if path := existing_directory(scan_root, output_root / "parameter-scans"): |
| | | dependent_directories.append(path) |
| | | return { |
| | | "capability": capability, |
| | | "runId": run_id, |
| | | "label": str(record.get("label") or run_id), |
| | | "removable": bool(output_directories), |
| | | "reason": None if output_directories else "The console-owned output directory is missing, so no deletion is performed.", |
| | | "outputDirectories": [relative_path(root, path) for path in output_directories], |
| | | "rawDirectories": [relative_path(root, path) for path in raw_directories], |
| | | "processedDirectories": [relative_path(root, path) for path in processed_directories], |
| | | "dependentDirectories": [relative_path(root, path) for path in dependent_directories], |
| | | "preservesExternalInputs": True, |
| | | } |
| | | |
| | | |
| | | def delete_console_run(root: Path, capability: str, run_id: str) -> dict[str, Any]: |
| | | plan = run_deletion_plan(root, capability, run_id) |
| | | if not plan["removable"]: |
| | | raise ApiError(str(plan["reason"] or "The selected result cannot be removed.")) |
| | | directories = [*plan["dependentDirectories"], *plan["processedDirectories"], *plan["rawDirectories"], *plan["outputDirectories"]] |
| | | for relative in directories: |
| | | location = (root / relative).resolve() |
| | | # Every entry was created by run_deletion_plan from fixed roots above. |
| | | if location.is_dir(): |
| | | shutil.rmtree(location) |
| | | return {"capability": capability, "runId": run_id, "removedDirectories": directories, "preservesExternalInputs": True} |
| | | |
| | | |
| | | def pointcloud_semantic_model_deletion_plan(root: Path, model_id: str) -> dict[str, Any]: |
| | | if not valid_run_deletion_id(model_id): |
| | | raise ApiError("Invalid semantic model id.") |
| | | model = next((item for item in pointcloud_semantic_models(root) if item["id"] == model_id), None) |
| | | if not model: |
| | | raise ApiError("The selected trained model is unavailable.") |
| | | output_root = (root / "shared" / "outputs" / "05-3d-pointcloud").resolve() |
| | | model_root = (output_root / "training-runs" / model_id).resolve() |
| | | model_path = (root / str(model["model"])).resolve() |
| | | if model_path != model_root / "model.pt" or not existing_directory(model_root, output_root / "training-runs"): |
| | | raise ApiError("The selected trained model is outside the console-owned training layout.") |
| | | model_sha256 = file_sha256(model_path) |
| | | inference_directories: list[Path] = [] |
| | | for metadata_path in (output_root / "model-inference-runs").glob("*/run_metadata.json"): |
| | | metadata = load_json(metadata_path) |
| | | value = metadata.get("model") |
| | | candidate = value.get("path") if isinstance(value, dict) else None |
| | | try: |
| | | if isinstance(candidate, str) and Path(candidate).resolve() == model_path: |
| | | inference_directories.append(metadata_path.parent.resolve()) |
| | | except OSError: |
| | | continue |
| | | auto_directories: list[Path] = [] |
| | | for metadata_path in (output_root / "auto-annotation-runs").glob("*/run_metadata.json"): |
| | | value = load_json(metadata_path).get("model") |
| | | if isinstance(value, dict) and value.get("sha256") == model_sha256: |
| | | auto_directories.append(metadata_path.parent.resolve()) |
| | | raw_root = (root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "model-inference-runs").resolve() |
| | | processed_root = (root / "shared" / "data" / "processed" / "05-3d-pointcloud" / "model-inference-runs").resolve() |
| | | raw_directories = [path for directory in inference_directories if (path := existing_directory(raw_root / directory.name, raw_root))] |
| | | processed_directories = [path for directory in inference_directories if (path := existing_directory(processed_root / directory.name, processed_root))] |
| | | return { |
| | | "modelId": model_id, |
| | | "label": str(model["label"]), |
| | | "removable": True, |
| | | "trainingDirectories": [relative_path(root, model_root)], |
| | | "inferenceDirectories": [relative_path(root, path) for path in inference_directories], |
| | | "autoAnnotationDirectories": [relative_path(root, path) for path in auto_directories], |
| | | "rawDirectories": [relative_path(root, path) for path in raw_directories], |
| | | "processedDirectories": [relative_path(root, path) for path in processed_directories], |
| | | "preservesAnnotationRevisions": True, |
| | | "preservesExternalInputs": True, |
| | | } |
| | | |
| | | |
| | | def delete_pointcloud_semantic_model(root: Path, model_id: str) -> dict[str, Any]: |
| | | plan = pointcloud_semantic_model_deletion_plan(root, model_id) |
| | | directories = [*plan["autoAnnotationDirectories"], *plan["inferenceDirectories"], *plan["processedDirectories"], *plan["rawDirectories"], *plan["trainingDirectories"]] |
| | | for relative in directories: |
| | | location = (root / relative).resolve() |
| | | if location.is_dir(): |
| | | shutil.rmtree(location) |
| | | return {"modelId": model_id, "removedDirectories": directories, "preservesAnnotationRevisions": True, "preservesExternalInputs": True} |
| | | |
| | | |
| | | def anomaly_job(job_id: str) -> dict[str, Any] | None: |
| | | with ANOMALY_JOB_LOCK: |
| | | value = ANOMALY_JOBS.get(job_id) |
| | |
| | | def photo_reconstruction_job(job_id: str) -> dict[str, Any] | None: |
| | | with PHOTO_RECONSTRUCTION_JOBS_LOCK: |
| | | value = PHOTO_RECONSTRUCTION_JOBS.get(job_id) |
| | | return dict(value) if value else None |
| | | job = dict(value) if value else None |
| | | if not job: |
| | | return None |
| | | progress_path = job.pop("progressPath", None) |
| | | if job["status"] == "complete": |
| | | job["progress"] = {"percent": 100, "stage": "complete", "message": "照片重建已完成,结果已加入案例库。", "inputImages": job["inputImages"], "estimate": True} |
| | | return job |
| | | if job["status"] == "failed": |
| | | job["progress"] = {"percent": 0, "stage": "failed", "message": job.get("error") or "照片重建失败。", "inputImages": job["inputImages"], "estimate": True} |
| | | return job |
| | | if isinstance(progress_path, str): |
| | | progress = load_json(Path(progress_path)) |
| | | if progress: |
| | | job["progress"] = progress |
| | | return job |
| | | |
| | | |
| | | def run_background_command(command: list[str], root: Path, timeout: int) -> None: |
| | |
| | | "--input", str(processed_root), "--output", str(sparse_output), |
| | | "--max-image-size", "2000", "--max-features", "18000", |
| | | "--camera-model", "OPENCV", |
| | | "--progress-file", str(processed_root / "photo_reconstruction_progress.json"), |
| | | ] |
| | | if use_position_priors: |
| | | sparse_command.extend(["--matching-mode", "spatial", "--matching-neighbors", "4", "--use-position-priors", "--prior-position-loss-scale-m", "0.05"]) |
| | |
| | | "--openmvs-bin", str(root / "shared" / "tools" / "openmvs-2.4.0" / "vc17" / "x64" / "Release"), |
| | | "--threads", "12", "--max-resolution", "2400", "--dense-resolution-level", "0", |
| | | "--dense-number-views", "8", "--dense-number-views-fuse", "2", "--target-faces", "800000", |
| | | "--progress-file", str(processed_root / "photo_reconstruction_progress.json"), |
| | | ] |
| | | try: |
| | | with PHOTO_RECONSTRUCTION_JOBS_LOCK: |
| | |
| | | |
| | | def do_GET(self) -> None: # noqa: N802 - inherited standard-library method name |
| | | path = urlsplit(self.path).path |
| | | run_deletion_prefix = "/api/runs/" |
| | | if path.startswith(run_deletion_prefix) and path.endswith("/deletion-plan"): |
| | | try: |
| | | parts = path[len(run_deletion_prefix):].split("/") |
| | | if len(parts) != 3 or parts[2] != "deletion-plan": |
| | | raise ApiError("Invalid result deletion-plan endpoint.") |
| | | capability, run_id = (unquote(parts[0]), unquote(parts[1])) |
| | | self.send_json(HTTPStatus.OK, {"plan": run_deletion_plan(self.root, capability, run_id)}) |
| | | except ApiError as exc: |
| | | self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) |
| | | return |
| | | if path == "/api/change-detection/runs": |
| | | self.send_json(HTTPStatus.OK, {"runs": change_runs(self.root)}) |
| | | return |
| | |
| | | if path == "/api/3d-pointcloud/annotation-sources": |
| | | self.send_json(HTTPStatus.OK, {"sources": pointcloud_annotation_sources(self.root)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/annotation-detail": |
| | | try: |
| | | query = parse_qs(urlsplit(self.path).query) |
| | | source_id = query.get("sourceId", [None])[0] |
| | | values = [query.get(axis, [None])[0] for axis in ("x", "y", "z", "radius")] |
| | | if not isinstance(source_id, str) or any(value is None for value in values): |
| | | raise ApiError("Detail request must include sourceId, x, y, z, and radius.") |
| | | try: |
| | | x, y, z, radius = (float(value) for value in values) |
| | | except (TypeError, ValueError) as exc: |
| | | raise ApiError("Detail coordinates and radius must be numbers.") from exc |
| | | payload = pointcloud_annotation_detail(self.root, source_id, (x, y, z), radius) |
| | | self.send_response(HTTPStatus.OK) |
| | | self.send_header("Content-Type", "application/octet-stream") |
| | | self.send_header("Content-Length", str(len(payload))) |
| | | self.send_header("Cache-Control", "no-store") |
| | | self.end_headers() |
| | | self.wfile.write(payload) |
| | | except ApiError as exc: |
| | | self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) |
| | | return |
| | | deletion_plan_prefix = "/api/3d-pointcloud/annotation-source-deletion-plans/" |
| | | if path.startswith(deletion_plan_prefix): |
| | | try: |
| | | source_id = unquote(path[len(deletion_plan_prefix):]) |
| | | self.send_json(HTTPStatus.OK, {"plan": pointcloud_annotation_source_deletion_plan(self.root, source_id)}) |
| | | except ApiError as exc: |
| | | self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/annotation-classes": |
| | | self.send_json(HTTPStatus.OK, {"classes": annotation_classes(self.root)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/annotations": |
| | | self.send_json(HTTPStatus.OK, {"annotations": pointcloud_annotations(self.root)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/semantic-models": |
| | | self.send_json(HTTPStatus.OK, {"models": pointcloud_semantic_models(self.root)}) |
| | | return |
| | | semantic_model_plan_prefix = "/api/3d-pointcloud/semantic-model-deletion-plans/" |
| | | if path.startswith(semantic_model_plan_prefix): |
| | | try: |
| | | model_id = unquote(path[len(semantic_model_plan_prefix):]) |
| | | self.send_json(HTTPStatus.OK, {"plan": pointcloud_semantic_model_deletion_plan(self.root, model_id)}) |
| | | except ApiError as exc: |
| | | self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/auto-annotation-runs/latest": |
| | | try: |
| | | query = parse_qs(urlsplit(self.path).query) |
| | | source_id = query.get("sourceId", [None])[0] |
| | | model_id = query.get("modelId", [None])[0] |
| | | if not isinstance(source_id, str) or not isinstance(model_id, str): |
| | | raise ApiError("Automatic-annotation recovery needs sourceId and modelId.") |
| | | self.send_json(HTTPStatus.OK, {"job": latest_pointcloud_auto_annotation_job(self.root, source_id, model_id)}) |
| | | except ApiError as exc: |
| | | self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) |
| | | return |
| | | review_prefix = "/api/3d-pointcloud/auto-annotation-review-drafts/" |
| | | if path.startswith(review_prefix): |
| | | try: |
| | | run_id = unquote(path[len(review_prefix):]) |
| | | if not run_id or SAFE_FILE_NAME.search(run_id): |
| | | raise ApiError("Invalid automatic-annotation run id.") |
| | | review_path = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "auto-annotation-runs" / run_id / "review-corrections.json" |
| | | self.send_json(HTTPStatus.OK, {"review": load_json(review_path) if review_path.is_file() else None}) |
| | | except ApiError as exc: |
| | | self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) |
| | | return |
| | | if path.startswith("/api/3d-pointcloud/model-inference-jobs/"): |
| | | job_id = path.rstrip("/").rsplit("/", 1)[-1] |
| | |
| | | job_id = path.rstrip("/").rsplit("/", 1)[-1] |
| | | job = pointcloud_training_job(job_id) |
| | | self.send_json(HTTPStatus.OK if job else HTTPStatus.NOT_FOUND, {"job": job} if job else {"error": "Unknown point-cloud training job."}) |
| | | return |
| | | if path.startswith("/api/3d-pointcloud/annotation-source-jobs/"): |
| | | job_id = path.rstrip("/").rsplit("/", 1)[-1] |
| | | job = pointcloud_annotation_source_job(job_id) |
| | | self.send_json(HTTPStatus.OK if job else HTTPStatus.NOT_FOUND, {"job": job} if job else {"error": "Unknown point-cloud annotation source job."}) |
| | | return |
| | | if path.startswith("/api/3d-pointcloud/photo-reconstruction-jobs/"): |
| | | job_id = path.rstrip("/").rsplit("/", 1)[-1] |
| | |
| | | if path == "/api/3d-pointcloud/annotations": |
| | | self.send_json(HTTPStatus.CREATED, {"annotation": self.create_pointcloud_annotation(payload)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/annotation-classes": |
| | | self.send_json(HTTPStatus.CREATED, {"class": self.create_pointcloud_annotation_class(payload)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/annotation-source-runs": |
| | | self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_pointcloud_annotation_source_run(payload)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/training-runs": |
| | | self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_pointcloud_training_run(payload)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/model-inference-runs": |
| | | self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_pointcloud_model_inference_run(payload)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/auto-annotation-runs": |
| | | self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_pointcloud_auto_annotation_run(payload)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/auto-annotation-acceptances": |
| | | self.send_json(HTTPStatus.CREATED, {"annotation": self.accept_pointcloud_auto_annotation(payload)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/auto-annotation-review-drafts": |
| | | self.send_json(HTTPStatus.CREATED, {"review": self.save_pointcloud_auto_annotation_review(payload)}) |
| | | return |
| | | if path == "/api/3d-pointcloud/photo-reconstruction-runs": |
| | | self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_photo_reconstruction_run(payload)}) |
| | |
| | | |
| | | def do_DELETE(self) -> None: # noqa: N802 - annotation revisions are explicitly user-removable |
| | | path = urlsplit(self.path).path |
| | | run_prefix = "/api/runs/" |
| | | if path.startswith(run_prefix): |
| | | try: |
| | | parts = path[len(run_prefix):].split("/") |
| | | if len(parts) != 2: |
| | | raise ApiError("Invalid result deletion endpoint.") |
| | | capability, run_id = (unquote(parts[0]), unquote(parts[1])) |
| | | self.send_json(HTTPStatus.OK, {"removed": delete_console_run(self.root, capability, run_id)}) |
| | | except ApiError as exc: |
| | | self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) |
| | | except Exception as exc: # pragma: no cover - defensive deletion boundary |
| | | self.log_error("console run deletion failed: %s", exc) |
| | | self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Result deletion failed. Check the console terminal for details."}) |
| | | return |
| | | semantic_model_prefix = "/api/3d-pointcloud/semantic-models/" |
| | | if path.startswith(semantic_model_prefix): |
| | | try: |
| | | model_id = unquote(path[len(semantic_model_prefix):]) |
| | | self.send_json(HTTPStatus.OK, {"removed": delete_pointcloud_semantic_model(self.root, model_id)}) |
| | | except ApiError as exc: |
| | | self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) |
| | | except Exception as exc: # pragma: no cover - defensive deletion boundary |
| | | self.log_error("semantic model deletion failed: %s", exc) |
| | | self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Semantic model deletion failed. Check the console terminal for details."}) |
| | | return |
| | | source_prefix = "/api/3d-pointcloud/annotation-sources/" |
| | | if path.startswith(source_prefix): |
| | | try: |
| | | source_id = unquote(path[len(source_prefix):]) |
| | | if not source_id or "/" in source_id or len(source_id) > 300: |
| | | raise ApiError("Invalid annotation source id.") |
| | | self.send_json(HTTPStatus.OK, {"removed": self.delete_pointcloud_annotation_source(source_id)}) |
| | | except ApiError as exc: |
| | | self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) |
| | | except Exception as exc: # pragma: no cover - defensive deletion boundary |
| | | self.log_error("annotation source deletion failed: %s", exc) |
| | | self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Annotation source deletion failed. Check the console terminal for details."}) |
| | | return |
| | | class_prefix = "/api/3d-pointcloud/annotation-classes/" |
| | | if path.startswith(class_prefix): |
| | | try: |
| | | code_value = path[len(class_prefix):] |
| | | if not code_value.isdigit(): |
| | | raise ApiError("Invalid annotation class code.") |
| | | deleted = self.delete_pointcloud_annotation_class(int(code_value)) |
| | | self.send_json(HTTPStatus.OK, {"deletedCode": deleted}) |
| | | except ApiError as exc: |
| | | self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) |
| | | return |
| | | prefix = "/api/3d-pointcloud/annotations/" |
| | | if not path.startswith(prefix): |
| | | self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown local API endpoint."}) |
| | |
| | | |
| | | def create_detection_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | uploads = payload.get("images") |
| | | requested_device = payload.get("device", "auto") |
| | | if not isinstance(uploads, list) or not uploads: |
| | | raise ApiError("Object-detection request must include at least one image.") |
| | | if len(uploads) > MAX_IMAGES_PER_RUN: |
| | | raise ApiError(f"A local run accepts at most {MAX_IMAGES_PER_RUN} images.") |
| | | if not isinstance(requested_device, str): |
| | | raise ApiError("Object-detection device must be auto, cpu, or cuda.") |
| | | execution = object_detection_execution_environment(self.root, requested_device) |
| | | decoded = [decode_upload(item, {".jpg", ".jpeg", ".png"}) for item in uploads] |
| | | if len({name.casefold() for name, _ in decoded}) != len(decoded): |
| | | raise ApiError("Uploaded image names must be unique within one run.") |
| | |
| | | for name, content in decoded: |
| | | (raw_root / name).write_bytes(content) |
| | | output = self.root / "shared" / "outputs" / "01-object-detection" / "runs" / run_id |
| | | execution = object_detection_execution_environment(self.root) |
| | | with RUN_LOCK: |
| | | self.run_command([execution["python"], str(self.root / "capabilities" / "01-object-detection" / "run_detection.py"), "--input", str(raw_root), "--output", str(output), "--device", execution["device"]], 1200) |
| | | if not (output / "run_metadata.json").is_file(): |
| | | metadata_path = output / "run_metadata.json" |
| | | if not metadata_path.is_file(): |
| | | raise ApiError("Detection script finished without the expected result metadata.") |
| | | record_execution_metadata(metadata_path, execution) |
| | | return next(item for item in detection_runs(self.root) if item["id"] == run_id) |
| | | |
| | | def create_change_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | files = payload.get("files") |
| | | uploads = payload.get("uploads") |
| | | requested_device = payload.get("device", "auto") |
| | | staged: dict[str, tuple[str, Path]] = {} |
| | | if isinstance(uploads, dict): |
| | | staged["before"] = self.resolve_change_upload(uploads.get("before"), "before") |
| | |
| | | max_dimension = int(max_dimension_value) |
| | | if max_dimension != CHANGE_MAX_DIMENSION_AUTO and not CHANGE_MAX_DIMENSION_MIN <= max_dimension <= CHANGE_MAX_DIMENSION_MAX: |
| | | raise ApiError("Change-detection resolution must be 0 or between 512 and 4096.") |
| | | if not isinstance(requested_device, str): |
| | | raise ApiError("Change-detection device must be auto, cpu, or cuda.") |
| | | execution = change_detection_execution_environment(self.root, requested_device) |
| | | if staged: |
| | | before_name, after_name = staged["before"][0], staged["after"][0] |
| | | else: |
| | |
| | | after_path.write_bytes(decoded_after[1]) |
| | | processed_root = self.root / "shared" / "data" / "processed" / "00-change-detection" / run_id |
| | | output = self.root / "shared" / "outputs" / "00-change-detection" / "runs" / run_id |
| | | execution = change_detection_execution_environment(self.root) |
| | | with RUN_LOCK: |
| | | self.run_command( |
| | | [ |
| | |
| | | |
| | | def create_change_scan(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | uploads = payload.get("uploads") |
| | | requested_device = payload.get("device", "auto") |
| | | if not isinstance(uploads, dict): |
| | | raise ApiError("Parameter scan must contain staged before and after uploads.") |
| | | staged = { |
| | |
| | | max_dimension = int(max_dimension_value) |
| | | if max_dimension != CHANGE_MAX_DIMENSION_AUTO and not CHANGE_MAX_DIMENSION_MIN <= max_dimension <= CHANGE_MAX_DIMENSION_MAX: |
| | | raise ApiError("Change-detection resolution must be 0 or between 512 and 4096.") |
| | | if not isinstance(requested_device, str): |
| | | raise ApiError("Change-detection device must be auto, cpu, or cuda.") |
| | | execution = change_detection_execution_environment(self.root, requested_device) |
| | | run_id = make_run_id("scan") |
| | | raw_root = self.root / "shared" / "data" / "raw" / "00-change-detection" / "runs" / run_id |
| | | before_path = raw_root / "before" / staged["before"][0] |
| | |
| | | "minimumAreas": areas, |
| | | "processingMode": processing_mode, |
| | | "maxDimension": max_dimension, |
| | | "requestedDevice": execution["requestedDevice"], |
| | | "device": execution["device"], |
| | | "environment": execution["environment"], |
| | | "torchVersion": execution["torchVersion"], |
| | | "fallbackUsed": execution["fallbackUsed"], |
| | | "fallbackReason": execution["fallbackReason"], |
| | | } |
| | | thread = threading.Thread( |
| | | target=self._run_change_scan, |
| | | args=(run_id, before_path, after_path, processed_root, inference_output, scan_output, thresholds, areas, processing_mode, max_dimension), |
| | | args=(run_id, before_path, after_path, processed_root, inference_output, scan_output, thresholds, areas, processing_mode, max_dimension, execution), |
| | | daemon=True, |
| | | name=f"change-scan-{run_id}", |
| | | ) |
| | |
| | | areas: list[int], |
| | | processing_mode: str, |
| | | max_dimension: int, |
| | | execution: dict[str, Any], |
| | | ) -> None: |
| | | execution = change_detection_execution_environment(self.root) |
| | | python = execution["python"] |
| | | try: |
| | | self._update_scan_job(run_id, status="running", phase="inference", device=execution["device"], environment=execution["environment"], torchVersion=execution["torchVersion"]) |
| | | self._update_scan_job(run_id, status="running", phase="inference") |
| | | with RUN_LOCK: |
| | | self.run_command( |
| | | [ |
| | |
| | | SCAN_JOB_TIMEOUT, |
| | | ) |
| | | inference_metadata_path = inference_output / "run_metadata.json" |
| | | record_execution_metadata(inference_metadata_path, execution) |
| | | inference_metadata = load_json(inference_metadata_path) |
| | | inference_metadata["kind"] = "parameter-scan-inference" |
| | | inference_metadata["scan_job_id"] = run_id |
| | |
| | | "device": execution["device"], |
| | | "environment": execution["environment"], |
| | | "torch_version": execution["torchVersion"], |
| | | "requested_device": execution["requestedDevice"], |
| | | "fallback_used": execution["fallbackUsed"], |
| | | "fallback_reason": execution["fallbackReason"], |
| | | "raw_input_dir": relative_path(self.root, before_path.parent.parent), |
| | | "processed_input_dir": relative_path(self.root, processed_root), |
| | | } |
| | |
| | | metadata_path = output / "run_metadata.json" |
| | | if not metadata_path.is_file(): |
| | | raise ApiError("3D point-cloud script finished without the expected result metadata.") |
| | | record_execution_metadata(metadata_path, execution) |
| | | metadata = load_json(metadata_path) |
| | | metadata["input_dir"] = relative_path(self.root, processed_root) |
| | | metadata["raw_input_dir"] = relative_path(self.root, raw_root) |
| | |
| | | source = next((item for item in pointcloud_annotation_sources(self.root) if item["id"] == source_id), None) |
| | | if not source: |
| | | raise ApiError("The selected generated annotation source is unavailable.") |
| | | if len(labels) > MAX_ANNOTATION_LABELS: |
| | | raise ApiError(f"An annotation revision accepts at most {MAX_ANNOTATION_LABELS} labelled points.") |
| | | active_classes = annotation_classes(self.root) |
| | | class_by_code = {item["code"]: item for item in active_classes} |
| | | compact: dict[int, int] = {} |
| | | for item in labels: |
| | | if not isinstance(item, list) or len(item) != 2 or not all(isinstance(value, int) for value in item): |
| | | raise ApiError("Each annotation label must be [pointIndex, classCode].") |
| | | index, code = item |
| | | if index < 0 or index >= int(source["pointCount"]) or code not in POINTCLOUD_CLASS_CODES: |
| | | if index < 0 or index >= int(source["pointCount"]) or code not in class_by_code: |
| | | raise ApiError("Annotation contains an out-of-range point index or unsupported class code.") |
| | | compact[index] = code |
| | | if not compact: |
| | |
| | | location = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations" / annotation_id |
| | | location.mkdir(parents=True, exist_ok=False) |
| | | source_path = self.root / str(source["artifactRoot"]) / str(source["file"]) |
| | | class_counts = {str(code): sum(value == code for value in compact.values()) for code in sorted(POINTCLOUD_CLASS_CODES)} |
| | | class_counts = {str(code): sum(value == code for value in compact.values()) for code in sorted(class_by_code)} |
| | | document = { |
| | | "schema_version": 1, "id": annotation_id, "created_at": datetime.now(UTC).isoformat(), |
| | | "source_id": source_id, "source_path": str(source_path.resolve()), "source_sha256": source["sha256"], |
| | | "source_run_id": source["runId"], "point_count": int(source["pointCount"]), |
| | | "labels": [[index, code] for index, code in sorted(compact.items())], "class_counts": class_counts, |
| | | "class_schema": {str(code): {"code": code} for code in sorted(POINTCLOUD_CLASS_CODES)}, |
| | | "class_schema": {str(code): item for code, item in sorted(class_by_code.items())}, |
| | | "provenance": "human_confirmed_point_labels_only", |
| | | } |
| | | path = location / "annotation.json" |
| | | path.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return {"id": annotation_id, "sourceId": source_id, "path": relative_path(self.root, path), "labelCount": len(compact), "classCounts": class_counts, "createdAt": document["created_at"]} |
| | | |
| | | def create_pointcloud_annotation_class(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | key, label, color = payload.get("key"), payload.get("label"), payload.get("color") |
| | | if not isinstance(key, str) or not isinstance(label, str) or not isinstance(color, list): |
| | | raise ApiError("A class key, label, and RGB color are required.") |
| | | current = annotation_classes(self.root) |
| | | used_codes = {item["code"] for item in current} |
| | | code = next((value for value in range(17, 256) if value not in used_codes), None) |
| | | if code is None: |
| | | raise ApiError("All LAS-compatible annotation class codes are already in use.") |
| | | candidate = {"code": code, "key": key, "label": label, "color": color, "builtIn": False} |
| | | updated = validate_annotation_classes([*current, candidate], allow_builtin=True) |
| | | write_annotation_classes(self.root, updated) |
| | | return next(item for item in updated if item["code"] == code) |
| | | |
| | | def delete_pointcloud_annotation_class(self, code: int) -> int: |
| | | current = annotation_classes(self.root) |
| | | item = next((value for value in current if value["code"] == code), None) |
| | | if not item: |
| | | raise ApiError("The annotation class is unavailable.") |
| | | if item["builtIn"]: |
| | | raise ApiError("Built-in annotation classes cannot be deleted.") |
| | | if code in annotation_class_codes_in_use(self.root): |
| | | raise ApiError("This annotation class is used by a saved annotation revision and cannot be deleted.") |
| | | write_annotation_classes(self.root, [value for value in current if value["code"] != code]) |
| | | return code |
| | | |
| | | def create_pointcloud_annotation_source_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | name, staged_path, expected_sha256 = self.resolve_pointcloud_upload(payload.get("pointCloud")) |
| | | run_id = make_run_id("annotation-source") |
| | | raw_root = self.root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "annotation-source-runs" / run_id |
| | | processed_root = self.root / "shared" / "data" / "processed" / "05-3d-pointcloud" / "annotation-source-runs" / run_id |
| | | output = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "runs" / run_id |
| | | raw_root.mkdir(parents=True, exist_ok=False) |
| | | processed_root.mkdir(parents=True, exist_ok=False) |
| | | raw_path = raw_root / name |
| | | shutil.copyfile(staged_path, raw_path) |
| | | source_sha256 = file_sha256(raw_path) |
| | | if expected_sha256 and source_sha256 != expected_sha256: |
| | | raise ApiError("Uploaded point-cloud checksum changed while staging.") |
| | | processed_path = processed_root / name |
| | | shutil.copyfile(raw_path, processed_path) |
| | | shutil.rmtree(staged_path.parent) |
| | | python = self.root / ".venvs" / POINTCLOUD_CPU_ENVIRONMENT / "Scripts" / "python.exe" |
| | | if not python.is_file(): |
| | | raise ApiError("3D point-cloud CPU virtual environment is unavailable. Run the capability setup first.") |
| | | job_id = uuid4().hex |
| | | job = {"id": job_id, "runId": run_id, "inputName": name, "status": "queued", "stage": "queued", "createdAt": datetime.now(UTC).isoformat(), "sourceSha256": source_sha256} |
| | | with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK: |
| | | POINTCLOUD_ANNOTATION_SOURCE_JOBS[job_id] = job |
| | | thread = threading.Thread(target=execute_pointcloud_annotation_source_job, args=(self.root, job_id, processed_path, output, source_sha256, raw_path.stat().st_size), daemon=True, name=f"annotation-source-{job_id[:8]}") |
| | | thread.start() |
| | | return dict(job) |
| | | |
| | | def delete_pointcloud_annotation_source(self, source_id: str) -> dict[str, Any]: |
| | | plan = pointcloud_annotation_source_deletion_plan(self.root, source_id) |
| | | source = next(item for item in pointcloud_annotation_sources(self.root) if item["id"] == source_id) |
| | | run_id = str(plan["runId"]) |
| | | sibling_ids = {item["id"] for item in pointcloud_annotation_sources(self.root) if item["runId"] == run_id} |
| | | output_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud").resolve() |
| | | annotation_root = output_root / "annotations" |
| | | annotations = [path.parent for path in annotation_root.glob("*/annotation.json") if load_json(path).get("source_id") in sibling_ids] |
| | | annotation_paths = {path / "annotation.json" for path in annotations} |
| | | training_root = output_root / "training-runs" |
| | | training = [] |
| | | for metrics_path in training_root.glob("*/metrics.json"): |
| | | value = load_json(metrics_path).get("annotation") |
| | | try: |
| | | if isinstance(value, str) and Path(value).resolve() in annotation_paths: |
| | | training.append(metrics_path.parent) |
| | | except OSError: |
| | | continue |
| | | training_models = {path / "model.pt" for path in training} |
| | | inference_root = output_root / "model-inference-runs" |
| | | inference = [] |
| | | for metadata_path in inference_root.glob("*/run_metadata.json"): |
| | | model = load_json(metadata_path).get("model") |
| | | value = model.get("path") if isinstance(model, dict) else None |
| | | try: |
| | | if isinstance(value, str) and Path(value).resolve() in training_models: |
| | | inference.append(metadata_path.parent) |
| | | except OSError: |
| | | continue |
| | | if active_pointcloud_source_dependencies(run_id, {path.name for path in annotations}, {path.name for path in training}): |
| | | raise ApiError("This data source has a queued or running dependent task. Wait for it to finish before removing the full data chain.") |
| | | artifact = (self.root / str(source["artifactRoot"])).resolve() |
| | | raw_root = self.root / "shared" / "data" / "raw" / "05-3d-pointcloud" |
| | | processed_root = self.root / "shared" / "data" / "processed" / "05-3d-pointcloud" |
| | | raw = [path for path in (raw_root / "annotation-source-runs" / run_id, raw_root / "runs" / run_id) if path.is_dir()] |
| | | processed = [path for path in (processed_root / "annotation-source-runs" / run_id, processed_root / "runs" / run_id) if path.is_dir()] |
| | | targets = [*inference, *training, *annotations, artifact, *raw, *processed] |
| | | unique: list[tuple[Path, Path]] = [] |
| | | seen: set[Path] = set() |
| | | for target in targets: |
| | | resolved = target.resolve() |
| | | if resolved in seen: |
| | | continue |
| | | seen.add(resolved) |
| | | if target in raw: |
| | | allowed = raw_root |
| | | elif target in processed: |
| | | allowed = processed_root |
| | | else: |
| | | allowed = output_root |
| | | assert_removable_pointcloud_directory(self.root, target, allowed) |
| | | unique.append((target, allowed)) |
| | | # Dependents first; every target was resolved against a fixed local root. |
| | | for target, _ in unique: |
| | | shutil.rmtree(target) |
| | | return {"sourceId": source_id, "runId": run_id, "removed": {"outputDirectories": int(plan["outputDirectories"]), "rawDirectories": int(plan["rawDirectories"]), "processedDirectories": int(plan["processedDirectories"]), "annotationRevisions": len(annotations), "trainingRuns": len(training), "inferenceRuns": len(inference), "siblingSources": int(plan["siblingSources"])}, "preservedExternalInputs": bool(plan["preservesExternalInputs"])} |
| | | |
| | | def create_pointcloud_training_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | annotation_id = payload.get("annotationId") |
| | |
| | | record = load_json(annotation) |
| | | if record.get("schema_version") != 1: |
| | | raise ApiError("The selected annotation revision is unavailable.") |
| | | source_id = record.get("source_id") |
| | | source = next((item for item in pointcloud_annotation_sources(self.root) if item["id"] == source_id), None) |
| | | if not source: |
| | | raise ApiError("The annotation source is unavailable or its verification contract no longer passes.") |
| | | if source.get("sourceHasRgb") is False: |
| | | raise ApiError("This annotation source has no readable RGB values. It can be reviewed visually but cannot train the current RGB semantic model.") |
| | | trainer = "multiview_local_attention_baseline" if str(source.get("sourceKind", "")).startswith("多视角照片特征融合") else "rgb_xyz_baseline" |
| | | 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", "requestedDevice": device, "device": execution["device"], "environment": execution["environment"], "torchVersion": execution["torchVersion"], "createdAt": datetime.now(UTC).isoformat()} |
| | | output = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "training-runs" / make_run_id("multiview-point-attention" if trainer == "multiview_local_attention_baseline" else "semantic-model") |
| | | job = {"id": job_id, "annotationId": annotation_id, "trainer": trainer, "status": "queued", "stage": "queued", "requestedDevice": execution["requestedDevice"], "device": execution["device"], "environment": execution["environment"], "torchVersion": execution["torchVersion"], "fallbackUsed": execution["fallbackUsed"], "fallbackReason": execution["fallbackReason"], "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, execution), 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, trainer), daemon=True, name=f"pointcloud-training-{job_id[:8]}") |
| | | thread.start() |
| | | return dict(job) |
| | | |
| | | def create_pointcloud_model_inference_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | model_id = payload.get("modelId") |
| | | upload = payload.get("pointCloud") |
| | | device = payload.get("device", "auto") |
| | | if not isinstance(model_id, str) or SAFE_FILE_NAME.search(model_id) or len(model_id) > 120: |
| | | raise ApiError("Invalid trained model id.") |
| | | model_record = next((item for item in pointcloud_semantic_models(self.root) if item["id"] == model_id), None) |
| | |
| | | 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) |
| | | execution = pointcloud_execution_environment(self.root) |
| | | if not isinstance(device, str): |
| | | raise ApiError("Point-cloud device must be auto, cpu, or cuda.") |
| | | execution = pointcloud_execution_environment(self.root, device) |
| | | 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", "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)} |
| | | job = {"id": job_id, "runId": run_id, "modelId": model_id, "inputName": name, "status": "queued", "stage": "queued", "requestedDevice": execution["requestedDevice"], "device": execution["device"], "environment": execution["environment"], "torchVersion": execution["torchVersion"], "fallbackUsed": execution["fallbackUsed"], "fallbackReason": execution["fallbackReason"], "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, execution), daemon=True, name=f"pointcloud-inference-{job_id[:8]}") |
| | | thread.start() |
| | | return dict(job) |
| | | |
| | | def create_pointcloud_auto_annotation_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | model_id, source_id, device, confidence = payload.get("modelId"), payload.get("sourceId"), payload.get("device", "auto"), payload.get("candidateConfidence", 0.95) |
| | | if not isinstance(model_id, str) or SAFE_FILE_NAME.search(model_id) or len(model_id) > 120: |
| | | raise ApiError("Invalid trained model id.") |
| | | if not isinstance(source_id, str): |
| | | raise ApiError("Automatic annotation needs a selected local annotation source.") |
| | | if not isinstance(device, str) or device not in COMPUTE_DEVICES: |
| | | raise ApiError("Point-cloud device must be auto, cpu, or cuda.") |
| | | if not isinstance(confidence, (int, float)) or isinstance(confidence, bool) or not 0.5 <= float(confidence) < 1.0: |
| | | raise ApiError("Candidate confidence must be between 0.5 and 1.0.") |
| | | model_record = next((item for item in pointcloud_semantic_models(self.root) if item["id"] == model_id), None) |
| | | source = next((item for item in pointcloud_annotation_sources(self.root) if item["id"] == source_id), None) |
| | | if not model_record or not source: |
| | | raise ApiError("The selected model or annotation source is unavailable.") |
| | | if source.get("sourceHasRgb") is False: |
| | | raise ApiError("Automatic annotation needs observed RGB point features.") |
| | | model_path = (self.root / str(model_record["model"])).resolve() |
| | | source_path = (self.root / str(source["artifactRoot"]) / str(source["file"])).resolve() |
| | | training_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud" / "training-runs").resolve() |
| | | output_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud").resolve() |
| | | try: |
| | | model_path.relative_to(training_root) |
| | | source_path.relative_to(output_root) |
| | | except ValueError as exc: |
| | | raise ApiError("Selected automatic-annotation inputs are outside local workbench outputs.") from exc |
| | | if not model_path.is_file() or not source_path.is_file() or file_sha256(source_path) != source["sha256"]: |
| | | raise ApiError("Selected automatic-annotation input no longer passes its verification contract.") |
| | | execution = pointcloud_execution_environment(self.root, device) |
| | | run_id = make_run_id("auto-annotation") |
| | | output = output_root / "auto-annotation-runs" / run_id |
| | | job_id = uuid4().hex |
| | | job = {"id": job_id, "runId": run_id, "modelId": model_id, "sourceId": source_id, "inputName": str(source["file"]), "candidateConfidence": float(confidence), "status": "queued", "stage": "queued", "requestedDevice": execution["requestedDevice"], "device": execution["device"], "environment": execution["environment"], "torchVersion": execution["torchVersion"], "fallbackUsed": execution["fallbackUsed"], "fallbackReason": execution["fallbackReason"], "createdAt": datetime.now(UTC).isoformat(), "sourceSha256": source["sha256"]} |
| | | 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, source_path, output, execution, source_id, float(confidence)), daemon=True, name=f"auto-annotation-{job_id[:8]}") |
| | | thread.start() |
| | | return dict(job) |
| | | |
| | | def save_pointcloud_auto_annotation_review(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | run_id, source_id, corrections = payload.get("runId"), payload.get("sourceId"), payload.get("corrections") |
| | | if not isinstance(run_id, str) or SAFE_FILE_NAME.search(run_id) or len(run_id) > 120: |
| | | raise ApiError("Invalid automatic-annotation run id.") |
| | | if not isinstance(source_id, str) or not isinstance(corrections, list): |
| | | raise ApiError("Automatic candidate review needs a source id and corrections array.") |
| | | source = pointcloud_annotation_source(self.root, source_id) |
| | | output_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud" / "auto-annotation-runs").resolve() |
| | | artifact = (output_root / run_id).resolve() |
| | | candidate_path = artifact / "automatic-annotation-candidates.json" |
| | | try: |
| | | artifact.relative_to(output_root) |
| | | except ValueError as exc: |
| | | raise ApiError("Automatic candidate review path is outside local workbench outputs.") from exc |
| | | candidate = load_json(candidate_path) |
| | | if candidate.get("schema_version") != 1 or candidate.get("source_id") != source_id or candidate.get("source_sha256") != source["sha256"]: |
| | | raise ApiError("Automatic candidate provenance no longer matches the selected source.") |
| | | active_codes = {item["code"] for item in annotation_classes(self.root)} |
| | | compact: dict[int, int] = {} |
| | | for item in corrections: |
| | | if not isinstance(item, list) or len(item) != 2 or not all(isinstance(value, int) for value in item): |
| | | raise ApiError("Each candidate correction must be [pointIndex, classCodeOrZero].") |
| | | index, code = item |
| | | if index < 0 or index >= int(source["pointCount"]) or (code != 0 and code not in active_codes): |
| | | raise ApiError("Candidate review contains an out-of-range point index or unsupported class code.") |
| | | compact[index] = code |
| | | review = { |
| | | "schema_version": 1, |
| | | "run_id": run_id, |
| | | "source_id": source_id, |
| | | "source_sha256": source["sha256"], |
| | | "candidate_confidence": candidate.get("candidate_confidence"), |
| | | "corrections": [[index, code] for index, code in sorted(compact.items())], |
| | | "saved_at": datetime.now(UTC).isoformat(), |
| | | } |
| | | (artifact / "review-corrections.json").write_text(json.dumps(review, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return {"runId": run_id, "sourceId": source_id, "correctionCount": len(compact), "savedAt": review["saved_at"]} |
| | | |
| | | def accept_pointcloud_auto_annotation(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | run_id, source_id, base_annotation_id = payload.get("runId"), payload.get("sourceId"), payload.get("baseAnnotationId") |
| | | if not isinstance(run_id, str) or SAFE_FILE_NAME.search(run_id) or len(run_id) > 120: |
| | | raise ApiError("Invalid automatic-annotation run id.") |
| | | if not isinstance(source_id, str): |
| | | raise ApiError("Automatic-annotation acceptance needs a source id.") |
| | | source = pointcloud_annotation_source(self.root, source_id) |
| | | output_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud" / "auto-annotation-runs").resolve() |
| | | candidate_path = (output_root / run_id / "automatic-annotation-candidates.json").resolve() |
| | | try: |
| | | candidate_path.relative_to(output_root) |
| | | except ValueError as exc: |
| | | raise ApiError("Automatic candidate path is outside local workbench outputs.") from exc |
| | | candidate = load_json(candidate_path) |
| | | if candidate.get("schema_version") != 1 or candidate.get("source_id") != source_id or candidate.get("source_sha256") != source["sha256"]: |
| | | raise ApiError("Automatic candidate provenance no longer matches the selected source.") |
| | | raw_labels = candidate.get("labels") |
| | | if not isinstance(raw_labels, list): |
| | | raise ApiError("Automatic candidate labels are unavailable.") |
| | | merged: dict[int, int] = {} |
| | | for item in raw_labels: |
| | | if not isinstance(item, list) or len(item) != 3 or not isinstance(item[0], int) or not isinstance(item[1], int): |
| | | raise ApiError("Automatic candidate labels are invalid.") |
| | | merged[item[0]] = item[1] |
| | | review_path = candidate_path.parent / "review-corrections.json" |
| | | review_correction_count = 0 |
| | | if review_path.is_file(): |
| | | review = load_json(review_path) |
| | | if review.get("schema_version") != 1 or review.get("run_id") != run_id or review.get("source_id") != source_id or review.get("source_sha256") != source["sha256"]: |
| | | raise ApiError("Candidate review draft provenance no longer matches the selected source.") |
| | | corrections = review.get("corrections") |
| | | if not isinstance(corrections, list): |
| | | raise ApiError("Candidate review corrections are invalid.") |
| | | valid_codes = {item["code"] for item in annotation_classes(self.root)} |
| | | for item in corrections: |
| | | if not isinstance(item, list) or len(item) != 2 or not all(isinstance(value, int) for value in item): |
| | | raise ApiError("Candidate review corrections are invalid.") |
| | | index, code = item |
| | | if index < 0 or index >= int(source["pointCount"]) or (code != 0 and code not in valid_codes): |
| | | raise ApiError("Candidate review contains an out-of-range point index or unsupported class code.") |
| | | if code == 0: |
| | | merged.pop(index, None) |
| | | else: |
| | | merged[index] = code |
| | | review_correction_count += 1 |
| | | if isinstance(base_annotation_id, str) and base_annotation_id: |
| | | base_path = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations" / base_annotation_id / "annotation.json" |
| | | base = load_json(base_path) |
| | | if base.get("source_id") != source_id: |
| | | raise ApiError("The base human annotation belongs to another source.") |
| | | for item in base.get("labels", []): |
| | | if isinstance(item, list) and len(item) == 2 and all(isinstance(value, int) for value in item): |
| | | merged[item[0]] = item[1] |
| | | annotation = self.create_pointcloud_annotation({"sourceId": source_id, "labels": [[index, code] for index, code in merged.items()]}) |
| | | annotation_path = self.root / str(annotation["path"]) |
| | | document = load_json(annotation_path) |
| | | document["provenance"] = "user_confirmed_high_confidence_model_candidates_with_human_labels_preferred" |
| | | document["automatic_annotation"] = {"run_id": run_id, "candidate_confidence": candidate.get("candidate_confidence"), "candidate_count": candidate.get("candidate_count"), "review_correction_count": review_correction_count, "base_annotation_id": base_annotation_id or None} |
| | | annotation_path.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return annotation |
| | | |
| | | def create_photo_reconstruction_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | uploads = payload.get("photos") |
| | |
| | | for _, staged_path, _ in photos: |
| | | shutil.rmtree(staged_path.parent) |
| | | |
| | | job = {"id": job_id, "runId": run_id, "status": "queued", "stage": "queued", "createdAt": datetime.now(UTC).isoformat(), "inputImages": len(photos), "usePositionPriors": use_position_priors} |
| | | progress_path = processed_root / "photo_reconstruction_progress.json" |
| | | progress_path.write_text(json.dumps({"percent": 0, "stage": "queued", "message": "照片已保存,正在等待 CPU 重建资源。", "inputImages": len(photos), "updatedAt": datetime.now(UTC).isoformat(), "estimate": True}, ensure_ascii=False), encoding="utf-8") |
| | | job = {"id": job_id, "runId": run_id, "status": "queued", "stage": "queued", "createdAt": datetime.now(UTC).isoformat(), "inputImages": len(photos), "usePositionPriors": use_position_priors, "progressPath": str(progress_path)} |
| | | with PHOTO_RECONSTRUCTION_JOBS_LOCK: |
| | | PHOTO_RECONSTRUCTION_JOBS[job_id] = job |
| | | thread = threading.Thread( |
| | |
| | | name=f"photo-reconstruction-{run_id}", |
| | | ) |
| | | thread.start() |
| | | return dict(job) |
| | | return photo_reconstruction_job(job_id) or {key: value for key, value in job.items() if key != "progressPath"} |
| | | |
| | | def create_risk_rule_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | files = payload.get("files") |
| | |
| | | import tempfile |
| | | import unittest |
| | | import json |
| | | import struct |
| | | import zipfile |
| | | from unittest import mock |
| | | from http import HTTPStatus |
| | | from urllib.parse import quote |
| | |
| | | SPEC.loader.exec_module(MODULE) |
| | | |
| | | |
| | | def write_test_npz(path: Path, point_count: int) -> None: |
| | | """Create just enough valid NPY headers for the console's dependency-free validator.""" |
| | | def npy_header(shape: tuple[int, int]) -> bytes: |
| | | text = repr({"descr": "<f8", "fortran_order": False, "shape": shape}).encode("ascii") |
| | | padding = (-((10 + len(text) + 1) % 16)) % 16 |
| | | header = text + (b" " * padding) + b"\n" |
| | | return b"\x93NUMPY\x01\x00" + struct.pack("<H", len(header)) + header |
| | | |
| | | with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: |
| | | archive.writestr("xyz.npy", npy_header((point_count, 3))) |
| | | archive.writestr("las_rgb.npy", npy_header((point_count, 3))) |
| | | |
| | | |
| | | class WorkbenchConsoleHandlerTests(unittest.TestCase): |
| | | def test_run_deletion_plan_rejects_unknown_capability_and_path_traversal(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | with self.assertRaises(MODULE.ApiError): |
| | | MODULE.run_deletion_plan(root, "not-a-capability", "run-1") |
| | | with self.assertRaises(MODULE.ApiError): |
| | | MODULE.run_deletion_plan(root, "02-semantic-mapping", "../run-1") |
| | | |
| | | def test_run_deletion_plan_protects_non_console_baseline(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | artifact = root / "shared" / "outputs" / "02-semantic-mapping" / "validation-baseline" |
| | | artifact.mkdir(parents=True) |
| | | (artifact / "run_metadata.json").write_text(json.dumps({"capability": "02-semantic-mapping", "images": []}), encoding="utf-8") |
| | | plan = MODULE.run_deletion_plan(root, "02-semantic-mapping", "validation-baseline") |
| | | self.assertFalse(plan["removable"]) |
| | | self.assertEqual(plan["outputDirectories"], []) |
| | | |
| | | def test_console_run_deletion_removes_only_discovered_owned_directories(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | run_id = "semantic-test-run" |
| | | output = root / "shared" / "outputs" / "02-semantic-mapping" / "runs" / run_id |
| | | raw = root / "shared" / "data" / "raw" / "02-semantic-mapping" / "runs" / run_id |
| | | processed = root / "shared" / "data" / "processed" / "02-semantic-mapping" / run_id |
| | | sibling = root / "shared" / "outputs" / "02-semantic-mapping" / "runs" / "keep-me" |
| | | external = root / "baseData" / "keep.jpg" |
| | | for directory in (output, raw, processed, sibling, external.parent): |
| | | directory.mkdir(parents=True, exist_ok=True) |
| | | (output / "run_metadata.json").write_text(json.dumps({"capability": "02-semantic-mapping", "images": [], "created_at": "2026-08-28T00:00:00Z"}), encoding="utf-8") |
| | | external.write_bytes(b"external") |
| | | plan = MODULE.run_deletion_plan(root, "02-semantic-mapping", run_id) |
| | | self.assertTrue(plan["removable"]) |
| | | self.assertEqual(plan["outputDirectories"], [f"shared/outputs/02-semantic-mapping/runs/{run_id}"]) |
| | | self.assertEqual(plan["rawDirectories"], [f"shared/data/raw/02-semantic-mapping/runs/{run_id}"]) |
| | | self.assertEqual(plan["processedDirectories"], [f"shared/data/processed/02-semantic-mapping/{run_id}"]) |
| | | removed = MODULE.delete_console_run(root, "02-semantic-mapping", run_id) |
| | | self.assertEqual(removed["runId"], run_id) |
| | | self.assertFalse(output.exists()) |
| | | self.assertFalse(raw.exists()) |
| | | self.assertFalse(processed.exists()) |
| | | self.assertTrue(sibling.is_dir()) |
| | | self.assertEqual(external.read_bytes(), b"external") |
| | | |
| | | def make_handler(self) -> MODULE.WorkbenchConsoleHandler: |
| | | handler = object.__new__(MODULE.WorkbenchConsoleHandler) |
| | | handler.directory = str(ROOT) |
| | |
| | | def test_large_tiff_upload_limits_allow_one_gibibyte_files(self) -> None: |
| | | self.assertEqual(MODULE.MAX_FILE_BYTES, 1024 * 1024 * 1024) |
| | | self.assertEqual(MODULE.MAX_REQUEST_BYTES, 3072 * 1024 * 1024) |
| | | |
| | | def test_photo_reconstruction_timeout_supports_long_cpu_runs(self) -> None: |
| | | self.assertEqual(MODULE.PHOTO_RECONSTRUCTION_TIMEOUT, 86_400) |
| | | |
| | | def test_binary_change_upload_preserves_original_bytes(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | |
| | | with self.assertRaisesRegex(MODULE.ApiError, "没有点记录"): |
| | | MODULE.validate_pointcloud_model_input(empty_las) |
| | | |
| | | def test_photo_reconstruction_request_requires_three_to_thirty_photos(self) -> None: |
| | | def test_photo_reconstruction_request_requires_three_to_one_thousand_photos(self) -> None: |
| | | handler = self.make_handler() |
| | | with self.assertRaisesRegex(MODULE.ApiError, "at least three"): |
| | | handler.create_photo_reconstruction_run({"photos": []}) |
| | |
| | | handler.create_photo_reconstruction_run({"photos": [{}] * (MODULE.MAX_PHOTO_RECONSTRUCTION_IMAGES_PER_RUN + 1)}) |
| | | with self.assertRaisesRegex(MODULE.ApiError, "true or false"): |
| | | handler.create_photo_reconstruction_run({"photos": [{}, {}, {}], "usePositionPriors": "yes"}) |
| | | |
| | | def test_photo_reconstruction_job_reads_progress_without_exposing_path(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | progress_path = Path(temp_dir) / "progress.json" |
| | | progress_path.write_text(json.dumps({"percent": 68, "stage": "dense_fusion", "message": "running", "inputImages": 18, "estimate": True}), encoding="utf-8") |
| | | with MODULE.PHOTO_RECONSTRUCTION_JOBS_LOCK: |
| | | MODULE.PHOTO_RECONSTRUCTION_JOBS["progress-test"] = {"id": "progress-test", "runId": "run", "status": "running", "stage": "dense_mvs", "inputImages": 18, "progressPath": str(progress_path)} |
| | | try: |
| | | job = MODULE.photo_reconstruction_job("progress-test") |
| | | finally: |
| | | with MODULE.PHOTO_RECONSTRUCTION_JOBS_LOCK: |
| | | MODULE.PHOTO_RECONSTRUCTION_JOBS.pop("progress-test", None) |
| | | self.assertEqual(job["progress"]["percent"], 68) |
| | | self.assertNotIn("progressPath", job) |
| | | |
| | | def test_binary_anomaly_upload_decodes_chinese_file_name(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | |
| | | 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_explicit_cpu_execution_never_probes_cuda(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | cpu_python = root / ".venvs" / MODULE.OBJECT_DETECTION_CPU_ENVIRONMENT / "Scripts" / "python.exe" |
| | | cpu_python.parent.mkdir(parents=True) |
| | | cpu_python.write_bytes(b"fixed-cpu-interpreter") |
| | | with mock.patch.object(MODULE.subprocess, "run") as probe: |
| | | execution = MODULE.object_detection_execution_environment(root, "cpu") |
| | | probe.assert_not_called() |
| | | self.assertEqual(execution["requestedDevice"], "cpu") |
| | | self.assertEqual(execution["device"], "cpu") |
| | | self.assertFalse(execution["fallbackUsed"]) |
| | | |
| | | def test_auto_cpu_fallback_records_the_probe_reason(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | cpu_python = root / ".venvs" / MODULE.CHANGE_DETECTION_CPU_ENVIRONMENT / "Scripts" / "python.exe" |
| | | gpu_python = root / ".venvs" / MODULE.CHANGE_DETECTION_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.change_detection_execution_environment(root, "auto") |
| | | self.assertTrue(execution["fallbackUsed"]) |
| | | self.assertIn("cannot use CUDA", execution["fallbackReason"]) |
| | | |
| | | def test_object_detection_execution_uses_fixed_gpu_environment_when_probe_succeeds(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | |
| | | validation = next(item for item in runs if item["id"] == "validation-normal-20260821-v2") |
| | | self.assertTrue(validation["artifactRoot"].startswith("shared/outputs/05-3d-pointcloud/")) |
| | | |
| | | def test_multiview_annotation_source_requires_complete_ordered_contract(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | artifact = root / "shared" / "outputs" / "05-3d-pointcloud" / "multiview-feature-valid" |
| | | artifact.mkdir(parents=True) |
| | | cloud = artifact / "multiview-annotation-source.ply" |
| | | cloud.write_text( |
| | | "ply\nformat ascii 1.0\nelement vertex 2\nproperty float x\nproperty float y\nproperty float z\nend_header\n0 0 0\n1 1 1\n", |
| | | encoding="ascii", |
| | | ) |
| | | dataset = artifact / "multiview-point-features.npz" |
| | | write_test_npz(dataset, 2) |
| | | (artifact / "run_metadata.json").write_text(json.dumps({ |
| | | "capability": "05-3d-pointcloud", |
| | | "artifacts": {"annotation_source": cloud.name, "feature_dataset": dataset.name}, |
| | | "annotation_source": { |
| | | "schema_version": 1, |
| | | "kind": "multiview_photo_feature_fusion", |
| | | "point_cloud": cloud.name, |
| | | "feature_dataset": dataset.name, |
| | | "point_count": 2, |
| | | "point_cloud_sha256": MODULE.file_sha256(cloud), |
| | | "feature_dataset_sha256": MODULE.file_sha256(dataset), |
| | | }, |
| | | }), encoding="utf-8") |
| | | |
| | | sources = MODULE.pointcloud_annotation_sources(root) |
| | | self.assertEqual(len(sources), 1) |
| | | self.assertEqual(sources[0]["pointCount"], 2) |
| | | self.assertIn("多视角照片特征融合", sources[0]["sourceKind"]) |
| | | |
| | | handler = object.__new__(MODULE.WorkbenchConsoleHandler) |
| | | handler.directory = str(root) |
| | | annotation = handler.create_pointcloud_annotation({"sourceId": sources[0]["id"], "labels": [[1, 15]]}) |
| | | self.assertEqual(annotation["labelCount"], 1) |
| | | execution = {"python": "fixed-python", "device": "cpu", "environment": "05-3d-pointcloud", "torchVersion": "2.13.0+cpu", "requestedDevice": "cpu", "fallbackUsed": False, "fallbackReason": None} |
| | | with mock.patch.object(MODULE, "pointcloud_execution_environment", return_value=execution), mock.patch.object(MODULE.threading, "Thread") as thread: |
| | | thread.return_value.start.return_value = None |
| | | job = handler.create_pointcloud_training_run({"annotationId": annotation["id"], "device": "cpu"}) |
| | | self.assertEqual(job["trainer"], "multiview_local_attention_baseline") |
| | | self.assertTrue(job["artifactRoot"] if "artifactRoot" in job else True) |
| | | self.assertEqual(thread.call_args.kwargs["args"][-1], "multiview_local_attention_baseline") |
| | | |
| | | dataset.unlink() |
| | | self.assertEqual(MODULE.pointcloud_annotation_sources(root), []) |
| | | |
| | | def test_pointcloud_request_requires_allowlisted_files(self) -> None: |
| | | handler = self.make_handler() |
| | | with self.assertRaisesRegex(MODULE.ApiError, "PLY, PCD, XYZ, LAS, or LAZ"): |
| | |
| | | with self.assertRaisesRegex(MODULE.ApiError, "Unsupported file type"): |
| | | handler.create_pointcloud_run({"pointClouds": [{"name": "unsafe.exe", "content": "eA=="}]}) |
| | | |
| | | def test_annotation_taxonomy_adds_custom_las_class_and_preserves_used_code(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | handler = object.__new__(MODULE.WorkbenchConsoleHandler) |
| | | handler.directory = str(root) |
| | | defaults = MODULE.annotation_classes(root) |
| | | self.assertEqual({item["code"] for item in defaults}, {1, 2, 5, 6, 15, 16}) |
| | | created = handler.create_pointcloud_annotation_class({"key": "transformer", "label": "变压器", "color": [30, 144, 255]}) |
| | | self.assertEqual(created["code"], 17) |
| | | self.assertTrue(MODULE.annotation_classes_path(root).is_file()) |
| | | revision = root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations" / "annotation-used" |
| | | revision.mkdir(parents=True) |
| | | (revision / "annotation.json").write_text(json.dumps({"schema_version": 1, "labels": [[0, 17]]}), encoding="utf-8") |
| | | with self.assertRaisesRegex(MODULE.ApiError, "used by a saved annotation"): |
| | | handler.delete_pointcloud_annotation_class(17) |
| | | (revision / "annotation.json").unlink() |
| | | self.assertEqual(handler.delete_pointcloud_annotation_class(17), 17) |
| | | self.assertNotIn(17, {item["code"] for item in MODULE.annotation_classes(root)}) |
| | | |
| | | def test_annotation_taxonomy_rejects_invalid_custom_metadata(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | handler = object.__new__(MODULE.WorkbenchConsoleHandler) |
| | | handler.directory = temp_dir |
| | | with self.assertRaisesRegex(MODULE.ApiError, "lowercase English"): |
| | | handler.create_pointcloud_annotation_class({"key": "Transformer", "label": "变压器", "color": [30, 144, 255]}) |
| | | with self.assertRaisesRegex(MODULE.ApiError, "RGB"): |
| | | handler.create_pointcloud_annotation_class({"key": "transformer", "label": "变压器", "color": [999, 1, 1]}) |
| | | |
| | | def test_preview_only_annotation_source_is_discovered_without_geometry_artifacts(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | run_id = "annotation-source-preview-only" |
| | | artifact = root / "shared" / "outputs" / "05-3d-pointcloud" / "runs" / run_id |
| | | artifact.mkdir(parents=True) |
| | | preview = artifact / "upload.annotation-source.ply" |
| | | preview.write_text( |
| | | "ply\nformat binary_little_endian 1.0\nelement vertex 2\nproperty float x\nproperty float y\nproperty float z\nproperty uchar red\nproperty uchar green\nproperty uchar blue\nend_header\n", |
| | | encoding="ascii", |
| | | ) |
| | | (artifact / "run_metadata.json").write_text(json.dumps({ |
| | | "capability": "05-3d-pointcloud", "annotation_source_job": True, |
| | | "annotation_source": {"schema_version": 1, "file": preview.name, "point_count": 2, "sha256": MODULE.file_sha256(preview)}, |
| | | "input": {"file": "upload.ply", "has_rgb": False}, |
| | | }), encoding="utf-8") |
| | | sources = MODULE.pointcloud_annotation_sources(root) |
| | | self.assertEqual(len(sources), 1) |
| | | self.assertEqual(sources[0]["id"], f"{run_id}:{preview.name}") |
| | | self.assertFalse(sources[0]["sourceHasRgb"]) |
| | | self.assertEqual(MODULE.pointcloud_annotation_source_deletion_plan(root, sources[0]["id"])["outputDirectories"], 1) |
| | | |
| | | def test_texture_baked_annotation_source_is_discovered_at_capability_root(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | run_id = "texture-baked-preview" |
| | | artifact = root / "shared" / "outputs" / "05-3d-pointcloud" / run_id |
| | | artifact.mkdir(parents=True) |
| | | preview = artifact / "texture-baked-annotation-source.ply" |
| | | preview.write_text( |
| | | "ply\nformat binary_little_endian 1.0\nelement vertex 1\nproperty float x\nproperty float y\nproperty float z\nend_header\n", |
| | | encoding="ascii", |
| | | ) |
| | | (artifact / "run_metadata.json").write_text(json.dumps({ |
| | | "capability": "05-3d-pointcloud", "annotation_source_job": True, |
| | | "annotation_source": {"schema_version": 1, "file": preview.name, "point_count": 1, "sha256": MODULE.file_sha256(preview)}, |
| | | "input": {"file": "Model_0.zip", "has_rgb": True}, |
| | | }), encoding="utf-8") |
| | | sources = MODULE.pointcloud_annotation_sources(root) |
| | | self.assertEqual([item["id"] for item in sources], [f"{run_id}:{preview.name}"]) |
| | | self.assertTrue(sources[0]["sourceHasRgb"]) |
| | | |
| | | def test_annotation_source_removal_deletes_only_its_complete_local_chain(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | run_id = "annotation-source-test" |
| | | artifact = root / "shared" / "outputs" / "05-3d-pointcloud" / "runs" / run_id |
| | | artifact.mkdir(parents=True) |
| | | annotation_cloud = artifact / "block.semantic-annotation-source.ply" |
| | | annotation_cloud.write_bytes(b"annotation-preview") |
| | | (artifact / "preview.png").write_bytes(b"preview") |
| | | (artifact / "vector.geojson").write_text('{"type":"FeatureCollection","features":[]}', encoding="utf-8") |
| | | (artifact / "run_metadata.json").write_text(json.dumps({"capability": "05-3d-pointcloud", "created_at": "2026-08-28T00:00:00+00:00", "annotation_source_job": True, "point_clouds": [{"file": "block.las", "preview_file": "preview.png", "vector_file": "vector.geojson", "semantic_annotation_source_point_cloud": annotation_cloud.name, "semantic_preview_points": 1}]}), encoding="utf-8") |
| | | source_id = f"{run_id}:{annotation_cloud.name}" |
| | | revision = root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations" / "annotation-test" |
| | | revision.mkdir(parents=True) |
| | | annotation_path = revision / "annotation.json" |
| | | annotation_path.write_text(json.dumps({"schema_version": 1, "id": "annotation-test", "source_id": source_id, "labels": [[0, 5]]}), encoding="utf-8") |
| | | training = root / "shared" / "outputs" / "05-3d-pointcloud" / "training-runs" / "model-test" |
| | | training.mkdir(parents=True) |
| | | model = training / "model.pt" |
| | | model.write_bytes(b"weights") |
| | | (training / "metrics.json").write_text(json.dumps({"annotation": str(annotation_path)}), encoding="utf-8") |
| | | inference = root / "shared" / "outputs" / "05-3d-pointcloud" / "model-inference-runs" / "inference-test" |
| | | inference.mkdir(parents=True) |
| | | (inference / "run_metadata.json").write_text(json.dumps({"model": {"path": str(model)}}), encoding="utf-8") |
| | | raw = root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "annotation-source-runs" / run_id |
| | | processed = root / "shared" / "data" / "processed" / "05-3d-pointcloud" / "annotation-source-runs" / run_id |
| | | raw.mkdir(parents=True); processed.mkdir(parents=True) |
| | | (raw / "block.las").write_bytes(b"raw"); (processed / "block.las").write_bytes(b"processed") |
| | | external = root / "baseData" / "block.las"; external.parent.mkdir(); external.write_bytes(b"must-remain") |
| | | |
| | | handler = object.__new__(MODULE.WorkbenchConsoleHandler) |
| | | handler.directory = str(root) |
| | | plan = MODULE.pointcloud_annotation_source_deletion_plan(root, source_id) |
| | | self.assertEqual((plan["rawDirectories"], plan["processedDirectories"], plan["annotationRevisions"], plan["trainingRuns"], plan["inferenceRuns"]), (1, 1, 1, 1, 1)) |
| | | result = handler.delete_pointcloud_annotation_source(source_id) |
| | | self.assertEqual(result["removed"]["trainingRuns"], 1) |
| | | self.assertFalse(artifact.exists()); self.assertFalse(raw.exists()); self.assertFalse(processed.exists()) |
| | | self.assertFalse(revision.exists()); self.assertFalse(training.exists()); self.assertFalse(inference.exists()) |
| | | self.assertEqual(external.read_bytes(), b"must-remain") |
| | | |
| | | def test_semantic_model_discovery_requires_complete_training_artifacts(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |