1. 项目缘起与整体设计思路
行人检测这个方向,我在实际项目里接触过不少次,从最早的 HOG+SVM 一路做到现在的深度学习方案,踩过的坑可以说能写满一个笔记本。这次要聊的是用 YOLOv3 来训练一个专门的行人检测模型,数据来源是从 VOC2012 数据集里抽取出来的行人标注数据。为什么选这个组合?说白了就是两个字:实用。YOLOv3 虽然已经不是最新的检测框架了,但它的工程成熟度、社区资源丰富程度、以及对中小规模数据集的友好性,至今仍然让它在很多实际落地场景里有一席之地。而 VOC2012 作为一个经典数据集,里面本身就包含大量带标注的行人样本,直接抽取出来做专项训练,省去了从零标注的麻烦。
先说说这个项目到底能做什么。简单讲,就是给你一个输入图片或者视频流,模型能框出画面中所有行人的位置,并给出置信度。听起来简单,但实际做起来涉及的东西不少:数据抽取与清洗、格式转换、锚框聚类、模型配置、训练调参、推理部署,每一步都有讲究。适合谁来参考?我觉得有两类人最合适:一是刚入门目标检测、想拿一个完整项目练手的同学;二是在实际业务中需要快速搭建行人检测基线、不想一上来就搞太复杂方案的工程师。前者可以跟着走一遍全流程,后者可以直接拿这套配置当起点,再根据自己场景微调。
为什么不用更新的 YOLOv5 或者 v8?这个问题我被问过很多次。我的回答通常是:看你手头的硬件和部署环境。YOLOv3 的 Darknet 框架用纯 C 写的,依赖极少,交叉编译到嵌入式设备上相对省心;而且它的权重文件小、推理速度快,在算力有限的边缘设备上反而比一些新模型更实用。当然,如果你追求极致精度且硬件充足,换更新的版本也没问题,但本文的流程和思路是通用的。
整个项目的设计思路可以概括为四个阶段:数据准备、模型配置、训练调优、推理验证。数据准备阶段的核心是从 VOC2012 的 XML 标注里筛选出所有包含行人的样本,并转换成 YOLO 需要的 txt 格式;模型配置阶段要根据行人数据的特点重新聚类锚框,调整网络结构中的类别数;训练调优阶段涉及学习率策略、数据增强、批次大小等超参的选择;推理验证阶段则要关注置信度阈值、NMS 参数对最终效果的影响。下面我会逐个拆开讲,把每个环节的“为什么”和“怎么做”都说清楚。
2. 数据准备:从VOC2012到YOLO格式的完整转换
2.1 VOC2012数据集的构成与行人样本分布
VOC2012 一共包含 20 个类别,其中 person 这一类就是我们要的行人。整个数据集有 17125 张图片,训练集和验证集加起来大概 11540 张,标注文件是 XML 格式,每张图对应一个 XML。行人样本的分布有个特点:大部分图片里行人数量不多,且尺寸变化很大。有站在远处的几十像素高的小目标,也有占据画面大半身的大目标。这种尺度分布对检测器来说是个挑战,也是为什么后面要专门聚类锚框的原因。
我实际统计过,VOC2012 里包含行人的图片大约有 4000 多张,去掉一些标注质量差、遮挡严重到几乎不可辨认的样本,能用的大概在 3500 张左右。这个量级对于 YOLOv3 来说偏小,所以数据增强是必须的,后面会细说。另外要注意,VOC 的标注里有个difficult字段,标记为 1 的目标表示难以识别,训练时通常忽略这些样本,否则会拉低模型的学习效果。
2.2 抽取行人标注的脚本实现
抽取的核心逻辑就是遍历所有 XML 文件,找到name为person的 object,把它的边界框坐标和图片路径记录下来。这里有个细节:VOC 的坐标是xmin, ymin, xmax, ymax,而 YOLO 需要的是归一化后的中心点坐标和宽高。转换公式如下:
x_center = (xmin + xmax) / 2.0 / image_width y_center = (ymin + ymax) / 2.0 / image_height width = (xmax - xmin) / image_width height = (ymax - ymin) / image_height用 Python 实现的话,大概长这样:
import xml.etree.ElementTree as ET import os def convert_voc_to_yolo(xml_path, img_width, img_height): tree = ET.parse(xml_path) root = tree.getroot() lines = [] for obj in root.findall('object'): name = obj.find('name').text if name != 'person': continue difficult = obj.find('difficult') if difficult is not None and int(difficult.text) == 1: continue bbox = obj.find('bndbox') xmin = float(bbox.find('xmin').text) ymin = float(bbox.find('ymin').text) xmax = float(bbox.find('xmax').text) ymax = float(bbox.find('ymax').text) x_center = (xmin + xmax) / 2.0 / img_width y_center = (ymin + ymax) / 2.0 / img_height w = (xmax - xmin) / img_width h = (ymax - ymin) / img_height lines.append(f"0 {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") return lines注意类别索引我写的是 0,因为行人检测是单类别任务,不需要保留 VOC 原来的 20 类索引。图片宽高可以从 XML 的size节点里读,也可以用 OpenCV 直接读图片获取,后者更可靠,因为偶尔会遇到 XML 里尺寸和实际图片不一致的情况。
2.3 数据清洗与难例处理
抽取完之后别急着训练,先做一轮清洗。我一般会检查这几项:边界框是否越界(坐标超出图片范围)、宽高是否为零或负数、图片是否损坏无法读取。越界的框要裁剪到图片范围内,宽高异常的框直接丢弃。另外,如果一张图里所有行人框都小于 8x8 像素,我建议整张图都不要,因为这种目标在训练中基本是噪声。
还有一个容易被忽略的点:重复框。VOC 标注里偶尔会出现同一个目标被标了两次的情况,两个框的 IoU 超过 0.9 就认为是重复,保留置信度高的那个(这里没有置信度,就保留面积大的)。清洗完之后,把图片和对应的 txt 文件按 8:2 划分训练集和验证集,文件名保持一致,方便后续加载。
提示:划分数据集时一定要随机打乱,但固定随机种子,保证每次实验的划分一致,否则不同轮次的结果没法对比。
3. 锚框聚类与网络配置调整
3.1 为什么必须重新聚类锚框
YOLOv3 原版的锚框是在 COCO 数据集上聚类得到的,那里面有各种尺度的目标,行人的比例和 COCO 整体分布并不一致。如果你直接拿原版锚框训练行人数据,会发现小目标召回率特别低,因为原版最小的锚框是 (10,13),而 VOC 里很多行人只有十几像素宽,匹配不上。所以用自己的数据重新跑一遍 K-means 聚类是必须的,这一步能明显提升召回率。
聚类的距离度量不能用欧氏距离,要用1 - IoU,因为框的大小和位置是耦合的。K-means 的 K 取 9,和 YOLOv3 的三个尺度、每个尺度三个锚框对应。聚类前把所有训练集的宽高归一化到 0-1 之间,聚类完再乘回 416(YOLOv3 默认输入尺寸)得到实际像素值。
3.2 K-means聚类实操与结果解读
聚类脚本的核心是自定义距离函数:
import numpy as np def iou(box, clusters): x = np.minimum(clusters[:, 0], box[0]) y = np.minimum(clusters[:, 1], box[1]) intersection = x * y box_area = box[0] * box[1] cluster_area = clusters[:, 0] * clusters[:, 1] iou_ = intersection / (box_area + cluster_area - intersection) return iou_ def kmeans(boxes, k=9, max_iter=100): n = boxes.shape[0] distances = np.empty((n, k)) last_clusters = np.zeros((n,)) np.random.seed(42) clusters = boxes[np.random.choice(n, k, replace=False)] for _ in range(max_iter): for i in range(n): distances[i] = 1 - iou(boxes[i], clusters) nearest = distances.argmin(axis=1) if (nearest == last_clusters).all(): break for j in range(k): if (nearest == j).any(): clusters[j] = boxes[nearest == j].mean(axis=0) last_clusters = nearest return clusters跑完之后按面积从小到大排序,最小的三个给第一个尺度(13x13 特征图),中间三个给第二个尺度(26x26),最大的三个给第三个尺度(52x52)。我实测在 VOC 行人数据上聚类出来的锚框大概是 (8,18)、(15,32)、(25,48)、(38,72)、(55,95)、(78,130)、(110,180)、(160,250)、(230,340) 这个量级,和原版差别挺大,尤其是小框明显更小。
3.3 配置文件的关键修改点
YOLOv3 的配置文件是.cfg格式,需要改的地方有:类别数(classes=1)、最后一个卷积层的 filters(公式是(classes + 5) * 3 = 18)、三个 yolo 层的 anchors(换成聚类结果)、三个 yolo 层的 classes。另外,如果显存不够,可以把batch和subdivisions调小,比如batch=32, subdivisions=8,等效批次是 4。学习率方面,初始learning_rate=0.001,burn_in=1000,max_batches根据数据量定,一般 3500 张图跑 20000 到 30000 批次差不多。
注意:改完 cfg 后一定要用
darknet partial或者直接从头训练,不要拿原版权重做迁移学习时只改类别数,那样最后一层的权重形状对不上会报错。如果要用预训练权重,需要先跑一次darknet partial把最后一层之前的权重抽出来。
4. 训练过程与调参经验
4.1 数据增强策略的选择
数据量只有 3500 张左右,不做增强肯定过拟合。YOLOv3 的 Darknet 框架自带几种增强:angle(旋转)、saturation、exposure、hue、flip。我的经验是行人检测场景下,旋转角度不要太大,±10 度就够了,因为行人通常是直立的,大角度旋转会产生不真实的样本。flip=1开启水平翻转没问题,但垂直翻转不要开。饱和度和曝光度可以适当调,模拟不同光照条件。另外,mosaic增强在 YOLOv3 原版里没有,需要自己改代码或者用支持该功能的 fork 版本,如果不想折腾,用基础的几种也够。
还有一个技巧是随机缩放,通过random=1开启多尺度训练,每 10 个批次换一次输入尺寸,范围在 320 到 608 之间。这对提升模型对不同尺度行人的适应能力很有帮助,代价是训练时间会稍微长一点。
4.2 训练过程中的监控与早停
训练时盯着几个关键指标:loss 曲线、验证集 mAP、召回率。Darknet 每 1000 批次会输出一次 mAP,如果连续几次 mAP 不升反降,说明过拟合了,该停了。我一般会保存每 1000 批次的权重,最后挑验证集上表现最好的那个,而不是用最后一个。另外,avg loss在 0.5 以下且不再明显下降时,基本就收敛了。
有个坑要提醒:学习率衰减的时机。YOLOv3 默认在max_batches的 80% 和 90% 时各衰减一次,如果你中途改了max_batches,记得同步调整steps参数,否则学习率可能一直不降,loss 震荡下不去。
4.3 显存不足时的应对方案
显存不够是训练中最常见的问题。除了调小batch和subdivisions,还可以把输入尺寸从 416 降到 320,或者用width和height参数改小。如果还是不够,可以冻结前几层不训练,只训后面的层,但这会牺牲一些精度。我的建议是优先降输入尺寸,因为行人检测对分辨率还是比较敏感的,批次太小会导致 BN 层统计不准,反而影响效果。
5. 推理验证与效果分析
5.1 推理参数对结果的影响
训练完拿到权重文件后,用darknet detector test跑推理。这里有两个参数直接决定最终效果:置信度阈值-thresh和NMS 阈值-nms。行人检测场景下,我一般把thresh设在 0.3 到 0.5 之间,太低会有一堆误检,太高会漏掉远处的小目标。nms设在 0.45 左右,因为行人之间经常有重叠,NMS 太激进会把挨着的两个人合并成一个框。
实测下来,VOC 行人数据训练出的模型在验证集上 mAP@0.5 大概能到 0.75 到 0.82 之间,具体取决于训练轮数和增强策略。小目标的召回率是短板,如果业务场景里远处行人很重要,可以考虑用更高分辨率的输入,比如 608,或者用 tiled inference 把大图切块检测。
5.2 常见误检与漏检的排查
误检主要集中在两类:类似行人形状的物体(比如消防栓、柱子)和人群密集处的重复框。前者可以通过增加负样本(不含行人的图片)来缓解,后者调 NMS 参数。漏检则多发生在遮挡严重和极小目标上,遮挡问题靠数据增强里的随机裁剪可以部分解决,极小目标只能靠提高输入分辨率或者用专门的小目标检测策略。
我踩过的一个坑是:验证集里有些图片的标注框特别大,几乎覆盖整张图,这种样本会让模型学到“大框就是行人”的错误关联。后来我把面积占比超过 0.8 的框都过滤掉了,mAP 反而升了两个点。
5.3 部署时的性能优化
如果要在实际业务里用,推理速度是个绕不开的问题。Darknet 原生支持 OpenCV 的 DNN 模块加载,也可以用 TensorRT 加速。我实测在同等硬件下,TensorRT 比原生 Darknet 快 2 到 3 倍。另外,如果输入是视频流,可以用跳帧检测加跟踪的方式,没必要每帧都跑检测,这样能大幅降低算力消耗。
6. 实操心得与避坑清单
6.1 数据层面的经验
- 标注质量比数量重要:VOC 里有些行人框标得比较松,包含了很多背景,训练时会让模型学到无关特征。如果条件允许,手动过一遍关键样本的标注。
- 类别不平衡问题:行人检测是单类别,不存在类别不平衡,但正负样本比例要注意。背景区域远多于行人区域,YOLOv3 的损失函数里对正负样本做了加权,一般不用额外处理,但如果发现模型倾向于把什么都判成背景,可以调高
objectness的损失权重。 - 验证集要独立:不要从训练集里随便抽几张当验证集,一定要随机划分,且保证验证集里各种尺度、各种场景的行人都有。
6.2 训练层面的经验
- 学习率预热很重要:前 1000 批次用较小的学习率预热,能避免初期 loss 爆炸。
- 权重保存策略:每 1000 批次存一个,最后用验证集挑最好的,不要只看最后一个。
- 多尺度训练:开启
random=1后训练时间会增加约 20%,但泛化能力提升明显,值得。
6.3 推理层面的经验
- 阈值要按场景调:安防场景宁可误检不可漏检,阈值调低;统计人流量的场景可以调高,减少重复计数。
- 输入尺寸匹配:训练用的什么尺寸,推理最好也用相近的,否则精度会掉。
- 后处理不能省:NMS 之后还可以加一个基于宽高比的过滤,把明显不像行人的框去掉,比如宽高比超过 1:3 或 3:1 的。
这个项目后续还可以这样扩展:把单类别行人检测扩展到多类别(比如加上骑行者、推车的人),或者引入时序信息做行人跟踪,再或者用知识蒸馏把大模型的能力迁移到更小的网络上,方便部署到边缘设备。我自己在实际操作中的体会是,行人检测的难点从来不在模型本身,而在数据质量和场景适配。同一个模型,换一个场景可能效果天差地别,所以每次上新场景,先花时间看看数据,比盲目调参有用得多。