From 385be2eca72eb3833efa4be0a0088b34e764788a Mon Sep 17 00:00:00 2001
From: shuishen <1109946754@qq.com>
Date: Mon, 31 Aug 2026 09:04:45 +0800
Subject: [PATCH] feat(pointcloud): complete annotation and result lifecycle workflows
---
scripts/serve_workbench_console.py | 1262 +++++++++++++++++++++++++++++++++++++++++++++++++++++----
1 files changed, 1,170 insertions(+), 92 deletions(-)
diff --git a/scripts/serve_workbench_console.py b/scripts/serve_workbench_console.py
index 6a97f99..a7fd5e8 100644
--- a/scripts/serve_workbench_console.py
+++ b/scripts/serve_workbench_console.py
@@ -3,15 +3,18 @@
from __future__ import annotations
import argparse
+import ast
import base64
import binascii
import hashlib
import json
+import math
import os
import re
import shutil
import subprocess
import threading
+import zipfile
from datetime import UTC, datetime
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
@@ -31,8 +34,8 @@
MAX_SEGMENTATION_IMAGES_PER_RUN = 6
MAX_MEASUREMENT_RASTERS_PER_RUN = 4
MAX_POINTCLOUDS_PER_RUN = 2
-MAX_PHOTO_RECONSTRUCTION_IMAGES_PER_RUN = 30
-PHOTO_RECONSTRUCTION_TIMEOUT = 7200
+MAX_PHOTO_RECONSTRUCTION_IMAGES_PER_RUN = 1_000
+PHOTO_RECONSTRUCTION_TIMEOUT = 86_400
RISK_RULE_REQUIRED_FILES = {"observations", "zones", "rules"}
MAX_ANOMALY_IMAGES_PER_ROLE = 6
CHANGE_THRESHOLD_DEFAULT = 0.5
@@ -74,14 +77,27 @@
POINTCLOUD_TRAINING_JOBS_LOCK = threading.Lock()
POINTCLOUD_INFERENCE_JOBS: dict[str, dict[str, Any]] = {}
POINTCLOUD_INFERENCE_JOBS_LOCK = threading.Lock()
-MAX_ANNOTATION_LABELS = 400_000
-POINTCLOUD_CLASS_CODES = {1, 2, 5, 6, 15, 16}
+POINTCLOUD_ANNOTATION_SOURCE_JOBS: dict[str, dict[str, Any]] = {}
+POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK = threading.Lock()
+POINTCLOUD_DETAIL_REQUEST_LOCK = threading.Lock()
+MAX_POINTCLOUD_DETAIL_RADIUS = 10_000_000.0
+DEFAULT_POINTCLOUD_ANNOTATION_CLASSES = (
+ {"code": 1, "key": "other_unknown", "label": "其他/未知", "color": [128, 128, 128], "builtIn": True},
+ {"code": 2, "key": "ground", "label": "地面", "color": [151, 111, 51], "builtIn": True},
+ {"code": 5, "key": "vegetation", "label": "植被", "color": [59, 163, 87], "builtIn": True},
+ {"code": 6, "key": "building_structure", "label": "建筑物", "color": [224, 115, 55], "builtIn": True},
+ {"code": 15, "key": "pole_tower", "label": "杆塔", "color": [149, 89, 210], "builtIn": True},
+ {"code": 16, "key": "power_line", "label": "电线", "color": [231, 196, 61], "builtIn": True},
+)
+POINTCLOUD_CLASS_CODES = {item["code"] for item in DEFAULT_POINTCLOUD_ANNOTATION_CLASSES}
+ANNOTATION_CLASS_KEY = re.compile(r"^[a-z][a-z0-9_]{0,47}$")
POINTCLOUD_CPU_ENVIRONMENT = "05-3d-pointcloud"
POINTCLOUD_GPU_ENVIRONMENT = "05-3d-pointcloud-gpu"
OBJECT_DETECTION_CPU_ENVIRONMENT = "01-object-detection"
OBJECT_DETECTION_GPU_ENVIRONMENT = "01-object-detection-cuda"
CHANGE_DETECTION_CPU_ENVIRONMENT = "00-change-detection"
CHANGE_DETECTION_GPU_ENVIRONMENT = "00-change-detection-cuda"
+COMPUTE_DEVICES = {"auto", "cpu", "cuda"}
class ApiError(ValueError):
@@ -117,7 +133,9 @@
def relative_path(root: Path, path: Path) -> str:
- return path.relative_to(root).as_posix()
+ # Windows may present a temporary root with its 8.3 spelling while an
+ # input directory has already been resolved to its long spelling.
+ return path.resolve().relative_to(root.resolve()).as_posix()
def load_json(path: Path) -> dict[str, Any]:
@@ -136,18 +154,19 @@
return digest.hexdigest()
-def pointcloud_execution_environment(root: Path, requested_device: str = "auto") -> dict[str, str]:
- """Select only a fixed point-cloud interpreter after a short CUDA probe.
+def select_execution_environment(root: Path, requested_device: str, cpu_environment: str, gpu_environment: str, label: str) -> dict[str, Any]:
+ """Choose an allowlisted CPU/CUDA interpreter and retain the selection evidence."""
+ if requested_device not in COMPUTE_DEVICES:
+ raise ApiError(f"{label} device must be auto, cpu, or cuda.")
+ cpu_python = root / ".venvs" / cpu_environment / "Scripts" / "python.exe"
+ gpu_python = root / ".venvs" / gpu_environment / "Scripts" / "python.exe"
+ if requested_device == "cpu":
+ if not cpu_python.is_file():
+ raise ApiError(f"{label} CPU virtual environment is unavailable. Run the capability setup first.")
+ return {"python": str(cpu_python), "device": "cpu", "environment": cpu_environment, "torchVersion": "unknown", "requestedDevice": "cpu", "fallbackUsed": False, "fallbackReason": None}
- The console never accepts a browser-supplied Python path. A failed or
- unavailable GPU environment is an expected condition for ``auto`` and
- falls back to the retained CPU environment.
- """
- if requested_device not in {"auto", "cpu", "cuda"}:
- raise ApiError("Point-cloud device must be auto, cpu, or cuda.")
- cpu_python = root / ".venvs" / POINTCLOUD_CPU_ENVIRONMENT / "Scripts" / "python.exe"
- gpu_python = root / ".venvs" / POINTCLOUD_GPU_ENVIRONMENT / "Scripts" / "python.exe"
- if requested_device != "cpu" and gpu_python.is_file():
+ probe_reason = "Fixed CUDA virtual environment is unavailable."
+ if gpu_python.is_file():
try:
probe = subprocess.run(
[str(gpu_python), "-c", "import json, torch; print(json.dumps({'cuda': bool(torch.cuda.is_available()), 'torch': torch.__version__}))"],
@@ -157,64 +176,48 @@
timeout=20,
check=False,
)
- payload = json.loads(probe.stdout.strip().splitlines()[-1]) if probe.returncode == 0 and probe.stdout.strip() else {}
- if payload.get("cuda") is True and isinstance(payload.get("torch"), str):
- return {"python": str(gpu_python), "device": "cuda", "environment": POINTCLOUD_GPU_ENVIRONMENT, "torchVersion": payload["torch"]}
+ if probe.returncode == 0 and probe.stdout.strip():
+ payload = json.loads(probe.stdout.strip().splitlines()[-1])
+ if payload.get("cuda") is True and isinstance(payload.get("torch"), str):
+ return {"python": str(gpu_python), "device": "cuda", "environment": gpu_environment, "torchVersion": payload["torch"], "requestedDevice": requested_device, "fallbackUsed": False, "fallbackReason": None}
+ probe_reason = "CUDA probe reports that PyTorch cannot use CUDA."
+ else:
+ probe_reason = "CUDA probe process did not complete successfully."
except (OSError, subprocess.SubprocessError, json.JSONDecodeError, IndexError):
- pass
+ probe_reason = "CUDA probe could not return a valid result."
if requested_device == "cuda":
- raise ApiError("CUDA was requested, but the fixed point-cloud GPU environment is unavailable.")
+ raise ApiError(f"CUDA was requested for {label}, but it is unavailable: {probe_reason}")
if not cpu_python.is_file():
- raise ApiError("3D point-cloud CPU virtual environment is unavailable. Run the capability setup first.")
- return {"python": str(cpu_python), "device": "cpu", "environment": POINTCLOUD_CPU_ENVIRONMENT, "torchVersion": "unknown"}
+ raise ApiError(f"{label} CPU virtual environment is unavailable. Run the capability setup first.")
+ return {"python": str(cpu_python), "device": "cpu", "environment": cpu_environment, "torchVersion": "unknown", "requestedDevice": "auto", "fallbackUsed": True, "fallbackReason": probe_reason}
-def object_detection_execution_environment(root: Path) -> dict[str, str]:
- """Choose the fixed object-detection CUDA environment only after probing it."""
- cpu_python = root / ".venvs" / OBJECT_DETECTION_CPU_ENVIRONMENT / "Scripts" / "python.exe"
- gpu_python = root / ".venvs" / OBJECT_DETECTION_GPU_ENVIRONMENT / "Scripts" / "python.exe"
- if gpu_python.is_file():
- try:
- probe = subprocess.run(
- [str(gpu_python), "-c", "import json, torch; print(json.dumps({'cuda': bool(torch.cuda.is_available()), 'torch': torch.__version__}))"],
- cwd=root,
- capture_output=True,
- text=True,
- timeout=20,
- check=False,
- )
- payload = json.loads(probe.stdout.strip().splitlines()[-1]) if probe.returncode == 0 and probe.stdout.strip() else {}
- if payload.get("cuda") is True and isinstance(payload.get("torch"), str):
- return {"python": str(gpu_python), "device": "cuda", "environment": OBJECT_DETECTION_GPU_ENVIRONMENT, "torchVersion": payload["torch"]}
- except (OSError, subprocess.SubprocessError, json.JSONDecodeError, IndexError):
- pass
- if not cpu_python.is_file():
- raise ApiError("Object-detection CPU virtual environment is unavailable. Run the capability setup first.")
- return {"python": str(cpu_python), "device": "cpu", "environment": OBJECT_DETECTION_CPU_ENVIRONMENT, "torchVersion": "unknown"}
+def pointcloud_execution_environment(root: Path, requested_device: str = "auto") -> dict[str, Any]:
+ return select_execution_environment(root, requested_device, POINTCLOUD_CPU_ENVIRONMENT, POINTCLOUD_GPU_ENVIRONMENT, "Point-cloud")
-def change_detection_execution_environment(root: Path) -> dict[str, str]:
- """Choose the fixed ChangeStar CUDA environment only after probing it."""
- cpu_python = root / ".venvs" / CHANGE_DETECTION_CPU_ENVIRONMENT / "Scripts" / "python.exe"
- gpu_python = root / ".venvs" / CHANGE_DETECTION_GPU_ENVIRONMENT / "Scripts" / "python.exe"
- if gpu_python.is_file():
- try:
- probe = subprocess.run(
- [str(gpu_python), "-c", "import json, torch; print(json.dumps({'cuda': bool(torch.cuda.is_available()), 'torch': torch.__version__}))"],
- cwd=root,
- capture_output=True,
- text=True,
- timeout=20,
- check=False,
- )
- payload = json.loads(probe.stdout.strip().splitlines()[-1]) if probe.returncode == 0 and probe.stdout.strip() else {}
- if payload.get("cuda") is True and isinstance(payload.get("torch"), str):
- return {"python": str(gpu_python), "device": "cuda", "environment": CHANGE_DETECTION_GPU_ENVIRONMENT, "torchVersion": payload["torch"]}
- except (OSError, subprocess.SubprocessError, json.JSONDecodeError, IndexError):
- pass
- if not cpu_python.is_file():
- raise ApiError("Change-detection CPU virtual environment is unavailable. Run the capability setup first.")
- return {"python": str(cpu_python), "device": "cpu", "environment": CHANGE_DETECTION_CPU_ENVIRONMENT, "torchVersion": "unknown"}
+def object_detection_execution_environment(root: Path, requested_device: str = "auto") -> dict[str, Any]:
+ return select_execution_environment(root, requested_device, OBJECT_DETECTION_CPU_ENVIRONMENT, OBJECT_DETECTION_GPU_ENVIRONMENT, "Object-detection")
+
+
+def change_detection_execution_environment(root: Path, requested_device: str = "auto") -> dict[str, Any]:
+ return select_execution_environment(root, requested_device, CHANGE_DETECTION_CPU_ENVIRONMENT, CHANGE_DETECTION_GPU_ENVIRONMENT, "Change-detection")
+
+
+def record_execution_metadata(path: Path, execution: dict[str, Any]) -> None:
+ """Preserve requested device, actual device and an automatic CPU fallback reason."""
+ metadata = load_json(path)
+ metadata["requested_device"] = execution["requestedDevice"]
+ metadata["device"] = execution["device"]
+ metadata["execution"] = {
+ "requested_device": execution["requestedDevice"],
+ "actual_device": execution["device"],
+ "environment": execution["environment"],
+ "torch_version": execution["torchVersion"],
+ "fallback_used": execution["fallbackUsed"],
+ "fallback_reason": execution["fallbackReason"],
+ }
+ path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
def trajectory_runs(root: Path) -> list[dict[str, Any]]:
@@ -511,8 +514,262 @@
"artifactRoot": case["artifactRoot"], "file": name, "url": f"/{case['artifactRoot']}/{name}",
"sha256": file_sha256(path), "pointCount": int(cloud.get("semantic_preview_points") or 0),
"sourceKind": str(cloud.get("semantic_annotation_source_kind") or "generated point-cloud preview"),
+ "detailAvailable": False, "detailFile": None,
})
+ sources.extend(standalone_pointcloud_annotation_sources(root))
+ sources.extend(multiview_pointcloud_annotation_sources(root))
return sources
+
+
+def standalone_pointcloud_annotation_sources(root: Path) -> list[dict[str, Any]]:
+ """Discover preview-only annotation uploads without pretending they are geometry runs."""
+ output_root = root / "shared" / "outputs" / "05-3d-pointcloud"
+ records: list[dict[str, Any]] = []
+ metadata_paths = [
+ *output_root.glob("runs/annotation-source-*/run_metadata.json"),
+ *output_root.glob("texture-baked-*/run_metadata.json"),
+ ]
+ for metadata_path in metadata_paths:
+ artifact = metadata_path.parent
+ metadata = load_json(metadata_path)
+ contract = metadata.get("annotation_source")
+ if metadata.get("capability") != "05-3d-pointcloud" or not metadata.get("annotation_source_job") or not isinstance(contract, dict):
+ continue
+ name, count, checksum = contract.get("file"), contract.get("point_count"), contract.get("sha256")
+ if not isinstance(name, str) or Path(name).name != name or not isinstance(count, int) or count < 1 or not isinstance(checksum, str):
+ continue
+ path = artifact / name
+ if not path.is_file() or path.suffix.lower() != ".ply" or file_sha256(path) != checksum or ply_vertex_count(path) != count:
+ continue
+ input_data = metadata.get("input") if isinstance(metadata.get("input"), dict) else {}
+ input_name = str(input_data.get("file") or name)
+ records.append({
+ "id": f"{artifact.name}:{name}", "runId": artifact.name, "label": f"{artifact.name} / {input_name}",
+ "artifactRoot": relative_path(root, artifact), "file": name, "url": f"/{relative_path(root, artifact)}/{name}",
+ "sha256": checksum, "pointCount": count,
+ "sourceKind": str(contract.get("kind") or "generated RGB/XYZ annotation preview"),
+ "sourceHasRgb": bool(input_data.get("has_rgb")),
+ "detailAvailable": False, "detailFile": None,
+ })
+ return sorted(records, key=lambda item: (item["runId"], item["id"]), reverse=True)
+
+
+def pointcloud_annotation_source(root: Path, source_id: str) -> dict[str, Any]:
+ if not isinstance(source_id, str) or not source_id or "/" in source_id or len(source_id) > 300:
+ raise ApiError("Invalid annotation source id.")
+ source = next((item for item in pointcloud_annotation_sources(root) if item["id"] == source_id), None)
+ if not source:
+ raise ApiError("The selected generated annotation source is unavailable.")
+ return source
+
+
+def pointcloud_annotation_detail(
+ root: Path,
+ source_id: str,
+ center: tuple[float, float, float],
+ radius: float,
+) -> bytes:
+ """Read one bounded, server-selected RGB detail window as a binary PLY."""
+ if not all(math.isfinite(value) and abs(value) <= 1_000_000_000.0 for value in center):
+ raise ApiError("Detail centre must contain finite local coordinates.")
+ if not math.isfinite(radius) or not 0 < radius <= MAX_POINTCLOUD_DETAIL_RADIUS:
+ raise ApiError(f"Detail radius must be between 0 and {MAX_POINTCLOUD_DETAIL_RADIUS:g}.")
+ source = pointcloud_annotation_source(root, source_id)
+ detail_name = source.get("detailFile")
+ if not source.get("detailAvailable") or not isinstance(detail_name, str) or Path(detail_name).name != detail_name:
+ raise ApiError("This annotation source has no local RGB detail layer.")
+ artifact = (root / str(source["artifactRoot"])).resolve()
+ output_root = (root / "shared" / "outputs" / "05-3d-pointcloud").resolve()
+ detail_path = (artifact / detail_name).resolve()
+ try:
+ artifact.relative_to(output_root)
+ detail_path.relative_to(artifact)
+ except ValueError as exc:
+ raise ApiError("The requested detail layer is outside the local point-cloud outputs.") from exc
+ if not detail_path.is_file() or detail_path.suffix.lower() not in {".las", ".laz", ".ply"}:
+ raise ApiError("The local RGB detail layer is unavailable.")
+ python = root / ".venvs" / POINTCLOUD_CPU_ENVIRONMENT / "Scripts" / "python.exe"
+ exporter = root / "capabilities" / "05-3d-pointcloud" / "export_pointcloud_detail.py"
+ if not python.is_file() or not exporter.is_file():
+ raise ApiError("Point-cloud detail exporter is unavailable. Run the capability setup first.")
+ command = [
+ str(python), str(exporter), "--input", str(detail_path), "--center",
+ *(f"{value:.12g}" for value in center), "--radius", f"{radius:.12g}",
+ "--max-points", "1200000",
+ ]
+ if not POINTCLOUD_DETAIL_REQUEST_LOCK.acquire(timeout=1):
+ raise ApiError("A point-cloud detail request is already running. Stop moving briefly and retry.")
+ try:
+ completed = subprocess.run(command, capture_output=True, timeout=120, check=False)
+ except subprocess.TimeoutExpired as exc:
+ raise ApiError("The local point-cloud detail request exceeded 120 seconds.") from exc
+ finally:
+ POINTCLOUD_DETAIL_REQUEST_LOCK.release()
+ if completed.returncode != 0:
+ message = completed.stderr.decode("utf-8", errors="replace").strip()
+ raise ApiError(message[:400] or "The local point-cloud detail exporter failed.")
+ payload = completed.stdout
+ if not payload.startswith(b"ply\nformat binary_little_endian 1.0\n") or len(payload) > 24 * 1024 * 1024:
+ raise ApiError("The local point-cloud detail response is invalid or exceeds its size limit.")
+ return payload
+
+
+def ply_vertex_count(path: Path) -> int | None:
+ """Read only the bounded PLY header; the point body can be hundreds of MB."""
+ try:
+ with path.open("rb") as stream:
+ header = bytearray()
+ while len(header) < 65_536:
+ line = stream.readline(4_096)
+ if not line:
+ return None
+ header.extend(line)
+ if line.rstrip(b"\r\n") == b"end_header":
+ break
+ else:
+ return None
+ vertex_count: int | None = None
+ for line in header.decode("ascii").splitlines():
+ fields = line.split()
+ if len(fields) == 3 and fields[:2] == ["element", "vertex"] and fields[2].isdigit():
+ vertex_count = int(fields[2])
+ return vertex_count
+ except (OSError, UnicodeDecodeError):
+ return None
+
+
+def npz_array_row_count(path: Path, array_name: str) -> int | None:
+ """Validate an NPZ array shape without importing a ML environment in the console."""
+ try:
+ with zipfile.ZipFile(path) as archive:
+ with archive.open(f"{array_name}.npy") as stream:
+ magic = stream.read(6)
+ version = stream.read(2)
+ if magic != b"\x93NUMPY" or len(version) != 2:
+ return None
+ header_size = 2 if version[0] == 1 else 4
+ header_length = int.from_bytes(stream.read(header_size), "little")
+ if header_length < 1 or header_length > 16_384:
+ return None
+ header = ast.literal_eval(stream.read(header_length).decode("latin1"))
+ shape = header.get("shape") if isinstance(header, dict) else None
+ if not isinstance(shape, tuple) or len(shape) != 2 or not all(isinstance(value, int) and value >= 0 for value in shape):
+ return None
+ return int(shape[0])
+ except (OSError, KeyError, ValueError, SyntaxError, zipfile.BadZipFile):
+ return None
+
+
+def multiview_pointcloud_annotation_sources(root: Path) -> list[dict[str, Any]]:
+ """Discover only complete, order-verified multi-view fusion annotation sources."""
+ output_root = root / "shared" / "outputs" / "05-3d-pointcloud"
+ records: list[dict[str, Any]] = []
+ for metadata_path in output_root.glob("multiview-feature-*/run_metadata.json"):
+ artifact = metadata_path.parent
+ metadata = load_json(metadata_path)
+ contract = metadata.get("annotation_source")
+ artifacts = metadata.get("artifacts")
+ if metadata.get("capability") != "05-3d-pointcloud" or not isinstance(contract, dict) or not isinstance(artifacts, dict):
+ continue
+ if contract.get("schema_version") != 1 or contract.get("kind") != "multiview_photo_feature_fusion":
+ continue
+ point_cloud = contract.get("point_cloud")
+ feature_dataset = contract.get("feature_dataset")
+ point_count = contract.get("point_count")
+ if (
+ not isinstance(point_cloud, str)
+ or not isinstance(feature_dataset, str)
+ or Path(point_cloud).name != point_cloud
+ or Path(feature_dataset).name != feature_dataset
+ or point_cloud != "multiview-annotation-source.ply"
+ or feature_dataset != "multiview-point-features.npz"
+ or artifacts.get("annotation_source") != point_cloud
+ or artifacts.get("feature_dataset") != feature_dataset
+ or not isinstance(point_count, int)
+ or point_count < 1
+ ):
+ continue
+ cloud_path = artifact / point_cloud
+ dataset_path = artifact / feature_dataset
+ if not cloud_path.is_file() or not dataset_path.is_file():
+ continue
+ if contract.get("point_cloud_sha256") != file_sha256(cloud_path) or contract.get("feature_dataset_sha256") != file_sha256(dataset_path):
+ continue
+ if ply_vertex_count(cloud_path) != point_count or npz_array_row_count(dataset_path, "xyz") != point_count or npz_array_row_count(dataset_path, "las_rgb") != point_count:
+ continue
+ records.append({
+ "id": f"{artifact.name}:{point_cloud}",
+ "runId": artifact.name,
+ "label": f"多视角照片特征融合样本 / {artifact.name}",
+ "artifactRoot": relative_path(root, artifact),
+ "file": point_cloud,
+ "url": f"/{relative_path(root, artifact)}/{point_cloud}",
+ "sha256": str(contract["point_cloud_sha256"]),
+ "pointCount": point_count,
+ "sourceKind": "多视角照片特征融合样本(原始 LAS RGB / XYZ;与特征数据同序)",
+ })
+ return sorted(records, key=lambda item: (item["runId"], item["id"]), reverse=True)
+
+
+def annotation_classes_path(root: Path) -> Path:
+ return root / "shared" / "outputs" / "05-3d-pointcloud" / "annotation-classes.json"
+
+
+def annotation_classes(root: Path) -> list[dict[str, Any]]:
+ """Return the local editable taxonomy, with stable defaults for old workspaces."""
+ saved = load_json(annotation_classes_path(root)).get("classes")
+ if not isinstance(saved, list):
+ return [dict(item) for item in DEFAULT_POINTCLOUD_ANNOTATION_CLASSES]
+ try:
+ return validate_annotation_classes(saved, allow_builtin=True)
+ except ApiError:
+ # A corrupt local taxonomy must not prevent existing annotations from opening.
+ return [dict(item) for item in DEFAULT_POINTCLOUD_ANNOTATION_CLASSES]
+
+
+def validate_annotation_classes(values: list[Any], *, allow_builtin: bool) -> list[dict[str, Any]]:
+ if not values or len(values) > 64:
+ raise ApiError("The annotation taxonomy must contain 1 to 64 classes.")
+ normalized: list[dict[str, Any]] = []
+ codes: set[int] = set()
+ keys: set[str] = set()
+ labels: set[str] = set()
+ built_in_codes = {item["code"] for item in DEFAULT_POINTCLOUD_ANNOTATION_CLASSES}
+ for item in values:
+ if not isinstance(item, dict):
+ raise ApiError("Each annotation class must be an object.")
+ code, key, label, color = item.get("code"), item.get("key"), item.get("label"), item.get("color")
+ if not isinstance(code, int) or not 1 <= code <= 255:
+ raise ApiError("Annotation class codes must be integers from 1 to 255 for LAS compatibility.")
+ if not isinstance(key, str) or not ANNOTATION_CLASS_KEY.fullmatch(key):
+ raise ApiError("Annotation class keys must use lowercase English letters, numbers, and underscores.")
+ if not isinstance(label, str) or not 1 <= len(label.strip()) <= 40:
+ raise ApiError("Annotation class labels must contain 1 to 40 characters.")
+ if not isinstance(color, list) or len(color) != 3 or not all(isinstance(value, int) and 0 <= value <= 255 for value in color):
+ raise ApiError("Annotation class colors must be three RGB integers from 0 to 255.")
+ if code in codes or key in keys or label.strip() in labels:
+ raise ApiError("Annotation class code, key, and label must each be unique.")
+ if code in built_in_codes and not allow_builtin:
+ raise ApiError("Built-in annotation classes cannot be replaced.")
+ codes.add(code); keys.add(key); labels.add(label.strip())
+ normalized.append({"code": code, "key": key, "label": label.strip(), "color": color, "builtIn": code in built_in_codes})
+ return sorted(normalized, key=lambda item: item["code"])
+
+
+def write_annotation_classes(root: Path, classes: list[dict[str, Any]]) -> None:
+ path = annotation_classes_path(root)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps({"schema_version": 1, "classes": classes}, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def annotation_class_codes_in_use(root: Path) -> set[int]:
+ used: set[int] = set()
+ for path in (root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations").glob("*/annotation.json"):
+ record = load_json(path)
+ for label in record.get("labels", []):
+ if isinstance(label, list) and len(label) == 2 and isinstance(label[1], int):
+ used.add(label[1])
+ return used
def pointcloud_annotations(root: Path) -> list[dict[str, Any]]:
@@ -526,16 +783,170 @@
return sorted(records, key=lambda item: (str(item["createdAt"]), str(item["id"])), reverse=True)
+def pointcloud_annotation_source_deletion_plan(root: Path, source_id: str) -> dict[str, Any]:
+ """Describe every generated artifact that will be removed for one source.
+
+ Source paths are discovered server-side. Files outside the established
+ point-cloud output/raw/processed layouts, including ``baseData``, are never
+ part of this plan.
+ """
+ source = next((item for item in pointcloud_annotation_sources(root) if item["id"] == source_id), None)
+ if not source:
+ raise ApiError("The selected annotation source is unavailable.")
+ output_root = (root / "shared" / "outputs" / "05-3d-pointcloud").resolve()
+ artifact = (root / str(source["artifactRoot"])).resolve()
+ try:
+ artifact.relative_to(output_root)
+ except ValueError as exc:
+ raise ApiError("The selected annotation source is outside the allowed output directory.") from exc
+ siblings = [item for item in pointcloud_annotation_sources(root) if item["runId"] == str(source["runId"])]
+ source_ids = {item["id"] for item in siblings}
+ annotation_root = output_root / "annotations"
+ annotations: list[Path] = []
+ for path in annotation_root.glob("*/annotation.json"):
+ if load_json(path).get("source_id") in source_ids:
+ annotations.append(path.parent)
+ annotation_paths = {path / "annotation.json" for path in annotations}
+ training_root = output_root / "training-runs"
+ training: list[Path] = []
+ for metrics_path in training_root.glob("*/metrics.json"):
+ value = load_json(metrics_path).get("annotation")
+ if not isinstance(value, str):
+ continue
+ try:
+ if Path(value).resolve() in annotation_paths:
+ training.append(metrics_path.parent)
+ except OSError:
+ continue
+ training_models = {path / "model.pt" for path in training}
+ inference_root = output_root / "model-inference-runs"
+ inference: list[Path] = []
+ for metadata_path in inference_root.glob("*/run_metadata.json"):
+ model = load_json(metadata_path).get("model")
+ model_path = model.get("path") if isinstance(model, dict) else None
+ if not isinstance(model_path, str):
+ continue
+ try:
+ if Path(model_path).resolve() in training_models:
+ inference.append(metadata_path.parent)
+ except OSError:
+ continue
+ run_id = str(source["runId"])
+ raw_root = root / "shared" / "data" / "raw" / "05-3d-pointcloud"
+ processed_root = root / "shared" / "data" / "processed" / "05-3d-pointcloud"
+ raw_candidates = [raw_root / "annotation-source-runs" / run_id, raw_root / "runs" / run_id]
+ processed_candidates = [processed_root / "annotation-source-runs" / run_id, processed_root / "runs" / run_id]
+ raw = [path for path in raw_candidates if path.is_dir()]
+ processed = [path for path in processed_candidates if path.is_dir()]
+ metadata = load_json(artifact / "run_metadata.json")
+ return {
+ "sourceId": source_id,
+ "label": source["label"],
+ "sourceKind": source["sourceKind"],
+ "runId": run_id,
+ "artifactRoot": str(source["artifactRoot"]),
+ "outputDirectories": 1,
+ "rawDirectories": len(raw),
+ "processedDirectories": len(processed),
+ "annotationRevisions": len(annotations),
+ "trainingRuns": len(training),
+ "inferenceRuns": len(inference),
+ "siblingSources": len(siblings),
+ "removesOriginalUpload": bool(raw),
+ "preservesExternalInputs": not bool(raw) and not bool(metadata.get("annotation_source_job")),
+ }
+
+
+def assert_removable_pointcloud_directory(root: Path, path: Path, allowed_root: Path) -> None:
+ resolved = path.resolve()
+ allowed = allowed_root.resolve()
+ try:
+ relative = resolved.relative_to(allowed)
+ except ValueError as exc:
+ raise ApiError("A deletion target is outside the allowed point-cloud workspace.") from exc
+ if not relative.parts or not resolved.is_dir():
+ raise ApiError("A deletion target is invalid.")
+
+
+def active_pointcloud_source_dependencies(run_id: str, annotation_ids: set[str], training_ids: set[str]) -> bool:
+ active = {"queued", "running"}
+ with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK:
+ if any(job.get("runId") == run_id and job.get("status") in active for job in POINTCLOUD_ANNOTATION_SOURCE_JOBS.values()):
+ return True
+ with POINTCLOUD_TRAINING_JOBS_LOCK:
+ if any(job.get("annotationId") in annotation_ids for job in POINTCLOUD_TRAINING_JOBS.values() if job.get("status") in active):
+ return True
+ with POINTCLOUD_INFERENCE_JOBS_LOCK:
+ if any(job.get("modelId") in training_ids and job.get("status") in active for job in POINTCLOUD_INFERENCE_JOBS.values()):
+ return True
+ return False
+
+
+def pointcloud_annotation_source_job(job_id: str) -> dict[str, Any] | None:
+ with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK:
+ value = POINTCLOUD_ANNOTATION_SOURCE_JOBS.get(job_id)
+ return dict(value) if value else None
+
+
+def execute_pointcloud_annotation_source_job(
+ root: Path,
+ job_id: str,
+ processed_path: Path,
+ output: Path,
+ source_sha256: str,
+ source_bytes: int,
+) -> None:
+ with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK:
+ POINTCLOUD_ANNOTATION_SOURCE_JOBS[job_id].update({"status": "running", "stage": "preparing_annotation_preview", "startedAt": datetime.now(UTC).isoformat()})
+ command = [
+ str(root / ".venvs" / POINTCLOUD_CPU_ENVIRONMENT / "Scripts" / "python.exe"),
+ str(root / "capabilities" / "05-3d-pointcloud" / "prepare_annotation_source.py"),
+ "--input", str(processed_path), "--output", str(output),
+ ]
+ try:
+ with RUN_LOCK:
+ completed = subprocess.run(command, cwd=root, capture_output=True, text=True, timeout=900, check=False)
+ if completed.returncode:
+ message = (completed.stderr or completed.stdout or "Unknown script error.").strip().splitlines()[-1]
+ raise RuntimeError(f"Processing failed: {message[:600]}")
+ metadata_path = output / "run_metadata.json"
+ if not metadata_path.is_file():
+ raise RuntimeError("Point-cloud processing finished without result metadata.")
+ metadata = load_json(metadata_path)
+ run_id = pointcloud_annotation_source_job(job_id)["runId"]
+ metadata["input_dir"] = relative_path(root, processed_path.parent)
+ metadata["raw_input_dir"] = relative_path(root, root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "annotation-source-runs" / run_id)
+ metadata["source_sha256"] = {processed_path.name: source_sha256}
+ metadata["source_bytes"] = {processed_path.name: source_bytes}
+ metadata["annotation_source_job"] = True
+ metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
+ source = next((item for item in standalone_pointcloud_annotation_sources(root) if item["runId"] == run_id), None)
+ if not source:
+ raise RuntimeError("Annotation preview finished without a discoverable annotation source.")
+ with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK:
+ POINTCLOUD_ANNOTATION_SOURCE_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "source": source})
+ except Exception as exc:
+ with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK:
+ POINTCLOUD_ANNOTATION_SOURCE_JOBS[job_id].update({"status": "failed", "stage": "failed", "completedAt": datetime.now(UTC).isoformat(), "error": str(exc)[:700]})
+
+
def pointcloud_training_job(job_id: str) -> dict[str, Any] | None:
with POINTCLOUD_TRAINING_JOBS_LOCK:
value = POINTCLOUD_TRAINING_JOBS.get(job_id)
return dict(value) if value else None
-def execute_pointcloud_training_job(root: Path, job_id: str, annotation: Path, output: Path, execution: dict[str, str]) -> None:
+def execute_pointcloud_training_job(root: Path, job_id: str, annotation: Path, output: Path, execution: dict[str, str], trainer: str = "rgb_xyz_baseline") -> None:
with POINTCLOUD_TRAINING_JOBS_LOCK:
POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "running", "stage": "training", "startedAt": datetime.now(UTC).isoformat()})
- command = [execution["python"], str(root / "capabilities" / "05-3d-pointcloud" / "train_pointcloud_semantic_model.py"), "--annotation", str(annotation), "--output", str(output), "--device", execution["device"]]
+ scripts = {
+ "rgb_xyz_baseline": "train_pointcloud_semantic_model.py",
+ "multiview_local_attention_baseline": "train_multiview_point_transformer.py",
+ }
+ script = scripts.get(trainer)
+ if not script:
+ raise ApiError("Unsupported point-cloud training workflow.")
+ command = [execution["python"], str(root / "capabilities" / "05-3d-pointcloud" / script), "--annotation", str(annotation), "--output", str(output), "--device", execution["device"]]
try:
with RUN_LOCK:
completed = subprocess.run(command, cwd=root, capture_output=True, text=True, timeout=14_400, check=False)
@@ -547,8 +958,11 @@
preview = output / "predicted-semantic-preview.ply"
if not all(path.is_file() for path in (metrics, model, preview)):
raise ApiError("Training finished without model, metrics, and predicted preview artifacts.")
+ metadata = output / "run_metadata.json"
+ if metadata.is_file():
+ record_execution_metadata(metadata, execution)
with POINTCLOUD_TRAINING_JOBS_LOCK:
- POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "artifactRoot": relative_path(root, output), "metrics": relative_path(root, metrics), "model": relative_path(root, model), "preview": relative_path(root, preview)})
+ POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "artifactRoot": relative_path(root, output), "metrics": relative_path(root, metrics), "model": relative_path(root, model), "preview": relative_path(root, preview), "trainer": trainer})
except Exception as exc:
with POINTCLOUD_TRAINING_JOBS_LOCK:
POINTCLOUD_TRAINING_JOBS[job_id].update({"status": "failed", "stage": "failed", "completedAt": datetime.now(UTC).isoformat(), "error": str(exc)[:700]})
@@ -562,7 +976,7 @@
metrics_path = model_path.with_name("metrics.json")
metrics = load_json(metrics_path)
classes = metrics.get("classes")
- if metrics.get("capability") != "05-3d-pointcloud" or metrics.get("classification") != "B" or not isinstance(classes, dict):
+ if metrics.get("capability") != "05-3d-pointcloud" or metrics.get("classification") != "B" or metrics.get("model_input_kind", "rgb_xyz") != "rgb_xyz" or not isinstance(classes, dict):
continue
class_codes = sorted(str(code) for code in classes if str(code).isdigit())
if len(class_codes) < 2:
@@ -584,6 +998,69 @@
with POINTCLOUD_INFERENCE_JOBS_LOCK:
value = POINTCLOUD_INFERENCE_JOBS.get(job_id)
return dict(value) if value else None
+
+
+def latest_pointcloud_auto_annotation_job(root: Path, source_id: str, model_id: str) -> dict[str, Any] | None:
+ """Recover a completed local automatic-annotation run after a server restart."""
+ source = next((item for item in pointcloud_annotation_sources(root) if item["id"] == source_id), None)
+ model = next((item for item in pointcloud_semantic_models(root) if item["id"] == model_id), None)
+ if not source or not model:
+ raise ApiError("The selected annotation source or trained model is unavailable.")
+ output_root = root / "shared" / "outputs" / "05-3d-pointcloud" / "auto-annotation-runs"
+ records: list[dict[str, Any]] = []
+ model_sha256 = file_sha256(root / str(model["model"]))
+ for metadata_path in output_root.glob("*/run_metadata.json"):
+ artifact = metadata_path.parent
+ metadata = load_json(metadata_path)
+ prediction = metadata.get("prediction")
+ input_record = metadata.get("input")
+ model_record = metadata.get("model")
+ processing = metadata.get("processing")
+ automatic = prediction.get("automatic_annotation") if isinstance(prediction, dict) else None
+ preview = artifact / "predicted-semantic-preview.ply"
+ classified_las = artifact / "predicted-semantic-classified.las"
+ class_counts = artifact / "class-counts.csv"
+ summary = artifact / "prediction-summary.json"
+ candidates = artifact / "automatic-annotation-candidates.json"
+ if (
+ metadata.get("capability") != "05-3d-pointcloud"
+ or not isinstance(input_record, dict)
+ or not isinstance(model_record, dict)
+ or not isinstance(processing, dict)
+ or not isinstance(automatic, dict)
+ or input_record.get("sha256") != source.get("sha256")
+ or model_record.get("sha256") != model_sha256
+ or not all(path.is_file() for path in (preview, classified_las, class_counts, summary, candidates))
+ ):
+ continue
+ confidence = automatic.get("candidate_confidence")
+ if not isinstance(confidence, (int, float)):
+ continue
+ records.append({
+ "id": f"recovered-{artifact.name}",
+ "runId": artifact.name,
+ "modelId": model_id,
+ "sourceId": source_id,
+ "inputName": str(source["file"]),
+ "candidateConfidence": float(confidence),
+ "status": "complete",
+ "stage": "complete",
+ "requestedDevice": processing.get("requested_device"),
+ "device": processing.get("device", "cpu"),
+ "environment": processing.get("environment"),
+ "torchVersion": metadata.get("versions", {}).get("torch") if isinstance(metadata.get("versions"), dict) else None,
+ "fallbackUsed": processing.get("fallback_used", False),
+ "fallbackReason": processing.get("fallback_reason"),
+ "createdAt": str(metadata.get("created_at") or ""),
+ "artifactRoot": relative_path(root, artifact),
+ "metadata": relative_path(root, metadata_path),
+ "preview": relative_path(root, preview),
+ "classifiedLas": relative_path(root, classified_las),
+ "classCounts": relative_path(root, class_counts),
+ "summary": relative_path(root, summary),
+ "candidateFile": relative_path(root, candidates),
+ })
+ return max(records, key=lambda item: (item["createdAt"], item["runId"])) if records else None
def validate_pointcloud_model_input(path: Path) -> None:
@@ -611,10 +1088,12 @@
raise ApiError("LAS/LAZ 文件没有点记录。请选择包含实际 RGB 点位的完整点云文件,而不是空分块。")
-def execute_pointcloud_inference_job(root: Path, job_id: str, model: Path, source: Path, output: Path, execution: dict[str, str]) -> None:
+def execute_pointcloud_inference_job(root: Path, job_id: str, model: Path, source: Path, output: Path, execution: dict[str, str], annotation_source_id: str | None = None, candidate_confidence: float | None = None) -> None:
with POINTCLOUD_INFERENCE_JOBS_LOCK:
POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "running", "stage": "inference", "startedAt": datetime.now(UTC).isoformat()})
command = [execution["python"], str(root / "capabilities" / "05-3d-pointcloud" / "apply_pointcloud_semantic_model.py"), "--model", str(model), "--input", str(source), "--output", str(output), "--device", execution["device"]]
+ if annotation_source_id:
+ command.extend(["--annotation-source-id", annotation_source_id, "--candidate-confidence", f"{candidate_confidence or 0.95:.6f}"])
try:
with RUN_LOCK:
completed = subprocess.run(command, cwd=root, capture_output=True, text=True, timeout=14_400, check=False)
@@ -628,8 +1107,12 @@
summary = output / "prediction-summary.json"
if not all(path.is_file() for path in (metadata, preview, classified_las, counts, summary)):
raise ApiError("Model inference finished without all expected prediction artifacts.")
+ candidates = output / "automatic-annotation-candidates.json"
+ if annotation_source_id and not candidates.is_file():
+ raise ApiError("Automatic annotation finished without the expected candidate artifact.")
+ record_execution_metadata(metadata, execution)
with POINTCLOUD_INFERENCE_JOBS_LOCK:
- POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "artifactRoot": relative_path(root, output), "metadata": relative_path(root, metadata), "preview": relative_path(root, preview), "classifiedLas": relative_path(root, classified_las), "classCounts": relative_path(root, counts), "summary": relative_path(root, summary)})
+ POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "complete", "stage": "complete", "completedAt": datetime.now(UTC).isoformat(), "artifactRoot": relative_path(root, output), "metadata": relative_path(root, metadata), "preview": relative_path(root, preview), "classifiedLas": relative_path(root, classified_las), "classCounts": relative_path(root, counts), "summary": relative_path(root, summary), "candidateFile": relative_path(root, candidates) if annotation_source_id else None})
except Exception as exc:
with POINTCLOUD_INFERENCE_JOBS_LOCK:
POINTCLOUD_INFERENCE_JOBS[job_id].update({"status": "failed", "stage": "failed", "completedAt": datetime.now(UTC).isoformat(), "error": str(exc)[:700]})
@@ -700,6 +1183,161 @@
return sorted(records, key=lambda item: (item["createdAt"], item["id"]), reverse=True)
+# A console run is removable only when it was created in the fixed ``runs``
+# layout. Discovery also exposes baseline and validation artifacts, but those
+# are project evidence rather than disposable console-owned copies.
+RUN_DELETION_CAPABILITIES: dict[str, tuple[str, Any]] = {
+ "00-change-detection": ("00-change-detection", change_runs),
+ "01-object-detection": ("01-object-detection", detection_runs),
+ "02-semantic-mapping": ("02-semantic-mapping", semantic_runs),
+ "04-spatial-measurement": ("04-spatial-measurement", measurement_runs),
+ "05-3d-pointcloud": ("05-3d-pointcloud", pointcloud_runs),
+ "07-risk-rule-engine": ("07-risk-rule-engine", risk_rule_runs),
+ "09-anomaly-detection": ("09-anomaly-detection", anomaly_runs),
+ "15-trajectory-analysis": ("15-trajectory-analysis", trajectory_runs),
+}
+
+
+def valid_run_deletion_id(run_id: str) -> bool:
+ return bool(run_id) and len(run_id) <= 160 and "/" not in run_id and "\\" not in run_id and not SAFE_FILE_NAME.search(run_id)
+
+
+def existing_directory(path: Path, parent: Path) -> Path | None:
+ """Return an existing direct child directory, never a caller-supplied path."""
+ try:
+ resolved_parent = parent.resolve()
+ resolved = path.resolve()
+ resolved.relative_to(resolved_parent)
+ except (OSError, ValueError):
+ return None
+ return resolved if resolved.is_dir() else None
+
+
+def run_deletion_plan(root: Path, capability: str, run_id: str) -> dict[str, Any]:
+ if capability not in RUN_DELETION_CAPABILITIES:
+ raise ApiError("This capability does not expose removable console runs.")
+ if not valid_run_deletion_id(run_id):
+ raise ApiError("Invalid run id.")
+ capability_dir, discover = RUN_DELETION_CAPABILITIES[capability]
+ record = next((item for item in discover(root) if item.get("id") == run_id), None)
+ if not record:
+ raise ApiError("The selected result is unavailable.")
+
+ output_root = (root / "shared" / "outputs" / capability_dir).resolve()
+ expected_output = (output_root / "runs" / run_id).resolve()
+ artifact_value = record.get("artifactRoot")
+ try:
+ artifact = (root / str(artifact_value)).resolve()
+ except OSError as exc:
+ raise ApiError("The selected result has an invalid artifact location.") from exc
+ if artifact != expected_output:
+ return {
+ "capability": capability,
+ "runId": run_id,
+ "label": str(record.get("label") or run_id),
+ "removable": False,
+ "reason": "This is a built-in baseline, validation artifact, or external result. It was not created in the console-owned run layout.",
+ "outputDirectories": [],
+ "rawDirectories": [],
+ "processedDirectories": [],
+ "dependentDirectories": [],
+ "preservesExternalInputs": True,
+ }
+
+ output_directories = [expected_output] if existing_directory(expected_output, output_root / "runs") else []
+ raw_root = (root / "shared" / "data" / "raw" / capability_dir).resolve()
+ processed_root = (root / "shared" / "data" / "processed" / capability_dir).resolve()
+ raw_candidates = [raw_root / "runs" / run_id]
+ processed_candidates = [processed_root / run_id, processed_root / "runs" / run_id]
+ raw_directories = [path for candidate in raw_candidates if (path := existing_directory(candidate, raw_root))]
+ processed_directories = [path for candidate in processed_candidates if (path := existing_directory(candidate, processed_root))]
+ dependent_directories: list[Path] = []
+ if capability == "00-change-detection":
+ scan_root = output_root / "parameter-scans" / run_id
+ if path := existing_directory(scan_root, output_root / "parameter-scans"):
+ dependent_directories.append(path)
+ return {
+ "capability": capability,
+ "runId": run_id,
+ "label": str(record.get("label") or run_id),
+ "removable": bool(output_directories),
+ "reason": None if output_directories else "The console-owned output directory is missing, so no deletion is performed.",
+ "outputDirectories": [relative_path(root, path) for path in output_directories],
+ "rawDirectories": [relative_path(root, path) for path in raw_directories],
+ "processedDirectories": [relative_path(root, path) for path in processed_directories],
+ "dependentDirectories": [relative_path(root, path) for path in dependent_directories],
+ "preservesExternalInputs": True,
+ }
+
+
+def delete_console_run(root: Path, capability: str, run_id: str) -> dict[str, Any]:
+ plan = run_deletion_plan(root, capability, run_id)
+ if not plan["removable"]:
+ raise ApiError(str(plan["reason"] or "The selected result cannot be removed."))
+ directories = [*plan["dependentDirectories"], *plan["processedDirectories"], *plan["rawDirectories"], *plan["outputDirectories"]]
+ for relative in directories:
+ location = (root / relative).resolve()
+ # Every entry was created by run_deletion_plan from fixed roots above.
+ if location.is_dir():
+ shutil.rmtree(location)
+ return {"capability": capability, "runId": run_id, "removedDirectories": directories, "preservesExternalInputs": True}
+
+
+def pointcloud_semantic_model_deletion_plan(root: Path, model_id: str) -> dict[str, Any]:
+ if not valid_run_deletion_id(model_id):
+ raise ApiError("Invalid semantic model id.")
+ model = next((item for item in pointcloud_semantic_models(root) if item["id"] == model_id), None)
+ if not model:
+ raise ApiError("The selected trained model is unavailable.")
+ output_root = (root / "shared" / "outputs" / "05-3d-pointcloud").resolve()
+ model_root = (output_root / "training-runs" / model_id).resolve()
+ model_path = (root / str(model["model"])).resolve()
+ if model_path != model_root / "model.pt" or not existing_directory(model_root, output_root / "training-runs"):
+ raise ApiError("The selected trained model is outside the console-owned training layout.")
+ model_sha256 = file_sha256(model_path)
+ inference_directories: list[Path] = []
+ for metadata_path in (output_root / "model-inference-runs").glob("*/run_metadata.json"):
+ metadata = load_json(metadata_path)
+ value = metadata.get("model")
+ candidate = value.get("path") if isinstance(value, dict) else None
+ try:
+ if isinstance(candidate, str) and Path(candidate).resolve() == model_path:
+ inference_directories.append(metadata_path.parent.resolve())
+ except OSError:
+ continue
+ auto_directories: list[Path] = []
+ for metadata_path in (output_root / "auto-annotation-runs").glob("*/run_metadata.json"):
+ value = load_json(metadata_path).get("model")
+ if isinstance(value, dict) and value.get("sha256") == model_sha256:
+ auto_directories.append(metadata_path.parent.resolve())
+ raw_root = (root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "model-inference-runs").resolve()
+ processed_root = (root / "shared" / "data" / "processed" / "05-3d-pointcloud" / "model-inference-runs").resolve()
+ raw_directories = [path for directory in inference_directories if (path := existing_directory(raw_root / directory.name, raw_root))]
+ processed_directories = [path for directory in inference_directories if (path := existing_directory(processed_root / directory.name, processed_root))]
+ return {
+ "modelId": model_id,
+ "label": str(model["label"]),
+ "removable": True,
+ "trainingDirectories": [relative_path(root, model_root)],
+ "inferenceDirectories": [relative_path(root, path) for path in inference_directories],
+ "autoAnnotationDirectories": [relative_path(root, path) for path in auto_directories],
+ "rawDirectories": [relative_path(root, path) for path in raw_directories],
+ "processedDirectories": [relative_path(root, path) for path in processed_directories],
+ "preservesAnnotationRevisions": True,
+ "preservesExternalInputs": True,
+ }
+
+
+def delete_pointcloud_semantic_model(root: Path, model_id: str) -> dict[str, Any]:
+ plan = pointcloud_semantic_model_deletion_plan(root, model_id)
+ directories = [*plan["autoAnnotationDirectories"], *plan["inferenceDirectories"], *plan["processedDirectories"], *plan["rawDirectories"], *plan["trainingDirectories"]]
+ for relative in directories:
+ location = (root / relative).resolve()
+ if location.is_dir():
+ shutil.rmtree(location)
+ return {"modelId": model_id, "removedDirectories": directories, "preservesAnnotationRevisions": True, "preservesExternalInputs": True}
+
+
def anomaly_job(job_id: str) -> dict[str, Any] | None:
with ANOMALY_JOB_LOCK:
value = ANOMALY_JOBS.get(job_id)
@@ -709,7 +1347,21 @@
def photo_reconstruction_job(job_id: str) -> dict[str, Any] | None:
with PHOTO_RECONSTRUCTION_JOBS_LOCK:
value = PHOTO_RECONSTRUCTION_JOBS.get(job_id)
- return dict(value) if value else None
+ job = dict(value) if value else None
+ if not job:
+ return None
+ progress_path = job.pop("progressPath", None)
+ if job["status"] == "complete":
+ job["progress"] = {"percent": 100, "stage": "complete", "message": "照片重建已完成,结果已加入案例库。", "inputImages": job["inputImages"], "estimate": True}
+ return job
+ if job["status"] == "failed":
+ job["progress"] = {"percent": 0, "stage": "failed", "message": job.get("error") or "照片重建失败。", "inputImages": job["inputImages"], "estimate": True}
+ return job
+ if isinstance(progress_path, str):
+ progress = load_json(Path(progress_path))
+ if progress:
+ job["progress"] = progress
+ return job
def run_background_command(command: list[str], root: Path, timeout: int) -> None:
@@ -737,6 +1389,7 @@
"--input", str(processed_root), "--output", str(sparse_output),
"--max-image-size", "2000", "--max-features", "18000",
"--camera-model", "OPENCV",
+ "--progress-file", str(processed_root / "photo_reconstruction_progress.json"),
]
if use_position_priors:
sparse_command.extend(["--matching-mode", "spatial", "--matching-neighbors", "4", "--use-position-priors", "--prior-position-loss-scale-m", "0.05"])
@@ -749,6 +1402,7 @@
"--openmvs-bin", str(root / "shared" / "tools" / "openmvs-2.4.0" / "vc17" / "x64" / "Release"),
"--threads", "12", "--max-resolution", "2400", "--dense-resolution-level", "0",
"--dense-number-views", "8", "--dense-number-views-fuse", "2", "--target-faces", "800000",
+ "--progress-file", str(processed_root / "photo_reconstruction_progress.json"),
]
try:
with PHOTO_RECONSTRUCTION_JOBS_LOCK:
@@ -869,6 +1523,17 @@
def do_GET(self) -> None: # noqa: N802 - inherited standard-library method name
path = urlsplit(self.path).path
+ run_deletion_prefix = "/api/runs/"
+ if path.startswith(run_deletion_prefix) and path.endswith("/deletion-plan"):
+ try:
+ parts = path[len(run_deletion_prefix):].split("/")
+ if len(parts) != 3 or parts[2] != "deletion-plan":
+ raise ApiError("Invalid result deletion-plan endpoint.")
+ capability, run_id = (unquote(parts[0]), unquote(parts[1]))
+ self.send_json(HTTPStatus.OK, {"plan": run_deletion_plan(self.root, capability, run_id)})
+ except ApiError as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
if path == "/api/change-detection/runs":
self.send_json(HTTPStatus.OK, {"runs": change_runs(self.root)})
return
@@ -905,11 +1570,73 @@
if path == "/api/3d-pointcloud/annotation-sources":
self.send_json(HTTPStatus.OK, {"sources": pointcloud_annotation_sources(self.root)})
return
+ if path == "/api/3d-pointcloud/annotation-detail":
+ try:
+ query = parse_qs(urlsplit(self.path).query)
+ source_id = query.get("sourceId", [None])[0]
+ values = [query.get(axis, [None])[0] for axis in ("x", "y", "z", "radius")]
+ if not isinstance(source_id, str) or any(value is None for value in values):
+ raise ApiError("Detail request must include sourceId, x, y, z, and radius.")
+ try:
+ x, y, z, radius = (float(value) for value in values)
+ except (TypeError, ValueError) as exc:
+ raise ApiError("Detail coordinates and radius must be numbers.") from exc
+ payload = pointcloud_annotation_detail(self.root, source_id, (x, y, z), radius)
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Type", "application/octet-stream")
+ self.send_header("Content-Length", str(len(payload)))
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ self.wfile.write(payload)
+ except ApiError as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+ deletion_plan_prefix = "/api/3d-pointcloud/annotation-source-deletion-plans/"
+ if path.startswith(deletion_plan_prefix):
+ try:
+ source_id = unquote(path[len(deletion_plan_prefix):])
+ self.send_json(HTTPStatus.OK, {"plan": pointcloud_annotation_source_deletion_plan(self.root, source_id)})
+ except ApiError as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+ if path == "/api/3d-pointcloud/annotation-classes":
+ self.send_json(HTTPStatus.OK, {"classes": annotation_classes(self.root)})
+ return
if path == "/api/3d-pointcloud/annotations":
self.send_json(HTTPStatus.OK, {"annotations": pointcloud_annotations(self.root)})
return
if path == "/api/3d-pointcloud/semantic-models":
self.send_json(HTTPStatus.OK, {"models": pointcloud_semantic_models(self.root)})
+ return
+ semantic_model_plan_prefix = "/api/3d-pointcloud/semantic-model-deletion-plans/"
+ if path.startswith(semantic_model_plan_prefix):
+ try:
+ model_id = unquote(path[len(semantic_model_plan_prefix):])
+ self.send_json(HTTPStatus.OK, {"plan": pointcloud_semantic_model_deletion_plan(self.root, model_id)})
+ except ApiError as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+ if path == "/api/3d-pointcloud/auto-annotation-runs/latest":
+ try:
+ query = parse_qs(urlsplit(self.path).query)
+ source_id = query.get("sourceId", [None])[0]
+ model_id = query.get("modelId", [None])[0]
+ if not isinstance(source_id, str) or not isinstance(model_id, str):
+ raise ApiError("Automatic-annotation recovery needs sourceId and modelId.")
+ self.send_json(HTTPStatus.OK, {"job": latest_pointcloud_auto_annotation_job(self.root, source_id, model_id)})
+ except ApiError as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
+ review_prefix = "/api/3d-pointcloud/auto-annotation-review-drafts/"
+ if path.startswith(review_prefix):
+ try:
+ run_id = unquote(path[len(review_prefix):])
+ if not run_id or SAFE_FILE_NAME.search(run_id):
+ raise ApiError("Invalid automatic-annotation run id.")
+ review_path = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "auto-annotation-runs" / run_id / "review-corrections.json"
+ self.send_json(HTTPStatus.OK, {"review": load_json(review_path) if review_path.is_file() else None})
+ except ApiError as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
return
if path.startswith("/api/3d-pointcloud/model-inference-jobs/"):
job_id = path.rstrip("/").rsplit("/", 1)[-1]
@@ -920,6 +1647,11 @@
job_id = path.rstrip("/").rsplit("/", 1)[-1]
job = pointcloud_training_job(job_id)
self.send_json(HTTPStatus.OK if job else HTTPStatus.NOT_FOUND, {"job": job} if job else {"error": "Unknown point-cloud training job."})
+ return
+ if path.startswith("/api/3d-pointcloud/annotation-source-jobs/"):
+ job_id = path.rstrip("/").rsplit("/", 1)[-1]
+ job = pointcloud_annotation_source_job(job_id)
+ self.send_json(HTTPStatus.OK if job else HTTPStatus.NOT_FOUND, {"job": job} if job else {"error": "Unknown point-cloud annotation source job."})
return
if path.startswith("/api/3d-pointcloud/photo-reconstruction-jobs/"):
job_id = path.rstrip("/").rsplit("/", 1)[-1]
@@ -976,11 +1708,26 @@
if path == "/api/3d-pointcloud/annotations":
self.send_json(HTTPStatus.CREATED, {"annotation": self.create_pointcloud_annotation(payload)})
return
+ if path == "/api/3d-pointcloud/annotation-classes":
+ self.send_json(HTTPStatus.CREATED, {"class": self.create_pointcloud_annotation_class(payload)})
+ return
+ if path == "/api/3d-pointcloud/annotation-source-runs":
+ self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_pointcloud_annotation_source_run(payload)})
+ return
if path == "/api/3d-pointcloud/training-runs":
self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_pointcloud_training_run(payload)})
return
if path == "/api/3d-pointcloud/model-inference-runs":
self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_pointcloud_model_inference_run(payload)})
+ return
+ if path == "/api/3d-pointcloud/auto-annotation-runs":
+ self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_pointcloud_auto_annotation_run(payload)})
+ return
+ if path == "/api/3d-pointcloud/auto-annotation-acceptances":
+ self.send_json(HTTPStatus.CREATED, {"annotation": self.accept_pointcloud_auto_annotation(payload)})
+ return
+ if path == "/api/3d-pointcloud/auto-annotation-review-drafts":
+ self.send_json(HTTPStatus.CREATED, {"review": self.save_pointcloud_auto_annotation_review(payload)})
return
if path == "/api/3d-pointcloud/photo-reconstruction-runs":
self.send_json(HTTPStatus.ACCEPTED, {"job": self.create_photo_reconstruction_run(payload)})
@@ -1002,6 +1749,55 @@
def do_DELETE(self) -> None: # noqa: N802 - annotation revisions are explicitly user-removable
path = urlsplit(self.path).path
+ run_prefix = "/api/runs/"
+ if path.startswith(run_prefix):
+ try:
+ parts = path[len(run_prefix):].split("/")
+ if len(parts) != 2:
+ raise ApiError("Invalid result deletion endpoint.")
+ capability, run_id = (unquote(parts[0]), unquote(parts[1]))
+ self.send_json(HTTPStatus.OK, {"removed": delete_console_run(self.root, capability, run_id)})
+ except ApiError as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ except Exception as exc: # pragma: no cover - defensive deletion boundary
+ self.log_error("console run deletion failed: %s", exc)
+ self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Result deletion failed. Check the console terminal for details."})
+ return
+ semantic_model_prefix = "/api/3d-pointcloud/semantic-models/"
+ if path.startswith(semantic_model_prefix):
+ try:
+ model_id = unquote(path[len(semantic_model_prefix):])
+ self.send_json(HTTPStatus.OK, {"removed": delete_pointcloud_semantic_model(self.root, model_id)})
+ except ApiError as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ except Exception as exc: # pragma: no cover - defensive deletion boundary
+ self.log_error("semantic model deletion failed: %s", exc)
+ self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Semantic model deletion failed. Check the console terminal for details."})
+ return
+ source_prefix = "/api/3d-pointcloud/annotation-sources/"
+ if path.startswith(source_prefix):
+ try:
+ source_id = unquote(path[len(source_prefix):])
+ if not source_id or "/" in source_id or len(source_id) > 300:
+ raise ApiError("Invalid annotation source id.")
+ self.send_json(HTTPStatus.OK, {"removed": self.delete_pointcloud_annotation_source(source_id)})
+ except ApiError as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ except Exception as exc: # pragma: no cover - defensive deletion boundary
+ self.log_error("annotation source deletion failed: %s", exc)
+ self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Annotation source deletion failed. Check the console terminal for details."})
+ return
+ class_prefix = "/api/3d-pointcloud/annotation-classes/"
+ if path.startswith(class_prefix):
+ try:
+ code_value = path[len(class_prefix):]
+ if not code_value.isdigit():
+ raise ApiError("Invalid annotation class code.")
+ deleted = self.delete_pointcloud_annotation_class(int(code_value))
+ self.send_json(HTTPStatus.OK, {"deletedCode": deleted})
+ except ApiError as exc:
+ self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
+ return
prefix = "/api/3d-pointcloud/annotations/"
if not path.startswith(prefix):
self.send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown local API endpoint."})
@@ -1208,10 +2004,14 @@
def create_detection_run(self, payload: dict[str, Any]) -> dict[str, Any]:
uploads = payload.get("images")
+ requested_device = payload.get("device", "auto")
if not isinstance(uploads, list) or not uploads:
raise ApiError("Object-detection request must include at least one image.")
if len(uploads) > MAX_IMAGES_PER_RUN:
raise ApiError(f"A local run accepts at most {MAX_IMAGES_PER_RUN} images.")
+ if not isinstance(requested_device, str):
+ raise ApiError("Object-detection device must be auto, cpu, or cuda.")
+ execution = object_detection_execution_environment(self.root, requested_device)
decoded = [decode_upload(item, {".jpg", ".jpeg", ".png"}) for item in uploads]
if len({name.casefold() for name, _ in decoded}) != len(decoded):
raise ApiError("Uploaded image names must be unique within one run.")
@@ -1221,16 +2021,18 @@
for name, content in decoded:
(raw_root / name).write_bytes(content)
output = self.root / "shared" / "outputs" / "01-object-detection" / "runs" / run_id
- execution = object_detection_execution_environment(self.root)
with RUN_LOCK:
self.run_command([execution["python"], str(self.root / "capabilities" / "01-object-detection" / "run_detection.py"), "--input", str(raw_root), "--output", str(output), "--device", execution["device"]], 1200)
- if not (output / "run_metadata.json").is_file():
+ metadata_path = output / "run_metadata.json"
+ if not metadata_path.is_file():
raise ApiError("Detection script finished without the expected result metadata.")
+ record_execution_metadata(metadata_path, execution)
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")
uploads = payload.get("uploads")
+ requested_device = payload.get("device", "auto")
staged: dict[str, tuple[str, Path]] = {}
if isinstance(uploads, dict):
staged["before"] = self.resolve_change_upload(uploads.get("before"), "before")
@@ -1255,6 +2057,9 @@
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 not isinstance(requested_device, str):
+ raise ApiError("Change-detection device must be auto, cpu, or cuda.")
+ execution = change_detection_execution_environment(self.root, requested_device)
if staged:
before_name, after_name = staged["before"][0], staged["after"][0]
else:
@@ -1273,7 +2078,6 @@
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
- execution = change_detection_execution_environment(self.root)
with RUN_LOCK:
self.run_command(
[
@@ -1417,6 +2221,7 @@
def create_change_scan(self, payload: dict[str, Any]) -> dict[str, Any]:
uploads = payload.get("uploads")
+ requested_device = payload.get("device", "auto")
if not isinstance(uploads, dict):
raise ApiError("Parameter scan must contain staged before and after uploads.")
staged = {
@@ -1433,6 +2238,9 @@
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 not isinstance(requested_device, str):
+ raise ApiError("Change-detection device must be auto, cpu, or cuda.")
+ execution = change_detection_execution_environment(self.root, requested_device)
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]
@@ -1453,10 +2261,16 @@
"minimumAreas": areas,
"processingMode": processing_mode,
"maxDimension": max_dimension,
+ "requestedDevice": execution["requestedDevice"],
+ "device": execution["device"],
+ "environment": execution["environment"],
+ "torchVersion": execution["torchVersion"],
+ "fallbackUsed": execution["fallbackUsed"],
+ "fallbackReason": execution["fallbackReason"],
}
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),
+ args=(run_id, before_path, after_path, processed_root, inference_output, scan_output, thresholds, areas, processing_mode, max_dimension, execution),
daemon=True,
name=f"change-scan-{run_id}",
)
@@ -1480,11 +2294,11 @@
areas: list[int],
processing_mode: str,
max_dimension: int,
+ execution: dict[str, Any],
) -> None:
- execution = change_detection_execution_environment(self.root)
python = execution["python"]
try:
- self._update_scan_job(run_id, status="running", phase="inference", device=execution["device"], environment=execution["environment"], torchVersion=execution["torchVersion"])
+ self._update_scan_job(run_id, status="running", phase="inference")
with RUN_LOCK:
self.run_command(
[
@@ -1502,6 +2316,7 @@
SCAN_JOB_TIMEOUT,
)
inference_metadata_path = inference_output / "run_metadata.json"
+ record_execution_metadata(inference_metadata_path, execution)
inference_metadata = load_json(inference_metadata_path)
inference_metadata["kind"] = "parameter-scan-inference"
inference_metadata["scan_job_id"] = run_id
@@ -1540,6 +2355,9 @@
"device": execution["device"],
"environment": execution["environment"],
"torch_version": execution["torchVersion"],
+ "requested_device": execution["requestedDevice"],
+ "fallback_used": execution["fallbackUsed"],
+ "fallback_reason": execution["fallbackReason"],
"raw_input_dir": relative_path(self.root, before_path.parent.parent),
"processed_input_dir": relative_path(self.root, processed_root),
}
@@ -1741,6 +2559,7 @@
metadata_path = output / "run_metadata.json"
if not metadata_path.is_file():
raise ApiError("3D point-cloud script finished without the expected result metadata.")
+ record_execution_metadata(metadata_path, execution)
metadata = load_json(metadata_path)
metadata["input_dir"] = relative_path(self.root, processed_root)
metadata["raw_input_dir"] = relative_path(self.root, raw_root)
@@ -1759,14 +2578,14 @@
source = next((item for item in pointcloud_annotation_sources(self.root) if item["id"] == source_id), None)
if not source:
raise ApiError("The selected generated annotation source is unavailable.")
- if len(labels) > MAX_ANNOTATION_LABELS:
- raise ApiError(f"An annotation revision accepts at most {MAX_ANNOTATION_LABELS} labelled points.")
+ active_classes = annotation_classes(self.root)
+ class_by_code = {item["code"]: item for item in active_classes}
compact: dict[int, int] = {}
for item in labels:
if not isinstance(item, list) or len(item) != 2 or not all(isinstance(value, int) for value in item):
raise ApiError("Each annotation label must be [pointIndex, classCode].")
index, code = item
- if index < 0 or index >= int(source["pointCount"]) or code not in POINTCLOUD_CLASS_CODES:
+ if index < 0 or index >= int(source["pointCount"]) or code not in class_by_code:
raise ApiError("Annotation contains an out-of-range point index or unsupported class code.")
compact[index] = code
if not compact:
@@ -1775,18 +2594,128 @@
location = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations" / annotation_id
location.mkdir(parents=True, exist_ok=False)
source_path = self.root / str(source["artifactRoot"]) / str(source["file"])
- class_counts = {str(code): sum(value == code for value in compact.values()) for code in sorted(POINTCLOUD_CLASS_CODES)}
+ class_counts = {str(code): sum(value == code for value in compact.values()) for code in sorted(class_by_code)}
document = {
"schema_version": 1, "id": annotation_id, "created_at": datetime.now(UTC).isoformat(),
"source_id": source_id, "source_path": str(source_path.resolve()), "source_sha256": source["sha256"],
"source_run_id": source["runId"], "point_count": int(source["pointCount"]),
"labels": [[index, code] for index, code in sorted(compact.items())], "class_counts": class_counts,
- "class_schema": {str(code): {"code": code} for code in sorted(POINTCLOUD_CLASS_CODES)},
+ "class_schema": {str(code): item for code, item in sorted(class_by_code.items())},
"provenance": "human_confirmed_point_labels_only",
}
path = location / "annotation.json"
path.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8")
return {"id": annotation_id, "sourceId": source_id, "path": relative_path(self.root, path), "labelCount": len(compact), "classCounts": class_counts, "createdAt": document["created_at"]}
+
+ def create_pointcloud_annotation_class(self, payload: dict[str, Any]) -> dict[str, Any]:
+ key, label, color = payload.get("key"), payload.get("label"), payload.get("color")
+ if not isinstance(key, str) or not isinstance(label, str) or not isinstance(color, list):
+ raise ApiError("A class key, label, and RGB color are required.")
+ current = annotation_classes(self.root)
+ used_codes = {item["code"] for item in current}
+ code = next((value for value in range(17, 256) if value not in used_codes), None)
+ if code is None:
+ raise ApiError("All LAS-compatible annotation class codes are already in use.")
+ candidate = {"code": code, "key": key, "label": label, "color": color, "builtIn": False}
+ updated = validate_annotation_classes([*current, candidate], allow_builtin=True)
+ write_annotation_classes(self.root, updated)
+ return next(item for item in updated if item["code"] == code)
+
+ def delete_pointcloud_annotation_class(self, code: int) -> int:
+ current = annotation_classes(self.root)
+ item = next((value for value in current if value["code"] == code), None)
+ if not item:
+ raise ApiError("The annotation class is unavailable.")
+ if item["builtIn"]:
+ raise ApiError("Built-in annotation classes cannot be deleted.")
+ if code in annotation_class_codes_in_use(self.root):
+ raise ApiError("This annotation class is used by a saved annotation revision and cannot be deleted.")
+ write_annotation_classes(self.root, [value for value in current if value["code"] != code])
+ return code
+
+ def create_pointcloud_annotation_source_run(self, payload: dict[str, Any]) -> dict[str, Any]:
+ name, staged_path, expected_sha256 = self.resolve_pointcloud_upload(payload.get("pointCloud"))
+ run_id = make_run_id("annotation-source")
+ raw_root = self.root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "annotation-source-runs" / run_id
+ processed_root = self.root / "shared" / "data" / "processed" / "05-3d-pointcloud" / "annotation-source-runs" / run_id
+ output = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "runs" / run_id
+ raw_root.mkdir(parents=True, exist_ok=False)
+ processed_root.mkdir(parents=True, exist_ok=False)
+ raw_path = raw_root / name
+ shutil.copyfile(staged_path, raw_path)
+ source_sha256 = file_sha256(raw_path)
+ if expected_sha256 and source_sha256 != expected_sha256:
+ raise ApiError("Uploaded point-cloud checksum changed while staging.")
+ processed_path = processed_root / name
+ shutil.copyfile(raw_path, processed_path)
+ shutil.rmtree(staged_path.parent)
+ python = self.root / ".venvs" / POINTCLOUD_CPU_ENVIRONMENT / "Scripts" / "python.exe"
+ if not python.is_file():
+ raise ApiError("3D point-cloud CPU virtual environment is unavailable. Run the capability setup first.")
+ job_id = uuid4().hex
+ job = {"id": job_id, "runId": run_id, "inputName": name, "status": "queued", "stage": "queued", "createdAt": datetime.now(UTC).isoformat(), "sourceSha256": source_sha256}
+ with POINTCLOUD_ANNOTATION_SOURCE_JOBS_LOCK:
+ POINTCLOUD_ANNOTATION_SOURCE_JOBS[job_id] = job
+ thread = threading.Thread(target=execute_pointcloud_annotation_source_job, args=(self.root, job_id, processed_path, output, source_sha256, raw_path.stat().st_size), daemon=True, name=f"annotation-source-{job_id[:8]}")
+ thread.start()
+ return dict(job)
+
+ def delete_pointcloud_annotation_source(self, source_id: str) -> dict[str, Any]:
+ plan = pointcloud_annotation_source_deletion_plan(self.root, source_id)
+ source = next(item for item in pointcloud_annotation_sources(self.root) if item["id"] == source_id)
+ run_id = str(plan["runId"])
+ sibling_ids = {item["id"] for item in pointcloud_annotation_sources(self.root) if item["runId"] == run_id}
+ output_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud").resolve()
+ annotation_root = output_root / "annotations"
+ annotations = [path.parent for path in annotation_root.glob("*/annotation.json") if load_json(path).get("source_id") in sibling_ids]
+ annotation_paths = {path / "annotation.json" for path in annotations}
+ training_root = output_root / "training-runs"
+ training = []
+ for metrics_path in training_root.glob("*/metrics.json"):
+ value = load_json(metrics_path).get("annotation")
+ try:
+ if isinstance(value, str) and Path(value).resolve() in annotation_paths:
+ training.append(metrics_path.parent)
+ except OSError:
+ continue
+ training_models = {path / "model.pt" for path in training}
+ inference_root = output_root / "model-inference-runs"
+ inference = []
+ for metadata_path in inference_root.glob("*/run_metadata.json"):
+ model = load_json(metadata_path).get("model")
+ value = model.get("path") if isinstance(model, dict) else None
+ try:
+ if isinstance(value, str) and Path(value).resolve() in training_models:
+ inference.append(metadata_path.parent)
+ except OSError:
+ continue
+ if active_pointcloud_source_dependencies(run_id, {path.name for path in annotations}, {path.name for path in training}):
+ raise ApiError("This data source has a queued or running dependent task. Wait for it to finish before removing the full data chain.")
+ artifact = (self.root / str(source["artifactRoot"])).resolve()
+ raw_root = self.root / "shared" / "data" / "raw" / "05-3d-pointcloud"
+ processed_root = self.root / "shared" / "data" / "processed" / "05-3d-pointcloud"
+ raw = [path for path in (raw_root / "annotation-source-runs" / run_id, raw_root / "runs" / run_id) if path.is_dir()]
+ processed = [path for path in (processed_root / "annotation-source-runs" / run_id, processed_root / "runs" / run_id) if path.is_dir()]
+ targets = [*inference, *training, *annotations, artifact, *raw, *processed]
+ unique: list[tuple[Path, Path]] = []
+ seen: set[Path] = set()
+ for target in targets:
+ resolved = target.resolve()
+ if resolved in seen:
+ continue
+ seen.add(resolved)
+ if target in raw:
+ allowed = raw_root
+ elif target in processed:
+ allowed = processed_root
+ else:
+ allowed = output_root
+ assert_removable_pointcloud_directory(self.root, target, allowed)
+ unique.append((target, allowed))
+ # Dependents first; every target was resolved against a fixed local root.
+ for target, _ in unique:
+ shutil.rmtree(target)
+ return {"sourceId": source_id, "runId": run_id, "removed": {"outputDirectories": int(plan["outputDirectories"]), "rawDirectories": int(plan["rawDirectories"]), "processedDirectories": int(plan["processedDirectories"]), "annotationRevisions": len(annotations), "trainingRuns": len(training), "inferenceRuns": len(inference), "siblingSources": int(plan["siblingSources"])}, "preservedExternalInputs": bool(plan["preservesExternalInputs"])}
def create_pointcloud_training_run(self, payload: dict[str, Any]) -> dict[str, Any]:
annotation_id = payload.get("annotationId")
@@ -1799,19 +2728,27 @@
record = load_json(annotation)
if record.get("schema_version") != 1:
raise ApiError("The selected annotation revision is unavailable.")
+ source_id = record.get("source_id")
+ source = next((item for item in pointcloud_annotation_sources(self.root) if item["id"] == source_id), None)
+ if not source:
+ raise ApiError("The annotation source is unavailable or its verification contract no longer passes.")
+ if source.get("sourceHasRgb") is False:
+ raise ApiError("This annotation source has no readable RGB values. It can be reviewed visually but cannot train the current RGB semantic model.")
+ trainer = "multiview_local_attention_baseline" if str(source.get("sourceKind", "")).startswith("多视角照片特征融合") else "rgb_xyz_baseline"
execution = pointcloud_execution_environment(self.root, device)
job_id = uuid4().hex
- output = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "training-runs" / make_run_id("semantic-model")
- job = {"id": job_id, "annotationId": annotation_id, "status": "queued", "stage": "queued", "requestedDevice": device, "device": execution["device"], "environment": execution["environment"], "torchVersion": execution["torchVersion"], "createdAt": datetime.now(UTC).isoformat()}
+ output = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "training-runs" / make_run_id("multiview-point-attention" if trainer == "multiview_local_attention_baseline" else "semantic-model")
+ job = {"id": job_id, "annotationId": annotation_id, "trainer": trainer, "status": "queued", "stage": "queued", "requestedDevice": execution["requestedDevice"], "device": execution["device"], "environment": execution["environment"], "torchVersion": execution["torchVersion"], "fallbackUsed": execution["fallbackUsed"], "fallbackReason": execution["fallbackReason"], "createdAt": datetime.now(UTC).isoformat()}
with POINTCLOUD_TRAINING_JOBS_LOCK:
POINTCLOUD_TRAINING_JOBS[job_id] = job
- thread = threading.Thread(target=execute_pointcloud_training_job, args=(self.root, job_id, annotation, output, execution), daemon=True, name=f"pointcloud-training-{job_id[:8]}")
+ thread = threading.Thread(target=execute_pointcloud_training_job, args=(self.root, job_id, annotation, output, execution, trainer), daemon=True, name=f"pointcloud-training-{job_id[:8]}")
thread.start()
return dict(job)
def create_pointcloud_model_inference_run(self, payload: dict[str, Any]) -> dict[str, Any]:
model_id = payload.get("modelId")
upload = payload.get("pointCloud")
+ device = payload.get("device", "auto")
if not isinstance(model_id, str) or SAFE_FILE_NAME.search(model_id) or len(model_id) > 120:
raise ApiError("Invalid trained model id.")
model_record = next((item for item in pointcloud_semantic_models(self.root) if item["id"] == model_id), None)
@@ -1822,7 +2759,9 @@
if Path(name).suffix.lower() not in suffixes:
raise ApiError("Model inference requires a PLY, PCD, XYZ, LAS, or LAZ point cloud.")
validate_pointcloud_model_input(staged_path)
- execution = pointcloud_execution_environment(self.root)
+ if not isinstance(device, str):
+ raise ApiError("Point-cloud device must be auto, cpu, or cuda.")
+ execution = pointcloud_execution_environment(self.root, device)
run_id = make_run_id("semantic-inference")
raw_root = self.root / "shared" / "data" / "raw" / "05-3d-pointcloud" / "model-inference-runs" / run_id
processed_root = self.root / "shared" / "data" / "processed" / "05-3d-pointcloud" / "model-inference-runs" / run_id
@@ -1845,12 +2784,149 @@
except ValueError as exc:
raise ApiError("Selected model is outside the allowed training output directory.") from exc
job_id = uuid4().hex
- job = {"id": job_id, "runId": run_id, "modelId": model_id, "inputName": name, "status": "queued", "stage": "queued", "requestedDevice": "auto", "device": execution["device"], "environment": execution["environment"], "torchVersion": execution["torchVersion"], "createdAt": datetime.now(UTC).isoformat(), "sourceSha256": actual_sha256, "rawInput": relative_path(self.root, raw_path), "processedInput": relative_path(self.root, processed_path)}
+ job = {"id": job_id, "runId": run_id, "modelId": model_id, "inputName": name, "status": "queued", "stage": "queued", "requestedDevice": execution["requestedDevice"], "device": execution["device"], "environment": execution["environment"], "torchVersion": execution["torchVersion"], "fallbackUsed": execution["fallbackUsed"], "fallbackReason": execution["fallbackReason"], "createdAt": datetime.now(UTC).isoformat(), "sourceSha256": actual_sha256, "rawInput": relative_path(self.root, raw_path), "processedInput": relative_path(self.root, processed_path)}
with POINTCLOUD_INFERENCE_JOBS_LOCK:
POINTCLOUD_INFERENCE_JOBS[job_id] = job
thread = threading.Thread(target=execute_pointcloud_inference_job, args=(self.root, job_id, model_path, processed_path, output, execution), daemon=True, name=f"pointcloud-inference-{job_id[:8]}")
thread.start()
return dict(job)
+
+ def create_pointcloud_auto_annotation_run(self, payload: dict[str, Any]) -> dict[str, Any]:
+ model_id, source_id, device, confidence = payload.get("modelId"), payload.get("sourceId"), payload.get("device", "auto"), payload.get("candidateConfidence", 0.95)
+ if not isinstance(model_id, str) or SAFE_FILE_NAME.search(model_id) or len(model_id) > 120:
+ raise ApiError("Invalid trained model id.")
+ if not isinstance(source_id, str):
+ raise ApiError("Automatic annotation needs a selected local annotation source.")
+ if not isinstance(device, str) or device not in COMPUTE_DEVICES:
+ raise ApiError("Point-cloud device must be auto, cpu, or cuda.")
+ if not isinstance(confidence, (int, float)) or isinstance(confidence, bool) or not 0.5 <= float(confidence) < 1.0:
+ raise ApiError("Candidate confidence must be between 0.5 and 1.0.")
+ model_record = next((item for item in pointcloud_semantic_models(self.root) if item["id"] == model_id), None)
+ source = next((item for item in pointcloud_annotation_sources(self.root) if item["id"] == source_id), None)
+ if not model_record or not source:
+ raise ApiError("The selected model or annotation source is unavailable.")
+ if source.get("sourceHasRgb") is False:
+ raise ApiError("Automatic annotation needs observed RGB point features.")
+ model_path = (self.root / str(model_record["model"])).resolve()
+ source_path = (self.root / str(source["artifactRoot"]) / str(source["file"])).resolve()
+ training_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud" / "training-runs").resolve()
+ output_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud").resolve()
+ try:
+ model_path.relative_to(training_root)
+ source_path.relative_to(output_root)
+ except ValueError as exc:
+ raise ApiError("Selected automatic-annotation inputs are outside local workbench outputs.") from exc
+ if not model_path.is_file() or not source_path.is_file() or file_sha256(source_path) != source["sha256"]:
+ raise ApiError("Selected automatic-annotation input no longer passes its verification contract.")
+ execution = pointcloud_execution_environment(self.root, device)
+ run_id = make_run_id("auto-annotation")
+ output = output_root / "auto-annotation-runs" / run_id
+ job_id = uuid4().hex
+ job = {"id": job_id, "runId": run_id, "modelId": model_id, "sourceId": source_id, "inputName": str(source["file"]), "candidateConfidence": float(confidence), "status": "queued", "stage": "queued", "requestedDevice": execution["requestedDevice"], "device": execution["device"], "environment": execution["environment"], "torchVersion": execution["torchVersion"], "fallbackUsed": execution["fallbackUsed"], "fallbackReason": execution["fallbackReason"], "createdAt": datetime.now(UTC).isoformat(), "sourceSha256": source["sha256"]}
+ with POINTCLOUD_INFERENCE_JOBS_LOCK:
+ POINTCLOUD_INFERENCE_JOBS[job_id] = job
+ thread = threading.Thread(target=execute_pointcloud_inference_job, args=(self.root, job_id, model_path, source_path, output, execution, source_id, float(confidence)), daemon=True, name=f"auto-annotation-{job_id[:8]}")
+ thread.start()
+ return dict(job)
+
+ def save_pointcloud_auto_annotation_review(self, payload: dict[str, Any]) -> dict[str, Any]:
+ run_id, source_id, corrections = payload.get("runId"), payload.get("sourceId"), payload.get("corrections")
+ if not isinstance(run_id, str) or SAFE_FILE_NAME.search(run_id) or len(run_id) > 120:
+ raise ApiError("Invalid automatic-annotation run id.")
+ if not isinstance(source_id, str) or not isinstance(corrections, list):
+ raise ApiError("Automatic candidate review needs a source id and corrections array.")
+ source = pointcloud_annotation_source(self.root, source_id)
+ output_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud" / "auto-annotation-runs").resolve()
+ artifact = (output_root / run_id).resolve()
+ candidate_path = artifact / "automatic-annotation-candidates.json"
+ try:
+ artifact.relative_to(output_root)
+ except ValueError as exc:
+ raise ApiError("Automatic candidate review path is outside local workbench outputs.") from exc
+ candidate = load_json(candidate_path)
+ if candidate.get("schema_version") != 1 or candidate.get("source_id") != source_id or candidate.get("source_sha256") != source["sha256"]:
+ raise ApiError("Automatic candidate provenance no longer matches the selected source.")
+ active_codes = {item["code"] for item in annotation_classes(self.root)}
+ compact: dict[int, int] = {}
+ for item in corrections:
+ if not isinstance(item, list) or len(item) != 2 or not all(isinstance(value, int) for value in item):
+ raise ApiError("Each candidate correction must be [pointIndex, classCodeOrZero].")
+ index, code = item
+ if index < 0 or index >= int(source["pointCount"]) or (code != 0 and code not in active_codes):
+ raise ApiError("Candidate review contains an out-of-range point index or unsupported class code.")
+ compact[index] = code
+ review = {
+ "schema_version": 1,
+ "run_id": run_id,
+ "source_id": source_id,
+ "source_sha256": source["sha256"],
+ "candidate_confidence": candidate.get("candidate_confidence"),
+ "corrections": [[index, code] for index, code in sorted(compact.items())],
+ "saved_at": datetime.now(UTC).isoformat(),
+ }
+ (artifact / "review-corrections.json").write_text(json.dumps(review, ensure_ascii=False, indent=2), encoding="utf-8")
+ return {"runId": run_id, "sourceId": source_id, "correctionCount": len(compact), "savedAt": review["saved_at"]}
+
+ def accept_pointcloud_auto_annotation(self, payload: dict[str, Any]) -> dict[str, Any]:
+ run_id, source_id, base_annotation_id = payload.get("runId"), payload.get("sourceId"), payload.get("baseAnnotationId")
+ if not isinstance(run_id, str) or SAFE_FILE_NAME.search(run_id) or len(run_id) > 120:
+ raise ApiError("Invalid automatic-annotation run id.")
+ if not isinstance(source_id, str):
+ raise ApiError("Automatic-annotation acceptance needs a source id.")
+ source = pointcloud_annotation_source(self.root, source_id)
+ output_root = (self.root / "shared" / "outputs" / "05-3d-pointcloud" / "auto-annotation-runs").resolve()
+ candidate_path = (output_root / run_id / "automatic-annotation-candidates.json").resolve()
+ try:
+ candidate_path.relative_to(output_root)
+ except ValueError as exc:
+ raise ApiError("Automatic candidate path is outside local workbench outputs.") from exc
+ candidate = load_json(candidate_path)
+ if candidate.get("schema_version") != 1 or candidate.get("source_id") != source_id or candidate.get("source_sha256") != source["sha256"]:
+ raise ApiError("Automatic candidate provenance no longer matches the selected source.")
+ raw_labels = candidate.get("labels")
+ if not isinstance(raw_labels, list):
+ raise ApiError("Automatic candidate labels are unavailable.")
+ merged: dict[int, int] = {}
+ for item in raw_labels:
+ if not isinstance(item, list) or len(item) != 3 or not isinstance(item[0], int) or not isinstance(item[1], int):
+ raise ApiError("Automatic candidate labels are invalid.")
+ merged[item[0]] = item[1]
+ review_path = candidate_path.parent / "review-corrections.json"
+ review_correction_count = 0
+ if review_path.is_file():
+ review = load_json(review_path)
+ if review.get("schema_version") != 1 or review.get("run_id") != run_id or review.get("source_id") != source_id or review.get("source_sha256") != source["sha256"]:
+ raise ApiError("Candidate review draft provenance no longer matches the selected source.")
+ corrections = review.get("corrections")
+ if not isinstance(corrections, list):
+ raise ApiError("Candidate review corrections are invalid.")
+ valid_codes = {item["code"] for item in annotation_classes(self.root)}
+ for item in corrections:
+ if not isinstance(item, list) or len(item) != 2 or not all(isinstance(value, int) for value in item):
+ raise ApiError("Candidate review corrections are invalid.")
+ index, code = item
+ if index < 0 or index >= int(source["pointCount"]) or (code != 0 and code not in valid_codes):
+ raise ApiError("Candidate review contains an out-of-range point index or unsupported class code.")
+ if code == 0:
+ merged.pop(index, None)
+ else:
+ merged[index] = code
+ review_correction_count += 1
+ if isinstance(base_annotation_id, str) and base_annotation_id:
+ base_path = self.root / "shared" / "outputs" / "05-3d-pointcloud" / "annotations" / base_annotation_id / "annotation.json"
+ base = load_json(base_path)
+ if base.get("source_id") != source_id:
+ raise ApiError("The base human annotation belongs to another source.")
+ for item in base.get("labels", []):
+ if isinstance(item, list) and len(item) == 2 and all(isinstance(value, int) for value in item):
+ merged[item[0]] = item[1]
+ annotation = self.create_pointcloud_annotation({"sourceId": source_id, "labels": [[index, code] for index, code in merged.items()]})
+ annotation_path = self.root / str(annotation["path"])
+ document = load_json(annotation_path)
+ document["provenance"] = "user_confirmed_high_confidence_model_candidates_with_human_labels_preferred"
+ document["automatic_annotation"] = {"run_id": run_id, "candidate_confidence": candidate.get("candidate_confidence"), "candidate_count": candidate.get("candidate_count"), "review_correction_count": review_correction_count, "base_annotation_id": base_annotation_id or None}
+ annotation_path.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8")
+ return annotation
def create_photo_reconstruction_run(self, payload: dict[str, Any]) -> dict[str, Any]:
uploads = payload.get("photos")
@@ -1891,7 +2967,9 @@
for _, staged_path, _ in photos:
shutil.rmtree(staged_path.parent)
- job = {"id": job_id, "runId": run_id, "status": "queued", "stage": "queued", "createdAt": datetime.now(UTC).isoformat(), "inputImages": len(photos), "usePositionPriors": use_position_priors}
+ progress_path = processed_root / "photo_reconstruction_progress.json"
+ progress_path.write_text(json.dumps({"percent": 0, "stage": "queued", "message": "照片已保存,正在等待 CPU 重建资源。", "inputImages": len(photos), "updatedAt": datetime.now(UTC).isoformat(), "estimate": True}, ensure_ascii=False), encoding="utf-8")
+ job = {"id": job_id, "runId": run_id, "status": "queued", "stage": "queued", "createdAt": datetime.now(UTC).isoformat(), "inputImages": len(photos), "usePositionPriors": use_position_priors, "progressPath": str(progress_path)}
with PHOTO_RECONSTRUCTION_JOBS_LOCK:
PHOTO_RECONSTRUCTION_JOBS[job_id] = job
thread = threading.Thread(
@@ -1901,7 +2979,7 @@
name=f"photo-reconstruction-{run_id}",
)
thread.start()
- return dict(job)
+ return photo_reconstruction_job(job_id) or {key: value for key, value in job.items() if key != "progressPath"}
def create_risk_rule_run(self, payload: dict[str, Any]) -> dict[str, Any]:
files = payload.get("files")
--
Gitblit v1.9.3