拆分 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:
@@ -39,7 +39,6 @@ from orisys_app_shared.acquisition.image_backend import (
|
|||||||
plan_image,
|
plan_image,
|
||||||
render_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 import select_display_forces
|
||||||
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
||||||
from orisys_app_shared.force.filters import FilterSettings
|
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.recording import RecordingCoordinator, record_config_from_prefs
|
||||||
from orisys_studio.replay import ReplayEngine
|
from orisys_studio.replay import ReplayEngine
|
||||||
from orisys_studio import session_metadata
|
from orisys_studio import session_metadata
|
||||||
|
from orisys_studio.session_controller import SessionController
|
||||||
from orisys_studio.scene3d.prefs import settings_to_prefs
|
from orisys_studio.scene3d.prefs import settings_to_prefs
|
||||||
from orisys_app_shared.i18n import set_locale, t
|
from orisys_app_shared.i18n import set_locale, t
|
||||||
from orisys_app_shared.logging import report_error
|
from orisys_app_shared.logging import report_error
|
||||||
@@ -90,10 +90,10 @@ MIN_PANEL_WIDTHS = {
|
|||||||
"plots": 340,
|
"plots": 340,
|
||||||
"cal": 300,
|
"cal": 300,
|
||||||
}
|
}
|
||||||
SESSION_PHASES = ("idle", "connecting", "running", "error")
|
|
||||||
#: Top-level views. Each one is a working arrangement of the same panels, not a
|
#: 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.
|
#: separate window: switching views must not cost the operator a dialog.
|
||||||
VIEW_PRESETS = ("custom", "live", "replay", "calibration")
|
VIEW_PRESETS = ("custom", "live", "replay", "calibration")
|
||||||
|
|
||||||
#: Splitter sizes per view, in panel order (3D, 2D, charts, calibration).
|
#: Splitter sizes per view, in panel order (3D, 2D, charts, calibration).
|
||||||
#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``).
|
#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``).
|
||||||
#: Replay is the live layout on purpose: the two views differ in their source
|
#: 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
|
#: 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".
|
#: "not in the table" must not mean "standing at its minimum width".
|
||||||
DEFAULT_3D_PANE_WIDTH = 340
|
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
|
#: 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
|
#: cursor is advanced from the wall clock, so a missed tick does not slow the
|
||||||
#: recording down.
|
#: recording down.
|
||||||
@@ -166,29 +164,14 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._view_preset = preset if preset in VIEW_PRESETS else "custom"
|
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_finger_3d = bool(self.prefs.get("show_finger_3d", True))
|
||||||
self._show_fps = bool(self.prefs.get("show_fps", False))
|
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.
|
#: True while the run over the video of the recompute mode is going.
|
||||||
self._recompute_running = False
|
self._recompute_running = False
|
||||||
self._preview = None
|
self._preview = None
|
||||||
self._preview_timer = QTimer(self)
|
self._preview_timer = QTimer(self)
|
||||||
self._preview_timer.setInterval(33)
|
self._preview_timer.setInterval(33)
|
||||||
self._preview_timer.timeout.connect(self._on_preview_tick)
|
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_plot_packet_key: tuple[int, float] | None = None
|
||||||
self._last_rendered_view_mode: str | None = None
|
self._last_rendered_view_mode: str | None = None
|
||||||
self._run_source_label = ""
|
|
||||||
# ---- replay ---------------------------------------------------------
|
# ---- replay ---------------------------------------------------------
|
||||||
# One engine for the history view: the recording reader, the cursor and
|
# One engine for the history view: the recording reader, the cursor and
|
||||||
# the asynchronous depth recomputation. It renders nothing itself; the
|
# the asynchronous depth recomputation. It renders nothing itself; the
|
||||||
@@ -211,28 +194,27 @@ class StudioMainWindow(QMainWindow):
|
|||||||
cal_recorder=self.cal_recorder,
|
cal_recorder=self.cal_recorder,
|
||||||
)
|
)
|
||||||
self.recorder = self.recording.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._active_calibration_id = ""
|
||||||
self._record_error_count = 0
|
|
||||||
self._dropped_reported = 0
|
|
||||||
#: Last display-unit decision (``None`` before the first sample): the
|
#: Last display-unit decision (``None`` before the first sample): the
|
||||||
#: status rail's calibration readout is derived from it, because an axis
|
#: status rail's calibration readout is derived from it, because an axis
|
||||||
#: without a newton curve is exactly an axis without calibration.
|
#: without a newton curve is exactly an axis without calibration.
|
||||||
self._last_display_fallback: bool | None = None
|
self._last_display_fallback: bool | None = None
|
||||||
self._last_display_available: tuple[bool, bool, 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).
|
#: Last coefficients file written by the calibration panel (not applied).
|
||||||
self._last_calibration_export = ""
|
self._last_calibration_export = ""
|
||||||
self._depth_range_value: tuple[float, float] | None = None
|
self._depth_range_value: tuple[float, float] | None = None
|
||||||
@@ -330,6 +312,107 @@ class StudioMainWindow(QMainWindow):
|
|||||||
def _preview_open(self) -> bool:
|
def _preview_open(self) -> bool:
|
||||||
return self._preview is not None and bool(self._preview.is_open)
|
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):
|
def _get_preview(self):
|
||||||
if self._preview is None:
|
if self._preview is None:
|
||||||
from orisys_app_shared.camera import RawPreviewSession
|
from orisys_app_shared.camera import RawPreviewSession
|
||||||
@@ -337,33 +420,6 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._preview = RawPreviewSession()
|
self._preview = RawPreviewSession()
|
||||||
return self._preview
|
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
|
# ------------------------------------------------------- experiment record
|
||||||
@property
|
@property
|
||||||
def _record_fps(self) -> float:
|
def _record_fps(self) -> float:
|
||||||
@@ -598,28 +654,6 @@ class StudioMainWindow(QMainWindow):
|
|||||||
"""Requested vs negotiated capture parameters, for ``session.json``."""
|
"""Requested vs negotiated capture parameters, for ``session.json``."""
|
||||||
return session_metadata.capture_metadata(self._active_worker)
|
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:
|
def _filter_metadata(self) -> dict:
|
||||||
"""The filters this session actually ran, for ``session.json``.
|
"""The filters this session actually ran, for ``session.json``.
|
||||||
|
|
||||||
@@ -640,12 +674,12 @@ class StudioMainWindow(QMainWindow):
|
|||||||
def _on_packet_recorded(self, packet) -> None:
|
def _on_packet_recorded(self, packet) -> None:
|
||||||
"""Runs on the **acquisition thread**. Must stay cheap and Qt-free.
|
"""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 record channel's mechanics — including how many readouts failed — are the
|
||||||
the UI's own bookkeeping (how many readouts failed).
|
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)
|
self.session.note_packet_recorded(packet)
|
||||||
if not record.valid:
|
|
||||||
self._record_error_count += 1
|
|
||||||
|
|
||||||
def _wire_toolbar(self) -> None:
|
def _wire_toolbar(self) -> None:
|
||||||
self.toolbar.search_clicked.connect(self._on_search_cameras)
|
self.toolbar.search_clicked.connect(self._on_search_cameras)
|
||||||
@@ -1487,17 +1521,13 @@ class StudioMainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
self.statusBar().showMessage(message, int(ms))
|
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:
|
def _refresh_status_chip(self) -> None:
|
||||||
chip = getattr(self, "status_chip", None)
|
chip = getattr(self, "status_chip", None)
|
||||||
if chip is None:
|
if chip is None:
|
||||||
return
|
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}"
|
token = f"status_{phase}"
|
||||||
# One dot carries the colour; the text says what the state is, so the
|
# One dot carries the colour; the text says what the state is, so the
|
||||||
# colour is never the only encoding.
|
# colour is never the only encoding.
|
||||||
@@ -2004,12 +2034,6 @@ class StudioMainWindow(QMainWindow):
|
|||||||
for _event in self.recording.drain_reset_events():
|
for _event in self.recording.drain_reset_events():
|
||||||
self.statusBar().showMessage(t("status.reset_ok"))
|
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:
|
def _on_calibration_point(self, point) -> None:
|
||||||
"""Called on the UI thread when a calibration point finishes."""
|
"""Called on the UI thread when a calibration point finishes."""
|
||||||
self.cal_panel.on_record_finished(point)
|
self.cal_panel.on_record_finished(point)
|
||||||
@@ -2155,14 +2179,6 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._persist_prefs()
|
self._persist_prefs()
|
||||||
|
|
||||||
# ----------------------------------------------------------- shortcuts
|
# ----------------------------------------------------------- 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:
|
def _sync_shortcut_actions(self) -> None:
|
||||||
if getattr(self, "act_run", None) is None:
|
if getattr(self, "act_run", None) is None:
|
||||||
return
|
return
|
||||||
@@ -2369,32 +2385,24 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.force_plots.reset()
|
self.force_plots.reset()
|
||||||
self._last_plot_packet_key = None
|
self._last_plot_packet_key = None
|
||||||
self._last_rendered_view_mode = 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
|
# Fresh record channel for the new session. The calibration identity is
|
||||||
# unknown until the device confirms which coefficients it loaded, so it
|
# unknown until the device confirms which coefficients it loaded, so it
|
||||||
# starts empty and is filled from that confirmation — a path is not a
|
# starts empty and is filled from that confirmation — a path is not a
|
||||||
# version, and the same path can hold different numbers.
|
# version, and the same path can hold different numbers.
|
||||||
self._active_calibration_id = ""
|
self._active_calibration_id = ""
|
||||||
self._record_error_count = 0
|
|
||||||
self._dropped_reported = 0
|
|
||||||
self._last_display_fallback = None
|
self._last_display_fallback = None
|
||||||
self._last_display_available = 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._depth_range_value = None
|
||||||
self._last_depth_frame = 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
|
# The new session's store is cleared *before* the charts are attached to
|
||||||
# it: attaching first rebuilt the window from the previous session's
|
# it: attaching first rebuilt the window from the previous session's
|
||||||
# samples, so every run started with the old curve's tail on screen.
|
# samples, so every run started with the old curve's tail on screen.
|
||||||
self._session_counter += 1
|
|
||||||
self.session_store.reset(
|
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)
|
self.force_plots.set_store(self.session_store)
|
||||||
# A new session is a live session: whichever view is on screen, the
|
# 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()
|
self.force_plots.follow_latest()
|
||||||
# A new session invalidates the previous recording.
|
# A new session invalidates the previous recording.
|
||||||
self._stop_recording(wait=True)
|
self._stop_recording(wait=True)
|
||||||
self._ensure_workers()
|
self.session.select_worker(sensor_type)
|
||||||
self._active_worker = (
|
# Both sinks are on the worker we are about to start (``select_worker`` wires
|
||||||
self._finger_worker if sensor_type == "finger" else self._box_worker
|
# them, and ``add_sink`` is idempotent): the record channel and the 3D pane.
|
||||||
)
|
|
||||||
# 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)
|
|
||||||
# The 3D pane has to know that a *new* session is starting: its frames carry
|
# 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
|
# 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
|
# 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)))
|
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
|
||||||
return
|
return
|
||||||
|
|
||||||
self._running = True
|
# The session is now connecting: it owns the phase, the connect clock and the
|
||||||
self._set_phase("connecting")
|
# run timer's cadence (the timer *is* the session, so it lives with it).
|
||||||
self._connect_started_at = time.perf_counter()
|
self.session.mark_running()
|
||||||
self._connect_slow_notified = False
|
|
||||||
self._raw_fallback_notified = False
|
|
||||||
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
|
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
|
||||||
self.cal_panel.set_running(True)
|
self.cal_panel.set_running(True)
|
||||||
self.toolbar.set_source_controls_enabled(False)
|
self.toolbar.set_source_controls_enabled(False)
|
||||||
self.sensor_picker.set_picker_enabled(False)
|
self.sensor_picker.set_picker_enabled(False)
|
||||||
self.toolbar.set_reset_enabled(False)
|
self.toolbar.set_reset_enabled(False)
|
||||||
self._run_timer.start()
|
|
||||||
# Keep the stop affordance consistent with the phase we just entered.
|
# Keep the stop affordance consistent with the phase we just entered.
|
||||||
self._update_run_enabled()
|
self._update_run_enabled()
|
||||||
self._refresh_status_rail()
|
self._refresh_status_rail()
|
||||||
@@ -2453,7 +2452,6 @@ class StudioMainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _stop_run(self, *, reason: str = "user_stop", wait: bool = False) -> None:
|
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.
|
# Close the experiment first so the reason lands in its event log.
|
||||||
self.recorder.log_event("session_stopped", f"reason={reason}")
|
self.recorder.log_event("session_stopped", f"reason={reason}")
|
||||||
self._stop_recording(wait=wait)
|
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.
|
# Never wait here: a device open that hangs must not freeze the GUI.
|
||||||
# ``wait=True`` is reserved for shutdown paths.
|
# ``wait=True`` is reserved for shutdown paths.
|
||||||
self._active_worker.stop(wait=wait)
|
self._active_worker.stop(wait=wait)
|
||||||
was_connecting = self._session_phase == "connecting"
|
# The session is over: it stops its own timer and drops the run's telemetry, so
|
||||||
self._running = False
|
# 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".
|
# A recompute pass is over: the transport button goes back to "play".
|
||||||
self._recompute_running = False
|
self._recompute_running = False
|
||||||
if self.toolbar.run_role() == "playback" and self.toolbar.btn_run.isChecked():
|
if self.toolbar.run_role() == "playback" and self.toolbar.btn_run.isChecked():
|
||||||
self.toolbar.set_run_checked(False)
|
self.toolbar.set_run_checked(False)
|
||||||
self.toolbar.refresh_labels()
|
self.toolbar.refresh_labels()
|
||||||
self._set_phase("error" if reason == "failed" else "idle")
|
|
||||||
self.cal_panel.set_running(False)
|
self.cal_panel.set_running(False)
|
||||||
self.toolbar.set_run_checked(False)
|
self.toolbar.set_run_checked(False)
|
||||||
self.toolbar.set_reset_enabled(False)
|
self.toolbar.set_reset_enabled(False)
|
||||||
@@ -2478,12 +2478,6 @@ class StudioMainWindow(QMainWindow):
|
|||||||
keep_frame = reason in ("ended", "disconnected")
|
keep_frame = reason in ("ended", "disconnected")
|
||||||
if not keep_frame:
|
if not keep_frame:
|
||||||
self.arrow_panel.clear_preview(t("panels.arrow_empty"))
|
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_fallback = None
|
||||||
self._last_display_available = None
|
self._last_display_available = None
|
||||||
self._refresh_status_rail()
|
self._refresh_status_rail()
|
||||||
@@ -2545,21 +2539,6 @@ class StudioMainWindow(QMainWindow):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.statusBar().showMessage(t("status.run_failed", error=str(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:
|
def _on_run_tick(self) -> None:
|
||||||
# The 3D pane advances on this timer rather than one of its own: the window's
|
# 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
|
# 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.
|
# the button and the summary.
|
||||||
self._drain_record_alarms()
|
self._drain_record_alarms()
|
||||||
self._poll_record_report()
|
self._poll_record_report()
|
||||||
if self._last_session_error:
|
err = self.session.take_session_error()
|
||||||
err = self._last_session_error
|
if err:
|
||||||
self._last_session_error = ""
|
self.session.consume_end_reason()
|
||||||
self._active_worker.consume_end_reason()
|
|
||||||
self._stop_run(reason="failed")
|
self._stop_run(reason="failed")
|
||||||
self.statusBar().showMessage(t("status.run_failed", error=err))
|
self.statusBar().showMessage(t("status.run_failed", error=err))
|
||||||
return
|
return
|
||||||
|
|
||||||
end_reason = self._active_worker.consume_end_reason()
|
end_reason = self.session.consume_end_reason()
|
||||||
if end_reason in ("ended", "disconnected"):
|
if end_reason in ("ended", "disconnected"):
|
||||||
self._stop_run(reason=end_reason)
|
self._stop_run(reason=end_reason)
|
||||||
return
|
return
|
||||||
@@ -2591,7 +2569,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not self._active_worker.is_running and self._running:
|
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"):
|
if leftover in ("ended", "disconnected"):
|
||||||
self._stop_run(reason=leftover)
|
self._stop_run(reason=leftover)
|
||||||
elif leftover == "error":
|
elif leftover == "error":
|
||||||
@@ -2603,12 +2581,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._stop_run(reason="user_stop")
|
self._stop_run(reason="user_stop")
|
||||||
return
|
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 worker may be stuck opening a device; make sure the user knows
|
||||||
# the (non-blocking) Stop button is the way out.
|
# the (non-blocking) Stop button is the way out.
|
||||||
if time.perf_counter() - self._connect_started_at >= CONNECT_SLOW_HINT_S:
|
self.statusBar().showMessage(t("status.connect_slow"))
|
||||||
self._connect_slow_notified = True
|
|
||||||
self.statusBar().showMessage(t("status.connect_slow"))
|
|
||||||
|
|
||||||
packet = self._active_worker.latest()
|
packet = self._active_worker.latest()
|
||||||
if packet is None:
|
if packet is None:
|
||||||
@@ -2616,9 +2592,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
packet_key = (int(packet.session_id), float(packet.timestamp))
|
packet_key = (int(packet.session_id), float(packet.timestamp))
|
||||||
is_new = packet_key != self._last_plot_packet_key
|
is_new = packet_key != self._last_plot_packet_key
|
||||||
|
|
||||||
if self._session_phase == "connecting" and is_new:
|
if is_new and self.session.note_first_frame():
|
||||||
self._set_phase("running")
|
|
||||||
self._connect_slow_notified = True
|
|
||||||
self.toolbar.set_reset_enabled(True)
|
self.toolbar.set_reset_enabled(True)
|
||||||
self._sync_shortcut_actions()
|
self._sync_shortcut_actions()
|
||||||
# No status-bar line here: the rail's phase chip says 采集中 and the
|
# 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
|
# Announced once per run, not once per frame: a notice that is
|
||||||
# re-issued every 16 ms never expires, and the rail already
|
# re-issued every 16 ms never expires, and the rail already
|
||||||
# carries the calibration state.
|
# carries the calibration state.
|
||||||
if not self._raw_fallback_notified:
|
if not self.session.raw_fallback_notified:
|
||||||
self._raw_fallback_notified = True
|
self.session.raw_fallback_notified = True
|
||||||
self._notify(t("status.units_raw_fallback"))
|
self._notify(t("status.units_raw_fallback"))
|
||||||
else:
|
else:
|
||||||
self._raw_fallback_notified = False
|
self.session.raw_fallback_notified = False
|
||||||
self._last_sample_valid = True
|
self.session.last_sample_valid = True
|
||||||
self._last_display_fallback = bool(display.using_raw_fallback)
|
self._last_display_fallback = bool(display.using_raw_fallback)
|
||||||
self._last_display_available = tuple(bool(flag) for flag in display.available)
|
self._last_display_available = tuple(bool(flag) for flag in display.available)
|
||||||
self.force_plots.push(
|
self.force_plots.push(
|
||||||
@@ -2661,8 +2635,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
available=display.available,
|
available=display.available,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._last_sample_valid = False
|
self.session.last_sample_valid = False
|
||||||
self._report_invalid_sample(record.invalid_reason)
|
self.statusBar().showMessage(
|
||||||
|
self.session.invalid_sample_message(record.invalid_reason)
|
||||||
|
)
|
||||||
# The calibration panel is a *view*: sampling is driven by the
|
# The calibration panel is a *view*: sampling is driven by the
|
||||||
# record channel, so hiding the panel can no longer stall it.
|
# record channel, so hiding the panel can no longer stall it.
|
||||||
self.cal_panel.set_live(
|
self.cal_panel.set_live(
|
||||||
@@ -2676,7 +2652,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.cal_panel.poll_recorder()
|
self.cal_panel.poll_recorder()
|
||||||
self._drain_calibration_events()
|
self._drain_calibration_events()
|
||||||
self._drain_reset_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()
|
mode = self.arrow_panel.view_mode()
|
||||||
if (is_new or mode != self._last_rendered_view_mode) and not (
|
if (is_new or mode != self._last_rendered_view_mode) and not (
|
||||||
@@ -2684,55 +2663,17 @@ class StudioMainWindow(QMainWindow):
|
|||||||
):
|
):
|
||||||
self._render_run_image(packet)
|
self._render_run_image(packet)
|
||||||
if is_new:
|
if is_new:
|
||||||
self._latest_fps = getattr(packet, "fps", None)
|
self.session.note_sample_telemetry(packet)
|
||||||
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
|
|
||||||
)
|
|
||||||
# Once per session, as soon as the driver has confirmed its rate.
|
# 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 rail carries the live telemetry (fps, validity, calibration) and
|
||||||
# the record strip its own numbers. Both compare before repainting, so
|
# the record strip its own numbers. Both compare before repainting, so
|
||||||
# this is safe on a 16 ms timer.
|
# this is safe on a 16 ms timer.
|
||||||
self._refresh_status_rail()
|
self._refresh_status_rail()
|
||||||
self._refresh_record_state()
|
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
|
# ------------------------------------------------------------ depth scale
|
||||||
def _depth_range(self) -> tuple[float, float] | None:
|
def _depth_range(self) -> tuple[float, float] | None:
|
||||||
"""The colour scale, latched to the first frame that carries depth.
|
"""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
|
def closeEvent(self, event) -> None: # noqa: N802
|
||||||
self._stop_preview(announce=False)
|
self._stop_preview(announce=False)
|
||||||
# Detach the record channel first: a worker that is still winding down
|
# Detach the sinks first: a worker that is still winding down must not keep a
|
||||||
# must not keep a reference to this window (and through it to widgets
|
# reference to this window (and through it to widgets Qt is about to destroy).
|
||||||
# that Qt is about to destroy).
|
self.session.detach_sinks()
|
||||||
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)
|
|
||||||
# Blocking: on the way out we must not leave a device handle or a
|
# Blocking: on the way out we must not leave a device handle or a
|
||||||
# half-written experiment behind.
|
# half-written experiment behind.
|
||||||
self._stop_run(reason="user_stop", wait=True)
|
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))
|
||||||
@@ -1974,7 +1974,7 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
|
|||||||
|
|
||||||
def fake_run() -> None:
|
def fake_run() -> None:
|
||||||
started.append(True)
|
started.append(True)
|
||||||
window._running = True
|
window.session.set_running(True)
|
||||||
|
|
||||||
window._start_run = fake_run
|
window._start_run = fake_run
|
||||||
window.act_recompute.setChecked(True)
|
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 = make_window()
|
||||||
window._show_fps = True
|
window._show_fps = True
|
||||||
window._latest_fps = 16.67
|
window.session.latest_fps = 16.67
|
||||||
window._latest_process_ms = 58.4
|
window.session.latest_process_ms = 58.4
|
||||||
window._refresh_status_rail()
|
window._refresh_status_rail()
|
||||||
text = window.toolbar.source_summary.text()
|
text = window.toolbar.source_summary.text()
|
||||||
assert "16.7 FPS" in text, text
|
assert "16.7 FPS" in text, text
|
||||||
assert "58 ms" in text, text
|
assert "58 ms" in text, text
|
||||||
|
|
||||||
# Without a measured cost the rate is still shown, on its own.
|
# 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()
|
window._refresh_status_rail()
|
||||||
assert window.toolbar.source_summary.text().strip().endswith("FPS")
|
assert window.toolbar.source_summary.text().strip().endswith("FPS")
|
||||||
window.close()
|
window.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user