智绘鸿蒙 · 如 7 而至端侧 AI 能力实战手记

红底蓝底一秒切换

智绘鸿蒙·如 7 而至

前言

证件照换底色是个很具体的需求:报名系统要红底,签证要白底,工卡要蓝底,而手机里只有同一张正面照。

主体分割这个能力之前已经把接口跑通了,这篇只做一件事:把 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%

三条结论:

  1. 羽化让过渡带变宽了(3.10% → 3.41%),方向反了。模型输出的边缘本来就只有一两个像素宽,再卷积一次只会把边界抹开。
  2. 阈值收缩把过渡带压窄到 0.58%,是原来的 1/5.3。这才是"想让边缘利落"该用的手段。
  3. 但在这张人像上,四张成图肉眼几乎分不出来。原因是主体(深色衣服、黑头发)和灰底对比足够强,边缘本来就没有可见的残留色。

所以顺序应该是:先看有没有 fringe,再决定要不要处理。收缩不是免费的——它会把本来正确的半透明像素强行推到 0 或 255,头发丝这种细节会变脆。

8. 展示

四列并排:硬边红底、羽化红底、羽化蓝底、羽化白底,再加一列收缩红底做对照。白底最容易暴露问题,因为残留的是浅灰,和红底比对比度低。

耗时

环节 实测
分割 doSegmentation 986 ~ 1112ms
单张合成(640×640,逐像素 JS 循环) 231ms
三张连做 674ms

231ms 是纯 ArkTS 循环跑出来的,41 万次迭代。分辨率上去之后这个数会线性增长,但我没有测更高分辨率的实际值,所以不给公式。真要上生产,两条路:把循环挪到 worker,或者用 Canvas 的图层混合代替手写合成。

为什么不用现成的前景图

SegmentationConfig 里有个 enableSubjectForegroundImage,开了会直接返回一张抠好的前景 PixelMap。看起来正好是换底色要的东西,但这次没用它,三个理由:

  1. 拿到的是一张带 alpha 的图,还要自己乘一遍底色才能得到不透明成品,省不掉那 41 万次循环。
  2. 看不到中间量。换底色出问题(毛边、透底、边缘残留原背景色)时,需要知道是 mask 的问题还是合成的问题。自己拿 mask 才能把这两步分开测。
  3. 边缘策略要改 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 已经是软的,再羽化只会把边缘抹糊;想利落该用阈值收缩,但先确认图上真的有毛边。

参考文献

  1. 主体分割(开发指南):https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-subject-segmentation
  2. subjectSegmentation API 参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-subjectsegmentation-api
  3. Core Vision Kit 错误码:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/errorcode-core-vision
  4. Core Vision Kit 目录:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/core-vision-kit-guide