Files
acRealman_xr/docs/superpowers/plans/2026-08-10-act-tomato-pick-data-collection.md

1574 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 右臂番茄采摘 ACT 数据采集实施计划
> **供代理执行者使用:** 必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`,逐项执行本计划。所有步骤使用复选框(`- [ ]`)跟踪。
**目标:** 在现有 ROS2 + PICO + RM75 右臂遥操链路上增加 30 Hz、双 RGB 相机、8 维状态/动作的 ALOHA/ACT 风格 HDF5 episode 采集能力,同时保持控制和安全链路不变。
**架构:** `single_arm_velocity_teleop` 在每个 90 Hz 控制周期结束时发布一条小型原子控制消息;独立 `act_episode_recorder` 节点每 3 个周期取样一次,并匹配两台 RealSense 不晚于该控制周期的最新帧。采集节点通过有界队列流式写临时 HDF5,结束后裁剪、校验并以不覆盖已有文件的方式发布到正式目录或拒绝目录。
**技术栈:** Ubuntu 22.04、ROS2 Humble、Python 3、rclpy、rosidl、NumPy、pyrealsense2、h5py、pytest、HDF5。
---
## 文件结构
本次不新建 ROS 包。文件职责锁定如下:
- 新建 `xr_rm_interfaces/msg/ActControlSample.msg`:定义单个控制周期的原子数据契约;
- 修改 `xr_rm_interfaces/CMakeLists.txt`:生成新消息;
- 修改 `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`:只读导出 7 关节位置限制;
- 修改 `xr_rm_teleop/xr_rm_teleop/fun_peripheral.py`:初始化工具时只发送一次完全打开;
- 修改 `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`:夹爪请求/完成状态与 90 Hz 原子消息;
- 新建 `xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py`:状态机、相机缓冲、对齐、流式 HDF5、质量检查和恢复;
- 修改 `xr_rm_teleop/setup.py`:安装采集节点入口;
- 新建 `xr_rm_bringup/config/act_tomato_pick.yaml`:硬件序列号和采集质量参数;
- 修改 `xr_rm_bringup/config/peripherals_rm75.yaml`:仅为右臂启用初始化打开;
- 修改 `xr_rm_bringup/launch/arm_debug.launch.py`:增加默认关闭的 `record_act`
- 修改 `xr_rm_teleop/test/test_initial_joint_pose.py`:夹爪初始化与关节限制测试;
- 新建 `xr_rm_teleop/test/test_act_control_sample.py`:原子消息构造测试;
- 新建 `xr_rm_teleop/test/test_act_episode_recorder.py`:状态机、对齐、HDF5、质量和恢复测试;
- 修改 `xr_rm_bringup/test/test_arm_debug_launch.py`:启动参数和非法组合测试。
`act_episode_recorder.py` 保持单文件,因为这些逻辑只服务一个节点;只提取可独立测试的小型数据类和纯函数,不创建通用采集框架、工厂或插件层。
## 统一执行约定
所有构建和测试命令必须从工作空间根目录执行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
```
不得在 `/home/robot/WS_xr/src` 运行 `colcon build`。自动化测试不得连接真机、移动
RM75 或操作真实夹爪。
### 任务 1:新增原子控制消息和关节限制只读接口
**文件:**
- 新建:`xr_rm_interfaces/msg/ActControlSample.msg`
- 修改:`xr_rm_interfaces/CMakeLists.txt`
- 修改:`xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`
- 修改:`xr_rm_teleop/test/test_placo_transforms.py`
- [ ] **步骤 1:先写关节限制只读副本测试**
`test_placo_transforms.py` 使用该文件现有的求解器构造辅助方式,增加:
```python
def test_joint_position_limits_returns_a_copy(solver) -> None:
first = solver.joint_position_limits
second = solver.joint_position_limits
assert first.shape == (7, 2)
assert np.isfinite(first).all()
assert np.all(first[:, 0] < first[:, 1])
first[0, 0] = 999.0
assert second[0, 0] != 999.0
```
- [ ] **步骤 2:运行测试并确认失败**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_placo_transforms.py -k joint_position_limits -v
```
预期:失败,原因是 `PlacoIkSolver` 尚无 `joint_position_limits` 属性。
- [ ] **步骤 3:实现只读关节限制属性**
`PlacoIkSolver` 的现有属性旁增加:
```python
@property
def joint_position_limits(self) -> np.ndarray:
return self._joint_limits.copy()
```
- [ ] **步骤 4:定义原子消息**
创建 `ActControlSample.msg`,内容固定为:
```text
std_msgs/Header header
uint64 control_seq
int64 control_monotonic_ns
int64 feedback_monotonic_ns
int64 action_monotonic_ns
float32 feedback_age_ms
float32 qp_duration_ms
float64[7] q_actual
float64[7] q_qp_raw
float64[7] q_target
float64[7] joint_lower_limits
float64[7] joint_upper_limits
geometry_msgs/Pose tcp_current
geometry_msgs/Pose tcp_raw_target
geometry_msgs/Pose tcp_target
geometry_msgs/Twist tcp_command_velocity
geometry_msgs/Pose pico_pose
bool pico_grip
float32 pico_trigger
bool pico_primary
bool pico_secondary
float32[2] pico_axis
bool gripper_target_open
bool gripper_state_open
bool gripper_state_known
bool gripper_command_pending
bool gripper_command_failed
bool teleop_active
bool feedback_valid
bool action_valid
bool command_sent
bool qp_attempted
bool qp_success
bool target_clamped
bool control_fault
```
`rosidl_generate_interfaces` 中把新文件加入现有列表;`geometry_msgs`
`std_msgs` 已经是声明过的依赖,不增加新接口依赖。
- [ ] **步骤 5:构建接口并检查生成结果**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
colcon build --symlink-install --packages-select xr_rm_interfaces
source install/setup.bash
ros2 interface show xr_rm_interfaces/msg/ActControlSample
pytest src/xr_rm_teleop/test/test_placo_transforms.py -k joint_position_limits -v
```
预期:接口构建成功,`ros2 interface show` 展示上述全部字段,新增测试通过。
- [ ] **步骤 6:提交任务 1**
```bash
git add src/xr_rm_interfaces/msg/ActControlSample.msg \
src/xr_rm_interfaces/CMakeLists.txt \
src/xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py \
src/xr_rm_teleop/test/test_placo_transforms.py
git commit -m "feat: 添加ACT原子控制消息"
```
### 任务 2:把右臂夹爪初始化和逻辑状态改成可观测结果
**文件:**
- 修改:`xr_rm_teleop/xr_rm_teleop/fun_peripheral.py`
- 修改:`xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
- 修改:`xr_rm_bringup/config/peripherals_rm75.yaml`
- 修改:`xr_rm_teleop/test/test_initial_joint_pose.py`
- 修改:`xr_rm_teleop/test/test_joint_control.py`
- [ ] **步骤 1:写右臂配置和完全打开测试**
`test_initial_joint_pose.py` 增加对部署配置的断言:
```python
def test_right_tool_initializes_open_only_for_right_arm() -> None:
config_file = CONFIG_DIR / "peripherals_rm75.yaml"
left = load_peripheral_config(str(config_file), "left")
right = load_peripheral_config(str(config_file), "right")
assert not left.set_initial_tool_state
assert right.set_initial_tool_state
```
再为 `peripheral_cfg` 增加一个使用假 SDK 类型和 `monkeypatch` 的测试,截获
`set_tool_position` 调用,核心断言为:
```python
assert calls == [(1.0, 1, 1)]
```
其中三项依次表示 `percent``device``scissorgripper`,不能出现 `0.75`
`0.15`
- [ ] **步骤 2:运行测试并确认失败**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_initial_joint_pose.py \
-k "right_tool_initializes_open or omnipic_initial_state" -v
```
预期:部署配置仍是 `false`,且旧代码发出 `0.75``0.15`,测试失败。
- [ ] **步骤 3:最小修改初始化行为**
把 OmniPicker 的初始化分支替换成一次调用:
```python
if set_initial_tool_state:
set_tool_position(
robot,
percent=1.0,
device=1,
scissorgripper=scissorgripper,
)
```
`peripherals_rm75.yaml` 保持全局默认关闭,并只在右臂覆盖:
```yaml
set_initial_tool_state: false
arms:
left:
scissorgripper: 0
right:
scissorgripper: 1
set_initial_tool_state: true
```
保留仓库当前实际使用的左臂 `scissorgripper` 值,不借本任务修正无关配置。
- [ ] **步骤 4:写夹爪请求与成功状态测试**
`test_joint_control.py` 构造无 ROS 初始化的遥操对象和假适配器,验证:
```python
def test_tool_state_changes_only_after_command_succeeds() -> None:
teleop, worker_gate = _tool_state_teleop()
teleop._enqueue_tool_command(False, "test")
assert teleop._tool_target_open is False
assert teleop._tool_state_open is True
assert teleop._tool_command_pending
worker_gate.complete_successfully()
assert teleop._tool_state_open is False
assert not teleop._tool_command_pending
assert not teleop._tool_command_failed
```
另加失败测试:失败后 `_tool_state_open` 保持原值、`_tool_command_failed=true`
下一次成功命令清除失败并更新状态。
- [ ] **步骤 5:运行夹爪状态测试并确认失败**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_joint_control.py -k tool_state -v
```
预期:失败,原因是当前遥操节点只有 `_trigger_tool_open`,没有请求、成功、处理中和
失败状态。
- [ ] **步骤 6:在线程锁内记录夹爪状态**
在遥操节点初始化时增加:
```python
self._tool_state_lock = threading.Lock()
self._tool_target_open = True
self._tool_state_open: bool | None = None
self._tool_command_pending = False
self._tool_command_failed = False
```
`configure_peripheral` 成功且右臂配置要求初始化后,将请求和已确认状态都设为
`True`。每次入队先设置目标与 pending;工作线程只有在
`self._adapter.set_tool_enabled(open_tool)` 正常返回后才更新
`_tool_state_open`。异常时保持状态不变并设置失败。所有跨线程读写都在
`_tool_state_lock` 中完成。
提供一个只读快照方法,后续原子消息复用:
```python
def _tool_state_snapshot(self) -> tuple[bool, bool | None, bool, bool]:
with self._tool_state_lock:
return (
self._tool_target_open,
self._tool_state_open,
self._tool_command_pending,
self._tool_command_failed,
)
```
- [ ] **步骤 7:运行相关测试**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_initial_joint_pose.py -v
pytest src/xr_rm_teleop/test/test_joint_control.py -k "tool or trigger" -v
```
预期:新增测试和现有工具/Trigger 测试全部通过。
- [ ] **步骤 8:提交任务 2**
```bash
git add src/xr_rm_teleop/xr_rm_teleop/fun_peripheral.py \
src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py \
src/xr_rm_bringup/config/peripherals_rm75.yaml \
src/xr_rm_teleop/test/test_initial_joint_pose.py \
src/xr_rm_teleop/test/test_joint_control.py
git commit -m "feat: 记录夹爪逻辑执行状态"
```
### 任务 3:在 90 Hz 控制周期发布原子样本
**文件:**
- 修改:`xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
- 新建:`xr_rm_teleop/test/test_act_control_sample.py`
- 修改:`xr_rm_teleop/test/test_joint_control.py`
- 修改:`xr_rm_teleop/test/test_orientation_control.py`
- [ ] **步骤 1:写原子样本构造测试**
新测试文件使用 `object.__new__(SingleArmVelocityTeleop)``FakePublisher` 和假
`ActCycleContext`,覆盖三个关键行为:
```python
def test_act_sample_uses_feedback_and_limited_target_from_one_cycle() -> None:
teleop = _act_sample_teleop()
cycle = _cycle(
seq=100,
q_actual=[0.1] * 7,
q_qp_raw=[0.3] * 7,
q_target=[0.2] * 7,
command_sent=True,
qp_attempted=True,
qp_success=True,
)
message = teleop._build_act_control_sample(cycle)
assert message.control_seq == 100
assert message.q_actual == pytest.approx([0.1] * 7)
assert message.q_qp_raw == pytest.approx([0.3] * 7)
assert message.q_target == pytest.approx([0.2] * 7)
assert message.command_sent
assert message.action_valid
def test_act_sample_marks_qp_fallback_as_valid_held_action() -> None:
teleop = _act_sample_teleop(last_successful_target=[0.2] * 7)
cycle = _cycle(qp_attempted=True, qp_success=False, command_sent=True)
message = teleop._build_act_control_sample(cycle)
assert message.q_target == pytest.approx([0.2] * 7)
assert message.action_valid
assert not message.qp_success
def test_act_sample_holds_last_action_while_grip_is_released() -> None:
teleop = _act_sample_teleop(last_successful_target=[0.4] * 7)
cycle = _cycle(teleop_active=False, command_sent=False)
message = teleop._build_act_control_sample(cycle)
assert message.q_target == pytest.approx([0.4] * 7)
assert message.action_valid
assert not message.command_sent
```
另测发送失败时 `action_valid=false`、未知夹爪状态时
`gripper_state_known=false`,以及上下关节限制来自求解器副本。
- [ ] **步骤 2:运行测试并确认失败**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
source install/setup.bash
pytest src/xr_rm_teleop/test/test_act_control_sample.py -v
```
预期:失败,原因是原子周期上下文和构造方法尚不存在。
- [ ] **步骤 3:增加仅供单周期使用的上下文数据类**
在遥操模块内增加私有数据类,默认值确保任何提前返回路径也能发布完整消息:
```python
@dataclass
class _ActCycleContext:
control_seq: int
control_monotonic_ns: int
feedback_monotonic_ns: int = -1
action_monotonic_ns: int = -1
feedback_age_ms: float = math.inf
q_actual: list[float] | None = None
q_qp_raw: list[float] | None = None
q_target: list[float] | None = None
current_pose: np.ndarray | None = None
raw_target_pose: np.ndarray | None = None
target_pose: np.ndarray | None = None
command_velocity: list[float] | None = None
feedback_valid: bool = False
command_sent: bool = False
send_failed: bool = False
qp_attempted: bool = False
qp_success: bool = False
target_clamped: bool = False
control_fault: bool = False
```
- [ ] **步骤 4:用包装方法保证所有返回路径发布一次**
保持现有控制主体顺序不变,把当前 `_control_tick` 主体移入
`_control_tick_impl(cycle)`,包装器只负责周期号和最终发布:
```python
def _control_tick(self) -> None:
cycle = _ActCycleContext(
control_seq=self._act_control_seq,
control_monotonic_ns=time.monotonic_ns(),
)
self._act_control_seq += 1
try:
self._control_tick_impl(cycle)
finally:
self._publish_act_control_sample(cycle)
```
在原有主体已经取得信息的位置只赋值给 `cycle`:反馈同步后写
`q_actual/current_pose`;QP 前后写目标、尝试、成功和耗时;最终限速并成功发送后写
`q_target/action_monotonic_ns/command_sent`。不改变这些步骤的先后顺序和异常处理。
QP 方法改为返回目标和成功标志:
```python
def _solve_joint_target(
self,
target_pose: np.ndarray,
) -> tuple[list[float], bool]:
try:
result = self._ik_solver.solve(target_pose)
except Exception as exc:
self.get_logger().warn(
f"{self._arm_name} QP 求解失败,保持上一组关节目标:{exc}",
throttle_duration_sec=1.0,
)
return list(self._last_valid_joint_target), False
self._last_valid_joint_target = list(result)
return list(result), True
```
调用点同步解包;`test_joint_control.py` 中直接调用该方法的测试同步断言返回的
`qp_success``test_joint_control.py``test_orientation_control.py` 中直接构造遥操
对象并调用 `_control_tick()` 的辅助对象补齐 `_act_control_seq` 和假 publisher。
现有 QP 失败保持行为不变。
- [ ] **步骤 5:构造并发布消息**
创建 best-effort、keep-last 深度 10 的 publisher。构造方法必须:
```python
q_actual = cycle.q_actual or [0.0] * 7
held_target = (
cycle.q_target
or self._last_successful_action_target
or q_actual
)
action_valid = (
self._last_successful_action_target is not None
and cycle.feedback_valid
and not cycle.send_failed
and not cycle.control_fault
)
```
成功发送后先更新 `_last_successful_action_target`,该字段不能被 Grip 松开时的
`_safe_stop(reset_active=True)` 清除。首次 Grip 建基准但尚未发送时保持
`action_valid=false`。位姿缺失时使用当前 TCP 的有限值回退并保持相应有效标志,
不能把缺失样本写入正式 recording。
发布方法必须隔离采集诊断故障,不能让消息构造或 DDS 发布异常中断机器人控制:
```python
def _publish_act_control_sample(self, cycle: _ActCycleContext) -> None:
try:
self._act_sample_pub.publish(
self._build_act_control_sample(cycle)
)
except Exception as exc:
self.get_logger().warn(
f"{self._arm_name} ACT原子样本发布失败:{exc}",
throttle_duration_sec=1.0,
)
```
增加一个假 publisher 抛异常的测试,断言 `_publish_act_control_sample` 不向控制
回调传播异常。
- [ ] **步骤 6:运行原子样本和受影响控制测试**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
source install/setup.bash
pytest src/xr_rm_teleop/test/test_act_control_sample.py -v
pytest src/xr_rm_teleop/test/test_joint_control.py -v
pytest src/xr_rm_teleop/test/test_orientation_control.py -v
```
预期:全部通过;现有安全停止、限速和姿态控制断言不变。
- [ ] **步骤 7:提交任务 3**
```bash
git add src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py \
src/xr_rm_teleop/test/test_act_control_sample.py \
src/xr_rm_teleop/test/test_joint_control.py \
src/xr_rm_teleop/test/test_orientation_control.py
git commit -m "feat: 发布同周期ACT控制样本"
```
### 任务 4:实现录制状态机、按键判定和 90→30 Hz 选择
**文件:**
- 新建:`xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py`
- 新建:`xr_rm_teleop/test/test_act_episode_recorder.py`
- [ ] **步骤 1:写状态机测试**
用纯 Python 对象覆盖:B 预检后进入 ARMED、第一次有效 Grip 进入 RECORDING、
每 3 周期采样、暂停/恢复、结束请求、Y 长按、A 拒绝和 60 秒上限。核心测试:
```python
def test_recording_starts_on_first_valid_grip_and_samples_every_third_cycle():
session = RecordingSession(max_samples=1800)
session.arm()
assert session.on_control(
100, grip=False, action_valid=True, command_sent=False
) == NO_ACTION
assert session.on_control(
101, grip=True, action_valid=True, command_sent=False
) == NO_ACTION
first = session.on_control(
102, grip=True, action_valid=True, command_sent=True
)
second = session.on_control(
103, grip=True, action_valid=True, command_sent=True
)
third = session.on_control(
105, grip=True, action_valid=True, command_sent=True
)
assert first.record_sample
assert not second.record_sample
assert third.record_sample
assert session.sample_origin_seq == 102
def test_final_grip_release_marks_crop_point_but_mid_pause_is_kept():
session = _recording_session(origin_seq=10)
session.on_control(13, grip=False, action_valid=True, command_sent=False)
first_crop = session.candidate_end_count
session.on_control(14, grip=True, action_valid=True, command_sent=False)
assert session.candidate_end_count is None
session.on_control(16, grip=False, action_valid=True, command_sent=False)
assert session.candidate_end_count > first_crop
def test_missing_control_sequence_rejects_recording():
session = _recording_session(origin_seq=10)
session.on_control(11, grip=True, action_valid=True, command_sent=True)
decision = session.on_control(
13, grip=True, action_valid=True, command_sent=True
)
assert decision.reject_reason == "control_sequence_gap"
```
按键追踪器测试必须证明:右 B 在 Grip 按下时忽略;左 Y 只有在 ARMED/RECORDING
期间发生新的按下并连续 1 秒才触发;IDLE 中按住 Y 后进入 ARMED 不会误丢弃;
RECORDING 中 A 返回 `initial_pose_command_during_episode`
- [ ] **步骤 2:运行测试并确认失败**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_act_episode_recorder.py -k "session or button" -v
```
预期:导入失败,因为采集模块尚不存在。
- [ ] **步骤 3:实现最小状态类型和控制决策**
在新模块中定义:
```python
class RecordingState(str, Enum):
IDLE = "IDLE"
ARMED = "ARMED"
RECORDING = "RECORDING"
SAVING = "SAVING"
SAVED = "SAVED"
DISCARDED = "DISCARDED"
REJECTED = "REJECTED"
@dataclass(frozen=True)
class ControlDecision:
record_sample: bool = False
finish: bool = False
reject_reason: str | None = None
NO_ACTION = ControlDecision()
```
`RecordingSession` 只保存状态、起始序号、上一个 90 Hz 序号、已选择样本数、最终
Grip 候选裁剪点和结束请求。ARMED 只有在 Grip 按下且
`action_valid、command_sent` 同时为真时进入 RECORDING,跳过只建立相对位姿基准但
尚未下发 CANFD 目标的首个 Grip 周期。`on_control` 先检查连续序号,再处理 Grip
边沿,最后用:
```python
record_sample = (
self.state is RecordingState.RECORDING
and (control_seq - self.sample_origin_seq) % 3 == 0
)
```
首次有效 Grip 样本必须计入。B 结束只设置 `finish_requested=true`;等待下一条原子
消息确认 Grip 已松开后才返回 `finish=true`,避免 PICO 回调与控制消息的到达顺序
造成错误裁剪。
- [ ] **步骤 4:实现按键边沿和 Y 长按追踪**
使用单调纳秒而不是 ROS wall clock
```python
class ButtonTracker:
def __init__(self, hold_ns: int) -> None:
self.hold_ns = hold_ns
self.right_b = False
self.right_a = False
self.left_y = False
self.left_y_started_ns: int | None = None
self.left_y_eligible = False
self.left_y_fired = False
```
只在按钮上升沿产生 B/A 事件。Y 上升沿时根据当前状态锁定
`left_y_eligible`;持续按住达到 `1_000_000_000 ns` 只触发一次,松开后完全复位。
- [ ] **步骤 5:运行状态机测试**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_act_episode_recorder.py -k "session or button" -v
```
预期:所有状态与按键测试通过。
- [ ] **步骤 6:提交任务 4**
```bash
git add src/xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py \
src/xr_rm_teleop/test/test_act_episode_recorder.py
git commit -m "feat: 添加ACT录制状态机"
```
### 任务 5:实现 RealSense 帧缓冲和非未来帧对齐
**文件:**
- 修改:`xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py`
- 修改:`xr_rm_teleop/test/test_act_episode_recorder.py`
- [ ] **步骤 1:写帧选择和相机质量测试**
增加纯数据测试:
```python
def test_select_frame_returns_latest_frame_not_after_control_time():
frames = [
CameraFrame(_image(1), 10, 100.0, 900_000_000),
CameraFrame(_image(2), 11, 133.3, 933_000_000),
CameraFrame(_image(3), 12, 166.6, 1_010_000_000),
]
selected, age_ms = select_frame(frames, 1_000_000_000, 100.0)
assert selected.frame_number == 11
assert age_ms == pytest.approx(67.0, abs=0.1)
def test_select_frame_rejects_frame_older_than_limit():
frames = [CameraFrame(_image(1), 10, 100.0, 900_000_000)]
with pytest.raises(QualityError, match="camera_frame_too_old"):
select_frame(frames, 1_000_000_000, 50.0)
```
第一项使用 `max_age_ms=100.0` 通过;随后显式用 50 ms 验证拒绝。另测未来帧
不会被选、两相机 skew 超过 50 ms 拒绝、形状或 dtype 错误拒绝、帧号统计能计算
采集 FPS 和丢帧率。
- [ ] **步骤 2:运行测试并确认失败**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_act_episode_recorder.py -k "frame or camera" -v
```
预期:失败,因为相机帧类型和选择函数尚不存在。
- [ ] **步骤 3:实现线程安全的小缓冲和选择函数**
```python
@dataclass(frozen=True)
class CameraFrame:
image: np.ndarray
frame_number: int
hardware_timestamp_ms: float
host_monotonic_ns: int
def select_frame(
frames: tuple[CameraFrame, ...],
control_monotonic_ns: int,
max_age_ms: float,
) -> tuple[CameraFrame, float]:
eligible = [
frame
for frame in frames
if frame.host_monotonic_ns <= control_monotonic_ns
]
if not eligible:
raise QualityError("camera_frame_missing")
frame = max(eligible, key=lambda item: item.host_monotonic_ns)
age_ms = (control_monotonic_ns - frame.host_monotonic_ns) * 1e-6
if age_ms > max_age_ms:
raise QualityError("camera_frame_too_old")
if frame.image.shape != (480, 640, 3) or frame.image.dtype != np.uint8:
raise QualityError("camera_frame_format")
return frame, age_ms
```
`CameraBuffer` 内部只使用 `deque(maxlen=4)``threading.Lock``snapshot()` 返回
不可变 tuple,避免采样线程持锁写 HDF5。
- [ ] **步骤 4:实现可停止的 RealSense 采集器**
`RealSenseCamera.start()` 内部才导入 `pyrealsense2`。启动时按序列号查找设备并
校验期望型号,配置唯一 RGB 流:
```python
config.enable_device(self.serial)
config.enable_stream(
rs.stream.color,
640,
480,
rs.format.rgb8,
30,
)
```
线程循环使用 `wait_for_frames(timeout_ms=1000)`,取得 color frame 后立即记录
`time.monotonic_ns()`,再把 `np.asanyarray(frame.get_data()).copy()`、帧号和硬件
时间戳推入缓冲。停止时设置 Event、join 线程并调用 `pipeline.stop()`。超时或 SDK
异常保存到 `last_error`,由节点预检/录制逻辑拒绝,不调用任何机器人接口。
- [ ] **步骤 5:运行相机纯逻辑测试**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_act_episode_recorder.py -k "frame or camera" -v
```
预期:测试仅使用合成 NumPy 图像,不访问 USB 相机,并全部通过。
- [ ] **步骤 6:提交任务 5**
```bash
git add src/xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py \
src/xr_rm_teleop/test/test_act_episode_recorder.py
git commit -m "feat: 对齐ACT双相机帧"
```
### 任务 6:实现流式 HDF5、编号防覆盖和崩溃恢复
**文件:**
- 修改:`xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py`
- 修改:`xr_rm_teleop/test/test_act_episode_recorder.py`
- [ ] **步骤 1:只在现有 XR Conda 环境安装并验证 h5py**
```bash
/home/robot/miniconda3/envs/xr/bin/python -m pip install h5py
/home/robot/miniconda3/envs/xr/bin/python -c \
"import h5py; print(h5py.__version__)"
```
预期:输出一个 h5py 版本号。不得使用 `sudo`、不得修改系统 Python、不得创建新
Conda 环境。
- [ ] **步骤 2:写 HDF5 结构和变量长度测试**
使用 `tmp_path` 创建 3 个合成样本,验证:
```python
def test_episode_store_writes_act_core_schema(tmp_path):
store = EpisodeStore.create(tmp_path / "episode_0.partial.hdf5", _metadata())
store.append(_episode_sample(seq=100))
store.append(_episode_sample(seq=103))
store.append(_episode_sample(seq=106))
store.close()
with h5py.File(store.path, "r") as root:
assert root.attrs["sim"] == np.bool_(False)
assert root.attrs["action_alignment"] == "same_step_causal"
assert root["observations/qpos"].shape == (3, 8)
assert root["observations/qpos"].dtype == np.float32
assert root["action"].shape == (3, 8)
assert root["action"].dtype == np.float32
assert root["observations/images/cam_high"].shape == (3, 480, 640, 3)
assert root["observations/images/cam_high"].dtype == np.uint8
assert root["observations/images/cam_right_wrist"].shape == (3, 480, 640, 3)
assert "observations/qvel" not in root
assert "observations/effort" not in root
assert "compress_len" not in root
```
另测 `truncate(2)` 后所有时间轴数据集长度都是 2。
- [ ] **步骤 3:运行 HDF5 测试并确认失败**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_act_episode_recorder.py -k "episode_store" -v
```
预期:失败,因为 `EpisodeStore` 尚未实现。
- [ ] **步骤 4:实现固定数据布局和流式追加**
定义所有数据集,不允许调用方任意创建路径。核心布局:
```python
CORE_LAYOUT = {
"observations/qpos": (np.float32, (8,), (256, 8)),
"action": (np.float32, (8,), (256, 8)),
"observations/images/cam_high": (
np.uint8, (480, 640, 3), (1, 480, 640, 3)
),
"observations/images/cam_right_wrist": (
np.uint8, (480, 640, 3), (1, 480, 640, 3)
),
}
```
Debug 布局完整包含规格中的时间戳、控制状态、QP、TCP、PICO、夹爪,并增加质量
检查所需的两个相机帧号:
```python
DEBUG_LAYOUT = {
"debug/timestamps/control_monotonic_ns": (np.int64, (), (256,)),
"debug/timestamps/feedback_monotonic_ns": (np.int64, (), (256,)),
"debug/timestamps/action_monotonic_ns": (np.int64, (), (256,)),
"debug/timestamps/cam_high_host_monotonic_ns": (np.int64, (), (256,)),
"debug/timestamps/cam_wrist_host_monotonic_ns": (np.int64, (), (256,)),
"debug/timestamps/cam_high_hardware_ms": (np.float64, (), (256,)),
"debug/timestamps/cam_wrist_hardware_ms": (np.float64, (), (256,)),
"debug/timestamps/cam_high_age_ms": (np.float32, (), (256,)),
"debug/timestamps/cam_wrist_age_ms": (np.float32, (), (256,)),
"debug/timestamps/inter_camera_skew_ms": (np.float32, (), (256,)),
"debug/cameras/cam_high_frame_number": (np.uint64, (), (256,)),
"debug/cameras/cam_wrist_frame_number": (np.uint64, (), (256,)),
"debug/control/control_seq": (np.uint64, (), (256,)),
"debug/control/teleop_active": (np.uint8, (), (256,)),
"debug/control/action_valid": (np.uint8, (), (256,)),
"debug/control/command_sent": (np.uint8, (), (256,)),
"debug/control/target_clamped": (np.uint8, (), (256,)),
"debug/control/control_fault": (np.uint8, (), (256,)),
"debug/qp/raw_target": (np.float32, (7,), (256, 7)),
"debug/qp/attempted": (np.uint8, (), (256,)),
"debug/qp/success": (np.uint8, (), (256,)),
"debug/qp/duration_ms": (np.float32, (), (256,)),
"debug/tcp/current_pose": (np.float32, (7,), (256, 7)),
"debug/tcp/raw_target_pose": (np.float32, (7,), (256, 7)),
"debug/tcp/final_target_pose": (np.float32, (7,), (256, 7)),
"debug/tcp/command_velocity": (np.float32, (6,), (256, 6)),
"debug/pico/right_pose": (np.float32, (7,), (256, 7)),
"debug/pico/right_inputs": (np.float32, (6,), (256, 6)),
"debug/pico/left_secondary": (np.uint8, (), (256,)),
"debug/gripper/target_open": (np.uint8, (), (256,)),
"debug/gripper/state_open": (np.uint8, (), (256,)),
"debug/gripper/command_pending": (np.uint8, (), (256,)),
"debug/gripper/command_failed": (np.uint8, (), (256,)),
}
```
每个数据集使用 `shape=(0, *sample_shape)``maxshape=(None, *sample_shape)`
`append` 先校验 key、shape 和有限值,再统一 resize 到 `count+1` 并写入。图像不
压缩。`truncate(count)` 对所有数据集使用同一个长度。
有限值扫描只用于数值状态、动作和 debug 浮点数据;图像只检查 `uint8` 和固定
shape,避免每帧重复扫描约 1.8 MB 图像。元数据类型固定为:
```python
@dataclass(frozen=True)
class EpisodeMetadata:
joint_names: tuple[str, ...]
joint_lower_limits: np.ndarray
joint_upper_limits: np.ndarray
```
`EpisodeStore.create` 必须一次写入:
```python
root.attrs["sim"] = False
root.attrs["task_name"] = "tomato_pick"
root.attrs["sample_rate_hz"] = 30
root.attrs["action_alignment"] = "same_step_causal"
root.attrs["arm"] = "right_rm75"
root.attrs["episode_status"] = "recording"
root.attrs["camera_high_serial"] = "234222303366"
root.attrs["camera_right_wrist_serial"] = "412622272532"
root.attrs["joint_names"] = metadata.joint_names
root.attrs["joint_lower_limits"] = metadata.joint_lower_limits
root.attrs["joint_upper_limits"] = metadata.joint_upper_limits
root.attrs["pose_order"] = "x,y,z,qx,qy,qz,qw"
root.attrs["right_input_order"] = "grip,trigger,primary,secondary,axis_x,axis_y"
root.attrs["interrupted"] = False
```
保存或拒绝前把 `episode_status` 更新为 `saved``rejected`;拒绝文件同时写入
`reject_reason`,中断/崩溃恢复文件把 `interrupted` 更新为 `true`
- [ ] **步骤 5:写编号、锁、正式发布和恢复测试**
```python
def test_next_index_uses_max_saved_episode_and_ignores_rejected(tmp_path):
(tmp_path / "episode_2.hdf5").touch()
(tmp_path / "episode_9.hdf5").touch()
rejected = tmp_path / "rejected"
rejected.mkdir()
(rejected / "episode_20_bad_20260810.hdf5").touch()
assert next_episode_index(tmp_path) == 10
def test_publish_never_overwrites_existing_episode(tmp_path):
partial = tmp_path / "episode_1.partial.hdf5"
partial.write_bytes(b"new")
final = tmp_path / "episode_1.hdf5"
final.write_bytes(b"old")
with pytest.raises(FileExistsError):
publish_without_overwrite(partial, final)
assert final.read_bytes() == b"old"
```
再测可读 partial 被标记 `crash_recovered` 并移入拒绝目录、不可读 partial 改名保留、
手动丢弃只删除当前 partial、拒绝不改变下一正式编号。
- [ ] **步骤 6:实现任务目录和不覆盖发布**
使用 `fcntl.flock(lock_file, LOCK_EX | LOCK_NB)` 持有任务目录锁。编号只匹配:
```python
EPISODE_PATTERN = re.compile(r"^episode_(\d+)\.hdf5$")
```
临时文件关闭后使用同文件系统硬链接实现不覆盖发布:
```python
def publish_without_overwrite(partial: Path, destination: Path) -> None:
os.link(partial, destination)
partial.unlink()
```
目标存在时 `os.link` 必须抛出 `FileExistsError`,原 partial 保留。恢复可读文件时用
`h5py.File(path, "r+")``episode_status="rejected"`
`reject_reason="crash_recovered"``interrupted=true`,再发布到带原因和时间戳的
拒绝文件。不可读文件只改成唯一带时间戳的 `.partial.hdf5` 名称。
- [ ] **步骤 7:运行 HDF5、编号与恢复测试**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_act_episode_recorder.py \
-k "episode_store or index or publish or recover" -v
```
预期:全部通过,且测试只操作 pytest 临时目录。
- [ ] **步骤 8:提交任务 6**
```bash
git add src/xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py \
src/xr_rm_teleop/test/test_act_episode_recorder.py
git commit -m "feat: 流式保存ACT HDF5数据"
```
### 任务 7:实现 episode 质量检查和拒绝原因
**文件:**
- 修改:`xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py`
- 修改:`xr_rm_teleop/test/test_act_episode_recorder.py`
- [ ] **步骤 1:写通过样本和逐项失败参数化测试**
先创建一个 60 样本的最小合法文件,再逐项破坏副本。参数化断言至少包含:
```python
@pytest.mark.parametrize(
("mutation", "reason"),
[
("short_episode", "too_few_samples"),
("control_seq_gap", "control_sequence_gap"),
("nonfinite_qpos", "nonfinite_qpos"),
("joint_limit", "joint_limit_violation"),
("invalid_gripper", "invalid_gripper_state"),
("feedback_age", "feedback_too_old"),
("action_invalid", "invalid_action"),
("control_fault", "control_fault"),
("camera_fps", "camera_fps"),
("camera_drop", "camera_drop_ratio"),
("camera_age", "camera_frame_too_old"),
("camera_skew", "camera_skew"),
("final_gripper_closed", "final_gripper_not_open"),
],
)
def test_validate_episode_reports_stable_reason(valid_episode, mutation, reason):
mutate_episode(valid_episode, mutation)
report = validate_episode(valid_episode, _quality_limits())
assert not report.accepted
assert report.reason == reason
```
另测多个 QP 失败样本仍通过,并正确得到失败次数、占比和最长连续失败数。
- [ ] **步骤 2:运行质量测试并确认失败**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_act_episode_recorder.py -k validate_episode -v
```
预期:失败,因为最终质量报告尚未实现。
- [ ] **步骤 3:实现质量配置和稳定报告类型**
```python
@dataclass(frozen=True)
class QualityLimits:
min_samples: int = 60
max_samples: int = 1800
min_control_hz: float = 27.0
max_control_gap_ms: float = 100.0
min_camera_fps: float = 27.0
max_drop_ratio: float = 0.01
max_feedback_age_ms: float = 50.0
max_camera_age_ms: float = 50.0
max_camera_skew_ms: float = 50.0
@dataclass(frozen=True)
class QualityReport:
accepted: bool
reason: str | None
metrics: dict[str, int | float]
```
`validate_episode` 按固定顺序返回第一个硬失败,保证拒绝原因可测试、可检索。校验
所有时间轴长度相同;核心 shape/dtype;有限值;根属性中的关节上下限;夹爪
`0/1`;控制序号差为 3;控制/反馈时间差;图像时间、shape 和 dtype;相机帧号;
平均控制频率和最大控制间隔;最终夹爪打开;根属性中的相机采集 FPS 和硬件丢帧率。
- [ ] **步骤 4:实现 QP 和限位汇总**
只在 `debug/qp/attempted==1` 的样本中统计失败:
```python
failed = attempted & ~success
metrics["qp_failure_count"] = int(failed.sum())
metrics["qp_failure_ratio"] = float(failed.sum() / max(1, attempted.sum()))
metrics["qp_longest_failure_streak"] = longest_true_run(failed)
metrics["target_clamped_count"] = int(target_clamped.sum())
```
这些指标不进入拒绝判断。`longest_true_run` 使用一次线性循环,不引入 pandas。
- [ ] **步骤 5:运行全部质量测试**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_act_episode_recorder.py -k validate_episode -v
```
预期:合法文件通过,各破坏样本返回预期稳定原因,QP 回退文件仍通过。
- [ ] **步骤 6:提交任务 7**
```bash
git add src/xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py \
src/xr_rm_teleop/test/test_act_episode_recorder.py
git commit -m "feat: 校验ACT episode数据质量"
```
### 任务 8:集成 ROS 采集节点、写入线程和完整操作流程
**文件:**
- 修改:`xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py`
- 修改:`xr_rm_teleop/test/test_act_episode_recorder.py`
- 修改:`xr_rm_teleop/setup.py`
- [ ] **步骤 1:写预检和节点流程测试**
使用假相机、假目录、假 publisher 和直接调用回调的方法,覆盖:
```python
def test_preflight_requires_open_gripper_fresh_inputs_and_disk_space():
recorder = _recorder_for_test()
recorder.latest_control.gripper_state_known = True
recorder.latest_control.gripper_state_open = True
recorder.right_controller_age_ms = 10.0
recorder.left_controller_age_ms = 10.0
recorder.free_space_bytes = 5 * 1024**3
assert recorder._run_preflight() is None
def test_end_to_end_fake_episode_saves_and_returns_idle(tmp_path):
recorder = _recorder_for_test(tmp_path=tmp_path)
recorder._on_right_b(grip=False)
assert recorder.state is RecordingState.ARMED
for message in _valid_control_messages(90 * 3):
recorder._on_control_sample(message)
recorder._request_finish()
recorder._on_control_sample(_released_message(seq=271))
recorder._wait_for_writer()
assert (tmp_path / "tomato_pick" / "episode_0.hdf5").is_file()
assert recorder.state is RecordingState.IDLE
```
另测:Y 删除 partial;A 生成拒绝文件;最大时长、相机故障、队列满、写盘异常和
Ctrl+C 均生成对应原因;拒绝不调用任何机器人 API。
- [ ] **步骤 2:运行集成测试并确认失败**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
source install/setup.bash
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_act_episode_recorder.py -k "preflight or end_to_end" -v
```
预期:失败,因为 ROS 节点和工作线程尚未连接现有纯逻辑。
- [ ] **步骤 3:实现节点参数、订阅和状态发布**
`ActEpisodeRecorder(Node)` 声明 YAML 中的全部参数,创建:
```python
self._status_pub = self.create_publisher(
String,
self._status_topic,
10,
)
self.create_subscription(
ActControlSample,
self._control_sample_topic,
self._on_control_sample,
sensor_data_qos,
)
self.create_subscription(
XrController,
self._right_controller_topic,
self._on_right_controller,
10,
)
self.create_subscription(
XrController,
self._left_controller_topic,
self._on_left_controller,
10,
)
```
节点启动顺序固定为:验证参数和 h5py → 创建并锁定任务目录 → 恢复 partial → 打开
两台相机并开始预热 → 创建 ROS 订阅。相机启动失败时节点记录错误并保持不可录制,
不触发 launch 全局关闭。
- [ ] **步骤 4:实现样本构造和有界写入线程**
收到被状态机选中的控制消息时:
```python
qpos = np.asarray(
[*message.q_actual, float(message.gripper_state_open)],
dtype=np.float32,
)
action = np.asarray(
[*message.q_target, float(message.gripper_target_open)],
dtype=np.float32,
)
```
然后按控制单调时间选择两路帧、计算年龄和 skew、构造全部核心/debug 值,并调用
`EpisodeWriter.submit(sample)``EpisodeWriter` 使用 `queue.Queue(maxsize=8)` 和单
写线程;队列满立即以 `writer_backlog` 拒绝,不在 ROS 回调中等待磁盘。结束时:
```text
停止接受新样本
→ queue.join()
→ 检查写线程异常
→ truncate(candidate_end_count)
→ 等待夹爪命令完成(最多 3 秒)
→ 关闭 HDF5
→ validate_episode
→ 发布正式文件或拒绝文件
```
`EpisodeWriter` 的线程异常保存在一个受锁保护的字段中,ROS 节点每次控制回调和
保存前检查;异常原因固定为 `disk_write_error`
进入 `ARMED` 时保存两台相机的累计帧数、首末主机时间和累计硬件丢帧数;结束时
用差值计算本 episode 的采集 FPS 与硬件丢帧率,并写入根属性。这样预热阶段不会
稀释本次 episode 的质量指标。
- [ ] **步骤 5:实现预检、状态结果和中断处理**
预检使用 `shutil.disk_usage`、PICO/控制消息接收单调时间、相机连续 5 秒统计和
夹爪状态。可用空间要求 `>=4*1024**3`。结果状态方法统一:
```python
def _publish_state(self, state: RecordingState, reason: str = "") -> None:
message = String()
message.data = state.value if not reason else f"{state.value}:{reason}"
self._status_pub.publish(message)
```
`SAVED``DISCARDED``REJECTED` 发布后立即发布 `IDLE``main()` 捕获
`KeyboardInterrupt` 后先调用 `node.interrupt_recording("interrupted")`,再停止
相机、关闭 writer、释放目录锁和销毁节点。正常 IDLE 退出不生成文件。
- [ ] **步骤 6:安装 console script 且保持普通遥操无 h5py 强依赖**
`setup.py` 增加:
```python
"act_episode_recorder = xr_rm_teleop.act_episode_recorder:main",
```
不要把 h5py 或 pyrealsense2 加进系统 `package.xml` 依赖;它们只属于现有 XR Conda
运行环境。`single_arm_velocity_teleop` 不导入采集模块,因此 `record_act=false`
不会加载相机或 HDF5。
- [ ] **步骤 7:运行采集节点全部合成测试**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
source install/setup.bash
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_act_episode_recorder.py -v
```
预期:所有测试通过,测试进程没有访问 RealSense 或 RealMan。
- [ ] **步骤 8:提交任务 8**
```bash
git add src/xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py \
src/xr_rm_teleop/test/test_act_episode_recorder.py \
src/xr_rm_teleop/setup.py
git commit -m "feat: 集成ACT episode采集节点"
```
### 任务 9:接入统一 launch 和番茄采摘配置
**文件:**
- 新建:`xr_rm_bringup/config/act_tomato_pick.yaml`
- 修改:`xr_rm_bringup/launch/arm_debug.launch.py`
- 修改:`xr_rm_bringup/test/test_arm_debug_launch.py`
- [ ] **步骤 1:写 launch 默认值和组合校验测试**
在现有测试中增加:
```python
def test_act_recording_is_disabled_by_default() -> None:
description = arm_debug_launch.generate_launch_description()
arguments = {
entity.name: entity
for entity in description.entities
if isinstance(entity, DeclareLaunchArgument)
}
assert perform_substitutions(
LaunchContext(),
arguments["record_act"].default_value,
) == "false"
@pytest.mark.parametrize(
("arm", "use_mock"),
[("left", False), ("both", False), ("right", True)],
)
def test_act_recording_rejects_unsupported_modes(arm, use_mock) -> None:
with pytest.raises(ValueError, match="arm:=right use_mock:=false"):
arm_debug_launch._validate_act_mode(arm, use_mock, True)
def test_act_recording_accepts_right_real_mode() -> None:
arm_debug_launch._validate_act_mode("right", False, True)
arm_debug_launch._validate_act_mode("both", True, False)
```
- [ ] **步骤 2:运行 launch 测试并确认失败**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_bringup/test/test_arm_debug_launch.py -v
```
预期:失败,因为 `record_act``_validate_act_mode` 尚不存在。
- [ ] **步骤 3:添加完整采集配置**
创建 `act_tomato_pick.yaml`
```yaml
act_episode_recorder:
ros__parameters:
output_root: /home/robot/ACT_Data
task_name: tomato_pick
control_sample_topic: /xr_rm/right_rm75/act_control_sample
right_controller_topic: /xr/right_controller
left_controller_topic: /xr/left_controller
status_topic: /act/recording_status
cam_high_serial: "234222303366"
cam_high_model: D455
cam_right_wrist_serial: "412622272532"
cam_right_wrist_model: D405
image_width: 640
image_height: 480
camera_fps: 30
camera_warmup_sec: 5.0
control_rate_hz: 90.0
sample_rate_hz: 30.0
min_samples: 60
max_samples: 1800
min_control_hz: 27.0
max_control_gap_ms: 100.0
min_camera_fps: 27.0
max_drop_ratio: 0.01
max_feedback_age_ms: 50.0
max_camera_age_ms: 50.0
max_camera_skew_ms: 50.0
min_free_space_gib: 4.0
y_hold_sec: 1.0
gripper_completion_timeout_sec: 3.0
writer_queue_size: 8
```
参数校验明确要求控制频率可以被采样频率整除且比值为 3,图像尺寸和通道必须与
固定 HDF5 schema 一致。
- [ ] **步骤 4:增加 launch 分支**
声明默认关闭参数并增加校验:
```python
def _validate_act_mode(arm: str, use_mock: bool, record_act: bool) -> None:
if record_act and (arm != "right" or use_mock):
raise ValueError(
"record_act:=true requires arm:=right use_mock:=false"
)
```
`_act_recorder_node()` 使用现有 `XR_PYTHON` prefix、专用 YAML 和固定节点名
`act_episode_recorder`。只有 `record_act=true` 才把该节点加入列表。不要为采集节点
设置 `on_exit=Shutdown`;UDP 接收器原有退出联动保持不变。
- [ ] **步骤 5:运行 launch 测试和参数展示**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_bringup/test/test_arm_debug_launch.py -v
colcon build --symlink-install --packages-select \
xr_rm_interfaces xr_rm_teleop xr_rm_bringup
source install/setup.bash
ros2 launch xr_rm_bringup arm_debug.launch.py --show-args
```
预期:测试和构建通过,参数列表显示 `record_act` 默认 `false`。该命令只展示参数,
不启动真机节点。
- [ ] **步骤 6:提交任务 9**
```bash
git add src/xr_rm_bringup/config/act_tomato_pick.yaml \
src/xr_rm_bringup/launch/arm_debug.launch.py \
src/xr_rm_bringup/test/test_arm_debug_launch.py
git commit -m "feat: 接入番茄采摘ACT采集启动项"
```
### 任务 10:完整回归验证和真机验收交接
**文件:**
- 验证:本计划涉及的全部文件
- 不修改:训练代码、系统 Python、真机安全配置
- [ ] **步骤 1:运行格式和变更范围检查**
```bash
cd /home/robot/WS_xr/src
git diff --check
git status --short
```
预期:`git diff --check` 无输出;状态只包含本规格和计划列出的文件,不包含
`build/``install/``log/` 或无关格式化改动。
- [ ] **步骤 2:运行全部相关 Python 测试**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
source install/setup.bash
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_act_control_sample.py \
src/xr_rm_teleop/test/test_act_episode_recorder.py \
src/xr_rm_teleop/test/test_initial_joint_pose.py \
src/xr_rm_teleop/test/test_joint_control.py \
src/xr_rm_teleop/test/test_orientation_control.py \
src/xr_rm_teleop/test/test_placo_transforms.py \
src/xr_rm_bringup/test/test_arm_debug_launch.py -v
```
预期:全部通过。若发现任务开始前就存在的失败,记录完整命令和失败输出,不能修改
无关逻辑掩盖它。
- [ ] **步骤 3:从工作空间根目录完成全量构建**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
colcon build --symlink-install
```
预期:所有包构建成功,源码目录中不生成 `build/``install/``log/`
- [ ] **步骤 4:运行不连接硬件的接口与启动检查**
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
source install/setup.bash
ros2 interface show xr_rm_interfaces/msg/ActControlSample
ros2 launch xr_rm_bringup arm_debug.launch.py --show-args
```
预期:接口字段完整,`record_act` 默认关闭。不要在自动验证中运行
`use_mock:=false`
- [ ] **步骤 5:复核 HDF5 合成产物**
使用测试生成的临时文件或单独的 `tempfile.TemporaryDirectory()` 调用
`EpisodeStore` 写 60 帧,再用 h5py 断言:
```python
assert root["observations/qpos"].shape == (60, 8)
assert root["action"].shape == (60, 8)
assert root["observations/images/cam_high"].shape == (60, 480, 640, 3)
assert root["observations/images/cam_right_wrist"].shape == (60, 480, 640, 3)
assert root.attrs["sim"] == np.bool_(False)
assert root.attrs["action_alignment"] == "same_step_causal"
```
预期:质量报告接受该文件;不存在 qvel、effort 和压缩字段。
- [ ] **步骤 6:提交最终验证修正(仅在确有必要时)**
如果步骤 1 至 5 暴露了本功能范围内的问题,先补失败测试、做最小修正并重跑对应
验证,再提交:
```bash
git add src/xr_rm_interfaces/msg/ActControlSample.msg \
src/xr_rm_interfaces/CMakeLists.txt \
src/xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py \
src/xr_rm_teleop/xr_rm_teleop/fun_peripheral.py \
src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py \
src/xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py \
src/xr_rm_teleop/setup.py \
src/xr_rm_bringup/config/peripherals_rm75.yaml \
src/xr_rm_bringup/config/act_tomato_pick.yaml \
src/xr_rm_bringup/launch/arm_debug.launch.py \
src/xr_rm_teleop/test/test_initial_joint_pose.py \
src/xr_rm_teleop/test/test_joint_control.py \
src/xr_rm_teleop/test/test_orientation_control.py \
src/xr_rm_teleop/test/test_placo_transforms.py \
src/xr_rm_teleop/test/test_act_control_sample.py \
src/xr_rm_teleop/test/test_act_episode_recorder.py \
src/xr_rm_bringup/test/test_arm_debug_launch.py
git commit -m "fix: 修正ACT采集集成问题"
```
如果没有产生修正,不创建空提交。
- [ ] **步骤 7:向用户交接真机手工验收命令,不自行执行**
只有用户明确授权连接真机后,才可运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
source install/setup.bash
ros2 launch xr_rm_bringup arm_debug.launch.py \
arm:=right use_mock:=false record_act:=true
```
手工验收顺序固定为:确认夹爪初始化打开 → 确认两路相机角色 → B 进入 ARMED →
Grip 完成采摘/释放 → 确认夹爪逻辑 open → 松 Grip → B 保存 → 等待 SAVED/IDLE →
A 回初始位姿。另行验证 Y 丢弃、A 误触拒绝、QP 短暂失败、重启编号延续和 Ctrl+C
中断文件。任何真机异常继续由现有安全停止流程处理。