HarmonyOS 7 PixelMap:透明PNG超分Alpha去预乘与边缘去污
一、模型没有把火箭贴纸放大错,错的是我们交给它的 RGB
AlphaSRLab 的需求很普通:把运营上传的透明 PNG 贴纸从 768×768 放大到 1536×1536。第一版只做三件事——PixelMap 解码、2× 超分、PNG 回写。照片样本都正常,换成 rocket_sticker_rgba.png 后,火焰和白色描边外面却多了一圈灰黑色光晕。把结果放在深色底上更明显,像图标被脏橡皮擦过。
我最初把问题归给模型,调锐化、换插值核、降低并发都没有用。后来直接查看 Alpha 为 1~31 的边缘像素,才发现 PixelMap 中的 RGB 已按 Alpha 预乘:一个原本接近白色、透明度只有 8/255 的像素,通道值只剩个位数。模型把这些“暗色边缘”当成真实纹理放大,重新合成时自然形成黑圈。

本次 Demo 固定项目 AlphaSRLab、页面 StickerSuperResPage(界面标题“透明贴纸超分”)、任务 SR-A2210,时间 22:18,generation 7。输入文件 rocket_sticker_rgba.png,2× 输出,边缘带像素 18,426。旧链路的 halo score 为 0.087;修复后经过 PREMUL → STRAIGHT → PREMUL,分数降到 0.006,透明区脏 RGB 为 0,最终状态 READY。
二、透明像素不是“没有颜色”,而是颜色与覆盖率绑在一起
这次最重要的判断,是把 RGB 内容和 Alpha 覆盖率拆开。预乘格式满足 storedRGB = straightRGB × alpha。它适合合成,也能避免插值越界,但超分模型通常按普通 RGB 图像训练;若直接把预乘值送入模型,半透明白边会被解释成深灰。
完全透明像素又是另一类风险。Alpha 为 0 时无法反推 straight RGB,原文件里可能还残留编辑器导出的红、绿噪声。若简单除以一个很小的 epsilon,这些隐藏颜色会被放大,最后从透明区渗进边界。我的处理是:Alpha 为 0 的像素先置零;Alpha 在 1~31 的薄边缘,从最近的可靠前景颜色做受限传播;其余像素正常去预乘。
工程目录没有把算法都塞进页面:
AlphaSRLab/
├── entry/src/main/ets/pages/StickerSuperResPage.ets
├── entry/src/main/ets/sr/AlphaPipeline.ets
├── entry/src/main/ets/sr/AlphaMath.ets
├── entry/src/main/ets/sr/EdgeColorDecontaminate.ets
├── entry/src/main/ets/sr/SuperResCommitter.ets
└── entry/src/main/ets/sr/SrAuditReport.ets
页面只显示 SrSnapshot,PixelMap、RGBA 缓冲区和模型会话由管线持有。这样页面退出、任务取消或连续选择新文件时,资源释放不会和 UI 生命周期互相打架。
三、先把 PixelMap 读成可审计的 RGBA 缓冲区
第一段代码解决“看见 PNG 有透明通道,却不知道当前像素语义”的问题。管线读取 ImageInfo,明确要求 RGBA_8888,再把数据复制到本轮 generation 私有缓冲区。输入不是 768×768、没有 Alpha 或行跨度不符合预期时,直接返回 INPUT_CONTRACT_MISMATCH,不会进入模型。
// AlphaPipeline.ets
async decode(source: image.ImageSource, generation: number): Promise<RgbaFrame> {
const pixelMap = await source.createPixelMap({
desiredSize: { width: 768, height: 768 },
desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
editable: true
})
try {
this.assertCurrent(generation)
const info = await pixelMap.getImageInfo()
if (info.size.width !== 768 || info.size.height !== 768) {
throw new Error('INPUT_CONTRACT_MISMATCH')
}
const buffer = new ArrayBuffer(768 * 768 * 4)
await pixelMap.readPixelsToBuffer(buffer)
return { width: 768, height: 768, bytes: new Uint8Array(buffer) }
} finally {
pixelMap.release()
}
}
这里没有把 PixelMap 跨阶段传递。读取结束就释放源对象,后续只认 RgbaFrame;这会多一次受控复制,却让 94.8 MB 的峰值内存可解释,也避免旧 PixelMap 被下一次任务误用。assertCurrent 放在异步解码之后,因为用户可能在系统解码期间重新选图,晚到结果必须在分配推理资源前终止。
四、去预乘不能只写一行除法
第二段代码处理实际的 Alpha 数学。unpremultiply 对可靠像素恢复 straight RGB;极低 Alpha 区不直接相除,而是记录到 edge mask。decontaminateEdge 只从 Alpha≥32 的近邻采样,最多传播 3 像素,避免把火箭主体颜色跨越透明空洞扩散到另一侧。
// AlphaMath.ets + EdgeColorDecontaminate.ets
export function toStraight(frame: RgbaFrame): StraightFrame {
const out = new Uint8Array(frame.bytes.length)
const edgeMask = new Uint8Array(frame.width * frame.height)
for (let p = 0, i = 0; i < frame.bytes.length; p++, i += 4) {
const a = frame.bytes[i + 3]
out[i + 3] = a
if (a === 0) {
out[i] = out[i + 1] = out[i + 2] = 0
} else if (a < 32) {
edgeMask[p] = 1
} else {
out[i] = Math.min(255, Math.round(frame.bytes[i] * 255 / a))
out[i + 1] = Math.min(255, Math.round(frame.bytes[i + 1] * 255 / a))
out[i + 2] = Math.min(255, Math.round(frame.bytes[i + 2] * 255 / a))
}
}
decontaminateEdge(out, edgeMask, frame.width, frame.height, 3)
return { ...frame, bytes: out, edgeMask }
}
这个阈值不是通用标准,而是 rocket_sticker_rgba.png 与当前模型的验收值。真正需要固化的是测试:Alpha 为 0 时 RGB 必须为 0;1~31 的像素不能产生超过前景邻域的异常色;往黑底和白底各合成一次,边缘误差都要低于 0.01。只有在两种底色上同时通过,才能排除“只对某个预览背景看起来正常”。
五、RGB 和 Alpha 要走两条倍率一致、插值目的不同的路径
模型只接收 straight RGB。Alpha 不进入模型,而是用 area-preserving 的双线性路径放大;模型输出 1536×1536 后,再按同一坐标网格合成。若两条路径的 padding、crop 或取整不同,即使颜色正确也会出现一像素亮缝。
第三段代码解决提交原子性。只有 RGB、Alpha、边缘审计和 PNG 打包都属于当前 generation,结果才写回 PixelMap。写入前重新预乘,并把 Alpha=0 的 RGB 再清零;失败时释放输出 PixelMap,上一张成功结果仍留在页面。
// SuperResCommitter.ets
async commit(job: SrJob, rgb: Uint8Array, alpha: Uint8Array): Promise<void> {
this.assertCurrent(job.generation)
const rgba = repremultiply(rgb, alpha, 1536, 1536)
const report = auditTransparentEdge(rgba, alpha)
if (report.haloScore > 0.01 || report.dirtyTransparentPixels !== 0) {
throw new Error('ALPHA_EDGE_AUDIT_FAILED')
}
const output = await image.createPixelMap(rgba.buffer, {
size: { width: 1536, height: 1536 },
pixelFormat: image.PixelMapFormat.RGBA_8888,
editable: true
})
try {
this.assertCurrent(job.generation)
this.resultStore.replace(job.taskId, output, report)
job.state = 'READY'
} catch (error) {
output.release()
throw error
}
}
resultStore.replace 接管成功对象的所有权,页面销毁只解除观察,不释放仍被结果库使用的 PixelMap。重复点击“开始超分”会把 generation 加一,旧任务可以完成计算和收口日志,却无权覆盖新结果。

六、真正有用的日志,是能解释哪一步把边缘弄脏
坏链路 generation 6 的日志停在 RECOMPOSITING,错误为 ALPHA_PREMULTIPLY_SKIPPED:
22:18:03.114 AlphaSR SR-A2210 DECODED size=768x768 alpha=PREMUL
22:18:03.146 AlphaSR SR-A2210 EDGE_SCAN pixels=18426 lowAlpha=6210
22:18:03.321 AlphaSR SR-A2210 FAILED generation=6 halo=0.087 error=ALPHA_PREMULTIPLY_SKIPPED
22:18:03.607 AlphaSR SR-A2210 READY generation=7 output=1536x1536 halo=0.006 dirty=0 peak=94.8MB
修复后的阶段是 DECODED → UNPREMULTIPLIED → UPSCALING → RECOMPOSITING → READY。总耗时 286 ms,其中解码 42 ms、去预乘与边缘去污 29 ms、模型 171 ms、Alpha 放大与合成 18 ms、打包 26 ms。这里的数字只对应当前样本和模拟器配置,用来判断回归,不宣称代表所有设备。
七、结果页保留失败代次,而不是只展示一张漂亮图
手机页上方展示 generation 7:任务 SR-A2210、输入 768×768、输出 1536×1536、2×、边缘像素 18,426、halo 0.006、透明脏点 0、峰值 94.8 MB、状态 READY。下方保留 generation 6 的失败卡,明确显示 halo 0.087 和 ALPHA_PREMULTIPLY_SKIPPED。

我刻意没有在运行图里放多张前后对比图,避免把技术页面做成营销海报。“深色底预览”和“浅色底预览”是同一画布的背景切换按钮,切换时只改变合成底色,不重新跑模型。这样验收的是同一个 RGBA 结果,而不是两份来源不明的截图。
八、边界:这条链路修的是透明边缘,不是所有超分伪影
这套处理能挡住预乘 Alpha、隐藏脏 RGB 和倍率对齐造成的光晕,却不会自动修复模型本身的过锐化、纹理幻觉或文字变形。对于半透明烟雾、玻璃和毛发,最近邻颜色传播也可能破坏真实渐变,应使用专门的 Alpha-aware 模型或保留高质量 matte。
另一个边界是格式。本文只验收 RGBA_8888 PNG;广色域、HDR、APNG 和其他像素格式需要独立契约,不能在代码里静默转成同一路径。发布前还应在至少黑、白、品牌主色三种背景上合成,并保留 halo score 与透明脏点报告。
这次看起来是在消除一圈黑边,实际修正的是数据语义:PixelMap 负责像素载体,Alpha 管覆盖率,模型只放大可解释的 straight RGB,提交器再恢复可合成格式。把这几层说清楚,透明贴纸才不会因为“解码成功、模型成功、写盘成功”而带着错误悄悄上线。
更多推荐



所有评论(0)