在HarmonyOS 7 / API 26上,一个名为“画质诊所”的超分应用把图像处理做成了就诊流程:用户不选算法、不调参数,而是走“挂号→初诊→处方→治疗→复诊”。引擎先对图像做四项盲指标体检,再由ArkTS规则引擎开出可解释的处理流水线,Native层执行治疗,最后用同一套指标复诊。工程位置为Image-Super-Resolution/sr-clinic,原文截图来自Mate 90 Pro模拟器(HarmonyOS 7.0 / API 26)实机运行。
一、为什么把参数收回来
系列前三个超分应用已经验证了引擎能力,但都把“选哪个算法、锐化多少、要不要去噪”留给用户。普通用户面对“USM强度0.8”这类参数,往往只能随便点。画质诊所的取舍是:用客观测量代替主观猜测。医生不会问病人想吃什么药,而是先检查再开处方;修图也按这个流程组织。
二、初诊:四项盲指标一次遍历算完
图像进入应用后是180×180的低清图,Native引擎立即计算四项不需要参考答案的盲指标:清晰度用拉普拉斯方差,噪声用1.26×mean|lap|估计,动态范围取5%~95%分位差,边缘密度用Sobel幅值超过120的像素占比。四项指标在同一次像素遍历中完成,180×180全图诊断小于5ms。
- void Diagnose(const std::vector<uint8_t>& img, int w, int h, ...) {
- for (int y = 1; y < h - 1; ++y) {
- for (int x = 1; x < w - 1; ++x) {
- double c = GrayAt(img, w, h, x, y);
- double lap = 4.0 * c
- - GrayAt(img, w, h, x - 1, y) - GrayAt(img, w, h, x + 1, y)
- - GrayAt(img, w, h, x, y - 1) - GrayAt(img, w, h, x, y + 1);
- sumAbs += std::fabs(lap);
- sumSq += lap * lap;
- double gx = ..., gy = ...;
- if (std::sqrt(gx * gx + gy * gy) > 120.0) edgeCount++;
- hist[ClampInt((int)c, 0, 255)]++;
- n++;
- }
- }
- *sharpness = sumSq / n;
- *noise = 1.26 * sumAbs / n;
- *range = 分位数(hist, 0.95) - 分位数(hist, 0.05);
- *edges = edgeCount / (double)n;
- }
复制代码
这里有三个值得留意的细节。噪声估计采用1.26×mean|lap|,因为对高斯噪声,|拉普拉斯|均值与噪声标准差之间存在1.26的理论系数,比“数极值点”稳健。动态范围用5%~95%分位差而不是max−min,单个坏点就能把极值法打到255,分位数对离群值免疫。边缘密度阈值取120,是因为Sobel幅值在纯噪声图上也会偶发过阈,120是“肉眼可辨结构”的经验下限。
三、处方:规则放ArkTS,像素放C++
诊断数字本身没有用,翻译成动作才有用。诊所内置一张规则表:噪声大于6先做3×3中值去噪,因为先锐化会把噪点一起放大;锐度越低,USM越强,强度按0.5 + (30 − min(锐度,30)) / 30 × 1.0计算,落在0.5~1.5之间;边缘密度小于4%时追加边缘增强;倍率固定2×,场景诉求是“看清”而不是“印刷”。
- 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;
- notes.push(`高频残差 ${d.noise.toFixed(1)} ${denoise ? '偏高,先中值去噪' : '正常,跳过去噪'}`);
- notes.push(`拉普拉斯方差 ${d.sharpness.toFixed(0)},USM 强度取 ${sharpen.toFixed(2)}`);
- return { denoise, factor: 2, sharpen, edgeBoost, notes };
- }
复制代码
返回值里除了参数还带notes数组,每条处方自带诊断依据,UI直接渲染成处方笺条目。规则引擎只有十几行ArkTS,放在ArkTS层而不是C++层是刻意分层:阈值、强度曲线会频繁调整,改ArkTS秒级生效,改C++要重新编译so。这里的分层依据是“变更频率”,不是教条式的高低层划分。
四、治疗:四级流水线与可复用积木
点“执行治疗”后,Native引擎按处方执行:中值去噪(可选)→双线性2×上采样→USM锐化→边缘增强(可选)。全部单线程CPU,雪山日出实测56ms,花间翠鸟51ms。
流水线由三块可复用积木搭成。3×3中值去噪每通道取9邻域中值,9个元素用插入排序排完,不用std::sort,9个元素的插入排序比通用排序快一个量级。双线性上采样采用半像素对齐映射和端点钳制。USM锐化用3×3盒模糊做低频版,原图减低频得高频再加回去;盒模糊中间结果用double暂存,8bit舍入两次会把锐化噪声翻倍;amount小于等于0直接return,处方里“跳过该步骤”就是传0;边缘增强就是第二次USM,强度0.5 + edgeBoost,复用同一块积木。
五、复诊:三份病历与一个尺度陷阱
雪山日出初诊噪声29.9,治疗后降到4.0,耗时56ms;老城黄昏初诊噪声33.7、动态范围109/255,属于高噪低反差,处方同样先命中去噪,治疗后噪声降到2.9,耗时56ms;花间翠鸟初诊噪声23.5、边缘密度18.6%属于正常,规则引擎免去边缘增强,该省的省,治疗后噪声3.1,耗时51ms。
疗效报告里有一个刺眼的数字:清晰度1394→27,暴跌98%。这不是治坏了,而是指标设计问题。拉普拉斯方差不是尺度不变量,2×上采样后同样的结构被摊到4倍像素上,逐像素梯度天然减半,方差跌到几十分之一。治疗前是180px尺子的读数,治疗后是360px尺子的读数,不能直接比。真正可比的是噪声:去噪前后在同一尺度、同一口径下测量,29.9→4.0才是真疗效。
修复思路也实现了:对比前先做同尺度对照,把原图用双线性放大到同一尺寸作为“参照患者”,两者用同一把尺子量。psnrRecon − psnrCtrl才是“治疗的净收益”,与尺度无关。
- double psnrRecon = ComputePsnr(recon, groundTruth);
- std::vector<uint8_t> ctrl;
- UpsampleBilinear(frame0, fw, fh, ctrl, dw, dh);
- double psnrCtrl = ComputePsnr(ctrl, groundTruth);
- // psnrRecon − psnrCtrl 才是“治疗的净收益”
复制代码
同理,清晰度指标若要跨尺度比较,应除以factor²做归一,或只在同尺度图像之间比较。教训写进了处方引擎:任何跨尺度的指标对比,必须先做尺度归一。
六、NAPI纪律:三行版经验
两个接口diagnose和treat共用一套异步模板,纪律有三条,全部来自上一个项目三次崩溃:解析失败立即拒绝Promise,绝不把默认零值任务送进工作线程,factor=0进工作线程就是除零崩溃;Execute里只做纯计算,不碰任何NAPI句柄,因为NAPI句柄非线程安全;所有出参装进任务结构体,回JS线程再打包。复诊直接复用diagnose,诊断与治疗共享同一套指标口径,同一把尺子量前后才有可比性。
七、两个设计取舍
第一,处方规则放ArkTS,像素操作放C++:判断噪声高不高、边缘密不密依赖阈值与产品语气,会反复调整;中值、卷积、锐化是稳定的计算密集段。第二,诊断是盲的,治疗是可解释的:盲指标换取了任意图像都能就诊的通用性,代价是无法给出PSNR这种需要真值的硬指标,所以复诊只承诺“噪声确实降了、与同尺度对照相比的增益”,不承诺绝对画质提升多少。能力边界写在产品里,是信誉而不是示弱。
这个项目真正产出不是某个算法,而是一种组织方式:把“诊断→处方→治疗→复诊”的闭环套在任意图像处理能力上,参数不再暴露给用户,而是从测量中推导,且每条推导都可解释、可反驳。后续排期包括处方规则开放成可编辑规则表、复诊增加与双线性对照的同尺度PSNR,以及把“诊所”套到去噪、HDR、色彩修复上验证这套隐喻的通用性。 |