一、前言
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这个人。
刚接触这个领域,尝试从这篇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.
一图流就是:
当然不懂的人看这个图也不会懂。请结合以下代码和流程图学习,取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