MaskCLIP无监督分割实战
MaskCLIP 复现完整记录
第一阶段:尝试复现与基础问题修复
1. 安装与环境配置
在 GitHub 上克隆源文件:chongzhou96/MaskCLIP: Official PyTorch implementation of "Extract Free Dense Labels from CLIP" (ECCV 22 Oral)
安装必要的依赖。
环境适配说明:本人配置为 RTX 5060,必须搭配CUDA 12.x 及 PyTorch 2.x;而MaskCLIP官方代码库基于早期 OpenMMLab 体系(mmsegmentationv0.x /mmcv1.x),默认要求的 Python 3.7/3.8 和 PyTorch 1.x 无法在新显卡上调用 CUDA,于是禁用所有 CUDA,改用 CPU 计算。
2. 下载并转换 CLIP 模型
运行convert_clip_weights.py:生成一个ViT16_clip_backbone.pth权重文件。
- 带
--backbone参数时:提取出ViT16_clip_backbone.pth(主干网络权重)。 - 不带
--backbone参数时:提取出ViT16_clip_weights.pth(解码头需要的投影层权重)
3. 准备感兴趣对象的文本嵌入
运行prompt_engineering.py,提取 PASCAL VOC 数据集的类别文本特征,用来做无监督分割:voc_ViT16_clip_text.pth文本特征文件生成并保存到pretrain/目录下。
运行python tools/test.py configs/maskclip/maskclip_vit16_520x520_pascal_context_59.py pretrain/ViT16_clip_backbone.pth --show-dir output/,生成了context_ViT16_clip_text.pth文件。
3.1 解决 mmcv._ext 模块缺失问题
mmseg在启动时会自动加载所有的网络组件和损失函数,其中包含了一些用 C++ 写的算子(如focal_loss)。它调用的mmcv.ops试图加载编译好的 C++ 动态链接库mmcv._ext。因为在 Windows + 较新版本 PyTorch 下,通过标准pip安装的mmcv通常只包含了纯 Python 代码,缺少编译好的_ext.pyd文件,所以抛出了ModuleNotFoundError: No module named 'mmcv._ext'。
4. 数据集准备
去 PASCAL Context 官网下载数据集。
E:\MaskCLIP\data\ └── VOCdevkit/ └── VOC2010/ ├── JPEGImages/ <-- 从 VOCtrainval_03-May-2010 得到的所有 .jpg 图片 │ ├── 2008_000002.jpg │ └── ... ├── ImageSets/ │ └── SegmentationContext/ │ └── val.txt └── SegmentationClassContext/ <-- 从 Stanford 的 59_context_labels.tar.gz 中解压 ├── 2008_000002.png └── ...5. 获取定量结果(mIoU)—— 首次尝试失败
5.1 为什么 mIoU 只有 1.42 且一直报 Missing Keys
5.1.1 权重文件被拆分
权重文件被拆分了:tools/test.py加载的pretrain/ViT16_clip_backbone.pth文件里只有主干网络 (Backbone) 的权重,完全不包含decode_head的文本向量和投影矩阵。
在tools/test.py里重写了校验逻辑,确保在模型推理前,强制把文本向量和 1x1 卷积权重补充挂载进去。
5.1.2 源码拼写错误
源码自带致命拼写 Bug:在mmseg/models/decode_heads/maskclip_head.py的init_weights函数中,原作者把类名拼错了:写成了super(MaskCLIPHead, self)(多了个大写的L),导致 Python 报NameError并跳过了权重的初始化。
5.1.3 DataContainer 格式解包失败
DataContainer格式解包失败:在mmsegmentation/mmcv中:
- 单卡/CPU 测试(
single_gpu_test)期望从 DataLoader 传进来的img_metas是一个标准的list(比如[{'ori_shape': ...}])。 - 但启动了强制 CPU 模式,没有用标准的
MMDataParallel包装,DataLoader 吐出来的img_metas被封装在了一个DataContainer对象里(没有自动被.data[0]解包出来)。 - 导致模型在读取图像尺寸
ori_shape时,拿着DataContainer当list迭代,直接抛出了TypeError。
5.1.4 CPU 单卡运行问题
CPU 单卡运行时,没有 MMCV 的MMDataParallel自动拆包,数据流里的img_meta在每个流程节点都需要手动把.data提出来。
5.1.5 img_meta 数据结构问题
问题原因:测试时img_meta经过forward_test整理后是List[List[dict]]结构(外层=测试增强,内层=batch 图片),inference中img_meta[0]拿到的是内层 list 而非 dict,导致img_meta[0]['ori_shape']抛出TypeError。
修复内容:在inference方法中增加一层扁平化处理,将[[{...}]]规范化为[{...}]。
5.2 修复后 mIoU 仍然很低
mIoU仍然很低~1.4%:所有类别的余弦相似度得分都在0.25 ~ 0.32之间,非常接近。
???
尝试改在wsl里面运行
第二阶段:深入诊断与根因分析
MaskCLIP Zero-Shot 评估全流程诊断报告
一、问题现象
在 PASCAL Context 59 类上运行 MaskCLIP (ViT-B/16) 零样本评估,命令:
python tools/test.py configs/maskclip/maskclip_vit16_520x520_pascal_context_59.py pretrain/ViT16_clip_backbone.pth --eval mIoU结果:mIoU = 1.63%(随机基线 1/59 ≈ 1.69%),几乎等于瞎猜。
二、排查过程
阶段 1:标签映射 (已解决,非根因)
问题:最初怀疑_LABEL_MAP映射错误。
关键发现:PASCAL Context 官方 Stanford 59_context_labels 是调色板/索引 PNG (mode='P'),像素值直接为 1~59。
重要教训:cv2.imread(GRAYSCALE)在读取调色板 PNG 时会读取 RGB 调色板色值而非索引值,导致错误分析。必须用PIL/Pillow读取:
# 错误方式 cv2.imread(file, cv2.IMREAD_GRAYSCALE) # 读取调色板颜色值,非索引 # 正确方式 np.array(Image.open(file)) # 正确返回 1~59 的调色板索引最终状态:mmseg/datasets/pascal_context.py中self.label_map = None,reduce_zero_label=True独自处理 1→0, 2→1, ..., 59→58 的映射。所有 5,105 张验证图片的 59 个类别标注均完整。
阶段 2:后处理阈值 (不相关)
Config 中ks_thresh=0.和pd_thresh=0.已为 0,refine_output()方法实际上跳过所有后处理,直接返回原始 logits。
阶段 3:权重加载验证 (全部正确)
Backbone (151/151 keys 全部匹配)
checkpoint = load_checkpoint(model, 'pretrain/ViT16_clip_backbone.pth', ...)验证了pos_embed、cls_token、patch_embed.projection.weight、所有 12 层的attn/ln/ffn权重 ——全部与 checkpoint 完全一致。
Decode Head
MaskClipHead.__init__()分别从两个文件加载:
| 文件 | 内容 | 状态 |
|---|---|---|
pretrain/context_ViT16_clip_text.pth | text_embeddings [59, 512], L2 norm ≈ 1.0 | 正确加载 |
pretrain/ViT16_clip_weights.pth | proj.weight [512, 768] + CLIP 原始权重备份 | 正确加载 |
阶段 4:前向传播逐阶段追踪 (核心发现)
对单张图片2008_000002.jpg(390×520)进行逐阶段追踪:
- Step 1: Backbone → v (value-path features)
- shape: [1, 768, 25, 33]
- per-pixel norm (channel L2): mean = 27.71 ≈ sqrt(768)
- Step 2: proj(v) [Conv2d 768→512]
- shape: [1, 512, 25, 33]
- per-pixel norm: mean = 7.87
- Step 3: L2 normalize per pixel
- 每个 512-dim 向量 → 单位向量
- Step 4: Cosine similarity with text_embeddings [59, 512]
- mean = 0.000685 ←正交!
- std = 0.031694
- Step 5: ×100 (logit_scale)
- range: [-8.52, 9.52]
- Step 6: Softmax
- 最大置信度 per-pixel: mean = 0.33, median = 0.36
- 仅 19/59 类别被预测到
阶段 5:GitHub Issue 线索
找到了一个相同的 GitHub issue (Xuefei98, 2023年7月26日),同样是在 PASCAL Context 59 上测试结果不对。评论者 WJsebastian 建议 "adding the pre-trained weights' path could be of help"。
阶段 6:convert_clip_weights.py 源码分析
# convert_clip_weights.py, line 28-33 if 'ViT' in args.model and prefix in key: new_key = key[len(f'{prefix}.'):] if new_key == 'proj': all_model['proj'] = {} all_model['proj']['weight'] = state_dict[key].float().t() # ← 转置! continueCLIP ViT 的visual.proj形状为[768, 512](width=768, output_dim=512)。脚本将其转置为[512, 768]保存,匹配 MaskCLIP head 中Conv2d(768, 512, 1)的权重格式。这个转置是正确的。
阶段 7:prompt_engineering.py 分析
使用标准 CLIP 提示工程(85 个模板),对每个类别进行:
- 对每个模板填充类名 → tokenize
- 用 CLIP text_encoder 编码 → 得到 embedding
- L2 normalize
- 对同一类别的 85 个模板 embedding 取平均 → 最终类 embedding
- L2 normalize 最终结果
三、根因判断(完整版)
3.1 排除法:四个怀疑方向全部验证通过
我们对四个最可能出问题的地方做了逐项验证,全部排除:
| 测试项 | 验证方法 | 结论 |
|---|---|---|
Root Cause A:init_weights()覆盖已加载权重 | 对比load_visual_projs()前后 decode_head 参数 | ✅ 通过 —load_visual_projs()在init_weights()之后调用,CLIP 权重不会被覆盖 |
| Root Cause B:文本嵌入 L2 归一化维度错误 | 检查所有 59 个类别的 L2 norm | ✅ 通过 — 全部 ≈ 1.0,沿dim=-1归一化正确 |
Root Cause C:proj权重转换错误 | 对比 CLIPvisual.proj和转换后proj.weight | ✅ 通过 — 转置.t()后形状正确,数值完全一致 |
| Root Cause D:MaskCLIP ViT 前向传播与原始 CLIP ViT 存在差异 | 逐层对比 mmcv ViT 与 PyTorch 原生 MultiheadAttention | ✅ 通过 — 实现完全等价 |
3.2 Test D 详细验证:mmcv ViT == PyTorch ViT
这是最关键的一次验证。我们写了一个修正了残差连接处理的诊断脚本,将 mmcv 的 MultiheadAttention 和 PyTorch 原生的 nn.MultiheadAttention 在完全相同的输入和权重下做对比:
Attention output comparison (residual removed): max_diff = 2.542511e-07 cos = 1.00000131 >>> IDENTICAL: mmcv MultiheadAttention == torch.nn.MultiheadAttention Full pre-norm block comparison: max_diff = 4.768372e-07 cos = 1.00000048 >>> IDENTICAL: mmcv block == naive torch block结论:mmcv 的 ViT 实现和 PyTorch 原生实现在数值上完全一致,不存在任何计算 Bug。
唯一的微小差异来自 LayerNorm 的 eps 参数(MaskCLIP 配置使用 eps=1e-6,CLIP 原版使用 eps=1e-5),这个差异在 12 层 Transformer 中累积后会导致 CLS token 余弦相似度约 0.94(~6% 的方向偏移),但这属于正常的数值行为,不是 Bug,也不足以解释 mIoU 1.63% 的惨烈结果。
3.3 真正的根因
所有代码层面的可能性都被排除了。真正的问题在于:
PASCAL Context 59 类的 CLIP 文本嵌入高度相关。
数据分析显示:
- 59 个文本嵌入的平均向量范数:0.9003
- 类间余弦相似度:最小 +0.159,均值 +0.322(全部为正!)
什么意思呢?在 CLIP 的 512 维特征空间里,这 59 个类别的文本描述全部挤在同一个狭窄的锥面内,彼此之间没有负相关(即不存在"对立"的语义方向)。所以无论你输入什么图片,模型在每个像素上都只能看到一堆相似度差不多的分数,softmax 后接近于均匀分布。
换数据集无法解决这个问题。我们用随机噪声torch.randn(1, 3, 520, 520)测试时,raw cosine mean 已经是 -0.002——即在完全没有语义信息的随机输入上,视觉特征就已经和文本嵌入正交了。这说明问题出在 proj 投影层的翻译能力,而非数据集本身。
CLIP 的visual.proj权重是为全局 [CLS] token 训练的,不是为 patch-level value features 训练的。把它直接用在每个像素的 v 特征上,投影后的方向就不对——这就是 "投影层翻译坏了" 的本质。
四、其他已修复的次要问题
| 问题 | 修复位置 | 说明 |
|---|---|---|
| persistent_workers 错误 | mmseg/datasets/builder.py:145-147 | num_workers=0 时自动禁用 persistent_workers |
| _LABEL_MAP 误用 | mmseg/datasets/pascal_context.py | 已移除,依赖 reduce_zero_label=True |
| single_gpu_test 兼容性 | tools/test.py:239 | 使用 scatter_kwargs 代替 MMDataParallel |
| cv2 读取调色板 PNG | 无代码改动 | 教训:必须用 PIL Image.open() 读取 indexed/palette PNG |
| convert_clip_weights.py LN 命名 | 脚本中 norm 应改为 ln | mmcv build_norm_layer 将 LN 命名为 ln0/ln1,非 norm0/norm1 |
第三阶段:PASCAL VOC 2012 完整评估结果
maskclip_a 在 Pascal VOC 2012 上的完整评估结果
1449张验证集图片,单尺度推理,mIoU = 24.62%
每类 IoU(按降序排列):
| 类别 | IoU | 类别 | IoU |
|---|---|---|---|
| dog (狗) | 53.80% | sheep (羊) | 18.12% |
| bus (公交车) | 53.32% | train (火车) | 16.36% |
| car (汽车) | 44.01% | boat (船) | 14.72% |
| bird (鸟) | 41.43% | bicycle (自行车) | 11.77% |
| horse (马) | 40.12% | sofa (沙发) | 9.20% |
| dining table (餐桌) | 38.09% | motorbike (摩托车) | 8.31% |
| cow (牛) | 38.07% | potted plant (盆栽) | 7.18% |
| cat (猫) | 37.19% | airplane (飞机) | 5.89% |
| bottle (瓶子) | 31.05% | tv monitor (电视) | 3.76% |
| chair (椅子) | 18.67% | person (人) | 1.26% |
切换到官方 mmseg 实现后的 VOC 20 类结果
后来,我用官方 mmseg 版本的 MaskCLIP(而非简化版)重新在 VOC 2012 上跑了一遍。这次的结果是:
官方 mmseg 版 VOC 2012 每类 IoU:
| 类别 | IoU | 类别 | IoU |
|---|---|---|---|
| cat | 69.2% | cow | 54.6% |
| airplane | 64.9% | car | 53.2% |
| train | 60.1% | motorbike | 49.9% |
| dog | 59.6% | person | 49.7% |
| bus | 58.2% | bottle | 42.1% |
| horse | 57.8% | sofa | 40.5% |
| bird | 57.5% | tv monitor | 39.2% |
| potted plant | 57.0% | bicycle | 38.4% |
| sheep | 56.2% | boat | 32.1% |
| dining table | 28.8% | chair | 20.6% |
mIoU: 49.49%!
这个数字远远高于之前非官方版本的 24.62%。
对比总结
| 对比项 | e:/MaskCLIP (mmseg) | maskclippytorch |
|---|---|---|
| VOC 2012 mIoU | 49.49% | 24.62% |
| Pascal Context 59 mIoU | 0.62% | ? |
| 架构风格 | mmseg 0.x, MaskClipHead | 独立封装, MaskCLIP.forward |
| 输入分辨率 | 512×512 (bicubic pos embed) | 512×512 |
| 归一化 | ImageNet (mean/std RGB) | CLIP (mean/std) |
| v features 处理 | out_proj + v+=x residual | 直接取 transformer layer v |
原论文里两个数据集的零样本结果是:
- PASCAL Context 59 类:21.7-22.9%
- PASCAL VOC 20 类:没有直接报告原始 MaskCLIP 的零样本数字,但从 MaskCLIP+ 的提升幅度(从 ~35% 到 86%)和社区复现的 58.5% 来看,VOC 20 类的合理零样本基线应该在 50%-60% 区间。
所以这次跑出的 49.49% 在 VOC 20 类上完全是合理的。
真正的问题是 PASCAL Context 59 类只有 1.6%。那为什么 PASCAL Context 59 类只有 1.6%,而 VOC 20 类有 49.49%?关键差异已经在之前的诊断中确定了——59 个类别的文本嵌入在 CLIP 空间中高度相关,类间余弦相似度全部为正值,模型无法有效区分。简单说就是:VOC 20 类的语义空间区分度足够,而 PASCAL Context 59 类的语义空间太挤。
49.49% 对零样本 MaskCLIP 在 VOC 2012 上是合理的。它是以下因素的组合:
- 正确的骨干架构(ViT + CLIP 权重)
- 较高的输入分辨率(512 vs 224)
- 正确的 v 特征提取(
out_proj+ 残差连接)
最终结论
不是数据集的问题。
官方的 mmseg 版 MaskCLIP 在 VOC 2012(20类)上跑得相当好,mIoU = 49.49%,没有任何一个类别零分,所有20类都有显著的 IoU。这说明 mmseg 实现本身是正常工作的。
