把 withoutBG 的开放权重模型包成了一个纯 Go 库:github.com/lib-x/withoutbg-go 。和上一个 PP-OCRv6 的封装一样,用 pure-onnx 通过 purego 调 ONNX Runtime,不需要 CGO,也不用装 PyTorch。

这个模型做的是背景去除:输入一张 RGB 照片,输出一张 alpha 蒙版,把蒙版挂到原图的 alpha 通道上就是抠图。模型仓库是 withoutbg/withoutbg-openweights-onnx ,455MB,Apache-2.0。

这篇记录的重点和上一篇不同:上次的第一手来源是模型自带的 inference.yml,这次是模型自带的 sidecar JSON;上次的难点是动态形状和字典映射,这次的难点是几何(letterbox 怎么缩放、蒙版怎么裁回去),踩错一步人物边缘就会被啃掉一圈。

先看术语:抠图和它的度量

没接触过图像处理也没关系,先看这几个词。

抠图(matting)和分割(segmentation)不是一回事。 分割给每个像素一个"是不是前景"的二值标签,抠图给的是 0 到 1 之间的连续值,叫 alpha 蒙版。连续值是为了处理半透明和边缘过渡:发丝、玻璃、烟雾这类区域,一个像素里可能一半是前景一半是背景,硬二值会切出锯齿。这个模型输出的就是连续 alpha。

alpha 合成,把抠图结果和任意背景拼起来用的公式:

$$ C = \alpha F + (1-\alpha) B $$

$F$ 是前景色,$B$ 是新背景,$\alpha$ 是蒙版值。$\alpha=1$ 保留原像素,$\alpha=0$ 完全换成背景,中间值按比例混合。库里 Remove() 返回的就是"原图颜色 + 新 alpha 通道"。

letterbox:把任意比例的图塞进正方形输入框的标准做法,等比缩放让长边贴到目标尺寸,空出来的地方填固定颜色。和直接拉伸成正方形的区别是:拉伸会改变物体形状,模型会把形变当成内容。

NCHW:张量内存布局,依次是批、通道、高、宽。这个模型的输入是 [1, 3, 448, 448]

归一化:把 0 到 255 的像素值映射到模型训练时的数值范围。这个模型只做 $x/255$ 映射到 $[0,1]$,不减均值除标准差,和很多视觉模型不一样,改错了结果会明显变差。

两个评估指标(后面验证用)。前景 IoU 衡量"判为前景的区域"重叠得怎么样:

$$ \mathrm{IoU} = \frac{|A \cap B|}{|A \cup B|} $$

平均绝对误差(MAE)衡量蒙版整体差多少,$N$ 是像素数:

$$ \mathrm{MAE} = \frac{1}{N}\sum_i |\hat\alpha_i - \alpha_i| $$

IoU 对边缘敏感(边缘像素在阈值附近来回跳),MAE 对整体偏移敏感。两个一起看才说明问题:IoU 高但 MAE 高,多半是软边区域有系统性偏差。

模型和配套文件从哪来

模型仓库 下载两个文件,必须成对使用

文件 大小 说明
withoutbg-open-weights.onnx 454,497,726 字节 推理图:DepthAnythingV2 深度分支 + ConvNeXt 抠图头
withoutbg-open-weights.onnx.json 721 字节 sidecar:画布尺寸、张量名、形状、精度、sha256

sidecar 是这次的第一手来源。 模型仓库的 README 直接写着"先读 sidecar,它是 canvas_size、输入输出名、精度、模型版本和 SHA256 的权威来源"。内容不长,值得完整看一遍:

{
  "canvas_size": 448,
  "matting_input_size": 448,
  "opset_version": 18,
  "precision": "fp32",
  "variant": "oss",
  "depth_variant": "dav2s",
  "convnext_size": "base",
  "model_version": "10.0.0",
  "input_name": "rgb",
  "output_name": "alpha",
  "input_dtype": "float32",
  "input_shape": [1, 3, 448, 448],
  "output_shape": [1, 1, 448, 448],
  "size_mb": 454.5,
  "sha256": "29930e48e9d5ecc56d6486c53c35a4c1470566c2a3359fa180b08c8d3c34ef0f",
  "mae": 3.67e-08,
  "max_abs": 3.0e-05
}

这几行各自决定了什么:

  • canvas_size: 448 决定所有几何:输入画布、letterbox 的目标、输出蒙版的尺寸。库里把它当 Config.CanvasSize 的默认值,sidecar 缺了这个字段直接报错,不猜
  • input_name / output_namergb / alpha,建会话时用,并和图里的张量名核对;名字对不上说明模型和 sidecar 不是一对
  • sha256 可以核对下载完整性:sha256sum withoutbg-open-weights.onnx,实测和声明一致。库把它做成可选项(校验要读完 455MB)
  • mae / max_abs 是导出质量指标,官方声明 ONNX 导出和参考实现的平均误差是 3.67e-08,属于浮点噪声级别;它只说明"导出没走样",不代表模型本身的精度

模型没有额外的配置文件要读,预处理和后处理规则写在 README 的 “Preprocessing (required)” 和 “Postprocessing (required)” 两节里,一共八步,下面逐条走。

拿到一个没见过的 ONNX 模型,怎么入手

和上一篇一样的顺序,只是每一步要问的东西不同。

第一步,把图读全。 用 Python 的 onnx 包读张量名、形状、dtype 和末端算子。这个模型读出来的结果和 sidecar 完全一致:输入 rgb [1, 3, 448, 448] float32,输出 alpha [1, 1, 448, 448] float32,opset 18。

和上一篇的模型有个重要区别:这个模型的形状是固定的,没有动态维。好处是不用做形状探测,坏处是图省事的空间也没有:输入尺寸写死在图里,图省事直接喂原图会直接报错,必须自己实现 letterbox。判断标准就是拿 sidecar 和图对一遍,形状、名字、dtype 三项都对上再往下走。

第二步,预处理去文档和 sidecar 里抄。 这个模型的处理步骤文档写得很明确(转 RGB、读 canvas_size、长边缩放、贴黑底、归一化转 CHW),比上一篇的 OCR 模型省事得多。判断标准:先看归一化是不是只有 $x/255$(如果误用 ImageNet 的均值方差,蒙版会整体偏掉),再看通道顺序是 RGB(输入张量名就叫 rgb,算是明示)。

第三步,输出语义。 输出是 [0,1] 的连续 alpha,不是 logits,也不需要 softmax 或 sigmoid 之外的解码。判断方法:打印一行看取值范围,如果出现负数或者大于 1,说明要么读错了张量,要么模型版本不对。

第四步,几何。 这一步是这个模型真正的难点,也是最容易错的地方:letterbox 的缩放和填充、蒙版的裁剪和还原,四处几何必须首尾一致。判断方法放在下一节的流程里。

第五步,找参考结果做 golden 对比。 模型仓库给了三张示例图,是三格拼接的(原图 / 抠图 / 蒙版),把第 1 格当输入、第 3 格当标准答案,就能算出 IoU 和 MAE。这一步比上一篇的 OCR 更幸运:不用自己标注,官方样本自带答案。

一张图走完全流程

拿官方示例的第二张(每格 400x267)走一遍,下面是库实际跑出来的结果:左边是输入,中间是抠图叠在棋盘格上(棋盘格用来显示透明区域),右边是模型输出的蒙版:

输入 / 抠图 / 蒙版

预处理:从原图到输入张量

原图 400x267,长边是 400,缩放到 448 得到 448x299(保持比例),贴在 448x448 的黑底画布左上角,下方留 149 行黑边。然后每个像素除以 255,从 HWC 排成 NCHW,得到 [1, 3, 448, 448] 的 float32 张量。

判断这一步对不对:算一下缩放后的尺寸是不是 round(原尺寸 * 448 / 长边),画布是不是 448 的正方形,内容是不是贴在左上角,填充值是不是 0。库里有四条单元测试盯着这些(其中一条用 2x1 的纯色图检查"左上角是内容、右下角是 0")。

推理:一张蒙版

模型输出 [1, 1, 448, 448],每个值在 0 到 1 之间,1 表示前景。上面右图就是它:白色是要保留的人,黑色是背景,灰色过渡带是发丝和半透明边缘。

判断这一步对不对:把蒙版存成灰度图看一眼,主体应该是连成一片的白,背景是黑,过渡带是灰。全黑或全白说明预处理错了;边缘一圈硬锯齿说明缩放或填充有问题。

后处理:裁回、放大、合成

蒙版先裁到左上角 448x299 的内容区域(黑边那 149 行丢掉),再双线性缩回原图尺寸 400x267,量化成 8 位,最后挂到原图的 alpha 通道上。

裁剪这一步是最容易错的:画布里的黑边不是"背景",而是"不存在的区域"。如果把整张 448x448 蒙版直接缩回 400x267,下方那条黑边会被当成"背景"混进缩放,主体的下边缘会被啃掉一圈。库里有专门一条单元测试(TestAlphaFromOutputCropsAndScales)盯着这个裁剪,构造的数据让"忘记裁剪"必然算出不同的结果。

各阶段的输入输出和判断方法:

阶段 输入来源 输出去向 判断方法
预处理 原图 [1,3,448,448] 张量 缩放尺寸、贴图位置、填充值、归一化范围
推理 上面的张量 [1,1,448,448] 蒙版 取值范围在 [0,1];存图看主体是否连片
后处理 蒙版 原尺寸 alpha 尺寸等于原图;边缘是渐变不是锯齿
合成 原图 + alpha RGBA 抠图 不透明处颜色和原图一致

这些数为什么是这么定的

画布 448。 来自 sidecar,是模型的固定输入尺寸,同时也是这个模型的最大分辨率:比 448 大的图会先缩小再推理,细发丝、细网格这类高频细节的蒙版精度会下降。官方 README 也写明 “Max resolution is 448px”。这不是可以调的参数,换更大的输入要么换模型,要么自己做分块再拼合。

黑底填充。 文档要求的填充颜色。选黑色有个工程上的好处:黑就是 0,而张量本来就是零值初始化的,填充不需要额外代码,只要"只写左上角"就够了。代价是如果原图本身是黑底,模型在边缘处可能分不清"内容黑"和"填充黑"。

只除以 255。 文档写的是 normalize to [0,1],没有减均值除标准差。这和很多视觉模型的 ImageNet 归一化不同,改错了蒙版会整体偏移(比如背景残留变多)。

长边缩放而不是拉伸。 保持比例是为了不让形变被当成内容。判断方向:如果缩放后出现"人变胖了"的效果,说明做成了拉伸。

双线性插值。 官方的 Python 示例用 PIL 的 BILINEAR,库里对应实现像素中心映射的双线性采样。蒙版是连续量,用最近邻会在边缘产生阶梯。

裁剪再缩放。 前面说过,黑边是"不存在的区域",必须先裁掉再缩放。这一步没有公式,纯几何,但错了就是边缘被啃。

量化加 0.5。 uint8(v*255 + 0.5) 是四舍五入,不是截断;截断会让所有蒙版值系统性偏小 0.5/255,虽然肉眼看不出来,但做指标对比时会一直有个小偏差。

验证:和官方样图对比

模型仓库给的三张示例图都是三格拼接,我把第 1 格当输入、第 3 格当标准答案,跑完算两个指标:

样本 前景 IoU MAE 说明
example1(泡泡) 0.869 0.038 主体是半透明泡泡,软边多
example2(人物) 0.973 0.016 主体清晰,几乎逐点一致
example3(人物) 0.991 0.082 前景形状高度一致,软边区域略保守

example2 近乎逐点一致,说明整条链路(缩放、填充、归一化、裁剪、还原)和官方是对齐的。另外两张的差异集中在软边:example1 的泡泡本身是半透明的,example3 里我的蒙版整体比参考值略低(均值 0.707 对 0.787)。还有一层客观原因:官方样图是缩放过的发布件,而它们的蒙版是在原始分辨率下生成的,我拿缩放后的图当输入,边缘像素本来就不可能逐点一致。所以这个测试断言的是"整体对齐"(IoU 不低于 0.85、MAE 不高于 0.10),不是逐像素相等。

除了官方样图,testdata 里还有三张测试照片,跑通后检查蒙版分布:必须有前景、必须有背景、边缘均值要明显低于中心均值。这类检查不需要人工标注,能挡住"模型没加载对"“预处理接错"这类整链路的错。三张图的实际结果(上排原图,下排抠图叠在浅色底上):

三张测试图的抠图结果

三张图的结果差别很明显:中间那张海报(照片质感、主体居中)背景去得干净,右侧的猫连胡须边缘都留住了;最左边的平涂卡通却留下了大片蓝色背景。原因在下面的已知边界里:模型训练分布是照片,矢量图的平涂色块缺少它依赖的纹理线索。

判断蒙版质量时还有个坑:IoU 单看会骗人。example3 的 IoU 是 0.991(形状几乎完全重合),MAE 却有 0.082(软边整体偏保守);只看 IoU 会以为完美,只看 MAE 会以为很差。两个指标加上人工看边缘,才能说清楚问题在哪。

踩过的坑:预乘 alpha

合成这一步看着只是"把蒙版挂到 alpha 通道上”,在 Go 里却踩了一个坑,值得单独讲。

Go 的 image.RGBA预乘(alpha-premultiplied)容器:存的 RGB 已经乘过 alpha,约定是 R, G, B ≤ A。而抠图要表达的是"原色 + 独立的透明度",是非预乘的。第一版我直接把原色写进 image.RGBA、把 A 设成蒙版值,违反了这个约定,两个后果:

  • draw 合成到别的背景时,颜色被重复乘一次 alpha,半透明区域发白
  • png.Encode 会按预乘语义反算:r = r × 0xffff / a。当 r > a 时这个除法溢出,截断成 8 位时回绕,得到完全错误的颜色

实测效果是:大面积不透明区域一切正常,半透明边缘却出现彩色杂斑。浅蓝背景 (121,157,254) 在 alpha 约 128 的像素上,蓝通道从 254 变成了 18,看起来像绿斑和红斑。一开始我怀疑是模型输出有问题,还去对比了官方样图;最后靠"取同坐标的源图像素和输出像素逐个打印"才定位到编码环节。

修法是返回 *image.NRGBA(非预乘容器),它存的正好是"原色 + alpha",PNG 编码和 draw 合成都会按预期工作。这条写进了 docs/PARAMETERS.md,并留了一条单元测试盯着"透明像素的颜色也要原样保留"。

封装和结果

参数集中在 Config 里,优先级是调用方显式设置 > sidecar > 包内常量,填充默认值和校验分成两个函数,方便单独检查有效配置。sidecar 里的信息(画布、张量名、形状)不重复暴露成必填项,但都可通过 Config 覆盖,覆盖后仍会和图做一致性校验。

r, err := withoutbg.New(withoutbg.Config{ModelPath: "withoutbg-open-weights.onnx"})
if err != nil { log.Fatal(err) }
defer r.Close()

cutout, err := r.Remove(img)  // *image.NRGBA,原尺寸,RGB 是原色、A 是蒙版
alpha, err := r.Alpha(img)    // *image.Gray,只要蒙版

// 流式接口:文件、HTTP body、内存 buffer 都能直接喂
err = r.Cutout(src, dst)      // io.Reader -> io.Writer,一行完成读图到写 PNG

CLI 一行出图:

go run ./cmd/withoutbg -model withoutbg-open-weights.onnx -o cutout.png -alpha mask.png photo.jpg

本机 8 核 CPU 上,448 分辨率的单张推理约 2 到 3 秒(含模型加载则更久,455MB 的模型每次进程启动都要加载一遍,服务化时应常驻进程)。ONNX Runtime 共享库通过 ONNXRUNTIME_LIB_PATH 指定,不设置时由 pure-onnx 自动下载到 ~/.cache/onnx-purego

已知边界(也写进了 README 和 release notes):

  • 最大 448 像素,大图细节受限,需要更高精度只能自己分块
  • 黑底 letterbox,原图本身是黑底时要留意边缘
  • 没有 GPU 执行提供者(pure-onnx 没暴露 EP 选择)
  • 只支持 fp32 导出,fp16/int8 会被契约校验拒绝
  • 矢量图/卡通效果差:模型训练分布是照片,平涂色块缺少纹理线索,实测卡通输入会留下大片背景;做过对照实验,把黑边填充去掉(裁成正方形再推理)反而更差,说明这是模型特性而不是预处理问题
  • 只验证过官方 oss 导出(version 10.0.0),换导出时 sidecar 会变,契约校验会拦住不匹配的组合

小结

这次能复用的经验,按重要性排:

第一手来源优先。 上一篇是模型自带的 inference.yml,这次是 sidecar JSON。两者都随模型分发、都写着"该怎么用",比博客和记忆可靠得多;把它读进代码并和 ONNX 图做校验,能挡掉"模型和配置不是一对"这类最难查的错。

几何参数最容易错。 归一化错了蒙版会整体偏,能看出来;letterbox 和裁剪错了只是边缘被啃一点,指标掉几个点,很容易被当成"模型就这样"。所以几何要有单元测试,而且测试数据要构造成"写错必然失败"的样子。

验证靠对比,不靠感觉。 官方样本自带标准答案,把它拆成输入和答案就能算指标;一个样本不够(泡泡那种半透明主体会拉低 IoU),多找几个不同难度的样本,再看 IoU 和 MAE 是不是指向同一个结论。

把边界写进文档。 448 的分辨率上限、黑底填充、无 GPU、只支持 fp32,这些都是使用者会踩的坑,写在 README 里比事后解释便宜。

模型和参数细节整理在仓库的 docs/PARAMETERS.md ,示意图的生成代码在 figures_test.go