【共创稿事节】鸿蒙图像超分 · 相册照片超分实验台:从系统相册选真实照片,端侧 AI 4× 超分、滑动对比、落盘沙箱
【共创稿事节】鸿蒙图像超分 · 相册照片超分实验台:从系统相册选真实照片,端侧 AI 4× 超分、滑动对比、落盘沙箱
前四篇里,超分的输入一直是 rawfile 里的内置素材——单图重建、实时放大镜、批量工作台都很完整,但始终差「最后一公里」:用户自己的照片。这一篇把端侧超分真正接到系统相册上:用系统安全选择器 PhotoViewPicker 选一张真实照片,按长边降采样成「低清输入」,端侧 NPU 一次 4× 超分,左右滑动对比「普通放大 vs AI 超分」,最后把结果落盘成 PNG。全程零权限、零联网、数据不出设备。
工程地址:
LI_harmonyOS/sr-photo-lab
运行环境:HarmonyOS 7.0(API 26)+ ArkTS 严格模式,已在真机安装运行验证。
包名:com.example.srphotolab,module.json5没有申请任何权限。

一、效果展示
完整流程只有三步:选照片 → 预览确认 → 超分对比并保存。以下截图均为演示模式(内置城市夜景素材)真机运行,真机模式走同一套 UI、只把超分内核换成 NPU。
选好照片后先停在预览页:来源描述(演示素材 / 相册原图尺寸 → 实际输入尺寸)、低清预览、输出尺寸预告 640×480,点「开始超分」才真正跑模型,避免选错照片白跑一次:
超分完成后的分割对比界面如文首主图:分割线居中时左侧「普通放大 4x」、右侧「AI 超分 4x」同尺寸重合,下方给出清晰度增益 +40% 与尺寸变化 160×120 → 640×480。
二、这个 App 解决什么问题
批量工作台(第四篇)证明了端侧超分是个可批量、可量化、可落盘的工程能力,但输入还是预置素材。面对真实用户照片,会冒出三个新问题:
- 怎么合法拿到照片:不能自己翻媒体库,更不该申请一堆存储权限;
- 真实照片尺寸不可控:相册里随便一张就是 4000×3000,远超端侧超分 NPU 的输入上限 2048,直接喂模型必挂;
- 结果要让人信服、拿得走:得跟「普通放大」并排对比,还得存成文件。
这个实验台给出的答案是:系统安全选择器返回临时授权 URI → 解码时按长边降采样到 480 → NPU 超分 → 裁剪式滑动对比 → PNG 落应用沙箱。选择器和沙箱都不需要权限,照片和结果自始至终不离开设备。
三、交互设计:一个三态状态机

整个页面用一个 stage 字段驱动,三态互斥,build() 里按状态渲染不同区块,没有任何命令式的页面跳转:
@State stage: 'pick' | 'preview' | 'result' = 'pick';
@State busy: boolean = false;
if (this.stage === 'pick') {
this.pickStage()
} else if (this.stage === 'preview') {
this.previewStage()
} else {
this.resultStage()
}
- pick(选图页):说明文案 + 主按钮。按钮文案随模式切换——演示模式显示「使用演示素材」,真机模式显示「从相册选择照片」;
- preview(预览页):展示来源描述和低清图,预告输出尺寸(
输入宽高 × 4),用户确认后才点「开始超分」。这一步的价值是把耗时的 NPU 调用与选图解耦,也避免选错照片白跑一次; - result(结果页):滑动对比图 + 增益/尺寸两张数据卡 + 保存按钮 + 「再来一张」复位回 pick。
顶部「真机模式 / 演示模式」按钮只是翻转 useReal 布尔,演示模式免 NPU,任何设备都能完整跑通流程。
四、工程结构
sr-photo-lab/
├── entry/src/main/
│ ├── ets/
│ │ ├── pages/Index.ets # 三态页面、相册选择器、滑动对比手势
│ │ └── common/
│ │ ├── PhotoLabModels.ets # LoadedImage / ProcessResult / PackResult
│ │ ├── PhotoLabService.ets # 单例服务:选图解码/降采样/NPU超分/落盘
│ │ └── PixelOps.ets # 像素读取 / 双线性基线 / 梯度能量
│ └── resources/rawfile/demo/ # 4 组低清/高清演示素材
服务沿用系列前作的单例 + 双模式设计,UI 只调 loadDemo / loadFromAlbum / process / savePng,完全不感知底下是 NPU 还是预置素材。
五、核心实现
5.1 零权限选照片:PhotoViewPicker 安全选择器
读相册最「重」的做法是申请 READ_IMAGEVIDEO,但这里根本不需要——PhotoViewPicker 是系统提供的安全选择器,用户在系统 UI 里点选后,应用只拿到这张图的临时只读 URI,无需任何权限声明:
const picker = new photoAccessHelper.PhotoViewPicker();
const res = await picker.select({
MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE,
maxSelectNumber: 1
});
if (!res.photoUris || res.photoUris.length === 0) {
return;
}
const loaded = await this.service.loadFromAlbum(res.photoUris[0], MAX_EDGE);
用户取消选择时 photoUris 为空,直接静默返回;抛异常则 toast 提示「未选择照片」。
5.2 URI 解码 + 长边降采样:把真实照片塞进 NPU
这是本篇最关键的一步。拿到的 URI 不能当文件路径直接读,要用 fileIo.openSync 换成 fd 再建 ImageSource。更重要的是:解码时直接带 desiredSize 按长边缩到 480——一来模拟「低清照片」的输入场景,二来保证落在 NPU 合法输入范围 [64, 2048] 内,三来解码内存也小得多:
const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
const src: image.ImageSource = image.createImageSource(file.fd);
const info: image.ImageInfo = await src.getImageInfo();
const w: number = info.size.width;
const h: number = info.size.height;
const ratio: number = maxEdge / Math.max(w, h);
const dw: number = Math.max(1, Math.floor(w * ratio));
const dh: number = Math.max(1, Math.floor(h * ratio));
const opts: image.DecodingOptions = {
desiredSize: { width: dw, height: dh },
desiredPixelFormat: image.PixelMapFormat.RGBA_8888
};
const pm: image.PixelMap = await src.createPixelMap(opts);
await src.release();
fileIo.closeSync(file);
return { lowPm: pm, lowW: dw, lowH: dh, srcW: w, srcH: h };
返回的 LoadedImage 同时保留真实原图尺寸(srcW/srcH)和实际喂给模型的尺寸(lowW/lowH),所以预览页能显示 相册照片 · 原图 4000×3000 → 输入 480×360 这样的完整链路。
5.3 处理主流程:NPU 超分 + 普通放大基线 + 增益量化
process 一条链路产出 UI 需要的全部东西:真机模式走 NPU,失败或演示模式回退到预置高清;同时把低清做双线性 4× 放大作为「普通放大」基线,最后用与第四篇同口径的梯度能量算出清晰度增益:
let hdPm: image.PixelMap | null = null;
if (useReal && this.analyzer) {
hdPm = await this.superResolve(lowPm);
}
if (!hdPm) {
hdPm = await this.decodeRawfile(DEMO_HD, DEMO_HD_W, DEMO_HD_H);
}
if (!hdPm) {
return null;
}
const info: image.ImageInfo = await hdPm.getImageInfo();
const hdW: number = info.size.width;
const hdH: number = info.size.height;
// 普通放大基线:低清双线性放大到高清尺寸
const baselineBuf: Uint8Array | null = bilinearUpscaleTo(lowBuf, lowW, lowH, hdW, hdH);
const eBilinear: number = gradientEnergy(baselineBuf, hdW, hdH);
const eAi: number = gradientEnergy(hdBuf, hdW, hdH);
let gain: number = Math.round((eAi / eBilinear - 1) * 100);
if (gain < 0) {
gain = 0;
}
if (gain > 99) {
gain = 99;
}
真机 NPU 调用本身与系列前几篇完全一致——降采样后长边只有 480,一次 process() 整图出结果,远小于 2048 上限:
const imageData: visionBase.ImageData = { pixelMap: low };
const request: visionBase.Request = { inputData: imageData };
const response: imageSuperResolution.ISPResponse = await this.analyzer.process(request);
return response.pixelMap;
5.4 滑动对比:Stack 叠两层 + clip 百分比裁剪
对比控件没有用任何像素混合,思路极其省事:Stack 里底层 AI 图满铺;上层放普通放大基线,只裁剪露出左侧 split 比例;再压一条白色分割线和两枚标签。拖动手势只改一个 0~1 的 @State split:
Stack() {
// 底层:AI 超分(满铺)
Image(this.result!.hdPm!)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
// 上层:普通放大基线,与 AI 图同尺寸重合,仅裁剪露出左侧 split 比例
Image(this.result!.baselinePm!)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.align(Alignment.TopStart)
.clip(new Rect({ width: `${this.split * 100}%`, height: '100%' }))
// 分割线
Column()
.width(3)
.height('100%')
.backgroundColor('#FFFFFF')
.position({ x: `${this.split * 100}%`, y: 0 })
两个容易错的细节:两张图都必须 objectFit(ImageFit.Cover) 且同尺寸,否则缩放口径不同,分割线两边会错位;上层要 .align(Alignment.TopStart),保证裁剪区域永远对应画面左侧。
手势用「起始位置 + 增量偏移 / 实际像素宽度」,宽度通过 onAreaChange 拿,最后 clamp 到 0~1,横屏、折叠屏都不会算错:
.gesture(
PanGesture({ fingers: 1, direction: PanDirection.Left | PanDirection.Right })
.onActionStart(() => {
this.startSplit = this.split;
})
.onActionUpdate((e: GestureEvent) => {
const w: number = this.fullW > 0 ? this.fullW : 1;
let s: number = this.startSplit + e.offsetX / w;
if (s < 0) {
s = 0;
}
if (s > 1) {
s = 1;
}
this.split = s;
})
)
因为裁剪比例就是 split 本身,把分割线一路拖到边缘时 clip 宽度变成 100%,整屏只剩普通放大基线(反向拖到另一端则全屏是 AI 超分),方便单独检查某一侧的模糊程度:
5.5 结果落盘:打包 PNG 写应用沙箱
落盘沿用第四篇的零权限方案:imagePacker.packToData 打包成 PNG 字节,写入 filesDir/sr_photo_out。真实照片 URI 的临时授权在处理完就结束,而写自己的沙箱目录天经地义,依旧不需要任何媒体权限:
const dir: string = this.context!.filesDir + '/' + OUT_DIR;
fileIo.mkdir(dir);
const path: string = `${dir}/${name}.png`;
const opts: image.PackingOption = { format: 'image/png', quality: 100 };
const packer = image.createImagePacker();
const buf: ArrayBuffer = await packer.packToData(pm, opts);
await packer.release();
const file = fileIo.openSync(path, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC);
fileIo.writeSync(file.fd, buf);
fileIo.fsyncSync(file.fd);
fileIo.closeSync(file);
const stat = fileIo.statSync(path);
return { path: path, sizeKB: stat.size / 1024 };
文件名用 photo-${Date.now()} 保证多次保存不互相覆盖。保存成功后界面直接回显沙箱完整路径与文件大小,路径文本挂了 copyOption(CopyOptions.InApp),长按即可复制;路径较长时会自动换行不截断:
5.6 一个很隐蔽的坑:按钮事件穿透
从 pick 进入 preview 是整页重渲染,但手指抬起的那一瞬间,「开始超分」按钮恰好出现在入口按钮的位置附近,ArkUI 会把同一次点击序列派发给新按钮,导致选完照片立刻自动触发超分。解法是进入预览后短暂锁住开始按钮 350ms:
this.blockStart = true;
setTimeout(() => {
this.blockStart = false;
}, 350);
.enabled(!this.busy && !this.blockStart)
六、真机运行指南
- 演示模式(默认):主按钮为「使用演示素材」,高清取预置
hd_city.png,不调 NPU,任何设备都能跑通选图→预览→对比→保存全流程; - 真机模式:点右上「真机模式」切换,主按钮变为「从相册选择照片」,在系统相册里选任意图。应用会先把它按长边 480 降采样,再真实走一遍端侧 NPU 4× 超分;
- 结果保存在沙箱
filesDir/sr_photo_out/photo-<时间戳>.png,取出方式:hdc shell "ls /data/app/el2/100/base/com.example.srphotolab/haps/entry/files/sr_photo_out" hdc file recv /data/app/el2/100/base/com.example.srphotolab/haps/entry/files/sr_photo_out ./out
七、踩坑清单速查表
| 现象 | 原因 | 修法 |
|---|---|---|
直接 openSync(uri) 解码失败 | picker 返回的是 file://docs/... 授权 URI,不是真实路径 | 用 fd:openSync(uri) → createImageSource(file.fd) |
| 大照片超分报错/超限 | NPU 输入范围 [64,2048],相册图常达数千像素 | 解码带 desiredSize,长边降到 480 |
| 想申请相册权限被劝退 | 安全选择器场景本就免权限 | PhotoViewPicker + 临时 URI,module.json5 零声明 |
| 选完图立刻自动超分 | 页面切换瞬间点击事件穿透到新按钮 | blockStart 锁定 350ms 再放开 |
| 分割线两边画面错位 | 两张图 objectFit/对齐方式不一致 | 都用 Cover + 同尺寸,上层 align(TopStart) |
| 拖到边缘比例跑出屏幕 | 用了手势绝对坐标 | startSplit + offsetX / onAreaChange 实际宽度,再 clamp 0~1 |
| 保存的图文件名冲突 | 固定文件名被覆盖 | photo-${Date.now()}.png |
| 演示模式增益忽高忽低 | 基线与高清口径不同 | 同尺寸双线性基线 vs 高清,统一梯度能量口径 |
八、总结
至此图像超分系列走完了第五步:真实照片进、真实文件出。
- 选图用系统安全选择器,零权限拿到临时 URI;
- 解码即降采样,把任意尺寸的真实照片安全塞进端侧 NPU;
- 滑动对比 + 梯度能量增益,让「AI 比普通放大清晰多少」一眼可见、有数可依;
- 结果落盘沙箱,原图与结果全程不出设备。
这一篇把前四篇的能力从「演示工程」推到了「能处理用户自己照片的工具」。下一步可以继续往前推:把沙箱里的超分结果通过 photoAccessHelper 的媒体资产变更接口回写系统相册,或者接上系统分享面板直接发给好友;再往后,还可以把相机预览帧接进来,做「拍照即超分」。内核都已经在这五篇里备齐了。
配套源码见 LI_harmonyOS/sr-photo-lab,构建 0 错误,已在真机安装运行验证。
更多推荐





所有评论(0)