端侧图像超分,第一反应往往是调用 CoreVisionKit 的 ImageSRAnalyzer 或走 NPU 加速的深度模型。但在 HarmonyOS 7(API 26)上做实机与模拟器联调时,这套路线有三个绕不开的现实约束:NPU 不可用或模型加载失败的设备上功能直接不可用,很多端侧 AI Kit 在 x86_64 模拟器上干脆拒绝创建分析器;NPU 通常要求输入尺寸落在固定区间(如 [64, 2048]),超出就得额外缩放,两段缩放还会叠加画质损失;结果是黑盒,既不可解释,也无法针对文字、线条、照片等不同场景做定制。
于是有了反向思路:把超分做成纯 Native C++ 引擎,用经典插值与边缘导向算法在 CPU 上跑出可接受的速度。它同时扮演三个角色——任何能跑 ArkTS 的设备都能用的兜底方案、与 AI 超分做同图 A/B 的对照组、每一行代码都可摊开讲的教学载体。
本文整理这个名为 sr-edge-forge 的工程实现:四种算法全部手写并用 NAPI 封装,180×180 低清图 4 倍放大到 720×720 约 40~50ms,模拟器与真机行为一致。
一、工程骨架与三条设计原则
工程位于系列目录 Image-Super-Resolution/ 下,bundleName 为 com.example.sredgeforge,entry 模块通过 externalNativeOptions 同时产出 arm64-v8a 与 x86_64 两套 so,核心文件只有几个:
- sr-edge-forge/
- ├── build-profile.json5 # API 26,useNormalizedOHMUrl
- └── entry/
- ├── build-profile.json5 # externalNativeOptions: arm64-v8a + x86_64
- ├── src/main/cpp/
- │ ├── CMakeLists.txt # add_library(entry SHARED napi_init.cpp)
- │ ├── napi_init.cpp # 超分引擎本体(约 500 行)
- │ └── types/libentry/ # Index.d.ts —— Native 接口的 TS 声明
- ├── src/main/ets/
- │ ├── pages/Index.ets # 源图/算法/倍数/结果/保存
- │ ├── common/SrService.ets # 像素读写 · 引擎调用 · PNG 落盘
- │ └── common/SrModels.ets # 数据模型与算法常量
- └── src/main/resources/rawfile/demo/ # 低清示例图 ×3
复制代码
数据通路是四段:ArkTS UI 层负责选项卡与结果展示,SrService 用 readPixelsToBuffer / createPixelMap 搬运像素并打包 PNG,libentry.so 通过 NAPI Promise 把计算丢到异步工作线程,napi_init.cpp 内部按 algo 分派到双线性、双三次 Catmull-Rom、Lanczos-3 或边缘导向 DSEI。
支撑这个结构的三条原则值得先记住:像素格式统一为 RGBA_8888,解码时就用 desiredPixelFormat 指定好,Native 内部不再做格式分支;计算全部离开 UI 线程,靠 NAPI 异步工作加 Promise 保证不卡顿;零第三方依赖,引擎只要 napi/native_api.h 加标准库,CMake 一行 add_library 就能编过。
二、四种算法各自的定位
双线性插值是最低复杂度的基线,目标点落在源图四个相邻像素之间,按两轴距离加权平均。它在本项目里不是参赛选手而是对照组——App 显示的"细节增益"就是其它算法相对它的梯度能量比值,让每张结果图都有量化参照系。
双三次 Catmull-Rom(a = -0.5)把邻域从 2×2 扩到 4×4,核函数是分段三次曲线。它过控制点(插值而非逼近),采样点处一阶导连续,视觉上比双线性锐一档,代价是强边缘附近出现轻微过冲振铃。
Lanczos-3 用 sinc 加窗截断覆盖 6 像素宽邻域,理论频响更接近理想低通,细节保留最好,但每轴 6 tap 计算量也最大,硬边缘周围容易起涟漪。工程实现里必须做权重归一化(除以权重和),否则亮度会随相位漂移。
第四种 DSEI 是自研简化版,思路来自 DCCI(Directional Cubic Convolution Interpolation)。双三次的根病在于它"不知道"边缘方向,永远做各向同性加权,于是在强边缘处把两侧颜色平均成一条模糊带。DCCI 的解法是先判方向再插值,但原版公式有 7 个权重、4 套组合,实现和调参都很绕。DSEI 把它压到最干净的几何形式:2 倍放大时每个源像素扩成 2×2 四个子像素,每个子像素恰好是一条对角线与一条反对角线的交点。
- A ─ B ─ C
- │ ╲ │ ╱ │ NW 子像素 ∈ 线段(A,G) ∩ 线段(B,D)
- D ─ G ─ E NE 子像素 ∈ 线段(C,G) ∩ 线段(B,E)
- │ ╱ │ ╲ │ SW 子像素 ∈ 线段(F,G) ∩ 线段(D,H)
- F ─ H ─ I SE 子像素 ∈ 线段(G,I) ∩ 线段(F,B)
复制代码
两条方向的可信度用端点差衡量:差越小说明这条线越贴合真实边缘。强边缘处一条方向的权重压倒另一条,输出贴近该方向端点,边缘锐利连续;平坦区两个权重趋同,退化为平均,平滑无噪。RGB 三通道分别处理,透明通道恒置 255。4 倍放大就是双程级联:先 2 倍得到中间图,再对中间图做一次 2 倍,中途需要一张 4 倍于源图的缓冲(180 → 360 → 720),内存开销完全可以接受。
三、可分离卷积:从秒级到几十毫秒的关键一步
三种核函数都满足可分离性,2D 重采样可以拆成横、纵两趟 1D。每输出像素的乘法次数从 n² 降到 2n,双三次 16→8、Lanczos 36→12,这是 4 倍放大能压进几十毫秒的决定性一步。以 180×180 → 720×720(输出 518400 像素)为例,纵向趟比横向趟贵 4 倍,因为输出像素多 4 倍;若不分离,Lanczos 的总乘法次数是分离后的 12 倍。
分离卷积的第一步是权重预计算,三个算法共用一个 BuildAxis,只差核函数求值:
- AxisWeights BuildAxis(int srcLen, int dstLen, int algo) {
- int taps = (algo == ALGO_BILINEAR) ? 2 : (algo == ALGO_BICUBIC ? 4 : 6);
- for (int d = 0; d < dstLen; ++d) {
- // 半像素对齐:目标像素中心映射回源坐标系
- double center = (d + 0.5) * srcLen / dstLen - 0.5;
- int s0 = floor(center) - (taps / 2 - 1);
- double sum = 0.0;
- for (int t = 0; t < taps; ++t) {
- double ax = fabs(center - (s0 + t));
- double k = /* 双线性 / Catmull-Rom / Lanczos-3 分段求值 */;
- aw.w[d * taps + t] = k;
- sum += k;
- }
- for (int t = 0; t < taps; ++t) aw.w[d * taps + t] /= sum; // 归一化
- aw.start[d] = s0;
- }
- }
复制代码
这里有两个容易踩的细节。半像素对齐写成 (d + 0.5) * srcLen / dstLen - 0.5,是让源/目标像素中心对齐而不是角点对齐;写成 d * srcLen / dstLen 会让整图偏移半个源像素,放大后所有边缘出现 1px 灰边。权重归一化则是为了 Lanczos 在边界 clamp 重复采样时权重和偏离 1,不归一化会出现亮度漂移条纹。预计算把"每个输出像素求 6 次三角函数"降为 0 次,总开销 O(dstLen·taps),相对主循环可忽略。
横纵两趟的主循环结构一致:横向趟 sw*sh → dw*sh,中间结果用 double 存 RGB,避免两次 8bit 舍入叠加;纵向趟 dw*sh → dw*dh,输出再 clamp 回 uint8。边界用复制延拓(clamp to edge),比零填充干净,图边缘不会发黑。
DSEI 则是整个引擎最短也最锋利的函数,核心就是一个四参数的融合 lambda:
- auto Blend = [](double a, double b, double c, double d, double* out) {
- double w1 = 1.0 / (1.0 + fabs(a - b)); // 方向①端点差越小 → 权重越大
- double w2 = 1.0 / (1.0 + fabs(c - d));
- *out = (w1 * (a + b) + w2 * (c + d)) / (2.0 * (w1 + w2));
- };
- // NW:Blend(A, G, B, D) NE:Blend(G, C, B, E)
- // SW:Blend(F, G, D, H) SE:Blend(G, I, F, B)
复制代码
四行完成"判向 + 插值",没有魔法数字,没有分支预测压力,每个子像素固定 2 次除法加 4 次减法,编译器能充分向量化。地址计算也做了简化:算出 NW 指针后,NE/SW/SE 都是 +4 或 +dw*4 的常数偏移,无乘法寻址。
四、NAPI 异步:Promise 三段式与线程边界
Native 计算绝不能占 UI 线程,标准模板是 async work 加 deferred 的三段式:
- struct SrTask { napi_async_work work; napi_deferred deferred; /* 输入输出 */ ... };
- napi_value StartTask(napi_env env, napi_callback_info info, int mode) {
- SrTask* t = new SrTask(); /* 解析参数 → 存入 t */
- napi_create_promise(env, &t->deferred, &promise);
- napi_create_async_work(env, nullptr, resource, ExecuteTask, CompleteTask, t, &t->work);
- napi_queue_async_work(env, t->work); // 入队,立即返回 promise
- return promise;
- }
- void ExecuteTask(napi_env env, void* data) { /* 纯计算,无 NAPI 调用 */ }
- void CompleteTask(napi_env env, napi_status status, void* data) {
- // 回到 UI 线程事件循环:打包 ArrayBuffer → resolve / reject
- napi_create_arraybuffer(env, t->out.size(), &buf, &result);
- napi_resolve_deferred(env, t->deferred, result);
- napi_delete_async_work(env, t->work);
- delete t;
- }
复制代码
ArkTS 侧因此干净到一行:const out: ArrayBuffer = await nativeSr.upscale(src, w, h, dw, dh, algo);
必须强调的坑:ExecuteTask 里不要调用任何 napi 接口(除文档明确允许的),异步工作的执行线程不是 JS 线程,napi_env 句柄不是线程安全的。所有需要回传的数据先放进自定义 SrTask 结构体,等 CompleteTask 回到 JS 线程再 napi_create_arraybuffer 打包。反过来把 std::vector 塞进 promise 回调是新手常见崩溃点。
五、ArkTS 服务层:像素搬运与 PNG 落盘
SrService 这一段链路没有任何跨进程序列化,ArrayBuffer 直进直出,这是 Native 方案的速度红利之一:
- // 读:PixelMap → ArrayBuffer(RGBA_8888)
- const buf = new ArrayBuffer(w * h * 4);
- await pixelMap.readPixelsToBuffer(buf);
- // 引擎
- const out = await nativeSr.upscale(buf, w, h, dw, dh, algo);
- // 写:ArrayBuffer → PixelMap → PNG
- const pm = await image.createPixelMap(out, { size: {...}, pixelFormat: RGBA_8888 });
- const packer = image.createImagePacker();
- const png = await packer.packToData(pm, { format: 'image/png', quality: 100 });
- fileIo.writeSync(file.fd, png); // 写入沙箱 filesDir/sr_edge_out,免存储权限
复制代码
把输出写进沙箱 filesDir 而不是公共媒体库,是这套流程能免存储权限跑通的前提。
六、测试闭环:程序图案做回归,实拍图看质感
算法写完不能靠肉眼扫两下。这里设计了两层素材,各有分工。
第一层是 Native 直接绘制的三张零反锯齿锐利图(180×132):落日峡谷由渐变天空、硬边太阳圆盘、两座三角山和前景棋盘草地组成,考察斜边与规则纹理;测试卡是八色竖条、同心圆环、45° 斜纹加 4px 细线栅格,考察各向异性细节;文字板用深底白色词块模拟 1px 字缝,考察文字锐度。零反锯齿是关键——抗锯齿会预先把边缘糊掉,算法差异会被噪声掩盖。看同心圆环与 45° 斜纹时,双三次整体发虚、过渡带明显变宽,DSEI 的条纹边界依然锋利,代价是强边缘处轻微阶梯感,这正是边缘导向类算法的指纹。
这一层还做成了离线回归:testdata/gen_testdata.js 是与 napi_init.cpp 逐行同构的 Node 版,同样的图案公式、同样的四种算法、同样的 PNG 编码,一条命令重跑全部对比图。C++ 改了算法,先跑 JS 版看效果,比每次编 HAP 装模拟器快一个数量级。
第二层是 AI 实拍图。流程是 AI 生图(1024×1024)→ 手写 PNG 解码器还原 RGBA 像素 → 盒式降采样到 180×180 放进 rawfile → 引擎 4 倍超分预览。两个点值得展开。手写 PNG 解码器是因为不想为一个测试脚本引 sharp,而 PNG 格式其实友好:IHDR 头加串联 IDAT 走 zlib inflate,剩下就是把每行前缀滤波字节还原(None/Sub/Up/Average/Paeth 五种),Paeth 预测器的核心就是比较 p = a + b - c 与 a、b、c 的距离,取差最小的那个作为预测值。降采样则坚持盒式(面积平均)而不是最近邻:最近邻会引入混叠伪影,等于让超分算法处理一个被污染的输入,面积平均才与相机光电采样的语义一致。
三张示例分别覆盖自然风光(雪山日出)、城市人文(老城黄昏)、微距生态(花间翠鸟),锐边、渐变、高频纹理各有着重。
七、实测数据与指标边界
测试环境为 HarmonyOS 7.0 / API 26 的 x86_64 模拟器,输入 180×180,4 倍输出 720×720。
雪山日出走 DSEI 4 倍:51ms,细节增益 +0%。这个 0 不是 bug,恰恰是指标诚实的证明——雪山湖面以平滑渐变为主,DSEI 与双线性在平坦区的输出几乎一致,梯度能量自然持平,平坦场景里边缘导向算法本就无用武之地。
老城黄昏走双三次 4 倍:40ms,细节增益 +9%。城市场景充满建筑轮廓、雨棚条纹、湿地反光,插值差异被内容放大。结论很直接:细节增益必须结合图像内容解读,脱离场景谈数字没有意义。
App 内的"细节增益"定义为梯度能量,L 取 Rec.601 加权亮度。它是简单的清晰度代理指标:同尺寸下高频越多、过渡越陡,E 越大。局限也要说清楚——它不等于质量,给图加噪声 E 会暴涨但观感更差,所以只适合同输入同尺寸下算法互比;它对内容敏感,平坦图天然趋近 0;严肃对比应上 PSNR / SSIM(需要参考图)或 NIQE / BRISQUE(无参考),这是后续工作。
八、性能优化账本
已落地的四项:权重预计算让三角函数只在 BuildAxis 出现一次;横向趟中间结果用 double 暂存,避免两次 8bit 舍入叠加;DSEI 直接索引写入,靠常数偏移寻址;输入规模控制,相册图长边先降到 320 再进引擎(4 倍输出 1280,单次处理仍在百毫秒内)。
还没做但已经排期的:按行带切 4 片加 pthread 多线程分片,理论上有 3~4 倍加速,模拟器多核可见、真机更明显;横向趟本质是 4/6-tap 卷积,可用 float32×4 lane 的 NEON 内联打满;把 double 权重换成 Q12 定点提升缓存友好度;中间缓冲改 half-float 省一半内存、精度损失可忽略。预估多线程加 NEON 之后,720p 输出可进入 10ms 量级,接近实时取景器的水准。
九、六个踩坑实录
坑 1:@Builder 按值传参,选项卡选中态卡死。把 active 作为普通参数传给 @Builder,它只在首次构建时求值,之后状态再变 Builder 也不重跑,所有高亮停在首帧。修复方式是把样式三元表达式内联到每个 Text 属性上(属性级状态绑定是响应式的)。一句话总结:@Builder 传参只认首帧值,要随状态刷新的样式必须内联或按引用传递。
坑 2:CLI 构建报 00308018 "Unknown Error"。报错零信息量,实际是两个环境问题叠加:Git 不在 PATH 导致 hvigor 取不到版本信息,加进 PATH 后必须完全重启 DevEco(从终端启动的子进程才继承新 PATH);打包阶段 spawn java ENOENT 则要求 CLI 环境里包含 DevEco 自带 JBR 的 bin 目录。遇到 00308018 先查环境变量,再怀疑代码。
坑 3:在 ExecuteTask 里调 NAPI,见第四节,结果是崩溃或不可预期的行为。
坑 4:半像素对齐写错,整图发灰。这类 bug 看图能发现、看代码极难,只能靠对照中心对齐公式排查。
坑 5:多工程共用 bundleName 且 versionCode 偏低。调试签名绑定包名,多个实验工程都用 com.example.xxx 时,若 HAP 的 versionCode 低于已安装版本,hdc install -r 会静默拒绝(错误 9568322 一类)。每个工程用独立 bundleName 最省心,本项目特意用 com.example.sredgeforge。
坑 6:长中文内容的文件写入截断。用工具写超长中文文件时 content 参数可能被截断甚至整个调用丢失,分块写入(每段不超过 4KB、显式 UTF-8 编码)是稳定做法。
十、小结
这个工程最大的启发是:把黑盒能力重写成白盒算法,失去的是一点效果上限,换来的是完全的可控性与讲解自由。在端侧 AI 大行其道的今天,经典算法依然有明确的工程位置——作为 AI 方案的兜底、对照组与教学基线。后续已排期的工作包括多线程加 NEON 优化(目标 10ms 级)、与 CoreVisionKit NPU 超分做同图 A/B(PSNR/SSIM 加主观评分)、以及把 DSEI 的方向权重指数做成参数化,配一个交互式调参页面。 |