【HarmonyOS 7 视觉 AI 开放能力深度实战】 06 图像超分与效果对照实战
文章目录
前言

用户从图片列表进入详情页时,原来只有缩略图大小的照片可能要占据更大的显示区域。图片组件可以把小图放大,但放大以后,杯口、文字或建筑边缘的模糊也会更容易被看见。对一些需要放大浏览的图片,应用可以先尝试生成更高分辨率的版本,再让用户比较处理前后的效果。
HarmonyOS 的 CoreVisionKit 提供图像超分能力。我们将准备一张低分辨率图片,把它交给 ImageSRAnalyzer,再把返回的像素图和原图放在相同条件下显示。处理后的图片还会保存到应用目录,便于继续检查和使用。
一、准备低分辨率输入
页面从工程的 resources/rawfile 中读取默认图片。资源文件中的 JPEG 数据先创建为图像源,再解码成 RGBA_8888 格式的 PixelMap。图像源在解码结束后释放,页面继续持有像素图,用它显示原图并作为超分接口的输入。
图片文件的大小和像素尺寸不是同一个概念。文件大小会受到编码格式和压缩程度影响,像素尺寸则描述图片横向和纵向包含多少个像素。超分接口需要检查输入图像的宽和高,因此页面从解码后的像素图读取实际尺寸,再显示在状态区域。
图像超分要求输入宽、高均大于 16 像素且小于 2048 像素。对于较大的输入,处理时间和资源占用也会上升,接入时优先使用小于 1024 × 1024 的图片有助于降低超时风险。页面目前展示尺寸,但没有在选图后额外裁剪或缩放,用户可以据此判断输入是否符合要求。
用户还可以打开系统图片选择器,更换成自己的照片。新图片解码成功以后,页面才替换旧输入,并清除上一张图的输出。用户如果取消选择,页面会保留已有图片,继续对它执行处理。
用于比较的输入最好能看清原来的模糊或锯齿,同时保留足够明确的主体。例如把一张普通照片缩小后作为输入,能够观察边缘在不同尺寸下的变化。拍摄抖动、失焦和过度压缩造成的信息损失各不相同,使用自己的图片时,也需要结合图片来源判断结果是否适合当前用途。
选择图片后,先观察输入尺寸和预览内容是否一致。例如原始文件在别的地方已经被缩略图服务处理过,应用拿到的可能只是较小的副本。页面应以实际解码结果为准,不能继续沿用原始照片的尺寸。确认输入以后,后续比较才能回答这张实际交给分析器的图片发生了什么变化。
二、取得超分后的像素图
输入准备好以后,我们从 @kit.CoreVisionKit 导入 imageSuperResolution。调用 ImageSRAnalyzer.create() 创建分析器,再把像素图放进 process() 的 inputData。处理完成后,返回值中的 pixelMap 就是本次输出。
const analyzer = await imageSuperResolution.ImageSRAnalyzer.create();
try {
// 保留返回的像素图,供页面对照和保存。
const data = await analyzer.process({ inputData: { pixelMap: input } });
result.picture = data.pixelMap;
const info = await data.pixelMap.getImageInfo();
result.text = '输出尺寸:' + info.size.width + ' × ' + info.size.height;
return result;
} finally { await analyzer.destroy(); }
分析器在处理完成或发生异常时通过 destroy() 释放。输入图和输出图则交由页面继续持有,因为用户还要同时查看两张图片,输出也要交给编码器保存。分析器的使用结束,不代表页面已经不再需要它返回的像素图。
页面读取输出图的 getImageInfo(),将宽高显示在详情区域。输入和输出的尺寸都来自各自的像素图,开发者可以直接比较两组数据,确认本次调用生成了什么尺寸的图片。这里展示的是实际返回值,页面没有根据输入尺寸预先写死一个输出数字。

当前请求没有传入自定义放大倍率。输出尺寸以返回图片为准,如果产品需要一个指定尺寸,可以在超分完成后再增加独立的尺寸调整步骤。界面也需要区分两次处理,避免用户设置的目标尺寸被误认为分析器本身接收的参数。
如果输出尺寸已经满足页面展示需求,应用可以直接保存本次结果。若还要适配某个固定宽度,则应先读取返回宽高,再按目标区域决定是否需要缩放。保持宽高比能够避免画面被拉伸,裁剪则会改变画面范围,选择哪一种取决于最终展示位置。
当分析器创建失败或处理抛出异常时,页面显示错误信息,并在任务结束后恢复按钮。原图继续保留,用户可以检查输入尺寸后重试。页面在每次处理前清除上一次输出,避免失败之后仍把旧结果显示成当前图片的处理结果。
三、在相同条件下比较原图和输出
处理成功后,页面把原图和输出图并排放在两个等大的区域中。两边都使用 ImageFit.Contain,让图片保持自己的宽高比并完整显示。相同的组件尺寸和显示方式减少了布局对观察结果的影响,用户可以先判断画面内容是否对应,再查看边缘和纹理。

图片的像素尺寸和页面上的显示尺寸需要分开理解。即使输出图片包含更多像素,放入较小的预览区域时,图片组件仍会把它缩小到容器中。预览适合查看整体轮廓,如果需要检查某个局部的细微变化,还要打开保存的输出文件,在相同位置和观看尺寸下比较。
例如要观察杯口边缘,可以先在原图中确定杯口所在的位置,再查看输出图的对应区域。如果只比较两张不同大小的整图,边缘变清晰可能来自显示缩放,也可能来自超分处理,单凭肉眼很难区分。统一观看条件以后,开发者才能更有把握地描述看到的变化。
观察时还可以留意细线、纹理和文字。超分处理会根据输入生成更高分辨率的图像,输出中出现的细节不一定能还原拍摄时未记录的信息。对于票据文字或其他需要保留原始内容的材料,页面应保留原图,让用户能够随时回看,避免把处理结果直接当作原始记录。
相同页面也不等于完整的质量评估。若产品需要决定是否默认使用增强图,可以准备多张业务图片,在相同目标尺寸下比较原图的普通放大效果和超分结果。除了观察边缘是否平滑,还要检查纹理是否失真、文字是否发生难以接受的变化。
比较时还应留意图片经过了几次编码。如果一边直接使用像素图,另一边先经过多次压缩,看到的差异就可能混入编码损失。应用可以分别保留原始输入和处理输出,再用相同的查看方式打开。这样出现异常纹理时,才容易判断它来自处理过程还是后续保存。
页面没有提供局部放大或三路对照工具,所以当前交互主要用于查看原图和输出。后续增加对照工具时,可以让两张图共享缩放比例和查看位置。用户移动到一处细节时,另一侧也显示对应区域,比较会更加直观。
四、保存输出并管理图片资源
页面收到输出像素图后,调用 ImageStore.png() 生成 PNG 文件。ImagePacker.packToData() 先返回编码数据,应用再把数据写入 filesDir。编码器和文件在各自操作结束后释放,输出像素图继续由页面持有,用于显示处理结果。
保存路径位于应用自己的文件目录。页面在详情中显示这个路径,便于开发时找到文件,但这一步不会自动把图片加入系统相册。如果产品需要用户在相册中直接查看或分享,还需要提供相应的媒体保存入口。
输出文件使用固定名称 super-resolution.png。再次处理并成功保存时,新文件会覆盖同名结果,原来的输入图片保持不变。这个方式适合只需要保留最近一次输出的页面,如果需要管理多张增强图,就要为每次输出分配不同名称。
超分后的图片通常包含更多像素,像素图占用的内存也会随宽高增加。可以用 宽 × 高 × 每像素字节数 粗略估算像素数据的大小。例如使用四字节表示一个像素时,宽和高各增加一倍,像素数量和这部分内存都会增加到原来的四倍。实际运行时还需要考虑处理过程中的缓冲区和其他对象。
页面同时保留输入和输出,是为了让用户对照。这个选择会增加内存占用,所以图片不再用于显示以后就应及时释放。如果未来扩展为批量处理,也需要控制同时在内存中的图片数量,并在每张图片完成保存后检查哪些对象仍然需要保留。
页面一次只处理一张图片。新的处理开始前,页面释放旧输出并清空详情,保留输入用于后续调用和前后比较。处理期间按钮不可重复触发,避免页面同时持有多次调用产生的输出,也避免用户把旧图结果误认为新图结果。
用户离开页面时,任务可能还在处理图片或者保存文件。页面先标记自己已经退出,使稍后返回的结果不再更新界面,然后等待当前操作结束,再释放输入和输出像素图。编码器仍在读取输出时,页面不会提前销毁这张图。
保存和显示共享同一份输出,因此不能在页面刚显示结果时就释放它。反过来,如果用户重新选择图片,新输入成功接管页面后,旧输出已经无法对应当前输入,可以清除并释放。按照图片何时被使用来确定释放时机,比单纯在某个接口返回后统一释放更容易保持状态正确。
任务层还设置了等待时间。超过设定时间后,页面会把本次结果标记为失效,并继续等待底层调用结束。这样可以避免过期结果覆盖后续界面状态,但不会把任务标记失效等同于底层算法已经立即停止。
五、为增强结果选择合适的用途
图片输出变大以后,应用仍然需要决定它怎样进入用户的操作。用于普通浏览时,可以把原图和增强图都保留下来,让用户选择更喜欢的显示版本。用户准备分享或保存时,界面也可以清楚标出当前使用的是哪一张图。
对于需要核对原始信息的图片,保留原图尤其重要。超分输出适合用来改善浏览体验,但应用不能仅根据增强后出现的细节推断原始场景中一定存在相同信息。让用户能够切回原图,比覆盖唯一输入更容易解释处理前后的差异。
排查问题时,可以按照图片经过的阶段逐步检查。输入迟迟没有显示,先查看资源读取和图片解码。输入已经就绪但调用失败,再检查尺寸和分析器返回的错误。页面出现输出而文件没有保存,则继续检查 PNG 编码和目标目录。
这个能力从 API 26 开始提供。接入页面需要根据应用支持的系统版本安排使用入口,并在能力不可用时保留原图的浏览方式。这样即使增强操作无法执行,用户仍然能完成查看原图的基本任务。
总结
我们从低分辨率图片生成像素图,调用超分分析器取得输出,再把原图和结果放在相同显示条件下比较。输出图片经过 PNG 编码后保存在应用目录,原图仍然可以继续查看。
ImageSRAnalyzer 负责图像处理,getImageInfo() 用于读取实际输入和输出尺寸,ImagePacker 负责保存结果。页面需要在处理、展示和保存期间分别管理输入图、输出图以及分析器的使用时间。
大家可以选择一张包含清晰轮廓和细小纹理的图片,比较处理前后的同一位置,再尝试用不同尺寸的输入观察输出。把结果放到自己的浏览场景中检查,才能判断它是否满足页面需要。
核心关键代码
下面的代码整理为独立的图像超分页面。上面的截图来自包含功能目录的页面,下面的版本启动后直接进入图像超分。请在 entry/src/main/resources/rawfile 中准备一张名为 low.jpg 的图片,并在页面配置中注册 pages/Index。
在 API 26 工程中,检查 entry/src/main/resources/base/profile/main_pages.json 的 src 是否包含 pages/Index,并让 entry/src/main/ets/entryability/EntryAbility.ets 中的 loadContent() 加载这个页面。缺少 services 目录时先创建,再添加以下文件。
Core Vision Kit 的视觉处理需要在支持该能力的真机上运行,模拟器暂不支持本篇调用。服务区域为中国境内,不含港澳台。
超分服务:VisionAdapter.ets
将以下代码保存为 entry/src/main/ets/services/VisionAdapter.ets。
import { image } from '@kit.ImageKit';
import { imageSuperResolution } from '@kit.CoreVisionKit';
export class VisionResult {
text: string = '';
picture: image.PixelMap | undefined = undefined;
}
export class VisionAdapter {
async process(kind: string, input: image.PixelMap): Promise<VisionResult> {
if (kind !== '超分') { throw new Error('未支持的处理功能:' + kind); }
const result = new VisionResult();
const analyzer = await imageSuperResolution.ImageSRAnalyzer.create();
try {
// 保留返回的像素图,供页面对照和保存。
const data = await analyzer.process({ inputData: { pixelMap: input } });
result.picture = data.pixelMap;
const info = await data.pixelMap.getImageInfo();
result.text = '输出尺寸:' + info.size.width + ' × ' + info.size.height;
return result;
} finally { await analyzer.destroy(); }
}
}
图片读取和保存:ImageStore.ets
将以下代码保存为 entry/src/main/ets/services/ImageStore.ets。
import { image } from '@kit.ImageKit';
import { common } from '@kit.AbilityKit';
import { fileIo } from '@kit.CoreFileKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
export class ImageStore {
private context: common.UIAbilityContext;
constructor(context: common.UIAbilityContext) { this.context = context; }
async raw(name: string): Promise<image.PixelMap> {
const bytes = await this.context.resourceManager.getRawFileContent(name);
const source = image.createImageSource(bytes.buffer as ArrayBuffer);
try {
// 图像解码后交给视觉接口,图像源不再由页面持有。
return await source.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888 });
} finally { source.release(); }
}
async choose(): Promise<image.PixelMap | undefined> {
const picker = new photoAccessHelper.PhotoViewPicker();
const result = await picker.select({ MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE, maxSelectNumber: 1 });
if (result.photoUris.length === 0) { return undefined; }
const file = fileIo.openSync(result.photoUris[0], fileIo.OpenMode.READ_ONLY);
let source: image.ImageSource | undefined = undefined;
try {
source = image.createImageSource(file.fd);
return await source.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888 });
} finally { source?.release(); fileIo.closeSync(file); }
}
async png(pm: image.PixelMap, name: string): Promise<string> {
const packer = image.createImagePacker();
try {
// 等待编码完成,再将 PNG 数据写入应用文件目录。
const bytes = await packer.packToData(pm, { format: 'image/png', quality: 100 });
const path = this.context.filesDir + '/' + name;
const file = fileIo.openSync(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
try { fileIo.writeSync(file.fd, bytes); } finally { fileIo.closeSync(file); }
return path;
} finally { packer.release(); }
}
}
任务管理:VisionTask.ets
将以下代码保存为 entry/src/main/ets/services/VisionTask.ets。
import { image } from '@kit.ImageKit';
import { VisionAdapter, VisionResult } from './VisionAdapter';
export class VisionTask {
busy: boolean = false;
state: string = '待处理';
private generation: number = 0;
private pending: Promise<VisionResult> | undefined = undefined;
cancel(): void {
if (!this.busy) { return; }
// 更换标识,使还在执行的任务返回后不再提交旧结果。
this.generation++;
this.state = '正在等待底层调用结束,结果将被丢弃';
}
async run(kind: string, input: image.PixelMap): Promise<VisionResult | undefined> {
if (this.busy) { throw new Error('上一次处理仍未结束'); }
this.busy = true;
this.state = '处理中';
const token = ++this.generation;
// 超时只使结果失效,仍要等待底层调用结束。
const timer = setTimeout(() => { if (this.busy && token === this.generation) { this.cancel(); } }, 30000);
try {
this.pending = new VisionAdapter().process(kind, input);
const result = await this.pending;
if (token !== this.generation) {
result.picture?.release();
this.state = '已取消,可重新处理';
return undefined;
}
this.state = '处理完成';
return result;
} catch (err) {
this.state = token === this.generation ? '处理失败,可检查输入后重试' : '已取消,可重新处理';
if (token === this.generation) { throw err as Error; }
return undefined;
} finally {
clearTimeout(timer);
this.pending = undefined;
this.busy = false;
}
}
}
页面入口:Index.ets
将以下代码保存为 entry/src/main/ets/pages/Index.ets。
import { image } from '@kit.ImageKit';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ImageStore } from '../services/ImageStore';
import { VisionTask } from '../services/VisionTask';
@Entry
@Component
struct Index {
@State input: image.PixelMap | undefined = undefined;
@State output: image.PixelMap | undefined = undefined;
@State status: string = '准备输入图片';
@State detail: string = '';
@State errorMessage: string = '';
@State busy: boolean = false;
@State imageWidth: number = 0;
@State imageHeight: number = 0;
private alive: boolean = true;
private activeJob: boolean = false;
private pagePending: Promise<void> | undefined = undefined;
private task: VisionTask = new VisionTask();
private context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
private io: ImageStore = new ImageStore(this.context);
aboutToAppear(): void { this.alive = true; this.launch(() => this.prepare()); }
aboutToDisappear(): void { this.alive = false; this.dispose(); }
private launch(action: () => Promise<void>): void {
if (this.activeJob || !this.alive) { return; }
this.activeJob = true;
// 输入、处理和保存都纳入页面任务,退出时等待它结束。
this.pagePending = action().catch((err: BusinessError) => { if (this.alive) { this.fail(err); } })
.finally(() => { this.activeJob = false; });
}
private async dispose(): Promise<void> {
this.task.cancel();
try { await this.pagePending; } catch (_) { /* 错误由任务入口处理。 */ }
this.input?.release(); this.input = undefined;
this.output?.release(); this.output = undefined;
}
private fail(err: BusinessError): void {
if (!this.alive) { return; }
this.status = '处理失败,可检查输入后重试';
this.errorMessage = '错误码:' + (err.code ?? -1) + '\n' + err.message;
}
private async prepare(): Promise<void> {
if (this.busy || this.task.busy) { return; }
this.busy = true; this.detail = ''; this.errorMessage = '';
try {
const pm = await this.io.raw('low.jpg');
if (!this.alive) { pm.release(); return; }
this.input?.release(); this.input = pm;
const info = await pm.getImageInfo(); this.imageWidth = info.size.width; this.imageHeight = info.size.height;
this.status = '输入已就绪:' + this.imageWidth + ' × ' + this.imageHeight;
} catch (err) { this.fail(err as BusinessError); }
finally { if (this.alive) { this.busy = false; } }
}
private async choose(): Promise<void> {
if (this.busy) { return; }
this.busy = true; this.errorMessage = '';
try {
const pm = await this.io.choose();
if (!this.alive) { pm?.release(); return; }
if (!pm) { this.status = '未选择图片,保留原输入'; return; }
// 新输入解码成功后,清除上一张图片的输出。
this.input?.release(); this.input = pm; this.detail = '';
this.output?.release(); this.output = undefined;
const info = await pm.getImageInfo(); this.imageWidth = info.size.width; this.imageHeight = info.size.height;
this.status = '已选择:' + this.imageWidth + ' × ' + this.imageHeight;
} catch (err) { this.fail(err as BusinessError); }
finally { if (this.alive) { this.busy = false; } }
}
private async run(): Promise<void> {
if (this.busy || !this.input) { return; }
this.busy = true; this.status = '处理中'; this.detail = ''; this.errorMessage = '';
this.output?.release(); this.output = undefined;
try {
const result = await this.task.run('超分', this.input);
if (!this.alive) { result?.picture?.release(); return; }
if (result) {
this.output = result.picture; this.detail = result.text;
if (this.output) {
const path = await this.io.png(this.output, 'super-resolution.png');
if (!this.alive) { return; }
this.detail += '\n保存到:' + path;
}
}
if (this.alive) { this.status = this.task.state; }
} catch (err) { if (this.alive) { this.fail(err as BusinessError); } }
finally { if (this.alive) { this.busy = false; } }
}
build() {
Column({ space: 12 }) {
Text('图像超分与效果对照').fontSize(23).fontWeight(FontWeight.Bold).fontColor('#142B52').width('100%')
Row({ space: 8 }) {
Image(this.input).width('48%').height(230).objectFit(ImageFit.Contain)
if (this.output) {
Image(this.output).width('48%').height(230).objectFit(ImageFit.Contain).backgroundColor('#DFE8F0')
}
}
Row({ space: 8 }) {
Button('选择图片').enabled(!this.busy).onClick(() => { this.launch(() => this.choose()); })
Button('开始处理').enabled(!this.busy).onClick(() => { this.launch(() => this.run()); })
}
Text(this.status).fontSize(15).fontColor('#145897').width('100%')
if (this.errorMessage) { Text(this.errorMessage).fontSize(14).width('100%') }
Scroll() { Text(this.detail).fontSize(14).width('100%') }.layoutWeight(1)
}.width('100%').height('100%').padding({ top: 16, left: 18, right: 18, bottom: 16 }).backgroundColor('#F1F4F9')
}
}
更多推荐


所有评论(0)