【共创稿事节】鸿蒙图像超分 · 壁纸适配工作台:三种屏幕比例、居中裁切、端侧 4× 超分、所见即所得
前五篇把端侧超分这个「单点能力」打磨得很顺了——单图重建、实时放大镜、批量落盘、相册直选,每一步都验证了 HarmonyOS 端侧 NPU 推理的可用性。但都是技术演示视角:选一张图、跑一遍模型、看对比。真实用户的诉求其实更朴素——「我想把这张照片做成手机壁纸」。这一篇换个视角,从「做成壁纸」这个具体场景出发,做一个把壁纸比例、居中裁切、端侧超分、导出尺寸串成一条链的工作台。同时借这个机会,把前作里几个一直被忽略的坑——壁纸预设形同虚设、保存功能永远失败、结构体get访问器运行时崩——一次性修掉。
工程地址:
LI_harmonyOS/Image-Super-Resolution/harmony-sr-studio
运行环境:HarmonyOS 7.0(API 26)+ ArkTS 严格模式,已在 Mate 90 Pro 模拟器(7.0.0/26.0.0)安装运行验证。
包名:com.example.harmonysrstudio,为读取相册照片申请了ohos.permission.READ_IMAGEVIDEO(user_grant,含 reason/usedScene)。
一、效果展示
工作台的完整链路只有四步:选壁纸规格 → 导入照片(相册或内置演示)→ 端侧超分 → 导出目标尺寸壁纸。以下截图均为模拟器真机运行,演示模式与真机模式走同一套 UI。
选好规格(这里是「平板桌面」2560×1800)并载入内置狗狗演示素材后,预览区按32:18 的横屏比例铺开,构图与最终壁纸一致——这就是「所见即所得」的核心:预览什么样,导出的壁纸就什么样:
往下滚动可以看到输入/工作/输出三个尺寸卡、处理方式按钮区。演示模式下明确标注「演示素材」「DEMO MODE」,不会伪装成真实推理:
超分完成后进入对比态:左侧「原始」、右侧「演示素材」,中间一条可拖动的白色分割线。默认分割居中,左右同源同构图、只分辨率不同:
把分割线一路拖到最左,整个预览区就只剩右侧的高清结果,方便单独检查细节:
切换到「手机壁纸」1440×3200 后,预览区立刻按9:20 的窄竖条重新裁切——同一张狗狗图,构图从横屏变成竖屏,壁纸比例的差异一目了然:
二、这个 App 解决什么问题
前五篇的超分输入都是「正方形或 4:3 的素材」,对比的是「放大前 vs 放大后」。但壁纸是有明确目标尺寸和宽高比的:手机 9:20、平板 32:18、方形封面 1:1。这就引出三个新命题:
- 比例不对就得裁:一张 4:3 照片要当手机壁纸,必须裁成 9:20,否则两边黑边或拉伸变形;
- 超分和裁切要算好顺序:先超分再裁,还是先裁到工作尺寸再超分?这直接决定 NPU 输入大小和最终清晰度;
- 结果要导出成精确的像素尺寸:不是「放大 4 倍就行」,而是「必须正好是 1440×3200」。
这个工作台给出的答案是:按预设宽高比居中裁切源图 → 缩放到目标尺寸的 1/4 作为超分工作输入 → 端侧 4× 超分正好得到目标像素 → 导出。核心思路是「工作尺寸 = 目标尺寸 / 4」,让 Core Vision Kit 的固定 4 倍放大刚好等于目标分辨率。
三、核心算法:为什么工作尺寸是目标尺寸的 1/4
这是本篇在算法上唯一需要想清楚的地方,也是整条链路的「灵魂」。
Core Vision Kit 的图像超分是固定 4 倍放大——输入W×H,输出就是4W×4H,不可调。而壁纸要求精确的目标像素(如 1440×3200)。两者要严丝合缝,唯一的办法就是反推输入尺寸:
工作尺寸 = 目标尺寸 / 4 手机壁纸 1440×3200 → 工作输入 360×800 平板桌面 2560×1800 → 工作输入 640×450 方形封面 2048×2048 → 工作输入 512×512这样喂给 NPU 一张360×800的图,输出正好1440×3200,一次推理直接得到目标分辨率,无需任何二次缩放。这套「反推法」有两个硬性约束要处理:
public async prepareInput(preset: WallpaperPreset): Promise<LoadedImage | null> { if (!this.source) { return null; } const info: image.ImageInfo = await this.source.getImageInfo(); const region: image.Region = PhotoLabService.centerCrop(info.size.width, info.size.height, preset.width / preset.height); const working: image.Size = this.workingSize(preset, region.size); const prepared: image.PixelMap | null = await this.coverTo(this.source, working.width, working.height); if (!prepared) { return null; } return { lowPm: prepared, lowW: working.width, lowH: working.height, srcW: this.srcW, srcH: this.srcH }; }第一个约束是NPU 输入上限 2048。工作尺寸里最大的是方形封面的 512×512,远小于 2048,安全;但如果素材本身分辨率不够(比如内置狗狗演示图只有 640×408),就退化为「裁切区域的 1/4」而不是目标的 1/4:
private workingSize(preset: WallpaperPreset, crop: image.Size): image.Size { let workW: number = Math.max(1, Math.round(preset.width / SR_SCALE)); let workH: number = Math.max(1, Math.round(preset.height / SR_SCALE)); if (workW > crop.width || workH > crop.height) { // 素材分辨率不足以支撑目标工作尺寸,改为素材裁切区域的 1/4 workW = Math.max(1, Math.round(crop.width / SR_SCALE)); workH = Math.max(1, Math.round(crop.height / SR_SCALE)); } const cap: number = Math.min(1, SR_MAX_EDGE / Math.max(workW, workH)); return { width: Math.max(MIN_WORK_EDGE, Math.round(workW * cap)), height: Math.max(MIN_WORK_EDGE, Math.round(workH * cap)) }; }第二个约束是先裁后缩。centerCrop先把源图按目标宽高比居中裁出一块(构图不变),coverTo再把这块缩放到工作尺寸。这样最终壁纸的构图、裁切位置在预览阶段就完全确定。
四、工程结构
harmony-sr-studio/ ├── entry/src/main/ │ ├── ets/ │ │ ├── pages/Index.ets # 三态页面、规格切换、滑动对比、导出 │ │ └── common/ │ │ ├── PhotoLabModels.ets # LoadedImage / ProcessResult / 壁纸预设 │ │ ├── PhotoLabService.ets # 单例服务:源管理/裁切/NPU超分/落盘 │ │ ├── PixelOps.ets # 像素读取 / 双线性基线 / 梯度能量 │ │ └── RealImageSuperResolutionService.ets # Core Vision Kit 官方接入参考 │ └── resources/rawfile/demo/ # 狗狗演示素材(同源低清/高清) └── AppScope/壁纸预设用一个常量数组统一定义,UI 和服务端都从这一个地方取,避免「UI 显示一套、实际处理另一套」:
export interface WallpaperPreset { name: string; width: number; height: number; } /** 内置壁纸规格 */ export const WALLPAPER_PRESETS: WallpaperPreset[] = [ { name: '手机壁纸', width: 1440, height: 3200 }, { name: '平板桌面', width: 2560, height: 1800 }, { name: '方形封面', width: 2048, height: 2048 } ];服务沿用系列前作的单例 + 双模式设计,但多了一个关键角色——输入源source。它持有解码后的源图 PixelMap,所有「按预设裁切」都基于这个源,这样切换规格时能重新裁切,而不是拿着旧裁切结果继续用。
五、核心实现
5.1 三态状态机:空态 / 预览 / 结果
整个页面用一个stage字段驱动,三态互斥,build()里按状态渲染,没有命令式跳转:
type ScreenStage = 'empty' | 'preview' | 'result';- empty:空态引导,提示「导入一张照片,开启细节重建」;
- preview:已载入素材、按当前规格裁切好,显示「待增强」,等用户点「开始图像超分」;
- result:超分完成,进入滑动对比,主按钮变为「导出壁纸」。
预览区高度不是写死的,而是随壁纸比例动态计算:宽度固定,高度 = 宽度 × 目标高宽比,再夹在[200, 360]之间。这样手机壁纸是窄竖条、平板桌面是横屏、方形封面是正方形,视觉上直接体现比例差异:
private previewHeight(): number { const preset: WallpaperPreset = this.currentPreset(); const width: number = this.previewWidth > 0 ? this.previewWidth : PREVIEW_FALLBACK_WIDTH; const raw: number = width * preset.height / preset.width; return Math.max(PREVIEW_MIN_HEIGHT, Math.min(PREVIEW_MAX_HEIGHT, Math.round(raw))); }5.2 Cover 适配:居中裁切 + 原生缩放
「把任意比例源图变成目标比例壁纸」用的是标准的Cover 适配:先居中裁切到目标宽高比,再等比缩放到目标尺寸。两步都走系统原生PixelMap操作,不做任何 JS 逐像素处理:
private async coverTo(src: image.PixelMap, targetW: number, targetH: number): Promise<image.PixelMap | null> { let clone: image.PixelMap | null = null; try { const info: image.ImageInfo = await src.getImageInfo(); const sw: number = info.size.width; const sh: number = info.size.height; if (sw <= 0 || sh <= 0 || targetW <= 0 || targetH <= 0) { return null; } const region: image.Region = PhotoLabService.centerCrop(sw, sh, targetW / targetH); clone = await src.clone(); if (region.size.width !== sw || region.size.height !== sh) { await clone.applyCrop(region); } const fx: number = targetW / region.size.width; const fy: number = targetH / region.size.height; if (Math.abs(fx - 1) > 0.001 || Math.abs(fy - 1) > 0.001) { await clone.applyScale(fx, fy, image.AntiAliasingLevel.LOW); } return clone; }关键细节:先 clone 再原地改。applyCrop/applyScale都是原地操作,直接改会污染源图,所以先clone()出一份再裁。裁切区域的计算是纯数学——比较源宽高比和目标宽高比,谁宽裁谁:
private static centerCrop(srcW: number, srcH: number, targetAspect: number): image.Region { const srcAspect: number = srcW / srcH; let cropW: number = srcW; let cropH: number = srcH; if (srcAspect > targetAspect) { cropW = Math.max(1, Math.min(srcW, Math.floor(srcH * targetAspect))); } else if (srcAspect < targetAspect) { cropH = Math.max(1, Math.min(srcH, Math.floor(srcW / targetAspect))); } const x: number = Math.max(0, Math.floor((srcW - cropW) / 2)); const y: number = Math.max(0, Math.floor((srcH - cropH) / 2)); return { size: { width: cropW, height: cropH }, x: x, y: y }; }5.3 规格切换:丢弃旧结果、重新裁切
这是把「壁纸预设」从摆设变成功能的关键。切换规格时不是简单改个 label,而是释放旧的低清图和结果,用新比例对源图重新裁切:
private async selectPreset(index: number): Promise<void> { if (this.busy || index === this.presetIndex) { return; } this.presetIndex = index; this.savedLabel = ''; if (this.stage === 'empty') { return; } // 已有素材:丢弃旧结果,按新比例重新裁切输入 this.busy = true; try { const label: string = this.service.isDemoSource() ? '内置演示素材' : '相册照片'; await this.prepareInput(label); } finally { this.busy = false; } }prepareInput内部先释放旧的lowPm和result,再按新预设裁切出新的工作图。这里有一个很容易犯的内存错误:必须先 release 旧 PixelMap 再赋新值,否则切换几次规格就泄漏好几张图:
private async prepareInput(label: string): Promise<boolean> { const loaded: LoadedImage | null = await this.service.prepareInput(this.currentPreset()); if (!loaded || !loaded.lowPm) { this.releaseImages(); this.stage = 'empty'; this.imageSource = '请选择一张照片开始'; this.toast('按所选规格裁切失败,请重试'); return false; } if (this.result) { if (this.result.hdPm) { this.result.hdPm.release(); } this.result = null; } if (this.lowPm) { this.lowPm.release(); } this.lowPm = loaded.lowPm; this.sourceWidth = loaded.srcW; this.sourceHeight = loaded.srcH; this.imageSource = `${label} · ${loaded.srcW} × ${loaded.srcH}`; this.stage = 'preview'; this.split = 50; return true; }5.4 处理主流程:NPU 超分 + 演示回退 + 增益量化
process一条链路产出 UI 需要的全部东西:真机模式走 NPU,失败或演示模式回退到「同源高清参考」(用同一源图裁出目标尺寸,保证对比图构图一致),最后统一裁切到精确目标像素并算清晰度增益:
public async process(lowPm: image.PixelMap, lowW: number, lowH: number, useReal: boolean, preset: WallpaperPreset): Promise<ProcessResult | null> { let raw: image.PixelMap | null = null; try { if (useReal && this.analyzer) { raw = await this.superResolve(lowPm); } else if (this.source) { // 演示模式:用同一输入源裁切出高清参考,保证前后对比同源同构图 raw = await this.coverTo(this.source, preset.width, preset.height); } if (!raw) { return null; } const hd: image.PixelMap | null = await this.coverTo(raw, preset.width, preset.height); raw.release(); if (!hd) { return null; } const gain: number = await this.estimateGain(lowPm, lowW, lowH, hd); return { hdPm: hd, hdW: preset.width, hdH: preset.height, gain: gain }; }真机 NPU 调用本身与系列前作完全一致——因为工作尺寸最大才 640×450,远小于 2048 上限,一次process()整图出结果:
private async superResolve(lowPm: image.PixelMap): Promise<image.PixelMap | null> { if (!this.analyzer) { return null; } try { const request: visionBase.Request = { inputData: { pixelMap: lowPm } }; const response: imageSuperResolution.ISPResponse = await this.analyzer.process(request); return response.pixelMap; } catch (error) { const err = error as Error; hilog.error(DOMAIN, TAG, 'super resolution failed: %{public}s', err.message); return null; } }5.5 增益评估:先降采样到小网格,避免 JS 逐像素卡顿
清晰度增益用系列前作同口径的梯度能量(邻域亮度差绝对值和,越高越清晰),但要比较「超分结果」和「低清双线性放大」,两者必须同尺寸。问题是结果可能是 2560×1800 这种大图,在 JS 里逐像素遍历几百万像素会卡好几秒。
解法是先把两边都用原生applyScale缩到192px小网格,JS 只遍历约 3.7 万像素,毫秒级完成:
private async estimateGain(lowPm: image.PixelMap, lowW: number, lowH: number, hdPm: image.PixelMap): Promise<number> { const hdInfo: image.ImageInfo = await hdPm.getImageInfo(); const hdW: number = hdInfo.size.width; const hdH: number = hdInfo.size.height; const longEdge: number = Math.max(hdW, hdH); if (longEdge <= 0) { return 0; } const ratio: number = Math.min(1, METRIC_EDGE / longEdge); const gridW: number = Math.max(2, Math.round(hdW * ratio)); const gridH: number = Math.max(2, Math.round(hdH * ratio)); const lowBuffer: Uint8Array | null = await readRgba(lowPm); if (!lowBuffer) { return 0; } const baseline: Uint8Array | null = bilinearUpscaleTo(lowBuffer, lowW, lowH, gridW, gridH); if (!baseline) { return 0; } const baselineEnergy: number = gradientEnergy(baseline, gridW, gridH); const highEnergy: number = await this.downscaledEnergy(hdPm, gridW, gridH); if (baselineEnergy <= 0) { return 0; } return Math.max(0, Math.min(99, Math.round((highEnergy / baselineEnergy - 1) * 100))); }5.6 滑动对比:Stack 叠两层 + clip 裁剪
对比控件没用任何像素混合,思路和前作一致但做了简化:Stack底层放结果图满铺,上层放一个宽度为split%、溢出裁剪的子 Stack 包住原始图,再压一条白色分割线和两枚标签。拖动手势只改一个 0~100 的@State split:
Stack({ alignContent: Alignment.Center }) { if (this.result?.hdPm && this.stage === 'result') { Image(this.result.hdPm) .width('100%') .height(height) .objectFit(ImageFit.Cover) .borderRadius(16) Stack({ alignContent: Alignment.Start }) { Image(this.lowPm) .width(this.previewWidth > 0 ? this.previewWidth : PREVIEW_FALLBACK_WIDTH) .height(height) .objectFit(ImageFit.Cover) } .width(`${this.split}%`) .height(height) .clip(true) Column() .width(2) .height(height) .backgroundColor('#FFFFFF') .position({ x: this.dividerX() - 1, y: 0 })两个几何细节:两张图都必须objectFit(ImageFit.Cover)且同尺寸同高度,否则分割线两边错位;上层子 Stack 用.clip(true)把超出split%宽度的部分裁掉,分割线位置由containerWidth() * split / 100算出,横竖屏、折叠屏都不会算错。
5.7 结果导出:按预设裁切后写 PNG 沙箱
导出沿用系列的零权限方案,但多了一步——先按预设再裁切缩放一次,确保落盘的像素尺寸就是目标尺寸:
public async exportWallpaper(hdPm: image.PixelMap, preset: WallpaperPreset, name: string): Promise<PackResult | null> { const fitted: image.PixelMap | null = await this.coverTo(hdPm, preset.width, preset.height); if (!fitted) { return null; } try { return await this.writePng(fitted, name); } finally { fitted.release(); } }文件名用{宽}x{高}_{时间戳}保证多次保存不覆盖,写盘用packToData→openSync→writeSync→fsyncSync→closeSync,最后回显沙箱路径和文件大小。
六、踩坑实录:三个隐蔽的「能编译但跑挂/失效」
这一篇把工程从「能编译」打磨到「真机稳定」,修掉了三个非常典型的 HarmonyOS 坑。它们有个共同点:编译全绿、静态检查不报错,但一跑就出问题。
6.1 结构体get访问器:编译通过、运行时undefined
最致命的一个。为了取当前壁纸规格,我最开始写了一个访问器:
// ❌ 错误写法:ArkTS @Component struct 不支持 get/set 访问器privategetpreset():WallpaperPreset{returnWALLPAPER_PRESETS[this.presetIndex];}编译完全通过,没有任何报错。但真机一进首帧渲染就 jscrash:
TypeError: Cannot read property width of undefined at initialRender (Index.ets:437:37)原因是 ArkTS 的@Component struct不支持get/set访问器——它在编译产物里没有被正确绑定,this.preset运行时就是undefined,再去取.width直接崩。改成普通方法立刻解决:
/** 取当前壁纸规格;ArkTS 结构体不支持 get 访问器,必须用普通方法 */ private currentPreset(): WallpaperPreset { const presets: WallpaperPreset[] = WALLPAPER_PRESETS; const index: number = (presets.length > 0 && this.presetIndex >= 0 && this.presetIndex < presets.length) ? this.presetIndex : 0; return presets[index]; }教训:ArkTS 结构体里访问派生数据,一律用方法,不要用get。这类「编译能过、运行时崩」的问题,只能真机/模拟器跑出来。
6.2fileIo.accessSync对不存在路径是抛异常,不是返回 false
保存功能一开始永远失败。原写法想「目录不存在就创建」:
// ❌ 错误写法:accessSync 对不存在路径抛异常,永远走不到 mkdirSyncif(!fileIo.accessSync(directory)){fileIo.mkdirSync(directory,true);}但accessSync的语义是「存在返回 true,不存在直接抛异常」,所以!accessSync(...)这个判断在目录不存在时根本执行不到——异常先抛了,被外层 catch 吞掉,首图保存必定失败。正确做法是 try/catch 里尝试访问,失败再创建:
private ensureDirectory(dir: string): boolean { try { if (fileIo.accessSync(dir)) { return true; } } catch (error) { // 目录不存在时 accessSync 会抛异常,落到下方创建逻辑 } try { fileIo.mkdirSync(dir, true); return true; } catch (error) { const err = error as Error; hilog.error(DOMAIN, TAG, 'create directory failed: %{public}s', err.message); return false; } }6.3 rawfile 解码:Uint8Array.buffer可能带非零偏移
读内置演示素材时,一开始直接image.createImageSource(content.buffer)。大部分时候能解码,但偶发解码错位——getRawFileContent返回的Uint8Array可能带有非零byteOffset,直接传底层buffer会把整个共享缓冲区(而不是这个视图)喂给解码器。必须按视图切片:
const content: Uint8Array = await this.context.resourceManager.getRawFileContent(name); // Uint8Array 可能带有非零 byteOffset,直接传 buffer 会导致解码错位,必须按视图切片 const data: ArrayBuffer = content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength); source = image.createImageSource(data); return await source.createPixelMap({ desiredPixelFormat: image.PixelMapFormat.RGBA_8888 });这三个坑在踩坑清单速查表里都有对应条目,是这一篇最有价值的「实战经验」。
七、权限配置:user_grant 权限的三要素
从相册读照片需要ohos.permission.READ_IMAGEVIDEO,这是个user_grant 权限,module.json5里必须同时声明reason和usedScene,缺一不可,而且reason必须是$string:资源引用,不能写中文文本:
{ "name": "ohos.permission.READ_IMAGEVIDEO", "reason": "$string:permission_read_imagevideo_reason", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } }对应的字符串资源放在resources/base/element/string.json:
{"name":"permission_read_imagevideo_reason","value":"用于读取您选择的图片或视频,进行端侧超分辨率处理。"}少了reason/usedScene会报00303218 Configuration Error;reason写了纯文本会报00303038 Schema validate failed。这两个错都是在 PreBuild 阶段卡死,先把配置写对才能进入真正的 ArkTS 编译。
八、真机 / 模拟器运行指南
- 演示模式(默认):点「载入演示图」用内置狗狗素材,不调 NPU,任何设备都能跑通「选规格 → 预览 → 超分 → 对比 → 导出」全流程,结果明确标注「演示素材」;
- 真机模式:点「从相册选择」在系统相册选任意图,会真实走一遍端侧 NPU 4× 超分。若设备不支持 Core Vision Kit,会自动回退演示素材并 toast 提示「真实超分需要支持的设备」,不会伪装成成功;
- 结果保存在沙箱
filesDir/wallpaper_sr_out/{宽}x{高}_{时间戳}.png,取出方式:hdc shell "ls /data/app/el2/100/base/com.example.harmonysrstudio/haps/entry/files/wallpaper_sr_out" hdc file recv /data/app/el2/100/base/com.example.harmonysrstudio/haps/entry/files/wallpaper_sr_out ./out
命令行构建需要把 DevEco 自带的 JBR 加进PATH(否则打包阶段报spawn java ENOENT):
$env:DEVECO_SDK_HOME="C:\Program Files\Huawei\DevEco Studio\sdk"$env:JAVA_HOME="C:\Program Files\Huawei\DevEco Studio\jbr"$env:PATH="C:\Program Files\Huawei\DevEco Studio\jbr\bin;$env:PATH"node"C:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.js"`--mode module-p module=entry@default-p product=default-p requiredDeviceType=phone ` assembleHap--analyze=normal--parallel--no-daemon九、踩坑清单速查表
| 现象 | 原因 | 修法 |
|---|---|---|
一进页面就 jscrashCannot read property width of undefined | ArkTS struct 用了get访问器,编译过但运行时返回undefined | 改用普通方法currentPreset() |
| 首次保存永远失败 | fileIo.accessSync对不存在路径抛异常,!accessSync()走不到创建分支 | try/catch 访问,失败再mkdirSync(dir, true) |
| 内置素材偶发解码错位 | getRawFileContent返回的Uint8Array带非零byteOffset,直接传.buffer | buffer.slice(byteOffset, byteOffset+byteLength)按视图切片 |
| 大图增益计算卡好几秒 | JS 逐像素遍历几百万像素 | 原生applyScale先缩到 192px 网格再算梯度能量 |
| 切换规格后预览构图不变 | 只改了 label,没重新裁切 | 保留源图source,切换时prepareInput重新裁切 |
| 分割线两边画面错位 | 两张图objectFit/尺寸/高度不一致 | 都用Cover+ 同宽高,上层clip(true)裁split% |
| 超分结果尺寸不是目标像素 | 直接用 NPU 输出,没按预设裁切 | 工作尺寸 = 目标/4,输出再coverTo到精确目标 |
PreBuild 报00303218 | user_grant 权限缺reason/usedScene | 补两字段 |
PreBuild 报00303038 | reason写了中文纯文本 | 改$string:资源引用 |
打包报spawn java ENOENT | shell 缺 JDK | 把 DevEco 自带jbr\bin加进PATH |
十、总结
至此图像超分系列走完第六步:从「技术演示」走向「场景闭环」。
这一篇的核心不是又接了一个新 API,而是把端侧超分放进一个真实的产品场景里重新审视——壁纸有明确的尺寸和比例,超分不能再是「放大 4 倍看效果」,而要「精确产出 1440×3200 这块能直接设成壁纸的图」。为此我们反推出「工作尺寸 = 目标尺寸 / 4」的输入策略,用 Cover 适配解决任意比例的居中裁切,用保留源图 + 重新裁切让规格切换真正生效,再把前作里潜伏的三个运行时坑(get访问器崩溃、accessSync语义、Uint8Array偏移)一次性填平。
至此,从单图重建、实时放大镜、批量工作台、相册直选,到今天的壁纸适配,端侧超分这条线已经覆盖了「能力验证 → 交互增强 → 工程批量化 → 真实输入 → 场景落地」的完整路径。下一步可以继续往「设置成系统壁纸」这个最后一步走(需要setWallpaper相关能力),或者把裁切框做成可手动拖拽调整构图的交互——那就留给下一篇了。
工程里RealImageSuperResolutionService.ets保留了 Core Vision Kit 的官方接入参考,真机上把useReal置真即可切换到 NPU 推理;模拟器/不支持设备自动回退演示素材,保证任何环境都能完整体验。壁纸比例与尺寸的工作台已经搭好,剩下的就是把每一张图,都适配到它的屏幕上。