vuer-cli 0.0.3__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,866 @@
1
+ """
2
+ MCAP Data Extractor
3
+ Extracts data from MCAP file into readable formats:
4
+ - RGB images as JPEG/PNG (compressed or raw sensor_msgs/msg/Image)
5
+ - Depth images as PNG (compressed or raw sensor_msgs/msg/Image)
6
+ - LiDAR point clouds as PLY files
7
+ - IMU data as CSV
8
+ - Camera info as JSON
9
+ - Transforms as CSV (from /tf or /tf_static topics)
10
+ - Odometry as CSV (from /odom topic)
11
+ - Joint states as CSV (from joint_states topics)
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import struct
17
+ from collections import defaultdict
18
+ from pathlib import Path
19
+
20
+ import cv2
21
+ import numpy as np
22
+ import pandas as pd
23
+ from mcap.reader import make_reader
24
+ from mcap_ros2.decoder import DecoderFactory
25
+
26
+
27
+ class MCAPExtractor:
28
+ """Extract data from MCAP files"""
29
+
30
+ def __init__(
31
+ self,
32
+ mcap_path: str,
33
+ output_dir: str = "demcap_data",
34
+ create_videos: bool = True,
35
+ video_fps: int = 30,
36
+ start_time_ns: int = None,
37
+ end_time_ns: int = None,
38
+ ):
39
+ self.mcap_path = Path(mcap_path)
40
+ self.output_dir = Path(output_dir)
41
+ self.output_dir.mkdir(exist_ok=True)
42
+ self.create_videos = create_videos
43
+ self.video_fps = video_fps
44
+ self.start_time_ns = start_time_ns
45
+ self.end_time_ns = end_time_ns
46
+
47
+ # Create subdirectories
48
+ self.dirs = {
49
+ "images": self.output_dir / "images",
50
+ "depth": self.output_dir / "depth",
51
+ "lidar": self.output_dir / "lidar",
52
+ "imu": self.output_dir / "imu",
53
+ "transforms": self.output_dir / "transforms",
54
+ "other": self.output_dir / "other",
55
+ "videos": self.output_dir / "videos",
56
+ }
57
+ for d in self.dirs.values():
58
+ d.mkdir(exist_ok=True)
59
+
60
+ self.counters = defaultdict(int)
61
+ self.imu_data = defaultdict(list)
62
+ self.tf_data = []
63
+ self.tf_empty_messages = 0
64
+ self.camera_intrinsics = {}
65
+ self.decoder_factory = DecoderFactory()
66
+ self.topic_info = {}
67
+ self.other_data = defaultdict(list)
68
+ self.odom_data = []
69
+ self.joint_states_data = []
70
+
71
+ def extract_all(self):
72
+ """Extract all data from MCAP file"""
73
+ print(f"{'='*80}")
74
+ print(f"Extracting data from: {self.mcap_path.name}")
75
+ print(f"Output directory: {self.output_dir}")
76
+ if self.start_time_ns is not None and self.end_time_ns is not None:
77
+ print(f"Time filter: {self.start_time_ns} to {self.end_time_ns}")
78
+ print(f"{'='*80}")
79
+
80
+ # First pass: discover all topics
81
+ self._discover_topics()
82
+
83
+ topic_counts = defaultdict(int)
84
+
85
+ with open(self.mcap_path, "rb") as f:
86
+ reader = make_reader(f)
87
+
88
+ for schema, channel, message in reader.iter_messages():
89
+ topic = channel.topic
90
+
91
+ # Check timestamp filter
92
+ if self.start_time_ns is not None and message.log_time < self.start_time_ns:
93
+ continue
94
+ if self.end_time_ns is not None and message.log_time > self.end_time_ns:
95
+ continue
96
+
97
+ topic_counts[topic] += 1
98
+
99
+ try:
100
+ handled = False
101
+
102
+ # Route to appropriate extractor
103
+ if "compressedDepth" in topic:
104
+ self._extract_depth_image(topic, message, schema, channel)
105
+ handled = True
106
+ elif "depth/image_rect_raw" in topic or "depth/image_raw" in topic:
107
+ self._extract_depth_image_raw(topic, message, schema, channel)
108
+ handled = True
109
+ elif "color/image_compressed" in topic:
110
+ self._extract_rgb_image(topic, message, schema, channel)
111
+ handled = True
112
+ elif "color/image_raw" in topic:
113
+ self._extract_rgb_image_raw(topic, message, schema, channel)
114
+ handled = True
115
+ elif "pandar_points" in topic or "hesai" in topic:
116
+ self._extract_lidar(topic, message)
117
+ handled = True
118
+ elif "imu" in topic.lower() or "vectornav" in topic.lower():
119
+ self._extract_imu(topic, message, schema, channel)
120
+ handled = True
121
+ elif "camera_info" in topic:
122
+ self._extract_camera_info(topic, message, schema, channel)
123
+ handled = True
124
+ elif "/tf" in topic:
125
+ self._extract_transform(topic, message, schema, channel)
126
+ handled = True
127
+ elif "/odom" in topic:
128
+ self._extract_odometry(topic, message, schema, channel)
129
+ handled = True
130
+ elif "joint_states" in topic.lower():
131
+ self._extract_joint_states(topic, message, schema, channel)
132
+ handled = True
133
+
134
+ if not handled:
135
+ self._extract_other(topic, message, schema, channel)
136
+
137
+ # Progress update
138
+ total = sum(topic_counts.values())
139
+ if total % 100000 == 0:
140
+ print(f"Processed {total} messages...")
141
+
142
+ except Exception as e:
143
+ print(f"Error processing {topic}: {e}")
144
+ continue
145
+
146
+ # Save accumulated data
147
+ self._save_imu_data()
148
+ self._save_transform_data()
149
+ self._save_odometry_data()
150
+ self._save_joint_states_data()
151
+ self._save_camera_intrinsics()
152
+ self._save_other_data()
153
+
154
+ # Create videos from RGB images
155
+ if self.create_videos:
156
+ self._create_videos(fps=self.video_fps)
157
+
158
+ # Print summary
159
+ self._print_summary()
160
+
161
+ def _discover_topics(self):
162
+ """Discover all topics and their schemas in the MCAP file"""
163
+ print("Discovering topics...")
164
+
165
+ with open(self.mcap_path, "rb") as f:
166
+ reader = make_reader(f)
167
+
168
+ for schema, channel, message in reader.iter_messages():
169
+ topic = channel.topic
170
+
171
+ if topic not in self.topic_info:
172
+ schema_name = schema.name if schema else "Unknown"
173
+ self.topic_info[topic] = {
174
+ "topic": topic,
175
+ "schema": schema_name,
176
+ "encoding": channel.message_encoding,
177
+ "count": 0,
178
+ }
179
+
180
+ self.topic_info[topic]["count"] += 1
181
+
182
+ print(f"\nFound {len(self.topic_info)} topics:")
183
+ for topic, info in sorted(self.topic_info.items()):
184
+ print(f" {topic} [Schema: {info['schema']}, Messages: {info['count']}]")
185
+
186
+ def _extract_other(self, topic, message, schema, channel):
187
+ """Extract data from unhandled topics"""
188
+ timestamp_ns = message.log_time
189
+ timestamp_s = timestamp_ns / 1e9
190
+
191
+ topic_name = self._sanitize_topic_name(topic)
192
+
193
+ try:
194
+ if schema and schema.name:
195
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
196
+ msg = decoder(message.data)
197
+
198
+ msg_dict = self._ros_msg_to_dict(msg)
199
+ msg_dict["timestamp_ns"] = timestamp_ns
200
+ msg_dict["timestamp_s"] = timestamp_s
201
+ msg_dict["topic"] = topic
202
+
203
+ self.other_data[topic_name].append(msg_dict)
204
+ self.counters[f"other_{topic_name}"] += 1
205
+ except Exception:
206
+ raw_dir = self.dirs["other"] / topic_name / "raw"
207
+ raw_dir.mkdir(parents=True, exist_ok=True)
208
+
209
+ filename = f"{timestamp_ns:019d}.bin"
210
+ output_path = raw_dir / filename
211
+
212
+ with open(output_path, "wb") as f:
213
+ f.write(message.data)
214
+
215
+ os.chmod(output_path, 0o644)
216
+ self.counters[f"other_{topic_name}_raw"] += 1
217
+
218
+ def _ros_msg_to_dict(self, msg):
219
+ """Convert ROS message to dictionary recursively"""
220
+ result = {}
221
+
222
+ for attr in dir(msg):
223
+ if attr.startswith("_"):
224
+ continue
225
+
226
+ try:
227
+ value = getattr(msg, attr)
228
+
229
+ if callable(value):
230
+ continue
231
+
232
+ if hasattr(value, "__dict__") and not isinstance(value, (str, bytes)):
233
+ result[attr] = self._ros_msg_to_dict(value)
234
+ elif isinstance(value, (list, tuple)):
235
+ if len(value) > 0 and hasattr(value[0], "__dict__"):
236
+ result[attr] = [self._ros_msg_to_dict(item) for item in value]
237
+ else:
238
+ result[attr] = list(value)
239
+ else:
240
+ result[attr] = value
241
+
242
+ except Exception:
243
+ continue
244
+
245
+ return result
246
+
247
+ def _extract_rgb_image(self, topic, message, schema=None, channel=None):
248
+ """Extract RGB image"""
249
+ camera_name = self._sanitize_topic_name(topic)
250
+ data = message.data[4:]
251
+
252
+ jpeg_start = data.find(b"\xff\xd8")
253
+ if jpeg_start == -1:
254
+ return
255
+
256
+ jpeg_data = data[jpeg_start:]
257
+
258
+ camera_dir = self.dirs["images"] / camera_name
259
+ camera_dir.mkdir(exist_ok=True)
260
+
261
+ # Get ROS timestamp from message header (preferred) or fall back to log_time
262
+ timestamp_ns = message.log_time # fallback
263
+ if schema and channel:
264
+ try:
265
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
266
+ msg = decoder(message.data)
267
+ timestamp_ns = msg.header.stamp.sec * 1_000_000_000 + msg.header.stamp.nanosec
268
+ except Exception:
269
+ pass # Use fallback log_time
270
+
271
+ filename = f"{timestamp_ns:019d}.jpg"
272
+ output_path = camera_dir / filename
273
+
274
+ with open(output_path, "wb") as f:
275
+ f.write(jpeg_data)
276
+
277
+ os.chmod(output_path, 0o644)
278
+ self.counters[f"rgb_{camera_name}"] += 1
279
+
280
+ if self.counters[f"rgb_{camera_name}"] % 100000 == 0:
281
+ print(f" Extracted {self.counters[f'rgb_{camera_name}']} RGB images from {camera_name}")
282
+
283
+ def _extract_rgb_image_raw(self, topic, message, schema, channel):
284
+ """Extract raw RGB image (sensor_msgs/msg/Image)"""
285
+ camera_name = self._sanitize_topic_name(topic)
286
+
287
+ try:
288
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
289
+ msg = decoder(message.data)
290
+
291
+ height = msg.height
292
+ width = msg.width
293
+ encoding = msg.encoding
294
+
295
+ if encoding == "rgb8":
296
+ img_data = np.frombuffer(msg.data, dtype=np.uint8).reshape(height, width, 3)
297
+ img_data = cv2.cvtColor(img_data, cv2.COLOR_RGB2BGR)
298
+ elif encoding == "bgr8":
299
+ img_data = np.frombuffer(msg.data, dtype=np.uint8).reshape(height, width, 3)
300
+ elif encoding == "mono8":
301
+ img_data = np.frombuffer(msg.data, dtype=np.uint8).reshape(height, width)
302
+ else:
303
+ print(f" Warning: Unsupported encoding {encoding} for {camera_name}")
304
+ return
305
+
306
+ camera_dir = self.dirs["images"] / camera_name
307
+ camera_dir.mkdir(exist_ok=True)
308
+
309
+ # Use ROS timestamp from message header
310
+ timestamp_ns = msg.header.stamp.sec * 1_000_000_000 + msg.header.stamp.nanosec
311
+ filename = f"{timestamp_ns:019d}.jpg"
312
+ output_path = camera_dir / filename
313
+
314
+ cv2.imwrite(str(output_path), img_data)
315
+ os.chmod(output_path, 0o644)
316
+
317
+ self.counters[f"rgb_{camera_name}"] += 1
318
+
319
+ if self.counters[f"rgb_{camera_name}"] % 100000 == 0:
320
+ print(f" Extracted {self.counters[f'rgb_{camera_name}']} RGB images from {camera_name}")
321
+
322
+ except Exception as e:
323
+ print(f" Error extracting raw RGB image from {camera_name}: {e}")
324
+
325
+ def _extract_depth_image(self, topic, message, schema=None, channel=None):
326
+ """Extract depth image"""
327
+ data = message.data[4:]
328
+
329
+ png_start = data.find(b"\x89PNG")
330
+ if png_start != -1:
331
+ png_data = data[png_start:]
332
+ camera_name = self._sanitize_topic_name(topic)
333
+ camera_dir = self.dirs["depth"] / camera_name
334
+ camera_dir.mkdir(exist_ok=True)
335
+
336
+ # Get ROS timestamp from message header (preferred) or fall back to log_time
337
+ timestamp_ns = message.log_time # fallback
338
+ if schema and channel:
339
+ try:
340
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
341
+ msg = decoder(message.data)
342
+ timestamp_ns = msg.header.stamp.sec * 1_000_000_000 + msg.header.stamp.nanosec
343
+ except Exception:
344
+ pass # Use fallback log_time
345
+
346
+ filename = f"{timestamp_ns:019d}.png"
347
+ output_path = camera_dir / filename
348
+
349
+ with open(output_path, "wb") as f:
350
+ f.write(png_data)
351
+
352
+ os.chmod(output_path, 0o644)
353
+ self.counters[f"depth_{camera_name}"] += 1
354
+
355
+ if self.counters[f"depth_{camera_name}"] % 100000 == 0:
356
+ print(f" Extracted {self.counters[f'depth_{camera_name}']} depth images from {camera_name}")
357
+
358
+ def _extract_depth_image_raw(self, topic, message, schema, channel):
359
+ """Extract raw depth image (sensor_msgs/msg/Image)"""
360
+ camera_name = self._sanitize_topic_name(topic)
361
+
362
+ try:
363
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
364
+ msg = decoder(message.data)
365
+
366
+ height = msg.height
367
+ width = msg.width
368
+ encoding = msg.encoding
369
+
370
+ if encoding == "16UC1":
371
+ depth_data = np.frombuffer(msg.data, dtype=np.uint16).reshape(height, width)
372
+ elif encoding == "32FC1":
373
+ depth_data = np.frombuffer(msg.data, dtype=np.float32).reshape(height, width)
374
+ depth_data = (depth_data * 1000).astype(np.uint16)
375
+ else:
376
+ print(f" Warning: Unsupported depth encoding {encoding} for {camera_name}")
377
+ return
378
+
379
+ camera_dir = self.dirs["depth"] / camera_name
380
+ camera_dir.mkdir(exist_ok=True)
381
+
382
+ # Use ROS timestamp from message header
383
+ timestamp_ns = msg.header.stamp.sec * 1_000_000_000 + msg.header.stamp.nanosec
384
+ filename = f"{timestamp_ns:019d}.png"
385
+ output_path = camera_dir / filename
386
+
387
+ cv2.imwrite(str(output_path), depth_data)
388
+ os.chmod(output_path, 0o644)
389
+
390
+ self.counters[f"depth_{camera_name}"] += 1
391
+
392
+ if self.counters[f"depth_{camera_name}"] % 100000 == 0:
393
+ print(f" Extracted {self.counters[f'depth_{camera_name}']} depth images from {camera_name}")
394
+
395
+ except Exception as e:
396
+ print(f" Error extracting raw depth image from {camera_name}: {e}")
397
+
398
+ def _extract_lidar(self, topic, message):
399
+ """Extract LiDAR point cloud as binary file"""
400
+ try:
401
+ data = message.data[4:]
402
+
403
+ timestamp_ns = message.log_time
404
+ filename = f"{timestamp_ns:019d}.bin"
405
+ output_path = self.dirs["lidar"] / filename
406
+
407
+ with open(output_path, "wb") as f:
408
+ f.write(data)
409
+
410
+ os.chmod(output_path, 0o644)
411
+ self.counters["lidar"] += 1
412
+
413
+ if self.counters["lidar"] % 100000 == 0:
414
+ print(f" Extracted {self.counters['lidar']} LiDAR scans")
415
+
416
+ except Exception as e:
417
+ print(f"Error extracting LiDAR: {e}")
418
+
419
+ def _extract_imu(self, topic, message, schema, channel):
420
+ """Extract IMU data"""
421
+ timestamp_ns = message.log_time
422
+ timestamp_s = timestamp_ns / 1e9
423
+
424
+ topic_name = self._sanitize_topic_name(topic)
425
+
426
+ if schema and schema.name:
427
+ if "Imu" in schema.name:
428
+ try:
429
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
430
+ msg = decoder(message.data)
431
+
432
+ self.imu_data[topic_name].append(
433
+ {
434
+ "timestamp_ns": timestamp_ns,
435
+ "timestamp_s": timestamp_s,
436
+ "type": "imu",
437
+ "orientation_x": msg.orientation.x,
438
+ "orientation_y": msg.orientation.y,
439
+ "orientation_z": msg.orientation.z,
440
+ "orientation_w": msg.orientation.w,
441
+ "angular_velocity_x": msg.angular_velocity.x,
442
+ "angular_velocity_y": msg.angular_velocity.y,
443
+ "angular_velocity_z": msg.angular_velocity.z,
444
+ "linear_acceleration_x": msg.linear_acceleration.x,
445
+ "linear_acceleration_y": msg.linear_acceleration.y,
446
+ "linear_acceleration_z": msg.linear_acceleration.z,
447
+ }
448
+ )
449
+ except Exception as e:
450
+ print(f" Warning: Could not deserialize IMU message for {topic_name}: {e}")
451
+
452
+ elif "Temperature" in schema.name:
453
+ try:
454
+ data = message.data[4:]
455
+ if len(data) >= 16:
456
+ temp = struct.unpack("<d", data[-16:-8])[0]
457
+ self.imu_data[topic_name].append(
458
+ {
459
+ "timestamp_ns": timestamp_ns,
460
+ "timestamp_s": timestamp_s,
461
+ "temperature": temp,
462
+ "type": "temperature",
463
+ }
464
+ )
465
+ except Exception:
466
+ pass
467
+
468
+ elif "Float64MultiArray" in schema.name:
469
+ try:
470
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
471
+ msg = decoder(message.data)
472
+
473
+ bias_values = list(msg.data) if hasattr(msg, "data") else []
474
+
475
+ data_dict = {
476
+ "timestamp_ns": timestamp_ns,
477
+ "timestamp_s": timestamp_s,
478
+ "type": "bias",
479
+ }
480
+
481
+ for i, val in enumerate(bias_values):
482
+ data_dict[f"bias_{i}"] = val
483
+
484
+ self.imu_data[topic_name].append(data_dict)
485
+
486
+ except Exception as e:
487
+ print(f" Warning: Could not deserialize bias data for {topic_name}: {e}")
488
+
489
+ elif "MagneticField" in schema.name:
490
+ try:
491
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
492
+ msg = decoder(message.data)
493
+
494
+ self.imu_data[topic_name].append(
495
+ {
496
+ "timestamp_ns": timestamp_ns,
497
+ "timestamp_s": timestamp_s,
498
+ "type": "magnetic",
499
+ "magnetic_field_x": msg.magnetic_field.x,
500
+ "magnetic_field_y": msg.magnetic_field.y,
501
+ "magnetic_field_z": msg.magnetic_field.z,
502
+ }
503
+ )
504
+ except Exception as e:
505
+ print(f" Warning: Could not deserialize magnetic field for {topic_name}: {e}")
506
+
507
+ elif "FluidPressure" in schema.name:
508
+ try:
509
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
510
+ msg = decoder(message.data)
511
+
512
+ self.imu_data[topic_name].append(
513
+ {
514
+ "timestamp_ns": timestamp_ns,
515
+ "timestamp_s": timestamp_s,
516
+ "type": "pressure",
517
+ "fluid_pressure": msg.fluid_pressure,
518
+ "variance": msg.variance if hasattr(msg, "variance") else None,
519
+ }
520
+ )
521
+ except Exception as e:
522
+ print(f" Warning: Could not deserialize pressure data for {topic_name}: {e}")
523
+
524
+ def _extract_camera_info(self, topic, message, schema, channel):
525
+ """Extract camera calibration info"""
526
+ camera_name = self._sanitize_topic_name(topic)
527
+
528
+ if camera_name in self.camera_intrinsics:
529
+ self.counters[f"camera_info_{camera_name}"] += 1
530
+ return
531
+
532
+ try:
533
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
534
+ msg = decoder(message.data)
535
+
536
+ intrinsics = {
537
+ "topic": topic,
538
+ "timestamp_ns": message.log_time,
539
+ "header": {
540
+ "stamp": {"sec": msg.header.stamp.sec, "nanosec": msg.header.stamp.nanosec},
541
+ "frame_id": msg.header.frame_id,
542
+ },
543
+ "height": msg.height,
544
+ "width": msg.width,
545
+ "distortion_model": msg.distortion_model,
546
+ "D": list(msg.d) if hasattr(msg, "d") else [],
547
+ "K": list(msg.k) if hasattr(msg, "k") else [],
548
+ "R": list(msg.r) if hasattr(msg, "r") else [],
549
+ "P": list(msg.p) if hasattr(msg, "p") else [],
550
+ "binning_x": msg.binning_x,
551
+ "binning_y": msg.binning_y,
552
+ "roi": {
553
+ "x_offset": msg.roi.x_offset,
554
+ "y_offset": msg.roi.y_offset,
555
+ "height": msg.roi.height,
556
+ "width": msg.roi.width,
557
+ "do_rectify": msg.roi.do_rectify,
558
+ },
559
+ }
560
+
561
+ self.camera_intrinsics[camera_name] = intrinsics
562
+
563
+ except Exception as e:
564
+ print(f" Warning: Could not deserialize camera_info for {camera_name}: {e}")
565
+
566
+ self.counters[f"camera_info_{camera_name}"] += 1
567
+
568
+ def _extract_transform(self, topic, message, schema, channel):
569
+ """Extract transform data"""
570
+ timestamp_ns = message.log_time
571
+ timestamp_s = timestamp_ns / 1e9
572
+
573
+ try:
574
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
575
+ msg = decoder(message.data)
576
+
577
+ if hasattr(msg, "transforms") and len(msg.transforms) > 0:
578
+ for transform in msg.transforms:
579
+ self.tf_data.append(
580
+ {
581
+ "timestamp_ns": timestamp_ns,
582
+ "timestamp_s": timestamp_s,
583
+ "topic": topic,
584
+ "parent_frame": transform.header.frame_id,
585
+ "child_frame": transform.child_frame_id,
586
+ "transform_sec": transform.header.stamp.sec,
587
+ "transform_nanosec": transform.header.stamp.nanosec,
588
+ "translation_x": transform.transform.translation.x,
589
+ "translation_y": transform.transform.translation.y,
590
+ "translation_z": transform.transform.translation.z,
591
+ "rotation_x": transform.transform.rotation.x,
592
+ "rotation_y": transform.transform.rotation.y,
593
+ "rotation_z": transform.transform.rotation.z,
594
+ "rotation_w": transform.transform.rotation.w,
595
+ }
596
+ )
597
+ self.counters[f'transform_{topic.replace("/", "")}'] += len(msg.transforms)
598
+ else:
599
+ self.tf_empty_messages += 1
600
+
601
+ except Exception as e:
602
+ print(f" Warning: Could not deserialize transform for {topic}: {e}")
603
+ self.counters["transform_errors"] += 1
604
+
605
+ def _extract_odometry(self, topic, message, schema, channel):
606
+ """Extract odometry data"""
607
+ timestamp_ns = message.log_time
608
+ timestamp_s = timestamp_ns / 1e9
609
+
610
+ try:
611
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
612
+ msg = decoder(message.data)
613
+
614
+ self.odom_data.append(
615
+ {
616
+ "timestamp_ns": timestamp_ns,
617
+ "timestamp_s": timestamp_s,
618
+ "topic": topic,
619
+ "frame_id": msg.header.frame_id,
620
+ "child_frame_id": msg.child_frame_id,
621
+ "position_x": msg.pose.pose.position.x,
622
+ "position_y": msg.pose.pose.position.y,
623
+ "position_z": msg.pose.pose.position.z,
624
+ "orientation_x": msg.pose.pose.orientation.x,
625
+ "orientation_y": msg.pose.pose.orientation.y,
626
+ "orientation_z": msg.pose.pose.orientation.z,
627
+ "orientation_w": msg.pose.pose.orientation.w,
628
+ "linear_velocity_x": msg.twist.twist.linear.x,
629
+ "linear_velocity_y": msg.twist.twist.linear.y,
630
+ "linear_velocity_z": msg.twist.twist.linear.z,
631
+ "angular_velocity_x": msg.twist.twist.angular.x,
632
+ "angular_velocity_y": msg.twist.twist.angular.y,
633
+ "angular_velocity_z": msg.twist.twist.angular.z,
634
+ }
635
+ )
636
+
637
+ self.counters["odometry"] += 1
638
+
639
+ except Exception as e:
640
+ print(f" Warning: Could not deserialize odometry for {topic}: {e}")
641
+
642
+ def _extract_joint_states(self, topic, message, schema, channel):
643
+ """Extract joint states data"""
644
+ timestamp_ns = message.log_time
645
+ timestamp_s = timestamp_ns / 1e9
646
+
647
+ try:
648
+ decoder = self.decoder_factory.decoder_for(channel.message_encoding, schema)
649
+ msg = decoder(message.data)
650
+
651
+ joint_state = {
652
+ "timestamp_ns": timestamp_ns,
653
+ "timestamp_s": timestamp_s,
654
+ "topic": topic,
655
+ "frame_id": msg.header.frame_id,
656
+ }
657
+
658
+ for i, name in enumerate(msg.name):
659
+ joint_state[f"{name}_position"] = msg.position[i] if i < len(msg.position) else None
660
+ joint_state[f"{name}_velocity"] = msg.velocity[i] if i < len(msg.velocity) else None
661
+ joint_state[f"{name}_effort"] = msg.effort[i] if i < len(msg.effort) else None
662
+
663
+ self.joint_states_data.append(joint_state)
664
+ self.counters["joint_states"] += 1
665
+
666
+ except Exception as e:
667
+ print(f" Warning: Could not deserialize joint states for {topic}: {e}")
668
+
669
+ def _save_imu_data(self):
670
+ """Save accumulated IMU data to CSV files"""
671
+ print("\nSaving IMU data to CSV...")
672
+ for topic_name, data_list in self.imu_data.items():
673
+ if data_list:
674
+ df = pd.DataFrame(data_list)
675
+ output_path = self.dirs["imu"] / f"{topic_name}.csv"
676
+ df.to_csv(output_path, index=False)
677
+ print(f" Saved {len(data_list)} IMU records to {output_path.name}")
678
+
679
+ def _save_transform_data(self):
680
+ """Save transform data to CSV"""
681
+ if self.tf_data:
682
+ print("\nSaving transform data to CSV...")
683
+ df = pd.DataFrame(self.tf_data)
684
+ output_path = self.dirs["transforms"] / "transforms.csv"
685
+ df.to_csv(output_path, index=False)
686
+ print(f" Saved {len(self.tf_data)} transform records to {output_path.name}")
687
+
688
+ def _save_odometry_data(self):
689
+ """Save odometry data to CSV"""
690
+ if self.odom_data:
691
+ print("\nSaving odometry data to CSV...")
692
+ df = pd.DataFrame(self.odom_data)
693
+ output_path = self.output_dir / "odometry.csv"
694
+ df.to_csv(output_path, index=False)
695
+ print(f" Saved {len(self.odom_data)} odometry records to {output_path.name}")
696
+
697
+ def _save_joint_states_data(self):
698
+ """Save joint states data to CSV"""
699
+ if self.joint_states_data:
700
+ print("\nSaving joint states data to CSV...")
701
+ df = pd.DataFrame(self.joint_states_data)
702
+ output_path = self.output_dir / "joint_states.csv"
703
+ df.to_csv(output_path, index=False)
704
+ print(f" Saved {len(self.joint_states_data)} joint state records to {output_path.name}")
705
+
706
+ def _save_camera_intrinsics(self):
707
+ """Save all camera intrinsics to a single JSON file"""
708
+ if self.camera_intrinsics:
709
+ print("\nSaving camera intrinsics...")
710
+ output_path = self.output_dir / "camera_intrinsics.json"
711
+ with open(output_path, "w") as f:
712
+ json.dump(self.camera_intrinsics, f, indent=2)
713
+ print(f" Saved intrinsics for {len(self.camera_intrinsics)} cameras to {output_path.name}")
714
+
715
+ def _save_other_data(self):
716
+ """Save other topic data to JSON files"""
717
+ if self.other_data:
718
+ print("\nSaving other topic data...")
719
+ for topic_name, data_list in self.other_data.items():
720
+ if data_list:
721
+ topic_dir = self.dirs["other"] / topic_name
722
+ topic_dir.mkdir(parents=True, exist_ok=True)
723
+
724
+ output_path = topic_dir / f"{topic_name}.json"
725
+ with open(output_path, "w") as f:
726
+ json.dump(data_list, f, indent=2, default=str)
727
+ print(f" Saved {len(data_list)} messages from {topic_name}")
728
+
729
+ def _create_videos(self, fps=30):
730
+ """Create videos from extracted RGB images"""
731
+ images_dir = self.dirs["images"]
732
+ videos_dir = self.dirs["videos"]
733
+
734
+ camera_dirs = [d for d in images_dir.iterdir() if d.is_dir()]
735
+
736
+ if not camera_dirs:
737
+ print("\nNo RGB images found to create videos.")
738
+ return
739
+
740
+ print(f"\n{'='*80}")
741
+ print("CREATING VIDEOS FROM RGB IMAGES")
742
+ print(f"{'='*80}")
743
+
744
+ for camera_dir in sorted(camera_dirs):
745
+ camera_name = camera_dir.name
746
+
747
+ image_files = sorted(camera_dir.glob("*.jpg"))
748
+
749
+ if not image_files:
750
+ continue
751
+
752
+ print(f"Processing {camera_name}...")
753
+ print(f" Found {len(image_files)} images")
754
+
755
+ try:
756
+ first_img = cv2.imread(str(image_files[0]))
757
+ if first_img is None:
758
+ print(" Error: Could not read first image")
759
+ continue
760
+
761
+ height, width = first_img.shape[:2]
762
+ print(f" Image size: {width}x{height}")
763
+
764
+ video_path = videos_dir / f"{camera_name}.mp4"
765
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
766
+ video_writer = cv2.VideoWriter(str(video_path), fourcc, fps, (width, height))
767
+
768
+ if not video_writer.isOpened():
769
+ print(" Error: Could not create video writer")
770
+ continue
771
+
772
+ frame_count = 0
773
+ for img_file in image_files:
774
+ img = cv2.imread(str(img_file))
775
+ if img is not None:
776
+ video_writer.write(img)
777
+ frame_count += 1
778
+ if frame_count % 1000 == 0:
779
+ print(f" Processed {frame_count}/{len(image_files)} frames...")
780
+
781
+ video_writer.release()
782
+
783
+ duration = frame_count / fps
784
+ print(f" Created video: {video_path.name}")
785
+ print(f" Frames: {frame_count}, Duration: {duration:.2f}s, FPS: {fps}")
786
+
787
+ except Exception as e:
788
+ print(f" Error creating video for {camera_name}: {e}")
789
+ continue
790
+
791
+ print(f"\n{'='*80}\n")
792
+
793
+ def _sanitize_topic_name(self, topic):
794
+ """Convert topic name to filesystem-safe name"""
795
+ return topic.replace("/", "_").strip("_")
796
+
797
+ def _print_summary(self):
798
+ """Print extraction summary"""
799
+ print(f"\n{'='*80}")
800
+ print("EXTRACTION SUMMARY")
801
+ print(f"{'='*80}")
802
+
803
+ print(f"\nOutput directory: {self.output_dir}")
804
+
805
+ print(f"\nTotal topics found: {len(self.topic_info)}")
806
+
807
+ if any("rgb_" in k for k in self.counters.keys()):
808
+ print("\nRGB Images:")
809
+ for k, v in sorted(self.counters.items()):
810
+ if "rgb_" in k:
811
+ print(f" {k.replace('rgb_', '')}: {v} images")
812
+
813
+ if any("depth_" in k and not k.startswith("camera_info_") for k in self.counters.keys()):
814
+ print("\nDepth Images:")
815
+ for k, v in sorted(self.counters.items()):
816
+ if "depth_" in k and not k.startswith("camera_info_"):
817
+ print(f" {k.replace('depth_', '')}: {v} images")
818
+
819
+ if "lidar" in self.counters:
820
+ print(f"\nLiDAR Point Clouds: {self.counters['lidar']} scans")
821
+
822
+ if self.imu_data:
823
+ print("\nIMU Data:")
824
+ for topic, data in self.imu_data.items():
825
+ print(f" {topic}: {len(data)} records")
826
+
827
+ if self.tf_data or self.tf_empty_messages > 0:
828
+ print("\nTransforms:")
829
+ print(f" Total transform records: {len(self.tf_data)}")
830
+ if self.tf_empty_messages > 0:
831
+ total_tf_messages = self.topic_info.get("/tf", {}).get("count", 0) + self.topic_info.get(
832
+ "/tf_static", {}
833
+ ).get("count", 0)
834
+ print(f" Total TFMessage messages: {total_tf_messages}")
835
+ print(f" Empty TFMessage messages: {self.tf_empty_messages} (common in ROS, not an error)")
836
+ print(f" Messages with transforms: {total_tf_messages - self.tf_empty_messages}")
837
+
838
+ if self.camera_intrinsics:
839
+ print(f"\nCamera Intrinsics: {len(self.camera_intrinsics)} cameras")
840
+ for name, info in sorted(self.camera_intrinsics.items()):
841
+ if info["K"] and len(info["K"]) == 9:
842
+ fx, fy = info["K"][0], info["K"][4]
843
+ cx, cy = info["K"][2], info["K"][5]
844
+ print(f" {name}: {info['width']}x{info['height']}, fx={fx:.1f}, fy={fy:.1f}")
845
+
846
+ if self.odom_data:
847
+ print(f"\nOdometry: {len(self.odom_data)} records")
848
+
849
+ if self.joint_states_data:
850
+ print(f"\nJoint States: {len(self.joint_states_data)} records")
851
+
852
+ other_topics = [k for k in self.counters.keys() if k.startswith("other_")]
853
+ if other_topics:
854
+ print("\nOther Topics:")
855
+ for k in sorted(other_topics):
856
+ if "_raw" in k:
857
+ topic_name = k.replace("other_", "").replace("_raw", "")
858
+ print(f" {topic_name} (raw): {self.counters[k]} messages")
859
+ else:
860
+ topic_name = k.replace("other_", "")
861
+ print(f" {topic_name}: {self.counters[k]} messages")
862
+
863
+ topic_info_path = self.output_dir / "topic_info.json"
864
+ with open(topic_info_path, "w") as f:
865
+ json.dump(self.topic_info, f, indent=2)
866
+ print(f"\nTopic information saved to: {topic_info_path.name}")