尝试做个机械臂(三) -- 机器人学

46 阅读10分钟

前言:

在前两篇文章中,基本不涉及机器人学。在这篇文章中,将主要验证运动学正逆解,运动学模型。

机器人学

MDH参数表建立

这里采用MDH,来源于《机器人学导论》这本书。 那么先看看机械臂样子,这是一个符合Pieper准则的机械臂:

Snipaste_2026-09-15_17-41-16.png

在建立MDH时,用手稿画了一版:

手稿.jpg

可能看的不直观,接下来在SW装配体中查看。

上图也是MDH参数建立完之后的姿态,solidworks中由于坐标的重叠,坐标系重叠在一起不容易看,所以我分为三张图片,展示了7个坐标(1个基坐标系origin_global和6个关节坐标系c1-c6):

Snipaste_2026-09-15_17-47-03.png

Snipaste_2026-09-15_17-50-44.png

Snipaste_2026-09-15_17-51-51.png

结合以上坐标系原点和通过测量连杆长度,可以得出以下MDH参数表:

序号α i - 1a i -1diθi
1000.1070
2pi / 2000
300.300
4- pi / 200.323150
5pi / 2000
6- pi /200.08250

运动学正逆解

将这个MDH参数表给AI(Gemini),生成出正逆解函数:

正解函数:

 static KinematicResult fk(List<double> jointAnglesDeg) {
    jointAnglesDeg[1] = jointAnglesDeg[1] + 90;
    jointAnglesDeg[2] = jointAnglesDeg[2] - 90;

    if (jointAnglesDeg.length != 6) {
      throw Exception("需要6个关节的角度");
    }

    // 1. 将输入的角度转为弧度用于运算
    List<double> th = jointAnglesDeg.map((deg) => _deg2rad(deg)).toList();

    // 2. 构建变换矩阵
    List<List<double>> t1 = _mdhMatrix(0, 0, d1, th[0]);
    List<List<double>> t2 = _mdhMatrix(pi / 2, 0, 0, th[1]);
    List<List<double>> t3 = _mdhMatrix(0, a2, 0, th[2]);
    List<List<double>> t4 = _mdhMatrix(-pi / 2, 0, d4, th[3]);
    List<List<double>> t5 = _mdhMatrix(pi / 2, 0, 0, th[4]);
    List<List<double>> t6 = _mdhMatrix(-pi / 2, 0, d6, th[5]);

    // 连续相乘求得末端姿态 t06 (行主序)
    List<List<double>> t06 = _multiply(
      _multiply(_multiply(_multiply(_multiply(t1, t2), t3), t4), t5),
      t6,
    );

    // 3. 构建 Matrix4 变换矩阵 (将行主序转为列主序)
    final Matrix4 transformation = Matrix4(
      t06[0][0],
      t06[1][0],
      t06[2][0],
      t06[3][0],
      t06[0][1],
      t06[1][1],
      t06[2][1],
      t06[3][1],
      t06[0][2],
      t06[1][2],
      t06[2][2],
      t06[3][2],
      t06[0][3],
      t06[1][3],
      t06[2][3],
      t06[3][3],
    );

    // 4. 提取位移 Vector3
    final Vector3 position = Vector3(t06[0][3], t06[1][3], t06[2][3]);

    // 5. 提取姿态 Quaternion
    // 利用 vector_math 提供的 getRotation 方法提取 3x3 旋转矩阵,并直接生成四元数
    final Quaternion quaternion = Quaternion.fromRotation(
      transformation.getRotation(),
    );

    // 6. 计算 Euler XYZ 欧拉角 (单位: 弧度)
    // 采用标准 XYZ 顺规解析,并处理万向锁(奇异点)保护
    double sy = sqrt(t06[0][0] * t06[0][0] + t06[1][0] * t06[1][0]);
    bool singular = sy < 1e-6; // 检测是否发生万向锁

    double ex, ey, ez;
    if (!singular) {
      ex = atan2(t06[2][1], t06[2][2]);
      ey = atan2(-t06[2][0], sy);
      ez = atan2(t06[1][0], t06[0][0]);
    } else {
      // 处于万向锁状态时,强行将 z 轴旋转归零,完全由 x 轴补偿
      ex = atan2(-t06[1][2], t06[1][1]);
      ey = atan2(-t06[2][0], sy);
      ez = 0;
    }
    final Vector3 eulerXYZ = Vector3(ex, ey, ez);

    // 7. 返回结果
    return KinematicResult(
      position: position,
      quaternion: quaternion,
      eulerXYZ: eulerXYZ,
      transformation: transformation,
    );
  }

逆解函数:

static List<List<double>> ik(Matrix4 targetPose, List<double>? preJointRad) {
    preJointRad?[1] = preJointRad[1] + pi / 2;
    preJointRad?[2] = preJointRad[2] - pi / 2;

    List<List<double>> solutions = [];

    // 1. 提取目标位移和旋转矩阵元素
    double px = targetPose.entry(0, 3);
    double py = targetPose.entry(1, 3);
    double pz = targetPose.entry(2, 3);

    double r11 = targetPose.entry(0, 0);
    double r12 = targetPose.entry(0, 1);
    double r13 = targetPose.entry(0, 2);
    double r21 = targetPose.entry(1, 0);
    double r22 = targetPose.entry(1, 1);
    double r23 = targetPose.entry(1, 2);
    double r31 = targetPose.entry(2, 0);
    double r32 = targetPose.entry(2, 1);
    double r33 = targetPose.entry(2, 2);

    // 2. 反推球形腕中心点 (Wrist Center)
    double wx = px - d6 * r13;
    double wy = py - d6 * r23;
    double wz = pz - d6 * r33;

    // ==========================================
    // 【新增】:肩部奇异点处理 (Shoulder Singularity)
    // 跨越北极点: 锁定轴1为上一帧的值
    // ==========================================
    double rXySq = wx * wx + wy * wy;
    List<double> th1List = [];

    // 如果腕心水平投影半径极小 (1毫米以内视为奇异)
    if (rXySq < 1e-6) {
      // 锁定 θ1 为上一帧的角度,此时不再分正反向
      double prevTh1 = (preJointRad != null && preJointRad.isNotEmpty)
          ? preJointRad[0]
          : 0.0;
      th1List = [prevTh1];
    } else {
      // 正常区域,产生正反两个解
      th1List = [atan2(wy, wx), atan2(-wy, -wx)];
    }

    // --- 分支 1:肩部姿态 ---
    for (double th1 in th1List) {
      double r = sqrt(rXySq);
      // 如果肩部反向,r 取负值
      if ((th1 - atan2(wy, wx)).abs() > 1e-4) {
        r = -r;
      }
      double zPrime = wz - d1;

      // ==========================================
      // 肘部奇异点处理 (Elbow Singularity): 可达空间的处理
      // 1.如果超过外部可达空间: 无解
      // 2.如果点位在于内部折叠导致的死区,大臂与小臂的差值:无解
      // ==========================================
      double sinTh3 =
          (a2 * a2 + d4 * d4 - (r * r + zPrime * zPrime)) / (2 * a2 * d4);

      // 数学钳位与物理越界拦截
      if (sinTh3 > 1.0) {
        if (sinTh3 > 1.001) continue; // 超出最大物理臂展过多,直接抛弃该解
        sinTh3 = 1.0; // 浮点误差导致的边界奇异点,强制钳位,防止后续 acos 出现 NaN
      } else if (sinTh3 < -1.0) {
        if (sinTh3 < -1.001) continue; // 进入内侧极限折叠死区,抛弃该解
        sinTh3 = -1.0; // 强制钳位
      }

      // --- 分支 2:肘部姿态 (2种: 肘朝上 / 肘朝下) ---
      double cosTh3Base = sqrt(1 - sinTh3 * sinTh3);
      List<double> cosTh3List = [cosTh3Base, -cosTh3Base];

      for (double cosTh3 in cosTh3List) {
        double th3 = atan2(sinTh3, cosTh3);

        double A = a2 - d4 * sinTh3;
        double B = d4 * cosTh3;
        double th2 = atan2(A * zPrime - B * r, A * r + B * zPrime);

        // 计算前置姿态 T03
        List<List<double>> t1 = _mdhMatrix(0, 0, d1, th1);
        List<List<double>> t2 = _mdhMatrix(pi / 2, 0, 0, th2);
        List<List<double>> t3m = _mdhMatrix(0, a2, 0, th3);
        List<List<double>> t03 = _multiply(_multiply(t1, t2), t3m);

        List<List<double>> r03T = [
          [t03[0][0], t03[1][0], t03[2][0]],
          [t03[0][1], t03[1][1], t03[2][1]],
          [t03[0][2], t03[1][2], t03[2][2]],
        ];

        List<List<double>> rTarget = [
          [r11, r12, r13],
          [r21, r22, r23],
          [r31, r32, r33],
        ];

        List<List<double>> r36 = List.generate(3, (_) => List.filled(3, 0.0));
        for (int i = 0; i < 3; i++) {
          for (int j = 0; j < 3; j++) {
            for (int k = 0; k < 3; k++) {
              r36[i][j] += r03T[i][k] * rTarget[k][j];
            }
          }
        }

        double r36_12 = r36[1][2].clamp(-1.0, 1.0);

        // --- 分支 3:腕部姿态 (2种: 姿态正向 / 姿态翻转) ---
        double th5Base = acos(r36_12);
        List<double> th5List = [th5Base, -th5Base];

        for (double th5 in th5List) {
          double th4, th6;
          double sinTh5 = sin(th5);

          // ==========================================
          // 【修改】:腕部奇异点处理 (Wrist Singularity)
          // ==========================================
          if (sinTh5.abs() > 1e-4) {
            // 正常姿态:使用 Dart 的 .sign 属性来获取正负符号 (1.0 或 -1.0)
            double sign = sinTh5.sign;
            th4 = atan2(r36[2][2] * sign, -r36[0][2] * sign);
            th6 = atan2(-r36[1][1] * sign, r36[1][0] * sign);
          } else {
            // 奇异姿态 (Gimbal Lock)(第五轴角度为0时):4轴和6轴重合
            if (preJointRad != null && preJointRad.length >= 6) {
              th4 = preJointRad[3]; // 核心:锁定 4轴 为上一帧的角度
              th6 = atan2(-r36[0][1], r36[0][0]) - th4; // 让 6轴 承担所有剩余的旋转补偿
            } else {
              th4 = 0.0;
              th6 = atan2(-r36[0][1], r36[0][0]);
            }
          }

          // solutions.add([th1, th2 - pi / 2, th3 + pi / 2, th4, th5, th6]);
          solutions.add(
            [
              th1,
              th2 - pi / 2,
              th3 + pi / 2,
              th4,
              th5,
              th6,
            ].map((e) => normalizeAngle(e)).toList(),
          );
        }
      }
    }

    return solutions;
  }

得到以上函数,直接将其集成进flutter app中,并验证:
正解验证:白色小方块即是正解得出的值在空间的位置:

image.png

逆解验证:实现机械臂末端沿样条曲线运动的功能: pathTrack.gif

动力学模型

主要有两种方式: 拉格朗日能量法和递归牛顿欧拉算法(RENA)。在六轴机械臂中,一般选用RENA。这个算法需要两个信息:1. MDH参数表 2. 连杆质量,质心位置和惯性张量
第一个参数已经有了。第二个参数可以通过solidwors软件的“评估质量”功能获得。 特别需要注意的是:评估质量的坐标系需要和MDH参数表的坐标系完全一致。 baseLink_motor1.png

link1_motor2.png

link2_motor3.png

由于我只有三个电机,所以连杆3开始的评估质量没有携带电机的质量。电机的位置和质量会影响质心和惯性。所以在此需要单独评估: link3.png

link4.png

link5.png

link6.png 上方图中的质量只是软件估计的值,打开Bambu studio,去掉支撑,切片一下,即可得到打印件的质量:

打印的质量.png 将这六张图片和MDH参数交给Gemini,就可以生成出RENA函数了。

/// 递归牛顿-欧拉算法(RNEA)计算函数(无腕部电机轻量测试版)
///
/// 输入参数:
/// - [q]: 各关节角度,单位:弧度 (rad)
/// - [dq]: 各关节角速度,单位:rad/s
/// - [ddq]: 各关节角加速度,单位:rad/s^2
/// - [gBase]: 基座重力加速度向量,默认竖直向下 [0, 0, -9.81] m/s^2
///
/// 返回:各关节所需的驱动力矩 List<double>,单位:N·m
List<double> computeRNEA(
  List<double> q,
  List<double> dq,
  List<double> ddq, {
  Vector3? gBase,
}) {
  final Vector3 gravity = gBase ?? Vector3(0.0, 0.0, -9.81);
  const int n = 6;

  // 初始化 6 个连杆的 MDH 参数与【不含腕部电机】的几何惯量数据
  final List<LinkParams> links = [
    LinkParams(
      alphaPrev: 0.0,
      aPrev: 0.0,
      d: 0.107,
      m: 0.4716,
      rC: Vector3(0.0000, -0.0323, -0.0018),
    ),
    LinkParams(
      alphaPrev: math.pi / 2,
      aPrev: 0.0,
      d: 0.0,
      // m: 0.5741,
      m: 0.5395,
      rC: Vector3(0.2362, 0.0000, 0.0897),
    ),
    LinkParams(
      alphaPrev: 0.0,
      aPrev: 0.3,
      d: 0.0,
      // m: 0.1968,
      m: 0.129,
      rC: Vector3(0.0000, 0.0113, 0.0054),
    ),
    LinkParams(
      alphaPrev: -math.pi / 2,
      aPrev: 0.0,
      d: 0.32315,
      // m: 0.3110,
      m: 0.205,
      rC: Vector3(-0.0055, 0.0324, -0.1524),
    ),
    LinkParams(
      alphaPrev: math.pi / 2,
      aPrev: 0.0,
      d: 0.0,
      // m: 0.1120,
      m: 0.09,
      rC: Vector3(0.0000, 0.0098, -0.0047),
    ),
    LinkParams(
      alphaPrev: -math.pi / 2,
      aPrev: 0.0,
      d: 0.0,
      // m: 0.0041,
      m: 0.00,
      rC: Vector3(0.0000, -0.0030, 0.0650),
    ),
  ];

  // Link 1 原点惯量
  links[0].iO.m[0][0] = 0.0013;
  links[0].iO.m[1][1] = 0.0005;
  links[0].iO.m[2][2] = 0.0013;

  // Link 2 原点惯量
  links[1].iO.m[0][0] = 0.0050;
  links[1].iO.m[1][1] = 0.0430;
  links[1].iO.m[2][2] = 0.0386;
  links[1].iO.m[0][2] = -0.0120;
  links[1].iO.m[2][0] = -0.0120;

  // Link 3 原点惯量 (无电机)
  links[2].iO.m[0][0] = 0.0006;
  links[2].iO.m[1][1] = 0.0005;
  links[2].iO.m[2][2] = 0.0005;

  // Link 4 原点惯量 (无电机)
  links[3].iO.m[0][0] = 0.0102;
  links[3].iO.m[1][1] = 0.0097;
  links[3].iO.m[2][2] = 0.0011;
  links[3].iO.m[0][2] = 0.0004;
  links[3].iO.m[2][0] = 0.0004;
  links[3].iO.m[1][2] = -0.0008;
  links[3].iO.m[2][1] = -0.0008;

  // Link 5 原点惯量 (无电机)
  links[4].iO.m[0][0] = 0.0002;
  links[4].iO.m[1][1] = 0.0002;
  links[4].iO.m[2][2] = 0.0002;

  // Link 6 原点惯量 (保持为 0)

  final List<Vector3> w = List.generate(n, (_) => Vector3());
  final List<Vector3> dw = List.generate(n, (_) => Vector3());
  final List<Vector3> a = List.generate(n, (_) => Vector3());
  final List<Vector3> aC = List.generate(n, (_) => Vector3());
  final List<Vector3> fC = List.generate(n, (_) => Vector3());
  final List<Vector3> nC = List.generate(n, (_) => Vector3());
  final List<Matrix3x3> rIPrev = List.generate(n, (_) => Matrix3x3());

  Vector3 wPrev = Vector3(0, 0, 0);
  Vector3 dwPrev = Vector3(0, 0, 0);
  Vector3 aPrev = gravity * -1.0;
  final Vector3 zHat = Vector3(0, 0, 1.0);

  // ================= 1. 前向递推 (Forward Recursion) =================
  for (int i = 0; i < n; i++) {
    final double theta = q[i];
    final double ct = math.cos(theta);
    final double st = math.sin(theta);
    final double ca = math.cos(links[i].alphaPrev);
    final double sa = math.sin(links[i].alphaPrev);

    final Matrix3x3 r = Matrix3x3();
    r.m[0][0] = ct;
    r.m[0][1] = st * ca;
    r.m[0][2] = st * sa;
    r.m[1][0] = -st;
    r.m[1][1] = ct * ca;
    r.m[1][2] = ct * sa;
    r.m[2][0] = 0.0;
    r.m[2][1] = -sa;
    r.m[2][2] = ca;
    rIPrev[i] = r;

    final Vector3 wInI = r.multiplyVector(wPrev);
    final Vector3 dwInI = r.multiplyVector(dwPrev);

    w[i] = wInI + zHat * dq[i];
    dw[i] = dwInI + wInI.cross(zHat * dq[i]) + zHat * ddq[i];

    final Vector3 pInI = Vector3(
      links[i].aPrev * ct,
      -links[i].aPrev * st,
      links[i].d,
    );
    a[i] =
        r.multiplyVector(aPrev) +
        dwInI.cross(pInI) +
        wInI.cross(wInI.cross(pInI));

    aC[i] =
        a[i] + dw[i].cross(links[i].rC) + w[i].cross(w[i].cross(links[i].rC));

    // 平行轴定理转换惯量张量
    final Matrix3x3 iC = Matrix3x3();
    final double m = links[i].m;
    final double xc = links[i].rC.x;
    final double yc = links[i].rC.y;
    final double zc = links[i].rC.z;

    iC.m[0][0] = links[i].iO.m[0][0] - m * (yc * yc + zc * zc);
    iC.m[1][1] = links[i].iO.m[1][1] - m * (xc * xc + zc * zc);
    iC.m[2][2] = links[i].iO.m[2][2] - m * (xc * xc + yc * yc);
    iC.m[0][1] = links[i].iO.m[0][1] + m * xc * yc;
    iC.m[1][0] = iC.m[0][1];
    iC.m[0][2] = links[i].iO.m[0][2] + m * xc * zc;
    iC.m[2][0] = iC.m[0][2];
    iC.m[1][2] = links[i].iO.m[1][2] + m * yc * zc;
    iC.m[2][1] = iC.m[1][2];

    fC[i] = aC[i] * m;
    nC[i] = iC.multiplyVector(dw[i]) + w[i].cross(iC.multiplyVector(w[i]));

    wPrev = w[i];
    dwPrev = dw[i];
    aPrev = a[i];
  }

  // ================= 2. 后向递推 (Backward Recursion) =================
  final List<double> tau = List.filled(n, 0.0);
  Vector3 fNext = Vector3(0, 0, 0);
  Vector3 nNext = Vector3(0, 0, 0);

  for (int i = n - 1; i >= 0; i--) {
    Vector3 fCurr = fC[i];
    Vector3 nCurr = nC[i] + links[i].rC.cross(fC[i]);

    if (i < n - 1) {
      final Matrix3x3 rNextCurr = rIPrev[i + 1].transpose();
      final Vector3 fNextInCurr = rNextCurr.multiplyVector(fNext);
      final Vector3 nNextInCurr = rNextCurr.multiplyVector(nNext);

      final Vector3 pNextInCurr = Vector3(
        links[i + 1].aPrev,
        -links[i + 1].d * math.sin(links[i + 1].alphaPrev),
        links[i + 1].d * math.cos(links[i + 1].alphaPrev),
      );

      fCurr = fCurr + fNextInCurr;
      nCurr = nCurr + nNextInCurr + pNextInCurr.cross(fNextInCurr);
    }

    tau[i] = nCurr.z;

    fNext = fCurr;
    nNext = nCurr;
  }

  return tau;
}

将其集成进flutter app验证一下,下图可看出在某个角度下,RENA计算出来的力矩:

image.png

image.png

将计算的数值应用到实际的机械臂中,做个‘零重力’模式验证一下:

IMG_5118.jpeg

结语

一路的过程挺麻烦的,到此要下笔了,却感觉没啥好写的。这是倒数第二篇,最后还有一篇,是关于vla和手眼标定,还没开始学,到时写完文章应该要明年了。