重建结果质量评估:SSIM/PSNR 端侧轻量实现

3DGS 重建跑完了,给用户看之前怎么知道这次重建好不好?我们一开始全靠肉眼——重建工程师逐个看,觉得糊就重拍。问题是肉眼判断主观、慢、且不可复现。同一组重建结果两个工程师一个说合格一个说不合格,评审会开成吵架会。我们需要一个端侧能跑的量化指标,重建完立刻给个分数,分数低于阈值自动建议重拍。这篇文章记录我们把 SSIM/PSNR 做端侧轻量实现的过程,以及在没有 ground truth 时怎么绕过"没有参考图"这个根本困难。

能力面:端侧轻量 SSIM/PSNR

SSIM(结构相似性)和 PSNR(峰值信噪比)是图像质量评估的经典指标。常规用法是:有原图 I 和失真图 K,按公式算出一个 0-1 的相似度分数。问题是 3DGS 重建结果没有 ground truth——你拍了一段视频重建出 3D 场景,没有"原始 3D 场景"可以对比。

我们的解法是 从采集视频里留出 holdout 帧当参考。采集 60 帧视频,重建时用 50 帧训练,留 10 帧不参与训练。重建完成后,把这 10 帧对应的视角用 3DGS 渲染出来,和真实采集的 10 帧对比算 SSIM/PSNR。这 10 帧重建结果没见过,相当于"考试题",分数反映重建的泛化能力。

// entry/src/main/ets/quality/QualityAssessor.ets
import { image } from '@kit.ImageKit';
import { spatialRender } from '@kit.SpatialReconKit';

export interface QualityResult {
  ssim: number;       // 0-1,越大越好,>0.85 我们认为可用
  psnr: number;       // dB,越大越好,>28 dB 我们认为可用
  assessTime: number; // 评估耗时 ms
  holdoutCount: number;
}

export class QualityAssessor {
  /**
   * 端侧轻量 SSIM/PSNR 评估
   * @param gsNode 重建得到的 3DGS 节点
   * @param holdoutFrames 留出的参考帧(视角 + 真实采集图)
   */
  static async assess(
    gsNode: spatialRender.GSNode,
    holdoutFrames: HoldoutFrame[]
  ): Promise<QualityResult> {
    const t0: number = Date.now();
    let ssimSum: number = 0;
    let psnrSum: number = 0;
    let validCount: number = 0;

    for (const frame of holdoutFrames) {
      // 1. 用 3DGS 在 holdout 视角下渲染出图
      const rendered: image.PixelMap = await gsNode.renderToImage({
        camera: frame.camera,
        width: 512,   // 降采样到 512 加速评估,原采集 1080p
        height: 512
      });
      // 2. 把真实采集帧也缩到同尺寸
      const reference: image.PixelMap = await downsample(frame.pixelMap, 512, 512);
      // 3. 算 SSIM 和 PSNR
      const ssim: number = this.computeSSIM(rendered, reference);
      const psnr: number = this.computePSNR(rendered, reference);
      if (ssim > 0 && psnr > 0) {
        ssimSum += ssim;
        psnrSum += psnr;
        validCount++;
      }
      rendered.release();
      reference.release();
    }

    const assessTime: number = Date.now() - t0;
    return {
      ssim: validCount > 0 ? ssimSum / validCount : 0,
      psnr: validCount > 0 ? psnrSum / validCount : 0,
      assessTime,
      holdoutCount: holdoutFrames.length
    };
  }

  // SSIM 轻量实现:分块计算,11x11 高斯窗口简化为 8x8 均值窗口
  private static computeSSIM(a: image.PixelMap, b: image.PixelMap): number {
    // 转灰度
    const grayA: Uint8Array = toGray(a);
    const grayB: Uint8Array = toGray(b);
    const w: number = 512;
    const h: number = 512;
    const blockSize: number = 8;
    let ssimSum: number = 0;
    let blockCount: number = 0;

    for (let y: number = 0; y < h - blockSize; y += blockSize) {
      for (let x: number = 0; x < w - blockSize; x += blockSize) {
        const statsA = blockStats(grayA, w, x, y, blockSize);
        const statsB = blockStats(grayB, w, x, y, blockSize);
        const covAB: number = blockCov(grayA, grayB, w, x, y, blockSize,
          statsA.mean, statsB.mean);
        // SSIM 常数,按 8bit 图标准取值
        const c1: number = (0.01 * 255) ** 2;
        const c2: number = (0.03 * 255) ** 2;
        const ssim: number = ((2 * statsA.mean * statsB.mean + c1) *
          (2 * covAB + c2)) /
          ((statsA.mean ** 2 + statsB.mean ** 2 + c1) *
            (statsA.var + statsB.var + c2));
        ssimSum += ssim;
        blockCount++;
      }
    }
    return blockCount > 0 ? ssimSum / blockCount : 0;
  }

  // PSNR:MSE 越小 PSNR 越大
  private static computePSNR(a: image.PixelMap, b: image.PixelMap): number {
    const dataA: Uint8Array = toGray(a);
    const dataB: Uint8Array = toGray(b);
    let mse: number = 0;
    for (let i: number = 0; i < dataA.length; i++) {
      const diff: number = dataA[i] - dataB[i];
      mse += diff * diff;
    }
    mse /= dataA.length;
    if (mse === 0) {
      return 100; // 完全相同
    }
    return 10 * Math.log10((255 * 255) / mse);
  }
}

实现上有几处简化:窗口从 11x11 高斯改成 8x8 均值,标准 SSIM 用 11x11 高斯窗口滑窗计算,端侧算力吃不消。8x8 块均值版本精度略低,但和标准 SSIM 的相关系数在我们测的 10 组数据上达到 0.94,够用。渲染分辨率降到 512x512,原采集 1080p 直接算要 4 倍耗时,512 下 SSIM 数值变化在 0.01 以内。

下面这张图把端侧评估的判定链路串起来。从重建结果到最终入库还是重采,每一步都有明确输入输出。

达标

不达标

重建完成得到 3DGS 结果

从采集视频留出 holdout 帧当参考

在 holdout 视角渲染出图

渲染图与真实帧统一降到 512 尺寸

分块计算 SSIM 并算 PSNR

多帧求均值得到质量分数

SSIM 大于 0.75 且 PSNR 大于 26

结果入库并展示

按 SSIM 区间给具体重采建议

触发重新采集或补拍

图里从渲染到阈值比较是评估主链路。低于阈值的分支回到采集环节,做一次质量兜底。

约束面:评估耗时与内存

端侧评估不能比重建本身还慢,否则没意义。我们在麒麟 9030 上测了不同 holdout 帧数下的耗时和内存。

holdout 帧数评估耗时内存峰值SSIM 数值稳定性
5380 ms42 MB±0.018
10720 ms48 MB±0.011
151080 ms56 MB±0.008
201450 ms64 MB±0.006
302310 ms78 MB±0.005

我们选了 10 帧。5 帧稳定性差,30 帧太慢且内存逼近重建本身的占用。10 帧在 720 ms 内完成,用户感知是"重建完稍等一下分数就出来了"。

内存峰值 48 MB 看着不大,但要叠加在重建过程的内存上。重建峰值已经 800 MB 左右,再加 48 MB 不会触发 OOM,但如果重建结果没释放就启动评估,峰值会到 880 MB,在 8 GB 设备上危险。我们的做法是重建完先释放中间张量,再启动评估。

场景落地:10 组重建结果评估

我们采集了 10 组不同场景的视频,每组用 50 帧训练、10 帧 holdout,重建后做端侧评估。同时请 3 位重建工程师盲打分(1-5 分)做人工对照。

真机数据:SSIM/PSNR 与人工评分

场景SSIMPSNR (dB)人工评分均值评估耗时
陶瓷茶壶0.9131.24.3680 ms
毛绒玩具0.8829.84.0710 ms
皮鞋0.8628.53.8695 ms
玻璃杯0.6222.12.1720 ms
镜面金属件0.5821.31.9730 ms
木雕0.8930.14.1685 ms
布艺沙发0.8427.93.6705 ms
绿植盆栽0.7926.43.2700 ms
强光下石雕0.8127.13.4690 ms
弱光下手办0.7325.02.8715 ms

SSIM 与人工评分的 Pearson 相关系数 0.93,PSNR 与人工评分 0.88。SSIM 比 PSNR 更贴合人感,这和文献一致——PSNR 对像素误差敏感,但人眼对结构误差更敏感,SSIM 抓的就是结构。

玻璃杯和镜面金属件两组数据是失败 case。SSIM 0.62、0.58,人工评分 2.1、1.9,都低。这两组是 3DGS 重建的已知弱项:透明和反光物体采集时多视角不一致,高斯点拟合不了反射光路。SSIM 准确地把这两组标了出来,我们据此把阈值定在 0.75——低于 0.75 自动建议重拍。

踩坑与取舍

坑一:SSIM 需要 reference 图,但重建结果没有参考

这是开头说的根本困难。holdout 帧方案能跑,但有个隐含假设:holdout 帧和训练帧视角分布接近。如果用户采集时只拍了正面,holdout 留的也是正面,SSIM 会偏高——正面重建容易,侧面才是难点。

我们试过强制 holdout 留侧面帧,但用户采集路径不可控,有时侧面帧本身就模糊。最后改成按视角均匀采样 holdout:把 60 帧按相机方位角分到 6 个桶,每桶留 1-2 帧做 holdout。这样 holdout 覆盖各视角,SSIM 能反映整体重建质量。

坑二:PSNR 对结构误差不敏感

PSNR 算的是像素级 MSE。重建结果如果整体偏了一像素(几何漂移),人眼看是错的,但 PSNR 可能不算太低——因为偏移后像素值差异不大。我们有一组木雕数据,PSNR 30.1 看着不错,但人工评分只有 4.1,原因是纹理对但轮廓偏了。

结论是 PSNR 只能当辅助指标,主判据用 SSIM。我们最终阈值是 SSIM > 0.75 且 PSNR > 26 dB,双指标都过才算合格。单看 PSNR 会放过几何漂移的 case。

坑三:弱光场景 SSIM 失真

弱光下手办那组 SSIM 0.73,人工评分 2.8。看起来 SSIM 准确反映了"质量不好"。但深入看发现 SSIM 偏低部分原因是采集帧本身噪声大,holdout 帧和渲染帧比,渲染帧反而更干净(3DGS 重建有降噪效果),SSIM 把"降噪后的干净"和"带噪的真实"比,分数被噪声拉低。

这意味着弱光场景下 SSIM 会低估重建质量。我们的处理是弱光场景(采集平均亮度 < 50)单独校准阈值,从 0.75 降到 0.68。这是个 hack,但比"弱光场景全判不合格"合理。

坑四:被放弃的方案——无参考质量评估(NRQA)

我们试过完全不需要 holdout 的无参考质量评估方案,用端侧小模型推理给个分数。模型用 BRISQUE 思路训练,2 MB 大小。问题是模型在训练分布外场景表现崩塌——训练集是自然图像,碰到 3DGS 渲染图(高斯点叠加出的图)分数完全不可信。陶瓷茶壶重建很好,模型给 1.8 分;玻璃杯重建很差,模型给 4.2 分。

这个方案被放弃了。无参考评估在没有 3DGS 渲染图训练集的前提下不可用,而构建这样一个训练集成本太高。holdout 帧方案虽然要多留几帧,但可靠。

评估结果怎么用

光算出分数不够,要把分数接到产品流程里。

// entry/src/main/ets/quality/QualityGate.ets
export class QualityGate {
  private static readonly SSIM_THRESHOLD: number = 0.75;
  private static readonly PSNR_THRESHOLD: number = 26;
  // 弱光场景阈值放宽
  private static readonly SSIM_THRESHOLD_LOWLIGHT: number = 0.68;

  static evaluate(result: QualityResult, isLowLight: boolean): QualityVerdict {
    const ssimThreshold: number = isLowLight
      ? this.SSIM_THRESHOLD_LOWLIGHT
      : this.SSIM_THRESHOLD;

    if (result.ssim >= ssimThreshold && result.psnr >= this.PSNR_THRESHOLD) {
      return { pass: true, score: result.ssim, suggestion: '' };
    }
    // 不通过时给具体建议,而不是笼统说"质量差"
    let suggestion: string;
    if (result.ssim < 0.6) {
      suggestion = '重建质量较差,建议重新采集,注意覆盖多角度且避免反光';
    } else if (result.ssim < ssimThreshold) {
      suggestion = '部分视角重建模糊,建议补拍侧面和背面';
    } else {
      suggestion = '几何精度不足,建议采集时放慢移动速度';
    }
    return { pass: false, score: result.ssim, suggestion };
  }
}

suggestion 不是笼统的"质量差请重拍",而是按 SSIM 区间给具体建议。SSIM < 0.6 是整体崩了,建议重采;0.6-0.75 是部分视角糊,建议补拍;SSIM 过了但 PSNR 没过是几何漂移,建议放慢移动速度。用户看到具体建议才知道怎么改,"质量差"三个字对用户没操作指导意义。

总结一下下

  • holdout 帧按视角均匀采样,不能只留正面帧,否则 SSIM 偏高不反映整体质量
  • SSIM 用 8x8 块均值窗口替代 11x11 高斯窗口,精度损失 0.01 以内,耗时减半
  • 评估分辨率降到 512x512,原 1080p 算 SSIM 耗时 4 倍,数值变化 0.01 以内
  • 主判据用 SSIM,PSNR 仅辅助,PSNR 对几何漂移不敏感,单看会放过结构错误
  • 弱光场景 SSIM 阈值从 0.75 降到 0.68,弱光下 SSIM 会因采集噪声低估重建质量
  • 评估前先释放重建中间张量,避免内存峰值叠加触发 OOM
  • holdout 帧数选 10,5 帧稳定性差,30 帧耗时超过 2 秒用户感知等待
  • 无参考质量评估在 3DGS 场景不可用,训练集分布外分数崩塌,holdout 方案更可靠
Logo

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

更多推荐