From e2cac0ae40eed022b428ea677c85bc4d897ec158 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 19 Oct 2026 14:48:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E6=9E=90=E7=BB=93=E6=9E=9C=E5=9C=A8?= =?UTF-8?q?=E7=BC=BA=E5=B0=91=E4=BC=9A=E8=AF=9D=E9=9B=B6=E7=82=B9=E6=97=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=8E=9F=E5=A7=8B=E5=8A=9B=E5=80=BC=E5=B9=B6?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多点标定未测零点时,分析把整帧判无效,原始值与牛顿值都显示"不可用";现按 SDK 标定模型约定只清空该轴牛顿值,并在回放提示与分析设置中说明需要零点。算法版本升为 flow-hhd-contact-2,旧缓存不再复用;新增测试在旧代码上失败、新代码通过,studio/shared 全量与 HEAD 失败集合一致。 --- apps/studio/config/locales/en_US.json | 2 + apps/studio/config/locales/zh_CN.json | 2 + .../src/orisys_studio/replay_controller.py | 29 +++++- .../src/orisys_studio/ui/force_plots.py | 14 ++- .../orisys_studio/ui/video_analysis_dialog.py | 30 ++++++ apps/studio/tests/test_session_zero_notice.py | 84 ++++++++++++++++ .../src/orisys_app_shared/analysis/model.py | 28 +++++- .../src/orisys_app_shared/analysis/service.py | 10 ++ shared/tests/test_analysis_force_validity.py | 99 +++++++++++++++++++ 9 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 apps/studio/tests/test_session_zero_notice.py create mode 100644 shared/tests/test_analysis_force_validity.py diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 7d00b88..e886f92 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -1,6 +1,7 @@ { "multical": { "video_zero": "I confirm 30 frames from the reference frame are unloaded", + "video_zero_needed": "The chosen calibration is a multi-point model and needs a session zero. Without the box above, the result has raw values only, no newtons.", "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?", @@ -518,6 +519,7 @@ "video_required": "Select a video file first", "config_missing": "Sensor config not found for {type}", "units_raw_fallback": "No usable calibration; showing raw for now", + "units_zero_missing": "This analysis measured no session zero, so only raw values exist: re-run it with “I confirm 30 frames from the reference frame are unloaded” checked", "reveal_failed": "Could not reveal config: {error}" }, "scene3d": { diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index f8e7074..244b615 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -1,6 +1,7 @@ { "multical": { "video_zero": "我确认从参考帧开始的 30 帧均无载荷", + "video_zero_needed": "所选标定是多点模型,需要会话零点。不勾选上面一项,分析结果只有原始值,没有牛顿值。", "video_zero_tip": "适用于需要单独测量零点的模型。用此片段测量会话零点;选择跟踪参考帧本身不代表零载荷。", "zero": "无载荷置零", "zero_confirm": "请移除全部载荷,并在接下来 30 个处理帧内保持无载荷。使用录像时,请先暂停在已确认的无载荷片段。现在测量本次会话零点吗?", @@ -518,6 +519,7 @@ "video_required": "请先选择视频文件", "config_missing": "未找到传感器配置: {type}", "units_raw_fallback": "无可用标定,暂显示原始单位", + "units_zero_missing": "这次分析没有测零点,只能显示原始值:请在分析设置中勾选「我确认从参考帧开始的 30 帧均无载荷」后重新分析", "reveal_failed": "无法打开配置位置: {error}" }, "scene3d": { diff --git a/apps/studio/src/orisys_studio/replay_controller.py b/apps/studio/src/orisys_studio/replay_controller.py index c8cb1c0..8f59c78 100644 --- a/apps/studio/src/orisys_studio/replay_controller.py +++ b/apps/studio/src/orisys_studio/replay_controller.py @@ -1519,9 +1519,16 @@ class ReplayController(QObject): something to show in newtons — over the result's own force instead of a CSV. """ wanted = "N" if self._charts.unit() == "N" else "raw" + reason = newton_missing_reason(getattr(source, "reader", None)) + if reason == "status.units_zero_missing": + # Said whichever unit is on screen: the operator has to re-run the pass with + # an unloaded segment to get newtons at all, and raw hides that otherwise. + self._host.notify(t(reason)) + self._charts.show_unit_fallback(t(reason)) + return "raw" if wanted == "N" and not source.unit_available("N"): - self._host.notify(t("status.units_raw_fallback")) - self._charts.show_unit_fallback() + self._host.notify(t(reason)) + self._charts.show_unit_fallback(t(reason)) return "raw" return wanted @@ -1541,3 +1548,21 @@ class ReplayController(QObject): return "raw" values = np.asarray(newtons, dtype=float) return "N" if values.size and bool(np.isfinite(values).any()) else "raw" + + +def newton_missing_reason(reader: Any) -> str: + """The locale key explaining why a recomputed result has no newton trace. + + ``status.units_zero_missing`` when the pass loaded a calibration model but every + frame lacked a session zero (the operator did not confirm an unloaded segment); + otherwise the generic ``status.units_raw_fallback``. + """ + meta = getattr(reader, "meta", None) or {} + counts = dict(meta.get("counts") or {}) if isinstance(meta, dict) else {} + if ( + int(counts.get("force_calibrated_frames") or 0) > 0 + and int(counts.get("force_newton_frames") or 0) == 0 + and int(counts.get("force_zero_unavailable_frames") or 0) > 0 + ): + return "status.units_zero_missing" + return "status.units_raw_fallback" diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index ec8e651..86c31a2 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -765,11 +765,17 @@ class ForcePlotsPanel(QFrame): self._last_overview_at = now self._sync_overview() - def show_unit_fallback(self) -> None: - """Explain a failed Newton switch beside the unit control.""" - self.unit_notice.setText(t("status.units_raw_fallback")) + def show_unit_fallback(self, message: str | None = None) -> None: + """Explain a failed Newton switch beside the unit control. + + ``message`` names a specific reason (for example a calibration model with no + session zero); without one the generic "no calibration" wording is shown. + """ + text = str(message) if message else t("status.units_raw_fallback") + self.unit_notice.setText(text) + self.unit_notice.setToolTip(text) self.unit_notice.show() - self._unit_notice_timer.start(6000) + self._unit_notice_timer.start(12000 if message else 6000) def set_units(self, unit: str) -> None: """Switch display units without mixing units in the curve buffer. diff --git a/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py b/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py index aa20c13..32c38c0 100644 --- a/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py +++ b/apps/studio/src/orisys_studio/ui/video_analysis_dialog.py @@ -204,6 +204,15 @@ class VideoAnalysisDialog(QDialog): self.unloaded_reference = QCheckBox(t("multical.video_zero")) self.unloaded_reference.setToolTip(t("multical.video_zero_tip")) calibration_form.addRow(self.unloaded_reference) + # Said before the pass, not after it: a multi-point model with no session + # zero yields raw values only, and a long video takes minutes to show that. + self.zero_warning = QLabel() + self.zero_warning.setObjectName("hintText") + self.zero_warning.setWordWrap(True) + self.zero_warning.hide() + calibration_form.addRow(self.zero_warning) + self.unloaded_reference.toggled.connect(self._sync_zero_warning) + self.calibration_combo.currentIndexChanged.connect(self._sync_zero_warning) root.addLayout(calibration_form) self.note_label = ShrinkableLabel() @@ -344,6 +353,7 @@ class VideoAnalysisDialog(QDialog): self.calibration_combo.setCurrentIndex(self.calibration_combo.count() - 1) self.calibration_combo.blockSignals(False) self._sync_device_row() + self._sync_zero_warning() def _apply_remembered_calibration(self) -> str: """Select the sensor dialog's saved choice for this profile. @@ -371,6 +381,25 @@ class VideoAnalysisDialog(QDialog): self.calibration_combo.setCurrentIndex(index) return "" + def needs_session_zero(self) -> bool: + """Whether the chosen calibration is a model needing a measured session zero.""" + path = self._chosen_calibration() + if path is None: + return False + try: + document = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + return isinstance(document, dict) and document.get("schema_version") == 3 + + def _sync_zero_warning(self, *_args) -> None: + warning = getattr(self, "zero_warning", None) + if warning is None: + return + show = self.needs_session_zero() and not self.unloaded_reference.isChecked() + warning.setText(t("multical.video_zero_needed") if show else "") + warning.setVisible(show) + def _chosen_calibration(self) -> Path | None: role = str(self.calibration_combo.currentData() or "") entry = self.entry() @@ -478,3 +507,4 @@ class VideoAnalysisDialog(QDialog): self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText(t("dialog.ok")) self.buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(t("dialog.cancel")) self._refresh_time_label() + self._sync_zero_warning() diff --git a/apps/studio/tests/test_session_zero_notice.py b/apps/studio/tests/test_session_zero_notice.py new file mode 100644 index 0000000..3615bd1 --- /dev/null +++ b/apps/studio/tests/test_session_zero_notice.py @@ -0,0 +1,84 @@ +"""A multi-point calibration with no session zero is explained, before and after a pass.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import cv2 +import numpy as np +from orisys_app_shared.analysis import bare_video +from orisys_app_shared.i18n import t +from orisys_app_shared.sensors import scan_sensor_catalog + +from orisys_studio.replay_controller import newton_missing_reason +from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog + + +def _reader(**counts) -> SimpleNamespace: + return SimpleNamespace(meta={"counts": counts}) + + +def test_a_result_without_a_session_zero_names_the_reason(): + reader = _reader( + force_calibrated_frames=9800, force_newton_frames=0, force_zero_unavailable_frames=9800 + ) + assert newton_missing_reason(reader) == "status.units_zero_missing" + assert t("status.units_zero_missing") != "status.units_zero_missing" + + +def test_other_results_keep_the_generic_fallback(): + assert newton_missing_reason(_reader()) == "status.units_raw_fallback" + assert newton_missing_reason(None) == "status.units_raw_fallback" + # Some newtons exist: the zero was measured, so "no zero" would be wrong. + reader = _reader( + force_calibrated_frames=10, force_newton_frames=4, force_zero_unavailable_frames=2 + ) + assert newton_missing_reason(reader) == "status.units_raw_fallback" + + +def _finger_dialog(tmp_path, document: dict) -> VideoAnalysisDialog: + clip = tmp_path / "clip.mp4" + writer = cv2.VideoWriter(str(clip), cv2.VideoWriter_fourcc(*"mp4v"), 10.0, (32, 24)) + for _ in range(4): + writer.write(np.zeros((24, 32, 3), dtype=np.uint8)) + writer.release() + facts = bare_video.probe(bare_video.resolve_video(clip)) + catalog = scan_sensor_catalog() + finger = next( + entry + for entry in catalog.entries + if str(entry.product_family) == "finger" + and bare_video.setup_blocked_reason(entry) is None + ) + path = tmp_path / "DD02-14.json" + path.write_text(json.dumps(document), encoding="utf-8") + dialog = VideoAnalysisDialog(video=clip, facts=facts, catalog=catalog) + assert dialog.select_profile(finger.ref) + dialog.select_calibration(path) + return dialog + + +def test_the_dialog_warns_before_a_model_pass_without_a_session_zero(qt_app, tmp_path): + dialog = _finger_dialog( + tmp_path, + {"schema_version": 3, "identity": {"sensor_type": "finger", "sn": "DD02-14"}}, + ) + try: + assert dialog.needs_session_zero() + assert not dialog.unloaded_reference.isChecked() + assert not dialog.zero_warning.isHidden() + assert dialog.zero_warning.text() == t("multical.video_zero_needed") + dialog.unloaded_reference.setChecked(True) + assert dialog.zero_warning.isHidden() + finally: + dialog.close() + + +def test_a_legacy_calibration_needs_no_session_zero(qt_app, tmp_path): + dialog = _finger_dialog(tmp_path, {"meta": {"sensor_type": "finger", "sn": "DD02-14"}}) + try: + assert not dialog.needs_session_zero() + assert dialog.zero_warning.isHidden() + finally: + dialog.close() diff --git a/shared/src/orisys_app_shared/analysis/model.py b/shared/src/orisys_app_shared/analysis/model.py index 29f640e..a4e88b4 100644 --- a/shared/src/orisys_app_shared/analysis/model.py +++ b/shared/src/orisys_app_shared/analysis/model.py @@ -135,7 +135,11 @@ ANALYSIS_FORMAT_VERSION = 2 #: ``compute_contact()`` — the same pair the live acquisition loop and the offline #: depth replay use. A change to either call, or to the chain they drive, must change #: this string or two different results would share one cache entry. -ALGORITHM_VERSION = "flow-hhd-contact-1" +#: ``-2``: a force sample's ``valid`` is the sensor readout's own verdict; a calibration +#: model status other than ``ok`` empties only that axis's newton value (SDK +#: calibration-model contract: raw is kept). ``-1`` results marked every frame invalid +#: when the model had no session zero, so they must not be served again. +ALGORITHM_VERSION = "flow-hhd-contact-2" #: Unit of ``dx``/``dy``/``magnitude``: UV pixels of arrow-tip displacement, the same #: unit :class:`~orisys_app_shared.scene3d.visual_policy.FlowSamples` documents. @@ -508,6 +512,9 @@ class ForceSample: shear_y_raw: float = _NAN valid: bool = True calibrated: bool = False + #: The calibration model's per-axis status for this frame, as ``(axis, status)`` + #: pairs. Kept in memory for the pass's counters; not part of the stored payload. + status: tuple[tuple[str, str], ...] = field(default=(), compare=False) @property def any_finite(self) -> bool: @@ -543,8 +550,21 @@ class ForceSample: """One :func:`~orisys_app_shared.acquisition.sdk_worker.read_forces_from_sensor` reading, as this record's own type.""" axes = reading.get("calibrated_axes") + statuses = { + str(axis): str(value) + for axis, value in (reading.get("calibration_status") or {}).items() + } + def newton(axis, key): - return float(reading.get(key, _NAN)) if axes is None or axis in axes else _NAN + if axes is not None and axis not in axes: + return _NAN + # A model status other than "ok" means the model gave no usable value for + # this axis on this frame (no contact, no session zero, out of range): the + # newton value is absent. The raw counts are still this frame's measurement. + if statuses.get(axis, "ok") != "ok": + return _NAN + return float(reading.get(key, _NAN)) + return cls( normal=newton("fn", "normal_force"), shear_x=newton("fx", "shear_x"), @@ -552,9 +572,9 @@ class ForceSample: normal_raw=float(reading.get("normal_force_raw", _NAN)), shear_x_raw=float(reading.get("shear_x_raw", _NAN)), shear_y_raw=float(reading.get("shear_y_raw", _NAN)), - valid=bool(reading.get("valid", True)) and all( - value == "ok" for value in reading.get("calibration_status", {}).values()), + valid=bool(reading.get("valid", True)), calibrated=bool(reading.get("force_calibrated", False)), + status=tuple(sorted(statuses.items())), ) def encode(self) -> bytes: diff --git a/shared/src/orisys_app_shared/analysis/service.py b/shared/src/orisys_app_shared/analysis/service.py index 6504d34..a0b5897 100644 --- a/shared/src/orisys_app_shared/analysis/service.py +++ b/shared/src/orisys_app_shared/analysis/service.py @@ -1119,6 +1119,16 @@ class AnalysisService: counts["force_calibrated_frames"] = counts.get( "force_calibrated_frames", 0 ) + (1 if force.calibrated else 0) + # Newtons can be absent while raw is valid: the two counters below are what + # lets a chart say *why* a calibrated result has nothing to show in newtons. + newton_finite = any(value == value for value in force.newtons()) + counts["force_newton_frames"] = counts.get("force_newton_frames", 0) + ( + 1 if newton_finite else 0 + ) + zero_missing = any(value == "zero_unavailable" for _axis, value in force.status) + counts["force_zero_unavailable_frames"] = counts.get( + "force_zero_unavailable_frames", 0 + ) + (1 if zero_missing else 0) def _provenance(self, key: ResultKey, facts: dict[str, Any]) -> Provenance: session = facts["session"] diff --git a/shared/tests/test_analysis_force_validity.py b/shared/tests/test_analysis_force_validity.py new file mode 100644 index 0000000..2a6dab9 --- /dev/null +++ b/shared/tests/test_analysis_force_validity.py @@ -0,0 +1,99 @@ +"""A recomputed force sample: raw validity is the sensor's, newtons follow the model status. + +The SDK calibration-model contract says a frame the model cannot answer (no contact, no +session zero, ...) has an empty calibrated output while the raw readout is kept. These +tests pin that a missing session zero no longer invalidates the raw counts of every +frame — the defect that made a whole analysed video read "unavailable" in both units. +""" + +from __future__ import annotations + +import math + +from orisys_app_shared.analysis.model import ALGORITHM_VERSION, ForceSample +from orisys_app_shared.analysis.service import AnalysisService + + +def _reading(status: dict[str, str], *, normal: float, valid: bool = True) -> dict: + return { + "normal_force": normal, + "shear_x": float("nan"), + "shear_y": float("nan"), + "normal_force_raw": 1234.0, + "shear_x_raw": -5.0, + "shear_y_raw": 7.0, + "valid": valid, + "force_calibrated": True, + "calibrated_axes": ("fn",), + "calibration_status": status, + } + + +def test_a_missing_session_zero_keeps_the_raw_counts_valid(): + sample = ForceSample.from_reading( + _reading({"fn": "zero_unavailable"}, normal=float("nan")) + ) + assert sample.valid, "a model without a session zero invalidated the raw readout" + assert sample.raw() == (1234.0, -5.0, 7.0) + assert math.isnan(sample.normal) + assert sample.calibrated + assert dict(sample.status) == {"fn": "zero_unavailable"} + + +def test_a_non_ok_status_empties_the_newton_value_even_when_one_is_reported(): + # ``out_of_range`` carries a number in the SDK, but outside the validated range. + sample = ForceSample.from_reading(_reading({"fn": "out_of_range"}, normal=42.0)) + assert math.isnan(sample.normal) + assert sample.valid + + +def test_an_ok_status_keeps_the_newton_value(): + sample = ForceSample.from_reading(_reading({"fn": "ok"}, normal=3.5)) + assert sample.normal == 3.5 + assert sample.valid + + +def test_a_failed_sensor_readout_is_still_invalid(): + sample = ForceSample.from_reading(_reading({"fn": "ok"}, normal=3.5, valid=False)) + assert not sample.valid + + +def test_a_legacy_calibration_without_statuses_is_unchanged(): + reading = _reading({}, normal=2.0) + reading["calibrated_axes"] = None + sample = ForceSample.from_reading(reading) + assert sample.normal == 2.0 and sample.valid + + +def test_the_status_is_not_part_of_the_stored_payload(): + sample = ForceSample.from_reading( + _reading({"fn": "zero_unavailable"}, normal=float("nan")) + ) + decoded = ForceSample.decode(sample.encode()) + assert decoded.valid and decoded.raw() == sample.raw() + assert decoded.status == () + + +def test_the_pass_counts_why_a_calibrated_result_has_no_newtons(): + counts: dict = {} + missing = ForceSample.from_reading( + _reading({"fn": "zero_unavailable"}, normal=float("nan")) + ) + for _ in range(3): + AnalysisService._accumulate_force(counts, missing) + assert counts["force_valid_frames"] == 3 + assert counts["force_calibrated_frames"] == 3 + assert counts["force_newton_frames"] == 0 + assert counts["force_zero_unavailable_frames"] == 3 + + AnalysisService._accumulate_force( + counts, ForceSample.from_reading(_reading({"fn": "ok"}, normal=1.0)) + ) + assert counts["force_newton_frames"] == 1 + assert counts["force_zero_unavailable_frames"] == 3 + + +def test_results_stored_under_the_old_validity_rule_are_not_served(): + # Version 1 marked every frame invalid when the model lacked a zero; the cache key + # carries this string, so those results must miss the cache and be recomputed. + assert ALGORITHM_VERSION != "flow-hhd-contact-1"