FAN- Face Alignment Network

 

一、前言

 Face Alignment Network

《How far are we from solving the 2D & 3D Face Alignment problem? (and a dataset of 230,000 3D facial landmarks)》

主要记一下Adrian Bulat这个人。

论文:https://arxiv.org/abs/1703.07332

作者主页:https://www.adrianbulat.com/

 

刚接触这个领域,尝试从这篇17年的工作开始阅读。

FaceAlignment,直译人脸对齐。(不知道为什么默认就人脸了,似乎其他生物的脸被忽略了)

做人脸对齐的关键步骤在于facial landmark localization,即面部关键点定位。更具体一点还要分2D和3D。

看上去似乎自从16~17达到顶峰之后,研究热度就没那么高了。

想一想iPhone什么时候引入的面部解锁,什么时候直播软件、抖音、美图广泛引入美颜滤镜。脸萌走红又暴死的年份,微信最美制服照活动刷屏的年份。

 

 

二、2个前置工作。

 

2.1 HourGlass沙漏模型

《Stacked hourglass networks for human pose estimation》 ECCV, 2016.

A. Newell, K. Yang, and J. Deng.

https://arxiv.org/pdf/1603.06937.pdf

一图流就是:

当然不懂的人看这个图也不会懂。请结合以下代码和流程图学习,取depth=4。

#pytorch版本1.3.1通过

class HourGlass(nn.Module):
    def __init__(self, num_modules, depth=4, num_features):
        super(HourGlass, self).__init__()
        self.num_modules = num_modules#这个参数根本用不到
        self.depth = depth
        self.features = num_features

        self._generate_network(self.depth)

    def _generate_network(self, level):
        self.add_module('b1_' + str(level), ConvBlock(self.features, self.features))

        self.add_module('b2_' + str(level), ConvBlock(self.features, self.features))

        if level > 1:
            self._generate_network(level - 1) #用递归写感觉上不舒服
        else:
            self.add_module('b2_plus_' + str(level), ConvBlock(self.features, self.features))

        self.add_module('b3_' + str(level), ConvBlock(self.features, self.features))

    def _forward(self, level, inp):
        # Upper branch
        up1 = inp
        up1 = self._modules['b1_' + str(level)](up1)

        # Lower branch
        low1 = F.avg_pool2d(inp, kernel_size=2, stride=2)
        low1 = self._modules['b2_' + str(level)](low1)

        if level > 1:
            low2 = self._forward(level - 1, low1) #又来递归
        else:
            low2 = low1
            low2 = self._modules['b2_plus_' + str(level)](low2)

        low3 = low2
        low3 = self._modules['b3_' + str(level)](low3)

        up2 = F.interpolate(low3, scale_factor=2, mode='nearest')

        return up1 + up2

    def forward(self, x):
        return self._forward(self.depth, x)

2.2 H,P&MS Block (Hierarchical Parallel and Multi-Scale Block)

名字很浮夸......仿佛看到了英文版的“超级无敌究极宇宙第一”这种前缀。

如其名彰显的,是一种基础的残差块结构,可以类比为Resnet里的Bottleneck。

是Adrian Bulat这个人自己的另一份工作成果,属于“我引用我自己”。

《Binarized Convolutional Landmark Localizers for Human Pose Estimation and Face Alignment with Limited Resources》

https://arxiv.org/pdf/1703.00862v2.pdf

除了他自己好像么见过别人用这种残差块了,所以原论文可以不用读......

很奇怪的是,这个人都是先BatchNorm,再Conv,俺也不知道为什么他要这么做。 

核心思路如图,约定好out_channel为某个常数,然后中间层分别输出通道out/2,out/4,out/4,最后concat得到通道数out。

再与经过downsample通道数也为out的input相加。(当且仅当in_channel!=out_channel时,需要downsample)

 

---插播一个题外话

搜索相关信息的时候正好看到某自媒体X智元的旧闻。

【世界最大人脸对齐数据集】ICCV 2017:距离解决人脸对齐已不远

看来当时的小编没有发现并不是“其他研究人员”,完全是他本人。

 

三、核心网络

 

上述2个前置工作与核心网络的关系体现在。

1) A.B.先生把HourGlass沙漏网络里的所有基础块都替换成了他自己H,P,M&S Block。引用自己,这很合理。

2) A.B.先生使用自己的MS Block改装后的全新版本HourGlass,又搭配了几个自己的MS Block,组成了最终的核心网络FAN。这同样很合理,值得学习。这样大家如果引用了FAN就得顺便引用一下HPMSB那篇。所以说提出一种基础block可以做到一次发明终身受益。

核心网络:

 开头一段从通道3到通道256的处理,经过一次k=7,stride=2和一次avgpool,缩放1/4。

 

中间部分是一个循环。

若只有一个工作层,直接输出heatmap_0就结束了。

如果有多个工作层,每2层之间要进行一次“过渡操作”,即取本层特征图(BN结果)卷一下+本层输出(heatmap_i)卷一下+本层输入(input_i),相加的结果,作为下一层输入input_i+1。

 

最终得到k张heatmap。

 

 

四、数据处理的关键步骤

 

4.1 target_heatmap的获得步骤

约定好输入图片的尺寸size=256,heatmap的尺寸heat=64,正好形成1/4关系。

获得原始图像的高宽,h,w,c=img.shape

缩放原始图像 img = cv2.resize(img, (size, size)) #到(size,size)

等比例缩放标注landmark*=np.array([heat/w,heat/h]),相当于把标注缩到了(size/4,size/4)的图片上。

然后heatmap = np.zeros((heat,heat,n_landmarks))

再根据landmark的位置,将对应方格置为1。

于是得到了target_heatmap。

 

上文说过经核心网络FAN,输出的pred_heatmap尺寸正好是(size/4,size/4) =(heat,heat),

跟我们利用标注生成的target尺寸一致。

于是可以进行逐pixel的MSE计算。

for heatmap in heatmaps:
    #逐pixel与标注的landmarks计算MSE
    loss += get_MSE(heatmap,target)

输入数据的处理思想,就是无脑resize到一个正方形,例如源代码的默认值size=256。

当然实际上还有一些旋转,噪声,翻转,这些属于锦上添花。

这样坏处显而易见,对一些长宽比不接近1:1的图片,无脑resize可能反而会破坏图像原本的形状特征导致不可识别。

即这种模型默认了输入是接近正方形的。

仗着训练时有bbox帮助crop,人的头部作为类球体,任意角度拍摄都应该含于近似正方形的框。

这很合理。但我不喜欢。也不方便迁移。

如果没有bbox让你crop,直接16:9整张图片输入,还能resize到正方形吗?

 

 

4.2 对prediction的处理

按源代码,预测的时候,我们对k张输出的heatmap,只取最后一张。(这同样很合理)

prediction = output[-1]  #(bz,n_ldmk,heat,heat)

 

然后采用取找最大值的方式,从heatmap中拿到landmark

#这似乎是原作者采用的函数
#heatmap通道数是n_landmark
#每个通道即submap中取数值最大的点,作为一个landmark预测值。
#总计获得n_landmark个(x,y)坐标。
#对上ratio缩放
def get_pts(heatmaps, ratio):
    ## Get landmarks from heatmaps with scaling ratios ##
    ## heatmaps: N x L x H x H ##
    ## ratio: N ##

    # Get x and y of landmarks
    lmx = torch.argmax(torch.max(heatmaps, dim=2)[0], dim=2).type(torch.cuda.FloatTensor)
    lmy = torch.argmax(torch.max(heatmaps, dim=3)[0], dim=2).type(torch.cuda.FloatTensor)

    # Stack them and scale with ratios
    landmarks = torch.stack((lmx,lmy), dim=2)
    landmarks *= ratio
    return landmarks

 

4.3 evaluation做法

基于Heatmap的pose estimation似乎已经广泛应用了。

这种算法的本质还是CNN的老问题,size不敏感,这导致了对位置信息的读取还是不够。

所以不能通过几个FC来直接输出2个int型变量x,y。我试了一下当成纯回归来做,效果非常差。

改用heatmap之后就变成了“另类”的分类问题。在一张pred_heatmap找一个预测的概率值最大的点,输出的就是概率值而不是回归值。

最终x,y来自点的坐标,而非网络的输出本身。

所以训练的时候不可能拿x,y的坐标去算loss,因为x,y身上不绑定梯度,没法BP。

 

但是,最终evaluation的计算却又可以当成回归来算NME,因为eval过程不需要BP,可以提取x,y坐标值,与landmark标注坐标算距离。

这很灵性。

图像任务或者说CNN的特殊性导致了train和eval可以用2种截然不同思路的效果衡量方式。

#eval过程的评价指标计算
def NME(preds, targets, boxes):
    ## Compute Normalized Mean Error using predicted heatmaps, ground-truth landmarks and bounding boxes ##
    ## preds: N x L x H x H; N = batch size; L = number of landmarks; H = heatmap dimension
    ## targets: N x L x 2; [x y]
    ## boxes: N x 2 x 2; [top-left bottom-right]; [x y]

    D = torch.squeeze(boxes[:, 1] - boxes[:, 0] + 1, dim=1)  # N x 2
    w, h = D[:, 0], D[:, 1] 
    RAs = torch.sqrt(w*h)  # Square root of box area, N
    if len(preds.shape)>3 :
        ratios = D.unsqueeze(dim=1)/preds.shape[2]  # scale, from 64 to w, h
        preds = get_pts(preds, ratios)  # convert heatmaps to landmarks
    mse = torch.sqrt((preds - targets)**2).sum(2) # L2-norm / sum-squared error; N x L
    nme = torch.mean(mse/RAs.reshape(-1,1)) # N x L divide N
    return float(nme) 

 

 

4.4 预测结果修正

 

由于FAN是用original_img*scale -> input_img(H,W)  经过模型-> heatmap(H/4,W/4) -> 得到pred_landmark (x,y)

在低尺寸上预测出坐标(x,y),而实际场景中还需要还原到original_img上。

最简单的方式是 (x,y)*4/scale。

但是这样会带来精度损失

例如原图上的坐标(15,15)和(12,12),经过scale=1/4后,再取整,都会得到(3,3)。

而我们利用一个预测出来的(3,3),若简单地*4,得到(12,12),可能会偏左上方一点,实际坐标值也许是(15,15)。

 

如下图所示,原图蓝色区域内的任意一个方块,都可以浓缩到小尺度heatmap的坐标(3,3)方块上。

而我们不知道(3,3)这个方块,在蓝色区域[12~15,12~15]内的offset。

 

表现在具体任务上。

可以看到offset信息的缺失,会让结果产生巨大的偏差,极大的影响准确率。 

尤其是缩放比例scale越大的情况下,最终误差越大。

设缩放倍数= n,n为正整数,则从x=3可以映射的范围是[3*n,3*n+(n-1)],最大的坐标偏差值max_diff = n-1。

 

怎么解决offset这个face alignment领域的“最后一公里”问题呢?

 

问题定义:我们希望对每一个预测关键点pred_landmark_i =(x,y),得到一个offset_i=(x_offset_i,y_offset_i),

使得gt_landmark == pred_landkmark_i + off_set_i 。

 

原作者A.B.先生的代码里这样处理的。

对于预测出来的(pred_x,pred_y),提取heatmap上其左右两个相邻点,即点(pred_x-1,pred_y)与点(pred_x+1,pred_y)。

再提取上下两个相邻点,即(pred_x,pred_y-1)与(pred_x,pred_y+1)。

然后右减左,下减上,分别通过示性函数sign_(),若大于0得到1,小于0得到-1。

最后乘以补偿系数0.25。即,如果heatmap上右侧概率值大于左侧,则pred_x += 0.25*1,反之 pred_x += 0.25*(-1)。

pred_y同理处理。

def get_preds_fromhm(hm, center=None, scale=None):
    """Obtain (x,y) coordinates given a set of N heatmaps. If the center
    and the scale is provided the function will return the points also in
    the original coordinate frame.
    Arguments:
        hm {torch.tensor} -- the predicted heatmaps, of shape [B, N, H, W]
    Keyword Arguments:
        center {torch.tensor} -- the center of the bounding box (default: {None})
        scale {float} -- face scale (default: {None})
    """
    hi = hm.shape[2] 
    wi = hm.shape[3]
    
    max, idx = torch.max(
        hm.view(hm.size(0), hm.size(1), hm.size(2) * hm.size(3)), 2)
    idx += 1
    preds = idx.view(idx.size(0), idx.size(1), 1).repeat(1, 1, 2).float()
    preds[..., 0].apply_(lambda x: (x - 1) % hm.size(3) + 1)
    preds[..., 1].add_(-1).div_(hm.size(2)).floor_().add_(1)

    #polish
    for i in range(preds.size(0)):
        for j in range(preds.size(1)):
            hm_ = hm[i, j, :]
            pX, pY = int(preds[i, j, 0]) - 1, int(preds[i, j, 1]) - 1
            if pX > 0 and pX < wi-1 and pY > 0 and pY < hi-1:
                diff = torch.FloatTensor(
                    [hm_[pY, pX + 1] - hm_[pY, pX - 1],
                     hm_[pY + 1, pX] - hm_[pY - 1, pX]])
                preds[i, j].add_(diff.sign_().mul_(.25))

    preds.add_(-.5)

    preds_orig = torch.zeros(preds.size())
    if center is not None and scale is not None:
        for i in range(hm.size(0)):
            for j in range(hm.size(1)):
                preds_orig[i, j] = transform(
                    preds[i, j], center, scale, hm.size(2), True)

    return preds, preds_orig

 

我测试了这个函数,在“最后一公里”问题上效果不是很好。

这个方法的缺点首先在于,补偿系数是完全固定的。

我们有nlandmarks个关键点,每个点的offset都一样吗?这显然不太合理。

其次,最后一公里问题上应该是不存在负值的,全部offset应该都是相对于 (x*n,y*n)的偏移。

但我们可以借鉴A.B.先生的这种思想。

 

考虑一种极端情况,我们想预测一只眼睛的landmark,它真实位于原图的(16,16)处。

那么缩小后, 对应点(4,4)。 而左侧点(4,3)占有的相关信息量,即预测的heatmap值,应该无限趋近点(4,4)。

即heatmap[4,3]/heatmap[4,4] 趋于1时,offset_x趋于0。此时heatmap[4,5]/heatmap[4,4]趋于0。

 

另一种极端情况是,左侧点heatmap[4,3]趋于0,而右侧点heatmap[4,5]近似等于heatmap[4,4]。

此时应该认为offset_x趋于n。

 

还有一种就是左右点占有信息量近似相等,则认为offset=0.5*n。

 

我们提炼一下规则。 

左1右0,offset=0;

左右相等,offset = 0.5n;

左0右1,offset=n;

 写成算法形式

设左侧点hm_left,右侧点hm_right,中间点hm_center。

offset_x_default = 0.5*n,

diff_percent = (hm_right/hm_center-hm_left/hm_center) = (hm_right-hm_left)/hm_center,范围(-1,1)

offset_x = offset_x_default +diff_percent*0.5*n,

=0.5*n+diff_percent*0.5*n

= 0.5*n(1+diff_percent),范围(0,n)

 这看起来很合理。

 

这样得到的offset带小数,正是我所期望的。

如果要打印在图片上,自然需要先取整。

但是如果用来打比赛刷排行榜,有小数就非常重要了,有效逼近精度。

 

示例代码

def get_landmarks(heatmaps, ratio):
    # Get landmarks from heatmaps with scaling ratios
    # heatmaps: Batchsize x N_landmarks x H_h x H_w
    # ratio: n = original_size / heatmap_size
    # select one max-point from each heatmap 
    # qq
    
    H_h,H_w = heatmaps.shape[2],heatmaps.shape[3]
    
    # Get x and y of landmarks
    lmx = torch.argmax(torch.max(heatmaps, dim=2)[0], dim=2) #(b,n_landmk)
    lmy = torch.argmax(torch.max(heatmaps, dim=3)[0], dim=2) #(b,n_landmk)

    # Stack them
    landmarks = torch.stack((lmx,lmy), dim=2) #(b,n_landmk,2)
    
    # Cal offset
    offsets = torch.zeros(heatmaps.shape[0],heatmaps.shape[1],2).float() #(b,n_landmk,2)
    offset_default = 0.5*ratio #0.5n as default
    
    for i in range(heatmaps.shape[0]):
        for j in range(heatmaps.shape[1]):
            hm_ = heatmaps[i,j,:,:] #(H_h,H_w)
            px,py = landmarks[i,j,0],landmarks[i,j,1]
            hm_center = hm_[py,px]
            
            if px>0 and py>0 and py<H_h-1 and px<H_w-1:
                diff_percent_x = (hm_[py,px+1]-hm_[py,px-1])/hm_center
                diff_percent_y = (hm_[py+1,px]-hm_[py-1,px])/hm_center
                offsets[i,j,0] = (diff_percent_x+1)*offset_default
                offsets[i,j,1] = (diff_percent_y+1)*offset_default
            else:
                #out of bound,set to default
                offsets[i,j,:] += offset_default
        
    # Scale with ratio
    landmarks = landmarks.float()*ratio
    
    # Add offset
    landmarks += offsets
    
    return landmarks

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值