鸿蒙HarmonyOS 7 图像超分·检验科手记:把七张病历截图当实验数据,复算盲指标、校准阈值、建立回归基准

画质诊所(Image-Super-Resolution/sr-clinic)把图像超分做成了一次「就诊」:挂号、初诊、处方、治疗、复诊。用户不用调参数,引擎自己量、自己决定、自己解释——本篇关心的则是这套流程背后,数字本身可信到什么程度。

同一批七张实机截图,不看流程,只看数字。把屏幕上每一个读数抄下来,当作一组需要复算的实验数据,然后追问三个常被略过的问题:四项盲指标的公式经得起推敲吗?处方阈值在这批样本上真的被触发过吗?治疗前后的数字能不能放在同一把尺子上比?

结论会有点意外:三份病历的处方其实完全一样,规则引擎里有两个分支从未被激活;噪声读数上的 1.26 系数与作用对象并不匹配;而「清晰度暴跌 98%」不是治坏了,是量纲错了。全文最后把这些发现固化成一套能在 CI 里复跑的回归基准。文中配图均为该应用在 Mate 90 Pro 模拟器上的实机运行截图。


一、重新读这七张图:把截图当数据源

1. 就诊环境:先锁定实验条件

就诊环境:HarmonyOS 工程与 Mate 90 Pro 模拟器

第一张是工程与模拟器全景。它在本篇里的作用不是展示界面,而是锁定实验口径: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.429.9173/25525.1%4.056ms去噪 + USM 0.50
老城黄昏—33.7109/255—2.956ms去噪 + USM 0.50
花间翠鸟—23.5—18.6%3.151ms去噪 + 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.96.666.0仍触发
老城黄昏33.77.516.0仍触发
花间翠鸟23.55.236.0不再触发

只改一个常量,就会让最干净的一张突然跳过去噪。 这正是「读数必须带正确量纲」的现实代价:阈值不是独立参数,它与它作用的量绑在一起。任何改系数的 PR 都必须同时重跑阈值标定,否则回归基准会一次抓出三处变化,却没人知道哪处是有意的。

敏感性分析:阈值动一倍,处方动多少

阈值当前读数域修正后 (\sigma) 域
噪声 6.03/3 去噪2/3 去噪
噪声 3.0(÷2)3/3 去噪3/3 去噪
噪声 12.0(×2)3/3 去噪0/3 去噪
边缘 0.040/2 增强0/2 增强
锐度截断 300/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

疗效报告上「清晰度 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
}

报告时至少写清四件事,否则数字没有可复现性:

  1. 计时边界:是否含 NAPI 跨语言调用、PixelMap 解码、readPixelsToBuffer、UI 渲染;
  2. 预热:前 2~3 轮丢弃,或单独标注冷启动值;
  3. 线程:明确单线程 CPU,模拟器与真机不可混报;
  4. 统计量: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. 把量纲钉死:1.26 是「由 (E|n|) 反推 (\sigma)」的系数,被误用到未归一化的拉普拉斯响应上,读数放大约 4.49 倍。改成真实 (\sigma) 后,阈值 6.0 会立刻让最干净的翠鸟跳过去噪——常量与阈值必须同版修改。
  2. 把规则验活:sharpness >= 30 全被钳到 USM 0.50、edges < 4% 在可读样本上恒关,规则引擎退化成固定配方。用分支覆盖探针 + 反例样本,让这类退化在 CI 里显形。
  3. 把尺子统一:四项盲指标里只有动态范围是尺度不变量;治疗前后数字要同尺度比,或与双线性对照比取净增益(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 与计时代码为可复现的改进建议,非该工程已提交实现。

Logo

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

更多推荐