让 Finger 视频分析使用所选的力标定文件。

分析链不再在打开前拒绝标定,而是把文件作为 cal_path 交给 FingerRuntime,并用操作者确认的设备编号核对文件里的 SN。标定传递测试与对话框 SN 校验通过。
This commit is contained in:
ykcheungab
2026-10-08 15:12:08 +08:00
parent 7afd3169fd
commit 48624dec99
9 changed files with 246 additions and 16 deletions
+6
View File
@@ -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}"
+6
View File
@@ -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}"
+5 -1
View File
@@ -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