YOLOX网络输出三个维度信息:
[1, 85, 80, 80]
[1, 85, 40, 40]
[1, 85, 20, 20]
再进行边框解码
一、网络输出解码
import torchdef decode_outputs(outputs, input_shape):"""Args:outputs:列表,里面的元素分别为各个检测头的输出input_shape:列表或元组,里面的两个元素分别为模型输入图片的高宽,如[640, 640]Returns:""""""以下代码的注释,都是假设只有三个检测头,要检测的类别数是80,input_shape为[640, 640]的情况下的结果"""grids = []strides = []hw = [x.shape[-2:] for x in outputs] # 三个检测头输出结果的高宽outputs = torch.cat([x.flatten(start_dim=2) for x in outputs], dim=2).permute(0, 2, 1)# [x.flatten(start_dim=2) for x in outputs]每次获得的x都是4个维度,# 第一个x的维度为torch.Size([batch_size, 85, 80, 80])# x.flatten(start_dim=2) 表示从2号维度开始展平,打平后的维度为torch.Size([batch_size, 85, 6400])# 三个输出维度展平为三个张量,维度分别为(batch_size, 85, 6400)、(batch_size, 85, 1600)、(batch_size, 85, 400)# torch.cat将列表中的三个张量按指定维度(dim=2)拼接进行拼接,得到的张量维度为torch.Size([batch_size, 85, 8400])# .permute(0, 2, 1)表示调整维度顺序,得到的张量维度为torch.Size([batch_size, 8400, 85])# 最后的outputs的shape变为torch.Size([batch_size, 8400, 85])outputs[:, :, 4:] = torch.sigmoid(outputs[:, :, 4:])# 最后一个维度,前面4个数是中心点坐标和高宽,从第5个数是置信度,后面是各个类别的概率,# 这里使用sigmoid函数将置信度和各个类别的概率压缩到0-1之间for h, w in hw:"""循环取出特征层的高和宽,第一次为80,80"""# 根据特征层生成网格点grid_y, grid_x = torch.meshgrid([torch.arange(h), torch.arange(w)])# grid_y和grid_x的维度都是torch.Size([80, 80])grid = torch.stack((grid_x, grid_y), 2).view(1, -1, 2)# shape为torch.Size([1, 6400, 2]),最后一个维度是2,为网格点的横纵坐标,而6400表示当前特征层的网格点数量# torch.stack((grid_x, grid_y), 2)对张量进行扩维拼接,返回的shape为torch.Size([80, 80, 2])# 关于torch.stack的用法,可以看这篇博客:https://blog.csdn.net/Teeyohuang/article/details/80362756/shape = grid.shape[:2] # shape为torch.Size([1, 6400])grids.append(grid)strides.append(torch.full((shape[0], shape[1], 1), input_shape[0] / h))# 为每个网格获取步长(对应于先验框的尺寸)# input_shape[0]/h 获得当前特征图(输出特征图特征图)高h方向的步长,这个步长也是宽w方向上的步长# 因为输入图片和检测头输出的特征图,在高和宽两个方向上的缩放比例是一样的,所以步长也是一样# torch.full((shape[0], shape[1], 1), input_shape[0]/h是由步长填充而成的张量# 将网格点堆叠到一起grids = torch.cat(grids, dim=1).type(outputs.type()) # torch.cat是让张量按照指定维度拼接,但得到的新张量维度数不会变# grides的维度为(1, 8400, 2),中间的8400表示8400个特征点strides = torch.cat(strides, dim=1).type(outputs.type()) # .type(outputs.type())指定张量的类型# strides的维度为(1, 8400, 1)# 根据网格点进行解码(乘以步长相当于回到原图的坐标)outputs[..., :2] = (outputs[..., :2] + grids) * strides # 解码得到中心点的坐标# 因为outputs[..., :2]是在0-1之间,为相对网格左上角的偏移量outputs[..., 2:4] = torch.exp(outputs[..., 2:4]) * strides # 解码得到预测框的高宽# 归一化(相对于图片大小)outputs[..., [0, 2]] = outputs[..., [0, 2]] / input_shape[1]outputs[..., [1, 3]] = outputs[..., [1, 3]] / input_shape[0]# 返回的outputs的维度为(batch_size, 8400, 85)return outputs
二、非极大值抑制
将解码后的输出进行筛选
def non_max_suppression(prediction, conf_thres=0.5, nms_thres=0.4):"""使用置信度过滤和非极大值抑制Args:prediction: 模型的预测结果(经过解码后的数据),如果要预测80个类别,那么prediction的维度为torch.Size([batch_size, num_anchors, 85])conf_thres: 置信度阈值nms_thres: NMS阈值Returns:一个列表,其元素个数为batch_size,每个元组都是torch张量,对应每张图片经过两轮筛选后的结果,如果图片中存在目标,那么对应的元素维度为(num_objs, 7),7列的内容分别为:x1, y1, x2, y2, obj_conf, class_conf, class_pred,其中坐标为归一化后的数值,如果图片中不存在目标,那么对应的元素为None"""# 将解码结果的中心点坐标和宽高转换成左上角和右下角的坐标box_corner = prediction.new(prediction.shape)box_corner[:, :, 0] = prediction[:, :, 0] - prediction[:, :, 2] / 2box_corner[:, :, 1] = prediction[:, :, 1] - prediction[:, :, 3] / 2box_corner[:, :, 2] = prediction[:, :, 0] + prediction[:, :, 2] / 2box_corner[:, :, 3] = prediction[:, :, 1] + prediction[:, :, 3] / 2prediction[:, :, :4] = box_corner[:, :, :4]output = [None for _ in range(len(prediction))] # len(prediction))是batch_size,即图片数量for image_i, image_pred in enumerate(prediction):"""第一轮过滤"""# 利用目标置信度(即对应的预测框存在要检测的目标的概率)做第一轮过滤image_pred = image_pred[image_pred[:, 4] >= conf_thres]# 如果当前图片中,所有目标的置信度都小于阈值,那么就进行下一轮循环,检测下一张图片if not image_pred.size(0):continue# 目标置信度乘以各个类别的概率,并对结果取最大值,获得各个预测框的scorescore = image_pred[:, 4] * image_pred[:, 5:].max(1)[0]# image_pred[:, 4]是置信度,image_pred[:, 5:].max(1)[0]是各个类别的概率最大值# 将image_pred中的预测框按score从大到小排序image_pred = image_pred[(-score).argsort()]# argsort()是将(-score)中的元素从小到大排序,返回排序后索引# 将(-score)中的元素从小到大排序,实际上是对score从大到小排序# 将排序后的索引放入image_pred中作为索引,实际上是对本张图片中预测出来的目标,按score从大到小排序# 获得第一轮过滤后的各个预测框的类别概率最大值及其索引class_confs, class_preds = image_pred[:, 5:].max(1, keepdim=True)# class_confs 类别置信度最大值,class_preds 预测类别在80个类别中的索引# 将各个目标框的上下角点坐标、目标置信度、类别置信度、类别索引串起来detections = torch.cat((image_pred[:, :5], class_confs.float(), class_preds.float()), 1)# 经过上条命令之后,detections的维度为(number_pred, 7)# 7列的内容分别为:x1, y1, x2, y2, obj_conf, class_conf, class_pred"""第二轮过滤"""keep_boxes = [] # 用来存储符合要求的目标框while detections.size(0): # 如果detections中还有目标"""以下标注是执行第一轮循环时的标注,后面几轮以此类推"""# 获得与第一个box(最大score对应的box)具有高重叠的预测框的布尔索引from utils.utils import bbox_ioularge_overlap = bbox_iou(detections[0, :4].unsqueeze(0), detections[:, :4]) > nms_thres# bbox_iou(detections[0, :4].unsqueeze(0), detections[:, :4])返回值的维度为(num_objects, )# detections[0, :4]代表score最高的边框信息# bbox_iou的返回值与非极大值抑制的阈值相比较,获得布尔索引# 即剩下的边框中,与detection[0]的iou大于nms_thres的,才抑制,即认为这些边框与detection[0]检测的是同一个目标# 获得与第一个box相同类别的预测框的索引label_match = detections[0, -1] == detections[:, -1]# 布尔索引,获得所有与detection[0]相同类别的对象的索引# 获得需要抑制的预测框的布尔索引invalid = large_overlap & label_match # &是位运算符,两个布尔索引进行位运算# 经过第一轮筛选后的剩余预测框,如果同时满足和第一个box有高重叠、类别相同这两个条件,那么就该被抑制# 这些应该被抑制的边框,其对应的索引即为无效索引# 获得被抑制预测框的置信度weights = detections[invalid, 4:5]# 加权获得最后的预测框坐标detections[0, :4] = (weights * detections[invalid, :4]).sum(0) / weights.sum()# 上面的命令是将当前边框,和被抑制的边框进行加权,# 类似于好几个边框都检测到了同一张人脸,将这几个边框的左上角点横坐标x进行加权(按照置信度加权),# 获得最后边框的x,对左上角点的纵坐标y,以及右下角点的横纵坐标也进行加权处理# 其他的obj_conf, class_conf, class_pred则使用当前box的keep_boxes += [detections[0]] # 将第一个box加入到 keep_boxes 中detections = detections[~invalid] # 去掉无效的预测框,更新detectionsif keep_boxes: # 如果keep_boxes不是空列表output[image_i] = torch.stack(keep_boxes) # 将目标堆叠,然后加入到列表# 假设NMS之后,第i张图中有num_obj个目标,那么torch.stack(keep_boxes)的结果是就是一个(num_obj, 7)的张量,没有图片索引# 如果keep_boxes为空列表,那么output[image_i]则未被赋值,保留原来的值(原来的为None)return output
三、绘制边框
3.1 在letterbox中绘制
def draw_boxes(image, outputs, font_file, class_names, colors_list=[]):"""在图片上画框Args:image: 要画框的图片,PIL.Image.open的返回值outputs: 一个列表,NMS后的结果,其中的坐标为归一化后的坐标font_file:字体文件路径class_names:类名列表colors_list:颜色列表Returns:"""# 根据图片的宽,动态调整字体大小font_size = np.floor(3e-2 * image.size[1] + 0.5).astype('int32')font = ImageFont.truetype(font=font_file, size=font_size) # 创建字体对象,包括字体和字号draw = ImageDraw.Draw(image) # 将letterbox_img作为画布for output in outputs: # ouput是每张图片的检测结果,当然这里batch_size为1就是了if output is not None:for obj in output: # 一张图片可能有多个目标,obj就是其中之一"""从obj中获得信息"""box = obj[:4] * 640 # 将归一化后的坐标转化为输入图片(letterbox_img)中的坐标cls_index = int(obj[6]) # 类别索引score = obj[4] * obj[5] # score,可以理解为类别置信度x1, y1, x2, y2 = map(int, box) # 转化为整数pred_class = class_names[cls_index] # 目标类别名称color = 'red' # TODO 具体使用时,还得改成colors_list[cls_index]"""组建要显示的文字信息"""label = ' {} {:.2f}'.format(pred_class, score)print(label, x1, y1, x2, y2)"""获得文字的尺寸"""label_size = draw.textsize(label, font)label = label.encode('utf-8')"""防止文字背景框在上边缘越界"""if y1 - label_size[1] >= 0:text_origin = np.array([x1, y1 - label_size[1]])else:# 如果越界,则将文字信息写在边框内部text_origin = np.array([x1, y1 + 1])"""绘制边框"""thickness = 2 # 边框厚度for i in range(thickness): # 根据厚度确定循环的执行次数draw.rectangle([x1 + i, y1 + i, x2 - i, y2 - i], outline=color) # colors[cls_index]"""绘制文字框"""draw.rectangle([tuple(text_origin), tuple(text_origin + label_size)], fill=color) # 背景draw.text(text_origin, str(label, 'UTF-8'), fill=(0, 0, 0), font=font) # 文字del drawreturn image
from PIL import Image, ImageFontimport numpy as npimport torchfrom nets.yolo import YoloBodyfrom utils.utils_bbox import decode_outputs, non_max_suppression, draw_boxesfrom utils.utils import preprocess_input, resize_image, load_model"""图片导入及预处理"""file_path = r"C:/Users/mzrs_wjh/Desktop/01/person_and_dog.jpeg" # 图片路径img = Image.open(file_path) # 打开图片letterbox_img = resize_image(img, (640, 640), True) # 缩放并进行letterbox转化# 使用ImageNet的均值和方差对图片进行归一化img_data = preprocess_input(np.array(letterbox_img, dtype='float32'))img_data = np.transpose(img_data, (2, 0, 1)) # 调整图片的维度img_data = np.expand_dims(img_data, 0) # 添加batch_size维度images = torch.from_numpy(img_data) # 将图片数据转化为torch张量"""模型导入与推理"""model_path = "model_data/yolox_s.pth" # 模型路径class_path = "model_data/coco.names" # 类名文件font_path = "model_data/simhei.ttf" # 字体文件"""获得类名列表"""with open(class_path, 'r') as f:class_names = f.read().split("/n")[:-1] # 最后一个去掉,是因最后一个字符是空格"""建立模型对象并导入权重"""model = YoloBody(80, 's') # 新建模型,'s'表示新建的为yolox_s模型load_model(model, model_path, 'cpu') # 导入模型权重with torch.no_grad():outputs = model(images) # 模型推理outputs = decode_outputs(outputs, [640, 640]) # 输出解码outputs = non_max_suppression(outputs) # NMSletterbox_img = draw_boxes(letterbox_img, outputs, font_file=font_path, class_names=class_names) # 画框# 这里之所以用letterbox,是因为我们获得的预测框,坐标是在letterbox_ima上的letterbox_img.show()
3.2 在原图中绘制
def yolo_correct_boxes(outputs, input_shape, image_shape, is_letterbox):"""Args:outputs: 一个列表,NMS后的输出,详见non_max_suppression的函数文档input_shape:输入图片尺寸,列表或数组image_shape:图片原尺寸,列表或数组is_letterbox:布尔值,是否对原图进行过letterbox处理Returns:"""if is_letterbox:for output in outputs: # output是单张图片中检测到的目标if output is not None:# 输入图像与真实图像比例scale = np.min(np.array(input_shape) / np.array(image_shape))# 左右、上下的灰条宽度offset = (np.array(input_shape)-np.array(image_shape) * scale)/input_shape / 2.# 将letterbox中的坐标转化为原图像中的坐标(归一化后的坐标)output[:, 0] = (output[:, 0] - offset[0]) / scaleoutput[:, 1] = (output[:, 1] - offset[1]) / scaleoutput[:, 2] = (output[:, 2] - offset[0]) / scaleoutput[:, 3] = (output[:, 3] - offset[1]) / scale
from PIL import Image, ImageFontimport numpy as npimport torchfrom nets.yolo import YoloBodyfrom utils.utils import cvtColor, preprocess_input, resize_image, load_modelfrom utils.utils_bbox import decode_outputs, non_max_suppression, draw_boxes, yolo_correct_boxes"""图片导入及预处理"""file_path = r"C:/Users/mzrs_wjh/Desktop/01/person_and_dog.jpeg" # 图片路径img = Image.open(file_path) # 打开图片img = cvtColor(img) # 使用cvtColor,这样即便是灰度图,也能检测letterbox_img = resize_image(img, (640, 640), True) # 缩放并进行letterbox转化# 使用ImageNet的均值和方差对图片进行归一化img_data = preprocess_input(np.array(letterbox_img, dtype='float32'))img_data = np.transpose(img_data, (2, 0, 1)) # 调整图片的维度img_data = np.expand_dims(img_data, 0) # 添加batch_size维度images = torch.from_numpy(img_data) # 将图片数据转化为torch张量"""模型导入与推理"""model_path = "model_data/yolox_s.pth" # 模型路径class_path = "model_data/coco.names" # 类名文件font_path = "model_data/simhei.ttf" # 字体文件"""获得类名列表"""with open(class_path, 'r') as f:class_names = f.read().split("/n")[:-1] # 最后一个去掉,是因最后一个字符是空格"""建立模型对象并导入权重"""model = YoloBody(80, 's') # 新建模型,'s'表示新建的为yolox_s模型load_model(model, model_path, 'cpu') # 导入模型权重with torch.no_grad():outputs = model(images) # 模型推理outputs = decode_outputs(outputs, [640, 640]) # 输出解码outputs = non_max_suppression(outputs) # NMSyolo_correct_boxes(outputs, (640, 640), img.size, True) # 坐标变换img = draw_boxes(img, outputs, font_file=font_path, class_names=class_names) # 画框# 这里之所以用letterbox,是因为我们获得的预测框,坐标是在letterbox_ima上的img.show()"""图像保存"""img_name = file_path.split('//')[-1]img.save(r'img_out/' + img_name)
