1. 从VOC2012里"捞"出行人:为什么值得单独训一个检测器
行人检测这件事,看起来像是通用目标检测的一个子集,很多人第一反应是"我直接拿COCO预训练的YOLOv3跑不就行了"。我一开始也是这么想的,直到在实际场景里被现实教育了几次。通用模型对"人"这个类别的定义太宽泛了——坐着的人、被遮挡一半的人、远处只有几十个像素的人、海报上印的人,它都可能给你框出来,而且框的置信度还不低。如果你下游要做的是客流统计、区域入侵判断、或者视频摘要里的人物轨迹提取,这种"什么都算人"的召回反而成了负担。
VOC2012这个数据集在行人检测里是个很微妙的存在。它不像COCO那样把person类别做得那么"全",也不像Caltech、CityPersons那样专门针对行人做了密集标注。VOC2012的person类别标注相对"干净",主要覆盖的是站立、行走、骑行等姿态比较明确的个体,而且图像分辨率普遍不高(大多在500×375上下),这反而和很多实际部署场景(比如720P监控截图、手机拍摄的街景)的尺度分布比较接近。所以从VOC2012里把person类抽出来单独训一个YOLOv3,得到的模型在"标准姿态行人"上的精度往往比通用COCO模型更可控,误检也更容易压下去。
这篇文章我想聊的就是这条路径的完整落地过程:怎么从VOC2012的XML标注里把行人数据抽干净、怎么转成YOLOv3要的格式、配置文件怎么改、训练时哪些参数是坑、以及训完之后怎么判断这个模型到底能不能用。适合已经跑通过YOLOv3官方demo、想进一步做垂直类别定制的朋友。如果你连Darknet都还没编译过,建议先把官方仓库的README走一遍再回来,不然中间很多报错会让人抓狂。
需要先说明一点:下面所有涉及路径、文件名的地方,我都会用通用写法,你按自己的目录结构替换即可。数据集我也不会贴具体下载链接,VOC2012的获取方式很成熟,自己搜一下就能找到。
2. 把VOC2012的标注"拆"开:行人数据的抽取逻辑
2.1 VOC的XML结构决定了抽取方式
VOC2012的标注是每张图一个XML文件,放在Annotations目录下,图像放在JPEGImages。一个典型的XML长这样:
<annotation> <filename>2008_000008.jpg</filename> <size> <width>500</width> <height>375</height> <depth>3</depth> </size> <object> <name>person</name> <bndbox> <xmin>174</xmin> <ymin>101</ymin> <xmax>349</xmax> <ymax>351</ymax> </bndbox> <difficult>0</difficult> </object> ... </annotation>抽取的核心逻辑就三步:遍历所有XML,找出所有name为person的object,把对应的bndbox记下来;如果一张图里有多个person,就记多行;如果一张图里一个person都没有,这张图直接丢弃。听起来简单,但真正动手时你会发现几个必须处理的细节。
第一个细节是difficult字段。VOC里标了difficult=1的目标,通常是被严重遮挡、或者小到人眼都难辨认的实例。训练时如果把它们当正样本,模型会学到一堆噪声;如果当负样本,又会让模型对遮挡行人过度保守。我的做法是:抽取阶段保留difficult标记,但在生成训练列表时把difficult=1的框过滤掉,同时如果一张图过滤后一个person都不剩,这张图也不进训练集。这样能保证训练样本的"干净度"。
第二个细节是坐标越界。VOC的标注偶尔会出现xmax超过图像宽度、或者xmin为负数的情况(标注员手抖)。YOLOv3在计算loss时如果遇到越界框,IoU计算会出问题,轻则loss异常,重则直接NaN。所以抽取时一定要做一次clamp:把坐标限制在[0, width]和[0, height]范围内,并且过滤掉宽或高小于3像素的框——这种框转成YOLO格式后基本就是一条线,没有训练价值。
2.2 抽取脚本的写法与踩坑
我用Python写抽取脚本,依赖xml.etree.ElementTree就够了,不需要装额外的解析库。核心结构大概是这样:
import os import xml.etree.ElementTree as ET def extract_person(anno_dir, img_dir, out_list): records = [] for xml_file in os.listdir(anno_dir): if not xml_file.endswith('.xml'): continue tree = ET.parse(os.path.join(anno_dir, xml_file)) root = tree.getroot() size = root.find('size') w = int(size.find('width').text) h = int(size.find('height').text) img_name = root.find('filename').text boxes = [] 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 = max(0, int(float(bbox.find('xmin').text))) ymin = max(0, int(float(bbox.find('ymin').text))) xmax = min(w, int(float(bbox.find('xmax').text))) ymax = min(h, int(float(bbox.find('ymax').text))) if xmax - xmin < 3 or ymax - ymin < 3: continue boxes.append((xmin, ymin, xmax, ymax)) if boxes: records.append((img_name, w, h, boxes)) return records这里有个容易忽略的点:VOC的坐标有时候是浮点字符串(比如174.0),直接int()会报错,所以要先float()再int()。我第一次跑的时候就因为这个卡了半小时,报错信息还特别不直观。
抽完之后,VOC2012的person实例大概在几千到一万出头这个量级(具体数字取决于你是否过滤difficult)。这个量级对YOLOv3来说偏少,所以后面必须靠数据增强来撑,这一点我在第4节会展开。
2.3 训练集/验证集的划分策略
划分不能简单随机。VOC2012的图像本身有场景聚集性——同一个场景可能有多张连续拍摄的图。如果随机划分,训练集和验证集里会出现高度相似的图,验证指标会虚高。我的做法是按filename的前缀(VOC的文件名格式是年份_编号)做分组,同一组只进一个集合,然后按8:2划分。这样验证集更能反映模型在"没见过场景"上的表现。
另外,验证集里我建议保留一部分difficult=1的样本,但评估时单独统计。这样你能看到模型对遮挡行人的真实能力,而不是被过滤后的"漂亮指标"骗了。
3. 转成YOLO格式:坐标归一化与列表生成
3.1 YOLO格式的本质
YOLOv3要的标注格式是每张图一个.txt,每行一个目标,格式为:
class_id center_x center_y width height其中后四个值都是相对于图像宽高的归一化值,范围[0,1]。因为我们只做person一个类,class_id恒为0。这里有个新手常犯的错误:以为width和height是像素值。不是,是归一化后的比例。比如一个框宽100像素、图宽500像素,那width=0.2。
转换公式:
center_x = (xmin + xmax) / 2.0 / img_w center_y = (ymin + ymax) / 2.0 / img_h width = (xmax - xmin) / img_w height = (ymax - ymin) / img_h3.2 生成train.txt和val.txt
YOLOv3训练时需要两个列表文件,每行是一张图的绝对路径。注意是绝对路径,相对路径在Darknet里经常出问题。生成逻辑:
def write_list(records, img_dir, list_path): with open(list_path, 'w') as f: for img_name, w, h, boxes in records: img_path = os.path.abspath(os.path.join(img_dir, img_name)) f.write(img_path + '\n') label_path = img_path.replace('JPEGImages', 'labels').replace('.jpg', '.txt') with open(label_path, 'w') as lf: for (xmin, ymin, xmax, ymax) in boxes: cx = (xmin + xmax) / 2.0 / w cy = (ymin + ymax) / 2.0 / h bw = (xmax - xmin) / 1.0 / w bh = (ymax - ymin) / 1.0 / h lf.write(f"0 {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}\n")这里我建议把label文件放在和图像平行的labels目录下,而不是混在JPEGImages里。原因很简单:Darknet在训练时会去读图像同目录下的同名.txt,如果你把label和图像放一起,JPEGImages目录会变得很乱,而且做数据增强、复制文件时容易漏掉label。分开存放,路径映射清晰,后期排查也方便。
3.3 一个必须检查的环节:label与图像的一一对应
转完之后,一定要写个校验脚本,检查三件事:train.txt里每张图是否存在、每张图对应的label文件是否存在、label文件里每行的数值是否都在[0,1]范围内。我见过太多人训练loss一直不降,最后发现是某几张图的label路径写错了,Darknet读不到label就当成"这张图没有目标"来训,等于往模型里灌负样本。
校验脚本很简单,遍历一遍,把异常的行打印出来。这个五分钟的检查能省你几个小时的debug。
4. 配置文件改造:从cfg到data的完整清单
4.1 创建person.data和person.names
Darknet的训练入口是.data文件,内容大概这样:
classes = 1 train = /path/to/train.txt valid = /path/to/val.txt names = /path/to/person.names backup = /path/to/backupperson.names里就一行:person。classes=1这个值必须和后面cfg里的classes以及filters严格对应,错一个就报维度不匹配。
4.2 修改yolov3.cfg的三个yolo层和前面的conv层
YOLOv3的cfg里有三个[yolo]层,分别对应三个尺度的输出。每个[yolo]层上面紧挨着一个[convolutional]层,那个conv层的filters值需要改。公式是:
filters = (classes + 5) * 3单类时filters = (1 + 5) * 3 = 18。原版是(80+5)*3=255,所以你要把三个filters=255都改成filters=18。同时每个[yolo]层里的classes=80改成classes=1。
这里有个坑:cfg里filters出现多次,除了yolo层前面的conv,其他地方也有conv层带filters。你只能改紧挨着[yolo]的那三个,改错了整个网络结构就崩了。建议用搜索定位[yolo],然后往上找最近的[convolutional]。
4.3 关于预训练权重的取舍
官方提供了darknet53.conv.74这个预训练权重,它只包含卷积层,不含检测头。用它是标准做法,能让模型在几千张图上也能收敛。但如果你直接用COCO训好的yolov3.weights去finetune,会遇到一个问题:COCO的检测头是80类,你的新头是1类,层结构对不上,Darknet加载时会跳过不匹配的层,实际上等于只用了backbone。所以直接用darknet53.conv.74更干净,不用纠结。
4.4 训练命令与关键参数
./darknet detector train person.data yolov3-person.cfg darknet53.conv.74 -gpus 0几个我实际调过的参数:
burn_in=1000:前1000次迭代用较低学习率预热,防止一开始梯度爆炸。max_batches:单类几千张图,我一般设到20000左右。设太大过拟合,设太小欠拟合。steps=16000,18000:在max_batches的80%和90%处降学习率。learning_rate=0.001:比原版80类的0.001略保守,因为数据量小。
如果显存不够,把cfg里[net]下的width和height从416降到320,batch从64降到16或8。单类任务对分辨率没那么敏感,320往往够用。
5. 数据增强:几千张图怎么撑起一个检测器
5.1 YOLOv3自带的增强够不够
Darknet的cfg里[net]段有angle、saturation、exposure、hue这些增强参数。默认值比较温和。对行人数据,我建议把angle设到±10度左右——行人本身有直立性,旋转太大会产生不自然的姿态。saturation和exposure可以适当调大,模拟不同光照。hue对行人检测帮助有限,保持默认即可。
但光靠这些不够。VOC2012的person实例偏少,模型很容易记住训练集的背景。我的补充做法是离线增强:对训练集里的每张图,随机做水平翻转、随机裁剪(裁剪后如果person框还在,就保留),把数据量扩到原来的3到4倍。注意水平翻转对行人完全安全,不像交通标志那样有方向语义。
5.2 mosaic增强的取舍
YOLOv4之后mosaic增强很流行,把四张图拼成一张。它对小目标检测提升明显,但对行人有个副作用:拼接边界处可能出现"半个人",模型会学到一些奇怪的截断特征。如果你下游场景里行人都是完整的,mosaic要慎用;如果场景里遮挡、截断很常见,那mosaic反而有帮助。我的建议是先不用mosaic训一版,看验证集表现,如果小目标召回差再加。
5.3 负样本的补充
行人检测最烦的误检来源是"像人的东西"——消防栓、邮筒、某些雕塑、甚至衣服模特。VOC2012里这类负样本不多。我的做法是从VOC里把不含person的图也抽一部分出来,作为纯负样本加入训练列表(label文件为空)。比例控制在正样本图的20%左右。这样模型对背景的判别会更稳。注意纯负样本的label文件要存在但为空,不能没有,否则Darknet会报错。
6. 训练过程中的异常信号与排查链路
6.1 loss曲线的正常形态
YOLOv3的loss是多项之和(box、obj、cls),Darknet打印的avg loss是滑动平均。正常情况:前几百次迭代loss从大几百快速降到几十,然后缓慢下降,到一万次左右进入个位数到十几的区间震荡。如果loss一直卡在几百不降,八成是数据或配置问题,不是模型问题。
6.2 我遇到过的三类典型故障
第一类:loss直接NaN。原因通常是学习率太大或者标注里有越界框。排查顺序是先检查label文件里有没有大于1或小于0的值,再检查cfg里的learning_rate。我遇到过一次是某张图的xmax等于图像宽度,归一化后正好是1.0,理论上没问题,但Darknet内部计算时浮点误差导致略大于1,就炸了。解决办法是归一化后统一clamp到[0.001, 0.999]。
第二类:loss降但mAP不涨。这通常是过拟合。看训练集loss和验证集loss的差距,如果训练集loss很低但验证集很高,就是过拟合。对策是加增强、加负样本、或者减小模型(把cfg里的通道数砍一砍)。单类任务其实不需要YOLOv3那么大的容量,用YOLOv3-tiny往往效果差不多还快。
第三类:训练中途loss突然跳高。检查是不是steps设置导致学习率降了之后又因为某种原因恢复。Darknet的学习率是按迭代数阶梯下降的,不会自己恢复。如果跳高,多半是某批数据有问题,比如某张图损坏读不出来。可以在训练日志里找跳高那次的迭代号,反推是哪张图。
6.3 验证时机的选择
Darknet每valid_interval次迭代跑一次验证(默认在cfg里设)。我建议设成每500次验证一次,太频繁浪费时间,太稀疏看不到趋势。验证时看的是mean average precision,单类就是AP。AP在0.7以上基本可用,0.8以上算不错。但别只看AP,还要看recall——行人检测里漏检往往比误检更致命。
7. 推理与后处理:让模型输出真正能用的结果
7.1 命令行推理与阈值调整
./darknet detector test person.data yolov3-person.cfg backup/yolov3-person_final.weights test.jpg -thresh 0.5-thresh是置信度阈值。默认0.25,对行人检测偏低,会框出一堆背景。我一般从0.5起调,看实际效果。如果漏检多就降到0.4,误检多就升到0.6。这个值没有标准答案,取决于你的场景对漏检和误检的容忍度。
7.2 NMS的IoU阈值
YOLOv3默认NMS的IoU阈值是0.45。行人密集场景(比如地铁口)里,两个人挨得近,框的IoU可能超过0.45,导致其中一个被抑制掉。这时候要把阈值调高到0.5甚至0.6。但调太高又会出现同一个人的重复框。我的经验是:如果场景里行人平均间距大于一个人宽,0.45够用;如果经常有并排走的情况,调到0.55。
7.3 输出结果的解析
Darknet的detector test会输出一个predictions.jpg,但如果你要集成到自己的程序里,需要用detector的API或者解析输出文本。输出格式是每行类别 置信度 xmin ymin xmax ymax(像素坐标)。注意这里的坐标是相对于原图的,不是归一化的。如果你在推理前resize了图像,要按比例映射回去。
8. 模型到底能不能用:一套务实的评估方法
8.1 别只看AP,要看场景指标
AP是个综合指标,但实际业务里你关心的是具体场景下的表现。我习惯做三件事:第一,从验证集里挑出所有"小目标"(框面积小于图像面积1%的),单独算AP,看模型对小行人的能力;第二,挑出所有"遮挡"样本(difficult=1的),单独算recall;第三,找一批模型没见过的场景图(比如不同摄像头角度),人工看误检和漏检。
8.2 一个容易被忽略的指标:框的稳定性
视频场景里,如果模型对同一个行人在连续帧上的框忽大忽小,下游跟踪算法会很难受。评估时可以抽一段视频,看框的抖动程度。如果抖动大,说明模型对尺度变化的鲁棒性不够,需要加尺度增强,或者用更大的输入分辨率。
8.3 什么时候该考虑换方案
如果你发现单类YOLOv3在验证集上AP怎么调都上不去0.7,先别急着换模型。检查三件事:数据量是不是真的太少(少于3000个实例基本没戏)、标注质量是不是有问题(抽几十张图人工核对)、输入分辨率是不是太低(320对远距离行人确实不够)。这三件事排除了,再考虑换YOLOv4/v5或者用专门的行人检测框架。很多时候问题不在模型,在数据。
9. 一些实操下来觉得值得记下的细节
训练到后期,我习惯把backup目录里的权重按迭代号存一份,别只留final。因为final不一定是最优的,有时候15000次的权重比20000次的好。Darknet默认每100次存一次,_10000.weights这种命名,保留几个关键节点就行。
另外,如果你打算把这个模型部署到边缘设备,训练时就把输入分辨率定成部署时的分辨率。比如部署用320,训练就用320,别用416训完再降,精度会掉一截。这个坑我在一个项目里踩过,重新训了一版才补回来。
最后,VOC2012的person标注里,骑自行车和骑摩托车的人也算person,但框通常只框人不框车。如果你的场景里需要区分"骑车的人"和"走路的人",单类模型做不到,得加类别或者用姿态估计辅助。这是单类行人检测的固有边界,提前想清楚,别到部署了才发现需求对不上。