Keras复现YOLOv1:从零训练自己的数据集实战指南
简介这份资源面向希望上手目标检测的深度学习研究者与开发者围绕Keras版YOLOv1算法提供训练自有数据集的完整配套材料。YOLOv1将检测视为回归问题速度优势明显而Keras高层API降低了实现门槛二者结合便于快速搭建与调优模型。压缩包共11个文件约128KB包含3个py脚本网络训练、测试与Darknet19骨干实现、3个txt数据列表与说明、4张jpg效果示例图及1份md说明文档覆盖从数据组织到模型验证的主要环节。已有108人学习下载。读者可据此了解数据清洗、标注、训练集与测试集划分的流程掌握网络参数调整、学习率设置与过拟合抑制等要点并借助示例图与列表文件核对检测效果在已有实现上继续优化模型精度适合初学者入门与有经验者二次开发。1. 用 Keras 复现 YOLOv1从零训练自己的数据集到底值不值得做很多人第一次接触目标检测都是从 YOLO 系列开始的。但真到动手环节面对 YOLOv5、YOLOv8 这些开箱即用的框架反而会有一个疑问为什么还要回头去啃 YOLOv1答案其实很直接——YOLOv1 是理解整个单阶段检测范式的入口。它把检测问题直接建模成回归问题一个网络、一次前向、输出 7×7×30 的张量没有 RPN、没有 Anchor、没有多尺度特征融合。你把这套流程跑通一遍后面再看任何 YOLO 变体都能一眼看出它在哪个环节做了改进。这篇笔记面向的是手头有一批标注数据、想用 Keras 版 YOLOv1 训练自己数据集的人。不管你是学生做课题、工程师做原型验证还是想搞明白检测损失函数到底怎么算的从业者只要你能装好 Python 环境、看得懂基本的 NumPy 操作就能跟着走完。我会把数据组织、模型结构、损失函数、训练参数、推理验证这几个环节拆开讲重点放在那些文档里不写、但实际跑起来一定会卡住的地方。2. 数据准备与目录结构标注格式怎么对齐 YOLOv1 的 7×7 网格YOLOv1 的数据输入和输出格式跟后来的版本差别很大很多人卡在第一步不是因为不会写代码而是因为标注文件和网络输出对不上。这一章先把数据这条链路理清楚。2.1 标注格式的选择与转换逻辑YOLOv1 要求每个标注框表示成(class_id, x_center, y_center, width, height)其中后四个值都是相对于整张图宽高的归一化值范围在 0 到 1 之间。如果你手头是 VOC 的 XML需要先解析出xmin, ymin, xmax, ymax再转成中心点加宽高的格式。如果是 COCO 的 JSON逻辑类似但要注意 COCO 的bbox本身就是[x, y, w, h]格式转的时候别搞反。常见做法是先把所有标注统一转成一个 txt 文件每行对应一张图格式如下image_path x1,y1,x2,y2,class_id x1,y1,x2,y2,class_id ...这样做的好处是训练时读数据不用再解析 XML 或 JSON直接按行读、按空格切分就行。转换脚本我一般这么写import xml.etree.ElementTree as ET import os def voc_to_txt(xml_dir, img_dir, output_txt, class_map): xml_dir: VOC标注文件目录 img_dir: 对应图片目录 output_txt: 输出的统一标注文件 class_map: {cat: 0, dog: 1} 这样的类别映射 lines [] for xml_file in os.listdir(xml_dir): if not xml_file.endswith(.xml): continue tree ET.parse(os.path.join(xml_dir, xml_file)) root tree.getroot() # 图片文件名VOC里通常不带路径 img_name root.find(filename).text img_path os.path.join(img_dir, img_name) # 读取图片尺寸用于归一化 size root.find(size) img_w int(size.find(width).text) img_h int(size.find(height).text) objects [] for obj in root.findall(object): cls_name obj.find(name).text if cls_name not in class_map: continue cls_id class_map[cls_name] 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_w y_center (ymin ymax) / 2.0 / img_h w (xmax - xmin) / img_w h (ymax - ymin) / img_h objects.append(f{x_center:.6f},{y_center:.6f},{w:.6f},{h:.6f},{cls_id}) if objects: lines.append(img_path .join(objects)) with open(output_txt, w) as f: f.write(\n.join(lines)) print(f转换完成共 {len(lines)} 张图)这段代码的关键点在于归一化。YOLOv1 的损失函数直接拿预测值和这个归一化值做均方误差如果你的坐标没归一化损失会大得离谱训练根本收敛不了。另外注意class_map要提前定义好类别索引从 0 开始和后面模型输出的类别维度对应。2.2 数据加载器的实现与增强策略YOLOv1 的输入是固定尺寸的原论文用的是 448×448。Keras 版实现里通常也保持这个尺寸或者根据你的显存调整到 224×224、320×320。数据加载器要做的事情是读图片、缩放到固定尺寸、把标注框也按同样比例缩放、生成 7×7×30 的目标张量。目标张量的构造是 YOLOv1 最绕的地方。假设你只有一个类别那么每个网格预测 2 个框每个框有 5 个值x, y, w, h, confidence再加上 1 个类别概率总共 2×5111。但原论文是 20 类所以是 2×52030。这里为了讲清楚先按单类别来后面再扩展到多类别。import numpy as np import cv2 def build_target(bboxes, img_size448, grid_size7, num_classes1, num_boxes2): bboxes: 列表每个元素是 (x_center, y_center, w, h, class_id)均已归一化 返回: (grid_size, grid_size, num_boxes*5 num_classes) 的目标张量 target np.zeros((grid_size, grid_size, num_boxes * 5 num_classes)) cell_size 1.0 / grid_size for bbox in bboxes: x, y, w, h, cls_id bbox # 找到中心点落在哪个网格 col int(x / cell_size) row int(y / cell_size) # 边界保护 col min(col, grid_size - 1) row min(row, grid_size - 1) # 中心点相对于网格的偏移 x_offset x / cell_size - col y_offset y / cell_size - row # 如果这个网格还没有目标就填进去 if target[row, col, 4] 0: target[row, col, 0] x_offset target[row, col, 1] y_offset target[row, col, 2] w target[row, col, 3] h target[row, col, 4] 1.0 # 置信度 target[row, col, 5] 1.0 # 第二个框的置信度训练时只惩罚一个 target[row, col, 10 int(cls_id)] 1.0 # 类别one-hot return target这里有几个细节值得说。第一x_offset和y_offset是相对于网格左上角的偏移范围在 0 到 1 之间这样网络学起来比直接回归绝对坐标稳定得多。第二w和h是相对于整张图的宽高不是相对于网格。第三置信度那一维我一般把两个框的置信度都设成 1但损失函数里只对负责预测的那个框计算坐标损失另一个框只算置信度损失。这个逻辑在下一章讲损失函数时会展开。数据增强方面YOLOv1 原论文用了随机缩放、平移、HSV 抖动。实际训练时我建议至少加上随机水平翻转和 HSV 亮度对比度扰动这两个对检测效果提升明显而且实现简单。注意翻转的时候标注框的 x 坐标要跟着变x_new 1 - x_old。2.3 训练集与验证集的划分比例小数据集几千张图按 8:2 划分就行大数据集可以 9:1。划分的时候要保证类别分布均衡别出现某个类别只在验证集里出现的情况。我一般用sklearn.model_selection.train_test_split按类别分层抽样或者自己写个简单的分层逻辑。划分完把文件路径分别写到train.txt和val.txt里训练时按文件读。3. Keras 搭建 YOLOv1 网络从 448×448 输入到 7×7×30 输出YOLOv1 的网络结构本身不复杂24 个卷积层加 2 个全连接层但 Keras 实现时有一些坑比如全连接层的维度怎么算、激活函数怎么选、输出层怎么设计。这一章把网络搭起来并解释每个设计决策背后的原因。3.1 骨干网络与全连接层的维度推导原论文的骨干网络是受 GoogLeNet 启发设计的但没用 Inception 模块而是用 1×1 卷积降维加 3×3 卷积提特征交替堆叠。Keras 里可以用Conv2D、BatchNormalization、LeakyReLU来搭。注意 YOLOv1 原论文用的是 LeakyReLU斜率 0.1不是 ReLU。这个细节对训练稳定性有影响别随手换成 ReLU。全连接层的维度推导是很多人卡住的地方。假设最后一个卷积层输出是(7, 7, 1024)那么 Flatten 之后是7*7*1024 50176维。第一个全连接层输出 4096 维第二个全连接层输出7*7*30 1470维然后 Reshape 成(7, 7, 30)。这里的关键是第二个全连接层后面不要加激活函数因为输出既有坐标需要线性输出又有置信度和类别概率需要 0 到 1 之间。常见的做法是坐标用线性输出置信度和类别在损失函数里过 sigmoid或者直接在网络里对特定通道加 sigmoid。from tensorflow.keras import layers, models def build_yolov1(input_shape(448, 448, 3), num_classes1, num_boxes2, grid_size7): inputs layers.Input(shapeinput_shape) # 卷积块定义conv bn leaky_relu def conv_block(x, filters, kernel_size, strides1): x layers.Conv2D(filters, kernel_size, stridesstrides, paddingsame, use_biasFalse)(x) x layers.BatchNormalization()(x) x layers.LeakyReLU(alpha0.1)(x) return x # 骨干网络按原论文结构简化 x conv_block(inputs, 64, 7, strides2) x layers.MaxPooling2D(pool_size2, strides2)(x) x conv_block(x, 192, 3) x layers.MaxPooling2D(pool_size2, strides2)(x) x conv_block(x, 128, 1) x conv_block(x, 256, 3) x conv_block(x, 256, 1) x conv_block(x, 512, 3) x layers.MaxPooling2D(pool_size2, strides2)(x) # 重复堆叠4次 256-512 for _ in range(4): x conv_block(x, 256, 1) x conv_block(x, 512, 3) x conv_block(x, 512, 1) x conv_block(x, 1024, 3) x layers.MaxPooling2D(pool_size2, strides2)(x) # 再堆叠2次 512-1024 for _ in range(2): x conv_block(x, 512, 1) x conv_block(x, 1024, 3) x conv_block(x, 1024, 3) x conv_block(x, 1024, 3, strides2) x conv_block(x, 1024, 3) x conv_block(x, 1024, 3) # 全连接层 x layers.Flatten()(x) x layers.Dense(4096)(x) x layers.LeakyReLU(alpha0.1)(x) x layers.Dropout(0.5)(x) # 输出层维度 grid*grid*(num_boxes*5 num_classes) x layers.Dense(grid_size * grid_size * (num_boxes * 5 num_classes))(x) outputs layers.Reshape((grid_size, grid_size, num_boxes * 5 num_classes))(x) model models.Model(inputsinputs, outputsoutputs) return model这段代码里use_biasFalse是因为后面接了 BatchNormalization偏置项会被 BN 的 beta 吸收加了反而多余。Dropout(0.5)放在第一个全连接层后面原论文也是这么做的防止过拟合。输出层没有加激活函数坐标直接线性输出置信度和类别概率在损失函数里处理。3.2 输出张量的通道含义与解码方式输出张量是(7, 7, 30)对于单类别、2 个框的情况通道含义如下通道索引含义0框1的 x 偏移1框1的 y 偏移2框1的 w3框1的 h4框1的置信度5框2的 x 偏移6框2的 y 偏移7框2的 w8框2的 h9框2的置信度10类别概率单类别解码的时候对于每个网格(row, col)取置信度最大的那个框然后把偏移和宽高还原成原图坐标def decode_predictions(pred, grid_size7, img_size448, conf_threshold0.5): pred: (7, 7, 30) 的网络输出 返回: 检测框列表 [(x1, y1, x2, y2, score, class_id), ...] boxes [] cell_size img_size / grid_size for row in range(grid_size): for col in range(grid_size): # 两个框的置信度 conf1 pred[row, col, 4] conf2 pred[row, col, 9] if max(conf1, conf2) conf_threshold: continue if conf1 conf2: x_offset, y_offset, w, h pred[row, col, 0:4] conf conf1 else: x_offset, y_offset, w, h pred[row, col, 5:9] conf conf2 # 还原到原图坐标 x_center (col x_offset) * cell_size y_center (row y_offset) * cell_size w_pixel w * img_size h_pixel h * img_size x1 x_center - w_pixel / 2 y1 y_center - h_pixel / 2 x2 x_center w_pixel / 2 y2 y_center h_pixel / 2 # 类别概率单类别时直接取通道10 class_prob pred[row, col, 10] score conf * class_prob boxes.append([x1, y1, x2, y2, score, 0]) return boxes解码之后还要做 NMS非极大值抑制因为同一个目标可能被多个网格检测到。NMS 的 IoU 阈值一般设 0.5这个在推理脚本里加上就行。3.3 损失函数的设计坐标、置信度、类别三部分怎么加权YOLOv1 的损失函数是整篇论文最核心的部分也是训练能不能收敛的关键。它由三部分组成坐标损失、置信度损失、类别损失。坐标损失只对负责预测目标的那个框计算置信度损失对两个框都计算但负样本的置信度损失要降权类别损失只对包含目标的网格计算。import tensorflow as tf def yolov1_loss(y_true, y_pred, lambda_coord5.0, lambda_noobj0.5): y_true: (batch, 7, 7, 30) y_pred: (batch, 7, 7, 30) # 分离各个部分 # 负责预测的框的mask网格里有目标且框1置信度为1 obj_mask y_true[..., 4] # (batch, 7, 7) noobj_mask 1.0 - obj_mask # 坐标损失只对obj_mask为1的网格计算 # 框1的坐标 coord_pred_1 y_pred[..., 0:4] coord_true_1 y_true[..., 0:4] coord_loss_1 tf.reduce_sum(obj_mask[..., tf.newaxis] * tf.square(coord_true_1 - coord_pred_1)) # 框2的坐标如果框2也负责预测这里简化处理只算框1 # 置信度损失 conf_pred_1 y_pred[..., 4] conf_true_1 y_true[..., 4] conf_pred_2 y_pred[..., 9] conf_true_2 y_true[..., 9] conf_loss_obj tf.reduce_sum(obj_mask * tf.square(conf_true_1 - conf_pred_1)) conf_loss_noobj tf.reduce_sum(noobj_mask * (tf.square(conf_true_1 - conf_pred_1) tf.square(conf_true_2 - conf_pred_2))) # 类别损失 class_pred y_pred[..., 10:] class_true y_true[..., 10:] class_loss tf.reduce_sum(obj_mask[..., tf.newaxis] * tf.square(class_true - class_pred)) # 总损失 total_loss lambda_coord * coord_loss_1 conf_loss_obj lambda_noobj * conf_loss_noobj class_loss return total_loss这里lambda_coord5.0和lambda_noobj0.5是原论文的参数。坐标损失放大 5 倍是因为坐标回归比置信度分类更难学不放大权重网络会倾向于预测置信度而忽略坐标。负样本置信度损失缩小 0.5 倍是因为一张图里大部分网格都是负样本不降权的话负样本损失会淹没正样本损失。实际训练时我建议把损失函数拆开打印分别看坐标损失、置信度损失、类别损失的变化趋势。如果坐标损失一直不降检查归一化是不是做对了如果置信度损失震荡可能是学习率太大如果类别损失不降检查类别标签的 one-hot 编码有没有问题。4. 训练参数与调参策略学习率、批次大小、冻结策略怎么定网络搭好了、损失函数写好了接下来就是训练。这一章讲训练参数怎么设以及训练过程中遇到问题怎么调。4.1 学习率预热与分段衰减YOLOv1 原论文用了带动量的 SGD学习率从 0.001 慢慢升到 0.01再分段衰减。Keras 里可以用tf.keras.optimizers.schedules来实现。我一般用余弦退火或者分段常数衰减前者更平滑后者更好控制。initial_learning_rate 0.001 epochs 100 # 前5个epoch预热从0.0001升到0.001 warmup_epochs 5 # 之后每30个epoch衰减为原来的0.1 boundaries [30, 60, 90] values [0.001, 0.0001, 0.00001, 0.000001] lr_schedule tf.keras.optimizers.schedules.PiecewiseConstantDecay( boundaries[b * steps_per_epoch for b in boundaries], valuesvalues ) optimizer tf.keras.optimizers.SGD(learning_ratelr_schedule, momentum0.9)steps_per_epoch是每个 epoch 的迭代次数等于训练集大小除以批次大小。预热阶段可以用tf.keras.optimizers.schedules.PolynomialDecay或者自己写个回调。学习率预热对 YOLOv1 这种全连接层参数量大的网络特别重要不预热的话前几个 epoch 损失容易炸。4.2 批次大小与显存占用的平衡YOLOv1 输入 448×448全连接层 4096 维参数量大概在 2 亿左右。显存占用主要在全连接层和中间特征图。8GB 显存的卡批次大小设 8 到 16 比较稳。如果显存不够可以把输入尺寸降到 320×320 或 224×224但要注意标注框的归一化值不用改因为归一化是相对于原图宽高的缩放图片时标注框也跟着缩放归一化值不变。批次大小太小比如 2 或 4会导致 BatchNormalization 统计量不准训练不稳定。如果实在只能用小批次可以把 BN 换成 GroupNormalization或者冻结 BN 层用预训练统计量。但 YOLOv1 从头训练的话还是建议批次至少 8。4.3 冻结骨干网络与微调策略如果你有预训练的骨干网络权重比如在 ImageNet 上训过的可以先冻结卷积层只训全连接层训几个 epoch 后再解冻全部微调。这样做的好处是收敛快坏处是如果预训练数据集和你的目标域差别大微调效果可能不如从头训。我一般这么操作前 10 个 epoch 冻结卷积层学习率设 0.001然后解冻全部学习率降到 0.0001再训 50 到 100 个 epoch。解冻后如果损失突然上升说明学习率还是太大再降一个数量级。# 冻结卷积层 for layer in model.layers: if conv in layer.name: layer.trainable False # 编译训练 model.compile(optimizeroptimizer, lossyolov1_loss) model.fit(train_dataset, epochs10, validation_dataval_dataset) # 解冻全部 for layer in model.layers: layer.trainable True # 降低学习率重新编译 optimizer tf.keras.optimizers.SGD(learning_rate0.0001, momentum0.9) model.compile(optimizeroptimizer, lossyolov1_loss) model.fit(train_dataset, epochs100, validation_dataval_dataset)注意解冻后重新编译时优化器的状态会重置动量信息丢失所以损失可能会有一个小反弹这是正常的。5. 训练中常见的坑从损失不降到检测框乱飞这一章记录几个我实际踩过的坑每个都按现象、原因、解决来写。如果你训练时遇到类似情况可以直接对照排查。5.1 损失降到某个值就不动了现象训练前几个 epoch 损失下降很快但降到 3.0 左右就几乎不动了验证集损失也不降。原因最常见的原因是学习率衰减太快或者坐标损失的权重设得太小。另一个可能是数据标注里有大量错误框网络学到一个平庸的解就停住了。解决先把学习率衰减的边界往后推比如从 30 个 epoch 推到 50 个 epoch。然后检查标注文件随机抽 20 张图把框画出来看看有没有框错位置或者漏标的情况。如果标注没问题把lambda_coord从 5.0 调到 8.0 试试让网络更关注坐标回归。5.2 检测框全部堆在图像中心现象推理时所有检测框都集中在图像中心区域不管目标实际在哪。原因这是 YOLOv1 训练不充分或者坐标损失设计有问题的典型表现。网络发现预测中心点附近的框平均损失最小就偷懒全预测中心。解决检查坐标损失是不是只对obj_mask为 1 的网格计算了。如果负样本网格的坐标也参与了损失网络会被大量零坐标拉向中心。另外确认x_offset和y_offset的计算是不是相对于网格的如果错算成相对于整张图网络学到的偏移会很小解码后框也集中在中心。5.3 置信度和类别概率输出全是 0.5 左右现象推理时所有框的置信度都在 0.5 附近类别概率也差不多没有明显区分度。原因损失函数里置信度损失和类别损失的权重不平衡或者网络最后一层加了 sigmoid 导致梯度消失。解决确认输出层没有加激活函数。如果加了 sigmoid把它去掉让置信度和类别在损失函数里通过均方误差学习。另外检查lambda_noobj是不是设得太大负样本置信度损失占主导的话网络会倾向于把所有置信度预测成中间值。5.4 训练损失正常但验证损失一直涨现象训练集损失稳定下降验证集损失从某个 epoch 开始持续上升。原因过拟合。YOLOv1 参数量大小数据集上很容易过拟合。解决加大 Dropout 比例从 0.5 提到 0.6 或 0.7。加数据增强特别是随机裁剪和颜色抖动。如果还不行把第一个全连接层的维度从 4096 降到 2048减少参数量。早停也是一种办法验证损失连续 10 个 epoch 不降就停。5.5 推理时同一个目标出现多个框现象NMS 之后同一个目标还是有两三个框IoU 都大于 0.5。原因NMS 的阈值设得太高或者网络对同一个目标在相邻网格都预测了高置信度。解决把 NMS 的 IoU 阈值从 0.5 降到 0.4 或 0.3。如果还不行检查训练数据里同一个目标是不是被标了多个框有些标注工具会重复标注。另外可以在损失函数里对相邻网格的重复预测加惩罚但这就偏离 YOLOv1 原设计了不如直接调 NMS 阈值来得快。6. 推理验证与效果评估用 mAP 和可视化确认模型真的学到了训练完模型最后一步是验证它到底有没有学到东西。这一章讲怎么算 mAP、怎么做可视化、以及一个我常用的快速检查技巧。6.1 计算 mAP 的简化实现mAP 是目标检测的标准评估指标但完整实现比较长。这里给一个简化版按 IoU 0.5 计算每个类别的 AP然后取平均。def compute_iou(box1, box2): box格式: [x1, y1, x2, y2] x1 max(box1[0], box2[0]) y1 max(box1[1], box2[1]) x2 min(box1[2], box2[2]) y2 min(box1[3], box2[3]) inter max(0, x2 - x1) * max(0, y2 - y1) area1 (box1[2] - box1[0]) * (box1[3] - box1[1]) area2 (box2[2] - box2[0]) * (box2[3] - box2[1]) union area1 area2 - inter return inter / union if union 0 else 0 def compute_ap(pred_boxes, gt_boxes, iou_threshold0.5): pred_boxes: 预测框列表按置信度降序排列 gt_boxes: 真实框列表 if len(gt_boxes) 0: return 0.0 tp np.zeros(len(pred_boxes)) fp np.zeros(len(pred_boxes)) matched set() for i, pred in enumerate(pred_boxes): best_iou 0 best_j -1 for j, gt in enumerate(gt_boxes): if j in matched: continue iou compute_iou(pred[:4], gt[:4]) if iou best_iou: best_iou iou best_j j if best_iou iou_threshold: tp[i] 1 matched.add(best_j) else: fp[i] 1 tp_cum np.cumsum(tp) fp_cum np.cumsum(fp) recalls tp_cum / len(gt_boxes) precisions tp_cum / (tp_cum fp_cum 1e-6) # 11点插值 ap 0.0 for t in np.arange(0, 1.1, 0.1): prec precisions[recalls t].max() if np.any(recalls t) else 0 ap prec / 11.0 return ap这个实现里pred_boxes要按置信度从高到低排序gt_boxes是真实标注。11 点插值是 VOC 的老标准现在一般用全点插值但差别不大。算完每个类别的 AP 后取平均就是 mAP。6.2 可视化检测结果与错误分析光看 mAP 数字不够还要把检测框画出来看。我一般写个脚本随机抽 20 张验证集图片把预测框和真实框画在一起预测框用红色真实框用绿色。这样一眼就能看出模型是漏检多还是误检多。import matplotlib.pyplot as plt import matplotlib.patches as patches def visualize_detection(img_path, pred_boxes, gt_boxes): img plt.imread(img_path) fig, ax plt.subplots(1) ax.imshow(img) # 画预测框 for box in pred_boxes: x1, y1, x2, y2, score, cls_id box rect patches.Rectangle((x1, y1), x2-x1, y2-y1, linewidth2, edgecolorr, facecolornone) ax.add_patch(rect) ax.text(x1, y1, f{score:.2f}, colorred, fontsize10) # 画真实框 for box in gt_boxes: x1, y1, x2, y2 box[:4] rect patches.Rectangle((x1, y1), x2-x1, y2-y1, linewidth2, edgecolorg, facecolornone) ax.add_patch(rect) plt.show()如果红色框和绿色框重合度高说明模型学得不错。如果红色框位置对但尺寸差很多说明宽高回归有问题检查损失函数里 w 和 h 的权重。如果红色框完全乱飞回到第 5 章排查。6.3 一个快速判断模型是否收敛的技巧训练过程中不用等 mAP 算完可以每个 epoch 结束后拿一张固定图片跑一次推理把检测框画出来保存。如果连续几个 epoch 检测框位置和数量都不变说明模型已经收敛了再训下去提升有限。这个技巧帮我省了很多盲目训练的时间。我自己的习惯是训练前先拿一张图跑一遍未训练的模型看看输出长什么样训练中每 10 个 epoch 跑一次同一张图训练完再跑一次。三次对比模型有没有学到东西一目了然。希望帮到你。本文还有配套的精品资源点击获取