挑一张建筑照片做配图,缩略图里的街灯看起来很漂亮。放大以后,灯罩边缘发虚,顶部纹路混成一片。把预览框继续拉大,图片占据了更多屏幕,缺少的细节却没有跟着回来。

很多人第一次接触图像超分,期待的就是这个时刻:一张尺寸不够的小图,能不能再多用一次?这样的期待很容易理解,但“更大”“看起来更清楚”和“与原始细节一致”,其实是三个不同问题。

HarmonyOS 7 把图像超分开放给应用,让选图、处理、对照和保存可以在同一段体验里完成。下面从灯罩、蝴蝶和瓷器的实际对照出发,看看它能改善什么,以及应用应该怎样把结果交给用户。

一、超分改变的是输出图像,放大预览只是显示方式

Core Vision Kit 在 API26 提供图像超分能力,用于对低分辨率输入进行超分辨率重建。官方能力介绍

普通预览放大首先改变显示尺寸;超分会处理输入并返回新的图像。对应用来说,这意味着结果可以有自己的尺寸、文件和保存动作。它不再只是当前页面里临时拉大的一幅画面。

但输入里已经丢失的内容,需要怎样重建,是另一个问题。轮廓变得鲜明,并不能单独证明复杂纹理恢复正确。判断时需要同时看整体观感、同一区域的细节,以及任务对准确性的要求。

在旅行图集、内容配图或旧素材预览中,改善轮廓可能已经有价值;如果用户要认读文字、核对商品纹样或判断精确形状,就应保留原始资料,不能把处理后的图像作为唯一依据。这也是本例坚持提供输入/输出切换的原因。

二、从一盏灯开始,观察改善发生在哪里

“拾图·放大”选择了一张建筑小图:输入尺寸为256×171,本轮实际输出为1024×684。处理完成后,页面显示实际尺寸,用户可以先看整图,再进入局部对照。

选择输入并执行实际超分

下面三列依次是普通 Bicubic 放大、实际超分输出和参考图的同区域对照。裁剪位置事先固定,避免只挑处理效果最好的部分。

建筑灯罩的普通放大、超分和参考对照

灯罩外沿和黑色支架更容易辨认,部分边界也更硬;但顶部细小的装饰纹路,没有像参考图那样完整出现。对正在挑配图的人,前一种变化可能有帮助;对需要辨认灯罩结构的人,后一种差异不能忽略。

再看蝴蝶,黑色条带更分明,细小鳞片纹理与参考图仍有区别。瓷器的轮廓和反光区域更利落,密集金线与纹样却被简化了。

蝴蝶翅膀的固定区域对照

瓷器纹样的细节边界

这三组对照把“更清楚”拆成了可观察的变化:边缘、条带、纹理不一定同时改善。产品页应该允许用户看清这些差别,再决定这张结果是否适合当前用途。

三、把比较留在用户决定保存之前

本例没有在处理完成后立刻覆盖输入,而是把比较安排成一个明确动作:点击“查看局部”,在“输入小图”和“超分输出”之间切换。两种图使用同一裁剪区域,预览框保持一致。

固定区域中的输入输出切换

如果两次预览的区域或缩放不同,人很容易把显示差异当成处理效果。固定观察区域,能让用户把注意力放在同一条边缘、同一处纹样上。

确认这轮结果可用后,再点击“保存图片”,通过系统选择器保存为 PNG。保留原照片,并让输入、输出尺寸、处理状态和保存文件对应同一轮,这些细节决定了能力接入后是否真的可用。

系统选择目录并保存PNG

本轮还重新打开导出的文件,与应用内输出逐字节比较:两者均为1,150,304字节,内容一致,重新解码得到1024×684。这样验证的是保存过程确实交付了刚才看到的结果。

四、把能力接进现有图片流程

工程采用 Stage 模型与 ArkTS。选图后先检查实际宽高,再将输入解码为 PixelMap;核心方法创建分析器、执行处理、取回输出,最后释放分析器。预览与保存完成后,还要释放相应图片资源。

先在 DevEco Studio 中打开仓库根目录,完成依赖同步与个人调试签名,选择受支持的 HarmonyOS 7 / API26 真机运行 entry。先用工程自带的建筑样本,选择长边256、PNG,点击“开始超分”;应看到256×171变为1024×684。再换自己的小图,便于区分环境配置问题与输入问题。

1. 选图先得到可处理的文件

在 entry/src/main/ets/pages/Index.ets 的 Index 组件中,pickPhoto() 从系统选择器取得用户授权的 URI,检查实际尺寸后复制到沙箱。ctx() 返回当前 UIAbilityContext,SrInput 与 loadSelected() 沿用仓库实现;后者会清除上一轮输出。

  private async pickPhoto(): Promise<void> {
    if (this.busy) { return; }
    this.busy = true;
    let file: fileIo.File | undefined = undefined;
    let source: image.ImageSource | undefined = undefined;
    try {
      const options = new photoAccessHelper.PhotoSelectOptions();
      options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE; options.maxSelectNumber = 1;
      const result = await new photoAccessHelper.PhotoViewPicker().select(options);
      if (result.photoUris.length === 0) { return; }
      file = await fileIo.open(result.photoUris[0], fileIo.OpenMode.READ_ONLY);
      source = image.createImageSource(file.fd);
      const size = (await source.getImageInfo()).size;
      if (size.width <= 16 || size.height <= 16 || size.width >= 2048 || size.height >= 2048) {
        this.notice = '请选择宽高均为 17–2047 像素的图片,建议长边低于 1024。'; return;
      }
      const custom = new SrInput(); custom.id = 'photo-' + Date.now(); custom.file = custom.id + '.img';
      custom.width = size.width; custom.height = size.height;
      const destination = await fileIo.open(this.ctx().filesDir + '/' + custom.file, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
      try { await fileIo.copyFile(file.fd, destination.fd); } finally { await fileIo.close(destination); }
      this.custom = custom; await this.loadSelected(); this.notice = '';
    } catch (error) { this.fail(error as BusinessError); }
    finally {
      try { if (source !== undefined) { await source.release(); } }
      finally { try { if (file !== undefined) { await fileIo.close(file); } } finally { this.busy = false; } }
    }
  }

宽高均需在17—2047像素之间。选择器取消时保留当前图片;文件描述符和图像源都在 finally 中释放。此时点击“选择照片”,应只更新输入图片与尺寸,还没有开始模型处理。

2. 处理要把输出和资源管理一起接好

先从文末仓库导入工程并配置个人签名,在受支持的真机上跑通前面的选图和比较。然后查看 visioncore/src/main/ets/SuperResolutionExperiment.ets。下面摘取核心处理部分:input 是已校验的输入,run 记录尺寸与耗时,handedOff 表示输出已交给调用方管理:

import { imageSuperResolution, visionBase } from '@kit.CoreVisionKit';
import { image } from '@kit.ImageKit';

export class SuperResolutionRun {
  inputWidth: number = 0;
  inputHeight: number = 0;
  outputWidth: number = 0;
  outputHeight: number = 0;
  initializationMs: number = 0;
  processMs: number = 0;
  destroySucceeded: boolean = false;
  output: image.PixelMap | undefined = undefined;
}

export class SuperResolutionExperiment {
  private static busy: boolean = false;

  // Caller owns the input; on success caller also owns output and must release it after saving/displaying.
  static async run(input: image.PixelMap): Promise<SuperResolutionRun> {
    if (SuperResolutionExperiment.busy) { throw new Error('SUPER_RESOLUTION_BUSY'); }
    SuperResolutionExperiment.busy = true;
    let analyzer: imageSuperResolution.ImageSRAnalyzer | undefined = undefined;
    let output: image.PixelMap | undefined = undefined;
    let handedOff: boolean = false;
    const run = new SuperResolutionRun();
    try {
      const info = await input.getImageInfo();
      run.inputWidth = info.size.width;
      run.inputHeight = info.size.height;
      if (info.size.width <= 16 || info.size.height <= 16 ||
        info.size.width >= 2048 || info.size.height >= 2048) {
        throw new Error('SUPER_RESOLUTION_DIMENSIONS_OUT_OF_RANGE');
      }
      let start = Date.now();
      analyzer = await imageSuperResolution.ImageSRAnalyzer.create();
      run.initializationMs = Date.now() - start;
      const request: visionBase.Request = { inputData: { pixelMap: input } };
      start = Date.now();
      const response = await analyzer.process(request);
      run.processMs = Date.now() - start;
      output = response.pixelMap;
      const outputInfo = await output.getImageInfo();
      run.outputWidth = outputInfo.size.width;
      run.outputHeight = outputInfo.size.height;
      run.output = output;
      handedOff = true;
      return run;
    } finally {
      try {
        if (!handedOff && output !== undefined) {
          try { await output.release(); } catch { /* Preserve original processing error. */ }
        }
        if (analyzer !== undefined) {
          try {
            await analyzer.destroy();
            run.destroySucceeded = true;
          } catch {
            run.destroySucceeded = false;
          }
        }
      } finally {
        SuperResolutionExperiment.busy = false;
      }
    }
  }
}

create → process → destroy 管理分析器服务的生命周期;输入和输出 PixelMap 则各有自己的持有者。资源销毁失败也被记录,不能只因为生成了图片,就把整次处理视为完全成功。

3. 将处理结果接回页面

entry/src/main/ets/SrSession.ets 把文件解码为 PixelMap,调用核心模块,再编码输出与同区域预览。下面是同一个文件中的实际单图流程;SrRecord、encode、crop 等类型与方法均由配套工程提供。

  static async run(inputPath: string, input: SrInput, directory: string): Promise<SrRecord> {
    const record = new SrRecord();
    record.inputId = input.id;
    record.inputSha256 = input.sha256;
    let file: fileIo.File | undefined = undefined;
    let source: image.ImageSource | undefined = undefined;
    let pixels: image.PixelMap | undefined = undefined;
    let result: SuperResolutionRun | undefined = undefined;
    try {
      file = await fileIo.open(inputPath, fileIo.OpenMode.READ_ONLY);
      source = image.createImageSource(file.fd);
      const info = await source.getImageInfo();
      record.inputWidth = info.size.width;
      record.inputHeight = info.size.height;
      record.stage = 'superResolution';
      pixels = await source.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888 });
      result = await SuperResolutionExperiment.run(pixels);
      record.initializationMs = result.initializationMs;
      record.processMs = result.processMs;
      record.outputWidth = result.outputWidth;
      record.outputHeight = result.outputHeight;
      record.destroySucceeded = result.destroySucceeded;
      record.stage = 'encode';
      if (result.output === undefined) { throw new Error('OUTPUT_MISSING'); }
      record.outputFile = input.id + '-output.png';
      const started = Date.now();
      await SrSession.encode(result.output, directory + '/' + record.outputFile);
      record.encodeMs = Date.now() - started;
      record.stage = 'crop';
      record.inputRoiFile = input.id + '-input-roi.png';
      record.outputRoiFile = input.id + '-output-roi.png';
      await SrSession.crop(inputPath, directory + '/' + record.inputRoiFile);
      await SrSession.crop(directory + '/' + record.outputFile, directory + '/' + record.outputRoiFile);
      record.status = record.destroySucceeded ? 'success' : 'cleanup_failed';
      record.stage = 'complete';
    } catch (error) {
      const failure = error as BusinessError;
      record.status = 'failed';
      record.errorCode = failure.code || 0;
      record.errorMessage = failure.message || String(failure);
    } finally {
      // The analyzer owns its service lifecycle; this layer owns all decoded/output PixelMaps.
      try { if (result?.output !== undefined) { await result.output.release(); } }
      finally {
        try { if (pixels !== undefined) { await pixels.release(); } }
        finally {
          try { if (source !== undefined) { await source.release(); } }
          finally { if (file !== undefined) { await fileIo.close(file); } }
        }
      }
    }
    return record;
  }

保存输出 PNG 的方法也在 SrSession.ets 中,编码结束后释放 ImagePacker:

  static async encode(pixelMap: image.PixelMap, path: string): Promise<void> {
    const packer = image.createImagePacker();
    try {
      const bytes = await packer.packToData(pixelMap, { format: 'image/png', quality: 100 });
      await SrSession.write(path, new Uint8Array(bytes));
    } finally { await packer.release(); }
  }

在页面里,“开始超分”调用 SrSession.run,只有当前任务成功,才更新 outputPath 和输出预览。输入尺寸或编码改变时,要清掉上一轮输出指向,避免把旧结果留在新输入旁边。

局部与整图的切换最终回到一个很短的方法,位于 entry/src/main/ets/pages/Index.ets:

  private previewPath(): string {
    if (this.localDetail && this.outputPath) {
      return this.journal.directory + '/' + (this.preview === 0 ? this.latest.inputRoiFile : this.latest.outputRoiFile);
    }
    return this.preview === 1 && this.outputPath ? this.outputPath : this.path;
  }

4. 保存本轮输出,并接上按钮

Index.ets 中的 saveFile() 使用系统文档选择器,让用户决定保存位置;它会重新读取导出文件核验大小、字节内容和尺寸,完成后才提示成功。下面两种方法放在已有 Index 组件中,SavedImageProof 是工程内的校验记录类型。

private async saveFile(source: string, name: string): Promise<void> {
    if (this.busy || !source) { return; }
    this.busy = true;
    try {
      const options = new picker.DocumentSaveOptions(); options.newFileNames = [name];
      const targets = await new picker.DocumentViewPicker(this.ctx()).save(options);
      if (targets.length === 0) { return; }
      const target = await fileIo.open(targets[0], fileIo.OpenMode.READ_WRITE);
      try { await fileIo.copyFile(source, target.fd); } finally { await fileIo.close(target); }
      if (name.endsWith('.png')) { await this.verifySavedImage(source, targets[0]); }
      this.notice = '已保存到所选位置。';
    } catch (error) { this.fail(error as BusinessError); }
    finally { this.busy = false; }
  }
  private async verifySavedImage(source: string, targetUri: string): Promise<void> {
    const original = await fileIo.open(source, fileIo.OpenMode.READ_ONLY);
    let exported: fileIo.File | undefined = undefined;
    let decoded: image.ImageSource | undefined = undefined;
    try {
      exported = await fileIo.open(targetUri, fileIo.OpenMode.READ_ONLY);
      const expected = (await fileIo.stat(original.fd)).size;
      const actual = (await fileIo.stat(exported.fd)).size;
      if (actual !== expected) { throw new Error('EXPORTED_IMAGE_SIZE_MISMATCH'); }
      let compared = 0;
      while (compared < expected) {
        const length = Math.min(65536, expected - compared);
        const left = new ArrayBuffer(length); const right = new ArrayBuffer(length);
        const a = await fileIo.read(original.fd, left);
        const b = await fileIo.read(exported.fd, right);
        if (a !== b || a <= 0) { throw new Error('EXPORTED_IMAGE_READ_MISMATCH'); }
        const x = new Uint8Array(left); const y = new Uint8Array(right);
        for (let i = 0; i < a; i++) {
          if (x[i] !== y[i]) { throw new Error('EXPORTED_IMAGE_BYTES_MISMATCH'); }
        }
        compared += a;
      }
      decoded = image.createImageSource(exported.fd);
      const info = await decoded.getImageInfo();
      const proof: SavedImageProof = {
        format: 'PNG', bytesEqual: true, sourceBytes: expected, exportedBytes: actual,
        comparedBytes: compared, width: info.size.width, height: info.size.height,
        sourceFile: source.split('/').pop() || '', checkedAt: new Date().toISOString()
      };
      await SrSession.write(this.ctx().filesDir + '/sr-save-verification.json',
        new util.TextEncoder().encodeInto(JSON.stringify(proof, null, 2)));
    } finally {
      try { if (decoded !== undefined) { await decoded.release(); } }
      finally {
        try { if (exported !== undefined) { await fileIo.close(exported); } }
        finally { await fileIo.close(original); }
      }
    }
  }

页面已有的 action() 构建器接收文字、主次样式、可操作状态和回调。将以下代码放回 Index.editor(),选图、开始、记录与保存就对应到同一轮任务:

Row({ space: 12 }) {
      Column() { this.action('选择照片', false, !this.busy, () => { this.pickPhoto(); }) }.layoutWeight(1)
      Column() { this.action('开始超分', true, !this.busy, () => { this.run(false); }) }.layoutWeight(1)
    }.width('100%')
    this.text('处理后可切换输入与超分结果,查看同一区域。', 12, true)
    Row({ space: 12 }) {
      Column() { this.action('实验记录', false, !this.busy, () => { this.recordsPage = true; }) }.layoutWeight(1)
      Column() { this.action('保存图片', false, !this.busy && !!this.outputPath, () => { this.saveFile(this.outputPath, 'shitu-sr.png'); }) }.layoutWeight(1)
    }.width('100%')
  }

按“选择照片 → 开始超分 → 查看局部 → 输入/输出切换 → 保存图片”运行一遍。处理时按钮应禁止重复提交,没有成功输出时不能保存;取消保存后仍能继续比较当前结果。

选图、解码、同区域裁剪、PNG 保存和固定实验的完整代码与按钮接线,见配套工程与 README。跟随同一轮输入走完处理、比较、保存,比只验证接口有返回值更接近用户实际完成任务的过程。

五、把等待和画质分开评价

这轮实验以8张有来源的参考照片,按3档输入长边与3种编码生成72项受控输入。Mate 60 Pro / HarmonyOS 7.0.0.109 / API26 上,72项均处理成功。

在“实验记录”点击“运行72项固定实验”,保持前台直到 complete 与72/72,再导出 JSON 核对数量与状态。以下动图展示开始、进度及完成,统计仍以完整记录为准。

固定实验的开始、进度和完成

输入长边本轮实际输出长边process 耗时中位数
2561024230.5ms
5122048604.5ms
76830721187ms

这些时间只覆盖 process,不包括初始化、解码、编码、写文件和页面刷新;运行前也已成功调用过单图任务。768档的 PNG 编码与写入中位数另有890ms,所以用户实际等待不能只看模型处理时间。页面需要明确的处理中状态,并阻止重复提交。

质量方面,在本轮的计算方法下,72项输出与参考图的 RGB PSNR 都低于 Bicubic 基线。PSNR 衡量像素误差,不能单独代表人的视觉偏好;结合局部图可以看到,边缘更鲜明与像素更接近参考图,可能朝不同方向变化。

这组输入研究的是受控缩小和编码损失,没有覆盖所有失焦、抖动或旧照片损伤。它更适合作为产品判断的起点:先用自己业务里的照片,验证哪些内容改善了,哪些细节需要保留原图核对。

六、一张小图能否再用一次,答案应该留给具体任务

回到挑配图的情境,超分最有价值的地方,是为尺寸不足的素材增加一次处理和比较的机会。轮廓改善能否帮助这次排版,要放在实际展示尺寸下判断;纹理是否可信,也需要按任务要求核对。

因此,一个好的接入不只有“增强”按钮。它还应让用户知道处理了哪张图、比较的是哪里、保存的是什么。能力提供新的输出,应用则负责把选择的依据呈现清楚。

配套工程与资料

GitCode:HarmonyOS 7 图像超分 Demo。README 提供导入、签名、运行、固定实验和数据复核步骤。

图像超分开发指南 · imageSuperResolution API · 照片与字体许可

Logo

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

更多推荐