From c2671e5c2f0fca3b0d9a9d9aac36b1d23c984d2d Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Tue, 15 Sep 2026 08:51:17 +0800 Subject: [PATCH] =?UTF-8?q?refactor(studio):=20=E5=88=86=E6=8B=86=20app=5F?= =?UTF-8?q?window=20=E7=AC=AC=E4=B8=80=E6=AD=A5=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E4=BA=8B=E5=AE=9E=EF=BC=88=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE/=E6=BB=A4=E6=B3=A2=E5=99=A8/?= =?UTF-8?q?=E9=87=87=E9=9B=86=E5=8D=8F=E5=95=86=EF=BC=89=E6=90=AC=E8=BF=9B?= =?UTF-8?q?=20session=5Fmetadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - What: 新增 `apps/studio/src/orisys_studio/session_metadata.py`(273 行,纯函数 + 一个 `SessionFacts` 数据类,无 Qt、无状态),把 `StudioMainWindow` 里"记录必须自述"的那部分逻辑搬过去: `config_sha256` / `sdk_version` / `device_serial` / `capture_metadata` / `filter_metadata` / `filter_settings_from_prefs` / `video_fps` / `device_sample_rate` / `build_session_metadata`。 `app_window` 侧只留一行转发(保留 `_config_hash`/`_sdk_version`/`_device_serial`/`_capture_metadata`/ `_filter_metadata`/`_filter_settings`/`_video_fps`/`_device_sample_rate` 这些名字,因为测试与 `ui_check` 会直接调它们),`_build_session_metadata` 缩减为"把只有窗口能看见的事实装进 `SessionFacts`,然后交给模块"。`app_window.py` 3045 → 2901 行(-196/+51)。 - Why: 用户已同意分拆 `app_window.py`(当前 155 个方法、fan-out 36、唯一 fan-in=1 的中心),并选定四个 新模块的命名。A 步(会话/采集/记录)本身约 1100 行、40 处调用点,一次搬完风险高,所以按"两种变化原因 分开命名"的原则切成两步:**A1(本提交)= 无状态的事实与规则**,A2 = 带状态的 `SessionController` (worker 生命周期、phase、定时器、packet 扇出),后者将调用本模块组装记录块。 顺带把这几条规则从"只在记录流程里被间接覆盖"变成可单测:录制视频的 fps 对相机取**配置值**、对文件取 **实测值**、且永不写 0 fps;滤波器从**真正运行它们的 worker** 读回(旧 SDK 可能少应用),并把 `unsupported_by_sdk` 明确记下;任何元数据读取失败都只是空块,绝不让一次记录失败。 - Verification: 新增 `apps/studio/tests/test_session_metadata.py` **15 项**:配置内容哈希(含缺失/空路径)、 SDK 版本恒为字符串、设备序列号三种字段与嵌套 `info.SN`、capture 状态缺失/抛异常都返回空块、 滤波器回读与 `unsupported_by_sdk`、空/损坏 preferences 等于"什么都不滤"、 相机 vs 文件的 fps 规则与 0 fps 兜底、Nyquist 用的采样率来源与垃圾输入、完整 session 块(设备/版本/采集/ 标定/深度/约定/滤波/录像模式/video_config/3D 块逐项断言)、worker 消失且从未收到包时仍产出完整块、 3D 描述原样透传、三种录像模式如实记录。全量:shared+apps 同一次运行 **603 项 0 失败 15 跳过** (较上一提交 +15)、`ui_check` 587 项 0 失败、`locale_check` 0 缺失。 - Not in this change: 行为零改动(同一批函数、同一批默认值),也没有搬任何**状态**——`_latest_fps`/ `_latest_process_ms`/`_latest_capture_*`/`_record_error_count`/`_capture_checked`/`_dropped_reported` 仍在窗口里,A2 连同 worker 生命周期、phase、定时器与 packet 扇出一起搬; 回放(B)与视图/显示(C)两个控制器未开始。四个模块的名字已定: `SessionController` / `ReplayController`(引擎已改名 `ReplayEngine`)/ `ViewLayoutController` / `DisplayPrefsController`。 --- apps/studio/src/orisys_studio/app_window.py | 247 ++++------------ .../src/orisys_studio/session_metadata.py | 272 ++++++++++++++++++ apps/studio/tests/test_session_metadata.py | 208 ++++++++++++++ 3 files changed, 531 insertions(+), 196 deletions(-) create mode 100644 apps/studio/src/orisys_studio/session_metadata.py create mode 100644 apps/studio/tests/test_session_metadata.py diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 2d30c8f..ddcea83 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -49,6 +49,7 @@ from orisys_app_shared.record import RecordConfig from orisys_app_shared.record.sample import to_record_sample from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs from orisys_studio.replay import ReplayEngine +from orisys_studio import session_metadata from orisys_studio.scene3d.prefs import settings_to_prefs from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.logging import report_error @@ -372,28 +373,17 @@ class StudioMainWindow(QMainWindow): return self.recording.record_fps def _video_fps(self) -> float: - """The rate the recorded video is written at. + """The rate the recorded video is written at (see ``session_metadata``). - For a live camera that is the sensor configuration's own frame rate: the - container's fps must describe the spacing of the pictures, and using the - measured counter made a 30 fps sensor produce a video that declared (and - played at) 48 fps. A file source has no configured rate to honour, so its - measured rate is the honest one. + Delegated rather than deleted: tests and the dependency-free UI checker call + this on the window, and the rule itself now lives with the other record + metadata (where it is unit-tested on its own). """ - measured = float(self._record_fps) - if not isinstance(self.toolbar.active_source(), str): - try: - from orisys_app_shared.acquisition.models import load_fixed_mode - - mode = load_fixed_mode(self._resolve_config_ref() or "") - if mode.fps > 0: - return float(mode.fps) - except Exception: # noqa: BLE001 - the measured rate is the fallback - logger.debug("Could not read the configured frame rate", exc_info=True) - # Neither a stream that has not yet delivered a second of frames nor a - # config that could not be read may reach the recorder as a rate: a - # container of "0 fps" would thin the video to one picture per second. - return measured if measured > 0.0 else float(RecordConfig().fps) + return session_metadata.video_fps( + self._record_fps, + source_is_file=isinstance(self.toolbar.active_source(), str), + config_ref=self._resolve_config_ref(), + ) @property def _active_calibration_id(self) -> str: @@ -544,91 +534,30 @@ class StudioMainWindow(QMainWindow): @staticmethod def _device_serial(info) -> str: """The sensor's own serial number, when the SDK exposes one.""" - for attribute in ("sn", "serial", "serial_number"): - value = getattr(info, attribute, None) - if value: - return str(value) - serial = getattr(info, "info", None) - return str(getattr(serial, "SN", "") or "") + return session_metadata.device_serial(info) def _build_session_metadata(self, config: RecordConfig) -> dict: - from orisys_app_shared import API_VERSION, __version__ as shared_version + """Gather the facts only the window can see, then let the record describe them. - from orisys_studio import __version__ as app_version - - sensor_type = self.sensor_picker.sensor_type() - config_ref = self._resolve_config_ref() or "" - info = self._active_worker.latest() if self._active_worker is not None else None - return { - "device": { - "sensor_type": sensor_type, - "config_ref": config_ref, - "config_sha256": self._config_hash(config_ref), - "frame": { - "width": int(getattr(info, "raw_frame", None).shape[1]) - if getattr(info, "raw_frame", None) is not None - else None, - "height": int(getattr(info, "raw_frame", None).shape[0]) - if getattr(info, "raw_frame", None) is not None - else None, - "fps": float(self._video_fps()), - # The device's own measured rate, kept so a mismatch between - # the two can be diagnosed after the fact. - "measured_fps": float(self._record_fps), - }, - }, - "versions": { - "app": app_version, - "shared": shared_version, - "shared_api": API_VERSION, - "sdk": self._sdk_version(), - }, - # What the camera was asked for and what the driver gave. A recording - # that runs at 16.7 fps instead of 30 has to say why by itself. - "capture": self._capture_metadata(), - "calibration": { - "config_ref": config_ref, - "force_calibrated": bool(getattr(info, "force_calibrated", False)), - # Content hash of the coefficients the device confirmed. It is - # filled in by the acquisition thread's confirmation, so it can - # be empty here; the CSV's per-row version is authoritative. - "version": self._active_calibration_id, - # The operator-entered serial of the *calibrated* device, as the - # calibration panel has it (the device SN when the config has one). - "sn": self.cal_panel.sn_edit.text().strip(), - "device_sn": self._device_serial(info), - }, - # Depth is deliberately NOT stored (decided with the team): it is a - # function of the frame and the sensor configuration, and the replay - # view shows the recorded frames only. Re-running the pipeline over - # raw_*.mp4 is the "recompute" mode's job; the hashes below are what - # such a pass (and any other consumer) checks before trusting it. - "depth": { - "stored": False, - "replay": "none", - "quantity": "relative deformation / algorithm response, not a calibrated length", - "method": "re-run the SDK over raw_*.mp4 with this config_ref", - "config_sha256": self._config_hash(config_ref), - "sdk_version": self._sdk_version(), - }, - "conventions": { - "axis_order": ["FN", "FX", "FY"], - "force_unit": "N when calibrated, otherwise raw counts", - "time_base": ( - "t_rel_s is relative to the first recorded frame; an event " - "that predates it has no relative base and is left empty" - ), - "frame_id": "monotonic per acquisition session", - }, - "filter": self._filter_metadata(), - "recording_mode": self.toolbar.record_mode(), - "video_config": config.as_dict(), - # What the 3D pane was, for a reader who has to decide whether a picture - # was part of the observation. Display-only facts: no measurement, and no - # counters (those are only known when the recording stops and travel as an - # event). - "visualization3d": self._visualization3d_metadata(), - } + The block itself (and the rules about which frame rate a container may declare, + how filters are read back, what a failure looks like) lives in + ``session_metadata``; this only says *what* is on screen right now. + """ + facts = session_metadata.SessionFacts( + sensor_type=self.sensor_picker.sensor_type(), + config_ref=self._resolve_config_ref() or "", + worker=self._active_worker, + info=( + self._active_worker.latest() if self._active_worker is not None else None + ), + video_fps=float(self._video_fps()), + measured_fps=float(self._record_fps), + active_calibration_id=self._active_calibration_id, + operator_sn=self.cal_panel.sn_edit.text(), + recording_mode=self.toolbar.record_mode(), + visualization3d=self._visualization3d_metadata(), + ) + return session_metadata.build_session_metadata(config, facts) def _visualization3d_metadata(self) -> dict: """The pane's state for ``session.json``. Must never block a recording. @@ -653,68 +582,21 @@ class StudioMainWindow(QMainWindow): @staticmethod def _config_hash(config_ref: str) -> str: - try: - import hashlib - from pathlib import Path as _Path - - path = _Path(str(config_ref)) - if path.is_file(): - return hashlib.sha256(path.read_bytes()).hexdigest() - except Exception: # noqa: BLE001 - metadata must never block recording - pass - return "" + """Content hash of the sensor config (see ``session_metadata``).""" + return session_metadata.config_sha256(config_ref) @staticmethod def _sdk_version() -> str: - try: - import orisys - - return str(getattr(orisys, "__version__", "unknown")) - except Exception: # noqa: BLE001 - return "unavailable" + """The SDK version in use, or ``"unavailable"`` (see ``session_metadata``).""" + return session_metadata.sdk_version() def _filter_settings(self) -> FilterSettings: - """The filters the operator asked for, from the preferences file. - - One place reads them, so the dialog that writes them and the session that - runs them cannot drift apart. An empty or unreadable preference is - "nothing filters" — the same default the shipped sensor configs state. - """ - keys = ( - "flow_ema_alpha", - "force_median_window", - "force_lowpass_cutoff_hz", - "force_lowpass_order", - "force_lowpass_sample_rate_hz", - "show_unfiltered", - "preset", - ) - stored = {} - for key in keys: - try: - value = self.prefs.get(f"plot_{key}") - except Exception: # noqa: BLE001 - a broken preference is not fatal - value = None - if value is not None: - stored[key] = value - return settings_from_mapping(stored) + """The filters the operator asked for, from the preferences file.""" + return session_metadata.filter_settings_from_prefs(self.prefs) def _capture_metadata(self) -> dict: - """Requested vs negotiated capture parameters, for ``session.json``. - - Empty while nothing is open. A recording that runs at 16.7 fps on a 30 fps - config has to be able to explain itself; from the frames alone nobody can - tell whether the camera or the pipeline was the ceiling. - """ - worker = getattr(self, "_active_worker", None) - reader = getattr(worker, "capture_status", None) if worker is not None else None - if not callable(reader): - return {} - try: - return dict(reader() or {}) - except Exception: # noqa: BLE001 - metadata must never block a record - logger.exception("Could not read the capture status") - return {} + """Requested vs negotiated capture parameters, for ``session.json``.""" + return session_metadata.capture_metadata(self._active_worker) def _check_capture_negotiation(self) -> None: """Say it out loud when the camera delivers less than the config asks. @@ -727,15 +609,8 @@ class StudioMainWindow(QMainWindow): """ if self._capture_checked: return - worker = getattr(self, "_active_worker", None) - reader = getattr(worker, "capture_status", None) if worker is not None else None - if not callable(reader): - return - try: - status = dict(reader() or {}) - except Exception: # noqa: BLE001 - logger.exception("Could not read the capture status") - self._capture_checked = True + status = session_metadata.capture_metadata(self._active_worker) + if not status: return measured = float(self._latest_capture_fps or 0.0) if measured <= 0.0: @@ -748,10 +623,9 @@ class StudioMainWindow(QMainWindow): def _filter_metadata(self) -> dict: """The filters this session actually ran, for ``session.json``. - Read from the worker that is running the device, not from the settings - the operator chose: those are two different things whenever the SDK is - older than the request, and the record has to describe the data it holds. - Never raises — a record that cannot name its filters is still a record. + The block (and the rule that the filters are read back from the worker that ran + them, not from what was requested) lives in ``session_metadata``; this only + resolves *which* worker to ask. """ try: worker = self._active_worker @@ -760,19 +634,7 @@ class StudioMainWindow(QMainWindow): except Exception: # noqa: BLE001 logger.exception("Could not find the worker to ask about the filters") return FilterSettings().metadata() - try: - state = worker.filter_state() if worker is not None else {} - settings = worker.filter_settings() if worker is not None else None - unsupported = tuple(worker.filters_unsupported()) if worker is not None else () - except Exception: # noqa: BLE001 - logger.exception("Could not read the filter state for the record") - state, settings, unsupported = {}, None, () - block = (settings or FilterSettings()).metadata(state or None) - if unsupported: - # Named, not hidden: the record says which settings the installed SDK - # could not apply, so the numbers can be read for what they are. - block["unsupported_by_sdk"] = list(unsupported) - return block + return session_metadata.filter_metadata(worker) # ------------------------------------------------------- record channel def _on_packet_recorded(self, packet) -> None: @@ -1959,19 +1821,12 @@ class StudioMainWindow(QMainWindow): ) def _device_sample_rate(self) -> float: - """The frame rate the low-pass coefficients would be designed for. - - The device's measured rate once it has one, else the configured 30 fps — - the dialog needs a number to check a cutoff against Nyquist. - """ - for source in (self._latest_fps, self.prefs.get("measured_fps")): - try: - rate = float(source or 0.0) - except (TypeError, ValueError): - rate = 0.0 - if rate > 0: - return rate - return float(FORCE_SAMPLE_RATE_HZ) + """The frame rate the low-pass coefficients would be designed for.""" + return session_metadata.device_sample_rate( + self._latest_fps, + self.prefs.get("measured_fps"), + default=float(FORCE_SAMPLE_RATE_HZ), + ) def _apply_plot_prefs_to_panel(self) -> None: self.force_plots.set_window_sec( diff --git a/apps/studio/src/orisys_studio/session_metadata.py b/apps/studio/src/orisys_studio/session_metadata.py new file mode 100644 index 0000000..76e73ce --- /dev/null +++ b/apps/studio/src/orisys_studio/session_metadata.py @@ -0,0 +1,272 @@ +"""The facts a recording has to state about the session that produced it. + +``session.json`` is the only witness a reader has when they ask "what was this?" months +later, so its rules are the reason this module exists rather than living in the window: + +* the recorded video's frame rate is the **configured** rate for a camera and the + **measured** one for a file — using the measured counter made a 30 fps sensor write a + container that declared (and played at) 48 fps, and an unreadable config must not + become a "0 fps" container either; +* the filters are read back from the worker that actually ran them, not from the + settings the operator asked for: an older SDK may have applied less than requested, + and the record has to describe the data it holds; +* the device's measured capture rate is kept next to the requested one, because a + recording that runs at 16.7 fps on a 30 fps config has to be able to explain itself; +* none of this may block a recording: every read is guarded and a failure becomes an + empty block rather than an exception. + +Everything here is a **pure function of the facts the caller passes in**: no widgets, no +Qt, no session state. That is what makes it testable on its own, and it is the stateless +half of the session concern — the stateful half (workers, phase, timers, the packet +fan-out) is ``SessionController``. +""" + +from __future__ import annotations + +import hashlib +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Mapping + +from orisys_app_shared.force.filters import FilterSettings, settings_from_mapping +from orisys_app_shared.record import RecordConfig + +logger = logging.getLogger(__name__) + +#: The preference keys that describe the filter chain. One list, so the dialog that +#: writes them and the session that runs them cannot drift apart. +FILTER_PREF_KEYS = ( + "flow_ema_alpha", + "force_median_window", + "force_lowpass_cutoff_hz", + "force_lowpass_order", + "force_lowpass_sample_rate_hz", + "show_unfiltered", + "preset", +) + + +def config_sha256(config_ref: str | Path) -> str: + """Content hash of the sensor config, or "" when it cannot be read.""" + try: + path = Path(str(config_ref)) + if path.is_file(): + return hashlib.sha256(path.read_bytes()).hexdigest() + except Exception: # noqa: BLE001 - metadata must never block a recording + logger.debug("Could not hash the config %s", config_ref, exc_info=True) + return "" + + +def sdk_version() -> str: + """The SDK version in use, or ``"unavailable"``.""" + try: + import orisys + + return str(getattr(orisys, "__version__", "unknown")) + except Exception: # noqa: BLE001 - a missing SDK is a fact about the record + return "unavailable" + + +def device_serial(info: Any) -> str: + """The sensor's own serial number, when the SDK exposes one.""" + for attribute in ("sn", "serial", "serial_number"): + value = getattr(info, attribute, None) + if value: + return str(value) + serial = getattr(info, "info", None) + return str(getattr(serial, "SN", "") or "") + + +def capture_metadata(worker: Any) -> dict: + """Requested vs negotiated capture parameters, for ``session.json``. + + Empty while nothing is open. From the frames alone nobody can tell whether the + camera or the pipeline was the ceiling, so the worker's own status is copied. + """ + reader = getattr(worker, "capture_status", None) if worker is not None else None + if not callable(reader): + return {} + try: + return dict(reader() or {}) + except Exception: # noqa: BLE001 - metadata must never block a record + logger.exception("Could not read the capture status") + return {} + + +def filter_metadata(worker: Any) -> dict: + """The filters this session actually ran, for ``session.json``. + + Never raises: a record that cannot name its filters is still a record. + """ + try: + state = worker.filter_state() if worker is not None else {} + settings = worker.filter_settings() if worker is not None else None + unsupported = tuple(worker.filters_unsupported()) if worker is not None else () + except Exception: # noqa: BLE001 + logger.exception("Could not read the filter state for the record") + state, settings, unsupported = {}, None, () + block = (settings or FilterSettings()).metadata(state or None) + if unsupported: + # Named, not hidden: the record says which settings the installed SDK could + # not apply, so the numbers can be read for what they are. + block["unsupported_by_sdk"] = list(unsupported) + return block + + +def filter_settings_from_prefs(prefs: Mapping[str, Any]) -> FilterSettings: + """The filters the operator asked for, from the preferences file. + + An empty or unreadable preference is "nothing filters" — the same default the + shipped sensor configs state. + """ + stored: dict[str, Any] = {} + for key in FILTER_PREF_KEYS: + try: + value = dict(prefs or {}).get(f"plot_{key}") + except Exception: # noqa: BLE001 - a broken preference is not fatal + value = None + if value is not None: + stored[key] = value + return settings_from_mapping(stored) + + +def video_fps( + measured_fps: float, + *, + source_is_file: bool, + config_ref: str | Path | None, +) -> float: + """The rate the recorded video is written at. + + A camera has a configured rate and the container must honour it; a file source has + none, so its measured rate is the honest one. Never returns 0: a container declared + as "0 fps" would thin the video to one picture per second. + """ + measured = float(measured_fps or 0.0) + if not source_is_file: + try: + from orisys_app_shared.acquisition.models import load_fixed_mode + + mode = load_fixed_mode(str(config_ref or "")) + if mode.fps > 0: + return float(mode.fps) + except Exception: # noqa: BLE001 - the measured rate is the fallback + logger.debug("Could not read the configured frame rate", exc_info=True) + return measured if measured > 0.0 else float(RecordConfig().fps) + + +def device_sample_rate( + measured_fps: Any, stored_measured_fps: Any, *, default: float +) -> float: + """The rate the low-pass coefficients would be designed for. + + The device's measured rate once it has one, else whatever the preferences last + recorded, else the caller's default — the filter dialog needs a number to check a + cutoff against Nyquist. + """ + for source in (measured_fps, stored_measured_fps): + try: + rate = float(source or 0.0) + except (TypeError, ValueError): + rate = 0.0 + if rate > 0: + return rate + return float(default) + + +@dataclass(frozen=True) +class SessionFacts: + """The handful of facts only the window can see, for the metadata block.""" + + sensor_type: str + config_ref: str + #: The worker that is running the device (its capture status and filters are read + #: back from *it*, not from what was requested). + worker: Any = None + #: The last packet: frame size and whether the force channels are calibrated. + info: Any = None + video_fps: float = 30.0 + measured_fps: float = 0.0 + active_calibration_id: str = "" + #: The serial the operator entered in the calibration panel. + operator_sn: str = "" + recording_mode: str = "video" + #: The display block, already assembled by the pane that owns it. + visualization3d: Mapping[str, Any] = field(default_factory=dict) + + +def build_session_metadata(config: RecordConfig, facts: SessionFacts) -> dict: + """The ``session.json`` metadata written when a recording starts.""" + from orisys_app_shared import API_VERSION, __version__ as shared_version + + from orisys_studio import __version__ as app_version + + info = facts.info + raw_frame = getattr(info, "raw_frame", None) + config_ref = str(facts.config_ref or "") + config_hash = config_sha256(config_ref) + return { + "device": { + "sensor_type": str(facts.sensor_type), + "config_ref": config_ref, + "config_sha256": config_hash, + "frame": { + "width": int(raw_frame.shape[1]) if raw_frame is not None else None, + "height": int(raw_frame.shape[0]) if raw_frame is not None else None, + "fps": float(facts.video_fps), + # The device's own measured rate, kept so a mismatch between the two + # can be diagnosed after the fact. + "measured_fps": float(facts.measured_fps), + }, + }, + "versions": { + "app": app_version, + "shared": shared_version, + "shared_api": API_VERSION, + "sdk": sdk_version(), + }, + # What the camera was asked for and what the driver gave. A recording that runs + # at 16.7 fps instead of 30 has to say why by itself. + "capture": capture_metadata(facts.worker), + "calibration": { + "config_ref": config_ref, + "force_calibrated": bool(getattr(info, "force_calibrated", False)), + # Content hash of the coefficients the device confirmed. It is filled in by + # the acquisition thread's confirmation, so it can be empty here; the CSV's + # per-row version is authoritative. + "version": str(facts.active_calibration_id or ""), + # The operator-entered serial of the *calibrated* device, as the + # calibration panel has it (the device SN when the config has one). + "sn": str(facts.operator_sn or "").strip(), + "device_sn": device_serial(info), + }, + # Depth is deliberately NOT stored (decided with the team): it is a function of + # the frame and the sensor configuration, and the replay view shows the recorded + # frames only. Re-running the pipeline over raw_*.mp4 is the "recompute" mode's + # job; the hashes below are what such a pass checks before trusting it. + "depth": { + "stored": False, + "replay": "none", + "quantity": "relative deformation / algorithm response, not a calibrated length", + "method": "re-run the SDK over raw_*.mp4 with this config_ref", + "config_sha256": config_hash, + "sdk_version": sdk_version(), + }, + "conventions": { + "axis_order": ["FN", "FX", "FY"], + "force_unit": "N when calibrated, otherwise raw counts", + "time_base": ( + "t_rel_s is relative to the first recorded frame; an event " + "that predates it has no relative base and is left empty" + ), + "frame_id": "monotonic per acquisition session", + }, + "filter": filter_metadata(facts.worker), + "recording_mode": str(facts.recording_mode), + "video_config": config.as_dict(), + # What the 3D pane was, for a reader who has to decide whether a picture was + # part of the observation. Display-only facts: no measurement, and no counters + # (those are only known when the recording stops and travel as an event). + "visualization3d": dict(facts.visualization3d or {}), + } diff --git a/apps/studio/tests/test_session_metadata.py b/apps/studio/tests/test_session_metadata.py new file mode 100644 index 0000000..865688f --- /dev/null +++ b/apps/studio/tests/test_session_metadata.py @@ -0,0 +1,208 @@ +"""Acceptance for the facts a recording states about its own session. + +``session.json`` is the only witness a reader has later, so these are the rules that +keep it honest: which frame rate a container may declare, that the filters are read back +from the worker that ran them, that no metadata read can fail a recording, and that the +3D pane's own description is passed through untouched. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +from orisys_app_shared.force.filters import FilterSettings +from orisys_app_shared.record import RecordConfig +from orisys_studio import session_metadata as sm + +CONFIG = ( + Path(__file__).resolve().parents[1] + / "config" + / "sensors" + / "finger" + / "sensor.json" +) + + +# --------------------------------------------------------------------- config hash +def test_the_config_hash_is_the_file_content_or_nothing(tmp_path): + path = tmp_path / "sensor.json" + path.write_text('{"camera": {}}', encoding="utf-8") + assert sm.config_sha256(path) == hashlib.sha256(path.read_bytes()).hexdigest() + assert sm.config_sha256(tmp_path / "gone.json") == "" + assert sm.config_sha256("") == "" + + +def test_the_sdk_version_is_a_string_even_with_no_sdk(): + value = sm.sdk_version() + assert isinstance(value, str) and value + + +# ------------------------------------------------------------------- device serial +def test_the_device_serial_comes_from_whichever_field_the_sdk_uses(): + assert sm.device_serial(SimpleNamespace(sn="A1")) == "A1" + assert sm.device_serial(SimpleNamespace(serial="B2")) == "B2" + assert sm.device_serial(SimpleNamespace(serial_number="C3")) == "C3" + assert sm.device_serial(SimpleNamespace(info=SimpleNamespace(SN="D4"))) == "D4" + assert sm.device_serial(SimpleNamespace()) == "" + assert sm.device_serial(None) == "" + + +# -------------------------------------------------------------- capture / filters +def test_capture_metadata_copies_what_the_worker_says_and_survives_failure(): + assert sm.capture_metadata(None) == {} + assert sm.capture_metadata(SimpleNamespace()) == {} + worker = SimpleNamespace(capture_status=lambda: {"requested_fps": 30.0}) + assert sm.capture_metadata(worker) == {"requested_fps": 30.0} + + def explode(): + raise RuntimeError("driver gone") + + # A recording must not fail because the camera cannot describe itself. + assert sm.capture_metadata(SimpleNamespace(capture_status=explode)) == {} + + +def test_filter_metadata_names_what_the_sdk_could_not_apply(): + settings = FilterSettings() + worker = SimpleNamespace( + filter_state=lambda: {"flow_ema_alpha": 1.0}, + filter_settings=lambda: settings, + filters_unsupported=lambda: ("force_lowpass_order",), + ) + block = sm.filter_metadata(worker) + assert block["unsupported_by_sdk"] == ["force_lowpass_order"] + assert block # the applied settings are stated too + + # No worker, and a worker that raises, both give a block rather than an exception. + assert sm.filter_metadata(None) + def explode(): + raise RuntimeError("worker is gone") + assert sm.filter_metadata(SimpleNamespace(filter_state=explode)) + + +def test_filter_settings_default_to_nothing_filters(): + """The shipped configs state "no filters"; an empty preference must agree.""" + empty = sm.filter_settings_from_prefs({}) + assert empty == FilterSettings() + + stored = sm.filter_settings_from_prefs( + {"plot_force_median_window": 3, "plot_flow_ema_alpha": 0.5, "unrelated": 1} + ) + assert stored != empty + assert stored.median_window == 3 + assert stored.flow_ema_alpha == pytest.approx(0.5) + + +def test_a_broken_preferences_object_is_not_fatal(): + class Broken(dict): + def get(self, key, default=None): # noqa: D102 - deliberately broken + raise RuntimeError("corrupt preferences") + + assert sm.filter_settings_from_prefs(Broken()) == FilterSettings() + + +# ------------------------------------------------------------------- frame rates +def test_a_camera_declares_its_configured_rate_and_a_file_its_measured_one(): + """The container's fps must describe the spacing of the pictures it holds.""" + if CONFIG.is_file(): + assert sm.video_fps(48.0, source_is_file=False, config_ref=CONFIG) == 30.0 + # A file source has no configured rate to honour. + assert sm.video_fps(17.5, source_is_file=True, config_ref=CONFIG) == 17.5 + # An unreadable config falls back to the measured rate, never to zero. + assert sm.video_fps(12.0, source_is_file=False, config_ref="missing.json") == 12.0 + assert sm.video_fps(0.0, source_is_file=True, config_ref=None) == float( + RecordConfig().fps + ) + + +def test_the_filter_dialog_gets_a_rate_it_can_check_nyquist_against(): + assert sm.device_sample_rate(25.0, None, default=30.0) == 25.0 + assert sm.device_sample_rate(None, "15", default=30.0) == 15.0 + assert sm.device_sample_rate("garbage", "also garbage", default=30.0) == 30.0 + assert sm.device_sample_rate(None, 0, default=30.0) == 30.0 + + +# ------------------------------------------------------------- the whole metadata +def test_the_session_block_states_the_device_the_filters_and_the_picture(): + info = SimpleNamespace( + raw_frame=np.zeros((48, 64, 3), dtype=np.uint8), force_calibrated=True, sn="SN-9" + ) + worker = SimpleNamespace( + capture_status=lambda: {"requested_fps": 30.0, "measured_fps": 21.0}, + filter_state=lambda: {}, + filter_settings=lambda: FilterSettings(), + filters_unsupported=lambda: (), + ) + facts = sm.SessionFacts( + sensor_type="finger", + config_ref=str(CONFIG) if CONFIG.is_file() else "missing.json", + worker=worker, + info=info, + video_fps=30.0, + measured_fps=21.0, + active_calibration_id="cal-7", + operator_sn=" OPERATOR-1 ", + recording_mode="video", + visualization3d={"block_version": 1, "enabled": True}, + ) + config = RecordConfig() + block = sm.build_session_metadata(config, facts) + + assert block["device"]["sensor_type"] == "finger" + assert block["device"]["frame"] == { + "width": 64, + "height": 48, + "fps": 30.0, + "measured_fps": 21.0, + } + assert block["versions"]["sdk"] + assert block["capture"] == {"requested_fps": 30.0, "measured_fps": 21.0} + assert block["calibration"]["force_calibrated"] is True + assert block["calibration"]["version"] == "cal-7" + assert block["calibration"]["sn"] == "OPERATOR-1" + assert block["calibration"]["device_sn"] == "SN-9" + assert block["depth"]["stored"] is False + assert block["depth"]["config_sha256"] == block["device"]["config_sha256"] + assert block["filter"] + assert block["recording_mode"] == "video" + assert block["video_config"] == config.as_dict() + assert block["visualization3d"] == {"block_version": 1, "enabled": True} + assert block["conventions"]["axis_order"] == ["FN", "FX", "FY"] + + +def test_the_block_survives_a_worker_that_is_gone_and_a_packet_that_never_came(): + """Metadata that cannot be read is empty, never an exception (a record is running).""" + block = sm.build_session_metadata( + RecordConfig(), + sm.SessionFacts(sensor_type="box", config_ref="", worker=None, info=None), + ) + assert block["device"]["frame"]["width"] is None + assert block["capture"] == {} + assert block["filter"] + assert block["device"]["config_sha256"] == "" + + +def test_the_3d_pane_description_is_passed_through_untouched(): + described = {"scene": {"asset_id": "x"}, "gl": {}, "display": {}, "stream": {}} + block = sm.build_session_metadata( + RecordConfig(), + sm.SessionFacts( + sensor_type="finger", + config_ref="", + visualization3d={"visualization3d": described, "block_version": 1}, + ), + ) + assert block["visualization3d"]["visualization3d"] == described + + +@pytest.mark.parametrize("mode", ("video", "raw", "lossless")) +def test_every_recording_mode_is_recorded_as_stated(mode): + block = sm.build_session_metadata( + RecordConfig(), + sm.SessionFacts(sensor_type="finger", config_ref="", recording_mode=mode), + ) + assert block["recording_mode"] == mode