HarmonyOS 7 ImageKit:超分切片边缘融合与内存门控【鸿蒙心迹】
上一轮做图像超分,我把重点放在了任务状态和结果文件的安全回写。这次再往图像处理链路里面走一步,遇到的是更难靠一个“成功”按钮解释的问题:同一张图分成许多块处理,每一块都清楚,拼到一起却能看到细细的接缝。
我做的 Demo 叫 TileBlend。输入图是 1200×800 的山湖照片,目标输出 4800×3200,倍率为四倍。为了约束一次处理的内存占用,我把源图按 tileSize=256、overlap=24 拆为 6×4、共 24 个分块。中途展示第 15/24 块、约 63% 的处理进度,配置的工程内存警戒线为 384 MB,示意记录里的峰值使用量为 318 MB。
要特别说明:Image Kit 负责图像解码、PixelMap 与像素数据等基础能力;本文的超分推理、瓦片融合、内存预算属于应用或自定义模型层的工程实现,并不是 Image Kit 直接提供的同名一键超分接口。文章里的 SuperResolutionEngine、MemorySampler、TileSink 都是工程适配层。配图是为说明流程而生成的拟真界面,其中运行耗时、接缝评分和峰值内存属于样例值,不是通过真机压测取得的基准结论。

一、为什么原本看不见的方格,在超分之后突然出现
先说现象:整张图缩小看时没有问题,放大山脊或天空渐变区域以后,方格线附近会出现亮度断层,偶尔还有纹理方向的不自然变化。第一次遇到时很容易怀疑渲染控件,实际上问题可能发生在模型的输入边界。对一个局部超分模型来说,边界附近没有足够的相邻上下文,输出像素就容易偏离旁边瓦片的预测。
如果把 1200×800 的照片切成互不重叠的 256 像素正方形,各块分别上采样,再按坐标严丝合缝地贴回去,几何位置虽然没有错,但边缘像素的观感未必连续。尤其是天空、纯色墙面和具有重复纹理的水面,微小差异都很容易暴露。把模型换得更大未必能根治,因为输入边缘缺失上下文这个条件没有变。
最直接的修复思路是让相邻瓦片在源图上多读一些像素,本轮选择 24 像素重叠区。四倍输出以后,相邻结果具有约 96 像素宽的对应重叠带,再通过加权融合决定最终每个输出像素来自哪一侧。这个策略不会改变模型本身,也不保证所有图片都看不到接缝,但它让边界问题从“全靠模型碰运气”变成“可以定义、测量和调节的融合问题”。
需要注意,overlap=24 说的是输入域中的像素数,不是超分输出图里的重叠宽度。如果日志只打印 overlap=24,而融合函数把它错误当成输出域像素,真正参与融合的区域会缩小四倍,问题很可能依然明显。工程里最好把输入重叠和输出重叠分别命名为 overlapIn 与 overlapOut,明确它们的倍数关系。
二、先设计瓦片覆盖规则,别到边界才补洞
瓦片规划其实决定了后面所有索引和拼接逻辑。我希望它同时满足四个条件:图片任意尺寸都不漏像素;相邻块存在已知重叠;边缘不足 256 像素时可以补边但不会越界读取;每个瓦片都记录它在原图中的坐标,以便结果回填。
第一段代码把图片拆分逻辑从页面抽成纯函数。这里采用步长 tileSize-overlap,靠近图片右边和下边时允许有效区域小于一个标准瓦片。之后模型适配层负责把不足尺寸的输入补齐,推理返回后再裁去补边对应的结果。这样不会为了强行对齐最后一个 256 像素方块而制造一个异常宽的重叠区。
interface TileRect {
id: number
x: number
y: number
width: number
height: number
}
function planTiles(width: number, height: number,
tileSize: number, overlap: number): TileRect[] {
if (width <= 0 || height <= 0 ||
tileSize <= 0 || overlap < 0 || overlap >= tileSize) {
throw new Error('INVALID_TILE_CONFIG')
}
const step = tileSize - overlap
const tiles: TileRect[] = []
let id = 0
for (let y = 0; y < height; y += step) {
for (let x = 0; x < width; x += step) {
tiles.push({
id: ++id, x, y,
width: Math.min(tileSize, width - x),
height: Math.min(tileSize, height - y)
})
}
}
return tiles
}
对本轮尺寸来说,横向起点是 0、232、464、696、928、1160,纵向起点是 0、232、464、696,因此是 6×4=24 个瓦片。最右一列有效宽度仅 40 像素,最下面一行有效高度为 104 像素。它们会先被按模型要求补边,再裁回有效范围,所以并不意味着模型必须支持任意尺寸的输入。
这里尤其容易写错两处。第一,模型通常要求输入张量对齐固定尺寸,但补边像素不应直接计入最终输出。第二,最后一块虽然有效区域窄,也不能把前一块的结果整片覆盖掉,因为那会丢失图像最右侧原本应有的内容。验收时我会使用棋盘格、斜线和单色渐变图,先验证几何坐标,再谈超分清晰度。
三、重叠区是融合,不是简单平均整张图
如果两块在重叠带对同一个像素都给出了预测值,最朴素的办法是取平均。然而平均也有局限:越靠近一个瓦片自己的外边缘,模型可能越缺乏上下文;如果一半权重仍然来自边缘位置,接缝只是变淡,不一定真正自然。
更好的做法是在重叠带上使用平滑权重。左侧瓦片越靠近右边界,权重越低;右侧瓦片越离开自己的左边界,权重越高;重叠区中间两者平滑交换主导权。二维图像还要考虑上下方向的权重,避免四块交汇处出现十字形痕迹。
第二段代码先给出一个可复用的权重函数。它只表达融合策略,没有直接操作 PixelMap 的内部缓冲区;真正写入图像时由 TileSink 接收各块的结果。边界瓦片如果一侧不存在相邻瓦片,就不能在图片边缘无条件衰减到零,否则会出现一圈黑边。
function smoothstep(t: number): number {
const x = Math.max(0, Math.min(1, t))
return x * x * (3 - 2 * x)
}
function blendWeight(pos: number, size: number,
overlapOut: number, hasBefore: boolean,
hasAfter: boolean): number {
let weight = 1
if (hasBefore && pos < overlapOut) {
weight *= smoothstep(pos / overlapOut)
}
if (hasAfter && pos >= size - overlapOut) {
weight *= smoothstep((size - 1 - pos) / overlapOut)
}
return Math.max(0.0001, weight)
}
为什么返回值留了一个非常小的下限?因为融合时需要计算“加权颜色总和 / 权重总和”,一旦某个输出位置的总权重为零就会产生未定义的像素。正式版本通常应通过覆盖验证和对有效邻接关系的判断保证归一化分母非零;这段下限只是示意保护,不应该掩盖规划错误。
融合逻辑还要考虑颜色空间。直接对经过伽马编码的 RGB 数值做平均,与在适当的线性光空间进行融合,视觉效果并不完全相同。如果目标是照片展示,可以比较不同方案的色彩过渡;若面向专业影像或科学图像,必须固定色彩空间、Alpha 处理和位深。不同设备上的输出一致性也不能只靠“我肉眼看着不错”判断。
四、真正消耗内存的往往不是单块推理,而是同时留住太多中间结果
源图 1200×800 本身不算大,为什么做四倍超分仍然需要控制内存?因为一张 4800×3200 的 RGBA8888 输出图光像素缓冲就约 61.4 MB(十进制),还没算输入解码、模型张量、临时输出、融合权重、编码与 UI 预览。如果为了融合方便再分配一张 Float32 的四通道累计图,开销会明显增大;若同时把 24 块全部留在内存里,峰值很容易失控。
我最终改成分带写入。核心原则是:推理完成一个瓦片,就把它交给融合器;当一个输出条带的全部邻接贡献已经到齐,立即归一化、编码或写入,并释放临时数组。内存占用随“活动条带高度”和“并发瓦片数”变化,而不是随整张输出和总瓦片数一起无限增长。
第三段代码是内存水位门控,MemorySampler 由设备诊断或 Native 统计层实现,不是 Image Kit 的直接接口。为了让示例可读,它在即将启动新瓦片之前询问当前占用,超出阈值就暂缓并发或走更保守的队列。384 MB 是本轮 Demo 自己配置的预算,不是 HarmonyOS 为应用统一规定的内存上限。
interface MemorySampler {
currentMegaBytes(): Promise<number>
}
class TileBudgetGate {
private readonly guardMB: number = 384
constructor(private sampler: MemorySampler) {}
async canSchedule(nextEstimatedMB: number): Promise<boolean> {
const used = await this.sampler.currentMegaBytes()
const projected = used + nextEstimatedMB
if (projected > this.guardMB) {
console.warn(`TileBlend memory guard: ${projected}MB`)
return false
}
return true
}
}
这里的 nextEstimatedMB 不能随便写成“一个瓦片 256×256×4 字节”。推理阶段可能同时存在模型输入输出张量、内部工作区、对齐缓冲、转换后的像素格式等内存。正确做法是在设备上记录多个图片尺寸、不同并发量对应的内存峰值,再给下一块估算值留出保守余量。若取样指标无法准确反映 Native 侧的内存,门控值就只能作为参考,不能把它当作硬保证。

DevEco Studio 拟真图展示了 TileTaskPage.ets 与 TilePlanner.ets 等工程文件,右侧页面正在处理第 15/24 块,进度约 63%,峰值内存显示为 318 MB / 384 MB。日志区把开始任务、边界融合和内存水位放在一起,便于排查“为什么页面没有继续处理下一块”。同样,这张图用于展示工程诊断的布局方式,并不表示已在所示设备上取得该内存数值。
五、取消与失败恢复,比正常跑完更能暴露资源泄漏
分块任务如果始终顺利结束,你可能不会发现内存问题。真正麻烦的是处理到第十五块时用户点了“停止”、上一块模型抛异常、系统回收 UI、图片源被更换。很多 Demo 用一个布尔变量表示取消,却没有定义正在执行的 Native 推理和临时 PixelMap 到底由谁释放。
我会把资源所有权写得很清楚。图片源由任务协调器创建并在整个任务结束时释放;单块的输入张量、推理输出和融合临时缓冲由瓦片执行器负责;页面只保存展示所需的 URI 或缩略 PixelMap,不持有完整结果的多份副本。这样才能在异常路径里判断什么可以立即释放,什么要等异步操作停止之后释放。
第四段代码是简化的资源封装。TileResource 是项目定义的资源接口,体现 try/finally 的使用方式,避免某个异步步骤失败后遗留上一次瓦片的缓存对象。
interface TileResource {
release(): Promise<void>
}
interface TileEngine {
infer(tile: TileRect): Promise<TileResource>
}
interface TileSink {
merge(tile: TileRect, result: TileResource): Promise<void>
}
async function processTile(tile: TileRect,
engine: TileEngine, sink: TileSink): Promise<void> {
let output: TileResource | undefined
try {
output = await engine.infer(tile)
await sink.merge(tile, output)
console.info(`TileBlend tile ${tile.id}/24 done`)
} finally {
if (output) {
await output.release()
}
}
}
这段示例仍有需要在实际工程补完的地方:如果 infer() 本身部分分配资源后失败,资源所有权应由 infer() 内部处理;如果 merge() 把异步读取延迟到了函数返回之后,就不能在这里提前释放结果。签名里的 Promise 必须意味着“该资源在本次写入中已经使用完毕”,否则生命周期依然错。对外部 PixelMap 等原生资源的释放,应按 Image Kit API 的实际规范执行。

运行截图中,输入 1200×800、输出 4800×3200,重叠输入像素 24,当前处理进度 15/24,峰值使用 318 MB,预算 384 MB。这张图重要的不是 63% 进度条,而是它把分块计划、正在写入的瓦片以及资源预算放在一起,使页面卡顿时能区分“还在推理”“正在融合”和“因水位门控暂停”。
六、接缝评分要固定定义,别用一个漂亮数字自我安慰
最终的验收页我增加了“接缝评分”,示意值是优化前 7.1,优化后 1.8,约定数值越小表示过渡越自然。这个值不能直接被写成模型的通用行业指标,因为接缝评分完全取决于采样区域、比较方法和基准图。若没有固定算法和数据集,两次数字可能只是在比较两张不同的图。
实际项目可以从两个角度评价。第一个是几何正确性:把棋盘格贴在全图里,逐块检查无缺口、无错位、无重复列、无黑边。第二个是视觉边界连续性:沿瓦片接合线采样亮度与颜色梯度,跟接合线两侧同类纹理区域比较,判断是否出现异常突变。更严格的场景还应引入有参考图像的感知指标,并把超分模型版本和测试设备写入测试报告。

图上的 COMPLETED、24/24、318 MB、384 MB 与 7.1 → 1.8 是同一轮演示参数。验收结果分为三项:边缘拼接、内存水位、资源释放。只有三个条件都过关才算完成。注意“资源已释放”应由真实资源跟踪与复测数据证明,不能因为页面显示了 YES 就推断某个 Native 对象已经消失。
我还会安排几种故障注入:把源图改成不能被瓦片尺寸整除的长条形,检查边缘补齐;把 overlap 改为 0,验证接缝评分如何变化;人为降低内存预算,看任务是否排队而不是突然崩溃;在第 15 块结束前点击停止,检查已经提交的条带和临时资源是否全部回收;最后再把应用切后台返回,确认不会沿用上一轮旧的 Tile ID 继续写入新任务。
这些测试看起来繁琐,却能排除一类很常见的错误:主路径正常时每个瓦片都能生成,但异常恢复后,新任务仍然拿到旧任务残留的结果。为避免这种跨任务污染,输出目录、临时文件和日志都应绑定 taskId=tb_20261009_05,新任务创建新的命名空间,不要共用一个叫 result.tmp 的固定路径。
1. 为什么一张纯色测试图有时比山水照片更管用
山水照片适合说明用户能否察觉接缝,却不一定适合定位接缝产生的原因。我通常先用三类合成输入做回归:第一类是从左到右平滑变化的灰度渐变,用来观察瓦片连接处有没有突然变亮或变暗;第二类是贯穿整幅图的斜直线,用来发现瓦片坐标是否偏移了一两个像素;第三类是交错棋盘格,用来验证最右边和最下边的裁剪是否正确。在这三类基础用例跑通之前,我不会因为一张真实照片看起来不错就把算法标成完成。
测试时还要记录每个瓦片的源图矩形、推理输入尺寸、有效输出矩形和实际写入范围。尤其是最右下角的那个瓦片:它只有一部分是图片里的真实像素,其余用于满足模型固定输入尺寸的填充区,若在输出侧把填充区当作有效像素写回,就可能出现重复边缘甚至内容被拉伸。把这几个矩形通过可视化调试层描出来,比在日志里只写 tile 24 done 更容易发现问题。
如果融合后仍出现细线,我会先关闭模型推理,用最简单的双线性放大替代模型输出。若细线依然存在,说明坐标、权重或色彩转换更值得怀疑;若只有模型输出存在,才进一步排查模型边界上下文和填充策略。这个二分法很节省时间,因为它避免了每次视觉异常都立即去调整模型参数,最终把原本简单的数组索引问题变成昂贵的训练问题。
2. 内存监控不能只写一个峰值
单次任务的峰值是一个有用数字,但还不够。我更在意相同图片重复运行十次之后,任务结束的资源基线是否持续上升。例如第一次运行后占用回落,第二次比第一次高一点,第三次又高一点,即使每次峰值都低于 384 MB,也说明可能存在泄漏或长时间持有的对象。对 Native 内存、图形缓冲和模型推理上下文,必须根据其真实资源拥有者分别检查,不能期望一条 ArkTS 的垃圾回收日志覆盖全部对象。
另一个指标是短时间峰值。假设采样器每秒读取一次,而某块推理只在几十毫秒里出现大内存突增,采样日志可能完全错过这个尖峰。所以内存门控首先依赖保守估算和严格并发上限,再用实际采样反馈校正估算值;不能因为屏幕显示 318 MB 就断言在整个任务过程中没有更高的瞬时占用。想做正式性能报告,必须说明采样频率、指标来源、缓存预热条件和设备负载。
我还会给取消任务设置明确的释放验收:停止之后,不再有新的瓦片被调度;已经启动的推理得到取消信号或自然结束后回收资源;临时输出不会被下一任务复用;预览页面关闭后只保留符合缓存策略的数据。若其中一步失败,即使结果图已写入磁盘,也不能把这次执行算作完整通过。图像处理的稳定性测试,需要同时覆盖输出质量、时间、内存与异常路径四个维度。
3. 在不同设备上,应该保留策略而不是固守参数
256/24 是本轮演示使用的一组参数,不是推荐给所有设备的固定最优配置。低内存设备可能需要更小的并发窗口;模型在某些加速器上对固定形状更友好,可能更适合较大的块;长图或接近极端宽高比的图片,还可能需要不同的条带扫描顺序。我更愿意把瓦片规划、融合函数和模型执行器做成三个相对独立的模块,然后在设备能力评估后选策略。
这样的抽象有一个实际好处:当超分模型需要更换时,图像坐标和生命周期控制不必全部重写。新模型只需要约定输入填充、输出有效区域、倍率与像素格式,就能复用之前的拼接验收数据。越是资源受限的端侧工程,越应该把算法结果和调度行为分开测量;否则你看到性能变差,很难判断是模型计算变慢、数据搬运增多,还是融合缓冲一直没有释放。
七、工程复盘:把内存预算放进算法设计,而不是最后收尾
这次 TileBlend 的判断有三个层次。第一层是正确的分块覆盖规则,不能因为简单对齐最后一个瓦片就制造很宽的重叠带。第二层是按实际邻接关系做边缘融合,保证几何位置和视觉过渡同时成立。第三层是把内存门控与资源回收放入调度协议,别等到模型推理全部写完才发现中间缓冲占用太高。
在更大的图像上,我会优先调整条带高度与并发数,其次再考虑修改 tileSize;盲目把瓦片调得更小会增加推理调用次数,也可能提高边界融合的比例。若设备支持高效的专用加速路径,应该结合模型输入限制、像素格式和数据搬运成本做实测,而不是认定某一组参数适用所有 HarmonyOS 机型。
现在再看到“超分成功”四个字,我更关心它背后的证据:边界有没有黑线,资源有没有释放,峰值有没有超过项目预算,输出有没有遗漏最右下角的像素。图像处理不是只要生成了一张大图就算交付;真正可用的结果应该在看得到的接缝和看不到的内存使用上同时经得起检查。
参考资料与使用边界:Image Kit 的 PixelMap 操作与释放说明可参考 https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/image-transformation 和 https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/pixelmap-c 。文中的分块推理接口、融合服务和内存采样器由应用自定义实现,不属于这两份官方指南的现成封装。
更多推荐




所有评论(0)