【共创稿事节】一张小图能否再用一次:HarmonyOS 7 图像超分解读与实测
挑一张建筑照片做配图,缩略图里的街灯看起来很漂亮。放大以后,灯罩边缘发虚,顶部纹路混成一片。把预览框继续拉大,图片占据了更多屏幕,缺少的细节却没有跟着回来。
很多人第一次接触图像超分,期待的就是这个时刻:一张尺寸不够的小图,能不能再多用一次?这样的期待很容易理解,但“更大”“看起来更清楚”和“与原始细节一致”,其实是三个不同问题。
HarmonyOS 7 把图像超分开放给应用,让选图、处理、对照和保存可以在同一段体验里完成。下面从灯罩、蝴蝶和瓷器的实际对照出发,看看它能改善什么,以及应用应该怎样把结果交给用户。
一、超分改变的是输出图像,放大预览只是显示方式
Core Vision Kit 在 API26 提供图像超分能力,用于对低分辨率输入进行超分辨率重建。官方能力介绍
普通预览放大首先改变显示尺寸;超分会处理输入并返回新的图像。对应用来说,这意味着结果可以有自己的尺寸、文件和保存动作。它不再只是当前页面里临时拉大的一幅画面。
但输入里已经丢失的内容,需要怎样重建,是另一个问题。轮廓变得鲜明,并不能单独证明复杂纹理恢复正确。判断时需要同时看整体观感、同一区域的细节,以及任务对准确性的要求。
在旅行图集、内容配图或旧素材预览中,改善轮廓可能已经有价值;如果用户要认读文字、核对商品纹样或判断精确形状,就应保留原始资料,不能把处理后的图像作为唯一依据。这也是本例坚持提供输入/输出切换的原因。
二、从一盏灯开始,观察改善发生在哪里
“拾图·放大”选择了一张建筑小图:输入尺寸为256×171,本轮实际输出为1024×684。处理完成后,页面显示实际尺寸,用户可以先看整图,再进入局部对照。
下面三列依次是普通 Bicubic 放大、实际超分输出和参考图的同区域对照。裁剪位置事先固定,避免只挑处理效果最好的部分。
灯罩外沿和黑色支架更容易辨认,部分边界也更硬;但顶部细小的装饰纹路,没有像参考图那样完整出现。对正在挑配图的人,前一种变化可能有帮助;对需要辨认灯罩结构的人,后一种差异不能忽略。
再看蝴蝶,黑色条带更分明,细小鳞片纹理与参考图仍有区别。瓷器的轮廓和反光区域更利落,密集金线与纹样却被简化了。
这三组对照把“更清楚”拆成了可观察的变化:边缘、条带、纹理不一定同时改善。产品页应该允许用户看清这些差别,再决定这张结果是否适合当前用途。
三、把比较留在用户决定保存之前
本例没有在处理完成后立刻覆盖输入,而是把比较安排成一个明确动作:点击“查看局部”,在“输入小图”和“超分输出”之间切换。两种图使用同一裁剪区域,预览框保持一致。
如果两次预览的区域或缩放不同,人很容易把显示差异当成处理效果。固定观察区域,能让用户把注意力放在同一条边缘、同一处纹样上。
确认这轮结果可用后,再点击“保存图片”,通过系统选择器保存为 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 耗时中位数 |
|---|---|---|
| 256 | 1024 | 230.5ms |
| 512 | 2048 | 604.5ms |
| 768 | 3072 | 1187ms |
这些时间只覆盖 process,不包括初始化、解码、编码、写文件和页面刷新;运行前也已成功调用过单图任务。768档的 PNG 编码与写入中位数另有890ms,所以用户实际等待不能只看模型处理时间。页面需要明确的处理中状态,并阻止重复提交。
质量方面,在本轮的计算方法下,72项输出与参考图的 RGB PSNR 都低于 Bicubic 基线。PSNR 衡量像素误差,不能单独代表人的视觉偏好;结合局部图可以看到,边缘更鲜明与像素更接近参考图,可能朝不同方向变化。
这组输入研究的是受控缩小和编码损失,没有覆盖所有失焦、抖动或旧照片损伤。它更适合作为产品判断的起点:先用自己业务里的照片,验证哪些内容改善了,哪些细节需要保留原图核对。
六、一张小图能否再用一次,答案应该留给具体任务
回到挑配图的情境,超分最有价值的地方,是为尺寸不足的素材增加一次处理和比较的机会。轮廓改善能否帮助这次排版,要放在实际展示尺寸下判断;纹理是否可信,也需要按任务要求核对。
因此,一个好的接入不只有“增强”按钮。它还应让用户知道处理了哪张图、比较的是哪里、保存的是什么。能力提供新的输出,应用则负责把选择的依据呈现清楚。
配套工程与资料
GitCode:HarmonyOS 7 图像超分 Demo。README 提供导入、签名、运行、固定实验和数据复核步骤。
更多推荐








所有评论(0)