☰
YOLOv11多任务联合训练:检测分割计数一次搞定
2026/10/5 4:02:47 网站建设 项目流程

简介:这份PDF围绕YOLOv11展开多任务联合训练方案,面向计算机视觉开发者、算法工程师及有深度学习基础的读者,解决单一模型难以同时高效完成目标检测、图像分割与目标计数的问题。资源包内含1个PDF文件,大小约2.2MB,共48页,内容完整、条理清晰,支持目录章节跳转和阅读器大纲快速定位,PDF内文字、图表、目录元素均正常显示,可放心查阅使用。目前已有123人浏览学习,适合作为系统学习YOLOv11多任务方案的参考资料。文档从YOLOv11的网络结构与创新点讲起,系统展开多任务框架设计、特征共享与任务分支搭建、三类损失函数的组合方式、数据准备与预处理、模型训练优化及实验结果分析,并覆盖智能交通、工业检测、安防监控等应用案例,可直接为工程实践中的多任务模型搭建与调优提供参考,也适合作为相关课程或技术分享的补充材料。

1. 为什么要把检测、分割、计数揉进一个模型

做工业视觉和农林业统计的同行应该都有过这种经历:一套产线要数传送带上的零件数量,又要框出缺陷位置,还得把缺陷区域的轮廓抠出来给机械臂做抓取路径。按传统思路,检测训练一个模型,分割训练一个模型,计数靠检测框数量硬算。三个模型分别部署,三份显存、三份推理延迟,还要写一堆胶水代码把结果对齐到同一个坐标系。YOLOv11虽然没有官方叫“多任务框架”的版本,但它的模型结构天然支持检测和分割共用一个主干,只要在头部加一个计数分支,就能把三件事塞进一次前向推理。这篇笔记就把我实际跑通的联合训练方案讲清楚:从数据标签怎么凑齐,到模型结构怎么改,再到损失权重怎么调。

这套方案适合谁?搞缺陷检测要同时输出类别、掩码和数量的工程师,或者做车辆、行人、果实计数但不想维护多套模型的团队。前提是你已经跑通过Ultralytics的基础训练流程,因为下面所有改动的落点都在这个框架里。

2. 训练前的数据准备:检测框、分割掩码、计数标签怎么凑齐

2.1 三套标签的来源与选择:LabelMe还是CVAT

多任务训练的第一步不是写代码,是把三套标签对齐到同一张图上。检测和分割的标签通常能一起标:在LabelMe或CVAT里画完多边形,既能导出检测框(外接矩形),也能导出分割掩码。计数标签则麻烦一点,它有两种形态:一种是把每个物体当作一个点,用点坐标做密度图回归;另一种是直接给整张图一个计数值,训练时用全局回归头去拟合。第一种精度高但标注成本大,第二种标注快但模型学不到空间分布信息。

我一般建议按场景选:场景里物体密集、遮挡严重,比如果园果实计数,用密度图方案;场景里物体稀疏、只需要总数,比如小批量产线零件计数,用全局计数值回归就行。下面的转换脚本处理的是LabelMe导出的JSON,它同时能产出检测框和分割掩码的YOLO格式,计数标签则单独生成一个CSV。

2.2 把LabelMe多边形转成YOLO分割格式:转换脚本与参数说明

YOLO分割格式的标签是一行一个目标:首先是类别ID,然后是一长串归一化的多边形顶点坐标。检测框格式则是class_id x_center y_center width height。写一个转换脚本就能一次搞定:

import json import numpy as np from pathlib import Path def labelme_to_yolo_seg(labelme_json_path: str, out_txt_path: str, class_map: dict): """把单个LabelMe标注JSON转成YOLO分割格式的txt文件。 Args: labelme_json_path: LabelMe导出的JSON文件路径 out_txt_path: 输出的YOLO格式txt路径 class_map: 标签名到类别ID的映射,例如 {'defect': 0, 'person': 1} """ with open(labelme_json_path, 'r', encoding='utf-8') as f: data = json.load(f) img_w = data['imageWidth'] img_h = data['imageHeight'] lines = [] for shape in data['shapes']: if shape['shape_type'] != 'polygon': # LabelMe里'circle'和'rectangle'需要另外转换,多边形最常见 continue label = shape['label'] if label not in class_map: continue cls_id = class_map[label] pts = np.array(shape['points'], dtype=np.float64) # 归一化:坐标除以图像宽高,得到0~1之间的比例值 pts[:, 0] = np.clip(pts[:, 0] / img_w, 0.0, 1.0) pts[:, 1] = np.clip(pts[:, 1] / img_h, 0.0, 1.0) # YOLO分割格式: class_id + 一维展平的多边形顶点(x1,y1,x2,y2,...) coords = pts.flatten() line = f"{cls_id} " + " ".join(f"{v:.6f}" for v in coords) lines.append(line) with open(out_txt_path, 'w', encoding='utf-8') as f: f.write("\n".join(lines))

这段脚本的关键点是顶点坐标必须归一化到0到1之间,且不能超出图像边界——边界外的顶点在训练时会让mask损失变成NaN。另外LabelMe导出的多边形坐标没有闭合(首尾不重复),YOLO格式也不要求闭合,直接展平即可。如果标注时用了矩形,需要转成四点多边形再进这个脚本。

检测框的转换更简单,从多边形的顶点取最小外接矩形就行。但有一个常见误用:直接用np.min和np.max取矩形时,如果物体正好贴图边缘,算出来的宽或高可能是0,训练时会被当成无效目标丢掉。我一般会加一个1像素的padding,确保宽高至少为1。

2.3 计数标签怎么挂到图像上

计数任务和检测、分割的标签结构不一样,得单独处理。我的做法是写一个counts.csv,每行是图像文件名,计数值,训练时按文件名索引读进来,和检测分割标签同步喂给模型。下面是计数标签生成脚本,用来统计每张图的多边形数量:

import json import csv from pathlib import Path def generate_count_labels(json_dir: str, csv_path: str, target_labels: set): """统计每张图中指定类别的目标数量,生成计数标签CSV。 Args: json_dir: 存放LabelMe JSON的目录 csv_path: 输出CSV路径 target_labels: 需要计数的类别名,例如 {'defect'} """ json_files = list(Path(json_dir).glob('*.json')) with open(csv_path, 'w', newline='', encoding='utf-8') as f: writer = csv.writer(f) writer.writerow(['filename', 'count']) for jf in json_files: with open(jf, 'r', encoding='utf-8') as f_json: data = json.load(f_json) count = sum( 1 for shape in data['shapes'] if shape['label'] in target_labels and shape['shape_type'] == 'polygon' ) writer.writerow([jf.stem + '.jpg', count])

这里要注意一个坑:如果你的数据里有遮挡,一个目标被分成了两个多边形,计数会偏大。标准做法是标注阶段就约定“遮挡超过50%的目标不标”,或者用点标注的密度图方案去回避这个歧义。我当时做的是果实计数,用的是密度图方案而不是全局计数值,因为密集场景里总数回归会让模型学到“反正图里一堆果子,大概输出30个”这种偷懒行为,边界情况完全失控。

3. 搭出多任务模型:YOLOv11检测头、分割头和计数分支怎么组合

3.1 先看清YOLOv11的结构:Backbone、Neck与多Head的关系

YOLOv11的模型结构在Ultralytics里由YAML文件定义,核心组成是Backbone(CSPNet变体)、Neck(PAN-FPN)和Head。检测和分割的差异主要在Head:检测头输出边界框和类别概率,分割头在检测头后面多接了一条掩码分支,用一组prototype掩码和系数做矩阵乘法,恢复出每个实例的分割结果。

多任务训练的关键在于:三个任务共享Backbone和Neck的特征,只把最后的一层换成三个并行Head。这样做的好处是特征提取部分被三个任务共同约束,泛化能力更强;坏处是如果某个任务梯度噪声太大,会污染共享层的参数。所以第4章的损失权重才需要仔细调。

在Ultralytics框架里,yolo11n-seg.pt这个权重本身就同时带检测头和分割头,训练时配置task=segment就能两者一起训。我们的改动只是在分割头旁边再挂一个计数分支。

3.2 在Ultralytics里增加一个轻量计数头:YAML与注册代码

Ultralytics框架的自定义模型有两种改法:一种是在YAML里直接改Head定义,另一种是写Python模块注册进去。计数分支本身不复杂——由共享Neck输出的特征图,经过一个1×1卷积生成密度图,然后全局平均池化回归出计数值。我先把自定义模块写出来:

import torch import torch.nn as nn class CountHead(nn.Module): """轻量计数头:输出密度图 + 全局计数值。 输入是Neck输出的特征图(B, C, H, W),输出是: - density_map: 密度图(B, 1, H, W),每个像素估计局部目标密度 - count: 整图计数值(B, 1) """ def __init__(self, in_channels: int = 256): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_channels, 64, kernel_size=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 1, kernel_size=1) ) # 用自适应平均池化把密度图压成一个标量 self.pool = nn.AdaptiveAvgPool2d(1) def forward(self, x): density_map = self.conv(x) count = self.pool(density_map).flatten(1) return density_map, count

然后在YAML模型文件里,把Head部分从Segment扩展成同时挂Detect、Segment和CountHead。下面是简化后的结构示意:

# yolov11-multi.yaml (骨架省略,只列关键部分) head: - [-1, 1, Conv, [64, 3, 2]] # 在检测/分割头之前再接一层下采样 - [-1, 1, Detect, [nc]] # 检测头,nc是类别数 - [-1, 1, Segment, [nc, nm, 32]] # 分割头,nm是掩码原型数 - [-1, 1, CountHead, []] # 计数头,读取Neck对应层特征

实际使用时,这三个Head读取的是Neck不同分辨率的特征图,我会在代码里做特征选择,一般取分辨率最高的一层给计数头用,因为计数对空间细节更敏感。

3.3 三个头的Loss怎么合并:别直接相加就完事

有了三个输出头,Loss对应的默认行为是只回传给对应任务的那个头——这在单任务框架里没问题,但联合训练必须手动合并。在Ultralytics里通常是给模型写一个自定义loss方法。下面是我用的合并逻辑:

def multi_task_loss(preds, batch, w_det=1.0, w_seg=1.0, w_cnt=1.0): """综合检测、分割、计数三个损失。 Args: preds: 模型输出的tuple (det_out, seg_out, density, count) batch: 一个batch的标注数据 w_det, w_seg, w_cnt: 三个任务的损失权重 """ det_loss, seg_loss = compute_det_seg_loss(preds[:2], batch) # 计数损失:密度图用MSE逐像素监督,计数值用L1监督 density_pred = preds[2] # (B, 1, H, W) count_pred = preds[3] # (B, 1) # 密度图标签需要先由标注点生成高斯核,这一步在数据加载时做 density_gt = batch['density_map'] # (B, 1, H, W) count_gt = batch['count'] # (B, 1) cnt_density_loss = nn.functional.mse_loss(density_pred, density_gt) cnt_count_loss = nn.functional.l1_loss(count_pred, count_gt) cnt_loss = cnt_density_loss + 0.5 * cnt_count_loss total_loss = w_det * det_loss + w_seg * seg_loss + w_cnt * cnt_loss return total_loss, {'det': det_loss.item(), 'seg': seg_loss.item(), 'cnt': cnt_loss.item()}

这里有个容易忽视的点:det和seg的损失量级天然不同。检测Box Loss在几十到几百,分割Mask Loss通常在个位数。如果直接w_det * det_loss + w_seg * seg_loss,梯度会被大的那一方主导。所以权重初值的经验是:先跑20个epoch,看三个loss的均值量级,再按量级反比设定权重,而不是拍脑袋定。

4. 联合训练的参数设计:损失权重、Batch Size、学习率与数据比例

4.1 损失权重是联合训练最玄学的参数:先从对齐量级开始

刚开始训练的时候,我们按3.3的合并逻辑跑了一遍,翻车来得很快:检测mAP正常,分割mAP也不错,但计数误差完全没降——损失曲线显示cnt_loss一直在0.1到0.2之间震荡。原因很简单,这个回归任务的loss量级远小于检测和分割loss,在反向传播时占比太低。

我建议的调节方法分三步。第一步,固定w_det=1.0、w_seg=1.0,只调w_cnt,从0.1、1.0、10三档里试。第二步,观察density_map的可视化结果,如果密度图里亮斑位置和物体位置对得上,说明共享特征已经被分割或检测任务带起来了,只是数值偏小,可以把cnt_count_loss的系数降下来;如果亮斑位置乱漂,说明计数头自己学到了错误的空间模式,要加大w_cnt。第三步,把三个loss曲线拉平——理想状态是训练到后期三个loss按各自的权重加权后,总loss的下降曲线没有明显平台期。

加权的实现我一般放在训练脚本里。Ultralytics的训练接口在model.loss回调处可以覆盖,或者直接改数据加载器里对batch['count']的生成方式。

4.2 最小可复现训练配置:命令与参数逐项说明

数据准备和模型结构都就位后,我用下面的命令启动训练:

yolo train \ model=yolo11n-seg.pt \ data=multi_task.yaml \ batch=16 \ imgsz=640 \ epochs=100 \ lr0=0.01 \ lrf=0.01 \ warmup_epochs=3 \ device=0 \ val=True \ save_period=5

参数说明如下:

  • model=yolo11n-seg.pt:用预训练分割权重做初始化。关键是它的Backbone和Neck已经在大规模数据上学到了通用特征,比自己从yolo11n.pt(纯检测权重)冷启动快很多,对小数据集尤其明显。
  • batch=16:多任务训练的内存占用明显高于单任务,因为分割头需要保存原型掩码和特征图。如果梯度累积没开,先减少batch,不要硬撑。
  • lr0=0.01:单任务用0.01没问题,但联合训练时共享层接收三个任务梯度,有效梯度幅值变大,0.01可能会让早期loss爆炸。如果前10个epoch loss不降反升,降为0.005再试。
  • warmup_epochs=3:预热轮数可以从默认的3加大到5,让三个Head先各自收敛一小段,再开始联合更新共享层。

multi_task.yaml的数据集文件和数据加载也要跟着改,核心是指定三个任务的标签路径:

# multi_task.yaml path: ./multi_task_data train: images/train val: images/val # 类别映射,检测和分割共用这套 names: 0: defect 1: person # 计数标签路径(我自己的约定,框架不会自动读,需要在数据集代码里手动加载) count_train: labels/train/counts.csv count_val: labels/val/counts.csv

4.3 任务冲突与数据缺失:部分图像没有计数标签怎么办

实际项目里不是每张图都有全套标签——可能检测框标了,分割掩码没标,或者只有一部分图有计数标签。如果直接训练,batch['count']就会缺失,导致梯度回传断裂。处理方式有两种常见做法:一是把没有某类标签的图像从该任务的loss计算里去除,二是做任务条件采样,保证每个batch里三种标签的样本数大致均衡。

我采用的是第二种,用一个自定义的MultiTaskDataset,在取样本时做标签组合采样:

class MultiTaskDataset: """按任务标签完整度做采样的数据集。 每一轮训练,先按batch里三种任务的覆盖率采样图像, 优先级是:三标签完整 > 检测+计数 > 仅检测。 """ def __init__(self, img_dir, det_label_dir, seg_label_dir, count_csv): self.img_paths = sorted(Path(img_dir).glob('*.jpg')) self.det_labels = {...} # 文件名 -> 检测标签路径 self.seg_labels = {...} # 文件名 -> 分割标签路径 self.counts = {...} # 文件名 -> 计数值 # 把样本按标签组合分桶 self.bucket_full = [p for p in self.img_paths if p in self.counts] self.bucket_partial = [p for p in self.img_paths if p not in self.counts] def sample_batch(self, batch_size): # 保证每个batch里至少有一半样本来自三标签完整的桶 n_full = batch_size // 2 n_partial = batch_size - n_full return random.sample(self.bucket_full, n_full) + random.sample(self.bucket_partial, n_partial)

这样做的原因是:如果计数分支只在部分样本上计算loss,它占的batch比例太低,收敛速度会被拉得很慢。强行把所有样本都算计数loss,又会让模型把没有计数标签的图当作“数量为0”来学,这是相当隐蔽的坑。

4.4 数据比例与数据增强的坑:同一个增强要同步作用于检测框和分割掩码

多任务训练里数据增强的同步问题值得单独说。检测和分割共用图像变换,比如随机翻转、缩放、裁剪都好处理;问题出在Mosaic增强上——四张图拼在一起后,分割掩码也要跟着做同样的拼接变换。Ultralytics的Segment任务类型已支持Mosaic同步,但你额外加的密度图标签可不会跟着自动变换,得在数据加载器里手动给密度图做同样的仿射变换。

我的做法是取消Mosaic增强,使用它提供的scale=0.5和hsv_h=0.015这类轻量增强。虽然单任务里Mosaic能提点,但联合训练里它对密度图标签的破坏性大于收益。省下的精力可以用在更稳妥的随机翻转上——水平翻转需要注意,如果计数任务对方向敏感,比如车辆计数不分方向,翻转没问题;但如果是带方向的缺陷计数,翻转后计数标签不变,模型就要学到翻转不变性,会多消耗一部分学习容量。

5. 联合训练的常见坑与排查记录

5.1 分割掩码全黑,但检测框输出正常

现象:训练到第20个epoch,检测框的精度已经能看,但分割头输出的掩码图全黑,里面没有任何白色区域。

原因:多边形的顶点顺序在标注时方向不一致。LabelMe导出时有的多边形是顺时针,有的是逆时针,而YOLO分割格式要求顶点按连续顺序排列,但不强制起点的位置。掩码分支在轮廓还原时用了顶点的前后顺序去填充多边形,方向不一致导致部分多边形被当成空洞挖掉了。更常见的原因是顶点坐标的归一化没有乘以图像宽高,而掩码在特征图上的投影错位了。

解决:在转换脚本里检查多边形面积是否小于1像素,顺手用convex_hull重新排序顶点方向。再写一个简单的回读脚本,把txt里的分割掩码画回原图上,人工抽查10张,比在训练代码里debug语义分割的loss快得多。

5.2 计数分支Loss不降,且密度图的高亮处和物体位置无关

现象:cnt_loss在头30个epoch里几乎不动,可视化密度图发现高亮区域出现在图像边缘角落,有时甚至天空背景上有尖峰。

原因:计数头的初始权重太大,或者共享Backbone提取的特征没有经过有效的空间归一化。最常见的实际原因是:我们直接用了Segment任务预训练权重,它的特征图通道分布在检测头和分割头上已经适配过,计数头用小的1×1卷积去接这个特征,初始梯度方向是随机的。加上计数Loss的量级本来就小,早期梯度被检测和分割压制。

解决:给计数头加一层LayerNorm或把初始化改成顺滑初始化,同时把w_cnt提到10来放大梯度。这两个操作同时做,比只调权重更有效。

5.3 联合训练后检测mAP比单任务降低了3个点

现象:用相同数据和相同训练轮数,单任务检测mAP50是0.87,联合训练后只有0.84。分割和计数能收敛,但检测精度滑坡。

原因:三个任务共享Backbone时,分割和计数的梯度会改变特征空间。分割希望特征编码更精细的边界信息,计数希望编码密度信息,检测希望编码类别和形心信息,三者方向不完全一致。这种冲突在数据量少、类别数多的时候更明显。

解决:先尝试把w_det调成2.0,让检测任务占主导;如果还不行,就冻结Backbone的前三层,只训练Neck和Head部分。冻结方式是在模型的Backbone层设置requires_grad=False,让前三层特征完全由预训练权重控制,三个head去适应这些特征。这个方法在大幅度缓解任务冲突的同时,也牺牲了Backbone针对数据集的微调能力,适合数据量在几千张的场景。

5.4 显存溢出:batch=16居然爆显存

现象:单任务分割训练batch=16没爆,加了多任务头后batch=16直接OOM。

原因:计数头的密度图分支在特征图分辨率较高时,显存占用是额外的B * 1 * H * W,加上分割头的prototype掩码,两个附加任务一起大幅增加中间张量。单任务分割的显存友好经验并不适合直接套用到多任务。

解决:把imgsz从640降到512,同时开启梯度累积accumulate=2,这样等效batch还是16但显存只需单batch的量。如果还想保住分辨率,就用AMP混合精度训练,半精度下密度图的显存占用直接减半。

5.5 验证时指标都对,线上推理时计数结果翻了一倍

现象:验证集上计数MAE稳定在5以下,放到生产环境跑视频流,画面里同样的几条产线,计数值突然变成原来的两倍。

原因:验证时是单帧推理,线上是视频流。相邻帧里同一个物体会被多帧连续检测到,每帧各计一次,计数自然膨胀。这种问题在纯检测模型里也存在,但不是检测指标能暴露的。

解决:在后处理加滞留逻辑。一个目标在连续N帧里位置变化小于阈值,就只计一次,并记录最后一次出现的帧号。具体阈值要按产线运行速度标定,我一般先用帧差法统计目标的平均移动速度,再设定滞留帧数。这一步不是模型的问题,但没有它计数系统根本不能上线。

6. 验证与落地:除了mAP,多任务模型还要看这四件事

联合训练模型的验证和单任务完全不同。检测看mAP50-95,分割看mAP50-mask,大部分人都知道。但计数任务没有标准指标,它必须回到业务口径里去定义。我通常的做法是同时报三个数:CountMAE(绝对误差均值)、CountRMSE(均方根误差,笨重错误会放大)、以及一个误检率——模型输出密度图里局部峰值和标注点对不上的比例。这三个指标如果只看MAE,生产环境里不同产线的负载波动会让MAE几乎无意义。

推理落地前,我习惯先跑一个简单的可视化验证脚本,把检测框、分割掩码、密度图热力图叠加在同一张图上,人工过一遍典型场景。这一步能筛掉大部分标签错位问题。然后导出模型做加速推理:

yolo export \ model=runs/segment/train/weights/best.pt \ format=onnx \ opset=12 \ dynamic=True

导出后可以继续转TensorRT,需要注意三个输出头分别对应三个输出张量,计数头的输出还包含密度图和标量两个部分。很多踩坑发生在转完模型后发现预测脚本里拿不到计数结果,实际是多输出模型的输出名字变了,要按导出节点名重新映射,而不是按训练时的字段名硬取。

最后补一个经验:多任务模型的收益在中等数据量(几千到几万张)时最明显,因为共享特征学习到了任务共有的底层结构。如果数据量只有几百张,联合训练不如把三套单任务模型都训练到过拟合再在后处理里融合,这是血泪经验换来的判断。

希望这篇笔记能帮你少走一遍我走过的弯路,祝落地顺利。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询