在 HarmonyOS 7(API 26)上做实时图像处理,难点往往不在算法本身,而在“交互帧率”和“Native 与 ArkTS 的边界”这两件事上。本文基于一个可复刻的“超分透镜”工程展开:手指按住低清图片任意位置,圆形透镜内即时显示该区域的 4 倍超分结果,拖动时逐帧刷新;透镜左半是双线性基线,右半是当前算法,同一圆内直接对比。官方给出的实测是单帧计算 1~6ms,60fps 预算只用了一半。
适合已有 ArkTS 页面开发经验、但没写过 NAPI 的开发者。工程规模约 Native 450 行加 ArkTS 350 行,顺利的话一个下午能跑通。需要说明的是,这里的算法全部是经典重采样与锐化,不涉及深度学习超分,但整条管线对 AI 引擎是完全开放的。
[标题]先定契约,再写代码[/标题]
交互式功能最容易死在“边写边想”。动手之前先把 Native 接口锁死,整个引擎只需要两个函数:
- // 取 (cx,cy) 处 2rl×2rl 源区域,放大 factor 倍,返回 2rl*factor 见方 RGBA
- lens(src: ArrayBuffer, sw: number, sh: number, cx: number, cy: number,
- rl: number, factor: number, algo: number): Promise<ArrayBuffer>
- // 最近一次自适应校准得到的锐化强度(UI 展示用,没有可返回 0)
- 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 的语义拆开是四步,每步都不长:
- // ① 裁剪:把透镜矩形钳制进图内,按行 memcpy
- int rx = ClampInt(cx - rl, 0, sw - 2 * rl);
- int ry = ClampInt(cy - rl, 0, sh - 2 * rl);
- for (int y = 0; y < m; ++y) // m = 2*rl
- memcpy(&patch[y * m * 4],
- &src[((ry + y) * sw + rx) * 4], m * 4);
- // ② 放大:双线性 2-tap / 双三次 4-tap,按轴分离先横后纵
- // ③(可选)自适应锐化,见后文
- // ④ 打包:结果拷进 napi_create_arraybuffer 返回
复制代码
重采样有三个细节必须守住。映射公式要用半像素对齐,即 (d + 0.5) * srcLen / dstLen - 0.5,写成角点对齐会让整图偏移半像素、边缘发灰。每个输出坐标要把核权重除以权重和做归一化,否则亮度会随相位漂移。边界用复制延拓(clamp to edge),不要零填充。
另一个影响观感的坑是精度:中间结果用 double 暂存 RGB,最后一步才 clamp 回 uint8。两次 8bit 舍入叠加,会让透镜画面明显发脏。
[标题]异步 Promise 三件套[/标题]
透镜计算不能占 UI 线程,但也不必想得太复杂,一个通用的异步任务模板可以长期复用:
- struct LensTask { // 一次调用的全部输入输出
- napi_async_work work;
- napi_deferred deferred;
- /* 输入:src、坐标、参数;输出:out 缓冲 */
- };
- napi_value LensAsync(napi_env env, napi_callback_info info) {
- auto* t = new LensTask(); // 1. 解析参数进 t
- napi_create_promise(env, &t->deferred, &promise);
- napi_create_async_work(env, nullptr, resource,
- ExecuteLens, CompleteLens, t, &t->work); // 2. 创建工作
- napi_queue_async_work(env, t->work); // 3. 入队,立即返回 promise
- return promise;
- }
复制代码
ExecuteLens 里只做纯计算,绝对不要调用任何 NAPI 接口,句柄非线程安全,调了必崩。所有结果装进 LensTask,等 CompleteLens 回到 JS 线程再用 napi_create_arraybuffer 打包并 resolve。CompleteLens 末尾记得 napi_delete_async_work 加 delete t,透镜是高频调用,泄漏会很快显形。
ArkTS 侧因此可以薄到两行:
- const out: ArrayBuffer = await nativeSr.lens(srcBuf, sw, sh, cx, cy, rl, factor, algo);
- const pm = await image.createPixelMap(out, { size: {...}, pixelFormat: RGBA_8888 });
复制代码
[标题]触摸驱动:请求合并与只追最新[/标题]
手指移动事件的产生速度远超引擎帧率,天真地“一个事件一个任务”会让透镜越追越落后。解法是请求合并,只在空闲时发起计算,忙碌时仅记录最新坐标,并在结果返回后校验请求号:
- private pending = false;
- private lastReq = 0;
- private onTouchAt(px: number, py: number): void {
- if (this.pending) { this.updateCoords(px, py); return; } // 忙:只记坐标
- this.lastReq++;
- const req = this.lastReq;
- this.pending = true;
- this.updateLens(req, px, py); // 空闲:立刻处理
- }
- private async updateLens(req: number, px: number, py: number): Promise<void> {
- const left = await this.svc.lens(..., ALGO_BILINEAR); // 左半:基线
- const right = await this.svc.lens(..., this.algo); // 右半:当前算法
- this.pending = false;
- if (req !== this.lastReq) return; // 过期请求:丢弃
- this.lensL = left.pm; // 状态更新即渲染
- this.lensR = right.pm;
- }
复制代码
坐标换算要注意:屏幕坐标乘以“源图宽 / 显示宽”,透镜源半径 rl 等于屏幕半径除以显示缩放。触摸位置与透镜拖动都要把中心钳制在图内,UI 层做一次、Native 层再兜底一次,防止越界读到脏数据。请求号校验必须放在 await 之后,这是“只追最新”的全部秘密。
[标题]圆形对比 UI[/标题]
不需要自绘组件,左右两个 Image 各绑一个 PixelMap,外层圆角裁切成圆即可:
- Row({ space: 2 }) {
- Image(this.lensL).width(LENS / 2).height(LENS) // 左半:双线性基线
- Image(this.lensR).width(LENS / 2).height(LENS) // 右半:当前算法
- }
- .borderRadius(LENS / 2)
- .clip(true)
- .border({ width: 2, color: '#5EEAD4' })
- .shadow({ radius: 12, color: '#5EEAD466' })
- .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),可在此基础上继续魔改。 |