fix(lab): enforce canonical replay runtime
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
"""Canonical recorded-LAB spatial adapter for sealed Rerun recordings.
|
||||
|
||||
The LAB viewer must not run an independent Rerun transport beside the camera
|
||||
transport. This adapter reads the immutable recording once, indexes the
|
||||
recorded source cloud, sensor pose and SLAM trajectory, and returns the latest
|
||||
source-paced spatial sample in the current body frame. Camera, spatial layers
|
||||
and the common timeline can therefore be driven by one host clock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bisect import bisect_right
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any, Final
|
||||
|
||||
import numpy as np
|
||||
import rerun_bindings as rr_bindings
|
||||
|
||||
_POINT_ENTITY: Final = "/world/points"
|
||||
_POSE_ENTITY: Final = "/world/sensor_pose"
|
||||
_TRAJECTORY_ENTITY: Final = "/world/trajectory"
|
||||
_POINT_COMPONENT: Final = "Points3D:positions"
|
||||
_POSE_TRANSLATION_COMPONENT: Final = "Transform3D:translation"
|
||||
_POSE_QUATERNION_COMPONENT: Final = "Transform3D:quaternion"
|
||||
_TRAJECTORY_COMPONENT: Final = "LineStrips3D:strips"
|
||||
_INDEX_LOCK: Final = Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TimedPoints:
|
||||
times_ns: tuple[int, ...]
|
||||
values: tuple[np.ndarray, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TimedPoses:
|
||||
times_ns: tuple[int, ...]
|
||||
translations: tuple[np.ndarray, ...]
|
||||
quaternions_xyzw: tuple[np.ndarray, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CanonicalSpatialIndex:
|
||||
points: _TimedPoints
|
||||
poses: _TimedPoses
|
||||
trajectories: _TimedPoints
|
||||
|
||||
|
||||
def _session_times(batch: Any) -> Any | None:
|
||||
if "session_time" not in batch.schema.names:
|
||||
return None
|
||||
return batch.column("session_time")
|
||||
|
||||
|
||||
def _point_rows(chunks: list[Any], entity: str, component: str, *, nested: bool = False) -> _TimedPoints:
|
||||
rows: list[tuple[int, np.ndarray]] = []
|
||||
for chunk in chunks:
|
||||
if chunk.entity_path != entity:
|
||||
continue
|
||||
batch = chunk.to_record_batch()
|
||||
times = _session_times(batch)
|
||||
if times is None or component not in batch.schema.names:
|
||||
continue
|
||||
column = batch.column(component)
|
||||
for row_index in range(batch.num_rows):
|
||||
timestamp = int(times[row_index].value)
|
||||
payload = column[row_index].as_py()
|
||||
if nested:
|
||||
payload = payload[0] if payload else []
|
||||
values = np.asarray(payload, dtype=np.float32)
|
||||
if values.ndim != 2 or values.shape[1] != 3 or not np.isfinite(values).all():
|
||||
continue
|
||||
values.setflags(write=False)
|
||||
rows.append((timestamp, values))
|
||||
rows.sort(key=lambda item: item[0])
|
||||
return _TimedPoints(
|
||||
times_ns=tuple(timestamp for timestamp, _ in rows),
|
||||
values=tuple(values for _, values in rows),
|
||||
)
|
||||
|
||||
|
||||
def _pose_rows(chunks: list[Any]) -> _TimedPoses:
|
||||
rows: list[tuple[int, np.ndarray, np.ndarray]] = []
|
||||
for chunk in chunks:
|
||||
if chunk.entity_path != _POSE_ENTITY:
|
||||
continue
|
||||
batch = chunk.to_record_batch()
|
||||
times = _session_times(batch)
|
||||
if (
|
||||
times is None
|
||||
or _POSE_TRANSLATION_COMPONENT not in batch.schema.names
|
||||
or _POSE_QUATERNION_COMPONENT not in batch.schema.names
|
||||
):
|
||||
continue
|
||||
translations = batch.column(_POSE_TRANSLATION_COMPONENT)
|
||||
quaternions = batch.column(_POSE_QUATERNION_COMPONENT)
|
||||
for row_index in range(batch.num_rows):
|
||||
translation_values = translations[row_index].as_py()
|
||||
quaternion_values = quaternions[row_index].as_py()
|
||||
if len(translation_values) != 1 or len(quaternion_values) != 1:
|
||||
continue
|
||||
translation = np.asarray(translation_values[0], dtype=np.float64)
|
||||
quaternion = np.asarray(quaternion_values[0], dtype=np.float64)
|
||||
if (
|
||||
translation.shape != (3,)
|
||||
or quaternion.shape != (4,)
|
||||
or not np.isfinite(translation).all()
|
||||
or not np.isfinite(quaternion).all()
|
||||
):
|
||||
continue
|
||||
norm = float(np.linalg.norm(quaternion))
|
||||
if norm <= 1e-9:
|
||||
continue
|
||||
translation.setflags(write=False)
|
||||
normalized = quaternion / norm
|
||||
normalized.setflags(write=False)
|
||||
rows.append((int(times[row_index].value), translation, normalized))
|
||||
rows.sort(key=lambda item: item[0])
|
||||
return _TimedPoses(
|
||||
times_ns=tuple(timestamp for timestamp, _, _ in rows),
|
||||
translations=tuple(translation for _, translation, _ in rows),
|
||||
quaternions_xyzw=tuple(quaternion for _, _, quaternion in rows),
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_index_cached(
|
||||
path_text: str,
|
||||
byte_length: int,
|
||||
modified_ns: int,
|
||||
generation_sha256: str,
|
||||
) -> _CanonicalSpatialIndex:
|
||||
path = Path(path_text)
|
||||
stat = path.stat()
|
||||
if stat.st_size != byte_length or stat.st_mtime_ns != modified_ns:
|
||||
raise ValueError("Recorded LAB source changed during spatial indexing")
|
||||
if len(generation_sha256) != 64:
|
||||
raise ValueError("Recorded LAB generation is invalid")
|
||||
# Decode only the three canonical entities in one pass. Building a lazy
|
||||
# store first decodes the complete RRD (including unrelated payloads), and
|
||||
# then scanning that store once per layer made first-open take more than a
|
||||
# minute on RAVNOVES004TREE.
|
||||
chunks = (
|
||||
rr_bindings.RrdReaderInternal(str(path))
|
||||
.stream()
|
||||
.filter(content=[_POINT_ENTITY, _POSE_ENTITY, _TRAJECTORY_ENTITY])
|
||||
.to_chunks()
|
||||
)
|
||||
points = _point_rows(chunks, _POINT_ENTITY, _POINT_COMPONENT)
|
||||
poses = _pose_rows(chunks)
|
||||
trajectories = _point_rows(
|
||||
chunks,
|
||||
_TRAJECTORY_ENTITY,
|
||||
_TRAJECTORY_COMPONENT,
|
||||
nested=True,
|
||||
)
|
||||
if not points.times_ns or not poses.times_ns or not trajectories.times_ns:
|
||||
raise ValueError("Recorded LAB source has no canonical spatial layers")
|
||||
return _CanonicalSpatialIndex(points=points, poses=poses, trajectories=trajectories)
|
||||
|
||||
|
||||
def _load_index(
|
||||
path_text: str,
|
||||
byte_length: int,
|
||||
modified_ns: int,
|
||||
generation_sha256: str,
|
||||
) -> _CanonicalSpatialIndex:
|
||||
# functools.lru_cache is coherent but intentionally releases its lock
|
||||
# during a miss. Serialize cold RRD indexing so simultaneous camera/TGS
|
||||
# admission cannot parse the same 80 MiB recording twice.
|
||||
with _INDEX_LOCK:
|
||||
return _load_index_cached(
|
||||
path_text,
|
||||
byte_length,
|
||||
modified_ns,
|
||||
generation_sha256,
|
||||
)
|
||||
|
||||
|
||||
def _latest_index(times_ns: tuple[int, ...], target_ns: int) -> int:
|
||||
return max(0, min(len(times_ns) - 1, bisect_right(times_ns, target_ns) - 1))
|
||||
|
||||
|
||||
def _rotation_map_from_body(quaternion_xyzw: np.ndarray) -> np.ndarray:
|
||||
x, y, z, w = (float(value) for value in quaternion_xyzw)
|
||||
return np.asarray(
|
||||
[
|
||||
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
||||
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
||||
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def _map_points_to_body(
|
||||
points_map: np.ndarray,
|
||||
translation_map: np.ndarray,
|
||||
quaternion_xyzw: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
rotation = _rotation_map_from_body(quaternion_xyzw)
|
||||
# Row vectors: inverse(map_from_body) == right-multiply by map_from_body.
|
||||
body = (points_map.astype(np.float64) - translation_map) @ rotation
|
||||
return body.astype(np.float32)
|
||||
|
||||
|
||||
def canonical_lab_spatial_frame(
|
||||
recording_path: Path,
|
||||
generation_sha256: str,
|
||||
target_time_ns: int,
|
||||
) -> dict[str, object]:
|
||||
"""Return the latest sealed source cloud and SLAM route on one host time."""
|
||||
|
||||
if target_time_ns < 0:
|
||||
raise ValueError("Recorded LAB target time is invalid")
|
||||
stat = recording_path.stat()
|
||||
index = _load_index(
|
||||
str(recording_path),
|
||||
stat.st_size,
|
||||
stat.st_mtime_ns,
|
||||
generation_sha256,
|
||||
)
|
||||
point_index = _latest_index(index.points.times_ns, target_time_ns)
|
||||
pose_index = _latest_index(index.poses.times_ns, index.points.times_ns[point_index])
|
||||
trajectory_index = _latest_index(index.trajectories.times_ns, target_time_ns)
|
||||
translation = index.poses.translations[pose_index]
|
||||
quaternion = index.poses.quaternions_xyzw[pose_index]
|
||||
basis_map_from_body = _rotation_map_from_body(quaternion)
|
||||
points_body = _map_points_to_body(index.points.values[point_index], translation, quaternion)
|
||||
trajectory_body = _map_points_to_body(
|
||||
index.trajectories.values[trajectory_index],
|
||||
translation,
|
||||
quaternion,
|
||||
)
|
||||
# The canonical local-SLAM layer is bounded around the vehicle. It must
|
||||
# never turn into the full world-route "blob" seen in the raw Rerun view.
|
||||
local_mask = (
|
||||
(np.abs(trajectory_body[:, 0]) <= 30.0)
|
||||
& (np.abs(trajectory_body[:, 1]) <= 30.0)
|
||||
& (np.abs(trajectory_body[:, 2]) <= 6.0)
|
||||
)
|
||||
local_trajectory = trajectory_body[local_mask]
|
||||
return {
|
||||
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v1",
|
||||
"target_time_ns": target_time_ns,
|
||||
"source_time_ns": index.points.times_ns[point_index],
|
||||
"pose_time_ns": index.poses.times_ns[pose_index],
|
||||
"trajectory_time_ns": index.trajectories.times_ns[trajectory_index],
|
||||
"body_frame": {
|
||||
"origin_map_xyz_m": translation.tolist(),
|
||||
"basis_map_from_body": basis_map_from_body.tolist(),
|
||||
},
|
||||
"source_point_count": int(points_body.shape[0]),
|
||||
"source_points_body_xyz_m": points_body.tolist(),
|
||||
"local_slam_body_xyz_m": local_trajectory.tolist(),
|
||||
}
|
||||
@@ -37,6 +37,7 @@ from k1link.sessions import (
|
||||
SessionStore,
|
||||
validate_recorded_media_timeline,
|
||||
)
|
||||
from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_frame
|
||||
from k1link.sessions.plugin_contract import RecordedPointColorRenderer
|
||||
from k1link.viewer.recorded import (
|
||||
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
||||
@@ -824,6 +825,76 @@ def build_session_router(
|
||||
**response_kwargs,
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame"
|
||||
)
|
||||
async def get_observation_session_canonical_lab_spatial_frame(
|
||||
session_id: str,
|
||||
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||
time_ns: Annotated[int, Query(ge=0, le=MAX_SAFE_INTEGER)],
|
||||
) -> JSONResponse:
|
||||
"""Serve one body-frame sample for the canonical recorded-LAB clock.
|
||||
|
||||
The camera timeline owns playback. Spatial evidence is sampled from
|
||||
the same immutable recording instead of starting a second Rerun clock.
|
||||
"""
|
||||
|
||||
if SAFE_SHA256.fullmatch(generation) is None:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Поколение spatial-записи не совпадает.",
|
||||
)
|
||||
if recording_preparation_manager is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис canonical LAB spatial playback не настроен.",
|
||||
)
|
||||
snapshot = recording_preparation_manager.status(session_id)
|
||||
if snapshot is None or snapshot.state != "ready" or snapshot.recording is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Запись canonical LAB ещё не подготовлена.",
|
||||
)
|
||||
_require_matching_recording_generation(snapshot.recording.sha256, generation)
|
||||
pinned = recording_preparation_manager.pin_ready(
|
||||
session_id,
|
||||
preparation_id=snapshot.preparation_id,
|
||||
)
|
||||
if pinned is None:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Подготовленная spatial-запись была заменена.",
|
||||
)
|
||||
pinned_snapshot, release_recording = pinned
|
||||
try:
|
||||
recording = pinned_snapshot.recording
|
||||
if recording is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Подготовленная spatial-запись недоступна.",
|
||||
)
|
||||
payload = await run_in_threadpool(
|
||||
canonical_lab_spatial_frame,
|
||||
recording.path,
|
||||
generation,
|
||||
time_ns,
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Canonical LAB spatial frame не прошёл проверку.",
|
||||
) from exc
|
||||
finally:
|
||||
release_recording()
|
||||
return JSONResponse(
|
||||
payload,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{generation}:{payload["source_time_ns"]}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
@router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd")
|
||||
async def get_observation_session_blueprint(
|
||||
session_id: str,
|
||||
|
||||
@@ -11,8 +11,9 @@ from functools import lru_cache
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Final
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
|
||||
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
|
||||
from k1link.laboratory.evidence_report import (
|
||||
@@ -262,9 +263,113 @@ def _build_vegetation_lab_router(
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/{result_id}/route-tgs-anchor/{source_sequence}")
|
||||
def get_route_tgs_anchor(result_id: str, source_sequence: int) -> JSONResponse:
|
||||
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||
manifest = _read_verified(candidate, definition)
|
||||
review = manifest.get("route_review")
|
||||
cases = review.get("cases") if isinstance(review, dict) else None
|
||||
if (
|
||||
not isinstance(cases, list)
|
||||
or review.get("source_id") != "RAVNOVES004TREE"
|
||||
or review.get("session_id") != "20260828T130511Z_viewer_live"
|
||||
or not any(
|
||||
isinstance(item, dict) and item.get("source_sequence") == source_sequence
|
||||
for item in cases
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Route TGS anchor not found")
|
||||
artifacts = manifest.get("artifacts")
|
||||
descriptor = next(
|
||||
(
|
||||
item
|
||||
for item in artifacts if isinstance(item, dict)
|
||||
and item.get("role") == "mixed-route-tgs-evidence"
|
||||
and item.get("path") == "proofs/tgs-evidence.npz"
|
||||
and item.get("media_type") == "application/x-npz"
|
||||
),
|
||||
None,
|
||||
) if isinstance(artifacts, list) else None
|
||||
if descriptor is None:
|
||||
raise HTTPException(status_code=404, detail="Route TGS anchor not found")
|
||||
try:
|
||||
payload = _route_tgs_anchor_payload(
|
||||
candidate / "proofs" / "tgs-evidence.npz",
|
||||
source_sequence,
|
||||
)
|
||||
except (KeyError, OSError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Route TGS anchor failed verification",
|
||||
) from None
|
||||
return JSONResponse(
|
||||
payload,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{descriptor.get("sha256", "")}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
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:
|
||||
source_indices = archive["source_frame_indices"]
|
||||
offsets = archive["current_increment_point_offsets"]
|
||||
points = archive["current_increment_points_xyz_m"]
|
||||
centers = archive["costmap_cell_centers_xy_m"]
|
||||
states = archive["causal_rolling_1s_costmap_states"]
|
||||
z_bounds = archive["causal_rolling_1s_costmap_z_bounds_m"]
|
||||
if (
|
||||
source_indices.shape != (10,)
|
||||
or offsets.shape != (11,)
|
||||
or points.ndim != 2
|
||||
or points.shape[1] != 3
|
||||
or centers.shape != (2244, 2)
|
||||
or states.shape != (10, 2244)
|
||||
or z_bounds.shape != (10, 2244, 2)
|
||||
):
|
||||
raise ValueError("Route TGS evidence shape changed")
|
||||
matches = np.flatnonzero(source_indices == source_sequence - 1)
|
||||
if matches.shape != (1,):
|
||||
raise ValueError("Route TGS source sequence changed")
|
||||
slot = int(matches[0])
|
||||
start = int(offsets[slot])
|
||||
end = int(offsets[slot + 1])
|
||||
if not 0 <= start <= end <= points.shape[0]:
|
||||
raise ValueError("Route TGS point offsets changed")
|
||||
selected_points = np.ascontiguousarray(points[start:end], dtype=np.float32)
|
||||
selected_states = np.ascontiguousarray(states[slot], dtype=np.uint8)
|
||||
selected_z_bounds = np.ascontiguousarray(z_bounds[slot], dtype=np.float32)
|
||||
if not np.isfinite(selected_points).all() or not np.isin(selected_states, [0, 1, 2, 3]).all():
|
||||
raise ValueError("Route TGS payload changed")
|
||||
result = {
|
||||
"schema_version": "missioncore.lab-v1-route-tgs-anchor/v1",
|
||||
"source_sequence": source_sequence,
|
||||
"slot": slot,
|
||||
"current_points_xyz_m": selected_points.astype(float).tolist(),
|
||||
"costmap": {
|
||||
"cell_size_m": 0.45,
|
||||
"centers_xy_m": centers.astype(float).tolist(),
|
||||
"state_codes": selected_states.astype(int).tolist(),
|
||||
"z_bounds_m": [
|
||||
[
|
||||
float(row[0]) if np.isfinite(row[0]) else None,
|
||||
float(row[1]) if np.isfinite(row[1]) else None,
|
||||
]
|
||||
for row in selected_z_bounds
|
||||
],
|
||||
},
|
||||
}
|
||||
after = path.stat()
|
||||
if before.st_size != after.st_size or before.st_mtime_ns != after.st_mtime_ns:
|
||||
raise ValueError("Route TGS evidence changed during read")
|
||||
return result
|
||||
|
||||
|
||||
def _zip_mask_response(archive_path: Path, sequence: int) -> Response:
|
||||
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user