- 关节状态State物理意义;对旋转关节,即urdf中type=continuous或revolute,关节状态是旋转轴的旋转角度。举个例子,值1.57,表示此个关节绕旋转轴旋转90度,至于是顺时间还是逆时针,要看axis的正、负号以及开发者如何定义值的变化方向。
在读这篇文章前,一定要非常熟悉urdf中的<joint>、<link>标签,这部分内容可参考“urdf中的<link>、<joint>”,那文章也给出这里要用到的关节a示例。
一、逆运动学求解器概述
参考:【机器人学】机器人开源项目KDL源码学习:(2)牛顿拉普森迭代法求机器人的数值解
求逆解方法分为两类,一是求解析解,moveit中的ikfast,二是求数值解,moveit中的kdl、trac_ik。对第二种,可以将求机器人逆解的问题看成是一个求多元非线性方程组的问题,而牛顿拉普森迭代法就是求这个问题的一个可选的方法。
第一步:方程组可写成如下形式
表示机械臂的正运动学函数,操作空间共有m个自由度。
表示机械臂具有n个关节。如果n>m则机械臂运动学冗余。
表示机械臂末端的在操作空间维度的目标点。
第二步:求取雅可比矩阵:
位姿有6个变量,3个平移和3个旋转,求逆解时,需要同时满足这6个变量,即m=6。相对应的,雅可比矩阵是个6行矩阵。
第三步:迭代法求解下一个关节的位置
其中:
表示当前的误差(目标点的位姿-当前点的位姿),这与(1)式是相反的,所以(3)式的等式后是加号,这么写是为了要和KDL中的程序一致。
逆矩阵使用雅可比矩阵的伪逆矩阵,KDL代码中并没有单独的伪逆矩阵的求解器,而是直接求的伪逆矩阵和误差的乘积。
二、正向运动学方程
正向运动学方程的x是关节状态,y是笛卡儿位姿。
2.1 ChainFkSolverPos_recursive::JntToCart
JntToCart功能是根据关节状态q_in,算出该规划组内某个关节的笛卡儿位姿。过程就是求解正向运动学方程。
- @q_in[IN]。自变量关节状态x。
- @p_out[OUT]。应变量笛卡儿位姿y。
- @seg_nr[IN]。要计算是第几个关节的位姿。如果seq_nr小于0,要计算最后一个关节的位姿。
namespace KDL { int ChainFkSolverPos_recursive::JntToCart(const JntArray& q_in, Frame& p_out, int seg_nr) { unsigned int segmentNr; if(seg_nr<0) segmentNr=chain.getNrOfSegments(); else segmentNr = seg_nr;
如果参数seg_nr小于0,像-1,意味着要计算最后一个关节的位姿。chain.getNrOfSegments()得到该规划组内的关节数,示例有4个关节,segmentNr=4,意味着要计算第4个,也就是最后一个关节的位姿。
p_out = Frame::Identity(); if(q_in.rows()!=chain.getNrOfJoints()) return (error = E_SIZE_MISMATCH); else if(segmentNr>chain.getNrOfSegments()) return (error = E_OUT_OF_RANGE); else{ int j=0; for(unsigned int i=0;i<segmentNr;i++){ const Segment& segment = chain.getSegment(i); const Joint& joint = segment.getJoint(); if(chain.getSegment(i).getJoint().getType()!=Joint::Fixed) { p_out = p_out*chain.getSegment(i).pose(q_in(j));
i是所有关节的索引。关节中有些是固定关节(type="fixed"),j是非固定关节中的索引。每个非固定关节有一个关节状态,因而j也是q_in的索引。
右侧p_out是索引i-1关节的位姿,左侧p_out是索引i关节在关节状态是q_in时的位姿。
j++; }else{ p_out = p_out*chain.getSegment(i).pose(0.0); } } return (error = E_NOERROR); } } }
2.2 p_out*chain.getSegment(i).pose(q_in(j))
p_out是位姿,“chain.getSegment(i).pose(q_in(j))”的结果也是位姿。为直观,可这么理解乘法的物理意义:向量a要依次经过两次坐标系变换,第一次是“chain.getSegment(i).pose(q_in(j))”,第二次是p_out。p_out就是一个累计位姿,pose较为复杂,或许吧,好多人不太熟悉KDL中的Frame、Rotation、Vector处理坐标变换,而是习惯了用Eigen,在深入前,让先看一段等价的用Eigen写的代码。
- @xyz[IN]:urdf中定义关节的xyz。关节“a”:(-0.02656, 0, 0.05)。
- @rpy[IN]:urdf中定义关节的rpy。关节“a”:(1.5708, 0, -1.5708)。
- @axis_tag_xyz[IN]:urdf中定义的<axis>内的xyz。关节“a”:(0, 0, -1)。
- @angle[IN]:要旋转的角度,即关节状态。值:0.5。
Eigen::Isometry3d chain_joint_pose(const Eigen::Vector3d& xyz, const Eigen::Vector3d& rpy, const Eigen::Vector3d& axis_tag_xyz, double angle) { Eigen::AngleAxisd rollAngle(Eigen::AngleAxisd(rpy(0), Eigen::Vector3d::UnitX())); Eigen::AngleAxisd pitchAngle(Eigen::AngleAxisd(rpy(1), Eigen::Vector3d::UnitY())); Eigen::AngleAxisd yawAngle(Eigen::AngleAxisd(rpy(2), Eigen::Vector3d::UnitZ())); Eigen::Isometry3d F_parent_jnt = Eigen::Isometry3d::Identity(); F_parent_jnt.rotate(yawAngle * pitchAngle * rollAngle);
欧氏变换矩阵F_parent_jnt,此时位置0,角度rpy。
F_parent_jnt: {p:(0, 0, 0), M:(1.57079, 0.00000, -1.57079)}
Eigen::Vector3d _axis = F_parent_jnt.rotation() * axis_tag_xyz; Eigen::Vector3d axis = _axis / _axis.norm();
“F_parent_jnt.rotation() * axis_tag_xyz”将向量axis_tag_xyz变换到F_parent_jnt坐标系,变换时平移值是0。axis是_axis对应的单位向量。对关节a,F_parent_jnt坐标系指的是arm_link。从图1可看到,Link1的Z轴和arm_link的X轴是同一平面,如果“补上”平移,它们处于同一直线,但方向相反。因而上面这个坐标系变换,直观上可以这么为:link1上的“Z=-1”变成了arm_link中的“X=1”。
_axis = (1, 0, 0)。由于_axis长度是1,axis值也是(1, 0, 0)。
Eigen::Isometry3d joint_pose = Eigen::Isometry3d::Identity(); joint_pose.rotate(Eigen::AngleAxisd(angle, axis)); joint_pose.pretranslate(xyz);
欧氏变换矩阵joint_pose,它的位置是xyz,角度是绕单位向量axis旋转angle度。
joint_pose: {p:(-0.02655, 0, 0.05000), M:(0.49999, -0.00000, 0.00000)}
Eigen::Isometry3d f_tip = Eigen::Isometry3d::Identity(); f_tip.rotate(F_parent_jnt.rotation());
f_tip语义“frame from the end of the joint to the tip of the segment, default: Frame::Identity()”。f_tip中的“f”应该指“Frame”,但不知怎么理解tip。——还没理解官方对f_tip的这句英文注释,看翻译好像是从该规划组初端或末端到该关节啥的。但个人认为某个关节的f_tip只依赖该关节数值,和其它关节无关。举个例子,对关节b,计算出的f_tip和前后关节a、c数值无关。
f_tip: {p:(0, 0, 0), M:(1.57079, 0.00000, -1.57079)}
Eigen::Isometry3d result = joint_pose * f_tip;
如果有一个向量a,那result乘上它,等价要依次经过两次坐标变换,第一次变换到arm_link坐标系,但只是旋转,角度对了,没有平移,位置不对。第二次变换是在arm_link坐标系,角度上,绕旋转轴axis旋转angle度,同时位置平移<origin.xyz>。
result: {p:(-0.02655, 0, 0.05000), M:(1.57080, 0.49999, -1.57079)}
return result; }
回到KDL代码pose(...)
Frame Segment::pose(const double& q)const { return joint.pose(q)*f_tip; } Frame Joint::pose(const double& q)const { switch(type){ case RotAxis: // calculate the rotation matrix around the vector "axis" if (q != q_previous){ q_previous = q; joint_pose.M = Rotation::Rot2(axis, scale*q+offset);
Rot2用于创建一个绕任意(单位)向量旋转的旋转矩阵,axis是这个旋转轴,scale*q+offst是要旋转的角度。默认scale=1,offset=0,因而第q,这个关节状态值往往就是旋转角度。
} return joint_pose; ... } return Frame::Identity(); }
当中要用到的f_tip、axis表示什么,可参考上面Eigen代码中同名变量。以下是KDL如何计算f_tip。
Segment::Segment(const std::string& _name, const Joint& _joint, const Frame& _f_tip, const RigidBodyInertia& _I): name(_name), joint(_joint),I(_I), f_tip(_joint.pose(0).Inverse() * _f_tip) { } 代入示例值后计算f_tip _joint.pose(0).Inverse(): {p:(0.02655, 0, -0.05000), M:(0, 0, 0)} * _f_tip: {p:(-0.02655, 0, 0.05000), M:(1.57079, 0, -1.57079)} --------------- = f_tip: {p:(0, 0, 0), M:(1.57079, 0, -1.57079)}
对“_joint.pose(0).Inverse()”,角度0,位置是_f_tip的逆。目的是把_f_tip的位置0,为何不直接把_f_tip的p部分置0,这不是比“乘”更快?
三、求解雅可比矩阵
还没理解雅可比矩阵细节,像Twist::RefPoint。已有注释只是目前理解的,可能有错误。参考:机械臂的雅可比矩阵这么厉害,怎么把它求出来呢?(雅可比矩阵中篇),【机器人学】机器人开源项目KDL源码学习:(5)KDL如何求解几何雅可比矩阵
- 为求矩阵,动态变量只有N个关节的此时状态q_in,和未端关节的前、后两次位姿误差无关。
- 过程中没有可调优化值的参数。
- 雅可比矩阵维度6xn。固定6行,每行表示线速度、角速度的一个分量。分别是线速度x、线速度y、线速度z、角速度x、角速度y、角速度z。n是该规划组中的非固定关节数,每一列表示一个关节,像示例中的a、b、c,那n=3。
3.1 ChainJntToJacSolver::JntToJac
- @q_in[IN]。此时各关节状态。q_in: [0.5, 0.6, -0.7]
- @jac[OUT]。存储求解出的雅可比矩阵。
- @seg_nr[IN]。要计算是第几个关节的位姿。如果seq_nr小于0,要计算最后一个关节的位姿。语义和ChainFkSolverPos_recursive::JntToCart中的seg_nr一样。
namespace KDL { int ChainJntToJacSolver::JntToJac(const JntArray& q_in, Jacobian& jac, int seg_nr) { if(locked_joints_.size() != chain.getNrOfJoints()) return (error = E_NOT_UP_TO_DATE); unsigned int segmentNr; if(seg_nr<0) segmentNr=chain.getNrOfSegments(); else segmentNr = seg_nr;
segmentNr是关节数:4。a、b、c、grasping_frame_joint。
//Initialize Jacobian to zero since only segmentNr columns are computed SetToZero(jac) ; ... T_tmp = Frame::Identity(); SetToZero(t_tmp); int j=0; int k=0; Frame total; for (unsigned int i=0;i<segmentNr;i++) { //Calculate new Frame_base_ee if(chain.getSegment(i).getJoint().getType()!=Joint::Fixed) { //pose of the new end-point expressed in the base total = T_tmp*chain.getSegment(i).pose(q_in(j));
total是在q_in角度时,索引i关节在arm_link坐标系下的笛卡儿位姿。
//changing base of new segment's twist to base frame if it is not locked //t_tmp = T_tmp.M*chain.getSegment(i).twist(1.0); if(!locked_joints_[j]) t_tmp = T_tmp.M*chain.getSegment(i).twist(q_in(j),1.0);
求的是索引i关节对索引i+1连杆末端的速度(在arm_link坐标系下),设索引i关节的角速度为1,
}else{ total = T_tmp*chain.getSegment(i).pose(0.0); } //Changing Refpoint of all columns to new ee changeRefPoint(jac,total.p-T_tmp.p,jac);
total是索引i关节的位姿,T_tmp是索引i-1关节的位姿,“total.p-T_tmp.p”是这两姿的位置差。更新雅可比矩阵,第k列表示第k个关节角速度为1时对末端的造成的线速度和角速度
//Only increase jointnr if the segment has a joint if(chain.getSegment(i).getJoint().getType()!=Joint::Fixed) { //Only put the twist inside if it is not locked if(!locked_joints_[j]) jac.setColumn(k++,t_tmp);
把索引i关节对应的twist列(t_tmp)“添加”到雅可比矩阵。“添加”加引号,是因为该列已经预分配,严格应该说是更新。由于上面的changeRefPoint,此次更新值到雅可比矩形中的线速度将来会被修改。
j++; } T_tmp = total; } return (error = E_NOERROR); } }
3.2 ChainJntToJacSolver::JntToJac
t_tmp = T_tmp.M*chain.getSegment(i).twist(q_in(j),1.0);
求的是索引i关节对索引i+1连杆末端的速度(在arm_link坐标系下),设索引i关节的角速度为1。它的核心是要理解Segment::twist。
Twist Segment::twist(const double& q, const double& qdot)const { return joint.twist(qdot).RefPoint(joint.pose(q).M * f_tip.p); }
Segment::twist中有4个操作。
[1/4] joint.twist(qdot)
namespace KDL { Twist Joint::twist(const double& qdot)const { switch(type){ case RotAxis: return Twist(Vector(0,0,0), axis * ( scale * qdot));
scale默认1。RotAxis表示按单元向量axis旋转。值是(1, 0, 0)时,等价RotX。
case RotX: return Twist(Vector(0.0,0.0,0.0),Vector(scale*qdot,0.0,0.0)); case RotY: return Twist(Vector(0.0,0.0,0.0),Vector(0.0,scale*qdot,0.0)); case RotZ: return Twist(Vector(0.0,0.0,0.0),Vector(0.0,0.0,scale*qdot)); ... } }
joint.twist(qdot)得到的是一个twist,它的线速度是0,因为qdot固定是1,角速度值等于axis。
[2/4]joint.pose(q)
joint.pose(q)结果是个欧氏变换矩阵,它的位置是<origin.xyz>,角度是绕单位向量axis旋转q度。这个变换是在它关节的parent坐标系。
[3/4]joint.pose(q).M * f_tip.p
f_tip.p是个向量,joint.pose(q).M乘上它的结果是它把变换到joint.pose(q),只是这次变换时平移是0。疑问:f_tip.p是不是总是0?
[4/4]twist(qdot).RefPoint(joint.pose(q).M * f_tip.p)
Twist Twist::RefPoint(const Vector& v_base_AB) const // Changes the reference point of the twist. // The vector v_base_AB is expressed in the same base as the twist // The vector v_base_AB is a vector from the old point to // the new point. // Complexity : 6M+6A { return Twist(this->vel+this->rot*v_base_AB,this->rot); }
RefPoint会生成一个新的Twist,相比旧的,角速度不变,线速度加上“this->rot * v_base_AB”,这个“*”是叉乘。
Vector operator *(const Vector & lhs,const Vector& rhs) // Complexity : 6M+3A { Vector tmp; tmp.data[0] = lhs.data[1]*rhs.data[2]-lhs.data[2]*rhs.data[1]; tmp.data[1] = lhs.data[2]*rhs.data[0]-lhs.data[0]*rhs.data[2]; tmp.data[2] = lhs.data[0]*rhs.data[1]-lhs.data[1]*rhs.data[0]; return tmp; }
3.3 changeRefPoint(jac,total.p-T_tmp.p,jac)
namespace KDL { bool changeRefPoint(const Jacobian& src1, const Vector& base_AB, Jacobian& dest) { if(src1.columns()!=dest.columns()) return false; for(unsigned int i=0;i<src1.columns();i++) dest.setColumn(i,src1.getColumn(i).RefPoint(base_AB));
src1.columns返回的是chain中非固定关节数,不论此时调用changeRefPoint是哪个关节,它的值都不变,像3。但叉积中若有一个是0向量,那结果肯定是0向量,因此对还未赋过初始值的列(关节),值不会变,即不会受影响,包括正次正计算的关节i。即changeRefRpoint只影响历史算过[0, i-1]的关节,而且不影响角速度。
return true; } }
3.4 Jacobian::setColumn
用参数t的值,更新到雅可比矩阵索引i列。
namespace KDL { void Jacobian::setColumn(unsigned int i,const Twist& t){ data.col(i).head<3>()=Eigen::Map(t.vel.data); data.col(i).tail<3>()=Eigen::Map(t.rot.data); } }
四、求公式(3)中的状态增量:
根据公式(3),求状态增量分两步,第一步求出雅可比矩阵jac,第二步求用雅可比矩阵的逆乘以Err。在第二步时,由于直接求逆较耗资源,使用奇异值分解(SVD)。奇异值分解是在机器学习领域广泛运用的算法,可参考“SVD分解”。
- @q_in[IN]。此时各关节状态。q_in: [0.5, 0.6, -0.7]
- @v_in[IN]。公式(3)中的。
- @qdot_out[OUT]。求出公式3中状态增量后,存储在这里。
<orocos_kdl>/src/chainiksolvervel_pinv.cpp ------ namespace KDL { int ChainIkSolverVel_pinv::CartToJnt(const JntArray& q_in, const Twist& v_in, JntArray& qdot_out) { ... //Let the ChainJntToJacSolver calculate the jacobian "jac" for //the current joint positions "q_in" error = jnt2jac.JntToJac(q_in,jac); if (error < E_NOERROR) return error;
jnt2jac.JntToJac用于求解雅可比矩阵,矩阵存放在jac。
double sum; unsigned int i,j; // Initialize near zero singular value counter nrZeroSigmas = 0 ; //Do a singular value decomposition of "jac" with maximum //iterations "maxiter", put the results in "U", "S" and "V" //jac = U*S*Vt svdResult = svd.calculate(jac,U,S,V,maxiter); if (0 != svdResult) { qdot_out.data.setZero(); return (error = E_SVD_FAILED); } // We have to calculate qdot_out = jac_pinv*v_in // Using the svd decomposition this becomes(jac_pinv=V*S_pinv*Ut): // qdot_out = V*S_pinv*Ut*v_in //first we calculate Ut*v_in for (i=0;i<jac.columns();i++) { sum = 0.0; for (j=0;j<jac.rows();j++) { sum+= U[j](i)*v_in(j); } //If the singular value is too small (<eps), don't invert it but //set the inverted singular value to zero (truncated svd) if ( fabs(S(i))<eps ) { tmp(i) = 0.0 ; // Count number of singular values near zero ++nrZeroSigmas ; } else { tmp(i) = sum/S(i) ; } } //tmp is now: tmp=S_pinv*Ut*v_in, we still have to premultiply //it with V to get qdot_out for (i=0;i<jac.columns();i++) { sum = 0.0; for (j=0;j<jac.columns();j++) { sum+=V[i](j)*tmp(j); } //Put the result in qdot_out qdot_out(i)=sum; }
由于直接求jac的逆较耗资源,使用奇异值分解。上面这段代码执行后,会算出公式(3)中的状态增量qdot_out。为提高效率,代码较难懂,后面eigen_jac_inv_mul_err是用Egien写的同功能代码,调用语句eigen_jac_inv_mul_err(jac, v_in)。
至此算出qdot_out,要没意外,它应该是很小值,像:(5.52382e-05, -5.70437e-05, -4.96106e-07)
// Note if the solution is singular, i.e. if number of near zero // singular values is greater than the full rank of jac if ( nrZeroSigmas > (jac.columns()-jac.rows()) ) { return (error = E_CONVERGE_PINV_SINGULAR); // converged but pinv singular } else { return (error = E_NOERROR); // have converged } } }
不考虑效率,已知(_jac)和(_v_in),计算。
void eigen_jac_inv_mul_err(const Jacobian& _jac, const Twist& _v_in) { Eigen::Matrix<double, 6, 3> jac; for (int row = 0; row < _jac.rows(); row ++) { for (int col = 0; col < _jac.columns(); col ++) { jac(row, col) = _jac(row, col); } } Eigen::Matrix<double, 6, 1> v_in; v_in(0, 0) = _v_in.vel.x(); v_in(1, 0) = _v_in.vel.y(); v_in(2, 0) = _v_in.vel.z(); v_in(3, 0) = _v_in.rot.x(); v_in(4, 0) = _v_in.rot.y(); v_in(5, 0) = _v_in.rot.z(); const Eigen::Matrix<double, 6, 6> I = Eigen::Matrix<double, 6, 6>::Identity(6, 6); // 步骤1/2:计算雅可比矩阵的逆。根据jac * jac_inv = I,用QR分解算出jac的逆矩阵jac_inv。 const Eigen::Matrix<double, 3, 6> jac_inv = jac.colPivHouseholderQr().solve(I); // 步骤2/2:jac_inv乘以Err,算出qdot_out。 const Eigen::Matrix<double, 3, 1> qdot_out = jac_inv * v_in; // 算出的qdot_out值应该等于ChainIkSolverVel_pinv::CartToJnt中的qdot_out。 // 想用下面语句验证jac_inv是不是jac的逆,但算出的I2不是6x6单位阵,为什么? const Eigen::Matrix<double, 6, 6> I2 = jac * jac_inv; }
moveit默认运行学求解器插件KDL计算时,调用的不是ChainIkSolverVel_pinv::CartToJnt,而是ChainIkSolverVelMimicSVD::CartToJnt。后者步骤类似ChainIkSolverVel_pinv::CartToJnt,同名变量qdot_out有着同样功能。