有人把一组围绕展台小摆件拍摄的图片交给重建流程,最初的问题不一定出在建模算法。素材可能集中在物体的正面,侧面与背面几乎没有覆盖;也可能拍了很多张,但机位仅在一个很窄的范围内重复。只看“拍摄数量”,很容易把这种缺口误判成进度充足。

本文讨论一个独立于重建 SDK 的前置检查器:OrbitCoach。它不生成 3DGS 模型,也不替华为的空间重建引擎计算质量,而是根据人为记录或由合规采集链路提供的水平朝向数据,做一次可解释的扇区覆盖统计,告诉操作者下一步值得检查哪个方向。页面 CoveragePage,示例任务 OC-091;全部帧数据、截图和日志均是说明算法的演示样例,不是实测数据。

一、先把“拍够了”变成可检查的条件

1. 固定一圈十二个扇区

这里用 30° 作为一个水平扇区,一圈划成十二格。它只是 Demo 的展示粒度,不是 Spatial Recon Kit 规定的采集间隔。输入八个演示帧,其水平朝向依次为 5°、12°、38°、95°、125°、150°、210°、270°。5° 与 12° 落在同一格,最终命中七个不同扇区:0、1、3、4、5、7、9。按十二格计,覆盖率是 7/12,即 58.3%。

缺口落在格号 2、6、8、10、11,对应每格的起点 60°、180°、240°、300°、330°。页面会先建议检查 60° 附近,状态显示 NEED_MORE_ANGLES。这里的百分比是启发式覆盖度,不能等同于重建成功概率,更不能据此保证模型精度。

写这种页面容易犯一个错误:把每一张图片都当成新增覆盖。拍摄者在 5° 处连续拍十张,统计图就会看起来飞快完成,但背面仍是空的。因此先处理角度归一化、数据有效性和扇区去重,再谈进度条。

2. 用 Map 保留重复证据,但不让重复项增加分数

下面的 ArkTS 代码只做确定性的内存计算。Map<number, string[]> 的键是扇区号,值是落入该格的帧 ID。quality 是演示输入方提供的 0~1 标记,0.65 是应用自设过滤线;它不是相机系统给出的统一锐度指标,也不是华为的重建阈值。

interface CaptureShot {
  id: string;
  yaw: number;
  quality: number;
}

interface CoverageReport {
  accepted: number;
  occupied: number;
  percent: number;
  missing: number[];
  bins: Map<number, string[]>;
}

function sectorOf(yaw: number): number {
  const normalized = ((yaw % 360) + 360) % 360;
  return Math.floor(normalized / 30);
}

function evaluateCoverage(shots: CaptureShot[]): CoverageReport {
  const bins = new Map<number, string[]>();
  let accepted = 0;
  for (const shot of shots) {
    if (!Number.isFinite(shot.yaw) || !Number.isFinite(shot.quality)) continue;
    if (shot.quality < 0.65 || shot.quality > 1) continue;
    accepted++;
    const slot = sectorOf(shot.yaw);
    const ids = bins.get(slot) ?? [];
    bins.set(slot, [...ids, shot.id]);
  }
  const missing: number[] = [];
  for (let slot = 0; slot < 12; slot++) {
    if (!bins.has(slot)) missing.push(slot);
  }
  return {
    accepted, occupied: bins.size,
    percent: Math.round(bins.size / 12 * 1000) / 10,
    missing, bins
  };
}

先归一化再取整,解决负角和超过 360° 的输入;但如果朝向本身不可靠,归一化不会让它变准确。accepted 表示有效记录数量,occupied 表示真正命中的不同格子。把二者分开,日志才有排障价值。无效数据被排除,原始帧和质量来源仍应保存在业务侧,不能在这一步直接删除采集文件。

二、下一步拍哪里,要能解释给操作员听

仅输出一个红色百分比没有帮助。OrbitCoach 将缺失格子转换为推荐方向,同时保留“未覆盖”说明。建议先展示按格号排序的第一项,既简单又稳定,不会因为数组输入顺序变化而跳来跳去;更复杂的方案可以再引入当前位置、机位可达性和遮挡权重。

接下来用一小段业务函数产生人能读懂的提示,避免把 Map 的内部键直接暴露给界面,也把阈值限制在业务逻辑里。

interface CaptureAdvice {
  state: string;
  nextYaw: number;
  message: string;
}

function chooseAdvice(report: CoverageReport): CaptureAdvice {
  if (report.occupied >= 9) {
    return { state: 'CHECK_QUALITY', nextYaw: -1,
      message: '水平覆盖已达到演示目标,请检查模糊、遮挡与高度变化' };
  }
  const slot = report.missing[0];
  return { state: 'NEED_MORE_ANGLES', nextYaw: slot * 30,
    message: `建议先检查 ${slot * 30}° 附近的缺失视角` };
}

九格即 75% 只是产品里预设的“触发人工复核”门槛。即使达到了九格,也不应该自动显示“可重建”或直接开始建模。一个围绕平面海报转圈的案例,水平格子可能齐全,但垂直视差、反光、距离变化都可能不足。对于真实空间重建,需要以所使用版本的 Spatial Recon Kit 接入指南、具体设备能力和可获得的结果质量反馈为准。

三、页面只保存业务状态,不持有重建会话

为了让演示可复现,CoveragePage 不申请相机权限,也不创建系统重建 Session。它使用固定样例驱动界面,展示任务 OC-091、八个输入帧、七个已覆盖扇区和五个待补方向。后续接入真实采集数据时,只需要把输入数组替换成合法的朝向记录;算法与页面仍保持隔离。

下面是精简的 ArkUI 页面。按钮模拟加入 65° 帧,再调用同一个纯函数重新生成统计。这里演示的是“计算状态刷新”,不表示底层相机已经拍照成功。

@Entry
@Component
struct CoveragePage {
  @State countText: string = '7/12';
  @State percentText: string = '58.3%';
  @State stateText: string = 'NEED_MORE_ANGLES';
  @State adviceText: string = '建议先检查 60° 附近的缺失视角';
  private shots: CaptureShot[] = [
    { id: 'F-001', yaw: 5, quality: .82 },
    { id: 'F-002', yaw: 12, quality: .76 },
    { id: 'F-003', yaw: 38, quality: .88 },
    { id: 'F-004', yaw: 95, quality: .79 },
    { id: 'F-005', yaw: 125, quality: .84 },
    { id: 'F-006', yaw: 150, quality: .91 },
    { id: 'F-007', yaw: 210, quality: .73 },
    { id: 'F-008', yaw: 270, quality: .86 }
  ];

  private refreshSummary(): void {
    const report = evaluateCoverage(this.shots);
    const advice = chooseAdvice(report);
    this.countText = `${report.occupied}/12`;
    this.percentText = `${report.percent}%`;
    this.stateText = advice.state;
    this.adviceText = advice.message;
  }

  build() {
    Column({ space: 16 }) {
      Text('OrbitCoach · OC-091').fontSize(23).fontWeight(FontWeight.Bold)
      Text('已覆盖 ' + this.countText + ' · ' + this.percentText)
      Text(this.stateText).fontColor(Color.Orange)
      Text(this.adviceText)
      Button('模拟补拍 60°').onClick(() => {
        this.shots.push({ id: 'F-009', yaw: 65, quality: .78 });
        this.refreshSummary();
      })
    }.padding(20).width('100%')
  }
}

这里每一次点击都是人工制造的演示事件。初始数据写在 shots 中,变动后由 refreshSummary() 汇总为四个可观察字段。正式项目若从异步采集模块获取数据,需要额外核对会话号与帧 ID,防止旧任务回调把新会话的进度覆盖;同时应对重复帧 ID 建立幂等规则。Map 记录的是角度桶,不负责永久保存图片路径。

四、如何检查数字没有“看起来对”

一套不依赖真机的校验表应该至少覆盖四组输入:正常的多角度记录、连续相近角度、负角与大于一圈的角度、无效质量值。第一组期望产生七个扇区;第二组要显示帧数增加但覆盖扇区不增加;第三组检验归一化;第四组不应计入 accepted。再对 chooseAdvice() 传入九格以上的报告,检查它仅进入人工质量复核,而不是直接断言建模成功。

示例配图中的底部日志 OC-091 | frames=8 | occupied=7/12 | 58.3% 与 recommend=60°,是按上述演示数组计算出的预期日志样本,并不是从 HiLog 实际抓取。出现差异时先打印帧 ID、原始角度、归一化角度和格号,不要一上来修改 UI 百分比。若角度单位误用弧度,所有帧都会异常集中在第零格;若把 accepted 当成 occupied,进度又会虚高。

手机配图展示的是未模拟补拍之前的状态:F-001~F-008,任务 OC-091,覆盖 7/12、58.3%,建议 60°,业务状态 NEED_MORE_ANGLES。图中的环形分段是抽象示意,不能反推出真实的相机轨迹或空间定位精度。

五、应当在何处停止使用这个分数

角度覆盖是一个廉价且直观的辅助提示,但它只描述一维水平方向的离散分布。3DGS 对输入的可用性远比它复杂:视差、景物尺度、运动模糊、纹理质量、曝光变化、遮挡、镜面反射和相机轨迹可靠性都不能靠十二个格子表达。对高反光商品、透明展品或狭窄室内空间,即使显示 100%,也可能需要完全不同的采集策略。

此外,本文的 quality 字段没有接入任何系统级图像质量算法;真实工程必须说明它由人工评估、机内元数据还是某个明确的检测模型提供,不应默默把模型输出当作官方质量值。对三维模型导出、格式兼容、场景加载和渲染,应使用 Spatial Recon Kit 已公开并适合目标设备的正式能力,本文没有调用或假设不存在的接口。

另外要把采集视角和拍摄顺序分开处理。操作员先拍正面,再绕到背面,中途可能临时退回前一个机位补一张细节。画面上的扇区分布不应依赖帧到达时间;如果异步导入先返回了后拍摄的图片,也不能把排序差异误当成轨迹异常。建议数据层同时保存帧 ID、采集时间、朝向来源和会话号,UI 只消费统一报告。删除帧后需要从剩余帧重新计算扇区,不能靠对覆盖数做减一操作,因为某一格可能还留着第二张合格照片。对于用户手动输入的朝向,界面应标注“估算值”,不要显示成设备姿态传感器给出的精确结果。

因此这套 Demo 的合理位置,是采集引导的前置辅助页,不是重建完成率,也不是成功率预测器。它能回答“目前哪些方位没有形成覆盖证据”,却不能回答“重建必定成功吗”。做到这一层,已经足以避免把一堆重复正面照片误当成合格素材,并为后续真实 SDK 集成留下清楚的责任边界。

参考资料:

  • 华为开发者《3DGS 端侧重建流程》:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/spatial-recon-c-spatial-recon-pipeline
  • 华为开发者《空间计算能力》:https://developer.huawei.com/consumer/cn/features/spatialization
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐