diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index cd74071..dbdf1ea 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -333,6 +333,12 @@ "calibration_file": "Chosen file — {name}", "calibration_browse": "Choose a file…", "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_time": "{seconds} s", "blocked": "This profile cannot be used: {reason}" diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 62746ab..5a49f25 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -333,6 +333,12 @@ "calibration_file": "已选文件 — {name}", "calibration_browse": "选择文件…", "calibration_filter": "力标定 (*.json);;所有文件 (*)", + "device": "设备编号", + "device_tip": "与标定文件中的 SN 一致", + "device_required": "使用这份 Finger 标定需要填写视频对应的设备编号", + "device_missing_sn": "这份标定文件没有 SN,无法确认它属于哪台传感器", + "device_mismatch": "标定文件的 SN 是 {sn},与填写的设备编号不一致", + "device_family": "这份标定属于 {family},不能用于 Finger", "reference": "参考帧", "reference_time": "{seconds} 秒", "blocked": "这套配置不能用:{reason}" diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 7b684a5..eadb18c 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -2732,7 +2732,9 @@ class StudioMainWindow(QMainWindow): session = RecordingReader(Path(recording)).session() synthetic = dict(session.get("synthetic") or {}) 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") if reference is None: @@ -2755,6 +2757,7 @@ class StudioMainWindow(QMainWindow): initial_calibration: Path | None = None, prefer_no_calibration: bool = False, initial_reference_frame: int | None = None, + initial_device_id: str | None = None, ) -> Path | None: """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, prefer_no_calibration=prefer_no_calibration, initial_reference_frame=initial_reference_frame, + initial_device_id=initial_device_id, theme=self._theme, ) if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted: diff --git a/apps/studio/src/orisys_studio/replay_controller.py b/apps/studio/src/orisys_studio/replay_controller.py index e65d7c3..ccf1cd6 100644 --- a/apps/studio/src/orisys_studio/replay_controller.py +++ b/apps/studio/src/orisys_studio/replay_controller.py @@ -439,6 +439,7 @@ class ReplayController(QObject): initial_calibration=None if prefer_none else Path(cal), prefer_no_calibration=prefer_none, initial_reference_frame=block.get("reference_frame"), + initial_device_id=str(block.get("device_id") or "") or None, ) if workspace is None: return diff --git a/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py b/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py index 2d711d0..e75dacc 100644 --- a/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py +++ b/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py @@ -21,6 +21,7 @@ reason it cannot be used is one line, both in the language the catalog already s from __future__ import annotations +import json from pathlib import Path from orisys_app_shared.analysis.bare_video import ( @@ -40,6 +41,7 @@ from PySide6.QtWidgets import ( QFormLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QSpinBox, QVBoxLayout, @@ -54,6 +56,25 @@ ROLE_NONE = "none" 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): """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, prefer_no_calibration: bool = False, initial_reference_frame: int | None = None, + initial_device_id: str | None = None, lock_profile: bool = False, theme: str = "dark", ) -> None: @@ -87,6 +109,8 @@ class VideoAnalysisDialog(QDialog): self._restore_reference = ( 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.setObjectName("videoAnalysisDialog") self.setModal(True) @@ -145,6 +169,13 @@ class VideoAnalysisDialog(QDialog): calibration_row.addWidget(self.btn_browse) 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.setObjectName("fieldLabel") self.reference_spin = QSpinBox() @@ -170,7 +201,7 @@ class VideoAnalysisDialog(QDialog): self.buttons = QDialogButtonBox( 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) root.addWidget(self.buttons) @@ -185,6 +216,9 @@ class VideoAnalysisDialog(QDialog): elif self._restore_calibration is not None: self.select_calibration(self._restore_calibration) 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 @property @@ -231,6 +265,10 @@ class VideoAnalysisDialog(QDialog): self.note_label.setText(t("video_analysis.blocked", reason=blocked)) return None reference = int(self.reference_spin.value()) + problem = self._device_problem() + if problem: + self.note_label.setText(problem) + return None return BareVideoSetup( profile_ref=entry.ref, 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 # as one that happened. reference_frame=None if reference == 1 else reference, + device_id=self._confirmed_device_id(), ) # ------------------------------------------------------------------ content @@ -261,6 +300,7 @@ class VideoAnalysisDialog(QDialog): ) self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked) self._refresh_time_label() + self._sync_device_row() def _refresh_calibrations(self, *, custom: Path | None) -> None: """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.blockSignals(False) + self._sync_device_row() def _chosen_calibration(self) -> Path | None: role = str(self.calibration_combo.currentData() or "") @@ -308,6 +349,64 @@ class VideoAnalysisDialog(QDialog): if 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: fps = float(self._facts.fps) or 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.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.btn_browse.setText(t("video_analysis.calibration_browse")) self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText(t("dialog.ok")) diff --git a/apps/studio/tests/test_bare_video_analysis.py b/apps/studio/tests/test_bare_video_analysis.py index 2e15e84..084274c 100644 --- a/apps/studio/tests/test_bare_video_analysis.py +++ b/apps/studio/tests/test_bare_video_analysis.py @@ -736,3 +736,48 @@ def test_analysis_setup_reopens_the_prompt_after_the_first_pass( assert block is not None assert int(block["reference_frame"]) == 10 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() diff --git a/shared/src/orisys_app_shared/analysis/bare_video.py b/shared/src/orisys_app_shared/analysis/bare_video.py index 080b0b6..bf13544 100644 --- a/shared/src/orisys_app_shared/analysis/bare_video.py +++ b/shared/src/orisys_app_shared/analysis/bare_video.py @@ -139,6 +139,9 @@ class BareVideoSetup: #: (the first frame it processes — which *is* a clean tracker, so the first frame is the #: reference without a reset). 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) @@ -346,6 +349,7 @@ def session_document( reference_frame: int | None, identity: str, source_facts: dict[str, Any], + device_id: str | None = None, ) -> dict[str, Any]: """The ``session.json`` a bare video is analysed under. @@ -388,6 +392,7 @@ def session_document( "profile_ref": str(profile.ref), "profile_id": str(profile.profile_id or ""), "calibration": "" if calibration is None else str(Path(calibration).resolve()), + "device_id": str(device_id or ""), "reference_frame": None if reference_frame is None else int(reference_frame), @@ -511,6 +516,7 @@ def build_workspace( identity, source_facts = video_identity(resolved) calibration = None if setup is None else setup.calibration 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(): raise BareVideoError(f"the chosen calibration {calibration} is not there") frames = synthetic_frame_index(facts) @@ -522,6 +528,7 @@ def build_workspace( reference_frame=reference_frame, identity=identity, source_facts=source_facts, + device_id=device_id, ) directory = workspace_directory(identity, root=root) directory.mkdir(parents=True, exist_ok=True) diff --git a/shared/src/orisys_app_shared/analysis/service.py b/shared/src/orisys_app_shared/analysis/service.py index e8d9876..98bdadd 100644 --- a/shared/src/orisys_app_shared/analysis/service.py +++ b/shared/src/orisys_app_shared/analysis/service.py @@ -167,14 +167,18 @@ class ChainSpec: export_dir: Path | None = None flip_source_vertical: bool = True mirror: bool = False - #: An explicit ``force_calibration.json`` for the box chain, or ``None`` for whatever - #: the configuration's own bundle resolves. It is *not* part of :meth:`identity`, - #: deliberately: a recording was taken under one calibration and the identity of *what - #: it is* must not move when a different curve is tried against it. The calibration - #: half of the invalidation key is the recording's own ``calibration_id`` (see + #: An explicit force-calibration file, or ``None`` for whatever the configuration's + #: own bundle resolves. It is *not* part of :meth:`identity`, deliberately: a recording + #: was taken under one calibration and the identity of *what it is* must not move when + #: a different curve is tried against it. The calibration half of the invalidation key + #: is the recording's own ``calibration_id`` (see #: :meth:`AnalysisService.dependency_report`), which the generated workspace sets from #: the chosen file's content. 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: if self.sensor_type not in ("finger", "box"): @@ -210,6 +214,7 @@ def chain_spec_for( config_ref: str | Path | None = None, export_dir: str | Path | None = None, calibration: str | Path | None = None, + device_id: str | None = None, ) -> ChainSpec: """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)) elif kind == "finger" and resolved is not None: bundle = resolve_finger_export_dir(resolved) + serial = str(device_id or "").strip() or None return ChainSpec( sensor_type=kind, config_ref=resolved, export_dir=bundle, 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": 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] = { "video": str(self.video), "verbose": False, @@ -315,6 +313,13 @@ class _OpenChain: kwargs["config"] = str(self.spec.config_ref) if self.spec.export_dir is not None: 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.sensor = self._runtime.tactile self._read = self._runtime.read_source diff --git a/shared/tests/test_finger_chain_calibration.py b/shared/tests/test_finger_chain_calibration.py new file mode 100644 index 0000000..7bc92c4 --- /dev/null +++ b/shared/tests/test_finger_chain_calibration.py @@ -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