13 files modified
8 files added
| | |
| | | *.pt |
| | | Ultralytics/ |
| | | reference_article/ |
| | | project_article/ |
| | | |
| | | shared/data/raw/* |
| | | shared/data/interim/* |
| | |
| | | |
| | | The environment reuses the compatible package set from the verified object-detection environment after the isolated setup exceeded the dependency-download timeout. It has its own Python 3.12 interpreter and `pip check` passes. |
| | | |
| | | Dedicated spatial-measurement environment: |
| | | |
| | | `E:\AllWorkProject\geoai-workbench\.venvs\04-spatial-measurement` |
| | | |
| | | | Component | Version | |
| | | | --- | --- | |
| | | | Python | 3.12.10 | |
| | | | geoai-py | 0.42.0 | |
| | | | rasterio | 1.5.1 | |
| | | | geopandas | 1.1.4 | |
| | | | OpenCV | 4.14.0 | |
| | | |
| | | The environment is CPU-only and `pip check` passes. |
| | | |
| | | Dedicated trajectory-analysis environment: |
| | | |
| | | `E:\AllWorkProject\geoai-workbench\.venvs\15-trajectory-analysis` |
| | |
| | | |
| | | `geoai-py` is intentionally not installed in this environment because version 0.42.0 has no trajectory, tracking, or behavior-recognition API. The capability consumes timestamped track results that may originate from an upstream GeoAI detection/export workflow. `pip check` passes. |
| | | |
| | | Dedicated change-detection environment: |
| | | |
| | | `E:\AllWorkProject\geoai-workbench\.venvs\00-change-detection` |
| | | |
| | | | Component | Version | |
| | | | --- | --- | |
| | | | Python | 3.12.10 | |
| | | | geoai-py | 0.42.0 | |
| | | | torchange | 0.0.4 | |
| | | | ever-beta | 0.6.1 | |
| | | | torch | 2.13.0+cpu | |
| | | | rasterio | 1.5.1 | |
| | | | geopandas | 1.1.4 | |
| | | | OpenCV | 4.14.0 | |
| | | |
| | | 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. |
| | | |
| | | ## 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: `01-object-detection`, `02-semantic-mapping`, and `15-trajectory-analysis` support new local runs, searchable case libraries, and visual result workspaces. Semantic mapping loads its controlled task catalog, shows source/raster comparison, a GeoJSON polygon preview, class proportions, and downloads. The server creates a new run ID for every submission, only accepts allowlisted input types and enabled tasks, and calls fixed capability scripts in fixed virtual environments. |
| | | - Current workflow: `00-change-detection`, `01-object-detection`, `02-semantic-mapping`, `04-spatial-measurement`, and `15-trajectory-analysis` support new local runs, searchable case libraries, and visual result workspaces. Change detection shows two-date imagery, a ChangeStar raster overlay, pixel-coordinate GeoJSON, per-feature probability stats and downloads. Spatial measurement shows raster/vector results, per-object metrics and downloads. The server creates a new run ID for every submission, only accepts allowlisted input types and enabled tasks, and calls fixed capability scripts in fixed virtual environments. |
| | | - 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` | Existing product capability confirmed; local Demo not implemented | The current system's orthophoto change detection already uses GeoAI. | |
| | | | `00-change-detection` | Runnable CPU Demo verified | A capability: `geoai.ChangeStarDetection` plus `geoai.masks_to_vector`; ORB registration and JPG-to-GeoTIFF conversion are input preparation. 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. Self-comparison completed with zero changed pixels. CLI/API/console runs accept a per-run `0.01`-`0.99` threshold (default `0.5`) and record it in `run_metadata.json`. The images have no CRS and the ChangeStar weights are CC BY-NC-SA 4.0, so no accuracy or commercial-use claim is made. | |
| | | | `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. | |
| | | | `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. | |
| | | | `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. | |
| | | | `03` through `14`, `16` through `18` | Directory and initial README only | No verified local Demo yet. Start each one through `$geoai-capability-builder`. | |
| | | | `03`, `05` through `14`, `16` through `18` | Directory and initial README only | No verified local Demo yet. Start each one through `$geoai-capability-builder`. | |
| | | |
| | | ## Semantic Mapping Snapshot |
| | | |
| | | ## Change Detection Snapshot |
| | | |
| | | - Script: `capabilities/00-change-detection/run_change_detection.py`. |
| | | - Boundary: A. The core inference and vector conversion call `geoai.ChangeStarDetection` and `geoai.masks_to_vector`; OpenCV ORB homography is only input registration for ordinary JPGs. |
| | | - Inputs: user-provided `baseData/07-16.jpg` and `baseData/07-19.jpg`, both 3664 x 2748 RGB with no usable CRS. Staged copies are under `shared/data/raw/00-change-detection/validation-20260817/`. |
| | | - Outputs: `shared/outputs/00-change-detection/validation-real-20260817-v4/` and `validation-self-20260817-v3/` contain registered GeoTIFFs, probability/mask rasters, overlay, GeoJSON, feature stats and `run_metadata.json`. |
| | | - 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. |
| | | - 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` on local `6xxx` ports. Real uploads at `6176` verified both an explicit `0.8` threshold and the omitted-threshold default `0.5`, with separate raw before/after directories and a separate processed directory; unsafe basename sanitization and invalid/oversized request rejection were verified. |
| | | - Next decision: obtain georeferenced same-GSD orthophotos and manually labelled change masks for the intended business class, then compare registration error, false positives, misses and IoU before any batch or product use. |
| | | |
| | | - Script: `capabilities/02-semantic-mapping/run_semantic_segmentation.py`. |
| | | - Boundary: B. `geoai-py 0.42.0` supplies class-wise `masks_to_vector`; deterministic RGB/HSV rules supply the first offline segmentation mask because no general pretrained weight is bundled. |
| | |
| | | - 当前对象检测环境已具备 Transformers、timm、Lightning 和 OpenCV;`torchange`、`omniwatermask`、`rfdetr`、`terratorch`、`segment-geospatial`、`detectron2` 和 `onnxruntime` 尚未安装。 |
| | | - 目标检测、分割、分类、变化检测、水体/树冠/深度/嵌入等是算法产出可被 GeoAI 工作流承接的方向;电子围栏、告警、无人机控制、工单和调度仍属于项目产品能力。 |
| | | |
| | | ## Spatial Measurement Snapshot |
| | | |
| | | - Script: `capabilities/04-spatial-measurement/run_spatial_measurement.py`. |
| | | - Boundary: B. `geoai-py 0.42.0` supplies `masks_to_vector`; Rasterio, GeoPandas and Shapely supply CRS handling and count/area/perimeter/centroid/bounds measurements. It consumes labelled rasters and does not infer classes from RGB imagery. |
| | | - Inputs: single-band PNG/GeoTIFF labels (`0` background, positive integers as classes). Outputs are object GeoTIFF, PNG preview, measured GeoJSON, CSV and `run_metadata.json`. |
| | | - Validation inputs: normal and difficult 3840 x 2160 label GeoTIFFs derived from the existing real drone-image semantic-mapping validation set, stored under `shared/data/raw/04-spatial-measurement/validation-20260817/`. |
| | | - Measured result: the normal raster produced 1,210 objects in 25.190 seconds; the difficult road-dominant raster produced 89 objects in 9.474 seconds. Both call `geoai.masks_to_vector`, detect incomplete complex contours against a Rasterio reference, and apply a recorded completeness repair; raster/vector/table artifacts and both previews were inspected. |
| | | - Unit boundary: both real validation rasters have no CRS, so results are explicitly `pixel^2` and `pixel`. A projected-CRS unit test confirms the map-unit branch. No metre or accuracy claim is made for the real samples. |
| | | - Console: `GET/POST /api/spatial-measurement/runs`, at most four allowlisted PNG/TIF/TIFF label rasters. A valid upload, unsafe-name sanitization, invalid extension, oversized request, and script failure were verified at local port 6175; the default remains 6173. Browser screenshot verification was unavailable because no browser backend was exposed. |
| | | - Next decision: obtain a georeferenced projected label GeoTIFF plus surveyed or manually measured truth, then evaluate count false positives/misses and area/perimeter error before product or batch use. |
| | | |
| | | ## Object Detection Snapshot |
| | | |
| | | Target classes: people, vehicles, and trees. |
| | |
| | | - `scripts/serve_workbench_console.py`:本地控制台服务和受限运行 API;仅暴露构建后的控制台、结果工件与必要原图,且只调用固定虚拟环境和能力脚本。 |
| | | - `shared/outputs/`:能力原始输出;控制台不复制、不修改这些文件。 |
| | | |
| | | 首版已接入 `01-object-detection`、`02-semantic-mapping` 和 `15-trajectory-analysis`。田墩实飞案例会显示 |
| | | 当前已接入 `00-change-detection`、`01-object-detection`、`02-semantic-mapping`、`04-spatial-measurement` 和 `15-trajectory-analysis`。变化检测案例显示两期原图、变化叠加、像素坐标 GeoJSON 图斑和概率/配准指标;上传接口只接受一对 JPG/JPEG/PNG/TIF/TIFF,服务端固定调用 `.venvs/00-change-detection`。田墩实飞案例会显示 |
| | | 实飞轨迹、计划航线、禁飞区与适飞区,并提供轨迹和区域范围的聚焦按钮。其他能力 |
| | | 保留目录和边界状态,待其首个 Demo 产出可检查工件后再接入。 |
| | | ## 本地运行工作台 |
| | |
| | | - 轨迹分析上传 `XLSX`、`KMZ`、禁飞区 `GeoJSON` 和可选适飞区 `Gzip`,原始、处理和输出分别归档在 `shared/data/raw`、`shared/data/processed`、`shared/outputs`。 |
| | | - 目标检测上传最多 12 张 `JPG/JPEG/PNG`,原图和标注图默认并列对比,每次生成独立运行编号。 |
| | | - 语义分割上传最多 6 张 `JPG/JPEG/PNG/TIF/TIFF`,任务预设从能力目录动态读取;当前只有通用颜色流程可运行,排洪沟、边坡、竖井和尾矿库任务在模型与真值齐备前保持禁用。结果展示原图、栅格叠加、GeoJSON 矢量预览、类别比例和可下载工件。 |
| | | - 本地 API 为 `GET/POST /api/trajectory/runs`、`GET/POST /api/object-detection/runs`、`GET/POST /api/semantic-mapping/runs` 和只读的 `GET /api/semantic-mapping/tasks`,仅监听回环地址,端口必须是 `6xxx`;服务只调用固定虚拟环境和能力脚本,不接受任意命令或任意路径。 |
| | | - 空间测量上传最多 4 份 `PNG/TIF/TIFF` 单波段标签栅格,展示彩色栅格、GeoAI 矢量对象、计数、面积、周长和对象明细;只有有效投影 CRS 才使用地图单位。 |
| | | - 变化检测上传一对 `JPG/JPEG/PNG/TIF/TIFF`,每次运行可设置 `0.01~0.99` 的变化阈值(默认 `0.5`),展示第一期、第二期、变化叠加、GeoJSON 图斑和概率/配准元数据;普通 JPG 坐标保持为像素坐标。 |
| | | - 本地 API 还包括 `GET/POST /api/change-detection/runs`;每次提交生成独立的 raw、processed 和 output 目录,不覆盖既有运行。 |
| | | - 本地 API 为 `GET/POST /api/trajectory/runs`、`GET/POST /api/object-detection/runs`、`GET/POST /api/semantic-mapping/runs`、`GET/POST /api/spatial-measurement/runs` 和只读的 `GET /api/semantic-mapping/tasks`,仅监听回环地址,端口必须是 `6xxx`;服务只调用固定虚拟环境和能力脚本,不接受任意命令或任意路径。 |
| | |
| | | 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 SemanticCase { id: string; label: string; note: string; artifactRoot: string; inputRoot: string; rawInputRoot: string; createdAt: string; run: SemanticRun; } |
| | | export interface SemanticTask { id: string; name: string; status: string; selectable: boolean; input_sensor: string[]; classes: string[]; project_use?: string; required_evidence?: string[]; prohibited_claim?: string; } |
| | | export interface MeasurementImage { file: string; width: number; height: number; raster_file: string; preview_file: string; vector_file: string; csv_file: string; object_count: number; class_counts: Record<string, number>; total_area: number; total_perimeter: number; area_unit: string; length_unit: string; measurement_basis: string; crs: string | null; georeferenced: boolean; vectorizer: string; elapsed_seconds: number; } |
| | | export interface MeasurementRun { capability: string; classification: string; created_at?: string; geoai_version: string; method: string; model: string; device: string; input_count: number; processed_images: number; elapsed_seconds: number; images: MeasurementImage[]; limitations: string[]; input_dir?: string; raw_input_dir?: string; } |
| | | export interface MeasurementCase { id: string; label: string; note: string; artifactRoot: string; createdAt: string; run: MeasurementRun; } |
| | | 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; thresholds: Record<string, number>; tile_size: number; overlap: 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; georeferenced: boolean; coordinate_basis: string; crs: string | null; elapsed_seconds: number; limitations: string[]; artifacts: { probability_raster: string; raw_mask_raster: string; mask_raster: string; overlay: string; vector: string; features: string }; created_at?: string; } |
| | | export interface ChangeCase { id: string; label: string; note: string; artifactRoot: string; beforeImage: string; afterImage: string; createdAt: string; run: ChangeRun; features: ChangeFeature[]; } |
| | | 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; } |
| | |
| | | interface TrajectoryDefinition extends CaseDefinition { showSpatialContext: boolean; showFlyableZones: boolean; } |
| | | interface DetectionDefinition extends CaseDefinition { inputRoot: string; } |
| | | interface SemanticDefinition extends CaseDefinition { inputRoot: string; rawInputRoot: string; } |
| | | interface MeasurementDefinition extends CaseDefinition {} |
| | | interface ChangeDefinition extends CaseDefinition { beforeImage: string; afterImage: string; } |
| | | export interface UploadFilePayload { name: string; content: string; } |
| | | |
| | | export const artifactUrl = (path: string) => `/${path.replace(/\\/g, "/").split("/").map(encodeURIComponent).join("/")}`; |
| | |
| | | return (await getJson<{ tasks: SemanticTask[] }>("api/semantic-mapping/tasks")).tasks; |
| | | } |
| | | |
| | | export async function loadMeasurementArtifacts(): Promise<Record<string, MeasurementCase>> { |
| | | const { runs } = await getJson<{ runs: MeasurementDefinition[] }>("api/spatial-measurement/runs"); |
| | | const cases = await Promise.all(runs.map(async (definition) => ({ ...definition, run: await getJson<MeasurementRun>(`${definition.artifactRoot}/run_metadata.json`) } satisfies MeasurementCase))); |
| | | return Object.fromEntries(cases.map((item) => [item.id, item])); |
| | | } |
| | | |
| | | export async function loadChangeArtifacts(): Promise<Record<string, ChangeCase>> { |
| | | const { runs } = await getJson<{ runs: ChangeDefinition[] }>("api/change-detection/runs"); |
| | | const cases = await Promise.all(runs.map(async (definition) => { |
| | | const run = await getJson<ChangeRun>(`${definition.artifactRoot}/run_metadata.json`); |
| | | const vector = await getJson<{ features?: ChangeFeature[] }>(`${definition.artifactRoot}/${run.artifacts.vector}`); |
| | | return { ...definition, run, features: vector.features ?? [] } satisfies ChangeCase; |
| | | })); |
| | | return Object.fromEntries(cases.map((item) => [item.id, item])); |
| | | } |
| | | |
| | | 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 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 createChangeRun(files: { before: UploadFilePayload; after: UploadFilePayload }, threshold = 0.5) { return postRun<{ run: ChangeDefinition }>("api/change-detection/runs", { files, threshold }); } |
| | | 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); }); } |
| New file |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, ref } from "vue"; |
| | | import { DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue"; |
| | | |
| | | import { artifactUrl, createChangeRun, readFileAsPayload, type ChangeCase, type ChangeFeature } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import { useArtifactStore } from "@/stores/artifacts"; |
| | | |
| | | const store = useArtifactStore(); |
| | | const caseId = ref(""); |
| | | const beforeFile = ref<File | null>(null); |
| | | const afterFile = ref<File | null>(null); |
| | | const threshold = ref(0.5); |
| | | const showRunForm = ref(false); |
| | | const running = ref(false); |
| | | const runError = ref<string | null>(null); |
| | | const searchText = ref(""); |
| | | 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()))); |
| | | 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)); |
| | | function updateThreshold(value: number | null | undefined) { |
| | | const parsed = Number(value); |
| | | threshold.value = Number.isFinite(parsed) ? Math.min(0.99, Math.max(0.01, parsed)) : 0.5; |
| | | } |
| | | |
| | | function beforeUpload(file: File, period: "before" | "after") { |
| | | if (period === "before") beforeFile.value = file; else afterFile.value = file; |
| | | return false; |
| | | } |
| | | const selectBefore = (file: File) => beforeUpload(file, "before"); |
| | | const selectAfter = (file: File) => beforeUpload(file, "after"); |
| | | function featureRings(feature: ChangeFeature): number[][][] { |
| | | if (feature.geometry.type === "Polygon") return feature.geometry.coordinates as number[][][]; |
| | | if (feature.geometry.type === "MultiPolygon") return (feature.geometry.coordinates as number[][][][]).flat(); |
| | | return []; |
| | | } |
| | | const vectorPaths = computed(() => { |
| | | const height = currentCase.value?.run.input_shape[0] ?? 1; |
| | | return (currentCase.value?.features ?? []).flatMap(featureRings).map((ring) => ring.map((point, index) => `${index ? "L" : "M"}${point[0]},${height - point[1]}`).join(" ") + " Z"); |
| | | }); |
| | | async function submitRun() { |
| | | if (!beforeFile.value || !afterFile.value) { runError.value = "请分别选择第一期和第二期影像。"; return; } |
| | | running.value = true; runError.value = null; |
| | | try { |
| | | const [before, after] = await Promise.all([readFileAsPayload(beforeFile.value), readFileAsPayload(afterFile.value)]); |
| | | const { run } = await createChangeRun({ before, after }, threshold.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; } |
| | | } |
| | | |
| | | onMounted(async () => { await store.loadChange(); caseId.value = Object.keys(store.changeCases)[0] ?? ""; }); |
| | | </script> |
| | | |
| | | <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 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><label>第一期影像</label><a-upload accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="selectBefore"><a-button><UploadOutlined />选择第一期</a-button></a-upload><span>{{ beforeFile?.name || "未选择" }}</span></div> |
| | | <div><label>第二期影像</label><a-upload accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="selectAfter"><a-button><UploadOutlined />选择第二期</a-button></a-upload><span>{{ afterFile?.name || "未选择" }}</span></div> |
| | | </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> |
| | | <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.afterImage)" /></figure><figure><figcaption>变化栅格叠加</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.registration.method }} / {{ currentCase.run.registration.inliers }} 内点</a-descriptions-item><a-descriptions-item label="坐标">无 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 />变化栅格</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 建筑变化权重的验证分布内;结果只能用于工作流与人工复核,不能直接形成工程结论。" /> |
| | | </template> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .change-run-form { display: grid; gap: 16px; margin-bottom: 24px; } |
| | | .change-upload-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } |
| | | .change-upload-grid > div { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 8px 12px; padding: 14px; background: #f7f9f7; border: 1px solid var(--wb-border); } |
| | | .change-upload-grid label { grid-column: 1 / -1; color: var(--wb-muted); font-size: 12px; } |
| | | .change-upload-grid span { min-width: 0; overflow: hidden; color: var(--wb-text); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } |
| | | .threshold-control { display: grid; gap: 8px; padding: 12px 14px; background: #f7f9f7; border: 1px solid var(--wb-border); } |
| | | .threshold-control label { color: var(--wb-muted); font-size: 12px; font-weight: 700; } |
| | | .threshold-inputs { display: grid; grid-template-columns: minmax(0, 1fr) 110px; align-items: center; gap: 16px; } |
| | | .threshold-control > span { color: var(--wb-muted); font-size: 12px; } |
| | | .change-workspace { align-items: stretch; margin-bottom: 24px; } |
| | | .change-workspace > :deep(.ant-col) { display: flex; } |
| | | .change-workspace .surface-section { width: 100%; } |
| | | .change-comparison { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } |
| | | .change-comparison figure { min-width: 0; margin: 0; } |
| | | .change-comparison figcaption { margin-bottom: 7px; color: #425148; font-size: 12px; font-weight: 700; } |
| | | .change-comparison :deep(.ant-image), .change-comparison :deep(img) { width: 100%; } |
| | | .change-comparison :deep(img) { height: 310px; object-fit: contain; background: #202b25; } |
| | | .change-vector { position: relative; min-height: 360px; overflow: hidden; background: #202b25; } |
| | | .change-vector > img { display: block; width: 100%; height: 430px; object-fit: contain; } |
| | | .change-vector > svg { position: absolute; inset: 0; width: 100%; height: 100%; } |
| | | .change-vector path { fill: rgba(232, 61, 49, 0.23); stroke: #e83d31; stroke-width: 7; vector-effect: non-scaling-stroke; } |
| | | .change-vector :deep(.ant-empty) { position: absolute; inset: 0; display: grid; align-content: center; margin: 0; background: rgba(32, 43, 37, 0.72); } |
| | | .change-vector :deep(.ant-empty-description) { color: #f7faf8; } |
| | | .change-files { margin-bottom: 16px; } |
| | | .change-limit { margin-bottom: 24px; } |
| | | @media (max-width: 1199px) { .change-workspace > :deep(.ant-col) { display: block; } } |
| | | @media (max-width: 760px) { .change-upload-grid, .change-comparison { grid-template-columns: 1fr; } .change-comparison :deep(img) { height: 280px; } .change-vector > img { height: 340px; } } |
| | | @media (max-width: 480px) { .threshold-inputs { grid-template-columns: 1fr; gap: 4px; } .threshold-inputs :deep(.ant-input-number) { width: 100%; } } |
| | | </style> |
| New file |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, ref, watch } from "vue"; |
| | | import { FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue"; |
| | | |
| | | import { artifactUrl, createMeasurementRun, readFileAsPayload, type MeasurementCase } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import { useArtifactStore } from "@/stores/artifacts"; |
| | | |
| | | interface GeoFeature { properties?: Record<string, unknown>; geometry?: { type: string; coordinates: unknown }; } |
| | | |
| | | const store = useArtifactStore(); |
| | | const caseId = ref(""); |
| | | const selectedName = ref(""); |
| | | const files = ref<File[]>([]); |
| | | const showRunForm = ref(false); |
| | | const running = ref(false); |
| | | const runError = ref<string | null>(null); |
| | | const searchText = ref(""); |
| | | const vectorFeatures = ref<GeoFeature[]>([]); |
| | | const measurementRows = ref<Record<string, string>[]>([]); |
| | | const currentCase = computed<MeasurementCase | undefined>(() => store.measurementCases[caseId.value] ?? Object.values(store.measurementCases)[0]); |
| | | const selectedImage = computed(() => currentCase.value?.run.images.find((item) => item.file === selectedName.value) ?? currentCase.value?.run.images[0]); |
| | | const caseOptions = computed(() => Object.values(store.measurementCases).map((item) => ({ value: item.id, label: item.label }))); |
| | | const filteredCaseOptions = computed(() => caseOptions.value.filter((item) => item.label.toLowerCase().includes(searchText.value.trim().toLowerCase()))); |
| | | |
| | | function rings(feature: GeoFeature): number[][][] { |
| | | const geometry = feature.geometry; |
| | | if (!geometry) return []; |
| | | if (geometry.type === "Polygon") return geometry.coordinates as number[][][]; |
| | | if (geometry.type === "MultiPolygon") return (geometry.coordinates as number[][][][]).flat(); |
| | | return []; |
| | | } |
| | | const allRings = computed(() => vectorFeatures.value.flatMap(rings)); |
| | | const vectorViewBox = computed(() => { |
| | | const points = allRings.value.flat(); |
| | | if (!points.length) return "0 0 1 1"; |
| | | const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); |
| | | const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys); |
| | | return `${minX} ${minY} ${Math.max(maxX - minX, 1)} ${Math.max(maxY - minY, 1)}`; |
| | | }); |
| | | const vectorPaths = computed(() => allRings.value.map((ring) => ring.map((point, index) => `${index ? "L" : "M"}${point[0]},${point[1]}`).join(" ") + " Z")); |
| | | |
| | | function syncSelection() { selectedName.value = currentCase.value?.run.images[0]?.file ?? ""; } |
| | | 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() { |
| | | vectorFeatures.value = []; |
| | | measurementRows.value = []; |
| | | if (!currentCase.value || !selectedImage.value) return; |
| | | const [vectorResponse, csvResponse] = await Promise.all([ |
| | | fetch(artifactUrl(`${currentCase.value.artifactRoot}/${selectedImage.value.vector_file}`), { cache: "no-store" }), |
| | | fetch(artifactUrl(`${currentCase.value.artifactRoot}/${selectedImage.value.csv_file}`), { cache: "no-store" }) |
| | | ]); |
| | | if (vectorResponse.ok) vectorFeatures.value = ((await vectorResponse.json()) as { features?: GeoFeature[] }).features ?? []; |
| | | if (csvResponse.ok) { |
| | | const lines = (await csvResponse.text()).trim().split(/\r?\n/); |
| | | const headers = lines.shift()?.replace(/^\uFEFF/, "").split(",") ?? []; |
| | | measurementRows.value = lines.filter(Boolean).map((line) => Object.fromEntries(headers.map((header, index) => [header, line.split(",")[index] ?? ""]))); |
| | | } |
| | | } |
| | | async function submitRun() { |
| | | if (!files.value.length) { runError.value = "请至少选择一份标签 PNG 或 GeoTIFF 栅格。"; return; } |
| | | running.value = true; runError.value = null; |
| | | try { |
| | | const rasters = await Promise.all(files.value.map(readFileAsPayload)); |
| | | const { run } = await createMeasurementRun(rasters); |
| | | await store.loadMeasurement(true); caseId.value = run.id; syncSelection(); files.value = []; showRunForm.value = false; |
| | | } catch (error) { runError.value = error instanceof Error ? error.message : "空间测量运行失败"; } |
| | | finally { running.value = false; } |
| | | } |
| | | |
| | | watch([caseId, selectedName], loadVector); |
| | | onMounted(async () => { await store.loadMeasurement(); caseId.value = Object.keys(store.measurementCases)[0] ?? ""; syncSelection(); await loadVector(); }); |
| | | </script> |
| | | |
| | | <template> |
| | | <ArtifactState :loading="store.loading" :error="store.error" /> |
| | | <section class="workspace-command measurement-command"><div><h2>新建空间测量运行</h2><p>上传标签栅格,在 CPU 上生成对象计数、面积/周长统计和 GeoAI GeoJSON。</p></div><a-button type="primary" @click="showRunForm = !showRunForm"><PlayCircleOutlined />{{ showRunForm ? "收起运行表单" : "上传并运行" }}</a-button></section> |
| | | <section v-if="showRunForm" class="surface-section measurement-run-form"><a-alert type="info" show-icon message="单次最多 4 份 PNG/TIF/TIFF 标签栅格;普通 RGB 影像不能直接作为测量输入。" /><a-upload multiple accept=".png,.tif,.tiff" :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"> |
| | | <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="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> |
| | | |
| | | <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="12"><section class="surface-section"><div class="section-heading"><div><h2>测量汇总</h2><p>{{ selectedImage.measurement_basis === "projected_crs" ? "有效投影 CRS,使用地图单位。" : "无有效投影 CRS,使用像素/坐标单位。" }}</p></div><a-tag color="green">{{ selectedImage.object_count }} 个对象</a-tag></div><a-descriptions size="small" :column="{ xs: 1, sm: 2 }"><a-descriptions-item label="对象计数">{{ selectedImage.object_count }}</a-descriptions-item><a-descriptions-item label="总面积">{{ selectedImage.total_area }} {{ selectedImage.area_unit }}</a-descriptions-item><a-descriptions-item label="总周长">{{ selectedImage.total_perimeter }} {{ selectedImage.length_unit }}</a-descriptions-item><a-descriptions-item label="矢量化">{{ selectedImage.vectorizer }}</a-descriptions-item></a-descriptions></section></a-col><a-col :xs="24" :xl="12"><section class="surface-section"><h2>对象明细</h2><a-table :data-source="measurementRows" :pagination="{ pageSize: 8 }" row-key="object_id" size="small" :scroll="{ x: 620 }"><a-table-column title="编号" data-index="object_id" key="object_id" /><a-table-column title="类别" data-index="class_name" key="class_name" /><a-table-column title="面积" data-index="area" key="area" align="right" /><a-table-column title="周长" data-index="perimeter" key="perimeter" align="right" /></a-table></section></a-col></a-row> |
| | | |
| | | <section class="surface-section result-band"><div class="section-heading"><div><h2>运行与下载</h2><p>{{ currentCase.note }}</p></div></div><a-descriptions size="small" :column="{ xs: 1, sm: 2, lg: 5 }"><a-descriptions-item label="分类边界">{{ currentCase.run.classification }}</a-descriptions-item><a-descriptions-item label="设备">{{ currentCase.run.device }}</a-descriptions-item><a-descriptions-item label="GeoAI">{{ currentCase.run.geoai_version }}</a-descriptions-item><a-descriptions-item label="处理影像">{{ currentCase.run.processed_images }}</a-descriptions-item><a-descriptions-item label="耗时">{{ currentCase.run.elapsed_seconds }} 秒</a-descriptions-item></a-descriptions><a-space wrap><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.raster_file}`)" target="_blank"><FileImageOutlined />对象栅格 GeoTIFF</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.vector_file}`)" target="_blank"><FileOutlined />测量 GeoJSON</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/${selectedImage.csv_file}`)" target="_blank"><FileOutlined />测量 CSV</a-button><a-button type="link" :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" target="_blank"><FileOutlined />运行元数据</a-button></a-space></section> |
| | | </template> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .measurement-run-form { display: grid; gap: 16px; margin-bottom: 24px; } |
| | | .measurement-workspace, .result-band { margin-bottom: 24px; } |
| | | .measurement-images figure { min-width: 0; } |
| | | .measurement-images :deep(.ant-image), .measurement-images :deep(img) { width: 100%; height: 360px; object-fit: contain; background: #171e1a; } |
| | | .vector-preview { width: 100%; height: 360px; border: 1px solid #d9d9d9; background: #f7f8f9; } |
| | | .vector-preview path { fill: rgba(23, 107, 80, 0.24); stroke: #176b50; stroke-width: 1.5; vector-effect: non-scaling-stroke; } |
| | | @media (max-width: 1199px) { .measurement-images :deep(img), .vector-preview { height: 300px; } } |
| | | </style> |
| | |
| | | } |
| | | |
| | | export const capabilities: CapabilityRecord[] = [ |
| | | { id: "00-change-detection", title: "变化检测", level: "A", status: "existing", note: "现有产品能力已确认;本地 Demo 尚未实现。" }, |
| | | { id: "00-change-detection", title: "变化检测", level: "A", status: "verified", note: "ChangeStar CPU Demo,展示双期影像、变化栅格和 GeoAI 像素坐标图斑。" }, |
| | | { id: "01-object-detection", title: "地物目标检测", level: "A", status: "verified", note: "人员与车辆实验可运行,含标注影像和结构化检测结果。" }, |
| | | { id: "02-semantic-mapping", title: "语义分割", level: "B", status: "verified", note: "CPU 颜色规则基线,展示栅格掩膜、叠加图和 GeoAI 矢量结果。" }, |
| | | { id: "03-attribute-classification", title: "属性分类", level: "A", status: "planned", note: "等待首个本地 Demo。" }, |
| | | { id: "04-spatial-measurement", title: "空间测量", level: "B", status: "planned", note: "等待首个本地 Demo。" }, |
| | | { id: "04-spatial-measurement", title: "空间测量", level: "B", status: "verified", note: "CPU 栅格对象计数、面积与周长测量,展示栅格和 GeoAI 矢量结果。" }, |
| | | { id: "05-3d-pointcloud", title: "三维点云", level: "B", status: "planned", note: "等待首个本地 Demo。" }, |
| | | { id: "06-spatial-reasoning", title: "空间推理", level: "C", status: "planned", note: "等待首个本地 Demo。" }, |
| | | { id: "07-risk-rule-engine", title: "风险规则引擎", level: "C", status: "planned", note: "等待首个本地 Demo。" }, |
| | |
| | | |
| | | import { |
| | | loadDetectionArtifacts, |
| | | loadChangeArtifacts, |
| | | loadSemanticArtifacts, |
| | | loadMeasurementArtifacts, |
| | | loadTrajectoryArtifacts, |
| | | type DetectionCase, |
| | | type ChangeCase, |
| | | type SemanticCase, |
| | | type MeasurementCase, |
| | | type TrajectoryCase |
| | | } from "@/api/artifacts"; |
| | | |
| | |
| | | detectionCases: Record<string, DetectionCase>; |
| | | trajectoryCases: Record<string, TrajectoryCase>; |
| | | semanticCases: Record<string, SemanticCase>; |
| | | measurementCases: Record<string, MeasurementCase>; |
| | | changeCases: Record<string, ChangeCase>; |
| | | loading: boolean; |
| | | error: string | null; |
| | | } |
| | |
| | | detectionRun: (state) => Object.values(state.detectionCases)[0]?.run ?? null, |
| | | detectionImages: (state) => Object.values(state.detectionCases)[0]?.images ?? [] |
| | | }, |
| | | state: (): ArtifactState => ({ detectionCases: {}, trajectoryCases: {}, semanticCases: {}, loading: false, error: null }), |
| | | state: (): ArtifactState => ({ detectionCases: {}, trajectoryCases: {}, semanticCases: {}, measurementCases: {}, changeCases: {}, loading: false, error: null }), |
| | | actions: { |
| | | async loadChange(force = false) { |
| | | if (!force && Object.keys(this.changeCases).length) return; |
| | | this.loading = true; this.error = null; |
| | | try { this.changeCases = await loadChangeArtifacts(); } |
| | | catch (error) { this.error = error instanceof Error ? error.message : "变化检测结果读取失败"; } |
| | | finally { this.loading = false; } |
| | | }, |
| | | async loadDetection(force = false) { |
| | | if (!force && Object.keys(this.detectionCases).length) return; |
| | | this.loading = true; this.error = null; |
| | |
| | | try { this.semanticCases = await loadSemanticArtifacts(); } |
| | | catch (error) { this.error = error instanceof Error ? error.message : "语义分割结果读取失败"; } |
| | | finally { this.loading = false; } |
| | | }, |
| | | async loadMeasurement(force = false) { |
| | | if (!force && Object.keys(this.measurementCases).length) return; |
| | | this.loading = true; this.error = null; |
| | | try { this.measurementCases = await loadMeasurementArtifacts(); } |
| | | catch (error) { this.error = error instanceof Error ? error.message : "空间测量结果读取失败"; } |
| | | finally { this.loading = false; } |
| | | } |
| | | } |
| | | }); |
| | |
| | | import ObjectDetectionPanel from "@/components/ObjectDetectionPanel.vue"; |
| | | import TrajectoryAnalysisPanel from "@/components/TrajectoryAnalysisPanel.vue"; |
| | | import SemanticMappingPanel from "@/components/SemanticMappingPanel.vue"; |
| | | import SpatialMeasurementPanel from "@/components/SpatialMeasurementPanel.vue"; |
| | | import ChangeDetectionPanel from "@/components/ChangeDetectionPanel.vue"; |
| | | import { capabilityById } from "@/data/capabilities"; |
| | | |
| | | const route = useRoute(); |
| | |
| | | <div class="view-container" v-if="capability"> |
| | | <PageHeader :eyebrow="`CAPABILITY ${capability.id} / ${capability.level}`" :title="capability.title" :description="capability.note" /> |
| | | <ObjectDetectionPanel v-if="capability.id === '01-object-detection'" /> |
| | | <ChangeDetectionPanel v-else-if="capability.id === '00-change-detection'" /> |
| | | <TrajectoryAnalysisPanel v-else-if="capability.id === '15-trajectory-analysis'" /> |
| | | <SemanticMappingPanel v-else-if="capability.id === '02-semantic-mapping'" /> |
| | | <SpatialMeasurementPanel v-else-if="capability.id === '04-spatial-measurement'" /> |
| | | <a-empty v-else description="等待第一个可验证 Demo"><template #description><h2>尚无本地结果工件</h2><p>该能力已登记在目录中,但尚未产生可视化结果、结构化输出或运行元数据。</p></template></a-empty> |
| | | </div> |
| | | <div v-else class="view-container"><a-result status="404" title="未找到能力页面" sub-title="请从左侧目录选择已登记的能力。" /></div> |
| | |
| | | # Change Detection |
| | | # 变化检测 |
| | | |
| | | 现有产品能力的集成、回归和对照入口。 |
| | | 这是一个可独立运行的 CPU Demo,用 `geoai-py 0.42.0` 的 ChangeStar 接口对双期 |
| | | 影像生成变化概率栅格、二值变化栅格和变化图斑 GeoJSON。能力边界为 **A(直接 |
| | | GeoAI 能力)**;OpenCV 只负责普通 JPG 的特征配准和尺寸处理,Rasterio/GeoPandas |
| | | 负责无 CRS 输入的栅格与矢量写出。 |
| | | |
| | | - 输入:双期正射影像或同航点双期照片。 |
| | | - 输出:变化图斑、类型、面积、置信度和事件建议。 |
| | | - 首个任务:整理现有接口、样例和评估口径,不重复开发现有算法。 |
| | | ## 输入契约 |
| | | |
| | | - `--before`:第一期影像(用户样例为 `baseData/07-16.jpg`)。 |
| | | - `--after`:第二期影像(用户样例为 `baseData/07-19.jpg`)。 |
| | | - JPG/PNG 可以运行,但必须先转为内部 GeoTIFF;生产输入应是同 CRS、同 GSD、已 |
| | | 正射校正的双期 GeoTIFF。 |
| | | - 用户样例是 3664 x 2748 的普通 JPG,没有有效 CRS。所有 GeoTIFF/GeoJSON 坐标 |
| | | 因此是像素坐标(栅格使用北向上变换,GeoJSON 的 y 从图像底部量),不能解释 |
| | | 为米或经纬度。 |
| | | |
| | | ## 运行 |
| | | |
| | | ```powershell |
| | | & .\.venvs\00-change-detection\Scripts\python.exe ` |
| | | .\capabilities\00-change-detection\run_change_detection.py ` |
| | | --before .\baseData\07-16.jpg ` |
| | | --after .\baseData\07-19.jpg ` |
| | | --threshold 0.5 ` |
| | | --output .\shared\outputs\00-change-detection\validation-real-20260817 |
| | | ``` |
| | | |
| | | 首次运行会从 Hugging Face 缓存 ChangeStar `s1_s1c1_vitb` 权重;CPU 运行时间与 |
| | | 机器和缩放参数有关。`--max-dimension` 默认 1024,`--tile-size` 默认 512,适合 |
| | | 先做代表性验证;增加尺寸或批量运行前应先人工检查配准和误报。每次运行可单独 |
| | | 选择 `0.01` 到 `0.99` 的变化概率阈值,默认值为 `0.5`;实际阈值会写入 |
| | | `run_metadata.json` 的 `thresholds.change_probability`。 |
| | | |
| | | ## 输出 |
| | | |
| | | 每次运行目录包含: |
| | | |
| | | - `before.tif`、`after_registered.tif`:处理输入,记录像素坐标变换。 |
| | | - `change_probability.tif`:模型输出的 float32 变化概率。 |
| | | - `change_mask_raw.tif`、`change_mask.tif`:阈值前后二值栅格(0/255)。 |
| | | - `change_overlay.png`:第二期影像上的红色变化叠加和双期对比。 |
| | | - `changes.geojson`:通过 `geoai.masks_to_vector` 导出的变化图斑,附带面积和概率 |
| | | 统计;无变化时也会写出空 `FeatureCollection`。 |
| | | - `change_features.json`:图斑面积、概率统计和像素边界。 |
| | | - `run_metadata.json`:版本、模型、阈值、配准、缩放、耗时、坐标和限制说明。 |
| | | |
| | | ## 验收口径 |
| | | |
| | | 已验证两类代表性样本: |
| | | |
| | | 1. 正常基线:`07-16.jpg` 与自身比较,期望变化比例接近 0,检查管线的空变化 |
| | | 输出、GeoJSON 和元数据是否完整。 |
| | | 2. 困难真实样本:`07-16.jpg` 对 `07-19.jpg`,检查特征配准、有效区域遮罩、栅格 |
| | | 和矢量输出是否可读。该样本是近景边坡照片,不是 ChangeStar 训练分布,不能 |
| | | 据此宣称工程变化检测准确率。 |
| | | |
| | | 没有人工变化真值,Demo 不报告 precision、recall、IoU 或“变化类型”。后续若要 |
| | | 用于边坡/排洪沟等业务,需要同 CRS 正射影像、人工掩膜、误报/漏报统计和许可审查。 |
| | | |
| | | ## 许可 |
| | | |
| | | - `geoai-py 0.42.0`:MIT。 |
| | | - `torchange 0.0.4`:Apache-2.0;`ever-beta` 的 PyPI 元数据标为保留权利,需 |
| | | 单独复核。 |
| | | - ChangeStar 权重 `EVER-Z/Changen2-ChangeStar1x256`:`CC BY-NC-SA 4.0`,当前 |
| | | 只能用于非商业验证,不可直接用于商业产品。 |
| | | - 用户提供的 `baseData` 图片:版权/使用授权未知,需由数据提供方确认。 |
| | |
| | | torch>=2.4,<3 |
| | | torchvision>=0.19,<1 |
| | | albumentations>=1.4,<3 |
| | | |
| | | geoai-py==0.42.0 |
| | | torchange==0.0.4 |
| New file |
| | |
| | | """CPU ChangeStar demo for two-date imagery with inspectable raster/vector outputs.""" |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import json |
| | | import math |
| | | import platform |
| | | import time |
| | | from datetime import UTC, datetime |
| | | from pathlib import Path |
| | | from typing import Any |
| | | |
| | | import cv2 |
| | | import numpy as np |
| | | import rasterio |
| | | from affine import Affine |
| | | from PIL import Image |
| | | from rasterio.features import shapes |
| | | from shapely.geometry import shape as shapely_shape |
| | | |
| | | |
| | | MODEL_NAME = "s1_s1c1_vitb" |
| | | DEFAULT_THRESHOLD = 0.5 |
| | | DEFAULT_TILE_SIZE = 512 |
| | | DEFAULT_OVERLAP = 64 |
| | | DEFAULT_MAX_DIMENSION = 1024 |
| | | MIN_THRESHOLD = 0.01 |
| | | MAX_THRESHOLD = 0.99 |
| | | |
| | | |
| | | def _read_rgb(path: Path) -> np.ndarray: |
| | | with Image.open(path) as image: |
| | | return np.asarray(image.convert("RGB")) |
| | | |
| | | |
| | | def _register_after(before: np.ndarray, after: np.ndarray) -> tuple[np.ndarray, np.ndarray, dict[str, Any]]: |
| | | """Register the second image using ORB homography, returning a valid-pixel mask.""" |
| | | height, width = before.shape[:2] |
| | | scale = min(1.0, 1200.0 / max(height, width)) |
| | | small_before = cv2.resize(before, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) |
| | | small_after = cv2.resize(after, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) |
| | | gray_before = cv2.cvtColor(small_before, cv2.COLOR_RGB2GRAY) |
| | | gray_after = cv2.cvtColor(small_after, cv2.COLOR_RGB2GRAY) |
| | | orb = cv2.ORB_create(nfeatures=4000, fastThreshold=7) |
| | | key_before, desc_before = orb.detectAndCompute(gray_before, None) |
| | | key_after, desc_after = orb.detectAndCompute(gray_after, None) |
| | | details: dict[str, Any] = {"method": "identity", "matches": 0, "inliers": 0, "inlier_ratio": 0.0} |
| | | matrix = np.eye(3, dtype=np.float32) |
| | | if desc_before is not None and desc_after is not None and len(key_before) >= 8 and len(key_after) >= 8: |
| | | matcher = cv2.BFMatcher(cv2.NORM_HAMMING) |
| | | pairs = matcher.knnMatch(desc_after, desc_before, k=2) |
| | | good = [a for a, b in pairs if a.distance < 0.75 * b.distance] |
| | | if len(good) >= 8: |
| | | src = np.float32([key_after[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) |
| | | dst = np.float32([key_before[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) |
| | | candidate, mask = cv2.findHomography(src, dst, cv2.RANSAC, 5.0) |
| | | if candidate is not None and mask is not None and int(mask.sum()) >= 8: |
| | | matrix = candidate.astype(np.float32) |
| | | inliers = int(mask.sum()) |
| | | details = { |
| | | "method": "orb_homography", |
| | | "matches": len(good), |
| | | "inliers": inliers, |
| | | "inlier_ratio": round(inliers / len(good), 4), |
| | | } |
| | | registered = cv2.warpPerspective(after, matrix, (width, height), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT) |
| | | valid = cv2.warpPerspective(np.full((after.shape[0], after.shape[1]), 255, dtype=np.uint8), matrix, (width, height), flags=cv2.INTER_NEAREST, borderMode=cv2.BORDER_CONSTANT) |
| | | details["valid_ratio"] = round(float((valid > 0).mean()), 6) |
| | | details["matrix"] = [[round(float(value), 8) for value in row] for row in matrix] |
| | | return registered, valid > 0, details |
| | | |
| | | |
| | | def _resize_pair(before: np.ndarray, after: np.ndarray, valid: np.ndarray, max_dimension: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, Any]]: |
| | | height, width = before.shape[:2] |
| | | scale = min(1.0, float(max_dimension) / max(height, width)) |
| | | out_width = max(32, int(round(width * scale / 32) * 32)) |
| | | out_height = max(32, int(round(height * scale / 32) * 32)) |
| | | # Keep a stable 32-pixel multiple for the model while documenting the actual scale. |
| | | resized_before = cv2.resize(before, (out_width, out_height), interpolation=cv2.INTER_AREA) |
| | | resized_after = cv2.resize(after, (out_width, out_height), interpolation=cv2.INTER_AREA) |
| | | resized_valid = cv2.resize(valid.astype(np.uint8), (out_width, out_height), interpolation=cv2.INTER_NEAREST) > 0 |
| | | return resized_before, resized_after, resized_valid, { |
| | | "original_width": width, |
| | | "original_height": height, |
| | | "processed_width": out_width, |
| | | "processed_height": out_height, |
| | | "scale_x": round(width / out_width, 8), |
| | | "scale_y": round(height / out_height, 8), |
| | | } |
| | | |
| | | |
| | | def _write_rgb_geotiff(path: Path, image: np.ndarray, scale_x: float, scale_y: float, original_height: int) -> None: |
| | | # North-up transform keeps both images in the same pixel-coordinate extent. |
| | | transform = Affine(scale_x, 0, 0, 0, -scale_y, original_height) |
| | | with rasterio.open(path, "w", driver="GTiff", height=image.shape[0], width=image.shape[1], count=3, dtype="uint8", transform=transform, compress="lzw") as dst: |
| | | dst.write(np.transpose(image, (2, 0, 1))) |
| | | |
| | | |
| | | def _write_raster(path: Path, data: np.ndarray, transform: Affine, dtype: str) -> None: |
| | | with rasterio.open(path, "w", driver="GTiff", height=data.shape[0], width=data.shape[1], count=1, dtype=dtype, transform=transform, compress="lzw") as dst: |
| | | dst.write(data.astype(dtype), 1) |
| | | |
| | | |
| | | def _write_overlay(path: Path, before: np.ndarray, after: np.ndarray, mask: np.ndarray) -> None: |
| | | left = before.copy() |
| | | right = after.copy() |
| | | red = np.zeros_like(right) |
| | | red[..., 0] = 255 |
| | | right[mask] = (right[mask].astype(np.float32) * 0.45 + red[mask].astype(np.float32) * 0.55).astype(np.uint8) |
| | | separator = np.full((before.shape[0], 8, 3), 235, dtype=np.uint8) |
| | | Image.fromarray(np.concatenate([left, separator, right], axis=1)).save(path, quality=92) |
| | | |
| | | |
| | | def _feature_summary(mask: np.ndarray, probability: np.ndarray, transform: Affine) -> list[dict[str, Any]]: |
| | | features: list[dict[str, Any]] = [] |
| | | for index, (geometry, value) in enumerate(shapes(mask.astype(np.uint8), transform=transform), start=1): |
| | | if int(value) != 255: |
| | | continue |
| | | polygon = shapely_shape(geometry) |
| | | coords = np.asarray(polygon.exterior.coords) |
| | | x = np.clip(np.round(coords[:, 0] / transform.a).astype(int), 0, mask.shape[1] - 1) |
| | | y = np.clip(np.round((transform.f - coords[:, 1]) / abs(transform.e)).astype(int), 0, mask.shape[0] - 1) |
| | | sample = probability[y, x] |
| | | features.append({ |
| | | "feature_id": index, |
| | | "area_pixels": round(float(polygon.area / (abs(transform.a * transform.e))), 3), |
| | | "mean_probability": round(float(sample.mean()) if sample.size else 0.0, 6), |
| | | "max_probability": round(float(sample.max()) if sample.size else 0.0, 6), |
| | | "bounds_pixel": [round(float(v), 3) for v in polygon.bounds], |
| | | }) |
| | | return features |
| | | |
| | | |
| | | def _enrich_vector(vector_path: Path, features: list[dict[str, Any]]) -> None: |
| | | import geopandas as gpd |
| | | |
| | | if vector_path.is_file(): |
| | | vector = gpd.read_file(vector_path) |
| | | for key in ("feature_id", "area_pixels", "mean_probability", "max_probability", "bounds_pixel"): |
| | | vector[key] = [item.get(key) for item in features[: len(vector)]] |
| | | vector.to_file(vector_path, driver="GeoJSON") |
| | | payload = json.loads(vector_path.read_text(encoding="utf-8")) |
| | | payload.pop("crs", None) |
| | | vector_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | else: |
| | | 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 = DEFAULT_MAX_DIMENSION) -> 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}") |
| | | before = _read_rgb(before_path) |
| | | after = _read_rgb(after_path) |
| | | if before.shape[:2] != after.shape[:2]: |
| | | raise ValueError(f"Input dimensions must match before={before.shape[:2]} after={after.shape[:2]}") |
| | | output_dir.mkdir(parents=True, exist_ok=False) |
| | | work_dir = processed_dir or output_dir |
| | | if processed_dir is not None: |
| | | processed_dir.mkdir(parents=True, exist_ok=False) |
| | | registered, valid, registration = _register_after(before, after) |
| | | before_small, after_small, valid_small, resize_details = _resize_pair(before, registered, valid, max_dimension) |
| | | _write_rgb_geotiff(work_dir / "before.tif", before_small, resize_details["scale_x"], resize_details["scale_y"], resize_details["original_height"]) |
| | | _write_rgb_geotiff(work_dir / "after_registered.tif", after_small, resize_details["scale_x"], resize_details["scale_y"], resize_details["original_height"]) |
| | | transform = Affine(resize_details["scale_x"], 0, 0, 0, -resize_details["scale_y"], resize_details["original_height"]) |
| | | |
| | | from geoai import ChangeStarDetection, masks_to_vector |
| | | |
| | | detector = ChangeStarDetection(model_name=model_name, device="cpu") |
| | | 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 |
| | | # Keep model output intact in raw_mask, then apply a small component filter for presentation. |
| | | cleaned = raw_mask.copy() |
| | | count, labels, stats, _ = cv2.connectedComponentsWithStats((cleaned > 0).astype(np.uint8), connectivity=8) |
| | | min_area = max(16, int(cleaned.size * 0.00002)) |
| | | for component in range(1, count): |
| | | if int(stats[component, cv2.CC_STAT_AREA]) < min_area: |
| | | cleaned[labels == component] = 0 |
| | | _write_raster(output_dir / "change_probability.tif", probability, transform, "float32") |
| | | _write_raster(output_dir / "change_mask_raw.tif", raw_mask, transform, "uint8") |
| | | _write_raster(output_dir / "change_mask.tif", cleaned, transform, "uint8") |
| | | _write_overlay(output_dir / "change_overlay.jpg", before_small, after_small, cleaned > 0) |
| | | vector_path = output_dir / "changes.geojson" |
| | | vector = masks_to_vector(str(output_dir / "change_mask.tif"), str(vector_path), simplify_tolerance=1.0, mask_threshold=0.5, min_object_area=min_area) |
| | | features = _feature_summary(cleaned, probability, transform) |
| | | _enrich_vector(vector_path, features) |
| | | (output_dir / "change_features.json").write_text(json.dumps({"features": features}, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | metadata: dict[str, Any] = { |
| | | "schema_version": 1, |
| | | "capability": "00-change-detection", |
| | | "classification": "A", |
| | | "geoai_version": "0.42.0", |
| | | "method": "geoai.ChangeStarDetection + geoai.masks_to_vector", |
| | | "model": model_name, |
| | | "device": "cpu", |
| | | "thresholds": {"change_probability": threshold, "minimum_component_pixels": min_area}, |
| | | "tile_size": tile_size, |
| | | "overlap": overlap, |
| | | "input_count": 2, |
| | | "processed_images": 2, |
| | | "input_files": [before_path.name, after_path.name], |
| | | "created_at": datetime.now(UTC).isoformat(), |
| | | "input_shape": [int(before.shape[0]), int(before.shape[1])], |
| | | "processed_shape": [int(before_small.shape[0]), int(before_small.shape[1])], |
| | | "registration": registration, |
| | | "resize": resize_details, |
| | | "valid_pixel_ratio": round(float(valid_small.mean()), 6), |
| | | "raw_changed_pixels": int((raw_mask > 0).sum()), |
| | | "changed_pixels": int((cleaned > 0).sum()), |
| | | "changed_pixel_ratio": round(float((cleaned > 0).mean()), 6), |
| | | "vector_feature_count": len(vector), |
| | | "georeferenced": False, |
| | | "coordinate_basis": "pixel_coordinates_north_up_transform_y_from_image_bottom", |
| | | "crs": None, |
| | | "elapsed_seconds": round(time.perf_counter() - started, 3), |
| | | "python": platform.python_version(), |
| | | "platform": platform.platform(), |
| | | "artifacts": { |
| | | "probability_raster": "change_probability.tif", |
| | | "raw_mask_raster": "change_mask_raw.tif", |
| | | "mask_raster": "change_mask.tif", |
| | | "overlay": "change_overlay.jpg", |
| | | "vector": "changes.geojson", |
| | | "features": "change_features.json", |
| | | }, |
| | | "limitations": [ |
| | | "输入 JPG 没有有效 CRS,GeoTIFF/GeoJSON 坐标是像素换算坐标,不是米或经纬度。", |
| | | "ChangeStar 权重训练于 Changen2/S1 建筑变化数据;当前近景边坡照片不在其验证分布内。", |
| | | "没有人工变化真值,不报告 precision、recall、IoU,也不输出变化类型或工程告警。", |
| | | "ORB 配准只用于工作流演示;生产使用需要正射校正、同 GSD 和独立配准质量验收。", |
| | | ], |
| | | "licenses": { |
| | | "geoai-py": "MIT", |
| | | "torchange": "Apache-2.0", |
| | | "ever-beta": "PyPI metadata indicates rights reserved; review required", |
| | | "model_weights": "CC BY-NC-SA 4.0 (non-commercial; source EVER-Z/Changen2-ChangeStar1x256)", |
| | | "user_images": "user-provided; authorization not verified", |
| | | }, |
| | | } |
| | | (output_dir / "run_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return metadata |
| | | |
| | | |
| | | def build_parser() -> argparse.ArgumentParser: |
| | | parser = argparse.ArgumentParser(description="Run CPU 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("--processed-output", type=Path) |
| | | parser.add_argument("--model", default=MODEL_NAME) |
| | | parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD) |
| | | parser.add_argument("--tile-size", type=int, default=DEFAULT_TILE_SIZE) |
| | | parser.add_argument("--overlap", type=int, default=DEFAULT_OVERLAP) |
| | | parser.add_argument("--max-dimension", type=int, default=DEFAULT_MAX_DIMENSION) |
| | | 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), ensure_ascii=False, indent=2)) |
| | | except (FileNotFoundError, ValueError, RuntimeError) as exc: |
| | | raise SystemExit(f"变化检测失败: {exc}") from exc |
| New file |
| | |
| | | import json |
| | | import tempfile |
| | | import unittest |
| | | from pathlib import Path |
| | | import numpy as np |
| | | |
| | | import importlib.util |
| | | |
| | | |
| | | def load_module(): |
| | | path = Path(__file__).parents[1] / "run_change_detection.py" |
| | | spec = importlib.util.spec_from_file_location("change_detection_demo", path) |
| | | module = importlib.util.module_from_spec(spec) |
| | | assert spec and spec.loader |
| | | spec.loader.exec_module(module) |
| | | return module |
| | | |
| | | |
| | | class ChangeDetectionTests(unittest.TestCase): |
| | | def test_resize_pair_preserves_extent(self): |
| | | module = load_module() |
| | | before = np.zeros((100, 200, 3), dtype=np.uint8) |
| | | after = before.copy() |
| | | valid = np.ones((100, 200), dtype=bool) |
| | | _, _, resized_valid, details = module._resize_pair(before, after, valid, 64) |
| | | self.assertEqual((resized_valid.shape[0] % 32, resized_valid.shape[1] % 32), (0, 0)) |
| | | self.assertEqual(details["original_width"], 200) |
| | | self.assertEqual(details["original_height"], 100) |
| | | |
| | | def test_mismatched_inputs_fail_before_creating_outputs(self): |
| | | module = load_module() |
| | | with tempfile.TemporaryDirectory() as directory: |
| | | root = Path(directory) |
| | | from PIL import Image |
| | | Image.new("RGB", (32, 32), "black").save(root / "before.jpg") |
| | | Image.new("RGB", (16, 32), "black").save(root / "after.jpg") |
| | | with self.assertRaises(ValueError): |
| | | module.run_change_detection(root / "before.jpg", root / "after.jpg", root / "out") |
| | | self.assertFalse((root / "out").exists()) |
| | | |
| | | def test_threshold_is_bounded_before_creating_outputs(self): |
| | | module = load_module() |
| | | with tempfile.TemporaryDirectory() as directory: |
| | | root = Path(directory) |
| | | from PIL import Image |
| | | Image.new("RGB", (32, 32), "black").save(root / "before.jpg") |
| | | Image.new("RGB", (32, 32), "black").save(root / "after.jpg") |
| | | with self.assertRaises(ValueError): |
| | | module.run_change_detection(root / "before.jpg", root / "after.jpg", root / "out", threshold=1.0) |
| | | self.assertFalse((root / "out").exists()) |
| | | |
| | | def test_empty_vector_is_written_as_feature_collection(self): |
| | | module = load_module() |
| | | with tempfile.TemporaryDirectory() as directory: |
| | | target = Path(directory) / "changes.geojson" |
| | | module._enrich_vector(target, []) |
| | | payload = json.loads(target.read_text(encoding="utf-8")) |
| | | self.assertEqual(payload, {"type": "FeatureCollection", "features": []}) |
| | | |
| | | def test_enriched_vector_does_not_claim_wgs84(self): |
| | | module = load_module() |
| | | with tempfile.TemporaryDirectory() as directory: |
| | | target = Path(directory) / "changes.geojson" |
| | | target.write_text(json.dumps({ |
| | | "type": "FeatureCollection", |
| | | "features": [{ |
| | | "type": "Feature", |
| | | "properties": {"confidence": 0.5, "class": 1}, |
| | | "geometry": {"type": "Polygon", "coordinates": [[[0, 0], [2, 0], [2, 2], [0, 0]]]}, |
| | | }], |
| | | }), encoding="utf-8") |
| | | details = [{"feature_id": 1, "area_pixels": 2.0, "mean_probability": 0.6, "max_probability": 0.8, "bounds_pixel": [0, 0, 2, 2]}] |
| | | module._enrich_vector(target, details) |
| | | payload = json.loads(target.read_text(encoding="utf-8")) |
| | | self.assertNotIn("crs", payload) |
| | | self.assertEqual(payload["features"][0]["properties"]["feature_id"], 1) |
| | | |
| | | if __name__ == "__main__": |
| | | unittest.main() |
| | |
| | | # Spatial Measurement |
| | | # 空间测量(计数与空间测量) |
| | | |
| | | - 输入:目标框、分割图斑、DEM/DSM 或点云。 |
| | | - 输出:数量、面积、长度、高度和体积。 |
| | | - 首个 Demo:GeoJSON 面积/长度统计和 CSV 导出。 |
| | | 状态:首个 CPU Demo 已验证(2026-08-17)。 |
| | | |
| | | ## 能力边界 |
| | | |
| | | 当前 Demo 属于 **B:GeoAI 与地理空间生态库组合能力**,不是 `geoai-py` 内置的完整测量产品。 |
| | | |
| | | - `geoai-py 0.42.0` 的 `masks_to_vector` 将每个非零栅格类别转为对象多边形。 |
| | | - Rasterio 读取和保留有效 CRS/仿射变换;GeoPandas/Shapely 计算对象数量、面积、周长、质心和包围盒。 |
| | | - Demo 不负责从普通 RGB 影像识别类别。输入必须是上游检测/分割流程产生的标签栅格。 |
| | | - 无有效投影 CRS 时只报告像素或坐标单位,不能表述为米、平方米或工程测量精度。 |
| | | |
| | | ## Demo 契约 |
| | | |
| | | 输入:单份或目录形式的单波段 `PNG/TIF/TIFF` 标签栅格,`0` 为背景,正整数为对象类别。普通 JPEG/RGB 影像不在本 Demo 输入范围内。 |
| | | |
| | | 每份输入输出: |
| | | |
| | | | 文件 | 内容 | |
| | | | --- | --- | |
| | | | `*.objects.tif` | 保留输入标签值的结果栅格;有效地理参考会被保留 | |
| | | | `*.measurement.png` | 可直接检查的彩色栅格预览 | |
| | | | `*.measurements.geojson` | GeoAI 矢量对象及面积、周长、质心、包围盒等属性 | |
| | | | `*.measurements.csv` | 每个对象一行的测量表 | |
| | | | `run_metadata.json` | 版本、设备、输入数、耗时、单位依据、汇总和限制 | |
| | | |
| | | 脚本拒绝写入非空输出目录,避免覆盖已有结果。 |
| | | |
| | | ## 环境与运行 |
| | | |
| | | ```powershell |
| | | powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\setup.ps1 -Capability 04-spatial-measurement |
| | | |
| | | $py = '.\.venvs\04-spatial-measurement\Scripts\python.exe' |
| | | & $py .\capabilities\04-spatial-measurement\run_spatial_measurement.py ` |
| | | --input .\shared\data\raw\04-spatial-measurement\validation-20260817 ` |
| | | --output .\shared\outputs\04-spatial-measurement\my-run |
| | | ``` |
| | | |
| | | 本机已验证 Python 3.12.10、`geoai-py 0.42.0`、Rasterio 1.5.1、GeoPandas 1.1.4 和 OpenCV 4.14.0;仅使用 CPU,`pip check` 通过。 |
| | | |
| | | ## 验收集与实测 |
| | | |
| | | 验收输入位于 `shared/data/raw/04-spatial-measurement/validation-20260817/`,来自语义分割能力对仓库内真实无人机 JPEG 的标签栅格输出副本: |
| | | |
| | | - 正常样本 `normal-vegetation-roads.mask.tif`:3840 x 2160,包含植被、水体和不透水面等多类区域。 |
| | | - 困难样本 `difficult-road.mask.tif`:3840 x 2160,大面积道路类别占主导,用于检查低多样性、小区域和噪声对象。 |
| | | |
| | | | 样本 | 对象数 | 类别对象数 | 总面积 | 总周长 | CPU 耗时 | |
| | | | --- | ---: | --- | ---: | ---: | ---: | |
| | | | 正常 | 1,210 | 1: 130、2: 548、3: 532 | 7,780,682 pixel^2 | 259,430 pixel | 25.190 秒 | |
| | | | 困难 | 89 | 1: 18、2: 49、3: 22 | 8,173,263 pixel^2 | 36,686 pixel | 9.474 秒 | |
| | | |
| | | 两份结果均先使用 `geoai.masks_to_vector`,再与 Rasterio 参考矢量覆盖面积比较。GeoAI 会丢弃部分复杂无效轮廓,因此三类都触发了完整性修复,最终 GeoJSON 保留栅格图斑及孔洞。栅格预览、GeoTIFF、GeoJSON、CSV 和元数据非空;两张 PNG 已视觉检查。GeoJSON 的对象条数分别为 1,210 和 89,且每条均含测量字段。 |
| | | |
| | | 自动验收: |
| | | |
| | | ```powershell |
| | | .\.venvs\04-spatial-measurement\Scripts\python.exe -m unittest discover ` |
| | | -s .\capabilities\04-spatial-measurement\tests -v |
| | | ``` |
| | | |
| | | 测试覆盖像素坐标计数、投影 CRS 测量单位和空标签栅格失败分支。 |
| | | |
| | | ## 本地实验控制台 |
| | | |
| | | 默认入口:<http://127.0.0.1:6173/apps/workbench-console/#/capability/04-spatial-measurement>。本次为避免占用已有 6173 服务,验证实例运行于 <http://127.0.0.1:6175/apps/workbench-console/#/capability/04-spatial-measurement>。 |
| | | |
| | | 页面遵循“新建运行 -> 案例库 -> 结果工作区”,支持最多 4 份标签栅格上传,并列展示栅格预览和矢量对象范围,显示汇总/对象明细并下载 GeoTIFF、GeoJSON、CSV 和元数据。服务端仅调用固定的 `04` 虚拟环境和能力脚本,每次创建新的 raw、processed 和 output run 目录。 |
| | | |
| | | 已验证控制台页面 200、运行发现、有效上传、非法扩展、危险文件名清洗、超大请求拒绝和脚本失败消息。当前浏览器控制接口没有可用浏览器,因此控制台页面未完成截图级桌面/移动端视觉验收。 |
| | | |
| | | ## 许可与限制 |
| | | |
| | | | 项目 | 当前记录 | |
| | | | --- | --- | |
| | | | `geoai-py 0.42.0` | MIT | |
| | | | Rasterio | BSD 3-Clause;GDAL 等随附组件需分别遵守许可 | |
| | | | GeoPandas / pandas / Shapely | BSD 3-Clause 系列 | |
| | | | OpenCV | Apache-2.0 | |
| | | | Pillow | HPND | |
| | | | 模型权重 | 无;本能力消费上游标签栅格 | |
| | | | 验收影像/标签 | 用户项目内已有影像与其派生结果,授权范围未在本 Demo 中扩大 | |
| | | |
| | | 当前对象计数取决于上游标签质量、连通关系和最小对象面积(4 像素)设置。没有人工真值,不能报告计数准确率、误报率或漏报率;真实米制测量还需要带有效投影 CRS、已校准分辨率且精度可追溯的 GeoTIFF。 |
| | |
| | | -r ../../requirements/base.txt |
| | | geopandas>=1,<2 |
| | | # GeoAI vectorization and raster/CRS measurement support. |
| | | geoai-py>=0.42,<0.43 |
| | | rasterio>=1.4,<2 |
| | | scipy>=1.13,<2 |
| | | |
| | | geopandas>=1,<2 |
| New file |
| | |
| | | """Count labelled raster objects and measure their vector footprints on CPU. |
| | | |
| | | The input is a single-band label raster (GeoTIFF or PNG). Non-zero values are |
| | | treated as object classes. ``geoai.masks_to_vector`` performs the mask-to-vector |
| | | step; GeoPandas/Shapely then provide the measurement layer. This is a B |
| | | capability: GeoAI supplies vectorization, while measurement is ecosystem code. |
| | | """ |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import json |
| | | import time |
| | | from datetime import UTC, datetime |
| | | from importlib import metadata as importlib_metadata |
| | | from pathlib import Path |
| | | from typing import Any |
| | | |
| | | import geopandas as gpd |
| | | import numpy as np |
| | | import pandas as pd |
| | | import rasterio |
| | | from PIL import Image |
| | | from rasterio.features import shapes |
| | | from rasterio.transform import Affine |
| | | from shapely.geometry import shape |
| | | |
| | | |
| | | SUPPORTED_SUFFIXES = {".png", ".tif", ".tiff"} |
| | | CLASS_LABELS = { |
| | | 1: "class_1", |
| | | 2: "class_2", |
| | | 3: "class_3", |
| | | 4: "class_4", |
| | | 5: "class_5", |
| | | } |
| | | COLORS = np.array( |
| | | [[0, 0, 0], [44, 160, 44], [31, 119, 180], [255, 127, 14], [148, 103, 189], [214, 39, 40]], |
| | | dtype=np.uint8, |
| | | ) |
| | | |
| | | |
| | | def _read_raster(path: Path) -> tuple[np.ndarray, Affine, str | None, bool]: |
| | | """Read a label raster and retain georeferencing only when it is valid.""" |
| | | if path.suffix.lower() in {".tif", ".tiff"}: |
| | | with rasterio.open(path) as src: |
| | | values = src.read(1) |
| | | transform = src.transform |
| | | crs = src.crs.to_string() if src.crs else None |
| | | georeferenced = bool(src.crs and transform != Affine.identity()) |
| | | return values, transform, crs, georeferenced |
| | | with Image.open(path) as image: |
| | | values = np.asarray(image.convert("L")) |
| | | return values, Affine.identity(), None, False |
| | | |
| | | |
| | | def _fallback_vector(mask: np.ndarray, transform: Affine, crs: str | None, class_id: int) -> gpd.GeoDataFrame: |
| | | binary = mask == class_id |
| | | pixel_area = abs(transform.a * transform.e - transform.b * transform.d) or 1.0 |
| | | records: list[dict[str, Any]] = [] |
| | | for geometry, value in shapes(binary.astype(np.uint8), mask=binary, transform=transform): |
| | | if value != 1: |
| | | continue |
| | | polygon = shape(geometry) |
| | | if polygon.area / pixel_area < 4: |
| | | continue |
| | | records.append({"geometry": polygon, "class_id": class_id}) |
| | | return gpd.GeoDataFrame(records, geometry="geometry", crs=crs) |
| | | |
| | | |
| | | def _vectorize(mask: np.ndarray, transform: Affine, crs: str | None, output_path: Path) -> tuple[gpd.GeoDataFrame, str]: |
| | | """Vectorize each non-zero class through GeoAI, with a transparent fallback.""" |
| | | try: |
| | | from geoai import masks_to_vector |
| | | |
| | | frames: list[gpd.GeoDataFrame] = [] |
| | | repaired_classes: list[int] = [] |
| | | for class_id in sorted(int(value) for value in np.unique(mask) if value > 0): |
| | | reference = _fallback_vector(mask, transform, crs, class_id) |
| | | temporary = output_path.with_name(f".{output_path.stem}-{class_id}.tif") |
| | | binary = np.where(mask == class_id, 255, 0).astype(np.uint8) |
| | | with rasterio.open( |
| | | temporary, |
| | | "w", |
| | | driver="GTiff", |
| | | height=binary.shape[0], |
| | | width=binary.shape[1], |
| | | count=1, |
| | | dtype="uint8", |
| | | transform=transform, |
| | | crs=crs, |
| | | nodata=0, |
| | | ) as dst: |
| | | dst.write(binary, 1) |
| | | try: |
| | | frame = masks_to_vector(str(temporary), min_object_area=4, simplify_tolerance=0.0) |
| | | if not frame.empty: |
| | | frame = frame.assign(class_id=class_id) |
| | | reference_area = float(reference.geometry.area.sum()) |
| | | geoai_area = float(frame.geometry.area.sum()) if not frame.empty else 0.0 |
| | | area_ratio = geoai_area / reference_area if reference_area else 0.0 |
| | | if len(frame) == len(reference) and 0.98 <= area_ratio <= 1.02: |
| | | frames.append(frame[["geometry", "class_id"]]) |
| | | else: |
| | | frames.append(reference[["geometry", "class_id"]]) |
| | | repaired_classes.append(class_id) |
| | | finally: |
| | | temporary.unlink(missing_ok=True) |
| | | if frames: |
| | | merged = gpd.GeoDataFrame(pd.concat(frames, ignore_index=True), geometry="geometry", crs=crs) |
| | | else: |
| | | merged = gpd.GeoDataFrame({"geometry": []}, geometry="geometry", crs=crs) |
| | | vectorizer = "geoai.masks_to_vector" if not repaired_classes else f"geoai.masks_to_vector + rasterio completeness repair (classes {','.join(map(str, repaired_classes))})" |
| | | except Exception: |
| | | frames = [_fallback_vector(mask, transform, crs, class_id) for class_id in sorted(int(value) for value in np.unique(mask) if value > 0)] |
| | | non_empty = [frame for frame in frames if not frame.empty] |
| | | merged = gpd.GeoDataFrame(pd.concat(non_empty, ignore_index=True), geometry="geometry", crs=crs) if non_empty else gpd.GeoDataFrame({"geometry": []}, geometry="geometry", crs=crs) |
| | | vectorizer = "rasterio.features.shapes fallback" |
| | | merged.to_file(output_path, driver="GeoJSON") |
| | | return merged, vectorizer |
| | | |
| | | |
| | | def _measurement_basis(crs: str | None, georeferenced: bool) -> tuple[str, str, str]: |
| | | if not georeferenced or not crs: |
| | | return "pixel_coordinates", "pixel^2", "pixel" |
| | | try: |
| | | from rasterio.crs import CRS |
| | | |
| | | parsed = CRS.from_string(crs) |
| | | if parsed.is_projected: |
| | | return "projected_crs", "map_unit^2", "map_unit" |
| | | except Exception: |
| | | pass |
| | | return "geographic_coordinates", "coordinate_unit^2", "coordinate_unit" |
| | | |
| | | |
| | | def process_raster(input_path: Path, output_dir: Path) -> dict[str, Any]: |
| | | started = time.perf_counter() |
| | | mask, transform, crs, georeferenced = _read_raster(input_path) |
| | | if mask.ndim != 2: |
| | | raise ValueError(f"Expected a single-band label raster: {input_path.name}") |
| | | if not np.any(mask > 0): |
| | | raise ValueError(f"Raster contains no non-zero labels: {input_path.name}") |
| | | output_dir.mkdir(parents=True, exist_ok=True) |
| | | stem = input_path.stem |
| | | raster_path = output_dir / f"{stem}.objects.tif" |
| | | preview_path = output_dir / f"{stem}.measurement.png" |
| | | vector_path = output_dir / f"{stem}.measurements.geojson" |
| | | csv_path = output_dir / f"{stem}.measurements.csv" |
| | | |
| | | with rasterio.open(raster_path, "w", driver="GTiff", height=mask.shape[0], width=mask.shape[1], count=1, dtype="uint16", transform=transform, crs=crs, nodata=0) as dst: |
| | | dst.write(mask.astype(np.uint16), 1) |
| | | palette_indices = np.clip(mask.astype(np.int64), 0, len(COLORS) - 1) |
| | | Image.fromarray(COLORS[palette_indices]).save(preview_path) |
| | | frame, vectorizer = _vectorize(mask, transform, crs, vector_path) |
| | | basis, area_unit, length_unit = _measurement_basis(crs, georeferenced) |
| | | rows: list[dict[str, Any]] = [] |
| | | for index, record in frame.iterrows(): |
| | | geometry = record.geometry |
| | | centroid = geometry.centroid |
| | | class_id = int(record.get("class_id", record.get("class", 1))) |
| | | rows.append( |
| | | { |
| | | "object_id": index + 1, |
| | | "class_id": class_id, |
| | | "class_name": CLASS_LABELS.get(class_id, f"class_{class_id}"), |
| | | "area": round(float(geometry.area), 3), |
| | | "perimeter": round(float(geometry.length), 3), |
| | | "centroid_x": round(float(centroid.x), 3), |
| | | "centroid_y": round(float(centroid.y), 3), |
| | | "bbox_width": round(float(geometry.bounds[2] - geometry.bounds[0]), 3), |
| | | "bbox_height": round(float(geometry.bounds[3] - geometry.bounds[1]), 3), |
| | | } |
| | | ) |
| | | for key in ("object_id", "class_id", "class_name", "area", "perimeter", "centroid_x", "centroid_y", "bbox_width", "bbox_height"): |
| | | frame[key] = [row[key] for row in rows] |
| | | frame.to_file(vector_path, driver="GeoJSON") |
| | | pd.DataFrame(rows).to_csv(csv_path, index=False, encoding="utf-8-sig") |
| | | return { |
| | | "file": input_path.name, |
| | | "width": int(mask.shape[1]), |
| | | "height": int(mask.shape[0]), |
| | | "raster_file": raster_path.name, |
| | | "preview_file": preview_path.name, |
| | | "vector_file": vector_path.name, |
| | | "csv_file": csv_path.name, |
| | | "object_count": len(rows), |
| | | "class_counts": {str(class_id): sum(1 for row in rows if row["class_id"] == class_id) for class_id in sorted({row["class_id"] for row in rows})}, |
| | | "total_area": round(float(sum(row["area"] for row in rows)), 3), |
| | | "total_perimeter": round(float(sum(row["perimeter"] for row in rows)), 3), |
| | | "area_unit": area_unit, |
| | | "length_unit": length_unit, |
| | | "measurement_basis": basis, |
| | | "crs": crs, |
| | | "georeferenced": georeferenced, |
| | | "vectorizer": vectorizer, |
| | | "elapsed_seconds": round(time.perf_counter() - started, 3), |
| | | } |
| | | |
| | | |
| | | def collect_inputs(input_path: Path) -> list[Path]: |
| | | if input_path.is_file(): |
| | | candidates = [input_path] |
| | | elif input_path.is_dir(): |
| | | candidates = sorted(item for item in input_path.iterdir() if item.is_file()) |
| | | else: |
| | | raise SystemExit(f"Input path does not exist: {input_path}") |
| | | inputs = [item for item in candidates if item.suffix.lower() in SUPPORTED_SUFFIXES] |
| | | if not inputs: |
| | | raise SystemExit("No label PNG or GeoTIFF inputs were found.") |
| | | return inputs |
| | | |
| | | |
| | | def main() -> int: |
| | | parser = argparse.ArgumentParser(description="Measure labelled raster objects on CPU.") |
| | | parser.add_argument("--input", type=Path, required=True, help="A label PNG/GeoTIFF or a directory of them.") |
| | | parser.add_argument("--output", type=Path, required=True, help="A new, empty output directory.") |
| | | args = parser.parse_args() |
| | | if args.output.exists() and any(args.output.iterdir()): |
| | | raise SystemExit(f"Output directory is not empty: {args.output}. Use a new run directory.") |
| | | args.output.mkdir(parents=True, exist_ok=True) |
| | | started = time.perf_counter() |
| | | images = [process_raster(item, args.output) for item in collect_inputs(args.input)] |
| | | metadata = { |
| | | "capability": "04-spatial-measurement", |
| | | "classification": "B", |
| | | "created_at": datetime.now(UTC).isoformat(), |
| | | "geoai_version": importlib_metadata.version("geoai-py"), |
| | | "method": "label raster connected-object vectorization and measurement", |
| | | "model": "none (consumes labelled raster output)", |
| | | "device": "CPU", |
| | | "thresholds": {"minimum_object_area_pixels": 4, "simplify_tolerance": 0.0, "morphological_close_kernel": 3}, |
| | | "input_dir": args.input.as_posix(), |
| | | "input_count": len(images), |
| | | "processed_images": len(images), |
| | | "elapsed_seconds": round(time.perf_counter() - started, 3), |
| | | "images": images, |
| | | "limitations": [ |
| | | "This capability measures labelled raster regions; it does not infer semantic classes or create labels from an ordinary RGB image.", |
| | | "Without a valid projected CRS, area and perimeter are reported in pixel or coordinate units, not metres.", |
| | | "Object count depends on the upstream mask and the GeoAI vectorizer minimum-object-area setting; no accuracy claim is made without manual truth labels.", |
| | | "GeoAI vectors are checked against Rasterio polygon coverage; incomplete classes are repaired and identified in each image's vectorizer field.", |
| | | ], |
| | | } |
| | | (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)) |
| | | return 0 |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | raise SystemExit(main()) |
| New file |
| | |
| | | import tempfile |
| | | import unittest |
| | | from pathlib import Path |
| | | |
| | | import numpy as np |
| | | import rasterio |
| | | from rasterio.transform import from_origin |
| | | |
| | | import sys |
| | | |
| | | sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
| | | from run_spatial_measurement import process_raster # noqa: E402 |
| | | |
| | | |
| | | class SpatialMeasurementTests(unittest.TestCase): |
| | | def test_counts_and_pixel_units(self): |
| | | with tempfile.TemporaryDirectory() as directory: |
| | | root = Path(directory) |
| | | source = root / "normal.tif" |
| | | mask = np.zeros((32, 40), dtype=np.uint8) |
| | | mask[2:8, 3:10] = 1 |
| | | mask[18:26, 25:35] = 2 |
| | | with rasterio.open(source, "w", driver="GTiff", height=32, width=40, count=1, dtype="uint8", transform=from_origin(0, 0, 1, 1), nodata=0) as dst: |
| | | dst.write(mask, 1) |
| | | result = process_raster(source, root / "out") |
| | | self.assertEqual(result["object_count"], 2) |
| | | self.assertEqual(result["measurement_basis"], "pixel_coordinates") |
| | | self.assertTrue((root / "out" / result["vector_file"]).is_file()) |
| | | self.assertTrue((root / "out" / result["csv_file"]).is_file()) |
| | | |
| | | def test_difficult_empty_input_fails(self): |
| | | with tempfile.TemporaryDirectory() as directory: |
| | | root = Path(directory) |
| | | source = root / "empty.tif" |
| | | with rasterio.open(source, "w", driver="GTiff", height=8, width=8, count=1, dtype="uint8") as dst: |
| | | dst.write(np.zeros((8, 8), dtype=np.uint8), 1) |
| | | with self.assertRaises(ValueError): |
| | | process_raster(source, root / "out") |
| | | |
| | | def test_projected_crs_uses_map_units(self): |
| | | with tempfile.TemporaryDirectory() as directory: |
| | | root = Path(directory) |
| | | source = root / "projected.tif" |
| | | mask = np.zeros((16, 16), dtype=np.uint8) |
| | | mask[3:9, 4:11] = 1 |
| | | with rasterio.open(source, "w", driver="GTiff", height=16, width=16, count=1, dtype="uint8", transform=from_origin(500000, 4000000, 2, 2), crs="EPSG:3857", nodata=0) as dst: |
| | | dst.write(mask, 1) |
| | | result = process_raster(source, root / "out") |
| | | self.assertEqual(result["measurement_basis"], "projected_crs") |
| | | self.assertEqual(result["object_count"], 1) |
| | | self.assertGreater(result["total_area"], 0) |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | unittest.main() |
| | |
| | | MAX_FILE_BYTES = 96 * 1024 * 1024 |
| | | MAX_IMAGES_PER_RUN = 12 |
| | | MAX_SEGMENTATION_IMAGES_PER_RUN = 6 |
| | | MAX_MEASUREMENT_RASTERS_PER_RUN = 4 |
| | | CHANGE_THRESHOLD_DEFAULT = 0.5 |
| | | CHANGE_THRESHOLD_MIN = 0.01 |
| | | CHANGE_THRESHOLD_MAX = 0.99 |
| | | ALLOWED_PATH_PREFIXES = ( |
| | | "apps/workbench-console", |
| | | "shared/outputs", |
| | | "shared/data/raw/00-change-detection", |
| | | "shared/data/raw/01-object-detection", |
| | | "shared/data/raw/02-semantic-mapping", |
| | | ) |
| | |
| | | return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True) |
| | | |
| | | |
| | | def change_runs(root: Path) -> list[dict[str, Any]]: |
| | | output_root = root / "shared" / "outputs" / "00-change-detection" |
| | | records: list[dict[str, Any]] = [] |
| | | for metadata_path in output_root.rglob("run_metadata.json"): |
| | | artifact = metadata_path.parent |
| | | metadata = load_json(metadata_path) |
| | | artifacts = metadata.get("artifacts") |
| | | if metadata.get("capability") != "00-change-detection" or metadata.get("schema_version") != 1 or not isinstance(artifacts, dict): |
| | | continue |
| | | if not (artifact / str(artifacts.get("overlay") or "")).is_file() or not (artifact / str(artifacts.get("vector") or "")).is_file(): |
| | | continue |
| | | raw_root_value = str(metadata.get("raw_input_dir") or "shared/data/raw/00-change-detection/validation-20260817") |
| | | raw_root = root / Path(raw_root_value) |
| | | input_files = metadata.get("input_files") |
| | | if not isinstance(input_files, list) or len(input_files) != 2: |
| | | continue |
| | | before_value = str(metadata.get("raw_before") or (Path(raw_root_value) / str(input_files[0])).as_posix()) |
| | | after_value = str(metadata.get("raw_after") or (Path(raw_root_value) / str(input_files[1])).as_posix()) |
| | | try: |
| | | before_path = (root / before_value).resolve() |
| | | after_path = (root / after_value).resolve() |
| | | allowed_raw = (root / "shared" / "data" / "raw" / "00-change-detection").resolve() |
| | | before_path.relative_to(allowed_raw) |
| | | after_path.relative_to(allowed_raw) |
| | | except ValueError: |
| | | continue |
| | | if not before_path.is_file() or not after_path.is_file(): |
| | | continue |
| | | run_id = artifact.name |
| | | records.append( |
| | | { |
| | | "id": run_id, |
| | | "label": run_id, |
| | | "note": "ChangeStar CPU 变化栅格与 GeoAI 像素坐标图斑;结果需人工复核。", |
| | | "artifactRoot": relative_path(root, artifact), |
| | | "beforeImage": relative_path(root, before_path), |
| | | "afterImage": relative_path(root, after_path), |
| | | "createdAt": str(metadata.get("created_at") or ""), |
| | | } |
| | | ) |
| | | return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True) |
| | | |
| | | |
| | | def semantic_runs(root: Path) -> list[dict[str, Any]]: |
| | | output_root = root / "shared" / "outputs" / "02-semantic-mapping" |
| | | records: list[dict[str, Any]] = [] |
| | |
| | | "artifactRoot": relative_path(root, artifact), |
| | | "inputRoot": str(metadata.get("input_dir") or "shared/data/processed/02-semantic-mapping"), |
| | | "rawInputRoot": str(metadata.get("raw_input_dir") or "shared/data/raw/02-semantic-mapping"), |
| | | "createdAt": str(metadata.get("created_at") or ""), |
| | | } |
| | | ) |
| | | return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True) |
| | | |
| | | |
| | | def measurement_runs(root: Path) -> list[dict[str, Any]]: |
| | | output_root = root / "shared" / "outputs" / "04-spatial-measurement" |
| | | records: list[dict[str, Any]] = [] |
| | | for metadata_path in output_root.rglob("run_metadata.json"): |
| | | artifact = metadata_path.parent |
| | | metadata = load_json(metadata_path) |
| | | if metadata.get("capability") != "04-spatial-measurement" or not isinstance(metadata.get("images"), list): |
| | | continue |
| | | run_id = artifact.name |
| | | records.append( |
| | | { |
| | | "id": run_id, |
| | | "label": run_id, |
| | | "note": "GeoAI 栅格转矢量后进行对象计数、面积和周长测量。", |
| | | "artifactRoot": relative_path(root, artifact), |
| | | "createdAt": str(metadata.get("created_at") or ""), |
| | | } |
| | | ) |
| | |
| | | |
| | | def do_GET(self) -> None: # noqa: N802 - inherited standard-library method name |
| | | path = urlsplit(self.path).path |
| | | if path == "/api/change-detection/runs": |
| | | self.send_json(HTTPStatus.OK, {"runs": change_runs(self.root)}) |
| | | return |
| | | if path == "/api/trajectory/runs": |
| | | self.send_json(HTTPStatus.OK, {"runs": trajectory_runs(self.root)}) |
| | | return |
| | |
| | | if path == "/api/semantic-mapping/tasks": |
| | | self.send_json(HTTPStatus.OK, {"tasks": semantic_tasks(self.root)}) |
| | | return |
| | | if path == "/api/spatial-measurement/runs": |
| | | self.send_json(HTTPStatus.OK, {"runs": measurement_runs(self.root)}) |
| | | return |
| | | if path == "/": |
| | | self.send_response(HTTPStatus.FOUND) |
| | | self.send_header("Location", "/apps/workbench-console/") |
| | |
| | | path = urlsplit(self.path).path |
| | | try: |
| | | payload = self.read_json_body() |
| | | if path == "/api/change-detection/runs": |
| | | self.send_json(HTTPStatus.CREATED, {"run": self.create_change_run(payload)}) |
| | | return |
| | | if path == "/api/trajectory/runs": |
| | | self.send_json(HTTPStatus.CREATED, {"run": self.create_trajectory_run(payload)}) |
| | | return |
| | |
| | | return |
| | | if path == "/api/semantic-mapping/runs": |
| | | self.send_json(HTTPStatus.CREATED, {"run": self.create_semantic_run(payload)}) |
| | | return |
| | | if path == "/api/spatial-measurement/runs": |
| | | self.send_json(HTTPStatus.CREATED, {"run": self.create_measurement_run(payload)}) |
| | | return |
| | | self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown local API endpoint."}) |
| | | except ApiError as exc: |
| | |
| | | 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) |
| | | |
| | | def create_change_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | files = payload.get("files") |
| | | if not isinstance(files, dict): |
| | | raise ApiError("Change-detection request must contain before and after files.") |
| | | threshold_value = payload.get("threshold", CHANGE_THRESHOLD_DEFAULT) |
| | | if isinstance(threshold_value, bool) or not isinstance(threshold_value, (int, float)): |
| | | raise ApiError("Change-detection threshold must be a number between 0.01 and 0.99.") |
| | | threshold = float(threshold_value) |
| | | if not CHANGE_THRESHOLD_MIN <= threshold <= CHANGE_THRESHOLD_MAX: |
| | | raise ApiError("Change-detection threshold must be between 0.01 and 0.99.") |
| | | suffixes = {".jpg", ".jpeg", ".png", ".tif", ".tiff"} |
| | | before = decode_upload(files.get("before"), suffixes) |
| | | after = decode_upload(files.get("after"), suffixes) |
| | | run_id = make_run_id("change") |
| | | raw_root = self.root / "shared" / "data" / "raw" / "00-change-detection" / "runs" / run_id |
| | | before_path = raw_root / "before" / before[0] |
| | | after_path = raw_root / "after" / after[0] |
| | | before_path.parent.mkdir(parents=True, exist_ok=False) |
| | | after_path.parent.mkdir(parents=True, exist_ok=False) |
| | | before_path.write_bytes(before[1]) |
| | | after_path.write_bytes(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.") |
| | | with RUN_LOCK: |
| | | self.run_command( |
| | | [ |
| | | str(python), |
| | | str(self.root / "capabilities" / "00-change-detection" / "run_change_detection.py"), |
| | | "--before", str(before_path), |
| | | "--after", str(after_path), |
| | | "--threshold", f"{threshold:.4f}", |
| | | "--processed-output", str(processed_root), |
| | | "--output", str(output), |
| | | ], |
| | | 1200, |
| | | ) |
| | | metadata_path = output / "run_metadata.json" |
| | | if not metadata_path.is_file(): |
| | | raise ApiError("Change-detection script finished without the expected result metadata.") |
| | | metadata = load_json(metadata_path) |
| | | metadata["raw_input_dir"] = relative_path(self.root, raw_root) |
| | | metadata["processed_input_dir"] = relative_path(self.root, processed_root) |
| | | metadata["raw_before"] = relative_path(self.root, before_path) |
| | | metadata["raw_after"] = relative_path(self.root, after_path) |
| | | metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return next(item for item in change_runs(self.root) if item["id"] == run_id) |
| | | |
| | | def create_semantic_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | task_id = str(payload.get("taskId") or "color_baseline") |
| | | task = next((item for item in semantic_tasks(self.root) if item["id"] == task_id), None) |
| | |
| | | metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return next(item for item in semantic_runs(self.root) if item["id"] == run_id) |
| | | |
| | | def create_measurement_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | uploads = payload.get("rasters") |
| | | if not isinstance(uploads, list) or not uploads: |
| | | raise ApiError("Spatial-measurement request must include at least one label raster.") |
| | | if len(uploads) > MAX_MEASUREMENT_RASTERS_PER_RUN: |
| | | raise ApiError(f"A spatial-measurement run accepts at most {MAX_MEASUREMENT_RASTERS_PER_RUN} rasters.") |
| | | decoded = [decode_upload(item, {".png", ".tif", ".tiff"}) for item in uploads] |
| | | if len({name.casefold() for name, _ in decoded}) != len(decoded): |
| | | raise ApiError("Uploaded raster names must be unique within one run.") |
| | | run_id = make_run_id("measurement") |
| | | raw_root = self.root / "shared" / "data" / "raw" / "04-spatial-measurement" / "runs" / run_id |
| | | processed_root = self.root / "shared" / "data" / "processed" / "04-spatial-measurement" / run_id |
| | | raw_root.mkdir(parents=True, exist_ok=False) |
| | | processed_root.mkdir(parents=True, exist_ok=False) |
| | | for name, content in decoded: |
| | | (raw_root / name).write_bytes(content) |
| | | (processed_root / name).write_bytes(content) |
| | | output = self.root / "shared" / "outputs" / "04-spatial-measurement" / "runs" / run_id |
| | | python = self.root / ".venvs" / "04-spatial-measurement" / "Scripts" / "python.exe" |
| | | if not python.is_file(): |
| | | raise ApiError("Spatial-measurement virtual environment is unavailable. Run the capability setup first.") |
| | | with RUN_LOCK: |
| | | self.run_command([str(python), str(self.root / "capabilities" / "04-spatial-measurement" / "run_spatial_measurement.py"), "--input", str(processed_root), "--output", str(output)], 900) |
| | | metadata_path = output / "run_metadata.json" |
| | | if not metadata_path.is_file(): |
| | | raise ApiError("Spatial-measurement script finished without the expected result metadata.") |
| | | metadata = load_json(metadata_path) |
| | | metadata["input_dir"] = relative_path(self.root, processed_root) |
| | | metadata["raw_input_dir"] = relative_path(self.root, raw_root) |
| | | metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return next(item for item in measurement_runs(self.root) if item["id"] == run_id) |
| | | |
| | | def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None: |
| | | body = json.dumps(payload, ensure_ascii=False).encode("utf-8") |
| | | self.send_response(status) |
| | |
| | | Path(handler.translate_path("/shared/data/raw/02-semantic-mapping/sample.tif")), |
| | | ROOT / "shared" / "data" / "raw" / "02-semantic-mapping" / "sample.tif", |
| | | ) |
| | | self.assertEqual( |
| | | Path(handler.translate_path("/shared/data/raw/00-change-detection/sample.jpg")), |
| | | ROOT / "shared" / "data" / "raw" / "00-change-detection" / "sample.jpg", |
| | | ) |
| | | |
| | | def test_upload_name_is_sanitized_and_extension_is_allowlisted(self) -> None: |
| | | self.assertEqual(MODULE.safe_file_name("../../unsafe name.JPG", {".jpg"}), "unsafe_name.jpg") |
| | |
| | | runs = MODULE.semantic_runs(ROOT) |
| | | self.assertTrue(any(item["id"] == "validation-20260817" for item in runs)) |
| | | |
| | | def test_spatial_measurement_run_is_discovered(self) -> None: |
| | | runs = MODULE.measurement_runs(ROOT) |
| | | validation = next(item for item in runs if item["id"] == "validation-normal-20260817-v4") |
| | | self.assertTrue(validation["artifactRoot"].startswith("shared/outputs/04-spatial-measurement/")) |
| | | |
| | | def test_change_run_requires_both_allowlisted_images(self) -> None: |
| | | handler = self.make_handler() |
| | | with self.assertRaisesRegex(MODULE.ApiError, "name and Base64"): |
| | | handler.create_change_run({"files": {"before": {"name": "before.jpg", "content": "eA=="}}}) |
| | | |
| | | def test_change_threshold_is_validated(self) -> None: |
| | | handler = self.make_handler() |
| | | payload = {"files": {"before": {"name": "before.jpg", "content": "eA=="}, "after": {"name": "after.jpg", "content": "eA=="}}} |
| | | with self.assertRaisesRegex(MODULE.ApiError, "between 0.01 and 0.99"): |
| | | handler.create_change_run({**payload, "threshold": 1.0}) |
| | | with self.assertRaisesRegex(MODULE.ApiError, "must be a number"): |
| | | handler.create_change_run({**payload, "threshold": "0.5"}) |
| | | |
| | | def test_change_validation_run_is_discovered_without_fake_crs(self) -> None: |
| | | runs = MODULE.change_runs(ROOT) |
| | | self.assertTrue(any(item["id"].startswith("validation-real-") for item in runs)) |
| | | |
| | | def test_semantic_task_catalog_only_enables_verified_baseline(self) -> None: |
| | | tasks = MODULE.semantic_tasks(ROOT) |
| | | self.assertEqual(len(tasks), 5) |