feat: activate self-hosted lidar spatial scene

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 10:36:51 +03:00
parent 6402601d6d
commit 904c6ea13a
27 changed files with 2009 additions and 646 deletions
+386
View File
@@ -0,0 +1,386 @@
from __future__ import annotations
import math
import time
from collections import deque
from collections.abc import Callable
from dataclasses import asdict, dataclass
from typing import Literal
import numpy as np
import rerun as rr
from rerun import blueprint as rrb
from k1link.protocol.streams import (
LegacyPointCloudFrame,
LegacyPoseFrame,
LioPointCloudFrame,
LioPoseFrame,
StreamDecodeError,
decode_legacy_pointcloud,
decode_legacy_pose,
decode_lio_pcl,
decode_lio_pose,
)
from k1link.viewer.foxglove_bridge import BridgeMetrics
from k1link.viewer.messages import StreamMessage
PointColorMode = Literal["intensity", "height", "distance", "rgb", "class"]
PointPalette = Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
MAX_TRAJECTORY_POSES = 20_000
DEFAULT_GRPC_PORT = 9876
DEFAULT_CORS_ORIGINS = (
"http://127.0.0.1:5173",
"http://localhost:5173",
"http://127.0.0.1:4173",
"http://localhost:4173",
"http://127.0.0.1:8000",
"http://localhost:8000",
)
@dataclass(frozen=True, slots=True)
class RerunSceneSettings:
point_size: float = 2.5
color_mode: PointColorMode = "intensity"
palette: PointPalette = "turbo"
custom_color: str = "#f7f8f4"
accumulation_seconds: float = 12.0
show_points: bool = True
show_trajectory: bool = True
show_grid: bool = True
def as_dict(self) -> dict[str, object]:
return asdict(self)
SettingsProvider = Callable[[], RerunSceneSettings]
class RerunBridge:
"""Decode verified device topics into a self-hosted Rerun recording stream."""
def __init__(
self,
*,
grpc_port: int = DEFAULT_GRPC_PORT,
metrics: BridgeMetrics | None = None,
settings_provider: SettingsProvider | None = None,
cors_allow_origin: tuple[str, ...] = DEFAULT_CORS_ORIGINS,
recording_factory: Callable[[str], rr.RecordingStream] | None = None,
) -> None:
self.metrics = metrics or BridgeMetrics()
self._settings_provider = settings_provider or RerunSceneSettings
self._settings = self._settings_provider()
self._recording = (recording_factory or rr.RecordingStream)(
"nodedc_device_spatial"
)
blueprint = _blueprint(self._settings)
self._url = self._recording.serve_grpc(
grpc_port=grpc_port,
default_blueprint=blueprint,
server_memory_limit="512MiB",
newest_first=True,
cors_allow_origin=list(cors_allow_origin),
)
self._recording.send_blueprint(
blueprint,
make_active=True,
make_default=True,
)
self._recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
self._recording.log(
"/world/sensor_pose",
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
static=True,
)
self._path: deque[tuple[float, float, float]] = deque(
maxlen=MAX_TRAJECTORY_POSES
)
self._last_trajectory_publish_ns = 0
self._last_point_count = 0
self._closed = False
@property
def grpc_url(self) -> str:
return self._url
def begin_session(self, metrics: BridgeMetrics | None = None) -> None:
"""Reset session-local state while keeping the process-wide server alive."""
if self._closed:
raise RuntimeError("Rerun bridge is already closed")
if metrics is not None:
self.metrics = metrics
self._settings = self._settings_provider()
self._path.clear()
self._last_trajectory_publish_ns = 0
self._last_point_count = 0
self._recording.set_time("stream_time", timestamp=time.time())
self._recording.log("/world", rr.Clear(recursive=True))
self._recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
self._recording.log(
"/world/sensor_pose",
rr.TransformAxes3D(axis_length=0.45, show_frame=True),
static=True,
)
self._recording.send_blueprint(
_blueprint(self._settings),
make_active=True,
make_default=True,
)
def process(self, message: StreamMessage) -> None:
started_ns = time.monotonic_ns()
self.metrics.received(len(message.payload))
self._apply_latest_settings()
self._set_message_time(message)
try:
if message.topic.endswith("/lio_pcl"):
self._publish_lio_pcl(decode_lio_pcl(message.payload))
point_frame = True
elif message.topic == "RealtimePointcloud":
self._publish_legacy_pcl(decode_legacy_pointcloud(message.payload))
point_frame = True
elif message.topic.endswith("/lio_pose"):
self._publish_lio_pose(decode_lio_pose(message.payload))
point_frame = False
elif message.topic == "RealtimePath":
self._publish_legacy_pose(decode_legacy_pose(message.payload))
point_frame = False
else:
return
except StreamDecodeError:
self.metrics.decode_error()
return
published_ns = time.monotonic_ns()
decode_publish_ms = (published_ns - started_ns) / 1_000_000
if point_frame:
self.metrics.published_pcl(
self._last_point_count,
published_ns,
decode_publish_ms,
)
else:
self.metrics.published_pose(
published_ns,
decode_publish_ms,
len(self._path),
)
if message.source == "live_mqtt" and message.received_monotonic_ns is not None:
self.metrics.record_latency(
(published_ns - message.received_monotonic_ns) / 1_000_000
)
def close(self) -> None:
if self._closed:
return
self._closed = True
recording = self._recording
try:
recording.flush(timeout_sec=5.0)
finally:
recording.disconnect()
# The Python wrapper owns the native gRPC server. Release it immediately
# instead of waiting for the publisher thread frame to be collected.
del self._recording
def _set_message_time(self, message: StreamMessage) -> None:
self._recording.set_time("stream_time", timestamp=time.time())
self._recording.set_time(
"capture_time",
timestamp=message.received_at_epoch_ns / 1_000_000_000,
)
self._recording.set_time("message_sequence", sequence=message.sequence)
def _apply_latest_settings(self) -> None:
settings = self._settings_provider()
if settings == self._settings:
return
self._settings = settings
self._recording.send_blueprint(_blueprint(settings))
def _publish_lio_pcl(self, frame: LioPointCloudFrame) -> None:
count = len(frame.points)
positions = np.empty((count, 3), dtype=np.float32)
intensities = np.empty(count, dtype=np.uint8)
scaler = frame.header.scaler
for index, point in enumerate(frame.points):
positions[index] = point.scaled_xyz(scaler)
intensities[index] = point.intensity
self._publish_points(positions, intensities, rgb=None)
def _publish_legacy_pcl(self, frame: LegacyPointCloudFrame) -> None:
count = len(frame.points)
positions = np.empty((count, 3), dtype=np.float32)
intensities = np.empty(count, dtype=np.uint8)
rgb = np.empty((count, 3), dtype=np.uint8)
for index, point in enumerate(frame.points):
positions[index] = (point.x, point.y, point.z)
intensities[index] = point.intensity
rgb[index] = (point.r, point.g, point.b)
self._publish_points(positions, intensities, rgb=rgb)
def _publish_points(
self,
positions: np.ndarray,
intensities: np.ndarray,
*,
rgb: np.ndarray | None,
) -> None:
self._last_point_count = int(positions.shape[0])
if not self._settings.show_points:
self._recording.log("/world/points", rr.Clear(recursive=False))
return
colors = _point_colors(positions, intensities, rgb, self._settings)
self._recording.log(
"/world/points",
rr.Points3D(
positions,
colors=colors,
radii=rr.Radius.ui_points(self._settings.point_size),
),
)
def _publish_lio_pose(self, frame: LioPoseFrame) -> None:
self._publish_pose(frame.position_xyz, frame.orientation_xyzw)
def _publish_legacy_pose(self, frame: LegacyPoseFrame) -> None:
self._publish_pose(frame.position_xyz, frame.orientation_xyzw)
def _publish_pose(
self,
position_xyz: tuple[float, float, float],
orientation_xyzw: tuple[float, float, float, float],
) -> None:
self._recording.log(
"/world/sensor_pose",
rr.Transform3D(
translation=position_xyz,
quaternion=rr.Quaternion(xyzw=orientation_xyzw),
),
)
self._path.append(position_xyz)
if not self._settings.show_trajectory:
self._recording.log("/world/trajectory", rr.Clear(recursive=False))
return
now_ns = time.monotonic_ns()
if (
len(self._path) > 2
and len(self._path) % 20 != 0
and now_ns - self._last_trajectory_publish_ns < 200_000_000
):
return
self._last_trajectory_publish_ns = now_ns
self._recording.log(
"/world/trajectory",
rr.LineStrips3D(
[list(self._path)],
colors=[247, 248, 244, 255],
radii=rr.Radius.ui_points(2.0),
),
)
def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
accumulation = max(0.0, settings.accumulation_seconds)
time_range = rr.VisibleTimeRange(
"stream_time",
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
end=rr.TimeRangeBoundary.cursor_relative(),
)
return rrb.Blueprint(
rrb.Spatial3DView(
origin="/world",
name="Пространственная сцена",
background=[7, 8, 10, 255],
line_grid=rrb.LineGrid3D(
visible=settings.show_grid,
color=[86, 91, 99, 110],
stroke_width=0.75,
),
time_ranges=[time_range],
),
auto_layout=False,
auto_views=False,
collapse_panels=True,
)
def _point_colors(
positions: np.ndarray,
intensities: np.ndarray,
rgb: np.ndarray | None,
settings: RerunSceneSettings,
) -> np.ndarray:
if settings.color_mode == "rgb" and rgb is not None:
return rgb
if settings.color_mode == "class":
color = _parse_hex_color(settings.custom_color)
return np.tile(np.asarray(color, dtype=np.uint8), (positions.shape[0], 1))
if settings.color_mode == "height":
values = _normalize(positions[:, 2])
elif settings.color_mode == "distance":
values = _normalize(np.linalg.norm(positions, axis=1))
else:
values = intensities.astype(np.float32) / 255.0
return _apply_palette(values, settings.palette, settings.custom_color)
def _normalize(values: np.ndarray) -> np.ndarray:
if values.size == 0:
return values.astype(np.float32)
minimum = float(np.min(values))
maximum = float(np.max(values))
if not math.isfinite(minimum) or not math.isfinite(maximum) or maximum <= minimum:
return np.full(values.shape, 0.5, dtype=np.float32)
return ((values - minimum) / (maximum - minimum)).astype(np.float32)
def _apply_palette(values: np.ndarray, palette: PointPalette, custom: str) -> np.ndarray:
clipped = np.clip(values, 0.0, 1.0)
if palette == "custom":
color = _parse_hex_color(custom)
return np.tile(np.asarray(color, dtype=np.uint8), (values.size, 1))
if palette == "grayscale":
channel = np.rint(clipped * 255).astype(np.uint8)
return np.column_stack((channel, channel, channel))
stops = {
"turbo": (
(0.00, (48, 18, 59)),
(0.25, (40, 126, 231)),
(0.50, (42, 240, 154)),
(0.75, (246, 214, 55)),
(1.00, (180, 4, 38)),
),
"viridis": (
(0.00, (68, 1, 84)),
(0.25, (59, 82, 139)),
(0.50, (33, 145, 140)),
(0.75, (94, 201, 98)),
(1.00, (253, 231, 37)),
),
"plasma": (
(0.00, (13, 8, 135)),
(0.25, (126, 3, 168)),
(0.50, (204, 71, 120)),
(0.75, (248, 149, 64)),
(1.00, (240, 249, 33)),
),
}[palette]
positions = np.asarray([item[0] for item in stops], dtype=np.float32)
colors = np.asarray([item[1] for item in stops], dtype=np.float32)
channels = [np.interp(clipped, positions, colors[:, index]) for index in range(3)]
return np.rint(np.column_stack(channels)).astype(np.uint8)
def _parse_hex_color(value: str) -> tuple[int, int, int]:
candidate = value.removeprefix("#")
if len(candidate) != 6:
return 247, 248, 244
try:
return tuple(int(candidate[index : index + 2], 16) for index in (0, 2, 4)) # type: ignore[return-value]
except ValueError:
return 247, 248, 244