【共创稿事节】鸿蒙图像超分 · 相册照片超分实验台:从系统相册选真实照片,端侧 AI 4× 超分、滑动对比、落盘沙箱

前四篇里,超分的输入一直是 rawfile 里的内置素材——单图重建、实时放大镜、批量工作台都很完整,但始终差「最后一公里」:用户自己的照片。这一篇把端侧超分真正接到系统相册上:用系统安全选择器 PhotoViewPicker 选一张真实照片,按长边降采样成「低清输入」,端侧 NPU 一次 4× 超分,左右滑动对比「普通放大 vs AI 超分」,最后把结果落盘成 PNG。全程零权限、零联网、数据不出设备。

结果页:滑动对比,左普通放大 4x / 右 AI 超分 4x

工程地址:LI_harmonyOS/sr-photo-lab
运行环境:HarmonyOS 7.0(API 26)+ ArkTS 严格模式,已在真机安装运行验证。
包名:com.example.srphotolab,module.json5 没有申请任何权限。


在这里插入图片描述

一、效果展示

完整流程只有三步:选照片 → 预览确认 → 超分对比并保存。以下截图均为演示模式(内置城市夜景素材)真机运行,真机模式走同一套 UI、只把超分内核换成 NPU。

选好照片后先停在预览页:来源描述(演示素材 / 相册原图尺寸 → 实际输入尺寸)、低清预览、输出尺寸预告 640×480,点「开始超分」才真正跑模型,避免选错照片白跑一次:

预览页:来源描述、低清预览、输出尺寸预告

超分完成后的分割对比界面如文首主图:分割线居中时左侧「普通放大 4x」、右侧「AI 超分 4x」同尺寸重合,下方给出清晰度增益 +40% 与尺寸变化 160×120 → 640×480。


二、这个 App 解决什么问题

批量工作台(第四篇)证明了端侧超分是个可批量、可量化、可落盘的工程能力,但输入还是预置素材。面对真实用户照片,会冒出三个新问题:

  1. 怎么合法拿到照片:不能自己翻媒体库,更不该申请一堆存储权限;
  2. 真实照片尺寸不可控:相册里随便一张就是 4000×3000,远超端侧超分 NPU 的输入上限 2048,直接喂模型必挂;
  3. 结果要让人信服、拿得走:得跟「普通放大」并排对比,还得存成文件。

这个实验台给出的答案是:系统安全选择器返回临时授权 URI → 解码时按长边降采样到 480 → NPU 超分 → 裁剪式滑动对比 → PNG 落应用沙箱。选择器和沙箱都不需要权限,照片和结果自始至终不离开设备。


三、交互设计:一个三态状态机

在这里插入图片描述

整个页面用一个 stage 字段驱动,三态互斥,build() 里按状态渲染不同区块,没有任何命令式的页面跳转:

  @State stage: 'pick' | 'preview' | 'result' = 'pick';
  @State busy: boolean = false;
        if (this.stage === 'pick') {
          this.pickStage()
        } else if (this.stage === 'preview') {
          this.previewStage()
        } else {
          this.resultStage()
        }
  • pick(选图页):说明文案 + 主按钮。按钮文案随模式切换——演示模式显示「使用演示素材」,真机模式显示「从相册选择照片」;
  • preview(预览页):展示来源描述和低清图,预告输出尺寸(输入宽高 × 4),用户确认后才点「开始超分」。这一步的价值是把耗时的 NPU 调用与选图解耦,也避免选错照片白跑一次;
  • result(结果页):滑动对比图 + 增益/尺寸两张数据卡 + 保存按钮 + 「再来一张」复位回 pick。

顶部「真机模式 / 演示模式」按钮只是翻转 useReal 布尔,演示模式免 NPU,任何设备都能完整跑通流程。


四、工程结构

sr-photo-lab/
├── entry/src/main/
│   ├── ets/
│   │   ├── pages/Index.ets            # 三态页面、相册选择器、滑动对比手势
│   │   └── common/
│   │       ├── PhotoLabModels.ets     # LoadedImage / ProcessResult / PackResult
│   │       ├── PhotoLabService.ets    # 单例服务:选图解码/降采样/NPU超分/落盘
│   │       └── PixelOps.ets           # 像素读取 / 双线性基线 / 梯度能量
│   └── resources/rawfile/demo/        # 4 组低清/高清演示素材

服务沿用系列前作的单例 + 双模式设计,UI 只调 loadDemo / loadFromAlbum / process / savePng,完全不感知底下是 NPU 还是预置素材。


五、核心实现

5.1 零权限选照片:PhotoViewPicker 安全选择器

读相册最「重」的做法是申请 READ_IMAGEVIDEO,但这里根本不需要——PhotoViewPicker 是系统提供的安全选择器,用户在系统 UI 里点选后,应用只拿到这张图的临时只读 URI,无需任何权限声明:

      const picker = new photoAccessHelper.PhotoViewPicker();
      const res = await picker.select({
        MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE,
        maxSelectNumber: 1
      });
      if (!res.photoUris || res.photoUris.length === 0) {
        return;
      }
      const loaded = await this.service.loadFromAlbum(res.photoUris[0], MAX_EDGE);

用户取消选择时 photoUris 为空,直接静默返回;抛异常则 toast 提示「未选择照片」。

5.2 URI 解码 + 长边降采样:把真实照片塞进 NPU

这是本篇最关键的一步。拿到的 URI 不能当文件路径直接读,要用 fileIo.openSync 换成 fd 再建 ImageSource。更重要的是:解码时直接带 desiredSize 按长边缩到 480——一来模拟「低清照片」的输入场景,二来保证落在 NPU 合法输入范围 [64, 2048] 内,三来解码内存也小得多:

      const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
      const src: image.ImageSource = image.createImageSource(file.fd);
      const info: image.ImageInfo = await src.getImageInfo();
      const w: number = info.size.width;
      const h: number = info.size.height;
      const ratio: number = maxEdge / Math.max(w, h);
      const dw: number = Math.max(1, Math.floor(w * ratio));
      const dh: number = Math.max(1, Math.floor(h * ratio));
      const opts: image.DecodingOptions = {
        desiredSize: { width: dw, height: dh },
        desiredPixelFormat: image.PixelMapFormat.RGBA_8888
      };
      const pm: image.PixelMap = await src.createPixelMap(opts);
      await src.release();
      fileIo.closeSync(file);
      return { lowPm: pm, lowW: dw, lowH: dh, srcW: w, srcH: h };

返回的 LoadedImage 同时保留真实原图尺寸(srcW/srcH)和实际喂给模型的尺寸(lowW/lowH),所以预览页能显示 相册照片 · 原图 4000×3000 → 输入 480×360 这样的完整链路。

5.3 处理主流程:NPU 超分 + 普通放大基线 + 增益量化

process 一条链路产出 UI 需要的全部东西:真机模式走 NPU,失败或演示模式回退到预置高清;同时把低清做双线性 4× 放大作为「普通放大」基线,最后用与第四篇同口径的梯度能量算出清晰度增益:

      let hdPm: image.PixelMap | null = null;
      if (useReal && this.analyzer) {
        hdPm = await this.superResolve(lowPm);
      }
      if (!hdPm) {
        hdPm = await this.decodeRawfile(DEMO_HD, DEMO_HD_W, DEMO_HD_H);
      }
      if (!hdPm) {
        return null;
      }
      const info: image.ImageInfo = await hdPm.getImageInfo();
      const hdW: number = info.size.width;
      const hdH: number = info.size.height;

      // 普通放大基线:低清双线性放大到高清尺寸
      const baselineBuf: Uint8Array | null = bilinearUpscaleTo(lowBuf, lowW, lowH, hdW, hdH);
      const eBilinear: number = gradientEnergy(baselineBuf, hdW, hdH);
      const eAi: number = gradientEnergy(hdBuf, hdW, hdH);
      let gain: number = Math.round((eAi / eBilinear - 1) * 100);
      if (gain < 0) {
        gain = 0;
      }
      if (gain > 99) {
        gain = 99;
      }

真机 NPU 调用本身与系列前几篇完全一致——降采样后长边只有 480,一次 process() 整图出结果,远小于 2048 上限:

      const imageData: visionBase.ImageData = { pixelMap: low };
      const request: visionBase.Request = { inputData: imageData };
      const response: imageSuperResolution.ISPResponse = await this.analyzer.process(request);
      return response.pixelMap;

5.4 滑动对比:Stack 叠两层 + clip 百分比裁剪

对比控件没有用任何像素混合,思路极其省事:Stack 里底层 AI 图满铺;上层放普通放大基线,只裁剪露出左侧 split 比例;再压一条白色分割线和两枚标签。拖动手势只改一个 0~1 的 @State split:

    Stack() {
      // 底层:AI 超分(满铺)
      Image(this.result!.hdPm!)
        .width('100%')
        .height('100%')
        .objectFit(ImageFit.Cover)
      // 上层:普通放大基线,与 AI 图同尺寸重合,仅裁剪露出左侧 split 比例
      Image(this.result!.baselinePm!)
        .width('100%')
        .height('100%')
        .objectFit(ImageFit.Cover)
        .align(Alignment.TopStart)
        .clip(new Rect({ width: `${this.split * 100}%`, height: '100%' }))

      // 分割线
      Column()
        .width(3)
        .height('100%')
        .backgroundColor('#FFFFFF')
        .position({ x: `${this.split * 100}%`, y: 0 })

两个容易错的细节:两张图都必须 objectFit(ImageFit.Cover) 且同尺寸,否则缩放口径不同,分割线两边会错位;上层要 .align(Alignment.TopStart),保证裁剪区域永远对应画面左侧。

手势用「起始位置 + 增量偏移 / 实际像素宽度」,宽度通过 onAreaChange 拿,最后 clamp 到 0~1,横屏、折叠屏都不会算错:

    .gesture(
      PanGesture({ fingers: 1, direction: PanDirection.Left | PanDirection.Right })
        .onActionStart(() => {
          this.startSplit = this.split;
        })
        .onActionUpdate((e: GestureEvent) => {
          const w: number = this.fullW > 0 ? this.fullW : 1;
          let s: number = this.startSplit + e.offsetX / w;
          if (s < 0) {
            s = 0;
          }
          if (s > 1) {
            s = 1;
          }
          this.split = s;
        })
    )

因为裁剪比例就是 split 本身,把分割线一路拖到边缘时 clip 宽度变成 100%,整屏只剩普通放大基线(反向拖到另一端则全屏是 AI 超分),方便单独检查某一侧的模糊程度:

分割线拖到一端,全屏查看单侧画面

5.5 结果落盘:打包 PNG 写应用沙箱

落盘沿用第四篇的零权限方案:imagePacker.packToData 打包成 PNG 字节,写入 filesDir/sr_photo_out。真实照片 URI 的临时授权在处理完就结束,而写自己的沙箱目录天经地义,依旧不需要任何媒体权限:

      const dir: string = this.context!.filesDir + '/' + OUT_DIR;
      fileIo.mkdir(dir);
      const path: string = `${dir}/${name}.png`;
      const opts: image.PackingOption = { format: 'image/png', quality: 100 };
      const packer = image.createImagePacker();
      const buf: ArrayBuffer = await packer.packToData(pm, opts);
      await packer.release();
      const file = fileIo.openSync(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC);
      fileIo.writeSync(file.fd, buf);
      fileIo.fsyncSync(file.fd);
      fileIo.closeSync(file);
      const stat = fileIo.statSync(path);
      return { path: path, sizeKB: stat.size / 1024 };

文件名用 photo-${Date.now()} 保证多次保存不互相覆盖。保存成功后界面直接回显沙箱完整路径与文件大小,路径文本挂了 copyOption(CopyOptions.InApp),长按即可复制;路径较长时会自动换行不截断:

保存成功:显示沙箱路径与文件大小 保存成功(路径较长时自动换行)

5.6 一个很隐蔽的坑:按钮事件穿透

从 pick 进入 preview 是整页重渲染,但手指抬起的那一瞬间,「开始超分」按钮恰好出现在入口按钮的位置附近,ArkUI 会把同一次点击序列派发给新按钮,导致选完照片立刻自动触发超分。解法是进入预览后短暂锁住开始按钮 350ms:

    this.blockStart = true;
    setTimeout(() => {
      this.blockStart = false;
    }, 350);
        .enabled(!this.busy && !this.blockStart)

六、真机运行指南

  • 演示模式(默认):主按钮为「使用演示素材」,高清取预置 hd_city.png,不调 NPU,任何设备都能跑通选图→预览→对比→保存全流程;
  • 真机模式:点右上「真机模式」切换,主按钮变为「从相册选择照片」,在系统相册里选任意图。应用会先把它按长边 480 降采样,再真实走一遍端侧 NPU 4× 超分;
  • 结果保存在沙箱 filesDir/sr_photo_out/photo-<时间戳>.png,取出方式:
    hdc shell "ls /data/app/el2/100/base/com.example.srphotolab/haps/entry/files/sr_photo_out"
    hdc file recv /data/app/el2/100/base/com.example.srphotolab/haps/entry/files/sr_photo_out ./out
    

七、踩坑清单速查表

现象原因修法
直接 openSync(uri) 解码失败picker 返回的是 file://docs/... 授权 URI,不是真实路径用 fd:openSync(uri) → createImageSource(file.fd)
大照片超分报错/超限NPU 输入范围 [64,2048],相册图常达数千像素解码带 desiredSize,长边降到 480
想申请相册权限被劝退安全选择器场景本就免权限PhotoViewPicker + 临时 URI,module.json5 零声明
选完图立刻自动超分页面切换瞬间点击事件穿透到新按钮blockStart 锁定 350ms 再放开
分割线两边画面错位两张图 objectFit/对齐方式不一致都用 Cover + 同尺寸,上层 align(TopStart)
拖到边缘比例跑出屏幕用了手势绝对坐标startSplit + offsetX / onAreaChange 实际宽度,再 clamp 0~1
保存的图文件名冲突固定文件名被覆盖photo-${Date.now()}.png
演示模式增益忽高忽低基线与高清口径不同同尺寸双线性基线 vs 高清,统一梯度能量口径

八、总结

至此图像超分系列走完了第五步:真实照片进、真实文件出。

  • 选图用系统安全选择器,零权限拿到临时 URI;
  • 解码即降采样,把任意尺寸的真实照片安全塞进端侧 NPU;
  • 滑动对比 + 梯度能量增益,让「AI 比普通放大清晰多少」一眼可见、有数可依;
  • 结果落盘沙箱,原图与结果全程不出设备。

这一篇把前四篇的能力从「演示工程」推到了「能处理用户自己照片的工具」。下一步可以继续往前推:把沙箱里的超分结果通过 photoAccessHelper 的媒体资产变更接口回写系统相册,或者接上系统分享面板直接发给好友;再往后,还可以把相机预览帧接进来,做「拍照即超分」。内核都已经在这五篇里备齐了。


配套源码见 LI_harmonyOS/sr-photo-lab,构建 0 错误,已在真机安装运行验证。

Logo

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

更多推荐