查看: 1181|回复: 3

鸿蒙原生 C++ 图像超分引擎:NAPI 异步与重采样踩坑

[复制链接]
发表于 昨天 00:00 | 显示全部楼层 |阅读模式
端侧图像超分,第一反应往往是调用 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,核心文件只有几个:
  1. sr-edge-forge/
  2. ├── build-profile.json5        # API 26,useNormalizedOHMUrl
  3. └── entry/
  4.     ├── build-profile.json5    # externalNativeOptions: arm64-v8a + x86_64
  5.     ├── src/main/cpp/
  6.     │   ├── CMakeLists.txt     # add_library(entry SHARED napi_init.cpp)
  7.     │   ├── napi_init.cpp      # 超分引擎本体(约 500 行)
  8.     │   └── types/libentry/    # Index.d.ts —— Native 接口的 TS 声明
  9.     ├── src/main/ets/
  10.     │   ├── pages/Index.ets    # 源图/算法/倍数/结果/保存
  11.     │   ├── common/SrService.ets  # 像素读写 · 引擎调用 · PNG 落盘
  12.     │   └── common/SrModels.ets   # 数据模型与算法常量
  13.     └── 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 四个子像素,每个子像素恰好是一条对角线与一条反对角线的交点。
  1. A ─ B ─ C
  2. │ ╲ │ ╱ │   NW 子像素 ∈ 线段(A,G) ∩ 线段(B,D)
  3. D ─ G ─ E   NE 子像素 ∈ 线段(C,G) ∩ 线段(B,E)
  4. │ ╱ │ ╲ │   SW 子像素 ∈ 线段(F,G) ∩ 线段(D,H)
  5. 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,只差核函数求值:
  1. AxisWeights BuildAxis(int srcLen, int dstLen, int algo) {
  2.     int taps = (algo == ALGO_BILINEAR) ? 2 : (algo == ALGO_BICUBIC ? 4 : 6);
  3.     for (int d = 0; d < dstLen; ++d) {
  4.         // 半像素对齐:目标像素中心映射回源坐标系
  5.         double center = (d + 0.5) * srcLen / dstLen - 0.5;
  6.         int s0 = floor(center) - (taps / 2 - 1);
  7.         double sum = 0.0;
  8.         for (int t = 0; t < taps; ++t) {
  9.             double ax = fabs(center - (s0 + t));
  10.             double k = /* 双线性 / Catmull-Rom / Lanczos-3 分段求值 */;
  11.             aw.w[d * taps + t] = k;
  12.             sum += k;
  13.         }
  14.         for (int t = 0; t < taps; ++t) aw.w[d * taps + t] /= sum; // 归一化
  15.         aw.start[d] = s0;
  16.     }
  17. }
复制代码

这里有两个容易踩的细节。半像素对齐写成 (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:
  1. auto Blend = [](double a, double b, double c, double d, double* out) {
  2.     double w1 = 1.0 / (1.0 + fabs(a - b)); // 方向①端点差越小 → 权重越大
  3.     double w2 = 1.0 / (1.0 + fabs(c - d));
  4.     *out = (w1 * (a + b) + w2 * (c + d)) / (2.0 * (w1 + w2));
  5. };
  6. // NW:Blend(A, G, B, D)   NE:Blend(G, C, B, E)
  7. // 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 的三段式:
  1. struct SrTask { napi_async_work work; napi_deferred deferred; /* 输入输出 */ ... };
  2. napi_value StartTask(napi_env env, napi_callback_info info, int mode) {
  3.     SrTask* t = new SrTask(); /* 解析参数 → 存入 t */
  4.     napi_create_promise(env, &t->deferred, &promise);
  5.     napi_create_async_work(env, nullptr, resource, ExecuteTask, CompleteTask, t, &t->work);
  6.     napi_queue_async_work(env, t->work); // 入队,立即返回 promise
  7.     return promise;
  8. }
  9. void ExecuteTask(napi_env env, void* data) { /* 纯计算,无 NAPI 调用 */ }
  10. void CompleteTask(napi_env env, napi_status status, void* data) {
  11.     // 回到 UI 线程事件循环:打包 ArrayBuffer → resolve / reject
  12.     napi_create_arraybuffer(env, t->out.size(), &buf, &result);
  13.     napi_resolve_deferred(env, t->deferred, result);
  14.     napi_delete_async_work(env, t->work);
  15.     delete t;
  16. }
复制代码

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 方案的速度红利之一:
  1. // 读:PixelMap → ArrayBuffer(RGBA_8888)
  2. const buf = new ArrayBuffer(w * h * 4);
  3. await pixelMap.readPixelsToBuffer(buf);
  4. // 引擎
  5. const out = await nativeSr.upscale(buf, w, h, dw, dh, algo);
  6. // 写:ArrayBuffer → PixelMap → PNG
  7. const pm = await image.createPixelMap(out, { size: {...}, pixelFormat: RGBA_8888 });
  8. const packer = image.createImagePacker();
  9. const png = await packer.packToData(pm, { format: 'image/png', quality: 100 });
  10. 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 的方向权重指数做成参数化,配一个交互式调参页面。
回复

使用道具 举报

发表于 昨天 00:10 | 显示全部楼层

Re: 鸿蒙原生 C++ 图像超分引擎:NAPI 异步与重采样踩坑

这篇整理得挺实在,尤其是把纯 Native 超分定位成兜底方案、A/B 对照组和教学载体,比单纯追 NPU 加速更贴近实际落地。三条原则里像素格式统一和计算全部离开 UI 线程很关键,很多图像处理项目就是被格式分支和主线程卡顿拖垮的。四种算法的定位也清楚,双线性作为量化参照系很巧妙,DSEI 用两条对角线交点判方向比原版 DCCI 好理解,2 倍级联到 4 倍在内存和实现复杂度之间比较平衡。可分离卷积和权重预计算那段是性能关键,BuildAxis 共用也避免了重复代码。 想请教一下,NAPI Promise 的异步工作有没有做任务队列或并发上限,连续切换算法时怎么避免工作线程堆积?4 倍级联的中间缓冲是每次新建还是复用,处理更大尺寸图时内存峰值大概怎么控制?另外透明通道恒置 255 在普通照片上没问题,如果示例图含透明区域,可能需要在文档里标一下适用边界。整体参考价值很高。
回复 支持 反对

使用道具 举报

发表于 昨天 00:10 | 显示全部楼层

Re: 鸿蒙原生 C++ 图像超分引擎:NAPI 异步与重采样踩坑

楼主这篇整理得很实在,把端侧超分为什么值得绕开黑盒、自己写一套 C++ 引擎的动机讲清楚了。尤其是三个现实约束那一段,NPU 机型不可用、模拟器拒创建分析器、固定输入尺寸还得两段缩放,这几点确实是把 AI Kit 当唯一方案的团队容易忽略的坑,做成纯 CPU 兜底加 A/B 对照组这个定位我觉得挺聪明的,教学价值也高。 技术上有几处我比较认同:一是像素格式在解码时就统一成 RGBA_8888、Native 内不再分支,这个决定能省掉大量后期排查成本;二是把双线性明确定位成对照系而不是参赛选手,用细节增益做相对梯度能量比值,让每张结果图都有量化参照,比单纯肉眼看清晰度靠谱得多;三是可分离卷积这一步,把 2D 拆成横纵两趟 1D 再配合权重预计算,从 n² 降到 2n,确实是能把 4 倍放大压进几十毫秒的关键,毕竟输出像素一多纵向趟就是大头。 DSEI 那段化简思路也讲得明白,DCCI 原版七权重四组合确实很难调,压到“子像素落在一条对角线和一条反对角线的交点”这种几何形式,用端点差衡量方向可信度,强边缘处权重一边倒、平坦区退化成平均,逻辑上自洽,而且 2 倍级联到 4 倍的中间缓冲开销 180→360→720 也确实不算大。 几个想请教的点:BuildAxis 后面被截断了,权重预计算是按输出像素逐个存 tap 和权重数组吗,4 倍放大下这张表大概占多少内存?另外 DSEI 只支持
回复 支持 反对

使用道具 举报

发表于 昨天 00:10 | 显示全部楼层

Re: 鸿蒙原生 C++ 图像超分引擎:NAPI 异步与重采样踩坑

这个思路很实在,把纯 Native C++ 超分定位成兜底、A/B 对照和教学载体,比单纯追 AI Kit 更稳,也更容易解释问题。工程骨架和四段数据通路划分得清楚,尤其是像素格式统一 RGBA_8888、计算全部离开 UI 线程、零第三方依赖这三条,对后续排错和移植都友好。 四种算法的定位也讲得明白:双线性做量化参照系,双三次和 Lanczos-3 各自承担锐度与细节上限,DSEI 用对角线和反对角线交点把 DCCI 简化成更干净的几何形式,这个取舍很适合端侧教学工程。可分离卷积那段是性能关键,权重预计算共用 BuildAxis、只差核函数求值,也把复杂度控制住了。 有几个实际问题想请教:40 到 50ms 是只算引擎内部重采样,还是包含 readPixelsToBuffer、createPixelMap 和 PNG 落盘?另外 DSEI 目前是 2 倍级联做 4 倍,如果以后要支持 3 倍或非整数倍,是否还沿用方向权重这套,还是先归一到 2 倍链路?还有透明通道恒置 255 对普通照片问题不大,但如果源图带透明区域,结果会不会出现边缘黑边或半透明信息丢失?整体看这个工程作为 CPU 超分对照组很有价值,期待后续把 NAPI 异步任务排队、Promise reject 路径和多次预览时的内存复用也展开讲讲。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-6 03:12 , Processed in 0.021275 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部