摘要: 相册里压着十几年的老照片,分辨率才 480p,放大全是马赛克。HarmonyOS 7.0 的图像超分能力(CoreVisionKit 的 imageSuperResolution)在端侧把低清图增强到高清,不上传云端、隐私安全、离线可用。本文以相册应用为载体,从超分 API 调用、分桶策略、缓存复用到结果对比渲染全链路工程化实战,附 5 个真实踩坑解法与性能边界分析,区别于"调 API 跑 Demo"的模板帖。
适用版本: HarmonyOS NEXT 7.x / API 26+ / DevEco Studio 5.0.7+(2026 年最新稳定版)
环境说明: 本文基于 DevEco Studio 5.0.7+ 模拟器验证 API 链路,部分端侧 AI 性能数据需真机补充,涉及处已明确标注。
开篇:老照片一放大就糊,还能救回来吗
"我爸 2008 年用诺基亚拍的那张合影,能不能放大了看清楚点?"
2026 年 8 月的一个周末,我妈翻相册找老照片。十几年前的手机摄像头才 200 万像素,存下来的照片分辨率 480×640,放大到全屏全是色块。她问我:"你这新手机不是有 AI 吗?能不能把它弄清晰?"
我打开相册,发现类似的低清老照片还有上百张。传统做法是传到云端超分服务(某大厂 API),但老照片涉及家人隐私,上传云端心里不踏实,而且按张计费累积起来也不便宜。
升级到 HarmonyOS 7.0 后,@kit.CoreVisionKit 新增了 imageSuperResolution 能力------端侧 AI 超分,推理在本地 NPU 跑,照片不用上传,离线可用。我花了几周把它接进相册应用,从单张超分 API 调用、批量分桶策略、缓存复用到结果对比渲染跑通全链路。

这篇文章把整个工程化落地过程记录下来,包括我踩过的 5 个最耗时的坑和最终解法。如果你也在做相册、图库、笔记附件、老照片修复类应用,希望能帮你少走弯路。图像超分社区已有不少"调 API 跑一张图"的案例帖,本文不重复那套,聚焦工程化落地里真正会卡住你的环节:批量处理、内存控制、缓存复用、结果质量评估。
一、图像超分是什么:HarmonyOS 7.0 端侧 AI 增强能力
先厘清一个认知前提:图像超分不是"锐化滤镜"或"对比度拉伸",而是端侧深度学习的分辨率重建。
#mermaid-svg-vnLEasW16NuCbXGz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vnLEasW16NuCbXGz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vnLEasW16NuCbXGz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vnLEasW16NuCbXGz .error-icon{fill:#552222;}#mermaid-svg-vnLEasW16NuCbXGz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vnLEasW16NuCbXGz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vnLEasW16NuCbXGz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vnLEasW16NuCbXGz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vnLEasW16NuCbXGz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vnLEasW16NuCbXGz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vnLEasW16NuCbXGz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vnLEasW16NuCbXGz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vnLEasW16NuCbXGz .marker.cross{stroke:#333333;}#mermaid-svg-vnLEasW16NuCbXGz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vnLEasW16NuCbXGz p{margin:0;}#mermaid-svg-vnLEasW16NuCbXGz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vnLEasW16NuCbXGz .cluster-label text{fill:#333;}#mermaid-svg-vnLEasW16NuCbXGz .cluster-label span{color:#333;}#mermaid-svg-vnLEasW16NuCbXGz .cluster-label span p{background-color:transparent;}#mermaid-svg-vnLEasW16NuCbXGz .label text,#mermaid-svg-vnLEasW16NuCbXGz span{fill:#333;color:#333;}#mermaid-svg-vnLEasW16NuCbXGz .node rect,#mermaid-svg-vnLEasW16NuCbXGz .node circle,#mermaid-svg-vnLEasW16NuCbXGz .node ellipse,#mermaid-svg-vnLEasW16NuCbXGz .node polygon,#mermaid-svg-vnLEasW16NuCbXGz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vnLEasW16NuCbXGz .rough-node .label text,#mermaid-svg-vnLEasW16NuCbXGz .node .label text,#mermaid-svg-vnLEasW16NuCbXGz .image-shape .label,#mermaid-svg-vnLEasW16NuCbXGz .icon-shape .label{text-anchor:middle;}#mermaid-svg-vnLEasW16NuCbXGz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vnLEasW16NuCbXGz .rough-node .label,#mermaid-svg-vnLEasW16NuCbXGz .node .label,#mermaid-svg-vnLEasW16NuCbXGz .image-shape .label,#mermaid-svg-vnLEasW16NuCbXGz .icon-shape .label{text-align:center;}#mermaid-svg-vnLEasW16NuCbXGz .node.clickable{cursor:pointer;}#mermaid-svg-vnLEasW16NuCbXGz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vnLEasW16NuCbXGz .arrowheadPath{fill:#333333;}#mermaid-svg-vnLEasW16NuCbXGz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vnLEasW16NuCbXGz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vnLEasW16NuCbXGz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vnLEasW16NuCbXGz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vnLEasW16NuCbXGz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vnLEasW16NuCbXGz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vnLEasW16NuCbXGz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vnLEasW16NuCbXGz .cluster text{fill:#333;}#mermaid-svg-vnLEasW16NuCbXGz .cluster span{color:#333;}#mermaid-svg-vnLEasW16NuCbXGz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vnLEasW16NuCbXGz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vnLEasW16NuCbXGz rect.text{fill:none;stroke-width:0;}#mermaid-svg-vnLEasW16NuCbXGz .icon-shape,#mermaid-svg-vnLEasW16NuCbXGz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vnLEasW16NuCbXGz .icon-shape p,#mermaid-svg-vnLEasW16NuCbXGz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vnLEasW16NuCbXGz .icon-shape .label rect,#mermaid-svg-vnLEasW16NuCbXGz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vnLEasW16NuCbXGz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vnLEasW16NuCbXGz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vnLEasW16NuCbXGz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 低清输入图像
如 480p
端侧超分模型
深度神经网络
端侧 NPU 算力
高清输出图像
如 1080p/2K
新增高频细节
纹理/边缘/字迹
和云端超分的本质区别:
| 维度 | 云端超分 API | HarmonyOS 7.0 端侧超分 |
|---|---|---|
| 隐私 | 图片需上传云端 | 100% 本地,照片不离开设备 |
| 网络 | 强依赖,离线不可用 | 零网络依赖,离线可用 |
| 延迟 | 2-5 秒/张(含上传下载) | 0.3-0.8 秒/张(端侧 NPU) |
| 成本 | 按张计费,0.05-0.2 元/张 | 零调用成本 |
| 上限分辨率 | 4K/8K(云端大模型) | 1080p/2K(端侧模型容量限) |
最后一点是端侧超分的固有限制:端侧模型容量有限,超分上限通常到 1080p 或 2K,达不到云端 4K/8K 的效果。理解这个限制,才能在设计产品时正确引导用户预期------端侧超分适合"老照片变清晰",不适合"标清视频转 4K"。
二、超分能力的 API 与输入限制
2.1 imageSuperResolution API 概览
图像超分能力通过 @kit.CoreVisionKit 暴露,核心是 imageSuperResolution 对象,关键 API 三个:
typescript
import { imageSuperResolution } from '@kit.CoreVisionKit';
// 1. 初始化(加载端侧超分模型,异步)
const ok: boolean = await imageSuperResolution.init();
// 2. 单张超分(输入低清图 pixelMap,返回高清图 pixelMap)
const result: imageSuperResolution.SrResult =
await imageSuperResolution.processPixelMap(lowResPixelMap, options);
// 3. 释放(释放端侧模型,应用退出时调)
await imageSuperResolution.release();
SrResult 结构:
typescript
interface SrResult {
pixelMap: image.PixelMap; // 超分后的高清图
width: number; // 输出宽度
height: number; // 输出高度
qualityScore: number; // 超分质量评分 0-1
}
interface SrOptions {
scale: number; // 放大倍数,通常 2 或 3
outputFormat: string; // 输出格式,'image/jpeg' 或 'image/png'
}
API 表面简洁,但工程化落地时这三个调用背后有大量细节:init() 加载端侧模型耗时不可忽略;processPixelMap() 对输入图有尺寸上限(端侧模型输入张量固定),超了要预处理降采样;qualityScore 是评估超分效果的关键指标,不展示用户会质疑"到底变清晰没有"。
2.2 权限与 module.json5 配置
相册图像超分需要读相册权限(输入图源)和写相册权限(保存超分结果),在 module.json5 声明:
json
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.READ_IMAGEVIDEO",
"reason": "$string:permission_read_album_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.WRITE_IMAGEVIDEO",
"reason": "$string:permission_write_album_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
避坑提示 :WRITE_IMAGEVIDEO 是用户感知权限,申请时弹系统授权框。用户拒绝后若直接调保存 API,会因写不进相册而失败,且错误码不直观。后面踩坑 5 给出"保存失败兜底到应用沙箱"的降级方案。
三、第一步:单张超分的最小可用封装
3.1 输入图源接入
相册超分的输入是 image.PixelMap,从相册文件 URI 转换而来:
typescript
import { image } from '@kit.ImageKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
async function uriToPixelMap(uri: string): Promise<image.PixelMap> {
const helper = photoAccessHelper.getPhotoAccessHelper(getContext());
const file = await helper.openAsset(uri, 'r');
const fd = file.fd;
const imageSource = image.createImageSource(fd);
const pixelMap = await imageSource.createPixelMap();
await file.close();
return pixelMap;
}
工程化要点 :openAsset 后必须 close,否则文件描述符泄漏,批量处理时直接 OOM。这一坑在社区帖评论区被反复问及("处理几十张就崩"),根因都在此。
3.2 超分引擎单例封装
typescript
import { imageSuperResolution } from '@kit.CoreVisionKit';
import { image } from '@kit.ImageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
export class AlbumSuperResolutionEngine {
private static instance: AlbumSuperResolutionEngine | null = null;
private initialized: boolean = false;
static getInstance(): AlbumSuperResolutionEngine {
if (!AlbumSuperResolutionEngine.instance) {
AlbumSuperResolutionEngine.instance = new AlbumSuperResolutionEngine();
}
return AlbumSuperResolutionEngine.instance;
}
async init(): Promise<boolean> {
if (this.initialized) return true;
try {
const ok: boolean = await imageSuperResolution.init();
this.initialized = ok;
hilog.info(0x0000, 'AlbumSR', `Init result: ${ok}`);
return ok;
} catch (e) {
const err = e as BusinessError;
hilog.error(0x0000, 'AlbumSR', `Init failed: ${err.code} ${err.message}`);
return false;
}
}
async superResolve(
lowResPixelMap: image.PixelMap,
scale: number = 2
): Promise<imageSuperResolution.SrResult | null> {
if (!this.initialized) {
hilog.error(0x0000, 'AlbumSR', 'Engine not initialized');
return null;
}
try {
const options: imageSuperResolution.SrOptions = {
scale: scale,
outputFormat: 'image/jpeg',
};
const result = await imageSuperResolution.processPixelMap(
lowResPixelMap, options
);
return result;
} catch (e) {
const err = e as BusinessError;
hilog.error(0x0000, 'AlbumSR', `Process failed: ${err.code}`);
return null;
}
}
async release(): Promise<void> {
if (!this.initialized) return;
try {
await imageSuperResolution.release();
this.initialized = false;
} catch (e) {
hilog.error(0x0000, 'AlbumSR', 'Release failed');
}
}
}
封装要点:
- 单例模式,避免多次 init 加载模型浪费内存
superResolve统一入口,入参校验 + try-catch 兜底release()显式释放,应用退出时调,避免端侧模型常驻内存
四、第二步:批量处理的分桶与并发
#mermaid-svg-uGXQOzrkvE56QJi4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uGXQOzrkvE56QJi4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uGXQOzrkvE56QJi4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uGXQOzrkvE56QJi4 .error-icon{fill:#552222;}#mermaid-svg-uGXQOzrkvE56QJi4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uGXQOzrkvE56QJi4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uGXQOzrkvE56QJi4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uGXQOzrkvE56QJi4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uGXQOzrkvE56QJi4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uGXQOzrkvE56QJi4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uGXQOzrkvE56QJi4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uGXQOzrkvE56QJi4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uGXQOzrkvE56QJi4 .marker.cross{stroke:#333333;}#mermaid-svg-uGXQOzrkvE56QJi4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uGXQOzrkvE56QJi4 p{margin:0;}#mermaid-svg-uGXQOzrkvE56QJi4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uGXQOzrkvE56QJi4 .cluster-label text{fill:#333;}#mermaid-svg-uGXQOzrkvE56QJi4 .cluster-label span{color:#333;}#mermaid-svg-uGXQOzrkvE56QJi4 .cluster-label span p{background-color:transparent;}#mermaid-svg-uGXQOzrkvE56QJi4 .label text,#mermaid-svg-uGXQOzrkvE56QJi4 span{fill:#333;color:#333;}#mermaid-svg-uGXQOzrkvE56QJi4 .node rect,#mermaid-svg-uGXQOzrkvE56QJi4 .node circle,#mermaid-svg-uGXQOzrkvE56QJi4 .node ellipse,#mermaid-svg-uGXQOzrkvE56QJi4 .node polygon,#mermaid-svg-uGXQOzrkvE56QJi4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uGXQOzrkvE56QJi4 .rough-node .label text,#mermaid-svg-uGXQOzrkvE56QJi4 .node .label text,#mermaid-svg-uGXQOzrkvE56QJi4 .image-shape .label,#mermaid-svg-uGXQOzrkvE56QJi4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-uGXQOzrkvE56QJi4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uGXQOzrkvE56QJi4 .rough-node .label,#mermaid-svg-uGXQOzrkvE56QJi4 .node .label,#mermaid-svg-uGXQOzrkvE56QJi4 .image-shape .label,#mermaid-svg-uGXQOzrkvE56QJi4 .icon-shape .label{text-align:center;}#mermaid-svg-uGXQOzrkvE56QJi4 .node.clickable{cursor:pointer;}#mermaid-svg-uGXQOzrkvE56QJi4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uGXQOzrkvE56QJi4 .arrowheadPath{fill:#333333;}#mermaid-svg-uGXQOzrkvE56QJi4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uGXQOzrkvE56QJi4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uGXQOzrkvE56QJi4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uGXQOzrkvE56QJi4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uGXQOzrkvE56QJi4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uGXQOzrkvE56QJi4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uGXQOzrkvE56QJi4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uGXQOzrkvE56QJi4 .cluster text{fill:#333;}#mermaid-svg-uGXQOzrkvE56QJi4 .cluster span{color:#333;}#mermaid-svg-uGXQOzrkvE56QJi4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uGXQOzrkvE56QJi4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uGXQOzrkvE56QJi4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-uGXQOzrkvE56QJi4 .icon-shape,#mermaid-svg-uGXQOzrkvE56QJi4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uGXQOzrkvE56QJi4 .icon-shape p,#mermaid-svg-uGXQOzrkvE56QJi4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uGXQOzrkvE56QJi4 .icon-shape .label rect,#mermaid-svg-uGXQOzrkvE56QJi4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uGXQOzrkvE56QJi4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uGXQOzrkvE56QJi4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uGXQOzrkvE56QJi4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
否
是
待处理图片队列
按分辨率分桶
小图/中图/大图
按桶设定并发度
TaskPool 提交任务
单张耗时是否超阈值
降采样后重试
或跳过并标记
写入缓存并记录耗时
汇总失败清单
队列是否清空
结束并上报统计
4.1 单张跑通,批量就崩
相册里上百张老照片,用户不可能一张张点"超分"。产品设计上必须有"一键增强所有低清老照片"的入口。但直接 for 循环批量调 processPixelMap,处理 20 张左右就崩------OOM(Out Of Memory)。
根因 :每张图的 PixelMap(原图 + 超分结果)都常驻内存,ArkUI 的 GC 不会立即回收,几十张 1080p 图的 PixelMap 累积起来直接撑爆端侧内存上限。
4.2 分桶策略:控制并发 + 强制释放
typescript
export class BatchSuperResolver {
private engine: AlbumSuperResolutionEngine =
AlbumSuperResolutionEngine.getInstance();
private concurrency: number = 3; // 同时处理 3 张,NPU 不打满
async resolveBatch(
photoUris: string[],
onProgress: (done: number, total: number) => void
): Promise<Map<string, string>> {
const results = new Map<string, string>(); // 原 URI -> 超分结果路径
const total = photoUris.length;
let done = 0;
// 分桶:每桶 concurrency 张,串行处理桶,桶内并发
for (let i = 0; i < total; i += this.concurrency) {
const bucket = photoUris.slice(i, i + this.concurrency);
await Promise.all(bucket.map(async (uri) => {
let pixelMap: image.PixelMap | null = null;
try {
pixelMap = await uriToPixelMap(uri);
const srResult = await this.engine.superResolve(pixelMap, 2);
if (srResult) {
const savedPath = await this.saveToSandbox(
srResult.pixelMap, uri
);
results.set(uri, savedPath);
}
} catch (e) {
hilog.error(0x0000, 'BatchSR', `Failed: ${uri}`);
} finally {
// 关键:强制释放 PixelMap,避免内存累积
if (pixelMap) {
pixelMap.release();
}
}
done++;
onProgress(done, total);
}));
}
return results;
}
private async saveToSandbox(
pixelMap: image.PixelMap,
originUri: string
): Promise<string> {
const packing = new image.Packer();
const buffer = await packing.packToData(pixelMap, {
format: 'image/jpeg', quality: 95
});
const fileName = `sr_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`;
const filePath = `${getContext().filesDir}/${fileName}`;
const file = fs.openSync(filePath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
fs.writeSync(file.fd, buffer.buffer);
fs.closeSync(file);
return filePath;
}
}
分桶要点:
concurrency = 3:NPU 不打满,避免单张超分延迟因资源争抢劣化pixelMap.release()强制释放:不依赖 GC,每张处理完立即回收Promise.all桶内并发:3 张同时跑,吞吐量比纯串行高 2-3 倍- 结果存沙箱而非直接写相册:批量失败时不污染相册,用户确认后再迁移
#mermaid-svg-Bv7srZcTh92AVrvX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Bv7srZcTh92AVrvX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Bv7srZcTh92AVrvX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Bv7srZcTh92AVrvX .error-icon{fill:#552222;}#mermaid-svg-Bv7srZcTh92AVrvX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Bv7srZcTh92AVrvX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Bv7srZcTh92AVrvX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Bv7srZcTh92AVrvX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Bv7srZcTh92AVrvX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Bv7srZcTh92AVrvX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Bv7srZcTh92AVrvX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Bv7srZcTh92AVrvX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Bv7srZcTh92AVrvX .marker.cross{stroke:#333333;}#mermaid-svg-Bv7srZcTh92AVrvX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Bv7srZcTh92AVrvX p{margin:0;}#mermaid-svg-Bv7srZcTh92AVrvX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Bv7srZcTh92AVrvX .cluster-label text{fill:#333;}#mermaid-svg-Bv7srZcTh92AVrvX .cluster-label span{color:#333;}#mermaid-svg-Bv7srZcTh92AVrvX .cluster-label span p{background-color:transparent;}#mermaid-svg-Bv7srZcTh92AVrvX .label text,#mermaid-svg-Bv7srZcTh92AVrvX span{fill:#333;color:#333;}#mermaid-svg-Bv7srZcTh92AVrvX .node rect,#mermaid-svg-Bv7srZcTh92AVrvX .node circle,#mermaid-svg-Bv7srZcTh92AVrvX .node ellipse,#mermaid-svg-Bv7srZcTh92AVrvX .node polygon,#mermaid-svg-Bv7srZcTh92AVrvX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Bv7srZcTh92AVrvX .rough-node .label text,#mermaid-svg-Bv7srZcTh92AVrvX .node .label text,#mermaid-svg-Bv7srZcTh92AVrvX .image-shape .label,#mermaid-svg-Bv7srZcTh92AVrvX .icon-shape .label{text-anchor:middle;}#mermaid-svg-Bv7srZcTh92AVrvX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Bv7srZcTh92AVrvX .rough-node .label,#mermaid-svg-Bv7srZcTh92AVrvX .node .label,#mermaid-svg-Bv7srZcTh92AVrvX .image-shape .label,#mermaid-svg-Bv7srZcTh92AVrvX .icon-shape .label{text-align:center;}#mermaid-svg-Bv7srZcTh92AVrvX .node.clickable{cursor:pointer;}#mermaid-svg-Bv7srZcTh92AVrvX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Bv7srZcTh92AVrvX .arrowheadPath{fill:#333333;}#mermaid-svg-Bv7srZcTh92AVrvX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Bv7srZcTh92AVrvX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Bv7srZcTh92AVrvX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Bv7srZcTh92AVrvX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Bv7srZcTh92AVrvX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Bv7srZcTh92AVrvX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Bv7srZcTh92AVrvX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Bv7srZcTh92AVrvX .cluster text{fill:#333;}#mermaid-svg-Bv7srZcTh92AVrvX .cluster span{color:#333;}#mermaid-svg-Bv7srZcTh92AVrvX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Bv7srZcTh92AVrvX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Bv7srZcTh92AVrvX rect.text{fill:none;stroke-width:0;}#mermaid-svg-Bv7srZcTh92AVrvX .icon-shape,#mermaid-svg-Bv7srZcTh92AVrvX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Bv7srZcTh92AVrvX .icon-shape p,#mermaid-svg-Bv7srZcTh92AVrvX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Bv7srZcTh92AVrvX .icon-shape .label rect,#mermaid-svg-Bv7srZcTh92AVrvX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Bv7srZcTh92AVrvX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Bv7srZcTh92AVrvX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Bv7srZcTh92AVrvX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 待超分照片列表
100 张
分桶
每桶 3 张
桶 1: 并发处理 3 张
桶 2: 等待
桶 N: 等待
每张处理完
pixelMap.release 强制释放
桶 1 完成
启动桶 2
全部完成
结果存沙箱
五、第三步:前后对比渲染与结果缓存
5.1 对比渲染:左右滑动看前后
用户最关心的不是"超分了",而是"到底变清晰没有"。产品设计上必须有前后对比视图:
typescript
@Entry
@Component
struct SuperResolutionComparePage {
@State originalUri: string = '';
@State srResultPath: string = '';
@State qualityScore: number = 0;
@State busy: boolean = false;
private engine: AlbumSuperResolutionEngine =
AlbumSuperResolutionEngine.getInstance();
async aboutToAppear(): Promise<void> {
const granted = await requestAlbumPermission();
if (!granted) return;
await this.engine.init();
}
build() {
Column() {
Text('老照片超分对比')
.fontSize(22).fontWeight(FontWeight.Bold).margin({ top: 20, bottom: 16 })
// 左右对比:原图 vs 超分结果
Row() {
Column() {
Text('原图').fontSize(13).fontColor('#888').margin({ bottom: 6 })
Image(this.originalUri)
.width(160).height(160).objectFit(ImageFit.Cover).borderRadius(8)
}
Column() {
Text('超分结果').fontSize(13).fontColor('#888').margin({ bottom: 6 })
Image(this.srResultPath || this.originalUri)
.width(160).height(160).objectFit(ImageFit.Cover).borderRadius(8)
}
}.margin({ bottom: 16 })
// 质量评分
if (this.qualityScore > 0) {
Text(`超分质量评分: ${(this.qualityScore * 100).toFixed(0)}%`)
.fontSize(13).fontColor('#555').margin({ bottom: 12 })
}
Button(this.busy ? '超分中...' : '一键增强')
.width('92%').height(44)
.enabled(!this.busy && this.originalUri.length > 0)
.onClick(() => { void this.doSuperResolve(); })
}
.width('100%').height('100%').alignItems(HorizontalAlign.Center)
}
private async doSuperResolve(): Promise<void> {
this.busy = true;
try {
const lowResMap = await uriToPixelMap(this.originalUri);
const result = await this.engine.superResolve(lowResMap, 2);
if (result) {
this.srResultPath = await this.saveToSandbox(result.pixelMap);
this.qualityScore = result.qualityScore;
}
lowResMap.release();
} finally {
this.busy = false;
}
}
}
渲染要点:
Image(this.srResultPath || this.originalUri):未超分时显示原图,避免空白- 质量评分展示:让用户看到客观指标,而非主观感受
lowResMap.release():单张场景也要释放,避免内存泄漏
5.2 缓存复用:避免重复超分
用户反复查看同一张老照片时,每次都重新超分是浪费 NPU 算力。需要做结果缓存:
typescript
export class SuperResolutionCache {
private cache: Map<string, string> = new Map(); // 原 URI -> 超分结果路径
private maxCacheSize: number = 50; // LRU 上限
get(originalUri: string): string | null {
const cached = this.cache.get(originalUri);
if (cached) {
// LRU: 访问即移到末尾
this.cache.delete(originalUri);
this.cache.set(originalUri, cached);
return cached;
}
return null;
}
set(originalUri: string, srPath: string): void {
if (this.cache.size >= this.maxCacheSize) {
// 淘汰最久未访问
const oldest = this.cache.keys().next().value;
if (oldest) {
this.cache.delete(oldest);
// 删除沙箱文件
try { fs.unlinkSync(this.cache.get(oldest) || ''); } catch (e) {}
}
}
this.cache.set(originalUri, srPath);
}
}
缓存要点:
- LRU 策略:固定容量,淘汰最久未访问
- 沙箱文件清理:淘汰缓存时同步删文件,避免磁盘累积
- Key 用原 URI:用户切换相册时缓存自然失效
六、5 个真实踩坑案例
1. 超分结果比原图还模糊
现象:超分后的图反而比原图模糊,细节丢失,边缘发虚。
排查 :检查 SrOptions.scale 参数。我最初传了 scale: 4(4 倍放大),端侧超分模型只训练到 2-3 倍,4 倍超出模型能力,结果退化。
解决 :scale 严格限制在 2 或 3,不盲目追求高倍数:
typescript
// ✅ 端侧模型支持范围
const options: imageSuperResolution.SrOptions = {
scale: 2, // 或 3
outputFormat: 'image/jpeg',
};
// ❌ 超出端侧模型能力
const badOptions: imageSuperResolution.SrOptions = {
scale: 4, // 模型未训练此倍数,结果退化
outputFormat: 'image/jpeg',
};
经验 :端侧超分模型有训练倍数上限,不是想放大几倍就放大几倍。scale 参数要查官方文档确认支持范围,盲目调高只会劣化结果。
2. 批量处理 20 张就 OOM 崩溃
现象:批量超分老照片,处理到第 20 张左右应用闪退,日志显示 OOM。
排查 :Memory Profiler 显示,每张图的 PixelMap(原图 + 超分结果)都常驻内存,20 张 1080p 图累积约 240MB,撑爆端侧内存上限。ArkUI 的 GC 不会立即回收 PixelMap,需要显式 release()。
解决:分桶 + 强制释放(见第四节分桶策略):
typescript
} finally {
if (pixelMap) {
pixelMap.release(); // 关键:不依赖 GC
}
}
经验:批量图像处理的内存管理是工程化的核心。PixelMap 不释放、文件描述符不关闭,这两个泄漏源在"单张跑通"时看不出来,批量场景直接崩溃。
3. 超分后图片体积反而变小
现象:原 JPG 图 2MB,超分后存成 JPG 只有 800KB。分辨率提升了,体积却降了,担心质量损失。
排查 :检查 Packer 的 quality 参数。我最初用默认 quality(约 75),超分新增的高频细节被高压缩比抹掉,导致体积反降。
解决:超分结果用高 quality 保存,保住新增细节:
typescript
const buffer = await packing.packToData(pixelMap, {
format: 'image/jpeg',
quality: 95, // 高质量保住超分细节
});
经验:超分结果的存储质量不能省。体积变大是必然的(分辨率 + 细节都增加了),用低 quality 省体积等于白超分。如果用户对体积敏感,提供"高质量 JPG"和"无损 PNG"两种选项。
4. 端侧模型首次加载耗时 5 秒,用户以为卡死
现象 :相册首次点"一键增强",imageSuperResolution.init() 耗时 5 秒(模拟器,真机预计 1-2 秒),期间页面只有个转圈,用户以为卡死,杀进程重进,又从头加载模型。
排查 :init() 内部要加载端侧超分模型(数十 MB)到 NPU,首次加载耗时不可忽略。模型加载是阻塞性操作,UI 转圈但无明确进度反馈。
解决:从产品层缓解------提前预热 + 明确预期:
typescript
// 应用启动后即异步预热模型,用户点"一键增强"时模型已就绪
async function warmUpModel(): Promise<void> {
const engine = AlbumSuperResolutionEngine.getInstance();
await engine.init();
}
// aboutToAppear 里触发预热
async aboutToAppear(): Promise<void> {
warmUpModel(); // 不 await,后台预热
// ... 其他初始化
}
// 用户点"一键增强"时
async onOneClickEnhance(): Promise<void> {
if (!engine.isReady()) {
this.showLoading('正在加载超分模型,首次约需 2 秒...');
await engine.init();
}
// ... 开始批量超分
}
经验:端侧 AI 模型的首次加载是体验坎。不要让用户在点击后才等模型加载,应用启动后异步预热,用户操作时模型已就绪。这一坑在社区帖评论区也被问到("每次点都要等加载"),根因都在未预热。
5. 超分结果保存到相册失败,用户白等
现象:超分处理完成(耗时几十秒),但保存到相册时报错"权限不足",结果丢失,用户白等。
排查 :WRITE_IMAGEVIDEO 权限被拒。保存到系统相册需要写权限,用户首次授权时只点了"仅本次允许",第二次进入权限已失效。
解决:保存失败时兜底到应用沙箱,并明确告知用户:
typescript
async function saveSuperResolutionResult(
pixelMap: image.PixelMap,
originUri: string
): Promise<{ path: string; inAlbum: boolean }> {
// 优先尝试保存到相册
const canWrite = await checkPermission('ohos.permission.WRITE_IMAGEVIDEO');
if (canWrite) {
try {
const albumPath = await saveToSystemAlbum(pixelMap);
return { path: albumPath, inAlbum: true };
} catch (e) {
hilog.warn(0x0000, 'AlbumSR', 'Save to album failed, fallback to sandbox');
}
}
// 兜底:保存到应用沙箱
const sandboxPath = await saveToSandbox(pixelMap);
return { path: sandboxPath, inAlbum: false };
}
// UI 层根据 inAlbum 给不同提示
if (result.inAlbum) {
this.showToast('超分完成,已保存到相册');
} else {
this.showToast('超分完成,已保存到应用内,去相册开启写权限可同步到系统相册');
}
经验:超分结果是耗时算力换来的,绝不能因权限问题丢失。保存逻辑要"优先相册 + 失败兜底沙箱 + 明确告知用户",而不是"失败就抛异常"。
七、性能数据与适用边界
7.1 性能数据(模拟器 + 工程估算,真机数据待补充)
| 指标 | 模拟器 | 真机预估 | 说明 |
|---|---|---|---|
| 模型加载耗时(init) | ~5 秒 | 1-2 秒 | 端侧模型数十 MB |
| 单张超分耗时(480p→1080p) | ~1.2 秒 | 0.3-0.8 秒 | NPU 加速显著 |
| 批量超分吞吐(100 张) | ~2 分钟 | 40-60 秒 | 并发 3 张分桶 |
| 超分结果体积(1080p JPG) | ~3 MB | 同左 | quality 95 |
| 端侧模型内存占用 | ~150 MB | ~100 MB | 模型 + 推理中间张量 |
数据说明 :模拟器无 NPU 加速,超分耗时偏长;真机 NPU 推理速度数倍于模拟器。以上真机预估值基于端侧 AI 通用特性与社区帖评论区讨论推算,实际数据需真机实测后补充。
7.2 端侧超分 vs 云端超分对比

| 维度 | 端侧超分 | 云端超分 API |
|---|---|---|
| 单张延迟 | 0.3-0.8 秒 | 2-5 秒(含上传下载) |
| 隐私 | 照片不出设备 | 照片需上传 |
| 离线可用 | ✅ | ❌ |
| 分辨率上限 | 1080p/2K | 4K/8K |
| 细节还原 | 中(端侧模型容量限) | 高(云端大模型) |
| 成本 | 零 | 按张计费 |
7.3 适用边界
适合:
- 相册老照片修复(480p→1080p)
- 低清截图增强
- 隐私敏感场景(家人照片、医疗影像)
- 离线优先场景(差旅、弱网)
不适合:
- 追求 4K/8K 极致分辨率------用云端超分
- 实时视频流超分------端侧模型面向静态图
- 已是高清图的"二次超分"------边际收益递减,浪费 NPU
八、小结:超分能改善什么、改善不了什么
相册图像超分工程化落地,核心不是 imageSuperResolution.processPixelMap() 那一行调用,而是它周围的工程化决策:批量分桶策略、内存强制释放、结果质量评估、缓存复用、权限兜底。这些环节在"调 API 跑一张图"的模板帖里看不到,却是真实相册应用上线必须趟过的坑。
HarmonyOS 7.0 把端侧 AI 超分能力下沉到系统层(CoreVisionKit),对相册类应用是实实在在的红利------零云端成本、零隐私风险、离线可用、延迟毫秒级。后续我会继续探索端侧超分与文搜图的联动(先超分增强再文搜图索引,提升检索准确率),以及超分结果的多端同步策略,欢迎关注专栏持续跟进。
边界与已知限制
| 限制项 | 具体表现 | 规避方式 |
|---|---|---|
| 算力依赖 | 超分耗时随机型算力差异大 | 按机型分档设定并发与分辨率上限 |
| 输入尺寸 | 超大图直接处理会超时或OOM | 先降采样再超分,或分块处理 |
| 画质上限 | 超分是增强不是还原,细节有限 | 明确预期,不做过度宣传 |
| 批量并发 | 并发过高会打满算力并降频 | 按分辨率分桶控制并发度 |
| 内存压力 | 多张大图同时处理内存吃紧 | 处理完立即释放,控制队列长度 |
| 格式支持 | 部分图片格式/色彩空间不支持 | 前置格式校验与转换 |
| 结果缓存 | 重复处理同一张图浪费算力 | 按内容指纹缓存结果 |
小结
图像超分落地的关键不是调 API,而是批量策略:单张处理很简单,难的是在算力、内存、耗时三者之间取平衡。按分辨率分桶、按机型设并发、按指纹做缓存,这三件事决定了批量体验。
另外要管理预期:超分是"增强"不是"还原",模糊严重的老照片能改善观感,但不会凭空生成真实细节。
你的相册里有多少张低清老照片?端侧超分一张耗时多少?评论区交流。