shuishen
20 hours ago d857b98578eb377da7cc36f653455fc5f916a9cd
feat:变化检测优化
9 files modified
495 ■■■■ changed files
PROJECT_CONTEXT.md 13 ●●●● patch | view | raw | blame | history
apps/workbench-console/README.md 4 ●●●● patch | view | raw | blame | history
apps/workbench-console/src/api/artifacts.ts 12 ●●●● patch | view | raw | blame | history
apps/workbench-console/src/components/ChangeDetectionPanel.vue 25 ●●●● patch | view | raw | blame | history
capabilities/00-change-detection/README.md 27 ●●●● patch | view | raw | blame | history
capabilities/00-change-detection/run_change_detection.py 207 ●●●● patch | view | raw | blame | history
capabilities/00-change-detection/tests/test_change_detection.py 54 ●●●●● patch | view | raw | blame | history
scripts/serve_workbench_console.py 122 ●●●●● patch | view | raw | blame | history
tests/test_serve_workbench_console.py 31 ●●●●● patch | view | raw | blame | history
PROJECT_CONTEXT.md
@@ -1,6 +1,6 @@
# GeoAI Workbench Current Context
Last updated: 2026-08-17
Last updated: 2026-08-18
This file is the current project snapshot for new Codex tasks. Keep it concise and replace stale facts instead of appending a conversation diary.
@@ -112,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: `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.
- 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. Change detection now uploads large TIFFs as raw binary streams and copies bytes into a new run directory before invoking the fixed capability script; the original file is not rewritten. 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.
@@ -120,7 +120,7 @@
| Capability | Status | Current conclusion |
| --- | --- | --- |
| `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. |
| `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. Auto/image mode handles ordinary imagery in pixel coordinates; GeoTIFF mode preserves spatial reference for inputs with matching CRS. When two GeoTIFF grids differ, the second image is reprojected to the first grid only in the processing copy; original files are unchanged. Real pair `07-16.jpg` -> `07-19.jpg` completed in about 41.6 seconds at 1024 px, with 755/769 registration inliers, 98.52% valid area, 674 changed pixels and one GeoJSON polygon. CLI/API/console runs accept per-run thresholds and processing modes/resolution options, all recorded in `run_metadata.json`. |
| `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. |
@@ -134,10 +134,11 @@
- 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.
- Outputs: `shared/outputs/00-change-detection/validation-real-20260817-v4/`, `validation-self-20260817-v3/` and `resolution-2048-urban-validation/` contain registered GeoTIFFs, probability/mask rasters, overlays, 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. On the urban parking sample, increasing from 1024 to 2048 produced 16 polygons instead of one and covered more vehicle/parking changes, but took about 84 seconds CPU and still had misses/false positives.
- GeoTIFF grid-alignment validation: uploaded `1.tif` (6217 x 5525) and `2.tif` (6218 x 5497) share `EPSG:32650` but have slightly different origins and pixel sizes. The second image was reprojected bilinearly to the first grid in the processing copy, then completed at 1024 px in 80.882 seconds with georeferenced outputs. SHA256 checks confirmed the source staging files and their raw run copies are byte-identical.
- Honest limitation: ChangeStar weights are trained for Changen2/S1 building change and the close-up rock/concrete scene is out of distribution. No manual truth exists, so no precision, recall, IoU, change type, or engineering alert is claimed. Pixel GeoJSON has no CRS; the downloaded model is CC BY-NC-SA 4.0 and not cleared for commercial use.
- Console: `GET/POST /api/change-detection/runs` 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.
- Console: `GET/POST /api/change-detection/runs` on local `6xxx` ports. Real uploads at `6176` verified thresholds and selectable processing resolution; auto/image and GeoTIFF mode handling is covered by unit tests. Separate raw before/after directories and a separate processed directory are used; unsafe basename sanitization plus invalid/oversized request rejection were verified.
- Next decision: 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`.
apps/workbench-console/README.md
@@ -68,6 +68,6 @@
- 目标检测上传最多 12 张 `JPG/JPEG/PNG`,原图和标注图默认并列对比,每次生成独立运行编号。
- 语义分割上传最多 6 张 `JPG/JPEG/PNG/TIF/TIFF`,任务预设从能力目录动态读取;当前只有通用颜色流程可运行,排洪沟、边坡、竖井和尾矿库任务在模型与真值齐备前保持禁用。结果展示原图、栅格叠加、GeoJSON 矢量预览、类别比例和可下载工件。
- 空间测量上传最多 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 目录,不覆盖既有运行。
- 变化检测上传一对 `JPG/JPEG/PNG/TIF/TIFF`,每次运行可选择自动识别、普通图片或 GeoTIFF 地理参考模式;阈值支持 `0.01~0.99`(默认 `0.5`),分辨率支持 GeoTIFF 原始分辨率/自动以及 `1024/1536/2048/3072` 档位。GeoTIFF 模式保留 CRS 和地图坐标,普通图片模式输出像素坐标。单文件最大 `1024 MB`,单次 Base64 JSON 请求最大 `3072 MB`。
- 本地 API 还包括 `GET/POST /api/change-detection/runs` 和 `PUT /api/change-detection/uploads/<upload-id>`;变化检测前端使用二进制流上传,服务端按原字节暂存并复制到独立 raw、processed 和 output 目录,不修改原始 TIFF,也不覆盖既有运行。
- 本地 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
@@ -10,7 +10,7 @@
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 ChangeRun { schema_version: number; capability: string; classification: string; geoai_version: string; method: string; model: string; device: string; processing_mode?: "image" | "geotiff"; requested_processing_mode?: "auto" | "image" | "geotiff"; thresholds: Record<string, number>; tile_size: number; overlap: number; max_dimension?: number; effective_max_dimension?: number; input_files: string[]; input_shape: number[]; processed_shape: number[]; registration: { method: string; matches: number; inliers: number; inlier_ratio: number; valid_ratio: number }; valid_pixel_ratio: number; raw_changed_pixels: number; changed_pixels: number; changed_pixel_ratio: number; vector_feature_count: number; 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; }
@@ -23,6 +23,7 @@
interface MeasurementDefinition extends CaseDefinition {}
interface ChangeDefinition extends CaseDefinition { beforeImage: string; afterImage: string; }
export interface UploadFilePayload { name: string; content: string; }
export interface ChangeUploadRef { uploadId: string; role: "before" | "after"; name: string; size: number; }
export const artifactUrl = (path: string) => `/${path.replace(/\\/g, "/").split("/").map(encodeURIComponent).join("/")}`;
@@ -84,5 +85,12 @@
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 async function createChangeRun(files: { before: UploadFilePayload; after: UploadFilePayload }, threshold = 0.5, maxDimension = 0, processingMode: "auto" | "image" | "geotiff" = "auto") { return postRun<{ run: ChangeDefinition }>("api/change-detection/runs", { files, threshold, maxDimension, processingMode }); }
export async function uploadChangeFile(file: File, role: "before" | "after"): Promise<ChangeUploadRef> {
  const uploadId = globalThis.crypto.randomUUID().replaceAll("-", "");
  const query = new URLSearchParams({ role });
  const response = await fetch(`/api/change-detection/uploads/${uploadId}?${query.toString()}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream", "X-Upload-Name": file.name }, body: file });
  return responseJson<ChangeUploadRef>(response);
}
export async function createChangeRunFromUploads(uploads: { before: ChangeUploadRef; after: ChangeUploadRef }, threshold = 0.5, maxDimension = 0, processingMode: "auto" | "image" | "geotiff" = "auto") { return postRun<{ run: ChangeDefinition }>("api/change-detection/runs", { uploads, threshold, maxDimension, processingMode }); }
export 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
@@ -2,7 +2,7 @@
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 { artifactUrl, createChangeRunFromUploads, uploadChangeFile, type ChangeCase, type ChangeFeature } from "@/api/artifacts";
import ArtifactState from "@/components/ArtifactState.vue";
import { useArtifactStore } from "@/stores/artifacts";
@@ -11,6 +11,8 @@
const beforeFile = ref<File | null>(null);
const afterFile = ref<File | null>(null);
const threshold = ref(0.5);
const processingMode = ref<"auto" | "image" | "geotiff">("auto");
const maxDimension = ref(0);
const showRunForm = ref(false);
const running = ref(false);
const runError = ref<string | null>(null);
@@ -23,6 +25,16 @@
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 updateMaxDimension(value: number | null | undefined) {
  const parsed = Number(value);
  maxDimension.value = [0, 1024, 1536, 2048, 3072].includes(parsed) ? parsed : 0;
}
function updateProcessingMode(value: string | null | undefined) {
  if (value !== "auto" && value !== "image" && value !== "geotiff") return;
  processingMode.value = value;
  if (value === "geotiff") maxDimension.value = 0;
  if (value === "image" && maxDimension.value === 0) maxDimension.value = 1024;
}
function beforeUpload(file: File, period: "before" | "after") {
@@ -44,8 +56,8 @@
  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);
    const [before, after] = await Promise.all([uploadChangeFile(beforeFile.value, "before"), uploadChangeFile(afterFile.value, "after")]);
    const { run } = await createChangeRunFromUploads({ before, after }, threshold.value, maxDimension.value, processingMode.value);
    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; }
@@ -63,7 +75,9 @@
      <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="resolution-control"><label for="change-mode">处理模式</label><a-select id="change-mode" :value="processingMode" @update:value="updateProcessingMode"><a-select-option value="auto">自动识别</a-select-option><a-select-option value="image">普通图片</a-select-option><a-select-option value="geotiff">GeoTIFF 地理参考</a-select-option></a-select><span>自动模式会识别带 CRS 的 GeoTIFF;普通图片输出像素坐标,GeoTIFF 模式保留空间参考。</span></div>
    <div class="threshold-control"><label for="change-threshold">变化阈值</label><div class="threshold-inputs"><a-slider id="change-threshold" :value="threshold" @update:value="updateThreshold" :min="0.01" :max="0.99" :step="0.01" /><a-input-number :value="threshold" @update:value="updateThreshold" :min="0.01" :max="0.99" :step="0.01" :precision="2" /></div><span>本次运行使用 {{ threshold.toFixed(2) }};默认值为 0.50</span></div>
    <div class="resolution-control"><label for="change-resolution">处理分辨率</label><a-select id="change-resolution" :value="maxDimension" @update:value="updateMaxDimension"><a-select-option :value="0">GeoTIFF 原始分辨率 / 自动</a-select-option><a-select-option :value="1024">快速预览 · 1024 px</a-select-option><a-select-option :value="1536">标准 · 1536 px</a-select-option><a-select-option :value="2048">小目标优先 · 2048 px</a-select-option><a-select-option :value="3072">高细节 · 3072 px</a-select-option></a-select><span v-if="maxDimension === 0">GeoTIFF 保持原始像素尺寸;普通图片自动使用 1024 px。</span><span v-else>本次运行使用 {{ maxDimension }} px 长边上限;分辨率越高,CPU 耗时和内存占用越大。</span></div>
    <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>
@@ -76,7 +90,7 @@
    <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>
    <a-row :gutter="[18, 18]" class="result-band"><a-col :xs="24" :xl="14"><section class="surface-section"><div class="section-heading"><div><h2>矢量图斑</h2><p>像素坐标结果;红线叠加已换算为图像左上角显示坐标。</p></div><a-tag>{{ currentCase.run.vector_feature_count }} 个</a-tag></div><div class="change-vector"><img :src="artifactUrl(currentCase.afterImage)" alt="第二期影像" /><svg v-if="vectorPaths.length" :viewBox="`0 0 ${currentCase.run.input_shape[1]} ${currentCase.run.input_shape[0]}`" preserveAspectRatio="xMidYMid meet"><path v-for="(path, index) in vectorPaths" :key="index" :d="path" /></svg><a-empty v-else description="当前阈值下没有变化图斑" /></div></section></a-col><a-col :xs="24" :xl="10"><section class="surface-section"><h2>图斑明细</h2><a-table :data-source="currentCase.features.map((item) => item.properties)" :pagination="false" row-key="feature_id" size="small" :scroll="{ x: 480 }"><a-table-column title="编号" data-index="feature_id" key="feature_id" /><a-table-column title="面积 (px)" data-index="area_pixels" key="area_pixels" align="right" /><a-table-column title="平均概率" data-index="mean_probability" key="mean_probability" align="right" /><a-table-column title="最大概率" data-index="max_probability" key="max_probability" align="right" /></a-table><a-divider /><a-descriptions size="small" :column="1"><a-descriptions-item label="模型">{{ currentCase.run.model }}</a-descriptions-item><a-descriptions-item label="阈值">{{ currentCase.run.thresholds.change_probability }}</a-descriptions-item><a-descriptions-item label="模式">{{ currentCase.run.processing_mode === "geotiff" ? "GeoTIFF 地理参考" : "普通图片像素坐标" }}</a-descriptions-item><a-descriptions-item label="配准">{{ currentCase.run.registration.method }} / {{ currentCase.run.registration.inliers }} 内点</a-descriptions-item><a-descriptions-item label="坐标">{{ currentCase.run.georeferenced ? `${currentCase.run.crs} 地图坐标` : "无 CRS,像素坐标" }}</a-descriptions-item></a-descriptions></section></a-col></a-row>
    <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 建筑变化权重的验证分布内;结果只能用于工作流与人工复核,不能直接形成工程结论。" />
@@ -96,6 +110,7 @@
.change-workspace { align-items: stretch; margin-bottom: 24px; }
.change-workspace > :deep(.ant-col) { display: flex; }
.change-workspace .surface-section { width: 100%; }
.change-workspace .run-library { align-self: flex-start; max-height: clamp(420px, calc(100vh - 260px), 620px); }
.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; }
@@ -109,7 +124,7 @@
.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: 1199px) { .change-workspace > :deep(.ant-col) { display: block; } .change-workspace .run-library { max-height: none; } }
@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>
capabilities/00-change-detection/README.md
@@ -9,11 +9,11 @@
- `--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 从图像底部量),不能解释
  为米或经纬度。
- 普通图片模式支持 JPG/PNG 和无 CRS 的 TIFF,先转换为内部 RGB GeoTIFF 并使用
  像素坐标;GeoTIFF 模式要求两期输入都是带相同 CRS 的 RGB GeoTIFF。若像元大小、原点、
  旋转方向或尺寸不同,流程只在处理副本中将第二期双线性重投影到第一期网格;不会改写原始 TIFF。
- 自动模式会在两期输入都具备有效 CRS/仿射变换时启用 GeoTIFF 模式,否则使用普通
  图片模式。带 CRS 的 GeoTIFF 会保留空间参考,普通 JPG/PNG 结果不能解释为米或经纬度。
## 运行
@@ -23,20 +23,31 @@
  --before .\baseData\07-16.jpg `
  --after .\baseData\07-19.jpg `
  --threshold 0.5 `
  --processing-mode auto `
  --output .\shared\outputs\00-change-detection\validation-real-20260817
```
带空间参考的双期 GeoTIFF 可显式使用 `--processing-mode geotiff --max-dimension 0`;
普通图片可使用 `--processing-mode image --max-dimension 2048`。省略模式时使用
`auto`,省略 `--max-dimension` 时普通图片默认 `1024`,带 CRS 的 GeoTIFF 默认保持原始尺寸。
首次运行会从 Hugging Face 缓存 ChangeStar `s1_s1c1_vitb` 权重;CPU 运行时间与
机器和缩放参数有关。`--max-dimension` 默认 1024,`--tile-size` 默认 512,适合
先做代表性验证;增加尺寸或批量运行前应先人工检查配准和误报。每次运行可单独
选择 `0.01` 到 `0.99` 的变化概率阈值,默认值为 `0.5`;实际阈值会写入
`run_metadata.json` 的 `thresholds.change_probability`。
处理分辨率也可按运行选择长边上限:`1024`(快速预览,默认)、`1536`(标准)、
`2048`(小目标优先)或 `3072`(高细节)。分辨率越高越容易保留车辆等小目标,
但 CPU 耗时和内存占用会增加;实际处理尺寸记录在 `run_metadata.json` 的
`max_dimension` 和 `processed_shape` 中。
GeoTIFF 模式还支持 `0`,表示保持原始像素尺寸和地理分辨率;普通图片的 `0` 会
自动回退到 `1024`。
## 输出
每次运行目录包含:
- `before.tif`、`after_registered.tif`:处理输入,记录像素坐标变换。
- `before.tif`、`after_registered.tif`:处理输入;GeoTIFF 模式保留 CRS 和仿射变换。
- `change_probability.tif`:模型输出的 float32 变化概率。
- `change_mask_raw.tif`、`change_mask.tif`:阈值前后二值栅格(0/255)。
- `change_overlay.png`:第二期影像上的红色变化叠加和双期对比。
@@ -55,6 +66,10 @@
   和矢量输出是否可读。该样本是近景边坡照片,不是 ChangeStar 训练分布,不能
   据此宣称工程变化检测准确率。
另外对城市停车场双期样本做了分辨率对照:`1024` 档位输出 1 个图斑,`2048`
档位输出 16 个图斑,更多车辆和停车区域变化被覆盖;`2048` 档位 CPU 用时约
84 秒,仍存在误报和漏检,结果不能视为车辆变化检测准确率。
没有人工变化真值,Demo 不报告 precision、recall、IoU 或“变化类型”。后续若要
用于边坡/排洪沟等业务,需要同 CRS 正射影像、人工掩膜、误报/漏报统计和许可审查。
capabilities/00-change-detection/run_change_detection.py
@@ -17,6 +17,7 @@
from affine import Affine
from PIL import Image
from rasterio.features import shapes
from rasterio.transform import rowcol
from shapely.geometry import shape as shapely_shape
@@ -25,13 +26,60 @@
DEFAULT_TILE_SIZE = 512
DEFAULT_OVERLAP = 64
DEFAULT_MAX_DIMENSION = 1024
DEFAULT_PROCESSING_MODE = "auto"
AUTO_MAX_DIMENSION = 0
MIN_THRESHOLD = 0.01
MAX_THRESHOLD = 0.99
MIN_MAX_DIMENSION = 512
MAX_MAX_DIMENSION = 4096
PROCESSING_MODES = {"auto", "image", "geotiff"}
def _read_rgb(path: Path) -> np.ndarray:
def _to_uint8(data: np.ndarray) -> np.ndarray:
    if data.dtype == np.uint8:
        return data
    if np.issubdtype(data.dtype, np.integer):
        info = np.iinfo(data.dtype)
        return np.clip(data.astype(np.float32) / max(1, info.max) * 255.0, 0, 255).astype(np.uint8)
    values = data.astype(np.float32)
    if float(np.nanmax(values)) <= 1.0:
        values = values * 255.0
    return np.clip(values, 0, 255).astype(np.uint8)
def _read_rgb(path: Path, processing_mode: str = DEFAULT_PROCESSING_MODE) -> tuple[np.ndarray, dict[str, Any]]:
    if processing_mode not in PROCESSING_MODES:
        raise ValueError(f"processing_mode must be one of {sorted(PROCESSING_MODES)}")
    if processing_mode in {"auto", "geotiff"} and path.suffix.lower() in {".tif", ".tiff"}:
        try:
            with rasterio.open(path) as dataset:
                has_georeference = dataset.crs is not None and not dataset.transform.is_identity
                if has_georeference:
                    if dataset.count < 3:
                        raise ValueError(f"GeoTIFF must contain at least 3 bands: {path.name}")
                    data = _to_uint8(dataset.read([1, 2, 3]))
                    image = np.transpose(data, (1, 2, 0))
                    return image, {
                        "processing_mode": "geotiff",
                        "georeferenced": True,
                        "crs": dataset.crs.to_string(),
                        "transform": dataset.transform,
                        "pixel_size": [abs(float(dataset.transform.a)), abs(float(dataset.transform.e))],
                        "grid_basis": [float(dataset.transform.a), float(dataset.transform.b), float(dataset.transform.d), float(dataset.transform.e)],
                    }
                if processing_mode == "geotiff":
                    raise ValueError(f"GeoTIFF has no valid CRS and affine transform: {path.name}")
        except rasterio.errors.RasterioIOError as exc:
            if processing_mode == "geotiff":
                raise ValueError(f"Unable to read GeoTIFF {path.name}: {exc}") from exc
    with Image.open(path) as image:
        return np.asarray(image.convert("RGB"))
        return np.asarray(image.convert("RGB")), {
            "processing_mode": "image",
            "georeferenced": False,
            "crs": None,
            "transform": None,
            "pixel_size": None,
        }
def _register_after(before: np.ndarray, after: np.ndarray) -> tuple[np.ndarray, np.ndarray, dict[str, Any]]:
@@ -71,11 +119,11 @@
    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]]:
def _resize_pair(before: np.ndarray, after: np.ndarray, valid: np.ndarray, max_dimension: int, *, native_resolution: bool = False) -> 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))
    scale = 1.0 if native_resolution else min(1.0, float(max_dimension) / max(height, width))
    out_width = width if native_resolution else max(32, int(round(width * scale / 32) * 32))
    out_height = height if native_resolution else 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)
@@ -87,18 +135,38 @@
        "processed_height": out_height,
        "scale_x": round(width / out_width, 8),
        "scale_y": round(height / out_height, 8),
        "native_resolution": native_resolution,
    }
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:
def _reproject_rgb_to_grid(path: Path, destination_shape: tuple[int, int], destination_transform: Affine, destination_crs: str) -> np.ndarray:
    """Read the second GeoTIFF onto the first image's grid without changing either source file."""
    from rasterio.warp import Resampling, reproject
    height, width = destination_shape
    aligned = np.zeros((3, height, width), dtype=np.uint8)
    with rasterio.open(path) as source:
        for band in range(3):
            source_band = _to_uint8(source.read(band + 1))
            reproject(
                source=source_band,
                destination=aligned[band],
                src_transform=source.transform,
                src_crs=source.crs,
                dst_transform=destination_transform,
                dst_crs=destination_crs,
                resampling=Resampling.bilinear,
            )
    return np.transpose(aligned, (1, 2, 0))
def _write_rgb_geotiff(path: Path, image: np.ndarray, transform: Affine, crs: str | None) -> None:
    with rasterio.open(path, "w", driver="GTiff", height=image.shape[0], width=image.shape[1], count=3, dtype="uint8", transform=transform, crs=crs, 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:
def _write_raster(path: Path, data: np.ndarray, transform: Affine, dtype: str, crs: str | None = None) -> None:
    with rasterio.open(path, "w", driver="GTiff", height=data.shape[0], width=data.shape[1], count=1, dtype=dtype, transform=transform, crs=crs, compress="lzw") as dst:
        dst.write(data.astype(dtype), 1)
@@ -112,58 +180,102 @@
    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]]:
def _feature_summary(mask: np.ndarray, probability: np.ndarray, transform: Affine, georeferenced: bool) -> 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)
        rows, cols = rowcol(transform, coords[:, 0], coords[:, 1])
        x = np.clip(np.asarray(cols), 0, mask.shape[1] - 1)
        y = np.clip(np.asarray(rows), 0, mask.shape[0] - 1)
        sample = probability[y, x]
        features.append({
        item: dict[str, Any] = {
            "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],
        })
        }
        if georeferenced:
            item["area_map_units"] = round(float(polygon.area), 6)
            item["bounds_map"] = [round(float(v), 6) for v in polygon.bounds]
        else:
            item["bounds_pixel"] = [round(float(v), 3) for v in polygon.bounds]
        features.append(item)
    return features
def _enrich_vector(vector_path: Path, features: list[dict[str, Any]]) -> None:
def _enrich_vector(vector_path: Path, features: list[dict[str, Any]], preserve_crs: bool = False) -> 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"):
        for key in ("feature_id", "area_pixels", "area_map_units", "mean_probability", "max_probability", "bounds_pixel", "bounds_map"):
            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)
        if not preserve_crs:
            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]:
def run_change_detection(before_path: Path, after_path: Path, output_dir: Path, *, processed_dir: Path | None = None, model_name: str = MODEL_NAME, threshold: float = DEFAULT_THRESHOLD, tile_size: int = DEFAULT_TILE_SIZE, overlap: int = DEFAULT_OVERLAP, max_dimension: int = AUTO_MAX_DIMENSION, processing_mode: str = DEFAULT_PROCESSING_MODE) -> dict[str, Any]:
    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 processing_mode not in PROCESSING_MODES:
        raise ValueError(f"processing_mode must be one of {sorted(PROCESSING_MODES)}")
    if not isinstance(max_dimension, int) or max_dimension != AUTO_MAX_DIMENSION and not MIN_MAX_DIMENSION <= max_dimension <= MAX_MAX_DIMENSION:
        raise ValueError(f"max_dimension must be 0 or between {MIN_MAX_DIMENSION} and {MAX_MAX_DIMENSION}")
    before, before_info = _read_rgb(before_path, processing_mode)
    after, after_info = _read_rgb(after_path, processing_mode)
    actual_mode = "geotiff" if before_info["georeferenced"] and after_info["georeferenced"] else "image"
    if before_info["georeferenced"] != after_info["georeferenced"]:
        raise ValueError("Both inputs must use the same processing mode and georeferencing.")
    if processing_mode == "geotiff" and actual_mode != "geotiff":
        raise ValueError("GeoTIFF mode requires both inputs to have a valid CRS and affine transform.")
    if actual_mode == "geotiff" and before_info["crs"] != after_info["crs"]:
        raise ValueError("GeoTIFF inputs must use the same CRS.")
    grid_alignment: dict[str, Any] = {"required": False, "method": "none"}
    if actual_mode == "geotiff":
        before_transform = before_info["transform"]
        after_transform = after_info["transform"]
        same_grid = before.shape[:2] == after.shape[:2] and np.allclose(
            [before_transform.a, before_transform.b, before_transform.c, before_transform.d, before_transform.e, before_transform.f],
            [after_transform.a, after_transform.b, after_transform.c, after_transform.d, after_transform.e, after_transform.f],
            rtol=1e-6,
            atol=1e-9,
        )
        if not same_grid:
            source_shape = [int(after.shape[0]), int(after.shape[1])]
            after = _reproject_rgb_to_grid(after_path, before.shape[:2], before_transform, before_info["crs"])
            grid_alignment = {
                "required": True,
                "method": "rasterio.reproject_bilinear",
                "source_shape": source_shape,
                "destination_shape": [int(before.shape[0]), int(before.shape[1])],
                "destination_crs": before_info["crs"],
            }
    if before.shape[:2] != after.shape[:2]:
        raise ValueError(f"Input dimensions must match before={before.shape[:2]} after={after.shape[:2]}")
    effective_max_dimension = max_dimension
    if effective_max_dimension == AUTO_MAX_DIMENSION:
        effective_max_dimension = 0 if actual_mode == "geotiff" else DEFAULT_MAX_DIMENSION
    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"])
    native_resolution = actual_mode == "geotiff" and effective_max_dimension == AUTO_MAX_DIMENSION
    before_small, after_small, valid_small, resize_details = _resize_pair(before, registered, valid, effective_max_dimension, native_resolution=native_resolution)
    source_transform = before_info["transform"] if actual_mode == "geotiff" else Affine(1, 0, 0, 0, -1, before.shape[0])
    transform = source_transform if native_resolution else source_transform * Affine.scale(resize_details["scale_x"], resize_details["scale_y"])
    output_crs = before_info["crs"] if actual_mode == "geotiff" else None
    _write_rgb_geotiff(work_dir / "before.tif", before_small, transform, output_crs)
    _write_rgb_geotiff(work_dir / "after_registered.tif", after_small, transform, output_crs)
    from geoai import ChangeStarDetection, masks_to_vector
@@ -178,15 +290,21 @@
    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_raster(output_dir / "change_probability.tif", probability, transform, "float32", output_crs)
    _write_raster(output_dir / "change_mask_raw.tif", raw_mask, transform, "uint8", output_crs)
    _write_raster(output_dir / "change_mask.tif", cleaned, transform, "uint8", output_crs)
    _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)
    features = _feature_summary(cleaned, probability, transform, actual_mode == "geotiff")
    _enrich_vector(vector_path, features, preserve_crs=actual_mode == "geotiff")
    (output_dir / "change_features.json").write_text(json.dumps({"features": features}, ensure_ascii=False, indent=2), encoding="utf-8")
    limitations = [
        "GeoTIFF 输入保留 CRS 和地图坐标;若两期网格不同,会在处理副本中将第二期双线性重投影到第一期网格,原始 TIFF 不会被改写。" if actual_mode == "geotiff" else "输入 JPG/PNG 或无 CRS TIFF 没有有效 CRS,GeoTIFF/GeoJSON 坐标是像素换算坐标,不是米或经纬度。",
        "ChangeStar 权重训练于 Changen2/S1 建筑变化数据;当前近景边坡照片不在其验证分布内。",
        "没有人工变化真值,不报告 precision、recall、IoU,也不输出变化类型或工程告警。",
        "ORB 配准只用于工作流演示;生产使用需要正射校正、同 GSD 和独立配准质量验收。",
    ]
    metadata: dict[str, Any] = {
        "schema_version": 1,
        "capability": "00-change-detection",
@@ -195,9 +313,13 @@
        "method": "geoai.ChangeStarDetection + geoai.masks_to_vector",
        "model": model_name,
        "device": "cpu",
        "processing_mode": actual_mode,
        "requested_processing_mode": processing_mode,
        "thresholds": {"change_probability": threshold, "minimum_component_pixels": min_area},
        "tile_size": tile_size,
        "overlap": overlap,
        "max_dimension": max_dimension,
        "effective_max_dimension": effective_max_dimension,
        "input_count": 2,
        "processed_images": 2,
        "input_files": [before_path.name, after_path.name],
@@ -205,15 +327,16 @@
        "input_shape": [int(before.shape[0]), int(before.shape[1])],
        "processed_shape": [int(before_small.shape[0]), int(before_small.shape[1])],
        "registration": registration,
        "grid_alignment": grid_alignment,
        "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,
        "georeferenced": actual_mode == "geotiff",
        "coordinate_basis": "source_crs_map_coordinates" if actual_mode == "geotiff" else "pixel_coordinates_north_up_transform_y_from_image_bottom",
        "crs": output_crs,
        "elapsed_seconds": round(time.perf_counter() - started, 3),
        "python": platform.python_version(),
        "platform": platform.platform(),
@@ -225,12 +348,7 @@
            "vector": "changes.geojson",
            "features": "change_features.json",
        },
        "limitations": [
            "输入 JPG 没有有效 CRS,GeoTIFF/GeoJSON 坐标是像素换算坐标,不是米或经纬度。",
            "ChangeStar 权重训练于 Changen2/S1 建筑变化数据;当前近景边坡照片不在其验证分布内。",
            "没有人工变化真值,不报告 precision、recall、IoU,也不输出变化类型或工程告警。",
            "ORB 配准只用于工作流演示;生产使用需要正射校正、同 GSD 和独立配准质量验收。",
        ],
        "limitations": limitations,
        "licenses": {
            "geoai-py": "MIT",
            "torchange": "Apache-2.0",
@@ -253,13 +371,14 @@
    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)
    parser.add_argument("--max-dimension", type=int, default=AUTO_MAX_DIMENSION, help="Long-edge cap in pixels; 0 keeps a valid GeoTIFF at native resolution and uses 1024 for ordinary images.")
    parser.add_argument("--processing-mode", choices=sorted(PROCESSING_MODES), default=DEFAULT_PROCESSING_MODE)
    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))
        print(json.dumps(run_change_detection(args.before, args.after, args.output, processed_dir=args.processed_output, model_name=args.model, threshold=args.threshold, tile_size=args.tile_size, overlap=args.overlap, max_dimension=args.max_dimension, processing_mode=args.processing_mode), ensure_ascii=False, indent=2))
    except (FileNotFoundError, ValueError, RuntimeError) as exc:
        raise SystemExit(f"变化检测失败: {exc}") from exc
capabilities/00-change-detection/tests/test_change_detection.py
@@ -3,6 +3,8 @@
import unittest
from pathlib import Path
import numpy as np
import rasterio
from rasterio.transform import from_origin
import importlib.util
@@ -27,6 +29,47 @@
        self.assertEqual(details["original_width"], 200)
        self.assertEqual(details["original_height"], 100)
    def test_geotiff_mode_reads_crs_and_native_resolution(self):
        module = load_module()
        with tempfile.TemporaryDirectory() as directory:
            root = Path(directory)
            transform = from_origin(100, 200, 0.5, 0.5)
            for name in ("before.tif", "after.tif"):
                with rasterio.open(root / name, "w", driver="GTiff", width=96, height=64, count=3, dtype="uint8", crs="EPSG:3857", transform=transform) as dataset:
                    dataset.write(np.zeros((3, 64, 96), dtype=np.uint8))
            image, info = module._read_rgb(root / "before.tif", "geotiff")
            self.assertEqual(image.shape, (64, 96, 3))
            self.assertEqual(info["crs"], "EPSG:3857")
            before = np.zeros((64, 96, 3), dtype=np.uint8)
            valid = np.ones((64, 96), dtype=bool)
            _, _, _, details = module._resize_pair(before, before, valid, 0, native_resolution=True)
            self.assertEqual((details["processed_width"], details["processed_height"]), (96, 64))
    def test_geotiff_mode_rejects_missing_spatial_reference(self):
        module = load_module()
        with tempfile.TemporaryDirectory() as directory:
            root = Path(directory)
            with rasterio.open(root / "plain.tif", "w", driver="GTiff", width=32, height=32, count=3, dtype="uint8") as dataset:
                dataset.write(np.zeros((3, 32, 32), dtype=np.uint8))
            with self.assertRaisesRegex(ValueError, "no valid CRS"):
                module._read_rgb(root / "plain.tif", "geotiff")
    def test_geotiff_grid_alignment_resamples_only_processing_copy(self):
        module = load_module()
        with tempfile.TemporaryDirectory() as directory:
            root = Path(directory)
            before_transform = from_origin(100, 200, 1, 1)
            after_transform = from_origin(100.25, 200.25, 1.01, 1.01)
            for name, transform, width, height in (("before.tif", before_transform, 8, 6), ("after.tif", after_transform, 9, 7)):
                with rasterio.open(root / name, "w", driver="GTiff", width=width, height=height, count=3, dtype="uint8", crs="EPSG:3857", transform=transform) as dataset:
                    dataset.write(np.full((3, height, width), 128, dtype=np.uint8))
            before, before_info = module._read_rgb(root / "before.tif", "geotiff")
            aligned = module._reproject_rgb_to_grid(root / "after.tif", before.shape[:2], before_info["transform"], before_info["crs"])
            self.assertEqual(aligned.shape, before.shape)
            with rasterio.open(root / "after.tif") as source:
                self.assertEqual(source.width, 9)
                self.assertEqual(source.height, 7)
    def test_mismatched_inputs_fail_before_creating_outputs(self):
        module = load_module()
        with tempfile.TemporaryDirectory() as directory:
@@ -49,6 +92,17 @@
                module.run_change_detection(root / "before.jpg", root / "after.jpg", root / "out", threshold=1.0)
            self.assertFalse((root / "out").exists())
    def test_max_dimension_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", max_dimension=256)
            self.assertFalse((root / "out").exists())
    def test_empty_vector_is_written_as_feature_collection(self):
        module = load_module()
        with tempfile.TemporaryDirectory() as directory:
scripts/serve_workbench_console.py
@@ -8,6 +8,7 @@
import json
import os
import re
import shutil
import subprocess
import threading
from datetime import UTC, datetime
@@ -15,20 +16,28 @@
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import unquote, urlsplit
from urllib.parse import parse_qs, unquote, urlsplit
from uuid import uuid4
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 6173
MAX_REQUEST_BYTES = 128 * 1024 * 1024
MAX_FILE_BYTES = 96 * 1024 * 1024
# Uploads are sent as Base64 JSON. Keep the request limit above two 1 GiB
# files after encoding while retaining a per-file bound for local experiments.
MAX_REQUEST_BYTES = 3072 * 1024 * 1024
MAX_FILE_BYTES = 1024 * 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
CHANGE_MAX_DIMENSION_DEFAULT = 1024
CHANGE_MAX_DIMENSION_AUTO = 0
CHANGE_MAX_DIMENSION_MIN = 512
CHANGE_MAX_DIMENSION_MAX = 4096
CHANGE_PROCESSING_MODE_DEFAULT = "auto"
CHANGE_PROCESSING_MODES = {"auto", "image", "geotiff"}
ALLOWED_PATH_PREFIXES = (
    "apps/workbench-console",
    "shared/outputs",
@@ -37,6 +46,7 @@
    "shared/data/raw/02-semantic-mapping",
)
SAFE_FILE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
SAFE_UPLOAD_ID = re.compile(r"^[0-9a-f]{32}$")
RUN_LOCK = threading.Lock()
@@ -295,6 +305,19 @@
            self.log_error("local run failed: %s", exc)
            self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Local run failed. Check the console terminal for details."})
    def do_PUT(self) -> None:  # noqa: N802 - binary upload endpoint
        path = urlsplit(self.path).path
        if not path.startswith("/api/change-detection/uploads/"):
            self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown local API endpoint."})
            return
        try:
            self.send_json(HTTPStatus.CREATED, self.receive_change_upload(path))
        except ApiError as exc:
            self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
        except Exception as exc:  # pragma: no cover - defensive server boundary
            self.log_error("binary upload failed: %s", exc)
            self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Binary upload failed. Check the console terminal for details."})
    def do_OPTIONS(self) -> None:  # noqa: N802
        self.send_response(HTTPStatus.NO_CONTENT)
        self.send_header("Allow", "GET, POST, OPTIONS")
@@ -322,6 +345,57 @@
        if completed.returncode:
            message = (completed.stderr or completed.stdout or "Unknown script error.").strip().splitlines()[-1]
            raise ApiError(f"Processing failed: {message[:600]}")
    def receive_change_upload(self, path: str) -> dict[str, Any]:
        upload_id = path.rstrip("/").rsplit("/", 1)[-1]
        if not SAFE_UPLOAD_ID.fullmatch(upload_id):
            raise ApiError("Invalid change-detection upload id.")
        query = parse_qs(urlsplit(self.path).query)
        role = query.get("role", [""])[0]
        if role not in {"before", "after"}:
            raise ApiError("Change-detection upload role must be before or after.")
        name = self.headers.get("X-Upload-Name", "")
        safe_name = safe_file_name(name, {".jpg", ".jpeg", ".png", ".tif", ".tiff"})
        content_length = self.headers.get("Content-Length")
        if content_length is None or not content_length.isdigit():
            raise ApiError("Binary upload requires a Content-Length header.")
        size = int(content_length)
        if size <= 0 or size > MAX_FILE_BYTES:
            raise ApiError(f"Uploaded file must be between 1 byte and {MAX_FILE_BYTES // (1024 * 1024)} MB: {safe_name}.")
        staging = self.root / "shared" / "data" / "raw" / "00-change-detection" / "uploads" / upload_id
        staging.mkdir(parents=True, exist_ok=False)
        part = staging / f"{role}.part"
        target = staging / f"{role}{Path(safe_name).suffix.lower()}"
        remaining = size
        try:
            with part.open("wb") as stream:
                while remaining:
                    chunk = self.rfile.read(min(8 * 1024 * 1024, remaining))
                    if not chunk:
                        raise ApiError("Binary upload ended before Content-Length was reached.")
                    stream.write(chunk)
                    remaining -= len(chunk)
            part.replace(target)
            (staging / f"{role}.json").write_text(json.dumps({"role": role, "name": safe_name, "size": size}), encoding="utf-8")
        except Exception:
            part.unlink(missing_ok=True)
            target.unlink(missing_ok=True)
            raise
        return {"uploadId": upload_id, "role": role, "name": safe_name, "size": size}
    def resolve_change_upload(self, payload: Any, role: str) -> tuple[str, Path]:
        if not isinstance(payload, dict) or not isinstance(payload.get("uploadId"), str):
            raise ApiError(f"Change-detection uploads must include a {role} uploadId.")
        upload_id = payload["uploadId"]
        if not SAFE_UPLOAD_ID.fullmatch(upload_id):
            raise ApiError("Invalid change-detection upload id.")
        staging = self.root / "shared" / "data" / "raw" / "00-change-detection" / "uploads" / upload_id
        manifest = load_json(staging / f"{role}.json")
        name = str(manifest.get("name") or "")
        path = staging / f"{role}{Path(name).suffix.lower()}"
        if manifest.get("role") != role or not name or not path.is_file():
            raise ApiError(f"The staged {role} TIFF upload is unavailable or incomplete.")
        return name, path
    def create_trajectory_run(self, payload: dict[str, Any]) -> dict[str, Any]:
        files = payload.get("files")
@@ -382,25 +456,47 @@
    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.")
        uploads = payload.get("uploads")
        staged: dict[str, tuple[str, Path]] = {}
        if isinstance(uploads, dict):
            staged["before"] = self.resolve_change_upload(uploads.get("before"), "before")
            staged["after"] = self.resolve_change_upload(uploads.get("after"), "after")
        elif isinstance(files, dict):
            decoded_before = decode_upload(files.get("before"), {".jpg", ".jpeg", ".png", ".tif", ".tiff"})
            decoded_after = decode_upload(files.get("after"), {".jpg", ".jpeg", ".png", ".tif", ".tiff"})
        else:
            raise ApiError("Change-detection request must contain before and after files or uploads.")
        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)
        processing_mode = payload.get("processingMode", CHANGE_PROCESSING_MODE_DEFAULT)
        if not isinstance(processing_mode, str) or processing_mode not in CHANGE_PROCESSING_MODES:
            raise ApiError("Change-detection processing mode must be auto, image, or geotiff.")
        max_dimension_value = payload.get("maxDimension", CHANGE_MAX_DIMENSION_AUTO)
        if isinstance(max_dimension_value, bool) or not isinstance(max_dimension_value, int):
            raise ApiError("Change-detection resolution must be an integer: 0 or between 512 and 4096.")
        max_dimension = int(max_dimension_value)
        if max_dimension != CHANGE_MAX_DIMENSION_AUTO and not CHANGE_MAX_DIMENSION_MIN <= max_dimension <= CHANGE_MAX_DIMENSION_MAX:
            raise ApiError("Change-detection resolution must be 0 or between 512 and 4096.")
        if staged:
            before_name, after_name = staged["before"][0], staged["after"][0]
        else:
            before_name, after_name = decoded_before[0], decoded_after[0]
        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 = raw_root / "before" / before_name
        after_path = raw_root / "after" / after_name
        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])
        if staged:
            shutil.copyfile(staged["before"][1], before_path)
            shutil.copyfile(staged["after"][1], after_path)
        else:
            before_path.write_bytes(decoded_before[1])
            after_path.write_bytes(decoded_after[1])
        processed_root = self.root / "shared" / "data" / "processed" / "00-change-detection" / run_id
        output = self.root / "shared" / "outputs" / "00-change-detection" / "runs" / run_id
        python = self.root / ".venvs" / "00-change-detection" / "Scripts" / "python.exe"
@@ -414,6 +510,8 @@
                    "--before", str(before_path),
                    "--after", str(after_path),
                    "--threshold", f"{threshold:.4f}",
                    "--max-dimension", str(max_dimension),
                    "--processing-mode", processing_mode,
                    "--processed-output", str(processed_root),
                    "--output", str(output),
                ],
tests/test_serve_workbench_console.py
@@ -3,6 +3,7 @@
import importlib.util
import io
import sys
import tempfile
import unittest
from pathlib import Path
@@ -56,6 +57,22 @@
        with self.assertRaises(MODULE.ApiError):
            handler.read_json_body()
    def test_large_tiff_upload_limits_allow_one_gibibyte_files(self) -> None:
        self.assertEqual(MODULE.MAX_FILE_BYTES, 1024 * 1024 * 1024)
        self.assertEqual(MODULE.MAX_REQUEST_BYTES, 3072 * 1024 * 1024)
    def test_binary_change_upload_preserves_original_bytes(self) -> None:
        with tempfile.TemporaryDirectory() as temp_dir:
            handler = object.__new__(MODULE.WorkbenchConsoleHandler)
            handler.directory = temp_dir
            handler.path = "/api/change-detection/uploads/0123456789abcdef0123456789abcdef?role=before"
            handler.headers = {"Content-Length": "13", "X-Upload-Name": "../1.tif"}
            handler.rfile = io.BytesIO(b"raw-tif-bytes")
            result = handler.receive_change_upload("/api/change-detection/uploads/0123456789abcdef0123456789abcdef")
            staged = Path(temp_dir) / "shared" / "data" / "raw" / "00-change-detection" / "uploads" / result["uploadId"] / "before.tif"
            self.assertEqual(staged.read_bytes(), b"raw-tif-bytes")
            self.assertEqual(result["name"], "1.tif")
    def test_script_failure_becomes_a_useful_api_error(self) -> None:
        handler = self.make_handler()
        with self.assertRaisesRegex(MODULE.ApiError, "Processing failed"):
@@ -83,6 +100,20 @@
        with self.assertRaisesRegex(MODULE.ApiError, "must be a number"):
            handler.create_change_run({**payload, "threshold": "0.5"})
    def test_change_resolution_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 512 and 4096"):
            handler.create_change_run({**payload, "maxDimension": 256})
        with self.assertRaisesRegex(MODULE.ApiError, "must be an integer"):
            handler.create_change_run({**payload, "maxDimension": "2048"})
    def test_change_processing_mode_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, "auto, image, or geotiff"):
            handler.create_change_run({**payload, "processingMode": "wrong"})
    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))