分析结果在缺少会话零点时保留原始力值并说明原因
多点标定未测零点时,分析把整帧判无效,原始值与牛顿值都显示"不可用";现按 SDK 标定模型约定只清空该轴牛顿值,并在回放提示与分析设置中说明需要零点。算法版本升为 flow-hhd-contact-2,旧缓存不再复用;新增测试在旧代码上失败、新代码通过,studio/shared 全量与 HEAD 失败集合一致。
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user