feat: capture head and waist RGB-D data

This commit is contained in:
2026-08-10 16:40:00 +08:00
parent 4ba0f63758
commit d02b99d2aa
8 changed files with 388 additions and 16 deletions

View File

@@ -53,8 +53,11 @@ TOML 解析检查。若还要核对外置 OmniSocketGo 版本,可设置任务
## 数采保存 ## 数采保存
Nvidia 独立服务录制明确白名单内的机器人实测状态、双臂/双手命令与反馈、行走、 Nvidia 独立服务录制明确白名单内的机器人实测状态、双臂/双手命令与反馈、行走、
IMU、电源状态和 xTELE 原始应用帧。它不接管或停止厂家 `/record_bag_node`,默认也不录 IMU、电源状态、xTELE 应用帧以及头/腰 Orbbec 的 RGB-D。RGB 使用标准 JPEG 压缩流,
相机和点云。正常结束后先校验 MCAP、生成 SHA-256 `manifest.json` 和 `READY`,再由 16 位深度使用无损 compressedDepth PNG,并同时保存相机内参与静态 TF;腰部相机是
必采,头部相机未启动时不阻塞,之后启动即可被同一录制自动发现。它不接管或停止厂家
`/record_bag_node`,也不录点云。正常结束后先校验 MCAP、生成 SHA-256
`manifest.json` 和 `READY`,再由
本机服务拉取到: 本机服务拉取到:
```text ```text

View File

@@ -1058,9 +1058,30 @@ Python 进程专用的最小 `ArmStatus` 绑定,会遮蔽厂家完整 `ros2_br
rosbag 加载 `RobotState/ArmCtrl` 时出现 undefined symbol。`run.sh` 仍需项目 overlay; rosbag 加载 `RobotState/ArmCtrl` 时出现 undefined symbol。`run.sh` 仍需项目 overlay;
`run_data_recorder.sh` 只用厂家完整消息 overlay。 `run_data_recorder.sh` 只用厂家完整消息 overlay。
还需 Python 3 的 PyYAML(Ubuntu 包 `python3-yaml`)解析 rosbag metadata。 还需 Python 3 的 PyYAML(Ubuntu 包 `python3-yaml`)解析 rosbag metadata。
迁移时在 `config.toml [data_collection]` 核对 `base_directory`、20 GiB 余量、30 分钟上限、 迁移时在 `config.toml [data_collection]` 核对 `base_directory`、100 GiB 余量、30 分钟
明确 topic 白名单和 required topics。不要改成 `ros2 bag record -a`,相机/点云需另行估算 上限、明确 topic 白名单和 required topics。不要改成 `ros2 bag record -a`,点云需另行
带宽和磁盘后再加入。 估算带宽和磁盘后再加入。当前白名单已经包含头/腰 Orbbec 的 JPEG RGB、无损 PNG 深度、
CameraInfo、Metadata 和 `/tf_static`;腰部 RGB-D/Metadata/静态 TF 为 required,两路
图像收尾时还要求各自平均频率至少 `20 Hz`。头部未启动时是 optional,以后启动
`orbbec_head.service` 即可由正在录制的 rosbag 自动发现。
新机器人先确认相机服务和发布者。相机必须在按 L3 开始 episode 之前启动:
```bash
systemctl is-active orbbec_waist.service orbbec_head.service
sudo systemctl enable --now orbbec_waist.service
# 同时需要头部 RGB-D 时:
sudo systemctl enable --now orbbec_head.service
ros2 topic info /ob_camera_waist/color/image_raw/compressed
ros2 topic info /ob_camera_waist/depth/image_raw/compressedDepth
```
两条腰部话题都必须显示 `Publisher count: 1`。原始 1280x720 RGB+深度约
`8.3 GB/min/相机`,所以默认不重复录 raw,而是录标准 image_transport 压缩流;现场
单相机约 `1.3–1.5 GB/min`,实际随画面变化。头部和腰部都运行时应按约两倍预留空间与
同步时间。`minimum_free_gib=100` 是停止下限,不是整条 episode 的容量预算;双相机
长录制前至少预留“100 GiB + 预计数据量”,并在短录包中先确认实际带宽。
### 11.2 PS 本机 ### 11.2 PS 本机

View File

@@ -160,9 +160,20 @@ OmniSocket 进程;在此之前桥会保持最后一帧目标。未 START 或
新会话和服务重启后都先锁定为“必须松开”,畸形或陈旧按键帧不能被当作有效松开。 新会话和服务重启后都先锁定为“必须松开”,畸形或陈旧按键帧不能被当作有效松开。
项目自有 `tg3-data-recorder.service` 在 Nvidia 上运行独立 `ros2 bag record`,不停止、 项目自有 `tg3-data-recorder.service` 在 Nvidia 上运行独立 `ros2 bag record`,不停止、
重配或接管 Ubuntu 厂家 `/record_bag_node`。默认仅录 `config.toml` 中的明确白名单, 重配或接管 Ubuntu 厂家 `/record_bag_node`。仅录 `config.toml` 中的明确白名单,包含
包含 `/robot_state`、双臂/BrainCo 双手命令与反馈、HBWALK、IMU、电源状态以及完整 `/robot_state`、双臂/BrainCo 双手命令与反馈、HBWALK、IMU、电源状态、完整 xTELE
xTELE 应用帧;相机和点云默认不录,也禁止改成 `-a`。 应用帧,以及头/腰 Orbbec 的 RGB、深度、内参和元数据;仍禁止改成 `-a` 或录点云。
为避免 1280x720 原始 RGB+深度每台相机约 `8.3 GB/min` 的持续写盘负载,默认录制
`color/image_raw/compressed`(JPEG)和 `depth/image_raw/compressedDepth`(16UC1
无损 PNG)。现场单台腰部相机实测合计约 `22–25 MB/s`(约 `1.3–1.5 GB/min`,随
场景变化)。腰部图像、内参、Metadata 和 `/tf_static` 是 required topics;两路图像还
必须在整条 bag 内分别达到至少 `20 Hz` 平均频率。相机服务未发布或图像严重掉流时,
L3 启动或最终校验会失败,不会生成缺图像却标记 READY 的 episode。头部话题在白名单
中但不是 required:当前
`orbbec_head.service` 未启动不影响腰部数采;将来在 episode 开始前启动后,rosbag 会
自动发现并采集,无需再改配置。静态 TF 会被保存用于相机外参,但当前收尾只验证该
Topic 非空,不解析每条 TF 的 frame 语义。
机器人暂存目录: 机器人暂存目录:
@@ -174,11 +185,21 @@ xTELE 应用帧;相机和点云默认不录,也禁止改成 `-a`。
``` ```
MCAP 使用 `zstd_fast`、64 MiB cache、每 300 秒分片;每条 episode 最长 30 分钟, MCAP 使用 `zstd_fast`、64 MiB cache、每 300 秒分片;每条 episode 最长 30 分钟,
启动/运行最低保留 20 GiB。正常停止以 SIGINT 让 rosbag 写完 `metadata.yaml`,随后执行 加入双 RGB-D 后启动/运行最低保留 100 GiB。正常停止以 SIGINT 让 rosbag 写完
`metadata.yaml`,随后执行
`ros2 bag info`,并确认所有 required topics 都有非零消息数;然后计算 SHA-256、写 `ros2 bag info`,并确认所有 required topics 都有非零消息数;然后计算 SHA-256、写
`manifest.json` 和 `READY`,最后才原子进入 `ready/`。Nvidia 需已安装 `python3-yaml` `manifest.json` 和 `READY`,最后才原子进入 `ready/`。Nvidia 需已安装 `python3-yaml`
(现场已验证 PyYAML 6.0.1)。 (现场已验证 PyYAML 6.0.1)。
相机由厂家系统服务管理,本项目只订阅、不自动启停。录制前检查或按需启动:
```bash
systemctl is-active orbbec_waist.service orbbec_head.service
sudo systemctl enable --now orbbec_waist.service
# 需要头部图像时再执行;启动后下一条 episode 会自动包含头部 RGB-D:
sudo systemctl enable --now orbbec_head.service
```
录制服务只加载厂家完整 `/opt/robot_tele_server/install` 消息 overlay,不加载本项目 录制服务只加载厂家完整 `/opt/robot_tele_server/install` 消息 overlay,不加载本项目
`ros2_py/install`;项目 overlay 仅供桥的 Python `ArmStatus` 绑定使用。两者混在同一个 `ros2_py/install`;项目 overlay 仅供桥的 Python `ArmStatus` 绑定使用。两者混在同一个
rosbag 环境会让部分 `ros2_bridge_msgs` C++ 类型支持出现动态库符号冲突。 rosbag 环境会让部分 `ros2_bridge_msgs` C++ 类型支持出现动态库符号冲突。

View File

@@ -75,12 +75,21 @@ control_topic = "/tg3/data_collection/control"
status_topic = "/tg3/data_collection/status" status_topic = "/tg3/data_collection/status"
iarm_frame_topic = "/tg3/data_collection/iarm_frame" iarm_frame_topic = "/tg3/data_collection/iarm_frame"
base_directory = "/home/nvidia/tg3_data_collection" base_directory = "/home/nvidia/tg3_data_collection"
minimum_free_gib = 20.0 minimum_free_gib = 100.0
max_duration_seconds = 1800.0 max_duration_seconds = 1800.0
# A camera endpoint merely existing is insufficient: completed episodes must
# also sustain this conservative average rate across the rosbag data span.
# The driver targets 30 Hz; 20 Hz tolerates startup/discovery jitter while
# rejecting a stream that only delivered a few frames or stopped mid-episode.
minimum_topic_rates_hz."/ob_camera_waist/color/image_raw/compressed" = 20.0
minimum_topic_rates_hz."/ob_camera_waist/depth/image_raw/compressedDepth" = 20.0
# Explicit control-data whitelist. Camera images and point clouds are omitted # Explicit data whitelist; never replace this with `-a` on the live robot.
# by default to bound disk and network load; never replace this with `-a` on the # RGB is recorded through image_transport JPEG and 16UC1 depth through its
# live robot. /robot_state is the authoritative measured robot state. # lossless compressedDepth PNG transport. Waist RGB-D is required because that
# camera is enabled on this robot. Head RGB-D stays optional: rosbag discovers
# and records it automatically whenever orbbec_head.service is running.
# /robot_state is the authoritative measured robot state.
topics = [ topics = [
"/robot_state", "/robot_state",
"/encoder_identical_joint", "/encoder_identical_joint",
@@ -100,6 +109,19 @@ topics = [
"/power/board/key_status", "/power/board/key_status",
"/power/board/status", "/power/board/status",
"/power/battery/status", "/power/battery/status",
"/ob_camera_head/color/image_raw/compressed",
"/ob_camera_head/color/camera_info",
"/ob_camera_head/color/metadata",
"/ob_camera_head/depth/image_raw/compressedDepth",
"/ob_camera_head/depth/camera_info",
"/ob_camera_head/depth/metadata",
"/ob_camera_waist/color/image_raw/compressed",
"/ob_camera_waist/color/camera_info",
"/ob_camera_waist/color/metadata",
"/ob_camera_waist/depth/image_raw/compressedDepth",
"/ob_camera_waist/depth/camera_info",
"/ob_camera_waist/depth/metadata",
"/tf_static",
"/tg3/data_collection/control", "/tg3/data_collection/control",
"/tg3/data_collection/status", "/tg3/data_collection/status",
"/tg3/data_collection/iarm_frame", "/tg3/data_collection/iarm_frame",
@@ -115,6 +137,13 @@ required_topics = [
"/left_hand/motor_status", "/left_hand/motor_status",
"/right_hand/motor_status", "/right_hand/motor_status",
"/hric/robot/rl_state", "/hric/robot/rl_state",
"/ob_camera_waist/color/image_raw/compressed",
"/ob_camera_waist/color/camera_info",
"/ob_camera_waist/color/metadata",
"/ob_camera_waist/depth/image_raw/compressedDepth",
"/ob_camera_waist/depth/camera_info",
"/ob_camera_waist/depth/metadata",
"/tf_static",
"/tg3/data_collection/control", "/tg3/data_collection/control",
"/tg3/data_collection/status", "/tg3/data_collection/status",
"/tg3/data_collection/iarm_frame", "/tg3/data_collection/iarm_frame",

View File

@@ -20,9 +20,10 @@ import threading
import time import time
import uuid import uuid
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from types import MappingProxyType
from typing import Any, Literal, Protocol from typing import Any, Literal, Protocol
import yaml import yaml
@@ -201,6 +202,7 @@ class RecorderConfig:
base_directory: Path | str = Path("/home/nvidia/tg3_data_collection") base_directory: Path | str = Path("/home/nvidia/tg3_data_collection")
topics: Sequence[str] = () topics: Sequence[str] = ()
required_topics: Sequence[str] = () required_topics: Sequence[str] = ()
minimum_topic_rates_hz: Mapping[str, float] = field(default_factory=dict)
minimum_free_bytes: int = 5 * 1024**3 minimum_free_bytes: int = 5 * 1024**3
max_duration_seconds: float = 30 * 60.0 max_duration_seconds: float = 30 * 60.0
poll_interval_seconds: float = 0.1 poll_interval_seconds: float = 0.1
@@ -214,6 +216,7 @@ class RecorderConfig:
base = Path(self.base_directory).expanduser() base = Path(self.base_directory).expanduser()
topics = tuple(self.topics) topics = tuple(self.topics)
required_topics = tuple(self.required_topics) required_topics = tuple(self.required_topics)
minimum_topic_rates: dict[str, float] = {}
if not topics: if not topics:
raise ValueError("at least one recording topic is required") raise ValueError("at least one recording topic is required")
if any( if any(
@@ -238,6 +241,23 @@ class RecorderConfig:
raise ValueError("required topics must be absolute ROS topic names") raise ValueError("required topics must be absolute ROS topic names")
if not set(required_topics).issubset(topics): if not set(required_topics).issubset(topics):
raise ValueError("required topics must be a subset of recording topics") raise ValueError("required topics must be a subset of recording topics")
if not isinstance(self.minimum_topic_rates_hz, Mapping):
raise ValueError("minimum topic rates must be a mapping")
for topic, rate in self.minimum_topic_rates_hz.items():
if topic not in required_topics:
raise ValueError(
"minimum-rate topics must be a subset of required topics"
)
if (
isinstance(rate, bool)
or not isinstance(rate, (int, float))
or not math.isfinite(rate)
or rate <= 0.0
):
raise ValueError(
f"minimum topic rate for {topic!r} must be positive and finite"
)
minimum_topic_rates[topic] = float(rate)
if ( if (
isinstance(self.minimum_free_bytes, bool) isinstance(self.minimum_free_bytes, bool)
or not isinstance(self.minimum_free_bytes, int) or not isinstance(self.minimum_free_bytes, int)
@@ -260,6 +280,11 @@ class RecorderConfig:
object.__setattr__(self, "base_directory", base) object.__setattr__(self, "base_directory", base)
object.__setattr__(self, "topics", topics) object.__setattr__(self, "topics", topics)
object.__setattr__(self, "required_topics", required_topics) object.__setattr__(self, "required_topics", required_topics)
object.__setattr__(
self,
"minimum_topic_rates_hz",
MappingProxyType(minimum_topic_rates),
)
class RecorderProcess(Protocol): class RecorderProcess(Protocol):
@@ -296,7 +321,11 @@ class _Episode:
node_name: str = "" node_name: str = ""
command: tuple[str, ...] = () command: tuple[str, ...] = ()
bag_info_validation: dict[str, Any] | None = None bag_info_validation: dict[str, Any] | None = None
topic_message_counts: dict[str, int] | None = None
required_topic_message_counts: dict[str, int] | None = None required_topic_message_counts: dict[str, int] | None = None
minimum_topic_message_counts: dict[str, int] | None = None
observed_topic_rates_hz: dict[str, float] | None = None
metadata_duration_nanoseconds: int | None = None
process: RecorderProcess | None = None process: RecorderProcess | None = None
stdout_stream: Any = None stdout_stream: Any = None
stderr_stream: Any = None stderr_stream: Any = None
@@ -728,8 +757,19 @@ class DataRecorderManager:
"start_reason": episode.request.reason, "start_reason": episode.request.reason,
"stop_reason": stop_reason, "stop_reason": stop_reason,
"topics": list(self.config.topics), "topics": list(self.config.topics),
"topic_message_counts": episode.topic_message_counts,
"required_topics": list(self.config.required_topics), "required_topics": list(self.config.required_topics),
"required_topic_message_counts": required_topic_counts, "required_topic_message_counts": required_topic_counts,
"minimum_topic_rates_hz": dict(
self.config.minimum_topic_rates_hz
),
"minimum_topic_message_counts": (
episode.minimum_topic_message_counts
),
"observed_topic_rates_hz": episode.observed_topic_rates_hz,
"metadata_duration_nanoseconds": (
episode.metadata_duration_nanoseconds
),
"storage_id": "mcap", "storage_id": "mcap",
"custom_data": { "custom_data": {
"capture_id": episode.request.episode_id, "capture_id": episode.request.episode_id,
@@ -778,6 +818,9 @@ class DataRecorderManager:
raise RecordingError( raise RecordingError(
"bag metadata.yaml lacks topics_with_message_count" "bag metadata.yaml lacks topics_with_message_count"
) )
episode.topic_message_counts = {
topic: 0 for topic in self.config.topics
}
episode.required_topic_message_counts = {} episode.required_topic_message_counts = {}
return {} return {}
@@ -812,6 +855,9 @@ class DataRecorderManager:
topic: all_counts.get(topic, 0) topic: all_counts.get(topic, 0)
for topic in self.config.required_topics for topic in self.config.required_topics
} }
episode.topic_message_counts = {
topic: all_counts.get(topic, 0) for topic in self.config.topics
}
episode.required_topic_message_counts = required_counts episode.required_topic_message_counts = required_counts
missing = [ missing = [
topic for topic in self.config.required_topics if topic not in all_counts topic for topic in self.config.required_topics if topic not in all_counts
@@ -828,8 +874,61 @@ class DataRecorderManager:
problems.append("zero-message required topics: " + ", ".join(empty)) problems.append("zero-message required topics: " + ", ".join(empty))
if problems: if problems:
raise RecordingError("; ".join(problems)) raise RecordingError("; ".join(problems))
self._validate_minimum_topic_rates(episode, information, all_counts)
return required_counts return required_counts
def _validate_minimum_topic_rates(
self,
episode: _Episode,
information: Mapping[str, Any],
all_counts: Mapping[str, int],
) -> None:
configured = self.config.minimum_topic_rates_hz
if not configured:
episode.minimum_topic_message_counts = {}
episode.observed_topic_rates_hz = {}
return
duration = information.get("duration")
if not isinstance(duration, Mapping):
raise RecordingError(
"bag metadata.yaml lacks duration for minimum-rate validation"
)
nanoseconds = duration.get("nanoseconds")
if (
isinstance(nanoseconds, bool)
or not isinstance(nanoseconds, int)
or nanoseconds <= 0
):
raise RecordingError(
"bag metadata.yaml duration.nanoseconds must be a positive integer"
)
episode.metadata_duration_nanoseconds = nanoseconds
minimum_counts: dict[str, int] = {}
observed_rates: dict[str, float] = {}
failures: list[str] = []
duration_seconds = nanoseconds / 1_000_000_000.0
for topic, minimum_rate in configured.items():
count = all_counts[topic]
minimum_count = math.floor(duration_seconds * minimum_rate)
observed_rate = count / duration_seconds
minimum_counts[topic] = minimum_count
observed_rates[topic] = observed_rate
if count < minimum_count:
failures.append(
f"{topic}: {count} messages < {minimum_count} required "
f"over {duration_seconds:.6f}s "
f"({observed_rate:.3f} Hz < {minimum_rate:.3f} Hz)"
)
episode.minimum_topic_message_counts = minimum_counts
episode.observed_topic_rates_hz = observed_rates
if failures:
raise RecordingError(
"required topics below minimum average rate: "
+ "; ".join(failures)
)
def _validate_bag_info(self, episode: _Episode) -> dict[str, Any]: def _validate_bag_info(self, episode: _Episode) -> dict[str, Any]:
"""Run rosbag2's reader-level validation before publishing READY.""" """Run rosbag2's reader-level validation before publishing READY."""
@@ -952,10 +1051,21 @@ class DataRecorderManager:
"start_reason": episode.request.reason, "start_reason": episode.request.reason,
"stop_reason": stop_reason, "stop_reason": stop_reason,
"topics": list(self.config.topics), "topics": list(self.config.topics),
"topic_message_counts": episode.topic_message_counts,
"required_topics": list(self.config.required_topics), "required_topics": list(self.config.required_topics),
"required_topic_message_counts": ( "required_topic_message_counts": (
episode.required_topic_message_counts episode.required_topic_message_counts
), ),
"minimum_topic_rates_hz": dict(
self.config.minimum_topic_rates_hz
),
"minimum_topic_message_counts": (
episode.minimum_topic_message_counts
),
"observed_topic_rates_hz": episode.observed_topic_rates_hz,
"metadata_duration_nanoseconds": (
episode.metadata_duration_nanoseconds
),
"storage_id": "mcap", "storage_id": "mcap",
"custom_data": { "custom_data": {
"capture_id": episode.request.episode_id, "capture_id": episode.request.episode_id,

View File

@@ -89,6 +89,19 @@ def _topic_list(value: Any, name: str, *, allow_empty: bool) -> tuple[str, ...]:
return result return result
def _minimum_topic_rates(value: Any) -> dict[str, float]:
name = "minimum_topic_rates_hz"
if not isinstance(value, dict):
raise ValueError(f"data_collection.{name} must be a TOML table")
result: dict[str, float] = {}
for topic, rate in value.items():
normalized_topic = _topic(topic, f"{name} key")
result[normalized_topic] = _positive_number(
rate, f"{name}.{normalized_topic}"
)
return result
@dataclass(frozen=True) @dataclass(frozen=True)
class SupervisorSettings: class SupervisorSettings:
enabled: bool enabled: bool
@@ -134,6 +147,17 @@ def load_settings(path: Path | str) -> SupervisorSettings:
"data_collection.required_topics must also appear in topics: " "data_collection.required_topics must also appear in topics: "
+ ", ".join(unknown_required) + ", ".join(unknown_required)
) )
minimum_topic_rates = _minimum_topic_rates(
section.get("minimum_topic_rates_hz", {})
)
unknown_rate_topics = sorted(
set(minimum_topic_rates) - set(required_topics)
)
if unknown_rate_topics:
raise ValueError(
"data_collection.minimum_topic_rates_hz keys must also appear in "
"required_topics: " + ", ".join(unknown_rate_topics)
)
heartbeat_timeout = _positive_number( heartbeat_timeout = _positive_number(
section.get("heartbeat_timeout_seconds", 3.0), section.get("heartbeat_timeout_seconds", 3.0),
@@ -162,6 +186,7 @@ def load_settings(path: Path | str) -> SupervisorSettings:
base_directory=Path(base_directory).expanduser(), base_directory=Path(base_directory).expanduser(),
topics=topics, topics=topics,
required_topics=required_topics, required_topics=required_topics,
minimum_topic_rates_hz=minimum_topic_rates,
minimum_free_bytes=int(minimum_free_gib * 1024**3), minimum_free_bytes=int(minimum_free_gib * 1024**3),
max_duration_seconds=max_duration, max_duration_seconds=max_duration,
poll_interval_seconds=0.1, poll_interval_seconds=0.1,

View File

@@ -165,6 +165,7 @@ class FakeProcess:
unexpected_returncode: int | None = None, unexpected_returncode: int | None = None,
ignore_sigint: bool = False, ignore_sigint: bool = False,
topic_counts: Mapping[str, int] | None = None, topic_counts: Mapping[str, int] | None = None,
duration_nanoseconds: int = 1_000_000_000,
) -> None: ) -> None:
self.command = list(command) self.command = list(command)
self.signals: list[int] = [] self.signals: list[int] = []
@@ -178,6 +179,7 @@ class FakeProcess:
metadata = { metadata = {
"rosbag2_bagfile_information": { "rosbag2_bagfile_information": {
"storage_identifier": "mcap", "storage_identifier": "mcap",
"duration": {"nanoseconds": duration_nanoseconds},
"topics_with_message_count": [ "topics_with_message_count": [
{ {
"topic_metadata": {"name": topic}, "topic_metadata": {"name": topic},
@@ -286,7 +288,9 @@ class DataRecorderManagerTest(unittest.TestCase):
process_options: dict[str, Any] | None = None, process_options: dict[str, Any] | None = None,
command_runner: Callable[..., Any] | None = None, command_runner: Callable[..., Any] | None = None,
validate_bag_info: bool = True, validate_bag_info: bool = True,
topics: Sequence[str] = ("/joint_states", "/tf"),
required_topics: Sequence[str] = ("/joint_states", "/tf"), required_topics: Sequence[str] = ("/joint_states", "/tf"),
minimum_topic_rates_hz: Mapping[str, float] | None = None,
) -> tuple[DataRecorderManager, Any]: ) -> tuple[DataRecorderManager, Any]:
if factory is None: if factory is None:
factory = FakeProcessFactory(**(process_options or {})) factory = FakeProcessFactory(**(process_options or {}))
@@ -298,8 +302,9 @@ class DataRecorderManagerTest(unittest.TestCase):
manager = DataRecorderManager( manager = DataRecorderManager(
RecorderConfig( RecorderConfig(
base_directory=self.base, base_directory=self.base,
topics=("/joint_states", "/tf"), topics=topics,
required_topics=required_topics, required_topics=required_topics,
minimum_topic_rates_hz=minimum_topic_rates_hz or {},
minimum_free_bytes=minimum_free, minimum_free_bytes=minimum_free,
max_duration_seconds=max_duration, max_duration_seconds=max_duration,
poll_interval_seconds=0.005, poll_interval_seconds=0.005,
@@ -346,6 +351,10 @@ class DataRecorderManagerTest(unittest.TestCase):
manifest = json.loads((ready / "manifest.json").read_text()) manifest = json.loads((ready / "manifest.json").read_text())
self.assertEqual(manifest["session_id"], "a" * 32) self.assertEqual(manifest["session_id"], "a" * 32)
self.assertEqual(manifest["topics"], ["/joint_states", "/tf"]) self.assertEqual(manifest["topics"], ["/joint_states", "/tf"])
self.assertEqual(
manifest["topic_message_counts"],
{"/joint_states": 25, "/tf": 50},
)
self.assertEqual( self.assertEqual(
manifest["required_topic_message_counts"], manifest["required_topic_message_counts"],
{"/joint_states": 25, "/tf": 50}, {"/joint_states": 25, "/tf": 50},
@@ -553,6 +562,108 @@ class DataRecorderManagerTest(unittest.TestCase):
self.assertEqual(bag_info.calls, []) self.assertEqual(bag_info.calls, [])
self.assertFalse((failed / "READY").exists()) self.assertFalse((failed / "READY").exists())
def test_minimum_average_topic_rates_are_recorded_in_ready_manifest(
self,
) -> None:
configured_rates = {"/joint_states": 20.0, "/tf": 40.0}
manager, _ = self.make_manager(
minimum_topic_rates_hz=configured_rates
)
# RecorderConfig owns an immutable copy, not the caller's dictionary.
configured_rates["/tf"] = 1.0
with self.assertRaises(TypeError):
manager.config.minimum_topic_rates_hz["/tf"] = 2.0 # type: ignore[index]
self.assertTrue(manager.request_start("b0" * 16))
self.wait_for(lambda: manager.status()["recording"])
self.assertTrue(manager.request_stop())
self.assertTrue(manager.wait_until_idle(2.0))
status = manager.status()
self.assertEqual(status["last_result"], "ready")
ready = Path(status["last_episode_directory"])
manifest = json.loads((ready / "manifest.json").read_text())
self.assertEqual(
manifest["minimum_topic_rates_hz"],
{"/joint_states": 20.0, "/tf": 40.0},
)
self.assertEqual(
manifest["minimum_topic_message_counts"],
{"/joint_states": 20, "/tf": 40},
)
self.assertEqual(
manifest["observed_topic_rates_hz"],
{"/joint_states": 25.0, "/tf": 50.0},
)
self.assertEqual(
manifest["metadata_duration_nanoseconds"], 1_000_000_000
)
def test_optional_topic_counts_show_absent_and_active_publishers(self) -> None:
head_topic = "/ob_camera_head/color/image_raw/compressed"
topics = ("/joint_states", "/tf", head_topic)
for head_count in (None, 17):
with self.subTest(head_count=head_count):
counts = {"/joint_states": 25, "/tf": 50}
if head_count is not None:
counts[head_topic] = head_count
manager, _ = self.make_manager(
topics=topics,
process_options={"topic_counts": counts},
)
self.assertTrue(manager.request_start("c0" * 16))
self.wait_for(lambda: manager.status()["recording"])
self.assertTrue(manager.request_stop())
self.assertTrue(manager.wait_until_idle(2.0))
status = manager.status()
self.assertEqual(status["last_result"], "ready")
manifest = json.loads(
(
Path(status["last_episode_directory"])
/ "manifest.json"
).read_text()
)
self.assertEqual(
manifest["topic_message_counts"][head_topic],
0 if head_count is None else head_count,
)
def test_topic_that_stops_mid_episode_fails_average_rate(self) -> None:
bag_info = FakeBagInfoRunner()
manager, _ = self.make_manager(
command_runner=bag_info,
minimum_topic_rates_hz={"/joint_states": 20.0, "/tf": 20.0},
process_options={
"duration_nanoseconds": 10_000_000_000,
# /tf delivered briefly, then stopped for most of the bag.
"topic_counts": {"/joint_states": 250, "/tf": 50},
},
)
self.assertTrue(manager.request_start("b1" * 16))
self.wait_for(lambda: manager.status()["recording"])
self.assertTrue(manager.request_stop())
self.assertTrue(manager.wait_until_idle(2.0))
status = manager.status()
self.assertEqual(status["last_result"], "failed")
self.assertIn(
"/tf: 50 messages < 200 required", status["last_error"]
)
failed = Path(status["last_episode_directory"])
manifest = json.loads((failed / "manifest.json").read_text())
self.assertEqual(
manifest["topic_message_counts"],
{"/joint_states": 250, "/tf": 50},
)
self.assertEqual(
manifest["observed_topic_rates_hz"],
{"/joint_states": 25.0, "/tf": 5.0},
)
self.assertEqual(
manifest["minimum_topic_message_counts"],
{"/joint_states": 200, "/tf": 200},
)
self.assertEqual(bag_info.calls, [])
self.assertFalse((failed / "READY").exists())
def test_bag_info_timeout_preserves_episode_as_failed(self) -> None: def test_bag_info_timeout_preserves_episode_as_failed(self) -> None:
manager, _ = self.make_manager( manager, _ = self.make_manager(
command_runner=FakeBagInfoRunner(timeout=True) command_runner=FakeBagInfoRunner(timeout=True)
@@ -693,6 +804,29 @@ class DataRecorderManagerTest(unittest.TestCase):
("/recorded",), ("/recorded",),
required_topics=("/recorded", "/recorded"), required_topics=("/recorded", "/recorded"),
) )
with self.assertRaises(ValueError):
RecorderConfig(
self.base,
("/recorded", "/other"),
required_topics=("/recorded",),
minimum_topic_rates_hz={"/other": 1.0},
)
for invalid_rate in (0, -1, float("nan"), float("inf"), True, "20"):
with self.subTest(invalid_rate=invalid_rate):
with self.assertRaises(ValueError):
RecorderConfig(
self.base,
("/recorded",),
required_topics=("/recorded",),
minimum_topic_rates_hz={"/recorded": invalid_rate},
)
with self.assertRaises(ValueError):
RecorderConfig(
self.base,
("/recorded",),
required_topics=("/recorded",),
minimum_topic_rates_hz=[], # type: ignore[arg-type]
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -40,7 +40,36 @@ import sys
import tomllib import tomllib
with open(sys.argv[1], "rb") as stream: with open(sys.argv[1], "rb") as stream:
tomllib.load(stream) document = tomllib.load(stream)
section = document["data_collection"]
topics = set(section["topics"])
required = set(section["required_topics"])
head_rgbd = {
"/ob_camera_head/color/image_raw/compressed",
"/ob_camera_head/color/camera_info",
"/ob_camera_head/depth/image_raw/compressedDepth",
"/ob_camera_head/depth/camera_info",
}
waist_rgbd = {
"/ob_camera_waist/color/image_raw/compressed",
"/ob_camera_waist/color/camera_info",
"/ob_camera_waist/depth/image_raw/compressedDepth",
"/ob_camera_waist/depth/camera_info",
}
assert head_rgbd <= topics, "head RGB-D topics must stay in the recording whitelist"
assert waist_rgbd <= topics, "waist RGB-D topics must stay in the recording whitelist"
assert waist_rgbd <= required, "live waist RGB-D topics must stay required"
assert head_rgbd.isdisjoint(required), "inactive head RGB-D must not block recording"
assert {
"/ob_camera_waist/color/metadata",
"/ob_camera_waist/depth/metadata",
"/tf_static",
} <= required
assert section["minimum_topic_rates_hz"] == {
"/ob_camera_waist/color/image_raw/compressed": 20.0,
"/ob_camera_waist/depth/image_raw/compressedDepth": 20.0,
}
assert section["minimum_free_gib"] >= 100.0
print("config.toml parse passed") print("config.toml parse passed")
PY PY