【共创稿事节】HarmonyOS 7重建结果质量评估:SSIM/PSNR 端侧轻量实现
重建结果质量评估: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 以内。
下面这张图把端侧评估的判定链路串起来。从重建结果到最终入库还是重采,每一步都有明确输入输出。
图里从渲染到阈值比较是评估主链路。低于阈值的分支回到采集环节,做一次质量兜底。
约束面:评估耗时与内存
端侧评估不能比重建本身还慢,否则没意义。我们在麒麟 9030 上测了不同 holdout 帧数下的耗时和内存。
| holdout 帧数 | 评估耗时 | 内存峰值 | SSIM 数值稳定性 |
|---|---|---|---|
| 5 | 380 ms | 42 MB | ±0.018 |
| 10 | 720 ms | 48 MB | ±0.011 |
| 15 | 1080 ms | 56 MB | ±0.008 |
| 20 | 1450 ms | 64 MB | ±0.006 |
| 30 | 2310 ms | 78 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 与人工评分
| 场景 | SSIM | PSNR (dB) | 人工评分均值 | 评估耗时 |
|---|---|---|---|---|
| 陶瓷茶壶 | 0.91 | 31.2 | 4.3 | 680 ms |
| 毛绒玩具 | 0.88 | 29.8 | 4.0 | 710 ms |
| 皮鞋 | 0.86 | 28.5 | 3.8 | 695 ms |
| 玻璃杯 | 0.62 | 22.1 | 2.1 | 720 ms |
| 镜面金属件 | 0.58 | 21.3 | 1.9 | 730 ms |
| 木雕 | 0.89 | 30.1 | 4.1 | 685 ms |
| 布艺沙发 | 0.84 | 27.9 | 3.6 | 705 ms |
| 绿植盆栽 | 0.79 | 26.4 | 3.2 | 700 ms |
| 强光下石雕 | 0.81 | 27.1 | 3.4 | 690 ms |
| 弱光下手办 | 0.73 | 25.0 | 2.8 | 715 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 方案更可靠
更多推荐



所有评论(0)