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:
@@ -340,23 +340,68 @@ class CalibrationRecorder:
|
||||
return point
|
||||
|
||||
# ------------------------------------------------------------ diagnostics
|
||||
def set_excluded(self, index: int, excluded: bool) -> None:
|
||||
def all_points(self) -> list[CalibrationPoint]:
|
||||
"""Snapshot of every recorded point, across channels."""
|
||||
with self._lock:
|
||||
return list(self.points)
|
||||
|
||||
def _resolve(self, point: CalibrationPoint) -> Optional[CalibrationPoint]:
|
||||
"""Find the recorded point that is ``point``, by identity.
|
||||
|
||||
Callers pass the object they are looking at. An integer row index would
|
||||
be ambiguous: the panel shows a per-channel filtered list while the
|
||||
recorder stores every channel in one list, so index 0 of the table is
|
||||
not index 0 of ``points``.
|
||||
"""
|
||||
if point is None:
|
||||
return None
|
||||
for candidate in self.points:
|
||||
if candidate is point:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def set_excluded(self, point: CalibrationPoint, excluded: bool) -> bool:
|
||||
"""Remove (or restore) a point from the fit and from the export."""
|
||||
with self._lock:
|
||||
if not 0 <= index < len(self.points):
|
||||
return
|
||||
self.points[index].excluded = bool(excluded)
|
||||
target = self._resolve(point)
|
||||
if target is None:
|
||||
return False
|
||||
target.excluded = bool(excluded)
|
||||
if excluded:
|
||||
self.points[index].is_validation = False
|
||||
target.is_validation = False
|
||||
return True
|
||||
|
||||
def set_validation(self, index: int, validation: bool) -> None:
|
||||
def set_validation(self, point: CalibrationPoint, validation: bool) -> bool:
|
||||
"""Hold a point out of the fit and use it to test the fit instead."""
|
||||
with self._lock:
|
||||
if not 0 <= index < len(self.points):
|
||||
return
|
||||
self.points[index].is_validation = bool(validation)
|
||||
target = self._resolve(point)
|
||||
if target is None:
|
||||
return False
|
||||
target.is_validation = bool(validation)
|
||||
if validation:
|
||||
self.points[index].excluded = False
|
||||
target.excluded = False
|
||||
return True
|
||||
|
||||
def remove_point(self, point: CalibrationPoint) -> bool:
|
||||
"""Drop a point entirely (used by "delete row")."""
|
||||
with self._lock:
|
||||
target = self._resolve(point)
|
||||
if target is None:
|
||||
return False
|
||||
self.points.remove(target)
|
||||
return True
|
||||
|
||||
def clear_points(self, channel: Optional[ChannelKey] = None) -> int:
|
||||
"""Drop every point, or every point of one channel. Returns the count."""
|
||||
with self._lock:
|
||||
if channel is None:
|
||||
removed = len(self.points)
|
||||
self.points = []
|
||||
return removed
|
||||
kept = [point for point in self.points if point.channel != channel]
|
||||
removed = len(self.points) - len(kept)
|
||||
self.points = kept
|
||||
return removed
|
||||
|
||||
def points_for(self, channel: ChannelKey) -> list[CalibrationPoint]:
|
||||
with self._lock:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Experiment recording (video + data + metadata)."""
|
||||
"""Experiment recording (video + data + metadata) and replay."""
|
||||
|
||||
from orisys_app_shared.record.depth import DepthRecomputer, DepthResult
|
||||
from orisys_app_shared.record.experiment import (
|
||||
EVENTS_HEADER,
|
||||
FORCES_HEADER,
|
||||
@@ -11,15 +12,27 @@ from orisys_app_shared.record.experiment import (
|
||||
RecordStats,
|
||||
format_value,
|
||||
)
|
||||
from orisys_app_shared.record.playback import (
|
||||
RecordingFrame,
|
||||
RecordingInfo,
|
||||
RecordingReader,
|
||||
list_recordings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EVENTS_HEADER",
|
||||
"FORCES_HEADER",
|
||||
"FRAMES_HEADER",
|
||||
"RECORD_FORMAT_VERSION",
|
||||
"DepthRecomputer",
|
||||
"DepthResult",
|
||||
"ExperimentRecorder",
|
||||
"RecordConfig",
|
||||
"RecordReport",
|
||||
"RecordStats",
|
||||
"RecordingFrame",
|
||||
"RecordingInfo",
|
||||
"RecordingReader",
|
||||
"format_value",
|
||||
]
|
||||
"list_recordings",
|
||||
]
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Recompute depth for a recording, offline.
|
||||
|
||||
Depth is not stored with a recording (a per-frame depth stream would be several
|
||||
GB per hour). It is a function of the frame and the sensor configuration, so a
|
||||
replay re-runs the SDK over ``raw_*.mp4`` using the ``config_ref`` recorded in
|
||||
``session.json``.
|
||||
|
||||
The honest failure modes are all surfaced rather than papered over:
|
||||
|
||||
* the SDK is not importable, or the recording's sensor config is gone;
|
||||
* the recording predates the recorded config hash, so the recomputation is not
|
||||
guaranteed to reproduce what was shown live;
|
||||
* the segment cannot be decoded.
|
||||
|
||||
The caller is expected to show the returned ``error`` and to mark the depth pane
|
||||
as unavailable instead of substituting some other image.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.record.playback import RecordingReader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DepthResult:
|
||||
"""Outcome of one recomputation attempt."""
|
||||
|
||||
depth: Optional[np.ndarray] = None
|
||||
error: str = ""
|
||||
frame_id: int = -1
|
||||
t_rel_s: float = 0.0
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.depth is not None and not self.error
|
||||
|
||||
|
||||
class DepthRecomputer:
|
||||
"""Recompute depth maps for one recording, on demand."""
|
||||
|
||||
def __init__(self, reader: RecordingReader) -> None:
|
||||
self.reader = reader
|
||||
self._sensor = None
|
||||
self._segment: Optional[Path] = None
|
||||
self.last_error = ""
|
||||
|
||||
# ------------------------------------------------------------------ probing
|
||||
@staticmethod
|
||||
def sdk_available() -> tuple[bool, str]:
|
||||
try:
|
||||
import orisys # noqa: F401
|
||||
|
||||
return True, str(getattr(__import__("orisys"), "__version__", "unknown"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, f"{type(exc).__name__}: {exc}"
|
||||
|
||||
def config_ref(self) -> str:
|
||||
session = self.reader.session()
|
||||
device = session.get("device") or {}
|
||||
return str(device.get("config_ref") or "")
|
||||
|
||||
def preflight(self) -> str:
|
||||
"""Return an empty string when recomputation can be attempted."""
|
||||
ok, detail = self.sdk_available()
|
||||
if not ok:
|
||||
return f"sdk_unavailable: {detail}"
|
||||
config_ref = self.config_ref()
|
||||
if not config_ref:
|
||||
return "config_missing"
|
||||
if not Path(config_ref).is_file():
|
||||
return "config_file_missing"
|
||||
if not self.reader.segments():
|
||||
return "no_video"
|
||||
return ""
|
||||
|
||||
# ----------------------------------------------------------------- recompute
|
||||
def depth_at(self, t_rel_s: float) -> DepthResult:
|
||||
"""Recompute the depth map for the frame at ``t_rel_s``."""
|
||||
problem = self.preflight()
|
||||
if problem:
|
||||
self.last_error = problem
|
||||
return DepthResult(error=problem, t_rel_s=float(t_rel_s))
|
||||
|
||||
frame = self.reader.frame_at(t_rel_s)
|
||||
if frame is None:
|
||||
return DepthResult(error="no_frame", t_rel_s=float(t_rel_s))
|
||||
if not frame.in_video:
|
||||
# The queue dropped this frame; say so rather than show the previous
|
||||
# frame as if it belonged to the cursor position.
|
||||
return DepthResult(
|
||||
error="frame_not_in_video", frame_id=frame.frame_id, t_rel_s=frame.t_rel_s
|
||||
)
|
||||
|
||||
segment = self.reader.segment_path(frame.segment)
|
||||
if segment is None:
|
||||
return DepthResult(
|
||||
error="segment_missing", frame_id=frame.frame_id, t_rel_s=frame.t_rel_s
|
||||
)
|
||||
try:
|
||||
depth = self._depth_from_segment(segment, frame.segment_frame)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the caller
|
||||
logger.exception("Depth recomputation failed")
|
||||
self.last_error = f"{type(exc).__name__}: {exc}"
|
||||
return DepthResult(
|
||||
error=self.last_error, frame_id=frame.frame_id, t_rel_s=frame.t_rel_s
|
||||
)
|
||||
if depth is None:
|
||||
self.last_error = "depth_not_available"
|
||||
return DepthResult(
|
||||
error=self.last_error, frame_id=frame.frame_id, t_rel_s=frame.t_rel_s
|
||||
)
|
||||
self.last_error = ""
|
||||
return DepthResult(depth=depth, frame_id=frame.frame_id, t_rel_s=frame.t_rel_s)
|
||||
|
||||
def _depth_from_segment(self, segment: Path, segment_frame: int) -> Optional[np.ndarray]:
|
||||
"""Open the segment with the SDK and read the depth channel."""
|
||||
import orisys
|
||||
|
||||
if self._segment != segment or self._sensor is None:
|
||||
self._close()
|
||||
self._sensor = orisys.Sensor(
|
||||
str(segment),
|
||||
config_name=self._config_name(),
|
||||
verbose=False,
|
||||
backend="auto",
|
||||
)
|
||||
self._segment = segment
|
||||
sensor = self._sensor
|
||||
# The SDK's video source advances frame by frame, so a seek means
|
||||
# stepping. Correctness first: we never return a frame we did not read.
|
||||
target = max(1, int(segment_frame))
|
||||
last_depth = None
|
||||
for _ in range(target):
|
||||
image = sensor.get_img()
|
||||
if image is None:
|
||||
break
|
||||
info = sensor.info
|
||||
if not hasattr(info, "DEPTH"):
|
||||
return None
|
||||
last_depth = sensor.read_info(info.DEPTH)
|
||||
return None if last_depth is None else np.asarray(last_depth)
|
||||
|
||||
def _config_name(self) -> str:
|
||||
config_ref = self.config_ref()
|
||||
path = Path(config_ref)
|
||||
return path.parent.name if path.name == "sensor.json" else path.stem
|
||||
|
||||
def _close(self) -> None:
|
||||
sensor, self._sensor = self._sensor, None
|
||||
self._segment = None
|
||||
if sensor is None:
|
||||
return
|
||||
try:
|
||||
sensor.disconnect()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
self._close()
|
||||
|
||||
def __enter__(self) -> "DepthRecomputer":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Read a recorded experiment back: forces, the frame index and the video.
|
||||
|
||||
Replay policy for depth (decided with the team): **depth is not stored**. It is
|
||||
a function of the frame and the sensor configuration, so it is recomputed from
|
||||
``raw_*.mp4`` with the config recorded in ``session.json``. That keeps a
|
||||
recording small at the cost of requiring the SDK to reproduce the same result,
|
||||
which is why :meth:`RecordingReader.replay_warnings` checks that the config hash
|
||||
and SDK version still match what was recorded.
|
||||
|
||||
Nothing here touches Qt, so the reader is testable without a display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Column names as written by the recorder (index == position in forces.csv).
|
||||
FORCE_COLUMNS = (
|
||||
"session_id",
|
||||
"frame_id",
|
||||
"t_rel_s",
|
||||
"FN_raw",
|
||||
"FX_raw",
|
||||
"FY_raw",
|
||||
"FN_N",
|
||||
"FX_N",
|
||||
"FY_N",
|
||||
"valid",
|
||||
"calibration_id",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecordingFrame:
|
||||
"""One row of ``frames.csv`` — the mapping from a frame to the video."""
|
||||
|
||||
frame_id: int
|
||||
t_rel_s: float
|
||||
acquired_at: str
|
||||
segment: int
|
||||
segment_frame: int
|
||||
in_video: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordingInfo:
|
||||
directory: Path
|
||||
session: dict
|
||||
frames: list[RecordingFrame]
|
||||
times: np.ndarray
|
||||
raw: np.ndarray
|
||||
newtons: np.ndarray
|
||||
valid: np.ndarray
|
||||
frame_ids: np.ndarray
|
||||
dropped_rows: int = 0
|
||||
|
||||
@property
|
||||
def duration_s(self) -> float:
|
||||
if self.times.size == 0:
|
||||
return 0.0
|
||||
return float(self.times[-1])
|
||||
|
||||
def value_for_unit(self, unit: str) -> np.ndarray:
|
||||
if str(unit).upper().startswith("N"):
|
||||
return self.newtons
|
||||
return self.raw
|
||||
|
||||
|
||||
class RecordingReader:
|
||||
"""Open one experiment directory and serve replay queries from it."""
|
||||
|
||||
def __init__(self, directory: str | Path) -> None:
|
||||
self.directory = Path(directory)
|
||||
if not self.directory.is_dir():
|
||||
raise FileNotFoundError(f"Not a recording directory: {self.directory}")
|
||||
self._info: Optional[RecordingInfo] = None
|
||||
|
||||
# ------------------------------------------------------------------ loading
|
||||
def info(self) -> RecordingInfo:
|
||||
if self._info is None:
|
||||
self._info = self._load()
|
||||
return self._info
|
||||
|
||||
def session(self) -> dict:
|
||||
path = self.directory / "session.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception: # noqa: BLE001 - a broken session.json must not crash replay
|
||||
logger.exception("Could not read %s", path)
|
||||
return {}
|
||||
|
||||
def _load(self) -> RecordingInfo:
|
||||
frames = self._load_frames()
|
||||
times, raw, newtons, valid, frame_ids, dropped = self._load_forces()
|
||||
return RecordingInfo(
|
||||
directory=self.directory,
|
||||
session=self.session(),
|
||||
frames=frames,
|
||||
times=times,
|
||||
raw=raw,
|
||||
newtons=newtons,
|
||||
valid=valid,
|
||||
frame_ids=frame_ids,
|
||||
dropped_rows=dropped,
|
||||
)
|
||||
|
||||
def _load_frames(self) -> list[RecordingFrame]:
|
||||
path = self.directory / "frames.csv"
|
||||
if not path.is_file():
|
||||
return []
|
||||
rows: list[RecordingFrame] = []
|
||||
with path.open(encoding="utf-8", newline="") as handle:
|
||||
for row in csv.DictReader(handle):
|
||||
try:
|
||||
rows.append(
|
||||
RecordingFrame(
|
||||
frame_id=int(row["frame_id"]),
|
||||
t_rel_s=float(row["t_rel_s"]),
|
||||
acquired_at=row.get("acquired_at", ""),
|
||||
segment=int(row.get("segment") or 0),
|
||||
segment_frame=int(row.get("segment_frame") or 0),
|
||||
in_video=row.get("in_video") == "1",
|
||||
)
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
return rows
|
||||
|
||||
def _load_forces(self):
|
||||
path = self.directory / "forces.csv"
|
||||
if not path.is_file():
|
||||
empty = np.empty(0, dtype=np.float64)
|
||||
return empty, np.empty((0, 3)), np.empty((0, 3)), np.empty(0, bool), np.empty(0, int), 0
|
||||
|
||||
def parse(text: str) -> float:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
# An empty cell means "not available"; it must never become 0.
|
||||
return float("nan")
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return float("nan")
|
||||
|
||||
times: list[float] = []
|
||||
raw: list[tuple[float, float, float]] = []
|
||||
newtons: list[tuple[float, float, float]] = []
|
||||
valid: list[bool] = []
|
||||
frame_ids: list[int] = []
|
||||
dropped = 0
|
||||
with path.open(encoding="utf-8", newline="") as handle:
|
||||
for row in csv.DictReader(handle):
|
||||
try:
|
||||
times.append(float(row["t_rel_s"]))
|
||||
frame_ids.append(int(row["frame_id"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
dropped += 1
|
||||
continue
|
||||
raw.append(
|
||||
(parse(row.get("FN_raw")), parse(row.get("FX_raw")), parse(row.get("FY_raw")))
|
||||
)
|
||||
newtons.append(
|
||||
(parse(row.get("FN_N")), parse(row.get("FX_N")), parse(row.get("FY_N")))
|
||||
)
|
||||
valid.append((row.get("valid") or "0").strip() == "1")
|
||||
return (
|
||||
np.asarray(times, dtype=np.float64),
|
||||
np.asarray(raw, dtype=np.float64).reshape(-1, 3),
|
||||
np.asarray(newtons, dtype=np.float64).reshape(-1, 3),
|
||||
np.asarray(valid, dtype=bool),
|
||||
np.asarray(frame_ids, dtype=np.int64),
|
||||
dropped,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ queries
|
||||
def frame_at(self, t_rel_s: float) -> Optional[RecordingFrame]:
|
||||
"""The recorded frame at or just before ``t_rel_s``."""
|
||||
frames = self.info().frames
|
||||
if not frames:
|
||||
return None
|
||||
times = [frame.t_rel_s for frame in frames]
|
||||
index = int(np.searchsorted(np.asarray(times), float(t_rel_s), side="right")) - 1
|
||||
index = max(0, min(index, len(frames) - 1))
|
||||
return frames[index]
|
||||
|
||||
def segment_path(self, segment: int) -> Optional[Path]:
|
||||
if segment <= 0:
|
||||
return None
|
||||
path = self.directory / f"raw_{segment:04d}.mp4"
|
||||
return path if path.is_file() else None
|
||||
|
||||
def segments(self) -> list[Path]:
|
||||
return sorted(self.directory.glob("raw_*.mp4"))
|
||||
|
||||
def video_frame_at(self, t_rel_s: float):
|
||||
"""Decode the video frame for a replay position.
|
||||
|
||||
Returns ``(frame_bgr, RecordingFrame)`` or ``(None, frame)`` when the
|
||||
frame was not written to video (the queue dropped it).
|
||||
"""
|
||||
import cv2
|
||||
|
||||
frame = self.frame_at(t_rel_s)
|
||||
if frame is None or not frame.in_video:
|
||||
return None, frame
|
||||
path = self.segment_path(frame.segment)
|
||||
if path is None:
|
||||
return None, frame
|
||||
capture = cv2.VideoCapture(str(path))
|
||||
if not capture.isOpened():
|
||||
return None, frame
|
||||
try:
|
||||
capture.set(cv2.CAP_PROP_POS_FRAMES, max(0, frame.segment_frame - 1))
|
||||
ok, image = capture.read()
|
||||
return (image if ok else None), frame
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
def forces_window(self, t0: float, t1: float) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""``(times, values)`` inside ``[t0, t1]`` for the raw triplet."""
|
||||
info = self.info()
|
||||
if info.times.size == 0:
|
||||
return info.times, info.raw
|
||||
mask = (info.times >= float(t0)) & (info.times <= float(t1))
|
||||
return info.times[mask], info.raw[mask]
|
||||
|
||||
# ------------------------------------------------------------------ policy
|
||||
def depth_policy(self) -> dict:
|
||||
return dict(self.info().session.get("depth") or {})
|
||||
|
||||
def depth_is_stored(self) -> bool:
|
||||
return bool(self.depth_policy().get("stored", False))
|
||||
|
||||
def recorded_config_ref(self) -> str:
|
||||
device = self.info().session.get("device") or {}
|
||||
return str(device.get("config_ref") or "")
|
||||
|
||||
@staticmethod
|
||||
def config_sha256(config_ref: str) -> str:
|
||||
"""Hash of the config file currently on disk (empty when unreadable)."""
|
||||
if not config_ref:
|
||||
return ""
|
||||
try:
|
||||
import hashlib
|
||||
|
||||
path = Path(str(config_ref))
|
||||
if path.is_file():
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
except Exception: # noqa: BLE001 - a missing config is reported, not raised
|
||||
return ""
|
||||
return ""
|
||||
|
||||
def replay_warnings(self, *, current_config_sha: str = "", sdk_version: str = "") -> list[str]:
|
||||
"""What could stop this recording from replaying faithfully.
|
||||
|
||||
Depth is recomputed rather than stored, so a changed config or SDK means
|
||||
the depth seen on replay may differ from what was shown live. That must
|
||||
be stated, not discovered later.
|
||||
"""
|
||||
session = self.info().session
|
||||
warnings: list[str] = []
|
||||
recorded_sha = str((session.get("device") or {}).get("config_sha256") or "")
|
||||
if not current_config_sha:
|
||||
# Compare against the config file as it is now, which is exactly what
|
||||
# the recomputation would use.
|
||||
current_config_sha = self.config_sha256(self.recorded_config_ref())
|
||||
if recorded_sha and current_config_sha and recorded_sha != current_config_sha:
|
||||
warnings.append("config_changed")
|
||||
versions = session.get("versions") or {}
|
||||
recorded_sdk = str(versions.get("sdk") or "")
|
||||
if recorded_sdk and sdk_version and recorded_sdk != sdk_version:
|
||||
warnings.append("sdk_changed")
|
||||
if recorded_sha and not current_config_sha:
|
||||
warnings.append("config_missing")
|
||||
if not self.depth_is_stored():
|
||||
warnings.append("depth_recomputed")
|
||||
if self.info().dropped_rows:
|
||||
warnings.append("rows_skipped")
|
||||
return warnings
|
||||
|
||||
def iter_forces(self, batch: int = 4096) -> Iterator[tuple[np.ndarray, np.ndarray]]:
|
||||
"""Stream the force rows, so a long recording is not read twice."""
|
||||
path = self.directory / "forces.csv"
|
||||
if not path.is_file():
|
||||
return
|
||||
times: list[float] = []
|
||||
values: list[tuple[float, float, float, float, float, float]] = []
|
||||
with path.open(encoding="utf-8", newline="") as handle:
|
||||
for row in csv.DictReader(handle):
|
||||
try:
|
||||
times.append(float(row["t_rel_s"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
values.append(
|
||||
tuple(
|
||||
float(row[key]) if (row.get(key) or "").strip() else float("nan")
|
||||
for key in ("FN_raw", "FX_raw", "FY_raw", "FN_N", "FX_N", "FY_N")
|
||||
)
|
||||
)
|
||||
if len(times) >= batch:
|
||||
yield np.asarray(times), np.asarray(values).reshape(-1, 6)
|
||||
times, values = [], []
|
||||
if times:
|
||||
yield np.asarray(times), np.asarray(values).reshape(-1, 6)
|
||||
|
||||
|
||||
def list_recordings(root: str | Path) -> list[Path]:
|
||||
"""Every recording directory under ``root``, newest first."""
|
||||
base = Path(root)
|
||||
if not base.is_dir():
|
||||
return []
|
||||
candidates = [
|
||||
path
|
||||
for path in base.iterdir()
|
||||
if path.is_dir() and (path / "forces.csv").is_file()
|
||||
]
|
||||
return sorted(candidates, key=lambda path: path.name, reverse=True)
|
||||
@@ -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