# 一张 320 像素的老照片，端侧 201ms 变 1280 # 智绘鸿蒙·如 7 而至#

## 前言

前阵子我妈翻出一张 1985 年的全家福，翻拍成电子档之后只有 320×397，人脸糊成一团，让我"想办法弄清楚点"。

第一反应是丢给某个在线修复工具。但一想到要把家里老人的照片传上去，还要按次付费，就有点不愿意。

后来发现 HarmonyOS 7（API 26）把这件事做成了系统能力：**图像超分**（`imageSuperResolution`），归属 `Core Vision Kit`。三行代码调用，不联网、不要权限、不用带模型，全程在设备上跑完。

实测下来，一张 320×397 的老照片，在 Mate 70 Pro 上 **201ms** 变成 1280×1588。这篇文章就把这个能力从头到尾过一遍，包括官方文档里没写、但我踩出来的那条硬边界。

## 图像超分能做什么

**对低分辨率图像做超分辨率重建，把丢失的高频细节还原出来。**

官方给的目标场景是"提升图片质量、修复老照片"。我自己把它归成三类：

- **家庭影像**：翻拍纸质老照片、低清截图、被聊天软件压缩过的图
- **工程与台账**：现场翻拍的图纸、仪表读数、货物标签
- **相册治理**：批量把历史低清图升档，供后续检索和展示

这里要特别说明一点：它**不是 AI 重绘**。它不会给你重新画一张脸，而是把原来糊在一起的像素细节还原出来。对老照片这种"原件不能改"的场景，这个边界非常重要。

![](assets/03-result.png)

左边是原图，右边是超分结果，中间那条蓝线可以左右拖动对比。

## 能力速览

| 项目 | 说明 |
| --- | --- |
| 所属 Kit | `@kit.CoreVisionKit` |
| 起始版本 | 26.0.0（HarmonyOS 7） |
| 系统能力 | `SystemCapability.AI.Vision.VisionBase` |
| 支持设备形态 | phone / tablet / 2in1 |
| 需要权限 | 无 |
| 需要联网 | 无，纯端侧推理 |
| 放大倍率 | 固定 **4 倍**，不可配置 |

调用面只有三个方法：

```typescript
import { imageSuperResolution, visionBase } from '@kit.CoreVisionKit';

const analyzer = await imageSuperResolution.ImageSRAnalyzer.create();
const imageData: visionBase.ImageData = { pixelMap: src };
const request: visionBase.Request = { inputData: imageData };
const response = await analyzer.process(request);
const sharp: image.PixelMap = response.pixelMap;
await analyzer.destroy();
```

> - **图像超分（Super-Resolution）**：从低分辨率图像恢复高分辨率细节的技术，这里指系统内置的端侧推理能力。
> - **PixelMap**：鸿蒙里的位图对象，承载像素数据，可以直接交给 `Image` 组件显示。
> - **端侧推理**：模型运行在设备本地，数据不出机器、不依赖网络。

`visionBase.Request` 目前只有一个有效字段 `inputData`，需要包一层 `ImageData`。`scene` 和 `requestId` 是预留字段，写了暂时不生效。

## 环境准备

- DevEco Studio **26.0.0.821**，SDK 为 `HarmonyOS 26.0.0`（API 26）
- 工程的 `compatibleSdkVersion` 与 `targetSdkVersion` 都要写成 `26.0.0`，低于这个版本连编译都过不去
- 不需要在 `module.json5` 里声明任何 `requestPermissions`

创建工程和跑真机，我直接用 `DevEcoCli` 一条龙：

```bash
devecocli create --project-path ./hmos7-superres --app-name SuperRes --api-level 26
devecocli run --product default --module entry@default --device <serial>
```

`run` 会把依赖解析、编译、签名、安装、拉起一次做完，日常开发够用。

## 把引擎封一层

官方示例把 `create()` 直接写在 `aboutToAppear` 里。单页面确实够用，但真实应用通常是"选图页 → 修复页 → 结果页"这种结构，页面级持有会导致每次进出都重新加载模型。

所以我封了一个 `SrEngine`，把生命周期、耗时统计和错误码归一化都收进去：

```typescript
export class SrEngine {
  private analyzer: imageSuperResolution.ImageSRAnalyzer | null = null;

  async ready(): Promise<boolean> {
    if (this.analyzer) {
      return true;
    }
    try {
      this.analyzer = await imageSuperResolution.ImageSRAnalyzer.create();
      return true;
    } catch (err) {
      const e = err as BusinessError;
      throw new SrError(e.code, `超分服务初始化失败：${e.message}`);
    }
  }

  async superResolve(src: image.PixelMap): Promise<SrResult> {
    if (!(await this.ready()) || !this.analyzer) {
      throw new SrError(-1, '超分引擎未就绪');
    }
    const imageData: visionBase.ImageData = { pixelMap: src };
    const request: visionBase.Request = { inputData: imageData };
    const start: number = Date.now();
    const response = await this.analyzer.process(request);
    const cost: number = Date.now() - start;
    // 这里把 src/dst 尺寸和 cost 一起带出去，给 UI 显示
  }

  async release(): Promise<void> {
    if (this.analyzer) {
      await this.analyzer.destroy();
      this.analyzer = null;
    }
  }
}
```

让 `process()` 自己保证引擎就绪，调用方就不用操心顺序问题。

需要注意的是，`create()` 可能抛 `1018700001 Service exception`，一般在低版本系统或者 AI 引擎服务未就绪时出现。**这个异常必须 catch 并给降级 UI**，不要让它冒到页面外面。

## 最关键的一步：输入必须限长边

这是整个功能最容易翻车的地方。把相册原图直接 `createPixelMap()` 解出来再喂进去，会得到：

```
process failed 401  The parameter check failed.
```

![](assets/07-error-401.png)

为什么会 401，我在真机上跑了一轮尺寸阶梯才搞清楚。下面是 Mate 70 Pro 的完整实测数据：

| 输入 | 输出 | 耗时 |
| --- | --- | --- |
| 240×298 | 960×1192 | 148 ms |
| 400×496 | 1600×1984 | 300 ms |
| 600×744 | 2400×2976 | 531 ms |
| 800×992 | 3200×3968 | 939 ms |
| 1000×1240 | 4000×4960 | 1507 ms |
| 1200×1488 | 4800×5952 | 2042 ms |
| 1400×1736 | 5600×6944 | 2907 ms |
| 1600×1984 | 6400×7936 | 3701 ms |
| **1652×2048** | **6608×8192** | 4217 ms |
| 1656×2053 | — | **401 失败** |
| 1700×2108 | — | **401 失败** |

![](assets/sr-cost.png)

三条结论可以直接抄进设计文档：

1. **输出单边硬上限是 8192px。** 1652×2048 刚好输出 8192 能通过，1656×2053 输出 8212 就报错。换算回来就是：**输入长边 ≤ 2048**。
2. **耗时和"输入像素数"线性相关，不是输出像素数。** 取 45 万像素以上的稳定区间，大约是 `输入像素数 × 1.18 μs + 50 ms`。有了这个模型，用户选了一张 1200 万像素的图，先压到长边 2048（约 300 万像素），就能预估 3.6 秒，进度条文案和超时策略提前就能定。
3. **首次调用会多花约 30ms。** 同一张图连跑三次是 229 → 199 → 195 ms，多出来的是模型加载。所以 `create()` 不要放在点击回调里。

正确的写法是**在解码阶段就把尺寸压住**：先读元数据，再决定解码尺寸。这样既不会 401，也省掉一张全尺寸位图的内存。

```typescript
export class SrEngine {
  /** 超分固定 4 倍，输出单边 > 8192 直接抛 401，故输入长边须 <= 2048。 */
  static readonly MAX_INPUT_EDGE: number = 2048;

  static async decodeCapped(source: image.ImageSource): Promise<image.PixelMap> {
    const info: image.ImageInfo = await source.getImageInfo();
    const long: number = Math.max(info.size.width, info.size.height);
    if (long <= SrEngine.MAX_INPUT_EDGE) {
      return source.createPixelMap();
    }
    const k: number = SrEngine.MAX_INPUT_EDGE / long;
    return source.createPixelMap({
      desiredSize: {
        width: Math.round(info.size.width * k),
        height: Math.round(info.size.height * k)
      }
    } as image.DecodingOptions);
  }
}
```

相册导入、内置样片、以后要加的相机拍照，三条路径共用这一个入口，上限只改一处。

反过来讲，如果先 `createPixelMap()` 再 `scale()` 缩小——一张 4000×3000 的 ARGB 位图是 48MB，内存先炸为敬，然后才轮到超分报错。

## 版本兼容：这个坑会直接白屏

如果你的工程要同时跑鸿蒙 6 和鸿蒙 7，这一点需要特别注意：

```typescript
import { imageSuperResolution } from '@kit.CoreVisionKit';  // 在低版本设备上直接崩
```

**运行时判断救不了你。** ES 的具名导入在模块加载阶段就解析，`imageSuperResolution` 是 26.0.0 才有的导出，在 API 24 设备上会在**你的任何一行代码执行之前**抛：

```
Error type:SyntaxError ... does not provide an export name 'imageSuperResolution'
```

随后进程以 254 退出。`canIUse()` 或者 `deviceInfo.sdkApiVersion >= 26` 这类判断写在函数体里，根本来不及执行。

唯一的解法是**把新 API 隔离到旧设备不会导航进去的页面（或者独立 HSP）**。我在页面里只放一个状态徽章：

```typescript
@State supported: boolean = deviceInfo.sdkApiVersion >= 26;
```

真正的超分调用只存在于这个页面，旧设备装的是不含此页面的产物。这条规律对鸿蒙 7 的所有新能力都成立——文搜图、FAST Kit、3DGS 都一样。

## 保存结果：不申请权限也能写相册

老照片涉及隐私，"全程不出设备"是这类产品的卖点，所以不该去申请受限权限 `ohos.permission.WRITE_IMAGEVIDEO`（申请它上架还要额外过审）。

用 `SaveButton` 安全控件 + `MediaAssetChangeRequest` 就够了：

```typescript
saveCallback: SaveButtonCallback = async (event, result, error) => {
  if (result !== SaveButtonOnClickResult.SUCCESS || !this.after) {
    return;
  }
  const packer = image.createImagePacker();
  const bytes = await packer.packToData(this.after,
    { format: 'image/jpeg', quality: 96 } as image.PackingOption);
  const request = photoAccessHelper.MediaAssetChangeRequest
    .createAssetRequest(this.ctx!, photoAccessHelper.PhotoType.IMAGE, 'jpg');
  request.addResource(photoAccessHelper.ResourceType.IMAGE_RESOURCE, bytes);
  await photoAccessHelper.getPhotoAccessHelper(this.ctx!).applyChanges(request);
};
```

用户点一下，系统弹这个确认框，授权只对本次点击有效：

![](assets/05-save-dialog.png)

这里有个小坑：**`SaveButton` 不支持 `margin()`**。安全控件只开放极小的属性白名单，写 `.margin()` 会直接编译报错：

```
Property 'margin' does not exist on type 'SaveButtonAttribute'
```

要控制位置就在外面套一层 `Column` 加 margin。

## 顺手记一个 ArkUI 老坑

指标卡（原分辨率 / 输出分辨率 / 耗时）我一开始写成：

```typescript
@Builder MetricCell(label: string, value: string) { ... }
this.MetricCell('原分辨率', this.srcSize ? `${this.srcSize.w}×${this.srcSize.h}` : '—')
```

真机上状态文案正常刷新，但三个指标**永远停在"—"**。原因是 `@Builder` 的简单类型参数是**值传递**，不建立状态依赖。改成传对象（引用传递）就好了：

```typescript
interface Metric { label: string; value: string }

@Builder MetricCell(m: Metric) { ... }
this.MetricCell({ label: '原分辨率', value: this.srcSize ? `${this.srcSize.w}×${this.srcSize.h}` : '—' })
```

这个坑和鸿蒙 7 没关系，但编译器和 linter 都不报，踩一次能白耗半小时。

## 真机运行效果

测试机：HUAWEI Mate 70 Pro（PLR-AL00），HarmonyOS 7.0.0.107，API 26。

首页，右上角是能力就绪徽章：

![](assets/01-home.png)

选一张内置样片，原图 320×397：

![](assets/02-loaded.png)

点「开始修复」，201ms 后出结果：

![](assets/03-result.png)

换一张 300×241 的毕业照，输出 1200×964，耗时 95ms：

![](assets/06-graduation.png)

至此整条链路就跑通了。

## 几条实测之外但值得知道的

**不同长宽比的图行为一致。** 第二张样片是 300×241 的宽图，输出 1200×964，倍率同样是 4。没有针对长宽比做特殊处理，所以横竖混合的相册可以放心批量跑。

**重复调用的收益会饱和。** 同一张图连跑四次是 229 → 199 → 195 → 195 ms 这个量级，第三次基本就平了。也就是说"预热一次"值得做，"预热十次"没意义。

**批量要自己排队。** `process()` 返回 Promise，但底层是重资源。我在页面上用一个 `busy` 标志把按钮锁住、串行执行，比并发提交更稳。真要批量，就把"解码 → 超分 → 落盘"做成队列，进度按张数报（"第 3/10 张"），别报百分比。

**和文搜图串联很自然。** 超分解决"看不清"，`textSearchImage` 解决"找不到"。老照片批量升档之后再建语义索引，检索质量会明显好于对糊图建索引——两个能力都在 `Core Vision Kit` 里，可以放进同一条离线流水线。

**一个诚实的提醒：超分只补细节，不去污。** 第一张对比图里那些白色斑点，是我故意在源图上加的灰尘噪点——超分之后它们被完整保留，甚至更锐利。

- 模糊、低分辨率、JPEG 压缩块 → 明显改善
- 划痕、霉点、水渍、褪色 → 基本不动

真要端到端的"老照片修复"，得把去污、上色和超分串起来，而且超分放最前面（它对输入质量最不挑）。

## 总结

什么场景值得用它：家庭影像、工程台账、相册批量治理这类"图已经糊了、又不能重拍"的场合。它只补细节不重绘内容，所以老照片这种"原件不能改"的场景特别合适。

反过来，划痕、霉点、水渍这类污损它不管，要端到端修复得自己串去污和上色；另外它是 26.0.0 才有的导出，低版本设备上不要碰。

> 💡 **一句话**：能力只有三行代码，真正要记住的是解码时限住长边 2048，以及别在 API 24 设备上导入它。

## 参考文献

1. HarmonyOS 7(API 26) 官方新能力解读 & 开发者实战开发案例合集：https://developer.huawei.com/consumer/cn/forum/topic/0208224160644765091
2. Core Vision Kit 开发指南 · 图像超分：https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-image-super-resolution
3. Core Vision Kit API 参考 · imageSuperResolution（图像超分）：https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-image-super-resolution-api
4. Core Vision Kit 错误码：https://developer.huawei.com/consumer/cn/doc/harmonyos-references/errorcode-core-vision
