From 38d3e67b00f646820e64b6616acd67a790e60a0b Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 5 Oct 2026 12:56:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BA=E5=AE=9A=20HHD=20=E4=B8=8B=E9=87=87?= =?UTF-8?q?=E6=A0=B7=E5=B9=B6=E6=A0=87=E8=AE=B0=E4=B8=89=E8=BD=B4=E5=8A=9B?= =?UTF-8?q?=E5=8D=95=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免运行中改变 HHD 测量配置,并明确部分标定时各轴单位。已通过 SDK 与共享层测试。 --- docs/api.md | 1 - src/orisys/sensor.py | 51 +++++++++++++++++++++------------ tests/test_calibration.py | 34 ++++++++++++++++++++++ tests/test_signal_processing.py | 24 ++++++---------- 4 files changed, 74 insertions(+), 36 deletions(-) diff --git a/docs/api.md b/docs/api.md index 352208e..417ba6d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -205,7 +205,6 @@ sensor.configure(lowpass_cutoff_hz=5.0, lowpass_sample_rate_hz=30.0) * **lowpass_sample_rate_hz** (`float`,可选): 力信号采样率,默认 `30.0`。 * **lowpass_order** (`int`,可选): Butterworth 滤波器阶数,默认 `2`。 * **lowpass_enabled** (`bool`,可选): 显式开启或关闭低通滤波。 -* **hhd_downsample** (`int`,可选): HHD 解算下采样倍数,需为 `>= 1` 的整数,配置默认 `3`。 #### 读取结果 diff --git a/src/orisys/sensor.py b/src/orisys/sensor.py index e72c17f..5671c39 100644 --- a/src/orisys/sensor.py +++ b/src/orisys/sensor.py @@ -1144,7 +1144,6 @@ class Sensor: lowpass_enabled=None, flow_ema_alpha=None, force_median_window=None, - hhd_downsample=None, ): """Update runtime-safe settings without reconnecting the camera. @@ -1167,9 +1166,18 @@ class Sensor: force_median_window : int, optional Causal sliding-median width applied to the raw force triple before the low-pass. ``<= 1`` disables it; 3 is the default. - hhd_downsample : int, optional - Integer ``>= 1``. Rebuilds the HHD solver on the next frame. """ + if flow_ema_alpha is not None: + alpha = float(flow_ema_alpha) + if not (0.0 < alpha <= 1.0): + raise ValueError("flow_ema_alpha must be in (0, 1]") + if self.calibration is not None: + mismatches, _ = cal_mod.filter_mismatches( + self.calibration.pipeline.get("filters"), + {"measurement": {"flow_ema_alpha": alpha}}, + ) + if mismatches: + raise cal_mod.CalibrationError("; ".join(mismatches)) filter_changed = False if lowpass_cutoff_hz is not None: self._lowpass_cutoff_hz = float(lowpass_cutoff_hz) @@ -1209,9 +1217,6 @@ class Sensor: filter_changed = True if flow_ema_alpha is not None: - alpha = float(flow_ema_alpha) - if not (0.0 < alpha <= 1.0): - raise ValueError("flow_ema_alpha must be in (0, 1]") self._flow_ema_alpha = alpha self._ema_flow = None @@ -1234,17 +1239,6 @@ class Sensor: if self.compute_deformation_valid: self._apply_force_filter() - if hhd_downsample is not None: - try: - k = max(1, int(hhd_downsample)) - except (TypeError, ValueError) as exc: - raise ValueError("hhd_downsample must be an integer >= 1") from exc - if k != self._hhd_downsample: - self._hhd_downsample = k - self.__hhd = None - self._hhd_grid_shape = None - self._hhd_ref_shape = None - self._update_hhd_mask_cache() return self def _bundle_name_candidates(self) -> list[str]: @@ -1381,7 +1375,11 @@ class Sensor: "stitch_resolution": camera.get("resolution_stitch"), "dis_mode": tracker.get("dis_mode"), "hhd_downsample": tracker.get("hhd_downsample"), - "filters": cal_mod.filters_fingerprint(self._config.get("filters")), + "filters": cal_mod.filters_fingerprint( + self.filters_state() + if hasattr(self, "_flow_ema_alpha") + else self._config.get("filters") + ), } @property @@ -1393,6 +1391,22 @@ class Sensor: def force_unit(self) -> str: return "N" if self.force_calibrated else "raw" + @property + def calibrated_axes(self) -> tuple[str, ...]: + """Axes with a loaded force-calibration curve.""" + channels = self.calibration.channels if self.calibration is not None else {} + return tuple(axis for axis in cal_mod.CHANNEL_IDS if axis in channels) + + @property + def force_units(self) -> tuple[str, str, str]: + """Units of FN, FX and FY in that order.""" + axes = self.calibrated_axes + return ( + "N" if "fn" in axes else "raw", + "N" if "fx" in axes else "raw", + "N" if "fy" in axes else "raw", + ) + def set_calibration( self, cal: "cal_mod.Calibration | str | Path | None", @@ -1481,7 +1495,6 @@ class Sensor: "measurement": { "flow_ema_alpha": float(self._flow_ema_alpha), "flow_ema_applied": bool(self._flow_ema_alpha < 1.0), - "hhd_downsample": int(getattr(self, "hhd_downsample", 0) or 0), "dis_mode": tracker_cfg.get("dis_mode"), }, "readout": { diff --git a/tests/test_calibration.py b/tests/test_calibration.py index f19e5ed..0b3007c 100644 --- a/tests/test_calibration.py +++ b/tests/test_calibration.py @@ -319,6 +319,40 @@ def test_device_calibration_rejects_partial_axes_and_unsafe_id(tmp_path: Path): load_for_device(root, "box", "../BOX-001") +def test_force_units_report_partial_calibration_without_changing_triplets(tmp_path: Path): + path = tmp_path / "partial.json" + doc = json.loads(_box_cal_path().read_text(encoding="utf-8")) + doc["channels"] = {"normal": doc["channels"]["normal"]} + path.write_text(json.dumps(doc), encoding="utf-8") + sensor = Sensor("external", config_name="box", cal_path=path, verbose=False, backend="cpu") + try: + assert sensor.calibrated_axes == ("fn",) + assert sensor.force_units == ("N", "raw", "raw") + assert set(sensor.force_triplets()) == {"filtered", "unfiltered"} + sensor.set_calibration(None) + assert sensor.calibrated_axes == () + assert sensor.force_units == ("raw", "raw", "raw") + finally: + sensor.disconnect() + + +def test_measurement_filter_change_refuses_incompatible_calibration(tmp_path: Path): + path = tmp_path / "filtered.json" + doc = json.loads(_box_cal_path().read_text(encoding="utf-8")) + doc["pipeline"] = {"filters": {"measurement": {"flow_ema_alpha": 1.0}}} + path.write_text(json.dumps(doc), encoding="utf-8") + sensor = Sensor("external", config_name="box", cal_path=path, verbose=False, backend="cpu") + try: + sensor.configure(force_median_window=3) + assert sensor.filters_state()["readout"]["force_median_window"] == 3 + with pytest.raises(CalibrationError, match="flow_ema_alpha"): + sensor.configure(flow_ema_alpha=0.5, force_median_window=5) + assert sensor.filters_state()["measurement"]["flow_ema_alpha"] == 1.0 + assert sensor.filters_state()["readout"]["force_median_window"] == 3 + finally: + sensor.disconnect() + + def test_parse_shipped_box_file_directly(): cal = parse(json.loads(_box_cal_path().read_text(encoding="utf-8")), source_path=_box_cal_path()) assert cal.schema_version == 2 diff --git a/tests/test_signal_processing.py b/tests/test_signal_processing.py index e3268e9..0a48d98 100644 --- a/tests/test_signal_processing.py +++ b/tests/test_signal_processing.py @@ -246,24 +246,16 @@ def test_read_info_force_stays_raw_without_calibration_json(monkeypatch): sensor.disconnect() -def test_configure_hhd_downsample_invalidates_solver_cache(): +def test_hhd_downsample_is_fixed_by_sensor_config(): sensor = _external_sensor() try: - sensor._hhd_downsample = 1 - sensor._Sensor__hhd = object() - sensor._hhd_grid_shape = (40, 40) - sensor.valid_mask = np.ones((90, 90), dtype=bool) - sensor.valid_mask_hhd = sensor.valid_mask - - sensor.configure(hhd_downsample=3) - assert sensor._hhd_downsample == 3 - assert sensor._Sensor__hhd is None - assert sensor._hhd_grid_shape is None - assert sensor.valid_mask_hhd is not None - assert sensor.valid_mask_hhd.shape == (30, 30) - - with pytest.raises(ValueError): - sensor.configure(hhd_downsample="nope") + configured = sensor._config["tracker"]["hhd_downsample"] + assert sensor._hhd_downsample == configured + sensor.configure(force_median_window=3) + assert sensor._hhd_downsample == configured + assert "hhd_downsample" not in sensor.filters_state()["measurement"] + with pytest.raises(TypeError): + sensor.configure(hhd_downsample=1) finally: sensor.disconnect()