212 lines
6.8 KiB
Python
212 lines
6.8 KiB
Python
"""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
|