feat(viewer): add recorded point colors and trajectory follow
This commit is contained in:
@@ -11,6 +11,9 @@ from k1link.device_plugins.xgrids_k1.archive import (
|
||||
LegacySessionCandidate,
|
||||
discover_legacy_viewer_sessions,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.recorded_point_colors import (
|
||||
RecordedPointColorOverlayStore,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.rrd_export import (
|
||||
RrdExportCancelled,
|
||||
RrdExportError,
|
||||
@@ -45,6 +48,7 @@ def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeCont
|
||||
resolve_missioncore_evidence_dir(repository_root),
|
||||
),
|
||||
)
|
||||
point_colors = RecordedPointColorOverlayStore()
|
||||
return ObservationRuntimeContribution(
|
||||
archives=tuple(
|
||||
ObservationArchiveSource(
|
||||
@@ -57,6 +61,7 @@ def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeCont
|
||||
for archive_id, root in roots
|
||||
),
|
||||
recording_exporter=_export_recording,
|
||||
point_color_renderer=point_colors.render,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
"""On-demand point-color overlays for prepared K1 recordings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import rerun as rr
|
||||
|
||||
from k1link.data_plane import DecodedPointCloudView, NormalizationError
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
|
||||
CaptureFormatError,
|
||||
read_capture_clock_envelope,
|
||||
read_capture_clock_origin,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
|
||||
from k1link.device_plugins.xgrids_k1.rrd_export import (
|
||||
APPLICATION_ID,
|
||||
CAPTURE_TIMELINE,
|
||||
SESSION_TIMELINE,
|
||||
_recorded_view_points,
|
||||
_should_publish_recorded_point_frame,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||
from k1link.sessions import ReplayCommand
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings, _point_colors
|
||||
|
||||
_POINT_TOPICS = frozenset({"RealtimePointcloud", "lixel/application/report/lio_pcl"})
|
||||
_MAX_METADATA_LINE_BYTES = 1024 * 1024
|
||||
_MAX_PAYLOAD_BYTES = 256 * 1024 * 1024
|
||||
_DEFAULT_INDEX_CACHE_BYTES = 384 * 1024 * 1024
|
||||
_DEFAULT_PAYLOAD_CACHE_BYTES = 192 * 1024 * 1024
|
||||
|
||||
|
||||
class RecordedPointColorError(RuntimeError):
|
||||
"""A recorded point-color overlay could not be prepared safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PointColorFrame:
|
||||
sequence: int
|
||||
session_time_ns: int
|
||||
capture_time_ns: int
|
||||
positions: np.ndarray
|
||||
intensities: np.ndarray
|
||||
rgb: np.ndarray | None
|
||||
|
||||
@property
|
||||
def byte_length(self) -> int:
|
||||
return (
|
||||
int(self.positions.nbytes)
|
||||
+ int(self.intensities.nbytes)
|
||||
+ (0 if self.rgb is None else int(self.rgb.nbytes))
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PointColorIndex:
|
||||
source_identity: tuple[object, ...]
|
||||
frames: tuple[_PointColorFrame, ...]
|
||||
byte_length: int
|
||||
|
||||
|
||||
class RecordedPointColorOverlayStore:
|
||||
"""Build small color-only RRD streams and retain bounded reusable inputs.
|
||||
|
||||
The base operator RRD already owns point positions. These overlays log only
|
||||
``Points3D:colors`` at the exact same session timestamps, so changing a
|
||||
palette never duplicates the point geometry, trajectory, video, or AI data.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
index_cache_bytes: int = _DEFAULT_INDEX_CACHE_BYTES,
|
||||
payload_cache_bytes: int = _DEFAULT_PAYLOAD_CACHE_BYTES,
|
||||
) -> None:
|
||||
if index_cache_bytes < 0 or payload_cache_bytes < 0:
|
||||
raise ValueError("recorded color cache budgets must be non-negative")
|
||||
self._index_cache_bytes = index_cache_bytes
|
||||
self._payload_cache_bytes = payload_cache_bytes
|
||||
self._lock = threading.RLock()
|
||||
self._session_locks: dict[str, threading.Lock] = {}
|
||||
self._indexes: OrderedDict[tuple[object, ...], _PointColorIndex] = OrderedDict()
|
||||
self._index_bytes = 0
|
||||
self._payloads: OrderedDict[tuple[object, ...], bytes] = OrderedDict()
|
||||
self._payload_bytes = 0
|
||||
|
||||
def render(
|
||||
self,
|
||||
command: ReplayCommand,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"],
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"],
|
||||
custom_color: str,
|
||||
) -> bytes:
|
||||
if command.plugin_id != "nodedc.device.xgrids-lixelkity-k1":
|
||||
raise RecordedPointColorError("recorded color provider received another plugin")
|
||||
if application_id != APPLICATION_ID:
|
||||
raise ValueError("recorded color application id is invalid")
|
||||
if not recording_id or len(recording_id) > 128:
|
||||
raise ValueError("recorded color recording id is invalid")
|
||||
|
||||
source = command.primary_artifact.path.expanduser().resolve()
|
||||
metadata = _artifact_path(command, "raw-transport-index")
|
||||
capture_clock = _artifact_path(command, "raw-transport-clock")
|
||||
capture_clock_origin = _artifact_path(command, "raw-transport-clock-origin")
|
||||
source_identity = _source_identity(
|
||||
source,
|
||||
metadata,
|
||||
capture_clock,
|
||||
capture_clock_origin,
|
||||
)
|
||||
settings = RerunSceneSettings(
|
||||
color_mode=color_mode,
|
||||
palette=palette,
|
||||
custom_color=custom_color,
|
||||
)
|
||||
settings_key = (
|
||||
color_mode,
|
||||
palette,
|
||||
(
|
||||
custom_color.casefold()
|
||||
if palette == "custom" or color_mode == "class"
|
||||
else "-"
|
||||
),
|
||||
)
|
||||
payload_key = (*source_identity, application_id, recording_id, *settings_key)
|
||||
|
||||
with self._lock:
|
||||
cached_payload = self._payloads.get(payload_key)
|
||||
if cached_payload is not None:
|
||||
self._payloads.move_to_end(payload_key)
|
||||
return cached_payload
|
||||
session_lock = self._session_locks.setdefault(command.session_id, threading.Lock())
|
||||
|
||||
with session_lock:
|
||||
with self._lock:
|
||||
cached_payload = self._payloads.get(payload_key)
|
||||
if cached_payload is not None:
|
||||
self._payloads.move_to_end(payload_key)
|
||||
return cached_payload
|
||||
index = self._index(
|
||||
source_identity,
|
||||
source,
|
||||
metadata,
|
||||
capture_clock,
|
||||
capture_clock_origin,
|
||||
)
|
||||
payload = _render_color_overlay(
|
||||
index.frames,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
settings=settings,
|
||||
)
|
||||
self._remember_payload(payload_key, payload)
|
||||
return payload
|
||||
|
||||
def _index(
|
||||
self,
|
||||
source_identity: tuple[object, ...],
|
||||
source: Path,
|
||||
metadata: Path | None,
|
||||
capture_clock: Path | None,
|
||||
capture_clock_origin: Path | None,
|
||||
) -> _PointColorIndex:
|
||||
with self._lock:
|
||||
cached = self._indexes.get(source_identity)
|
||||
if cached is not None:
|
||||
self._indexes.move_to_end(source_identity)
|
||||
return cached
|
||||
if metadata is None:
|
||||
raise RecordedPointColorError("native point-color index is unavailable")
|
||||
index = _build_index(
|
||||
source,
|
||||
metadata,
|
||||
capture_clock,
|
||||
capture_clock_origin,
|
||||
source_identity=source_identity,
|
||||
)
|
||||
if index.byte_length > self._index_cache_bytes:
|
||||
return index
|
||||
with self._lock:
|
||||
existing = self._indexes.pop(source_identity, None)
|
||||
if existing is not None:
|
||||
self._index_bytes -= existing.byte_length
|
||||
self._indexes[source_identity] = index
|
||||
self._index_bytes += index.byte_length
|
||||
while self._indexes and self._index_bytes > self._index_cache_bytes:
|
||||
_, stale = self._indexes.popitem(last=False)
|
||||
self._index_bytes -= stale.byte_length
|
||||
return index
|
||||
|
||||
def _remember_payload(self, key: tuple[object, ...], payload: bytes) -> None:
|
||||
if len(payload) > self._payload_cache_bytes:
|
||||
return
|
||||
with self._lock:
|
||||
existing = self._payloads.pop(key, None)
|
||||
if existing is not None:
|
||||
self._payload_bytes -= len(existing)
|
||||
self._payloads[key] = payload
|
||||
self._payload_bytes += len(payload)
|
||||
while self._payloads and self._payload_bytes > self._payload_cache_bytes:
|
||||
_, stale = self._payloads.popitem(last=False)
|
||||
self._payload_bytes -= len(stale)
|
||||
|
||||
|
||||
def _build_index(
|
||||
source: Path,
|
||||
metadata: Path,
|
||||
capture_clock: Path | None,
|
||||
capture_clock_origin: Path | None,
|
||||
*,
|
||||
source_identity: tuple[object, ...],
|
||||
) -> _PointColorIndex:
|
||||
try:
|
||||
envelope = None if capture_clock is None else read_capture_clock_envelope(capture_clock)
|
||||
origin = (
|
||||
None
|
||||
if capture_clock_origin is None
|
||||
else read_capture_clock_origin(capture_clock_origin)
|
||||
)
|
||||
except CaptureFormatError as exc:
|
||||
raise RecordedPointColorError("native point-color clock is invalid") from exc
|
||||
if envelope is not None and origin is not None and (
|
||||
envelope.started_at_epoch_ns != origin.started_at_epoch_ns
|
||||
or envelope.started_monotonic_ns != origin.started_monotonic_ns
|
||||
):
|
||||
raise RecordedPointColorError("native point-color clocks do not match")
|
||||
session_origin_ns = (
|
||||
envelope.started_monotonic_ns
|
||||
if envelope is not None
|
||||
else origin.started_monotonic_ns
|
||||
if origin is not None
|
||||
else None
|
||||
)
|
||||
|
||||
frames: list[_PointColorFrame] = []
|
||||
total_bytes = 0
|
||||
point_frame_number = 0
|
||||
previous_sequence = 0
|
||||
previous_monotonic_ns: int | None = None
|
||||
try:
|
||||
source_size = source.stat().st_size
|
||||
with source.open("rb") as raw_stream, metadata.open("r", encoding="utf-8") as index_stream:
|
||||
for raw_line in index_stream:
|
||||
if len(raw_line.encode("utf-8")) > _MAX_METADATA_LINE_BYTES:
|
||||
raise RecordedPointColorError("native point-color metadata line is too large")
|
||||
record = json.loads(raw_line)
|
||||
sequence = record.get("sequence")
|
||||
monotonic_ns = record.get("received_monotonic_ns")
|
||||
capture_time_ns = record.get("received_at_epoch_ns")
|
||||
if (
|
||||
record.get("record_type") != "message"
|
||||
or not isinstance(sequence, int)
|
||||
or sequence <= previous_sequence
|
||||
or not isinstance(monotonic_ns, int)
|
||||
or monotonic_ns < 0
|
||||
or not isinstance(capture_time_ns, int)
|
||||
or capture_time_ns < 0
|
||||
):
|
||||
raise RecordedPointColorError("native point-color metadata is invalid")
|
||||
if previous_monotonic_ns is not None and monotonic_ns < previous_monotonic_ns:
|
||||
raise RecordedPointColorError("native point-color timeline decreases")
|
||||
previous_sequence = sequence
|
||||
previous_monotonic_ns = monotonic_ns
|
||||
if session_origin_ns is None:
|
||||
session_origin_ns = monotonic_ns
|
||||
topic = record.get("topic")
|
||||
if topic not in _POINT_TOPICS:
|
||||
continue
|
||||
point_frame_number += 1
|
||||
if not _should_publish_recorded_point_frame(point_frame_number):
|
||||
continue
|
||||
payload_offset = record.get("raw_payload_offset")
|
||||
payload_bytes = record.get("payload_bytes")
|
||||
payload_sha256 = record.get("payload_sha256")
|
||||
if (
|
||||
not isinstance(payload_offset, int)
|
||||
or payload_offset < 8
|
||||
or not isinstance(payload_bytes, int)
|
||||
or payload_bytes < 0
|
||||
or payload_bytes > _MAX_PAYLOAD_BYTES
|
||||
or payload_offset + payload_bytes > source_size
|
||||
or not isinstance(payload_sha256, str)
|
||||
or len(payload_sha256) != 64
|
||||
):
|
||||
raise RecordedPointColorError("native point-color offset is invalid")
|
||||
raw_stream.seek(payload_offset)
|
||||
payload = raw_stream.read(payload_bytes)
|
||||
if (
|
||||
len(payload) != payload_bytes
|
||||
or hashlib.sha256(payload).hexdigest() != payload_sha256
|
||||
):
|
||||
raise RecordedPointColorError("native point-color payload is corrupt")
|
||||
message = StreamMessage(
|
||||
sequence=sequence,
|
||||
topic=topic,
|
||||
payload=payload,
|
||||
received_at_epoch_ns=capture_time_ns,
|
||||
received_monotonic_ns=monotonic_ns,
|
||||
source="k1mqtt",
|
||||
)
|
||||
try:
|
||||
decoded = normalize_k1_message(
|
||||
message,
|
||||
processing_started_monotonic_ns=monotonic_ns,
|
||||
)
|
||||
except NormalizationError as exc:
|
||||
raise RecordedPointColorError(
|
||||
"native point-color frame failed normalization"
|
||||
) from exc
|
||||
if not isinstance(decoded, DecodedPointCloudView):
|
||||
raise RecordedPointColorError("native point-color topic is not a point frame")
|
||||
positions = np.asarray(decoded.positions_xyz, dtype=np.float32).reshape((-1, 3))
|
||||
intensities = (
|
||||
np.full(decoded.point_count, 255, dtype=np.uint8)
|
||||
if decoded.intensities is None
|
||||
else np.frombuffer(decoded.intensities, dtype=np.uint8)
|
||||
)
|
||||
rgb = (
|
||||
None
|
||||
if decoded.colors_rgb is None
|
||||
else np.frombuffer(decoded.colors_rgb, dtype=np.uint8).reshape((-1, 3))
|
||||
)
|
||||
positions, intensities, rgb = _recorded_view_points(
|
||||
positions,
|
||||
intensities,
|
||||
rgb,
|
||||
)
|
||||
frame = _PointColorFrame(
|
||||
sequence=sequence,
|
||||
session_time_ns=monotonic_ns - session_origin_ns,
|
||||
capture_time_ns=capture_time_ns,
|
||||
positions=positions.copy(),
|
||||
intensities=intensities.copy(),
|
||||
rgb=None if rgb is None else rgb.copy(),
|
||||
)
|
||||
frames.append(frame)
|
||||
total_bytes += frame.byte_length
|
||||
except (OSError, json.JSONDecodeError, UnicodeError) as exc:
|
||||
raise RecordedPointColorError("native point-color index could not be read") from exc
|
||||
if session_origin_ns is None or not frames:
|
||||
raise RecordedPointColorError("native point-color index contains no point frames")
|
||||
return _PointColorIndex(
|
||||
source_identity=source_identity,
|
||||
frames=tuple(frames),
|
||||
byte_length=total_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _render_color_overlay(
|
||||
frames: tuple[_PointColorFrame, ...],
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
settings: RerunSceneSettings,
|
||||
) -> bytes:
|
||||
recording = rr.RecordingStream(
|
||||
application_id,
|
||||
recording_id=recording_id,
|
||||
send_properties=False,
|
||||
)
|
||||
stream = rr.binary_stream(recording)
|
||||
try:
|
||||
for frame in frames:
|
||||
recording.set_time(
|
||||
SESSION_TIMELINE,
|
||||
duration=np.timedelta64(frame.session_time_ns, "ns"),
|
||||
)
|
||||
recording.set_time(
|
||||
CAPTURE_TIMELINE,
|
||||
timestamp=np.datetime64(frame.capture_time_ns, "ns"),
|
||||
)
|
||||
recording.set_time("message_sequence", sequence=frame.sequence)
|
||||
recording.log(
|
||||
"/world/points",
|
||||
rr.Points3D.from_fields(
|
||||
colors=_point_colors(
|
||||
frame.positions,
|
||||
frame.intensities,
|
||||
frame.rgb,
|
||||
settings,
|
||||
)
|
||||
),
|
||||
)
|
||||
payload = stream.read(flush=True, flush_timeout_sec=30.0)
|
||||
except Exception as exc:
|
||||
raise RecordedPointColorError("recorded point-color RRD serialization failed") from exc
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
recording.disconnect()
|
||||
if not payload or not payload.startswith(b"RRF2"):
|
||||
raise RecordedPointColorError("recorded point-color RRD is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _artifact_path(command: ReplayCommand, artifact_id: str) -> Path | None:
|
||||
matches = tuple(
|
||||
artifact.path.expanduser().resolve()
|
||||
for artifact in command.artifacts
|
||||
if artifact.artifact_id == artifact_id
|
||||
)
|
||||
if not matches:
|
||||
return None
|
||||
if len(matches) != 1:
|
||||
raise RecordedPointColorError("recorded point-color artifact is ambiguous")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _source_identity(source: Path, *companions: Path | None) -> tuple[object, ...]:
|
||||
values: list[object] = []
|
||||
for path in (source, *companions):
|
||||
if path is None:
|
||||
values.extend((None, None, None))
|
||||
continue
|
||||
try:
|
||||
metadata = path.stat()
|
||||
except OSError as exc:
|
||||
raise RecordedPointColorError("recorded point-color source is unavailable") from exc
|
||||
if not os.path.isfile(path):
|
||||
raise RecordedPointColorError("recorded point-color source is not a file")
|
||||
values.extend((str(path), metadata.st_size, metadata.st_mtime_ns))
|
||||
return tuple(values)
|
||||
@@ -11,9 +11,9 @@ import threading
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from .models import ObservationSessionCandidate
|
||||
from .models import ObservationSessionCandidate, ReplayCommand
|
||||
|
||||
RecordingProgressPulse = Callable[[], None]
|
||||
RecordingExportResult = Mapping[str, object]
|
||||
@@ -38,6 +38,19 @@ class RecordingExporter(Protocol):
|
||||
) -> RecordingExportResult: ...
|
||||
|
||||
|
||||
class RecordedPointColorRenderer(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
command: ReplayCommand,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"],
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"],
|
||||
custom_color: str,
|
||||
) -> bytes: ...
|
||||
|
||||
|
||||
ObservationArchiveDiscovery = Callable[
|
||||
[Path],
|
||||
tuple[ObservationSessionCandidate, ...],
|
||||
@@ -66,3 +79,4 @@ class ObservationRuntimeContribution:
|
||||
|
||||
archives: tuple[ObservationArchiveSource, ...]
|
||||
recording_exporter: RecordingExporter
|
||||
point_color_renderer: RecordedPointColorRenderer | None = None
|
||||
|
||||
@@ -12,13 +12,15 @@ import rerun as rr
|
||||
import rerun_bindings as bindings
|
||||
from rerun import blueprint as rrb
|
||||
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings, _parse_hex_color
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
|
||||
APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
|
||||
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||
RECORDED_SPATIAL_RESET_VIEW_ID = UUID("1480abcc-a0ae-4287-ab69-4b606d15d947")
|
||||
RECORDED_SPATIAL_FOLLOW_VIEW_ID = UUID("e96acd7f-1bb8-49e2-bff8-a661e5c6ceab")
|
||||
RECORDED_SPATIAL_FOLLOW_RESET_VIEW_ID = UUID("f409403d-84ac-4309-82aa-501fc69905f5")
|
||||
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||
RECORDED_SPATIAL_RESET_ROOT_CONTAINER_ID = UUID("27d95ad7-1e72-46ca-b854-d02cd99b7610")
|
||||
RECORDED_PERCEPTION_ROOT_CONTAINER_ID = UUID("70ea9fd5-bbbb-4b23-8ae8-8098af92b997")
|
||||
@@ -33,6 +35,8 @@ RECORDED_CAMERA_VIEW_ID = UUID("5c1db75b-07cd-479a-903d-f4f2ed554513")
|
||||
RECORDED_CAMERA_RESET_VIEW_ID = UUID("d0618e0c-c889-4222-a643-60a4a882935c")
|
||||
RECORDED_PERCEPTION_3D_VIEW_ID = UUID("0496bd2e-2b4d-4a4f-87b8-3ce4f9f7e114")
|
||||
RECORDED_PERCEPTION_3D_RESET_VIEW_ID = UUID("31f63ab8-ffdd-4751-ae40-e8e7b4462c8f")
|
||||
RECORDED_PERCEPTION_3D_FOLLOW_VIEW_ID = UUID("6bb43236-9507-4e8f-bc54-3a73b31c07b5")
|
||||
RECORDED_PERCEPTION_3D_FOLLOW_RESET_VIEW_ID = UUID("0e01e3c6-8b7b-47aa-b22b-e413301599eb")
|
||||
RECORDED_METRICS_VIEW_ID = UUID("f973fc11-0867-4732-ad3c-97008621fab7")
|
||||
RECORDED_UNIFIED_ROOT_CONTAINER_ID = UUID("e9934ef8-453f-432e-9136-2b0908190253")
|
||||
RECORDED_UNIFIED_RESET_ROOT_CONTAINER_ID = UUID("2781407d-9f4e-4405-86e8-bbe6065e83df")
|
||||
@@ -155,6 +159,7 @@ def recorded_blueprint(
|
||||
show_detections_2d: bool = False,
|
||||
show_segmentation: bool = False,
|
||||
show_cuboids_3d: bool = False,
|
||||
follow_trajectory: bool = False,
|
||||
) -> rrb.Blueprint:
|
||||
accumulation = max(0.0, settings.accumulation_seconds)
|
||||
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||
@@ -171,16 +176,10 @@ def recorded_blueprint(
|
||||
)
|
||||
point_visualizer = rr.Points3D.from_fields(
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
colors=(
|
||||
[_parse_hex_color(settings.custom_color)] if settings.palette == "custom" else None
|
||||
),
|
||||
).visualizer()
|
||||
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
|
||||
perception_point_visualizer = rr.Points3D.from_fields(
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
colors=(
|
||||
[_parse_hex_color(settings.custom_color)] if settings.palette == "custom" else None
|
||||
),
|
||||
).visualizer()
|
||||
perception_point_visualizer.id = RECORDED_PERCEPTION_POINTS_VISUALIZER_ID
|
||||
point_overrides: list[Any] = [
|
||||
@@ -219,10 +218,21 @@ def recorded_blueprint(
|
||||
# Log an explicit empty range set so a previous accumulated blueprint
|
||||
# for this stable view id is cleared instead of surviving in Rerun.
|
||||
time_ranges=rrb.VisibleTimeRanges([]),
|
||||
eye_controls=(
|
||||
rrb.EyeControls3D(
|
||||
kind=rrb.Eye3DKind.Orbital,
|
||||
tracking_entity="/world/sensor_pose",
|
||||
)
|
||||
if follow_trajectory
|
||||
else None
|
||||
),
|
||||
)
|
||||
spatial_view.id = (
|
||||
RECORDED_SPATIAL_RESET_VIEW_ID if view_reset_generation else RECORDED_SPATIAL_VIEW_ID
|
||||
)
|
||||
spatial_view.id = {
|
||||
(False, 0): RECORDED_SPATIAL_VIEW_ID,
|
||||
(False, 1): RECORDED_SPATIAL_RESET_VIEW_ID,
|
||||
(True, 0): RECORDED_SPATIAL_FOLLOW_VIEW_ID,
|
||||
(True, 1): RECORDED_SPATIAL_FOLLOW_RESET_VIEW_ID,
|
||||
}[(follow_trajectory, view_reset_generation)]
|
||||
camera_view = rrb.Spatial2DView(
|
||||
origin="/perception/camera",
|
||||
name="Оригинальное видео · слои AI",
|
||||
@@ -269,12 +279,21 @@ def recorded_blueprint(
|
||||
# native cloud from /world/points.
|
||||
"/world/perception/lidar": rrb.EntityBehavior(visible=False),
|
||||
},
|
||||
eye_controls=(
|
||||
rrb.EyeControls3D(
|
||||
kind=rrb.Eye3DKind.Orbital,
|
||||
tracking_entity="/world/sensor_pose",
|
||||
)
|
||||
if follow_trajectory
|
||||
else None
|
||||
),
|
||||
)
|
||||
perception_3d_view.id = (
|
||||
RECORDED_PERCEPTION_3D_RESET_VIEW_ID
|
||||
if view_reset_generation
|
||||
else RECORDED_PERCEPTION_3D_VIEW_ID
|
||||
)
|
||||
perception_3d_view.id = {
|
||||
(False, 0): RECORDED_PERCEPTION_3D_VIEW_ID,
|
||||
(False, 1): RECORDED_PERCEPTION_3D_RESET_VIEW_ID,
|
||||
(True, 0): RECORDED_PERCEPTION_3D_FOLLOW_VIEW_ID,
|
||||
(True, 1): RECORDED_PERCEPTION_3D_FOLLOW_RESET_VIEW_ID,
|
||||
}[(follow_trajectory, view_reset_generation)]
|
||||
metrics_view = rrb.TimeSeriesView(
|
||||
origin="/metrics/device",
|
||||
name="Маршрут и время",
|
||||
@@ -372,6 +391,7 @@ def recorded_blueprint_rrd(
|
||||
show_detections_2d: bool = False,
|
||||
show_segmentation: bool = False,
|
||||
show_cuboids_3d: bool = False,
|
||||
follow_trajectory: bool = False,
|
||||
) -> bytes:
|
||||
"""Serialize a bounded active blueprint update without recorded data."""
|
||||
|
||||
@@ -384,6 +404,7 @@ def recorded_blueprint_rrd(
|
||||
show_detections_2d=show_detections_2d,
|
||||
show_segmentation=show_segmentation,
|
||||
show_cuboids_3d=show_cuboids_3d,
|
||||
follow_trajectory=follow_trajectory,
|
||||
)
|
||||
payload: bytes | None
|
||||
if blueprint_session_id is not None:
|
||||
|
||||
@@ -491,6 +491,9 @@ def _point_colors(
|
||||
rgb: np.ndarray | None,
|
||||
settings: RerunSceneSettings,
|
||||
) -> np.ndarray:
|
||||
if settings.palette == "custom":
|
||||
color = _parse_hex_color(settings.custom_color)
|
||||
return np.tile(np.asarray(color, dtype=np.uint8), (positions.shape[0], 1))
|
||||
if settings.color_mode == "rgb" and rgb is not None:
|
||||
return rgb
|
||||
if settings.color_mode == "class":
|
||||
|
||||
@@ -393,6 +393,7 @@ app.include_router(
|
||||
media_inspector=session_recorded_media_inspector,
|
||||
perception_overlay_provider=session_perception_overlay_store,
|
||||
perception_media_provider=session_perception_epoch_store,
|
||||
point_color_renderers=plugin_environment.point_color_renderers,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,11 @@ from uuid import uuid4
|
||||
from fastapi import APIRouter
|
||||
from missioncore_plugin_sdk.v0alpha2 import RuntimeHandshakeRequest
|
||||
|
||||
from k1link.sessions.plugin_contract import ObservationArchiveSource, RecordingExporter
|
||||
from k1link.sessions.plugin_contract import (
|
||||
ObservationArchiveSource,
|
||||
RecordedPointColorRenderer,
|
||||
RecordingExporter,
|
||||
)
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
|
||||
from k1link.web.plugin_runtime import (
|
||||
DevicePluginDispatcher,
|
||||
@@ -48,6 +52,15 @@ class InstalledDevicePluginEnvironment:
|
||||
if contribution.observation is not None
|
||||
}
|
||||
|
||||
@property
|
||||
def point_color_renderers(self) -> dict[str, RecordedPointColorRenderer]:
|
||||
return {
|
||||
contribution.runtime.descriptor.plugin_id: renderer
|
||||
for contribution in self._contributions
|
||||
if contribution.observation is not None
|
||||
if (renderer := contribution.observation.point_color_renderer) is not None
|
||||
}
|
||||
|
||||
@property
|
||||
def runtime_health(self) -> tuple[dict[str, Any], ...]:
|
||||
return tuple(
|
||||
|
||||
@@ -33,6 +33,7 @@ from k1link.sessions import (
|
||||
SessionStore,
|
||||
validate_recorded_media_timeline,
|
||||
)
|
||||
from k1link.sessions.plugin_contract import RecordedPointColorRenderer
|
||||
from k1link.viewer.recorded import (
|
||||
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
||||
)
|
||||
@@ -108,6 +109,7 @@ class RecordedBlueprintRequest(StrictApiModel):
|
||||
show_trajectory: StrictBool
|
||||
show_grid: StrictBool
|
||||
point_size: float = Field(default=2.5, strict=True, ge=0.1, le=32.0)
|
||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
||||
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
active_view: Literal["spatial", "perception", "perception3d", "metrics"] = "spatial"
|
||||
@@ -116,6 +118,7 @@ class RecordedBlueprintRequest(StrictApiModel):
|
||||
show_detections_2d: StrictBool = False
|
||||
show_segmentation: StrictBool = False
|
||||
show_cuboids_3d: StrictBool = False
|
||||
follow_trajectory: StrictBool = False
|
||||
|
||||
|
||||
class RecordedPerceptionRequest(StrictApiModel):
|
||||
@@ -127,6 +130,18 @@ class RecordedPerceptionRequest(StrictApiModel):
|
||||
)
|
||||
|
||||
|
||||
class RecordedPointColorsRequest(StrictApiModel):
|
||||
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}$",
|
||||
)
|
||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"]
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
|
||||
custom_color: str = Field(pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
class SceneSettingsDocument(StrictApiModel):
|
||||
projection: Literal["3d", "2d", "map"]
|
||||
point_size: float = Field(ge=0.1, le=32.0)
|
||||
@@ -284,6 +299,7 @@ def build_session_router(
|
||||
media_inspector: RecordedMediaInspector | None = None,
|
||||
perception_overlay_provider: RecordedPerceptionOverlayProvider | None = None,
|
||||
perception_media_provider: RecordedPerceptionMediaProvider | None = None,
|
||||
point_color_renderers: Mapping[str, RecordedPointColorRenderer] | None = None,
|
||||
allow_synchronous_recording_fallback: bool = False,
|
||||
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
||||
) -> APIRouter:
|
||||
@@ -796,6 +812,7 @@ def build_session_router(
|
||||
recorded_blueprint_rrd,
|
||||
RerunSceneSettings(
|
||||
point_size=request.point_size,
|
||||
color_mode=request.color_mode,
|
||||
palette=request.palette,
|
||||
custom_color=request.custom_color,
|
||||
accumulation_seconds=request.accumulation_seconds,
|
||||
@@ -812,6 +829,7 @@ def build_session_router(
|
||||
show_detections_2d=request.show_detections_2d,
|
||||
show_segmentation=request.show_segmentation,
|
||||
show_cuboids_3d=request.show_cuboids_3d,
|
||||
follow_trajectory=request.follow_trajectory,
|
||||
)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
@@ -887,6 +905,61 @@ def build_session_router(
|
||||
},
|
||||
)
|
||||
|
||||
@router.post("/api/v1/observation-sessions/{session_id}/point-colors.rrd")
|
||||
async def get_observation_session_point_colors(
|
||||
session_id: str,
|
||||
request: RecordedPointColorsRequest,
|
||||
) -> Response:
|
||||
try:
|
||||
command = await run_in_threadpool(
|
||||
_prepare_replay,
|
||||
store,
|
||||
catalog_refresher,
|
||||
session_id,
|
||||
1.0,
|
||||
False,
|
||||
False,
|
||||
)
|
||||
point_color_renderer = (point_color_renderers or {}).get(command.plugin_id)
|
||||
if point_color_renderer is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Сервис архивной окраски точек не настроен.",
|
||||
)
|
||||
payload = await run_in_threadpool(
|
||||
point_color_renderer,
|
||||
command,
|
||||
application_id=request.application_id,
|
||||
recording_id=request.recording_id,
|
||||
color_mode=request.color_mode,
|
||||
palette=request.palette,
|
||||
custom_color=request.custom_color,
|
||||
)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Некорректные параметры окраски точек.",
|
||||
) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Не удалось подготовить окраску облака точек.",
|
||||
) from exc
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type="application/vnd.rerun.rrd",
|
||||
headers={
|
||||
"Cache-Control": "private, no-cache, no-transform",
|
||||
"Content-Length": str(len(payload)),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Disposition": 'inline; filename="point-colors.rrd"',
|
||||
},
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/api/v1/observation-sessions/{session_id}/perception-media/"
|
||||
"{result_id}/manifest"
|
||||
|
||||
Reference in New Issue
Block a user