HarmonyOS 7 Spatial Recon Kit 开发实录 02:相机内参与位姿校验驱动的多角度帧采集【鸿蒙心迹】
ReconRoom 的第一条重建链路已经能稳定跑起来。真正连续扫了几轮以后,一个比“接口能不能调用”更实际的问题出现了:帧越多,并不代表结果越稳。
同一间客厅,我有一次采了两百多帧,结果反而比一百多帧的那次更虚。回看采集日志,里面塞了很多连续位姿几乎没变化的图片。表面上帧数在增长,真正能够提供新视角的信息并没有同步增加。
所以这一轮没有继续做结果页,而是先在 PushFrame 前面加一层质量门:尺寸、格式、相机内参、位姿变化、队列长度都先过一遍。当前任务固定为 recon_20261002_02。

一、这次先把“帧数”换成“有效视角”
测试时我把页面上的“已拍摄”拆成四个值:
采集通过:186
过滤:27
关键帧:72
场景覆盖率:63%
其中“覆盖率”和“关键帧”都是 ReconRoom 自己的工程指标,不是 Spatial Recon Kit 的系统返回值。这样设计只是为了把采集质量变成可观察状态。
当前输入图片统一为:
1080 × 1440
RGB
这和当前官方资料给出的输入规范保持一致。相机内参则来自采集链路,而不是写死成“所有设备都一样”。本轮真机记录到的参数是:
fx = 921.4
fy = 919.8
cx = 540.0
cy = 720.0
这些数字只属于当前这次采集,后续换设备、换相机配置,都应该重新读取。
二、先把 Pose 数据模型收紧,别让页面传一堆散值
第一版里,相机位置和平移量是几组散落的 number。真正开始做过滤时,很快就不够用了:一会儿要算位移,一会儿要比较旋转,一会儿还要记录上一帧。
我把它收成了 CameraPose.ets。这一段解决的是“如何比较两帧之间的空间变化”:
export interface Vec3 {
x: number
y: number
z: number
}
export interface Quaternion {
x: number
y: number
z: number
w: number
}
export interface CameraPose {
position: Vec3
rotation: Quaternion
timestamp: number
}
export class PoseMath {
static translationDelta(a: CameraPose, b: CameraPose): number {
const dx = a.position.x - b.position.x
const dy = a.position.y - b.position.y
const dz = a.position.z - b.position.z
return Math.sqrt(dx * dx + dy * dy + dz * dz)
}
static rotationDeltaDeg(a: CameraPose, b: CameraPose): number {
const qa = a.rotation
const qb = b.rotation
const dot = Math.abs(
qa.x * qb.x +
qa.y * qb.y +
qa.z * qb.z +
qa.w * qb.w
)
const safeDot = Math.min(1, Math.max(-1, dot))
return 2 * Math.acos(safeDot) * 180 / Math.PI
}
}
这里用四元数点积算两次姿态的夹角。对当前需求来说,重点不是“把数学公式写得多复杂”,而是保证比较的是 两次已归一化的相机姿态。
如果上游拿到的四元数没有归一化,这里的角度会直接失真。所以我把归一化放在相机数据进入 CameraPose 之前做,Gate 只消费已经清洗过的 Pose。
三、过滤条件不要写成“变化越大越好”
刚开始我只加了一个平移阈值:移动不到 8 cm 就丢弃。实际扫房间时很快暴露问题——站在原地转身几乎没有平移,但视角变化其实很大,这种帧反而是有价值的。
所以现在规则改成:
平移 < 0.08 m
并且
旋转 < 6°
→ 判定为相似帧
只有“平移和旋转都很小”才过滤。只要其中一个变化明显,就允许进入候选队列。
这一段解决的是“相似帧大量重复进入队列”的问题:
export interface ReconFrameMeta {
width: number
height: number
format: 'RGB'
pose: CameraPose
fx: number
fy: number
cx: number
cy: number
}
export class FrameQualityGate {
private readonly width: number = 1080
private readonly height: number = 1440
private readonly transThreshold: number = 0.08
private readonly rotThreshold: number = 6.0
private lastAcceptedPose: CameraPose | null = null
validate(frame: ReconFrameMeta): boolean {
if (frame.width !== this.width || frame.height !== this.height) {
return false
}
if (frame.format !== 'RGB') {
return false
}
if (this.lastAcceptedPose === null) {
this.lastAcceptedPose = frame.pose
return true
}
const trans = PoseMath.translationDelta(
frame.pose,
this.lastAcceptedPose
)
const rotation = PoseMath.rotationDeltaDeg(
frame.pose,
this.lastAcceptedPose
)
if (trans < this.transThreshold && rotation < this.rotThreshold) {
return false
}
this.lastAcceptedPose = frame.pose
return true
}
}
有个细节我专门改过:lastAcceptedPose 只在“接受帧”时更新。
如果每次收到相机帧都更新它,那么用户缓慢移动时,每一帧和上一帧差值都很小,可能连续几十张都被过滤;而和最后一张真正进入 Session 的帧相比,累计变化其实已经足够大。
Gate 应该比较的是“当前帧”和“上一张有效帧”,不是“上一张摄像头回调帧”。
四、内参先校验再入队,别等 Native 报错才回头找
本轮页面里展示了 fx / fy / cx / cy,不是为了做参数展示,而是为了让采集链路有证据可查。
当前参数:
fx=921.4
fy=919.8
cx=540.0
cy=720.0
进入队列前我会先做三类检查:
fx / fy必须是有效正值;cx / cy必须落在当前图像范围内;- 当前帧使用的内参与图像尺寸必须来自同一套相机配置。
这一层非常重要。因为图片本身看起来“能显示”,并不代表它的几何参数一定能用于重建。特别是采集链路中做过裁剪、旋转或缩放以后,如果图像尺寸变了而内参没同步换算,问题往往不会在 UI 上直接暴露。
我宁愿让 Gate 提前把异常帧拦掉,也不希望到 Native 层才收到一个模糊的失败结果。
1. 图像旋转和裁剪,是内参最容易被忽略的地方
相机采集到的原始帧如果后面又做了旋转、缩放或裁剪,fx / fy / cx / cy 不能继续原样沿用。第一次做这块时,我只确认了最终图片还是 1080 × 1440,就以为输入没有变化。
后来才意识到,尺寸一样不代表坐标系一样。比如原图先旋转再裁剪回相同尺寸,主点位置已经可能发生变化。如果仍把旧的 cx / cy 送进去,肉眼看图片没有任何异常,几何关系却已经错了。
所以现在采集链路里额外记录一个 frameTransformVersion。只要图像经历任何几何变换,就必须同时走内参转换;没有转换成功的帧直接被 Gate 拒绝。这样能避免“图片看起来正常,模型却整体漂”的隐蔽问题。
当前 ReconRoom 还没有开放用户自由缩放预览图,就是为了先保证采集 Buffer 和重建 Buffer 是同一条路径。等后面真正需要裁剪时,再把内参变换单独抽成模块,不在页面层临时补公式。
2. Gate 的目标不是把帧丢得越多越好
27 张被过滤的帧只是一次结果,不是优化指标。假如把阈值从 0.08 m / 6° 提高一倍,过滤数量肯定会增加,但场景边缘和转角可能因此缺少有效视角。
这也是为什么我没有写“过滤率越高,重建质量越好”。更合理的观察方式是把过滤原因拆开:
INVALID_SIZE
INVALID_INTRINSICS
SIMILAR_POSE
QUEUE_OVERFLOW
同样是“被丢弃”,含义完全不同。尺寸或内参错误属于输入异常,应该尽量降到零;相似位姿属于主动筛选;队列溢出则说明消费速度跟不上,是性能问题。
只有把原因分开,测试时看到 rejected 从 27 变成 60,才知道到底是采集策略变好了,还是系统已经开始忙不过来。
五、队列要有上限,而且 PushFrame 要串行
过滤完以后,我又碰到一个新问题:相机回调速度比 Native 消费速度快。
如果每次通过 Gate 就立即并发 pushFrame(),短时间里会出现大量 Promise 堆积。更麻烦的是,重建 Session 本来就不是用来做“多任务并发压测”的。
所以我加了 FrameQueueManager.ets,当前上限固定为 12。这个 12 不是系统规格,只是 ReconRoom 当前版本的工程值。
这一段解决的是“相机回调持续积压导致内存和 Session 压力增加”的问题:
export interface ReconFramePacket {
meta: ReconFrameMeta
buffer: ArrayBuffer
}
export class FrameQueueManager {
private readonly maxQueueSize: number = 12
private queue: ReconFramePacket[] = []
private draining: boolean = false
async enqueue(packet: ReconFramePacket): Promise<boolean> {
if (this.queue.length >= this.maxQueueSize) {
return false
}
this.queue.push(packet)
if (!this.draining) {
await this.drain()
}
return true
}
private async drain(): Promise<void> {
this.draining = true
try {
while (this.queue.length > 0) {
const packet = this.queue.shift()
if (packet === undefined) {
continue
}
await reconNative.pushFrame(packet)
}
} finally {
this.draining = false
}
}
clear(): void {
this.queue = []
}
}
这里的关键不是数组,而是 draining。
只允许一个 drain 循环工作,页面哪怕连续收到很多帧,也只会排队,不会同时压进 Native。后面如果要做背压策略,可以把“队列满了直接丢弃”改成优先保留位姿变化更大的帧,现在先把并发收住。
1. 队列真正占内存的不是对象,而是像素 Buffer
ReconFramePacket 本身很小,真正大的部分是 ArrayBuffer。1080 × 1440 的 RGB 数据即使不考虑额外对齐,一帧原始像素就已经是数 MB 级别。队列上限如果随手写成 50,短时间内就可能把几十帧原始数据同时留在内存里。
所以 maxQueueSize=12 的意义不是“12 是最优数字”,而是明确告诉系统:我们接受有限排队,不接受无限堆积。
当前策略是队列满后拒绝最新帧,同时计入 QUEUE_OVERFLOW。后续更成熟的做法可能是比较新旧帧位姿,淘汰信息量更低的一张。但那会让队列本身承担调度算法,第二篇先不把复杂度提得太高。
Buffer 释放也必须和 Native 调用边界绑定。ArkTS 侧不能看到 pushFrame() Promise resolve 就武断认为底层已经永久持有数据;具体拥有权要按照当前 SDK 和桥接实现确认。ReconRoom 目前由 Native Adapter 明确复制或接管需要的数据,完成后再通知队列释放临时 Buffer。
如果这里没有统一约定,最危险的不是内存涨,而是“偶现”的野指针或图像内容被覆盖。这类问题往往只在高频采集时出现,很难靠普通 UI 测试复现。
六、Native 适配层只做一件事:把已校验的数据送进 Session
ArkTS Gate 已经保证尺寸、内参和位姿满足当前项目规则以后,Native 层不再重新做一套业务判断,只负责数据结构转换和 HMS_SpatialRecon_PushFrame。
这一段解决的是“ArkTS 与 C++ 两边各写一套过滤逻辑,最后规则不一致”的问题:
HMS_SpatialReconStatus PushValidatedFrame(
const ReconFramePacket& packet)
{
HMS_SpatialRecon_Session* session = recon::GetSession();
if (session == nullptr) {
return SPATIAL_RECON_STATUS_INVALID_PARAM;
}
HMS_SpatialRecon_DataFrame frame {};
if (!BuildDataFrame(packet, frame)) {
return SPATIAL_RECON_STATUS_INVALID_PARAM;
}
return HMS_SpatialRecon_PushFrame(session, &frame);
}
BuildDataFrame() 负责把 ReconRoom 自己的数据对象转换成当前 SDK 头文件要求的 HMS_SpatialRecon_DataFrame。我没有在 ArkTS 侧复制 HMS 的原始结构体,这样以后 SDK 字段变化时,只需要改 Native Adapter。
实际项目里还需要处理像素 Buffer 的拥有权和释放时机。当前规则是:PushValidatedFrame 返回后,才允许 FrameQueue 释放当前 packet 对应的临时缓存,避免异步过程中提前回收。
七、这次 DevEco 里重点看的是“为什么丢帧”
第二篇的工程目录比第一篇多了三个模块:
model/CameraPose.ets
manager/FrameQualityGate.ets
manager/FrameQueueManager.ets
也就是说,项目是在原来的 Session 管理上继续长,而不是重新建了一个 Demo。

这一轮日志里我专门保留了一个被过滤的例子:
pose_delta_t=0.031m
pose_delta_r=2.4deg
它同时小于 0.08 m 和 6°,所以被 Gate 判定为相似帧。紧接着新的有效帧进入队列:
accepted=186
rejected=27
keyFrames=72
coverage=63%
queueSize=12
这里 queueSize=12 表示当前队列上限配置,不代表当时一定堆满 12 帧。日志打印配置值,是为了排查测试版本有没有被改动。
DevEco 右侧模拟器仍然只负责 UI 和状态复现。真正的 PushFrame、相机位姿和 Native Session 仍然要在支持 Spatial Recon Kit 的真机上验证。
1. 我把这一轮验收拆成“静态参数”和“动态路径”两组
静态参数先看一眼就能发现的问题:尺寸是否固定为当前要求、RGB 格式是否正确、内参是否有效、任务 ID 是否和目录对应、Gate 阈值是否是当前版本的配置。
动态路径则必须真的走动起来测。测试时我专门做了四种动作:
- 原地不动连续采集,确认相似帧会被持续过滤;
- 原地缓慢旋转,确认平移很小但旋转超过 6° 时仍能进入候选;
- 直线平移相机,确认位移超过 0.08 m 时无需依赖旋转也可通过;
- 快速移动并连续触发回调,观察 queue overflow 和 Buffer 释放是否正常。
这几种动作比单纯“绕房间走一圈”更容易把 Gate 逻辑测清楚。最终页面显示 186 张通过、27 张过滤、72 张关键帧,至少能解释每一类数字是怎么来的。
2. 阈值要进入版本配置,而不是散落在代码里
0.08 m 和 6° 是这一轮在客厅场景里调出来的工程参数,不应该被写成永远正确的常量。
空间更小、物体更密集时,8 cm 可能已经跨过不少细节;空间很大时,这个阈值又可能过于敏感。旋转阈值也一样,近距离扫桌面和远距离扫房间,对视角变化的需求并不相同。
所以我给 Gate 增加了配置版本概念:
gateProfile=room_v1
translation=0.08m
rotation=6deg
queue=12
每次任务开始把这组参数写进 task metadata,HiLog 也打印 profile。以后如果调成 room_v2,出现重建质量变化时,可以直接知道两次任务是不是用了不同规则。
这种记录看起来琐碎,但做性能和质量回归时很省事。否则一周以后只看到“这次过滤了 27 张,上次过滤了 14 张”,很难判断是用户路径不同,还是代码阈值已经被某次提交改过。
3. “关键帧 72”也不能直接等同于送进系统的最终训练帧
ReconRoom 的 keyFrames=72 是应用层筛选后的一个观测值,用来表示覆盖不同位置和方向的代表帧。它并不等于 Spatial Recon Kit 内部最终采用了 72 张。
底层重建能力仍然可能继续做自己的数据处理和筛选。应用层 Gate 的目标是减少明显低价值输入、控制队列和资源占用,而不是试图替代系统内部的重建策略。
这个边界我专门保留在文章里,是因为很容易出现一个误区:应用自己做了“关键帧”就认为越精确越好,最后把前置算法做得越来越复杂。当前版本更克制,只做确定性较强的检查,把真正的重建选择交给系统能力。
我还把 coverage=63% 明确标成 Demo 指标。当前算法只是根据相机轨迹和预设空间分区估算覆盖度,用于提醒用户“还有哪些方向没扫到”,不能把它写成 Spatial Recon Kit 官方提供的重建质量分数。
八、帧少了一点,采集反而更可控
最终手机页的状态如下:

这次几组数据统一为:
taskId=recon_20261002_02
state=CAPTURING
coverage=63%
accepted=186
rejected=27
keyFrames=72
image=1080×1440 RGB
fx=921.4
fy=919.8
cx=540.0
cy=720.0
translationThreshold=0.08m
rotationThreshold=6°
实际用下来,我更在意的不是“过滤掉了 27 帧”这个数字,而是采集路径开始可解释了。
以前模型不好,只能猜是不是“拍得不够多”;现在可以继续往下看:有效视角是否覆盖不足、某一段位姿是否变化太小、队列是否出现积压、相机内参是否发生切换。
这就是第二阶段最大的变化:把一个模糊的“扫描质量不好”,拆成了几组可以记录、复现和调整的工程参数。
下一阶段我会继续沿用 recon_20261002_02 这一套 Manager 结构,把 进度、暂停/恢复、前后台切换和热状态 收进统一任务状态机。到那一步,ReconRoom 才算从“能采集”进入“能长时间稳定运行”。
参考资料
- HarmonyOS 7 空间计算能力:https://developer.huawei.com/consumer/cn/features/spatialization
- Spatial Recon Kit 3DGS 端侧重建介绍:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/spatial-recon-introduction
- 3DGS 空间重建 Pipeline:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/spatial-recon-c-spatial-recon-pipeline
更多推荐




所有评论(0)