导言:这其实是为的毕业设计时做的项目,在不使用openpose官方API,仅使用openpose训练得到的模型进行pose Estimation,这篇paper很牛逼,很巧妙地解决了多人的姿态估计的问题。刚开始的时候(本科毕设)只做到了单人的预测,但是本来模型的提出就是针对多人的预测,所以我那时并没有很好的利用了paper所提供的模型,可以说只用了1/3.为什么说1/3再之后会解释,这篇博客不会纠结太多关于Pose的知识,如果有CPM的基础会更好。CPM在之后我也会跟着出一篇,那篇主要将CPM应用到手势估计中(这就是我毕设的原始课题..)
模型的下载
OpenPose官方github
项目中没有模型权值,下载后会自动运行getModels.sh来得到权值文件。可以从其中扣除链接来下载,也可以在国内的网盘中下载,也可以私信我的email来获取(主页左方)。这篇文章使用pose_iter_440000.caffemodel
这个模型做姿态预测。
Caffe的准备
默认你已经配置好了你的Caffe环境,我们不会使用Pycaffe接口,而直接在源码中添加代码进行预测,如果你是在linux上使用clion,请配置好其对应的cmake文件。
我们的运行文件可以创建在your_caffe_path/examples/
下面,新建一个类名:Regressor
Regressor 类
一些类中的变量
1 | caffe::shared_ptr<Net<float> > net_; |
- inputgeometry 是网络所要求的input image的大小
- img_geometry是原始图像的大小
- numchannels是输入的通道数
- oriImg 是输入图像
- nPoints是结点数量,coco是预测18个节点
构造函数:
1 | Regressor::Regressor(const string& model_file, const string& trained_file) |
构造函数中主要是对权值文件的加载,以及一些变量的设置。
预测接口函数
在PoseRegress函数中主要是运行的第一个入口,在其中利用模型进行了预测。并且对预测结果进行加工返回。1
2
3
4
5
6
7
8
9
10
11
12
13cv::Mat Regressor::PoseRegress(const cv::Mat & img, Skeleton_Info & predictions)
{
img_geometry.width = img.cols;
img_geometry.height = img.rows;
std::vector<float> output = Predict(img, PROB_PIX);//PROB_PIX这里是46,即368/8得到的(3个缩小池化)
cv::Mat output_M = cv::Mat(output);
cv::Mat pre_input = output_M.reshape(0, PROB_PIX * PROB_CHANEL_POSE); //PROB_PIX这里是46,即368/8得到的(3个缩小池化)
this->oriImg = img;
return prossBlob2PoseInfo(pre_input, predictions);
}
预测函数
1 | std::vector<float> Regressor::Predict(const cv::Mat& img, int prob_len) { |
在这里注意的是WrapInputLayer(&input_channels)
和Preprocess(img, &input_channels)
函数,
- 第一:必须将输入image改为caffe的input格式:
N C H W
。并且要归一化至0-1之间。并且要化为Opencv中的RGB顺序即BGR。 - 第二:caffe的input_data都是一维的以行顺序访问的长向量,所以在caffe中很常见一些offset的用法。这一点可以在
WrapInputLayer
中看出:1
2
3
4
5
6
7
8
9
10
11void Regressor::WrapInputLayer(std::vector<cv::Mat>* input_channels) {
Blob<float>* input_layer = net_->input_blobs()[0];
int width = input_layer->width();
int height = input_layer->height();
float* input_data = input_layer->mutable_cpu_data();
for (int i = 0; i < input_layer->channels(); ++i) {
cv::Mat channel(height, width, CV_32FC1, input_data);
input_channels->push_back(channel);
input_data += width * height;
}
}
在为input_layer装载数据指针时,为了能像操作Mat一样操作input_data,caffe灵活运用了c++的指针特性。这之间的原理如图所示
/1.png)
同理,在输出层这个blob的cpu_data()也是一维的长向量,用指针将其读出后装在一个vector中(我采用的是转化为vector然后再转为Mat进行操作,当然最好地一定是按照官方源码一样直接包裹成channels类似于WrapInputLayer)。1
2
3const float* begin = output_layer->cpu_data();
const float* end = begin + output_layer->channels() * prob_len * prob_len;
return std::vector<float>(begin, end);
在caffe中可读可写的指针用input_layer->mutable_cpu_data()获得,只读的用output_layer->cpu_data()获得,gpu数据同理。不能直接读gpu的数据除非是在cuda编程中。需要现将数据换到cpu上在读出。
当数据装载好了后net_->Forward()
开始预测。
视角再回到预测函数PoseRegress这里,为方便阅读我这里再贴下源码:1
2
3
4
5
6
7
8
9
10
11
12
13cv::Mat Regressor::PoseRegress(const cv::Mat & img, Skeleton_Info & predictions)
{
img_geometry.width = img.cols;
img_geometry.height = img.rows;
std::vector<float> output = Predict(img, PROB_PIX);//PROB_PIX这里是46,即368/8得到的(3个缩小池化)
cv::Mat output_M = cv::Mat(output);
cv::Mat pre_input = output_M.reshape(0, PROB_PIX * PROB_CHANEL_POSE);//PROB_PIX这里是46,即368/8得到的(3个缩小池化)
this->oriImg = img;
return prossBlob2PoseInfo(pre_input, predictions);
}
得到Predict(img, PROB_PIX)
返回的向量后我们将其转为Mat,然后reshape一下,这里的reshape可以参考我有一篇专门将reshape的博客,这里最后得到的pre_input的通道数等于原来的output_M的通道数等于1,有46×19×3行,有46列。
处理预测得到的信息
通过论文(Cao_Realtime_Multi-Person_2D_CVPR_2017_paper)可以得到模型的输出是有两个stage:
- 第一个stage是Keypoint的预测
- 第二个则是PAF的预测
对于KeyPoint我相信读者并不陌生,无论有没有看过CPM(当然看过最好)都会很感性地运用kaypoint的数据:对于模型output的前19通道,每一个通道对应18个关节点的热力图,最后一个是背景,值越大probability越大。如果我们只针对的是单人的预测接下来就很好办了只用依次遍历热力图然后找出最大的位置即可(这也是我毕设的做法)但是如果输出帧中有多人怎么处理呢?这才是这篇论文的核心:Multi-Person级的预测。
这里先介绍PAF,刚开始的时候我花非了很大的精力取搞懂PAF是个什么东西,论文中是这么介绍的:
/2.png)
考虑单个的肢,节点$x{j1,k}$ 与$x{j2,k}$组成一个肢体,对于每一个落在该肢体上的点,其PAF定义为一个由$x{j1,k}$ 指向$x{j2,k}$的单位向量。
所以我们得到这个预测信息怎么使用呢?这里类比一下余弦相似度,当且仅当两个向量同向时,其相似度最大,而当一个向量方向越是偏离另一个向量其相似度越低。图PAF2举了一个例子:
/3.png)
$Y{j1,kn}$和$Y{j2,kn}$中
- Y代表预测的值
- j1代表关节点1号类型(瞎举例的类型和原本coco类型没有任何关系),j1,j2构成一个肢体。
- kn代表第几个人,图中假设3个人
版本一:我们只需要计算两两点直接的单位向量(绿色的箭头)然后将该向量与该点所处的PAF向量(红色箭头)做内积,然后选择大的那个点配对就可以找到合法的匹配点。
缺陷:如果直接使用两个预测的端点来计算相似度,那么对于这种情况将会出错:
/4.png)
当两个预测的端点在同一水瓶线时,相似度都是一样的。
版本二:我们两两点中抽样几个点,将总的相似度记为所有抽样点的相似度的和,每一个抽样点的相似度的计算方式就是当前的单位向量($ x{j1,k} $ 指向$$x{j2,k}$$的单位向量),与该点处的PAF值的点积。
/5.png)
蓝色点是抽样点,当计算相似度时,以该点的PAF的值点积单位向量的值,然后加权起来。可以发现如果是水平的情况,中间红色点的PAF值是0,那么就可以轻易区分这种情况了。
解释:很多读者肯定有这样一个疑问:如果这个红点也在黄色区域(PAF不等于0的地方)那么这种情况不会还是分别不出来吗?
针对这种问题我的解释是:
第一,这种绝对水平的情况在真实或者预测中一般不可能出现。即使出现了其PAF的预测值也不可能完全一样,因为预测模型需要具有很强的范化能力,所以不可能预测处一个和PAF的label一样的结果出来。
第二,只要控制好抽样的数量这个问题一定是可以解决的。
prossBlob2PoseInfo
源码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62cv::Mat Regressor::prossBlob2PoseInfo(cv::Mat & probMat, Skeleton_Info & skeletons_people)
{
probMat = probMat.reshape(0,57);
probMat = probMat.t();
probMat = probMat.reshape(57,46);
std::vector<Mat> probChs;
split(probMat, probChs);
for(int i = 0;i<probChs.size();i++){
resize(probChs[i], probChs[i], input_Ori_geometry_);
}
int keyPointId = 0;
std::vector<std::vector<KeyPoint>> detectedKeypoints;
std::vector<KeyPoint> keyPointsList;
for(int i = 0; i < nPoints;++i){
std::vector<KeyPoint> keyPoints;
getKeyPoints(probChs[i],0.1,keyPoints);
for(int i = 0; i< keyPoints.size();++i,++keyPointId){
keyPoints[i].id = keyPointId;
}
detectedKeypoints.push_back(keyPoints);
keyPointsList.insert(keyPointsList.end(),keyPoints.begin(),keyPoints.end());
}
std::vector<cv::Scalar> colors;
populateColorPalette(colors,nPoints);
cv::Mat outputFrame = this->oriImg.clone();
for(int i = 0; i < nPoints;++i){
for(int j = 0; j < detectedKeypoints[i].size();++j){
cv::circle(outputFrame,detectedKeypoints[i][j].point,5,colors[i],-1,cv::LINE_AA);
}
}
std::vector<std::vector<ValidPair>> validPairs;
std::set<int> invalidPairs;
getValidPairs(probChs,detectedKeypoints,validPairs,invalidPairs);
std::vector<std::vector<int>> personwiseKeypoints;
getPersonwiseKeypoints(validPairs,invalidPairs,personwiseKeypoints);
for(int i = 0; i< nPoints-1;++i){
for(int n = 0; n < personwiseKeypoints.size();++n){
const std::pair<int,int>& posePair = posePairs[i];
int indexA = personwiseKeypoints[n][posePair.first];
int indexB = personwiseKeypoints[n][posePair.second];
if(indexA == -1 || indexB == -1){
continue;
}
const KeyPoint& kpA = keyPointsList[indexA];
const KeyPoint& kpB = keyPointsList[indexB];
cv::line(outputFrame,kpA.point,kpB.point,colors[i],3,cv::LINE_AA);
}
}
cv::imshow("Detected Pose",outputFrame);
cv::waitKey(0);
}
找到所有人的对应节点
从18张热力图中我们可以得到每一个节点的热力图,但是单张热力图中包含了多人(数量未知的人)的同一个关节点位置图,在getKeyPoints
函数中就是为了提取出每一个人的同一类关节点。
1 | void Regressor::getKeyPoints(cv::Mat& probMap,double threshold,std::vector<KeyPoint>& keyPoints){ |
/8.png)
这里也可以看出如果只针对单人的预测的话,我们只需要将得到图中最大的那个点的位置就可以了,但是在多人预测中这显然是不行的,我们必须在每一个高斯圆中找到圆中的局部最大点才可以找到所有人的该类的关节点。这里也体现了文章为什么要使用高斯圆的好处,为了形成多个极大值区域,在之后依次对这些区域进行最大值查找。而如果只是一个点的预测的话是无法形成区域的。
读者可能说单点预测的话可以用top(N)来得到前几个最大值的位置。首先这个N是率先无法确定的;如果使用阈值的话,阈值的选取暂且不说,使用高斯圆从大范围锁定极大值点的精确度明显更高。
所以我们需要对于每一个独立的高斯圆构造掩码图,然后使用原来的热力图依次乘以掩码图得到单一的高斯圆的热力图,然后在这里寻找最大值即可。
步骤如下:
- 高斯处理边界(这个和高斯圆没有关系哈)
- Threshold阈值二分,大于阈值的置为255
- 联通域检测,findcounter()
- 利用findcounter返回的每一个独立的连通域进行填充,将每一个边界内的填充为1
cv::fillConvexPoly(blobMask,contours[i],cv::Scalar(1))
- 使用原来的热力图和上一步得到的掩码进行mul操作,(元素对应乘积)
- 寻找每一个最大值。
找寻配对的两点
返回的值是validPairs
:第一维一共18层对应18个关节点,第二维有每个关节类别对应侦测出来的不同节点(比如有4个人的对应的鼻子节点,则有4个Point),每一个节点有其对于的id号起到唯一性标志的作用
1 | void Regressor::getValidPairs(const std::vector<cv::Mat> &netOutputParts, |
组合每一个人体图:
1 | void getPersonwiseKeypoints(const std::vector<std::vector<ValidPair>>& validPairs, |
疑问:如果出现 同一个人的不同limb但是这两个limb没有公共点怎么办?换句话说当前的limb是属于已经侦测但未完全侦测的A人体图,但是该limb和A中已有的点没有公共点,这种情况使得personwiseKeypoints[j][indexA] == localValidPairs[i].aId
不成立,逻辑出错。
/7.png)
解答
- 正是由于这种情况所以必须要按照
posePairs
的顺序进行识别,才能保证下一个limb必和已检测的关节点有公共点。(不懂得可以按照posePairs
的顺序画一下人体支架图) - 如果还出现了这种情况,那么openpose会将其视为另一个人的人体图,意味着这个人体被分成了2部分。但是仔细一样这样的设定也是合理的,因为当人物前后重叠时,这样可以最大限度的区分。图示(问题图1)中的输出人数有7人。
/9.png)问题图1
结果:
/6.png)