前言

图像超分辨率解决的是低分辨率图片的细节重建问题,并不等同于无损恢复原始信息。HarmonyOS 7 的 @kit.CoreVisionKit 提供了端侧 imageSuperResolution,可以将输入 PixelMap 交给 ImageSRAnalyzer 处理。本文把模型调用、尺寸预处理、前后对比和相册保存串成一条完整流程,同时说明输入尺寸和资源释放等限制。

效果演示

在这里插入图片描述

项目准备

创建工程

在 DevEco Studio 中新建 Empty Ability 工程,API 版本选择 26(HarmonyOS7),Stage 模型。

权限配置

图像超分修复涉及读取和保存图片,需要在 entry/src/main/module.json5 中声明以下权限:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.READ_IMAGEVIDEO",
        "reason": "$string:read_imagevideo_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.WRITE_IMAGEVIDEO",
        "reason": "$string:read_imagevideo_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}
  • READ_IMAGEVIDEO:从相册选择图片时需要读取权限
  • WRITE_IMAGEVIDEO:保存修复后图片到相册需要写入权限

这两个权限都是用户授权权限(user_grant),需要在运行时动态申请。本案例中,系统会在首次选择图片和首次保存时自动弹出授权弹窗。

核心实现:a1.ets 完整拆解

导入与常量

import { imageSuperResolution, visionBase } from '@kit.CoreVisionKit';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo, picker } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { common } from '@kit.AbilityKit';

各 Kit 的职责:

  • @kit.CoreVisionKit:核心——imageSuperResolution 提供超分分析器,visionBase 提供通用的 ImageDataRequest 类型
  • @kit.ImageKit:图片编解码——ImageSource 解码、ImagePacker 编码、PixelMap 操作
  • @kit.CoreFileKit:文件读写 + 图片选择器(picker.PhotoViewPicker
  • @kit.MediaLibraryKit:相册写入(photoAccessHelper
  • @kit.AbilityKit:获取 UIAbilityContext 用于文件路径和相册操作
  • @kit.PerformanceAnalysisKit:日志输出
  • @kit.BasicServicesKitBusinessError 类型用于错误处理
const DOMAIN = 0x0000;
const TAG = 'ImageSuperResolution';

hilog 的 DOMAIN 和 TAG 常量,DOMAIN = 0x0000 是应用级域。

组件状态定义

@Entry
@Component
struct A1 {
  @State inputImage: PixelMap | undefined = undefined;
  @State outputImage: PixelMap | undefined = undefined;
  @State statusText: string = '';
  @State isProcessing: boolean = false;
  @State isRestored: boolean = false;
  @State sliderOffset: number = 0.5;
  @State containerWidth: number = 0;
  @State inputSize: string = '';
  @State outputSize: string = '';
  @State stepIndex: number = 0;
  @State showCompareTip: boolean = false;
  private analyzer: imageSuperResolution.ImageSRAnalyzer | null = null;
  private lastOffset: number = 0.5;
}

状态分四组:

  • 图片数据inputImage(原图)、outputImage(修复后图片),类型是 PixelMap
  • 流程控制isProcessing(是否正在处理)、isRestored(是否已完成修复)、stepIndex(步骤进度 0/1/2/3)
  • 对比交互sliderOffset(0~1,原图裁切位置)、containerWidth(容器宽度,用于手势偏移计算)、lastOffset(手势开始时的偏移快照)
  • 信息展示inputSize / outputSize(图片尺寸文本)、statusText(状态提示)、showCompareTip(是否显示对比提示)

analyzer 是超分分析器实例,private 修饰不触发 UI 刷新——它只是工具对象,不需要驱动界面更新。

分析器生命周期:创建与销毁

async aboutToAppear(): Promise<void> {
  try {
    this.analyzer = await imageSuperResolution.ImageSRAnalyzer.create();
    hilog.info(DOMAIN, TAG, 'ImageSRAnalyzer created');
  } catch (error) {
    let err: BusinessError = error as BusinessError;
    hilog.error(DOMAIN, TAG, `Create analyzer failed: ${err.code}, ${err.message}`);
  }
}

async aboutToDisappear(): Promise<void> {
  if (this.analyzer) {
    try {
      await this.analyzer.destroy();
      hilog.info(DOMAIN, TAG, 'Analyzer destroyed');
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG, `Destroy failed: ${err.code}, ${err.message}`);
    }
  }
}

ImageSRAnalyzer.create() 是异步工厂方法,不能 new,必须用 create() 创建。分析器占用端侧 NPU 资源,所以 aboutToDisappear 里必须调用 destroy() 释放。如果忘了销毁,下次进入页面可能创建失败——资源被占着呢。

图片选择:PhotoViewPicker

private async selectImage(): Promise<void> {
  try {
    let options = new picker.PhotoSelectOptions();
    options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
    options.maxSelectNumber = 1;
    let photoPicker = new picker.PhotoViewPicker();
    let result: picker.PhotoSelectResult = await photoPicker.select(options);
    if (result.photoUris.length === 0) {
      return;
    }
    await this.loadImage(result.photoUris[0]);
  } catch (error) {
    let err: BusinessError = error as BusinessError;
    hilog.error(DOMAIN, TAG, `Select failed: ${err.code}, ${err.message}`);
    this.statusText = '选择图片失败';
  }
}

HarmonyOS7 的图片选择用 picker.PhotoViewPicker,不再用旧版的 PhotoPicker。配置 MIMEType = IMAGE_TYPE 过滤只显示图片,maxSelectNumber = 1 限制单选。选择完成后 result.photoUris[0] 拿到的是 URI 字符串,传给 loadImage 加载。

图片加载:URI → PixelMap

private async loadImage(uri: string): Promise<void> {
  try {
    let fileSource = await fileIo.open(uri, fileIo.OpenMode.READ_ONLY);
    let imageSource: image.ImageSource = image.createImageSource(fileSource.fd);
    this.inputImage = await imageSource.createPixelMap();
    let info: image.ImageInfo = this.inputImage.getImageInfoSync();
    this.inputSize = `${info.size.width} x ${info.size.height}`;
    this.outputImage = undefined;
    this.outputSize = '';
    this.isRestored = false;
    this.sliderOffset = 0.5;
    this.stepIndex = 1;
    this.statusText = '照片已就绪';
    await fileIo.close(fileSource);
    await imageSource.release();
  } catch (error) {
    let err: BusinessError = error as BusinessError;
    hilog.error(DOMAIN, TAG, `Load failed: ${err.code}, ${err.message}`);
    this.statusText = '加载图片失败';
  }
}

URI 不能直接用——必须通过 fileIo.open 拿到文件描述符 fd,再用 image.createImageSource(fd) 创建 ImageSource,最后 createPixelMap() 解码为 PixelMap。

加载完成后同步获取图片尺寸 getImageInfoSync(),用于界面展示。同时重置所有输出状态(outputImageoutputSizeisRestoredsliderOffset),确保切换图片时不会残留上一次的结果。

资源释放不能忘fileIo.close(fileSource) 关闭文件描述符,imageSource.release() 释放解码器资源。这两个如果不做,文件句柄泄漏,多选几次图片就会出问题。

尺寸预处理:限制最大分辨率

private async scaleToLimit(pixelMap: PixelMap, maxDim: number): Promise<PixelMap> {
  let info: image.ImageInfo = pixelMap.getImageInfoSync();
  let w: number = info.size.width;
  let h: number = info.size.height;
  if (w <= maxDim && h <= maxDim) {
    return pixelMap;
  }
  let scale: number = Math.min(maxDim / w, maxDim / h);
  let newW: number = Math.floor(w * scale);
  let newH: number = Math.floor(h * scale);
  await pixelMap.scale(scale, scale);
  hilog.info(DOMAIN, TAG, `Scaled from ${w}x${h} to ${newW}x${newH}`);
  return pixelMap;
}

ImageSRAnalyzer 对输入图片的最大尺寸有限制(2048 像素),超过会报错。所以处理前必须缩放。scaleToLimit 的逻辑:取宽高中超过 maxDim 的那条边,计算缩放比例 Math.min(maxDim / w, maxDim / h),确保缩放后两条边都不超限。

注意 PixelMap.scale() 是原地修改(in-place),不是返回新对象。所以这里直接返回入参 pixelMap,调用方拿到的已经是缩放后的图。

核心处理:超分辨率推理

private async startRestore(): Promise<void> {
  if (!this.inputImage || !this.analyzer) {
    this.statusText = '请先选择照片';
    return;
  }
  this.isProcessing = true;
  this.stepIndex = 2;
  this.statusText = '正在超分修复中...';
  try {
    let scaledInput: PixelMap = await this.scaleToLimit(this.inputImage!, 2048);
    let imageData: visionBase.ImageData = {
      pixelMap: scaledInput
    };
    let request: visionBase.Request = {
      inputData: imageData
    };
    let response: imageSuperResolution.ISPResponse = await this.analyzer.process(request);
    this.outputImage = response.pixelMap;
    let outInfo: image.ImageInfo = this.outputImage.getImageInfoSync();
    this.outputSize = `${outInfo.size.width} x ${outInfo.size.height}`;
    this.isRestored = true;
    this.stepIndex = 3;
    this.statusText = '修复完成';
    this.showCompareTip = true;
    setTimeout(() => {
      this.showCompareTip = false;
    }, 3000);
    hilog.info(DOMAIN, TAG, 'Super resolution completed');
  } catch (error) {
    let err: BusinessError = error as BusinessError;
    hilog.error(DOMAIN, TAG, `Process failed: ${err.code}, ${err.message}`);
    this.statusText = '修复失败,请重试';
    this.stepIndex = 1;
  }
  this.isProcessing = false;
}

这是整个案例最核心的三步:

  1. scaleToLimit(input, 2048):缩放到 2048 以内,避免 API 报错
  2. 构造请求visionBase.ImageData 包装 PixelMap → visionBase.Request 包装 ImageData。CoreVisionKit 的所有视觉能力都用这套 Request → Response 模型
  3. analyzer.process(request):执行超分推理,返回 ISPResponse,其中 response.pixelMap 就是修复后的高分辨率图片

处理完成后更新尺寸信息、步骤状态,并显示 3 秒对比提示。失败时步骤回退到 1(照片已就绪),让用户可以重试。

保存到相册:编码 + MediaStore 写入

private async saveToGallery(): Promise<void> {
  if (!this.outputImage) {
    return;
  }
  try {
    let context: Context = getContext(this) as common.UIAbilityContext;
    let imagePacker: image.ImagePacker = image.createImagePacker();
    let packOpts: image.PackingOption = {
      format: 'image/jpeg',
      quality: 95
    };
    let packingData: ArrayBuffer = await imagePacker.packing(this.outputImage!, packOpts);
    let fileName = `SR_Restored_${Date.now()}.jpg`;
    let filePath = `${context.filesDir}/${fileName}`;
    let file = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
    fileIo.writeSync(file.fd, packingData);
    fileIo.closeSync(file);
    await imagePacker.release();

    let phAccessHelper: photoAccessHelper.PhotoAccessHelper =
      photoAccessHelper.getPhotoAccessHelper(context);
    let assetChangeRequest: photoAccessHelper.MediaAssetChangeRequest =
      photoAccessHelper.MediaAssetChangeRequest.createImageAssetRequest(context, filePath);
    await phAccessHelper.applyChanges(assetChangeRequest);
    this.statusText = '已保存到相册';
    hilog.info(DOMAIN, TAG, 'Saved to gallery');
    try {
      fileIo.unlinkSync(filePath);
    } catch (_e) {
      // ignore
    }
  } catch (error) {
    let err: BusinessError = error as BusinessError;
    hilog.error(DOMAIN, TAG, `Save failed: ${err.code}, ${err.message}`);
    this.statusText = '保存失败';
  }
}

保存流程分两阶段:

阶段一:PixelMap → JPEG 文件

  1. ImagePacker 将 PixelMap 编码为 JPEG,质量 95(高质量)
  2. 写入应用沙箱目录 context.filesDir,文件名用时间戳避免冲突
  3. fileIo.openSync / writeSync / closeSync 同步写入——这里用同步没问题,编码已经完成,写入量不大

阶段二:沙箱文件 → 系统相册

  1. photoAccessHelper.getPhotoAccessHelper(context) 获取相册管理器
  2. MediaAssetChangeRequest.createImageAssetRequest(context, filePath) 创建一个"把文件导入相册"的请求
  3. phAccessHelper.applyChanges(request) 执行导入

导入完成后,用 fileIo.unlinkSync 删除沙箱中的临时文件——图片已经在系统相册里了,临时文件没用了。删失败也不影响,所以 _e 被静默忽略。

滑动对比组件:Before/After 交互

这是 UI 部分最复杂的 Builder,逐步拆解:

####1 Stack 叠放两层图片

Stack() {
  if (this.outputImage) {
    Image(this.outputImage)
      .objectFit(ImageFit.Contain)
      .width('100%')
      .height('100%')
  }
  if (this.inputImage) {
    Row() {
      Column() {
        Image(this.inputImage)
          .objectFit(ImageFit.Contain)
          .width('100%')
          .height('100%')
      }
      .width(`${this.sliderOffset * 100}%`)
      .height('100%')
      .clip(true)
    }
    .width('100%')
    .height('100%')
  }
}

Stack 里的层次:底层是修复后图片(全宽),上层是原图(宽度由 sliderOffset 控制)。原图外层用 Row + Column 包裹,Column 的 width 设为 sliderOffset * 100%,配合 clip(true) 裁切溢出部分。

sliderOffset = 0.5 时,原图占 50% 宽度,右边 50% 露出底层的修复图——这就是 Before/After 效果。

####2 标签层:原图/修复后

if (this.isRestored) {
  Row() {
    Column() {
      Text('原图')
        .fontSize(11)
        .fontColor('#FFFFFF')
        .backgroundColor('#000000AA')
        .padding({ left: 6, right: 6, top: 3, bottom: 3 })
        .borderRadius(4)
    }
    .width(`${this.sliderOffset * 100}%`)
    .justifyContent(FlexAlign.Start)
    .padding({ left: 12, top: 8 })

    Column() {
      Text('修复后')
        .fontSize(11)
        .fontColor('#FFFFFF')
        .backgroundColor('#6366F1AA')
        .padding({ left: 6, right: 6, top: 3, bottom: 3 })
        .borderRadius(4)
    }
    .layoutWeight(1)
    .justifyContent(FlexAlign.End)
    .padding({ right: 12, top: 8 })
  }
  .width('100%')
  .height(32)
  .position({ x: 0, y: 0 })
}

标签也跟随 sliderOffset 动态分割——左侧"原图"标签的宽度等于原图可见宽度,右侧"修复后"标签占剩余空间。用 position({ x: 0, y: 0 }) 固定在 Stack 顶部。

####3 分割线与拖拽手柄

if (this.isRestored) {
  Column() {
    Column() {
      Row() {
        Column()
          .width(2)
          .height(16)
          .backgroundColor('#FFFFFF')
        Column()
          .width(2)
          .height(16)
          .backgroundColor('#FFFFFF')
      }
      .alignItems(VerticalAlign.Center)
    }
    .width(28)
    .height(28)
    .borderRadius(14)
    .backgroundColor('#FFFFFF')
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
  }
  .width(2)
  .height('100%')
  .backgroundColor('#FFFFFF66')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .position({ x: `${this.sliderOffset * 100}%`, y: 0 })
  .markAnchor({ x: 1 })
}

分割线由两部分组成:一条半透明白色竖线(宽 2px,高 100%)+ 一个圆形手柄(28x28,白色背景,中间两条竖线图标)。整体用 position({ x: sliderOffset * 100% }) 定位到分割位置。

markAnchor({ x: 1 }) 是关键——它把定位锚点右移 1px。因为 position 是基于组件左边缘定位的,如果不设锚点偏移,竖线会出现在 sliderOffset 位置的右侧 1px 处,和原图裁切线对不上。markAnchor({ x: 1 }) 让竖线左边缘和裁切线重合。

####4 手势驱动:PanGesture

.gesture(
  PanGesture()
    .onActionStart(() => {
      this.lastOffset = this.sliderOffset;
    })
    .onActionUpdate((event: GestureEvent) => {
      if (this.containerWidth > 0) {
        let newOffset = this.lastOffset + event.offsetX / this.containerWidth;
        this.sliderOffset = Math.max(0.05, Math.min(0.95, newOffset));
      }
    })
)

手势逻辑:

  1. onActionStart:记录开始滑动时的 sliderOffset 快照到 lastOffset
  2. onActionUpdatelastOffset + offsetX / containerWidth 计算新偏移——offsetX 是手指相对起点的水平位移,除以容器宽度转为 0~1 的比例
  3. Math.max(0.05, Math.min(0.95, newOffset)):限制范围 5%~95%,防止原图完全不可见

为什么要用 lastOffset 而不是直接累加? 因为 PanGestureoffsetX 是相对起点的累计偏移,不是每帧的增量。如果直接在 sliderOffset 上累加,手指抬起再按下时会从上次的终点开始跳变。用 lastOffset 快照 + 增量计算,每次手势都从按下时的位置开始,不会跳。

####5 容器宽度获取

.onAreaChange((_oldArea: Area, newArea: Area) => {
  this.containerWidth = Number(newArea.width);
})

containerWidth 在手势计算中当分母,必须知道容器的实际像素宽度。onAreaChange 在组件首次布局和尺寸变化时触发,用 Number(newArea.width) 转换 Area 的宽度值为数字。

顶部步骤进度条

@Builder
headerBar() {
  Row() {
    Column() {
      Text('AI')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
      Text('超分修复')
        .fontSize(10)
        .fontColor('#FFFFFFAA')
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Center)

    Blank()

    Text('图像超分辨率')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .layoutWeight(1)
      .textAlign(TextAlign.Center)

    Blank()

    Row() {
      Text(this.stepIndex === 0 ? '1' : (this.stepIndex >= 1 ? '✓' : '1'))
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor(this.stepIndex >= 1 ? '#0D0D1A' : '#FFFFFF')
        .width(24)
        .height(24)
        .textAlign(TextAlign.Center)
        .borderRadius(12)
        .backgroundColor(this.stepIndex >= 1 ? '#4ADE80' : '#333355')
      // ... 步骤 2、3 同理
    }
  }
  .width('100%')
  .height(56)
  .padding({ left: 20, right: 20 })
  .alignItems(VerticalAlign.Center)
  .backgroundColor('#0F0F23')
}

三步进度条:选图(1) → 修复(2) → 完成(3)。stepIndex 控制每个圆圈的状态——未到达显示数字,已到达显示绿色背景 + ✓。条件表达式 this.stepIndex >= 1 ? '✓' : '1' 根据当前步骤判断显示内容,backgroundColor 同理切换颜色。

空状态占位

@Builder
emptyPlaceholder() {
  Column() {
    Column() {
      Text('🖼️')
        .fontSize(56)
        .margin({ bottom: 16 })
      Text('选择一张老旧照片')
        .fontSize(18)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Medium)
      Text('AI 超分辨率技术,让模糊旧照焕然一新')
        .fontSize(13)
        .fontColor('#AAAACC')
        .margin({ top: 8 })
    }
    .justifyContent(FlexAlign.Center)
    .alignItems(HorizontalAlign.Center)
    .width('100%')
    .layoutWeight(1)

    Row() {
      Column() {
        Text('🔍')
          .fontSize(28)
        Text('智能超分')
          .fontSize(12)
          .fontColor('#AAAACC')
          .margin({ top: 6 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 16, bottom: 16 })
      .backgroundColor('#1A1A33')
      .borderRadius(12)
      // ... 其他两个卡片同理
    }
    .width('100%')
    .padding({ left: 20, right: 20, bottom: 24 })
  }
  .width('100%')
  .layoutWeight(1)
}

未选择图片时显示引导页——大图标 + 提示文字 + 三个功能卡片(智能超分 / 4倍放大 / 画质增强),告诉用户这个应用能干什么。选中图片后切换到对比区。

底部操作面板

@Builder
controlPanel() {
  Column() {
    Row() {
      Text(this.statusText)
        .fontSize(14)
        .fontColor(this.isRestored ? '#4ADE80' : '#CCCCDD')
        .fontWeight(this.isRestored ? FontWeight.Bold : FontWeight.Normal)
    }
    .width('100%')
    .justifyContent(FlexAlign.Center)
    .padding({ top: 16, bottom: 12 })

    if (this.isProcessing) {
      Progress({ value: 0, total: 0, type: ProgressType.Ring })
        .width(32)
        .height(32)
        .color('#6366F1')
        .margin({ bottom: 12 })
    }

    Row() {
      Button('选择照片')
        .type(ButtonType.Capsule)
        .fontColor('#FFFFFF')
        .fontSize(15)
        .fontWeight(FontWeight.Medium)
        .backgroundColor('#6366F1')
        .layoutWeight(1)
        .height(44)
        .enabled(!this.isProcessing)
        .onClick(() => {
          void this.selectImage();
        })

      if (!this.isRestored) {
        Button(this.isProcessing ? '修复中...' : '开始修复')
          .type(ButtonType.Capsule)
          .fontColor('#FFFFFF')
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .backgroundColor(this.isProcessing ? '#333355' : '#F59E0B')
          .layoutWeight(1)
          .height(44)
          .margin({ left: 12 })
          .enabled(!this.isProcessing && this.inputImage !== undefined)
          .onClick(() => {
            void this.startRestore();
          })
      }

      if (this.isRestored) {
        Button('保存到相册')
          .type(ButtonType.Capsule)
          .fontColor('#FFFFFF')
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .backgroundColor('#10B981')
          .layoutWeight(1)
          .height(44)
          .margin({ left: 12 })
          .onClick(() => {
            void this.saveToGallery();
          })
      }
    }
    .width('100%')
    .padding({ left: 20, right: 20 })

    if (this.isRestored) {
      Text('← 滑动对比修复前后效果 →')
        .fontSize(12)
        .fontColor('#666688')
        .padding({ top: 10, bottom: 16 })
    } else {
      Text('支持 JPG / PNG 等常见格式')
        .fontSize(12)
        .fontColor('#555566')
        .padding({ top: 10, bottom: 16 })
    }
  }
  .width('100%')
  .backgroundColor('#141428')
  .borderRadius({ topLeft: 24, topRight: 24 })
  .shadow({ radius: 12, color: '#00000033', offsetY: -2 })
}

按钮状态流转:

  • 初始状态:只有"选择照片"可用,"开始修复"灰色禁用(inputImage === undefined
  • 选图后:“选择照片” + “开始修复”(黄色)都可用
  • 处理中:"选择照片"禁用,“开始修复"变灰显示"修复中…”,显示环形进度条
  • 修复完成:“选择照片” + “保存到相册”(绿色),底部提示改为"滑动对比修复前后效果"

Progress({ value: 0, total: 0 }) 是不确定进度模式——total 为 0 时自动显示无限循环动画,因为超分推理没有进度回调,无法确定完成百分比。

面板顶部圆角 borderRadius({ topLeft: 24, topRight: 24 }) + shadow({ offsetY: -2 }) 做出卡片浮起效果,和中间图片区形成视觉分层。

主布局

build() {
  Column() {
    this.headerBar()

    if (this.inputImage) {
      this.imageCompareArea()
    } else {
      this.emptyPlaceholder()
    }

    this.controlPanel()
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#0A0A1A')
}

整体纵向三分:顶部进度条(固定高度 56)→ 中间内容区(layoutWeight(1) 填满)→ 底部操作面板(固定高度)。中间内容区根据 inputImage 是否存在,切换空状态占位和图片对比区。

常见问题与适用边界

处理大图时失败或内存明显上升

超分后的像素数量会按宽高倍率同时增长,内存开销不是线性增加。代码在推理前用 scaleToLimit 限制输入尺寸,实际项目还应结合设备能力控制并发,并及时释放输入、输出 PixelMap 和分析器。

放大后仍然模糊

超分模型会根据已有纹理推测细节,无法找回原图中从未记录的信息。严重失焦、运动模糊、过度压缩和大面积遮挡不适合只靠超分处理;这类图片通常还需要去模糊、降噪或人工修复。

保存成功但相册中找不到图片

确认写入权限、临时文件是否完整关闭,以及 photoAccessHelper 的导入调用是否成功。不要在编码和写入完成前删除沙箱临时文件,异常路径也要关闭文件描述符并释放 ImagePacker

完整代码

把上面所有部分组合起来,就是完整的案例代码:

import { imageSuperResolution, visionBase } from '@kit.CoreVisionKit';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo, picker } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { common } from '@kit.AbilityKit';

const DOMAIN = 0x0000;
const TAG = 'ImageSuperResolution';

@Entry
@Component
struct A1 {
  @State inputImage: PixelMap | undefined = undefined;
  @State outputImage: PixelMap | undefined = undefined;
  @State statusText: string = '';
  @State isProcessing: boolean = false;
  @State isRestored: boolean = false;
  @State sliderOffset: number = 0.5;
  @State containerWidth: number = 0;
  @State inputSize: string = '';
  @State outputSize: string = '';
  @State stepIndex: number = 0;
  @State showCompareTip: boolean = false;
  private analyzer: imageSuperResolution.ImageSRAnalyzer | null = null;
  private lastOffset: number = 0.5;

  async aboutToAppear(): Promise<void> {
    try {
      this.analyzer = await imageSuperResolution.ImageSRAnalyzer.create();
      hilog.info(DOMAIN, TAG, 'ImageSRAnalyzer created');
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG, `Create analyzer failed: ${err.code}, ${err.message}`);
    }
  }

  async aboutToDisappear(): Promise<void> {
    if (this.analyzer) {
      try {
        await this.analyzer.destroy();
        hilog.info(DOMAIN, TAG, 'Analyzer destroyed');
      } catch (error) {
        let err: BusinessError = error as BusinessError;
        hilog.error(DOMAIN, TAG, `Destroy failed: ${err.code}, ${err.message}`);
      }
    }
  }

  private async selectImage(): Promise<void> {
    try {
      let options = new picker.PhotoSelectOptions();
      options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
      options.maxSelectNumber = 1;
      let photoPicker = new picker.PhotoViewPicker();
      let result: picker.PhotoSelectResult = await photoPicker.select(options);
      if (result.photoUris.length === 0) {
        return;
      }
      await this.loadImage(result.photoUris[0]);
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG, `Select failed: ${err.code}, ${err.message}`);
      this.statusText = '选择图片失败';
    }
  }

  private async loadImage(uri: string): Promise<void> {
    try {
      let fileSource = await fileIo.open(uri, fileIo.OpenMode.READ_ONLY);
      let imageSource: image.ImageSource = image.createImageSource(fileSource.fd);
      this.inputImage = await imageSource.createPixelMap();
      let info: image.ImageInfo = this.inputImage.getImageInfoSync();
      this.inputSize = `${info.size.width} x ${info.size.height}`;
      this.outputImage = undefined;
      this.outputSize = '';
      this.isRestored = false;
      this.sliderOffset = 0.5;
      this.stepIndex = 1;
      this.statusText = '照片已就绪';
      await fileIo.close(fileSource);
      await imageSource.release();
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG, `Load failed: ${err.code}, ${err.message}`);
      this.statusText = '加载图片失败';
    }
  }

  private async scaleToLimit(pixelMap: PixelMap, maxDim: number): Promise<PixelMap> {
    let info: image.ImageInfo = pixelMap.getImageInfoSync();
    let w: number = info.size.width;
    let h: number = info.size.height;
    if (w <= maxDim && h <= maxDim) {
      return pixelMap;
    }
    let scale: number = Math.min(maxDim / w, maxDim / h);
    let newW: number = Math.floor(w * scale);
    let newH: number = Math.floor(h * scale);
    await pixelMap.scale(scale, scale);
    hilog.info(DOMAIN, TAG, `Scaled from ${w}x${h} to ${newW}x${newH}`);
    return pixelMap;
  }

  private async startRestore(): Promise<void> {
    if (!this.inputImage || !this.analyzer) {
      this.statusText = '请先选择照片';
      return;
    }
    this.isProcessing = true;
    this.stepIndex = 2;
    this.statusText = '正在超分修复中...';
    try {
      let scaledInput: PixelMap = await this.scaleToLimit(this.inputImage!, 2048);
      let imageData: visionBase.ImageData = {
        pixelMap: scaledInput
      };
      let request: visionBase.Request = {
        inputData: imageData
      };
      let response: imageSuperResolution.ISPResponse = await this.analyzer.process(request);
      this.outputImage = response.pixelMap;
      let outInfo: image.ImageInfo = this.outputImage.getImageInfoSync();
      this.outputSize = `${outInfo.size.width} x ${outInfo.size.height}`;
      this.isRestored = true;
      this.stepIndex = 3;
      this.statusText = '修复完成';
      this.showCompareTip = true;
      setTimeout(() => {
        this.showCompareTip = false;
      }, 3000);
      hilog.info(DOMAIN, TAG, 'Super resolution completed');
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG, `Process failed: ${err.code}, ${err.message}`);
      this.statusText = '修复失败,请重试';
      this.stepIndex = 1;
    }
    this.isProcessing = false;
  }

  private async saveToGallery(): Promise<void> {
    if (!this.outputImage) {
      return;
    }
    try {
      let context: Context = getContext(this) as common.UIAbilityContext;
      let imagePacker: image.ImagePacker = image.createImagePacker();
      let packOpts: image.PackingOption = {
        format: 'image/jpeg',
        quality: 95
      };
      let packingData: ArrayBuffer = await imagePacker.packing(this.outputImage!, packOpts);
      let fileName = `SR_Restored_${Date.now()}.jpg`;
      let filePath = `${context.filesDir}/${fileName}`;
      let file = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
      fileIo.writeSync(file.fd, packingData);
      fileIo.closeSync(file);
      await imagePacker.release();

      let phAccessHelper: photoAccessHelper.PhotoAccessHelper =
        photoAccessHelper.getPhotoAccessHelper(context);
      let assetChangeRequest: photoAccessHelper.MediaAssetChangeRequest =
        photoAccessHelper.MediaAssetChangeRequest.createImageAssetRequest(context, filePath);
      await phAccessHelper.applyChanges(assetChangeRequest);
      this.statusText = '已保存到相册';
      hilog.info(DOMAIN, TAG, 'Saved to gallery');
      try {
        fileIo.unlinkSync(filePath);
      } catch (_e) {
        // ignore
      }
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      hilog.error(DOMAIN, TAG, `Save failed: ${err.code}, ${err.message}`);
      this.statusText = '保存失败';
    }
  }

  @Builder
  headerBar() {
    Row() {
      Column() {
        Text('AI')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Text('超分修复')
          .fontSize(10)
          .fontColor('#FFFFFFAA')
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Center)

      Blank()

      Text('图像超分辨率')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .layoutWeight(1)
        .textAlign(TextAlign.Center)

      Blank()

      Row() {
        Text(this.stepIndex === 0 ? '1' : (this.stepIndex >= 1 ? '✓' : '1'))
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.stepIndex >= 1 ? '#0D0D1A' : '#FFFFFF')
          .width(24)
          .height(24)
          .textAlign(TextAlign.Center)
          .borderRadius(12)
          .backgroundColor(this.stepIndex >= 1 ? '#4ADE80' : '#333355')
        Text('—')
          .fontSize(12)
          .fontColor('#555577')
          .margin({ left: 4, right: 4 })
        Text(this.stepIndex < 2 ? '2' : (this.stepIndex >= 2 ? '✓' : '2'))
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.stepIndex >= 2 ? '#0D0D1A' : '#FFFFFF')
          .width(24)
          .height(24)
          .textAlign(TextAlign.Center)
          .borderRadius(12)
          .backgroundColor(this.stepIndex >= 2 ? '#4ADE80' : '#333355')
        Text('—')
          .fontSize(12)
          .fontColor('#555577')
          .margin({ left: 4, right: 4 })
        Text(this.stepIndex < 3 ? '3' : '✓')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.stepIndex >= 3 ? '#0D0D1A' : '#FFFFFF')
          .width(24)
          .height(24)
          .textAlign(TextAlign.Center)
          .borderRadius(12)
          .backgroundColor(this.stepIndex >= 3 ? '#4ADE80' : '#333355')
      }
    }
    .width('100%')
    .height(56)
    .padding({ left: 20, right: 20 })
    .alignItems(VerticalAlign.Center)
    .backgroundColor('#0F0F23')
  }

  @Builder
  emptyPlaceholder() {
    Column() {
      Column() {
        Text('🖼️')
          .fontSize(56)
          .margin({ bottom: 16 })
        Text('选择一张老旧照片')
          .fontSize(18)
          .fontColor('#FFFFFF')
          .fontWeight(FontWeight.Medium)
        Text('AI 超分辨率技术,让模糊旧照焕然一新')
          .fontSize(13)
          .fontColor('#AAAACC')
          .margin({ top: 8 })
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .width('100%')
      .layoutWeight(1)

      Row() {
        Column() {
          Text('🔍')
            .fontSize(28)
          Text('智能超分')
            .fontSize(12)
            .fontColor('#AAAACC')
            .margin({ top: 6 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 16, bottom: 16 })
        .backgroundColor('#1A1A33')
        .borderRadius(12)

        Column() {
          Text('📐')
            .fontSize(28)
          Text('4倍放大')
            .fontSize(12)
            .fontColor('#AAAACC')
            .margin({ top: 6 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 16, bottom: 16 })
        .backgroundColor('#1A1A33')
        .borderRadius(12)
        .margin({ left: 10, right: 10 })

        Column() {
          Text('✨')
            .fontSize(28)
          Text('画质增强')
            .fontSize(12)
            .fontColor('#AAAACC')
            .margin({ top: 6 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 16, bottom: 16 })
        .backgroundColor('#1A1A33')
        .borderRadius(12)
      }
      .width('100%')
      .padding({ left: 20, right: 20, bottom: 24 })
    }
    .width('100%')
    .layoutWeight(1)
  }

  @Builder
  imageCompareArea() {
    Column() {
      Stack() {
        if (this.outputImage) {
          Image(this.outputImage)
            .objectFit(ImageFit.Contain)
            .width('100%')
            .height('100%')
        }
        if (this.inputImage) {
          Row() {
            Column() {
              Image(this.inputImage)
                .objectFit(ImageFit.Contain)
                .width('100%')
                .height('100%')
            }
            .width(`${this.sliderOffset * 100}%`)
            .height('100%')
            .clip(true)
          }
          .width('100%')
          .height('100%')
        }

        if (this.isRestored) {
          Row() {
            Column() {
              Text('原图')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .backgroundColor('#000000AA')
                .padding({ left: 6, right: 6, top: 3, bottom: 3 })
                .borderRadius(4)
            }
            .width(`${this.sliderOffset * 100}%`)
            .justifyContent(FlexAlign.Start)
            .padding({ left: 12, top: 8 })

            Column() {
              Text('修复后')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .backgroundColor('#6366F1AA')
                .padding({ left: 6, right: 6, top: 3, bottom: 3 })
                .borderRadius(4)
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.End)
            .padding({ right: 12, top: 8 })
          }
          .width('100%')
          .height(32)
          .position({ x: 0, y: 0 })
        }

        if (this.isRestored) {
          Column() {
            Column() {
              Row() {
                Column()
                  .width(2)
                  .height(16)
                  .backgroundColor('#FFFFFF')
                Column()
                  .width(2)
                  .height(16)
                  .backgroundColor('#FFFFFF')
              }
              .alignItems(VerticalAlign.Center)
            }
            .width(28)
            .height(28)
            .borderRadius(14)
            .backgroundColor('#FFFFFF')
            .justifyContent(FlexAlign.Center)
            .alignItems(HorizontalAlign.Center)
          }
          .width(2)
          .height('100%')
          .backgroundColor('#FFFFFF66')
          .justifyContent(FlexAlign.Center)
          .alignItems(HorizontalAlign.Center)
          .position({ x: `${this.sliderOffset * 100}%`, y: 0 })
          .markAnchor({ x: 1 })
        }
      }
      .width('100%')
      .layoutWeight(1)
      .clip(true)
      .borderRadius(16)
      .onAreaChange((_oldArea: Area, newArea: Area) => {
        this.containerWidth = Number(newArea.width);
      })
      .gesture(
        PanGesture()
          .onActionStart(() => {
            this.lastOffset = this.sliderOffset;
          })
          .onActionUpdate((event: GestureEvent) => {
            if (this.containerWidth > 0) {
              let newOffset = this.lastOffset + event.offsetX / this.containerWidth;
              this.sliderOffset = Math.max(0.05, Math.min(0.95, newOffset));
            }
          })
      )

      if (this.isRestored) {
        Row() {
          Text('原图: ')
            .fontSize(12)
            .fontColor('#888899')
          Text(this.inputSize)
            .fontSize(12)
            .fontColor('#CCCCEE')
            .fontWeight(FontWeight.Medium)
          Text('  →  修复后: ')
            .fontSize(12)
            .fontColor('#888899')
          Text(this.outputSize)
            .fontSize(12)
            .fontColor('#4ADE80')
            .fontWeight(FontWeight.Medium)
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
        .padding({ top: 8, bottom: 4 })
      }
    }
    .width('100%')
    .layoutWeight(1)
    .padding({ left: 16, right: 16, top: 12, bottom: 4 })
  }

  @Builder
  controlPanel() {
    Column() {
      Row() {
        Text(this.statusText)
          .fontSize(14)
          .fontColor(this.isRestored ? '#4ADE80' : '#CCCCDD')
          .fontWeight(this.isRestored ? FontWeight.Bold : FontWeight.Normal)
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .padding({ top: 16, bottom: 12 })

      if (this.isProcessing) {
        Progress({ value: 0, total: 0, type: ProgressType.Ring })
          .width(32)
          .height(32)
          .color('#6366F1')
          .margin({ bottom: 12 })
      }

      Row() {
        Button('选择照片')
          .type(ButtonType.Capsule)
          .fontColor('#FFFFFF')
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .backgroundColor('#6366F1')
          .layoutWeight(1)
          .height(44)
          .enabled(!this.isProcessing)
          .onClick(() => {
            void this.selectImage();
          })

        if (!this.isRestored) {
          Button(this.isProcessing ? '修复中...' : '开始修复')
            .type(ButtonType.Capsule)
            .fontColor('#FFFFFF')
            .fontSize(15)
            .fontWeight(FontWeight.Medium)
            .backgroundColor(this.isProcessing ? '#333355' : '#F59E0B')
            .layoutWeight(1)
            .height(44)
            .margin({ left: 12 })
            .enabled(!this.isProcessing && this.inputImage !== undefined)
            .onClick(() => {
              void this.startRestore();
            })
        }

        if (this.isRestored) {
          Button('保存到相册')
            .type(ButtonType.Capsule)
            .fontColor('#FFFFFF')
            .fontSize(15)
            .fontWeight(FontWeight.Medium)
            .backgroundColor('#10B981')
            .layoutWeight(1)
            .height(44)
            .margin({ left: 12 })
            .onClick(() => {
              void this.saveToGallery();
            })
        }
      }
      .width('100%')
      .padding({ left: 20, right: 20 })

      if (this.isRestored) {
        Text('← 滑动对比修复前后效果 →')
          .fontSize(12)
          .fontColor('#666688')
          .padding({ top: 10, bottom: 16 })
      } else {
        Text('支持 JPG / PNG 等常见格式')
          .fontSize(12)
          .fontColor('#555566')
          .padding({ top: 10, bottom: 16 })
      }
    }
    .width('100%')
    .backgroundColor('#141428')
    .borderRadius({ topLeft: 24, topRight: 24 })
    .shadow({ radius: 12, color: '#00000033', offsetY: -2 })
  }

  build() {
    Column() {
      this.headerBar()

      if (this.inputImage) {
        this.imageCompareArea()
      } else {
        this.emptyPlaceholder()
      }

      this.controlPanel()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0A0A1A')
  }
}

总结

这个案例拆下来,核心也是三件事:

1. CoreVisionKit 超分 API 的使用范式。 ImageSRAnalyzer.create() 创建 → 构造 visionBase.Requestanalyzer.process() 推理 → 从 ISPResponse 取 PixelMap。CoreVisionKit 下其他能力(人脸检测、文字识别等)也是这套 Request/Response 模型,学会一个就通了全组。有两点必须注意:输入图片尺寸不能超 2048(用 scaleToLimit 预处理),分析器用完必须 destroy() 释放 NPU 资源。

2. 图片选-存全链路。 PhotoViewPicker 选图 → fileIo.open + ImageSource 解码 → ImagePacker 编码 → 沙箱暂存 → photoAccessHelper 导入相册 → 清理临时文件。这条链路涉及的 Kit 多、容易遗漏步骤(比如忘了 close fd、忘了 release ImageSource),但只要跑通一次,以后任何图片处理应用都能复用。

3. Before/After 滑动对比交互。 Stack 叠两层 Image + clip 裁切原图 + PanGesture 驱动偏移 + markAnchor 修正分割线位置。这个交互模式在修图、滤镜类应用里非常常见,理解了 clip + 偏移宽度的思路,以后做类似功能可以直接搬。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐