【共创稿事节】HarmonyOS7 图像超分辨率实战:CoreVisionKit 让老旧模糊照片焕然一新
前言
图像超分辨率解决的是低分辨率图片的细节重建问题,并不等同于无损恢复原始信息。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提供通用的ImageData和Request类型@kit.ImageKit:图片编解码——ImageSource解码、ImagePacker编码、PixelMap操作@kit.CoreFileKit:文件读写 + 图片选择器(picker.PhotoViewPicker)@kit.MediaLibraryKit:相册写入(photoAccessHelper)@kit.AbilityKit:获取UIAbilityContext用于文件路径和相册操作@kit.PerformanceAnalysisKit:日志输出@kit.BasicServicesKit:BusinessError类型用于错误处理
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(),用于界面展示。同时重置所有输出状态(outputImage、outputSize、isRestored、sliderOffset),确保切换图片时不会残留上一次的结果。
资源释放不能忘: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;
}
这是整个案例最核心的三步:
scaleToLimit(input, 2048):缩放到 2048 以内,避免 API 报错- 构造请求:
visionBase.ImageData包装 PixelMap →visionBase.Request包装 ImageData。CoreVisionKit 的所有视觉能力都用这套Request → Response模型 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 文件
ImagePacker将 PixelMap 编码为 JPEG,质量 95(高质量)- 写入应用沙箱目录
context.filesDir,文件名用时间戳避免冲突 fileIo.openSync/writeSync/closeSync同步写入——这里用同步没问题,编码已经完成,写入量不大
阶段二:沙箱文件 → 系统相册
photoAccessHelper.getPhotoAccessHelper(context)获取相册管理器MediaAssetChangeRequest.createImageAssetRequest(context, filePath)创建一个"把文件导入相册"的请求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));
}
})
)
手势逻辑:
onActionStart:记录开始滑动时的sliderOffset快照到lastOffsetonActionUpdate:lastOffset + offsetX / containerWidth计算新偏移——offsetX是手指相对起点的水平位移,除以容器宽度转为 0~1 的比例Math.max(0.05, Math.min(0.95, newOffset)):限制范围 5%~95%,防止原图完全不可见
为什么要用 lastOffset 而不是直接累加? 因为 PanGesture 的 offsetX 是相对起点的累计偏移,不是每帧的增量。如果直接在 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.Request → analyzer.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 + 偏移宽度的思路,以后做类似功能可以直接搬。
更多推荐



所有评论(0)