22cc59c402
What - record: one packet->row conversion (record/sample.py). A failed compute chain records no force value at all, a newton value is written only for an axis that really has a calibration curve, and mono_t (not the wall clock) orders history with a per-row calibration hash. - record lifecycle: explicit starting/recording/stopping/stopped/failed states owned by the writer thread; stop() reports completed=False on timeout and does not write ended_at; consecutive write failures stop acceptance immediately. - record capacity: byte budget for the video queue (512 MiB default); a full queue sheds the *frame* and parks the tiny data row, so a video overflow no longer deletes CSV rows. Lost rows leave a dropped=1 index row and are counted as dropped_gaps / unrecorded_gaps. - calibration identity: sha256 content hash instead of a path, minted from the calibration the sensor actually loaded (reported before the first frame) and confirmed by the acquisition thread; events are timestamped when they happen. - calibration diagnostics: the fit summary, residual and validation error now come from the model that export/apply would use (piecewise_linear); validation points are held out of the build; repeated reference loads are merged with an n-weighted mean and pooled std. - calibration record: every point keeps its full three-axis sample series (frame id, time, raw, newtons, valid) plus diagnostics, written to an independent calibration_session_*.json that can be reopened for inspection; save record / export coefficients / apply to device are three separate actions. - depth replay: the cursor's frame is bound to the depth result by request sequence and frame id, so a moved cursor never shows another frame's map; the SDK is stepped from its current position instead of re-reading from the start; the live chain (box deformation+contact, finger unroll+tactile) is replayed with the exact resolved config path; algorithm state is rebuilt from a reset marker or from the recorded pre-roll, and says so when it cannot be. Why The review (D:/orisys_ws/_review_phase12/review.md) showed the app writing raw counts into the newton columns, recording a stale value after a failed compute, losing CSV rows when the video queue filled, reporting success while a writer was still running, diagnosing a model nobody applies, applying a calibration before the device confirmed it, dropping the samples behind a calibration point, showing a stale depth map under a moved cursor and recomputing depth of the wrong frame. Verification - D:/orisys_ws/_review_phase12/reproduce.py: all ten acceptance lines flipped (log saved as _review_phase12/reproduce_after.log). Including 25/25 rows kept with 8 video frames shed, frames [2]->[3], depth 8.0/8.0, fit 2/2 points. - apps/studio/tools/ui_check.py: 249 checks, 0 failures (twice). - shared suites: 91 tests, 0 failures (new: test_record_semantics, test_record_lifecycle, test_calibration_model, test_calibration_record, test_depth_replay). - apps/studio/tests/test_ui_smoke.py: 23/30; the 7 remaining are harness artifacts of the sandbox (4 tmp_path PermissionError, 3 parametrized tests the shim cannot drive), unchanged from before this change. - compileall clean; locale parity zh/en + t() key coverage: 0 failures. Not in this change - P2#10 disk-based browsing of a long recording and range CSV export (next round, per your choice). - Stability-triggered calibration sampling, replay fixed colour scale, PyOpenGL three-dimensional view. - Hardware verification: no camera attached, so the SDK's real processing chain and the pre-roll's effect on a live tracker were exercised with fakes only.
249 lines
8.0 KiB
Python
249 lines
8.0 KiB
Python
"""Offline depth replay: where to start, how far to step, which chain to run.
|
|
|
|
P1-3 and P1-4: the pane kept showing the previous frame's depth after the cursor
|
|
moved, the recomputer re-read ``target`` frames on every call (so requesting
|
|
frames 2 and 3 landed on 5), it skipped the live processing chain, and it
|
|
reduced the recorded config path to a name that could load a different file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from orisys_app_shared.record.depth import DepthRecomputer
|
|
|
|
SCRATCH = Path(__file__).resolve().parent / "_scratch"
|
|
|
|
|
|
def _root() -> Path:
|
|
shutil.rmtree(SCRATCH, ignore_errors=True)
|
|
SCRATCH.mkdir(parents=True, exist_ok=True)
|
|
return SCRATCH
|
|
|
|
|
|
class _CountingSensor:
|
|
"""A sensor whose reported depth is the number of frames it has read."""
|
|
|
|
opened: list[dict] = []
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
_CountingSensor.opened.append({"args": args, "kwargs": kwargs})
|
|
self.n = 0
|
|
self.chain_calls = 0
|
|
self.info = SimpleNamespace(DEPTH=1)
|
|
|
|
def get_img(self):
|
|
self.n += 1
|
|
return np.ones((2, 2))
|
|
|
|
def compute_deformation(self, **_kwargs):
|
|
self.chain_calls += 1
|
|
|
|
def compute_contact(self):
|
|
self.chain_calls += 1
|
|
|
|
def read_info(self, _channel):
|
|
return np.array([self.n])
|
|
|
|
def disconnect(self):
|
|
return None
|
|
|
|
|
|
def _reader(session: dict, *, segments=("raw_0001.mp4",), frames=()):
|
|
directory = _root()
|
|
for name in segments:
|
|
(directory / name).write_bytes(b"")
|
|
return SimpleNamespace(
|
|
session=lambda: session,
|
|
segment_path=lambda segment: directory / f"raw_{segment:04d}.mp4",
|
|
segments=lambda: [directory / name for name in segments],
|
|
info=lambda: SimpleNamespace(frames=list(frames)),
|
|
frame_of=lambda frame_id: next(
|
|
(frame for frame in frames if frame.frame_id == frame_id), None
|
|
),
|
|
frame_at=lambda t: None,
|
|
last_reset_frame=lambda frame_id: None,
|
|
segment_start_frame=lambda segment: next(
|
|
(frame for frame in frames if frame.segment == segment), None
|
|
),
|
|
preroll_frames=lambda: 0,
|
|
preroll_segment=lambda: None,
|
|
directory=directory,
|
|
)
|
|
|
|
|
|
def _session(config_ref: str) -> dict:
|
|
return {"device": {"config_ref": config_ref, "sensor_type": "box"}}
|
|
|
|
|
|
def _config_file() -> str:
|
|
"""A config that exists, so ``preflight`` is not what a test measures."""
|
|
path = _root() / "sensor.json"
|
|
path.write_text("{}", encoding="utf-8")
|
|
return str(path)
|
|
|
|
|
|
def _reader_with_config(frames):
|
|
"""A reader whose recorded config really exists on disk.
|
|
|
|
``_reader`` recreates the scratch directory, so the config file is written
|
|
*after* the reader is built.
|
|
"""
|
|
directory = _root()
|
|
config = directory / "sensor.json"
|
|
reader = _reader(_session(str(config)), frames=frames)
|
|
config.write_text("{}", encoding="utf-8")
|
|
return reader
|
|
|
|
|
|
def test_stepping_continues_from_the_current_position():
|
|
"""The old code re-read ``target`` frames every call: 2 then 3 landed on 5."""
|
|
_CountingSensor.opened.clear()
|
|
computer = DepthRecomputer(_reader(_session("/cfg/sensor.json")))
|
|
with patch("orisys.Sensor", _CountingSensor):
|
|
first = computer._depth_from_segment(Path("raw_0001.mp4"), 2)
|
|
second = computer._depth_from_segment(Path("raw_0001.mp4"), 3)
|
|
assert first.tolist() == [2]
|
|
assert second.tolist() == [3]
|
|
# One sensor for the whole replay: reopening would reset the algorithm state.
|
|
assert len(_CountingSensor.opened) == 1
|
|
computer.close()
|
|
|
|
|
|
def test_stepping_backwards_reopens_the_segment():
|
|
_CountingSensor.opened.clear()
|
|
computer = DepthRecomputer(_reader(_session("/cfg/sensor.json")))
|
|
with patch("orisys.Sensor", _CountingSensor):
|
|
assert computer._depth_from_segment(Path("raw_0001.mp4"), 5).tolist() == [5]
|
|
assert computer._depth_from_segment(Path("raw_0001.mp4"), 2).tolist() == [2]
|
|
assert len(_CountingSensor.opened) == 2, "seeking backwards needs a fresh source"
|
|
computer.close()
|
|
|
|
|
|
def test_the_live_processing_chain_runs_for_every_replayed_frame():
|
|
"""Without it the replay is a different algorithm than the recording."""
|
|
_CountingSensor.opened.clear()
|
|
computer = DepthRecomputer(_reader(_session("/cfg/sensor.json")))
|
|
with patch("orisys.Sensor", _CountingSensor):
|
|
computer._depth_from_segment(Path("raw_0001.mp4"), 3)
|
|
sensor = computer._sensor._sensor
|
|
assert sensor.chain_calls == 6, "one deformation + one contact per frame"
|
|
computer.close()
|
|
|
|
|
|
def test_the_exact_config_path_is_opened(tmp_path=None):
|
|
"""A reduced name could load a different file with the same name."""
|
|
_CountingSensor.opened.clear()
|
|
config = Path("D:/custom/box/sensor.json")
|
|
computer = DepthRecomputer(_reader(_session(str(config))))
|
|
with patch("orisys.Sensor", _CountingSensor):
|
|
computer._depth_from_segment(Path("raw_0001.mp4"), 1)
|
|
assert _CountingSensor.opened[0]["kwargs"]["config_name"] == str(config)
|
|
computer.close()
|
|
|
|
|
|
def test_the_plan_starts_at_the_last_reset_in_the_same_segment():
|
|
frames = [
|
|
SimpleNamespace(
|
|
frame_id=index,
|
|
t_rel_s=index / 30.0,
|
|
segment=1,
|
|
segment_frame=index + 1,
|
|
in_video=True,
|
|
dropped=False,
|
|
)
|
|
for index in range(10)
|
|
]
|
|
reader = _reader(_session("/cfg/sensor.json"), frames=frames)
|
|
reader.last_reset_frame = lambda frame_id: 6
|
|
computer = DepthRecomputer(reader)
|
|
plan = computer.plan_for(frames[8])
|
|
assert plan.stop_at == 9
|
|
assert plan.preroll is None
|
|
assert plan.warnings == ()
|
|
computer.close()
|
|
|
|
|
|
def test_the_plan_uses_the_pre_roll_for_the_start_of_a_recording():
|
|
frames = [
|
|
SimpleNamespace(
|
|
frame_id=index,
|
|
t_rel_s=index / 30.0,
|
|
segment=1,
|
|
segment_frame=index + 1,
|
|
in_video=True,
|
|
dropped=False,
|
|
)
|
|
for index in range(5)
|
|
]
|
|
|
|
def reader_factory():
|
|
reader = _reader(_session("/cfg/sensor.json"), frames=frames)
|
|
reader.preroll_frames = lambda: 30
|
|
reader.preroll_segment = lambda: reader.directory / "preroll_0001.mp4"
|
|
return reader
|
|
|
|
reader = reader_factory()
|
|
(reader.directory / "preroll_0001.mp4").write_bytes(b"")
|
|
computer = DepthRecomputer(reader)
|
|
plan = computer.plan_for(frames[3])
|
|
assert plan.preroll is not None
|
|
assert plan.preroll_frames == 30
|
|
assert plan.warnings == ()
|
|
computer.close()
|
|
|
|
|
|
def test_a_segment_without_history_is_flagged_approximate():
|
|
"""Starting mid-recording with no reset and no pre-roll cannot be exact."""
|
|
frames = [
|
|
SimpleNamespace(
|
|
frame_id=index,
|
|
t_rel_s=index / 30.0,
|
|
segment=2,
|
|
segment_frame=index + 1,
|
|
in_video=True,
|
|
dropped=False,
|
|
)
|
|
for index in range(5)
|
|
]
|
|
reader = _reader(_session("/cfg/sensor.json"), frames=frames)
|
|
computer = DepthRecomputer(reader)
|
|
plan = computer.plan_for(frames[2])
|
|
assert "state_approximate" in plan.warnings
|
|
computer.close()
|
|
|
|
|
|
def test_a_dropped_frame_is_reported_not_substituted():
|
|
"""The index marks the hole; replay must say so instead of showing a neighbour."""
|
|
frames = [
|
|
SimpleNamespace(
|
|
frame_id=0,
|
|
t_rel_s=0.0,
|
|
segment=1,
|
|
segment_frame=1,
|
|
in_video=True,
|
|
dropped=False,
|
|
),
|
|
SimpleNamespace(
|
|
frame_id=1,
|
|
t_rel_s=1 / 30.0,
|
|
segment=1,
|
|
segment_frame=0,
|
|
in_video=False,
|
|
dropped=True,
|
|
),
|
|
]
|
|
reader = _reader_with_config(frames)
|
|
reader.frame_at = lambda _t: frames[1]
|
|
computer = DepthRecomputer(reader)
|
|
result = computer.depth_at(1 / 30.0)
|
|
assert not result.ok
|
|
assert result.error == "frame_not_recorded"
|
|
computer.close()
|