【共创稿事节】老照片修复 · 从能力到产品:端侧 4 倍重建 + 色调修复一体化(端侧AI新能力-图像超分)

基于 HarmonyOS 7.0(API 26) 与 Core Vision Kit(imageSuperResolution),
把端侧超分能力包装成一个完整的产品闭环:选片 → 端侧重建 → 色调修复 → 保存 / 分享。
本文所有截图均来自真机实拍运行画面,工程可直接复现。

上一篇《图像超分 · 画质增强》验证了 ImageSRAnalyzer 的 create → process → destroy 三段式能力闭环。这一篇往前走一步:能力不等于产品。用户要的不是"一张变清晰的照片",而是"把爷爷的老照片修好、存下来、发给家人"的完整体验。于是有了这个老照片修复工作台——4 倍端侧重建打底,再补上超分解决不了的色调问题(褪色、泛黄、对比度塌陷),最后用安全控件一键保存分享。
在这里插入图片描述

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

修复完成 50% 对比

一、与「图像超分第一篇」的关系:能力验证 → 场景落地

第一篇:能力验证本篇:场景落地
目标跑通 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 提示修复完成:

50% 对比

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

拖拽分割

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

色调修复

六、API 26 踩坑清单(本工程实测沉淀)

工程在 DevEco Studio 26(API 26)下从零同步、构建、跑通,踩坑都有普遍性:

  1. SDK 版本号写法变更:API ≥ 26 时 compatibleSdkVersion / targetSdkVersion 必须写 "26.0.0"(平台版本号,不带括号;API < 26 才是 "5.0.0(12)" 形式),且必须为字符串;
  2. 模块级 targets 只允许 6 个字段:name / config / source / resource / runtimeOS / output,旧写法 applyTo 改 applyToProducts,compileSdkVersion/targetSdkVersion 收敛到应用级;
  3. 工程必须带 Hvigor 三件套:根/模块 hvigorfile.ts + hvigor/hvigor-config.json5(dependencies 留空,交给内置插件);
  4. picker.PhotoViewPicker 自 API 12 起 deprecated → 改用 photoAccessHelper.PhotoViewPicker;
  5. ImagePacker.packing() 自 API 13 起 deprecated → 改用 packToData();
  6. @Builder 方法体内只能写 UI 组件语法,不允许 const 声明,需要的值从参数传入;
  7. @Builder 按值传参不建立响应关联(本文第三节坑二),像素级数据建议状态化后由 builder 直读;
  8. 安全控件(SaveButton 等)是全局声明,不能从 @kit.ArkUI import;
  9. fileIo.mkdirSync(path, true)(没有 MkdirOptions 导出);photoAccessHelper 下是 CreateOptions 不是 PhotoCreateOptions;
  10. ArkTS 禁止对象字面量作类型声明(arkts-no-obj-literals-as-types),回调参数类型一律抽成具名接口。

七、总结:超分的正确打开方式

第一篇证明了"端侧 4 倍超分"可行,这一篇回答"然后呢":

  • 能力要包进流程:create → process → destroy 三段式只是地基,用户要的是选片、对比、调色、保存的一气呵成;
  • 指标要客观:梯度能量对比插值基线,让"更清晰"变成可复现的数字(+99%);
  • 体验要零门槛:PhotoViewPicker 免权限选图、SaveButton 免权限保存,全程不弹权限框;
  • 预览与落盘要一致:GPU 属性预览 + 像素烘焙落盘,同一套参数推导保证所见即所得。

下一步的自然延伸:把真实相册批量化(任务队列 + 进度聚合),或与文搜图案例联动——搜到老照片后一键送入修复工作台,形成"找到 → 修复 → 分享"的完整记忆管理闭环。


工程路径:LI_harmonyOS/photo-restoration;本文基于 DevEco Studio 26.0 构建,在 HarmonyOS 7.0.0 设备上实测运行并实拍截图。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐