【共创稿事节】老照片修复 · 从能力到产品:端侧 4 倍重建 + 色调修复一体化(端侧AI新能力-图像超分)
基于 HarmonyOS 7.0(API 26) 与 Core Vision Kit(imageSuperResolution),
把端侧超分能力包装成一个完整的产品闭环:选片 → 端侧重建 → 色调修复 → 保存 / 分享。
本文所有截图均来自真机实拍运行画面,工程可直接复现。
上一篇《图像超分 · 画质增强》验证了 ImageSRAnalyzer 的 create → process → destroy 三段式能力闭环。这一篇往前走一步:能力不等于产品。用户要的不是"一张变清晰的照片",而是"把爷爷的老照片修好、存下来、发给家人"的完整体验。于是有了这个老照片修复工作台——4 倍端侧重建打底,再补上超分解决不了的色调问题(褪色、泛黄、对比度塌陷),最后用安全控件一键保存分享。

先看最终效果:同一张 300×200 的做旧老照片,右侧是端侧重建 + 色调修复后的 1200×800 成果,细节增益(对比普通插值放大)+99%:

一、与「图像超分第一篇」的关系:能力验证 → 场景落地
| 目标 | 跑通 create → process → destroy | 跑通一条完整用户流程 |
| 输入 | 内置示例图 | 内置示例图 + 相册选图 |
| 处理 | 4× 超分 | 4× 超分 + 色调修复(去黄/提对比/恢复饱和) |
| 输出 | 屏幕对比 | 屏幕对比 + 保存到相册 + 系统分享 |
| 指标 | 分辨率、耗时 | 分辨率、耗时、细节增益(对比普通插值) |
产品化多出来的每一行,背后都是一个工程模块。整个工程的结构:
entry/src/main/ets/
├── entryability/EntryAbility.ets
├── common/
│ ├── RestoreModels.ets # 数据模型 + IRestoreService 统一接口
│ ├── RestoreKit.ets # 选图 / 解码 / 色调烘焙编码 工具集
│ ├── DemoRestoreService.ets # 演示模式(内置示例 + CPU 4× 基线)
│ ├── RealRestoreService.ets # 真机模式(Core Vision Kit 端侧 NPU)
│ ├── CpuUpscaler.ets # 纯 CPU 双线性 4× + USM 锐化(基线实现)
│ ├── ToneRestorer.ets # 色调修复:参数推导 + 像素级烘焙
│ ├── SharpnessMeter.ets # 梯度能量 → 细节增益(客观指标)
│ └── PhotoShareKit.ets # 保存相册 + 系统分享
└── pages/Index.ets # 修复工作台 UI
页面只依赖 IRestoreService 接口(create → restore* → destroy),演示模式与真机模式一键切换、语义完全一致;真机能力不可用时自动回退演示模式,页面永远可用。
二、四种角色各司其职:这套架构怎么分工

2.1 双模式服务:演示与真机共用一套交互

// 演示模式(默认):内置示例走预置高清结果;相册选图走 CPU 4× 放大 + USM 锐化
private service: IRestoreService = DemoRestoreService.getInstance();
// 真机模式:全部走 ImageSRAnalyzer 端侧 NPU 超分(创建失败自动回退演示模式)
this.service = RealRestoreService.getInstance();
演示模式不是"假数据"敷衍:相册选图路径会真的做 CPU 双线性 4× 放大 + USM 锐化,指标是真的、处理耗时是真的,只是不用 NPU。这保证了在没有超分能力的设备上,整条交互链路(选片 → 处理动画 → 对比 → 调色 → 保存)依然完整可走。
两个实现的公开契约只有 7 个方法(RestoreModels.ets),页面只依赖这层接口:
/** 修复服务统一接口:演示实现与真机实现语义一致,页面只依赖本接口 */
export interface IRestoreService {
setContext(context: common.Context): void;
create(): Promise<boolean>;
isCreated(): boolean;
destroy(): Promise<void>;
/** 内置示例图修复 */
restoreDemo(id: string): Promise<RestoreResult | null>;
/** 相册选图修复(PhotoViewPicker,免权限) */
restoreFromGallery(): Promise<RestoreResult | null>;
/** 导出最终成果字节流 */
exportData(result: RestoreResult, tone: number): Promise<ExportData | null>;
}
RestoreResult 同时兼容两种展示源——lowRawfile/hdRawfile(rawfile 相对路径,示例图)与 lowPixelMap/hdPixelMap(真机/相册产物),UI 层按非空判断取用,一套交互吃两种数据。
演示模式的内置示例实现(DemoRestoreService.restoreDemo):预置高清图当"超分结果",模拟 900–1400ms 推理耗时,清晰度走客观口径实算:
public async restoreDemo(id: string): Promise<RestoreResult | null> {
if (!this.created || !this.context) {
return null;
}
const demo: DemoPhoto | undefined = DEMO_PHOTOS.find((item: DemoPhoto) => item.id === id);
if (!demo) {
return null;
}
const started: number = Date.now();
const low: image.PixelMap | null = await decodeRawfile(this.context, demo.low);
const hd: image.PixelMap | null = await decodeRawfile(this.context, demo.hd);
if (!low || !hd) {
return null;
}
// 模拟端侧推理耗时,让处理动画与真机体验一致
await this.delay(900 + (this.pseudo(id) % 500));
const gain: number = await detailGain(hd, low); // 客观指标:AI 结果 vs 插值基线
const lowSize = sizeOf(low);
const hdSize = sizeOf(hd);
const result: RestoreResult = emptyResult();
result.title = demo.title;
result.lowPixelMap = low;
result.hdPixelMap = hd;
result.widthBefore = lowSize.width;
result.heightBefore = lowSize.height;
result.widthAfter = hdSize.width;
result.heightAfter = hdSize.height;
result.costMs = Date.now() – started;
result.clarityGain = gain;
return result;
}
2.2 色调修复:预览与落盘的「所见即所得」
超分解决分辨率,解决不了老照片的褪色泛黄。色调修复做了双通道设计:
- 预览态:直接用 Image 组件的 saturate / contrast / brightness 属性,GPU 合成零拷贝,拖动滑杆实时变化;
- 落盘态:保存前用 applyTone() 对 PixelMap 做同等数学的像素级烘焙。
两段共用同一套参数推导 buildToneParams(),强度 0~100 线性映射:
export function buildToneParams(intensity: number): ToneParams {
const k: number = clamp01(intensity / 100);
return {
saturate: 1 + 0.55 * k, // 恢复褪色
contrast: 1 + 0.28 * k, // 拉开明暗层次
brightness: 1 + 0.06 * k, // 提亮暗部
deyellow: k // 去黄强度
};
}
像素级烘焙按同构数学实现(对比度/亮度 → Rec.601 亮度轴心外扩饱和度 → 压红绿抬蓝去黄):
// 2) 饱和度提升:以 Rec.601 亮度为轴心外扩
const gray: number = 0.299 * nr + 0.587 * ng + 0.114 * nb;
nr = gray + (nr – gray) * p.saturate;
ng = gray + (ng – gray) * p.saturate;
nb = gray + (nb – gray) * p.saturate;
// 3) 去黄:老照片偏黄,压红绿、抬蓝
nr = nr * (1 – 0.06 * p.deyellow);
ng = ng * (1 – 0.03 * p.deyellow);
nb = nb + (255 – nb) * 0.06 * p.deyellow;
这样预览时看到什么,存到相册的就是什么——调色不耍赖。
2.3 细节增益:用梯度能量说话
"修复后更清晰"是主观感受,产品需要一个客观口径。SharpnessMeter 的做法:邻域亮度差绝对值和(梯度能量)量化清晰度,再拿「普通双线性插值放大」当基线,算 AI 结果多出来的细节百分比:
const baseline: image.PixelMap | null = await bilinearUp4(low, false);
const eEnh: number = await gradientEnergy(enhanced);
const eBase: number = await gradientEnergy(baseline);
let gain: number = Math.round((eEnh / eBase – 1) * 100); // 0~99,负值归零
这不是自夸指标——它度量的正是"超分相对插值多恢复了多少高频细节",也是本篇开头 +99% 的来源。
2.4 保存与分享:安全控件免权限
写相册通常要申请 WRITE_IMAGEVIDEO 敏感权限,体验很差。本工程改用 SaveButton 安全控件:用户点击的那一刻获得临时授权,photoAccessHelper.createAsset 直接落盘,全程零权限声明。保存成功拿到 URI 后,systemShare 拉起系统分享面板。
UI 侧的用法非常简单——SaveButton 是全局声明(不能 import),回调里检查授权结果即可:
// 成果出口:保存 + 分享
Row({ space: 12 }) {
// 安全控件样式受限:只设置尺寸与间距,不套用通用布局属性
SaveButton()
.width(150)
.height(44)
.onClick((_event: ClickEvent, result: SaveButtonOnClickResult) => {
if (result === SaveButtonOnClickResult.SUCCESS) {
this.saveResult(); // 授权窗口内立即落盘
} else {
this.toast('保存授权未完成');
}
})
Button('分享')
.layoutWeight(1)
.height(44)
.borderRadius(22)
.backgroundColor('#FFFFFF')
.fontColor('#7A5320')
.onClick(() => {
this.shareResult();
})
}
落盘与分享封装在 PhotoShareKit:在授权窗口内 createAsset 建相册项并写入字节流;分享只传相册 URI,不复制文件:
/** 在 SaveButton 的授权窗口内把字节流写入相册,返回相册 URI */
public static async saveToGallery(context: common.Context, data: ExportData, title: string): Promise<string | null> {
const helper: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
const options: photoAccessHelper.CreateOptions = {
title: title,
subtype: photoAccessHelper.PhotoSubtype.DEFAULT
};
const uri: string = await helper.createAsset(photoAccessHelper.PhotoType.IMAGE, data.ext, options);
const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.WRITE_ONLY);
try {
fileIo.writeSync(file.fd, data.bytes);
} finally {
fileIo.closeSync(file.fd);
}
return uri;
}
/** 拉起系统分享面板 */
public static share(context: common.Context, uri: string, title: string): boolean {
const shareData: systemShare.SharedData = new systemShare.SharedData({
utd: utd.UniformDataType.IMAGE,
uri: uri,
title: title
});
const controller: systemShare.ShareController = new systemShare.ShareController(shareData);
controller.show(context as common.UIAbilityContext, {
previewMode: systemShare.SharePreviewMode.DETAIL
});
return true;
}
三、前后对比交互:一个拖拽分割线的正确写法
「左修复前 / 右修复后」的分割对比是相册类 App 的标配交互,实现要点是三层叠加 + 两个状态:
Stack({ alignContent: Alignment.Start }) {
this.hdLayer() // 底层:修复后高清图(铺满)
if (this.hasResult) {
Stack({ alignContent: Alignment.Start }) {
this.lowLayer() // 上层:修复前低清图
}
.width(`${this.splitPercent}%`) // 宽度 = 分割百分比
.height(IMG_HEIGHT)
.clip(true) // 超出裁剪
}
// 分割线 + 拖拽手柄(绝对定位跟随 splitPercent)
}
.gesture(
PanGesture({ fingers: 1, direction: PanDirection.Horizontal, distance: 5 })
.onActionStart((_event: GestureEvent) => {
this.panStartPercent = this.splitPercent;
})
.onActionUpdate((event: GestureEvent) => {
if (this.areaWidth <= 0) {
return;
}
const pct: number = this.panStartPercent + event.offsetX / this.areaWidth * 100;
this.splitPercent = Math.round(Math.min(100, Math.max(0, pct)));
})
)
这里有两个实测踩出来的坑,值得单独说明:
坑一:结果对象必须是 @State。 最初 current(承载低清/高清 PixelMap 的结果对象)是普通 private 属性,修复完成后界面纹丝不动——非状态属性的赋值不触发任何刷新。改成 @State 后,PixelMap 就绪的瞬间两层图同步刷新。
坑二:@Builder 按值传参会"冻结"参数。 最初把 PixelMap 以参数形式传进 @Builder(this.hdLayer(this.hdPm())),滑动分割线时底层图像毫无反应——ArkTS 中 @Builder 按值传参不建立响应关联,参数在首帧后被"冻结"。改为 builder 内部直读 this.current(状态属性),刷新立即恢复。这也是为什么上面的 builder 都是无参的:
@Builder
hdLayer() {
if (this.current && this.current.hdPixelMap) {
Image(this.current.hdPixelMap)
.width(this.areaWidth)
.height(IMG_HEIGHT)
.objectFit(ImageFit.Cover)
.saturate(buildToneParams(this.tone).saturate) // 色调修复走 GPU 属性
.contrast(buildToneParams(this.tone).contrast)
.brightness(buildToneParams(this.tone).brightness)
} else {
Image($rawfile(this.lowRawfile))
}
}
修复后的实际体验见下文第五节——在图上左右滑动,分割线实时跟手(分割交互的完整实现在第三章)。
四、关键代码解析:从选图到落盘的完整链路
4.1 RestoreKit:选图、解码、导出的三件套
相册选图走系统 Picker(免权限),但有两个规范动作:改用 photoAccessHelper.PhotoViewPicker(@kit.CoreFileKit 里的旧 picker 自 API 12 已 deprecated),以及先把图片拷贝到应用沙箱再解码(后续处理都在沙箱路径上进行,避免跨权限读 URI):
export async function pickImageToSandbox(context: common.Context): Promise<string | null> {
const photoPicker: photoAccessHelper.PhotoViewPicker = new photoAccessHelper.PhotoViewPicker();
const options: photoAccessHelper.PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
options.maxSelectNumber = 1;
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
const result: photoAccessHelper.PhotoSelectResult = await photoPicker.select(options);
if (!result.photoUris || result.photoUris.length === 0) {
return null;
}
const uri: string = result.photoUris[0];
const destDir: string = `${context.filesDir}/restore`;
fileIo.mkdirSync(destDir, true);
const destPath: string = `${destDir}/src_${Date.now()}.jpg`;
const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
try {
fileIo.copyFileSync(file.fd, destPath);
} finally {
fileIo.closeSync(file.fd);
}
return destPath;
}
解码统一 RGBA_8888 并执行输入上限保护(官方约束 2048×2048,超限先等比缩放):
export async function decodeScaled(path: string, maxSide: number): Promise<image.PixelMap | null> {
const file: fileIo.File = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
const source: image.ImageSource = image.createImageSource(file.fd);
const info: image.ImageInfo = await source.getImageInfo(0);
const opts: image.DecodingOptions = { desiredPixelFormat: image.PixelMapFormat.RGBA_8888 };
if (info.size.width > maxSide || info.size.height > maxSide) {
const ratio: number = Math.min(maxSide / info.size.width, maxSide / info.size.height);
opts.desiredSize = {
width: Math.floor(info.size.width * ratio),
height: Math.floor(info.size.height * ratio)
};
}
const pixelMap: image.PixelMap = await source.createPixelMap(opts);
await source.release();
return pixelMap;
}
4.2 导出落盘:色调烘焙 + JPEG 编码
保存前的最后一环 packWithTone:把预览态的色调修复烘焙进像素(读全图缓冲 → applyTone → 用缓冲重建 PixelMap),再用 ImagePacker 编码 JPEG。整个过程不依赖 PixelMap 的 editable 属性,规避"写回失败"的经典坑:
export async function packWithTone(pixelMap: image.PixelMap, tone: number): Promise<ArrayBuffer | null> {
const size = sizeOf(pixelMap);
const buffer: ArrayBuffer = new ArrayBuffer(size.width * size.height * 4);
await pixelMap.readPixelsToBuffer(buffer);
const data: Uint8Array = new Uint8Array(buffer);
applyTone(data, tone); // 像素级烘焙(见 2.2)
const graded: image.PixelMap = await image.createPixelMap(buffer, {
size: { width: size.width, height: size.height },
pixelFormat: image.PixelMapFormat.RGBA_8888
});
const packer: image.ImagePacker = image.createImagePacker();
// packing 自 API 13 起 deprecated,官方 useinstead 指向 packToData
const bytes: ArrayBuffer = await packer.packToData(graded, { format: 'image/jpeg', quality: 98 });
await packer.release();
await graded.release();
return bytes;
}
4.3 CPU 放大基线:双线性 4× + USM 锐化
CpuUpscaler 是演示模式的相册链路,也是细节增益的基线实现。核心是逐像素双线性插值(坐标回推半像素保证几何对齐),完成后叠加一轮轻量 USM 锐化:
for (let y: number = 0; y < dh; y++) {
const fy: number = (y + 0.5) / SCALE – 0.5; // 半像素回推,几何中心对齐
const y0: number = Math.max(0, Math.min(sh – 1, Math.floor(fy)));
const y1: number = Math.min(sh – 1, y0 + 1);
const wy: number = fy – y0;
for (let x: number = 0; x < dw; x++) {
const fx: number = (x + 0.5) / SCALE – 0.5;
// … 同理求 x0/x1/wx
for (let c: number = 0; c < 4; c++) {
const v0: number = s[i00 + c] * (1 – wx) + s[i01 + c] * wx;
const v1: number = s[i10 + c] * (1 – wx) + s[i11 + c] * wx;
d[di + c] = v0 * (1 – wy) + v1 * wy; // 先横向、再纵向两次插值
}
}
}
if (sharpen) {
unsharp(d, dw, dh, 0.35); // USM:中心 + amount × (4×中心 − 四邻域)
}
512×512 的输入(MAX_SIDE_DEMO 上限)放大到 2048×2048 纯 ArkTS 循环约 1–2 秒,配合处理动画体验刚好;它同时定义了"普通放大"的天花板——AI 超分相对它的能量差,就是细节增益。
4.4 真机链路:一次推理,缓存复用
RealRestoreService 的完整用户链路:相册选图 → 解码缩放(≤2048)→ ImageSRAnalyzer.process() → 返回 4× PixelMap。关键约束都在前文出现过,这里看主流程:
public async pickAndProcess(): Promise<image.PixelMap | null> {
const picked: string | null = await this.pickOneImage(); // Picker 选图 → 沙箱
if (!picked) {
return null;
}
const src: image.PixelMap | null = await this.decodeScaled(picked, MAX_SIDE);
if (!src) {
return null;
}
return await this.processPixelMap(src); // NPU 4× 重建
}
processPixelMap 就是第一篇讲过的分析器三段式中的 process 段:visionBase.ImageData → visionBase.Request → ISPResponse.pixelMap,全程 try/catch 兜底,失败返回 null 由页面回退演示模式。
4.5 生命周期与资源纪律
PixelMap 是重对象,本工程定了一条铁律:谁产出谁释放,换结果先释放。页面上只有一处持有:
private releaseCurrent(): void {
if (this.current) {
const low: image.PixelMap | null = this.current.lowPixelMap;
const hd: image.PixelMap | null = this.current.hdPixelMap;
if (low) {
low.release();
}
if (hd) {
hd.release();
}
this.current = null;
}
}
applyResult() 在写入新结果前必调它;aboutToDisappear() 收口销毁分析器。超分分析器是重推理对象,官方明确建议不常驻内存。
五、运行效果(真机实拍)
初始 · 选片:进入即见待修复的做旧老照片(300×200,灰暗低饱和),流程步骤条「选片 → 重建 → 调色 → 保存」标示当前进度,右上角可在演示模式 / 真机模式间切换:

重建中:点击「开始修复」,展示区覆盖遮罩与进度动画,提示"端侧重建中,正在还原老照片细节…"。真机上这一步是 NPU 在跑 ImageSRAnalyzer.process():

重建完成 · 50% 对比:遮罩褪去,分割线居中——左侧灰暗模糊的"修复前"与右侧鲜艳清晰的"修复后"同屏对立,反差一目了然。指标卡同步刷新:分辨率 300×200 → 1200×800(4 倍)、重建耗时 2073 ms、细节增益 +99%,Toast 提示修复完成:

图上拖拽 · 22% 分割:在对比图上直接左右滑动,分割线实时跟手——左侧只留 22% 的"修复前",右侧 78% 全是修复后的清晰世界。湖面的碎光、陶罐的釉面纹理、水珠的边缘,在两个世界里的差距就是这条分割线的意义:

色调修复 100%:把"色调修复"滑杆从 45% 推到 100%,右侧饱和度、对比度、去黄效果同步增强(GPU 属性实时生效),暖调的老照片质感被完全唤醒——而这一切在保存时会被像素级烘焙进成品:

六、API 26 踩坑清单(本工程实测沉淀)
工程在 DevEco Studio 26(API 26)下从零同步、构建、跑通,踩坑都有普遍性:
七、总结:超分的正确打开方式
第一篇证明了"端侧 4 倍超分"可行,这一篇回答"然后呢":
- 能力要包进流程:create → process → destroy 三段式只是地基,用户要的是选片、对比、调色、保存的一气呵成;
- 指标要客观:梯度能量对比插值基线,让"更清晰"变成可复现的数字(+99%);
- 体验要零门槛:PhotoViewPicker 免权限选图、SaveButton 免权限保存,全程不弹权限框;
- 预览与落盘要一致:GPU 属性预览 + 像素烘焙落盘,同一套参数推导保证所见即所得。
下一步的自然延伸:把真实相册批量化(任务队列 + 进度聚合),或与文搜图案例联动——搜到老照片后一键送入修复工作台,形成"找到 → 修复 → 分享"的完整记忆管理闭环。
工程路径:LI_harmonyOS/photo-restoration;本文基于 DevEco Studio 26.0 构建,在 HarmonyOS 7.0.0 设备上实测运行并实拍截图。




