7 files modified
3 files added
| | |
| | | # GeoAI Workbench Current Context |
| | | |
| | | Last updated: 2026-08-18 |
| | | Last updated: 2026-08-19 |
| | | |
| | | This file is the current project snapshot for new Codex tasks. Keep it concise and replace stale facts instead of appending a conversation diary. |
| | | |
| | |
| | | - Start command: `py -3.12 .\scripts\serve_workbench_console.py` |
| | | - URL: `http://127.0.0.1:6173` (built console); Vite development is `http://127.0.0.1:6174/apps/workbench-console/`. Only `6xxx` ports are accepted. |
| | | - Scope: independent local experiment workbench for this repository. It has no code, account, or product API link to the two drone-product repositories. The map client directly requests public ArcGIS tiles and, only when configured, TianDiTu tiles. |
| | | - Current workflow: `00-change-detection`, `01-object-detection`, `02-semantic-mapping`, `04-spatial-measurement`, 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. |
| | | - 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. Its scan workspace shows historical scans and accepts new user-uploaded pairs with selectable threshold/minimum-area combinations, asynchronous CPU execution, per-combination overlays/statistics and GeoJSON, without treating scan inference as a formal model run. Spatial measurement shows raster/vector results, per-object metrics and downloads. Change detection 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. |
| | | |
| | |
| | | - 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 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. |
| | | - Console: `GET/POST /api/change-detection/runs`, `GET/POST /api/change-detection/scans`, `GET /api/change-detection/scan-jobs/<job-id>`, `POST /api/change-detection/scans/<scan-id>/promote` and binary upload endpoints on local `6xxx` ports. Real uploads at `6176` verified thresholds and selectable processing resolution; scan submissions are validated for at most 24 combinations and execute asynchronously through fixed capability scripts. Scan inference outputs remain in the scan workspace until a user promotes one selected combination; promotion creates a new formal run directory with raw-input references, makes the case discoverable, and uses rule rectangles as the primary case vector while retaining original polygons and masks. GeoTIFF candidates include source-CRS and EPSG:4326 vectors; ordinary images remain in px coordinates. Minimum connected area is a processed-pixel filter for both modes; an empty console scan selection defaults to `256 px`. Binary upload filenames are percent-encoded in the browser and decoded/sanitized by the server, so Chinese filenames no longer fail Request header validation. The historical candidate `0.50/256` was promoted and appeared in the case library with 42 WGS84 rectangles. 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`. |
| | |
| | | VITE_TIANDITU_TOKEN=<your-token> |
| | | ``` |
| | | |
| | | Change detection also exposes the read-only `GET /api/change-detection/scans` endpoint. The page renders the low-cost parameter scan contact sheet, a selected threshold/area overlay, scan metrics, and downloads for the three materialized candidate GeoJSON results without presenting the scan as a formal model run. |
| | | |
| | | The change-detection page also accepts a user-submitted parameter scan: upload a before/after pair, select threshold and minimum-area combinations (up to 24), choose processing mode/resolution, and start a background job. Poll `GET /api/change-detection/scan-jobs/<job-id>` until it completes; the resulting scan then appears in the read-only scan workspace with overlays, statistics, and GeoJSON artifacts. |
| | | |
| | | 普通图片的最小连通区域使用处理后影像的 px 面积,只用于滤除零碎噪声,与 CRS 无关。参数扫描允许该选项留空,留空时自动使用 `256 px`;需要比较保留小目标和过滤噪声的差异时再多选面积组合。变化检测二进制上传会对文件名做 URL 百分号编码,因此中文文件名可以安全通过浏览器请求头,服务端解码后仍会执行扩展名白名单和安全文件名处理。 |
| | | |
| | | 若某一组合没有检测到变化,结果区会显示 0 个图斑,并提供空 GeoJSON 下载,不会将“无变化”误报成处理失败。 |
| | | |
| | | 扫描结果不会自动进入案例库。确认某个组合后,在扫描结果区点击“将当前组合转为正式案例”,控制台会通过 `POST /api/change-detection/scans/<scan-id>/promote` 创建新的正式运行目录;成功后刷新案例库即可查看。正式案例优先展示规则四边形,仍提供原始不规则图斑、掩膜和概率栅格下载。GeoTIFF 结果同时提供源 CRS 和 EPSG:4326 矢量;普通图片结果使用 px 像素坐标。 |
| | | |
| | | 可先参考同目录的 `.env.example`。`VITE_` 变量会被打包进本地浏览器代码,因此 |
| | | 只应填入适合客户端使用的天地图访问 token,不要填入其他服务密钥。 |
| | | |
| | |
| | | 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; 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 ChangeRun { schema_version: number; capability: string; classification: string; geoai_version: string; method: string; model: string; device: string; processing_mode?: "image" | "geotiff"; requested_processing_mode?: "auto" | "image" | "geotiff"; thresholds: Record<string, number>; tile_size: number; overlap: number; max_dimension?: number; effective_max_dimension?: number; input_files: string[]; input_shape: number[]; processed_shape: number[]; registration: { method: string; matches: number; inliers: number; inlier_ratio: number; valid_ratio: number }; valid_pixel_ratio: number; raw_changed_pixels: number; changed_pixels: number; changed_pixel_ratio: number; vector_feature_count: number; rectangle_feature_count?: number; 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; rectangle_vector?: string; rectangle_vector_wgs84?: string | null; features: string }; created_at?: string; } |
| | | export interface ChangeCase { id: string; label: string; note: string; artifactRoot: string; beforeImage: string; afterImage: string; createdAt: string; run: ChangeRun; features: ChangeFeature[]; } |
| | | export interface ChangeScanResult { id: string; label: string; threshold: number; minimumAreaPixels: number; cleanedComponents: number; changedPixels: number; changedPixelRatio: number; vectorFeatureCount: number; fullVectorFeatureCount?: number | null; rectangleFeatureCount?: number | null; overlay: string; mask: string; regions: string; vector?: string | null; rectangleVector?: string | null; rectangleVectorWgs84?: string | null; } |
| | | export interface ChangeParameterScan { id: string; label: string; note: string; artifactRoot: string; sourceRun?: string; userSubmitted?: boolean; contactSheet?: string | null; results: ChangeScanResult[]; } |
| | | export interface ChangeScanJob { id: string; status: "queued" | "running" | "completed" | "failed"; phase?: "inference" | "parameter-scan" | "vectorization" | "done" | "error"; scanId?: string; error?: string; thresholds?: number[]; minimumAreas?: number[]; } |
| | | export interface 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; } |
| | |
| | | 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}`); |
| | | const vector = await getJson<{ features?: ChangeFeature[] }>(`${definition.artifactRoot}/${run.artifacts.rectangle_vector || run.artifacts.vector}`); |
| | | return { ...definition, run, features: vector.features ?? [] } satisfies ChangeCase; |
| | | })); |
| | | return Object.fromEntries(cases.map((item) => [item.id, item])); |
| | | } |
| | | export async function loadChangeParameterScans(): Promise<ChangeParameterScan[]> { |
| | | return (await getJson<{ scans: ChangeParameterScan[] }>("api/change-detection/scans")).scans; |
| | | } |
| | | |
| | | 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 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 }); |
| | | // HTTP header values are restricted to ISO-8859-1 by browsers. Percent-encode |
| | | // the original name so Chinese filenames do not fail before the upload starts. |
| | | const response = await fetch(`/api/change-detection/uploads/${uploadId}?${query.toString()}`, { method: "PUT", headers: { "Content-Type": "application/octet-stream", "X-Upload-Name": encodeURIComponent(file.name) }, body: file }); |
| | | return responseJson<ChangeUploadRef>(response); |
| | | } |
| | | export async function createChangeRunFromUploads(uploads: { before: ChangeUploadRef; after: ChangeUploadRef }, threshold = 0.5, maxDimension = 0, processingMode: "auto" | "image" | "geotiff" = "auto") { return postRun<{ run: ChangeDefinition }>("api/change-detection/runs", { uploads, threshold, maxDimension, processingMode }); } |
| | | export async function createChangeScanFromUploads(uploads: { before: ChangeUploadRef; after: ChangeUploadRef }, thresholds: number[], minimumAreas: number[], maxDimension = 0, processingMode: "auto" | "image" | "geotiff" = "auto") { return postRun<{ job: ChangeScanJob }>("api/change-detection/scans", { uploads, thresholds, minimumAreas, maxDimension, processingMode }); } |
| | | export async function getChangeScanJob(jobId: string) { return (await getJson<{ job: ChangeScanJob }>(`api/change-detection/scan-jobs/${jobId}`)).job; } |
| | | export async function promoteChangeScan(scanId: string, resultId: string) { return postRun<{ run: ChangeDefinition }>(`api/change-detection/scans/${encodeURIComponent(scanId)}/promote`, { resultId }); } |
| | | export function readFileAsPayload(file: File): Promise<UploadFilePayload> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error(`无法读取 ${file.name}`)); reader.onload = () => { const value = String(reader.result ?? ""); resolve({ name: file.name, content: value.slice(value.indexOf(",") + 1) }); }; reader.readAsDataURL(file); }); } |
| | |
| | | import { computed, onMounted, ref } from "vue"; |
| | | import { DownloadOutlined, FileImageOutlined, FileOutlined, PlayCircleOutlined, UploadOutlined } from "@ant-design/icons-vue"; |
| | | |
| | | import { artifactUrl, createChangeRunFromUploads, uploadChangeFile, type ChangeCase, type ChangeFeature } from "@/api/artifacts"; |
| | | import { artifactUrl, createChangeRunFromUploads, createChangeScanFromUploads, getChangeScanJob, loadChangeParameterScans, promoteChangeScan, uploadChangeFile, type ChangeCase, type ChangeFeature, type ChangeParameterScan, type ChangeScanResult } from "@/api/artifacts"; |
| | | import ArtifactState from "@/components/ArtifactState.vue"; |
| | | import { useArtifactStore } from "@/stores/artifacts"; |
| | | |
| | |
| | | const running = ref(false); |
| | | const runError = ref<string | null>(null); |
| | | const searchText = ref(""); |
| | | const parameterScans = ref<ChangeParameterScan[]>([]); |
| | | const selectedScanId = ref(""); |
| | | const selectedScanResultId = ref(""); |
| | | const scanBeforeFile = ref<File | null>(null); |
| | | const scanAfterFile = ref<File | null>(null); |
| | | const scanThresholds = ref([0.3, 0.4, 0.5]); |
| | | const scanMinimumAreas = ref<number[]>([64, 256, 686]); |
| | | const effectiveScanMinimumAreas = computed(() => scanMinimumAreas.value.length ? scanMinimumAreas.value : [256]); |
| | | const scanProcessingMode = ref<"auto" | "image" | "geotiff">("auto"); |
| | | const scanMaxDimension = ref(0); |
| | | const scanRunning = ref(false); |
| | | const scanJobMessage = ref<string | null>(null); |
| | | const scanJobError = ref<string | null>(null); |
| | | const promoting = ref(false); |
| | | 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)); |
| | | const selectedScan = computed(() => parameterScans.value.find((item) => item.id === selectedScanId.value) ?? parameterScans.value[0]); |
| | | const scanResultOptions = computed(() => selectedScan.value?.results.map((item) => ({ value: item.id, label: item.label })) ?? []); |
| | | const selectedScanResult = computed<ChangeScanResult | undefined>(() => selectedScan.value?.results.find((item) => item.id === selectedScanResultId.value) ?? selectedScan.value?.results.find((item) => item.threshold === 0.4 && item.minimumAreaPixels === 256) ?? selectedScan.value?.results[0]); |
| | | function selectScan(id: string) { |
| | | selectedScanId.value = id; |
| | | const scan = parameterScans.value.find((item) => item.id === id); |
| | | selectedScanResultId.value = scan?.results.find((item) => item.threshold === 0.4 && item.minimumAreaPixels === 256)?.id ?? scan?.results[0]?.id ?? ""; |
| | | } |
| | | function selectScanBefore(file: File) { scanBeforeFile.value = file; return false; } |
| | | function selectScanAfter(file: File) { scanAfterFile.value = file; return false; } |
| | | function updateScanMode(value: string | null | undefined) { |
| | | if (value !== "auto" && value !== "image" && value !== "geotiff") return; |
| | | scanProcessingMode.value = value; |
| | | if (value === "geotiff") scanMaxDimension.value = 0; |
| | | if (value === "image" && scanMaxDimension.value === 0) scanMaxDimension.value = 1024; |
| | | } |
| | | function updateScanDimension(value: number | null | undefined) { |
| | | const parsed = Number(value); |
| | | scanMaxDimension.value = [0, 1024, 1536, 2048, 3072].includes(parsed) ? parsed : 0; |
| | | } |
| | | 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; |
| | |
| | | } catch (error) { runError.value = error instanceof Error ? error.message : "变化检测运行失败"; } |
| | | finally { running.value = false; } |
| | | } |
| | | async function submitParameterScan() { |
| | | if (!scanBeforeFile.value || !scanAfterFile.value) { scanJobError.value = "请分别选择扫描的第一期和第二期影像。"; return; } |
| | | if (!scanThresholds.value.length) { scanJobError.value = "请至少选择一个扫描阈值。"; return; } |
| | | scanRunning.value = true; scanJobError.value = null; scanJobMessage.value = "正在上传影像并排队..."; |
| | | try { |
| | | const [before, after] = await Promise.all([uploadChangeFile(scanBeforeFile.value, "before"), uploadChangeFile(scanAfterFile.value, "after")]); |
| | | const { job: created } = await createChangeScanFromUploads({ before, after }, scanThresholds.value, effectiveScanMinimumAreas.value, scanMaxDimension.value, scanProcessingMode.value); |
| | | let job = created; |
| | | while (job.status === "queued" || job.status === "running") { |
| | | scanJobMessage.value = job.phase === "parameter-scan" ? "模型推理完成,正在扫描参数组合..." : job.phase === "vectorization" ? "参数组合完成,正在生成完整 GeoJSON..." : "正在执行 ChangeStar 推理..."; |
| | | await new Promise((resolve) => window.setTimeout(resolve, 2000)); |
| | | job = await getChangeScanJob(created.id); |
| | | } |
| | | if (job.status === "failed") throw new Error(job.error || "参数扫描失败。"); |
| | | parameterScans.value = await loadChangeParameterScans(); |
| | | selectScan(job.scanId || created.id); |
| | | scanBeforeFile.value = null; scanAfterFile.value = null; scanJobMessage.value = "参数扫描完成,可在下方切换组合查看结果。"; |
| | | } catch (error) { scanJobError.value = error instanceof Error ? error.message : "参数扫描失败。"; scanJobMessage.value = null; } |
| | | finally { scanRunning.value = false; } |
| | | } |
| | | async function promoteSelectedScan() { |
| | | if (!selectedScan.value || !selectedScanResult.value) return; |
| | | promoting.value = true; scanJobError.value = null; |
| | | try { |
| | | const { run } = await promoteChangeScan(selectedScan.value.id, selectedScanResult.value.id); |
| | | await store.loadChange(true); |
| | | caseId.value = run.id; |
| | | scanJobMessage.value = "已转为正式案例,可在案例库中查看。"; |
| | | } catch (error) { scanJobError.value = error instanceof Error ? error.message : "转为正式案例失败。"; } |
| | | finally { promoting.value = false; } |
| | | } |
| | | |
| | | onMounted(async () => { await store.loadChange(); caseId.value = Object.keys(store.changeCases)[0] ?? ""; }); |
| | | onMounted(async () => { |
| | | await Promise.all([store.loadChange(), loadChangeParameterScans().then((items) => { parameterScans.value = items; if (items[0]) selectScan(items[0].id); }).catch(() => { parameterScans.value = []; })]); |
| | | caseId.value = Object.keys(store.changeCases)[0] ?? ""; |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | |
| | | <a-button type="primary" :loading="running" :disabled="!beforeFile || !afterFile" @click="submitRun"><PlayCircleOutlined />开始检测</a-button> |
| | | </section> |
| | | |
| | | <section class="surface-section parameter-scan-workspace"> |
| | | <div class="section-heading"><div><h2>自己上传并执行参数扫描</h2><p>上传一组两期影像,先执行一次模型推理,再对同一份变化概率结果扫描多个阈值和最小面积。</p></div><a-tag color="blue">新建扫描</a-tag></div> |
| | | <div class="change-upload-grid"><div><label>扫描第一期影像</label><a-upload accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="selectScanBefore"><a-button><UploadOutlined />选择第一期</a-button></a-upload><span>{{ scanBeforeFile?.name || "未选择" }}</span></div><div><label>扫描第二期影像</label><a-upload accept=".jpg,.jpeg,.png,.tif,.tiff" :show-upload-list="false" :before-upload="selectScanAfter"><a-button><UploadOutlined />选择第二期</a-button></a-upload><span>{{ scanAfterFile?.name || "未选择" }}</span></div></div> |
| | | <div class="scan-parameter-grid"><div><label>扫描阈值</label><a-select mode="multiple" :value="scanThresholds" :max-tag-count="3" @update:value="scanThresholds = $event" style="width: 100%"><a-select-option :value="0.3">0.30</a-select-option><a-select-option :value="0.4">0.40</a-select-option><a-select-option :value="0.5">0.50</a-select-option><a-select-option :value="0.6">0.60</a-select-option><a-select-option :value="0.7">0.70</a-select-option></a-select></div><div><label>最小连通区域(可选)</label><a-select mode="multiple" placeholder="留空自动使用 256 px" :value="scanMinimumAreas" :max-tag-count="3" @update:value="scanMinimumAreas = ($event ?? [])" style="width: 100%"><a-select-option :value="16">16 px</a-select-option><a-select-option :value="64">64 px</a-select-option><a-select-option :value="256">256 px</a-select-option><a-select-option :value="686">686 px</a-select-option><a-select-option :value="1024">1024 px</a-select-option></a-select><span class="scan-field-note">普通图片按 px 过滤小噪声;留空时扫描 256 px。</span></div></div> |
| | | <div class="scan-parameter-grid"><div><label>处理模式</label><a-select :value="scanProcessingMode" @update:value="updateScanMode" style="width: 100%"><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></div><div><label>处理分辨率</label><a-select :value="scanMaxDimension" @update:value="updateScanDimension" style="width: 100%"><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></div></div> |
| | | <a-alert v-if="scanThresholds.length * effectiveScanMinimumAreas.length > 24" type="warning" show-icon message="最多选择 24 个参数组合,请减少阈值或最小面积选项。" /><a-alert v-if="scanJobError" type="error" show-icon :message="scanJobError" /><a-alert v-if="scanJobMessage" type="info" show-icon :message="scanJobMessage" /><a-button type="primary" :loading="scanRunning" :disabled="!scanBeforeFile || !scanAfterFile || !scanThresholds.length || scanThresholds.length * effectiveScanMinimumAreas.length > 24" @click="submitParameterScan"><PlayCircleOutlined />开始参数扫描</a-button> |
| | | </section> |
| | | |
| | | <section v-if="selectedScan && selectedScanResult" class="surface-section parameter-scan-workspace"> |
| | | <div class="section-heading"><div><h2>低成本参数扫描</h2><p>复用已有变化概率结果,仅调整阈值和最小连通区域;不会修改原始影像或重新运行模型。</p></div><a-tag color="blue">扫描结果</a-tag></div> |
| | | <div class="scan-controls"><a-select :value="selectedScanId" @update:value="selectScan" style="min-width: 280px"><a-select-option v-for="scan in parameterScans" :key="scan.id" :value="scan.id">{{ scan.label }}</a-select-option></a-select><a-select :value="selectedScanResultId" @update:value="selectedScanResultId = $event" style="min-width: 260px"><a-select-option v-for="item in scanResultOptions" :key="item.value" :value="item.value">{{ item.label }}</a-select-option></a-select></div> |
| | | <div class="scan-preview-grid"><figure v-if="selectedScan.contactSheet"><figcaption>全部组合总览</figcaption><a-image :src="artifactUrl(selectedScan.contactSheet)" /></figure><figure><figcaption>{{ selectedScanResult.label }} · 变化叠加</figcaption><a-image :src="artifactUrl(selectedScanResult.overlay)" /></figure></div> |
| | | <div class="scan-metrics"><a-statistic title="变化像素" :value="selectedScanResult.changedPixels" /><a-statistic title="变化比例" :value="(selectedScanResult.changedPixelRatio * 100).toFixed(3)" suffix="%" /><a-statistic title="清理后区域" :value="selectedScanResult.cleanedComponents" /><a-statistic title="规则四边形" :value="selectedScanResult.rectangleFeatureCount ?? selectedScanResult.fullVectorFeatureCount ?? selectedScanResult.vectorFeatureCount" /></div> |
| | | <a-space wrap><a-button type="primary" :loading="promoting" @click="promoteSelectedScan"><PlayCircleOutlined />将当前组合转为正式案例</a-button><a-button v-if="selectedScanResult.rectangleVector" :href="artifactUrl(selectedScanResult.rectangleVector)" download><DownloadOutlined />下载规则四边形</a-button><a-button v-if="selectedScanResult.rectangleVectorWgs84" :href="artifactUrl(selectedScanResult.rectangleVectorWgs84)" download><DownloadOutlined />下载经纬度 GeoJSON</a-button><a-button v-if="selectedScanResult.vector" :href="artifactUrl(selectedScanResult.vector)" download><FileOutlined />下载原始图斑</a-button><a-button :href="artifactUrl(selectedScanResult.regions)" download><FileOutlined />下载区域统计</a-button></a-space> |
| | | <a-alert type="info" show-icon :message="`当前组合:阈值 ${selectedScanResult.threshold.toFixed(2)},最小面积 ${selectedScanResult.minimumAreaPixels} px。建议先与人工框选区域对照,再决定是否用于正式运行。`" /> |
| | | </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-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> |
| | | <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 type="primary" v-if="currentCase.run.artifacts.rectangle_vector" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.rectangle_vector}`)" download><DownloadOutlined />规则四边形</a-button><a-button v-if="currentCase.run.artifacts.rectangle_vector_wgs84" :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.rectangle_vector_wgs84}`)" download><DownloadOutlined />经纬度 GeoJSON</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/${currentCase.run.artifacts.vector}`)" download><FileOutlined />原始图斑 GeoJSON</a-button><a-button :href="artifactUrl(`${currentCase.artifactRoot}/run_metadata.json`)" download><FileOutlined />运行元数据</a-button></a-space></section> |
| | | <a-alert class="change-limit" type="warning" show-icon message="当前近景边坡样本不在 ChangeStar 建筑变化权重的验证分布内;结果只能用于工作流与人工复核,不能直接形成工程结论。" /> |
| | | </template> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .change-run-form { display: grid; gap: 16px; margin-bottom: 24px; } |
| | | .parameter-scan-workspace { display: grid; gap: 16px; margin-bottom: 24px; } |
| | | .scan-controls { display: flex; flex-wrap: wrap; gap: 12px; } |
| | | .scan-parameter-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } |
| | | .scan-parameter-grid > div { display: grid; gap: 8px; } |
| | | .scan-parameter-grid label { color: var(--wb-muted); font-size: 12px; font-weight: 700; } |
| | | .scan-field-note { color: var(--wb-muted); font-size: 12px; } |
| | | .scan-preview-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } |
| | | .scan-preview-grid figure { min-width: 0; margin: 0; } |
| | | .scan-preview-grid figcaption { margin-bottom: 7px; color: #425148; font-size: 12px; font-weight: 700; } |
| | | .scan-preview-grid :deep(.ant-image), .scan-preview-grid :deep(img) { width: 100%; } |
| | | .scan-preview-grid :deep(img) { height: 330px; object-fit: contain; background: #202b25; } |
| | | .scan-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; padding: 14px; background: #f7f9f7; border: 1px solid var(--wb-border); } |
| | | .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-files { margin-bottom: 16px; } |
| | | .change-limit { margin-bottom: 24px; } |
| | | @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: 760px) { .change-upload-grid, .change-comparison, .scan-preview-grid, .scan-parameter-grid { grid-template-columns: 1fr; } .change-comparison :deep(img), .scan-preview-grid :deep(img) { height: 280px; } .change-vector > img { height: 340px; } .scan-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } } |
| | | @media (max-width: 480px) { .threshold-inputs { grid-template-columns: 1fr; gap: 4px; } .threshold-inputs :deep(.ant-input-number) { width: 100%; } } |
| | | </style> |
| | |
| | | - ChangeStar 权重 `EVER-Z/Changen2-ChangeStar1x256`:`CC BY-NC-SA 4.0`,当前 |
| | | 只能用于非商业验证,不可直接用于商业产品。 |
| | | - 用户提供的 `baseData` 图片:版权/使用授权未知,需由数据提供方确认。 |
| | | # Candidate parameter-scan outputs |
| | | |
| | | The low-cost scan reuses the existing `change_probability.tif` and does not rerun ChangeStar. Complete GeoJSON outputs are materialized for `threshold-0.40_area-256` (45 features), `threshold-0.40_area-64` (104 features), and `threshold-0.30_area-256` (47 features). The console exposes their overlays and downloads; compare them with manual labels before treating any combination as an accuracy improvement. |
| | | |
| | | The console can also run a scan from a new user upload. It performs one ChangeStar inference at a neutral threshold, applies the selected threshold/minimum-area combinations to that probability raster, and materializes per-combination GeoJSON in a separate `parameter-scans/<scan-id>/` directory. The API runs this as a background job so CPU inference and vectorization do not depend on a browser request remaining open. |
| | | |
| | | 最小连通区域适用于普通图片和 GeoTIFF:它表示处理后掩膜中的像素数量,不是平方米。普通图片参数扫描无需手工填写,留空时控制台使用 `256 px`;数值越小越容易保留小变化,也会保留更多噪声,数值越大则相反。 |
| | | |
| | | 当某个阈值组合没有任何变化像素时,仍会生成空的 `changes.geojson` 和 `changes_rectangles.geojson`(`FeatureCollection`,0 个要素),不会因为矢量化阶段缺少文件而使整个扫描失败。 |
| | | |
| | | 扫描结果默认只属于参数试验区,不会自动进入案例库。人工确认某个阈值/面积组合后,在扫描结果区点击“将当前组合转为正式案例”,控制台会复制该组合的掩膜、规则四边形 GeoJSON、经纬度 GeoJSON(若输入有 CRS)和预览图到新的正式运行目录,并保留扫描原始输入引用;随后它才会出现在案例库。这样可以避免未经复核的扫描组合被误认为正式结果。 |
| | | |
| | | 矢量输出分为两层:`changes.geojson` 保留模型原始不规则图斑,`changes_rectangles.geojson` 将每个图斑转换为最小外接旋转矩形,适合产品中按规则四边形消费,但可能覆盖比原图斑更大的区域,仍需人工复核。带有效 CRS/仿射变换的 GeoTIFF 另外生成 `changes_rectangles_wgs84.geojson`(EPSG:4326)用于地图叠加;普通 JPG/PNG 或无 CRS TIFF 只输出像素坐标,单位为 px,用于叠加原图,不伪造经纬度。 |
| New file |
| | |
| | | """Materialize full GeoJSON artifacts for selected parameter-scan masks.""" |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import json |
| | | from pathlib import Path |
| | | |
| | | |
| | | def materialize(scan_dir: Path, candidates: list[str]) -> list[dict]: |
| | | from geoai import masks_to_vector |
| | | from rectangularize_vectors import rectangularize_vector |
| | | |
| | | results: list[dict] = [] |
| | | for candidate in candidates: |
| | | item_dir = scan_dir / candidate |
| | | mask_path = item_dir / "change_mask.tif" |
| | | summary_path = item_dir / "summary.json" |
| | | if not mask_path.is_file() or not summary_path.is_file(): |
| | | raise FileNotFoundError(f"Missing scan result: {candidate}") |
| | | summary = json.loads(summary_path.read_text(encoding="utf-8")) |
| | | vector_path = item_dir / "changes.geojson" |
| | | vector = masks_to_vector( |
| | | str(mask_path), |
| | | str(vector_path), |
| | | simplify_tolerance=1.0, |
| | | mask_threshold=0.5, |
| | | min_object_area=int(summary["minimum_area_pixels"]), |
| | | ) |
| | | rectangle_path = item_dir / "changes_rectangles.geojson" |
| | | rectangle_wgs84_path = item_dir / "changes_rectangles_wgs84.geojson" |
| | | rectangle_count = rectangularize_vector(vector_path, rectangle_path, rectangle_wgs84_path) |
| | | record = { |
| | | **summary, |
| | | "full_vector_feature_count": len(vector), |
| | | "full_vector": vector_path.name, |
| | | "rectangle_vector_feature_count": rectangle_count, |
| | | "rectangle_vector": rectangle_path.name, |
| | | "rectangle_vector_wgs84": rectangle_wgs84_path.name if rectangle_wgs84_path.is_file() else None, |
| | | } |
| | | (item_dir / "full_result.json").write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | results.append(record) |
| | | output_path = scan_dir / "candidate_results.json" |
| | | output_path.write_text(json.dumps({"candidates": results}, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return results |
| | | |
| | | |
| | | def main() -> None: |
| | | parser = argparse.ArgumentParser() |
| | | parser.add_argument("--scan-dir", type=Path, required=True) |
| | | parser.add_argument( |
| | | "--candidate", |
| | | action="append", |
| | | dest="candidates", |
| | | default=None, |
| | | ) |
| | | args = parser.parse_args() |
| | | candidates = args.candidates or [ |
| | | "threshold-0.40_area-256", |
| | | "threshold-0.40_area-64", |
| | | "threshold-0.30_area-256", |
| | | ] |
| | | print(json.dumps({"candidates": materialize(args.scan_dir, candidates)}, ensure_ascii=False, indent=2)) |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | main() |
| New file |
| | |
| | | """Convert irregular change polygons into product-friendly rectangles.""" |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import json |
| | | from pathlib import Path |
| | | |
| | | from shapely.geometry import box, mapping, shape |
| | | |
| | | |
| | | def rectangularize_vector(source: Path, destination: Path, wgs84_destination: Path | None = None) -> int: |
| | | # geoai.masks_to_vector may omit the file when a threshold produces no |
| | | # features. Keep the artifact contract stable by materializing an empty |
| | | # GeoJSON collection so downstream promotion and downloads still work. |
| | | if source.is_file(): |
| | | payload = json.loads(source.read_text(encoding="utf-8")) |
| | | else: |
| | | payload = {"type": "FeatureCollection", "features": []} |
| | | source.parent.mkdir(parents=True, exist_ok=True) |
| | | source.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | rectangles = [] |
| | | for feature in payload.get("features", []): |
| | | geometry = feature.get("geometry") |
| | | if not geometry: |
| | | continue |
| | | source_shape = shape(geometry) |
| | | if source_shape.is_empty: |
| | | continue |
| | | rectangle = box(*source_shape.bounds) |
| | | properties = dict(feature.get("properties") or {}) |
| | | properties["geometry_mode"] = "axis_aligned_rectangle" |
| | | properties["source_geometry_type"] = source_shape.geom_type |
| | | rectangles.append({"type": "Feature", "properties": properties, "geometry": mapping(rectangle)}) |
| | | output = {"type": "FeatureCollection", "features": rectangles} |
| | | if payload.get("crs"): |
| | | output["crs"] = payload["crs"] |
| | | destination.parent.mkdir(parents=True, exist_ok=True) |
| | | destination.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | if wgs84_destination is not None and payload.get("crs") and rectangles: |
| | | import geopandas as gpd |
| | | |
| | | frame = gpd.GeoDataFrame.from_features(output["features"], crs=payload["crs"].get("properties", {}).get("name")) |
| | | if frame.crs: |
| | | frame.to_crs("EPSG:4326").to_file(wgs84_destination, driver="GeoJSON") |
| | | return len(rectangles) |
| | |
| | | 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, actual_mode == "geotiff") |
| | | _enrich_vector(vector_path, features, preserve_crs=actual_mode == "geotiff") |
| | | from rectangularize_vectors import rectangularize_vector |
| | | |
| | | rectangle_path = output_dir / "changes_rectangles.geojson" |
| | | rectangle_wgs84_path = output_dir / "changes_rectangles_wgs84.geojson" if actual_mode == "geotiff" else None |
| | | rectangle_count = rectangularize_vector(vector_path, rectangle_path, rectangle_wgs84_path) |
| | | (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 坐标是像素换算坐标,不是米或经纬度。", |
| | |
| | | "changed_pixels": int((cleaned > 0).sum()), |
| | | "changed_pixel_ratio": round(float((cleaned > 0).mean()), 6), |
| | | "vector_feature_count": len(vector), |
| | | "rectangle_feature_count": rectangle_count, |
| | | "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, |
| | |
| | | "mask_raster": "change_mask.tif", |
| | | "overlay": "change_overlay.jpg", |
| | | "vector": "changes.geojson", |
| | | "rectangle_vector": "changes_rectangles.geojson", |
| | | "rectangle_vector_wgs84": "changes_rectangles_wgs84.geojson" if actual_mode == "geotiff" else None, |
| | | "features": "change_features.json", |
| | | }, |
| | | "limitations": limitations, |
| New file |
| | |
| | | """Sweep ChangeStar probability and component-area thresholds without rerunning inference.""" |
| | | |
| | | from __future__ import annotations |
| | | |
| | | import argparse |
| | | import csv |
| | | import json |
| | | from pathlib import Path |
| | | |
| | | import cv2 |
| | | import numpy as np |
| | | import rasterio |
| | | from PIL import Image, ImageDraw, ImageFont |
| | | |
| | | import importlib.util |
| | | |
| | | |
| | | def load_demo(): |
| | | path = Path(__file__).with_name("run_change_detection.py") |
| | | spec = importlib.util.spec_from_file_location("change_detection_demo", path) |
| | | if spec is None or spec.loader is None: |
| | | raise RuntimeError("Unable to load change-detection helpers.") |
| | | module = importlib.util.module_from_spec(spec) |
| | | spec.loader.exec_module(module) |
| | | return module |
| | | |
| | | |
| | | def clean_mask(raw: np.ndarray, minimum_area: int) -> tuple[np.ndarray, int]: |
| | | cleaned = raw.copy() |
| | | count, labels, stats, _ = cv2.connectedComponentsWithStats((cleaned > 0).astype(np.uint8), connectivity=8) |
| | | for component in range(1, count): |
| | | if int(stats[component, cv2.CC_STAT_AREA]) < minimum_area: |
| | | cleaned[labels == component] = 0 |
| | | return cleaned, count - 1 |
| | | |
| | | |
| | | def preview(image: np.ndarray, mask: np.ndarray, max_dimension: int = 720) -> Image.Image: |
| | | height, width = image.shape[:2] |
| | | scale = min(1.0, max_dimension / max(height, width)) |
| | | size = (max(1, int(round(width * scale))), max(1, int(round(height * scale)))) |
| | | base = Image.fromarray(image).resize(size, Image.Resampling.LANCZOS) |
| | | small_mask = Image.fromarray((mask > 0).astype(np.uint8) * 255).resize(size, Image.Resampling.NEAREST) |
| | | base_array = np.asarray(base).copy() |
| | | mask_array = np.asarray(small_mask) > 0 |
| | | red = np.zeros_like(base_array) |
| | | red[..., 0] = 255 |
| | | base_array[mask_array] = (base_array[mask_array].astype(np.float32) * 0.45 + red[mask_array].astype(np.float32) * 0.55).astype(np.uint8) |
| | | return Image.fromarray(base_array) |
| | | |
| | | |
| | | def run_scan(run_dir: Path, output_dir: Path, thresholds: list[float], areas: list[int]) -> dict: |
| | | demo = load_demo() |
| | | probability_path = run_dir / "change_probability.tif" |
| | | input_path = run_dir.parents[3] / "data" / "processed" / "00-change-detection" / run_dir.name / "after_registered.tif" |
| | | if not probability_path.is_file() or not input_path.is_file(): |
| | | raise FileNotFoundError("The source run must contain change_probability.tif and processed after_registered.tif.") |
| | | with rasterio.open(probability_path) as probability_dataset: |
| | | probability = probability_dataset.read(1).astype(np.float32) |
| | | transform = probability_dataset.transform |
| | | crs = probability_dataset.crs.to_string() if probability_dataset.crs else None |
| | | with rasterio.open(input_path) as input_dataset: |
| | | image = np.transpose(input_dataset.read([1, 2, 3]), (1, 2, 0)) |
| | | output_dir.mkdir(parents=True, exist_ok=False) |
| | | rows: list[dict] = [] |
| | | contact_items: list[tuple[str, Image.Image]] = [] |
| | | for threshold in thresholds: |
| | | for area in areas: |
| | | label = f"threshold-{threshold:.2f}_area-{area}" |
| | | item_dir = output_dir / label |
| | | item_dir.mkdir() |
| | | raw = (probability >= threshold).astype(np.uint8) * 255 |
| | | cleaned, raw_components = clean_mask(raw, area) |
| | | cleaned_components = int(cv2.connectedComponents((cleaned > 0).astype(np.uint8), connectivity=8)[0] - 1) |
| | | mask_path = item_dir / "change_mask.tif" |
| | | with rasterio.open(mask_path, "w", driver="GTiff", height=cleaned.shape[0], width=cleaned.shape[1], count=1, dtype="uint8", transform=transform, crs=crs, compress="lzw") as destination: |
| | | destination.write(cleaned, 1) |
| | | # Vectorization is intentionally deferred: full-resolution GeoTIFF |
| | | # polygons are expensive and are not needed to compare thresholds. |
| | | features = demo._feature_summary(cleaned, probability, transform, crs is not None) |
| | | (item_dir / "regions.json").write_text(json.dumps({"features": features}, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | overlay = preview(image, cleaned) |
| | | overlay.save(item_dir / "overlay_preview.jpg", quality=90) |
| | | row = { |
| | | "threshold": threshold, |
| | | "minimum_area_pixels": area, |
| | | "raw_components": raw_components, |
| | | "cleaned_components": cleaned_components, |
| | | "raw_changed_pixels": int((raw > 0).sum()), |
| | | "changed_pixels": int((cleaned > 0).sum()), |
| | | "changed_pixel_ratio": round(float((cleaned > 0).mean()), 6), |
| | | "vector_feature_count": len(features), |
| | | "directory": label, |
| | | } |
| | | (item_dir / "summary.json").write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | rows.append(row) |
| | | contact_items.append((f"T={threshold:.2f} / A={area} | {len(features)} regions", overlay)) |
| | | (output_dir / "scan_summary.json").write_text(json.dumps({"source_run": run_dir.name, "results": rows}, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | with (output_dir / "scan_summary.csv").open("w", newline="", encoding="utf-8-sig") as stream: |
| | | writer = csv.DictWriter(stream, fieldnames=list(rows[0])) |
| | | writer.writeheader() |
| | | writer.writerows(rows) |
| | | tile_width = max(image.width for _, image in contact_items) |
| | | tile_height = max(image.height for _, image in contact_items) + 30 |
| | | sheet = Image.new("RGB", (tile_width * 3, tile_height * 4), "#202b25") |
| | | draw = ImageDraw.Draw(sheet) |
| | | for index, (label, item) in enumerate(contact_items): |
| | | x = (index % 3) * tile_width |
| | | y = (index // 3) * tile_height |
| | | sheet.paste(item, (x, y + 28)) |
| | | draw.text((x + 6, y + 6), label, fill="white") |
| | | sheet.save(output_dir / "parameter_scan_contact_sheet.jpg", quality=92) |
| | | return {"source_run": run_dir.name, "output_dir": str(output_dir), "results": rows} |
| | | |
| | | |
| | | def main() -> None: |
| | | parser = argparse.ArgumentParser() |
| | | parser.add_argument("--run-dir", type=Path, required=True) |
| | | parser.add_argument("--output", type=Path, required=True) |
| | | parser.add_argument("--threshold", type=float, action="append", dest="thresholds") |
| | | parser.add_argument("--minimum-area", type=int, action="append", dest="areas") |
| | | args = parser.parse_args() |
| | | thresholds = args.thresholds or [0.3, 0.4, 0.5] |
| | | areas = args.areas or [16, 64, 256, 686] |
| | | result = run_scan(args.run_dir, args.output, thresholds, areas) |
| | | print(json.dumps(result, ensure_ascii=False, indent=2)) |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | main() |
| | |
| | | CHANGE_MAX_DIMENSION_MAX = 4096 |
| | | CHANGE_PROCESSING_MODE_DEFAULT = "auto" |
| | | CHANGE_PROCESSING_MODES = {"auto", "image", "geotiff"} |
| | | SCAN_DEFAULT_THRESHOLDS = [0.3, 0.4, 0.5] |
| | | SCAN_DEFAULT_AREAS = [64, 256, 686] |
| | | SCAN_MAX_THRESHOLDS = 6 |
| | | SCAN_MAX_AREAS = 6 |
| | | SCAN_MAX_COMBINATIONS = 24 |
| | | SCAN_JOB_TIMEOUT = 1800 |
| | | ALLOWED_PATH_PREFIXES = ( |
| | | "apps/workbench-console", |
| | | "shared/outputs", |
| | |
| | | ) |
| | | SAFE_FILE_NAME = re.compile(r"[^A-Za-z0-9._-]+") |
| | | SAFE_UPLOAD_ID = re.compile(r"^[0-9a-f]{32}$") |
| | | SAFE_SCAN_ID = re.compile(r"^[A-Za-z0-9._-]{1,100}$") |
| | | SAFE_SCAN_RESULT_ID = re.compile(r"^threshold-\d+(?:\.\d+)?_area-\d+$") |
| | | RUN_LOCK = threading.Lock() |
| | | SCAN_JOBS: dict[str, dict[str, Any]] = {} |
| | | SCAN_JOBS_LOCK = threading.Lock() |
| | | |
| | | |
| | | class ApiError(ValueError): |
| | |
| | | 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): |
| | | if metadata.get("capability") != "00-change-detection" or metadata.get("schema_version") != 1 or metadata.get("kind") == "parameter-scan-inference" 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 |
| | |
| | | } |
| | | ) |
| | | return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True) |
| | | |
| | | |
| | | def change_parameter_scans(root: Path) -> list[dict[str, Any]]: |
| | | """Discover read-only parameter scans produced from an existing change run.""" |
| | | output_root = root / "shared" / "outputs" / "00-change-detection" |
| | | records: list[dict[str, Any]] = [] |
| | | for summary_path in output_root.rglob("scan_summary.json"): |
| | | scan_root = summary_path.parent |
| | | summary = load_json(summary_path) |
| | | results: list[dict[str, Any]] = [] |
| | | for item in summary.get("results", []): |
| | | if not isinstance(item, dict) or not isinstance(item.get("directory"), str): |
| | | continue |
| | | directory = scan_root / item["directory"] |
| | | overlay = directory / "overlay_preview.jpg" |
| | | mask = directory / "change_mask.tif" |
| | | regions = directory / "regions.json" |
| | | if not overlay.is_file() or not mask.is_file() or not regions.is_file(): |
| | | continue |
| | | results.append( |
| | | { |
| | | "id": item["directory"], |
| | | "label": f"T={float(item.get('threshold', 0.5)):.2f} / 面积={int(item.get('minimum_area_pixels', 0))} px", |
| | | "threshold": item.get("threshold"), |
| | | "minimumAreaPixels": item.get("minimum_area_pixels"), |
| | | "cleanedComponents": item.get("cleaned_components"), |
| | | "changedPixels": item.get("changed_pixels"), |
| | | "changedPixelRatio": item.get("changed_pixel_ratio"), |
| | | "vectorFeatureCount": item.get("vector_feature_count"), |
| | | "fullVectorFeatureCount": (load_json(directory / "full_result.json").get("full_vector_feature_count") if (directory / "full_result.json").is_file() else None), |
| | | "rectangleFeatureCount": ( |
| | | load_json(directory / "full_result.json").get("rectangle_vector_feature_count") |
| | | if (directory / "full_result.json").is_file() and load_json(directory / "full_result.json").get("rectangle_vector_feature_count") is not None |
| | | else len(load_json(directory / "changes_rectangles.geojson").get("features", [])) if (directory / "changes_rectangles.geojson").is_file() else None |
| | | ), |
| | | "overlay": relative_path(root, overlay), |
| | | "mask": relative_path(root, mask), |
| | | "regions": relative_path(root, regions), |
| | | "vector": (relative_path(root, directory / "changes.geojson") if (directory / "changes.geojson").is_file() else None), |
| | | "rectangleVector": (relative_path(root, directory / "changes_rectangles.geojson") if (directory / "changes_rectangles.geojson").is_file() else None), |
| | | "rectangleVectorWgs84": (relative_path(root, directory / "changes_rectangles_wgs84.geojson") if (directory / "changes_rectangles_wgs84.geojson").is_file() else None), |
| | | } |
| | | ) |
| | | # A failed job may have been repaired or materialized later. Keep it |
| | | # discoverable whenever at least one complete candidate exists; only |
| | | # hide scans that still have no usable result. |
| | | if not results: |
| | | continue |
| | | scan_metadata = load_json(scan_root / "scan_metadata.json") |
| | | contact_sheet = scan_root / "parameter_scan_contact_sheet.jpg" |
| | | records.append( |
| | | { |
| | | "id": scan_root.name, |
| | | "label": f"低成本参数扫描 · {scan_root.name}", |
| | | "note": f"复用已有变化概率结果,不重新运行 ChangeStar;源运行:{summary.get('source_run', '未知')}", |
| | | "artifactRoot": relative_path(root, scan_root), |
| | | "sourceRun": summary.get("source_run"), |
| | | "userSubmitted": bool(scan_metadata), |
| | | "contactSheet": relative_path(root, contact_sheet) if contact_sheet.is_file() else None, |
| | | "results": results, |
| | | } |
| | | ) |
| | | return sorted(records, key=lambda item: item["id"], reverse=True) |
| | | |
| | | |
| | | def semantic_runs(root: Path) -> list[dict[str, Any]]: |
| | |
| | | if path == "/api/change-detection/runs": |
| | | self.send_json(HTTPStatus.OK, {"runs": change_runs(self.root)}) |
| | | return |
| | | if path == "/api/change-detection/scans": |
| | | self.send_json(HTTPStatus.OK, {"scans": change_parameter_scans(self.root)}) |
| | | return |
| | | if path.startswith("/api/change-detection/scan-jobs/"): |
| | | job_id = path.rstrip("/").rsplit("/", 1)[-1] |
| | | with SCAN_JOBS_LOCK: |
| | | job = dict(SCAN_JOBS.get(job_id, {})) |
| | | if not job: |
| | | self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown change-detection scan job."}) |
| | | else: |
| | | self.send_json(HTTPStatus.OK, {"job": job}) |
| | | return |
| | | if path == "/api/trajectory/runs": |
| | | self.send_json(HTTPStatus.OK, {"runs": trajectory_runs(self.root)}) |
| | | return |
| | |
| | | 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/change-detection/scans": |
| | | self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_change_scan(payload)}) |
| | | return |
| | | if path.startswith("/api/change-detection/scans/") and path.endswith("/promote"): |
| | | scan_id = path.split("/")[-2] |
| | | self.send_json(HTTPStatus.CREATED, {"run": self.promote_change_scan(scan_id, payload)}) |
| | | return |
| | | if path == "/api/trajectory/runs": |
| | | self.send_json(HTTPStatus.CREATED, {"run": self.create_trajectory_run(payload)}) |
| | |
| | | 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", "") |
| | | encoded_name = self.headers.get("X-Upload-Name", "") |
| | | try: |
| | | name = unquote(encoded_name) |
| | | except Exception as exc: |
| | | raise ApiError("The uploaded filename is invalid.") from exc |
| | | 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(): |
| | |
| | | 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 _scan_parameters(self, payload: dict[str, Any]) -> tuple[list[float], list[int]]: |
| | | raw_thresholds = payload.get("thresholds", SCAN_DEFAULT_THRESHOLDS) |
| | | raw_areas = payload.get("minimumAreas", SCAN_DEFAULT_AREAS) |
| | | if not isinstance(raw_thresholds, list) or not raw_thresholds or len(raw_thresholds) > SCAN_MAX_THRESHOLDS: |
| | | raise ApiError(f"Parameter scan thresholds must contain 1-{SCAN_MAX_THRESHOLDS} values.") |
| | | if not isinstance(raw_areas, list) or not raw_areas or len(raw_areas) > SCAN_MAX_AREAS: |
| | | raise ApiError(f"Parameter scan minimum areas must contain 1-{SCAN_MAX_AREAS} values.") |
| | | thresholds: list[float] = [] |
| | | for value in raw_thresholds: |
| | | if isinstance(value, bool) or not isinstance(value, (int, float)): |
| | | raise ApiError("Each scan threshold must be a number between 0.01 and 0.99.") |
| | | number = round(float(value), 4) |
| | | if not CHANGE_THRESHOLD_MIN <= number <= CHANGE_THRESHOLD_MAX: |
| | | raise ApiError("Each scan threshold must be between 0.01 and 0.99.") |
| | | if number not in thresholds: |
| | | thresholds.append(number) |
| | | areas: list[int] = [] |
| | | for value in raw_areas: |
| | | if isinstance(value, bool) or not isinstance(value, int) or not 16 <= value <= 200000: |
| | | raise ApiError("Each scan minimum area must be an integer between 16 and 200000 pixels.") |
| | | if value not in areas: |
| | | areas.append(value) |
| | | if len(thresholds) * len(areas) > SCAN_MAX_COMBINATIONS: |
| | | raise ApiError(f"A parameter scan accepts at most {SCAN_MAX_COMBINATIONS} combinations.") |
| | | return thresholds, areas |
| | | |
| | | def _scan_root(self, scan_id: str) -> Path: |
| | | if not SAFE_SCAN_ID.fullmatch(scan_id): |
| | | raise ApiError("Invalid parameter-scan id.") |
| | | scan_root = self.root / "shared" / "outputs" / "00-change-detection" / "parameter-scans" / scan_id |
| | | if not scan_root.is_dir() or not (scan_root / "scan_summary.json").is_file(): |
| | | raise ApiError("The parameter-scan result is unavailable.") |
| | | return scan_root |
| | | |
| | | def promote_change_scan(self, scan_id: str, payload: dict[str, Any]) -> dict[str, Any]: |
| | | scan_root = self._scan_root(scan_id) |
| | | result_id = payload.get("resultId") |
| | | if not isinstance(result_id, str) or not SAFE_SCAN_RESULT_ID.fullmatch(result_id): |
| | | raise ApiError("A valid parameter-scan resultId is required.") |
| | | result_dir = scan_root / result_id |
| | | summary = load_json(result_dir / "summary.json") |
| | | full_result = load_json(result_dir / "full_result.json") |
| | | inference_run_id = str(load_json(scan_root / "scan_summary.json").get("source_run") or "") |
| | | inference_output = self.root / "shared" / "outputs" / "00-change-detection" / "runs" / inference_run_id |
| | | inference_metadata = load_json(inference_output / "run_metadata.json") |
| | | if not result_dir.is_dir() or not (result_dir / "changes.geojson").is_file() or not inference_metadata: |
| | | raise ApiError("The selected scan result is incomplete and cannot be promoted.") |
| | | run_id = make_run_id("change") |
| | | output = self.root / "shared" / "outputs" / "00-change-detection" / "runs" / run_id |
| | | output.mkdir(parents=True, exist_ok=False) |
| | | for source_name, destination_name in ( |
| | | ("change_probability.tif", "change_probability.tif"), |
| | | ("change_mask.tif", "change_mask.tif"), |
| | | ("changes.geojson", "changes.geojson"), |
| | | ("changes_rectangles.geojson", "changes_rectangles.geojson"), |
| | | ("changes_rectangles_wgs84.geojson", "changes_rectangles_wgs84.geojson"), |
| | | ): |
| | | source = result_dir / source_name if source_name.startswith("change_mask") or source_name.startswith("changes") else inference_output / source_name |
| | | if source.is_file(): |
| | | shutil.copyfile(source, output / destination_name) |
| | | overlay_source = result_dir / "overlay_preview.jpg" |
| | | if not overlay_source.is_file(): |
| | | raise ApiError("The selected scan preview is unavailable.") |
| | | shutil.copyfile(overlay_source, output / "change_overlay.jpg") |
| | | metadata = dict(inference_metadata) |
| | | scan_raw_root = self.root / "shared" / "data" / "raw" / "00-change-detection" / "runs" / scan_id |
| | | before_raw = scan_raw_root / "before" / str(inference_metadata.get("input_files", ["before.tif", "after.tif"])[0]) |
| | | after_raw = scan_raw_root / "after" / str(inference_metadata.get("input_files", ["before.tif", "after.tif"])[1]) |
| | | rectangle_count = int(full_result.get("rectangle_vector_feature_count") or 0) |
| | | if rectangle_count == 0 and (result_dir / "changes_rectangles.geojson").is_file(): |
| | | rectangle_count = len(load_json(result_dir / "changes_rectangles.geojson").get("features", [])) |
| | | metadata.update( |
| | | { |
| | | "kind": "formal-change-run", |
| | | "created_at": datetime.now(UTC).isoformat(), |
| | | "thresholds": {"change_probability": float(summary.get("threshold", 0.5)), "minimum_component_pixels": int(summary.get("minimum_area_pixels", 16))}, |
| | | "raw_changed_pixels": int(summary.get("raw_changed_pixels", 0)), |
| | | "changed_pixels": int(summary.get("changed_pixels", 0)), |
| | | "changed_pixel_ratio": float(summary.get("changed_pixel_ratio", 0)), |
| | | "vector_feature_count": int(full_result.get("full_vector_feature_count", summary.get("vector_feature_count", 0))), |
| | | "rectangle_feature_count": rectangle_count, |
| | | "promoted_from_scan": scan_id, |
| | | "promoted_result": result_id, |
| | | "raw_input_dir": relative_path(self.root, scan_raw_root), |
| | | "raw_before": relative_path(self.root, before_raw), |
| | | "raw_after": relative_path(self.root, after_raw), |
| | | "artifacts": { |
| | | "probability_raster": "change_probability.tif", |
| | | "raw_mask_raster": "change_mask.tif", |
| | | "mask_raster": "change_mask.tif", |
| | | "overlay": "change_overlay.jpg", |
| | | "vector": "changes.geojson", |
| | | "rectangle_vector": "changes_rectangles.geojson", |
| | | "rectangle_vector_wgs84": "changes_rectangles_wgs84.geojson" if (output / "changes_rectangles_wgs84.geojson").is_file() else None, |
| | | "features": "full_result.json", |
| | | }, |
| | | } |
| | | ) |
| | | (output / "full_result.json").write_text(json.dumps(full_result, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | (output / "run_metadata.json").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_change_scan(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | uploads = payload.get("uploads") |
| | | if not isinstance(uploads, dict): |
| | | raise ApiError("Parameter scan must contain staged before and after uploads.") |
| | | staged = { |
| | | "before": self.resolve_change_upload(uploads.get("before"), "before"), |
| | | "after": self.resolve_change_upload(uploads.get("after"), "after"), |
| | | } |
| | | thresholds, areas = self._scan_parameters(payload) |
| | | 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.") |
| | | run_id = make_run_id("scan") |
| | | raw_root = self.root / "shared" / "data" / "raw" / "00-change-detection" / "runs" / run_id |
| | | before_path = raw_root / "before" / staged["before"][0] |
| | | after_path = raw_root / "after" / staged["after"][0] |
| | | before_path.parent.mkdir(parents=True, exist_ok=False) |
| | | after_path.parent.mkdir(parents=True, exist_ok=False) |
| | | shutil.copyfile(staged["before"][1], before_path) |
| | | shutil.copyfile(staged["after"][1], after_path) |
| | | processed_root = self.root / "shared" / "data" / "processed" / "00-change-detection" / run_id |
| | | inference_output = self.root / "shared" / "outputs" / "00-change-detection" / "runs" / run_id |
| | | scan_output = self.root / "shared" / "outputs" / "00-change-detection" / "parameter-scans" / run_id |
| | | with SCAN_JOBS_LOCK: |
| | | SCAN_JOBS[run_id] = { |
| | | "id": run_id, |
| | | "status": "queued", |
| | | "createdAt": datetime.now(UTC).isoformat(), |
| | | "thresholds": thresholds, |
| | | "minimumAreas": areas, |
| | | "processingMode": processing_mode, |
| | | "maxDimension": max_dimension, |
| | | } |
| | | thread = threading.Thread( |
| | | target=self._run_change_scan, |
| | | args=(run_id, before_path, after_path, processed_root, inference_output, scan_output, thresholds, areas, processing_mode, max_dimension), |
| | | daemon=True, |
| | | name=f"change-scan-{run_id}", |
| | | ) |
| | | thread.start() |
| | | return dict(SCAN_JOBS[run_id]) |
| | | |
| | | def _update_scan_job(self, job_id: str, **values: Any) -> None: |
| | | with SCAN_JOBS_LOCK: |
| | | if job_id in SCAN_JOBS: |
| | | SCAN_JOBS[job_id].update(values) |
| | | |
| | | def _run_change_scan( |
| | | self, |
| | | run_id: str, |
| | | before_path: Path, |
| | | after_path: Path, |
| | | processed_root: Path, |
| | | inference_output: Path, |
| | | scan_output: Path, |
| | | thresholds: list[float], |
| | | areas: list[int], |
| | | processing_mode: str, |
| | | max_dimension: int, |
| | | ) -> None: |
| | | python = self.root / ".venvs" / "00-change-detection" / "Scripts" / "python.exe" |
| | | try: |
| | | if not python.is_file(): |
| | | raise ApiError("Change-detection virtual environment is unavailable. Run the capability setup first.") |
| | | self._update_scan_job(run_id, status="running", phase="inference") |
| | | 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", "0.5000", |
| | | "--max-dimension", str(max_dimension), |
| | | "--processing-mode", processing_mode, |
| | | "--processed-output", str(processed_root), |
| | | "--output", str(inference_output), |
| | | ], |
| | | SCAN_JOB_TIMEOUT, |
| | | ) |
| | | inference_metadata_path = inference_output / "run_metadata.json" |
| | | inference_metadata = load_json(inference_metadata_path) |
| | | inference_metadata["kind"] = "parameter-scan-inference" |
| | | inference_metadata["scan_job_id"] = run_id |
| | | inference_metadata_path.write_text(json.dumps(inference_metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | self._update_scan_job(run_id, phase="parameter-scan") |
| | | command = [ |
| | | str(python), |
| | | str(self.root / "capabilities" / "00-change-detection" / "scan_change_detection_parameters.py"), |
| | | "--run-dir", str(inference_output), |
| | | "--output", str(scan_output), |
| | | ] |
| | | for threshold in thresholds: |
| | | command.extend(["--threshold", f"{threshold:.4f}"]) |
| | | for area in areas: |
| | | command.extend(["--minimum-area", str(area)]) |
| | | self.run_command(command, SCAN_JOB_TIMEOUT) |
| | | self._update_scan_job(run_id, phase="vectorization") |
| | | vector_command = [ |
| | | str(python), |
| | | str(self.root / "capabilities" / "00-change-detection" / "materialize_parameter_scan_candidates.py"), |
| | | "--scan-dir", str(scan_output), |
| | | ] |
| | | for threshold in thresholds: |
| | | for area in areas: |
| | | vector_command.extend(["--candidate", f"threshold-{threshold:.2f}_area-{area}"]) |
| | | self.run_command(vector_command, SCAN_JOB_TIMEOUT) |
| | | metadata = { |
| | | "capability": "00-change-detection", |
| | | "kind": "parameter-scan", |
| | | "source_run": run_id, |
| | | "created_at": datetime.now(UTC).isoformat(), |
| | | "thresholds": thresholds, |
| | | "minimum_areas": areas, |
| | | "processing_mode": processing_mode, |
| | | "max_dimension": max_dimension, |
| | | "raw_input_dir": relative_path(self.root, before_path.parent.parent), |
| | | "processed_input_dir": relative_path(self.root, processed_root), |
| | | } |
| | | scan_output.mkdir(parents=True, exist_ok=True) |
| | | (scan_output / "scan_metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | self._update_scan_job(run_id, status="completed", phase="done", scanId=run_id) |
| | | except Exception as exc: # background errors are returned through polling |
| | | scan_output.mkdir(parents=True, exist_ok=True) |
| | | (scan_output / "scan_failed.json").write_text(json.dumps({"job_id": run_id, "error": str(exc)[:600]}, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | self._update_scan_job(run_id, status="failed", phase="error", error=str(exc)[:600]) |
| | | |
| | | 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) |