基于CenterFace的模型优化记录
【GiantPandaCV导语】CenterFace移动端模型优化实验记录
一、序¶
CenterFace是基于CenterNet的一种AnchorFree人脸检测模型。在widerface上性能虽然没有超过SOTA(Retinaface),但是胜于推理速度较快(不需要NMS),模型结构简单,便于移植部署。
二、背景¶
模型主要是使用在移动端App中,需要满足:
- 上传图片后返回带有人脸检测框的结果图片。
- 打开摄像头实时进行检测并拍照,返回检测后的图片。
- 需要在室内、室外、白天,晚上场景下均可使用,受众群体密集度比较高,需要支持戴口罩检测,Recall和precision均要求比较高(大于90%)。
- 需要支持大小人脸检测。 样例如下,难点在于支持小人脸检测以及小模型优化。
三、CenterFace¶
本节先简单介绍一下CenterFace模型
-
模型结构¶
CenterFace模型构造比较简单,基础backbone+FPN+head即完成网络构建。
1. **backbone**模型上采用了mobilenetv2作为backbone,mobilenetv3因为多分支以及SE模块,手机端移植不是很友好,实机测试,iphonex上mbv3相比mbv2要慢1ms左右,如果是低端机,这个差距会更大。
2. **FPN**采用的是传统的top-down结构,没有使用PAN。
3. **Head**采用4个conv+bn结构分别输出**locationmap**,**scalemap**,**offsetmap**和**pointsmap**。
整体结构图如下:
-
损失函数¶
CenterFace的损失函数和CenterNet一样,由FocalLoss形式Location分类损失和L1回归损失构成
1.Location分类损失:
2.L1回归损失: - offset
- points
- scale
3.最终损失:
对于location损失,只有每个bbox的中心点为正样本,其余点均为负样本,公式(1)中\alpha=2,\beta=4。对于offset损失,由于featuremap进行下采样的时候,计算中心点会由于取整产生偏移,需要用l1损失计算这个偏差。对于scale损失,这个是对bbox的w和h进行回归,取log便于计算。对于points损失,计算的是人脸5个关键点到中心点之间的距离的损失,做了normalize处理。最后的损失,就是各个损失的加权之和\lambda_{offset}=1, \lambda_{scale}=0.1, \lambda_{points}=0.1。
-
标签生成¶
-
locationmap, centerface中最重要的target就是bbox中心点gaussianmap生成,代码,效果图如下:
def _gaussian_radiusv1(self, height, width, min_overlap=0.7): """from cornernet""" a1 = 1 b1 = (height + width) c1 = width * height * (1 - min_overlap) / (1 + min_overlap) sq1 = torch.sqrt(b1 ** 2 - 4 * a1 * c1) r1 = (b1 + sq1) / 2 a2 = 4 b2 = 2 * (height + width) c2 = (1 - min_overlap) * width * height sq2 = torch.sqrt(b2 ** 2 - 4 * a2 * c2) r2 = (b2 + sq2) / 2 a3 = 4 * min_overlap b3 = -2 * min_overlap * (height + width) c3 = (min_overlap - 1) * width * height sq3 = torch.sqrt(b3 ** 2 - 4 * a3 * c3) r3 = (b3 + sq3) / 2 return min(r1, r2, r3) def _gaussian2D(self, shape, sigma=1): m, n = [(ss - 1.) / 2. for ss in shape] # y, x = np.ogrid[-m:m+1,-n:n+1] y = torch.arange(-m, m+1, dtype=torch.int).view(-1, 1) x = torch.arange(-n, n+1, dtype=torch.int).view(1, -1) h = torch.exp(-(x * x + y * y) / (2 * sigma * sigma)) h[h < 1e-5 * h.max()] = 0 return h def _draw_umich_gaussian(self, heatmap, center, radius, k=1): diameter = 2 * radius + 1 # get the gaussian heatmap gaussian = self._gaussian2D((diameter, diameter), sigma=diameter / 6) x, y = int(center[0]), int(center[1]) height, width = heatmap.shape[0:2] left, right = min(x, radius), min(width - x, radius + 1) top, bottom = min(y, radius), min(height - y, radius + 1) masked_heatmap = heatmap[y - top:y + bottom, x - left:x + right] masked_gaussian = gaussian[radius - top:radius + bottom, radius - left:radius + right] if min(masked_gaussian.shape) > 0 and min(masked_heatmap.shape) > 0: # TODO debug torch.max(masked_heatmap, masked_gaussian * k, out=masked_heatmap) return heatmap
图3-heatmap -
offsetmap, 每个bbox中心点上x方向和y方向的偏移结果,输出是一个(BX2XHXW)的map。
-
scalemap, 每个bbox中心点上w和h的log结果,输出是一个(BX2XHXW)的map。
-
pointsmap, 每个bbox的中心点到5个关键点x和y方向的距离,输出是一个(BX10XHXW)的map。
-
模型推理¶
CenterFace没有使用NMS作为后处理,而是采用的maxpooling作为代替,代码如下:
loc = loc.unsqueeze(0) # heatmap
hm_max = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)(loc)
loc[loc != hm_max] = 0
四、优化路线¶
-
数据: 由于场景任务中存在部分带有口罩的情况,所以采集了2000张百度的带有口罩的数据,混合widerface的train和val的数据来进行训练,测试数据使用业务提供的数据,保持一致。
-
优化: 流程图如下:
-
baseline模型为mobilenetv2+fpn模型,测试数据上ap为95%,初始图片训练大小为800x800,测试大小为416x416,FLOPs为1G。
-
通常来讲,train的size大于测试的时候,效果表现不好。所以调整图片的trainsize,分别为800,640,512,416,最终在416x416测试的情况下,训练size为416的时候效果最好。模型固定为416训练,416测试。
-
centerface的FPN的最后一层直接进行输出,这里把多层layer进行了concat,ap提升了一个点,但是FLOPs增加,收益不大。
-
由于centerface没有anchorbbox,bbox回归和中心点损失没有实质的关联,所以bbox的表现不是很好,添加了一个scale_dis分支,输出(BX4XHXW)的一个featuremap,分别表示的是中心点到上下左右的距离,计算IOU损失,效果提升不明显,还带来了4个通道的featuremap的冗余计算,直接弃用了(有兴趣的同学可以参考FCOS的说明)。
-
由于FPN最后的输出,经过一个卷积后,要输出多个head,会带来多个卷积计算,所以考虑优化为一个卷积输出,输出的层为(BX(1+2+2+10)xHXW),计算的时候分别对应channel计算损失,减少了10M的FLOPs,推理速度有所提升,同时提升了将近1个点的ap。
图5-修改head结构 -
由于业务只要求输出框,对于关键点没有需求,所以构建结构图的时候,把关键点的channel砍掉,可以减少20M的FLOPs,精度保持不变。
图6-推理减少channel -
使用mobilenetv2x0.25+fpn作为backbone,使用上面的操作,FLOPs降低为0.2G,ap基本保持不变。
-
修改训练size为400,推理size也为400,修改FPN的channel从24降低到16,FLOPs降低到了0.131G,精度保持95.2%相比baseline有轻微的提升。
-
最后对模型进行剪枝,采用SlimmingBN方法,对mobilenetv2中的
InvertResdiual
模块中的升维层进行剪枝。流程如下:- 训练的时候,对bn的gamma进行l1正则,s设置为1e-4代码如下:
def updataBN(self): """add a l1 panety for bn channel""" for m in self.model.modules(): if isinstance(m, nn.BatchNorm2d): m.weight.grad.data.add_(self.s * torch.sign(m.weight.data))
-
剪枝的时候,根据bn总数设置阈值,对weights从小到大进行排序,按比例定位阈值,根据阈值对bn的weights置0,重新计算测试集ap,测试发现卡0.3的时候,精度保持不变,0.4的时候精度下降1个点的ap,需要进行finetune。
-
保存模型,根据剪枝出来的Config重构模型结构,复制保留下来的权重到对应修改的层上,再次进行推理,ap保持不变即可,需要finetune的话,load权重后进行finetune,这里finetune个人建议是pretrain,finetune的话,可能会由于剪枝导致过检和漏检的情况在训练结束后还存在。
for [m0, m1] in zip(model.fpn.backbone.modules(), newmodel.fpn.backbone.modules()): # only prune the invertedresidual conv and bn if isinstance(m0, InvertedResidual): if len(m0.conv) > 5: for i in range(len(m0.conv)): if i == 1: if isinstance(m0.conv[i], nn.BatchNorm2d): idx1 = np.squeeze(np.argwhere(np.asarray(mask.cpu().numpy()))) # first batchnormalize (hidden) m1.conv[i].weight.data = m0.conv[i].weight.data[idx1].clone() m1.conv[i].bias.data = m0.conv[i].bias.data[idx1].clone() m1.conv[i].running_mean = m0.conv[i].running_mean[idx1].clone() m1.conv[i].running_var = m0.conv[i].running_var[idx1].clone() # first conv (hidden, inp, 1, 1) if isinstance(m0.conv[i-1], nn.Conv2d): w = m0.conv[i-1].weight.data[idx1, :, :, :].clone() m1.conv[i-1].weight.data = w.clone() # second conv (hidden, 1, 1, 1) if isinstance(m0.conv[i+2], nn.Conv2d): w = m0.conv[i+2].weight.data[idx1, :, :, :].clone() m1.conv[i+2].weight.data = w.clone() # second bn (hidden) if isinstance(m0.conv[i+3], nn.BatchNorm2d): m1.conv[i+3].weight.data = m0.conv[i+3].weight.data[idx1].clone() m1.conv[i+3].bias.data = m0.conv[i+3].bias.data[idx1].clone() m1.conv[i+3].running_mean = m0.conv[i+3].running_mean[idx1].clone() m1.conv[i+3].running_var = m0.conv[i+3].running_var[idx1].clone() # third conv (oup, hidden, 1, 1) if isinstance(m0.conv[i+5], nn.Conv2d): w = m0.conv[i+5].weight.data[:, idx1, :, :].clone() m1.conv[i+5].weight.data = w.clone() # third bn (oup) if isinstance(m0.conv[i+6], nn.BatchNorm2d): m1.conv[i+6].weight.data = m0.conv[i+6].weight.data.clone() m1.conv[i+6].bias.data = m0.conv[i+6].bias.data.clone() m1.conv[i+6].running_mean = m0.conv[i+6].running_mean.clone() m1.conv[i+6].running_var = m0.conv[i+6].running_var.clone() layer_id_in_cfg += 1 if layer_id_in_cfg < len(cfg_mask): mask = cfg_mask[layer_id_in_cfg]
- 最后输出的模型为0.116G的FLOPs,相比baseline降低了10倍FLOPs,参数量400k左右,测试集上ap95.2%相比baseline高了0.2%个点。iphonex上测试10ms左右,低端机可以满足10FPS以上的输出,精度,速度均满足要求。
五、简单的思考¶
- 为什么gaussian map是一个3X3的map,而不是全局的map或者说是一个点?
- 可能是因为cornernet是计算角点的,因为存在偏差所以需要一个小点的map来做约束,centernet直接继承了这个idea,论文中也是直接引用没有过多的思考。
- 从损失构建上来看,如果增大gaussianmap,对应的增加负样本,会影响中心点的约束。相当于中心点是points anchor,其他的都是非anchor。
- 从gaussianmap生成来看,对应的3x3可以cover出现offset过大的情况,可以约束范围,毕竟没有bboxanchor。
- 如果把bbox全部填满?
- bbox填满,那么就是每个点都是正样本,框中不存在负样本,这样中心点的价值就不存在了,论文的延伸也就是FCOS。
- loss之间的联系?
- 由于anchorbase的方法是存在海量的positive和negative anchors,回归的好坏,一是会受到anchor的影响,二是会受到classification的影响,所以生成的bbox比较稳定。centernet的各个loss实际上是独立的,只是建立在中心点的基础上,所以导致框会不稳定,尤其是在实时中抖动比较大,OneNet就这个问题给出了解决方案。
预测结果¶
400x400 移动端上进行测试的结果如下
结束语¶
本人才疏学浅,以上都是自己在做项目中的一些方法和实验,以及一些粗浅的思考,并不一定完全正确,只是个人的理解,欢迎大家指正,留言评论。
参考文献¶
- CenterFace(https://arxiv.org/pdf/1911.03599.pdf)
- CenteNet(https://arxiv.org/abs/1904.07850)
- CornerNet(https://arxiv.org/abs/1808.01244)
- SlimmingBN(https://openaccess.thecvf.com/content_ICCV_2017/papers/Liu_Learning_Efficient_Convolutional_ICCV_2017_paper.pdf)
- FCOS(https://arxiv.org/abs/1904.01355)
- OneNet(https://arxiv.org/abs/2012.05780)
欢迎关注GiantPandaCV, 在这里你将看到独家的深度学习分享,坚持原创,每天分享我们学习到的新鲜知识。( • ̀ω•́ )✧
有对文章相关的问题,或者想要加入交流群,欢迎添加BBuf微信:
为了方便读者获取资料以及我们公众号的作者发布一些Github工程的更新,我们成立了一个QQ群,二维码如下,感兴趣可以加入。
本文总阅读量次