简介:这份资源面向具备一定Python与深度学习基础、希望动手实现目标检测的开发者与学习者,围绕YOLO、SSD、Faster R-CNN等主流检测器,结合TensorFlow框架讲解从数据预处理、模型构建到训练评估与预测部署的完整流程,帮助读者把目标检测理论落到可运行的代码上。压缩包共308个文件,约4.05MB,以210个py源码为主体,辅以26个proto、15个config、12个build等配置与构建文件,另有16个md说明、9个sh脚本及少量图片、ipynb与yml,覆盖数据加载、网络定义、训练与推理等模块,目录结构清晰,便于按环节查阅与二次修改。目前已有3265人学习下载,适合作为课程实验、项目练手或自学参考,读者可借此理解检测模型的工程组织方式,掌握调试与排错思路,并在此基础上迁移到自己的数据集与任务中。
1. 目标检测 Python 代码实现:从跑通到改对,这份资源能省你两周
如果你正在搜「深度学习 目标检测 python 代码实现」,大概率不是想听卷积神经网络的历史,而是手里有个具体活儿:要么是课程设计要交一个能框出物体的 Demo,要么是产线上想快速验证一个检测思路能不能落地。这份资源就是一套围绕目标检测的 Python 代码实现集合,覆盖数据读取、模型搭建、训练循环、推理可视化的完整链路,用的是 PyTorch 和 Tensor 生态里最常见的写法。它适合两类人:一类是刚学完深度学习基础、想找一个能直接跑起来的检测项目练手的新手;另一类是有工程背景、想快速对比不同检测头写法的熟手。下面我按「先跑通、再改对、最后避坑」的顺序拆一遍,重点讲参数怎么设、报错怎么看、哪些地方最容易翻车。
2. 环境与数据管线:把张量形状对齐再谈模型
2.1 依赖版本与目录结构
目标检测代码跑不起来,十次有八次不是模型写错,而是环境版本对不上。这份资源默认用 PyTorch,但里面不少张量操作(比如permute、meshgrid)在 TensorFlow 里也有对应写法,所以标题里带了 tensor 这个词。我一般会先锁死三个版本:Python 3.8 到 3.10 之间、PyTorch 1.12 以上、torchvision 跟 PyTorch 主版本对齐。别小看这个,torchvision 的nms接口在不同版本里参数名变过,老代码直接抄会报unexpected keyword argument。
目录结构建议按下面这样摆,后面所有路径都基于这个根目录:
detection_project/ ├── data/ │ ├── images/ # 原始图片 │ └── labels/ # 标注文件,每张图一个 txt ├── configs/ │ └── default.yaml # 超参和路径配置 ├── models/ │ ├── backbone.py │ └── detector.py ├── utils/ │ ├── dataset.py │ └── box_ops.py └── train.pyconfigs/default.yaml里至少要有img_size、batch_size、num_classes、anchors这几项。我见过有人把 anchors 硬编码在模型文件里,换数据集时忘了改,结果小目标全漏检,查了一整天才发现是 anchor 尺寸跟数据分布差太远。
2.2 自定义 Dataset 与张量归一化
数据读取这块,核心是把图片和标注转成模型能吃的张量。下面这段代码是资源里utils/dataset.py的简化版,我加了注释说明每个参数的作用:
import torch from torch.utils.data import Dataset from PIL import Image import numpy as np class DetDataset(Dataset): def __init__(self, img_dir, label_dir, img_size=640, augment=False): self.img_dir = img_dir self.label_dir = label_dir self.img_size = img_size # 统一缩放尺寸,必须是 32 的倍数 self.augment = augment # 训练时开,验证时关 self.img_files = sorted(list(Path(img_dir).glob("*.jpg"))) def __getitem__(self, idx): img_path = self.img_files[idx] img = Image.open(img_path).convert("RGB") # 等比缩放 + 灰边填充,避免直接 resize 导致长宽比失真 img, ratio, pad = letterbox(img, self.img_size) img = np.array(img, dtype=np.float32) / 255.0 # 归一化到 0-1 img = img.transpose(2, 0, 1) # HWC -> CHW img = torch.from_numpy(img) label_path = self.label_dir / (img_path.stem + ".txt") labels = np.loadtxt(label_path, ndmin=2) # 标注格式:class x_center y_center w h,全部归一化到 0-1 targets = torch.zeros((len(labels), 6)) targets[:, 1:] = torch.from_numpy(labels) return img, targets这里有两个参数最容易设错。一个是img_size,它必须是 32 的整数倍,因为主干网络通常下采样 5 次,输入 640 得到 20×20 的特征图,输入 650 就会在某一层对不齐,报size mismatch。另一个是归一化方式,资源里用的是除以 255,但如果你换用 ImageNet 预训练权重,就得改成减均值除标准差,否则推理结果会偏得离谱。letterbox这个函数负责等比缩放和填充,返回的ratio和pad在推理画框时要用来把坐标映射回原图,很多人只存了缩放后的图,画框时坐标全错位,就是漏了这一步。
2.3 数据加载器的 batch 组装
collate_fn是目标检测里绕不开的一环,因为每张图的标注数量不一样,默认的default_collate会直接报错。资源里给了一个自定义组装函数:
def collate_fn(batch): imgs, targets = zip(*batch) # 把 targets 拼成 [batch_idx, class, x, y, w, h] 的形式 for i, t in enumerate(targets): t[:, 0] = i # 第 0 列存 batch 索引 targets = torch.cat(targets, 0) imgs = torch.stack(imgs, 0) return imgs, targets逻辑说明:batch_idx这一列是给后面的正负样本匹配用的,模型输出的是每个网格的预测,需要知道哪个预测对应哪张图的哪个真实框。参数上,targets的列顺序必须跟损失函数里的解析顺序一致,资源里用的是[batch_idx, class, x, y, w, h],如果你改成[class, x, y, w, h, batch_idx],匹配逻辑就得跟着改,否则训练 loss 会一直不降。我一般会在collate_fn里加一句断言检查列数,防止标注文件里混入空行导致维度错乱。
3. 模型搭建与损失计算:检测头怎么写才不玄学
3.1 主干网络与特征金字塔
资源里的主干网络用的是类似 ResNet 的结构,但做了简化,方便新手看清张量流向。真正值得说的是特征金字塔(FPN)部分,它决定了小目标能不能被检出来。下面这段是models/backbone.py里 FPN 的构建代码:
import torch.nn as nn class FPN(nn.Module): def __init__(self, in_channels=[256, 512, 1024], out_channels=256): super().__init__() self.lateral_convs = nn.ModuleList([ nn.Conv2d(c, out_channels, 1) for c in in_channels ]) self.smooth_convs = nn.ModuleList([ nn.Conv2d(out_channels, out_channels, 3, padding=1) for _ in in_channels ]) def forward(self, features): # features 从浅到深排列,浅层分辨率高、语义弱 laterals = [conv(f) for conv, f in zip(self.lateral_convs, features)] # 自顶向下融合:深层上采样后与浅层相加 for i in range(len(laterals) - 1, 0, -1): laterals[i - 1] += nn.functional.interpolate( laterals[i], scale_factor=2, mode="nearest" ) outs = [conv(l) for conv, l in zip(self.smooth_convs, laterals)] return outs参数说明:in_channels要跟主干网络输出的通道数严格对应,写错会在zip处静默跳过某一层,导致特征图数量不对。out_channels一般设 256,这是检测头的统一输入通道。上采样用nearest而不是bilinear,是因为在检测任务里最近邻插值对位置信息保留更好,换成双线性有时会让小目标框偏移一两个像素。融合顺序必须是从深到浅,反过来做浅层语义会污染深层特征,mAP 掉几个点是常事。
3.2 检测头与正负样本匹配
检测头负责把特征图上的每个位置映射成类别概率和框偏移。资源里用的是 anchor-based 写法,每个位置预设 3 个不同尺度的 anchor。正负样本匹配用的是简化版 IoU 阈值法:
def assign_targets(pred_boxes, true_boxes, anchors, iou_thresh=0.5): # pred_boxes: [num_anchors, 4],true_boxes: [num_gt, 4] ious = box_iou(pred_boxes, true_boxes) # [num_anchors, num_gt] max_iou, matched_gt = ious.max(dim=1) # 低于阈值的设为背景,类别索引 0 保留给背景 labels = torch.zeros(len(pred_boxes), dtype=torch.long) labels[max_iou < iou_thresh] = 0 labels[max_iou >= iou_thresh] = matched_gt[max_iou >= iou_thresh] + 1 return labels这里iou_thresh设 0.5 是常见做法,但如果你数据集里密集小目标多,可以降到 0.4,让更多 anchor 参与正样本。注意matched_gt加 1 是因为类别 0 留给了背景,这个偏移量在算损失时必须保持一致,否则模型会把背景当目标学。我见过有人忘了加 1,训练 loss 看着在降,但推理时全图都是背景,一个框都出不来,这就是典型的「loss 降了但模型废了」。
3.3 损失函数:分类与回归的权重平衡
损失由分类损失和框回归损失两部分组成,资源里分类用交叉熵,回归用 CIoU Loss。关键在权重系数:
def detection_loss(pred_cls, pred_box, target_cls, target_box, lambda_cls=1.0, lambda_box=2.0): cls_loss = F.cross_entropy(pred_cls, target_cls, reduction="mean") # 只对正样本算回归损失 pos_mask = target_cls > 0 if pos_mask.sum() > 0: box_loss = ciou_loss(pred_box[pos_mask], target_box[pos_mask]) else: box_loss = torch.tensor(0.0, device=pred_box.device) return lambda_cls * cls_loss + lambda_box * box_losslambda_box设 2.0 是为了让回归梯度跟分类梯度量级接近,如果设成 1.0,框的位置往往收敛得比类别慢,表现为「类别对了但框很糙」。反过来设太大(比如 5.0),早期训练框会剧烈震荡,loss 曲线像心电图。我一般会先跑 100 个 iteration 看两条 loss 的下降速度,如果分类 loss 降得明显快,就把lambda_box往上调一点。另外pos_mask为空的情况必须处理,否则ciou_loss收到空张量会返回 nan,整个训练直接崩掉。
4. 训练与推理:把 mAP 从 0.3 拉到 0.7 的实操细节
4.1 训练循环与学习率调度
训练循环本身不复杂,但学习率调度和梯度裁剪是拉开效果差距的地方。资源里用的是余弦退火加 warmup:
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr=0.01, total_steps=epochs * len(loader), pct_start=0.1 ) for epoch in range(epochs): model.train() for imgs, targets in loader: preds = model(imgs) loss = detection_loss(*preds, *parse_targets(targets)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=10.0) optimizer.step() scheduler.step()max_lr设 0.01 是 SGD 的常用起点,如果你换 Adam,要降到 1e-3 甚至 1e-4。pct_start=0.1表示前 10% 的步数用来 warmup,防止一开始梯度太大把预训练权重冲垮。clip_grad_norm_的max_norm设 10.0 是个经验值,检测任务里梯度爆炸不算罕见,尤其是标注框坐标异常时(比如归一化后出现大于 1 的值),不加裁剪 loss 会直接变 nan。我习惯在每个 epoch 结束后打印一次当前学习率,确认调度器真的在动,有时候scheduler.step()放错位置会导致学习率一直不变,训练后期完全没收敛。
4.2 推理与 NMS 后处理
推理阶段的核心是解码预测框和做非极大值抑制。资源里utils/box_ops.py给了一个可复用的 NMS 实现:
def nms(boxes, scores, iou_thresh=0.45): # boxes: [N, 4] 格式为 xyxy,scores: [N] keep = [] order = scores.argsort(descending=True) while order.numel() > 0: i = order[0].item() keep.append(i) if order.numel() == 1: break ious = box_iou(boxes[i].unsqueeze(0), boxes[order[1:]])[0] order = order[1:][ious < iou_thresh] return torch.tensor(keep)iou_thresh设 0.45 是通用值,但如果你检测的是细长物体(比如电线杆),可以提到 0.5 以上,否则相邻的框会被误删。解码时要注意把预测的偏移量还原成绝对坐标,再乘回letterbox的ratio并减去pad,这一步顺序错了框会整体偏移。我一般会在推理脚本里加一个可视化开关,把原图、缩放图、还原后的框画在一起对比,一眼就能看出坐标映射对不对。
4.3 评估指标与调参方向
mAP 上不去时,别急着换模型,先按下面这个顺序排查:
| 现象 | 可能原因 | 调整方向 |
|---|---|---|
| 分类 loss 正常,框 loss 不降 | 回归权重太低或 anchor 不匹配 | 提高 lambda_box,重新聚类 anchor |
| 小目标全漏 | FPN 浅层特征被忽略 | 检查 FPN 融合顺序,增加浅层输出 |
| 验证集 mAP 远低于训练集 | 过拟合或数据增强太弱 | 加随机翻转、马赛克增强,加 weight_decay |
| 推理框大量重叠 | NMS 阈值太高或置信度阈值太低 | 降 iou_thresh,提 conf_thresh |
这张表是我自己踩坑总结的,资源里没有现成答案,但按这个顺序查基本能定位到问题。调参时一次只动一个变量,同时改三个参数你根本不知道是哪个起了作用。
5. 避坑与排查:五个让我熬夜的检测代码问题
5.1 现象:训练 loss 正常但推理全图无框
原因:类别索引偏移没对齐。标注里类别从 0 开始,但模型输出把 0 当成了背景,正样本全被当成背景学。解决:统一约定背景类为 0,真实类别从 1 开始,在assign_targets和损失函数里保持一致,并在数据加载时打印一次类别分布确认。
5.2 现象:报错Expected all tensors to be on the same device
原因:模型在 GPU 上,但collate_fn里新建的targets张量默认在 CPU。解决:在collate_fn返回前不指定设备,让训练循环里统一.to(device),或者在collate_fn里加device参数。我习惯在训练循环开头写imgs = imgs.to(device); targets = targets.to(device),比在数据层处理更可控。
5.3 现象:mAP 计算出来是 0 或者负数
原因:框的坐标格式混用了。标注是xywh,但 NMS 里按xyxy处理,IoU 算出来全是 0。解决:在box_ops.py里明确写两个转换函数xywh2xyxy和xyxy2xywh,所有涉及框运算的地方都显式调用,别靠记忆判断当前是什么格式。
5.4 现象:训练几个 epoch 后 loss 突然变 nan
原因:学习率太大导致梯度爆炸,或者标注里有宽高为 0 的框,除法时产生 inf。解决:加梯度裁剪,同时在 Dataset 里过滤掉宽高小于 1 像素的标注。我一般会在__getitem__里加一句if w < 1 or h < 1: continue,虽然会丢几个样本,但比整个训练崩掉划算。
5.5 现象:换数据集后 mAP 掉一半
原因:anchor 尺寸还是旧数据集的,新数据里目标尺度分布完全不同。解决:用 k-means 对新数据集的标注框做聚类,重新生成 3 到 9 组 anchor。资源里附了一个kmeans_anchor.py,输入标注文件路径和聚类数就能跑,比手动试快得多。
6. 进阶技巧:用 TensorBoard 盯住每一个张量
跑通检测代码只是第一步,真正省时间的是把训练过程变成可观测的。我现在的习惯是每个项目都接 TensorBoard,把 loss 分量、学习率、正样本数量、甚至中间特征图的均值都记下来。下面这段是资源里train.py可以加的监控代码:
from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter("runs/detection_exp1") for step, (imgs, targets) in enumerate(loader): # ... 前向和反向 ... if step % 50 == 0: writer.add_scalar("loss/cls", cls_loss.item(), step) writer.add_scalar("loss/box", box_loss.item(), step) writer.add_scalar("lr", scheduler.get_last_lr()[0], step) writer.add_scalar("pos_anchors", pos_mask.sum().item(), step) # 把第一张图的预测框画出来,每 500 步存一次 if step % 500 == 0: vis = draw_boxes(imgs[0], pred_box[0], pred_cls[0]) writer.add_image("pred/vis", vis, step)参数说明:add_scalar的第二个参数是纵轴值,第三个是横轴步数,用全局 step 而不是 epoch 能更细地看到波动。pos_anchors这个指标特别有用,如果它长期是 0 或者接近 anchor 总数,说明匹配策略出了问题,不用等 mAP 出来就能发现。add_image存可视化结果,能直观看到框是在收敛还是乱飞。
除了 TensorBoard,我还会在训练前跑一遍「过拟合小样本」测试:拿 10 张图,关掉所有增强,看模型能不能把 loss 降到接近 0。如果连 10 张图都过拟合不了,说明模型结构或损失函数有根本问题,这时候调超参是浪费时间。这个习惯帮我省过很多次盲目调参的功夫。
从那以后我每次拿到新的检测代码,都强制先跑一遍小样本过拟合和 TensorBoard 监控,确认链路通了再上全量数据。希望这套拆解能帮你少走几个弯路,代码包里的配置文件和脚本按上面的顺序过一遍,基本就能跑起来。
本文还有配套的精品资源,点击获取