HarmonyOS 7 Camera Kit + Preferences:3DGS 采集会话的内参漂移拦截、断点快照与重启续拍【鸿蒙心迹】
一、那组“能训练、但像果冻”的素材
这次问题不是相机打不开,也不是视频丢帧。GaussianCaptureGuard 的一组室内环拍素材顺利收满 240 帧,端侧预检也没有发现曝光突变,送进 3DGS 管线后却出现了很典型的漂浮边缘:桌腿在两个观察角度之间摆动,纹理还能对上,几何却始终收不紧。
把采集日志按时间展开后,异常发生在第 168 帧。拍摄者为了看清屏幕角落,顺手把倍率从 1.00 调到了 1.08,又切出应用回复了一条消息。回到页面时预览仍在,帧编号也继续增长,于是旧版本把这段素材当成同一条连续轨迹。对普通录像来说,8% 的倍率变化不算事故;对依赖稳定内参的多视图重建来说,它等价于中途换了一台相机。
我最后没有去补一条“拍摄时不要缩放”的提示,而是把采集会话做成可验证、可暂停、可恢复的状态机:每帧进入队列前计算内参指纹,超过阈值就先写安全快照,再停止接收;进程重启后必须核对相机、分辨率、方向和基准倍率,全部一致才允许从 168/240 继续。本文的演示任务固定为 GS-1008,页面是 CaptureSessionPage,快照文件是 gs_1008_checkpoint.json。

二、内参指纹不是一个“焦距数字”
移动端很难把光学内参当成永远不变的常量。即使页面上没有显式切镜头,倍率、输出尺寸、传感器方向以及重新创建会话后的设备选择,都可能改变输入模型。因此 Demo 没有伪造一个专业标定矩阵,而是定义了工程上可比较的 IntrinsicFingerprint:相机 ID、输出宽高、传感器方向、基准倍率、当前倍率和配置版本。
这里的关键是区分“允许的抖动”和“改变了几何模型”。倍率浮点值会有微小噪声,直接用 === 会频繁误报,所以 GS-1008 使用相对漂移阈值 0.05。当前倍率 1.08 相对基准 1.00 的漂移为 0.08,状态从 CAPTURING 切到 PAUSING;写入成功后才进入 PAUSED_SAFE。如果保存失败,则进入 PAUSE_FAILED,不允许继续收帧,因为此时“多拍几帧”只会让恢复边界更模糊。
下面的第一段代码解决的是采集会话启动时如何建立不可偷换的基线。相机查询和会话创建仍由 Camera Kit 负责,控制器只接收已经确认的参数,并生成稳定字符串用于后续比对。
interface IntrinsicFingerprint {
cameraId: string
width: number
height: number
sensorOrientation: number
baseZoom: number
currentZoom: number
revision: number
}
class IntrinsicGuard {
private readonly driftLimit: number = 0.05
private baseline?: IntrinsicFingerprint
bindBaseline(value: IntrinsicFingerprint): string {
this.baseline = { ...value }
return `${value.cameraId}|${value.width}x${value.height}|` +
`${value.sensorOrientation}|${value.baseZoom.toFixed(2)}|r${value.revision}`
}
check(current: IntrinsicFingerprint): number {
if (!this.baseline || current.cameraId !== this.baseline.cameraId ||
current.width !== this.baseline.width || current.height !== this.baseline.height ||
current.sensorOrientation !== this.baseline.sensorOrientation) {
return 1.0
}
return Math.abs(current.currentZoom - this.baseline.baseZoom) /
Math.max(this.baseline.baseZoom, 0.01)
}
shouldPause(current: IntrinsicFingerprint): boolean {
return this.check(current) > this.driftLimit
}
}
这段代码故意不在 check() 里直接暂停相机。检测属于纯计算,暂停、落盘和 UI 提示属于有时序的副作用;揉成一个方法后,多帧回调很容易重复关闭 VideoOutput。revision=4 也不是系统版本,它是应用自己的指纹结构版本。以后加入裁剪区或镜头角色时,旧快照应明确拒绝,而不是用缺失字段猜一个默认值。
三、先冻结帧入口,再写快照
最初的实现先调用 Preferences 保存,再把 acceptingFrame 设为 false。实机上写入只用了十几毫秒,却足够多进来两帧,结果日志说快照停在 168,文件队列已经推进到 170。恢复后重复编号与真实文件不再一一对应。
修正后的顺序是:抢占一次性暂停闩锁、关闭帧入口、等待当前写文件任务结束、写临时快照、刷新持久化、最后更新页面状态。pauseEpoch 用来挡住已经排队的旧回调;生命周期 onPageHide、用户点击暂停和倍率漂移都走同一个入口,第二次调用只复用正在执行的 Promise,不会再写一份互相覆盖的快照。
下面代码展示 GS-1008 在第 168 帧触发安全暂停的核心路径。示例用 Preferences 保存小型恢复记录,原始帧仍放在应用文件目录,Preferences 里只保存文件名和一致性字段。
type CaptureState = 'IDLE' | 'CAPTURING' | 'PAUSING' |
'PAUSED_SAFE' | 'PAUSE_FAILED' | 'RECOVERING' | 'CAPTURE_RESUMED'
interface CaptureCheckpoint {
taskId: string
frameDone: number
frameTarget: number
fingerprint: string
pauseEpoch: number
snapshotFile: string
}
class CaptureCoordinator {
state: CaptureState = 'CAPTURING'
acceptingFrame: boolean = true
private pauseEpoch: number = 0
private pendingPause?: Promise<void>
pauseSafely(store: Preferences, fingerprint: string,
frameDone: number): Promise<void> {
if (this.pendingPause) return this.pendingPause
this.pendingPause = (async () => {
this.state = 'PAUSING'
this.acceptingFrame = false
const epoch = ++this.pauseEpoch
await FrameWriter.instance.drain(epoch)
const checkpoint: CaptureCheckpoint = {
taskId: 'GS-1008', frameDone, frameTarget: 240,
fingerprint, pauseEpoch: epoch,
snapshotFile: 'gs_1008_checkpoint.json'
}
await store.put('capture_checkpoint', JSON.stringify(checkpoint))
await store.flush()
this.state = 'PAUSED_SAFE'
})().catch((error: Error) => {
this.state = 'PAUSE_FAILED'
throw error
})
return this.pendingPause
}
}
资源释放的边界也要讲清楚:安全暂停只停止帧进入和写入,不立即销毁整个相机会话,这样前台短暂遮挡后可以快速核对并恢复;页面真正销毁时,才按 VideoOutput.stop()、输出释放、输入关闭、会话释放的顺序清理。若把 onPageHide 当成销毁信号,用户只拉一下控制中心就会失去恢复条件。
四、恢复不是“读到 JSON 就继续”
快照存在只能说明上次保存过,不能说明当前环境仍可续拍。恢复阶段重新取得相机配置,生成 currentFingerprint,再与快照中的基线逐项核对。相机 ID、分辨率或传感器方向不一致时直接标记 INTRINSIC_MISMATCH;只有倍率漂移回到阈值内、最后一帧文件可读、序号连续,状态才从 RECOVERING 进入 CAPTURE_RESUMED。
第三段代码把校验顺序写得比较保守。先验证任务与版本,再验证磁盘事实,最后才更新 UI。这样即使应用在恢复过程中再次被杀,Preferences 中仍保留上一份有效快照,不会出现“页面显示已恢复,实际上最后一帧不存在”的假成功。
async function restoreCapture(store: Preferences,
guard: IntrinsicGuard, current: IntrinsicFingerprint): Promise<CaptureCheckpoint> {
const raw = await store.get('capture_checkpoint', '') as string
if (raw.length === 0) throw new Error('CHECKPOINT_MISSING')
const checkpoint = JSON.parse(raw) as CaptureCheckpoint
if (checkpoint.taskId !== 'GS-1008' || current.revision !== 4) {
throw new Error('CHECKPOINT_VERSION_MISMATCH')
}
if (guard.shouldPause(current)) throw new Error('INTRINSIC_MISMATCH')
const complete = await FrameWriter.instance.verifyTail(
checkpoint.frameDone, checkpoint.pauseEpoch)
if (!complete) throw new Error('FRAME_TAIL_INCOMPLETE')
AppStorage.setOrCreate('captureState', 'CAPTURE_RESUMED')
AppStorage.setOrCreate('frameDone', checkpoint.frameDone)
hilog.info(0x1200, 'CaptureGuard',
`task=${checkpoint.taskId} restored=${checkpoint.frameDone}/${checkpoint.frameTarget}`)
return checkpoint
}
JSON.parse 在生产项目里还应加结构校验,本文为了让关键时序更清楚省略了验证器。更重要的是,恢复成功后不要立即删除快照;应在第 169 帧完成原子落盘后再更新它。否则刚恢复就二次崩溃,会退回“无快照但文件已存在”的尴尬状态。
五、把日志当成恢复协议的一部分
Demo 工程目录把页面、相机适配、指纹守卫、帧写入器和快照仓库分开。CaptureSessionPage.ets 只订阅状态;IntrinsicGuard.ets 没有 UI 依赖;CaptureCheckpointStore.ets 负责 Preferences;FrameWriter.ets 只回答 drain 与尾帧验证。这样的拆分不是为了层数好看,而是便于故障注入:可以单独模拟倍率跳变、Preferences 刷新失败以及尾帧损坏。
本次验证固定输出三行 HiLog:task=GS-1008 frame=168/240 drift=0.080 limit=0.050、snapshot=gs_1008_checkpoint.json state=PAUSED_SAFE、restored=168/240 elapsed=96ms state=CAPTURE_RESUMED。页面里的数字全部来自同一个 CaptureViewState,图片和日志不会各自维护一份演示常量。

六、最终结果与故障注入
我做了四组回归。倍率从 1.00 抖到 1.02 时不暂停;到 1.08 时在第 168 帧冻结,快照写成 PAUSED_SAFE;后台杀进程后重新进入页面,96ms 内完成尾帧与指纹核对;把相机 ID 人为改掉时,恢复按钮保持禁用并显示 INTRINSIC_MISMATCH。最终演示页显示 CAPTURE_RESUMED、168/240、基准倍率 1.00、当前倍率 1.00、指纹版本 4,没有把异常的 1.08 当作新基线。

七、这套方案解决什么,不解决什么
它解决的是采集应用自己的会话一致性:参数漂移能被阻断,安全边界能被持久化,生命周期中断后能以确定条件续拍。它不能替代相机标定,也不能从有损视频中恢复真实焦距;多摄切换、电子防抖裁剪、滚动快门和温漂仍需要更专业的模型处理。
我现在更愿意把 3DGS 采集看作一段有事务语义的数据生产,而不是一个“录满多少帧”的进度条。帧数只是表面进度,真正决定素材能否复用的是:这一段数据是否共享同一套假设,假设被打破时有没有可靠的停止点,以及下一次启动能否证明自己仍站在那个停止点上。
更多推荐


所有评论(0)