refactor(lab): restore canonical RAV004 replay
This commit is contained in:
@@ -5,7 +5,7 @@ transport. This adapter reads the immutable recording once, indexes the
|
||||
recorded source cloud and sensor pose, estimates the session sensor height from
|
||||
the initial stationary cloud, and returns both the current increment and a
|
||||
bounded accumulated local-SLAM cloud in a ground-rebased body frame. Camera,
|
||||
spatial layers and the common timeline can therefore be driven by one host
|
||||
spatial layers and the common timeline can therefore be driven by one media
|
||||
clock without a per-LAB coordinate adapter.
|
||||
"""
|
||||
|
||||
@@ -21,7 +21,7 @@ from typing import Any, Final
|
||||
import numpy as np
|
||||
import rerun_bindings as rr_bindings
|
||||
|
||||
CANONICAL_LAB_SPATIAL_PROFILE: Final = "source-paced-ground-v2"
|
||||
CANONICAL_LAB_SPATIAL_PROFILE: Final = "source-paced-ground-v3"
|
||||
_POINT_ENTITY: Final = "/world/points"
|
||||
_POSE_ENTITY: Final = "/world/sensor_pose"
|
||||
_TRAJECTORY_ENTITY: Final = "/world/trajectory"
|
||||
@@ -35,11 +35,15 @@ _HEIGHT_CALIBRATION_MAX_FRAMES: Final = 120
|
||||
_HEIGHT_NEAR_MIN_RADIUS_M: Final = 1.0
|
||||
_HEIGHT_NEAR_MAX_RADIUS_M: Final = 6.0
|
||||
_HEIGHT_LOWER_QUANTILE: Final = 0.025
|
||||
_LOCAL_HEIGHT_QUANTILE: Final = 0.10
|
||||
_LOCAL_HEIGHT_HALF_WINDOW_SECONDS: Final = 1.0
|
||||
_LOCAL_SLAM_HISTORY_SECONDS: Final = 5.0
|
||||
_LOCAL_SLAM_RADIUS_M: Final = 30.0
|
||||
_LOCAL_SLAM_VERTICAL_LIMIT_M: Final = 6.0
|
||||
_LOCAL_SLAM_VOXEL_SIZE_M: Final = 0.12
|
||||
_LOCAL_SLAM_POINT_LIMIT: Final = 27_000
|
||||
_FORWARD_HALF_WINDOW_SECONDS: Final = 1.0
|
||||
_FORWARD_MINIMUM_DISPLACEMENT_M: Final = 0.15
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -233,6 +237,67 @@ def _map_points_to_body(
|
||||
return body.astype(np.float32)
|
||||
|
||||
|
||||
def _gravity_stable_basis_map_from_body(
|
||||
poses: _TimedPoses,
|
||||
target_time_ns: int,
|
||||
) -> tuple[np.ndarray, str]:
|
||||
"""Return a right-handed forward/left/up base frame in the RFU map.
|
||||
|
||||
Rerun declares this recording map as RFU, while the metric LAB scene
|
||||
consumes points as forward/left/up. The LiDAR quaternion columns are sensor
|
||||
right/forward/up and also contain rover or handheld roll/pitch, so they are
|
||||
not a body basis. Route displacement owns yaw when available; the sensor's
|
||||
local +Y (Rerun Forward) projected onto map gravity is the stationary
|
||||
fallback. Map +Z always owns up.
|
||||
"""
|
||||
|
||||
center = _latest_index(poses.times_ns, target_time_ns)
|
||||
half_window_ns = round(_FORWARD_HALF_WINDOW_SECONDS * 1_000_000_000)
|
||||
first = _latest_index(poses.times_ns, max(0, target_time_ns - half_window_ns))
|
||||
last = min(
|
||||
len(poses.times_ns) - 1,
|
||||
max(0, bisect_right(poses.times_ns, target_time_ns + half_window_ns) - 1),
|
||||
)
|
||||
route = poses.translations[last] - poses.translations[first]
|
||||
route_xy = np.asarray([route[0], route[1], 0.0], dtype=np.float64)
|
||||
route_norm = float(np.linalg.norm(route_xy))
|
||||
|
||||
sensor_rotation = _rotation_map_from_body(poses.quaternions_xyzw[center])
|
||||
sensor_forward = np.asarray(
|
||||
[sensor_rotation[0, 1], sensor_rotation[1, 1], 0.0],
|
||||
dtype=np.float64,
|
||||
)
|
||||
sensor_forward_norm = float(np.linalg.norm(sensor_forward))
|
||||
if sensor_forward_norm <= 1e-9:
|
||||
raise ValueError("Recorded LAB sensor forward axis is invalid")
|
||||
sensor_forward /= sensor_forward_norm
|
||||
|
||||
if route_norm >= _FORWARD_MINIMUM_DISPLACEMENT_M:
|
||||
forward = route_xy / route_norm
|
||||
if float(np.dot(forward, sensor_forward)) < 0.0:
|
||||
forward = -forward
|
||||
forward_source = "smoothed-pose-trajectory-tangent"
|
||||
else:
|
||||
forward = sensor_forward
|
||||
forward_source = "rerun-rfu-sensor-forward-fallback"
|
||||
|
||||
up = np.asarray([0.0, 0.0, 1.0], dtype=np.float64)
|
||||
left = np.cross(up, forward)
|
||||
left_norm = float(np.linalg.norm(left))
|
||||
if left_norm <= 1e-9:
|
||||
raise ValueError("Recorded LAB body left axis is invalid")
|
||||
left /= left_norm
|
||||
forward = np.cross(left, up)
|
||||
forward /= float(np.linalg.norm(forward))
|
||||
basis = np.column_stack((forward, left, up))
|
||||
if (
|
||||
not np.allclose(basis.T @ basis, np.eye(3), atol=1e-7)
|
||||
or np.linalg.det(basis) < 0.999999
|
||||
):
|
||||
raise ValueError("Recorded LAB gravity-stable body basis is invalid")
|
||||
return basis, forward_source
|
||||
|
||||
|
||||
def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[float, int, float]:
|
||||
"""Estimate one session mount height from the initial qualified cloud.
|
||||
|
||||
@@ -253,17 +318,13 @@ def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[f
|
||||
estimates: list[float] = []
|
||||
for point_index in candidates:
|
||||
pose_index = _latest_index(poses.times_ns, points.times_ns[point_index])
|
||||
body = _map_points_to_body(
|
||||
points.values[point_index],
|
||||
poses.translations[pose_index],
|
||||
poses.quaternions_xyzw[pose_index],
|
||||
)
|
||||
radius = np.linalg.norm(body[:, :2], axis=1)
|
||||
eligible = body[
|
||||
delta = points.values[point_index].astype(np.float64) - poses.translations[pose_index]
|
||||
radius = np.linalg.norm(delta[:, :2], axis=1)
|
||||
eligible = delta[
|
||||
(radius >= _HEIGHT_NEAR_MIN_RADIUS_M)
|
||||
& (radius <= _HEIGHT_NEAR_MAX_RADIUS_M)
|
||||
& (body[:, 2] >= -2.0)
|
||||
& (body[:, 2] <= 0.5)
|
||||
& (delta[:, 2] >= -2.0)
|
||||
& (delta[:, 2] <= 0.5)
|
||||
]
|
||||
if eligible.shape[0] < 100:
|
||||
continue
|
||||
@@ -278,12 +339,55 @@ def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[f
|
||||
return height, len(estimates), mad
|
||||
|
||||
|
||||
def _estimate_local_sensor_height(
|
||||
points: _TimedPoints,
|
||||
poses: _TimedPoses,
|
||||
target_time_ns: int,
|
||||
fallback_height_m: float,
|
||||
) -> tuple[float, int, float, str]:
|
||||
"""Estimate the current gravity-axis height without a fixed camera mount.
|
||||
|
||||
RAVNOVES004TREE changes sensor height during the route. A session-wide
|
||||
constant therefore moves the scene vertically whenever the operator raises
|
||||
or lowers K1. Use a short source-time window and a conservative near-field
|
||||
ground quantile; fall back to the sealed session calibration only when the
|
||||
current cloud has insufficient support.
|
||||
"""
|
||||
|
||||
half_window_ns = round(_LOCAL_HEIGHT_HALF_WINDOW_SECONDS * 1_000_000_000)
|
||||
first = bisect_right(points.times_ns, max(0, target_time_ns - half_window_ns) - 1)
|
||||
last = bisect_right(points.times_ns, target_time_ns + half_window_ns)
|
||||
estimates: list[float] = []
|
||||
for point_index in range(first, last):
|
||||
pose_index = _latest_index(poses.times_ns, points.times_ns[point_index])
|
||||
delta = points.values[point_index].astype(np.float64) - poses.translations[pose_index]
|
||||
radius = np.linalg.norm(delta[:, :2], axis=1)
|
||||
eligible = delta[
|
||||
(radius >= _HEIGHT_NEAR_MIN_RADIUS_M)
|
||||
& (radius <= _HEIGHT_NEAR_MAX_RADIUS_M)
|
||||
& (delta[:, 2] >= -2.5)
|
||||
& (delta[:, 2] <= 0.5)
|
||||
]
|
||||
if eligible.shape[0] < 100:
|
||||
continue
|
||||
estimate = -float(np.quantile(eligible[:, 2], _LOCAL_HEIGHT_QUANTILE))
|
||||
if 0.03 <= estimate <= 2.5:
|
||||
estimates.append(estimate)
|
||||
if not estimates:
|
||||
return fallback_height_m, 0, 0.0, "session-source-cloud-fallback"
|
||||
values = np.asarray(estimates, dtype=np.float64)
|
||||
height = float(np.median(values))
|
||||
mad = float(np.median(np.abs(values - height)))
|
||||
return height, len(estimates), mad, "local-source-cloud-ground-quantile-median"
|
||||
|
||||
|
||||
def _ground_origin_map(
|
||||
sensor_origin_map: np.ndarray,
|
||||
basis_map_from_body: np.ndarray,
|
||||
sensor_height_m: float,
|
||||
) -> np.ndarray:
|
||||
return sensor_origin_map - basis_map_from_body[:, 2] * sensor_height_m
|
||||
# The calibrated height belongs to the map gravity axis. Sensor roll/pitch
|
||||
# must never tilt the ground origin or the accumulated world cloud.
|
||||
return sensor_origin_map - np.asarray([0.0, 0.0, sensor_height_m])
|
||||
|
||||
|
||||
def _map_points_to_ground_body(
|
||||
@@ -329,32 +433,29 @@ def _bounded_local_slam(
|
||||
return np.ascontiguousarray(local, dtype=np.float32), len(selected), source_count
|
||||
|
||||
|
||||
def canonical_lab_spatial_frame(
|
||||
recording_path: Path,
|
||||
generation_sha256: str,
|
||||
def _canonical_lab_spatial_frame_from_index(
|
||||
index: _CanonicalSpatialIndex,
|
||||
target_time_ns: int,
|
||||
) -> dict[str, object]:
|
||||
"""Return the current source cloud and bounded Local SLAM 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)
|
||||
sensor_height_m, sensor_height_sample_count, sensor_height_mad_m, height_source = (
|
||||
_estimate_local_sensor_height(
|
||||
index.points,
|
||||
index.poses,
|
||||
index.points.times_ns[point_index],
|
||||
index.sensor_height_m,
|
||||
)
|
||||
)
|
||||
basis_map_from_body, forward_source = _gravity_stable_basis_map_from_body(
|
||||
index.poses,
|
||||
index.points.times_ns[point_index],
|
||||
)
|
||||
ground_origin = _ground_origin_map(
|
||||
translation,
|
||||
basis_map_from_body,
|
||||
index.sensor_height_m,
|
||||
sensor_height_m,
|
||||
)
|
||||
points_body = _map_points_to_ground_body(
|
||||
index.points.values[point_index],
|
||||
@@ -368,17 +469,18 @@ def canonical_lab_spatial_frame(
|
||||
basis_map_from_body,
|
||||
)
|
||||
return {
|
||||
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v2",
|
||||
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||
"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],
|
||||
"coordinate_frame": "body-ground",
|
||||
"sensor_height": {
|
||||
"meters": index.sensor_height_m,
|
||||
"source": "initial-source-cloud-lower-quantile-median",
|
||||
"sample_count": index.sensor_height_sample_count,
|
||||
"mad_m": index.sensor_height_mad_m,
|
||||
"meters": sensor_height_m,
|
||||
"source": height_source,
|
||||
"sample_count": sensor_height_sample_count,
|
||||
"mad_m": sensor_height_mad_m,
|
||||
"session_fallback_meters": index.sensor_height_m,
|
||||
"authority": "visual-derived",
|
||||
},
|
||||
"spatial_profile": {
|
||||
@@ -392,6 +494,8 @@ def canonical_lab_spatial_frame(
|
||||
"origin_map_xyz_m": ground_origin.tolist(),
|
||||
"sensor_origin_map_xyz_m": translation.tolist(),
|
||||
"basis_map_from_body": basis_map_from_body.tolist(),
|
||||
"up_source": "rerun-rfu-map-gravity-axis",
|
||||
"forward_source": forward_source,
|
||||
},
|
||||
"source_point_count": int(points_body.shape[0]),
|
||||
"source_points_body_xyz_m": points_body.tolist(),
|
||||
@@ -400,3 +504,70 @@ def canonical_lab_spatial_frame(
|
||||
"local_slam_point_count": int(local_slam.shape[0]),
|
||||
"local_slam_body_xyz_m": local_slam.tolist(),
|
||||
}
|
||||
|
||||
|
||||
def canonical_lab_spatial_frame(
|
||||
recording_path: Path,
|
||||
generation_sha256: str,
|
||||
target_time_ns: int,
|
||||
) -> dict[str, object]:
|
||||
"""Return the current source cloud and bounded Local SLAM on one media 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,
|
||||
)
|
||||
return _canonical_lab_spatial_frame_from_index(index, target_time_ns)
|
||||
|
||||
|
||||
def canonical_lab_spatial_timeline_samples(
|
||||
recording_path: Path,
|
||||
generation_sha256: str,
|
||||
frame_times_ns: tuple[int, ...],
|
||||
start_sequence: int,
|
||||
frame_count: int,
|
||||
) -> tuple[dict[str, object] | None, ...]:
|
||||
"""Project only new source increments onto a denser camera timeline.
|
||||
|
||||
Camera is roughly 10 Hz in RAVNOVES004TREE while the sealed source cloud is
|
||||
roughly 2 Hz. Returning the same JSON point array for every camera frame
|
||||
multiplies transfer and parse cost and makes the viewer chase itself. A row
|
||||
is populated only when its nearest causal source increment changes; the
|
||||
canonical viewer retains that spatial frame until the next increment.
|
||||
"""
|
||||
|
||||
if (
|
||||
start_sequence < 0
|
||||
or frame_count < 1
|
||||
or start_sequence >= len(frame_times_ns)
|
||||
or any(current <= previous for previous, current in zip(frame_times_ns, frame_times_ns[1:]))
|
||||
):
|
||||
raise ValueError("Recorded LAB timeline sample request is invalid")
|
||||
stat = recording_path.stat()
|
||||
index = _load_index(
|
||||
str(recording_path),
|
||||
stat.st_size,
|
||||
stat.st_mtime_ns,
|
||||
generation_sha256,
|
||||
)
|
||||
stop = min(len(frame_times_ns), start_sequence + frame_count)
|
||||
samples: list[dict[str, object] | None] = []
|
||||
for sequence in range(start_sequence, stop):
|
||||
target_time_ns = frame_times_ns[sequence]
|
||||
point_index = _latest_index(index.points.times_ns, target_time_ns)
|
||||
previous_point_index = (
|
||||
-1
|
||||
if sequence == 0
|
||||
else _latest_index(index.points.times_ns, frame_times_ns[sequence - 1])
|
||||
)
|
||||
samples.append(
|
||||
_canonical_lab_spatial_frame_from_index(index, target_time_ns)
|
||||
if point_index != previous_point_index
|
||||
else None
|
||||
)
|
||||
return tuple(samples)
|
||||
|
||||
@@ -360,6 +360,15 @@ def _m48_recorded_camera_playback_source(
|
||||
return session_recorded_camera_frame_service.playback_source(session_id)
|
||||
|
||||
|
||||
def _canonical_lab_recording_source(session_id: str) -> tuple[Path, str] | None:
|
||||
"""Resolve one already-published immutable RRD without starting new work."""
|
||||
|
||||
snapshot = session_recording_preparation_manager.status(session_id)
|
||||
if snapshot is None or snapshot.state != "ready" or snapshot.recording is None:
|
||||
return None
|
||||
return snapshot.recording.path, snapshot.recording.sha256
|
||||
|
||||
|
||||
def refresh_observation_catalog() -> tuple[str, ...]:
|
||||
"""Discover completed or recoverable local evidence without copying payloads."""
|
||||
|
||||
@@ -1032,6 +1041,12 @@ app.include_router(
|
||||
/ "lab-v1-vegetation"
|
||||
/ "results"
|
||||
),
|
||||
canonical_recording_provider=_canonical_lab_recording_source,
|
||||
camera_frame_provider=(
|
||||
session_recorded_camera_frame_service.extract
|
||||
if session_recorded_camera_frame_service is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
|
||||
@@ -835,11 +835,11 @@ def build_session_router(
|
||||
session_id: str,
|
||||
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||
time_ns: Annotated[int, Query(ge=0, le=MAX_SAFE_INTEGER)],
|
||||
profile: Literal["source-paced-ground-v2"],
|
||||
profile: Literal["source-paced-ground-v3"],
|
||||
) -> JSONResponse:
|
||||
"""Serve one body-frame sample for the canonical recorded-LAB clock.
|
||||
|
||||
The camera timeline owns playback. Spatial evidence is sampled from
|
||||
The camera media clock owns playback. Spatial evidence is sampled from
|
||||
the same immutable recording instead of starting a second Rerun clock.
|
||||
"""
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
@@ -14,6 +17,7 @@ from typing import Any, Final
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from PIL import Image
|
||||
|
||||
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
|
||||
from k1link.laboratory.evidence_report import (
|
||||
@@ -21,9 +25,14 @@ from k1link.laboratory.evidence_report import (
|
||||
verify_laboratory_evidence_result,
|
||||
)
|
||||
from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA
|
||||
from k1link.sessions import RecordedCameraFrame, SessionIntegrityError
|
||||
from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_timeline_samples
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None]
|
||||
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
|
||||
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024
|
||||
_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 8
|
||||
_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
||||
work_id="lab-v1-vegetation-shadow",
|
||||
runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"),
|
||||
@@ -41,12 +50,17 @@ _BENCHMARK_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
||||
|
||||
|
||||
def build_vegetation_shadow_lab_router(
|
||||
*, root_provider: RootProvider = lambda: None,
|
||||
*,
|
||||
root_provider: RootProvider = lambda: None,
|
||||
canonical_recording_provider: CanonicalRecordingProvider | None = None,
|
||||
camera_frame_provider: CameraFrameProvider | None = None,
|
||||
) -> APIRouter:
|
||||
return _build_vegetation_lab_router(
|
||||
prefix="/api/v1/laboratory/vegetation-shadow",
|
||||
definition=_DEFINITION,
|
||||
root_provider=root_provider,
|
||||
canonical_recording_provider=canonical_recording_provider,
|
||||
camera_frame_provider=camera_frame_provider,
|
||||
)
|
||||
|
||||
|
||||
@@ -65,6 +79,8 @@ def _build_vegetation_lab_router(
|
||||
prefix: str,
|
||||
definition: LaboratoryEvidenceDefinition,
|
||||
root_provider: RootProvider,
|
||||
canonical_recording_provider: CanonicalRecordingProvider | None = None,
|
||||
camera_frame_provider: CameraFrameProvider | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(
|
||||
prefix=prefix,
|
||||
@@ -263,6 +279,143 @@ def _build_vegetation_lab_router(
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/{result_id}/timeline")
|
||||
def get_canonical_route_timeline(result_id: str) -> dict[str, object]:
|
||||
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||
manifest = _read_verified(candidate, definition)
|
||||
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||
intervals = [
|
||||
(current - previous) / 1_000_000_000
|
||||
for previous, current in zip(frame_times_ns, frame_times_ns[1:])
|
||||
]
|
||||
nominal_interval = statistics.median(intervals)
|
||||
if not math.isfinite(nominal_interval) or nominal_interval <= 0:
|
||||
raise HTTPException(status_code=503, detail="Full-route timeline cadence is invalid")
|
||||
return {
|
||||
"schema_version": "missioncore.recorded-spatial-evidence-timeline/v1",
|
||||
"result_id": result_id,
|
||||
"recorded_source": {
|
||||
"session_id": route["session_id"],
|
||||
"source_id": route["source_id"],
|
||||
"representation_id": "registered-map-increment-v1",
|
||||
"synchronization": "host-arrival-best-effort",
|
||||
},
|
||||
"frame_count": len(frame_times_ns),
|
||||
"frame_times_ns": list(frame_times_ns),
|
||||
"timeline_start_seconds": frame_times_ns[0] / 1_000_000_000,
|
||||
"timeline_end_seconds": frame_times_ns[-1] / 1_000_000_000,
|
||||
"nominal_frame_interval_seconds": nominal_interval,
|
||||
"nominal_rate_hz": 1.0 / nominal_interval,
|
||||
"max_chunk_frames": _CANONICAL_ROUTE_CHUNK_FRAMES,
|
||||
"point_sample_limit": 100_000,
|
||||
"maximum_source_points_per_frame": 100_000,
|
||||
"point_delivery": "exact-current-increment",
|
||||
"world_state_frame_count": len(frame_times_ns),
|
||||
"superseded_frame_count": 0,
|
||||
"local_surface_visualization": {
|
||||
"derivation": "bounded-registered-increment-accumulation",
|
||||
"window_seconds": 5.0,
|
||||
"voxel_size_m": 0.12,
|
||||
"radius_m": 30.0,
|
||||
"point_limit": 27_000,
|
||||
"authority": "visual-derived",
|
||||
},
|
||||
"image_width": route["width"],
|
||||
"image_height": route["height"],
|
||||
"rig": {"length_m": 1.0, "width_m": 0.8, "nominal_sensor_height_m": 0.4},
|
||||
"corridor": {
|
||||
"forward_length_m": 8.0,
|
||||
"rear_margin_m": 0.5,
|
||||
"occupied_voxel_size_m": 0.45,
|
||||
"half_width_m": 0.6,
|
||||
"prediction_horizon_seconds": 8.0,
|
||||
},
|
||||
"ground_truth": False,
|
||||
"authority": "replay-simulated",
|
||||
"access": "read-only-bounded-recorded-replay",
|
||||
}
|
||||
|
||||
@router.get("/{result_id}/timeline/chunk")
|
||||
def get_canonical_route_timeline_chunk(
|
||||
result_id: str,
|
||||
start: int = 0,
|
||||
count: int = _CANONICAL_ROUTE_CHUNK_FRAMES,
|
||||
include_points: bool = True,
|
||||
) -> dict[str, object]:
|
||||
if start < 0 or not 1 <= count <= _CANONICAL_ROUTE_CHUNK_FRAMES:
|
||||
raise HTTPException(status_code=422, detail="Full-route timeline chunk is invalid")
|
||||
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||
manifest = _read_verified(candidate, definition)
|
||||
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||
if start >= len(frame_times_ns):
|
||||
raise HTTPException(status_code=404, detail="Full-route timeline chunk not found")
|
||||
if canonical_recording_provider is None:
|
||||
raise HTTPException(status_code=503, detail="Canonical spatial recording is unavailable")
|
||||
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:
|
||||
samples = canonical_lab_spatial_timeline_samples(
|
||||
recording_path,
|
||||
generation_sha256,
|
||||
frame_times_ns,
|
||||
start,
|
||||
count,
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
raise HTTPException(status_code=503, detail="Canonical spatial chunk failed") from None
|
||||
stop = start + len(samples)
|
||||
frames = [
|
||||
_canonical_timeline_frame(
|
||||
result_id=result_id,
|
||||
endpoint_prefix=prefix,
|
||||
candidate=candidate,
|
||||
route=route,
|
||||
sequence=sequence,
|
||||
source_time_ns=frame_times_ns[sequence],
|
||||
spatial=sample,
|
||||
include_points=include_points,
|
||||
)
|
||||
for sequence, sample in zip(range(start, stop), samples, strict=True)
|
||||
]
|
||||
return {
|
||||
"schema_version": "missioncore.recorded-spatial-evidence-chunk/v1",
|
||||
"result_id": result_id,
|
||||
"start_sequence": start,
|
||||
"frame_count": len(frames),
|
||||
"next_sequence": stop if stop < len(frame_times_ns) else None,
|
||||
"frames": frames,
|
||||
"ground_truth": False,
|
||||
"authority": "replay-simulated",
|
||||
"access": "read-only-bounded-recorded-replay",
|
||||
}
|
||||
|
||||
@router.get("/{result_id}/timeline/frames/{sequence}/camera")
|
||||
def get_canonical_route_camera(result_id: str, sequence: int) -> Response:
|
||||
if camera_frame_provider is None:
|
||||
raise HTTPException(status_code=503, detail="Recorded camera decoder is unavailable")
|
||||
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||
manifest = _read_verified(candidate, definition)
|
||||
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||
if not 0 <= sequence < len(frame_times_ns):
|
||||
raise HTTPException(status_code=404, detail="Full-route camera frame not found")
|
||||
try:
|
||||
camera = camera_frame_provider(str(route["session_id"]), sequence)
|
||||
except (OSError, SessionIntegrityError, ValueError):
|
||||
raise HTTPException(status_code=503, detail="Full-route camera frame unavailable") from None
|
||||
if camera.width != route["width"] or camera.height != route["height"]:
|
||||
raise HTTPException(status_code=503, detail="Full-route camera dimensions changed")
|
||||
return Response(
|
||||
content=camera.payload,
|
||||
media_type=camera.media_type,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{camera.sha256}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
@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)
|
||||
@@ -314,6 +467,242 @@ def _build_vegetation_lab_router(
|
||||
return router
|
||||
|
||||
|
||||
def _full_route_context(
|
||||
candidate: Path,
|
||||
manifest: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], tuple[int, ...]]:
|
||||
route = manifest.get("route_full_review")
|
||||
timeline = route.get("timeline") if isinstance(route, dict) else None
|
||||
relative_text = timeline.get("path") if isinstance(timeline, dict) else None
|
||||
if (
|
||||
not isinstance(route, dict)
|
||||
or route.get("source_id") != "RAVNOVES004TREE"
|
||||
or route.get("session_id") != "20260828T130511Z_viewer_live"
|
||||
or route.get("frame_count") != 6830
|
||||
or route.get("width") != 800
|
||||
or route.get("height") != 600
|
||||
or not isinstance(relative_text, str)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Full-route canonical timeline not found")
|
||||
path = candidate.joinpath(*PurePosixPath(relative_text).parts)
|
||||
try:
|
||||
payload = path.read_bytes()
|
||||
if (
|
||||
len(payload) != timeline.get("byte_length")
|
||||
or hashlib.sha256(payload).hexdigest() != timeline.get("sha256")
|
||||
):
|
||||
raise ValueError("timeline digest changed")
|
||||
values = np.frombuffer(payload, dtype="<u8")
|
||||
frame_times_ns = tuple(int(value) for value in values)
|
||||
except (OSError, ValueError):
|
||||
raise HTTPException(status_code=503, detail="Full-route timeline verification failed") from None
|
||||
if (
|
||||
len(frame_times_ns) != route["frame_count"]
|
||||
or any(current <= previous for previous, current in zip(frame_times_ns, frame_times_ns[1:]))
|
||||
):
|
||||
raise HTTPException(status_code=503, detail="Full-route timeline order changed")
|
||||
return route, frame_times_ns
|
||||
|
||||
|
||||
def _canonical_timeline_frame(
|
||||
*,
|
||||
result_id: str,
|
||||
endpoint_prefix: str,
|
||||
candidate: Path,
|
||||
route: dict[str, Any],
|
||||
sequence: int,
|
||||
source_time_ns: int,
|
||||
spatial: dict[str, object] | None,
|
||||
include_points: bool,
|
||||
) -> dict[str, object]:
|
||||
points = [] if spatial is None or not include_points else spatial["source_points_body_xyz_m"]
|
||||
point_count = 0 if spatial is None else int(spatial["source_point_count"])
|
||||
body_frame = None if spatial is None else spatial["body_frame"]
|
||||
local_slam = [] if spatial is None else spatial["local_slam_body_xyz_m"]
|
||||
return {
|
||||
"schema_version": "missioncore.recorded-spatial-evidence-frame/v1",
|
||||
"sequence": sequence,
|
||||
"frame_id": f"frame-{sequence:06d}",
|
||||
"source_time_ns": source_time_ns,
|
||||
"session_seconds": source_time_ns / 1_000_000_000,
|
||||
"source_available": spatial is not None,
|
||||
"spatial_available": spatial is not None,
|
||||
"world_state_available": True,
|
||||
"terminal_outcome": "delivered",
|
||||
"body_frame": body_frame,
|
||||
"point_cloud_body_xyz_m": points,
|
||||
"point_cloud_source_count": point_count,
|
||||
"point_cloud_sample_count": point_count if not include_points else len(points),
|
||||
"point_cloud_layer": "current-increment",
|
||||
"local_slam_body_xyz_m": local_slam,
|
||||
"local_slam_source_frame_count": 0
|
||||
if spatial is None else spatial["local_slam_source_frame_count"],
|
||||
"local_slam_source_point_count": 0
|
||||
if spatial is None else spatial["local_slam_source_point_count"],
|
||||
"rolling_map_component_count": 0,
|
||||
"metric_obstacles": [],
|
||||
"camera_proposals": _semantic_component_proposals(candidate, route, sequence),
|
||||
"decision_counts": {"threat": 0, "not-threat": 0, "unknown": 0},
|
||||
"camera_url": (
|
||||
f"{endpoint_prefix}/{result_id}/timeline/frames/{sequence}/camera"
|
||||
),
|
||||
"ground_truth": False,
|
||||
"authority": "replay-simulated",
|
||||
}
|
||||
|
||||
|
||||
def _semantic_component_proposals(
|
||||
candidate: Path,
|
||||
route: dict[str, Any],
|
||||
sequence: int,
|
||||
) -> list[dict[str, object]]:
|
||||
layers = route.get("layers")
|
||||
city = layers.get("city") if isinstance(layers, dict) else None
|
||||
archive = city.get("mask_archive") if isinstance(city, dict) else None
|
||||
relative = archive.get("path") if isinstance(archive, dict) else None
|
||||
if not isinstance(relative, str):
|
||||
return []
|
||||
archive_path = candidate.joinpath(*PurePosixPath(relative).parts)
|
||||
try:
|
||||
stat = archive_path.stat()
|
||||
except OSError:
|
||||
return []
|
||||
return [
|
||||
dict(proposal)
|
||||
for proposal in _semantic_component_proposals_cached(
|
||||
str(archive_path),
|
||||
stat.st_size,
|
||||
stat.st_mtime_ns,
|
||||
sequence,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _semantic_component_proposals_cached(
|
||||
archive_path_text: str,
|
||||
archive_size: int,
|
||||
archive_mtime_ns: int,
|
||||
sequence: int,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
del archive_size, archive_mtime_ns
|
||||
archive_path = Path(archive_path_text)
|
||||
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as frozen:
|
||||
payload = frozen.read(member)
|
||||
with Image.open(io.BytesIO(payload)) as image:
|
||||
mask = np.asarray(image.convert("L"), dtype=np.uint8)
|
||||
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
||||
return ()
|
||||
labels = {
|
||||
1: "semantic person",
|
||||
2: "semantic bicycle",
|
||||
3: "semantic motorcycle",
|
||||
4: "semantic car",
|
||||
5: "semantic heavy vehicle",
|
||||
13: "semantic static obstacle",
|
||||
14: "semantic animal",
|
||||
}
|
||||
proposals: list[dict[str, object]] = []
|
||||
for class_id, label in labels.items():
|
||||
minimum_pixels = 80 if class_id == 13 else 24
|
||||
for component_index, (left, top, right, bottom, pixel_count) in enumerate(
|
||||
_mask_component_boxes(mask, class_id, minimum_pixels=minimum_pixels)[:12]
|
||||
):
|
||||
proposals.append({
|
||||
"proposal_id": f"semantic-{class_id}-{sequence}-{component_index}",
|
||||
"bbox_xyxy": [left, top, right, bottom],
|
||||
"objectness": round(min(0.99, 0.5 + pixel_count / 20_000), 4),
|
||||
"semantic_hint": label,
|
||||
"occupied_support": False,
|
||||
"range_m": None,
|
||||
"threat_decision": None,
|
||||
"threat_reason_codes": ["semantic-mask-derived-not-fail-safe-detector"],
|
||||
})
|
||||
proposals.sort(
|
||||
key=lambda proposal: (
|
||||
-float(proposal["objectness"]),
|
||||
str(proposal["proposal_id"]),
|
||||
)
|
||||
)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user