让 Finger 视频分析使用所选的力标定文件。
分析链不再在打开前拒绝标定,而是把文件作为 cal_path 交给 FingerRuntime,并用操作者确认的设备编号核对文件里的 SN。标定传递测试与对话框 SN 校验通过。
This commit is contained in:
@@ -333,6 +333,12 @@
|
|||||||
"calibration_file": "Chosen file — {name}",
|
"calibration_file": "Chosen file — {name}",
|
||||||
"calibration_browse": "Choose a file…",
|
"calibration_browse": "Choose a file…",
|
||||||
"calibration_filter": "Force calibration (*.json);;All files (*)",
|
"calibration_filter": "Force calibration (*.json);;All files (*)",
|
||||||
|
"device": "Device id",
|
||||||
|
"device_tip": "Must match the SN in the calibration file",
|
||||||
|
"device_required": "A Finger calibration needs the device id of the sensor in this video",
|
||||||
|
"device_missing_sn": "This calibration file has no SN, so it cannot be matched to a sensor",
|
||||||
|
"device_mismatch": "The calibration file's SN is {sn}, which is not the device id entered",
|
||||||
|
"device_family": "This calibration belongs to {family}, not Finger",
|
||||||
"reference": "Reference frame",
|
"reference": "Reference frame",
|
||||||
"reference_time": "{seconds} s",
|
"reference_time": "{seconds} s",
|
||||||
"blocked": "This profile cannot be used: {reason}"
|
"blocked": "This profile cannot be used: {reason}"
|
||||||
|
|||||||
@@ -333,6 +333,12 @@
|
|||||||
"calibration_file": "已选文件 — {name}",
|
"calibration_file": "已选文件 — {name}",
|
||||||
"calibration_browse": "选择文件…",
|
"calibration_browse": "选择文件…",
|
||||||
"calibration_filter": "力标定 (*.json);;所有文件 (*)",
|
"calibration_filter": "力标定 (*.json);;所有文件 (*)",
|
||||||
|
"device": "设备编号",
|
||||||
|
"device_tip": "与标定文件中的 SN 一致",
|
||||||
|
"device_required": "使用这份 Finger 标定需要填写视频对应的设备编号",
|
||||||
|
"device_missing_sn": "这份标定文件没有 SN,无法确认它属于哪台传感器",
|
||||||
|
"device_mismatch": "标定文件的 SN 是 {sn},与填写的设备编号不一致",
|
||||||
|
"device_family": "这份标定属于 {family},不能用于 Finger",
|
||||||
"reference": "参考帧",
|
"reference": "参考帧",
|
||||||
"reference_time": "{seconds} 秒",
|
"reference_time": "{seconds} 秒",
|
||||||
"blocked": "这套配置不能用:{reason}"
|
"blocked": "这套配置不能用:{reason}"
|
||||||
|
|||||||
@@ -2732,7 +2732,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
session = RecordingReader(Path(recording)).session()
|
session = RecordingReader(Path(recording)).session()
|
||||||
synthetic = dict(session.get("synthetic") or {})
|
synthetic = dict(session.get("synthetic") or {})
|
||||||
chain = chain_spec_for(
|
chain = chain_spec_for(
|
||||||
session, calibration=synthetic.get("calibration") or None
|
session,
|
||||||
|
calibration=synthetic.get("calibration") or None,
|
||||||
|
device_id=synthetic.get("device_id") or None,
|
||||||
)
|
)
|
||||||
reference = synthetic.get("reference_frame")
|
reference = synthetic.get("reference_frame")
|
||||||
if reference is None:
|
if reference is None:
|
||||||
@@ -2755,6 +2757,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
initial_calibration: Path | None = None,
|
initial_calibration: Path | None = None,
|
||||||
prefer_no_calibration: bool = False,
|
prefer_no_calibration: bool = False,
|
||||||
initial_reference_frame: int | None = None,
|
initial_reference_frame: int | None = None,
|
||||||
|
initial_device_id: str | None = None,
|
||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
"""Ask how to analyse a bare video, then generate the workspace to do it in.
|
"""Ask how to analyse a bare video, then generate the workspace to do it in.
|
||||||
|
|
||||||
@@ -2786,6 +2789,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
initial_calibration=initial_calibration,
|
initial_calibration=initial_calibration,
|
||||||
prefer_no_calibration=prefer_no_calibration,
|
prefer_no_calibration=prefer_no_calibration,
|
||||||
initial_reference_frame=initial_reference_frame,
|
initial_reference_frame=initial_reference_frame,
|
||||||
|
initial_device_id=initial_device_id,
|
||||||
theme=self._theme,
|
theme=self._theme,
|
||||||
)
|
)
|
||||||
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
|
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ class ReplayController(QObject):
|
|||||||
initial_calibration=None if prefer_none else Path(cal),
|
initial_calibration=None if prefer_none else Path(cal),
|
||||||
prefer_no_calibration=prefer_none,
|
prefer_no_calibration=prefer_none,
|
||||||
initial_reference_frame=block.get("reference_frame"),
|
initial_reference_frame=block.get("reference_frame"),
|
||||||
|
initial_device_id=str(block.get("device_id") or "") or None,
|
||||||
)
|
)
|
||||||
if workspace is None:
|
if workspace is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ reason it cannot be used is one line, both in the language the catalog already s
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from orisys_app_shared.analysis.bare_video import (
|
from orisys_app_shared.analysis.bare_video import (
|
||||||
@@ -40,6 +41,7 @@ from PySide6.QtWidgets import (
|
|||||||
QFormLayout,
|
QFormLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
@@ -54,6 +56,25 @@ ROLE_NONE = "none"
|
|||||||
ROLE_FILE = "file"
|
ROLE_FILE = "file"
|
||||||
|
|
||||||
|
|
||||||
|
def calibration_identity(path: Path) -> tuple[str, str]:
|
||||||
|
"""``(sensor_type, sn)`` from a force-calibration file, or empty strings.
|
||||||
|
|
||||||
|
The SDK reads the same two places (``identity``, then ``meta``) when it checks
|
||||||
|
that a chosen file belongs to the device the operator named.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||||
|
return "", ""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return "", ""
|
||||||
|
meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
|
||||||
|
identity = data.get("identity") if isinstance(data.get("identity"), dict) else {}
|
||||||
|
sensor_type = str(identity.get("sensor_type") or meta.get("sensor_type") or "")
|
||||||
|
sn = str(identity.get("sn") or meta.get("sn") or "")
|
||||||
|
return sensor_type, sn
|
||||||
|
|
||||||
|
|
||||||
class VideoAnalysisDialog(QDialog):
|
class VideoAnalysisDialog(QDialog):
|
||||||
"""Ask how one bare video should be analysed, and hand back the three answers."""
|
"""Ask how one bare video should be analysed, and hand back the three answers."""
|
||||||
|
|
||||||
@@ -68,6 +89,7 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
initial_calibration: Path | None = None,
|
initial_calibration: Path | None = None,
|
||||||
prefer_no_calibration: bool = False,
|
prefer_no_calibration: bool = False,
|
||||||
initial_reference_frame: int | None = None,
|
initial_reference_frame: int | None = None,
|
||||||
|
initial_device_id: str | None = None,
|
||||||
lock_profile: bool = False,
|
lock_profile: bool = False,
|
||||||
theme: str = "dark",
|
theme: str = "dark",
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -87,6 +109,8 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
self._restore_reference = (
|
self._restore_reference = (
|
||||||
1 if initial_reference_frame is None else int(initial_reference_frame)
|
1 if initial_reference_frame is None else int(initial_reference_frame)
|
||||||
)
|
)
|
||||||
|
self._restore_device = str(initial_device_id or "")
|
||||||
|
self._device_source: Path | None = None
|
||||||
self._lock_profile = bool(lock_profile)
|
self._lock_profile = bool(lock_profile)
|
||||||
self.setObjectName("videoAnalysisDialog")
|
self.setObjectName("videoAnalysisDialog")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
@@ -145,6 +169,13 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
calibration_row.addWidget(self.btn_browse)
|
calibration_row.addWidget(self.btn_browse)
|
||||||
calibration_form.addRow(self.calibration_label, calibration_row)
|
calibration_form.addRow(self.calibration_label, calibration_row)
|
||||||
|
|
||||||
|
self.device_label = QLabel()
|
||||||
|
self.device_label.setObjectName("fieldLabel")
|
||||||
|
self.device_edit = QLineEdit()
|
||||||
|
self.device_edit.setObjectName("deviceEdit")
|
||||||
|
calibration_form.addRow(self.device_label, self.device_edit)
|
||||||
|
self._calibration_form = calibration_form
|
||||||
|
|
||||||
self.reference_label = QLabel()
|
self.reference_label = QLabel()
|
||||||
self.reference_label.setObjectName("fieldLabel")
|
self.reference_label.setObjectName("fieldLabel")
|
||||||
self.reference_spin = QSpinBox()
|
self.reference_spin = QSpinBox()
|
||||||
@@ -170,7 +201,7 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
self.buttons = QDialogButtonBox(
|
self.buttons = QDialogButtonBox(
|
||||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||||
)
|
)
|
||||||
self.buttons.accepted.connect(self.accept)
|
self.buttons.accepted.connect(self._on_accept)
|
||||||
self.buttons.rejected.connect(self.reject)
|
self.buttons.rejected.connect(self.reject)
|
||||||
root.addWidget(self.buttons)
|
root.addWidget(self.buttons)
|
||||||
|
|
||||||
@@ -185,6 +216,9 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
elif self._restore_calibration is not None:
|
elif self._restore_calibration is not None:
|
||||||
self.select_calibration(self._restore_calibration)
|
self.select_calibration(self._restore_calibration)
|
||||||
self.reference_spin.setValue(max(1, int(self._restore_reference)))
|
self.reference_spin.setValue(max(1, int(self._restore_reference)))
|
||||||
|
if self._restore_device:
|
||||||
|
self.device_edit.setText(self._restore_device)
|
||||||
|
self._sync_device_row()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ selection
|
# ------------------------------------------------------------------ selection
|
||||||
@property
|
@property
|
||||||
@@ -231,6 +265,10 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
self.note_label.setText(t("video_analysis.blocked", reason=blocked))
|
self.note_label.setText(t("video_analysis.blocked", reason=blocked))
|
||||||
return None
|
return None
|
||||||
reference = int(self.reference_spin.value())
|
reference = int(self.reference_spin.value())
|
||||||
|
problem = self._device_problem()
|
||||||
|
if problem:
|
||||||
|
self.note_label.setText(problem)
|
||||||
|
return None
|
||||||
return BareVideoSetup(
|
return BareVideoSetup(
|
||||||
profile_ref=entry.ref,
|
profile_ref=entry.ref,
|
||||||
calibration=self._chosen_calibration(),
|
calibration=self._chosen_calibration(),
|
||||||
@@ -239,6 +277,7 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
# Saying so as ``None`` keeps a reset the pass cannot apply from being recorded
|
# Saying so as ``None`` keeps a reset the pass cannot apply from being recorded
|
||||||
# as one that happened.
|
# as one that happened.
|
||||||
reference_frame=None if reference == 1 else reference,
|
reference_frame=None if reference == 1 else reference,
|
||||||
|
device_id=self._confirmed_device_id(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ content
|
# ------------------------------------------------------------------ content
|
||||||
@@ -261,6 +300,7 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked)
|
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked)
|
||||||
self._refresh_time_label()
|
self._refresh_time_label()
|
||||||
|
self._sync_device_row()
|
||||||
|
|
||||||
def _refresh_calibrations(self, *, custom: Path | None) -> None:
|
def _refresh_calibrations(self, *, custom: Path | None) -> None:
|
||||||
"""The calibration field: the profile's default, raw force, or a chosen file."""
|
"""The calibration field: the profile's default, raw force, or a chosen file."""
|
||||||
@@ -286,6 +326,7 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self.calibration_combo.setCurrentIndex(self.calibration_combo.count() - 1)
|
self.calibration_combo.setCurrentIndex(self.calibration_combo.count() - 1)
|
||||||
self.calibration_combo.blockSignals(False)
|
self.calibration_combo.blockSignals(False)
|
||||||
|
self._sync_device_row()
|
||||||
|
|
||||||
def _chosen_calibration(self) -> Path | None:
|
def _chosen_calibration(self) -> Path | None:
|
||||||
role = str(self.calibration_combo.currentData() or "")
|
role = str(self.calibration_combo.currentData() or "")
|
||||||
@@ -308,6 +349,64 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
if chosen:
|
if chosen:
|
||||||
self._refresh_calibrations(custom=Path(chosen))
|
self._refresh_calibrations(custom=Path(chosen))
|
||||||
|
|
||||||
|
def _is_finger(self) -> bool:
|
||||||
|
entry = self.entry()
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
return str(entry.product_family or "").strip().lower() == "finger"
|
||||||
|
|
||||||
|
def _chosen_file(self) -> Path | None:
|
||||||
|
"""A file the operator browsed. The profile default is not one of these."""
|
||||||
|
if str(self.calibration_combo.currentData() or "") != ROLE_FILE:
|
||||||
|
return None
|
||||||
|
return self._custom_calibration
|
||||||
|
|
||||||
|
def _sync_device_row(self) -> None:
|
||||||
|
"""Show the device serial only when a Finger pass will load a browsed file."""
|
||||||
|
path = self._chosen_file() if self._is_finger() else None
|
||||||
|
visible = path is not None
|
||||||
|
self.device_label.setVisible(visible)
|
||||||
|
self.device_edit.setVisible(visible)
|
||||||
|
form = getattr(self, "_calibration_form", None)
|
||||||
|
if form is not None and hasattr(form, "setRowVisible"):
|
||||||
|
form.setRowVisible(self.device_label, visible)
|
||||||
|
if not visible:
|
||||||
|
self._device_source = None
|
||||||
|
return
|
||||||
|
if path != self._device_source:
|
||||||
|
self._device_source = path
|
||||||
|
_sensor_type, sn = calibration_identity(path)
|
||||||
|
if sn:
|
||||||
|
self.device_edit.setText(sn)
|
||||||
|
|
||||||
|
def _confirmed_device_id(self) -> str | None:
|
||||||
|
if self._chosen_file() is None or not self._is_finger():
|
||||||
|
return None
|
||||||
|
serial = self.device_edit.text().strip()
|
||||||
|
return serial or None
|
||||||
|
|
||||||
|
def _device_problem(self) -> str:
|
||||||
|
"""Why this Finger file cannot be applied, or "" when the serial matches."""
|
||||||
|
path = self._chosen_file()
|
||||||
|
if not self._is_finger() or path is None:
|
||||||
|
return ""
|
||||||
|
serial = self.device_edit.text().strip()
|
||||||
|
if not serial:
|
||||||
|
return t("video_analysis.device_required")
|
||||||
|
sensor_type, sn = calibration_identity(path)
|
||||||
|
if sensor_type and sensor_type != "finger":
|
||||||
|
return t("video_analysis.device_family", family=sensor_type)
|
||||||
|
if not sn:
|
||||||
|
return t("video_analysis.device_missing_sn")
|
||||||
|
if sn != serial:
|
||||||
|
return t("video_analysis.device_mismatch", sn=sn)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _on_accept(self) -> None:
|
||||||
|
if self.choice() is None:
|
||||||
|
return
|
||||||
|
self.accept()
|
||||||
|
|
||||||
def _refresh_time_label(self) -> None:
|
def _refresh_time_label(self) -> None:
|
||||||
fps = float(self._facts.fps) or 0.0
|
fps = float(self._facts.fps) or 0.0
|
||||||
seconds = (int(self.reference_spin.value()) - 1) / fps if fps > 0 else 0.0
|
seconds = (int(self.reference_spin.value()) - 1) / fps if fps > 0 else 0.0
|
||||||
@@ -329,6 +428,8 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self.profile_label.setText(t("video_analysis.profile"))
|
self.profile_label.setText(t("video_analysis.profile"))
|
||||||
self.calibration_label.setText(t("video_analysis.calibration"))
|
self.calibration_label.setText(t("video_analysis.calibration"))
|
||||||
|
self.device_label.setText(t("video_analysis.device"))
|
||||||
|
self.device_edit.setPlaceholderText(t("video_analysis.device_tip"))
|
||||||
self.reference_label.setText(t("video_analysis.reference"))
|
self.reference_label.setText(t("video_analysis.reference"))
|
||||||
self.btn_browse.setText(t("video_analysis.calibration_browse"))
|
self.btn_browse.setText(t("video_analysis.calibration_browse"))
|
||||||
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText(t("dialog.ok"))
|
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText(t("dialog.ok"))
|
||||||
|
|||||||
@@ -736,3 +736,48 @@ def test_analysis_setup_reopens_the_prompt_after_the_first_pass(
|
|||||||
assert block is not None
|
assert block is not None
|
||||||
assert int(block["reference_frame"]) == 10
|
assert int(block["reference_frame"]) == 10
|
||||||
assert window.replay_ctl.available_result() is not None
|
assert window.replay_ctl.available_result() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_finger_calibration_file_must_name_the_same_device(qt_app, tmp_path):
|
||||||
|
"""A browsed Finger file is accepted only when the device id matches its SN."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
clip = tmp_path / "clip.mp4"
|
||||||
|
writer = cv2.VideoWriter(str(clip), cv2.VideoWriter_fourcc(*"mp4v"), 10.0, (32, 24))
|
||||||
|
for _ in range(4):
|
||||||
|
writer.write(np.zeros((24, 32, 3), dtype=np.uint8))
|
||||||
|
writer.release()
|
||||||
|
facts = bare_video.probe(bare_video.resolve_video(clip))
|
||||||
|
catalog = scan_sensor_catalog()
|
||||||
|
finger = next(
|
||||||
|
(
|
||||||
|
entry
|
||||||
|
for entry in catalog.entries
|
||||||
|
if str(entry.product_family) == "finger"
|
||||||
|
and bare_video.setup_blocked_reason(entry) is None
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert finger is not None, "the catalog has no startable Finger profile"
|
||||||
|
path = tmp_path / "DD02-14.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
dialog = VideoAnalysisDialog(video=clip, facts=facts, catalog=catalog)
|
||||||
|
try:
|
||||||
|
assert dialog.select_profile(finger.ref)
|
||||||
|
dialog.select_calibration(path)
|
||||||
|
assert not dialog.device_edit.isHidden()
|
||||||
|
assert dialog.device_edit.text() == "DD02-14"
|
||||||
|
chosen = dialog.choice()
|
||||||
|
assert chosen is not None
|
||||||
|
assert chosen.calibration == path
|
||||||
|
assert chosen.device_id == "DD02-14"
|
||||||
|
dialog.device_edit.setText("OTHER")
|
||||||
|
assert dialog.choice() is None
|
||||||
|
assert dialog.note_label.text() == t(
|
||||||
|
"video_analysis.device_mismatch", sn="DD02-14"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
dialog.deleteLater()
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ class BareVideoSetup:
|
|||||||
#: (the first frame it processes — which *is* a clean tracker, so the first frame is the
|
#: (the first frame it processes — which *is* a clean tracker, so the first frame is the
|
||||||
#: reference without a reset).
|
#: reference without a reset).
|
||||||
reference_frame: int | None = None
|
reference_frame: int | None = None
|
||||||
|
#: Serial the operator confirmed for ``calibration``. Empty when the pass uses the
|
||||||
|
#: bundle's own file or raw force.
|
||||||
|
device_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -346,6 +349,7 @@ def session_document(
|
|||||||
reference_frame: int | None,
|
reference_frame: int | None,
|
||||||
identity: str,
|
identity: str,
|
||||||
source_facts: dict[str, Any],
|
source_facts: dict[str, Any],
|
||||||
|
device_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""The ``session.json`` a bare video is analysed under.
|
"""The ``session.json`` a bare video is analysed under.
|
||||||
|
|
||||||
@@ -388,6 +392,7 @@ def session_document(
|
|||||||
"profile_ref": str(profile.ref),
|
"profile_ref": str(profile.ref),
|
||||||
"profile_id": str(profile.profile_id or ""),
|
"profile_id": str(profile.profile_id or ""),
|
||||||
"calibration": "" if calibration is None else str(Path(calibration).resolve()),
|
"calibration": "" if calibration is None else str(Path(calibration).resolve()),
|
||||||
|
"device_id": str(device_id or ""),
|
||||||
"reference_frame": None
|
"reference_frame": None
|
||||||
if reference_frame is None
|
if reference_frame is None
|
||||||
else int(reference_frame),
|
else int(reference_frame),
|
||||||
@@ -511,6 +516,7 @@ def build_workspace(
|
|||||||
identity, source_facts = video_identity(resolved)
|
identity, source_facts = video_identity(resolved)
|
||||||
calibration = None if setup is None else setup.calibration
|
calibration = None if setup is None else setup.calibration
|
||||||
reference_frame = None if setup is None else setup.reference_frame
|
reference_frame = None if setup is None else setup.reference_frame
|
||||||
|
device_id = None if setup is None else setup.device_id
|
||||||
if calibration is not None and not Path(calibration).is_file():
|
if calibration is not None and not Path(calibration).is_file():
|
||||||
raise BareVideoError(f"the chosen calibration {calibration} is not there")
|
raise BareVideoError(f"the chosen calibration {calibration} is not there")
|
||||||
frames = synthetic_frame_index(facts)
|
frames = synthetic_frame_index(facts)
|
||||||
@@ -522,6 +528,7 @@ def build_workspace(
|
|||||||
reference_frame=reference_frame,
|
reference_frame=reference_frame,
|
||||||
identity=identity,
|
identity=identity,
|
||||||
source_facts=source_facts,
|
source_facts=source_facts,
|
||||||
|
device_id=device_id,
|
||||||
)
|
)
|
||||||
directory = workspace_directory(identity, root=root)
|
directory = workspace_directory(identity, root=root)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -167,14 +167,18 @@ class ChainSpec:
|
|||||||
export_dir: Path | None = None
|
export_dir: Path | None = None
|
||||||
flip_source_vertical: bool = True
|
flip_source_vertical: bool = True
|
||||||
mirror: bool = False
|
mirror: bool = False
|
||||||
#: An explicit ``force_calibration.json`` for the box chain, or ``None`` for whatever
|
#: An explicit force-calibration file, or ``None`` for whatever the configuration's
|
||||||
#: the configuration's own bundle resolves. It is *not* part of :meth:`identity`,
|
#: own bundle resolves. It is *not* part of :meth:`identity`, deliberately: a recording
|
||||||
#: deliberately: a recording was taken under one calibration and the identity of *what
|
#: was taken under one calibration and the identity of *what it is* must not move when
|
||||||
#: it is* must not move when a different curve is tried against it. The calibration
|
#: a different curve is tried against it. The calibration half of the invalidation key
|
||||||
#: half of the invalidation key is the recording's own ``calibration_id`` (see
|
#: is the recording's own ``calibration_id`` (see
|
||||||
#: :meth:`AnalysisService.dependency_report`), which the generated workspace sets from
|
#: :meth:`AnalysisService.dependency_report`), which the generated workspace sets from
|
||||||
#: the chosen file's content.
|
#: the chosen file's content.
|
||||||
calibration: Path | None = None
|
calibration: Path | None = None
|
||||||
|
#: Device serial the operator confirmed for ``calibration``. Passed with ``cal_path``
|
||||||
|
#: so the SDK can reject a file whose ``sn`` is a different sensor. Not part of
|
||||||
|
#: :meth:`identity`: a mismatch fails the open, and a match does not change the file.
|
||||||
|
device_id: str | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.sensor_type not in ("finger", "box"):
|
if self.sensor_type not in ("finger", "box"):
|
||||||
@@ -210,6 +214,7 @@ def chain_spec_for(
|
|||||||
config_ref: str | Path | None = None,
|
config_ref: str | Path | None = None,
|
||||||
export_dir: str | Path | None = None,
|
export_dir: str | Path | None = None,
|
||||||
calibration: str | Path | None = None,
|
calibration: str | Path | None = None,
|
||||||
|
device_id: str | None = None,
|
||||||
) -> ChainSpec:
|
) -> ChainSpec:
|
||||||
"""The chain a recording was made with, unless the caller names a different one.
|
"""The chain a recording was made with, unless the caller names a different one.
|
||||||
|
|
||||||
@@ -234,11 +239,13 @@ def chain_spec_for(
|
|||||||
bundle = Path(str(export_dir))
|
bundle = Path(str(export_dir))
|
||||||
elif kind == "finger" and resolved is not None:
|
elif kind == "finger" and resolved is not None:
|
||||||
bundle = resolve_finger_export_dir(resolved)
|
bundle = resolve_finger_export_dir(resolved)
|
||||||
|
serial = str(device_id or "").strip() or None
|
||||||
return ChainSpec(
|
return ChainSpec(
|
||||||
sensor_type=kind,
|
sensor_type=kind,
|
||||||
config_ref=resolved,
|
config_ref=resolved,
|
||||||
export_dir=bundle,
|
export_dir=bundle,
|
||||||
calibration=None if calibration is None else Path(str(calibration)),
|
calibration=None if calibration is None else Path(str(calibration)),
|
||||||
|
device_id=serial,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -296,15 +303,6 @@ class _OpenChain:
|
|||||||
if self.spec.sensor_type == "finger":
|
if self.spec.sensor_type == "finger":
|
||||||
from orisys.finger import FingerRuntime
|
from orisys.finger import FingerRuntime
|
||||||
|
|
||||||
if self.spec.calibration is not None:
|
|
||||||
# The finger runtime opens its ``Sensor`` without a calibration path,
|
|
||||||
# so a chosen curve cannot be applied to this chain. Refusing is the
|
|
||||||
# only honest answer: a pass that ran anyway would store numbers from a
|
|
||||||
# different calibration than the one the caller asked for.
|
|
||||||
raise RuntimeError(
|
|
||||||
"the finger chain cannot take a chosen force calibration; it uses "
|
|
||||||
"the one the bundle resolves"
|
|
||||||
)
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"video": str(self.video),
|
"video": str(self.video),
|
||||||
"verbose": False,
|
"verbose": False,
|
||||||
@@ -315,6 +313,13 @@ class _OpenChain:
|
|||||||
kwargs["config"] = str(self.spec.config_ref)
|
kwargs["config"] = str(self.spec.config_ref)
|
||||||
if self.spec.export_dir is not None:
|
if self.spec.export_dir is not None:
|
||||||
kwargs["export_dir"] = str(self.spec.export_dir)
|
kwargs["export_dir"] = str(self.spec.export_dir)
|
||||||
|
if self.spec.calibration is not None:
|
||||||
|
# ``cal_path`` is the file the operator chose. ``device_id`` is the
|
||||||
|
# serial they confirmed for this video; the SDK rejects the file when
|
||||||
|
# its ``sn`` is a different sensor.
|
||||||
|
kwargs["cal_path"] = str(self.spec.calibration)
|
||||||
|
if self.spec.device_id:
|
||||||
|
kwargs["device_id"] = str(self.spec.device_id)
|
||||||
self._runtime = FingerRuntime.open(**kwargs)
|
self._runtime = FingerRuntime.open(**kwargs)
|
||||||
self.sensor = self._runtime.tactile
|
self.sensor = self._runtime.tactile
|
||||||
self._read = self._runtime.read_source
|
self._read = self._runtime.read_source
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""A chosen Finger calibration is handed to the runtime, not refused before it opens."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from orisys_app_shared.analysis.service import ChainSpec, _OpenChain
|
||||||
|
|
||||||
|
|
||||||
|
class _Runtime:
|
||||||
|
tactile = object()
|
||||||
|
capture = None
|
||||||
|
opened: dict = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def open(cls, **kwargs):
|
||||||
|
cls.opened = dict(kwargs)
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
def read_source(self):
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
def process_frame(self, frame):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_finger_chain_passes_the_chosen_file_and_device(monkeypatch, tmp_path):
|
||||||
|
import orisys.finger as finger
|
||||||
|
|
||||||
|
monkeypatch.setattr(finger, "FingerRuntime", _Runtime)
|
||||||
|
_Runtime.opened = {}
|
||||||
|
calibration = tmp_path / "DD02-14.json"
|
||||||
|
calibration.write_text("{}", encoding="utf-8")
|
||||||
|
video = tmp_path / "clip.mp4"
|
||||||
|
video.write_bytes(b"")
|
||||||
|
spec = ChainSpec(
|
||||||
|
sensor_type="finger",
|
||||||
|
calibration=calibration,
|
||||||
|
device_id="DD02-14",
|
||||||
|
)
|
||||||
|
_OpenChain(spec, video, tmp_path).open()
|
||||||
|
assert _Runtime.opened["cal_path"] == str(calibration)
|
||||||
|
assert _Runtime.opened["device_id"] == "DD02-14"
|
||||||
|
assert _Runtime.opened["video"] == str(video)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_finger_chain_without_a_file_does_not_invent_one(monkeypatch, tmp_path):
|
||||||
|
import orisys.finger as finger
|
||||||
|
|
||||||
|
monkeypatch.setattr(finger, "FingerRuntime", _Runtime)
|
||||||
|
_Runtime.opened = {}
|
||||||
|
video = tmp_path / "clip.mp4"
|
||||||
|
video.write_bytes(b"")
|
||||||
|
spec = ChainSpec(sensor_type="finger")
|
||||||
|
_OpenChain(spec, video, tmp_path).open()
|
||||||
|
assert "cal_path" not in _Runtime.opened
|
||||||
|
assert "device_id" not in _Runtime.opened
|
||||||
Reference in New Issue
Block a user