第5章〓目标检测 目标检测、物体检测及缺陷检测是卷积神经网络的重要应用场景,其主要任务是找出图像或者视频中所感兴趣的物体(目标)的位置并指明所属类别,在计算机视觉领域中占有重要地位。 本章将详细介绍经典目标检测网络,并结合深度学习框架从零重现RCNN、Faster RCNN、SSD、YOLOv1、YOLOv2、YOLOv3、YOLOv4、YOLOv5、YOLOv7模型代码。通过阅读本章内容,读者不仅可以掌握经典目标检测算法的原理,同时也具备目标检测算法代码的实现及调优的能力。 5.1标签处理及代码 相对于图像分类任务,目标检测任务需要将真实感兴趣的目标标识出来,然后交给神经网络训练学习。标签处理包括数据标注、标签数据格式转换、标签数据读取等过程。 数据标注可使用开源工具labelimg,安装方法为pip install labelimg。安装成功后,在命令行中输入labelimg即可打开该软件。选择Open Dir配置训练图片的文件夹。选择Change Save Dir配置标注目标的保存目录,如图51所示。 图51labelimg选择训练文件夹和标注目标文件夹 加载图片后,选择Create RectBox同时默认保留标注文件格式为PascalVoc格式,拖动鼠标在目标区域生成一个矩形框后将矩形框的分类填写为face_mask,如图52所示。 图52labelimg数据标注方法 标注完成后会在图51设置的文件夹中生成与图片名称相同的XML文件,其标签表示有一个目标区域,为目标图像左上角坐标点的位置,为目标图像右下角坐标点的位置,为当前目标图像的类别,如图53所示。 图53PascalVoc标注内容 如果有多个感兴趣的目标和图像,就需要逐张图片对感兴趣的区域进行标注,这个过程是比较耗费时间和体力的。 如果选择YOLO格式,则会在标注保存文件夹中生成一个TXT文件,其中第1位为感兴趣的物体类别的下标,假设类别为[face_mask,face]则此时为0,第2位和第3位为矩形框的中心点位置(center_x,center_y),第4位和第5位为矩形框的宽和高(width,height),然后针对(center_x,width)/原图的宽、(center_y,height)/原图的高,得到如图54所示的小数。 图54YOLO标注内容 使用代码可以由Voc格式转换成YOLO格式,代码如下: #第5章/ObjectDetection/LabelConversion/voc2yolo.py from glob import glob import os import xml.etree.cElementTree as ET import cv2 import numpy as np def draw_box(image, boxes): #在原图中绘出所有的boxes和label for i, rect in enumerate(boxes): #获得box index, x1, y1, x2, y2 = rect[0:5].astype("int") #绘矩形框,需要左上、右下坐标 cv2.rectangle(image, (x1, y1), (x2, y2), (0, 255, 0), 1, cv2.LINE_AA) #绘矩形框的类别 cv2.putText(image, f"{index}", (x1 - 10, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.75, (0, 0, 255), 2) cv2.imshow('show box', image) cv2.waitKey(0) def read_annotations(xml_path, all_name=[], image=None): #使用etree读取XML文件 et = ET.parse(xml_path) element = et.getroot() #查找XML文件中所有的object目标区域 element_objs = element.findall('object') #用来存储矩形框的区域 results = [] #遍历所有的box区域的object标签 for element_obj in element_objs: #获得类名称 class_name = element_obj.find('name').text #如果XML文件中类的名称与指定类的名称相同,则获取列表中类的索引值 for i, n in enumerate(all_name): if n == class_name: index = i break #从bndbox中获取矩形框的左上、右下坐标并转换成int类型 obj_bbox = element_obj.find('bndbox') #lambda表达式,根据坐标名称获取对应内容,并转换成float型 coord_xy = lambda coord_name: float(obj_bbox.find(coord_name).text) #组成[xmin,ymin,xmax,ymax,label index]的数组 label = [index] + [coord_xy(name) for name in ['xmin', 'ymin', 'xmax', 'ymax']] results.append(label) #如果image不为None,则可以将获取出来的label还原到图像中 boxes = np.array(results) if image is not None: draw_box(image, boxes) return boxes def xyxy2cxcywh(boxes, image_shape): #由xmin、ymin、xmax、ymax分别转换成cx、cy、w、h #width,height = (xmax,ymax)-(xmin,ymin) 得到矩形框的width和height boxes[..., 3:5] = boxes[..., 3:5] - boxes[..., 1:3] #(center_x,center_y) = (xmin,ymin + wh/2) 中心点在图像中的位置 boxes[..., 1:3] = boxes[..., 1:3] + boxes[..., 3:5] / 2 #分别除以width和height得到归一化后的值 boxes[..., 1:5] = boxes[..., 1:5] / image_shape return boxes if __name__ == "__main__": #根目录 root = "../train_data" #训练图片文件夹 jpg_path = f"{root}/face_img" #标注过的文件夹 xml_path = f"{root}/face_label" #期望转换为YOLO格式的文件夹 save_path = f"{root}/yolo_label" #如果指定的保存文件夹不存在,则创建1个 if not os.path.exists(save_path): os.mkdir(save_path) #获取指定文件夹中所有以.jpeg为后缀名的文件 images_file = glob(os.path.join(jpg_path, '*.jpeg')) #遍历所有的图片 for image_path in images_file: #读取图片以获取图片的width和height #不从XML文件读取,这是因为有时与实际图片不一致 image = cv2.imread(image_path) image_shape = image.shape[0:2][::-1] #组成width,height,width,height的数组,方便后续计算 image_shape = np.array(image_shape + image_shape) #根据图片名称找到对应的Voc XML文件 jpg_name = os.path.split(image_path) name = jpg_name[-1].split('.')[0] name_xml = f"{xml_path}/{name}.xml" #获取原始标注类别和位置[index,xmin,ymin,xmax,ymax] boxes = read_annotations(name_xml, all_name=['face_mask', 'face'], image=image) #实现从Voc格式转换成YOLO格式 boxes = xyxy2cxcywh(boxes, image_shape) #设置保存YOLO格式的TXT文件名 save_txt = f"{save_path}/{name}.txt" #将box信息写入save_path中 np.savetxt(save_txt, boxes, fmt='%.4f') 代码中read_annotations()函数实现读取XML文件得到[index,xmin,ymin,xmax,ymax]的数组内容,并在xyxy2cxcywh()函数中实现转换成[index,center_x,center_y,width,height]的数组。draw_box()函数主要将标注box还原到图像中,以便进行抽查标注信息是否正确。np.savetxt()实现整个数组保存,由于保存为float类型,TXT文件中第1位index会显示为4位小数,这个index在喂入训练数据时需要转换为int。 也可以由YOLO格式转换成Voc格式,代码如下: #第5章/ObjectDetection/LabelConversion/YOLOv2voc.py from glob import glob import os import cv2 import numpy as np def cxywh2xyxy(boxes, image_shape): #实现从矩形中心点坐标转换成xmin,ymin,xmax,ymax #从小数还原为图像中的位置 box = boxes[..., 1:5].copy() * image_shape #(center_x,center_y) - (width,height/2)就是左上角 xminymin = box[..., 0:2] - box[..., 2:4] / 2 #(center_x,center_y) + (width,height/2)就是右下角 xmaxymax = box[..., 0:2] + box[..., 2:4] / 2 #合成[xmin,ymin,xmax,ymax] boxes[..., 1:5] = np.concatenate([xminymin, xmaxymax], axis=-1) #转换为int类型 return boxes.astype('int32') def generate_voc(boxes, all_name=[]): #根据boxes信息生成Voc格式的文件 voc_str = "" for i, rect in enumerate(boxes.astype("int32")): object_str = f""" {all_name[0]} Unspecified 0 0 {rect[1]} {rect[2]} {rect[3]} {rect[4]} """ voc_str += object_str return f"""{voc_str}""" if __name__ == "__main__": #根目录 root = "../train_data" #训练图片文件夹 jpg_path = f"{root}/face_img" #YOLO格式的文件夹 yolo_path = f"{root}/yolo_label" #期望保存为Voc格式的文件夹 save_path = f"{root}/voc_label" #如果指定的文件夹不存在,则创建1个 if not os.path.exists(save_path): os.mkdir(save_path) #获取指定文件夹中所有以.jpeg为后缀名的文件 images_file = glob(os.path.join(jpg_path, '*.jpeg')) #遍历所有的图片 for image_path in images_file: #读取图片以获取图片的width和height #不从XML文件读取,这是因为有时与实际图片不一致 image = cv2.imread(image_path) image_shape = image.shape[0:2][::-1] #组成width,height,width,height的数组,方便后续计算 image_shape = np.array(image_shape + image_shape) #根据图片名称找到对应的Voc XML文件 jpg_name = os.path.split(image_path) name = jpg_name[-1].split('.')[0] name_txt = f"{yolo_path}/{name}.txt" #读取YOLO格式内容 boxes = np.loadtxt(name_txt) #转换成[index,xmin,ymin,xmax,ymax] boxes_xyxy = cxywh2xyxy(boxes, image_shape) #创建XML文件 save_xml = f"{save_path}/{name}.xml" #调用generate_voc()实现Voc格式的写入 with open(save_xml, 'w', encoding='utf-8') as f: f.write(generate_voc(boxes_xyxy, all_name=['face_mask', 'face'])) #将YOLO格式还原到图像中 draw_box(image, boxes_xyxy) cxywh2xyxy()实现从YOLO格式转换为xmin,ymin,xmax,ymax的格式。同时generate_voc()实现XML文件的写入,np.loadtxt()实现TXT文件的数组读入。 标注文件内容格式的转换为高频操作,本书的代码可以在工作中直接使用。 总结 目标检测标注工具labeling可标注YOLO格式、Voc格式,并且实现了YOLO格式与Voc格式的互相转换。 练习 寻找一份数据集进行标注,并调试实现YOLO格式与Voc格式数据的互相转换。 5.2开山之作RCNN 5.2.1模型介绍 RCNN由Ross Girshick等在2014年发表的论文Rich Feature Hierarchies for Accurate Object Detection and Semantic Segmentation中首次使用深度学习进行目标检测,其网络结构和实现过程对后续模型提供了重要参考。 其网络的主要过程如图55所示,分为训练过程和预测过程。 图55RCNN模型过程 (1) 训练时需要输入训练图片和标注BOX,通过选择区域搜索算法生成多个目标框(取前2000个区域),当区域选择框与标注BOX重叠面积较大时认为是有目标的框(正样本),而重叠面积较小或无重叠时认为是无目标的框(称为负样本)。将正样本图片和负样本图片输入CNN中提取特征,例如使用VGG中的FC层得到4096维特征,然后使用4096维特征,通过支持向量机(SVM)进行分类训练。由于区域选择框与标注BOX之间有一定的偏移,所以这里使用回归算法对这个偏移进行了回归训练。 (2) 预测推理时需要输入未知图片,同样通过区域搜索算法选取2000个区域选择框,使用CNN提取特征,然后将区域选择框中的图像信息输入SVM权重得到预测分类,然后通过阈值设置过滤掉概率较低的区域选择框。将分类概率较高的框输入Reg回归权重得到区域选择框与预测框的偏移,通过区域选择框和预测偏移得到多个预测框。多个预测框之间有可能出现重叠面积较大的框,这会被认为是同一个分类。使用非极大值抑制(NMS)对其进行剔除,得到最后的预测框,将预测框和预测分类信息绘制到原图中,就得到了目标检测的结果。 整个过程步骤较多且涉及较多新概念,详细过程和相关代码将在后续几节中逐步展开。 5.2.2代码实战选择区域搜索 选择区域搜索(Selective Search)算法,首先输入一张图片,通过图像分割的方法获得很多小的区域,然后对这些小的区域采用相似度计算的方法,将相似区域不断地进行合并,一直到无法合并为止。图像相似度计算包括颜色相似度、纹理相似度、尺度相似度、填充相似度等方法,具体可参考原论文。 选择区域搜索算法的实现可调用OpenCV中的createSelectiveSearchSegmentation()对象实现,代码如下: #第5章/ObjectDetection/R-CNN/selectivesearch.py import cv2 import numpy as np def start_search(image): #开启优化设置 cv2.setUseOptimized(True) #创建选择优化搜索对象 #pip install opencv-contrib-python objSrh = cv2.ximgproc. segmentation.createSelectiveSearchSegmentation() #加载图片 objSrh.setBaseImage(image) #快速搜索 objSrh.switchToSelectiveSearchFast() #提取区域选择框 objSrhRects = objSrh.process() #区域选择框由x、y、w、h分别转换成xmin、ymin、xmax、ymax rect = objSrhRects.copy() objSrhRects[..., 2:4] = rect[..., 0:2] + rect[..., 2:4] #在原图绘出区域框 #u.draw_box(image,objSrhRects) return objSrhRects[:2000, ...] 函数start_search(image)用于实现选择区域搜索,但是objSrhRects提取的是左上角坐标和高宽,通过objSrhRects[...,2:4]=rect[...,0:2]+rect[...,2:4]转换成左上、右下角坐标。 5.2.3代码实战正负样本选择 区域选择框与标注BOX重叠面积占比的计算又称为交并比(Intersection over Union,IOU),其计算公式为 IOU=areaintersectionareabox1+areabox2-areaintersection(51) 当交集区域最大时,GTBOX与SSBOX会重叠,此时IOU=1; 当交集区域为0时,IOU=0; 求交集则为areaintersection=abs(GTBOXmaxx-SSBOXminx)×abs(GTBOXmaxy-SSBOXminy),加绝对值abs()是由于GTBOX有可能在SSBOX的右侧,如图56所示。 图56IOU计算 具体的代码如下: #第5章/ObjectDetection/R-CNN/utils.py import numpy as np def get_iou(boxes1, boxes2): #比较哪个box在左边,哪个box在右边,此时不再需要abs left = np.maximum(boxes1[..., :2], boxes2[..., :2]) right = np.minimum(boxes1[..., 2:4], boxes2[..., 2:4]) #计算交集的wh intersection = np.maximum(0.0, right - left) #计算交集的面积 area_inter = intersection[..., 0] * intersection[..., 1] #计算每个box的面积 area_box1 = (boxes1[..., 2] - boxes1[..., 0]) * (boxes1[..., 3] - boxes1[..., 1]) area_box2 = (boxes2[..., 2] - boxes2[..., 0]) * (boxes2[..., 3] - boxes2[..., 1]) #计算并集的面积,1e-10保证分母为0 union_square = np.maximum(area_box1 + area_box2 - area_inter, 1e-10) #计算交并比。如果比0小,则为0; 如果比1大,则为1 score = np.clip(area_inter / union_square, 0.0, 1.0) return score 当IOU>0.5时,SSBOX选取为正样本; 当IOU<0.3时,SSBOX选取为负样本,代码如下: #第5章/ObjectDetection/R-CNN/selectivesearch.py #根据IOU的得分,选择正样本和负样本 def based_iou_sample(objSrhRects, iou_array, g_index, max_threshold, is_greater=True, image=None): #如果is_greater为True,则选择IOU大于指定分数的box作为正样本 #如果is_greater为False,则选择IOU小于指定分数的box作为负样本 index = np.argwhere(iou_array > max_threshold if is_greater else iou_array < max_threshold) if len(index) > 0: #根据index获取ss的box信息 ss_box = objSrhRects[index].reshape([-1, 4]) #在原图中绘出这个区域 u.draw_box(image, ss_box, color=[0, 0, 255]) #大于0.5的框被认为是真实框,然后将这个label复制N份 #小于0.5的框被认为是负样本,然后将类别置为背景,没有目标 true_label = np.tile(np.array(g_index), [len(ss_box), 1]) #对大于max_threshold的框增加label信息 ss_box = np.append(ss_box,true_label, axis=-1) #返回区域选择框,以及样本的下标 return ss_box, index 函数based_iou_sample()根据传入的IOU分数通过np.argwhere()函数得到满足条件的索引号,当大于0.5时为正样本,当小于0.3时为负样本。通过参数g_index来区分是正样本还是负样本,如果是负样本,则g_index为所有类别数+1。 调试程序,可发现IOU>0.5的区域选择框共有15个,其中最大值为0.98190,如图57所示。 图57IOU正样本分数 如果将IOU>0.5的区域选择框还原到图像中,则可发现较多框可覆盖标注BOX。将这些区域选择框送入CNN提取特征,也就是正样本的特征,而那些IOU<0.3的区域选择框为负样本,CNN提取的特征为负样本特征。选择负样本来进行训练的一个原因是为了让神经网络学习到某些参数,以便将非目标区域排除。 正样本区域选择框如图58所示,负样本区域选择框如图59所示,而中间区域的区域和特征信息将会被丢弃。 图58正样本区域选择框(IOU>0.5) 图59负样本区域选择框(IOU<0.01) 正负样本确定后,需要根据SSBOX信息进行切图,并保存到本地以进行CNN特征提取,代码如下: #第5章/ObjectDetection/R-CNN/selectivesearch.py #切割图像并转换到224×224的大小 def cut_image(image, boxes): #image[ymin:ymax, xmin:xmax] 切图的方法 return [cv2.resize(image[box[0]:box[1], box[2]:box[3]], [224, 224]) for box in boxes] cut_positive_box = positive_box.copy() #正样本的格式为xmin,ymin,xmax,ymax,其下标分别为0,1,2,3 #需要转换为ymin,ymax,xmin,xmax,其下标分别为1,3,0,2,并以这些值进行切图 cut_positive_box[..., [0, 1, 2, 3]] = cut_positive_box[..., [1, 3, 0, 2]] positive_image = cut_image(img.copy(), cut_positive_box[..., 0:4]) 因为区域选择框得到的格式为xmin,ymin,xmax,ymax,而切图需要ymin,ymax,xmin,xmax格式,所以通过代码cut_positive_box[...,[0,1,2,3]]=cut_positive_box[...,[1,3,0,2]]进行了转换。同时cut_image()函数实现了切图,并转换到指定图像的大小。将图像大小设置为[224,224]是由于VGG的输入要求,如图510所示。 图510切图后将大小转换到224×224 标注信息使用5.1节标签处理及代码中的函数read_annotations()进行true_box的读取,同时随书源码中ObjectDetection/RCNN/selectivesearch.p文件中的get_positive_negative_samples()函数实现了更完整的代码。 另外在读取图片时将输入图像和标注BOX进行了等比例缩放。RCNN由于采用的是区域选择框提取,可以不进行图像的大小转换,但是为了网络的训练学习需要统一尺度及提高稳定性,本书将图像大小等比例缩放到640×640,代码如下: #第5章/ObjectDetection/R-CNN/utils.py def letterbox_image(image, size, box): #对图片大小进行转换,使图片不失真。在空缺的地方进行padding #位置不会有偏移的情况 iw, ih = image.size w, h = size #如果刚好输入的尺寸与要求的尺寸相同,则原图返回 if iw == w and ih == h: return cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR), box #计算是宽还是高的比例更小 scale = min(w / iw, h / ih) #按最小的比例进行缩放 nw = int(iw * scale) nh = int(ih * scale) #缩放比例距原图的大小,因为需要上下或者左右同时缩放,所以需要整除2 dw = (w - nw) //2 dh = (h - nh) //2 #等比例缩放 image = image.resize((nw, nh), Image.BICUBIC) #新建一个灰度图 new_image = Image.new('RGB', size, (128, 128, 128)) #在缩放的图中增加填充的像素 new_image.paste(image, (dw, dh)) #对BOX进行等比例调整 box_resize = [] for boxx in box: #对BOX进行等比例缩减,并加上填充的灰度图 boxx[0] = int(boxx[0] * scale + dw) boxx[1] = int(boxx[1] * scale + dh) boxx[2] = int(boxx[2] * scale + dw) boxx[3] = int(boxx[3] * scale + dh) #略有裁剪,可不使用 boxx[0] = np.clip(boxx[0], 0, w - 1) boxx[2] = np.clip(boxx[2], 0, h - 1) boxx[1] = np.clip(boxx[1], 0, w - 1) boxx[3] = np.clip(boxx[3], 0, h - 1) boxx = boxx.astype("int32") box_resize.append(boxx) #转回NumPy格式 new_image = cv2.cvtColor(np.array(new_image), cv2.COLOR_RGB2BGR) return new_image, np.array(box_resize) 函数letterbox_image()会对输入图像进行等比例缩放,例如原图为1125×750,按640×640进行缩放,则scale=min(0.568,0.853)按0.568进行等比例缩放,而此时(nw,nh)=(640,426),缩放后图像的高不够640,则需要上下各补107像素,即new_image.paste(image,(0,107))。同理原图输入的标注BOX信息,也按0.568进行缩放,所以boxx[1]×scale,但是可能像素不够,所以通过boxx[1]×scale+107进行填充,效果如图511所示。 图511等比例缩放图像和标注BOX 5.2.4代码实战特征提取 5.2.3节已提取正样本和负样本图片、正样本SSBOX和标注框GTBOX信息,搭建CNN中的VGG16网络并使用ImageNet的初始权重,代码如下: #第5章/ObjectDetection/R-CNN/vgg_features.py def init_vgg(): #VGG-16模型,从Keras中直接提取 base_model = VGG-16(weights='ImageNet',include_top=True) base_model.summary() #构建模型,output参数用于确定使用哪一个网络层作为输出 model = Model(inputs=base_model.input, outputs=base_model.layers["fc2"].output) return model #从ImageNet VGG-16中得到图片的特征信息 def vgg_features(model, image): x = preprocess_input(image) #前向传播得到特征 features = model.predict(x) return features if __name__ == "__main__": model = init_vgg() get_feature_map("./train_data/ss_info/正样本图片.npy", "./train_data/ss_feature/正样本图片特征.npy", model) get_feature_map("./train_data/ss_info/负样本图片.npy", "./train_data/ss_feature/负样本图片特征.npy", model) 调用keras. applications模块下已写好的VGG16模型,并同时使用weights='ImageNet'的初始权重,选择fc2层4096维作为输出特征,然后分别加载正样本图片.py和负样本图片.npy,并保存特征信息。 5.2.5代码实战SVM分类训练 支持向量机(SVM)是一个经典的机器学习分类方法,如图512中(a)图红色和蓝色的点显然是可以被一条直线分开的,而能够分开的线不止一条,例如(b)、(c)中的黑线A和B,如果是一个平面,则称为决策面。 图512最大间隔距离(见彩插) 如果从决策面来观察,则可知A和B均可,但是从直觉来讲A优于B,其原因是A的间隔距离比B大,所以支持向量机的核心思想就是求解能够正确划分训练数据集并且几何间隔距离最大的超平面。 支持向量机的原理推导较复杂(非重点),不过调用sklearn库中的SVC()对象可轻松实现,代码如下: #第5章/ObjectDetection/R-CNN/train.py #SVM训练分类器 def svm_classifier(): #加载特征提取信息和类别 pos_X = np.load("./train_data/ss_feature/正样本图片特征.npy") pos_Y = np.load("./train_data/ss_info/正样本图片分类label.npy") neg_X = np.load("./train_data/ss_feature/负样本图片特征.npy") neg_Y = np.load("./train_data/ss_info/负样本图片分类label.npy") #合并正样本和负样本 X = np.concatenate([pos_X, neg_X], axis=0) #label信息 Y = np.concatenate([pos_Y, neg_Y], axis=0) #划分训练集和验证集 x_train, x_test, y_train, y_test = train_test_split(X, Y, random_state=14) #SVM线性分类器 clf = SVC(C=1.0, kernel='linear', random_state=28, max_iter=1000, probability=True) #训练 clf.fit(x_train, y_train) #验证评分 pred = clf.predict(x_test) print("F1-score: {0:.2f}".format(f1_score(pred, y_test, average='micro'))) #保存SVM模型和参数 joblib.dump(clf, "face_mask_svm.m") 对象SVC()为一个线性可分对象,其中C为惩罚系数,训练完成后使用joblib.dump()保存模型。 论文的作者在这里使用SVM而不是用分类网络的重要原因是由于此时只有少量的正样本,但有大量的负样本,样本数据不平衡对于CNN来讲会非常敏感,而SVM对于不平衡数据并不敏感。 5.2.6代码实战边界框回归训练 通过SVM对区域选择框的背景或者目标类别进行预测,此时正样本区域选择框与标注真实框会有一定的偏差,通过以下公式进行偏移值的计算: tx=(Gx-Px)/Pw ty=(Gy-Py)/Ph tw=logGwPw(52) th=logGhPh 其中,Gx和Gy表示标注GTBOX的中心点,Px和Py为区域选择框SSBOX的中心点; Gx和Gy为GTBOX的宽和高,Pw和Ph为SSBOX的宽和高; tx和ty为GTBOX中心点与SSBOX中心点的偏移,tw和th为GTBOX与SSBOX的宽和高的偏移。当然tx、ty、tw和th越小越好,说明GTBOX与SSBOX的IOU分数较高,如图513所示。 图513标注GTBOX和SSBOX 计算偏移值的代码如下: #第5章/ObjectDetection/R-CNN/train.py def xyxy2cxcywh(boxes): #由xmin,ymin,xmax,ymax分别转换成xmin,ymin,w,h #由xmax-xmin和ymax-ymin得到中心点坐标 wh = boxes[..., 2:4] - boxes[..., :2] center_xy = boxes[..., :2] + wh / 2 #合并数组 return np.concatenate([center_xy, wh], axis=-1) #真实框cx,cy,w,h。区域选择框p_cx,p_cy,w,h def cxywh2offset(g_cxywh_boxes, p_cxywh_boxes): #公式(5-2) t_xy = (g_cxywh_boxes[..., :2] - p_cxywh_boxes[..., :2]) / p_cxywh_boxes[..., 2:4] t_wh = np.log(g_cxywh_boxes[..., 2:4] / p_cxywh_boxes[..., 2:4]) #合并数组 return np.concatenate([t_xy, t_wh], axis=-1) def box_regression(num_class=1): #导入正样本的特征信息和BOX信息 p_box = np.load("./train_data/ss_info/正样本box.npy") p_feature = np.load("./train_data/ss_feature/正样本图片特征.npy") g_box = np.load("./train_data/ss_info/真样本box.npy") for i in range(0, num_class): #类别号的下标 index = np.where(p_box[..., -1] == i) #根据类别号取不同的特征信息和box信息并进行归一化操作 p_class_feature = p_feature[index] #区域选择框,图像输入已统一到640 p_class_box = p_box[index] / 640 #标注GTBOX g_class_box = g_box[index] / 640 #因为区域选择框和真实框得到的坐标是xmin,ymin,xmax,ymax #为了计算偏移值需要转换成cx,cy,w,h p_class_box = xyxy2cxcywh(p_class_box) g_class_box = xyxy2cxcywh(g_class_box) #计算偏移值 offset_box = u.cxywh2offset(g_class_box, p_class_box) #输入4096维特征,输出4位偏移值 x_train, x_test, y_train, y_test = train_test_split(p_class_feature, offset_box, random_state=14) #sklearn中的线性回归 model = LinearRegression() #Reg回归训练 model.fit(x_train, y_train) #预测 y_pre = model.predict(x_test) #计算预测值和真实值之间的平均误差 loss = mean_squared_error(y_pre, y_test) print("误差", loss) joblib.dump(model, f"face_mask_lr{i}.m") return p_class_feature, offset_box 由于GTBOX和SSBOX格式均为xmin,ymin,xmax,ymax,但是计算偏移时需要cx,cy,w,h,所以通过函数xyxy2cxcywh(boxes)进行了实现。函数cxywh2offset(g_cxywh_boxes,p_cxywh_boxes)传入GTBOX和SSBOX的信息,套用公式(52)实现GTBOX与正样本SSBOX的偏移量的计算。函数box_regression(num_class=1)读取正样本特征并调用cxywh2offset()生成偏移量的数据,以便喂入Reg回归网络训练。 LinearRegression()是sklearn库中的逻辑回归对象,输入是正样本4096维特征,Y值为GTBOX与SSBOX的偏移值数据offset_box,当model.predict(x_test)预测的偏移与offset_box最小时,说明回归权重系数已获得。回归损失使用均方差mse()函数。 随书代码中还存在一份使用神经网络来做回归权重训练的代码,感兴趣的读者可查阅。 5.2.7代码实战预测推理 根据训练结果得到分类权重文件face_mask_svm.m,以及回归权重文件face_mask_lr0.m,读入待预测图片的大小转换到训练所需的640×640大小,并调用区域选择搜索算法对预测图片生成2000个区域框,然后使用VGG提取特征输入分类权重,对这2000个区域图片的背景和低概率分类进行过滤,此时得到有预测目标的SSBOX,代码如下: #第5章/ObjectDetection/R-CNN/predictdetect.py def inference_code(num_class=1,max_threshold=0.5): #VGG特征提取 model = v.init_vgg() #读入推理图片 org_image = cv2.imread( r"../val_data/pexels-photo-5211438.jpeg") #由NumPy格式转换为PIL对象 old_image = Image.fromarray(np.uint8(org_image.copy())) #等比例到640×640 img, _ = u.letterbox_image(old_image, [640, 640], []) #区域选择,默认得到的是xmin,ymin,xmax,ymax p_box = s.start_search(img.copy())[:2000,...] #根据区域选择切图 cut_box = p_box[..., 0:4].copy() cut_box[..., [0, 1, 2, 3]] = cut_box[..., [1, 3, 0, 2]] p_img = s.cut_image(img.copy(), cut_box) #对切出来的图全部升一维,由原来的[w,h,c]合并成b,w,h,c all_p_img = np.concatenate([np.expand_dims(p, axis=0) for p in p_img], axis=0) #提取特征 features = v.vgg_features(model, all_p_img) #调SVM进行预测 clf = joblib.load("./face_mask_svm.m") class_result = clf.predict_proba(features) #过滤置信度较低的结果 for i in range(num_class): #根据分类的下标进行置信度的过滤,得到分类可能性大于0.5的下标 index = np.where(class_result[..., i] >= max_threshold) #.......接下1段代码....... 在index处断点可知0为face_mask的分类,而1为背景的概率。类别概率大于0.5的区域选择框共有17个,其中最大概率为0.96,最小概率为0.03,index得到满足阈值数组的下标,如图514所示。 图514大于类别阈值的概率值 然后将满足分类概率的SSBOX和概率分数进行非极大值抑制(NMS)。NMS先对所有分类概率进行降序排列,将最大概率的数组下标存储下来,然后将最大概率的BOX与其他传入的boxes进行IOU得分,如果有小于阈值的框,则认为是同类别的其他目标,否则剔除,然后对找出来的框再次进行NMS,直到所有的框都不重叠,并返回boxes的下标,代码如下: #第5章/ObjectDetection/R-CNN/predictdetect.py def nms(boxes, nms_thresh=0.2): #用非极大值抑制,去掉相似框 #当前框的分类得分 scores = boxes[:, 4] #对boxes的概率得分从大到小进行排序,得到数组下标 order = scores.argsort()[::-1] keep = [] while order.size > 0: #取最大概率的下标 i = order[0] #将最大概率BOX的下标存起来 keep.append(i) #计算当前最大概率的BOX与其他框boxes的IOU分数 iou_score = get_iou(boxes[i, ...], boxes[order[1:], ...]) #只有小于指定阈值的框才会被认为是不同的框 index = np.where(iou_score <= nms_thresh)[0] #将上一步的index存起来 order = order[index + 1] return keep 非极大值之后使用p_boxes=p_boxes[nms_index]获得区域选择框,调用u.draw_box()函数实现目标区域的绘图,代码如下: #第5章/ObjectDetection/R-CNN/predictdetect.py def inference_code(num_class=1): #.......接上1段代码....... index = np.where(class_result[..., i] >= 0.5) if index[0].size: #得到BOX,并归一化 class_box = p_box[index] / 640 #得到BOX当前类别的分数 class_score = class_result[index][..., i] #将BOX和置信度合并成n*5 p_boxes = np.column_stack([class_box, class_score]) #进行非极大值抑制,得到保留BOX的下标 nms_index = u.nms(p_boxes) #保留的区域选择BOX p_boxes = p_boxes[nms_index] ss_img = u.draw_box(img.copy(), (p_boxes * 640).astype('int')) 直接使用区域选择框的位置也能识别出戴口罩的区域,如图515所示。 图515区域选择框NMS后的目标区域 根据SSBOX与Reg回归器预测的偏移值进行微调以生成预测框,其公式如下: G^x=Pwdx(P)+Px G^y=Phdy(P)+Py G^w=Pwexp(dw(P))(53) G^h=Phexp(dh(P)) 其中,Px和Py为SSBOX的中心点坐标,Pw和Ph为宽和高; dx(P)和dy(P)为Reg回归器预测SSBOX与预测框中心点的偏移值,dw(P)和dh(P)为宽和高的偏移。通过式(53)解码得到G^x、G^y、G^w、G^h预测框的中心点、宽和高值。 根据偏移值和SSBOX得到预测框的代码实现,代码如下: #第5章/ObjectDetection/R-CNN/predictdetect.py def offset2xyxy(offset_box, p_cxywh_boxes): p_cxy = offset_box[..., :2] * p_cxywh_boxes[..., 2:4] + p_cxywh_boxes[..., :2] p_wh = np.exp(offset_box[..., 2:4]) * p_cxywh_boxes[..., 2:4] boxes = np.concatenate([p_cxy, p_wh], axis=-1) #转换成xmin,ymin,xmax,ymax boxes[..., :2] -= boxes[..., 2:] / 2 boxes[..., 2:] += boxes[..., :2] return boxes def inference_code(num_class=1): #.......接上1段代码....... #调回归器权重进行微调 lr = joblib.load(f"./face_mask_lr{i}.m") pre_offset = lr.predict(features[nms_index]) #将区域选择框转换成cx,cy,w,h p_boxes = u.xyxy2cxcywh(p_boxes) #解码,从偏移值和区域选择BOX转换成xmin,ymin,xmax,ymax box = u.offset2xyxy(pre_offset, p_boxes) rg_img = u.draw_box(img.copy(), box) s_img = np.hstack([ss_img, rg_img]) cv2.imshow('', s_img) cv2.waitKey(0) 函数offset2xyxy(offset_box, p_cxywh_boxes)套用式(53)实现解码,同时将boxes信息转换为左上角、右下角的格式以方便绘图。在inference_code()函数中,调用线性回归权重face_mask_lr0.m,输入正样本的4096维特征信息,预测得到pre_offset的偏移值,然后调用u.offset2xyxy(pre_offset, p_boxes)将偏移值转换成预测框,如图516所示。 图516根据预测偏移值获得的预测框 图516中的预测框比SSBOX要差一些,其原因是本训练集只有1张图片,训练数据泛化能力不够。 总结 RCNN使用区域选择搜索算法,训练过程存在多次文件存储,需要消耗大量计算时间和空间资源。使用CNN进行特征提取,偏移值Reg回归预测框,IOU选择正样本,推理的非极大值抑制对于其他网络有极大的启发作用。 练习 运行并调试本节代码,理解算法思想与代码的结合。 5.3两阶段网络Faster RCNN 5.3.1模型介绍 Ross Girshick等在2016年发表的论文Faster RCNN: Towards RealTime Object Detection with Region Proposal Networks中提出Faster RCNN,在结构上Faster RCNN将特征提取、区域提取、边界框Reg回归、分类整合在了一个网络中,使检测速度有极大的提高,其网络结构如图517所示。 图517Faster RCNN网络结构 特征提取使用VGG16,其结构为223333(2为卷积池化,最后的3为全连接),但不包括最后全连接层,使用block5_conv3层的输出作为RPN、ROI网络的输入特征。 FasterRCNN为2阶段网络,第1个阶段为RPN网络,block5_conv3输入后经过3×3卷积,分成两个分支,通过1×1卷积输出2×9个特征,每个特征有9个建议框,每个建议框有目标或者没有目标,所以类别数为2。另一个1×1卷积输出4×9个特征,代表每个建议框的4个位置。RPN的输出,9个建议框的有目标、无目标的分类及9个建议框的位置。 Faster RCNN中的建议框相当于RCNN中的区域搜索框,不同之处在于RCNN是在原图中通过选择区域搜索算法生成,而Faster RCNN通过预设矩形框尺寸和比例在block5_conv3上生成。CNN提取的特征点相对于原图的感受野的区域如图518所示。 图518特征点在原图中的感受野 根据网络特征的提取,可以发现block5_conv3提取的特征刚好是原图的1/16,因此可以看成在原图中对图像每隔16像素进行均分,并以每个均分点为中心点,以尺寸[8,16,32]和比例[0.5,1,2]生成9个建议框,如图519所示。 图519中绿色框为真实框,红色框为生成的建议框。图中红色点坐标之间的区域相对于block5_conv3特征点信息,以每个红色点的坐标为中心点生成9个建议框,假设block5_conv3得到的是40×40,那么共计生成14400个建议框,则RPN的输出为14400×2个分类,14400×4个框。 图519Faster RCNN建议框的生成(见彩插) 将14400个建议框与真实框之间做IOU,当IOU大于设定值时,则选择此建议框作为正样本、有目标,然后按照式(52)计算建议框与真实框的偏移,作为真实值与预测值之间的损失(预测值为预测框基于建议框的偏移); 因为正样本少,负样本较多,选择当IOU小于设定值时,将此建议框作为负样本、无目标; 将两个阈值之间的建议框丢弃。正负样本数量之和可以设定为256,如图520所示,蓝色框为真实框,绿色框为建议框,两个建议框与真实框的IOU=0.708,则此时有两个正样本,计算offset作为y值。 图520Faster RCNN正样本的选择(见彩插) RPN的损失函数为 L({pi},{ti})=1Ncls∑iLcls(pi,p*i)+λ1Nreg∑ipiLreg(ti,t*i) Lreg(ti,t*i)=∑i∈(x,y,w,h)smoothL1(ti-t*i)(54) smoothL1(ti-t*i)=0.5x2如果|x|<1|x|-0.5其他 其中,i表示建议框数组的索引号,pi表示预测框有无目标的分类,p*i代表真实框有无目标的分类,分类损失使用交叉熵损失; ti代表预测框相对于建议框的偏移,t*i代表真实框相对于建议框的偏移,位置损失使用smoothL1损失。 回归损失可使用L1、L2损失,L1的导数为L1(x)x=1如果x≥0-1其他,L2的导数为L2(x)x=2x,而smoothL1的导数为smoothL1(x)x=x如果|x|<1±1其他,当x增大时L2损失对x的导数也增大,这就导致训练的初期,当预测值与真实值的差异过大时,损失函数对预测值的梯度较大,训练不稳定。当x变小时,L1对x的导数为常数,在训练后期当预测值与真实值的差异较小时,L1损失对预测值的导数的绝对值仍然为1,如果此时学习率不变,则损失函数将在稳定值附近波动,难以继续收敛以达到更高精度,而smoothL1损失则避免了L2、L1的缺点,如图521所示。 图521L1、L2、smoothL1损失 从图521中可知smoothL1在远离坐标点时下降得非常快,类似L1,而离坐标点较近时又类似L2有一个转折的平滑效果,下降会慢一些。 第2阶段为ROI网络,ROI网络的输入为block5_conv3的特征信息,以及RPN网络输出的预测建议框和有无目标的分类信息。图517中Proposal是RPN预测框信息,但是并没有全部输入ROI网络,而是将RPN输出信息解码成预测框,然后在预测框与建议框之间根据有无目标的概率做非极大值抑制,选出2000个重叠率不高的框作为Proposal,然后对Proposal的区域进行ROI Pooling以得到7×7的特征信息,输入全连接后再次微调BOX的位置信息,并预测目标区域是什么的分类信息,如图522所示。 图522ROI网络过程 图522中y_true为真实值,训练时需要将真实BOX的信息与选出来重叠率较低的RPN框做IOU,当大于阈值时为正样本,当小于阈值时为负样本,正负样本之和可自定义设置,例如128。 ROI Pooling对输入的特征进行提取后形成一个固定的区域,Proposal投影之后左上角的位置为[0,3],右下角为[7,8],然后划分成2×2的区域,对区域中的最大值进行提取,输出为2×2的特征,如图523所示。 图523ROI Pooling过程 因为Faster RCNN要求的输入图像的尺度为M×N,原图映射到特征图的大小为[M/16,N/16],不同的图像输入的尺度M、N值不同,但是通过ROI Pooling可以得到相同大小特征图的输出。因为其划分比例为3∶4,同时也可以看到是不同尺度最大池化信息的融合,这对于分类或回归是有益的。 ROI Pooling后进入全连接,并通过Reg回归再次精修位置,通过Softmax进行位置的回归,其损失函数同式(54)。 5.3.2代码实战RPN、ROI模型搭建 根据图517中的结构描述,在CNN特征提取时使用VGG16模型,同时使用ImageNet的权重信息作为初始权重。将VGG16中block5_conv3的输出作为RPN网络的输入,得到建议框的位置out_rpn_offset和有无目标out_rpn_clf,然后将block5_conv3的输出及roi_input的输入作为ROI网络的输入,输出分类预测和回归预测信息。 前向传播的代码如下: #第5章/ObjectDetection/FasterR-CNN/model.py import tensorflow as tf from keras.applications.VGG-16 import VGG-16 from keras.layers import Input, Conv2D, Reshape, \ Layer, Flatten, Dense, TimeDistributed from keras import Model def Faster R-CNN(input_shape=(640, 640, 3), roi_input_shape=(None, 4), num_anchors=9, num_class=2 + 1 ): #图片输入shape inputs = Input(shape=input_shape, name='image_input') #ROI Pooling 的输入维度 roi_input = Input(shape=roi_input_shape, name='roi_input') #调用Keras自带的VGG-16作为backbone,并且不要全连接层,并使用ImageNet的权重 #假设输入为600 × 600 × 3 base_model = VGG-16(weights=None, include_top=False) base_model.load_weights( "../R-CNN/VGG-16_weights_tf_dim_ordering_tf_kernels.h5", by_name=True, skip_mismatch=True ) #重新构建backbone网络,使用VGG-16的block5_conv3作为特征信息的输出 base_model = Model(inputs=base_model.input, outputs=base_model.get_layer('block5_conv3').output) #得到block5_conv3的特征 backbone_feature = base_model(inputs) #得到backbone的输出(None,None,512),作为RPN的输入 #RPN的作用: 根据先验框,得到建议框 out_rpn_clf, out_rpn_offset = rpn_proposal(backbone_feature, num_anchors) #RPN模块的网络 rpn_net = Model(inputs=inputs, outputs=[out_rpn_clf, out_rpn_offset]) #第2个阶段,根据输入的Feature Map特征信息,对区域BOX进行offset微调,同时对 #BOX属于哪个类别进行预测 out_class, out_regbox = roi_pooling_box_cls(backbone_feature, roi_input, num_class) #预测时为整个网络: 输入图片和ROI框的信息,以及输出类别和Reg BOX偏移值 all_net = Model(inputs=[inputs, roi_input], outputs=[out_class, out_regbox]) return rpn_net, all_net RPN网络的实现封装在函数rpn_proposal(backbone_feature, num_anchors)中,代码如下: #第5章/ObjectDetection/FasterR-CNN/model.py def rpn_proposal(backbone_feature, num_anchors=9): #候选区域网络。在R-CNN区域选择算法的基础上进行改进,每个像素生成9个建议框,有目 #标或无目标类别 #RPN以任意大小的图像作为输入,输出一组矩形的目标Proposals #每个Proposals都有一个目标得分,即有目标还是没有目标 None*None*512 x = Conv2D(512, (3, 3), padding='same', activation='relu', name='rpn_conv_3x3')(backbone_feature) #在Feature Map的基础上,每个特征点输出9个Anchor,每个Anchor有4个位置 #None × None × 36 p_box_x = Conv2D(4 * num_anchors, (1, 1), padding='same', activation='linear', name='rpn_box_conv_1x1')(x) #每个BOX分为有目标或者没有目标两个类别 #None × None × 18 p_conf_x = Conv2D(2 * num_anchors, (1, 1), padding='same', activation='softmax', name='rpn_cnf_conv_1x1')(x) #(h×w×2×num_anchors,1),即每个像素只有一个概率,则有无目标 out_rpn_clf = Reshape((-1, 1), name="rpn_p_conf")(p_conf_x) #(h×w×num_anchors,4),即每个像素有9个Anchor,每个Anchor有4个位置 out_rpn_offset = Reshape((-1, 4), name="rpn_p_box")(p_box_x) return out_rpn_clf, out_rpn_offset VGG16网络层block5_conv3的特征信息,经过3×3的卷积后,分别经过1×1的p_box_x位置预测,1×1的p_conf_x有无目标Softmax的预测。假设输入为640×640,则out_rpn_clf成[40×40×2×9,1]=[28800,1],out_rpn_offset为[40×40×9,4]=[14400,4]。 ROI网络封装在roi_pooling_box_cls(backbone_feature,roi_input,num_class)函数中,输入为block5_conv3的特征信息及RPN网络预测的Proposal,将其值赋给roi_input变量,经过全连接后输出分类out_class和out_regbox位置信息,代码如下: #第5章/ObjectDetection/FasterR-CNN/model.py def roi_pooling_box_cls(feature_map_input, roi_input, num_class=2 + 1): #实现roi_pool的过程 roi_pool = RoiPooling()([feature_map_input, roi_input]) #根据roi_pool使用两个全连接层,分别输出BOX的偏移和类别的概率 #不加TimeDistributed得到的是None,None。加了之后得到的是None,None,25088 #TimeDistributed实现了在每个num_rois上面进行一个全连接操作,实现多对多的功能 x = TimeDistributed(Flatten(name='flatten'))(roi_pool) #batch_size,h,w,1024 x = Dense(1024, activation='relu', name='fc1')(x) x = Dense(1024, activation='relu', name='fc2')(x) #分类的概率 out_class = Dense(num_class, activation='softmax')(x) #每个类别的4个位置 out_regbox = Dense(4 * (num_class - 1), activation='linear')(x) return [out_class, out_regbox] out_class中包含“分类+有无目标”的概率,假设类别为2,Proposal为2000个,则out_class输出是2000×3,out_regbox为每个类别的BOX信息,输出为2000×8。 ROI Pooling在RoiPooling()([feature_map_input, roi_input])中实现,主要调用tf.image.crop_and_resize()函数实现7×7区域的Pooling,代码如下: #第5章/ObjectDetection/FasterR-CNN/model.py class RoiPooling(Layer): #Region of Interest,将不同大小的特征图ROI Pooling到同一个尺寸 def __init__(self): super(RoiPooling, self).__init__() self.pool_size = (7, 7) def build(self, input_shape): self.out_channel = input_shape[0][3] def compute_output_shape(self, input_shape): input_shape2 = input_shape[1] return None, input_shape2[1], self.pool_size[0], self.pool_size[1], self.out_channel def call(self, inputs, *args, **kwargs): #输入block5_conv3特征图及RPN #40×40×512, 2000×4 feature_map, roi_x = inputs #区域选择框的数量batch,2k batch_size, num_roi = tf.shape(feature_map)[0], tf.shape(roi_x)[1] #假设batch_size=2,则[0,1]-->[[0],[1]] index = tf.expand_dims(tf.range(0, batch_size), 1) #假设num_roi=2,则[[0],[1]]-->[[0,0],[1,1]] index = tf.tile(index, (1, num_roi)) #由[[0,0],[1,1]]变回[0,0,1,1] index = tf.reshape(index, [-1]) #2000×7×7×512 roi_pooling_feature = tf.image.crop_and_resize( feature_map, #特征图 tf.reshape(roi_x, [-1, 4]), #BOX的个数,每个有4个位置信息 index, #roi_x与特征图下标的对应关系 self.pool_size #裁剪到特征图的大小 ) #batch_size × 2000 × 7 × 7 × 512 output = tf.reshape( roi_pooling_feature, (batch_size, num_roi, self.pool_size[0], self.pool_size[1], self.out_channel) ) return output 如代码输入的是40×40×512的特征,2000×4的Proposal,经过ROI Polling后输出是2000×7×7×512。整个网络模块搭建的思路是按功能、按算子分别进行封装,然后组建成Faster RCNN模型,并根据需要可调用RPN或者ROI网络。 5.3.3代码实战RPN损失函数及训练 代码实现损失函数通常需要如图524所示的步骤。 图524损失函数实现过程 因为要做损失,所以需要先计算y值,y值需要在特征图上计算建议框(Anchor)与真实框IOU,并将满足IOU分数的Anchor计算偏移,并记录有目标的Anchor的索引index,及选取为负样本的index,然后在前向传播预测后,根据index信息从预测值中取对应的预测偏移值、预测正负样本的分类信息,按损失函数公式进行计算,然后网络会根据损失函数进行反向传播并学习权重参数。整个过程实现较复杂,但比较关键的是如何计算y值。 首先看Anchor的生成,代码如下: #第5章/ObjectDetection/FasterR-CNN/anchors.py import numpy as np import utils as u #生成Anchor默认的各种尺寸 def generate_anchors(base_size=16, ratios=[0.5, 1, 2], anchor_scales=[8, 16, 32]): #M×N/16,映射到特征图上的大小,如果目标较小或者Feature Map较小,则此值需要调节 #需要跟Feature Map相等 py, px = base_size / 2., base_size / 2. #Anchor的3种比例 num_ratios = len(ratios) #3种Anchor的尺寸 num_scales = len(anchor_scales) #初始全为0的矩阵 3× 3 = 9个Anchor anchor_base = np.zeros([num_ratios * num_scales, 4], dtype=np.float32) #每个以不同比例生成不同h和w的Anchor for i in range(num_ratios): for j in range(num_scales): #Anchor的height和width #h=16×8×sqrt(0.5)=90。对于小目标来讲,此值较大。需要调节比例或者尺寸 h = base_size * anchor_scales[j] * np.sqrt(ratios[i]) #w=16×8×1/sqrt(0.5)=181 w = base_size * anchor_scales[j] * np.sqrt(1. / ratios[i]) index = i * num_scales + j #计算每个框的xmin,ymin,xmax,ymax anchor_base[index, 0] = py - h / 2. anchor_base[index, 1] = px - w / 2. anchor_base[index, 2] = py + h / 2. anchor_base[index, 3] = px + w / 2. return anchor_base def mapping_anchor_2_original_image( anchor_base, #基本Anchor的尺寸 feature_hw_size, #特征图的尺寸 feature_stride=16, #M/16 anchor_num=9, image=None, gt_boxes=None ): #feature_hw_size 特征图的尺寸 h, w = feature_hw_size #每隔16生成一个坐标信息,h×feature_stride即原图的大小。也就是原图被分成了16份 #每隔16,将原图分为40份,宽和高各为640。也就是此时1像素,映射到原图为16×16的区域 shift_y = np.arange(0, h * feature_stride, feature_stride) shift_x = np.arange(0, w * feature_stride, feature_stride) #组成(x,y)的坐标信息 shift_x, shift_y = np.meshgrid(shift_x, shift_y) shift = np.stack((shift_y.ravel(), shift_x.ravel(), shift_y.ravel(), shift_x.ravel()), axis=1) #在每个坐标中都成9个Anchor,并且9个Anchor根据坐标的位置进行相应调整 #1×9×4 + 1×64×4 anchor = anchor_base.reshape([1, anchor_num, 4]) + \ shift.reshape([1, shift.shape[0], 4]).transpose([1, 0, 2]) #以第693个坐标点为中心,绘Anchor #u.draw_anchor(image, shift_x, shift_y, anchor[693, ...],gt_boxes) #reshape成每个特征点都有4个位置,即(num_box,4) anchor = anchor.reshape([shift.shape[0] * anchor_num, 4]).astype(np.float32) return anchor 在函数generate_anchors(base_size=16,ratios=[0.5,1,2],anchor_scales=[8,16,32])中根据经验值将Anchor的比例先验设置为[0.5,1,2],然后每个比例预测尺寸为[8,16,32],通过base_size×anchor_scales[j]×np.sqrt(ratios[i])和base_size×anchor_scales[j]×np.sqrt(1./ratios[i])来计算Anchor的大小。每个坐标点都会生成9个Anchor。 在mapping_anchor_2_original_image()函数中feature_hw_size为特征图的尺寸,将原图划分为np.arange(0,h×feature_stride,feature_stride)份,假设输入为640×640,则为np.arange(0,40×16,16),然后沿x轴和y轴进行复制,并且在每个坐标中对Anchor共9个框进行复制,如果可视化,则可得图518。 然后根据图524中的结构,计算offset、正样本、负样本,代码如下: #第5章/ObjectDetection/FasterR-CNN/data_processing.py def assign( self, true_boxes, pos_threshold=0.7, neg_threshold=0.3, num_sample=256, #正样本+负样本,一共只能有256个 image=None, old_boxes=None ): anchor_num, feature_stride = 9, 16 feature_hw_size = np.array(self.input_shape) //feature_stride #各种Anchor的比例,得到的是xmin,ymin,xmax,ymax anchor_base = a.generate_anchors(base_size=feature_stride) #以640×640输入时,在每幅图中生成14400×4个BOX的信息 anchors = a.mapping_anchor_2_original_image( anchor_base, feature_hw_size, feature_stride, anchor_num, image, gt_boxes=old_boxes ) #Anchor归一化 anchors = anchors / self.input_shape[0] #初始化1个14400×4为0的BOX信息 true_boxes_assign = np.zeros([anchors.shape[0], 4]) #[14400,2],初始概率为[0,1]。0代表背景的概率,1代表前景的概率 true_boxes_assign_clf = np.zeros([anchors.shape[0], 2]) #遍历每个GT BOX,计算IOU后的偏移 for i in range(len(true_boxes)): #计算Anchor与GT BOX的IOU iou_score = u.get_iou(true_boxes[i], anchors) #区域搜索出来的BOX与真实框>0.7的BOX信息,当为正样本数据时正样本有目标, #所以类别为1 #得到的是满足条件的索引index pos_index = np.argwhere(iou_score >= pos_threshold) if not pos_index.shape[0]: #如果没有一个IOU的值大于0.5,则获取最大的那个BOX,并置为有目标 pos_index = np.argmax(iou_score) num_pos = 1 else: num_pos = pos_index.shape[0] #根据正样本的index取出正样本的Anchor positive_box = anchors[pos_index] #正样本可视化 #anchor_img = u.draw_box(image,positive_box.reshape([-1,4])*640) #GT BOX可视化 #u.draw_box(anchor_img,true_boxes[i][0:4].reshape([-1,4])*640, #color=[255,0,0]) #offset计算时,需要转换成cx,cy,w,h a_class_box = u.xyxy2cxcywh(positive_box) #正样本 g_class_box = u.xyxy2cxcywh(true_boxes[i]) #GT BOX #计算偏移值 offset_box = u.cxywh2offset(g_class_box, a_class_box) #正样本的偏移值,更新到true_boxes_assign中 true_boxes_assign[pos_index] = offset_box #RPN不用读取分类信息。正样本的np.array([1, 0])代表前景概率为1,背景概率为0 true_boxes_assign_clf[pos_index] = np.array([1, 0]) #负样本<0.3并且256-正样本的数量为负样本 neg_index = np.argwhere(iou_score < neg_threshold) neg_num = abs(num_sample - num_pos) neg_index = neg_index[:neg_num] true_boxes_assign_clf[neg_index] = np.array([0, 1]) #reshape成[14400×2,1] true_boxes_assign_clf = np.reshape(true_boxes_assign_clf, [-1, 1]) #输出[14400×4],[14400×2,1] return true_boxes_assign, true_boxes_assign_clf, anchors 代码中Anchors的生成调用mapping_anchor_2_original_image()函数,然后遍历每个true_boxes通过get_iou(true_boxes[i],anchors)计算IOU和得分。根据pos_index=np.argwhere(iou_score>=pos_threshold)得到满足阈值数组的index,然后由positive_box=anchors[pos_index]取出正样本的信息,由offset_box=u.cxywh2offset(g_class_box,a_class_box)得到正样本的偏移值。最后将编码的值赋给true_boxes_assign,true_boxes_assign_clf得到真实的偏移值和有无目标的分类概率。 RPN损失函数的构建,代码如下: #第5章/ObjectDetection/FasterR-CNN/loss.py import tensorflow as tf def l1_smooth_loss(y_true, y_pred): """回归损失""" abs_loss = tf.abs(y_true - y_pred) sq_loss = 0.5 * (y_true - y_pred) ** 2 l1_loss = tf.where(tf.less(abs_loss, 1.0), sq_loss, abs_loss - 0.5) return tf.reduce_sum(l1_loss, -1) def cross_entropy_loss(y_true, y_pred): """交叉熵损失""" y_pred = tf.maximum(y_pred, 1e-8) softmax_loss = -tf.reduce_sum(y_true * tf.math.log(y_pred), axis=-1) return softmax_loss def rpn_loss(y_pre, true_box, true_clf): batch_size = true_box.shape[0] #由NumPy转换成Tensor类型 true_box = tf.cast(true_box, dtype=tf.float32) true_clf = tf.cast(true_clf, dtype=tf.float32) #预测值 (b,14400,4) (b,28800,1) pre_box, pre_clf = y_pre[1], y_pre[0] #当y_true传过来时,true_clf已包括正样本和负样本,并且均有设置 #因为true_clf默认为[0,0],当有目标时是[1,0],当没有目标时是[0,1] #reshape后变成[[0],[0]],[[1],[0]],[[0],[1]], #而交叉熵-y_ture*log(y_pre),如果y_true为0,则最后的值为0,所以不再取正样本 clf_loss = tf.reduce_mean( cross_entropy_loss(true_clf, pre_clf) ) #因为如果是负样本,则true_box对应的值为0 pos_mask = true_box[..., :4] != tf.convert_to_tensor([0., 0., 0., 0.]) #只选正样本的偏移值 box_loss = tf.reduce_mean( l1_smooth_loss(true_box[pos_mask], pre_box[pos_mask]) ) total = clf_loss + box_loss return total / batch_size 函数l1_smooth_loss()封装了Smooth损失,cross_entropy_loss()则封装了交叉熵损失,rpn_loss(y_pre,true_box,true_clf)对RPN有无目标进行分类损失和正样本offset的回归损失。损失函数的构建需要用深度学习框架构建,否则在反向传播时求出的梯度值有可能为None。rpn_loss()构建的复杂、简易度受assign()函数的影响。 训练时只需读取DataProcessingAndEnhancement()类中的generate()方法,根据参数设置传输数据,代码如下: #第5章/ObjectDetection/FasterR-CNN/data_processing.py def generate(self, isTraining=True, isRoi=False): #训练时将训练集打乱,不训练时使用验证集 shuffle(self.train_lines) lines = self.train_lines if isTraining else self.val_lines #batch存储相关字段 inputs = [] true_box_list = [] true_clf_list = [] roi_offset_list = [] roi_class_list = [] #循环处理数据集中的数据 for row in lines: #将图像和BOX缩放到指定的尺寸 img, y = self.get_image_processing_results(row) if len(y) != 0: boxes = np.array(y[:, :4], dtype=np.float32) #对label进行归一化 old_boxes = boxes.copy() boxes = boxes / np.array(self.input_shape[0:2] + self.input_shape[0:2]) #对label构建one_hot编码 one_hot_label = np.eye(self.num_classes)[np.array(y[:, 4], np.int32)] #将max xy - min xy <0 的label过滤掉 if ((boxes[:, 3] - boxes[:, 1]) <= 0).any() and ((boxes[:, 2] - boxes[:, 0]) <= 0).any(): continue #组成 xmin,ymin,xmax,ymax,[0,1] y = np.concatenate([boxes, one_hot_label], axis=-1) true_boxes_assign, true_boxes_assign_clf, anchors = self.assign(y, image=img, old_boxes=old_boxes) if isRoi: roi_offset_y, roi_class = self.roi_generate(img, anchors, y) roi_offset_list.append(roi_offset_y) roi_class_list.append(roi_class) inputs.append(img) true_box_list.append(true_boxes_assign) true_clf_list.append(true_boxes_assign_clf) #按batch_size传输targets if len(inputs) == self.batch_size: tmp_inp = np.array(inputs, dtype=np.float32) if isRoi: tmp_roi = np.array(roi_offset_list) tmp_roi_class = np.array(roi_class_list) roi_offset_list = [] roi_class_list = [] inputs = [] yield tmp_inp, tmp_roi, tmp_roi_class else: tmp_box = np.array(true_box_list) tmp_clf = np.array(true_clf_list) true_box_list = [] true_clf_list = [] inputs = [] yield tmp_inp, tmp_box, tmp_clf 方法generate()主要根据isRoi的设定返回RPN的训练数据或者ROI的训练数据,如果是RNP数据,则通过self.assign()方法得到Anchor与GT BOX的offset和class分类信息。self.get_image_processing_results()仅实现了输入尺寸M×N的调节及图像的归一化功能。当len(inputs)==self.batch_size相等时,通过yield生成器返回tmp_inp图片、tmp_boxanchor与GT BOX的offset、tmp_clf有无目标的分类信息。 训练代码相对容易,只需读取generate()的数据,然后调用train_step()实现反向传播,代码如下: #第5章/ObjectDetection/FasterR-CNN/rpn_train.py def train_step(model, features, true_box, true_clf): with tf.GradientTape() as tape: #置信度是batch×28 800×1 #位置是batch×14 400×4 predictions = model(features, training=True) #传入RPN预测的分类和位置信息,并且传入true_box信息 loss = rpn_loss(predictions, true_box, true_clf) #求梯度 gradients = tape.gradient(loss, model.trainable_variables) #反向传播 optimizer.apply_gradients(zip(gradients, model.trainable_variables)) #更新loss信息 train_loss.update_state(loss) train_metric.update_state(true_clf, predictions[0]) global_steps.assign_add(1) 代码loss=rpn_loss(predictions,true_box,true_clf)中的predictions包含pre_box预测框基于Anchor的偏移,以及pre_clf预测框有无目标的概率,此时维度要跟GT BOX的信息保持一致,这样就更方便rpn_loss()函数进行计算。更多更详细的代码可参考随书代码。 5.3.4代码实战ROI损失函数及训练 经过RPN的训练此时14400×4个预测框基于Anchor的偏移,根据图522的结构需要选出2000个重叠率较低的框,然后将2000个框与GT BOX再进行1次偏移作为roi_loss()中的y_true,此过程封装在代码roi_generate()中,代码如下: #第5章/ObjectDetection/FasterR-CNN/data_processing.py def roi_generate(self, img, anchors, true_box, input_shape=[640, 640, 3]): rpn_model, all_model = Faster R-CNN( input_shape=input_shape, roi_input_shape=[None, 4], num_anchors=9, num_class=2 + 1 ) #加载上一步训练的权重 rpn_model.load_weights('./weights/last.h5') #输入图片的特征,输出 pre_rpn_cls, pre_rpn_box = rpn_model(np.expand_dims(img, axis=0)) #如果启动ROI,则根据y值和上一次的权重得到区域选择框 #得到2000个roi(1,2000,4) pre_roi = pre_roi_2_box(pre_rpn_box, pre_rpn_cls, anchors, np.array(self.input_shape[0:2])) #u.draw_box(img * 255, pre_roi.reshape([2000, 4]) * 640) #构建ROI网络的y值 2000×8 roi_y = gt_box_2_roi(true_box, pre_roi) return roi_y 代码rpn_model.load_weights()调用RPN网络的权重,然后前向传播得到pre_rpn_cls,pre_rpn_box的输出,然后通过pre_roi_2_box()函数得到2000个建议框,gt_box_2_roi()函数得到ROI的y_true。 函数pre_roi_2_box()的实现,代码如下: #第5章/ObjectDetection/FasterR-CNN/proposal.py import numpy as np import utils as u import tensorflow as tf def pre_roi_2_box(rpn_box_loc, rpn_box_score, anchor, img_size, num_pre=12000, nms_thresh=0.7, ss_num=2000): #根据RPN网络输入BOX Loc 和BOX Score,以及Anchor #选择输出2000个训练rois。注意此时没有GT BOX的事情 #因为Anchor是xmin,ymin,xmax,ymax转换成x,y,w,h的形式 anchor = u.xyxy2cxcywh(anchor) batch = rpn_box_loc.shape[0] #因为RPN输出的是offset值,所以需要将其解码成xmin,ymin,xmax,ymax roi_box = u.offset2xyxy(rpn_box_loc.NumPy(), anchor) #对roi_box中的BOX进行裁剪 roi_box[:, slice(0, 4, 2)] = np.clip(roi_box[:, slice(0, 4, 2)], 0, img_size[0]) roi_box[:, slice(1, 4, 2)] = np.clip(roi_box[:, slice(1, 4, 2)], 0, img_size[1]) ######################### #得到满足目标大小的置信度分数[1, 0] rpn_box_score = np.reshape(rpn_box_score, [batch, -1, 2]) pos_score = rpn_box_score[..., 0] #得到前景的分数 #对置信度的分数进行降序排列 order = tf.argsort(pos_score, direction='DESCENDING').NumPy() #取前12000个预测框 order = order.ravel()[:num_pre] #对ROI进行过滤,只要120000个置信度较大的框 roi = roi_box[..., order, :] score = pos_score[..., order] #使用NMS去掉交并比>0.7的框,留下重叠率不高的框,最多只有2000个 #non_max_suppression()得到的是ROI中的index keep = tf.image.non_max_suppression( np.reshape(roi, roi.shape[1:]), np.reshape(score, score.shape[1:]), max_output_size=ss_num, iou_threshold=nms_thresh ) #利用Anchor和RPN网络预测出来的offset,选取可能有目标最大的前2000个框,作为建议框 roi = roi[..., keep, :] return roi 因为RPN预测输出的是offset,所以调用offset2xyxy()进行解码以得到左上、右下坐标,此时BOX信息较多,然后根据pos_score=rpn_box_score[...,0]有无目标概率的得分进行非极大值抑制,从而保留重叠率不高的框,共计2000个,所以此函数的输出为2000×4,代码如下: #第5章/ObjectDetection/FasterR-CNN/proposal.py def gt_box_2_roi(gt_box, roi, sample_num=128, pos_iou_thresh=0.7, neg_iou_thresh=0.1, num_class=2 + 1): #RPN产生了2000个区域选择框,但是并没有将2000个框全用作训练,而是将2000个框与GT BOX做IOU,一共选择128个正样本 #假设IOU>0.7的正样本选择32个。IOU<0.3的负样本选择128-32=96个 roi = np.reshape(roi, roi.shape[1:]) #初始为0的数组,用来保存计算后的值 true_offset = np.zeros([roi.shape[0], 4 * (num_class - 1)]) true_class = np.zeros([roi.shape[0], num_class]) for box in gt_box: #如果此时的BOX类别假设为[1,0,0] #如果此时的BOX类别假设为[0,1,0],offset则更新到1的位置 #再次计算每个GT BOX与ROI的IOU值 iou_score = u.get_iou(box[:4], roi[..., :4]) #如果IOU>0.7,则全记为正样本 pos_iou = iou_score > pos_iou_thresh if not sum(pos_iou): pos_index = np.argmax(iou_score) else: pos_index = np.argwhere(pos_iou == True) num_pos = pos_index.size #如果IOU<0.3,则为负样本 neg_iou = iou_score < neg_iou_thresh neg_index = np.argwhere(neg_iou == True) if pos_index.size != 1: pos_index = pos_index[:num_pos] #负样本的数量 neg_index = neg_index[:sample_num - num_pos] #取出正样本的BOX pos_box = roi[pos_index] pos_box = np.reshape(pos_box, [-1, 4]) #计算cx,cy,w,h pos_box = u.xyxy2cxcywh(pos_box) gt_box = u.xyxy2cxcywh(gt_box) #然后对正样本的BOX进行编码 roi_offset = u.cxywh2offset(box, pos_box) for i in range(num_class - 1): if box[4 + i] == 1: start = i * 4 #正样本的offset true_offset[pos_index.flat, start:4 + start] = roi_offset #负样本只填充负样本的分类信息 neg_class = np.zeros(num_class) neg_class[-1] = 1 #[0,0,1]代表负样本 #将计算后的值赋给true_class true_class[neg_index, :] = neg_class true_class[pos_index, :] = box[4:] return true_offset, true_class 函数gt_box_2_roi()实现将输入的2000个建议框与GT BOX之间做IOU,如果IOU>0.7,则为正样本并计算offset,如果IOU<0.3,则为负样本,则正负样本之和为128(可调)。因为true_offset的输出为2000×4×(num_class-1),在此函数为2000×8,即每个框可能为两个分类的偏移,所以当if box[4+i]==1时,当前分类才赋值true_offset[pos_index.flat,start:4+start]=roi_offset。 接下来是损失函数的构建,代码如下: #第5章/ObjectDetection/FasterR-CNN/loss.py def roi_loss(y_pre, roi, roi_class, num_class=2 + 1): #预测信息 2000×3和2000×8 out_class, out_regbox = y_pre #真实值 2000×8和2000×3 y_true = tf.cast(roi, tf.float32) true_cls = tf.cast(roi_class, tf.float32) batch_size = y_true.shape[0] #只要位置信息 true_box = y_true[..., :8] #因为true_cls默认为[0,0,0],负样本是[0,0,1],正样本是[1,0,0] #所以-y_true * log(y_pred),不用提取出负样本的分类 clf_loss = tf.reduce_mean( cross_entropy_loss(true_cls, out_class) ) #按每个类别去求正样本BOX offset的损失 box_loss = 0 for i in range(num_class - 1): #因为如果是负样本,true_box对应的值为0,所以只选正样本的偏移值 mask = tf.reshape(true_cls[..., i], [batch_size, -1, 1]) ind = tf.where(mask == 1) #根据index取值 pos_true_box = tf.gather_nd(true_box, ind) pos_out_box = tf.gather_nd(out_regbox, ind) box_loss += tf.reduce_mean( l1_smooth_loss(pos_true_box, pos_out_box) ) total = clf_loss + box_loss return total / batch_size 函数roi_loss()与rpn_loss()与此类似,不同之处在于求解了预测BOX的分类信息,同时实现时按每个类别的offset损失进行求和。 训练代码train_step()与RPN类似,不同之处在于predictions=model([features,roi_box[...,:4]],training=True)时传入图像信息及2000个Proposal信息。更多更详细的代码可参考随书代码。 5.3.5代码实战预测推理 Faster RCNN的推理首先要经过RPN网络得到14400×2个分类、14400×4个框,再经过NMS选出2000×4个框,然后送入ROI网络,假设分类数量为2,则类别概率为2000×3(2+1前景/背景),每个类别的BOX信息为2000×8(有目标的BOX和没有目标的BOX),其详细的推理代码如下: #第5章/ObjectDetection/FasterR-CNN/detected.py class Detected(): def __init__(self, rpn_path, roi_path, input_size): #读取模型和权重 self.rpn_model = load_model( rpn_path, custom_objects={'rpn_loss': rpn_loss} ) self.roi_model = load_model( roi_path, custom_objects={'roi_loss': roi_loss} ) self.confidence_threshold = 0.5 #有无目标置信度 self.class_prob = [0.5, 0.5] #两个类别的分类阈值 self.nms_threshold = 0.5 #NMS的阈值 self.input_size = input_size def generate_anchor(self): #默认Anchor的生成 anchor_num, feature_stride = 9, 16 feature_hw_size = np.array(self.input_size) //feature_stride #各种Anchor的比例, 得到的是xmin,ymin,xmax,ymax anchor_base = a.generate_anchors(base_size=feature_stride) #以640×640输入时,在每幅图中生成14400×4个BOX的信息 anchors = a.mapping_anchor_2_original_image( anchor_base, feature_hw_size, feature_stride, anchor_num ) #Anchor归一化 anchors = anchors / self.input_size[0] self.anchors = anchors def readImg(self, img_path=None): #读取要预测的图片 img = cv2.imread(img_path) #将图片转换为640×640 self.img, _ = u.letterbox_image(img, self.input_size, []) def rpn_forward(self): #读取图片以进行前向传播 img_tensor = tf.expand_dims(self.img / 255.0, axis=0) #前向传播 self.output = self.rpn_model.predict(img_tensor) self.old_img = self.img.copy() def rpn_nms2k(self): #预测置信度的结果为14400×2 pre_rpn_cls = tf.cast(self.output[0], dtype=tf.float32) #预测框的偏移 pre_rpn_box = tf.cast(self.output[1], dtype=tf.float32) #pre_roi_2_box已集成根据预测框进行解码操作,并根据置信度的得分进行NMS去重, #得到2000×4个框 #14400× 4, 28800×1 pre_roi = pre_roi_2_box(pre_rpn_box, pre_rpn_cls, self.anchors, np.array(self.input_size[0:2]), isReturnAnchor=True) #喂入ROI网络此时的BOX信息 self.pre_roi = pre_roi[0] #喂入ROI网络此时BOX对应的Anchor值 self.pre_anchor = pre_roi[1] def roi_forward(self): img_tensor = tf.expand_dims(self.img / 255.0, axis=0) #ROI前向传播,得到 2000×3和2000×8 self.output = self.roi_model.predict([img_tensor, self.pre_roi]) #分类+置信度的值 self.pre_class = self.output[0] #BOX的值,但是输出为2000×8,其中有4位是没有目标的框 #此时仍为偏移值 self.pre_box = self.output[1] def _roi_box_decode(self, pre_box): #转换成cx,cy,w,h。只取喂入ROI网络的2000个Anchor anchor = utils.xyxy2cxcywh(self.pre_anchor) #解码操作封装在offset2xyxy函数中 decode_box = utils.offset2xyxy(pre_box, anchor) #裁剪值域在[0,1] decode_box = np.clip(decode_box, 0, 1) return decode_box def classification_filtering(self): #首先根据置信度过滤 #因为在训练时 [0,0,1]代表负样本,所以只有最后1位小于阈值时才代表有目标 #即背景的概率越小越好 obj_mask = self.pre_class[..., -1] < self.confidence_threshold #将ROI输出的预测值BOX与RPN输出的最佳2000个框进行解码操作 #并且只传前4个位置的,因为后面4个位置的是背景所处的位置 boxes = self._roi_box_decode(self.pre_box[..., :4]) #根据类别的概率过滤 for i, p in enumerate(self.class_prob): if len(obj_mask): #根据obj_mask过滤分类 cls_obj = self.pre_class[obj_mask] #类别的得分要超过阈值 cls_obj_mask = cls_obj[..., i] > p classification = cls_obj[cls_obj_mask] #根据置信度和类别的mask过滤出对应boxes中的信息 class_boxes = boxes[obj_mask][cls_obj_mask] class_score = classification[..., i] #将BOX与分类得分合并 box = np.concatenate([class_boxes, np.reshape(class_score, [-1, 1])], axis=-1) #NMS得到的索引 index = u.nms(box, nms_thresh=self.nms_threshold) #根据索引取出boxes信息 box = class_boxes[index] #绘图 img = u.draw_box(self.old_img, box) u.show(img) if __name__ == "__main__": rpn_path = "./weights/last" roi_path = "./weightsRoi/RoiLast" img_path = "../val_data/pexels-photo-5211438.jpeg" #实例化对象 det = Detected(rpn_path, roi_path, [640, 640]) #读取预测图片 det.readImg(img_path) #生成Anchor det.generate_anchor() #################### #RPN网络前向传播 det.rpn_forward() #RPN网络解析得到2000个框 det.rpn_nms2k() #################### #ROI前向传播 det.roi_forward() #对最后的结果解码 det.classification_filtering() 因为Faster RCNN是两阶段网络,所以需要分别加载RPN、ROI的权重进行预测。rpn_forward(self)实现读取图片的前向传播,然后调用rpn_nms2k(self)得到2000个候选框,并同时得到此时相对应的2000个Anchor的值。将ROI的2000个框在roi_forward(self)进行预测self.roi_model.predict([img_tensor,self.pre_roi]),获得分类和置信度的输出self.pre_class及最终的BOX信息self.pre_box。在classification_filtering(self)方法中根据置信度为前景的概率和分类得分进行过滤,并对self.pre_box进行由偏移值转向位置值的解码操作,最后经过NMS非极大值抑制到得最终的结果。 总结 Faster RCNN延续了RCNN中的思想,分为两个阶段。第1个阶段,RPN网络用来提取2000个前景、背景框; 第2个阶段,网络输入特征信息和2000个框,进行分类+背景及BOX的微调。 练习 运行并调试本节代码,理解算法的设计与代码的结合。 5.4单阶段多尺度检测网络SSD 5.4.1模型介绍 SSD是于2015年由Wei Liu等在发表的论文SSD: Single Shot MultiBox Detector中提出的单阶段、多检测头的目标检测网络,其网络结构如图525所示。 在特征提取阶段使用VGG16(结构为2—2—3—3—3—3,2卷积,1池化,最后3为全连接),并选取Conv4_3(第4个层的最后1个卷积)输出38×38×512作为classifier1检测头的输入。当时没有使用BN归一化,而Conv4_3提取的特征值较大,所以使用Normalization归一化以防止梯度爆炸。 将VGG16中的全连接FC6层更换为卷积,并使用空洞卷积,空洞率为6×6,增大感受野。SSD的源码在Conv5_3后的Pooling由2×2-s2更换为3×3-s1,原有FC6在Conv5_3上的感受野为14×14,而FC6由原来的7×7变成3×3后,使用空洞卷积3+(3+2(n-1)-1)×1=14,算出来n=5.5,向上取整为6,使FC6的感受野与VGG16时保持一致,然后将FC7更换为1×1×1024卷积,得到输出19×19×1024作为classifier2检测头的输入。 在VGG16的基础上添加Extra Layer,Conv8_2由1×1×256、3×3×512-s2输出10×10×512作为classifier3检测头的输入; 同理Conv9_2由1×1×128、3×3×256-s2输出为5×5×256作为classifier4检测头的输入; Conv10_2由1×1×128、3×3×256-s1并且卷积valid丢弃,输出为3×3×256作为classifier5检测头的输入; Conv11_2与此类似,输出为1×1×256作为classifier6检测头的输入。 图525SSD网络结构 共计6个特征作为6个检测头的输入,并且由classifier1检测小目标、classifier6检测大目标(classifier6的感受野最大,所以检测大目标),中间的检测头次之。 得到特征信息后,分别输入两个3×3卷积,如classifier1输出38×38×4,即BOX的4个偏移位置信息,38×38×(1+num_class)的分类信息,其中1代表BOX有无目标。另外一个分支输出Default BOX指在38×38的特征层上生成4个建议框,即每个特征图对应到原图约300/38=7.9的区域,那么一共就有38×38×4=5776个建议框,并随着classifier1进行输出,SSD检测头有4个,分别是位置偏移值、置信度(有无目标)及分类概率、Default BOX。Default BOX本来是5776×4,但是因为offset的值较小,作者对(tx,ty)/variance 0.1、(tw,th)/variance 0.2进行了放大,防止梯度消失,所以其输出变成了5776×8。 建议框为classifier1分配4个,为classifier2、classifier3、classifier4分配6个,为classifier5、classifier6分配4个,共计38×38×4+19×19×6+10×10×6+5×5×6+3×3×4+1×1×4=8732个建议框,所以SSD的输出为8732×4个框,8732×(1+num)分类概率,8732×8个Default BOX,合并后输出为8732×(4+1cls+num+4Default BOX+4variance)。 综上,SSD拥有6个检测头,其特征信息使用Conv4_3、Conv7、Conv8_2、Conv9_2、Conv10_2、Conv11_2的输出,每个特征信息分配4、6、6、6、4、4个框,直接输出8732×(4+1cls+num+4Default BOX+4variance),不再经过Faster RCNN中的RPN选出建议框,直接预测极大地提高了检测速度。同时由于使用了6个不同尺度的特征信息和不同尺度的Default BOX,3×3卷积分别进行位置和分类预测,对于模型的精度也有极大的提高。 SSD的建议框生成过程首先经过以下公式计算最小、最大高宽的尺寸。 Sk=Smin+Smax-Sminm-1(k-1),k∈[1,m](55) 公式中m指有几个检测头,所以m=6,同时原文初始设定Smin=0.2,Smax=0.9。 实际在计算时,由于考虑到第1个检测头Sk=0.2×300=60作为max_size1,而将min_size1=60×1/2=30,所以求其他检测头的尺寸时设置m=5,故Smax-Sminm-1=0.9-0.25-1≈0.17,然后max_size2=(0.2+0.17×1)×300=111,max_size3=162,max_size4=213,max_size5=264,max_size6=315,再将上一个检测头的最大值作为下一个检测头的最小值,见表51。 表51SSD建议框默认尺寸 特征层名称 特征层尺寸 min_size(k) max_size(k) 比例 step Conv4_3 38×38 30 60 1∶1,1∶2,2∶1,1∶1 8 Conv7 19×19 60 111 1∶1,1∶2,2∶1,1∶3,3∶1,1∶1 16 Conv8_2 10×10 111 162 1∶1,1∶2,2∶1,1∶3,3∶1,1∶1 32 Conv9_2 5×5 162 213 1∶1,1∶2,2∶1,1∶3,3∶1,1∶1 64 Conv10_2 3×3 213 264 1∶1,1∶2,2∶1,1∶1 100 Conv11_2 1×1 264 315 1∶1,1∶2,2∶1,1∶1 300 第1个1∶1的比例,宽和高wh=[30,30]; 1∶2时为wh=[30×2,30×1/2]=[42,21]; 2∶1时wh=[30×1/2,30×2]=[21,42],最后1个1∶1为wh=30×60∶30×60=42∶42,其他层与此类似,最后得到建议框的宽和高见表52。 表52SSD建议框宽和高默认值 特征层名称 特征层尺寸 建议框宽和高 Conv4_3 38×38 [30,30]、[42,21]、[21,42]、[42,42] Conv7 19×19 [60,60]、[84,42]、[42,84]、[103,34]、[34,103]、[81,81] Conv8_2 10×10 [111,111]、[156,78]、[78,156]、[192,64]、[64,192]、[134,134] Conv9_2 5×5 [162,162]、[229,114]、[114,229]、[280,93]、[93,280]、[185,185] Conv10_2 3×3 [213,213]、[301,150]、[150,301]、[237,237] Conv11_2 1×1 [264,264]、[373,186]、[186,373]、[288,288] 与Faster RCNN建议框生成略有不同的是均分不是从图像中的(0,0)坐标开始的,而是采用[0.5×step,300-0.5×step]均分到特征图的尺寸份,例如Conv7则为[0.5×16,300-0.5×16]均分成19份。 假设19×19特征图中的每个像素映射回原图,x轴和y轴均有19个均分点,每两个点之间相差16×16,并且建议框需要在每个x和y坐标点为中心生成6个红色建议框,然后计算红色框与绿色框之间的IOU,当IOU≥0.5时Anchor为正样本,其他为负样本。绿色为真实标注框,可视化效果如图526所示。 图526Conv7特征图中生成的建议框(见彩插) 在计算建议框与真实框之间的偏移时,其计算公式仍采用式(52),得到偏移值后tx/variance1、ty/variance1、tw/variance2、th/variance2,其中variance1=0.1,variance2=0.2,对偏移值进行了放大。在推理时使用式(53),然后对于预测值乘以variance1、variance2进行相同比例的缩小还原。 SSD的损失函数分为位置损失、分类误差损失: L(x,c,l,g)=1N(Lconf(x,c)+αLloc(x,l,g)) Lconf(x,c)=-∑Ni∈Posxpijlog(c^pi)-∑Ni∈Neglog(c^0i)(56) c^pi=exp(cpi)∑pexp(cpi) 其中,N为真实边界框配对的建议框数量,对于x,如果1个建议框与真实边界框配对为1,否则为0; c为真实物体的预测值; l用于预测边界框中心位置的长、宽; g为真实边界框中心位置的长、宽。Lloc(x,l,g)使用的是Smooth损失函数; Lconf(x,c)包含正样本的损失和负样本的损失,并使用Softmax求解分类c^pi。 因为正样本较少,负样本较多,所以在求解损失时将负样本按背景(无目标)概率从大到小进行排序,控制负样本与正样本的比例为3∶1。整个网络的实现,可参见5.4.2节。 5.4.2代码实战模型搭建 整个模型的实现可以分为3部分,VGG16作为特征提取部分,extra_layer作为VGG补充特征提取,detect_head作为检测头进行多尺度检测并作为输出。 重构VGG16的代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/backbone/vggssd.py def vgg(input_tensor): net = {} #默认不使用BN is_bn = False #将输入内容放入net字典中 net['input'] = input_tensor #原VGG的配置结构,数字代表输出channel,M代表池化 vgg_config = [ 64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512, 'M' ] #输入内容 x = net['input'] #对配置文件进行解析 for i, channel in enumerate(vgg_config): if channel != 'M': #ConvBnRelu已封装 x = ConvBnRelu(filters_channels=channel, kernel_size=[3, 3], strides=[1, 1], padding='same', is_bn=is_bn, is_relu=True )(x) #每次将卷积内容放入字典中 net['conv_{}'.format(i + 1)] = x else: #如果不是最后1个池化,则默认为2×2-s2 if i != len(vgg_config) - 1: pool_size = [2, 2] strides = [2, 2] else: pool_size = [3, 3] #pool5,最后一个池化为3×3-s=1 strides = [1, 1] x = layers.MaxPooling2D(pool_size=pool_size, strides=strides, padding='same')(x) net['max_pool_{}'.format(i + 1)] = x return net 重构并通过配置生成模型是为了调优时增加卷积深度和宽度。在构建模型时使用字典来管理各个网络层。ConvBnRelu()是卷积、BN、归一化的封装,is_bn默认不开启。 在VGG的基础上添加extra_layer的代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/backbone/vggssd.py def extra_layer(input_tensor): """VGG 额外增加的一些Net """ net = {} is_bn = False x = input_tensor #格式为 [channel, kernel_size,strides,padding] extra_config = [ [1024, [3, 3], [6, 6], 'same'], #fc6 [1024, [1, 1], [1, 1], 'same'], #fc7 [256, [1, 1], [1, 1], 'same'], #conv8_1 [512, [3, 3], [2, 2], 'same'], #conv8_2 [128, [1, 1], [1, 1], 'same'], #conv9_1 [256, [3, 3], [2, 2], 'same'], #conv9_2 [128, [1, 1], [1, 1], 'same'], #conv10_1 [256, [3, 3], [1, 1], 'valid'], #conv10_2 [128, [1, 1], [1, 1], 'same'], #conv11_1 [256, [3, 3], [1, 1], 'valid'], #conv11_2 ] #读配置文件,生成extra_layer for i, cnf in enumerate(extra_config): if i == 0: #第1个是空洞卷积,[6, 6],dilation_rate指定空洞率 x = ConvBnRelu(cnf[0], cnf[1], strides=[1, 1], dilation_rate=cnf[-2], padding=cnf[-1], is_bn=is_bn, is_relu=True)(x) else: x = ConvBnRelu(cnf[0], cnf[1], cnf[2], cnf[-1], is_bn=is_bn, is_relu=True)(x) net['conv_extra_{}'.format(i)] = x return net def vgg_extra(input_tensor): """对VGG和extra_layer进行整合""" net1 = vgg(input_tensor) net2 = extra_layer(net1['max_pool_18']) net1.update(net2) return net1 函数extra_layer()根据配置文件增加网络层,并且第1个网络层的卷积为空洞卷积,vgg_extra(input_tensor)将VGG和extra_layer进行了整合,构建成整个特征提取网络。net1['max_pool_18']也就是VGG的第5个卷积后的池化层,如图527所示。 对net1和net2进行合并后一共有29个网络层,其中网络图与代码的对应关系为conv4_3=conv_13、conv7=conv_extra_1、conv8_2=conv_extra_3、conv9_2=conv_extra_5、conv10_2=conv_extra_7、conv11_2=conv_extra_9,如图528所示。 图527max_pool_18所处网络层 图528SSD选择的特征层 检测头的构建分别用了两个3×3卷积,并且传入当前特征建议框的宽高比,将在DefaultBox()中自动生成对应的建议框,代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/model/detect_head.py def vgg_detect_head(input_tensor, num_priors, num_classes, box_layer_name, min_max_size, ratios, img_size=[300, 300]): """ VGG的检测头,一个用来预测偏移值,一个用来预测分类,另外一个是Default BOX :param input_tensor: 预测的卷积层 :param num_priors: Default BOX的数量 :param num_classes: 类别的数量 :param box_layer_name: 预测的卷积层的名称 :param min_max_size: 锚框的最小和最大尺寸 :param ratios: 建议框的比例 :param img_size: 输入图像的尺寸 :return: """ net = {} x = input_tensor #用来检测location 的偏移值 net[box_layer_name + "_loc"] = ConvBnRelu(num_priors * 4, kernel_size=[3, 3], strides=[1, 1], padding='same', is_bn=False, is_relu=False)(x) #location位置摊平 net[box_layer_name + "_loc" + "_flat"] = layers.Flatten()(net[box_layer_name + "_loc"]) #用来检测每个default_box的classes得分 net[box_layer_name + "_conf"] = ConvBnRelu(num_priors * num_classes, kernel_size=[3, 3], strides=[1, 1], padding='same', is_bn=False, is_relu=False)(x) net[box_layer_name + "_conf" + "_flat"] = layers.Flatten()(net[box_layer_name + "_conf"]) #Default BOX的生成 net[box_layer_name + "_default_box"] = DefaultBox(min_max_size, ratios, img_size)(x) return net 代码中num_priors*4为每个检测头预测4个偏移位置,在num_priors*num_classes中,num_priors为当前检测头分配的建议框的数量,num_classes为预测的1+分类数,1代表有无目标的分类。DefaultBox(min_max_size,ratios,img_size)传入的当前特征图,生成的建议框的尺寸和比例将在预测时输出建议框的详细信息,具体实现见5.4.3节。layers.Flatten()操作是为了对多检测头的输出进行合并。box_layer_name为检测头输出的字典名称。 将上面的操作在vgg_ssd_300(input_shape,num_classes=21)中进行整合,就能实现SSD的前向传播,代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/model/vggssd.py def vgg_ssd_300(input_shape, num_classes=21): """构建SSD模型""" #输入 input_tensor = layers.Input(shape=input_shape) #输入宽、高 img_w, img_h = input_shape[0], input_shape[1] net = vgg_extra(input_tensor) #vgg+extra layer #对conv4_3的输入进行归一化 net['conv_13_norm'] = Normalize()(net['conv_13']) #38 × 38 × 512,即conv_4_3 #依赖的layer,Default BOX的数量,输出层的名称,[最小尺寸,最大尺寸],[比例] detect_layer = { 'conv_13_norm': [4, 'conv_13_norm', [30, 60], [2], [38, 38]], 'conv_extra_1': [6, 'fc7_mbox', [60, 111], [2,3], [19, 19]], 'conv_extra_3': [6, 'conv8_2_mbox', [111, 162], [2, 3], [10, 10]], 'conv_extra_5': [6, 'conv9_2_mbox', [162, 213], [2,3], [5, 5]], 'conv_extra_7': [4, 'conv10_2_mbox', [213, 264], [2], [3, 3]], 'conv_extra_9': [4, 'conv11_2_mbox', [264, 315], [2], [1, 1]], } #detect_layer为配置的检测头的key名称 for k, v in detect_layer.items(): #传入检测头的信息 pre_head = vgg_detect_head(net[k], v[0], num_classes, v[1], v[2], v[3], [img_w, img_h]) net.update(pre_head) #将位置合并在一起 net['mbox_loc'] = layers.Concatenate(axis=1)( [net[v[1] + "_loc_flat"] for v in detect_layer.values()] ) #将置信度合并在一起 net['mbox_conf'] = layers.Concatenate(axis=1)( [net[v[1] + "_conf_flat"] for v in detect_layer.values()] ) #将分类合并在一起 net['mbox_default_box'] = layers.Concatenate(axis=1)( [net[v[1] + "_default_box"] for v in detect_layer.values()] ) #location 8732 * 4 net['mbox_loc'] = layers.Reshape([-1, 4])(net['mbox_loc']) #conf 8732 * num_classes net['mbox_conf'] = layers.Reshape([-1, num_classes])(net['mbox_conf']) #Softmax会互斥 net['mbox_conf'] = layers.Activation('softmax')(net['mbox_conf']) #将预测值合并在一起,8732 * 33 = 8732 * [4 + 21 + 4 Default BOX+ 4variances] net['predictions'] = layers.Concatenate(axis=2)([ net['mbox_loc'], net['mbox_conf'], net['mbox_default_box'] ]) return Model(inputs=net['input'], outputs=net['predictions']) 代码中detect_layer格式分别为传入的特征层net字典的名称、Default BOX的数量、输出层的名称、建议框[最小尺寸,最大尺寸]、建议框[比例]、特征层的大小。通过for k,v in detect_layer.items()遍历特征层并进行检测头的构建,然后将检测头的输出在net['mbox_loc']中进行合并,并进行维度的Reshape,如图529所示。 图529各个检测头输出Flat 最后对位置、置信度的预测及Default BOX进行合并,输出为8732×33,如图530所示。 图530SSD网络的输出 5.4.3代码实战建议框的生成 代码生成Default BOX需要根据表51中的比例进行计算,假设输入为38×38×512的特征层,则比例为1∶1、1∶2、2∶1、1∶1,然后根据输入的min_size和max_size生成Default BOX的宽和高,然后将原图分为300/38份,使用均分指令将原图生成38×38的坐标点(将这个坐标点看成特征较上的每个点),在每个坐标点根据计算出来的尺寸,计算Default BOX的左上、右下角位置的坐标,详细的代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/utils/default_box.py class BaseDefaultBox(object): def __init__( self, min_max_size: list, #Anchor的最大和最小尺寸 ratios: list, #比例 img_size: list = [300, 300], #原图大小 variances: list = [0.1, 0.1, 0.2, 0.2], #缩放尺寸 **kwargs): #属性初始化 self.variances = np.array(variances) self.ratios = aspect_ratios(ratios) self.img_size = img_size self.min_max_size = min_max_size super(BaseDefaultBox, self).__init__(**kwargs) def call(self, feature_map, *args, **kwargs): #Feature Map 中的w和h feature_map_width, feature_map_height = feature_map[0], feature_map[1] #原图大小 img_width, img_height = self.img_size[0], self.img_size[1] #存放Default Box的w和h box_width, box_height = [], [] #根据self.ratios属性选择生成Anchor的w和h for ar in self.ratios: #假设输入为38×38的特征,则第1个比例为 30∶30 if ar == 1.0 and len(box_width) == 0: box_width.append(self.min_max_size[0]) box_height.append(self.min_max_size[0]) elif ar == 1.0 and len(box_width) > 0: #第2个1∶1比例为 sqrt(30 × 60) box_width.append(np.sqrt(self.min_max_size[0] * self.min_max_ size[1])) box_height.append(np.sqrt(self.min_max_size[0] * self.min_max_ size[1])) elif ar != 1.0: #第3个为 30 * sqrt(2): 30 * 1/sqrt(2),即1∶2 #第4个为 30 * 1/sqrt(2) : 30* sqrt(2) 即2∶1 box_width.append(self.min_max_size[0] * np.sqrt(ar)) box_height.append(self.min_max_size[0] / np.sqrt(ar)) #求BOX中心点 box_widths = 0.5 * np.array(box_width) box_heights = 0.5 * np.array(box_height) #映射到Feature Map中的比例,即300/38=7.8 step_x = self.img_size[0] / feature_map_width step_y = self.img_size[1] / feature_map_height #在原图中均分为38份,生成每个坐标点 lin_x = np.linspace(0.5 * step_x, img_width - 0.5 * step_x, feature_map_width) lin_y = np.linspace(0.5 * step_y, img_width - 0.5 * step_y, feature_map_height) #得到38 × 38和38 × 38坐标点 centers_x, centers_y = np.meshgrid(lin_x, lin_y) #得到1444×1和1444×1 centers_x, centers_y = centers_x.reshape(-1, 1), centers_y.reshape(-1, 1) #变成一维 #每个先验框需要两个(centers_x, centers_y),前一个用来计算左上角,后一个用来 #计算右下角 default_box = np.concatenate([centers_x, centers_y], axis=1) #1444 × 2 #再复制一份 num_default_box = len(self.ratios) #先沿x轴复制1倍,再沿y轴复制2 × 4和1444 × 16,两个位置预测 xmin,ymin, #xmax,ymax,共4个锚框 default_box = np.tile(default_box, (1, 2 * num_default_box)) #1444×16 #将锚框各个比例的值更新到Default BOX中 default_box[:, 0::4] = default_box[:, 0::4] - box_widths #xmin default_box[:, 1::4] = default_box[:, 1::4] - box_heights #ymin default_box[:, 2::4] = default_box[:, 2::4] + box_widths #xmax default_box[:, 3::4] = default_box[:, 3::4] + box_heights #ymax #转换成浮点数 default_box[:, ::2] = default_box[:, ::2] / self.img_size[0] default_box[:, 1::2] = default_box[:, 1::2] / self.img_size[1] #38 × 38 × 4 原比例为(1444,16) = 1444 × 4个框 × 4个位置,reshape之后就是 #5776 × 4个位置 default_box = default_box.reshape([-1, 4]) #将那些位置信息为负数的值转换成0 default_box = np.minimum(np.maximum(default_box, 0.0), 1.0) #将variances信息复制Default BOX这么多份 variances = np.tile(self.variances, (len(default_box), 1)) #将default_box 和variances合并 default_box = np.concatenate([default_box, variances], axis=1) return default_box 代码中step_x,step_y在这里取7.8,意味着38×38的特征图在原图的区域是7.8,np.linspace(0.5*step_x,img_width-0.5*step_x,feature_map_width)表明Default BOX的起点从(0,0)偏移了0.5*step_x; 生成的BOX信息归一化后,再通过default_box=np.concatenate([default_box,variances],axis=1)对缩放因子[0.1,0.1,0.2,0.2]进行了合并。 然后根据BaseDefaultBox类的实现,封装DefaultBox(layers.Layer)算子,使在搭建模型时使用net[box_layer_name+"_default_box"]=DefaultBox(min_max_size,ratios,img_size)(x)进行调用,并合并到网络层中。DefaultBox(layers.Layer)的代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/utils/default_box.py class DefaultBox(layers.Layer): def __init__( self, min_max_size: list, #Anchor设置的最小尺寸和最大尺寸 ratios: list, #比例 img_size: list = [300, 300], #输入图像尺寸 variances: list = [0.1, 0.1, 0.2, 0.2], #缩放因子 **kwargs): super(DefaultBox, self).__init__() #默认框生成类实例化 self.base = BaseDefaultBox( min_max_size, ratios, img_size, variances, **kwargs ) def call(self, inputs, *args, **kwargs): if hasattr(inputs, '_keras_shape'): input_shape = inputs._keras_shape elif hasattr(K, 'int_shape'): input_shape = K.int_shape(inputs) #根据特征图的尺寸调用self.base对象,并生成默认框,假设为38×38,则输出为[5776,8] default_box = self.base.call([input_shape[2], input_shape[1]]) #增维并转换成Tensor格式[1,5776,8] default_box_tensor = K.expand_dims(tf.cast(default_box, dtype= tf.float32), 0) #在每个batch_size中都复制1份 pattern = [tf.shape(inputs)[0], 1, 1] #[b,5776,8] prior_boxes_tensor = tf.tile(default_box_tensor, pattern) return prior_boxes_tensor DefaultBox(layers.Layer)用于获得建议框,接下来就需要在建议框与真实标注框之间做IOU的计算,并将IOU>0.5的样本设置为正样本,以此计算真实框与建议框的偏移,作为y值,代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/utils/default_box.py def assign_boxes(targets, row): """ 在图像上生成Default BOX及y值,构建成8732 × 33 :param targets: GT格式xmin,ymin,xmax,ymax,one_hot :return: """ #生成一个初始为0的 8732×8的矩阵 assignment = np.zeros((self.num_priors, 4 + self.num_classes + 4 + 4)) #是否为背景,默认都为背景 assignment[:, 4] = 0. #如果没有目标,则返回全是0的Tensor if len(targets) == 0: return assignment #对真实的BOX进行编码 #在true_encode_box()函数内实现编码 encoded_boxes = np.apply_along_axis(true_encode_box, 1, targets[:, :4], self.priors_box, self.iou_overlap_threshold, True, row) #reshape成[batch_size,8732,5] encoded_boxes = encoded_boxes.reshape(-1, self.num_priors, 5) #取重合程度最大的先验框,并且获取这个先验框的index #-1的位置是IOU的得分,即IOU最大得分的BOX的index best_iou = encoded_boxes[:, :, -1].max(axis=0) best_iou_mask = best_iou > 0 best_iou_idx = encoded_boxes[:, :, -1].argmax(axis=0) best_iou_idx = best_iou_idx[best_iou_mask] #有物体的先验框的个数 assign_num = len(best_iou_idx) #保留重合程度最大的先验框的应该有的预测结果 encoded_boxes = encoded_boxes[:, best_iou_mask, :] #把有物体的BOX更新到assignment中[8732, 4 + self.num_classes + 4 + 4] assignment[:, :4][best_iou_mask] = encoded_boxes[best_iou_idx, np.arange(assign_num), :4] assignment[:, 4][best_iou_mask] = 1. #4 为背景的概率,当然为0;损失要求有样本是1 assignment[:, 5:-8][best_iou_mask] = targets[best_iou_idx, 4:] #one_hot return assignment 在函数assign_boxes()中实现了真实框与建议框进行IOU的计算并获得正样本的代码,其中np.apply_along_axis为关键代码,即将targets[:, :4]真实框的位置信息和self.priors_box建议框信息,根据self.iou_overlap_threshold的阈值0.5在true_encode_box()函数中进行编码操作,然后将true_encode_box()得到的正样本信息赋值到assignment中,包括位置assignment[:, :4][best_iou_mask]=encoded_boxes[best_iou_idx,np.arange(assign_num), :4]、有无目标assignment[:, 4][best_iou_mask]=1、分类信息assignment[:, 5:-8][best_iou_mask]=targets[best_iou_idx, 4:]。 计算真实框与建议框的偏移在true_encode_box()函数中实现,其核心仍然是调用式(53)完成,代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/utils/default_box.py def true_encode_box( true_box, #真实BOX priors_box, #Default BOX overlap_threshold=0.5, #阈值 is_there_any_object=True, row="" ): #对GT BOX进行>0.5选择为正样本 iou_score = iou(priors_box, true_box) #将Tensor初始为8732 × 5 encoded_box = np.zeros([len(priors_box), 4 + is_there_any_object]) #[8732,5] #将先验框得到的IOU与阈值进行对比 assign_mask = iou_score >= overlap_threshold if not assign_mask.any(): assign_mask[iou_score.argmax()] = True if is_there_any_object: #将iou_score大于阈值的BOX的得分设置到encoded_box中,即有物体的概率 encoded_box[:, -1][assign_mask] = iou_score[assign_mask] #得到有物体的BOX位置信息 assigned_priors = priors_box[assign_mask] #[any_object_total_num_priors, 8] #因为输入的格式为xmin,ymin,xmax,ymax,所以要转换为中心点来计算 box_center = 0.5 * (true_box[:2] + true_box[2:]) #xmin,ymin + xmax,ymax box_wh = true_box[2:] - true_box[:2] #xmax,ymax - xmin,ymin #计算先验框的中心点 assigned_priors_center = 0.5 * (assigned_priors[:, :2] + assigned_priors[:, 2:4]) assigned_priors_wh = assigned_priors[:, 2:4] - assigned_priors[:, :2] #encoded_box为[8732, 5],先验框与真实框IOU大于0.5的,真实框与先验框中心点的偏移值 encoded_box[:, :2][assign_mask] = box_center - assigned_priors_center #dx和dy,代入公式进行计算 encoded_box[:, :2][assign_mask] /= assigned_priors_wh #variances是[0.1,0.1,0.2,0.2],[-4,-2]就是[0.1,0.1] encoded_box[:, :2][assign_mask] /= assigned_priors[:, -4:-2] #dw和dh的偏移值,代入log函数进行计算 encoded_box[:, 2:4][assign_mask] = np.log(box_wh / assigned_priors_wh) #[-2:]就是[0.2,0.2] encoded_box[:, 2:4][assign_mask] /= assigned_priors[:, -2:] #encoded_box为[8732,5] return encoded_box.ravel() 代码中assigned_priors[:, -4:-2]为variances参数中的[0.1,0.1],assigned_priors[:, -2:]为variances参数中的[0.2,0.2],在编码时这里对值进行了放大。 然后新建DataProcessingAndEnhancement(object)类,并创建generate(self,isTraining=True)方法,使训练时得到x的图片数据img,y的数据(真实框与建议框的偏移及分类信息),代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/data/data_processing.py class DataProcessingAndEnhancement(object): def generate(self, isTraining=True): while True: if isTraining: shuffle(self.train_lines) lines = self.train_lines else: lines = self.val_lines inputs, targets = [], [] if len(lines): rnd_row = random.choice(lines) else: rnd_row = None label_result = {x: 0 for x in range(self.num_classes - 1)} for row in lines: #读图片和BOX的数据 img, y = self.get_image_processing_results(row, rnd_row, isTraining) if len(y) != 0: boxes = np.array(y[:, :4], dtype=np.float32) #BOX归一化 boxes[:, 0] = boxes[:, 0] / self.input_shape[1] #xmin boxes[:, 1] = boxes[:, 1] / self.input_shape[0] #ymin boxes[:, 2] = boxes[:, 2] / self.input_shape[1] #xmax boxes[:, 3] = boxes[:, 3] / self.input_shape[0] #ymax #构建one_hot编码 one_hot_label = np.eye(self.num_classes - 1)[np.array(y[:, 4], np.int32)] #[[1,0],[0,1]] if ((boxes[:, 3] - boxes[:, 1]) <= 0).any() and ((boxes[:, 2] - boxes[:, 0]) <= 0).any(): continue #GT格式xmin,ymin,xmax,ymax,[0,1] y = np.concatenate([boxes, one_hot_label], axis=-1) y = self.assign_boxes(y, row)#将y值统一到8732×8这个维度, #并且是编码后的内容 inputs.append(img) targets.append(y) #按batch_size传输targets if len(targets) == self.batch_size: tmp_inp = np.array(inputs, dtype=np.float32) tmp_targets = np.array(targets) inputs = [] targets = [] #注释以下语句,就可以进行调试 yield preprocess_input(tmp_inp), tmp_targets 代码中self.assign_boxes()实现将y值统一到8732×33这个维度,np.concatenate([boxes,one_hot_label],axis=-1)增加了真实的分类one_hot编码值,if len(targets)==self.batch_size成立时返回指定batch_size的数据。 5.4.4代码实战损失函数的构建及训练 根据式(56)实现SSD的损失函数的构建,代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/utils/loss.py class MultiAngleLoss(object): """ SSD损失函数的构建 """ def __init__(self, num_classes=21, negative_sample_ratio=3.0, difficult_sample_top=300, total_regression_loss_ratio=1., total_classification_loss_ratio=1.0 ): """ :param num_classes: 类别数 :param negative_sample_ratio: 负样本: 正样本的比例,SSD默认为3∶1 :param difficult_sample_top: 当一个正样本都没有时设置负样本挖掘数量 :param total_regression_loss_ratio: 最后回归损失的比例 :param total_classification_loss_ratio: 最后分类损失的比例 """ super(MultiAngleLoss, self).__init__() self.num_classes = num_classes - 1 self.negative_sample_ratio = negative_sample_ratio self.difficult_sample_top = difficult_sample_top self.total_regression_loss_ratio = total_regression_loss_ratio self.total_classification_loss_ratio = total_classification_loss_ratio def compute_loss(self, y_true, y_pred): batch_size = tf.shape(y_true)[0] num_boxes = tf.cast(tf.shape(y_true)[1], tf.float32) #8732 #conf_loss[0][conf_loss[0]!=0],每个框的损失 conf_loss = self._softmax_loss(y_true[:, :, 4:-8], y_pred[:, :, 4:-8]) #每个框的回归损失 loc_loss = self._l1_smooth_loss(y_true[:, :, :4], y_pred[:, :, :4]) #统计不为背景的BOX的数量 true_box_num = tf.reduce_sum(y_true[:, :, 4], axis=-1) #每个框的回归损失,然后求和 regression_loss_per_box = tf.reduce_sum(loc_loss * y_true[:, :, 4], axis=1) #每个框的分类损失,然后求和 classification_loss_of_each_box = tf.reduce_sum(conf_loss * y_true[:, :, 4], axis=1) #进行正负样本比例的调节,负样本是正样本的3倍 select_negative_samples_num = tf.minimum(self.negative_sample_ratio * true_box_num, num_boxes - true_box_num) #select_negative_samples_num,有可能一个也没有,因为true_box_num可能为0, #即一个正样本也没有 #与0进行比较,得到[True,True,...]或者[True,True,...,False]的情况 negative_samples_num_mask = tf.greater(select_negative_samples_num, 0) #tf.reduce_any在张量的维度上计算元素的 "逻辑或",如果所有的结果为False, #则has_min为0;只要有一个为True,则为1 #如果图片中没有一个正样本,则select_negative_samples_num为0,也就没有负样本 #但是这时应该有很多负样本,所以我们要处理一下 is_a_negative_sample = tf.cast(tf.reduce_any(negative_samples_num_mask), tf.float32) select_negative_samples_num = tf.concat(axis=0, values=[ select_negative_samples_num, #如果一个正样本都没有,则此项为0 [(1 - is_a_negative_sample) * self.difficult_sample_top] #那么此时补difficult_sample_top; ]) #求本次batch中负样本数的平均数 batch_avg_select_negative_samples_num = tf.reduce_mean( tf.boolean_mask( select_negative_samples_num, tf.greater(select_negative_samples_num, 0) ) ) #将负样本数转换为int32数据类型 batch_avg_select_negative_samples_num = tf.cast(batch_avg_select_negative_samples_num, tf.int32 #取出来预测的最大的分类损失 pre_max_confs = tf.reduce_max(y_pred[:, :, 5:-8], axis=2) #得到true label中负样本的置信度损失的下标 _, top_negative_sample_loss_index = tf.nn.top_k( pre_max_confs * (1 - y_true[:, :, 4]), k=batch_avg_select_negative_samples_num ) #获取难例样本的损失 #tf.gather 根据难例样本的下标取conf_loss中的损失 negative_sample_loss = tf.gather(tf.reshape(conf_loss, [-1]), top_negative_sample_loss_index) #对难例样本的损失求总数 total_negative_sample_loss = tf.reduce_sum(negative_sample_loss, axis=1) #如果为真,则为x,否则为y true_box_num = tf.where(tf.not_equal(true_box_num, 0), true_box_num, tf.ones_like(true_box_num)) total_true_box_num = tf.reduce_sum(true_box_num) total_conf_loss = tf.reduce_sum(classification_loss_of_each_box) + tf.reduce_sum(total_negative_sample_loss) #总分类损失 = (正样本的损失 + 难例样本的损失) / (正样本数 + 难例样本数) #total_conf_loss = total_conf_loss / (total_batch_select_negative_ #samples_num + total_true_box_num) #N在论文中取正样本的个数 total_conf_loss = total_conf_loss / (0 + total_true_box_num) #总回归损失 = 回归损失 / 正样本数 total_loc_loss = tf.reduce_sum(regression_loss_per_box) / total_true_box_num #总损失 = 总分类损失 + alpha * 总回归损失,论文中alpha是1 total_loss = \ self.total_classification_loss_ratio * total_conf_loss + self.total_regression_loss_ratio * total_loc_loss return total_loss 损失函数的构建,主要在compute_loss(self,y_true,y_pred)中实现,传入y_true为真实框,即上文assign_boxes(targets,row)函数中处理过的编码值8732×33,y_pred预测值也为8732×33。self._softmax_loss(y_true[:,:,4:-8],y_pred[:,:,4:-8]),4:-8为置信度+分类的位置,此处使用交叉熵损失。self._l1_smooth_loss(y_true[:,:,:4],y_pred[:,:,:4])为回归Smooth损失。在true_box_num=tf.reduce_sum(y_true[:,:,4],axis=-1)中4这个位置为置信度,因为只有为1时才代表有物体,所以true_box_num为有目标的正样本的数量。tf.reduce_sum(loc_loss*y_true[:,:,4],axis=1)只对有目标的损失进行求和运算。 损失函数的第2部分是实例难例样本的挖掘,即根据正样本的数量,求3倍负样本的损失值。batch_avg_select_negative_samples_num为考虑特殊情况后的负样本的数量。pre_max_confs=tf.reduce_max(y_pred[:,:,5:-8],axis=2)取出预测的分类概率,pre_max_confs*(1-y_true[:,:,4])中因为(1-y_true[:,:,4])得到的是没有目标的分类,所以整个语句得到的就是没有目标但是分类预测最大的概率,并通过tf.nn.top_k()获得其难例样本的概率排前k的索引下标top_negative_sample_loss_index,然后根据negative_sample_loss=tf.gather(tf.reshape(conf_loss,[-1]),top_negative_sample_loss_index)获得置信度和分类的损失,求和后将正样本、难例样本的损失相加得到total_conf_loss,最后实现位置损失+正样本分类损失+负样本分类损失。 训练代码调用model.fit()函数,将相关参数传入即可,主要代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/train/main.py #构建模型 model = vgg_ssd_300(input_shape, num_classes) model.build([1, 300, 300, 3]) model.compile( optimizer=Adam(learning_rate=init_lr), loss=MultiAngleLoss(num_classes).compute_loss, run_eagerly=True, #是否启用调试模型 ) model.fit( data_object.generate(True), steps_per_epoch=num_train //BATCH_SIZE, validation_data=data_object.generate(False), validation_steps=num_val //BATCH_SIZE, epochs=end_EPOCH, initial_epoch=init_EPOCH, callbacks=[logging, checkpoint] ) 更多更详细的代码可参考随书代码。 5.4.5代码实战预测推理 SSD的推理流程包括加载模型、加载推理图片进行置信度阈值过滤、偏移值解码,以及NMS非极大值抑制等操作,详细的代码如下: #第5章/ObjectDetection/TensorFlow_SSD_Detected/detected/detected.py class Detected(): def __init__(self, model_path, input_size): self.model = load_model( model_path, custom_objects={'compute_loss': MultiAngleLoss(3).compute_loss} ) #读取模型和权重 self.confidence_threshold = 0.5 #有无目标置信度 self.class_prob = [0.5, 0.5] #两个类别的分类阈值 self.nms_threshold = 0.5 #NMS的阈值 self.input_size = input_size def readImg(self, img_path=None): img = cv2.imread(img_path) #读取要预测的图片 #将图片转到300×300 self.img, _ = u.letterbox_image(img, self.input_size, []) self.old_img = self.img.copy() def forward(self): #升成4维 img_tensor = tf.expand_dims(self.img, axis=0) #前向传播 self.output = self.model.predict(img_tensor) def confidence_filtering(self): #根据置信度的阈值进行过滤,如果大于>0.5,则为有目标 self.targeted = self.output[self.output[..., 4] > self.confidence_threshold] def classification_filtering(self): #根据类别的概率过滤 for i, p in enumerate(self.class_prob): #第i个类别 classification = self.targeted[self.targeted[..., 5 + i] > p] if len(classification): #偏移值 offset_box = classification[..., :4] #置信度 confidence = classification[..., [4]] #default box default_box = classification[..., -8:-4] #缩放因子 variances = classification[..., -4:] #对该类别的框进行解码 #因为Default BOX为xmin,ymin,xmax,ymax,所以需要转换成cx,cy,w,h default_box = u.xyxy2cxcywh(default_box) boxes = u.offset2xyxy(offset_box, default_box, variances) boxes = np.concatenate([boxes, confidence], axis=-1) #NMS得到的索引 index = u.nms(boxes, nms_thresh=self.nms_threshold) #根据索引取出boxes信息 boxes = boxes[index] #绘图 self.old_img = u.draw_box(self.old_img, boxes) u.show(self.old_img) if __name__ == "__main__": d = Detected('../weights') #读权重 d.readImg('../../val_data/pexels-photo-5211438.jpeg') #预测图片 d.forward() #前向传播 d.confidence_filtering() #置信度过滤 d.classification_filtering() #分类过滤并进行解码,NMS 在Detected类中按功能进行了区分,readImg()用来读取图片,并调用letterbox_image转换成SSD的300×300大小,然后在forward()中进行前向传播以得到预测值,在confidence_filtering()中进行置信度阈值的过滤,将大于0.5的self.targeted传递给classification_filtering()进行类别的概率过滤,将满足类别概率的boxes进行解码u.offset2xyxy和u.nms非极大值抑制操作,从而得到最终预测目标。 总结 SSD有6个检测头,每个检测头分配不同的先验框,共计分配8732个先验框进行预测,具有多尺度、密集锚框检测的特点。在损失函数构建中,采用了难例样本挖掘的技术。 练习 运行并调试本节代码,理解算法的设计与代码的结合,重点梳理本算法的实现方法。 5.5单阶段速度快的检测网络YOLOv1 5.5.1模型介绍 YOLOv1由Joseph Redmon等在2015年发表的论文You Only Look Once: Unified, RealTime Object Detection中提出,是一个单阶段目标检测网络,其主要特点为速度快。YOLOv1的主要结构如图531所示。 图531YOLOv1网络结构 YOLOv1其主干特征提取网络由DarkNet19构成,DarkNet19的主要特点是由3×3和1×1卷积构成,共计8个块,提取到最后的特征层为7×7×1024,然后将7×7×1024摊平送入4096维的全连接层,预测输出为7×7×(2×(4个位置+1有无目标的概率)+20个类别的概率)=7×7×30。将7×7×30中的特征映射到原图就相对于将原图划分成7×7个格子,每个格子对应到输出就是每个特征点,这个特征点有30个通道,代表预测30个特征的输出。 YOLOv1没有建议框(先验框),由真实标注物体所在格子进行预测,也就是标注物体所在格子为正样本,每个标注物体所在格子最多预测两个物体,所以YOLOv1一共只能预测7×7×2=98个目标,而没有标注物体的格子全都是负样本,如图532所示。 图532YOLOv1正样本的选择 图532中口罩所处中心点为(center_x,center_y),其所在格子的左上角行i=3、列j=2,所以tx=center_x-i,ty=center_y-j,则tx和ty为相对于原点(0,0)的偏移,w、h为标签目标的宽和高,所以y_true为tx,ty,w,h; y_true只在高亮背景中有目标,其他格式都没有目标,所以当前格子的置信度为1,其他为0,当前格子也负责预测分类的概率。 预测值y_pred也为tx,ty,w,h,每个格子预测两个偏移值,也就是两个预测框,每个预测框预测前背景、分类的概率。在求损失时,只有高亮背景的格子为正样本,其他格子全是负样本,当预测值y_true与真实框y_pred很接近时,说明网络预测接近目标。需要注意的是YOLOv1,宽和高wh是直接预测,tx、ty是相对于(0,0)点的偏移。没有设置建议框的这种方法被称为Anchor Free。 在损失函数方面,位置损失、置信度损失(有无目标)、分类损失均使用均方差,其公式如下: Loss=λcoord∑s2i=0∑Bj=01objij[(xi-x^i)2+(yi-y^i)2]+ λcoord∑s2i=0∑Bj=01objij[(wi-w^i)2+(hi-h^i)2]+ ∑s2i=0∑Bj=01objij[(Ci-C^i)2]+λnoobi∑s2i=0∑Bj=01noobjij[(Ci-C^i)2]+ ∑s2i=01objij∑c∈1[(pi(c)-p^i(c))2](57) 其中,λcoord为平衡系数,原作者将其设置为5。1objij代表有物体的格子。xi代表真实tx,x^i代表预测tx。wi为真实宽,w^i为预测宽,由于w、h的值较大,所以开方是为了防止梯度爆炸。1noobjij代表没有物体,所以置信度损失由有物体的损失+没有物体的损失构成,而没有物体的损失占比应该更小,所以超参数λnoobi=0.5,最后pi(c)为真实物体的分类,p^i(c)为预测物体的分类。ij代表每个格子。整个网络的实现,可参见5.5.2节。 5.5.2代码实战模型搭建 YOLOv1模型前向传播的代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V1_Detected/backbone/yolo_v1.py def yolo_v1(input_shape, CLASS_NUM=20, BOX_NUM=2, GRID_NUM=7): #输入 input_tensor = layers.Input(shape=input_shape) #默认不启用BN is_bn = False #DarkNet19的结构,Conv代表卷积,MaxP即最大池化 #[算子类型,核,步长,channel] darknet_config = [ ['Conv', 7, 2, 64], ['MaxP', 2, 2],#112 × 112 × 64 ['Conv', 3, 1, 192], ['MaxP', 2, 2], #56 × 56 × 192 ['Conv', 1, 1, 128], ['Conv', 3, 1, 256], ['Conv', 1, 1, 256], ['Conv', 3, 1, 512], ['MaxP', 2, 2], #28 × 28 × 512 ['Conv', 1, 1, 256], ['Conv', 3, 1, 512], ['Conv', 1, 1, 256], ['Conv', 3, 1, 512], ['Conv', 1, 1, 256], ['Conv', 3, 1, 512], ['Conv', 1, 1, 256], ['Conv', 3, 1, 512], ['Conv', 1, 1, 512], ['Conv', 3, 1, 1024], ['MaxP', 2, 2], #14 × 14 × 1024 ['Conv', 1, 1, 512], ['Conv', 3, 1, 1024], ['Conv', 1, 1, 512], ['Conv', 3, 1, 1024], ['Conv', 3, 1, 1024], ['Conv', 3, 2, 1024], #7 × 7 × 1024 ['Conv', 3, 1, 1024], ['Conv', 3, 1, 1024], #7 × 7 × 1024 ] x = input_tensor #解析配置文件,生成模型结构 for i, c in enumerate(darknet_config): if c[0] == 'Conv': x = ConvBnLeakRelu(c[3], c[1], c[2], is_bn=is_bn, padding='same', is_relu=True)(x) elif c[0] == 'MaxP': x = layers.MaxPooling2D(c[1], c[2], padding='same')(x) x = layers.Flatten()(x) #7 × 7 × 1024 #全连接后激活函数使用LeakyReLU x = layers.LeakyReLU(0.1)(layers.Dense(4096)(x)) x = layers.DropOut(0.5)(x) #防止过拟合 #输出为1470维特征向量 x = layers.Dense(GRID_NUM * GRID_NUM * (5 * BOX_NUM + CLASS_NUM))(x) x = layers.DropOut(0.5)(x)#防止过拟合 x = tf.sigmoid(x) #Sigmoid是为了将值域控制在0~1 #将1470维特征向量变成7×7×30,7×7是把原图像划为7×7个格子和(4+1)×2+20个类别 x = tf.reshape(x, [-1, GRID_NUM, GRID_NUM, 5 * BOX_NUM + CLASS_NUM]) return Model(inputs=input_tensor, outputs=x) 特征提取使用配置文件darknet_config来构建DarkNet19,具体实现在for循环中。ConvBnLeakRelu()即Conv→BN→LeakRelu的封装。通过DarkNet19后layers.Flatten()得到50176个神经元,然后输出layers.Dense(GRID_NUM*GRID_NUM*(5*BOX_NUM+CLASS_NUM))(x)变成1470维特征,对这1470维特征归一化后reshape成7×7×30,即每个特征点代表每个格子,每个格子预测(4+1)×2个框和有无目标,并预测20个分类的概率。 5.5.3无建议框时标注框编码 虽然YOLOv1为Anchor Free,但仍然需要将标注框编码成tx,ty,w,h作为y_true,以便与预测y_pred做损失,代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V1_Detected/utils/tools.py def yolo_v1_true_encode_box(true_boxes, CLASS_NUM=20, BOX_NUM=2, GRID_NUM=7): """ 将图片编码成YOLOv1的输出格式 :param GRID_NUM: :param BOX_NUM: 默认为两个 :param CLASS_NUM: 预测的类别数 :param true_boxes: NumPy类型[center_x,center_y,w,h,object] YOLO格式 :return: 7 × 7 × (5 × 2 + 20) """ #初始7×7×30为0的Tensor target_box = np.zeros([GRID_NUM, GRID_NUM, (5 * BOX_NUM + CLASS_NUM)]) #cell_size = 1.0 /7 每个格子的宽、高,归一化 cell_size = 1.0 / GRID_NUM if len(true_boxes) == 0: return target_box boxes_wh = true_boxes[:, 2:4] #gt wh boxes_cxy = true_boxes[:, :2] #gt cx,cy box_label = true_boxes[:, -1] #gt label for ibox in range(true_boxes.shape[0]): center_xy, wh, label = boxes_cxy[ibox], boxes_wh[ibox], int(box_label[ibox]) #得到 1/ S=7 格子中的相对位置。-1.0是为了排除当前格子 ij = np.ceil(center_xy / cell_size) - 1.0 i, j = int(ij[0]), int(ij[1]) #第几个格子中 #由第i、第j个格子的中心点进行预测 grid_xy = ij * cell_size #获得格子的左上角xy #(bbox中心坐标 - 网络左上角的坐标) / 网格大小 = tx,ty grid_p_center_xy = (center_xy - grid_xy) / cell_size for k in range(BOX_NUM): s = 5 * k target_box[j, i, s:s + 2] = grid_p_center_xy target_box[j, i, s + 2:s + 4] = wh target_box[j, i, s + 4] = 1.0 #置信度,有没有物体 #i,j正样本格子的label target_box[j, i, 5 * BOX_NUM + label] = label return target_box #输出类型为 7 × 7 × 30,与预测的结果保持一致 代码中cell_size=1.0/GRID_NUM,1/7=0.14为每个格子的大小。true_boxes传进来时就是center_x,center_y,w,h。ij=np.ceil(center_xy/cell_size)-1.0,i=3、j=2即标注框的中心点落在此格子中(见图532),那么在计算损失时正样本为i=3、j=2,其他样本均为负样本。 在得到i=3、j=2后,计算当前格子的左上角xy的坐标grid_xy=ij*cell_size,然后通过(center_xy-grid_xy)/cell_size得到标注中心点相对于(0,0)点的偏移。target_box[j,i,s:s+2]=grid_p_center_xy,即target_box[2,3,0:2]=grid_p_center_xy为tx,ty的偏移值,target_box[2,3,2:4]=wh为wh的宽和高。因为每个格子预测两个物体,所以当k=1时,target_box[2,3,5:7]=grid_p_center_xy,也就是当前格子赋两个相同值作为y_true。 然后再次编写generate(self,isTraining=True)函数实现,数据的传输,代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V1_Detected/data/data_processing.py class DataProcessingAndEnhancement(object): def generate(self, isTraining=True): while True: if isTraining: shuffle(self.train_lines) lines = self.train_lines else: lines = self.val_lines inputs, targets = [], [] if len(lines): rnd_row = random.choice(lines) else: rnd_row = None for row in lines: #读取Image和BOX信息 img, y = self.get_image_processing_results(row, rnd_row, isTraining) #将Voc格式转换为YOLO格式 y = self.voc_label_convert_to_yolo(y) #将GT BOX编码成与预测值相同的格式 y = yolo_v1_true_encode_box(y, self.CLASS_NUM, self.BOX_NUM, self.GRID_NUM) #存储图片和编码后的BOX信息 inputs.append(img) targets.append(y) #按batch_size传输targets if len(targets) == self.batch_size: tmp_inp = np.array(inputs, dtype=np.float32) tmp_targets = np.array(targets) inputs = [] targets = [] #注释以下语句,就可以进行调试了 yield preprocess_input(tmp_inp), tmp_targets 因为本数据集的默认格式为Voc,所以需要调用self.voc_label_convert_to_yolo(y)实现由Voc格式转YOLO格式,代码类似5.1节标签处理及代码,更多更详细的内容可参考随书代码。 5.5.4代码实现损失函数的构建及训练 第1步,根据式(57)实现正样本格子i、j位置的损失、置信度的损失及分类概率的损失,同时对1负样本给予较小权重的损失,详细的代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V1_Detected/utils/loss.py class MultiAngleLoss(object): def __init__(self, CLASS_NUM=20, BOX_NUM=2, GRID_NUM=7, coord=5.0, no_obj=0.5): """ YOLOv1的loss计算 """ self.CLASS_NUM = CLASS_NUM #类别数 self.BOX_NUM = BOX_NUM #每个格子的预测数 self.GRID_NUM = GRID_NUM #一共有多少个格子 self.coord = coord #坐标损失的系数 self.no_obj = no_obj #不包含物体的损失系数 self.output_dim = 5 * self.BOX_NUM + self.CLASS_NUM #输出维度,即30 super(MultiAngleLoss, self).__init__() def compute_loss(self, y_true, y_pred): """ 计算损失的函数 :param y_true: :param y_pred: :return: """ #(1)第1部分,获取有物体和没有物体的mask,有物体为true,没有物体为false batch_size = tf.shape(y_true)[0] #true值中有物体的框 get_object_mask = y_true[:, :, :, 4] > 0 #true值中没有物体的框 get_no_object_mask = y_true[:, :, :, 4] == 0 #(2)第2部分,根据第1部分获得的mask,从预测值获取有物体get_pre_object_mask、 #没有物体的get_pre_no_object_mask #及有物体的bbox_pre、class_pre #扩维成 b,7,7,30,好获得整组的值 get_object_mask = tf.tile(np.expand_dims(get_object_mask, -1), [1, 1, 1, self.output_dim]) get_no_object_mask = tf.tile(np.expand_dims(get_no_object_mask, -1), [1, 1, 1, self.output_dim]) #从预测框中获得置信度框的内容,y_pred[get_object_mask] get_object_mask #有物体的mask get_pre_object_mask = tf.reshape(y_pred[get_object_mask], [-1, self.output_dim]) #获取两个预测框的值,因为预测输出为 2×(4+1)+20 bbox_pre = tf.reshape(get_pre_object_mask[..., :5 * self.BOX_NUM], [-1, 5]) #类别信息的值 class_pre = get_pre_object_mask[..., 5 * self.BOX_NUM:] #获取没有物体的格子 y_pred[get_no_object_mask] get_no_object_mask没有物体 #的mask get_pre_no_object_mask = tf.reshape(y_pred[get_no_object_mask], [-1, self.output_dim]) #(3)第1部分只是获得置信度的mask,接下来需要根据mask获取标注物体为正样本的 #get_true_object_mask,bbox_true #及class_true,和没有物体的get_true_no_object_mask #y_true[get_object_mask],有物体的mask。get_true_object_mask为标注框, #有物体的部分 get_true_object_mask = tf.reshape(y_true[get_object_mask], [-1, self.output_dim]) #标注框,有物体的BOX信息 bbox_true = tf.reshape(get_true_object_mask[..., :5 * self.BOX_NUM], [-1, 5]) #标注框,有物体的分类信息 class_true = get_pre_object_mask[..., 5 * self.BOX_NUM:] #y_true[get_no_object_mask],标注没有物体的mask get_true_no_object_mask = tf.reshape(y_true[get_no_object_mask], [-1, self.output_dim]) #(4)根据预测没有物体的get_pre_no_object_mask,分别去获取没有物体的预测 #no_obj_pre_conf #及没有物体的标注no_obj_true_conf #初始为0,没有物体的mask,此时背景为1 get_pre_conf_no_object_mask = np.zeros(get_pre_no_object_mask.shape) for b in range(self.BOX_NUM): #起到的作用是默认先选择所有的没有物体的格子,为了方便从get_true_no_ #object_mask和get_pre_no_object_mask中取值 get_pre_conf_no_object_mask[:, 4 + b * 5] = 1 #取出来没有物体预测的格子 no_obj_pre_conf = tf.gather(get_pre_no_object_mask, get_pre_conf_no_object_mask.astype(int)) #取出来没有物体真实的格子 no_obj_true_conf = tf.gather(get_true_no_object_mask, get_pre_conf_no_object_mask.astype(int)) #所有没有物体的格子的损失 loss_no_obj = tf.reduce_sum(tf.losses.MSE(no_obj_pre_conf, no_obj_true_conf)) #(5)从预测的BOX中获取与真实框最大的IOU,取最大的为有物体的mask,然后让预测值 #与真实值之间做损失 coord_response_mask = np.zeros(bbox_true.shape) coord_not_response_mask = np.ones(bbox_true.shape) bbox_target_iou = np.zeros(bbox_true.shape) #从预测的BOX中获取与真实框最大的IOU,遍历batch下所有的有物体的格子。因为每 #个格子预测两个框,所以step是2 for i in range(0, bbox_true.shape[0], self.BOX_NUM): pre_box = bbox_pre[i:i + self.BOX_NUM] #[0,2],预测的每个格子的2个框都 #进行计算 pre_xy = np.zeros(pre_box.shape) #因为预测出来的是cx,cy,w,h,并且缩放了,所以要还原到原图中,以便进行IOU的比较 #算出x1,y1,x2,y2 pre_xy[:, :2] = pre_box[:, :2] / float(self.GRID_NUM) - 0.5 * pre_box[:, 2:4] pre_xy[:, 2:4] = pre_xy[:, :2] / float(self.GRID_NUM) + 0.5 * pre_xy[:, 2:4] #当为真实值时,因为编码时每个格子的2个框赋的值是一样的,所以取1个就可以了 target_true = bbox_true[i] target_true = tf.reshape(target_true, [-1, 5]) true_xy = np.zeros_like(pre_xy) #因为传的是cx,cy,w,h,但是计算IOU要使用x1,y1,x2,y2,所以要转换一下 true_xy[:, :2] = target_true[:, :2] / float(self.GRID_NUM) - 0.5 * target_true[:, 2:4] true_xy[:, 2:4] = target_true[:, :2] / float(self.GRID_NUM) + 0.5 * target_true[:, 2:4] #获取预测框与真实框之间的IOU get_iou = iou(pre_xy, true_xy) #得到所有框中最大的max max_iou, max_index = np.max(get_iou), np.argmax(get_iou) coord_response_mask[i + max_index] = 1 #将有物体的IOU位置设置为1,默认为0 coord_not_response_mask[i + max_index] = 0 #将没有物体的设置为0,默认 #为1,为1是为了方便取值 bbox_target_iou[i + max_index, 4] = max_iou #将IOU的值赋为置信度 #(6)计算损失 #根据有目标的mask取出pre的BOX值。因为batch中设置的都是第1个格子有目标, #所以这里都是第1个格子 bbox_pred_response = tf.reshape(tf.gather(bbox_pre, coord_response_mask.astype(int)), [-1, 5]) bbox_target_response = tf.reshape(tf.gather(bbox_true, coord_response_mask.astype(int)), [-1, 5]) target_iou = tf.reshape(bbox_target_iou[coord_response_mask.astype(int)], [-1, 5]) #计算x和y的损失 loc_loss_xy = tf.reduce_sum( tf.losses.MSE(bbox_pred_response[:, :2], bbox_target_response[:, :2]) ) #计算w和h的损失 loc_loss_wh = tf.reduce_sum( tf.losses.MSE(tf.sqrt(bbox_pred_response[:, 2:4]), tf.sqrt(bbox_target_response[:, 2:4])) ) #位置损失 loc_loss = loc_loss_xy + loc_loss_wh #计算置信度损失。预测的置信度与true值和pre的IOU越接近越好 loss_obj = tf.reduce_sum(tf.losses.MSE(bbox_pred_response[:, 4], target_iou[:, 4])) #分类损失 class_loss = tf.reduce_sum( tf.losses.MSE(class_pre, class_true) ) #位置损失+有物体的置信度损失+没有物体所有格子的损失+有物体的分类损失 loss = self.coord * loc_loss + \ tf.cast(loss_obj, dtype=tf.float32) + \ self.no_obj * loss_no_obj + class_loss #总损失/batch_size loss = loss / tf.cast(batch_size, dtype=tf.float32) return loss 此损失计算的代码较长,共由6个步骤构成,首先y_true[:,:,:,4]>0获取有目标的get_object_mask,y_true[:,:,:,4]==0没有目标的get_no_object_mask,如图533所示。 图533y_true有无目标mask 第2步,根据get_object_mask、get_no_object_mask到y_pred预测值中获取有目标的置信度、预测BOX、预测分类信息,并得到预测值为有物体的get_pre_object_mask、没有物体的get_pre_no_object_mask,如图534所示。 图534y_pred有无目标BOX信息 第3步,根据第1步中的get_object_mask计算真实值的BOX、置信度、分类信息的值,并获取没有目标get_true_no_object_mask的值,如图535所示。 图535y_true有无目标BOX信息 第4步,根据预测没有物体的get_pre_no_object_mask,分别获取没有物体的预测no_obj_pre_conf及没有物体的标注no_obj_true_conf,然后计算所有没有目标的置信度损失loss_no_obj=tf.reduce_sum(tf.losses.MSE(no_obj_pre_conf,no_obj_true_conf)),如图336所示。 图536没有目标的置信度损失 第5步,从预测的BOX中获取与真实BOX最大的IOU,取最大IOU作为有物体的mask,然后让预测值与真实值之间做损失。具体是将pre_box与bbox_true计算IOU,取最大的那个IOU所在的BOX作为有目标,然后根据coord_response_mask取出预测值的bbox_pred_response,以及真实值bbox_target_response,如图537所示。 图537BOX损失取预测BOX与真实BOX的最大IOU 第6步,根据前面的步骤按式(57)计算损失,完成“5×位置损失+有物体的置信度损失+0.5×没有物体所有格子的损失+有物体的分类损失”。 训练代码可使用model.fit()函数完成,更多更详细的代码可参考随书代码。 5.5.5代码实战预测推理 YOLOv1的推理流程包括加载模型、加载推理图片进行置信度阈值过滤、偏移值解码,以及NMS非极大值抑制等操作,详细的代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V1_Detected/detected/detected.py class Detected(): def __init__(self, model_path, input_size): self.model = load_model( model_path, custom_objects={'compute_loss': MultiAngleLoss(3).compute_loss} ) #读取模型和权重 self.confidence_threshold = 0.5 #有无目标置信度 self.class_prob = [0.5, 0.5] #两个类别的分类阈值 self.nms_threshold = 0.5 #NMS的阈值 self.input_size = input_size def readImg(self, img_path=None): img = cv2.imread(img_path) #读取要预测的图片 #将图片转换到448×448 self.img, _ = u.letterbox_image(img, self.input_size, []) self.old_img = self.img.copy() def forward(self): #升成4维 img_tensor = tf.expand_dims(self.img / 255.0, axis=0) #前向传播 self.output = self.model.predict(img_tensor) def confidence_filtering(self): #根据置信度的阈值进行过滤,如果大于>0.5,则为有目标 #YOLOv1的输出是 7×7×[(4+1) ×2+2],所以这里应该输出7×7×2 self.targeted = np.concatenate([self.output[..., [4]], self.output [..., [4 + 5]]], axis=-1) def classification_filtering(self): self.S = 7 #划分的格子数 self.B = 2 #每个格子预测两个框 cell_size = 1.0 / float(self.S) #每个格子的size为1/7 #用来存储筛选后的内容 boxes, labels, confidences, class_scores = [], [], [], [] #遍历每个格子 for i in range(self.S): for j in range(self.S): #遍历每个格子中的两个框 for b in range(self.B): #分类得分 class_score = self.output[..., j, i, 5 * self.B:] #取最大的分类得分的下标 class_label = np.argmax(class_score) #最大的分类得分值 score = class_score[..., class_label] #当前格子的置信度 conf = self.targeted[..., j, i, b] #每个格子最后的得分为置信度*分类得分 prob = score * conf #如果小于阈值,则跳过 if float(prob) < self.confidence_threshold: continue #当前预测BOX信息 box = self.output[..., j, i, 5 * b:5 * b + 4] #每个格子点的归一化坐标 x0y0_normalized = np.array([i, j]) * cell_size #解码操作,x+i,y+j即预测的cxcy xy_normalized = box[..., :2] * cell_size + x0y0_normalized #YOLOv1直接预测wh wh_normalized = box[..., 2:] #合并cxcyxy cxcywh = np.concatenate([xy_normalized, wh_normalized], axis=-1) #由cx,cy,w,h转换成xmin,ymin,xmax,ymax xyxy_box = u.cxcy2xyxy(cxcywh) #对结果进行存储 boxes.append(xyxy_box) labels.append([class_label]) confidences.append(conf) class_scores.append(class_score) #对于得到的BOX信息,按类别进行非极大值抑制 if len(boxes) > 0: #由list合并成NumPy boxes_normalized_all = np.stack(boxes, 1) class_labels_all = np.stack(labels, 1) confidences_all = np.stack(confidences, 1) class_scores_all = np.stack(class_scores, 1) #遍历每个类别 for label in range(len(self.class_prob)): #如果class_labels_all==label,则取当前label中的信息 mask = class_labels_all == label #如果都不是当前label,则跳过 if np.sum(mask) == 0: continue #当前label 的boxes boxes_mask = boxes_normalized_all[mask] #当前label 的class_labels。reshape是由于得到的是(50,)变成(50,1), #方便后面计算 class_labels_mask = class_labels_all[mask].reshape([-1, 1]) #当前label的confidences confidences_mask = confidences_all[mask].reshape([-1, 1]) #当前label的class_scores class_scores_mask = class_scores_all[mask][..., label].reshape([-1, 1]) #合并 cat_boxes = np.concatenate([boxes_mask, confidences_mask], axis=-1) #NMS index = u.nms(cat_boxes, self.nms_threshold) #绘框 self.old_img = u.draw_box(self.old_img, cat_boxes[index]) #最后结果 u.show(self.old_img) if __name__ == "__main__": d = Detected(r'../weights', input_size=[448, 448]) d.readImg('../../val_data/pexels-photo-5211438.jpeg') d.forward() d.confidence_filtering() d.classification_filtering() 相对于其他模型,推理的改变在confidence_filtering(self)方法中,这是由于YOLOv1的输出是7×7×[(4box+1置信度)×2个框+2个分类],所以self.output输出7×7×12,获取置信度结果在第4、第9位,如图538所示。 图538YOLOv1置信度取值 在classification_filtering(self)中实现了根据每个格子每个框prob=score*conf,置信度×分类概率的得分得到box=self.output[...,j,i,5*b:5*b+4]信息,然后根据当前所在的格子进行xy_normalized=box[..., :2]*cell_size+x0y0_normalized解码操作,得到预测的cx,cy。由于YOLOv1采用的是无锚框机制,所以它直接预测的是wh_normalized=box[...,2:],然后将解码的内容存储到boxes中,如图539所示。 图539YOLOv1解码操作 因为class_labels_all中存储了所有解码后的label,如果mask=class_labels_all==label,则表明只取当前label的信息,经过u.nms(cat_boxes,self.nms_threshold)非极大值抑制,最后得到当前类别的cat_boxes[index],以及最后预测的BOX信息,如图540所示。 图540mask过滤当前label 总结 YOLOv1为Anchor Free机制,通过划分7×7个格子,由GT落入某个格子的中心点来预测目标,其主要特点为速度快。 练习 运行并调试本节代码,理解算法的设计与代码的结合,重点梳理本算法的实现方法。 5.6单阶段速度快的检测网络YOLOv2 5.6.1模型介绍 YOLOv2由Joseph Redmon等在2016年论文YOLO9000: Better,Faster,Stronger中提出,其主要特点在YOLOv1的基础上引入了BN归一化、建议框和PassThrough Layer层,其网络结构如图541所示。 在主干特征提取方面将YOLOv1的7×7卷积替换成3×3的卷积,并且在每个卷积后面跟了BN归一化。深层的语义信息较丰富,而浅层的几何信息较丰富,为了提高多尺度特征信息的融合,作者在这里使用了PassThrough层,经过PassThrough层的特征信息与13×13×1024进行Concate,从而得到13×13×1280维特征。 PassThrough具体的实现对输入的26×26×64每两个尺度进行重组,促进特征和通道信息的合并交流,如图542所示。 图542中输入为4×3×3,对每两个通道且每个通道的每两组进行拼接,通道与通道之间的信息进行了重组,加强了通道之间的交流,并且没有权重参数。 在Concate之后经过3×3卷积,然后用1×1卷积代替全连接,输出为(4+1+20)×5,每个特征点输出4个预测BOX相对于Anchor的偏移,并且预测BOX有无目标,20个分类的概率,每个特征分配5个建议框,所以输出为13×13×125维向量。 图541YOLOv2结构图 图542PassThrough结构 YOLOv2在计算tx、ty时仍然是基于(0,0)坐标的偏移,但是在计算tw、th时是基于每个像素生成的建议框与GT BOX标注框wh的偏移,每个坐标点生成5个尺寸的建议框,如图543所示。 图543YOLOv2的正样本 YOLOv2将原图等比例缩放至416×416,其输出为13×13×125,则相对于将原图划分为13×13个格子,每个格子对于特征图的输出。每个特征点生成5个建议框,这5个建议框使用以下公式计算tx、ty、tw、th。 tx=Gx-j ty=Gy-i tw=logGwPw(58) th=logGhPh 其中,Gx、Gy代表标注BOX所在图像的中心点; j、i为标注BOX所在的格子; Pw、Ph为预设建议框的宽和高。从公式58可知YOLOv2正样本的选择与标注BOX所在的格子及建议框有关,在挑选建议框时将标注BOX与建议框计算IOU,并挑选最大IOU作为正样本的Anchor。 在损失函数方面,仍由正样本BOX损失+正样本置信度损失+负样本置信度损失+分类损失构成,其公式可表述如下: Loss=∑Wi=0∑Hj=0∑Ak=0λnoobj1noobjijk[(Ci-C^i)2]+λobj1objijk[(Ci-C^i)2]+ λcoord1objijk[(Boxi-Box∧i)2]+λclass1objijk[(pi(c)-p^i(c))2](59) 其中,Ci为标注BOX与Anchor之间IOU得分值,如果大于设定的阈值,则为正样本,如果小于设定的预测值,则为负样本。C^i为预测的置信度概率值。Boxi为标注BOX与Anchor的偏移,Box∧i为预测的偏移值。pi(c)为标注BOX的分类概率,p^i(c)为预测物体的分类概率。∑Wi=0∑Hj=0∑Ak=0表明需要遍历每个格子及建议框。 5.6.2代码实战模型搭建 模型代码实现参考图541完成,PassThrough层的实现可调用tf.nn.space_to_depth(),详细的代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V2_Detected/backbone/yolo_v2.py def yolo_v2(input_shape, class_num=20, anchor_num=5): """YOLOv2由两部分构成,一部分是DarkNet19,另一部分是YOLO增加的 :param anchor_num:Anchor的数量 :param class_num:分类的数量 :param input_shape:输入shape :return: """ input_tensor = layers.Input(shape=input_shape) is_bn = True #使用BN net = {} #结构配置文件 #[类型, kernel_size, strides, out_channel, 'same'] darknet_config = [ ['Conv', 3, 1, 32, 'same'], #416 × 416 ['MaxP', 2, 2, 32, 'same'], ['Conv', 3, 1, 64, 'same'], #208 × 208 ['MaxP', 2, 2, 64, 'same'], ['Conv', 3, 1, 128, 'same'], #104 × 104 ['Conv', 1, 1, 64, 'same'], ['Conv', 3, 1, 128, 'same'], ['MaxP', 2, 2, 128, 'same'], ['Conv', 3, 1, 256, 'same'], #52 × 52 ['Conv', 1, 1, 128, 'same'], ['Conv', 3, 1, 256, 'same'], ['MaxP', 2, 2, 256, 'same'], ['Conv', 3, 1, 512, 'same'], #26 × 26 ['Conv', 1, 1, 256, 'same'], ['Conv', 3, 1, 512, 'same'], ['Conv', 1, 1, 256, 'same'], ['Conv', 3, 1, 512, 'same'], ['MaxP', 2, 2, 512, 'same'], ['Conv', 3, 1, 1024, 'same'], #13 × 13 ['Conv', 1, 1, 512, 'same'], ['Conv', 3, 1, 1024, 'same'], ['Conv', 1, 1, 512, 'same'], ['Conv', 3, 1, 1024, 'same'], #Conv-18, 13 × 13 ] #concate前的两个3×3卷积 yolo_add = [ ['Conv', 3, 1, 1024, 'same'], ['Conv', 3, 1, 1024, 'same'], ] x = input_tensor net['input'] = x #解析配置文件 for i, c in enumerate(darknet_config): if c[0] == 'Conv': #已封装好的conv→bn→leak_relu x = ConvBnLeakRelu(c[3], c[1], c[2], is_bn=is_bn, padding=c[4], is_relu=True)(x) elif c[0] == 'MaxP': #池化 x = layers.MaxPooling2D(c[1], c[2], padding=c[4])(x) net[i] = x #构建两个3×3卷积 for j, c in enumerate(yolo_add): if c[0] == 'Conv': x = ConvBnLeakRelu(c[3], c[1], c[2], is_bn=is_bn, padding=c[4], is_relu=True)(x) net[j + i] = x #将通过pass_through得到的13×13×256与13×13×1024进行合并 x = layers.concatenate([x, pass_through(net[16], is_bn)], axis=-1) net['pass_concatenate'] = x x = ConvBnLeakRelu(1024, 3, 1, is_bn=is_bn, padding='same', is_relu=True)(x) net['pass_next'] = x #输出 13 × 13 × (4+1+num_class) ×anchor,即13 × 13 × 125 #直接位置预测,(4+1+20) ×5 net['output'] = ConvBnLeakRelu((4 + 1 + class_num) * anchor_num, 1, 1, is_bn=False, is_relu=False)(x) #构建模型 return Model(inputs=net['input'], outputs=net['output']) def pass_through(x, is_bn=True, channel=64): #pass_through的封装,由space_to_depth()函数实现该功能 cx = ConvBnLeakRelu(channel, 1, 1, is_bn=is_bn, padding='same', is_relu=True)(x) #26 × 26 × 512 cx = tf.nn.space_to_depth(cx, 2) #13×13×256 pass through起到的作用 #是在各个通道中每隔两个进行合并交流 return cx 代码中darknet_config为主干网络的配置,ConvBnLeakRelu()为封装好的卷积、BN、LeakRelu激活函数,pass_through(x,is_bn=True,channel=64)实现PassThrough层,网络最后的输出为13×13×(4+1+num_class)×anchor。 5.6.3代码实战聚类得到建议框宽和高 YOLOv2建议框的宽和高通过聚类得到,其判断距离的公式为 d(box,centroid)=1-IOU(box,centroid)(510) 其中,d代表距离,box代表标注BOX,centroid为聚簇中心,1-IOU(box,centroid),因为IOU越大说明两个BOX越近为1,所以最小距离接近0,核心代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V2_Detected/utils/get_anchors.py class AnchorKmeans(object): """聚类实现,获取Anchor的宽和高""" def __init__(self, k, max_iter=300, random_seed=None): self.k = k #设置几个中心点 self.max_iter = max_iter #最多迭代多少次 self.random_seed = random_seed #随机种子 self.n_iter = 0 self.anchors_ = None self.labels_ = None self.ious_ = None def fit(self, boxes): """得到anchors""" assert self.k < len(boxes), "K必须少于BOX的数量" #迭代次数,保证每次从0开始 if self.n_iter > 0: self.n_iter = 0 #随机种子 np.random.seed(self.random_seed) #boxes的数量 n = boxes.shape[0] #从现有Anchor中随机选择K个Anchor作为初始点 self.anchors_ = boxes[np.random.choice(n, self.k, replace=True)] #label标签 self.labels_ = np.zeros((n,)) #开始聚类 while True: #每迭代1次self.n_iter+1 self.n_iter += 1 #迭代的次数要小于设置的总次数 if self.n_iter > self.max_iter: break #将其他BOX与随机选择的中心点Anchor做IOU self.ious_ = self.iou(boxes, self.anchors_) #距离1-IOU→0,如果离得很近,则说明BOX与Anchor趋近于1 distances = 1 - self.ious_ #取最小距离的下标 cur_labels = np.argmin(distances, axis=1) #如果最小距离的下标与分配的下标一致,则停止 if (cur_labels == self.labels_).all(): break #更新Anchor的位置 for i in range(self.k): self.anchors_[i] = np.mean(boxes[cur_labels == i], axis=0) self.labels_ = cur_labels if __name__ == "__main__": xml_dir = "../face_mask/facemask_dataset_annotations" jpg_dir = '../face_mask/facemask_dataset' #获取所有标注的boxes boxes = parse_xml(xml_dir, jpg_dir) #设置k=5 model = AnchorKmeans(5, random_seed=1000) model.fit(boxes) #获得聚类结果 print(model.anchors_) 代码distances=1-self.ious_,表示所有BOX与聚簇中心越近,且当if(cur_labels==self.labels_).all():break时,如果最小距离的下标与分配的下标一致,则停止。如果将K的数量从2设置到20,则可以获取K越大其Anchor与GT BOX的IOU得分越高,如图544中K=15时平均IOU=0.75。增大K的设置可以提高精度,但同时会降低网络的推理速度,所以可以根据实际情况进行选择。 图544聚类Anchor中K不同选择IOU的变化 5.6.4代码实战建议框的生成 首先,代码需要先计算GT BOX在哪个格子中,然后计算GT BOX与Anchor的IOU值,取最大IOU值所在的Anchor作为正样本,然后在GT BOX与挑选出来的Anchor之间计算偏移,参考代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V2_Detected/utils/tools.py def yolo_v2_true_encode_box(true_boxes, anchors=None, input_size=(416, 416)): """ YOLOv2的真值编码。需要编码成 GRID_NUM * GRID_NUM * (4 + 1 + class_num) * anchor_num :param true_boxes: 两个维度: 第1个维度: 一张图片中有几个实际框 第2个维度: [cx, cy, w, h, class],x和y 是框中心点坐标,w和h 是框的宽度和高度 x,y,w,h 均是除以图片分辨率(原始图片尺寸416*416)得到的[0,1]范围的比值 :param anchors: 实际anchor boxes 的值,论文中使用了5个。[w,h]都是相对于grid cell 的比值 :param input_size: true box中的输入图片的尺寸要与预测时的保持一致 :return: true_boxes2 13 × 13 × 5 × 4,返回它是为了方便背景损失的计算 detectors_mask 13 × 13 × 5 × 1,置信度 matching_true_boxes 13 × 13 × 5 × 5,GT BOX基于Anchor的偏移 """ if anchors is None: #默认设置的Anchor大小,需要乘以13 anchors = [[1.08, 1.19], [3.42, 4.41], [6.63, 11.38], [9.42, 5.11], [16.62, 10.52]] #anchors×13为真实Anchor anchors = np.array(anchors) * (input_size[0] //32) #输入图像的尺寸 height, width = input_size assert height % 32 == 0 #必须是32的倍数 assert width % 32 == 0 #特征图的尺寸,也就是13×13 conv_height, conv_width = height //32, width //32 #true box 的数量 num_box_params = true_boxes.shape[1] #5个Anchor NumPy_boxes = len(anchors) #初始一个Tensor,用来存储BOX正样本值13×13×5×4 true_boxes2 = np.zeros( [conv_height, conv_width, NumPy_boxes, 4], dtype=np.float32 ) #初始一个Tensor,用来存储置信度值13×13×5×1 detectors_mask = np.zeros( [conv_height, conv_width, NumPy_boxes, 1], dtype=np.float32 ) #13 × 13 × 5 × len(true_boxes), BOX+置信度,跟true boxes数保持一致 matching_true_boxes = np.zeros( [conv_height, conv_width, NumPy_boxes, num_box_params], dtype=np.float32 ) #对所有的true_boxes进行编码 for box in true_boxes: #置信度 box_class = box[4:5] #这样得到的是一个数组。如果是box[4],则得到的是一个 #具体值 #box = (13 × x,13 × y, 13 × w, 13 × h) 换算成相对grid cell的值 #[0.5078125 0.36830357 0.14955357 0.19642857] × 13 #[6.6015625 4.78794637 1.94419637 2.55357137] box = box[0:4] * np.array([ conv_width, conv_height, conv_width, conv_height ]) box_true = box.copy() #向下取整,计算中心点落在哪个格子中 i = np.floor(box[1]).astype('int') #i=4 j = np.floor(box[0]).astype('int') #j=6 best_iou = 0 best_anchor = 0 #将true_box与每个Anchor进行IOU,并取最佳的Anchor作为正样本 for k, anchor in enumerate(anchors): #true box 的 wh 1/2 box_maxes = box[2:4] * 0.5 box_mines = -box_maxes #Anchor BOX的wh 1/2 anchor_maxes = anchor * 0.5 anchor_mines = -anchor_maxes #将真实wh与Anchor之间进行IOU的计算,并获取最佳IOU是哪个Anchor intersect_mines = np.maximum(box_mines, anchor_mines) intersect_maxes = np.minimum(box_maxes, anchor_maxes) intersect_wh = np.maximum(intersect_maxes - intersect_mines, 0.) intersect_area = intersect_wh[0] * intersect_wh[1] box_area = box[2] * box[3] anchor_area = anchor[0] * anchor[1] iou_score = intersect_area / (box_area + anchor_area - intersect_area) #比较当前Anchor的IOU是否比上一次的IOU值大,如果大,则是最佳IOU,并记录是 #第几个k if iou_score > best_iou: best_iou = iou_score best_anchor = k if best_iou > 0: #当前示例best_iou=0.85,k=2 #detectors_mask为13×13×5×1,将最佳Anchor的置信度设置为1.0 #detectors_mask[4,6,2]=1.0 detectors_mask[i, j, best_anchor] = 1.0 #true_boxes2为13×13×5×4,即BOX的值 #true_boxes2[4,6,2]=[6.6015625 4.78794637 1.94419637 2.55357137] true_boxes2[i, j, best_anchor] = box_true #套公式,算偏移。cx - j, cy - i, np.log(w/w*),np.log(h/h*) adjusted_box = np.array([ box[0] - j, #gt_cx-j,gt_cy-i box[1] - i, np.log(box[2] / anchors[best_anchor][0]),#gt_w/anchors[2][0], #即gt_w/a_w np.log(box[3] / anchors[best_anchor][1]),#gt_h/anchors[2][1], #即gt_w/a_h box_class #GT BOX的label下标 ], dtype=np.float32) #matching_true_boxes 13 × 13 × 5 × 5 #matching_true_boxes[4,6,2] = offset值 matching_true_boxes[i, j, best_anchor] = adjusted_box #返回GT BOX,Anchor正样本的置信度,GT BOX基于Anchor的偏移 return true_boxes2, detectors_mask, matching_true_boxes def test_yolo_v2_true_encode(): data = np.array( [[0.5078125, 0.36830357, 0.14955357, 0.19642857, 1.]] , dtype=np.float32 ) return yolo_v2_true_encode_box(data) 代码中box=box[0:4]*13,box[0:4]是归一化后的值,乘以13得到[6.6015625 4.78794637 1.94419637 2.55357137],即从归一化值后放大到13×13的特征图的大小,向下取整则所在格子为i=4、j=6。box_maxes=box[2:4]*0.5得到GT BOX的中心点,因为计算IOU还需要左上角的值,所以box_mines=-box_maxes,得到best_iou=0.8,best_anchor=2,那么将detectors_mask[4,6,2]=1.0,表示第i=4、j=6格子中的第2个Anchor置信度为1,然后选择第2个Anchor并通过公式求与GT BOX的偏移值并赋给adjusted_box,最后matching_true_boxes[4,6,2]=adjusted_box,返回正样本的偏移值。 得到正样本的偏移值后,仍然像YOLOv1中的DataProcessingAndEnhancement(object)类的generate(self,isTraining=True)喂给训练数据,具体代码实际基本一致,详细可参考随书代码。 5.6.5代码实现损失函数的构建及训练 根据式(59)实现正样本格子i、j、k位置的损失、置信度的损失及分类概率的损失,核心代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V2_Detected/utils/loss.py class MultiAngleLoss(object): def yolo_v2_loss(self, y_true, y_pre): """ 求YOLOv2损失 :param y_pre: model预测值,需要解码 :param y_true: 真实值 :return: """ #预测值 #(1)对预测出来的offset解码成xmin,ymin,xmax,ymax,conf,class_score #对解码出来的值跟GT BOX之间做IOU,如果IOU>阈值,则为正样本置信度,否则为负样 #本置信度 yolo_output = y_pre true_boxes = y_true[..., :4] #真实坐标 b × 13 × 13 × 5 × 4 detectors_mask = y_true[..., 4:5] #置信度 b × 13 × 13 × 5 × 1 matching_true_boxes = y_true[..., 5:] #anchor b × 13 × 13 × 5 × 5 #对预测出来的值进行offset解码,解码成xmin,ymin,xmax,ymax boxes, pre_box_confidence, pre_box_class_props = yolo_v2_head_decode( yolo_output, self.anchors, self.num_classes, self.input_size ) #预测置信度 b×13×13×5×1 pre_box_confidence = tf.reshape( pre_box_confidence, [-1, self.conv_height, self.conv_width, self.num_anchors, 1] ) #预测分类 b×13×13×5×20 pre_box_class_props = tf.reshape( pre_box_class_props, [-1, self.conv_height, self.conv_width, self.num_anchors, self.num_classes] ) #预测xmin,ymin,xmax,ymax pre_boxes_xy = boxes[..., :2] pre_boxes_wh = boxes[..., 2:4] ######################################### #(2)再从pre_y中取出偏移值,是为了计算预测偏移与真实BOX偏移之间的损失 #将YOLO输出的[b, 13, 13, 125]转换为[b, 13, 13, 5, 25],5是Anchor数量 yolo_out_shape = yolo_output.shape[1: 3] #[b, 13, 13, 125] features = tf.reshape( yolo_output, [-1, yolo_out_shape[0], yolo_out_shape[1], self.num_anchors, self.num_classes + 5] ) #预测出来的是偏移值xy限制了值域,只能在0~1 pre_d_boxes = tf.concat([tf.nn.sigmoid(features[..., 0:2]), features[..., 2:4]], axis=-1) ####################################### #(3)由xmin,ymin,xmax,ymax转换成cx,cy,w,h,这是为了计算IOU #维度调整 pre_boxes_xy = tf.reshape(pre_boxes_xy, [-1, self.conv_height, self.conv_width, self.num_anchors, 2]) pre_boxes_wh = tf.reshape(pre_boxes_wh, [-1, self.conv_height, self.conv_width, self.num_anchors, 2]) #预测cx,cy,w,h,为了计算IOU pre_box = tf.concat([ (pre_boxes_xy - pre_boxes_wh) * 0.5, (pre_boxes_xy + pre_boxes_wh) * 0.5, ], axis=-1) ######################################## #(4)当GT BOX与pre box 之间IOU>阈值时,才认为object_detections有目标 #算一下true的xmin,ymin,xmax,ymax,方便做IOU的计算 true_box = tf.concat([ (true_boxes[..., 0:2] - true_boxes[..., 2:4]) * 0.5, (true_boxes[..., 0:2] + true_boxes[..., 2:4]) * 0.5, ], axis=-1) #得到预测框与真实框之间的IOU得分 iou_result = iou(pre_box, true_box) #1 × 13 × 13 × 5 #获得最大得分,即13 * 13 * 5 * 1 iou_score = tf.expand_dims(tf.reduce_max(iou_result, axis=-4), axis=-1) #过滤IOU要大于指定的置信度 object_detections = iou_score > self.overlap_threshold object_detections = tf.cast(object_detections, dtype=iou_score.dtype) ######################################## #(5)根据公式计算损失 #当预测框的IOU与真实框中的IOU小于0.6时都为背景 #没有目标物体的损失 1 - object_detections预测没有目标,1 - detectors_mask #真实没有目标 #tf.square(0-pre_box_confidence)没有目标的置信度 no_object_loss = self.no_obj_scale * ( 1 - object_detections ) * (1 - detectors_mask) * tf.square(0-pre_box_confidence) #有目标物体的损失 object_loss = self.obj_scale * detectors_mask * tf.square(1 - pre_box_confidence) #置信度损失=没有目标物体的损失+有目标物体的损失 confidence_loss = tf.reduce_sum(object_loss + no_object_loss) #分类损失 matching_classes = tf.cast(matching_true_boxes[..., 4], 'int32') matching_classes = tf.one_hot(matching_classes, self.num_classes) classification_loss = tf.reduce_sum( self.class_scale * detectors_mask * tf.square(matching_classes - pre_box_class_props) ) #boxes loss,计算的是偏移值之间的误差 box_loss = tf.reduce_sum( self.coordinates_scale * detectors_mask * tf.square(matching_true_boxes[..., 0:4] - pre_d_boxes) ) #所有损失 total_loss = (confidence_loss + classification_loss + box_loss) * 0.5 return total_loss 此损失计算的代码较长,共由5个步骤构成。第1步,对预测出来的y_pre进行解码,y_pre输出的是预测值基于Anchor的偏移,根据以下公式可计算出预测框的左上、右下坐标。 Px=Sigmoid(tx)+cx Py=Sigmoid(ty)+cy Pw=Awetw(511) Ph=Aheth 其中,tx、ty为预测的偏移值,cx、cy为每个13×13的坐标值,tw、th为预测框与Anchor的偏移值。Aw、Ah为Anchor的w、h。Px、Py、Pw、Ph为解码出来的cx、cy、w、h,其具体的代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V2_Detected/utils/tools.py def yolo_v2_head_decode(features, anchors=None, num_classes=20, input_size=(416, 416)): """ YOLO预测边界框的中心点相对于网格左上角的偏移值,而每个网格有5个Anchor,然后套用公式便可得到实际位置 features:预测出来的值conv。预测出来的是偏移值 [None, 13, 13, (4 + 1 + num_classes) * 5] anchors:Anchor的widths和heights num_classes:分类数 :return: boxes : 返回xmin,ymin,xmax,ymax,这是为了方便求背景损失的计算,实际上求位置的损失没用这个返回值 box_confidence: 置信度 box_class_props: 类别,类别是进行了Softmax的 """ height, width = input_size assert height % 32 == 0#必须是32的倍数 assert width % 32 == 0 conv_height, conv_width = height //32, width //32 if anchors is None: anchors = [[1.08, 1.19], [3.42, 4.41], [6.63, 11.38], [9.42, 5.11], [16.62, 10.52]] #即anchors×13 anchors = np.array(anchors) * (input_size[0] //32) anchor_size = tf.constant(len(anchors)) #将输入的b×13×13×125 reshape成 b × 169×5×25 features = tf.reshape(features, [features.shape[0], conv_height * conv_width, anchor_size, num_classes + (4 + 1)]) #因为预测出来的是相对于该左上角的偏移值,Sigmoid函数归一化到(0,1)之间 xy_offset = tf.nn.sigmoid(features[..., 0:2]) #置信度 box_confidence = tf.sigmoid(features[..., 4:5]) #wh偏移 wh_offset = tf.exp(features[..., 2:4]) #类别进行Softmax输出 box_class_props = tf.nn.softmax(features[..., 5:]) #在feature上面生成anchors height_index = tf.range(conv_height, dtype=tf.float32) width_index = tf.range(conv_width, dtype=tf.float32) #得到网格13×13 x_cell, y_cell = tf.meshgrid(height_index, width_index) #和上面[h,w,num_anchors,num_class+5]对应 x_cell = tf.reshape(x_cell, [1, -1, 1]) # y_cell = tf.reshape(y_cell, [1, -1, 1]) #根据网格求坐标位置,套公式 bbox_x = (x_cell + xy_offset[..., 0]) / conv_height bbox_y = (y_cell + xy_offset[..., 1]) / conv_width bbox_w = (anchors[:, 0] * wh_offset[..., 0]) / conv_height bbox_h = (anchors[:, 1] * wh_offset[..., 1]) / conv_width #由cx,cy,w,h转换成xmin, ymin, xmax, ymax boxes = tf.stack( [ bbox_x - bbox_w / 2, bbox_y - bbox_h / 2, bbox_x + bbox_w / 2, bbox_y + bbox_h / 2 ], axis=3 ) return boxes, box_confidence, box_class_props 在解码代码中x_cell和y_cell为每个格子的坐标,x_cell+xy_offset[...,0]为每个偏移值解码后的x值,除以conv_height=13是为了归一化操作,如图545所示。 图545预测偏移值解码 损失函数的第2、第3步是从预测值中得到预测的偏移值pre_d_boxes=tx,ty,tw,th,同时从预测解码boxes中得到pre_box=cx,cy,w,h,以便计算真实BOX与预测BOX的IOU,如图546所示。 图546从预测值中获取预测偏移值 第4步,将解码出来的pre_boxes与true_boxes做IOU,当IOU>0.5时置为正样本mask的object_detections,如图547所示。 图547IOU>0.5时的mask 第5步,根据式(511)计算没有目标的置信度损失、有目标的置信度损失、分类损失和偏移值之间的损失,如图548所示。 图548损失计算 在训练方面,先使用224×224训练DarkNet19的权重并迁移到YOLOv2的主干中,然后使用不同尺度的大小进行训练,例如320、352、608,然后在416上进行微调,这样训练将使模型具备不同分辨率图像的泛化能力,稳健性更强,关键代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V2_Detected/utils/train_YOLOv2.py if __name__ == "__main__": input_shape = [ (320, 320, 3), (352, 352, 3), (608, 608, 3), (416, 416, 3) ] #每个尺度的训练次数 train_EPOCH = [10, 20, 30, 50] #每个尺度训练的学习率 learn = [1e-3, 1e-4, 1e-5, 1e-5] old_epoch = 0 #上一次的epoch num old_name = 0 #上一次训练的权重名 #构建不同尺度的图形模型训练 for im_shape, epoch, lr, save_dir in zip(input_shape, train_EPOCH, learn, save_weights): model = yolo_v2(im_shape, num_classes, is_class=False) #如果已经有训练好的其他尺度的权重文件,则作为下一个尺度的初始权重 if not old_name: exist_weights = f"../weights/last_{old_name}.h5" if os.path.exists(exist_weights): model.load_weights(exist_weights, by_name=True, skip_mismatch=True) else: #第1个尺寸调入分类的训练权重 model.load_weights('../weights/darknet_10_224.h5', by_name=True, skip_mismatch=True) #每隔3个epoch设置检测点并保存最优模型 checkpoint = ModelCheckpoint( save_dir, monitor='val_loss', save_weights_only=False, save_best_only=True, period=check_step_epoch ) #设置优化器 model.compile( optimizer=Adam(learning_rate=lr), loss=MultiAngleLoss(num_classes=num_classes, input_size=im_shape [:2]).yolo_v2_loss, run_eagerly=False, #是否启用调试模型 ) #加载数据类 data_object = DataProcessingAndEnhancement( train_lines, val_lines, num_classes, batch_size=BATCH_SIZE, input_shape=im_shape[0:2] ) #训练 model.fit( data_object.generate(True), steps_per_epoch=num_train //BATCH_SIZE, validation_data=data_object.generate(False), validation_steps=num_val //BATCH_SIZE, epochs=epoch, initial_epoch=old_epoch, callbacks=[logging, checkpoint] ) old_epoch = epoch old_name = im_shape[0] #保存模型 model.save(f"../weights/last_{old_name}.h5") 更多更详细的代码可参考随书代码。 5.6.6代码实战预测推理 YOLOv2引入了5个Anchor并根据式(511)对预测值进行解码操作,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V2_Detected/detected/detected.py class Detected(): def __init__(self, model_path, input_size): self.model = load_model( model_path, custom_objects={'compute_loss': MultiAngleLoss(2).compute_loss} ) #读取模型和权重 self.confidence_threshold = 0.01 #有无目标置信度 self.class_prob = [0.5, 0.5] #两个类别的分类阈值 self.nms_threshold = 0.5 #NMS的阈值 self.input_size = input_size self.anchors = np.array([ (1.3221, 1.73145), (3.19275, 4.00944), (5.05587, 8.09892), (9.47112, 4.84053), (11.2364, 10.0071) ]) def readImg(self, img_path=None): img = cv2.imread(img_path) #读取要预测的图片 #将图片转换到300×300 self.img, _ = u.letterbox_image(img, self.input_size, []) self.old_img = self.img.copy() def forward(self): #升成4维 img_tensor = tf.expand_dims(self.img / 255.0, axis=0) #前向传播 self.output = self.model.predict(img_tensor) self.h, self.w = self.output.shape[1:3] #1*13*13*(4+1+num_class)*5 def generate_anchor(self): #使用均分的方法生成格子。一共有h×w个格子,即169个格子 self.lin_x = np.ascontiguousarray(np.linspace(0, self.w - 1, self.w).repeat(self.h)).reshape(self.h * self.w) self.lin_y = np.ascontiguousarray(np.linspace(0, self.h - 1, self.h).repeat(self.w)).T.reshape(self.h * self.w) #得到锚框的w和h,因为一共有5个Anchor,所以reshape成[1,5,1] self.anchor_w = np.ascontiguousarray(self.anchors[..., 0]).reshape([-1, 5, 1]) self.anchor_h = np.ascontiguousarray(self.anchors[..., 1]).reshape([-1, 5, 1]) def _decode(self): #对预测出来的结果解码 #YOLOv2的输出(4+1+num_class) ×5个Anchor self.b = self.output.shape[0] #变成[b,5,(4+1+2),169) logits = np.reshape(self.output, [self.b, 5, -1, self.h * self.w]) self.result = np.zeros(logits.shape) #套用公式进行解码,Sigmoid()函数进行值域限制 #根据Anchor得到cx和cy self.result[:, :, 0, :] = (tf.sigmoid(logits[:, :, 0, :]).NumPy() + self.lin_x) / self.w self.result[:, :, 1, :] = (tf.sigmoid(logits[:, :, 1, :]).NumPy() + self.lin_y) / self.h #根据Anchor得到w和h self.result[:, :, 2, :] = (tf.exp(logits[:, :, 2, :]).NumPy() * self.anchor_w) / self.w self.result[:, :, 3, :] = (tf.exp(logits[:, :, 3, :]).NumPy() * self.anchor_h) / self.h #得到置信度 self.result[:, :, 4, :] = tf.sigmoid(logits[:, :, 4, :]).NumPy() #得到分类 self.result[:, :, 5:, :] = tf.nn.softmax(logits[:, :, 5:, :]).NumPy() def classification_filtering(self): self._decode() #取最大的分类得分的下标,此时result为[b,5,(4+1+num_class),169] class_score = self.result[:, :, 5:, :] #[1,5,num_class,169] class_label = np.argmax(class_score, axis=2) #[1,5,169] #最大的分类得分值 score = np.max(class_score, axis=2) #[1,5,169] #当前格子的置信度 conf = self.result[:, :, 4, :] #[1,5,169] #每个格子最后的得分为置信度*分类得分 prob = score * conf #[1,5,169] #根据阈值,得到评分 score_mask = prob > self.confidence_threshold #[1,5,169] if np.sum(score_mask.reshape([-1])) > 0: #只有转置成 [b×5×(4+1+num_class) ×169],才能根据score_mask进行取值, #最后需要得到[b,5,169,7]中的7 self.result = np.reshape(self.result, [self.b, 5, self.h * self.w, -1]) boxes = self.result[score_mask][..., :4] #根据score_mask过滤得到boxes cls_scores = prob[score_mask] #根据score_mask过滤得到cls_scores idx = class_label[score_mask] #根据score_mask过滤得到labels index #根据类别进行非极大值抑制 for label in range(len(self.class_prob)): #如果class_labels_all==label,则取当前label中的信息 mask = idx == label #如果都不是当前label,则跳过 if np.sum(mask) == 0: continue #由cx,cy,w,h转换成xmin,ymin,xmax,ymax xyxy_box = u.cxcy2xyxy(boxes[mask][..., :4]) cat_boxes = np.concatenate([xyxy_box, cls_scores[mask].reshape ([-1, 1])], axis=-1) #NMS index = u.nms(cat_boxes, self.nms_threshold) #绘框 self.old_img = u.draw_box(self.old_img, cat_boxes[index]) #最后结果 u.show(self.old_img) if __name__ == "__main__": d = Detected(r'../weights/chk416', input_size=[416, 416]) d.readImg('../../val_data/pexels-photo-5211438.jpeg') d.forward() d.generate_anchor() d.classification_filtering() 在__init__()中增加self.anchors以描述先验框的wh值,forward()前向传播后得到的shape为[b,13,13,(4+1+num_class)*5],如图549所示。 图549YOLOv2前向传播输出 在generate_anchor()中根据np.linspace(0,self.w-1,self.w)指令将输入图像均分为13份,为了组成完整坐标,所以repeat(self.h)重复了13次,摊平后reshape(self.h*self.w)得到169个坐标并存储在self.lin_x变量中,以同样的方法得到self.lin_y。在self.anchor_w、self.anchor_h分别得到锚框的宽和高,如图550所示。 图550YOLOv2画格子 _decode()首先将self.output由[b,13,13,(4+1+num_class)*5]变成[b,5,(4+1+num_class),13*13],然后根据self.result[:, :, 0, :]的位置对于预测的offset值根据式(511)进行解码,并采用tf.sigmoid()函数进行值域限定,如图551所示。 图551YOLOv2解码操作 在classification_filtering()中对于解码出的内容进行score_mask=prob>self.confidence_threshold的过滤,当置信度×分类概率的值大于self.confidence_threshold时,score_mask为True,否则为False。此时由于self.result为[b,5,(4+1+num_class),169],而score_mask为[1,5,169],所以需要将self.result的shape转换np.reshape(self.result,[self.b,5,self.h*self.w, -1])变成[b,5,169,(4+1+num_class)],从而boxes=self.result[score_mask][..., :4]得到过滤后的boxes内容,继而得到分类概率cls_scores、置信度概率cls_scores,如图552所示。 图552YOLOv2过滤大于条件概率值 遍历for label in range(len(self.class_prob))每个类别的下标,当mask=idx==label时从boxes中取出当前类别的boxes,然后通过xyxy_box=u.cxcy2xyxy(boxes[mask][...,:4]),由cx,cy,w,h转换成xmin,ymin,xmax,ymax,最后进行NMS,从而得出预测框的内容,如图553所示。 图553YOLOv2根据类别索引过滤 总结 YOLOv2采用Anchor机制,并划分为13×13个格子,每个格子有5个Anchor,由GT BOX落在某个格子所在的最佳Anchor进行预测。Anchor的生成采用聚类算法获得,该方法在YOLO系列得到延续。 练习 运行并调试本节代码,理解算法的设计与代码的结合,重点梳理本算法的实现方法。 5.7单阶段速度快多检测头网络YOLOv3 5.7.1模型介绍 YOLOv3由Joseph Redmon等在2018年发表的论文YOLOv3: An Incremental Improvement中提出,其主要特点是在YOLOv2的基础上将主干特征提取网络换成DarkNet53,使用了FPN。检测头变为3个,建议框的数量从YOLOv2的5个变成9个,每个检测头分配3个建议框。损失函数方面,分类损失和置信度损失从均方差更换为交叉熵,其结构如图554所示。 图554YOLOv3结构图 YOLOv3在主干特征提取层引入了Residual差结构,可以使网络更稀疏、网络层数更深,以便更好地提取特征信息,如图554所示,分别重复1、2、8、8、4次。输出特征13×13×1024后经过Convolutional Set 512层,接3×3、1×1卷积输出13×13×3建议框(4偏移位置+1置信度+80个分类)作为第1个检测头,检测大目标(13×13×255的感受野最大,所以检测大目标)。 Convolutional Set 512后经过1×1卷积、2倍Up Sampling上采集得26×26×256,与R5的输出Concat得26×26×768,然后Convolutional Set 256,接3×3、1×1卷积输出26×26×3建议框(4偏移位置+1置信度+80个分类)作为第2个检测头,检测中目标。 Convolutional Set 256后经过1×1卷积、2倍Up Sampling上采集得52×52×128,与R5的输出Concat得26×26×384,然后Convolutional Set 128,接3×3、1×1卷积输出52×52×3建议框(4偏移位置+1置信度+80个分类)作为第3个检测头,检测小目标。 在CNN结构中深层网络语义特征信息丰富,浅层特征几何信息丰富,在目标检测任务中特征提取是一个很重要的问题,深层网络虽然能够得到丰富的语义特征信息,但是由于特征图的尺寸较小,所以包含的几何信息较少,不利于物体的位置检测,潜层网络虽然包含了丰富的几何信息,但是图像的语义信息较少,又不利于图像的分类预测,这个问题尤其在小目标检测任务中表现尤为明显。 回顾Faster RCNN、YOLOv1等只使用最深层的特征图信息,单尺度特征图限制了模型的检测能力,尤其是那些较小的样本或者数量较少的建议框尺寸。SSD利用卷积的层次结构,从VGG中的Conv4_3、Conv7、Conv8_2、Conv9_2、Conv10_2、Conv11_2得到多尺度特征信息,该方法虽然能提高精度并且检测速度略有下降,但由于没有使用更加深层的特征信息,所以对于检测小目标仍然不够稳健,如图555所示。 图555不同的检测头结构 FPN在SSD的结构上不仅使用了深层特征图的信息,并且浅层网络的特征信息也被使用,并通过自底向上、自顶向下及横向连接的方式对这些特征图的信息进行整合,在提升精度的同时检测速度也没有较大降低,其结构如图556所示。 图556FPN结构 FPN自上而下、由底而上进行了特征信息的融合,将语义信息与几何信息进行融合,有助于小目标的特征信息提取,能够显著地提高小目标的检测能力。 在FPN进行上采样时,可选择最近邻插值、双线性插值的方法放大图像。最近邻插值在放大图时补充的像素是最近邻的像素值,由于方法简单,所以处理速度很快,但是当放大图像时会有锯齿,画质较低,如图557所示。 已知A=(x0,y0)、B=(x1,y1),将(x0,y0)和(x1,y1)连成一条直线,求区间(x0,x1)上某一点x在该直线上的y值,这个求解过程就是单线性插值的过程,如图558所示。 图557最近邻插值 图558单线性插值 因为y1-yx1-y=y-y0x-x0,所以y=y0+x1-yx1-x(x-x0)=x-x0x1-x0y1+x1-xx1-x0y0,同样已知y也可求出x,代码如下: #第5章/ObjectDetection/TesnsorFlow_YOLO_V3_Detected/utils/interp.py def interp(): #x值 x = np.arange(0, 10, 0.5) y = x**2 #插入点为xvals x_vals = np.linspace(0, 10, 5) y_interp = np.interp(x_vals, x, y) for ix,iy in zip(x_vals,y_interp): plt.text(ix,iy+1,f"{ix},{iy}") plt.plot(x, y, 'o',label="xy轴") plt.plot(x_vals, y_interp, '-x',label='线性插值') plt.legend() plt.show() 调用代码,运行后单线性插值如图559所示。 在卷积图像中图像的维度是三维的,在进行上采样时就需要用到双线性插值,如图560所示。 图559单线性插值随x_vals变化 图560双线性插值算法 所谓双线性插值,原理与线性插值相同,如图560中已知点Q11=(x1,y1)、Q12=(x1,y2)、Q21=(x2,y1)和Q22=(x2,y2)共四个点的值,求函数f(x,y)在P=(x,y)的值。 首先做两次线性插值,分别求出点R1=(x,y1)和R2=(x,y2)的像素值,然后用这两个点再做1次线性插值以求出P=(x,y)的像素值。对于Q11和Q12来讲,它们连成线的纵坐标是相同的,所以可以忽略这个纵坐标的影响(但这个影响是存在的,所以线性插值是近似的),而用当前点的像素值直接代替纵坐标,所以Q11=(x,f(Q11))。f(R1)≈x-x1x2-x1f(Q21)+x2-xx2-x1f(Q11),f(R2)≈x-x1x2-x1f(Q22)+x2-xx2-x1f(Q12),f(P)≈y-y1y2-y1f(R2)+y2-yy2-y1f(R1)=y-y1y2-y1,y2-yy2-y1f(Q22),f(Q12)f(Q22),f(Q12)x-x1x2-x1,x2-xx2-x1T,在双线性插值上采样时x2-x1=1、y2-y1=1。 双线性插值是一种比较好的图像缩放算法,它充分利用一源图像中虚拟的点四周的4个真实存在的像素来共同决定目标图像中的一像素,这样所生成的新图效果更好,过渡更自然,边缘更光滑,如图561所示的效果比图557要好得多。 图561双线性插值效果图 YOLOv3虽然有3个检测头,但是它正样本的选取仍然跟YOLOv2一样,即从检测头13×13、26×26、52×52生成的Anchor与标注BOX做IOU,取最大的IOU得分作为正样本,其他样本作为负样本。稍有不同之处,3个检测头分配的建议框尺寸有变化,13×13分配大一些的建议框,如[116,90]、[156,198]、[373,326]; 26×26分配中等大小的建议框,如[30,61]、[62,45]、[59,119]; 52×52分配小目标的建议框,如[10,13]、[16,30]、[33,23]。这些建议框仍然通过聚类得到,一共有9个Anchor。 在损失函数方面,将YOLOv2中的分类损失更改为二元交叉熵、位置损失,仍然使用均方差损失,置信度损失使用交叉熵,其公式如下: Loss=λcoord∑s2i=0∑Bj=0lobji,j[(bx-b^x)2+(by-b^y)2+(bw-b^w)2+ (bh-b^h)2]+∑s2i=0∑Bj=0lobji,j[-log(pc)]+ λnoobj∑s2i=0∑Bj=0lnoobji,j[-log(1-pc)]+∑ni=1BCE(c^i,ci)(512) 其中,lobji,j表示每个格子中有目标; bx、by、bw、bh代表真实BOX的位置,b^x、b^y、b^w、b^h代表预测的BOX位置; -log(pc)表示有目标的置信度、-log(1-pc)代表没有目标的置信度; BCE(c^i,ci)为二次交叉熵,即逻辑回归的损失函数,每个类为0或者1; 原作者论文并没有说明具体的损失函数,不同的代码作者所使用的损失函数可能有所不同。 5.7.2代码实战模型搭建 因为YOLOv3使用了残差结构,所以需要对Residual进行实现,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V3_Detected/utils/conv_utils.py class ResidualBlock(layers.Layer): def __init__(self, num_filters, num_blocks, **kwargs): """ 基本残差模块,即a 3 × 3 channels,接着b 1 × 1 channels/2,再c 3 × 3 channels 然后a + c :param num_filters: channels数 :param num_blocks: 重复次数 """ super(ResidualBlock, self).__init__(**kwargs) #3×3卷积,s=2 因为padding=same,所以尺寸不变 self.conv1 = ConvBnLeakRelu(num_filters, kernel_size=3, strides=2) #1×1卷积,s=1,outchannel是输入的1/2 self.block1 = ConvBnLeakRelu(num_filters //2, kernel_size=1, strides=1, padding='valid') #1×1卷积,s=1 self.block2 = ConvBnLeakRelu(num_filters, kernel_size=3, strides=1) self.add = layers.Add() self.num_blocks = num_blocks def call(self, inputs, *args, **kwargs): #输入的卷积 x1 = self.conv1(inputs) #残差可能会执行多次 for i in range(self.num_blocks): #1×1 y = self.block1(x1) #1×1 y = self.block2(y) #进行add残差 x1 = self.add([x1, y]) return x1 ConvBnLeakRelu中已对Conv、BN、LeakRelu进行了封装。self.num_blocks用来控制残差重复的次数。 然后根据结构图554实现DarkNet53,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V3_Detected/backbone/YOLOv3.py def darknet_body(input_x): """根据结构图实现DarkNet-53部分""" #input 416 × 416 × 3 x = ConvBnLeakRelu(32, 3, 1)(input_x) #重复的次数是1,2,8,8,4 #-> 208 × 208 × 64 x = ResidualBlock(64, 1)(x) #-> 104 × 104 × 128 x = ResidualBlock(128, 2)(x) #-> 52 × 52 × 256 result4 = ResidualBlock(256, 8)(x) #-> 26 × 26 × 512 result5 = ResidualBlock(512, 8)(result4) #-> 13 × 13 × 1024 result6 = ResidualBlock(1024, 4)(result5) return result4, result5, result6 因为YOLOv3使用13×13×1024作为第1个检测头,使用26×26×512、52×52×256作为FPN上采样Concat层,所以输出为result4、result5和result6。 在图554中还存在Convolutional Set结构,该结构主要是由1×1、3×3、1×1、3×3、1×1卷积构成的,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V3_Detected/utils/conv_utils.py class ConvolutionSet(layers.Layer): """ 即1 × 1 -> 3 × 3 -> 1 × 1 -> 3 × 3 -> 1 × 1 """ def __init__(self, number_filters, **kwargs): super(ConvolutionSet, self).__init__(**kwargs) self.conv1x1_1 = ConvBnLeakRelu(number_filters, kernel_size=1, strides=1) self.conv3x3_2 = ConvBnLeakRelu(number_filters * 2, kernel_size=3, strides=1) self.conv1x1_3 = ConvBnLeakRelu(number_filters, kernel_size=1, strides=1) self.conv3x3_4 = ConvBnLeakRelu(number_filters * 2, kernel_size=3, strides=1) self.conv1x1_5 = ConvBnLeakRelu(number_filters, kernel_size=1, strides=1) def call(self, inputs, *args, **kwargs): x = self.conv1x1_1(inputs) x = self.conv3x3_2(x) x = self.conv1x1_3(x) x = self.conv3x3_4(x) x = self.conv1x1_5(x) return x 在检测头方面由3×3、1×1卷积组成,并且1×1卷积的输出采用线性模型,不经过ReLU也不经过BN,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V3_Detected/utils/conv_utils.py class Yolo_V3_Pre_Head(layers.Layer): """预测的头部,即先经过一个3×3卷积,再经过一个1×1卷积""" def __init__(self, number_filters, classes_filters, **kwargs): super(Yolo_V3_Pre_Head, self).__init__(**kwargs) self.conv3 = ConvBnLeakRelu(number_filters, kernel_size=3, strides=1) self.out = ConvBnLeakRelu(classes_filters, kernel_size=1, is_relu= False, is_bn=False, strides=1) def call(self, inputs, *args, **kwargs): x = self.conv3(inputs) x = self.out(x) return x 基于以上结构的封装,然后组合在yolov3()函数中实现前向传播,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V3_Detected/backbone/yolo_v3.py def yolo_v3(input_shape, anchors=None, class_num=80, is_class=False): if anchors is None: anchors_mask = np.array([ [[10, 13], [16, 30], [33, 23]], #小目标 [[30, 61], [62, 45], [59, 119]], #中目标 [[116, 90], [156, 198], [373, 326]] #大目标 ]) else: anchors_mask = anchors #输入层 input_x = layers.Input(shape=input_shape, dtype='float32') #调用DarkNet-53 r4, r5, r6 = darknet_body(input_x) #经过基本的DarkNet后,先经过一个ConvolutionalSet,进入第1个预测 x = ConvolutionSet(512)(r6) #第1个预测结果,小目标 p5 = Yolo_V3_Pre_Head(512, len(anchors_mask[0]) * (class_num + 4 + 1))(x) #1个置信度 + 4个bbox,每个anchors有3个尺度 #第1个上采样 p5_1x1 = ConvBnLeakRelu(256, kernel_size=1, strides=1)(p5) p5_up = layers.UpSampling2D(size=2)(p5_1x1) #2倍上采样 x = layers.Concatenate()([p5_up, r5]) #26×26 x = ConvolutionSet(256)(x) #第1个预测,中目标 p6 = Yolo_V3_Pre_Head(256, len(anchors_mask[1]) * (class_num + 4 + 1))(x) #第1个上采样 p6_1x1 = ConvBnLeakRelu(128, kernel_size=1, strides=1)(p6) p6_up = layers.UpSampling2D(size=2)(p6_1x1) x = layers.Concatenate()([p6_up, r4]) #52×52 x = ConvolutionSet(128)(x) #第2个预测,大目标 p7 = Yolo_V3_Pre_Head(128, len(anchors_mask[2]) * (class_num + 4 + 1))(x) return Model(inputs=input_x, outputs=[p5, p6, p7]) 建议框anchors_mask默认初始设置为9个,其中每个检测头为3个。r4、r5和r6分别对应52×52×256、26×26×512和13×13×1024,所以r6经过ConvolutionSet后成为p5的输入,p5的输出为len(anchors_mask[0])*(class_num+4+1)*512; p5经过2倍上采样(默认为最近邻),然后由layers.Concatenate()([p5_up,r5])得到26×26×768并经过ConvolutionSet后成为p6的输入,p6的输出为len(anchors_mask[0])*(class_num+4+1)*256; p6经过2倍上采样,然后由layers.Concatenate()([p6_up,r4])得到52×52×384,并经过ConvolutionSet后成为p7的输入,p7的输出为len(anchors_mask[0])*(class_num+4+1)*128。 5.7.3代码实战建议框的生成 将真实框转换为GT BOX与Anchor的偏移,其实现思路是遍历每个检测头和每个Anchor以获取所在j、i格子最佳IOU得分的Anchor作为正样本,计算偏移后赋给true_box,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V3_Detected/utils/tools.py def YOLOv3_v3_true_encode_box( true_boxes, #传GT BOX anchors=None, #新的Anchor class_num=80, #类别数 input_size=(416, 416), #默认尺寸 ratio=[32, 16, 8] #416//13, 416//26, 416//52 ): #将3个检测头Anchor与GT BOX最大IOU设为正样本 if anchors is None: anchors = np.array([ [[10, 13], [16, 30], [33, 23]], #小目标 [[30, 61], [62, 45], [59, 119]], #中目标 [[116, 90], [156, 198], [373, 326]] #大目标 ]) #Anchor的数量 detected_num = anchors.shape[0] #用来存储3个检测头正样本结果 grid_shape = [] #用来存储3个检测头GT BOX结果 true_boxes2 = [] #升维 true_boxes = np.expand_dims(true_boxes, axis=0) #batch size batch_size = true_boxes.shape[0] #每个检测头分配为0的grid矩阵 for i in range(detected_num): #input_size[0] //ratio[i] = 13,获得格子数 grid = np.zeros( [batch_size, input_size[0] //ratio[i], input_size[1] //ratio[i], len(anchors[i]), 4 + 1 + class_num], dtype=np.float32) grid_shape.append(grid) # true_boxes2.append( np.zeros([batch_size, input_size[0] //ratio[i], input_size[1] //ratio[i], len(anchors[0]), 4 + 1 + class_num], dtype=np.float32)) #3个检测头的grid grid = grid_shape #遍历每个GT BOX for box_index in range(true_boxes.shape[1]): #根据下标取是第几个GT BOX box = true_boxes[:, box_index, :] box = np.squeeze(box, axis=0) #降维 box_class = box[4].astype('int32') best_choice = {} #将每个GT BOX与每个检测头中的Anchor进行IOU的计算 for index in range(0, 3, 1): #即input_size[0] //ratio[i] = 13 ratio_input = grid[index].shape[-3] #因为BOX是归一化的值 × 13,放大到13×13的特征图的尺寸 box2 = box[0:4] * np.array([ ratio_input, ratio_input, ratio_input, ratio_input ]) #内存复制 box_true = box2.copy() box2 = box2.copy() #算是第ij个格子 i = np.floor(box2[1]).astype('int') j = np.floor(box2[0]).astype('int') #如果ij>13就继续下一个BOX,说明这个BOX可能标注错误 if i > ratio_input or j > ratio_input: continue #最佳IOU best_iou = 0 #最佳Anchor; 接下来,将true_box与Anchor进行IOU,并取最佳Anchor用来与 #预测的内容进行loss best_anchor = 0 #遍历每1个Anchor以获取最佳IOU作为正样本 for k, anchor in enumerate(anchors[2 - index]): #wh center box_maxes = box2[2:4] * 0.5 box_mines = -box_maxes # anchor_maxes = anchor * 0.5 anchor_mines = -anchor_maxes #将真实wh与Anchor之间进行IOU的计算,并获取最佳IOU是哪个Anchor intersect_mines = np.maximum(box_mines, anchor_mines) intersect_maxes = np.minimum(box_maxes, anchor_maxes) intersect_wh = np.maximum(intersect_maxes - intersect_mines, 0.) intersect_area = intersect_wh[0] * intersect_wh[1] box_area = box2[2] * box2[3] anchor_area = anchor[0] * anchor[1] #IOU得分 iou_score = intersect_area / (box_area + anchor_area - intersect_area) #如果IOU得分>上1次的得分,就更改best_iou,并记录k if iou_score > best_iou: best_iou = iou_score best_anchor = k #记录下来与最佳best_iou相关的参数 #[历史GT BOX, 第几个检测头, 最佳IOU得分,GT BOX,格子j,格子i,最佳第几个 #Anchor,历史GT BOX,历史GT BOX] best_choice[best_iou] = [box_true, index, best_iou, box_index, j, i, best_anchor, box2, box] #按最佳IOU的得分进行排序,获得最大IOU best_iou_choice = best_choice[sorted(best_choice.keys(), reverse=True)[0]] #从best_iou_choice中获得最佳box,j,i的信息 box = best_iou_choice[-1] j = best_iou_choice[-5] i = best_iou_choice[-4] box2 = best_iou_choice[-2] index = best_iou_choice[1] best_anchor = best_iou_choice[-3] box_true = best_iou_choice[0] #对最佳box2进行偏移值的求解 adjusted_box = np.array([ box2[0] - j, box2[1] - i, np.log(box2[2] / anchors[2 - index][best_anchor][0]), np.log(box2[3] / anchors[2 - index][best_anchor][1]), ], dtype=np.float32) #检测头[第几个][...,j格子,i格子,最佳anchor,:4] = 偏移值 grid[index][..., j, i, best_anchor, 0:4] = adjusted_box #置信度 grid[index][..., j, i, best_anchor, 4] = 1 #分类信息 box_class分类的下标,假设为1,则为2 grid[index][..., j, i, best_anchor, 5 + box_class] = 1 #获取真值,方便后面计算损失时做IOU true_boxes2[index][..., j, i, best_anchor, 0:4] = box_true return true_boxes2, grid def test_yolo_v3_true_encode_box(): data = np.array( [[0.50, 0.47, 0.05, 0.12, 0], [0.50, 0.47, 0.05, 0.12, 1]] , dtype=np.float32 ) return YOLOv3_v3_true_encode_box(data) 代码中for i in range(detected_num)初始化设置3个grid,即设置3个检测头为0的矩阵,用来存放正样本相关值。for box_index in range(true_boxes.shape[1])循环每个true box(可能有多个),for index in range(0, 3, 1)循环每个检测头,for k, anchor in enumerate(anchors[2-index])循环每个检测头分配的第k个Anchor,best_iou=iou_score得到最佳的得分,best_anchor=k得到最佳Anchor,best_choice[best_iou]=[box_true,index,best_iou,box_index,j,i,best_anchor,box2,box]记录下来与最佳best_iou相关的参数,因为best_choice是一个字典,如果best_iou有相同得分,则将会被替换掉,如果没有,则会保留下来。 3个检测头和Anchor循环结束后,best_iou_choice=best_choice[sorted(best_choice.keys(),reverse=True)[0]]根据best_choice.keys()即IOU的得分进行降序排列,从而得到当前最佳的best_iou_choice信息,如图562所示。 图562best_iou的计算示例 然后根据best_iou_choice中的信息,计算adjusted_box偏移值,并同时赋给grid[index]相关值。此时就实现某个GT BOX从3个检测头中得到最佳IOU分配到某个检测头中,如图563所示。 图563正样本赋值 代码中的GT BOX将分配到第2个检测头(26×26),第j=26、i=24的第1个Anchor为正样本,其他都为负样本。 得到正样本的偏移值后,仍然像YOLOv1中的DataProcessingAndEnhancement(object)类的generate(self,isTraining=True)喂给训练数据,具体代码实现基本一致,详细可参考随书代码。 5.7.4代码实现损失函数的构建及训练 YOLOv3的损失函数与YOLOv2的损失函数基本类似,不同之处在于需要将3个检测头的损失相加,另外在计算分类损失时使用二元交叉熵损失,关键代码参考如下: #第5章/ObjectDetection/TensorFlow_Yolo_V3_Detected/utils/loss.py class MultiAngleLoss(object): def __init__(self, wh_scale=0.5, overlap_threshold=0.5, num_layers=3, num_class=80, anchors=None): #置信度 self.overlap_threshold = overlap_threshold #wh损失的比例 self.wh_scale = wh_scale self.num_layers = num_layers self.num_class = num_class if anchors is None: self.anchor = np.array([ [[10, 13], [16, 30], [33, 23]], #小目标 [[30, 61], [62, 45], [59, 119]], #中目标 [[116, 90], [156, 198], [373, 326]] #大目标 ]) else: self.anchor = anchors def yolo_v3_loss(self, y_true, y_pred, true_box2): #true_box2传入的是原标签值 #有几个检测头 num_layers = self.num_layers #将数据类型转换成Tensor mf = tf.cast(y_pred[0].shape[0], dtype=y_pred[0].dtype) loss = 0 #累加每个检测头中的损失 for index in range(num_layers): y_pre1 = y_pred[index] #第1个检测头 #维度从b×13×13×255转换为b×13×13×3×85,3是Anchor,85是4+1+80 y_pre = tf.reshape( y_pre1, [ -1, y_pre1.shape[1], y_pre1.shape[2], len(self.anchor[0]), 4 + 1 + self.num_class ]) #所有的同层true_box concat在一起 true_boxes = true_box2[0][index] #传入的是编码过的3个检测头的真值tx,ty,tw,th true_grid = y_true[0][index] #获取置信度信息,在真值中只有一个检测头是用来预测的 object_mask = true_grid[..., 4:5] #true_class_probs = true_grid[..., 5:] #获取分类信息 #预测返回的是 xmin,ymin, xmax, ymax位置信息,置信度信息,分类信息 pre_boxes, pre_box_confidence, pre_box_class_props = yolo_v3_head_decode(y_pre, 2 - index, num_class=self.num_class) #因为true_box中的位置信息是cx,cy,w,h,所以要转换成xmin,ymin,xmax, #ymax做IOU计算 #计算true的xmin,ymin,xmax,ymax以方便做IOU的计算 true_box = tf.concat([ (true_boxes[..., 0:2] - true_boxes[..., 2:4]) * 0.5, (true_boxes[..., 0:2] + true_boxes[..., 2:4]) * 0.5, ], axis=-1) #2 - (w × h) 如果wh较少,则惩罚学习小框。因为值放大了 #如果小目标较多,则可以在box_loss_scale的基础上再乘以1.5 box_loss_scale = 2 - true_boxes[..., 2] * true_boxes[..., 3] #将预测值与真值之间做IOU,通过IOU进行过滤 iou_result = iou(pre_boxes, true_box) iou_score = tf.reduce_max(iou_result, axis=-4) iou_score = tf.expand_dims(iou_score, axis=-1) #预测值与真值之间的IOU,如果大于指定阈值,但是又不是最大IOU的内容则会被 #忽略处理 #真实的框只有一个,而小于阈值的都作为负样本 object_detections = iou_score > self.overlap_threshold object_detections = tf.cast(object_detections, dtype=iou_score.dtype) #没有物体的损失,即背景的损失 #置信度损失,预测框的IOU与真实框中的IOU小于0.6的都为背景 no_object_loss = (1 - object_detections) * ( 1 - object_mask) * tf.expand_dims(tf.keras.losses.binary_ crossentropy( object_mask, pre_box_confidence, from_logits=True), axis=-1) object_loss = object_mask * tf.expand_dims(tf.keras.losses.binary_crossentropy( object_mask, pre_box_confidence, from_logits=True), axis=-1) confidence_loss = object_loss + no_object_loss #有文章说使用binary_crossentropy有助于抑制exp指数溢出,所以这里更改了 #位置损失 #https://github.com/qqwweee/keras-yolov3/blob/master/yolov3/model.py xy_loss = object_mask * tf.expand_dims(box_loss_scale, axis=-1) * tf.expand_dims( tf.keras.losses.binary_crossentropy( true_grid[..., :2], y_pre[..., :2], from_logits=True ), axis=-1) wh_loss = object_mask * tf.expand_dims(box_loss_scale, axis=-1) * tf.square( true_grid[..., 2:4] - y_pre[..., 2:4]) * self.wh_scale #分类损失,使用binary_crossentropy交叉熵 class_loss = object_mask * tf.expand_dims(tf.keras.losses.binary_crossentropy( true_grid[..., 5:], y_pre[..., 5:], from_logits=True), axis=-1) #统计损失 xy_loss = tf.reduce_sum(xy_loss) / mf wh_loss = tf.reduce_sum(wh_loss) / mf confidence_loss = tf.reduce_sum(confidence_loss) / mf class_loss = tf.reduce_sum(class_loss) / mf #将3个检测头的损失合并在一起 loss += xy_loss + wh_loss + confidence_loss + class_loss return loss 代码中for index in range(num_layers)循环了3个检测头以进行损失的计算。yolo_v3_head_decode()解码函数的实现逻辑与YOLOv2是一致的,求GT BOX与Anchor BOX的偏移,然后将iou_result=iou(pre_boxes, true_box)预测BOX与真实BOX做IOU,根据object_detections=iou_score>self.overlap_threshold得到预测有目标,然后由1-object_detections得到预测,没有目标,1-object_mask是真值,有目标,所以将binary_crossentropy(object_mask,pre_box_confidence)做交叉熵后得到没有目标的置信度损失。将有目标和没有目标相加confidence_loss=object_loss+no_object_loss,得到总的置信度损失。类别损失这里使用交叉熵损失,class_loss=object_mask*binary_crossentropy(true_grid[...,5:],y_pre[...,5:]),最后将xy_loss+wh_loss+confidence_loss+class_loss相加得到总损失。求损失的整体过程跟YOLOv2类似,故不再赘述。训练脚本也无重要更新,更多更详细的代码可参考随书代码。 5.7.5代码实战预测推理 YOLOv3的推理过程与YOLOv2基本相同,不同之处在于YOLOv3需要遍历3个检测层的结果,并根据不同的检测层生成不同的锚框,详细的代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V3_Detected/detected/detected.py class Detected(): def __init__(self, model_path, input_size): self.model = load_model( model_path, custom_objects={'compute_loss': MultiAngleLoss(2).compute_loss} ) #读取模型和权重 self.confidence_threshold = 0.5 #有无目标置信度 self.class_prob = [0.5, 0.5] #两个类别的分类阈值 self.nms_threshold = 0.5 #NMS的阈值 self.input_size = input_size #锚框初始值 self.anchors = np.array([ [[10, 13], [16, 30], [33, 23]], [[30, 61], [62, 45], [59, 119]], [[116, 90], [156, 198], [373, 326]] ]) / 416 def readImg(self, img_path=None): img = cv2.imread(img_path) #读取要预测的图片 #将图片转换到416×416 self.img, _ = u.letterbox_image(img, self.input_size, []) self.old_img = self.img.copy() def forward(self): #升成4维 img_tensor = tf.expand_dims(self.img / 255.0, axis=0) #前向传播 self.output = self.model.predict(img_tensor) def _generate_anchor(self): #根据当前预测layer得到特征图的height、width self.h, self.w = self.output[self.currentLayer].shape[1:3] #1×13×13×21 #使用均分的方法生成格子。一共有h×w个格子,即169个格子 self.lin_x = np.ascontiguousarray(np.linspace(0, self.w - 1, self.w).repeat(self.h)).reshape(self.h * self.w) self.lin_y = np.ascontiguousarray(np.linspace(0, self.h - 1, self.h).repeat(self.w)).T.reshape(self.h * self.w) #得到锚框的w和h,因为一共有3个Anchor,所以reshape成[1,3,1] self.anchor_w = np.ascontiguousarray(self.anchors[2 - self.currentLayer][..., 0] * (416 //self.w)).reshape([-1, 3, 1]) self.anchor_h = np.ascontiguousarray(self.anchors[2 - self.currentLayer][..., 1] * (416 //self.h)).reshape([-1, 3, 1]) def _decode(self): #根据当前预测layer对预测出来的结果解码 #YOLOv3的输出(4+1+num_class) ×3个Anchor self.b = self.output[self.currentLayer].shape[0] #变成[b,3,(4+1+2),169) logits = np.reshape(self.output[self.currentLayer], [self.b, 3, -1, self.h * self.w]) self.result = np.zeros(logits.shape) #套用公式进行解码,Sigmoid()函数进行值域限制 self.result[:, :, 0, :] = (tf.sigmoid(logits[:, :, 0, :]).NumPy() + self.lin_x) / self.w self.result[:, :, 1, :] = (tf.sigmoid(logits[:, :, 1, :]).NumPy() + self.lin_y) / self.h self.result[:, :, 2, :] = (tf.exp(logits[:, :, 2, :]).NumPy() * self.anchor_ w) / self.w self.result[:, :, 3, :] = (tf.exp(logits[:, :, 3, :]).NumPy() * self.anchor_ h) / self.h self.result[:, :, 4, :] = tf.sigmoid(logits[:, :, 4, :]).NumPy() self.result[:, :, 5:, :] = tf.nn.softmax(logits[:, :, 5:, :]).NumPy() def classification_filtering(self): #根据3个预测layer对预测结果进行解析 all_boxes = [] all_cls_scores = [] all_idx = [] for i in range(3): #当前预测layer self.currentLayer = i #根据当前预测layer进行Anchor的生成 self._generate_anchor() #根据当前预测layer进行解码操作,解码后此时的内容存储在self.result属性中 self._decode() #取最大的分类得分的下标,此时result为[b,3,(4+1+num_class),169] class_score = self.result[:, :, 5:, :] #[1,3,num_class,169] class_label = np.argmax(class_score, axis=2) #[1,3,169] #最大的分类得分值 score = np.max(class_score, axis=2) #[1,3,169] #当前格子的置信度 conf = self.result[:, :, 4, :] #[1,3,169] #每个格子最后的得分为置信度*分类得分 prob = score * conf #[1,3,169] #根据阈值,得到评分 score_mask = prob > self.confidence_threshold #[1,3,169] num = np.sum(score_mask.reshape([-1])) if num > 0: #只有转置成 [b*3*(4+1+num_class)*169],才能根据score_mask进行取 #值,最后需要得到[b,3,169,7]中的7 self.result = np.reshape(self.result, [self.b, 3, self.h * self.w, -1]) boxes = self.result[score_mask][..., :4] #根据score_mask过滤得到boxes cls_scores = prob[score_mask] #根据score_mask过滤得到cls_scores idx = class_label[score_mask] #根据score_mask过滤得到labels index #将当前layer满足box、cls、idx进行存储 all_boxes.append(boxes) all_cls_scores.append(cls_scores) all_idx.append(idx) if len(all_boxes): #遍历3个layer中的预测内容并进行合并 all_boxes = np.concatenate(all_boxes, axis=0) all_cls_scores = np.concatenate(all_cls_scores, axis=0) all_idx = np.concatenate(all_idx, axis=0) #根据类别进行非极大值抑制 for label in range(len(self.class_prob)): #如果class_labels_all==label,则取当前label中的信息 mask = all_idx == label #如果都不是当前label,则跳过 if np.sum(mask) == 0: continue #由cx,cy,w,h转换成xmin,ymin,xmax,ymax xyxy_box = u.cxcy2xyxy(all_boxes[mask][..., :4]) cat_boxes = np.concatenate([xyxy_box, all_cls_scores[mask].reshape ([-1, 1])], axis=-1) #NMS index = u.nms(cat_boxes, self.nms_threshold) #绘框 self.old_img = u.draw_box(self.old_img, cat_boxes[index]) #最后结果 u.show(self.old_img) if __name__ == "__main__": d = Detected(r'../weights', input_size=[416, 416]) d.readImg('../../val_data/pexels-photo-5211438.jpeg') d.forward() d.classification_filtering() 主要变化在__init__()中self.anchors变为9个锚框,每个预测层分配3个锚框。在_generate_anchor(self)中根据self.currentLayer的结果取当前预测层的结果,如图564所示。 图564YOLOv3根据预测层生成锚框 在解码_decode(self)方法中,根据self.output[self.currentLayer]获取当前层的输出,并且解码公式仍然与YOLOv2的解码公式相同。 在classification_filtering(self)方法中,for i in range(3)循环3个预测层,然后将i值赋给self.currentLayer属性并传递给self._generate_anchor()方法生成锚框、self._decode()方法进行当前预测Layer的解码,将满足score_mask=prob>self.confidence_threshold阈值的结果存储到all_boxes、all_cls_scores、all_idx变量中,如图565所示。 图565YOLOv3阈值过滤 然后根据all_boxes的结果,根据mask=all_idx==label进行mask的过滤,最后通过NMS算法就可以得到预测结果,如图566所示。 图566YOLOv3根据类别索引过滤 总结 YOLOv3有3个检测并且每个检测头分配3个Anchor,分别检测大、中、小物体。同时引入FPN以加强几何信息与语义信息的融合,这样更有利于检测小目标。 练习 运行并调试本节代码,理解算法的设计与代码的结合,重点梳理本算法的实现方法。 5.8单阶段速度快多检测头网络YOLOv4 5.8.1模型介绍 YOLOv4由Bochkovskiy等在2020年论文YOLOv4: Optimal Speed and Accuracy of Object Detection中提出,其主要特点在YOLOv3的基础上将主干特征提取网络换成CSPDarkNet53,增加了SPP和PANet。引入数据增强Mosic和DropBlock抑制过拟合。在损失函数方面将位置BOX损失更改为CIOU损失,其结构如图567所示。 图567YOLOv4网络结构 首先看CBM结构,引入了Mish激活函数,其公式如下: f(x)≡x×tanh(ln(1+ex))(513) Mish函数的优点是无上界、有下界。无上界是任何激活函数都需要的特征,因为它避免了导致训练速度急剧下降的梯度饱和。Mish函数具有非单调性,这种性质有助于保持小的负值,从而稳定网络梯度流。Mish函数是光滑函数,具有较好的泛化能力和结果,可以提高训练的准确率,但是Mish函数的计算量相比ReLU要大,需要的资源更多,其值域如图568所示。 图568Mish函数值域 主干提取网络由CSP构成,原作者认为在推理过程中计算量过高的问题是由于网络优化中的梯度信息重复导致的,CSPNet通过将梯度的变化从头到尾地集成在特征图中,在减少计算量的同时可以保证准确率。进入CSP之后,一条分支进行残差并重复指定次数,另外一条分支经过卷积后与其他分支concat,使用残差可以使网络更稀疏的同时保留更好的特征信息,另一个分支可以保留不同尺度的特征,使concat之后能够增强主干特征的提取能力。ShuffLeNet的思想,分支结构只有两个时也可降低内存消耗。图567中CSP模块残差处重复的次数分别是1、2、8、8、4次。 SPP模块将输入直接作为输出,这为第1个分支,第2个分支为5×5的最大池化,第3个分支为9×9的最大池化,第4个分支为13×13的最大池化,其步距都为1,进行padding填充,然后将4个分支的特征信息concat,实现多尺度特征信息的融合,更有助于提升检测的性能。 经过SPP模块之后,进行2倍上采样为38×38并与csp8_2的输入进行concat,再进行1次上采样与csp8_1的输入进行concat得到76×76的特征信息。沿76×76的特征进行2次下采样得到19×19的特征,从而用来预测较大目标。FPN是自底向上的,将深层的语义特征传递过来,对整个特征金字塔进行了增强,不过更多地是增强了语义信息,对定位更有益的几何信息没有传递,PAN针对这一点,在FPN的后面进行了补充,将浅层的定位特征传递到深层,更有助于语义和几何特征信息的融合,如图569所示。 在计算位置损失时YOLOv1~3都使用了均方差损失,这是因为IOU(GT BOX与预测BOX)损失当IOU=0时并不能反映两个框的距离,此时损失函数不可导,无法优化两个框不相交的情况; 另一种情况虽然IOU的值相同,但是从IOU的角度也无法区分两者相交情况的不同,如图570所示。 图569FPN+PAN结构 图570IOU损失 为了缓解IOU损失的不足,在GIOU损失中增加相交尺度的衡量方式,其公式为 GIOULoss=1-GIOU=1-IOU-|D||C|(514) 其中,C代表全集,D代表差集。当差集D=0时,此时为IOU损失。当IOU=0时,1-|D||C|仍然能够进行求导计算损失。不过,如果某个BOX在另外一个BOX的中间时,则差集为0,此时也就退化成IOU损失,仍然无法区分相对位置关系,如图571所示。 图571GIOU损失 DIOU损失考虑了重叠面积和中心点距离,其公式: DIOULoss=1-DIOU=1-IOU-Distance22DistanceC2(515) 其中,Distance22代表两个框中心点的距离,DistanceC2代表最小外接矩形(对角线)的距离。当两个框重叠时,Distance22=0,IOU=1,损失为0; 当离得很远时,IOU=0,损失为1-0-Distance22DistanceC2仍然能够进行求导计算损失,如图572所示。 图572DIOU损失 DIOU损失: 当目标框包住预测BOX时,改为直接度量两个框的距离,但是如图572中的情况,当预测框的中心点都一样时,没有考虑到宽高比的情况,仍然不能更好地做出选择。 CIOU损失在DIOU损失的基础上考虑了宽高比,其公式如下: CIOULoss=1-CIOU=1-IOU-Distance22DistanceC2-v2(1-IOU)+v(516) v=4π2arctanwgthgt-arctanwphp2 其中,v为衡量宽高比一致性的参数。对v进行求导可得 vw=8π2arctanwgthgt-arctanwphphwp2+hp2 vh=8π2arctanwgthgt-arctanwphpwwp2+hp2 图573DropBlock可视化效果 当预测到wp2+hp2很小时,1wp2+hp2的值会很大,为了避免梯度爆炸,在反向传播时此项值设置为1。 YOLOv4在主干特征提取时使用了DropBlock按空间位置进行丢弃,强迫网络去学习特征图的其他地方的特征。DropBlock作用在卷积层,而DropOut则作用在全连接层,在RGB通道进行DropBlock的效果如图573所示。 关于正负样本匹配,YOLOv4仍然有3个检测头,每个检测头有3个Anchor,当Anchor与标注GT BOX之间的IOU大于阈值时都设定为正样本,其他样本为负样本。在设置置信度的值时使用了标签平滑,允许标签的类别有一点错误,其公式为 smoothlabels=ytrue×(1.0-errorrate)+0.5×errorrate(517) 其中,errorrate为允许出现错误的百分比,ytrue=1。 在损失函数方面,在训练时将位置损失改为CIOU_loss,推理时使用DIOU_loss。 5.8.2代码实战模型搭建 从结构图567中可知,在每次进行CSP结果之前有两个CBM模块,所以代码中使用self.split_conv0、self.split_conv1存储这两个结构。在每次进入CSP结构时会经过卷积核为3×3且步长s=2的下采样。在1×CSP,c=64时,输入channel与输出channel相等,当为其他CSP结构时,输出channel是输入channel的1半,关键代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V4_Detected/utils/conv_utils.py class CSPResBlock(layers.Layer): def __init__(self, in_channel, out_channel, num_block, first=False, **kwargs): super(CSPResBlock, self).__init__(**kwargs) #CSP中第1个layer1是用来做下采样的,s=2 #ConvBNMish是Conv->BN->Mish函数的封装 self.downSample = ConvBNMish(in_channel, kernel_size=3, strides=2) #32 if first: #在CSPRes1中输入的是64,残差之后输出的仍然是64 self.split_conv0 = ConvBNMish(out_channel, kernel_size=1, strides=1) self.split_conv1 = ConvBNMish(out_channel, kernel_size=1, strides=1) #CSP结构中的残差 self.bocks_conv = Sequential([ ResBlockMish(in_channel, out_channel),#残差结构 ConvBNMish(out_channel, kernel_size=1, strides=1) ]) self.cat_conv = ConvBNMish(out_channel, kernel_size=1, strides=1) else: #假设: #CSPRes2 128->64 #CSPRes3 256->128 #CSPRes4 512->256 #CSPRes5 1024->512 #从第2个CSP开始,输出channel是输入channel的1半 self.split_conv0 = ConvBNMish(out_channel //2, kernel_size=1, strides=1) #128 //2 self.split_conv1 = ConvBNMish(out_channel //2, kernel_size=1, strides=1) #*[ResBlockMish for ...] *的作用是将列表反射成参数。num_block重复的次数 self.bocks_conv = Sequential([ *[ResBlockMish(out_channel //2, out_channel //2) for _ in range(num_block)], ConvBNMish(out_channel //2, kernel_size=1, strides=1) ]) #合并 self.cat_conv = ConvBNMish(out_channel, kernel_size=1, strides=1) def call(self, inputs, *args, **kwargs): #layer_1 进行下采样 x = self.downSample(inputs) #两个分支 x0 = self.split_conv0(x) x1 = self.split_conv1(x) #残差 x0 = self.bocks_conv(x0) out = layers.Concatenate()([x1, x0]) #再进行一个1x1 out = self.cat_conv(out) return out 然后根据配置文件中的参数进行设置,构建成cspdarknet模块,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V4_Detected/backbone/YOLOv4.py def csp_darknet_body(input_x): """""" x = ConvBNMish(32, kernel_size=3, strides=1)(input_x) #CPS模块的输入channel和输出channel,重复次数 items = [ [64, 64, 1], [128, 128, 2], [256, 256, 8],#76 × 76 × 255 [512, 512, 8], #38 × 38 × 255 [1024, 1024, 4] #19 × 19 × 255 ] result = [] for i, item in enumerate(items): #i = 0 first=True first = True if i == 0 else False #按结构图传递参数 x = CSPResBlock(item[0], item[1], item[2], first=first)(x) return x 根据items中描述的输入channel和输出channel循环调用CSPResBlock(item[0], item[1], item[2], first=first)函数,实现cspdarknet特征提取网络的构建。 然后根据结构图构建CBL_Repeat3模块,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V4_Detected/utils/conv_utils.py class CBL_Repeat3(layers.Layer): def __init__(self, list_channels: list): super(CBL_Repeat3, self).__init__() self.list_channels = list_channels self.cbl1 = ConvBnLeakRelu(self.list_channels[0], 1, 1) self.cbl2 = ConvBnLeakRelu(self.list_channels[1], 3, 1) self.cbl3 = ConvBnLeakRelu(self.list_channels[2], 1, 1) def call(self, inputs, *args, **kwargs): inputs = self.cbl1(inputs) inputs = self.cbl2(inputs) inputs = self.cbl3(inputs) return inputs 根据传入的3个channel数进行卷积、BN、LeakRelu的计算。与CBL_Repeat5的计算类似,只是增加了self.cbl4、self.cbl5。 对检测头进行封装,输入[channel,3*(4+1+class_num)]数进行输出,不同检测头的通道channel略有不同,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V4_Detected/utils/conv_utils.py class Yolo_V4_Head(layers.Layer): def __init__(self, list_channels: list): super(Yolo_V4_Head, self).__init__() #list_channels=[1024, len(anchors_mask[0]) * (4 + 1 + class_num)] self.list_channels = list_channels #3×3卷积 self.cbl1 = ConvBnLeakRelu(self.list_channels[0], 3, 1) #1×1卷积,输出 self.conv = layers.Conv2D(self.list_channels[1], 1, 1) def call(self, inputs, *args, **kwargs): inputs = self.cbl1(inputs) inputs = self.conv(inputs) return inputs 对CspDarknet、CBL_Repeat3、CBL_Repeat5、Yolo_V4_Head等结构进行封装,构建完成YOLOv4的主体结构,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V4_Detected/backbone/YOLOv4.py def YOLOv4_body(input_shape, anchors=None, class_num=80, is_class=False): """构建YOLO的网络模型""" if anchors is None: anchors_mask = np.array([ [[10, 13], [16, 30], [33, 23]], #小目标 [[30, 61], [62, 45], [59, 119]], #中目标 [[116, 90], [156, 198], [373, 326]] #大目标 ]) else: anchors_mask = anchors input_x = layers.Input(shape=input_shape, dtype='float32') #backbone #76 × 76, 38 × 38, 19 × 19 result = csp_darknet_body(input_x) csp8_1, csp8_2, csp4 = result[0] #19 × 19的分支处理 csp4_19 = CBL_Repeat3(list_channels=[512, 1024, 512])(csp4) #19 * 19 * 512 csp4_19_spp = SPP()(csp4_19) #19 × 19 × 2048 #供 19 × 19输出头concatenate csp4_19_spp_cbl3 = CBL_Repeat3(list_channels=[512, 1024, 512])(csp4_19_spp) #19 * 19 * 512 #上采样,38 × 38 × 256 csp4_19_spp_cbl3_for_middle_upsample = ConvUpsample(256)(csp4_19_spp_cbl3) #csp8_2分支的处理 38 × 38 × 256 csp8_2_cbl = ConvBnLeakRelu(filters_channels=256, kernel_size=1, strides=1)(csp8_2) middle_concat1 = layers.Concatenate()([csp4_19_spp_cbl3_for_middle_upsample, csp8_2_cbl]) #38 × 38 × 512 #38 × 38 × 256 middle_concat_cbl5 = CBL_Repeat5(list_channels=[256, 512, 256, 512, 256])(middle_concat1) #等待第2个concat #上采样 76 × 76 × 128 middle_concat_cbl5_for_76_upsample = ConvUpsample(128)(middle_concat_cbl5) #csp8_1分支的处理 76 × 76 × 128 csp8_1_cbl = ConvBnLeakRelu(filters_channels=128, kernel_size=1, strides=1)(csp8_1) #76 × 76 × 256 last_concat = layers.Concatenate()([csp8_1_cbl, middle_concat_cbl5_for_76_upsample]) #供中间层进行middle_concat2, 并且也是76 × 76的检测头的来源 #最后一层的输出 76 × 76 #76 × 76 × 128 last_concat_cbl5 = CBL_Repeat5(list_channels=[128, 256, 128, 256, 128])(last_concat) #p5 #cbl5后面还有一个下采样 #38 × 38 × 256 last_concat_cbl5_down_sample = ConvBnLeakRelu(filters_channels=256, kernel_size=3, strides=2)(last_concat_cbl5) #中间层的输出 38 × 38 × 512 middle_concat2 = layers.Concatenate()([last_concat_cbl5_down_sample, middle_concat_cbl5]) #38 × 38的检测头来源,并且也给19 × 19进行concatenate 38 × 38 × 256 middle_head_cbl5 = CBL_Repeat5(list_channels=[256, 512, 256, 512, 256])(middle_concat2) #p4 #下采样 19 × 19 × 512 middle_head_cbl5_down_sample = ConvBnLeakRelu(filters_channels=512, kernel_size=3, strides=2)(middle_head_cbl5) #第1个层的输出 19 × 19 × 1024 first_concat = layers.Concatenate()([csp4_19_spp_cbl3, middle_head_cbl5_down_sample]) #19 × 19 × 512 first_cbl5 = CBL_Repeat5(list_channels=[512, 1024, 512, 1024, 512])(first_concat) #p3 #构建检测头 #19 × 19 × 255用于检测大图 out1 = Yolo_V4_Head([1024, len(anchors_mask[0]) * (4 + 1 + class_num)])(first_cbl5) #38 × 38 × 255用于检测中图 out2 = Yolo_V4_Head([512, len(anchors_mask[1]) * (4 + 1 + class_num)])(middle_head_cbl5) #76 × 76 × 255用于检测小图 out3 = Yolo_V4_Head([256, len(anchors_mask[2]) * (4 + 1 + class_num)])(last_concat_cbl5) return Model(inputs=input_x, outputs=[out1, out2, out3]) csp8_1、csp8_2和csp4为cspdarknet中获取的特征输出,经过SPP、FPN、PAN之后得到19×19×255、38×38×255、76×76×255的3个检测头,然后通过outputs=[out1,out2,out3]进行输出。在FPN、PAN、Head部分使用激活函数LeakRule是由于Mish函数的运算量过大,能够加快模型的收敛。 5.8.3代码实战建议框的生成 在YOLOv4中如果真实GT BOX与Anchor的IOU>0.5,则设为正样本,这样有可能会导致某个GT BOX均能在3个检测头中找到正样本,其实现代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V4_Detected/utils/tools.py def yolo_v4_true_encode_box( true_boxes, anchors=None, class_num=80, input_size=(416, 416), ratio=[32, 16, 8], iou_threshold=0.213 ): """当YOLOv4使用真值编码时,如果IOU>0.26,则为正样本。也就说一个GT可能会有多个正样本,而YOLOv3是一个GT只有一个正样本""" if anchors is None: anchors = np.array([ [[10, 13], [16, 30], [33, 23]], #小目标 [[30, 61], [62, 45], [59, 119]], #中目标 [[116, 90], [156, 198], [373, 326]] #大目标 ]) #Anchor的数量 detected_num = anchors.shape[0] #用来存储结果 grid_shape = [] true_boxes2 = [] #用来存储结果,对输入的boxes进行了升维 true_boxes = np.expand_dims(true_boxes, axis=0) #因为每个img都要转换一下,所以应该是1。 batch_size = true_boxes.shape[0] #跟YOLOv3一样,初始3个检测头的矩阵 for i in range(detected_num): #13 × 13 sw, sh = input_size[0] //ratio[i], input_size[1] //ratio[i] grid = np.zeros( [batch_size, sw, sh, len(anchors[i]), 4 + 1 + class_num], dtype=np.float32) grid_shape.append(grid) true_boxes2.append( np.zeros( [batch_size, sw, sh, len(anchors[0]), 4 + 1 + class_num], dtype=np.float32)) grid = grid_shape #遍历每个BOX进行IOU的选择 for box_index in range(true_boxes.shape[1]): #BOX信息 box = true_boxes[:, box_index, :] box = np.squeeze(box, axis=0) #降维 #类别 box_class = box[4].astype('int32') best_choice = {} for index in range(0, 3, 1): #anchors = anchors * grid[index].shape[-3] #box = (13 × x,13 × y, 13 × w, 13 × h) 换算成相对grid cell的值 sa = grid[index].shape[-3] #box[0:4]是归一化后的结果 × 13,将BOX信息映射到某个特征层 box2 = box[0:4] * np.array([sa, sa, sa, sa]) box_true = box2.copy() box2 = box2.copy() #在i,j个格子 i = np.floor(box2[1]).astype('int') j = np.floor(box2[0]).astype('int') if i > grid[index].shape[-3] or j > grid[index].shape[-3]: continue #遍历每个Anchor for k, anchor in enumerate(anchors[2 - index]): #wh box_maxes = box2[2:4] * 0.5 box_mines = -box_maxes #anchor wh anchor_maxes = anchor * 0.5 anchor_mines = -anchor_maxes #将真实wh与Anchor之间进行IOU的计算,并获取最佳IOU是哪个Anchor intersect_mines = np.maximum(box_mines, anchor_mines) intersect_maxes = np.minimum(box_maxes, anchor_maxes) intersect_wh = np.maximum(intersect_maxes - intersect_mines, 0.) intersect_area = intersect_wh[0] * intersect_wh[1] #计算IOU box_area = box2[2] * box2[3] anchor_area = anchor[0] * anchor[1] iou_score = intersect_area / (box_area + anchor_area - intersect_area) #只要IOU大于0.26都认为是正样本 if iou_score >= iou_threshold: #计算偏移值 adjusted_box = np.array([ box2[0] - j, #x和y都是相对于gird cell的位置,左上角为[0,0],右 #下角为[1,1] box2[1] - i, np.log(box2[2] / anchors[2 - index][k][0]), np.log(box2[3] / anchors[2 - index][k][1]), ], dtype=np.float32) #存储偏移值 grid[index][..., j, i, k, 0:4] = adjusted_box #置信度 grid[index][..., j, i, k, 4] = 1 #标签平滑处理 grid[index][..., j, i, k, 5 + box_class] = smooth_labels(1, 0.1) #获取真值,方便后面计算损失时做IOU true_boxes2[index][..., j, i, k, 0:4] = box_true return true_boxes2, grid 与YOLOv3的代码类似,for box_index in range(true_boxes.shape[1])循环每个GT BOX,for index in range(0, 3, 1)循环每个检测头,for k, anchor in enumerate(anchors[2index])循环每个Anchor,当iou_score>=iou_threshold(大于设定的阈值)时,grid[index][...,j,i,k,0:4]=adjusted_box设置为j,i个格子中的第k个Anchor的偏移值为adjusted_box。与YOLOv3不同之处在于可能有多个正样本,如图574所示。通常来说提高正样本的数量对于模型的训练有益,正样本越多越容易学到更多的特征。 图574YOLOv4多个正样本 5.8.4代码实现损失函数的构建及训练 YOLOv4损失函数的代码实现与YOLOv3损失函数的代码实现基本相同,不同之处仅在于计算BOX损失时使用CIOU损失,CIOU的计算代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V4_Detected/utils/iou_help.py def c_iou(priors_box, true_box): """ 不仅考虑到重叠面积和中心点距离,还考虑到纵横比。c_iou一般用于训练 :param priors_box: cx, cy, w,h :param true_box: cx, cy, w,h :return: """ #box1的信息 b1_xy = priors_box[..., :2] b1_wh = priors_box[..., 2:4] b1_wh_half = b1_wh / 2. #中心点 b1_mins = b1_xy - b1_wh_half #xmin, ymin b1_maxes = b1_xy + b1_wh_half #xmax, ymax #box2的信息 b2_xy = true_box[..., :2] b2_wh = true_box[..., 2:4] b2_wh_half = b2_wh / 2. b2_mins = b2_xy - b2_wh_half #xmin, ymin b2_maxes = b2_xy + b2_wh_half #xmax, ymax #计算IOU intersect_mins = tf.maximum(b1_mins, b2_mins) intersect_maxes = tf.minimum(b1_maxes, b2_maxes) intersect_wh = tf.maximum(intersect_maxes - intersect_mins, 0.) intersect_area = intersect_wh[..., 0] * intersect_wh[..., 1] b1_area = b1_wh[..., 0] * b1_wh[..., 1] b2_area = b2_wh[..., 0] * b2_wh[..., 1] union_area = b1_area + b2_area - intersect_area #calculate IoU, add epsilon in denominator to avoid dividing by 0 iou = intersect_area / (union_area + tf.keras.backend.epsilon()) #iou #计算两个BOX的中心点距离 center_distance = tf.keras.backend.sum(tf.square(b1_xy - b2_xy), axis=-1) #获得全集 enclose_mins = tf.minimum(b1_mins, b2_mins) enclose_maxes = tf.maximum(b1_maxes, b2_maxes) enclose_wh = tf.maximum(enclose_maxes - enclose_mins, 0.0) #计算全集中心点的距离 enclose_diagonal = tf.keras.backend.sum(tf.square(enclose_wh), axis=-1) #d**2 #求Diou = iou - --------- #c**2 diou = iou - 1.0 * (center_distance)/(enclose_diagonal + tf.keras.backend.epsilon()) #计算w、h宽高比 v = 4 * tf.keras.backend.square( tf.math.atan2(b1_wh[..., 0], b1_wh[..., 1]) - tf.math.atan2(b2_wh[..., 0], b2_wh[..., 1]) ) / (tf.cast(np.pi ** 2, dtype=tf.float32)) #公式中的alpha为了防止梯度爆炸,alpha不进行梯度计算 alpha = v / (1.0 - iou + v) K.stop_gradient(alpha) #此句不进行梯度求解 #得到CIOU ciou = diou - alpha * v ciou = tf.expand_dims(ciou, -1) return ciou 在计算CIOU时先计算iou=intersect_area/(union_area+tf.keras.backend.epsilon()),再计算diou=iou-1.0*(center_distance)/(enclose_diagonal+tf.keras.backend.epsilon()),然后计算v,最后得ciou=diou-alpha*v的损失结果。计算损失的核心代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V4_Detected/utils/loss.py class MultiAngleLoss(object): def yolo_v4_loss(self, y_true, y_pred, true_box2): """整体过程跟YOLOv3区别不大。主要是在计算BOX时,使用的是CIOU进行计算""" #网络层数 num_layers = self.num_layers loss = 0 for index in range(num_layers): #每层的预测值 y_pre1 = y_pred[index] y_pre = tf.reshape(y_pre1, [-1, y_pre1.shape[1], y_pre1.shape[2], len(self.anchor[0]), 4 + 1 + self.num_class]) #y_pre = tf.nn.sigmoid(y_pre) #把预测出来的值限定在0~1 #所有的同层true_box concat在一起 true_boxes = tf.concat([t[index] for t in true_box2], axis=0) true_grid = tf.concat([t[index] for t in y_true], axis=0) #获取置信度信息,在真值中只有一个检测头是用来预测的 object_mask = true_grid[..., 4:5] num_pos = tf.maximum(K.sum(K.cast(object_mask, tf.float32)), 1) #预测返回的是位置信息、置信度信息和分类信息,与YOLOv3相同 pre_boxes, pre_box_confidence, pre_box_class_props = yolo_v4_head_decode(y_pre, 2 - index, num_class=self.num_class) #平衡系数 #2 - (w × h) 如果wh较小,则惩罚学习小框。也可人为设定 box_loss_scale = 2 - true_boxes[..., 2] * true_boxes[..., 3] #使用CIOU直接求BOX的loss ciou_score = c_iou(pre_boxes, true_boxes[0:4]) ciou_loss = object_mask * tf.expand_dims(box_loss_scale, axis=-1) * (1 - ciou_score) #预测值与真值之间的IOU,如果大于指定阈值,但是又不是最大IOU的内容,则会被 #忽略处理 iou_score = tf.reduce_max(ciou_score, axis=-1) iou_score = tf.expand_dims(iou_score, axis=-1) object_detections = iou_score > self.overlap_threshold #正样本的mask object_detections = tf.cast(object_detections, dtype=iou_score.dtype) #负样本置信度损失 no_object_loss = (1 - object_detections) * ( 1 - object_mask) * tf.expand_dims(tf.keras.losses.binary_crossentropy( object_mask, pre_box_confidence, from_logits=True), axis=-1) #正样本损失 object_loss = object_mask * tf.expand_dims(tf.keras.losses.binary_crossentropy( object_mask, pre_box_confidence, from_logits=True), axis=-1) #负样本+正样本的损失 confidence_loss = object_loss + no_object_loss #分类损失 class_loss = object_mask * tf.expand_dims(tf.keras.losses.binary_crossentropy( true_grid[..., 5:], y_pre[..., 5:], from_logits=True), axis=-1) #位置平均损失 location_loss = tf.abs(tf.reduce_sum(ciou_loss)) / num_pos confidence_loss = tf.reduce_mean(confidence_loss) class_loss = tf.reduce_sum(class_loss) / num_pos #求合 loss += location_loss + confidence_loss + class_loss return loss 跟YOLOv3相比区别仅在ciou_score=c_iou(pre_boxes,true_boxes[0:4])的计算上,即位置损失使用ciou_score,同时根据object_detections=iou_score>self.overlap_threshold的得分计算正样本的mask,然后由confidence_loss=object_loss+no_object_loss计算正样本、负样本的置信度损失,最后再将location_loss+confidence_loss+class_loss相加得到总损失。 5.8.5代码实战预测推理 YOLOv4的预测推理流程与YOLOv3的预测推理流程一致,即需要先遍历3个检测头,接着对每个检测头的输出结果进行置信度、分类概率的过滤,然后对3个检测头中满足条件的结果进行合并,再通过NMS去除重复的框,详细代码可参考YOLOv3中的内容。 总结 YOLOv4在主干特征提取方面使用了CSP结构,引用SPP、FPN和PAN进行更细粒度的特征融合,仍然由3个检测头构成多尺度的预测。在损失函数方面,引入CIOU来计算位置损失。 练习 运行并调试本节代码,理解算法的设计与代码的结合,重点梳理本算法的实现方法。 5.9单阶段速度快多检测头网络YOLOv5 5.9.1模型介绍 YOLOv5由ultralytics公司开源在GitHub上面,到本书成稿时没有发表论文。此模型得以流行,主要在于GitHub上代码工程化的易用性,并且精度和速度较佳,其主要结构如图575所示。 图575YOLOv5的结构 图像的输入尺寸设定为640×640,CBS为卷积、池化、SiLU的激活函数,其公式为 SiLU(x)=x*Sigmoid(x)(518) SiLU函数在接近0时具有更平滑的曲线,并且由于Sigmoid(x)函数,可以使网络输出在一个较小值的范围,其函数的值域如图576所示。 图576SiLU激活函数值域 C3模块分两种情况,在主干特征提取部分为C3Bottle1,其结构与YOLOv4中的CSP结构相同,并且在YOLOv5中将C3Bottle2应用于FPN、PAN结构之中(YOLOv4没有)。C3Bottle2为正常卷积结构,使用C3为一个普通残差结构。将YOLOv4中的SPP结构更换为SPPF,由原来的并联更改为了3个5×5卷积的串联,据作者说能够提高运行速度。 因为输入的分辨率增大了,所以网络的3个检测头的输出也有变化,并且不同尺寸的输出分别用于检测小、中、大目标。输出尺寸为80×80×(4box+1conf+num_class)×3Anchor、40×40×255、20×20×255。 在正负样本匹配方面,YOLOv5改为将真实GT BOX宽和高与Anchor BOX宽和高的比例值小于4设为正样本,如图577所示,只要在这个范围内的Anchor均被选取,如果超过这个范围,则不选取。 图577YOLOv5正样本选取 在计算偏移值时,围绕GT BOX的中心点cx和cy所在的格子选择了附近的3个格子,并且都计算偏移值,并设定增加新的ij作为有目标的格子,由原来1个格子,变为3个格子,结合宽高比小于4的Anchor,极大地增加了正样本的数量,更有利于网络的学习,如图578所示。 图578单GT多锚点 图578中cx和cy为真实GT BOX的中心点,因为每个格子的值域为[0,1],所以由cx%1和cy%1可得GT BOX中心点所在区域,然后根据这个区域选择附近的格子来计算偏移值。 根据预测偏移值和Anchor,计算预测Px、Py、Pw、Ph时解码式(511),YOLOv4的作者指出当真实目标中心点非常靠近网格的左上角时,Sigmoid(tx)、Sigmoid(ty)会趋近于0,如果在右下角,则会趋近于1,网络的预测值需要在负无穷或者正无穷时才能取得,而这种极端值网络一般无法达到,GT BOX在黄色、红色处时X值需要很大才能接近,如图579所示。 图579Grid敏感度(见彩插) 为了解决这个问题,YOLOv4的作者将偏移值从原来的[0,1]缩放到[-0.5,1.5],所以其公式变为 Px=(2*Sigmoid(tx)-0.5)+cx Py=(2*Sigmoid(ty)-0.5)+cy Pw=Aw(2*Sigmoid(tw))2(519) Ph=Ah(2*Sigmoid(th))2 虽然YOLOv4提出了这个改进策略,但是代码中没有实现,而这一点在YOLOv5中被应用。在YOLOv5中除了调整Px、Py外,还对Pw、Ph进行了限制,这样可以避免出现梯度爆炸、训练不稳定的情况。 在损失函数方面没有大的变化,位置损失使用CIOU,而置信度和分类损失则使用交叉熵,不同的检测头有不同的权重比例。 5.9.2代码实战模型搭建 首先根据如图575所示的结构图实现CSP_Bottle1,即CSP结构,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/utils/conv_utils.py class Yolo5_BottleNeck1(layers.Layer): #残差结构,输入channel,输出channel def __init__(self, input_channels1, input_channels2): super(Yolo5_BottleNeck1, self).__init__() self.silu1 = ConvBNSiLU(input_channels1, 1, 1)#conv->bn->silu self.silu2 = ConvBNSiLU(input_channels2, 3, 1) def call(self, inputs, *args, **kwargs): return layers.add([inputs, self.silu2(self.silu1(inputs))]) #CSP bottle1的结构 class Yolo5_CSP_Bottleneck1(layers.Layer): #CSP bottle1的结构 def __init__(self, left_channel, right_channel1, bottleneck_channel: list, bottlenect_repeat: int, out_channel): super(Yolo5_CSP_Bottleneck1, self).__init__() #左侧channel self.left = ConvBNSiLU(left_channel, 1, 1) #右侧channel self.right1 = ConvBNSiLU(right_channel1, 1, 1) #残差结构 self.bottle = Yolo5_BottleNeck1(bottleneck_channel[0], bottleneck_channel[1]) #输出结构 self.out = ConvBNSiLU(out_channel, 1, 1) self.repeat = bottlenect_repeat def call(self, inputs, *args, **kwargs): left = self.left(inputs) right = self.right1(inputs) #重复次数 for _ in range(self.repeat): right = self.bottle(right) out = layers.concatenate([left, right], axis=-1) return self.out(out) 代码中Yolo5_BottleNeck1()实现了残差的封装,如果是第1个C3Bottle1,则通道值input_channels1=64,input_channels2=64。Yolo5_CSP_Bottleneck1()实现了C3Bottle1整个的封装,left_channel和right_channel1分别为左侧输入、右侧输入通道数,bottleneck_channel为残差通道数,bottlenect_repeat为残差重复的次数,out_channel为输出的通道数,第1个C3Bottle1重复的次数为3,输入通道为64,输出为128,则第1个C3Bottle1应传入的参数值为Yolo5_CSP_Bottleneck1(64,64,[64,64],3,128)。 然后根据结构图实现C3Bottle2的结构,C3Bottle1主要用在FPN、PAN中,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/utils/conv_utils.py class Yolo5_BottleNeck2(layers.Layer): #bottle2,没有残差 def __init__(self, input_channels1, input_channels2): super(Yolo5_BottleNeck2, self).__init__() self.silu1 = ConvBNSiLU(input_channels1, 1, 1) self.silu2 = ConvBNSiLU(input_channels2, 3, 1) def call(self, inputs, *args, **kwargs): return self.silu2(self.silu1(inputs)) class Yolo5_CSP_Bottleneck2(layers.Layer): #neck部分使用csp_bottle2结构 def __init__(self, left_channel, right_channel1, bottleneck_channel: list, bottlenect_repeat: int, out_channel): super(Yolo5_CSP_Bottleneck2, self).__init__() self.left = ConvBNSiLU(left_channel, 1, 1) self.right1 = ConvBNSiLU(right_channel1, 1, 1) #此时就是一个串行的卷积 self.bottle = Yolo5_BottleNeck2(bottleneck_channel[0], bottleneck_channel[1]) self.out = ConvBNSiLU(out_channel, 1, 1) self.repeat = bottlenect_repeat def call(self, inputs, *args, **kwargs): left = self.left(inputs) right = self.right1(inputs) #重复多次 for _ in range(self.repeat): right = self.bottle(right) #concat out = layers.concatenate([left, right], axis=-1) return self.out(out) Yolo5_BottleNeck2()是没有残差的,只是一个串行的卷积结构。按指定的重复次数bottlenect_repeat进行解析。Yolo5_CSP_Bottleneck2()的形参的含义相同,根据传入的参数如Yolo5_CSP_Bottleneck2(512,512,[512,512],3,512)进行解析。 完成了相关模块的封装后,对主干特征提取网络进行实现,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/backbone/yolo_v5.py def yolo5_csp_darknet(input_x): """DarkNet部分,也是backbone的部分""" x = ConvBNSiLU(64, 6, 2)(input_x) x = ConvBNSiLU(128, 3, 2)(x) x = Yolo5_CSP_Bottleneck1(64, 64, [64, 64], 3, 128)(x) p3 = ConvBNSiLU(256, 3, 2)(x) #P3 neck1 = Yolo5_CSP_Bottleneck1(128, 128, [128, 128], 6, 256)(p3) p4 = ConvBNSiLU(512, 3, 2)(neck1) #P4 neck2 = Yolo5_CSP_Bottleneck1(256, 256, [256, 256], 9, 512)(p4) p5 = ConvBNSiLU(1024, 3, 2)(neck2) #P5 x = Yolo5_CSP_Bottleneck1(512, 512, [512, 512], 3, 1024)(p5) return x, neck2, neck1 函数yolo5_csp_darknet()实现了backbone的封装,每个C3Bottle1结构之后经过ConvBNSiLU()得到结构图中的P3、P4、P5,而neck1、neck2、x会与FPN模块进行结合。对x进行SPPF,以便进行多尺度池化提取,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/utils/conv_utils.py class Yolo5_SPPF(layers.Layer): #SPPF模块 def __init__(self, input_channel=512, out_channel=1024): super(Yolo5_SPPF, self).__init__() self.con_si = ConvBNSiLU(input_channel, 1, 1) #都是5×5的池化 self.maxpool = layers.MaxPool2D(5, 1, padding='same') self.con_out = ConvBNSiLU(out_channel, 1, 1) def call(self, inputs, *args, **kwargs): inputs = self.con_si(inputs)#输入 pool1 = self.maxpool(inputs) pool2 = self.maxpool(pool1) pool3 = self.maxpool(pool2) #输入与3个5×5串联的池化concat out1 = layers.concatenate([pool3, pool2, pool1, inputs], axis=-1) return self.con_out(out1) Yolo5_SPPF()中对inputs进行pool1,然后对pool1最大池化得到pool2,对poo2最大池化得到pool3,然后将inputs与pool1、pool2、pool3进行concat后由ConvBNSiLU()输出,从而得到多尺度的特征池化特征信息。 将上面的模块按YOLOv5结构图组合得到YOLOv5的前向传播,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/backbone/yolo_v5.py def yolo_v5(input_shape, anchors=None, class_num=80): """YOLOv5的前向传播""" if anchors is None: anchors_mask = np.array([ [[10, 13], [16, 30], [33, 23]], #小目标 [[30, 61], [62, 45], [59, 119]], #中目标 [[116, 90], [156, 198], [373, 326]] #大目标 ]) else: anchors_mask = anchors input_x = layers.Input(shape=input_shape, dtype='float32') #neck1 80 × 80 × 256 #neck2 40 × 40 × 512 #neck3 20 × 20 × 1024 x, neck2, neck1 = yolo5_csp_darknet(input_x) #SPPF实现多尺度池化 neck3 = Yolo5_SPPF(512, 1024)(x) #输入head head3 = ConvBNSiLU(512, 1, 1)(neck3) #2倍上采样,这里用转置卷积代替upsample up3 = layers.Conv2DTranspose(512, 2, 2, padding='same')(head3) #将up3与neck2进行concat up3_neck2 = layers.concatenate([neck2, up3], axis=-1) #经过csp2 up3_neck2_bot1 = Yolo5_CSP_Bottleneck2(512, 512, [512, 512], 3, 512)(up3_neck2) #输入head2 head2 = ConvBNSiLU(256, 1, 1)(up3_neck2_bot1) up2_head2 = layers.Conv2DTranspose(256, 2, 2, padding='same')(head2) #将up2与neck1进行concat up2_neck1 = layers.concatenate([neck1, up2_head2], axis=-1) #输入head1 pred1 = Yolo5_CSP_Bottleneck2(256, 256, [256, 256], 3, 256)(up2_neck1) #进行PAN下采样 pred1_down1 = ConvBNSiLU(256, 3, 2)(pred1) pred1_down1_cat = layers.concatenate([pred1_down1, head2], axis=-1) pred2 = Yolo5_CSP_Bottleneck2(256, 256, [256, 256], 3, 512)(pred1_down1_cat) pred2_down2 = ConvBNSiLU(512, 3, 2)(pred2) pred2_down2_cat = layers.concatenate([pred2_down2, head3], axis=-1) pred3 = Yolo5_CSP_Bottleneck2(512, 512, [512, 512], 3, 1024)(pred2_down2_cat) #构建检测头 out1 = layers.Conv2D((4 + 1 + class_num) * len(anchors_mask[0]), 1, 1)(pred1) #小 out2 = layers.Conv2D((4 + 1 + class_num) * len(anchors_mask[1]), 1, 1)(pred2) #中 out3 = layers.Conv2D((4 + 1 + class_num) * len(anchors_mask[2]), 1, 1)(pred3) #大 return Model(inputs=input_x, outputs=[out3, out2, out1]) #大,中,小 代码中layers.Conv2DTranspose()替换了layers.UpSample(2),输出内容分别为out1、out2、out3。 5.9.3代码实战建议框的生成 YOLOv5中正样本选择分为两步,即先对真实GT BOX与Anchor进行宽高比的计算,当宽高比小于4时选择此Anchor作为正样本,同时还实现了单个真实GT BOX挑选多个锚点的操作,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/util/tools.py def yolo_v5_true_encode_box( true_boxes, #GT BOX的值 anchors=None,#传入的Anchor class_num=80, #num class input_size=(640, 640),#输入尺度 ratio=[32, 16, 8], #特征图的尺寸比 max_wh_threshold=4.0 #阈值 ): """ YOLOv5不再采用IOU匹配,YOLOv5采用基于宽高比例的匹配策略,GT的宽和高与Anchors的宽和高对应相除得到ratio1; Anchors的宽和高与GT的宽和高对应相除得到ratio2; 取ratio1和ratio2的最大值作为最后的宽高比,该宽高比和设定的阈值(默认为4)比较,小于设定阈值的Anchor则为匹配到的Anchor """ if anchors is None: anchors = np.array([ [[10, 13], [16, 30], [33, 23]], #小目标 [[30, 61], [62, 45], [59, 119]], #中目标 [[116, 90], [156, 198], [373, 326]] #大目标 ]) / 255. #Anchor的数量 detected_num = anchors.shape[0] #存储结果 grid_shape = [] true_boxes2 = [] true_boxes = np.expand_dims(true_boxes, axis=0) batch_size = true_boxes.shape[0] #初始3个检测头的矩阵 for i in range(detected_num): sw,sh = input_size[0] //ratio[i],input_size[1] //ratio[i] grid = np.zeros( [batch_size, sw, sh, len(anchors[i]), 4 + 1 + class_num], dtype=np.float32) grid_shape.append(grid) true_boxes2.append( np.zeros( [batch_size, sw, sh, , len(anchors[0]), 4 + 1 + class_num], dtype=np.float32)) grid = grid_shape #遍历每个GT BOX for box_index in range(true_boxes.shape[1]): #box信息 box = true_boxes[:, box_index, :] box = np.squeeze(box, axis=0) #降维 #类别 box_class = box[4].astype('int32') best_choice = {} for index in range(0, 3, 1): #box = (13 × x,13 × y, 13 × w, 13 × h) 换算成特征图上的值 box2 = box[0:4] * np.array([ grid[index].shape[-3], grid[index].shape[-3], grid[index].shape[-3], grid[index].shape[-3] ]) box_true = box2.copy() box2 = box2.copy() #GT BOX本来所处的格子 i = np.floor(box2[1]).astype('int') #y轴 j = np.floor(box2[0]).astype('int') #x轴 if i > grid[index].shape[-3] or j > grid[index].shape[-3]: continue #循环每个Anchor for k, anchor in enumerate(anchors[2 - index]): box_maxes = box2[2:4] anchor_maxes = anchor #(1)计算每个GT BOX与对应的Anchor Template模板的高宽比例 #(2)统计这些比例和它们倒数之间的最大值 #这里可以理解成计算GT BOX和Anchor Template分别在宽度及高度方向的 #最大差异(当相等时比例为1,差异最小) #假设 r_wh_ratio = 1,则r_wh_max就是 max(1, 1),即为1 #假设 r_wh_ratio = 0.5, 则r_wh_max就是 max(0.5, 1/0.5)--> max(0.5,2)-->2 #假设 r_wh_ratio = 0.25,则r_wh_max就是 max(0.25, 1/0.25)--> #max(0.5,4)-->4 #假设 r_wh_ratio = 0.1, 则r_wh_max就是 max(0.1, 1/0.1)--> #max(0.1,10) --> 10 #离得越近,比值越接近1; 离得越远,比值越大(正无穷) #每层的Anchor相比较 r_wh_ratio = box_maxes / anchor_maxes #r_w, r_h = w_gt, h_gt / w_ach, h_ach r_wh_max = np.maximum(r_wh_ratio, 1 / r_wh_ratio) #(3)获取宽度方向与高度方向的最大差异之间的最大值 r_max = np.max(r_wh_max) #如果最大的比例小于4,则将GT BOX分配给该Anchor Template模板 if r_max >= 4: continue ############################### #下一步需要计算 i, j, 2-index(第几组Anchor),k(第几组的第几个Anchor) #按规则获取偏移范围 offset = get_near_points(box2[0], box2[1]) #计算偏移位置,假设offset有3个偏移位置,则都认为是正样本 for off_xy in offset: #原来的位置 + 偏移后的位置 off_xy_value = np.array([j, i]) + np.array(off_xy) #新的第j,i个格子 new_j = int(off_xy_value[0]) new_i = int(off_xy_value[1]) if (new_j >= grid[index].shape[-3] or new_j <= 0) or (new_i >= grid[index].shape[-3] or new_i <= 0): continue #计算新的偏移 adjusted_box = np.array([ box2[0] - new_j, #x和y都是相对于grid cell的位置,左上角为 #[0,0],右下角为[1,1] box2[1] - new_i, np.log(box2[2] / anchor[0]), np.log(box2[3] / anchor[1]) ], dtype=np.float32) #正样本赋值 grid[index][..., new_j, new_i, k, 0:4] = adjusted_box grid[index][..., new_j, new_i, k, 4] = 1 #标签平滑处理 grid[index][..., new_j, new_i, k, 5 + box_class] = smooth_labels(1, 0.1) true_boxes2[index][..., j, i, k, 0:4] = box_true return true_boxes2, grid def get_near_points(x, y): """根据cx和cy计算新的锚点""" gx = x % 1 gy = y % 1 g = 0.5 #先水平,再垂直 if gx < g and gy < g: return [[0, 0], [-1, 0], [0, -1]] elif gx > g and gy > g: return [[0, 0], [1, 0], [0, 1]] elif gx > g and gy < g: return [[0, 0], [1, 0], [0, -1]] elif gx < g and gy > g: return [[0, 0], [-1, 0], [0, 1]] else: return [[0, 0]] 代码较长但是与YOLOv3、YOLOv4类似,重点看不同之处是将GT BOX与Anchor做宽高比,如图580所示。 图580宽高比选择正样本 代码中box_maxes的真实BOX的宽和高为[1.3,1.6],此时选择的Anchor的宽和高为[0.45,0.35],box_maxes/anchor_maxes=[2.85,4.54],然后如果np.max(r_wh_max)=4.54,宽高比>4则不会选取,循环下1个Anchor的值。 多次循环后,当前GT BOX所在的格子为i=4,j=7且第3个检测头中的第2个Anchor此时r_max<4,然后根据cx=7.69和cy=4.45在get_near_points(box2[0],box2[1])中计算最近的3个锚点,如图581所示。 图581根据GT BOX选择锚点 因为cx=7.69和cy=4.45,所以gx=0.69和gy=0.45,其GT BOX的锚点在格子的右上方,所以新锚点格子需要[7,4]+[[0,0],[1,0],[0,-1]],其锚点由原来的1个变成了3个,如图582所示。 图582根据GT BOX计算3个新锚点 然后得到的新的格子为[[7,4],[8,4],[7,3]],根据新的锚点计算偏移值,box2[0]-new_j、box2[1]-new_i,如图583所示。 图583新锚点的正样本赋值 5.9.4代码实现损失函数的构建及训练 YOLOv5的损失基本与YOLOv4的损失相同,主要代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/util/tools.py class MultiAngleLoss(object): def yolo_v5_loss(self, y_true, y_pred, true_box2): """YOLOv5的损失的整体计算过程跟YOLOv4区别不大""" #3个检测头 num_layers = self.num_layers loss = 0 for index in range(num_layers): y_pre1 = y_pred[index] y_pre = tf.reshape(y_pre1, [ -1, y_pre1.shape[1], y_pre1.shape[2], len(self.anchor[0]), 4 + 1 + self.num_class ]) #真实值合并 true_grid = tf.concat([t[index] for t in y_true], axis=0) #获取置信度信息 object_mask = true_grid[..., 4:5] #对每个检测头进行解码,预测返回的是 xmin,ymin,xmax,ymax位置信息、置信度 #信息和分类信息 pre_boxes, pre_box_confidence, pre_box_class_props = yolo_v5_head_decode( y_pre, 2 - index, anchor=self.anchor, num_class=self.num_class ) #使用CIOU直接求BOX的loss ciou_score = c_iou(pre_boxes, true_grid) ciou_loss = object_mask * tf.abs((1 - ciou_score)) #确定正样本 tobj = tf.where( tf.equal(object_mask, 1), tf.maximum(ciou_score, tf.zeros_like(ciou_score)), tf.zeros_like(ciou_score) ) #置信度损失 confidence_loss = K.binary_crossentropy(tobj, pre_box_confidence, from_logits=True) #分类损失 class_loss = object_mask * K.binary_crossentropy( true_grid[..., 5:], y_pre[..., 5:], from_logits=True ) #正样本数量 num_pos = tf.maximum(K.sum(K.cast(object_mask, tf.float32)), 1) #位置损失 location_loss = tf.abs(K.sum(ciou_loss)) * self.box_ratio / num_pos #置信度损失 confidence_loss = K.mean(confidence_loss) * self.balance[index] * self.obj_ratio #分类损失 class_loss = K.sum(class_loss) * self.cls_ratio / num_pos / self.num_class loss += location_loss + confidence_loss + class_loss return loss 代码中没有求负样本的置信度损失,而是使用K.binary_crossentropy(tobj,pre_box_confidence)求正样本的置信度损失,位置损失使用ciou_loss进行求解。总损失仍然是位置损失、置信度损失、分类损失之和location_loss+confidence_loss+class_loss。 与YOLOv4实现不同的是在解码函数yolo_v5_head_decode()中对Grid敏感度进行了限制,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/util/tools.py def yolo_v5_head_decode(features, index, anchor=None, num_class=80, scale_x_y=2): """对预测出来的值进行解码,得到xmin,ymin,w,h""" #features 1 × 13 × 13 × 3 × 85 if anchor is None: anchor = np.array([ [[10, 13], [16, 30], [33, 23]], #小目标 [[30, 61], [62, 45], [59, 119]], #中目标 [[116, 90], [156, 198], [373, 326]] #大目标 ])/255. anchor_size = len(anchor) features = tf.reshape(features, [-1, features.shape[1], features.shape[2], len(anchor[0]), 4 + 1 + num_class]) conv_height, conv_width = features.shape[-4], features.shape[-3] #2 ×sigmoid(xy) - 0.5,值域就变成了-0.5~1.5 xy_offset = scale_x_y * tf.nn.sigmoid(features[..., 0:2]) - 0.5 * (scale_x_y - 1) #2 × pow(sigmoid(wh),2),限定值域 wh_offset = tf.square(scale_x_y * tf.sigmoid(features[..., 2:4])) #置信度 box_confidence = tf.sigmoid(features[..., 4:5]) # box_class_props = tf.nn.sigmoid(features[..., 5:]) #在feature上面生成anchors height_index = tf.range(conv_height, dtype=tf.float32) width_index = tf.range(conv_width, dtype=tf.float32) #得到网格 x_cell, y_cell = tf.meshgrid(height_index, width_index) x_cell = tf.reshape(x_cell, [x_cell.shape[0], x_cell.shape[1], 1]) y_cell = tf.reshape(y_cell, [x_cell.shape[0], x_cell.shape[1], 1]) #根据网格求坐标位置,套公式 bbox_x = (x_cell + xy_offset[..., 0]) / conv_height bbox_y = (y_cell + xy_offset[..., 1]) / conv_width bbox_w = (anchor[index][:, 0] * wh_offset[..., 0]) / conv_height bbox_h = (anchor[index][:, 1] * wh_offset[..., 1]) / conv_width boxes = tf.stack( [ bbox_x, bbox_y, bbox_w, bbox_h ], axis=3 ) boxes = tf.reshape(boxes, [boxes.shape[0], boxes.shape[1], boxes.shape[2], anchor_size, 4]) return boxes, box_confidence, box_class_props 在代码xy_offset和wh_offset中根据公式使值域为[-0.5,1.5],通过bbox_x、bbox_y、bbox_w和bbox_h解码得到预测值。 5.9.5代码实战预测推理 YOLOv5的预测推理流程仍然与YOLOv3一致,不同之处是在将预测值解码时的公式变为公式518,所以在YOLOv3的基础上应对解码函数_decode(self)进行修改,修改后的代码如下: def _decode(self): #根据当前预测layer对预测出来的结果解码 #YOLOv3的输出(4+1+num_class) ×3个Anchor self.b = self.output[self.currentLayer].shape[0] #变成[b,3,(4+1+2),169) logits = np.reshape(self.output[self.currentLayer], [self.b, 3, -1, self.h * self.w]) self.result = np.zeros(logits.shape) #套用公式进行解码,使用Sigmoid()函数对值域进行限制 self.result[:, :, 0, :] = ((2 * tf.nn.sigmoid(logits[:, :, 0, :]) - 0.5 * (2 - 1)).NumPy() + self.lin_x) / self.w self.result[:, :, 1, :] = ((2 * tf.nn.sigmoid(logits[:, :, 1, :]) - 0.5 * (2 - 1)).NumPy() + self.lin_y) / self.h self.result[:, :, 2, :] = (tf.square(2 * tf.sigmoid(logits[:, :, 2, :]))). NumPy() * self.anchor_w) / self.w self.result[:, :, 3, :] = (tf.square(2 * tf.sigmoid(logits[:, :, 3, :]))). NumPy() * self.anchor_h) / self.h self.result[:, :, 4, :] = tf.sigmoid(logits[:, :, 4, :]).NumPy() self.result[:, :, 5:, :] = tf.nn.softmax(logits[:, :, 5:, :]).NumPy() 总结 YOLOv5没有发表论文(截至本节撰写时),其主要结构与YOLOv4类似。在代码中主要使用了GT和Anchor宽高比小于4,并且由某个GT中心点附近的3个格子进行预测,极大地提高了正样本的数量。 练习 运行并调试本节代码,理解算法的设计与代码的结合,重点梳理本算法的实现方法。 5.10单阶段速度快多检测头网络YOLOv7 5.10.1模型介绍 YOLOv7于2022年由ChienYao Wang等在发表的论文YOLOv7: Trainable bagoffreebies sets new stateoftheart for realtime object detectors中提出。相对于YOLOv5,主要引入了ELAN、MP、SPPCSPC、REPConv等模块,正负样本匹配时使用了Better simOTA的计算方法,并在训练时增加了Aux Head检测,其网络结构如图584所示。 图584YOLOv7的结构 图像的输入尺寸设定为640×640,进行两次CBS,其中CBS分别为卷积、BN、SiLU激活函数。第1个CBS的步长为1,改变通道数,第2个CBS的步长为2,进行下采样,重复后得到160×160×128的特征图。 ELAN模块是一个高效的网络结构,它通过控制最短和最长的梯度路径,使网络能够学习到更多的特征,并且具有更强的稳健性。ELAN有两个分支,第1个分支经过1×1卷积做通道数的变化; 第2个分支首先经过1×1卷积模块做通道数的变化,接着经过4个3×3的卷积做特征提取,然后将4个特征叠加在一起再经过CBS模块输出特征。ELANW模块与ELAN模块类似,只是融合的特征层多了两个分支。 MP模块进一步做下采样,由两个分支构成。第1个分支先经过最大池化后接1×1卷积以改变通道数。第2个分支经过1×1卷积做通道数的变化后再经过步长为2的3×3卷积也进行下采样,然后将两个分支进行Concat得到不同尺度,以及不同特征值的下采样特征图。MP1在backbone中输入通道数减半,而head时MP2的通道数不变。 在YOLOv4中SPP的作用是能够增大感受野,使算法适应不同分辨率的图像,获取不同的感受野。在SPPCSPC中首先将特征分为两部分,第1个分支经过5、9、13的最大池化,并与s=1的CBS模块进行Concat,使该结构能够处理不同尺度的感受野,利于区分大目标和小目标。另1个分支进行常规处理,并与经过最大池化的分支合并,使该结构能够减少计算量,从而使精度得到提升。 模块重参数RepConv在训时将一个整体模块分割成多个不同的模块分支,而在推理过程将多个分支模块集成到一个完全等价的模块中,在保证精度的条件下使推理效率更高。 图585YOLOv7正样本选择 正负样本匹配继承了YOLOv5中的宽高比的匹配方法,并根据标注框GT BOX的中心位置获取临近的两个网格并作为正样本,即1个GT BOX由3个网格来预测。默认Anchor有9个,1个GT BOX如果与Anchor的宽高比都小于4,则一共有3×9=27个正样本,并且此时正样本将分布在3个检测头中。有可能存在一个GT BOX对应多个正样本的情况,而且一个正样本有可能对应多个GT BOX。这种多Anchor、多Grid网格、多检测头Head匹配的方法是YOLOv7中的初选,如图585所示。 然后进入复选OTA算法,动态地确定每个GT BOX真正需要的正样本数量。首先计算GT BOX与预测框的IOU,然后根据IOU的得分从大到小进行排序,取Top 10个IOU的得分进行求和,求和所得值设为当前动态正样本数K(K最小取1)。这个K值就是GT BOX所要选取的正样本的数量。 然后计算每个GT BOX中的置信度与预测结果中的置信度×分类的交叉熵损失pair_wise_cls_loss,再将初选框正样本的回归Reg IOU Loss加上pair_wise_cls_loss,并为每个GT BOX取loss最小的前K个样本作为正样本。 因为一个GT BOX可以有多个正样本,一个正样本应该只能对应一个GT BOX,所以如果一个正样本对应多个GT BOX,就能找到它跟多个GT BOX的损失值,用最小的那个损失所在的正样本进行预测。这个过程可以称为精选,更详细的代码实现过程可参考后文。 YOLOv7论文中使用一辅助头Aux Head检测,需要注意的是正样本在粗选时,辅助头中每个网络与GT BOX匹配后选择附近的4个网格,而Lead Head是两个。在精选时Aux Head选择Top 20个进行GT BOX与初选正样本的求和,而Lead Head是10个,然后在平衡Aux Head loss和Lead Head loss时,需要按照0.25∶1的比例进行,否则会导致Lead Head精度变低,如图586所示。 图586辅助头检测 在损失函数方面没有大的变化,位置损失使用CIOU,置信度和分类损失使用交叉熵,不同的检测头有不同的权重比例。 5.10.2代码实战模型搭建 根据模型结构图584使用PyTorch生成结构ELAN,代码如下: #第5章/ObjectDetection/Pytorch_Yolo_V7_Detected/conv_utils.py def autopad(k, p=None): #根据步长自动补0 #Pad to 'same' if p is None: p = k //2 if isinstance(k, int) else [x //2 for x in k] #auto-pad return p class CBS(nn.Module): #标准卷积模块 #输入cannel,输出channel,核长,步长,padding数量,groups分组卷积数量 def __init__(self, c1, c2, k=1, s=1, p=None, g=1, act=True): super(CBS, self).__init__() #autopad(k,p)根据卷积核自动补0 self.conv = nn.Conv2d(c1, c2, k, s, autopad(k, p), groups=g, bias=False) self.bn = nn.BatchNorm2d(c2) self.act = nn.SiLU() if act is True else (act if isinstance(act, nn.Module) else nn.Identity()) def forward(self, x): return self.act(self.bn(self.conv(x))) def fuseforward(self, x): return self.act(self.conv(x)) class ELAN(nn.Module): #ELAN模块 def __init__(self, c1, c2): #C1为输入通道,C2为输出通道 super(ELAN, self).__init__() #CBS为Conv->BN->SiLU的封装 #c1、c2、1、1分别为输入、输出、kernelSize、strides self.cbs1 = CBS(c1, c2, 1, 1) self.cbs2 = CBS(c1, c2, 1, 1) #cbs3->cbs6输入与输出通道相同 self.cbs3 = CBS(c2, c2, 3, 1) self.cbs4 = CBS(c2, c2, 3, 1) self.cbs5 = CBS(c2, c2, 3, 1) self.cbs6 = CBS(c2, c2, 3, 1) #因为会将out1,out2,out3,out4合并,所以输入/输出通道是4*c2 self.cbs7 = CBS(4 * c2, c2 * 4, 1, 1) def forward(self, x): out1 = self.cbs1(x) #假设x为128×160×160,则经cbs1后输出为64×160×160 out2 = self.cbs2(x) #cbs2为另一个分支,输出为64×160×160 out3 = self.cbs4(self.cbs3(out2)) #out2进行cbs3和cbs4 out4 = self.cbs6(self.cbs5(out3)) #out3进行cbs5和cbs6 #合并后使用cbs7 return self.cbs7(torch.cat([out1, out2, out3, out4], dim=1)) PyTorch在创建子模块时继承了nn.Module,然后在__init__()中描述算子的属性,在forward()中实现前向传播。ELAN模块在cbs3、cbs4、cbs5、cbs6时,输入与输出的通道相同。在forward()中将out1、out2、out3、out4进行cat操作,通道数变为4倍,所以cbs7的输出为4*c2。另外,PyTorch的输入shape为[batch_size,channel,height,width],所以在cat操作时dim=1(在1轴),而TensorFlow的输入shape为[batch_size,height,width,channel],所以在cat操作时axis=-1。 ELANW模块与ELAN模块类似,将out1、out2、out3、out4、out5、out6的输出特征进行了融合,cbs3、cbs4、cbs5、cbs6是输入通道数的一半,详细的代码如下: #第5章/ObjectDetection/Pytorch_Yolo_V7_Detected/conv_utils.py class ELAN_W(nn.Module): #ELAN_W模块 def __init__(self, c1, c2): super(ELAN_W, self).__init__() self.cbs1 = CBS(c1, c2, 1, 1) self.cbs2 = CBS(c1, c2, 1, 1) #输出是c2//2的一半 self.cbs3 = CBS(c2, c2 //2, 3, 1) self.cbs4 = CBS(c2 //2, c2 //2, 3, 1) self.cbs5 = CBS(c2 //2, c2 //2, 3, 1) self.cbs6 = CBS(c2 //2, c2 //2, 3, 1) self.cbs7 = CBS(4 * c2, c2, 1, 1) def forward(self, x): out1 = self.cbs1(x) #假设x为512 × 40 × 40,则经cbs1后输出为512 × 40 × 40 out2 = self.cbs2(x) out3 = self.cbs3(out2) #256 × 40 × 40 out4 = self.cbs4(out3) out5 = self.cbs5(out4) out6 = self.cbs6(out5) #将每个cbs的输出都进行了合并 return self.cbs7(torch.cat([out1, out2, out3, out4, out5, out6], dim=1)) 接下来对MP模块进行实现,代码如下: #第5章/ObjectDetection/Pytorch_Yolo_V7_Detected/conv_utils.py class MP(nn.Module): #MP模块 def __init__(self, c1, c2, k=2, p=None): #C1为输入通道,C2为输出通道 super(MP, self).__init__() self.k = k self.pool1_1 = nn.MaxPool2d(self.k, self.k) #输入与输出通道相同 self.cbs1_2 = CBS(c1, c2, 1, 1) self.cbs2_1 = CBS(c1, c2, 1, 1) self.cbs2_2 = CBS(c2, c2, 3, 2) def forward(self, x): #先池化后cbs x1 = self.cbs1_2(self.pool1_1(x)) #128 × 80 × 80 #另一个分支进行两次cbs x2 = self.cbs2_2(self.cbs2_1(x)) #128 × 80 × 80 #池化和cbs进行融合 return torch.cat([x1, x2], dim=1) 当采用MP(2*c1,c1)时,即c1是c2的两倍时为MP1结构; 当采用MP(c1,c1)时,即c1==c2时为MP2结构。 对SPPCSPC模块进行封装,代码如下: #第5章/ObjectDetection/Pytorch_Yolo_V7_Detected/conv_utils.py class SPPCSPC(nn.Module): #SPPCSPC模块 def __init__(self, c1, c2, e=0.5, k=(5, 9, 13)): #c1为输入,c2为输出,e为通道扩展倍数 #k=(5, 9, 13)表明进行SPP时尺寸的变化 super(SPPCSPC, self).__init__() c_ = int(2 * c2 * e) #输出channel self.cv1 = CBS(c1, c_, 1, 1) self.cv2 = CBS(c1, c_, 1, 1) self.cv3 = CBS(c_, c_, 3, 1) self.cv4 = CBS(c_, c_, 1, 1) #对k=(5, 9, 13)进行3个尺度的池化 self.m = nn.ModuleList([nn.MaxPool2d(kernel_size=x, stride=1, padding= x //2) for x in k]) #因为cv5是对cv4和m的cat,所以输入通道扩展了4倍 self.cv5 = CBS(4 * c_, c_, 1, 1) #对cv5进行卷积,所以输入、输出又降为c self.cv6 = CBS(c_, c_, 3, 1) #对cv2和cv6进行cat,所以输入变为2c self.cv7 = CBS(2 * c_, c2, 1, 1) def forward(self, x): #先进行串列卷积 x1 = self.cv4(self.cv3(self.cv1(x))) #不同尺度池化,进行cv5、cv6的卷积 y1 = self.cv6(self.cv5(torch.cat([x1] + [m(x1) for m in self.m], 1))) #对原输入x进行卷积 y2 = self.cv2(x) #合并后卷积 return self.cv7(torch.cat((y1, y2), dim=1)) SPPCSPC模块对于输入x进行3次串列cv1、cv3、cv4的输出,从而得到x1,然后对x1进行5、9、13的不同尺度池化cat,从而得到x2,将x1和x2合并进行两次串联cv5、cv6,从而得到y1。另1个分支对于x进行1次cv2,从而得到y2,将y1和y2进行cat后cv7得到最终的输出。该结构能够处理不同尺度的感受野,利于区分大目标和小目标。RepConv模块可参考4.12节重参数网络RepVGGNet的实现。 将CBS、ELAN、MP、ELANW、SPPCSPC模块按模型图进行组装就能实现YOLOv7的前向传播,代码如下: #第5章/ObjectDetection/Pytorch_Yolo_V7_Detected/yolo7.py class Yolo7(nn.Module): #YOLOv7模型的实现 def __init__(self, num_class=80, layer_num_anchors=3): #num_class:预测类别数 #layer_num_anchors:锚框数量 super(Yolo7, self).__init__() self.num_class = num_class ####bakcbone #重复4次cbs,即结构图中的cbs_repeat4 self.cbs1 = CBS(3, 32, 3, 1) self.cbs2 = CBS(32, 64, 3, 2) self.cbs3 = CBS(64, 64, 3, 1) self.cbs4 = CBS(64, 128, 3, 2) #第1个ELAN->MP,输出是b×(64×4) ×160×160 #因为ELAN会对out1、out2、out3和out4进行合并,所以输出是64×4 self.elan_b1 = ELAN(128, 64) #mp1,输出是b×(128×2) ×80×80,因为MP会对x1和x2合并,所以输出是128×2 self.mp1_b1 = MP(64 * 4, 128) #第2个ELAN->MP self.elan_b2 = ELAN(128 * 2, 128) self.mp1_b2 = MP(128 * 4, 256) #out b×(256×2) ×40 × 40 #第3个ELAN->MP self.elan_b3 = ELAN(256 * 2, 256) self.mp1_b3 = MP(256 * 4, 512) #out b×(512×2) ×20 × 20 #ELAN->SPPCSP self.elan_b4 = ELAN(512 * 2, 256) #out b×(256×4) ×20 × 20 self.sppcsp = SPPCSPC(512 * 2, 512) #out b×512×20 × 20 ####FPN结构,cbs->upsample->cat->ELAN_W... self.cbs_f1 = CBS(512, 256, 1, 1) self.up_f1 = nn.Upsample(scale_factor=2) self.cbs_route2 = CBS(1024, 256, 1, 1) self.cbs_route1 = CBS(512, 128, 1, 1) self.elan_w_f1 = ELAN_W(512, 256) self.cbs_f2 = CBS(256, 128, 1, 1) self.up_f2 = nn.Upsample(scale_factor=2) self.elan_w_f2 = ELAN_W(256, 128) ####PAN结构下采样 self.mp2_p1 = MP(128, 128) self.elan_w_p1 = ELAN_W(512, 256) self.mp2_p2 = MP(256, 256) self.elan_w_p2 = ELAN_W(1024, 512) ####head输出头 4代表位置,1为置信度,num_class为分类的概率,每个检测头有3个锚框 out_c = (4 + 1 + num_class) * layer_num_anchors #第1个检测头,进行重参数化 255 × 80 ×80 self.repcov1 = RepConv(128, 256) #预测输出 self.cbs_out1 = CBS(256, out_c, act=False) #第2个检测头,进行重参数化,预测输出 255 × 40 ×40 self.repcov2 = RepConv(256, 512) self.cbs_out2 = CBS(512, out_c) #第3个检测头,进行重参数化,预测输出 255 × 20 ×20 self.repcov3 = RepConv(512, 1024) self.cbs_out3 = CBS(1024, out_c) def forward(self, x): #cbs repeat4->elan->mp1 x = self.cbs4(self.cbs3(self.cbs2(self.cbs1(x)))) #1×128×160×160 x = self.mp1_b1(self.elan_b1(x)) #256 × 80 × 80 #route1会经过cbs进入FPN结构进行cat route1 = self.elan_b2(x) #512 × 80 × 80 x = self.mp1_b2(route1) #512 × 40 × 40 #route2会经过cbs进入FPN结构进行cat route2 = self.elan_b3(x) #1024 × 40 × 40 x = self.mp1_b3(route2) #1024 × 20 × 20 #route3会经过cbs进入FPN结构进行cat x = self.elan_b4(x) #1024 × 20 × 20 route3 = self.sppcsp(x) #512 × 20 × 20 #FPN上采样的构建 512 × 40 × 40 #上采样,并实现浅深层特征合并,然后ELAN-W x = torch.concat([self.up_f1(self.cbs_f1(route3)), self.cbs_route2(route2)], dim=1) #这一层特征会进入PAN进行特征合并 fpn_route1 = self.elan_w_f1(x) #256 × 40 × 40 #继续上采样,并实现浅深层特征合并,然后ELAN-W x = torch.concat([self.up_f2(self.cbs_f2(fpn_route1)), self.cbs_route1(route1)], dim=1) #256 × 80 × 80 #上采样,并实现浅深层特征合并,然后ELAN-W,并作为第1个检测层来检测小目标 out1 = self.elan_w_f2(x) #128 × 80 × 80 #PAN下采样的构建 256 × 40 × 40 #MP->CAT-ELAN-W作为第2个检测头,检测中目标 out2 = self.elan_w_p1(torch.concat([self.mp2_p1(out1), fpn_route1], dim=1)) #继承MP->CAT-ELAN-W作为第3个检测头,检测大目标 out3 = self.elan_w_p2(torch.concat([self.mp2_p2(out2), route3], dim=1)) #512 × 20 × 20 #根据out1、out2和out3输出进行重参数化和检测 p1 = self.cbs_out1(self.repcov1(out1)) #255 × 80 ×80 p1_shape = p1.shape #维度(batch,3,height,width, (4 + 1 + self.num_class)) #表示每个特征图有3个锚框,每个锚框有4个位置、1个置信度和num_class个类别 p1 = p1.view(p1_shape[0], 3, p1_shape[2], p1_shape[3], (4 + 1 + self.num_class)) p2 = self.cbs_out2(self.repcov2(out2)) #255 × 40 ×40 p2_shape = p2.shape p2 = p2.view(p2_shape[0], 3, p2_shape[2], p2_shape[3], (4 + 1 + self.num_class)) p3 = self.cbs_out3(self.repcov3(out3)) #255 * 20 *20 p3_shape = p3.shape p3 = p3.view(p3_shape[0], 3, p3_shape[2], p3_shape[3], (4 + 1 + self.num_class)) #返回3个输出 return p1, p2, p3 在YOLOv7类中对模型进行了实现,__init__()为每个结构类初始化,在forward()中进行了实现。提取Backbone特征,输出route1、route2、route3,然后在FPN中对route3进行上采样,并与self.cbs_route2(route2)进行特征合并,通过self.elan_w_f1输出fpn_route1,并对fpn_route1继续上采样,然后与self.cbs_route1(route1)进行特征合并,经过self.elan_w_f2()后得到out1。 在out1之后就是PAN结构,通过self.mp2_p1(out1)进行下采样,并跟fpn_route1进行特征合并,然后经过self.elan_w_p1作为out2; 通过self.mp2_p2(out2)进行下采样,并与route3进行特征合并,通过self.elan_w_pw()输出out3; 在out1、out2、out3之后经过self.repcov()重参数和self.cbs_out卷积,输出p1、p2、p3并通过view(p1_shape[0],3,p1_shape[2],p1_shape[3],(4+1+self.num_class))方法输出(batch,3,height,width,(4+1+self.num_class)),表示每个特征图有3个锚框,每个锚框有4个位置、1个置信度和num_class个类别。 5.10.3代码实战建议框的生成 YOLOv7的正样本提取参考了YOLOv5中的方法进行初选,然后由OTA算法进行精选,其代码实现过程较复杂,在此参考YOLOv7官方代码进行实现。 训练标签数据的文件格式的内容如下: 文件路径 xmin,ymin,xmax,ymax,label xmin,ymin,xmax,ymax,label ./face_train/19_Couple_Couple_19_461.jpg 441,432,525,528,0 592,435,676,546,0 PyTorch读取自定义标签文件需要继承Dataset类,并在__init__()中进行属性的初始化,在__len__()中返回标签的数量,在__getitem__()中实现按batch size生成指定的内容,详细的代码如下: #第5章/ObjectDetection/Pytorch_Yolo_V7_Detected/dataloader.py class FaceDataSet(Dataset): def __init__(self, data_root, transform=None, size=(640, 640)): """ 自定义读取数据格式初始化 :param data_dir: 路径 :param transform: 数据预处理 """ self.transform = transform self.data_root = data_root self.img_size = size #获取训练图片和label信息 self.data_info = self.get_data() def __len__(self): #类返回数据容量 return len(self.data_info) def __getitem__(self, item): #从所有数据中获取指定长度的内容,只能返回维度相等的内容 #所以需要指定collate_fn进行合并拼接 datas = self.data_info[item] img, true_box, path = self.get_data_rows(datas) if self.transform is not None: #数据格式转换等 img = self.transform(img) return img, true_box, path @staticmethod def collate_fn(batch): #根据指定batch 返回img和boxes的信息 img, true_box, path = zip(*batch) #将true_box [n,6]中的第1位标明是哪一张图片 for i, l in enumerate(true_box): l[..., 0] = i img = torch.stack(img, 0) true_box = torch.cat(true_box, 0) return img, true_box, path def get_data(self): with open(self.data_root, encoding='utf-8') as f: rows = f.readlines() return rows def get_data_rows(self, row): #存储image path和boxes #按格式进行解析 #./face_train/19_Couple_Couple_19_461.jpg 441,432,525,528,0 592,435, #676,546,0 #解析后为['./face_train/19_Couple_Couple_19_461.jpg', '441,432,525,528,0', #'592,435,676,546,0'] data = row.strip().split(' ') path = data[0] #读图片和boxes img = cv2.imread(path) true_box = np.array([np.array(list(map(int, box1.split(',')))) for box1 in data[1:]]) #转换为指定大小的图片和boxes信息 img, true_box = u.letterbox_image( Image.fromarray(np.uint8(img.copy())), self.img_size, true_box ) #print(true_box) #将true_box由xmin,ymin,xmax,ymax转换为cx,cy,w,h true_box[..., :4] = u.xyxy2cxcywh(true_box) #[len,6]是为了在collate_fn中的第1个位置标明是第几张图片 true_box_out = torch.zeros([len(true_box), 6]) true_box = torch.from_numpy(true_box) if true_box[0] != 0: true_box_out[..., 1] = true_box[..., -1] true_box_out[..., 2:6] = true_box[..., 0:4] / self.img_size[0] return torch.tensor(img / 255., dtype=torch.float32).transpose(0, -1), true_box_out, path if __name__ == "__main__": data_root = r"../2021_train_yolo.txt" t = FaceDataSet(data_root) train_loader = DataLoader( dataset=t, batch_size=4, shuffle=True, collate_fn=FaceDataSet.collate_fn ) #每次得到的是batch size的矩阵。在data中应保留inputs和labels for i, (img, truebox, path) in enumerate(train_loader): #truebox返回的是[4,6],第1位表明是第几张图片 inputs, labels = img, truebox 在自定义FaceDataSet类的__init__()中self.data_info属性用于返回所有训练标签文件,如图587所示。 图587self.data_info属性值 在__getitem__()中根据随机的item从self.get_data_row中获取数据,datas为标签文件中的第3902行数据,如图588所示。 图588__getitem__()构造函数 在get_data_rows(row)中根据传入的标签内容,先对标签的数据进行清洗,然后经过letterbox_image()对图片和GT BOX进行等比例缩放,并将输入的标签格式转换成cx,cy,w,h,然后使用true_box_out矩阵的第1位标明当前图片是batch size中的第几张图片,使输出格式的矩阵变为[batch index,label,cx,cy,w,h]。 torch.tensor(img/255.,dtype=torch.float32).transpose(0, -1)是由于OpenCV读取的格式是[height,width,channel],而PyTorch格式是[channel,height,width],所以图片的shape要进行改变,如图589所示。 图589self.get_data_rows的实现 在collate_fn(batch)中根据batch size返回数据。batch变量用于存储img、true_box、path的信息,如图590所示。 图590batch参数的值 然后根据enumerate(true_box)中的内容,通过l[...,0]=i修改第1位的值,以此来表明是batch size中的第几张图片。最后通过img=torch.stack(img,0)对图片数据进行合并,true_box=torch.cat(true_box,0)实现标签数据的合并,如图591所示。 图591collate_fn的实现 然后使用DataLoader构建生成器train_loader,经enumerate(train_loader)之后便可获得指定batch size的图片和label信息,如图592所示。 图592DataLoader的实现 读取完数据后根据YOLOv5中的实现,先将GT BOX与Anchor BOX进行高宽比,如果小于4,则入选,同时再以GT BOX为中心点挑选附近的3个格子作为正样本,此函数被封装在find_3_positive(self,p,targets)中,p为3个检测头的预测值,targets为GT BOX的标签值,详细的代码如下: #第5章/ObjectDetection/Pytorch_Yolo_V7_Detected/loss.py def find_3_positive(self, p, targets): na = len(self.anchor) #Anchor的数量 nt = targets.shape[0] #GT BOX的数量,4 × 6 #存放indices和anchors indices, anch = [], [] #序号2:6为特征层的高宽 gain = torch.ones(7, device=targets.device).long() #GT BOX的下标 ai = torch.arange(na, device=targets.device).float().view(na, 1).repeat(1, nt) #实现每个Anchor都复制一份GT的信息 targets = torch.cat((targets.repeat(na, 1, 1), ai[:, :, None]), 2) #targets [0.0000, 1.0000, 0.4344, 0.2141, 0.7750, 0.6594, 2.0000] #第几张图, 类别, gt_cx, gt_cy, gt_h, gt_w, 第几个Anchor g = 0.5 #偏置 off = torch.tensor([ [0, 0], [1, 0], [0, 1], [-1, 0], [0, -1], #j,k,l,m ], device=targets.device).float() * g #offsets #遍历每个检测头 for i in range(len(p)): #每个检测头对应的Anchor anchors = self.anchor[2 - i] * p[i].shape[2] #4×3×20×20×7-->20×20×20×20 #gain本来是[1,1,1,1,1,1,1],此时变为[1,1,20,20,20,20,1] gain[2:6] = torch.tensor(p[i].shape)[[3, 2, 3, 2]] #xyxy gain #将targets乘以gain,将真实框映射到特征层上。每个格子都存有targets值 t = targets * gain #如果存在GT if nt: #4:6为GT BOX的高宽,在YOLOv5中根据高宽比来确定正样本 r = t[:, :, 4:6] / anchors[:, None] #高宽比小于4.0的mask j = torch.max(r, 1. / r).max(2)[0] < 4.0 t = t[j]#通过j的布尔值过滤出t的正样本 #gxy用于获得t对应的正样本的x坐标和y坐标 gxy = t[:, 2:4] #gxi用于获取每个格子的xy坐标20*20 gxi = gain[[2, 3]] - gxy #gxy离每个格子左上角点的距离 #根据gxi的值,计算附近的格子的mask j, k = ((gxy % 1. < g) & (gxy > 1.)).T l, m = ((gxi % 1. < g) & (gxi > 1.)).T j = torch.stack((torch.ones_like(j), j, k, l, m)) #t重复5次,使用满足条件的j进行框的提取 #假设t本来有17个,则先扩充5倍,变成[5,17,4+1+num_class] #j代表当前特征点在[0, 0], [1, 0], [0, 1], [-1, 0], [0, -1]方向是否存在 t = t.repeat((5, 1, 1))[j] #在[5,17,4+1+num_class]个样本中根据mask j提 #取正样本,就变成了[50,7] offsets = (torch.zeros_like(gxy)[None] + off[:, None])[j] #附近新正样本 #的偏移值 else: #没有目标当负样本 t = targets[0] offsets = 0 #b为第几张图片,c为类别 b, c = t[:, :2].long().T gxy = t[:, 2:4] #正样本 xy #gwh = t[:, 4:6] #正样本 wh gij = (gxy - offsets).long() #偏移值 gi, gj = gij.T #得到在gi,gj个格子中存在目标 #a为第几个Anchor a = t[:, 6].long() #返回indices [第几张图片,第几个anchor,第j个列,第i行] indices.append( (b, a, gj.clamp_(0, gain[3] - 1), #gj.clamp_(0, gain[3] - 1) 限定格 #子的位置在0~19 gi.clamp_(0, gain[2] - 1)) ) #image, anchor, grid indices anch.append(anchors[a]) #此时正样本的anchors值是多少 return indices, anch 代码较难理解,先看第1部分,targets为输入GT BOX的信息,因为batch size=4,所以这里的shape=4*6。ai这个变量根据设置的anchor=3数量,生成Anchor的下标索引值,因为Anchor也有4个位置,所以ai的值为tensor([[0.,0.,0.,0.],[1.,1.,1.,1.],[2.,2.,2.,2.]]); targets.repeat(na,1,1)将每个Anchor都分配targets的内容,所以shape=3*4*6,然后与ai[…,None]升维合并后变成shape=3*4*7,如内容[0.0000,1.0000,0.4344,0.2141,0.7750,0.6594,2.0000]的含义为[第几张图,类别,gt_cx,gt_cy,gt_h,gt_w,第几个anchor],如图593所示。 图593find_3_positive中的targets 第2部分,offset描述了每个当前格子,如果假设为[0,0],则附近的格式的位置为[1,0],[0,1],[-1,0],[0,-1]。anchors=self.anchor[2-i]*p[i].shape[2]根据当前预测p的shape计算Anchor在当前特征图上的大小。t=targets*gain为计算GT BOX在当前特征图上的大小; gain变量用来存储计算后的值。r=t[:,:,4:6] / anchors[:,None]用于得到当前特征图上GT BOX与anchors的高宽比,并通过j=torch.max(r,1./r).max(2)[0]<4.0来判断宽高比或者高宽比是否小于4,如果小于4,则j为True,否则为False。t[j]用于取出满足小于4.0的GT BOX在此特征图上的值,关键代码如图594所示。 图594find_3_positive中的GT BOX与Anchors的高宽比 第3部分,t[:,2:4]为targets的x和y坐标,假设特征图为20*20,则gain[[2,3]]=20*20,则gxi=gain[[2,3]]-gxy用于获取targets离每个格子左上角所在的格子数。(gxy%1.1.)用于计算当前格子偏离每个格子中心点的位置,得到j、i个格子的布尔值,然后将t=t.repeat((5,1,1))[j]复制5份,通过j的布尔值得到离GT中心点最近的3个框,假设原有11个正样本BOX,那么此时就扩展为33个正样本,如图595所示。 图595find_3_positive中正样本的扩展 第4部分,根据offsets值gij=(gxy-offsets).long()计算所在格子的位置,根据t[:,6].long()计算Anchor的位置,indices返回[第几张图片,第几个anchor,第j个列,第i行],anch.append(anchors[a])返回此时正样本的anchors值,如图596所示。 图596find_3_positive返回indices 根据上面初选的正样本,进入复选流程,复选的代码在build_targets()中,详细的代码如下: #第5章/ObjectDetection/Pytorch_Yolo_V7_Detected/loss.py def build_targets(self, p, targets, imgs): device = torch.device(targets.device) #初选,寻找目标GT中心点位置附近的3个格子作为正样本 indices, anch = self.find_3_positive(p, targets) #匹配,[batch img,anchor,j,i,gt box, anchors此时的比例] matching_bs = [[] for pp in p] matching_as = [[] for pp in p] matching_gjs = [[] for pp in p] matching_gis = [[] for pp in p] matching_targets = [[] for pp in p] matching_anchs = [[] for pp in p] #检测头 nl = len(p) #p[0].shape[0]即batch size,遍历每幅图来计算 for batch_idx in range(p[0].shape[0]): #targets [0.0000, 1.0000, 0.4344, 0.2141, 0.7750, 0.6594, 2.0000] #第几张图, 类别, gt_cx, gt_cy, gt_h, gt_w, 第几个Anchor #只取当前图的targets信息 b_idx = targets[:, 0] == batch_idx this_target = targets[b_idx] #如果没有GT BOX,则不处理 if this_target.shape[0] == 0: continue #因为target是归一化后的值,所以如果乘原图的尺寸,则返回真实的txywh #这样算的原因是后面要计算IOU txywh = this_target[:, 2:6] * imgs[batch_idx].shape[1] #将xywh转换成xmin,ymin,xmax,ymax,也是为了计算IOU txyxy = tools.xywh2xyxy(txywh) #预测xyxy,预测分类,预测置信度 pxyxys = [] p_cls = [] p_obj = [] #在哪个预测层 from_which_layer = [] #存储所有的[batch img,anchor,j,i,gt box, anchors此时的比例] all_b = [] all_a = [] all_gj = [] all_gi = [] all_anch = [] #遍历每个预测层 for i, pi in enumerate(p): b, a, gj, gi = indices[i] #根据检测头,从初选中获取 #获取当前图片 idx = (b == batch_idx) #进一步获取当前图片 b, a, gj, gi, anchi = b[idx], a[idx], gj[idx], gi[idx], anch[i][idx] all_b.append(b) all_a.append(a) all_gj.append(gj) all_gi.append(gi) all_anch.append(anchi) from_which_layer.append((torch.ones(size=(len(b),)) * i).to(device)) #在预测结果中根据真实b,a,gj,gi进行筛选 fg_pred = pi[b, a, gj, gi] #存储预测结果置信度和分类 p_obj.append(fg_pred[:, 4:5]) p_cls.append(fg_pred[:, 5:]) #所在格子合并 grid = torch.stack([gi, gj], dim=1) #因为预测出来的是偏移值,所以需要解码成xyxy #即式(2×sigmoid(txy)-0.5 + grid) × 32 pxy = (fg_pred[:, :2].sigmoid() * 2. - 0.5 + grid) * self.stride[i] #32 pwh = (fg_pred[:, 2:4].sigmoid() * 2) ** 2 * anchi * self.stride[i] #32 pxywh = torch.cat([pxy, pwh], dim=-1) pxyxy = tools.xywh2xyxy(pxywh) pxyxys.append(pxyxy) #合并3个检测头的预测值 pxyxys = torch.cat(pxyxys, dim=0) if pxyxys.shape[0] == 0: continue #对每个层预测的相关信息进行合并 p_obj = torch.cat(p_obj, dim=0) p_cls = torch.cat(p_cls, dim=0) from_which_layer = torch.cat(from_which_layer, dim=0) all_b = torch.cat(all_b, dim=0) all_a = torch.cat(all_a, dim=0) all_gj = torch.cat(all_gj, dim=0) all_gi = torch.cat(all_gi, dim=0) all_anch = torch.cat(all_anch, dim=0) #每张图片和预测值进行IOU pair_wise_iou = tools.box_iou(txyxy, pxyxys) #GT BOX和预测值进行IOU #-log(pair_wise_iou),pair_wise_iou越大,-log(y)就越小。反之离得越远,重合度越低 pair_wise_iou_loss = -torch.log(pair_wise_iou + 1e-8) #假设pair_wise_iou.shape[1]=66,则取10个。如果pair_wise_iou.shape[1]=1,则取1 top_k, _ = torch.topk(pair_wise_iou, min(10, pair_wise_iou.shape[1]), dim=1) #得到动态k,假设为[2,2,3,3],代表4个GT BOX中的每个与pre box的前10个IOU #得分之和的值 dynamic_ks = torch.clamp(top_k.sum(1).int(), min=1) #根据标签类别进行one_hot编码并升维与pxyxys一致[预测box的个数与正样本个数相同] gt_cls_per_image = ( F.one_hot(this_target[:, 1].to(torch.int64), self.num_class) .float() .unsqueeze(1) .repeat(1, pxyxys.shape[0], 1) ) #GT的数量 num_gt = this_target.shape[0] #预测 置信度 * 分类 cls_preds_ = ( p_cls.float().unsqueeze(0).repeat(num_gt, 1, 1).sigmoid_() * p_obj.unsqueeze(0).repeat(num_gt, 1, 1).sigmoid_() ) #开平方根 y = cls_preds_.sqrt_() #将GT BOX的置信度与预测log(y/1-y)进行求交叉熵损失,从而得到pair_wise_cls_ #loss损失 pair_wise_cls_loss = F.binary_cross_entropy_with_logits( torch.log(y / (1 - y)), gt_cls_per_image, reduction="none" ).sum(-1) del cls_preds_ #精选总损失 cost = ( #置信度的损失 pair_wise_cls_loss #GT BOX与Pre BOX重合度的损失。3.0表明cost更多地学习BOX之间的重叠 + 3.0 * pair_wise_iou_loss ) #根据cost初始矩阵,假设pxyxys是12个,则cost也是12个 #pxyxys的个数与正样本的i,j,k有关。根据正样本的i,j,k取对应的预测框 matching_matrix = torch.zeros_like(cost, device=device) new_pos_idx = [] for gt_idx in range(num_gt): #循环每个GT BOX,gt_idx为其下标 _, pos_idx = torch.topk( cost[gt_idx], k=dynamic_ks[gt_idx].item(), largest=False ) #dynamic_ks按batch size生成 tensor([2, 2, 3, 3, 3, 3], dtype=torch.int32) #如果gt_idx=0,则k = 2。 cost[0]即损失12个预测框中的第1组。 topk则取第1组 #的两个损失,并且largest是从小到大 matching_matrix[gt_idx][pos_idx] = 1.0 #matching_matrix将对应位置设置为1 #如果match[0][[17,17]] = 1.0,则表明此时有一个Anchor被分配到多个GT new_pos_idx += list(pos_idx.NumPy()) del top_k, dynamic_ks #假设matching_matrix 4×21,将变为21 anchor_matching_gt = matching_matrix.sum(0) if (anchor_matching_gt > 1).sum() > 0: #Anchor 匹配GT的个数,如果大于1, #则需要去重 print(self.count_repet(new_pos_idx)) #pos_idx如果有重复项,就会去重 #anchor_matching_gt > 1,只留1个Anchor匹配1个GT的内容。 #anchor_matching_gt如果有2,则会舍弃 #cost[:, anchor_matching_gt > 1]选出不重复Anchor匹配GT的cost损失 #torch.min表明取cost里面的最小值。返回最小值_和其索引cost_argmin_, #cost_argmin = torch.min(cost[:, anchor_matching_gt > 1], dim=0) #将anchor_matching_gt>1在matching_matrix中的值设置为0; 如果原来是2, #则现在变成0 matching_matrix[:, anchor_matching_gt > 1] *= 0.0 #将anchor_matching_gt>1且可以令cost最小的位置重新设置为1,表明只取 #1个anchor matching_matrix[cost_argmin, anchor_matching_gt > 1] = 1.0 #matching_matrix已去掉重复的Anchor分配到某个GT上。再次求sum(0)>0.0是否 #存在Anchor fg_mask_inboxes = (matching_matrix.sum(0) > 0.0).to(device) #最后确定有多少个正样本,并确定其编号 matched_gt_inds = matching_matrix[:, fg_mask_inboxes].argmax(0) #根据最后的正样本的index去筛选相关信息 from_which_layer, all_b, \ all_a, all_gj, all_gi, all_anch = [ x[fg_mask_inboxes] for x in [from_which_layer, all_b, all_a, all_gj, all_gi, all_anch] ] #根据正样本对GT BOX的对应的位置进行赋值。使两者维度保持一致 this_target = this_target[matched_gt_inds] #遍历3个检测头。根据检测头的编号重新整理all_b的值。使b,a,j,i等值分配到 #正确的layer头上 #matching_bs,最后正样本分配在3个头的存储 for i in range(nl): layer_idx = from_which_layer == i matching_bs[i].append(all_b[layer_idx]) matching_as[i].append(all_a[layer_idx]) matching_gjs[i].append(all_gj[layer_idx]) matching_gis[i].append(all_gi[layer_idx]) matching_targets[i].append(this_target[layer_idx]) matching_anchs[i].append(all_anch[layer_idx]) #按batch size重新整合 for i in range(nl): if matching_targets[i] != []: matching_bs[i] = torch.cat(matching_bs[i], dim=0) matching_as[i] = torch.cat(matching_as[i], dim=0) matching_gjs[i] = torch.cat(matching_gjs[i], dim=0) matching_gis[i] = torch.cat(matching_gis[i], dim=0) matching_targets[i] = torch.cat(matching_targets[i], dim=0) matching_anchs[i] = torch.cat(matching_anchs[i], dim=0) else: matching_bs[i] = torch.tensor([], device=device, dtype=torch.int64) matching_as[i] = torch.tensor([], device=device, dtype=torch.int64) matching_gjs[i] = torch.tensor([], device=device, dtype=torch.int64) matching_gis[i] = torch.tensor([], device=device, dtype=torch.int64) matching_targets[i] = torch.tensor([], device=device, dtype=torch.int64) matching_anchs[i] = torch.tensor([], device=device, dtype=torch.int64) #最后返回3个检测头中的相关信息 return matching_bs, matching_as, matching_gjs, matching_gis, matching_targets, matching_anchs 代码较长且实现较复杂,先看第1部分代码中matching_的相关变量,用来存储经过复选的正样本内容,包括每个batch中的图片,anchor值、第ji个格子,GT BOX的值,以及此时anchor值,[[]for pp in p]是由于有3个检测头,所以需要循环接收。在循环每个batch的图片中,通过b_idx=targets[:,0]==batch_idx获取当前图片的b_idx,按每张图片的b_idx去获取this_target[:,2:6]在当前图片中的txywh值并转换成xmin,ymin,xmax,ymax值,以此来计算GT BOX与Pre BOX预测值的IOU,如图597所示。 图597build_targets获取当前batch idx的真实值 第2部分,根据预测值当前所在batch及find_3_positive()返回的[batch img,anchor,j,i,gt box,anchors值]去预测值fg_pred中获取预测的置信度和分类信息的概率,然后使用pxy=(fg_pred[:,:2].sigmoid()*2.-0.5+grid)*self.stride[i]对于预测的偏移值进行解码操作,转换成cx,cy,w,h。tools.xywh2xyxy(pxywh)将预测值又转换成xmin,ymin,xmax,ymax的内容,循环enumerate(p),从而得到3个检测头的预测值,如图598所示。 图598build_targets中pxyxys的获取 第3部分,计算正样本txyxy与预测pxyxys的IOU,存储在pair_wise_iou中,然后由torch.topk(pair_wise_iou,min(10,pair_wise_iou.shape[1]),dim=1)计算pair_wise_iou中最大IOU值前10个top_k.sum(1).int()之和并作为正样本的dynamic_ks个数。gt_cls_per_image根据标签类别进行one_hot编码,升维后跟pxyxys一致。cls_preds_是预测置信度p_obj*分类p_cls的概率。-torch.log(pair_wise_iou+1e8)表明pair_wise_iou值越大,-torch.log(pair_wise_iou)就越小,反之离得越远,重合度越低,如图599所示。 图599从build_targets中获取动态k的值决定正样本 第4部分,将预测置信度*分类概率cls_preds_与每张图片中分类的概率gt_cls_per_image做binary_cross_entropy_with_logits()交叉熵损失,并对pair_wise_cls_loss+3.0*pair_wise_iou_loss进行求和,得到可以令置信度损失和回归损失最小的cost,也就是dynamic_ks个能够令选出的正样本的置信度损失与回归损失最小,如图5100所示。 图5100build_targets中的dynamic_ks决定cost最小 第5部分,根据cost值,遍历每个GT BOX,取出dynamic_ks动态K能够令cost最小的正样本索引pos_idx,并且如果此时存在,则将matching_matrix[gt_idx][pos_idx]=1.0,表明当前gt_idx和pos_idx都进行选择。new_pos_idx用来统计每个pos_idx的索引号,如图5101所示。 图5101从build_targets中获取正样本pos_idx 第6部分,如果pos_idx存在重复,则表明某个Anchor被分配给多个GT BOX。在理想情况下,一个GT BOX可以有多个正样本,但是一个Anchor应该只分配1个GT BOX。在matching_matrix中存储gt_idx和pos_idx的值,假设matching_matrix[0][[17,17]]=1.0,则表明pos_idx被分配给多个GT BOX,所以如果matching_matrix.sum(0)>1,则表明有重复Anchor被分配给GT BOX,所以matching_matrix[:,anchor_matching_gt>1]*=0.0可以令anchor_matching_gt>1的位置在matching_matrix中将值设置为0,从而剔除重复项,并得到最后的正样本fg_mask_inboxes,如图5102所示。 图5102从build_targets中去除重复pos_idx 最后遍历3个检测头,根据检测头的编号重新整理all_b的值,使b,a,j,i值分配到正确的layer头上,并使matching_bs分配正确,至此build_targets()完成正样本的提取。 整个正样本的代码比较复杂,建议下载源码后根据注释和解释进行调试。 5.10.4代码实现损失函数的构建及训练 损失函数的构建根据self.build_targets(p,targets,imgs)返回的bs、as_、gjs、gis、targets和anchors值遍历3个检测头进行位置损失计算,分类和置信度损失进行计算,并对3个检测头的损失按指定的比例进行相加,详细的代码如下: #第5章/ObjectDetection/Pytorch_Yolo_V7_Detected/loss.py class ComputeLossOTASim(object): def __call__(self, p, targets, imgs): device = targets.device #分类、位置和置信度的初始值 lcls, lbox, lobj = [torch.zeros(1, device=device) for _ in range(3)] #在build_targets中进行正样本的匹配,分为精选和复选 bs, as_, gjs, gis, targets, anchors = self.build_targets(p, targets, imgs) #因为p是3个检测头的输出,而pp则是1*3anchor*80*80*num_class等 #所以[3, 2, 3, 2]得到的Tensor为80*80*80*80 pre_gen_gains = [torch.tensor(pp.shape, device=device)[[3, 2, 3, 2]] for pp in p] #i表明是第几个检测头。pi为取的特征 for i, pi in enumerate(p): #b为第几张图,a表示第几个Anchor,gj表示第j个格子,gi表示第i格子 b, a, gj, gi = bs[i], as_[i], gjs[i], gis[i] #初始GT的矩阵跟预测出来的结果保持一致 tobj = torch.zeros_like(pi[..., 0], device=device) n = b.shape[0] if n: ps = pi[b, a, gj, gi] #根据build_targets返回的index在预测值 #中取对应位置的值 #在哪个格子 grid = torch.stack([gi, gj], dim=1) #对预测值限制值域 pxy = ps[:, :2].sigmoid() * 2. - 0.5 pwh = (ps[:, 2:4].sigmoid() * 2) ** 2 * anchors[i] pbox = torch.cat((pxy, pwh), 1) #合并 #将真实框映射到特性图上 selected_tbox = targets[i][:, 2:6] * pre_gen_gains[i] #计算真实框的偏移值 selected_tbox[:, :2] -= grid #求预测BOX与真实BOX之间的loss,这里使用的是CIOU 损失 iou = tools.bbox_iou(pbox.T, selected_tbox, x1y1x2y2=False, CIoU=True) lbox += (1.0 - iou).mean() # #tobj置信度损失,self.gr obj loss的权重。原作者默认写为1 #tobj[b, a, gj, gi],即对应build_targets的位置的置信度值 tobj[b, a, gj, gi] = (1.0 - self.gr) + self.gr * iou.detach().clamp(0).type(tobj.dtype) #GT的分类值 selected_tcls = targets[i][:, 1].long() if self.num_class > 1: t = torch.full_like(ps[:, 5:], self.cn, device=device) t[range(n), selected_tcls] = self.cp #分类的损失 lcls += self.BCEcls(ps[:, 5:], t) #置信度损失 obji = self.BCEobj(pi[..., 4], tobj) lobj += obji * self.balance[i] #不同检测头的权重不同 lbox *= self.hyp['box'] lobj *= self.hyp['obj'] lcls *= self.hyp['cls'] bs = tobj.shape[0] #batch size #总损失 loss = lbox + lobj + lcls return loss * bs, torch.cat((lbox, lobj, lcls, loss)).detach() 第1部分代码根据self.build_targets(p,targets,imgs)获得bs、as_、gjs、gis、targets和anchors,然后遍历3个检测头,得到当前检测头中的grid=torch.stack([gi,gj],dim=1)所在的格子,限制预测值的值域后计算真实框的偏移值selected_tbox[:, :2]-=grid,然后将预测框pbox与真实框selected_tbox做CIOU损失,如图5103所示。 图5103CIOU位置损失 然后对分类损失、置信度损失、回归损失进行求和,从而得到总损失,如图5104所示。 图5104总损失(求和) 5.10.5代码实战预测推理 YOLOv7的预测推理流程与YOLOv5一致,即在YOLOv3的基础上只变更了解码公式,其流程仍然是先遍历3个检测头,接着对每个检测头的输出结果进行置信度、分类概率的过滤,然后对3个检测头中满足条件的结果进行合并,再通过NMS去除重复的框,详细代码可参考YOLOv5中的解码代码和YOLOv3的推理代码。 注意: YOLOv7使用PyTorch框架进行了模型的搭建、训练。 总结 YOLOv7在YOLOv4的基础上引入了MP、ELAN、SPPCSPC模块,保护FPN、PAN、3个检测头的预测,同时在损失函数方面使用了OTASim进行更精细化的正样本提取。 练习 运行并调试本节代码,理解算法的设计与代码的结合,重点梳理本算法的实现方法。 5.11数据增强 5.11.1数据增强的作用 数据增强是一种基于原有数据,通过一些技术手段生成新数据的方法,通过数据增强可以提高模型的泛化能力,缓解过拟合,提高模型的稳健性。 目标检测中常见的数据增强有旋转、翻转、HSV等,而在YOLOv4、YOLOv5中使用Mosic数据增强提高了模型的精确率。 本节重点介绍CutOut、MixUp、Mosic、随机复制label等增强手段,其他数据增强如图5105所示。 图5105数据增强相关方法 5.11.2代码实现CutOut数据增强 CutOut是在2017年提出的一种数据增强方法,即在训练时随机裁剪掉图像的一部分,起到类似DropOut正则化的效果,在论文Improved Regularization of Convolutional Neural Networks with CutOut中表明在原有数据的基础上精度均有提高,如图5106所示。 图5106CutOut论文效果 原论文表明,增加CutOut数据增强在STL10上面精度提高了0.38。CutOut的代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/data/enhancement.py def CutOut(img, gt_boxes, amount=0): '''CutOut数据增强 img: image gt_boxes: 格式[[x1 y1 x2 y2,obj],...] amount: 概率 ''' out = img.copy() #随机选择CutOut区域 ran_select = [random.randint(0, int(len(gt_boxes) - 1 * amount)) for i in range(int(len(gt_boxes) - 1 * amount))] #根据区域进行操作 for i in ran_select: #选择哪个GT BOX进行CutOut box = gt_boxes[i] x1 = int(box[0]) y1 = int(box[1]) x2 = int(box[2]) y2 = int(box[3]) #在原有GT BOX的基础上裁一定的BOX mask_w = int((x2 - x1) * 0.5) mask_h = int((y2 - y1) * 0.5) mask_x1 = random.randint(x1, x2 - mask_w) mask_y1 = random.randint(y1, y2 - mask_h) mask_x2 = mask_x1 + mask_w mask_y2 = mask_y1 + mask_h #绘框 cv2.rectangle(out, (mask_x1, mask_y1), (mask_x2, mask_y2), (0, 0, 0), thickness=-1) #位置CutOut gt_boxes[i][0:4] = [mask_x1, mask_y1, mask_x2, mask_y2] return out, gt_boxes 传入图片和BOX信息调用后的效果如图5107所示。 图5107CutOut示意效果图 5.11.3代码实现MixUp数据增强 MixUp是在论文MixUp: BEYOND EMPIRICAL RISK MINIMIZATION中提出的,其实际上是将两张图片按一定透明度进行叠加的操作。在论文中在ERM数据集中使用VGG11,使用MixUp数据增强对于分类网络错误率约降低了0.1,如图5108所示。 图5108MixUp论文效果 MixUp的实现代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/data/enhancement.py def mixup(im, labels, im2, labels2): #传入两张图的image和label信息 r = np.random.beta(32.0, 32.0) #mixup ratio, alpha和beta=32.0 #resize到相同的大小 if im.shape[0] > im2.shape[0]: im2 = cv2.resize(im2, (im.shape[1], im.shape[0])) else: im = cv2.resize(im, (im2.shape[1], im2.shape[0])) #两张图按一定比例进行融合 im = (im * r + im2 * (1 - r)).astype(np.uint8) #对两个BOX信息进行融合 if len(labels) != 0 and len(labels2) != 0: labels = np.concatenate((labels, labels2), 0) elif len(labels) == 0: labels = labels2 elif len(labels2) == 0: labels = labels return im, labels 调用运行代码后其效果如图5109所示。 图5109MixUp示意效果图 5.11.4代码实现随机复制Label数据增强 在完成某些任务(例如缺陷检测)时,由于某些类别的缺陷数量较少,所以可以通过复制标注BOX实现特定目标分类的重采样,从而提高网络的稳健性,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/data/enhancement.py def replicate(im, labels, label_index=0, repetitions=1): """标签复制""" #得到当前图像的宽和高 h, w = im.shape[:2] #得到BOX的位置 boxes = labels[:, 0:4].astype("int") x1, y1, x2, y2 = boxes.T #计算所处坐标的位置 s = ((x2 - x1) + (y2 - y1)) / 2 for i in s.argsort()[:round(s.size * 0.5)]: #如果指定的类别不为空 if labels[i][-1] != None: #判断当前的标签是否与指定的类别一致 if labels[i][-1] == label_index: #重复标签的次数 for x in range(repetitions): #得到4个坐标点 x1b, y1b, x2b, y2b = boxes[i] #得到BOX的高、宽 bh, bw = y2b - y1b, x2b - x1b #随机原位置偏移位置 yc, xc = int(random.uniform(0, h - bh)), int(random.uniform(0, w - bw)) x1a, y1a, x2a, y2a = [xc, yc, xc + bw, yc + bh] #从当前图像中切图并改变透明度,赋给新指定的区域 im[y1a:y2a, x1a:x2a] = im[y1b:y2b, x1b:x2b]*rand() #保存BOX信息 labels = np.append(labels, [[x1a, y1a, x2a, y2a, labels[i, -1]]], axis=0) return im, labels 调用运行后红色为原目标、绿色为重复目标,如图5110所示,实现目标框增加两次。 图5110Replicate数据增强(见彩插) 5.11.5代码实现Mosic数据增强 Mosic数据增强在YOLOv4、YOLOv5、YOLOv7中均有使用,使用Mosic数据增强可以涨点,其实现思路是将经过翻转、随机裁剪、翻转、色域变换等其他数据增强的4张图合并成1张新的图,代码如下: #第5章/ObjectDetection/TensorFlow_Yolo_V5_Detected/data/enhancement.py def getList_img_box(rnd_all_lines: list, num: int = 4): """ 获得随机的图片和地址 :param rnd_all_lines: 所有训练的lines中的地址,包括图片和位置 :param num: :return: 返回对应lines的下标 """ idx = random.sample(range(len(rnd_all_lines)), num) return idx def rand(): np.random.seed(1000) return np.random.uniform() #合并4张图片 def mosic_join_img(rnd_all_lines: list, output_size=None, scale_range=None): """Mosic 合并4张图片""" if output_size is None: output_size = [1024, 1024] #设定图像尺寸 if scale_range is None: scale_range = [0.5, 0.5] #新建1个为0的图像 output_img = np.zeros([output_size[0], output_size[1], 3], dtype=np.uint8) #图像缩小的比例 scale_x = scale_range[0] + random.random() * (scale_range[1] - scale_range[0]) scale_y = scale_range[0] + random.random() * (scale_range[1] - scale_range[0]) #贴的图像大小 point_x = int(scale_x * output_size[1]) point_y = int(scale_y * output_size[0]) new_bbox = [] #从所有的lines中获取随机的4张图片进行Mosic idx = getList_img_box(rnd_all_lines, 4) for i, ix in enumerate(idx): #对选择的4张照片进行处理,得到boxes信息 line = rnd_all_lines[ix].split() img_path = line[0] img_boxes = np.array([np.array(list(map(int, box1.split(',')))) for box1 in line[1:]]) #读图片 img = cv2.imread(img_path) #对图像进行加工操作,调用已有函数 #翻转 if rand() < 0.5: img, img_boxes = random_horizontal_flip(img, img_boxes) #色域变换 if rand() < 0.3: img, img_boxes = random_hue(img, img_boxes) #CutOut if rand() < 0.1: img, img_boxes = CutOut(img, img_boxes) #随机裁剪 if rand() < 0.2: img, img_boxes = random_crop(img, img_boxes) #左上角图片的处理 if i == 0: #用letter_box进行替换,得到指定大小的图片和boxes img2, img_boxes = letterbox_image(Image.fromarray(np.uint8(img. copy())), (point_x, point_y), np.array(img_boxes.copy())) #更新到要保存的图像中 output_img[:point_x, :point_y, :] = img2 #处理bbox for bbox in img_boxes: xmin = bbox[0] #第1张图的位置不变 ymin = bbox[1] xmax = bbox[2] ymax = bbox[3] new_bbox.append([xmin, ymin, xmax, ymax, bbox[-1]]) elif i == 1: #第2张图的x轴的位置发生了变化 img2, img_boxes = letterbox_image( Image.fromarray(np.uint8(img.copy())), (output_size[1] - point_x, point_y), np.array(img_boxes.copy()) ) #更新到要保存的图像中 output_img[:point_y, point_x:output_size[1], :] = img2 for bbox in img_boxes: xmin = point_x + bbox[0] #第2张图的x发生了变化 ymin = bbox[1] xmax = point_x + bbox[2] ymax = bbox[3] new_bbox.append([xmin, ymin, xmax, ymax, bbox[-1]]) elif i == 2: #第3张图的x轴的位置发生了变化 img2, img_boxes = letterbox_image( Image.fromarray(np.uint8(img.copy())), (point_x, output_size[0] - point_y), np.array(img_boxes.copy()) ) output_img[point_y:output_size[0], :point_x, :] = img2 #x不变,y轴变了 for bbox in img_boxes: xmin = bbox[0] #第3张图的y发生了变化 ymin = point_y + bbox[1] xmax = bbox[2] ymax = point_y + bbox[3] new_bbox.append([xmin, ymin, xmax, ymax, bbox[-1]]) elif i == 3: img2, img_boxes = letterbox_image( Image.fromarray(np.uint8(img.copy())), (output_size[1] - point_x, output_size[0] - point_y), np.array(img_boxes.copy()) ) output_img[point_y:output_size[0], point_x:output_size[1], :] = img2 for bbox in img_boxes: xmin = point_x + bbox[0] #第4张图的x、y方向同时变化 ymin = point_y + bbox[1] xmax = point_x + bbox[2] ymax = point_y + bbox[3] new_bbox.append([xmin, ymin, xmax, ymax, bbox[-1]]) return output_img, np.array(new_bbox, dtype=np.int) 代码实现思路是先通过np.zeros()得到一张1024×1024全黑的图片,然后根据传入的lines信息随机获得第idx张图片,然后解析出img_boxes。根据rand()随机值调用翻转、色域变换、CutOut、随机裁剪的数据增强函数,以1024×1024的中心点为坐标,在第1个位置将图像更新上去,即通过代码output_img[:point_x,:point_y,:]=img2实现,同时计算bbox的位置变化,调用该代码实现的效果如图5111所示。 图5111Mosic数据增强 更多数据增强的代码,可参考随书代码。 总结 数据增强不仅能提高算法的稳健性,同时也能有效地缓解过拟合,是模型训练调优的重要组成方法。 练习 动手实现Mosic和标签随机复制的数据增强代码。