feat:改为加速度计优化
This commit is contained in:
parent
9c3fe6d695
commit
4eab42b3fb
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,426 @@
|
||||
# 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。
|
||||
@ -102,7 +102,7 @@ export function useLenticularCraftTiltPreview(layersRef) {
|
||||
|
||||
dlog('[Discrete] mag:', tiltMag.toFixed(1), '→ center')
|
||||
simulate(0, 0)
|
||||
snapSimulatedTilt({ resetLayerSmoothing: true })
|
||||
snapSimulatedTilt({ resetLayerSmoothing: false })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
75
frontend/composables/useOneEuroFilter.js
Normal file
75
frontend/composables/useOneEuroFilter.js
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* One-Euro Filter(速度自适应低通滤波器)
|
||||
* 参考:Casiez et al., "1€ Filter: A Simple Speed-based Low-pass Filter for Noisy Input in Interactive Systems", CHI 2012
|
||||
* https://cristal.univ-lille.fr/~casiez/1euro/
|
||||
*
|
||||
* 用法:每个轴一个实例
|
||||
* const f = createOneEuroFilter({ mincutoff: 0.2, beta: 0.05, dcutoff: 1.0 })
|
||||
* const out = f.filter(x, performance.now())
|
||||
* f.reset()
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.mincutoff=0.2] - Hz,静止截止频率
|
||||
* @param {number} [opts.beta=0.05] - 速度系数(越大快速运动越跟手)
|
||||
* @param {number} [opts.dcutoff=1.0] - Hz,速度估计的截止频率
|
||||
*/
|
||||
export function createOneEuroFilter({ mincutoff = 0.2, beta = 0.05, dcutoff = 1.0 } = {}) {
|
||||
let xPrev = null // 上次 FILTERED 输出(与 Casiez 论文一致)
|
||||
let dxPrev = 0 // 平滑后的速度
|
||||
let tPrev = null // 上次时间戳
|
||||
|
||||
function smoothingFactor(cutoff, dt) {
|
||||
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. 速度差分(用 filtered xPrev,与 Casiez 一致)
|
||||
const dx = (x - xPrev) / dt
|
||||
|
||||
// 2. 平滑速度
|
||||
const aD = smoothingFactor(dcutoff, dt)
|
||||
const edx = aD * dx + (1 - aD) * dxPrev
|
||||
|
||||
// 3. 自适应截止频率
|
||||
const cutoff = mincutoff + beta * Math.abs(edx)
|
||||
|
||||
// 4. 平滑位置
|
||||
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 精度)。
|
||||
*/
|
||||
export function nowMs() {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now()
|
||||
}
|
||||
return Date.now()
|
||||
}
|
||||
@ -65,7 +65,8 @@
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
|
||||
"<uses-permission android:name=\"android.permission.BODY_SENSORS\"/>"
|
||||
],
|
||||
"abiFilters" : [ "armeabi-v7a", "arm64-v8a" ],
|
||||
"schemes" : "topfans"
|
||||
@ -77,7 +78,8 @@
|
||||
"NSCameraUsageDescription" : "我们需要访问相机以便您拍摄头像",
|
||||
"NSPhotoLibraryUsageDescription" : "我们需要访问相册以便您选择图片",
|
||||
"NSMicrophoneUsageDescription" : "我们需要访问麦克风以便您使用语音输入功能",
|
||||
"NSSpeechRecognitionUsageDescription" : "我们需要使用语音识别以便将您的语音转换为文字"
|
||||
"NSSpeechRecognitionUsageDescription" : "我们需要使用语音识别以便将您的语音转换为文字",
|
||||
"NSMotionUsageDescription" : "我们需要访问运动传感器以实现光栅卡倾斜预览功能"
|
||||
},
|
||||
"urltypes" : [
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user