头部姿态估计 - OpenCV/Dlib/Ceres
基本思想
通过dlib获得当前人脸的特征点,然后通过旋转平移标准模型的特征点进行拟合,计算标准模型求得的特征点与dlib获得的特征点之间的差,使用ceres不断迭代优化,最终得到最佳的旋转和平移参数。
使用环境
系统环境:ubuntu 18.04
使用语言:c++
编译工具:cmake
第三方工具
dlib:用于获得人脸特征点
ceres:用于进行非线性优化
cminpack:用于进行非线性优化 (optional)
源代码
https://github.com/great-keith/head-pose-estimation
基础概念
旋转矩阵
头部的任意姿态可以转化为6个参数(yaw, roll, pitch, tx, ty, tz),前三个为旋转参数,后三个为平移参数。
平移参数好理解,原坐标加上对应的变化值即可;旋转参数需要构成旋转矩阵,三个参数分别对应了绕y轴旋转的角度、绕z轴旋转的角度和绕x轴旋转的角度。
具体代码实现我们可以通过dlib已经封装好的api,rotate_around_x/y/z(angle)
。该函数返回的类型是dlib::point_transform_affine3d
,可以通过括号进行三维的变形,我们将其封装成一个rotate函数使用如下:
void rotate(std::vector<point3f>& points, const double yaw, const double pitch, const double roll) { dlib::point_transform_affine3d around_z = rotate_around_z(roll * pi / 180); dlib::point_transform_affine3d around_y = rotate_around_y(yaw * pi / 180); dlib::point_transform_affine3d around_x = rotate_around_x(pitch * pi / 180); for(std::vector<point3f>::iterator iter=points.begin(); iter!=points.end(); ++iter) *iter = around_z(around_y(around_x(*iter))); }
[note] 其中point3f是我自己定义的一个三维点坐标类型,因为dlib中并没有提供,而使用opencv中的cv::point3f会与dlib::point定义起冲突。定义如下:
typedef dlib::vector<double, 3> point3f;
[note] dlib中的dlib::vector不是std::vector,注意二者区分。
lm算法
这边不进行赘诉,建议跟着推导一遍高斯牛顿法,lm算法类似于高斯牛顿法的进阶,用于迭代优化求解非线性最小二乘问题。在该程序中使用ceres/cminpack封装好的api(具体使用见后文)。
三维空间到二维平面的映射
根据针孔相机模型我们可以轻松的得到三维坐标到二维坐标的映射:
\(x^{2d}=f_x(\frac{x^{3d}}{z^{3d}})+c_x\)
\(y^{2d}=f_y(\frac{y^{3d}}{z^{3d}})+c_y\)
[note] 使用上角标来表示3维坐标还是2维坐标,下同。
其中\(f_x, f_y, c_x, c_y\)为相机的内参,我们通过opencv官方提供的calibration样例进行获取:
例如我的电脑所获得的结果如下:
从图中矩阵对应关系可以获得对应的参数值。
#define fx 1744.327628674942 #define fy 1747.838275588676 #define cx 800 #define cy 600
[note] 本程序不考虑外参。
具体步骤
获得标准模型的特征点
该部分可见前一篇文章:bfm使用 - 获取平均脸模型的68个特征点坐标
我们将获得的特征点保存在文件 landmarks.txt
当中。
使用dlib获得人脸特征点
该部分不进行赘诉,官方有给出了详细的样例。
具体可以参考如下样例:
- (通过这个样例可以学习opencv如何调用摄像头)
其中使用官方提供的预先训练好的模型,下载地址:
具体在代码中使用如下:
cv::mat temp; if(!cap.read(temp)) break; dlib::cv_image<bgr_pixel> img(temp); std::vector<rectangle> dets = detector(img); cout << "number of faces detected: " << dets.size() << endl; std::vector<full_object_detection> shapes; for (unsigned long j = 0; j < dets.size(); ++j) { /* use dlib to get landmarks */ full_object_detection shape = sp(img, dets[j]); /* ... */ }
其中shape.part
就存放着我们通过dlib获得的当前人脸的特征点二维点序列。
[note] 在最后cmake配置的时候,需要使用release
版本(最重要),以及增加选项use_avx_instructions
和use_sse2_instructions
/use_sse4_instructions
,否则因为dlib的检测耗时较长,使用摄像头即时拟合会有严重的卡顿。
使用ceres进行非线性优化
ceres的使用官方也提供了详细的样例,在此我们使用的是数值差分的方法,可参考:
problem problem; costfunction* cost_function = new numericdiffcostfunction<costfunctor, ceres::ridders, landmark_num, 6>(new costfunctor(shape)); problem.addresidualblock(cost_function, null, x); solver::options options; options.minimizer_progress_to_stdout = true; solver::summary summary; solve(options, &problem, &summary); std::cout << summary.briefreport() << endl;
这里我直接使用了数值差分的方法(numericdiffcostfunction
),而不是使用自动差分(autodiffcostfunction
),是因为自动差分的costfunctor是通过template实现的,利用template来实现jacobian矩阵的计算使用的同一个结构,这样的话下方旋转矩阵就不能直接通过调用dlib提供的三维坐标旋转接口,而是要将整个矩阵拆解开来实现(这边暂时没有细想到底能不能实现),因此出于简便,使用数值差分,在准确性上是会受到影响的。
并且注意到,具体的方法使用了ridders(ceres::ridders
),而不是向前差分(ceres::forward
)或者中分(ceres::central
),因为用后两者进行处理的时候,lm算法\(\beta_{k+1}=\beta_k-(j^tj+\lambda i)^{-1}j^tr)\)的更新项为0,无法进行迭代,暂时没有想到原因,之前这里也被卡了很久。
[note] 源代码中还有使用了cminpack的版本,该版本不可用的原因也是使用了封装最浅的lmdif1_
调用(返回结果info=4),该版本下使用的向前差分,如果改为使用lmdif_
对其中的一些参数进行调整应该是可以实现的。
costfunctor的构建
costfunctor的构建是ceres,也是这个程序,最重要的部分。首先我们需要先把想要计算的式子写出来:
\(q=\sum_i^{landmark\_num} \|q_i^{2d}-p_i^{2d}\|^2\)
\(q=\sum_i^{landmark\_num} \|q_i^{2d}-map(r(yaw, roll, pitch)p_i^{3d}+t(t_x, t_y, t_z))\|^2\)。
其中:
- landmark_num:该程序中为68,因为dlib算法获得的特征点数为68;;
- \(q_i^{2d}\):通过dlib获得的2维特征点坐标,大小为68的vector<dlib::point>
- \(p_i^{2d}\):经过一系列变换得到的标准模型的2维特征点坐标,大小为68的vector<dlib::point>,具体计算方法是通过\(p_i^{2d}=map(r(yaw, roll, pitch)(p_i^{3d})+t(t_x, t_y, t_z))\);
- \(p_i^{3d}\):标准模型的三维3维特征点坐标,大小为68的vector<point3f>;
- \(r(yaw, roll, pitch)\):旋转矩阵;
- \(t(t_x, t_y, t_z)\):平移矩阵;
- \(map()\):3维点转2维点的映射,如上所描述通过相机内参获得。
-
\(\|·\|\):因为是两个2维点的差,我们使用欧几里得距离来作为2点的差。
ceres当中的costfunctor只需要写入平方以内的内容,因此我们如下构建:
struct costfunctor { public: costfunctor(full_object_detection _shape){ shape = _shape; } bool operator()(const double* const x, double* residual) const { /* init landmarks to be transformed */ fitting_landmarks.clear(); for(std::vector<point3f>::iterator iter=model_landmarks.begin(); iter!=model_landmarks.end(); ++iter) fitting_landmarks.push_back(*iter); transform(fitting_landmarks, x); std::vector<point> model_landmarks_2d; landmarks_3d_to_2d(fitting_landmarks, model_landmarks_2d); /* calculate the energe (euclid distance from two points) */ for(int i=0; i<landmark_num; i++) { long tmp1 = shape.part(i).x() - model_landmarks_2d.at(i).x(); long tmp2 = shape.part(i).y() - model_landmarks_2d.at(i).y(); residual[i] = sqrt(tmp1 * tmp1 + tmp2 * tmp2); } return true; } private: full_object_detection shape; /* 3d landmarks coordinates got from dlib */ };
其中的参数x是一个长度为6的数组,对应了我们要获得的6个参数。
初始值的选定
当前并没有多考虑这个因素,在landmark-fitting-cam程序中除了第一帧的初始值是提前设置好的以外,后续的初始值都是前一帧的最优值。
后面的表现都很好,但这第一帧确实会存在紊乱的情况。
因此后续优化可以考虑使用一个粗估计的初始值,因为对于这些迭代优化方法,初始值的选择决定了会不会陷入局部最优的情况。
测试结果
脸部效果:
输出工作环境:
上一篇: Tt电商主机顶配旗舰售价19999元 浑身都是RGB
下一篇: 纯CSS实现扑克牌效果