feat: finalize corrected-route planning and Rerun recording review
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
"""Content-addressed, paired display derivatives, never planning references.
|
||||
|
||||
Only an offline publisher may attach an explicitly reviewed version to a source
|
||||
overview. HTTP callers select a digest, not filesystem paths or a mutable latest
|
||||
map. Both representations preserve point/pose correspondence and share colors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.reconstruction.map_version import json_bytes
|
||||
|
||||
SCHEMA = "missioncore.overview-comparison/v1"
|
||||
MAX_POINTS = 180_000
|
||||
MAX_POSES = 20_000
|
||||
MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def _digest(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[a-f0-9]{64}", value):
|
||||
raise ValueError("Invalid comparison identity.")
|
||||
return value
|
||||
|
||||
|
||||
def _read(path, maximum):
|
||||
if path.is_symlink() or path.stat().st_size > maximum:
|
||||
raise ValueError("Invalid comparison artifact.")
|
||||
with path.open("rb") as stream:
|
||||
value = stream.read(maximum + 1)
|
||||
if len(value) > maximum:
|
||||
raise ValueError("Comparison exceeds display budget.")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OverviewComparison:
|
||||
generation: str
|
||||
document: dict
|
||||
original: np.ndarray
|
||||
corrected: np.ndarray
|
||||
original_route: np.ndarray
|
||||
corrected_route: np.ndarray
|
||||
|
||||
@property
|
||||
def bounds(self):
|
||||
# Only bounds are combined; there is no correspondence or fitting here.
|
||||
return np.array(
|
||||
[
|
||||
self.original.min(axis=0),
|
||||
self.original.max(axis=0),
|
||||
self.corrected.min(axis=0),
|
||||
self.corrected.max(axis=0),
|
||||
]
|
||||
)
|
||||
|
||||
def metadata(self):
|
||||
return dict(
|
||||
generation=self.generation,
|
||||
map_generation=self.document["map_generation"],
|
||||
sample_points=len(self.original),
|
||||
source_points=self.document["source_points"],
|
||||
height_min_m=min(-3.0, float(self.bounds[:, 2].min())),
|
||||
height_max_m=max(80.0, float(self.bounds[:, 2].max())),
|
||||
original_path_m=self.document["original_path_m"],
|
||||
corrected_path_m=self.document["corrected_path_m"],
|
||||
)
|
||||
|
||||
|
||||
def _arrays(payload):
|
||||
# np.savez (uncompressed) is deliberate: reject compressed archive bombs.
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||||
if len(archive.infolist()) != 4 or any(
|
||||
x.compress_type != zipfile.ZIP_STORED for x in archive.infolist()
|
||||
):
|
||||
raise ValueError("Invalid comparison array archive.")
|
||||
with np.load(io.BytesIO(payload), allow_pickle=False) as data:
|
||||
result = [
|
||||
np.array(data[k])
|
||||
for k in ("original", "corrected", "original_route", "corrected_route")
|
||||
]
|
||||
a, b, p, q = result
|
||||
if (
|
||||
a.shape != b.shape
|
||||
or p.shape != q.shape
|
||||
or not 1 <= len(a) <= MAX_POINTS
|
||||
or not 2 <= len(p) <= MAX_POSES
|
||||
or any(
|
||||
x.ndim != 2
|
||||
or x.shape[1:] != (3,)
|
||||
or x.dtype != np.dtype("<f4")
|
||||
or not np.isfinite(x).all()
|
||||
for x in result
|
||||
)
|
||||
):
|
||||
raise ValueError("Invalid paired comparison geometry.")
|
||||
return result
|
||||
|
||||
|
||||
def load_comparison(
|
||||
root: Path,
|
||||
overview_generation: str,
|
||||
session_id: str,
|
||||
source_digests: dict,
|
||||
generation: str | None = None,
|
||||
):
|
||||
root = Path(root)
|
||||
_digest(overview_generation)
|
||||
pointer = root / "selected" / f"{overview_generation}.json"
|
||||
if generation is None:
|
||||
if not pointer.exists():
|
||||
return None
|
||||
generation = json.loads(_read(pointer, 4096))["generation"]
|
||||
_digest(generation)
|
||||
directory = root / generation
|
||||
if directory.is_symlink():
|
||||
raise ValueError("Comparison directory must not be a link.")
|
||||
payload = _read(directory / "manifest.json", 32_768)
|
||||
if hashlib.sha256(payload).hexdigest() != generation:
|
||||
raise ValueError("Comparison manifest changed.")
|
||||
doc = json.loads(payload)
|
||||
if (
|
||||
doc.get("schema_version") != SCHEMA
|
||||
or doc.get("session_id") != session_id
|
||||
or doc.get("overview_generation") != overview_generation
|
||||
or doc.get("source_digests") != source_digests
|
||||
or doc.get("view_only") is not True
|
||||
):
|
||||
raise ValueError("Comparison belongs to another recording generation.")
|
||||
_digest(doc["map_generation"])
|
||||
arrays = _read(directory / "geometry.npz", MAX_BYTES)
|
||||
if hashlib.sha256(arrays).hexdigest() != doc["geometry_sha256"]:
|
||||
raise ValueError("Comparison geometry changed.")
|
||||
return OverviewComparison(generation, doc, *_arrays(arrays))
|
||||
|
||||
|
||||
def publish_comparison(
|
||||
root,
|
||||
*,
|
||||
session_id,
|
||||
overview_generation,
|
||||
source_digests,
|
||||
map_generation,
|
||||
source_points,
|
||||
original_path_m,
|
||||
corrected_path_m,
|
||||
original,
|
||||
corrected,
|
||||
original_route,
|
||||
corrected_route,
|
||||
):
|
||||
"""Publish a view-only pair; no catalog or mission mutation, no implicit promotion."""
|
||||
root = Path(root)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
_digest(overview_generation)
|
||||
_digest(map_generation)
|
||||
with TemporaryDirectory(prefix=".comparison-", dir=root) as temporary:
|
||||
stage = Path(temporary)
|
||||
np.savez(
|
||||
stage / "geometry.npz",
|
||||
**{
|
||||
name: np.asarray(value, dtype="<f4")
|
||||
for name, value in (
|
||||
("original", original),
|
||||
("corrected", corrected),
|
||||
("original_route", original_route),
|
||||
("corrected_route", corrected_route),
|
||||
)
|
||||
},
|
||||
)
|
||||
payload = _read(stage / "geometry.npz", MAX_BYTES)
|
||||
_arrays(payload)
|
||||
doc = dict(
|
||||
schema_version=SCHEMA,
|
||||
session_id=session_id,
|
||||
overview_generation=overview_generation,
|
||||
source_digests=source_digests,
|
||||
map_generation=map_generation,
|
||||
source_points=source_points,
|
||||
original_path_m=original_path_m,
|
||||
corrected_path_m=corrected_path_m,
|
||||
view_only=True,
|
||||
geometry_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
)
|
||||
manifest = json_bytes(doc)
|
||||
generation = hashlib.sha256(manifest).hexdigest()
|
||||
(stage / "manifest.json").write_bytes(manifest)
|
||||
if not (root / generation).exists():
|
||||
stage.rename(root / generation)
|
||||
load_comparison(root, overview_generation, session_id, source_digests, generation)
|
||||
pointers = root / "selected"
|
||||
pointers.mkdir(exist_ok=True)
|
||||
temporary = pointers / f".{uuid4().hex}.json"
|
||||
try:
|
||||
temporary.write_bytes(json_bytes(dict(generation=generation)))
|
||||
os.replace(temporary, pointers / f"{overview_generation}.json")
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return generation
|
||||
Reference in New Issue
Block a user