feat: 添加ACT录制状态机
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
from xr_rm_teleop.act_episode_recorder import (
|
||||||
|
NO_ACTION,
|
||||||
|
ButtonTracker,
|
||||||
|
RecordingSession,
|
||||||
|
RecordingState,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _recording_session(*, origin_seq=10, max_samples=1800):
|
||||||
|
session = RecordingSession(max_samples=max_samples)
|
||||||
|
session.arm()
|
||||||
|
decision = session.on_control(
|
||||||
|
origin_seq,
|
||||||
|
grip=True,
|
||||||
|
action_valid=True,
|
||||||
|
command_sent=True,
|
||||||
|
)
|
||||||
|
assert decision.record_sample
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def test_recording_starts_on_first_sent_grip_action_and_samples_every_third_cycle():
|
||||||
|
session = RecordingSession(max_samples=1800)
|
||||||
|
session.arm()
|
||||||
|
|
||||||
|
assert session.on_control(
|
||||||
|
100,
|
||||||
|
grip=False,
|
||||||
|
action_valid=True,
|
||||||
|
command_sent=False,
|
||||||
|
) == NO_ACTION
|
||||||
|
assert session.on_control(
|
||||||
|
101,
|
||||||
|
grip=True,
|
||||||
|
action_valid=True,
|
||||||
|
command_sent=False,
|
||||||
|
) == NO_ACTION
|
||||||
|
first = session.on_control(
|
||||||
|
102,
|
||||||
|
grip=True,
|
||||||
|
action_valid=True,
|
||||||
|
command_sent=True,
|
||||||
|
)
|
||||||
|
second = session.on_control(
|
||||||
|
103,
|
||||||
|
grip=True,
|
||||||
|
action_valid=True,
|
||||||
|
command_sent=True,
|
||||||
|
)
|
||||||
|
session.on_control(
|
||||||
|
104,
|
||||||
|
grip=True,
|
||||||
|
action_valid=True,
|
||||||
|
command_sent=True,
|
||||||
|
)
|
||||||
|
third = session.on_control(
|
||||||
|
105,
|
||||||
|
grip=True,
|
||||||
|
action_valid=True,
|
||||||
|
command_sent=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.record_sample
|
||||||
|
assert not second.record_sample
|
||||||
|
assert third.record_sample
|
||||||
|
assert session.sample_origin_seq == 102
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_grip_release_marks_crop_point_but_mid_pause_is_kept():
|
||||||
|
session = _recording_session(origin_seq=10)
|
||||||
|
session.on_control(11, grip=True, action_valid=True, command_sent=True)
|
||||||
|
session.on_control(12, grip=True, action_valid=True, command_sent=True)
|
||||||
|
session.on_control(13, grip=False, action_valid=True, command_sent=False)
|
||||||
|
first_crop = session.candidate_end_count
|
||||||
|
|
||||||
|
assert first_crop == 2
|
||||||
|
|
||||||
|
session.on_control(14, grip=True, action_valid=True, command_sent=False)
|
||||||
|
assert session.candidate_end_count is None
|
||||||
|
session.on_control(15, grip=True, action_valid=True, command_sent=True)
|
||||||
|
session.on_control(16, grip=False, action_valid=True, command_sent=False)
|
||||||
|
|
||||||
|
assert session.candidate_end_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_waits_for_control_sample_after_b_request():
|
||||||
|
session = _recording_session(origin_seq=10)
|
||||||
|
session.on_control(11, grip=False, action_valid=True, command_sent=False)
|
||||||
|
session.request_finish()
|
||||||
|
|
||||||
|
decision = session.on_control(
|
||||||
|
12,
|
||||||
|
grip=False,
|
||||||
|
action_valid=True,
|
||||||
|
command_sent=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decision.finish
|
||||||
|
assert session.candidate_end_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_control_sequence_rejects_recording():
|
||||||
|
session = _recording_session(origin_seq=10)
|
||||||
|
session.on_control(11, grip=True, action_valid=True, command_sent=True)
|
||||||
|
|
||||||
|
decision = session.on_control(
|
||||||
|
13,
|
||||||
|
grip=True,
|
||||||
|
action_valid=True,
|
||||||
|
command_sent=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decision.reject_reason == "control_sequence_gap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_samples_rejects_without_stopping_robot():
|
||||||
|
session = _recording_session(origin_seq=10, max_samples=2)
|
||||||
|
session.on_control(11, grip=True, action_valid=True, command_sent=True)
|
||||||
|
session.on_control(12, grip=True, action_valid=True, command_sent=True)
|
||||||
|
|
||||||
|
decision = session.on_control(
|
||||||
|
13,
|
||||||
|
grip=True,
|
||||||
|
action_valid=True,
|
||||||
|
command_sent=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decision.reject_reason == "max_duration"
|
||||||
|
|
||||||
|
|
||||||
|
def test_right_b_is_ignored_while_grip_is_pressed():
|
||||||
|
tracker = ButtonTracker(hold_ns=1_000_000_000)
|
||||||
|
tracker.on_right(False, False, False, 0, RecordingState.IDLE)
|
||||||
|
|
||||||
|
events = tracker.on_right(
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
1,
|
||||||
|
RecordingState.IDLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not events.right_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_left_y_requires_new_press_inside_active_recording_state():
|
||||||
|
tracker = ButtonTracker(hold_ns=1_000_000_000)
|
||||||
|
tracker.on_left(True, 0, RecordingState.IDLE)
|
||||||
|
assert not tracker.on_left(
|
||||||
|
True,
|
||||||
|
2_000_000_000,
|
||||||
|
RecordingState.RECORDING,
|
||||||
|
).discard
|
||||||
|
tracker.on_left(False, 2_100_000_000, RecordingState.RECORDING)
|
||||||
|
tracker.on_left(True, 3_000_000_000, RecordingState.RECORDING)
|
||||||
|
|
||||||
|
assert not tracker.on_left(
|
||||||
|
True,
|
||||||
|
3_999_999_999,
|
||||||
|
RecordingState.RECORDING,
|
||||||
|
).discard
|
||||||
|
assert tracker.on_left(
|
||||||
|
True,
|
||||||
|
4_000_000_000,
|
||||||
|
RecordingState.RECORDING,
|
||||||
|
).discard
|
||||||
|
assert not tracker.on_left(
|
||||||
|
True,
|
||||||
|
5_000_000_000,
|
||||||
|
RecordingState.RECORDING,
|
||||||
|
).discard
|
||||||
|
|
||||||
|
|
||||||
|
def test_right_a_reports_recording_rejection_event():
|
||||||
|
tracker = ButtonTracker(hold_ns=1_000_000_000)
|
||||||
|
tracker.on_right(False, False, False, 0, RecordingState.RECORDING)
|
||||||
|
|
||||||
|
events = tracker.on_right(
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
1,
|
||||||
|
RecordingState.RECORDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert events.reject_reason == "initial_pose_command_during_episode"
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""ALOHA/ACT 风格的右臂 episode 采集节点。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingState(str, Enum):
|
||||||
|
IDLE = "IDLE"
|
||||||
|
ARMED = "ARMED"
|
||||||
|
RECORDING = "RECORDING"
|
||||||
|
SAVING = "SAVING"
|
||||||
|
SAVED = "SAVED"
|
||||||
|
DISCARDED = "DISCARDED"
|
||||||
|
REJECTED = "REJECTED"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ControlDecision:
|
||||||
|
record_sample: bool = False
|
||||||
|
finish: bool = False
|
||||||
|
reject_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
NO_ACTION = ControlDecision()
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingSession:
|
||||||
|
def __init__(self, *, max_samples: int) -> None:
|
||||||
|
if max_samples <= 0:
|
||||||
|
raise ValueError("max_samples must be positive")
|
||||||
|
self.max_samples = max_samples
|
||||||
|
self.state = RecordingState.IDLE
|
||||||
|
self.sample_origin_seq: int | None = None
|
||||||
|
self.last_control_seq: int | None = None
|
||||||
|
self.selected_count = 0
|
||||||
|
self.candidate_end_count: int | None = None
|
||||||
|
self.finish_requested = False
|
||||||
|
self._last_grip = False
|
||||||
|
|
||||||
|
def arm(self) -> None:
|
||||||
|
if self.state is not RecordingState.IDLE:
|
||||||
|
raise RuntimeError("recording session can only arm from IDLE")
|
||||||
|
self.state = RecordingState.ARMED
|
||||||
|
self.sample_origin_seq = None
|
||||||
|
self.last_control_seq = None
|
||||||
|
self.selected_count = 0
|
||||||
|
self.candidate_end_count = None
|
||||||
|
self.finish_requested = False
|
||||||
|
self._last_grip = False
|
||||||
|
|
||||||
|
def request_finish(self) -> None:
|
||||||
|
if self.state is RecordingState.RECORDING:
|
||||||
|
self.finish_requested = True
|
||||||
|
|
||||||
|
def on_control(
|
||||||
|
self,
|
||||||
|
control_seq: int,
|
||||||
|
*,
|
||||||
|
grip: bool,
|
||||||
|
action_valid: bool,
|
||||||
|
command_sent: bool,
|
||||||
|
) -> ControlDecision:
|
||||||
|
if self.state is RecordingState.ARMED:
|
||||||
|
if not (grip and action_valid and command_sent):
|
||||||
|
return NO_ACTION
|
||||||
|
self.state = RecordingState.RECORDING
|
||||||
|
self.sample_origin_seq = control_seq
|
||||||
|
self.last_control_seq = control_seq
|
||||||
|
self.selected_count = 1
|
||||||
|
self._last_grip = True
|
||||||
|
return ControlDecision(record_sample=True)
|
||||||
|
|
||||||
|
if self.state is not RecordingState.RECORDING:
|
||||||
|
return NO_ACTION
|
||||||
|
assert self.sample_origin_seq is not None
|
||||||
|
assert self.last_control_seq is not None
|
||||||
|
|
||||||
|
if control_seq != self.last_control_seq + 1:
|
||||||
|
self.state = RecordingState.REJECTED
|
||||||
|
return ControlDecision(reject_reason="control_sequence_gap")
|
||||||
|
self.last_control_seq = control_seq
|
||||||
|
if not action_valid:
|
||||||
|
self.state = RecordingState.REJECTED
|
||||||
|
return ControlDecision(reject_reason="invalid_action")
|
||||||
|
|
||||||
|
record_sample = (
|
||||||
|
(control_seq - self.sample_origin_seq) % 3 == 0
|
||||||
|
)
|
||||||
|
if record_sample:
|
||||||
|
self.selected_count += 1
|
||||||
|
|
||||||
|
if self._last_grip and not grip:
|
||||||
|
self.candidate_end_count = self.selected_count
|
||||||
|
elif not self._last_grip and grip:
|
||||||
|
self.candidate_end_count = None
|
||||||
|
self._last_grip = grip
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.finish_requested
|
||||||
|
and not grip
|
||||||
|
and self.candidate_end_count is not None
|
||||||
|
):
|
||||||
|
self.state = RecordingState.SAVING
|
||||||
|
return ControlDecision(
|
||||||
|
record_sample=record_sample,
|
||||||
|
finish=True,
|
||||||
|
)
|
||||||
|
if self.selected_count >= self.max_samples:
|
||||||
|
self.state = RecordingState.REJECTED
|
||||||
|
return ControlDecision(
|
||||||
|
record_sample=record_sample,
|
||||||
|
reject_reason="max_duration",
|
||||||
|
)
|
||||||
|
return ControlDecision(record_sample=record_sample)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ButtonEvents:
|
||||||
|
right_b: bool = False
|
||||||
|
right_a: bool = False
|
||||||
|
discard: bool = False
|
||||||
|
reject_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonTracker:
|
||||||
|
def __init__(self, *, hold_ns: int) -> None:
|
||||||
|
if hold_ns <= 0:
|
||||||
|
raise ValueError("hold_ns must be positive")
|
||||||
|
self.hold_ns = hold_ns
|
||||||
|
self._right_a: bool | None = None
|
||||||
|
self._right_b: bool | None = None
|
||||||
|
self._left_y: bool | None = None
|
||||||
|
self._left_y_started_ns: int | None = None
|
||||||
|
self._left_y_eligible = False
|
||||||
|
self._left_y_fired = False
|
||||||
|
|
||||||
|
def on_right(
|
||||||
|
self,
|
||||||
|
primary: bool,
|
||||||
|
secondary: bool,
|
||||||
|
grip: bool,
|
||||||
|
now_ns: int,
|
||||||
|
state: RecordingState,
|
||||||
|
) -> ButtonEvents:
|
||||||
|
del now_ns
|
||||||
|
if self._right_a is None or self._right_b is None:
|
||||||
|
self._right_a = primary
|
||||||
|
self._right_b = secondary
|
||||||
|
return ButtonEvents()
|
||||||
|
|
||||||
|
a_rising = primary and not self._right_a
|
||||||
|
b_rising = secondary and not self._right_b
|
||||||
|
self._right_a = primary
|
||||||
|
self._right_b = secondary
|
||||||
|
reject_reason = None
|
||||||
|
if a_rising and state is RecordingState.RECORDING:
|
||||||
|
reject_reason = "initial_pose_command_during_episode"
|
||||||
|
return ButtonEvents(
|
||||||
|
right_b=b_rising and not grip,
|
||||||
|
right_a=a_rising,
|
||||||
|
reject_reason=reject_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_left(
|
||||||
|
self,
|
||||||
|
secondary: bool,
|
||||||
|
now_ns: int,
|
||||||
|
state: RecordingState,
|
||||||
|
) -> ButtonEvents:
|
||||||
|
if self._left_y is None:
|
||||||
|
self._left_y = secondary
|
||||||
|
if secondary:
|
||||||
|
self._start_left_y(now_ns, state)
|
||||||
|
return ButtonEvents()
|
||||||
|
|
||||||
|
if secondary and not self._left_y:
|
||||||
|
self._start_left_y(now_ns, state)
|
||||||
|
elif not secondary and self._left_y:
|
||||||
|
self._left_y_started_ns = None
|
||||||
|
self._left_y_eligible = False
|
||||||
|
self._left_y_fired = False
|
||||||
|
self._left_y = secondary
|
||||||
|
|
||||||
|
if (
|
||||||
|
secondary
|
||||||
|
and self._left_y_eligible
|
||||||
|
and not self._left_y_fired
|
||||||
|
and self._left_y_started_ns is not None
|
||||||
|
and now_ns - self._left_y_started_ns >= self.hold_ns
|
||||||
|
):
|
||||||
|
self._left_y_fired = True
|
||||||
|
return ButtonEvents(discard=True)
|
||||||
|
return ButtonEvents()
|
||||||
|
|
||||||
|
def _start_left_y(
|
||||||
|
self,
|
||||||
|
now_ns: int,
|
||||||
|
state: RecordingState,
|
||||||
|
) -> None:
|
||||||
|
self._left_y_started_ns = now_ns
|
||||||
|
self._left_y_eligible = state in (
|
||||||
|
RecordingState.ARMED,
|
||||||
|
RecordingState.RECORDING,
|
||||||
|
)
|
||||||
|
self._left_y_fired = False
|
||||||
Reference in New Issue
Block a user