topfans/docs/superpowers/plans/2026-06-30-android-accel-oneeuro-implementation.md

35 KiB
Raw Blame History

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.5xbPwr=${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 (Android startNativeApp closure)

  • 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 --checkNode 不会处理 @/ 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.jsfrontend/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.08vs 原 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