
【共创稿事节】鸿蒙应用图像超分·双镜头细节放大镜端侧 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.png160×120与hd_city.png640×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):PromiseLensPair|null{// 1. 坐标夹取保证取样窗口不越界constclampedXMath.max(SAMPLE_SIDE/2,Math.min(this.lowW-SAMPLE_SIDE/2,cx));// 2. 低清源裁 40×40 → 手动双线性放大 4× →「普通放大」帧constlowRegionextractSquare(this.lowBuf,this.lowW,this.lowH,clampedX,clampedY,SAMPLE_SIDE);constbilinearDatabilinearUpscale(lowRegion,SAMPLE_SIDE,UPSCALE);// 3. 高清源坐标 ×4 后裁 160×160 →「AI 超分」帧consthdCxclampedX*(this.hdW/this.lowW);consthdRegionextractSquare(this.hdBuf,this.hdW,this.hdH,hdCx,hdCy,SAMPLE_SIDE*UPSCALE);// 4. 双帧各自算梯度能量得出细节增益consteBilineargradientEnergy(bilinearData,LENS_SIDE,LENS_SIDE);consteAigradientEnergy(hdRegion,LENS_SIDE,LENS_SIDE);letgainMath.round((eAi/eBilinear-1)*100);// 5. RGBA 缓冲转 PixelMap 供 Image 组件显示constbilinearPmawaittoPixelMap(bilinearData,LENS_SIDE);constaiPmawaittoPixelMap(hdRegion,LENS_SIDE);returnnewLensPair(bilinearPm,aiPm,gain);}真机模式下prepare()阶段会对整张 160×120 低清图执行一次ImageSRAnalyzer.process()把 NPU 重建结果缓存为hdBuf之后每一次镜头取样都只是内存里的区域裁剪一次推理全程流畅——而不是每拖动一帧都去调一次推理接口。这是放大镜场景下最重要的性能决策if(useRealthis.analyzer){hdSourceawaitthis.superResolve(lowRaw);// 整图一次 NPU 超分}if(!hdSource){hdSourceawaitthis.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{constdstSideside*scale;constoutnewUint8Array(dstSide*dstSide*4);for(letdy0;dydstSide;dy){constgy(dy0.5)/scale-0.5;// 中心对齐采样避免整体偏移consty0Math.max(0,Math.min(side-1,Math.floor(gy)));constfyMath.max(0,Math.min(1,gy-y0));for(letdx0;dxdstSide;dx){// ... 对 RGB 三通道分别做 2×2 邻域加权out[oc]clampByte(top(bot-top)*fy);}}returnout;}「细节增益」的口径是梯度能量邻域像素亮度差的绝对值之和值越高代表边缘越锐利、细节越丰富。这与第一篇的SharpnessMeter是同一套算法两篇文章的指标可以互相印证exportfunctiongradientEnergy(data:Uint8Array,w:number,h:number):number{letsum0;for(lety0;yh-1;y){for(letx0;xw-1;x){constl0.299*r0.587*g0.114*b;// 当前点亮度sumMath.abs(lr-l)Math.abs(ld-l);// 右邻差 下邻差}}returnsum;}AI 超分重建出更多高频细节能量更高平滑区域两种放大都没东西可放增益自然趋近 0——这正好解释了截图中海面 0%、帆船 10% 的差异。4.4 手势与坐标换算三个必须踩对的坑放大镜跟手的实现只有几十行但坐标换算有三个坑踩错一个镜头就会飘。坑一手势坐标是屏幕系组件定位是组件系。FingerInfo.globalX/globalY相对窗口而镜头position()相对父组件。需要在手势开始时记录展示区在窗口中的偏移每次事件里做减法privatecaptureAreaOffset():void{constdensitydisplay.getDefaultDisplaySync().densityPixels;constrectthis.getUIContext().getComponentUtils().getRectangleById(lensArea);// windowOffset 单位是 px除以像素密度换算成与 vp 口径一致的值this.areaWindowXNumber(rect.windowOffset.x)/density;this.areaWindowYNumber(rect.windowOffset.y)/density;}privateonLensPan(fingerX:number,fingerY:number,isStart:boolean):void{constxfingerX-this.areaWindowX;// 屏幕坐标 → 组件坐标constyfingerY-this.areaWindowY;if(x0||y0||xthis.areaWidth||yIMG_HEIGHT){return;// 拖出展示区不响应}this.loupeXx;this.loupeYy;// 40ms 节流后把组件坐标反算回低清图像素坐标去取样constcxx/this.areaWidth*demo.lowW;constcyy/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();// 每次手势开始重新校准偏移constfingerevent.fingerList[0];if(finger){this.onLensPan(finger.globalX,finger.globalY,true);}}).onActionUpdate((event:GestureEvent){constfingerevent.fingerList[0];if(finger){this.onLensPan(finger.globalX,finger.globalY,false);}}))坑三Builder 与 State 的配合。镜头内容直接读State的 PixelMap帧就绪即刻渲染释放上一帧后立刻赋新值避免 PixelMap 泄漏privateasyncrefreshLens(cx:number,cy:number):Promisevoid{constpairawaitthis.service.loupeAt(cx,cy,this.useReal);if(!pair){return;}if(this.bilinearPm){this.bilinearPm.release();}// 释放上一帧if(this.aiPm){this.aiPm.release();}this.bilinearPmpair.bilinear;this.aiPmpair.ai;this.gainpair.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是窗口坐标需减去展示区windowOffsetpx→vp 除以densityPixels手势不触发按住图片无反应PanDirection.All部分环境判定不稳改用PanDirection.Horizontaldistance: 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(每帧裁剪)对比实验要控制变量自研双线性插值保证普通放大口径完全可控梯度能量给出可复现的量化指标坐标系统一是跟手体验的命门窗口系手势↔ 组件系布局↔ 像素系取样三套坐标换算基准与单位密度必须逐一核对。系列至此超分能力已经覆盖「重建 → 修复对比 → 实时对比」三种形态。