【共创稿事节】鸿蒙应用图像超分·双镜头细节放大镜:端侧 AI 超分与普通放大的同屏实时对比

本文是图像超分系列的第三篇。前两篇我们分别完成了「4 倍高清重建」主流程和「老照片修复」对比滑块,这一篇我们把超分能力做成一个更直观、更有演示张力的形态——超分双镜头·细节对比放大镜:手指按住图片移动,两枚圆形镜头实时跟手,左镜头是普通双线性放大 4× 的效果,右镜头是端侧 AI 超分 4× 的效果,同一取样位置、同一放大倍率,差距一眼可见。页面下方的指标卡还会实时给出当前位置的「细节增益」百分比。

文章配套完整可运行的工程源码(sr-detail-loupe),在 DevEco Studio 中 Sync 通过后直接运行到真机即可体验。全部推理与像素计算都在端侧完成,数据不出设备。

在这里插入图片描述

一、最终效果先看为快

应用启动后进入「城市夜景 · 灯火」示例。图片本体是 160×120 的低清原图(故意保留马赛克感),两枚镜头默认悬停在图片中心:

  • 左镜头(灰色边框):普通放大 4×,双线性插值,边缘发糊、锯齿感明显;
  • 右镜头(蓝色边框):AI 超分 4×,边缘干净、结构完整;
  • 下方指标卡给出 输入分辨率 / 超分分辨率 / 细节增益 三项实时数据。
双镜头首屏:城市夜景,细节增益 +16%

按住图片拖动,取样圆点跟手移动,两枚镜头实时刷新当前取样位置的放大内容。把镜头拖到帆船边缘时对比最强烈:左镜头里帆的斜边是一级一级的"楼梯",右镜头里则是平滑的斜线,此时细节增益 +10%:

帆船边缘对比:左普通放大锯齿明显,右 AI 超分平滑,+10%

镜头组会自动避开图片边缘:手指靠近顶部时镜头自动落到下方,靠近左右边时整体被夹回可视区内。每枚镜头下方都有标签(普通放大 4× / AI 超分 4×),截图、录屏时观众不需要解释就能看懂:

山峦日出:拖到山脊位置,标签与锯齿对比清晰可见

内置四组示例(城市夜景、花朵特写、山峦日出、海边风景),点「换一张」循环切换。不同素材的增益差异本身就是很好的讲解素材——花瓣、海面这类大面积平滑区域增益只有 +0%~+1%,而帆船、灯火这类高频细节区域可以到 +10% 以上:

花朵特写:中心取样 +1% 花朵拖动后:取样到花瓣边缘,增益升到 +4% 山峦日出:首屏状态 海边风景:大面积平滑区域增益 +0%,正好用来说明指标的物理含义

二、交互与产品设计思路

放大镜类 Demo 最容易做成"两张图左右一摆"的静态对比,观众感知不到"实时"。这一版的设计原则是:让差距跟着手指走。

  1. 镜头常驻:打开页面双镜头就显示在图片中心,不需要先做一次手势才出现,录屏第一帧就有内容;
  2. 跟手节流:手势帧 40ms 节流,取样、放大、能量计算、双帧生成全链路跑完仍然流畅;
  3. 抬手保留:手指抬起后镜头停在最后取样位置,方便对着某个位置讲解、截图;
  4. 量化指标:光靠肉眼说"更清楚"不够有说服力,指标卡的「细节增益」把差距变成一个可变动的数字,拖到哪里涨到哪里,演示效果非常好;
  5. 双模式:演示模式用同源预置高清素材(无需 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 提示,不白屏

六、运行指南

  1. DevEco Studio 打开 sr-detail-loupe 工程,等待 Sync 完成;
  2. 真机连接并开启开发者模式,签名配置完成后直接 Run;
  3. 启动即进入演示模式,按住图片拖动体验双镜头跟手;
  4. 点「切换真机模式」走端侧 NPU 推理链路(低清图 160×120 远小于 2048 上限,整图一次推理);
  5. 点「换一张」在四组示例间循环。

七、总结

这一篇把超分从"看结果"做成了"摸差距":同一个位置、同一个倍率,普通放大与端侧 AI 超分并排跟手,差距不再需要语言描述。技术上值得带走的三点:

  1. 放大镜场景的性能公式:整图一次推理 + 小窗内存裁剪 + 手势节流,把 O(每帧推理) 降为 O(每帧裁剪);
  2. 对比实验要控制变量:自研双线性插值保证"普通放大"口径完全可控,梯度能量给出可复现的量化指标;
  3. 坐标系统一是跟手体验的命门:窗口系(手势)↔ 组件系(布局)↔ 像素系(取样)三套坐标,换算基准与单位密度必须逐一核对。

系列至此,超分能力已经覆盖「重建 → 修复对比 → 实时对比」三种形态。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐