接入 Studio 多点标定的会话置零与状态记录
支持显式无载荷置零、处理线程内切换模型及完整标定包快照。shared、界面检查与兼容测试通过;Studio 全量仍有一项回放停止时序测试失败。
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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": "关于",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user