这次重建不是跑不起来,而是结果里多了几个“幽灵”:同事从镜头前走过一次,导出的 3DGS 场景里便留下半透明人影,桌腿附近还漂着一团不该存在的高斯点。

一、完成率 100%,场景却不干净

Demo 名为 StaticScene Guard Lab,会话 ID 是 recon_20261001_13。我们围着一张展品桌采集 640 帧,Spatial Recon 流程顺利结束,没有崩溃,也没有丢文件。真正把结果放进查看器后,动态人影和低置信深度产生的漂浮点才暴露出来。

问题不在“帧有没有送进管线”,而在“这帧适不适合成为静态场景证据”。3DGS 会把多视角观测共同拟合成可渲染表示,短暂出现的人、摆动的袋子、反光边缘和深度空洞都可能进入训练。采集进度越顺畅,错误观测反而越稳定地被保留下来。

修复后,这轮 640 帧里,动态占比超限的 73 帧被拒绝,低置信深度的 41 帧被拒绝,首轮有效帧 526;系统随后按视角空洞补采 32 帧,最终有效关键帧 558。重投影误差从 2.8 px 降到 0.9 px,漂浮点从 128 个降到 9 个,状态 STATIC_READY。

我把这次工作拆成三道门:动态遮罩不合格的帧不进入候选集;深度置信度不足的帧不参与几何约束;被过滤后形成的视角空洞必须明确补采。只做前两步会让质量变好,但场景背面可能缺数据,所以“拒绝”和“补回”必须成对设计。

二、每帧先形成质量判决

最早的实现只有模糊度和位姿变化判断。人从镜头前经过时,画面并不一定模糊,位姿也可能稳定,因此仍会被选成关键帧。后来我们给每帧附加两个量:动态遮罩像素占比 motionRatio,以及有效深度的平均置信度 depthConfidence。

下面的代码解决“不同质量原因必须可区分”。它不会只返回布尔值,而是给出明确判决,方便 UI、日志和补采调度使用同一套结果。

export enum FrameDecision {
  ACCEPT = 'ACCEPT',
  REJECT_DYNAMIC = 'REJECT_DYNAMIC',
  REJECT_DEPTH = 'REJECT_DEPTH',
  REJECT_BLUR = 'REJECT_BLUR'
}

export interface FrameQuality {
  frameId: number
  motionRatio: number
  depthConfidence: number
  sharpness: number
  azimuth: number
}

export function evaluateFrame(q: FrameQuality): FrameDecision {
  if (q.motionRatio > 0.12) return FrameDecision.REJECT_DYNAMIC
  if (q.depthConfidence < 0.78) return FrameDecision.REJECT_DEPTH
  if (q.sharpness < 0.66) return FrameDecision.REJECT_BLUR
  return FrameDecision.ACCEPT
}

判决顺序有意把动态放在最前面。动态遮罩过大时,即便深度与清晰度正常,这帧也不应该成为静态场景证据。0.12 / 0.78 / 0.66 来自本次展品桌标注集,不适合作为所有场景的固定默认值;室外、玻璃展柜或人流密集区域都要重新校准。

数据从“原始帧”变成“带原因的候选帧”,失败不是无声丢弃。页面会分别统计动态拒绝、深度拒绝和模糊拒绝,采集人员能判断应该等待行人离开、靠近反光区域,还是放慢移动速度。正式项目还应保留阈值版本,否则重放旧会话时无法解释判决变化。

三、遮罩和深度结果必须属于同一帧

动态检测、深度估计和相机回调并不一定同时完成。第一版用“最新遮罩”配“当前相机帧”,快速移动时产生错配:第 218 帧的遮罩被用到第 220 帧,桌边的人影刚好漏过门禁。

当前代码解决异步结果错配。每个工作项带 sessionId + frameId + timestamp,只有三者同时匹配才合成质量结果;旧会话和晚到结果会被丢弃,关联的临时 PixelMap 立即释放。

export class FrameJoiner {
  private generation = 0
  private pending = new Map<number, Partial<FramePacket>>()

  beginSession(): number {
    this.generation += 1
    this.clearPending()
    return this.generation
  }

  pushPart(generation: number, frameId: number,
    part: Partial<FramePacket>): FramePacket | undefined {
    if (generation !== this.generation) {
      part.mask?.release()
      part.depth?.release()
      return undefined
    }
    const merged = { ...(this.pending.get(frameId) ?? {}), ...part }
    if (!merged.image || !merged.mask || !merged.depth || !merged.quality) {
      this.pending.set(frameId, merged)
      return undefined
    }
    this.pending.delete(frameId)
    return merged as FramePacket
  }

  clearPending(): void {
    this.pending.forEach(item => { item.mask?.release(); item.depth?.release() })
    this.pending.clear()
  }
}

generation 解决页面重进或重新开始采集后的旧回调污染,frameId 解决同一会话内的异步配对。合成成功后,所有权交给管线;合成失败或会话失效时,Joiner 负责释放临时资源。谁持有 PixelMap,谁就必须清楚它在哪个分支释放,不能只等垃圾回收。

还要给 pending 设置容量与超时。本 Demo 最多保留 12 帧,超过 300 ms 仍不完整就按缺失原因丢弃。否则某个深度回调永久缺失,Map 会随着采集持续增长。页面离开时先停止新帧,再等待当前任务完成,最后调用 clearPending(),避免释放后仍有新回调写入。

四、过滤之后,视角空洞要补回来

114 帧被拒绝后,重建质量没有立刻变好。右后方恰好有人经过,连续 19 帧被过滤,导致这个方位覆盖明显不足。查看器里的幽灵少了,桌子背面却变薄了。

下面的调度器解决“补采什么,而不是盲目多拍”。它把环绕路径按方位角分成 12 个桶,每桶目标至少 44 帧;过滤完成后只为不足的桶生成补采任务,并限制一次最多补 4 帧。

export class CoverageScheduler {
  private readonly bucketCount = 12
  private readonly targetPerBucket = 44

  buildRefill(accepted: FrameQuality[]): RefillTarget[] {
    const counts = new Array<number>(this.bucketCount).fill(0)
    accepted.forEach(frame => {
      const index = Math.floor(normalizeAngle(frame.azimuth) / 30)
      counts[Math.min(index, this.bucketCount - 1)] += 1
    })

    return counts.flatMap((count, index) => {
      const need = Math.min(4, Math.max(0, this.targetPerBucket - count))
      return need === 0 ? [] : [{
        bucket: index,
        centerAzimuth: index * 30 + 15,
        requiredFrames: need
      }]
    })
  }
}

状态从 FILTERING 进入 REFILLING 后,手机页会显示缺口方位和需要补几帧,采集人员只需回到对应角度。32 帧补采全部重新经过动态、深度和清晰度门禁,不因为“补采”就降低标准。某个桶连续失败三次时,系统提示调整距离或光线,不无限要求重拍。

视角桶只是 Demo 的简化表示。正式项目可以同时考虑俯仰角、相机距离和表面可见性,但不要一次展示太多导航信息,否则采集人员会被复杂提示牵着走。补采目标应解释成方向和数量,而不是暴露内部矩阵。

这一版还加了一个容易被忽略的限制:补采任务不能和首轮任务共享“已完成”事件。最初两套流程都监听管线的完成回调,首轮 526 帧一结束,页面就提前显示完成;随后补采结果虽然继续写入,操作员却已经离开采集位置。现在首轮结束只会把状态推进到 FILTERING,过滤完成后由覆盖调度器决定是进入 REFILLING,还是直接进入 STATIC_READY。页面按钮也跟着状态变化:扫描阶段是“结束首轮”,补采阶段是“提交补采”,只有静态就绪后才允许导出。

这里没有用一个布尔值 isFinished 硬撑全部流程。布尔值无法表达“首轮结束但仍待补采”,也无法区分用户主动停止与质量门禁失败。实际工程里,状态迁移由单独的 reducer 处理,非法迁移会写入 HiLog。例如从 SCANNING 直接跳到 STATIC_READY 会被拒绝,这类日志比最终渲染异常更早暴露流程缺口。为了防止连续点击,按钮事件先比较当前状态和会话 ID,再调用底层管线;底层仍保留幂等保护,不能把防重责任只交给 UI。

五、从 128 个漂浮点回到 9 个

验收时我们固定相机路径和渲染视点,对比过滤前后结果。原始方案的动态人影在三个角度可见,桌腿周围统计到 128 个离群漂浮点;加门禁与补采后,人影不再成片出现,离群点剩 9 个,重投影误差为 0.9 px。

这里的 9 个漂浮点不是靠肉眼随便数出来的。我们在固定包围盒内用相同阈值做离群聚类,并把小于最小支持视角数的点簇标记为可疑;过滤前后都使用同一条检测链。重投影误差也取固定验证帧,不把参与优化的训练帧拿来证明自己。这样的对照虽然比截一张漂亮渲染图麻烦,却能回答改动究竟减少了异常,还是只是换了观察角度。

采集人员的操作成本同样纳入验收。补采 32 帧耗时不到一分钟,页面只提示右后方两个欠覆盖角度;如果门禁让用户整圈重拍,即使指标更好也很难落地。我们给质量策略设了双目标:离群点必须降到两位数以内,补采量不能超过首轮有效帧的 10%。本轮补采占比约 6.1%,在可接受范围内。

手机运行图没有只放一张“看起来更干净”的渲染图,而是同时展示帧计数、拒绝原因、补采进度、误差和漂浮点。页面时间为 13:28,会话 recon_20261001_13,状态 STATIC_READY,阈值 motion ≤ 0.12 / depth ≥ 0.78。这些数据都能和 HiLog 对上。

六、边界条件与资源闭环

动态遮罩并非越严格越好。电风扇、屏幕动画、水面反光等内容可能本来就是场景的一部分,产品需要定义“重建的是静态几何,还是连动态外观也要保留”。本 Demo 面向静态展品,所以采取保守剔除;人流场景更适合提示暂缓采集,而不是把大半帧全部删除。

深度置信度也不能替代几何校验。置信度高只说明深度模型对该区域更确定,不保证跨帧一致。正式重建仍需检查位姿、重投影误差和覆盖情况。本轮把平均置信度作为前置门禁,最终效果由独立的重投影与离群点指标验收。

生命周期方面,CameraSession、动态遮罩 PixelMap、深度 PixelMap 与管线任务分别计数。结束顺序是停止相机输入、拒绝新任务、等待 Joiner 清空、释放未合成资源、再关闭重建会话。重复点击“结束采集”通过会话状态去重,不能执行两次释放。

性能检查也改变了实现取舍。动态遮罩原本在主线程上转成逐像素数组,640 帧采集到中段时,预览会周期性掉帧。现在遮罩统计放进任务线程,只回传像素总数和动态像素数;主线程只计算比例并更新状态。深度置信度同样使用分块累加,不把整张浮点图复制回 UI。我们给质量门禁设置了 12 帧背压上限:处理落后时,优先丢弃尚未进入关键帧候选的最新普通帧,而不是让内存继续增长。因为相机仍在移动,迟到数秒的旧帧即使完成计算,也已经失去导航价值。

调试时我额外保留了一份“拒绝原因时间轴”,但没有把原始图像写入普通日志。时间轴只记录帧号、方位桶、三项质量值和判决,足够复现为什么第 218 帧被拒绝,又不会让带有人脸的画面进入日志包。需要离线分析时,用户主动开启诊断模式,图片写入应用沙箱并设置短期清理策略。正式产品还要把诊断开关、存储容量和用户告知做成同一套能力,不能为了查重建问题无限留存相机数据。

最后一个边界是阈值热更新。采集进行中如果把 motionRatio 从 0.12 改成 0.08,前后帧会使用不同标准,统计结果也失去可比性。Demo 在 beginSession() 时冻结阈值快照,并把快照版本写进会话元数据;新阈值只对下一次会话生效。这样导出后看到 73 个动态拒绝,能够明确对应哪一组规则,而不是拿当前配置去猜过去的判决。

这次问题说明,3DGS 采集的完成率并不等于输入质量。动态遮罩负责告诉管线“什么不属于静态场景”,深度置信度告诉它“哪些几何证据不可靠”,覆盖调度则补回过滤造成的视角缺口。三者连起来,STATIC_READY 才不仅是流程结束,而是输入已经达到可重建的边界。

Logo

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

更多推荐