feat: 对齐ACT双相机帧

This commit is contained in:
2026-08-10 18:06:45 +08:00
parent deeee076d7
commit 1ec74f107b
2 changed files with 289 additions and 1 deletions
+90 -1
View File
@@ -1,8 +1,16 @@
import numpy as np
import pytest
from xr_rm_teleop.act_episode_recorder import ( from xr_rm_teleop.act_episode_recorder import (
NO_ACTION, NO_ACTION,
ButtonTracker, ButtonTracker,
CameraBuffer,
CameraFrame,
QualityError,
RecordingSession, RecordingSession,
RecordingState, RecordingState,
select_camera_pair,
select_frame,
) )
@@ -19,7 +27,7 @@ def _recording_session(*, origin_seq=10, max_samples=1800):
return session return session
def test_recording_starts_on_first_sent_grip_action_and_samples_every_third_cycle(): def test_recording_starts_on_first_sent_grip_action_and_downsamples():
session = RecordingSession(max_samples=1800) session = RecordingSession(max_samples=1800)
session.arm() session.arm()
@@ -184,3 +192,84 @@ def test_right_a_reports_recording_rejection_event():
) )
assert events.reject_reason == "initial_pose_command_during_episode" assert events.reject_reason == "initial_pose_command_during_episode"
def _image(value, *, shape=(480, 640, 3), dtype=np.uint8):
return np.full(shape, value, dtype=dtype)
def test_select_frame_returns_latest_frame_not_after_control_time():
frames = (
CameraFrame(_image(1), 10, 100.0, 900_000_000),
CameraFrame(_image(2), 11, 133.3, 933_000_000),
CameraFrame(_image(3), 12, 166.6, 1_010_000_000),
)
selected, age_ms = select_frame(frames, 1_000_000_000, 100.0)
assert selected.frame_number == 11
assert age_ms == pytest.approx(67.0, abs=0.1)
def test_select_frame_rejects_missing_old_and_invalid_images():
with pytest.raises(QualityError, match="camera_frame_missing"):
select_frame((), 1_000_000_000, 50.0)
with pytest.raises(QualityError, match="camera_frame_too_old"):
select_frame(
(CameraFrame(_image(1), 10, 100.0, 900_000_000),),
1_000_000_000,
50.0,
)
with pytest.raises(QualityError, match="camera_frame_format"):
select_frame(
(
CameraFrame(
_image(1, shape=(10, 10, 3)),
10,
100.0,
990_000_000,
),
),
1_000_000_000,
50.0,
)
def test_select_camera_pair_rejects_inter_camera_skew():
high = (CameraFrame(_image(1), 10, 100.0, 990_000_000),)
wrist = (CameraFrame(_image(2), 20, 100.0, 930_000_000),)
with pytest.raises(QualityError, match="camera_skew"):
select_camera_pair(
high,
wrist,
1_000_000_000,
max_age_ms=100.0,
max_skew_ms=50.0,
)
def test_camera_buffer_is_bounded_and_counts_dropped_frames():
buffer = CameraBuffer(maxlen=4)
for frame_number in (10, 11, 13, 14, 15):
buffer.push(
CameraFrame(
_image(frame_number),
frame_number,
float(frame_number),
frame_number * 1_000_000,
)
)
stats = buffer.stats()
assert [frame.frame_number for frame in buffer.snapshot()] == [
11,
13,
14,
15,
]
assert stats.frame_count == 5
assert stats.dropped_frames == 1
assert stats.drop_ratio == pytest.approx(1.0 / 6.0)
assert stats.fps == pytest.approx(800.0)
@@ -2,9 +2,208 @@
from __future__ import annotations from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
import numpy as np
class QualityError(RuntimeError):
pass
@dataclass(frozen=True)
class CameraFrame:
image: np.ndarray
frame_number: int
hardware_timestamp_ms: float
host_monotonic_ns: int
@dataclass(frozen=True)
class CameraStats:
frame_count: int
dropped_frames: int
first_host_monotonic_ns: int | None
last_host_monotonic_ns: int | None
@property
def drop_ratio(self) -> float:
expected = self.frame_count + self.dropped_frames
return self.dropped_frames / expected if expected else 0.0
@property
def fps(self) -> float:
if self.frame_count < 2:
return 0.0
assert self.first_host_monotonic_ns is not None
assert self.last_host_monotonic_ns is not None
elapsed_ns = self.last_host_monotonic_ns - self.first_host_monotonic_ns
if elapsed_ns <= 0:
return 0.0
return (self.frame_count - 1) * 1e9 / elapsed_ns
class CameraBuffer:
def __init__(self, *, maxlen: int = 4) -> None:
if maxlen <= 0:
raise ValueError("maxlen must be positive")
self._frames: deque[CameraFrame] = deque(maxlen=maxlen)
self._lock = threading.Lock()
self._frame_count = 0
self._dropped_frames = 0
self._first_host_monotonic_ns: int | None = None
self._last_host_monotonic_ns: int | None = None
self._last_frame_number: int | None = None
def push(self, frame: CameraFrame) -> None:
with self._lock:
if (
self._last_frame_number is not None
and frame.frame_number > self._last_frame_number + 1
):
self._dropped_frames += (
frame.frame_number - self._last_frame_number - 1
)
self._last_frame_number = frame.frame_number
self._frame_count += 1
if self._first_host_monotonic_ns is None:
self._first_host_monotonic_ns = frame.host_monotonic_ns
self._last_host_monotonic_ns = frame.host_monotonic_ns
self._frames.append(frame)
def snapshot(self) -> tuple[CameraFrame, ...]:
with self._lock:
return tuple(self._frames)
def stats(self) -> CameraStats:
with self._lock:
return CameraStats(
frame_count=self._frame_count,
dropped_frames=self._dropped_frames,
first_host_monotonic_ns=self._first_host_monotonic_ns,
last_host_monotonic_ns=self._last_host_monotonic_ns,
)
def select_frame(
frames: tuple[CameraFrame, ...],
control_monotonic_ns: int,
max_age_ms: float,
) -> tuple[CameraFrame, float]:
eligible = [
frame
for frame in frames
if frame.host_monotonic_ns <= control_monotonic_ns
]
if not eligible:
raise QualityError("camera_frame_missing")
frame = max(eligible, key=lambda item: item.host_monotonic_ns)
age_ms = (control_monotonic_ns - frame.host_monotonic_ns) * 1e-6
if age_ms > max_age_ms:
raise QualityError("camera_frame_too_old")
if frame.image.shape != (480, 640, 3) or frame.image.dtype != np.uint8:
raise QualityError("camera_frame_format")
return frame, age_ms
def select_camera_pair(
high_frames: tuple[CameraFrame, ...],
wrist_frames: tuple[CameraFrame, ...],
control_monotonic_ns: int,
*,
max_age_ms: float,
max_skew_ms: float,
) -> tuple[CameraFrame, CameraFrame, float, float, float]:
high, high_age_ms = select_frame(
high_frames, control_monotonic_ns, max_age_ms
)
wrist, wrist_age_ms = select_frame(
wrist_frames, control_monotonic_ns, max_age_ms
)
skew_ms = abs(high.host_monotonic_ns - wrist.host_monotonic_ns) * 1e-6
if skew_ms > max_skew_ms:
raise QualityError("camera_skew")
return high, wrist, high_age_ms, wrist_age_ms, skew_ms
class RealSenseCamera:
def __init__(
self,
serial: str,
expected_model: str,
*,
buffer_size: int = 4,
) -> None:
self.serial = serial
self.expected_model = expected_model
self.buffer = CameraBuffer(maxlen=buffer_size)
self.last_error: Exception | None = None
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._pipeline = None
def start(self) -> None:
import pyrealsense2 as rs
if self._thread is not None:
raise RuntimeError("camera already started")
devices = {
device.get_info(rs.camera_info.serial_number): device
for device in rs.context().query_devices()
}
if self.serial not in devices:
raise RuntimeError(f"RealSense serial not found: {self.serial}")
model = devices[self.serial].get_info(rs.camera_info.name)
if self.expected_model not in model:
raise RuntimeError(
f"RealSense {self.serial} model mismatch: expected "
f"{self.expected_model}, got {model}"
)
pipeline = rs.pipeline()
config = rs.config()
config.enable_device(self.serial)
config.enable_stream(rs.stream.color, 640, 480, rs.format.rgb8, 30)
pipeline.start(config)
self._pipeline = pipeline
self._stop.clear()
self.last_error = None
self._thread = threading.Thread(target=self._capture, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=2.0)
if self._pipeline is not None:
self._pipeline.stop()
self._thread = None
self._pipeline = None
def _capture(self) -> None:
try:
while not self._stop.is_set():
frames = self._pipeline.wait_for_frames(timeout_ms=1000)
color = frames.get_color_frame()
received_ns = time.monotonic_ns()
if not color:
continue
self.buffer.push(
CameraFrame(
np.asanyarray(color.get_data()).copy(),
color.get_frame_number(),
color.get_timestamp(),
received_ns,
)
)
except Exception as exc:
if not self._stop.is_set():
self.last_error = exc
class RecordingState(str, Enum): class RecordingState(str, Enum):
IDLE = "IDLE" IDLE = "IDLE"