【共创稿事节】鸿蒙应用图像超分·双镜头细节放大镜:端侧 AI 超分与普通放大的同屏实时对比
【共创稿事节】鸿蒙应用图像超分·双镜头细节放大镜:端侧 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 数据模型:成对素材是一切的前提
export interface LoupeDemo {
id: string;
low: string; // 低清输入(展示原图)
hd: string; // 预置 AI 超分结果(演示模式的「AI 镜头」数据源)
title: string;
lowW: number; // 160×120
lowH: 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 帧 + 增益值):
public async loupeAt(cx: number, cy: number, useReal: boolean): Promise<LensPair | null> {
// 1. 坐标夹取,保证取样窗口不越界
const clampedX = Math.max(SAMPLE_SIDE / 2, Math.min(this.lowW - SAMPLE_SIDE / 2, cx));
// 2. 低清源:裁 40×40 → 手动双线性放大 4× →「普通放大」帧
const lowRegion = extractSquare(this.lowBuf, this.lowW, this.lowH, clampedX, clampedY, SAMPLE_SIDE);
const bilinearData = bilinearUpscale(lowRegion, SAMPLE_SIDE, UPSCALE);
// 3. 高清源:坐标 ×4 后裁 160×160 →「AI 超分」帧
const hdCx = clampedX * (this.hdW / this.lowW);
const hdRegion = extractSquare(this.hdBuf, this.hdW, this.hdH, hdCx, hdCy, SAMPLE_SIDE * UPSCALE);
// 4. 双帧各自算梯度能量,得出细节增益
const eBilinear = gradientEnergy(bilinearData, LENS_SIDE, LENS_SIDE);
const eAi = gradientEnergy(hdRegion, LENS_SIDE, LENS_SIDE);
let gain = Math.round((eAi / eBilinear - 1) * 100);
// 5. RGBA 缓冲转 PixelMap 供 Image 组件显示
const bilinearPm = await toPixelMap(bilinearData, LENS_SIDE);
const aiPm = await toPixelMap(hdRegion, LENS_SIDE);
return new LensPair(bilinearPm, aiPm, gain);
}
真机模式下,prepare() 阶段会对整张 160×120 低清图执行一次 ImageSRAnalyzer.process(),把 NPU 重建结果缓存为 hdBuf;之后每一次镜头取样都只是内存里的区域裁剪,一次推理,全程流畅——而不是每拖动一帧都去调一次推理接口。这是放大镜场景下最重要的性能决策:
if (useReal && this.analyzer) {
hdSource = await this.superResolve(lowRaw); // 整图一次 NPU 超分
}
if (!hdSource) {
hdSource = await this.decodeRawfile(demo.hd, demo.hdW, demo.hdH); // 回退预置素材
}
ImageSRAnalyzer 是重资源对象,随页面生命周期在 aboutToDisappear() 里 destroy();模式切换(演示 ↔ 真机)也走 destroy() → create() 的完整流程,失败自动回退演示模式并提示,保证任何设备上都不会白屏。
4.3 像素工具:把"普通放大"的口径握在自己手里
PixelOps.ets 提供五个纯函数。为什么不用 Image 组件自带的插值放大来做"普通放大"镜头?因为组件插值的行为受渲染管线影响,不可控也不可量化。手动双线性插值只有 40×40 → 160×160 的计算量,开销极小,却让两种口径完全可控:
/** 双线性插值放大(正方形区域,RGBA),用于「普通放大 4×」基线 */
export function bilinearUpscale(src: Uint8Array, side: number, scale: number): Uint8Array | null {
const dstSide = side * scale;
const out = new Uint8Array(dstSide * dstSide * 4);
for (let dy = 0; dy < dstSide; dy++) {
const gy = (dy + 0.5) / scale - 0.5; // 中心对齐采样,避免整体偏移
const y0 = Math.max(0, Math.min(side - 1, Math.floor(gy)));
const fy = Math.max(0, Math.min(1, gy - y0));
for (let dx = 0; dx < dstSide; dx++) {
// ... 对 RGB 三通道分别做 2×2 邻域加权
out[o + c] = clampByte(top + (bot - top) * fy);
}
}
return out;
}
「细节增益」的口径是梯度能量:邻域像素亮度差的绝对值之和,值越高代表边缘越锐利、细节越丰富。这与第一篇的 SharpnessMeter 是同一套算法,两篇文章的指标可以互相印证:
export function gradientEnergy(data: Uint8Array, w: number, h: number): number {
let sum = 0;
for (let y = 0; y < h - 1; y++) {
for (let x = 0; x < w - 1; x++) {
const l = 0.299 * r + 0.587 * g + 0.114 * b; // 当前点亮度
sum += Math.abs(lr - l) + Math.abs(ld - l); // 右邻差 + 下邻差
}
}
return sum;
}
AI 超分重建出更多高频细节,能量更高;平滑区域两种放大都"没东西可放",增益自然趋近 0——这正好解释了截图中海面 +0%、帆船 +10% 的差异。
4.4 手势与坐标换算:三个必须踩对的坑
放大镜跟手的实现只有几十行,但坐标换算有三个坑,踩错一个镜头就会"飘"。
坑一:手势坐标是屏幕系,组件定位是组件系。 FingerInfo.globalX/globalY 相对窗口,而镜头 position() 相对父组件。需要在手势开始时记录展示区在窗口中的偏移,每次事件里做减法:
private captureAreaOffset(): void {
const density = display.getDefaultDisplaySync().densityPixels;
const rect = this.getUIContext().getComponentUtils().getRectangleById('lensArea');
// windowOffset 单位是 px,除以像素密度换算成与 vp 口径一致的值
this.areaWindowX = Number(rect.windowOffset.x) / density;
this.areaWindowY = Number(rect.windowOffset.y) / density;
}
private onLensPan(fingerX: number, fingerY: number, isStart: boolean): void {
const x = fingerX - this.areaWindowX; // 屏幕坐标 → 组件坐标
const y = fingerY - this.areaWindowY;
if (x < 0 || y < 0 || x > this.areaWidth || y > IMG_HEIGHT) {
return; // 拖出展示区不响应
}
this.loupeX = x;
this.loupeY = y;
// 40ms 节流后,把组件坐标反算回低清图像素坐标去取样
const cx = x / this.areaWidth * demo.lowW;
const cy = 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(); // 每次手势开始重新校准偏移
const finger = event.fingerList[0];
if (finger) { this.onLensPan(finger.globalX, finger.globalY, true); }
})
.onActionUpdate((event: GestureEvent) => {
const finger = event.fingerList[0];
if (finger) { this.onLensPan(finger.globalX, finger.globalY, false); }
})
)
坑三:@Builder 与 @State 的配合。 镜头内容直接读 @State 的 PixelMap,帧就绪即刻渲染;释放上一帧后立刻赋新值,避免 PixelMap 泄漏:
private async refreshLens(cx: number, cy: number): Promise<void> {
const pair = await this.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(每帧裁剪);
- 对比实验要控制变量:自研双线性插值保证"普通放大"口径完全可控,梯度能量给出可复现的量化指标;
- 坐标系统一是跟手体验的命门:窗口系(手势)↔ 组件系(布局)↔ 像素系(取样)三套坐标,换算基准与单位密度必须逐一核对。
系列至此,超分能力已经覆盖「重建 → 修复对比 → 实时对比」三种形态。
更多推荐





所有评论(0)