
这次不是模型算不出来而是相机给得太快。采集两分钟后预览依然顺滑重建队列却已经积了十几帧等手机发热系统开始限频前面的积压反而把后面的有效视角拖成了“过期数据”。我把问题拆成了一个小工程ReconBudget Lab。本次任务编号是recon_20261001_21相机输入 30 fps重建端的稳定吞吐只有 18 fps。如果每帧都保留延迟会持续上涨如果随机丢帧帧率降下来了视角覆盖却可能断层。16:06 的最终运行状态固定为DEGRADED设备热状态WARM当前质量档BALANCED接收 30 fps、入队 18 fps队列深度4 / 6累计丢弃 96 帧已融合 642 帧高斯点数 142 万。这组数据不追求“全部吃掉”而是让管线在可预期的时延内继续收敛。一、相机帧率不等于重建吞吐Spatial Recon 管线需要连续的图像、位姿与时间戳但“连续”不等于一帧不落。端侧 3DGS 还要做特征提取、可见性判断、高斯参数更新与阶段性缓存某个阶段一旦慢下来上游相机仍会按固定节奏产生数据。最初的实现是在帧回调中直接调用session.submit(frame)。前 20 秒看不出问题队列也只偶尔到 2。当融合阶段的单帧耗时从 32 ms 上涨到 55 ms队列开始单向增长。此时再使用“处理完一帧再取下一帧”只是把堆积从 native 层换到 ArkTS 层。我更关心三个量队列深度、队首帧年龄和新帧的视角价值。队列深度回答“挤了多少”队首帧年龄回答“旧到什么程度”视角价值则决定“新帧值不值得换掉旧帧”。二、入队之前先做采样不在队尾做无限等待当前需要解决的不是“如何把数组 push 进去”而是超出预算时保留哪帧。我使用一个最多 6 帧的有界队列在队列未满时正常接受队列已满时比较新帧与最后入队帧的位移、旋转以及清晰度。只有视角足够新才替换队尾否则当场丢弃。下面这段代码解决的是“队列满了以后不要随机丢掉有价值视角”的问题。exportclassFrameAdmission{privatequeue:ReconFrame[][]privatereadonlycapacity:number6privatelastAccepted?:ReconFrameoffer(frame:ReconFrame,budget:CaptureBudget):AdmissionResult{constinterval1000/budget.targetFpsif(this.lastAcceptedframe.timestampMs-this.lastAccepted.timestampMsinterval){return{accepted:false,reason:FPS_BUDGET}}constnoveltyposeDistance(frame.pose,this.lastAccepted?.pose)if(this.queue.lengththis.capacity){if(noveltybudget.minPoseDelta){return{accepted:false,reason:LOW_NOVELTY}}this.queue[this.queue.length-1]?.release()this.queue.pop()}this.queue.push(frame)this.lastAcceptedframereturn{accepted:true,reason:ADMITTED}}}这里有两个容易忽略的资源问题。第一被拒绝的帧不能只从数组里移除底层图像缓冲必须按封装约定释放第二被替换的队尾帧也要释放否则 UI 上的队列长度是 6实际图像内存却会一直涨。当前 Demo 的ReconFrame.release()是一次性的重复调用只记录警告正式工程应让帧所有权在“相机—队列—重建会话”之间只转移一次。这个策略也没有拿固定的 18 fps 当真理。targetFps是质量预算的输入热状态、单帧耗时与队首年龄都可以让它上下浮动。三、温控降级要改“预算”不要突然停掉会话发热后直接暂停相机是最容易写的方案但用户会立刻丢掉扫描节奏已经稳定的位姿追踪也要重新建立。我把热状态映射成三档预算QUALITY保留 24 fpsBALANCED保留 18 fpsSURVIVAL保留 12 fps并同时调整特征尺寸与阶段性优化频率。下面这段代码解决的是“热状态短时间抖动档位在 18 和 12 fps 之间来回跳”的问题。exportclassBudgetGovernor{privateprofile:BudgetProfileBudgetProfile.QUALITYprivatepending?:BudgetProfileprivatependingSince:number0update(thermal:ThermalLevel,queueDepth:number,now:number):CaptureBudget{constnextthermalThermalLevel.HOT||queueDepth6?BudgetProfile.SURVIVAL:thermalThermalLevel.WARM||queueDepth4?BudgetProfile.BALANCED:BudgetProfile.QUALITYif(next!this.profile){if(this.pending!next){this.pendingnextthis.pendingSincenow}elseif(now-this.pendingSince1500){this.profilenextthis.pendingundefined}}returnbudgetOf(this.profile)}}1.5 秒的稳定窗口是当前 Demo 的工程选择不是系统固定值。它让档位只在趋势确认后切换也让日志能解释QUALITY - BALANCED是由WARM queue4引起而不是偶发的一次耗时峰值。升档和降档不应对称。降档要快以避免队列继续积压恢复到QUALITY要多观察几个窗口否则温度刚降就把负载又拉满。正式项目还应把电量、充电状态和用户选择的质量模式纳入策略不能只看一个热等级。四、消费端只拉取一帧页面只订阅摘要第二个性能陷阱是让 UI 直接订阅每帧的完整调试对象。一帧内包含位姿矩阵、清晰度、特征数量和内部时间点30 fps 刷新到页面反而会让调试面板成为新的卡顿源。下面这段代码解决的是“重建消费和 UI 刷新互相干扰”的问题。消费循环一次只从队首取一帧转移给 native 会话后立即更新计数页面每 250 ms 读取一份不可变快照。exportclassReconCoordinator{privaterunning:booleanfalseprivateaccepted:number0privatedropped:number0asyncdrain():Promisevoid{if(this.running)returnthis.runningtruetry{while(!this.queue.isEmpty()this.lifecycleFOREGROUND){constframethis.queue.shift()if(!frame)breaktry{awaitthis.nativeSession.submit(frame)this.accepted}finally{frame.release()}}}finally{this.runningfalse}}snapshot():ReconSnapshot{returnObject.freeze({taskId:recon_20261001_21,state:this.governor.profileBudgetProfile.QUALITY?RUNNING:DEGRADED,acceptedFps:this.metrics.acceptedFps,queueDepth:this.queue.size,dropped:this.dropped})}}running防止相机回调连续触发多个消费循环。页面退到后台时循环不再拉取新帧队列中尚未消费的图像统一释放重新回到前台后由新的相机时间戳重建采集基线不使用后台前的旧帧。当前 Demo 为了看清背压把丢帧原因分成FPS_BUDGET、LOW_NOVELTY和QUEUE_REPLACE。正式产品日志不需要记录每一帧可以每秒聚合一次否则日志 I/O 会改变原本想测量的管线耗时。1. 从四行日志反推管线卡在哪里第一版日志只打印“已处理帧数”数字一直增长看起来很正常。直到我把采集、入队、融合和释放四个计数拆开才发现“处理进度在走”不等于“新数据没有堆积”。采集每秒增加 30融合只增加 18中间的 12 如果没有受控去向就是未来的内存和延迟。现在每秒只输出一份PipelineTick相机产生多少帧、入队多少帧、主动丢弃多少帧、重建完成多少帧再加上队首年龄和当前预算档。如果采集与入队的差值稳定队列也没有增长说明丢帧是有意图的采样如果入队和融合的差值持续增大才说明消费端跟不上。调试期间还出现过一个反直觉现象队列已经回到 1手机仍然很热。检查后发现重建会话在队列空闲时触发了更频繁的局部优化所以只看队列深度会错把高计算负载判成“已恢复”。我因此把滑动窗口内的单帧融合耗时和优化耗时也纳入升档条件只有两者同时回落才从BALANCED回到QUALITY。2. 异常不能让队列失去所有权native 会话拒绝某一帧时最容易出现的是“谁来释放”不明确。如果submit()失败后底层已经回收图像ArkTS 的finally再释放一次就可能变成重复回收如果双方都认为对方会处理又会变成泄漏。ReconBudget Lab 把规则定死submit()只借用数据帧所有权一直在协调器无论成功还是失败都由同一个finally收口。连续失败三帧后状态从DEGRADED进入PAUSED_ERROR停止新帧源并释放队列不再用“继续丢帧”掩盖真实异常。用户重试时会创建新的采集代号旧回调即使晚到也只会释放自己携带的帧不会改写新会话的队列和指标。DevEco Studio 图中左侧是capture / budget / recon / model四层中间停在BudgetGovernor.ets右侧模拟器展示recon_20261001_21。底部 HiLog 的关键数据是30 - 18 fps、queue4/6、dropped96与QUALITY - BALANCED这几个数放在一起才能判断是主动降级而不是相机无故掉帧。五、看到 DEGRADED不代表这轮重建失败运行页没有把降档包装成一个红色错误。DEGRADED的意思是当前会话仍在处理但已主动收紧质量预算。页面展示输入帧率、接收帧率、队列、丢帧、融合数和高斯点数用户可以继续扫描也可以暂停等待设备降温。16:06 的手机页保持了同一组运行数据WARM / BALANCED / DEGRADED30 fps 输入18 fps 入队队列4 / 6丢弃 96 帧融合 642 帧高斯点数 142 万。红色细箭头只标了两个判断帧率差是背压结果4 / 6是当前仍可恢复的安全水位。验收时我不只看最终模型而是做三组对照。第一组固定 30 fps 且不丢帧队首年龄超过 1.8 秒第二组随机丢帧时延下来了但家具边缘出现空洞第三组使用视角价值与热预算队首年龄稳定在 260 ms 内且扫描路径上的新视角没有明显缺口。六、还有几个边界不能被 Demo 掩盖第一丢帧策略不能只看时间。用户转身很快时低帧率下仍应保留姿态差足够大的帧用户原地停留时即使时间间隔到了低新颖度帧也不一定需要进管线。第二热降级是设备级现象。后台还有导航、录屏或其他 AI 任务时同一套机型上的稳定吞吐也会变。预算不要写成机型表的硬编码。第三切到后台要先停止帧源再清空队列最后挂起重建会话。顺序反了相机回调可能在清理过程又塞进新帧。这类竞态很难在短时 Demo 里出现长时间扫描却很常见。第四质量不能只用高斯点数衡量。点多可能是重复观测堆出来的还要观察视角覆盖、表面空洞、边缘稳定性和最终包体。当前的 142 万是一个运行证据不是通用合格线。第五不同阶段的帧价值不一样。会话刚开始时管线需要快速建立粗略结构视角覆盖比局部细节更重要进入稳定阶段以后清晰度和重投影误差才应获得更高权重。如果从头到尾只用一个minPoseDelta可能前期收得太紧、后期又放得太宽。我在实际产品里会让采样阈值跟随管线阶段而不是跟随一个全局常量。第六调试数据应该能回放。只看手机现场的实时数字很难比较两套预算策略。ReconBudget Lab 会保留每秒的聚合快照包括帧率差、队首年龄、温控档位、丢帧原因分布和阶段耗时。同一段扫描路径分别使用“不丢帧”、“随机丢帧”和“质量预算”运行再把快照与最终模型一起比较。这能防止我们因为某次设备恰好比较凉就误判新策略更好。第七采集提示也是调度的一部分。当队列连续升到 5页面不会只在右上角改一个档位而是提示用户放慢移动尤其不要在短时间内大幅转身。这不是把性能问题甩给用户而是让采集节奏与当前设备预算匹配。提示只在背压持续数秒后出现队列回到 3 以下就自动收起避免短时波动反复打断扫描。七、让管线稳定比让每帧都进去更重要这次修正后我对“丢帧”的看法变了。它不一定是管线失败的证据在有界队列、视角采样和资源释放都可解释时主动丢掉低价值帧反而是保住重建时效性的必要手段。recon_20261001_21最终没有回到QUALITY而是在BALANCED档完成本轮采集。它看起来不如全程 30 fps 漂亮但队列没有失控重建结果没有被两秒前的旧视角拖着跑手机页上的每个数也都能对应一次真实的调度决策。这也是我给后续模型调参留下的底线先保证输入是新鲜、有界、可释放的再谈特征数量和训练轮数。否则上层再精细的质量参数也只是在替失控的采集管线补洞。工程上真正可持续的优化往往从承认设备预算有上限开始。参考资料HarmonyOSSpatial Reconstruction PipelineHarmonyOS多设备与折叠屏相机适配入口HarmonyOSArkTS 并发能力概览