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.
@@ -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)