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:
@@ -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