YOLOv5-4.0-test.py 源代码导读(模型测试)

YOLOv5介绍

YOLOv5为兼顾速度与性能的目标检测算法。笔者将在近期更新一系列YOLOv5的代码导读博客。YOLOv5为2021.1.5日发布的4.0版本。
YOLOv5开源项目github网址
源代码导读汇总网址
本博客导读的代码为主文件夹下的test.py取自11月份版本

test.py

以下为该文件必须导入的模块,其中部分文件来源于其他项目子文件。utils文件中其他子文件笔者会在后续逐渐更新。 (以下代码均保留原始缩进以方便阅读)

import argparse #解析命令行参数的库
import json     #实现字典列表和JSON字符串之间的相互解析
import os       #与操作系统进行交互的文件库 包含文件路径操作与解析
from pathlib import Path  #Path能够更加方便得对字符串路径进行处理
from threading import Thread #python中处理多线程的库

import numpy as np    #矩阵计算基础库
import torch          #pytorch 深度学习库
import yaml           #yaml是一种表达高级结构的语言 易读 便于指定模型架构及运行配置
from tqdm import tqdm #用于直观显示进度条的一个库 看起来很舒服

from models.experimental import attempt_load #调用models文件夹中的experimental.py文件中的attempt_load函数 目的是加载模型

#以下调用均为utils文件夹中各种已写好的函数
from utils.datasets import create_dataloader
from utils.general import coco80_to_coco91_class, check_dataset, check_file, check_img_size, box_iou, \
    non_max_suppression, scale_coords, xyxy2xywh, xywh2xyxy, set_logging, increment_path
from utils.loss import compute_loss
from utils.metrics import ap_per_class, ConfusionMatrix
from utils.plots import plot_images, output_to_target, plot_study_txt
from utils.torch_utils import select_device, time_synchronized

测试函数输入变量、加载模型和确定运行设备部分。

#测试函数 输入为测试过程中需要的各种参数
def test(data,
         weights=None,
         batch_size=32,
         imgsz=640,
         conf_thres=0.001,
         iou_thres=0.6,  # 为NMS设置的iou阈值
         save_json=False,
         single_cls=False,
         augment=False,
         verbose=False,
         model=None,
         dataloader=None,
         save_dir=Path(''),  # 存储测试图片的路径
         save_txt=False,  # 自动实现对测试图片的标注
         save_conf=False, #保存置信度
         plots=True,
         log_imgs=0):  # 已记录图片的数量

    # 初始化/加载模型 并设置设备
    training = model is not None #有模型则 training 为True
    if training:  # 调用train.py
        device = next(model.parameters()).device  # 获得记录在模型中的设备 next为迭代器

    else:  
        set_logging() #调用general.py文件中的函数 设置日志 opt对象main中解析传入变量的对象
        device = select_device(opt.device, batch_size=batch_size) #调用torch_utils中select_device来选择执行程序时的设备
        save_txt = opt.save_txt  # 获取保存测试之后的label文件路径 格式为txt

        # 路径
        # 调用genera.py中的increment_path函数来设置保存文件的路径
        save_dir = Path(increment_path(Path(opt.project) / opt.name, exist_ok=opt.exist_ok))
        # mkdir创建路径最后一级目录  
        (save_dir / 'labels' if save_txt else save_dir).mkdir(parents=True, exist_ok=True)  

        # 加载模型
        # 加载模型为32位浮点数模型(权重参数) 调用experimental.py文件中的attempt_load函数
        model = attempt_load(weights, map_location=device)
        # 调用general.py中的check_img_size函数来检查图像分辨率能否被32整除  
        imgsz = check_img_size(imgsz, s=model.stride.max())  

确定精度是否减半、加载数据集yaml配置文件、初始化日志部分。

    # 精度减半
    #如果设备类型不是cpu 则将模型由32位浮点数转换为16位浮点数
    half = device.type != 'cpu'  
    if half:
        model.half()

    # 加载配置
    model.eval() #将模型转换为测试模式 固定住dropout层和Batch Normalization层
    is_coco = data.endswith('coco.yaml')  # 判断输入的数据yaml文件是否是coco.yaml文件
    with open(data) as f: #打开data(yaml格式)文件
        data = yaml.load(f, Loader=yaml.FullLoader)  # 获取模型配置的字典格式文件
    check_dataset(data)  # 调用general.py中的check_dataset函数来检查数据文件是否正常
    nc = 1 if single_cls else int(data['nc'])  # 确定检测的类别数目
    iouv = torch.linspace(0.5, 0.95, 10).to(device)  # [email protected]:0.95 的iou向量
    niou = iouv.numel() #numel为pytorch预置函数 用来获取张量中的元素个数

    # 日志
    log_imgs, wandb = min(log_imgs, 100), None  # ceil
    try:
        import wandb  # 权重和偏置 wandb为可视化权重和各种指标的库
    except ImportError:
        log_imgs = 0

获取dataloader、初始化模型测试当中用到的相应参数。

    # Dataloader
    if not training:
        img = torch.zeros((1, 3, imgsz, imgsz), device=device)  #创建一张全为0的图片(四维张量)
        # 利用空图片对模型进行测试 (只在运行设备不是cpu时进行)
        _ = model(img.half() if half else img) if device.type != 'cpu' else None  
        path = data['test'] if opt.task == 'test' else data['val']  #如果任务为test 则获得yaml文件中测试的路径
        #调用datasets.py文件中的create_dataloader函数创建dataloader
        dataloader = create_dataloader(path, imgsz, batch_size, model.stride.max(), opt, pad=0.5, rect=True)[0]

    seen = 0 #初始化已完成测试的图片数量
    confusion_matrix = ConfusionMatrix(nc=nc) #调用matrics中函数 存储混淆矩阵
    #获取模型训练中存储的类别名字数据
    names = {
     k: v for k, v in enumerate(model.names if hasattr(model, 'names') else model.module.names)}
    coco91class = coco80_to_coco91_class()#调用general.py中的函数 来转换coco的类
    #为后续设置基于tqdm的进度条作基础
    s = ('%20s' + '%12s' * 6) % ('Class', 'Images', 'Targets', 'P', 'R', '[email protected]', '[email protected]:.95')
    #初始化detection中各个指标的值 t0和t1为时间
    p, r, f1, mp, mr, map50, map, t0, t1 = 0., 0., 0., 0., 0., 0., 0., 0., 0.
    #初始化网络训练的loss
    loss = torch.zeros(3, device=device)
    #初始化json文件涉及到的字典、统计信息、AP、每一个类别的AP、图片汇总
    jdict, stats, ap, ap_class, wandb_images = [], [], [], [], []

开始循环、获取dataloader当中的batch、图片、目标、路径、形状信息等。以下为循环头。

    for batch_i, (img, targets, paths, shapes) in enumerate(tqdm(dataloader, desc=s)):
        #将图片数据拷贝到device(GPU)上面
        img = img.to(device, non_blocking=True)
        #将图片从64位精度转换为32位精度
        img = img.half() if half else img.float()
        #将图像像素值0-255的范围归一化到0-1的范围
        img /= 255.0
        #对targets也做同样拷贝的操作
        targets = targets.to(device)
        #四个变量分别代表batchsize、通道数目、图像高度、图像宽度
        nb, _, height, width = img.shape  

初始化梯度分布,利用模型进行测试并计算loss,应用NMS。

        with torch.no_grad(): #强制之后进行的过程不生成计算图 开始运行模型
            
            t = time_synchronized() #调用torch_utils中的函数 开始计时
            inf_out, train_out = model(img, augment=augment)  # 输入图片进行模型推断 返回推断结果及训练结果
            t0 += time_synchronized() - t #t0 为累计的各个推断所用时间

            # 计算损失
            if training: #如果在训练时进行test
                #loss 包含bounding box 回归的GIoU、object和class 三者的损失
                loss += compute_loss([x.float() for x in train_out], targets, model)[1][:3]  

            # 运行NMS 目标检测的后处理模块 用于删除冗余的bounding box
            targets[:, 2:] *= torch.Tensor([width, height, width, height]).to(device)  # 对targets进行处理并拷贝到GPU
            #提取bach中每一张图片的目标的label 
            lb = [targets[targets[:, 0] == i, 1:] for i in range(nb)] if save_txt else []  
            
            t = time_synchronized() #计算NMS过程所需要的时间
            #调用general.py中的函数 进行非极大值抑制操作
            output = non_max_suppression(inf_out, conf_thres=conf_thres, iou_thres=iou_thres, labels=lb)
            t1 += time_synchronized() - t

统计每一张图片的相关信息

        # si代表第si张图片,pred是对应图片预测的label信息
        for si, pred in enumerate(output):
            labels = targets[targets[:, 0] == si, 1:] #调取第si张图片的label信息
            nl = len(labels) #nl为图片检测到的目标个数
            tcls = labels[:, 0].tolist() if nl else []  # 检测到的目标的类别 label矩阵的第一列
            path = Path(paths[si]) # 找到第si张照片对应的文件路径
            seen += 1 #处理的图片增加1

            if len(pred) == 0: # 如果没有预测到目标则
                if nl:         # 同时有label信息
                    #stats初始化为一个空列表[] 此处添加一个空信息
                    #添加的每一个元素均为tuple 其中第二第三个变量为一个空的tensor
                    stats.append((torch.zeros(0, niou, dtype=torch.bool), torch.Tensor(), torch.Tensor(), tcls))
                continue

利用得到的上述信息进行目标的预测,并将结果保存同时输出日志,将结果保存在json dict当中。

            # 预测
            predn = pred.clone() #对pred进行深复制
            #调用general.py中的函数 将图片调整为原图大小
            scale_coords(img[si].shape[1:], predn[:, :4], shapes[si][0], shapes[si][1])  

            # 将结果添加到文本文档里面
            if save_txt:
                # shapes具体变量设置应看dataloader 此处应为提取长和宽并构建新tensor gn
                # 对torch.tensor()[[1,0]]操作可构建一个新tensor其中第一行为内层列表中第一个索引对应的行
                gn = torch.tensor(shapes[si][0])[[1, 0, 1, 0]]  # normalization gain whwh
                for *xyxy, conf, cls in predn.tolist():
                    """
                    将xyxy格式的坐标转换成xywh坐标 调用general.py中的函数
                    xyxy格式为记录bounding box 的左上角和右下角坐标
                    xywh格式为记录中心点坐标和bounding box的宽和高
                    """
                    xywh = (xyxy2xywh(torch.tensor(xyxy).view(1, 4)) / gn).view(-1).tolist()
                    # line为按照YOLO格式输出的测试结果 [类别 x y w h]
                    line = (cls, *xywh, conf) if save_conf else (cls, *xywh)
                    # 将上述test得到的信息输出保存 输出为xywh格式 coco数据格式也为xywh格式
                    with open(save_dir / 'labels' / (path.stem + '.txt'), 'a') as f:
                        f.write(('%g ' * len(line)).rstrip() % line + '\n')

            # 记录图片测试结果相关信息 并 存储在日志当中
            if plots and len(wandb_images) < log_imgs:
                # 一个 包含嵌套字典的列表的数据结构 存储 一个box对应的数据信息
                box_data = [{
     "position": {
     "minX": xyxy[0], "minY": xyxy[1], "maxX": xyxy[2], "maxY": xyxy[3]},
                             "class_id": int(cls),
                             "box_caption": "%s %.3f" % (names[cls], conf),
                             "scores": {
     "class_score": conf},
                             "domain": "pixel"} for *xyxy, conf, cls in pred.tolist()]
                boxes = {
     "predictions": {
     "box_data": box_data, "class_labels": names}}  # inference-space
                # 记录每一张图片 每一个box的相关信息 wandb_images 初始化为一个空列表
                wandb_images.append(wandb.Image(img[si], boxes=boxes, caption=path.name))

            # 将信息添加到JSON字典当中
            if save_json:
                # 储存的格式 [{"image_id": 42, "category_id": 18, "bbox": [258.15, 41.29, 348.26, 243.78], "score": 0.236}, ...
                # 记录的信息有id box xy to top-left 得分等 如下所示
                image_id = int(path.stem) if path.stem.isnumeric() else path.stem
                box = xyxy2xywh(predn[:, :4])  # xywh
                box[:, :2] -= box[:, 2:] / 2  # xy center to top-left corner
                for p, b in zip(pred.tolist(), box.tolist()):
                    jdict.append({
     'image_id': image_id,
                                  'category_id': coco91class[int(p[5])] if is_coco else int(p[5]),
                                  'bbox': [round(x, 3) for x in b],
                                  'score': round(p[4], 5)})

对每一个目标类寻找相应的结果,以得到计算P、R、mAP的基础数据。

            # Assign all predictions as incorrect
            # 初始化时将所有的预测都当做错误
            # niou为iou阈值的个数
            correct = torch.zeros(pred.shape[0], niou, dtype=torch.bool, device=device)
            if nl: #当检测到的个数不为0时 nl为图片检测到的目标个数
                detected = []  # 目标相关指标
                tcls_tensor = labels[:, 0] # 获得类别的tensor

                # 目标的boundingbox
                tbox = xywh2xyxy(labels[:, 1:5]) #xywh格式转换为xyxy格式
                # 按照原本图片的缩放标准 对bounding box 进行缩放
                scale_coords(img[si].shape[1:], tbox, shapes[si][0], shapes[si][1])  # native-space labels
                if plots: #这里是绘制混淆矩阵 见metrics.py 代码解读
                    confusion_matrix.process_batch(pred, torch.cat((labels[:, 0:1], tbox), 1))

                # 对于每一个目标类
                for cls in torch.unique(tcls_tensor):
                    # numpy.nonzero()函数为获得非零元素的所以 返回值为长为numpy.dim长度的tuple
                    # 因此ti为标签box对应的索引 ti = test indices
                    ti = (cls == tcls_tensor).nonzero(as_tuple=False).view(-1)  
                    # 因此pi为预测box对应的索引 pi = prediction indices
                    pi = (cls == pred[:, 5]).nonzero(as_tuple=False).view(-1)  # target indices

                    #  对于每个单独的类别寻找检测结果
                    if pi.shape[0]:
                        # Prediction to target ious
                        # 调用general.py中的box_iou函数 返回最大的iou对应对象及相关指标
                        ious, i = box_iou(predn[pi, :4], tbox[ti]).max(1)  # best ious, indices

                        # 将检测到目标统一添加到 detected_set集合当中
                        detected_set = set()
                        for j in (ious > iouv[0]).nonzero(as_tuple=False):
                            d = ti[i[j]]  # 检测到的目标
                            if d.item() not in detected_set:
                                #将不在检测集合中的目标添加到集合里面
                                detected_set.add(d.item())
                                detected.append(d)
                                # 只有iou大于阈值的才会被认为是正确的目标
                                correct[pi[j]] = ious[j] > iouv  # iou_thres 是一个 1xn 的向量
                                if len(detected) == nl:  # 图像中所有的目标都已经被处理完
                                    break

            # 向stats(list)中添加统计指标 格式为:(correct, conf, pcls, tcls)
            stats.append((correct.cpu(), pred[:, 4].cpu(), pred[:, 5].cpu(), tcls))

创建子进程进行绘图。

        # Plot images
        #画出前三个图片的ground truth和 对应的预测框 Q:当测试时并没有ground truth支持时该如何处理?
        if plots and batch_i < 3:
            f = save_dir / f'test_batch{batch_i}_labels.jpg'  # labels
            #Thread()函数为创建一个新的线程来执行这个函数 函数为plots.py中的plot_images函数
            Thread(target=plot_images, args=(img, targets, paths, f, names), daemon=True).start()
            f = save_dir / f'test_batch{batch_i}_pred.jpg'  # predictions
            Thread(target=plot_images, args=(img, output_to_target(output), paths, f, names), daemon=True).start()

计算评判分类效果的各种指标

    # 计算上述测试过程中的各种性能指标
    stats = [np.concatenate(x, 0) for x in zip(*stats)]  # 转换为对应格式numpy
    #以下性能指标的计算方法及原理 可移步笔者 metrics.py源代码解读博客
    if len(stats) and stats[0].any():
        p, r, ap, f1, ap_class = ap_per_class(*stats, plot=plots, save_dir=save_dir, names=names)
        p, r, ap50, ap = p[:, 0], r[:, 0], ap[:, 0], ap.mean(1)  # [P, R, [email protected], [email protected]:0.95]
        mp, mr, map50, map = p.mean(), r.mean(), ap50.mean(), ap.mean()
        nt = np.bincount(stats[3].astype(np.int64), minlength=nc)  # number of targets per class
    else:
        nt = torch.zeros(1)

输出测试指标、输出每个过程的时间消耗,绘制混淆矩阵。

    # 按照以下格式来打印测试过程的指标
    pf = '%20s' + '%12.3g' * 6  # print format
    print(pf % ('all', seen, nt.sum(), mp, mr, map50, map))

    # 打印每一个类别对应的性能指标
    if verbose and nc > 1 and len(stats):
        for i, c in enumerate(ap_class):
            print(pf % (names[c], seen, nt[c], p[i], r[i], ap50[i], ap[i]))

    # 打印 推断/NMS过程/总过程 的在每一个batch上面的时间消耗
    t = tuple(x / seen * 1E3 for x in (t0, t1, t0 + t1)) + (imgsz, imgsz, batch_size)  # tuple
    if not training:
        print('Speed: %.1f/%.1f/%.1f ms inference/NMS/total per %gx%g image at batch-size %g' % t)

    # 绘图 绘制混淆矩阵 wandb日志文件中也对测试图片进行可视化(具体可视化样子目前暂不清)
    if plots:
        confusion_matrix.plot(save_dir=save_dir, names=list(names.values()))
        if wandb and wandb.run:
            wandb.log({
     "Images": wandb_images})
            wandb.log({
     "Validation": [wandb.Image(str(f), caption=f.name) for f in sorted(save_dir.glob('test*.jpg'))]})

利用cocoapi进行相关性能指标的评估并返回对应的测试结果。

    # 保存之前json格式的预测结果,并利用coco的api进行评估
    # 因为COCO测试集的标签是给出的 因此此评估过程结合了测试集标签
    # 在更多的目标检测场合下 为保证公正测试集标签不会给出 因此以下过程应进行修改
    if save_json and len(jdict):
        w = Path(weights[0] if isinstance(weights, list) else weights).stem if weights is not None else ''  # weights
        anno_json = '../coco/annotations/instances_val2017.json'  # 注释的json格式
        pred_json = str(save_dir / f"{w}_predictions.json")  # 预测的json格式
        print('\nEvaluating pycocotools mAP... saving %s...' % pred_json)
        with open(pred_json, 'w') as f:
            json.dump(jdict, f)

        try:  # https://github.com/cocodataset/cocoapi/blob/master/PythonAPI/pycocoEvalDemo.ipynb
            # 以下过程为利用官方coco工具进行结果的评测
            from pycocotools.coco import COCO
            from pycocotools.cocoeval import COCOeval

            anno = COCO(anno_json)  # init annotations api
            pred = anno.loadRes(pred_json)  # init predictions api
            eval = COCOeval(anno, pred, 'bbox')
            if is_coco:
                eval.params.imgIds = [int(Path(x).stem) for x in dataloader.dataset.img_files]  # image IDs to evaluate
            eval.evaluate()
            eval.accumulate()
            eval.summarize()
            map, map50 = eval.stats[:2]  # update results ([email protected]:0.95, [email protected])
        except Exception as e:
            print(f'pycocotools unable to run: {e}')

    # 返回结果
    if not training: #如果不是训练过程则将结果保存到对应的路径
        s = f"\n{len(list(save_dir.glob('labels/*.txt')))} labels saved to {save_dir / 'labels'}" if save_txt else ''
        print(f"Results saved to {save_dir}{s}")
    model.float()  # 将模型转换为适用于训练的状态
    maps = np.zeros(nc) + map
    for i, c in enumerate(ap_class):
        maps[c] = ap[i]
    # 返回对应的测试结果
    return (mp, mr, map50, map, *(loss.cpu() / len(dataloader)).tolist()), maps, t

主函数中,设置命令行解析器相关部分。

if __name__ == '__main__': 
    """
    以下为 用解析器解析命令行设置test.py相关的参数
    weights:选择测试时的模型权重文件,默认为yolov5s.pt
    data:选择测试时的data配置yaml格式文件,默认为coco128.yaml
    batch-size:前向传播的batch尺寸, 默认32
    img-size:输入图片分辨率大小, 默认640
    conf-thres:筛选框的时候的置信度阈值, 默认0.001
    iou-thres:进行NMS的时候的IOU阈值, 默认0.6
    save-json:是否按照coco的json格式保存预测框,并且使用cocoapi做评估(需要同样coco的json格式的标签), 默认False
    task:设置测试形式, 默认val。
    device:测试的设备,cpu;0(表示一个gpu设备cuda:0);0,1,2,3
    single-cls:数据集是否只有一个类别
    augment:测试时是否使用TTA(test time augmentation)
    verbose:是否详细记录每一类的mAP指标
    save-txt:是否以txt文件的形式保存模型预测的bounding box
    save-conf:在保存txt文件当中的labels是否包含置信度
    save-json:是否通过cocoapi来保存json结果文件
    project:默认储存测试过程的相对路径,默认为runs/test
    name:默认保存子文件夹的名称,默认为exp,若果exp已经存在会添加递加数字
    exist-ok:已存在的项目/名称可用,不继续增加。
    """
    parser = argparse.ArgumentParser(prog='test.py')
    parser.add_argument('--weights', nargs='+', type=str, default='yolov5s.pt', help='model.pt path(s)')
    parser.add_argument('--data', type=str, default='data/coco128.yaml', help='*.data path')
    parser.add_argument('--batch-size', type=int, default=32, help='size of each image batch')
    parser.add_argument('--img-size', type=int, default=640, help='inference size (pixels)')
    parser.add_argument('--conf-thres', type=float, default=0.001, help='object confidence threshold')
    parser.add_argument('--iou-thres', type=float, default=0.6, help='IOU threshold for NMS')
    parser.add_argument('--task', default='val', help="'val', 'test', 'study'")
    parser.add_argument('--device', default='', help='cuda device, i.e. 0 or 0,1,2,3 or cpu')
    parser.add_argument('--single-cls', action='store_true', help='treat as single-class dataset')
    parser.add_argument('--augment', action='store_true', help='augmented inference')
    parser.add_argument('--verbose', action='store_true', help='report mAP by class')
    parser.add_argument('--save-txt', action='store_true', help='save results to *.txt')
    parser.add_argument('--save-conf', action='store_true', help='save confidences in --save-txt labels')
    parser.add_argument('--save-json', action='store_true', help='save a cocoapi-compatible JSON results file')
    parser.add_argument('--project', default='runs/test', help='save to project/name')
    parser.add_argument('--name', default='exp', help='save to project/name')
    parser.add_argument('--exist-ok', action='store_true', help='existing project/name ok, do not increment')
    opt = parser.parse_args() #解析上述参数
    opt.save_json |= opt.data.endswith('coco.yaml') # |为或 两者中有一个正确即opt.save_json为正确
    opt.data = check_file(opt.data)  # 检查是否存在相关的配置文件
    print(opt) #打印相关的配置

根据不同任务需求,进行测试或研究过程。

    if opt.task in ['val', 'test']:  # 在评估和测试任务中对相关参数进行赋值
        test(opt.data,
             opt.weights,
             opt.batch_size,
             opt.img_size,
             opt.conf_thres,
             opt.iou_thres,
             opt.save_json,
             opt.single_cls,
             opt.augment,
             opt.verbose,
             save_txt=opt.save_txt,
             save_conf=opt.save_conf,
             )

    elif opt.task == 'study':  # 在不同的模型下 进行性能测试
        for weights in ['yolov5s.pt', 'yolov5m.pt', 'yolov5l.pt', 'yolov5x.pt']:
            f = 'study_%s_%s.txt' % (Path(opt.data).stem, Path(weights).stem)  # 保存的文件名
            x = list(range(320, 800, 64))  # x坐标轴
            y = []  # y 坐标
            for i in x:  # img-size
                print('\nRunning %s point %s...' % (f, i))
                r, _, t = test(opt.data, weights, opt.batch_size, i, opt.conf_thres, opt.iou_thres, opt.save_json,
                               plots=False)
                y.append(r + t)  # 返回相关结果和时间
            np.savetxt(f, y, fmt='%10.4g')  # 将y输出保存
        os.system('zip -r study.zip study_*.txt') # 命令行执行命令将study文件进行压缩
        plot_study_txt(f, x)  # 调用plots.py中的函数

因该代码文本量较大,且内含较多的输入输出文件,会在后续看完utils文件夹子文件后对变量进行更加详细的注释。当前版本如有不当之处,欢迎读者批评指正!

你可能感兴趣的:(YOLOv5源代码导读,深度学习,pytorch,神经网络,机器学习,自动驾驶)