shuishen
2 days ago 404ab607688ce9c02a0b1155406446cf6026d664
feat:变化检测的
13 files modified
8 files added
1347 ■■■■■ changed files
.gitignore 1 ●●●● patch | view | raw | blame | history
PROJECT_CONTEXT.md 61 ●●●●● patch | view | raw | blame | history
apps/workbench-console/README.md 7 ●●●● patch | view | raw | blame | history
apps/workbench-console/src/api/artifacts.ts 26 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/ChangeDetectionPanel.vue 115 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/SpatialMeasurementPanel.vue 101 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/data/capabilities.ts 4 ●●●● patch | view | raw | blame | history
apps/workbench-console/src/stores/artifacts.ts 22 ●●●●● patch | view | raw | blame | history
apps/workbench-console/src/views/CapabilityView.vue 4 ●●●● patch | view | raw | blame | history
baseData/07-16.jpg patch | view | raw | blame | history
baseData/07-19.jpg patch | view | raw | blame | history
capabilities/00-change-detection/README.md 70 ●●●●● patch | view | raw | blame | history
capabilities/00-change-detection/requirements.txt 3 ●●●● patch | view | raw | blame | history
capabilities/00-change-detection/run_change_detection.py 265 ●●●●● patch | view | raw | blame | history
capabilities/00-change-detection/tests/test_change_detection.py 79 ●●●●● patch | view | raw | blame | history
capabilities/04-spatial-measurement/README.md 88 ●●●●● patch | view | raw | blame | history
capabilities/04-spatial-measurement/requirements.txt 6 ●●●● patch | view | raw | blame | history
capabilities/04-spatial-measurement/run_spatial_measurement.py 251 ●●●●● patch | view | raw | blame | history
capabilities/04-spatial-measurement/tests/test_spatial_measurement.py 55 ●●●●● patch | view | raw | blame | history
scripts/serve_workbench_console.py 163 ●●●●● patch | view | raw | blame | history
tests/test_serve_workbench_console.py 26 ●●●●● patch | view | raw | blame | history
.gitignore
@@ -17,6 +17,7 @@
*.pt
Ultralytics/
reference_article/
project_article/
shared/data/raw/*
shared/data/interim/*
PROJECT_CONTEXT.md
@@ -55,6 +55,20 @@
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`
@@ -72,6 +86,24 @@
`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/`
@@ -80,7 +112,7 @@
- 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.
@@ -88,13 +120,25 @@
| 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.
@@ -123,6 +167,17 @@
- 当前对象检测环境已具备 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.
apps/workbench-console/README.md
@@ -57,7 +57,7 @@
- `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 产出可检查工件后再接入。
## 本地运行工作台
@@ -67,4 +67,7 @@
- 轨迹分析上传 `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`;服务只调用固定虚拟环境和能力脚本,不接受任意命令或任意路径。
apps/workbench-console/src/api/artifacts.ts
@@ -6,6 +6,12 @@
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; }
@@ -14,6 +20,8 @@
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("/")}`;
@@ -55,8 +63,26 @@
  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); }); }
apps/workbench-console/src/components/ChangeDetectionPanel.vue
New file
@@ -0,0 +1,115 @@
<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>
apps/workbench-console/src/components/SpatialMeasurementPanel.vue
New file
@@ -0,0 +1,101 @@
<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>
apps/workbench-console/src/data/capabilities.ts
@@ -9,11 +9,11 @@
}
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。" },
apps/workbench-console/src/stores/artifacts.ts
@@ -2,10 +2,14 @@
import {
  loadDetectionArtifacts,
  loadChangeArtifacts,
  loadSemanticArtifacts,
  loadMeasurementArtifacts,
  loadTrajectoryArtifacts,
  type DetectionCase,
  type ChangeCase,
  type SemanticCase,
  type MeasurementCase,
  type TrajectoryCase
} from "@/api/artifacts";
@@ -13,6 +17,8 @@
  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;
}
@@ -22,8 +28,15 @@
    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;
@@ -44,6 +57,13 @@
      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; }
    }
  }
});
apps/workbench-console/src/views/CapabilityView.vue
@@ -6,6 +6,8 @@
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();
@@ -16,8 +18,10 @@
  <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>
baseData/07-16.jpg
baseData/07-19.jpg
capabilities/00-change-detection/README.md
@@ -1,8 +1,68 @@
# 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` 图片:版权/使用授权未知,需由数据提供方确认。
capabilities/00-change-detection/requirements.txt
@@ -4,4 +4,5 @@
torch>=2.4,<3
torchvision>=0.19,<1
albumentations>=1.4,<3
geoai-py==0.42.0
torchange==0.0.4
capabilities/00-change-detection/run_change_detection.py
New file
@@ -0,0 +1,265 @@
"""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
capabilities/00-change-detection/tests/test_change_detection.py
New file
@@ -0,0 +1,79 @@
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()
capabilities/04-spatial-measurement/README.md
@@ -1,6 +1,86 @@
# 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。
capabilities/04-spatial-measurement/requirements.txt
@@ -1,5 +1,5 @@
-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
capabilities/04-spatial-measurement/run_spatial_measurement.py
New file
@@ -0,0 +1,251 @@
"""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())
capabilities/04-spatial-measurement/tests/test_spatial_measurement.py
New file
@@ -0,0 +1,55 @@
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()
scripts/serve_workbench_console.py
@@ -25,9 +25,14 @@
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",
)
@@ -131,6 +136,49 @@
    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]] = []
@@ -148,6 +196,27 @@
                "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 ""),
            }
        )
@@ -173,6 +242,9 @@
    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
@@ -185,6 +257,9 @@
        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/")
@@ -196,6 +271,9 @@
        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
@@ -204,6 +282,9 @@
                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:
@@ -299,6 +380,56 @@
            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)
@@ -339,6 +470,38 @@
        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)
tests/test_serve_workbench_console.py
@@ -39,6 +39,10 @@
            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")
@@ -61,6 +65,28 @@
        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)