本文基于人工智能领域大佬Bubbliiiing睿智的目标检测42——Pytorch搭建Retinaface人脸检测与关键点定位平台
原文链接:https://blog.csdn.net/weixin_44791964/article/details/106872072
这是是我的学习笔记,记录我复现与拓展的学习过程,万分感谢大佬的开源和无私奉献。本文部分内容来自网上搜集与个人实践。如果任何信息存在错误,欢迎读者批评指正。本文仅用于学习交流,不用作任何商业用途。
Retinaface实现人脸检测与关键点定位-深度学习学习笔记-1
Facenet实现人脸特征比对-深度学习学习笔记-2
RetinaFace人脸检测模型-Gradio界面设计
FaceNet人脸识别模型-Gradio界面设计
Retinaface+FaceNet人脸识别系统-Gradio界面设计
Retinaface在实际训练的时候使用两种网络作为主干特征提取网络。分别是MobilenetV1-0.25和Resnet。
Keras API documentation
MobilenetV1-0.25:这是一种特殊的计算方法,它被设计成非常轻巧快速。使用MobilenetV1-0.25作为RetinaFace的特征提取方法,可以在资源有限的设备上实现实时的人脸检测,比如手机和一些不太强大的电脑。但是,由于其轻量级设计,可能在一些情况下会稍微牺牲一些精度。
Resnet:这是另一种特征提取方法,它相对复杂但更精确。通过使用Resnet,RetinaFace可以在更复杂的场景下找到更准确的人脸位置和特征点。但因为它的复杂性,它需要更多计算资源,所以在一些设备上可能不太适合实时应用。
因此,RetinaFace有两种特征提取方法可供选择。如果需要快速的实时人脸检测,可以选择使用MobilenetV1-0.25,在手机等设备上表现较好。而如果对于更高的精度要求,可以切换到Resnet,但这可能需要更强大的计算设备。
总的来说,RetinaFace是一种灵活的人脸识别技术,能够根据不同场景和设备的需求,选择合适的特征提取方法,从而在不同情况下实现准确的人脸检测和特征点定位。
我们主要使用MobilenetV1-0.25作为主干网络,设计的一个轻量级的深层神经网络模型。它的核心思想是使用depthwise separable convolution(深度可分离卷积)来减少模型参数量和计算量。
我们先了解一些基本概念:
卷积:
卷积是一种数学运算,用于将一个函数与另一个函数进行操作,以产生一个新的函数。在图像处理中,我们可以把卷积看作是一种滤波操作。它通过在图像上滑动一个小的窗口(称为卷积核),对窗口内的像素进行加权求和,从而得到新的像素值。
新像素 = 卷积核中的权重 * 窗口内像素的加权平均值
我们可以把卷积想象成用一个小小的滤网去过滤咖啡。
比如我们有一张5x5的图片,每一个小格子是一个像素点,用数字1到5表示它的颜色:
1 1 2 3 4
2 2 3 4 5
3 3 3 4 5
4 4 4 5 5
5 5 5 5 5
现在我们定义一个3x3的滤波器,就是一个3行3列的小矩阵:
0 1 0
1 0 1
0 1 0
我们把这个滤波器放在图片上,让它从左到右、从上到下滑动,每次停在一个位置。
当它停在最上面最左边时,会覆盖图片中的:
1 1 2
2 2 3
3 3 3
对应元素相乘就是:
(1 x 0) (1 x 1) (2 x 0)
(2 x 1) (2 x 0) (3 x 1)
(3 x 0) (3 x 1) (3 x 0)
然后我们把滤波器中的数字与覆盖的图片中对应的数字分别相乘,再把 9 个乘积加起来,就可以得到一个新的数字,比如这里是18。
18就会成为输出图片中对应位置的新的像素值。
我们让滤波器继续在图片上滑动,每次输出一个乘积求和的结果,最终就可以得到一个新的图片,它保留了原图片在这个滤波器下的特征。
如果我们改变滤波器的数字的安排组合,就可以得到不同的特征。
这个过程,就像我们用不同的滤网去过滤咖啡,不同的滤网会提取咖啡中的不同成分。
所以卷积核其实就是一个提取图像特征的滤波器,经过卷积操作,可以得到代表不同特征的图像。这一技术在图像处理和机器学习中很重要,比如可以用来进行图像识别等任务。
卷积层:
卷积核:
卷积层和卷积核之间的关系:卷积层包含了多个卷积核。每个卷积核可以提取不同的特征。卷积层通过并行地使用多个卷积核,可以同时提取多个特征。每个卷积核在卷积层中滑动并与输入数据进行卷积操作,生成对应的特征图。这些特征图可以被传递给神经网络的下一层进行进一步的处理和分析。
总结起来
特征图(featuremap)
通道:
步长:
深度可分离卷积(depthwise separable convolution)
假设有一个3×3大小的卷积层,其输入通道为16、输出通道为32。具体为,32个3×3大小的卷积核会遍历16个通道中的每个数据,最后可得到所需的32个输出通道,所需参数为16×32×3×3=4608个。
应用深度可分离卷积,用16个3×3大小的卷积核分别遍历16通道的数据,得到了16个特征图谱。在融合操作之前,接着用32个1×1大小的卷积核遍历这16个特征图谱,所需参数为16×3×3+16×32×1×1=656个。
可以看出来depthwise separable convolution可以减少模型的参数。
通俗来说
普通的卷积操作中,我们的输入图片有 16 个通道(可以看成 16 层玻璃板),我们希望卷积层输出 32 个通道(特征图)。
而深度可分离卷积则是分两步刷:
这样参数数量只需要16×3×3+16×32×1×1=656个。大约减少到原来的1/7。
所以深度可分离卷积通过分解步骤,显著减少了参数量。这使得模型更加轻量化,也降低了计算量。
希望这个通俗的刷玻璃板比喻可以让你更直观地理解卷积通道和深度可分离卷积的工作原理。
如下这张图就是depthwise separable convolution的结构
在建立模型的时候,可以将卷积group设置成in_filters层实现深度可分离卷积,然后再利用1x1卷积调整channels数。
通俗地理解就是3x3的卷积核厚度只有一层,然后在输入张量上一层一层地滑动,每一次卷积完生成一个输出通道,当卷积完成后,在利用1x1的卷积调整厚度。
如下就是MobileNet的结构,其中Conv dw就是分层卷积,在其之后都会接一个1x1的卷积进行通道处理,
上图所示是的mobilenetV1-1的结构,本文所用的mobilenetV1-0.25是mobilenetV1-1通道数压缩为原来1/4的网络。
# nets/mobilenet025.py
# 卷积+BN+LeakyReLU激活函数模块
def conv_bn(inp, oup, stride=1, leaky=0.1):
# inp:输入通道数,oup:输出通道数,stride:步长,leaky:negative_slope,默认0.1
return nn.Sequential(
nn.Conv2d(inp, oup, 3, stride, 1, bias=False),
# 卷积层
# inp:输入通道数,oup:输出通道数,kernel_size=3,stride:步长,padding=1,bias=False
'''
参数:
Conv2d层实际上就是二维卷积层,它对输入信号(由多个输入平面组成)应用二维卷积。
该层的主要参数有:
- in_channels:输入图像中的通道数
- out_channels:卷积产生的通道数
- kernel_size:卷积核的大小,可以是单个数值或者是一个tuple(h,w)表示height和width
- stride:卷积步长,控制卷积窗口移动的步幅,默认是1。可以是一个数值或者是一个tuple(sh,sw)表示height和width的步幅
- padding:添加在输入两侧的零填充,默认是0。可以是一个数值或者是一个tuple(ph,pw)表示height和width方向的填充量。
- dilation:卷积核元素之间的间距,默认是1。可以是一个数值或者是一个tuple(dh,dw)表示height和width方向的间距。
- groups:从输入通道到输出通道的连接块数。默认为1。
- bias:是否添加可学习的偏置项,默认为True。
该层的输出形状可以根据以下公式计算:
out_height = (in_height + 2*padding[0] - dilation[0]*(kernel_size[0] - 1) - 1)/stride[0] + 1
out_width = (in_width + 2*padding[1] - dilation[1]*(kernel_size[1] - 1) - 1)/stride[1] + 1
该层在图像分类、目标检测、语义分割等任务中很常用。可以提取图像中的低层特征,并且可以通过stack多层Conv2d层构建更深的卷积神经网络。
文档复制自:Conv2d
'''
# 卷积层是图像分类和物体检测网络中最核心的模块之一。它实现了卷积操作,可以提取图像的空间特征,构建特征图。
# 卷积层主要由三部分组成:
# 1. 卷积核:也叫滤波器,是一个小的权重矩阵,用于与输入特征图的部分区域相乘,来检测输入特征图中的局部模式。
# 2. 步长:控制卷积核在输入特征图上滑动的步长,步长越大,输出特征图越小。
# 3. 填充:在输入特征图外围填充0值,可以控制输出特征图的大小。填充为1时,输出特征图大小不变。
# 卷积层的作用:
# 1. 特征提取:通过卷积核可以检测输入特征图中的局部模式,实现特征提取。
# 2. 参数共享:卷积层的参数(卷积核)在空间上重复使用,这种参数共享方式可以大幅减少参数量。
# 3. 空间cup:通过步长可以减小特征图的大小,实现下采样。
nn.BatchNorm2d(oup),
# BN层
# 作用:加速训练,提高精度,稳定性。oup:BN层通道数
# BN层实现批量归一化,它对每个mini-batch的每个通道进行归一化,使得输入的分布更加均匀。
# BN层的作用:
# 1. 提高训练效率:BN层可以对模型的中间激活值施加约束,限制其分布在一定范围内,这可以加速模型的训练过程。
# 2. 降低过拟合:BN层使得输入的分布更加均匀,这可以在一定程度上减轻过拟合问题。
# 3. 使训练更加稳定:不使用BN层,在训练过程中,如果激活值变化较大,那么模型的参数也要作出较大调整,这会使得训练过程不稳定。BN层可以限制激活值的变化范围,参数的调整幅度也会相应小一些,模型变得更加稳定。
# 4. 改善梯度消失问题:激活值变化过小会导致梯度消失或爆炸,BN层可以在一定程度上防止这个问题。
# 总之,卷积层实现特征提取与降维,BN层可以加速训练、防止过拟合与梯度消失,二者在深度神经网络中起到非常重要的作用。
nn.LeakyReLU(negative_slope=leaky, inplace=True)
# LeakyReLU激活
# LeakyReLU是一种修正的ReLU激活函数。它的表达式为:
# f(x) = max(0.01x, x) (当x < 0时)
# f(x) = x (当x >= 0时)
# 也就是说,当x < 0时,LeakyReLU会让一小部分的负值通过,而标准的ReLU函数会完全阻断负值。
# LeakyReLU的作用主要有:
# 1. 防止死亡节点问题:标准的ReLU会完全阻断负值,这可能导致某些节点的梯度在训练过程中永远为0,这种节点称为“死亡节点”。LeakyReLU可以让一小部分负值通过,所以可以在一定程度上缓解这个问题。
# 2. 加速收敛:让一小部分负值通过,可以增加模型表达能力,有利于加速模型的收敛。
# 3. 防止梯度消失:ReLU会使负值对应的梯度消失,而LeakyReLU只是减小了负值梯度,所以可以在一定程度上改善梯度消失问题。
# 4. 使得模型对噪声更鲁棒:完全阻断某一部分值的范围可能会使模型对这一部分的输入更加敏感,而LeakyReLU可以缓解这个问题,使模型对噪声输入更加稳定。
# 所以,总的来说,LeakyReLU是一种修正后的激活函数,它可以防止死亡节点问题,加速收敛,缓解梯度消失问题,使模型对噪声更加鲁棒。这也是为什么该激活函数在深度学习中得到广泛应用的原因。
# 除了LeakyReLU,其他的修正过的激活函数还有:
# - ELU:f(x) = max(0,x) + min(0, alpha*(exp(x)-1))
# - SELE:f(x) = x*(1+(e^(x)-1)*alpha)
# - GELU:f(x) = x*Phi(x) (Phi(x)是高斯误差线性单元函数)
# 它们的作用与LeakyReLU类似,都是为了改进ReLU的一些缺点,得到更优的激活函数。
# 激活函数:y=x if x>0 else y=leaky*x
)
# 深度可分离卷积模块
def conv_dw(inp, oup, stride=1, leaky=0.1):
# inp:输入通道数,oup:输出通道数,stride:步长,leaky:negative_slope
return nn.Sequential(
# 深度可分离卷积
nn.Conv2d(inp, inp, 3, stride, 1, groups=inp, bias=False),
# groups=inp:每个输入通道自己卷积
# 3x3的深度可分离卷积,每个输入通道独立卷积,不进行跨channel的互相关联
# BN
nn.BatchNorm2d(inp),
# LeakyReLU
nn.LeakyReLU(negative_slope=leaky, inplace=True),
# 点卷积
nn.Conv2d(inp, oup, 1, 1, 0, bias=False),
# 1x1的卷积,起到通道数的转换作用,不改变特征图大小
# BN
nn.BatchNorm2d(oup),
# LeakyReLU
nn.LeakyReLU(negative_slope=leaky, inplace=True),
)
# MobileNetV1模型
class MobileNetV1(nn.Module):
def __init__(self):
super(MobileNetV1, self).__init__()
# stage1:输入640x640x3,输出320x320x8
# 用于处理输入图片,获得较浅的特征
self.stage1 = nn.Sequential(
# 卷积最大池化,缩小2倍
conv_bn(3, 8, 2, leaky=0.1), # 3->8, 640->320
# 输入3通道,输出8通道,步长为2,输入640x640,输出320x320
# conv_dw 16
conv_dw(8, 16, 1), # 8->16
# 8通道输入,16通道输出,步长1,输出大小不变
# conv_dw 32 + conv_dw 32
conv_dw(16, 32, 2), # 16->32, 320->160
# 16通道输入,32通道输出,步长2,输入320x320,输出160x160
conv_dw(32, 32, 1), # 32通道输入输出,步长1
# 32通道输入输出,步长1,输出大小不变
# conv_dw 64 + conv_dw 64
conv_dw(32, 64, 2), # 32->64, 160->80
# 32通道输入,64通道输出,步长2,输入160x160,输出80x80
conv_dw(64, 64, 1), # 64通道输入输出,步长1
# 64通道输入输出,步长1,输出大小不变
)
# stage2:输入80x80x64,输出40x40x128
# 中间层特征提取模块
self.stage2 = nn.Sequential(
conv_dw(64, 128, 2), # 64->128, 80->40
# 64通道输入,128通道输出,步长2,输入80x80,输出40x40
conv_dw(128, 128, 1), # 128通道输入输出,步长1
# 128通道输入输出,步长1,输出大小不变
conv_dw(128, 128, 1),
# 128通道输入输出,步长1,输出大小不变
conv_dw(128, 128, 1),
# 128通道输入输出,步长1,输出大小不变
conv_dw(128, 128, 1),
# 128通道输入输出,步长1,输出大小不变
conv_dw(128, 128, 1), # 128通道输入输出,步长1
# 128通道输入输出,步长1,输出大小不变
)
# stage3:输入40x40x128,输出20x20x256
# 用于获取较深层的特征,输出较高维的特征
self.stage3 = nn.Sequential(
conv_dw(128, 256, 2), # 128->256, 40->20
# 128通道输入,256通道输出,步长2,输入40x40,输出20x20
conv_dw(256, 256, 1), # 256通道输入输出,步长1
# 256通道输入输出,步长1,输出大小不变
)
# 自适应平均池化层和全连接层
self.avg = nn.AdaptiveAvgPool2d((1, 1))
# 自适应平均池化,输出1x1
# 这段代码定义了MobileNetV1的最后两层:自适应平均池化层和全连接层。下面对它们进行详细解释:
# self.avg = nn.AdaptiveAvgPool2d((1, 1))
# 这行定义了一个自适应平均池化层,池化核的大小是1x1,输出大小也是1x1。
# 自适应平均池化与平均池化的区别是,它会自适应地根据输入大小选择不同的池化核,使得输出大小是固定的,而不是像平均池化那样,池化核是固定的,输出大小会变化。
# 这层池化层的作用是:
# 1. 降维:从256通道的20x20的特征图降维到256维的向量。
# 2. 起到加权平均的作用:每个值代表了特定通道上20x20个值的平均数,这可以得到该通道的整体响应情况。
self.fc = nn.Linear(256, 1000)
# 全连接层,输入256维,输出1000类
# self.fc = nn.Linear(256, 1000)
# 这行定义了一个全连接层,输入是256维向量,输出是1000维,用于分类。
# 该全连接层的作用是:
# 1. 起到分类作用:把256维的特征向量转化为1000维的输出概率,进行图像分类。
# 2.可以看作是该网络的预测层。
# 所以,整体来说,自适应平均池化层起到降维和加权平均的作用,而全连接层起到分类和预测的作用,它们共同构成了MobileNetV1的最后两层,实现了对特征的抽象和分类预测。
def forward(self, x):
x = self.stage1(x)
# 经过stage1,输出320x320x8
x = self.stage2(x)
# 经过stage2,输出40x40x128
x = self.stage3(x)
# 经过stage3,输出20x20x256
x = self.avg(x)
# 自适应平均池化,输出1x1
x = x.view(-1, 256)
# 改变tensor的形状,-1表示由其他维度推断出来
x = self.fc(x)
return x
该代码实现了MobileNetV1模型的网络结构。下面对代码中的关键部分进行详细解释:
conv_bn函数:这个函数定义了一个卷积层,后面跟着批归一化(Batch Normalization)和LeakyReLU激活函数。卷积层通过3x3卷积核对输入进行卷积操作,并使用步长和填充来控制输出特征图的大小。批归一化层用于加速训练过程并提高模型的稳定性,而LeakyReLU激活函数可以防止梯度消失问题。
conv_dw函数:这个函数定义了一个深度可分离卷积模块,包括深度卷积、批归一化、LeakyReLU激活函数以及点卷积等操作。深度可分离卷积将卷积操作分成两步:先对每个输入通道进行独立的卷积,然后再使用1x1的卷积核进行通道之间的线性组合。这样可以大幅减少参数数量,同时保持较好的特征提取能力。
MobileNetV1类:这个类定义了MobileNetV1模型的网络结构。它包含三个阶段(stage 1、stage 2、stage 3),每个阶段由多个深度可分离卷积模块组成。通过将输入数据依次经过这些阶段,逐渐提取出更高层次的特征表示。
forward方法:这个方法实现了模型的前向传播过程。在该方法中,输入数据首先经过stage1,然后通过stage2和stage3进行进一步处理。最后,通过自适应平均池化层将特征图降维为一个256维的向量,并通过全连接层输出对不同类别的预测结果。
class FPN(nn.Module):
def __init__(self, in_channels_list, out_channels):
# in_channels_list:输入特征层通道数,out_channels:输出特征层通道数
super(FPN, self).__init__()
leaky = 0
if (out_channels <= 64): # 如果输出通道数<=64,negative_slope设置为0.1,否则默认为0
leaky = 0.1
self.output1 = conv_bn1X1(in_channels_list[0], out_channels, stride=1, leaky=leaky)
# 获得C3特征层,80x80x64,首先使用1x1卷积调整C3通道数为out_channels
self.output2 = conv_bn1X1(in_channels_list[1], out_channels, stride=1, leaky=leaky)
# 获得C4特征层,40x40x64,首先使用1x1卷积调整C4通道数为out_channels
self.output3 = conv_bn1X1(in_channels_list[2], out_channels, stride=1, leaky=leaky)
# 获得C5特征层,20x20x64,首先使用1x1卷积调整C5通道数为out_channels
self.merge1 = conv_bn(out_channels, out_channels, leaky=leaky)
# 用于融合C3和上采样的C4特征,1x1卷积调整通道数
self.merge2 = conv_bn(out_channels, out_channels, leaky=leaky)
# 用于融合C4和上采样的C5特征,1x1卷积调整通道数
def forward(self, inputs):
# FPN的前向传播
inputs = list(inputs.values())
# 获得三个有效特征层,C3,C4,C5
output1 = self.output1(inputs[0])
# 获得C3特征层,80x80xout_channels
output2 = self.output2(inputs[1])
# 获得C4特征层,40x40xout_channels
output3 = self.output3(inputs[2])
# 获得C5特征层,20x20xout_channels
up3 = F.interpolate(output3, size=[output2.size(2), output2.size(3)], mode="nearest")
# 使用最近邻插值上采样,C5特征层上采样到与C4特征层大小相同,40x40
output2 = output2 + up3
# C4特征层和上采样的C5特征层进行像素级特征融合
output2 = self.merge2(output2)
# 使用1x1卷积融合特征,输出40x40xout_channels
up2 = F.interpolate(output2, size=[output1.size(2), output1.size(3)], mode="nearest")
# 将融合后的C4特征层上采样到与C3特征层大小相同,80x80
output1 = output1 + up2
# C3特征层和上采样的融合后的C4特征层进行像素级特征融合
output1 = self.merge1(output1)
# 使用1x1卷积融合特征,输出80x80xout_channels
out = [output1, output2, output3]
# 得到三个尺度80x80xout_channels,
# 40x40xout_channels和
# 20x20xout_channels的特征表达
return out
在SSH模块之后,通过分类头部、框的回归头部和关键点回归头部对每个有效特征层进行预测。
class SSH(nn.Module):
def __init__(self, in_channel, out_channel):
# in_channel:输入通道数,out_channel:输出通道数
super(SSH, self).__init__()
assert out_channel % 4 == 0
# 输出通道数约束为4的倍数
leaky = 0
if (out_channel <= 64):
# 如果输出通道数<=64,leaky=0.1,否则默认为0
leaky = 0.1
self.conv3X3 = conv_bn_no_relu(in_channel, out_channel // 2, stride=1)
# 3x3卷积,步长1,输入通道数in_channel,输出通道数out_channel//2
self.conv5X5_1 = conv_bn(in_channel, out_channel // 4, stride=1, leaky=leaky)
# 第一个3x3卷积,步长1,输入通道数in_channel,输出通道数out_channel//4,leaky=0.1
self.conv5X5_2 = conv_bn_no_relu(out_channel // 4, out_channel // 4, stride=1)
# 第二个3x3卷积,步长1,输入输出通道数out_channel//4
self.conv7X7_2 = conv_bn(out_channel // 4, out_channel // 4, stride=1, leaky=leaky)
# 第一个3x3卷积,步长1,输入输出通道数out_channel//4,leaky=0.1
self.conv7x7_3 = conv_bn_no_relu(out_channel // 4, out_channel // 4, stride=1)
# 第二个3x3卷积,步长1,输入输出通道数out_channel//4
def forward(self, inputs):
# SSH的前向传播
conv3X3 = self.conv3X3(inputs)
conv5X5_1 = self.conv5X5_1(inputs)
conv5X5 = self.conv5X5_2(conv5X5_1)
conv7X7_2 = self.conv7X7_2(conv5X5_1)
conv7X7 = self.conv7x7_3(conv7X7_2)
# 所有结果堆叠起来
out = torch.cat([conv3X3, conv5X5, conv7X7], dim=1)
# 在通道维度上拼接,获得out_channel的特征表达
out = F.relu(out)
# ReLU激活
return out
通过第三步,我们已经可以获得SSH1,SSH2,SHH3三个有效特征层了。在获得这三个有效特征层后,我们需要通过这三个有效特征层获得预测结果。
通过前面的步骤,我们已经得到了三个有效特征层:SSH1、SSH2和SSH3。现在我们需要利用这些特征层来获得预测结果。
RetinaFace模型的预测结果可以分为三部分:分类预测结果、框的回归预测结果和人脸关键点的回归预测结果。
分类预测结果用于判断先验框内是否包含物体(即人脸)。原版的RetinaFace使用softmax函数进行判断。为了实现这一功能,我们可以使用一个1x1的卷积操作将SSH的通道数调整为num_anchors x 2,其中每个通道代表一个先验框内包含人脸的概率。
框的回归预测结果用于调整先验框以获得更准确的预测框。我们需要四个参数来对先验框进行调整。同样地,我们可以使用一个1x1的卷积操作将SSH的通道数调整为num_anchors x 4,其中每个通道代表一个先验框的调整参数。
人脸关键点的回归预测结果用于调整先验框以获得人脸的关键点位置。每个人脸关键点需要两个调整参数,而且共有五个人脸关键点。为了实现这一功能,我们可以使用一个1x1的卷积操作将SSH的通道数调整为num_anchors x 10(即num_anchors x 5 x 2),其中每个通道代表一个先验框的每个人脸关键点的调整参数。
总之,通过适当的卷积操作,我们可以从SSH特征层中提取出分类预测结果、框的回归预测结果和人脸关键点的回归预测结果,以实现准确的人脸检测。
实现代码为:
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision.models._utils as _utils
from torchvision import models
from nets.layers import FPN, SSH
# 导入FPN和SSH模块
from nets.mobilenet025 import MobileNetV1
# ---------------------------------------------------#
# 种类预测(是否包含人脸)
# ---------------------------------------------------#
class ClassHead(nn.Module):
def __init__(self, inchannels=512, num_anchors=2):
# inchannels: 输入通道数,num_anchors: 锚框数
super(ClassHead, self).__init__()
self.num_anchors = num_anchors
# 锚框数
self.conv1x1 = nn.Conv2d(inchannels, self.num_anchors * 2, kernel_size=(1, 1), stride=1, padding=0)
def forward(self, x):
# 分类类别预测
out = self.conv1x1(x)
# 1x1卷积
out = out.permute(0, 2, 3, 1).contiguous()
# 维度变换 shape=[b, h, w, num_anchors, 2]
out = out.view(out.shape[0], -1, 2)
# reshape为 shape=[b, h*w*num_anchors, 2]
return out
# ---------------------------------------------------#
# 预测框预测
# ---------------------------------------------------#
class BboxHead(nn.Module):
def __init__(self, inchannels=512, num_anchors=2):
# inchannels: 输入通道数,num_anchors: 锚框数
super(BboxHead, self).__init__()
self.conv1x1 = nn.Conv2d(inchannels, num_anchors * 4, kernel_size=(1, 1), stride=1, padding=0)
def forward(self, x):
# 预测框回归
out = self.conv1x1(x)
# 1x1卷积
out = out.permute(0, 2, 3, 1).contiguous()
# 维度变换 shape=[b, h, w, num_anchors, 4]
out = out.view(out.shape[0], -1, 4)
# reshape为 shape=[b, h*w*num_anchors, 4]
return out
# ---------------------------------------------------#
# 人脸关键点预测
# ---------------------------------------------------#
class LandmarkHead(nn.Module):
def __init__(self, inchannels=512, num_anchors=2):
# inchannels: 输入通道数,num_anchors: 锚框数
super(LandmarkHead, self).__init__()
self.conv1x1 = nn.Conv2d(inchannels, num_anchors * 10, kernel_size=(1, 1), stride=1, padding=0)
def forward(self, x):
# 人脸关键点回归
out = self.conv1x1(x)
# 1x1卷积
out = out.permute(0, 2, 3, 1).contiguous()
# 维度变换 shape=[b, h, w, num_anchors, 10]
out = out.view(out.shape[0], -1, 10)
# reshape为 shape=[b, h*w*num_anchors, 10]
return out
class RetinaFace(nn.Module):
def __init__(self, cfg=None, pretrained=False, mode='train'):
# cfg: 模型配置参数,pretrained: 是否载入预训练权重,mode: 训练或预测模式
super(RetinaFace, self).__init__()
backbone = None
# 主干网络
# -------------------------------------------#
# 选择使用mobilenet0.25、resnet50作为主干
# -------------------------------------------#
if cfg['name'] == 'mobilenet0.25':
backbone = MobileNetV1()
# 选择MobileNetV1作为主干网络
if pretrained:
# 如果载入预训练权重
checkpoint = torch.load("./model_data/mobilenetV1X0.25_pretrain.tar", map_location=torch.device('cpu'))
from collections import OrderedDict
new_state_dict = OrderedDict()
for k, v in checkpoint['state_dict'].items():
name = k[7:]
new_state_dict[name] = v
backbone.load_state_dict(new_state_dict)
elif cfg['name'] == 'Resnet50':
backbone = models.resnet50(pretrained=pretrained)
# 选择ResNet50作为主干网络,并载入预训练权重
self.body = _utils.IntermediateLayerGetter(backbone, cfg['return_layers'])
# 使用IntermediateLayerGetter获得指定层的输出
# -------------------------------------------#
# 获得每个初步有效特征层的通道数
# 分别是C3 80, 80, 64
# C4 40, 40, 128
# C5 20, 20, 256
# -------------------------------------------#
in_channels_list = [cfg['in_channel'] * 2, cfg['in_channel'] * 4, cfg['in_channel'] * 8]
# -------------------------------------------#
# 利用初步有效特征层构建特征金字塔
# 分别是output1 80, 80, 64
# output2 40, 40, 64
# output3 20, 20, 64
# -------------------------------------------#
self.fpn = FPN(in_channels_list, cfg['out_channel'])
# FPN模块
# -------------------------------------------#
# 利用ssh模块提高模型感受野
# -------------------------------------------#
self.ssh1 = SSH(cfg['out_channel'], cfg['out_channel'])
self.ssh2 = SSH(cfg['out_channel'], cfg['out_channel'])
self.ssh3 = SSH(cfg['out_channel'], cfg['out_channel'])
self.ClassHead = self._make_class_head(fpn_num=3, inchannels=cfg['out_channel'])
# 分类预测模块
self.BboxHead = self._make_bbox_head(fpn_num=3, inchannels=cfg['out_channel'])
# 预测框回归模块
self.LandmarkHead = self._make_landmark_head(fpn_num=3, inchannels=cfg['out_channel'])
# 关键点回归模块
self.mode = mode
def _make_class_head(self, fpn_num=3, inchannels=64, anchor_num=2):
# 模块运行模式,训练或预测
classhead = nn.ModuleList()
for i in range(fpn_num):
# 在三个特征层上分别获得分类预测
classhead.append(ClassHead(inchannels, anchor_num))
return classhead
def _make_bbox_head(self, fpn_num=3, inchannels=64, anchor_num=2):
bboxhead = nn.ModuleList()
for i in range(fpn_num):
# 在三个特征层上分别获得预测框回归
bboxhead.append(BboxHead(inchannels, anchor_num))
return bboxhead
def _make_landmark_head(self, fpn_num=3, inchannels=64, anchor_num=2):
landmarkhead = nn.ModuleList()
for i in range(fpn_num):
# 在三个特征层上分别获得人脸关键点回归
landmarkhead.append(LandmarkHead(inchannels, anchor_num))
return landmarkhead
def forward(self, inputs):
# RetinaFace的前向传播
out = self.body.forward(inputs)
# 获得三个shape的有效特征层
fpn = self.fpn.forward(out)
# 获得三个shape的有效特征层
feature1 = self.ssh1(fpn[0]) # 使用SSH模块增强特征
feature2 = self.ssh2(fpn[1])
feature3 = self.ssh3(fpn[2])
features = [feature1, feature2, feature3]
# 将所有结果进行堆叠
bbox_regressions = torch.cat([self.BboxHead[i](feature) for i, feature in enumerate(features)], dim=1)
classifications = torch.cat([self.ClassHead[i](feature) for i, feature in enumerate(features)], dim=1)
ldm_regressions = torch.cat([self.LandmarkHead[i](feature) for i, feature in enumerate(features)], dim=1)
if self.mode == 'train':
output = (bbox_regressions, classifications, ldm_regressions)
# 训练模式,输出回归目标
else:
output = (bbox_regressions, F.softmax(classifications, dim=-1), ldm_regressions)
# 预测模式,输出回归和分类预测
return output
这段代码实现了RetinaFace人脸检测模型的前向传播过程。下面是对每个部分的详细注释:
ClassHead类:用于进行种类预测,判断是否包含人脸。
BboxHead类:用于进行预测框回归,精确定位人脸的边界框。
LandmarkHead类:用于进行人脸关键点预测,识别人脸的关键点位置。
RetinaFace类:主要的RetinaFace模型类,包含了分类预测、预测框回归和人脸关键点预测等部分。
总之,这段代码实现了RetinaFace人脸检测模型的各个组件,并将它们整合在一起进行前向传播。通过特征提取、特征金字塔构建以及分类预测、预测框回归和人脸关键点预测等任务的计算,模型能够准确地检测出图像中的人脸。
通过第四步,我们可以获得三个有效特征层SSH1、SSH2、SSH3。
这三个有效特征层相当于将整幅图像划分成不同大小的网格,当我们输入进来的图像是(640, 640, 3)的时候。
SSH1的shape为(80, 80, 64);
SSH2的shape为(40, 40, 64);
SSH3的shape为(20, 20, 64)
SSH1就表示将原图像划分成80x80的网格;SSH2就表示将原图像划分成40x40的网格;SSH3就表示将原图像划分成20x20的网格,每个网格上有两个先验框,每个先验框代表图片上的一定区域。
Retinaface的预测结果用来判断先验框内部是否包含人脸,并且对包含人脸的先验框进行调整获得预测框与人脸关键点。
1、分类预测结果用于判断先验框内部是否包含物体,我们可以利用一个1x1的卷积,将SSH的通道数调整成num_anchors x 2,用于代表每个先验框内部包含人脸的概率。
2、框的回归预测结果用于对先验框进行调整获得预测框,我们需要用四个参数对先验框进行调整。此时我们可以利用一个1x1的卷积,将SSH的通道数调整成num_anchors x 4,用于代表每个先验框的调整参数。每个先验框的四个调整参数中,前两个用于对先验框的中心进行调整,后两个用于对先验框的宽高进行调整。
3、人脸关键点的回归预测结果用于对先验框进行调整获得人脸关键点,每一个人脸关键点需要两个调整参数,一共有五个人脸关键点。此时我们可以利用一个1x1的卷积,将SSH的通道数调整成num_anchors x 10(num_anchors x 5 x 2),用于代表每个先验框的每个人脸关键点的调整。每个人脸关键点的两个调整参数用于对先验框中心的x、y轴进行调整获得关键点坐标。
完成调整、判断之后,还需要进行非极大移植。
下图是经过非极大抑制的。
下图是未经过非极大抑制的。
可以很明显的看出来,未经过非极大抑制的图片有许多重复的框,这些框都指向了同一个物体!
可以用一句话概括非极大抑制的功能就是:
筛选出一定区域内属于同一种类得分最大的框。
这段代码包含了一些辅助函数,用于在RetinaFace人脸检测模型中进行预测结果的解码和后处理。
全部实现代码如下:
def decode(loc, priors, variances):
# 中心解码,宽高解码
boxes = torch.cat((priors[:, :2] + loc[:, :2] * variances[0] * priors[:, 2:],
priors[:, 2:] * torch.exp(loc[:, 2:] * variances[1])), 1)
boxes[:, :2] -= boxes[:, 2:] / 2
boxes[:, 2:] += boxes[:, :2]
return boxes
def decode_landm(pre, priors, variances):
# 关键点解码
landms = torch.cat((priors[:, :2] + pre[:, :2] * variances[0] * priors[:, 2:],
priors[:, :2] + pre[:, 2:4] * variances[0] * priors[:, 2:],
priors[:, :2] + pre[:, 4:6] * variances[0] * priors[:, 2:],
priors[:, :2] + pre[:, 6:8] * variances[0] * priors[:, 2:],
priors[:, :2] + pre[:, 8:10] * variances[0] * priors[:, 2:],
), dim=1)
return landms
def non_max_suppression(boxes, conf_thres=0.5, nms_thres=0.3):
detection = boxes
# 1、找出该图片中得分大于门限函数的框。在进行重合框筛选前就进行得分的筛选可以大幅度减少框的数量。
mask = detection[:,4] >= conf_thres
detection = detection[mask]
if not np.shape(detection)[0]:
return []
best_box = []
scores = detection[:,4]
# 2、根据得分对框进行从大到小排序。
arg_sort = np.argsort(scores)[::-1]
detection = detection[arg_sort]
while np.shape(detection)[0]>0:
# 3、每次取出得分最大的框,计算其与其它所有预测框的重合程度,重合程度过大的则剔除。
best_box.append(detection[0])
if len(detection) == 1:
break
ious = iou(best_box[-1],detection[1:])
detection = detection[1:][ious<nms_thres]
return np.array(best_box)
def iou(b1,b2):
b1_x1, b1_y1, b1_x2, b1_y2 = b1[0], b1[1], b1[2], b1[3]
b2_x1, b2_y1, b2_x2, b2_y2 = b2[:, 0], b2[:, 1], b2[:, 2], b2[:, 3]
inter_rect_x1 = np.maximum(b1_x1, b2_x1)
inter_rect_y1 = np.maximum(b1_y1, b2_y1)
inter_rect_x2 = np.minimum(b1_x2, b2_x2)
inter_rect_y2 = np.minimum(b1_y2, b2_y2)
inter_area = np.maximum(inter_rect_x2 - inter_rect_x1, 0) * \
np.maximum(inter_rect_y2 - inter_rect_y1, 0)
area_b1 = (b1_x2-b1_x1)*(b1_y2-b1_y1)
area_b2 = (b2_x2-b2_x1)*(b2_y2-b2_y1)
iou = inter_area/np.maximum((area_b1+area_b2-inter_area),1e-6)
return iou
这段代码包含了一些辅助函数,用于在RetinaFace人脸检测模型中进行预测结果的解码和后处理。下面是对每个函数的详细注释:
decode函数:该函数用于对定位信息进行解码,得到预测框的坐标。
decode_landm函数:该函数用于对关键点信息进行解码,得到预测的人脸关键点坐标。
non_max_suppression函数:该函数实现了非极大值抑制(NMS)算法,用于筛选重叠度较低的预测框。
iou函数:该函数用于计算两个矩形框之间的交并比(IOU)。
decode函数:根据定位信息、先验框和方差参数,将预测框的编码值解码为实际坐标。
decode_landm函数:根据关键点信息、先验框和方差参数,将关键点的编码值解码为实际坐标。
non_max_suppression函数:使用非极大值抑制算法对预测框进行筛选,去除重叠度较高的冗余框。
iou函数:计算两个矩形框之间的交并比(IOU)。
match函数:根据阈值和匹配结果,将真实框和关键点信息进行匹配和编码操作。
encode函数:对真实框进行编码,将其转换为模型需要的形式。
encode_landm函数:对关键点信息进行编码,将其转换为模型需要的形式。
这些辅助函数在RetinaFace人脸检测模型中起到了解码、筛选和编码等作用,帮助我们处理预测结果、筛选出准确的目标框,并进行数据编码以便于训练和评估。
def point_form(boxes):
# 转换形式,转换成左上角右下角的形式
return torch.cat((boxes[:, :2] - boxes[:, 2:]/2, # xmin, ymin
boxes[:, :2] + boxes[:, 2:]/2), 1) # xmax, ymax
def center_size(boxes):
# 转换成中心宽高的形式
return torch.cat((boxes[:, 2:] + boxes[:, :2])/2, # cx, cy
boxes[:, 2:] - boxes[:, :2], 1) # w, h
def intersect(box_a, box_b):
# 计算所有真实框和先验框的交面积
A = box_a.size(0)
B = box_b.size(0)
max_xy = torch.min(box_a[:, 2:].unsqueeze(1).expand(A, B, 2),
box_b[:, 2:].unsqueeze(0).expand(A, B, 2))
min_xy = torch.max(box_a[:, :2].unsqueeze(1).expand(A, B, 2),
box_b[:, :2].unsqueeze(0).expand(A, B, 2))
inter = torch.clamp((max_xy - min_xy), min=0)
return inter[:, :, 0] * inter[:, :, 1]
def jaccard(box_a, box_b):
# 计算所有真实框和先验框的交并比
# 行为真实框,列为先验框
inter = intersect(box_a, box_b)
area_a = ((box_a[:, 2]-box_a[:, 0]) *
(box_a[:, 3]-box_a[:, 1])).unsqueeze(1).expand_as(inter) # [A,B]
area_b = ((box_b[:, 2]-box_b[:, 0]) *
(box_b[:, 3]-box_b[:, 1])).unsqueeze(0).expand_as(inter) # [A,B]
union = area_a + area_b - inter
return inter / union # [A,B]
def match(threshold, truths, priors, variances, labels, landms, loc_t, conf_t, landm_t, idx):
# 计算交并比
overlaps = jaccard(
truths,
point_form(priors)
)
best_prior_overlap, best_prior_idx = overlaps.max(1, keepdim=True)
best_prior_idx.squeeze_(1)
best_prior_overlap.squeeze_(1)
# 计算每个先验框最对应的真实框
best_truth_overlap, best_truth_idx = overlaps.max(0, keepdim=True)
best_truth_idx.squeeze_(0)
best_truth_overlap.squeeze_(0)
# 找到与真实框重合程度最好的先验框,用于保证每个真实框都要有对应的一个先验框
best_truth_overlap.index_fill_(0, best_prior_idx, 2)
# 对best_truth_idx内容进行设置
for j in range(best_prior_idx.size(0)):
best_truth_idx[best_prior_idx[j]] = j
# Shape: [num_priors,4] 此处为每一个anchor对应的bbox取出来
matches = truths[best_truth_idx]
# Shape: [num_priors] 此处为每一个anchor对应的label取出来
conf = labels[best_truth_idx]
conf[best_truth_overlap < threshold] = 0
loc = encode(matches, priors, variances)
matches_landm = landms[best_truth_idx]
landm = encode_landm(matches_landm, priors, variances)
loc_t[idx] = loc # [num_priors,4] encoded offsets to learn
conf_t[idx] = conf # [num_priors] top class label for each prior
landm_t[idx] = landm
def encode(matched, priors, variances):
# 进行编码的操作
g_cxcy = (matched[:, :2] + matched[:, 2:])/2 - priors[:, :2]
# 中心编码
g_cxcy /= (variances[0] * priors[:, 2:])
# 宽高编码
g_wh = (matched[:, 2:] - matched[:, :2]) / priors[:, 2:]
g_wh = torch.log(g_wh) / variances[1]
return torch.cat([g_cxcy, g_wh], 1) # [num_priors,4]
def encode_landm(matched, priors, variances):
matched = torch.reshape(matched, (matched.size(0), 5, 2))
priors_cx = priors[:, 0].unsqueeze(1).expand(matched.size(0), 5).unsqueeze(2)
priors_cy = priors[:, 1].unsqueeze(1).expand(matched.size(0), 5).unsqueeze(2)
priors_w = priors[:, 2].unsqueeze(1).expand(matched.size(0), 5).unsqueeze(2)
priors_h = priors[:, 3].unsqueeze(1).expand(matched.size(0), 5).unsqueeze(2)
priors = torch.cat([priors_cx, priors_cy, priors_w, priors_h], dim=2)
# 减去中心后除上宽高
g_cxcy = matched[:, :, :2] - priors[:, :, :2]
g_cxcy /= (variances[0] * priors[:, :, 2:])
g_cxcy = g_cxcy.reshape(g_cxcy.size(0), -1)
return g_cxcy
MultiBoxLoss类:多任务损失函数的定义。
rgb_mean = (104, 117, 123) # bgr order
class MultiBoxLoss(nn.Module):
def __init__(self, num_classes, overlap_thresh, neg_pos, cuda=True):
super(MultiBoxLoss, self).__init__()
# 对于retinaface而言num_classes等于2
self.num_classes = num_classes
# 重合程度在多少以上认为该先验框可以用来预测
self.threshold = overlap_thresh
# 正负样本的比率
self.negpos_ratio = neg_pos
self.variance = [0.1, 0.2]
self.cuda = cuda
def forward(self, predictions, priors, targets):
loc_data, conf_data, landm_data = predictions
priors = priors
num = loc_data.size(0)
num_priors = (priors.size(0))
# match priors (default boxes) and ground truth boxes
loc_t = torch.Tensor(num, num_priors, 4)
landm_t = torch.Tensor(num, num_priors, 10)
conf_t = torch.LongTensor(num, num_priors)
for idx in range(num):
truths = targets[idx][:, :4].data
labels = targets[idx][:, -1].data
landms = targets[idx][:, 4:14].data
defaults = priors.data
match(self.threshold, truths, defaults, self.variance, labels, landms, loc_t, conf_t, landm_t, idx)
zeros = torch.tensor(0)
if self.cuda:
loc_t = loc_t.cuda()
conf_t = conf_t.cuda()
landm_t = landm_t.cuda()
zeros = zeros.cuda()
# landm Loss (Smooth L1)
# Shape: [batch,num_priors,10]
pos1 = conf_t > zeros
num_pos_landm = pos1.long().sum(1, keepdim=True)
N1 = max(num_pos_landm.data.sum().float(), 1)
pos_idx1 = pos1.unsqueeze(pos1.dim()).expand_as(landm_data)
landm_p = landm_data[pos_idx1].view(-1, 10)
landm_t = landm_t[pos_idx1].view(-1, 10)
loss_landm = F.smooth_l1_loss(landm_p, landm_t, reduction='sum')
pos = conf_t != zeros
conf_t[pos] = 1
# Localization Loss (Smooth L1)
# Shape: [batch,num_priors,4]
pos_idx = pos.unsqueeze(pos.dim()).expand_as(loc_data)
loc_p = loc_data[pos_idx].view(-1, 4)
loc_t = loc_t[pos_idx].view(-1, 4)
loss_l = F.smooth_l1_loss(loc_p, loc_t, reduction='sum')
# Compute max conf across batch for hard negative mining
batch_conf = conf_data.view(-1, self.num_classes)
loss_c = log_sum_exp(batch_conf) - batch_conf.gather(1, conf_t.view(-1, 1))
# Hard Negative Mining
loss_c[pos.view(-1, 1)] = 0 # filter out pos boxes for now
loss_c = loss_c.view(num, -1)
_, loss_idx = loss_c.sort(1, descending=True)
_, idx_rank = loss_idx.sort(1)
num_pos = pos.long().sum(1, keepdim=True)
num_neg = torch.clamp(self.negpos_ratio*num_pos, max=pos.size(1)-1)
neg = idx_rank < num_neg.expand_as(idx_rank)
# Confidence Loss Including Positive and Negative Examples
pos_idx = pos.unsqueeze(2).expand_as(conf_data)
neg_idx = neg.unsqueeze(2).expand_as(conf_data)
conf_p = conf_data[(pos_idx+neg_idx).gt(0)].view(-1,self.num_classes)
targets_weighted = conf_t[(pos+neg).gt(0)]
loss_c = F.cross_entropy(conf_p, targets_weighted, reduction='sum')
# Sum of losses: L(x,c,l,g) = (Lconf(x, c) + αLloc(x,l,g)) / N
N = max(num_pos.data.sum().float(), 1)
loss_l /= N
loss_c /= N
loss_landm /= N1
return loss_l, loss_c, loss_landm