把能在浏览器标签页里跑的分割做出来
点击即分割与开放词表文本提示都在客户端运行,无需 GPU。为此付出了什么:一份经过验证的编码器契约、一个由实测选出的量化方案,以及一个手写的分词器。
pip install potato-annotation 之后就应当得到可用的分割能力:不需要 GPU,不需要新的 Python 依赖,标注时也不产生对外网络请求。
最后这条约束不是锦上添花。有若干研究组把 Potato 部署在气隙环境中,在那里,一个要在点击时去拉取的模型不是"慢",而是"缺失"。因此 Potato 的两个图像辅助模型都通过 ONNX Runtime Web 在标注者的浏览器中运行:MobileSAM 负责点击即分割,Grounding DINO 负责文本提示。
其中有三件事值得记下来,因为它们的适用范围超出了这个项目本身。
编码器契约允许多种看似合理的解读
SAM 的图像编码器接收一个经过预处理的张量,而规格说明留下了解释空间:归一化方式、缩放约定、填充、通道顺序。
我们实现了其中一种解读,得到了掩码。它们看起来是对的。相对基准的质心误差在 70 到 148 像素之间——这种误差读起来像是标注者有点马虎,而不像是流水线坏了。三种各自看似合理的解读,都产出了自信、看似合理却错误的掩码。
而正确的解读落在 0.1 像素。
这件事的教训不是"要更仔细地读规格"。而是:此处解读错误在没有基准的情况下是不可见的——掩码形状正确、位置也大致正确,下游的每一项检查都能通过。只有与已知真值做数值比对,才能把这四个候选区分开,而其中只有一个是对的。
因此这份契约是由一项针对真实权重的测试固定下来的,而不是写在注释里。注释同样是"真的",却抓不住回归。
量化是一个真实的选择
把一个 686 MB 的模型量化到能在浏览器中运行,这不是可选项。但采用哪种量化,通常是由导出工具的默认值决定的。
对照全精度的 Grounding DINO 导出实测:
| 导出方式 | 相对全精度的框 IoU | 体积 |
|---|---|---|
q4f16 | 0.972 | 151 MB |
int8 | 0.874 | 201 MB |
q4f16 保留了明显更多的原始几何信息,并且还小 50 MB。int8 是惯常选择,采用它就会用更大的文件装上一个更差的模型。
这项测量花了一个下午,否则它会成为永久性的决定,因为一旦框已经出现在屏幕上,没人会再回头审视量化方案。
之后又在 COCO 的双猫照片上做了实测:两只猫均被检出,置信度分别为 0.724 与 0.688,边界框与 Python 参考实现吻合到小数点后四位。
手写分词器
Grounding DINO 需要其文字描述按 BERT 的方式分词。最直接的做法是引入 transformers.js。
那是为了一个函数而引入约 2 MB 的 JavaScript,而这个页面已经要加载一个 151 MB 的模型,所以我们直接实现了 WordPiece——大约 200 行——并与 HuggingFace 的 tokenizers 做了逐 token 比对。
省下的字节其实不是重点。重点是:一个与模型训练时所用分词器不一致的分词器,会产出细微错误的检测结果,看上去像是阈值没调好。你会先花一整天去调 box_threshold,才可能怀疑到分词器头上。有了 token 级别的等价性测试,这种失效模式就成了不可能,而不只是不太可能。
浏览器不再是正确答案的地方
视频掩码传播运行在服务端,这是一个有意为之的例外,而不是不一致。
它的成本结构不同。点击即分割每次点击只需一次廉价的解码器前向,而编码是每张图片算一次并缓存的。传播则是每一帧都要跑一次完整的模型前向,模型有 181 MB、分布在五张计算图中,而视频本来就在服务端。在浏览器标签页中跑上一百帧,意味着页面要冻结好几分钟。
我们保留了一条更轻量的浏览器内延续路径,它逐帧重新提示,并且我们很小心地不把两者说成同一种能力。"SAM 2 传播在你的浏览器中运行"是一句更好听的话,也是一句假话。
如果有人要做同样的事,我们会说什么
- 先拿到基准,再看输出。 看似合理却错误的输出才是真正的失效模式,而且它能躲过评审。
- 实测量化方案。 默认值是别人在另一套约束下替你做的选择。
- 用数值方式对照参考实现测试预处理。 肉眼一扫是分不出 0.1 像素与 148 像素的误差的。
- 把编码器与解码器分开,并缓存开销大的那一半。这一分离正是点击之所以感觉像交互的全部原因。