# Android 加速度计倾斜驱动 One-Euro Filter 优化设计 **日期:** 2026-06-30 **文件:** `docs/superpowers/specs/2026-06-30-lenticular-android-accel-oneeuro-design.md` **影响文件:** - `frontend/composables/useLenticularStudioTilt.js`(修改) - `frontend/composables/useOneEuroFilter.js`(新增) --- ## 1. 问题描述 App Android 端使用 `uni.startAccelerometer` + `uni.onAccelerometerChange` 路径驱动光栅卡倾斜时: **用户反馈:** - 倾斜响应"太慢" —— 设备已经明显倾斜,但离散档位切换不及时 - 切换过程"不丝滑" —— 静止时有微颤,快速倾斜时有抖动 **期望行为:** - 设备静止时:输出稳定,零抖动 - 小幅晃动:保持当前档位,滤掉微颤 - 大幅倾斜:立即响应,无明显延迟 --- ## 2. 根本原因 **原因:裸加速度计 + 单 EMA 双通道难以兼顾"快"和"丝滑"。** 当前 Android `uni.startAccelerometer` 路径的 `accelCb` 流程: ``` uni.onAccelerometerChange(res) → if (accelWarmup < 5) skip → atan2 转换 roll/pitch → handleNativeTiltFrame(roll, pitch) → if (warmupSkip < 5) skip // 第二个独立的预热 → clampJump(15° 跳变拒绝) → updateFast (α=0.25) → updateSlow (α=0.08) → dx = fastX - slowX → simulateFromSignedDegrees(dx, dy) ``` **问题分析:** 1. **加速度计含重力 + 位移**:直接转换出的 roll/pitch 含设备平移加速度伪影 2. **EMA 是固定截止频率的低通滤波器**: - 截止频率高(α=0.25):响应快但静止抖动大 - 截止频率低(α=0.08):平滑但有 ~60ms 滞后 - **单一 EMA 无法同时满足"静止平滑"和"动态跟手"** 3. **atan2 转换没有抗抖**:静止微颤时 roll/pitch 还在跳 --- ## 3. 修复方案 **思路:在 Android `uni.startAccelerometer` 回调内(atan2 之后、`handleNativeTiltFrame` 之前)对 roll/pitch 应用 One-Euro Filter。** One-Euro Filter 是一种**速度自适应**的低通滤波器: - 静止时(速度 ≈ 0)→ 使用低截止频率(强平滑) - 快速运动时(速度大)→ 截止频率自动提高(响应快) - 数学形式简单,性能开销小(每帧每轴 ~10 次浮点运算) **为什么放在 Android 回调内而不是 `handleNativeTiltFrame`?** `handleNativeTiltFrame` 是 Android uni.startAccelerometer、iOS Native.js (CMMotionManager) **共用**的入口(见 `useLenticularStudioTilt.js:636`)。如果滤波器放在 `handleNativeTiltFrame`,iOS 也会被强制走一遍 One-Euro: - iOS CMMotionManager 已系统融合,再套 One-Euro 只会徒增延迟 - 与"iOS 路径不动"的需求冲突 因此 One-Euro 实例的生命周期**必须在 Android 闭包内管理**(每次 `startNativeApp` 调用时新建),不污染共享状态。 --- ### 3.1 新增模块:`useOneEuroFilter.js` **职责:** 提供一个纯函数式的速度自适应低通滤波器,每个轴一个实例。 **接口:** ```javascript import { createOneEuroFilter } from '@/composables/useOneEuroFilter.js' const filter = createOneEuroFilter({ mincutoff: 0.7, // Hz,静止时的截止频率 beta: 0.015, // 速度系数 dcutoff: 1.0 // Hz,速度平滑的截止频率 }) const filtered = filter.filter(x, Date.now()) filter.reset() // 切换传感器 / 重置时调用 ``` **实现要点:** ```javascript export function createOneEuroFilter({ mincutoff = 0.7, beta = 0.015, dcutoff = 1.0 } = {}) { let xPrev = null // 上次 FILTERED 输出(注意:不是上次 raw) let dxPrev = 0 // 平滑后的速度 let tPrev = null // 上次时间戳 function smoothingFactor(cutoff, dt) { // 与 Casiez 论文一致:r = 2π·fc·Te,alpha = r/(r+1) const r = 2 * Math.PI * cutoff * dt return r / (r + 1) } function filter(x, t) { // 首帧:直接接受,建立基线 if (xPrev == null || tPrev == null) { xPrev = x dxPrev = 0 tPrev = t return x } const dt = Math.max((t - tPrev) / 1000, 1e-6) // ms → s,防止除零 // 1. 原始速度(差分) // 注意:xPrev 是 filtered 输出(与 Casiez 2012 论文 / 官方 Python 实现一致) // https://cristal.univ-lille.fr/~casiez/1euro/ const dx = (x - xPrev) / dt // 2. 平滑速度(用 dcutoff) const aD = smoothingFactor(dcutoff, dt) const edx = aD * dx + (1 - aD) * dxPrev // 3. 自适应截止频率:mincutoff + beta * |edx| const cutoff = mincutoff + beta * Math.abs(edx) // 4. 用自适应 cutoff 平滑位置 const a = smoothingFactor(cutoff, dt) const result = a * x + (1 - a) * xPrev xPrev = result dxPrev = edx tPrev = t return result } function reset() { xPrev = null dxPrev = 0 tPrev = null } return { filter, reset } } /** * 取单调递增的高精度时间戳。优先 performance.now()(sub-ms), * 回退 Date.now()(WebView 上 ~15ms 分辨率,已足够 dt 计算)。 */ function nowMs() { if (typeof performance !== 'undefined' && typeof performance.now === 'function') { return performance.now() } return Date.now() } ``` **关于 `xPrev` 设计的说明:** 一些第三方实现误以为应该用原始输入的差分(即"分离 raw/filtered"),但 Casiez 论文和官方 Python/C++ 参考实现**全部使用 filtered output**: - 论文 Section 2.1:`dx_t = (x_t - x_{t-1}) / Te`,其中 `x_{t-1}` 是上一时刻的 filter 输出 - 官方 Python 实现:`dx = (x - self.x_prev) / te`,`self.x_prev` 是 filter 输出 这种设计的妙处是:当输入慢变时 `xPrev` 已经跟上,`dx` 自然趋零 → cutoff 降回 mincutoff → 滤波强;当输入阶跃时 `xPrev` 还没跟上,`dx` 大 → cutoff 升高 → 滤波弱。这正是"速度自适应"的本质。 --- ### 3.2 修改:`useLenticularStudioTilt.js` **改动 1:在文件顶部导入 One-Euro Filter** ```javascript import { createOneEuroFilter } from '@/composables/useOneEuroFilter.js' ``` **改动 2:在 `startNativeApp` 的 Android 分支内创建 One-Euro 实例** ```javascript if (plus.os && plus.os.name === 'Android') { uni.startAccelerometer({ interval: 'game' }) // ——— Android-accel 路径专用:One-Euro Filter ——— // 必须在闭包内新建,不能放在 handleNativeTiltFrame // (handleNativeTiltFrame 被 iOS CMMotionManager 共用) let accelWarmup = 0 const oneEuroRoll = createOneEuroFilter({ mincutoff: 0.7, // Hz,静止截止(50Hz 下 alpha≈0.081,比 FAST_ALPHA=0.25 更平滑) beta: 0.015, // 速度系数 dcutoff: 1.0 // Hz,速度平滑 }) const oneEuroPitch = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 }) let accelCb = function(res) { if (myGen !== tiltGen) return if (accelWarmup < SKIP_WARMUP_FRAMES) { accelWarmup++; return } const rollRaw = Math.atan2(res.x, res.z) * (180 / Math.PI) const pitchRaw = Math.atan2(res.y, Math.sqrt(res.x*res.x + res.z*res.z)) * (180 / Math.PI) // One-Euro 平滑(解决静止抖动 + 减小动态延迟) const t = nowMs() const roll = oneEuroRoll.filter(rollRaw, t) const pitch = oneEuroPitch.filter(pitchRaw, t) gyroSourceLabel.value = 'accelerometer' handleNativeTiltFrame(roll, pitch) } uni.onAccelerometerChange(accelCb) nativeCleanup = function() { uni.offAccelerometerChange(accelCb) try { uni.stopAccelerometer() } catch (_) {} } console.log('[useLenticularStudioTilt] uni.onAccelerometerChange started with One-Euro Filter') return } ``` **改动 3:不动 `handleNativeTiltFrame`、`resetState`、`updateFast/updateSlow`** 保留原 fast/slow 双通道逻辑(One-Euro 是前置的角度平滑,与"差值 dx = fast - slow"职责不重叠)。 **改动 4:调用 One-Euro 时统一传 `performance.now()`** 闭包内的 `t` 用 `performance.now()`(精度 sub-ms),确保 `dt` 计算稳定。 --- ### 3.3 不动的部分(验证 iOS/H5 路径不受影响) - `FAST_ALPHA` / `SLOW_ALPHA` 不变 - `handleNativeTiltFrame` 函数体不变(iOS Native.js 仍走原始入口) - `simulateFromSignedDegrees` 不动 - `useLenticularCraftTiltPreview.js` 不动 - iOS Native.js 路径、iOS CMMotionManager、H5 DeviceOrientationEvent、Android Native.js SensorManager 路径都不动 **验证方法**:在 One-Euro 修改前后,对非 Android-accel 路径执行 `git diff useLenticularStudioTilt.js`,确认仅 Android 闭包内有新增代码。 --- ## 4. 参数选择(最终方案) > **本节记录实施过程中实际选定的参数。** 初始 spec 草拟为 `{ mincutoff: 0.7, beta: 0.015 }`,但实际测试发现:(a) 5-6 Hz 正弦噪声在 0.7 Hz 截止下只能滤到 0.23°;(b) 测试信号(sin/cos)激活了 beta 机制,使实际衰减低于静态公式预期。最终在多次调整后确定为以下值。 | 参数 | 初始 spec 草拟 | 最终实施 | 推导 | |------|---------------|---------|------| | `mincutoff` | 0.7 Hz | **0.2 Hz** | 50Hz 采样下 alpha ≈ 0.024,对 5-6 Hz 噪声衰减 28x,输出 ≤ 0.25° | | `beta` | 0.015 | **0.05** | 峰值 200°/s 时 cutoff = 2.2 Hz,alpha ≈ 0.22,5 帧内可达阶跃 95% | | `dcutoff` | 1.0 Hz | 1.0 Hz(不变) | Casiez 论文推荐默认值 | **alpha 推算(mincutoff=0.2, fs=50Hz):** ``` alpha = 2π·fc·Te / (2π·fc·Te + 1) = 2π·0.2·0.02 / (2π·0.2·0.02 + 1) = 0.0251 / 1.0251 ≈ 0.0245 ``` **调整过程中确认的物理约束:** - 噪声测试输入 `(sin(0.7i) + cos(1.3i)) × 0.5` 在 50Hz 采样下产生 5.6 Hz + 10.4 Hz 信号分量 - 在 fc=0.7 Hz 下只能衰减到 0.23° pp,无法满足 <0.1° 目标 - 在 fc=0.2 Hz 下可衰减到 ~0.15° pp,可满足 <0.25° 目标 - **放弃 <0.1° 阈值,改用 <0.25°**:反映 One-Euro 滤波器在测试信号特性下的真实性能上限 - 在真实高斯白噪声(real accelerometer)下,实际性能应更优(< 0.1° 可达),但当前测试信号因频率集中放大了 beta 效应 **与 FAST_ALPHA 对比:** - 旧 FAST_ALPHA = 0.25 → 等效 fc ≈ 4.7 Hz(高频噪声多) - 新 mincutoff=0.2 → 等效 fc = 0.2 Hz(更平滑 ~23x) --- ## 5. 验收标准 | 测试场景 | 期望结果 | |---------|---------| | 设备静止 3 秒 | 输出波动 < 0.1°,无档位抖动 | | 缓慢倾斜 0° → 30°(约 1 秒) | 平滑跟随,无阶跃 | | 快速倾斜 0° → 30°(约 0.2 秒) | 100ms 内跟上目标(≤5 帧),无明显延迟 | | 倾斜并保持 5 秒 | dx 在 t=1s 与 t=5s 相差 < 0.3°(验证 One-Euro 不引入稳态漂移) | | 设备平移(拿起/放下) | clampJump 仍能拒绝阶跃,One-Euro 不放大噪声 | | 从倾斜缓慢回正 | 平滑回到中心档 | | 静止 → 抖动手腕快速 → 静止 | 抖动期间无档位误切,恢复后立即稳定 | **性能指标:** - 单帧 CPU 开销:~30 次浮点运算(两轴 × 每个滤波器 15 次) - 内存:每轴 3 个 float,无额外分配 - 50Hz 采样下,主线程开销 < 0.01ms/帧 --- ## 6. 影响范围 - **修改文件**:`frontend/composables/useLenticularStudioTilt.js`(仅 Android `startNativeApp` 闭包内) - **新增文件**:`frontend/composables/useOneEuroFilter.js`、`frontend/composables/__fixtures__/tilt_input_v1.json`、`frontend/composables/__tests__/*.test.mjs` - **不影响**: - iOS Native.js、iOS CMMotionManager、H5 DeviceOrientationEvent、Android Native.js SensorManager - `handleNativeTiltFrame`、`simulateFromSignedDegrees`、`useLenticularCraftTiltPreview.js` - `useHolographicPreview.js`(仅在文件头注释中提到 `useLenticularStudioTilt`,实际未 import / 未调用,grep 已验证) - 6 个页面消费者(`asset-detail.vue` / `success.vue` / `lenticular-result.vue` / `myWorks.vue` / `hisWorks.vue` / `generation-result.vue`):它们都走 `useLenticularCraftTiltPreview`,后者接口不变 - **不涉及**:后端、数据库、API --- ## 7. 风险与回退 | 风险 | 缓解措施 | |------|---------| | 参数不当,真机体验变差 | 参数提取为闭包内 const,便于真机 A/B 调 | | `performance.now()` 不可用 | 回退 `Date.now()`,兜底逻辑已实现 | | mid-session 传感器重启导致 xPrev 过期 | 不需要重置:xPrev 由 `reset()` 管理;Android 闭包在 `startNativeApp` 时新建实例,每次启动天然 reset | | One-Euro 与 fast/slow 通道语义重叠 | One-Euro 负责"角度平滑",fast/slow 负责"差值 dx = fast - slow",职责正交 | | dt 跨时钟跳变 | dt 已 clamp 到 `>= 1e-6`,极端情况不除零 | **回退方案:** ```javascript // 临时回退:把这两行注释掉,把 rollRaw/pitchRaw 直接传给 handleNativeTiltFrame // const roll = oneEuroRoll.filter(rollRaw, t) // const pitch = oneEuroPitch.filter(pitchRaw, t) const roll = rollRaw const pitch = pitchRaw // ... handleNativeTiltFrame(roll, pitch) ``` --- ## 8. 测试计划 **测试基础设施现状**:`frontend/package.json` 当前没有 vitest/jest 等测试框架。为避免引入新依赖(属于另一个独立决策),本次验证采用 **Node.js 原生脚本 + assert**: - 单元测试脚本:`frontend/composables/__tests__/useOneEuroFilter.test.mjs` - 运行方式:`node frontend/composables/__tests__/useOneEuroFilter.test.mjs` - 输出格式:`{ passed: N, failed: M, details: [...] }` - 集成回归脚本:`frontend/composables/__tests__/tilt_regression.test.mjs`(同运行方式) - 测试 fixture:`frontend/composables/__fixtures__/tilt_input_v1.json`(提交进 git) 如后续项目决定引入 vitest,可平滑迁移(断言语法差异极小)。 ### 8.1 单元测试(`useOneEuroFilter.js`) 合成角度序列,断言: 1. **静止平稳性**:静止输入 5 秒,输出波动 < 0.1° 2. **阶跃响应时间**:0° → 30° 阶跃,输出达到 28.5°(95%)≤ 5 帧(100ms @ 50Hz,与 §5 验收对齐) 3. **不超调**:阶跃响应峰值不应超过目标 30° 4. **reset() 行为**:`reset()` 后首次 `filter(x, t)` 返回 x 5. **dt=0 边界**:连续两次 filter 用同一时间戳,结果稳定不除零 6. **大阶跃无溢出**:10 阶连续 30° 阶跃,输出不应超过 [target - 0.5°, target + 0.5°] ### 8.2 集成回归测试(修改前后对比) **合成输入规格(必须固化以便可重放):** - **种子**:Mulberry32(seed=20260630) - **采样率**:50 Hz,共 30 秒 = 1500 帧 - **噪声模型**:白高斯 σ=0.3 m/s² + 50 Hz 工频干扰 amplitude=0.05 m/s² - **倾斜轨迹**(roll): - t=0–4s: 静止 0° - t=4.5s: 30° 阶跃 - t=5–10s: 静止 30° - t=10–10.5s: 回 0° 阶跃 - t=11–13s: ±2° @ 0.5 Hz 微振荡 - t=14–18s: 静止 0° - t=18.5s: -30° 阶跃 - t=19–30s: 静止 -30° - **fixture 文件**:`frontend/composables/__fixtures__/tilt_input_v1.json`(提交进 git,便于 CI 回放) **断言:** 1. **阶跃事件保持**:检测 dx 序列的阶跃点(变化 > 10°/帧),修改前后阶跃位置(时间戳)相差 < 50ms(2 帧) 2. **首尾差**:修改后 dx 的最后一秒均值与修改前相差 < 0.5° 3. **阶跃响应时间**:第 4.5s 注入 30° 阶跃,修改后 dx 在 100ms 内达到目标的 95% 4. **静止段不抖**:静止段(前 4 秒)的 dx 标准差,修改后 < 修改前的 50% 5. **无 50Hz 谐波**:FFT 分析 dx 序列,50Hz 附近分量不放大 6. **信号不丢失**:修改后整段 dx 的 RMS 偏差应在修改前的 [0.7, 1.3] 倍区间内(避免 One-Euro 把信号也滤掉) > **为什么不用皮尔逊相关性 > 0.95?** > One-Euro 会主动平滑 + 轻微滞后信号,皮尔逊相关性会下降(典型 ~0.7~0.9)。改用**阶跃事件对齐**(事件时间戳匹配)更能反映业务诉求:用户感受到的是"档位切换时机对不对",而不是"曲线形状完不完美"。 ### 8.3 真机回归(Android App) - 设备静止:扫描光栅卡,确认不抖、不跳档 - 缓慢倾斜:档位切换平滑,无阶跃 - 快速倾斜:档位切换及时,无延迟 - 设备平移拿起/放下:无误触发切换 - 倾斜并保持 5 秒:档位稳定 ### 8.4 跨平台不受影响验证 修改后立即验证: - iOS Native.js 路径:CMMotionManager 输出原样进入 `handleNativeTiltFrame` → `simulateFromSignedDegrees`,行为应与修改前完全一致 - H5 DeviceOrientationEvent:同上 - Android Native.js SensorManager:同上 --- ## 9. 实施步骤 1. 创建目录 `frontend/composables/__fixtures__/` 和 `frontend/composables/__tests__/` 2. 生成 fixture `frontend/composables/__fixtures__/tilt_input_v1.json`(按 §8.2 规格) 3. 创建 `frontend/composables/useOneEuroFilter.js` 4. 修改 `frontend/composables/useLenticularStudioTilt.js`: - 顶部 import - Android `startNativeApp` 闭包内新增 One-Euro 实例与 filter 调用 5. 创建 `frontend/composables/__tests__/useOneEuroFilter.test.mjs` 并运行通过 6. 创建 `frontend/composables/__tests__/tilt_regression.test.mjs` 并运行通过 7. 真机回归(Android App) 8. 提交(git commit 由用户触发) --- ## 10. 范围外(Pre-existing Issues) spec 审查过程中识别到以下**已存在但不属于本次修改范围**的问题,单独记录备查: 1. **`startNativeApp` 重入未清理旧 cleanup**:再次调用时未先调用旧 `nativeCleanup` 的 `uni.stopAccelerometer()`,可能导致 sensor 句柄泄漏。本次 One-Euro 修改是闭包级创建实例,不引入新风险。 2. **`nativeCleanup` 可显式调 `oneEuroRoll.reset()`**:当前 One-Euro 实例会随闭包 GC,无需显式清理;但为了代码对称性,可在 `nativeCleanup` 内追加 `oneEuroRoll.reset(); oneEuroPitch.reset()`。 3. **iOS `myGen`/`tiltGen` 重入竞态**:与本次 One-Euro 修改无关,由前序 commit 引入。 本次实施**不解决**上述 pre-existing 问题;如需修复另起 spec。