鸿蒙HarmonyOS 7 图像超分·检验科手记:把七张病历截图当实验数据,复算盲指标、校准阈值、建立回归基准
鸿蒙HarmonyOS 7 图像超分·检验科手记:把七张病历截图当实验数据,复算盲指标、校准阈值、建立回归基准
画质诊所(
Image-Super-Resolution/sr-clinic)把图像超分做成了一次「就诊」:挂号、初诊、处方、治疗、复诊。用户不用调参数,引擎自己量、自己决定、自己解释——本篇关心的则是这套流程背后,数字本身可信到什么程度。同一批七张实机截图,不看流程,只看数字。把屏幕上每一个读数抄下来,当作一组需要复算的实验数据,然后追问三个常被略过的问题:四项盲指标的公式经得起推敲吗?处方阈值在这批样本上真的被触发过吗?治疗前后的数字能不能放在同一把尺子上比?
结论会有点意外:三份病历的处方其实完全一样,规则引擎里有两个分支从未被激活;噪声读数上的
1.26系数与作用对象并不匹配;而「清晰度暴跌 98%」不是治坏了,是量纲错了。全文最后把这些发现固化成一套能在 CI 里复跑的回归基准。文中配图均为该应用在 Mate 90 Pro 模拟器上的实机运行截图。
一、重新读这七张图:把截图当数据源
1. 就诊环境:先锁定实验条件

第一张是工程与模拟器全景。它在本篇里的作用不是展示界面,而是锁定实验口径:HarmonyOS 7 / API 26、Mate 90 Pro 模拟器、Native C++ 单线程 CPU、180×180 低清图入诊、360×360 出院。后面所有数字都只在这个口径下成立,换设备、换线程数、换输入尺寸都要重新标定。
工程本体是 Image-Super-Resolution/sr-clinic,Native 侧只暴露两个接口:diagnose(rgba, w, h) 与 treat(...)。本篇的全部复算都围绕这两个接口的输入输出展开。
2. 雪山初诊:四个数字,四种量纲

第二张信息密度最高。雪山日出的初诊读数:清晰度 1394.4、噪声 29.9、动态范围 173/255、边缘密度 25.1%,右侧是自动处方。四项指标各配一条体温计式刻度条——刻度条只告诉你「高不高」,没告诉你「单位是什么」。这正是本篇要补的部分:四个数字的量纲彼此不同,其中两个还不能直接跟治疗后的同名数字比。
3. 雪山疗效报告

治疗 56ms,噪声 29.9 → 4.0,左右并排 360×360。这张图同时藏着那个刺眼的对照:清晰度 1394 → 27。第四章会用严格推导说明它不是疗效,是尺子换了。
4. 老城初诊:全场最"该治"的一张

噪声全场最高 33.7,动态范围却只有 109/255——高噪低反差的典型样本。夜景灯光复杂、暗部大片平坦,是三份病历里最考验去噪的一张。
5. 老城疗效报告

噪声 33.7 → 2.9,全场最大降噪幅度,56ms。
6. 翠鸟初诊:唯一被注明"免除"的一项

噪声 23.5,边缘密度 18.6%(正常,处方免去边缘增强)。微距水珠密布,是三张里结构最细的一张。
7. 翠鸟疗效报告

噪声 23.5 → 3.1,51ms。
把七张图抄成一张表
| 病历 | 清晰度(初诊) | 噪声(初诊) | 动态范围 | 边缘密度 | 噪声(疗后) | 耗时 | 处方要点 |
|---|---|---|---|---|---|---|---|
| 雪山日出 | 1394.4 | 29.9 | 173/255 | 25.1% | 4.0 | 56ms | 去噪 + USM 0.50 |
| 老城黄昏 | — | 33.7 | 109/255 | — | 2.9 | 56ms | 去噪 + USM 0.50 |
| 花间翠鸟 | — | 23.5 | — | 18.6% | 3.1 | 51ms | 去噪 + USM 0.50,免边缘增强 |
— 表示截图里没有可读到的读数,不是零,也不是"没测"。这个区分在后文建回归基准时很关键:golden 数据只能锚定确实能读到的字段,其余字段要靠重新运行引擎补齐,不能靠猜。
处方要点一栏已经透出一件怪事:USM 强度三行全是 0.50。按规则的设计意图,「锐度越低,USM 越强」应该是一条曲线,可三条曲线值一模一样。第三章会把这个巧合拆开。
二、四项盲指标:把公式重新推一遍
盲指标(no-reference metric)的共同前提是:不允许有参考答案。所有结论必须从图像自身的统计量里得出。诊所引擎内置四项指标,本节逐一补上它们各自的量纲、成立条件,以及一处值得修正的系数。
1. 清晰度:拉普拉斯方差,单位是「灰度平方」
对灰度图做四邻域拉普拉斯:
[
L(x,y)=4I(x,y)-I(x-1,y)-I(x+1,y)-I(x,y-1)-I(x,y+1)
]
然后取 ( \operatorname{Var}(L)=E[L2]-E[L]2 )。由于自然图像的 (E[L]) 接近 0,实现里直接用 sumSq / n 近似。它的单位是 灰度级²,所以 1394.4 意味着拉普拉斯响应的均方根约 37 个灰度级——这是相当锐利的一张图。
要注意它不是「锐度百分比」,也没有上界。把它画成一条 0~100% 的体温计条是 UI 的归一化,不是指标本身的性质。
2. 噪声:1.26 这个系数到底从哪来
引擎实现写的是 noise = 1.26 × mean|lap|,并称之为「经典 MAD 法高斯噪声估计」。把这句话拆开复算,会发现系数与作用对象对不上。
设图像噪声为独立同分布的高斯 (n \sim N(0,\sigma^2)),拉普拉斯核为
[
K=\begin{bmatrix}0&-1&0\-1&4&-1\0&-1&0\end{bmatrix}
]
则 (L = K*n) 仍是零均值高斯,方差为
[
\operatorname{Var}(L)=\sigma^2\sum K_i2=\sigma2(4\times 1 + 16)=20\sigma^2
]
对零均值高斯有 (E|Z|=\sqrt{2/\pi},\operatorname{std}(Z)),于是
[
E|L| = 0.7979 \times \sqrt{20},\sigma \approx 3.568,\sigma
\quad\Longrightarrow\quad
\sigma \approx 0.2803 \times E|L|
]
而 1.2533 = 1/0.7979 是另一个公式的系数:如果我们手上直接就是高斯样本 (n) 本身(而不是拉普拉斯响应),那么 (\sigma = 1.2533 \times E|n|)。这就是 1.26 的真实出处——它把「从 (E|n|) 反推 (\sigma)」误用到了「从 (E|L|) 反推 (\sigma)」。
两者相差正好一个 (\sqrt{20}\approx 4.472) 因子。换句话说,当前读数与真实噪声标准差的关系是:
[
\text{读数} = 1.26 \times E|L| \approx 1.26 \times 3.568,\sigma \approx 4.49,\sigma
]
读数被放大了约 4.5 倍。这解释了截图里为什么噪声是 23.5~33.7 这么"大"的数字——一张 8bit 图的真实高斯噪声 (\sigma) 通常在个位数。
用一段十几行的蒙特卡洛就能把两个系数同时验出来,建议任何改这个常量的人先跑一遍:
import random, math
N = 300000
random.seed(7)
# A. 直接对高斯样本:sigma = 1.2533 * E|n|
m1 = sum(abs(random.gauss(0, 1)) for _ in range(N)) / N
print('E|n|/sigma =', round(m1, 4), ' -> 反推系数', round(1 / m1, 4))
# B. 对四邻域拉普拉斯响应:sigma = mean|lap| / 3.568
def lap_sample():
c = random.gauss(0, 1)
return 4 * c - sum(random.gauss(0, 1) for _ in range(4))
mL = sum(abs(lap_sample()) for _ in range(N)) / N
print('E|lap|/sigma =', round(mL, 4), ' -> 理论', round(0.7979 * math.sqrt(20), 4))
print('当前系数 1.26 等效放大倍数 =', round(1.26 * mL, 4))
理论输出应为 0.7979 / 1.2533、3.5682、4.4960。
要不要改? 分两层看:
- 作为相对排序的分数,放大 4.5 倍不影响任何判断——三张图的先后顺序、阈值是否触发都不变(阈值同比例放大即可),这是产品没出问题的原因;
- 作为标注为「噪声」的物理量,它是错的。
29.9不是 (\sigma=29.9),而是 (\sigma\approx 6.6)。一旦有人拿这个数去和别的库的噪声估计对比、或者写进论文表格,就会出错。
最小修正有两种,二选一即可:
// 方案 A:把读数改成真实的 sigma(推荐,语义最干净)
out->noise = sumAbs / n / (0.7979 * std::sqrt(20.0)); // ≈ 0.2803 * mean|lap|
// 方案 B:保留现有读数但改名字 + 改文档,明确它是"任意单位的高频残差指数"
out->hfIndex = 1.26 * sumAbs / n; // 不再叫 noise,阈值 6.0 随之复查
方案 A 之后,第三章会指出一个连锁后果:噪声阈值 6.0 必须重新标定,因为原来的 23.5~33.7 会变成 5.2~7.5,最干净的一张已经贴到阈值边缘了。
3. 动态范围:5%~95% 分位差
用灰度直方图的 95% 分位减 5% 分位,而不是 max - min。理由是抗离群:单个过曝点或死点就能把极值法打到 255,分位数对 5% 以内的异常完全免疫。
代价是它会系统性低估有长尾分布的图:雪山日出大片亮天空 + 暗山体本来跨得很开,5%~95% 分位差给到 173/255 是合理的;老城黄昏暗部占绝大多数,109/255 就明显偏窄。这两个数字放在一起,恰好构成本批样本里「反差最大 / 最小」的两极。
分位数实现只要一次 256 桶扫描,不需要排序:
static int Percentile(const int* hist, int total, double q) {
double target = q * total;
int acc = 0;
for (int v = 0; v < 256; ++v) {
acc += hist[v];
if (acc >= target) return v;
}
return 255;
}
4. 边缘密度:Sobel 阈值 120 的标定
Sobel 幅值 (\sqrt{g_x2+g_y2} > 120) 记为一个强边缘像素,占比即边缘密度。阈值 120 是经验值,标定时要考虑一件反直觉的事:纯噪声图上也会偶发过阈。因为 (\sqrt{20}\sigma) 量级的拉普拉斯噪声经过 Sobel((\sum K^2=12) 量级)也会被放大。所以 120 实际上是「肉眼可辨结构」与「噪声尖峰」之间的分界,而不是「梯度不为零」。
这也意味着 §2.2 的噪声读数如果修正,120 这个阈值应该顺带复查一遍——它俩共用同一个高频假设。
5. 一次遍历的融合实现
四项指标共用同一个 3×3 邻域窗口,取数一次、四项全部算出。180×180 全图诊断因此能压在 5ms 内:
struct DiagReport {
double sharpness; // 拉普拉斯方差,灰度²
double noise; // 噪声估计,见 §2.2 的量纲讨论
double range; // 5%~95% 分位差,灰度级
double edges; // 强 Sobel 像素占比,0..1
};
static inline double GrayAt(const std::vector<uint8_t>& img, int w, int x, int y) {
const uint8_t* p = &img[(static_cast<size_t>(y) * w + x) * 4];
return 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2];
}
void Diagnose(const std::vector<uint8_t>& img, int w, int h, DiagReport* out) {
if (w < 3 || h < 3 || img.size() != static_cast<size_t>(w) * h * 4) return;
int hist[256] = {0};
double sumAbs = 0.0, sumSq = 0.0;
int edgeCount = 0, n = 0;
for (int y = 1; y < h - 1; ++y) {
for (int x = 1; x < w - 1; ++x) {
double g[3][3];
for (int dy = -1; dy <= 1; ++dy)
for (int dx = -1; dx <= 1; ++dx)
g[dy + 1][dx + 1] = GrayAt(img, w, x + dx, y + dy);
double c = g[1][1];
double lap = 4.0 * c - g[1][0] - g[1][2] - g[0][1] - g[2][1];
sumAbs += std::fabs(lap);
sumSq += lap * lap;
double gx = -g[0][0] + g[0][2] - 2.0 * g[1][0] + 2.0 * g[1][2]
- g[2][0] + g[2][2];
double gy = -g[0][0] - 2.0 * g[0][1] - g[0][2]
+ g[2][0] + 2.0 * g[2][1] + g[2][2];
if (std::sqrt(gx * gx + gy * gy) > 120.0) edgeCount++;
hist[ClampInt(static_cast<int>(c + 0.5), 0, 255)]++;
n++;
}
}
out->sharpness = sumSq / n;
out->noise = 1.26 * sumAbs / n; // 见 §2.2:系数需修
out->range = Percentile(hist, n, 0.95) - Percentile(hist, n, 0.05);
out->edges = static_cast<double>(edgeCount) / n;
}
三个容易踩的实现细节:边界从 1 到 h-2 遍历,避免额外判越界;直方图桶用 (int)(c+0.5) 而不是截断,否则整体偏暗一格;n 是有效像素数而不是 w*h,分母必须跟着边界收缩走,否则四项指标会一起被系统性压低。
三、把阈值放到真实数据上:两个从未被激活的分支
把处方规则放在 ArkTS 层(改规则不用重编 so)是一个正确的分层决定。但把截图里的读数逐条代进去,会看到一张规则表在实测域上几乎退化成固定流水线。
先把原规则抄回来:
export function prescribe(d: DiagReport): Prescription {
const denoise = d.noise > 6.0;
const sharpen = Math.min(1.5,
Math.max(0.5, 0.5 + (30 - Math.min(d.sharpness, 30)) / 30 * 1.0));
const edgeBoost = d.edges < 0.04;
return { denoise, factor: 2, sharpen, edgeBoost, notes };
}
发现一:Math.min(d.sharpness, 30) 让 USM 曲线变成常数
雪山初诊的清晰度是 1394.4。代入:
[
\min(1394.4,\ 30)=30
;\Longrightarrow;
0.5 + \frac{30-30}{30}\times 1.0 = 0.5
]
也就是说,任何 sharpness >= 30 的图像都拿到 0.50。而 180×180 自然图的拉普拉斯方差通常在几百到几千量级,30 这个截断上限比实测值低了约 46 倍。三份病历的「USM 0.50」不是曲线算出来的,是 clamp 钳出来的。
这条规则的意图(越模糊越用力)本身没错,错在阈值用的是绝对量纲,而输入是无上界的灰度²。要让曲线真的活起来,得先把输入压到有界区间:
function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : (v > hi ? hi : v);
}
// 对数压缩:1394 -> 3.14,27 -> 1.43,无上界量变成 0~4 的刻度
const sharpNorm = Math.log10(1 + d.sharpness);
const sharpen = clamp(0.5 + (3.2 - sharpNorm) * 0.6, 0.5, 1.5);
// 雪山 3.14 -> 0.54;若某图模糊到 1.43 -> 1.50,曲线开始有区分度
发现二:edges < 0.04 在可读样本上必然关闭
edgeBoost 的阈值是 4%,而截图读到的边缘密度是雪山 25.1%、翠鸟 18.6%,分别高出 6.3 倍与 4.7 倍。处方笺只对翠鸟明写了「免去边缘增强」,但从读数看雪山同样不会触发——至少这两张上该分支是死的(老城的边缘密度截图未抄录,暂不下结论)。
发现三:噪声阈值 6.0 的安全边际,会在修正系数后消失
当前读数下,三个样本 23.5 / 29.9 / 33.7 全部远超 6.0,最小的一个也是阈值的 3.9 倍——去噪分支「总是开」,规则同样没有区分度。
但 §2.2 的修正会连锁改变这件事。把读数除以 4.49 反算真实 (\sigma):
| 病历 | 截图读数 | 反算 (\sigma) | 当前阈值 6.0 | 判定 |
|---|---|---|---|---|
| 雪山日出 | 29.9 | 6.66 | 6.0 | 仍触发 |
| 老城黄昏 | 33.7 | 7.51 | 6.0 | 仍触发 |
| 花间翠鸟 | 23.5 | 5.23 | 6.0 | 不再触发 |
只改一个常量,就会让最干净的一张突然跳过去噪。 这正是「读数必须带正确量纲」的现实代价:阈值不是独立参数,它与它作用的量绑在一起。任何改系数的 PR 都必须同时重跑阈值标定,否则回归基准会一次抓出三处变化,却没人知道哪处是有意的。
敏感性分析:阈值动一倍,处方动多少
| 阈值 | 当前读数域 | 修正后 (\sigma) 域 |
|---|---|---|
噪声 6.0 | 3/3 去噪 | 2/3 去噪 |
噪声 3.0(÷2) | 3/3 去噪 | 3/3 去噪 |
噪声 12.0(×2) | 3/3 去噪 | 0/3 去噪 |
边缘 0.04 | 0/2 增强 | 0/2 增强 |
锐度截断 30 | 0/3 触发补偿 | 0/3 触发补偿 |
结论很清楚:在当前读数域里,规则引擎的三条判断有两条是常数、一条是恒真。产品之所以没出问题,是因为这三张样本都恰好"该去噪 + 该做基础锐化";但把这套规则拿到一张干净图上,它会照样去噪、照样不增强——它其实没有在"诊断",只是在执行固定配方。
把死分支变成 CI 能抓到的技术债
与其靠人肉复查,不如给规则表加一个分支覆盖探针,并在回归基准里断言每个分支至少被命中一次:
export class RuleCoverage {
denoiseTrue: number = 0;
denoiseFalse: number = 0;
sharpenAboveBase: number = 0;
sharpenAtBase: number = 0;
edgeBoostTrue: number = 0;
edgeBoostFalse: number = 0;
observe(p: Prescription): void {
if (p.denoise) this.denoiseTrue++; else this.denoiseFalse++;
if (p.sharpen > 0.5001) this.sharpenAboveBase++; else this.sharpenAtBase++;
if (p.edgeBoost) this.edgeBoostTrue++; else this.edgeBoostFalse++;
}
deadBranches(): string[] {
const dead: string[] = [];
if (this.denoiseFalse === 0) dead.push('denoise=false 从未命中');
if (this.denoiseTrue === 0) dead.push('denoise=true 从未命中');
if (this.sharpenAboveBase === 0) dead.push('USM>0.50 从未命中:锐度补偿是死代码');
if (this.edgeBoostTrue === 0) dead.push('edgeBoost=true 从未命中');
return dead;
}
}
关键点在于样本集要故意包含反例:一张干净图(低噪声)、一张极模糊图(低拉普拉斯方差)、一张大色块图(低边缘密度)。只用三张"都该去噪"的 AI 示例图跑回归,覆盖率永远是绿的,也永远发现不了死分支。这是第六章建 golden 表时最重要的一条设计约束。
四、尺子换了:1394 → 27 的严格拆解,以及一个被漏掉的同案

疗效报告上「清晰度 1394 → 27、暴跌 98%」不是治坏了,是尺度陷阱;但修复不能停留在「知道有坑」,要把正确的对照协议落到代码里。本节把那笔账算完,顺便指出同一张疗效报告里还藏着一个更隐蔽的同案。
1. 单纯 2× 双线性插值,拉普拉斯方差该衰减多少
设原图相邻像素差为 (\Delta = I(x+1,y)-I(x,y))。双线性插值放大 2 倍后,新插入的像素是相邻两者的平均,于是新图上相邻像素差约为 (\Delta/2)。
拉普拉斯是相邻差分的线性组合,因此 (L’ \approx L/2),方差按平方衰减:
[
\operatorname{Var}(L’) \approx \frac{1}{4}\operatorname{Var}(L)
]
所以只靠尺度效应,1394.4 应该落到 ≈348 附近,而不是 27。剩下的 348→27(再衰减约 13 倍)来自流水线本身:3×3 中值去噪把高频残差大面积抹平,USM 只按 0.50 的强度加回有限一部分。
也就是说,1394 → 27 是两个独立效应叠出来的复合数:
[
1394 \xrightarrow[\times 1/4]{\text{尺度}} 348
\xrightarrow[\approx \times 1/13]{\text{去噪 + 有限锐化回补}} 27
]
把任何一个单独拎出来当"疗效"都是错的。所以说它既不在同一把尺子上,也不在同一个处理链上。
2. 被漏掉的同案:噪声读数其实也跨尺度
一个自然的想法是「真正可比的是噪声,29.9 → 4.0 才是真疗效」。方向对,但严格说这一步也跨了尺度:初诊测的是 180×180,疗效报告测的是 360×360。
插值让相邻差减半,mean|lap| 大约随之减半。于是光是"把图放大"这一件事,就足以把 29.9 拉到 ≈15;从 15 再到 4.0 才是中值去噪与 USM 的净贡献。换句话说,「降噪 87%」里大约有一半是插值送的。
修正口径有两条,都比"直接比两个数"干净:
// 口径 A:把治疗结果降采样回入诊尺寸,在同一把尺子上量
std::vector<uint8_t> back(static_cast<size_t>(fw) * fh * 4);
DownsampleBox(recon, dw, dh, back, fw, fh);
DiagReport before, after;
Diagnose(low, fw, fh, &before);
Diagnose(back, fw, fh, &after); // 29.9 vs 这里才是可比的疗后值
// 口径 B:与同尺度双线性对照比,取"增益"而不是"绝对值"
DiagReport rc, cc;
Diagnose(recon, dw, dh, &rc);
Diagnose(ctrl, dw, dh, &cc);
double netDenoise = cc.noise - rc.noise; // 治疗相对"只放大"多降了多少
3. 四项指标的可比性矩阵
把尺度敏感性逐项过一遍,会得到一个挺有用的结论:
| 指标 | 定义 | 2× 插值后的行为 | 跨尺度可直接比? |
|---|---|---|---|
| 拉普拉斯方差 | 差分平方的均值 | 约 ÷4 | 否,需同尺度对照或 ÷factor² 归一 |
| 噪声读数 | (1.26\times E|L|) | 约 ÷2 | 否,需降采样回原尺寸或减去对照 |
| 动态范围 | 灰度 5%~95% 分位差 | 插值不产生新极值,基本不变 | 是(四项里唯一的尺度不变量) |
| 边缘密度 | Sobel 幅值 > 120 的占比 | 梯度减半 → 大量像素跌破固定阈值 | 否,阈值本身要随尺度重标定 |
四项盲指标里,只有动态范围天然可跨尺度。 这也是为什么疗效报告把动态范围写成 173/255 这种带单位的比值而不是百分比——它本来就是灰度级刻度上的绝对量,与图像尺寸无关。
以后设计疗效报告,最省事的做法是:所有指标都只在同尺度上比,动态范围可以额外给一个绝对值做参考。
4. 正确的复诊协议:同尺度对照 + 净增益
下面这段把「治疗路径」与「只放大不做别的对照路径」放在完全相同尺寸上,用同一把尺子量:
struct FollowUp {
double sharpRecon, sharpCtrl, sharpRatio; // 相对对照的锐度增益
double psnrRecon, psnrCtrl, netGain; // 有真值时的净收益
};
FollowUp ReCheck(const std::vector<uint8_t>& low, int fw, int fh,
const std::vector<uint8_t>& recon,
const std::vector<uint8_t>* groundTruth) {
int dw = fw * 2, dh = fh * 2;
std::vector<uint8_t> ctrl(static_cast<size_t>(dw) * dh * 4);
UpsampleBilinear(low, fw, fh, ctrl, dw, dh); // 对照:同样 360×360
DiagReport r, c;
Diagnose(recon, dw, dh, &r); // 两条路径共用 Diagnose
Diagnose(ctrl, dw, dh, &c); // 口径完全一致
FollowUp out;
out.sharpRecon = r.sharpness;
out.sharpCtrl = c.sharpness;
out.sharpRatio = c.sharpness > 0 ? r.sharpness / c.sharpness : 0.0;
if (groundTruth != nullptr) {
out.psnrRecon = ComputePsnr(recon, *groundTruth);
out.psnrCtrl = ComputePsnr(ctrl, *groundTruth);
out.netGain = out.psnrRecon - out.psnrCtrl;
}
return out;
}
sharpRatio 与 netGain 都是相对量:分子分母同尺度、同口径,尺度因子在相除时被消掉。这才是疗效报告该印的数字。PSNR 的实现很短,注意 MSE 要在 double 上累加,别用 8bit 差值相乘:
double ComputePsnr(const std::vector<uint8_t>& a,
const std::vector<uint8_t>& b) {
if (a.empty() || a.size() != b.size()) return 0.0;
double mse = 0.0;
for (size_t i = 0; i < a.size(); ++i) {
double d = static_cast<double>(a[i]) - static_cast<double>(b[i]);
mse += d * d;
}
mse /= static_cast<double>(a.size());
if (mse < 1e-9) return 99.0;
return 10.0 * std::log10(255.0 * 255.0 / mse);
}
至于常见的 ÷ factor² 归一化,它的适用面比想象中窄:
// 只修正插值带来的衰减,修不了"去噪主动抹掉高频"这件事
double NormalizeSharpness(double s, int factor) {
return s / static_cast<double>(factor * factor);
}
它假设「治疗前后结构不变、只是被摊薄」,可中值去噪恰恰改变了结构。所以归一化后的数字可以当作"尺度校正参考",不能拿来宣称疗效。
五、把结论固化成一套可 CI 复跑的回归基准
前面四章的发现如果只写在文章里,下一次改引擎时照样会丢。所以最后一步是把它们变成断言:一张从七张截图抄录的 golden 表、一个跑批 harness、一套分级容差,外加一条故意会让 CI 变红的覆盖率检查。
1. golden.csv:只锚定确实读到的字段
case,stage,sharpness,noise,range,edges,time_ms,denoise,usm,edge_boost
lake,initial,1394.4,29.9,173,0.251,,1,0.50,0
lake,treated,,4.0,,,56,1,0.50,0
town,initial,,33.7,109,,,1,0.50,0
town,treated,,2.9,,,56,1,0.50,0
bird,initial,,23.5,,0.186,,1,0.50,0
bird,treated,,3.1,,,51,1,0.50,0
空字段就是第一章那张表里的 —:不参与断言。跑批时会读回实际值写进 actual.csv,但不会因为"截图没抄到"而误判。这是整套基准最容易做错的一步——把未知当零断言,会得到一堆假红。
2. 断言分级:不是所有数字都配当断言
| 字段 | 断言方式 | 容差依据 |
|---|---|---|
| 四项读数 | 绝对 + 相对容差取大者 | 截图显示 1 位小数(±0.05),叠加浮点与像素舍入,取 max(0.6, 3%) |
处方(denoise / usm / edge_boost) | 精确相等 | 布尔与枚举,没有舍入空间;usm 容差 0.005 |
time_ms | 只记录,不断言 | 模拟器抖动大,见 §6 |
| 分支覆盖 | 必须全部命中,否则失败 | 把第三章的死分支变成显式技术债 |
3. C++ 跑批 harness
struct GoldenRow {
std::string caseName;
bool treated;
bool hasSharp, hasNoise, hasRange, hasEdges;
double sharp, noise, range, edges;
double timeMs; // 只记录
bool denoise, edgeBoost;
double usm;
};
static bool Near(double got, double want, double absTol, double relTol) {
double tol = std::max(absTol, std::fabs(want) * relTol);
return std::fabs(got - want) <= tol;
}
// 与 ArkTS 侧 RuleCoverage 同语义的镜像统计
struct CovCounter {
int denoiseTrue = 0, denoiseFalse = 0;
int sharpenAboveBase = 0, edgeBoostTrue = 0;
void observe(bool denoise, double sharpen, bool edgeBoost) {
if (denoise) denoiseTrue++; else denoiseFalse++;
if (sharpen > 0.5001) sharpenAboveBase++;
if (edgeBoost) edgeBoostTrue++;
}
};
int RunGolden(const std::vector<GoldenRow>& rows,
const std::map<std::string, std::vector<uint8_t>>& images) {
int failed = 0;
CovCounter cov;
for (const GoldenRow& g : rows) {
int side = g.treated ? 360 : 180;
const std::vector<uint8_t>& img =
images.at(g.caseName + (g.treated ? "_treated" : "_initial"));
DiagReport d;
Diagnose(img, side, side, &d); // 复诊与初诊共用同一口径
if (g.hasSharp && !Near(d.sharpness, g.sharp, 6.0, 0.03)) failed++;
if (g.hasNoise && !Near(d.noise, g.noise, 0.6, 0.03)) failed++;
if (g.hasRange && !Near(d.range, g.range, 2.0, 0.03)) failed++;
if (g.hasEdges && !Near(d.edges, g.edges, 0.02, 0.03)) failed++;
// 处方契约:真实实现在 ArkTS,这里用同一份规则做契约镜像
bool denoise = d.noise > 6.0;
double sharpen = std::min(1.5,
std::max(0.5, 0.5 + (30 - std::min(d.sharpness, 30.0)) / 30 * 1.0));
bool edgeBoost = d.edges < 0.04;
cov.observe(denoise, sharpen, edgeBoost);
if (denoise != g.denoise || edgeBoost != g.edgeBoost ||
!Near(sharpen, g.usm, 0.005, 0.0)) failed++;
}
if (cov.denoiseFalse == 0 || cov.denoiseTrue == 0 ||
cov.sharpenAboveBase == 0 || cov.edgeBoostTrue == 0) {
std::fprintf(stderr, "DEAD BRANCH: 规则表存在从未命中的分支\n");
failed++; // 故意让 CI 变红
}
return failed;
}
那段 DEAD BRANCH 是整套基准里最有价值的一行:它让第三章那个"看起来一切正常"的退化无法再悄悄留在代码里。要让它变绿,只能补反例样本(干净图 / 极模糊图 / 大色块图)或修规则——两条路都是好事。
4. 跑批与比对脚本
# run_golden.ps1 —— CI 入口
$ErrorActionPreference = 'Stop'
$bench = '.\build\default\intermediates\cpp\default\obj\diagnose_bench.exe'
& $bench '.\golden.csv' '.\actual.csv'
if ($LASTEXITCODE -ne 0) { throw 'benchmark 执行失败' }
$golden = Import-Csv '.\golden.csv'
$actual = Import-Csv '.\actual.csv'
if ($golden.Count -ne $actual.Count) { throw 'golden / actual 行数不一致' }
$fail = 0
for ($i = 0; $i -lt $golden.Count; $i++) {
foreach ($col in @('sharpness','noise','range','edges','usm')) {
if ([string]::IsNullOrEmpty($golden[$i].$col)) { continue } # 未抄录字段跳过
$want = [double]$golden[$i].$col
$got = [double]$actual[$i].$col
$tol = [Math]::Max(0.6, [Math]::Abs($want) * 0.03)
if ([Math]::Abs($got - $want) -gt $tol) {
Write-Host ("FAIL {0}/{1}/{2}: want {3} got {4}" -f `
$golden[$i].case, $golden[$i].stage, $col, $want, $got)
$fail++
}
}
}
Write-Host ("PASS {0} / FAIL {1}" -f ($golden.Count - $fail), $fail)
if ($fail -gt 0) { exit 1 }
5. 样本集必须故意包含反例
只用三张 AI 生成的示例图跑回归,覆盖率永远是绿的,也永远发现不了死分支。golden 集至少补三张:
| 补样 | 目的 | 期望打破的常数 |
|---|---|---|
| 干净低噪图((\sigma < 3)) | 让 denoise=false 被命中 | 去噪恒真 |
| 强模糊图(拉普拉斯方差 < 30) | 让 USM > 0.50 被命中 | 锐度补偿死代码 |
| 大色块 / 纯天空图(边缘密度 < 4%) | 让 edgeBoost=true 被命中 | 边缘增强恒关 |
补完之后第三章的敏感性表就会重新长成一张有区分度的表——规则引擎这才算真的在"诊断"。
六、56 / 56 / 51 ms:这个数字该怎么读
三份疗效报告分别写着 56ms、56ms、51ms。它们的流水线其实完全一样(都是去噪 + 2× 双线性 + USM 0.50),输入尺寸也一样,所以耗时本应几乎相同。那 5ms 差(约 9%)更可能来自模拟器线程调度、PixelMap 解码抖动、首次缓存预热,而不是"翠鸟这张更好治"。
单次计时不能当性能结论。建议改成多轮取中位数:
#include <chrono>
#include <algorithm>
template <typename F>
double MedianMs(F&& fn, int rounds = 9) {
std::vector<double> v;
for (int i = 0; i < rounds; ++i) {
auto t0 = std::chrono::steady_clock::now();
fn(); // 只包住治疗流水线本身
auto t1 = std::chrono::steady_clock::now();
v.push_back(std::chrono::duration<double, std::milli>(t1 - t0).count());
}
std::sort(v.begin(), v.end());
return v[v.size() / 2]; // P50,不要用 mean
}
报告时至少写清四件事,否则数字没有可复现性:
- 计时边界:是否含 NAPI 跨语言调用、
PixelMap解码、readPixelsToBuffer、UI 渲染; - 预热:前 2~3 轮丢弃,或单独标注冷启动值;
- 线程:明确单线程 CPU,模拟器与真机不可混报;
- 统计量:P50 / P90 / 轮数,而不是"随手跑一次的读数"。
这也是为什么 §5 的断言表里 time_ms 只记录、不断言——在 HarmonyOS 模拟器上给毫秒数加断言,等于给随机数加断言。
七、七张图在本篇里的角色
同一批截图,换一种眼光价值就完全不同。放进就诊流程里,它们是流程节点;放进测量学里,它们是数据锚点:
| 配图 | 截图可读到的读数 | 本篇用途 |
|---|---|---|
| 就诊环境 | 口径:API 26 / 180→360 / 单线程 CPU | §1.1 锁定实验条件 |
| 雪山初诊 | 1394.4 / 29.9 / 173 / 25.1% | §2 公式重推、§3 死分支 |
| 雪山疗效 | 29.9→4.0 / 1394→27 / 56ms | §4 尺度拆解、§6 性能口径 |
| 老城初诊 | 33.7 / 109 | §3 阈值敏感性上限 |
| 老城疗效 | 33.7→2.9 / 56ms | §5 golden 锚点 |
| 翠鸟初诊 | 23.5 / 18.6% | §3 阈值安全边际下限 |
| 翠鸟疗效 | 23.5→3.1 / 51ms | §5 golden、§6 计时讨论 |
七张里没有一张是"插图"——每张至少贡献了一个能进 golden 表的数,或者一个能反驳某条结论的边界值。这大概也是"复用图片"最不浪费的做法:不换图,换问题。
八、出院小结:从「能跑」到「可复算」
画质诊所交付的是一个完整闭环:诊断 → 处方 → 治疗 → 复诊,参数不再暴露给用户。本篇没有增加任何新功能,只做了三件事:
- 把量纲钉死:
1.26是「由 (E|n|) 反推 (\sigma)」的系数,被误用到未归一化的拉普拉斯响应上,读数放大约 4.49 倍。改成真实 (\sigma) 后,阈值 6.0 会立刻让最干净的翠鸟跳过去噪——常量与阈值必须同版修改。 - 把规则验活:
sharpness >= 30全被钳到 USM 0.50、edges < 4%在可读样本上恒关,规则引擎退化成固定配方。用分支覆盖探针 + 反例样本,让这类退化在 CI 里显形。 - 把尺子统一:四项盲指标里只有动态范围是尺度不变量;治疗前后数字要同尺度比,或与双线性对照比取净增益(
sharpRatio/psnrRecon - psnrCtrl)。
一句话收尾:可解释不等于可复算。 处方笺上写清「为什么这么治」只是第一步;让每一个「为什么」都能被一个断言重新算出来,才算把可信度真正落到工程里。
FAQ
Q:这是说引擎设计错了吗?
A:不是。诊所的产品流程、相对判断与工程取舍都成立——三张图确实都该去噪,噪声确实降了。本篇补的是更细的一层:物理量与统计口径的严格化。修正系数不改变任何排序,只改变数值语义,以及它与别的库、别的论文表格对话时的正确性。
Q:既然 PSNR 更严谨,为什么不干脆全换成 PSNR?
A:PSNR 需要真值(高清原图),而盲指标的立身之本恰恰是「任意一张图都能就诊」。诊所场景里没有 ground truth,所以同尺度对照 + 相对增益是折中:既不需要真值,又把尺度因子消掉了。有真值的离线评测可以另开一条路径,两者不冲突。
Q:golden.csv 只有六行够用吗?
A:不够,它是截图锚点不是完整基准。按 §5.5 补三张反例(干净图 / 极模糊图 / 大色块图),让 denoise=false、USM>0.50、edgeBoost=true 各至少命中一次,覆盖率断言才有意义。
Q:本篇的数字是重新跑引擎得到的吗?
A:不是。golden 表里的锚点全部抄自本文七张实机截图;重新运行引擎产出的值应写进 actual.csv 再比对。两者不一致时以复跑为准,并顺手更新 golden(同时记一笔原因),不要反过来改代码去迁就截图上的四舍五入值。
Q:如果采纳 §2.2 的方案 A,旧 golden 怎么处理?
A:整体换版、golden 版本号 +1,不要在旧表上打补丁。读数和阈值是绑定的一对,分开改会产生一个"谁都说不清哪次是有意变更"的中间状态——这正是 §3 那张敏感性表想避免的局面。
Q:文中的修正公式和回归 harness 可以直接提交吗?
A:建议当成一次带断言的评审结论来用:先跑一遍 §2.2 的蒙特卡洛验算确认系数问题,再连同阈值重标定、反例样本一起改——单独换一个常量,会立刻触发 §3 敏感性表里的连锁翻转。
本文配图为画质诊所(Image-Super-Resolution/sr-clinic)在 Mate 90 Pro 模拟器(HarmonyOS 7.0 / API 26)上的实机运行截图,示例照片由 AI 生成。文中修正版公式、回归 harness 与计时代码为可复现的改进建议,非该工程已提交实现。
更多推荐


所有评论(0)