如有错误,恳请指出。
在上一篇文章中,见《Yolov3-spp系列 | yolov3spp的正负样本匹配》,介绍了yolov3spp的最难部分,就是正负样本的匹配以及损失计算,那么现在就可以了解整个训练的过程,以及验证与测试过程,在知道了yolov3spp如何进行正负样本的匹配,以及如何设置损失函数,剩下的就没有那么令人头疼了。
所以接下来,用这一篇文章来对yolov3spp这个目标检测算法的训练、验证以及来做一个总结。
1. 训练过程
1.1 参考代码
代码如下:
def train_one_epoch(model, optimizer, data_loader, device, epoch, epochs,
print_freq, accumulate, img_size,
grid_min, grid_max, gs,
multi_scale=False, warmup=True):
"""
Args:
data_loader: len = 1430 1430个batch_size=1个epochs分成一块块的batch_size
print_freq: 每50个batch在logger中更新
accumulate: 1、多尺度训练时accumulate个batch改变一次图片的大小
2、每训练accumulate*batch_size张图片更新一次权重和学习率
第一个epoch accumulate=1
img_size: 训练图像的大小
grid_min, grid_max: 在给定最大最小输入尺寸范围内随机选取一个size(size为32的整数倍)
gs: grid_size
warmup: 用在训练第一个epoch时,这个时候的训练学习率要调小点,慢慢训练
Returns:
mloss: 每个epch计算的mloss [box_mean_loss, obj_mean_loss, class_mean_loss, total_mean_loss]
now_lr: 每个epoch之后的学习率
"""
model.train()
metric_logger = utils.MetricLogger(delimiter=" ")
metric_logger.add_meter('lr', utils.SmoothedValue(window_size=1, fmt='{value:.6f}'))
header = 'Epoch: [{}/{}]'.format(epoch, epochs)
# 模型训练开始第一轮采用warmup训练 慢慢训练
lr_scheduler = None
if epoch == 1 and warmup is True: # 当训练第一轮(epoch=1)时,启用warmup训练方式,可理解为热身训练
warmup_factor = 1.0 / 1000
warmup_iters = min(1000, len(data_loader) - 1)
lr_scheduler = utils.warmup_lr_scheduler(optimizer, warmup_iters, warmup_factor)
accumulate = 1 # 慢慢训练,每个batch都改变img大小,每个batch都改变权重
# amp.GradScaler: 混合精度训练
# GradScaler: 在反向传播前给 loss 乘一个 scale factor,所以之后反向传播得到的梯度都乘了相同的 scale factor
# scaler: GradScaler对象用来自动做梯度缩放
# https://blog.csdn.net/l7H9JA4/article/details/114324414?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522161944073216780357273770%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fall.%2522%257D&request_id=161944073216780357273770&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~first_rank_v2~rank_v29-1-114324414.pc_search_result_cache&utm_term=amp.GradScaler%28enabled%3Denable_amp%29
enable_amp = True if "cuda" in device.type else False
scaler = amp.GradScaler(enabled=enable_amp)
# mean losses [box_mean_loss, obj_mean_loss, class_mean_loss, total_mean_loss]
mloss = torch.zeros(4).to(device)
now_lr = 0. # 本batch的lr
nb = len(data_loader) # number of batches
# imgs: [batch_size, 3, img_size, img_size]
# targets: [num_obj, 6] , that number 6 means -> (img_index, obj_index, x, y, w, h)
# paths: list of img path
# 这里调用一次datasets.__len__; batch_size次datasets.__getitem__; 再执行1次datasets.collate_fn
for i, (imgs, targets, paths, _, _) in enumerate(metric_logger.log_every(data_loader, print_freq, header)):
# ni 统计从epoch0开始的所有batch数
ni = i + nb * epoch # number integrated batches (since train start)
# imgs: [4, 3, 736, 736]一个batch的图片
# targets(真实框): [22, 6] 22: num_object 6: batch中第几张图(0,1,2,3),类别,x,y,w,h
imgs = imgs.to(device).float() / 255.0 # 对imgs进行归一化 uint8 to float32, 0 - 255 to 0.0 - 1.0
targets = targets.to(device)
# Multi-Scale
if multi_scale:
# 每训练accumulate个batch(batch_size*accumulate张图片),就随机修改一次输入图片大小
# 由于label已转为相对坐标,故缩放图片不影响label的值
if ni % accumulate == 0: # adjust img_size (67% - 150%) every 1 batch
# 在给定最大最小输入尺寸范围内随机选取一个size(size为32的整数倍)
img_size = random.randrange(grid_min, grid_max + 1) * gs # img_size = 320~736
sf = img_size / max(imgs.shape[2:]) # scale factor
# 如果图片最大边长不等于img_size, 则缩放一个batch图片,并将长和宽调整到32的整数倍
if sf != 1:
# gs: (pixels) grid size
ns = [math.ceil(x * sf / gs) * gs for x in imgs.shape[2:]] # new shape (stretched to 32-multiple)
imgs = F.interpolate(imgs, size=ns, mode='bilinear', align_corners=False)
# 混合精度训练上下文管理器,如果在CPU环境中不起任何作用
with amp.autocast(enabled=enable_amp):
# pred: tensor格式 list列表 存放三个tensor 对应的是三个yolo层的输出
# 例如[batch_size, 3, 23, 23, 25] [batch_size, 3, 46, 46, 25] [batch_size, 3, 96, 96, 25]
# [batch_size, anchor_num, grid_h, grid_w, xywh + obj + classes]
# 可以看出来这里的预测值是三个yolo层每个grid_cell(每个grid_cell有三个预测值)的预测值,后面肯定要进行正样本筛选
pred = model(imgs)
# dict格式 存放三分tensor 每个tensor对应一个loss的键值对
# loss的顺序(键)为: 'box_loss', 'obj_loss', 'class_loss'
# targets(数据增强后的真实框): [21, 6] 21: num_object 6: batch中第几张图(0,1,2,3)+类别+x+y+w+h
loss_dict = compute_loss(pred, targets, model)
losses = sum(loss for loss in loss_dict.values()) # 三个相加
# reduce losses over all GPUs for logging purpose
loss_dict_reduced = utils.reduce_dict(loss_dict)
losses_reduced = sum(loss for loss in loss_dict_reduced.values())
loss_items = torch.cat((loss_dict_reduced["box_loss"],
loss_dict_reduced["obj_loss"],
loss_dict_reduced["class_loss"],
losses_reduced)).detach()
mloss = (mloss * i + loss_items) / (i + 1) # update mean losses
# 如果losses_reduced无效,则输出对应图片信息
if not torch.isfinite(losses_reduced):
print('WARNING: non-finite loss, ending training ', loss_dict_reduced)
print("training image path: {}".format(",".join(paths)))
sys.exit(1)
losses *= 1. / accumulate # scale loss
# 1、backward 反向传播 scale loss 先将梯度放大 防止梯度消失
scaler.scale(losses).backward()
# optimize
# 每训练accumulate*batch_size张图片更新一次权重
if ni % accumulate == 0:
# 2、scaler.step() 首先把梯度的值unscale回来.
# 如果梯度的值不是 infs 或者 NaNs, 那么调用optimizer.step()来更新权重,
# 否则,忽略step调用,从而保证权重不更新(不被破坏)
scaler.step(optimizer)
# 3、准备着,看是否要增大scaler 不一定更新 看scaler.step(optimizer)的结果,需要更新就更新
scaler.update()
# 正常更新权重
optimizer.zero_grad()
metric_logger.update(loss=losses_reduced, **loss_dict_reduced)
now_lr = optimizer.param_groups[0]["lr"]
metric_logger.update(lr=now_lr)
# 每训练accumulate*batch_size张图片更新一次学习率(只在第一个epoch) warmup=True 才会执行
if ni % accumulate == 0 and lr_scheduler is not None:
lr_scheduler.step()
return mloss, now_lr
1.2 简要分析
在yolov3spp的训练过程中,其重点只是利用网络的预测的三层特征层,来进行一个正负样本的匹配与损失计算,并结算的损失是根据中心点xy预测的偏移量已经以指数的形式来预测匹配到的anchor与targe的偏移量。
其中所使用到的iou计算不一样,对于边界框偏移量来说,正常使用iou/giou/diou/ciou来进行计算,但是对于正负样本匹配部分来说,选择那一个anchor与对应的target来进行匹配,是根据wh_iou,也就是重叠率来简单的进行计算的。也就是说,只需要根据两个预测框与目标框的宽高,而不需要知道其具体的位置信息来进行一个在大小上的匹配。
所以,整个训练过程是根据匹配好的anchor来进行拟合偏移量,这没有设置到非极大值抑制处理,也没有使用到coco的评价指标,只是计算了平均损失与当前的一个学习率。
2. 验证过程
2.1 参考代码
代码如下:
@torch.no_grad()
def evaluate(model, data_loader, coco=None, device=None):
"""
Args:
coco: coco api
Returns:
"""
n_threads = torch.get_num_threads() # 8线程
# FIXME remove this and make paste_masks_in_image run on the GPU
torch.set_num_threads(1)
cpu_device = torch.device("cpu")
model.eval()
metric_logger = utils.MetricLogger(delimiter=" ")
header = "Test: "
if coco is None:
coco = get_coco_api_from_dataset(data_loader.dataset)
iou_types = _get_iou_types(model) # ['bbox']
coco_evaluator = CocoEvaluator(coco, iou_types)
# 这里调用一次datasets.__len__; batch_size次datasets.__getitem__; 再执行1次datasets.collate_fn
# 调用__len__将dataset分为batch个批次; 再调用__getitem__取出(增强后)当前批次的每一张图片(batch_size张);
# 最后调用collate_fn函数将当前整个批次的batch_size张图片(增强过的)打包成一个batch, 方便送入网络进行前向传播
# img_index:对于的是哪一张图像的索引
# paths:图像的路径
# imgs:一个batch的图像本身
# targets:一个列表,存储着每张图像的边界框信息
for imgs, targets, paths, shapes, img_index in metric_logger.log_every(data_loader, 1, header):
imgs = imgs.to(device).float() / 255.0 # uint8 to float32, 0 - 255 to 0.0 - 1.0
# targets = targets.to(device)
# 当使用CPU时,跳过GPU相关指令
if device != torch.device("cpu"):
torch.cuda.synchronize(device)
model_time = time.time()
pred = model(imgs)[0] # only get inference result [4, 5040, 25]
# [4, 5040, 25] => len=4 [57,6], [5,6], [14,6], [1,6] 6: batch中第几张图(0,1,2,3),类别,x,y,w,h
pred = non_max_suppression(pred, conf_thres=0.01, nms_thres=0.6, multi_cls=False)
outputs = []
for index, p in enumerate(pred):
if p is None:
p = torch.empty((0, 6), device=cpu_device)
boxes = torch.empty((0, 4), device=cpu_device)
else:
# xmin, ymin, xmax, ymax
boxes = p[:, :4]
# shapes: (h0, w0), ((h / h0, w / w0), pad)
# 将boxes信息还原回原图尺度,这样计算的mAP才是准确的
boxes = scale_coords(imgs[index].shape[1:], boxes, shapes[index][0]).round()
# 注意这里传入的boxes格式必须是xmin, ymin, xmax, ymax,且为绝对坐标
info = {"boxes": boxes.to(cpu_device),
"labels": p[:, 5].to(device=cpu_device, dtype=torch.int64),
"scores": p[:, 4].to(cpu_device)}
outputs.append(info)
model_time = time.time() - model_time
# 对每一张图片的信息进行打包出来,信息包括:边界框坐标、类别。置信度
res = {img_id: output for img_id, output in zip(img_index, outputs)}
evaluator_time = time.time()
coco_evaluator.update(res)
evaluator_time = time.time() - evaluator_time
metric_logger.update(model_time=model_time, evaluator_time=evaluator_time)
# gather the stats from all processes
metric_logger.synchronize_between_processes()
print("Averaged stats:", metric_logger)
coco_evaluator.synchronize_between_processes()
# accumulate predictions from all images
coco_evaluator.accumulate()
coco_evaluator.summarize()
torch.set_num_threads(n_threads)
result_info = coco_evaluator.coco_eval[iou_types[0]].stats.tolist() # numpy to list
return result_info
def _get_iou_types(model):
model_without_ddp = model
if isinstance(model, torch.nn.parallel.DistributedDataParallel):
model_without_ddp = model.module
iou_types = ["bbox"]
return iou_types
2.2 简要分析
在验证过程中,最主要的不同是需要看看训练完后对当前这个验证集的效果,这个是验证过程的核心环节,可以用来检验训练效果以及挑选最后的一个训练权重。
这一部分其实和测试部分有点相像,对于训练好的模型输出的一个预测结果,需要经过非极大值抑制处理,来进行一步步的筛选出比较符合的预测框。其中的重点就是需要非极大值抑制处理,也就是所谓的nms后处理方法。根据预测结果获取后处理后的boxes、labels、scores(置信度),然后对于每一张图像就可以得到他的预测边界框信息,预测类别信息,预测的置信度分数三个类别,够成一个字典,形式为:{img_id: img_info}。
然后使用CocoEvaluator来对刚刚所构建的图像-预测结果字典来作一个评价:coco_evaluator.update(res),随机可以调用coco_evaluator类中的相关函数获取对于验证集的预测结果。
调用coco_evaluator的简要流程为:
coco = get_coco_api_from_dataset(data_loader.dataset)
coco_evaluator = CocoEvaluator(coco, iou_types)
...
res = {img_id: output for img_id, output in zip(img_index, outputs)} # 构建图像-预测结果字典
coco_evaluator.update(res)
coco_evaluator.synchronize_between_processes()
coco_evaluator.accumulate()
coco_evaluator.summarize()
result_info = coco_evaluator.coco_eval[iou_types[0]].stats.tolist() # 返回最后的评价结果
对于这一部分,之后会开设一个目标检测的技巧(trick)来详细介绍其使用方法。
3. 测试过程
3.1 参考代码
代码如下:
import os
import json
import time
import torch
import cv2
import argparse
import numpy as np
from matplotlib import pyplot as plt
from build_utils import datasets
from modules.model import DarkNet
from train_val_utils.draw_box_utils import draw_box
from train_val_utils.other_utils import time_synchronized, check_file
from train_val_utils.post_processing_utils import non_max_suppression, scale_coords
def main(opt):
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print("Using {} device training.".format(device.type))
# 1、载入opt参数
cfg = opt.cfg # yolo网络配置文件path
weights = opt.weights # 训练权重path
json_path = opt.json_path # voc classes json path
img_path = opt.img_path # 预测图片地址
img_size = opt.img_size # 预测图像大小(letterbox后)
# 2、载入json文件 得到所有class
json_file = open(json_path, 'r')
class_dict = json.load(json_file)
category_index = {v: k for k, v in class_dict.items()}
# 3、初始化模型 模型载入权重
model = DarkNet(cfg)
model.load_state_dict(torch.load(weights, map_location=device)["model"], strict=False)
model.to(device)
# eval测试模式
model.eval()
with torch.no_grad():
# 载入原图 img_o (375, 500, 3) H W C
img_o = cv2.imread(img_path) # BGR numpy格式
assert img_o is not None, "Image Not Found " + img_path
# letterbox numpy格式(array) img:(384, 512, 3) H W C
# 将原图最长边缩放到指定大小,再将原图较短边按原图比例缩放,最后将较短边两边pad操作缩放到最长边大小(不会失真)
img = datasets.letterbox(img_o, new_shape=img_size, auto=True, color=(0, 0, 0))[0]
# Convert (384, 512, 3) => (384, 512, 3) => (3, 384, 512)
# img[:, :, ::-1] BGR to RGB => transpose(2, 0, 1) HWC(384, 512, 3) to CHW(3, 384, 512)
img = img[:, :, ::-1].transpose(2, 0, 1)
img = np.ascontiguousarray(img) # 使内存是连续的
# numpy(3, 384, 512) CHW => torch.tensor [3, 384, 512] CHW
img = torch.from_numpy(img).to(device).float()
img /= 255.0 # 归一化scale (0, 255) to (0, 1)
# [3, 384, 512] CHW => [1, 3, 384, 512] BCHW
img = img.unsqueeze(0) # add batch dimension
# start inference
t1 = time_synchronized() # 获取当前时间 其实可以用time.time()
# 推理阶段实际上会有两个返回值 x(相对原图的), p
# x: predictor数据处理后的输出(数值是相对原图的,这里是img)
# [batch_size, anchor_num * grid * grid, xywh + obj + classes]
# 这里pred[1,12096,25] (实际上是等于x)表示这张图片总共生成了12096个anchor(一个grid中三个anchor)
# p: predictor原始输出即数据是相对feature map的
# [batch_size, anchor_num, grid, grid, xywh + obj + classes]
pred = model(img)[0] # only get inference result
t2 = time_synchronized()
print("model inference time:", t2 - t1)
# nms pred=[7,6]=[obj_num, xyxy+score+cls] 这里的xyxy是相对img的
# pred: 按score从大到小排列; output[0]=第一张图片的预测结果 不一定一次只传入一张图片的
pred = non_max_suppression(pred)[0]
t3 = time.time()
print("nms time:", t3 - t2)
if pred is None:
print("No target detected.")
exit(0)
# 将nms后的预测结果pred tensor格式(是相对img上的)img.shape=[B,C,H,W]
# 映射到原图img_o上 img_o.shape=[H, W, C] pred=(anchor_nums, xyxy+score+class)
pred[:, :4] = scale_coords(img.shape[2:], pred[:, :4], img_o.shape).round()
print("pred shape:", pred.shape)
# tensor.detach()截断tensor变量反向传播的梯度流,因为是预测所以不需要计算梯度信息
# bboxes、scores、classes: 按score从大到小排列 tensor=>numpy
bboxes = pred[:, :4].detach().cpu().numpy() # xyxys
scores = pred[:, 4].detach().cpu().numpy() # scores
classes = pred[:, 5].detach().cpu().numpy().astype(int) + 1 # classes
# 到这一步,我们就得到了最终的相对原图的所有预测信息bboxes(位置信息)(7,4); scores(7); classes(类别)(7)
# 画出每个预测结果
img_o = draw_box(img_o[:, :, ::-1], bboxes, classes, scores, category_index)
# 显示预测图片
plt.imshow(img_o)
plt.show()
# 保存预测后的图片
img_o.save("outputs/predict_result.jpg")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument('--cfg', type=str, default='cfg/yolov3-spp.cfg', help="cfg/*.cfg path")
parser.add_argument('--weights', type=str, default='weights/yolov3spp-voc-512.pt',
help='pretrain weights path')
parser.add_argument('--json-path', type=str, default='data/pascal_voc_classes.json',
help="voc_classes_json_path")
parser.add_argument('--img-path', type=str, default='imgs/2008_000011.jpg',
help="predict img path")
parser.add_argument('--img-size', type=int, default=512,
help="predict img path [416, 512, 608] 32的倍数")
opt = parser.parse_args()
# 检查文件是否存在
opt.cfg = check_file(opt.cfg)
opt.data = check_file(opt.weights)
opt.hyp = check_file(opt.json_path)
opt.hyp = check_file(opt.img_path)
print(opt)
main(opt)
3.2 简要分析
对于测试来说,最重要的就是根据训练好的权重来对图像进行预测,与验证类似,关键是对预测结果作一个非极大值抑制处理,也就是一个后处理方法,然后甚至不需要对其作一个评价指标,因为没有label。
所以,在测试来说,最后就是根据后处理完的预测结果来直接的进行一个可视化展示,就是对特征层结果来进行一个缩放处理,缩放到原图的原大小,以及对预测的边界框与类别信息在原图上画出来。
总结:
大致看完了整个yolov3spp项目,收获良多,知道了主要的处理细节。对于训练过程,重点就是正负样本的匹配问题了,也就是标签的分配,其是不需要进行一个后处理方法的;而对于验证过程,需要对预测结果进行nms然后使用coco评价指标来进行测试,这是个工具知道如何掉包就好;而对于测试结果,由于测试是没有标签信息的,所以直接将nms后处理后的预测结果直接在原图上进行一个可视化展示即可。
并且,为了加快验证与测试,验证过程与测试过程都会使用Rectangular inference来加快推理。Rectangular inference属于是目标检测中常见的一个数据处理的技巧了,作用就是加快推理速度与验证速度。
除了Rectangular inference之外,yolov3spp中还涉及大量的技巧,这里我并没有对其过多的介绍,只是记录了yolov3spp最本质的算法核心,就是如何进行正负样本匹配以及其损失是如何计算,如何进行训练与验证的。
对于其他设计的训练与测试技巧,之后会放在其他专栏来学习与记录。
参考资料:
- https://blog.csdn.net/qq_38253797/article/details/118046587
- https://blog.csdn.net/qq_38253797/article/details/117920079