最近用PyTorch做图像识别练习选了个特别有画面感的入门任务——小黄人检测。目标检测这个名字听着有点唬人但拆开其实就两件事先把图片里的小黄人找出来再用矩形框把它框住。上学那会儿做这类需求第一反应是搞滑动窗口加人工特征计算量大不说换个背景就得重新调参。现在深度学习时代直接用PyTorch训练一个端到端模型让网络自己去学“哪个区域更像小黄人”“该用多大的框去圈它”效果和开发效率完全不在一个量级。这篇文章是整个系列的第一篇我会完整记录我用 PyTorch 训练一个小黄人检测模型的全过程包括数据准备、标注格式、模型选型、finetune 预训练权重、推理可视化以及我踩过的几个典型的坑。它适合两类人一类是刚接触目标检测、想找一个简单项目练手的学生另一类是工作中突然接到“识别图中某个物体”需求、需要快速出个实验方案的工程师。我会尽量把每一步的“为什么这么做”也讲清楚而不是只贴代码。1. 整体方案设计先把检测任务拆清楚1.1 目标检测的底层逻辑分类加定位做检测和做分类最大的区别在于多了一个定位分支。分类网络干的事情是看到一张图输出“这是猫、狗还是小黄人”的概率分布。检测网络则需要额外输出一组坐标这组坐标往往是(x_min, y_min, x_max, y_max)含义是目标框的左上角和右下角位置。小黄人检测这个任务本质上就是二元分类加回归判断某个候选区域里有没有小黄人如果有就把这个区域四边的偏移量算准。这两件事在PyTorch里通常是一起做的用一个多任务损失函数同时监督两条分支。分类分支用交叉熵回归分支用Smooth L1这两个损失加在一起就是我们要最小化的目标。这里有一个初学者容易对不上的概念什么是候选区域。早期做法是通过Selective Search这类算法预先在图片上提几千个可能包含物体的区域然后再逐个判断。现在流行的检测框架基本抛弃了这个显式的区域提议过程改成在网格或锚点上直接回归模型自己就能学会“哪里该出框”。但不管哪种做法最后的输出结构都是统一的每个预测框都对应一个类别概率和四个坐标值。1.2 为什么拿小黄人来做实验我在选数据集的时候其实对比过几个方向。用公开数据集例如COCO那种上百类别的跑一遍训练少说要等一晚上而且参数量大入门期容易因为显存限制各种报错。用小白兔或者可乐瓶这类物体呢图片来源又太杂边界不清晰。小黄人这个目标有几个天然优势。第一外观特征非常鲜明黄色身体、蓝色背带裤、大眼睛颜色分布高度集中这对检测模型来说就是明摆着的特征。第二网上能找到的小黄人图片数量多、画风统一很多是相对干净的卡通背景标注起来省力。第三也是最重要的一点目标形态多样性足够大——单个、多个、正脸、侧脸、全身、半身都有适合检验模型对不同位置尺度的适应能力。另外从训练策略上考虑目标种类单一意味着分类分支很简单模型可以把大部分能力集中在定位精度上这对理解检测模型的损失函数变化规律很有帮助。如果你一上来就选一个几十类的数据集当出现loss不降的时候你很难判断是分类没学好还是回归没学好排查成本会增加不少。1.3 框架选型为什么我选了Faster R-CNN作为基线当前PyTorch环境下目标检测框架主流就三个方向Faster R-CNN、SSD、YOLO系列。它们没有绝对的好坏只有合适不合适。Faster R-CNN是两阶段方法先由RPN网络生成候选框再做分类和回归精修。它的优点是精度高、收敛稳缺点是速度慢。SSD和YOLO是一阶段方法直接在特征图上做密集预测速度快但对小目标和小区域重叠的情况精度相对吃亏。我的建议是如果你的目标是理解检测模型内部机制、调试训练过程第一版实验用Faster R-CNN最稳妥。因为它的每个模块边界清晰什么地方出了问题可以单独看。torchvision里也直接封装好了fasterrcnn_resnet50_fpn一行代码就能把一个预训练模型拿下来非常适合作为基线。YOLO虽然速度和直接部署都更友好但对训练数据的标注质量要求更高后面一旦出现误检排查起来线索相对零散。本次实验我以Faster R-CNN为主线最后在推理阶段会额外聊聊YOLO系列与其对比时的情况。等这个流程跑通了你再换YOLO做速度优化会有更明确的参照系。2. 数据准备与标注尝到目标检测的主要工作量2.1 图片搜集与整理数据是检测项目里最容易被低估的部分。分类任务里一张图只要打个标签检测任务里一张图可能含多个目标每个目标都要框标注成本翻了好几倍。我这次的图片来源主要有两个公开数据集里筛选的小黄人相关图片以及自己截取的动画视频帧。注意如果后续你有把模型做成产品的打算版权和授权问题一定要提前查清楚。纯学习实验可以不做过多纠结但如果你要发论文或者对外商用图片来源必须有可追溯的授权许可。图片总量我控制在600张左右。其中训练集500张验证集100张。每张图里小黄人的数量从1个到10个不等。这里有个小建议不要只收集画面里只有一个目标的中规中矩图片一定要有遮挡、重叠、模糊、暗光这类“困难样本”。检测模型能否在实际场景中用起来就看它在这种困难样本上的表现。图片尺寸方面我没有做过多的统一核心原因是torchvision的检测模型内部会做Resize操作。但建议最短边不要小于300像素因为目标太小的话标注框都没法保证准确模型训练出来精度也上不去。2.2 标注工具与格式转换标注是所有工作里最耗时的一步。我用的是开源标注工具labelImg操作逻辑很简单打开一张图鼠标画矩形框下拉选择类别保存。这个工具默认生成的是Pascal VOC格式的XML文件后续我们写程序方便统一处理。VOC格式的XML里最重要的几个字段是filename图片文件名size图片的宽高和通道数object/name目标类别object/bndboxxmin、ymin、xmax、ymax这一步很多教程会忽略一个细节坐标是否归一化。VOC格式存的是像素级坐标即左上角为原点向右向下为正向。如果模型内部对图片做了缩放标签坐标必须跟着缩放。torchvision的detection模块要求我们传入的目标target是一个字典字段包括boxes和labels坐标是原始像素坐标。这一点要特别留意不然训练时会出现loss剧烈震荡却降不下来。我还顺手写了个小脚本把XML里所有标注信息统一打印出来核对每个框的坐标是否超出图片边界。收集到的图片经过缩放处理后偶尔会出现标注框比图片还大的情况这类脏数据要直接丢弃否则会严重影响边界回归的收敛。提示训练样本里宁可少一张图也不要留一张标注坐标有问题的图。脏数据对检测任务的破坏力远大于分类任务因为回归分支对异常值特别敏感。2.3 数据增强策略让模型看到更多“花样”600张图的原始数据量对检测模型来说远远不够。要解决这个问题最有效的手段就是数据增强。数据增强是对训练图片做一系列随机变换让模型每次迭代看到的图片都不一样。我在本次项目中使用了水平翻转、随机裁剪、亮度饱和度微调这三种增强方式。代码实现上torchvision.transforms已经封装了大部分操作但需要注意一点对检测任务来说图像变换和标注框变换必须同步进行。如果只翻转了图片而没有同步更新bounding box坐标模型学到的就是这个目标在错误位置的错误特征问题比不做增强还大。我自己写了一个坐标变换函数水平翻转后目标新的x_min等于原图宽度减去原x_maxx_max等于原图宽度减去原x_min。随机裁剪更麻烦一点因为裁剪可能把目标裁掉一半。我的处理策略是在裁剪区域生成时强制要求每个目标与裁剪区域的IoU至少大于0.5。IoU太低的框直接丢掉这样既发布了更多样本形态又不会破坏训练数据的标注一致性。torchvision的transforms模块里内置了一些检测专用的变换函数但我仍然建议初学者先手动实现一遍能实实在在加深对坐标变换的理解。3. 基于PyTorch的训练过程从预训练权重到收敛3.1 加载预训练模型与修改输出头PyTorch训练检测模型有一个绝对值得用的技巧加载预训练权重。这里说的预训练权重是模型在ImageNet或COCO这样的大数据集上训练好的参数。它不是一步到位但能让你在很小的数据集上得到一个可用的模型训练时间也大幅缩短。以torchvision的Faster R-CNN为例加载方法如下import torchvision from torchvision.models.detection import fasterrcnn_resnet50_fpn from torchvision.models.detection.faster_rcnn import FastRCNNPredictor def get_model(num_classes): model fasterrcnn_resnet50_fpn(pretrainedTrue) in_features model.roi_heads.box_predictor.cls_score.in_features model.roi_heads.box_predictor FastRCNNPredictor(in_features, num_classes) return model这里有一个关键点为什么只替换最后一层分类器Faster R-CNN的主干网络backbone负责提取通用视觉特征这些特征对任何物体识别任务都有用不需要重新训练。需要调整的是输出层——原本它知道COCO的80个类现在要让它知道“背景”和“小黄人”两个类。FastRCNNPredictor在创建时会重新初始化最后分类头和回归头的参数而前面所有的卷积层参数都保留原有值这就是迁移学习的核心思想。注意num_classes的取值。很多人写1认为只有一个小黄人类这是错的。在torchvision的检测框架里分类数必须包含背景类所以这里num_classes2编码时0代表背景1代表小黄人。3.2 训练代码的核心结构拆解训练一个检测模型整个循环框架和普通分类网络差别不大差别在于loss的计算方式。以下是训练阶段的关键代码import torch device torch.device(cuda if torch.cuda.is_available() else cpu) model get_model(num_classes2).to(device) params [p for p in model.parameters() if p.requires_grad] optimizer torch.optim.SGD(params, lr0.005, momentum0.9, weight_decay0.0005) lr_scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size8, gamma0.5) def train_one_epoch(model, optimizer, data_loader, device): model.train() total_loss 0 for images, targets in data_loader: images [img.to(device) for img in images] targets [{k: v.to(device) for k, v in t.items()} for t in targets] loss_dict model(images, targets) losses sum(loss for loss in loss_dict.values()) optimizer.zero_grad() losses.backward() optimizer.step() total_loss losses.item() return total_loss / len(data_loader)这里要说清楚model(images, targets)在训练模式下返回的不是预测结果而是一个字典里面包含rpn_reg_loss、rpn_cls_loss、roi_cls_loss、roi_reg_loss四个部分。这四个部分分别对应RPN网络和ROI头的分类与回归损失。把它们加总起来就是整体损失。一个常见困惑是为什么数据加载器取出来的是images和targets两个列表而不是一个批次的tensor原因是检测任务中每个图片的目标数量不同无法拼成一个固定形状的tensor。torchvision的DataLoader默认行为在检测任务里不适用需要自定义一个collate_fn让DataLoader直接用列表返回数据。这个细节是新手最容易卡住的地方。3.3 损失函数变化的判读方法训练启动之后真正要盯的就是loss曲线。我的经验是在一个正常收敛的训练过程中loss曲线不是平滑下降的而是像锯齿一样抖动下降。每一次迭代的loss都有随机波动这很正常。我在这个项目里观察到刚开始的前100次迭代总loss大约在5.0附近其中RPN分类损失占大头。随着训练进行RPN损失率先下降因为它的问题是“哪里可能有目标”相对简单。ROI回归损失会晚几步才开始下降因为它要精修位置对特征质量的要求更高。如果你看到loss在初始阶段不降反升先不要慌也许是学习率的初始态造成的。我建议先打印每个子loss的值逐个分析是哪个分支出了问题。比如RPN分类loss在震荡但数值在3.0以上大概率是正负样本严重不均衡造成的。训练轮数方面我在这个项目里跑了30个epoch。由于用了预训练权重和500张训练图到第10个epoch时验证集上的表现已经比较可用了。后面20个epoch主要是对边界框微调。4. 推理与可视化把模型输出变成我们能看懂的样子4.1 加载权重与执行预测模型训练完权重保存在checkpoint里。推理时要先构造模型结构再把权重加载进去然后切到eval模式。这里有一个特别容易踩的坑加载权重时必须确保构造模型时用的num_classes与训练时一致。如果你训练时用num_classes2推理时构造模型传了3参数名对不上直接报错。如果出现size mismatch的相关提示多半就是这里的问题。推理阶段代码model.eval() with torch.no_grad(): prediction model([img.to(device)]) boxes prediction[0][boxes].cpu().numpy() scores prediction[0][scores].cpu().numpy() labels prediction[0][labels].cpu().numpy()这里输出的boxes是所有候选框scores是对应置信度。一次性输出几百个框很正常其中绝大多数都是没有物体的背景框需要我们按照置信度做筛选。注意推理时输入的图片不需要做与训练完全一致的尺寸归一化但越接近训练时的尺寸模型表现越好。一般我会先把图片最长边缩放到800左右最短边不小于400。4.2 后处理过滤与NMS的实现原理直接从模型拿到的几百个框里小黄人周围会有一圈高度重叠的框。原因在于Faster R-CNN的RPN生成候选框时就天然存在大量重叠ROI头会同时对多个位置产生响应。解决这个问题的标准做法是Non-Maximum Suppression简称NMS。它的核心思路很简单按置信度从高到低排序优先保留最高分的框。计算其余所有框与该最高分框的IoU如果IoU超过阈值说明这两个框很可能圈住了同一个目标直接丢弃分数较低的框。重复直到处理完所有框。torchvision封装好了这一步。我用了以下参数组合from torchvision.ops import nms keep nms(boxes, scores, iou_threshold0.5)这里iou_threshold取0.5含义是两个框重叠面积超过50%就认为是同一个目标。如果你希望减少漏检可以把阈值调到0.6或者0.7效果是允许更多重叠的框保留下来同一目标可能出现双框如果希望减少误检就调低到0.4但可能把遮挡的目标漏掉。置信度阈值我一般设在0.5即confidence score大于0.5才认为“检出了一个小黄人”。注意这里的0.5只影响最终可视化不代表模型输出的真实精度最终还是要看验证集上的mAP。4.3 用mAP衡量模型好坏很多人训练完检测模型只看几张可视化图就断言“效果不错”。这种习惯很危险。可视化图只能给你直观感受真正的量化指标是mAP。mAP的计算逻辑展开讲会很长这里说一个简化版本把所有预测框按照置信度排序设定一个IoU门槛计算precision和recall曲线然后求该曲线下的平均精度AP。mAP是所有类别的平均值。因为我们的项目只有一个类别mAP和AP在这里其实是同一个值。torchvision提供了现成的评估工具from torchvision.ops import box_iou iou box_iou(pred_boxes, gt_boxes)自己实现一个简单的mAP计算其实也就几十行代码。我在自己的项目中写了一个版本对每个IoU阈值从0.5到0.95按0.05递增各算一次AP再平均这是COCO的标准做法。最终我在验证集上得到的mAP约为0.72作为一个小数据集实验方案我觉得是可以接受的。如果你不想自己实现也可以用torchmetrics库里的目标检测评估模块几行代码就能拿结果。不过我还是建议至少手写一次评估流程这样才能理解mAP的细节含义。5. 常见问题与排查技巧发给三天前的自己5.1 显存不足与Batch Size调整训练检测模型和训练分类模型的显存需求完全不同。一张512x512的图片经过特征提取后候选区域数量可能超过1000个每个区域都要做ROI Pooling并送入后续网络显存增长非常快。我刚开始尝试Batch Size设为8结果第一轮迭代就报CUDA out of memory。解决思路有两个方向第一个方向是降低Batch Size到2或者1。检测模型对batch size并不像分类网络那么敏感因为每张图内部的多样性已经足够支撑梯度更新。第二个方向是调整图片的输入尺寸。把最长边从800降到640显存需求大约会下降30%-40%。代价是检测精度略有下降因为小目标的特征在缩放后更容易丢失。我先用Batch Size2跑通流程等代码无误后再尝试把Batch Size调到4配合注意监控显存使用。信息密度有限的阶段不建议一开始就追求大Batch。5.2 漏检与误检怎么排查如果模型在小黄人比较小的时候经常漏检优先查看输入层尺寸设置。torchvision的模型默认至少要接收一个相对小的最短边目标太小的话特征图上一个像素点代表原图上的多个像素小目标响应值会被平均化自然检测不到。此时可以考虑的优化路径有三条。第一条是提高输入图片分辨率比如把最短边设为1000。第二条是使用FPN结构torchvision的fasterrcnn_resnet50_fpn已经带了FPN高分辨率特征图和低分辨率特征图都有分支输出对小目标友好。第三条是数据增强时多切一些包含大尺度目标的局部区域比如RandomZoomOut。误检问题则往往和数据集样本结构有关。如果验证集出现把黄色圆形物体识别成小黄人的情况先检查训练集里有没有相似的负样本图片。坦白说我项目的训练图里确实混入了少量包含黄色小鸭子的图片虽然标注正确但分类分支很难区分这种高度相似的特征只能在后处理里提高置信度阈值到0.6来缓解误检。5.3 训练不收敛的排查顺序碰到loss不降如果按以下顺序排查能省下很多时间第一检查标注坐标是否可视化正确。把训练数据随机抽样二三十张画上标注框看一眼。标注框和目标位置错位训练再久也是在垃圾数据上学习。第二检查学习率。预训练模型本身的参数处于一个相对良性的状态如果学习率过大超过0.01微调过程中可能直接冲坏已经有语义信息的主干特征。建议初始学习率设在0.005以内并使用多阶段衰减。第三检查模型是否真的在训练模式。如果数据加载环节没有调用model.train()模型里的BatchNorm和Dropout都处于推理模式训练过程不会出效果。第四检查loss_dict是否完整。某些自定义网络可能只计算了RPN的损失没有把ROI损失加入反向传播。此时表面loss在下降实际上最后的分类头完全没有被更新训练自然无效。结尾来自实操中的体会用PyTorch做目标检测最难的部分其实不是建模和训练而是数据准备以及对框架内部机制的耐心调试。我第一次跑通这个流程时花了整整三天其中一天用来处理坐标系变换的bug一天用来排查mAP评估脚本里的数据对齐问题真正写网络骨架的时间反而没多少。想对刚开始接触这个方向的朋友说的是千万不要满足于“模型跑起来了”这个状态。跑起来只是开始你要能回答出这些问题——你的预训练权重在哪一步起了作用你的loss有哪个分量没有下降你的mAP在哪个类别哪个尺寸区间被拉低了。能把这几个问题说清楚你对目标检测的理解才真正入门。这个系列接下来我还会继续更新包括替换成YOLO做推理加速对比、模型剪枝、部署到移动端等后续内容。这个项目的代码和标注脚本我已经整理成了一个结构清晰的Demo本地把依赖装好后跑上十几分钟就能出结果。等系列后续文章发布时我也会把扩展版本放出来方便大家把训练好的权重直接用在视频流检测里。