From 31750f0f84d1076e657a70c238b36d20f55fa05f Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 19 Oct 2026 09:47:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20Studio=20=E5=A4=9A?= =?UTF-8?q?=E7=82=B9=E6=A0=87=E5=AE=9A=E7=9A=84=E4=BC=9A=E8=AF=9D=E7=BD=AE?= =?UTF-8?q?=E9=9B=B6=E4=B8=8E=E7=8A=B6=E6=80=81=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持显式无载荷置零、处理线程内切换模型及完整标定包快照。shared、界面检查与兼容测试通过;Studio 全量仍有一项回放停止时序测试失败。 --- apps/studio/config/locales/en_US.json | 14 ++ apps/studio/config/locales/zh_CN.json | 14 ++ apps/studio/docs/sensor-bundles.md | 26 +++ .../src/orisys_studio/acquire_presenter.py | 3 + apps/studio/src/orisys_studio/app_window.py | 22 ++- apps/studio/src/orisys_studio/ui/toolbar.py | 22 +++ .../orisys_studio/ui/video_analysis_dialog.py | 5 + .../studio/tests/test_model_calibration_ui.py | 36 ++++ .../acquisition/finger_worker.py | 117 +++++++++-- .../orisys_app_shared/acquisition/models.py | 17 +- .../acquisition/sdk_worker.py | 10 + .../orisys_app_shared/analysis/bare_video.py | 5 + .../src/orisys_app_shared/analysis/model.py | 12 +- .../src/orisys_app_shared/analysis/service.py | 25 ++- shared/src/orisys_app_shared/force/units.py | 2 +- .../record/calibration_snapshot.py | 20 +- .../orisys_app_shared/record/experiment.py | 6 +- shared/src/orisys_app_shared/record/sample.py | 6 +- shared/tests/test_multical_integration.py | 184 ++++++++++++++++++ 19 files changed, 514 insertions(+), 32 deletions(-) create mode 100644 apps/studio/tests/test_model_calibration_ui.py create mode 100644 shared/tests/test_multical_integration.py diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 30202d5..7d00b88 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -1,4 +1,18 @@ { + "multical": { + "video_zero": "I confirm 30 frames from the reference frame are unloaded", + "video_zero_tip": "For models requiring a session zero. Measure it from this interval; tracker reference alone does not imply zero load.", + "zero": "Unloaded zero", + "zero_confirm": "Remove all load and keep the sensor unloaded for the next 30 processed frames. For video, pause at a known unloaded interval first. Measure a new session zero now?", + "zero_pending": "Measuring unloaded zero from 30 frames. Keep all load removed.", + "ok": "Ready", + "zero_unavailable": "Unloaded zero required", + "no_contact": "No contact", + "outside_coverage": "Outside calibration coverage", + "coordinate_unmapped": "Contact position unavailable", + "out_of_range": "Outside calibrated force range", + "invalid_input": "Invalid measurement" + }, "app": { "title": "Orisys Studio", "about_title": "About", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 0002ad9..f8e7074 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -1,4 +1,18 @@ { + "multical": { + "video_zero": "我确认从参考帧开始的 30 帧均无载荷", + "video_zero_tip": "适用于需要单独测量零点的模型。用此片段测量会话零点;选择跟踪参考帧本身不代表零载荷。", + "zero": "无载荷置零", + "zero_confirm": "请移除全部载荷,并在接下来 30 个处理帧内保持无载荷。使用录像时,请先暂停在已确认的无载荷片段。现在测量本次会话零点吗?", + "zero_pending": "正在用 30 帧测量零点,请保持无载荷。", + "ok": "有效", + "zero_unavailable": "需要无载荷置零", + "no_contact": "未检测到接触", + "outside_coverage": "超出标定覆盖区域", + "coordinate_unmapped": "接触位置无法映射", + "out_of_range": "超出标定力值范围", + "invalid_input": "测量无效" + }, "app": { "title": "Orisys Studio", "about_title": "关于", diff --git a/apps/studio/docs/sensor-bundles.md b/apps/studio/docs/sensor-bundles.md index 8ac0ceb..63c1b62 100644 --- a/apps/studio/docs/sensor-bundles.md +++ b/apps/studio/docs/sensor-bundles.md @@ -64,6 +64,32 @@ Dropping a key means migrating those readers first, not tidying up the JSON. ## Verification +### Multi-point force calibration + +Finger sessions accept SDK schema 3 files through the existing calibration-file picker and +operator-confirmed SN. The SDK checks processing settings, geometry and coordinate-map identity; +a mismatch blocks loading rather than falling back to raw values. Keep the JSON and its declared +NPZ artifacts together. The file format and supported models are defined in the +[SDK calibration contract](../../../../orisys-sdk/docs/calibration-model-contract.zh.md). + +After starting, remove all load and select **Unloaded zero**. Confirm that the next 30 processed +frames are unloaded. Contact, invalid readings or tracker reset cancel an unfinished measurement. +Zero values belong to this session; tracker reset and model replacement require a new zero. +Model replacement is applied between frames on the SDK HHD thread. The rail reports per-axis +zero, contact, coverage and range states; an unavailable newton value stays unavailable while raw +measurements remain accessible. Selecting a tracker reference alone never implies zero load. + +For bare-video recomputation, explicitly confirm the unloaded 30-frame interval starting at the +selected reference frame. This choice changes the analysis cache identity; a short or invalid +interval fails the pass. Leave the option unchecked for legacy calibration or when no unloaded +interval has been verified. + +Recording format 1.5 appends `calibration_status` and `calibration_reference` JSON columns to +`forces.csv`; existing columns keep their positions. Raw validity and per-axis model validity +are distinct. Recordings store each schema 3 JSON with its artifacts in a directory identified by +the SDK model fingerprint, so recorded calibration remains loadable without the original folder. +Older recordings remain readable and carry no invented zero or model-state information. + `apps/studio/tests/test_sensor_catalog_root.py` asserts the descriptor's fields, that every declared path is relative and present, that the declared map is the one the running chain derives for itself (the SDK's `scaled_map_cache_path`, so `_s0.42.npz` and never a neighbouring diff --git a/apps/studio/src/orisys_studio/acquire_presenter.py b/apps/studio/src/orisys_studio/acquire_presenter.py index 4db5c2b..29a7520 100644 --- a/apps/studio/src/orisys_studio/acquire_presenter.py +++ b/apps/studio/src/orisys_studio/acquire_presenter.py @@ -132,6 +132,9 @@ class AcquirePresenter(QObject): self._host.refresh_record_state() def _push_forces(self, packet: Any): + show_model = getattr(self._host.toolbar, "set_model_calibration_status", None) + if callable(show_model): + show_model(dict(getattr(packet, "calibration_status", {}) or {})) record = to_record_sample(packet) if not record.valid: self._host.session.last_sample_valid = False diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index f5e3e92..e1b9b21 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -911,6 +911,7 @@ class StudioMainWindow(QMainWindow): self.toolbar.stop_clicked.connect(self._on_stop_clicked) self.toolbar.record_toggled.connect(self._on_record_toggled) self.toolbar.reset_clicked.connect(self._on_reset) + self.toolbar.calibration_zero_clicked.connect(self._on_calibration_zero) self.toolbar.units_changed.connect(self.display_ctl.on_units_changed) self.toolbar.settings_toggled.connect(self.layout_ctl.on_settings_toggled) @@ -1846,7 +1847,7 @@ class StudioMainWindow(QMainWindow): t("sensor.live_calibration_invalid", reason=problem) ) return - cal_path, _device_id, problem = self._resolved_live_calibration() + cal_path, device_id, problem = self._resolved_live_calibration() if problem: self.statusBar().showMessage( t("sensor.live_calibration_invalid", reason=problem) @@ -1859,7 +1860,10 @@ class StudioMainWindow(QMainWindow): if worker is None or not hasattr(worker, "set_pending_calibration"): self.statusBar().showMessage(t("cal.apply_no_target")) return - worker.set_pending_calibration(cal_path) + if device_id and hasattr(worker, "request_calibration_zero"): + worker.set_pending_calibration(cal_path, device_id=device_id) + else: + worker.set_pending_calibration(cal_path) self.statusBar().showMessage( t("sensor.live_calibration_applied", name=cal_path.name) ) @@ -2319,6 +2323,7 @@ class StudioMainWindow(QMainWindow): self._stop_preview(announce=False) self.force_plots.reset() + self.toolbar.set_model_calibration_status({}) units = "N" if self.toolbar.units() == "newton" else "raw" self.force_plots.configure_presentation( sensor_type=sensor_type, @@ -2482,6 +2487,19 @@ class StudioMainWindow(QMainWindow): except Exception as exc: self.statusBar().showMessage(t("status.run_failed", error=str(exc))) + def _on_calibration_zero(self) -> None: + """A separate explicit unloaded operation; tracker reset never assumes no load.""" + worker = self._active_worker + if self._session_phase != "running" or not hasattr(worker, "request_calibration_zero"): + return + answer = QMessageBox.question( + self, t("multical.zero"), t("multical.zero_confirm"), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No) + if answer == QMessageBox.StandardButton.Yes: + worker.request_calibration_zero() + self.statusBar().showMessage(t("multical.zero_pending")) + def _reset_display(self) -> None: """The rail's reset in the replay view: clear the display and start the replay over. diff --git a/apps/studio/src/orisys_studio/ui/toolbar.py b/apps/studio/src/orisys_studio/ui/toolbar.py index 6126fcd..304c0e2 100644 --- a/apps/studio/src/orisys_studio/ui/toolbar.py +++ b/apps/studio/src/orisys_studio/ui/toolbar.py @@ -97,6 +97,7 @@ class ToolbarWidget(QWidget): #: Stop: end the live session (releasing the device), or leave the replay. stop_clicked = Signal() reset_clicked = Signal() + calibration_zero_clicked = Signal() units_changed = Signal(str) record_toggled = Signal(bool) #: The settings area was expanded or collapsed by the user. @@ -282,6 +283,12 @@ class ToolbarWidget(QWidget): self.btn_reset.setMinimumHeight(BUTTON_HEIGHT) self.btn_reset.setEnabled(False) self.btn_reset.clicked.connect(self.reset_clicked.emit) + self.btn_calibration_zero = QPushButton() + self.btn_calibration_zero.setMinimumHeight(BUTTON_HEIGHT) + self.btn_calibration_zero.clicked.connect(self.calibration_zero_clicked.emit) + self.btn_calibration_zero.setVisible(False) + self.model_state = ShrinkableLabel() + self.model_state.setVisible(False) self.btn_settings = QPushButton() self.btn_settings.setObjectName("ghostButton") @@ -297,6 +304,7 @@ class ToolbarWidget(QWidget): rail.addWidget(self.source_slot) rail.addWidget(self.source_summary) rail.addWidget(self.calibration_label) + rail.addWidget(self.model_state) rail.addWidget(self.data_state) # Shown, the hint takes the spare width (stretch 1); hidden, the spacer does. rail.addWidget(self.run_hint, 1) @@ -306,6 +314,7 @@ class ToolbarWidget(QWidget): rail.addWidget(_rail(self.btn_run)) rail.addWidget(_rail(self.btn_stop)) rail.addWidget(_rail(self.btn_reset)) + rail.addWidget(self.btn_calibration_zero) rail.addWidget(_rail(self.btn_settings)) return rail @@ -393,6 +402,17 @@ class ToolbarWidget(QWidget): self.calibration_label.setToolTip(self.calibration_label.text()) self.calibration_label.setVisible(bool(text)) + def set_model_calibration_status(self, statuses: dict[str, str]) -> None: + """Model validity is separate from whether a calibration file is loaded.""" + text = ", ".join(f"{axis.upper()}: {t(f'multical.{value}')}" for axis, value in statuses.items()) + self.model_state.setText(text) + self.model_state.setToolTip(text) + self.model_state.setVisible(bool(statuses)) + self.btn_calibration_zero.setText(t("multical.zero")) + self.btn_calibration_zero.setToolTip(t("multical.zero_confirm")) + self.btn_calibration_zero.setVisible(bool(statuses) and self._run_role == "acquire") + self.btn_calibration_zero.setEnabled(self._reset_available) + def set_data_state(self, text: str, token: str = "text_secondary") -> None: """Show/clear the validity readout, coloured by a theme token.""" text = str(text or "") @@ -435,6 +455,7 @@ class ToolbarWidget(QWidget): if wanted == self._run_role: return self._run_role = wanted + self.set_model_calibration_status({}) # A view switch never carries the other view's running state across. if self.btn_run.isChecked(): self.btn_run.blockSignals(True) @@ -524,6 +545,7 @@ class ToolbarWidget(QWidget): """ self._reset_available = bool(enabled) self.btn_reset.setEnabled(self._reset_available) + self.btn_calibration_zero.setEnabled(self._reset_available) self._refresh_reset_label() def reset_target(self) -> str: 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 6031ccd..aa20c13 100644 --- a/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py +++ b/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py @@ -35,6 +35,7 @@ from orisys_app_shared.i18n import t from orisys_app_shared.sensors import CatalogEntry, SensorCatalog from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDialog, QDialogButtonBox, @@ -200,6 +201,9 @@ class VideoAnalysisDialog(QDialog): self.reference_time_label.setObjectName("panelMeta") reference_row.addWidget(self.reference_time_label) calibration_form.addRow(self.reference_label, reference_row) + self.unloaded_reference = QCheckBox(t("multical.video_zero")) + self.unloaded_reference.setToolTip(t("multical.video_zero_tip")) + calibration_form.addRow(self.unloaded_reference) root.addLayout(calibration_form) self.note_label = ShrinkableLabel() @@ -287,6 +291,7 @@ class VideoAnalysisDialog(QDialog): # as one that happened. reference_frame=None if reference == 1 else reference, device_id=self._confirmed_device_id(), + unloaded_reference_frames=30 if self.unloaded_reference.isChecked() else 0, ) # ------------------------------------------------------------------ content diff --git a/apps/studio/tests/test_model_calibration_ui.py b/apps/studio/tests/test_model_calibration_ui.py new file mode 100644 index 0000000..a5a60e8 --- /dev/null +++ b/apps/studio/tests/test_model_calibration_ui.py @@ -0,0 +1,36 @@ +"""Unloaded confirmation is separate from reset and model failures stay visible.""" + +from types import SimpleNamespace + +import pytest +from PySide6.QtWidgets import QMessageBox + +from orisys_studio.app_window import StudioMainWindow +from orisys_studio.ui.toolbar import ToolbarWidget + + +@pytest.mark.parametrize("confirmed", [False, True]) +def test_unloaded_zero_requires_operator_confirmation(monkeypatch, confirmed): + requested = [] + messages = [] + host = SimpleNamespace( + _session_phase="running", + _active_worker=SimpleNamespace(request_calibration_zero=lambda: requested.append(True)), + statusBar=lambda: SimpleNamespace(showMessage=messages.append)) + monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.StandardButton.Yes + if confirmed else QMessageBox.StandardButton.No) + StudioMainWindow._on_calibration_zero(host) + assert bool(requested) == confirmed + + +def test_model_status_and_zero_control_are_cleared_on_playback(qt_app): + toolbar = ToolbarWidget() + toolbar.set_reset_enabled(True) + toolbar.set_model_calibration_status({"fn": "zero_unavailable"}) + assert not toolbar.btn_calibration_zero.isHidden() + assert "FN" in toolbar.model_state.text() + assert toolbar.btn_calibration_zero.isEnabled() + toolbar.set_run_role("playback") + assert toolbar.btn_calibration_zero.isHidden() + assert toolbar.model_state.isHidden() + toolbar.close() diff --git a/shared/src/orisys_app_shared/acquisition/finger_worker.py b/shared/src/orisys_app_shared/acquisition/finger_worker.py index 52740b5..97260d1 100644 --- a/shared/src/orisys_app_shared/acquisition/finger_worker.py +++ b/shared/src/orisys_app_shared/acquisition/finger_worker.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import statistics import threading import time from collections import deque @@ -323,6 +324,12 @@ class FingerAcquisitionWorker(RecordSinkMixin): self._end_reason: str | None = None self._phase: str = "idle" self._pending_calibration: str | None = None + self._pending_device_id: str | None = None + self._pending_zero = False + self._cancel_zero = False + self._zero_samples: list[tuple[float, float, float]] | None = None + self._zero_model = None + self._zero_reference: dict = {} #: Path and device serial for the **next** ``start()`` (live open kwargs). self._session_cal_path: str | None = None self._session_device_id: str | None = None @@ -425,6 +432,11 @@ class FingerAcquisitionWorker(RecordSinkMixin): # zeroing request must not fire into the new session's frames. self._calibration_version = "" self._pending_reset = False + self._pending_zero = False + self._cancel_zero = False + self._zero_samples = None + self._zero_reference = {} + self._pending_calibration = None # Nor any filter state: until this session's sensor has been # configured and asked, there is nothing to report about it. self._filter_state = {} @@ -499,6 +511,9 @@ class FingerAcquisitionWorker(RecordSinkMixin): never computed: keeping the pre-pause reference frame would report the hold itself as a large deformation. """ + with self._lock: + if self._zero_samples is not None: + self._cancel_zero = True pipe = self._pipe if pipe is None and self._pause.is_set(): with self._lock: @@ -531,6 +546,7 @@ class FingerAcquisitionWorker(RecordSinkMixin): if self._session_id <= 0: raise RuntimeError("Finger runtime is not running") self._pending_reset = True + self._cancel_zero = True def _consume_pending_reset( self, runtime, frame_id: int, mono_t: float, *, pipeline=None @@ -563,10 +579,53 @@ class FingerAcquisitionWorker(RecordSinkMixin): if self._on_reset_applied is not None: self._on_reset_applied(int(frame_id), float(mono_t), int(session)) - def set_pending_calibration(self, path: str | Path | None) -> None: + def set_pending_calibration(self, path: str | Path | None, *, device_id=None) -> None: """Queue a force_calibration.json path for the acquisition thread.""" with self._lock: self._pending_calibration = str(path) if path is not None else None + self._pending_device_id = device_id + + def request_calibration_zero(self) -> None: + """Operator-confirmed unloaded interval; collect 30 frames on the HHD owner thread.""" + with self._lock: + self._pending_zero = True + + def _collect_zero(self, runtime, values, frame_id): + sensor = getattr(runtime.tactile, "raw_sensor", runtime.tactile) + model = getattr(sensor, "calibration", None) + contact_found = bool(getattr(sensor, "centroid_found", False)) + with self._lock: + requested = self._pending_zero + self._pending_zero = False + resetting = self._pending_reset or self._cancel_zero + self._cancel_zero = False + if resetting or model is not self._zero_model: + self._zero_samples = None + if resetting: + self._zero_reference = {} + return + if requested: + if getattr(model, "schema_version", None) != 3: + raise ValueError("unloaded zero requires a schema 3 model") + sensor.set_calibration_reference({}, reference_id="zero-measurement-pending") + self._zero_samples = [] + self._zero_model = model + self._zero_reference = {} + if self._zero_samples is None: + return + if contact_found or not np.all(np.isfinite(values)): + self._zero_samples = None + raise ValueError("unloaded zero cancelled: reset, contact or invalid force; remove load and retry") + self._zero_samples.append(tuple(values)) + if len(self._zero_samples) == 30: + raw = {axis: statistics.median(row[index] for row in self._zero_samples) + for index, axis in enumerate(("fn", "fx", "fy")) if axis in model.channels} + reference_id = f"studio-unloaded-{self._session_id}-{frame_id}" + runtime.set_calibration_reference(raw, reference_id=reference_id) + self._zero_reference = {"reference_id": reference_id, "raw": raw, "samples": 30, + "applied_after_frame": frame_id} + self._zero_samples = None + self._status("Unloaded session zero measured; calibration is ready for contact.") def _consume_pending_calibration( self, sensor, frame_id: int = 0, mono_t: float = 0.0 @@ -579,13 +638,19 @@ class FingerAcquisitionWorker(RecordSinkMixin): """ with self._lock: path = self._pending_calibration + device_id = self._pending_device_id self._pending_calibration = None session = self._session_id if path is None or sensor is None: return if not hasattr(sensor, "set_calibration"): raise RuntimeError("sensor does not support set_calibration") - sensor.set_calibration(path) + if device_id: + sensor.set_calibration(path, device_id=device_id) + else: + sensor.set_calibration(path) + self._zero_samples = None + self._zero_reference = {} version = calibration_digest(path) self._calibration_version = version if self._on_calibration_applied is not None: @@ -762,6 +827,21 @@ class FingerAcquisitionWorker(RecordSinkMixin): with self._lock: self._phase = "running" + def on_frame(snap): + # SDK calls this on its HHD thread, after copying this frame's outputs. + if any(value == "zero_unavailable" for value in (getattr(snap, "calibration_status", None) or {}).values()): + self._zero_reference = {} + snapshots.put((snap, self._calibration_version, dict(self._zero_reference))) + try: + mono_ns = getattr(snap.source_metadata, "mono_ns", None) + mono = max(0.0, mono_ns / 1e9 - mono_t0) if mono_ns is not None else max(0.0, time.perf_counter() - mono_t0) + self._consume_pending_calibration(runtime, snap.seq + 1, mono) + self._collect_zero(runtime, (snap.normal_force_raw, snap.shear_x_raw, snap.shear_y_raw), snap.seq) + except Exception as exc: # noqa: BLE001 - reject the request, keep the existing model + logger.warning("Calibration request rejected: %s", exc) + if self._on_error is not None: + self._on_error(str(exc)) + try: pipe = StreamPipeline( raw_sensor, @@ -770,7 +850,7 @@ class FingerAcquisitionWorker(RecordSinkMixin): # the pane's contact overlay and the 2D arrow both read it. contact=True, include=self._stream_include(), - on_frame=snapshots.put, + on_frame=on_frame, ) except Exception as exc: # noqa: BLE001 - reported, not swallowed self._set_end_reason("error") @@ -808,7 +888,7 @@ class FingerAcquisitionWorker(RecordSinkMixin): snap = None if expect > 0: try: - snap = snapshots.get(timeout=0.05) + snap, version, reference = snapshots.get(timeout=0.05) except queue_module.Empty: snap = None except Exception as exc: # noqa: BLE001 - a stage failure ends the session @@ -844,6 +924,8 @@ class FingerAcquisitionWorker(RecordSinkMixin): break capture_fps = packet.capture_fps capture_dropped = packet.capture_dropped + packet.calibration_version = version + packet.calibration_reference = reference if self._pause.is_set(): # The hold arrived after this frame was published but before # it was built: see the box worker. A paused worker publishes @@ -853,18 +935,6 @@ class FingerAcquisitionWorker(RecordSinkMixin): # A zeroing is queued onto the pipeline's flow thread, which # owns the tracker, and reported with the frame and time it # takes effect — the same confirmation rule as the serial loop. - try: - self._consume_pending_calibration( - tactile, frame_id + 1, float(packet.mono_t or 0.0) - ) - except Exception as exc: # noqa: BLE001 - errors.append(f"calibration rejected: {type(exc).__name__}: {exc}") - packet.valid = False - packet.stale_forces = True - packet.forces_valid = False - packet.invalid_reason = packet.invalid_reason or errors[-1] - if self._on_error is not None and session == self._session_id: - self._on_error(errors[-1]) try: self._consume_pending_reset( runtime, @@ -1121,7 +1191,7 @@ class FingerAcquisitionWorker(RecordSinkMixin): # frame is dated when it happened rather than when it was queued. frame_mono = max(0.0, mono_ns / 1e9 - self._mono_t0) try: - self._consume_pending_calibration(tactile, frame_id, frame_mono) + self._consume_pending_calibration(runtime, frame_id, frame_mono) except Exception as exc: # noqa: BLE001 message = f"calibration rejected: {type(exc).__name__}: {exc}" if self._on_error is not None and session == self._session_id: @@ -1168,6 +1238,14 @@ class FingerAcquisitionWorker(RecordSinkMixin): sensor, config_ref=mode.config_ref, ) + if any(value == "zero_unavailable" for value in forces.get("calibration_status", {}).values()): + self._zero_reference = {} + reference = dict(self._zero_reference) + try: + self._collect_zero(runtime, (forces["normal_force_raw"], forces["shear_x_raw"], forces["shear_y_raw"]), frame_id) + except ValueError as exc: + if self._on_error is not None: + self._on_error(str(exc)) invalid_reason = forces.get("invalid_reason") or compute_error raw_frame = _ensure_bgr(source_frame).copy() # The frame the tactile chain consumed, kept for depth replay. @@ -1219,6 +1297,8 @@ class FingerAcquisitionWorker(RecordSinkMixin): force_unit=forces["force_unit"], force_calibrated=forces["force_calibrated"], calibrated_axes=tuple(forces.get("calibrated_axes", ())), + calibration_status=forces.get("calibration_status", {}), + calibration_reference=reference, calibration_version=self._calibration_version, session_id=session, frame_id=frame_id, @@ -1238,7 +1318,8 @@ class FingerAcquisitionWorker(RecordSinkMixin): flow_frame_id=frame_id if flow_valid else last_flow_frame_id, depth_valid=depth_valid, depth_frame_id=frame_id if depth_valid else last_depth_frame_id, - forces_valid=bool(forces.get("valid", True)) and not compute_error, + forces_valid=bool(forces.get("valid", True)) and not compute_error + and all(value == "ok" for value in forces.get("calibration_status", {}).values()), contact=contact, ) with self._lock: diff --git a/shared/src/orisys_app_shared/acquisition/models.py b/shared/src/orisys_app_shared/acquisition/models.py index b0db9c3..7991b9d 100644 --- a/shared/src/orisys_app_shared/acquisition/models.py +++ b/shared/src/orisys_app_shared/acquisition/models.py @@ -9,7 +9,7 @@ from __future__ import annotations import json import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path import cv2 @@ -128,6 +128,8 @@ class SdkPacket: forces_valid: bool | None = None #: Contact centre from ``compute_contact``; optional so old call sites stay valid. contact: ContactObservation | None = None + calibration_status: dict[str, str] = field(default_factory=dict) + calibration_reference: dict = field(default_factory=dict) def contact_from_arrays( @@ -205,6 +207,9 @@ def from_snapshot( asks for it and reads back what the device reports). """ err = str(compute_error or "") + snapshot_contact = getattr(snapshot, "contact_found", None) + if snapshot_contact is not None: + contact_found = snapshot_contact if valid is None: valid = not err stale = bool(err) @@ -230,6 +235,11 @@ def from_snapshot( flow_valid = flow is not None and not stale depth_valid = depth is not None and not stale calibrated = _force_calibrated(sensor) + statuses = dict(getattr(snapshot, "calibration_status", None) or {}) + units = getattr(snapshot, "force_units", None) + axes = tuple(axis for axis, unit in zip(("fn", "fx", "fy"), units) if unit == "N") if units else _calibrated_axes(sensor) + if units: + calibrated = bool(axes) return SdkPacket( raw_frame=ensure_bgr(raw_frame) if raw_frame is not None else processed, processed_frame=processed, @@ -257,7 +267,7 @@ def from_snapshot( shear_y_raw=float(snapshot.shear_y_raw), force_unit=str(snapshot.unit), force_calibrated=calibrated, - calibrated_axes=_calibrated_axes(sensor), + calibrated_axes=axes, calibration_version=str(calibration_version or ""), session_id=int(session_id), frame_id=frame, @@ -272,8 +282,9 @@ def from_snapshot( flow_frame_id=frame if flow_valid else None, depth_valid=depth_valid, depth_frame_id=frame if depth_valid else None, - forces_valid=bool(valid), + forces_valid=bool(valid) and all(value == "ok" for value in statuses.values()), contact=contact, + calibration_status=statuses, ) diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index 833984d..09d5acb 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -311,6 +311,7 @@ def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None): which would be indistinguishable from a genuine zero reading and would silently poison every average built on top of it. """ + sensor = getattr(sensor, "raw_sensor", sensor) info = sensor.info nan = float("nan") fn = fx = fy = nan @@ -408,6 +409,7 @@ def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None): "valid": valid, "invalid_reason": error, "raw_available": read_raw, + "calibration_status": dict(getattr(sensor, "calibration_status", {}) or {}), } @@ -640,12 +642,18 @@ def calibration_digest(path: str | Path) -> str: # A parsed SDK document has no path of its own; hash its content. to_dict = getattr(path, "to_dict", None) + model_fingerprint = getattr(path, "fingerprint", None) + if isinstance(model_fingerprint, str): + return model_fingerprint if callable(to_dict): return hashlib.sha256( json.dumps(to_dict(), sort_keys=True, default=str).encode("utf-8") ).hexdigest() target = Path(str(path)) if target.is_file(): + if json.loads(target.read_text(encoding="utf-8-sig")).get("schema_version") == 3: + from orisys.calibration import load + return load(target, strict=True).fingerprint return hashlib.sha256(target.read_bytes()).hexdigest() if not isinstance(path, (str, Path)): return hashlib.sha256( @@ -658,6 +666,7 @@ def calibration_digest(path: str | Path) -> str: def loaded_path(sensor) -> str | None: """Where the sensor's active calibration came from, if it knows.""" + sensor = getattr(sensor, "raw_sensor", sensor) calibration = getattr(sensor, "calibration", None) if calibration is None: return None @@ -671,6 +680,7 @@ def loaded_calibration_version(sensor) -> str: The sensor may have loaded a product-bundle file while opening, which is exactly the set of coefficients the first frames are measured with. """ + sensor = getattr(sensor, "raw_sensor", sensor) calibration = getattr(sensor, "calibration", None) if calibration is None: return "" diff --git a/shared/src/orisys_app_shared/analysis/bare_video.py b/shared/src/orisys_app_shared/analysis/bare_video.py index bf13544..a3bea8f 100644 --- a/shared/src/orisys_app_shared/analysis/bare_video.py +++ b/shared/src/orisys_app_shared/analysis/bare_video.py @@ -142,6 +142,7 @@ class BareVideoSetup: #: Serial the operator confirmed for ``calibration``. Empty when the pass uses the #: bundle's own file or raw force. device_id: str | None = None + unloaded_reference_frames: int = 0 @dataclass(frozen=True) @@ -335,6 +336,9 @@ def _calibration_identity(path: Path | None) -> str: return "" try: digest = hashlib.sha256(Path(path).read_bytes()).hexdigest()[:12] + if json.loads(Path(path).read_text(encoding="utf-8-sig")).get("schema_version") == 3: + from orisys.calibration import load + digest = load(path, strict=True).fingerprint[:12] except OSError: return Path(path).name return f"{Path(path).name}@{digest}" @@ -531,6 +535,7 @@ def build_workspace( device_id=device_id, ) directory = workspace_directory(identity, root=root) + document["synthetic"]["unloaded_reference_frames"] = 0 if setup is None else setup.unloaded_reference_frames directory.mkdir(parents=True, exist_ok=True) mode = _link_or_copy(resolved, directory / VIDEO_FILENAME) document["synthetic"]["video_in_workspace"] = mode diff --git a/shared/src/orisys_app_shared/analysis/model.py b/shared/src/orisys_app_shared/analysis/model.py index 9f9376e..29f640e 100644 --- a/shared/src/orisys_app_shared/analysis/model.py +++ b/shared/src/orisys_app_shared/analysis/model.py @@ -542,14 +542,18 @@ class ForceSample: def from_reading(cls, reading: Mapping[str, Any]) -> ForceSample: """One :func:`~orisys_app_shared.acquisition.sdk_worker.read_forces_from_sensor` reading, as this record's own type.""" + axes = reading.get("calibrated_axes") + def newton(axis, key): + return float(reading.get(key, _NAN)) if axes is None or axis in axes else _NAN return cls( - normal=float(reading.get("normal_force", _NAN)), - shear_x=float(reading.get("shear_x", _NAN)), - shear_y=float(reading.get("shear_y", _NAN)), + normal=newton("fn", "normal_force"), + shear_x=newton("fx", "shear_x"), + shear_y=newton("fy", "shear_y"), normal_raw=float(reading.get("normal_force_raw", _NAN)), shear_x_raw=float(reading.get("shear_x_raw", _NAN)), shear_y_raw=float(reading.get("shear_y_raw", _NAN)), - valid=bool(reading.get("valid", True)), + valid=bool(reading.get("valid", True)) and all( + value == "ok" for value in reading.get("calibration_status", {}).values()), calibrated=bool(reading.get("force_calibrated", False)), ) diff --git a/shared/src/orisys_app_shared/analysis/service.py b/shared/src/orisys_app_shared/analysis/service.py index d18b34e..6504d34 100644 --- a/shared/src/orisys_app_shared/analysis/service.py +++ b/shared/src/orisys_app_shared/analysis/service.py @@ -618,9 +618,12 @@ class AnalysisService: or dict(session.get("calibration") or {}).get("version") or "" ) + zero_frames = int((session.get("synthetic") or {}).get("unloaded_reference_frames", 0)) + if zero_frames not in (0, 30): + raise ValueError("unloaded reference must contain exactly 30 frames") key = result_key( recording=identity, - sensor_config=self.chain.identity(), + sensor_config=self.chain.identity() + (";unloaded-reference=30" if zero_frames else ""), calibration=calibration, reference_frame=self.reference_frame, ) @@ -810,6 +813,7 @@ class AnalysisService: from orisys_app_shared.acquisition.sdk_worker import process_chain provenance = state.provenance + zero_frames = int((self.reader().session().get("synthetic") or {}).get("unloaded_reference_frames", 0)) for position, number in enumerate(state.segments): if state.reasons or self._stop_requested(): if self._stop_requested(): @@ -823,6 +827,7 @@ class AnalysisService: if any(row.segment_frame != index + 1 for index, row in enumerate(rows)): provenance.warnings.append(f"segment_frame_gap:{number}") with _OpenChain(self.chain, path, state.working) as chain: + zero_samples = [] offset, measured = self._segment_offset(chain, number, state.offsets) state.offsets[str(number)] = offset state.sources.add("measured" if measured else "declared") @@ -873,6 +878,22 @@ class AnalysisService: provenance.warnings.append(f"chain:{error}") state.reasons.append(f"chain_failed:{row.frame_id}") return + if zero_frames and (self.reference_frame is None or row.frame_id >= self.reference_frame) and len(zero_samples) < zero_frames: + sensor = getattr(chain.sensor, "raw_sensor", chain.sensor) + model = getattr(sensor, "calibration", None) + if getattr(model, "schema_version", None) != 3: + raise ValueError("unloaded reference requires a schema 3 calibration") + keys = (sensor.info.FNORMAL_RAW, sensor.info.FSHEARX_RAW, sensor.info.FSHEARY_RAW) + values = sensor.read_info(*keys) + if getattr(sensor, "centroid_found", False) or not np.all(np.isfinite(values)): + raise ValueError("unloaded reference contains contact or invalid readings") + zero_samples.append(values) + if len(zero_samples) == zero_frames: + raw = {axis: float(np.median([value[index] for value in zero_samples])) + for index, axis in enumerate(("fn", "fx", "fy")) if axis in model.channels} + sensor.set_calibration_reference(raw, reference_id=f"video-unloaded-{row.frame_id}") + provenance.counts["unloaded_reference_frames"] = zero_frames + provenance.counts["unloaded_reference_fn_raw"] = raw.get("fn") force = self._read_force(chain) flow = getattr(chain.sensor, "flow", None) if flow is None or not getattr(flow, "size", 0): @@ -909,6 +930,8 @@ class AnalysisService: depth=depth_array, force=force, ) + if zero_frames and len(zero_samples) != zero_frames: + raise ValueError("video ended before 30 unloaded reference frames were available") # ------------------------------------------------------------------- internals def _read_force(self, chain: _OpenChain) -> ForceSample | None: diff --git a/shared/src/orisys_app_shared/force/units.py b/shared/src/orisys_app_shared/force/units.py index 1e787c7..1dc1f66 100644 --- a/shared/src/orisys_app_shared/force/units.py +++ b/shared/src/orisys_app_shared/force/units.py @@ -88,7 +88,7 @@ def select_display_forces(*, units_pref: str, sample) -> DisplayForces: values.append(float("nan")) available.append(False) - if not any(available): + if not any(available) and not getattr(sample, "calibration_status", {}): # Nothing is calibrated: showing raw is honest as long as it is labelled. return DisplayForces( values=raw, diff --git a/shared/src/orisys_app_shared/record/calibration_snapshot.py b/shared/src/orisys_app_shared/record/calibration_snapshot.py index d7eff92..7d23ec7 100644 --- a/shared/src/orisys_app_shared/record/calibration_snapshot.py +++ b/shared/src/orisys_app_shared/record/calibration_snapshot.py @@ -17,6 +17,7 @@ collide. from __future__ import annotations +import json import logging import shutil from pathlib import Path @@ -42,6 +43,23 @@ def store_calibration_snapshot(directory: str | Path, source: str | Path, versio base = Path(directory) / SNAPSHOT_DIRNAME base.mkdir(parents=True, exist_ok=True) origin = Path(source) + data = json.loads(origin.read_text(encoding="utf-8-sig")) + if data.get("schema_version") == 3: + from orisys.calibration import load + + document = load(origin, strict=True) + # Keep each model's relative artifact paths in its own immutable directory. + package = base / document.fingerprint + package.mkdir(parents=True, exist_ok=True) + for artifact in data["model"].get("artifacts", []): + relative = Path(artifact["path"]) + destination = package / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(origin.parent / relative, destination) + target = package / origin.name + shutil.copy2(origin, target) + load(target, strict=True) + return target.relative_to(Path(directory)).as_posix() target = base / snapshot_name(version, origin) if not target.is_file() or target.stat().st_size != origin.stat().st_size: # Same content-addressed name means the same coefficients; a size check @@ -56,5 +74,5 @@ def list_calibration_snapshots(directory: str | Path) -> list[str]: if not base.is_dir(): return [] return sorted( - f"{SNAPSHOT_DIRNAME}/{path.name}" for path in base.iterdir() if path.is_file() + path.relative_to(Path(directory)).as_posix() for path in base.rglob("*") if path.is_file() ) diff --git a/shared/src/orisys_app_shared/record/experiment.py b/shared/src/orisys_app_shared/record/experiment.py index 8b90b5c..128f042 100644 --- a/shared/src/orisys_app_shared/record/experiment.py +++ b/shared/src/orisys_app_shared/record/experiment.py @@ -46,7 +46,7 @@ from orisys_app_shared.record.video_writer import VideoSegments logger = logging.getLogger(__name__) -RECORD_FORMAT_VERSION = "1.4" +RECORD_FORMAT_VERSION = "1.5" # Lifecycle states. ``failed`` and ``stopped`` are terminal; a recorder that has # not reached a terminal state must not be reused. @@ -78,6 +78,8 @@ FORCES_HEADER = ( "FX_output_N", "FY_output_N", "Fmag_raw", + "calibration_status", + "calibration_reference", ) FRAMES_HEADER = ( "frame_id", @@ -1152,6 +1154,8 @@ class _SessionWriter: format_value(sample.fx_n_processed), format_value(sample.fy_n_processed), format_value(raw_magnitude(sample.fn_raw, sample.fx_raw, sample.fy_raw)), + json.dumps(sample.calibration_status, sort_keys=True), + json.dumps(sample.calibration_reference, sort_keys=True), ) ) diff --git a/shared/src/orisys_app_shared/record/sample.py b/shared/src/orisys_app_shared/record/sample.py index 228619c..5f64444 100644 --- a/shared/src/orisys_app_shared/record/sample.py +++ b/shared/src/orisys_app_shared/record/sample.py @@ -26,7 +26,7 @@ from __future__ import annotations import math from collections.abc import Iterable -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING if TYPE_CHECKING: # numpy is imported lazily, by the one function that needs it @@ -80,6 +80,8 @@ class RecordSample: time_basis: str = "" media_time_ms: float | None = None capture_dropped: int = 0 + calibration_status: dict[str, str] = field(default_factory=dict) + calibration_reference: dict = field(default_factory=dict) # ------------------------------------------------------------- accessors def raw_triplet(self) -> tuple[float, float, float]: @@ -183,6 +185,8 @@ def to_record_sample(packet, *, calibration_version: str = "") -> RecordSample: "time_basis": str(getattr(packet, "time_basis", "") or ""), "media_time_ms": getattr(packet, "media_time_ms", None), "capture_dropped": int(getattr(packet, "capture_dropped", 0) or 0), + "calibration_status": dict(getattr(packet, "calibration_status", {}) or {}), + "calibration_reference": dict(getattr(packet, "calibration_reference", {}) or {}), } if not valid or stale: diff --git a/shared/tests/test_multical_integration.py b/shared/tests/test_multical_integration.py new file mode 100644 index 0000000..301c49d --- /dev/null +++ b/shared/tests/test_multical_integration.py @@ -0,0 +1,184 @@ +"""Model validity, explicit unloaded references and portable schema 3 snapshots.""" + +import json +import math +from types import SimpleNamespace + +import numpy as np +import pytest +from orisys import calibration +from orisys.finger import TactileProcessor +from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker +from orisys_app_shared.acquisition.sdk_worker import ( + loaded_calibration_version, + read_forces_from_sensor, +) +from orisys_app_shared.force.units import select_display_forces +from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot +from orisys_app_shared.record.sample import to_record_sample + + +def model_file(tmp_path): + import hashlib + + attachment = tmp_path / "coordinates.npz" + np.savez(attachment, uv_by_output_px=np.zeros((2, 2, 2)), valid_mask=np.ones((2, 2), bool)) + digest = hashlib.sha256(attachment.read_bytes()).hexdigest() + data = { + "schema_version": 3, "unit": "N", "identity": {"sensor_type": "finger", "sn": "test"}, + "pipeline": {"feature_signature_sha256": "a" * 64, "geometry_sha256": "b" * 64, + "stitch_resolution": [2, 2], "dis_mode": 1, "hhd_downsample": 3, "source_scale": .42, + "filters": {"measurement": {"flow_ema_alpha": 1.0}}, + "coordinates": {"frame": "autocal_flat", "unit": "px", "origin_xy": [0, 0], + "map_sha256": digest}}, + "model": {"type": "spatial_gain/1", "input_profile": "fn_delta_autocal_uv/1", + "output_frame": "sdk_force/1", "outputs": ["fn"], + "params": {"points_uv": [[0, 0], [10, 0], [0, 10]], + "gains_N_per_raw": [0.001, 0.002, 0.003], "triangles": [[0, 1, 2]], + "valid_force_range_N": [0, 3]}, + "artifacts": [{"role": "coordinate_map", "path": attachment.name, "sha256": digest}]}} + path = tmp_path / "model.json" + path.write_text(json.dumps(data)) + return path + + +def test_recorded_model_remains_loadable_without_original_resources(tmp_path): + source = model_file(tmp_path) + original = calibration.load(source, strict=True) + recorded = tmp_path / "recording" + relative = store_calibration_snapshot(recorded, source, "version") + source.unlink() + (tmp_path / "coordinates.npz").unlink() + reloaded = calibration.load(recorded / relative, strict=True) + assert reloaded.fingerprint == original.fingerprint + prediction = reloaded.predict(1, 0, dict(delta_raw=1000, u=2.5, v=2.5, + zero_valid=True, contact_found=True, mapping_valid=True), "z") + assert prediction.values_N["fn"] == pytest.approx(1.75) + + +def test_snapshot_keeps_its_axes_contact_and_status_after_model_swap(): + from orisys.pipeline import FrameSnapshot + from orisys_app_shared.acquisition.models import from_snapshot + + snapshot = FrameSnapshot(seq=0, timestamp=1., age_ms=0., fps=30., unit="N", + normal_force=1., shear_x=20., shear_y=30., normal_force_raw=1000., shear_x_raw=20., shear_y_raw=30., + torque=0., produce_ms=0., flow_ms=0., hhd_ms=0., force_units=("N", "raw", "raw"), + calibration_status={"fn": "ok"}, contact_found=True, centroid=np.array([2., 3.])) + next_sensor = SimpleNamespace(calibration=None, force_calibrated=False, centroid_found=False) + packet = from_snapshot(snapshot, next_sensor, contact_analysed=True, contact_found=False) + assert packet.calibrated_axes == ("fn",) + assert packet.calibration_status == {"fn": "ok"} + assert packet.contact.found + + +def test_serial_read_unwraps_tactile_and_preserves_invalid_model_status(tmp_path): + model = calibration.load(model_file(tmp_path), strict=True) + info = SimpleNamespace(FNORMAL=0, FSHEARX=1, FSHEARY=2, FNORMAL_RAW=3, FSHEARX_RAW=4, FSHEARY_RAW=5) + values = [math.nan, 2., 3., 2000., 2., 3.] + sensor = SimpleNamespace(info=info, calibration=model, force_calibrated=True, + calibration_status={"fn": "zero_unavailable"}, + read_info=lambda *keys: [values[key] for key in keys]) + tactile = TactileProcessor(sensor) + reading = read_forces_from_sensor(tactile) + assert reading["calibrated_axes"] == ("fn",) + assert reading["calibration_status"] == {"fn": "zero_unavailable"} + assert loaded_calibration_version(tactile) == model.fingerprint + record = to_record_sample(SimpleNamespace(**reading)) + assert record.fn_raw == 2000 and math.isnan(record.fn_n) + assert record.calibration_status == {"fn": "zero_unavailable"} + displayed = select_display_forces(units_pref="newton", sample=record) + assert displayed.unit == "N" and not any(displayed.available) + assert not displayed.using_raw_fallback + + +def test_zero_is_explicit_complete_and_cancelled_on_contact(): + applied = [] + sensor = SimpleNamespace(calibration=SimpleNamespace(schema_version=3, channels={"fn": None}), + centroid_found=False, + set_calibration_reference=lambda raw, **kw: applied.append((raw, kw))) + runtime = SimpleNamespace(tactile=SimpleNamespace(raw_sensor=sensor), + set_calibration_reference=sensor.set_calibration_reference) + worker = FingerAcquisitionWorker() + worker.request_calibration_zero() + assert applied == [] + for index in range(29): + worker._collect_zero(runtime, (index, 0, 0), index) + assert applied == [({}, {"reference_id": "zero-measurement-pending"})] + worker._collect_zero(runtime, (29, 0, 0), 29) + assert applied[-1][0] == {"fn": 14.5} + assert worker._zero_reference["samples"] == 30 + worker.request_calibration_zero() + sensor.centroid_found = True + with pytest.raises(ValueError, match="cancelled"): + worker._collect_zero(runtime, (1000, 0, 0), 30) + assert applied[-1][0] == {} + assert worker._zero_reference == {} + + +def test_resume_cancels_partial_zero_because_tracker_is_rebased(): + worker = FingerAcquisitionWorker() + worker._zero_samples = [(1., 2., 3.)] + worker._pipe = SimpleNamespace(resume=lambda **kwargs: None) + worker.resume() + assert worker._cancel_zero + + +def test_hot_swap_uses_runtime_and_confirmed_serial_then_clears_zero(tmp_path): + path = model_file(tmp_path) + calls = [] + runtime = SimpleNamespace(set_calibration=lambda path, **kw: calls.append((path, kw))) + worker = FingerAcquisitionWorker() + worker._zero_reference = {"raw": {"fn": 123}} + worker.set_pending_calibration(path, device_id="test") + assert calls == [] + worker._consume_pending_calibration(runtime, 4, .2) + assert calls == [(str(path), {"device_id": "test"})] + assert worker._zero_reference == {} + + +@pytest.mark.parametrize("frame_count,contact", [(31, False), (29, False), (31, True)]) +def test_video_unloaded_reference_is_explicit_and_requires_the_whole_interval(monkeypatch, tmp_path, frame_count, contact): + from orisys_app_shared.acquisition import sdk_worker + from orisys_app_shared.analysis import service + from orisys_app_shared.analysis.model import Provenance + from orisys_app_shared.record.playback import RecordingFrame + + applied = [] + sensor = SimpleNamespace(calibration=SimpleNamespace(schema_version=3, channels={"fn": None}), + info=SimpleNamespace(FNORMAL_RAW=0, FSHEARX_RAW=1, FSHEARY_RAW=2), + read_info=lambda *keys: (100., 2., 3.), centroid_found=contact, + set_calibration_reference=lambda raw, **kw: applied.append(raw), + flow=np.zeros((4, 4, 2)), valid_mask=None) + class Chain: + def __init__(self, *args): + self.sensor = sensor + self.open_seconds = 0. + self.debug_files = [] + self.index = 0 + def __enter__(self): + return self + def __exit__(self, *args): + return False + def read(self): + self.index += 1 + return (True, np.zeros((4, 4))) if self.index <= frame_count else (False, None) + def feed(self, frame): + return frame + + monkeypatch.setattr(service, "_OpenChain", Chain) + monkeypatch.setattr(sdk_worker, "process_chain", lambda sensor: (None, "")) + processor = service.AnalysisService(tmp_path, chain=service.ChainSpec("finger"), store_force=False) + reader = SimpleNamespace(session=lambda: {"synthetic": {"unloaded_reference_frames": 30}}, + segment_path=lambda number: tmp_path / "clip.mp4") + monkeypatch.setattr(processor, "reader", lambda: reader) + monkeypatch.setattr(processor, "_segment_offset", lambda *args: (0, True)) + rows = [RecordingFrame(i + 1, i / 30, "", 1, i + 1, True) for i in range(frame_count)] + state = service._PassState(Provenance(), [1], {1: rows}, tmp_path) + if frame_count < 30 or contact: + with pytest.raises(ValueError): + list(processor._pass_frames(state)) + assert applied == [] + else: + assert len(list(processor._pass_frames(state))) == frame_count + assert applied == [{"fn": 100.}] + assert state.provenance.counts["unloaded_reference_frames"] == 30