前言
证件照换底色是个很具体的需求:报名系统要红底,签证要白底,工卡要蓝底,而手机里只有同一张正面照。
主体分割这个能力之前已经把接口跑通了,这篇只做一件事:把 mask 变成能用的合成结果,并且把三种边缘处理方式放在一起比数据。
结论先放这里:模型给的 alpha 已经是软的,再羽化几乎没有收益;真正能改变过渡带宽的是阈值收缩。但在人像这种"主体和背景对比强"的图上,三种做法肉眼看不出差别——所以别默认加滤镜,先测。
清单
1. 素材与目标
一张 640×640 的正面人像,灰色棚拍背景。目标:输出红底(#D9001B)、蓝底(#438EDB)、白底三张,加两个边缘对照组。
2. 分割
const vi: subjectSegmentation.VisionInfo = { pixelMap: pm };
const res = await subjectSegmentation.doSegmentation(vi, {});
const mask: Int32Array = res.fullSubject.mattingList;配置传空对象即可。这次不需要 enableSubjectForegroundImage——我们要的是 mask 本身,前景图是给"只想直接拿抠好的图"的场景用的。
3. 读 mask
| 项 | 实测值 |
|---|---|
mask.length |
409600,正好等于 640×640 |
| 取值种类 | 256 |
| 过渡像素(0 < a < 255) | 12703,占 3.10% |
三个要点:
- 类型是
Int32Array,不是Uint8Array,但值域是 0~255。当字节流处理会读错。 - 逐像素,一一对应行优先排列,不需要额外缩放。
- 256 种取值 + 3.1% 的过渡带,说明它给的是软 alpha。这一点决定了后面所有事。
4. 读原始像素
const buf: ArrayBuffer = new ArrayBuffer(w * h * 4);
await pm.readPixelsToBuffer(buf);
const p: Uint8Array = new Uint8Array(buf);RGBA_8888,四个字节一像素。
5. 合成(over 运算)
const t: number = alpha[i] / 255;
d[i * 4] = Math.round(bk.r * (1 - t) + p[i * 4] * t);
d[i * 4 + 1] = Math.round(bk.g * (1 - t) + p[i * 4 + 1] * t);
d[i * 4 + 2] = Math.round(bk.b * (1 - t) + p[i * 4 + 2] * t);
d[i * 4 + 3] = 255;注意最后一位直接写 255:底色是不透明的,输出不需要 alpha。留着 alpha 的话,白底图放到深色界面上会透出界面颜色。
6. 从 buffer 造回 PixelMap
const opts: image.InitializationOptions = {
size: { width: w, height: h },
srcPixelFormat: image.PixelMapFormat.RGBA_8888,
pixelFormat: image.PixelMapFormat.RGBA_8888,
editable: true
};
return await image.createPixelMap(out, opts);srcPixelFormat 必须显式给。不给的话缓冲区会被按默认格式解释,颜色直接错掉,而且不报错——这是最容易踩的一条。
7. 三种边缘处理,比数据
| 做法 | 公式 | 过渡像素 | 占比 |
|---|---|---|---|
| 直接用 | a |
12703 | 3.10% |
| 3×3 均值羽化 | mean(a) |
13950 | 3.41% |
| 阈值收缩 | clamp((a-128)*2.2+128) |
2391 | 0.58% |
三条结论:
- 羽化让过渡带变宽了(3.10% → 3.41%),方向反了。模型输出的边缘本来就只有一两个像素宽,再卷积一次只会把边界抹开。
- 阈值收缩把过渡带压窄到 0.58%,是原来的 1/5.3。这才是"想让边缘利落"该用的手段。
- 但在这张人像上,四张成图肉眼几乎分不出来。原因是主体(深色衣服、黑头发)和灰底对比足够强,边缘本来就没有可见的残留色。
所以顺序应该是:先看有没有 fringe,再决定要不要处理。收缩不是免费的——它会把本来正确的半透明像素强行推到 0 或 255,头发丝这种细节会变脆。
8. 展示
四列并排:硬边红底、羽化红底、羽化蓝底、羽化白底,再加一列收缩红底做对照。白底最容易暴露问题,因为残留的是浅灰,和红底比对比度低。
耗时
| 环节 | 实测 |
|---|---|
分割 doSegmentation |
986 ~ 1112ms |
| 单张合成(640×640,逐像素 JS 循环) | 231ms |
| 三张连做 | 674ms |
231ms 是纯 ArkTS 循环跑出来的,41 万次迭代。分辨率上去之后这个数会线性增长,但我没有测更高分辨率的实际值,所以不给公式。真要上生产,两条路:把循环挪到 worker,或者用 Canvas 的图层混合代替手写合成。
为什么不用现成的前景图
SegmentationConfig 里有个 enableSubjectForegroundImage,开了会直接返回一张抠好的前景 PixelMap。看起来正好是换底色要的东西,但这次没用它,三个理由:
- 拿到的是一张带 alpha 的图,还要自己乘一遍底色才能得到不透明成品,省不掉那 41 万次循环。
- 看不到中间量。换底色出问题(毛边、透底、边缘残留原背景色)时,需要知道是 mask 的问题还是合成的问题。自己拿 mask 才能把这两步分开测。
- 边缘策略要改 mask。阈值收缩、局部腐蚀这些操作只能作用在 alpha 上,拿到成品图就只能整体处理,会把头发丝一起削掉。
所以规则是:只要需要对边缘做主,就自己拿 mattingList;只是想去掉背景、不关心边缘质量的,用现成前景图更省事。
阈值收缩那一步在做什么
let v: number = (raw[i] - 128) * 2.2 + 128;
v = v < 0 ? 0 : (v > 255 ? 255 : v);这是围绕 128 做对比度拉伸:把 128 以下压到 0,128 以上抬到 255,中间只有 1/2.2 的宽度还保留过渡。效果是过渡带从 3.10% 缩到 0.58%,边界更"硬"。
代价有两个方向。一是原来正确的半透明像素被强行推到端点,头发丝、宠物毛这类地方会断;二是 128 这个中心点是假设,如果模型对某类主体给的 alpha 整体偏低(比如半透明衣物),以 128 为中心收缩会直接把主体吃掉一块。
真要上线,这个中心点和倍率都得做成可调参数,并且在灰底、白底、复杂背景三类样本上各测一遍再定默认值。这次只有一张灰底人像,不足以定参数。
还差的几步
这篇只覆盖到"能换底色"。离一张能交的证件照还差:
- 尺寸与头部占比裁剪(一寸/二寸有明确头部高度比例,靠
subjectRectangle定位) - 白底的光晕检查(浅灰背景最容易残留)
- 打印色彩管理(RGB 转 CMYK 的偏色,端侧不管这个)
这三项本轮没做,不猜结论。
真机数据
MatePad Pro(HarmonyOS 7 / API 26,序列号略),样本为 640×640 灰底人像。


总结
换底色这件事,接口只提供了"哪里是人",剩下的 alpha 合成、格式声明、边缘策略全是自己的。清单里最容易翻车的是第 6 步的 srcPixelFormat,错了不报错只是颜色不对。
适合证件照、工卡、电商主图换背景这类"主体明确、背景单一"的活。发丝、毛皮、半透明衣物这类边缘,软 alpha 也救不了,别指望调参解决。
💡 一句话:模型给的 alpha 已经是软的,再羽化只会把边缘抹糊;想利落该用阈值收缩,但先确认图上真的有毛边。
参考文献
- 主体分割(开发指南):https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-subject-segmentation
- subjectSegmentation API 参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-subjectsegmentation-api
- Core Vision Kit 错误码:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/errorcode-core-vision
- Core Vision Kit 目录:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-kit-guide