35 KiB
Android 加速度计 One-Euro Filter 优化 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在 Android uni.startAccelerometer 路径上引入 One-Euro Filter,让光栅卡倾斜交互在静止时更稳定、动态时更跟手。
Architecture: 新增独立 composable useOneEuroFilter.js(纯函数式速度自适应低通滤波器,每轴一个实例),在 useLenticularStudioTilt.js 的 Android accel 闭包内嵌入,不污染 iOS / H5 / Native.js 路径。同步建立 Node.js 原生脚本测试体系(项目无 vitest)。
Tech Stack: uni-app Vue3、Node.js 原生 assert、@dcloudio/uni 原生 API、JavaScript ES2020+。
参考 Spec: docs/superpowers/specs/2026-06-30-lenticular-android-accel-oneeuro-design.md
不在本期: vitest 引入(独立 spec 决策)、pre-existing 重入竞态修复(spec §10 已记录)。
文件结构
| 文件 | 职责 |
|---|---|
frontend/composables/useOneEuroFilter.js |
新建 — 纯函数式速度自适应低通滤波器 |
frontend/composables/useLenticularStudioTilt.js |
修改 — Android startNativeApp 闭包内嵌入 One-Euro 调用 |
frontend/composables/__fixtures__/tilt_input_v1.json |
新建 — 30 秒合成加速度计序列,用于回归对比 |
frontend/composables/__tests__/useOneEuroFilter.test.mjs |
新建 — One-Euro Filter 单元测试(Node.js 原生 assert) |
frontend/composables/__tests__/tilt_regression.test.mjs |
新建 — 集成回归测试(修改前 vs 修改后对比) |
Task 1: 建立测试基础设施
Files:
-
Create:
frontend/composables/__fixtures__/(目录) -
Create:
frontend/composables/__tests__/(目录) -
Create:
frontend/composables/__tests__/.gitkeep -
Step 1: 验证 Node.js 版本(≥ 16,必须支持 ESM)
node --version
node -e "import('node:fs').then(m => console.log('ESM OK, writeFileSync:', typeof m.writeFileSync))"
Expected: 输出 Node 版本(≥ 16.x),第二行 ESM OK, writeFileSync: function。
如果 Node < 16:测试方案需要降级为 CommonJS(不在本计划范围,另起 spec)。
- Step 2: 创建测试目录
mkdir -p frontend/composables/__fixtures__ frontend/composables/__tests__
touch frontend/composables/__tests__/.gitkeep
- Step 3: 验证目录创建
ls -la frontend/composables/__tests__ frontend/composables/__fixtures__
Expected: 两个空目录,__tests__/ 内含 .gitkeep。
- Step 4: 暂存
git add frontend/composables/__tests__/.gitkeep
git status
Expected: 工作区显示新增的 .gitkeep,未提交(用户决定 commit 时机)。
Task 2: 生成测试 Fixture
Files:
-
Create:
frontend/composables/__fixtures__/tilt_input_v1.json -
Step 1: 创建 fixture 生成脚本(一次性,提交后保留作为可复现凭证)
创建 frontend/composables/__tests__/gen_tilt_fixture.mjs:
/**
* 生成 tilt_input_v1.json fixture
* - 种子:Mulberry32(seed=20260630) — 确定性
* - 30 秒 @ 50Hz = 1500 帧
* - 噪声:白高斯 σ=0.3° + 50 Hz 工频干扰 amplitude=0.05°
* - 轨迹见 spec §8.2
*/
import { writeFileSync } from 'node:fs'
function mulberry32(seed) {
return function () {
let t = seed += 0x6D2B79F5
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Box-Muller 标准正态
function gauss(rng) {
const u = Math.max(rng(), 1e-9)
const v = rng()
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v)
}
const FS = 50
const N = FS * 30
const NOISE_STD_DEG = 0.3
const MAINS_AMP = 0.05
const rng = mulberry32(20260630)
function targetRoll(t) {
if (t < 4.5) return 0
if (t < 10) return 30
if (t < 10.5) return 0
if (t < 13) return 2 * Math.sin(2 * Math.PI * 0.5 * (t - 11))
if (t < 18.5) return 0
return -30
}
const frames = []
for (let i = 0; i < N; i++) {
const t = i / FS
const noise = gauss(rng) * NOISE_STD_DEG
const mains = MAINS_AMP * Math.sin(2 * Math.PI * 50 * t)
frames.push({
tMs: Math.round(t * 1000),
rollDeg: targetRoll(t) + noise + mains,
pitchDeg: gauss(rng) * NOISE_STD_DEG + mains,
})
}
const fixture = {
version: 1,
sampleRateHz: FS,
durationSec: 30,
seed: 20260630,
noiseModel: 'white_gaussian_0.3deg + 50Hz_mains_0.05deg',
schedule: [
't=0-4s: stationary 0deg',
't=4.5s: +30deg step',
't=5-10s: hold 30deg',
't=10.5s: 0deg step',
't=11-13s: 2deg @ 0.5Hz micro-oscillation',
't=14-18s: stationary 0deg',
't=18.5s: -30deg step',
't=19-30s: hold -30deg',
],
frames,
}
writeFileSync(
'frontend/composables/__fixtures__/tilt_input_v1.json',
JSON.stringify(fixture, null, 2)
)
console.log(`Generated ${frames.length} frames`)
- Step 2: 运行脚本生成 fixture
cd frontend && node composables/__tests__/gen_tilt_fixture.mjs
cd ..
Expected: 输出 Generated 1500 frames。
- Step 3: 验证 fixture 内容
node -e "const f=require('./frontend/composables/__fixtures__/tilt_input_v1.json'); console.log('frames:', f.frames.length, 'first:', f.frames[0], 'last:', f.frames[f.frames.length-1])"
Expected: frames: 1500 first: { tMs: 0, rollDeg: <num>, pitchDeg: <num> } last: { tMs: 29800, ... }
- Step 4: 暂存
git add frontend/composables/__tests__/gen_tilt_fixture.mjs frontend/composables/__fixtures__/tilt_input_v1.json
git status
Expected: 两个新文件在工作区,未提交。
Task 3: One-Euro Filter 单元测试 (TDD - 红)
Files:
-
Create:
frontend/composables/__tests__/useOneEuroFilter.test.mjs -
Step 1: 编写失败的测试
/**
* useOneEuroFilter 单元测试
* 运行:node frontend/composables/__tests__/useOneEuroFilter.test.mjs
* 退出码:0=全部通过,1=有失败
*/
import assert from 'node:assert/strict'
import { createOneEuroFilter, nowMs } from '../useOneEuroFilter.js'
const results = { passed: 0, failed: 0, details: [] }
function test(name, fn) {
try {
fn()
results.passed++
results.details.push({ name, status: 'PASS' })
console.log(` ✓ ${name}`)
} catch (e) {
results.failed++
results.details.push({ name, status: 'FAIL', error: e.message })
console.log(` ✗ ${name}: ${e.message}`)
}
}
console.log('\n--- useOneEuroFilter 单元测试 ---')
test('nowMs 返回数字', () => {
assert.equal(typeof nowMs(), 'number')
assert.ok(nowMs() > 0)
})
test('首帧返回原值', () => {
const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 })
const out = f.filter(10, 1000)
assert.equal(out, 10)
})
test('reset 后下次 filter 返回原值', () => {
const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 })
f.filter(5, 1000)
f.filter(8, 1020)
f.reset()
const out = f.filter(42, 1100)
assert.equal(out, 42)
})
test('静止 3 秒输出波动 < 0.1°', () => {
// 测 spec §5:「设备静止 3 秒 → 输出波动 < 0.1°」
// 验证方法:喂入白噪声输入 3 秒,统计滤波器输出波动范围
const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 })
// 预热 1 秒(让滤波器状态稳定)
for (let i = 0; i < 50; i++) {
f.filter(0, i * 20)
}
// 测 3 秒:喂 ±1° 噪声,统计输出 max - min
const outputs = []
for (let i = 0; i < 3 * 50; i++) {
const t = (i + 50) * 20
const noise = (Math.sin(i * 0.7) + Math.cos(i * 1.3)) * 0.5 // ±1° 噪声
outputs.push(f.filter(noise, t))
}
const min = Math.min(...outputs)
const max = Math.max(...outputs)
const range = max - min
assert.ok(range < 0.1, `波动范围=${range.toFixed(4)}° 应 < 0.1° (min=${min.toFixed(3)}, max=${max.toFixed(3)})`)
})
test('阶跃响应 0→30° 在 5 帧内达到 95%', () => {
const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 })
// 预热:5 帧 0
for (let i = 0; i < 5; i++) f.filter(0, i * 20)
// 阶跃到 30
let reachedFrame = -1
for (let i = 0; i < 20; i++) {
const t = (i + 5) * 20
const out = f.filter(30, t)
if (reachedFrame === -1 && out >= 30 * 0.95) {
reachedFrame = i
}
}
assert.ok(reachedFrame >= 0 && reachedFrame <= 5, `reached at frame ${reachedFrame}, 应 ≤ 5`)
})
test('阶跃响应无超调(峰值不超过目标)', () => {
const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 })
for (let i = 0; i < 5; i++) f.filter(0, i * 20)
let peak = 0
for (let i = 0; i < 30; i++) {
const out = f.filter(30, (i + 5) * 20)
if (out > peak) peak = out
}
assert.ok(peak <= 30.5, `peak=${peak.toFixed(4)} 应 ≤ 30.5°`)
})
test('dt=0 边界不除零', () => {
const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 })
f.filter(5, 1000)
// 同时间戳
const out1 = f.filter(8, 1000)
const out2 = f.filter(10, 1000)
assert.ok(Number.isFinite(out1))
assert.ok(Number.isFinite(out2))
assert.ok(Math.abs(out1 - out2) < 30, 'dt=0 时不应产生极端跳变')
})
test('10 阶连续阶跃稳态误差 < 0.5°', () => {
// 测 spec §8.1 #6:「大阶跃无溢出:10 阶连续 30° 阶跃,输出不应超过 [target - 0.5°, target + 0.5°]」
// 验证方法:每阶跃结束后,等待稳态(30 帧),断言 |output - target| < 0.5°
const f = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 })
for (let step = 0; step < 10; step++) {
const target = step % 2 === 0 ? 30 : -30
// 喂 30 帧让滤波器收敛
const outputs = []
for (let i = 0; i < 30; i++) {
outputs.push(f.filter(target, (step * 30 + i) * 20))
}
const final = outputs[outputs.length - 1]
const err = Math.abs(final - target)
assert.ok(err < 0.5, `step=${step} 稳态误差=${err.toFixed(4)} 应 < 0.5° (target=${target}, final=${final.toFixed(4)})`)
}
})
console.log(`\n--- 总结 ---`)
console.log(`通过: ${results.passed}, 失败: ${results.failed}`)
process.exit(results.failed > 0 ? 1 : 0)
- Step 2: 运行测试,验证失败
node frontend/composables/__tests__/useOneEuroFilter.test.mjs
Expected: 测试失败(useOneEuroFilter.js 不存在或函数未导出),退出码 1。
如果意外通过:停下来确认 useOneEuroFilter.js 是否已存在;按 TDD 必须先红再绿。
Task 4: One-Euro Filter 实现 (TDD - 绿)
Files:
-
Create:
frontend/composables/useOneEuroFilter.js -
Step 1: 实现 One-Euro Filter
/**
* 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.7, beta: 0.015, dcutoff: 1.0 })
* const out = f.filter(x, performance.now())
* f.reset()
*
* @param {object} [opts]
* @param {number} [opts.mincutoff=0.7] - Hz,静止截止频率
* @param {number} [opts.beta=0.015] - 速度系数(越大快速运动越跟手)
* @param {number} [opts.dcutoff=1.0] - Hz,速度估计的截止频率
*/
export function createOneEuroFilter({ mincutoff = 0.7, beta = 0.015, 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()
}
- Step 2: 运行单元测试,验证全部通过
node frontend/composables/__tests__/useOneEuroFilter.test.mjs
Expected: 9/9 通过,退出码 0。
如果仍有失败:
-
检查
mincutoff是否被正确读取 -
检查 dt clamp 是否生效
-
不要修改测试代码来"让它通过"
-
Step 3: 暂存 One-Euro 模块 + 测试
git add frontend/composables/useOneEuroFilter.js frontend/composables/__tests__/useOneEuroFilter.test.mjs
git status
Expected: 两个新文件暂存。
Task 5: 集成回归测试脚本 (TDD - 红)
Files:
-
Create:
frontend/composables/__tests__/tilt_regression.test.mjs -
Step 1: 编写回归测试
/**
* tilt_regression.test.mjs
* 用 fixture 模拟 30 秒合成加速度计序列,验证 One-Euro 修改后行为符合验收标准。
*
* 对比对象:
* - baselineDx[i]: 修改前的 dx 序列(用同样输入运行原 fast/slow 通道)
* - actualDx[i]: 修改后(含 One-Euro)的 dx 序列
*
* 运行:node frontend/composables/__tests__/tilt_regression.test.mjs
*/
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createOneEuroFilter } from '../useOneEuroFilter.js'
const FAST_ALPHA = 0.25
const SLOW_ALPHA = 0.08
const JUMP_REJECT_DEG = 15
const WARMUP_SKIP = 5
const FIXTURE_PATH = '../__fixtures__/tilt_input_v1.json'
const fixture = JSON.parse(readFileSync(new URL(FIXTURE_PATH, import.meta.url), 'utf-8'))
const frames = fixture.frames
/**
* 仿真"修改前"的 fast/slow 双通道 + clampJump + warmup
*/
function simulateBaseline(inputSeq) {
let prevRawX = NaN, prevRawY = NaN
let warmup = 0
let fastX = NaN, fastY = NaN
let slowX = NaN, slowY = NaN
const dxOut = []
for (const f of inputSeq) {
if (warmup < WARMUP_SKIP) {
warmup++
dxOut.push(0)
continue
}
// clampJump
let cx = f.rollDeg, cy = f.pitchDeg
if (Number.isFinite(prevRawX)) {
if (Math.abs(f.rollDeg - prevRawX) > JUMP_REJECT_DEG) {
cx = prevRawX + Math.sign(f.rollDeg - prevRawX) * JUMP_REJECT_DEG
}
if (Math.abs(f.pitchDeg - prevRawY) > JUMP_REJECT_DEG) {
cy = prevRawY + Math.sign(f.pitchDeg - prevRawY) * JUMP_REJECT_DEG
}
}
prevRawX = f.rollDeg; prevRawY = f.pitchDeg
// updateFast
if (!Number.isFinite(fastX)) { fastX = cx; fastY = cy } else {
fastX += (cx - fastX) * FAST_ALPHA
fastY += (cy - fastY) * FAST_ALPHA
}
// updateSlow
if (!Number.isFinite(slowX)) { slowX = 0; slowY = 0 } else {
slowX += (cx - slowX) * SLOW_ALPHA
slowY += (cy - slowY) * SLOW_ALPHA
}
dxOut.push(fastX - slowX)
}
return dxOut
}
/**
* 仿真"修改后":在 atan2 后插 One-Euro,再走同一 fast/slow 通道
*/
function simulateWithOneEuro(inputSeq) {
const oneEuroRoll = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 })
const oneEuroPitch = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 })
// 预热 One-Euro 用前 5 帧(与 accelWarmup 一致)
for (let i = 0; i < WARMUP_SKIP; i++) {
oneEuroRoll.filter(frames[i].rollDeg, frames[i].tMs)
oneEuroPitch.filter(frames[i].pitchDeg, frames[i].tMs)
}
let prevRawX = NaN, prevRawY = NaN
let fastX = NaN, fastY = NaN
let slowX = NaN, slowY = NaN
const dxOut = []
for (let i = 0; i < inputSeq.length; i++) {
const f = inputSeq[i]
if (i < WARMUP_SKIP) {
dxOut.push(0)
continue
}
// One-Euro 平滑
const roll = oneEuroRoll.filter(f.rollDeg, f.tMs)
const pitch = oneEuroPitch.filter(f.pitchDeg, f.tMs)
// clampJump
let cx = roll, cy = pitch
if (Number.isFinite(prevRawX)) {
if (Math.abs(roll - prevRawX) > JUMP_REJECT_DEG) {
cx = prevRawX + Math.sign(roll - prevRawX) * JUMP_REJECT_DEG
}
if (Math.abs(pitch - prevRawY) > JUMP_REJECT_DEG) {
cy = prevRawY + Math.sign(pitch - prevRawY) * JUMP_REJECT_DEG
}
}
prevRawX = roll; prevRawY = pitch
if (!Number.isFinite(fastX)) { fastX = cx; fastY = cy } else {
fastX += (cx - fastX) * FAST_ALPHA
fastY += (cy - fastY) * FAST_ALPHA
}
if (!Number.isFinite(slowX)) { slowX = 0; slowY = 0 } else {
slowX += (cx - slowX) * SLOW_ALPHA
slowY += (cy - slowY) * SLOW_ALPHA
}
dxOut.push(fastX - slowX)
}
return dxOut
}
const baselineDx = simulateBaseline(frames)
const actualDx = simulateWithOneEuro(frames)
let passed = 0, failed = 0
function check(name, fn) {
try { fn(); passed++; console.log(` ✓ ${name}`) }
catch (e) { failed++; console.log(` ✗ ${name}: ${e.message}`) }
}
console.log('\n--- tilt 集成回归测试 ---')
// 1. 静止段不抖:前 4 秒(前 200 帧)dx 标准差,actual < baseline 的 50%
check('静止段不抖(actual std < baseline std 的 50%)', () => {
const slice = (arr, s, e) => arr.slice(s, e)
const bSlice = slice(baselineDx, WARMUP_SKIP, 4 * 50)
const aSlice = slice(actualDx, WARMUP_SKIP, 4 * 50)
const std = arr => {
const m = arr.reduce((a, b) => a + b, 0) / arr.length
return Math.sqrt(arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length)
}
const bStd = std(bSlice), aStd = std(aSlice)
console.log(` baseline std=${bStd.toFixed(4)}, actual std=${aStd.toFixed(4)}`)
assert.ok(aStd < bStd * 0.5, `aStd=${aStd} 应 < bStd*0.5=${bStd * 0.5}`)
})
// 2. 阶跃响应:4.5s 注入 30° 阶跃,actual dx 在 100ms 内达到目标 95%
check('阶跃响应时间(actual 在 100ms 内达到目标 95%)', () => {
const stepFrame = Math.round(4.5 * 50) // 225
const target = 28.5 // 30° * 0.95
let reachedAt = -1
for (let i = 0; i < 10; i++) {
if (actualDx[stepFrame + i] >= target) { reachedAt = i; break }
}
console.log(` reached at +${reachedAt} frames (${reachedAt * 20}ms)`)
assert.ok(reachedAt >= 0 && reachedAt <= 5, `reachedAt=${reachedAt} 应 ≤ 5`)
})
// 3. 阶跃事件保持:检测 baseline 与 actual 的阶跃位置(dx 变化 > 10°/帧),时间戳相差 < 50ms
check('阶跃事件保持(baseline/actual 阶跃位置相差 < 50ms)', () => {
function detectSteps(dxSeq, threshold = 10) {
const out = []
for (let i = 1; i < dxSeq.length; i++) {
if (Math.abs(dxSeq[i] - dxSeq[i - 1]) > threshold) {
out.push(i)
}
}
return out
}
const bSteps = detectSteps(baselineDx)
const aSteps = detectSteps(actualDx)
console.log(` baseline steps: ${bSteps.length}, actual steps: ${aSteps.length}`)
// 主要阶跃位置应能找到对应
const mainSteps = bSteps.filter(f => Math.abs(baselineDx[f]) > 10).slice(0, 3)
for (const f of mainSteps) {
const nearest = aSteps.reduce((best, x) =>
Math.abs(x - f) < Math.abs(best - f) ? x : best, aSteps[0])
assert.ok(Math.abs(nearest - f) * 20 < 50,
`baseline step@frame${f} 实际阶跃在 frame${nearest},相差 ${Math.abs(nearest - f) * 20}ms 应 < 50ms`)
}
})
// 4. 信号不丢失:actual RMS 偏差在 baseline 的 [0.7, 1.3] 倍区间内
check('信号不丢失(actual RMS 在 baseline [0.7, 1.3]x)', () => {
const rms = arr => Math.sqrt(arr.reduce((s, v) => s + v * v, 0) / arr.length)
const bRms = rms(baselineDx), aRms = rms(actualDx)
console.log(` baseline rms=${bRms.toFixed(4)}, actual rms=${aRms.toFixed(4)}`)
assert.ok(aRms >= bRms * 0.7 && aRms <= bRms * 1.3,
`aRms/bRms=${(aRms / bRms).toFixed(3)} 应 ∈ [0.7, 1.3]`)
})
// 5. 末尾稳态差:actual dx 最后一秒均值与 baseline 相差 < 0.5°
check('末尾稳态差(最后 1 秒均值差 < 0.5°)', () => {
const last = arr => arr.slice(-50).reduce((a, b) => a + b, 0) / 50
const bLast = last(baselineDx), aLast = last(actualDx)
console.log(` baseline last=${bLast.toFixed(4)}, actual last=${aLast.toFixed(4)}`)
assert.ok(Math.abs(aLast - bLast) < 0.5, `差=${Math.abs(aLast - bLast).toFixed(4)} 应 < 0.5`)
})
// 6. 无 50Hz 谐波放大:对 actual dx 做简易 DFT,验证 50Hz 附近幅值不放大
check('无 50Hz 谐波放大(actual 50Hz-band 能量不高于 baseline 的 1.5 倍)', () => {
const FS = 50
function bandPower(signal, fs, centerHz, bandwidthHz = 2) {
// 计算 [centerHz - bw, centerHz + bw] 频带的能量(简化版 DFT)
const N = signal.length
let power = 0
for (let f = Math.round((centerHz - bandwidthHz) * N / fs); f <= Math.round((centerHz + bandwidthHz) * N / fs); f++) {
if (f <= 0 || f >= N / 2) continue
let re = 0, im = 0
for (let n = 0; n < N; n++) {
const angle = 2 * Math.PI * f * n / N
re += signal[n] * Math.cos(angle)
im -= signal[n] * Math.sin(angle)
}
power += re * re + im * im
}
return power
}
const bPwr = bandPower(baselineDx, FS, 50)
const aPwr = bandPower(actualDx, FS, 50)
console.log(` baseline 50Hz-band power=${bPwr.toFixed(2)}, actual=${aPwr.toFixed(2)}`)
// One-Euro 应该滤掉 50Hz,所以 actual 应该 < baseline(而非放大)
assert.ok(aPwr < bPwr * 1.5, `actual 50Hz-band power 放大超过 1.5x(bPwr=${bPwr}, aPwr=${aPwr})`)
})
console.log(`\n--- 总结 ---`)
console.log(`通过: ${passed}, 失败: ${failed}`)
process.exit(failed > 0 ? 1 : 0)
- Step 2: 运行测试,验证"actual"分支在未修改 tilt 文件时仍跑通(即 baseline 路径有效)
node frontend/composables/__tests__/tilt_regression.test.mjs
Expected: 测试运行通过(因为 baseline 和 actual 都内联在测试脚本里,不依赖 tilt.js 的代码)。如果失败:检查 fixture 加载路径、参数。
注:此测试是"自我闭环"的,验证 One-Euro 参数选择是否正确,不是验证 tilt.js 修改后的行为。后者在 Task 7 通过手测或 diff 验证。
Task 6: 修改 useLenticularStudioTilt.js 集成 One-Euro
Files:
-
Modify:
frontend/composables/useLenticularStudioTilt.js:1-12(top import) -
Modify:
frontend/composables/useLenticularStudioTilt.js:609-628(AndroidstartNativeAppclosure) -
Step 1: 读取当前文件首部,确认 import 区域位置
head -15 frontend/composables/useLenticularStudioTilt.js
Expected: 文件顶部有 JSDoc 注释块(第 1-34 行),第 35 行开始是常量定义。import 应放在第 34 行之后、常量之前。
- Step 2: 添加 import
在 useLenticularStudioTilt.js 第 34 行(SKIP_WARMUP_FRAMES = 5 之前)插入:
import { createOneEuroFilter, nowMs } from '@/composables/useOneEuroFilter.js'
- Step 3: 修改 Android
startNativeApp闭包(替换原第 609-628 行)
定位:从 if (plus.os && plus.os.name === 'Android') { 开始,到 return(第 628 行)结束的整个 if 块。
将原代码:
if (plus.os && plus.os.name === 'Android') {
uni.startAccelerometer({ interval: 'game' })
let accelWarmup = 0
let accelCb = function(res) {
if (myGen !== tiltGen) return
// 跳过前几帧初始化噪声
if (accelWarmup < SKIP_WARMUP_FRAMES) { accelWarmup++; return }
// 不额外 LPF——交给管道的 fast/slow 双通道做平滑
const roll = Math.atan2(res.x, res.z) * (180 / Math.PI)
const pitch = Math.atan2(res.y, Math.sqrt(res.x*res.x + res.z*res.z)) * (180 / Math.PI)
gyroSourceLabel.value = 'accelerometer'
handleNativeTiltFrame(roll, pitch)
}
uni.onAccelerometerChange(accelCb)
nativeCleanup = function() {
uni.offAccelerometerChange(accelCb)
try { uni.stopAccelerometer() } catch (_) {}
}
console.log('[useLenticularStudioTilt] uni.onAccelerometerChange started (no LPF)')
return
}
替换为:
if (plus.os && plus.os.name === 'Android') {
uni.startAccelerometer({ interval: 'game' })
let accelWarmup = 0
// ——— Android-accel 路径专用:One-Euro Filter(速度自适应低通)———
// 必须在闭包内新建,不能放在 handleNativeTiltFrame
// (handleNativeTiltFrame 被 iOS CMMotionManager 共用)
const oneEuroRoll = createOneEuroFilter({ mincutoff: 0.7, beta: 0.015, dcutoff: 1.0 })
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
}
关于
nativeCleanup是否调oneEuroRoll.reset(): 本计划不追加此调用。Spec §7 列了「为对称性可追加」作为缓解,Spec §10 又把它列为 pre-existing 范围外问题。startNativeApp每次调用都新建闭包实例,新实例首帧直接返回原值(无需 reset),所以行为正确。如后续需要对称性可单独立项修复(影响面小,独立 commit)。
- Step 4: 验证仅 Android 闭包被修改
git diff frontend/composables/useLenticularStudioTilt.js | head -100
Expected: diff 仅显示:
- 顶部新增的
import行 - Android
if块内的变更 console.log文案变更
不应该有:
handleNativeTiltFrame函数体的变更- iOS 路径的变更
handleDeviceOrientation/createAndroidTiltSensor/createIOSTiltSensor的变更resetState的变更simulate/simulateFromSignedDegrees的变更
如果有意外的 diff:停下来重新检查。
- Step 5: 验证 import 解析正确
grep -n "import.*useOneEuroFilter" frontend/composables/useLenticularStudioTilt.js
Expected: 第 35 行(或附近)显示 import { createOneEuroFilter, nowMs } from '@/composables/useOneEuroFilter.js'。
- Step 6: 运行单元测试确认 One-Euro 模块仍正常
node frontend/composables/__tests__/useOneEuroFilter.test.mjs
Expected: 9/9 通过。
- Step 7: Build-time 验证 import 可解析
@/ alias 由 @dcloudio/uni-cli-shared 在构建时解析(不是 vite.config.js alias)。需要真正跑一次 uni-app 构建确认 import 能解析:
cd frontend
# 用项目现有 dev 命令启动 H5 构建(不部署到设备,只看是否有解析错误)
# 命令名根据项目 package.json 而定,常见的有:
npm run dev:h5
# 或 yarn dev:h5 / pnpm dev:h5
# 让构建跑 5-10 秒,观察终端输出:
# - 期望:无 `Could not resolve '@/composables/useOneEuroFilter.js'` 错误
# - 期望:无 `Failed to resolve import` 警告
# Ctrl+C 终止
Expected: 构建无 import 解析错误。
如果有解析错误:
- 确认文件路径
frontend/composables/useOneEuroFilter.js存在 - 确认 import 路径拼写正确(区分大小写)
- 确认
frontend/package.json的@dcloudio/vite-plugin-uni版本 ≥ 3.0
注意:此步骤必须用真构建(H5 / App 都行),不能用
node --check(Node 不会处理@/alias)。
- Step 8: 暂存
git add frontend/composables/useLenticularStudioTilt.js
git status
Expected: 仅 useLenticularStudioTilt.js 暂存,无其他未暂存文件。
Task 7: 跨平台非受影响验证
Files:
-
不修改任何文件,仅验证
-
Step 1: 阅读 iOS 分支,确认无 One-Euro 调用泄漏
sed -n '630,660p' frontend/composables/useLenticularStudioTilt.js
Expected: iOS 分支(if (plus.os && plus.os.name === 'iOS'))的代码与修改前完全一致,仍调用 handleNativeTiltFrame(roll, pitch),无 One-Euro。
- Step 2: 阅读 handleNativeTiltFrame,确认函数体未改
sed -n '152,166p' frontend/composables/useLenticularStudioTilt.js
Expected: 函数体从 clampJump → updateFast → updateSlow 流程未引入 One-Euro。
- Step 3: 阅读 H5 DeviceOrientation 路径
sed -n '691,711p' frontend/composables/useLenticularStudioTilt.js
Expected: handleDeviceOrientation 函数体与修改前一致,无 One-Euro。
- Step 4: 阅读 Android Native.js SensorManager 路径
sed -n '298,500p' frontend/composables/useLenticularStudioTilt.js | grep -E "(oneEuro|OneEuro|createOneEuro)" || echo "未发现 One-Euro 调用,✓"
Expected: 未发现 One-Euro 调用,✓
- Step 5: 确认 import 路径在生产构建中正确解析
检查 frontend/vite.config.js 或 frontend/pages.json 是否有 @/ alias 配置:
grep -rn '"@/' frontend/*.config.js frontend/vite.config.js 2>/dev/null | head -5
grep -n "useLenticularStudioTilt" frontend/composables/useLenticularCraftTiltPreview.js | head -3
Expected:
vite.config.js中应有resolve.alias['@']配置(如果useLenticularCraftTiltPreview.js已用@/composables/useLenticularStudioTilt.js导入并能工作,说明 alias 配置存在)useLenticularCraftTiltPreview.js第 13 行有同样的@/composables/...导入
Task 8: 回归测试矩阵
Files:
-
不修改任何文件,整理回归证据
-
Step 1: 收集测试输出
echo "=== 单元测试 ===" > frontend/composables/__tests__/RESULTS.md
node frontend/composables/__tests__/useOneEuroFilter.test.mjs >> frontend/composables/__tests__/RESULTS.md 2>&1
echo "" >> frontend/composables/__tests__/RESULTS.md
echo "=== 集成回归 ===" >> frontend/composables/__tests__/RESULTS.md
node frontend/composables/__tests__/tilt_regression.test.mjs >> frontend/composables/__tests__/RESULTS.md 2>&1
cat frontend/composables/__tests__/RESULTS.md
Expected: RESULTS.md 末尾显示两个测试套件的"通过: N, 失败: 0"。
- Step 2: 列出本次所有变更文件清单
git status --short
echo "---"
git diff --stat HEAD
Expected: 5 个新文件 + 1 个修改文件:
-
frontend/composables/useOneEuroFilter.js(新建) -
frontend/composables/__fixtures__/tilt_input_v1.json(新建) -
frontend/composables/__tests__/gen_tilt_fixture.mjs(新建) -
frontend/composables/__tests__/useOneEuroFilter.test.mjs(新建) -
frontend/composables/__tests__/tilt_regression.test.mjs(新建) -
frontend/composables/__tests__/RESULTS.md(新建) -
frontend/composables/useLenticularStudioTilt.js(修改) -
Step 3: 暂存测试结果
git add frontend/composables/__tests__/RESULTS.md
注:commit 由用户触发,本任务不 commit。
Task 9: 真机回归(需 Android 设备)
Files:
-
不修改任何文件,需要 Android 真机 + 已构建的 App
-
Step 1: 构建并安装 App 到测试设备
按项目现有流程:
# 具体命令取决于项目构建配置,此处仅说明
cd frontend
# npm/yarn run build:android 或 HBuilderX 真机运行
Expected: App 启动到任意一个光栅卡预览页(asset-detail / lenticular-result / generation-result 任一)。
-
Step 2: 验证「设备静止」场景
-
设备平放在桌面
-
打开任一光栅卡预览
-
观察 30 秒
-
期望:档位稳定不动,无微颤切换
-
Step 3: 验证「缓慢倾斜」场景
-
设备从水平缓慢倾斜到约 30°(约 1 秒完成)
-
期望:档位切换平滑跟随,无阶跃跳变
-
Step 4: 验证「快速倾斜」场景
-
设备从水平快速倾斜到约 30°(约 0.2 秒完成)
-
期望:档位切换及时,无明显延迟(< 100ms 感知)
-
Step 5: 验证「设备平移拿起/放下」场景
-
拿起设备(不倾斜,纯垂直拿起)
-
期望:不误触发档位切换(clampJump 应拒绝阶跃)
-
Step 6: 记录真机结果
在 frontend/composables/__tests__/RESULTS.md 末尾追加:
### 真机回归(YYYY-MM-DD HH:MM)
- 设备:[设备型号] / Android [版本]
- 构建:[commit hash 或构建编号]
| 场景 | 结果 | 备注 |
|------|------|------|
| 设备静止 30 秒 | [PASS/FAIL] | |
| 缓慢倾斜 | [PASS/FAIL] | |
| 快速倾斜 | [PASS/FAIL] | |
| 设备平移拿起 | [PASS/FAIL] | |
- Step 7: 暂存真机结果
git add frontend/composables/__tests__/RESULTS.md
Task 10: 自审与最终回归
Files:
-
不修改代码,仅自审
-
Step 1: 对照 CLAUDE.md「自审清单」逐项检查
| 检查项 | 结果 |
|---|---|
| 修改的函数所有调用方仍正常工作 | 6 个页面消费者、2 个 composable 消费者均未改接口 |
| 相关单元测试是否通过 | 9/9 + 5/5 |
| 是否有条件分支只改了其中一支 | 仅 Android accel 分支;iOS / H5 / Native.js 分支未动 |
| 错误处理 / 边界条件 | dt 除零、reset、首帧、性能均覆盖 |
| 数据库 / API 字段变更 | N/A |
| 日志、错误信息、文档 | spec + RESULTS.md 同步 |
- Step 2: 验证 final commit 准备就绪
git status --short
echo "---"
ls -la frontend/composables/useOneEuroFilter.js
ls -la frontend/composables/__fixtures__/tilt_input_v1.json
ls -la frontend/composables/__tests__/*.mjs frontend/composables/__tests__/RESULTS.md
Expected: 所有目标文件存在。
完成标准
✅ Task 1-8 在开发环境完成(无需真机) ✅ Task 9 真机回归通过(需 Android 设备) ✅ Task 10 自审清单全部勾选 ✅ 单元测试 9/9 通过 ✅ 集成回归测试 5/5 通过 ✅ git status 仅显示本次 7 个新文件 + 1 个修改文件
Commit 由用户触发:根据 CLAUDE.md「未经用户明确指示,AI 严禁执行 git commit」。
建议的 commit 信息(仅供用户参考):
feat(光栅倾斜): Android 加速度计路径引入 One-Euro Filter
- 新增 useOneEuroFilter composable(速度自适应低通)
- 在 Android uni.startAccelerometer 闭包内嵌入(不动 iOS/H5/Native.js)
- 新增 Node.js 单元测试 + 集成回归测试 + fixture
- 参数:mincutoff=0.7Hz, beta=0.015, dcutoff=1.0Hz
- 效果:静止时 alpha≈0.08(vs 原 0.25),快速运动自适应提速
参考 spec: docs/superpowers/specs/2026-06-30-lenticular-android-accel-oneeuro-design.md
参考 plan: docs/superpowers/plans/2026-06-30-android-accel-oneeuro-implementation.md