【共创稿事节】鸿蒙图像超分 · 壁纸适配工作台:三种屏幕比例、居中裁切、端侧 4× 超分、所见即所得

前五篇把端侧超分这个「单点能力」打磨得很顺了——单图重建、实时放大镜、批量落盘、相册直选,每一步都验证了 HarmonyOS 端侧 NPU 推理的可用性。但都是技术演示视角:选一张图、跑一遍模型、看对比。真实用户的诉求其实更朴素——「我想把这张照片做成手机壁纸」。这一篇换个视角,从「做成壁纸」这个具体场景出发,做一个把壁纸比例、居中裁切、端侧超分、导出尺寸串成一条链的工作台。同时借这个机会,把前作里几个一直被忽略的坑——壁纸预设形同虚设、保存功能永远失败、结构体 get 访问器运行时崩——一次性修掉。

在这里插入图片描述

DevEco Studio 与模拟器:工作台空态首屏

工程地址: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 的横屏比例铺开,构图与最终壁纸一致——这就是「所见即所得」的核心:预览什么样,导出的壁纸就什么样:

平板桌面规格下载入演示图,预览区按 32:18 横屏比例显示

往下滚动可以看到输入/工作/输出三个尺寸卡、处理方式按钮区。演示模式下明确标注「演示素材」「DEMO MODE」,不会伪装成真实推理:

信息卡与处理方式:明确标注演示模式

超分完成后进入对比态:左侧「原始」、右侧「演示素材」,中间一条可拖动的白色分割线。默认分割居中,左右同源同构图、只分辨率不同:

滑动对比:左原始 / 右演示素材,分割居中

把分割线一路拖到最左,整个预览区就只剩右侧的高清结果,方便单独检查细节:

分割线拖到最左,全屏查看右侧结果

切换到「手机壁纸」1440×3200 后,预览区立刻按 9:20 的窄竖条重新裁切——同一张狗狗图,构图从横屏变成竖屏,壁纸比例的差异一目了然:

切换到手机壁纸后,预览按 9:20 窄竖条重新裁切

二、这个 App 解决什么问题

前五篇的超分输入都是「正方形或 4:3 的素材」,对比的是「放大前 vs 放大后」。但壁纸是有明确目标尺寸和宽高比的:手机 9:20、平板 32:18、方形封面 1:1。这就引出三个新命题:

  1. 比例不对就得裁:一张 4:3 照片要当手机壁纸,必须裁成 9:20,否则两边黑边或拉伸变形;
  2. 超分和裁切要算好顺序:先超分再裁,还是先裁到工作尺寸再超分?这直接决定 NPU 输入大小和最终清晰度;
  3. 结果要导出成精确的像素尺寸:不是「放大 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 访问器
private get preset(): WallpaperPreset {
  return WALLPAPER_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.2 fileIo.accessSync 对不存在路径是抛异常,不是返回 false

保存功能一开始永远失败。原写法想「目录不存在就创建」:

// ❌ 错误写法:accessSync 对不存在路径抛异常,永远走不到 mkdirSync
if (!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

九、踩坑清单速查表

现象原因修法
一进页面就 jscrash Cannot read property width of undefinedArkTS struct 用了 get 访问器,编译过但运行时返回 undefined改用普通方法 currentPreset()
首次保存永远失败fileIo.accessSync 对不存在路径抛异常,!accessSync() 走不到创建分支try/catch 访问,失败再 mkdirSync(dir, true)
内置素材偶发解码错位getRawFileContent 返回的 Uint8Array 带非零 byteOffset,直接传 .bufferbuffer.slice(byteOffset, byteOffset+byteLength) 按视图切片
大图增益计算卡好几秒JS 逐像素遍历几百万像素原生 applyScale 先缩到 192px 网格再算梯度能量
切换规格后预览构图不变只改了 label,没重新裁切保留源图 source,切换时 prepareInput 重新裁切
分割线两边画面错位两张图 objectFit/尺寸/高度不一致都用 Cover + 同宽高,上层 clip(true) 裁 split%
超分结果尺寸不是目标像素直接用 NPU 输出,没按预设裁切工作尺寸 = 目标/4,输出再 coverTo 到精确目标
PreBuild 报 00303218user_grant 权限缺 reason/usedScene补两字段
PreBuild 报 00303038reason 写了中文纯文本改 $string: 资源引用
打包报 spawn java ENOENTshell 缺 JDK把 DevEco 自带 jbr\bin 加进 PATH

在这里插入图片描述

十、总结

至此图像超分系列走完第六步:从「技术演示」走向「场景闭环」。

这一篇的核心不是又接了一个新 API,而是把端侧超分放进一个真实的产品场景里重新审视——壁纸有明确的尺寸和比例,超分不能再是「放大 4 倍看效果」,而要「精确产出 1440×3200 这块能直接设成壁纸的图」。为此我们反推出「工作尺寸 = 目标尺寸 / 4」的输入策略,用 Cover 适配解决任意比例的居中裁切,用保留源图 + 重新裁切让规格切换真正生效,再把前作里潜伏的三个运行时坑(get 访问器崩溃、accessSync 语义、Uint8Array 偏移)一次性填平。

至此,从单图重建、实时放大镜、批量工作台、相册直选,到今天的壁纸适配,端侧超分这条线已经覆盖了「能力验证 → 交互增强 → 工程批量化 → 真实输入 → 场景落地」的完整路径。下一步可以继续往「设置成系统壁纸」这个最后一步走(需要 setWallpaper 相关能力),或者把裁切框做成可手动拖拽调整构图的交互——那就留给下一篇了。

工程里 RealImageSuperResolutionService.ets 保留了 Core Vision Kit 的官方接入参考,真机上把 useReal 置真即可切换到 NPU 推理;模拟器/不支持设备自动回退演示素材,保证任何环境都能完整体验。壁纸比例与尺寸的工作台已经搭好,剩下的就是把每一张图,都适配到它的屏幕上。

Logo

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

更多推荐