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

426 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`)。如果滤波器放在 `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·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.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 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.js`、`frontend/composables/__fixtures__/tilt_input_v1.json`、`frontend/composables/__tests__/*.test.mjs`
- **不影响**
- iOS Native.jsiOS CMMotionManagerH5 DeviceOrientationEventAndroid 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=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 输出原样进入 `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