拆分 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,
|
||||
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,12 +2581,10 @@ 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"))
|
||||
self.statusBar().showMessage(t("status.connect_slow"))
|
||||
|
||||
packet = self._active_worker.latest()
|
||||
if packet is None:
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user