topfans/docs/superpowers/specs/2026-06-30-lenticular-android-accel-oneeuro-design.md

18 KiB
Raw Blame History

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   // 第二个独立的预热
       → clampJump15° 跳变拒绝)
       → 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)。如果滤波器放在 handleNativeTiltFrameiOS 也会被强制走一遍 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·Tealpha = 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.1dx_t = (x_t - x_{t-1}) / Te,其中 x_{t-1} 是上一时刻的 filter 输出
  • 官方 Python 实现:dx = (x - self.x_prev) / teself.x_prev 是 filter 输出

这种设计的妙处是:当输入慢变时 xPrev 已经跟上,dx 自然趋零 → cutoff 降回 mincutoff → 滤波强;当输入阶跃时 xPrev 还没跟上,dx 大 → cutoff 升高 → 滤波弱。这正是"速度自适应"的本质。


3.2 修改:useLenticularStudioTilt.js

改动 1在文件顶部导入 One-Euro Filter

import { createOneEuroFilter } from '@/composables/useOneEuroFilter.js'

改动 2startNativeApp 的 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不动 handleNativeTiltFrameresetStateupdateFast/updateSlow

保留原 fast/slow 双通道逻辑One-Euro 是前置的角度平滑,与"差值 dx = fast - slow"职责不重叠)。

改动 4调用 One-Euro 时统一传 performance.now()

闭包内的 tperformance.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 Hzalpha ≈ 0.225 帧内可达阶跃 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.jsfrontend/composables/__fixtures__/tilt_input_v1.jsonfrontend/composables/__tests__/*.test.mjs
  • 不影响
    • iOS Native.js、iOS CMMotionManager、H5 DeviceOrientationEvent、Android Native.js SensorManager
    • handleNativeTiltFramesimulateFromSignedDegreesuseLenticularCraftTiltPreview.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,极端情况不除零

回退方案:

// 临时回退:把这两行注释掉,把 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(同运行方式)
  • 测试 fixturefrontend/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=04s: 静止 0°
    • t=4.5s: 30° 阶跃
    • t=510s: 静止 30°
    • t=1010.5s: 回 0° 阶跃
    • t=1113s: ±2° @ 0.5 Hz 微振荡
    • t=1418s: 静止 0°
    • t=18.5s: -30° 阶跃
    • t=1930s: 静止 -30°
  • fixture 文件frontend/composables/__fixtures__/tilt_input_v1.json(提交进 git便于 CI 回放)

断言:

  1. 阶跃事件保持:检测 dx 序列的阶跃点(变化 > 10°/帧),修改前后阶跃位置(时间戳)相差 < 50ms2 帧)
  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 输出原样进入 handleNativeTiltFramesimulateFromSignedDegrees,行为应与修改前完全一致
  • 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:再次调用时未先调用旧 nativeCleanupuni.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。