当相册里那张承载着十年回忆的老照片糊成一团,当聊天里收到的缩略图点开全是马赛克,当电商详情页的大图在弱网下转圈转了半分钟------我们真正想要的,其实是一个"小图先传、大图再看"的体验。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 设备或老机型可能跑不起来 |
| 并发 | 一个分析器实例顺序处理多张图;建议复用实例而非每张图都新建 |
几个容易踩的预期坑,提前说清楚:
-
"固定 4 倍"意味着输入图不能太大。 如果你喂一张本来就已经 2000×3000 的图,输出会变成 8000×12000,那是一张近 1 亿像素的图,内存占用会爆炸。端侧超分的正确用法是喂小图、出大图,而不是喂大图、出巨图。本 demo 的"内置低清示例图"设计就是基于这个考虑。
-
能力不可用要做好降级。 API 26 只是"系统支持",具体某台设备有没有 NPU、模型有没有预装,运行时才能确定。所以代码里一定要有 try-catch 和能力检测,能力不可用时给用户一个友好提示,而不是崩溃。
-
分析器要复用、要释放。 每次超分都
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'
这里有两个关键点:
- 统一用
@kit.XxxKit命名空间 ,不要用老的@ohos.xxx。这是 HarmonyOS 当前推荐写法,也是 DevEco 默认生成的写法。 imageSuperResolution和visionBase是两个并列的导出 ,来自同一个 Kit。前者是超分能力本身,后者提供通用的请求/响应数据结构(ImageData、Request)。
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) {
// 忽略
}
}
释放的时机有两个:
- 页面销毁时 (
aboutToDisappear):释放分析器,清理当前持有的 PixelMap。 - 每次切换/重新超分时 :先释放旧的
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 示例图 + 图库选图
这种分层不是摆设,它带来三个实际好处:
- 纯逻辑层可单测 。
SrCompareModel.ets里全是纯函数(位置换算、文案生成),不依赖任何 ArkUI 运行时,可以直接在ohosTest里测。本 demo 的滑块位置 clamp 逻辑就是这么验证的。 - 服务层可复用 。
SrService是个独立的 class,不依赖任何@Component,明天你想把超分做到另一个页面,直接new SrService()就能用。 - 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)
})
)
原理拆解:
- 下层是高清图,铺满整个容器。它是"底",永远完整显示。
- 上层是原图,但宽度被限制成
splitRatio × 容器宽。比如splitRatio = 0.5时,上层宽度是容器的一半,只覆盖左半边。 - 关键在
.clip(true)。它会把上层超出自身宽度范围的内容裁掉。因为上层Image用了objectFit(ImageFit.Cover),图片本身会填满(甚至溢出)它的父容器,但被clip(true)裁成了"容器左半边"的形状。 - 视觉效果 :左半边(上层覆盖的区域)显示原图,右半边(上层没覆盖、露出下层)显示高清图。分割线就在
splitRatio对应的位置。 - 手柄是一根竖线 + 一个圆形按钮 ,用
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 基础组件(Stack、Image、Column、PanGesture、.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 // 再赋新结果
}
同理,切换源图时也要释放旧的 inputPm 和 outputPm。本 demo 的 loadCurrentSource() 开头就做了这个清理。这看似是个小细节,但它是 demo 能否经得起"反复点"的关键------没有这步,点个十来次就崩了。
7.2 Image 组件渲染大图的卡顿
拿到 4 倍高清 PixelMap 后,直接丢给 Image 组件显示,在某些设备上会感觉到一瞬间的卡顿。原因是 Image 要把 PixelMap 上传到 GPU 纹理,大图纹理上传是耗时操作。
本 demo 的对比区固定高度 300px(逻辑像素),远小于 4 倍图的实际尺寸,所以 Image 内部会做下采样,只渲染需要的分辨率,卡顿不明显。但如果你要在全屏展示原图大小的 4 倍图,建议:
- 用
objectFit(ImageFit.Cover)配合固定容器尺寸 ,让Image只解码需要的大小(这依赖系统的局部解码能力)。 - 避免在同一屏放多个大 PixelMap。本 demo 只有两张(原图 + 高清图),还能接受;如果是图墙,要做缩略图 + 点开才超分的两级方案。
- 考虑用
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 已经把工具递到你手上了。剩下的,看你怎么用。