简介:PDF 文档围绕 YOLOv11 单阶段检测算法,系统讲解如何基于同一模型同时完成目标检测、图像分割与目标计数三项任务,适合已有一定 YOLO 基础的研究者、算法工程师与高校学生。文档共 48 页,从 YOLOv11 网络结构与创新点引入,逐步展开多任务学习原理、共享特征层与任务特定分支设计、多任务损失函数组合方法,以及检测、分割、计数三类任务的融合策略;针对联合训练实践,还梳理了数据集选择与标注、数据增强、优化器与学习率调整,以及 mAP、mIoU、MAE 等评估指标的应用要点。全文涵盖智能交通、工业质检、安防监控、农业、医疗、零售、教育等九类应用场景,便于读者对照迁移。资源包共 1 个文件,PDF 格式,约 2.2MB,支持目录章节跳转与阅读器左侧大纲快速定位。目前已有 123 人学习,适合需要搭建多任务一体化解决方案、关注检测-分割-计数联合训练细节的读者。
1. 多任务框架与YOLOv11:为什么同时做检测、分割、计数能替代三套模型
做工业质检或者密集目标统计的时候,最头疼的不是单个模型精度,而是检测、分割、计数各跑一套流程。检测模型给框,分割模型给掩码,计数再做一遍连通域分析,三个模型串联,链路延迟翻三倍,误差还一路叠加。多任务框架的 YOLOv11 就是把检测和分割的 backbone 共享,再挂一个计数分支,用一份联合训练的数据,让一个模型同时输出框、掩码和数量。这方案适合输出指标既要位置、又要像素级轮廓、还要总数的场景,比如细胞统计、料盘针脚检测、颗粒计数、自动易拉罐计数。它最大的价值不是省了一个模型,而是让检测和分割的特征互相补充,尤其是小目标密集场景,比单独训练两个模型更稳。
2. YOLOv11 多任务改法:网络结构、头部分叉与特征共享
2.1 从 YOLOv11-Seg 看多任务头怎么长出来
先说一个常见的误解:多任务不是把三个模型塞进一个可执行文件里,而是让一个主干网络(Backbone)和特征金字塔(Neck)共享计算图,只在最后分叉出不同粒度的输出头。YOLOv11 的网络结构沿用 Backbone + Neck + Head 的经典设计,Backbone 由 C3k2 和 SPPF 堆叠,输出不同尺度的特征;Neck 用 PAN-FPN 做特征融合;检测头在不同特征层上做 anchor-free 的边框回归和分类。如果你看过 YOLOv11 的分割版本,会发现它并没有重新设计一个主干,而是在检测头的旁边并列了一个分割头:检测头出的是框和类别,分割头出的是图像掩码的原型(prototypes)和每个实例对应的系数(coefficients),推理时用矩阵乘法把两者组合成原始尺寸的掩码。这个思路是我们可以直接复用的。
为什么要复用 backbone?因为检测框和分割掩码本来就需要同一批底层语义:边缘、纹理、遮挡边界。检测头教给 backone 的知识是“目标在那里”,分割头教的是“目标占据哪些像素”,两个损失从不同角度提供梯度,对小目标的召回往往比单头更好。计数头如果再复用同一份特征,那三个任务等于在同一个信息瓶颈上做多次读取,代价是特征需要更宽,所以实际结构里会把 P4、P5 层的通道数适当加大,我一般会在 width_multiple 上从 0.5 调整到 0.75。
2.2 计数头的两种挂载方式:Box 回归头与密度图头
计数头的挂载方式直接影响训练难度。第一种最简单:直接统计检测头的有效框数量,不需要额外分支,但计数质量完全取决于检测框的召回,重叠目标经过 NMS 后会被吞掉,密集场景容易漏。第二种是给分割头再并一个 1x1 卷积,输出一张密度图,每个像素代表该位置的目标密度,对整张图求和就是总数;也可以直接回归一个 count 标量,但从工程上看,密度图更容易训练,而且能感知空间分布,不会像标量回归那样只看整体数。
在联合训练框架中,我推荐密度图头。原因是它的梯度能传到特征图的每个位置,对 backone 的贡献比单点 count 回归更充分。密度图计数还有一个额外收益:当检测框因为遮挡漏检时,密度图还在持续贡献梯度,模型不会完全放弃那个区域。如果遇到的是高密度场景,比如细胞或者粉末颗粒,密度图常是唯一能保住计数的方案;如果场景是料盘针脚这种可分离目标,直接统计检测框也很稳。具体怎么选,后面第 5 章会给一张对比表。
2.3 用 YAML 配置定义多任务模型结构
常见的做法是直接在 YOLOv11 的模型定义上改良。YOLOv11 本身用 yaml 描述结构,检测头的注册名是 Detect,分割版的注册名是 Segment,计数头没有现成定义,需要改 task code。下面给一个可复用的 PyTorch 多任务头示意,以单层特征图为例,实际使用时需要对 P3/P4/P5 三层各做一次:
import torch
import torch.nn as nn
class MultiTaskHead(nn.Module):
def __init__(self, in_channels, num_classes, mask_proto=32, density_feat=64):
super().__init__()
# 先用一个卷积做特征提纯,减少任务之间的梯度冲突
self.shared = nn.Sequential(
nn.Conv2d(in_channels, in_channels, 3, padding=1, bias=False),
nn.BatchNorm2d(in_channels),
nn.ReLU(inplace=True),
)
# 检测分支:回归框和类别
self.box = nn.Conv2d(in_channels, 4, 1)
self.cls = nn.Conv2d(in_channels, num_classes, 1)
# 分割分支:生成掩码原型和系数
self.proto = nn.Conv2d(in_channels, mask_proto, 3, padding=1)
self.coef = nn.Conv2d(in_channels, mask_proto, 1)
# 计数分支:密度图回归
self.density = nn.Sequential(
nn.Conv2d(in_channels, density_feat, 3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(density_feat, 1, 1),
)
def forward(self, x):
feat = self.shared(x)
box_out = self.box(feat) # [B, 4, H, W]
cls_out = self.cls(feat) # [B, C, H, W]
proto = self.proto(x) # [B, mask_proto, H, W]
coef = self.coef(feat) # [B, mask_proto, H, W]
density = self.density(feat) # [B, 1, H, W]
return box_out, cls_out, proto, coef, density
逻辑说明:这里用一个 shared 卷积先做特征提纯,目的是让三个任务在共享分支上先完成一次信息融合,避免检测头的梯度直接把计数头的梯度冲散。分割头的 proto 和 coef 在推理时通过 Einstein 求和还原成掩码,例如 mask = torch.einsum('bphw,bcp->bcHW', coef, proto) 。参数说明: mask_proto 是原型通道数,通常取 32,大了显存涨得快,小了掩码边缘糊; density_feat 取 64 够用,密度图分支本身不追求高分辨率,因为它的监督是高斯核生成的目标图,精细到像素没有意义。
3. 联合训练数据管道:检测框、掩码与计数标签的对齐
3.1 三种标注的来源和归一化
联合训练的前提是同一张图上同时存在框、掩码和计数标签,这三类数据的来源往往不同。常见的情况是:你有一份目标检测标注(x1, y1, x2, y2),一份实例分割标注(polygon 或者 RLE),计数标签则可以自动从掩码实例数里算出来。怎么对齐?我的顺序是:优先保留实例分割标注,因为从多边形可以直接算外接矩形,得到检测框;再从掩码数量得到 count。反过来不行,检测框无法还原像素级掩码。所以数据管道的入口是语义分割 / 实例分割标注文件,而不是检测框文件。
归一化时要注意坐标系。COCO 的 polygon 坐标是绝对像素,YOLO 系列的 txt 标注要求归一化到 0~1。如果 mask 是多边形,转 YOLO-seg 格式时要除以图像宽高;如果 mask 是 RLE,需要先解码成 0/255 的掩码图,再通过 cv2.findContours 转成多边形。计数标签不需要归一化,但建议做一步 clip:如果掩码因为遮挡或者标注边界切碎,一个目标可能被标成两段,直接按掩码实例数算会多计,这一步最好人工检查一遍。
3.2 转成 YOLOv11 多任务训练格式的脚本
下面是我常用的一段转换脚本,输入是 COCO 风格的 JSON,输出是 YOLOv11 训练用的 txt 和掩码目录。它同时生成检测框标签和掩码多边形,并顺带算出每张图的 count 标签:
import json, cv2, numpy as np
from pathlib import Path
def coco_to_yolo_multitask(coco_json, image_dir, out_dir):
with open(coco_json, 'r', encoding='utf-8') as f:
coco = json.load(f)
img_id_to_info = {img['id']: img for img in coco['images']}
ann_by_img = {}
for ann in coco['annotations']:
ann_by_img.setdefault(ann['image_id'], []).append(ann)
cat_id_to_cls = {cat['id']: i for i, cat in enumerate(coco['categories'])}
for img_id, anns in ann_by_img.items():
img = img_id_to_info[img_id]
h, w = img['height'], img['width']
lines = []
count = 0
for ann in anns:
cls = cat_id_to_cls[ann['category_id']]
# segmentation 是 polygon 列表或 RLE
seg = ann['segmentation']
if isinstance(seg, list):
poly = np.array(seg[0], dtype=np.float32).reshape(-1, 2)
else:
mask = coco_rle_to_binary(seg, w, h) # 需另写解码函数
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
poly = contours[0].squeeze().astype(np.float32)
# YOLO 格式归一化坐标
poly_norm = poly / np.array([w, h], dtype=np.float32)
xs = poly_norm[:, 0].tolist()
ys = poly_norm[:, 1].tolist()
line = [str(cls)] + [f'{xs[i]:.6f}' for i in range(len(xs))] + \
[f'{ys[i]:.6f}' for i in range(len(ys))]
lines.append(' '.join(line))
# 同时计算检测框:从归一化多边形取外接矩形
x_c = (poly_norm[:, 0].min() + poly_norm[:, 0].max()) / 2
y_c = (poly_norm[:, 1].min() + poly_norm[:, 1].max()) / 2
bbox_w = poly_norm[:, 0].max() - poly_norm[:, 0].min()
bbox_h = poly_norm[:, 1].max() - poly_norm[:, 1].min()
# 注意:这里将 box 也写入另一个文件,便于检测头读取
box_line = f'{cls} {x_c:.6f} {y_c:.6f} {bbox_w:.6f} {bbox_h:.6f}'
save_box(out_dir, img['file_name'], box_line)
count += 1
save_seg_txt(out_dir, img['file_name'], lines)
save_count(out_dir, img['file_name'], count) # 每图一个txt,内容是数字
逻辑说明:这段脚本的核心是让三个标签从同一个 annotation 生成,保证框、掩码、计数天然对齐。参数说明: img['file_name'] 只是主键,最终保存时统一用 image_id 做文件名,避免不同子目录重名。额外的 box txt 文件是给只读检测头的分支用的,如果你直接去掉 box 层、完全从掩码生成框,也可以省掉这一步,但不推荐,因为检测头需要有独立监督,否则会退化成随分支。
3.3 数据增强必须同步掩码:边界坑
如果数据增强只对图像做左右翻转和缩放,而掩码和框不跟着变,那训练时模型看到的监督就是错的。常见做法是用 albumentations 的 Compose 同时传入 bboxes 和 masks ,它保证同一种几何变换应用到所有目标。但这还不够,计数标签也需要重新算。翻转和缩放不会改变实例数量,但随机裁剪会:裁剪掉半个目标,掩码可能只剩面积小于阈值的碎片,这时候你怎么计数?我的习惯是在增强函数里,对每个 mask 计算变换后的实例数,只有掩码面积大于原始 30% 的才保留,并且把 count 重新赋值为保留实例数。
import albumentations as A
def get_train_transform(target_size=640):
return A.Compose([
A.RandomSizedBBoxSafeCrop(width=target_size, height=target_size, erosion_rate=0.2),
A.HorizontalFlip(p=0.5),
A.RandomBrightnessContrast(p=0.3),
], bbox_params=A.BboxParams(format='yolo', label_fields=['class_labels']),
mask_params=A.MaskParams(masks_as_masks=True))
逻辑说明: RandomSizedBBoxSafeCrop 保证裁剪区域至少包含一个完整的目标,避免有的图被裁成空图。 mask_params 会让 albumentations 对掩码应用和我们图像完全一致的变换,这一点是手工同步最容易出 bug 的地方。参数说明: erosion_rate=0.2 表示允许裁剪区域损失部分目标边缘,但不允许整个目标被切没; masks_masks_as_masks=True 表示输入的 mask 是 0/255 的单通道数组,不要转成 float,否则后续计算实例数时阈值会出错。
4. 联合训练配置与损失平衡:让三个任务不打架
4.1 多任务损失组合与初始权重
联合训练最典型的失败是检测收敛得很好,但分割 mask 只有一团噪点,count 则一直震荡。问题出在损失函数搭配。常见的 YOLOv11 检测损失是 box_CIoU + cls_bce ,分割分支可以加 mask_bce + mask_dice ,计数分支加 count_mse 或 density_mae 。把它们直接相加时,检测损失的量级通常远大于 mask 损失和 count 损失,梯度被检测主导,后面两个任务学不动。
我一般会用这样的初始公式:
total_loss = (
1.0 * (box_loss + cls_loss)
+ 0.5 * mask_loss
+ 0.2 * density_loss
)
逻辑说明:检测任务作为主任务权重最高,分割其次,计数最低。原因是计数结果在多数业务里是一种汇总统计,允许推理后处理纠正;但框和掩码错了,整个输出就没法用。参数说明: density_loss 权重从 0.2 起步,如果观察到 mask 质量好但 count 偏差大,可以逐步提到 0.5;如果 mask 一直不收敛,说明训练阶段 mask_loss 被抑制,需要先提高它到 1.0,让每一步梯度都能穿透。
4.2 动态 loss 权重:从 GradNorm 到不确定性加权
固定权重只能碰运气,因为训练到后期三个任务的难度变化不同。常见的做法是用不确定性加权,拿噪声方差作为置信度,损失越大的任务自动降权。但实现稍麻烦,实战中更稳的是分阶段手动调整:前 20 个 epoch 用固定权重,后 20 个 epoch 把 count 权重逐渐提上来,因为那时检测和分割已经稳定,不会抢梯度。
如果你想把权重也纳入训练,可以用 GradNorm 的思想:计算三个任务损失的梯度范数,然后乘一个权重,让各任务的梯度范数接近。我踩过这个坑,GradNorm 在小 batch size 下非常不稳定,训练前 500 步 loss 直接飞掉。后来我改成用验证集上每个任务的 mAP 和 Dice 动态调整权重:哪个任务涨得慢,就把它的权重往上调 10%。这属于“土办法”,但比纯理论可靠,而且能解释给项目上的人听。
4.3 训练超参调整:batch size、学习率与 YOLOv11 默认值差异
YOLOv11 的默认训练配置是为单任务检测设计的,搬到多任务时只改权重还不够。我用过的实际参数对比表如下:
| 参数 | 单任务 YOLOv11 常见值 | 多任务联合训练建议 | 原因 |
|---|---|---|---|
| imgsz | 640 | 640 或 768 | 太小掩码边缘模糊,太大显存爆炸 |
| batch size | 16 | 8 或 4 | 显存被 mask prototype 和密度图占掉 |
| lr0 | 0.01 | 0.005 | 多任务梯度冲突更频繁,学习率要降低 |
| epochs | 100 | 150 以上 | 分割和计数需要更多 epoch 才稳定 |
| optimizer | SGD | AdamW | AdamW 对多任务梯度的二阶动量更友好 |
| warmup epochs | 3 | 5 | 给三个头更充分的热身期 |
参数说明:多任务训练时 batch size 降低,直接影响 BN 的统计量,所以我会把 batch_norm_epsilon 稍微调大一点,或者固定前 20 个 epoch 的 BN 参数。还有一个常见做法是先用单任务 YOLOv11 检测预训练权重,冻结 backbone 跑 10 个 epoch,等分割头和计数头的随机输出不再把 loss 拉崩,再解冻联合训练。
5. 计数分支避坑:三种计数方案与 5 个常见翻车点
5.1 方案选型:框计数、连通域计数、密度图计数的边界
同样是计数,三条路线各有适用范围,选错代价很大。我把它们列成一张对比表:
| 方案 | 适用场景 | 精度瓶颈 | 实际代价 |
|---|---|---|---|
| 统计检测框数量 | 目标稀疏、不重叠 | NMS 抑制掉相邻框 | 实现成本最低,直接复用检测头 |
| 掩码连通域计数 | 目标之间有空隙 | 粘连掩码被连成一个域 | 需要用分水岭或边界 loss 辅助 |
| 密度图计数 | 高密度、大量堆叠 | 高斯核半径影响峰值合并 | 需要额外计算密度标签 |
我的经验是:如果场景是 opencv 硬币检测与计数那种大小统一、边界分离的,直接统计检测框就够了,还省一个分支。如果是轨道零件、电子元件这种有规则间隔的,用掩码连通域计数,配合测距修正重复计数。如果是细胞、颗粒这种大量贴合堆积的,密度图是唯一能稳定收敛的方案,但要花大量时间调高斯核半径。
5.2 现象:模型只收敛检测,分割和计数纹丝不动
原因:检测 loss 量级太大,梯度被主任务吞掉。解决:先冻结检测头,单独让分割头和计数头用 mask loss 和 density loss 训练 2 个 epoch,观察 loss 有没有下降;然后再打开检测头,用 4.1 的权重做联合训练。如果冻结后分割 loss 也不降,问题就出在 backone 特征没解冻,设置 backone 的 lr 乘 0.1 但不要冻结。
5.3 现象:训练正常,推理时掩码粘连导致分割计数少算
原因:训练数据的 mask 边界标注粗糙,两个紧挨目标的 mask 边缘重叠,连通域分析把两个实例合并成一个。解决:在 mask loss 里加一个边界惩罚项,对 mask 边缘像素的监督权重大于内部像素;推理时不要直接对整张 mask 做连通域,先做形态学腐蚀去掉桥接,再用 cv2.connectedComponentsWithStats 按面积阈值过滤。
5.4 现象:数据增强后 count 标签和实际目标数对不上
原因:随机裁剪把目标切成两半,但 count 依然用原始标签里的实例数。解决:增强后重新统计掩码实例数,并丢弃面积小于阈值的碎片。我在 3.3 已经说过,这一步要写在 transform 之后,而不是原始数据读取时。
5.5 现象:推理时检测框数量和计数分支输出差一大截
原因:计数分支是回归任务,训练数据统计量分布不均匀时,它倾向于输出训练集的平均总数,而不是当前图的真实数。解决:不要拿计数分支的输出当最终结果,把它作为辅助信号,最终 count 用检测框统计或掩码连通域数。如果一定要用密度图,每条图单独做一次 min-max 归一化再求和,减少整体亮度对计数结果的影响。
6. 推理部署与后处理:把三个输出统一成一份结果
6.1 推理 pipeline:NMS、掩码还原与连通域计数
模型训练完后,部署阶段的坑一点也不比训练少。YOLOv11 的输出头在不同特征层上,检测框需要先解码再 NMS,分割掩码需要从 prototype 和系数生成,计数密度图需要从下采样尺寸 resize 回原图。下面是一段精简的推理后处理流程:
import cv2, torch, numpy as np
def postprocess(outputs, orig_shape, conf_thres=0.25, iou_thres=0.45):
box_out, cls_out, proto, coef, density = outputs
# 1. 解码检测框:YOLOv11 是 anchor-free,直接用特征图映射到原图
boxes, confs, clss = decode_boxes(box_out, cls_out, orig_shape)
keep = nms(boxes, confs, clss, conf_thres, iou_thres)
boxes, confs, clss = boxes[keep], confs[keep], clss[keep]
# 2. 生成掩码:coef 是每个实例的系数,proto 是全局原型
masks = torch.einsum('bphw,bcp->bcHW', coef, proto)
masks = torch.sigmoid(masks)
# resize 到原图并二值化
final_masks = []
for i in range(masks.shape[1]):
mask = masks[0, i].cpu().numpy()
mask = cv2.resize(mask, (orig_shape[1], orig_shape[0]))
final_masks.append((mask > 0.5).astype(np.uint8))
# 3. 使用连通域统计作为最终计数
counts = []
for mask in final_masks:
num, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
valid = [s for s in stats[1:] if s[4] >= 50] # 过滤噪声
counts.append(len(valid))
# 4. 密度图求和作为辅助验证
density_map = density[0, 0].cpu().numpy()
density_map = cv2.resize(density_map, (orig_shape[1], orig_shape[0]))
density_count = float(density_map.sum())
return boxes, final_masks, counts, density_count
逻辑说明:NMS 之前的所有操作在模型特征层坐标系,解码时要把下采样倍率乘以特征图尺寸;掩码 resize 用双线性,阈值不用传统的 0.5,而是 0.45,因为分割网络的预测边界不是硬边界,取低一点能让掩码覆盖更完整。参数说明: connectedComponentsWithStats 的 stats 第 4 列是像素面积,过滤阈值 50 是按业务调的,如果用预训练权重,建议按验证集统计面积分布再定。
6.2 计数结果校准:按置信度阈值与业务绝对数校准
多任务框架的 count 输出和业务口径往往不完全一致。比如“数量”是按完整目标算,但模型预测出许多半截切片,连通域统计会多算。我的终校准习惯是:在验证集上做一次 density_count 和真实总数的线性回归,得到一个比例系数,推理时把密度图求和再乘以系数。这个系数在不同相机角度下会变,所以不要只标定一次。
另外一个容易踩的坑是:保存推理结果时只写模型 raw output,不做后处理。实际部署项目里,用户要的是最终掩码图和总数,不是一个 tensor。我会把检测框、掩码、密度图、count 全部写回 JSON,并附上图像尺寸和模型版本,避免后面换模型后对不上。
6.3 从 ONNX 到 TensorRT:多任务头导出注意事项
多任务模型的导出,最大的问题是输出头数量多,且掩码生成有矩阵乘法。ONNX 导出时要把 einsum 拆成 MatMul ,否则一些推理框架不支持。TensorRT 部署时,建议把密度图头固定成单通道输出,不要画蛇添足加 softmax。我自己在 Orin 和 Jetson 上部署 YOLOv11 分割版的经验是,多任务头导出 fp16 后性能下降不大,但 mask 分支的反卷积对显存要求高,建议在 low_complexity 模式下把 mask_proto 降到 16,能省接近一半显存。
最后说个我的教训:一开始把计数分支做成标量回归,训练时 loss 降得很快,但在验证集上总数就是比人工数老是差 2 和 3,因为标量无法感知目标的空间分布。后来换成密度图,再用连通域做最终统计,计数才真正稳定。这个方向想落地,不要迷信模型输出,要在后处理上留一手。希望帮到你。
438




被折叠的 条评论
为什么被折叠?



