cd D:\orisys_ws\orisys-app
git add apps/studio/config/locales/zh_CN.json `
apps/studio/config/locales/en_US.json `
apps/studio/tests/test_locales.py `
apps/studio/tools/ui_check.py `
apps/studio/tests/test_ui_smoke.py `
apps/studio/src/orisys_studio/app_window.py `
apps/studio/src/orisys_studio/ui/cal_panel.py `
apps/studio/src/orisys_studio/ui/playback_dialog.py `
apps/studio/src/orisys_studio/ui/force_plots.py `
apps/studio/docs/record-channel.md `
shared/src/orisys_app_shared/force/calibration_recorder.py `
shared/src/orisys_app_shared/record/__init__.py `
shared/src/orisys_app_shared/record/playback.py `
shared/src/orisys_app_shared/record/depth.py `
shared/tests/test_record_channel.py `
shared/tests/test_playback.py
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
"""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,
|
||||
)
|
||||
from orisys_app_shared.record.depth import DepthRecomputer
|
||||
|
||||
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_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
|
||||
assert "depth_recomputed" 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_depth_recompute_preflight_states_why_it_cannot_run(recording):
|
||||
reader = RecordingReader(recording)
|
||||
recomputer = DepthRecomputer(reader)
|
||||
try:
|
||||
problem = recomputer.preflight()
|
||||
assert problem in ("", "sdk_unavailable"), problem
|
||||
|
||||
# A recording with no config reference is refused, not guessed at.
|
||||
missing = RecordingReader(recording)
|
||||
missing._info = None # noqa: SLF001 - force a re-read below
|
||||
empty = SCRATCH / "empty"
|
||||
empty.mkdir(exist_ok=True)
|
||||
(empty / "forces.csv").write_text(
|
||||
",".join(("session_id", "frame_id", "t_rel_s", "FN_raw", "FX_raw", "FY_raw",
|
||||
"FN_N", "FX_N", "FY_N", "valid", "calibration_id")) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(empty / "session.json").write_text(json.dumps({"depth": {"stored": False}}), encoding="utf-8")
|
||||
bare = DepthRecomputer(RecordingReader(empty))
|
||||
assert bare.preflight() in ("config_missing", "no_video", "sdk_unavailable")
|
||||
finally:
|
||||
recomputer.close()
|
||||
|
||||
|
||||
def test_depth_result_is_failed_not_blank_when_sdk_is_missing(recording):
|
||||
"""A missing SDK must produce an explicit error, never a stand-in image."""
|
||||
saved = sys.modules.get("orisys")
|
||||
blocker = types.ModuleType("orisys")
|
||||
try:
|
||||
# A module object with no Sensor/DEPTH surface: importing succeeds but
|
||||
# nothing can be recomputed with it.
|
||||
sys.modules["orisys"] = blocker
|
||||
reader = RecordingReader(recording)
|
||||
recomputer = DepthRecomputer(reader)
|
||||
problem = recomputer.preflight()
|
||||
assert problem in ("sdk_unavailable", ""), problem
|
||||
if problem:
|
||||
result = recomputer.depth_at(1.0)
|
||||
assert not result.ok
|
||||
assert result.depth is None
|
||||
assert result.error
|
||||
finally:
|
||||
if saved is None:
|
||||
sys.modules.pop("orisys", None)
|
||||
else:
|
||||
sys.modules["orisys"] = saved
|
||||
|
||||
|
||||
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)
|
||||
@@ -292,6 +292,58 @@ def test_recorder_cancel_discards_the_point():
|
||||
assert recorder.points == []
|
||||
|
||||
|
||||
def test_recorder_diagnostics_use_point_identity_not_table_rows():
|
||||
"""A per-channel table row is not an index into the cross-channel list.
|
||||
|
||||
The panel shows the points of one channel while the recorder stores every
|
||||
channel in a single list, so an integer index resolves to the wrong point as
|
||||
soon as a second channel has been recorded.
|
||||
"""
|
||||
recorder = _recorder()
|
||||
for channel, load, raw in (("fn", 1.0, 100.0), ("fn", 2.0, 200.0), ("fx", 1.0, 7.0)):
|
||||
recorder.start(channel=channel, reference_load_n=load, duration_s=1.0)
|
||||
for index in range(10):
|
||||
sample = _sample(index)
|
||||
sample = SessionSample(
|
||||
frame_id=index,
|
||||
t_rel_s=index / 100.0,
|
||||
fn_raw=raw if channel == "fn" else 0.4,
|
||||
fx_raw=raw if channel == "fx" else 0.4,
|
||||
fy_raw=-0.2,
|
||||
valid=True,
|
||||
)
|
||||
recorder.feed(sample)
|
||||
recorder.finish_now()
|
||||
|
||||
fx_point = recorder.points_for("fx")[0]
|
||||
assert recorder.set_excluded(fx_point, True) is True
|
||||
assert [point.excluded for point in recorder.all_points()] == [False, False, True], (
|
||||
"excluding the FX point must not touch the FN points"
|
||||
)
|
||||
|
||||
# A point that was never recorded is rejected rather than silently ignored.
|
||||
from orisys_app_shared.force.calibration_recorder import CalibrationPoint
|
||||
|
||||
stranger = CalibrationPoint(channel="fn", reference_load_n=9.0)
|
||||
assert recorder.set_excluded(stranger, True) is False
|
||||
assert recorder.set_validation(stranger, True) is False
|
||||
assert recorder.remove_point(stranger) is False
|
||||
|
||||
|
||||
def test_clear_points_can_target_one_channel():
|
||||
recorder = _recorder()
|
||||
for channel in ("fn", "fx", "fn"):
|
||||
recorder.start(channel=channel, reference_load_n=1.0, duration_s=1.0)
|
||||
recorder.feed(_sample(0))
|
||||
recorder.feed(_sample(1))
|
||||
recorder.finish_now()
|
||||
|
||||
assert recorder.clear_points("fn") == 2
|
||||
assert [point.channel for point in recorder.all_points()] == ["fx"]
|
||||
assert recorder.clear_points() == 1
|
||||
assert recorder.all_points() == []
|
||||
|
||||
|
||||
def test_recorder_round_index_advances_per_channel():
|
||||
recorder = _recorder()
|
||||
for _ in range(2):
|
||||
|
||||
Reference in New Issue
Block a user