diff --git a/docs/filters.md b/docs/filters.md new file mode 100644 index 0000000..880b9d5 --- /dev/null +++ b/docs/filters.md @@ -0,0 +1,74 @@ +# 滤波:三级、预设与可追溯性 + +`json.load` 不支持注释(见 `src/orisys/configs.py`),所以"建议的滤波"**不写在 JSON 注释里**, +而是写成**代码里的预设**(`src/orisys/sensor.py::FILTER_PRESETS`)+ 这份文档 + +`examples/filters/` 里可直接复制的配置。预设是可执行、可回显、可审计的;注释不是。 + +--- + +## 1. 三级滤波,位置不同、性质不同 + +| 级 | 配置键 | 作用位置 | 性质 | +|---|---|---|---| +| 光流时间 EMA | `flow_ema_alpha` | `compute_flow()` 内,**HHD 之前** | **改变测量**:改 `vnormal`/接触统计与 `_force_raw` 的值。两个 `alpha` 不同的录制**不可比** | +| 力中值 | `force_median_window` | `_apply_force_filter()`:raw → 中值 → 低通 → 标定 | **读数滤波**:删除单点尖峰(因果滑动中值) | +| 力低通 | `force_lowpass_cutoff_hz` / `_order` / `_sample_rate_hz` | 同上(中值之后) | **读数滤波**:因果 Butterworth IIR(`sosfilt` + 持久状态) | + +因此 `filters_state()` 把它们分成两组:`measurement`(测量身份)与 `readout`(读数滤波)。 +标定/可比性检查应比对 `measurement`,记录里两者都记。 + +## 2. 取值规则(自描述,无隐藏覆盖) + +- **每个级由自己的值开关**:`flow_ema_alpha >= 1`、`force_median_window <= 1`、 + `force_lowpass_cutoff_hz <= 0` 都是"关"。 +- **非法值 → 关**,绝不回落到"默认开"。 +- `filters.enabled` 是**废弃别名**,显式 `false` 表示"三级全关";没有任何开关能覆盖已写的值。 +- `filters.preset` 可引用下面的预设;**显式键优先于预设值**(可从预设出发只改一个旋钮)。 +- 任何时刻的**真值**是 `Sensor.filters_state()`,不是配置文件 —— 运行时 `configure()` 可以改它。 + +## 3. 预设(`FILTER_PRESETS`) + +| 预设 | `flow_ema_alpha` | `force_median_window` | `force_lowpass_cutoff_hz` | 延迟 @30fps | 用途 | +|---|---|---|---|---|---| +| `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 干掉力级滤波够不到的光流抖动 | +| `low_noise` | 0.3 | 3 | 10.0 | ≈3 帧(100 ms) | 底噪优先,读数再低通一道 | + +延迟换算 @60fps 减半;数字来自 `_perf/`(对照表见 `orisys-sdk-perf-plan.md`)。 +≥1 帧的延迟与相位差是**因果滤波的代价**:旧显示端曾用零相位 `filtfilt`,那需要"未来", +无法在采集里做,也让"显示 == 记录"不可能——所以现在只有一条因果链。 + +## 4. 怎么用 + +```jsonc +// sensors/mini/sensor.json(片段;完整可复制版本见 examples/filters/) +"filters": { "preset": "balanced" } +``` +```python +# 或运行时(UI 的齿轮就走这条路;不重连相机) +sensor.configure(flow_ema_alpha=0.5, force_median_window=3, lowpass_cutoff_hz=0.0) +print(sensor.filters_state()) # 实际生效值 + preset + applied 标志 +``` +UI 想列出可选档位:`from orisys.sensor import available_filter_presets`。 + +## 5. 两个 buffer:滤波前 / 滤波后 + +```python +t = sensor.force_triplets() +t["filtered"]["raw"], t["filtered"]["newton"] # 记录与显示用的值(同一套) +t["unfiltered"]["raw"], t["unfiltered"]["newton"] # 中值/低通之前;同帧、同标定系数 +``` +- `filtered.raw` = 链的输出(标定前),`unfiltered.raw` = 设备计数; +- 两个 `newton` 用同一份 `force_calibration.json`,差异**只**来自滤波链; +- 实测:注入 `10,10,10,999,10,10`(中值窗口 3)→ `filtered.raw` 在尖峰帧为 `10.0`、 + `unfiltered.raw` 为 `999.0`。 + +## 6. 可追溯性清单 + +1. 录制元数据写 `sensor.filters_state()` 的**生效值**(不是配置文件内容),并含 `preset` 与 + `"source": "sensor.filters_state"` + `sdk_version`。 +2. 界面显示"实际生效"而不是"我填的值";两者不一致时以实际为准并提示。 +3. 记录里主列 = 滤波后(与界面一致),未滤波列另存;列结构与格式版本可区分。 +4. 标定/可比性指纹用 `measurement` 组;`readout` 组变化只记录、不拦截。 +5. 双份数据在同一帧、同一份标定系数下产生,差异可完全归因于滤波链。 diff --git a/examples/filters/README.md b/examples/filters/README.md new file mode 100644 index 0000000..4d49244 --- /dev/null +++ b/examples/filters/README.md @@ -0,0 +1,88 @@ +# 滤波预设:可直接复制的 `filters` 段 + +`json.load` 不支持注释,所以这个目录就是"在配置里写说明"的替代品:**把下面任意一段整块替换** +到 `sensors//sensor.json` 的 `"filters"` 段即可。原有的 `filters` 段请整段替换, +不要只改其中一行(避免出现 `preset` 与数值混写的歧义——显式键优先于预设值,混写容易看错)。 + +背景、代价与可追溯性约定见 `docs/filters.md`;延迟/噪声实测见 `_perf/` 与 +`orisys-sdk-perf-plan.md`。 + +--- + +## `off` —— 原始读数(默认) + +```json +"filters": { + "flow_ema_alpha": 1.0, + "force_median_window": 1, + "force_lowpass_cutoff_hz": 0.0, + "force_lowpass_order": 2, + "force_lowpass_sample_rate_hz": "auto" +} +``` + +## `low_latency` —— 只去尖峰(≈1 帧 @30fps) + +```json +"filters": { + "flow_ema_alpha": 1.0, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 0.0, + "force_lowpass_order": 2, + "force_lowpass_sample_rate_hz": "auto" +} +``` + +## `balanced` —— 建议默认(≈2 帧 @30fps) + +```json +"filters": { + "flow_ema_alpha": 0.5, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 0.0, + "force_lowpass_order": 2, + "force_lowpass_sample_rate_hz": "auto" +} +``` + +## `low_noise` —— 底噪优先(≈3 帧 @30fps) + +```json +"filters": { + "flow_ema_alpha": 0.3, + "force_median_window": 3, + "force_lowpass_cutoff_hz": 10.0, + "force_lowpass_order": 2, + "force_lowpass_sample_rate_hz": "auto" +} +``` + +## 用预设名(等价写法,数值由代码展开) + +```json +"filters": { "preset": "balanced" } +``` + +`off / low_latency / balanced / low_noise` 四个名字来自 +`src/orisys/sensor.py::FILTER_PRESETS`(单一真值);想从预设出发只改一个旋钮,就在同一段里写 +那一个键,例如: + +```json +"filters": { "preset": "balanced", "force_lowpass_cutoff_hz": 10.0 } +``` + +--- + +## 复制后请核对"实际生效值" + +配置只是**意图**;`Sensor.configure()` 可以在运行时再改,所以审计与记录一律看生效值: + +```python +from orisys.sensor import Sensor +s = Sensor("external", config_name="mini", verbose=False) # 无需硬件即可核对 +print(s.filters_state()["preset"], s.filters_state()["measurement"], s.filters_state()["readout"]) +``` + +`flow_ema_applied` / `force_median_applied` / `force_lowpass_applied` 三个布尔就是 +"这一级到底有没有在滤"的答案;`flow_ema_alpha` 属于 **measurement**(改变测量, +不同值的两次录制不可比),中值与低通属于 **readout**(只影响读数呈现)。