小图传输,大图呈现——用 HarmonyOS 7 端侧 AI 实现 4 倍图像超分重建

当相册里那张承载着十年回忆的老照片糊成一团,当聊天里收到的缩略图点开全是马赛克,当电商详情页的大图在弱网下转圈转了半分钟------我们真正想要的,其实是一个"小图先传、大图再看"的体验。HarmonyOS 7 把这件事做到了端侧:一张低清图交给手机里的 NPU,几百毫秒内重建出 4 倍分辨率的高清大图,全程不联网、数据不出设备。

本文从算法原理讲到工程落地,手把手带你用 Core Vision Kit 的图像超分能力,做一个"超分相册"------选一张低清图,端侧重建,再用滑块对比器直观感受放大前后的差异。


一、一张模糊照片的救赎:为什么我们需要端侧超分

先说一个反直觉的事实:今天绝大多数"放大就糊"的图片,并不是真的没有细节,而是细节在传输和存储时被压缩掉了。

一个很常见的场景是这样的。你在群里看到朋友发的一张风景照,缩略图时代(也就是你看到的小图)为了省流量,服务端把它压到了 200×300 像素。你觉得好看想保存,点开"查看原图",结果发现------即便网络通畅,原图也只是一张被过度 JPEG 压缩的 800×600,放大看全是色块。

这个问题的传统解法有两条路,但都有硬伤:

第一条,云端超分。 把图片传到服务器,用大模型重建高清后再传回来。问题显而易见:一来一回的流量和时间成本高,更要命的是隐私------人脸、证件、聊天截图这类图片,用户根本不希望它离开自己的手机。

第二条,传统的双线性/双三次插值放大。 这是几乎所有看图软件都内置的"放大"功能。它的本质是"按周围像素的颜色取平均",结果就是把模糊放大成更大的模糊,边缘依然是软塌塌的,没有任何"脑补"出来的细节。

端侧 AI 超分给出的是第三条路:让手机自带的 NPU(神经网络处理器)跑一个超分模型,在设备本地、在几百毫秒内、不联网地重建出 4 倍分辨率的高清图。 它和传统插值的本质区别在于------插值是"凭已知像素算未知像素",而 AI 超分是"凭大量训练学到的先验知识,去补全那些本该存在的纹理"。

举个具象的例子:一张糊掉的头发丝,双线性放大后是一团灰色的色块;而 AI 超分会"认出"这是头发,补出一根根分明的发丝边缘。一张糊掉的眼睛,超分后能重新出现瞳孔的明暗层次。这不是魔法,是模型从海量高清图里学到的"低清→高清"映射关系。

HarmonyOS 7 把这套能力封装进了 Core Vision Kit ,对开发者暴露的就是一个叫 imageSuperResolution 的模块。它有三个特性标签特别值得关注:

  • 端侧推理:模型和算力都在设备上,零网络依赖,弱网/断网环境照样能用;
  • 数据不出域:图片像素数据始终留在本地内存,从架构层面规避隐私风险;
  • NPU 加速:跑在专用神经网络上,单张图重建通常在几百毫秒级,体验上是"点一下就出来"。

这三个特性合起来,让端侧超分特别适合这些场景:相册老照片修复、聊天图片高清查看、电商详情图弱网加载、头像/壁纸放大、文档/票据 OCR 前的清晰度增强。

三种方案对比

  • 传统插值(双线性/双三次):纯数学运算,放大快但结果是"更大的模糊",无细节补全。
  • 云端超分:效果好,但需上传图片到服务器,有流量、延迟和隐私顾虑。
  • 端侧 AI 超分:在设备本地用 NPU 推理,兼顾效果、速度与隐私,断网可用。

二、认识图像超分:从算法到端侧

在动手写代码之前,花一点时间理解超分到底在做什么,会让你后面调优时心里有底。

2.1 超分辨率到底"超"在哪

图像超分辨率(Super-Resolution,简称 SR),学术上叫单图超分(Single Image Super-Resolution,SISR),要解决的问题是:给定一张低分辨率图片,重建出一张更高分辨率的图片,并且让重建出的细节看起来真实、合理。

关键词是"真实合理"。如果只是把图片放大,那插值就够了。超分难就难在,它要"无中生有"------那些在低清图里根本不存在的细节,超分得猜出来,而且猜得要像真的。

怎么"猜"?早期的方法是用信号处理的思路,分析图像的频率成分,试图还原高频细节(边缘、纹理)。这类方法(比如著名的 SRCNN 的前身们)效果有限,因为低清图里高频信息确实丢了,纯靠数学反推能补回来的很有限。

真正的转折是深度学习。研究者发现,让神经网络看海量的"高清图---对应的低清图"对,网络就能学到一种"从低清纹理反推高清细节"的能力。典型的网络结构从早期的 SRCNN,到后来的 EDSR、RCAN,再到效果惊艳的 Real-ESRGAN,核心思想都是:用卷积神经网络学习一个从低清到高清的端到端映射。

这个映射学得好不好,直接决定了超分效果。而端侧超分要额外解决的问题是:模型不能太大、推理不能太慢,否则手机跑不动、电池扛不住。 这就是为什么 HarmonyOS 的端侧超分要用 NPU------NPU 是专门为矩阵运算(神经网络的核心)设计的硬件,算同样的模型,比 CPU 快得多、也省电得多。

2.2 为什么端侧可行:NPU 改变了游戏规则

五年前你跟人讲"在手机上跑一个超分神经网络",对方大概率觉得你疯了。那时候手机 NPU 的算力还在 TOPS(每秒万亿次操作)的个位数级别,跑个小模型都吃力。

但今天情况完全不同了。HarmonyOS 设备搭载的 NPU 算力早就进入了能让超分模型流畅跑起来的区间。更重要的是,HarmonyOS 在系统层做了大量优化:模型量化(把浮点权重压成整数,推理更快)、算子融合(把多个神经网络操作合并,减少内存搬运)、内存复用(推理过程中的中间结果复用内存,峰值内存更低)。这些优化叠加起来,才让"端侧几百毫秒超分一张图"成为现实。

对你这个应用开发者来说,好消息是------这些底层优化你完全不用管。 Core Vision Kit 把它们全封装好了,你只需要调一个 process 方法,剩下的交给系统。

2.3 和双线性放大的本质区别

这一点很容易被误解,值得单独拎出来说。很多人第一次接触超分会想:"这不就是把小图放大吗?我看图软件里早就有放大功能了啊。"

差别在于信息来源

  • 双线性/双三次插值:放大的每个新像素,只由它周围的几个已知像素加权平均得到。这是一种"纯局部、纯数学"的操作,它没有任何"知识",不知道这块区域是头发还是皮肤还是文字,所以放出来的结果就是"平滑的模糊"。
  • AI 超分:放大的每个新像素,由整个神经网络基于它学到的全局知识计算得出。网络"见过"几百万张高清头发、高清皮肤、高清文字,所以它在补这个像素时,会结合上下文判断"这应该是一根发丝的边缘,应该有锐利的明暗过渡"。

一句话总结:插值是"算"出来的,超分是"想"出来的。 这个"想"的能力,来自训练数据和神经网络结构,是插值永远无法企及的。

理解了这一点,你就能解释为什么超分有时会"脑补过度"------比如把一幅油画的噪点也当成纹理给增强了。这是 AI 超分的固有特性(学术界叫"幻觉"问题),也是后面第 7 节调优时要权衡的点。

细节差异:把同一张低清图分别用双线性放大和 AI 超分放大,对比局部------双线性放大的发丝是一团灰色的色块,文字边缘是软塌塌的渐变;AI 超分放大的发丝是一根根分明的线条,文字边缘有清晰的明暗突变。这正是"算出来的细节"与"想出来的细节"的本质差别。


三、Core Vision Kit 能力地图

讲完原理,来看 HarmonyOS 给我们提供了什么。Core Vision Kit 是 HarmonyOS 的视觉 AI 能力合集,imageSuperResolution 只是其中之一。

3.1 在 Kit 体系中的位置

HarmonyOS 把系统能力按"Kit"来组织,Core Vision Kit 专门承载端侧视觉 AI。这个 Kit 里除了图像超分,还有通用文字识别(OCR)、文本搜图(HarmonyOS 7 新增)、图像分类、人脸比对等能力。它们共享相似的调用范式:创建分析器 → 构造请求 → 执行推理 → 拿到结果 → 释放分析器。

这意味着你一旦掌握了图像超分的用法,迁移到其他视觉能力几乎是零成本的------这就是 Kit 抽象的价值。

3.2 imageSuperResolution 的能力边界

在动手前,必须先把这个能力的"能做什么、不能做什么"摸清楚,免得期待落空:

维度 说明
放大倍数 固定 4 倍。不支持自定义 2×/3×,输入一张图,输出边长放大 4 倍的同内容图(面积放大 16 倍)
输入类型 PixelMap,即鸿蒙图像体系里的位图对象
输出类型 同样是 PixelMap,可以直接交给 Image 组件显示
模型可配置性 无 scale/quality 等参数,全靠系统内置模型,开发者无需也无法调参
最低 API API 26(HarmonyOS 7) 起。低于此版本的设备能力不可用
依赖 需要 NPU 硬件支持;纯 CPU 设备或老机型可能跑不起来
并发 一个分析器实例顺序处理多张图;建议复用实例而非每张图都新建

几个容易踩的预期坑,提前说清楚:

  1. "固定 4 倍"意味着输入图不能太大。 如果你喂一张本来就已经 2000×3000 的图,输出会变成 8000×12000,那是一张近 1 亿像素的图,内存占用会爆炸。端侧超分的正确用法是喂小图、出大图,而不是喂大图、出巨图。本 demo 的"内置低清示例图"设计就是基于这个考虑。

  2. 能力不可用要做好降级。 API 26 只是"系统支持",具体某台设备有没有 NPU、模型有没有预装,运行时才能确定。所以代码里一定要有 try-catch 和能力检测,能力不可用时给用户一个友好提示,而不是崩溃。

  3. 分析器要复用、要释放。 每次超分都 create() 一个新分析器,既慢又费内存;用完不 destroy(),会泄漏 NPU 资源。本 demo 把分析器生命周期封装成了一个 SrService 类,统一管理。

Core Vision Kit 能力版图 :这个 Kit 是 HarmonyOS 端侧视觉 AI 的合集,图像超分(imageSuperResolution)只是其中一员,与之并列的还有通用文字识别(OCR)、文本搜图(HarmonyOS 7 新增)、图像分类、人脸比对等。它们共享同一套"分析器---请求---推理"调用范式,掌握一个即可举一反三。


四、工程准备:升级到 API 26

图像超分要求 API 26(HarmonyOS 7),所以第一步是确认并把工程的编译目标升上去。这一节看似简单,但版本号配置是新手最容易卡住的地方,值得详细说。

4.1 搞清楚三个版本号的关系

鸿蒙开发里,"版本"是个容易绕晕新人的概念。你得先分清三件事:

  • HarmonyOS 系统版本:用户手机上跑的操作系统版本,比如 HarmonyOS 7。这个决定了"设备上有没有这个能力"。
  • API Level:系统能力的版本号,HarmonyOS 7 对应 API 26。这个决定了"代码里能不能用某个 API"。
  • DevEco Studio 版本:开发工具版本,比如 DevEco Studio 26.0.0 Beta。这个决定了"你的 IDE 能装多高的 SDK"。

三者的关系是:DevEco Studio 决定你能装多高的 SDK → 工程的 compatibleSdkVersion 决定用哪个 API 编译 → 设备的 HarmonyOS 版本决定能不能真跑起来。 你可以装了 DevEco 26.0.0(支持 API 26),但工程还写着 5.0.0(12),那代码里用 API 26 的符号照样编不过。

所以升级的第一步,是改工程的 build-profile.json5

4.2 修改 build-profile.json5

从 API 26 开始,HarmonyOS 和 OpenHarmony 的版本字符串配置统一了 ,不再用以前的 "6.0.1(21)" 这种"主版本( API 号)"格式,而是直接用 API 号对应的版本字符串 "26.0.0"。这是华为官方文档明确说明的,很多老教程还在教你写旧格式,会编译失败。

打开工程根目录的 build-profile.json5,把 products 里的 SDK 版本字段改成:

json 复制代码
{
  "products": [
    {
      "name": "default",
      "compileSdkVersion": "26.0.0",
      "targetSdkVersion": "26.0.0",
      "compatibleSdkVersion": "26.0.0",
      "runtimeOS": "HarmonyOS"
    }
  ]
}

三个字段的含义:

  • compileSdkVersion:编译时用的 SDK 版本,决定能用到哪些 API。API 26 必须设 "26.0.0"
  • targetSdkVersion:目标 SDK 版本,影响系统的运行时行为(比如权限弹窗策略)。
  • compatibleSdkVersion最低兼容版本 ,这个最关键------它决定了你的 app 能装到多低版本的系统上。设成 "26.0.0" 意味着只有 HarmonyOS 7 及以上的设备能装。

⚠️ 注意:把 compatibleSdkVersion 设成 26,意味着你的 app 放弃了所有 HarmonyOS 6 及以下用户。如果是正式产品,要用"按版本判断、能力不可用则降级"的策略(见第 7 节),并把 compatibleSdkVersion 设低一些。本文 demo 为了聚焦超分本身,直接设到了 26。

改完之后在 DevEco Studio 里 Sync 一下工程,让新配置生效。

配置要点 :在 DevEco Studio 中打开工程根目录的 build-profile.json5,把 products.default 下的三个版本字段都改成 "26.0.0"。注意从 API 26 起,HarmonyOS 与 OpenHarmony 的版本字符串配置统一了,直接写 API 号对应的 "26.0.0",不要再用 "6.0.1(21)" 这种旧格式,否则编译会失败。改完记得 Sync 工程。

4.3 关于权限:一个好消息

很多 AI 能力会要求一堆权限(相机、麦克风、位置等),但图像超分本身不需要任何额外权限声明------因为它只是一个纯计算能力,不碰任何敏感硬件或数据。

不过,我这里要从图库选图,这里有个细节值得说清楚。鸿蒙里访问用户媒体文件,传统做法是声明 ohos.permission.READ_IMAGEVIDEO 权限,然后用 photoAccessHelper 直接查媒体库。但这种方式需要申请敏感权限,用户体验不好(要弹窗确认)。

更好的做法是用 photoAccessHelper.PhotoViewPicker(系统图库选择器) 。它的工作机制是:拉起系统图库让用户主动选 一张图,系统临时授予你对这张图的只读权限。因为是用户主动选择的,所以完全不需要声明任何权限,也没有权限弹窗。这是个非常优雅的设计,本 demo 的"从图库选"功能就用了它。

所以我这里的 module.json5不需要新增任何权限,这是端侧超分相比很多 AI 能力省心的地方。


五、超分核心链路拆解

工程准备好了,现在进入正题:怎么调图像超分。这一节把整个链路拆成五步。理解了这个最小链路,后面的 demo 不过是在它外面套 UI。

5.1 第一步:导入模块

超分能力在 @kit.CoreVisionKit 里,配套的图像处理在 @kit.ImageKit,文件 IO 在 @kit.CoreFileKit。本 demo 还会用到图库(@kit.MediaLibraryKit)和日志(@kit.PerformanceAnalysisKit)。

typescript 复制代码
import { imageSuperResolution, visionBase } from '@kit.CoreVisionKit'
import { image } from '@kit.ImageKit'
import { fileIo } from '@kit.CoreFileKit'
import { photoAccessHelper } from '@kit.MediaLibraryKit'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { BusinessError } from '@kit.BasicServicesKit'

这里有两个关键点:

  1. 统一用 @kit.XxxKit 命名空间 ,不要用老的 @ohos.xxx。这是 HarmonyOS 当前推荐写法,也是 DevEco 默认生成的写法。
  2. imageSuperResolutionvisionBase 是两个并列的导出 ,来自同一个 Kit。前者是超分能力本身,后者提供通用的请求/响应数据结构(ImageDataRequest)。

5.2 第二步:创建分析器

超分的核心对象是 ImageSRAnalyzer(Image Super-Resolution Analyzer)。它是一个静态工厂方法创建的,返回一个 Promise:

typescript 复制代码
private analyzer: imageSuperResolution.ImageSRAnalyzer | null = null

async init(): Promise<boolean> {
  try {
    this.analyzer = await imageSuperResolution.ImageSRAnalyzer.create()
    return true
  } catch (err) {
    const be = err as BusinessError
    hilog.error(0x0709, 'srsvc', 'init failed: %{public}s', be.message)
    return false
  }
}

几个要点:

  • create() 是静态方法 ,挂在 ImageSRAnalyzer 类上,不是实例方法。这一点网上有些示例写法不一致,以本文为准。
  • 返回 Promise,因为创建分析器内部要加载模型,是耗时操作,不能在主线程同步阻塞。
  • 可能失败 :如果设备没有 NPU、模型没预装、或者 API 版本不够,create() 会抛异常。所以一定要 try-catch,并且让 init() 返回一个布尔值告诉调用方是否成功。
  • 分析器要复用。它内部持有一个加载好的模型实例,创建成本不低。一个页面里创建一次、复用处理多张图,是正确做法。每次超分都新建分析器,既慢又浪费内存。

5.3 第三步:把图片变成 PixelMap

超分只认 PixelMap,不认别的格式。所谓 PixelMap,是鸿蒙图像体系里的位图对象------它把图片解码成内存里的像素数组,可以直接参与像素级运算,也可以直接交给 Image 组件显示。

但问题来了:用户选的图,或者内置的示例图,怎么变成 PixelMap? 这里有个新手必踩的坑。

很多人第一反应是用 $r('app.media.xxx')$rawfile('xxx.jpg'),然后传给某个解码函数。但这是错的 ------$r$rawfile 返回的是 ResourceStr/Resource 类型,是给 Image 组件直接显示 用的,不能传给 image.createImageSource()createImageSource 不接受 Resource 类型。

正确的路径有两条,我这里两条都用到了:

路径 A:读 rawfile 内置图(demo 默认示例图走这条)

typescript 复制代码
import { image } from '@kit.ImageKit'
import { common } from '@kit.AbilityKit'

async function loadFromRawfile(ctx: common.UIAbilityContext, name: string): Promise<PixelMap | null> {
  // 1. 用 resourceManager 读 rawfile 内容,得到 Uint8Array
  const buf: Uint8Array = await ctx.resourceManager.getRawFileContent(name)
  // 2. 用 buf.buffer(ArrayBuffer)建 ImageSource
  const imageSource: image.ImageSource = image.createImageSource(buf.buffer)
  // 3. 解码成 PixelMap
  const pixelMap: PixelMap = await imageSource.createPixelMap()
  // 4. ImageSource 用完即释放(PixelMap 还能用)
  imageSource.release()
  return pixelMap
}

注意第 1 步,getRawFileContent 返回的是 Uint8Array,而 createImageSource 要的是 ArrayBuffer,所以要传 buf.buffer。这个细节错了会编译报错。

路径 B:从图库 uri 解码(demo"从图库选"走这条)

图库选择器返回的是一个 file://media/... 形式的 uri。要把它变成 PixelMap,先用 fileIo.openSync 打开文件拿 fd,再用 fd 建 ImageSource:

typescript 复制代码
import { fileIo } from '@kit.CoreFileKit'

async function decodeFromUri(uri: string): Promise<PixelMap | null> {
  let file: fileIo.File | null = null
  let imageSource: image.ImageSource | null = null
  try {
    // 1. openSync 直接传 file:// uri 即可,返回 File 对象(含 fd)
    file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY)
    // 2. 用 file.fd 建 ImageSource
    imageSource = image.createImageSource(file.fd)
    // 3. 解码
    const pixelMap: PixelMap = await imageSource.createPixelMap()
    return pixelMap
  } finally {
    // 4. 释放顺序:先 ImageSource 再关文件
    if (imageSource !== null) imageSource.release()
    if (file !== null) fileIo.closeSync(file)
  }
}

这条路径有几个新手坑,都帮你标出来了:

  • openSync 返回的是 File 对象,不是 fd 数字 。要取 .fd 字段传给 createImageSource
  • file:// 前缀的 uri 可以直接传给 openSync,不用手动去掉前缀。
  • 释放有顺序 :先 imageSource.release()fileIo.closeSync(file),反了可能报"文件被占用"。用 try-finally 保证一定释放。

这两条路径搞清楚,"图片来源"这个老大难就解决了。我这里把它们封装成了 SrImageSource 类的静态方法,UI 层只管调。

5.4 第四步:执行超分推理

万事俱备,开始推理。这一步代码最短,但信息量最大:

typescript 复制代码
async superResolve(input: PixelMap): Promise<PixelMap | null> {
  if (this.analyzer === null) return null
  try {
    // 1. 把 PixelMap 包成 ImageData
    const imageData: visionBase.ImageData = { pixelMap: input }
    // 2. 把 ImageData 包成 Request
    const request: visionBase.Request = { inputData: imageData }
    // 3. 执行推理,返回 ISPResponse
    const resp: imageSuperResolution.ISPResponse = await this.analyzer!.process(request)
    // 4. 从响应里取出结果 PixelMap
    return resp.pixelMap
  } catch (err) {
    const be = err as BusinessError
    hilog.error(0x0709, 'srsvc', 'process failed: %{public}s', be.message)
    return null
  }
}

这里的数据结构层级是 PixelMap → ImageData → Request → process → ISPResponse → PixelMap,看起来有点绕,但其实是一种通用的"请求-响应"封装,整个 Core Vision Kit 都用这套:

  • visionBase.ImageData :输入数据的容器,目前只有一个 pixelMap 字段(未来如果要支持其他输入类型,这套结构能扩展)。
  • visionBase.Request :请求体,把 ImageData 包在 inputData 字段里。
  • imageSuperResolution.ISPResponse :超分专用的响应类型,结果在 pixelMap 字段里。

几个要点:

  • 没有 scale/quality 参数。前面说过,超分固定 4 倍,开发者无需也无法配置。这让 API 极其简洁,但也意味着你没法调"放得慢一点但效果好一点"这种参数。
  • process 是异步的 ,因为推理本身耗时(NPU 上几百毫秒)。不要在任何同步上下文里调用它,要在 async 函数里 await。
  • 输入和输出是两个不同的 PixelMap 对象 。超分不会修改你的输入图,而是生成一张新的更大的图。这意味着你两张都要管理生命周期 ------用完后都要 release(),否则内存泄漏。
  • 失败处理process 可能因为输入图过大、格式不支持、NPU 异常等原因抛错,必须 try-catch。我这里让它返回 null 表示失败,UI 层据此显示错误态。

5.5 第五步:释放资源

这是最容易被忽略、但最关键的一步。超分涉及的对象都不小:分析器持有模型(几十 MB),PixelMap 持有像素数据(一张 4 倍图可能几十 MB),ImageSource 持有文件句柄。用完不释放,内存会肉眼可见地涨,多跑几次就 OOM。

typescript 复制代码
async destroy(): Promise<void> {
  if (this.analyzer !== null) {
    try {
      await this.analyzer.destroy()  // 释放分析器(含模型)
    } catch (err) {
      // 忽略重复释放
    }
    this.analyzer = null
  }
}

// 单独的 PixelMap 释放(安全包装)
async function safeReleasePixelMap(pm: PixelMap | null): Promise<void> {
  if (pm === null) return
  try {
    await pm.release()
  } catch (err) {
    // 忽略
  }
}

释放的时机有两个:

  1. 页面销毁时aboutToDisappear):释放分析器,清理当前持有的 PixelMap。
  2. 每次切换/重新超分时 :先释放旧的 outputPm,再赋新的。否则用户连续点几次"超分",旧的 4 倍图全堆在内存里。

这里把这两个时机都处理了------cleanup()aboutToDisappear 调,runSuperResolution() 里在赋新结果前先 safeReleasePixelMap(this.outputPm)

至此,五步最小链路讲完了。

五步链路总览创建分析器把图片解码成 PixelMap构造 Request执行 process 推理释放资源。整个链路的代码被封装在 SrService.ets 里,不到 150 行,却是 demo 的核心。


六、实战:超分相册应用

理论讲够了,来看完整 demo。这个 demo 叫"超分相册",功能是:选一张低清图(内置示例或从图库选)→ 点按钮触发端侧超分 → 用滑块对比器左右拖动,直观对比原图和 4 倍高清图。

6.1 代码组织:三层架构

本 demo 的代码组织遵循一个原则:业务逻辑和 UI 渲染分离。纯逻辑(状态机、文案、颜色、坐标换算)放纯逻辑层,服务(超分调用、文件 IO)放服务层,UI 只管渲染和手势。这样纯逻辑可以被单元测试覆盖,UI 层也清爽。

复制代码
entry/src/main/ets/pages/erqi/
├── SrMainPage.ets              # UI 启动页:超分流程编排 + 滑块对比器
└── superresolution/
    ├── SrCompareModel.ets      # 纯逻辑:状态机、文案、颜色、位置换算
    ├── SrService.ets           # 服务:Core Vision Kit 超分封装
    └── SrImageSource.ets       # 服务:rawfile 示例图 + 图库选图

这种分层不是摆设,它带来三个实际好处:

  1. 纯逻辑层可单测SrCompareModel.ets 里全是纯函数(位置换算、文案生成),不依赖任何 ArkUI 运行时,可以直接在 ohosTest 里测。本 demo 的滑块位置 clamp 逻辑就是这么验证的。
  2. 服务层可复用SrService 是个独立的 class,不依赖任何 @Component,明天你想把超分做到另一个页面,直接 new SrService() 就能用。
  3. UI 层够薄SrMainPage 只负责"用户点了什么→调什么服务→状态变成什么→怎么渲染",不掺杂任何业务推导,改起来不头疼。

这个分层模式在本项目里是通用约定------日历组件、播放器组件都这么分。统一的结构让代码可读性大幅提升。

6.2 状态设计:用状态机管理超分流程

demo 的核心交互是一个状态机,它决定 UI 当前长什么样。状态定义在纯逻辑层:

typescript 复制代码
// SrCompareModel.ets
export type SrPhase = 'idle' | 'loading' | 'done' | 'error'

四个状态的含义:

  • idle(待处理):刚加载图、还没超分。主按钮显示"开始超分",对比区只显示原图。
  • loading(推理中):正在跑超分。主按钮禁用显示"超分中...",对比区可以加个 loading。
  • done(已完成):超分成功。对比区显示滑块对比器,主按钮变成"重新超分",右侧出现"耗时 X ms"标签。
  • error(出错):超分失败或图加载失败。主按钮变成"重试",对比区显示错误信息。

用状态机的好处是,UI 渲染只看 this.phase 一个变量,所有"现在该显示什么"的判断都收敛到状态上,不会出现"按钮显示超分中但对比区还显示 loading"这种不一致。状态转换的规则也很清晰:idle → loading → done/error,用户点按钮时 idle/done/error → loading

派生数据(文案、颜色)也都由纯函数从状态派生,UI 层不写任何三元判断逻辑:

typescript 复制代码
// 状态 → 主按钮文案
export function primaryActionText(phase: SrPhase): string {
  if (phase === 'loading') return '超分中...'
  if (phase === 'done') return '重新超分'
  if (phase === 'error') return '重试'
  return '开始超分'
}

// 状态 → 主题色(徽标、进度)
export function phaseColor(phase: SrPhase): string {
  if (phase === 'loading') return '#5B7CFA'  // 蓝
  if (phase === 'done') return '#2ECC71'     // 绿
  if (phase === 'error') return '#E64545'    // 红
  return '#9E9E9E'                            // 灰
}

这种"状态→派生数据"的写法,让 UI 代码变得极其规整------Text(primaryActionText(this.phase)).backgroundColor(phaseColor(this.phase)),一眼就能看出渲染逻辑,没有任何散落在各处的判断。

6.3 UI 编排:从顶到底的五个区块

SrMainPage 的 UI 用项目惯用的 @Builder 拼成五个区块,从顶到底依次是:

typescript 复制代码
build() {
  Column() {
    this.Header()      // 标题 + 状态徽标
    this.SourceBar()   // 源图选择条(内置示例 + 从图库选按钮)
    this.CompareArea() // 对比区(核心:滑块对比器)
    this.ActionBar()   // 操作栏(超分按钮 + 耗时)
    this.LogPanel()    // 运行日志(调试用)
  }
}

每个 @Builder 对应一个视觉区块,职责单一。这种拆法让超长的 UI 文件依然好读------你想改"对比区"的逻辑,直接跳到 CompareArea() 里,不用翻几百行其他代码。

Header 是标题加一个状态徽标(小圆点 + 状态文字),颜色随 phase 变化。SourceBar 是一个横向滚动的源图列表(内置三张示例图 + 用户从图库新选的图),点击切换。ActionBar 是主操作按钮,文案和可用性都从 phase 派生。LogPanel 是一个等宽字体的日志区,记录每一步操作(建分析器、加载图、超分耗时),调试时极其有用------你能直接看到"分析器建了多久""超分跑了多少毫秒"。

6.4 核心:滑块对比器的实现

整个 demo 最有意思、也最值得讲的就是这个滑块对比器。它的效果是:一张图上叠加一根可拖动的竖线,线左边显示原图,线右边显示超分高清图,拖动竖线就能直观对比同一位置在原图和高清图里的差异。

实现思路是用 Stack 叠两层 Image,配合动态宽度和裁剪:

typescript 复制代码
Stack() {
  // 下层:超分高清图(铺满整个容器)
  if (this.outputPm !== null) {
    Image(this.outputPm)
      .objectFit(ImageFit.Cover)
      .width('100%').height('100%')
      .borderRadius(16)
  }
  // 上层:原图,宽度动态变化 + clip(true) 裁掉超出部分
  Column() {
    Image(this.inputPm)
      .objectFit(ImageFit.Cover)
      .width('100%').height('100%')
  }
  .width(`${this.splitRatio * 100}%`)   // 宽度 = 容器宽 × 分割比例
  .height('100%')
  .clip(true)                           // 关键:裁掉超出手柄右边的部分
  .borderRadius(16)

  // 分割线 + 手柄(只在已超分时显示)
  if (this.outputPm !== null) {
    this.SplitHandle()
  }
}
.gesture(
  PanGesture()
    .onActionUpdate((event: GestureEvent) => {
      const x: number = event.fingerList[0].localX  // 容器内 x 坐标
      this.onSliderDrag(x)
    })
)

原理拆解:

  1. 下层是高清图,铺满整个容器。它是"底",永远完整显示。
  2. 上层是原图,但宽度被限制成 splitRatio × 容器宽 。比如 splitRatio = 0.5 时,上层宽度是容器的一半,只覆盖左半边。
  3. 关键在 .clip(true) 。它会把上层超出自身宽度范围的内容裁掉。因为上层 Image 用了 objectFit(ImageFit.Cover),图片本身会填满(甚至溢出)它的父容器,但被 clip(true) 裁成了"容器左半边"的形状。
  4. 视觉效果 :左半边(上层覆盖的区域)显示原图,右半边(上层没覆盖、露出下层)显示高清图。分割线就在 splitRatio 对应的位置。
  5. 手柄是一根竖线 + 一个圆形按钮 ,用 position 定位在 splitRatio × 100% 处,随分割比例移动。

手势处理有个细节值得一提。最初我把 PanGesture 绑在手柄那个小圆圈上,但发现一个问题:手柄自身只有 34px 宽,手指一旦滑出手柄范围,localX 就不准了。后来改成PanGesture 绑在整个 Stack 对比区容器上 ------因为容器占满整个对比区,手指在容器内的 localX 永远是准确的容器内坐标,换算成 splitRatio 就稳了:

typescript 复制代码
private onSliderDrag(localX: number): void {
  if (this.compareWidth <= 0) return
  const r: number = ratioFromPixel(localX, this.compareWidth)
  this.splitRatio = r
}

compareWidth 是容器实际宽度,通过 .onAreaChange 在容器尺寸确定时回填。ratioFromPixel 是纯逻辑层的函数,把像素坐标换算成 0,1 的归一化比例并 clamp 到合理范围(避免手柄完全贴边看不见)。

这套实现用到的全是 ArkUI 基础组件(StackImageColumnPanGesture.clip),没有任何高级 API,但组合出来的效果非常专业。这也是本 demo 想传递的一个理念:好的交互不一定依赖复杂 API,把基础组件组合好,效果一样惊艳。

demo 运行状态:页面从上到下依次是标题栏(含状态徽标)、源图选择条、对比区、操作栏、日志面板。四个核心状态------

  • idle(待处理):刚加载图,对比区只显示原图,主按钮为"开始超分"。
  • loading(推理中):点按钮后主按钮变灰显示"超分中...",防止重复点击。
  • done(已完成):对比区出现可拖动滑块,主按钮变"重新超分",右侧出现耗时标签。
  • error(出错):能力不可用或图缺失时显示友好提示,主按钮变"重试"。

6.5 状态变更的规范写法

本 demo 用的状态管理是 ArkUI 的 V2 模式,核心是几个装饰器:@Local 管组件内部状态,@Entry 标入口。这些细节本文不展开(不是重点),但有一个写法规范值得强调,因为它直接关系到代码能不能编译通过、运行时刷新对不对。

鸿蒙 ArkTS 禁止对象展开语法 { ...obj } 和解构声明 const { a } = obj 这意味着所有"基于旧状态生成新状态"的操作,都得显式构造新对象。比如往日志列表追加一条,不能写 [...this.logs, newLog],得这么写:

typescript 复制代码
// 纯逻辑层的 appendLog
export function appendLog(logs: SrLogLine[], level: string, text: string): SrLogLine[] {
  const next: SrLogLine[] = []
  logs.forEach((l: SrLogLine) => { next.push(l) })  // 显式拷贝每个元素
  next.push({ ts: Date.now(), level: level, text: text })
  while (next.length > 14) next.shift()             // 保持最多 14 条
  return next
}
// UI 层调用
this.logs = appendLog(this.logs, 'info', '超分完成')

这种写法看起来啰嗦,但它强制你每次都生成一个全新的数组/对象,配合 @Local 的"赋值即刷新"机制,状态更新天然可靠------你永远不用担心"改了属性但 UI 没刷新"的问题。本 demo 的所有状态变更(日志追加、源图列表追加、状态机切换)都遵循这个规范。


七、性能与体验调优

把功能跑起来只是第一步,要让它真正好用,还有几个性能和体验的点必须处理。这一节讲的都是实战中真实会遇到的问题。

7.1 大 PixelMap 的内存管理

超分输出是 4 倍图,意味着面积放大 16 倍。如果输入是 500×500,输出就是 2000×2000,像素数从 25 万飙到 400 万,按 RGBA 四通道算就是 16MB 的原始像素数据。这还只是一张图。

如果用户连续点几次"重新超分",每次都生成一张新的 4 倍图却没释放旧的,内存会迅速涨到几百 MB,触发系统内存警告甚至 OOM 闪退。所以每次生成新结果前,必须先释放旧结果

typescript 复制代码
async runSuperResolution(): Promise<void> {
  // ... 推理 ...
  safeReleasePixelMap(this.outputPm)   // 先释放旧结果
  this.outputPm = out                   // 再赋新结果
}

同理,切换源图时也要释放旧的 inputPmoutputPm。本 demo 的 loadCurrentSource() 开头就做了这个清理。这看似是个小细节,但它是 demo 能否经得起"反复点"的关键------没有这步,点个十来次就崩了。

7.2 Image 组件渲染大图的卡顿

拿到 4 倍高清 PixelMap 后,直接丢给 Image 组件显示,在某些设备上会感觉到一瞬间的卡顿。原因是 Image 要把 PixelMap 上传到 GPU 纹理,大图纹理上传是耗时操作。

本 demo 的对比区固定高度 300px(逻辑像素),远小于 4 倍图的实际尺寸,所以 Image 内部会做下采样,只渲染需要的分辨率,卡顿不明显。但如果你要在全屏展示原图大小的 4 倍图,建议:

  1. objectFit(ImageFit.Cover) 配合固定容器尺寸 ,让 Image 只解码需要的大小(这依赖系统的局部解码能力)。
  2. 避免在同一屏放多个大 PixelMap。本 demo 只有两张(原图 + 高清图),还能接受;如果是图墙,要做缩略图 + 点开才超分的两级方案。
  3. 考虑用 image.createPixelMap 的解码选项,在源头控制 PixelMap 尺寸。但超分场景输出尺寸是固定的 4 倍,这招用不上。

7.3 推理耗时的用户感知

端侧超分虽然快(几百毫秒),但毕竟不是瞬间。这段时间如果 UI 没有任何反馈,用户会以为卡死了。本 demo 的处理是:

  • 状态切到 loading,主按钮变成"超分中..."并禁用,防止重复点击。
  • 记录并展示耗时:超分完成后,在操作栏右侧显示一个"X ms 端侧"的标签。这个数字对开发者调试很有价值(能直观对比不同设备的 NPU 性能),对用户也是个"原来这么快"的惊喜点。
typescript 复制代码
const t0: number = Date.now()
const out: PixelMap | null = await this.srSvc.superResolve(this.inputPm)
this.costMs = Date.now() - t0

如果你想做得更精致,可以在 loading 阶段加一个进度条或骨架屏。但要注意------超分是黑盒推理,系统不提供进度回调,所以进度条只能是"假进度"(匀速走到 90% 然后等结果)。本 demo 没做这个,因为几百毫秒的等待用"按钮变灰 + 文案变化"已经足够,假进度条反而显得做作。

7.4 能力不可用时的降级

这一点怎么强调都不过分。compatibleSdkVersion 设成 26,只能保证"系统支持",不能保证"设备能跑"。具体某台 HarmonyOS 7 设备有没有 NPU、模型有没有预装,要运行时才知道。

所以 SrService.init() 失败时,demo 不是崩溃,而是切到 error 状态,显示友好提示:

typescript 复制代码
const ok: boolean = await this.srSvc.init()
if (!ok) {
  this.phase = 'error'
  this.errorMsg = '端侧超分能力不可用,请在 HarmonyOS 7(API26) 及以上设备运行'
}

如果是正式产品,降级策略可以更丰富:检测到能力不可用,回退到云端超分(如果有服务端),或者干脆只显示原图(明确告知"本设备不支持超分")。本 demo 为了聚焦,只做了最基本的错误提示,但这个"先检测、不可用就降级"的思路是必须有的。

7.5 分析器复用 vs 新建

前面提过分析器要复用,这里展开说。分析器内部持有一个加载好的模型,create() 时要加载模型到 NPU,这是个毫秒到秒级的操作。如果你每次超分都 create() 一次,用户每点一次按钮都要等模型加载,体验很差。

正确做法是页面进入时 create() 一次,页面退出时 destroy() 一次,中间所有超分共用这一个实例 。本 demo 的 SrService 就是这么设计的------init() 幂等(重复调用安全),destroy()aboutToDisappear 调。

typescript 复制代码
aboutToAppear() {
  this.initAnalyzer()      // 建一次
}
aboutToDisappear() {
  this.cleanup()           // 销毁一次
}
// 中间多次 runSuperResolution 复用同一个 analyzer

优化前后对比:不释放旧结果时,连续点几次"重新超分",应用内存占用会呈阶梯状持续攀升(每张 4 倍图几十 MB 堆积),多跑几次触发系统内存警告甚至 OOM 闪退;加了"赋新结果前先 release 旧结果"后,内存占用趋于平稳,反复操作不再上涨。


八、结语:端侧 AI 的更多可能

写到这里,一个完整的端侧图像超分 demo 就成型了。回顾一下我们做了什么:从理解超分原理,到升级工程配置,到拆解五步核心链路,再到用一个滑块对比器把效果直观呈现,最后处理了内存、卡顿、降级这些工程细节。

但这只是 Core Vision Kit 的冰山一角。HarmonyOS 7 的视觉 AI 能力远不止超分:

  • 文本搜图(HarmonyOS 7 新增):用自然语言搜索本地相册里的图,比如搜"海边的夕阳"就能找到对应照片,全程端侧、不联网。它的底层和超分共享 Core Vision Kit 的调用范式,你掌握一个就能快速上手另一个。
  • 通用文字识别(OCR):端侧识别图片里的文字。和超分组合起来特别有用------先超分把糊掉的文档变清晰,再 OCR 识别,准确率会显著提升。
  • 图像分类、人脸比对等:都是同一套"分析器-请求-推理"范式。

端侧 AI 的真正价值,不在于"比云端快多少",而在于它带来的三个根本性改变:数据不出设备(隐私)、不依赖网络(可用性)、零延迟(体验)。这三个特性叠加,让很多以前不敢想、做不到的场景成为可能------医疗影像的隐私保护、断网环境下的文档处理、对延迟极其敏感的实时增强。

图像超分是一个很好的起点:它效果直观(一张糊图变清晰,谁都能看懂)、API 简洁(五步搞定)、应用场景广(相册、聊天、电商、文档都能用)。希望这篇文能帮你跨过端侧 AI 的门槛,把它真正用进你的应用里。

端侧 AI 的故事才刚开始,HarmonyOS 已经把工具递到你手上了。剩下的,看你怎么用。


相关推荐
星核0penstarry1 小时前
从 Dialog-RSN-1 看语音 Agent 走向:企业如何评估音频原生模型与 API 服务
人工智能·音视频·音频·api
GetcharZp2 小时前
让照片开口说话:LivePortrait 本地部署玩法详解
人工智能·计算机视觉
OpenCSG2 小时前
CSGClaw v0.4.2-v0.4.3 版本更新
人工智能·opencsg
大鱼>2 小时前
因子挖掘+回测+RL交易:量化金融完整系统
人工智能·深度学习·算法·金融
梦想三三2 小时前
YOLOv5 口罩目标检测实战(一):项目整体介绍与数据准备
人工智能·yolo·目标检测
梵构广告2 小时前
平台怎么做品牌营销策划?—品牌营销
大数据·人工智能·平面·品牌策划
土豆12502 小时前
DeepSeek V4-Flash 正式版深度解读:一行 changelog 里的暗涌与野心
人工智能·llm
Aloudata2 小时前
Prompt 驱动分析 vs Skill 驱动分析:企业 AI 分析如何从会问走向可复用
大数据·人工智能·数据分析·prompt·skill·语义层