关键点检测——Openpose源码解析篇

2,117 阅读16分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

🍊作者简介:秃头小苏,致力于用最通俗的语言描述问题

🍊专栏推荐:深度学习网络原理与实战

🍊近期目标:写好专栏的每一篇文章

🍊支持小苏:点赞👍🏼、收藏⭐、留言📩

Openpose源码解析篇

写在前面

Hello,大家好,我是小苏👦👦👦

在上一节中,我已经为大家介绍了Openpose的原理,还不清楚的速速点击下列连接了解详情:

在这一节中,主要来为大家介绍Openpose的源码,着重介绍其标签的构建和网络的搭建。需要注意的是,本节使用的代码并非官方源码,而是复现版本,两种源码地址如下:

Openpose源码复现🌱🌱🌱

Openpose官方源码🌱🌱🌱

下面就和我一起来看看Openpose的源码到底是如何实现的叭~~~🚀🚀🚀

Openpose源码解析

首先,需要大家注意的是,我为大家调试使用的Backbone是VGG19,即使用train_VGG19.py文件进行调试,位置如下:

image-20240426153213033

代码整体训练的框架我就不一步步介绍了,都是网络训练的基础代码,重点将放在原理详解篇着重介绍的三部分,即关键点热图标签构建、连接关系标签构建和Openpose网络模型。🍀🍀🍀


关键点热图标签构建

这部分的实现代码在datasets.py中的get_ground_truth方法中,get_ground_truth方法会接收一个anns参数,这个参数是数据集加载过程中得到的,包含某张图像中人体的标注信息,本次调试图像的image_id为428142,图像如下图所示:

000000428142

其中,anns中一共有14条标注信息,表示标注了14个人体关键点信息。代码中,首先得到网格范围grid_x和grid_y:

 grid_y = int(self.input_y / self.stride)
 grid_x = int(self.input_x / self.stride)

grid_x和grid_y其实就是我们经过VGG特征提取网络得到的特征图的尺寸,self.input_x和self.input_y这里都为368,self.stride表示vgg下采样的倍数,即2^3=8。因此grid_x和grid_y都是46。接着还是一些初始化的工作,如下:

 channels_heat = (self.HEATMAP_COUNT + 1)
 heatmaps = np.zeros((int(grid_y), int(grid_x), channels_heat))

self.HEATMAP_COUNT为18,表示有18个关键点,+ 1 表示背景信息(原理篇有介绍),然后依此构建一个全0的尺寸为46×46×19的heatmaps,用于构建热图标签。

从原理篇我们了解到,原始的coco数据集有17个关键点,而Openpose添加了一个neck关键点,并修改了原始coco关键点的连接顺序,相关代码如下:

 def add_neck(self, keypoint):
     '''
         MS COCO annotation order:
         0: nose         1: l eye        2: r eye    3: l ear    4: r ear
         5: l shoulder   6: r shoulder   7: l elbow  8: r elbow
         9: l wrist      10: r wrist     11: l hip   12: r hip   13: l knee
         14: r knee      15: l ankle     16: r ankle
         The order in this work:
         (0-'nose'   1-'neck' 2-'right_shoulder' 3-'right_elbow' 4-'right_wrist'
         5-'left_shoulder' 6-'left_elbow'        7-'left_wrist'  8-'right_hip'
         9-'right_knee'   10-'right_ankle'   11-'left_hip'   12-'left_knee'
         13-'left_ankle'  14-'right_eye'     15-'left_eye'   16-'right_ear'
         17-'left_ear' )
         '''
     our_order = [0, 17, 6, 8, 10, 5, 7, 9,                  12, 14, 16, 11, 13, 15, 2, 1, 4, 3]
     # Index 6 is right shoulder and Index 5 is left shoulder
     right_shoulder = keypoint[6, :]
     left_shoulder = keypoint[5, :]
     neck = (right_shoulder + left_shoulder) / 2
     if right_shoulder[2] == 2 and left_shoulder[2] == 2:
         neck[2] = 2
     else:
         neck[2] = right_shoulder[2] * left_shoulder[2]
 ​
         neck = neck.reshape(1, len(neck))
         neck = np.round(neck)
         keypoint = np.vstack((keypoint, neck))
         keypoint = keypoint[our_order, :]
 ​
     return keypoint

这段代码也很简单,首先通过左肩和右肩的中点neck = (right_shoulder + left_shoulder) / 2来拟合neck关键点,并将neck关键点添加到原始17个关键点的最后面,最后通过事先定义的顺序(our_order = [0, 17, 6, 8, 10, 5, 7, 9,12, 14, 16, 11, 13, 15, 2, 1, 4, 3])来得到最终的关键点keypoint = keypoint[our_order, :]。这样我们就有18个关键点了,顺序和原理篇介绍的一致,我们也可以来看一下此时keypoints的尺寸,如下图所示:

image-20240426160723044

14表示有14个人体关键点信息,18表示有18个关键点位,3表示x,y,v(横坐标、纵坐标,可见性)。【注意:大家这里可能有疑惑,上面说构建的标签heatmaps有19个通道,这里有只有18个关键点,怎么理解?其实呀,很简单,最后一个通道是背景,我们在处理完关键点后会对背景单独处理,后文再说。🌿🌿🌿】

得到18个关键点后,我们将其经过remove_illegal_joint函数,旨在把一些关键点的x、y坐标不在0和最大尺寸(即input_x或input_y)之间的关键点舍弃掉,代码如下:

 def remove_illegal_joint(self, keypoints):
 ​
     MAGIC_CONSTANT = (-1, -1, 0)
     mask = np.logical_or.reduce((keypoints[:, :, 0] >= self.input_x,
                                  keypoints[:, :, 0] < 0,
                                  keypoints[:, :, 1] >= self.input_y,
                                  keypoints[:, :, 1] < 0))
     keypoints[mask] = MAGIC_CONSTANT
 ​
     return keypoints

接下来这段代码就是构建关键点热图标签的重点啦,如下:

 # confidance maps for body parts
 for i in range(self.HEATMAP_COUNT):
     joints = [jo[i] for jo in keypoints]#每一种关节点
     for joint in joints:#遍历每一个点
         if joint[2] > 0.5:#1是标注被遮挡 2是标注且没被遮挡
             center = joint[:2]#点坐标
             gaussian_map = heatmaps[:, :, i]
             heatmaps[:, :, i] = putGaussianMaps(
                 center, gaussian_map,
                 7.0, grid_y, grid_x, self.stride)

首先我们会遍历每种关键点,比如,i=0时,joints表示图片中所有的nose关键点的信息;i=1时,joints表示图片中所有的neck关键点的信息,依此类推。我们之前说了,本次调试的anns有标注14个人体,因此这里的joints有14个值,分别表示14个人体的nose关键点信息(i=0时),如下图所示:

image-20240426163119024

接着我们会遍历每个关键点,并找到标注了的关键点。(关键点坐标三个一组,分别表示横坐标、纵坐标和可见性,可见性为0表示没有标注该关键点,可以从上图看到很多关键点可见性为0,即该关键点是没有标注的)当遍历到第三个关键点时,可见性为2,此时获取关键点的坐标center = joint[:2],并且通过切片得到一个第i个通道的高斯图:gaussian_map = heatmaps[:, :, i],其初始为46×46大小的全0数组,如下:

image-20240426163950280

得到这些值后,会将他们作为参数送入putGaussianMaps函数中,用于构建热图标签,putGaussianMaps函数代码如下:

 def putGaussianMaps(center, accumulate_confid_map, sigma, grid_y, grid_x, stride):
 ​
     start = stride / 2.0 - 0.5
     y_range = [i for i in range(int(grid_y))]
     x_range = [i for i in range(int(grid_x))]
     xx, yy = np.meshgrid(x_range, y_range)
     xx = xx * stride + start
     yy = yy * stride + start
     d2 = (xx - center[0]) ** 2 + (yy - center[1]) ** 2
     exponent = d2 / 2.0 / sigma / sigma
     mask = exponent <= 4.6052
     cofid_map = np.exp(-exponent)
     cofid_map = np.multiply(mask, cofid_map)
     accumulate_confid_map += cofid_map # 多个点会叠加的
     accumulate_confid_map[accumulate_confid_map > 1.0] = 1.0
     
     return accumulate_confid_map

这里的热图构建方式和HRNet中有所不同,我们一起来学习一下这种方式,感兴趣的也去比较比较这两种方式的不同之处。🥗🥗🥗

首先,我们会设置以一个偏移量start,它为stride / 2.0 - 0.5=8/2-0.5=3.5,然后构建一个网格范围,x_range和y_range的值为0-45范围内的整数,如下:

image-20240426164643633

x_range和y_range构建成了一个棋盘网格,大致图像如下,只画了0-3范围内的数,实际是0-45。

接着会将棋盘上的值映射到原图尺寸,如下:

 xx = xx * stride + start
 yy = yy * stride + start

这两行代码我真的理解了很长时间,这里只谈谈我的理解,如果有误希望各位大佬批评指正。首先xx = xx * strideyy = yy * stride,我想这个大家是非常容易理解的,就是将我们的棋盘上的值映射到原图的尺寸上嘛,stride=8,因为这里原图和特征图之前的尺寸相差8倍。但是后面为什么会加上start,而start=stride / 2.0 - 0.5=3.5,这让我百思不得起解。我也尝试问了问GPT,它的回答是:加上 + start 的偏移量能够将网格点的坐标对齐到图像的像素中心,但是我始终无法Get到这句话的意思,经过不断的画图,思考,我似乎有了点眉目,这里用图来为大家展示一下,就很好理解了。


首先,为了画图方便,我们假设原图的大小为32×32,那么按照代码中的思想,grid_x和grid_y都为32/8=8,那么x_range和y_range的范围则是0-3,我们绘制出其网格,如下:

image-20240426170313387

首先我们来执行xx = xx * strideyy = yy * stride这两句代码,这两句是将上述网格映射到32×32大小的原图上,如下:

image-20240426170625956

上图右边图像中,蓝色背景表示图像的尺寸,可以看到,从网格映射到图像,很多区域出现空白,映射区域是从图像左上角开始的,直观上就感觉这种方式不好。然后我们来看看加上+ start的效果,如下:

加上start,映射后的结果都会集中分布在图像的中心,这种映射方式更好。


理解了上面两句代码,后面就是依据高斯分布公式来构建高斯核了,代码和公式的对应关系如下图所示:

image-20240426172537284

注意到上图还有一行代码mask = exponent <= 4.6052,然后配合下面的cofid_map = np.multiply(mask, cofid_map),这两句是用来限制高斯核的,就是让其在一定范围内就截断。什么意思呢,就是高斯核是中间大,越往四周越小,这句就是要把小到一定范围的值直接变成0。我们可以来看看高斯核cofid_map的值,如下图所示:

image.png 接着执行accumulate_confid_map += cofid_map,这句就是按照原理篇的思想,如果有多个关键点,则热图进行累计,比如我在调试一次,热图会变成如下图所示的样子,即新增了一个关键点:

image-20240426173559524

最后还有一句accumulate_confid_map[accumulate_confid_map > 1.0] = 1.0是为了防止累加过程中热图值大于1(因为有的点距离较近,值相加会超过1),即设置最大值为1。


在最后,会在热图的最后一个位置加上背景信息,如下:

 # background
 heatmaps[:, :, -1] = np.maximum(
     1 - np.max(heatmaps[:, :, :self.HEATMAP_COUNT], axis=2),
     0.
 )

PAF标签构建

首先会有一些初始变量的设置,如下:

 channels_paf = 2 * len(self.LIMB_IDS)
 pafs = np.zeros((int(grid_y), int(grid_x), channels_paf))

其中,self.LIMB_IDS为19,表示有19个骨骼,则channels_paf为38,表示PAF标签的通道数。pafs为初始化的一个全0的PAF标签数组,尺寸为46×46×38,如下图所示:

image-20240426192239181

接着就是构建PAF标签了,代码如下:

 # pafs
 for i, (k1, k2) in enumerate(self.LIMB_IDS):
     # limb
     count = np.zeros((int(grid_y), int(grid_x)), dtype=np.uint32) # 表示该位置是否被计算了多次(计算的数量)
     for joint in keypoints:
         if joint[k1, 2] > 0.5 and joint[k2, 2] > 0.5:
             centerA = joint[k1, :2]
             centerB = joint[k2, :2]
             vec_map = pafs[:, :, 2 * i:2 * (i + 1)] #每一个躯干位置,选择x和y两个方向
 ​
             pafs[:, :, 2 * i:2 * (i + 1)], count = putVecMaps(
                 centerA=centerA,
                 centerB=centerB,
                 accumulate_vec_map=vec_map,
                 count=count, grid_y=grid_y, grid_x=grid_x, stride=self.stride
             )

首先,我们会遍历所有的骨骼,self.LIMB_IDS是一个列表,一共19个值,表示19个骨骼,这19个值也是由列表构成,列表有两个元素,这两个元素表示两个关键点索引,这个列表表示将这两个关键点连接起来。self.LIMB_IDS的值如下:

image-20240426193156782

比如上图的[1,8]表示将脖子和右臀部相连,[8,9]表示右臀部和右膝盖相连,连接规则与原理篇一致。接着我们会定义一个计数数组count,尺寸为46×46,用于计算某位置是否被计算了多次。

然后我们会遍历关键点,并判断k1和k2关键点是否都可见。【k1和k2就是self.LIMB_IDS中子列表的两个值,比如第一次遍历k1和k2分别为1和8】当k1和k2都可见时,我们取两个关键点的坐标centerA和centerB。以此次调试结果为例,当第一次遍历骨骼,i=0,即当k1和k2分别为1和8时,可以获取到centerA和centerB的坐标,如下:

image-20240426194620980

接着会通过vec_map = pafs[:, :, 2 * i:2 * (i + 1)]切片获取骨骼[1,8]的PAF数组,此时vec_map的尺寸为46×46×2。其中,2表示x,y两个坐标值。这步结束之后,一切准备工作就完成了,通过putVecMaps构建PAF标签,代码如下:

 def putVecMaps(centerA, centerB, accumulate_vec_map, count, grid_y, grid_x, stride):
     centerA = centerA.astype(float)
     centerB = centerB.astype(float)
 ​
     thre = 1  # limb width
     centerB = centerB / stride #映射到特征图中
     centerA = centerA / stride
 ​
     limb_vec = centerB - centerA
     norm = np.linalg.norm(limb_vec)#求范数
     if (norm == 0.0):
         # print 'limb is too short, ignore it...'
         return accumulate_vec_map, count
     limb_vec_unit = limb_vec / norm #单位向量
     # print 'limb unit vector: {}'.format(limb_vec_unit)
 ​
     min_x = max(int(round(min(centerA[0], centerB[0]) - thre)), 0)  # 得到所有可能区域
     max_x = min(int(round(max(centerA[0], centerB[0]) + thre)), grid_x)
     min_y = max(int(round(min(centerA[1], centerB[1]) - thre)), 0)
     max_y = min(int(round(max(centerA[1], centerB[1]) + thre)), grid_y)
     # To make sure not beyond the border of this two points
 ​
 ​
     range_x = list(range(int(min_x), int(max_x), 1))
     range_y = list(range(int(min_y), int(max_y), 1))
     xx, yy = np.meshgrid(range_x, range_y)
     ba_x = xx - centerA[0]  # the vector from (x,y) to centerA 根据位置判断是否在该区域上(分别得到X和Y方向的)
     ba_y = yy - centerA[1]
     limb_width = np.abs(ba_x * limb_vec_unit[1] - ba_y * limb_vec_unit[0]) #向量叉乘根据阈值选择赋值区域,任何向量与单位向量的叉乘即为四边形的面积
     mask = limb_width < thre  # mask is 2D # 小于阈值的表示在该区域上
 ​
     vec_map = np.copy(accumulate_vec_map) * 0.0 #本次计算
 ​
     vec_map[yy, xx] = np.repeat(mask[:, :, np.newaxis], 2, axis=2)
     vec_map[yy, xx] *= limb_vec_unit[np.newaxis, np.newaxis, :] #在该区域上的都用对应的方向向量表示(根据mask结果表示是否在)
 ​
     mask = np.logical_or.reduce(
         (np.abs(vec_map[:, :, 0]) > 0, np.abs(vec_map[:, :, 1]) > 0)) #在特征图中(46*46)中 哪些区域是该躯干所在区域
 ​
     accumulate_vec_map = np.multiply(
         accumulate_vec_map, count[:, :, np.newaxis]) #每次返回的accumulate_vec_map都是平均值,现在还原成实际值
     accumulate_vec_map += vec_map # 加上当前关键点位置形成的向量
     count[mask == True] += 1 # 该区域计算次数都+1
 ​
     mask = count == 0
 ​
     count[mask == True] = 1 # 没有被计算过的地方就等于自身(因为一会要除法)
 ​
     accumulate_vec_map = np.divide(accumulate_vec_map, count[:, :, np.newaxis])# 算平均向量
     count[mask == True] = 0 # 还原回去
 ​
     return accumulate_vec_map, count

这段代码和我们原理详解篇表达的含义一致,但是在实现上与原理稍有差别。首先我们会把关键点的坐标映射到特征图上,然后再求出两个关键点之间的单位向量limb_vec_unit。这里也一步步画图帮大家理解一下:【这步就是构建了一个单位向量AB,这里为了方便画图,假设AB就是单位向量了】

image-20240426200558575

然后是这段代码:

 min_x = max(int(round(min(centerA[0], centerB[0]) - thre)), 0)  # 得到所有可能区域
 max_x = min(int(round(max(centerA[0], centerB[0]) + thre)), grid_x)
 min_y = max(int(round(min(centerA[1], centerB[1]) - thre)), 0)
 max_y = min(int(round(max(centerA[1], centerB[1]) + thre)), grid_y)

上述代码其实就是根据A、B的坐标得到一个AB骨骼附近的区域,如下图所示:

image-20240426201333718

上图黑框表示特征图的边界,红框表示上述代码选取的区域。接着这三句代码是关键:

 ba_x = xx - centerA[0] 
 ba_y = yy - centerA[1]
 limb_width = np.abs(ba_x * limb_vec_unit[1] - ba_y * limb_vec_unit[0])

这三句代码是什么意思呢,其实它表示特征图中有一点C(xx,yy),然后向量AB和向量AC的叉乘的绝对值,而我们在数学中学过,向量叉乘的几何意义是两个向量组成四边形的面积。【对向量叉乘不清楚的自己去搜搜叉乘的代数和几何意义】🥗🥗🥗

这样一来limb_width其实就是向量AB和向量AC构成平行四边形的面积,但是呢,AB是单位向量,它的模长是1,因此平行四边形的面积就等于平行四边形以AB为底的高,如下图所示:

image-20240426203230584

有了limb_width,我们就根据limb_width与阈值thre=1的关系来定义范围,确认哪些位置是与AB同方向的标签,哪些位置是0向量。

image-20240426203653673

上图C的位置,AC与AB构成的平行四边形面积大于阈值1,因此C为0向量。【注意:这里的图的尺寸不标准,理解意思就行】

image-20240426204013146

上图这个C的位置,AC与AB构成的平行四边形面积小于阈值1,因此C的方向由A指向B。你会发现,最后非0向量的C点都在下图两条平行线之间:

image-20240426204544167

上述所说就是PAF的构建过程,后面还有一些代码,其实就是当图片中出现多个骨骼重叠的情况时,对PAF标签的修改,其实就是取平均,大家调试一下代码,应该就会明白,这里就不过多叙述了。但是有一个点需要提醒大家注意一下,就是如果有多个数求均值时,这里拿3个数为例,我们需要把三个数相加再除以3,而不能两个两个相加除以2,比如求1、2、3的均值应该为\frac{1+2+3}{3}=2,而不能是\frac{\frac{1+2}{2}+3}{2}=2.25,明白这一点上述代码相信你就没问题啦。🥗🥗🥗

Openpose网络模型

Openpose的网络模型真的很简单,首先是一个Backbone,本文采用的是VGG19,主要结构如下,VGG19会将原图下采样8倍得到特征图,即网络结构中存在3个MaxPool2d层。

image-20240426211715887

接着会分别送入Stage 1 中的Branch 1 和Branch 2,Branch 1的模型结构如下,最后一个卷积有19个卷积核,表示18个关键点 + 1个背景输出。

image-20240426212105274

Branch 2的模型结构如下,最后一个卷积有38个卷积核,表示19个骨骼,每个骨骼有x,y两个输出值。

image.png

得到Branch 1和Branch 2的输出后,将输入特征图和Branch 1、Branch 2在通道方向拼接,得到新的输出特征图,相关代码如下:

 out2 = torch.cat([out1_1, out1_2, out1], 1)

然后将这个新的输出特征图作为Stage 2的输入继续堆积网络,一共堆积6个Stage。这些就是Openpose网络的模型啦,是不是很简单呢~~~🍋🍋🍋

小结

呼呼呼~~~源码详解篇终于介绍完啦🍚🍚🍚,至此,人体姿态估计之关键点检测任务到这里也告一段落啦,相信阅读了这个系列的博客,你已经对关键点检测有了一定的了解,再接触其它的模型理解起来也肯定会更加容易一点。如果还想我更新其它的关键点检测模型,也欢迎评论区留言,有时间一定更新。🍄🍄🍄

拜拜啦,我们下期见~~~🖐🖐🖐

如若文章对你有所帮助,那就🛴🛴🛴

         一键三连 (1).gif