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:
ykcheungab
2026-09-13 14:01:24 +08:00
parent 92da51217c
commit 9354d7be1a
14 changed files with 1689 additions and 35 deletions
@@ -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)