YOLOX网络输出三个维度信息:
[1, 85, 80, 80]
[1, 85, 40, 40]
[1, 85, 20, 20]
再进行边框解码

一、网络输出解码

  1. import torch
  2. def decode_outputs(outputs, input_shape):
  3. """
  4. Args:
  5. outputs:列表,里面的元素分别为各个检测头的输出
  6. input_shape:列表或元组,里面的两个元素分别为模型输入图片的高宽,如[640, 640]
  7. Returns:
  8. """
  9. """以下代码的注释,都是假设只有三个检测头,要检测的类别数是80,input_shape为[640, 640]的情况下的结果"""
  10. grids = []
  11. strides = []
  12. hw = [x.shape[-2:] for x in outputs] # 三个检测头输出结果的高宽
  13. outputs = torch.cat([x.flatten(start_dim=2) for x in outputs], dim=2).permute(0, 2, 1)
  14. # [x.flatten(start_dim=2) for x in outputs]每次获得的x都是4个维度,
  15. # 第一个x的维度为torch.Size([batch_size, 85, 80, 80])
  16. # x.flatten(start_dim=2) 表示从2号维度开始展平,打平后的维度为torch.Size([batch_size, 85, 6400])
  17. # 三个输出维度展平为三个张量,维度分别为(batch_size, 85, 6400)、(batch_size, 85, 1600)、(batch_size, 85, 400)
  18. # torch.cat将列表中的三个张量按指定维度(dim=2)拼接进行拼接,得到的张量维度为torch.Size([batch_size, 85, 8400])
  19. # .permute(0, 2, 1)表示调整维度顺序,得到的张量维度为torch.Size([batch_size, 8400, 85])
  20. # 最后的outputs的shape变为torch.Size([batch_size, 8400, 85])
  21. outputs[:, :, 4:] = torch.sigmoid(outputs[:, :, 4:])
  22. # 最后一个维度,前面4个数是中心点坐标和高宽,从第5个数是置信度,后面是各个类别的概率,
  23. # 这里使用sigmoid函数将置信度和各个类别的概率压缩到0-1之间
  24. for h, w in hw:
  25. """循环取出特征层的高和宽,第一次为80,80"""
  26. # 根据特征层生成网格点
  27. grid_y, grid_x = torch.meshgrid([torch.arange(h), torch.arange(w)])
  28. # grid_y和grid_x的维度都是torch.Size([80, 80])
  29. grid = torch.stack((grid_x, grid_y), 2).view(1, -1, 2)
  30. # shape为torch.Size([1, 6400, 2]),最后一个维度是2,为网格点的横纵坐标,而6400表示当前特征层的网格点数量
  31. # torch.stack((grid_x, grid_y), 2)对张量进行扩维拼接,返回的shape为torch.Size([80, 80, 2])
  32. # 关于torch.stack的用法,可以看这篇博客:https://blog.csdn.net/Teeyohuang/article/details/80362756/
  33. shape = grid.shape[:2] # shape为torch.Size([1, 6400])
  34. grids.append(grid)
  35. strides.append(torch.full((shape[0], shape[1], 1), input_shape[0] / h))
  36. # 为每个网格获取步长(对应于先验框的尺寸)
  37. # input_shape[0]/h 获得当前特征图(输出特征图特征图)高h方向的步长,这个步长也是宽w方向上的步长
  38. # 因为输入图片和检测头输出的特征图,在高和宽两个方向上的缩放比例是一样的,所以步长也是一样
  39. # torch.full((shape[0], shape[1], 1), input_shape[0]/h是由步长填充而成的张量
  40. # 将网格点堆叠到一起
  41. grids = torch.cat(grids, dim=1).type(outputs.type()) # torch.cat是让张量按照指定维度拼接,但得到的新张量维度数不会变
  42. # grides的维度为(1, 8400, 2),中间的8400表示8400个特征点
  43. strides = torch.cat(strides, dim=1).type(outputs.type()) # .type(outputs.type())指定张量的类型
  44. # strides的维度为(1, 8400, 1)
  45. # 根据网格点进行解码(乘以步长相当于回到原图的坐标)
  46. outputs[..., :2] = (outputs[..., :2] + grids) * strides # 解码得到中心点的坐标
  47. # 因为outputs[..., :2]是在0-1之间,为相对网格左上角的偏移量
  48. outputs[..., 2:4] = torch.exp(outputs[..., 2:4]) * strides # 解码得到预测框的高宽
  49. # 归一化(相对于图片大小)
  50. outputs[..., [0, 2]] = outputs[..., [0, 2]] / input_shape[1]
  51. outputs[..., [1, 3]] = outputs[..., [1, 3]] / input_shape[0]
  52. # 返回的outputs的维度为(batch_size, 8400, 85)
  53. return outputs

二、非极大值抑制

将解码后的输出进行筛选

  1. def non_max_suppression(prediction, conf_thres=0.5, nms_thres=0.4):
  2. """
  3. 使用置信度过滤和非极大值抑制
  4. Args:
  5. prediction: 模型的预测结果(经过解码后的数据),
  6. 如果要预测80个类别,那么prediction的维度为torch.Size([batch_size, num_anchors, 85])
  7. conf_thres: 置信度阈值
  8. nms_thres: NMS阈值
  9. Returns:一个列表,其元素个数为batch_size,每个元组都是torch张量,对应每张图片经过两轮筛选后的结果,
  10. 如果图片中存在目标,那么对应的元素维度为(num_objs, 7),
  11. 7列的内容分别为:x1, y1, x2, y2, obj_conf, class_conf, class_pred,
  12. 其中坐标为归一化后的数值,如果图片中不存在目标,那么对应的元素为None
  13. """
  14. # 将解码结果的中心点坐标和宽高转换成左上角和右下角的坐标
  15. box_corner = prediction.new(prediction.shape)
  16. box_corner[:, :, 0] = prediction[:, :, 0] - prediction[:, :, 2] / 2
  17. box_corner[:, :, 1] = prediction[:, :, 1] - prediction[:, :, 3] / 2
  18. box_corner[:, :, 2] = prediction[:, :, 0] + prediction[:, :, 2] / 2
  19. box_corner[:, :, 3] = prediction[:, :, 1] + prediction[:, :, 3] / 2
  20. prediction[:, :, :4] = box_corner[:, :, :4]
  21. output = [None for _ in range(len(prediction))] # len(prediction))是batch_size,即图片数量
  22. for image_i, image_pred in enumerate(prediction):
  23. """第一轮过滤"""
  24. # 利用目标置信度(即对应的预测框存在要检测的目标的概率)做第一轮过滤
  25. image_pred = image_pred[image_pred[:, 4] >= conf_thres]
  26. # 如果当前图片中,所有目标的置信度都小于阈值,那么就进行下一轮循环,检测下一张图片
  27. if not image_pred.size(0):
  28. continue
  29. # 目标置信度乘以各个类别的概率,并对结果取最大值,获得各个预测框的score
  30. score = image_pred[:, 4] * image_pred[:, 5:].max(1)[0]
  31. # image_pred[:, 4]是置信度,image_pred[:, 5:].max(1)[0]是各个类别的概率最大值
  32. # 将image_pred中的预测框按score从大到小排序
  33. image_pred = image_pred[(-score).argsort()]
  34. # argsort()是将(-score)中的元素从小到大排序,返回排序后索引
  35. # 将(-score)中的元素从小到大排序,实际上是对score从大到小排序
  36. # 将排序后的索引放入image_pred中作为索引,实际上是对本张图片中预测出来的目标,按score从大到小排序
  37. # 获得第一轮过滤后的各个预测框的类别概率最大值及其索引
  38. class_confs, class_preds = image_pred[:, 5:].max(1, keepdim=True)
  39. # class_confs 类别置信度最大值,class_preds 预测类别在80个类别中的索引
  40. # 将各个目标框的上下角点坐标、目标置信度、类别置信度、类别索引串起来
  41. detections = torch.cat((image_pred[:, :5], class_confs.float(), class_preds.float()), 1)
  42. # 经过上条命令之后,detections的维度为(number_pred, 7)
  43. # 7列的内容分别为:x1, y1, x2, y2, obj_conf, class_conf, class_pred
  44. """第二轮过滤"""
  45. keep_boxes = [] # 用来存储符合要求的目标框
  46. while detections.size(0): # 如果detections中还有目标
  47. """以下标注是执行第一轮循环时的标注,后面几轮以此类推"""
  48. # 获得与第一个box(最大score对应的box)具有高重叠的预测框的布尔索引
  49. from utils.utils import bbox_iou
  50. large_overlap = bbox_iou(detections[0, :4].unsqueeze(0), detections[:, :4]) > nms_thres
  51. # bbox_iou(detections[0, :4].unsqueeze(0), detections[:, :4])返回值的维度为(num_objects, )
  52. # detections[0, :4]代表score最高的边框信息
  53. # bbox_iou的返回值与非极大值抑制的阈值相比较,获得布尔索引
  54. # 即剩下的边框中,与detection[0]的iou大于nms_thres的,才抑制,即认为这些边框与detection[0]检测的是同一个目标
  55. # 获得与第一个box相同类别的预测框的索引
  56. label_match = detections[0, -1] == detections[:, -1]
  57. # 布尔索引,获得所有与detection[0]相同类别的对象的索引
  58. # 获得需要抑制的预测框的布尔索引
  59. invalid = large_overlap & label_match # &是位运算符,两个布尔索引进行位运算
  60. # 经过第一轮筛选后的剩余预测框,如果同时满足和第一个box有高重叠、类别相同这两个条件,那么就该被抑制
  61. # 这些应该被抑制的边框,其对应的索引即为无效索引
  62. # 获得被抑制预测框的置信度
  63. weights = detections[invalid, 4:5]
  64. # 加权获得最后的预测框坐标
  65. detections[0, :4] = (weights * detections[invalid, :4]).sum(0) / weights.sum()
  66. # 上面的命令是将当前边框,和被抑制的边框进行加权,
  67. # 类似于好几个边框都检测到了同一张人脸,将这几个边框的左上角点横坐标x进行加权(按照置信度加权),
  68. # 获得最后边框的x,对左上角点的纵坐标y,以及右下角点的横纵坐标也进行加权处理
  69. # 其他的obj_conf, class_conf, class_pred则使用当前box的
  70. keep_boxes += [detections[0]] # 将第一个box加入到 keep_boxes 中
  71. detections = detections[~invalid] # 去掉无效的预测框,更新detections
  72. if keep_boxes: # 如果keep_boxes不是空列表
  73. output[image_i] = torch.stack(keep_boxes) # 将目标堆叠,然后加入到列表
  74. # 假设NMS之后,第i张图中有num_obj个目标,那么torch.stack(keep_boxes)的结果是就是一个(num_obj, 7)的张量,没有图片索引
  75. # 如果keep_boxes为空列表,那么output[image_i]则未被赋值,保留原来的值(原来的为None)
  76. return output

三、绘制边框

3.1 在letterbox中绘制

  1. def draw_boxes(image, outputs, font_file, class_names, colors_list=[]):
  2. """
  3. 在图片上画框
  4. Args:
  5. image: 要画框的图片,PIL.Image.open的返回值
  6. outputs: 一个列表,NMS后的结果,其中的坐标为归一化后的坐标
  7. font_file:字体文件路径
  8. class_names:类名列表
  9. colors_list:颜色列表
  10. Returns:
  11. """
  12. # 根据图片的宽,动态调整字体大小
  13. font_size = np.floor(3e-2 * image.size[1] + 0.5).astype('int32')
  14. font = ImageFont.truetype(font=font_file, size=font_size) # 创建字体对象,包括字体和字号
  15. draw = ImageDraw.Draw(image) # 将letterbox_img作为画布
  16. for output in outputs: # ouput是每张图片的检测结果,当然这里batch_size为1就是了
  17. if output is not None:
  18. for obj in output: # 一张图片可能有多个目标,obj就是其中之一
  19. """从obj中获得信息"""
  20. box = obj[:4] * 640 # 将归一化后的坐标转化为输入图片(letterbox_img)中的坐标
  21. cls_index = int(obj[6]) # 类别索引
  22. score = obj[4] * obj[5] # score,可以理解为类别置信度
  23. x1, y1, x2, y2 = map(int, box) # 转化为整数
  24. pred_class = class_names[cls_index] # 目标类别名称
  25. color = 'red' # TODO 具体使用时,还得改成colors_list[cls_index]
  26. """组建要显示的文字信息"""
  27. label = ' {} {:.2f}'.format(pred_class, score)
  28. print(label, x1, y1, x2, y2)
  29. """获得文字的尺寸"""
  30. label_size = draw.textsize(label, font)
  31. label = label.encode('utf-8')
  32. """防止文字背景框在上边缘越界"""
  33. if y1 - label_size[1] >= 0:
  34. text_origin = np.array([x1, y1 - label_size[1]])
  35. else:
  36. # 如果越界,则将文字信息写在边框内部
  37. text_origin = np.array([x1, y1 + 1])
  38. """绘制边框"""
  39. thickness = 2 # 边框厚度
  40. for i in range(thickness): # 根据厚度确定循环的执行次数
  41. draw.rectangle([x1 + i, y1 + i, x2 - i, y2 - i], outline=color) # colors[cls_index]
  42. """绘制文字框"""
  43. draw.rectangle([tuple(text_origin), tuple(text_origin + label_size)], fill=color) # 背景
  44. draw.text(text_origin, str(label, 'UTF-8'), fill=(0, 0, 0), font=font) # 文字
  45. del draw
  46. return image
  1. from PIL import Image, ImageFont
  2. import numpy as np
  3. import torch
  4. from nets.yolo import YoloBody
  5. from utils.utils_bbox import decode_outputs, non_max_suppression, draw_boxes
  6. from utils.utils import preprocess_input, resize_image, load_model
  7. """图片导入及预处理"""
  8. file_path = r"C:/Users/mzrs_wjh/Desktop/01/person_and_dog.jpeg" # 图片路径
  9. img = Image.open(file_path) # 打开图片
  10. letterbox_img = resize_image(img, (640, 640), True) # 缩放并进行letterbox转化
  11. # 使用ImageNet的均值和方差对图片进行归一化
  12. img_data = preprocess_input(np.array(letterbox_img, dtype='float32'))
  13. img_data = np.transpose(img_data, (2, 0, 1)) # 调整图片的维度
  14. img_data = np.expand_dims(img_data, 0) # 添加batch_size维度
  15. images = torch.from_numpy(img_data) # 将图片数据转化为torch张量
  16. """模型导入与推理"""
  17. model_path = "model_data/yolox_s.pth" # 模型路径
  18. class_path = "model_data/coco.names" # 类名文件
  19. font_path = "model_data/simhei.ttf" # 字体文件
  20. """获得类名列表"""
  21. with open(class_path, 'r') as f:
  22. class_names = f.read().split("/n")[:-1] # 最后一个去掉,是因最后一个字符是空格
  23. """建立模型对象并导入权重"""
  24. model = YoloBody(80, 's') # 新建模型,'s'表示新建的为yolox_s模型
  25. load_model(model, model_path, 'cpu') # 导入模型权重
  26. with torch.no_grad():
  27. outputs = model(images) # 模型推理
  28. outputs = decode_outputs(outputs, [640, 640]) # 输出解码
  29. outputs = non_max_suppression(outputs) # NMS
  30. letterbox_img = draw_boxes(letterbox_img, outputs, font_file=font_path, class_names=class_names) # 画框
  31. # 这里之所以用letterbox,是因为我们获得的预测框,坐标是在letterbox_ima上的
  32. letterbox_img.show()

3.2 在原图中绘制

  1. def yolo_correct_boxes(outputs, input_shape, image_shape, is_letterbox):
  2. """
  3. Args:
  4. outputs: 一个列表,NMS后的输出,详见non_max_suppression的函数文档
  5. input_shape:输入图片尺寸,列表或数组
  6. image_shape:图片原尺寸,列表或数组
  7. is_letterbox:布尔值,是否对原图进行过letterbox处理
  8. Returns:
  9. """
  10. if is_letterbox:
  11. for output in outputs: # output是单张图片中检测到的目标
  12. if output is not None:
  13. # 输入图像与真实图像比例
  14. scale = np.min(np.array(input_shape) / np.array(image_shape))
  15. # 左右、上下的灰条宽度
  16. offset = (np.array(input_shape)-np.array(image_shape) * scale)/input_shape / 2.
  17. # 将letterbox中的坐标转化为原图像中的坐标(归一化后的坐标)
  18. output[:, 0] = (output[:, 0] - offset[0]) / scale
  19. output[:, 1] = (output[:, 1] - offset[1]) / scale
  20. output[:, 2] = (output[:, 2] - offset[0]) / scale
  21. output[:, 3] = (output[:, 3] - offset[1]) / scale
  1. from PIL import Image, ImageFont
  2. import numpy as np
  3. import torch
  4. from nets.yolo import YoloBody
  5. from utils.utils import cvtColor, preprocess_input, resize_image, load_model
  6. from utils.utils_bbox import decode_outputs, non_max_suppression, draw_boxes, yolo_correct_boxes
  7. """图片导入及预处理"""
  8. file_path = r"C:/Users/mzrs_wjh/Desktop/01/person_and_dog.jpeg" # 图片路径
  9. img = Image.open(file_path) # 打开图片
  10. img = cvtColor(img) # 使用cvtColor,这样即便是灰度图,也能检测
  11. letterbox_img = resize_image(img, (640, 640), True) # 缩放并进行letterbox转化
  12. # 使用ImageNet的均值和方差对图片进行归一化
  13. img_data = preprocess_input(np.array(letterbox_img, dtype='float32'))
  14. img_data = np.transpose(img_data, (2, 0, 1)) # 调整图片的维度
  15. img_data = np.expand_dims(img_data, 0) # 添加batch_size维度
  16. images = torch.from_numpy(img_data) # 将图片数据转化为torch张量
  17. """模型导入与推理"""
  18. model_path = "model_data/yolox_s.pth" # 模型路径
  19. class_path = "model_data/coco.names" # 类名文件
  20. font_path = "model_data/simhei.ttf" # 字体文件
  21. """获得类名列表"""
  22. with open(class_path, 'r') as f:
  23. class_names = f.read().split("/n")[:-1] # 最后一个去掉,是因最后一个字符是空格
  24. """建立模型对象并导入权重"""
  25. model = YoloBody(80, 's') # 新建模型,'s'表示新建的为yolox_s模型
  26. load_model(model, model_path, 'cpu') # 导入模型权重
  27. with torch.no_grad():
  28. outputs = model(images) # 模型推理
  29. outputs = decode_outputs(outputs, [640, 640]) # 输出解码
  30. outputs = non_max_suppression(outputs) # NMS
  31. yolo_correct_boxes(outputs, (640, 640), img.size, True) # 坐标变换
  32. img = draw_boxes(img, outputs, font_file=font_path, class_names=class_names) # 画框
  33. # 这里之所以用letterbox,是因为我们获得的预测框,坐标是在letterbox_ima上的
  34. img.show()
  35. """图像保存"""
  36. img_name = file_path.split('//')[-1]
  37. img.save(r'img_out/' + img_name)

Reference

http://blog.17baishi.com/16361/