Demo:PoseClockLab
页面:FrameSyncPage
任务 ID:sync_20261001_21

同一组书桌采集数据,慢慢绕一圈时重建很稳,走快一点,显示器边框却会拖出一层半透明“重影”。我起初把它归到运动模糊,降曝光、删糊帧、提高纹理阈值,画面有改善,墙角的双边仍然存在。直到把相机帧时间戳和陀螺仪时间戳画在一条轴上,问题才露出来:两路数据都单调递增,却相差约 38 ms。

38 ms 对普通预览不一定显眼,对 3DGS 输入姿态却足以把相机放到“稍早以前”的位置。PoseClockLab 这次只处理一件事:在送入 Spatial Reconstruction 管线前,对齐相机帧与 IMU 时间轴,并为每帧插值得到同一时刻的姿态。固定回放包含 240 帧和 1920 条 IMU 样本;修复后偏移从 38 ms 收敛到 3 ms,17 帧无匹配降为 0,重影评分从 0.42 降到 0.11,插值开销 P95 为 0.8 ms。

一、两条都“正确”的时间戳,合在一起却错了

采集层原来只做了两件事:相机回调来了就保存图像和 frameTimestamp,传感器回调来了就把角速度写入数组。重建任务开始时,再按数组下标把八条 IMU 数据配给一帧。这个比例在稳定帧率下看起来合理,却隐含了三个假设:两路时钟同源、回调延迟恒定、相机永不掉帧。真机上三个假设都不成立。

日志里最有用的不是帧率,而是相邻差值。相机帧间隔偶尔从 33 ms 跳到 66 ms,说明中间发生过丢帧;IMU 仍按自身节奏采样。继续按八比一切片,后面所有姿态都会整体错一格。另一方面,事件到达 ArkTS 的时间只反映调度,不等于硬件采样时刻。若用 Date.now() 替代事件自带时间戳,线程忙时偏差会突然增大。

因此页面状态没有直接从“采集中”跳到“可重建”。我把流程拆成 IDLE → CALIBRATING → ALIGNING → SYNC_STABLE。前 24 帧用于估计时钟偏移,校准完成才开始输出同步包;窗口切换、暂停恢复或采集会话重建时,generation 增加,旧缓存与迟到回调全部作废。

二、偏移估计要抗住一次慢回调

当前要解决的问题,是从一组相机/IMU 同步观测中估计稳定偏移。直接取平均值很容易被一次 GC 或调度抖动拉偏,所以 Demo 对差值排序后取中位数,再用最大允许偏移做门禁。

export interface SyncPair {
  frameTsNs: number
  imuTsNs: number
}

export class ClockOffsetEstimator {
  static estimateNs(pairs: SyncPair[]): number {
    if (pairs.length < 12) {
      throw new Error('insufficient sync pairs')
    }
    const offsets = pairs
      .map((pair: SyncPair) => pair.frameTsNs - pair.imuTsNs)
      .sort((a: number, b: number) => a - b)
    const middle = Math.floor(offsets.length / 2)
    const offset = offsets[middle]
    if (Math.abs(offset) > 80_000_000) {
      throw new Error('clock offset out of range')
    }
    return offset
  }
}

这段代码在 CALIBRATING 阶段执行。输入不是“回调到达时间相近”的随便两条记录,而是采集层确认属于同一校准动作的同步对;输出单位固定为纳秒。得到偏移后,后续 IMU 时间统一换算到相机时间域,原始样本不修改,方便问题回放。

中位数能压住离群点,却不能解决时钟随时间漂移。正式项目应按固定窗口重新估计,并观察斜率;如果偏移持续单向增长,应该拟合 frameTs = a * imuTs + b,而不是只减一个常量。Demo 的录制时长较短,因此只维护 offset。页面退出或应用进入后台时会停止校准,恢复后重新建立会话,不能复用上次的 3 ms 结果。

三、不找“最近样本”,而是找包围帧时刻的两端

当前要解决的问题,是给每个相机帧找到前后两条 IMU 姿态,并在同一时刻插值。只取距离最近的一条会把姿态变成阶梯,转动快时尤其明显。环形缓冲区保留最近两秒数据,既限制内存,也保证掉一帧后仍有足够的上下文。

export interface PoseSample {
  tsNs: number
  rotation: Quaternion
}

export class ImuRingBuffer {
  private samples: PoseSample[] = []

  push(sample: PoseSample): void {
    const last = this.samples[this.samples.length - 1]
    if (last && sample.tsNs <= last.tsNs) return
    this.samples.push(sample)
    const floor = sample.tsNs - 2_000_000_000
    while (this.samples.length > 2 && this.samples[0].tsNs < floor) {
      this.samples.shift()
    }
  }

  interpolate(frameTsNs: number): Quaternion | undefined {
    const right = this.samples.findIndex((item: PoseSample) => item.tsNs >= frameTsNs)
    if (right <= 0) return undefined
    const a = this.samples[right - 1]
    const b = this.samples[right]
    const ratio = (frameTsNs - a.tsNs) / (b.tsNs - a.tsNs)
    return Quaternion.slerp(a.rotation, b.rotation, ratio)
  }
}

push() 明确拒绝倒序或重复时间戳,避免二分/区间查找建立在错误顺序上。示例为了可读性使用 findIndex();正式项目样本量更大时应保存游标或二分查找,不能每帧线性扫描。Quaternion.slerp() 处理球面插值,不能把四元数四个分量各自线性混合后直接使用,否则接近 180 度时会走错路径,插值后也必须归一化。

当帧时刻早于首条样本或晚于末条样本时,方法返回 undefined,不做危险外推。旧逻辑会拿最后姿态硬凑,最终留下 17 帧“看似有姿态”的坏输入;新逻辑先短暂等待末端样本,超时才丢帧并记录原因。固定回放中,缓冲深度足够后无匹配帧降为 0,但正式采集仍要允许丢弃,不能为了数字归零无限等待。

四、同步包要带会话代数,防止暂停后的旧回调混进来

当前要解决的问题,是用户快速暂停、恢复时,上一会话的相机回调可能晚到。如果只看 taskId,这些帧会混入新缓冲区。协调器为每次采集维护 generation,帧、IMU 样本和偏移估计必须属于同一代。

export interface SyncedFrame {
  frameId: number
  frameTsNs: number
  pose: Quaternion
  generation: number
}

export class FrameSyncCoordinator {
  private generation: number = 0
  private offsetNs: number = 0
  private active: boolean = false

  start(offsetNs: number): number {
    this.generation += 1
    this.offsetNs = offsetNs
    this.active = true
    return this.generation
  }

  sync(frameId: number, frameTsNs: number, generation: number,
    buffer: ImuRingBuffer): SyncedFrame | undefined {
    if (!this.active || generation !== this.generation) return undefined
    const imuDomainTs = frameTsNs - this.offsetNs
    const pose = buffer.interpolate(imuDomainTs)
    return pose ? { frameId, frameTsNs, pose, generation } : undefined
  }

  stop(): void {
    this.active = false
    this.generation += 1
  }
}

start() 在偏移估计通过后调用,状态从 CALIBRATING 进入 ALIGNING。sync() 只返回完整同步包,不把半成品交给重建管线;stop() 先让旧代失效,再取消相机与 Sensor Service Kit 订阅,最后清空环形缓存。顺序不能反过来,否则取消订阅期间到达的回调仍可能访问已释放状态。

Demo 把四元数当成由传感器融合层产生的姿态,未在 ArkTS 主线程里重新积分原始角速度。正式产品要明确坐标系、轴方向和设备姿态变换,时间对齐正确并不代表坐标系自动正确。如果相机使用滚动快门,还应考虑单帧内部不同行的曝光时刻;本文只校正帧级时间,不声称消除了滚动快门畸变。

五、用残差看同步,而不是盯着“当前偏移 3 ms”

校准面板最初只显示 offset,很容易让人误判:偏移小就一定同步好。后来我增加了三类残差。第一类是每帧与包围样本的时间距离;第二类是相邻帧姿态角速度与图像光流方向是否一致;第三类是重建预览中静态直线的双边缘比例。只有三者一起下降,才能说明修复真正进入重建结果。

固定回放的 HiLog 最终保持四行:

task=sync_20261001_21 frames=240 imuSamples=1920
clockOffset=38ms->3ms unmatched=17->0
ghostingScore=0.42->0.11 interpP95=0.8ms
state=SYNC_STABLE

DevEco Studio 图里,左侧目录把 clock、buffer、sync 和 diagnostics 分开;中间打开 ImuRingBuffer.ets;右侧模拟器显示同一任务的同步状态;底部日志与正文完全一致。红色细圈只标出 Quaternion.slerp(),因为这正是旧实现“取最近样本”被替换的位置。

六、最终运行页只展示能解释重影变化的数据

手机页没有堆点云参数。顶部是 Pose Clock Lab,核心区域画出相机帧和 IMU 样本的两条时间轴,红色箭头标注“38 ms → 3 ms”;下面展示 240 帧、1920 样本、未匹配 17→0、重影评分 0.42→0.11 和 P95 0.8 ms。状态栏时间为 21:34,5G、Wi-Fi、信号和 79% 电量完整保留。

SYNC_STABLE 不是“重建完成”,只表示当前同步窗口的偏移与残差通过门限,可以把同步包交给后续管线。若温度变化、前后台切换或传感器服务重启,状态会退回 CALIBRATING。我特意保留这条回退路径,因为工程上最危险的并非暂时不能重建,而是时间基准已经失效,页面却仍显示稳定。

压力测试还覆盖了三种边界:注入一条倒序 IMU 样本,缓冲区拒绝并计数;连续丢两帧,相邻样本仍按时间包围而非按数量分组;在 ALIGNING 阶段暂停,旧 generation 的回调全部丢弃。资源侧检查订阅计数,开始时相机和陀螺仪各 1 个,停止后必须归零,重复进入页面不能累加。

为了避免“校准阶段看起来稳定,正式采集又漂了”,我把整段录制切成若干十秒窗口。每个窗口都重新计算 offset 中位数、绝对中位差以及匹配失败率,但不会立刻替换当前参数。只有新窗口连续两次通过门限,协调器才在帧边界切换到新 offset;如果差异超过 10 ms,则暂停输出并退回 CALIBRATING。这样一次偶发慢回调不会让姿态突然跳动,真正的时钟变化也不会被旧参数长期掩盖。

切换参数时同样使用 generation。旧 offset 下已经生成的同步包继续属于旧代,重建队列要么完整消费这一代,要么整批丢弃,不能把两种时间基准混在同一关键帧窗口里。Demo 的队列上限为 32 个同步包,达到上限后优先丢弃最旧的未消费包,并记录 SYNC_BACKPRESSURE。如果主线程持续忙碌,系统宁可少收几帧,也不能无限堆积 PixelMap、姿态和诊断数据。

诊断数据的保存也做了分级。普通运行只保留窗口摘要:偏移、中位差、失败率与 generation;用户显式开启开发模式后,才保存经过抽样的时间戳对,不保存原始图像。任务结束后关闭文件句柄并写入校验尾记录,崩溃恢复时只读取完整窗口。这样既能复盘 38 ms 是如何出现的,又不会让调试日志变成另一套无上限的采集系统。

我还用静止设备做了一组反证。设备完全不动时,即便故意注入 38 ms 偏移,预览重影也不一定明显,因为相邻姿态差接近零;这说明“画面看起来正常”不能作为同步正确的证据。相反,固定角速度转动时,同样偏移会稳定映射成角度残差,更适合作为回归用例。测试夹具因此包含静止、匀速转动和突然停下三段,分别验证零漂、线性插值和边界响应。

在正式接入 Spatial Reconstruction 前,我只提交不可变 SyncedFrame,后续阶段不能回写 pose 或 timestamp。若算法需要修正,必须生成新的派生包并保留来源 generation。这个限制略显啰嗦,却避免调优代码在后台悄悄修改已经进入队列的输入,导致同一个 taskId 每次回放得出不同结果。

七、这次留下的是时间契约

这次复盘让我重新确认:3DGS 输入不只是图像、内参和位姿三个文件,时间也是数据契约的一部分。相机帧和 IMU 样本各自合法,并不代表组合后仍然合法。只有明确时间域、偏移、插值区间、会话代数和失败策略,姿态才真正属于那一帧。

修复后的数字并不夸张:3 ms 仍不是零,0.11 的重影评分也不代表所有场景都完美。但它们能被固定回放复现,能从日志一路对应到运行页,也能在生命周期变化后主动失效。比起把每个坏帧都归咎于纹理或曝光,这套时间契约更接近问题真正发生的位置。

后续如果接入新的相机模式或更高频率传感器,我会先更新回放基线和时间域说明,再调整缓存容量,而不是沿用旧比例。帧率、采样率只是容量参数,时间戳与 generation 才是同步关系的依据。只要这条边界不变,管线扩展时就不必重新靠肉眼猜测重影来自哪里。

参考资料:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐