接入 Studio 多点标定的会话置零与状态记录

支持显式无载荷置零、处理线程内切换模型及完整标定包快照。shared、界面检查与兼容测试通过;Studio 全量仍有一项回放停止时序测试失败。
This commit is contained in:
ykcheungab
2026-10-19 09:47:51 +08:00
parent bc5dfc035d
commit 31750f0f84
19 changed files with 514 additions and 32 deletions
+14
View File
@@ -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": { "app": {
"title": "Orisys Studio", "title": "Orisys Studio",
"about_title": "About", "about_title": "About",
+14
View File
@@ -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": { "app": {
"title": "Orisys Studio", "title": "Orisys Studio",
"about_title": "关于", "about_title": "关于",
+26
View File
@@ -64,6 +64,32 @@ Dropping a key means migrating those readers first, not tidying up the JSON.
## Verification ## 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 `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 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 for itself (the SDK's `scaled_map_cache_path`, so `_s0.42.npz` and never a neighbouring
@@ -132,6 +132,9 @@ class AcquirePresenter(QObject):
self._host.refresh_record_state() self._host.refresh_record_state()
def _push_forces(self, packet: Any): 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) record = to_record_sample(packet)
if not record.valid: if not record.valid:
self._host.session.last_sample_valid = False self._host.session.last_sample_valid = False
+19 -1
View File
@@ -911,6 +911,7 @@ class StudioMainWindow(QMainWindow):
self.toolbar.stop_clicked.connect(self._on_stop_clicked) self.toolbar.stop_clicked.connect(self._on_stop_clicked)
self.toolbar.record_toggled.connect(self._on_record_toggled) self.toolbar.record_toggled.connect(self._on_record_toggled)
self.toolbar.reset_clicked.connect(self._on_reset) 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.units_changed.connect(self.display_ctl.on_units_changed)
self.toolbar.settings_toggled.connect(self.layout_ctl.on_settings_toggled) 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) t("sensor.live_calibration_invalid", reason=problem)
) )
return return
cal_path, _device_id, problem = self._resolved_live_calibration() cal_path, device_id, problem = self._resolved_live_calibration()
if problem: if problem:
self.statusBar().showMessage( self.statusBar().showMessage(
t("sensor.live_calibration_invalid", reason=problem) t("sensor.live_calibration_invalid", reason=problem)
@@ -1859,6 +1860,9 @@ class StudioMainWindow(QMainWindow):
if worker is None or not hasattr(worker, "set_pending_calibration"): if worker is None or not hasattr(worker, "set_pending_calibration"):
self.statusBar().showMessage(t("cal.apply_no_target")) self.statusBar().showMessage(t("cal.apply_no_target"))
return return
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) worker.set_pending_calibration(cal_path)
self.statusBar().showMessage( self.statusBar().showMessage(
t("sensor.live_calibration_applied", name=cal_path.name) t("sensor.live_calibration_applied", name=cal_path.name)
@@ -2319,6 +2323,7 @@ class StudioMainWindow(QMainWindow):
self._stop_preview(announce=False) self._stop_preview(announce=False)
self.force_plots.reset() self.force_plots.reset()
self.toolbar.set_model_calibration_status({})
units = "N" if self.toolbar.units() == "newton" else "raw" units = "N" if self.toolbar.units() == "newton" else "raw"
self.force_plots.configure_presentation( self.force_plots.configure_presentation(
sensor_type=sensor_type, sensor_type=sensor_type,
@@ -2482,6 +2487,19 @@ class StudioMainWindow(QMainWindow):
except Exception as exc: except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(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: def _reset_display(self) -> None:
"""The rail's reset in the replay view: clear the display and start the replay over. """The rail's reset in the replay view: clear the display and start the replay over.
@@ -97,6 +97,7 @@ class ToolbarWidget(QWidget):
#: Stop: end the live session (releasing the device), or leave the replay. #: Stop: end the live session (releasing the device), or leave the replay.
stop_clicked = Signal() stop_clicked = Signal()
reset_clicked = Signal() reset_clicked = Signal()
calibration_zero_clicked = Signal()
units_changed = Signal(str) units_changed = Signal(str)
record_toggled = Signal(bool) record_toggled = Signal(bool)
#: The settings area was expanded or collapsed by the user. #: 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.setMinimumHeight(BUTTON_HEIGHT)
self.btn_reset.setEnabled(False) self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self.reset_clicked.emit) 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 = QPushButton()
self.btn_settings.setObjectName("ghostButton") self.btn_settings.setObjectName("ghostButton")
@@ -297,6 +304,7 @@ class ToolbarWidget(QWidget):
rail.addWidget(self.source_slot) rail.addWidget(self.source_slot)
rail.addWidget(self.source_summary) rail.addWidget(self.source_summary)
rail.addWidget(self.calibration_label) rail.addWidget(self.calibration_label)
rail.addWidget(self.model_state)
rail.addWidget(self.data_state) rail.addWidget(self.data_state)
# Shown, the hint takes the spare width (stretch 1); hidden, the spacer does. # Shown, the hint takes the spare width (stretch 1); hidden, the spacer does.
rail.addWidget(self.run_hint, 1) rail.addWidget(self.run_hint, 1)
@@ -306,6 +314,7 @@ class ToolbarWidget(QWidget):
rail.addWidget(_rail(self.btn_run)) rail.addWidget(_rail(self.btn_run))
rail.addWidget(_rail(self.btn_stop)) rail.addWidget(_rail(self.btn_stop))
rail.addWidget(_rail(self.btn_reset)) rail.addWidget(_rail(self.btn_reset))
rail.addWidget(self.btn_calibration_zero)
rail.addWidget(_rail(self.btn_settings)) rail.addWidget(_rail(self.btn_settings))
return rail return rail
@@ -393,6 +402,17 @@ class ToolbarWidget(QWidget):
self.calibration_label.setToolTip(self.calibration_label.text()) self.calibration_label.setToolTip(self.calibration_label.text())
self.calibration_label.setVisible(bool(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: def set_data_state(self, text: str, token: str = "text_secondary") -> None:
"""Show/clear the validity readout, coloured by a theme token.""" """Show/clear the validity readout, coloured by a theme token."""
text = str(text or "") text = str(text or "")
@@ -435,6 +455,7 @@ class ToolbarWidget(QWidget):
if wanted == self._run_role: if wanted == self._run_role:
return return
self._run_role = wanted self._run_role = wanted
self.set_model_calibration_status({})
# A view switch never carries the other view's running state across. # A view switch never carries the other view's running state across.
if self.btn_run.isChecked(): if self.btn_run.isChecked():
self.btn_run.blockSignals(True) self.btn_run.blockSignals(True)
@@ -524,6 +545,7 @@ class ToolbarWidget(QWidget):
""" """
self._reset_available = bool(enabled) self._reset_available = bool(enabled)
self.btn_reset.setEnabled(self._reset_available) self.btn_reset.setEnabled(self._reset_available)
self.btn_calibration_zero.setEnabled(self._reset_available)
self._refresh_reset_label() self._refresh_reset_label()
def reset_target(self) -> str: def reset_target(self) -> str:
@@ -35,6 +35,7 @@ from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import CatalogEntry, SensorCatalog from orisys_app_shared.sensors import CatalogEntry, SensorCatalog
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox,
QComboBox, QComboBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
@@ -200,6 +201,9 @@ class VideoAnalysisDialog(QDialog):
self.reference_time_label.setObjectName("panelMeta") self.reference_time_label.setObjectName("panelMeta")
reference_row.addWidget(self.reference_time_label) reference_row.addWidget(self.reference_time_label)
calibration_form.addRow(self.reference_label, reference_row) 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) root.addLayout(calibration_form)
self.note_label = ShrinkableLabel() self.note_label = ShrinkableLabel()
@@ -287,6 +291,7 @@ class VideoAnalysisDialog(QDialog):
# 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(), device_id=self._confirmed_device_id(),
unloaded_reference_frames=30 if self.unloaded_reference.isChecked() else 0,
) )
# ------------------------------------------------------------------ content # ------------------------------------------------------------------ content
@@ -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()
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import statistics
import threading import threading
import time import time
from collections import deque from collections import deque
@@ -323,6 +324,12 @@ class FingerAcquisitionWorker(RecordSinkMixin):
self._end_reason: str | None = None self._end_reason: str | None = None
self._phase: str = "idle" self._phase: str = "idle"
self._pending_calibration: str | None = None 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). #: Path and device serial for the **next** ``start()`` (live open kwargs).
self._session_cal_path: str | None = None self._session_cal_path: str | None = None
self._session_device_id: 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. # zeroing request must not fire into the new session's frames.
self._calibration_version = "" self._calibration_version = ""
self._pending_reset = False 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 # Nor any filter state: until this session's sensor has been
# configured and asked, there is nothing to report about it. # configured and asked, there is nothing to report about it.
self._filter_state = {} self._filter_state = {}
@@ -499,6 +511,9 @@ class FingerAcquisitionWorker(RecordSinkMixin):
never computed: keeping the pre-pause reference frame would report the hold never computed: keeping the pre-pause reference frame would report the hold
itself as a large deformation. itself as a large deformation.
""" """
with self._lock:
if self._zero_samples is not None:
self._cancel_zero = True
pipe = self._pipe pipe = self._pipe
if pipe is None and self._pause.is_set(): if pipe is None and self._pause.is_set():
with self._lock: with self._lock:
@@ -531,6 +546,7 @@ class FingerAcquisitionWorker(RecordSinkMixin):
if self._session_id <= 0: if self._session_id <= 0:
raise RuntimeError("Finger runtime is not running") raise RuntimeError("Finger runtime is not running")
self._pending_reset = True self._pending_reset = True
self._cancel_zero = True
def _consume_pending_reset( def _consume_pending_reset(
self, runtime, frame_id: int, mono_t: float, *, pipeline=None 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: if self._on_reset_applied is not None:
self._on_reset_applied(int(frame_id), float(mono_t), int(session)) 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.""" """Queue a force_calibration.json path for the acquisition thread."""
with self._lock: with self._lock:
self._pending_calibration = str(path) if path is not None else None 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( def _consume_pending_calibration(
self, sensor, frame_id: int = 0, mono_t: float = 0.0 self, sensor, frame_id: int = 0, mono_t: float = 0.0
@@ -579,13 +638,19 @@ class FingerAcquisitionWorker(RecordSinkMixin):
""" """
with self._lock: with self._lock:
path = self._pending_calibration path = self._pending_calibration
device_id = self._pending_device_id
self._pending_calibration = None self._pending_calibration = None
session = self._session_id session = self._session_id
if path is None or sensor is None: if path is None or sensor is None:
return return
if not hasattr(sensor, "set_calibration"): if not hasattr(sensor, "set_calibration"):
raise RuntimeError("sensor does not support set_calibration") raise RuntimeError("sensor does not support set_calibration")
if device_id:
sensor.set_calibration(path, device_id=device_id)
else:
sensor.set_calibration(path) sensor.set_calibration(path)
self._zero_samples = None
self._zero_reference = {}
version = calibration_digest(path) version = calibration_digest(path)
self._calibration_version = version self._calibration_version = version
if self._on_calibration_applied is not None: if self._on_calibration_applied is not None:
@@ -762,6 +827,21 @@ class FingerAcquisitionWorker(RecordSinkMixin):
with self._lock: with self._lock:
self._phase = "running" 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: try:
pipe = StreamPipeline( pipe = StreamPipeline(
raw_sensor, raw_sensor,
@@ -770,7 +850,7 @@ class FingerAcquisitionWorker(RecordSinkMixin):
# the pane's contact overlay and the 2D arrow both read it. # the pane's contact overlay and the 2D arrow both read it.
contact=True, contact=True,
include=self._stream_include(), include=self._stream_include(),
on_frame=snapshots.put, on_frame=on_frame,
) )
except Exception as exc: # noqa: BLE001 - reported, not swallowed except Exception as exc: # noqa: BLE001 - reported, not swallowed
self._set_end_reason("error") self._set_end_reason("error")
@@ -808,7 +888,7 @@ class FingerAcquisitionWorker(RecordSinkMixin):
snap = None snap = None
if expect > 0: if expect > 0:
try: try:
snap = snapshots.get(timeout=0.05) snap, version, reference = snapshots.get(timeout=0.05)
except queue_module.Empty: except queue_module.Empty:
snap = None snap = None
except Exception as exc: # noqa: BLE001 - a stage failure ends the session except Exception as exc: # noqa: BLE001 - a stage failure ends the session
@@ -844,6 +924,8 @@ class FingerAcquisitionWorker(RecordSinkMixin):
break break
capture_fps = packet.capture_fps capture_fps = packet.capture_fps
capture_dropped = packet.capture_dropped capture_dropped = packet.capture_dropped
packet.calibration_version = version
packet.calibration_reference = reference
if self._pause.is_set(): if self._pause.is_set():
# The hold arrived after this frame was published but before # The hold arrived after this frame was published but before
# it was built: see the box worker. A paused worker publishes # 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 # A zeroing is queued onto the pipeline's flow thread, which
# owns the tracker, and reported with the frame and time it # owns the tracker, and reported with the frame and time it
# takes effect — the same confirmation rule as the serial loop. # 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: try:
self._consume_pending_reset( self._consume_pending_reset(
runtime, runtime,
@@ -1121,7 +1191,7 @@ class FingerAcquisitionWorker(RecordSinkMixin):
# frame is dated when it happened rather than when it was queued. # frame is dated when it happened rather than when it was queued.
frame_mono = max(0.0, mono_ns / 1e9 - self._mono_t0) frame_mono = max(0.0, mono_ns / 1e9 - self._mono_t0)
try: 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 except Exception as exc: # noqa: BLE001
message = f"calibration rejected: {type(exc).__name__}: {exc}" message = f"calibration rejected: {type(exc).__name__}: {exc}"
if self._on_error is not None and session == self._session_id: if self._on_error is not None and session == self._session_id:
@@ -1168,6 +1238,14 @@ class FingerAcquisitionWorker(RecordSinkMixin):
sensor, sensor,
config_ref=mode.config_ref, 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 invalid_reason = forces.get("invalid_reason") or compute_error
raw_frame = _ensure_bgr(source_frame).copy() raw_frame = _ensure_bgr(source_frame).copy()
# The frame the tactile chain consumed, kept for depth replay. # The frame the tactile chain consumed, kept for depth replay.
@@ -1219,6 +1297,8 @@ class FingerAcquisitionWorker(RecordSinkMixin):
force_unit=forces["force_unit"], force_unit=forces["force_unit"],
force_calibrated=forces["force_calibrated"], force_calibrated=forces["force_calibrated"],
calibrated_axes=tuple(forces.get("calibrated_axes", ())), calibrated_axes=tuple(forces.get("calibrated_axes", ())),
calibration_status=forces.get("calibration_status", {}),
calibration_reference=reference,
calibration_version=self._calibration_version, calibration_version=self._calibration_version,
session_id=session, session_id=session,
frame_id=frame_id, frame_id=frame_id,
@@ -1238,7 +1318,8 @@ class FingerAcquisitionWorker(RecordSinkMixin):
flow_frame_id=frame_id if flow_valid else last_flow_frame_id, flow_frame_id=frame_id if flow_valid else last_flow_frame_id,
depth_valid=depth_valid, depth_valid=depth_valid,
depth_frame_id=frame_id if depth_valid else last_depth_frame_id, 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, contact=contact,
) )
with self._lock: with self._lock:
@@ -9,7 +9,7 @@ from __future__ import annotations
import json import json
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import cv2 import cv2
@@ -128,6 +128,8 @@ class SdkPacket:
forces_valid: bool | None = None forces_valid: bool | None = None
#: Contact centre from ``compute_contact``; optional so old call sites stay valid. #: Contact centre from ``compute_contact``; optional so old call sites stay valid.
contact: ContactObservation | None = None contact: ContactObservation | None = None
calibration_status: dict[str, str] = field(default_factory=dict)
calibration_reference: dict = field(default_factory=dict)
def contact_from_arrays( def contact_from_arrays(
@@ -205,6 +207,9 @@ def from_snapshot(
asks for it and reads back what the device reports). asks for it and reads back what the device reports).
""" """
err = str(compute_error or "") 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: if valid is None:
valid = not err valid = not err
stale = bool(err) stale = bool(err)
@@ -230,6 +235,11 @@ def from_snapshot(
flow_valid = flow is not None and not stale flow_valid = flow is not None and not stale
depth_valid = depth is not None and not stale depth_valid = depth is not None and not stale
calibrated = _force_calibrated(sensor) 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( return SdkPacket(
raw_frame=ensure_bgr(raw_frame) if raw_frame is not None else processed, raw_frame=ensure_bgr(raw_frame) if raw_frame is not None else processed,
processed_frame=processed, processed_frame=processed,
@@ -257,7 +267,7 @@ def from_snapshot(
shear_y_raw=float(snapshot.shear_y_raw), shear_y_raw=float(snapshot.shear_y_raw),
force_unit=str(snapshot.unit), force_unit=str(snapshot.unit),
force_calibrated=calibrated, force_calibrated=calibrated,
calibrated_axes=_calibrated_axes(sensor), calibrated_axes=axes,
calibration_version=str(calibration_version or ""), calibration_version=str(calibration_version or ""),
session_id=int(session_id), session_id=int(session_id),
frame_id=frame, frame_id=frame,
@@ -272,8 +282,9 @@ def from_snapshot(
flow_frame_id=frame if flow_valid else None, flow_frame_id=frame if flow_valid else None,
depth_valid=depth_valid, depth_valid=depth_valid,
depth_frame_id=frame if depth_valid else None, 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, contact=contact,
calibration_status=statuses,
) )
@@ -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 which would be indistinguishable from a genuine zero reading and would
silently poison every average built on top of it. silently poison every average built on top of it.
""" """
sensor = getattr(sensor, "raw_sensor", sensor)
info = sensor.info info = sensor.info
nan = float("nan") nan = float("nan")
fn = fx = fy = nan fn = fx = fy = nan
@@ -408,6 +409,7 @@ def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None):
"valid": valid, "valid": valid,
"invalid_reason": error, "invalid_reason": error,
"raw_available": read_raw, "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. # A parsed SDK document has no path of its own; hash its content.
to_dict = getattr(path, "to_dict", None) 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): if callable(to_dict):
return hashlib.sha256( return hashlib.sha256(
json.dumps(to_dict(), sort_keys=True, default=str).encode("utf-8") json.dumps(to_dict(), sort_keys=True, default=str).encode("utf-8")
).hexdigest() ).hexdigest()
target = Path(str(path)) target = Path(str(path))
if target.is_file(): 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() return hashlib.sha256(target.read_bytes()).hexdigest()
if not isinstance(path, (str, Path)): if not isinstance(path, (str, Path)):
return hashlib.sha256( return hashlib.sha256(
@@ -658,6 +666,7 @@ def calibration_digest(path: str | Path) -> str:
def loaded_path(sensor) -> str | None: def loaded_path(sensor) -> str | None:
"""Where the sensor's active calibration came from, if it knows.""" """Where the sensor's active calibration came from, if it knows."""
sensor = getattr(sensor, "raw_sensor", sensor)
calibration = getattr(sensor, "calibration", None) calibration = getattr(sensor, "calibration", None)
if calibration is None: if calibration is None:
return 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 The sensor may have loaded a product-bundle file while opening, which is
exactly the set of coefficients the first frames are measured with. exactly the set of coefficients the first frames are measured with.
""" """
sensor = getattr(sensor, "raw_sensor", sensor)
calibration = getattr(sensor, "calibration", None) calibration = getattr(sensor, "calibration", None)
if calibration is None: if calibration is None:
return "" return ""
@@ -142,6 +142,7 @@ class BareVideoSetup:
#: Serial the operator confirmed for ``calibration``. Empty when the pass uses the #: Serial the operator confirmed for ``calibration``. Empty when the pass uses the
#: bundle's own file or raw force. #: bundle's own file or raw force.
device_id: str | None = None device_id: str | None = None
unloaded_reference_frames: int = 0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -335,6 +336,9 @@ def _calibration_identity(path: Path | None) -> str:
return "" return ""
try: try:
digest = hashlib.sha256(Path(path).read_bytes()).hexdigest()[:12] 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: except OSError:
return Path(path).name return Path(path).name
return f"{Path(path).name}@{digest}" return f"{Path(path).name}@{digest}"
@@ -531,6 +535,7 @@ def build_workspace(
device_id=device_id, device_id=device_id,
) )
directory = workspace_directory(identity, root=root) 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) directory.mkdir(parents=True, exist_ok=True)
mode = _link_or_copy(resolved, directory / VIDEO_FILENAME) mode = _link_or_copy(resolved, directory / VIDEO_FILENAME)
document["synthetic"]["video_in_workspace"] = mode document["synthetic"]["video_in_workspace"] = mode
@@ -542,14 +542,18 @@ class ForceSample:
def from_reading(cls, reading: Mapping[str, Any]) -> ForceSample: def from_reading(cls, reading: Mapping[str, Any]) -> ForceSample:
"""One :func:`~orisys_app_shared.acquisition.sdk_worker.read_forces_from_sensor` """One :func:`~orisys_app_shared.acquisition.sdk_worker.read_forces_from_sensor`
reading, as this record's own type.""" 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( return cls(
normal=float(reading.get("normal_force", _NAN)), normal=newton("fn", "normal_force"),
shear_x=float(reading.get("shear_x", _NAN)), shear_x=newton("fx", "shear_x"),
shear_y=float(reading.get("shear_y", _NAN)), shear_y=newton("fy", "shear_y"),
normal_raw=float(reading.get("normal_force_raw", _NAN)), normal_raw=float(reading.get("normal_force_raw", _NAN)),
shear_x_raw=float(reading.get("shear_x_raw", _NAN)), shear_x_raw=float(reading.get("shear_x_raw", _NAN)),
shear_y_raw=float(reading.get("shear_y_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)), calibrated=bool(reading.get("force_calibrated", False)),
) )
@@ -618,9 +618,12 @@ class AnalysisService:
or dict(session.get("calibration") or {}).get("version") or dict(session.get("calibration") or {}).get("version")
or "" 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( key = result_key(
recording=identity, recording=identity,
sensor_config=self.chain.identity(), sensor_config=self.chain.identity() + (";unloaded-reference=30" if zero_frames else ""),
calibration=calibration, calibration=calibration,
reference_frame=self.reference_frame, reference_frame=self.reference_frame,
) )
@@ -810,6 +813,7 @@ class AnalysisService:
from orisys_app_shared.acquisition.sdk_worker import process_chain from orisys_app_shared.acquisition.sdk_worker import process_chain
provenance = state.provenance provenance = state.provenance
zero_frames = int((self.reader().session().get("synthetic") or {}).get("unloaded_reference_frames", 0))
for position, number in enumerate(state.segments): for position, number in enumerate(state.segments):
if state.reasons or self._stop_requested(): if state.reasons or self._stop_requested():
if 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)): if any(row.segment_frame != index + 1 for index, row in enumerate(rows)):
provenance.warnings.append(f"segment_frame_gap:{number}") provenance.warnings.append(f"segment_frame_gap:{number}")
with _OpenChain(self.chain, path, state.working) as chain: with _OpenChain(self.chain, path, state.working) as chain:
zero_samples = []
offset, measured = self._segment_offset(chain, number, state.offsets) offset, measured = self._segment_offset(chain, number, state.offsets)
state.offsets[str(number)] = offset state.offsets[str(number)] = offset
state.sources.add("measured" if measured else "declared") state.sources.add("measured" if measured else "declared")
@@ -873,6 +878,22 @@ class AnalysisService:
provenance.warnings.append(f"chain:{error}") provenance.warnings.append(f"chain:{error}")
state.reasons.append(f"chain_failed:{row.frame_id}") state.reasons.append(f"chain_failed:{row.frame_id}")
return 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) force = self._read_force(chain)
flow = getattr(chain.sensor, "flow", None) flow = getattr(chain.sensor, "flow", None)
if flow is None or not getattr(flow, "size", 0): if flow is None or not getattr(flow, "size", 0):
@@ -909,6 +930,8 @@ class AnalysisService:
depth=depth_array, depth=depth_array,
force=force, force=force,
) )
if zero_frames and len(zero_samples) != zero_frames:
raise ValueError("video ended before 30 unloaded reference frames were available")
# ------------------------------------------------------------------- internals # ------------------------------------------------------------------- internals
def _read_force(self, chain: _OpenChain) -> ForceSample | None: def _read_force(self, chain: _OpenChain) -> ForceSample | None:
+1 -1
View File
@@ -88,7 +88,7 @@ def select_display_forces(*, units_pref: str, sample) -> DisplayForces:
values.append(float("nan")) values.append(float("nan"))
available.append(False) 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. # Nothing is calibrated: showing raw is honest as long as it is labelled.
return DisplayForces( return DisplayForces(
values=raw, values=raw,
@@ -17,6 +17,7 @@ collide.
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import shutil import shutil
from pathlib import Path 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 = Path(directory) / SNAPSHOT_DIRNAME
base.mkdir(parents=True, exist_ok=True) base.mkdir(parents=True, exist_ok=True)
origin = Path(source) 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) target = base / snapshot_name(version, origin)
if not target.is_file() or target.stat().st_size != origin.stat().st_size: 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 # 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(): if not base.is_dir():
return [] return []
return sorted( 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()
) )
@@ -46,7 +46,7 @@ from orisys_app_shared.record.video_writer import VideoSegments
logger = logging.getLogger(__name__) 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 # Lifecycle states. ``failed`` and ``stopped`` are terminal; a recorder that has
# not reached a terminal state must not be reused. # not reached a terminal state must not be reused.
@@ -78,6 +78,8 @@ FORCES_HEADER = (
"FX_output_N", "FX_output_N",
"FY_output_N", "FY_output_N",
"Fmag_raw", "Fmag_raw",
"calibration_status",
"calibration_reference",
) )
FRAMES_HEADER = ( FRAMES_HEADER = (
"frame_id", "frame_id",
@@ -1152,6 +1154,8 @@ class _SessionWriter:
format_value(sample.fx_n_processed), format_value(sample.fx_n_processed),
format_value(sample.fy_n_processed), format_value(sample.fy_n_processed),
format_value(raw_magnitude(sample.fn_raw, sample.fx_raw, sample.fy_raw)), 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),
) )
) )
@@ -26,7 +26,7 @@ from __future__ import annotations
import math import math
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass, replace from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: # numpy is imported lazily, by the one function that needs it if TYPE_CHECKING: # numpy is imported lazily, by the one function that needs it
@@ -80,6 +80,8 @@ class RecordSample:
time_basis: str = "" time_basis: str = ""
media_time_ms: float | None = None media_time_ms: float | None = None
capture_dropped: int = 0 capture_dropped: int = 0
calibration_status: dict[str, str] = field(default_factory=dict)
calibration_reference: dict = field(default_factory=dict)
# ------------------------------------------------------------- accessors # ------------------------------------------------------------- accessors
def raw_triplet(self) -> tuple[float, float, float]: 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 ""), "time_basis": str(getattr(packet, "time_basis", "") or ""),
"media_time_ms": getattr(packet, "media_time_ms", None), "media_time_ms": getattr(packet, "media_time_ms", None),
"capture_dropped": int(getattr(packet, "capture_dropped", 0) or 0), "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: if not valid or stale:
+184
View File
@@ -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