我把一个抠图模型从 167.9 MB 量化到 42.1 MB,在测试集上量到 IoU 0.976–0.984,推理快了 35%,于是上线了。一周后收到一张图,模型只抠出了角色头顶王冠的一个角。
同一个模型,同一套预处理,IoU 从 0.98 掉到 0.30。
这篇不是量化教程,是一次排查记录。我先后提出四个假设,前三个都被实验推翻,第四个才是真的——而真正的教训不在量化本身,在我当初验证它的取样方式。
现场
场景是浏览器端的动漫角色抠图:ONNX Runtime Web 加载 isnet-anime,在用户设备上跑分割,输出遮罩后做矢量化。模型用 quantize_dynamic(QuantType.QUInt8) 动态量化。
上线前我在动漫人物插画上测了量化前后的一致性:
IoU (int8 vs fp32) 0.976 – 0.984
平均绝对差 0.004 – 0.023
推理耗时 -35%
体积 167.9 MB -> 42.1 MB
这组数字看起来没有任何问题。出问题的那张图是一只卡通生物:浅蓝色身体、金色头冠,背景是蓝绿色的山和黄色的天空。主体和背景大面积同色。
返回的统计是「主体占比 8.2%」。这只生物在画面里至少占四成。
假设一:模型输出的形状对不上
代码里裁剪 letterbox 填充的那一步,是按 (y + dy) * 1024 + dx 直接索引的,前提是模型输出恰好是 1024×1024。如果实际输出是别的尺寸——比如某个低分辨率的侧输出——索引就会读到错位的内存,产出的正是那种扭曲色块。
ISNet 训练时确实有多个侧输出,这个假设很具体。查 ONNX 元数据:
输入 img [1, 3, 1024, 1024] tensor(float)
输出数 1
输出 mask [1, 1, 1024, 1024] tensor(float)
实跑后第 0 个输出元素数 = 1048576
代码假设的元素数 = 1048576
单输出,尺寸严格吻合。推翻。
假设二:预处理写错了
这个模型对预处理很挑剔——它要的是「除以 255、不做均值减法、保持长宽比 letterbox 到 1024、输出直接当 alpha」。之前我照搬过通用的 ImageNet 归一化,模型峰值输出只有 0.02,几乎是全黑遮罩。有过前科,值得怀疑。
验证方式是把浏览器里那段 JavaScript 逐行在 Python 里复刻一遍:同样的 letterbox 计算、同样的画布填充、同样的通道排布、同样的裁剪与缩放。然后拿一张普通的动漫人物图跑。
letterbox: w=934 h=1024 dx=45 dy=0
模型原始输出范围: 0.0000 .. 1.0000 均值 0.2183
最终主体占比: 23.9%
输出干净、范围正常、抠图结果是完整的人物剪影。预处理没问题。推翻。
假设三:矢量化环节把遮罩毁了
遮罩之后还有一整条链路:颜色量化分层、连通域标记、轮廓追踪、折线简化。这条链路里的轮廓追踪我修过一个真实的 bug(停止条件判断早于方向更新,导致只沿上边缘走),所以它有嫌疑。
验证方式是把真实的 alpha 和图像喂进 Node,用线上同一份 core.js,参数和用户当时完全一致(分层 6、简化 0、锐化 0):
quantiseForeground -> 6 个色簇
rgb(73,45,44) rgb(208,159,139) rgb(152,58,51) ...
labelComponents(minArea=417) -> 19 个区域
区域 id=0 area=13155 bbox=[251,138,435,402]
轮廓追踪结果 轮廓bbox=[251,138,436,403]
19 个区域的轮廓包围盒和区域包围盒逐一吻合(差 1 像素是外沿追踪的正常偏移),色簇也是合理的人物配色:深棕头发、肤色、红衣、白边。推翻。
假设四:WASM 后端跑量化模型会退化
到这里我开始怀疑运行环境。我验证量化用的是 Python 的 CPU 执行提供者,而浏览器跑的是 WebAssembly 后端——两者的量化算子实现不同,MatMulInteger / ConvInteger 这类算子在 WASM 上的完备性历来不如原生。
这个假设的检验必须干净:只留后端一个变量。做法是用 Python 把预处理后的输入张量原样导出成 .f32 文件,然后让 Node 里的 onnxruntime-web 读同一份字节去跑。
Python CPU 后端: max=0.9727 mean=0.1264
Node WASM 后端: max=0.9727 mean=0.1264
逐位一致。后端是清白的。推翻。
真正的原因
四个假设排除完,剩下的只有量化本身。把同一张图在三种精度下跑一遍,和 fp32 对比:
最大绝对差 平均绝对差 [email protected]
int8 0.388 0.0547 0.296
fp16 0.001 0.00008 0.9996
int8 在这张图上的 IoU 是 0.296。而我上线前测出来的是 0.976–0.984。
换不同阈值看得更清楚:
阈值 int8 fp16 fp32
48 32.4% 33.3% 33.3%
80 25.9% 32.0% 32.0%
112 11.8% 29.2% 29.2%
127 7.6% 25.9% 25.9%
fp16 和 fp32 在每个阈值下都吻合;int8 随着阈值升高急剧塌陷。
插一段:这是不是选错模型了
看到量化背锅之前,我先怀疑过一件更简单的事:isnet-anime 是为动漫人物训练的,而这张图是一只非人形的卡通生物。会不会换个通用主体分割模型就好了?
拿 isnet-general-use 试。第一次跑出来主体占比只有 1.9%,比 anime 模型还差——但这个结果不能信,因为我又一次用错了预处理。
这个模型在 rembg 里的调用是 mean=(0.5,0.5,0.5)、std=(1.0,1.0,1.0),而且除的是像素最大值而不是 255。我下意识套了 ImageNet 的均值方差。改成正确的预处理后:
ImageNet 归一化(错误) 主体占比 1.9%
官方预处理(正确) 主体占比 21.4%
21.4% 比 anime 模型 fp32 的 25.9% 还低,而且失败模式完全一样:抠出了头部和王冠,身体、四肢、尾巴全丢。
所以换模型解决不了这个问题。两个不同架构、不同训练数据的模型,在同一张图上犯同一个错误——这说明难点不在模型选型,在图片本身:主体的平涂浅蓝和背景的蓝绿在特征空间里靠得太近。
顺带一提,”预处理套错了归一化参数”这个错我在这次排查里犯了两次(anime 模型一次、general 模型一次)。每个分割模型的预处理都是它训练时的一部分,不能跨模型套用,哪怕它们看起来是同一个系列。
把原始输出画出来看
确定是量化的锅之后,我把 fp32 的原始软遮罩(没有二值化)保存成灰度图。这张图比任何指标都直观:
头部 + 王冠 + 颈毛 明亮,接近纯白
身体、四肢 暗灰
尾巴 几乎看不见
模型不是”没看见”身体,而是看见了但不确定。量化后的版本在同样的位置上把这些本就微弱的信号又压低了一档,于是二值化时整片消失。
这解释了为什么最终结果看起来像”随机色块”而不是”缺了一块的角色”:留下来的是若干个高置信孤岛,孤岛之间的连接部分被切断了,矢量化再把这些碎片各自描成轮廓。
为什么两次测量差这么多
关键在于模型对样本的置信度。
量化误差不会凭空制造或消灭结构,它只是让每个像素的输出值抖动一点。对一个模型很有把握的样本——干净的动漫人物、主体和背景颜色分明——输出要么接近 1 要么接近 0,中间地带很窄。零点几的抖动落在 0.95 上还是 0.95,落在 0.03 上还是 0.03,判定不会翻转,所以 IoU 高达 0.98。
而这只卡通生物的身体是平涂浅蓝,背景也是蓝绿——模型本来就拿不准,大片区域的输出徘徊在 0.3–0.5。这时候同样的抖动足以把判定推到阈值另一侧。
把这两件事放在一起,结论就很难看了:
我只在模型有把握的样本上验证量化,等于在最不敏感的区域测量灵敏度。测出来的 0.98 不是量化安全的证据,它只是说明我的测试集里没有难样本。
这不是量化特有的问题。任何”压缩后精度损失可接受”的结论——剪枝、蒸馏、低秩分解、降采样——只要验证集偏向简单样本,都会得到同样过于乐观的数字。
换成 fp16
修法是放弃 int8 改用 fp16。转换时保留输入输出为 float32,前端一行代码都不用改:
from onnxconverter_common import float16
m16 = float16.convert_float_to_float16(model, keep_io_types=True)
keep_io_types=True 是关键:它让计算图内部走 fp16,但输入输出仍然是 float32。前端那句 new ort.Tensor('float32', data, [1,3,1024,1024]) 完全不用动,输出读取也不用改。
转换过程会刷一堆这样的警告:
UserWarning: the float32 number -4.266044584255724e-08
will be truncated to -1e-07
这些是权重里绝对值小于 fp16 最小非规格化数的值被钳到 ±1e-07。看着吓人,实际影响可以直接量:最大绝对差 0.001070,IoU 0.9996。这类警告要看实测差异,不能靠它本身判断严重性。
fp32 167.9 MB
fp16 84.0 MB IoU vs fp32 = 0.9996
int8 42.1 MB IoU vs fp32 = 0.296(该图)
体积是 int8 的两倍,但在 WASM 上实测加载更快(79 ms vs 337 ms,量化模型要额外解析量化参数),推理耗时基本持平。对这个场景,int8 省下的 42 MB 换来的是不可用的输出——不是一笔划算的交易。
顺带被这次排查暴露的第二个问题
把软遮罩切成二值时,我把阈值写死成了 127。统计那张图的原始输出:
头部区域 中位数 154
身体区域 中位数 89
就算用 fp32,127 这个阈值也正好把身体切掉了。低置信主体的四肢天然落在 80–90 区间,一个写死的阈值等于替所有图片预设了模型的置信度。默认值改成 80,并且做成可调。
下次我会怎么验证
这次排查真正值得留下的是方法,不是结论:
验证集必须包含模型不擅长的样本。专门找主体与背景同色、低对比度、非典型构图的输入。如果找不到失败样本,说明取样有偏,不说明模型稳健。
看 IoU 或逐样本指标,别只看均值和峰值。这次 int8 的输出峰值是 0.9727,看起来相当健康——但 IoU 只有 0.296。均值和峰值对空间结构的崩坏完全不敏感。
在真正运行的后端上验证。我差点把锅扣给 WASM。检验方法很简单:把输入张量导成文件,让两个后端读同一份字节,只留一个变量。这次的结论是后端无辜,但如果不做这个对照,我会改错东西。
先证伪再动手。四个假设里前三个都很合理,如果任选一个直接开改,都会在错误的地方浪费时间,而且改完问题还在——最坏的情况是”改了之后好像好一点”,然后带着一个错误的因果认知继续往前走。
这里描述的失效机制做成了可交互的演示:软遮罩阈值实验。拖动二值化阈值和噪声幅度,可以直接看到高置信区纹丝不动、低置信区碎成孤立像素,而总覆盖率几乎不变——这正是只看 IoU 均值会漏掉的那类破坏。