feat: 校验ACT episode数据质量
This commit is contained in:
@@ -14,6 +14,7 @@ from xr_rm_teleop.act_episode_recorder import (
|
||||
EpisodeMetadata,
|
||||
EpisodeStore,
|
||||
QualityError,
|
||||
QualityLimits,
|
||||
RecordingSession,
|
||||
RecordingState,
|
||||
TaskDirectoryLock,
|
||||
@@ -23,6 +24,7 @@ from xr_rm_teleop.act_episode_recorder import (
|
||||
recover_partial_files,
|
||||
select_camera_pair,
|
||||
select_frame,
|
||||
validate_episode,
|
||||
)
|
||||
|
||||
|
||||
@@ -477,3 +479,122 @@ def test_task_directory_lock_rejects_second_recorder(tmp_path):
|
||||
second.acquire()
|
||||
finally:
|
||||
first.release()
|
||||
|
||||
|
||||
def _quality_limits():
|
||||
return QualityLimits(min_samples=3, max_samples=100)
|
||||
|
||||
|
||||
def _valid_episode(tmp_path):
|
||||
path = tmp_path / "episode_0.partial.hdf5"
|
||||
store = EpisodeStore.create(path, _metadata())
|
||||
for index, seq in enumerate((100, 103, 106)):
|
||||
sample = _episode_sample(seq)
|
||||
sample["debug/cameras/cam_high_frame_number"] = 200 + index
|
||||
sample["debug/cameras/cam_wrist_frame_number"] = 300 + index
|
||||
store.append(sample)
|
||||
store.close()
|
||||
with h5py.File(path, "r+") as root:
|
||||
root.attrs["camera_high_fps"] = 30.0
|
||||
root.attrs["camera_right_wrist_fps"] = 30.0
|
||||
root.attrs["camera_high_drop_ratio"] = 0.0
|
||||
root.attrs["camera_right_wrist_drop_ratio"] = 0.0
|
||||
return path
|
||||
|
||||
|
||||
@requires_h5py
|
||||
def test_validate_episode_accepts_valid_file(tmp_path):
|
||||
report = validate_episode(_valid_episode(tmp_path), _quality_limits())
|
||||
|
||||
assert report.accepted
|
||||
assert report.reason is None
|
||||
assert report.metrics["control_hz"] == pytest.approx(30.0, rel=1e-5)
|
||||
|
||||
|
||||
def _mutate_episode(path, mutation):
|
||||
with h5py.File(path, "r+") as root:
|
||||
if mutation == "short_episode":
|
||||
root.visititems(
|
||||
lambda _name, item: item.resize(2, axis=0)
|
||||
if isinstance(item, h5py.Dataset)
|
||||
else None
|
||||
)
|
||||
elif mutation == "control_seq_gap":
|
||||
root["debug/control/control_seq"][1] = 104
|
||||
elif mutation == "nonfinite_qpos":
|
||||
root["observations/qpos"][1, 0] = np.nan
|
||||
elif mutation == "joint_limit":
|
||||
root["action"][1, 0] = 4.0
|
||||
elif mutation == "invalid_gripper":
|
||||
root["action"][1, 7] = 0.5
|
||||
elif mutation == "feedback_age":
|
||||
control_ns = root["debug/timestamps/control_monotonic_ns"][1]
|
||||
root["debug/timestamps/feedback_monotonic_ns"][1] = (
|
||||
control_ns - 60_000_000
|
||||
)
|
||||
elif mutation == "action_invalid":
|
||||
root["debug/control/action_valid"][1] = 0
|
||||
elif mutation == "control_fault":
|
||||
root["debug/control/control_fault"][1] = 1
|
||||
elif mutation == "camera_fps":
|
||||
root.attrs["camera_high_fps"] = 20.0
|
||||
elif mutation == "camera_drop":
|
||||
root.attrs["camera_right_wrist_drop_ratio"] = 0.02
|
||||
elif mutation == "camera_age":
|
||||
root["debug/timestamps/cam_high_age_ms"][1] = 60.0
|
||||
elif mutation == "camera_skew":
|
||||
root["debug/timestamps/inter_camera_skew_ms"][1] = 60.0
|
||||
elif mutation == "final_gripper_closed":
|
||||
root["observations/qpos"][-1, 7] = 0.0
|
||||
else:
|
||||
raise AssertionError(f"unknown mutation: {mutation}")
|
||||
|
||||
|
||||
@requires_h5py
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "reason"),
|
||||
[
|
||||
("short_episode", "too_few_samples"),
|
||||
("control_seq_gap", "control_sequence_gap"),
|
||||
("nonfinite_qpos", "nonfinite_qpos"),
|
||||
("joint_limit", "joint_limit_violation"),
|
||||
("invalid_gripper", "invalid_gripper_state"),
|
||||
("feedback_age", "feedback_too_old"),
|
||||
("action_invalid", "invalid_action"),
|
||||
("control_fault", "control_fault"),
|
||||
("camera_fps", "camera_fps"),
|
||||
("camera_drop", "camera_drop_ratio"),
|
||||
("camera_age", "camera_frame_too_old"),
|
||||
("camera_skew", "camera_skew"),
|
||||
("final_gripper_closed", "final_gripper_not_open"),
|
||||
],
|
||||
)
|
||||
def test_validate_episode_reports_stable_reason(
|
||||
tmp_path,
|
||||
mutation,
|
||||
reason,
|
||||
):
|
||||
path = _valid_episode(tmp_path)
|
||||
_mutate_episode(path, mutation)
|
||||
|
||||
report = validate_episode(path, _quality_limits())
|
||||
|
||||
assert not report.accepted
|
||||
assert report.reason == reason
|
||||
|
||||
|
||||
@requires_h5py
|
||||
def test_validate_episode_reports_qp_failures_without_rejecting(tmp_path):
|
||||
path = _valid_episode(tmp_path)
|
||||
with h5py.File(path, "r+") as root:
|
||||
root["debug/qp/attempted"][:] = (1, 1, 1)
|
||||
root["debug/qp/success"][:] = (0, 0, 1)
|
||||
root["debug/control/target_clamped"][:] = (1, 0, 1)
|
||||
|
||||
report = validate_episode(path, _quality_limits())
|
||||
|
||||
assert report.accepted
|
||||
assert report.metrics["qp_failure_count"] == 2
|
||||
assert report.metrics["qp_failure_ratio"] == pytest.approx(2.0 / 3.0)
|
||||
assert report.metrics["qp_longest_failure_streak"] == 2
|
||||
assert report.metrics["target_clamped_count"] == 2
|
||||
|
||||
Reference in New Issue
Block a user