分析结果在缺少会话零点时保留原始力值并说明原因

多点标定未测零点时,分析把整帧判无效,原始值与牛顿值都显示"不可用";现按 SDK 标定模型约定只清空该轴牛顿值,并在回放提示与分析设置中说明需要零点。算法版本升为 flow-hhd-contact-2,旧缓存不再复用;新增测试在旧代码上失败、新代码通过,studio/shared 全量与 HEAD 失败集合一致。
This commit is contained in:
ykcheungab
2026-10-19 14:48:58 +08:00
parent ff5f089dc1
commit e2cac0ae40
9 changed files with 288 additions and 10 deletions
+2
View File
@@ -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": {
+2
View File
@@ -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": {
@@ -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"
@@ -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.
@@ -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()
@@ -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()