refactor(lab): canonicalize recorded spatial replay
This commit is contained in:
@@ -2,9 +2,11 @@
|
||||
|
||||
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.
|
||||
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
|
||||
clock without a per-LAB coordinate adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,6 +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"
|
||||
_POINT_ENTITY: Final = "/world/points"
|
||||
_POSE_ENTITY: Final = "/world/sensor_pose"
|
||||
_TRAJECTORY_ENTITY: Final = "/world/trajectory"
|
||||
@@ -27,6 +30,16 @@ _POSE_TRANSLATION_COMPONENT: Final = "Transform3D:translation"
|
||||
_POSE_QUATERNION_COMPONENT: Final = "Transform3D:quaternion"
|
||||
_TRAJECTORY_COMPONENT: Final = "LineStrips3D:strips"
|
||||
_INDEX_LOCK: Final = Lock()
|
||||
_HEIGHT_CALIBRATION_SECONDS: Final = 60.0
|
||||
_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_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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -47,6 +60,9 @@ class _CanonicalSpatialIndex:
|
||||
points: _TimedPoints
|
||||
poses: _TimedPoses
|
||||
trajectories: _TimedPoints
|
||||
sensor_height_m: float
|
||||
sensor_height_sample_count: int
|
||||
sensor_height_mad_m: float
|
||||
|
||||
|
||||
def _session_times(batch: Any) -> Any | None:
|
||||
@@ -159,7 +175,17 @@ def _load_index_cached(
|
||||
)
|
||||
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)
|
||||
sensor_height_m, sensor_height_sample_count, sensor_height_mad_m = (
|
||||
_estimate_sensor_height(points, poses)
|
||||
)
|
||||
return _CanonicalSpatialIndex(
|
||||
points=points,
|
||||
poses=poses,
|
||||
trajectories=trajectories,
|
||||
sensor_height_m=sensor_height_m,
|
||||
sensor_height_sample_count=sensor_height_sample_count,
|
||||
sensor_height_mad_m=sensor_height_mad_m,
|
||||
)
|
||||
|
||||
|
||||
def _load_index(
|
||||
@@ -207,12 +233,108 @@ def _map_points_to_body(
|
||||
return body.astype(np.float32)
|
||||
|
||||
|
||||
def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[float, int, float]:
|
||||
"""Estimate one session mount height from the initial qualified cloud.
|
||||
|
||||
The K1 recording has no explicit physical mount-height entity. The initial
|
||||
stationary minute is therefore the only admissible automatic calibration
|
||||
source. A low near-field quantile is measured per source increment and the
|
||||
session median rejects vegetation/ravine outliers. The result stays
|
||||
diagnostic and is never promoted to navigation authority by this adapter.
|
||||
"""
|
||||
|
||||
first_time_ns = points.times_ns[0]
|
||||
calibration_end_ns = first_time_ns + round(_HEIGHT_CALIBRATION_SECONDS * 1_000_000_000)
|
||||
candidates = [
|
||||
index
|
||||
for index, timestamp in enumerate(points.times_ns)
|
||||
if timestamp <= calibration_end_ns
|
||||
][:_HEIGHT_CALIBRATION_MAX_FRAMES]
|
||||
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[
|
||||
(radius >= _HEIGHT_NEAR_MIN_RADIUS_M)
|
||||
& (radius <= _HEIGHT_NEAR_MAX_RADIUS_M)
|
||||
& (body[:, 2] >= -2.0)
|
||||
& (body[:, 2] <= 0.5)
|
||||
]
|
||||
if eligible.shape[0] < 100:
|
||||
continue
|
||||
estimate = -float(np.quantile(eligible[:, 2], _HEIGHT_LOWER_QUANTILE))
|
||||
if 0.08 <= estimate <= 2.5:
|
||||
estimates.append(estimate)
|
||||
if len(estimates) < 8:
|
||||
raise ValueError("Recorded LAB sensor height cannot be estimated from source cloud")
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _map_points_to_ground_body(
|
||||
points_map: np.ndarray,
|
||||
ground_origin_map: np.ndarray,
|
||||
basis_map_from_body: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
body = (points_map.astype(np.float64) - ground_origin_map) @ basis_map_from_body
|
||||
return body.astype(np.float32)
|
||||
|
||||
|
||||
def _bounded_local_slam(
|
||||
points: _TimedPoints,
|
||||
target_time_ns: int,
|
||||
ground_origin_map: np.ndarray,
|
||||
basis_map_from_body: np.ndarray,
|
||||
) -> tuple[np.ndarray, int, int]:
|
||||
start_ns = target_time_ns - round(_LOCAL_SLAM_HISTORY_SECONDS * 1_000_000_000)
|
||||
first = bisect_right(points.times_ns, start_ns - 1)
|
||||
last = bisect_right(points.times_ns, target_time_ns)
|
||||
selected = points.values[first:last]
|
||||
if not selected:
|
||||
return np.empty((0, 3), dtype=np.float32), 0, 0
|
||||
source_count = sum(int(value.shape[0]) for value in selected)
|
||||
local = _map_points_to_ground_body(
|
||||
np.concatenate(selected, axis=0),
|
||||
ground_origin_map,
|
||||
basis_map_from_body,
|
||||
)
|
||||
mask = (
|
||||
(np.linalg.norm(local[:, :2], axis=1) <= _LOCAL_SLAM_RADIUS_M)
|
||||
& (np.abs(local[:, 2]) <= _LOCAL_SLAM_VERTICAL_LIMIT_M)
|
||||
)
|
||||
local = local[mask]
|
||||
if local.shape[0] == 0:
|
||||
return local, len(selected), source_count
|
||||
voxel = np.floor(local / _LOCAL_SLAM_VOXEL_SIZE_M).astype(np.int32)
|
||||
_, retained = np.unique(voxel, axis=0, return_index=True)
|
||||
local = local[np.sort(retained)]
|
||||
if local.shape[0] > _LOCAL_SLAM_POINT_LIMIT:
|
||||
stride = int(np.ceil(local.shape[0] / _LOCAL_SLAM_POINT_LIMIT))
|
||||
local = local[::stride][:_LOCAL_SLAM_POINT_LIMIT]
|
||||
return np.ascontiguousarray(local, dtype=np.float32), len(selected), source_count
|
||||
|
||||
|
||||
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."""
|
||||
"""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")
|
||||
@@ -229,31 +351,52 @@ def canonical_lab_spatial_frame(
|
||||
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],
|
||||
ground_origin = _ground_origin_map(
|
||||
translation,
|
||||
quaternion,
|
||||
basis_map_from_body,
|
||||
index.sensor_height_m,
|
||||
)
|
||||
# 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)
|
||||
points_body = _map_points_to_ground_body(
|
||||
index.points.values[point_index],
|
||||
ground_origin,
|
||||
basis_map_from_body,
|
||||
)
|
||||
local_slam, local_slam_source_frames, local_slam_source_points = _bounded_local_slam(
|
||||
index.points,
|
||||
index.points.times_ns[point_index],
|
||||
ground_origin,
|
||||
basis_map_from_body,
|
||||
)
|
||||
local_trajectory = trajectory_body[local_mask]
|
||||
return {
|
||||
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v1",
|
||||
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v2",
|
||||
"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,
|
||||
"authority": "visual-derived",
|
||||
},
|
||||
"spatial_profile": {
|
||||
"profile_id": CANONICAL_LAB_SPATIAL_PROFILE,
|
||||
"local_slam_history_seconds": _LOCAL_SLAM_HISTORY_SECONDS,
|
||||
"local_slam_radius_m": _LOCAL_SLAM_RADIUS_M,
|
||||
"local_slam_voxel_size_m": _LOCAL_SLAM_VOXEL_SIZE_M,
|
||||
"local_slam_point_limit": _LOCAL_SLAM_POINT_LIMIT,
|
||||
},
|
||||
"body_frame": {
|
||||
"origin_map_xyz_m": translation.tolist(),
|
||||
"origin_map_xyz_m": ground_origin.tolist(),
|
||||
"sensor_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(),
|
||||
"local_slam_source_frame_count": local_slam_source_frames,
|
||||
"local_slam_source_point_count": local_slam_source_points,
|
||||
"local_slam_point_count": int(local_slam.shape[0]),
|
||||
"local_slam_body_xyz_m": local_slam.tolist(),
|
||||
}
|
||||
|
||||
@@ -37,7 +37,10 @@ from k1link.sessions import (
|
||||
SessionStore,
|
||||
validate_recorded_media_timeline,
|
||||
)
|
||||
from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_frame
|
||||
from k1link.sessions.canonical_lab_spatial import (
|
||||
CANONICAL_LAB_SPATIAL_PROFILE,
|
||||
canonical_lab_spatial_frame,
|
||||
)
|
||||
from k1link.sessions.plugin_contract import RecordedPointColorRenderer
|
||||
from k1link.viewer.recorded import (
|
||||
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
||||
@@ -832,6 +835,7 @@ 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"],
|
||||
) -> JSONResponse:
|
||||
"""Serve one body-frame sample for the canonical recorded-LAB clock.
|
||||
|
||||
@@ -890,7 +894,10 @@ def build_session_router(
|
||||
payload,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{generation}:{payload["source_time_ns"]}"',
|
||||
"ETag": (
|
||||
f'"{generation}:{CANONICAL_LAB_SPATIAL_PROFILE}:'
|
||||
f'{payload["source_time_ns"]}"'
|
||||
),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user