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:
ykcheungab
2026-10-05 16:52:27 +08:00
parent 3433eb0fec
commit c0a10e6adb
26 changed files with 2226 additions and 158 deletions
+121 -10
View File
@@ -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.
+3 -1
View File
@@ -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()