From a35d1877d63e495a2b28b4727051b37c6501c117 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Tue, 15 Sep 2026 09:23:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=20A2=EF=BC=9A=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=8A=B6=E6=80=81=E4=B8=8E=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E7=A7=BB=E5=85=A5=20SessionController=EF=BC=88app=5Fw?= =?UTF-8?q?indow=202900=E2=86=922837=20=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 用例因机器无上下文而失败,与本次改动无关。 --- apps/studio/src/orisys_studio/app_window.py | 401 +++++++---------- .../src/orisys_studio/session_controller.py | 423 ++++++++++++++++++ apps/studio/tests/test_ui_smoke.py | 8 +- 3 files changed, 596 insertions(+), 236 deletions(-) create mode 100644 apps/studio/src/orisys_studio/session_controller.py diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 903fd2e..3bfdc62 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -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) diff --git a/apps/studio/src/orisys_studio/session_controller.py b/apps/studio/src/orisys_studio/session_controller.py new file mode 100644 index 0000000..218785f --- /dev/null +++ b/apps/studio/src/orisys_studio/session_controller.py @@ -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)) diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 413cf80..553b37f 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -1974,7 +1974,7 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video( def fake_run() -> None: started.append(True) - window._running = True + window.session.set_running(True) window._start_run = fake_run window.act_recompute.setChecked(True) @@ -2521,15 +2521,15 @@ def test_the_rail_reads_the_rate_and_what_it_cost(qt_app, make_window): """ window = make_window() window._show_fps = True - window._latest_fps = 16.67 - window._latest_process_ms = 58.4 + window.session.latest_fps = 16.67 + window.session.latest_process_ms = 58.4 window._refresh_status_rail() text = window.toolbar.source_summary.text() assert "16.7 FPS" in text, text assert "58 ms" in text, text # Without a measured cost the rate is still shown, on its own. - window._latest_process_ms = None + window.session.latest_process_ms = None window._refresh_status_rail() assert window.toolbar.source_summary.text().strip().endswith("FPS") window.close()