拆分 A2:会话状态与生命周期移入 SessionController(app_window 2900→2837 行)

- What: 新增 apps/studio/src/orisys_studio/session_controller.py(423 行 / 41 成员):
  它拥有两个 worker 及当前 worker、相位、会话计数、每会话遥测(fps / process_ms /
  capture_fps / capture_dropped / 读数失败计数 / 丢帧上报语义)和运行定时器(16 ms),
  并给出 reset_for_run / mark_running / mark_stopped / note_first_frame / note_connect_slow /
  note_sample_telemetry / check_capture_negotiation / take_dropped_message /
  take_session_error / consume_end_reason / detach_sinks。app_window 只剩「看到什么」:
  按钮、轨道、状态栏、面板,以及 16 ms 呈现槽 _on_run_tick。
  同时删除窗口里已成副本的 _check_capture_negotiation / _session_time / _current_frame_id /
  _report_invalid_sample / _report_dropped_samples / _on_worker_error / _on_worker_status,
  并让 _on_packet_recorded、_on_calibration_applied、_set_phase 只在窗口一层转发。
- What: 窗口保留过渡 shim(_running / _active_worker / _box_worker / _finger_worker /
  _run_timer / _session_phase / _session_counter / _connect_started_at 等属性与
  _ensure_workers / _worker_for / _session_time 等方法),因为这些名字是
  apps/studio/tests 与 tools/ui_check.py 驱动窗口的既有接口;它们随后续切片逐个删除。
  test_ui_smoke.py 里两处直接写窗口状态改为写 window.session.*(否则会绕开新所有者)。
- Why: app_window 是唯一 fan-in=1 的中心、也是四个独立变更驱动的交汇点。会话/采集/
  记录生命周期是其中第一个:设备与相位规则改一次,这个 2900 行文件就要跟着改一次。
  状态与机制搬进控制器后,「谁在跑、跑到哪一阶段、报了什么数」只有一个所有者,
  窗口只负责把这些事实画出来。
- Verification: pytest shared/tests apps/studio/tests(QT_QPA_PLATFORM=offscreen)
  603 passed / 0 failed / 15 skipped(与改动前基线一致);apps/studio/tools/ui_check.py
  587 项检查 0 失败;.repro/locale_check.py 325 个字面 key 0 缺失。
- Not in this change: 不改行为(纯搬运 + 一层转发)。有意的语义收敛只有一处:相位信号
  现在每次 set_phase 都发出(状态芯片据此重绘,幂等),窗口不再自留一份相位判据。
  B 回放控制器、C1 视图排列、C2 显示偏好、标定交互仍未拆分;docs 里的行数/方法数
  指标待全部切片落定后统一刷新。
  已知未接线(本次未动、也非本次引入):worker 的 on_reset_applied 从未接到
  recording.note_reset,所以真机上按「清零」只会看到「指令已发出」,确认事件不会
  进入记录;这是独立缺陷,另行处理。
  真机 GL 冒烟本轮无法复跑:本机当前创建不出 GL 上下文(eglError 3005),
  test_scene3d_gl_smoke 13 例跳过、probe 用例因机器无上下文而失败,与本次改动无关。
This commit is contained in:
ykcheungab
2026-09-15 09:23:52 +08:00
parent 384e24514b
commit a35d1877d6
3 changed files with 596 additions and 236 deletions
+168 -231
View File
@@ -39,7 +39,6 @@ from orisys_app_shared.acquisition.image_backend import (
plan_image,
render_image,
)
from orisys_app_shared.acquisition.sdk_worker import short_capture_fallacy
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.filters import FilterSettings
@@ -50,6 +49,7 @@ 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.session_controller import SessionController
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
@@ -90,10 +90,10 @@ MIN_PANEL_WIDTHS = {
"plots": 340,
"cal": 300,
}
SESSION_PHASES = ("idle", "connecting", "running", "error")
#: Top-level views. Each one is a working arrangement of the same panels, not a
#: separate window: switching views must not cost the operator a dialog.
VIEW_PRESETS = ("custom", "live", "replay", "calibration")
#: Splitter sizes per view, in panel order (3D, 2D, charts, calibration).
#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``).
#: Replay is the live layout on purpose: the two views differ in their source
@@ -114,8 +114,6 @@ VIEW_SHOWS_3D = ("custom", "live", "replay")
#: nothing. The table describes the two-dimensional layout; the pane is optional, and
#: "not in the table" must not mean "standing at its minimum width".
DEFAULT_3D_PANE_WIDTH = 340
#: After this long in ``connecting``, tell the user that Stop is the way out.
CONNECT_SLOW_HINT_S = 8.0
#: Playback tick while a recording is playing. 33 ms is one frame at 30 fps; the
#: cursor is advanced from the wall clock, so a missed tick does not slow the
#: recording down.
@@ -166,29 +164,14 @@ class StudioMainWindow(QMainWindow):
self._view_preset = preset if preset in VIEW_PRESETS else "custom"
self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True))
self._show_fps = bool(self.prefs.get("show_fps", False))
self._session_phase = "idle"
self._running = False
self._connect_started_at = 0.0
self._connect_slow_notified = False
#: Whether this run has already said "showing raw, nothing is calibrated".
self._raw_fallback_notified = False
#: True while the run over the video of the recompute mode is going.
self._recompute_running = False
self._preview = None
self._preview_timer = QTimer(self)
self._preview_timer.setInterval(33)
self._preview_timer.timeout.connect(self._on_preview_tick)
self._run_timer = QTimer(self)
self._run_timer.setInterval(16)
self._run_timer.timeout.connect(self._on_run_tick)
self._box_worker = None
self._finger_worker = None
self._active_worker = None
self._active_sensor_type = "box"
self._last_session_error = ""
self._last_plot_packet_key: tuple[int, float] | None = None
self._last_rendered_view_mode: str | None = None
self._run_source_label = ""
# ---- replay ---------------------------------------------------------
# One engine for the history view: the recording reader, the cursor and
# the asynchronous depth recomputation. It renders nothing itself; the
@@ -211,28 +194,27 @@ class StudioMainWindow(QMainWindow):
cal_recorder=self.cal_recorder,
)
self.recorder = self.recording.recorder
self._session_counter = 0
# ---- the acquisition session ----------------------------------------
# Which worker runs, in which phase, and what it reports: a controller, so
# this window owns the widgets around it rather than its state. The window's
# remaining ``self._running`` / ``self._active_worker`` reads go through the
# transitional shims further down; they become ``self.session.*`` as the rest
# of the split lands (docs/architecture/studio-architecture.md, suggestion 1).
self.session = SessionController(
recording=self.recording,
packet_sink=self._on_packet_recorded,
packet_for_3d=self._on_packet_for_3d,
sensor_type="box",
)
self.session.tick.connect(self._on_run_tick)
# The phase is the session's, not the window's; the chip is one view of it.
self.session.phaseChanged.connect(self._on_session_phase_changed)
self._active_calibration_id = ""
self._record_error_count = 0
self._dropped_reported = 0
#: Last display-unit decision (``None`` before the first sample): the
#: status rail's calibration readout is derived from it, because an axis
#: without a newton curve is exactly an axis without calibration.
self._last_display_fallback: bool | None = None
self._last_display_available: tuple[bool, bool, bool] | None = None
#: Was the most recent readout valid? ``None`` before the first sample.
self._last_sample_valid: bool | None = None
self._latest_fps: float | None = None
#: What the last frame cost to acquire and process, in milliseconds — the
#: other half of the frame-rate reading.
self._latest_process_ms: float | None = None
#: The capture source's own published rate and shortfall, over the same
#: window: together with the above they say who is the slow part.
self._latest_capture_fps: float = 0.0
self._latest_capture_dropped: int = 0
#: Whether this session has already reported a shortfall between the
#: requested and the negotiated capture rate (said once, not per frame).
self._capture_checked = True
#: Last coefficients file written by the calibration panel (not applied).
self._last_calibration_export = ""
self._depth_range_value: tuple[float, float] | None = None
@@ -330,6 +312,107 @@ class StudioMainWindow(QMainWindow):
def _preview_open(self) -> bool:
return self._preview is not None and bool(self._preview.is_open)
# ------------------------------------------- session shims (transitional)
# The acquisition session's *state* lives in ``SessionController``; these forward so
# the call sites still in this file (and the tests / ``tools/ui_check``, which drive
# the window) keep working while the rest of the split lands. Each one is a plain
# read of the controller — no state is kept here — and they are deleted as the
# remaining slices move call sites to ``self.session.*``.
@property
def _running(self) -> bool:
return self.session.is_running
@property
def _session_phase(self) -> str:
return self.session.phase
@property
def _active_worker(self):
return self.session.active_worker
@property
def _box_worker(self):
return self.session.box_worker
@_box_worker.setter
def _box_worker(self, worker) -> None:
self.session.set_worker("box", worker)
@property
def _finger_worker(self):
return self.session.finger_worker
@_finger_worker.setter
def _finger_worker(self, worker) -> None:
self.session.set_worker("finger", worker)
@property
def _run_timer(self):
return self.session.run_timer
@property
def _session_counter(self) -> int:
return self.session.session_counter
@property
def _connect_started_at(self) -> float:
return self.session.connect_started_at
@_connect_started_at.setter
def _connect_started_at(self, started_at: float) -> None:
# Settable because the slow-connect hint is measured from this clock and the
# dependency-free checker winds it back rather than waiting eight seconds.
self.session.connect_started_at = started_at
@property
def _last_sample_valid(self):
return self.session.last_sample_valid
@property
def _latest_fps(self):
return self.session.latest_fps
@property
def _latest_process_ms(self):
return self.session.latest_process_ms
@property
def _latest_capture_fps(self) -> float:
return self.session.latest_capture_fps
@property
def _latest_capture_dropped(self) -> int:
return self.session.latest_capture_dropped
@property
def _record_error_count(self) -> int:
return self.session.record_error_count
# The session's own methods, forwarded for the same reason.
def _ensure_workers(self) -> None:
self.session.ensure_workers()
def _worker_for(self, sensor_type: str):
return self.session.worker_for(sensor_type)
def _set_phase(self, phase: str) -> None:
"""Track the acquisition phase (the chip follows through ``phaseChanged``)."""
self.session.set_phase(phase)
def _on_session_phase_changed(self, phase: str) -> None:
self.toolbar.set_run_phase(phase)
self._refresh_status_chip()
@property
def _session_active(self) -> bool:
return self.session.session_active
def _session_time(self, packet) -> float:
return self.session.session_time(packet, self.session_store.origin_time())
def _current_frame_id(self) -> int:
return self.session.current_frame_id(self.session_store)
def _get_preview(self):
if self._preview is None:
from orisys_app_shared.camera import RawPreviewSession
@@ -337,33 +420,6 @@ class StudioMainWindow(QMainWindow):
self._preview = RawPreviewSession()
return self._preview
def _ensure_workers(self) -> None:
if self._box_worker is not None and self._finger_worker is not None:
return
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
if self._box_worker is None:
self._box_worker = SdkAcquisitionWorker(
on_error=self._on_worker_error,
on_status=self._on_worker_status,
on_calibration_applied=self._on_calibration_applied,
)
if self._finger_worker is None:
self._finger_worker = FingerAcquisitionWorker(
on_error=self._on_worker_error,
on_status=self._on_worker_status,
on_calibration_applied=self._on_calibration_applied,
)
# Both workers publish into the same record channel.
for worker in (self._box_worker, self._finger_worker):
worker.add_sink(self._on_packet_recorded)
# The 3D pane is a second sink on the same thread. It is registered even
# while the pane is off, because a sink that is added and removed as the
# pane is toggled is a sink whose cost depends on the click order; the
# view's own enabled flag is what makes it do nothing.
worker.add_sink(self._on_packet_for_3d)
# ------------------------------------------------------- experiment record
@property
def _record_fps(self) -> float:
@@ -598,28 +654,6 @@ class StudioMainWindow(QMainWindow):
"""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.
The evidence is the *measured* capture rate, not the driver's reported
one: OpenCV's DSHOW backend echoes ``CAP_PROP_FPS`` back as whatever was
set, so a camera advertising 30 and delivering 15 reports "30.0" (measured
on this project's own hardware). Said once per session, and only once a
measured rate exists.
"""
if self._capture_checked:
return
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:
return # no measured rate yet: ask again on the next frame
self._capture_checked = True
detail = short_capture_fallacy(status, measured_fps=measured)
if detail:
self._notify(t("status.capture_slow", detail=detail))
def _filter_metadata(self) -> dict:
"""The filters this session actually ran, for ``session.json``.
@@ -640,12 +674,12 @@ class StudioMainWindow(QMainWindow):
def _on_packet_recorded(self, packet) -> None:
"""Runs on the **acquisition thread**. Must stay cheap and Qt-free.
The record channel's mechanics are the coordinator's; all this keeps is
the UI's own bookkeeping (how many readouts failed).
The record channel's mechanics — including how many readouts failed — are the
session controller's. This stays the entry point the workers are handed (the
tests and ``tools/ui_check`` assert *which* callable is registered), so it
forwards rather than keeping a second copy of the rule.
"""
record = self.recording.on_packet(packet)
if not record.valid:
self._record_error_count += 1
self.session.note_packet_recorded(packet)
def _wire_toolbar(self) -> None:
self.toolbar.search_clicked.connect(self._on_search_cameras)
@@ -1487,17 +1521,13 @@ class StudioMainWindow(QMainWindow):
return
self.statusBar().showMessage(message, int(ms))
def _set_phase(self, phase: str) -> None:
"""Track the acquisition phase and reflect it in the status chip."""
self._session_phase = phase if phase in SESSION_PHASES else "idle"
self.toolbar.set_run_phase(self._session_phase)
self._refresh_status_chip()
def _refresh_status_chip(self) -> None:
chip = getattr(self, "status_chip", None)
if chip is None:
return
phase = self._session_phase if self._session_phase in SESSION_PHASES else "idle"
# The session normalises the phase once, when it is set, so there is nothing
# to sanitise here: a second normalisation is a second rule to keep in step.
phase = self._session_phase
token = f"status_{phase}"
# One dot carries the colour; the text says what the state is, so the
# colour is never the only encoding.
@@ -2004,12 +2034,6 @@ class StudioMainWindow(QMainWindow):
for _event in self.recording.drain_reset_events():
self.statusBar().showMessage(t("status.reset_ok"))
def _worker_for(self, sensor_type: str):
self._ensure_workers()
if sensor_type == "finger":
return self._finger_worker
return self._box_worker
def _on_calibration_point(self, point) -> None:
"""Called on the UI thread when a calibration point finishes."""
self.cal_panel.on_record_finished(point)
@@ -2155,14 +2179,6 @@ class StudioMainWindow(QMainWindow):
self._persist_prefs()
# ----------------------------------------------------------- shortcuts
@property
def _session_active(self) -> bool:
"""True until the worker has fully settled, not just until Stop was hit."""
if self._running:
return True
worker = self._active_worker
return worker is not None and worker.is_running
def _sync_shortcut_actions(self) -> None:
if getattr(self, "act_run", None) is None:
return
@@ -2369,32 +2385,24 @@ class StudioMainWindow(QMainWindow):
self.force_plots.reset()
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
self._last_session_error = ""
self._run_source_label = str(source)
self._active_sensor_type = sensor_type
# Fresh record channel for the new session. The calibration identity is
# unknown until the device confirms which coefficients it loaded, so it
# starts empty and is filled from that confirmation — a path is not a
# version, and the same path can hold different numbers.
self._active_calibration_id = ""
self._record_error_count = 0
self._dropped_reported = 0
self._last_display_fallback = None
self._last_display_available = None
self._last_sample_valid = None
self._latest_fps = None
self._latest_process_ms = None
self._latest_capture_fps = 0.0
self._latest_capture_dropped = 0
self._capture_checked = False
self._depth_range_value = None
self._last_depth_frame = None
# The session's own bookkeeping (its counter and telemetry) is the
# controller's: telling it a run starts is what drops the previous run's
# numbers, so none of them can be read as this run's.
self.session.reset_for_run(source=source, sensor_type=sensor_type)
# The new session's store is cleared *before* the charts are attached to
# it: attaching first rebuilt the window from the previous session's
# samples, so every run started with the old curve's tail on screen.
self._session_counter += 1
self.session_store.reset(
session_id=self._session_counter, calibration_id=""
session_id=self.session.session_counter, calibration_id=""
)
self.force_plots.set_store(self.session_store)
# A new session is a live session: whichever view is on screen, the
@@ -2402,15 +2410,9 @@ class StudioMainWindow(QMainWindow):
self.force_plots.follow_latest()
# A new session invalidates the previous recording.
self._stop_recording(wait=True)
self._ensure_workers()
self._active_worker = (
self._finger_worker if sensor_type == "finger" else self._box_worker
)
# Register the record channel on the worker we are about to start, so
# it is wired even if the worker was installed by something other than
# ``_ensure_workers`` (``add_sink`` is idempotent).
self._active_worker.add_sink(self._on_packet_recorded)
self._active_worker.add_sink(self._on_packet_for_3d)
self.session.select_worker(sensor_type)
# Both sinks are on the worker we are about to start (``select_worker`` wires
# them, and ``add_sink`` is idempotent): the record channel and the 3D pane.
# The 3D pane has to know that a *new* session is starting: its frames carry
# this session's id, and to the coordinator an unfamiliar id is otherwise a
# stale frame from a previous session — which is how the pane ended up frozen
@@ -2428,17 +2430,14 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
return
self._running = True
self._set_phase("connecting")
self._connect_started_at = time.perf_counter()
self._connect_slow_notified = False
self._raw_fallback_notified = False
# The session is now connecting: it owns the phase, the connect clock and the
# run timer's cadence (the timer *is* the session, so it lives with it).
self.session.mark_running()
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
self.cal_panel.set_running(True)
self.toolbar.set_source_controls_enabled(False)
self.sensor_picker.set_picker_enabled(False)
self.toolbar.set_reset_enabled(False)
self._run_timer.start()
# Keep the stop affordance consistent with the phase we just entered.
self._update_run_enabled()
self._refresh_status_rail()
@@ -2453,7 +2452,6 @@ class StudioMainWindow(QMainWindow):
)
def _stop_run(self, *, reason: str = "user_stop", wait: bool = False) -> None:
self._run_timer.stop()
# Close the experiment first so the reason lands in its event log.
self.recorder.log_event("session_stopped", f"reason={reason}")
self._stop_recording(wait=wait)
@@ -2461,14 +2459,16 @@ class StudioMainWindow(QMainWindow):
# Never wait here: a device open that hangs must not freeze the GUI.
# ``wait=True`` is reserved for shutdown paths.
self._active_worker.stop(wait=wait)
was_connecting = self._session_phase == "connecting"
self._running = False
# The session is over: it stops its own timer and drops the run's telemetry, so
# a stopped session cannot keep reporting the previous run's frame rate. Whether
# it was still connecting is read *before* that — the status line below says
# whether the worker is still releasing the device in the background.
was_connecting = self.session.mark_stopped(reason)
# A recompute pass is over: the transport button goes back to "play".
self._recompute_running = False
if self.toolbar.run_role() == "playback" and self.toolbar.btn_run.isChecked():
self.toolbar.set_run_checked(False)
self.toolbar.refresh_labels()
self._set_phase("error" if reason == "failed" else "idle")
self.cal_panel.set_running(False)
self.toolbar.set_run_checked(False)
self.toolbar.set_reset_enabled(False)
@@ -2478,12 +2478,6 @@ class StudioMainWindow(QMainWindow):
keep_frame = reason in ("ended", "disconnected")
if not keep_frame:
self.arrow_panel.clear_preview(t("panels.arrow_empty"))
self._latest_fps = None
self._latest_process_ms = None
self._latest_capture_fps = 0.0
self._latest_capture_dropped = 0
self._capture_checked = False
self._last_sample_valid = None
self._last_display_fallback = None
self._last_display_available = None
self._refresh_status_rail()
@@ -2545,21 +2539,6 @@ class StudioMainWindow(QMainWindow):
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
def _current_frame_id(self) -> int:
"""The most recent frame id in the session store (-1 when unknown)."""
try:
frame_ids = self.session_store.data().frame_ids
except Exception: # noqa: BLE001 - an empty store is not an error here
return -1
return int(frame_ids[-1]) if len(frame_ids) else -1
def _on_worker_error(self, message: str) -> None:
self._last_session_error = message
logger.error("Acquisition error: %s", message)
def _on_worker_status(self, message: str) -> None:
logger.info("%s", message)
def _on_run_tick(self) -> None:
# The 3D pane advances on this timer rather than one of its own: the window's
# refresh rate is then the only one that exists, and hiding or disabling the
@@ -2573,15 +2552,14 @@ class StudioMainWindow(QMainWindow):
# the button and the summary.
self._drain_record_alarms()
self._poll_record_report()
if self._last_session_error:
err = self._last_session_error
self._last_session_error = ""
self._active_worker.consume_end_reason()
err = self.session.take_session_error()
if err:
self.session.consume_end_reason()
self._stop_run(reason="failed")
self.statusBar().showMessage(t("status.run_failed", error=err))
return
end_reason = self._active_worker.consume_end_reason()
end_reason = self.session.consume_end_reason()
if end_reason in ("ended", "disconnected"):
self._stop_run(reason=end_reason)
return
@@ -2591,7 +2569,7 @@ class StudioMainWindow(QMainWindow):
return
if not self._active_worker.is_running and self._running:
leftover = self._active_worker.consume_end_reason()
leftover = self.session.consume_end_reason()
if leftover in ("ended", "disconnected"):
self._stop_run(reason=leftover)
elif leftover == "error":
@@ -2603,11 +2581,9 @@ class StudioMainWindow(QMainWindow):
self._stop_run(reason="user_stop")
return
if not self._connect_slow_notified and self._session_phase == "connecting":
if self._session_phase == "connecting" and self.session.note_connect_slow():
# The worker may be stuck opening a device; make sure the user knows
# the (non-blocking) Stop button is the way out.
if time.perf_counter() - self._connect_started_at >= CONNECT_SLOW_HINT_S:
self._connect_slow_notified = True
self.statusBar().showMessage(t("status.connect_slow"))
packet = self._active_worker.latest()
@@ -2616,9 +2592,7 @@ class StudioMainWindow(QMainWindow):
packet_key = (int(packet.session_id), float(packet.timestamp))
is_new = packet_key != self._last_plot_packet_key
if self._session_phase == "connecting" and is_new:
self._set_phase("running")
self._connect_slow_notified = True
if is_new and self.session.note_first_frame():
self.toolbar.set_reset_enabled(True)
self._sync_shortcut_actions()
# No status-bar line here: the rail's phase chip says 采集中 and the
@@ -2641,12 +2615,12 @@ class StudioMainWindow(QMainWindow):
# Announced once per run, not once per frame: a notice that is
# re-issued every 16 ms never expires, and the rail already
# carries the calibration state.
if not self._raw_fallback_notified:
self._raw_fallback_notified = True
if not self.session.raw_fallback_notified:
self.session.raw_fallback_notified = True
self._notify(t("status.units_raw_fallback"))
else:
self._raw_fallback_notified = False
self._last_sample_valid = True
self.session.raw_fallback_notified = False
self.session.last_sample_valid = True
self._last_display_fallback = bool(display.using_raw_fallback)
self._last_display_available = tuple(bool(flag) for flag in display.available)
self.force_plots.push(
@@ -2661,8 +2635,10 @@ class StudioMainWindow(QMainWindow):
available=display.available,
)
else:
self._last_sample_valid = False
self._report_invalid_sample(record.invalid_reason)
self.session.last_sample_valid = False
self.statusBar().showMessage(
self.session.invalid_sample_message(record.invalid_reason)
)
# The calibration panel is a *view*: sampling is driven by the
# record channel, so hiding the panel can no longer stall it.
self.cal_panel.set_live(
@@ -2676,7 +2652,10 @@ class StudioMainWindow(QMainWindow):
self.cal_panel.poll_recorder()
self._drain_calibration_events()
self._drain_reset_events()
self._report_dropped_samples()
# Never let the memory bound discard data silently: one line per new batch.
dropped_message = self.session.take_dropped_message(self.session_store.dropped)
if dropped_message:
self.statusBar().showMessage(dropped_message)
mode = self.arrow_panel.view_mode()
if (is_new or mode != self._last_rendered_view_mode) and not (
@@ -2684,55 +2663,17 @@ class StudioMainWindow(QMainWindow):
):
self._render_run_image(packet)
if is_new:
self._latest_fps = getattr(packet, "fps", None)
self._latest_process_ms = getattr(packet, "process_ms", None)
self._latest_capture_fps = float(getattr(packet, "capture_fps", 0.0) or 0.0)
self._latest_capture_dropped = int(
getattr(packet, "capture_dropped", 0) or 0
)
self.session.note_sample_telemetry(packet)
# Once per session, as soon as the driver has confirmed its rate.
self._check_capture_negotiation()
detail = self.session.check_capture_negotiation()
if detail:
self._notify(t("status.capture_slow", detail=detail))
# The rail carries the live telemetry (fps, validity, calibration) and
# the record strip its own numbers. Both compare before repainting, so
# this is safe on a 16 ms timer.
self._refresh_status_rail()
self._refresh_record_state()
def _session_time(self, packet) -> float:
"""Session-relative acquisition time for the live view.
The session store already defines the origin (its first appended sample),
and ``forces.csv`` uses the same base. Deriving the live time from that
one origin is what keeps the live buffer, a rebuilt history and the replay
on a single X axis.
"""
origin = self.session_store.origin_time()
absolute = getattr(packet, "mono_t", None)
if absolute is None:
absolute = getattr(packet, "timestamp", 0.0)
try:
return float(absolute) - float(origin)
except (TypeError, ValueError):
return 0.0
def _report_invalid_sample(self, reason: str) -> None:
"""Surface failed readouts instead of letting them pass as data.
The session phase is deliberately left alone: a single dropped sample
must not lock the session into ``error`` (that would also disable
reset). The count makes a persistent failure visible.
"""
self.statusBar().showMessage(
t("status.sample_invalid", count=self._record_error_count, reason=reason)
)
def _report_dropped_samples(self) -> None:
"""Never let the memory bound discard data silently."""
dropped = self.session_store.dropped
if dropped > self._dropped_reported:
self._dropped_reported = dropped
self.statusBar().showMessage(t("status.samples_dropped", count=dropped))
# ------------------------------------------------------------ depth scale
def _depth_range(self) -> tuple[float, float] | None:
"""The colour scale, latched to the first frame that carries depth.
@@ -2880,13 +2821,9 @@ class StudioMainWindow(QMainWindow):
def closeEvent(self, event) -> None: # noqa: N802
self._stop_preview(announce=False)
# Detach the record channel first: a worker that is still winding down
# must not keep a reference to this window (and through it to widgets
# that Qt is about to destroy).
for worker in (self._box_worker, self._finger_worker):
if worker is not None and hasattr(worker, "remove_sink"):
worker.remove_sink(self._on_packet_recorded)
worker.remove_sink(self._on_packet_for_3d)
# Detach the sinks first: a worker that is still winding down must not keep a
# reference to this window (and through it to widgets Qt is about to destroy).
self.session.detach_sinks()
# Blocking: on the way out we must not leave a device handle or a
# half-written experiment behind.
self._stop_run(reason="user_stop", wait=True)
@@ -0,0 +1,423 @@
"""The acquisition session: which worker runs, in which phase, and what it reports.
The window used to own all of this *and* the widgets around it, which is why one file
had 155 methods and a fan-out of 36. The split is by responsibility, not by convenience:
* this controller owns the **session state** — the workers and which one is active, the
phase, the session counter, and the per-session telemetry (frame rate, process cost,
capture rate and drops, how many readouts failed) — plus the UI-free mechanics that
drive it (start/stop bookkeeping, end-reason handling, capture negotiation, the record
channel's per-packet bookkeeping, the record report poll);
* the window owns **what the operator sees**: buttons, the rail, the status bar, the
panels, and the 16 ms presenter slot (``_on_run_tick``) that reads this state and
paints it. Messages that belong on screen are returned or emitted, never printed here.
It owns one Qt object — the run timer — because the timer *is* the session's cadence, and
a timer whose lifetime the window decides is a timer that can outlive its session.
No widgets are referenced from this module: the tests and the dependency-free UI checker
drive the window, and the window's transitional shims (``self._running``,
``self._active_worker``, …) forward here while the remaining slices of the split land.
"""
from __future__ import annotations
import logging
import time
from typing import Any, Callable, Optional
from PyQt5.QtCore import QObject, QTimer, pyqtSignal
from orisys_app_shared.i18n import t
from orisys_studio import session_metadata
logger = logging.getLogger(__name__)
#: The phases a session can be in; anything else is normalised to ``idle``.
SESSION_PHASES = ("idle", "connecting", "running", "error")
#: After this long in ``connecting``, the operator is told that Stop is the way out.
CONNECT_SLOW_HINT_S = 8.0
#: Cadence of the window's run timer, in milliseconds.
RUN_TICK_MS = 16
class SessionController(QObject):
"""Owns the acquisition session's state and its UI-free mechanics."""
#: The run timer fired: the window's presenter slot does the painting.
tick = pyqtSignal()
#: The phase was (re)applied, not only changed: the status chip repaints from it,
#: and an idempotent repaint is cheaper than a second source of truth.
phaseChanged = pyqtSignal(str)
runningChanged = pyqtSignal(bool)
def __init__(
self,
*,
recording: Any,
packet_for_3d: Callable[[Any], None],
packet_sink: Optional[Callable[[Any], None]] = None,
sensor_type: str = "box",
) -> None:
super().__init__()
self._recording = recording
self._packet_for_3d = packet_for_3d
#: The record channel's sink. It defaults to this object's own method; the
#: window may hand in its own bound method instead, because the tests and the
#: dependency-free UI checker assert *which* callable is registered on the
#: worker. That bridge is deleted when those call sites move here.
self._packet_sink = packet_sink if packet_sink is not None else self.note_packet_recorded
self._box_worker: Any = None
self._finger_worker: Any = None
self._active_worker: Any = None
self._active_sensor_type = str(sensor_type)
self._running = False
self._phase = "idle"
self._session_counter = 0
self._session_error = ""
self._run_source_label = ""
self._connect_started_at = 0.0
self._connect_slow_notified = False
self._raw_fallback_notified = False
# --- per-session telemetry (read by the rail, the plots and the record) -----
self.last_sample_valid: Optional[bool] = None
self.latest_fps: Optional[float] = None
self.latest_process_ms: Optional[float] = None
self.latest_capture_fps = 0.0
self.latest_capture_dropped = 0
#: How many readouts failed this session, for the status line's count.
self.record_error_count = 0
#: Samples the memory bound discarded, already reported once.
self.dropped_reported = 0
#: Whether the "camera delivers less than the config asks" note was sent.
self.capture_checked = False
self.run_timer = QTimer(self)
self.run_timer.setInterval(RUN_TICK_MS)
self.run_timer.timeout.connect(self.tick.emit)
# ------------------------------------------------------------------- state
@property
def is_running(self) -> bool:
return self._running
@property
def phase(self) -> str:
return self._phase
@property
def session_counter(self) -> int:
return self._session_counter
@property
def active_sensor_type(self) -> str:
return self._active_sensor_type
@property
def active_worker(self) -> Any:
return self._active_worker
@property
def box_worker(self) -> Any:
return self._box_worker
@property
def finger_worker(self) -> Any:
return self._finger_worker
def set_worker(self, sensor_type: str, worker: Any) -> None:
"""Install a worker for a sensor type (``None`` forgets it, as before a run)."""
if str(sensor_type) == "finger":
self._finger_worker = worker
else:
self._box_worker = worker
@property
def run_source_label(self) -> str:
return self._run_source_label
@property
def session_error(self) -> str:
return self._session_error
@property
def connect_started_at(self) -> float:
return self._connect_started_at
@connect_started_at.setter
def connect_started_at(self, started_at: float) -> None:
"""When the current connect attempt began (the slow hint is measured from it)."""
self._connect_started_at = float(started_at)
@property
def connect_slow_notified(self) -> bool:
return self._connect_slow_notified
@property
def raw_fallback_notified(self) -> bool:
return self._raw_fallback_notified
@raw_fallback_notified.setter
def raw_fallback_notified(self, value: bool) -> None:
self._raw_fallback_notified = bool(value)
@property
def session_active(self) -> bool:
"""True until the worker has fully settled, not just until Stop was hit."""
if self._running:
return True
worker = self._active_worker
return worker is not None and bool(getattr(worker, "is_running", False))
def set_phase(self, phase: str) -> str:
"""Track the acquisition phase. An unknown phase is ``idle``, never a new one."""
wanted = str(phase) if str(phase) in SESSION_PHASES else "idle"
self._phase = wanted
self.phaseChanged.emit(wanted)
return self._phase
def note_first_frame(self) -> bool:
"""The session's first frame arrived: it is running now.
Returns whether this call is what made the change, so the caller can do the
once-per-session UI work (enabling reset, refreshing the shortcuts).
"""
if self._phase != "connecting":
return False
self._connect_slow_notified = True
self.set_phase("running")
return True
def note_connect_slow(self) -> bool:
"""True once, when opening the device has taken longer than the hint threshold.
The worker may be stuck opening a device; the operator has to be told that the
(non-blocking) Stop button is the way out. Said once per session.
"""
if self._connect_slow_notified:
return False
if time.perf_counter() - self._connect_started_at < CONNECT_SLOW_HINT_S:
return False
self._connect_slow_notified = True
return True
def set_running(self, running: bool) -> None:
wanted = bool(running)
if wanted != self._running:
self._running = wanted
self.runningChanged.emit(wanted)
# ----------------------------------------------------------------- workers
def ensure_workers(self) -> None:
"""Create both workers once, with the sinks the session needs.
Both are created even when only one will run: the sensor type can change while
the window is open, and a worker that is created late would miss the record
channel's first packets.
"""
if self._box_worker is not None and self._finger_worker is not None:
return
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
if self._box_worker is None:
self._box_worker = SdkAcquisitionWorker(
on_error=self.note_error,
on_status=self.note_status,
on_calibration_applied=self.note_calibration_applied,
)
if self._finger_worker is None:
self._finger_worker = FingerAcquisitionWorker(
on_error=self.note_error,
on_status=self.note_status,
on_calibration_applied=self.note_calibration_applied,
)
self.wire_sinks()
def wire_sinks(self) -> None:
"""Register the record channel and the 3D pane on both workers (idempotent)."""
for worker in (self._box_worker, self._finger_worker):
if worker is None:
continue
worker.add_sink(self._packet_sink)
# The 3D pane is a second sink on the same thread. It is registered even
# while the pane is off, because a sink whose cost depends on the click
# order is worse than a view that is told to do nothing (the view's own
# enabled flag).
worker.add_sink(self._packet_for_3d)
def detach_sinks(self) -> None:
"""Unregister both sinks. Called on the way out, before Qt tears widgets down.
A worker that is still winding down must not keep a reference to the window
(and through it to widgets that no longer exist).
"""
for worker in (self._box_worker, self._finger_worker):
if worker is None or not hasattr(worker, "remove_sink"):
continue
worker.remove_sink(self._packet_sink)
worker.remove_sink(self._packet_for_3d)
def worker_for(self, sensor_type: str) -> Any:
self.ensure_workers()
return self._finger_worker if str(sensor_type) == "finger" else self._box_worker
def select_worker(self, sensor_type: str) -> Any:
"""Adopt the worker this sensor type runs on. Returns the active worker."""
self._active_sensor_type = str(sensor_type)
self._active_worker = self.worker_for(sensor_type)
self.wire_sinks()
return self._active_worker
# --------------------------------------------------------------- lifecycle
def reset_for_run(self, *, source: str, sensor_type: str) -> None:
"""Start-of-run bookkeeping, before the device is opened.
A new session is a new identity: the counter moves, the telemetry of the
previous run is dropped, and nothing from it may be read as this run's.
"""
self._session_error = ""
self._run_source_label = str(source)
self._active_sensor_type = str(sensor_type)
self._session_counter += 1
self.record_error_count = 0
self.dropped_reported = 0
self.last_sample_valid = None
self.latest_fps = None
self.latest_process_ms = None
self.latest_capture_fps = 0.0
self.latest_capture_dropped = 0
self.capture_checked = False
self._raw_fallback_notified = False
def mark_running(self) -> None:
"""The worker was told to start: enter ``connecting`` and start the timer."""
self.set_running(True)
self.set_phase("connecting")
self._connect_started_at = time.perf_counter()
self._connect_slow_notified = False
self.run_timer.start()
def mark_stopped(self, reason: str) -> bool:
"""The session is over. Returns whether it was still ``connecting``.
The telemetry is dropped here rather than by each caller: a stopped session that
still reports the previous run's frame rate is a number from a session that no
longer exists.
"""
was_connecting = self._phase == "connecting"
self.run_timer.stop()
self.set_running(False)
self.set_phase("error" if reason == "failed" else "idle")
self.latest_fps = None
self.latest_process_ms = None
self.latest_capture_fps = 0.0
self.latest_capture_dropped = 0
self.capture_checked = False
self.last_sample_valid = None
return was_connecting
# ----------------------------------------------------------------- signals
def note_error(self, message: str) -> None:
"""Called from the acquisition thread: plain state only, never Qt."""
self._session_error = str(message)
logger.error("Acquisition error: %s", message)
def note_status(self, message: str) -> None:
logger.info("%s", message)
def take_session_error(self) -> str:
error, self._session_error = self._session_error, ""
return error
def note_packet_recorded(self, packet: Any) -> None:
"""Runs on the **acquisition thread**. Must stay cheap and Qt-free.
The record channel's mechanics are the recording coordinator's; all this keeps
is the UI's own bookkeeping (how many readouts failed).
"""
record = self._recording.on_packet(packet)
if not record.valid:
self.record_error_count += 1
def note_calibration_applied(
self, path: str, version: str, frame_id: int, mono_t: float, session: int
) -> None:
"""Called from the acquisition thread once new coefficients are loaded."""
self._recording.note_calibration_applied(path, version, frame_id, mono_t, session)
def note_sample_telemetry(self, packet: Any) -> None:
"""Adopt one frame's own numbers. Called on the UI thread, once per new frame.
These are *reports*: what the device and the pipeline said about the frame that
just arrived. Nothing here derives a rate from a clock, because a derived rate
hides the difference between a slow camera and a slow consumer.
"""
self.latest_fps = getattr(packet, "fps", None)
self.latest_process_ms = getattr(packet, "process_ms", None)
self.latest_capture_fps = float(getattr(packet, "capture_fps", 0.0) or 0.0)
self.latest_capture_dropped = int(getattr(packet, "capture_dropped", 0) or 0)
def consume_end_reason(self) -> Any:
"""The worker's own verdict on why it stopped, consumed once."""
worker = self._active_worker
consumer = getattr(worker, "consume_end_reason", None)
return consumer() if callable(consumer) else None
# ------------------------------------------------------------- presenter help
def check_capture_negotiation(self) -> Optional[str]:
"""A once-per-session warning when the camera delivers less than configured.
The evidence is the *measured* capture rate, not the driver's reported one:
OpenCV's DSHOW backend echoes ``CAP_PROP_FPS`` back as whatever was set, so a
camera advertising 30 and delivering 15 reports "30.0" (measured on this
project's own hardware). Returns the message, or ``None`` when there is nothing
(yet) to say — the caller decides where it goes.
"""
from orisys_app_shared.acquisition.sdk_worker import short_capture_fallacy
if self.capture_checked:
return None
status = session_metadata.capture_metadata(self._active_worker)
if not status:
return None
measured = float(self.latest_capture_fps or 0.0)
if measured <= 0.0:
return None # no measured rate yet: ask again on the next frame
self.capture_checked = True
return short_capture_fallacy(status, measured_fps=measured)
def session_time(self, packet: Any, origin: float) -> float:
"""Session-relative acquisition time for the live view.
The origin is the session store's (its first appended sample), which
``forces.csv`` uses too: one base for the live buffer, a rebuilt history and the
replay, so the three share an X axis.
"""
absolute = getattr(packet, "mono_t", None)
if absolute is None:
absolute = getattr(packet, "timestamp", 0.0)
try:
return float(absolute) - float(origin)
except (TypeError, ValueError):
return 0.0
def current_frame_id(self, store: Any) -> int:
"""The most recent frame id in a session store (-1 when unknown)."""
try:
frame_ids = store.data().frame_ids
except Exception: # noqa: BLE001 - an empty store is not an error here
return -1
return int(frame_ids[-1]) if len(frame_ids) else -1
def invalid_sample_message(self, reason: str) -> str:
"""The status line for a failed readout (the phase is deliberately untouched)."""
return t("status.sample_invalid", count=self.record_error_count, reason=reason)
def take_dropped_message(self, dropped: int) -> Optional[str]:
"""One message per new batch of samples the memory bound discarded."""
if int(dropped) <= self.dropped_reported or int(dropped) <= 0:
return None
self.dropped_reported = int(dropped)
return t("status.samples_dropped", count=int(dropped))
+4 -4
View File
@@ -1974,7 +1974,7 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
def fake_run() -> None:
started.append(True)
window._running = True
window.session.set_running(True)
window._start_run = fake_run
window.act_recompute.setChecked(True)
@@ -2521,15 +2521,15 @@ def test_the_rail_reads_the_rate_and_what_it_cost(qt_app, make_window):
"""
window = make_window()
window._show_fps = True
window._latest_fps = 16.67
window._latest_process_ms = 58.4
window.session.latest_fps = 16.67
window.session.latest_process_ms = 58.4
window._refresh_status_rail()
text = window.toolbar.source_summary.text()
assert "16.7 FPS" in text, text
assert "58 ms" in text, text
# Without a measured cost the rate is still shown, on its own.
window._latest_process_ms = None
window.session.latest_process_ms = None
window._refresh_status_rail()
assert window.toolbar.source_summary.text().strip().endswith("FPS")
window.close()