最近在学习YOLO V3算法,经过了一段时间的挣扎,目前大概理出了自己的一些思路。
与我前面学习的MTCNN算法相比,YOLO不算复杂,代码量也不如MTCNN,但就是在代码实现的时候有些地方比较绕,需要多思考,多理一下自己的思路。
YOLO的话,核心思想比较容易理解,不同于RCNN系列two-stage的方法,YOLO利用整张图作为网络的输入,将图片划分为N*N的格子,object的中心点落在哪个格子里,这个格子就负责回归边框及所属类别。在YOLO V3里采用了类似FPN的结构,对26*26和52*52的特征图使用了concatenate连接,至于为什么采用路由而非add,我想可能是路由会使张量维度增加,特征抽象能力越强则表达能力越强,并且通过将低阶的位置信息与高阶的语义信息相结合,能提高预测精度。
下面开始从网络---数据集---训练---侦测四个部分慢慢理思路。
网络部分,这是我在百度找的一张DarkNet53的图片,为什么叫DarkNet53呢,这是由于主网络包括了52层的CNN+BN+LeakyRelu层加上一层输出,所以叫DarkNet53。
整个网络采用416*416的输入,不像DarkNet19一样,网络没有采用Maxpooling取而代之的是步长为2的卷积下采样,这样做的目的防止池化粗暴的丢掉特征对整个模型的影响,卷积下采样的话网络有学习参数,信息融合比较好,整个网络共计5个下采样层;整个网络没有全连接层,减少了网络模型的参数;整个网络最主要的部分就是残差结构,加入残差的主要目的是:由于我们输入的是整张图片进行学习,包含了很多的信息,因此就需要更深的网络来提取更抽象的特征,但随着网络的加深,就难以避免整个模型的退化问题,这时引入残差就很好的解决了此问题,通过加入短连接的方式,将输入信息直接绕道与输出信息相加,这样既保护了信息的完整性,整个网络同时又在差异中进行了学习,简化了学习目标和难度,残差结构是神经网络中的一个重要结构,能够解决梯度消失这个万年大难题,但避免不了的一点就是带来了一些计算量,但是相对于解决的问题来讲,这都不是事儿;最后是网络的concatenate连接,在52*52及26*26的特征图上分别进行了一次torch.cat连接,这是参考了SSD的做法,和SSD每一个输出的特征图都进行侦测的做法不同,YOLO V3将底层位置信息和深层语义信息进行融合,不仅解决了YOLO V1和YOLO V2对小目标进行侦测的局限性,还使侦测效果更佳。(这一部分代码较简单且比较长,就不展示了)
下面是网络的数据集制作
首先你要拿到图片里物体关键位置信息,比如object在原图上的中心点坐标、w和h或左上角右下角坐标
首先你要思考,你想让网络学习什么,也就是你想让网络输出什么?毫无疑问,我们需要网络在13*13、26*26、52*52的特征图上输出每个格子里有没有object、如果有,物体的中心落在哪个格子里,我还需要输出偏移量(我需要用这个偏移量对物体的位置进行进一步的修正)、我还需要输出框的w和h、当然我还需要输出这个物体的类别。ok,你想让网络输出的,就我们给网络输入并学习的。
这里受设备影响,我自己制作了部分数据集,包含3个类别,训练了一个过拟合版本。
话不多说,先上代码:
label_file_path = "./data/person_label.txt"
img_base_dir = "data"
trans = torchvision.transforms.Compose([
torchvision.transforms.ToTensor(),
torchvision.transforms.Normalize(constant.DATA_MEAN, constant.DATA_STD)])
"
ANCHORS_GROUP={
13:[[360,360],[360,180],[180,360]],
26:[[180,180],[180,90],[90,180]],
52:[[90,90],[90,45],[45,90]]}
ANCHORS_GROUP_AREA={
13:[x*y for x,y in ANCHORS_GROUP[13]],
26:[x*y for x,y in ANCHORS_GROUP[26]],
52:[x*y for x,y in ANCHORS_GROUP[52]]}
"
def one_hot(cls_num, i):
b = np.zeros(cls_num)
b[i] = 1
return b
class My_Dataset(Dataset):
def __init__(self):
with open(label_file_path) as f:
self.dataset = f.readlines()
def __len__(self):
return len(self.dataset)
def __getitem__(self, index):
labels = {}
line = self.dataset[index]
strs = line.split()
image_path = os.path.join(img_base_dir, strs[0])
img = Image.open(image_path)
img = img.convert("RGB")
img_data = trans(img)
# _boxes=np.array(float(x) for x in strs[1:])
_boxes = np.array(list(map(float, strs[1:])))
boxes = np.split(_boxes, len(_boxes) // 5)
for feature_size, anchors in constant.ANCHORS_GROUP.items(): # 获取键和值
labels[feature_size] = np.zeros((feature_size, feature_size, 3, 5 + constant.CLASS_NUM))
for box in boxes:
cls, cx, cy, w, h = box
cx_offset, cx_index = math.modf(cx * feature_size / constant.IMG_WIDTH)
cy_offset, cy_index = math.modf(cy * feature_size / constant.IMG_HEIGHT)
for i, anchor in enumerate(anchors):
anchor_area = constant.ANCHORS_GROUP_AREA[feature_size][i]
p_w, p_h = w / anchor[0], h / anchor[1]
p_area = w * h
iou = min(p_area, anchor_area) / max(p_area, anchor_area)
labels[feature_size][int(cy_index), int(cx_index), i] = np.array(
[iou, cx_offset, cy_offset, np.log(p_w), np.log(p_h), *one_hot(constant.CLASS_NUM, int(cls))])
return labels[13], labels[26], labels[52], img_data
利用三个循环嵌套,第一个循环,得到每一个feature_map的大小,以13*13为例,同时得到对应的3个anchor box的大小(包括w、h),然后创建一个torch.size([13,13,3,8])形状的零矩阵,接下来就可以往里面填值了,第二个循环,得到object在原图上框的坐标(中心点坐标及w和h)及所属类别,通过等比缩放得到在13*13特征图上具体位置(包括在哪个格子以及偏移量),第三个循环,将w和h进行压缩(这里用log的方式进行压缩,没有采用开根号,若不对数据进行压缩,损失函数会更加倾向于调大的预测框,减小大尺寸与小尺寸之间的差异,可以理解为归一化操作)求IOU值作为置信度,并找到每一个anchor然后对整个零矩阵进行填入值。整个数据集关键的部分就是最后一步,不是很好理解。
下面是网络的训练,网络训练最主要就是损失部分。先来看一下下面这张图:
是也是在别人文章里选取的,也写的比较详细了,只是我这里对于w和h的压缩采用的是log。
最后将这几个损失函数加起来就是总损失,然后对总损失进行梯度下降。
原图链接:https://blog.csdn.net/taifengzikai/article/details/8650075
def loss_fn(output,target,alpha):
conf_loss_fn=torch.nn.BCEWithLogitsLoss()#置信度
crood_loss_fn=torch.nn.MSELoss()#坐标
output=output.permute(0,2,3,1)
output=output.reshape(output.size(0), output.size(1), output.size(2), 3, -1)
output=output.cpu()
mask_obj=target[...,0]>0
output_obj=output[mask_obj]
target_obj=target[mask_obj]
loss_obj_conf=conf_loss_fn(output_obj[:,0],target[:,0])
loss_obj_crood=crood_loss_fn(output_obj[:,1:5].float(),target_obj[:,1:5].float())
loss_obj_cls=conf_loss_fn(output_obj[:,5:],target_obj[:,5:])
loss_obj=loss_obj_cls+loss_obj_crood+loss_obj_conf
mask_noobj=target[...,0]==0
output_noobj=output[mask_noobj]
target_noobj=target[mask_noobj]
loss_noobj=conf_loss_fn(output_noobj[:,0],target_noobj[:,0])
loss=alpha*loss_obj+(1-alpha)*loss_noobj
return loss
if __name__ == '__main__':
if not os.path.exists("models"):
os.makedirs("models")
save_path="models/net_yolo.pth"
mydataset=dataset.My_Dataset()
train_loader=DataLoader(mydataset,batch_size=5,shuffle=True)
device=torch.device("cuda" if torch.cuda.is_available() else "cpu")
net=MainNet().to(device)
if os.path.exists(save_path):
net.load_state_dict(torch.load(save_path))
else:
print("No param")
net.train()
opt=torch.optim.Adam(net.parameters())
epoch=0
while epoch<2000:
for target_13,target_26,target_52,image_data_ in train_loader:
image_data=image_data_.to(device)
output_13,output_26,output_52=net(image_data)
loss_13=loss_fn(output_13,target_13,0.7)
loss_26=loss_fn(output_26,target_26,0.7)
loss_52=loss_fn(output_52,target_52,0.7)
loss=loss_13+loss_26+loss_52
opt.zero_grad()
loss.backward()
opt.step()
if epoch%100==0:
torch.save(net.state_dict(),save_path)
print("epoch{}".format(epoch), loss.item())
epoch+=1
这里有两点值得注意:1、只对有中心点的格子做置信度,坐标点,分类损失,格子内没有中心点的只做置信度的损失
2、置信度损失采用torch.nn.BCEWithLogitsLoss(),BCEWith和BCE损失区别是,BCE必须输入Sigmoid激活后的值,而前者将Sigmoid和BCE结合成一个类,比BCE更加稳定。
最后一部分是侦测部分,也是最难的一部分。
经过前面数据集制作部分,已经了解网络学习的是什么,那侦测的话,也就清楚了,实际就是一个反算的过程,只是需要绕一下。还是先上代码:
class Detestor(nn.Module):
def __init__(self):
super(Detestor, self).__init__()
self.save_path="models/net_yolo.pth"
self.device=torch.device("cuda" if torch.cuda.is_available() else "cpu")
self.net=MainNet().to(self.device)
self.net.load_state_dict(torch.load(self.save_path))
self.net.eval()
def forward(self,input,thresh,anchors):
output_13,output_26,output_52=self.net(input)
idxs_13,vecs_13=self._filter(output_13,thresh)
boxes_13=self._parse(idxs_13,vecs_13,32,anchors[13])
idxs_26,vecs_26=self._filter(output_26,thresh)
boxes_26=self._parse(idxs_26,vecs_26,16,anchors[26])
idxs_52,vecs_52=self._filter(output_52,thresh)
boxes_52=self._parse(idxs_52,vecs_52,8,anchors[52])
return torch.cat([boxes_13,boxes_26,boxes_52],dim=0)
def _filter(self,output,thresh):
output=output.permute(0,2,3,1)
output=output.reshape(output.size(0),output.size(1),output.size(2),3,-1)
mask=output[...,0]>thresh
idex=np.nonzero(mask)
vecs=output[mask]
return idex,vecs
def _parse(self,idex,vecs,t,anchors):
anchors=torch.tensor(anchors)
a=idex[:,3]
confidence=vecs[:,0]
_classify=vecs[:,5:]
if len(_classify)==0:
classify=torch.tensor([])
else:
classify=torch.argmax(_classify,dim=1).float()
cy=(idex[:,1].float()+vecs[:,2])*t
cx=(idex[:,2].float()+vecs[:,1])*t
w=anchors[a,0]*torch.exp(vecs[:,3])
h=anchors[a,1]*torch.exp(vecs[:,4])
x1=cx-w/2
y1=cy-h/2
x2=x1+w
y2=y1+h
out=torch.stack([confidence,x1,y1,x2,y2,classify],dim=1)
return out
if __name__ == '__main__':
transforms=trans.Compose([
trans.ToTensor(),
trans.Normalize(constant.DATA_MEAN,constant.DATA_STD)
])
file_images=os.listdir(r"./data/images")
for i in range(len(file_images)):
detector = Detestor()
img1 = Image.open(r"./data/images/{}.jpg".format(i+1))
img=img1.convert("RGB")
img=transforms(img)
img=img.unsqueeze(dim=0)
out_value=detector(img,0.5,constant.ANCHORS_GROUP)
boxes=[]
for j in range(constant.CLASS_NUM):
classify_mask=(out_value[...,-1]==j)
_boxes=out_value[classify_mask]
boxes.append(tool.nms(_boxes))
for box in boxes:
try:
for k, box_ in enumerate(box):
img_draw = ImageDraw.Draw(img1)
c, x1, y1, x2, y2, cls = box[k, 0:6]
img_draw.rectangle((x1, y1, x2, y2), outline="red", width=2)
cls_num = {"cat": "0", "dog": "1", "person": "2"}
cls_name = {v: k for k, v in cls_num.items()}[f"{int(cls)}"] # 根据值获取键名
font = ImageFont.truetype("simhei.ttf", 20, encoding="utf-8")
img_draw.text((x1, y1), f"{cls_name}{'%.2f' % (c)}", (255, 0, 0), font=font)
except:
continue
img1.show()
还是以13*13的特征图为例,理一下流程:
输出形状为[N,24,H,W]---reshape为[N,H,W,3,8](其中的3代表输出的3个建议框,8表示1个置信度+4个偏移量+3个cls概率)---对置信度进行筛选---输出满足条件对象的位置以及对应输出值---找到满足条件的anchor框---进行坐标反算---torch.stack输出框
上面这一部分代码量很少,但是理解确实是有些难度,你得一步一步打出输出形状来帮助理解。
最后在Draw部分有一点值得关注:
for j in range(constant.CLASS_NUM):
classify_mask=(out_value[...,-1]==j)
_boxes=out_value[classify_mask]
boxes.append(tool.nms(_boxes))
意思是找到同类别的对象,然后做NMS,比如说通过循环拿到一张图片里面的所有人的boxes,然后进行NMS。
最后放一张效果图:
以上部分就是我对YOLO V3的学习笔记,若有不当之处还请不吝赐教,共同学习,共同进步。