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
@@ -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()