查看: 166|回复: 3

鸿蒙图像超分透镜实战:NAPI 异步管线与请求合并优化

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
在 HarmonyOS 7(API 26)上做实时图像处理,难点往往不在算法本身,而在“交互帧率”和“Native 与 ArkTS 的边界”这两件事上。本文基于一个可复刻的“超分透镜”工程展开:手指按住低清图片任意位置,圆形透镜内即时显示该区域的 4 倍超分结果,拖动时逐帧刷新;透镜左半是双线性基线,右半是当前算法,同一圆内直接对比。官方给出的实测是单帧计算 1~6ms,60fps 预算只用了一半。

适合已有 ArkTS 页面开发经验、但没写过 NAPI 的开发者。工程规模约 Native 450 行加 ArkTS 350 行,顺利的话一个下午能跑通。需要说明的是,这里的算法全部是经典重采样与锐化,不涉及深度学习超分,但整条管线对 AI 引擎是完全开放的。

[标题]先定契约,再写代码[/标题]

交互式功能最容易死在“边写边想”。动手之前先把 Native 接口锁死,整个引擎只需要两个函数:
  1. // 取 (cx,cy) 处 2rl×2rl 源区域,放大 factor 倍,返回 2rl*factor 见方 RGBA
  2. lens(src: ArrayBuffer, sw: number, sh: number, cx: number, cy: number,
  3.      rl: number, factor: number, algo: number): Promise<ArrayBuffer>
  4. // 最近一次自适应校准得到的锐化强度(UI 展示用,没有可返回 0)
  5. lastParam(): number
复制代码

这两行签名背后有两条设计纪律,后面每一步都在为它们服务。第一是无状态:每次调用只依赖入参,透镜移到任何位置结果都一致,缓存、并行、回放都可以后加。第二是无整图操作:接口里只有透镜小窗坐标,Native 永远不知道整图多大,这是实时性的根基。

这里有个容易踩的坑:不要把“整图处理再裁剪结果”当成透镜实现。那等于每帧重算全图,前期看着能跑,后面必然先慢后卡。

[标题]工程骨架与配置核对[/标题]

用 DevEco 新建 Native C++ 工程,然后逐项核对配置,漏一项就可能白跑:

entry/build-profile.json5 中 externalNativeOptions.abiFilters 要同时包含 arm64-v8a 和 x86_64,模拟器是 x86_64,漏掉就只能干瞪眼;CMakeLists.txt 的核心是一行 add_library(entry SHARED napi_init.cpp),并链接 libace_napi.z.so;entry/oh-package.json5 中声明 "libentry.so": "file:./src/main/cpp/types/libentry";cpp/types/libentry/Index.d.ts 写好上面两个函数的声明,ArkTS 侧的类型提示全靠它;module.json5 沿用默认 EntryAbility 配置即可,本功能不需要任何权限。

[标题]Native 侧:裁剪、重采样与边界处理[/标题]

lens 的语义拆开是四步,每步都不长:
  1. // ① 裁剪:把透镜矩形钳制进图内,按行 memcpy
  2. int rx = ClampInt(cx - rl, 0, sw - 2 * rl);
  3. int ry = ClampInt(cy - rl, 0, sh - 2 * rl);
  4. for (int y = 0; y < m; ++y) // m = 2*rl
  5.     memcpy(&patch[y * m * 4],
  6.            &src[((ry + y) * sw + rx) * 4], m * 4);
  7. // ② 放大:双线性 2-tap / 双三次 4-tap,按轴分离先横后纵
  8. // ③(可选)自适应锐化,见后文
  9. // ④ 打包:结果拷进 napi_create_arraybuffer 返回
复制代码

重采样有三个细节必须守住。映射公式要用半像素对齐,即 (d + 0.5) * srcLen / dstLen - 0.5,写成角点对齐会让整图偏移半像素、边缘发灰。每个输出坐标要把核权重除以权重和做归一化,否则亮度会随相位漂移。边界用复制延拓(clamp to edge),不要零填充。

另一个影响观感的坑是精度:中间结果用 double 暂存 RGB,最后一步才 clamp 回 uint8。两次 8bit 舍入叠加,会让透镜画面明显发脏。

[标题]异步 Promise 三件套[/标题]

透镜计算不能占 UI 线程,但也不必想得太复杂,一个通用的异步任务模板可以长期复用:
  1. struct LensTask { // 一次调用的全部输入输出
  2.     napi_async_work work;
  3.     napi_deferred deferred;
  4.     /* 输入:src、坐标、参数;输出:out 缓冲 */
  5. };
  6. napi_value LensAsync(napi_env env, napi_callback_info info) {
  7.     auto* t = new LensTask();               // 1. 解析参数进 t
  8.     napi_create_promise(env, &t->deferred, &promise);
  9.     napi_create_async_work(env, nullptr, resource,
  10.         ExecuteLens, CompleteLens, t, &t->work); // 2. 创建工作
  11.     napi_queue_async_work(env, t->work);    // 3. 入队,立即返回 promise
  12.     return promise;
  13. }
复制代码

ExecuteLens 里只做纯计算,绝对不要调用任何 NAPI 接口,句柄非线程安全,调了必崩。所有结果装进 LensTask,等 CompleteLens 回到 JS 线程再用 napi_create_arraybuffer 打包并 resolve。CompleteLens 末尾记得 napi_delete_async_work 加 delete t,透镜是高频调用,泄漏会很快显形。

ArkTS 侧因此可以薄到两行:
  1. const out: ArrayBuffer = await nativeSr.lens(srcBuf, sw, sh, cx, cy, rl, factor, algo);
  2. const pm = await image.createPixelMap(out, { size: {...}, pixelFormat: RGBA_8888 });
复制代码

[标题]触摸驱动:请求合并与只追最新[/标题]

手指移动事件的产生速度远超引擎帧率,天真地“一个事件一个任务”会让透镜越追越落后。解法是请求合并,只在空闲时发起计算,忙碌时仅记录最新坐标,并在结果返回后校验请求号:
  1. private pending = false;
  2. private lastReq = 0;
  3. private onTouchAt(px: number, py: number): void {
  4.     if (this.pending) { this.updateCoords(px, py); return; } // 忙:只记坐标
  5.     this.lastReq++;
  6.     const req = this.lastReq;
  7.     this.pending = true;
  8.     this.updateLens(req, px, py); // 空闲:立刻处理
  9. }
  10. private async updateLens(req: number, px: number, py: number): Promise<void> {
  11.     const left = await this.svc.lens(..., ALGO_BILINEAR);   // 左半:基线
  12.     const right = await this.svc.lens(..., this.algo);      // 右半:当前算法
  13.     this.pending = false;
  14.     if (req !== this.lastReq) return; // 过期请求:丢弃
  15.     this.lensL = left.pm;  // 状态更新即渲染
  16.     this.lensR = right.pm;
  17. }
复制代码

坐标换算要注意:屏幕坐标乘以“源图宽 / 显示宽”,透镜源半径 rl 等于屏幕半径除以显示缩放。触摸位置与透镜拖动都要把中心钳制在图内,UI 层做一次、Native 层再兜底一次,防止越界读到脏数据。请求号校验必须放在 await 之后,这是“只追最新”的全部秘密。

[标题]圆形对比 UI[/标题]

不需要自绘组件,左右两个 Image 各绑一个 PixelMap,外层圆角裁切成圆即可:
  1. Row({ space: 2 }) {
  2.   Image(this.lensL).width(LENS / 2).height(LENS) // 左半:双线性基线
  3.   Image(this.lensR).width(LENS / 2).height(LENS) // 右半:当前算法
  4. }
  5. .borderRadius(LENS / 2)
  6. .clip(true)
  7. .border({ width: 2, color: '#5EEAD4' })
  8. .shadow({ radius: 12, color: '#5EEAD466' })
  9. .position({ x: this.lensX, y: this.lensY })
复制代码

底图加 .interpolation(ImageInterpolation.None) 强制像素风,低清底图与透镜内的细腻结果反差越大,功能感知越强。选中态样式用内联三元表达式,不要封装成按值传参的 @Builder,因为值只在首帧求值,选项卡会卡死在初始状态。状态栏实时显示右半算法名和单帧耗时,这是用户感知“实时”的最直接证据。

[标题]加分项:自举式盲锐化[/标题]

锐化强度 s 传统靠手调,但平坦区域锐了出噪点、纹理区域不锐就发糊,全局一个 s 必然顾此失彼。可以做一个“往返一致性”的对照实验,让图像自己回答:取 64×64 的透镜 patch,先用双三次下采样 factor 倍得到 16×16 的 tiny,再用与透镜完全相同的算法和候选锐化 s 放大回 64×64,把结果与原始 P 逐像素算 SSE,在候选集 {0, 0.4, 0.8, 1.2, 1.6} 中取误差最小者。

实现要点有几个:下采样用双三次而不是最近邻,让“低清化”的频谱特征贴近真实相机采样,实验结论才可迁移;校准里的放大路径必须与真实透镜完全同构,同算法、同级联,否则结论不成立;透明通道不参与误差;tiny 小于 4px 时直接放弃校准,小实验不如不做。开销方面,5 次小图放大的总开销不到 1ms,每一透镜帧都做校准也毫无压力。

这个策略有一个很有说服力的行为:在失焦光斑上它会自动选 0,也就是“这个区域不要锐化”,因为强行锐化只会放大噪点,往返实验里 s=0 的误差本来就最小。把 s 显示在状态栏里,用户会看到引擎在“自己做判断”。

[标题]验收清单与性能基准[/标题]

正确性方面:透镜移到图片四角与中心,结果无越界花屏(双份钳制生效);左右两半在选双线性时完全一致,说明管线没有不对称误差;底图缩放或换图后透镜坐标换算依然正确,rl 跟随显示缩放重算。

性能方面:4x 透镜单帧不超过 8ms(模拟器基准);快速拖动 10 秒,透镜始终朝最新位置追,没有“回放”轨迹;连续使用 5 分钟内存平稳,每帧临时 buffer 均被回收。

体验方面:松手后透镜停留原地,可单独拖动微调;状态栏显示算法名与耗时(可加自适应 s 值);透镜可一键保存 PNG,用 createImagePacker 写入沙箱,免存储权限。

参考基准也可以印证管线设计:雪山山脊上的透镜,左半双线性明显发糊,右半双三次棱线立挺,约 6ms/帧;2x 湖面倒影左右同为双线性,约 1ms/帧,用于验证管线零不对称误差;花间翠鸟这类微距题材是透镜交互的甜点区,适合逐区巡视水珠与羽毛。

[标题]三个改造方向[/标题]

换引擎:把 lens 里的算法换成任何想试的方案,NPU 调用、AI 模型、去噪、风格化都行,交互层一行不用改,这正是第 0 步定契约的回报。换数据源:把“源图缓冲常驻”换成相机帧队列,透镜就变成实时取景放大镜,但缓冲轮转与 VSync 对齐是新增工作量。升级对比:单指改双指,两只手指各带一个透镜跑不同算法,或者把左右分屏改成上下分屏、四象限。

[标题]复盘[/标题]

这篇教程刻意反过来讲:先钉死两个函数签名,再逐步填充背后的实现。你会发现无状态、无整图操作、请求合并这些架构决策,都是从签名推导出来的,而不是拍脑袋定的。做任何实时交互功能时,先写签名再写代码,是一条值得复用的经验。工程完整源码为 Image-Super-Resolution/sr-magic-lens(HarmonyOS 7 / API 26),可在此基础上继续魔改。
回复

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: 鸿蒙图像超分透镜实战:NAPI 异步管线与请求合并优化

这篇实战思路很清楚,尤其是先把 lens 和 lastParam 两个接口定死、坚持无状态和小窗计算,对实时透镜确实很关键。配置清单里 abiFilters 同时带 arm64-v8a 和 x86_64、Index.d.ts 同步声明这些点很实用。Native 侧半像素对齐、权重归一化、边界复制延拓和中间用 double 暂存这几个细节也很有帮助。异步部分把纯计算放 ExecuteLens、结果回 CompleteLens 的约束讲得很明白,高频调用下清理 async_work 这点也值得注意。想继续看触摸驱动里请求合并和只追最新具体怎么落地,比如拖动时旧请求是直接丢弃还是做序号校验,以及 Promise 返回乱序时 UI 侧怎么处理。
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: 鸿蒙图像超分透镜实战:NAPI 异步管线与请求合并优化

感谢分享,契约先行和无状态这两条很受用,尤其是“Native 永远不知道整图多大”这个约束,把实时性的边界讲得很清楚。半像素对齐、权重归一化和复制延拓这几个细节也很实在,异步三件套里强调 ExecuteLens 不碰 NAPI 更是关键提醒,高频调用下删除 async work 和释放任务确实不能忘。 想请教一下请求合并部分:触摸频率超过帧率时,是不是维护一个最新请求序号,CompleteLens 里只 resolve 最新序号、旧结果直接丢弃?另外高频调用下 ArrayBuffer 的创建和拷贝有没有考虑复用缓冲池,还是先以正确性和接口简洁为主?整体工程规模看着很友好,准备照着跑一遍。
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: 鸿蒙图像超分透镜实战:NAPI 异步管线与请求合并优化

楼主这篇把“先定契约”放在最前面,我觉得是整篇最值钱的地方。很多教程上来就贴 NAPI 注册代码,结果接口改来改去,ArkTS 那边跟着返工。lens 这个签名只吃透镜小窗参数、Native 完全不知道整图尺寸,这个约束一立,无状态和可回放就自然成立了,后面想加缓存或者并行的确不用动接口。 几个细节我很有共鸣:半像素对齐那条,写成角点对齐后边缘发灰、整图偏移半像素,肉眼在透镜这种放大场景下特别明显;边界用复制延拓也同意,零填充在放大看边缘时会出一圈黑边,体验很差。还有中间结果用 double 暂存最后才 clamp 回 uint8,这点容易被忽略,两次 8bit 舍入叠加在 4 倍放大下确实会让画面发脏。 异步三件套那段提醒 ExecuteLens 里绝不碰 NAPI、CompleteLens 末尾 delete work 和 task,这个对高频拖动来说太关键了,不释放的话跑一会儿就能感觉到。配置核对里 abiFilters 同时包含 arm64-v8a 和 x86_64 也是实打实的坑,模拟器上跑不起来大多栽在这。 帖子最后停在“手指移动事件的产生速度远超引擎帧率”,请求合并和只追最新这块正好是交互手感的关键,希望楼主能把这部分补完,尤其是怎么判定丢弃旧请求、以及丢弃时 Promise 那边怎么收尾不留下悬挂。整体工程规模一个下午跑通的说法我觉得挺实在,收藏了准备照着搭一遍。
回复 支持 反对

使用道具 举报

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

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

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

官方核心成员

关注微信公众号

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

GMT+8, 2026-11-5 02:51 , Processed in 0.021784 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部