| | |
| | | MAX_FILE_BYTES = 96 * 1024 * 1024 |
| | | MAX_IMAGES_PER_RUN = 12 |
| | | MAX_SEGMENTATION_IMAGES_PER_RUN = 6 |
| | | MAX_MEASUREMENT_RASTERS_PER_RUN = 4 |
| | | CHANGE_THRESHOLD_DEFAULT = 0.5 |
| | | CHANGE_THRESHOLD_MIN = 0.01 |
| | | CHANGE_THRESHOLD_MAX = 0.99 |
| | | ALLOWED_PATH_PREFIXES = ( |
| | | "apps/workbench-console", |
| | | "shared/outputs", |
| | | "shared/data/raw/00-change-detection", |
| | | "shared/data/raw/01-object-detection", |
| | | "shared/data/raw/02-semantic-mapping", |
| | | ) |
| | |
| | | return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True) |
| | | |
| | | |
| | | def change_runs(root: Path) -> list[dict[str, Any]]: |
| | | output_root = root / "shared" / "outputs" / "00-change-detection" |
| | | records: list[dict[str, Any]] = [] |
| | | for metadata_path in output_root.rglob("run_metadata.json"): |
| | | artifact = metadata_path.parent |
| | | metadata = load_json(metadata_path) |
| | | artifacts = metadata.get("artifacts") |
| | | if metadata.get("capability") != "00-change-detection" or metadata.get("schema_version") != 1 or not isinstance(artifacts, dict): |
| | | continue |
| | | if not (artifact / str(artifacts.get("overlay") or "")).is_file() or not (artifact / str(artifacts.get("vector") or "")).is_file(): |
| | | continue |
| | | raw_root_value = str(metadata.get("raw_input_dir") or "shared/data/raw/00-change-detection/validation-20260817") |
| | | raw_root = root / Path(raw_root_value) |
| | | input_files = metadata.get("input_files") |
| | | if not isinstance(input_files, list) or len(input_files) != 2: |
| | | continue |
| | | before_value = str(metadata.get("raw_before") or (Path(raw_root_value) / str(input_files[0])).as_posix()) |
| | | after_value = str(metadata.get("raw_after") or (Path(raw_root_value) / str(input_files[1])).as_posix()) |
| | | try: |
| | | before_path = (root / before_value).resolve() |
| | | after_path = (root / after_value).resolve() |
| | | allowed_raw = (root / "shared" / "data" / "raw" / "00-change-detection").resolve() |
| | | before_path.relative_to(allowed_raw) |
| | | after_path.relative_to(allowed_raw) |
| | | except ValueError: |
| | | continue |
| | | if not before_path.is_file() or not after_path.is_file(): |
| | | continue |
| | | run_id = artifact.name |
| | | records.append( |
| | | { |
| | | "id": run_id, |
| | | "label": run_id, |
| | | "note": "ChangeStar CPU 变化栅格与 GeoAI 像素坐标图斑;结果需人工复核。", |
| | | "artifactRoot": relative_path(root, artifact), |
| | | "beforeImage": relative_path(root, before_path), |
| | | "afterImage": relative_path(root, after_path), |
| | | "createdAt": str(metadata.get("created_at") or ""), |
| | | } |
| | | ) |
| | | return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True) |
| | | |
| | | |
| | | def semantic_runs(root: Path) -> list[dict[str, Any]]: |
| | | output_root = root / "shared" / "outputs" / "02-semantic-mapping" |
| | | records: list[dict[str, Any]] = [] |
| | |
| | | "artifactRoot": relative_path(root, artifact), |
| | | "inputRoot": str(metadata.get("input_dir") or "shared/data/processed/02-semantic-mapping"), |
| | | "rawInputRoot": str(metadata.get("raw_input_dir") or "shared/data/raw/02-semantic-mapping"), |
| | | "createdAt": str(metadata.get("created_at") or ""), |
| | | } |
| | | ) |
| | | return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True) |
| | | |
| | | |
| | | def measurement_runs(root: Path) -> list[dict[str, Any]]: |
| | | output_root = root / "shared" / "outputs" / "04-spatial-measurement" |
| | | records: list[dict[str, Any]] = [] |
| | | for metadata_path in output_root.rglob("run_metadata.json"): |
| | | artifact = metadata_path.parent |
| | | metadata = load_json(metadata_path) |
| | | if metadata.get("capability") != "04-spatial-measurement" or not isinstance(metadata.get("images"), list): |
| | | continue |
| | | run_id = artifact.name |
| | | records.append( |
| | | { |
| | | "id": run_id, |
| | | "label": run_id, |
| | | "note": "GeoAI 栅格转矢量后进行对象计数、面积和周长测量。", |
| | | "artifactRoot": relative_path(root, artifact), |
| | | "createdAt": str(metadata.get("created_at") or ""), |
| | | } |
| | | ) |
| | |
| | | |
| | | def do_GET(self) -> None: # noqa: N802 - inherited standard-library method name |
| | | path = urlsplit(self.path).path |
| | | if path == "/api/change-detection/runs": |
| | | self.send_json(HTTPStatus.OK, {"runs": change_runs(self.root)}) |
| | | return |
| | | if path == "/api/trajectory/runs": |
| | | self.send_json(HTTPStatus.OK, {"runs": trajectory_runs(self.root)}) |
| | | return |
| | |
| | | if path == "/api/semantic-mapping/tasks": |
| | | self.send_json(HTTPStatus.OK, {"tasks": semantic_tasks(self.root)}) |
| | | return |
| | | if path == "/api/spatial-measurement/runs": |
| | | self.send_json(HTTPStatus.OK, {"runs": measurement_runs(self.root)}) |
| | | return |
| | | if path == "/": |
| | | self.send_response(HTTPStatus.FOUND) |
| | | self.send_header("Location", "/apps/workbench-console/") |
| | |
| | | path = urlsplit(self.path).path |
| | | try: |
| | | payload = self.read_json_body() |
| | | if path == "/api/change-detection/runs": |
| | | self.send_json(HTTPStatus.CREATED, {"run": self.create_change_run(payload)}) |
| | | return |
| | | if path == "/api/trajectory/runs": |
| | | self.send_json(HTTPStatus.CREATED, {"run": self.create_trajectory_run(payload)}) |
| | | return |
| | |
| | | return |
| | | if path == "/api/semantic-mapping/runs": |
| | | self.send_json(HTTPStatus.CREATED, {"run": self.create_semantic_run(payload)}) |
| | | return |
| | | if path == "/api/spatial-measurement/runs": |
| | | self.send_json(HTTPStatus.CREATED, {"run": self.create_measurement_run(payload)}) |
| | | return |
| | | self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown local API endpoint."}) |
| | | except ApiError as exc: |
| | |
| | | raise ApiError("Detection script finished without the expected result metadata.") |
| | | return next(item for item in detection_runs(self.root) if item["id"] == run_id) |
| | | |
| | | def create_change_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | files = payload.get("files") |
| | | if not isinstance(files, dict): |
| | | raise ApiError("Change-detection request must contain before and after files.") |
| | | threshold_value = payload.get("threshold", CHANGE_THRESHOLD_DEFAULT) |
| | | if isinstance(threshold_value, bool) or not isinstance(threshold_value, (int, float)): |
| | | raise ApiError("Change-detection threshold must be a number between 0.01 and 0.99.") |
| | | threshold = float(threshold_value) |
| | | if not CHANGE_THRESHOLD_MIN <= threshold <= CHANGE_THRESHOLD_MAX: |
| | | raise ApiError("Change-detection threshold must be between 0.01 and 0.99.") |
| | | suffixes = {".jpg", ".jpeg", ".png", ".tif", ".tiff"} |
| | | before = decode_upload(files.get("before"), suffixes) |
| | | after = decode_upload(files.get("after"), suffixes) |
| | | run_id = make_run_id("change") |
| | | raw_root = self.root / "shared" / "data" / "raw" / "00-change-detection" / "runs" / run_id |
| | | before_path = raw_root / "before" / before[0] |
| | | after_path = raw_root / "after" / after[0] |
| | | before_path.parent.mkdir(parents=True, exist_ok=False) |
| | | after_path.parent.mkdir(parents=True, exist_ok=False) |
| | | before_path.write_bytes(before[1]) |
| | | after_path.write_bytes(after[1]) |
| | | processed_root = self.root / "shared" / "data" / "processed" / "00-change-detection" / run_id |
| | | output = self.root / "shared" / "outputs" / "00-change-detection" / "runs" / run_id |
| | | python = self.root / ".venvs" / "00-change-detection" / "Scripts" / "python.exe" |
| | | if not python.is_file(): |
| | | raise ApiError("Change-detection virtual environment is unavailable. Run the capability setup first.") |
| | | with RUN_LOCK: |
| | | self.run_command( |
| | | [ |
| | | str(python), |
| | | str(self.root / "capabilities" / "00-change-detection" / "run_change_detection.py"), |
| | | "--before", str(before_path), |
| | | "--after", str(after_path), |
| | | "--threshold", f"{threshold:.4f}", |
| | | "--processed-output", str(processed_root), |
| | | "--output", str(output), |
| | | ], |
| | | 1200, |
| | | ) |
| | | metadata_path = output / "run_metadata.json" |
| | | if not metadata_path.is_file(): |
| | | raise ApiError("Change-detection script finished without the expected result metadata.") |
| | | metadata = load_json(metadata_path) |
| | | metadata["raw_input_dir"] = relative_path(self.root, raw_root) |
| | | metadata["processed_input_dir"] = relative_path(self.root, processed_root) |
| | | metadata["raw_before"] = relative_path(self.root, before_path) |
| | | metadata["raw_after"] = relative_path(self.root, after_path) |
| | | metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return next(item for item in change_runs(self.root) if item["id"] == run_id) |
| | | |
| | | def create_semantic_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | task_id = str(payload.get("taskId") or "color_baseline") |
| | | task = next((item for item in semantic_tasks(self.root) if item["id"] == task_id), None) |
| | |
| | | metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return next(item for item in semantic_runs(self.root) if item["id"] == run_id) |
| | | |
| | | def create_measurement_run(self, payload: dict[str, Any]) -> dict[str, Any]: |
| | | uploads = payload.get("rasters") |
| | | if not isinstance(uploads, list) or not uploads: |
| | | raise ApiError("Spatial-measurement request must include at least one label raster.") |
| | | if len(uploads) > MAX_MEASUREMENT_RASTERS_PER_RUN: |
| | | raise ApiError(f"A spatial-measurement run accepts at most {MAX_MEASUREMENT_RASTERS_PER_RUN} rasters.") |
| | | decoded = [decode_upload(item, {".png", ".tif", ".tiff"}) for item in uploads] |
| | | if len({name.casefold() for name, _ in decoded}) != len(decoded): |
| | | raise ApiError("Uploaded raster names must be unique within one run.") |
| | | run_id = make_run_id("measurement") |
| | | raw_root = self.root / "shared" / "data" / "raw" / "04-spatial-measurement" / "runs" / run_id |
| | | processed_root = self.root / "shared" / "data" / "processed" / "04-spatial-measurement" / run_id |
| | | raw_root.mkdir(parents=True, exist_ok=False) |
| | | processed_root.mkdir(parents=True, exist_ok=False) |
| | | for name, content in decoded: |
| | | (raw_root / name).write_bytes(content) |
| | | (processed_root / name).write_bytes(content) |
| | | output = self.root / "shared" / "outputs" / "04-spatial-measurement" / "runs" / run_id |
| | | python = self.root / ".venvs" / "04-spatial-measurement" / "Scripts" / "python.exe" |
| | | if not python.is_file(): |
| | | raise ApiError("Spatial-measurement virtual environment is unavailable. Run the capability setup first.") |
| | | with RUN_LOCK: |
| | | self.run_command([str(python), str(self.root / "capabilities" / "04-spatial-measurement" / "run_spatial_measurement.py"), "--input", str(processed_root), "--output", str(output)], 900) |
| | | metadata_path = output / "run_metadata.json" |
| | | if not metadata_path.is_file(): |
| | | raise ApiError("Spatial-measurement script finished without the expected result metadata.") |
| | | metadata = load_json(metadata_path) |
| | | metadata["input_dir"] = relative_path(self.root, processed_root) |
| | | metadata["raw_input_dir"] = relative_path(self.root, raw_root) |
| | | metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") |
| | | return next(item for item in measurement_runs(self.root) if item["id"] == run_id) |
| | | |
| | | def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None: |
| | | body = json.dumps(payload, ensure_ascii=False).encode("utf-8") |
| | | self.send_response(status) |