feat(sessions): add durable observation archive and replay API
This commit is contained in:
@@ -53,11 +53,17 @@ def _iter_native_capture(path: Path) -> Iterator[StreamMessage]:
|
||||
epoch_ns = fallback_epoch_ns + frame.sequence - 1
|
||||
monotonic_ns: int | None = None
|
||||
if metadata_stream is not None:
|
||||
epoch_ns, monotonic_ns = _read_native_timing(
|
||||
timing = _read_native_timing(
|
||||
metadata_stream,
|
||||
expected_sequence=frame.sequence,
|
||||
fallback_epoch_ns=epoch_ns,
|
||||
)
|
||||
if timing is None:
|
||||
# A crash may leave one raw frame ahead of the last fully
|
||||
# committed metadata line. Only the aligned prefix has a
|
||||
# trustworthy source timeline and is safe to replay.
|
||||
return
|
||||
epoch_ns, monotonic_ns = timing
|
||||
yield StreamMessage(
|
||||
sequence=frame.sequence,
|
||||
topic=frame.topic,
|
||||
@@ -78,15 +84,19 @@ def _read_native_timing(
|
||||
*,
|
||||
expected_sequence: int,
|
||||
fallback_epoch_ns: int,
|
||||
) -> tuple[int, int | None]:
|
||||
) -> tuple[int, int | None] | None:
|
||||
line = stream.readline(MAX_METADATA_LINE_CHARS + 1)
|
||||
if not line:
|
||||
return fallback_epoch_ns, None
|
||||
return None
|
||||
if len(line) > MAX_METADATA_LINE_CHARS:
|
||||
raise ReplayFormatError("native metadata line exceeds the reviewed bound")
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
if not line.endswith(("\n", "\r")):
|
||||
# A non-newline final tail is the only tolerated corruption: the
|
||||
# writer may have crashed between raw and metadata group commits.
|
||||
return None
|
||||
raise ReplayFormatError(
|
||||
f"native metadata line {expected_sequence} is not valid JSON"
|
||||
) from exc
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import numpy as np
|
||||
import rerun as rr
|
||||
from rerun import blueprint as rrb
|
||||
|
||||
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
|
||||
from k1link.protocol.normalizer import normalize_k1_message
|
||||
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages
|
||||
from k1link.viewer.rerun_bridge import (
|
||||
MAX_TRAJECTORY_POSES,
|
||||
TRAJECTORY_APPEND_INTERVAL_NS,
|
||||
TRAJECTORY_FORCE_APPEND_NS,
|
||||
TRAJECTORY_MIN_DISTANCE_METERS,
|
||||
TRAJECTORY_PUBLISH_INTERVAL_NS,
|
||||
RerunSceneSettings,
|
||||
_parse_hex_color,
|
||||
_point_colors,
|
||||
)
|
||||
|
||||
APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
CAPTURE_TIMELINE = "capture_time"
|
||||
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
|
||||
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
|
||||
# blueprint update makes the update overwrite the existing scene instead of
|
||||
# creating a fresh view/container (which would also reset the operator's eye
|
||||
# position and layout).
|
||||
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
||||
|
||||
|
||||
class RrdExportSummary(TypedDict):
|
||||
schema_version: int
|
||||
input_path: str
|
||||
output_path: str
|
||||
recording_id: str
|
||||
timeline: str
|
||||
capture_timeline: str
|
||||
source_messages: int
|
||||
decoded_messages: int
|
||||
point_frames: int
|
||||
pose_frames: int
|
||||
ignored_messages: int
|
||||
points: int
|
||||
trajectory_poses: int
|
||||
trajectory_updates: int
|
||||
session_origin_monotonic_ns: int
|
||||
timeline_start_ns: int
|
||||
timeline_end_ns: int
|
||||
timeline_span_ns: int
|
||||
first_decoded_time_ns: int
|
||||
last_decoded_time_ns: int
|
||||
source_sha256: str
|
||||
rrd_sha256: str
|
||||
rrd_bytes: int
|
||||
|
||||
|
||||
class RrdExportError(RuntimeError):
|
||||
"""A raw capture could not be converted into a complete durable RRD."""
|
||||
|
||||
|
||||
class RrdExportCancelled(RrdExportError):
|
||||
"""A background RRD export was cooperatively cancelled."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ExportCounters:
|
||||
source_messages: int = 0
|
||||
point_frames: int = 0
|
||||
pose_frames: int = 0
|
||||
ignored_messages: int = 0
|
||||
points: int = 0
|
||||
first_decoded_time_ns: int | None = None
|
||||
last_decoded_time_ns: int | None = None
|
||||
|
||||
@property
|
||||
def decoded_messages(self) -> int:
|
||||
return self.point_frames + self.pose_frames
|
||||
|
||||
def observe_decoded(self, session_time_ns: int) -> None:
|
||||
if self.first_decoded_time_ns is None:
|
||||
self.first_decoded_time_ns = session_time_ns
|
||||
self.last_decoded_time_ns = session_time_ns
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _TrajectoryBuffer:
|
||||
positions: list[tuple[float, float, float]]
|
||||
last_append_time_ns: int | None = None
|
||||
last_publish_time_ns: int | None = None
|
||||
updates: int = 0
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> _TrajectoryBuffer:
|
||||
return cls(positions=[])
|
||||
|
||||
def process(
|
||||
self,
|
||||
recording: rr.RecordingStream,
|
||||
position: tuple[float, float, float],
|
||||
session_time_ns: int,
|
||||
) -> None:
|
||||
if not self._append(position, session_time_ns):
|
||||
return
|
||||
if (
|
||||
self.last_publish_time_ns is not None
|
||||
and session_time_ns - self.last_publish_time_ns < TRAJECTORY_PUBLISH_INTERVAL_NS
|
||||
):
|
||||
return
|
||||
self.last_publish_time_ns = session_time_ns
|
||||
self.updates += 1
|
||||
recording.log(
|
||||
"/world/trajectory",
|
||||
rr.LineStrips3D(
|
||||
[list(self.positions)],
|
||||
colors=[247, 248, 244, 255],
|
||||
radii=rr.Radius.ui_points(2.0),
|
||||
),
|
||||
)
|
||||
|
||||
def _append(self, position: tuple[float, float, float], session_time_ns: int) -> bool:
|
||||
if not self.positions:
|
||||
self.positions.append(position)
|
||||
self.last_append_time_ns = session_time_ns
|
||||
return True
|
||||
|
||||
assert self.last_append_time_ns is not None
|
||||
elapsed_ns = session_time_ns - self.last_append_time_ns
|
||||
if elapsed_ns < TRAJECTORY_APPEND_INTERVAL_NS:
|
||||
return False
|
||||
if (
|
||||
math.dist(self.positions[-1], position) < TRAJECTORY_MIN_DISTANCE_METERS
|
||||
and elapsed_ns < TRAJECTORY_FORCE_APPEND_NS
|
||||
):
|
||||
return False
|
||||
|
||||
self.positions.append(position)
|
||||
self.last_append_time_ns = session_time_ns
|
||||
if len(self.positions) > MAX_TRAJECTORY_POSES:
|
||||
last = self.positions[-1]
|
||||
self.positions = self.positions[::2]
|
||||
if self.positions[-1] != last:
|
||||
self.positions.append(last)
|
||||
return True
|
||||
|
||||
|
||||
def export_k1mqtt_to_rrd(
|
||||
input_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
cancel_event: threading.Event | None = None,
|
||||
activity_callback: Callable[[], None] | None = None,
|
||||
) -> RrdExportSummary:
|
||||
"""Losslessly project every decodable K1 data-plane frame into one RRD.
|
||||
|
||||
The raw capture remains the source of record. The derived RRD uses a
|
||||
recording-local duration timeline whose zero is the first raw message's
|
||||
receive-monotonic timestamp. It never traverses the bounded live-preview
|
||||
queue, so export throughput cannot drop point or pose frames.
|
||||
|
||||
The destination is replaced only after the temporary RRD has been closed,
|
||||
flushed and fsynced. Any decode, timing, sink or rename failure therefore
|
||||
leaves an existing destination artifact untouched.
|
||||
"""
|
||||
|
||||
_raise_if_cancelled(cancel_event)
|
||||
source = input_path.expanduser().resolve()
|
||||
destination = output_path.expanduser().resolve()
|
||||
_validate_paths(source, destination)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recording_id = str(uuid4())
|
||||
temporary = destination.with_name(f".{destination.name}.{recording_id}.tmp")
|
||||
source_sha256 = _sha256_file(
|
||||
source,
|
||||
cancel_event=cancel_event,
|
||||
activity_callback=activity_callback,
|
||||
)
|
||||
settings = RerunSceneSettings()
|
||||
blueprint = _recorded_blueprint(settings)
|
||||
recording: rr.RecordingStream | None = None
|
||||
recording_closed = False
|
||||
published = False
|
||||
|
||||
counters = _ExportCounters()
|
||||
trajectory = _TrajectoryBuffer.empty()
|
||||
session_origin_ns: int | None = None
|
||||
previous_monotonic_ns: int | None = None
|
||||
|
||||
try:
|
||||
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
|
||||
recording.set_sinks(
|
||||
rr.FileSink(temporary, write_footer=True),
|
||||
default_blueprint=blueprint,
|
||||
)
|
||||
_log_static_scene(recording)
|
||||
_log_session_origin(recording)
|
||||
|
||||
for message in iter_replay_messages(source):
|
||||
_raise_if_cancelled(cancel_event)
|
||||
_notify_activity(activity_callback)
|
||||
counters.source_messages += 1
|
||||
monotonic_ns = message.received_monotonic_ns
|
||||
if monotonic_ns is None:
|
||||
raise RrdExportError(
|
||||
"native capture metadata must provide received_monotonic_ns "
|
||||
f"for message {message.sequence}"
|
||||
)
|
||||
if previous_monotonic_ns is not None and monotonic_ns < previous_monotonic_ns:
|
||||
raise RrdExportError(
|
||||
f"native capture monotonic time decreases at message {message.sequence}"
|
||||
)
|
||||
if session_origin_ns is None:
|
||||
session_origin_ns = monotonic_ns
|
||||
session_time_ns = monotonic_ns - session_origin_ns
|
||||
if session_time_ns > JS_MAX_SAFE_INTEGER:
|
||||
raise RrdExportError(
|
||||
"session duration exceeds the exact JavaScript nanosecond range"
|
||||
)
|
||||
previous_monotonic_ns = monotonic_ns
|
||||
|
||||
try:
|
||||
decoded = normalize_k1_message(
|
||||
message,
|
||||
processing_started_monotonic_ns=monotonic_ns,
|
||||
)
|
||||
except NormalizationError as exc:
|
||||
raise RrdExportError(
|
||||
f"known K1 frame {message.sequence} failed normalization"
|
||||
) from exc
|
||||
if decoded is None:
|
||||
counters.ignored_messages += 1
|
||||
continue
|
||||
|
||||
_set_frame_time(
|
||||
recording,
|
||||
decoded.context.sequence,
|
||||
session_time_ns,
|
||||
decoded.context.captured_at_epoch_ns,
|
||||
)
|
||||
counters.observe_decoded(session_time_ns)
|
||||
if isinstance(decoded, DecodedPointCloudView):
|
||||
_log_points(recording, decoded, settings)
|
||||
counters.point_frames += 1
|
||||
counters.points += decoded.point_count
|
||||
elif isinstance(decoded, DecodedPoseView):
|
||||
position = (
|
||||
float(decoded.position_xyz[0]),
|
||||
float(decoded.position_xyz[1]),
|
||||
float(decoded.position_xyz[2]),
|
||||
)
|
||||
_log_pose(recording, decoded, position)
|
||||
trajectory.process(recording, position, session_time_ns)
|
||||
counters.pose_frames += 1
|
||||
else:
|
||||
counters.ignored_messages += 1
|
||||
|
||||
if session_origin_ns is None:
|
||||
raise RrdExportError("native capture contains no messages")
|
||||
if counters.decoded_messages == 0:
|
||||
raise RrdExportError("native capture contains no decodable point or pose frames")
|
||||
assert counters.first_decoded_time_ns is not None
|
||||
assert counters.last_decoded_time_ns is not None
|
||||
|
||||
_raise_if_cancelled(cancel_event)
|
||||
recording.flush(timeout_sec=30.0)
|
||||
recording.disconnect()
|
||||
recording_closed = True
|
||||
_raise_if_cancelled(cancel_event)
|
||||
_fsync_file(temporary)
|
||||
rrd_bytes = temporary.stat().st_size
|
||||
if rrd_bytes <= 0:
|
||||
raise RrdExportError("Rerun produced an empty recording")
|
||||
rrd_sha256 = _sha256_file(
|
||||
temporary,
|
||||
cancel_event=cancel_event,
|
||||
activity_callback=activity_callback,
|
||||
)
|
||||
|
||||
summary = RrdExportSummary(
|
||||
schema_version=1,
|
||||
input_path=str(source),
|
||||
output_path=str(destination),
|
||||
recording_id=recording_id,
|
||||
timeline=SESSION_TIMELINE,
|
||||
capture_timeline=CAPTURE_TIMELINE,
|
||||
source_messages=counters.source_messages,
|
||||
decoded_messages=counters.decoded_messages,
|
||||
point_frames=counters.point_frames,
|
||||
pose_frames=counters.pose_frames,
|
||||
ignored_messages=counters.ignored_messages,
|
||||
points=counters.points,
|
||||
trajectory_poses=len(trajectory.positions),
|
||||
trajectory_updates=trajectory.updates,
|
||||
session_origin_monotonic_ns=session_origin_ns,
|
||||
timeline_start_ns=0,
|
||||
# Playback completeness is defined by data actually written to
|
||||
# the RRD. K1 status/heartbeat packets may continue long after the
|
||||
# final point or pose frame; advertising that raw tail as the RRD
|
||||
# end makes a strict browser buffering gate wait forever.
|
||||
timeline_end_ns=counters.last_decoded_time_ns,
|
||||
timeline_span_ns=counters.last_decoded_time_ns,
|
||||
first_decoded_time_ns=counters.first_decoded_time_ns,
|
||||
last_decoded_time_ns=counters.last_decoded_time_ns,
|
||||
source_sha256=source_sha256,
|
||||
rrd_sha256=rrd_sha256,
|
||||
rrd_bytes=rrd_bytes,
|
||||
)
|
||||
_raise_if_cancelled(cancel_event)
|
||||
os.replace(temporary, destination)
|
||||
_fsync_directory(destination.parent)
|
||||
published = True
|
||||
return summary
|
||||
except (ReplayFormatError, OSError) as exc:
|
||||
raise RrdExportError(f"RRD export failed: {exc}") from exc
|
||||
finally:
|
||||
if recording is not None and not recording_closed:
|
||||
with suppress(BaseException):
|
||||
recording.disconnect()
|
||||
if not published:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _validate_paths(source: Path, destination: Path) -> None:
|
||||
if not source.is_file():
|
||||
raise RrdExportError(f"raw capture does not exist: {source}")
|
||||
if source.suffix.casefold() != ".k1mqtt":
|
||||
raise RrdExportError("RRD export accepts only native .k1mqtt captures")
|
||||
if destination.suffix.casefold() != ".rrd":
|
||||
raise RrdExportError("RRD destination must use the .rrd suffix")
|
||||
if source == destination:
|
||||
raise RrdExportError("raw capture and RRD destination must be different files")
|
||||
try:
|
||||
replay_format = detect_replay_format(source)
|
||||
except ReplayFormatError as exc:
|
||||
raise RrdExportError(str(exc)) from exc
|
||||
if replay_format != "k1mqtt":
|
||||
raise RrdExportError("RRD export accepts only native K1MQTT captures")
|
||||
|
||||
|
||||
def _recorded_blueprint(
|
||||
settings: RerunSceneSettings,
|
||||
*,
|
||||
include_initial_playback_state: bool = True,
|
||||
) -> rrb.Blueprint:
|
||||
accumulation = max(0.0, settings.accumulation_seconds)
|
||||
# An omitted range means Rerun's native latest-at query: the most recent
|
||||
# LiDAR frame at the cursor. A zero-width range is *not* equivalent; it
|
||||
# only matches rows stamped at the cursor's exact nanosecond and therefore
|
||||
# makes an ordinary recorded scan appear empty.
|
||||
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||
if accumulation > 0:
|
||||
time_ranges = [
|
||||
rr.VisibleTimeRange(
|
||||
SESSION_TIMELINE,
|
||||
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
|
||||
end=rr.TimeRangeBoundary.cursor_relative(),
|
||||
)
|
||||
]
|
||||
point_visualizer = rr.Points3D.from_fields(
|
||||
# A negative Radius value is Rerun's serialized representation
|
||||
# for UI points. Blueprint overrides broadcast this singleton
|
||||
# value across every recorded point without rewriting the store.
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
# Recorded height/intensity/distance/RGB palettes are baked into
|
||||
# each Points3D row. A uniform custom color is the one color mode
|
||||
# that can be replaced safely by a singleton blueprint override.
|
||||
colors=(
|
||||
[_parse_hex_color(settings.custom_color)]
|
||||
if settings.palette == "custom"
|
||||
else None
|
||||
),
|
||||
).visualizer()
|
||||
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
|
||||
spatial_view = 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,
|
||||
),
|
||||
overrides={
|
||||
# EntityBehavior is evaluated from the blueprint store and can
|
||||
# therefore hide/reveal already-recorded entities without
|
||||
# rewriting the data RRD. Keep the point visualizer alongside it
|
||||
# so size/color overrides remain active for the same entity.
|
||||
"/world/points": [
|
||||
rrb.EntityBehavior(visible=settings.show_points),
|
||||
point_visualizer,
|
||||
],
|
||||
"/world/trajectory": rrb.EntityBehavior(
|
||||
visible=settings.show_trajectory,
|
||||
),
|
||||
},
|
||||
# A positive window accumulates historical frames. With no
|
||||
# window, latest-at deliberately keeps one current LiDAR frame.
|
||||
time_ranges=time_ranges,
|
||||
)
|
||||
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
|
||||
root_container = rrb.Tabs(spatial_view)
|
||||
root_container.id = RECORDED_ROOT_CONTAINER_ID
|
||||
|
||||
if include_initial_playback_state:
|
||||
# This state is appropriate only while opening a newly exported RRD.
|
||||
# Settings-only blueprint messages must not pause an already playing
|
||||
# recording or mutate the host-owned panel state.
|
||||
return rrb.Blueprint(
|
||||
root_container,
|
||||
rrb.TimePanel(
|
||||
timeline=SESSION_TIMELINE,
|
||||
play_state="paused",
|
||||
state="hidden",
|
||||
),
|
||||
auto_layout=False,
|
||||
auto_views=False,
|
||||
collapse_panels=True,
|
||||
)
|
||||
|
||||
return rrb.Blueprint(
|
||||
root_container,
|
||||
auto_layout=False,
|
||||
auto_views=False,
|
||||
collapse_panels=False,
|
||||
)
|
||||
|
||||
|
||||
def recorded_blueprint_rrd(
|
||||
settings: RerunSceneSettings,
|
||||
*,
|
||||
application_id: str = APPLICATION_ID,
|
||||
recording_id: str,
|
||||
) -> bytes:
|
||||
"""Serialize a small active blueprint update for an already-open recording.
|
||||
|
||||
The returned RRD contains blueprint-store messages only; it never copies the
|
||||
recorded data store and is therefore safe to push through a WebViewer log
|
||||
channel when operator display settings change.
|
||||
"""
|
||||
|
||||
recording = rr.RecordingStream(
|
||||
application_id,
|
||||
recording_id=recording_id,
|
||||
send_properties=False,
|
||||
)
|
||||
stream = rr.binary_stream(recording)
|
||||
try:
|
||||
recording.send_blueprint(
|
||||
_recorded_blueprint(
|
||||
settings,
|
||||
include_initial_playback_state=False,
|
||||
),
|
||||
make_active=True,
|
||||
make_default=False,
|
||||
)
|
||||
payload = stream.read(flush=True, flush_timeout_sec=5.0)
|
||||
except Exception as exc:
|
||||
raise RrdExportError("failed to serialize recorded blueprint") from exc
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
recording.disconnect()
|
||||
if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576:
|
||||
raise RrdExportError("serialized recorded blueprint is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _log_static_scene(recording: rr.RecordingStream) -> None:
|
||||
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||
recording.log(
|
||||
"/world/sensor_pose",
|
||||
rr.TransformAxes3D(axis_length=0.45, show_frame=False),
|
||||
static=True,
|
||||
)
|
||||
|
||||
|
||||
def _log_session_origin(recording: rr.RecordingStream) -> None:
|
||||
"""Materialize the declared zero of ``session_time`` outside the 3D scene."""
|
||||
|
||||
recording.set_time(
|
||||
SESSION_TIMELINE,
|
||||
duration=np.timedelta64(0, "ns"),
|
||||
)
|
||||
recording.log(
|
||||
"/__mission_core/session_origin",
|
||||
rr.AnyValues(session_origin=True),
|
||||
)
|
||||
|
||||
|
||||
def _set_frame_time(
|
||||
recording: rr.RecordingStream,
|
||||
sequence: int,
|
||||
session_time_ns: int,
|
||||
capture_time_ns: int,
|
||||
) -> None:
|
||||
recording.set_time(
|
||||
SESSION_TIMELINE,
|
||||
duration=np.timedelta64(session_time_ns, "ns"),
|
||||
)
|
||||
recording.set_time(
|
||||
CAPTURE_TIMELINE,
|
||||
timestamp=np.datetime64(capture_time_ns, "ns"),
|
||||
)
|
||||
recording.set_time("message_sequence", sequence=sequence)
|
||||
|
||||
|
||||
def _log_points(
|
||||
recording: rr.RecordingStream,
|
||||
frame: DecodedPointCloudView,
|
||||
settings: RerunSceneSettings,
|
||||
) -> None:
|
||||
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
|
||||
if frame.intensities is None:
|
||||
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
|
||||
else:
|
||||
intensities = np.frombuffer(frame.intensities, dtype=np.uint8)
|
||||
rgb = (
|
||||
None
|
||||
if frame.colors_rgb is None
|
||||
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
|
||||
)
|
||||
recording.log(
|
||||
"/world/points",
|
||||
rr.Points3D(
|
||||
positions,
|
||||
colors=_point_colors(positions, intensities, rgb, settings),
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _log_pose(
|
||||
recording: rr.RecordingStream,
|
||||
frame: DecodedPoseView,
|
||||
position: tuple[float, float, float],
|
||||
) -> None:
|
||||
recording.log(
|
||||
"/world/sensor_pose",
|
||||
rr.Transform3D(
|
||||
translation=position,
|
||||
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _fsync_file(path: Path) -> None:
|
||||
with path.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _sha256_file(
|
||||
path: Path,
|
||||
*,
|
||||
cancel_event: threading.Event | None = None,
|
||||
activity_callback: Callable[[], None] | None = None,
|
||||
) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
_raise_if_cancelled(cancel_event)
|
||||
_notify_activity(activity_callback)
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
raise RrdExportCancelled("RRD export was cancelled")
|
||||
|
||||
|
||||
def _notify_activity(callback: Callable[[], None] | None) -> None:
|
||||
if callback is not None:
|
||||
callback()
|
||||
Reference in New Issue
Block a user