vuer-cli 0.0.4__py3-none-any.whl → 0.0.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vuer_cli/add.py +66 -68
- vuer_cli/envs_publish.py +335 -309
- vuer_cli/envs_pull.py +177 -170
- vuer_cli/login.py +459 -0
- vuer_cli/main.py +7 -2
- vuer_cli/remove.py +84 -84
- vuer_cli/scripts/demcap.py +19 -15
- vuer_cli/scripts/mcap_playback.py +661 -0
- vuer_cli/scripts/minimap.py +113 -210
- vuer_cli/scripts/viz_ptc_cams.py +1 -1
- vuer_cli/scripts/viz_ptc_proxie.py +1 -1
- vuer_cli/sync.py +314 -308
- vuer_cli/upgrade.py +118 -126
- {vuer_cli-0.0.4.dist-info → vuer_cli-0.0.5.dist-info}/METADATA +36 -6
- vuer_cli-0.0.5.dist-info/RECORD +22 -0
- vuer_cli/scripts/vuer_ros_bridge.py +0 -210
- vuer_cli-0.0.4.dist-info/RECORD +0 -21
- {vuer_cli-0.0.4.dist-info → vuer_cli-0.0.5.dist-info}/WHEEL +0 -0
- {vuer_cli-0.0.4.dist-info → vuer_cli-0.0.5.dist-info}/entry_points.txt +0 -0
- {vuer_cli-0.0.4.dist-info → vuer_cli-0.0.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
"""MCAP Playback - Stream MCAP data to Vuer clients or server downlink.
|
|
2
|
+
|
|
3
|
+
This module provides a playback system for MCAP files that generates typed events
|
|
4
|
+
for each message type and can push them to Vuer clients via websocket or directly
|
|
5
|
+
to the server's downlink pipe.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from vuer_cli.scripts.mcap_playback import McapPlayback, RgbImageEvent
|
|
9
|
+
|
|
10
|
+
playback = McapPlayback("/path/to/file.mcap")
|
|
11
|
+
|
|
12
|
+
# Register handlers for specific event types
|
|
13
|
+
@playback.on(RgbImageEvent)
|
|
14
|
+
async def handle_rgb(event):
|
|
15
|
+
print(f"RGB image from {event.camera_name} at {event.timestamp_s}")
|
|
16
|
+
|
|
17
|
+
# Start playback
|
|
18
|
+
await playback.play(fps=30, loop=True)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# Event Types
|
|
31
|
+
# =============================================================================
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class McapEvent:
|
|
36
|
+
"""Base class for all MCAP events."""
|
|
37
|
+
|
|
38
|
+
timestamp_ns: int
|
|
39
|
+
timestamp_s: float
|
|
40
|
+
topic: str
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def event_type(self) -> str:
|
|
44
|
+
return self.__class__.__name__
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class RgbImageEvent(McapEvent):
|
|
49
|
+
"""RGB image data from a camera."""
|
|
50
|
+
|
|
51
|
+
camera_name: str
|
|
52
|
+
data: bytes # JPEG or raw image bytes
|
|
53
|
+
width: int = 0
|
|
54
|
+
height: int = 0
|
|
55
|
+
encoding: str = "jpeg"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class DepthImageEvent(McapEvent):
|
|
60
|
+
"""Depth image data from a camera."""
|
|
61
|
+
|
|
62
|
+
camera_name: str
|
|
63
|
+
data: bytes # PNG or raw depth bytes
|
|
64
|
+
width: int = 0
|
|
65
|
+
height: int = 0
|
|
66
|
+
encoding: str = "16UC1"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ImuEvent(McapEvent):
|
|
71
|
+
"""IMU sensor data."""
|
|
72
|
+
|
|
73
|
+
orientation: List[float] = field(default_factory=lambda: [0, 0, 0, 1]) # xyzw
|
|
74
|
+
angular_velocity: List[float] = field(default_factory=lambda: [0, 0, 0])
|
|
75
|
+
linear_acceleration: List[float] = field(default_factory=lambda: [0, 0, 0])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class TransformEvent(McapEvent):
|
|
80
|
+
"""TF transform data."""
|
|
81
|
+
|
|
82
|
+
parent_frame: str = ""
|
|
83
|
+
child_frame: str = ""
|
|
84
|
+
translation: List[float] = field(default_factory=lambda: [0, 0, 0])
|
|
85
|
+
rotation: List[float] = field(default_factory=lambda: [0, 0, 0, 1]) # xyzw
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class OdometryEvent(McapEvent):
|
|
90
|
+
"""Odometry data."""
|
|
91
|
+
|
|
92
|
+
frame_id: str = ""
|
|
93
|
+
child_frame_id: str = ""
|
|
94
|
+
position: List[float] = field(default_factory=lambda: [0, 0, 0])
|
|
95
|
+
orientation: List[float] = field(default_factory=lambda: [0, 0, 0, 1])
|
|
96
|
+
linear_velocity: List[float] = field(default_factory=lambda: [0, 0, 0])
|
|
97
|
+
angular_velocity: List[float] = field(default_factory=lambda: [0, 0, 0])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class JointStatesEvent(McapEvent):
|
|
102
|
+
"""Joint states data."""
|
|
103
|
+
|
|
104
|
+
frame_id: str = ""
|
|
105
|
+
names: List[str] = field(default_factory=list)
|
|
106
|
+
positions: List[float] = field(default_factory=list)
|
|
107
|
+
velocities: List[float] = field(default_factory=list)
|
|
108
|
+
efforts: List[float] = field(default_factory=list)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class LidarEvent(McapEvent):
|
|
113
|
+
"""LiDAR point cloud data."""
|
|
114
|
+
|
|
115
|
+
data: bytes = b""
|
|
116
|
+
point_count: int = 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class CameraInfoEvent(McapEvent):
|
|
121
|
+
"""Camera intrinsics information."""
|
|
122
|
+
|
|
123
|
+
camera_name: str = ""
|
|
124
|
+
width: int = 0
|
|
125
|
+
height: int = 0
|
|
126
|
+
K: List[float] = field(default_factory=list) # 3x3 intrinsic matrix
|
|
127
|
+
D: List[float] = field(default_factory=list) # distortion coefficients
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Type alias for event handlers
|
|
131
|
+
E = TypeVar("E", bound=McapEvent)
|
|
132
|
+
EventHandler = Callable[[McapEvent], Any]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# =============================================================================
|
|
136
|
+
# MCAP Playback Class
|
|
137
|
+
# =============================================================================
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class McapPlayback:
|
|
141
|
+
"""Playback MCAP files and dispatch events to registered handlers.
|
|
142
|
+
|
|
143
|
+
Supports both callback-based handlers and direct streaming to Vuer sessions.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
playback = McapPlayback("/path/to/recording.mcap")
|
|
147
|
+
|
|
148
|
+
@playback.on(RgbImageEvent)
|
|
149
|
+
async def handle_rgb(event):
|
|
150
|
+
sess.upsert @ ImagePlane(event.data, key=event.camera_name)
|
|
151
|
+
|
|
152
|
+
await playback.play(fps=30)
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
mcap_path: str,
|
|
158
|
+
start_time_ns: Optional[int] = None,
|
|
159
|
+
end_time_ns: Optional[int] = None,
|
|
160
|
+
):
|
|
161
|
+
self.mcap_path = Path(mcap_path)
|
|
162
|
+
self.start_time_ns = start_time_ns
|
|
163
|
+
self.end_time_ns = end_time_ns
|
|
164
|
+
|
|
165
|
+
self._handlers: Dict[Type[McapEvent], List[EventHandler]] = {}
|
|
166
|
+
self._global_handlers: List[EventHandler] = []
|
|
167
|
+
|
|
168
|
+
# Lazy-loaded decoder
|
|
169
|
+
self._decoder_factory = None
|
|
170
|
+
|
|
171
|
+
def on(self, event_type: Type[E]) -> Callable[[EventHandler], EventHandler]:
|
|
172
|
+
"""Decorator to register a handler for a specific event type.
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
@playback.on(RgbImageEvent)
|
|
176
|
+
async def handle_rgb(event):
|
|
177
|
+
print(event.camera_name)
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def decorator(handler: EventHandler) -> EventHandler:
|
|
181
|
+
if event_type not in self._handlers:
|
|
182
|
+
self._handlers[event_type] = []
|
|
183
|
+
self._handlers[event_type].append(handler)
|
|
184
|
+
return handler
|
|
185
|
+
|
|
186
|
+
return decorator
|
|
187
|
+
|
|
188
|
+
def on_all(self, handler: EventHandler) -> EventHandler:
|
|
189
|
+
"""Register a handler that receives all events."""
|
|
190
|
+
self._global_handlers.append(handler)
|
|
191
|
+
return handler
|
|
192
|
+
|
|
193
|
+
async def _dispatch(self, event: McapEvent):
|
|
194
|
+
"""Dispatch an event to all registered handlers."""
|
|
195
|
+
# Type-specific handlers
|
|
196
|
+
handlers = self._handlers.get(type(event), [])
|
|
197
|
+
for handler in handlers:
|
|
198
|
+
result = handler(event)
|
|
199
|
+
if asyncio.iscoroutine(result):
|
|
200
|
+
await result
|
|
201
|
+
|
|
202
|
+
# Global handlers
|
|
203
|
+
for handler in self._global_handlers:
|
|
204
|
+
result = handler(event)
|
|
205
|
+
if asyncio.iscoroutine(result):
|
|
206
|
+
await result
|
|
207
|
+
|
|
208
|
+
def _get_decoder_factory(self):
|
|
209
|
+
"""Lazy-load the decoder factory."""
|
|
210
|
+
if self._decoder_factory is None:
|
|
211
|
+
from mcap_ros2.decoder import DecoderFactory
|
|
212
|
+
|
|
213
|
+
self._decoder_factory = DecoderFactory()
|
|
214
|
+
return self._decoder_factory
|
|
215
|
+
|
|
216
|
+
def _sanitize_topic_name(self, topic: str) -> str:
|
|
217
|
+
"""Convert topic name to a clean identifier."""
|
|
218
|
+
return topic.replace("/", "_").strip("_")
|
|
219
|
+
|
|
220
|
+
def _parse_rgb_image(self, topic, message, schema, channel) -> Optional[RgbImageEvent]:
|
|
221
|
+
"""Parse RGB image message into event."""
|
|
222
|
+
camera_name = self._sanitize_topic_name(topic)
|
|
223
|
+
timestamp_ns = message.log_time
|
|
224
|
+
timestamp_s = timestamp_ns / 1e9
|
|
225
|
+
|
|
226
|
+
if "image_compressed" in topic:
|
|
227
|
+
# Compressed JPEG
|
|
228
|
+
data = message.data[4:]
|
|
229
|
+
jpeg_start = data.find(b"\xff\xd8")
|
|
230
|
+
if jpeg_start == -1:
|
|
231
|
+
return None
|
|
232
|
+
jpeg_data = data[jpeg_start:]
|
|
233
|
+
return RgbImageEvent(
|
|
234
|
+
timestamp_ns=timestamp_ns,
|
|
235
|
+
timestamp_s=timestamp_s,
|
|
236
|
+
topic=topic,
|
|
237
|
+
camera_name=camera_name,
|
|
238
|
+
data=jpeg_data,
|
|
239
|
+
encoding="jpeg",
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
# Raw image
|
|
243
|
+
try:
|
|
244
|
+
decoder = self._get_decoder_factory().decoder_for(channel.message_encoding, schema)
|
|
245
|
+
msg = decoder(message.data)
|
|
246
|
+
return RgbImageEvent(
|
|
247
|
+
timestamp_ns=timestamp_ns,
|
|
248
|
+
timestamp_s=timestamp_s,
|
|
249
|
+
topic=topic,
|
|
250
|
+
camera_name=camera_name,
|
|
251
|
+
data=bytes(msg.data),
|
|
252
|
+
width=msg.width,
|
|
253
|
+
height=msg.height,
|
|
254
|
+
encoding=msg.encoding,
|
|
255
|
+
)
|
|
256
|
+
except Exception:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
def _parse_depth_image(self, topic, message, schema, channel) -> Optional[DepthImageEvent]:
|
|
260
|
+
"""Parse depth image message into event."""
|
|
261
|
+
camera_name = self._sanitize_topic_name(topic)
|
|
262
|
+
timestamp_ns = message.log_time
|
|
263
|
+
timestamp_s = timestamp_ns / 1e9
|
|
264
|
+
|
|
265
|
+
if "compressedDepth" in topic:
|
|
266
|
+
# Compressed PNG
|
|
267
|
+
data = message.data[4:]
|
|
268
|
+
png_start = data.find(b"\x89PNG")
|
|
269
|
+
if png_start == -1:
|
|
270
|
+
return None
|
|
271
|
+
png_data = data[png_start:]
|
|
272
|
+
return DepthImageEvent(
|
|
273
|
+
timestamp_ns=timestamp_ns,
|
|
274
|
+
timestamp_s=timestamp_s,
|
|
275
|
+
topic=topic,
|
|
276
|
+
camera_name=camera_name,
|
|
277
|
+
data=png_data,
|
|
278
|
+
encoding="png",
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
# Raw depth
|
|
282
|
+
try:
|
|
283
|
+
decoder = self._get_decoder_factory().decoder_for(channel.message_encoding, schema)
|
|
284
|
+
msg = decoder(message.data)
|
|
285
|
+
return DepthImageEvent(
|
|
286
|
+
timestamp_ns=timestamp_ns,
|
|
287
|
+
timestamp_s=timestamp_s,
|
|
288
|
+
topic=topic,
|
|
289
|
+
camera_name=camera_name,
|
|
290
|
+
data=bytes(msg.data),
|
|
291
|
+
width=msg.width,
|
|
292
|
+
height=msg.height,
|
|
293
|
+
encoding=msg.encoding,
|
|
294
|
+
)
|
|
295
|
+
except Exception:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
def _parse_imu(self, topic, message, schema, channel) -> Optional[ImuEvent]:
|
|
299
|
+
"""Parse IMU message into event."""
|
|
300
|
+
timestamp_ns = message.log_time
|
|
301
|
+
timestamp_s = timestamp_ns / 1e9
|
|
302
|
+
|
|
303
|
+
if not schema or "Imu" not in schema.name:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
decoder = self._get_decoder_factory().decoder_for(channel.message_encoding, schema)
|
|
308
|
+
msg = decoder(message.data)
|
|
309
|
+
return ImuEvent(
|
|
310
|
+
timestamp_ns=timestamp_ns,
|
|
311
|
+
timestamp_s=timestamp_s,
|
|
312
|
+
topic=topic,
|
|
313
|
+
orientation=[msg.orientation.x, msg.orientation.y, msg.orientation.z, msg.orientation.w],
|
|
314
|
+
angular_velocity=[msg.angular_velocity.x, msg.angular_velocity.y, msg.angular_velocity.z],
|
|
315
|
+
linear_acceleration=[
|
|
316
|
+
msg.linear_acceleration.x,
|
|
317
|
+
msg.linear_acceleration.y,
|
|
318
|
+
msg.linear_acceleration.z,
|
|
319
|
+
],
|
|
320
|
+
)
|
|
321
|
+
except Exception:
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
def _parse_transform(self, topic, message, schema, channel) -> List[TransformEvent]:
|
|
325
|
+
"""Parse TF message into events (one per transform)."""
|
|
326
|
+
timestamp_ns = message.log_time
|
|
327
|
+
timestamp_s = timestamp_ns / 1e9
|
|
328
|
+
events = []
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
decoder = self._get_decoder_factory().decoder_for(channel.message_encoding, schema)
|
|
332
|
+
msg = decoder(message.data)
|
|
333
|
+
|
|
334
|
+
if hasattr(msg, "transforms"):
|
|
335
|
+
for tf in msg.transforms:
|
|
336
|
+
events.append(
|
|
337
|
+
TransformEvent(
|
|
338
|
+
timestamp_ns=timestamp_ns,
|
|
339
|
+
timestamp_s=timestamp_s,
|
|
340
|
+
topic=topic,
|
|
341
|
+
parent_frame=tf.header.frame_id,
|
|
342
|
+
child_frame=tf.child_frame_id,
|
|
343
|
+
translation=[
|
|
344
|
+
tf.transform.translation.x,
|
|
345
|
+
tf.transform.translation.y,
|
|
346
|
+
tf.transform.translation.z,
|
|
347
|
+
],
|
|
348
|
+
rotation=[
|
|
349
|
+
tf.transform.rotation.x,
|
|
350
|
+
tf.transform.rotation.y,
|
|
351
|
+
tf.transform.rotation.z,
|
|
352
|
+
tf.transform.rotation.w,
|
|
353
|
+
],
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
return events
|
|
360
|
+
|
|
361
|
+
def _parse_odometry(self, topic, message, schema, channel) -> Optional[OdometryEvent]:
|
|
362
|
+
"""Parse odometry message into event."""
|
|
363
|
+
timestamp_ns = message.log_time
|
|
364
|
+
timestamp_s = timestamp_ns / 1e9
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
decoder = self._get_decoder_factory().decoder_for(channel.message_encoding, schema)
|
|
368
|
+
msg = decoder(message.data)
|
|
369
|
+
return OdometryEvent(
|
|
370
|
+
timestamp_ns=timestamp_ns,
|
|
371
|
+
timestamp_s=timestamp_s,
|
|
372
|
+
topic=topic,
|
|
373
|
+
frame_id=msg.header.frame_id,
|
|
374
|
+
child_frame_id=msg.child_frame_id,
|
|
375
|
+
position=[msg.pose.pose.position.x, msg.pose.pose.position.y, msg.pose.pose.position.z],
|
|
376
|
+
orientation=[
|
|
377
|
+
msg.pose.pose.orientation.x,
|
|
378
|
+
msg.pose.pose.orientation.y,
|
|
379
|
+
msg.pose.pose.orientation.z,
|
|
380
|
+
msg.pose.pose.orientation.w,
|
|
381
|
+
],
|
|
382
|
+
linear_velocity=[
|
|
383
|
+
msg.twist.twist.linear.x,
|
|
384
|
+
msg.twist.twist.linear.y,
|
|
385
|
+
msg.twist.twist.linear.z,
|
|
386
|
+
],
|
|
387
|
+
angular_velocity=[
|
|
388
|
+
msg.twist.twist.angular.x,
|
|
389
|
+
msg.twist.twist.angular.y,
|
|
390
|
+
msg.twist.twist.angular.z,
|
|
391
|
+
],
|
|
392
|
+
)
|
|
393
|
+
except Exception:
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
def _parse_joint_states(self, topic, message, schema, channel) -> Optional[JointStatesEvent]:
|
|
397
|
+
"""Parse joint states message into event."""
|
|
398
|
+
timestamp_ns = message.log_time
|
|
399
|
+
timestamp_s = timestamp_ns / 1e9
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
decoder = self._get_decoder_factory().decoder_for(channel.message_encoding, schema)
|
|
403
|
+
msg = decoder(message.data)
|
|
404
|
+
return JointStatesEvent(
|
|
405
|
+
timestamp_ns=timestamp_ns,
|
|
406
|
+
timestamp_s=timestamp_s,
|
|
407
|
+
topic=topic,
|
|
408
|
+
frame_id=msg.header.frame_id,
|
|
409
|
+
names=list(msg.name),
|
|
410
|
+
positions=list(msg.position) if msg.position else [],
|
|
411
|
+
velocities=list(msg.velocity) if msg.velocity else [],
|
|
412
|
+
efforts=list(msg.effort) if msg.effort else [],
|
|
413
|
+
)
|
|
414
|
+
except Exception:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
def _parse_camera_info(self, topic, message, schema, channel) -> Optional[CameraInfoEvent]:
|
|
418
|
+
"""Parse camera info message into event."""
|
|
419
|
+
camera_name = self._sanitize_topic_name(topic)
|
|
420
|
+
timestamp_ns = message.log_time
|
|
421
|
+
timestamp_s = timestamp_ns / 1e9
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
decoder = self._get_decoder_factory().decoder_for(channel.message_encoding, schema)
|
|
425
|
+
msg = decoder(message.data)
|
|
426
|
+
return CameraInfoEvent(
|
|
427
|
+
timestamp_ns=timestamp_ns,
|
|
428
|
+
timestamp_s=timestamp_s,
|
|
429
|
+
topic=topic,
|
|
430
|
+
camera_name=camera_name,
|
|
431
|
+
width=msg.width,
|
|
432
|
+
height=msg.height,
|
|
433
|
+
K=list(msg.k) if hasattr(msg, "k") else [],
|
|
434
|
+
D=list(msg.d) if hasattr(msg, "d") else [],
|
|
435
|
+
)
|
|
436
|
+
except Exception:
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
def _parse_lidar(self, topic, message) -> Optional[LidarEvent]:
|
|
440
|
+
"""Parse lidar message into event."""
|
|
441
|
+
timestamp_ns = message.log_time
|
|
442
|
+
timestamp_s = timestamp_ns / 1e9
|
|
443
|
+
|
|
444
|
+
try:
|
|
445
|
+
data = message.data[4:]
|
|
446
|
+
return LidarEvent(
|
|
447
|
+
timestamp_ns=timestamp_ns,
|
|
448
|
+
timestamp_s=timestamp_s,
|
|
449
|
+
topic=topic,
|
|
450
|
+
data=data,
|
|
451
|
+
)
|
|
452
|
+
except Exception:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
async def iter_events(self):
|
|
456
|
+
"""Iterate through all events in the MCAP file."""
|
|
457
|
+
from mcap.reader import make_reader
|
|
458
|
+
|
|
459
|
+
with open(self.mcap_path, "rb") as f:
|
|
460
|
+
reader = make_reader(f)
|
|
461
|
+
|
|
462
|
+
for schema, channel, message in reader.iter_messages():
|
|
463
|
+
topic = channel.topic
|
|
464
|
+
|
|
465
|
+
# Apply time filter
|
|
466
|
+
if self.start_time_ns is not None and message.log_time < self.start_time_ns:
|
|
467
|
+
continue
|
|
468
|
+
if self.end_time_ns is not None and message.log_time > self.end_time_ns:
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
# Route to parser based on topic
|
|
472
|
+
events = []
|
|
473
|
+
|
|
474
|
+
if "compressedDepth" in topic or "depth/image" in topic:
|
|
475
|
+
event = self._parse_depth_image(topic, message, schema, channel)
|
|
476
|
+
if event:
|
|
477
|
+
events.append(event)
|
|
478
|
+
|
|
479
|
+
elif "color/image" in topic:
|
|
480
|
+
event = self._parse_rgb_image(topic, message, schema, channel)
|
|
481
|
+
if event:
|
|
482
|
+
events.append(event)
|
|
483
|
+
|
|
484
|
+
elif "imu" in topic.lower():
|
|
485
|
+
event = self._parse_imu(topic, message, schema, channel)
|
|
486
|
+
if event:
|
|
487
|
+
events.append(event)
|
|
488
|
+
|
|
489
|
+
elif "/tf" in topic:
|
|
490
|
+
events.extend(self._parse_transform(topic, message, schema, channel))
|
|
491
|
+
|
|
492
|
+
elif "/odom" in topic:
|
|
493
|
+
event = self._parse_odometry(topic, message, schema, channel)
|
|
494
|
+
if event:
|
|
495
|
+
events.append(event)
|
|
496
|
+
|
|
497
|
+
elif "joint_states" in topic.lower():
|
|
498
|
+
event = self._parse_joint_states(topic, message, schema, channel)
|
|
499
|
+
if event:
|
|
500
|
+
events.append(event)
|
|
501
|
+
|
|
502
|
+
elif "camera_info" in topic:
|
|
503
|
+
event = self._parse_camera_info(topic, message, schema, channel)
|
|
504
|
+
if event:
|
|
505
|
+
events.append(event)
|
|
506
|
+
|
|
507
|
+
elif "pandar_points" in topic or "hesai" in topic:
|
|
508
|
+
event = self._parse_lidar(topic, message)
|
|
509
|
+
if event:
|
|
510
|
+
events.append(event)
|
|
511
|
+
|
|
512
|
+
for event in events:
|
|
513
|
+
yield event
|
|
514
|
+
|
|
515
|
+
async def play(
|
|
516
|
+
self,
|
|
517
|
+
fps: float = 30.0,
|
|
518
|
+
loop: bool = False,
|
|
519
|
+
speed: float = 1.0,
|
|
520
|
+
):
|
|
521
|
+
"""Play back the MCAP file at the specified rate.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
fps: Target frames per second
|
|
525
|
+
loop: Whether to loop playback
|
|
526
|
+
speed: Playback speed multiplier (1.0 = realtime)
|
|
527
|
+
"""
|
|
528
|
+
frame_delay = 1.0 / fps
|
|
529
|
+
|
|
530
|
+
while True:
|
|
531
|
+
last_time_ns = None
|
|
532
|
+
|
|
533
|
+
async for event in self.iter_events():
|
|
534
|
+
# Dispatch to handlers
|
|
535
|
+
await self._dispatch(event)
|
|
536
|
+
|
|
537
|
+
# Timing control
|
|
538
|
+
if last_time_ns is not None:
|
|
539
|
+
delta_ns = event.timestamp_ns - last_time_ns
|
|
540
|
+
delta_s = delta_ns / 1e9 / speed
|
|
541
|
+
if delta_s > 0 and delta_s < 1.0:
|
|
542
|
+
await asyncio.sleep(min(delta_s, frame_delay))
|
|
543
|
+
else:
|
|
544
|
+
await asyncio.sleep(frame_delay)
|
|
545
|
+
else:
|
|
546
|
+
await asyncio.sleep(frame_delay)
|
|
547
|
+
|
|
548
|
+
last_time_ns = event.timestamp_ns
|
|
549
|
+
|
|
550
|
+
if not loop:
|
|
551
|
+
break
|
|
552
|
+
|
|
553
|
+
def push_to_session(self, sess):
|
|
554
|
+
"""Create handlers that push events directly to a Vuer session.
|
|
555
|
+
|
|
556
|
+
This registers default handlers for common event types that update
|
|
557
|
+
the Vuer scene.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
sess: VuerSession instance
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
@self.on(RgbImageEvent)
|
|
564
|
+
async def push_rgb(event: RgbImageEvent):
|
|
565
|
+
from vuer.schemas import ImagePlane
|
|
566
|
+
|
|
567
|
+
sess.upsert @ ImagePlane(
|
|
568
|
+
src=event.data,
|
|
569
|
+
key=f"rgb-{event.camera_name}",
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
@self.on(DepthImageEvent)
|
|
573
|
+
async def push_depth(event: DepthImageEvent):
|
|
574
|
+
from vuer.schemas import DepthPointCloud
|
|
575
|
+
|
|
576
|
+
sess.upsert @ DepthPointCloud(
|
|
577
|
+
src=event.data,
|
|
578
|
+
key=f"depth-{event.camera_name}",
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
@self.on(TransformEvent)
|
|
582
|
+
async def push_transform(event: TransformEvent):
|
|
583
|
+
# Transform events can update object positions
|
|
584
|
+
# This is a placeholder - actual implementation depends on scene structure
|
|
585
|
+
pass
|
|
586
|
+
|
|
587
|
+
return self
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# =============================================================================
|
|
591
|
+
# Vuer Integration
|
|
592
|
+
# =============================================================================
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
class VuerMcapPlayer:
|
|
596
|
+
"""High-level integration of MCAP playback with Vuer server.
|
|
597
|
+
|
|
598
|
+
Example:
|
|
599
|
+
player = VuerMcapPlayer("/path/to/file.mcap")
|
|
600
|
+
|
|
601
|
+
@app.spawn(start=True)
|
|
602
|
+
async def main(sess):
|
|
603
|
+
await player.stream_to_session(sess, fps=30)
|
|
604
|
+
"""
|
|
605
|
+
|
|
606
|
+
def __init__(
|
|
607
|
+
self,
|
|
608
|
+
mcap_path: str,
|
|
609
|
+
start_time_ns: Optional[int] = None,
|
|
610
|
+
end_time_ns: Optional[int] = None,
|
|
611
|
+
):
|
|
612
|
+
self.playback = McapPlayback(mcap_path, start_time_ns, end_time_ns)
|
|
613
|
+
|
|
614
|
+
def on(self, event_type: Type[E]) -> Callable[[EventHandler], EventHandler]:
|
|
615
|
+
"""Register an event handler."""
|
|
616
|
+
return self.playback.on(event_type)
|
|
617
|
+
|
|
618
|
+
async def stream_to_session(
|
|
619
|
+
self,
|
|
620
|
+
sess,
|
|
621
|
+
fps: float = 30.0,
|
|
622
|
+
loop: bool = False,
|
|
623
|
+
speed: float = 1.0,
|
|
624
|
+
):
|
|
625
|
+
"""Stream MCAP data to a Vuer session.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
sess: VuerSession instance
|
|
629
|
+
fps: Target frames per second
|
|
630
|
+
loop: Whether to loop playback
|
|
631
|
+
speed: Playback speed multiplier
|
|
632
|
+
"""
|
|
633
|
+
self.playback.push_to_session(sess)
|
|
634
|
+
await self.playback.play(fps=fps, loop=loop, speed=speed)
|
|
635
|
+
|
|
636
|
+
async def push_to_downlink(
|
|
637
|
+
self,
|
|
638
|
+
server,
|
|
639
|
+
fps: float = 30.0,
|
|
640
|
+
loop: bool = False,
|
|
641
|
+
speed: float = 1.0,
|
|
642
|
+
):
|
|
643
|
+
"""Push MCAP data directly to server's downlink pipe.
|
|
644
|
+
|
|
645
|
+
This bypasses the websocket and pushes events directly to all
|
|
646
|
+
connected clients via the server's internal downlink.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
server: Vuer server instance
|
|
650
|
+
fps: Target frames per second
|
|
651
|
+
loop: Whether to loop playback
|
|
652
|
+
speed: Playback speed multiplier
|
|
653
|
+
"""
|
|
654
|
+
# Register handlers that use server.broadcast or server.downlink
|
|
655
|
+
@self.playback.on_all
|
|
656
|
+
async def broadcast(event: McapEvent):
|
|
657
|
+
# Convert event to Vuer schema and broadcast
|
|
658
|
+
# This is a placeholder - actual implementation depends on server API
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
await self.playback.play(fps=fps, loop=loop, speed=speed)
|