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.
- vuer_cli/add.py +69 -84
- vuer_cli/envs_publish.py +335 -309
- vuer_cli/envs_pull.py +177 -170
- vuer_cli/login.py +459 -0
- vuer_cli/main.py +52 -88
- vuer_cli/mcap_extractor.py +866 -0
- vuer_cli/remove.py +87 -95
- vuer_cli/scripts/demcap.py +171 -0
- vuer_cli/scripts/mcap_playback.py +661 -0
- vuer_cli/scripts/minimap.py +365 -0
- vuer_cli/scripts/ptc_utils.py +434 -0
- vuer_cli/scripts/viz_ptc_cams.py +613 -0
- vuer_cli/scripts/viz_ptc_proxie.py +483 -0
- vuer_cli/sync.py +314 -308
- vuer_cli/upgrade.py +121 -136
- vuer_cli/utils.py +11 -38
- {vuer_cli-0.0.3.dist-info → vuer_cli-0.0.5.dist-info}/METADATA +59 -6
- vuer_cli-0.0.5.dist-info/RECORD +22 -0
- vuer_cli-0.0.3.dist-info/RECORD +0 -14
- {vuer_cli-0.0.3.dist-info → vuer_cli-0.0.5.dist-info}/WHEEL +0 -0
- {vuer_cli-0.0.3.dist-info → vuer_cli-0.0.5.dist-info}/entry_points.txt +0 -0
- {vuer_cli-0.0.3.dist-info → vuer_cli-0.0.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -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}")
|