现在VLA基本还是得靠底层的机械臂逆解和轨迹规划来实现操作,在实现端到端的模型前,咱们还是得把这个基础搭建好


起因:一个看似简单的需求

“让机械臂的末端从 A 点移动到 B 点,轨迹要平滑,不要撞到障碍物。”

听起来很简单,在外行眼里,好像就是动了一下,小儿科的东西。naive! 这背后其实是一整套系统工程:

  • 逆解(IK):知道目标位置,怎么算出各个关节该转多少度?
  • 路径规划:怎么避开障碍物?
  • 轨迹平滑:怎么让运动丝滑不抖?
  • 实时执行:高频率下怎么保证延迟和精度?

更要命的是,我们还想要:

  • 切换不同机械臂(5-DOF → 7-DOF)不用改代码
  • 支持 3D 位置控制 + 6D 姿态控制
  • 可以在仿真和实机之间无缝切换
  • 调参方便,不用每次都重新编译

于是,一场技术选型的冒险就这么开始了。


第一站:ROS2 + MoveIt 不香吗?

做机器人,机械臂规划,第一反应当然是:用 MoveIt 啊,现成的!

MoveIt 确实很强大,功能全面:

  • OMPL 路径规划
  • Trajopt 轨迹优化
  • KDL解析 IK
  • 可视化界面(RViz)
  • 丰富的文档和社区支持

但是(重点来了)——

问题 1:我们不想用 ROS2

之前讲架构的文章里我详细分析过,ROS2 的架构对我们的场景不够友好,我们用的是 AimRT 框架,轻量、高性能、配置简洁。如果为了 MoveIt 再套一层 ROS2,那就是为了一颗葡萄建一个葡萄园

问题 2:MoveIt 的灵活性不够

MoveIt 是个重型框架,你要么全盘接受它的设计,要么就得啃源码魔改。

举个例子:我想让机械臂"姿态随便,只要位置到了就行",MoveIt 默认的 IK 求解器会强制约束姿态,要改?对不起,请重新实现 IK Plugin。

再比如:我想在仿真环境下测试一个新的轨迹优化算法,MoveIt 的架构让你很难只替换某一个模块而不影响其他部分。

问题 3:性能开销

MoveIt 的服务化架构(Move Group Server)在工业机器人场景下很合理,但我们做的是实时控制

  • 机械臂需要 50-200Hz 的控制频率
  • IK 求解要在数毫秒内完成
  • 轨迹平滑要支持在线重规划

MoveIt 架构的抽象层开销(ROS Service 调用 + 序列化)在这种场景下就显得有点"笨重"。


所以,虽然 MoveIt 很强大,但不是我们的菜。我们需要的是一个:

  • 轻量级:只解决运动规划问题,不带其他包袱
  • 模块化:IK、路径规划、轨迹平滑可以独立替换
  • 高性能:毫秒级 IK 求解,实时轨迹生成
  • 可配置:通过 YAML 适配不同机械臂,不用改代码

第二站:自己造轮子?先试试插值法

我们首先尝试了最经典的笛卡尔插值 + 雅可比迭代(Jacobian-based IK):

基本思路

# 伪代码
def solve_ik(target_position, current_joints):
    # 1. 把目标位置分成 30 个小段(插值)
    steps = interpolate(current_position, target_position, num_steps=30)
    
    q = current_joints  # 初始关节角度
    for step in steps:
        # 2. 对每一小段,用雅可比迭代求解
        for i in range(max_iterations):
            current_pos = forward_kinematics(q)  # 正解
            error = step - current_pos
            if norm(error) < threshold:
                break
            
            J = compute_jacobian(q)  # 雅可比矩阵
            dq = J.T @ inv(J @ J.T + damping * I) @ error  # 阻尼伪逆
            q = q + step_size * dq  # 更新关节角度
    
    return q

效果

  • 成功率高(在工作空间内)
  • 收敛速度快(平均 5-10ms)
  • 代码简单,易于理解和调试

我们用这套方案跑了一段时间,整体还行。

但是…

随着需求升级,问题来了:

问题 1:只能控制位置,不能控制姿态

笛卡尔插值 + 3D 误差(xyz)只能保证末端位置到达目标,但姿态是随机的

抓取任务倒还好(姿态无所谯),但如果要做插拔连接器、拧螺丝这种需要精确定向的任务,就 GG 了。

要支持 6D 姿态控制(xyz + roll/pitch/yaw),需要:

  • 计算 SE(3) 流形上的误差(不是简单的向量减法)
  • 雅可比矩阵从 3×n 扩展到 6×n
  • 加上 Jlog6 变换来处理旋转误差

理论上可行,但实现起来容易踩坑

问题 2:没有避障能力

插值法只管"从 A 到 B",中间要是有障碍物?对不起,撞上去了。

要加避障,就得引入路径规划(Path Planning)。但插值法和路径规划怎么结合?又是一堆工程问题。

问题 3:多机械臂适配麻烦

虽然我们用 Pinocchio 做 FK/雅可比计算(已经支持 URDF 加载),但一些关键参数还是硬编码的:

const int DOF = 5;  // 💀 硬编码!换个 7-DOF 机械臂就得改
Eigen::VectorXd q(DOF);

每次换机械臂,都要改代码、重新编译、重新测试。不够优雅。


所以,插值法作为原型验证很好,但要长期维护、功能扩展,还是得找更系统的方案。


第三站:最终方案 - Pinocchio + OMPL + Ruckig

经过一番调研和实践,我们确定了这个组合:

模块 职责
运动学/动力学 Pinocchio FK、IK、雅可比、重力补偿
路径规划 OMPL 避障、采样式规划(RRT/PRM)
轨迹生成 Ruckig 时间最优、平滑轨迹(符合速度/加速度约束)

为什么选择 Pinocchio 而非 KDL?

Pinocchio 是 LAAS-CNRS(法国国家科学研究中心)开发的机器人运动学库,性能怪兽级别,远超 KDL(Kinematics and Dynamics Library,Orocos 项目的老牌库,常用于 ROS/MoveIt):

  • :基于 Eigen3 高度优化,FK/IK 计算微秒级;KDL 基于较旧的实现,速度慢 5-10 倍(毫秒级),不适合高频实时控制
  • :内置 CLIK 闭环逆运动学 + 阻尼伪逆,数值稳定、抗奇异点;KDL 的 NumPy-like IK 易发散,需手动调参,精度在复杂姿态下逊色
  • :覆盖 FK/IK、雅可比、动力学、碰撞检测,甚至 ABA/RNEA 算法;KDL 只专注基本运动学,动力学支持弱,扩展需额外库
  • 通用:原生 URDF/SDF 支持,API 现代易集成(C++/Python);KDL 虽兼容 ROS,但配置繁琐,移植性差

逆解的部分,Pinocchio 的官方文档提供了 3D IK6D IK 的参考实现,采用 CLIK 算法。

为什么是 OMPL?

OMPL(Open Motion Planning Library)是运动规划领域的标准

  • 算法全:RRT、RRT*、PRM、EST…十几种采样式规划算法
  • 接口简洁:只需提供状态空间、碰撞检测器,剩下的 OMPL 帮你搞定
  • 学术背书:被 MoveIt、Drake 等主流框架采用

而且 OMPL 不依赖 ROS,可以作为独立库使用(这点很重要)。

为什么是 Ruckig?

Ruckig 是一个实时轨迹生成库,专注于一件事:

给定起点、终点、速度约束、加速度约束,生成一条时间最优、符合约束的轨迹。

听起来简单,但工程实现极其硬核

  • 实时:微秒级计算,支持在线重规划
  • 精确:严格满足速度/加速度/加加速度约束
  • 可拼接:支持多段轨迹缝合(Trajectory Stitching)

我们之前用过 MoveIt 的 Time Parameterization 和一些多项式插值法,Ruckig 的效果吊打它们


这个组合的优势

1. 职责清晰,模块解耦

Pinocchio:算 IK,告诉我关节角度
   ↓
OMPL:规划路径,告诉我怎么走不撞
   ↓
Ruckig:生成轨迹,告诉我每个时刻的位置/速度

每个库只做自己最擅长的事,出问题了也好定位。

2. 性能拉满

毫秒级的求解速度,总体延迟完全满足实时控制需求。

3. 灵活性强

  • 想换 IK 算法?Pinocchio 提供多种实现(DLS、NLOPT、Levenberg-Marquardt)
  • 想换规划算法?OMPL 随便挑
  • 想换轨迹优化?Ruckig 参数调一调

4. 不依赖 ROS

这三个库都是纯 C++ 库,不需要 ROS 环境。配合 AimRT 框架,整个系统轻得飞起。


实现:从 0 到 1 的架构设计

理论说完了,来点实战。

整体架构

我们把规划模块设计成了分层架构

┌─────────────────────────────────────────────┐
│           PlanningModule (协调者)           │
├─────────────────────────────────────────────┤
│  Input Adapter Layer (输入适配层)           │
│  ├─ XYZPoseInput  : 3D 位置控制            │
│  └─ SE3PoseInput  : 6D 位姿控制            │
├─────────────────────────────────────────────┤
│  IK Solver Layer (逆解层)                   │
│  ├─ Interpolation IK : 笛卡尔插值   │
│  ├─ Official 3D IK   : Pinocchio 3D       │
│  └─ Official 6D IK   : Pinocchio 6D       │
├─────────────────────────────────────────────┤
│  Path Planner Layer (路径规划层)            │
│  └─ OMPL Planner : RRT-Connect (避障)     │
├─────────────────────────────────────────────┤
│  Trajectory Smoother Layer (轨迹平滑层)     │
│  └─ Ruckig Smoother : 时间最优轨迹         │
├─────────────────────────────────────────────┤
│  Kinematics Core (运动学核心)               │
│  └─ PinocchioPlanner : FK/IK/雅可比/重力  │
└─────────────────────────────────────────────┘

工作流程

用户在 UI 上点一下"移动到 (x, y, z)",背后发生了这些事:

Step 1: 输入适配
  └─ SE3PoseInput 把 (x, y, z, roll, pitch, yaw) 
     转换成 SE(3) 变换矩阵

Step 2: IK 求解 (Pinocchio Official 6D)
  ├─ 初始状态:q_current (当前关节角度)
  ├─ 目标位姿:target_pose (SE3 矩阵)
  ├─ 迭代求解:
  │   for i in 1..1000:
  │     current_pose = FK(q_solution)
  │     error = log6(current_pose⁻¹ * target_pose)  # SE(3) 误差
  │     if ||error|| < 0.0001:  # 理想阈值 (0.1mm)
  │       return SUCCESS
  │     
  │     J = compute_jacobian(q_solution)  # 6×n 雅可比
  │     J_corrected = -Jlog6(iMd⁻¹) * J   # 流形修正
  │     dq = J^T (JJ^T + λI)⁻¹ error      # 阻尼伪逆
  │     q_solution = integrate(q_solution, dq * dt)
  │
  └─ 输出:q_goal (目标关节角度)

Step 3: 路径规划 (OMPL RRT-Connect)
  ├─ 起点:q_current
  ├─ 终点:q_goal
  ├─ 碰撞检测:调用 Pinocchio 的 collision module
  └─ 输出:path_waypoints (一系列无碰撞的关节配置)

Step 4: 轨迹平滑 (Ruckig Stitching)
  ├─ 输入:path_waypoints (离散点)
  ├─ 约束:max_vel=1.0, max_acc=2.0, max_jerk=5.0
  ├─ 生成:
  │   for each segment in path:
  │     trajectory_segment = ruckig.solve(start, end, constraints)
  │     trajectory_queue.push(trajectory_segment)
  │
  └─ 输出:trajectory_queue (缝合后的连续轨迹)

Step 5: 实时执行
  └─ 每 20ms (50Hz):
       (pos, vel) = trajectory_queue.at(current_time)
       publish("motion_planning", pos, vel)

代码实现片段(IK 求解核心)

这是我们从 Pinocchio 官方示例直接移植过来的 CLIK 实现(6D 版本):

bool solveIK_6D(const SE3& target_pose, 
                const VectorXd& q_init, 
                VectorXd& q_solution) {
    q_solution = q_init;
    const double DT = 0.1;      // 步长
    const double damp = 1e-6;   // 阻尼系数
    
    Data data(*model_);
    Matrix6x J(6, model_->nv);
    Vector6d err;
    VectorXd v(model_->nv);
    
    double best_error = INFINITY;
    VectorXd best_q = q_init;
    
    for (int i = 0; i < max_iterations; ++i) {
        // 正向运动学
        forwardKinematics(*model_, data, q_solution);
        
        // 计算 SE(3) 误差(关键!)
        const SE3& current_pose = data.oMf[tip_frame_id_];
        const SE3 iMd = current_pose.actInv(target_pose);
        err = log6(iMd).toVector();  // SE(3) → R^6
        
        double current_error = err.norm();
        if (current_error < best_error) {
            best_error = current_error;
            best_q = q_solution;
        }
        
        // 理想收敛:误差 < 0.1mm
        if (current_error < eps) {
            return true;
        }
        
        // 计算雅可比矩阵(在 Frame 局部坐标系)
        computeFrameJacobian(*model_, data, q_solution, 
                             tip_frame_id_, LOCAL, J);
        
        // Jlog6 修正(流形上的雅可比)
        Matrix6 Jlog;
        Jlog6(iMd.inverse(), Jlog);
        J = -Jlog * J;
        
        // 阻尼伪逆求解(避免奇异点)
        Matrix6 JJt = J * J.transpose();
        JJt.diagonal().array() += damp;
        v = -J.transpose() * JJt.ldlt().solve(err);
        
        // 更新配置(使用 integrate,不是简单的加法)
        q_solution = integrate(*model_, q_solution, v * DT);
    }
    
    // 宽松收敛:误差 < 10mm,返回最优解
    if (best_error < eps_relaxed) {
        q_solution = best_q;
        return true;
    }
    
    return false;
}

几个关键点

[1] SE(3) 误差计算log6(iMd) 而不是简单的向量减法,因为旋转不是欧几里得空间

[2] Jlog6 修正:流形上的雅可比需要额外变换,否则数值不稳定

[3] 阻尼伪逆JJ^T + λI 避免奇异点(机械臂完全伸直时)导致数值爆炸

[4] 双阈值机制

  • 理想阈值 0.0001 (0.1mm):追求高精度
  • 宽松阈值 0.01 (10mm):提高成功率
  • 如果 1000 次迭代无法达到理想精度,返回误差最小的解

这套逻辑直接从官方示例搬过来,稳得一批

等等,这和插值法有啥区别?

细心的读者可能发现了:我们之前的插值法也是用雅可比迭代 + 阻尼伪逆,现在的 CLIK 也是。这俩不是一回事吗?

是,也不是。

核心区别

维度 插值法 CLIK (官方实现)
策略 分段求解(30 步) 直接求解(一步到位)
误差计算 世界坐标系(xyz 减法) Frame 局部坐标系actInv + log6
雅可比坐标系 LOCAL_WORLD_ALIGNED LOCAL(关键差异!)
SE(3) 处理 简化(只处理位置) 严格按流形几何Jlog6 修正)
收敛性 稳定(误差小) 依赖初始值(误差可能大)

通俗解释

插值法就像爬楼梯——把目标分成 30 个小台阶,每次只爬一级,累是累点,但稳。

CLIK 就像坐电梯——直接从 1 楼到 30 楼,快是快,但如果电梯坏了(奇异点、局部最优)就卡住了。

这是最优的实现吗?

坦白说,不是。

Pinocchio 的 examples/ 目录里的代码,目的是教学演示,不是生产级优化。更高级的 IK 方法包括:

1. 基于优化的方法(比 CLIK 更强)

  • Levenberg-Marquardt:自适应阻尼,收敛速度比固定阻尼快
  • BFGS/L-BFGS:拟牛顿法,利用二阶信息加速收敛
  • NLOPT:全局优化器,可以跳出局部最优

2. 解析解(比数值解快 100 倍)

  • IKFast:针对特定机械臂生成符号解
  • Analytical IK:手推公式(比如 6-DOF 球腕机械臂)

缺点:每个机械臂都要单独处理,通用性差。

3. 学习方法(未来方向)

  • 神经网络 IK:训练一个网络直接预测关节角度
  • 强化学习:在避障、能耗、舒适度等多目标下优化

缺点:黑盒不可解释,难以保证预期效果和安全性。

4. 多目标优化

  • 不只是"能到就行",还要考虑:
    • 最小化关节力矩(省电)
    • 远离奇异点(提高鲁棒性)
    • 最大化可操作度(留出操作余地)

我们为什么现在用 CLIK?

因为它是性能、通用性、工程复杂度的最佳平衡点

  • 性能够用:3-10ms,满足实时需求
  • 通用性强:换 URDF 就行,不用重新生成代码
  • 工程简单:200 行代码,易于维护和调试

未来的优化方向(我们的 TODO List):

  • 集成 NLOPT,支持全局优化
  • 实现多目标代价函数(力矩、奇异性、舒适度)
  • 探索神经网络 IK(特别是对于复杂机械臂)
  • 添加 IKFast 支持(对于已知型号的机械臂,用解析解加速)

这不是技术上做不到,而是当前的 CLIK 已经满足需求了。过早优化是万恶之源,等真正遇到性能瓶颈再说。


配置驱动:换机械臂只需改 YAML

现在面对多样的产品和方案,迭代与测试效率实在太重要了,必须要有一套可靠敏捷的切换方案,以下是一个配置示范。

首先是通用配置文件 (arm_planning.yaml)

PlanningModule:
  # ========== 机械臂配置 ==========
  dof: 5                      # 自由度
  urdf_path: "../models/robot_arm.urdf"
  tip_link: "end_effector_link"  # 末端 Frame 名称
  gripper_offset_z: 0.35      # TCP 偏移 (米)
  
  # ========== 控制模式 ==========
  input_type: "xyz"           # xyz (3D位置) / se3 (6D位姿)
  ik_solver_type: "official_3d"  # interpolation / official_3d / official_6d
  
  # ========== IK 求解参数 ==========
  ik_max_iterations: 1000     # 最大迭代次数
  ik_epsilon: 0.0001          # 理想阈值 (0.1mm)
  ik_epsilon_relaxed_3d: 0.005   # 3D 宽松阈值 (5mm)
  ik_epsilon_relaxed_6d: 0.01    # 6D 宽松阈值 (10mm)
  ik_damping_3d: 1e-12        # 3D 阻尼系数
  ik_damping_6d: 1e-6         # 6D 阻尼系数 (更大,更稳定)
  ik_step_size: 0.1           # 迭代步长
  
  # ========== 轨迹约束 ==========
  max_velocity: 1.0           # rad/s
  max_acceleration: 2.0       # rad/s²
  max_jerk: 5.0               # rad/s³
  control_frequency: 50.0     # Hz
  
  # ========== 关节限位 ==========
  joint_limits_min: [-3.14, -2.0, -2.5, -3.14, -3.14]
  joint_limits_max: [3.14, 2.0, 2.5, 3.14, 3.14]

场景配置覆盖 (panda_ik.yaml)

想测试 Franka Panda 机械臂(7-DOF)?直接覆盖:

PlanningModule:
  dof: 9  # 7 个旋转关节 + 2 个夹爪
  urdf_path: "../models/panda.urdf"
  tip_link: "panda_link8"
  gripper_offset_z: 0.0  # Panda link8 就是法兰盘
  
  input_type: "se3"           # 启用 6D 姿态控制
  ik_solver_type: "official_6d"
  
  # Panda 是 7-DOF 冗余机械臂,需要更多迭代
  ik_max_iterations: 1500
  ik_epsilon_relaxed_6d: 0.008  # Panda 精度更高
  
  max_velocity: 1.5
  max_acceleration: 3.0

就这么简单。不用改一行代码,不用重新编译。


参数调优指南

这些参数不是拍脑袋定的,每个都有讲究:

Q: ik_epsilonik_epsilon_relaxed 有啥区别?

A: 双阈值机制:

  • ik_epsilon (0.1mm):理想阈值,如果能达到就完美
  • ik_epsilon_relaxed (10mm):宽松阈值,实在达不到理想值,误差 < 10mm 也接受

为什么这么设计?因为 6D IK 太难了,有时候迭代 1000 次也就能收敛到 2mm 误差。如果只有一个严格阈值,成功率会很低;但如果只有一个宽松阈值,又无法优先追求高精度。

双阈值既追求极致,又不失实用


Q: ik_damping_3d 为什么是 1e-12,ik_damping_6d 是 1e-6?

A: 阻尼项 λ 通过阻尼最小二乘法(Damped Least Squares, DLS)用来防止奇异点导致数值爆炸的。

机械臂完全伸直时,此时雅可比矩阵几乎奇异(行列式接近 0),求逆会得到天文数字。加上阻尼:

(J·J^T + λ·I)^(-1)  // λ 就是阻尼系数

保证数值稳定。

但阻尼太大又会降低收敛精度,所以:

  • 3D IK:约束少(只有 xyz),阻尼可以极小 (1e-12)
  • 6D IK:约束多(xyz + rpy),更容易遇到奇异点,阻尼要大点 (1e-6)

效果展示

在这里插入图片描述

从日志中可以看到:

Received new planning request: (0.7, 0, 0.7, 0.0, -3.0, 0.0)  
q_current size: 9  
Using official 6D IK solver  
IK_6D converged perfectly in 57 iterations, error: 0.004974  
Step 1: IK solved successfully.  
q_goal: 0.0001143489937803, 0.7939736954387110, 0.0000359184955357, -0.2533000510055207, -0.0007637594565482  
Starting OMPL path planning...  
--- OMPL 边界检查 ---  
OMPL 认为的关节下限是: [ -2.9871 -1.8526 -2.9871 -3.1616 -2.9871 -0.1073 -2.9871 -0.02 -0.02 ]  
OMPL 认为的关节上限是: [ 2.9871 1.8526 2.9871 0.02 2.9871 3.8423 2.9871 0.06 0.06 ]  
------------------------  
Info:    LBKPIECE1: Starting planning with 1 states already in datastructure  
Info:    LBKPIECE1: Created 88 (45 start + 43 goal) states in 87 cells (44 start (44 on boundary) + 43 goal (43 on boundary))  
Info:    Solution found in 0.001423 seconds  
Info:    SimpleSetup: Path simplification took 0.000800 seconds and changed from 44 to 2 states  
Step 2: OMPL found a path with 2 waypoints.  
Starting Ruckig trajectory smoothing...  
成功生成缝合轨迹,共 1 段, 总时长 1.7940861670582369s  
Step 3: Ruckig generated a stitched trajectory of duration 1.79s.  
Preparing trajectory execution...  
Trajectory execution started.  
Trajectory execution finished.
  • IK 求解:57 次迭代,误差 5mm(完全够用)
  • OMPL 规划:1.4ms 找到无碰撞路径
  • Ruckig 平滑:生成 1.8 秒的丝滑轨迹

机械臂动起来又快又稳,没有抖动,没有超速。


参考资料

Logo

立足具身智能前沿赛道,致力于搭建全球化、开源化、全栈式技术交流与实践共创平台。

更多推荐