InsightFace力作:RetinaFace單階段人臉檢測器

1. 前言

RetinaFace 是 2019 年 5 月來自 InsightFace 的又一力作,它是一個魯棒性較強的人臉檢測器。它在目標檢測這一塊的變動其實並不大,主要貢獻是新增了一個人臉關鍵點回歸分支(5 個人臉關鍵點)和一個自監督學習分支(主要是和 3D 有關),加入的任務可以用下圖來表示:

Figure1 RetinaFace的主要貢獻

語言是空洞的,我們來看一下在 WiderFace 數據集上 RetinaFace 的表現:

RetinaFace的精度在Easy,Medium,Hard中均SOTA

另外再來看看論文跑出來的一個效果圖:

RetinaFace的一個效果圖

2. RetinaFace 的特點

RetinaFace 有幾個主要特點:

  • 採用 FPN 特徵金字塔提取多尺度特徵。
  • 單階段&&e2e,使用 MobileNet Backbone 可以在 ARM 上實時。
  • 引入 SSH 演算法的 Context Modeling。
  • 多任務訓練,提供額外的監督資訊。

2.1 RetinaFace 的結構圖

RetinaFace的結構圖

2.2 FPN

下圖表示 FPN 的整體結構:

FPN接面構

我們可以看到 FPN 的整體結構分為自底向上自頂向下和側向連接的過程。接下來我們分別解釋一下這兩個關鍵部分。

2.2.1 自底向上

這一部分就是普通的特徵提取網路,特徵解析度不斷縮小,容易想到這個特徵提取網路可以換成任意 Backbone,並且 CNN 網路一般都是按照特徵圖大小分為不同的 stage,每個 stage 的特徵圖長寬差距為 2 倍。在這個自底向上的結構中,一個 stage 對應特徵金字塔的一個 level。以我們要用的 ResNet 為例,選取 conv2、conv3、conv4、conv5 層的最後一個殘差 block 層特徵作為 FPN 的特徵,記為{C2、C3、C4、C5},也即是 FPN 網路的 4 個級別。這幾個特徵層相對於原圖的步長分別為 4、8、16、32。

2.2.2 自上向下和側向連接

自上向下是特徵圖放大的過程,我們一般採用上取樣來實現。FPN 的巧妙之處就在於從高層特徵上取樣既可以利用頂層的高級語義特徵(有助於分類)又可以利用底層的高解析度資訊(有助於定位)。上取樣可以使用插值的方式實現。為了將高層語義特徵和底層的精確定位能力結合,論文提出了類似於殘差結構的側向連接。向連接將上一層經過上取樣後和當前層解析度一致的特徵,通過相加的方法進行融合。同時為了保持所有級別的特徵層通道數都保持一致,這裡使用1\times 1卷積來實現。在網上看到一張圖,比較好的解釋了這個過程:

自上向下和側向連接

FPN 只是一個特徵金字塔結構,需要配合其他目標檢測演算法才能使用。

2.3 SSH 演算法

2.3.1 檢測模組

下面的 Figure3 是檢測模組的示意圖:

檢測模組結構

分類和回歸的特徵圖是融合了普通卷積層和上下文模組輸出的結果。分類和回歸支路輸出的K表示特徵圖上每個點都設置了K個 Anchor,這K個 Anchor 的寬高比例都是1:1,論文說增加寬高比例對於人臉檢測的效果沒有提示還會增加 Anchor 的數量。

2.3.2 上下文模組

下面的 Figure4 是上下文模組的示意圖:

上下文模組結構

作者原本是通過引入卷積核尺寸較大的卷積層例如5\times 57\times 7來增大感受野,從而引入更多的上下文資訊。但為了減少計算量,作者借鑒了 GoogleNet 中用多個3\times 3卷積代替5\times 5卷積或者7\times 7卷積的做法,於是最終上下文模組的結構就如 Figure4 所示了,另外注意到開頭的3\times 3卷積是共享的。

2.4 損失函數

損失函數的改進是這篇論文的一個核心貢獻,為了實現下面的損失函數的訓練,作者還在 WIDER FACE 這個巨大的人臉數據集上進行了 5 點的標註,損失函數如下:

RetinaFace的Loss

前面兩項和以前的多任務人臉檢測的損失是一樣的,即分類和 bbox 回歸損失,第三項是5個人臉關鍵點的回歸損失。而最後一項是論文提出的 Dense Regression 分支帶來的損失,下面會再簡單介紹一下。其中\lambda_1,\lambda_2,\lambda_3的取值分別是0.25,0.1,0.01,也即是說來自檢測分支和關鍵點分支的損失權重更高,而 Dense Regression 分支的損失權重佔比小。

2.5 Dense Regression 分支

這個分支就是將 2D 的人臉映射到 3D 模型上,再將 3D 模型解碼為 2D 圖片,然後計算經過了編解碼的圖片和原始圖片的差別。中間還用到了 GCN。這裡實際上用到了 mesh decoder 來做解碼部分,mesh decoder 是一種基於 GCN 的方法,參數量比常用的 2D 卷積要少。如 Figure3 所示:

2D卷積和圖卷積的對比

從上圖可以看到,普通的卷積操作參數量一般是kernel_H\times kernel_W\times channels_{in}\times channels_{out},而 GCN 的參數量只有K\times channel_{in}\times channel_{out}

在 Decoder 完成之後會再做一個 3D 到 2D 的映射,之後就會有一個 Dense Regression Loss,實際上就是比較編解碼前後的人臉圖片的5個人臉特徵點的位置差距了,Dense Regression Loss 的公式表達如下:

Dense Regression Loss

其中,WH分別表示 Anchor 區域I_{i,j}^*的寬高。

3. 實現細節

RetinaFace 使用 ResNet152 為 Backbone 的 FPN 結構,對於每一層金字塔都有特定的 Anchor 設置,這樣可以捕捉到不同尺寸的目標,P2 被設置為捕捉微小人臉。具體設置如下:

Anchor的具體設置

除此之外,中間還使用了 OHEM 來平衡 positive 和 negative 的 anchors,因為 negative anchors 的數量明顯要多。另外還使用可變形卷積MASA DCN(可變形卷積) 演算法筆記描述 代替了 lateral connections 和 context modules 中的 3*3 卷積 。

最後,除了上面提到的針對網路結構上的 Trick,還在數據上做了額外的標註資訊,具體為:

  • 定義了 5 個等級的人臉品質,根據清晰度來定義檢測難度。
  • 定義了 5 個人臉關鍵點。

數據的額外標註資訊,過於模糊的就沒有關鍵點

最終,基於上面的這些改進就成就了這一大名鼎鼎的 RetinaFace。這些 Trick 的消融實驗如 Table3 所示:

RetinaFace Trick的消融實驗

4. 速度

論文給出的在 Wider Face 上的結果是基於 ResNet-152 的骨幹網路,如果將骨幹網路換成輕量化網路,那麼 RetinaFace 就可以輕鬆在 ARM 上達到實時檢測。

不同Backbone的RetinaFace在不同硬體上的耗時測試

5. 部署

前幾天 Hanson 大佬發表了一篇文章:NINE之RetinaFace部署量化,詳細介紹了 MobileNet-0.25 為 BackBone 的 RetinaFace 在 Hisi35xx 板子上的部署流程,列舉了部署中的所有坑點,歡迎去學習並將 RetinaFace 部署在自己的工程中。實際上 MobileNet-0.25 的 RetinaFace 參數量是很少的,CaffeModel 只有 1.7M,從速度和精度的 TradeOff 來看,這可能是至今為止最好用的人臉檢測器了。(訓練過 MTCNN 的都懂,那是真不好訓練,並且我個人也更傾向於這種單階段的,不僅好訓練還相對好部署啊)。

6. 核心程式碼

import torch
import torch.nn as nn
import torchvision.models.detection.backbone_utils as backbone_utils
import torchvision.models._utils as _utils
import torch.nn.functional as F
from collections import OrderedDict

from models.net import MobileNetV1 as MobileNetV1
from models.net import FPN as FPN
from models.net import SSH as SSH



class ClassHead(nn.Module):
    def __init__(self,inchannels=512,num_anchors=3):
        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)
        out = out.permute(0,2,3,1).contiguous()
        
        return out.view(out.shape[0], -1, 2)

class BboxHead(nn.Module):
    def __init__(self,inchannels=512,num_anchors=3):
        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)
        out = out.permute(0,2,3,1).contiguous()

        return out.view(out.shape[0], -1, 4)

class LandmarkHead(nn.Module):
    def __init__(self,inchannels=512,num_anchors=3):
        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)
        out = out.permute(0,2,3,1).contiguous()

        return out.view(out.shape[0], -1, 10)

class RetinaFace(nn.Module):
    def __init__(self, cfg = None, phase = 'train'):
        """
        :param cfg:  Network related settings.
        :param phase: train or test.
        """
        super(RetinaFace,self).__init__()
        self.phase = phase
        backbone = None
        if cfg['name'] == 'mobilenet0.25':
            backbone = MobileNetV1()
            if cfg['pretrain']:
                checkpoint = torch.load("./weights/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:]  # remove module.
                    new_state_dict[name] = v
                # load params
                backbone.load_state_dict(new_state_dict)
        elif cfg['name'] == 'Resnet50':
            import torchvision.models as models
            backbone = models.resnet50(pretrained=cfg['pretrain'])

        self.body = _utils.IntermediateLayerGetter(backbone, cfg['return_layers'])
        in_channels_stage2 = cfg['in_channel']
        in_channels_list = [
            in_channels_stage2 * 2,
            in_channels_stage2 * 4,
            in_channels_stage2 * 8,
        ]
        out_channels = cfg['out_channel']
        self.fpn = FPN(in_channels_list,out_channels)
        self.ssh1 = SSH(out_channels, out_channels)
        self.ssh2 = SSH(out_channels, out_channels)
        self.ssh3 = SSH(out_channels, out_channels)

        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'])

    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):
        out = self.body(inputs)

        # FPN
        fpn = self.fpn(out)

        # SSH
        feature1 = self.ssh1(fpn[0])
        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.phase == 'train':
            output = (bbox_regressions, classifications, ldm_regressions)
        else:
            output = (bbox_regressions, F.softmax(classifications, dim=-1), ldm_regressions)
        return output

引用的 fpn,SSH 等結構程式碼

import time
import torch
import torch.nn as nn
import torchvision.models._utils as _utils
import torchvision.models as models
import torch.nn.functional as F
from torch.autograd import Variable

def conv_bn(inp, oup, stride = 1, leaky = 0):
    return nn.Sequential(
        nn.Conv2d(inp, oup, 3, stride, 1, bias=False),
        nn.BatchNorm2d(oup),
        nn.LeakyReLU(negative_slope=leaky, inplace=True)
    )

def conv_bn_no_relu(inp, oup, stride):
    return nn.Sequential(
        nn.Conv2d(inp, oup, 3, stride, 1, bias=False),
        nn.BatchNorm2d(oup),
    )

def conv_bn1X1(inp, oup, stride, leaky=0):
    return nn.Sequential(
        nn.Conv2d(inp, oup, 1, stride, padding=0, bias=False),
        nn.BatchNorm2d(oup),
        nn.LeakyReLU(negative_slope=leaky, inplace=True)
    )

def conv_dw(inp, oup, stride, leaky=0.1):
    return nn.Sequential(
        nn.Conv2d(inp, inp, 3, stride, 1, groups=inp, bias=False),
        nn.BatchNorm2d(inp),
        nn.LeakyReLU(negative_slope= leaky,inplace=True),

        nn.Conv2d(inp, oup, 1, 1, 0, bias=False),
        nn.BatchNorm2d(oup),
        nn.LeakyReLU(negative_slope= leaky,inplace=True),
    )

class SSH(nn.Module):
    def __init__(self, in_channel, out_channel):
        super(SSH, self).__init__()
        assert out_channel % 4 == 0
        leaky = 0
        if (out_channel <= 64):
            leaky = 0.1
        self.conv3X3 = conv_bn_no_relu(in_channel, out_channel//2, stride=1)

        self.conv5X5_1 = conv_bn(in_channel, out_channel//4, stride=1, leaky = leaky)
        self.conv5X5_2 = conv_bn_no_relu(out_channel//4, out_channel//4, stride=1)

        self.conv7X7_2 = conv_bn(out_channel//4, out_channel//4, stride=1, leaky = leaky)
        self.conv7x7_3 = conv_bn_no_relu(out_channel//4, out_channel//4, stride=1)

    def forward(self, input):
        conv3X3 = self.conv3X3(input)

        conv5X5_1 = self.conv5X5_1(input)
        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 = F.relu(out)
        return out

class FPN(nn.Module):
    def __init__(self,in_channels_list,out_channels):
        super(FPN,self).__init__()
        leaky = 0
        if (out_channels <= 64):
            leaky = 0.1
        self.output1 = conv_bn1X1(in_channels_list[0], out_channels, stride = 1, leaky = leaky)
        self.output2 = conv_bn1X1(in_channels_list[1], out_channels, stride = 1, leaky = leaky)
        self.output3 = conv_bn1X1(in_channels_list[2], out_channels, stride = 1, leaky = leaky)

        self.merge1 = conv_bn(out_channels, out_channels, leaky = leaky)
        self.merge2 = conv_bn(out_channels, out_channels, leaky = leaky)

    def forward(self, input):
        # names = list(input.keys())
        input = list(input.values())

        output1 = self.output1(input[0])
        output2 = self.output2(input[1])
        output3 = self.output3(input[2])

        up3 = F.interpolate(output3, size=[output2.size(2), output2.size(3)], mode="nearest")
        output2 = output2 + up3
        output2 = self.merge2(output2)

        up2 = F.interpolate(output2, size=[output1.size(2), output1.size(3)], mode="nearest")
        output1 = output1 + up2
        output1 = self.merge1(output1)

        out = [output1, output2, output3]
        return out



class MobileNetV1(nn.Module):
    def __init__(self):
        super(MobileNetV1, self).__init__()
        self.stage1 = nn.Sequential(
            conv_bn(3, 8, 2, leaky = 0.1),    # 3
            conv_dw(8, 16, 1),   # 7
            conv_dw(16, 32, 2),  # 11
            conv_dw(32, 32, 1),  # 19
            conv_dw(32, 64, 2),  # 27
            conv_dw(64, 64, 1),  # 43
        )
        self.stage2 = nn.Sequential(
            conv_dw(64, 128, 2),  # 43 + 16 = 59
            conv_dw(128, 128, 1), # 59 + 32 = 91
            conv_dw(128, 128, 1), # 91 + 32 = 123
            conv_dw(128, 128, 1), # 123 + 32 = 155
            conv_dw(128, 128, 1), # 155 + 32 = 187
            conv_dw(128, 128, 1), # 187 + 32 = 219
        )
        self.stage3 = nn.Sequential(
            conv_dw(128, 256, 2), # 219 +3 2 = 241
            conv_dw(256, 256, 1), # 241 + 64 = 301
        )
        self.avg = nn.AdaptiveAvgPool2d((1,1))
        self.fc = nn.Linear(256, 1000)

    def forward(self, x):
        x = self.stage1(x)
        x = self.stage2(x)
        x = self.stage3(x)
        x = self.avg(x)
        # x = self.model(x)
        x = x.view(-1, 256)
        x = self.fc(x)
        return x

7. 留坑

注意到論文裡面還比較了一下在 RetinaFace 上加入 ArcFace 的實驗結果,仍然還是有提升的,ArcFace 我後面我會仔細講講,今天暫時講到這裡了,如果對你有幫助右下角點贊哦,謝謝。有問題請留言區交流哦。

8. 參考


歡迎關注 GiantPandaCV, 在這裡你將看到獨家的深度學習分享,堅持原創,每天分享我們學習到的新鮮知識。( • ̀ ω•́ )✧

有對文章相關的問題,或者想要加入交流群,歡迎添加 BBuf 微信:

二維碼
為了方便讀者獲取資料以及我們公眾號的作者發布一些 GitHub 工程的更新,我們成立了一個 QQ 群,二維碼如下,感興趣可以加入。

公眾號QQ交流群