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.
Files changed (50) hide show
  1. plexus/__init__.py +31 -0
  2. plexus/__main__.py +4 -0
  3. plexus/adapters/__init__.py +122 -0
  4. plexus/adapters/base.py +409 -0
  5. plexus/adapters/ble.py +257 -0
  6. plexus/adapters/can.py +439 -0
  7. plexus/adapters/can_detect.py +174 -0
  8. plexus/adapters/mavlink.py +642 -0
  9. plexus/adapters/mavlink_detect.py +192 -0
  10. plexus/adapters/modbus.py +622 -0
  11. plexus/adapters/mqtt.py +350 -0
  12. plexus/adapters/opcua.py +607 -0
  13. plexus/adapters/registry.py +206 -0
  14. plexus/adapters/serial_adapter.py +547 -0
  15. plexus/buffer.py +257 -0
  16. plexus/cameras/__init__.py +57 -0
  17. plexus/cameras/auto.py +239 -0
  18. plexus/cameras/base.py +189 -0
  19. plexus/cameras/picamera.py +171 -0
  20. plexus/cameras/usb.py +143 -0
  21. plexus/cli.py +783 -0
  22. plexus/client.py +465 -0
  23. plexus/config.py +169 -0
  24. plexus/connector.py +666 -0
  25. plexus/deps.py +246 -0
  26. plexus/detect.py +1238 -0
  27. plexus/importers/__init__.py +25 -0
  28. plexus/importers/rosbag.py +778 -0
  29. plexus/sensors/__init__.py +118 -0
  30. plexus/sensors/ads1115.py +164 -0
  31. plexus/sensors/adxl345.py +179 -0
  32. plexus/sensors/auto.py +290 -0
  33. plexus/sensors/base.py +412 -0
  34. plexus/sensors/bh1750.py +102 -0
  35. plexus/sensors/bme280.py +241 -0
  36. plexus/sensors/gps.py +317 -0
  37. plexus/sensors/ina219.py +149 -0
  38. plexus/sensors/magnetometer.py +239 -0
  39. plexus/sensors/mpu6050.py +162 -0
  40. plexus/sensors/sht3x.py +139 -0
  41. plexus/sensors/spi_scan.py +164 -0
  42. plexus/sensors/system.py +261 -0
  43. plexus/sensors/vl53l0x.py +109 -0
  44. plexus/streaming.py +743 -0
  45. plexus/tui.py +642 -0
  46. plexus_python-0.1.0.dist-info/METADATA +470 -0
  47. plexus_python-0.1.0.dist-info/RECORD +50 -0
  48. plexus_python-0.1.0.dist-info/WHEEL +4 -0
  49. plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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
+ }