18 KiB
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)
问题分析:
- 加速度计含重力 + 位移:直接转换出的 roll/pitch 含设备平移加速度伪影
- EMA 是固定截止频率的低通滤波器:
- 截止频率高(α=0.25):响应快但静止抖动大
- 截止频率低(α=0.08):平滑但有 ~60ms 滞后
- 单一 EMA 无法同时满足"静止平滑"和"动态跟手"
- 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
职责: 提供一个纯函数式的速度自适应低通滤波器,每个轴一个实例。
接口:
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() // 切换传感器 / 重置时调用
实现要点:
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
import { createOneEuroFilter } from '@/composables/useOneEuroFilter.js'
改动 2:在 startNativeApp 的 Android 分支内创建 One-Euro 实例
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(仅 AndroidstartNativeApp闭包内) - 新增文件:
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.jsuseHolographicPreview.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,极端情况不除零 |
回退方案:
// 临时回退:把这两行注释掉,把 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)
合成角度序列,断言:
- 静止平稳性:静止输入 5 秒,输出波动 < 0.1°
- 阶跃响应时间:0° → 30° 阶跃,输出达到 28.5°(95%)≤ 5 帧(100ms @ 50Hz,与 §5 验收对齐)
- 不超调:阶跃响应峰值不应超过目标 30°
- reset() 行为:
reset()后首次filter(x, t)返回 x - dt=0 边界:连续两次 filter 用同一时间戳,结果稳定不除零
- 大阶跃无溢出: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 回放)
断言:
- 阶跃事件保持:检测 dx 序列的阶跃点(变化 > 10°/帧),修改前后阶跃位置(时间戳)相差 < 50ms(2 帧)
- 首尾差:修改后 dx 的最后一秒均值与修改前相差 < 0.5°
- 阶跃响应时间:第 4.5s 注入 30° 阶跃,修改后 dx 在 100ms 内达到目标的 95%
- 静止段不抖:静止段(前 4 秒)的 dx 标准差,修改后 < 修改前的 50%
- 无 50Hz 谐波:FFT 分析 dx 序列,50Hz 附近分量不放大
- 信号不丢失:修改后整段 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. 实施步骤
- 创建目录
frontend/composables/__fixtures__/和frontend/composables/__tests__/ - 生成 fixture
frontend/composables/__fixtures__/tilt_input_v1.json(按 §8.2 规格) - 创建
frontend/composables/useOneEuroFilter.js - 修改
frontend/composables/useLenticularStudioTilt.js:- 顶部 import
- Android
startNativeApp闭包内新增 One-Euro 实例与 filter 调用
- 创建
frontend/composables/__tests__/useOneEuroFilter.test.mjs并运行通过 - 创建
frontend/composables/__tests__/tilt_regression.test.mjs并运行通过 - 真机回归(Android App)
- 提交(git commit 由用户触发)
10. 范围外(Pre-existing Issues)
spec 审查过程中识别到以下已存在但不属于本次修改范围的问题,单独记录备查:
startNativeApp重入未清理旧 cleanup:再次调用时未先调用旧nativeCleanup的uni.stopAccelerometer(),可能导致 sensor 句柄泄漏。本次 One-Euro 修改是闭包级创建实例,不引入新风险。nativeCleanup可显式调oneEuroRoll.reset():当前 One-Euro 实例会随闭包 GC,无需显式清理;但为了代码对称性,可在nativeCleanup内追加oneEuroRoll.reset(); oneEuroPitch.reset()。- iOS
myGen/tiltGen重入竞态:与本次 One-Euro 修改无关,由前序 commit 引入。
本次实施不解决上述 pre-existing 问题;如需修复另起 spec。