refactor(lab): перевести RAV004 на канонический Rerun pipeline

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 12:59:16 +03:00
parent e9ffb829c9
commit f5ee42751d
30 changed files with 1425 additions and 288 deletions
@@ -0,0 +1,626 @@
"""Native Rerun sidecar for immutable recorded laboratory evidence.
The sidecar deliberately contains only evidence missing from the canonical K1
recording: camera video, semantic images and diagnostic 2D boxes. The base RRD
continues to own poses, point clouds and trajectory. Both files use the same
Rerun recording id and ``session_time`` timeline, so the upstream viewer is the
only playback clock and the only spatial renderer.
"""
from __future__ import annotations
import hashlib
import io
import json
import os
import subprocess
import tempfile
import threading
import zipfile
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any, Final
from uuid import uuid4
import numpy as np
import rerun as rr
import rerun_bindings as bindings
from PIL import Image
APPLICATION_ID: Final = "nodedc_mission_core_recorded"
SESSION_TIMELINE: Final = "session_time"
RENDERER_VERSION: Final = "upstream-rerun-0.36.3-v2"
MAX_SOURCE_BYTES: Final = 768 * 1024 * 1024
MAX_OVERLAY_BYTES: Final = 512 * 1024 * 1024
class CanonicalLabOverlayError(RuntimeError):
"""A sealed LAB result could not be projected into a native Rerun sidecar."""
@dataclass(frozen=True, slots=True)
class CanonicalLabOverlayArtifact:
path: Path
byte_length: int
sha256: str
_render_lock = threading.Lock()
_memory_cache: dict[tuple[str, str, str], CanonicalLabOverlayArtifact] = {}
def canonical_recording_id(path: Path) -> str:
"""Read the single data-store identity from a sealed base RRD."""
try:
entries = bindings.RrdReaderInternal(str(path.resolve(strict=True))).store_entries()
matches = [
entry.recording_id
for entry in entries
if entry.kind == "recording" and entry.application_id == APPLICATION_ID
]
except Exception as exc:
raise CanonicalLabOverlayError("canonical recording identity is unavailable") from exc
if len(matches) != 1 or not matches[0] or len(matches[0]) > 128:
raise CanonicalLabOverlayError("canonical recording identity is ambiguous")
return str(matches[0])
def canonical_lab_overlay(
result_root: Path,
manifest: dict[str, Any],
*,
recording_id: str,
base_generation_sha256: str,
jobs_root: Path,
cache_root: Path,
ffmpeg_path: Path,
) -> CanonicalLabOverlayArtifact:
"""Return one cached, digest-bound RRD sidecar for a full-route LAB result."""
root = result_root.expanduser().resolve(strict=True)
jobs = jobs_root.expanduser().resolve(strict=True)
ffmpeg = ffmpeg_path.expanduser().resolve(strict=True)
cache = cache_root.expanduser().absolute()
result_id = str(manifest.get("result_id", ""))
result_path = root / "result.json"
result_sha256 = _sha256(result_path)
if (
root.name != result_id
or not result_id.startswith("lab-v1-vegetation-shadow-")
or len(result_id) != len("lab-v1-vegetation-shadow-") + 64
or not recording_id
or len(recording_id) > 128
or not _is_sha256(base_generation_sha256)
or not ffmpeg.is_file()
or not os.access(ffmpeg, os.X_OK)
):
raise CanonicalLabOverlayError("canonical LAB overlay identity is invalid")
key = (result_id, recording_id, base_generation_sha256)
cached = _memory_cache.get(key)
if cached is not None and _artifact_is_regular(cached):
return cached
with _render_lock:
cached = _memory_cache.get(key)
if cached is not None and _artifact_is_regular(cached):
return cached
cache.mkdir(parents=True, exist_ok=True)
if cache.is_symlink() or not cache.is_dir():
raise CanonicalLabOverlayError("canonical LAB overlay cache is invalid")
identity = hashlib.sha256(
"\0".join(
(RENDERER_VERSION, result_sha256, recording_id, base_generation_sha256)
).encode()
).hexdigest()
output = cache / f"{identity}.rrd"
sidecar = cache / f"{identity}.json"
restored = _restore_cached(
output,
sidecar,
result_id=result_id,
result_sha256=result_sha256,
recording_id=recording_id,
base_generation_sha256=base_generation_sha256,
)
if restored is not None:
_memory_cache[key] = restored
return restored
temporary = cache / f".{identity}.{uuid4().hex}.rrd"
proxy: Path | None = None
source: Path | None = None
try:
route = _full_route(manifest)
source = _verified_camera_source(root, route, jobs)
proxy = _camera_proxy(source, int(route["frame_count"]), ffmpeg, cache)
_render_overlay(temporary, root, route, recording_id, proxy)
stat = temporary.stat()
if stat.st_size < 4 or stat.st_size > MAX_OVERLAY_BYTES:
raise CanonicalLabOverlayError("canonical LAB overlay size is invalid")
digest = _sha256(temporary)
os.chmod(temporary, 0o600)
os.replace(temporary, output)
_write_json_atomic(
sidecar,
{
"schema_version": "missioncore.canonical-lab-rerun-overlay/v1",
"renderer_version": RENDERER_VERSION,
"result_id": result_id,
"result_sha256": result_sha256,
"recording_id": recording_id,
"base_generation_sha256": base_generation_sha256,
"byte_length": stat.st_size,
"sha256": digest,
},
)
artifact = CanonicalLabOverlayArtifact(output, stat.st_size, digest)
_memory_cache[key] = artifact
return artifact
except CanonicalLabOverlayError:
raise
except Exception as exc:
raise CanonicalLabOverlayError("failed to render canonical LAB overlay") from exc
finally:
temporary.unlink(missing_ok=True)
if proxy is not None:
proxy.unlink(missing_ok=True)
if source is not None:
source.unlink(missing_ok=True)
def _full_route(manifest: dict[str, Any]) -> dict[str, Any]:
route = manifest.get("route_full_review")
layers = route.get("layers") if isinstance(route, dict) else None
if (
not isinstance(route, dict)
or route.get("source_id") != "RAVNOVES004TREE"
or route.get("frame_count") != 6830
or route.get("width") != 800
or route.get("height") != 600
or not isinstance(layers, dict)
or set(layers) != {"city", "vegetation"}
):
raise CanonicalLabOverlayError("full-route LAB contract is unavailable")
return route
def _verified_camera_source(root: Path, route: dict[str, Any], jobs_root: Path) -> Path:
source_job_id = route.get("source_job_id")
proof = route.get("proofs", {}).get("job") if isinstance(route.get("proofs"), dict) else None
if (
not isinstance(source_job_id, str)
or not source_job_id.startswith("recorded-camera-")
or not isinstance(proof, dict)
or proof.get("path") != "proofs/job.json"
or not _is_sha256(proof.get("sha256"))
):
raise CanonicalLabOverlayError("camera job binding is invalid")
proof_path = root / "proofs" / "job.json"
job_root = (jobs_root / source_job_id).resolve(strict=True)
job_path = job_root / "job.json"
if (
not job_root.is_relative_to(jobs_root)
or _sha256(proof_path) != proof["sha256"]
or _sha256(job_path) != proof["sha256"]
):
raise CanonicalLabOverlayError("camera job proof changed")
job = json.loads(job_path.read_text(encoding="utf-8"))
source = job.get("input") if isinstance(job, dict) else None
files = source.get("files") if isinstance(source, dict) else None
frame_count = int(route["frame_count"])
if (
not isinstance(source, dict)
or source.get("session_id") != route.get("session_id")
or source.get("source_id") != "sensor.camera.right"
or source.get("segment_count") != frame_count
or source.get("byte_length", 0) > MAX_SOURCE_BYTES
or not isinstance(files, list)
):
raise CanonicalLabOverlayError("camera job source contract changed")
epoch_prefix = PurePosixPath(
"input/camera/sensor.camera.right"
) / f"epoch-{source.get('codec_epoch')}"
required = [epoch_prefix / "init.mp4"] + [
epoch_prefix / "segments" / f"{index}.m4s"
for index in range(1, frame_count + 1)
]
descriptors = {
item.get("path"): item
for item in files
if isinstance(item, dict) and isinstance(item.get("path"), str)
}
temporary_descriptor, temporary_name = tempfile.mkstemp(
prefix=".canonical-lab-source-", suffix=".mp4", dir=root.parent
)
output = Path(temporary_name)
total = 0
try:
with os.fdopen(temporary_descriptor, "wb") as stream:
for relative in required:
descriptor = descriptors.get(str(relative))
path = (job_root / relative).resolve(strict=True)
if (
descriptor is None
or not path.is_relative_to(job_root)
or path.is_symlink()
or descriptor.get("byte_length") != path.stat().st_size
or not _is_sha256(descriptor.get("sha256"))
):
raise CanonicalLabOverlayError("camera fragment contract changed")
digest = hashlib.sha256()
with path.open("rb") as source_stream:
while chunk := source_stream.read(1024 * 1024):
digest.update(chunk)
stream.write(chunk)
total += len(chunk)
if digest.hexdigest() != descriptor["sha256"]:
raise CanonicalLabOverlayError("camera fragment digest changed")
stream.flush()
os.fsync(stream.fileno())
if total <= 0 or total > MAX_SOURCE_BYTES:
raise CanonicalLabOverlayError("camera source size is invalid")
return output
except BaseException:
output.unlink(missing_ok=True)
raise
def _camera_proxy(source: Path, frame_count: int, ffmpeg: Path, root: Path) -> Path:
descriptor, name = tempfile.mkstemp(prefix=".canonical-lab-video-", suffix=".mp4", dir=root)
os.close(descriptor)
output = Path(name)
output.unlink(missing_ok=True)
completed = subprocess.run(
[
str(ffmpeg),
"-hide_banner",
"-loglevel",
"error",
"-i",
str(source),
"-frames:v",
str(frame_count),
"-an",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"28",
"-g",
"20",
"-keyint_min",
"20",
"-pix_fmt",
"yuv420p",
"-fps_mode",
"passthrough",
"-movflags",
"+faststart",
str(output),
],
check=False,
capture_output=True,
timeout=900,
)
if completed.returncode != 0 or not output.is_file() or output.stat().st_size <= 0:
output.unlink(missing_ok=True)
raise CanonicalLabOverlayError(
f"canonical LAB video proxy failed: {completed.stderr[-1000:]!r}"
)
os.chmod(output, 0o600)
return output
def _render_overlay(
output: Path,
root: Path,
route: dict[str, Any],
recording_id: str,
proxy: Path,
) -> None:
frame_times = _frame_times(root, route)
layers = route["layers"]
archives: dict[str, zipfile.ZipFile] = {}
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
try:
recording.set_sinks(rr.FileSink(output, write_footer=True))
recording.log(
"/perception/camera/image",
rr.AssetVideo(path=proxy),
static=True,
)
video = rr.AssetVideo(path=proxy)
video_timestamps = video.read_frame_timestamps_nanos()
# The sealed fMP4 source has one fragment per LAB tick, but not every
# fragment contains a decodable video sample. Preserve the source PTS
# in the proxy and bind every LAB tick to the latest available sample.
# Re-numbering decoded samples at 10 Hz shortens RAV004 by ~43 seconds
# and is exactly the camera/semantics drift this projection prevents.
video_references = _video_reference_timestamps(video_timestamps, frame_times)
for layer_id in ("city", "vegetation"):
layer = layers[layer_id]
taxonomy = layer.get("taxonomy")
classes = taxonomy.get("classes") if isinstance(taxonomy, dict) else None
if not isinstance(classes, list):
raise CanonicalLabOverlayError("semantic taxonomy is invalid")
context = rr.AnnotationContext(
[
rr.ClassDescription(
info=rr.AnnotationInfo(
id=int(item["class_id"]),
label=str(item["label"]),
color=[*map(int, item["color_rgb"]), 255],
)
)
for item in classes
if isinstance(item, dict)
]
)
recording.log(
f"/perception/camera/segmentation/{layer_id}",
context,
static=True,
)
archive = layer.get("mask_archive")
relative = archive.get("path") if isinstance(archive, dict) else None
if not isinstance(relative, str):
raise CanonicalLabOverlayError("semantic archive is unavailable")
path = (root / PurePosixPath(relative)).resolve(strict=True)
if not path.is_relative_to(root) or path.is_symlink():
raise CanonicalLabOverlayError("semantic archive path is unsafe")
archives[layer_id] = zipfile.ZipFile(path)
for index, timestamp in enumerate(frame_times):
recording.set_time(SESSION_TIMELINE, duration=np.timedelta64(timestamp, "ns"))
recording.log(
"/perception/camera/image",
rr.VideoFrameReference(nanoseconds=int(video_references[index])),
)
masks = {
layer_id: _read_mask(archive, index)
for layer_id, archive in archives.items()
}
for layer_id, mask in masks.items():
recording.log(
f"/perception/camera/segmentation/{layer_id}",
rr.SegmentationImage(mask, opacity=0.72, draw_order=1.0),
)
boxes, labels = semantic_component_boxes(masks["city"], index)
if boxes:
recording.log(
"/perception/camera/detections",
rr.Boxes2D(
array=boxes,
array_format=rr.Box2DFormat.XYXY,
labels=labels,
show_labels=True,
colors=[[255, 210, 55, 255]] * len(boxes),
),
)
else:
recording.log(
"/perception/camera/detections",
rr.Clear(recursive=False),
)
recording.flush(timeout_sec=300.0)
finally:
for archive in archives.values():
with suppress(Exception):
archive.close()
with suppress(Exception):
recording.disconnect()
def _frame_times(root: Path, route: dict[str, Any]) -> np.ndarray:
descriptor = route.get("timeline")
relative = descriptor.get("path") if isinstance(descriptor, dict) else None
if not isinstance(relative, str):
raise CanonicalLabOverlayError("LAB timeline is unavailable")
path = (root / PurePosixPath(relative)).resolve(strict=True)
payload = path.read_bytes()
if (
not path.is_relative_to(root)
or path.is_symlink()
or descriptor.get("byte_length") != len(payload)
or descriptor.get("sha256") != hashlib.sha256(payload).hexdigest()
):
raise CanonicalLabOverlayError("LAB timeline changed")
values = np.frombuffer(payload, dtype="<u8").astype(np.int64, copy=False)
if values.shape != (route["frame_count"],) or np.any(np.diff(values) <= 0):
raise CanonicalLabOverlayError("LAB timeline order changed")
return values
def _video_reference_timestamps(
video_timestamps: np.ndarray,
frame_times: np.ndarray,
) -> np.ndarray:
"""Bind every LAB tick to the latest decodable source video sample."""
relative_frame_times = frame_times - frame_times[0]
if (
len(video_timestamps) < int(len(frame_times) * 0.9)
or np.any(np.diff(video_timestamps) < 0)
or abs(int(video_timestamps[-1]) - int(relative_frame_times[-1]))
> 2_000_000_000
):
raise CanonicalLabOverlayError("video proxy timeline changed")
indices = np.searchsorted(
video_timestamps,
relative_frame_times,
side="right",
) - 1
return video_timestamps[np.clip(indices, 0, len(video_timestamps) - 1)]
def _read_mask(archive: zipfile.ZipFile, sequence: int) -> np.ndarray:
member = f"masks/frame-{sequence + 1:06d}.png"
with Image.open(io.BytesIO(archive.read(member))) as image:
mask = np.asarray(image.convert("L"), dtype=np.uint8).copy()
if mask.shape != (600, 800):
raise CanonicalLabOverlayError("semantic mask dimensions changed")
return mask
def semantic_component_boxes(
mask: np.ndarray,
_sequence: int,
) -> tuple[list[list[int]], list[str]]:
labels = {
1: "person",
2: "bicycle",
3: "motorcycle",
4: "car",
5: "heavy vehicle",
13: "static obstacle",
14: "animal",
}
candidates: list[tuple[float, list[int], str]] = []
for class_id, label in labels.items():
minimum_pixels = 80 if class_id == 13 else 24
for left, top, right, bottom, pixels in _mask_component_boxes(
mask, class_id, minimum_pixels=minimum_pixels
)[:12]:
score = min(0.99, 0.5 + pixels / 20_000)
candidates.append(
(score, [left, top, right, bottom], f"{label} · {score:.0%} · semantic-only")
)
candidates.sort(key=lambda row: (-row[0], row[1][1], row[1][0]))
selected = candidates[:32]
return [row[1] for row in selected], [row[2] for row in selected]
def _mask_component_boxes(
mask: np.ndarray,
class_id: int,
*,
minimum_pixels: int,
) -> list[tuple[int, int, int, int, int]]:
"""Return deterministic 8-connected run-length components."""
if mask.ndim != 2 or minimum_pixels < 1:
return []
parents: list[int] = []
runs: list[tuple[int, int, int, int]] = []
def root(index: int) -> int:
while parents[index] != index:
parents[index] = parents[parents[index]]
index = parents[index]
return index
def union(left: int, right: int) -> None:
left_root = root(left)
right_root = root(right)
if left_root != right_root:
parents[right_root] = left_root
previous: list[int] = []
for row_index, row in enumerate(mask):
matches = np.flatnonzero(row == class_id)
if matches.size == 0:
previous = []
continue
groups = np.split(matches, np.flatnonzero(np.diff(matches) > 1) + 1)
current: list[int] = []
previous_cursor = 0
for group in groups:
start = int(group[0])
stop = int(group[-1]) + 1
run_index = len(runs)
runs.append((row_index, start, stop, stop - start))
parents.append(run_index)
current.append(run_index)
while previous_cursor < len(previous) and runs[previous[previous_cursor]][2] < start:
previous_cursor += 1
candidate_cursor = previous_cursor
while candidate_cursor < len(previous):
previous_index = previous[candidate_cursor]
_, previous_start, previous_stop, _ = runs[previous_index]
if previous_start > stop:
break
union(run_index, previous_index)
candidate_cursor += 1
previous = current
components: dict[int, list[int]] = {}
for run_index, (row, start, stop, count) in enumerate(runs):
component = components.setdefault(root(run_index), [start, row, stop, row + 1, 0])
component[0] = min(component[0], start)
component[1] = min(component[1], row)
component[2] = max(component[2], stop)
component[3] = max(component[3], row + 1)
component[4] += count
result = [
(left, top, right, bottom, count)
for left, top, right, bottom, count in components.values()
if count >= minimum_pixels and right - left >= 2 and bottom - top >= 3
]
result.sort(key=lambda box: (-box[4], box[1], box[0]))
return result
def _restore_cached(
output: Path,
sidecar: Path,
**identity: str,
) -> CanonicalLabOverlayArtifact | None:
try:
value = json.loads(sidecar.read_text(encoding="utf-8"))
stat = output.stat()
if (
output.is_symlink()
or sidecar.is_symlink()
or value.get("schema_version") != "missioncore.canonical-lab-rerun-overlay/v1"
or value.get("renderer_version") != RENDERER_VERSION
or any(value.get(key) != expected for key, expected in identity.items())
or value.get("byte_length") != stat.st_size
or not _is_sha256(value.get("sha256"))
or _sha256(output) != value["sha256"]
):
return None
return CanonicalLabOverlayArtifact(output, stat.st_size, value["sha256"])
except (OSError, ValueError, json.JSONDecodeError):
return None
def _artifact_is_regular(artifact: CanonicalLabOverlayArtifact) -> bool:
try:
return (
not artifact.path.is_symlink()
and artifact.path.stat().st_size == artifact.byte_length
and _sha256(artifact.path) == artifact.sha256
)
except OSError:
return False
def _write_json_atomic(path: Path, value: dict[str, Any]) -> None:
temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
try:
with temporary.open("x", encoding="utf-8") as stream:
json.dump(value, stream, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()
def _is_sha256(value: object) -> bool:
return isinstance(value, str) and len(value) == 64 and all(
character in "0123456789abcdef" for character in value
)
+41 -22
View File
@@ -63,7 +63,7 @@ class _RecordedBlueprintStream:
self.view_reset_generation = view_reset_generation
self._lock = Lock()
self._sequence = 0
self._follow_trajectory: bool | None = None
self._eye_contract: tuple[bool, bool] | None = None
self._closed = False
native = bindings.new_blueprint(
application_id=application_id,
@@ -85,14 +85,13 @@ class _RecordedBlueprintStream:
blueprint_factory: Callable[[bool], rrb.Blueprint],
*,
follow_trajectory: bool,
plan_view: bool,
) -> bytes:
with self._lock:
if self._closed:
raise RecordedBlueprintError("stable blueprint stream is closed")
update_eye_controls = (
self._follow_trajectory is None
or self._follow_trajectory != follow_trajectory
)
eye_contract = (follow_trajectory, plan_view)
update_eye_controls = self._eye_contract != eye_contract
blueprint = blueprint_factory(update_eye_controls)
self._blueprint_recording.set_time(
"blueprint",
@@ -110,7 +109,7 @@ class _RecordedBlueprintStream:
payload = self._transport.read(flush=True, flush_timeout_sec=5.0)
if not payload:
raise RecordedBlueprintError("stable blueprint stream produced no data")
self._follow_trajectory = follow_trajectory
self._eye_contract = eye_contract
return payload
def close(self) -> None:
@@ -165,6 +164,8 @@ def recorded_blueprint(
active_view: RecordedView = "spatial",
view_reset_generation: Literal[0, 1] = 0,
unified_perception: bool = False,
semantic_layer: Literal["city", "vegetation"] | None = None,
plan_view: bool = False,
show_detections_2d: bool = False,
show_segmentation: bool = False,
show_cuboids_3d: bool = False,
@@ -202,6 +203,27 @@ def recorded_blueprint(
if accumulated_time_ranges is not None:
point_overrides.append(accumulated_time_ranges)
trajectory_overrides.append(accumulated_time_ranges)
selected_semantic_path = (
f"/perception/camera/segmentation/{semantic_layer}"
if semantic_layer is not None
else "/perception/camera/segmentation"
)
spatial_eye_controls = (
rrb.EyeControls3D.from_fields(
kind=rrb.Eye3DKind.Orbital,
position=[0.0, 0.0, 30.0],
look_target=[0.0, 0.0, 0.0],
eye_up=[0.0, 1.0, 0.0],
tracking_entity="/world/sensor_pose" if follow_trajectory else "",
)
if plan_view and update_eye_controls
else rrb.EyeControls3D.from_fields(
kind=rrb.Eye3DKind.Orbital if follow_trajectory else None,
tracking_entity="/world/sensor_pose" if follow_trajectory else "",
)
if update_eye_controls
else None
)
spatial_view = rrb.Spatial3DView(
origin="/world",
name="Мир · LiDAR и объекты" if unified_perception else "Пространственная сцена",
@@ -232,14 +254,7 @@ def recorded_blueprint(
# Keeping the view id stable preserves the current orbit offset when
# tracking is toggled. An explicit empty path clears tracking without
# overwriting the position/look-target saved by user interaction.
eye_controls=(
rrb.EyeControls3D.from_fields(
kind=rrb.Eye3DKind.Orbital if follow_trajectory else None,
tracking_entity="/world/sensor_pose" if follow_trajectory else "",
)
if update_eye_controls
else None
),
eye_controls=spatial_eye_controls,
)
spatial_view.id = (
RECORDED_SPATIAL_RESET_VIEW_ID
@@ -258,6 +273,12 @@ def recorded_blueprint(
"/perception/camera/segmentation": rrb.EntityBehavior(
visible=show_segmentation,
),
"/perception/camera/segmentation/city": rrb.EntityBehavior(
visible=show_segmentation and selected_semantic_path.endswith("/city"),
),
"/perception/camera/segmentation/vegetation": rrb.EntityBehavior(
visible=show_segmentation and selected_semantic_path.endswith("/vegetation"),
),
},
)
camera_view.id = (
@@ -292,14 +313,7 @@ def recorded_blueprint(
# native cloud from /world/points.
"/world/perception/lidar": rrb.EntityBehavior(visible=False),
},
eye_controls=(
rrb.EyeControls3D.from_fields(
kind=rrb.Eye3DKind.Orbital if follow_trajectory else None,
tracking_entity="/world/sensor_pose" if follow_trajectory else "",
)
if update_eye_controls
else None
),
eye_controls=spatial_eye_controls,
)
perception_3d_view.id = (
RECORDED_PERCEPTION_3D_RESET_VIEW_ID
@@ -400,6 +414,8 @@ def recorded_blueprint_rrd(
active_view: RecordedView = "spatial",
view_reset_generation: Literal[0, 1] = 0,
unified_perception: bool = False,
semantic_layer: Literal["city", "vegetation"] | None = None,
plan_view: bool = False,
show_detections_2d: bool = False,
show_segmentation: bool = False,
show_cuboids_3d: bool = False,
@@ -414,6 +430,8 @@ def recorded_blueprint_rrd(
active_view=active_view,
view_reset_generation=view_reset_generation,
unified_perception=unified_perception,
semantic_layer=semantic_layer,
plan_view=plan_view,
show_detections_2d=show_detections_2d,
show_segmentation=show_segmentation,
show_cuboids_3d=show_cuboids_3d,
@@ -432,6 +450,7 @@ def recorded_blueprint_rrd(
).render(
build_blueprint,
follow_trajectory=follow_trajectory,
plan_view=plan_view,
)
except RecordedBlueprintError:
raise
+1 -1
View File
@@ -149,7 +149,7 @@ class RerunBridge:
# of record. Raw MQTT evidence is persisted independently. A large
# late-client backlog can block the native SDK and freeze preview.
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
# Rerun 0.34.1 can replay ActivateStore before StoreInfo when an
# Rerun transport can replay ActivateStore before StoreInfo when an
# evicted buffer is served newest-first, leaving late viewers on the
# welcome screen. Preserve protocol order within the bounded cache.
newest_first=False,
+5
View File
@@ -1047,6 +1047,11 @@ app.include_router(
if session_recorded_camera_frame_service is not None
else None
),
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
rerun_overlay_cache_root=(
session_store.data_dir / "laboratory-rerun-overlays"
),
ffmpeg_path=_ffmpeg,
)
)
app.include_router(
+4
View File
@@ -124,6 +124,8 @@ class RecordedBlueprintRequest(StrictApiModel):
active_view: Literal["spatial", "perception", "perception3d", "metrics"] = "spatial"
view_reset_generation: Literal[0, 1] = 0
unified_perception: StrictBool = False
semantic_layer: Literal["city", "vegetation"] | None = None
plan_view: StrictBool = False
show_detections_2d: StrictBool = False
show_segmentation: StrictBool = False
show_cuboids_3d: StrictBool = False
@@ -935,6 +937,8 @@ def build_session_router(
active_view=request.active_view,
view_reset_generation=request.view_reset_generation,
unified_perception=request.unified_perception,
semantic_layer=request.semantic_layer,
plan_view=request.plan_view,
show_detections_2d=request.show_detections_2d,
show_segmentation=request.show_segmentation,
show_cuboids_3d=request.show_cuboids_3d,
+84 -75
View File
@@ -12,13 +12,21 @@ import zipfile
from collections.abc import Callable
from functools import lru_cache
from pathlib import Path, PurePosixPath
from typing import Any, Final
from typing import Any, Final, Literal
import numpy as np
from fastapi import APIRouter, HTTPException
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, JSONResponse, Response
from PIL import Image
from pydantic import BaseModel, ConfigDict, Field
from k1link.laboratory.canonical_rerun_overlay import (
CanonicalLabOverlayError,
_mask_component_boxes,
canonical_lab_overlay,
canonical_recording_id,
)
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
from k1link.laboratory.evidence_report import (
LaboratoryEvidenceReportError,
@@ -34,6 +42,17 @@ from k1link.sessions.canonical_lab_spatial import (
RootProvider = Callable[[], Path | None]
CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None]
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
class CanonicalLabRerunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
application_id: Literal["nodedc_mission_core_recorded"]
recording_id: str = Field(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
)
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024
_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 24
_DEFINITION: Final = LaboratoryEvidenceDefinition(
@@ -57,6 +76,9 @@ def build_vegetation_shadow_lab_router(
root_provider: RootProvider = lambda: None,
canonical_recording_provider: CanonicalRecordingProvider | None = None,
camera_frame_provider: CameraFrameProvider | None = None,
jobs_root: Path | None = None,
rerun_overlay_cache_root: Path | None = None,
ffmpeg_path: Path | None = None,
) -> APIRouter:
return _build_vegetation_lab_router(
prefix="/api/v1/laboratory/vegetation-shadow",
@@ -64,6 +86,9 @@ def build_vegetation_shadow_lab_router(
root_provider=root_provider,
canonical_recording_provider=canonical_recording_provider,
camera_frame_provider=camera_frame_provider,
jobs_root=jobs_root,
rerun_overlay_cache_root=rerun_overlay_cache_root,
ffmpeg_path=ffmpeg_path,
)
@@ -84,6 +109,9 @@ def _build_vegetation_lab_router(
root_provider: RootProvider,
canonical_recording_provider: CanonicalRecordingProvider | None = None,
camera_frame_provider: CameraFrameProvider | None = None,
jobs_root: Path | None = None,
rerun_overlay_cache_root: Path | None = None,
ffmpeg_path: Path | None = None,
) -> APIRouter:
router = APIRouter(
prefix=prefix,
@@ -270,6 +298,61 @@ def _build_vegetation_lab_router(
},
)
@router.post("/{result_id}/canonical-overlay.rrd")
async def get_canonical_rerun_overlay(
result_id: str,
request: CanonicalLabRerunRequest,
) -> FileResponse:
"""Project LAB-only evidence into the base recording's native clock."""
if (
canonical_recording_provider is None
or jobs_root is None
or rerun_overlay_cache_root is None
or ffmpeg_path is None
):
raise HTTPException(status_code=503, detail="Canonical LAB Rerun overlay unavailable")
candidate = _resolve_candidate(root_provider, definition, result_id)
manifest = _read_verified(candidate, definition)
route, _ = _full_route_context(candidate, manifest)
recording = canonical_recording_provider(str(route["session_id"]))
if recording is None:
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
recording_path, generation_sha256 = recording
try:
expected_recording_id = await run_in_threadpool(
canonical_recording_id,
recording_path,
)
if request.recording_id != expected_recording_id:
raise HTTPException(status_code=412, detail="Canonical recording identity changed")
artifact = await run_in_threadpool(
canonical_lab_overlay,
candidate,
manifest,
recording_id=request.recording_id,
base_generation_sha256=generation_sha256,
jobs_root=jobs_root,
cache_root=rerun_overlay_cache_root,
ffmpeg_path=ffmpeg_path,
)
except HTTPException:
raise
except CanonicalLabOverlayError as exc:
raise HTTPException(
status_code=503,
detail="Canonical LAB Rerun overlay failed verification",
) from exc
return FileResponse(
artifact.path,
media_type="application/vnd.rerun.rrd",
headers={
"Cache-Control": "private, max-age=31536000, immutable",
"ETag": f'"{artifact.sha256}"',
"X-Content-Type-Options": "nosniff",
},
)
@router.get("/{result_id}/timeline")
def get_canonical_route_timeline(result_id: str) -> dict[str, object]:
candidate = _resolve_candidate(root_provider, definition, result_id)
@@ -811,80 +894,6 @@ def _semantic_component_proposals_cached(
return tuple(proposals[:32])
def _mask_component_boxes(
mask: np.ndarray,
class_id: int,
*,
minimum_pixels: int,
) -> list[tuple[int, int, int, int, int]]:
"""Return 8-connected run-length components without an OpenCV dependency."""
if mask.ndim != 2 or minimum_pixels < 1:
return []
parents: list[int] = []
runs: list[tuple[int, int, int, int]] = []
def root(index: int) -> int:
while parents[index] != index:
parents[index] = parents[parents[index]]
index = parents[index]
return index
def union(left: int, right: int) -> None:
left_root = root(left)
right_root = root(right)
if left_root != right_root:
parents[right_root] = left_root
previous: list[int] = []
for row_index, row in enumerate(mask):
matches = np.flatnonzero(row == class_id)
if matches.size == 0:
previous = []
continue
split_at = np.flatnonzero(np.diff(matches) > 1) + 1
groups = np.split(matches, split_at)
current: list[int] = []
previous_cursor = 0
for group in groups:
start = int(group[0])
stop = int(group[-1]) + 1
run_index = len(runs)
runs.append((row_index, start, stop, stop - start))
parents.append(run_index)
current.append(run_index)
while (
previous_cursor < len(previous)
and runs[previous[previous_cursor]][2] < start
):
previous_cursor += 1
candidate_cursor = previous_cursor
while candidate_cursor < len(previous):
previous_index = previous[candidate_cursor]
_, previous_start, previous_stop, _ = runs[previous_index]
if previous_start > stop:
break
union(run_index, previous_index)
candidate_cursor += 1
previous = current
components: dict[int, list[int]] = {}
for run_index, (row, start, stop, count) in enumerate(runs):
component = components.setdefault(root(run_index), [start, row, stop, row + 1, 0])
component[0] = min(component[0], start)
component[1] = min(component[1], row)
component[2] = max(component[2], stop)
component[3] = max(component[3], row + 1)
component[4] += count
result = [
(left, top, right, bottom, count)
for left, top, right, bottom, count in components.values()
if count >= minimum_pixels and right - left >= 2 and bottom - top >= 3
]
result.sort(key=lambda box: (-box[4], box[1], box[0]))
return result
def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, object]:
before = path.stat()
with np.load(path, allow_pickle=False) as archive: