Files
orisys-app/shared/tests/test_playback.py
T
ykcheungab da8165cf03 删除深度重算引擎;修好回放视频每帧重开文件导致的无法播放,运输控制改到状态轨主按钮 - What: 1) 彻底删除深度重算引擎:shared/.../record/depth.py(DepthRecomputer/DepthResult)与 depth_backends.py(BoxReplay/FingerReplay)、其专属测试 test_depth_replay.py,record/__init__ 不再导出,playback.py::replay_warnings 不再报 depth_recomputed;apps/studio/replay.py 由 515 行瘦身为只做读盘+游标+视频帧+提示,回放视图的 2D 面板只提供「原始图像」;session.json 的 depth.replay 由 recompute 改为 none;删除 31 个只剩深度使用的文案键(depth_*/warn_state_*/warn_reset_*),warn_config_changed 与 warn_sdk_changed 改为描述"由视频重算";ui_check 深度段、studio 两个深度用例、test_playback 三例、drive_shared 的 test_depth_replay 与 reproduce.py 的深度段一并清理(shared 套件 150→134 项)。2) 无法播放的根因是 RecordingReader.video_frame_at 每帧都 cv2.VideoCapture 重开并 seek:小片段已 ~10 ms/帧,1080p H.264 每次 seek 需从关键帧解码(上百 ms),而播放每 33 ms 要一帧且全在 UI 线程上,视频因此看着不动;改为按分段缓存解码器、顺序帧用 read() 步进(实测 10 ms → 0.5 ms/帧)、跳转才 seek,新增 RecordingReader.close() 释放。运输控制按用户建议上移到状态轨主按钮:toolbar.set_run_role("playback") 使回放视图的主按钮变成「▶ 回放」/「⏸ 暂停」(实时视图仍是「▶ 运行」),点按以 1× 推进游标(墙钟计时,漏拍不变慢)、到末尾自动停,拖动游标/框选/滚轮/拖总览条经 ForcePlotsPanel.timeline_taken_over 自动暂停,空格键同效;曲线表头不再放第二个播放按钮;按钮样式在回放角色下不再套用"停止"的红色语义。 - Why: 用户要求先清掉深度重算引擎、并反馈"现在无法播放视频",建议把上方播放按钮在历史回放形式下改为回放。 - Verification: ui_check.py 554 项 0 失败(新增 seek<250ms/步进<60ms 的解码预算、回放按钮文字随播放/暂停切换、手动拖游标即暂停、切回实时视图按钮恢复运行、按深度视图已移除);test_ui_smoke.py 71 项 0 失败;drive_shared.py 134 项 0 失败(深度引擎用例随代码删除);locale_studio.py 262 键齐全、zh==en;reproduce.py 退出 0;.repro/probe_play.py 实测按「回放」后 0.75 s 内游标推进 0.689 s、面板 12/12 帧都在更新;两张复核图(回放视图的「▶ 回放」、实时视图的「▶ 运行」)。 - Not in this change: 录制文件格式与 CSV 列序不变(raw_*.mp4 与 forces.csv 继续一起写);「由视频重算」复核模式保留(它复用实时管线,是有状态、只能顺序前进的一遍)。
2026-09-14 09:31:21 +08:00

293 lines
10 KiB
Python

"""Replay of a recording: the reader, and the depth replay policy.
Depth is not stored with a recording (team decision): replay recomputes it from
``raw_*.mp4`` and the recorded config. These tests cover the reader contract and
that the policy's risks are reported instead of discovered later.
"""
from __future__ import annotations
import csv
import json
import shutil
import sys
import types
from pathlib import Path
import numpy as np
import pytest
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
from orisys_app_shared.record import (
ExperimentRecorder,
RecordConfig,
RecordingReader,
list_recordings,
)
SCRATCH = Path(__file__).resolve().parent / "_scratch_playback"
def _packet(index: int, *, valid: bool = True) -> SdkPacket:
nan = float("nan")
frame = np.full((24, 32, 3), (index * 5) % 255, np.uint8)
return SdkPacket(
raw_frame=frame,
processed_frame=frame,
timestamp=1000.0 + index / 30.0,
fps=30.0,
process_ms=1.0,
normal_force=2.0 if valid else nan,
shear_x=0.5 if valid else nan,
shear_y=-0.5 if valid else nan,
normal_force_raw=(9.0 + index * 0.1) if valid else nan,
shear_x_raw=0.4 if valid else nan,
shear_y_raw=-0.4 if valid else nan,
force_unit="N",
force_calibrated=True,
session_id=1,
frame_id=index,
valid=valid,
invalid_reason="" if valid else "read failed",
raw_available=True,
)
@pytest.fixture
def recording():
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=0.4))
directory = recorder.start(
root=SCRATCH,
session_id=3,
calibration_id="/cfg/sensor.json",
metadata={
"device": {
"sensor_type": "box",
"config_ref": str(SCRATCH / "sensor.json"),
"config_sha256": "recorded-hash",
},
"versions": {"sdk": "0.5.1"},
"depth": {"stored": False, "replay": "recompute"},
},
)
(SCRATCH / "sensor.json").write_text('{"camera": {}}\n', encoding="utf-8")
for index in range(60):
recorder.on_packet(_packet(index))
recorder.on_packet(_packet(90, valid=False))
recorder.stop()
yield directory
shutil.rmtree(SCRATCH, ignore_errors=True)
def test_reader_reports_the_recording_shape(recording):
reader = RecordingReader(recording)
info = reader.info()
assert len(info.frames) == 61
assert info.times.size == 61
assert info.duration_s == pytest.approx(3.0, abs=0.05)
assert int(info.valid.sum()) == 60, "the failed readout should be marked invalid"
def test_reader_keeps_unavailable_values_empty(recording):
reader = RecordingReader(recording)
info = reader.info()
assert np.isnan(info.raw[-1]).all(), "an empty CSV cell must not read back as 0"
assert np.isfinite(info.raw[0]).all()
def test_reader_maps_time_to_the_video_segment(recording):
reader = RecordingReader(recording)
frame = reader.frame_at(1.0)
assert frame is not None
assert frame.frame_id == 30
assert frame.t_rel_s == pytest.approx(1.0, abs=0.02)
assert frame.in_video
assert frame.segment >= 1
assert reader.segment_path(frame.segment) is not None
# Before the start and past the end clamp instead of raising.
assert reader.frame_at(-5.0).frame_id == 0
assert reader.frame_at(500.0).frame_id == 90
def test_reader_decodes_the_video_frame(recording):
import cv2
if not hasattr(cv2, "VideoCapture"): # pragma: no cover
pytest.skip("no video backend")
reader = RecordingReader(recording)
image, frame = reader.video_frame_at(1.0)
assert frame is not None and frame.in_video
assert image is not None, "the frame index and the video disagreed"
assert image.shape[:2] == (24, 32)
def test_reader_window_and_streaming_agree(recording):
reader = RecordingReader(recording)
times, values = reader.forces_window(0.5, 1.0)
assert len(times) == 16
assert values.shape == (16, 3)
streamed = sum(len(batch_times) for batch_times, _ in reader.iter_forces(batch=25))
assert streamed == 61, "streaming must see every row"
def test_an_empty_recording_directory_is_readable():
"""A folder with no CSVs must open (empty), not raise an unpack error."""
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
try:
reader = RecordingReader(SCRATCH)
info = reader.info()
assert info.frames == []
assert info.times.size == 0
assert info.duration_s == 0.0
assert reader.frame_at(1.0) is None
assert reader.replay_warnings(sdk_version="0.5.1")
finally:
shutil.rmtree(SCRATCH, ignore_errors=True)
def test_a_new_recording_is_reported_as_time_ordered(recording):
info = RecordingReader(recording).info()
assert info.frames_out_of_order, "the writer no longer produces ordered rows"
assert info.forces_out_of_order
codes = RecordingReader(recording).replay_warnings(sdk_version="0.5.1")
assert "out_of_order" not in codes
def test_an_old_out_of_order_recording_is_re_sorted_and_reported():
"""Rows written by the pre-fix writer are not in time order.
``frame_at`` binary-searches these times, so an unsorted file would answer
with the wrong frame; the reader sorts and says so instead.
"""
shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
(SCRATCH / "forces.csv").write_text(
"session_id,frame_id,t_rel_s,FN_raw,FX_raw,FY_raw,FN_N,FX_N,FY_N,valid,calibration_id\n"
"1,0,0.0,1.0,0.1,0.1,2.0,0.1,0.1,1,h\n"
"1,2,0.066,3.0,0.1,0.1,6.0,0.1,0.1,1,h\n" # written before frame 1
"1,1,0.033,2.0,0.1,0.1,4.0,0.1,0.1,1,h\n",
encoding="utf-8",
)
(SCRATCH / "frames.csv").write_text(
"frame_id,t_rel_s,acquired_at,segment,segment_frame,in_video,dropped\n"
"0,0.0,2026-01-01T00:00:00.000Z,1,1,1,0\n"
"2,0.066,2026-01-01T00:00:00.066Z,1,3,1,0\n"
"1,0.033,2026-01-01T00:00:00.033Z,1,2,1,0\n",
encoding="utf-8",
)
(SCRATCH / "session.json").write_text(
json.dumps(
{
"format_version": "1.1",
"device": {"sensor_type": "box", "config_ref": "/cfg/sensor.json"},
"versions": {"sdk": "0.5.1"},
"depth": {"stored": False},
}
),
encoding="utf-8",
)
try:
reader = RecordingReader(SCRATCH)
info = reader.info()
assert not info.frames_out_of_order
assert not info.forces_out_of_order
assert [frame.frame_id for frame in info.frames] == [0, 1, 2]
assert info.times.tolist() == pytest.approx([0.0, 0.033, 0.066])
# The binary search is only valid because the rows were sorted.
assert reader.frame_at(0.05).frame_id == 1
assert reader.frame_at(0.07).frame_id == 2
assert "out_of_order" in reader.replay_warnings(sdk_version="0.5.1")
finally:
shutil.rmtree(SCRATCH, ignore_errors=True)
def test_depth_policy_says_recompute_and_warns_about_drift(recording):
reader = RecordingReader(recording)
assert reader.depth_is_stored() is False
assert reader.depth_policy().get("replay") == "recompute"
assert reader.recorded_config_ref() == str(SCRATCH / "sensor.json")
# The config file on disk no longer matches what was recorded.
codes = reader.replay_warnings(sdk_version="0.5.1")
assert "config_changed" in codes
# Same SDK: no version warning.
assert "sdk_changed" not in codes
assert "sdk_changed" in reader.replay_warnings(sdk_version="9.9.9")
def test_list_recordings_finds_directories_newest_first(recording):
second = SCRATCH / "zzz_later"
second.mkdir()
(second / "forces.csv").write_text("", encoding="utf-8")
found = list_recordings(SCRATCH)
assert found[0].name == "zzz_later", "recordings should be newest first"
assert recording in found
if __name__ == "__main__": # pragma: no cover - manual runner
import shutil as _shutil
failures = 0
for name, function in sorted(globals().items()):
if not name.startswith("test_") or not callable(function):
continue
try:
if name == "test_list_recordings_finds_directories_newest_first":
_shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
recorder = ExperimentRecorder(RecordConfig(video=False))
directory = recorder.start(
root=SCRATCH, session_id=1, metadata={"depth": {"stored": False}}
)
recorder.on_packet(_packet(0))
recorder.stop()
try:
function(directory)
finally:
_shutil.rmtree(SCRATCH, ignore_errors=True)
print(f" ok {name}")
continue
_shutil.rmtree(SCRATCH, ignore_errors=True)
SCRATCH.mkdir(parents=True, exist_ok=True)
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=0.4))
directory = recorder.start(
root=SCRATCH,
session_id=3,
calibration_id="/cfg/sensor.json",
metadata={
"device": {
"sensor_type": "box",
"config_ref": str(SCRATCH / "sensor.json"),
"config_sha256": "recorded-hash",
},
"versions": {"sdk": "0.5.1"},
"depth": {"stored": False, "replay": "recompute"},
},
)
(SCRATCH / "sensor.json").write_text('{"camera": {}}\n', encoding="utf-8")
for index in range(60):
recorder.on_packet(_packet(index))
recorder.on_packet(_packet(90, valid=False))
recorder.stop()
try:
function(directory)
finally:
_shutil.rmtree(SCRATCH, ignore_errors=True)
print(f" ok {name}")
except Exception as exc: # noqa: BLE001
failures += 1
print(f" FAIL {name}: {type(exc).__name__}: {exc}")
print(f"failures: {failures}")
raise SystemExit(1 if failures else 0)