ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

YOLO-World开放词表检测:原理、微调与部署避坑指南

YOLO-World开放词表检测:原理、微调与部署避坑指南

简介:面向开放词汇目标检测与视觉定位的YOLO-World资料包,适合PyTorch开发者、视觉方向学生及需复现或二次开发检测器的算法工程师。内容提供PyTorch实现、预训练权重及预训练/微调代码,围绕prompt-then-detect范式组织,可用于自定义词表推理、检测与grounding实验。压缩包共161个文件,以110个Python源码为核心,配合20个Markdown文档、10个文本说明,以及JSON类别文本、样例图像、Jupyter推理示例和Docker构建配置等,整体约2.35MB,便于按模块查阅源码与文档。已有883人学习下载,说明其在开放词汇检测学习中具有参考价值。读者可了解模型加载、推理入口、数据处理与容器化运行方式,并开展迁移实验、词表适配和训练流程梳理;许可证与依赖说明也有助于合规使用和环境搭建,目录结构清晰便于定位工程配置与实验模块。

1. 类别表写死的那一刻,我就想换个思路

做产线物料盘点那阵子,最烦的不是模型精度,是类别表。今天客户要认五种纸箱,明天加两种托盘,改完 yaml 重训一轮,两周就过去了。后来翻到yolo-world 的资料包(pytorch 源码加文档),核心就一句话:把检测头里的分类权重从"固定类别向量"换成"文本嵌入",类别由你现场输入的自然语言决定。它是基于成熟检测框架二次构建的开放词表检测器,支持零样本迁移、自定义词表,也能重参数化后当普通检测器跑。适合手上堆着一大把零散类别需求、不想每个场景都重训的开发者;也适合想搞明白视觉-语言对齐怎么落到检测头里的同学。下面按我拆包的顺序,从跑通、架构、训练到改造一层层往下走。

2. 环境跑通:pytorch 依赖、权重落位与第一次推理

拿到资料包先别急着pip install -r,先搞清楚里面有两条技术路线,依赖完全不是一回事。一条是官方仓库那条,基于 mm 系生态(mmengine / mmcv / mmdet / mmyolo),配置驱动、训练友好、支持分布式;另一条是上游检测框架把开放词表能力集成进去的版本,一个类就能调,推理和导出省事。两条路都能跑,但装错环境会出现"能 import 但不能训练"这种半死不活的状态,排查起来很耗人。

我的建议是:只想快速验证效果,走上游集成路线;要做微调、改结构、复现论文指标,走官方仓库路线。下面按官方仓库为主、集成版为辅来讲,因为大部分二次开发最终都会落到配置文件上。

2.1 依赖装不对,后面全是白费

最容易翻车的是 mmcv。它不是纯 Python 包,带 C++/CUDA 扩展,必须和 torch 版本、CUDA 版本三方对齐,直接从 PyPI 装大概率拿到一个不匹配的 wheel,报undefined symbol或者编译到一半挂掉。血泪经验是先定 torch,再用 mim 装 mm 系,顺序反了就要推倒重来。

# 1. 建环境,Python 版本对齐仓库 README,别用太新的 conda create -n yolo-world python=3.10 -y conda activate yolo-world # 2. 先装 torch,按本机驱动选 CUDA 版本,这一步决定了后面 mmcv 能不能编译过 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 3. 用 mim 装 mm 系,mim 会自动挑和当前 torch 匹配的版本 pip install -U openmim mim install mmengine "mmcv>=2.0.0" "mmdet>=3.0.0" "mmyolo>=0.6.0" # 4. 最后装项目自身的其余依赖 pip install -r requirements.txt

这几行的顺序是有讲究的:torch决定了mmcv的编译目标,mim会读当前环境里的 torch 版本去索引对应 wheel,所以必须在装完 torch 之后执行。--index-url指向的是官方 wheel 源,换成 CPU 版就把cu118去掉。requirements.txt放最后,是因为里面可能锁了某些和 mm 系冲突的包(比如旧版 numpy),冲突时能一眼看出来是谁覆盖了谁。

装完先做一次体检,别等到跑模型才发现环境是坏的:

import torch import mmcv, mmengine, mmdet import numpy as np print("torch:", torch.__version__, "cuda:", torch.cuda.is_available()) print("mmcv:", mmcv.__version__, "mmengine:", mmengine.__version__) print("mmdet:", mmdet.__version__, "numpy:", np.__version__) # 真正跑一次卷积,能跑通才说明 CUDA 扩展没问题,光看 is_available 会骗人 x = torch.randn(2, 3, 64, 64).cuda() print((x * 2).mean().item())

这里的重点是最后那两行。torch.cuda.is_available()返回 True 但实际算子不可用的情况我遇到过不止一次,通常是被别的包覆盖了 CUDA 运行时。跑一个真实的卷积算子,能把这类问题提前暴露。numpy 也顺手打出来,因为新版 numpy 对旧代码里np.int、np.float这类别名的移除会导致一些数据增强库直接崩,看到 2.x 就要有心理准备。

2.2 权重和配置文件怎么放

资料包里通常有四大块东西,搞混了就会出现"配置指向的文件不存在"这种低级错误。我习惯在仓库根目录建一个weights/,把外部文件统一丢进去,配置里的路径也跟着改,别散落在各个子目录。

文件类型典型位置作用常见坑
预训练权重.pthweights/模型参数,含文本分支和检测头文件名和配置里load_from不一致
模型配置.pyconfigs/下按模型规模分目录定义 backbone、neck、head 结构改结构忘了同步改这里
词表文件.json/.txtdata/或configs/同级训练用的类别名集合,映射成文本嵌入换数据集后没换词表,类别全错位
示例图片 / demo 脚本demo/快速验证图片路径相对路径,cd 错目录就找不到

词表文件是最容易被忽略的一个。它不是简单的类别名列表,训练阶段往往是一个 JSON,里面按 train/val 分成两组词汇,模型启动时会把每个词过一遍文本编码器缓存成嵌入向量。你换了自己的数据集却还用原词表,模型输出的索引和你的类别名就对不上,检测框位置看着还行,标签全是乱的。

2.3 三种推理入口,按场景选

跑通第一张图有三条路,从简单到复杂分别是:命令行 demo、Python API、自己写推理循环。先跑 demo 确认权重没问题,再用 API 接业务代码。

# 官方仓库的 demo 形式,参数顺序按仓库 README 调整 python demo/demo.py \ configs/pretrain/你的模型配置.py \ weights/你的权重.pth \ demo/sample.jpg \ "person,dog,carton" \ --topk 100 \ --threshold 0.25 \ --device cuda:0

--topk是保留的候选文本-区域匹配对数量,开放词表下不建议设太小,词表大了容易漏;--threshold是相似度阈值,和普通检测的置信度阈值不是一个量纲,通常要更低一些,0.2 到 0.3 起步比较常见。词表用逗号分隔,中文词也能跑,但实测英文提示词召回更稳,这是文本编码器的训练分布决定的。

集成版更省事,一个类搞定,适合往业务代码里塞:

from ultralytics import YOLOWorld # 加载带开放词表能力的权重 model = YOLOWorld("yolov8s-worldv2.pt") # 现场指定词表,这一步会把文本嵌入写进检测头(重参数化) model.set_classes(["carton", "pallet", "forklift", "safety vest"]) results = model.predict( source="demo/sample.jpg", conf=0.25, # 置信度阈值 iou=0.5, # NMS 的 IoU 阈值 imgsz=640, # 输入分辨率,和训练时对齐效果最好 verbose=False, ) for box in results[0].boxes: cls_id = int(box.cls) print(model.names[cls_id], float(box.conf), box.xyxy.tolist())

set_classes是整段代码的关键,它做的事就是把你的词表编码成文本嵌入,替换掉检测头里原来的分类权重,模型从此变成一个只认这几个类的普通检测器,速度也回到正常水平。names会同步更新成你传进去的词,所以后面取标签直接用model.names[cls_id]就行。conf和刚才的--threshold是同一个东西,只是换了个名字。

提示:set_classes是原地修改模型状态的,同一个 model 对象换词表会覆盖上一次的设置。业务里要来回切词表,每次切完都要重新确认model.names,别拿旧索引去查新词表。

跑通这一步大概能覆盖六成使用场景:给个词表,图里找对应物体。但如果你发现某些词死活检测不出来,别急着怀疑权重,先去看第 5 章的排查清单,八成是词表表达和训练分布对不上。

3. 架构拆解:一个 batch 在模型里怎么走

理解结构比背 API 有用得多,尤其当你需要判断"我的类别检测不出来到底是哪一环坏了"的时候。整个模型可以粗暴切成三块:图像分支、文本分支、以及把两者缝在一起的融合层。图像分支就是标准的检测器骨架,文本分支是一个冻结的文本编码器,融合层才是这个项目真正新增的东西。

3.1 整体链路:从像素到相似度矩阵

图像侧走的是经典的单阶段检测结构:CSP 结构的 Darknet backbone 逐级下采样,抽出 C3/C4/C5 三个尺度的特征图,再进一个 PAN 结构的 neck 做自顶向下加自底向上的双向融合,最后三个解耦头分别输出每个位置的框回归和类别预测。

文本侧简单得多:词表里的每个类别名先套一个提示词模板(常见做法是a photo of a {class}这种),拼成一个句子列表丢进文本编码器,得到形状[N, D]的嵌入矩阵,N 是词表大小,D 是文本嵌入维度(通常 512),做完 L2 归一化后缓存起来,训练时整个 epoch 都不变,属于"算一次用到底"的角色。

关键差异在检测头。普通检测器的分类分支是一个Conv输出num_classes维向量,每个维度硬绑一个类别;这里的分类分支输出的是视觉嵌入,然后和文本嵌入矩阵做矩阵乘法得到一个[位置数, N]的相似度矩阵,再走 sigmoid 得到每个位置的类别得分。类别不再是权重里的固定一列,而是运行时可替换的一组向量,这就是"开放词表"的物理实现。

用一个简短的伪代码把这条链路串起来:

# 仅示意前向数据流,具体模块名以仓库实现为准 def forward(self, images, txt_feats): # images: [B, 3, H, W] # txt_feats: [N, D],N 为词表类别数,D 为文本嵌入维度 feats = self.backbone(images) # 多尺度视觉特征 # 关键一步:文本反过来调制视觉特征,让特征"知道"当前要找什么 feats = self.neck(feats, txt_feats) cls_scores, bbox_preds = [], [] for feat in feats: # 视觉特征投到文本空间,再和文本嵌入做点积 visual_embed = self.text_proj(feat) # [B, D, H, W] # einsum 得到每个空间位置与每个类别的相似度 score = torch.einsum("bchw,nd->bnhw", visual_embed, txt_feats) cls_scores.append(score.sigmoid()) bbox_preds.append(self.reg_head(feat)) return cls_scores, bbox_preds

einsum那一行是整个模型的灵魂,它把"分类"从查表变成了相似度计算。txt_feats换掉,输出语义就跟着换,不需要动任何一个卷积权重。这也解释了一个常见困惑:为什么同一张图换词表后,框的位置会微微变化?因为 neck 里的文本调制也参与了特征生成,不只是最后那一下点积。

3.2 RepVL-PAN:文本怎么反过来调制视觉

名字里的 RepVL 是"可重参数化的视觉-语言"的意思,PAN 就是那个特征金字塔。它在标准 PAN 的基础上插了两类模块,一类管"文本指导视觉",一类管"视觉补充文本"。

先看文本指导视觉的那一层,本质是一个通道门控:

import torch import torch.nn as nn class TextGuidedLayer(nn.Module): """用文本嵌入生成逐通道的门控权重,调制视觉特征""" def __init__(self, channels, text_dim, reduction=16): super().__init__() # 文本维度 -> 视觉通道数,中间降维减少参数量 self.text_proj = nn.Sequential( nn.Linear(text_dim, channels // reduction, bias=False), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels, bias=False), ) self.norm = nn.BatchNorm2d(channels) self.act = nn.SiLU(inplace=True) def forward(self, x, txt_feats): # x: [B, C, H, W],当前层视觉特征 # txt_feats: [B, N, D],N 为词表大小,D 为文本嵌入维度 txt = txt_feats.mean(dim=1) # 沿类别维求均值 -> [B, D] w = torch.sigmoid(self.text_proj(txt)) # 门控权重 -> [B, C] w = w.unsqueeze(-1).unsqueeze(-1) # [B, C, 1, 1] return self.act(self.norm(x * w)) # 通道级调制

三个参数要盯住:channels必须等于该层视觉特征通道数,写错了直接 shape 报错;text_dim必须等于文本编码器输出维度,两者不一致时中间要加投影层;reduction控制中间瓶颈宽度,默认 16 是个参数量和表达力的折中,词表特别大时可以调小一点保留更多信息。

用sigmoid而不是softmax是有原因的。softmax会让通道之间互相竞争,总和恒为 1,等于强行压缩特征能量;sigmoid是独立的 0 到 1 门控,允许某些通道被放大、某些被抑制,不会破坏特征的尺度。这种细节在改结构时很容易被忽略,换成softmax之后精度掉一两个点,还找不到原因,属于典型的玄学故障。

另一个模块解决相反方向的问题:文本嵌入是脱离图像的,同一个词在任何图里都一样,缺乏上下文。做法是把视觉特征池化成固定数量的 token,和文本嵌入拼在一起做注意力:

class ImagePoolingAttention(nn.Module): """视觉上下文回写文本:把图像池化成 3x3 token,与文本拼起来做自注意力""" def __init__(self, embed_dim, num_heads=8, pool_size=3): super().__init__() # 用固定池化尺寸,保证不同输入分辨率下 token 数不变 self.pool = nn.AdaptiveAvgPool2d(pool_size) self.num_tokens = pool_size ** 2 self.mha = nn.MultiheadAttention(embed_dim, num_heads, batch_first=True) def forward(self, txt_feats, visual_feats): # visual_feats: [B, C, H, W],从 PAN 的某一层取 # txt_feats: [B, N, C],注意这里通道必须已经对齐到 embed_dim v = self.pool(visual_feats).flatten(2).transpose(1, 2) # [B, 9, C] kv = torch.cat([txt_feats, v], dim=1) # [B, N+9, C] out, _ = self.mha(kv, kv, kv) # 自注意力 return out[:, : txt_feats.shape[1]] # 只取回文本部分

pool_size=3意味着固定 9 个视觉 token,这是为了在导出和重参数化时形状可预期。self.mha的embed_dim必须和文本维度一致,视觉特征通道数不是这个值时,前面要加一层 1x1 卷积做投影,这是新手最容易漏的一步。返回时只切回文本部分,视觉 token 用完就丢,因为它们的作用只是给文本注入当前图像的上下文。

注意:这两个模块是串联在 PAN 的每一层上的,改动其中一个的通道数,要同步检查相邻层的输入输出,否则会在 neck 中间炸掉。

3.3 重参数化:为什么推理还能跑回 YOLO 的速度

训练时模型带着文本分支,要跑文本编码器、要做跨模态注意力,比普通检测器慢一截。但推理时根本不需要这些:词表是固定的,文本嵌入算一次就够,融合层的门控权重也是固定的。所谓重参数化,就是把这部分预计算的结果直接烘焙进检测头的权重里,之后整个文本分支可以整个丢掉。

流程大致是三步:先把词表编码成嵌入矩阵,再让它过一遍融合层算出对应的门控参数,最后把这些参数固化到 neck 和 head 的权重里。做完之后,模型结构上和普通检测器没差别,速度也对齐了。

# 集成版里这一步被封装成了一个调用,本质就是重参数化 model.set_classes(["carton", "pallet", "forklift"]) # 验证一下:names 变了,模型结构里也不再有文本分支的参与 print(model.names) # {0: 'carton', 1: 'pallet', 2: 'forklift'} print(model.model[-1].nc) # 检测头类别数已同步

这也解释了一个现象:为什么改词表之后必须重新set_classes,不能直接改names字典。改names只是改标签显示,检测头里还是上一组嵌入,输出语义完全对不上。我见过有人为了省事直接改字典,结果检测框全在,标签全错,排查了半天以为是权重加载错了。

4. 训练链路:数据组织、损失函数与微调配置

推理跑通了,接下来是自己训练。这块的门槛比推理高不少,主要卡在数据格式和显存上。开放词表训练和普通检测训练最大的区别是:标注不只要有框,还要有和框对应的文本描述,而且词表是动态的。

4.1 数据组织:框标注加词表文件

检测框部分还是老样子,YOLO 格式的 txt,每行class_id cx cy w h,坐标归一化到 0 到 1:

# annotations/000001.txt # 每行: class_id center_x center_y width height(均为归一化值) 0 0.4821 0.5310 0.2130 0.3870 2 0.7315 0.6120 0.1540 0.2210 1 0.2043 0.4780 0.0980 0.3120

真正需要额外准备的是词表。训练时模型的类别数不是固定的,而是跟着词表走,所以要先给一份类别名清单,再给一份索引到名字的映射:

{ "train": ["carton", "pallet", "forklift", "safety vest", "oil drum"], "val": ["carton", "pallet", "forklift"] }

train是训练时参与文本编码的词,val是验证时用的子集。两个列表可以不一样,这正好对应开放词表的评估逻辑:训练见过的词和评估用的词不必完全相同,跨词表泛化能力就是这么测出来的。移植到自己数据时,class_id必须和train列表的下标严格对应,错一位就全乱。

提示:类别名尽量用英文、名词、单数,别写 "红色的纸箱子" 这种描述性短语。文本编码器是在图像-文本对上训出来的,它对名词短语的响应最好,长描述反而会稀释语义。

4.2 损失函数与配置项

配置是 mm 系那种字典嵌套风格,改的时候像在改 Python 代码,比 yaml 灵活但也更容易写错。下面只保留和开放词表相关的关键字段:

# 路径按仓库实际配置文件调整,这里只截取结构性字段 model = dict( type="YOLOWorldDetector", backbone=dict( type="YOLOv8CSPDarknet", deepen_factor=0.33, # 控制深度,s/m/l 靠这个和 widen 区分 widen_factor=0.50, # 控制宽度,显存不够就往下调 ), neck=dict( type="YOLOWorldPAFPN", text_channels=512, # 必须等于文本编码器输出维度 embed_channels=[256, 256], # 必须和 backbone 各层输出通道对齐 guide_channels=512, # 文本指导分支的中间维度 ), bbox_head=dict( type="YOLOWorldHead", num_classes=len(CLASSES), # 跟着词表走,不要写死 reg_max=16, # DFL 的离散区间数,一般不改 loss_cls=dict(type="BCELoss"), # 分类走二值交叉熵 loss_bbox=dict(type="IoULoss", loss_type="ciou"), # 框回归 loss_dfl=dict(type="DFLoss"), # 分布式焦点损失 ), train_cfg=dict( assigner=dict( type="TaskAlignedAssigner", topk=10, # 每个 GT 选多少个预测点做正样本 alpha=0.5, # 分类分与 IoU 的平衡系数 beta=6.0, # IoU 的幂次,越大越偏向高 IoU ), ), )

num_classes这行是最常改的,但很多人改错方向:它应该等于词表大小,不是你的业务类别数。词表里塞了 500 个词,这里就写 500,哪怕你的数据只标了 3 类。alpha和beta是任务对齐分配器的核心,alpha大一点更看重分类质量,beta大一点更看重框的贴合度,一般不动,除非你的数据小目标特别多。

损失侧还有一个只在训练时存在的项:区域-文本对比损失。它要求图像里每个区域的特征和它对应的文本描述相似、和其他文本不相似,是开放词表能力的来源。这一项通常不需要手动配权重,跟着仓库默认走;但如果你的数据里有些类别标注很稀疏,可以考虑单独调低这一项的权重,避免它把分类分支带偏。

训练命令按卡数选:

# 单卡 python tools/train.py configs/你的配置.py --amp --work-dir work_dirs/exp1 # 多卡,第二个参数是卡数 bash tools/dist_train.sh configs/你的配置.py 8 --amp --work-dir work_dirs/exp1

--amp混合精度基本是必开的,省显存还能快一点,但要注意有些自定义损失在 fp16 下会溢出,出现 loss 变 NaN 就先关掉它验证一下。--work-dir一定要显式指定,不然日志和权重混在默认目录里,跑几轮就分不清哪个是哪个了。

4.3 在自己的数据上微调

完全从零训开放词表检测器不现实,数据量根本不够。常规做法是加载预训练权重做微调,再配合冻结策略控制遗忘。

# 加载预训练权重,只训检测头和 neck 的融合层 python tools/train.py configs/你的配置.py \ --cfg-options load_from=weights/预训练权重.pth \ --amp \ --work-dir work_dirs/finetune

load_from和配置文件里的load_from是同一个字段,写在命令行里更方便切换。微调时有几个经验值:学习率比从头训小一个数量级,backbone 前两个 stage 可以冻结,EMA 一定要开(开放词表的分类分支波动比较大,EMA 对最终精度影响明显)。数据量少于几百张时,建议把 backbone 整个冻住只训 neck 和 head,否则很容易过拟合到几个样本上。

训练日志里重点看两个指标:loss_cls和验证集上的 mAP。loss_cls震荡但不发散是正常的,开放词表的相似度计算本身就有噪声;如果它连续几个 epoch 单调上涨,先检查词表和标注的索引是否对齐,这是最常见的原因。

5. 避坑与排查:跑 YOLO-World 最容易翻车的五件事

下面这五条全是我自己踩过的,按踩到的频率排序。每条都按"看到什么现象、真实原因是什么、怎么解决"来写,方便你对着症状直接查。

5.1 词表里的词检测不出来,换个说法就有了

现象:设了"car",图里明明有车,一个框都没有;改成"automobile"或者"a photo of a car",框立刻出来了。

原因:开放词表的召回强烈依赖文本嵌入和训练词表的分布匹配。预训练用的词汇表里某些概念是用特定词表达的,你换一个近义词,嵌入向量就偏了,相似度掉到阈值以下。这不是模型坏了,是文本侧的语义漂移。

解决:给每个业务类别准备 2 到 4 个同义表达,一起塞进词表,让模型多路召回;同时把conf阈值先降到 0.15 左右看有没有候选,确认是阈值问题还是真的没有响应。实在不行就上提示词模板,a photo of a xxx这种对多数类别都有小幅提升。

5.2 import 就报 undefined symbol

现象:import mmcv或者跑训练脚本时直接抛ImportError: undefined symbol: ...,或者No module named 'mmcv._ext'。

原因:mmcv 的 C++ 扩展和当前 torch/CUDA 版本不匹配。多半是先用 pip 装了 torch,之后又装了 CPU 版的 mmcv,或者反过来,二进制接口对不上。

解决:别硬修,直接重建环境。顺序固定成"torch → mim install mm 系 → 项目依赖",全程不要手动pip install mmcv。已经污染的环境,卸载重装往往比排查快。

5.3 训练第二个 batch 就 OOM

现象:batch_size=2都能爆显存,或者训到一半显存缓慢上涨最后挂掉。

原因:开放词表训练除了常规的检测计算图,还要缓存文本嵌入、维护跨模态注意力的中间变量,显存占用比同规模的普通检测器高一截。另外多尺度训练时最大尺度那张图是显存峰值,配置里写了multi_scale就容易踩到。

解决:先开--amp;再把base_lr按 batch 线性缩放(batch 减半,学习率也减半),不要只改 batch;还不行就关掉多尺度训练,固定imgsz;最后的手段是开梯度检查点,用时间换显存。

5.4 微调之后只认新类别,原词表全丢

现象:拿自己的数据微调完,新类别检测挺好,但原来能检出的通用类别全部消失,模型像是"变傻了"。

原因:分类分支被新词表整体覆盖,加上学习率过大、backbone 没冻结,模型把之前的视觉-语言对齐关系冲掉了。这是典型的灾难性遗忘。

解决:词表用并集,把原词表和新类别合在一起训;backbone 前两个 stage 冻结;学习率降一个数量级;打开 EMA。如果数据量很少,只训 neck 和 head,backbone 完全不动,精度反而更好。

5.5 导出 ONNX 之后输出对不上

现象:模型导出成功,但推理结果的框和 PyTorch 侧差很多,或者输出张量维度不对,类别数也不是你设的那些。

原因:直接导出一个还带文本分支的模型,ONNX 图里会多出文本输入,形状不固定,很多推理引擎处理不了;或者忘了先做重参数化,导出的还是原始词表。

解决:导出前必须先set_classes完成重参数化,确认model.names和model.model[-1].nc都变了,再导出。导出后用固定 shape 的随机输入跑一遍,和 PyTorch 侧的输出逐个比对,差在 1e-3 以内才算过关。

6. 二次开发:把开放词表接进业务链路

真正落地的时候,模型只是链路里的一环,剩下的是词表管理、阈值策略、批量推理和导出。这部分没有标准答案,只有几个我自己验证过好用的做法。

6.1 词表动态切换与后处理

业务里常见需求是同一个进程服务多个场景,每个场景一套词表。频繁调set_classes会有一次文本编码开销,可以按词表做缓存,用哈希做 key:

from ultralytics import YOLOWorld import hashlib class WordlistDetector: def __init__(self, weight_path): self.model = YOLOWorld(weight_path) self._current_key = None def _key(self, words): # 词表顺序会影响类别索引,所以用排序后的拼接做 key return hashlib.md5(",".join(sorted(words)).encode()).hexdigest() def detect(self, image, words, conf=0.25): key = self._key(words) if key != self._current_key: self.model.set_classes(words) # 只在词表变化时重参数化 self._current_key = key # agnostic_nms 关掉,避免不同类别互相压制 return self.model.predict(image, conf=conf, iou=0.5, agnostic_nms=False)

agnostic_nms=False这一项值得单独说。开放词表下同一个物体可能被多个近义词同时命中(carton和box都框住同一个箱子),如果开类别无关的 NMS,会丢掉其中一个,看起来像是漏检;关掉之后保留多个标签,在业务层再按优先级去重更可控。

6.2 导出与一致性验证

部署侧一般走 ONNX 或者 TensorRT,导出脚本很短,但验证不能省:

import numpy as np import onnxruntime as ort import torch # 导出前必须完成重参数化,否则图里还带文本分支 model.set_classes(["carton", "pallet", "forklift"]) model.export(format="onnx", opset=12, simplify=True, imgsz=640) # 用固定输入比对 PyTorch 与 ONNX 输出 sess = ort.InferenceSession("yolov8s-worldv2.onnx") x = np.random.rand(1, 3, 640, 640).astype(np.float32) onnx_out = sess.run(None, {sess.get_inputs()[0].name: x}) with torch.no_grad(): torch_out = model.model(torch.from_numpy(x)) diff = max( float(np.abs(o.detach().numpy() - n).max()) for o, n in zip(torch_out, onnx_out) ) print("max diff:", diff) # 超过 1e-3 就别急着上线

opset选 12 是兼容性比较好的一个档位,太新的算子某些推理引擎还不支持。比对的时候注意输出顺序,PyTorch 侧和 ONNX 侧的张量排列可能不同(一个[B, C, N]、一个[B, N, C]),先打印 shape 对齐再做差,不然会误判成导出失败。

6.3 一个我反复验证的习惯

从那以后我每次改完词表或者换数据集,都强制走一遍固定流程:先打印model.names和检测头的nc,再拿一张必定包含目标类别的图跑一次,确认至少有一个框,最后才跑到批处理上。原因很简单,开放词表这条链路上有三处地方都能让类别悄悄错位——词表顺序、set_classes的调用时机、重参数化后的权重固化——任何一处错了,模型都不会报错,只会安静地给你一堆框配上错的标签。这种静默失败比崩溃难查十倍,加三步确认花不了两分钟。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进