把 Studio 主机协议改成单向端口,并让「从视频重算」走分析入口

控制器经 AcquireGate / ImageSink / ShellNotes 与信号接线,采集 tick 用 acquire_presenter 上屏;三维测试证明分析结果已喂 3D 后,重算并入 on_analysis_requested。
验证:apps/studio 下相关 pytest 与 tools/ui_check.py(634 checks)通过。
This commit is contained in:
ykcheungab
2026-10-05 14:00:24 +08:00
parent 25ccffd814
commit 28e955a817
27 changed files with 1764 additions and 745 deletions
@@ -0,0 +1,198 @@
"""Present one acquisition packet onto the shared panels.
The session controller owns *whether* a run is alive; this module owns what a
live packet does to the furniture once the lifecycle has decided the session is
still going. Calibration sampling stays here on purpose: it rides the record
channel, so hiding the calibration panel must not stall it.
The replay view's 33 ms play tick stays in :class:`ReplayController`. Ownership
of the 2D panel is decided here once (``replay_owns_image``) and nowhere else.
"""
from __future__ import annotations
import logging
from typing import Any, Protocol
import numpy as np
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.i18n import t
from orisys_app_shared.record.sample import to_record_sample
from PySide6.QtCore import QObject
logger = logging.getLogger(__name__)
class AcquirePresentHost(Protocol):
"""What the presenter needs from the shell around a live packet."""
session: Any
session_store: Any
toolbar: Any
def session_time(self, packet: Any) -> float: ...
def notify(self, text: str, ms: int = 0) -> None: ...
def update_run_enabled(self) -> None: ...
def refresh_status_rail(self) -> None: ...
def refresh_record_state(self) -> None: ...
def drain_calibration_events(self) -> None: ...
def drain_reset_events(self) -> None: ...
def render_run_image(self, packet: Any) -> None: ...
def replay_owns_image(self) -> bool: ...
def show_status(self, text: str) -> None: ...
class AcquirePresenter(QObject):
"""Force charts, calibration sampling, 3D advance, and the live 2D paint."""
def __init__(
self,
*,
host: AcquirePresentHost,
force_plots: Any,
cal_panel: Any,
cal_recorder: Any,
arrow_panel: Any,
mesh_panel: Any,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._host = host
self._charts = force_plots
self._cal_panel = cal_panel
self._cal_recorder = cal_recorder
self._arrow = arrow_panel
self._mesh = mesh_panel
self._last_plot_packet_key: tuple[int, float] | None = None
self._last_rendered_view_mode: str | None = None
self.last_display_fallback: bool | None = None
self.last_display_available: tuple[bool, bool, bool] | None = None
def drop_render_marker(self) -> None:
"""A recorded frame was on the panel: the next live packet must repaint it."""
self._last_rendered_view_mode = None
def note_rendered_mode(self, mode: str | None) -> None:
"""Remember the mode the 2D sink just painted (live or preview)."""
self._last_rendered_view_mode = None if mode is None else str(mode)
def clear_plot_marker(self) -> None:
"""The next packet is a new sample again (pause resume / tracker reset)."""
self._last_plot_packet_key = None
def tick_3d(self, *, display_paused: bool) -> None:
"""Advance the 3D pane on the session's clock. No second timer."""
if not display_paused:
self._mesh.tick()
def present(self, packet: Any, *, display_paused: bool) -> None:
"""Paint one packet. Caller has already handled session end / errors."""
packet_key = (int(packet.session_id), float(packet.timestamp))
is_new = packet_key != self._last_plot_packet_key
if is_new and self._host.session.note_first_frame():
self._host.update_run_enabled()
if is_new and not display_paused:
self._last_plot_packet_key = packet_key
self._push_forces(packet)
self._cal_panel.set_live(
packet.normal_force_raw,
packet.shear_x_raw,
packet.shear_y_raw,
)
# Finalise calibration sampling on the UI thread once its deadline passes.
self._cal_recorder.tick()
self._cal_panel.poll_recorder()
self._host.drain_calibration_events()
self._host.drain_reset_events()
dropped_message = self._host.session.take_dropped_message(
self._host.session_store.dropped
)
if dropped_message:
self._host.show_status(dropped_message)
mode = self._arrow.view_mode()
if not display_paused and (
(is_new or mode != self._last_rendered_view_mode)
and not self._host.replay_owns_image()
):
self._host.render_run_image(packet)
# ``render_run_image`` resolves the mode onto the panel; note what was
# actually painted so a mode-only change still repaints once.
self._last_rendered_view_mode = self._arrow.view_mode()
if is_new:
self._host.session.note_sample_telemetry(packet)
detail = self._host.session.check_capture_negotiation()
if detail:
self._host.notify(t("status.capture_slow", detail=detail))
self._host.refresh_status_rail()
self._host.refresh_record_state()
def _push_forces(self, packet: Any) -> None:
record = to_record_sample(packet)
if not record.valid:
self._host.session.last_sample_valid = False
self._host.show_status(
self._host.session.invalid_sample_message(record.invalid_reason)
)
return
display = select_display_forces(
units_pref=self._host.toolbar.units(),
sample=record,
)
if display.using_raw_fallback:
if not self._host.session.raw_fallback_notified:
self._host.session.raw_fallback_notified = True
self._host.notify(t("status.units_raw_fallback"))
else:
self._host.session.raw_fallback_notified = False
self._host.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)
flow_p95 = _flow_p95(packet)
contact = getattr(packet, "contact", None)
contact_found = bool(getattr(contact, "found", False)) if contact else False
self._charts.push(
self._host.session_time(packet),
display.values[0],
display.values[1],
display.values[2],
unit=display.unit,
available=display.available,
flow_p95=flow_p95,
contact_found=contact_found,
forces_valid=bool(getattr(packet, "forces_valid", True)),
)
def _flow_p95(packet: Any) -> float | None:
flow = getattr(packet, "flow", None)
if flow is None:
return None
try:
arr = np.asarray(flow, dtype=np.float64)
if arr.ndim != 3 or arr.shape[-1] != 2:
return None
mag = np.linalg.norm(arr, axis=-1)
mask = getattr(packet, "valid_mask", None)
if mask is not None:
m = np.asarray(mask, dtype=bool)
vals = mag[m & np.isfinite(mag)] if m.shape == mag.shape else mag[np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
if vals.size:
return float(np.percentile(vals, 95))
except Exception: # noqa: BLE001
logger.debug("flow p95 skipped", exc_info=True)
return None
+229 -208
View File
@@ -25,7 +25,6 @@ from orisys_app_shared.acquisition.image_backend import (
plan_image,
render_image,
)
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
from orisys_app_shared.force.session import SessionStore
@@ -34,7 +33,6 @@ from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, recordings_dir, sensor_json_path
from orisys_app_shared.prefs import save_preferences
from orisys_app_shared.record import RecordConfig
from orisys_app_shared.record.sample import to_record_sample
from orisys_app_shared.theme import build_qss, clear_theme_cache, color
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QActionGroup, QIcon
@@ -49,6 +47,7 @@ from PySide6.QtWidgets import (
)
from orisys_studio import session_metadata
from orisys_studio.acquire_presenter import AcquirePresenter
from orisys_studio.calibration_service import CalibrationService
from orisys_studio.display_prefs import DisplayPrefsController
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
@@ -74,6 +73,7 @@ from orisys_studio.ui.plot_settings_dialog import (
)
from orisys_studio.ui.sensor_picker import SensorPickerWidget
from orisys_studio.ui.toolbar import ToolbarWidget
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
from orisys_studio.view_layout import ViewLayoutController
logger = logging.getLogger(__name__)
@@ -178,8 +178,6 @@ class StudioMainWindow(QMainWindow):
self._preview_timer = QTimer(self)
self._preview_timer.setInterval(33)
self._preview_timer.timeout.connect(self._on_preview_tick)
self._last_plot_packet_key: tuple[int, float] | None = None
self._last_rendered_view_mode: str | None = None
# ---- replay ---------------------------------------------------------
# One engine for the history view: the recording reader, the cursor and
# the asynchronous depth recomputation. It renders nothing itself; the policy
@@ -222,13 +220,11 @@ class StudioMainWindow(QMainWindow):
#: frame that was already in flight cannot repaint the panels they paused on.
self._display_paused = False
self._active_calibration_id = ""
#: 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
#: Last coefficients file written by the calibration panel (not applied).
self._last_calibration_export = ""
#: Built once the panels exist (see below); until then the status rail
#: treats calibration as unknown.
self.acquire_presenter: AcquirePresenter | None = None
self._depth_range_value: tuple[float, float] | None = None
self._last_depth_frame = None
#: The 2D domain boundary, latched for the session the way the depth range is:
@@ -364,6 +360,22 @@ class StudioMainWindow(QMainWindow):
self.brand_bar.calibration_requested.connect(
self.layout_ctl.on_calibration_requested
)
# Replay asks for a view change or an acquire start/stop by signal: the
# window wires those acts, so the controller never names a private method.
self.replay_ctl.view_requested.connect(self.layout_ctl.apply_preset)
self.replay_ctl.acquire_requested.connect(self._start_run)
self.replay_ctl.acquire_stop_requested.connect(self._on_acquire_stop_requested)
# One presenter paints live packets: force charts, calibration sampling,
# 3D advance and the 2D sink when acquisition owns the image.
self.acquire_presenter = AcquirePresenter(
host=self,
force_plots=self.force_plots,
cal_panel=self.cal_panel,
cal_recorder=self.cal_recorder,
arrow_panel=self.arrow_panel,
mesh_panel=self.mesh3d_panel,
parent=self,
)
self.setStatusBar(_NoticeBar())
# The status bar carries **one-off notices** only (a recording finished, a
@@ -385,7 +397,9 @@ class StudioMainWindow(QMainWindow):
self._restore_3d_settings()
# Visibility and the nav highlight come from the restored view; the
# stored splitter sizes still win at startup (``apply_sizes=False``).
self._apply_view_preset(self._view_preset, apply_sizes=False, persist=False)
self.layout_ctl.apply_preset(
self._view_preset, apply_sizes=False, persist=False
)
self._restore_splitter_sizes()
self._update_run_enabled()
self.cal_panel.bind_recorder(self.cal_recorder)
@@ -511,6 +525,101 @@ class StudioMainWindow(QMainWindow):
self._preview = RawPreviewSession()
return self._preview
# --------------------------------------------------------- shell ports
# Controllers talk to these public names (see ``shell_ports``). The private
# methods below remain as the implementations tests and ``ui_check`` still
# drive; nothing here duplicates state.
@property
def running(self) -> bool:
return self._running
@property
def preview_open(self) -> bool:
return self._preview_open
@property
def session_active(self) -> bool:
return self._session_active
def start_acquire(self) -> None:
self._start_run()
def stop_acquire(self, *, reason: str = "user_stop", wait: bool = False) -> None:
self._stop_run(reason=reason, wait=wait)
def _on_acquire_stop_requested(self, reason: str) -> None:
self._stop_run(reason=str(reason or "user_stop"))
def show_frame(self, frame) -> None:
self._show_image_frame(frame)
def show_analysis_packet(self, packet) -> None:
self._show_analysis_frame(packet)
def drop_render_marker(self) -> None:
self._drop_render_marker()
def session_time(self, packet) -> float:
return self._session_time(packet)
def drain_calibration_events(self) -> None:
self._drain_calibration_events()
def drain_reset_events(self) -> None:
self._drain_reset_events()
def render_run_image(self, packet) -> None:
self._render_run_image(packet)
def replay_owns_image(self) -> bool:
return self._replay_owns_image()
def refresh_record_state(self) -> None:
self._refresh_record_state()
def show_status(self, text: str) -> None:
self.statusBar().showMessage(str(text or ""))
def notify(self, text: str, ms: int = 0) -> None:
if ms:
self._notify(text, ms)
else:
self._notify(text)
def refresh_status_rail(self) -> None:
self._refresh_status_rail()
def persist_prefs(self) -> None:
self._persist_prefs()
def update_run_enabled(self) -> None:
self._update_run_enabled()
def set_source_mode(self, mode: str) -> None:
self._set_source_mode(mode)
def set_display_unit(self, unit: str) -> None:
self._set_display_unit(unit)
def current_source_kind(self) -> str:
return self._current_source_kind()
def analysis_service(self, recording, cancelled=None, *, on_progress=None):
return self._analysis_service(recording, cancelled, on_progress=on_progress)
def bare_video_workspace(self, video):
return self._bare_video_workspace(video)
def device_geometry_provider(self):
return self._device_geometry_provider()
@property
def latest_fps(self):
return self._latest_fps
def repaint_live_frame(self) -> None:
self._repaint_live_frame()
# ------------------------------------------------------- experiment record
@property
def _record_fps(self) -> float:
@@ -823,27 +932,38 @@ class StudioMainWindow(QMainWindow):
def _start_playback(self) -> None:
self.replay_ctl.start_playback()
def _pause_playback(self) -> None:
self.replay_ctl.pause_playback()
def _on_play_shortcut(self) -> None:
self.replay_ctl.on_play_shortcut()
def _stop_replay_activity(self) -> None:
self.replay_ctl.stop_activity()
def _replay_owns_image(self) -> bool:
return self.replay_ctl.owns_image()
def _can_recompute(self) -> bool:
return self.replay_ctl.can_recompute()
def _in_replay_view(self) -> bool:
return self._view_preset == "replay"
def _drop_render_marker(self) -> None:
"""A recorded frame was on the panel: the next live packet must repaint it."""
self._last_rendered_view_mode = None
if self.acquire_presenter is not None:
self.acquire_presenter.drop_render_marker()
@property
def _last_display_fallback(self) -> bool | None:
if self.acquire_presenter is None:
return None
return self.acquire_presenter.last_display_fallback
@_last_display_fallback.setter
def _last_display_fallback(self, value: bool | None) -> None:
if self.acquire_presenter is not None:
self.acquire_presenter.last_display_fallback = value
@property
def _last_display_available(self) -> tuple[bool, bool, bool] | None:
if self.acquire_presenter is None:
return None
return self.acquire_presenter.last_display_available
@_last_display_available.setter
def _last_display_available(self, value: tuple[bool, bool, bool] | None) -> None:
if self.acquire_presenter is not None:
self.acquire_presenter.last_display_available = value
@property
def _play_timer(self):
@@ -1112,7 +1232,7 @@ class StudioMainWindow(QMainWindow):
``recompute`` only while a pass is actually running: the label is a claim
about the pixels on screen, and a stale claim is worse than none.
"""
if self._in_replay_view():
if self.layout_ctl.in_replay_view():
return "recompute" if self._recompute_running else "replay"
return "live"
@@ -1145,13 +1265,6 @@ class StudioMainWindow(QMainWindow):
def _show_finger_3d(self, enabled: bool) -> None:
self.layout_ctl.show_finger_3d = enabled
def _apply_view_preset(
self, preset: str, *, apply_sizes: bool = True, persist: bool = True
) -> None:
self.layout_ctl.apply_preset(
preset, apply_sizes=apply_sizes, persist=persist
)
def _apply_panel_visibility(self) -> None:
self.layout_ctl.apply_panel_visibility()
@@ -1203,11 +1316,12 @@ class StudioMainWindow(QMainWindow):
reason = t("toolbar.playback_disabled")
self.toolbar.set_run_tooltip_reason(reason)
self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing())
# Stop leaves the replay, whatever it is doing: plain playback, a
# recompute pass, or a session the pass started. The operator must never
# have to work out which of those is stuck before they can stop it.
# Stop leaves the replay, whatever it is doing: plain playback, an
# analysis pass, or a leftover acquisition session. The operator must
# never have to work out which of those is stuck before they can stop it.
self.toolbar.set_stop_enabled(
self.replay_ctl.is_playing()
or self.replay_ctl.analysis_running()
or self._recompute_running
or self._session_active
)
@@ -1663,7 +1777,8 @@ class StudioMainWindow(QMainWindow):
# shows next time is what the session will actually be given.
for key, value in filters.as_dict().items():
self.prefs[f"plot_{key}"] = value
self._last_plot_packet_key = None
if self.acquire_presenter is not None:
self.acquire_presenter.clear_plot_marker()
self._persist_prefs()
if not self._running and not self.recorder.is_recording:
# Nothing is streaming: hand the choice over now, so the next session
@@ -1945,6 +2060,7 @@ class StudioMainWindow(QMainWindow):
if self.toolbar.run_role() == "playback":
if (
self.replay_ctl.is_playing()
or self.replay_ctl.analysis_running()
or self._session_active
or self._recompute_running
):
@@ -2132,15 +2248,16 @@ class StudioMainWindow(QMainWindow):
# it for every other product.
enabled=bool(self.prefs.get("raw_auto_zero", True)),
)
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
if self.acquire_presenter is not None:
self.acquire_presenter.clear_plot_marker()
self.acquire_presenter.drop_render_marker()
self.acquire_presenter.last_display_fallback = None
self.acquire_presenter.last_display_available = None
# 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._last_display_fallback = None
self._last_display_available = None
self._depth_range_value = None
self._last_depth_frame = None
# The new session has not established a domain boundary yet; the previous
@@ -2263,30 +2380,6 @@ class StudioMainWindow(QMainWindow):
else:
self.statusBar().showMessage(t("status.run_stopped"))
def _stop_replay_activity(self) -> None:
"""Stop whatever the replay view is doing. The operator's escape hatch.
Two different things can be running there — plain playback of the recorded
timeline, and a recompute pass over the video — and the person in front of the
screen should not have to work out which one is stuck before they can stop it.
So this stops both, clears a recomputed 3D picture, and says what it stopped.
Safe to press when nothing is running (it says so rather than doing nothing
silently, which is what made a stuck state feel stuck).
"""
stopped: list[str] = []
if self._recompute_running or self._session_active:
self._stop_run(reason="user_stop")
stopped.append(t("toolbar.stopped_pass"))
if self._play_timer.isActive():
self._pause_playback()
stopped.append(t("toolbar.stopped_playback"))
self.mesh3d_panel.show_reference_only()
self._update_run_enabled()
if stopped:
self._notify(t("toolbar.stopped", what=", ".join(stopped)))
else:
self._notify(t("toolbar.nothing_to_stop"))
def _on_reset(self) -> None:
"""Zero the *sensor tracker* — or, where there is no device, reset the display.
@@ -2316,7 +2409,8 @@ class StudioMainWindow(QMainWindow):
# estimate stale, so the display baseline is re-learned from the samples
# that follow. The curve history itself is kept.
self.force_plots.request_presentation_reestimate()
self._last_plot_packet_key = None
if self.acquire_presenter is not None:
self.acquire_presenter.clear_plot_marker()
self.statusBar().showMessage(t("status.reset_pending"))
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
@@ -2381,7 +2475,8 @@ class StudioMainWindow(QMainWindow):
else:
# The next packet is a new sample again: without this the first one after a
# resume can be swallowed as a repeat of the last key the tick saw.
self._last_plot_packet_key = None
if self.acquire_presenter is not None:
self.acquire_presenter.clear_plot_marker()
self.statusBar().showMessage(t("status.resumed"))
def _on_pause_action_toggled(self, paused: bool) -> None:
@@ -2421,16 +2516,15 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("status.view_reset"))
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
# pane cannot leave a timer behind. It starts at most one compose on the
# pane's own worker and takes at most one finished frame.
if not self._display_paused:
# While paused the pane holds the frame it had. The acquisition thread has
# already stopped computing, so this only matters for a frame that was in
# flight when the operator pressed pause — which must not be the one frame
# they cannot see.
self.mesh3d_panel.tick()
"""Session lifecycle on the 16 ms clock; painting is :class:`AcquirePresenter`.
Errors, end reasons and a stuck connect stay here — they call stop. A live
packet's charts, calibration sampling and 2D/3D paint live in the presenter
so a replay owning the image cannot be overwritten by accident.
"""
presenter = self.acquire_presenter
if presenter is not None:
presenter.tick_3d(display_paused=self._display_paused)
if self._active_worker is None:
return
# Record bookkeeping happens before anything that can return early: a
@@ -2473,122 +2567,9 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("status.connect_slow"))
packet = self._active_worker.latest()
if packet is None:
if packet is None or presenter is None:
return
packet_key = (int(packet.session_id), float(packet.timestamp))
is_new = packet_key != self._last_plot_packet_key
if is_new and self.session.note_first_frame():
# The phase just became ``running``, which is the session state the rail
# reads (the reset keys off it): ask the one owner of that rule rather
# than stating the enablement a second time here.
self._update_run_enabled()
# No status-bar line here: the rail's phase chip says 采集中 and the
# source slot names the camera, so "运行中(0)" was a second copy of
# the same fact that then stayed on screen for the rest of the session.
if is_new and not self._display_paused:
self._last_plot_packet_key = packet_key
# The live view consumes the *same* standardised measurement as the
# CSV and the session store: one conversion decides what a value
# means, so the chart cannot show something the record does not
# contain (or vice versa).
record = to_record_sample(packet)
if record.valid:
display = select_display_forces(
units_pref=self.toolbar.units(),
sample=record,
)
if display.using_raw_fallback:
# 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.session.raw_fallback_notified:
self.session.raw_fallback_notified = True
self._notify(t("status.units_raw_fallback"))
else:
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)
flow = getattr(packet, "flow", None)
flow_p95 = None
if flow is not None:
try:
import numpy as np
arr = np.asarray(flow, dtype=np.float64)
if arr.ndim == 3 and arr.shape[-1] == 2:
mag = np.linalg.norm(arr, axis=-1)
mask = getattr(packet, "valid_mask", None)
if mask is not None:
m = np.asarray(mask, dtype=bool)
if m.shape == mag.shape:
vals = mag[m & np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
if vals.size:
flow_p95 = float(np.percentile(vals, 95))
except Exception: # noqa: BLE001
flow_p95 = None
contact = getattr(packet, "contact", None)
contact_found = bool(getattr(contact, "found", False)) if contact else False
self.force_plots.push(
# Session-relative time, the same base the store and
# ``forces.csv`` use. The wall clock must never reach the
# buffer: it would put the live axis on a different scale.
self._session_time(packet),
display.values[0],
display.values[1],
display.values[2],
unit=display.unit,
available=display.available,
flow_p95=flow_p95,
contact_found=contact_found,
forces_valid=bool(getattr(packet, "forces_valid", True)),
)
else:
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(
packet.normal_force_raw,
packet.shear_x_raw,
packet.shear_y_raw,
)
# Finalise calibration sampling on the UI thread once its deadline passes.
self.cal_recorder.tick()
self.cal_panel.poll_recorder()
self._drain_calibration_events()
self._drain_reset_events()
# 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 not self._display_paused and (
(is_new or mode != self._last_rendered_view_mode)
and not (self._replay_owns_image())
):
self._render_run_image(packet)
if is_new:
self.session.note_sample_telemetry(packet)
# Once per session, as soon as the driver has confirmed its rate.
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()
presenter.present(packet, display_paused=self._display_paused)
# ------------------------------------------------------------ depth scale
def _depth_range(self) -> tuple[float, float] | None:
@@ -2649,21 +2630,6 @@ class StudioMainWindow(QMainWindow):
"""
return self._domain_mask.update(frame.valid_mask)
def _replay_owns_image(self) -> bool:
"""True while the 2D panel shows the replay, not the live camera.
Without this the live renderer overwrote the replay's frame on every
packet *and* on every preview tick, so a replay running next to an active
session (or a preview) looked as if it had opened the camera instead of
the recording. A recompute pass is the pipeline drawing again, so it owns
the panel while it runs.
"""
return (
self._view_preset == "replay"
and self.replay.has_video()
and not self._recompute_running
)
def _render_run_image(self, packet) -> None:
if packet is None or self._replay_owns_image():
return
@@ -2702,7 +2668,8 @@ class StudioMainWindow(QMainWindow):
self.arrow_panel.set_plan(plan)
mode = plan.resolve(self.arrow_panel.view_mode())
self.arrow_panel.set_view_mode(mode)
self._last_rendered_view_mode = mode
if self.acquire_presenter is not None:
self.acquire_presenter.note_rendered_mode(mode)
draw_style = self._draw_style()
# The legend is decided by the same inputs as the picture: the arrow
# magnification of this sensor's draw style, and whether this frame
@@ -2726,7 +2693,9 @@ class StudioMainWindow(QMainWindow):
depth_range=self._depth_range(),
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
domain_mask=boundary,
arrow_policy=self._arrow_policy(),
# A frame read from a stored analysis result says which policy its *recovered*
# field can be sampled with; a live field is dense and takes the display's own.
arrow_policy=frame.arrow_policy or self._arrow_policy(),
# The 2D presentation of the finger's unrolled canvas. Display only: the
# frame's own flow/mask/image are handed over untouched, and the 3D pane is
# not involved at all (its frame is the design export's).
@@ -2762,19 +2731,71 @@ class StudioMainWindow(QMainWindow):
now. ``cancelled`` is the pass's cancellation event and ``on_progress`` its
progress callback; both are ``None`` for a caller that only wants to ask whether
a compatible result is already stored.
Two facts a *generated* workspace adds and a recording never has: the calibration
the operator chose (a recording names the curve in force, not the file), and
whether to store the chain's own per-frame force. Beside a recording, force is a
measurement in ``forces.csv`` and the pass stores none of it; over a bare video
there is no CSV, so the chain's recomputation is all there is and ``synthetic`` in
the session is what says so.
"""
from orisys_app_shared.analysis.service import AnalysisService, chain_spec_for
from orisys_app_shared.record import RecordingReader
session = RecordingReader(Path(recording)).session()
chain = chain_spec_for(session)
synthetic = dict(session.get("synthetic") or {})
chain = chain_spec_for(
session, calibration=synthetic.get("calibration") or None
)
return AnalysisService(
Path(recording),
chain=chain,
cancelled=cancelled,
on_progress=on_progress,
store_force=bool(synthetic),
)
# ------------------------------------------------------ bare video (T1 setup)
def _bare_video_workspace(self, video: Path) -> Path | None:
"""Ask how to analyse a bare video, then generate the workspace to do it in.
The three answers a video cannot carry — the profile, the calibration and the
reference frame — are asked for in one prompt whose defaults come from the catalog
(the same catalog the rail's picker lists) and from the profile's own descriptor.
Declining returns ``None`` and leaves the video playing exactly as it did before;
an input that cannot become a workspace raises
:class:`~orisys_app_shared.analysis.bare_video.BareVideoError` with its reason.
The workspace is written under :func:`paths.video_workspace_dir` — the app's own
per-user data — and never beside the video: the frame index and the session
document are the app's derived data, and the folder the operator keeps footage in
is theirs.
"""
from orisys_app_shared.analysis import bare_video
from orisys_app_shared.sensors import scan_sensor_catalog
facts = bare_video.probe(bare_video.resolve_video(video))
catalog = scan_sensor_catalog()
selected = self.sensor_picker.selected_entry()
dialog = VideoAnalysisDialog(
video=Path(video),
facts=facts,
catalog=catalog,
window=self,
initial_profile=selected.ref if selected is not None else "",
theme=self._theme,
)
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
return None
setup = dialog.choice()
entry = dialog.entry()
if setup is None or entry is None:
return None
workspace = bare_video.build_workspace(
Path(video), profile=entry, setup=setup
)
return workspace.directory
def _show_analysis_frame(self, packet) -> None:
"""Hand one stored analysis frame to the 3D pane's producer.
+21 -36
View File
@@ -11,18 +11,21 @@ they do to the panel) and leaves the *dialog* to the window: opening a QDialog a
the filters to the acquisition workers is window work, and the session's filters are not a
display preference.
What it borrows from the window is named by :class:`DisplayHost` — persisting the choice,
the rail, the filter settings the dialog shows, the latest measured rate, and a live
repaint when the 2D view mode changes.
What it borrows from the window is named by
:class:`~orisys_studio.shell_ports.DisplayShell` — persisting the choice, the rail,
the latest measured rate, and a live repaint when the 2D view mode changes. The
filter dialog stays on the window: opening a QDialog and handing filters to the
acquisition workers is not a display preference.
"""
from __future__ import annotations
import logging
from typing import Any, Protocol
from typing import Any
from PySide6.QtCore import QObject
from orisys_studio.shell_ports import DisplayShell
from orisys_studio.ui.plot_settings_dialog import (
DEFAULT_PLOT_DEADBAND_N,
DEFAULT_PLOT_DEADBAND_RAW,
@@ -55,26 +58,8 @@ PLOT_DEADBAND_DEFAULTS = {
"deadband_raw": DEFAULT_PLOT_DEADBAND_RAW,
}
class DisplayHost(Protocol):
"""What the display controller needs from the window.
The names are the window's own (its transitional shims included), because the
window is the only implementation today.
"""
prefs: dict
@property
def _latest_fps(self) -> float | None: ...
def _persist_prefs(self) -> None: ...
def _refresh_status_rail(self) -> None: ...
def _drop_render_marker(self) -> None: ...
def _repaint_live_frame(self) -> None: ...
# Kept as an alias so older imports and tests that name ``DisplayHost`` keep working.
DisplayHost = DisplayShell
class DisplayPrefsController(QObject):
@@ -83,7 +68,7 @@ class DisplayPrefsController(QObject):
def __init__(
self,
*,
host: DisplayHost,
host: DisplayShell,
toolbar: Any,
charts: Any,
arrow_panel: Any,
@@ -153,7 +138,7 @@ class DisplayPrefsController(QObject):
# recording being replayed), which keeps both triplets, instead of appending
# new-unit values onto old ones.
self._charts.set_units(unit)
self._host._persist_prefs()
self._host.persist_prefs()
def on_units_changed(self, units: str) -> None:
self.set_display_unit(
@@ -169,9 +154,9 @@ class DisplayPrefsController(QObject):
self._host.prefs["image_view_mode"] = str(mode)
# The next packet must repaint the panel: the frame that is on screen was drawn
# in the previous mode.
self._host._drop_render_marker()
self._host._persist_prefs()
self._host._repaint_live_frame()
self._host.drop_render_marker()
self._host.persist_prefs()
self._host.repaint_live_frame()
# --------------------------------------------------------------------- plot
def plot_settings(self) -> dict:
@@ -212,7 +197,7 @@ class DisplayPrefsController(QObject):
settings = self.plot_settings()
self._charts.set_window_sec(float(settings["window_sec"]))
self._apply_ranges(settings)
self._host._persist_prefs()
self._host.persist_prefs()
def apply_plot_prefs_to_panel(self) -> None:
"""Show the stored chart range without asking anything (startup path)."""
@@ -243,7 +228,7 @@ class DisplayPrefsController(QObject):
from orisys_studio import session_metadata
return session_metadata.device_sample_rate(
self._host._latest_fps,
self._host.latest_fps,
self._host.prefs.get("measured_fps"),
default=self._default_sample_rate_hz,
)
@@ -276,15 +261,15 @@ class DisplayPrefsController(QObject):
return
deg = self._arrow_panel.rotate_90_ccw()
self._host.prefs["arrow_rotation_deg"] = deg
self._host._persist_prefs()
self._host.persist_prefs()
# ---------------------------------------------------------------------- fps
def on_show_fps_toggled(self, visible: bool) -> None:
self._show_fps = bool(visible)
self._host.prefs["show_fps"] = self._show_fps
# FPS has one home now: the status rail.
self._host._refresh_status_rail()
self._host._persist_prefs()
self._host.refresh_status_rail()
self._host.persist_prefs()
# -------------------------------------------------------------- diagnostics
@property
@@ -308,7 +293,7 @@ class DisplayPrefsController(QObject):
self._show_diagnostics = bool(visible)
self._host.prefs["show_diagnostics"] = self._show_diagnostics
self._apply_diagnostics()
self._host._persist_prefs()
self._host.persist_prefs()
def _apply_diagnostics(self) -> None:
for panel in (self._arrow_panel, self._mesh_panel):
@@ -329,7 +314,7 @@ class DisplayPrefsController(QObject):
"""
self._raw_auto_zero = bool(enabled)
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
self._host._persist_prefs()
self._host.persist_prefs()
self.apply_presentation_prefs()
def on_raw_auto_zero_toggled(self, enabled: bool) -> None:
+267 -192
View File
@@ -13,10 +13,10 @@ and the window when the furniture changes.
What this controller drives directly: the toolbar's replay controls, the charts (the
timeline), the 2D panel, the 3D pane's source label, and the five replay actions. What
it borrows from the window is named by :class:`ReplayHost` — the status line, the rail,
the view switch, the display unit, the single 2D sink, and the run pipeline a recompute
pass is. Those are window-level operations, so the window keeps them and this module
states its dependence on them out loud instead of reaching into a god object.
it borrows from the window is named by :class:`~orisys_studio.shell_ports.ReplayShell`
— acquire-gate reads, the image sink, shell notes, and analysis support. View switches
and acquire start/stop are signals the window wires, so this module never calls a
window private method by name.
"""
from __future__ import annotations
@@ -25,7 +25,7 @@ import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from typing import Any
import numpy as np
from orisys_app_shared.acquisition.image_backend import (
@@ -40,9 +40,10 @@ from orisys_app_shared.acquisition.image_backend import (
ImageFrame,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.force.source import AnalysisCurveSource
from orisys_app_shared.paths import recordings_dir
from PySide6.QtCore import QObject, QTimer
from PySide6.QtWidgets import QFileDialog
from PySide6.QtCore import QObject, QTimer, Signal
from PySide6.QtWidgets import QFileDialog, QWidget
from orisys_studio.analysis_worker import AnalysisReport, AnalysisWorker
from orisys_studio.replay import MODE_ANALYSIS
@@ -52,6 +53,7 @@ from orisys_studio.replay_analysis import (
ResultPolicyError,
policy_divergence,
)
from orisys_studio.shell_ports import ReplayShell
logger = logging.getLogger(__name__)
@@ -60,72 +62,8 @@ logger = logging.getLogger(__name__)
#: recording down.
PLAYBACK_TICK_MS = 33
class ReplayHost(Protocol):
"""What the replay controller needs from the window.
The names are the window's own (its transitional shims included), because the
window is the only implementation today. They are listed here so the boundary is
explicit and so a test can stand in for the window with a fake.
"""
#: Operator preferences (the chosen video path is remembered here).
prefs: dict
@property
def _running(self) -> bool: ...
@property
def _preview_open(self) -> bool: ...
@property
def _session_active(self) -> bool: ...
def _in_replay_view(self) -> bool: ...
def _apply_view_preset(self, preset: str) -> None: ...
def _set_display_unit(self, unit: str) -> None: ...
def _set_source_mode(self, mode: str) -> None: ...
def _show_image_frame(self, frame: ImageFrame) -> None: ...
def _show_analysis_frame(self, packet: Any) -> None:
"""Hand one stored analysis frame to the 3D pane's producer.
The pane is fed through its ordinary path (it snapshots a packet), so a replay
goes through the same adapter, the same domain latch and the same validity
intersection as a live run. The window owns the pane, so it owns this call.
"""
...
def _current_source_kind(self) -> str: ...
def _drop_render_marker(self) -> None: ...
def _notify(self, text: str, ms: int = ...) -> None: ...
def _start_run(self) -> None: ...
def _stop_run(self, *, reason: str = ..., wait: bool = ...) -> None: ...
def _update_run_enabled(self) -> None: ...
def _refresh_status_rail(self) -> None: ...
def _analysis_service(
self, recording: Path, cancelled: Any, *, on_progress: Any = None
) -> Any:
"""A ready-to-run ``AnalysisService`` over ``recording``.
The window owns this because the chain specification comes from the app's
sensor configuration (``chain_spec_for`` over the recording's session), which is
window-level knowledge; the controller owns *when* a pass runs and what is done
with its result. ``cancelled`` is the pass's cancellation event (``None`` when
the caller only wants to ask whether a result is already stored).
"""
...
# Kept as an alias so older imports and tests that name ``ReplayHost`` keep working.
ReplayHost = ReplayShell
@dataclass
@@ -142,11 +80,18 @@ class ReplayActions:
class ReplayController(QObject):
"""The replay view's state machine and the panels it drives."""
#: Ask the shell to show one of the top-level views (``live`` / ``replay`` / …).
view_requested = Signal(str)
#: Ask the shell to start an acquisition chain (the live recompute path).
acquire_requested = Signal()
#: Ask the shell to stop the acquisition chain. Argument is the stop reason.
acquire_stop_requested = Signal(str)
def __init__(
self,
*,
engine: Any,
host: ReplayHost,
host: ReplayShell,
toolbar: Any,
charts: Any,
image_panel: Any,
@@ -170,6 +115,9 @@ class ReplayController(QObject):
#: True while a recompute pass is running: it is a *run* over a recording, so
#: the acquire pipeline owns the panels and the transport stops the pass.
self._recompute_running = False
#: Which top-level view the layout last stated. The layout pushes this via
#: :meth:`set_active_view` so this controller never asks the window by name.
self._active_view = "custom"
#: The stored results the timeline can read from, and the pass in flight.
self.results = ResultLibrary()
self._analysis_worker: AnalysisWorker | None = None
@@ -184,6 +132,11 @@ class ReplayController(QObject):
#: Non-empty when this recording names no chain to analyse with: the key cannot be
#: computed, so neither a cache read nor a pass is possible.
self._analysis_no_chain = ""
#: The generated workspace a bare video is analysed in, once the operator has
#: answered the setup prompt. It is what :meth:`recording_directory` answers with
#: for a video that is not a recording, so every later step — the stored-result
#: lookup, the pass, the charts — reads it exactly as it reads a recording.
self._video_workspace: Path | None = None
self._play_from = 0.0
self._play_started_at = 0.0
self.play_timer = QTimer(self)
@@ -192,6 +145,17 @@ class ReplayController(QObject):
if self._progress is not None:
self._progress.cancel_requested.connect(self.on_analysis_cancel)
def set_active_view(self, preset: str) -> None:
"""The layout states which furniture is up. Replay only reads this flag."""
self._active_view = str(preset or "custom")
def in_replay_view(self) -> bool:
return self._active_view == "replay"
def _dialog_parent(self) -> QWidget | None:
parent = self.parent()
return parent if isinstance(parent, QWidget) else None
# ------------------------------------------------------------------ wiring
def wire(self) -> None:
"""Connect the engine and the charts. Called once, after construction."""
@@ -256,23 +220,15 @@ class ReplayController(QObject):
Without this the live renderer overwrote the replay's frame on every packet
*and* on every preview tick, so a replay running next to an active session (or
a preview) looked as if it had opened the camera instead of the recording. A
recompute pass is the pipeline drawing again, so it owns the panel while it
runs.
a preview) looked as if it had opened the camera instead of the recording.
Analysis results are drawn by this controller too, so an analysis pass keeps
ownership — it does not hand the panel back to the acquisition tick.
"""
return (
self._host._in_replay_view()
and self._engine.has_video()
and not self._recompute_running
)
return self.in_replay_view() and self._engine.has_video()
# ------------------------------------------------------------------ entries
def on_cursor_moved(self, t_rel_s: float) -> None:
if self._recompute_running:
# The recompute pass owns the timeline: its samples are arriving in
# order and the cursor follows them.
return
if self._host._in_replay_view() and self._engine.has_video():
if self.in_replay_view() and self._engine.has_video():
self._engine.set_position(float(t_rel_s))
def on_open_recording(self) -> None:
@@ -284,7 +240,7 @@ class ReplayController(QObject):
"""
start = str(recordings_dir())
chosen, _filter = QFileDialog.getOpenFileName(
self._host,
self._dialog_parent(),
t("playback.open"),
start,
t("playback.video_filter"),
@@ -300,11 +256,11 @@ class ReplayController(QObject):
path as the rail's button: a recorded video is something to *play*, not a
second way to acquire.
"""
if self._host._running or self._host._preview_open:
if self._host.running or self._host.preview_open:
return
start = self._toolbar.video_path() or str(Path.home())
path, _ = QFileDialog.getOpenFileName(
self._host,
self._dialog_parent(),
t("menu.open_video"),
start,
t("playback.video_filter"),
@@ -337,7 +293,7 @@ class ReplayController(QObject):
"""
if self._actions is None:
return
allowed = self._host._in_replay_view()
allowed = self.in_replay_view()
reason = "" if allowed else t("toolbar.replay_view_only")
for action in (
self._actions.open_video,
@@ -353,7 +309,7 @@ class ReplayController(QObject):
def can_recompute(self) -> bool:
"""Whether 「从视频重算」 has something to read: a recording, or a video file."""
if not self._host._in_replay_view():
if not self.in_replay_view():
return False
if self._engine.has_video():
return True
@@ -373,11 +329,11 @@ class ReplayController(QObject):
that is the only way to get *numbers* out of a video that has no recording.
"""
video = Path(video)
if not self._host._in_replay_view():
if not self.in_replay_view():
# Defensive: the entries are disabled outside the replay view, so this only
# happens programmatically — and refusing is better than leaving the window
# with a video source and no replay renderer.
self._host._notify(t("toolbar.replay_view_only"))
self._host.notify(t("toolbar.replay_view_only"))
return False
folder = video.parent if video.is_file() else video
if not (folder / "forces.csv").is_file():
@@ -386,34 +342,41 @@ class ReplayController(QObject):
self._host.prefs["video_path"] = str(video)
# The window's own switch, which also remembers the choice: the file
# menu's source selector and this path must leave the same state.
self._host._set_source_mode("video")
self._host.set_source_mode("video")
self.render_frame()
self._host._notify(t("playback.video_only_opened", name=video.name))
self._host.notify(t("playback.video_only_opened", name=video.name))
self.refresh_state()
# A bare video used to stop here: playable, and unanalysable. It is still
# playable, and now the pass can be run over it — the two files it reads are
# generated for it in an app-owned workspace (Part A's rules unchanged).
self.prepare_bare_video(video)
return True
if video.is_file():
self._toolbar.set_video_path(str(video))
self._host.prefs["video_path"] = str(video)
self._host._set_source_mode("video")
self._host.set_source_mode("video")
if not self._engine.has_video():
# Nothing else is on screen, so the reason belongs in the panel that
# would have shown the frames — not only in a status line that expires.
self._image_panel.clear_preview(t("playback.video_only_panel"))
self.refresh_state()
self._host._notify(t("playback.not_a_recording", name=video.name))
self._host.notify(t("playback.not_a_recording", name=video.name))
return False
if not self.load_recording(folder):
return False
self._toolbar.set_video_path(str(video))
self._host._refresh_status_rail()
self._host.refresh_status_rail()
return True
def load_recording(self, directory: Path) -> bool:
if not self._host._in_replay_view():
self._host._apply_view_preset("replay")
if not self.in_replay_view():
self.view_requested.emit("replay")
# A recording is a recording: whatever workspace the previous bare video was
# analysed in stops being this timeline's input.
self._video_workspace = None
opened = self._engine.open_recording(Path(directory))
if not opened:
self._host._notify(t("playback.open_failed", error=Path(directory).name))
self._host.notify(t("playback.open_failed", error=Path(directory).name))
return False
self.refresh_state()
# Analysis on open: serve the stored result when there is a compatible one, run
@@ -421,16 +384,66 @@ class ReplayController(QObject):
self.on_analysis_requested()
return True
# ------------------------------------------------------------- bare video setup
def prepare_bare_video(self, video: Path) -> bool:
"""Ask how to analyse a bare video, then analyse it — in a generated workspace.
A video with no recording beside it has no ``frames.csv``, no ``session.json`` and no
``forces.csv``, and the pass reads the first two. Rather than teach the pass a second
input shape, the two files it already expects are generated for the video in an
app-owned location (``analysis.bare_video``), and everything after this point is the
*existing* path: the stored-result lookup, the pass, the charts and the 2D canvas
read the workspace exactly as they read a recording.
The prompt is the one thing a recording never needed: a video does not carry the
profile it should be processed with, the calibration or the reference frame. That
question is asked **after** the video opens, so the operator is looking at the
footage they are answering about, and declining leaves today's behaviour — the video
plays, and the panel says no result is coming.
Returns whether a workspace is ready and a result was asked for.
"""
from orisys_app_shared.analysis.bare_video import BareVideoError
self._video_workspace = None
self._clear_analysis_state()
if self.analysis_running():
self._host.notify(t("playback.analysis_busy"))
return False
try:
workspace = self._host.bare_video_workspace(Path(video))
except BareVideoError as exc:
# Refused before anything ran, with the input's own reason: an unreadable
# container, more than one file, a profile with no chain. It is state, not a
# status line, so the panel can say it for as long as it is true.
self._analysis_error = str(exc)
self._host.notify(str(exc))
self.render_frame()
self.refresh_state()
return False
self._clear_analysis_state()
if workspace is None:
# Declined. The video plays, and the panel says no pass was run — which is a
# different sentence from "no pass is possible", and the truthful one.
self._analysis_skip = t("playback.analysis_setup_declined")
self.render_frame()
self.refresh_state()
return False
self._video_workspace = Path(workspace)
self.refresh_state()
self.on_analysis_requested()
return True
def open_latest_recording(self) -> None:
"""Switch to the replay view on the most recent recording."""
from orisys_app_shared.record import list_recordings
if not self._host._in_replay_view():
self._host._notify(t("toolbar.replay_view_only"))
if not self.in_replay_view():
self._host.notify(t("toolbar.replay_view_only"))
return
found = list_recordings(recordings_dir())
if not found:
self._host._notify(t("playback.none_found"))
self._host.notify(t("playback.none_found"))
return
self.load_recording(Path(found[-1]))
@@ -438,9 +451,12 @@ class ReplayController(QObject):
"""A recording was opened (or dropped): re-point everything that reads it."""
self.refresh_state()
if not opened:
# Nothing is open any more, so no generated workspace is this timeline's input
# either. It is keyed by the video, so reopening the same file finds it again.
self._video_workspace = None
self.pause_playback()
return
if self._host._in_replay_view():
if self.in_replay_view():
self.apply_source(True)
self.render_frame()
@@ -465,8 +481,8 @@ class ReplayController(QObject):
if self._actions is not None:
self._actions.recompute.setEnabled(self.can_recompute())
self.sync_actions()
self._host._update_run_enabled()
self._host._refresh_status_rail()
self._host.update_run_enabled()
self._host.refresh_status_rail()
# ---------------------------------------------------------------- transport
def on_replay_position(self, position_s: float) -> None:
@@ -474,21 +490,6 @@ class ReplayController(QObject):
def on_transport_toggled(self, playing: bool) -> None:
"""The rail's primary button in the replay view: play or pause."""
if self.recompute_active():
# A recompute pass is a *run*: play starts the pipeline over the
# video, pause stops it. It can only go forward (the chain is
# stateful), so playing again after the end starts from the top.
if playing:
self._recompute_running = True
self._host._start_run()
elif self._recompute_running:
# The flag is this controller's, so it is cleared here as well as in
# the session stop below: a pass that ended must not keep claiming to
# be running just because the stop came from the transport.
self._recompute_running = False
self._host._stop_run(reason="user_stop")
self._toolbar.set_run_checked(bool(playing) and self._host._running)
return
if playing and self._engine.has_video():
self.start_playback()
else:
@@ -500,7 +501,7 @@ class ReplayController(QObject):
# A zero-length timeline is not something to play, and silently un-checking
# the button is how "pressing 回放 does nothing" felt to the operator.
self._toolbar.set_run_checked(False)
self._host._notify(t("playback.empty_recording"))
self._host.notify(t("playback.empty_recording"))
return
position = self._engine.position()
if position >= duration - 0.02:
@@ -514,7 +515,7 @@ class ReplayController(QObject):
self._toolbar.refresh_labels()
# The stop button appears with the playback: one place decides what the rail
# offers, so it is told here rather than kept in step in two places.
self._host._update_run_enabled()
self._host.update_run_enabled()
def pause_playback(self) -> None:
if self.play_timer.isActive():
@@ -522,7 +523,7 @@ class ReplayController(QObject):
if self._toolbar.btn_run.isChecked():
self._toolbar.set_run_checked(False)
self._toolbar.refresh_labels()
self._host._update_run_enabled()
self._host.update_run_enabled()
def on_play_tick(self) -> None:
"""Advance the playhead from the wall clock, not from the tick count."""
@@ -543,30 +544,36 @@ class ReplayController(QObject):
"""Stop whatever the replay view is doing. The operator's escape hatch.
Two different things can be running there — plain playback of the recorded
timeline, and a recompute pass over the video — and the person in front of the
timeline, and an analysis pass over the video — and the person in front of the
screen should not have to work out which one is stuck before they can stop it.
So this stops both, clears a recomputed 3D picture, and says what it stopped.
So this stops both, clears a derived 3D picture, and says what it stopped.
Safe to press when nothing is running (it says so rather than doing nothing
silently, which is what made a stuck state feel stuck).
"""
stopped: list[str] = []
if self._recompute_running or self._host._session_active:
if self.analysis_running():
self.cancel_analysis()
stopped.append(t("toolbar.stopped_pass"))
if self._recompute_running or self._host.session_active:
# Legacy live-recompute flag, plus any acquisition session left open.
self._recompute_running = False
self._host._stop_run(reason="user_stop")
self.acquire_stop_requested.emit("user_stop")
stopped.append(t("toolbar.stopped_pass"))
if self.play_timer.isActive():
self.pause_playback()
stopped.append(t("toolbar.stopped_playback"))
self._mesh_panel.show_reference_only()
self._host._update_run_enabled()
self._host.update_run_enabled()
if stopped:
self._host._notify(t("toolbar.stopped", what=", ".join(stopped)))
# Deduplicate the pass label when both analysis and a leftover session stop.
unique = list(dict.fromkeys(stopped))
self._host.notify(t("toolbar.stopped", what=", ".join(unique)))
else:
self._host._notify(t("toolbar.nothing_to_stop"))
self._host.notify(t("toolbar.nothing_to_stop"))
# ---------------------------------------------------------------- recompute
def video_segment(self) -> Path | None:
"""The recorded video a recompute pass should read."""
"""The recorded video an analysis pass should read."""
reader = self._engine.reader()
segments = sorted(reader.segments()) if reader is not None else []
if segments:
@@ -577,60 +584,48 @@ class ReplayController(QObject):
return None
def on_recompute_toggled(self, checked: bool) -> None:
"""Off: read the recorded CSV. On: re-run the pipeline over the video.
"""On: the same analysis entry opening a recording uses. Off: cancel / browse.
The CSV is the session's own measurement. Re-running the vision chain over the
recorded (lossy) video answers a different question — "what would this algorithm
version say about that footage" — and it can only run *forward*, because the
chain is stateful, so it is a pass rather than a scrub.
Part B: 「从视频重算」 and the open path share ``on_analysis_requested`` — serve
a compatible cache, or run the offline pass. The live acquisition chain over
the video is gone: analysis results already feed the 2D canvas and the 3D
pane from one cursor (``show_analysis_packet``).
"""
wanted = bool(checked) and self.can_recompute()
if self._actions is not None and self._actions.recompute.isChecked() != wanted:
self._actions.recompute.blockSignals(True)
self._actions.recompute.setChecked(wanted)
self._actions.recompute.blockSignals(False)
self._toolbar.set_recompute_checked(wanted)
self.pause_playback()
if wanted:
segment = self.video_segment()
if segment is None:
self._host._notify(t("playback.no_video"))
if self._actions is not None:
self._actions.recompute.blockSignals(True)
self._actions.recompute.setChecked(False)
self._actions.recompute.blockSignals(False)
# A bare video may have been opened and the setup prompt declined: play-only,
# no workspace. 「从视频重算」 asks again — that is how numbers appear — then
# shares ``on_analysis_requested`` with opening a recording.
if self.recording_directory() is None:
video = self.video_segment()
if video is None:
named = self._toolbar.video_path()
video = Path(named) if named else None
if video is not None and Path(video).is_file() and self._engine.is_video_only():
if not self.prepare_bare_video(Path(video)):
self._set_recompute_checked(False)
return
self._host.notify(t("playback.no_video"))
self._set_recompute_checked(False)
return
# The charts stop reading the CSV; the pipeline's own samples become the
# data, and the 2D panel shows what it produces. The pass starts here so
# the switch is not a blank chart waiting for a second click.
self._charts.clear_recording_source()
self._image_panel.set_source_kind("recompute")
self._toolbar.set_source_mode("video")
self._toolbar.set_video_path(str(segment))
self._host._refresh_status_rail()
self._recompute_running = True
# The 3D pane now says "recomputed", not "replayed": the pass is what feeds
# it, through the ordinary acquisition sink.
self._mesh_panel.set_source_kind(self._host._current_source_kind())
# Playing is what starts the pass: one path in, so the button and the menu
# cannot start it twice.
self._toolbar.set_run_checked(True)
self._toolbar.refresh_labels()
self.on_transport_toggled(True)
self.on_analysis_requested()
return
if self.analysis_running():
self.cancel_analysis()
if self._recompute_running:
# The flag is cleared here, not only by the session stop: it belongs to this
# controller, and a pass that ended must stop claiming to be running.
self._recompute_running = False
self._host._stop_run(reason="user_stop")
# The pass is over, so the last recomputed frame must go: leaving it on
# screen would show a measurement nothing is producing any more.
self.acquire_stop_requested.emit("user_stop")
self._mesh_panel.show_reference_only()
if self._host._in_replay_view():
self._mesh_panel.set_source_kind(self._host._current_source_kind())
if self._engine.is_loaded():
if self.in_replay_view():
self._mesh_panel.set_source_kind(self._host.current_source_kind())
if self._engine.is_loaded() or self._engine.is_video_only():
self.apply_source(True)
# A bare video has no charts to point at the recording, but it does have
# frames to show again now that the pass is over.
self.render_frame()
# ---------------------------------------------------------------- analysis
@@ -704,7 +699,7 @@ class ReplayController(QObject):
policy = policy_for_settings(settings)
if not session.serve_policy(policy):
moved = policy_divergence(session.stored_policy(), policy)
self._host._notify(t("playback.analysis_grid_mismatch", keys="·".join(moved)))
self._host.notify(t("playback.analysis_grid_mismatch", keys="·".join(moved)))
self._engine.redraw()
def on_analysis_requested(self) -> None:
@@ -724,27 +719,27 @@ class ReplayController(QObject):
it (:meth:`result_state`) instead of promising a pass that will not come.
"""
if self.analysis_running():
self._host._notify(t("playback.analysis_busy"))
self._host.notify(t("playback.analysis_busy"))
return
recording = self.recording_directory()
if recording is None:
# No session metadata and no segment: nothing here can be analysed at all,
# which is a different statement from "not analysed yet" (see ``result_state``).
self._clear_analysis_state()
self._host._notify(t("playback.analysis_no_source"))
self._host.notify(t("playback.analysis_no_source"))
self._set_recompute_checked(False)
self.render_frame()
return
self._clear_analysis_state()
if self._activate_stored_result(recording, notify_reuse=True):
return
if self._host._running or self._host._preview_open or self._host._session_active:
if self._host.running or self._host.preview_open or self._host.session_active:
# A pass opens an SDK chain in this process, and so does a session. One at a
# time — and the working directory stays untouched while it runs. The refusal
# is *state*, not a status line: the panel says which one it is, and
# 「从视频重算」 starts the pass the moment the session is stopped.
self._analysis_skip = t("analysis.blocked")
self._host._notify(self._analysis_skip)
self._host.notify(self._analysis_skip)
self._set_recompute_checked(False)
self.render_frame()
return
@@ -791,10 +786,21 @@ class ReplayController(QObject):
return RESULT_NONE, ""
def recording_directory(self) -> Path | None:
"""The recording directory an analysis pass should read."""
"""The directory an analysis pass should read.
Three shapes, one answer: a recording's own folder, the workspace *generated* for a
bare video (see :meth:`prepare_bare_video`), or — for a video named in the toolbar
but not yet opened — a folder that happens to hold a ``session.json``. Everything
downstream (the stored-result lookup, the chain specification, the pass) reads this
one value, which is what keeps a generated workspace from needing a second code
path.
"""
directory = self._engine.directory()
if directory is not None:
return Path(directory)
workspace = self._video_workspace
if workspace is not None and Path(workspace).is_dir():
return Path(workspace)
named = self._toolbar.video_path()
if named:
folder = Path(named)
@@ -815,7 +821,7 @@ class ReplayController(QObject):
serve; whether a pass can then run is the caller's question.
"""
try:
service = self._host._analysis_service(recording, None)
service = self._host.analysis_service(recording, None)
except Exception as exc: # noqa: BLE001 - a recording with no chain is not fatal
# The recording does not say which sensor and configuration produced it, so
# the result key cannot even be computed and no pass could be built. Recorded
@@ -868,7 +874,7 @@ class ReplayController(QObject):
# The rail's own notification (the 3D pane's source line and the 2D footer say
# "analysis result"), not the transient status line: this is a state, and it
# stays true for as long as the timeline is in it.
self._host._refresh_status_rail()
self._host.refresh_status_rail()
if self._progress is not None:
self._progress.reused(frames=frames)
return True
@@ -887,7 +893,7 @@ class ReplayController(QObject):
"""
self.results.open_session(session)
self._engine.use_recording()
self._charts.set_recording_source(self._engine.reader())
self._serve_curves(session)
# The timeline is the recording's, so the panel is told "replay" — the same word
# ``apply_source`` uses for an open recording. It used to be told "analysis",
# which is not a 2D source kind at all: ``ArrowPanel`` knows live/recompute/replay
@@ -941,7 +947,7 @@ class ReplayController(QObject):
def _start_pass(self, recording: Path) -> None:
"""Run the analysis pass on a worker thread, with progress and a cancel."""
worker = AnalysisWorker(
lambda event, on_progress: self._host._analysis_service(
lambda event, on_progress: self._host.analysis_service(
recording, event, on_progress=on_progress
),
parent=self,
@@ -1021,7 +1027,7 @@ class ReplayController(QObject):
)
# The rail states what the timeline is reading from (the source label), so a
# completed pass says so where the state lives rather than in a line that expires.
self._host._refresh_status_rail()
self._host.refresh_status_rail()
self.refresh_state()
def on_analysis_cancel(self) -> None:
@@ -1146,8 +1152,17 @@ class ReplayController(QObject):
# The result this frame came from is the thing that makes arrows and depth
# drawable at all; the plan reads it to decide what to offer.
result_available=True,
# ...and the field it carries is *recovered*: finite only at the candidate
# cells the pass recorded, so it can only be re-selected on the recorded grid.
# The display's own policy is the caller's for a live field, and wrong here.
arrow_policy=self._session_policy(),
)
def _session_policy(self):
"""The sample policy of the result behind the cursor, or ``None`` without one."""
session = self.available_result()
return None if session is None else session.paint_policy()
def render_frame(self) -> None:
"""Draw what the 2D panel should show for the current replay cursor.
@@ -1171,7 +1186,7 @@ class ReplayController(QObject):
Nothing here writes anything: a display change reaches this method through the
engine's ``redraw()`` and rebuilds the picture from the same stored frame.
"""
if not self._host._in_replay_view() or not self._engine.has_video():
if not self.in_replay_view() or not self._engine.has_video():
return
if self._recompute_running:
# The pipeline is drawing its own frames (see ``owns_image``).
@@ -1194,7 +1209,7 @@ class ReplayController(QObject):
else:
frame = None
if frame is not None:
self._host._show_image_frame(
self._host.show_frame(
self._analysis_image_frame(
frame,
source=SOURCE_ANALYSIS if self.analysis_active() else SOURCE_REPLAY,
@@ -1202,7 +1217,7 @@ class ReplayController(QObject):
)
# The same frame, in the shape the 3D pane's producer consumes: one
# cursor, two figures, one frame id.
self._host._show_analysis_frame(session.packet(frame))
self._host.show_analysis_packet(session.packet(frame))
return
image = self._engine.video_frame_bgr()
if image is None:
@@ -1213,7 +1228,7 @@ class ReplayController(QObject):
# that is coming, one that failed or was refused (with its reason), and a frame
# no pass can serve are not the same news as "nothing has run yet".
state, reason = self.result_state()
self._host._show_image_frame(
self._host.show_frame(
ImageFrame(
image,
source=SOURCE_REPLAY,
@@ -1239,22 +1254,82 @@ class ReplayController(QObject):
wanted = "N" if self._charts.unit() == "N" else "raw"
unit = self.unit_for(reader)
self._engine.set_unit(unit)
self._host._set_display_unit(unit)
self._host.set_display_unit(unit)
if unit != wanted:
self._host._notify(t("status.units_raw_fallback"))
self._host.notify(t("status.units_raw_fallback"))
self._charts.show_all()
self._image_panel.set_source_kind("replay")
return
if active and self._engine.is_video_only():
# A bare video has no ``forces.csv`` at all. The only force there can be is the
# analysis pass's own recomputation, stored inside the result — so the charts
# read that, or state which thing has to happen before they can.
self._serve_curves()
self._image_panel.set_source_kind("replay")
return
if self._charts.is_recording_source():
self._charts.clear_recording_source()
self._charts.follow_latest()
self._image_panel.set_source_kind("live")
# The panel held a recorded frame: the next live packet must repaint it, so the
# "already rendered" marker is dropped.
self._host._drop_render_marker()
if not self._host._running and not self._host._preview_open:
self._host.drop_render_marker()
if not self._host.running and not self._host.preview_open:
self._image_panel.clear_preview(t("panels.arrow_empty"))
def _serve_curves(self, session: AnalysisSession | None = None) -> str:
"""Point the charts at the force trace this timeline really has, and label it.
Two sources are possible and they are **different measurements of the same
footage**, so the panel gets a different name for each rather than one "replay":
* ``recorded`` — a recording's own ``forces.csv``, written while the device was in
the loop. This is the measurement.
* ``recomputed`` — the analysis result's per-frame force, which the *pass* produced
by re-running the vision chain over the video (``provenance.sources["forces"]``).
A bare video has nothing else, and it must never be read as the first.
When there is neither, the panel is told *why* instead of being left empty under
wording that implies data is coming: nothing has been analysed yet, or the profile's
chain produced no force for this footage at all (a finger chain answers with ``NaN``
for frames it cannot calibrate). Returns which of the three happened.
"""
reader = self._engine.reader()
if reader is not None and (Path(reader.directory) / "forces.csv").is_file():
self._charts.set_recording_source(reader)
return "recorded"
if session is None:
session = self.available_result()
if session is None:
self._charts.set_curve_source(
None, reason=t("chart.trace_not_analysed")
)
return "none"
source = AnalysisCurveSource(session.reader)
first, last = source.extent()
if last > first:
unit = self._video_unit(source)
source.unit = unit
self._engine.set_unit(unit)
self._host.set_display_unit(unit)
self._charts.set_curve_source(source)
self._charts.show_all()
return "recomputed"
self._charts.set_curve_source(None, reason=t("chart.trace_no_force"))
return "none"
def _video_unit(self, source: AnalysisCurveSource) -> str:
"""The unit a recomputed trace can be shown in: the operator's, when it has one.
The same rule :meth:`unit_for` applies to a recording — newton only when there is
something to show in newtons — over the result's own force instead of a CSV.
"""
wanted = "N" if self._charts.unit() == "N" else "raw"
if wanted == "N" and not source.unit_available("N"):
self._host.notify(t("status.units_raw_fallback"))
return "raw"
return wanted
def unit_for(self, reader: Any) -> str:
"""The unit a recording can be shown in: raw unless it has newtons.
@@ -0,0 +1,119 @@
"""Named ports the shell controllers use instead of window private methods.
Views and data sources are different things: a recompute pass can be active while
the operator is looking at the replay furniture, and the picture then comes from
the acquisition session. These ports are named for that responsibility, not for
the three product views.
* :class:`AcquireGate` — whether a chain can start, and (for non-signal callers)
how to start or stop one.
* :class:`ImageSink` — the single place a frame reaches the 2D panel / 3D producer.
* :class:`ShellNotes` — notices, the status rail, preferences, and source/unit
bookkeeping that every controller needs.
* :class:`AnalysisSupport` — building an analysis pass and a bare-video workspace.
Replay emits ``view_requested`` / ``acquire_requested`` / ``acquire_stop_requested``
so the window wires those acts; the ports below are what it *reads* and what it
hands a finished picture to.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Protocol
from orisys_app_shared.acquisition.image_backend import ImageFrame
class AcquireGate(Protocol):
"""Whether a processing chain may open in this process."""
@property
def running(self) -> bool: ...
@property
def preview_open(self) -> bool: ...
@property
def session_active(self) -> bool: ...
def start_acquire(self) -> None: ...
def stop_acquire(self, *, reason: str = "user_stop", wait: bool = False) -> None: ...
class ImageSink(Protocol):
"""The one exit that paints a frame onto the shared panels."""
def show_frame(self, frame: ImageFrame) -> None: ...
def show_analysis_packet(self, packet: Any) -> None: ...
def drop_render_marker(self) -> None: ...
class ShellNotes(Protocol):
"""Notices, the rail, preferences, and source/unit bookkeeping."""
prefs: dict
def notify(self, text: str, ms: int = 0) -> None: ...
def refresh_status_rail(self) -> None: ...
def persist_prefs(self) -> None: ...
def update_run_enabled(self) -> None: ...
def set_source_mode(self, mode: str) -> None: ...
def set_display_unit(self, unit: str) -> None: ...
def current_source_kind(self) -> str: ...
class AnalysisSupport(Protocol):
"""How a replay builds an analysis pass or a bare-video workspace."""
def analysis_service(
self, recording: Path, cancelled: Any = None, *, on_progress: Any = None
) -> Any: ...
def bare_video_workspace(self, video: Path) -> Path | None: ...
class LayoutShell(Protocol):
"""What the layout controller needs from the window."""
prefs: dict
def persist_prefs(self) -> None: ...
def update_run_enabled(self) -> None: ...
def refresh_status_rail(self) -> None: ...
def current_source_kind(self) -> str: ...
def device_geometry_provider(self) -> Any: ...
class DisplayShell(Protocol):
"""What the display-preferences controller needs from the window."""
prefs: dict
@property
def latest_fps(self) -> float | None: ...
def persist_prefs(self) -> None: ...
def refresh_status_rail(self) -> None: ...
def drop_render_marker(self) -> None: ...
def repaint_live_frame(self) -> None: ...
class ReplayShell(AcquireGate, ImageSink, ShellNotes, AnalysisSupport):
"""Combined host a replay controller talks to (usually the main window)."""
@@ -18,6 +18,11 @@ from datetime import datetime
from pathlib import Path
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import (
SHAPE_COMPATIBILITY,
SHAPE_DESCRIPTOR,
SHAPE_UNREADABLE,
)
from PySide6.QtWidgets import (
QDialog,
QFrame,
@@ -29,6 +34,14 @@ from PySide6.QtWidgets import (
from orisys_studio.ui.fit_text import ShrinkableLabel
#: How the catalog's shape identifiers read here. One literal key per kind, so the locale scan
#: sees them and a new kind is a visible fallback rather than a printed identifier.
_SHAPE_KEYS: dict[str, str] = {
SHAPE_DESCRIPTOR: "sensor.catalog_shape_descriptor",
SHAPE_COMPATIBILITY: "sensor.catalog_shape_compatibility",
SHAPE_UNREADABLE: "sensor.catalog_shape_unreadable",
}
class SensorDialog(QDialog):
"""Sensor type / config picker plus the calibration facts that follow it."""
@@ -190,8 +203,10 @@ class SensorDialog(QDialog):
def _refresh_profile(self) -> None:
"""State the selected catalog entry, and the reason when it cannot be started.
Read from the picker rather than re-derived here, so the window and the dialog can never
say two different things about one bundle.
This is the surface that keeps the facts the picker row no longer carries: the identity
the SDK assigned, how the bundle was read and its validation. It is read from the picker
rather than re-derived here, so the window and the dialog can never say two different
things about one bundle.
"""
read_entry = getattr(self.picker, "selected_entry", None)
entry = read_entry() if callable(read_entry) else None
@@ -201,8 +216,8 @@ class SensorDialog(QDialog):
return
line = t(
"sensor.catalog_profile",
family=entry.product_family or t("sensor.family_unidentified"),
profile_id=entry.profile_id or entry.folder,
shape=t(_SHAPE_KEYS.get(entry.shape, _SHAPE_KEYS[SHAPE_UNREADABLE])),
origin=entry.origin,
state=entry.state,
)
@@ -9,9 +9,10 @@ own labels and tooltips instead of a bare "配置" combo.
Both combos are filled from the configuration catalog
(:mod:`orisys_app_shared.sensors`), not from a hard-coded product list: the type
combo offers the families bundles actually exist for, and the config combo offers
the catalog's entries for the chosen family — each labelled with its profile,
origin and the SDK's own verdict. An entry the SDK rejects stays listed, because
that is how its reason reaches the operator, and
the catalog's entries for the chosen family — each labelled with what it is, where
it came from and what the calibration selection gives, so the choice is between
products and outcomes rather than between ``profile_id`` strings. An entry the SDK
rejects stays listed, because that is how its reason reaches the operator, and
:meth:`SensorPickerWidget.start_blocked_reason` states why the current selection
must not be started (see also :meth:`selected_entry`).
"""
@@ -238,10 +239,11 @@ def _family_label(family: str) -> str:
def _entry_label(entry: CatalogEntry) -> str:
"""One combo item: the catalog's label with its family group named in this locale.
"""One combo item: the catalog's operator label, with nothing internal in it.
The catalog's own label is the data form (and is what a log or a test should compare); the
internal marker for "no product family" is replaced here, where translating belongs, so an
operator never reads a sentinel.
The catalog names an entry by its display name, so the internal marker for "no product
family" cannot reach the row today; it is still substituted here because a sentinel is
never something an operator should read, and the item text is the one place it could
surface if an entry ever had to fall back to its family.
"""
return entry.label().replace(FAMILY_UNIDENTIFIED, t("sensor.family_unidentified"))
+29 -35
View File
@@ -11,42 +11,27 @@ What the controller owns: the active preset (``view_preset``), whether the calib
panel is shown (``show_cal``) and whether the 3D pane is on (``show_finger_3d``). Its
initial values come from the preferences, so the window does not keep a second copy.
What it borrows from the window is named by :class:`LayoutHost` — persisting the choice,
the run button's state, the rail, the arrow rotation that follows the sensor, and the
source kind the 3D pane should claim. It drives the panels and the focus of the session's
view directly, and it tells the replay controller that the view changed, because leaving
the replay view is what hands the charts back to the live session.
What it borrows from the window is named by :class:`~orisys_studio.shell_ports.LayoutShell`
— persisting the choice, the run button's state, the rail, and the source kind the 3D
pane should claim. It drives the panels directly, and it tells the replay controller
which view is active, because leaving the replay view is what hands the charts back to
the live session.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Protocol
from typing import Any
from PySide6.QtCore import QObject
from orisys_studio.shell_ports import LayoutShell
logger = logging.getLogger(__name__)
class LayoutHost(Protocol):
"""What the layout controller needs from the window.
The names are the window's own (its transitional shims included), because the
window is the only implementation today.
"""
prefs: dict
def _persist_prefs(self) -> None: ...
def _update_run_enabled(self) -> None: ...
def _refresh_status_rail(self) -> None: ...
def _current_source_kind(self) -> str: ...
def _device_geometry_provider(self): ...
# Kept as an alias so older imports and tests that name ``LayoutHost`` keep working.
LayoutHost = LayoutShell
@dataclass(frozen=True)
@@ -64,7 +49,7 @@ class ViewLayoutController(QObject):
def __init__(
self,
*,
host: LayoutHost,
host: LayoutShell,
splitter: Any,
mesh_panel: Any,
image_panel: Any,
@@ -198,7 +183,7 @@ class ViewLayoutController(QObject):
self._mesh_panel.setVisible(show_3d)
# Where the field comes from: a live view acquires it, a replay has none
# stored and only a recompute pass produces one.
self._mesh_panel.set_source_kind(self._host._current_source_kind())
self._mesh_panel.set_source_kind(self._host.current_source_kind())
self._image_panel.setVisible(show_arrow)
self._charts.setVisible(True)
self._cal_panel.setVisible(show_cal)
@@ -286,6 +271,12 @@ class ViewLayoutController(QObject):
self.apply_panel_visibility()
if apply_sizes and preset in self._splitter_sizes:
self._splitter.setSizes(self.splitter_sizes_for(preset))
# Replay asks "am I in the replay view?" without reaching back into the window.
# Stated *before* apply_source / on_recompute_toggled: turning recompute off
# while still marked as replay would re-attach the recording to the charts.
set_view = getattr(self._replay, "set_active_view", None)
if callable(set_view):
set_view(preset)
if preset == "replay":
# History browsing is the point of this view: the charts read the open
# recording, and the chart cursor is its timeline. Without a recording the
@@ -300,10 +291,10 @@ class ViewLayoutController(QObject):
self._brand_bar.set_active_view(preset if preset != "custom" else None)
self.sync_view_checks()
self._replay.sync_actions()
self._host._update_run_enabled()
self._host._refresh_status_rail()
self._host.update_run_enabled()
self._host.refresh_status_rail()
if persist:
self._host._persist_prefs()
self._host.persist_prefs()
def on_live_requested(self) -> None:
self.apply_preset("live")
@@ -330,8 +321,11 @@ class ViewLayoutController(QObject):
self._host.prefs["view_preset"] = "live"
self._host.prefs["show_cal_panel"] = False
self.apply_panel_visibility()
set_view = getattr(self._replay, "set_active_view", None)
if callable(set_view):
set_view("live")
self._brand_bar.set_active_view("live")
self._host._persist_prefs()
self._host.persist_prefs()
def on_finger_3d_toggled(self, visible: bool) -> None:
if self._picker.sensor_type() != "finger":
@@ -339,7 +333,7 @@ class ViewLayoutController(QObject):
self._show_finger_3d = bool(visible)
self._host.prefs["show_finger_3d"] = self._show_finger_3d
self.apply_panel_visibility()
self._host._persist_prefs()
self._host.persist_prefs()
def on_3d_pane_toggled(self, enabled: bool) -> None:
"""The pane's own switch. Same state as the View menu entry, one owner.
@@ -353,14 +347,14 @@ class ViewLayoutController(QObject):
self._show_finger_3d = bool(enabled)
self._host.prefs["show_finger_3d"] = self._show_finger_3d
self.apply_panel_visibility()
self._host._persist_prefs()
self._host.persist_prefs()
def set_settings_expanded(self, expanded: bool, *, persist: bool = True) -> None:
self._toolbar.set_settings_expanded(bool(expanded))
self._host.prefs["settings_expanded"] = bool(expanded)
self.sync_settings_action()
if persist:
self._host._persist_prefs()
self._host.persist_prefs()
def on_settings_toggled(self, expanded: bool) -> None:
self.set_settings_expanded(expanded)
@@ -387,7 +381,7 @@ class ViewLayoutController(QObject):
self._mesh_panel.set_keys(resolve_scene_keys(self._host.prefs))
self._mesh_panel.set_sensor(
self._picker.sensor_type(),
expected_provider=self._host._device_geometry_provider(),
expected_provider=self._host.device_geometry_provider(),
package_override=self._host.prefs.get("scene3d_package"),
)