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
+208
View File
@@ -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