1. 开放词汇语义分割到底在解决什么问题
1.1 传统语义分割的“类别锁死”困境
接触过语义分割的人,应该都体会过那种别扭:模型训练时定义了多少个类别,推理时就只能输出多少个类别。做自动驾驶场景分割,模型一开始定死“车、人、路、树、建筑”这些类别,后面车主上传了一个新想识别的目标,比如“充电桩”,就得重新采数据、标掩码、跑训练流程,动辄几天时间。问题不在于能不能训,而在于这套流程没法规模化——现实世界里的视觉概念太多了,不可能为每一个可能出现的类别都预先准备一份训练数据。
我此前在一个模拟项目中接入视觉感知模块时,就被这个限制卡住过。现场需要识别的东西并不固定,今天可能是“蓝色工具箱”,明天可能是“贴着胶带的箱子”。传统分割头最后的全连接层参数数量是固定的,类别数变了,最后一层就要重建,前面所有的特征也得重新适配。这种特性在工业落地场景里极度不友好,因为不确定性才是常态。
开放词汇语义分割(open-vocabulary semantic segmentation)就是冲着这个问题来的。它允许你在推理阶段输入任意的类别描述文本,比如“透明玻璃杯”“浅色毛衣”“带金属光泽的零件”,模型直接根据这些描述对图像中的每个像素进行分类。也就是说,类别集合不再是一个训练前定死的列表,而是每一次推理时动态给出的文本集合。
1.2 文本驱动的像素分类如何运作
开放词汇分割的基本思路,本质上是搭一座连接“图像像素”和“自然语言描述”的桥。视觉侧提取每个像素或每个区域的特征,文本侧提取类别描述的特征,然后把两者放在同一个语义空间里做相似度比较。像素特征和哪个文本描述最像,就把它划分到那个类别。整个过程可以框定为:先计算像素特征,再计算文本特征,最后做逐像素相似度匹配。
难点在于“同一个语义空间”这几个字。图像特征和文本特征来自完全不同的模态,如果不做对齐,两者的向量方向毫无可比性。所以这类任务通常会借助一个预训练的跨模态特征对齐基础,让两个编码器的输出尽可能落在相近的几何区域里。视觉主干负责输出高分辨率的、具备语义区分能力的稠密特征,文本编码器则负责把类别名词变成稳定的语义向量。
我在实现时采用的方案是:视觉侧使用 DINOv3 这类自监督预训练视觉模型,提取 patch 级别的特征;文本侧使用常规的预训练文本编码器;两者之间再加一个轻量级投影层做空间校准。这样做的自由度比较高,因为 DINOv3 不是为某个固定类别集合训练出来的,它的特征天然更关注物体本身的结构和语义边界,而不是停留在分类任务的决策面上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么视觉主干我选 DINOv3 而不是图像分类预训练模型
2.1 自监督特征与监督分类特征的差异
很多人一听到视觉特征,第一反应是拿一个在千万张分类图上训练过的模型来主干。但我自己的实测感受是:纯粹为分类任务训练出来的特征,用在语义分割这种稠密预测场景里,会有一种“特征偏科”的感觉。
分类模型的目标是判断整张图像属于哪个大类,所以它学到的特征会不断向“全局可区分性”收敛,边缘细节、物体边界、局部纹理这些信息会被当作冗余逐渐抹掉。这对切片分类之类的问题没问题,但对分割来说却是硬伤。分割需要把注意力放在单个像素和它周围的小邻域上,需要特征保留相当程度的局部结构信息。自监督视觉模型正好在这点上有优势,因为它的训练目标往往来自图像自身,比如不同视角下同一物体特征应该保持一致,这种约束不会强迫模型在某个固定标签列表上做决策,反而会保留更完整的密集语义结构。
DINOv3 沿用了这类自监督训练的路线,通过大量无标注图像学习视觉特征。它不依赖任何人工标签,因此在特征空间里保留了更丰富的“概念密度”。我在迁移到分割任务时明显感觉到,patch 级别的特征对物体边缘的响应比较准,同类别不同实例之间的特征一致性也更强。
2.2 DINOv3 特征在稠密任务上的可迁移性
DINOv3 使用的是 ViT 架构,它把图像切成一格格 patch,每个 patch 经过多层的自注意力编码后,保留了输入图像的空间对应关系。换句话说,patch token 天然就是一个空间网格,这正好是分割任务需要的东西。
传统分类预训练模型如果采用 ViT 结构,末层往往还要加一个全局聚合向量做分类,所以中间层虽然保留了空间信息,但最后一层因为分类头的存在,会倾向于压缩全局信息。DINOv3 这类自监督模型很少把全部信息压到单个向量里,它训练时就有意维护 patch token 之间的细节关系,所以末层特征仍然保有较好的空间分辨能力。这也解释了为什么很多分割方法在 DINO 系列特征上做简单线性探查,就已经能得到非常可观的分割效果。
我自己的选择是在 DINOv3 的中间层和末层之间做特征拼接,而不是只用最后一层。原因是浅层特征带有更多边界和纹理信息,深层特征带有更多语义类别信息,两者融合之后,开放词汇分割的召回率和边界精细度都能兼顾。只靠最后一层的话,小目标容易丢,边缘也比较糊。
3. 把 DINOv3 特征变成分割掩码:我的流水线实现与选型思路
3.1 特征提取:加载权重与选择特征层
在实际操作中,第一步是把 DINOv3 的预训练权重加载进来,然后固定它的参数。这里有一个容易忽略的点:千万不要一上来就把图像缩到特别小的尺寸。虽然 ViT 理论上可以处理任意尺寸,但 patch 大小固定时,输入越小,patch 数量越少,空间细节损失就越严重。
我一般把输入图像缩放到一个相对固定的长边,比如 518 或者 640 像素,保证 patch token 数量在一个合理范围。显存紧张时可以先用 378,但不要低于 224,否则分割结果基本没法看。
代码如下,加载模型后直接取指定层的输出:
python复制import torch
def extract_patch_features(model, image, layers=(8, 11)):
# image: [B, 3, H, W],已经过归一化处理
# 让模型前向跑一遍,同时保留多个中间层输出
out = model.forward_features(image)
features = []
for idx, feat in enumerate(out):
if idx in layers:
# 去掉 CLS token,只保留 patch token
patch_tokens = feat[:, 1:, :]
B, N, C = patch_tokens.shape
h = w = int(N ** 0.5)
features.append(patch_tokens.transpose(1, 2).reshape(B, C, h, w))
return features
选层时我建议至少拼接两层:一层偏浅的负责边缘,一层偏深的负责语义。反正 DINOv3 前向一次就能拿到所有层输出,拼在一起不会增加额外计算成本,只增加一点特征通道数。
3.2 轻量解码器的取舍
特征拿到之后,面临一个问题:patch token 的特征图分辨率通常只有输入的 1/16 或者 1/8 左右,跟原始图像差距不小。直接把这个粗分辨率结果上采样回原图大小,边缘会出现明显的马赛克感,尤其是细长物体,比如电线、栏杆,特征响应会被插值抹平。
一个实用的做法是在特征后面接一个轻量级解码器。解码器不需要很重,因为 DINOv3 的特征质量已经足够高,它的职责主要是把分辨率提上来,同时融合一下多尺度语义。我使用的是一个简化的特征金字塔结构,自上而下逐层上采样,然后和不同层级的 DINOv3 特征相加:
- 第一路:深层特征上采样到 1/8 分辨率;
- 第二路:中间层特征保持 1/8 分辨率;
- 两路拼接后经过两个 3×3 卷积,再上采样到 1/4;
- 最后通过双线性插值恢复到原始分辨率。
这个解码器的参数量大约只有几百万,训练却比想象中稳定。不要一开始就上 U-Net 那种高容量结构,因为开放词汇分割的训练数据往往不如封闭集分割那么规整,解码器容量过大会把视觉特征本身的结构淹没掉。
3.3 文本嵌入与相似度计算
文本侧的处理也很有讲究。类别单词本身比较“干瘪”,直接拿去编码,效果往往很一般。比如“杯子”这个词,在不同语境下可以是陶瓷杯、玻璃杯、纸杯,语义向量容易漂移。缓解办法是用模板把类别词扩展成完整句子,再对多个模板的编码取平均。
我常用的一组模板如下:
| 模板编号 | 描述形式 |
|---|---|
| 1 | a photo of a |
| 2 | {class_name} in the image |
| 3 | a segmentation of |
| 4 | the {class_name} region |
| 5 | close-up of a |
把五条模板分别送进文本编码器,得到五个向量,取平均值后做 L2 归一化,就得到这个类别的最终文本嵌入。多个模板取平均能显著提升类别向量的稳定性,比单独用一个模板鲁棒得多。
像素侧类似,也要对投影后的特征做归一化。最后用矩阵乘法计算每个像素和所有类别文本的相似度,得到形状为 [H×W, K] 的 logits,再取 softmax 或者直接 argmax。这个过程可以并行处理任意数量的类别,因此“开放词汇”的能力在推理时是纯增量扩展的——只要把新的文本向量拼进来就行。
4. 训练与推理的正确打开方式:对比损失、显存策略与多尺度融合
4.1 损失函数的具体选择
开放词汇分割的训练,本质上是在拉近“属于某个类别的像素特征”和“该类别的文本特征”的距离。最常用的做法就是像素-文本对比损失(pixel-text contrastive loss)。我简化一下核心逻辑:
python复制# logits: [B, N, K],B 是 batch,N 是每个图像的像素点数,K 是类别数
logits = pixel_feat @ text_feat.transpose(-1, -2) / temperature
# label_index: [B, N],表示每个像素对应的类别编号
loss = F.cross_entropy(logits.view(-1, K), label_index.view(-1), ignore_index=-1)
这个公式是基础版。真正用起来,有个细节必须注意:如果图像里背景占比非常大,或者某个类别占了半个画面,简单的交叉熵很容易被大区域类别带偏,小目标类别的梯度被稀释掉。我后来做了一点改动,把背景像素的损失权重调低,并且在 batch 内做类别均衡采样,让每个类别在损失中的贡献相对平均。
还有一个进阶做法:同一 batch 的所有图像共享一个文本嵌入,所以负样本不仅来自同一张图的其他类别,还能来自其他图的类别描述。这让对比学习的信息量更大,模型不容易过拟合到某一张图的颜色分布上。
4.2 显存策略与特征缓存
DINOv3 这种 ViT 模型跑高分辨率图像时,显存占用一点都不友好。我在一张常见显卡上做实验,输入 640×640,batch size 设成 4,解码器加上文本编码器的显存消耗已经非常逼近极限。强行加大 batch 会直接爆显存,这也是很多人第一次复现开放词汇分割时最容易翻车的地方。
我自己总结了三个实用策略:
第一,视觉主干全程冻结,只训练投影层和解码器。这样反向传播不会经过 ViT 的所有层,显存占用大头只在 forward 激活值上,但至少省掉了视觉侧的梯度存储。冻结主干还有一个好处是训练稳定性高,不会因为分割数据的规模小而把视觉特征训坏。
第二,如果数据量不大,可以采用特征预计算策略。先把所有训练图像过一遍 DINOv3,保存每个图像的 patch 特征到磁盘,训练时只加载这些特征,不再重复跑视觉模型。这样显存开销几乎只看解码器和投影层,batch 可以开得很大。缺点是插不了数据增强,因为增强后的图像特征和原图不一致,所以适合前期快速验证。
第三,采用梯度累积。显存不够时不要盲目减小输入尺寸,可以保持分辨率不变,把 batch 拆成若干个小批次,累积梯度后再更新参数。实测下来,用梯度累积得到的效果比降低分辨率更好,因为分辨率才是分割质量的命根子。
4.3 多尺度推理与后处理
推理阶段有一个几乎白捡的精度提升手段:多尺度推理。把输入图像分别缩放到几个不同尺寸,比如 0.75 倍、1.0 倍、1.25 倍,分别跑一遍分割,得到三组 logits,插值到原图大小后取平均,再做 argmax。
这个操作能明显改善小目标召回率,也能让边缘更稳定。单独跑 1.25 倍尺寸的时候,模型能看见更多细节;单独跑 0.75 倍的时候,大物体更完整。两者平均下来,等于让模型对不同尺度有投票机制,比单尺度硬猜稳得多。
后处理阶段,我一般还会加一个条件随机场或者最简单的连通域过滤。CRF 不是必须,如果项目对边缘精细度有硬性要求,可以接一个结构化后处理模块;如果只是做区域级理解,只在连通域上做一次面积过滤就够了,太细碎的点直接去掉,避免生成大量孤立像素。
5. 真实调试里的高频问题:相似度漂移、描述模板与边缘混淆
5.1 背景像素带来的相似度漂移
我第一次跑通整个流程时,发现一个很奇怪的现象:某些类别总是莫名其妙地出现在背景区域。比如模型把大片天空判断成“白色墙壁”,把路面判断成“灰色地面”。后来检查特征分布才发现,问题出在背景像素数量太多,它们在对比损失里贡献了绝大多数负样本,导致模型学会了一种偷懒的解:只要像素特征比较中性,就往数量多的类别上靠。
这个问题的本质是训练分布不平衡。解决思路不是单纯提高背景的 ignore 比例,而是给每个类别设置独立的温度系数,或者使用类别加权损失。我把损失改成:
python复制# weight: [K],每个类别一个标量权重
loss = F.cross_entropy(logits.view(-1, K), label_index.view(-1), weight=class_weight, ignore_index=-1)
频率高的类别权重调低,频率低的小目标权重调高。改完之后,小目标的召回率肉眼可见地上升,背景误判也收敛了不少。
5.2 类别描述模板带来的结果差异
同样一张图,我把类别写成“car”和“a car on the road”,得到的分割结果会有差别,后者往往更准确。这说明文本编码器对描述形式的敏感度比自己想象中高。
有一次我调试“桌子”和“餐桌”两个词,发现模型对“餐桌”的响应明显更好,原因可能是“餐桌”这个词在文本语料中的共现对象更具体,与叉子、椅子、食物这些视觉概念关联更紧密。所以类别词不是越短越好,而是要选一个视觉指向性强的描述。
实操层面,我会在推理前对每个类别做一次“提示优化”:把候选模板全部跑一遍,挑出在验证集上表现最好的一组,或者干脆多个模板取平均。不要试图用同一个模板套所有数据集。像“a photo of a {cls}”在普通场景下够用,但在医学图像、遥感图像上就非常水土不服,这时候改成“{cls} in medical imaging”之类会更稳。
5.3 边缘混淆的完整排查链路
再分享一个更典型的调试过程。某次分割结果里,物体内部识别很准确,但轮廓总是往外扩了一圈,尤其是浅色物体,边缘和背景黏在一起。
我的排查顺序是这样的:
第一步,先看特征图分辨率。如果解码器输出是 1/4 分辨率,直接插值回原图,边缘本身就有一层“过渡带”。两个不同物体的过渡带里,特征往往取的是两边像素的混合值,这是必然的信息损失。检查方法是把中间层特征单独上采样可视化,如果边缘在特征层面就已经糊了,问题出在解码器或者特征融合。
第二步,看温度系数。温度系数设得太高时,softmax 输出会趋于平均,模型对类别差异不敏感,边缘像素的置信度飘忽不定。我把温度从 0.1 调整到 0.05,边缘像素的分类分数立刻变得更加锐利。
第三步,检查多尺度融合。多尺度虽然提升整体精度,但没有做尺度对齐的话,不同尺度的边缘位置会有几个像素的偏移。平均之后,边缘就会比任何单一尺度都宽。解决办法是在融合前对边缘区域做对齐,或者对 logits 做一次边界保留滤波。
最后,如果所有技术手段都试过还是没有改善,我会回头检查文本侧。边缘混淆往往是因为两个类别的文本嵌入本身就很接近,比如“桌子”和“椅子”在文本空间里距离很近,图像特征稍微模糊一点,模型就会在两者之间摇摆。这种情况下,多尺度、后处理都只是缓解,真正有效的手段是重新设计类别描述,让两个类别的语义向量拉得更开。
排查问题最忌讳一上来就加模型容量。先用可视化手段一层层确认问题出在哪一层,再做针对性修改,比盲目调参有效率得多。我自己踩了这么多次坑,最大的体会就是:开放词汇分割不是单点技术,它是一套由特征、对齐、损失、后处理共同构成的系统,任何一个环节失衡,最终都会反应在分割图上。
