☰
YOLOv3训练自定义数据集全流程避坑指南:环境配置与报错排查
2026/10/15 5:29:00 网站建设 项目流程

YOLOv3放在今天来看依然是很多实际项目的主力检测方案,尤其在工业视觉、小样本自定义目标识别这些场景里,用Keras/TensorFlow配合Python做训练的人非常多。原因很简单:这套组合的资料最全、代码可读性最好、改起来最灵活。但真到了要训练“自己的数据集”这一步,你会发现真正劝退你的不是网络结构,而是环境版本、标注转换、配置文件、训练崩溃这些零零碎碎的问题。我自己当年第一次跑通全套流程,光环境折腾了两天,后面各种报错又花了将近一周才把模型真正训练出来。这篇文章把我踩过的坑和排查思路都整理出来,给正在被这套流程折磨的朋友做个参考。

我默认你是照着主流的Keras版YOLOv3开源实现来做的,代码结构大致是:模型定义在yolo3/model.py,训练入口在train.py,推理在yolo.py。后面所有讨论都基于这种结构展开。

1. 环境搭建:版本组合的兼容性地狱

1.1 Keras与TensorFlow的版本搭配

这是整条链路里最容易出问题的一步,几乎90%的人还没见到训练界面就先挂在这里。

如果你照着两三年以前的教程操作,教程很可能让你装TensorFlow 1.x和Keras 2.1.x,那套组合确实稳。但问题是现在新环境默认装的都是TensorFlow 2.x,直接跑老代码会炸出一堆AttributeError。最常见的几个:

  • AttributeError: module 'keras.backend' has no attribute 'get_session'
  • AttributeError: module 'tensorflow' has no attribute 'Session'
  • TypeError: Cannot convert a symbolic Keras input/output to a numpy array

这几个报错的本质都是同一个:你用的代码依赖TensorFlow 1.x的静态图机制,而当前运行环境是TensorFlow 2.x默认开启的Eager模式。get_session、tf.Session这些API在TF2里被移除了,Keras 2.4以后的版本也不再兼容旧式session调用。

解决方案有两条路。

第一条,降级环境,搭建一个旧版本环境专门跑训练。具体组合我用过最顺的是tensorflow==1.14.0和Keras==2.2.4,Python版本控制在3.6或3.7。这个组合跑经典的Keras YOLOv3实现几乎不用改任何代码,load_weights、训练、推理都正常。缺点是旧版TensorFlow在Python 3.8以上环境很难安装,你需要用conda单独创建一个环境。

第二条,留在TF2.x,对代码做兼容性修改。不用改太多,核心就两处:把keras的import改成tensorflow.keras,或者在代码开头加上:

import tensorflow as tf import keras tf.compat.v1.disable_eager_execution() tf.compat.v1.disable_v2_behavior()

这两行会让TF2以1.x兼容模式运行,很多老代码能直接跑起来。但要注意,disable_v2_behavior()之后有些新API会失效,如果你的其他依赖库是基于TF2写的,可能反而报新错。所以我的建议是:既然项目本身是老结构,就直接用老环境,别硬在TF2上兼容,省下的时间够你多训十轮模型。

1.2 CUDA和cuDNN版本不对,训练中出现Could not create cudnn handle

这个坑更隐蔽,因为很多报错不是出现在import阶段,而是出现在第一次真正调用卷积运算的时候,比如你加载预训练权重后做一次前向推理,然后突然弹出:

  • Could not create cudnn handle: CUDNN_STATUS_NOT_INITIALIZED
  • failed to get solver or cuDNN version
  • ResourceExhaustedError: OOM when allocating tensor with shape[...](这个后面单独讲)

出现这类问题,先别急着怀疑代码。去看TensorFlow版本、CUDA版本、cuDNN版本三者是否匹配。TensorFlow 1.14通常对应CUDA 10.0和cuDNN 7.4或7.6,TF2.x各有各的对应表,官方文档有详细说明。我踩过一次最典型的坑:环境里装的是CUDA 10.2,cuDNN却是8.0,结果TF1.14死活初始化不了cuDNN。把cuDNN降到7.6.5之后一切恢复正常。

建议你装环境时用conda统一管理:

conda create -n yolo python=3.7 conda activate yolo conda install cudatoolkit=10.0 cudnn=7.6 pip install tensorflow-gpu==1.14.0 pip install keras==2.2.4

用conda装CUDA和cuDNN的好处是不会污染系统全局环境,不同项目之间互相隔离,版本冲突时直接重建一个环境即可。

2. 数据集准备:标注和目录决定了你的训练上限

2.1 目录结构:不要自己发明,直接照抄成熟格式

很多新手喜欢把图片和标注文件全部堆在一个文件夹里,然后在脚本里各种拼接路径。这样不是不行,但出错的概率高很多。主流的Keras YOLOv3实现需要你提供的是train.txt、val.txt这样的文件列表,每个文件里是一行一个图片的绝对路径或者相对路径,代码会自己根据路径去读对应的标注文件。

我建议的目录结构是这样的:

dataset/ ├── images/ │ ├── img_0001.jpg │ ├── img_0002.jpg │ └── ... ├── labels/ │ ├── img_0001.txt │ ├── img_0002.txt │ └── ... ├── train.txt └── val.txt

图片和标注文件保持同名,只是扩展名不同。train.txt里每一行是images/img_0001.jpg这种相对路径,训练脚本会自动把images替换成labels去读标注。这样结构最清晰,也方便后续做数据划分和增量训练。

如果你手里的标注是VOC格式的XML,就先转换再划分。千万别试图让训练脚本直接读XML,大多数Keras版实现只吃YOLO格式的txt标注。

2.2 XML转YOLO格式时最容易算错的几个值

YOLO格式的标注是归一化后的中心点坐标加宽高,格式为:

class_id x_center y_center width height

所有值都除以图片的宽高,范围在0到1之间。从XML转换时,最容易出的问题有三个。

第一个是类别编号没从0开始。class_id必须是0、1、2这样的索引,不是1、2、3。如果你只有两类目标,类别编号只能是0和1,写成1和2训练时不会报错,但模型预测结果和你的预期会对不上。

第二个是坐标归一化时除错了数。有人会把x_center / image_width写成x_center / image_height,或者忘了坐标是中心点而不是左上角。转换逻辑应该是:

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

第三个是标注框越界。有些标注工具的框会超出图片边界,导致归一化后出现大于1或者负数。训练时虽然不一定报错,但会导致loss异常甚至出现NaN。转换后最好加一道clip:

x_center = max(0, min(1, x_center)) width = max(0.001, min(1, width))

宽度高度不能为0。如果真的出现宽高为0的框,直接把这一行删掉。

2.3 划分训练集和验证集的小脚本

我习惯在转换完标注之后写一个简单脚本,按比例随机划分训练集和验证集,同时检查每个txt标注是否为空文件。

import os import random image_dir = "dataset/images" label_dir = "dataset/labels" train_rate = 0.9 images = [f for f in os.listdir(image_dir) if f.endswith(".jpg")] random.shuffle(images) train_num = int(len(images) * train_rate) with open("dataset/train.txt", "w") as f_train: for img in images[:train_num]: label_path = os.path.join(label_dir, img.replace(".jpg", ".txt")) if os.path.getsize(label_path) < 5: print("empty label:", img) continue f_train.write(os.path.join("images", img) + "\n") with open("dataset/val.txt", "w") as f_val: for img in images[train_num:]: f_val.write(os.path.join("images", img) + "\n")

这里检查空文件很关键。我自己遇到过一种情况:数据里有一部分图确实没有目标,标注工具生成了只含一行0 0 0 0 0的空文件,训练时loss曲线突然跳一下。把图片路径打出来看才发现是空标注导致的。

另外建议在正式训练之前,写个脚本把标注框画回图片上看一遍。这个步骤虽然烦,但能提前发现坐标归一化错误、类别错乱等问题。画框时如果发现很多框位置偏了或者尺寸不对,马上回头检查转换脚本,不要等到训练结束才来怀疑数据。

3. 改配置不是改代码:cfg、classes和anchors的联动

3.1 filters到底怎么算

Keras版YOLOv3一般会让你改yolo3/model.py里的classes数量和每个检测层前面的filters数量。这是训练自定义数据集时最容易被忽略的一个数值。

每层检测头前面的卷积层filters计算公式是固定的:

filters = 3 * (num_classes + 5)

为什么是5?因为YOLO每个anchor要预测4个坐标偏移量加上1个objectness置信度,最后再加上类别数。你有C个类别,每个anchor输出C+5个值。每个网格点有3个anchor,所以就是3乘以(C+5)。

假设你检测两类目标,num_classes = 2,三个yolo层前面的filters就是3 * (2 + 5) = 21。如果你只检测1类,那就是18。网上很多报错比如ValueError: Shapes (None, 13, 13, 18) and (None, 13, 13, 255) are incompatible,几乎都是这里算错了。

截图里这种shape mismatch信息已经直接把期望的输出尺寸告诉你了,看到这种错误优先去检查model.py里的三个filters,而不是怀疑训练数据。

3.2 classes文件和类别顺序

主流的Keras实现会有两个地方用到类别:一个是model_data/classes.txt,另一个是voc_classes.txt或者你自己指定的类别文件。类别顺序极其重要,因为你的标注txt里每一行开头的数字对应的是这个列表的索引。也就是说,如果classes.txt内容是这样:

person car

那么编号0就是person,编号1就是car。如果你训练时用的类别文件顺序和标注时的顺序不一致,模型学习到的类别就会错位。举个例子,标注文件里0代表person,但类别文件写成car, person,那么模型预测的person类别实际上对应的是你手里的car。这类错位训练时完全不会报错,只有最后验证预测结果时才暴露出来,特别坑。

所以统一标注工具、转换脚本、类别文件三者使用的类别顺序,是最基本的前提。我的做法是把类别文件放在数据集目录下,跟标注转换脚本放在一起,而不是随手放在代码目录里,防止版本乱了。

3.3 要不要重新聚类anchors

YOLOv3默认使用的anchors是COCO数据集上聚出来的9个框。如果你的目标和人、车这种COCO常见类别没关系,而是工业零件、细胞、小物件之类,或者目标的尺寸和COCO数据分布差异很大,那就强烈建议用你自己的训练数据重新聚类。

不聚类会出现什么情况?最典型的症状是训练了很长时间loss能降,但检测框普遍偏大或者偏小,尤其小目标召回率很低。原因就是anchor的尺寸和真实目标尺寸差太多,回归的起点太离谱,模型难以修正。

聚类很简单,用K-Means,距离度量用IoU相关距离。这里贴一个我常用的聚类脚本核心逻辑:

import numpy as np from sklearn.cluster import KMeans def iou(box, clusters): x = np.minimum(box[0], clusters[:, 0]) y = np.minimum(box[1], clusters[:, 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_ boxes = [] for label_file in label_txts: with open(label_file) as f: for line in f: parts = line.strip().split() w = float(parts[3]) h = float(parts[4]) if w > 0 and h > 0: boxes.append([w, h]) boxes = np.array(boxes) kmeans = KMeans(n_clusters=9, random_state=0).fit(boxes) clusters = kmeans.cluster_centers_

聚类完成后把得到的9个anchors从小到大排序,按顺序分配到大、中、小三个尺度:最小的3个给52x52特征图,中间的3个给26x26,最大的3个给13x13。然后在model.py里替换掉原来的anchors。这里顺序不要搞反,YOLOv3的anchor映射关系是:特征图越小,对应anchor越大。

4. 训练过程中的典型报错与排查思路

4.1 刚启动就崩:load_weights报错的前因后果

model.load_weights("model_data/yolo_weights.h5")这行代码,几乎是每个人都会卡住的地方。

如果你用的预训练权重是从官方或者常见来源下载的,它通常是用原始YOLOv3结构保存的,包含yolo_darknet53那几个阶段。但你改过类别数之后,模型结构已经变了,此时直接load_weights就会报一个很迷惑的错误:

  • AttributeError: 'NoneType' object has no attribute 'shape'
  • ValueError: Dimension 0 in both shapes must be equal, but are 256 and 18

解决思路是分层加载,跳过因为类别数变化而不匹配的层。主流的Keras版实现通常提供了一个model.py里带load_weights或者load_darknet_weights的函数,专门用来从darknet权重转换加载。如果用的是预训练h5文件,建议用load_weights(..., by_name=True, skip_mismatch=True)这种方式加载。示例如下:

model.load_weights("model_data/yolo_weights.h5", by_name=True, skip_mismatch=True)

skip_mismatch=True会跳过尺寸不匹配的层,保留能匹配的层。但要注意,这个操作会把所有匹配层的权重都加载进来,不匹配的层(比如你改过filters的卷积层)保持随机初始化。这意味着网络里大部分detection层其实是从头训练的,如果你希望效果接近完整迁移学习,加载完以后应该在这个基础上去微调整个模型,而不是指望刚加载权重就有好的检测能力。

另外,h5py版本太新也会带来坑。如果你环境里装的是h5py>=3.0,读旧权重文件经常会报H5DeprecationWarning或者KeyError,这种时候要么降级h5py==2.10.0,要么确认权重文件本身是不是没下载完整。后者常见的情况是下载文件只有几十KB,而正常的权重文件有200多MB。下载文件后先看一眼大小,省得后面排查半天。

4.2 训练第一轮就出现loss=NaN

训练开始后没几分钟,日志里loss变成了NaN,这是最让人抓狂的问题之一。出现NaN的原因不多,但每个都很隐蔽。

最常见的原因是学习率太大。YOLOv3在Keras版里默认学习率通常设置为0.001,如果你用的优化器是Adam或者SGD,在不冻结主干的情况下直接0.001起训,很容易炸。尤其是网络大部分层从头训练时,梯度爆炸的概率更高。解决办法:把初始学习率降到0.0001,或者先用0.0005跑两轮看看loss是否平稳再决定。

第二个常见原因是标注数据里面有非法值。比如某个txt文件里出现了nan,或者宽高是负数。这些非法值经过模型计算后会传导到loss里,直接变成NaN。排查方法很简单:用脚本扫描所有标注文件,用浮点数读取每一行,看看有没有NaN或者Inf。

第三个原因是输入图片本身有问题。比如某些图片是损坏的,cv2读取出来是None,有些图片的shape是0x0,或者图片里有异常的像素值。这种问题在训练脚本里通常体现为空指针或者输入张量形状不对。我在数据准备阶段就加过一道检查:

import cv2 for img_path in image_list: img = cv2.imread(img_path) if img is None: print("bad image:", img_path)

把坏图直接剔出数据集,省掉一堆后续麻烦。

4.3 loss不降反升,或者全程纹丝不动

loss变成NaN固然吓人,但更常见也更磨人的情况是:loss一直降得很慢,或者前几轮降了一点之后就再也不动了,甚至偶尔反弹。

如果loss卡在一个不高不低的位置完全不动,我第一个怀疑的是anchor问题。前面说过,anchor和真实目标尺寸差异过大时,回归任务的起始误差就已经很大,模型再怎么训练也很难修正回来。这种情况重新聚类anchor,然后重新训练,效果通常立竿见影。

如果loss不降反升,方向不对,一般出在训练策略上。Keras版YOLOv3的实现通常有一个分段训练逻辑:先冻结backbone只训练检测头,跑若干轮后再解冻全部层。如果你一上来就解冻全部层全量训练,学习率又稍高,很容易出现loss上升。建议严格按照原代码的分段训练流程来:第一阶段用较低的学习率冻结主干,第二阶段再用更小的学习率微调全网络。第一阶段跑多少轮要看数据集大小,小数据集跑二三十轮就能看到loss缓慢下降。

我还遇到过一种情况:loss是下降的,但验证集val_loss一直在涨,典型的过拟合。这种情况说明模型容量对于你的数据量来说太大了,抑或数据增强不够。解决方向是加数据增强(随机翻转、随机裁剪、HSV扰动等)、加正则化、增大训练数据量,或者减少训练轮数。

4.4 显存不足OOM和batch_size的取舍

训练时弹出ResourceExhaustedError: OOM when allocating tensor with shape,本质就一个原因:batch_size太大,输入的图片尺寸太大,或者两者叠加导致显存超出上限。

常见的解决办法是调小batch_size。YOLOv3的Keras实现里通常会在train.py里设置batch_size,默认值可能是8或者16,输入尺寸是416x416。你的显卡显存如果是6GB以下,直接改成4试试。

但盲目调小batch_size会影响BN(Batch Normalization)层的统计估计。batch_size太小,比如小于4,BN的统计会很不稳定,导致训练时loss忽高忽低。如果显存实在不够,可以尝试降低输入图片尺寸,从416降到320,这样显存占用会减小很多,对检测精度的影响相对可控。同时也可以打开Keras的allow growth配置,让显存按需分配而不是一次性占用全部:

import tensorflow as tf config = tf.ConfigProto() config.gpu_options.allow_growth = True session = tf.Session(config=config)

这一招对多人共用GPU服务器的情况特别有用,能避免因为某一进程占满显存导致其他人的任务直接OOM。

还有一个容易被忽略的坑:如果你训练脚本里同时加载了图片和标注文件,并且数据处理没有用generator而是一次性把所有数据读进内存,那在数据量大的时候也会导致内存OOM,而不是显存OOM。错误信息会出现在np.array(batch_data)这类语句附近。解决方案是把数据读取改成实时读取或者半实时读取,确保每次只load当前batch的图片。

4.5 开启TensorBoard实时监控训练

很多人训练时只看终端打印的loss,一轮跑下来十几分钟甚至更久,中间不便频繁查看。我的习惯是在正式训练前就配置好TensorBoard回调,这样能实时看到loss曲线的走势,及时判断训练发没发现异常,不用傻等一轮跑完才看到NaN。

from keras.callbacks import TensorBoard, ModelCheckpoint tensorboard = TensorBoard(log_dir="logs", batch_size=batch_size) checkpoint = ModelCheckpoint( "checkpoints/yolo_weights_{epoch:02d}.h5", monitor="loss", save_best_only=False, period=1 )

TensorBoard能同时显示loss和val_loss。有时候终端输出的loss看着平缓,但TensorBoard里能看到最后几十个step的loss出现脉冲毛刺,这往往就是数据里有坏标注的信号,可以提前排查。

5. 训练完成之后:评估和部署的几个新问题

5.1 用mAP而不是肉眼判断模型好坏

训练结束后,下一步是评估模型。肉眼看一下预测图片固然直观,但很容易被迷惑。比如你随机挑了几张图,看起来检测效果都很好,实际情况可能只是这几张图都是模型擅长的大目标场景。严谨一点,应该跑一遍验证集,计算mAP。

Keras版YOLOv3的实现里一般会提供yolo_video.py或test.py这类推理脚本,但mAP计算往往需要额外代码。我自己常用的做法是:用训练好的权重跑验证集的每一张图片,保存预测结果为标准格式(图片id、置信度、xmin、ymin、xmax、ymax、类别id),然后用通用mAP工具脚本和标注做比对。

计算mAP时需要注意两个阈值:conf_thresh和iou_thresh。conf_thresh是置信度阈值,低于这个值的预测会被过滤;iou_thresh是NMS的IoU阈值,决定两个框是否被合并。这两个值直接影响最终mAP结果。不同实现默认值不一样,在同一个数据集上横向对比不同模型时,必须统一这两个参数,否则比较没有意义。

5.2 权重文件的格式和转换

训练过程中ModelCheckpoint保存的是Keras格式的h5权重,这里面保存的是网络结构和权重的完整状态。这个文件可以直接用于推理,也可以加载后继续训练。但如果你要把模型部署到移动端,或者换到其他推理框架,就需要转换成其他格式。

转换为TFLite或者ONNX之前,一个常见的坑是模型里用了自定义层或者Lambda层,转换工具对这些支持不一定完善。YOLOv3的Keras实现里通常会好一些,但如果遇到转换报错,建议先检查模型里是否有tf.image相关操作或者Lambda层。必要时需要简化模型结构或者用tf.function包一层再转。

如果是转ONNX,一个重要注意点是输入输出的名字要固定。写转换脚本时建议显式声明:

import tf2onnx spec = (tf.TensorSpec((1, 416, 416, 3), tf.float32, name="input"),) output = tf2onnx.convert.from_keras(model, input_signature=spec)

转出来的ONNX再放到推理引擎里使用,输入输出名字才不会变成随机字符串。

5.3 推理阶段不能忽视的预处理细节

部署推理时有一个经常被忽略的细节:训练时的预处理和后处理必须跟推理时保持一致。很多人在自定义数据集上训练效果很好,但一到部署推理就发现检测不到目标,或者框的位置偏得离谱,排查半天最后发现是推理脚本里对图片做了resize但忘了按比例缩放坐标,或者推理时没有把BGR转RGB。

YOLOv3训练时对图片的处理通常是resize到416x416,除以255归一化,再转换成模型输入格式。推理时也要做完全相同的处理,坐标还原时要把416坐标系映射回原始图片分辨率。如果这些细节不一致,模型给出的框自然是对不上的。

另一个容易被忽略的是类别文件在推理代码中的加载。推理脚本里必须使用与训练时完全一致的类别文件,顺序错一个,所有预测就全乱了。我习惯把classes.txt跟训练权重放在同一个目录下,打包部署时一起带上,这样能减少版本错乱的概率。

5.4 中途崩溃导致前功尽弃的防丢权重策略

最后分享一个我用血泪教训换来的建议:训练周期动辄几十个小时甚至几天,中途任何一次意外崩溃都可能让你丢失全部进度。因此ModelCheckpoint的保存策略一定要设置好。

我的设置方案是每个epoch都保存一次,文件名带上epoch编号,这样即使某一轮训练崩了,也能从最近的一个epoch恢复继续训练,而不是一切从头开始。同时每过几个epoch会把表现最好的权重单独存一份best_weights.h5。恢复训练时用最近的权重文件加载,再配合调整学习率继续跑。

还有一点,日志文件要保留。训练日志不仅能帮你复盘问题,还是判断恢复训练时应该使用多大学习率的重要参考。如果你发现loss已经很低但还在震荡,恢复训练时就应该把学习率降一个数量级继续微调。


如果你正准备开始这套流程,我个人的建议是先别急着上全部数据。找几十张有代表性的图片,跑通整个训练和推理流程,确认标注、路径、类别编号、anchors都没问题,再放开全部数据正式训练。这一步至少能省你一半的排障时间。Keras版YOLOv3这套代码虽然老,但胜在结构清晰、可改性强,只要把数据配置环节稳扎稳打走一遍,后面训练就是按部就班的事。真到了部署阶段,你还会发现,当初在最容易偷懒的环节上多花的功夫,都会给你省回来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询