From d857b98578eb377da7cc36f653455fc5f916a9cd Mon Sep 17 00:00:00 2001
From: shuishen <1109946754@qq.com>
Date: Tue, 18 Aug 2026 17:25:06 +0800
Subject: [PATCH] feat:变化检测优化

---
 scripts/serve_workbench_console.py |  122 ++++++++++++++++++++++++++++++++++++----
 1 files changed, 110 insertions(+), 12 deletions(-)

diff --git a/scripts/serve_workbench_console.py b/scripts/serve_workbench_console.py
index 9063f7e..e6fd1b1 100644
--- a/scripts/serve_workbench_console.py
+++ b/scripts/serve_workbench_console.py
@@ -8,6 +8,7 @@
 import json
 import os
 import re
+import shutil
 import subprocess
 import threading
 from datetime import UTC, datetime
@@ -15,20 +16,28 @@
 from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
 from pathlib import Path, PurePosixPath
 from typing import Any
-from urllib.parse import unquote, urlsplit
+from urllib.parse import parse_qs, unquote, urlsplit
 from uuid import uuid4
 
 
 DEFAULT_HOST = "127.0.0.1"
 DEFAULT_PORT = 6173
-MAX_REQUEST_BYTES = 128 * 1024 * 1024
-MAX_FILE_BYTES = 96 * 1024 * 1024
+# Uploads are sent as Base64 JSON. Keep the request limit above two 1 GiB
+# files after encoding while retaining a per-file bound for local experiments.
+MAX_REQUEST_BYTES = 3072 * 1024 * 1024
+MAX_FILE_BYTES = 1024 * 1024 * 1024
 MAX_IMAGES_PER_RUN = 12
 MAX_SEGMENTATION_IMAGES_PER_RUN = 6
 MAX_MEASUREMENT_RASTERS_PER_RUN = 4
 CHANGE_THRESHOLD_DEFAULT = 0.5
 CHANGE_THRESHOLD_MIN = 0.01
 CHANGE_THRESHOLD_MAX = 0.99
+CHANGE_MAX_DIMENSION_DEFAULT = 1024
+CHANGE_MAX_DIMENSION_AUTO = 0
+CHANGE_MAX_DIMENSION_MIN = 512
+CHANGE_MAX_DIMENSION_MAX = 4096
+CHANGE_PROCESSING_MODE_DEFAULT = "auto"
+CHANGE_PROCESSING_MODES = {"auto", "image", "geotiff"}
 ALLOWED_PATH_PREFIXES = (
     "apps/workbench-console",
     "shared/outputs",
@@ -37,6 +46,7 @@
     "shared/data/raw/02-semantic-mapping",
 )
 SAFE_FILE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
+SAFE_UPLOAD_ID = re.compile(r"^[0-9a-f]{32}$")
 RUN_LOCK = threading.Lock()
 
 
@@ -295,6 +305,19 @@
             self.log_error("local run failed: %s", exc)
             self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Local run failed. Check the console terminal for details."})
 
+    def do_PUT(self) -> None:  # noqa: N802 - binary upload endpoint
+        path = urlsplit(self.path).path
+        if not path.startswith("/api/change-detection/uploads/"):
+            self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown local API endpoint."})
+            return
+        try:
+            self.send_json(HTTPStatus.CREATED, self.receive_change_upload(path))
+        except ApiError as exc:
+            self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+        except Exception as exc:  # pragma: no cover - defensive server boundary
+            self.log_error("binary upload failed: %s", exc)
+            self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Binary upload failed. Check the console terminal for details."})
+
     def do_OPTIONS(self) -> None:  # noqa: N802
         self.send_response(HTTPStatus.NO_CONTENT)
         self.send_header("Allow", "GET, POST, OPTIONS")
@@ -322,6 +345,57 @@
         if completed.returncode:
             message = (completed.stderr or completed.stdout or "Unknown script error.").strip().splitlines()[-1]
             raise ApiError(f"Processing failed: {message[:600]}")
+
+    def receive_change_upload(self, path: str) -> dict[str, Any]:
+        upload_id = path.rstrip("/").rsplit("/", 1)[-1]
+        if not SAFE_UPLOAD_ID.fullmatch(upload_id):
+            raise ApiError("Invalid change-detection upload id.")
+        query = parse_qs(urlsplit(self.path).query)
+        role = query.get("role", [""])[0]
+        if role not in {"before", "after"}:
+            raise ApiError("Change-detection upload role must be before or after.")
+        name = self.headers.get("X-Upload-Name", "")
+        safe_name = safe_file_name(name, {".jpg", ".jpeg", ".png", ".tif", ".tiff"})
+        content_length = self.headers.get("Content-Length")
+        if content_length is None or not content_length.isdigit():
+            raise ApiError("Binary upload requires a Content-Length header.")
+        size = int(content_length)
+        if size <= 0 or size > MAX_FILE_BYTES:
+            raise ApiError(f"Uploaded file must be between 1 byte and {MAX_FILE_BYTES // (1024 * 1024)} MB: {safe_name}.")
+        staging = self.root / "shared" / "data" / "raw" / "00-change-detection" / "uploads" / upload_id
+        staging.mkdir(parents=True, exist_ok=False)
+        part = staging / f"{role}.part"
+        target = staging / f"{role}{Path(safe_name).suffix.lower()}"
+        remaining = size
+        try:
+            with part.open("wb") as stream:
+                while remaining:
+                    chunk = self.rfile.read(min(8 * 1024 * 1024, remaining))
+                    if not chunk:
+                        raise ApiError("Binary upload ended before Content-Length was reached.")
+                    stream.write(chunk)
+                    remaining -= len(chunk)
+            part.replace(target)
+            (staging / f"{role}.json").write_text(json.dumps({"role": role, "name": safe_name, "size": size}), encoding="utf-8")
+        except Exception:
+            part.unlink(missing_ok=True)
+            target.unlink(missing_ok=True)
+            raise
+        return {"uploadId": upload_id, "role": role, "name": safe_name, "size": size}
+
+    def resolve_change_upload(self, payload: Any, role: str) -> tuple[str, Path]:
+        if not isinstance(payload, dict) or not isinstance(payload.get("uploadId"), str):
+            raise ApiError(f"Change-detection uploads must include a {role} uploadId.")
+        upload_id = payload["uploadId"]
+        if not SAFE_UPLOAD_ID.fullmatch(upload_id):
+            raise ApiError("Invalid change-detection upload id.")
+        staging = self.root / "shared" / "data" / "raw" / "00-change-detection" / "uploads" / upload_id
+        manifest = load_json(staging / f"{role}.json")
+        name = str(manifest.get("name") or "")
+        path = staging / f"{role}{Path(name).suffix.lower()}"
+        if manifest.get("role") != role or not name or not path.is_file():
+            raise ApiError(f"The staged {role} TIFF upload is unavailable or incomplete.")
+        return name, path
 
     def create_trajectory_run(self, payload: dict[str, Any]) -> dict[str, Any]:
         files = payload.get("files")
@@ -382,25 +456,47 @@
 
     def create_change_run(self, payload: dict[str, Any]) -> dict[str, Any]:
         files = payload.get("files")
-        if not isinstance(files, dict):
-            raise ApiError("Change-detection request must contain before and after files.")
+        uploads = payload.get("uploads")
+        staged: dict[str, tuple[str, Path]] = {}
+        if isinstance(uploads, dict):
+            staged["before"] = self.resolve_change_upload(uploads.get("before"), "before")
+            staged["after"] = self.resolve_change_upload(uploads.get("after"), "after")
+        elif isinstance(files, dict):
+            decoded_before = decode_upload(files.get("before"), {".jpg", ".jpeg", ".png", ".tif", ".tiff"})
+            decoded_after = decode_upload(files.get("after"), {".jpg", ".jpeg", ".png", ".tif", ".tiff"})
+        else:
+            raise ApiError("Change-detection request must contain before and after files or uploads.")
         threshold_value = payload.get("threshold", CHANGE_THRESHOLD_DEFAULT)
         if isinstance(threshold_value, bool) or not isinstance(threshold_value, (int, float)):
             raise ApiError("Change-detection threshold must be a number between 0.01 and 0.99.")
         threshold = float(threshold_value)
         if not CHANGE_THRESHOLD_MIN <= threshold <= CHANGE_THRESHOLD_MAX:
             raise ApiError("Change-detection threshold must be between 0.01 and 0.99.")
-        suffixes = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
-        before = decode_upload(files.get("before"), suffixes)
-        after = decode_upload(files.get("after"), suffixes)
+        processing_mode = payload.get("processingMode", CHANGE_PROCESSING_MODE_DEFAULT)
+        if not isinstance(processing_mode, str) or processing_mode not in CHANGE_PROCESSING_MODES:
+            raise ApiError("Change-detection processing mode must be auto, image, or geotiff.")
+        max_dimension_value = payload.get("maxDimension", CHANGE_MAX_DIMENSION_AUTO)
+        if isinstance(max_dimension_value, bool) or not isinstance(max_dimension_value, int):
+            raise ApiError("Change-detection resolution must be an integer: 0 or between 512 and 4096.")
+        max_dimension = int(max_dimension_value)
+        if max_dimension != CHANGE_MAX_DIMENSION_AUTO and not CHANGE_MAX_DIMENSION_MIN <= max_dimension <= CHANGE_MAX_DIMENSION_MAX:
+            raise ApiError("Change-detection resolution must be 0 or between 512 and 4096.")
+        if staged:
+            before_name, after_name = staged["before"][0], staged["after"][0]
+        else:
+            before_name, after_name = decoded_before[0], decoded_after[0]
         run_id = make_run_id("change")
         raw_root = self.root / "shared" / "data" / "raw" / "00-change-detection" / "runs" / run_id
-        before_path = raw_root / "before" / before[0]
-        after_path = raw_root / "after" / after[0]
+        before_path = raw_root / "before" / before_name
+        after_path = raw_root / "after" / after_name
         before_path.parent.mkdir(parents=True, exist_ok=False)
         after_path.parent.mkdir(parents=True, exist_ok=False)
-        before_path.write_bytes(before[1])
-        after_path.write_bytes(after[1])
+        if staged:
+            shutil.copyfile(staged["before"][1], before_path)
+            shutil.copyfile(staged["after"][1], after_path)
+        else:
+            before_path.write_bytes(decoded_before[1])
+            after_path.write_bytes(decoded_after[1])
         processed_root = self.root / "shared" / "data" / "processed" / "00-change-detection" / run_id
         output = self.root / "shared" / "outputs" / "00-change-detection" / "runs" / run_id
         python = self.root / ".venvs" / "00-change-detection" / "Scripts" / "python.exe"
@@ -414,6 +510,8 @@
                     "--before", str(before_path),
                     "--after", str(after_path),
                     "--threshold", f"{threshold:.4f}",
+                    "--max-dimension", str(max_dimension),
+                    "--processing-mode", processing_mode,
                     "--processed-output", str(processed_root),
                     "--output", str(output),
                 ],

--
Gitblit v1.9.3