一、模型长出双边,不一定是画面模糊
PoseClock 的问题最初很像普通抖动:围着桌上的摆件走一圈,预览清楚、曝光正常,重建结果却在轮廓外多出一层半透明边。任务GSYNC-1618在 16:18 复现,240 张采集帧中没有一张解码失败。我们先查了运动速度、焦距和图像锐度,最后从日志里看到真正的异常:图像时间戳与送入它的位姿时间相差 18.6 ms。
Spatial Recon Kit 接收的不只是图像,还包括与图像对应的相机内参和位姿。图像本身再清楚,若位姿来自更早或更晚的设备姿态,重建器看到的就是“这张照片拍于 A 点,却被标记成 B 点”。运动越快,误差越像重影。旧实现把“最近一次位姿”直接塞给下一张帧,没有比较时间基准,也没有在相机会话重启后隔离旧缓存。
这一轮我把 Demo 改成显式的配对器:相机帧通过OH_ImageNative_GetTimestamp()取得纳秒时间,位姿回调进入有序环形缓冲区;每张图只选择时间差绝对值最小的位姿,超过 8.0 ms 就拒绝,不再把坏数据推给重建会话。最终结果是 240 帧输入、226 帧接收、14 帧拒绝,p95 偏差 5.8 ms,会话代次为 3,页面状态SYNC_STABLE。
二、先确认能比较,再谈“最近”
OH_ImageNative_GetTimestamp()返回纳秒时间,通常单调递增,但官方文档也提醒:时间戳的含义和基准取决于生产者,不同生产者的值未必可直接比较。这句话决定了工程边界。PoseClock 的图像与位姿都来自同一次采集管线,并在创建会话时记录同一单调时钟锚点;如果位姿来自网络设备、文件 EXIF 或另一套传感器服务,不能直接相减,必须先做时钟域转换或同步标定。
第一个问题是位姿流比图像流密。任务里每帧图像周围通常有两到三条位姿样本。下面的 C++ 缓冲区只保留最近 64 条,并保证代次一致。PushPoseSample拒绝时间倒退,避免驱动重启或回调乱序污染二分查找。
structPoseSample{int64_ttimestampNs;uint32_tepoch;std::array<float,16>worldFromCamera;};classPoseRing{public:voidPushPoseSample(constPoseSample&sample){std::lock_guard<std::mutex>lock(mutex_);if(sample.epoch!=epoch_)return;if(!samples_.empty()&&sample.timestampNs<=samples_.back().timestampNs){outOfOrder_++;return;}samples_.push_back(sample);while(samples_.size()>64)samples_.pop_front();}voidReset(uint32_tepoch){std::lock_guard<std::mutex>lock(mutex_);samples_.clear();epoch_=epoch;outOfOrder_=0;}private:std::mutex mutex_;std::deque<PoseSample>samples_;uint32_tepoch_=0;uint32_toutOfOrder_=0;};这里不对乱序样本排序。排序看似更宽容,却会掩盖数据源的生命周期错误,并让已经完成的配对结果被后来样本改变。采集管线要求位姿按单调时间到达;违反合同时记录并丢弃,问题会更早暴露。64 条只是本 Demo 在当前采样率下的容量,不是通用数字。实际产品应按最大回调抖动窗口换算,并设置内存上限。
Reset()必须与相机会话代次一起调用。只清空 UI 计数而保留位姿队列,会让新会话的第一张帧匹配到旧会话末尾。时间值即便更接近,也不代表它们属于同一个相机内参与坐标系。
三、配对门禁要在构造重建帧之前
旧代码先复制图像,再寻找位姿;找不到时才释放临时内存。高速采集下,这会为最终被拒绝的帧付出一次 1080×1440 拷贝。新的顺序是先读时间戳、匹配位姿、判断偏差,再组装HMS_SpatialRecon_DataFrame。官方管线要求输入图像和对应信息,本 Demo 固定使用 1080×1440 的重建输入,不在拒绝路径分配图像副本。
下面的MatchFrame()在相邻样本中选绝对偏差最小者,并把阈值写成纳秒常量。日志和页面仍用毫秒展示,避免内部比较因浮点换算丢精度。
constexprint64_tkMaxSkewNs=8'000'000;// 8.0 msstd::optional<MatchedFrame>MatchFrame(int64_tframeTsNs,uint32_tframeEpoch,conststd::deque<PoseSample>&poses){constPoseSample*best=nullptr;int64_tbestSkew=INT64_MAX;for(constauto&pose:poses){if(pose.epoch!=frameEpoch)continue;int64_tskew=std::llabs(pose.timestampNs-frameTsNs);if(skew<bestSkew){bestSkew=skew;best=&pose;}if(pose.timestampNs>frameTsNs&&skew>bestSkew)break;}if(best==nullptr||bestSkew>kMaxSkewNs)returnstd::nullopt;returnMatchedFrame{frameTsNs,best->timestampNs,bestSkew,frameEpoch,best->worldFromCamera};}FrameDecisionOnImageAvailable(OH_ImageNative*image,uint32_tepoch){int64_tframeTs=0;if(OH_ImageNative_GetTimestamp(image,&frameTs)!=IMAGE_SUCCESS){returnFrameDecision::TIMESTAMP_ERROR;}automatched=MatchFrame(frameTs,epoch,poseRing.Snapshot());if(!matched)returnFrameDecision::SKEW_REJECTED;returnPushMatchedFrame(image,*matched);// 此处才复制并组装重建帧}这里没有做位姿插值。对慢速扫描,最近邻加 8 ms 门禁已经让结果稳定;插值会引入四元数归一化、坐标系约定和异常运动模型,验证成本更高。若产品需要快速移动,应在确认两侧样本属于同一坐标系后使用平移线性插值与旋转球面插值,并继续保留最大时间跨度限制,不能把插值当成无限补洞。
拒绝帧不等于失败会话。它只增加rejected,释放当前OH_ImageNative,不推进重建输入序号。真正异常是连续拒绝超过预算:PoseClock 规定 12 帧滑窗内拒绝 4 帧时进入SYNC_DEGRADED,提示用户放慢移动;若检测到时间倒退或相机会话变化,则直接提升 epoch,进入重启流程。
四、epoch 隔离的是回调,不只是计数器
相机切到后台再回来,旧线程里可能仍有一条图像回调排队。若只清空缓冲区,没有给回调带代次,旧帧仍可能晚到并填进新会话。PoseClock 将captureEpoch同时附在位姿、图像任务和 Native 回调结果上;任何完成时 epoch 不匹配的任务只负责释放自己的资源,不允许更新统计,也不能调用重建会话。
第三段代码是 ArkTS 侧的页面状态桥。C++ 每 20 帧上报一次不可变快照,页面只接受当前 epoch 且序号更大的数据。aboutToDisappear()先关闭页面接收,再通知 Native 停止会话,避免销毁中的页面被迟到回调改写。
interfaceSyncSnapshot{epoch:number;seq:number;input:number;accepted:number;rejected:number;p95SkewMs:number;state:'SYNCING'|'SYNC_DEGRADED'|'SYNC_STABLE';}exportclassSyncProbeStore{current:SyncSnapshot={epoch:0,seq:0,input:0,accepted:0,rejected:0,p95SkewMs:0,state:'SYNCING'};privateaccepting:boolean=true;apply(next:SyncSnapshot):void{if(!this.accepting)return;if(next.epoch<this.current.epoch)return;if(next.epoch===this.current.epoch&&next.seq<=this.current.seq)return;this.current={...next};}close():void{this.accepting=false;NativeRecon.stopSession(this.current.epoch);}}顺序很重要。先停止 Native、后关闭页面,会留下一个短窗口:Native 的最后快照仍能触发 ArkTS 状态变化。先关闭观察入口,迟到数据就只能在桥接层被丢弃。Native 侧仍必须完成图像释放、队列清空和重建 Session 销毁;ArkTS 的accepting=false不是资源释放,只是防止 UI 被写。
五、目录、日志和状态必须能互相证明
工程只保留这条链路需要的文件:native/PoseRing.cpp维护位姿队列,native/FrameMatcher.cpp执行纳秒配对,native/ReconSession.cpp管理 Spatial Recon 会话与代次,pages/SyncProbePage.ets展示采集状态。调试页面不展示漂亮模型,而是把“帧是否可信”放在第一屏。
任务日志从GSYNC-1618 epoch=3 session started size=1080x1440开始;采集中会看到frame=87 skew=18.6ms rejected threshold=8.0ms;终态必须是input=240 accepted=226 rejected=14 p95=5.8ms与state=SYNC_STABLE lateCallback=0。如果页面显示 226,而 Native 日志只有 225,说明还有快照提交或回调代次问题,不能把模型看起来正常当作通过。
我还做了三个破坏性测试。第一组故意让位姿延迟 20 ms,确认坏帧被拒绝而不是进入重建;第二组在第 120 帧切到后台再回来,确认 epoch 从 2 变为 3,旧回调计为释放但不计入 input;第三组把图像时间戳来源换成录制文件,确认程序拒绝跨生产者直接比较,而不是输出一个看似合理的负偏差。
六、稳定不是零拒绝,而是拒绝发生在正确位置
最终页面时间 16:18,任务GSYNC-1618,状态SYNC_STABLE。输入 240、接收 226、拒绝 14,接受率 94.2%;p95 偏差 5.8 ms,门禁 8.0 ms,epoch 3,迟到回调 0。页面还显示固定输入尺寸 1080×1440,便于确认与重建管线约束一致。
这些数字并不意味着 8 ms 适合所有设备和路径。阈值应通过运动速度、相机帧率和实际重建质量标定;采集静物与快速绕拍可能需要不同预算。也不要为追求 100% 接收率不断放宽阈值,那只会把数据质量问题推到更难排查的模型阶段。
这次修复最重要的变化,是把错误挡在HMS_SpatialRecon_DataFrame组装之前。图像与位姿只有在同一时钟域、同一会话代次、偏差预算内,才成为一帧可以交给 3DGS 重建的数据。否则它仍是一张清楚的图片,却不是一张可信的重建帧。
七、参考资料
- HarmonyOS Spatial Recon Kit:重建三维场景(C/C++)
- OH_ImageNative_GetTimestamp API
- AR Engine:获取设备位姿(C/C++)