HarmonyOS 7 AI图像超分:分块推理调度与结果缓存落盘【鸿蒙心迹】
把一张 1120 × 1584 的海报放大到 2240 × 3168,最开始我只想做一个“选择图片、处理、保存”的小工具。真正把页面做成任务型应用后,问题立刻从“算法能不能返回一张清晰图”变成了“用户如何知道它还活着”。尤其在端侧推理耗时不固定、预览图需要先显示、系统随时可能打断页面的时候,简单地在按钮上挂一个转圈动画并不够。
这篇围绕 SuperResLab 的一条任务记录展开。输入图片 poster_1120.jpg,业务任务 ID 为 sr_20261009_02,Demo 调度配置叫 SR-X2-Lite,目标放大倍率 2x,应用层切成 16 个 tiles,最终输出 sr_20261009_02.png。界面上正在处理的断面显示 12 / 16 个分块已交付、总进度 68%;完成后详情页显示 16 / 16、SAVED、尺寸 2240 × 3168、文件大小 4.8 MB。
需要先把边界说清:HarmonyOS 7 / API 26 的 Core Vision Kit 提供 imageSuperResolution.ImageSRAnalyzer,包括创建、处理、销毁等能力;并没有把本文的 TileScheduler、SRWorker、ResultCache、进度 68% 或 SR-X2-Lite 作为官方接口公布。这些是我们为了任务管理设计的应用层包装。具体是否适合切片、输入尺寸约束、推理并发度和图像边界处理,都必须依据实际设备、官方限制和实验结果决定。文中统一配图是 Demo 的设计复现,不是已验证的设备性能截图。

一、问题是从一张处理中的预览图开始的
原型只有两个画面:左边显示原图,右边显示“高清结果”。选择图片以后,程序立刻调用超分分析器,等待返回,再替换右侧图片。这个流程演示单图效果没问题,放到真实产品的交互链路里却有三个明显缺口。
第一,图片只要大一点,等待时间就让用户没有反馈。第二,单次处理过程中如果导航返回、进入后台或者用户再次点击“开始”,会遇到并发任务和资源管理问题。第三,结果在内存里显示过不等于文件已保存。最容易被忽视的是第三点:界面已经有高清预览,用户点“分享”时才发现没有可读的输出文件。
因此我做了一个取舍:把图像算法调用和任务生命周期分开。 UpscaleTaskPage 只描述用户希望处理哪张图、当前采用什么策略以及进度到了哪里;TileScheduler 处理业务切片和排队;SRWorker 是分析器调用的封装层;ResultCache 只负责输出结果是否真实落盘。最后,再由 TaskDebugPage 解释算法输出、拼接和缓存的全过程。
这里的 SR-X2-Lite 只是本 Demo 的业务策略标识,用于区别不同的尺寸、内存和调度方案,不能误认为系统可选的公开模型名称。输入宽高各扩大两倍,像素总数扩大四倍,正好对应示例从 1120 × 1584 到 2240 × 3168 的变化。这也意味着即使 JPEG 最终只有几 MB,解码成 RGBA 缓冲后仍会占用显著更大的内存,不能拿磁盘文件大小直接推断推理峰值。
二、项目分层后,按钮不再直接驱动算法
先看这次整理后的工程目录。页面、调度器、分析器适配和持久化各自独立,依赖方向由外往内,方便替换底层策略:
SuperResLab/
└── entry/src/main/ets/
├── pages/
│ ├── UpscaleTaskPage.ets
│ └── TaskDebugPage.ets
├── engine/
│ └── TileScheduler.ets
├── worker/
│ └── SRWorker.ets
├── cache/
│ └── ResultCache.ets
└── model/
├── SRTaskInfo.ets
└── TileResult.ets
一条任务采用四个核心阶段:READY 表示入参已经验证,PREPROCESS 表示源图解码、尺寸检查与预处理,UPSCALING 表示执行推理和拼接,RESULT_READY 表示输出像素已可用。只有文件写入与校验完成,整个任务才进入 SAVED。注意 RESULT_READY 与 SAVED 不能合并。前者属于内存和计算结果,后者属于持久化承诺。
页面采用另外一组可见状态 RUNNING / PAUSED / FAILED / SAVED。一旦采用分块策略,每个 tile 还有自己的 QUEUED / PROCESSING / DONE / FAILED 子状态。某一块失败不应直接让整个界面变成成功,也不应无限次静默重试。做成任务对象以后,这些状态能在测试用例里被逐个校验,而不用盯着一张进度条猜测。
我的经验是,图像处理工具尤其要控制“重入”。用户在同一张图上快速点击两次,不应该同时启动两个高成本分析器;如果切换图片,也应由旧任务明确取消或完成释放后再创建新任务。这个限制在业务层实现比在按钮上临时禁用更可靠,因为从通知、路由或恢复入口仍可能发起任务。
三、第一段代码:任务信息先有统一的数据模型
项目最初的问题是不同页面都自己计算进度。任务页用 finishedTiles / totalTiles,详情页用“当前步骤 × 权重”,造成同一个时刻两张图显示不同百分比。解决方法是让进度只由调度器产出一个快照,页面只负责读取。下面的模型把分块数、阶段和进度单独保存:
// model/SRTaskInfo.ets:应用层状态,不是系统官方的任务结构
export type SRStage =
'READY' | 'PREPROCESS' | 'UPSCALING' | 'RESULT_READY';
export type SRStatus =
'RUNNING' | 'PAUSED' | 'FAILED' | 'SAVED';
export interface SRTaskInfo {
taskId: string;
source: string;
model: string;
scale: number;
totalTiles: number;
finishedTiles: number;
memoryCapMB: number;
stage: SRStage;
status: SRStatus;
progress: number;
revision: number;
}
export const DEMO_TASK: SRTaskInfo = {
taskId: 'sr_20261009_02',
source: 'poster_1120.jpg',
model: 'SR-X2-Lite',
scale: 2, totalTiles: 16, finishedTiles: 12,
memoryCapMB: 256, stage: 'UPSCALING',
status: 'RUNNING', progress: 68, revision: 12
};
revision 是最容易漏掉的字段。tile 回调是异步的,如果同一个 tile 被重试,旧结果可能晚于新结果回到页面。没有版本控制就可能出现“进度从 70 跳回 65”的假回退。每次发布新的任务快照,应当让版本单调增加;页面看到旧版本,直接忽略。
至于 memoryCapMB: 256,它描述 Demo 的应用层缓存和工作队列预算,并非声明 Core Vision Kit 能提供“设置 256 MB 上限”的系统接口。实际峰值还包括解码图、输入输出 PixelMap、模型内部工作空间、合成目标图、预览图以及系统其他图形内存。做预算必须分别统计,不能把业务阈值当作系统硬限制。
四、12 / 16 为什么不是 75%,而显示 68%
这是我认为这篇最值得单独解释的一点。截图显示 Tiles = 12 / 16,如果按简单除法确实是 75%。然而页面进度是 68%。这不是疏忽,而是我们明确采用了任务加权进度:预处理占 8%、tile 结果计算与交付占 80%、拼接 / 修缝占 8%、缓存写入与校验占 4%。
运行到 12 块已交付时,预处理已完成,tile 阶段占比为 0.8 × 12 / 16 = 0.6,因此综合进度就是 8% + 60% = 68%。这时输出图并没有拼接完成,缓存也还没落盘,所以不能提前给满进度。等 16 块都提交完,算法与分块部分到 88%;拼接和修缝完成到 96%;文件实际写入、校验完成后才显示 100%。
有些应用会直接用 12/16 作为“分块进度”,同时用 68% 作为“总任务进度”,这当然也可以。问题不在采用哪个公式,而在于口径必须写清。如果 UI 只写一个大大的“68%”,用户很容易以为还有 32% 的模型推理工作;事实上剩下可能主要是分块结果拼接、编码与缓存。进度条越漂亮,越容易掩盖真正的等待阶段。
五、第二段代码:调度器聚合结果而不是相信回调次序
我把每个 tile 的唯一编号、重试代数与结果提交状态保存下来,调度器收到回调以后先判断能否入账,再计算综合进度。这样即使同一块因为超时被重试,旧回调也不会让完成数重复加一。下面展示的是关键聚合逻辑:
// engine/TileScheduler.ets:应用层调度与加权进度
export interface TileResult {
tileId: number;
attempt: number;
outputPath: string;
}
export class TileScheduler {
private committed: Map<number, number> = new Map();
private latestAttempt: Map<number, number> = new Map();
private task: SRTaskInfo;
constructor(task: SRTaskInfo) { this.task = task; }
markAttempt(tileId: number, attempt: number): void {
this.latestAttempt.set(tileId, attempt);
}
mergeTileProgress(result: TileResult): boolean {
if (result.tileId < 0 || result.tileId >= this.task.totalTiles) {
return false;
}
if (this.latestAttempt.get(result.tileId) !== result.attempt) {
return false; // 旧重试代数,不再入账
}
if (this.committed.has(result.tileId)) { return false; }
this.committed.set(result.tileId, result.attempt);
this.task.finishedTiles = this.committed.size;
this.task.progress = 8 + Math.floor(
80 * this.task.finishedTiles / this.task.totalTiles
);
this.task.revision++;
return true;
}
}
调用侧还要管理队列背压,不能把 16 个原图 tile 一口气都解码成高分辨率 PixelMap。这个设计里的“16 tiles”只是业务切片数量,不等于底层 Core Vision Kit 同时执行 16 路推理。在一些设备上串行执行更稳,在另一些设备上也许可以有限并行;具体上限需要跑内存、温度和失败率测试。
还有一个被切片方案天然放大的边界问题:如果直接把源图切成不重叠方块,再分别超分,拼回去可能出现缝隙、颜色过渡不一致或边缘纹理断裂。实际处理必须给 tile 加重叠边界、记录有效区域、选择融合权重,并在目标图中使用统一的坐标系。不是所有模型和系统能力都适合外部分块;官方分析器的输入约束也可能决定最合理的方案是整图处理或先缩放到可支持范围。
因此,对应用层而言,调度器只是可替换模块:当设备无法满足切片输入条件时,选择整图调用;当图片或模型需要分块时,才启用带重叠区的 tile pipeline。把这点写出来,比把所有图像都强制塞进“16 块并行”更符合实际工程。
六、真正的系统能力调用应该放在哪里
Core Vision Kit 的 imageSuperResolution.ImageSRAnalyzer 才是官方分析器。按照官方参考,典型步骤是创建分析器、构造包含 PixelMap 的 visionBase.Request,等待 process() 返回处理后的 PixelMap,最后释放分析器以及相关图像资源。本文应用层的 Worker 只是把这套调用包起来,并没有发明新的系统超分 API。
下面是一个单张输入的关键调用片段。为了突出资源释放,省略了图库选择、图片解码、模型约束预检查和输出写文件;这些必须在正式工程里补齐。
// worker/SRWorker.ets:调用官方 Core Vision Kit 的关键部分
import { imageSuperResolution, visionBase } from '@kit.CoreVisionKit';
export async function superResolveOne(input: PixelMap): Promise<PixelMap> {
const analyzer = await imageSuperResolution.ImageSRAnalyzer.create();
try {
const data: visionBase.ImageData = { pixelMap: input };
const req: visionBase.Request = { inputData: data };
const response = await analyzer.process(req);
return response.pixelMap;
} finally {
await analyzer.destroy();
}
}
要特别强调两个生命周期细节。第一,函数返回的输出 PixelMap 所有权交给调用方,调用方必须在编码、缓存或显示完成以后自行释放,不能在这里提前释放。第二,输入 PixelMap 的释放要由创建它的那一层负责,避免一层释放后另一层还在显示。创建失败、推理抛错、页面销毁都需要通往同一个可靠的资源回收路径。
try / finally 能保证大部分正常异常分支执行释放逻辑,但不能代替系统能力中止或进程终止时的恢复机制。如果真实项目要允许用户“取消”,必须有明确定义:取消排队中的 tile 很容易;中止一个已经进入系统分析器的 process(),则要按当前版本公开的取消 / 释放语义验证,不能假定销毁对象就能安全强杀正在运行的底层任务。
七、DevEco Studio 对照一次分块进度

在示意开发图里,左侧工程结构包含 UpscaleTaskPage.ets、TileScheduler.ets、SRWorker.ets 和 ResultCache.ets;中间是任务提交、进度聚合和缓存保存;右侧预览是同一个任务 sr_20261009_02;底部 HiLog 用相同 ID 把几种阶段串起来。它的价值是解释“哪个模块拥有哪种状态”,不是作为真机编译通过的证据。
特别要提醒:图中编辑器片段用 Math.floor(finishedTiles / totalTiles * 100) 表示了纯分块进度的简化写法。如果直接用于真实应用,总进度会变成 75%,与手机图 03 的 68% 不一致。因此本文正文采用上面的 8 / 80 / 8 / 4 加权方案作为最终任务进度口径。把两种公式并排讲清,比勉强宣称所有图完全无差异更能帮助读者理解版本迭代。
这也揭示出“真实开发截图感”和“工程真实性”不是一回事。界面可以非常像 IDE,但数据一致性仍需要通过源码、构建产物和实际日志验证。读者复刻时建议用同一个任务快照驱动界面与日志,不要分别手工拼数字。日志中记录 taskId、tileId、attempt、当前阶段、内存样本和最终结果路径,会让一次分块异常可追溯。
八、任务页展示的内容应该让用户判断下一步

这张 UpscaleTaskPage 设计图中,当前任务 RUNNING / UPSCALING,模型策略 SR-X2-Lite,分块 12 / 16,综合进度 68%,输入 poster_1120.jpg,业务缓存预算 256 MB。预览区显示原图 1120 × 1584 和目标超分结果 2240 × 3168。由于任务尚未 SAVED,右侧更适合显示可快速刷新的低分辨率预览,而不是误导用户“高清输出已经完成”。
我会在产品里把“预览”和“输出”做成两份资源。预览可以是低分辨率合成图、缩略图或者上一次稳定结果,用来保证交互反馈;正式输出必须经过全部 tile、融合修缝、图像编码、磁盘写入和校验。这样既避免 UI 长时间空白,也不需要每次刷新进度条就重新生成完整大图。
这张图里有一个值得调试的矛盾:当进度还是 68% 时,界面下方已经出现“保存结果”按钮。这个按钮在实际应用中应当置灰,或者明示“处理中,暂不可保存”;示意图用它说明 UI 操作入口,但不代表可以绕过 SAVED 门槛。我宁可在文章里指出这一点,也不愿把不完整的交互当作已验收的产品界面。
至于“暂停任务”,含义也不能随便承诺。本文的暂停首先作用于队列分发:不再启动新的 tile,正在执行中的任务按底层能力安全完成或自然返回。恢复时读取调度器已确认的 tile 列表,跳过真正完成的分块;不能简单重启一批没有持久化记录的任务后宣称“断点续推”。
九、第四段代码:结果落盘比显示一张图更重要
在 SuperResLab 里,输出文件的目标位置是应用沙箱缓存目录下的 sr_20261009_02.png,最终详情显示完整路径 /data/storage/el2/base/cache/sr_20261009_02.png。这里刻意不用“相册路径”这个说法:应用沙箱文件和用户相册资源的授权、访问、可见性、清理策略都不同。未来要发布到相册或分享给其他应用,需要遵守相应的媒体资源和 URI 授权流程。
落盘最好采用临时文件与原子替换策略,避免写了一半却显示 SAVED。下面给的是存储层协议,而不是偷懒用某个不存在的系统函数假装写盘。writeEncodedImage()、verifyFile() 和 atomicCommit() 都必须在 ResultCache 里用可用文件 API 实现。
// cache/ResultCache.ets:持久化事务的业务接口示意
export interface EncodedImage {
bytes: Uint8Array;
width: number;
height: number;
checksum: string;
}
export async function saveResultToCache(
taskId: string, image: EncodedImage,
cache: ResultCache, store: SRTaskStore
): Promise<string> {
const target = `/data/storage/el2/base/cache/${taskId}.png`;
const temp = `${target}.writing`;
await cache.writeEncodedImage(temp, image.bytes);
const verified = await cache.verifyFile(temp, image.checksum);
if (!verified) {
await cache.removeIfExists(temp);
throw new Error('CACHE_VERIFY_FAILED');
}
await cache.atomicCommit(temp, target);
await store.markSaved(taskId, target, image.width, image.height);
return target;
}
atomicCommit() 的行为要结合实际文件系统 API 验证,而不是因为函数名字里有“atomic”就默认可靠。如果写入接口没有提供应用需要的原子语义,至少要做到“临时文件、完整校验、成功后发布路径、失败清理”。记录数据库状态时也要考虑文件已写成但事务尚未提交、事务已提交但文件被系统清理两类不一致情况。这要求读取结果时再做存在性与完整性检查。
图里的 4.8 MB、校验摘要 3E9A-A210、推理 6.1s、拼接 1.2s、总耗时 8.4s 都是本演示预设的结果字段,不是对某台 HarmonyOS 设备跑出的性能承诺;总耗时还包括预处理、编码和其他业务开销,不一定等于两个子耗时简单相加。工程测试应该记录冷启动与热启动、不同图片类别、不同设备温度、长短边尺寸、编码格式,才能给用户可信的预期。
十、结果详情应当回答的是“真的保存成功了吗”

在 TaskDebugPage 中,任务完成态为 SAVED,阶段为 RESULT_READY,总分块数 16 / 16。它还保留一条时间线:READY → PREPROCESS → UPSCALING → SAVED。有的人会问,既然阶段叫 RESULT_READY,为什么整体状态叫 SAVED?因为一个描述算法结果是否形成,另一个描述用户可用结果是否已经可靠存储,这是两条不同的判断轴。
详情页还标出了“缝隙修复完成”。这一步不能仅凭肉眼看缩略图确认。常见的检查方法是把所有 tile 的重叠边缘单独抽出来做差分分析,重点看边界是否出现亮度突变、色彩断层、重复纹理或者被错误裁切的像素。若应用支持文字海报,也要在标题边缘检查模型有没有凭空生成笔画:超分提高观感,并不意味着能恢复原图里不存在的真实文字细节。
我会给结果校验加两层:文件层检查是否可读取、尺寸是否符合目标、编码内容是否完整;视觉层检查色彩、边缘缝隙和关键区域细节。文件校验通过只能证明落盘成功,不能保证图像看起来正确;视觉验收通过也不能替代文件一致性。对用户来说这两个步骤最终都折叠成一个绿色状态,对开发者来说则必须留两条日志。
最后还有一个经常被忽略的风险:系统或用户可能清理 cache 目录。既然它是缓存,就不能保证永久存在。页面在重新打开任务时,应重新检查输出文件是否还可读取;如果已失效,状态应明确转为“结果已过期 / 可重新生成”,而不是继续展示一个历史 SAVED 标签让用户点击后报错。对需要长期保存的文件,应转移至适当的持久位置并处理用户授权。
十一、验收方案比一张对比图更有价值
这次我列的验收项没有堆成几十个按钮测试,而是集中在五种行为。快速重复提交:同一任务 ID 不会启动两个推理作业。旧回调迟到:版本号落后的 tile 结果不能覆盖新状态。强制退出恢复:已确认的分块可以追踪,无法恢复的中间状态有明确说明。磁盘空间不足:临时文件写入失败后任务不得显示 SAVED。结果被清理:重新进入详情页必须发现缓存缺失并展示可操作的失败态。
如果设备采用整图处理策略,还要有一套旁路测试:不拆 tile 时进度不应虚构 12 / 16;如果用户取消,只能按分析器真实支持的生命周期处理;如果输入图不满足尺寸和像素约束,应该在创建昂贵资源之前给出可解释提示。这里涉及 Stage 模型、API 版本、设备支持范围以及 Core Vision Kit 当前的输入限制,均以官方文档和实际运行环境为准。
每个耗时指标也必须标注统计范围。比如所谓“推理 6.1s”可能不包含模型加载、图片解码和写盘,因此不等于用户从点击到获得结果的时间。没有口径的性能数字不如不放。在数据量大、发热明显的端侧场景里,分阶段记录耗时、峰值内存和失败原因,后续优化才有真正的抓手。
十二、这次重构留下的判断
一个图像超分 Demo 能不能调用 API,并不等于它已经是稳定的图片处理工具。真正的工程工作发生在分析器之外:异步任务是否可观察、每个分块是否可幂等、进度是否有统一口径、资源是否及时释放、结果是否经过校验、缓存是否能在下一次打开时找到。图片“更清晰”是目标,但过程“可信”才是产品能长期使用的基础。
SuperResLab 当前的模型仍是一个演示约定,不代表任何设备都能稳定以 256 MB 跑完 16 个 tile,也不能据此承诺每次都在 8.4 秒内完成。下一步如果把它做成实际产品,我会优先测三种情况:一张满是文字的复杂海报、一张大面积平滑渐变的人像背景、以及一张高频细节密集的夜景。它们暴露的问题完全不同,足够检验分块、融合和质量提示是否站得住。
最值得保留的一条原则是:把“算法处理完”与“用户确实拿到了可使用的文件”分开。 进度可以加权,预览可以渐进显示,任务可以在安全边界下暂停,但 SAVED 必须是经过文件层确认的事实。这才是图像超分从一次按钮演示走向工程能力的关键。
参考资料
图示说明:四张图片围绕 SuperResLab 统一设计,IDE 与手机截图为生成的技术示意,不是实际编译、真机性能或相册保存的证据。
TileScheduler、SRWorker、ResultCache、SR-X2-Lite和图片中的耗时、大小均为业务层演示定义,需依据 SDK 文档和真机测试调整。
更多推荐



所有评论(0)