Author SHA1 Message Date
YikaiFu-cart 05bed64c46 fix: 调整peripheral_cfg函数中的目标力矩设置值 2026-08-13 22:45:56 +08:00
YikaiFu-cart 79e7c12989 docs: 更新ACT相机预览说明 2026-08-13 22:19:22 +08:00
YikaiFu-cart 235ba61454 feat: 添加ACT双相机实时预览 2026-08-13 22:18:32 +08:00
YikaiFu-cart 676b33bfe4 fix: 修正ACT相机采样质量判定 2026-08-13 22:12:23 +08:00
YikaiFu-cart 8329c6a44d docs: 规划ACT相机预览与质量判定修正 2026-08-13 22:09:53 +08:00
YikaiFu-cart 94e1bf9467 feat: 更新右臂和左臂配置,禁用奇异性避免;增强ACT数据采集功能,添加日志记录 2026-08-13 16:58:54 +08:00
YikaiFu-cart 2d89fe1820 feat: 更新.gitignore和README.md 2026-08-11 12:40:16 +08:00
YikaiFu-cart e20c5a983e fix: 拒绝ACT episode编号冲突 2026-08-10 18:26:38 +08:00
YikaiFu-cart 6982620041 feat: 接入番茄采摘ACT采集启动项 2026-08-10 18:24:02 +08:00
YikaiFu-cart 6008e34b5d feat: 集成ACT episode采集节点 2026-08-10 18:22:35 +08:00
YikaiFu-cart c1ea1a2816 feat: 校验ACT episode数据质量 2026-08-10 18:14:10 +08:00
YikaiFu-cart 40be5560ee feat: 流式保存ACT HDF5数据 2026-08-10 18:11:32 +08:00
YikaiFu-cart 1ec74f107b feat: 对齐ACT双相机帧 2026-08-10 18:06:45 +08:00
YikaiFu-cart deeee076d7 feat: 添加ACT录制状态机 2026-08-10 18:03:25 +08:00
YikaiFu-cart e60d620dfe feat: 发布同周期ACT控制样本 2026-08-10 18:01:05 +08:00
YikaiFu-cart 043d3d0533 feat: 记录夹爪逻辑执行状态 2026-08-10 17:57:07 +08:00
YikaiFu-cart 7b2caf3012 feat: 添加ACT原子控制消息 2026-08-10 17:54:45 +08:00
YikaiFu-cart c62d69e9ab docs: 添加ACT数据采集规格与计划 2026-08-10 17:30:05 +08:00
YikaiFu-cart f6b484d168 feat: 添加多摄像头测试工具,支持实时预览和快照功能 2026-08-06 15:01:17 +08:00
YikaiFu-cart bb672e3f39 fix: 修改 RealManAdapter 中对机械臂的引用,确保正确获取关节状态 2026-08-05 15:59:06 +08:00
48 changed files with 7621 additions and 27793 deletions
+3 -1
View File
@@ -44,4 +44,6 @@ AMENT_IGNORE
*.vsix *.vsix
.codex .codex
.worktrees/
# RealSense camera test snapshots
/xr_rm_bringup/test/camera_test_output/
+1 -1
View File
@@ -288,7 +288,7 @@ test: 添加 xxx 测试
## 项目专属规则 ## 项目专属规则
* 本项目面向 Ubuntu 22.04 和 ROS2 Humble构建、测试和运行命令应在工作空间根目录 `/home/robot/WS_xr` 执行,并先 `source /opt/ros/humble/setup.bash` * 本项目面向 Ubuntu 22.04 和 ROS2 Humble编译前必须先切换到工作空间根目录 `/home/robot/WS_xr`,并执行 `source /opt/ros/humble/setup.bash`禁止在 `/home/robot/WS_xr/src` 中运行 `colcon build`,否则会在源码目录生成多余的 `build/``install/``log/`;测试和运行命令也应在工作空间根目录执行。
* 工作空间包含 `xr_rm_input``xr_rm_teleop` 两个 `ament_python` 包,以及 `xr_rm_interfaces``xr_rm_bringup` 两个 `ament_cmake` 包;优先使用现有 ROS2 包、节点和消息,不要另建重复入口。 * 工作空间包含 `xr_rm_input``xr_rm_teleop` 两个 `ament_python` 包,以及 `xr_rm_interfaces``xr_rm_bringup` 两个 `ament_cmake` 包;优先使用现有 ROS2 包、节点和消息,不要另建重复入口。
* 修改 ROS 节点、launch、消息定义或安装配置后,至少运行 `colcon build --symlink-install`;涉及遥操作姿态控制时,再运行 `pytest src/xr_rm_teleop/test/test_orientation_control.py` * 修改 ROS 节点、launch、消息定义或安装配置后,至少运行 `colcon build --symlink-install`;涉及遥操作姿态控制时,再运行 `pytest src/xr_rm_teleop/test/test_orientation_control.py`
* 遥操作统一使用 `xr_rm_bringup/launch/arm_debug.launch.py`;调试和验证默认使用 `use_mock:=true`,未经用户明确要求不得连接真机、移动机械臂或操作夹爪。 * 遥操作统一使用 `xr_rm_bringup/launch/arm_debug.launch.py`;调试和验证默认使用 `use_mock:=true`,未经用户明确要求不得连接真机、移动机械臂或操作夹爪。
+64 -9
View File
@@ -1,7 +1,7 @@
# XR-RM75 双臂遥操作 # XR-RM75 双臂遥操作
基于 **Ubuntu 22.04、ROS2 Humble、PICO 4 Ultra 和睿尔曼 RM75** 的双臂 XR 遥操作 基于 **Ubuntu 22.04、ROS2 Humble、PICO 4 Ultra 和睿尔曼 RM75** 的双臂 XR 遥操作
工作空间,支持单臂/双臂 Mock 与真机控制,以及 MuJoCo 运动学显示。 工作空间,支持单臂/双臂 Mock 与真机MuJoCo 显示和右臂番茄采摘 ACT 数据采集
> [!WARNING] > [!WARNING]
> 真机命令会连接并控制机械臂。首次运行必须从 Mock 和单臂低速验证开始,确保急停 > 真机命令会连接并控制机械臂。首次运行必须从 Mock 和单臂低速验证开始,确保急停
@@ -10,11 +10,11 @@
## 当前能力 ## 当前能力
- PICO/XR 双手柄 UDP 输入,相对位姿目标与 Placo QP 七关节控制。 - PICO/XR 双手柄 UDP 输入,相对位姿目标与 Placo QP 七关节控制。
- 单臂/双臂 Mock 与真机、手柄/话题夹爪控制,以及只读 MuJoCo 双臂显示。 - 单臂/双臂 Mock 与真机、夹爪开合,以及只读 MuJoCo 双臂显示。
- 工作空间/圆柱限位、速度限制、指令超时和安全慢停。 - 工作空间/圆柱限位、速度限制、指令超时和安全慢停。
- 统一 launch、Tkinter 启动面板、调试话题和 Mock 输入工具 - 三路 RealSense 链路测试与右臂 ACT/ALOHA 风格 HDF5 采集
尚未完成:D405/D435 视频流、数据记录、相机标定、目标检测、双臂碰撞避障、任务级状态机,以及 PICO 与 ROS 的完整时间同步和状态回传。 尚未完成:左腕 D405 的 ACT 接入、相机 ROS launch/TF/标定、目标检测、双臂碰撞避障、任务级状态机,以及 PICO 与 ROS 的完整时间同步和状态回传。
## 系统架构 ## 系统架构
@@ -27,11 +27,14 @@ PICO / XRoboToolkit
-> 相对 TCP 目标 + Placo QP -> 相对 TCP 目标 + Placo QP
-> Mock 或 RM75 rm_movej_canfd -> Mock 或 RM75 rm_movej_canfd
-> joint_states / 调试话题 -> joint_states / 调试话题
-> 可选 xr_rm_mujoco/dual_arm_simulator ├── xr_rm_mujoco/dual_arm_simulator
└── ActControlSample + D455/D405
-> act_episode_recorder
-> episode_<编号>.hdf5
``` ```
工作空间包含五个 ROS2 包:`xr_rm_input` 负责手柄输入,`xr_rm_interfaces` 定义消息, 工作空间包含五个 ROS2 包:`xr_rm_input` 负责手柄输入,`xr_rm_interfaces` 定义消息,
`xr_rm_teleop` 实现遥操作与真机适配`xr_rm_bringup` 提供启动和配置, `xr_rm_teleop` 实现控制与 ACT 录制`xr_rm_bringup` 提供启动和配置,
`xr_rm_mujoco` 负责只读运动学显示。 `xr_rm_mujoco` 负责只读运动学显示。
`single_arm_velocity_teleop` 每个实例只控制一台机械臂;双臂模式分别启动 `left_arm_teleop``right_arm_teleop` `single_arm_velocity_teleop` 每个实例只控制一台机械臂;双臂模式分别启动 `left_arm_teleop``right_arm_teleop`
@@ -49,11 +52,12 @@ colcon build --symlink-install
source install/setup.bash source install/setup.bash
``` ```
遥操作MuJoCo 节点固定使用 `/home/robot/miniconda3/envs/xr/bin/python` 遥操作MuJoCo 和 ACT 节点固定使用 `/home/robot/miniconda3/envs/xr/bin/python`
其中固定 Placo 0.9.4、Pinocchio 3.7.0、NumPy 2.2.6 和 MuJoCo 3.10.0;不要从 其中固定 Placo 0.9.4、Pinocchio 3.7.0、NumPy 2.2.6 和 MuJoCo 3.10.0;不要从
用户或系统 Python 覆盖这些版本。 用户或系统 Python 覆盖这些版本。
真机模式另需睿尔曼 Python API2Mock 模式不依赖厂商 SDK。 真机模式另需睿尔曼 Python API2。ACT 采集需要 `h5py``pyrealsense2`
三相机测试还需要 OpenCV。Mock 模式不依赖厂商 SDK。
## 快速开始 ## 快速开始
@@ -121,7 +125,56 @@ ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false
``` ```
按住 `grip` 控制对应机械臂;松开后停止。点击 `trigger` 切换对应夹爪开/关。 按住 `grip` 控制对应机械臂;松开后停止。点击 `trigger` 切换对应夹爪开/关。
左手 X、右手 A 会请求对应机械臂回到配置的初始位姿;真机使用前必须清空安全区。
## RealSense 与 ACT 采集
### 三相机测试
连接两台 D405 和一台 D455 后执行:
```bash
/home/robot/miniconda3/envs/xr/bin/python \
src/xr_rm_bringup/tools/realsense_multi_camera_test.py
```
默认将序列号 `260322272273` 识别为左腕 D405,另一台 D405 为右腕,D455 为
全局相机。按 `S` 保存三路快照,按 `Q``Esc` 退出并打印链路汇总。
快照目录为 `src/xr_rm_bringup/test/camera_test_output/`
### ACT episode
ACT 目前仅支持右臂真机。以下命令会连接并控制右侧 RM75:
```bash
ros2 launch xr_rm_bringup arm_debug.launch.py \
arm:=right use_mock:=false record_act:=true
```
`record_act:=true` 启动后默认显示全局 D455 和右腕 D405 双路画面,并显示实时
FPS、真实丢帧率、帧龄、双相机时间差、录制状态、episode 编号和样本数。按
`Q``Esc` 或关闭窗口只会停止预览,ACT 相机采集和录制继续运行;没有桌面环境
或 OpenCV 显示失败时也不会影响录制。
相机采集线程观察到的真实掉帧仍会拒绝 episode。独立 `30 Hz` 控制和相机时钟
造成的 ACT 样本重复/跨帧只写入 HDF5 质量指标,不再误报为相机丢包。
默认配置位于 `xr_rm_bringup/config/act_tomato_pick.yaml`D455 序列号
`234222303366`,右腕 D405 序列号 `412622272532`90 Hz 控制数据下采样为
30 Hz。输出位于 `/home/robot/ACT_Data/tomato_pick/`,状态发布到
`/act/recording_status`
录制流程:
1. 打开夹爪并松开右手 `grip`
2. 点击右手 B 完成预检并进入 `ARMED`
3. 按住右手 `grip` 开始遥操作和录制。
4. 松开 `grip`,点击右手 B 保存。
`ARMED` 或录制期间长按左手 Y 一秒可丢弃当前 episode。录制期间点击右手 A
会触发初始化位姿并使当前数据无效。
预检和保存会检查控制连续性、反馈有效性、夹爪状态、磁盘空间、相机帧率/掉帧、
帧龄和双相机时间差。不合格或中断的数据保存在 `rejected/`
## Launch 参数 ## Launch 参数
@@ -132,6 +185,7 @@ ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false
| `arm` | `right` | `left``right``both` | | `arm` | `right` | `left``right``both` |
| `use_mock` | `true` | `false` 会连接真机 | | `use_mock` | `true` | `false` 会连接真机 |
| `use_mujoco` | `false` | 仅支持 `arm:=both` | | `use_mujoco` | `false` | 仅支持 `arm:=both` |
| `record_act` | `false` | 仅支持 `arm:=right use_mock:=false` |
| `udp_host` | `0.0.0.0` | UDP 监听地址 | | `udp_host` | `0.0.0.0` | UDP 监听地址 |
| `udp_port` | `15000` | UDP 监听端口 | | `udp_port` | `15000` | UDP 监听端口 |
| `udp_timer_hz` | `200.0` | UDP receiver 轮询频率 | | `udp_timer_hz` | `200.0` | UDP receiver 轮询频率 |
@@ -145,6 +199,7 @@ ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false
| `right_arm_rm75.yaml` | 右臂单独调试 | | `right_arm_rm75.yaml` | 右臂单独调试 |
| `peripherals_rm75.yaml` | 工具坐标、负载和末端执行器 | | `peripherals_rm75.yaml` | 工具坐标、负载和末端执行器 |
| `dual_arm_mujoco.yaml` | MuJoCo 刷新频率 | | `dual_arm_mujoco.yaml` | MuJoCo 刷新频率 |
| `act_tomato_pick.yaml` | ACT 相机、存储与质量阈值 |
修改某一侧控制参数时,同时检查单臂和双臂 YAML 是否需要同步。必须保留工作空间/ 修改某一侧控制参数时,同时检查单臂和双臂 YAML 是否需要同步。必须保留工作空间/
圆柱限位、线速度与角速度限制、关节速度/加速度限制、指令超时和安全停止逻辑。 圆柱限位、线速度与角速度限制、关节速度/加速度限制、指令超时和安全停止逻辑。
File diff suppressed because it is too large Load Diff
@@ -1,406 +0,0 @@
# RM75 双臂 J3 参考角仿真标定实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 使用当前双臂 URDF、Placo QP 和 MuJoCo 运动学模型运行可复现的左右臂 J3 参考角粗扫与细扫,并输出评分、稳定区间和推荐角度。
**Architecture:** 新增一个仅供离线实验使用的脚本,负责生成 18 条严格六维 TCP 轨迹、建立三类 QP 试验配置、运行候选角度扫描、计算硬门槛与并列评分,并生成 CSV/JSON/Markdown 结果。生产控制器、QP 求解器和 YAML 均不修改;测试只覆盖轨迹、评分和一个真实 Placo/MuJoCo 冒烟评估。
**Tech Stack:** Python 3.10、NumPy、Placo 0.9.4、MuJoCo 3.10、pytest、ROS2 Humble 工作空间。
---
## 文件结构
- 新增 `xr_rm_teleop/test/j3_reference_calibration.py`:离线轨迹生成、QP/MuJoCo 评估、评分、结果输出和命令行入口。
- 新增 `xr_rm_teleop/test/test_j3_reference_calibration.py`:轨迹端点、严格姿态插值、百分位评分、平台选择和真实模型冒烟测试。
- 生成 `docs/superpowers/results/2026-08-12-rm75-j3-calibration/summary.csv`:候选角度汇总。
- 生成 `docs/superpowers/results/2026-08-12-rm75-j3-calibration/trajectories.csv`:逐轨迹指标。
- 生成 `docs/superpowers/results/2026-08-12-rm75-j3-calibration/result.json`:机器可读结果。
- 生成 `docs/superpowers/results/2026-08-12-rm75-j3-calibration/report.md`:左右臂推荐角度、平台区间、基线对比和最差轨迹。
### Task 1:用失败测试固定轨迹与评分行为
**Files:**
- Create: `xr_rm_teleop/test/test_j3_reference_calibration.py`
- Create: `xr_rm_teleop/test/j3_reference_calibration.py`
- [ ] **Step 1:写轨迹生成失败测试**
测试使用以下公开接口:
```python
def build_task_trajectories(
initial_world_pose: np.ndarray,
control_rate_hz: float = 90.0,
max_linear_speed: float = 0.15,
max_angular_speed: float = 0.5,
) -> list[Trajectory]:
...
```
断言:
```python
def test_build_task_trajectories_creates_nine_strict_6d_routes() -> None:
initial = np.eye(4)
initial[:3, 3] = [0.35, 0.20, 0.10]
trajectories = build_task_trajectories(initial)
assert len(trajectories) == 9
assert {(route.harvest_y, route.harvest_z) for route in trajectories} == {
(y, z)
for y in (0.30, 0.40, 0.50)
for z in (-0.30, -0.20, -0.10)
}
for route in trajectories:
assert np.allclose(route.poses[0], initial)
assert np.allclose(route.poses[-1], initial)
basket = route.waypoints[5]
assert basket[0, 3] == pytest.approx(initial[0, 3])
assert basket[1, 3] == pytest.approx(initial[1, 3])
assert basket[2, 3] == pytest.approx(initial[2, 3] - 0.40)
assert basket[:3, 2] == pytest.approx([0.0, 0.0, -1.0], abs=1e-6)
```
- [ ] **Step 2:写评分和平台选择失败测试**
公开接口:
```python
def rank_candidates(rows: list[CandidateMetrics]) -> list[CandidateMetrics]:
...
def choose_stable_platform(
ranked: list[CandidateMetrics],
scan_step_deg: float,
) -> tuple[float, tuple[float, float]]:
...
```
测试构造三个硬门槛相同的候选,断言评分严格等于:
```python
score = (
0.45 * r_sigma
+ 0.20 * r_q4
+ 0.20 * r_elbow
+ 0.10 * r_smooth
+ 0.05 * r_track
)
```
并断言连续候选均达到最高分的 98% 时返回平台中点,而不是孤立端点。
- [ ] **Step 3:运行测试并确认按预期失败**
Run:
```bash
source /opt/ros/humble/setup.bash
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_j3_reference_calibration.py -q
```
Expected: FAIL,原因是 `j3_reference_calibration` 或公开函数尚不存在。
### Task 2:实现最小轨迹与评分模块
**Files:**
- Create: `xr_rm_teleop/test/j3_reference_calibration.py`
- Test: `xr_rm_teleop/test/test_j3_reference_calibration.py`
- [ ] **Step 1:实现旋转和 SE(3) 插值**
只使用 NumPy 和标准库:
```python
def rotation_angle(rotation: np.ndarray) -> float:
cosine = np.clip((np.trace(rotation) - 1.0) * 0.5, -1.0, 1.0)
return float(math.acos(cosine))
def rotation_vector(rotation: np.ndarray) -> np.ndarray:
angle = rotation_angle(rotation)
if angle <= 1e-12:
return np.zeros(3)
axis = np.array([
rotation[2, 1] - rotation[1, 2],
rotation[0, 2] - rotation[2, 0],
rotation[1, 0] - rotation[0, 1],
]) / (2.0 * math.sin(angle))
return axis * angle
def interpolate_pose(start: np.ndarray, end: np.ndarray, count: int) -> list[np.ndarray]:
relative = end[:3, :3] @ start[:3, :3].T
vector = rotation_vector(relative)
return [
make_pose(
start[:3, 3] + alpha * (end[:3, 3] - start[:3, 3]),
so3_exp(alpha * vector) @ start[:3, :3],
)
for alpha in np.linspace(0.0, 1.0, count + 1)[1:]
]
```
`rotation_vector` 对接近 180° 的情况使用特征向量兜底,避免工具朝下转换产生除零。
- [ ] **Step 2:实现九条本侧完整轨迹**
定义不可变数据类:
```python
@dataclass(frozen=True)
class Trajectory:
name: str
harvest_y: float
harvest_z: float
waypoints: tuple[np.ndarray, ...]
poses: tuple[np.ndarray, ...]
```
航点固定为:初始、预接近、采摘、预接近、筐上方、筐内、筐上方、初始。每段点数为:
```python
duration = max(
translation_distance / max_linear_speed,
rotation_distance / max_angular_speed,
)
steps = max(1, math.ceil(duration * control_rate_hz))
```
- [ ] **Step 3:实现百分位排名和并列评分**
同值获得同一百分位,单一取值获得 1.0。平滑性和跟踪排名分别定义为:
```python
r_smooth = 0.5 * rank_low(motion_cost) + 0.5 * rank_low(max_joint_speed)
r_track = 0.5 * rank_low(max_position_error) + 0.5 * rank_low(max_orientation_error)
```
硬门槛按 `(N_fail, -N_complete)` 字典序先筛选;只有满足位置误差、姿态误差、J4、
关节限位、速度和跳变条件的候选进入综合评分。
- [ ] **Step 4:运行测试确认通过**
Run:
```bash
source /opt/ros/humble/setup.bash
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_j3_reference_calibration.py -q
```
Expected: 轨迹与评分测试 PASS。
### Task 3:用失败测试固定真实 Placo/MuJoCo 单轨迹评估
**Files:**
- Modify: `xr_rm_teleop/test/test_j3_reference_calibration.py`
- Modify: `xr_rm_teleop/test/j3_reference_calibration.py`
- [ ] **Step 1:写真实模型冒烟失败测试**
接口:
```python
def evaluate_trajectory(
arm: str,
trajectory: Trajectory,
variant: Variant,
urdf_path: Path,
initial_joint_degrees: tuple[float, ...],
) -> TrajectoryMetrics:
...
```
使用左臂从初始 TCP 沿公共 `+Y` 移动 1 mm 的两点轨迹,断言:
```python
assert metrics.cycles == 2
assert metrics.failures == 0
assert math.isfinite(metrics.min_sigma)
assert metrics.min_q4_margin_deg > 0.0
assert metrics.max_position_error_m <= 2e-3
assert metrics.max_orientation_error_rad <= 5e-3
```
测试还将求得的七关节状态写入 `DualArmKinematicModel` 并断言按名称读回一致。
- [ ] **Step 2:运行冒烟测试并确认按预期失败**
Run:
```bash
source /opt/ros/humble/setup.bash
PYTHONPATH=src/xr_rm_teleop:src/xr_rm_mujoco \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_j3_reference_calibration.py::test_evaluate_trajectory_uses_real_placo_and_mujoco -q
```
Expected: FAIL,原因是评估器尚未实现。
- [ ] **Step 3:实现三类 QP 变体**
```python
@dataclass(frozen=True)
class Variant:
name: str
q3_reference_deg: float | None
enable_manipulability: bool
q4_min_deg: float | None
```
- `original`:三个可选项均关闭;
- `manip_j4`:位置可操作度权重 `1e-4`J4 硬下限 10°;
- `q3_<angle>`:在 `manip_j4` 基础上加入 J3 软任务,权重 `1e-5`
J4 约束使用 Placo 0.9.4 的 `add_joint_space_half_spaces_constraint(A, b)`,构造
`-q4 <= -q4_min`。J3 使用 `add_joints_task()`;位置可操作度使用
`add_manipulability_task(tcp_frame, "position", 1.0)`
- [ ] **Step 4:实现逐周期评估与失败保持**
每个目标点前将上一有效关节状态同步给 Placo。求解失败时:
```python
failures += 1
solver.update_joint_state(last_valid_joints)
current_joints = last_valid_joints.copy()
```
不把失败后的 Placo 内部迭代状态带到下一周期。成功状态写入 MuJoCo,并记录六维
雅可比最小奇异值、J4 余量、肘部外展量、TCP 误差、关节速度和运动代价。
- [ ] **Step 5:运行全部标定脚本测试**
Run:
```bash
source /opt/ros/humble/setup.bash
PYTHONPATH=src/xr_rm_teleop:src/xr_rm_mujoco \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_j3_reference_calibration.py -q
```
Expected: 全部 PASS。
### Task 4:运行粗扫、细扫并生成结果
**Files:**
- Modify: `xr_rm_teleop/test/j3_reference_calibration.py`
- Generate: `docs/superpowers/results/2026-08-12-rm75-j3-calibration/*`
- [ ] **Step 1:实现命令行和结果输出**
命令行:
```bash
python j3_reference_calibration.py \
--urdf <path> \
--config <dual_arm_rm75.yaml> \
--output-dir <directory> \
--phase coarse|fine|all
```
粗扫结束后对每侧选择最高分候选,在其 ±10°、原扫描边界内以 2° 细扫。CSV 使用
`csv.DictWriter`JSON 使用 `json.dump`,Markdown 报告由同一汇总对象生成,不新增依赖。
- [ ] **Step 2:运行完整仿真标定**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTHONPATH=src/xr_rm_teleop:src/xr_rm_mujoco \
/home/robot/miniconda3/envs/xr/bin/python \
src/xr_rm_teleop/test/j3_reference_calibration.py \
--urdf src/xr_rm_teleop/models/dual_rm75/Dual_arm.urdf \
--config src/xr_rm_bringup/config/dual_arm_rm75.yaml \
--output-dir src/docs/superpowers/results/2026-08-12-rm75-j3-calibration \
--phase all
```
Expected: 左右臂粗扫和细扫完成;输出两个基线、全部候选、推荐角度和平台区间。
- [ ] **Step 3:检查结果完整性**
Run:
```bash
/home/robot/miniconda3/envs/xr/bin/python - <<'PY'
import json
from pathlib import Path
path = Path('src/docs/superpowers/results/2026-08-12-rm75-j3-calibration/result.json')
data = json.loads(path.read_text(encoding='utf-8'))
assert set(data['arms']) == {'left', 'right'}
for arm in data['arms'].values():
assert arm['coarse_candidates']
assert arm['fine_candidates']
assert arm['recommended_reference_deg'] is not None
assert len(arm['stable_interval_deg']) == 2
print('result integrity: OK')
PY
```
Expected: `result integrity: OK`
### Task 5:工作空间验证与结果复核
**Files:**
- Verify only.
- [ ] **Step 1:运行新增测试和相关现有测试**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTHONPATH=src/xr_rm_teleop:src/xr_rm_mujoco \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_j3_reference_calibration.py \
src/xr_rm_teleop/test/test_placo_transforms.py \
src/xr_rm_mujoco/test/test_dual_arm_simulator.py -q
```
Expected: 全部 PASS。
- [ ] **Step 2:按项目规则构建工作空间**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
colcon build --symlink-install
```
Expected: 相关 ROS2 包构建成功。
- [ ] **Step 3:运行姿态控制回归测试**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_orientation_control.py -q
```
Expected: 全部 PASS。
- [ ] **Step 4:人工复核结果报告**
确认:
- 每侧确有 9 条完整轨迹;
- `original``manip_j4` 和 J3 候选均存在;
- 推荐角来自硬门槛通过集合;
- 平台选择符合 98% 规则;
- 报告明确列出失败轨迹,且没有把失败更多的候选排到前面;
- 没有修改生产控制器和 YAML。
@@ -0,0 +1,756 @@
# ACT 双相机预览与采样质量判定修正实施计划
> **供代理执行者使用:** 必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`,逐项执行本计划。所有步骤使用复选框(`- [ ]`)跟踪。
**目标:** 保持 ACT 当前因果时间对齐,同时把真实相机丢帧与软件采样相位漂移分开判定,并在 ACT 数采启动时默认显示全局 D455 和右腕 D405 实时画面。
**架构:** 继续由 `act_episode_recorder` 独占两台 RealSense,并按每三个 `90 Hz` 控制周期选择不晚于控制时刻的最新图像。`CameraBuffer` 负责真实采集质量,HDF5 校验只记录 ACT 样本重复/跨帧率;同一节点内新增一个只读 OpenCV 预览线程,复用现有帧缓冲且不进入机器人控制链路。
**技术栈:** Ubuntu 22.04、ROS2 Humble、Python 3、rclpy、NumPy、h5py、pyrealsense2、OpenCV、pytest、HDF5。
---
## 文件结构
本次不新建 ROS 包或运行进程,文件职责保持如下:
- 修改 `xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py`:真实采集统计、ACT 采样相位指标、质量判定、双路预览及关闭顺序;
- 修改 `xr_rm_teleop/test/test_act_episode_recorder.py`:指标口径、误拒绝复现、帧号回退和预览隔离测试;
- 修改 `README.md`:说明 ACT 数采默认预览、显示内容和关闭行为。
不修改 `arm_debug.launch.py``launcher_ui.py`、ROS 消息、机器人控制节点和相机 YAML。工作区已有的 `xr_rm_teleop/xr_rm_teleop/fun_peripheral.py` 未提交改动属于用户,不加入本任务提交。
所有构建和测试命令从工作空间根目录执行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
```
自动化测试不得启动真机 launch、移动机械臂或操作夹爪。
### 任务 1:区分真实丢帧与 ACT 采样相位漂移
**文件:**
- 修改:`xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py:408-579`
- 修改:`xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py:595-657`
- 修改:`xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py:1472-1511`
- 测试:`xr_rm_teleop/test/test_act_episode_recorder.py:273-296`
- 测试:`xr_rm_teleop/test/test_act_episode_recorder.py:491-607`
- [ ] **步骤 1:写异步采样不拒绝和指标测试**
在测试导入中加入 `sample_frame_metrics`,并增加:
```python
def test_sample_frame_metrics_separate_repeats_skips_and_regressions():
repeat, skip, regression = sample_frame_metrics(
np.asarray((986, 986, 988), dtype=np.uint64)
)
assert repeat == pytest.approx(0.5)
assert skip == pytest.approx(0.5)
assert regression == 0
@requires_h5py
def test_validate_episode_accepts_async_camera_phase_drift(tmp_path):
path = _valid_episode(tmp_path)
with h5py.File(path, "r+") as root:
root["debug/cameras/cam_high_frame_number"][:] = (986, 986, 988)
report = validate_episode(path, _quality_limits())
assert report.accepted
assert report.metrics["cam_high_sample_repeat_ratio"] == pytest.approx(0.5)
assert report.metrics["cam_high_sample_skip_ratio"] == pytest.approx(0.5)
```
`_valid_episode()` 写入新增的真实采集属性:
```python
root.attrs["camera_high_frame_number_regression_count"] = 0
root.attrs["camera_right_wrist_frame_number_regression_count"] = 0
```
- [ ] **步骤 2:写真实帧号回退测试**
扩展现有 `CameraBuffer` 测试:
```python
def test_camera_buffer_counts_frame_number_regressions():
buffer = CameraBuffer(maxlen=4)
for frame_number in (100, 101, 1, 2):
buffer.push(
CameraFrame(
_image(frame_number),
frame_number,
float(frame_number),
time.monotonic_ns(),
)
)
stats = buffer.stats()
assert stats.frame_number_regression_count == 1
assert stats.dropped_frames == 0
```
测试文件顶部增加标准库导入:
```python
import time
```
并在稳定拒绝原因参数中增加真实采集帧号回退:
```python
elif mutation == "camera_frame_regression":
root.attrs["camera_high_frame_number_regression_count"] = 1
```
对应期望:
```python
("camera_frame_regression", "camera_frame_number_regression"),
```
- [ ] **步骤 3:运行新增测试并确认失败**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_act_episode_recorder.py \
-k "sample_frame_metrics or async_camera_phase_drift or frame_number_regressions or camera_frame_regression" -v
```
预期:测试因 `sample_frame_metrics` 尚不存在、`CameraStats` 没有回退计数,或旧校验仍以 `camera_sample_drop_ratio` 拒绝而失败。
- [ ] **步骤 4:实现最小采样相位指标**
在质量校验辅助函数附近增加纯函数:
```python
def sample_frame_metrics(
frame_numbers: np.ndarray,
) -> tuple[float, float, int]:
diffs = np.diff(np.asarray(frame_numbers, dtype=np.int64))
denominator = max(1, len(diffs))
return (
float(np.count_nonzero(diffs == 0) / denominator),
float(np.count_nonzero(diffs > 1) / denominator),
int(np.count_nonzero(diffs < 0)),
)
```
`validate_episode()` 中原有“所有 `diff != 1` 都拒绝”的循环替换为:
```python
for camera in ("cam_high", "cam_wrist"):
frame_numbers = datasets[
f"debug/cameras/{camera}_frame_number"
][:]
repeat_ratio, skip_ratio, regression_count = sample_frame_metrics(
frame_numbers
)
metrics[f"{camera}_sample_repeat_ratio"] = repeat_ratio
metrics[f"{camera}_sample_skip_ratio"] = skip_ratio
if regression_count:
return _quality_failure("camera_frame_number_regression", metrics)
```
这样 `986 → 986 → 988` 只产生统计,不再触发 `camera_sample_drop_ratio`
- [ ] **步骤 5:在采集层统计帧号回退**
扩展 `CameraStats`
```python
@dataclass(frozen=True)
class CameraStats:
frame_count: int
dropped_frames: int
frame_number_regression_count: int
first_host_monotonic_ns: int | None
last_host_monotonic_ns: int | None
```
`CameraBuffer.__init__()` 增加:
```python
self._frame_number_regression_count = 0
```
`CameraBuffer.push()` 更新帧号前使用互斥分支:
```python
if self._last_frame_number is not None:
if frame.frame_number <= self._last_frame_number:
self._frame_number_regression_count += 1
elif frame.frame_number > self._last_frame_number + 1:
self._dropped_frames += (
frame.frame_number - self._last_frame_number - 1
)
```
`stats()` 返回:
```python
frame_number_regression_count=self._frame_number_regression_count,
```
- [ ] **步骤 6:把真实采集回退纳入 episode 属性和拒绝条件**
`_interval_camera_metrics()` 的返回值扩展为 FPS、真实丢帧率和本 episode 新增的回退数:
```python
@staticmethod
def _interval_camera_metrics(
baseline: CameraStats,
current: CameraStats,
) -> tuple[float, float, int]:
frames = max(0, current.frame_count - baseline.frame_count)
dropped = max(0, current.dropped_frames - baseline.dropped_frames)
regressions = max(
0,
current.frame_number_regression_count
- baseline.frame_number_regression_count,
)
if (
baseline.last_host_monotonic_ns is None
or current.last_host_monotonic_ns is None
):
return 0.0, 1.0, regressions
elapsed_ns = (
current.last_host_monotonic_ns
- baseline.last_host_monotonic_ns
)
fps = frames * 1e9 / elapsed_ns if elapsed_ns > 0 else 0.0
expected = frames + dropped
drop_ratio = dropped / expected if expected else 1.0
return fps, drop_ratio, regressions
```
`_write_camera_metrics()` 写入:
```python
"camera_high_frame_number_regression_count": high[2],
"camera_right_wrist_frame_number_regression_count": wrist[2],
```
`validate_episode()` 在读取 FPS 和真实丢帧率后要求两个回退属性存在且为零:
```python
for name in (
"camera_high_frame_number_regression_count",
"camera_right_wrist_frame_number_regression_count",
):
if name not in root.attrs:
return _quality_failure("camera_stats_missing", metrics)
value = int(root.attrs[name])
metrics[name] = value
if value:
return _quality_failure("camera_frame_number_regression", metrics)
```
- [ ] **步骤 7:确保采样指标写入保存和拒绝文件**
增加读取 HDF5 根节点的辅助函数:
```python
def episode_sample_frame_metrics(root: Any) -> dict[str, int | float]:
metrics: dict[str, int | float] = {}
for camera in ("cam_high", "cam_wrist"):
repeat, skip, regression = sample_frame_metrics(
root[f"debug/cameras/{camera}_frame_number"][:]
)
metrics[f"{camera}_sample_repeat_ratio"] = repeat
metrics[f"{camera}_sample_skip_ratio"] = skip
metrics[f"{camera}_sample_frame_number_regression_count"] = regression
return metrics
```
`validate_episode()` 复用该函数更新 `report.metrics`。在 `_reject_closed_partial()` 已打开 HDF5 后也执行:
```python
for name, value in episode_sample_frame_metrics(root).items():
root.attrs[name] = value
```
保存路径继续由 `_complete_save()``report.metrics` 写入属性。保存日志追加紧凑摘要:
```python
self.get_logger().info(
"ACT相机采样相位:"
f"high重复={report.metrics['cam_high_sample_repeat_ratio']:.2%}, "
f"high跨帧={report.metrics['cam_high_sample_skip_ratio']:.2%}, "
f"wrist重复={report.metrics['cam_wrist_sample_repeat_ratio']:.2%}, "
f"wrist跨帧={report.metrics['cam_wrist_sample_skip_ratio']:.2%}"
)
```
- [ ] **步骤 8:运行相关测试并确认通过**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_act_episode_recorder.py \
-k "camera or validate_episode" -v
```
预期:所有选中测试通过;真实丢帧率仍使用 `camera_drop_ratio` 拒绝,异步重复/跨帧不拒绝。
- [ ] **步骤 9:提交任务 1**
```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 "fix: 修正ACT相机采样质量判定"
```
### 任务 2:在录制器中增加非阻塞双路预览
**文件:**
- 修改:`xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py:595-657`
- 修改:`xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py:1006-1152`
- 修改:`xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py:1668-1671`
- 测试:`xr_rm_teleop/test/test_act_episode_recorder.py`
- [ ] **步骤 1:写两秒滚动 FPS 测试**
扩展 `CameraBuffer` 测试,使用明确的单调时间:
```python
def test_camera_buffer_reports_two_second_rolling_fps():
buffer = CameraBuffer(maxlen=4)
start_ns = 10_000_000_000
for index in range(61):
buffer.push(
CameraFrame(
_image(index),
index,
float(index),
start_ns + index * 33_333_333,
)
)
assert buffer.stats().rolling_fps == pytest.approx(30.0, rel=0.02)
```
- [ ] **步骤 2:写预览显示异常隔离测试**
构造不经过 ROS 初始化的录制器和抛错的 `cv2` 替身:
```python
def test_preview_failure_does_not_change_recording_state():
recorder = object.__new__(ActEpisodeRecorder)
recorder._session = _recording_session()
recorder._preview_stop = threading.Event()
recorder._preview_thread = None
recorder.get_logger = lambda: _Logger()
class FailingCv2:
WINDOW_NORMAL = 0
@staticmethod
def namedWindow(*_args):
raise RuntimeError("no display")
recorder._preview_loop(FailingCv2())
assert recorder.state is RecordingState.RECORDING
```
`_Logger` 增加 `warns` 收集和 `warn()`
```python
self.warns = []
def warn(self, message):
self.warns.append(message)
```
- [ ] **步骤 3:写预览关闭顺序测试**
验证关闭节点时先停预览,再停相机:
```python
def test_close_stops_preview_before_cameras_and_releases_lock():
events = []
recorder = object.__new__(ActEpisodeRecorder)
recorder._stop_preview = lambda: events.append("preview")
recorder._high_camera = SimpleNamespace(
stop=lambda: events.append("high")
)
recorder._wrist_camera = SimpleNamespace(
stop=lambda: events.append("wrist")
)
recorder._directory_lock = SimpleNamespace(
release=lambda: events.append("lock")
)
recorder.close()
assert events == ["preview", "high", "wrist", "lock"]
```
- [ ] **步骤 4:运行新增预览测试并确认失败**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_act_episode_recorder.py \
-k "rolling_fps or preview or close_stops_preview" -v
```
预期:测试因 `rolling_fps``_preview_loop()``_stop_preview()` 尚不存在而失败。
- [ ] **步骤 5:实现两秒滚动 FPS**
`CameraBuffer.__init__()` 增加:
```python
self._recent_host_monotonic_ns: deque[int] = deque()
```
每次 `push()` 时裁剪两秒窗口:
```python
self._recent_host_monotonic_ns.append(frame.host_monotonic_ns)
cutoff_ns = frame.host_monotonic_ns - 2_000_000_000
while (
self._recent_host_monotonic_ns
and self._recent_host_monotonic_ns[0] < cutoff_ns
):
self._recent_host_monotonic_ns.popleft()
```
`CameraStats` 增加字段和属性:
```python
recent_host_monotonic_ns: tuple[int, ...]
@property
def rolling_fps(self) -> float:
if len(self.recent_host_monotonic_ns) < 2:
return 0.0
elapsed_ns = (
self.recent_host_monotonic_ns[-1]
- self.recent_host_monotonic_ns[0]
)
return (
(len(self.recent_host_monotonic_ns) - 1) * 1e9 / elapsed_ns
if elapsed_ns > 0
else 0.0
)
```
`stats()` 使用:
```python
recent_host_monotonic_ns=tuple(self._recent_host_monotonic_ns),
```
- [ ] **步骤 6:实现最小预览渲染方法**
`ActEpisodeRecorder` 增加固定窗口名:
```python
PREVIEW_WINDOW = "ACT - D455 Global / D405 Right Wrist"
```
增加生成单个画面块的方法。相机帧是 RGB,因此显示前转换成 BGR:
```python
@staticmethod
def _preview_tile(role: str, frame, stats, cv2):
if frame is None:
tile = np.zeros((480, 640, 3), dtype=np.uint8)
frame_number = "-"
else:
tile = cv2.cvtColor(frame.image, cv2.COLOR_RGB2BGR)
frame_number = str(frame.frame_number)
tile = tile.copy()
cv2.rectangle(tile, (0, 0), (640, 74), (0, 0, 0), -1)
lines = (
f"{role} FPS {stats.rolling_fps:.1f} Frame {frame_number}",
f"Received {stats.frame_count} Dropped "
f"{stats.dropped_frames} ({stats.drop_ratio:.2%})",
)
for index, line in enumerate(lines):
cv2.putText(
tile,
line,
(10, 28 + index * 30),
cv2.FONT_HERSHEY_SIMPLEX,
0.65,
(255, 255, 255),
1,
cv2.LINE_AA,
)
return tile
```
增加 `_compose_preview()`:读取两个缓冲最新帧,水平拼接,并在底部显示状态:
```python
def _compose_preview(self, cv2):
high_frames = self._high_camera.buffer.snapshot()
wrist_frames = self._wrist_camera.buffer.snapshot()
high = high_frames[-1] if high_frames else None
wrist = wrist_frames[-1] if wrist_frames else None
high_stats = self._high_camera.buffer.stats()
wrist_stats = self._wrist_camera.buffer.stats()
image = np.hstack(
(
self._preview_tile("GLOBAL D455", high, high_stats, cv2),
self._preview_tile("RIGHT WRIST D405", wrist, wrist_stats, cv2),
)
)
now_ns = self._now_ns()
high_age = (
f"{(now_ns - high.host_monotonic_ns) * 1e-6:.1f} ms"
if high is not None
else "-"
)
wrist_age = (
f"{(now_ns - wrist.host_monotonic_ns) * 1e-6:.1f} ms"
if wrist is not None
else "-"
)
skew = (
f"{abs(high.host_monotonic_ns - wrist.host_monotonic_ns) * 1e-6:.1f} ms"
if high is not None and wrist is not None
else "-"
)
episode = (
f"episode_{self._episode_index}"
if self._episode_index is not None
else "-"
)
samples = self._store.count if self._store is not None else 0
footer = np.zeros((80, image.shape[1], 3), dtype=np.uint8)
lines = (
f"Age high={high_age} wrist={wrist_age} Camera skew={skew}",
f"ACT {self.state.value} {episode} Samples {samples}",
)
for index, line in enumerate(lines):
cv2.putText(
footer,
line,
(10, 30 + index * 32),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
(255, 255, 255),
1,
cv2.LINE_AA,
)
return np.vstack((image, footer))
```
若尚无帧,对应数值显示 `-`;该方法不修改任何录制器状态。
- [ ] **步骤 7:实现预览线程生命周期和故障隔离**
在相机成员创建前初始化:
```python
self._preview_stop = threading.Event()
self._preview_thread: threading.Thread | None = None
```
两台相机成功启动后调用 `_start_preview()`
```python
if self._camera_start_error is None:
self._start_preview()
```
实现:
```python
def _start_preview(self) -> None:
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
self.get_logger().warn("未检测到桌面显示环境,ACT双相机预览已停用。")
return
try:
import cv2
except ImportError as exc:
self.get_logger().warn(f"OpenCV不可用,ACT双相机预览已停用:{exc}")
return
self._preview_stop.clear()
self._preview_thread = threading.Thread(
target=self._preview_loop,
args=(cv2,),
name="act_camera_preview",
daemon=True,
)
self._preview_thread.start()
def _preview_loop(self, cv2) -> None:
try:
cv2.namedWindow(self.PREVIEW_WINDOW, cv2.WINDOW_NORMAL)
while not self._preview_stop.is_set():
cv2.imshow(self.PREVIEW_WINDOW, self._compose_preview(cv2))
key = cv2.waitKey(1) & 0xFF
if key in (ord("q"), ord("Q"), 27):
break
if cv2.getWindowProperty(
self.PREVIEW_WINDOW,
cv2.WND_PROP_VISIBLE,
) < 1:
break
self._preview_stop.wait(0.1)
except Exception as exc:
self.get_logger().warn(f"ACT双相机预览已停用:{exc}")
finally:
try:
cv2.destroyWindow(self.PREVIEW_WINDOW)
except Exception:
pass
def _stop_preview(self) -> None:
self._preview_stop.set()
if self._preview_thread is not None:
self._preview_thread.join(timeout=2.0)
self._preview_thread = None
```
`close()` 的第一步调用 `self._stop_preview()`,然后保持现有相机停止和目录锁释放顺序。
- [ ] **步骤 8:运行预览相关测试并确认通过**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_act_episode_recorder.py \
-k "rolling_fps or preview or close_stops_preview" -v
```
预期:所有选中测试通过,测试过程不打开真实窗口或相机。
- [ ] **步骤 9:运行 ACT 录制器完整单元测试**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
pytest src/xr_rm_teleop/test/test_act_episode_recorder.py -v
```
预期:全部通过。
- [ ] **步骤 10:提交任务 2**
```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双相机实时预览"
```
### 任务 3:更新使用说明并完成工作空间验证
**文件:**
- 修改:`README.md:144-169`
- [ ] **步骤 1:更新 ACT 数采说明**
在 ACT episode 启动命令后补充:
```markdown
`record_act:=true` 启动后默认显示全局 D455 和右腕 D405 双路画面,并显示实时
FPS、真实丢帧率、帧龄、双相机时间差、录制状态、episode 编号和样本数。按
`Q``Esc` 或关闭窗口只会停止预览,ACT 相机采集和录制继续运行;没有桌面环境
或 OpenCV 显示失败时也不会影响录制。
相机采集线程观察到的真实掉帧仍会拒绝 episode。独立 `30 Hz` 控制和相机时钟
造成的 ACT 样本重复/跨帧只写入 HDF5 质量指标,不再误报为相机丢包。
```
- [ ] **步骤 2:运行文档和 Python 静态检查**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
python -m py_compile \
src/xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py \
src/xr_rm_teleop/test/test_act_episode_recorder.py
git diff --check
```
预期:命令返回码为 `0`,没有语法或空白错误。
- [ ] **步骤 3:运行项目要求的工作空间构建**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
colcon build --symlink-install
```
预期:所有包构建成功。不得在 `/home/robot/WS_xr/src` 中运行该命令。
- [ ] **步骤 4:构建后再次运行 ACT 录制器测试**
运行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
source install/setup.bash
pytest src/xr_rm_teleop/test/test_act_episode_recorder.py -v
```
预期:全部通过。
- [ ] **步骤 5:确认提交范围并提交任务 3**
运行:
```bash
git status --short
git diff -- README.md \
src/xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py \
src/xr_rm_teleop/test/test_act_episode_recorder.py
git add README.md
git commit -m "docs: 更新ACT相机预览说明"
```
不得暂存或提交 `xr_rm_teleop/xr_rm_teleop/fun_peripheral.py`
## 现场验证
自动化实施结束后,由用户在确认现场安全条件后运行现有 ACT 数采入口:
```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
```
该命令会连接右臂真机,代理不得自行执行。现场确认:
1. 双路画面默认出现且角色正确;
2. FPS、真实丢帧率、帧龄、双相机时间差和 ACT 状态持续更新;
3. 关闭窗口后录制状态和 HDF5 写入继续;
4. 正常异步重复/跨帧的 episode 能保存;
5. HDF5 属性包含真实采集质量与两路采样重复/跨帧指标。
@@ -1,372 +0,0 @@
# RM75 双臂采摘 QP 稳健性优化实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在当前双臂严格六维遥操作链路中实现 QP 失败参考状态保持、J3 初始姿态软引导、J4 硬下限与软缓冲,以及按六维奇异值动态启用的可操作度任务。
**Architecture:** 保留 Placo 相对六维位姿主任务和下游关节速度/加速度限制。遥操作层将滤波结果作为候选值,只有 QP 求解和关节发送都成功后才提交;QP 求解器复用 Placo 现有 joints、half-space 和 manipulability 任务,不新增求解框架或依赖。
**Tech Stack:** Python 3.10、ROS2 Humble、Placo 0.9.4、NumPy、pytest、ament/colcon。
---
## 文件结构
- 修改 `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`:QP 失败状态和笛卡尔参考状态提交。
- 修改 `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`:J3、J4、动态六维可操作度和失败状态恢复。
- 修改 `xr_rm_teleop/test/test_joint_control.py`:失败不发送、不提交和发送失败保持测试。
- 修改 `xr_rm_teleop/test/test_placo_transforms.py`:辅助任务参数、激活函数和真实模型测试。
- 修改 `xr_rm_teleop/test/test_initial_joint_pose.py`:三份 YAML 的 QP 参数一致性测试。
- 修改 `xr_rm_bringup/config/dual_arm_rm75.yaml`:左右臂独立 QP 参数。
- 修改 `xr_rm_bringup/config/left_arm_rm75.yaml`:左臂 QP 参数。
- 修改 `xr_rm_bringup/config/right_arm_rm75.yaml`:右臂 QP 参数。
### Task 1:QP 失败时不提交笛卡尔参考状态
**Files:**
- Modify: `xr_rm_teleop/test/test_joint_control.py`
- Modify: `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
- [ ] **Step 1:修改 QP 失败测试并增加候选滤波测试**
把现有失败测试改为要求 `_solve_joint_target()` 返回 `None`,同时增加位置和姿态滤波只计算候选、不直接修改已提交状态的断言:
```python
def test_qp_failure_returns_none_and_keeps_last_known_good_target() -> None:
...
target = teleop._solve_joint_target(np.eye(4))
assert target is None
assert teleop._last_valid_joint_target == pytest.approx([0.1] * 7)
def test_target_filters_do_not_commit_candidate_state() -> None:
teleop = object.__new__(SingleArmVelocityTeleop)
teleop._filtered_target = [0.0, 0.0, 0.0]
teleop._filtered_orientation_target = np.eye(3)
teleop._target_filter_alpha = 0.5
teleop._target_filter_alpha_fast = 0.5
teleop._target_filter_fast_threshold_m = 1.0
teleop._orientation_filter_alpha = 0.5
position = teleop._filter_target([0.2, 0.0, 0.0])
orientation = teleop._filter_orientation_target(
_so3_exp(np.asarray([0.0, 0.0, 0.2]))
)
assert position == pytest.approx([0.1, 0.0, 0.0])
assert teleop._filtered_target == pytest.approx([0.0, 0.0, 0.0])
assert teleop._filtered_orientation_target == pytest.approx(np.eye(3))
assert np.linalg.norm(_so3_log(orientation)) == pytest.approx(0.1)
```
- [ ] **Step 2:运行新测试并确认按预期失败**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONPATH=src/xr_rm_teleop:$PYTHONPATH \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_joint_control.py \
-k 'qp_failure or target_filters_do_not_commit' -q
```
Expected: FAIL;当前失败路径仍返回旧关节数组,滤波函数会立即修改成员状态。
- [ ] **Step 3:实现最小失败保持逻辑**
修改 `_filter_target()``_filter_orientation_target()` 只返回候选值,不直接写成员。
修改 `_solve_joint_target()` 在异常时返回 `None`,成功时也不提前更新
`_last_valid_joint_target`。控制周期只在结果非空时发送,并在发送成功后统一提交:
```python
joint_target = self._solve_joint_target(target_pose)
sent = (
joint_target is not None
and self._send_joint_target(joint_target)
)
if sent:
self._last_valid_joint_target = list(joint_target)
self._filtered_target = list(filtered_target)
self._filtered_orientation_target = filtered_orientation.copy()
self._last_sent_target = sent_target
self._last_sent_orientation = sent_orientation.copy()
self._last_command_time = now
self._stop_sent = False
```
失败时不调用 `_send_joint_target()`,因此不会把旧关节保持动作伪装成新 QP 成功;已
存在的指令超时和反馈故障保持逻辑不改变。
- [ ] **Step 4:运行关节控制测试**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONPATH=src/xr_rm_teleop:$PYTHONPATH \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_joint_control.py -q
```
Expected: PASS。
### Task 2J3、J4 与动态六维可操作度
**Files:**
- Modify: `xr_rm_teleop/test/test_placo_transforms.py`
- Modify: `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`
- [ ] **Step 1:写辅助任务激活和参数失败测试**
增加纯激活函数测试:
```python
def test_lower_margin_activation_is_clamped_and_linear() -> None:
assert _lower_margin_activation(0.05, 0.01, 0.04) == 0.0
assert _lower_margin_activation(0.025, 0.01, 0.04) == pytest.approx(0.5)
assert _lower_margin_activation(0.005, 0.01, 0.04) == 1.0
```
增加真实左右臂求解器测试,构造时传入:
```python
solver = PlacoIkSolver(
str(DUAL_URDF_PATH),
1.0 / 90.0,
arm,
j3_reference_deg=j3_reference_deg,
j3_weight=1e-5,
j4_min_deg=10.0,
j4_warn_deg=25.0,
j4_weight=1e-4,
manipulability_sigma_stop=0.01,
manipulability_sigma_warn=0.04,
manipulability_weight=1e-4,
)
```
断言 J3 任务目标等于该侧参考角、J4 half-space 为 `-q4 <= -10°`,六维雅可比为
`6x7` 且奇异值有限。
- [ ] **Step 2:运行新测试并确认按预期失败**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONPATH=src/xr_rm_teleop:$PYTHONPATH \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_placo_transforms.py \
-k 'lower_margin_activation or auxiliary_qp_tasks' -q
```
Expected: FAIL;激活函数和构造参数尚不存在。
- [ ] **Step 3:实现 Placo 辅助任务**
新增 `_lower_margin_activation(value, stop, warn)`,并在构造器中验证有限参数及
`j4_warn > j4_min``sigma_warn > sigma_stop > 0`。复用 Placo 原生接口:
```python
self._j3_task = self._solver.add_joints_task()
self._j3_task.set_joints({self._joint_names[2]: np.deg2rad(j3_reference_deg)})
self._j3_task.configure("j3_reference", "soft", j3_weight)
self._j4_task = self._solver.add_joints_task()
self._j4_task.set_joints({self._joint_names[3]: np.deg2rad(j4_warn_deg)})
matrix = np.zeros((1, self._robot.state.q.size))
matrix[0, self._q_offsets[3]] = -1.0
self._j4_constraint = self._solver.add_joint_space_half_spaces_constraint(
matrix,
np.asarray([-np.deg2rad(j4_min_deg)]),
)
self._j4_constraint.configure("j4_lower_bound", "hard")
self._manipulability_task = self._solver.add_manipulability_task(
self._tcp_frame,
"both",
1.0,
)
```
每次数值迭代前,从 `frame_jacobian(..., "local_world_aligned")` 的当前臂 `6x7`
雅可比计算 `sigma_min`。J4 和可操作度任务分别使用线性夹紧激活系数重新配置软权重;
J3 权重使用节点传入的左右臂独立配置。启用 Placo 原生关节限位,保留现有速度限位
和结果校验。
- [ ] **Step 4:失败时恢复 Placo 到实际关节反馈**
`solve()` 入口保存实际关节状态;任何求解异常或 30 次未收敛时,将活动臂关节
恢复到 `_actual_joints` 并更新运动学后重新抛出异常。测试制造不收敛,断言内部活动
关节未停留在失败迭代结果。
- [ ] **Step 5:运行 Placo 测试**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONPATH=src/xr_rm_teleop:$PYTHONPATH \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_placo_transforms.py -q
```
Expected: PASS。
### Task 3:同步节点和三份控制配置
**Files:**
- Modify: `xr_rm_teleop/test/test_initial_joint_pose.py`
- Modify: `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
- Modify: `xr_rm_bringup/config/dual_arm_rm75.yaml`
- Modify: `xr_rm_bringup/config/left_arm_rm75.yaml`
- Modify: `xr_rm_bringup/config/right_arm_rm75.yaml`
- [ ] **Step 1:写三份 YAML 一致性失败测试**
扩展现有 YAML 参数化测试,断言左右臂分别为:
```python
expected = {
"left": {
"qp_j3_reference_deg": 67.96,
"qp_j3_weight": 1e-5,
},
"right": {
"qp_j3_reference_deg": -89.57,
"qp_j3_weight": 1e-4,
},
}
shared = {
"qp_j4_min_deg": 10.0,
"qp_j4_warn_deg": 25.0,
"qp_j4_weight": 1e-4,
"qp_manipulability_sigma_stop": 0.01,
"qp_manipulability_sigma_warn": 0.04,
"qp_manipulability_weight": 1e-4,
}
```
同时断言单臂 YAML 与双臂同侧节点值一致。
- [ ] **Step 2:运行配置测试并确认按预期失败**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONPATH=src/xr_rm_teleop:$PYTHONPATH \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_initial_joint_pose.py -q
```
Expected: FAILQP 参数尚未写入 YAML。
- [ ] **Step 3:声明、读取并传入 QP 参数**
节点声明上述八个 `qp_*` 参数,进行有限性和大小关系验证,并作为关键字参数传入
`PlacoIkSolver`。三份 YAML 同步写入相同共享参数,J3 只按左右臂设置不同参考角;
不修改 `configure_safety_limits``move_to_initial_pose_on_connect`
- [ ] **Step 4:运行配置和遥操作姿态测试**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONPATH=src/xr_rm_teleop:$PYTHONPATH \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test/test_initial_joint_pose.py \
src/xr_rm_teleop/test/test_orientation_control.py -q
```
Expected: PASS。
### Task 4:完整验证和本地提交
**Files:**
- Verify all modified files.
- [ ] **Step 1:运行遥操作包测试**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 PYTHONPATH=src/xr_rm_teleop:$PYTHONPATH \
/home/robot/miniconda3/envs/xr/bin/python -m pytest \
src/xr_rm_teleop/test -q
```
Expected: 全部 PASS,无失败。
- [ ] **Step 2:运行真实 URDF 左右臂 QP 冒烟测试**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
PYTHONPATH=src/xr_rm_teleop:$PYTHONPATH /home/robot/miniconda3/envs/xr/bin/python \
src/xr_rm_teleop/test/placo_ik_smoke.py \
src/xr_rm_teleop/models/dual_rm75/Dual_arm.urdf
```
Expected: 左右臂保持位姿漂移和 1 cm 六维 QP 冒烟断言均通过。
- [ ] **Step 3:构建 ROS2 工作空间**
Run:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
colcon build --symlink-install
```
Expected: 所有工作空间包构建成功。
- [ ] **Step 4:检查差异与安全配置**
Run:
```bash
cd /home/robot/WS_xr/src
git diff --check
git diff --stat
rg -n "configure_safety_limits: true|move_to_initial_pose_on_connect: false" \
xr_rm_bringup/config/{dual_arm_rm75,left_arm_rm75,right_arm_rm75}.yaml
```
Expected: 无空白错误,三份配置继续保留安全设置。
- [ ] **Step 5:创建本地提交**
规格文档和实施计划必须在同一个本地提交中;实现与测试一并纳入该提交,避免文档和
代码版本不一致:
```bash
git add \
docs/superpowers/specs/2026-08-13-rm75-qp-robustness-design.md \
docs/superpowers/plans/2026-08-13-rm75-qp-robustness.md \
xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py \
xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py \
xr_rm_teleop/test/test_joint_control.py \
xr_rm_teleop/test/test_placo_transforms.py \
xr_rm_teleop/test/test_initial_joint_pose.py \
xr_rm_bringup/config/dual_arm_rm75.yaml \
xr_rm_bringup/config/left_arm_rm75.yaml \
xr_rm_bringup/config/right_arm_rm75.yaml
git commit -m "feat: 优化双臂采摘QP稳健性"
```
禁止 `git push`、合并分支或连接真机。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,657 @@
# 右臂番茄采摘 ACT 数据采集适配设计
## 背景与目标
当前项目已经通过 ROS2 Humble、PICO 手柄、Placo QP 和 RealMan Python API2
完成 RM75 遥操作。右臂遥操节点以名义 `90 Hz` 读取实际关节反馈,根据 PICO
相对位姿生成 TCP 目标,经过工作空间限制、QP、关节速度与加速度限制后,通过
`rm_movej_canfd` 下发最终关节目标。
本次变更在不重写现有遥操链路的前提下,增加一个独立的 ALOHA/ACT 风格数据采集
节点。第一阶段只采集右臂番茄采摘任务,每个 episode 覆盖从初始位姿出发、抓取
番茄、搬运至 RM75 下方收集篮并释放番茄的完整过程。
核心目标如下:
- 以右臂 7 个实际关节角和夹爪逻辑状态作为 `observations/qpos`
- 以实际成功下发或零阶保持的 7 个最终关节目标和夹爪目标作为 `action`
- 同步采集一台全局 D455 和一台右腕 D405 的 RGB 图像;
-`30 Hz` 形成同周期因果对齐的数据;
- 每个完整任务流式保存为一个 ALOHA/ACT 核心结构兼容的 HDF5 文件;
- 支持手柄开始、结束、丢弃、拒绝、质量检查、崩溃恢复和编号防覆盖;
- 保留足够的 PICO、TCP、QP、夹爪和时间戳调试数据,但不让采集节点进入机器人
控制链路。
参考实现为 ALOHA 官方仓库中的
[`record_episodes.py`](https://github.com/tonyzhaozh/aloha/blob/master/aloha_scripts/record_episodes.py)。
官方双臂数据使用 14 维状态和动作;本项目第一阶段采用右臂 `7+1=8` 维,因此只
保证 HDF5 核心组织方式兼容,不声称官方旧训练加载器可以不修改直接训练。
## 非目标
第一阶段明确不实现:
- 左臂或双臂 ACT 采集;
- ACT 训练代码、数据加载器、策略部署或自动完成判定;
- 深度图、红外图、点云、图像压缩或 ROS 图像话题;
- rosbag、中间格式、离线转换工具、GUI、声音或手柄震动反馈;
- 让 ACT 学习 A 键触发的回初始位姿运动;
- 修改现有工作空间限制、圆柱限制、速度限制、指令超时、安全停止或
`move_to_initial_pose_on_connect` 默认值;
- 新建重复的 ROS2 包或第二个 RealMan 连接。
后续训练时建议将 ACT `chunk_size` 设为 `60`,对应约 2 秒动作长度;该参数属于
训练配置,不写死在采集逻辑中。
## 现有控制链路与关键约束
现有 `single_arm_velocity_teleop` 在每个控制周期内依次完成:
```text
读取最新 RM75 反馈
→ 同步 Placo 状态
→ 读取 PICO 状态并生成 TCP 目标
→ 工作空间、位姿步长与速度限制
→ Placo QP
→ 关节速度与加速度限制
→ rm_movej_canfd 下发
→ 发布 joint_target 调试话题
```
当前 `joint_states`、TCP 调试话题和 `joint_target` 分别发布并各自取时间戳。如果
采集节点仅订阅这些分散话题,即使时间接近,也可能把第 100 个控制周期的
`q_actual` 与第 99 个控制周期的 `q_target` 拼在一起。这里的“第 100 个周期”是
控制序号,不是 `100 Hz`;现有控制频率仍是名义 `90 Hz`
此外,A 键回位调用的是一次阻塞式 `rm_movej(initial_joint_pose)`。轨迹由 RM75
控制器内部生成,项目不能获得每个控制周期的中间目标,也不经过当前 QP 和
`rm_movej_canfd` 链路,因此不能与遥操动作混用同一种标签语义。
## 总体架构
采用一个自定义原子控制采样消息和一个独立 ACT 采集节点:
```text
PICO 输入 + RM75 反馈
single_arm_velocity_teleop90 Hz
QP → 关节限速/限加速度 → RM75 下发
ActControlSample(同周期、同时间戳、同序号)
act_episode_recorder(每 3 个控制周期取 1 个)
├──────────────┐
↓ ↓
全局 D455 RGB 右腕 D405 RGB
└──────┬───────┘
30 Hz 流式写入临时 HDF5
裁剪 → 质量检查 → 保存/拒绝/丢弃
```
“原子”表示消息是一个逻辑上不可拆分的控制周期快照。订阅者要么收到该周期完整的
反馈、求解结果、最终动作和状态,要么该周期整体缺失;不会自行拼接多个异步话题。
职责边界如下:
- 遥操节点继续唯一负责 RM75 连接、反馈、QP、限位、动作下发和安全停止;
- 遥操节点只增加原子消息发布和夹爪逻辑状态记录,不读取相机、不写 HDF5;
- 采集节点只订阅控制/PICO 数据、独占两台相机并写文件,不连接或控制 RM75;
- 采集节点异常、退出或写盘过慢不得阻塞遥操发布或改变机器人动作;
- 相机由采集节点通过 `pyrealsense2` 直接打开,不再发布和重新订阅 ROS 图像。
原子消息使用本机低延迟、非阻塞的 best-effort QoS。采集节点检测任何需要保留的
控制周期丢失并拒绝 episode,而不是让 DDS 反压影响遥操控制。
## 原子控制采样消息
`xr_rm_interfaces` 中新增 `ActControlSample.msg`,发布话题为:
```text
/xr_rm/right_rm75/act_control_sample
```
消息至少表达以下内容:
| 类别 | 字段语义 |
|---|---|
| 周期标识 | ROS header、`control_seq`、控制周期单调时间戳 |
| 关节反馈 | `q_actual[7]`、当前 URDF 关节上下限、反馈接收单调时间戳、反馈年龄、反馈有效状态 |
| QP | QP 原始输出或失败时的保持目标 `q_qp_raw[7]`、是否尝试、成功状态、耗时 |
| 最终动作 | 经关节限速后的 `q_target[7]`、动作时间戳、是否当前周期成功下发 |
| TCP | 当前 TCP、PICO 映射前的原始目标 TCP、最终受限目标 TCP、命令速度 |
| PICO | 当前右手位姿、Grip、Trigger、A、B 和摇杆值 |
| 夹爪 | 请求目标、已确认逻辑状态、命令是否处理中、命令是否失败 |
| 控制状态 | `teleop_active``action_valid`、QP 回退、目标限位和控制故障状态 |
数值型关节字段在 ROS 消息中保持双精度,写入 HDF5 核心数据时显式转换成
`float32`。位姿使用位置加四元数,不保存完整 Placo 对象、Hessian、约束矩阵或
其他大体积求解器内部状态。
消息在每个实际执行的 90 Hz 控制回调中发布,包括 Grip 松开和安全停止状态:
- 当前周期成功发送动作时,`command_sent=true`
- 当前周期没有发送,但此前存在成功目标时,`q_target` 零阶保持上一个成功目标,
`command_sent=false``action_valid=true`
- 尚未形成任何有效目标或当前动作发送失败时,`action_valid=false`
- QP 失败但成功重发上次有效目标时,`qp_success=false``action_valid=true`
- 发送失败必须发布失败状态,并由采集节点拒绝当前 episode。
相机数据不放进该消息。相机时间戳和帧号由采集节点在同一主机的单调时钟域中补充。
## 夹爪数据语义
右臂使用 Modbus 电动夹爪。由于不同番茄尺寸会导致实际停止开度不同,而本项目只
关心抓取意图,第一阶段不读取或估算实际开度。
统一约定:
```text
0 = closed
1 = open
```
两类状态必须分开:
- `action[7]` 是目标状态,在 Trigger 产生开合请求的控制周期立即改变;
- `qpos[7]` 是已确认逻辑状态,只有 Modbus 命令正常返回后才改变;
- 命令失败时 `qpos[7]` 保持原值,并拒绝当前 episode;
- 采集前右臂夹爪必须成功初始化为完全打开 `1.0`,之后才允许把初始
`qpos[7]` 设为 `1`
- 不再使用原先含义不明确的 `0.75 → 0.15` 初始化序列。
结束一个有效番茄采摘 episode 前,操作者应先请求打开夹爪,等待日志/状态确认
逻辑状态已经变为 `open`,再松开 Grip 并按 B。结束时夹爪命令仍在执行,保存状态
最多等待 3 秒;失败或超时则拒绝。最终裁剪后的数据若没有包含已确认的打开状态,
同样拒绝,不能把保存后的成功状态回填到更早样本中。
## 相机配置与采集
第一阶段固定使用两台已确定序列号的 RealSense:
| ACT 名称 | 型号与位置 | 序列号 |
|---|---|---|
| `cam_high` | 全局 D455 | `234222303366` |
| `cam_right_wrist` | 右臂腕部 D405 | `412622272532` |
两路图像参数统一为:
```text
分辨率:640 × 480
帧率:30 FPS
格式:RGB uint8
HDF5 形状:(T, 480, 640, 3)
```
不采集深度、红外和点云,不使用 JPEG 压缩。采集线程直接请求 RealSense RGB8
避免为颜色通道转换引入 OpenCV 依赖。
每台相机使用独立采集线程和一个很小的 `deque` 帧缓冲。每帧保存:
- RealSense 帧号;
- RealSense 硬件时间戳;
- `wait_for_frames` 返回后立即读取的主机单调时间戳;
- RGB 数组。
两台设备的硬件时钟不能默认视为同一时钟域,因此正式对齐只使用同一主机的单调
时钟;硬件时间戳只用于发现设备重启、帧号跳变和采集异常。
采集节点独占相机。指定设备缺失、型号/序列号不匹配、流配置失败或已经被其他
进程占用时,预检失败并停留在 `IDLE`,不自动替换成其他相机。
## 30 Hz 采样与因果对齐
正式采样不使用独立的 30 Hz ROS 定时器。采集节点在首次有效 Grip 控制周期记录
`sample_origin_seq`,随后只选择:
```text
(control_seq - sample_origin_seq) % 3 == 0
```
因此 90 Hz 控制消息按 `0、3、6、9...` 的相对序号形成名义 30 Hz 数据,同时保证
第一个正式样本就是首次有效动作,而不是等待一个全局取模相位。
每个样本的定义为:
```text
observation[t]
= 当前控制周期开始时读取的 q_actual
+ 对每台相机选择主机时间戳不晚于该控制周期的最新帧
action[t]
= 同一控制周期经 QP、关节限速后成功发送的 q_target
或 Grip 暂停/QP 回退时明确定义的上次成功目标
```
采集节点收到消息时,相机缓冲中可能已经存在晚于控制周期的帧,因此不能简单取
“回调时最新帧”,必须按 `host_monotonic_ns <= control_monotonic_ns` 选择最近帧。
不存在满足条件且年龄不超过 50 ms 的帧时,当前 episode 拒绝。
不采用官方旧加载器中的 `action[t-1]` 补丁。HDF5 根属性写入:
```text
action_alignment = "same_step_causal"
```
控制、反馈、动作和图像源时间戳全部保存在 `/debug`,未来只有在真实延迟测量证明
存在稳定偏移时,才在训练加载器中调整;原始 HDF5 不进行不可逆移位。
## Episode 边界与手柄状态机
### 按键映射
- 右手 B,即右手 `secondary` 单击:开始准备或结束保存;
- 左手 Y,即左手 `secondary` 长按 1 秒:丢弃当前准备/录制;
- 右手 A,即右手 `primary`:继续保持现有右臂回初始位姿功能;
- Grip:继续只控制遥操离合,不作为“只在按下时才记录”的采集开关。
右手 B 在右手 Grip 按下时始终忽略,避免运动中误触开始或结束。左手 Y 仅在
`ARMED``RECORDING` 中长按有效,在 `IDLE` 中无作用,且永远不删除上一个已经
保存的 episode。
### 状态机
```text
IDLE
└─ Grip 松开时单击右手 B
├─ 预检失败 → IDLE
└─ 预检通过 → ARMED
ARMED
├─ 第一次 Grip 有效动作 → RECORDING
├─ 再次单击右手 B → 取消 → IDLE
├─ 长按左手 Y 1 秒 → DISCARDED → IDLE
└─ 按右手 A → 取消 → IDLE
RECORDING
├─ Grip 松开后单击右手 B → SAVING
├─ 长按左手 Y 1 秒 → DISCARDED → IDLE
├─ 按右手 A → REJECTED → IDLE
└─ 硬质量故障/60 秒上限/Ctrl+C → REJECTED → IDLE
SAVING
├─ 质量检查通过 → SAVED → IDLE
└─ 质量检查失败 → REJECTED → IDLE
```
`IDLE``ARMED``RECORDING``SAVING` 是运行状态;`SAVED``DISCARDED`
`REJECTED` 是短暂结果状态,发布一次结果并输出日志后回到 `IDLE``SAVING`
期间忽略 B/Y 录制按键,A 键仍属于原有遥操逻辑,但不会再进入已经结束的数据。
状态通过 `std_msgs/msg/String` 话题 `/act/recording_status` 和终端日志报告,不增加
新状态消息、声音或震动接口。
### 连续记录与 Grip 暂停
正式时间轴从 `ARMED` 后 Grip 按下且第一次
`action_valid=true、command_sent=true` 的控制周期开始。Grip 刚按下的建基准周期
尚未向 RM75 发送新的 CANFD 目标,因此不作为第一个训练样本。
录制过程中临时松开 Grip 时仍以 30 Hz 保存图像和 `qpos``action` 零阶保持上次
成功目标,并记录 `teleop_active=false`
- 松开后重新按 Grip:暂停区间保留,继续同一个 episode;
- 最后一次松开后按 B:将该次松开至 B 之间的纯操作等待数据裁掉,episode 结束在
最后一次 Grip 松开附近;
- 夹爪打开确认必须已经包含在裁剪终点之前,否则拒绝该 episode。
只在 Grip 按下时保存数据会丢失接近任务开始、暂停恢复和完整视觉上下文,因此不
采用该方案。
### Episode 是否包含 A 键回位
一个正式 episode 只包含:
```text
初始位姿、夹爪打开
→ 接近番茄
→ 闭合夹爪
→ 搬运至收集篮
→ 打开夹爪并确认成功
→ 松开 Grip
→ 按 B 结束
```
A 键的 `rm_movej(initial_joint_pose)` 必须在成功结束 episode 后执行,不写进
episode。这样 ACT 始终学习同一种逐周期 `q_target` 动作语义。未来实时推理若要
连续采摘,应由上层状态机执行:
```text
ACT 完成一次采摘 → 完成判定 → 固定 rm_movej 复位 → 下一次 ACT 采摘
```
若在 `RECORDING` 中误按 A,当前文件转入拒绝目录,原因写为:
```text
initial_pose_command_during_episode
```
拒绝数据不会拦截 A 键原有回位动作,也不会额外控制机器人。
## HDF5 核心结构
数据根目录和任务目录固定为:
```text
/home/robot/ACT_Data
/home/robot/ACT_Data/tomato_pick
```
正式文件核心结构:
```text
/observations/qpos float32 (T, 8)
/observations/images/cam_high uint8 (T, 480, 640, 3)
/observations/images/cam_right_wrist uint8 (T, 480, 640, 3)
/action float32 (T, 8)
/debug/...
```
`T` 是该次任务的实际样本数,不要求所有 episode 等长,不进行文件内 padding。
最短有效 episode 为 `60` 个样本,即 2 秒;最长为 `1800` 个样本,即 60 秒。
8 维字段顺序固定为:
```text
qpos[0:7] = RM75 实际反馈关节角,单位 rad
qpos[7] = 已确认夹爪逻辑状态,0 closed、1 open
action[0:7] = 最终成功下发或明确定义为保持的关节目标,单位 rad
action[7] = 夹爪请求目标,0 closed、1 open
```
根属性至少包括:
| 属性 | 值或语义 |
|---|---|
| `sim` | `false`,与 ALOHA 真实数据约定一致 |
| `task_name` | `tomato_pick` |
| `sample_rate_hz` | `30` |
| `action_alignment` | `same_step_causal` |
| `arm` | `right_rm75` |
| `episode_status` | `saved``rejected` |
| `camera_high_serial` | `234222303366` |
| `camera_right_wrist_serial` | `412622272532` |
| `joint_names` | 7 个 RM75 关节名和 `gripper` 的固定顺序 |
| `joint_lower_limits` | 来自当前 Placo/URDF 的 7 关节下限 |
| `joint_upper_limits` | 来自当前 Placo/URDF 的 7 关节上限 |
| `reject_reason` | 仅拒绝文件存在 |
| `interrupted` | 正常文件为 `false`Ctrl+C/异常恢复为 `true` |
图像不压缩,每帧使用一个 HDF5 chunk;数值数据使用可扩展的一维时间轴并分块
写入。按两路 `640×480×3×30` 计算,图像数据约为 3.3 GB/分钟,因此不能把完整
episode 先缓存到内存再一次性保存。
不创建 `/observations/qvel``/observations/effort`、压缩标记或 `compress_len`
也不使用零值、有限差分或其他伪数据填充缺失字段。后续训练加载器按存在的核心
字段读取。
## Debug 结构
自定义消息是运行时传输载体,进程退出后不会保留;HDF5 `/debug` 是永久诊断记录。
ACT 训练默认不读取该组。
建议使用以下精简结构,布尔状态以 `uint8` 保存:
```text
/debug/timestamps/control_monotonic_ns int64 (T,)
/debug/timestamps/feedback_monotonic_ns int64 (T,)
/debug/timestamps/action_monotonic_ns int64 (T,)
/debug/timestamps/cam_high_host_monotonic_ns int64 (T,)
/debug/timestamps/cam_wrist_host_monotonic_ns int64 (T,)
/debug/timestamps/cam_high_hardware_ms float64 (T,)
/debug/timestamps/cam_wrist_hardware_ms float64 (T,)
/debug/timestamps/cam_high_age_ms float32 (T,)
/debug/timestamps/cam_wrist_age_ms float32 (T,)
/debug/timestamps/inter_camera_skew_ms float32 (T,)
/debug/cameras/cam_high_frame_number uint64 (T,)
/debug/cameras/cam_wrist_frame_number uint64 (T,)
/debug/control/control_seq uint64 (T,)
/debug/control/teleop_active uint8 (T,)
/debug/control/action_valid uint8 (T,)
/debug/control/command_sent uint8 (T,)
/debug/control/target_clamped uint8 (T,)
/debug/control/control_fault uint8 (T,)
/debug/qp/raw_target float32 (T, 7)
/debug/qp/attempted uint8 (T,)
/debug/qp/success uint8 (T,)
/debug/qp/duration_ms float32 (T,)
/debug/tcp/current_pose float32 (T, 7)
/debug/tcp/raw_target_pose float32 (T, 7)
/debug/tcp/final_target_pose float32 (T, 7)
/debug/tcp/command_velocity float32 (T, 6)
/debug/pico/right_pose float32 (T, 7)
/debug/pico/right_inputs float32 (T, 6)
/debug/pico/left_secondary uint8 (T,)
/debug/gripper/target_open uint8 (T,)
/debug/gripper/state_open uint8 (T,)
/debug/gripper/command_pending uint8 (T,)
/debug/gripper/command_failed uint8 (T,)
```
位姿顺序统一为 `[x, y, z, qx, qy, qz, qw]`TCP 速度顺序统一为
`[vx, vy, vz, wx, wy, wz]``right_inputs` 顺序在文件属性中写明。当前周期没有发送
动作时,`action_monotonic_ns=-1`,并以 `command_sent=false` 消除歧义。
episode 根属性额外保存 QP 失败次数、失败占比、最长连续失败次数、目标限位次数、
相机帧率、丢帧率和最大时间偏差等汇总指标。
## 数据质量规则
### 开始前预检
Grip 松开时单击 B 后,采集节点检查:
- 输出目录存在或可以创建且可写;
- 可用空间不少于 4 GiB,约为 60 秒原始图像估算值的 1.2 倍;
- 两台指定相机均在线、已经连续预热 5 秒且当前帧率合格;
- 最近 `q_actual` 合法,反馈年龄不超过 50 ms,RM75 无掉使能或控制故障;
- 右臂夹爪初始化打开命令已经成功;
- 左右 PICO 话题均在现有手柄超时范围内保持新鲜;
- 没有第二个采集进程持有任务目录锁或相机设备;
- 启动组合是 `arm:=right use_mock:=false record_act:=true`
任一预检失败时输出明确原因并停留在 `IDLE`,不生成空文件,也不改变机器人状态。
### 硬拒绝条件
以下任一情况使当前 episode 进入 `REJECTED`
- 样本少于 60 或达到 60 秒上限;
- 控制周期序列缺失、有效平均采样率低于 27 Hz 或相邻样本间隔超过 100 ms;
- `qpos/action` 不是 `(T,8)`、包含 NaN/Inf、违反配置关节限制或夹爪值不是
`0/1`
- `q_actual` 年龄超过 50 ms,反馈超时、掉使能或出现控制故障;
- 最终关节动作发送失败,或消息表示的反馈和动作不属于同一控制周期;
- 夹爪命令失败、超时,或最终裁剪数据没有包含已确认的打开状态;
- 任一路相机平均帧率低于 27 FPS;
- 任一路相机硬件帧号丢失率超过 1%,或采样后的重复/跳帧比例超过 1%;
- 任一采样图像年龄超过 50 ms,或两路图像主机时间差超过 50 ms;
- 图像形状、数据类型或 RGB 通道约定错误;
- 录制中按 A
- HDF5 写入失败、磁盘空间不足或有界写入队列持续积压;
- Ctrl+C、采集节点异常退出或启动时恢复崩溃残留文件。
采集节点的拒绝只处理数据,不额外发送停止或运动命令。若原因来自控制故障,仍由
现有遥操安全链路执行原有安全停止。
### 允许但记录告警的情况
QP 求解偶发失败时,现有逻辑保留上次有效关节目标。只要该保持目标最终成功发送、
反馈和其他质量规则正常,就不自动拒绝 episode,而是保存:
- QP 失败样本数;
- 失败占比;
- 最长连续失败样本数;
- 每个样本的 `qp_success`
工作空间限位、TCP 步长限制或关节速度/加速度限制生效同样只记录,不自动拒绝。
这些限制是正常安全控制的一部分。
## 文件编号、保存、拒绝与恢复
正式编号只扫描任务目录根部的 `episode_<数字>.hdf5`,取最大编号加一:
```text
已有 episode_0.hdf5 ... episode_9.hdf5
重启后下一个正式文件仍为 episode_10.hdf5
```
不填补编号空洞,绝不覆盖已有正式文件。任务目录使用标准库文件锁保证同一时刻只有
一个采集进程分配编号和写入;临时文件与目标文件位于同一文件系统,检查通过后使用
不覆盖已有目标的原子发布方式。
文件生命周期:
```text
录制中:
/home/robot/ACT_Data/tomato_pick/episode_10.partial.hdf5
检查通过:
/home/robot/ACT_Data/tomato_pick/episode_10.hdf5
检查失败:
/home/robot/ACT_Data/tomato_pick/rejected/
episode_10_<reason>_<timestamp>.hdf5
```
- 只有正式保存成功才消耗编号;
- 手动长按 Y 丢弃时关闭并删除当前临时文件,不生成拒绝文件;
- 普通质量拒绝转入 `rejected/`,不消耗正式编号;
- Ctrl+C 时尽力关闭可读 HDF5,根属性写入 `interrupted=true`,文件名原因使用
`interrupted`
- 下次启动先处理遗留临时文件:可读文件转入 `rejected/` 并标记
`crash_recovered`,不可读文件改成带时间戳的 `.partial.hdf5` 保留;
- 拒绝原因同时写入文件名和根属性;
- 任一步出现目标文件冲突时停止保存并报警,不能覆盖或自动删除已有 episode。
写入采用单独工作线程和有界队列,ROS 回调只完成取样、对齐和入队。队列容量只需
覆盖短暂磁盘抖动,不能无限增长掩盖磁盘吞吐不足;持续积压时拒绝数据。
## 启动、配置与依赖
继续使用唯一入口 `xr_rm_bringup/launch/arm_debug.launch.py`,新增参数:
```text
record_act:=false
```
默认 `false`,现有 mock、单臂、双臂和 MuJoCo 启动行为不变。第一阶段唯一允许的
采集组合为:
```bash
ros2 launch xr_rm_bringup arm_debug.launch.py \
arm:=right use_mock:=false record_act:=true
```
`record_act:=true` 配合 `arm:=left|both``use_mock:=true` 时,在 launch 参数校验
阶段明确拒绝。ACT 采集节点退出不触发整个 launch 的 `Shutdown`,保证数据进程故障
不会终止遥操;终端必须清晰显示采集已经不可用。
新增一份专用 YAML,集中保存:
- 数据根目录和任务名;
- 两个相机序列号、分辨率和帧率;
- 30 Hz 采样率、2 秒最短时长和 60 秒最长时长;
- 相机、反馈、磁盘和时间对齐质量阈值;
- PICO 左右话题、原子采样话题和状态话题。
硬件相关阈值保留为配置项,核心 HDF5 字段顺序和 8 维语义固定,不为未来可能的
变体增加插件或通用框架。
采集节点继续由 `/home/robot/miniconda3/envs/xr/bin/python` 启动。复用该环境已有的
`pyrealsense2` 和 NumPy,只在该环境增加 HDF5 必需依赖 `h5py`;不修改系统 Python
不新增 Conda 环境,也不增加 OpenCV 依赖。依赖缺失时节点应在打开相机或创建文件前
给出明确错误。
## 最小代码范围
预计只修改或新增以下位置:
- `xr_rm_interfaces/msg/ActControlSample.msg`:原子消息;
- `xr_rm_interfaces/CMakeLists.txt``package.xml`:生成并导出消息;
- `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`:发布原子样本、跟踪夹爪
请求/成功状态和 A 键事件;
- `xr_rm_teleop/xr_rm_teleop/fun_peripheral.py`:把请求的初始工具状态明确改为完全
打开;
- `xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py`:独立采集节点;
- `xr_rm_teleop/setup.py``package.xml`:安装入口和 ROS 运行依赖声明;
- `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` 启动分支;
- 现有测试目录中的最小相关测试。
不改左臂/双臂运动参数,不改变 `left_arm_teleop``right_arm_teleop` 节点名,不创建
第二个相机包、训练包或数据工具包。
## 测试与验收
### 自动化验证
测试全部使用 mock、假适配器、合成相机帧和临时目录,不连接真机、不移动机械臂、
不操作真实夹爪:
- 原子消息中的 `q_actual`、QP 输出和最终 `q_target` 来自同一控制周期;
- QP 失败时记录失败并保持上次目标,不自动拒绝;
- 发送失败、夹爪失败和 A 键误触触发拒绝;
- Trigger 请求立即改变 `action[7]`,成功返回后才改变 `qpos[7]`
- B/Y/Grip 的边沿、长按、忽略条件和所有状态转换正确;
- 从首次有效样本开始按控制序号每 3 个周期取 1 个;
- 相机只选择不晚于控制时间的最新帧,并能发现过期、偏斜和帧号异常;
- Grip 中途暂停保留,最终松开到 B 的等待段正确裁剪;
- HDF5 核心路径、形状、dtype、8 维顺序、根属性和 debug 字段正确;
- 变量长度、最短/最长限制、质量拒绝、手动丢弃和 Ctrl+C 正确;
- 编号从最大正式编号加一,拒绝不占号,已有文件不被覆盖;
- 可读和不可读崩溃残留分别按设计恢复;
- `record_act` 默认关闭,非法 arm/mock 组合被 launch 拒绝;
- mock 模式不会导入或调用 RealMan SDK、RealSense 或 HDF5 采集链路。
按照仓库要求,构建和测试从工作空间根目录执行:
```bash
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
colcon build --symlink-install
pytest src/xr_rm_teleop/test/test_orientation_control.py
```
实施时还应运行新增测试及受影响的现有关节控制、初始位姿和外设配置测试。未看到
实际通过输出前不得声称验证通过。
### 真机手工验收
真机验收必须由用户明确授权并在现有安全检查完成后进行,至少验证:
1. 启动后右臂夹爪完全打开,状态未在命令成功前提前标记;
2. 两台相机序列号和画面角色正确,持续 30 FPS 左右;
3. B 开始、Grip 激活、B 结束、Y 丢弃和 A 误触拒绝符合状态机;
4. 正常采摘文件包含完整抓取、搬运和释放,不包含 A 键回位;
5. `qpos/action` 是有限的 `(T,8)` `float32`,图像是两路
`(T,480,640,3)` `uint8`
6. QP 短暂失败只增加 debug 计数,成功调整后仍可完成 episode;
7. 重启后编号继续递增,丢弃和拒绝不会覆盖或占用正式编号;
8. Ctrl+C、相机断流和磁盘不足产生带明确原因的拒绝文件;
9. ACT 采集节点退出后,遥操安全链路仍按现有行为运行。
## 后续训练与推理影响
本设计生成 ALOHA/ACT 风格的核心数据,但原始 ACT 代码通常把状态维度硬编码为
双臂 14 维,并可能假设固定 episode 长度。训练阶段需要单独适配:
- `state_dim=8`
- 两个相机名 `cam_high``cam_right_wrist`
- 变量长度 episode 的 padding 和 mask
- `chunk_size≈60`
- 不使用 `action[t-1]` 旧补丁;
- 忽略 `/debug`,除非用于筛选或诊断。
实时推理只负责从初始位姿执行一次采摘到释放。回初始位姿继续调用当前确定性的
`rm_movej`,由未来的上层任务状态机协调,避免让一个低层策略混合两种动作接口和
任务阶段。
@@ -1,245 +0,0 @@
# RM75 双臂 J3 参考角仿真标定设计
## 1. 目标
在不连接真机、不修改现有生产控制参数的前提下,基于当前双臂 URDF、Placo QP
求解器和 MuJoCo 运动学模型,分别标定左臂与右臂的第三关节软引导参考角:
\[
q_{3,\mathrm{ref}}^{L,*},\qquad q_{3,\mathrm{ref}}^{R,*}。
\]
标定结果只作为当前机器人初始姿态、采摘区域、工具安装和本侧收集筐布局下的
仿真初值。后续必须通过 mock 完整控制链路和真机低速试验复验,允许根据实测结果
更新参数。
## 2. 范围
本轮只做离线参数标定:
- 左右臂分别从当前 YAML 初始关节姿态出发;
- 左臂放入左臂初始 TCP 下方约 40 cm 的本侧收集筐;
- 右臂放入右臂初始 TCP 下方约 40 cm 的本侧收集筐;
- 每个轨迹点同时指定 TCP 位置与姿态,保持严格六维跟踪;
- 第四关节下限暂定左右臂均为 10°;
- 使用现有 Placo 任务接口临时加入 J3 软任务和位置可操作度任务;
- 将每个有效关节结果同步写入现有 MuJoCo 双臂模型,检查关节映射和状态有效性;
- 输出候选角度的逐轨迹指标、汇总排名和推荐平台区间。
本轮不修改 `placo_ik_solver.py`、遥操作节点或 YAML,不测试真机,不加入任务阶段
状态机、自动姿态放松、碰撞规划或新依赖。
## 3. 坐标与姿态约定
- 双臂机器人公共坐标系 `+Y` 为正前方;
- 公共坐标系 `+Z` 为机器人垂直向上;
- 左右方向使用公共坐标系 `X`
- 采摘点保持对应机械臂初始 TCP 的横向 `X` 位置;
- 采摘与退出阶段保持初始 TCP 姿态;
- 从退出点移动到收集筐上方时,TCP 姿态采用四元数球面插值,平滑旋转为工具工作
轴沿公共坐标系 `-Z`
- 收集筐上方至筐内的垂直下降段保持工具朝下姿态;
- 返回初始位姿时平滑恢复初始 TCP 姿态。
“严格六维”表示每个时刻的位置和姿态目标均参与同一 QP,仿真不会因接近奇异点
而自动降低姿态权重。
## 4. 轨迹族
### 4.1 采摘点
每条机械臂使用 3 个前向距离和 3 个高度:
\[
y_h\in\{0.30,0.40,0.50\}\ \mathrm{m},
\]
\[
z_h\in\{-0.30,-0.20,-0.10\}\ \mathrm{m}。
\]
这些点位于用户给定的前方 30~50 cm、相对机械臂基座高度 ±50 cm 范围内,并且
是当前固定初始工具姿态下离线预扫描得到的主要可解高度区间。左右臂各 9 条轨迹,
共 18 条完整轨迹。
### 4.2 单条完整轨迹
每条轨迹由以下连续段组成:
1. 初始 TCP 位姿;
2. 采摘点前方 5 cm 的预接近点;
3. 沿公共 `+Y` 直线进入采摘点;
4. 沿原路径退回预接近点;
5. 移动到本侧收集筐上方 10 cm,同时平滑旋转到工具朝下;
6. 垂直下降 10 cm,到达初始 TCP 下方约 40 cm 的收集筐目标;
7. 垂直抬升 10 cm
8. 返回初始 TCP 位姿。
轨迹按当前控制频率 90 Hz 离散,平移速度不超过 0.15 m/s,角速度不超过
0.5 rad/s。每条轨迹均从相同初始关节状态重新开始,避免上一候选角或上一轨迹的
状态污染下一次评估。
### 4.3 边界轨迹
工作区边界、不可达目标和 QP 失败恢复轨迹不参与第一轮 J3 参数排名。J3 参数确定后,
再使用这些轨迹验证失败保持和恢复逻辑,避免不可达点数量掩盖参考角本身的差异。
## 5. QP 试验配置
主任务保持现有严格六维相对位姿任务,内部等价于:
\[
\left\|J_p\Delta q-e_p\right\|^2
+\left\|J_R\Delta q-e_R\right\|^2
\]
其中误差定义为目标减当前。仿真脚本不改变 Placo 的误差符号。
在主任务之外临时加入:
\[
w_e\left(q_3+\Delta q_3-q_{3,\mathrm{ref}}\right)^2,
\qquad w_e=10^{-5}
\]
以及 TCP 位置可操作度任务:
\[
-w_m\nabla m_p(q)^T\Delta q,
\qquad w_m=10^{-4}。
\]
保留当前动能正则、URDF 关节位置限制和关节速度限制。第四关节临时增加:
\[
q_4\ge10^\circ。
\]
## 6. 参数扫描
### 6.1 粗扫
左臂:
\[
q_{3,\mathrm{ref}}^L\in\{0^\circ,10^\circ,\ldots,100^\circ\}。
\]
右臂:
\[
q_{3,\mathrm{ref}}^R\in\{0^\circ,-10^\circ,\ldots,-120^\circ\}。
\]
### 6.2 细扫
在粗扫最优候选附近 ±10° 内以 2° 为步长再次扫描。若多个相邻候选没有明显差异,
选择稳定平台区的中心,而不是选择孤立的单点峰值。
### 6.3 基线
同时运行两组基线:
- `original`:当前原始 QP,不含 J3 软任务、位置可操作度任务和 J4 额外下限;
- `manip_j4`:不含 J3 软任务,但加入位置可操作度任务和 J4 额外下限。
所有 J3 候选均在 `manip_j4` 基础上只改变 J3 参考角。最终结果必须同时报告:
- 相对当前原始 QP 的改善;
- 相对“只加位置可操作度”的改善;
- J3 软任务是否降低六维跟踪成功率。
## 7. 记录指标
对每个候选角度、每条轨迹记录:
- QP 求解失败周期数 `N_fail`
- 完成全部轨迹的数量 `N_complete`
- 整条轨迹六维雅可比的最小奇异值 `sigma_min`
- 第四关节最小安全余量 `m_q4 = min(q4 - 10°)`
- 肘部最小外展量 `d_elbow`
- 最大 TCP 位置误差和姿态误差;
- 最大关节速度;
- 累计关节运动代价 `E_q`
- 是否出现超过阈值的单周期关节构型跳变。
肘部外展量使用公共坐标系中第四连杆位置计算:
\[
d_{\mathrm{elbow}}^L=-x_{\mathrm{elbow}}^L,
\qquad
d_{\mathrm{elbow}}^R=x_{\mathrm{elbow}}^R。
\]
当前 URDF 碰撞网格对相邻连杆存在已知自碰撞警告,因此本轮不把 MuJoCo/Placo
碰撞距离加入评分,避免错误碰撞几何影响 J3 选择。
## 8. 选择规则与评分函数
### 8.1 硬门槛
候选角度首先按以下顺序筛选:
1. `N_fail` 最少;
2. `N_complete` 最多;
3. 最大位置误差不超过 2 mm
4. 最大姿态误差不超过 0.005 rad;
5. 不违反第四关节、URDF 关节位置和速度限制;
6. 不出现超过配置阈值的单周期关节跳变。
只在通过同一组硬门槛的候选之间使用评分函数。这样不能用较高可操作度抵消更多的
QP 失败或更差的 TCP 跟踪。
### 8.2 并列候选评分函数
对通过硬门槛的候选,将各项指标在同一机械臂的候选集合内转换为 `[0,1]` 的百分位
排名。数值越大越好的指标直接排名,数值越小越好的指标反向排名:
- `r_sigma`:全轨迹最小奇异值排名;
- `r_q4`:第四关节最小安全余量排名;
- `r_elbow`:肘部最小外展量排名;
- `r_smooth`:累计关节运动代价与最大关节速度的联合反向排名;
- `r_track`:最大六维 TCP 跟踪误差的反向排名。
并列候选的综合评分为:
\[
S(q_{3,\mathrm{ref}})
=0.45r_{\sigma}
+0.20r_{q4}
+0.20r_{\mathrm{elbow}}
+0.10r_{\mathrm{smooth}}
+0.05r_{\mathrm{track}}。
\]
选择:
\[
q_{3,\mathrm{ref}}^*=\arg\max S(q_{3,\mathrm{ref}})。
\]
最小奇异值权重最高,因为本轮首要目标是降低奇异点和 QP 失败风险;第四关节余量
和肘部外展各占 0.20;平滑性和跟踪误差用于区分性能接近的候选。若评分最高点与
相邻角度差异小于 2%,取相邻稳定平台的中心角度。
### 8.3 结果报告
左右臂分别输出:
- 推荐参考角;
- 推荐稳定区间;
- 粗扫与细扫排名表;
- 与两组基线的指标对比;
- 最差轨迹及其失败位置;
- 是否建议保留左右臂统一的第四关节 10° 下限。
## 9. 实施边界与后续流程
标定完成后的顺序为:
1. 根据仿真结果形成正式 QP 修改规格;
2. 将左右臂 J3 参考角作为独立可调参数写入对应 YAML;
3.`use_mock:=true` 下运行完整遥操作控制链路;
4. 加入 QP 失败时不提交笛卡尔目标历史的修复并验证恢复;
5. 经过安全评审后,在真机上以低速、小范围方式复验;
6. 根据真机日志更新 J3 参考角,但不取消工作空间、速度、超时和安全停止限制。
@@ -0,0 +1,218 @@
# ACT 双相机预览与采样质量判定修正设计
## 背景
右臂番茄采摘 ACT 采集当前以 `90 Hz` 接收原子控制消息,每三个控制周期生成一个
`30 Hz` 样本,并为该样本选择不晚于控制时刻的最新全局 D455 和右腕 D405 RGB
帧。现阶段测试暴露出两个相机相关问题:
1. ACT 采样帧号偶尔出现 `986 → 986 → 988` 一类重复和跨帧,episode 因
`camera_sample_drop_ratio` 被拒绝;
2. ACT 数采启动后没有现场预览,操作者不能直观看到两路画面、相机质量和录制
状态。
已保存 HDF5 的调查结果表明,被 `camera_sample_drop_ratio` 拒绝的 episode 中,
相机采集线程统计的 `camera_high_drop_ratio`
`camera_right_wrist_drop_ratio` 均为 `0.0`。异常主要出现在独立运行的两个
`30 Hz` 时钟之间:控制采样早于下一张相机帧时会再次选择上一帧,下一个采样点
则可能选择更新两号的帧。相机采集线程实际收到过中间帧,因此这不是 RealSense
传输丢帧。
## 目标
- 保持现有因果时间对齐,不选择控制时刻之后的图像;
- 只用相机采集线程观察到的真实帧号缺失率判定相机丢帧;
- 将 ACT 样本中的重复帧和跨帧改为可观测指标,不再据此拒绝 episode;
- `record_act:=true` 启动录制器时默认显示全局 D455 和右腕 D405 实时画面;
- 在预览中显示相机质量和 ACT 录制状态;
- 预览关闭或显示故障不得影响相机采集、HDF5 写入和机器人控制。
## 非目标
本次不实现:
- 原始视频流保存和离线重采样;
- D455 与 D405 硬件同步;
- ROS 图像话题、Web 界面或新的相机进程;
- 深度图、点云、图像压缩或 HDF5 核心训练字段变更;
- 关节曲线、机器人控制按钮或预览截图;
- 夹爪实际开度反馈或 episode 终点裁剪逻辑修改;
- 修改 `record_act` 的全局默认值;
- 修改工作空间/圆柱限位、速度限制、指令超时、安全停止或真机连接行为。
夹爪录制继续使用现有操作顺序:保持 Grip,按 Trigger 打开并等待打开命令完成,
然后松开 Grip,最后按 B 保存。
## 方案选择
### 采用:保持当前因果对齐
数据流保持为:
```text
90 Hz ActControlSample
↓ 每三个连续控制周期选择一次
30 Hz ACT 目标时刻
↓ 分别选择 host_monotonic_ns 不晚于目标时刻的最新帧
D455 图像 + D405 图像 + qpos + action
现有 HDF5
```
该方案维持现有训练数据语义,图像不会包含控制时刻之后的未来信息。少量重复帧和
跨帧作为异步时钟相位漂移保留在数据中,并用明确指标量化。
### 未采用:以 D455 为软件主时钟
该方案可以避免 D455 重复帧,但会使控制序号间隔不再固定,并需要重新定义状态、
动作和 D405 图像的对齐语义,当前收益不足以覆盖兼容性成本。
### 未采用:保存原始流并离线重采样
该方案最灵活,但需要新的原始存储结构和转换工具,无压缩双路 RGB 也会显著增加
存储开销。只有后续训练表明快速接触或释放动作受到当前单帧级时间抖动影响时,才
考虑升级。
## 相机质量指标
### 真实采集质量
`CameraBuffer` 在每次收到 RealSense 帧时比较相邻原始帧号。原始帧号向前跳过的
数量计入真实丢帧数;帧号不递增单独计为回退/重启异常。episode 期间的统计写入
现有或新增 HDF5 属性:
- `camera_high_fps`
- `camera_right_wrist_fps`
- `camera_high_drop_ratio`
- `camera_right_wrist_drop_ratio`
- `camera_high_frame_number_regression_count`
- `camera_right_wrist_frame_number_regression_count`
以下条件继续拒绝 episode
- 任一路实际采集 FPS 小于配置的 `min_camera_fps`,当前为 `27 Hz`
- 任一路真实丢帧率大于配置的 `max_drop_ratio`,当前为 `1%`
- 任一路图像帧龄超过 `max_camera_age_ms`,当前为 `50 ms`
- 两路所选图像的主机单调时间差超过 `max_camera_skew_ms`,当前为 `50 ms`
- 任一路原始帧号发生回退或重启;
- 相机启动、取帧或图像格式发生错误。
### ACT 采样相位指标
对每路写入 HDF5 的 ACT 样本帧号计算相邻差值:
```text
diff == 0:重复使用同一相机帧
diff == 1:理想连续取样
diff > 1:相邻 ACT 样本跨过相机帧
diff < 0:帧号回退,仍按异常拒绝
```
`N` 个 ACT 样本,分母为 `max(1, N - 1)`
```text
sample_repeat_ratio = count(diff == 0) / max(1, N - 1)
sample_skip_ratio = count(diff > 1) / max(1, N - 1)
```
分别写入:
- `cam_high_sample_repeat_ratio`
- `cam_high_sample_skip_ratio`
- `cam_wrist_sample_repeat_ratio`
- `cam_wrist_sample_skip_ratio`
这些指标写入保存/拒绝文件属性,并在保存日志中摘要输出,但不参与 episode 接受
判定。原有 `camera_sample_drop_ratio` 拒绝路径移除,避免把软件采样相位漂移误报
为相机传输丢帧。
## 双路实时预览
### 生命周期
`ActEpisodeRecorder` 成功启动两台相机后,默认启动一个独立的 OpenCV 预览线程。
该线程只读取两个现有 `CameraBuffer` 的最新帧和统计,不打开新的 RealSense
pipeline,也不发布 ROS 图像话题。
预览约以 `10 Hz` 刷新,降低显示开销;相机采集和 HDF5 录制仍保持 `30 Hz`
节点退出时先通知并回收预览线程,再停止两台相机。关闭预览不会重新启动。
`arm_debug.launch.py` 继续保持 `record_act:=false` 的安全默认值。tools 中现有 ACT
数采入口已经显式传入 `arm:=right use_mock:=false record_act:=true`,因此无需修改
launch 参数或增加新的预览开关;只要录制器节点启动,预览就默认启动。
### 布局与信息
窗口使用左右并排的两块画面:
```text
┌──────────────────────┬──────────────────────┐
│ 全局 D455 │ 右腕 D405 │
│ 实时画面 │ 实时画面 │
│ FPS / 当前帧号 │ FPS / 当前帧号 │
│ 接收数 / 真实丢帧率 │ 接收数 / 真实丢帧率 │
├──────────────────────┴──────────────────────┤
│ 两路帧龄 / 两相机时间差 │
│ ACT 状态 / episode 编号 / 已写入样本数 │
└─────────────────────────────────────────────┘
```
实时 FPS 使用相机采集线程最近约两秒的到帧时间计算,而不是预览刷新率。未开始
episode 时编号和样本数显示为空或 `-`;录制过程中读取当前录制器状态。
### 关闭和异常处理
-`Q``Esc` 或点击窗口关闭按钮,只停止预览线程;
- 没有 `DISPLAY``WAYLAND_DISPLAY` 时不创建窗口,只记录一次警告;
- `cv2` 导入失败、窗口创建失败或显示过程中抛出异常时,记录一次警告并停止
预览;
- 预览异常不改变 ACT 状态,不关闭相机,不丢弃或拒绝 episode;
- RealSense 相机本身启动或采集失败仍沿用现有预检/拒绝行为;
- 预览线程不得调用机器人适配器、发布夹爪命令或阻塞 ROS 控制样本回调。
本次复用 `xr` 运行环境中现有的 OpenCV,不新增 Python 或 ROS 依赖。
## 代码范围
预计只修改:
- `xr_rm_teleop/xr_rm_teleop/act_episode_recorder.py`:真实丢帧与采样相位指标、
双路预览及生命周期;
- `xr_rm_teleop/test/test_act_episode_recorder.py`:质量判定和预览失败隔离测试;
- `README.md`:补充 ACT 默认双路预览及关闭方式。
无需修改 `arm_debug.launch.py``launcher_ui.py`、ROS 消息、相机配置或机器人控制
节点。实现继续保留在现有录制器文件中,只提取必要的纯计算/渲染辅助函数,不创建
通用相机框架。
## 测试与验证
自动化测试不连接 RealSense、RM75 或真实夹爪:
1. 构造 `986 → 986 → 988`,验证重复率和跨帧率均被记录,episode 不再因
`camera_sample_drop_ratio` 被拒绝;
2.`CameraBuffer` 原始输入中跳过帧号,验证真实丢帧数和丢帧率仍触发拒绝;
3. 构造原始帧号回退,验证 episode 被拒绝;
4. 验证两路采样指标分别计算,且分母在单样本时安全;
5. 使用替代显示函数验证按键关闭、窗口关闭、无显示环境和显示异常只停用预览,
不改变录制状态;
6. 运行 `xr_rm_teleop/test/test_act_episode_recorder.py`
7.`/home/robot/WS_xr` 执行:
```bash
source /opt/ros/humble/setup.bash
colcon build --symlink-install
```
现场再通过现有 ACT 数采入口验证窗口布局、画面刷新和关闭行为。该验证会连接右臂
真机,必须由用户明确执行;自动化过程不得启动真机 launch 或移动机器人。
## 完成标准
- 真实相机丢帧、帧龄超限、双相机偏差和相机错误仍能拒绝不合格 episode;
- 正常异步相位漂移造成的样本重复/跨帧不再拒绝 episode;
- HDF5 和日志能够区分真实丢帧与 ACT 采样相位指标;
- ACT 数采启动时默认出现全局 D455 与右腕 D405 双路预览;
- 关闭或损坏预览不影响 ACT 采集;
- 现有 HDF5 核心结构、机器人控制和安全行为保持不变;
- 相关测试与工作空间构建通过。
@@ -1,175 +0,0 @@
# RM75 双臂采摘 QP 稳健性优化方案概述
## 1. 目标与边界
本方案面向当前双臂机器人从初始位姿向机器人公共坐标系 `+Y` 前方采摘,再移动到
本侧机械臂初始 TCP 正下方约 40 cm、位于底盘车上的收集筐这一流程。首要目标是:
- 保持手柄给出的 TCP 位置和姿态严格参与六维逆解;
- 减少奇异点附近的构型恶化、QP 不收敛和连续失败;
- QP 失败时保持上一安全关节解,并且不提交本周期笛卡尔参考状态;
- 保留现有工作空间、速度、加速度、指令超时和安全停止限制。
本轮不加入自动采摘状态机、自动放松姿态或真机自动运动,不取消现有安全限制。
此前离线扫描中所有候选均未通过完整轨迹硬门槛,因此不能把扫描得到的左臂 34°、
右臂 0°写成“最优 J3”。仿真只能说明:两臂 `q4 >= 10°` 均保持正余量,J4 的 10°
硬下限不是当次 QP 失败的直接原因。
## 2. 更新后的 QP 目标
主任务和辅助任务写为:
\[
\begin{aligned}
\min_{\Delta q}\quad
&\left\|J_p\Delta q-e_p\right\|_{W_p}^2
+\left\|J_R\Delta q-e_R\right\|_{W_R}^2 \\
&+\lambda\left\|\Delta q\right\|^2
-w_m\alpha_m(\sigma)\nabla m_6(q)^T\Delta q \\
&+w_3\left(q_3+\Delta q_3-q_{3,\mathrm{ref}}\right)^2 \\
&+w_4\alpha_4(q_4)
\left[q_{4,\mathrm{warn}}-(q_4+\Delta q_4)\right]_+^2
\end{aligned}
\]
其中:
- `e = 目标位姿 - 当前位姿`,因此主任务使用 `JΔq - e`。如果误差定义相反,公式
才写成加号;当前 Placo 代码不翻转误差符号。
- 前两项是严格六维 TCP 位置和姿态任务,始终保持最高权重。
- `λ||Δq||²` 是现有动能正则,用于抑制过大的关节增量和数值抖动。
- `m6` 使用 Placo 支持的 `both` 类型六维可操作度;`αm` 只在完整六维雅可比的
最小奇异值进入预警区时逐渐激活,正常区域为零。
- J3 是低权重软引导,不属于可行性硬门槛。
- `[x]+ = max(0, x)`;J4 软项只在进入预警区后产生作用,提前远离 10° 硬下限。
辅助项不能通过提高权重来抵消六维 TCP 跟踪。第一版复用 Placo 现有任务接口,不
引入新的分层 QP 框架或外部依赖。
## 3. 四处修改
### 3.1 六维主任务、可操作度与数值迭代
严格六维 TCP 主任务保持不变,可操作度由“全程恒定启用的位置任务”改为“接近奇异
区才启用的六维任务”:
- `sigma_min >= sigma_warn``αm = 0`,不干扰正常遥操作;
- `sigma_stop < sigma_min < sigma_warn``αm` 从 0 平滑增加到 1
- `sigma_min <= sigma_stop`:保持最大辅助权重,但仍不降低六维 TCP 权重。
`sigma_warn``sigma_stop` 和最大辅助权重先保留为仿真可调参数,根据现有完整轨迹
日志确定;不直接沿用此前效果不明显的恒定位置可操作度权重。
当前 30 次求解是同一目标的数值迭代,不是 30 个真实控制周期。生产控制链路仍由
现有关节速度和加速度限制器约束实际运动,因此不把“单个物理周期到达完整目标”作为
收敛要求。求解器逐次检查六维误差;只有达到当前位置和姿态阈值的结果才允许发送。
30 次内未收敛则恢复到本周期实际关节反馈,不发送未收敛的中间结果。
### 3.2 J3 初始姿态软参考
取消继续扫描 J3 最优角,使用当前 YAML 初始姿态作为第一版参考:
\[
q_{3,\mathrm{ref}}^L=67.96^\circ\qquad
q_{3,\mathrm{ref}}^R=-89.57^\circ。
\]
J3 只使用低权重软任务,不设置 J3 硬限位,不因追踪参考角而放松 TCP 位姿任务。
代表性严格六维 mock 路径显示:左臂使用 `1e-5` 可完成路径,提高到 `1e-4` 会提前
触及关节限位;右臂使用 `1e-5` 时 J6 到达 URDF 下限,提高到 `1e-4` 后保留约
23° J6 余量并完成前伸段。因此第一版分别取:
\[
w_3^L=10^{-5}\qquad w_3^R=10^{-4}。
\]
左右臂参数分别配置,后续只在完整 mock 轨迹明显改善时再调整,不把参考角本身当作
成功保证。
### 3.3 J4 硬下限与软缓冲区
左右臂暂时保持相同硬约束:
\[
q_4\geq q_{4,\min}=10^\circ。
\]
在硬下限上方增加预警区,第一版取 `q4_warn = 25°`
- `q4 >= 25°`J4 软项关闭;
- `10° < q4 < 25°`:软项随接近 10°逐渐增强;
- `q4 <= 10°`:由硬约束禁止继续向下。
这样保留收集筐下降阶段所需的可达空间,同时避免 QP 到达 10°附近才突然遇到约束
边界。`25°` 是待仿真验证的缓冲起点,不是新的硬下限;左右臂允许分别调整预警角,
但除非轨迹数据证明有必要,不增加更多参数。
### 3.4 QP 失败恢复与笛卡尔参考状态提交
这是除目标函数外最关键的修复。当前风险流程为:
```text
QP 失败
→ 关节指令保持不动
→ 笛卡尔目标历史仍向前更新
→ 下一周期误差进一步增大
→ 连续失败或恢复时突跳
```
修改后,QP 求解结果、关节目标和笛卡尔参考状态按同一周期提交。
QP 成功时:
```text
QP 成功
→ 发送新关节目标
→ 关节目标发送成功
→ 提交新的笛卡尔参考状态
```
QP 失败或关节目标发送失败时:
```text
QP 失败
→ 丢弃失败后的 Placo 内部迭代结果
→ 保持上一有效关节目标
→ 不提交本周期笛卡尔参考状态
→ 操作者把手柄移回可行区域后继续求解
```
“不提交笛卡尔参考状态”包括不更新本周期候选的滤波状态、
`_last_sent_target``_last_sent_orientation` 和命令时间。下一周期仍从上一已提交的
笛卡尔参考状态以及实际关节反馈出发计算,防止 QP 误差在机械臂不动时继续累积。
手柄原始输入仍正常接收,不会被程序改写,也不会自动改变操作者给出的末端姿态。
失败时机械臂不会为了恢复而自行移动;操作者主动将手柄移回可行区域后,QP 使用新的
手柄输入重新求解。收集筐到达和松开夹爪仍以实际 TCP 反馈及位置、姿态容差为判据。
## 4. 保留约束
QP 和下游控制继续保留:
- URDF 关节位置限制和 J4 的 10°额外硬下限;
- 现有关节速度、关节加速度、TCP 线速度和角速度限制;
- 工作空间/圆柱限位、指令超时和安全停止;
- `configure_safety_limits` 默认启用;
- `move_to_initial_pose_on_connect` 默认关闭;
- mock 模式不依赖睿尔曼真机 SDK。
## 5. 验证顺序与通过标准
实施按以下顺序进行:
1. 先实现 QP 失败保持,以及关节目标与笛卡尔参考状态的成功后统一提交;
2. 加入 J4 的 10°硬下限与 25°软缓冲区;
3. 加入左右臂 J3 初始姿态软参考;
4. 加入按六维最小奇异值激活的 `both` 可操作度任务;
5.`use_mock:=true` 下运行初始位姿、前方 30~50 cm 采摘、本侧下方 40 cm 收集
筐和返回初始位姿的完整严格六维轨迹。
至少记录并比较修改前后的:QP 成功/失败周期数、连续失败长度、失败周期参考状态是否
保持不变、恢复时的关节跳变量、完整轨迹成功数、
六维最小奇异值、最大位置/姿态误差、J4 最小余量、最大关节速度以及目标历史与实际
TCP 的偏差。只有失败周期下降、完整轨迹成功率不降低、严格六维误差和全部安全约束
仍满足时,辅助项才保留;否则首先回退可操作度或 J4 软项,不回退失败状态修复。
@@ -1,415 +0,0 @@
# RM75 三种逆运动学方法离线对比实验设计
## 1. 目标
基于现有番茄采摘 episode 的右臂目标位姿轨迹,在完全一致的机械臂模型、初始关节
状态、时间轴、收敛判据和输出安全限制下,对比以下三种七自由度逆运动学方法:
1. Jacobian Moore-Penrose 伪逆法;
2. 阻尼最小二乘法(Damped Least SquaresDLS);
3. 当前项目中的优化 Placo QP 方法。
实验输出用于补充中期报告 2.3.4 节预留的三张图,并同时生成逐采样数据、汇总指标和
可直接粘贴到报告中的中文结果分析。实验必须由真实计算结果驱动,不预设或硬编码
“QP 更优”的结论。
## 2. 现有上下文
### 2.1 报告要求
中期报告 2.3.4 节已经确定:
- 三种方法使用同一机械臂模型、初始关节状态和末端目标轨迹;
- 统计位置 RMSE、姿态 RMSE、归一化关节安全裕度、最大关节速度、求解时间和
求解成功率;
- 章节末尾预留三张对比图。
现有图号从图 2-10 跳到图 2-14,因此本实验生成图 2-11、图 2-12 和图 2-13。
### 2.2 当前 QP 与报告文字的差异
报告 2.3.2 节主要描述六维末端软任务、动能正则化、关节位置和速度限制。当前分支的
`PlacoIkSolver` 还包含:
- J3 初始构型软引导;
- J4 硬下限和预警区软缓冲;
- 接近奇异区时动态启用的六维可操作度任务;
- QP 失败时恢复实际关节状态并保持上一安全输出。
本实验使用当前优化 QP,而不是关闭上述辅助任务的基础 QP。最终分析文件需要提供一段
方法补充文字,避免报告方法描述与对比对象不一致。
## 3. 范围与安全边界
### 3.1 本次包含
- 只读加载一个现有右臂 episode;
- 离线重采样目标位姿;
- 在同一 URDF 上运行三种逆运动学方法;
- 复用当前 QP 代码和右臂 YAML 参数;
- 对三种方法使用相同的输出端安全处理;
- 生成 SVG、300 dpi PNG、CSV、JSON 和中文 Markdown 分析。
### 3.2 本次不包含
- 不连接真机,不移动机械臂,不操作夹爪;
- 不启动新的 PICO 录制;
- 不修改生产遥操作节点、launch、YAML 默认值或公开 API
- 不使用 episode 中已经记录的 QP 关节结果充当本次 QP 结果;
- 不模拟电机、通信和接触动力学;
- 不直接编辑用户提供的 PDF。
实验只使用当前 Conda 环境已经安装的 NumPy、h5py、Matplotlib 和 Placo,不新增项目
依赖。
因此,结果应表述为“基于真实遥操作目标轨迹的离线运动学对比”,不得表述为新的真机
在线控制对比。
## 4. 数据源与质量基线
实验固定使用:
```text
/home/robot/ACT_Data/tomato_pick/episode_0.hdf5
```
该文件的已核对属性如下:
- 机械臂:`right_rm75`
- 样本数:484
- 有效时长:约 16.1 s
- 保存采样率:约 30 Hz
- 位姿顺序:`x,y,z,qx,qy,qz,qw`
- 所有目标位姿、当前位姿和关节状态均为有限值;
- 目标和当前四元数范数接近 1
- 483 帧为遥操作激活且已发送命令;
- 记录时 QP 尝试 483 次并成功 483 次;
- 132 帧触发过目标限幅,轨迹本身包含足够的约束压力。
只使用满足以下条件的最长连续区间:
```text
teleop_active && action_valid && command_sent
```
共同末端目标取 `debug/tcp/final_target_pose`。该字段已通过原系统的工作空间限制、目标
平滑和单帧笛卡尔步长限制,适合作为三种逆运动学方法的共同安全输入。共同初始关节角
取有效区间第一帧的 `observations/qpos[:7]`
episode 中后续 `observations/qpos``debug/qp/raw_target`、QP 成功标志和耗时只用于
数据质量核对,不替代任何方法在本实验中的离线计算结果。
## 5. 统一复放架构
数据流为:
```text
episode_0.hdf5
-> 有效区间与共同初始状态
-> 30 Hz 目标位姿重采样到 90 Hz
-> 伪逆 / DLS / 当前优化 QP 三路独立复放
-> 共同输出安全层
-> 正向运动学和逐采样指标
-> CSV / JSON / 三张图 / 中文分析
```
三种方法各自维护独立的关节状态和上一周期关节速度。每个方法的下一状态只能由该方法
本周期的安全输出推进,三路之间不共享可变状态。
离线状态推进采用理想位置跟随,即共同输出限速器给出的关节目标直接作为下一 90 Hz
周期的关节状态。这一简化隔离了逆运动学方法本身,不引入未建模的电机和网络差异。
## 6. 目标轨迹重采样
原 episode 按约 30 Hz 保存,而当前遥操作控制器使用 90 Hz。重采样使用 episode 的
`debug/timestamps/control_monotonic_ns`,目标时间轴保持原始起止时刻并以 1/90 s
采样:
- 位置使用分段线性插值;
- 姿态使用归一化四元数的最短弧 SLERP;
- 相邻四元数点积为负时先翻转后一四元数,避免绕长弧插值;
- 第一个和最后一个重采样位姿必须与原始有效区间端点一致;
- 不对目标轨迹额外放大、延长或人工加入困难片段。
## 7. 三种逆运动学方法
### 7.1 共同任务定义
当前关节状态为 `q`,正向运动学得到当前 TCP 位姿 `(p, R)`,目标位姿为
`(p_d, R_d)`。位置误差和姿态误差分别为:
```text
e_p = p_d - p
e_R = Log(R^T R_d)
```
求解时的角速度误差表达必须与所用 `local_world_aligned` Jacobian 的坐标表达一致;
姿态误差大小统一使用目标与实际旋转矩阵之间的最短夹角评价。伪逆和 DLS 使用相同的
位置、姿态反馈增益、相同 Jacobian、相同 90 Hz 步长和相同数值迭代框架。
三种方法对单个目标最多执行 30 次数值迭代。满足以下两个条件时记为收敛:
```text
位置误差 <= 0.002 m
姿态误差 <= 0.005 rad
```
### 7.2 Jacobian 伪逆法
伪逆法按报告公式计算:
```text
q_dot = pinv(J) * v_d
```
其中 `v_d` 由共同的六维位姿反馈误差生成。实现直接使用 NumPy 的 Moore-Penrose
伪逆,不增加零空间任务、阻尼或自适应奇异值阈值,以保持基线定义清楚。
### 7.3 DLS 方法
DLS 按报告公式计算:
```text
q_dot = J^T * inv(J * J^T + mu^2 * I) * v_d
```
公式保持与报告一致;数值实现使用线性方程求解,不显式计算矩阵逆。
只扫描固定阻尼系数,不实现自适应 DLS。候选值使用对数尺度的小集合:
```text
0.001, 0.003, 0.01, 0.03, 0.1, 0.3
```
每个候选均完整复放 episode,先按求解成功率从高到低选择,再在成功率相同的候选中
最小化:
```text
位置 RMSE / 0.002 + 姿态 RMSE / 0.005
```
若仍并列,选择最大关节速度更小的候选。阻尼扫描使用同一条评价轨迹,因此最终文字
必须说明该 DLS 是“在当前轨迹上选优的固定阻尼基线”;这一口径对 DLS 较有利,不能
将其解释为跨轨迹最优参数。
阻尼扫描耗时不计入三种方法的在线求解时间对比。
### 7.4 当前优化 QP
QP 直接实例化现有 `xr_rm_teleop.placo_ik_solver.PlacoIkSolver`,使用 90 Hz 步长、
当前双臂 URDF 和右臂配置中的参数:
```text
qp_j3_reference_deg: -89.57
qp_j3_weight: 0.0001
qp_j4_min_deg: 10.0
qp_j4_warn_deg: 25.0
qp_j4_weight: 0.0001
qp_manipulability_sigma_stop: 0.01
qp_manipulability_sigma_warn: 0.04
qp_manipulability_weight: 0.0001
```
保留现有六维末端软任务、`1e-6` 动能正则化、URDF 关节位置和速度限制、30 次迭代、
收敛阈值、输入变换校验、失败恢复和结果有效性检查。实验脚本不复制或重写 QP。
## 8. 共同输出安全层与失败处理
三种方法使用同一安全口径:
1. 每次数值迭代结果必须为 7 个有限关节值;
2. 数值迭代的关节状态不得超出 URDF 位置范围;
3. 相邻数值迭代的关节变化不得超过 URDF 速度上限乘以 `1/90 s`
4. 有效求解结果继续经过生产控制器现有的关节速度/加速度限制逻辑;
5. 输出端最大关节速度为 `180 deg/s`,最大关节加速度为 `300 deg/s^2`
6. 未在 30 次内收敛、出现非有限值或违反硬边界时,本周期记为失败并保持上一安全
关节状态;
7. 失败不会停止离线复放,时间轴继续推进,并记录失败次数和最长连续失败长度。
QP 在优化内部主动处理关节边界;伪逆和 DLS 在每次候选步之后接受同样的硬检查。
共同输出层不会消除算法差异:基线仍可能因候选步无效而失败或保持,QP 则可能在优化
过程中找到满足约束的解。
## 9. 评价指标
### 9.1 末端跟踪
- 逐采样位置误差 `||p_d - p||`,单位为 mm
- 逐采样姿态夹角误差,单位为 degree;
- 全轨迹位置 RMSE,报告中仍以 m 给出,图中用 mm;
- 全轨迹姿态 RMSE,报告公式使用 rad,图中用 degree。
### 9.2 关节运动
- 每个采样时刻七关节绝对速度的最大值,单位为 `deg/s`
- 全轨迹最大关节速度;
- 超过或触发共同速度/加速度限制器的周期数;
- 按报告式 (2-28) 计算的逐采样最小归一化关节安全裕度;
- 全轨迹最小归一化关节安全裕度。
速度图不再使用归一化速度。当前 URDF 中右臂七个关节的速度上限均为 `3.14 rad/s`
(约 `180 deg/s`),直接展示实际速度更直观且与报告文字一致。
### 9.3 求解性能
- 收敛成功周期数和成功率;
- 失败周期数和最长连续失败长度;
- 单周期 IK 求解平均时间和最大时间,单位为 ms。
耗时只覆盖单次 IK 求解,不包含 HDF5 读取、重采样、指标汇总和绘图。先执行一次完整
预热复放,再对选定参数的三种方法各重复 10 次。轨迹和非耗时指标必须在重复复放间
保持确定;平均和最大耗时从 10 次计时复放汇总。
## 10. 三张图设计
### 10.1 图 2-11 三种逆运动学方法末端位姿跟踪误差对比
使用上下两个共享时间轴的子图:
- `(a)` 位置误差时序,单位 mm
- `(b)` 姿态误差时序,单位 degree。
三种方法使用固定颜色、不同线型,并在失败保持区间添加不遮挡曲线的标记。图中不绘制
episode 原始 QP 误差曲线。
### 10.2 图 2-12 三种逆运动学方法关节运动约束对比
使用两个共享时间轴的子图:
- `(a)` 每个时刻的最大关节速度,单位 `deg/s`,并绘制 `180 deg/s` 虚线;
- `(b)` 每个时刻的最小归一化关节安全裕度,数值越大表示离关节上下限越远。
### 10.3 图 2-13 三种逆运动学方法综合性能指标对比
使用 `2 x 3` 六个小型分组柱状图,避免不同量纲共用坐标轴:
1. 位置 RMSE
2. 姿态 RMSE
3. 最大关节速度;
4. 最小归一化关节安全裕度;
5. 平均和最大求解时间;
6. 求解成功率。
柱顶标注精确数值。最终配色需兼顾色盲识别和灰度打印,除颜色外再使用线型、标记和
图例区分方法。
## 11. 文件与产物
实验实现优先保持最小范围:
```text
xr_rm_teleop/test/ik_method_comparison.py
xr_rm_teleop/test/test_ik_method_comparison.py
```
前者包含命令行入口、HDF5 读取、重采样、三种方法复放、指标计算和绘图;后者只覆盖
无法由现有测试保护的新非平凡逻辑,不新增测试框架或通用评测抽象。
默认输出目录为:
```text
output/ik_comparison/episode_0/
```
产物包括:
```text
samples.csv
summary.json
figure_2_11_tracking_error.svg
figure_2_11_tracking_error.png
figure_2_12_joint_constraints.svg
figure_2_12_joint_constraints.png
figure_2_13_summary.svg
figure_2_13_summary.png
analysis_2.3.4.md
```
`samples.csv` 使用长表结构,每行对应“方法 + 时间点”,至少包含目标位姿、实际位姿、
位置误差、姿态误差、七关节角、七关节速度、最小安全裕度、求解耗时、成功标志和限制
触发标志。`summary.json` 保存输入路径、Git 提交、参数、选定 DLS 阻尼、指标和产物路径,
保证结果可追溯。
`analysis_2.3.4.md` 使用中文撰写,包含:
- 数据来源和离线实验口径;
- DLS 最终阻尼和选择规则;
- 三张图的建议图题与图注;
- 与式 (2-25) 至式 (2-28) 对应的数值结果;
- 对优势、代价和异常结果的客观分析;
- 当前优化 QP 相对报告 2.3.2 节的补充方法说明。
## 12. 错误处理
以下情况在生成任何正式图前立即报错:
- episode 路径不存在或不是 HDF5
- 必需字段或属性缺失;
- 数组长度不一致;
- 找不到至少包含两个样本的连续有效遥操作区间;
- 时间戳非严格递增;
- 位姿、关节角或四元数含 NaN/Inf;
- 四元数无法正规化;
- episode 机械臂不是 `right_rm75`
- URDF 或当前右臂配置不存在;
- Placo 版本不是项目固定的 0.9.4;
- 任一方法没有生成与统一时间轴等长的结果;
- CSV、JSON 和绘图使用的汇总数值不一致。
单个目标的逆运动学失败属于实验结果,按上一安全状态保持,不中止整条轨迹。输入数据
结构错误、模型错误和结果长度错误属于实验无效,必须中止并说明原因。
## 13. 测试与验证
### 13.1 聚焦测试
最小测试至少覆盖:
- 30 Hz 到 90 Hz 重采样保持首尾位置和姿态;
- SLERP 选择最短弧并输出单位四元数;
- 姿态夹角误差在单位旋转和已知小角度下正确;
- 关节安全裕度与式 (2-28) 一致;
- 无效候选触发失败保持而不是推进状态;
- DLS 选择规则按成功率、归一化误差和最大速度依次决策;
- 汇总指标与逐采样数据一致。
### 13.2 真实模型冒烟验证
使用当前双臂 URDF 和右臂初始关节角,对三种方法各运行一小段真实目标位姿序列,确认:
- 输出始终为有限 7 维关节值;
- 没有输出越过 URDF 关节位置边界;
- 失败时保持上一安全状态;
- 当前 QP 直接走现有 `PlacoIkSolver`,没有本地复制实现。
### 13.3 项目级验证
从工作空间根目录 `/home/robot/WS_xr` 执行,并先加载 ROS2 Humble
```bash
source /opt/ros/humble/setup.bash
colcon build --symlink-install
pytest src/xr_rm_teleop/test/test_orientation_control.py
```
随后使用项目固定的 Conda Python 运行聚焦测试和完整离线实验。验证完成后还需检查:
- 三张 PNG 无裁切、重叠、乱码或不可辨识曲线;
- SVG 可编辑且文字完整;
- PNG 为 300 dpi
- 图题、坐标轴、单位和图例为中文论文风格;
- `summary.json` 与图中柱顶数值一致;
- 同一输入重复运行时,除耗时外的结果一致。
## 14. 验收标准
满足以下条件才视为完成:
1. 三种方法从完全相同的 episode 目标轨迹和初始关节状态开始;
2. 当前优化 QP 复用现有实现和右臂参数;
3. 三种方法使用同一输出安全口径,任何失败均安全保持;
4. DLS 固定阻尼选择过程和最终值可追溯;
5. 生成三张与报告公式和图号一致的正式对比图;
6. 生成完整 CSV、JSON 和中文 2.3.4 分析文字;
7. 所有实际执行的测试和构建结果如实记录;
8. 不连接真机、不修改生产控制默认值、不新增依赖和重复 QP 实现。
@@ -1,29 +0,0 @@
# 2.3.4 三种逆运动学方法对比补充分析
本结果是基于真实遥操作目标轨迹的离线运动学对比,不代表真机闭环实验。数据来自
`/home/robot/ACT_Data/tomato_pick/episode_0.hdf5`,目标位姿以 90.0 Hz 重采样;三种方法使用同一初始
关节状态、同一 URDF、相同收敛阈值和共同的输出速度/加速度限制。
DLS 扫描的固定阻尼候选为 0.001, 0.003, 0.01, 0.03, 0.1, 0.3,本轨迹选定
`0.3`。该参数是在当前评价轨迹上选优,不应解释为跨轨迹最优参数。
| 方法 | 位置 RMSE (m) | 姿态 RMSE (rad) | 最大关节速度 (°/s) | 最小归一化裕度 | 平均/最大求解时间 (ms) | 成功率 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Jacobian 伪逆 | 0.260542 | 0.713938 | 30.000 | 0.1509 | 0.136 / 1.061 | 1.17% |
| DLS | 0.005845 | 0.011201 | 49.999 | 0.0607 | 0.391 / 1.942 | 100.00% |
| 优化 QP | 0.004929 | 0.011075 | 46.666 | 0.0611 | 0.206 / 1.057 | 100.00% |
图 2-11 三种逆运动学方法的末端位置与姿态跟踪误差。纵轴采用对数坐标以同时显示不同
数量级的误差,叉号稀疏标记数值求解失败并保持上一安全关节状态的周期。
图 2-12 三种逆运动学方法的最大关节速度与最小归一化关节安全裕度。红色虚线表示
180°/s 输出速度上限,裕度越大表示离关节位置边界越远。
图 2-13 三种逆运动学方法的综合性能对比,包括误差、关节运动、求解时间和成功率。
按本次单轨迹数值比较,位置 RMSE 最低的方法为优化 QP,姿态 RMSE 最低的方法为
优化 QP,成功率最高的方法为DLS、优化 QP。这些结论只描述本次离线复放,
未进行统计显著性检验。
当前优化 QP 除六维末端主任务外,还保留项目中的 J3 参考软任务、J4 硬下界与软缓冲,
以及按最小奇异值动态激活的六维可操作度任务;伪逆和 DLS 基线不包含这些附加任务。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 179 KiB

File diff suppressed because it is too large Load Diff
@@ -1,50 +0,0 @@
{
"source_episode": "/home/robot/ACT_Data/tomato_pick/episode_0.hdf5",
"git_commit": "f30aac547b6fe84703044c66b30b70720ee4d40a",
"sample_rate_hz": 90.00141938026849,
"selected_dls_damping": 0.3,
"methods": {
"pinv": {
"method": "pinv",
"damping": null,
"success_rate": 0.011748445058742226,
"position_rmse_m": 0.26054182896155575,
"orientation_rmse_rad": 0.713938311140316,
"max_joint_speed_deg_s": 29.999526880705357,
"min_joint_margin": 0.15094435580838983,
"mean_solve_ms": 0.13594157401520388,
"max_solve_ms": 1.061404,
"failure_count": 1430,
"longest_failure_streak": 1430,
"command_limited_count": 17
},
"dls": {
"method": "dls",
"damping": 0.3,
"success_rate": 1.0,
"position_rmse_m": 0.005845412147117041,
"orientation_rmse_rad": 0.011200514621088859,
"max_joint_speed_deg_s": 49.999211467842265,
"min_joint_margin": 0.060689206887586424,
"mean_solve_ms": 0.39115279765031097,
"max_solve_ms": 1.94196,
"failure_count": 0,
"longest_failure_streak": 0,
"command_limited_count": 1282
},
"qp": {
"method": "qp",
"damping": null,
"success_rate": 1.0,
"position_rmse_m": 0.004929450681893051,
"orientation_rmse_rad": 0.01107496419458487,
"max_joint_speed_deg_s": 46.66593070331945,
"min_joint_margin": 0.06105232712172298,
"mean_solve_ms": 0.20632571050449205,
"max_solve_ms": 1.057197,
"failure_count": 0,
"longest_failure_streak": 0,
"command_limited_count": 1267
}
}
}
+33
View File
@@ -0,0 +1,33 @@
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
+2 -21
View File
@@ -28,16 +28,6 @@ left_arm_teleop:
orientation_deadband_rad: 0.005 orientation_deadband_rad: 0.005
orientation_filter_alpha: 0.65 orientation_filter_alpha: 0.65
max_orientation_speed: 0.5 max_orientation_speed: 0.5
# QP 辅助任务:严格六维 TCP 主任务保持最高权重。
qp_j3_reference_deg: 67.96
qp_j3_weight: 0.00001
qp_j4_min_deg: 10.0
qp_j4_warn_deg: 25.0
qp_j4_weight: 0.0001
qp_manipulability_sigma_stop: 0.01
qp_manipulability_sigma_warn: 0.04
qp_manipulability_weight: 0.0001
workspace_min: [-0.70, -0.70, 0.10] workspace_min: [-0.70, -0.70, 0.10]
workspace_max: [0.70, 0.10, 0.75] workspace_max: [0.70, 0.10, 0.75]
cyl_radius_limit: [0.10, 0.80] cyl_radius_limit: [0.10, 0.80]
@@ -55,7 +45,7 @@ left_arm_teleop:
realtime_push_host_ip: 192.168.192.148 realtime_push_host_ip: 192.168.192.148
realtime_push_port: 8089 realtime_push_port: 8089
realtime_push_cycle_ms: 5 realtime_push_cycle_ms: 5
avoid_singularity: 1 avoid_singularity: 0
follow: false follow: false
canfd_trajectory_mode: 2 canfd_trajectory_mode: 2
canfd_radio: 0 canfd_radio: 0
@@ -95,15 +85,6 @@ right_arm_teleop:
orientation_deadband_rad: 0.005 orientation_deadband_rad: 0.005
orientation_filter_alpha: 0.65 orientation_filter_alpha: 0.65
max_orientation_speed: 0.5 max_orientation_speed: 0.5
qp_j3_reference_deg: -89.57
qp_j3_weight: 0.0001
qp_j4_min_deg: 10.0
qp_j4_warn_deg: 25.0
qp_j4_weight: 0.0001
qp_manipulability_sigma_stop: 0.01
qp_manipulability_sigma_warn: 0.04
qp_manipulability_weight: 0.0001
workspace_min: [-0.70, -0.70, 0.10] workspace_min: [-0.70, -0.70, 0.10]
workspace_max: [0.70, 0.10, 0.75] workspace_max: [0.70, 0.10, 0.75]
cyl_radius_limit: [0.10, 0.80] cyl_radius_limit: [0.10, 0.80]
@@ -121,7 +102,7 @@ right_arm_teleop:
realtime_push_host_ip: 192.168.192.148 realtime_push_host_ip: 192.168.192.148
realtime_push_port: 8090 realtime_push_port: 8090
realtime_push_cycle_ms: 5 realtime_push_cycle_ms: 5
avoid_singularity: 1 avoid_singularity: 0
follow: false follow: false
canfd_trajectory_mode: 2 canfd_trajectory_mode: 2
canfd_radio: 0 canfd_radio: 0
+1 -11
View File
@@ -22,16 +22,6 @@ single_arm_velocity_teleop:
orientation_deadband_rad: 0.005 orientation_deadband_rad: 0.005
orientation_filter_alpha: 0.65 orientation_filter_alpha: 0.65
max_orientation_speed: 0.5 max_orientation_speed: 0.5
# QP 辅助任务:严格六维 TCP 主任务保持最高权重。
qp_j3_reference_deg: 67.96
qp_j3_weight: 0.00001
qp_j4_min_deg: 10.0
qp_j4_warn_deg: 25.0
qp_j4_weight: 0.0001
qp_manipulability_sigma_stop: 0.01
qp_manipulability_sigma_warn: 0.04
qp_manipulability_weight: 0.0001
workspace_min: [-0.70, -0.70, 0.10] workspace_min: [-0.70, -0.70, 0.10]
workspace_max: [0.70, 0.10, 0.75] workspace_max: [0.70, 0.10, 0.75]
cyl_radius_limit: [0.10, 0.80] cyl_radius_limit: [0.10, 0.80]
@@ -48,7 +38,7 @@ single_arm_velocity_teleop:
realtime_push_host_ip: 192.168.192.148 realtime_push_host_ip: 192.168.192.148
realtime_push_port: 8089 realtime_push_port: 8089
realtime_push_cycle_ms: 5 realtime_push_cycle_ms: 5
avoid_singularity: 1 avoid_singularity: 0
follow: false follow: false
canfd_trajectory_mode: 2 canfd_trajectory_mode: 2
canfd_radio: 0 canfd_radio: 0
@@ -27,3 +27,4 @@ arms:
scissorgripper: 0 scissorgripper: 0
right: right:
scissorgripper: 1 scissorgripper: 1
set_initial_tool_state: true
+1 -11
View File
@@ -21,16 +21,6 @@ single_arm_velocity_teleop:
orientation_deadband_rad: 0.005 orientation_deadband_rad: 0.005
orientation_filter_alpha: 0.65 orientation_filter_alpha: 0.65
max_orientation_speed: 0.5 max_orientation_speed: 0.5
# QP 辅助任务:严格六维 TCP 主任务保持最高权重。
qp_j3_reference_deg: -89.57
qp_j3_weight: 0.0001
qp_j4_min_deg: 10.0
qp_j4_warn_deg: 25.0
qp_j4_weight: 0.0001
qp_manipulability_sigma_stop: 0.01
qp_manipulability_sigma_warn: 0.04
qp_manipulability_weight: 0.0001
workspace_min: [-0.70, -0.70, 0.10] workspace_min: [-0.70, -0.70, 0.10]
workspace_max: [0.70, 0.10, 0.75] workspace_max: [0.70, 0.10, 0.75]
cyl_radius_limit: [0.10, 0.80] cyl_radius_limit: [0.10, 0.80]
@@ -47,7 +37,7 @@ single_arm_velocity_teleop:
realtime_push_host_ip: 192.168.192.148 realtime_push_host_ip: 192.168.192.148
realtime_push_port: 8090 realtime_push_port: 8090
realtime_push_cycle_ms: 5 realtime_push_cycle_ms: 5
avoid_singularity: 1 avoid_singularity: 0
# 厂商 MovejCANFD 示例默认低跟随;高跟随仅在验证规划轨迹后单独开启。 # 厂商 MovejCANFD 示例默认低跟随;高跟随仅在验证规划轨迹后单独开启。
follow: false follow: false
canfd_trajectory_mode: 2 canfd_trajectory_mode: 2
+31
View File
@@ -80,6 +80,29 @@ def _validate_mujoco_mode(arm: str, use_mujoco: bool) -> None:
raise ValueError("use_mujoco:=true requires arm:=both") raise ValueError("use_mujoco:=true requires arm:=both")
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"
)
def _act_recorder_node() -> Node:
"""创建独立 ACT 数据采集节点;退出时不终止遥操作。"""
return Node(
package="xr_rm_teleop",
executable="act_episode_recorder",
name="act_episode_recorder",
output="screen",
prefix=[XR_PYTHON],
parameters=[_config_file("act_tomato_pick.yaml")],
)
def _single_arm_node( def _single_arm_node(
arm: str, arm: str,
use_mock: bool, use_mock: bool,
@@ -164,10 +187,14 @@ def _launch_setup(context, *args, **kwargs):
use_mujoco = _as_bool( use_mujoco = _as_bool(
LaunchConfiguration("use_mujoco").perform(context) LaunchConfiguration("use_mujoco").perform(context)
) )
record_act = _as_bool(
LaunchConfiguration("record_act").perform(context)
)
if arm not in ("left", "right", "both"): if arm not in ("left", "right", "both"):
raise ValueError("arm must be one of: left, right, both") raise ValueError("arm must be one of: left, right, both")
_validate_mujoco_mode(arm, use_mujoco) _validate_mujoco_mode(arm, use_mujoco)
_validate_act_mode(arm, use_mock, record_act)
nodes = [_udp_receiver_node()] nodes = [_udp_receiver_node()]
if arm == "both": if arm == "both":
@@ -176,6 +203,8 @@ def _launch_setup(context, *args, **kwargs):
nodes.append(_single_arm_node(arm, use_mock)) nodes.append(_single_arm_node(arm, use_mock))
if use_mujoco: if use_mujoco:
nodes.append(_mujoco_node()) nodes.append(_mujoco_node())
if record_act:
nodes.append(_act_recorder_node())
return nodes return nodes
@@ -187,6 +216,8 @@ def generate_launch_description() -> LaunchDescription:
DeclareLaunchArgument("use_mock", default_value="true"), DeclareLaunchArgument("use_mock", default_value="true"),
# true 时额外启动只读 MuJoCo 双臂显示,默认不改变现有启动行为。 # true 时额外启动只读 MuJoCo 双臂显示,默认不改变现有启动行为。
DeclareLaunchArgument("use_mujoco", default_value="false"), DeclareLaunchArgument("use_mujoco", default_value="false"),
# true 时只允许右臂真机,并启动独立 ACT 数据采集节点。
DeclareLaunchArgument("record_act", default_value="false"),
# UDP 监听参数,需要与 PICO 端或 sample_udp_sender 保持一致。 # UDP 监听参数,需要与 PICO 端或 sample_udp_sender 保持一致。
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"), DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
DeclareLaunchArgument("udp_port", default_value="15000"), DeclareLaunchArgument("udp_port", default_value="15000"),
@@ -41,3 +41,37 @@ def test_udp_receiver_exit_shuts_down_launch() -> None:
receiver = arm_debug_launch._udp_receiver_node() receiver = arm_debug_launch._udp_receiver_node()
assert isinstance(receiver._ExecuteLocal__on_exit, Shutdown) assert isinstance(receiver._ExecuteLocal__on_exit, Shutdown)
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)
def test_act_recorder_exit_does_not_shutdown_teleoperation() -> None:
recorder = arm_debug_launch._act_recorder_node()
assert recorder._ExecuteLocal__on_exit is None
@@ -46,6 +46,14 @@ class LauncherCommandsTest(unittest.TestCase):
"Open ROS Topic/Node List Monitor", "Open ROS Topic/Node List Monitor",
"Open Controller Topic Monitor", "Open Controller Topic Monitor",
], ],
"ACT Data Collection": [
"Right Arm ACT Data Collection Launch",
"XRobotoolkit UDP Bridge (90 Hz)",
"Open ACT Recording Status",
"Open Right Arm ACT Control Sample Hz",
"Open ROS Topic/Node List Monitor",
"Open Controller Topic Monitor",
],
"Diagnostics": [ "Diagnostics": [
"ROS Doctor Report", "ROS Doctor Report",
"XR-RM Bringup Prefix", "XR-RM Bringup Prefix",
@@ -79,6 +87,22 @@ class LauncherCommandsTest(unittest.TestCase):
commands["2. Dual Arm MuJoCo Real Hardware Launch"], commands["2. Dual Arm MuJoCo Real Hardware Launch"],
) )
def test_act_mode_uses_confirmed_right_hardware_topics(self) -> None:
commands = dict(launcher_ui.build_commands_by_mode("ACT Data Collection"))
self.assertIn(
"arm:=right use_mock:=false record_act:=true",
commands["1. Right Arm ACT Data Collection Launch"],
)
self.assertEqual(
commands["3. Open ACT Recording Status"],
"ros2 topic echo /act/recording_status",
)
self.assertEqual(
commands["4. Open Right Arm ACT Control Sample Hz"],
"ros2 topic hz /xr_rm/right_rm75/act_control_sample",
)
def test_cmd_vel_monitor_is_completely_removed(self) -> None: def test_cmd_vel_monitor_is_completely_removed(self) -> None:
self.assertFalse(hasattr(launcher_ui, "CMD_VEL_MONITOR_ACTION")) self.assertFalse(hasattr(launcher_ui, "CMD_VEL_MONITOR_ACTION"))
for mode in launcher_ui.MODES: for mode in launcher_ui.MODES:
@@ -0,0 +1,76 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import pytest
MODULE_PATH = (
Path(__file__).resolve().parents[1]
/ "tools"
/ "realsense_multi_camera_test.py"
)
SPEC = importlib.util.spec_from_file_location("realsense_multi_camera_test", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
camera_test = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = camera_test
SPEC.loader.exec_module(camera_test)
def devices() -> list[camera_test.DeviceInfo]:
return [
camera_test.DeviceInfo("Intel RealSense D405", "D405", "412622272532", "3.2"),
camera_test.DeviceInfo("Intel RealSense D455", "D455", "234222303366", "3.2"),
camera_test.DeviceInfo("Intel RealSense D405", "D405", "260322272273", "3.2"),
]
def test_assigns_camera_roles_with_and_without_left_serial() -> None:
unidentified = camera_test.assign_camera_roles(devices(), None)
assert [camera.role for camera in unidentified] == ["GLOBAL", "D405-A", "D405-B"]
assert [camera.serial for camera in unidentified[1:]] == ["260322272273", "412622272532"]
identified = camera_test.assign_camera_roles(devices(), "412622272532")
assert {camera.role: camera.serial for camera in identified} == {
"GLOBAL": "234222303366",
"LEFT": "412622272532",
"RIGHT": "260322272273",
}
def test_rejects_invalid_camera_selection() -> None:
with pytest.raises(ValueError, match="左臂序列号"):
camera_test.assign_camera_roles(devices(), "missing")
with pytest.raises(ValueError, match="2 台 D405 和 1 台 D455"):
camera_test.assign_camera_roles(devices()[:-1], None)
def test_counts_frame_number_gaps() -> None:
stats = camera_test.FrameStats(target_fps=30, start_time=0.0)
stats.update(10, 0.0)
stats.update(11, 1.0 / 30.0)
stats.update(14, 2.0 / 30.0)
assert stats.received == 3
assert stats.dropped == 2
assert stats.drop_rate == pytest.approx(0.4)
assert stats.average_fps(2.0 / 30.0) == pytest.approx(30.0)
def test_snapshot_names_follow_camera_roles() -> None:
identified = camera_test.assign_camera_roles(devices(), "412622272532")
assert camera_test.snapshot_filenames(identified) == {
"234222303366": "global.png",
"412622272532": "left.png",
"260322272273": "right.png",
}
unidentified = camera_test.assign_camera_roles(devices(), None)
assert camera_test.snapshot_filenames(unidentified) == {
"234222303366": "global.png",
"260322272273": "d405_260322272273.png",
"412622272532": "d405_412622272532.png",
}
+20 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""XR-RM 桌面调试启动器。 """XR-RM 桌面调试启动器。
提供 Tkinter 图形界面仿真/MuJoCo/真机/诊断组织常用 ROS2 launch 提供 Tkinter 图形界面仿真/MuJoCo/真机/ACT采集/诊断组织常用 ROS2 launch
sample_udp_sendertopic 监控和环境检查命令降低现场调试时的命令输入成本 sample_udp_sendertopic 监控和环境检查命令降低现场调试时的命令输入成本
""" """
@@ -76,6 +76,7 @@ MODES = [
"Simulation", "Simulation",
"MuJoCo", "MuJoCo",
"Real Hardware", "Real Hardware",
"ACT Data Collection",
"Diagnostics", "Diagnostics",
] ]
@@ -275,6 +276,23 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
("Right Gripper Open", _tool_command("right", True)), ("Right Gripper Open", _tool_command("right", True)),
("Right Gripper Close", _tool_command("right", False)), ("Right Gripper Close", _tool_command("right", False)),
] ]
elif mode == "ACT Data Collection":
items = [
(
"Right Arm ACT Data Collection Launch",
"ros2 launch xr_rm_bringup arm_debug.launch.py "
"arm:=right use_mock:=false record_act:=true",
),
("XRobotoolkit UDP Bridge (90 Hz)", _xrobotoolkit_bridge_command()),
(
"Open ACT Recording Status",
"ros2 topic echo /act/recording_status",
),
(
"Open Right Arm ACT Control Sample Hz",
"ros2 topic hz /xr_rm/right_rm75/act_control_sample",
),
]
else: else:
items = [ items = [
("ROS Doctor Report", "ros2 doctor --report"), ("ROS Doctor Report", "ros2 doctor --report"),
@@ -329,7 +347,7 @@ class LauncherApp:
mode_frame, mode_frame,
textvariable=self.mode_var, textvariable=self.mode_var,
values=MODES, values=MODES,
width=16, width=20,
state="readonly", state="readonly",
) )
self.mode_combo.grid(row=0, column=1, sticky="ew") self.mode_combo.grid(row=0, column=1, sticky="ew")
+494
View File
@@ -0,0 +1,494 @@
#!/usr/bin/env python3
"""同时预览并检查两台 D405 和一台 D455 的彩色画面。"""
from __future__ import annotations
import argparse
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
import threading
import time
from typing import Any
DEFAULT_WIDTH = 640
DEFAULT_HEIGHT = 480
DEFAULT_FPS = 30
DEFAULT_LEFT_SERIAL = "260322272273"
FPS_WINDOW_SECONDS = 2.0
WARMUP_SECONDS = 5.0
MAX_DROP_RATE = 0.01
MIN_FPS_RATIO = 0.9
PREVIEW_TILE_WIDTH = 640
WINDOW_NAME = "XR RM - Three RealSense Camera Test"
OUTPUT_DIR = Path(__file__).resolve().parents[1] / "test" / "camera_test_output"
@dataclass(frozen=True)
class DeviceInfo:
name: str
model: str
serial: str
usb_type: str
@dataclass(frozen=True)
class CameraAssignment:
role: str
name: str
model: str
serial: str
usb_type: str
def assign_camera_roles(
devices: list[DeviceInfo], left_serial: str | None
) -> list[CameraAssignment]:
d405 = sorted(
(device for device in devices if device.model == "D405"),
key=lambda device: device.serial,
)
d455 = [device for device in devices if device.model == "D455"]
if len(d405) != 2 or len(d455) != 1 or len(devices) != 3:
raise ValueError(
f"需要连接 2 台 D405 和 1 台 D455,当前识别到 "
f"{len(d405)} 台 D405、{len(d455)} 台 D455、共 {len(devices)} 台 RealSense"
)
for device in devices:
if not device.usb_type.startswith("3"):
raise ValueError(
f"{device.model} ({device.serial}) 当前为 USB {device.usb_type}"
"请检查扩展坞和数据线"
)
global_camera = CameraAssignment("GLOBAL", **d455[0].__dict__)
if left_serial is None:
arms = [
CameraAssignment(f"D405-{suffix}", **device.__dict__)
for suffix, device in zip(("A", "B"), d405)
]
else:
matches = [device for device in d405 if device.serial == left_serial]
if not matches:
raise ValueError(f"左臂序列号 {left_serial} 不属于当前连接的 D405")
left = matches[0]
right = next(device for device in d405 if device.serial != left_serial)
arms = [
CameraAssignment("LEFT", **left.__dict__),
CameraAssignment("RIGHT", **right.__dict__),
]
return [global_camera, *arms]
def snapshot_filenames(assignments: list[CameraAssignment]) -> dict[str, str]:
role_names = {
"GLOBAL": "global.png",
"LEFT": "left.png",
"RIGHT": "right.png",
}
return {
camera.serial: role_names.get(camera.role, f"d405_{camera.serial}.png")
for camera in assignments
}
@dataclass
class FrameStats:
target_fps: int
start_time: float
received: int = 0
dropped: int = 0
last_frame_number: int | None = None
first_frame_time: float | None = None
recent_times: deque[float] = field(default_factory=deque)
def update(self, frame_number: int, now: float) -> None:
if self.last_frame_number is not None and frame_number > self.last_frame_number:
self.dropped += max(0, frame_number - self.last_frame_number - 1)
self.last_frame_number = frame_number
self.received += 1
if self.first_frame_time is None:
self.first_frame_time = now
self.recent_times.append(now)
cutoff = now - FPS_WINDOW_SECONDS
while self.recent_times and self.recent_times[0] < cutoff:
self.recent_times.popleft()
@property
def drop_rate(self) -> float:
expected = self.received + self.dropped
return self.dropped / expected if expected else 0.0
@property
def rolling_fps(self) -> float:
if len(self.recent_times) < 2:
return 0.0
elapsed = self.recent_times[-1] - self.recent_times[0]
return (len(self.recent_times) - 1) / elapsed if elapsed > 0 else 0.0
def average_fps(self, now: float) -> float:
if self.received < 2 or self.first_frame_time is None:
return 0.0
last_frame_time = self.recent_times[-1] if self.recent_times else now
elapsed = last_frame_time - self.first_frame_time
return (self.received - 1) / elapsed if elapsed > 0 else 0.0
@dataclass(frozen=True)
class CameraState:
frame: Any | None
actual_size: tuple[int, int]
received: int
dropped: int
drop_rate: float
rolling_fps: float
average_fps: float
elapsed: float
error: str
class CameraWorker:
def __init__(
self,
assignment: CameraAssignment,
width: int,
height: int,
fps: int,
rs: Any,
np: Any,
) -> None:
self.assignment = assignment
self.width = width
self.height = height
self.fps = fps
self._rs = rs
self._np = np
self._pipeline = rs.pipeline()
self._stop_event = threading.Event()
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
self._frame: Any | None = None
self._actual_size = (width, height)
self._stats = FrameStats(fps, time.monotonic())
self._error = ""
def start(self) -> None:
config = self._rs.config()
config.enable_device(self.assignment.serial)
config.enable_stream(
self._rs.stream.color,
self.width,
self.height,
self._rs.format.bgr8,
self.fps,
)
profile = self._pipeline.start(config)
video_profile = profile.get_stream(self._rs.stream.color).as_video_stream_profile()
with self._lock:
self._actual_size = (video_profile.width(), video_profile.height())
self._stats = FrameStats(self.fps, time.monotonic())
self._thread = threading.Thread(
target=self._capture_loop,
name=f"camera-{self.assignment.serial}",
daemon=True,
)
self._thread.start()
def _capture_loop(self) -> None:
while not self._stop_event.is_set():
try:
frames = self._pipeline.wait_for_frames(timeout_ms=1000)
except RuntimeError as exc:
if self._stop_event.is_set():
return
with self._lock:
self._error = str(exc)
return
color_frame = frames.get_color_frame()
if not color_frame:
continue
frame = self._np.asanyarray(color_frame.get_data()).copy()
now = time.monotonic()
with self._lock:
self._frame = frame
self._stats.update(color_frame.get_frame_number(), now)
def state(self, now: float) -> CameraState:
with self._lock:
return CameraState(
frame=self._frame,
actual_size=self._actual_size,
received=self._stats.received,
dropped=self._stats.dropped,
drop_rate=self._stats.drop_rate,
rolling_fps=self._stats.rolling_fps,
average_fps=self._stats.average_fps(now),
elapsed=now - self._stats.start_time,
error=self._error,
)
def stop(self) -> None:
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=1.2)
try:
self._pipeline.stop()
except RuntimeError:
pass
if self._thread is not None and self._thread.is_alive():
self._thread.join(timeout=1.0)
def load_runtime_dependencies() -> tuple[Any, Any, Any]:
try:
import cv2
import numpy as np
import pyrealsense2 as rs
except ImportError as exc:
raise RuntimeError(
"缺少相机测试依赖。请使用 /home/robot/miniconda3/envs/xr/bin/python "
"运行,并确认 xr 环境已安装 pyrealsense2、numpy 和 opencv-python。"
) from exc
return cv2, np, rs
def enumerate_devices(rs: Any) -> list[DeviceInfo]:
devices = []
for device in rs.context().query_devices():
name = device.get_info(rs.camera_info.name)
if "D405" in name:
model = "D405"
elif "D455" in name:
model = "D455"
else:
model = name
usb_type = (
device.get_info(rs.camera_info.usb_type_descriptor)
if device.supports(rs.camera_info.usb_type_descriptor)
else "unknown"
)
devices.append(
DeviceInfo(
name=name,
model=model,
serial=device.get_info(rs.camera_info.serial_number),
usb_type=usb_type,
)
)
return devices
def camera_status(state: CameraState, target_fps: int) -> tuple[str, tuple[int, int, int]]:
if state.error:
return "ERROR", (0, 0, 255)
if state.received == 0:
return "WAITING", (0, 215, 255)
if state.elapsed < WARMUP_SECONDS:
return "WARMUP", (0, 215, 255)
if state.rolling_fps >= target_fps * MIN_FPS_RATIO and state.drop_rate <= MAX_DROP_RATE:
return "PASS", (0, 200, 0)
return "FAIL", (0, 0, 255)
def render_tile(
worker: CameraWorker,
state: CameraState,
tile_width: int,
tile_height: int,
cv2: Any,
np: Any,
) -> Any:
if state.frame is None:
tile = np.zeros((tile_height, tile_width, 3), dtype=np.uint8)
else:
tile = cv2.resize(state.frame, (tile_width, tile_height))
status, color = camera_status(state, worker.fps)
cv2.rectangle(tile, (0, 0), (tile_width, 100), (0, 0, 0), -1)
width, height = state.actual_size
lines = [
f"{worker.assignment.role} {worker.assignment.model} {worker.assignment.serial}",
f"USB {worker.assignment.usb_type} {width}x{height}@{worker.fps}",
f"FPS {state.rolling_fps:.1f} Frames {state.received} "
f"Dropped {state.dropped} ({state.drop_rate:.2%})",
status if not state.error else f"ERROR: {state.error[:70]}",
]
for index, line in enumerate(lines):
cv2.putText(
tile,
line,
(10, 22 + index * 24),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
color if index == len(lines) - 1 else (255, 255, 255),
1,
cv2.LINE_AA,
)
return tile
def compose_preview(
workers: list[CameraWorker],
states: dict[str, CameraState],
capture_width: int,
capture_height: int,
cv2: Any,
np: Any,
) -> Any:
tile_width = min(capture_width, PREVIEW_TILE_WIDTH)
tile_height = round(tile_width * capture_height / capture_width)
tiles = {
worker.assignment.serial: render_tile(
worker,
states[worker.assignment.serial],
tile_width,
tile_height,
cv2,
np,
)
for worker in workers
}
global_worker = next(worker for worker in workers if worker.assignment.role == "GLOBAL")
arm_workers = [worker for worker in workers if worker.assignment.role != "GLOBAL"]
top = np.zeros((tile_height, tile_width * 2, 3), dtype=np.uint8)
offset = tile_width // 2
top[:, offset : offset + tile_width] = tiles[global_worker.assignment.serial]
bottom = np.hstack([tiles[worker.assignment.serial] for worker in arm_workers])
return np.vstack((top, bottom))
def save_snapshots(
assignments: list[CameraAssignment],
states: dict[str, CameraState],
cv2: Any,
) -> Path:
missing = [camera.role for camera in assignments if states[camera.serial].frame is None]
if missing:
raise RuntimeError(f"以下相机尚无有效画面,不能保存快照: {', '.join(missing)}")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
snapshot_dir = OUTPUT_DIR / timestamp
snapshot_dir.mkdir(parents=True, exist_ok=False)
filenames = snapshot_filenames(assignments)
for camera in assignments:
path = snapshot_dir / filenames[camera.serial]
if not cv2.imwrite(str(path), states[camera.serial].frame):
raise RuntimeError(f"保存快照失败: {path}")
return snapshot_dir
def print_summary(workers: list[CameraWorker]) -> bool:
now = time.monotonic()
print("\n相机测试汇总:")
passed = True
for worker in workers:
state = worker.state(now)
status, _color = camera_status(state, worker.fps)
passed = passed and status == "PASS"
print(
f" {worker.assignment.role:<7} {worker.assignment.serial}: "
f"平均 {state.average_fps:.1f} FPS, 接收 {state.received}, "
f"掉帧 {state.dropped} ({state.drop_rate:.2%}), {status}"
)
print("结论: " + ("三路链路满足当前阈值" if passed else "至少一路未满足当前阈值"))
return passed
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--width", type=int, default=DEFAULT_WIDTH, help="采集宽度")
parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT, help="采集高度")
parser.add_argument("--fps", type=int, default=DEFAULT_FPS, help="目标帧率")
parser.add_argument(
"--left-serial",
default=DEFAULT_LEFT_SERIAL,
help=f"左臂 D405 的 RealSense 序列号(默认: {DEFAULT_LEFT_SERIAL}",
)
args = parser.parse_args()
if args.width <= 0 or args.height <= 0 or args.fps <= 0:
parser.error("width、height 和 fps 必须为正数")
return args
def main() -> int:
args = parse_args()
try:
cv2, np, rs = load_runtime_dependencies()
assignments = assign_camera_roles(enumerate_devices(rs), args.left_serial)
except RuntimeError as exc:
print(f"错误: {exc}")
return 2
except ValueError as exc:
print(f"设备检查失败: {exc}")
return 2
print("相机分配:")
for camera in assignments:
print(
f" {camera.role:<7} {camera.model} serial={camera.serial} "
f"USB={camera.usb_type}"
)
workers: list[CameraWorker] = []
passed = False
try:
for assignment in assignments:
worker = CameraWorker(
assignment,
args.width,
args.height,
args.fps,
rs,
np,
)
workers.append(worker)
worker.start()
cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_NORMAL)
print("按 S 保存三路快照,按 Q 或 Esc 退出。")
while True:
now = time.monotonic()
states = {worker.assignment.serial: worker.state(now) for worker in workers}
preview = compose_preview(
workers,
states,
args.width,
args.height,
cv2,
np,
)
cv2.imshow(WINDOW_NAME, preview)
key = cv2.waitKey(1) & 0xFF
if key in (ord("q"), ord("Q"), 27):
break
if key in (ord("s"), ord("S")):
try:
output = save_snapshots(assignments, states, cv2)
print(f"快照已保存: {output}")
except RuntimeError as exc:
print(f"快照失败: {exc}")
if cv2.getWindowProperty(WINDOW_NAME, cv2.WND_PROP_VISIBLE) < 1:
break
except KeyboardInterrupt:
print("\n收到 Ctrl+C,正在停止相机。")
except Exception as exc:
print(f"相机启动或显示失败: {exc}")
finally:
for worker in reversed(workers):
worker.stop()
try:
cv2.destroyAllWindows()
except Exception:
pass
if workers:
passed = print_summary(workers)
return 0 if passed else 1
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -11,6 +11,7 @@ find_package(rosidl_default_generators REQUIRED)
find_package(std_msgs REQUIRED) find_package(std_msgs REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME} rosidl_generate_interfaces(${PROJECT_NAME}
"msg/ActControlSample.msg"
"msg/XrController.msg" "msg/XrController.msg"
DEPENDENCIES geometry_msgs std_msgs DEPENDENCIES geometry_msgs std_msgs
) )
+41
View File
@@ -0,0 +1,41 @@
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
+3 -1
View File
@@ -55,7 +55,9 @@ setup(
tests_require=["pytest"], tests_require=["pytest"],
entry_points={ entry_points={
"console_scripts": [ "console_scripts": [
"single_arm_velocity_teleop = xr_rm_teleop.single_arm_velocity_teleop:main", "act_episode_recorder = xr_rm_teleop.act_episode_recorder:main",
"single_arm_velocity_teleop = "
"xr_rm_teleop.single_arm_velocity_teleop:main",
], ],
}, },
) )
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,194 @@
from types import SimpleNamespace
import numpy as np
import pytest
from builtin_interfaces.msg import Time as TimeMsg
from xr_rm_interfaces.msg import XrController
from xr_rm_teleop.single_arm_velocity_teleop import (
SingleArmVelocityTeleop,
_ActCycleContext,
)
class FakePublisher:
def __init__(self, error=None) -> None:
self.error = error
self.messages = []
def publish(self, message) -> None:
if self.error is not None:
raise self.error
self.messages.append(message)
class FakeLogger:
def __init__(self) -> None:
self.warnings = []
def warn(self, message, **kwargs) -> None:
del kwargs
self.warnings.append(message)
def _controller(*, grip=True) -> XrController:
message = XrController()
message.hand = "right"
message.grip = grip
message.trigger = 0.25
message.primary = False
message.secondary = False
message.axis = [0.1, -0.2]
message.pose.orientation.w = 1.0
return message
def _teleop(*, last_target=None, tool_state=True):
teleop = object.__new__(SingleArmVelocityTeleop)
teleop._arm_name = "right_rm75"
teleop._last_msg = _controller()
teleop._active = True
teleop._control_fault_latched = False
teleop._last_current_pose = np.eye(4)
teleop._robot_start_transform = None
teleop._last_sent_target = None
teleop._last_sent_orientation = None
teleop._last_successful_action_target = last_target
teleop._ik_solver = SimpleNamespace(
joint_position_limits=np.asarray([[-1.0, 1.0]] * 7)
)
teleop._tool_state_snapshot = lambda: (
True,
tool_state,
False,
False,
)
teleop.get_clock = lambda: SimpleNamespace(
now=lambda: SimpleNamespace(to_msg=lambda: TimeMsg())
)
teleop.get_logger = lambda: FakeLogger()
return teleop
def _cycle(**overrides) -> _ActCycleContext:
values = {
"control_seq": 100,
"control_monotonic_ns": 1_000_000_000,
"feedback_monotonic_ns": 990_000_000,
"action_monotonic_ns": 1_005_000_000,
"feedback_age_ms": 10.0,
"q_actual": [0.1] * 7,
"q_qp_raw": [0.3] * 7,
"q_target": [0.2] * 7,
"current_pose": np.eye(4),
"raw_target_pose": np.eye(4),
"target_pose": np.eye(4),
"command_velocity": [0.0] * 6,
"feedback_valid": True,
"command_sent": True,
"qp_attempted": True,
"qp_success": True,
}
values.update(overrides)
return _ActCycleContext(**values)
def test_act_sample_uses_feedback_and_limited_target_from_one_cycle() -> None:
teleop = _teleop(last_target=[0.2] * 7)
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.joint_lower_limits == pytest.approx([-1.0] * 7)
assert message.joint_upper_limits == pytest.approx([1.0] * 7)
assert message.command_sent
assert message.action_valid
assert message.qp_attempted
assert message.qp_success
def test_act_sample_marks_qp_fallback_as_valid_held_action() -> None:
teleop = _teleop(last_target=[0.2] * 7)
cycle = _cycle(
q_qp_raw=[0.2] * 7,
q_target=[0.2] * 7,
qp_success=False,
)
message = teleop._build_act_control_sample(cycle)
assert message.q_target == pytest.approx([0.2] * 7)
assert message.action_valid
assert message.qp_attempted
assert not message.qp_success
def test_act_sample_holds_last_action_while_grip_is_released() -> None:
teleop = _teleop(last_target=[0.4] * 7)
teleop._last_msg = _controller(grip=False)
teleop._active = False
cycle = _cycle(
q_qp_raw=None,
q_target=None,
command_sent=False,
qp_attempted=False,
qp_success=False,
action_monotonic_ns=-1,
)
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
assert not message.teleop_active
def test_act_sample_marks_send_failure_invalid() -> None:
teleop = _teleop(last_target=[0.4] * 7)
message = teleop._build_act_control_sample(
_cycle(send_failed=True, command_sent=False)
)
assert not message.action_valid
def test_act_sample_marks_unknown_gripper_state() -> None:
teleop = _teleop(last_target=[0.2] * 7, tool_state=None)
message = teleop._build_act_control_sample(_cycle())
assert not message.gripper_state_known
def test_act_sample_publish_failure_does_not_escape_control_path() -> None:
teleop = _teleop(last_target=[0.2] * 7)
logger = FakeLogger()
teleop._act_sample_pub = FakePublisher(RuntimeError("dds failed"))
teleop.get_logger = lambda: logger
teleop._publish_act_control_sample(_cycle())
assert logger.warnings == [
"right_rm75 ACT原子样本发布失败:dds failed"
]
def test_control_tick_wraps_one_impl_call_in_one_atomic_sample() -> None:
teleop = object.__new__(SingleArmVelocityTeleop)
cycles = []
published = []
teleop._act_control_seq = 7
teleop._control_tick_impl = lambda cycle: cycles.append(cycle)
teleop._publish_act_control_sample = lambda cycle: published.append(cycle)
teleop._control_tick()
assert len(cycles) == 1
assert published == cycles
assert cycles[0].control_seq == 7
assert teleop._act_control_seq == 8
File diff suppressed because it is too large Load Diff
@@ -1,303 +0,0 @@
from __future__ import annotations
import math
import sys
from pathlib import Path
import h5py
import numpy as np
import pytest
TEST_DIR = Path(__file__).resolve().parent
if str(TEST_DIR) not in sys.path:
sys.path.insert(0, str(TEST_DIR))
import ik_method_comparison as comparison
def test_slerp_uses_shortest_arc_and_returns_unit_quaternion() -> None:
start = np.asarray([0.0, 0.0, 0.0, 1.0])
end = -np.asarray([0.0, 0.0, math.sin(0.1), math.cos(0.1)])
actual = comparison._slerp_quaternion(start, end, 0.5)
assert np.linalg.norm(actual) == pytest.approx(1.0)
assert actual == pytest.approx(
[0.0, 0.0, math.sin(0.05), math.cos(0.05)]
)
def test_resample_trajectory_keeps_endpoints_and_uses_requested_rate() -> None:
trajectory = comparison.EpisodeTrajectory(
source_path=Path("episode.hdf5"),
times_s=np.asarray([0.0, 0.5, 1.0]),
target_poses=np.asarray(
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
[0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
]
),
initial_joints=np.zeros(7),
)
actual = comparison.resample_trajectory(trajectory, 4.0)
assert actual.times_s == pytest.approx([0.0, 0.25, 0.5, 0.75, 1.0])
assert actual.target_poses[0] == pytest.approx(trajectory.target_poses[0])
assert actual.target_poses[-1] == pytest.approx(trajectory.target_poses[-1])
assert actual.target_poses[:, 0] == pytest.approx(actual.times_s)
def _write_episode(path: Path) -> None:
poses = np.asarray(
[
[0.1, -0.2, 0.3, 0.0, 0.0, 0.0, 1.0],
[0.2, -0.2, 0.3, 0.0, 0.0, 0.0, 1.0],
[0.3, -0.2, 0.3, 0.0, 0.0, 0.0, 1.0],
[0.4, -0.2, 0.3, 0.0, 0.0, 0.0, 1.0],
],
dtype=np.float32,
)
with h5py.File(path, "w") as handle:
handle.attrs["arm"] = "right_rm75"
handle.attrs["pose_order"] = "x,y,z,qx,qy,qz,qw"
handle.create_dataset("debug/tcp/final_target_pose", data=poses)
handle.create_dataset(
"debug/timestamps/control_monotonic_ns",
data=np.asarray([0, 33_000_000, 66_000_000, 99_000_000]),
)
handle.create_dataset(
"debug/control/teleop_active", data=[0, 1, 1, 0]
)
handle.create_dataset(
"debug/control/action_valid", data=[1, 1, 1, 1]
)
handle.create_dataset(
"debug/control/command_sent", data=[0, 1, 1, 0]
)
qpos = np.zeros((4, 8), dtype=np.float32)
qpos[1, :7] = np.arange(7) * 0.1
handle.create_dataset("observations/qpos", data=qpos)
def test_load_episode_uses_longest_valid_run_and_first_valid_qpos(
tmp_path: Path,
) -> None:
path = tmp_path / "episode.hdf5"
_write_episode(path)
actual = comparison.load_episode(path)
assert actual.times_s == pytest.approx([0.0, 0.033])
assert actual.target_poses[:, 0] == pytest.approx([0.2, 0.3])
assert actual.initial_joints == pytest.approx(np.arange(7) * 0.1)
def test_load_episode_rejects_wrong_arm(tmp_path: Path) -> None:
path = tmp_path / "episode.hdf5"
_write_episode(path)
with h5py.File(path, "r+") as handle:
handle.attrs.modify("arm", "left_rm75")
with pytest.raises(ValueError, match="right_rm75"):
comparison.load_episode(path)
def test_orientation_error_and_joint_margin_match_definitions() -> None:
identity = np.eye(3)
quarter_turn = comparison._rotation_z(math.pi / 2.0)
joints = np.asarray([0.0, -0.5])
lower = np.asarray([-1.0, -1.0])
upper = np.asarray([1.0, 3.0])
assert comparison.orientation_error_rad(identity, quarter_turn) \
== pytest.approx(math.pi / 2.0)
assert comparison.normalized_joint_margin(joints, lower, upper) \
== pytest.approx(0.125)
def test_choose_dls_damping_is_lexicographic() -> None:
candidates = [
comparison.MethodSummary("dls", 0.01, 0.90, 0.004, 0.01, 50.0),
comparison.MethodSummary("dls", 0.03, 0.95, 0.006, 0.02, 30.0),
comparison.MethodSummary("dls", 0.10, 0.95, 0.004, 0.01, 40.0),
]
assert comparison.choose_dls_damping(candidates) == pytest.approx(0.10)
def test_limit_joint_command_reuses_production_limiter() -> None:
target, velocity, limited = comparison.limit_joint_command(
target=np.full(7, 1.0),
previous_target=np.zeros(7),
previous_velocity=np.zeros(7),
max_speed=1.0,
max_acceleration=10.0,
dt=0.1,
)
assert target == pytest.approx([0.1] * 7)
assert velocity == pytest.approx([1.0] * 7)
assert limited
class _FakeSolver:
def __init__(self, fail: bool) -> None:
self.fail = fail
self.joint_limits = np.asarray([[-2.0, 2.0]] * 7)
def update_joint_state(self, joints: list[float]) -> np.ndarray:
pose = np.eye(4)
pose[0, 3] = joints[0]
return pose
def solve(self, target: np.ndarray) -> list[float]:
if self.fail:
raise RuntimeError("not converged")
return [float(target[0, 3])] + [0.0] * 6
def test_replay_holds_previous_state_on_solver_failure() -> None:
trajectory = comparison.EpisodeTrajectory(
source_path=Path("episode.hdf5"),
times_s=np.asarray([0.0, 0.1]),
target_poses=np.asarray(
[
[0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
[0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0],
]
),
initial_joints=np.zeros(7),
)
result = comparison.run_replay(
"fake",
_FakeSolver(fail=True),
trajectory,
max_speed=1.0,
max_acceleration=10.0,
measure_time=False,
)
assert result.joints == pytest.approx(np.zeros((2, 7)))
assert not result.success.any()
assert result.velocities == pytest.approx(np.zeros((2, 7)))
def test_real_urdf_solvers_return_finite_safe_outputs() -> None:
pytest.importorskip("placo")
urdf = TEST_DIR.parent / "models" / "dual_rm75" / "Dual_arm.urdf"
config_path = (
TEST_DIR.parents[1]
/ "xr_rm_bringup"
/ "config"
/ "right_arm_rm75.yaml"
)
joints = np.radians(
[-86.10, 22.80, -89.57, 93.98, -91.82, -87.32, -89.35]
)
solvers = [
comparison.DifferentialIkSolver(urdf, 1.0 / 90.0, "pinv"),
comparison.DifferentialIkSolver(urdf, 1.0 / 90.0, "dls", 0.03),
comparison.make_qp_solver(
urdf,
1.0 / 90.0,
comparison.load_right_config(config_path),
),
]
for solver in solvers:
target = solver.update_joint_state(joints.tolist())
target = target.copy()
target[0, 3] += 0.003
result = np.asarray(solver.solve(target), dtype=float)
assert result.shape == (7,)
assert np.isfinite(result).all()
assert np.all(result >= solver.joint_limits[:, 0] - 1e-9)
assert np.all(result <= solver.joint_limits[:, 1] + 1e-9)
def test_load_right_config_returns_qp_and_command_limits() -> None:
config = (
TEST_DIR.parents[1]
/ "xr_rm_bringup"
/ "config"
/ "right_arm_rm75.yaml"
)
actual = comparison.load_right_config(config)
assert actual["qp_j3_reference_deg"] == pytest.approx(-89.57)
assert actual["qp_j4_min_deg"] == pytest.approx(10.0)
assert actual["qp_manipulability_weight"] == pytest.approx(1e-4)
assert actual["joint_max_speed"] == pytest.approx(180.0)
assert actual["joint_max_acc"] == pytest.approx(300.0)
def test_summarize_result_uses_report_metrics() -> None:
result = comparison.ReplayResult(
method="pinv",
times_s=np.asarray([0.0, 0.1]),
target_poses=np.zeros((2, 7)),
actual_poses=np.zeros((2, 7)),
joints=np.zeros((2, 7)),
velocities=np.asarray([[0.0] * 7, [math.pi] + [0.0] * 6]),
position_errors_m=np.asarray([0.003, 0.004]),
orientation_errors_rad=np.asarray([0.01, 0.02]),
joint_margins=np.asarray([0.2, 0.1]),
solve_durations_ms=np.asarray([1.0, 2.0]),
success=np.asarray([True, False]),
command_limited=np.asarray([False, True]),
)
actual = comparison.summarize_result(result)
assert actual.success_rate == pytest.approx(0.5)
assert actual.position_rmse_m == pytest.approx(0.0035355339)
assert actual.orientation_rmse_rad == pytest.approx(0.0158113883)
assert actual.max_joint_speed_deg_s == pytest.approx(180.0)
def test_write_outputs_creates_consistent_files(tmp_path: Path) -> None:
result = comparison.ReplayResult(
method="qp",
times_s=np.asarray([0.0, 0.1]),
target_poses=np.tile(
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], (2, 1)
),
actual_poses=np.tile(
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], (2, 1)
),
joints=np.zeros((2, 7)),
velocities=np.zeros((2, 7)),
position_errors_m=np.asarray([0.001, 0.002]),
orientation_errors_rad=np.asarray([0.001, 0.002]),
joint_margins=np.asarray([0.2, 0.2]),
solve_durations_ms=np.asarray([0.5, 0.6]),
success=np.asarray([True, True]),
command_limited=np.asarray([False, False]),
)
summary = comparison.summarize_result(result)
comparison.write_outputs(
tmp_path,
{"pinv": result, "dls": result, "qp": result},
{"pinv": summary, "dls": summary, "qp": summary},
selected_damping=0.03,
source_path=Path("episode_0.hdf5"),
git_commit="abc1234",
)
expected = {
"samples.csv",
"summary.json",
"figure_2_11_tracking_error.svg",
"figure_2_11_tracking_error.png",
"figure_2_12_joint_constraints.svg",
"figure_2_12_joint_constraints.png",
"figure_2_13_summary.svg",
"figure_2_13_summary.png",
"analysis_2.3.4.md",
}
assert expected == {path.name for path in tmp_path.iterdir()}
assert all((tmp_path / name).stat().st_size > 0 for name in expected)
+74 -38
View File
@@ -6,13 +6,14 @@ from types import ModuleType, SimpleNamespace
import pytest import pytest
import yaml import yaml
from xr_rm_teleop import realman_adapter from xr_rm_teleop import fun_peripheral, realman_adapter
from xr_rm_teleop.realman_adapter import RealManAdapter from xr_rm_teleop.realman_adapter import RealManAdapter
from xr_rm_teleop.realman_adapter import MockRealManAdapter from xr_rm_teleop.realman_adapter import MockRealManAdapter
from xr_rm_teleop.fun_peripheral import ( from xr_rm_teleop.fun_peripheral import (
PeripheralConfig, PeripheralConfig,
_configure_tool_frame, _configure_tool_frame,
load_peripheral_config, load_peripheral_config,
peripheral_cfg,
) )
@@ -82,6 +83,78 @@ def test_deployed_peripheral_config_selects_left_and_right_tools() -> None:
assert right.tool_pose == [0.0, 0.0, 0.14, 0.0, 0.0, 0.0, 1.0] assert right.tool_pose == [0.0, 0.0, 0.14, 0.0, 0.0, 0.0, 1.0]
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
def test_omnipic_initial_state_opens_fully(monkeypatch) -> None:
calls = []
class FakeArm:
def rm_set_voltage(self, *args):
del args
def rm_set_io_mode(self, *args):
del args
def rm_algo_quaternion2euler(self, quaternion):
del quaternion
return [0.0, 0.0, 0.0]
def rm_get_total_tool_frame(self):
return {"return_code": 0, "tool_names": []}
def rm_set_manual_tool_frame(self, *, frame):
del frame
return 0
def rm_change_tool_frame(self, tool_name):
del tool_name
return 0
def rm_set_modbus_mode(self, **kwargs):
del kwargs
return 0
def rm_write_single_register(self, params, value):
del params, value
return 0
sdk = ModuleType("Robotic_Arm.rm_robot_interface")
sdk.rm_frame_t = lambda *args: object()
sdk.rm_peripheral_read_write_params_t = lambda *args: object()
package = ModuleType("Robotic_Arm")
package.rm_robot_interface = sdk
monkeypatch.setitem(sys.modules, "Robotic_Arm", package)
monkeypatch.setitem(sys.modules, "Robotic_Arm.rm_robot_interface", sdk)
monkeypatch.setattr(fun_peripheral.time, "sleep", lambda seconds: None)
monkeypatch.setattr(
fun_peripheral,
"set_tool_position",
lambda robot, percent, device, scissorgripper: calls.append(
(percent, device, scissorgripper)
),
)
tools = {
"scissor": [[0.0] * 7, [0.0] * 7],
"omnipic": [[0.0] * 7, [0.0] * 7],
}
peripheral_cfg(
FakeArm(),
1,
tools,
set_initial_tool_state=True,
)
assert calls == [(1.0, 1, 1)]
@pytest.mark.parametrize( @pytest.mark.parametrize(
("config_name", "node_names"), ("config_name", "node_names"),
[ [
@@ -100,43 +173,6 @@ def test_deployed_workspace_is_in_front_of_robot(config_name, node_names) -> Non
assert parameters["workspace_max"] == [0.70, 0.10, 0.75] assert parameters["workspace_max"] == [0.70, 0.10, 0.75]
@pytest.mark.parametrize(
"arm,single_config,dual_node,j3_reference_deg,j3_weight",
[
("left", "left_arm_rm75.yaml", "left_arm_teleop", 67.96, 1e-5),
("right", "right_arm_rm75.yaml", "right_arm_teleop", -89.57, 1e-4),
],
)
def test_qp_optimization_parameters_match_single_and_dual_configs(
arm,
single_config,
dual_node,
j3_reference_deg,
j3_weight,
) -> None:
del arm
with (CONFIG_DIR / single_config).open(encoding="utf-8") as stream:
single = yaml.safe_load(stream)["single_arm_velocity_teleop"][
"ros__parameters"
]
with (CONFIG_DIR / "dual_arm_rm75.yaml").open(encoding="utf-8") as stream:
dual = yaml.safe_load(stream)[dual_node]["ros__parameters"]
expected = {
"qp_j3_reference_deg": j3_reference_deg,
"qp_j3_weight": j3_weight,
"qp_j4_min_deg": 10.0,
"qp_j4_warn_deg": 25.0,
"qp_j4_weight": 1e-4,
"qp_manipulability_sigma_stop": 0.01,
"qp_manipulability_sigma_warn": 0.04,
"qp_manipulability_weight": 1e-4,
}
for name, value in expected.items():
assert single[name] == pytest.approx(value)
assert dual[name] == pytest.approx(value)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("existing", "expected_operation"), ("existing", "expected_operation"),
[(False, "create"), (True, "update")], [(False, "create"), (True, "update")],
+72 -54
View File
@@ -1,4 +1,5 @@
import math import math
import threading
import time import time
from types import SimpleNamespace from types import SimpleNamespace
@@ -11,7 +12,6 @@ from xr_rm_teleop.single_arm_velocity_teleop import (
SingleArmVelocityTeleop, SingleArmVelocityTeleop,
_make_transform, _make_transform,
_so3_exp, _so3_exp,
_so3_log,
) )
@@ -43,6 +43,69 @@ class FakePublisher:
self.messages.append(message) self.messages.append(message)
def _tool_state_teleop(*, command_error=None):
started = threading.Event()
release = threading.Event()
class Adapter:
def set_tool_enabled(self, open_tool):
del open_tool
started.set()
assert release.wait(timeout=1.0)
if command_error is not None:
raise command_error
teleop = object.__new__(SingleArmVelocityTeleop)
teleop._arm_name = "right_rm75"
teleop._adapter = Adapter()
teleop._tool_command_queue = None
teleop._tool_worker_stop = threading.Event()
teleop._tool_worker_thread = None
teleop._tool_state_lock = threading.Lock()
teleop._tool_target_open = True
teleop._tool_state_open = True
teleop._tool_command_pending = False
teleop._tool_command_failed = False
teleop.get_logger = lambda: FakeLogger()
teleop._start_tool_worker()
return teleop, started, release
def test_tool_state_changes_only_after_command_succeeds() -> None:
teleop, started, release = _tool_state_teleop()
try:
teleop._enqueue_tool_command(False, "test")
assert started.wait(timeout=1.0)
assert teleop._tool_state_snapshot() == (False, True, True, False)
release.set()
assert teleop._tool_command_queue is not None
teleop._tool_command_queue.join()
assert teleop._tool_state_snapshot() == (False, False, False, False)
finally:
release.set()
teleop._shutdown_tool_worker()
def test_tool_failure_keeps_previous_state_and_is_reported() -> None:
teleop, started, release = _tool_state_teleop(
command_error=RuntimeError("modbus failed")
)
try:
teleop._enqueue_tool_command(False, "test")
assert started.wait(timeout=1.0)
release.set()
assert teleop._tool_command_queue is not None
teleop._tool_command_queue.join()
assert teleop._tool_state_snapshot() == (False, True, False, True)
finally:
release.set()
teleop._shutdown_tool_worker()
def _joint_publishing_teleop() -> SingleArmVelocityTeleop: def _joint_publishing_teleop() -> SingleArmVelocityTeleop:
names = [f"omnipic_joint_{index}" for index in range(1, 8)] names = [f"omnipic_joint_{index}" for index in range(1, 8)]
teleop = object.__new__(SingleArmVelocityTeleop) teleop = object.__new__(SingleArmVelocityTeleop)
@@ -611,7 +674,7 @@ def test_first_feedback_initializes_last_valid_target_without_solving() -> None:
assert teleop._ik_solver.solve_calls == 0 assert teleop._ik_solver.solve_calls == 0
def test_qp_failure_returns_none_and_keeps_last_known_good_target() -> None: def test_qp_failure_returns_last_known_good_target() -> None:
class FailingSolver: class FailingSolver:
def solve(self, target): def solve(self, target):
del target del target
@@ -623,13 +686,14 @@ def test_qp_failure_returns_none_and_keeps_last_known_good_target() -> None:
teleop._arm_name = "right_rm75" teleop._arm_name = "right_rm75"
teleop.get_logger = lambda: FakeLogger() teleop.get_logger = lambda: FakeLogger()
target = teleop._solve_joint_target(np.eye(4)) target, qp_success = teleop._solve_joint_target(np.eye(4))
assert target is None assert target == pytest.approx([0.1] * 7)
assert not qp_success
assert teleop._last_valid_joint_target == pytest.approx([0.1] * 7) assert teleop._last_valid_joint_target == pytest.approx([0.1] * 7)
def test_qp_success_waits_for_send_before_updating_last_known_good_target() -> None: def test_qp_success_updates_last_known_good_target() -> None:
class SuccessfulSolver: class SuccessfulSolver:
def solve(self, target): def solve(self, target):
del target del target
@@ -641,57 +705,11 @@ def test_qp_success_waits_for_send_before_updating_last_known_good_target() -> N
teleop._arm_name = "left_rm75" teleop._arm_name = "left_rm75"
teleop.get_logger = lambda: FakeLogger() teleop.get_logger = lambda: FakeLogger()
target = teleop._solve_joint_target(np.eye(4)) target, qp_success = teleop._solve_joint_target(np.eye(4))
assert target == pytest.approx([0.2] * 7) assert target == pytest.approx([0.2] * 7)
assert teleop._last_valid_joint_target == pytest.approx([0.1] * 7) assert qp_success
assert teleop._last_valid_joint_target == pytest.approx([0.2] * 7)
def test_target_filters_do_not_commit_candidate_state() -> None:
teleop = object.__new__(SingleArmVelocityTeleop)
teleop._filtered_target = [0.0, 0.0, 0.0]
teleop._filtered_orientation_target = np.eye(3)
teleop._target_filter_alpha = 0.5
teleop._target_filter_alpha_fast = 0.5
teleop._target_filter_fast_threshold_m = 1.0
teleop._orientation_filter_alpha = 0.5
position = teleop._filter_target([0.2, 0.0, 0.0])
orientation = teleop._filter_orientation_target(
_so3_exp(np.asarray([0.0, 0.0, 0.2]))
)
assert position == pytest.approx([0.1, 0.0, 0.0])
assert teleop._filtered_target == pytest.approx([0.0, 0.0, 0.0])
assert teleop._filtered_orientation_target == pytest.approx(np.eye(3))
assert np.linalg.norm(_so3_log(orientation)) == pytest.approx(0.1)
def test_failed_send_does_not_commit_cartesian_reference_state() -> None:
teleop = object.__new__(SingleArmVelocityTeleop)
teleop._last_valid_joint_target = [0.1] * 7
teleop._filtered_target = [0.2, 0.0, 0.0]
teleop._filtered_orientation_target = np.eye(3)
teleop._last_sent_target = [0.2, 0.0, 0.0]
teleop._last_sent_orientation = np.eye(3)
teleop._last_command_time = FakeTime()
teleop._send_joint_target = lambda joints: False
sent = teleop._send_and_commit_joint_target(
[0.3] * 7,
[0.3, 0.0, 0.0],
_so3_exp(np.asarray([0.0, 0.0, 0.1])),
[0.3, 0.0, 0.0],
_so3_exp(np.asarray([0.0, 0.0, 0.1])),
FakeTime(),
)
assert not sent
assert teleop._last_valid_joint_target == pytest.approx([0.1] * 7)
assert teleop._filtered_target == pytest.approx([0.2, 0.0, 0.0])
assert teleop._filtered_orientation_target == pytest.approx(np.eye(3))
assert teleop._last_sent_target == pytest.approx([0.2, 0.0, 0.0])
assert teleop._last_sent_orientation == pytest.approx(np.eye(3))
def test_enter_active_control_initializes_se3_orientation_state() -> None: def test_enter_active_control_initializes_se3_orientation_state() -> None:
+14 -129
View File
@@ -6,7 +6,6 @@ from xml.etree import ElementTree
import numpy as np import numpy as np
import pytest import pytest
from xr_rm_teleop import placo_ik_solver
from xr_rm_teleop.placo_ik_solver import ( from xr_rm_teleop.placo_ik_solver import (
QP_ORIENTATION_TOLERANCE_RAD, QP_ORIENTATION_TOLERANCE_RAD,
QP_POSITION_TOLERANCE_M, QP_POSITION_TOLERANCE_M,
@@ -243,7 +242,6 @@ def test_qp_solve_rejects_position_error_above_two_millimeters() -> None:
solver._frame_task = SimpleNamespace(T_a_b=None) solver._frame_task = SimpleNamespace(T_a_b=None)
solver._solver = SimpleNamespace(solve=lambda update: None) solver._solver = SimpleNamespace(solve=lambda update: None)
solver._validate_result = lambda result, previous: None solver._validate_result = lambda result, previous: None
solver._update_auxiliary_task_weights = lambda: None
solver._target_errors = lambda: (2.1e-3, 0.0) solver._target_errors = lambda: (2.1e-3, 0.0)
with pytest.raises(RuntimeError, match="QP did not converge after 30"): with pytest.raises(RuntimeError, match="QP did not converge after 30"):
@@ -274,6 +272,20 @@ def test_validated_transform_rejects_invalid_se3(transform: np.ndarray) -> None:
_validated_transform(transform) _validated_transform(transform)
def test_joint_position_limits_returns_a_copy() -> None:
solver = object.__new__(PlacoIkSolver)
solver._joint_limits = np.asarray([[-1.0, 1.0]] * 7)
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
def test_qp_result_rejects_nan_position_and_velocity_violations() -> None: def test_qp_result_rejects_nan_position_and_velocity_violations() -> None:
solver = object.__new__(PlacoIkSolver) solver = object.__new__(PlacoIkSolver)
solver._joint_limits = np.asarray([[-1.0, 1.0]] * 7) solver._joint_limits = np.asarray([[-1.0, 1.0]] * 7)
@@ -287,130 +299,3 @@ def test_qp_result_rejects_nan_position_and_velocity_violations() -> None:
solver._validate_result(np.full(7, 2.0)) solver._validate_result(np.full(7, 2.0))
with pytest.raises(ValueError, match="velocity"): with pytest.raises(ValueError, match="velocity"):
solver._validate_result(np.full(7, 0.2)) solver._validate_result(np.full(7, 0.2))
def test_lower_margin_activation_is_clamped_and_linear() -> None:
activation = placo_ik_solver._lower_margin_activation
assert activation(0.05, 0.01, 0.04) == 0.0
assert activation(0.025, 0.01, 0.04) == pytest.approx(0.5)
assert activation(0.005, 0.01, 0.04) == 1.0
@pytest.mark.parametrize(
"arm,joint_degrees,j3_reference_deg",
[
("left", ARM_CASES[0][1], 67.96),
("right", ARM_CASES[1][1], -89.57),
],
)
def test_solver_configures_auxiliary_qp_tasks(
arm: str,
joint_degrees: list[float],
j3_reference_deg: float,
) -> None:
pytest.importorskip("placo")
solver = PlacoIkSolver(
str(DUAL_URDF_PATH),
1.0 / 90.0,
arm,
j3_reference_deg=j3_reference_deg,
j3_weight=1e-5,
j4_min_deg=10.0,
j4_warn_deg=25.0,
j4_weight=1e-4,
manipulability_sigma_stop=0.01,
manipulability_sigma_warn=0.04,
manipulability_weight=1e-4,
)
joints = np.radians(joint_degrees).tolist()
solver.update_joint_state(joints)
assert solver._j3_task.get_joint(
solver._joint_names[2]
) == pytest.approx(math.radians(j3_reference_deg))
assert np.asarray(solver._j4_constraint.A)[
solver._q_offsets[3]
] == pytest.approx(-1.0)
assert np.asarray(solver._j4_constraint.b) == pytest.approx(
[-math.radians(10.0)]
)
assert solver._j4_constraint.priority == "hard"
jacobian = solver._active_tcp_jacobian()
assert jacobian.shape == (6, 7)
assert np.isfinite(jacobian).all()
assert np.linalg.svd(jacobian, compute_uv=False)[-1] > 0.0
def test_failed_qp_restores_internal_state_to_actual_feedback() -> None:
solver, joints = _dual_placo_solver("left", ARM_CASES[0][1])
current_pose = solver.update_joint_state(joints)
unreachable = current_pose.copy()
unreachable[2, 3] += 10.0
with pytest.raises((RuntimeError, ValueError)):
solver.solve(unreachable)
assert solver._robot.state.q[solver._q_offsets] == pytest.approx(joints)
def test_solver_rejects_non_positive_manipulability_threshold() -> None:
pytest.importorskip("placo")
with pytest.raises(ValueError, match="manipulability thresholds"):
PlacoIkSolver(
str(DUAL_URDF_PATH),
1.0 / 90.0,
"left",
manipulability_sigma_stop=0.0,
manipulability_sigma_warn=0.04,
)
@pytest.mark.parametrize(
"q4_deg,sigma_min,expected_activation",
[
(25.0, 0.04, 0.0),
(17.5, 0.025, 0.5),
(10.0, 0.01, 1.0),
],
)
def test_auxiliary_weights_activate_only_inside_warning_margins(
q4_deg: float,
sigma_min: float,
expected_activation: float,
) -> None:
class TaskSpy:
def __init__(self) -> None:
self.calls = []
def configure(self, name, priority, weight) -> None:
self.calls.append((name, priority, weight))
solver = object.__new__(PlacoIkSolver)
solver._q_offsets = np.arange(7, 14)
solver._robot = SimpleNamespace(
state=SimpleNamespace(q=np.zeros(21))
)
solver._robot.state.q[solver._q_offsets[3]] = math.radians(q4_deg)
solver._j4_task = TaskSpy()
solver._j4_min = math.radians(10.0)
solver._j4_warn = math.radians(25.0)
solver._j4_weight = 1e-4
solver._manipulability_task = TaskSpy()
solver._manipulability_sigma_stop = 0.01
solver._manipulability_sigma_warn = 0.04
solver._manipulability_weight = 1e-4
jacobian = np.zeros((6, 7))
jacobian[:, :6] = np.diag([1.0] * 5 + [sigma_min])
solver._active_tcp_jacobian = lambda: jacobian
solver._update_auxiliary_task_weights()
expected_weight = 1e-4 * expected_activation
assert solver._j4_task.calls == [
("j4_soft_buffer", "soft", pytest.approx(expected_weight))
]
assert solver._manipulability_task.calls == [
("tcp_6d_manipulability", "soft", pytest.approx(expected_weight))
]
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -218,16 +218,19 @@ def peripheral_cfg(
addr = 1 addr = 1
# 依次设置目标速度、目标力矩、目标加速度和目标减速度。 # 依次设置目标速度、目标力矩、目标加速度和目标减速度。
reg_value = [255, 60, 255, 255] reg_value = [255, 150, 255, 255]
for i, reg_addr in enumerate([11, 12, 13, 14]): for i, reg_addr in enumerate([11, 12, 13, 14]):
write_params = rm_peripheral_read_write_params_t(1, reg_addr, addr, 1) write_params = rm_peripheral_read_write_params_t(1, reg_addr, addr, 1)
robot.rm_write_single_register(write_params, reg_value[i]) robot.rm_write_single_register(write_params, reg_value[i])
time.sleep(0.5) time.sleep(0.5)
if set_initial_tool_state: if set_initial_tool_state:
set_tool_position(robot, percent=0.75, device=1, scissorgripper=scissorgripper) set_tool_position(
time.sleep(1.5) robot,
set_tool_position(robot, percent=0.15, device=1, scissorgripper=scissorgripper) percent=1.0,
device=1,
scissorgripper=scissorgripper,
)
elif scissorgripper == 2: elif scissorgripper == 2:
# 小型剪刀夹爪通过控制器 DO3/DO4 控制。 # 小型剪刀夹爪通过控制器 DO3/DO4 控制。
+4 -141
View File
@@ -31,14 +31,6 @@ QP_POSITION_TOLERANCE_M = 2e-3
QP_ORIENTATION_TOLERANCE_RAD = 5e-3 QP_ORIENTATION_TOLERANCE_RAD = 5e-3
def _lower_margin_activation(value: float, stop: float, warn: float) -> float:
if not all(np.isfinite(item) for item in (value, stop, warn)):
raise ValueError("activation values must be finite")
if stop >= warn:
raise ValueError("activation stop must be smaller than warn")
return float(np.clip((warn - value) / (warn - stop), 0.0, 1.0))
def _validated_transform(transform: np.ndarray) -> np.ndarray: def _validated_transform(transform: np.ndarray) -> np.ndarray:
values = np.asarray(transform, dtype=float) values = np.asarray(transform, dtype=float)
if values.shape != (4, 4) or not np.isfinite(values).all(): if values.shape != (4, 4) or not np.isfinite(values).all():
@@ -68,15 +60,6 @@ class PlacoIkSolver:
urdf_path: str, urdf_path: str,
dt: float, dt: float,
arm: str, arm: str,
*,
j3_reference_deg: float | None = None,
j3_weight: float = 1e-5,
j4_min_deg: float | None = None,
j4_warn_deg: float | None = None,
j4_weight: float = 1e-4,
manipulability_sigma_stop: float = 0.01,
manipulability_sigma_warn: float = 0.04,
manipulability_weight: float = 0.0,
) -> None: ) -> None:
if dt <= 0.0: if dt <= 0.0:
raise ValueError("dt must be positive") raise ValueError("dt must be positive")
@@ -151,37 +134,12 @@ class PlacoIkSolver:
] ]
) )
self._actual_joints: np.ndarray | None = None self._actual_joints: np.ndarray | None = None
weights = (j3_weight, j4_weight, manipulability_weight)
if not all(np.isfinite(value) and value >= 0.0 for value in weights):
raise ValueError("QP auxiliary weights must be finite and non-negative")
if j3_reference_deg is not None and not np.isfinite(j3_reference_deg):
raise ValueError("J3 reference must be finite")
if (j4_min_deg is None) != (j4_warn_deg is None):
raise ValueError("J4 minimum and warning angles must be configured together")
if j4_min_deg is not None:
if not all(np.isfinite(value) for value in (j4_min_deg, j4_warn_deg)):
raise ValueError("J4 angles must be finite")
if j4_warn_deg <= j4_min_deg:
raise ValueError("J4 warning angle must exceed its minimum")
j4_limits_deg = np.degrees(self._joint_limits[3])
if j4_min_deg < j4_limits_deg[0] or j4_warn_deg > j4_limits_deg[1]:
raise ValueError("J4 safety angles must stay within URDF limits")
if not (
np.isfinite(manipulability_sigma_stop)
and np.isfinite(manipulability_sigma_warn)
and 0.0 < manipulability_sigma_stop
< manipulability_sigma_warn
):
raise ValueError(
"manipulability thresholds must satisfy 0 < stop < warn"
)
self._solver = placo.KinematicsSolver(self._robot) self._solver = placo.KinematicsSolver(self._robot)
self._solver.dt = dt self._solver.dt = dt
self._solver.mask_fbase(True) self._solver.mask_fbase(True)
for name in inactive_joint_names: for name in inactive_joint_names:
self._solver.mask_dof(name) self._solver.mask_dof(name)
self._solver.enable_joint_limits(True)
self._solver.enable_velocity_limits(True) self._solver.enable_velocity_limits(True)
self._frame_task = self._solver.add_relative_frame_task( self._frame_task = self._solver.add_relative_frame_task(
self._base_frame, self._base_frame,
@@ -191,53 +149,6 @@ class PlacoIkSolver:
self._frame_task.configure("rm75_relative_frame", "soft", 1.0) self._frame_task.configure("rm75_relative_frame", "soft", 1.0)
self._solver.add_kinetic_energy_regularization_task(1e-6) self._solver.add_kinetic_energy_regularization_task(1e-6)
self._j3_task = None
if j3_reference_deg is not None and j3_weight > 0.0:
self._j3_task = self._solver.add_joints_task()
self._j3_task.set_joints(
{self._joint_names[2]: np.deg2rad(j3_reference_deg)}
)
self._j3_task.configure("j3_reference", "soft", j3_weight)
self._j4_task = None
self._j4_constraint = None
self._j4_min = None
self._j4_warn = None
self._j4_weight = j4_weight
if j4_min_deg is not None:
self._j4_min = float(np.deg2rad(j4_min_deg))
self._j4_warn = float(np.deg2rad(j4_warn_deg))
self._j4_task = self._solver.add_joints_task()
self._j4_task.set_joints(
{self._joint_names[3]: self._j4_warn}
)
self._j4_task.configure("j4_soft_buffer", "soft", 0.0)
matrix = np.zeros((1, self._robot.state.q.size))
matrix[0, self._q_offsets[3]] = -1.0
self._j4_constraint = (
self._solver.add_joint_space_half_spaces_constraint(
matrix,
np.asarray([-self._j4_min]),
)
)
self._j4_constraint.configure("j4_lower_bound", "hard")
self._manipulability_task = None
self._manipulability_sigma_stop = manipulability_sigma_stop
self._manipulability_sigma_warn = manipulability_sigma_warn
self._manipulability_weight = manipulability_weight
if manipulability_weight > 0.0:
self._manipulability_task = self._solver.add_manipulability_task(
self._tcp_frame,
"both",
1.0,
)
self._manipulability_task.configure(
"tcp_6d_manipulability",
"soft",
0.0,
)
@property @property
def joint_names(self) -> list[str]: def joint_names(self) -> list[str]:
return list(self._joint_names) return list(self._joint_names)
@@ -246,6 +157,10 @@ class PlacoIkSolver:
def base_configuration(self) -> list[float]: def base_configuration(self) -> list[float]:
return self._robot.state.q[:7].tolist() return self._robot.state.q[:7].tolist()
@property
def joint_position_limits(self) -> np.ndarray:
return self._joint_limits.copy()
def update_joint_state(self, joints: list[float]) -> np.ndarray: def update_joint_state(self, joints: list[float]) -> np.ndarray:
values = np.asarray(joints, dtype=float) values = np.asarray(joints, dtype=float)
if values.shape != (7,) or not np.isfinite(values).all(): if values.shape != (7,) or not np.isfinite(values).all():
@@ -272,57 +187,9 @@ class PlacoIkSolver:
float(orientation_task.error_norm()), float(orientation_task.error_norm()),
) )
def _active_tcp_jacobian(self) -> np.ndarray:
jacobian = np.asarray(
self._robot.frame_jacobian(
self._tcp_frame,
"local_world_aligned",
),
dtype=float,
)[:, self._v_offsets]
if jacobian.shape != (6, 7) or not np.isfinite(jacobian).all():
raise ValueError("TCP Jacobian must be a finite 6x7 matrix")
return jacobian
def _update_auxiliary_task_weights(self) -> None:
if self._j4_task is not None:
q4 = float(self._robot.state.q[self._q_offsets[3]])
activation = _lower_margin_activation(
q4,
self._j4_min,
self._j4_warn,
)
self._j4_task.configure(
"j4_soft_buffer",
"soft",
self._j4_weight * activation,
)
if self._manipulability_task is not None:
sigma_min = float(
np.linalg.svd(
self._active_tcp_jacobian(),
compute_uv=False,
)[-1]
)
activation = _lower_margin_activation(
sigma_min,
self._manipulability_sigma_stop,
self._manipulability_sigma_warn,
)
self._manipulability_task.configure(
"tcp_6d_manipulability",
"soft",
self._manipulability_weight * activation,
)
def _restore_actual_joint_state(self) -> None:
self._robot.state.q[self._q_offsets] = self._actual_joints
self._robot.update_kinematics()
def solve(self, target_tool_pose: np.ndarray) -> list[float]: def solve(self, target_tool_pose: np.ndarray) -> list[float]:
if self._actual_joints is None: if self._actual_joints is None:
raise RuntimeError("joint state must be initialized before QP solve") raise RuntimeError("joint state must be initialized before QP solve")
try:
self._frame_task.T_a_b = _validated_transform( self._frame_task.T_a_b = _validated_transform(
target_tool_pose target_tool_pose
) )
@@ -339,7 +206,6 @@ class PlacoIkSolver:
for _ in range(QP_MAX_ITERATIONS): for _ in range(QP_MAX_ITERATIONS):
previous = result previous = result
self._update_auxiliary_task_weights()
self._solver.solve(True) self._solver.solve(True)
self._robot.update_kinematics() self._robot.update_kinematics()
result = np.asarray( result = np.asarray(
@@ -360,9 +226,6 @@ class PlacoIkSolver:
f"position_error={position_error:.6f} m, " f"position_error={position_error:.6f} m, "
f"orientation_error={orientation_error:.6f} rad" f"orientation_error={orientation_error:.6f} rad"
) )
except Exception:
self._restore_actual_joint_state()
raise
def _validate_result( def _validate_result(
self, self,
+8 -7
View File
@@ -237,9 +237,9 @@ class RealManAdapter:
) )
def read_joint_state(self) -> JointStateSnapshot: def read_joint_state(self) -> JointStateSnapshot:
self._require_arm() arm = self._require_arm()
started_at = time.monotonic() started_at = time.monotonic()
result = self._arm.rm_get_joint_degree() result = arm.rm_get_joint_degree()
finished_at = time.monotonic() finished_at = time.monotonic()
if not isinstance(result, tuple) or len(result) != 2: if not isinstance(result, tuple) or len(result) != 2:
raise RuntimeError( raise RuntimeError(
@@ -256,10 +256,10 @@ class RealManAdapter:
) )
def send_joint_target(self, joints: list[float], follow: bool) -> None: def send_joint_target(self, joints: list[float], follow: bool) -> None:
self._require_arm() arm = self._require_arm()
if len(joints) != 7 or not all(math.isfinite(value) for value in joints): if len(joints) != 7 or not all(math.isfinite(value) for value in joints):
raise ValueError("joint target must contain 7 finite values") raise ValueError("joint target must contain 7 finite values")
ret = self._arm.rm_movej_canfd( ret = arm.rm_movej_canfd(
[math.degrees(value) for value in joints], [math.degrees(value) for value in joints],
follow, follow,
0, 0,
@@ -315,9 +315,10 @@ class RealManAdapter:
self._arm = None self._arm = None
self._realtime_callback = None self._realtime_callback = None
def _require_arm(self) -> None: def _require_arm(self) -> Any:
if self._arm is None: if self._arm is None:
raise RuntimeError("睿尔曼机械臂尚未连接") raise RuntimeError("睿尔曼机械臂尚未连接")
return self._arm
def _on_realtime_arm_state(self, data: Any) -> None: def _on_realtime_arm_state(self, data: Any) -> None:
if not self._accept_realtime_feedback: if not self._accept_realtime_feedback:
@@ -457,11 +458,11 @@ class RealManAdapter:
self._try_call("rm_set_joint_max_acc", joint_index, self._joint_max_acc) self._try_call("rm_set_joint_max_acc", joint_index, self._joint_max_acc)
def move_to_initial_pose(self) -> None: def move_to_initial_pose(self) -> None:
self._require_arm() arm = self._require_arm()
if self._initial_joint_pose is None: if self._initial_joint_pose is None:
raise RuntimeError("启用初始位姿移动时必须配置 initial_joint_pose") raise RuntimeError("启用初始位姿移动时必须配置 initial_joint_pose")
ret = self._arm.rm_movej( ret = arm.rm_movej(
self._initial_joint_pose, self._initial_joint_pose,
self._init_move_speed, self._init_move_speed,
0, 0,
@@ -10,17 +10,19 @@ import math
import queue import queue
import threading import threading
import time import time
from dataclasses import dataclass
from typing import Iterable from typing import Iterable
import numpy as np import numpy as np
import rclpy import rclpy
from geometry_msgs.msg import PoseStamped, TwistStamped from geometry_msgs.msg import PoseStamped, TwistStamped
from rclpy.node import Node from rclpy.node import Node
from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy
from rclpy.time import Time from rclpy.time import Time
from sensor_msgs.msg import JointState from sensor_msgs.msg import JointState
from std_msgs.msg import Bool from std_msgs.msg import Bool
from xr_rm_interfaces.msg import XrController from xr_rm_interfaces.msg import ActControlSample, XrController
from .fun_peripheral import load_peripheral_config from .fun_peripheral import load_peripheral_config
from .placo_ik_solver import PlacoIkSolver from .placo_ik_solver import PlacoIkSolver
@@ -31,6 +33,30 @@ from .realman_adapter import (
) )
@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
qp_duration_ms: float = 0.0
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
def _norm(values: Iterable[float]) -> float: def _norm(values: Iterable[float]) -> float:
return math.sqrt(sum(value * value for value in values)) return math.sqrt(sum(value * value for value in values))
@@ -201,14 +227,6 @@ class SingleArmVelocityTeleop(Node):
self.declare_parameter("xr_to_robot_matrix", [0.0, 0.0, -1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]) self.declare_parameter("xr_to_robot_matrix", [0.0, 0.0, -1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0])
self.declare_parameter("use_mock", True) self.declare_parameter("use_mock", True)
self.declare_parameter("robot_urdf_path", "") self.declare_parameter("robot_urdf_path", "")
self.declare_parameter("qp_j3_reference_deg", 0.0)
self.declare_parameter("qp_j3_weight", 1e-5)
self.declare_parameter("qp_j4_min_deg", 10.0)
self.declare_parameter("qp_j4_warn_deg", 25.0)
self.declare_parameter("qp_j4_weight", 1e-4)
self.declare_parameter("qp_manipulability_sigma_stop", 0.01)
self.declare_parameter("qp_manipulability_sigma_warn", 0.04)
self.declare_parameter("qp_manipulability_weight", 1e-4)
self.declare_parameter("robot_ip", "192.168.1.18") self.declare_parameter("robot_ip", "192.168.1.18")
self.declare_parameter("robot_port", 8080) self.declare_parameter("robot_port", 8080)
self.declare_parameter("realtime_push_host_ip", "") self.declare_parameter("realtime_push_host_ip", "")
@@ -268,30 +286,6 @@ class SingleArmVelocityTeleop(Node):
self._low_z_min_radius = float(self.get_parameter("low_z_min_radius").value) self._low_z_min_radius = float(self.get_parameter("low_z_min_radius").value)
self._xr_to_robot_matrix = self._float_list_parameter("xr_to_robot_matrix", 9) self._xr_to_robot_matrix = self._float_list_parameter("xr_to_robot_matrix", 9)
self._use_mock = self._bool_parameter("use_mock") self._use_mock = self._bool_parameter("use_mock")
self._qp_j3_reference_deg = float(
self.get_parameter("qp_j3_reference_deg").value
)
self._qp_j3_weight = float(
self.get_parameter("qp_j3_weight").value
)
self._qp_j4_min_deg = float(
self.get_parameter("qp_j4_min_deg").value
)
self._qp_j4_warn_deg = float(
self.get_parameter("qp_j4_warn_deg").value
)
self._qp_j4_weight = float(
self.get_parameter("qp_j4_weight").value
)
self._qp_manipulability_sigma_stop = float(
self.get_parameter("qp_manipulability_sigma_stop").value
)
self._qp_manipulability_sigma_warn = float(
self.get_parameter("qp_manipulability_sigma_warn").value
)
self._qp_manipulability_weight = float(
self.get_parameter("qp_manipulability_weight").value
)
self._follow = self._bool_parameter("follow") self._follow = self._bool_parameter("follow")
self._enable_tool_control = self._bool_parameter("enable_tool_control") self._enable_tool_control = self._bool_parameter("enable_tool_control")
self._enable_trigger_gripper_control = self._bool_parameter("enable_trigger_gripper_control") self._enable_trigger_gripper_control = self._bool_parameter("enable_trigger_gripper_control")
@@ -323,12 +317,19 @@ class SingleArmVelocityTeleop(Node):
self._latest_joint_positions: list[float] | None = None self._latest_joint_positions: list[float] | None = None
self._last_joint_command_target: list[float] | None = None self._last_joint_command_target: list[float] | None = None
self._last_joint_command_velocity: list[float] | None = None self._last_joint_command_velocity: list[float] | None = None
self._last_successful_action_target: list[float] | None = None
self._act_control_seq = 0
self._joint_feedback_ready = False self._joint_feedback_ready = False
self._grip_rearm_required = False self._grip_rearm_required = False
self._feedback_resync_attempted = False self._feedback_resync_attempted = False
self._control_fault_latched = False self._control_fault_latched = False
self._stop_sent = True self._stop_sent = True
self._trigger_tool_open = True self._trigger_tool_open = True
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
self._last_primary_pressed: bool | None = None self._last_primary_pressed: bool | None = None
self._last_trigger_pressed: bool | None = None self._last_trigger_pressed: bool | None = None
self._tool_command_queue: queue.Queue[tuple[bool, str] | None] | None = None self._tool_command_queue: queue.Queue[tuple[bool, str] | None] | None = None
@@ -360,18 +361,6 @@ class SingleArmVelocityTeleop(Node):
str(self.get_parameter("robot_urdf_path").value), str(self.get_parameter("robot_urdf_path").value),
self._dt, self._dt,
peripheral_arm, peripheral_arm,
j3_reference_deg=self._qp_j3_reference_deg,
j3_weight=self._qp_j3_weight,
j4_min_deg=self._qp_j4_min_deg,
j4_warn_deg=self._qp_j4_warn_deg,
j4_weight=self._qp_j4_weight,
manipulability_sigma_stop=(
self._qp_manipulability_sigma_stop
),
manipulability_sigma_warn=(
self._qp_manipulability_sigma_warn
),
manipulability_weight=self._qp_manipulability_weight,
) )
debug_ns = f"{self._debug_topic_prefix}/{self._arm_name}" debug_ns = f"{self._debug_topic_prefix}/{self._arm_name}"
self._joint_state_pub = self.create_publisher( self._joint_state_pub = self.create_publisher(
@@ -384,6 +373,15 @@ class SingleArmVelocityTeleop(Node):
f"{debug_ns}/joint_target", f"{debug_ns}/joint_target",
10, 10,
) )
self._act_sample_pub = self.create_publisher(
ActControlSample,
f"{debug_ns}/act_control_sample",
QoSProfile(
history=HistoryPolicy.KEEP_LAST,
depth=10,
reliability=ReliabilityPolicy.BEST_EFFORT,
),
)
self._adapter = self._make_adapter() self._adapter = self._make_adapter()
self._adapter.connect() self._adapter.connect()
self._initialize_joint_state() self._initialize_joint_state()
@@ -474,11 +472,20 @@ class SingleArmVelocityTeleop(Node):
def _setup_tool_control(self) -> None: def _setup_tool_control(self) -> None:
peripheral_arm = self._peripheral_arm_name() peripheral_arm = self._peripheral_arm_name()
if self._bool_parameter("configure_peripheral_on_connect"): configure_on_connect = self._bool_parameter(
"configure_peripheral_on_connect"
)
if configure_on_connect:
self._adapter.configure_peripheral( self._adapter.configure_peripheral(
self._peripheral_config, self._peripheral_config,
peripheral_arm, peripheral_arm,
) )
if self._peripheral_config.set_initial_tool_state:
with self._tool_state_lock:
self._tool_target_open = True
self._tool_state_open = True
self._tool_command_pending = False
self._tool_command_failed = False
if not self._enable_tool_control: if not self._enable_tool_control:
if self._enable_trigger_gripper_control: if self._enable_trigger_gripper_control:
@@ -528,6 +535,10 @@ class SingleArmVelocityTeleop(Node):
) )
return return
with self._tool_state_lock:
self._tool_target_open = open_tool
self._tool_command_pending = True
item = (open_tool, source) item = (open_tool, source)
while True: while True:
try: try:
@@ -556,15 +567,34 @@ class SingleArmVelocityTeleop(Node):
try: try:
self._adapter.set_tool_enabled(open_tool) self._adapter.set_tool_enabled(open_tool)
except Exception as exc: except Exception as exc:
with self._tool_state_lock:
self._tool_command_failed = True
self.get_logger().error( self.get_logger().error(
f"{self._arm_name} tool {action} failed from {source}: {exc}" f"{self._arm_name} tool {action} failed from {source}: {exc}"
) )
continue continue
with self._tool_state_lock:
self._tool_state_open = open_tool
self._tool_command_failed = False
self.get_logger().info( self.get_logger().info(
f"{self._arm_name} tool {action} command sent from {source}" f"{self._arm_name} tool {action} command sent from {source}"
) )
finally: finally:
self._tool_command_queue.task_done() self._tool_command_queue.task_done()
if self._tool_command_queue.empty():
with self._tool_state_lock:
self._tool_command_pending = False
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,
)
def _peripheral_arm_name(self) -> str: def _peripheral_arm_name(self) -> str:
configured = str(self.get_parameter("peripheral_arm").value).strip().lower() configured = str(self.get_parameter("peripheral_arm").value).strip().lower()
@@ -626,6 +656,18 @@ class SingleArmVelocityTeleop(Node):
self._enqueue_tool_command(self._trigger_tool_open, "trigger") self._enqueue_tool_command(self._trigger_tool_open, "trigger")
def _control_tick(self) -> None: def _control_tick(self) -> None:
control_seq = getattr(self, "_act_control_seq", 0)
self._act_control_seq = control_seq + 1
cycle = _ActCycleContext(
control_seq=control_seq,
control_monotonic_ns=time.monotonic_ns(),
)
try:
self._control_tick_impl(cycle)
finally:
self._publish_act_control_sample(cycle)
def _control_tick_impl(self, cycle: _ActCycleContext) -> None:
tick_started_ns = time.perf_counter_ns() tick_started_ns = time.perf_counter_ns()
last_tick_started_ns = getattr( last_tick_started_ns = getattr(
self, self,
@@ -640,10 +682,12 @@ class SingleArmVelocityTeleop(Node):
) )
now = self.get_clock().now() now = self.get_clock().now()
if self._control_fault_latched: if self._control_fault_latched:
cycle.control_fault = True
return return
snapshot = self._adapter.get_latest_joint_state() snapshot = self._adapter.get_latest_joint_state()
if not self._joint_snapshot_is_motion_ready(snapshot): if not self._joint_snapshot_is_motion_ready(snapshot):
cycle.control_fault = True
self._grip_rearm_required = True self._grip_rearm_required = True
if self._joint_feedback_ready: if self._joint_feedback_ready:
self.get_logger().warn( self.get_logger().warn(
@@ -654,13 +698,18 @@ class SingleArmVelocityTeleop(Node):
self._safe_stop(reset_active=True) self._safe_stop(reset_active=True)
return return
assert snapshot is not None assert snapshot is not None
cycle.q_actual = list(snapshot.positions)
cycle.feedback_monotonic_ns = int(snapshot.received_at * 1e9)
feedback_age = time.monotonic() - snapshot.received_at feedback_age = time.monotonic() - snapshot.received_at
cycle.feedback_age_ms = feedback_age * 1000.0
if feedback_age < 0.0: if feedback_age < 0.0:
cycle.control_fault = True
self._grip_rearm_required = True self._grip_rearm_required = True
self._joint_feedback_ready = False self._joint_feedback_ready = False
self._safe_stop(reset_active=True) self._safe_stop(reset_active=True)
return return
if feedback_age > self._command_timeout_sec: if feedback_age > self._command_timeout_sec:
cycle.control_fault = True
self._handle_stale_joint_feedback(feedback_age) self._handle_stale_joint_feedback(feedback_age)
return return
@@ -668,6 +717,7 @@ class SingleArmVelocityTeleop(Node):
try: try:
current_pose = self._sync_joint_feedback(snapshot) current_pose = self._sync_joint_feedback(snapshot)
except Exception as exc: except Exception as exc:
cycle.control_fault = True
self.get_logger().error( self.get_logger().error(
f"{self._arm_name} 关节反馈同步到 Placo 失败:{exc}", f"{self._arm_name} 关节反馈同步到 Placo 失败:{exc}",
throttle_duration_sec=1.0, throttle_duration_sec=1.0,
@@ -676,6 +726,8 @@ class SingleArmVelocityTeleop(Node):
self._grip_rearm_required = True self._grip_rearm_required = True
self._safe_stop(reset_active=True) self._safe_stop(reset_active=True)
return return
cycle.current_pose = current_pose
cycle.feedback_valid = True
if not self._joint_feedback_ready: if not self._joint_feedback_ready:
if self._grip_rearm_required: if self._grip_rearm_required:
message = ( message = (
@@ -724,6 +776,7 @@ class SingleArmVelocityTeleop(Node):
try: try:
controller_quat = self._controller_quaternion(self._last_msg) controller_quat = self._controller_quaternion(self._last_msg)
except ValueError as exc: except ValueError as exc:
cycle.control_fault = True
self.get_logger().warn( self.get_logger().warn(
f"{self._arm_name} XR 手柄姿态无效,停止输出:{exc}", f"{self._arm_name} XR 手柄姿态无效,停止输出:{exc}",
throttle_duration_sec=1.0, throttle_duration_sec=1.0,
@@ -768,23 +821,35 @@ class SingleArmVelocityTeleop(Node):
sent_target, sent_target,
sent_orientation, sent_orientation,
) )
cycle.raw_target_pose = raw_target_pose
cycle.target_pose = target_pose
cycle.command_velocity = list(velocity)
cycle.target_clamped = target_clamped
self._publish_debug(raw_target_pose, target_pose, velocity, target_clamped) self._publish_debug(raw_target_pose, target_pose, velocity, target_clamped)
cycle.qp_attempted = True
qp_started_ns = time.perf_counter_ns() qp_started_ns = time.perf_counter_ns()
joint_target = self._solve_joint_target(target_pose) joint_target, qp_success = self._solve_joint_target(target_pose)
qp_ms = (time.perf_counter_ns() - qp_started_ns) * 1e-6 qp_ms = (time.perf_counter_ns() - qp_started_ns) * 1e-6
cycle.qp_duration_ms = qp_ms
cycle.q_qp_raw = list(joint_target)
cycle.qp_success = qp_success
send_started_ns = time.perf_counter_ns() send_started_ns = time.perf_counter_ns()
sent = self._send_and_commit_joint_target( sent = self._send_joint_target(joint_target)
joint_target,
filtered_target,
filtered_orientation,
sent_target,
sent_orientation,
now,
)
send_ms = (time.perf_counter_ns() - send_started_ns) * 1e-6 send_ms = (time.perf_counter_ns() - send_started_ns) * 1e-6
if sent: if sent:
assert self._last_joint_command_target is not None
final_target = list(self._last_joint_command_target)
self._last_successful_action_target = final_target
cycle.q_target = final_target
cycle.action_monotonic_ns = time.monotonic_ns()
cycle.command_sent = True
self._last_sent_target = sent_target
self._last_sent_orientation = sent_orientation.copy()
self._last_command_time = now
self._stop_sent = False self._stop_sent = False
else:
cycle.send_failed = True
total_ms = (time.perf_counter_ns() - tick_started_ns) * 1e-6 total_ms = (time.perf_counter_ns() - tick_started_ns) * 1e-6
try: try:
self._record_timing_sample( self._record_timing_sample(
@@ -915,15 +980,17 @@ class SingleArmVelocityTeleop(Node):
def _filter_target(self, target: list[float]) -> list[float]: def _filter_target(self, target: list[float]) -> list[float]:
if self._filtered_target is None: if self._filtered_target is None:
self._filtered_target = list(target)
return list(target) return list(target)
delta = [target[i] - self._filtered_target[i] for i in range(3)] delta = [target[i] - self._filtered_target[i] for i in range(3)]
distance = _norm(delta) distance = _norm(delta)
alpha = self._adaptive_filter_alpha(distance) alpha = self._adaptive_filter_alpha(distance)
return [ self._filtered_target = [
alpha * target[i] + (1.0 - alpha) * self._filtered_target[i] alpha * target[i] + (1.0 - alpha) * self._filtered_target[i]
for i in range(3) for i in range(3)
] ]
return list(self._filtered_target)
def _adaptive_filter_alpha(self, distance: float) -> float: def _adaptive_filter_alpha(self, distance: float) -> float:
if self._target_filter_fast_threshold_m <= 1e-9: if self._target_filter_fast_threshold_m <= 1e-9:
@@ -962,15 +1029,17 @@ class SingleArmVelocityTeleop(Node):
def _filter_orientation_target(self, target_rotation: np.ndarray) -> np.ndarray: def _filter_orientation_target(self, target_rotation: np.ndarray) -> np.ndarray:
if self._filtered_orientation_target is None: if self._filtered_orientation_target is None:
return _project_rotation(target_rotation) self._filtered_orientation_target = _project_rotation(target_rotation)
return self._filtered_orientation_target.copy()
error = _so3_log( error = _so3_log(
target_rotation @ self._filtered_orientation_target.T target_rotation @ self._filtered_orientation_target.T
) )
return _project_rotation( self._filtered_orientation_target = _project_rotation(
_so3_exp(self._orientation_filter_alpha * error) _so3_exp(self._orientation_filter_alpha * error)
@ self._filtered_orientation_target @ self._filtered_orientation_target
) )
return self._filtered_orientation_target.copy()
def _limit_orientation_step( def _limit_orientation_step(
self, self,
@@ -1243,7 +1312,7 @@ class SingleArmVelocityTeleop(Node):
def _solve_joint_target( def _solve_joint_target(
self, self,
target_pose: np.ndarray, target_pose: np.ndarray,
) -> list[float] | None: ) -> tuple[list[float], bool]:
if self._last_valid_joint_target is None: if self._last_valid_joint_target is None:
raise RuntimeError("valid joint feedback has not been initialized") raise RuntimeError("valid joint feedback has not been initialized")
try: try:
@@ -1253,27 +1322,9 @@ class SingleArmVelocityTeleop(Node):
f"{self._arm_name} QP 求解失败,保持上一组关节目标:{exc}", f"{self._arm_name} QP 求解失败,保持上一组关节目标:{exc}",
throttle_duration_sec=1.0, throttle_duration_sec=1.0,
) )
return None return list(self._last_valid_joint_target), False
return list(result) self._last_valid_joint_target = list(result)
return list(result), True
def _send_and_commit_joint_target(
self,
joint_target: list[float] | None,
filtered_target: list[float],
filtered_orientation: np.ndarray,
sent_target: list[float],
sent_orientation: np.ndarray,
now: Time,
) -> bool:
if joint_target is None or not self._send_joint_target(joint_target):
return False
self._last_valid_joint_target = list(joint_target)
self._filtered_target = list(filtered_target)
self._filtered_orientation_target = filtered_orientation.copy()
self._last_sent_target = list(sent_target)
self._last_sent_orientation = sent_orientation.copy()
self._last_command_time = now
return True
def _safe_stop(self, reset_active: bool) -> None: def _safe_stop(self, reset_active: bool) -> None:
if not self._stop_sent: if not self._stop_sent:
@@ -1445,6 +1496,122 @@ class SingleArmVelocityTeleop(Node):
self._cmd_vel_pub.publish(velocity_msg) self._cmd_vel_pub.publish(velocity_msg)
self._target_clamped_pub.publish(clamped_msg) self._target_clamped_pub.publish(clamped_msg)
def _build_act_control_sample(
self,
cycle: _ActCycleContext,
) -> ActControlSample:
message = ActControlSample()
message.header.stamp = self.get_clock().now().to_msg()
message.header.frame_id = "rm_base"
message.control_seq = cycle.control_seq
message.control_monotonic_ns = cycle.control_monotonic_ns
message.feedback_monotonic_ns = cycle.feedback_monotonic_ns
message.action_monotonic_ns = cycle.action_monotonic_ns
message.feedback_age_ms = float(cycle.feedback_age_ms)
message.qp_duration_ms = float(cycle.qp_duration_ms)
q_actual = cycle.q_actual or [0.0] * 7
held_target = (
cycle.q_target
or self._last_successful_action_target
or q_actual
)
qp_target = cycle.q_qp_raw or held_target
limits = np.asarray(
self._ik_solver.joint_position_limits,
dtype=float,
)
if limits.shape != (7, 2) or not np.isfinite(limits).all():
raise ValueError("joint limits must have finite shape (7, 2)")
message.q_actual = [float(value) for value in q_actual]
message.q_qp_raw = [float(value) for value in qp_target]
message.q_target = [float(value) for value in held_target]
message.joint_lower_limits = limits[:, 0].tolist()
message.joint_upper_limits = limits[:, 1].tolist()
current_pose = cycle.current_pose
if current_pose is None:
current_pose = self._debug_pose_fallback()
if current_pose is None:
current_pose = np.eye(4)
raw_target_pose = cycle.raw_target_pose
if raw_target_pose is None:
raw_target_pose = current_pose
target_pose = cycle.target_pose
if target_pose is None:
target_pose = current_pose
message.tcp_current = self._pose_msg(
message.header.stamp,
current_pose,
).pose
message.tcp_raw_target = self._pose_msg(
message.header.stamp,
raw_target_pose,
).pose
message.tcp_target = self._pose_msg(
message.header.stamp,
target_pose,
).pose
velocity = cycle.command_velocity or [0.0] * 6
if len(velocity) != 6:
raise ValueError("ACT command velocity must contain 6 values")
message.tcp_command_velocity.linear.x = float(velocity[0])
message.tcp_command_velocity.linear.y = float(velocity[1])
message.tcp_command_velocity.linear.z = float(velocity[2])
message.tcp_command_velocity.angular.x = float(velocity[3])
message.tcp_command_velocity.angular.y = float(velocity[4])
message.tcp_command_velocity.angular.z = float(velocity[5])
controller = self._last_msg
if controller is not None:
message.pico_pose = controller.pose
message.pico_grip = bool(controller.grip)
message.pico_trigger = float(controller.trigger)
message.pico_primary = bool(controller.primary)
message.pico_secondary = bool(controller.secondary)
message.pico_axis = [float(value) for value in controller.axis]
tool_target, tool_state, tool_pending, tool_failed = (
self._tool_state_snapshot()
)
message.gripper_target_open = tool_target
message.gripper_state_known = tool_state is not None
message.gripper_state_open = bool(tool_state)
message.gripper_command_pending = tool_pending
message.gripper_command_failed = tool_failed
message.teleop_active = bool(self._active)
message.feedback_valid = cycle.feedback_valid
message.action_valid = bool(
self._last_successful_action_target is not None
and cycle.feedback_valid
and not cycle.send_failed
and not cycle.control_fault
)
message.command_sent = cycle.command_sent
message.qp_attempted = cycle.qp_attempted
message.qp_success = cycle.qp_success
message.target_clamped = cycle.target_clamped
message.control_fault = bool(
cycle.control_fault or self._control_fault_latched
)
return message
def _publish_act_control_sample(
self,
cycle: _ActCycleContext,
) -> None:
publisher = getattr(self, "_act_sample_pub", None)
if publisher is None:
return
try:
publisher.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,
)
@staticmethod @staticmethod
def _pose_msg(stamp, pose: np.ndarray) -> PoseStamped: def _pose_msg(stamp, pose: np.ndarray) -> PoseStamped:
transform = _make_transform(pose[:3, 3], pose[:3, :3]) transform = _make_transform(pose[:3, 3], pose[:3, :3])
@@ -1526,35 +1693,6 @@ class SingleArmVelocityTeleop(Node):
raise ValueError("joint_max_speed must be > 0") raise ValueError("joint_max_speed must be > 0")
if self._joint_command_max_acceleration <= 0.0: if self._joint_command_max_acceleration <= 0.0:
raise ValueError("joint_max_acc must be > 0") raise ValueError("joint_max_acc must be > 0")
qp_weights = (
self._qp_j3_weight,
self._qp_j4_weight,
self._qp_manipulability_weight,
)
if not all(
math.isfinite(value) and value >= 0.0
for value in qp_weights
):
raise ValueError("QP auxiliary weights must be finite and non-negative")
if not math.isfinite(self._qp_j3_reference_deg):
raise ValueError("qp_j3_reference_deg must be finite")
if not all(
math.isfinite(value)
for value in (self._qp_j4_min_deg, self._qp_j4_warn_deg)
):
raise ValueError("QP J4 angles must be finite")
if self._qp_j4_warn_deg <= self._qp_j4_min_deg:
raise ValueError("qp_j4_warn_deg must exceed qp_j4_min_deg")
if not (
math.isfinite(self._qp_manipulability_sigma_stop)
and math.isfinite(self._qp_manipulability_sigma_warn)
and 0.0 < self._qp_manipulability_sigma_stop
< self._qp_manipulability_sigma_warn
):
raise ValueError(
"QP manipulability sigma thresholds must satisfy "
"0 < stop < warn"
)
def _shutdown_tool_worker(self) -> None: def _shutdown_tool_worker(self) -> None:
if self._tool_worker_thread is None or self._tool_command_queue is None: if self._tool_worker_thread is None or self._tool_command_queue is None: