Present replay 3D with the cursor and open Analyze on a remembered video
Replay left the 3D pane on the rest mesh or a sparse overlay, and Analyze skipped the setup dialog unless the file was re-picked. Live Run no longer starts a chain on a video path. Verified with Studio tests for flush, overlay mask, Analyze open, live-video refusal, and locales.
This commit is contained in:
@@ -169,6 +169,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self.prefs: dict[str, Any] = dict(prefs or {})
|
||||
self._theme = str(self.prefs.get("theme") or "dark")
|
||||
self._language = str(self.prefs.get("language") or "zh_CN")
|
||||
#: Reference-frame override for the next analysis pass on a real recording
|
||||
#: (from View ▸ Analysis setup…). Bare-video workspaces store this in session.json.
|
||||
self._analysis_reference_override: int | None = None
|
||||
# The view preset, the calibration panel, the 3D pane's switch and the fps
|
||||
# readout are the controllers' state; the window reads and writes them through
|
||||
# the shims below.
|
||||
@@ -607,8 +610,11 @@ class StudioMainWindow(QMainWindow):
|
||||
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 bare_video_workspace(self, video, **kwargs):
|
||||
return self._bare_video_workspace(video, **kwargs)
|
||||
|
||||
def recording_analysis_overrides(self, recording):
|
||||
return self._recording_analysis_overrides(recording)
|
||||
|
||||
def device_geometry_provider(self):
|
||||
return self._device_geometry_provider()
|
||||
@@ -1116,13 +1122,16 @@ class StudioMainWindow(QMainWindow):
|
||||
self.act_rotate_arrows.triggered.connect(self.display_ctl.on_rotate_arrows)
|
||||
self.view_menu.addAction(self.act_rotate_arrows)
|
||||
|
||||
# The replay reads the recorded CSV (the values the session actually
|
||||
# measured). This switches it to re-running the vision pipeline over the
|
||||
# recorded video instead — an algorithm check, not the record.
|
||||
# Serve / leave a stored analysis result (or run the pass when there is none).
|
||||
self.act_recompute = QAction(self)
|
||||
self.act_recompute.setCheckable(True)
|
||||
self.act_recompute.setChecked(False)
|
||||
self.view_menu.addAction(self.act_recompute)
|
||||
|
||||
# Reopen the setup dialog after the first pass (profile / calibration / reference).
|
||||
self.act_analysis_setup = QAction(self)
|
||||
self.act_analysis_setup.triggered.connect(self.replay_ctl.on_analysis_setup)
|
||||
self.view_menu.addAction(self.act_analysis_setup)
|
||||
self.view_menu.addSeparator()
|
||||
|
||||
self.act_run = QAction(self)
|
||||
@@ -1344,9 +1353,15 @@ class StudioMainWindow(QMainWindow):
|
||||
reason = ""
|
||||
if not sensor_ok:
|
||||
reason = t("toolbar.run_disabled_sensor")
|
||||
elif self.toolbar.source_mode() == "video" and not self.toolbar.video_path():
|
||||
elif self.toolbar.source_mode() == "video":
|
||||
# Live acquires from a camera. A video file is played from the History
|
||||
# replay view (Play and the chart cursor), not by starting a chain here.
|
||||
source_ok = False
|
||||
reason = t("toolbar.run_disabled_video")
|
||||
reason = (
|
||||
t("toolbar.run_video_in_replay")
|
||||
if self.toolbar.video_path()
|
||||
else t("toolbar.run_disabled_video")
|
||||
)
|
||||
self.toolbar.set_run_tooltip_reason(reason)
|
||||
# While a session exists the transport stays enabled — pausing and resuming
|
||||
# are the whole point of it being checked, not an exception to an idle rule.
|
||||
@@ -1390,6 +1405,8 @@ class StudioMainWindow(QMainWindow):
|
||||
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
|
||||
self.act_recompute.setText(t("menu.recompute"))
|
||||
self.act_recompute.setToolTip(t("menu.recompute_tip"))
|
||||
self.act_analysis_setup.setText(t("menu.analysis_setup"))
|
||||
self.act_analysis_setup.setToolTip(t("menu.analysis_setup_tip"))
|
||||
self.act_run.setText(t("menu.run"))
|
||||
self.act_stop.setText(t("menu.stop"))
|
||||
self.act_preview.setText(t("menu.preview"))
|
||||
@@ -2216,6 +2233,13 @@ class StudioMainWindow(QMainWindow):
|
||||
self.toolbar.set_run_checked(False)
|
||||
self.statusBar().showMessage(t("status.finger_run_unavailable"))
|
||||
return
|
||||
if self.toolbar.source_mode() == "video":
|
||||
# Opening a clip sets source_mode to video and that flag survives a
|
||||
# switch back to live. Starting a chain on the file painted live 3D
|
||||
# while the chart cursor could not seek — playback belongs to replay.
|
||||
self.toolbar.set_run_checked(False)
|
||||
self.notify(t("toolbar.run_video_in_replay"))
|
||||
return
|
||||
ok, detail = orisys_importable()
|
||||
if not ok:
|
||||
self.toolbar.set_run_checked(False)
|
||||
@@ -2747,16 +2771,28 @@ class StudioMainWindow(QMainWindow):
|
||||
chain = chain_spec_for(
|
||||
session, calibration=synthetic.get("calibration") or None
|
||||
)
|
||||
reference = synthetic.get("reference_frame")
|
||||
if reference is None:
|
||||
reference = getattr(self, "_analysis_reference_override", None)
|
||||
return AnalysisService(
|
||||
Path(recording),
|
||||
chain=chain,
|
||||
cancelled=cancelled,
|
||||
on_progress=on_progress,
|
||||
store_force=bool(synthetic),
|
||||
reference_frame=None if reference is None else int(reference),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------ bare video (T1 setup)
|
||||
def _bare_video_workspace(self, video: Path) -> Path | None:
|
||||
def _bare_video_workspace(
|
||||
self,
|
||||
video: Path,
|
||||
*,
|
||||
initial_profile: str = "",
|
||||
initial_calibration: Path | None = None,
|
||||
prefer_no_calibration: bool = False,
|
||||
initial_reference_frame: int | None = None,
|
||||
) -> 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
|
||||
@@ -2769,7 +2805,7 @@ class StudioMainWindow(QMainWindow):
|
||||
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.
|
||||
is theirs. Prefill arguments restore a prior setup when the dialog is reopened.
|
||||
"""
|
||||
from orisys_app_shared.analysis import bare_video
|
||||
from orisys_app_shared.sensors import scan_sensor_catalog
|
||||
@@ -2777,12 +2813,16 @@ class StudioMainWindow(QMainWindow):
|
||||
facts = bare_video.probe(bare_video.resolve_video(video))
|
||||
catalog = scan_sensor_catalog()
|
||||
selected = self.sensor_picker.selected_entry()
|
||||
profile = initial_profile or (selected.ref if selected is not None else "")
|
||||
dialog = VideoAnalysisDialog(
|
||||
video=Path(video),
|
||||
facts=facts,
|
||||
catalog=catalog,
|
||||
window=self,
|
||||
initial_profile=selected.ref if selected is not None else "",
|
||||
initial_profile=profile,
|
||||
initial_calibration=initial_calibration,
|
||||
prefer_no_calibration=prefer_no_calibration,
|
||||
initial_reference_frame=initial_reference_frame,
|
||||
theme=self._theme,
|
||||
)
|
||||
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
|
||||
@@ -2796,18 +2836,89 @@ class StudioMainWindow(QMainWindow):
|
||||
)
|
||||
return workspace.directory
|
||||
|
||||
def _recording_analysis_overrides(self, recording: Path) -> bool:
|
||||
"""Ask for reference frame (and optional calibration) on a real recording.
|
||||
|
||||
The profile is locked to the session's own chain. Accept stores a reference-frame
|
||||
override on the window for the next :meth:`_analysis_service` call; declining
|
||||
leaves the current result alone.
|
||||
"""
|
||||
from orisys_app_shared.analysis import bare_video
|
||||
from orisys_app_shared.record import RecordingReader
|
||||
from orisys_app_shared.sensors import scan_sensor_catalog
|
||||
|
||||
reader = RecordingReader(Path(recording))
|
||||
session = reader.session()
|
||||
video = reader.segment_path(1)
|
||||
if video is None or not Path(video).is_file():
|
||||
self.notify(t("playback.analysis_no_source"))
|
||||
return False
|
||||
facts = bare_video.probe(bare_video.resolve_video(video))
|
||||
catalog = scan_sensor_catalog()
|
||||
config_ref = str(dict(session.get("device") or {}).get("config_ref") or "")
|
||||
selected = self.sensor_picker.selected_entry()
|
||||
dialog = VideoAnalysisDialog(
|
||||
video=Path(video),
|
||||
facts=facts,
|
||||
catalog=catalog,
|
||||
window=self,
|
||||
initial_profile=config_ref or (selected.ref if selected is not None else ""),
|
||||
initial_reference_frame=getattr(self, "_analysis_reference_override", None),
|
||||
lock_profile=True,
|
||||
theme=self._theme,
|
||||
)
|
||||
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
|
||||
return False
|
||||
setup = dialog.choice()
|
||||
if setup is None:
|
||||
return False
|
||||
self._analysis_reference_override = setup.reference_frame
|
||||
return True
|
||||
|
||||
def _show_analysis_frame(self, packet) -> None:
|
||||
"""Hand one stored analysis frame to the 3D pane's producer.
|
||||
|
||||
The pane snapshots a packet through its ordinary path, so a replay's figures go
|
||||
through the same adapter, domain latch and mask intersection as a live run. The
|
||||
producer is O(1) and Qt-free, and it is the pane that decides whether it is on.
|
||||
|
||||
Live acquisition advances the mailbox on the session run timer. Replay stops
|
||||
that timer, so after submit the pane must be ticked here — otherwise the frame
|
||||
sits pending and later submits are skipped (``has_pending_frame``). The overlay
|
||||
policy is the open session's ``paint_policy``, so 3D arrows share the 2D canvas's
|
||||
grid and threshold (including the box painter's 2 px floor).
|
||||
"""
|
||||
view = self.mesh3d_panel.view
|
||||
if view is None:
|
||||
return
|
||||
self._connect_analysis_display(self.mesh3d_panel)
|
||||
policy = None
|
||||
replay = getattr(self, "replay_ctl", None)
|
||||
if replay is not None:
|
||||
policy = replay._session_policy()
|
||||
view.set_overlay_policy(policy)
|
||||
view.submit_packet(packet)
|
||||
self._tick_analysis_3d()
|
||||
|
||||
def _tick_analysis_3d(self) -> None:
|
||||
"""Compose and present the analysis frame while replay owns the image.
|
||||
|
||||
Replay has no run timer, so two blind ``tick`` calls left the compose in
|
||||
flight on a thread pool and the rest model on screen. ``flush`` waits for
|
||||
that compose and presents it before the cursor returns.
|
||||
"""
|
||||
panel = getattr(self, "mesh3d_panel", None)
|
||||
if panel is None:
|
||||
return
|
||||
try:
|
||||
flush = getattr(panel, "flush", None)
|
||||
if callable(flush):
|
||||
flush()
|
||||
else:
|
||||
panel.tick()
|
||||
panel.tick()
|
||||
except Exception: # noqa: BLE001 - display must not break scrubbing
|
||||
logger.debug("3D analysis flush skipped", exc_info=True)
|
||||
|
||||
def _connect_analysis_display(self, mesh_panel) -> bool:
|
||||
"""Route every display-settings change through the replay controller.
|
||||
|
||||
@@ -260,19 +260,32 @@ class AnalysisSession:
|
||||
the pass's own answer — the field is finite only at those cells, and
|
||||
:meth:`serve_policy` refuses a display setting that moves the grid — so the grid
|
||||
travels from the result's provenance. *Which* candidates are active is a display
|
||||
question, and ``magnitude`` is stored precisely so it can be asked again later, so
|
||||
the threshold travels from the display policy.
|
||||
question for the shared finger painter, and ``magnitude`` is stored precisely so it
|
||||
can be asked again later. A box result on the live painter's grid is different: the
|
||||
2D canvas calls ``orisys.util.draw_arrows``, which does not read the display
|
||||
threshold, so the on/off floors stay at that painter's values (2 / 1.5 px). The 3D
|
||||
overlay uses this same policy so the two figures zero at the same strength.
|
||||
|
||||
Without this, a display setting that moves the grid would be refused by
|
||||
``serve_policy`` and then applied to the drawing anyway, re-selecting the recovered
|
||||
field on a grid whose cells mostly hold ``NaN``: the canvas would go blank rather
|
||||
than draw the result's own samples.
|
||||
"""
|
||||
from orisys_app_shared.acquisition.arrows import sdk_box_policy
|
||||
|
||||
stored = self.stored_policy()
|
||||
display = self.display_policy
|
||||
box_grid = uses_sdk_box_grid(stored) or uses_sdk_box_grid(self.policy)
|
||||
if box_grid:
|
||||
painter = sdk_box_policy()
|
||||
on_px = float(painter.flow_on_px)
|
||||
off_px = float(painter.flow_off_px)
|
||||
else:
|
||||
on_px = float(display.flow_on_px)
|
||||
off_px = float(display.flow_off_px)
|
||||
policy = VisualPolicy(
|
||||
flow_on_px=display.flow_on_px,
|
||||
flow_off_px=display.flow_off_px,
|
||||
flow_on_px=on_px,
|
||||
flow_off_px=off_px,
|
||||
flow_grid_spacing=int(
|
||||
stored.get("flow_grid_spacing", self.policy.flow_grid_spacing)
|
||||
),
|
||||
@@ -370,9 +383,9 @@ class AnalysisSession:
|
||||
valid_mask=frame.valid_mask,
|
||||
flow_valid=True,
|
||||
flow_frame_id=int(frame.frame_id),
|
||||
depth=None,
|
||||
depth_valid=False,
|
||||
depth_frame_id=None,
|
||||
depth=None if frame.depth is None else np.asarray(frame.depth, dtype=np.float32),
|
||||
depth_valid=bool(frame.has_depth),
|
||||
depth_frame_id=int(frame.frame_id) if frame.has_depth else None,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ building
|
||||
|
||||
@@ -385,6 +385,61 @@ class ReplayController(QObject):
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------- bare video setup
|
||||
def on_analysis_setup(self) -> None:
|
||||
"""Reopen the analysis setup dialog after a first pass (or before one).
|
||||
|
||||
For a bare / synthetic video this rebuilds the workspace from the new answers;
|
||||
a different calibration or reference frame changes the result key, so the pass
|
||||
runs again. For a real recording, only the reference frame is overridden for the
|
||||
next pass (the session's own profile stays locked).
|
||||
"""
|
||||
from orisys_app_shared.analysis import bare_video
|
||||
|
||||
if self.analysis_running():
|
||||
self._host.notify(t("playback.analysis_busy"))
|
||||
return
|
||||
recording = self.recording_directory()
|
||||
if recording is not None and bare_video.is_synthetic(recording):
|
||||
video = bare_video.source_video(recording)
|
||||
if video is None or not Path(video).is_file():
|
||||
self._host.notify(t("playback.analysis_no_source"))
|
||||
return
|
||||
block = bare_video.read_synthetic(recording) or {}
|
||||
cal = str(block.get("calibration") or "")
|
||||
prefer_none = not cal
|
||||
workspace = self._host.bare_video_workspace(
|
||||
Path(video),
|
||||
initial_profile=str(block.get("profile_ref") or ""),
|
||||
initial_calibration=None if prefer_none else Path(cal),
|
||||
prefer_no_calibration=prefer_none,
|
||||
initial_reference_frame=block.get("reference_frame"),
|
||||
)
|
||||
if workspace is None:
|
||||
return
|
||||
self._video_workspace = Path(workspace)
|
||||
self._clear_analysis_state()
|
||||
self.refresh_state()
|
||||
self.on_analysis_requested()
|
||||
return
|
||||
if recording is not None:
|
||||
if not self._host.recording_analysis_overrides(Path(recording)):
|
||||
return
|
||||
self._clear_analysis_state()
|
||||
# Drop a cached result that was keyed under a different reference so the
|
||||
# new pass is what serves the cursor.
|
||||
self.results.close()
|
||||
self.refresh_state()
|
||||
self.on_analysis_requested()
|
||||
return
|
||||
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():
|
||||
self.prepare_bare_video(Path(video))
|
||||
return
|
||||
self._host.notify(t("playback.no_video"))
|
||||
|
||||
def prepare_bare_video(self, video: Path) -> bool:
|
||||
"""Ask how to analyse a bare video, then analyse it — in a generated workspace.
|
||||
|
||||
@@ -600,19 +655,26 @@ class ReplayController(QObject):
|
||||
self.pause_playback()
|
||||
if wanted:
|
||||
# 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.
|
||||
# no workspace. 「分析视频」 asks again — that is how numbers appear — then
|
||||
# shares ``on_analysis_requested`` with opening a recording. A path that is
|
||||
# only remembered on the toolbar (engine not open yet) goes through
|
||||
# ``load_video``, which opens the clip and shows the same setup dialog.
|
||||
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)):
|
||||
if video is None or not Path(video).is_file():
|
||||
self._host.notify(t("playback.no_video"))
|
||||
self._set_recompute_checked(False)
|
||||
return
|
||||
path = Path(video)
|
||||
if self._engine.is_video_only():
|
||||
if not self.prepare_bare_video(path):
|
||||
self._set_recompute_checked(False)
|
||||
return
|
||||
self._host.notify(t("playback.no_video"))
|
||||
self._set_recompute_checked(False)
|
||||
if not self.load_video(path):
|
||||
self._set_recompute_checked(False)
|
||||
return
|
||||
self.on_analysis_requested()
|
||||
return
|
||||
|
||||
@@ -424,7 +424,19 @@ class FingerFrameAdapter:
|
||||
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
|
||||
flow_uv = freeze(arr, dtype=np.float32)
|
||||
device_for_flow, _ = _device_mask(packet, self.shape_hw, self.domain_latch)
|
||||
flow_mask = _intersect(np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]), device_for_flow)
|
||||
# The overlay selector already requires a finite sample. The mask it
|
||||
# is handed must be the sensing island (the 2D canvas's ``valid_mask``),
|
||||
# not the finite cells of a recovered field. Those cells are only the
|
||||
# stored candidates; requiring the arrow tip to land on another
|
||||
# candidate drops almost every 3D arrow (the tip is typically one
|
||||
# neighbour over, which is ``NaN`` on a recovered grid).
|
||||
if device_for_flow is not None:
|
||||
flow_mask = freeze(np.asarray(device_for_flow, dtype=bool), dtype=np.bool_)
|
||||
else:
|
||||
flow_mask = freeze(
|
||||
np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]),
|
||||
dtype=np.bool_,
|
||||
)
|
||||
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
|
||||
if not np.isfinite(fn):
|
||||
fn = float(getattr(packet, "normal_force", 0.0) or 0.0)
|
||||
|
||||
@@ -404,6 +404,27 @@ class ThreeDCoordinator:
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------- consumer
|
||||
def flush(self) -> None:
|
||||
"""Compose and present the pending frame now.
|
||||
|
||||
Live acquisition advances this coordinator on the session run timer. Replay
|
||||
stops that timer, so a submit alone leaves the compose in flight and the
|
||||
rest model on screen. Calling here ignores the frame-period gate, waits for
|
||||
the in-flight future, then presents — one cursor move, one picture.
|
||||
"""
|
||||
if self._closed or not self._visible or self._assets is None:
|
||||
return
|
||||
self._last_compose_start = float("-inf")
|
||||
self.tick()
|
||||
item = self._inflight
|
||||
if item is not None:
|
||||
try:
|
||||
# Wait only; acceptance and diagnostics stay in ``tick``.
|
||||
item.future.result()
|
||||
except Exception: # noqa: BLE001 - the next tick records the failure
|
||||
pass
|
||||
self.tick()
|
||||
|
||||
def tick(self) -> None:
|
||||
"""GUI timer: start one compose, take the newest result, repaint."""
|
||||
if self._closed or not self._visible or self._assets is None:
|
||||
@@ -415,19 +436,21 @@ class ThreeDCoordinator:
|
||||
period = self.display_period_s()
|
||||
now = self._clock()
|
||||
if self._inflight is None and (now - self._last_compose_start) >= period:
|
||||
if self._settings_dirty and self._last_field_frame is not None:
|
||||
# A gain change re-draws the field that is already on screen; taking
|
||||
# a newer one would show a different instant than the one the
|
||||
# operator just changed the exaggeration of.
|
||||
# A new measurement always wins: replay sets overlay policy (which
|
||||
# dirties settings) then submits the cursor frame. Recomposing the
|
||||
# held field first left that frame in the mailbox, later submits
|
||||
# skipped (``has_pending_frame``), and the pane stayed on the first
|
||||
# near-zero depth while 2D kept scrubbing.
|
||||
frame = self._incoming.take()
|
||||
if frame is not None:
|
||||
self._settings_dirty = False
|
||||
self._last_compose_start = now
|
||||
self._start_compose(frame)
|
||||
elif self._settings_dirty and self._last_field_frame is not None:
|
||||
# No newer field: a gain/overlay change re-draws what is on screen.
|
||||
self._settings_dirty = False
|
||||
self._last_compose_start = now
|
||||
self._start_compose(self._last_field_frame)
|
||||
else:
|
||||
self._settings_dirty = False
|
||||
frame = self._incoming.take()
|
||||
if frame is not None:
|
||||
self._last_compose_start = now
|
||||
self._start_compose(frame)
|
||||
composed = self._composed.take()
|
||||
if composed is not None:
|
||||
if not self._identity_is_current(composed.identity):
|
||||
@@ -496,6 +519,19 @@ class ThreeDCoordinator:
|
||||
except AttributeError: # pragma: no cover - a composer stand-in without state
|
||||
pass
|
||||
|
||||
def set_overlay_policy(self, policy) -> None:
|
||||
"""Pin the flow-arrow policy the composer uses for overlays, or clear it.
|
||||
|
||||
Replay analysis sets the session's ``paint_policy`` so 3D arrows share the 2D
|
||||
canvas's grid and threshold. Live leaves this unset (``None``).
|
||||
"""
|
||||
try:
|
||||
self._composer.overlay_policy = policy
|
||||
except AttributeError: # pragma: no cover - a composer stand-in without state
|
||||
pass
|
||||
if self._assets is not None and self._last_field_frame is not None:
|
||||
self._settings_dirty = True
|
||||
|
||||
def show_rest(self) -> None:
|
||||
"""Put the reference model back on screen and stop claiming a measurement.
|
||||
|
||||
@@ -505,6 +541,7 @@ class ThreeDCoordinator:
|
||||
measurement that is no longer being made, which is exactly what the rest frame
|
||||
exists to prevent. Idempotent, and safe before any scene is active.
|
||||
"""
|
||||
self.set_overlay_policy(None)
|
||||
if self._closed or self._assets is None or not self._visible:
|
||||
return
|
||||
self._invalidate()
|
||||
|
||||
@@ -412,6 +412,11 @@ class Scene3DView(QWidget):
|
||||
if adapter is not None and hasattr(adapter, "set_preferred_field"):
|
||||
adapter.set_preferred_field(settings.field)
|
||||
|
||||
def set_overlay_policy(self, policy) -> None:
|
||||
"""Pin (or clear) the flow-arrow policy used when composing analysis overlays."""
|
||||
if self._coordinator is not None:
|
||||
self._coordinator.set_overlay_policy(policy)
|
||||
|
||||
def apply_preset(self, name: str) -> None:
|
||||
if self._assets is None:
|
||||
return
|
||||
@@ -767,6 +772,19 @@ class Scene3DView(QWidget):
|
||||
if self._coordinator is None or not self._enabled:
|
||||
return
|
||||
self._coordinator.tick()
|
||||
self._publish_after_tick()
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Compose and present the pending analysis frame without a run timer."""
|
||||
if self._coordinator is None or not self._enabled:
|
||||
return
|
||||
self._coordinator.flush()
|
||||
self._publish_after_tick()
|
||||
|
||||
def _publish_after_tick(self) -> None:
|
||||
"""Publish coordinator state after a tick or flush."""
|
||||
if self._coordinator is None:
|
||||
return
|
||||
# The coordinator owns the truths a pane cannot see from here — a refused
|
||||
# field, an expired measurement, a failed compose. It has no Qt signals by
|
||||
# design, so the one place that already runs per tick publishes them.
|
||||
|
||||
@@ -79,7 +79,9 @@ class AnalysisSupport(Protocol):
|
||||
self, recording: Path, cancelled: Any = None, *, on_progress: Any = None
|
||||
) -> Any: ...
|
||||
|
||||
def bare_video_workspace(self, video: Path) -> Path | None: ...
|
||||
def bare_video_workspace(self, video: Path, **kwargs: Any) -> Path | None: ...
|
||||
|
||||
def recording_analysis_overrides(self, recording: Path) -> bool: ...
|
||||
|
||||
|
||||
class LayoutShell(Protocol):
|
||||
|
||||
@@ -518,6 +518,11 @@ class Mesh3dPanel(QFrame):
|
||||
if self._view is not None:
|
||||
self._view.tick()
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Compose and present a pending analysis frame (replay has no run timer)."""
|
||||
if self._view is not None:
|
||||
self._view.flush()
|
||||
|
||||
def close_view(self) -> None:
|
||||
"""Release GL and the loader. Idempotent, and safe to call twice."""
|
||||
if self._view is not None:
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""The setup prompt for a bare video: which profile, which calibration, which reference.
|
||||
|
||||
A recording carries everything a pass needs — the configuration it was taken under, the
|
||||
calibration, the frame index. A bare video carries none of it, so the three answers the
|
||||
pass cannot invent are asked for here, once, before any work starts:
|
||||
|
||||
* **profile** — the processing configuration, listed from the catalog with what it can do
|
||||
in one line. A profile the SDK would refuse to start is shown with that refusal and
|
||||
cannot be chosen: the catalog's own :meth:`CatalogEntry.start_blocked_reason` is the
|
||||
verdict, so this dialog cannot disagree with the picker about the same bundle.
|
||||
* **calibration** — optional, and defaulting to the profile's ``default_calibration`` when
|
||||
its descriptor declares one (the box bundle resolves ``force_calibration.json`` beside
|
||||
its ``sensor.json``). "No calibration — raw force" is a choice, not an empty field.
|
||||
* **reference frame** — the frame the tracker reference is reset at, defaulting to the
|
||||
first. The list is the video's own frames, not an index into them.
|
||||
|
||||
This is deliberately *not* the capability panel: it offers what the pass needs to know and
|
||||
nothing about how the result will be drawn. What each profile can do is one line, and the
|
||||
reason it cannot be used is one line, both in the language the catalog already speaks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.analysis.bare_video import (
|
||||
BareVideoSetup,
|
||||
VideoFacts,
|
||||
profiles_for_video,
|
||||
setup_blocked_reason,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.sensors import CatalogEntry, SensorCatalog
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
#: The calibration combo's two fixed rows. The first is a *file* the profile declares; the
|
||||
#: second is the absence of one, which is a different answer and never an empty field.
|
||||
ROLE_DEFAULT = "default"
|
||||
ROLE_NONE = "none"
|
||||
ROLE_FILE = "file"
|
||||
|
||||
|
||||
class VideoAnalysisDialog(QDialog):
|
||||
"""Ask how one bare video should be analysed, and hand back the three answers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
video: Path,
|
||||
facts: VideoFacts,
|
||||
catalog: SensorCatalog,
|
||||
window=None,
|
||||
initial_profile: str = "",
|
||||
initial_calibration: Path | None = None,
|
||||
prefer_no_calibration: bool = False,
|
||||
initial_reference_frame: int | None = None,
|
||||
lock_profile: bool = False,
|
||||
theme: str = "dark",
|
||||
) -> None:
|
||||
super().__init__(window)
|
||||
self._video = Path(video)
|
||||
self._facts = facts
|
||||
self._catalog = catalog
|
||||
self._theme = str(theme)
|
||||
self._entries: list[CatalogEntry] = profiles_for_video(catalog.entries)
|
||||
self._custom_calibration: Path | None = None
|
||||
self._restore_calibration = (
|
||||
None
|
||||
if prefer_no_calibration
|
||||
else (None if initial_calibration is None else Path(initial_calibration))
|
||||
)
|
||||
self._prefer_no_calibration = bool(prefer_no_calibration)
|
||||
self._restore_reference = (
|
||||
1 if initial_reference_frame is None else int(initial_reference_frame)
|
||||
)
|
||||
self._lock_profile = bool(lock_profile)
|
||||
self.setObjectName("videoAnalysisDialog")
|
||||
self.setModal(True)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(10)
|
||||
|
||||
self.title_label = QLabel()
|
||||
self.title_label.setObjectName("panelTitle")
|
||||
root.addWidget(self.title_label)
|
||||
self.video_label = ShrinkableLabel()
|
||||
self.video_label.setObjectName("panelMeta")
|
||||
self.video_label.setWordWrap(True)
|
||||
root.addWidget(self.video_label)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
self.profile_label = QLabel()
|
||||
self.profile_label.setObjectName("fieldLabel")
|
||||
self.profile_combo = QComboBox()
|
||||
self.profile_combo.setObjectName("profileCombo")
|
||||
for entry in self._entries:
|
||||
# The row is the bundle's **name** and nothing else — the catalog's own rule, and
|
||||
# the reason the capability line is a label under the selector rather than text
|
||||
# bolted onto the row. An entry that cannot be started is listed too, disabled,
|
||||
# because "why is finger-mems not offered" has to have an answer on the surface
|
||||
# rather than in a log.
|
||||
self.profile_combo.addItem(catalog.row_label(entry), entry.key)
|
||||
index = self.profile_combo.count() - 1
|
||||
self.profile_combo.setItemData(index, entry.tooltip(), Qt.ItemDataRole.ToolTipRole)
|
||||
if setup_blocked_reason(entry) is not None:
|
||||
model = self.profile_combo.model()
|
||||
item = model.item(index) if hasattr(model, "item") else None
|
||||
if item is not None:
|
||||
item.setEnabled(False)
|
||||
form.addRow(self.profile_label, self.profile_combo)
|
||||
|
||||
self.capability_label = ShrinkableLabel()
|
||||
self.capability_label.setObjectName("panelMeta")
|
||||
self.capability_label.setWordWrap(True)
|
||||
root.addLayout(form)
|
||||
root.addWidget(self.capability_label)
|
||||
|
||||
calibration_form = QFormLayout()
|
||||
calibration_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.calibration_label = QLabel()
|
||||
self.calibration_label.setObjectName("fieldLabel")
|
||||
self.calibration_combo = QComboBox()
|
||||
self.calibration_combo.setObjectName("calibrationCombo")
|
||||
calibration_row = QHBoxLayout()
|
||||
calibration_row.addWidget(self.calibration_combo, 1)
|
||||
self.btn_browse = QPushButton()
|
||||
self.btn_browse.setProperty("compact", "true")
|
||||
self.btn_browse.clicked.connect(self._on_browse)
|
||||
calibration_row.addWidget(self.btn_browse)
|
||||
calibration_form.addRow(self.calibration_label, calibration_row)
|
||||
|
||||
self.reference_label = QLabel()
|
||||
self.reference_label.setObjectName("fieldLabel")
|
||||
self.reference_spin = QSpinBox()
|
||||
self.reference_spin.setObjectName("referenceSpin")
|
||||
# The frame *ids*, not indices: the reference is a frame the recording names, and
|
||||
# an id that is not on the list is one the pass would refuse anyway.
|
||||
self.reference_spin.setRange(1, max(1, int(facts.frame_count)))
|
||||
self.reference_spin.setValue(1)
|
||||
self.reference_spin.valueChanged.connect(self._refresh_time_label)
|
||||
reference_row = QHBoxLayout()
|
||||
reference_row.addWidget(self.reference_spin, 1)
|
||||
self.reference_time_label = ShrinkableLabel()
|
||||
self.reference_time_label.setObjectName("panelMeta")
|
||||
reference_row.addWidget(self.reference_time_label)
|
||||
calibration_form.addRow(self.reference_label, reference_row)
|
||||
root.addLayout(calibration_form)
|
||||
|
||||
self.note_label = ShrinkableLabel()
|
||||
self.note_label.setObjectName("panelMeta")
|
||||
self.note_label.setWordWrap(True)
|
||||
root.addWidget(self.note_label)
|
||||
|
||||
self.buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
self.buttons.accepted.connect(self.accept)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
root.addWidget(self.buttons)
|
||||
|
||||
self.profile_combo.currentIndexChanged.connect(self._on_profile_changed)
|
||||
self._select_initial(initial_profile)
|
||||
if self._lock_profile:
|
||||
self.profile_combo.setEnabled(False)
|
||||
self.retranslate()
|
||||
self._on_profile_changed()
|
||||
if self._prefer_no_calibration:
|
||||
self.select_calibration(None)
|
||||
elif self._restore_calibration is not None:
|
||||
self.select_calibration(self._restore_calibration)
|
||||
self.reference_spin.setValue(max(1, int(self._restore_reference)))
|
||||
|
||||
# ------------------------------------------------------------------ selection
|
||||
@property
|
||||
def entries(self) -> list[CatalogEntry]:
|
||||
return list(self._entries)
|
||||
|
||||
def entry(self) -> CatalogEntry | None:
|
||||
key = self.profile_combo.currentData()
|
||||
for candidate in self._entries:
|
||||
if candidate.key == str(key):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def select_profile(self, ref_or_key: str) -> bool:
|
||||
"""Choose a profile by its ``origin/folder`` ref or its selection key."""
|
||||
wanted = str(ref_or_key or "")
|
||||
for index, entry in enumerate(self._entries):
|
||||
if entry.key == wanted or entry.ref == wanted:
|
||||
self.profile_combo.setCurrentIndex(index)
|
||||
return True
|
||||
return False
|
||||
|
||||
def select_calibration(self, path: Path | None) -> None:
|
||||
"""Choose "no calibration" (``None``) or a specific file."""
|
||||
self._refresh_calibrations(custom=path)
|
||||
if path is None:
|
||||
for index in range(self.calibration_combo.count()):
|
||||
if self.calibration_combo.itemData(index) == ROLE_NONE:
|
||||
self.calibration_combo.setCurrentIndex(index)
|
||||
return
|
||||
|
||||
def choice(self) -> BareVideoSetup | None:
|
||||
"""The answers, or ``None`` when the chosen profile must not be started.
|
||||
|
||||
A refusal carries the reason the catalog (or this app's chain table) gave, which the
|
||||
caller states: the dialog cannot start a profile the SDK rejected, and it does not
|
||||
pretend the reason is its own.
|
||||
"""
|
||||
entry = self.entry()
|
||||
if entry is None:
|
||||
return None
|
||||
blocked = setup_blocked_reason(entry)
|
||||
if blocked:
|
||||
self.note_label.setText(t("video_analysis.blocked", reason=blocked))
|
||||
return None
|
||||
reference = int(self.reference_spin.value())
|
||||
return BareVideoSetup(
|
||||
profile_ref=entry.ref,
|
||||
calibration=self._chosen_calibration(),
|
||||
# The first frame is the pass's own default: it starts from a clean tracker, so
|
||||
# the first frame it processes already *is* the reference and no reset is owed.
|
||||
# Saying so as ``None`` keeps a reset the pass cannot apply from being recorded
|
||||
# as one that happened.
|
||||
reference_frame=None if reference == 1 else reference,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ content
|
||||
def _select_initial(self, wanted: str) -> None:
|
||||
if wanted and self.select_profile(wanted):
|
||||
return
|
||||
if self.profile_combo.count():
|
||||
self.profile_combo.setCurrentIndex(0)
|
||||
|
||||
def _on_profile_changed(self) -> None:
|
||||
entry = self.entry()
|
||||
self._custom_calibration = None
|
||||
self._refresh_calibrations(custom=None)
|
||||
self.capability_label.setText(
|
||||
"" if entry is None else entry.capability_line()
|
||||
)
|
||||
blocked = None if entry is None else setup_blocked_reason(entry)
|
||||
self.note_label.setText(
|
||||
"" if not blocked else t("video_analysis.blocked", reason=blocked)
|
||||
)
|
||||
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked)
|
||||
self._refresh_time_label()
|
||||
|
||||
def _refresh_calibrations(self, *, custom: Path | None) -> None:
|
||||
"""The calibration field: the profile's default, raw force, or a chosen file."""
|
||||
entry = self.entry()
|
||||
self.calibration_combo.blockSignals(True)
|
||||
self.calibration_combo.clear()
|
||||
declared = None if entry is None or entry.profile is None else entry.profile.default_calibration
|
||||
if declared is not None:
|
||||
self.calibration_combo.addItem(
|
||||
t("video_analysis.calibration_default", name=Path(declared).name),
|
||||
ROLE_DEFAULT,
|
||||
)
|
||||
elif entry is not None and entry.selected_calibration:
|
||||
self.calibration_combo.addItem(
|
||||
t("video_analysis.calibration_default", name=entry.selected_calibration),
|
||||
ROLE_DEFAULT,
|
||||
)
|
||||
self.calibration_combo.addItem(t("video_analysis.calibration_none"), ROLE_NONE)
|
||||
if custom is not None:
|
||||
self._custom_calibration = Path(custom)
|
||||
self.calibration_combo.addItem(
|
||||
t("video_analysis.calibration_file", name=Path(custom).name), ROLE_FILE
|
||||
)
|
||||
self.calibration_combo.setCurrentIndex(self.calibration_combo.count() - 1)
|
||||
self.calibration_combo.blockSignals(False)
|
||||
|
||||
def _chosen_calibration(self) -> Path | None:
|
||||
role = str(self.calibration_combo.currentData() or "")
|
||||
entry = self.entry()
|
||||
if role == ROLE_FILE:
|
||||
return self._custom_calibration
|
||||
if role == ROLE_NONE:
|
||||
return None
|
||||
if entry is None or entry.profile is None:
|
||||
return None
|
||||
return entry.profile.default_calibration
|
||||
|
||||
def _on_browse(self) -> None:
|
||||
chosen, _filter = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
t("video_analysis.calibration_browse"),
|
||||
str(Path(self._video).parent),
|
||||
t("video_analysis.calibration_filter"),
|
||||
)
|
||||
if chosen:
|
||||
self._refresh_calibrations(custom=Path(chosen))
|
||||
|
||||
def _refresh_time_label(self) -> None:
|
||||
fps = float(self._facts.fps) or 0.0
|
||||
seconds = (int(self.reference_spin.value()) - 1) / fps if fps > 0 else 0.0
|
||||
self.reference_time_label.setText(
|
||||
t("video_analysis.reference_time", seconds=f"{seconds:.3f}")
|
||||
)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(t("video_analysis.title"))
|
||||
self.title_label.setText(t("video_analysis.title"))
|
||||
self.video_label.setText(
|
||||
t(
|
||||
"video_analysis.video",
|
||||
name=Path(self._video).name,
|
||||
frames=int(self._facts.frame_count),
|
||||
fps=f"{float(self._facts.fps):.3f}",
|
||||
seconds=f"{float(self._facts.duration_s):.3f}",
|
||||
)
|
||||
)
|
||||
self.profile_label.setText(t("video_analysis.profile"))
|
||||
self.calibration_label.setText(t("video_analysis.calibration"))
|
||||
self.reference_label.setText(t("video_analysis.reference"))
|
||||
self.btn_browse.setText(t("video_analysis.calibration_browse"))
|
||||
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText(t("dialog.ok"))
|
||||
self.buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(t("dialog.cancel"))
|
||||
self._refresh_time_label()
|
||||
Reference in New Issue
Block a user