把 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:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user