plexus-python 0.1.0__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.
- plexus/__init__.py +31 -0
- plexus/__main__.py +4 -0
- plexus/adapters/__init__.py +122 -0
- plexus/adapters/base.py +409 -0
- plexus/adapters/ble.py +257 -0
- plexus/adapters/can.py +439 -0
- plexus/adapters/can_detect.py +174 -0
- plexus/adapters/mavlink.py +642 -0
- plexus/adapters/mavlink_detect.py +192 -0
- plexus/adapters/modbus.py +622 -0
- plexus/adapters/mqtt.py +350 -0
- plexus/adapters/opcua.py +607 -0
- plexus/adapters/registry.py +206 -0
- plexus/adapters/serial_adapter.py +547 -0
- plexus/buffer.py +257 -0
- plexus/cameras/__init__.py +57 -0
- plexus/cameras/auto.py +239 -0
- plexus/cameras/base.py +189 -0
- plexus/cameras/picamera.py +171 -0
- plexus/cameras/usb.py +143 -0
- plexus/cli.py +783 -0
- plexus/client.py +465 -0
- plexus/config.py +169 -0
- plexus/connector.py +666 -0
- plexus/deps.py +246 -0
- plexus/detect.py +1238 -0
- plexus/importers/__init__.py +25 -0
- plexus/importers/rosbag.py +778 -0
- plexus/sensors/__init__.py +118 -0
- plexus/sensors/ads1115.py +164 -0
- plexus/sensors/adxl345.py +179 -0
- plexus/sensors/auto.py +290 -0
- plexus/sensors/base.py +412 -0
- plexus/sensors/bh1750.py +102 -0
- plexus/sensors/bme280.py +241 -0
- plexus/sensors/gps.py +317 -0
- plexus/sensors/ina219.py +149 -0
- plexus/sensors/magnetometer.py +239 -0
- plexus/sensors/mpu6050.py +162 -0
- plexus/sensors/sht3x.py +139 -0
- plexus/sensors/spi_scan.py +164 -0
- plexus/sensors/system.py +261 -0
- plexus/sensors/vl53l0x.py +109 -0
- plexus/streaming.py +743 -0
- plexus/tui.py +642 -0
- plexus_python-0.1.0.dist-info/METADATA +470 -0
- plexus_python-0.1.0.dist-info/RECORD +50 -0
- plexus_python-0.1.0.dist-info/WHEEL +4 -0
- plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
- plexus_python-0.1.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ROS Bag Importer - Import ROS1/ROS2 bags into Plexus
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- ROS1 bags (.bag)
|
|
6
|
+
- ROS2 bags (.db3)
|
|
7
|
+
- MCAP files (.mcap)
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from plexus.importers import RosbagImporter
|
|
11
|
+
|
|
12
|
+
importer = RosbagImporter("robot_data.bag")
|
|
13
|
+
|
|
14
|
+
# Detect schema
|
|
15
|
+
schema = importer.detect_schema()
|
|
16
|
+
print(f"Found {len(schema.topics)} topics")
|
|
17
|
+
|
|
18
|
+
# Iterate through metrics
|
|
19
|
+
for batch in importer.iter_metrics(batch_size=100):
|
|
20
|
+
for metric in batch:
|
|
21
|
+
print(f"{metric.name}: {metric.value}")
|
|
22
|
+
|
|
23
|
+
# Or upload directly to Plexus
|
|
24
|
+
from plexus import Plexus
|
|
25
|
+
px = Plexus()
|
|
26
|
+
importer.upload_to_plexus(px, run_id="robot-test-001")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Dict, Generator, List, Optional, Union
|
|
32
|
+
|
|
33
|
+
from plexus.adapters.base import Metric
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Image topic message types that contain video/image data
|
|
37
|
+
IMAGE_MESSAGE_TYPES = {
|
|
38
|
+
"sensor_msgs/Image",
|
|
39
|
+
"sensor_msgs/msg/Image",
|
|
40
|
+
"sensor_msgs/CompressedImage",
|
|
41
|
+
"sensor_msgs/msg/CompressedImage",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Common numeric field types in ROS messages
|
|
45
|
+
NUMERIC_TYPES = {
|
|
46
|
+
"float32", "float64", "double", "float",
|
|
47
|
+
"int8", "int16", "int32", "int64",
|
|
48
|
+
"uint8", "uint16", "uint32", "uint64",
|
|
49
|
+
"bool",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class TopicInfo:
|
|
55
|
+
"""Information about a ROS topic"""
|
|
56
|
+
name: str # Original ROS topic name (e.g., "/robot/imu")
|
|
57
|
+
message_type: str # ROS message type (e.g., "sensor_msgs/Imu")
|
|
58
|
+
message_count: int # Number of messages in bag
|
|
59
|
+
plexus_name: str = "" # Mapped Plexus metric name
|
|
60
|
+
is_image: bool = False # Whether this is an image/video topic
|
|
61
|
+
fields: List[str] = field(default_factory=list) # Extracted field names
|
|
62
|
+
frequency_hz: float = 0.0 # Estimated publish frequency
|
|
63
|
+
|
|
64
|
+
def __post_init__(self):
|
|
65
|
+
# Convert ROS topic to Plexus metric name if not set
|
|
66
|
+
if not self.plexus_name:
|
|
67
|
+
# /robot/arm/joint_states → robot.arm.joint_states
|
|
68
|
+
self.plexus_name = self.name.lstrip("/").replace("/", ".")
|
|
69
|
+
|
|
70
|
+
# Detect if this is an image topic
|
|
71
|
+
if not self.is_image:
|
|
72
|
+
self.is_image = self.message_type in IMAGE_MESSAGE_TYPES
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class RosbagSchema:
|
|
77
|
+
"""Schema extracted from a ROS bag"""
|
|
78
|
+
topics: List[TopicInfo]
|
|
79
|
+
duration_sec: float
|
|
80
|
+
start_time: float
|
|
81
|
+
end_time: float
|
|
82
|
+
message_count: int
|
|
83
|
+
bag_path: str
|
|
84
|
+
bag_type: str # "ros1", "ros2", "mcap"
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def telemetry_topics(self) -> List[TopicInfo]:
|
|
88
|
+
"""Topics that contain telemetry (non-image) data"""
|
|
89
|
+
return [t for t in self.topics if not t.is_image]
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def image_topics(self) -> List[TopicInfo]:
|
|
93
|
+
"""Topics that contain image/video data"""
|
|
94
|
+
return [t for t in self.topics if t.is_image]
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
97
|
+
"""Convert to dictionary for JSON serialization"""
|
|
98
|
+
return {
|
|
99
|
+
"bag_path": self.bag_path,
|
|
100
|
+
"bag_type": self.bag_type,
|
|
101
|
+
"duration_sec": self.duration_sec,
|
|
102
|
+
"start_time": self.start_time,
|
|
103
|
+
"end_time": self.end_time,
|
|
104
|
+
"message_count": self.message_count,
|
|
105
|
+
"topics": [
|
|
106
|
+
{
|
|
107
|
+
"name": t.name,
|
|
108
|
+
"plexus_name": t.plexus_name,
|
|
109
|
+
"message_type": t.message_type,
|
|
110
|
+
"message_count": t.message_count,
|
|
111
|
+
"is_image": t.is_image,
|
|
112
|
+
"fields": t.fields,
|
|
113
|
+
"frequency_hz": t.frequency_hz,
|
|
114
|
+
}
|
|
115
|
+
for t in self.topics
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class RosbagImporter:
|
|
121
|
+
"""
|
|
122
|
+
Import ROS bags into Plexus.
|
|
123
|
+
|
|
124
|
+
Handles ROS1 bags, ROS2 bags (SQLite), and MCAP files.
|
|
125
|
+
Automatically detects bag type and extracts schema.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
bag_path: Path to the ROS bag file
|
|
129
|
+
topics: Optional list of topics to import (default: all)
|
|
130
|
+
skip_images: Whether to skip image topics (default: False)
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
importer = RosbagImporter("data.bag")
|
|
134
|
+
schema = importer.detect_schema()
|
|
135
|
+
|
|
136
|
+
for batch in importer.iter_metrics():
|
|
137
|
+
for m in batch:
|
|
138
|
+
px.send(m.name, m.value, timestamp=m.timestamp)
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
bag_path: Union[str, Path],
|
|
144
|
+
topics: Optional[List[str]] = None,
|
|
145
|
+
skip_images: bool = False,
|
|
146
|
+
):
|
|
147
|
+
self.bag_path = Path(bag_path)
|
|
148
|
+
self.topics_filter = topics
|
|
149
|
+
self.skip_images = skip_images
|
|
150
|
+
|
|
151
|
+
if not self.bag_path.exists():
|
|
152
|
+
raise FileNotFoundError(f"Bag file not found: {bag_path}")
|
|
153
|
+
|
|
154
|
+
self._bag_type = self._detect_bag_type()
|
|
155
|
+
self._schema: Optional[RosbagSchema] = None
|
|
156
|
+
|
|
157
|
+
# Lazy imports
|
|
158
|
+
self._reader = None
|
|
159
|
+
self._connections = None
|
|
160
|
+
|
|
161
|
+
def _detect_bag_type(self) -> str:
|
|
162
|
+
"""Detect the type of ROS bag"""
|
|
163
|
+
suffix = self.bag_path.suffix.lower()
|
|
164
|
+
|
|
165
|
+
if suffix == ".bag":
|
|
166
|
+
return "ros1"
|
|
167
|
+
elif suffix == ".mcap":
|
|
168
|
+
return "mcap"
|
|
169
|
+
elif suffix == ".db3":
|
|
170
|
+
return "ros2"
|
|
171
|
+
elif self.bag_path.is_dir():
|
|
172
|
+
# ROS2 bags can be directories
|
|
173
|
+
if (self.bag_path / "metadata.yaml").exists():
|
|
174
|
+
return "ros2"
|
|
175
|
+
|
|
176
|
+
# Try to detect by reading header
|
|
177
|
+
try:
|
|
178
|
+
with open(self.bag_path, "rb") as f:
|
|
179
|
+
header = f.read(16)
|
|
180
|
+
if header.startswith(b"#ROSBAG"):
|
|
181
|
+
return "ros1"
|
|
182
|
+
elif header.startswith(b"\x89MCAP"):
|
|
183
|
+
return "mcap"
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
raise ValueError(f"Unknown bag format: {self.bag_path}")
|
|
188
|
+
|
|
189
|
+
def _ensure_rosbags(self):
|
|
190
|
+
"""Ensure rosbags library is available"""
|
|
191
|
+
try:
|
|
192
|
+
import rosbags
|
|
193
|
+
return rosbags
|
|
194
|
+
except ImportError:
|
|
195
|
+
raise ImportError(
|
|
196
|
+
"ROS bag support requires the 'rosbags' library.\n"
|
|
197
|
+
"Install it with: pip install plexus-python[ros]"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def detect_schema(self) -> RosbagSchema:
|
|
201
|
+
"""
|
|
202
|
+
Detect schema from the ROS bag.
|
|
203
|
+
|
|
204
|
+
Reads bag metadata to extract topic information without
|
|
205
|
+
reading all messages. Fast operation even for large bags.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
RosbagSchema with topic information
|
|
209
|
+
"""
|
|
210
|
+
if self._schema is not None:
|
|
211
|
+
return self._schema
|
|
212
|
+
|
|
213
|
+
rosbags = self._ensure_rosbags()
|
|
214
|
+
|
|
215
|
+
if self._bag_type == "ros1":
|
|
216
|
+
schema = self._detect_schema_ros1(rosbags)
|
|
217
|
+
elif self._bag_type == "ros2":
|
|
218
|
+
schema = self._detect_schema_ros2(rosbags)
|
|
219
|
+
elif self._bag_type == "mcap":
|
|
220
|
+
schema = self._detect_schema_mcap(rosbags)
|
|
221
|
+
else:
|
|
222
|
+
raise ValueError(f"Unsupported bag type: {self._bag_type}")
|
|
223
|
+
|
|
224
|
+
self._schema = schema
|
|
225
|
+
return schema
|
|
226
|
+
|
|
227
|
+
def _detect_schema_ros1(self, rosbags) -> RosbagSchema:
|
|
228
|
+
"""Detect schema from ROS1 bag"""
|
|
229
|
+
from rosbags.rosbag1 import Reader
|
|
230
|
+
|
|
231
|
+
topics = []
|
|
232
|
+
start_time = float("inf")
|
|
233
|
+
end_time = float("-inf")
|
|
234
|
+
total_messages = 0
|
|
235
|
+
|
|
236
|
+
with Reader(self.bag_path) as reader:
|
|
237
|
+
for connection in reader.connections:
|
|
238
|
+
# Filter topics if specified
|
|
239
|
+
if self.topics_filter and connection.topic not in self.topics_filter:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
topic_info = TopicInfo(
|
|
243
|
+
name=connection.topic,
|
|
244
|
+
message_type=connection.msgtype,
|
|
245
|
+
message_count=connection.msgcount,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Skip images if requested
|
|
249
|
+
if self.skip_images and topic_info.is_image:
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
topics.append(topic_info)
|
|
253
|
+
total_messages += connection.msgcount
|
|
254
|
+
|
|
255
|
+
# Get time range from first/last messages
|
|
256
|
+
if reader.duration:
|
|
257
|
+
start_time = reader.start_time / 1e9 # nanoseconds to seconds
|
|
258
|
+
end_time = reader.end_time / 1e9
|
|
259
|
+
else:
|
|
260
|
+
start_time = 0
|
|
261
|
+
end_time = 0
|
|
262
|
+
|
|
263
|
+
# Calculate frequencies
|
|
264
|
+
duration = end_time - start_time if end_time > start_time else 1.0
|
|
265
|
+
for topic in topics:
|
|
266
|
+
topic.frequency_hz = topic.message_count / duration
|
|
267
|
+
|
|
268
|
+
return RosbagSchema(
|
|
269
|
+
topics=topics,
|
|
270
|
+
duration_sec=duration,
|
|
271
|
+
start_time=start_time,
|
|
272
|
+
end_time=end_time,
|
|
273
|
+
message_count=total_messages,
|
|
274
|
+
bag_path=str(self.bag_path),
|
|
275
|
+
bag_type="ros1",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def _detect_schema_ros2(self, rosbags) -> RosbagSchema:
|
|
279
|
+
"""Detect schema from ROS2 bag"""
|
|
280
|
+
from rosbags.rosbag2 import Reader
|
|
281
|
+
|
|
282
|
+
topics = []
|
|
283
|
+
start_time = float("inf")
|
|
284
|
+
end_time = float("-inf")
|
|
285
|
+
total_messages = 0
|
|
286
|
+
|
|
287
|
+
with Reader(self.bag_path) as reader:
|
|
288
|
+
for connection in reader.connections:
|
|
289
|
+
# Filter topics if specified
|
|
290
|
+
if self.topics_filter and connection.topic not in self.topics_filter:
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
topic_info = TopicInfo(
|
|
294
|
+
name=connection.topic,
|
|
295
|
+
message_type=connection.msgtype,
|
|
296
|
+
message_count=connection.msgcount,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Skip images if requested
|
|
300
|
+
if self.skip_images and topic_info.is_image:
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
topics.append(topic_info)
|
|
304
|
+
total_messages += connection.msgcount
|
|
305
|
+
|
|
306
|
+
# Get time range
|
|
307
|
+
if reader.duration:
|
|
308
|
+
start_time = reader.start_time / 1e9
|
|
309
|
+
end_time = reader.end_time / 1e9
|
|
310
|
+
else:
|
|
311
|
+
start_time = 0
|
|
312
|
+
end_time = 0
|
|
313
|
+
|
|
314
|
+
duration = end_time - start_time if end_time > start_time else 1.0
|
|
315
|
+
for topic in topics:
|
|
316
|
+
topic.frequency_hz = topic.message_count / duration
|
|
317
|
+
|
|
318
|
+
return RosbagSchema(
|
|
319
|
+
topics=topics,
|
|
320
|
+
duration_sec=duration,
|
|
321
|
+
start_time=start_time,
|
|
322
|
+
end_time=end_time,
|
|
323
|
+
message_count=total_messages,
|
|
324
|
+
bag_path=str(self.bag_path),
|
|
325
|
+
bag_type="ros2",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def _detect_schema_mcap(self, rosbags) -> RosbagSchema:
|
|
329
|
+
"""Detect schema from MCAP file"""
|
|
330
|
+
# MCAP uses the same rosbags.rosbag2 reader
|
|
331
|
+
return self._detect_schema_ros2(rosbags)
|
|
332
|
+
|
|
333
|
+
def iter_metrics(
|
|
334
|
+
self,
|
|
335
|
+
batch_size: int = 100,
|
|
336
|
+
flatten_messages: bool = True,
|
|
337
|
+
) -> Generator[List[Metric], None, None]:
|
|
338
|
+
"""
|
|
339
|
+
Iterate through messages as Plexus metrics.
|
|
340
|
+
|
|
341
|
+
Yields batches of Metric objects converted from ROS messages.
|
|
342
|
+
Large bags can be processed incrementally without loading
|
|
343
|
+
everything into memory.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
batch_size: Number of metrics per batch
|
|
347
|
+
flatten_messages: If True, flatten nested message fields
|
|
348
|
+
|
|
349
|
+
Yields:
|
|
350
|
+
List of Metric objects
|
|
351
|
+
|
|
352
|
+
Example:
|
|
353
|
+
for batch in importer.iter_metrics(batch_size=100):
|
|
354
|
+
for metric in batch:
|
|
355
|
+
px.send(metric.name, metric.value, timestamp=metric.timestamp)
|
|
356
|
+
"""
|
|
357
|
+
rosbags = self._ensure_rosbags()
|
|
358
|
+
|
|
359
|
+
if self._bag_type == "ros1":
|
|
360
|
+
yield from self._iter_metrics_ros1(rosbags, batch_size, flatten_messages)
|
|
361
|
+
elif self._bag_type in ("ros2", "mcap"):
|
|
362
|
+
yield from self._iter_metrics_ros2(rosbags, batch_size, flatten_messages)
|
|
363
|
+
else:
|
|
364
|
+
raise ValueError(f"Unsupported bag type: {self._bag_type}")
|
|
365
|
+
|
|
366
|
+
def _iter_metrics_ros1(
|
|
367
|
+
self,
|
|
368
|
+
rosbags,
|
|
369
|
+
batch_size: int,
|
|
370
|
+
flatten: bool,
|
|
371
|
+
) -> Generator[List[Metric], None, None]:
|
|
372
|
+
"""Iterate metrics from ROS1 bag"""
|
|
373
|
+
from rosbags.rosbag1 import Reader
|
|
374
|
+
from rosbags.serde import deserialize_cdr, ros1_to_cdr
|
|
375
|
+
|
|
376
|
+
batch = []
|
|
377
|
+
|
|
378
|
+
with Reader(self.bag_path) as reader:
|
|
379
|
+
for connection, timestamp, rawdata in reader.messages():
|
|
380
|
+
# Filter topics if specified
|
|
381
|
+
if self.topics_filter and connection.topic not in self.topics_filter:
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
# Skip images if requested
|
|
385
|
+
if self.skip_images and connection.msgtype in IMAGE_MESSAGE_TYPES:
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
# Convert timestamp (nanoseconds to seconds)
|
|
389
|
+
ts = timestamp / 1e9
|
|
390
|
+
|
|
391
|
+
# Convert ROS topic to Plexus name
|
|
392
|
+
base_name = connection.topic.lstrip("/").replace("/", ".")
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
# Deserialize message
|
|
396
|
+
cdr_data = ros1_to_cdr(rawdata, connection.msgtype)
|
|
397
|
+
msg = deserialize_cdr(cdr_data, connection.msgtype)
|
|
398
|
+
|
|
399
|
+
# Convert message to metrics
|
|
400
|
+
metrics = self._message_to_metrics(msg, base_name, ts, flatten)
|
|
401
|
+
batch.extend(metrics)
|
|
402
|
+
|
|
403
|
+
if len(batch) >= batch_size:
|
|
404
|
+
yield batch
|
|
405
|
+
batch = []
|
|
406
|
+
|
|
407
|
+
except Exception:
|
|
408
|
+
# Skip messages that fail to deserialize
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
# Yield remaining
|
|
412
|
+
if batch:
|
|
413
|
+
yield batch
|
|
414
|
+
|
|
415
|
+
def _iter_metrics_ros2(
|
|
416
|
+
self,
|
|
417
|
+
rosbags,
|
|
418
|
+
batch_size: int,
|
|
419
|
+
flatten: bool,
|
|
420
|
+
) -> Generator[List[Metric], None, None]:
|
|
421
|
+
"""Iterate metrics from ROS2/MCAP bag"""
|
|
422
|
+
from rosbags.rosbag2 import Reader
|
|
423
|
+
from rosbags.serde import deserialize_cdr
|
|
424
|
+
|
|
425
|
+
batch = []
|
|
426
|
+
|
|
427
|
+
with Reader(self.bag_path) as reader:
|
|
428
|
+
for connection, timestamp, rawdata in reader.messages():
|
|
429
|
+
# Filter topics if specified
|
|
430
|
+
if self.topics_filter and connection.topic not in self.topics_filter:
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
# Skip images if requested
|
|
434
|
+
if self.skip_images and connection.msgtype in IMAGE_MESSAGE_TYPES:
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
# Convert timestamp (nanoseconds to seconds)
|
|
438
|
+
ts = timestamp / 1e9
|
|
439
|
+
|
|
440
|
+
# Convert ROS topic to Plexus name
|
|
441
|
+
base_name = connection.topic.lstrip("/").replace("/", ".")
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
# Deserialize message
|
|
445
|
+
msg = deserialize_cdr(rawdata, connection.msgtype)
|
|
446
|
+
|
|
447
|
+
# Convert message to metrics
|
|
448
|
+
metrics = self._message_to_metrics(msg, base_name, ts, flatten)
|
|
449
|
+
batch.extend(metrics)
|
|
450
|
+
|
|
451
|
+
if len(batch) >= batch_size:
|
|
452
|
+
yield batch
|
|
453
|
+
batch = []
|
|
454
|
+
|
|
455
|
+
except Exception:
|
|
456
|
+
# Skip messages that fail to deserialize
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
# Yield remaining
|
|
460
|
+
if batch:
|
|
461
|
+
yield batch
|
|
462
|
+
|
|
463
|
+
def _message_to_metrics(
|
|
464
|
+
self,
|
|
465
|
+
msg: Any,
|
|
466
|
+
base_name: str,
|
|
467
|
+
timestamp: float,
|
|
468
|
+
flatten: bool,
|
|
469
|
+
) -> List[Metric]:
|
|
470
|
+
"""
|
|
471
|
+
Convert a ROS message to Plexus metrics.
|
|
472
|
+
|
|
473
|
+
Flattens nested messages into dot-notation metric names.
|
|
474
|
+
|
|
475
|
+
Examples:
|
|
476
|
+
Imu message → [
|
|
477
|
+
Metric("imu.linear_acceleration.x", 9.81),
|
|
478
|
+
Metric("imu.linear_acceleration.y", 0.0),
|
|
479
|
+
Metric("imu.angular_velocity.x", 0.01),
|
|
480
|
+
...
|
|
481
|
+
]
|
|
482
|
+
|
|
483
|
+
JointState message → [
|
|
484
|
+
Metric("joint_states.position", [0.1, 0.2, 0.3]),
|
|
485
|
+
Metric("joint_states.velocity", [0.0, 0.0, 0.0]),
|
|
486
|
+
...
|
|
487
|
+
]
|
|
488
|
+
"""
|
|
489
|
+
metrics = []
|
|
490
|
+
|
|
491
|
+
if flatten:
|
|
492
|
+
self._flatten_message(msg, base_name, timestamp, metrics)
|
|
493
|
+
else:
|
|
494
|
+
# Send entire message as dict
|
|
495
|
+
msg_dict = self._message_to_dict(msg)
|
|
496
|
+
metrics.append(Metric(base_name, msg_dict, timestamp=timestamp))
|
|
497
|
+
|
|
498
|
+
return metrics
|
|
499
|
+
|
|
500
|
+
def _flatten_message(
|
|
501
|
+
self,
|
|
502
|
+
obj: Any,
|
|
503
|
+
prefix: str,
|
|
504
|
+
timestamp: float,
|
|
505
|
+
metrics: List[Metric],
|
|
506
|
+
) -> None:
|
|
507
|
+
"""Recursively flatten a message into metrics"""
|
|
508
|
+
# Handle primitive types
|
|
509
|
+
if isinstance(obj, (int, float, bool)):
|
|
510
|
+
metrics.append(Metric(prefix, obj, timestamp=timestamp))
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
if isinstance(obj, str):
|
|
514
|
+
metrics.append(Metric(prefix, obj, timestamp=timestamp))
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
# Handle numpy arrays (common in ROS)
|
|
518
|
+
if hasattr(obj, "tolist"):
|
|
519
|
+
# numpy array - convert to list and send as array metric
|
|
520
|
+
metrics.append(Metric(prefix, obj.tolist(), timestamp=timestamp))
|
|
521
|
+
return
|
|
522
|
+
|
|
523
|
+
# Handle lists/tuples
|
|
524
|
+
if isinstance(obj, (list, tuple)):
|
|
525
|
+
# For short arrays of primitives, send as array
|
|
526
|
+
if len(obj) <= 100 and all(isinstance(x, (int, float, bool)) for x in obj):
|
|
527
|
+
metrics.append(Metric(prefix, list(obj), timestamp=timestamp))
|
|
528
|
+
else:
|
|
529
|
+
# For longer arrays, create indexed metrics
|
|
530
|
+
for i, item in enumerate(obj):
|
|
531
|
+
self._flatten_message(item, f"{prefix}[{i}]", timestamp, metrics)
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
# Handle ROS message objects (have __slots__ or __dataclass_fields__)
|
|
535
|
+
if hasattr(obj, "__slots__"):
|
|
536
|
+
for slot in obj.__slots__:
|
|
537
|
+
if slot.startswith("_"):
|
|
538
|
+
continue
|
|
539
|
+
value = getattr(obj, slot, None)
|
|
540
|
+
if value is not None:
|
|
541
|
+
self._flatten_message(value, f"{prefix}.{slot}", timestamp, metrics)
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
if hasattr(obj, "__dataclass_fields__"):
|
|
545
|
+
for field_name in obj.__dataclass_fields__:
|
|
546
|
+
if field_name.startswith("_"):
|
|
547
|
+
continue
|
|
548
|
+
value = getattr(obj, field_name, None)
|
|
549
|
+
if value is not None:
|
|
550
|
+
self._flatten_message(value, f"{prefix}.{field_name}", timestamp, metrics)
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
# Handle dicts
|
|
554
|
+
if isinstance(obj, dict):
|
|
555
|
+
for key, value in obj.items():
|
|
556
|
+
self._flatten_message(value, f"{prefix}.{key}", timestamp, metrics)
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
# Unknown type - try to send as string
|
|
560
|
+
try:
|
|
561
|
+
metrics.append(Metric(prefix, str(obj), timestamp=timestamp))
|
|
562
|
+
except Exception:
|
|
563
|
+
pass
|
|
564
|
+
|
|
565
|
+
def _message_to_dict(self, obj: Any) -> Any:
|
|
566
|
+
"""Convert ROS message to dictionary"""
|
|
567
|
+
if isinstance(obj, (int, float, bool, str)):
|
|
568
|
+
return obj
|
|
569
|
+
|
|
570
|
+
if hasattr(obj, "tolist"):
|
|
571
|
+
return obj.tolist()
|
|
572
|
+
|
|
573
|
+
if isinstance(obj, (list, tuple)):
|
|
574
|
+
return [self._message_to_dict(x) for x in obj]
|
|
575
|
+
|
|
576
|
+
if hasattr(obj, "__slots__"):
|
|
577
|
+
return {
|
|
578
|
+
slot: self._message_to_dict(getattr(obj, slot, None))
|
|
579
|
+
for slot in obj.__slots__
|
|
580
|
+
if not slot.startswith("_")
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if hasattr(obj, "__dataclass_fields__"):
|
|
584
|
+
return {
|
|
585
|
+
name: self._message_to_dict(getattr(obj, name, None))
|
|
586
|
+
for name in obj.__dataclass_fields__
|
|
587
|
+
if not name.startswith("_")
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if isinstance(obj, dict):
|
|
591
|
+
return {k: self._message_to_dict(v) for k, v in obj.items()}
|
|
592
|
+
|
|
593
|
+
return str(obj)
|
|
594
|
+
|
|
595
|
+
def upload_to_plexus(
|
|
596
|
+
self,
|
|
597
|
+
client, # Plexus client
|
|
598
|
+
run_id: Optional[str] = None,
|
|
599
|
+
batch_size: int = 100,
|
|
600
|
+
progress_callback: Optional[callable] = None,
|
|
601
|
+
) -> Dict[str, Any]:
|
|
602
|
+
"""
|
|
603
|
+
Upload bag contents directly to Plexus.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
client: Plexus client instance
|
|
607
|
+
run_id: Optional run ID for grouping data
|
|
608
|
+
batch_size: Number of points per API call
|
|
609
|
+
progress_callback: Optional callback(uploaded, total) for progress
|
|
610
|
+
|
|
611
|
+
Returns:
|
|
612
|
+
Dict with upload statistics
|
|
613
|
+
|
|
614
|
+
Example:
|
|
615
|
+
from plexus import Plexus
|
|
616
|
+
px = Plexus()
|
|
617
|
+
importer = RosbagImporter("data.bag")
|
|
618
|
+
stats = importer.upload_to_plexus(px, run_id="test-001")
|
|
619
|
+
print(f"Uploaded {stats['metrics_uploaded']} metrics")
|
|
620
|
+
"""
|
|
621
|
+
schema = self.detect_schema()
|
|
622
|
+
total_messages = schema.message_count
|
|
623
|
+
uploaded = 0
|
|
624
|
+
errors = 0
|
|
625
|
+
|
|
626
|
+
# Use run context if provided
|
|
627
|
+
from contextlib import nullcontext
|
|
628
|
+
|
|
629
|
+
context = client.run(run_id) if run_id else nullcontext()
|
|
630
|
+
|
|
631
|
+
with context:
|
|
632
|
+
for batch in self.iter_metrics(batch_size=batch_size):
|
|
633
|
+
try:
|
|
634
|
+
for metric in batch:
|
|
635
|
+
client.send(
|
|
636
|
+
metric.name,
|
|
637
|
+
metric.value,
|
|
638
|
+
timestamp=metric.timestamp,
|
|
639
|
+
tags=metric.tags,
|
|
640
|
+
)
|
|
641
|
+
uploaded += len(batch)
|
|
642
|
+
|
|
643
|
+
if progress_callback:
|
|
644
|
+
progress_callback(uploaded, total_messages)
|
|
645
|
+
|
|
646
|
+
except Exception:
|
|
647
|
+
errors += 1
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
"bag_path": str(self.bag_path),
|
|
651
|
+
"bag_type": self._bag_type,
|
|
652
|
+
"run_id": run_id,
|
|
653
|
+
"metrics_uploaded": uploaded,
|
|
654
|
+
"errors": errors,
|
|
655
|
+
"duration_sec": schema.duration_sec,
|
|
656
|
+
"topics_imported": len(schema.telemetry_topics),
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
def extract_images(
|
|
660
|
+
self,
|
|
661
|
+
output_dir: Union[str, Path],
|
|
662
|
+
topics: Optional[List[str]] = None,
|
|
663
|
+
frame_rate: Optional[float] = None,
|
|
664
|
+
format: str = "jpg",
|
|
665
|
+
) -> Dict[str, Any]:
|
|
666
|
+
"""
|
|
667
|
+
Extract image frames from the bag.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
output_dir: Directory to save extracted frames
|
|
671
|
+
topics: Image topics to extract (default: all)
|
|
672
|
+
frame_rate: Target frame rate (None = extract all frames)
|
|
673
|
+
format: Output format (jpg, png)
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Dict with extraction statistics
|
|
677
|
+
|
|
678
|
+
Example:
|
|
679
|
+
stats = importer.extract_images(
|
|
680
|
+
"./frames",
|
|
681
|
+
frame_rate=10.0, # 10 fps
|
|
682
|
+
)
|
|
683
|
+
"""
|
|
684
|
+
try:
|
|
685
|
+
import cv2
|
|
686
|
+
import numpy as np
|
|
687
|
+
except ImportError:
|
|
688
|
+
raise ImportError(
|
|
689
|
+
"Image extraction requires OpenCV.\n"
|
|
690
|
+
"Install it with: pip install plexus-python[video]"
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
output_path = Path(output_dir)
|
|
694
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
695
|
+
|
|
696
|
+
schema = self.detect_schema()
|
|
697
|
+
image_topics = [t for t in schema.image_topics if not topics or t.name in topics]
|
|
698
|
+
|
|
699
|
+
if not image_topics:
|
|
700
|
+
return {"error": "No image topics found", "frames_extracted": 0}
|
|
701
|
+
|
|
702
|
+
self._ensure_rosbags()
|
|
703
|
+
from rosbags.rosbag2 import Reader
|
|
704
|
+
from rosbags.rosbag1 import Reader as Reader1
|
|
705
|
+
from rosbags.serde import deserialize_cdr
|
|
706
|
+
|
|
707
|
+
# Track last frame time per topic for frame rate limiting
|
|
708
|
+
last_frame_time: Dict[str, float] = {}
|
|
709
|
+
frame_interval = 1.0 / frame_rate if frame_rate else 0
|
|
710
|
+
|
|
711
|
+
frames_extracted = 0
|
|
712
|
+
frame_counts: Dict[str, int] = {}
|
|
713
|
+
|
|
714
|
+
ReaderClass = Reader1 if self._bag_type == "ros1" else Reader
|
|
715
|
+
|
|
716
|
+
with ReaderClass(self.bag_path) as reader:
|
|
717
|
+
for connection, timestamp, rawdata in reader.messages():
|
|
718
|
+
if connection.topic not in [t.name for t in image_topics]:
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
ts = timestamp / 1e9
|
|
722
|
+
|
|
723
|
+
# Frame rate limiting
|
|
724
|
+
if frame_rate:
|
|
725
|
+
last_ts = last_frame_time.get(connection.topic, 0)
|
|
726
|
+
if ts - last_ts < frame_interval:
|
|
727
|
+
continue
|
|
728
|
+
|
|
729
|
+
try:
|
|
730
|
+
# Deserialize based on bag type
|
|
731
|
+
if self._bag_type == "ros1":
|
|
732
|
+
from rosbags.serde import ros1_to_cdr
|
|
733
|
+
cdr_data = ros1_to_cdr(rawdata, connection.msgtype)
|
|
734
|
+
msg = deserialize_cdr(cdr_data, connection.msgtype)
|
|
735
|
+
else:
|
|
736
|
+
msg = deserialize_cdr(rawdata, connection.msgtype)
|
|
737
|
+
|
|
738
|
+
# Extract image data
|
|
739
|
+
if "Compressed" in connection.msgtype:
|
|
740
|
+
# CompressedImage
|
|
741
|
+
img_data = np.frombuffer(msg.data, np.uint8)
|
|
742
|
+
frame = cv2.imdecode(img_data, cv2.IMREAD_COLOR)
|
|
743
|
+
else:
|
|
744
|
+
# Raw Image
|
|
745
|
+
if msg.encoding in ("rgb8", "bgr8"):
|
|
746
|
+
frame = np.frombuffer(msg.data, np.uint8)
|
|
747
|
+
frame = frame.reshape((msg.height, msg.width, 3))
|
|
748
|
+
if msg.encoding == "rgb8":
|
|
749
|
+
frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
|
|
750
|
+
elif msg.encoding == "mono8":
|
|
751
|
+
frame = np.frombuffer(msg.data, np.uint8)
|
|
752
|
+
frame = frame.reshape((msg.height, msg.width))
|
|
753
|
+
else:
|
|
754
|
+
continue # Skip unsupported encodings
|
|
755
|
+
|
|
756
|
+
if frame is None:
|
|
757
|
+
continue
|
|
758
|
+
|
|
759
|
+
# Save frame
|
|
760
|
+
topic_name = connection.topic.lstrip("/").replace("/", "_")
|
|
761
|
+
frame_num = frame_counts.get(connection.topic, 0)
|
|
762
|
+
frame_counts[connection.topic] = frame_num + 1
|
|
763
|
+
|
|
764
|
+
filename = f"{topic_name}_{frame_num:06d}.{format}"
|
|
765
|
+
cv2.imwrite(str(output_path / filename), frame)
|
|
766
|
+
|
|
767
|
+
frames_extracted += 1
|
|
768
|
+
last_frame_time[connection.topic] = ts
|
|
769
|
+
|
|
770
|
+
except Exception:
|
|
771
|
+
continue
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
"output_dir": str(output_path),
|
|
775
|
+
"frames_extracted": frames_extracted,
|
|
776
|
+
"frame_counts": frame_counts,
|
|
777
|
+
"topics": [t.name for t in image_topics],
|
|
778
|
+
}
|