【共创稿事节】鸿蒙应用图像超分·双镜头细节放大镜:端侧 AI 超分与普通放大的同屏实时对比
本文是图像超分系列的第三篇。前两篇我们分别完成了「4 倍高清重建」主流程和「老照片修复」对比滑块,这一篇我们把超分能力做成一个更直观、更有演示张力的形态——超分双镜头·细节对比放大镜:手指按住图片移动,两枚圆形镜头实时跟手,左镜头是普通双线性放大 4× 的效果,右镜头是端侧 AI 超分 4× 的效果,同一取样位置、同一放大倍率,差距一眼可见。页面下方的指标卡还会实时给出当前位置的「细节增益」百分比。
文章配套完整可运行的工程源码(sr-detail-loupe),在 DevEco Studio 中 Sync 通过后直接运行到真机即可体验。全部推理与像素计算都在端侧完成,数据不出设备。
一、最终效果先看为快
应用启动后进入「城市夜景 · 灯火」示例。图片本体是 160×120 的低清原图(故意保留马赛克感),两枚镜头默认悬停在图片中心:
- 左镜头(灰色边框):普通放大 4×,双线性插值,边缘发糊、锯齿感明显;
- 右镜头(蓝色边框):AI 超分 4×,边缘干净、结构完整;
- 下方指标卡给出
输入分辨率 / 超分分辨率 / 细节增益三项实时数据。
按住图片拖动,取样圆点跟手移动,两枚镜头实时刷新当前取样位置的放大内容。把镜头拖到帆船边缘时对比最强烈:左镜头里帆的斜边是一级一级的"楼梯",右镜头里则是平滑的斜线,此时细节增益 +10%:
镜头组会自动避开图片边缘:手指靠近顶部时镜头自动落到下方,靠近左右边时整体被夹回可视区内。每枚镜头下方都有标签(普通放大 4×/AI 超分 4×),截图、录屏时观众不需要解释就能看懂:
内置四组示例(城市夜景、花朵特写、山峦日出、海边风景),点「换一张」循环切换。不同素材的增益差异本身就是很好的讲解素材——花瓣、海面这类大面积平滑区域增益只有 +0%~+1%,而帆船、灯火这类高频细节区域可以到 +10% 以上:
二、交互与产品设计思路
放大镜类 Demo 最容易做成"两张图左右一摆"的静态对比,观众感知不到"实时"。这一版的设计原则是:让差距跟着手指走。
- 镜头常驻:打开页面双镜头就显示在图片中心,不需要先做一次手势才出现,录屏第一帧就有内容;
- 跟手节流:手势帧 40ms 节流,取样、放大、能量计算、双帧生成全链路跑完仍然流畅;
- 抬手保留:手指抬起后镜头停在最后取样位置,方便对着某个位置讲解、截图;
- 量化指标:光靠肉眼说"更清楚"不够有说服力,指标卡的「细节增益」把差距变成一个可变动的数字,拖到哪里涨到哪里,演示效果非常好;
- 双模式:演示模式用同源预置高清素材(无需 NPU,任何设备都能跑出完整效果);真机模式切换到
ImageSRAnalyzer端侧推理,接口语义完全一致,useReal一个布尔值切换实现。
三、工程结构
sr-detail-loupe/ └── entry/src/main/ets/ ├── common/ │ ├── LoupeModels.ets // 示例数据模型 + 四组「低清/高清」成对素材 │ ├── PixelOps.ets // 像素工具:读取/裁剪/双线性放大/梯度能量/转 PixelMap │ └── LoupeService.ets // 双模式镜头服务:一次取样产出双帧 + 增益 ├── entryability/ │ └── EntryAbility.ets └── pages/ └── Index.ets // 主页面:展示区 + 双镜头 + 指标卡 + 手势素材放在resources/rawfile/demo/下,命名成对:low_city.png(160×120)与hd_city.png(640×480),高清图是低清图同一画面的 4× 超分结果,保证两枚镜头看到的是"同一块区域"。
四、核心实现详解
4.1 数据模型:成对素材是一切的前提
exportinterfaceLoupeDemo{id:string;low:string;// 低清输入(展示原图)hd:string;// 预置 AI 超分结果(演示模式的「AI 镜头」数据源)title:string;lowW:number;// 160×120lowH:number;hdW:number;// 640×480,恰好是低清的 4×hdH:number;}为什么高清素材必须是低清的整数倍同源图?因为镜头取样时,低清窗口和高清窗口要做坐标一一对应:低清图上以(cx, cy)为中心取 40×40 的窗口,高清图上就以(cx×4, cy×4)为中心取 160×160 的窗口。两帧内容严格对齐,对比才有意义。
4.2 双模式服务:一次取样,产出双帧
LoupeService是整个应用的核心。loupeAt(cx, cy)接收低清图坐标,一次调用返回LensPair(双线性帧 + AI 帧 + 增益值):
publicasyncloupeAt(cx:number,cy:number,useReal:boolean):Promise<LensPair|null>{// 1. 坐标夹取,保证取样窗口不越界constclampedX=Math.max(SAMPLE_SIDE/2,Math.min(this.lowW-SAMPLE_SIDE/2,cx));// 2. 低清源:裁 40×40 → 手动双线性放大 4× →「普通放大」帧constlowRegion=extractSquare(this.lowBuf,this.lowW,this.lowH,clampedX,clampedY,SAMPLE_SIDE);constbilinearData=bilinearUpscale(lowRegion,SAMPLE_SIDE,UPSCALE);// 3. 高清源:坐标 ×4 后裁 160×160 →「AI 超分」帧consthdCx=clampedX*(this.hdW/this.lowW);consthdRegion=extractSquare(this.hdBuf,this.hdW,this.hdH,hdCx,hdCy,SAMPLE_SIDE*UPSCALE);// 4. 双帧各自算梯度能量,得出细节增益consteBilinear=gradientEnergy(bilinearData,LENS_SIDE,LENS_SIDE);consteAi=gradientEnergy(hdRegion,LENS_SIDE,LENS_SIDE);letgain=Math.round((eAi/eBilinear-1)*100);// 5. RGBA 缓冲转 PixelMap 供 Image 组件显示constbilinearPm=awaittoPixelMap(bilinearData,LENS_SIDE);constaiPm=awaittoPixelMap(hdRegion,LENS_SIDE);returnnewLensPair(bilinearPm,aiPm,gain);}真机模式下,prepare()阶段会对整张 160×120 低清图执行一次ImageSRAnalyzer.process(),把 NPU 重建结果缓存为hdBuf;之后每一次镜头取样都只是内存里的区域裁剪,一次推理,全程流畅——而不是每拖动一帧都去调一次推理接口。这是放大镜场景下最重要的性能决策:
if(useReal&&this.analyzer){hdSource=awaitthis.superResolve(lowRaw);// 整图一次 NPU 超分}if(!hdSource){hdSource=awaitthis.decodeRawfile(demo.hd,demo.hdW,demo.hdH);// 回退预置素材}ImageSRAnalyzer是重资源对象,随页面生命周期在aboutToDisappear()里destroy();模式切换(演示 ↔ 真机)也走destroy()→create()的完整流程,失败自动回退演示模式并提示,保证任何设备上都不会白屏。
4.3 像素工具:把"普通放大"的口径握在自己手里
PixelOps.ets提供五个纯函数。为什么不用Image组件自带的插值放大来做"普通放大"镜头?因为组件插值的行为受渲染管线影响,不可控也不可量化。手动双线性插值只有 40×40 → 160×160 的计算量,开销极小,却让两种口径完全可控:
/** 双线性插值放大(正方形区域,RGBA),用于「普通放大 4×」基线 */exportfunctionbilinearUpscale(src:Uint8Array,side:number,scale:number):Uint8Array|null{constdstSide=side*scale;constout=newUint8Array(dstSide*dstSide*4);for(letdy=0;dy<dstSide;dy++){constgy=(dy+0.5)/scale-0.5;// 中心对齐采样,避免整体偏移consty0=Math.max(0,Math.min(side-1,Math.floor(gy)));constfy=Math.max(0,Math.min(1,gy-y0));for(letdx=0;dx<dstSide;dx++){// ... 对 RGB 三通道分别做 2×2 邻域加权out[o+c]=clampByte(top+(bot-top)*fy);}}returnout;}「细节增益」的口径是梯度能量:邻域像素亮度差的绝对值之和,值越高代表边缘越锐利、细节越丰富。这与第一篇的SharpnessMeter是同一套算法,两篇文章的指标可以互相印证:
exportfunctiongradientEnergy(data:Uint8Array,w:number,h:number):number{letsum=0;for(lety=0;y<h-1;y++){for(letx=0;x<w-1;x++){constl=0.299*r+0.587*g+0.114*b;// 当前点亮度sum+=Math.abs(lr-l)+Math.abs(ld-l);// 右邻差 + 下邻差}}returnsum;}AI 超分重建出更多高频细节,能量更高;平滑区域两种放大都"没东西可放",增益自然趋近 0——这正好解释了截图中海面 +0%、帆船 +10% 的差异。
4.4 手势与坐标换算:三个必须踩对的坑
放大镜跟手的实现只有几十行,但坐标换算有三个坑,踩错一个镜头就会"飘"。
坑一:手势坐标是屏幕系,组件定位是组件系。FingerInfo.globalX/globalY相对窗口,而镜头position()相对父组件。需要在手势开始时记录展示区在窗口中的偏移,每次事件里做减法:
privatecaptureAreaOffset():void{constdensity=display.getDefaultDisplaySync().densityPixels;constrect=this.getUIContext().getComponentUtils().getRectangleById('lensArea');// windowOffset 单位是 px,除以像素密度换算成与 vp 口径一致的值this.areaWindowX=Number(rect.windowOffset.x)/density;this.areaWindowY=Number(rect.windowOffset.y)/density;}privateonLensPan(fingerX:number,fingerY:number,isStart:boolean):void{constx=fingerX-this.areaWindowX;// 屏幕坐标 → 组件坐标consty=fingerY-this.areaWindowY;if(x<0||y<0||x>this.areaWidth||y>IMG_HEIGHT){return;// 拖出展示区不响应}this.loupeX=x;this.loupeY=y;// 40ms 节流后,把组件坐标反算回低清图像素坐标去取样constcx=x/this.areaWidth*demo.lowW;constcy=y/IMG_HEIGHT*demo.lowH;this.refreshLens(cx,cy);}注意windowOffset的单位是 px,而组件布局用 vp,两者之间要除以densityPixels,否则在有密度缩放的屏幕上镜头永远差一个固定偏移。
坑二:手势方向选择。桌面预览器与真机对PanDirection.All的触发判定不一致,横竖都要跟手的场景下可能出现"按住了但手势不触发"。本工程实际使用PanDirection.Horizontal,配合distance: 5,各设备表现一致稳定:
.gesture(PanGesture({fingers:1,direction:PanDirection.Horizontal,distance:5}).onActionStart((event:GestureEvent)=>{this.captureAreaOffset();// 每次手势开始重新校准偏移constfinger=event.fingerList[0];if(finger){this.onLensPan(finger.globalX,finger.globalY,true);}}).onActionUpdate((event:GestureEvent)=>{constfinger=event.fingerList[0];if(finger){this.onLensPan(finger.globalX,finger.globalY,false);}}))坑三:@Builder 与 @State 的配合。镜头内容直接读@State的 PixelMap,帧就绪即刻渲染;释放上一帧后立刻赋新值,避免 PixelMap 泄漏:
privateasyncrefreshLens(cx:number,cy:number):Promise<void>{constpair=awaitthis.service.loupeAt(cx,cy,this.useReal);if(!pair){return;}if(this.bilinearPm){this.bilinearPm.release();}// 释放上一帧if(this.aiPm){this.aiPm.release();}this.bilinearPm=pair.bilinear;this.aiPm=pair.ai;this.gain=pair.gain;}另外 ArkTS 的@Builder返回void,不能链式调用.position(),镜头定位要写在@Builder内部组件上或外层容器上。镜头组的"翻面"逻辑也很简单:手指靠近顶部时loupeY - LENS_SIZE - 34 < 8,镜头落到取样点下方,否则悬在上方。
4.5 布局组织
展示区是一个Stack:底层低清Image,其上是镜头Row(两枚 128vp 圆形镜头并排)和取样指示Circle,最上层是加载遮罩。.clip(true)保证镜头移出图片边缘时被裁掉,.id('lensArea')供getRectangleById定位。指标卡是标准三列Row+ 竖直Divider,数字随@State实时刷新。
五、踩坑清单(速查)
| 问题 | 现象 | 解法 |
|---|---|---|
| 手势坐标错位 | 镜头总偏移一个固定距离 | FingerInfo.globalX/globalY是窗口坐标,需减去展示区windowOffset(px→vp 除以densityPixels) |
| 手势不触发 | 按住图片无反应 | PanDirection.All部分环境判定不稳,改用PanDirection.Horizontal+distance: 5 |
| 拖动卡顿 | 镜头刷新跟不上手 | 每帧全图推理不可行;改为整图一次推理 + 40ms 节流 + 40×40 小窗运算 |
| PixelMap 泄漏 | 长时间拖动内存上涨 | 每帧赋值前release()上一帧;页面销毁统一releaseFrames() |
@Builder报错 | void上链式.position()编译失败 | 定位属性写在@Builder内部组件上 |
createPixelMap假同步 | 拿到空图像 | 该 API 是异步的,必须await |
| 对象字面量当类型 | arkts-no-obj-literals-as-types | 用interface显式声明(LoupeDemo、LensPair均如此) |
| NPU 不可用 | 真机模式创建失败 | create()失败自动回退演示模式,toast提示,不白屏 |
六、运行指南
- DevEco Studio 打开
sr-detail-loupe工程,等待 Sync 完成; - 真机连接并开启开发者模式,签名配置完成后直接 Run;
- 启动即进入演示模式,按住图片拖动体验双镜头跟手;
- 点「切换真机模式」走端侧 NPU 推理链路(低清图 160×120 远小于 2048 上限,整图一次推理);
- 点「换一张」在四组示例间循环。
七、总结
这一篇把超分从"看结果"做成了"摸差距":同一个位置、同一个倍率,普通放大与端侧 AI 超分并排跟手,差距不再需要语言描述。技术上值得带走的三点:
- 放大镜场景的性能公式:整图一次推理 + 小窗内存裁剪 + 手势节流,把 O(每帧推理) 降为 O(每帧裁剪);
- 对比实验要控制变量:自研双线性插值保证"普通放大"口径完全可控,梯度能量给出可复现的量化指标;
- 坐标系统一是跟手体验的命门:窗口系(手势)↔ 组件系(布局)↔ 像素系(取样)三套坐标,换算基准与单位密度必须逐一核对。
系列至此,超分能力已经覆盖「重建 → 修复对比 → 实时对比」三种形态。