这篇文章讨论的不是“怎样把 3DGS 跑起来”,而是更靠前的一道工程问题:哪些采集帧值得进入重建管线。Spatial Recon Kit 可以承担重建能力,但业务仍要决定输入是否连续、是否清晰、是否过曝,以及失败后让用户回到哪里补拍。

文中的 FrameGate3D 是可复现的演示工程,数据用于说明状态设计和验收口径,不代表某款设备的真实跑分,也不把生成的界面配图当作实机测试证据。

一、重建失败常常从“看起来还能用”的帧开始

采集页最容易出现一种错觉:预览画面没有黑屏,快门也在持续工作,于是应用就认为素材有效。真正把帧送进重建后,才发现模型局部发虚、细节断裂,或者某个角度长期没有补齐。此时再回头看原始素材,问题往往不是文件损坏,而是大量帧处在“能显示、不能稳定参与重建”的灰区。

这个灰区至少包含三类情况。

第一类是运动模糊。用户移动设备的速度超过当前曝光条件能够承受的范围,整张图仍然有轮廓,却缺少可用于匹配的高频细节。第二类是亮度失衡,暗部几乎落成一片,或者高光区域大面积饱和。第三类是覆盖失衡:总帧数不少,但用户一直绕着同一侧移动,缺失的角度没有被补回来。

因此,采集完成不能只看 frameCount。FrameGate3D 把一次任务定义为 RECON-FRAME-0054,演示输入为 180 帧;经过质量门禁后接受 132 帧,其中 31 帧因清晰度不足被拒绝,17 帧因亮度越界被拒绝。最终角度覆盖率为 73%,状态停在 NEED_RECAPTURE,而不是直接进入 READY_TO_RECONSTRUCT。

这个结果有意设计成“不通过”。因为质量门禁最有价值的部分,不是给出一个绿色对勾,而是把补拍动作缩小到可解释的范围:不是重新拍 180 帧,而是提示用户补齐右后方 90°~135° 的覆盖,并在连续五帧通过后再提交。

二、先把门禁写成状态机,再谈算法分数

如果页面只维护一个 progress,后面很快会遇到两个问题。其一,采集进度和质量进度混在一起,180 帧拍完会显示 100%,但输入仍不合格;其二,旧任务的异步回调可能覆盖新一轮补拍状态。

我更倾向于把状态拆成四段:CAPTURING 负责接收帧,EVALUATING 负责像素指标,NEED_RECAPTURE 负责解释缺口,READY_TO_RECONSTRUCT 才允许把合格清单交给重建适配层。每轮补拍增加一个 generation,回调必须同时匹配 taskId 与 generation。

这段代码解决什么问题:把帧数量、质量判断和补拍状态分开,避免旧批次结果污染当前页面。

type GateState = 'CAPTURING' | 'EVALUATING' |
  'NEED_RECAPTURE' | 'READY_TO_RECONSTRUCT' | 'ERROR'

interface FrameMetric {
  frameId: string
  sharpness: number
  meanLuma: number
  yaw: number
  accepted: boolean
  reason: 'PASS' | 'BLUR' | 'DARK' | 'OVEREXPOSED'
}

@ObservedV2
class CaptureAuditStore {
  @Trace taskId: string = 'RECON-FRAME-0054'
  @Trace generation: number = 6
  @Trace state: GateState = 'CAPTURING'
  @Trace total: number = 0
  @Trace accepted: number = 0
  @Trace rejectedBlur: number = 0
  @Trace rejectedExposure: number = 0
  @Trace coverage: number = 0
  metrics: FrameMetric[] = []

  beginRecapture(): number {
    this.generation += 1
    this.state = 'CAPTURING'
    return this.generation
  }

  commit(metric: FrameMetric, callbackGeneration: number): void {
    if (callbackGeneration !== this.generation) {
      return
    }
    this.metrics.push(metric)
    this.total += 1
    if (metric.accepted) this.accepted += 1
    if (metric.reason === 'BLUR') this.rejectedBlur += 1
    if (metric.reason === 'DARK' || metric.reason === 'OVEREXPOSED') {
      this.rejectedExposure += 1
    }
  }
}

这里没有把 accepted 反推自页面颜色,而是把拒绝原因写进数据。这样做的好处是补拍页能给出具体证据,日志也能区分“模糊过多”和“曝光越界”。状态变化由业务动作驱动,而不是由某个组件是否可见驱动。

易错点在 generation。开始补拍后,旧分析任务仍可能返回,如果只比较 taskId,它们仍属于同一个重建任务。实际项目应让每次采集阶段都拥有独立代次,并在页面离开时停止接收结果。@ObservedV2、@Trace 用于演示状态组织,是否采用应以当前工程的状态管理方案为准。

三、像素指标只做“门禁”,不要伪装成重建质量结论

演示工程用两个便于解释的指标。清晰度采用灰度图的拉普拉斯方差,阈值设为 86;平均亮度允许区间为 42~218。阈值不是系统固定值,也不是 Spatial Recon Kit 的公开质量分,它只是当前 Demo 的应用层规则。

拉普拉斯方差对边缘细节敏感,但同样会把高 ISO 噪点当作细节。因此,工程里不能看到数值高就认定“这是一张好帧”。门禁的目标是快速排除明显无效输入,真正的可重建性仍要由后续管线判断。

NativeImage 提供 OH_PixelmapNative_GetImageInfo、OH_PixelmapNative_ReadPixels 等能力,可以在 Native 层读取 PixelMap 信息和像素。像素格式、行跨度与缓冲区长度必须来自实际图像信息,不能用 width * height * 4 代替所有情况。

这段代码解决什么问题:从 PixelMap 读取像素并计算可解释的清晰度、亮度指标,同时把资源释放边界写清楚。

#include "napi/native_api.h"
#include "multimedia/image_framework/image/pixelmap_native.h"
#include <vector>
#include <cmath>

struct QualityMetric {
  double lapVariance;
  double meanLuma;
};

QualityMetric EvaluateRgba(OH_PixelmapNative* pixelMap,
                           uint32_t width,
                           uint32_t height,
                           uint32_t rowStride) {
  size_t size = static_cast<size_t>(rowStride) * height;
  std::vector<uint8_t> rgba(size);
  size_t actual = size;
  Image_ErrorCode code = OH_PixelmapNative_ReadPixels(
    pixelMap, rgba.data(), &actual);
  if (code != IMAGE_SUCCESS) {
    throw std::runtime_error("ReadPixels failed");
  }

  double sum = 0.0;
  std::vector<double> gray(width * height);
  for (uint32_t y = 0; y < height; y++) {
    for (uint32_t x = 0; x < width; x++) {
      size_t p = y * rowStride + x * 4;
      double g = 0.299 * rgba[p] + 0.587 * rgba[p + 1] +
        0.114 * rgba[p + 2];
      gray[y * width + x] = g;
      sum += g;
    }
  }

  std::vector<double> lap;
  lap.reserve((width - 2) * (height - 2));
  for (uint32_t y = 1; y + 1 < height; y++) {
    for (uint32_t x = 1; x + 1 < width; x++) {
      size_t i = y * width + x;
      lap.push_back(4 * gray[i] - gray[i - 1] - gray[i + 1]
        - gray[i - width] - gray[i + width]);
    }
  }
  double mean = 0.0;
  for (double v : lap) mean += v;
  mean /= lap.size();
  double variance = 0.0;
  for (double v : lap) variance += (v - mean) * (v - mean);
  variance /= lap.size();
  return { variance, sum / (width * height) };
}

这段实现把计算过程写得比较直白,便于核对。实际产品可以缩小采样图、按固定步长取点,减少每帧全量扫描的成本。更重要的是,不要在 UI 线程里执行这段循环;Native 层分析完成后只返回数字和原因,不把整块像素再复制回 ArkTS。

资源释放需要按所有权判断。如果 PixelMap 由 ArkTS 传入、生命周期仍由调用方管理,Native 函数不应擅自释放;如果 Native 侧创建了独立 PixelMap,则要在成功、失败和取消路径中成对调用释放接口。文章中的代码省略了 NAPI 参数转换和异常映射,完整工程应把错误码转成结构化结果,而不是只抛字符串。

四、连续帧规则比单帧高分更重要

单帧通过并不代表采集过程稳定。用户快速扫过一个角度时,偶尔会得到一张清晰帧,但前后帧都模糊;如果立刻把它计入覆盖率,UI 会频繁在“缺失”和“完成”之间跳动。

演示工程要求同一角度桶连续五帧通过,才把该桶记为稳定覆盖。角度按 15° 分桶,整个环绕过程共 24 个桶;132 张合格帧并不等于 132 个有效覆盖点,最终只有 17 个角度桶稳定,因此显示 73%。

这段代码解决什么问题:把单帧质量结果聚合成稳定角度覆盖,避免偶然高分导致进度虚高。

interface CoverageBucket {
  index: number
  passStreak: number
  stable: boolean
}

class CoverageTracker {
  private buckets: CoverageBucket[] = Array.from({ length: 24 },
    (_, index: number) => ({ index, passStreak: 0, stable: false }))

  update(yaw: number, accepted: boolean): number {
    const normalized = ((yaw % 360) + 360) % 360
    const index = Math.min(23, Math.floor(normalized / 15))
    const bucket = this.buckets[index]
    bucket.passStreak = accepted ? bucket.passStreak + 1 : 0
    if (bucket.passStreak >= 5) {
      bucket.stable = true
    }
    const stableCount = this.buckets.filter((item: CoverageBucket) =>
      item.stable).length
    return Math.floor(stableCount * 100 / this.buckets.length)
  }

  missingRange(): string {
    const missing = this.buckets.filter((item: CoverageBucket) => !item.stable)
    return missing.some((item: CoverageBucket) => item.index >= 6 && item.index <= 9)
      ? '右后方 90°~135°' : '继续缓慢环绕主体'
  }
}

为什么采用连续通过,而不是取五帧平均值?因为补拍提示关心的是用户有没有在这个方向稳定停留,而不是某个区间的数学均值。状态变化也更容易解释:连续失败会把 passStreak 清零,但不会撤销已经稳定的角度桶;如果业务需要更严格的回退,可以再增加过期时间或二次确认。

易错点是角度来源。这里把 yaw 当作适配层已经提供的归一化数据,没有声称它来自某个不存在的 Spatial Recon Kit 回调。实际工程可以使用采集管线已有的姿态信息,也可以用应用自己的传感器融合结果,但必须统一坐标系、方向和时间戳。

五、补拍页要把“拒绝原因”翻译成下一步动作

当任务停在 NEED_RECAPTURE 时,页面展示四个关键数据:132 / 180、清晰度拒绝 31、曝光拒绝 17、覆盖率 73%。这些数字不是为了做仪表盘,而是决定下一步操作。

如果清晰度拒绝集中出现在同一时间段,提示应该是“移动过快,请降低环绕速度”;如果曝光越界集中在某个方向,提示应该是“避开强逆光并保持主体亮度”;如果单帧质量总体正常但覆盖不足,就只提示缺失角度。三种情况不能都归结成“采集失败”。

手机运行图展示的是演示态:时间 03:12,任务 RECON-FRAME-0054,状态 NEED_RECAPTURE,覆盖率 73%。红色标注指向缺失角度和补拍按钮。这里的图用于解释交互与数据对应,不是设备截图证据。

补拍开始后,页面不会清空前 132 张合格帧,而是把 generation 从 6 提升到 7,只接受新代次的回调。当右后方连续五帧通过,覆盖率达到 92%,状态才切换到 READY_TO_RECONSTRUCT。演示没有把阈值设置为 100%,因为封闭环绕、遮挡和场景结构会影响可达覆盖,产品应按对象类型确定最低要求。

这段代码解决什么问题:把门禁结果转换成可执行的补拍动作,并在提交前再次校验任务代次。

async function submitQualifiedFrames(store: CaptureAuditStore,
  adapter: ReconPipelineAdapter): Promise<void> {
  const snapshotGeneration = store.generation
  store.state = 'EVALUATING'
  const qualified = store.metrics.filter((item: FrameMetric) => item.accepted)

  if (store.coverage < 90) {
    store.state = 'NEED_RECAPTURE'
    return
  }

  const result = await adapter.prepareInput({
    taskId: store.taskId,
    generation: snapshotGeneration,
    frameIds: qualified.map((item: FrameMetric) => item.frameId)
  })
  if (snapshotGeneration !== store.generation) {
    return
  }
  store.state = result.ready ? 'READY_TO_RECONSTRUCT' : 'ERROR'
}

这段代码没有直接虚构某个 Spatial Recon Kit 的 ArkTS 提交接口,而是用 ReconPipelineAdapter 表达业务与官方 C/C++ 重建管线之间的边界。适配层负责把帧清单转换成真实输入,页面只消费 ready 结果。

实际项目要注意取消和清理。如果用户退出页面,应停止新的质量分析、释放由当前任务创建的像素对象,并阻止完成回调更新已销毁页面。已经交给底层管线的资源是否能够取消,要以当前官方接口和设备能力为准,不能把 UI 上的“取消”按钮等同于底层立即终止。

六、日志要能回答“这一帧为什么没进去”

只记录 accepted=132 远远不够。FrameGate3D 的 HiLog 演示包含任务、代次、帧号、清晰度、平均亮度、角度桶和最终原因:

日志中的三条关键记录分别是:frame=F0142 sharp=63 luma=118 bucket=07 reject=BLUR;frame=F0151 sharp=104 luma=231 bucket=08 reject=OVEREXPOSED;批次汇总为 accepted=132 blur=31 exposure=17 coverage=73 state=NEED_RECAPTURE。每条都带有 task=RECON-FRAME-0054 gen=6,可以跟补拍后的第 7 代数据区分。

诊断页则把缺失桶、阈值和代次放到一起。这样当页面显示 73% 时,开发者能沿着同一组字段回到代码,而不需要猜测是 UI 算错、回调过期还是帧确实没通过。

图中补拍后的诊断结果为:generation 6 → 7,右后方角度桶由 0 个稳定桶变为 5 个,覆盖率 73% → 92%,状态 NEED_RECAPTURE → READY_TO_RECONSTRUCT。这组数据与正文一致,但仍属于演示样本。

七、这道门禁能做什么,不能做什么

它能做三件事:在重建前排除明显模糊和曝光异常帧;把覆盖不足转成具体补拍方向;保留一份可追踪的帧清单和拒绝原因。这样,即使重建结果仍不理想,也能判断问题来自输入、管线还是渲染阶段。

它不能替代 Spatial Recon Kit 的内部质量判断,也不能用两个阈值证明模型一定成功。纹理重复、透明反光物体、动态主体和极端光照都可能让“清晰且曝光正常”的帧仍然难以重建。应用层门禁应保持克制:拦住明显坏输入,给用户可执行的反馈,把最终能力边界留给真实管线。

如果把这套思路放进正式工程,我还会补三项验证:同一批素材在不同阈值下的误拒率;质量分析对采集帧率和功耗的影响;页面退后台、旋转和任务重启时的资源回收。没有这些数据之前,不能把演示阈值直接写成产品常量。

性能上也不必追求“每一帧都全分辨率分析”。采集阶段真正需要的是稳定反馈,可以在固定时间间隔抽样,把预览帧缩小后计算门禁指标,并让原始素材继续按管线要求保存。抽样频率、缩放比例和连续通过帧数应作为同一组参数验证:采样太稀会让补拍提示滞后,采样太密又会跟相机和重建任务争抢 CPU、内存带宽。诊断报告应同时记录这些参数,否则两次 73% 覆盖率未必来自相同条件。

还要区分“质量分析失败”和“帧质量不合格”。前者可能是像素读取失败、格式不支持或资源已释放,属于工程异常;后者才是 BLUR、DARK、OVEREXPOSED 这类业务结论。把异常也算进模糊数量,会让用户得到错误提示,也会掩盖 Native 层生命周期问题。

参考资料:

Logo

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

更多推荐