Files
acRealman_xr/docs/superpowers/plans/2026-08-13-act-camera-preview-alignment.md
T

757 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ACT 双相机预览与采样质量判定修正实施计划
> **供代理执行者使用:** 必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`,逐项执行本计划。所有步骤使用复选框(`- [ ]`)跟踪。
**目标:** 保持 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 属性包含真实采集质量与两路采样重复/跨帧指标。