feat(console): auto-select cuda for detection workflows
| | |
| | | |
| | | `pip check` passes in this environment. |
| | | |
| | | Dedicated object-detection CUDA environment: |
| | | |
| | | `E:\AllWorkProject\geoai-workbench\.venvs\01-object-detection-cuda` |
| | | |
| | | | Component | Version | |
| | | | --- | --- | |
| | | | Python | 3.12.10 | |
| | | | geoai-py | 0.42.0 | |
| | | | torch | 2.11.0+cu128 | |
| | | | torchvision | 0.26.0+cu128 | |
| | | | ultralytics | 8.4.118 | |
| | | |
| | | It reuses the verified CUDA Torch packages from the point-cloud GPU environment |
| | | and the compatible object-detection ecosystem packages. CUDA probe and `pip |
| | | check` pass. The unused `.venvs/01-object-detection-gpu` experiment is not a |
| | | runtime target. |
| | | |
| | | Dedicated anomaly-detection environment: |
| | | |
| | | `D:\lgh\code\geoai-workbench\.venvs\09-anomaly-detection` |
| | |
| | | The environment reuses the compatible 3.12 package set from the verified semantic-mapping |
| | | environment through a local `.pth` file and adds ChangeStar dependencies. `pip check` passes. |
| | | |
| | | Dedicated change-detection CUDA environment: |
| | | |
| | | `E:\AllWorkProject\geoai-workbench\.venvs\00-change-detection-cuda` |
| | | |
| | | | Component | Version | |
| | | | --- | --- | |
| | | | Python | 3.12.10 | |
| | | | geoai-py | 0.42.0 | |
| | | | torchange | 0.0.4 | |
| | | | torch | 2.11.0+cu128 | |
| | | | torchvision | 0.26.0+cu128 | |
| | | |
| | | It exposes fixed local package directories for the verified CUDA Torch, |
| | | ChangeStar and shared geospatial dependencies through `.pth` files. CUDA probe |
| | | and `pip check` pass. |
| | | |
| | | ## Local Experiment Console |
| | | |
| | | - Location: `apps/workbench-console/` |
| | |
| | | - 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. 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 CPU 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 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. |
| | | - 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. |
| | | |
| | |
| | | |
| | | | Capability | Status | Current conclusion | |
| | | | --- | --- | --- | |
| | | | `00-change-detection` | Runnable CPU Demo verified; benchmark evaluator ready | A capability: `geoai.ChangeStarDetection`; ORB registration and JPG-to-GeoTIFF conversion are input preparation, while final cleaned-mask vector export uses Rasterio to preserve one feature per retained region. Auto/image mode handles ordinary imagery in pixel coordinates; GeoTIFF mode preserves spatial reference for inputs with matching CRS. When two GeoTIFF grids differ, the second image is reprojected to the first grid only in the processing copy; original files are unchanged. Real pair `07-16.jpg` -> `07-19.jpg` completed in about 41.6 seconds at 1024 px, with 755/769 registration inliers, 98.52% valid area, 674 changed pixels and one GeoJSON polygon. CLI/API/console runs accept per-run thresholds and processing modes/resolution options, all recorded in `run_metadata.json`. An independent evaluator now rejects misaligned truth grids and reports pixel TP/FP/FN/TN, Precision, Recall, F1, IoU and threshold comparisons, but no reviewed real truth set exists yet. | |
| | | | `01-object-detection` | In progress; runnable people and vehicle experiments | Tiled YOLO helps people; GeoAI NWPU substantially improves top-down vehicles; tree detection is not implemented. | |
| | | | `00-change-detection` | Runnable GPU/CPU Demo verified; benchmark evaluator ready | A capability: `geoai.ChangeStarDetection`; ORB registration and JPG-to-GeoTIFF conversion are input preparation, while final cleaned-mask vector export uses Rasterio to preserve one feature per retained region. Auto/image mode handles ordinary imagery in pixel coordinates; GeoTIFF mode preserves spatial reference for inputs with matching CRS. The fixed server-side CUDA probe selects the verified CUDA environment or CPU fallback. The real pair `07-16.jpg` -> `07-19.jpg` produced exactly 674 changed pixels, one GeoJSON polygon and 755/769 inliers on both routes; CUDA completed in 22.583 seconds versus 41.556 seconds on CPU. CLI/API/console runs record requested and actual device, thresholds and processing options in `run_metadata.json`. An independent evaluator now rejects misaligned truth grids and reports pixel TP/FP/FN/TN, Precision, Recall, F1, IoU and threshold comparisons, but no reviewed real truth set exists yet. | |
| | | | `01-object-detection` | Runnable GPU/CPU people and vehicle experiments | B capability: Ultralytics/PyTorch plus `geoai-py`; fixed server-side CUDA probing selects the verified environment or CPU fallback. Tiled YOLO helps people; GeoAI NWPU substantially improves top-down vehicles; tree detection is not implemented. | |
| | | | `02-semantic-mapping` | Runnable CPU baseline verified | B capability: deterministic RGB/HSV segmentation plus `geoai.masks_to_vector`; raster and vector outputs are inspectable, but this is not a trained GeoAI model. | |
| | | | `04-spatial-measurement` | Runnable CPU Demo verified | B capability: `geoai.masks_to_vector` plus ecosystem measurement; counts labelled raster objects and reports area/perimeter in projected or explicitly non-metric pixel/coordinate units. | |
| | | | `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. | |
| | |
| | | - 2026-08-20 road-pair validation: `shared/outputs/00-change-detection/runs/change-20260820-evidence-optimized/` completed at 2048 x 1152 in 68.643 seconds. ChangeStar retained 135,522 pixels and 34 polygons; the separate visual-difference layer retained 106,509 candidate pixels in 136 components. Visual inspection confirmed cyan coverage around the moved vehicle and some person-sized differences, with remaining edge/shadow/registration false positives. |
| | | - Parameter-scan promotion now preserves the source inference's generic-difference raster, combined overlay and registered-image previews instead of reverting to the red-only scan overlay. Formal case `change-20260820-032014-615889` verifies cyan coverage at the disappeared vehicle and person locations for the `V_1` to `V_2` road pair. |
| | | - Measured result: real pair at 1024 px / tile 512 / overlap 64 took 41.556 seconds on CPU, found 755 inliers from 769 ORB matches, 98.52% valid area, 674 changed pixels (0.0857%) and one polygon. Self-comparison at 512 px took 25.902 seconds and found zero changed pixels and zero polygons. On the urban parking sample, increasing from 1024 to 2048 produced 16 polygons instead of one and covered more vehicle/parking changes, but took about 84 seconds CPU and still had misses/false positives. |
| | | - GPU validation 2026-08-25: `.venvs/00-change-detection-cuda` ran the same 1024 px real pair with exactly the CPU result (674 changed pixels, one polygon, 755/769 ORB inliers) in 22.583 seconds. A CUDA self-comparison produced zero changed pixels and zero polygons. The fixed console probe routes normal and parameter-scan inference to CUDA only after verifying this environment; otherwise it uses the retained CPU environment. `run_metadata.json` records `requested_device` and actual `device`. CUDA parity is a speed check, not a model-accuracy claim. |
| | | - GeoTIFF grid-alignment validation: uploaded `1.tif` (6217 x 5525) and `2.tif` (6218 x 5497) share `EPSG:32650` but have slightly different origins and pixel sizes. The second image was reprojected bilinearly to the first grid in the processing copy, then completed at 1024 px in 80.882 seconds with georeferenced outputs. SHA256 checks confirmed the source staging files and their raw run copies are byte-identical. |
| | | - Honest limitation: ChangeStar weights are trained for Changen2/S1 building change and the close-up rock/concrete scene is out of distribution. No manual truth exists, so no precision, recall, IoU, change type, or engineering alert is claimed. Pixel GeoJSON has no CRS; the downloaded model is CC BY-NC-SA 4.0 and not cleared for commercial use. |
| | | - Console: `GET/POST /api/change-detection/runs`, `GET/POST /api/change-detection/scans`, `GET /api/change-detection/scan-jobs/<job-id>`, `POST /api/change-detection/scans/<scan-id>/promote` and binary upload endpoints on local `6xxx` ports. Real uploads at `6176` verified thresholds and selectable processing resolution; scan submissions are validated for at most 24 combinations and execute asynchronously through fixed capability scripts. Scan inference outputs remain in the scan workspace until a user promotes one selected combination; promotion creates a new formal run directory with raw-input references, makes the case discoverable, and uses rule rectangles as the primary case vector while retaining original polygons and masks. GeoTIFF candidates include source-CRS and EPSG:4326 vectors; ordinary images remain in px coordinates. Minimum connected area is a processed-pixel filter for both modes; an empty console scan selection defaults to `256 px`. Vector export now uses `rasterio.features.shapes` after the pixel-area filter, rather than GeoAI's morphological/NMS vector helper: a GeoTIFF validation changed from 42 exported vectors to all 308 retained mask regions, with 308 rectangles and 308 WGS84 features; every original polygon was covered by its matching rectangle. A non-overwriting corrected scan `scan-20260819-063712-744f45-vector-fixed` is available for review. Binary upload filenames are percent-encoded in the browser and decoded/sanitized by the server, so Chinese filenames no longer fail Request header validation. Auto/image and GeoTIFF mode handling is covered by unit tests. Separate raw before/after directories and a separate processed directory are used; unsafe basename sanitization plus invalid/oversized request rejection were verified. |
| | | - Console: `GET/POST /api/change-detection/runs`, `GET/POST /api/change-detection/scans`, `GET /api/change-detection/scan-jobs/<job-id>`, `POST /api/change-detection/scans/<scan-id>/promote` and binary upload endpoints on local `6xxx` ports. The server selects only a fixed CUDA interpreter after a probe, or the fixed CPU interpreter after fallback; no browser-supplied device/interpreter is accepted. Real uploads at `6176` verified thresholds and selectable processing resolution; scan submissions are validated for at most 24 combinations and execute asynchronously through fixed capability scripts. Scan inference outputs remain in the scan workspace until a user promotes one selected combination; promotion creates a new formal run directory with raw-input references, makes the case discoverable, and uses rule rectangles as the primary case vector while retaining original polygons and masks. GeoTIFF candidates include source-CRS and EPSG:4326 vectors; ordinary images remain in px coordinates. Minimum connected area is a processed-pixel filter for both modes; an empty console scan selection defaults to `256 px`. Vector export now uses `rasterio.features.shapes` after the pixel-area filter, rather than GeoAI's morphological/NMS vector helper: a GeoTIFF validation changed from 42 exported vectors to all 308 retained mask regions, with 308 rectangles and 308 WGS84 features; every original polygon was covered by its matching rectangle. A non-overwriting corrected scan `scan-20260819-063712-744f45-vector-fixed` is available for review. Binary upload filenames are percent-encoded in the browser and decoded/sanitized by the server, so Chinese filenames no longer fail Request header validation. Auto/image and GeoTIFF mode handling is covered by unit tests. Separate raw before/after directories and a separate processed directory are used; unsafe basename sanitization plus invalid/oversized request rejection were verified. |
| | | - Next decision: populate benchmark version `v1` with georeferenced same-GSD orthophotos and manually reviewed change masks for one intended business class. Include at least one no-change pair, one representative change pair and one difficult pseudo-change pair; then use the evaluator to compare registration error, false positives, misses, F1 and IoU before changing the model, selecting a production threshold, batch-running or integrating with a product. |
| | | |
| | | - Script: `capabilities/02-semantic-mapping/run_semantic_segmentation.py`. |
| | |
| | | |
| | | - General tiled detector: `capabilities/01-object-detection/run_detection.py` |
| | | - GeoAI aerial vehicle detector: `capabilities/01-object-detection/run_geoai_vehicle_detection.py` |
| | | - General model: Ultralytics `yolo11n.pt`, CPU, 1024-pixel tiles, 20% overlap, confidence 0.20. |
| | | - Aerial vehicle model: `giswqs/nwpu-vhr10-maskrcnn:best_model.pth`, called through `geoai-py`, 512-pixel windows, 128-pixel overlap, confidence 0.30. |
| | | - General model: Ultralytics `yolo11n.pt`, 1024-pixel tiles, 20% overlap, confidence 0.20; CLI supports `--device auto|cpu|cuda`. |
| | | - Aerial vehicle model: `giswqs/nwpu-vhr10-maskrcnn:best_model.pth`, called through `geoai-py`, 512-pixel windows, 128-pixel overlap, confidence 0.30; CLI supports `--device auto|cpu|cuda`. |
| | | - CUDA runtime: `.venvs/01-object-detection-cuda` provides PyTorch `2.11.0+cu128`, Torchvision `0.26.0+cu128`, Ultralytics `8.4.118`, `geoai-py 0.42.0`, a passing CUDA probe and `pip check`. The server runs only this fixed interpreter after its probe passes, otherwise uses retained `.venvs/01-object-detection` CPU. |
| | | |
| | | Measured results: |
| | | |
| | | - Whole-image YOLO baseline across 19 JPEGs: 13 detections, including 3 people and 10 cars; this misses many small targets. |
| | | - `DJI_20260713102047_0001_V_19.jpeg`: tiled YOLO found five person candidates, including duplicates and a false positive; the visible red vehicle was still missed by both YOLO and NWPU. |
| | | - `DJI_20260810092727_0001_V_10.jpeg`: general YOLO found only 2-3 cars. GeoAI NWPU produced 33 raw vehicle detections and 32 after containment deduplication, with about 90 seconds CPU inference. |
| | | - `DJI_20260810092727_0001_V_10.jpeg` CUDA validation: tiled YOLO retained the same three candidates in 4.261 seconds versus 5.642 seconds CPU; GeoAI NWPU retained the same 32 vehicles in 13.145 seconds versus 97.054 seconds CPU. |
| | | - `DJI_20260713102047_0001_V_19.jpeg`: CUDA and CPU on PyTorch 2.11 both returned four YOLO candidates, while legacy PyTorch 2.13 CPU returned five. This is a version-level numerical difference, so CUDA is not presented as an accuracy change. |
| | | - NWPU is much better for top-down aerial vehicles but still misses vehicles and can produce partial-box duplicates or other aerial-class false positives. |
| | | |
| | | Current technical decisions: |
| | |
| | | |
| | | Vite 开发服务固定使用 `http://127.0.0.1:6174/apps/workbench-console/`,会通过本机代理读取 `6173` 的结果工件。 |
| | | |
| | | ## 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. |
| | | |
| | | ## 地图底图 |
| | | |
| | | Cesium 默认加载公开 ArcGIS World Imagery 底图。要叠加天地图影像和注记,在 |
| | |
| | | |
| | | <template> |
| | | <ArtifactState :loading="store.loading" :error="store.error" /> |
| | | <section class="workspace-command change-command"><div><h2>新建变化检测运行</h2><p>上传同一场景的两期影像,在本机 CPU 上生成变化栅格和像素坐标图斑。</p></div><a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button></section> |
| | | <section class="workspace-command change-command"><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 change-run-form"> |
| | | <a-alert type="warning" show-icon message="普通 JPG 会先做特征配准;工程验收应使用同 CRS、同 GSD 的正射 GeoTIFF。" /> |
| | | <div class="change-upload-grid"> |
| | |
| | | </div> |
| | | <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 长边上限;分辨率越高,CPU 耗时和内存占用越大。</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> |
| | | <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="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">CPU {{ 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-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> |
| | |
| | | <template> |
| | | <ArtifactState :loading="store.loading" :error="store.error" /> |
| | | <section class="workspace-command"> |
| | | <div><h2>新建检测运行</h2><p>上传少量代表性影像,CPU 基线会生成独立结果,不覆盖既有案例。</p></div> |
| | | <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> |
| | |
| | | # 变化检测 |
| | | |
| | | 这是一个可独立运行的 CPU Demo,用 `geoai-py 0.42.0` 的 ChangeStar 接口对双期 |
| | | 这是一个可独立运行的 Demo,用 `geoai-py 0.42.0` 的 ChangeStar 接口对双期 |
| | | 影像生成变化概率栅格、二值变化栅格和变化图斑 GeoJSON。核心推理的能力边界为 |
| | | **A(直接 GeoAI 能力)**;OpenCV 只负责普通 JPG 的特征配准和尺寸处理,Rasterio |
| | | 负责网格对齐、栅格写出及清理后掩膜的一对一矢量化。 |
| | |
| | | --output .\shared\outputs\00-change-detection\validation-real-20260817 |
| | | ``` |
| | | |
| | | The retained CPU environment is always usable with `--device cpu`. When the |
| | | verified CUDA environment `.venvs/00-change-detection-cuda` is present and its |
| | | server-side probe succeeds, the console invokes that fixed interpreter with |
| | | `--device cuda`; otherwise it falls back to the fixed CPU interpreter. The CLI |
| | | also accepts `--device auto|cpu|cuda` and records both `requested_device` and |
| | | the actual `device` in `run_metadata.json`. CUDA changes execution speed, not |
| | | the trained ChangeStar weights or this Demo's accuracy claim. |
| | | |
| | | 带空间参考的双期 GeoTIFF 可显式使用 `--processing-mode geotiff --max-dimension 0`; |
| | | 普通图片可使用 `--processing-mode image --max-dimension 2048`。省略模式时使用 |
| | | `auto`,省略 `--max-dimension` 时普通图片默认 `1024`,带 CRS 的 GeoTIFF 默认保持原始尺寸。 |
| | | |
| | | 首次运行会从 Hugging Face 缓存 ChangeStar `s1_s1c1_vitb` 权重;CPU 运行时间与 |
| | | 首次运行会从 Hugging Face 缓存 ChangeStar `s1_s1c1_vitb` 权重;运行时间与 |
| | | 机器和缩放参数有关。`--max-dimension` 默认 1024,`--tile-size` 默认 512,适合 |
| | | 先做代表性验证;增加尺寸或批量运行前应先人工检查配准和误报。每次运行可单独 |
| | | 选择 `0.01` 到 `0.99` 的变化概率阈值,默认值为 `0.5`;实际阈值会写入 |
| | |
| | | 和矢量输出是否可读。该样本是近景边坡照片,不是 ChangeStar 训练分布,不能 |
| | | 据此宣称工程变化检测准确率。 |
| | | |
| | | 2026-08-25 GPU validation used the same real pair at 1024 px: CPU produced 674 |
| | | changed pixels and one vector in 41.556 seconds; CUDA produced the same 674 |
| | | pixels, one vector, and 755/769 registration inliers in 22.583 seconds. A |
| | | self-comparison on CUDA produced zero changed pixels and zero vectors. This is |
| | | output parity and runtime evidence only, not an accuracy benchmark. |
| | | |
| | | 控制台必须使用配准后的第二期作为栅格和矢量叠加底图;原始第二期只用于查看 |
| | | 输入。2026-08-20 的道路样本验证中,青色视觉差异层补充覆盖了车辆与部分人员 |
| | | 变化,但也出现道路边缘、阴影和局部配准残差,因此只能作为人工复核候选,不能 |
| | |
| | | vector_path.write_text(json.dumps({"type": "FeatureCollection", "features": []}, indent=2), encoding="utf-8") |
| | | |
| | | |
| | | def run_change_detection(before_path: Path, after_path: Path, output_dir: Path, *, processed_dir: Path | None = None, model_name: str = MODEL_NAME, threshold: float = DEFAULT_THRESHOLD, tile_size: int = DEFAULT_TILE_SIZE, overlap: int = DEFAULT_OVERLAP, max_dimension: int = AUTO_MAX_DIMENSION, processing_mode: str = DEFAULT_PROCESSING_MODE) -> dict[str, Any]: |
| | | def run_change_detection(before_path: Path, after_path: Path, output_dir: Path, *, processed_dir: Path | None = None, model_name: str = MODEL_NAME, threshold: float = DEFAULT_THRESHOLD, tile_size: int = DEFAULT_TILE_SIZE, overlap: int = DEFAULT_OVERLAP, max_dimension: int = AUTO_MAX_DIMENSION, processing_mode: str = DEFAULT_PROCESSING_MODE, device: str = "auto") -> dict[str, Any]: |
| | | started = time.perf_counter() |
| | | if not math.isfinite(threshold) or not MIN_THRESHOLD <= threshold <= MAX_THRESHOLD: |
| | | raise ValueError(f"threshold must be between {MIN_THRESHOLD} and {MAX_THRESHOLD}") |
| | | if processing_mode not in PROCESSING_MODES: |
| | | raise ValueError(f"processing_mode must be one of {sorted(PROCESSING_MODES)}") |
| | | if device not in {"auto", "cpu", "cuda"}: |
| | | raise ValueError("device must be auto, cpu, or cuda") |
| | | if not isinstance(max_dimension, int) or max_dimension != AUTO_MAX_DIMENSION and not MIN_MAX_DIMENSION <= max_dimension <= MAX_MAX_DIMENSION: |
| | | raise ValueError(f"max_dimension must be 0 or between {MIN_MAX_DIMENSION} and {MAX_MAX_DIMENSION}") |
| | | before, before_info = _read_rgb(before_path, processing_mode) |
| | |
| | | Image.fromarray(after_small).save(output_dir / "after_registered_preview.jpg", quality=92) |
| | | |
| | | from geoai import ChangeStarDetection |
| | | import torch |
| | | |
| | | detector = ChangeStarDetection(model_name=model_name, device="cpu") |
| | | if device == "cuda" and not torch.cuda.is_available(): |
| | | raise RuntimeError("CUDA was requested but is unavailable.") |
| | | device_name = "cuda" if device == "cuda" or (device == "auto" and torch.cuda.is_available()) else "cpu" |
| | | detector = ChangeStarDetection(model_name=model_name, device=device_name) |
| | | result = detector.predict(str(work_dir / "before.tif"), str(work_dir / "after_registered.tif"), tile_size=tile_size, overlap=overlap, threshold=threshold) |
| | | probability = np.asarray(result["change_prob"], dtype=np.float32) |
| | | raw_mask = ((probability >= threshold) & valid_small).astype(np.uint8) * 255 |
| | |
| | | "geoai_version": "0.42.0", |
| | | "method": "geoai.ChangeStarDetection + rasterio.features.shapes", |
| | | "model": model_name, |
| | | "device": "cpu", |
| | | "requested_device": device, |
| | | "device": device_name, |
| | | "processing_mode": actual_mode, |
| | | "requested_processing_mode": processing_mode, |
| | | "thresholds": {"change_probability": threshold, "minimum_component_pixels": min_area}, |
| | |
| | | |
| | | |
| | | def build_parser() -> argparse.ArgumentParser: |
| | | parser = argparse.ArgumentParser(description="Run CPU ChangeStar change detection on a pair of images.") |
| | | parser = argparse.ArgumentParser(description="Run ChangeStar change detection on a pair of images.") |
| | | parser.add_argument("--before", type=Path, required=True) |
| | | parser.add_argument("--after", type=Path, required=True) |
| | | parser.add_argument("--output", type=Path, required=True) |
| | |
| | | parser.add_argument("--overlap", type=int, default=DEFAULT_OVERLAP) |
| | | parser.add_argument("--max-dimension", type=int, default=AUTO_MAX_DIMENSION, help="Long-edge cap in pixels; 0 keeps a valid GeoTIFF at native resolution and uses 1024 for ordinary images.") |
| | | parser.add_argument("--processing-mode", choices=sorted(PROCESSING_MODES), default=DEFAULT_PROCESSING_MODE) |
| | | parser.add_argument("--device", choices={"auto", "cpu", "cuda"}, default="auto") |
| | | return parser |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | args = build_parser().parse_args() |
| | | try: |
| | | print(json.dumps(run_change_detection(args.before, args.after, args.output, processed_dir=args.processed_output, model_name=args.model, threshold=args.threshold, tile_size=args.tile_size, overlap=args.overlap, max_dimension=args.max_dimension, processing_mode=args.processing_mode), ensure_ascii=False, indent=2)) |
| | | print(json.dumps(run_change_detection(args.before, args.after, args.output, processed_dir=args.processed_output, model_name=args.model, threshold=args.threshold, tile_size=args.tile_size, overlap=args.overlap, max_dimension=args.max_dimension, processing_mode=args.processing_mode, device=args.device), ensure_ascii=False, indent=2)) |
| | | except (FileNotFoundError, ValueError, RuntimeError) as exc: |
| | | raise SystemExit(f"变化检测失败: {exc}") from exc |
| | |
| | | |
| | | ## 目标 |
| | | |
| | | 先做一个可在本机 CPU 运行的预训练模型 Demo:输入无人机照片,输出带目标框、类别和置信度的标注图片,以及结构化 JSON。第二阶段再支持正射 GeoTIFF 切片,并把像素坐标转换为经纬度后输出 GeoJSON,便于接入现有 Cesium 地图和算法管理模块。 |
| | | 先做一个可在本机运行的预训练模型 Demo:输入无人机照片,输出带目标框、类别和置信度的标注图片,以及结构化 JSON。第二阶段再支持正射 GeoTIFF 切片,并把像素坐标转换为经纬度后输出 GeoJSON,便于接入现有 Cesium 地图和算法管理模块。 |
| | | |
| | | 本能力使用 [opengeos/geoai](https://github.com/opengeos/geoai) 发布的 `geoai-py` 作为 GeoAI 工作流层。它负责把地理影像处理、AI 推理、地理结果和可视化串起来;PyTorch、Rasterio、GeoPandas 等仍是它和本 Demo 的底层运行依赖。 |
| | | |
| | |
| | | |
| | | ## 环境与硬件 |
| | | |
| | | - 使用 Python 3.12 专用环境:`.venvs/01-object-detection`。 |
| | | - 当前电脑是 AMD RX 590 GME 8GB,不具备 NVIDIA CUDA;首版使用 CPU 推理。 |
| | | - 使用 Python 3.12 专用环境:`.venvs/01-object-detection` 保留为 CPU 路径;`.venvs/01-object-detection-cuda` 是已验证的 CUDA 路径。 |
| | | - 当前电脑使用 NVIDIA GeForce RTX 3050 Laptop GPU(8 GB VRAM,CUDA capability 8.6)。CUDA 环境使用 PyTorch `2.11.0+cu128`、Torchvision `0.26.0+cu128`、Ultralytics `8.4.118` 和 `geoai-py 0.42.0`;`pip check` 通过。 |
| | | - 64GB 内存足够运行 10–30 张图片的 Demo。大尺寸正射影像需要切片,不能一次性全部载入内存。 |
| | | - 模型权重和 Python 包必须记录版本与许可证,产品使用前检查是否允许商用。 |
| | | - `geoai-py` 项目本身为 MIT 许可证;它依赖的模型权重、数据集和第三方库仍需分别核查许可证。`ultralytics` 等可选底层组件的许可证不能由 GeoAI 的 MIT 许可证自动覆盖。 |
| | |
| | | - `annotated/`:画出检测框和标签的图片; |
| | | - `detections.json`:图片名、类别、置信度、像素框坐标; |
| | | - `detections.geojson`:正射影像场景下的地理框或中心点; |
| | | - `run_metadata.json`:模型版本、阈值、运行时间、设备(CPU/DirectML)。 |
| | | - `run_metadata.json`:模型版本、阈值、运行时间、请求设备和实际设备。 |
| | | |
| | | ## 首版验收标准 |
| | | |
| | |
| | | .\.venvs\01-object-detection\Scripts\python.exe .\capabilities\01-object-detection\run_detection.py |
| | | ``` |
| | | |
| | | 默认使用 CPU、`yolo11n.pt`、1024 像素切片、20% 重叠和置信度阈值 0.20。 |
| | | 默认使用 `--device auto`、`yolo11n.pt`、1024 像素切片、20% 重叠和置信度阈值 0.20。CLI 可显式传入 `--device auto|cpu|cuda`;请求 CUDA 但不可用会明确失败,`auto` 会回退 CPU。 |
| | | 结果写入 `shared/outputs/01-object-detection/`。 |
| | | |
| | | 执行 GeoAI NWPU-VHR10 航拍车辆检测: |
| | |
| | | .\.venvs\01-object-detection\Scripts\python.exe .\capabilities\01-object-detection\run_geoai_vehicle_detection.py |
| | | ``` |
| | | |
| | | 该脚本默认使用 CPU、512 像素滑窗、128 像素重叠和置信度阈值 0.30,结果写入 |
| | | 该脚本默认使用 `--device auto`、512 像素滑窗、128 像素重叠和置信度阈值 0.30,结果写入 |
| | | `shared/outputs/01-object-detection/geoai-vehicles/`。首次运行会自动下载约 98 MB |
| | | 的模型权重。 |
| | | |
| | | ## 当前验证结论 |
| | | |
| | | - `DJI_20260810092727_0001_V_10.jpeg`:通用 YOLO 检出 2–3 辆车;GeoAI NWPU 检出 33 个车辆候选,CPU 推理约 90 秒,明显改善俯视小车辆漏检。 |
| | | - GPU validation on the same `V_10` image: tiled YOLO returned the same three candidates in 4.261 seconds on CUDA versus 5.642 seconds on the retained CPU route. GeoAI NWPU returned the same 32 retained vehicles in 13.145 seconds on CUDA versus 97.054 seconds on CPU. |
| | | - On difficult `V_19`, CUDA and CPU under PyTorch 2.11 both returned four YOLO candidates, while the previous PyTorch 2.13 CPU output had five. This is a version-level numerical difference, not evidence that CUDA improves or degrades accuracy. |
| | | - `DJI_20260713102047_0001_V_19.jpeg`:切片 YOLO 检出多个人员,但两种模型均未检出右上角红色汽车;该近景车顶外观仍需要更匹配的航拍数据或本项目样本微调。 |
| | | - NWPU 会产生少量其他航拍类别误检,正式 Demo 只保留 `vehicle`;脚本还会过滤被高置信度整车框大部分包含的重复局部框。 |
| | | - 树木不属于当前两个模型的有效类别,必须单独建设树冠检测/分割分支。 |
| | | ## 控制台运行入口 |
| | | |
| | | 本地实验控制台可以直接上传最多 12 张 `JPG/JPEG/PNG` 进行一次 CPU 基线检测。每次运行会把原图保存到 `shared/data/raw/01-object-detection/runs/<run-id>/`,结果保存到 `shared/outputs/01-object-detection/runs/<run-id>/`,不会覆盖既有基线或用户原图。页面默认将同一影像的原图与标注图并列展示,并可在案例库中回看历史运行。 |
| | | 本地实验控制台可以直接上传最多 12 张 `JPG/JPEG/PNG` 进行一次检测。每次运行会把原图保存到 `shared/data/raw/01-object-detection/runs/<run-id>/`,结果保存到 `shared/outputs/01-object-detection/runs/<run-id>/`,不会覆盖既有基线或用户原图。页面默认将同一影像的原图与标注图并列展示,并可在案例库中回看历史运行。 |
| | | |
| | | 该入口调用固定的 `run_detection.py` 与 `01-object-detection` 虚拟环境,仍然只适用于人员和常见车辆的基线验证;树木、真实准确率、许可审查和生产批处理不在此入口的承诺范围内。 |
| | | 该入口只调用固定的 `run_detection.py`。服务端先探测固定的 `01-object-detection-cuda` 环境;CUDA 可用时使用它,否则调用固定的 `01-object-detection` CPU 环境。仍然只适用于人员和常见车辆的基线验证;树木、真实准确率、许可审查和生产批处理不在此入口的承诺范围内。 |
| | |
| | | parser.add_argument("--image-size", type=int, default=1024, help="Model input size for each tile.") |
| | | parser.add_argument("--tile-size", type=int, default=1024, help="Pixel size of the sliding detection window.") |
| | | parser.add_argument("--tile-overlap", type=float, default=0.20, help="Overlap ratio between adjacent windows.") |
| | | parser.add_argument("--device", choices={"auto", "cpu", "cuda"}, default="auto") |
| | | return parser.parse_args() |
| | | |
| | | |
| | |
| | | return kept |
| | | |
| | | |
| | | def predict_tiled(model: Any, image_path: Path, args: argparse.Namespace, np: Any) -> tuple[list[dict[str, Any]], int, int]: |
| | | def predict_tiled(model: Any, image_path: Path, args: argparse.Namespace, np: Any, device: str) -> tuple[list[dict[str, Any]], int, int]: |
| | | with Image.open(image_path) as source: |
| | | image = source.convert("RGB") |
| | | width, height = image.size |
| | |
| | | tile = image.crop((x0, y0, min(x0 + args.tile_size, width), min(y0 + args.tile_size, height))) |
| | | result = model.predict( |
| | | source=np.asarray(tile), |
| | | device="cpu", |
| | | device=device, |
| | | imgsz=args.image_size, |
| | | conf=args.confidence, |
| | | classes=COCO_TARGET_CLASS_IDS, |
| | |
| | | import ultralytics |
| | | from ultralytics import YOLO |
| | | |
| | | if args.device == "cuda" and not torch.cuda.is_available(): |
| | | raise SystemExit("CUDA was requested but is unavailable.") |
| | | device = "cuda:0" if args.device == "cuda" or (args.device == "auto" and torch.cuda.is_available()) else "cpu" |
| | | |
| | | started = time.perf_counter() |
| | | model = YOLO(args.model) |
| | | detections: list[dict[str, Any]] = [] |
| | | for image_path in image_paths: |
| | | image_detections, width, height = predict_tiled(model, image_path, args, np) |
| | | image_detections, width, height = predict_tiled(model, image_path, args, np, device) |
| | | annotated_path = annotated_dir / image_path.name |
| | | draw_detections(image_path, image_detections, annotated_path) |
| | | detections.append( |
| | |
| | | "geoai_package": getattr(geoai, "__version__", "unknown"), |
| | | "ultralytics": ultralytics.__version__, |
| | | "torch": torch.__version__, |
| | | "device": "cpu", |
| | | "requested_device": args.device, |
| | | "device": device, |
| | | "cuda_available": bool(torch.cuda.is_available()), |
| | | "model": args.model, |
| | | "confidence": args.confidence, |
| | |
| | | parser.add_argument("--overlap", type=int, default=128) |
| | | parser.add_argument("--nms-threshold", type=float, default=0.30) |
| | | parser.add_argument("--batch-size", type=int, default=4) |
| | | parser.add_argument("--device", choices={"auto", "cpu", "cuda"}, default="auto") |
| | | return parser.parse_args() |
| | | |
| | | |
| | |
| | | import torch |
| | | from geoai.object_detect import NWPU_VHR10_CLASSES, multiclass_detection |
| | | |
| | | if args.device == "cuda" and not torch.cuda.is_available(): |
| | | raise SystemExit("CUDA was requested but is unavailable.") |
| | | device_name = "cuda" if args.device == "cuda" or (args.device == "auto" and torch.cuda.is_available()) else "cpu" |
| | | |
| | | started = time.perf_counter() |
| | | results: list[dict[str, Any]] = [] |
| | | for image_path in image_paths: |
| | |
| | | confidence_threshold=args.confidence, |
| | | nms_threshold=args.nms_threshold, |
| | | batch_size=args.batch_size, |
| | | device=torch.device("cpu"), |
| | | device=torch.device(device_name), |
| | | ) |
| | | vehicles = serializable_vehicle_detections(raw_detections) |
| | | annotated_path = annotated_dir / f"{image_path.stem}.jpg" |
| | |
| | | metadata = { |
| | | "created_at": datetime.now(UTC).isoformat(), |
| | | "geoai_package": getattr(geoai, "__version__", "unknown"), |
| | | "device": "cpu", |
| | | "requested_device": args.device, |
| | | "device": device_name, |
| | | "model": "giswqs/nwpu-vhr10-maskrcnn:best_model.pth", |
| | | "model_architecture": "Mask R-CNN ResNet-50 FPN", |
| | | "source_classes": NWPU_VHR10_CLASSES, |
| | |
| | | POINTCLOUD_CLASS_CODES = {1, 2, 5, 6, 15, 16} |
| | | 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" |
| | | |
| | | |
| | | class ApiError(ValueError): |
| | |
| | | return {"python": str(cpu_python), "device": "cpu", "environment": POINTCLOUD_CPU_ENVIRONMENT, "torchVersion": "unknown"} |
| | | |
| | | |
| | | 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 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 trajectory_runs(root: Path) -> list[dict[str, Any]]: |
| | | output_root = root / "shared" / "outputs" / "15-trajectory-analysis" |
| | | records: list[dict[str, Any]] = [] |
| | |
| | | except ValueError: |
| | | continue |
| | | run_id = artifact.name if artifact != output_root else "baseline" |
| | | device = str(metadata.get("device") or "cpu").lower() |
| | | execution = "GPU" if device.startswith("cuda") else "CPU" |
| | | records.append( |
| | | { |
| | | "id": run_id, |
| | | "label": "既有基线结果" if run_id == "baseline" else run_id, |
| | | "note": "CPU 基线:人员与常见车辆;树木不在当前模型有效类别内。", |
| | | "note": f"{execution} 基线:人员与常见车辆;树木不在当前模型有效类别内。", |
| | | "artifactRoot": relative_path(root, artifact), |
| | | "inputRoot": input_root, |
| | | "createdAt": str(metadata.get("created_at") or ""), |
| | |
| | | registered_before_path = artifact / registered_before_name if registered_before_name else None |
| | | registered_after_path = artifact / registered_after_name if registered_after_name else None |
| | | run_id = artifact.name |
| | | device = str(metadata.get("device") or "cpu").lower() |
| | | execution = "GPU" if device.startswith("cuda") else "CPU" |
| | | record = { |
| | | "id": run_id, |
| | | "label": run_id, |
| | | "note": "ChangeStar CPU 变化栅格与 GeoAI 像素坐标图斑;结果需人工复核。", |
| | | "note": f"ChangeStar {execution} 变化栅格与 GeoAI 像素坐标图斑;结果需人工复核。", |
| | | "artifactRoot": relative_path(root, artifact), |
| | | "beforeImage": relative_path(root, before_path), |
| | | "afterImage": relative_path(root, after_path), |
| | |
| | | for name, content in decoded: |
| | | (raw_root / name).write_bytes(content) |
| | | output = self.root / "shared" / "outputs" / "01-object-detection" / "runs" / run_id |
| | | python = self.root / ".venvs" / "01-object-detection" / "Scripts" / "python.exe" |
| | | if not python.is_file(): |
| | | raise ApiError("Object-detection virtual environment is unavailable. Run the capability setup first.") |
| | | execution = object_detection_execution_environment(self.root) |
| | | with RUN_LOCK: |
| | | self.run_command([str(python), str(self.root / "capabilities" / "01-object-detection" / "run_detection.py"), "--input", str(raw_root), "--output", str(output)], 1200) |
| | | 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(): |
| | | raise ApiError("Detection script finished without the expected result metadata.") |
| | | return next(item for item in detection_runs(self.root) if item["id"] == run_id) |
| | |
| | | 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 |
| | | python = self.root / ".venvs" / "00-change-detection" / "Scripts" / "python.exe" |
| | | if not python.is_file(): |
| | | raise ApiError("Change-detection virtual environment is unavailable. Run the capability setup first.") |
| | | execution = change_detection_execution_environment(self.root) |
| | | with RUN_LOCK: |
| | | self.run_command( |
| | | [ |
| | | str(python), |
| | | execution["python"], |
| | | str(self.root / "capabilities" / "00-change-detection" / "run_change_detection.py"), |
| | | "--before", str(before_path), |
| | | "--after", str(after_path), |
| | | "--threshold", f"{threshold:.4f}", |
| | | "--max-dimension", str(max_dimension), |
| | | "--processing-mode", processing_mode, |
| | | "--device", execution["device"], |
| | | "--processed-output", str(processed_root), |
| | | "--output", str(output), |
| | | ], |
| | |
| | | processing_mode: str, |
| | | max_dimension: int, |
| | | ) -> None: |
| | | python = self.root / ".venvs" / "00-change-detection" / "Scripts" / "python.exe" |
| | | execution = change_detection_execution_environment(self.root) |
| | | python = execution["python"] |
| | | try: |
| | | if not python.is_file(): |
| | | raise ApiError("Change-detection virtual environment is unavailable. Run the capability setup first.") |
| | | self._update_scan_job(run_id, status="running", phase="inference") |
| | | self._update_scan_job(run_id, status="running", phase="inference", device=execution["device"], environment=execution["environment"], torchVersion=execution["torchVersion"]) |
| | | with RUN_LOCK: |
| | | self.run_command( |
| | | [ |
| | | str(python), |
| | | python, |
| | | str(self.root / "capabilities" / "00-change-detection" / "run_change_detection.py"), |
| | | "--before", str(before_path), |
| | | "--after", str(after_path), |
| | | "--threshold", "0.5000", |
| | | "--max-dimension", str(max_dimension), |
| | | "--processing-mode", processing_mode, |
| | | "--device", execution["device"], |
| | | "--processed-output", str(processed_root), |
| | | "--output", str(inference_output), |
| | | ], |
| | |
| | | inference_metadata_path.write_text(json.dumps(inference_metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | self._update_scan_job(run_id, phase="parameter-scan") |
| | | command = [ |
| | | str(python), |
| | | python, |
| | | str(self.root / "capabilities" / "00-change-detection" / "scan_change_detection_parameters.py"), |
| | | "--run-dir", str(inference_output), |
| | | "--output", str(scan_output), |
| | |
| | | self.run_command(command, SCAN_JOB_TIMEOUT) |
| | | self._update_scan_job(run_id, phase="vectorization") |
| | | vector_command = [ |
| | | str(python), |
| | | python, |
| | | str(self.root / "capabilities" / "00-change-detection" / "materialize_parameter_scan_candidates.py"), |
| | | "--scan-dir", str(scan_output), |
| | | ] |
| | |
| | | "minimum_areas": areas, |
| | | "processing_mode": processing_mode, |
| | | "max_dimension": max_dimension, |
| | | "device": execution["device"], |
| | | "environment": execution["environment"], |
| | | "torch_version": execution["torchVersion"], |
| | | "raw_input_dir": relative_path(self.root, before_path.parent.parent), |
| | | "processed_input_dir": relative_path(self.root, processed_root), |
| | | } |
| | |
| | | with self.assertRaisesRegex(MODULE.ApiError, "CUDA was requested"): |
| | | MODULE.pointcloud_execution_environment(root, "cuda") |
| | | |
| | | def test_object_detection_execution_uses_fixed_gpu_environment_when_probe_succeeds(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | gpu_python = root / ".venvs" / MODULE.OBJECT_DETECTION_GPU_ENVIRONMENT / "Scripts" / "python.exe" |
| | | gpu_python.parent.mkdir(parents=True) |
| | | gpu_python.write_bytes(b"fixed-interpreter") |
| | | with mock.patch.object(MODULE.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, '{"cuda": true, "torch": "2.11.0+cu128"}\n', "")): |
| | | execution = MODULE.object_detection_execution_environment(root) |
| | | self.assertEqual(execution["device"], "cuda") |
| | | self.assertEqual(execution["environment"], MODULE.OBJECT_DETECTION_GPU_ENVIRONMENT) |
| | | |
| | | def test_run_discovery_uses_actual_device_in_case_note(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | detection = root / "shared" / "outputs" / "01-object-detection" / "gpu-case" |
| | | raw = root / "shared" / "data" / "raw" / "01-object-detection" / "gpu-case" |
| | | detection.mkdir(parents=True) |
| | | raw.mkdir(parents=True) |
| | | (detection / "detections.json").write_text("{}", encoding="utf-8") |
| | | (detection / "run_metadata.json").write_text(json.dumps({"input_dir": str(raw), "created_at": "2026-08-25", "device": "cuda:0"}), encoding="utf-8") |
| | | self.assertIn("GPU", MODULE.detection_runs(root)[0]["note"]) |
| | | |
| | | change = root / "shared" / "outputs" / "00-change-detection" / "gpu-case" |
| | | change_raw = root / "shared" / "data" / "raw" / "00-change-detection" / "gpu-case" |
| | | before = change_raw / "before.jpg" |
| | | after = change_raw / "after.jpg" |
| | | change.mkdir(parents=True) |
| | | change_raw.mkdir(parents=True) |
| | | before.write_bytes(b"before") |
| | | after.write_bytes(b"after") |
| | | (change / "overlay.jpg").write_bytes(b"overlay") |
| | | (change / "changes.geojson").write_text("{}", encoding="utf-8") |
| | | (change / "run_metadata.json").write_text(json.dumps({"capability": "00-change-detection", "schema_version": 1, "created_at": "2026-08-25", "device": "cuda", "input_files": ["before.jpg", "after.jpg"], "raw_input_dir": change_raw.relative_to(root).as_posix(), "raw_before": before.relative_to(root).as_posix(), "raw_after": after.relative_to(root).as_posix(), "artifacts": {"overlay": "overlay.jpg", "vector": "changes.geojson"}}), encoding="utf-8") |
| | | self.assertIn("GPU", MODULE.change_runs(root)[0]["note"]) |
| | | |
| | | def test_object_detection_execution_falls_back_to_fixed_cpu_environment(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | cpu_python = root / ".venvs" / MODULE.OBJECT_DETECTION_CPU_ENVIRONMENT / "Scripts" / "python.exe" |
| | | gpu_python = root / ".venvs" / MODULE.OBJECT_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.object_detection_execution_environment(root) |
| | | self.assertEqual(execution["device"], "cpu") |
| | | self.assertEqual(execution["environment"], MODULE.OBJECT_DETECTION_CPU_ENVIRONMENT) |
| | | |
| | | def test_change_detection_execution_uses_fixed_gpu_environment_when_probe_succeeds(self) -> None: |
| | | with tempfile.TemporaryDirectory() as temp_dir: |
| | | root = Path(temp_dir) |
| | | gpu_python = root / ".venvs" / MODULE.CHANGE_DETECTION_GPU_ENVIRONMENT / "Scripts" / "python.exe" |
| | | gpu_python.parent.mkdir(parents=True) |
| | | gpu_python.write_bytes(b"fixed-interpreter") |
| | | with mock.patch.object(MODULE.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, '{"cuda": true, "torch": "2.11.0+cu128"}\n', "")): |
| | | execution = MODULE.change_detection_execution_environment(root) |
| | | self.assertEqual(execution["device"], "cuda") |
| | | self.assertEqual(execution["environment"], MODULE.CHANGE_DETECTION_GPU_ENVIRONMENT) |
| | | |
| | | def test_change_detection_execution_falls_back_to_fixed_cpu_environment(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) |
| | | self.assertEqual(execution["device"], "cpu") |
| | | self.assertEqual(execution["environment"], MODULE.CHANGE_DETECTION_CPU_ENVIRONMENT) |
| | | |
| | | def test_semantic_validation_run_is_discovered(self) -> None: |
| | | runs = MODULE.semantic_runs(ROOT) |
| | | self.assertTrue(any(item["id"] == "validation-20260817" for item in runs)) |