接入 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": {
"title": "Orisys Studio",
"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": {
"title": "Orisys Studio",
"about_title": "关于",
+26
View File
@@ -64,6 +64,32 @@ Dropping a key means migrating those readers first, not tidying up the JSON.
## Verification
### Multi-point force calibration
Finger sessions accept SDK schema 3 files through the existing calibration-file picker and
operator-confirmed SN. The SDK checks processing settings, geometry and coordinate-map identity;
a mismatch blocks loading rather than falling back to raw values. Keep the JSON and its declared
NPZ artifacts together. The file format and supported models are defined in the
[SDK calibration contract](../../../../orisys-sdk/docs/calibration-model-contract.zh.md).
After starting, remove all load and select **Unloaded zero**. Confirm that the next 30 processed
frames are unloaded. Contact, invalid readings or tracker reset cancel an unfinished measurement.
Zero values belong to this session; tracker reset and model replacement require a new zero.
Model replacement is applied between frames on the SDK HHD thread. The rail reports per-axis
zero, contact, coverage and range states; an unavailable newton value stays unavailable while raw
measurements remain accessible. Selecting a tracker reference alone never implies zero load.
For bare-video recomputation, explicitly confirm the unloaded 30-frame interval starting at the
selected reference frame. This choice changes the analysis cache identity; a short or invalid
interval fails the pass. Leave the option unchecked for legacy calibration or when no unloaded
interval has been verified.
Recording format 1.5 appends `calibration_status` and `calibration_reference` JSON columns to
`forces.csv`; existing columns keep their positions. Raw validity and per-axis model validity
are distinct. Recordings store each schema 3 JSON with its artifacts in a directory identified by
the SDK model fingerprint, so recorded calibration remains loadable without the original folder.
Older recordings remain readable and carry no invented zero or model-state information.
`apps/studio/tests/test_sensor_catalog_root.py` asserts the descriptor's fields, that every
declared path is relative and present, that the declared map is the one the running chain derives
for itself (the SDK's `scaled_map_cache_path`, so `_s0.42.npz` and never a neighbouring
@@ -132,6 +132,9 @@ class AcquirePresenter(QObject):
self._host.refresh_record_state()
def _push_forces(self, packet: Any):
show_model = getattr(self._host.toolbar, "set_model_calibration_status", None)
if callable(show_model):
show_model(dict(getattr(packet, "calibration_status", {}) or {}))
record = to_record_sample(packet)
if not record.valid:
self._host.session.last_sample_valid = False
+20 -2
View File
@@ -911,6 +911,7 @@ class StudioMainWindow(QMainWindow):
self.toolbar.stop_clicked.connect(self._on_stop_clicked)
self.toolbar.record_toggled.connect(self._on_record_toggled)
self.toolbar.reset_clicked.connect(self._on_reset)
self.toolbar.calibration_zero_clicked.connect(self._on_calibration_zero)
self.toolbar.units_changed.connect(self.display_ctl.on_units_changed)
self.toolbar.settings_toggled.connect(self.layout_ctl.on_settings_toggled)
@@ -1846,7 +1847,7 @@ class StudioMainWindow(QMainWindow):
t("sensor.live_calibration_invalid", reason=problem)
)
return
cal_path, _device_id, problem = self._resolved_live_calibration()
cal_path, device_id, problem = self._resolved_live_calibration()
if problem:
self.statusBar().showMessage(
t("sensor.live_calibration_invalid", reason=problem)
@@ -1859,7 +1860,10 @@ class StudioMainWindow(QMainWindow):
if worker is None or not hasattr(worker, "set_pending_calibration"):
self.statusBar().showMessage(t("cal.apply_no_target"))
return
worker.set_pending_calibration(cal_path)
if device_id and hasattr(worker, "request_calibration_zero"):
worker.set_pending_calibration(cal_path, device_id=device_id)
else:
worker.set_pending_calibration(cal_path)
self.statusBar().showMessage(
t("sensor.live_calibration_applied", name=cal_path.name)
)
@@ -2319,6 +2323,7 @@ class StudioMainWindow(QMainWindow):
self._stop_preview(announce=False)
self.force_plots.reset()
self.toolbar.set_model_calibration_status({})
units = "N" if self.toolbar.units() == "newton" else "raw"
self.force_plots.configure_presentation(
sensor_type=sensor_type,
@@ -2482,6 +2487,19 @@ class StudioMainWindow(QMainWindow):
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
def _on_calibration_zero(self) -> None:
"""A separate explicit unloaded operation; tracker reset never assumes no load."""
worker = self._active_worker
if self._session_phase != "running" or not hasattr(worker, "request_calibration_zero"):
return
answer = QMessageBox.question(
self, t("multical.zero"), t("multical.zero_confirm"),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No)
if answer == QMessageBox.StandardButton.Yes:
worker.request_calibration_zero()
self.statusBar().showMessage(t("multical.zero_pending"))
def _reset_display(self) -> None:
"""The rail's reset in the replay view: clear the display and start the replay over.
@@ -97,6 +97,7 @@ class ToolbarWidget(QWidget):
#: Stop: end the live session (releasing the device), or leave the replay.
stop_clicked = Signal()
reset_clicked = Signal()
calibration_zero_clicked = Signal()
units_changed = Signal(str)
record_toggled = Signal(bool)
#: The settings area was expanded or collapsed by the user.
@@ -282,6 +283,12 @@ class ToolbarWidget(QWidget):
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self.reset_clicked.emit)
self.btn_calibration_zero = QPushButton()
self.btn_calibration_zero.setMinimumHeight(BUTTON_HEIGHT)
self.btn_calibration_zero.clicked.connect(self.calibration_zero_clicked.emit)
self.btn_calibration_zero.setVisible(False)
self.model_state = ShrinkableLabel()
self.model_state.setVisible(False)
self.btn_settings = QPushButton()
self.btn_settings.setObjectName("ghostButton")
@@ -297,6 +304,7 @@ class ToolbarWidget(QWidget):
rail.addWidget(self.source_slot)
rail.addWidget(self.source_summary)
rail.addWidget(self.calibration_label)
rail.addWidget(self.model_state)
rail.addWidget(self.data_state)
# Shown, the hint takes the spare width (stretch 1); hidden, the spacer does.
rail.addWidget(self.run_hint, 1)
@@ -306,6 +314,7 @@ class ToolbarWidget(QWidget):
rail.addWidget(_rail(self.btn_run))
rail.addWidget(_rail(self.btn_stop))
rail.addWidget(_rail(self.btn_reset))
rail.addWidget(self.btn_calibration_zero)
rail.addWidget(_rail(self.btn_settings))
return rail
@@ -393,6 +402,17 @@ class ToolbarWidget(QWidget):
self.calibration_label.setToolTip(self.calibration_label.text())
self.calibration_label.setVisible(bool(text))
def set_model_calibration_status(self, statuses: dict[str, str]) -> None:
"""Model validity is separate from whether a calibration file is loaded."""
text = ", ".join(f"{axis.upper()}: {t(f'multical.{value}')}" for axis, value in statuses.items())
self.model_state.setText(text)
self.model_state.setToolTip(text)
self.model_state.setVisible(bool(statuses))
self.btn_calibration_zero.setText(t("multical.zero"))
self.btn_calibration_zero.setToolTip(t("multical.zero_confirm"))
self.btn_calibration_zero.setVisible(bool(statuses) and self._run_role == "acquire")
self.btn_calibration_zero.setEnabled(self._reset_available)
def set_data_state(self, text: str, token: str = "text_secondary") -> None:
"""Show/clear the validity readout, coloured by a theme token."""
text = str(text or "")
@@ -435,6 +455,7 @@ class ToolbarWidget(QWidget):
if wanted == self._run_role:
return
self._run_role = wanted
self.set_model_calibration_status({})
# A view switch never carries the other view's running state across.
if self.btn_run.isChecked():
self.btn_run.blockSignals(True)
@@ -524,6 +545,7 @@ class ToolbarWidget(QWidget):
"""
self._reset_available = bool(enabled)
self.btn_reset.setEnabled(self._reset_available)
self.btn_calibration_zero.setEnabled(self._reset_available)
self._refresh_reset_label()
def reset_target(self) -> str:
@@ -35,6 +35,7 @@ from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import CatalogEntry, SensorCatalog
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
@@ -200,6 +201,9 @@ class VideoAnalysisDialog(QDialog):
self.reference_time_label.setObjectName("panelMeta")
reference_row.addWidget(self.reference_time_label)
calibration_form.addRow(self.reference_label, reference_row)
self.unloaded_reference = QCheckBox(t("multical.video_zero"))
self.unloaded_reference.setToolTip(t("multical.video_zero_tip"))
calibration_form.addRow(self.unloaded_reference)
root.addLayout(calibration_form)
self.note_label = ShrinkableLabel()
@@ -287,6 +291,7 @@ class VideoAnalysisDialog(QDialog):
# as one that happened.
reference_frame=None if reference == 1 else reference,
device_id=self._confirmed_device_id(),
unloaded_reference_frames=30 if self.unloaded_reference.isChecked() else 0,
)
# ------------------------------------------------------------------ content
@@ -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()