| | |
| | | import json |
| | | import os |
| | | import re |
| | | import shutil |
| | | import subprocess |
| | | import threading |
| | | from datetime import UTC, datetime |
| | |
| | | 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", |
| | |
| | | "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() |
| | | |
| | | |
| | |
| | | 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") |
| | |
| | | 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") |
| | |
| | | |
| | | 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" |
| | |
| | | "--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), |
| | | ], |