diff --git a/docs/filters.md b/docs/filters.md index 48ae3b3..f357b71 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -21,6 +21,9 @@ - **每个级由自己的值开关**:`flow_ema_alpha >= 1`、`force_median_window <= 1`、 `force_lowpass_cutoff_hz <= 0` 都是"关"。 +- **配置没写的键 = 关**:缺省不是"我们觉得合理的那档",而是"不滤波"。因此**没有 `filters` + 段的配置文件什么都不滤**,出厂 bundle 也把三个值显式写成全关 —— 想让哪一级跑起来, + 必须在文件里写出来(数值或 `preset`)。这样"文件说的"和"设备跑的"才总是一致。 - **非法值 → 关**,绝不回落到"默认开"。 - `filters.enabled` 是**废弃别名**,显式 `false` 表示"三级全关";没有任何开关能覆盖已写的值。 - `filters.preset` 可引用下面的预设;**显式键优先于预设值**(可从预设出发只改一个旋钮)。 @@ -32,7 +35,7 @@ |---|---|---|---|---|---| | `off` | 1.0 | 1 | 0.0 | 0 帧 | 原始读数;文件说什么就跑什么 | | `low_latency` | 1.0 | 3 | 0.0 | ≈1 帧(33 ms) | 延迟敏感,只去尖峰 | -| `balanced` | 0.5 | 3 | 0.0 | ≈2 帧(67 ms) | 默认建议:EMA 干掉力级滤波够不到的光流抖动 | +| `balanced` | 0.5 | 3 | 0.0 | ≈2 帧(67 ms) | 建议起步档:EMA 干掉力级滤波够不到的光流抖动 | | `low_noise` | 0.3 | 3 | 10.0 | ≈3 帧(100 ms) | 底噪优先,读数再低通一道 | 延迟换算 @60fps 减半;数字来自 `_perf/`(对照表见 `orisys-sdk-perf-plan.md`)。 diff --git a/examples/filters/README.md b/examples/filters/README.md index 64f2c96..6340b81 100644 --- a/examples/filters/README.md +++ b/examples/filters/README.md @@ -4,6 +4,8 @@ 到 `sensors//sensor.json` 的 `"filters"` 段即可。原有的 `filters` 段请整段替换, 不要只改其中一行(避免出现 `preset` 与数值混写的歧义——显式键优先于预设值,混写容易看错)。 +配置里**没写**的滤波键等于"关":整段删掉就是什么都不滤,和 `off` 那段等价。 + 背景、代价与可追溯性约定见 `docs/filters.md`;延迟/噪声实测见 `_perf/` 与 `orisys-sdk-perf-plan.md`。 diff --git a/src/orisys/sensor.py b/src/orisys/sensor.py index 452756e..7ec6b54 100644 --- a/src/orisys/sensor.py +++ b/src/orisys/sensor.py @@ -32,16 +32,24 @@ from .signal_processing import ( ) # Default signal-conditioning settings (config: top-level "filters" block). -# Measured on dd02-0703 at 280x300 (see the noise analysis in the perf notes): -# flow EMA 0.5 + median 3 costs ~2 frames at 30 fps, cuts steady-press force -# jitter by roughly half and removes ~85% of the heavy-tailed spikes, while also -# stabilising vnormal/contact (which a force-level filter cannot do). -# The force low-pass is OFF by default: on top of the EMA it added no measurable -# noise benefit at the same latency, so it is kept as an opt-in stage for sites -# with different noise characteristics. +# +# A key a config does not state is **off**, not "whatever we thought was +# sensible": the file has to name what runs, or a device can be filtering a +# measurement nobody asked it to filter and no reader of the file could tell. +# The shipped bundles state all three values explicitly, so they are unaffected +# by these defaults; a config with no "filters" block at all filters nothing. +# +# Which settings are worth asking for is measured, not guessed. On dd02-0703 at +# 280x300 (see the noise analysis in the perf notes) flow EMA 0.5 + median 3 +# costs ~2 frames at 30 fps and cuts steady-press force jitter by roughly half +# while also stabilising vnormal/contact (which a force-level filter cannot do); +# that combination is the "balanced" preset. The force low-pass stays off there +# too: on top of the EMA it added no measurable noise benefit at the same +# latency, so it is an opt-in stage for sites with different noise +# characteristics (the "low_noise" preset). DEFAULT_FORCE_LOWPASS_HZ = 0.0 -DEFAULT_FORCE_MEDIAN_WINDOW = 3 -DEFAULT_FLOW_EMA_ALPHA = 0.5 +DEFAULT_FORCE_MEDIAN_WINDOW = 1 +DEFAULT_FLOW_EMA_ALPHA = 1.0 #: Named filter presets, so a recommendation is *executable* instead of a #: comment in a JSON file (``json.load`` cannot carry comments). A config may diff --git a/tests/test_filter_presets.py b/tests/test_filter_presets.py index 3120c44..dd27fef 100644 --- a/tests/test_filter_presets.py +++ b/tests/test_filter_presets.py @@ -10,6 +10,7 @@ when a copy stops matching them. from __future__ import annotations import json +import shutil from pathlib import Path import pytest @@ -88,3 +89,29 @@ def test_shipped_bundles_report_all_off(bundle: str): assert fp["measurement"] == {"flow_ema_alpha": 1.0} assert fp["readout"]["force_median_window"] == 1 assert fp["readout"]["force_lowpass_cutoff_hz"] == 0.0 + + +def test_a_config_with_no_filters_block_filters_nothing(tmp_path: Path): + """Absence is off, not "the values we thought were sensible". + + A key that is missing has to mean the stage is not running: otherwise a + device filters a measurement nobody asked for, and nothing in the file says + so. Callers with a hand-written config (an old customer file, a bench + script) rely on this. + """ + copy = tmp_path / "box" + shutil.copytree(REPO_ROOT / "sensors" / "box", copy) + path = copy / "sensor.json" + cfg = json.loads(path.read_text(encoding="utf-8")) + cfg.pop("filters", None) + path.write_text(json.dumps(cfg, indent=2), encoding="utf-8") + + sensor = Sensor("external", config_name=str(path), verbose=False, backend="cpu") + try: + state = sensor.filters_state() + finally: + sensor.disconnect() + assert state["measurement"]["flow_ema_alpha"] == 1.0 + assert state["readout"]["force_median_window"] == 1 + assert state["readout"]["force_lowpass_cutoff_hz"] == 0.0 + assert state["any_stage_applied"] is False