3DGS 重建效果不稳定时,我以前总是先查重建参数。后来才发现很多问题在“按下开始重建”之前就已经埋进去了:模糊帧太多、视角重叠不足、曝光跳变、采集到一半退后台后又从头来。这次我把工程重点前移到采集阶段。

一、这篇不再聊模型渲染,而是只盯采集入口

前面做 3DGS Demo 时,我更关注模型怎么加载、怎么渲染、怎么分块。

这次项目叫 ReconCapture,目标完全不同:先保证送进空间重建链路的输入足够稳定。

测试会话固定为:

sessionId:CAP-20260930-026
候选帧:156
通过质检:112
过滤帧:44
检查点:第 96 帧
恢复次数:1
最终状态:READY_FOR_RECON

44 张被过滤的原因分成:

运动模糊:21
重叠不足:14
曝光异常:9

这里必须先说明一个边界。

ReconCapture 的“检查点恢复”是我在应用采集阶段自己做的。 它不是在声称 Spatial Recon Kit 的重建 Session 支持任意中断后继续跑。

我的设计是:

Camera Kit 采集
↓
应用侧帧质检
↓
保存通过帧 + 相机元数据 + 检查点
↓
达到条件后
↓
新建 Spatial Recon 重建任务

如果应用退到后台,恢复的是“采集清单”,不是已经开始执行的重建会话。

这个区别非常重要,不然“续采”和“续建”很容易被混成一件事。

二、相机预配置先做能力检查,不要默认设备都支持同一套参数

Camera Kit Native API 提供预配置能力,可以快速选择 720P、1080P、4K、高质量等预设和不同画幅比例。

但官方文档明确要求,在使用预配置之前先通过 OH_CaptureSession_CanPreconfig 或对应的比例检查接口确认设备支持情况。

ReconCapture 的原则是:采集规格可以降级,但不能硬启动一个设备不支持的组合。

我会优先尝试高质量 4:3;不支持时回落到 1080P 4:3。

伪代码逻辑是:

if (OH_CaptureSession_CanPreconfig(
      session, PRECONFIG_HIGH_QUALITY)) {
    useHighQuality();
} else {
    use1080P();
}

这层只解决相机能力匹配,不负责判断某一帧“适不适合重建”。

真正的帧质量门控在应用层。

三、一张图能拍下来,不代表它值得进入 3DGS 输入集

ReconCapture 给每一张候选帧计算三个业务指标:

clarityScore
overlapScore
exposureScore

它们不是 Spatial Recon Kit 官方要求必须提供的字段,而是 Demo 自己的前置质量门。

我没有追求一套复杂视觉算法,而是先做足够可解释的规则:

clarityScore >= 0.72
overlapScore >= 0.55
exposureScore >= 0.65

只要任何一项不通过,这张图就不会写进重建输入 manifest。

这段代码解决什么问题:把“拍到了”与“允许进入重建输入集”分成两个状态。

export interface FrameQuality {
  clarityScore: number
  overlapScore: number
  exposureScore: number
}

export interface QualityDecision {
  accepted: boolean
  reason?: 'BLUR' | 'LOW_OVERLAP' | 'BAD_EXPOSURE'
}

export class FrameQualityGate {
  evaluate(q: FrameQuality): QualityDecision {
    if (q.clarityScore < 0.72) {
      return { accepted: false, reason: 'BLUR' }
    }

    if (q.overlapScore < 0.55) {
      return { accepted: false, reason: 'LOW_OVERLAP' }
    }

    if (q.exposureScore < 0.65) {
      return { accepted: false, reason: 'BAD_EXPOSURE' }
    }

    return { accepted: true }
  }
}

这里的阈值全部是 ReconCapture Demo 的测试参数,不是官方固定门槛。

真实项目应该拿自己的目标场景测试。扫室内客厅、展厅和户外建筑,运动速度、纹理密度、光照条件差异都很大,一组阈值不可能覆盖全部产品。

四、质检结果不能只显示“通过 / 不通过”,还要立刻反向指导采集

采集页面如果只默默丢帧,用户会很困惑:明明拍了很久,为什么有效帧一直不涨。

所以 ReconCapture 把最近一次拒绝原因直接变成采集提示:

BLUR → 移动慢一点
LOW_OVERLAP → 回到上一个视角附近继续
BAD_EXPOSURE → 调整拍摄方向,避免强逆光

这样帧质检就不只是后台过滤器,而是采集 UI 的反馈源。

代码里会把候选数和接受数分开维护:

这段代码解决什么问题:被过滤的帧不会增加有效进度,同时把拒绝原因反馈给页面。

private async handleFrame(sample: FrameSample): Promise<void> {
  this.candidateCount += 1

  const decision = this.qualityGate.evaluate(sample.quality)

  if (!decision.accepted) {
    this.rejectedCount += 1
    this.lastRejectReason = decision.reason ?? ''
    return
  }

  this.acceptedCount += 1
  await this.manifest.append(sample)

  if (this.acceptedCount % 24 === 0) {
    await this.saveCheckpoint()
  }
}

这里检查点按“有效帧”保存,而不是按候选帧保存。

因为如果用户连续晃动产生 30 张模糊帧,我不希望检查点看起来前进了 30 帧,实际重建输入却完全没有增长。

DevEco Studio 图里同样能看到这组数据:156 个候选、112 个接受、44 个拒绝。HiLog 里把三类过滤数量也拆开了,方便判断问题到底来自手抖、路线走偏还是光照。

五、检查点保存的不是一张图,而是一份“可恢复采集清单”

第 96 张有效帧时,ReconCapture 保存了一次检查点。

内容包括:

sessionId
accepted frame list
frame file path
capture timestamp
camera metadata reference
acceptedCount
lastCapturePoseHint

这里不会把大图二进制直接塞进 Preferences。

大文件已经落到应用自己的采集目录,检查点只保存 manifest 和必要元数据。

这段代码解决什么问题:应用退后台或页面重建以后,可以恢复已通过质检的帧清单,不用从第 1 张重新采。

export interface CaptureCheckpointData {
  sessionId: string
  acceptedCount: number
  framePaths: string[]
  savedAt: number
}

export class CaptureCheckpoint {
  async save(data: CaptureCheckpointData): Promise<void> {
    const content = JSON.stringify(data)
    await this.fileStore.writeText(
      `checkpoint/${data.sessionId}.json`,
      content
    )
  }

  async restore(sessionId: string):
    Promise<CaptureCheckpointData | undefined> {
    const text = await this.fileStore.readText(
      `checkpoint/${sessionId}.json`
    )

    if (!text) {
      return undefined
    }

    return JSON.parse(text) as CaptureCheckpointData
  }
}

实际项目还要给 manifest 做版本号和完整性校验。

如果检查点里写着 96 个文件,但目录里只剩 94 个,就不能直接恢复成 96。恢复阶段要重新校验文件是否存在、是否可读,再计算真正的有效数量。

六、APP_BACKGROUND 发生时,我停止采集,但不立刻启动重建

本次测试在第 96 个有效帧以后切了一次后台。

记录是:

interruptReason = APP_BACKGROUND
checkpoint = 96
resumeCount = 1

我的做法是先停止继续拿新帧,完成当前 manifest 落盘,再释放相机采集资源。

回到前台后,读取检查点,重新建立 Camera Kit 会话,然后继续采集新帧。

这里绝不能把旧 Camera Session 句柄序列化下来。句柄属于运行期资源,检查点保存的是业务数据,不是底层对象。

恢复之后,acceptedCount 从校验后的 96 开始,最终继续增长到 112。

这就是所谓的“续采”。

七、只有采集阶段结束,才真正进入 Spatial Recon 重建

当页面满足产品定义的输入条件以后,状态从:

CAPTURING

进入:

READY_FOR_RECON

这时候才把最终 manifest 交给 Native Bridge,由 C/C++ 侧根据 Spatial Recon Kit 正式创建空间重建会话。

Spatial Recon Kit 的 C API 使用 HMS_SpatialRecon_Session 表示重建会话;官方能力覆盖空间重建以及后续 3DGS 渲染链路。

ReconCapture 不在 ArkTS 层伪造一组所谓“重建 API”,而是只暴露自己的业务方法:

await reconNative.startFromManifest(manifestPath)

这个 reconNative 是项目自定义 Node-API 封装,内部才去调用官方 C API。

这样 ArkTS 不需要持有 Native Session 指针,生命周期也更清晰。

八、为什么我宁愿过滤 44 帧,也不想把 156 张全部塞进去

“输入更多”不一定等于“重建更好”。

这篇并不是在给 Spatial Recon Kit 的内部关键帧算法下结论。官方能力本身就会处理多角度输入并完成自己的重建逻辑。

应用前置质检的价值是另一层:尽量减少明显无价值或可能干扰体验的输入,同时让用户在采集阶段及时修正动作。

比如 21 张明显运动模糊帧,对用户来说最有价值的信息不是“系统后面会不会自己剔除”,而是现在就告诉他“移动慢一点”。

这会直接改变采集体验。

九、最终运行结果不是“156 帧采完了”,而是“112 帧准备好了”

最后一次测试:

CAP-20260930-026
候选帧:156
通过质检:112
过滤帧:44
上次检查点:第 96 帧
恢复次数:1
中断原因:APP_BACKGROUND
状态:READY_FOR_RECON

图三里把 44 张过滤帧继续拆成:

模糊 21
重叠不足 14
曝光异常 9

这比一个“采集进度 100%”更有诊断价值。

用户能知道哪些输入真正会进入后续重建,开发者也能判断引导策略需不需要调整。

十、清晰度、重叠度和曝光分数从哪里来

为了避免把“质量评分”写成一个神秘黑盒,我给 ReconCapture 的三项分数定义了明确来源。

清晰度主要来自图像高频信息和边缘响应。如果连续多帧高频能量明显下降,同时陀螺仪或相机运动信息显示用户正在快速移动,这一帧大概率不适合作为输入。

重叠度并不是简单比较两张图像素相似,而是估计当前帧和最近已接受关键帧之间是否保留足够共同视觉区域。Demo 里用轻量特征匹配得到一个业务分数,再结合视角变化判断是否“走得太快、跨度太大”。

曝光分数则用亮度直方图和极暗 / 极亮像素比例做简单约束。

这三项都是 ReconCapture 自己的工程指标,并不是 Spatial Recon Kit 对外暴露的官方质量评分。

我把它们设计成可替换接口:

export interface FrameQualityAnalyzer {
  analyze(framePath: string): Promise<FrameQuality>
}

以后如果产品要换更好的清晰度算法,不需要改采集状态机,只替换 Analyzer。

这种设计比把三段算法直接写进 CapturePage.ets 更适合长期维护。

十一、检查点必须原子写入,不能写到一半就算保存成功

最早版本的 saveCheckpoint() 直接覆盖:

checkpoint/CAP-20260930-026.json

如果刚写一半应用就被终止,下次启动读到的是半截 JSON。

后来我改成两阶段:

写 checkpoint.tmp
↓
flush / close
↓
rename 为正式 checkpoint.json

只有重命名完成以后,页面才显示“检查点已保存”。

如果平台文件接口不保证你需要的原子语义,也可以采用双文件版本:

checkpoint_A.json
checkpoint_B.json

每次写另一份,并在头部保存 version、savedAt 和校验摘要。恢复时选最新且完整的一份。

这段代码解决什么问题:中断恰好发生在保存检查点过程中时,不把损坏文件当成有效恢复点。

interface CheckpointEnvelope {
  version: number
  sessionId: string
  acceptedCount: number
  framePaths: string[]
  savedAt: number
}

async function saveSafely(data: CheckpointEnvelope): Promise<void> {
  const temp = `checkpoint/${data.sessionId}.tmp`
  const target = `checkpoint/${data.sessionId}.json`

  await fileStore.writeText(temp, JSON.stringify(data))
  await fileStore.replace(temp, target)
}

检查点不是“尽量记一下进度”,而是恢复链路的依据。既然要依赖它,就应该把它当成一份需要完整性的正式数据。

十二、恢复以后第一件事不是继续拍,而是重新验证环境

从后台回来后,我不会立刻把相机重新打开然后继续计数。

恢复流程是:

读取检查点
↓
校验 96 个文件是否仍然存在
↓
校验当前相机能力
↓
重新建立 Camera Session
↓
重新获取当前方向 / 画幅配置
↓
给用户一帧恢复提示
↓
继续采集

为什么还要重新检查相机能力?

因为运行期资源已经释放,设备状态可能变化。应用可能经历横竖屏切换、相机被其他应用占用、系统资源调整。旧 Session 的配置不能理所当然地套到新 Session 上。

所以检查点只恢复业务事实:

已经接受哪些帧
最后保存到哪里
当前会话是谁

至于新的相机资源,永远重新创建。

这种区分对 Native 能力尤其重要:可序列化的是数据,不可序列化的是句柄。

十三、重复帧也是一种质量问题

44 张过滤帧里,我在截图中把 14 张归到“重叠不足”。

实际实现里,还有另一种相反问题:重叠太高,视角几乎没变。

比如用户站在原地连拍 20 张,清晰度很好、曝光也正常,但信息增量很低。

ReconCapture 会给这种帧设置一个 TOO_SIMILAR 诊断标记。当前文章为了让统计和图片保持一致,把它合并到重叠策略内部,没有单独增加第四类数字。

产品提示会区分:

重叠不足 → 回到上一视角附近
信息重复 → 向侧边移动,补充新视角

这也是为什么“重叠分数”不能简单理解成越大越好。

3DGS 采集需要的是连续而有变化的视角。真正的门控规则往往是一个区间,而不是只设置一个最低值。

十四、112 张有效帧也不是一个固定的完成门槛

截图里最终接受 112 张以后进入 READY_FOR_RECON,这只是本次客厅 Demo 的产品条件。

真实场景是否可以开始重建,至少还应该综合:

有效帧数量
空间覆盖范围
视角分布
是否存在明显未覆盖区域
用户主动结束意图

一个小物体桌面扫描和一个完整客厅,不可能使用同一个固定帧数。

所以 ReconCapture 的状态机里不会写:

if (acceptedCount >= 112) {
  READY_FOR_RECON
}

而是交给 CaptureCompletenessEvaluator:

const completeness = await evaluator.evaluate(manifest)
if (completeness.ready) {
  this.state = 'READY_FOR_RECON'
}

112 只是本轮运行结果,不是系统阈值。

这种写法也方便后面扩展场景覆盖热力图、轨迹闭环等判断,而不用把 CapturePage 继续堆大。

十五、真正开始重建以后,采集目录不要立刻删除

进入 Spatial Recon 以后,我会把当前 manifest 状态从:

CAPTURE_READY

改成:

RECON_SUBMITTED

但不会马上删除 112 张源图。

因为重建可能失败。

如果 Native 层返回设备不支持、输入异常、资源不足等错误,用户不应该被迫重新绕客厅走一圈。

更稳妥的做法是:

重建成功并生成目标产物
↓
校验结果可用
↓
再按产品策略决定是否清理源采集

如果产品允许用户二次重建、调整参数,源图甚至应该保留更久。

采集数据的生命周期应该由业务结果决定,而不是“调用 startFromManifest 成功返回”就立即删除。

十六、我给采集链路加了一套比模型更早的 DFX

以前查 3DGS 问题时,我只有“重建失败”一条错误。

现在 HiLog 会记录:

session=CAP-20260930-026
candidate=156
accepted=112
rejected=44
reject.blur=21
reject.overlap=14
reject.exposure=9
checkpoint=96
resumeCount=1
interrupt=APP_BACKGROUND
state=READY_FOR_RECON

如果某个版本突然出现“有效帧率明显下降”,我可以先看是哪类过滤上涨。

比如:

blur 突然翻倍

可能是采集 UI 动画或相机参数变化导致用户移动节奏变快;

overlap 过滤变多

可能是引导路线有问题;

exposure 上涨

可能是场景切换到窗边以后提示不够及时。

这种诊断比最后只看模型质量更早,也更容易复现。

十七、这篇真正想保留的是“采集也是算法链路的一部分”

很多 3DGS Demo 最容易展示的是最后的三维模型。

但在真实产品里,用户花时间最多的往往是模型出现之前那几分钟:拿着手机走、对准、补角度、被打断、回来继续。

如果这段体验没有状态、有问题不反馈、退后台就清零,再强的重建能力也很难让普通用户顺利完成一次采集。

所以这次我刻意把技术重心放到模型之前:

设备能力检查
→ 帧质量门控
→ 即时提示
→ 检查点
→ 中断恢复
→ 完整性判断
→ 交给 Spatial Recon

它没有修改 Spatial Recon Kit 内部算法,只是在系统能力之前加了一层更适合产品使用的采集工程。

对我来说,这和后处理优化一样重要。

十八、这次把问题前移以后,我留下了四个判断

第一,相机成功输出帧,不代表这张帧值得进入 3DGS 输入集。

第二,中断续采是业务采集层能力,不要和 Spatial Recon 重建会话恢复混为一谈。

第三,检查点应该保存可重建的业务事实,而不是保存 Camera Session、Native 指针这类运行期对象。

第四,帧质检不仅用来丢数据,还应该反向驱动用户采集动作。

Spatial Recon Kit 已经提供了 3DGS 空间重建和渲染能力。真正把它做成一个稳定产品时,模型之前的那几分钟采集体验同样重要。

以前我总盯着“模型最后长什么样”,这次更关心另一件事:

送进模型的 112 张图,到底是不是用户这次采集里真正值得留下的 112 张。

参考资料

  • Spatial Recon Kit 简介 / API
    https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/api/spatial-recon-api
  • Spatial Recon Kit 术语
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/spatial-recon-glossary
  • Camera Kit Native 相机预配置
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/camera-preconfig-native
  • spatialRender
    https://developer.huawei.com/consumer/en/doc/harmonyos-references/spatial-recon-spatialrender
Logo

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

更多推荐