refactor(studio): 分拆 app_window 第一步 —— 会话事实(记录元数据/滤波器/采集协商)搬进 session_metadata

- 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`。
This commit is contained in:
ykcheungab
2026-09-15 08:51:17 +08:00
parent 8d374e5850
commit c2671e5c2f
3 changed files with 531 additions and 196 deletions
+51 -196
View File
@@ -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(
@@ -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 {}),
}