manipulationrobot-armforward-kinematicsdh-parametersse3ur5eigenpinocchiorobotics-toolbox

Forward Kinematics from Scratch: DH to SE(3) in C++

Compute UR5 FK from DH by hand, implement NumPy/C++ Eigen from scratch, then verify with GoogleTest, RTB and Pinocchio.

Nguyễn Anh TuấnJune 13, 202614 min read
Forward Kinematics from Scratch: DH to SE(3) in C++

In Part 3, we agreed on the basic robot-arm model: joints, links, frames and transforms. This article focuses on one thing only: forward kinematics. Given a joint vector q, we compute the tool pose in the base frame:

q = [q1, q2, q3, q4, q5, q6]^T  --->  T_base_tool in SE(3)

This is the foundation of a classical controller. MoveJ needs FK to display the current pose. MoveL needs FK to check the TCP path. Tool-frame jogging needs FK to know where the tool axes are pointing. IK in Part 5 goes in the opposite direction, from pose to joints. Jacobians and singularities are intentionally left for Part 6, because packing FK, IK and Jacobians into a single beginner article usually hides the most important issue: conventions.

The article has three layers. First, we compute FK by hand for one concrete UR5 configuration. Second, we implement it from scratch in Python and production-style C++/Eigen. Third, we validate the result with real libraries: Robotics Toolbox for Python and Pinocchio 3.x. The DH parameters come from the Universal Robots DH parameters page. Robotics Toolbox documents DHRobot and fkine() in its DH model documentation. For Pinocchio, the important pattern is pin.forwardKinematics() followed by pin.updateFramePlacements(); according to GitHub releases, Pinocchio 3.9.0 is the latest 3.x release before June 13, 2026.

Universal Robots DH and link diagram - source: Universal Robots
Universal Robots DH and link diagram - source: Universal Robots

What FK Computes

A 3D rigid-body pose is commonly represented as a 4x4 homogeneous matrix:

T = [ R  p ]
    [ 0  1 ]

R is a 3x3 rotation matrix and p is a 3D translation vector. If R is orthonormal and det(R) = 1, then T belongs to SE(3), the special Euclidean group. In a controller core, avoid returning only (x, y, z, roll, pitch, yaw). Euler angles have multiple conventions and can become singular. Compute FK as SE(3) first; convert to Euler angles, quaternions or axis-angle only at the UI or API boundary.

For a 6-DOF serial arm, FK is just a chain product:

T_0_6 = A_1(q1) * A_2(q2) * A_3(q3) * A_4(q4) * A_5(q5) * A_6(q6)

If a TCP offset exists, append it:

T_base_tcp = T_0_6 * T_flange_tcp

In this article, the TCP is assumed to coincide with the last DH frame, so T_flange_tcp = I. On a real robot, a suction cup, welding tip, camera or gripper needs its own fixed tool transform. Frames and TCP offsets return in the article on frames and jogging.

Standard DH for UR5

We use standard DH:

A_i = RotZ(theta_i) * TransZ(d_i) * TransX(a_i) * RotX(alpha_i)

The matrix form is:

A_i =
[ cosθ  -sinθ cosα   sinθ sinα   a cosθ ]
[ sinθ   cosθ cosα  -cosθ sinα   a sinθ ]
[ 0      sinα        cosα        d      ]
[ 0      0           0           1      ]

The nominal UR5 DH table from Universal Robots is:

Joint a (m) d (m) alpha (rad)
1 0 0.089159 pi/2
2 -0.425 0 0
3 -0.39225 0 0
4 0 0.10915 pi/2
5 0 0.09465 -pi/2
6 0 0.0823 0

There are two practical caveats. First, these are nominal parameters, not the factory calibration for your specific arm. A real UR controller uses calibration data to compensate manufacturing deviations. If you need exact agreement with the teach pendant, you must use that calibration. Second, libraries and URDF files may use frame conventions that differ from standard DH. If the result is rotated by 90 degrees or an axis changes sign, inspect the frames before changing the numbers.

Manual FK for One UR5 Configuration

Choose this UR5 joint configuration:

q = [30°, -60°, 90°, -120°, -90°, 45°]
  = [0.523599, -1.047198, 1.570796, -2.094395, -1.570796, 0.785398] rad

Substituting theta_i = q_i into the DH formula gives the six matrices below. Values are rounded to six decimals for readability; tests should use a tolerance around 1e-6.

A1 =
[  0.866025  -0.000000   0.500000   0.000000 ]
[  0.500000   0.000000  -0.866025   0.000000 ]
[  0.000000   1.000000   0.000000   0.089159 ]
[  0.000000   0.000000   0.000000   1.000000 ]

A2 =
[  0.500000   0.866025  -0.000000  -0.212500 ]
[ -0.866025   0.500000  -0.000000   0.368061 ]
[  0.000000   0.000000   1.000000   0.000000 ]
[  0.000000   0.000000   0.000000   1.000000 ]

A3 =
[  0.000000  -1.000000   0.000000  -0.000000 ]
[  1.000000   0.000000  -0.000000  -0.392250 ]
[  0.000000   0.000000   1.000000   0.000000 ]
[  0.000000   0.000000   0.000000   1.000000 ]

A4 =
[ -0.500000   0.000000  -0.866025  -0.000000 ]
[ -0.866025  -0.000000   0.500000  -0.000000 ]
[  0.000000   1.000000   0.000000   0.109150 ]
[  0.000000   0.000000   0.000000   1.000000 ]

A5 =
[  0.000000   0.000000   1.000000   0.000000 ]
[ -1.000000   0.000000   0.000000  -0.000000 ]
[  0.000000  -1.000000   0.000000   0.094650 ]
[  0.000000   0.000000   0.000000   1.000000 ]

A6 =
[  0.707107  -0.707107   0.000000   0.000000 ]
[  0.707107   0.707107  -0.000000   0.000000 ]
[  0.000000   0.000000   1.000000   0.082300 ]
[  0.000000   0.000000   0.000000   1.000000 ]

Now multiply the chain step by step:

T0_2 = A1 * A2 =
[  0.433013   0.750000   0.500000  -0.184030 ]
[  0.250000   0.433013  -0.866025  -0.106250 ]
[ -0.866025   0.500000   0.000000   0.457220 ]
[  0.000000   0.000000   0.000000   1.000000 ]

T0_3 = T0_2 * A3 =
[  0.750000  -0.433013   0.500000  -0.478218 ]
[  0.433013  -0.250000  -0.866025  -0.276099 ]
[  0.500000   0.866025   0.000000   0.261095 ]
[  0.000000   0.000000   0.000000   1.000000 ]

T0_4 = T0_3 * A4 =
[  0.000000   0.500000  -0.866025  -0.423643 ]
[  0.000000  -0.866025  -0.500000  -0.370626 ]
[ -1.000000   0.000000  -0.000000   0.261095 ]
[  0.000000   0.000000   0.000000   1.000000 ]

T0_5 = T0_4 * A5 =
[ -0.500000   0.866025   0.000000  -0.505612 ]
[  0.866025   0.500000  -0.000000  -0.417951 ]
[ -0.000000   0.000000  -1.000000   0.261095 ]
[  0.000000   0.000000   0.000000   1.000000 ]

Finally:

T0_6 = T0_5 * A6 =
[  0.258819   0.965926   0.000000  -0.505612 ]
[  0.965926  -0.258819  -0.000000  -0.417951 ]
[  0.000000   0.000000  -1.000000   0.178795 ]
[  0.000000   0.000000   0.000000   1.000000 ]

The nominal TCP position is:

p = [-0.505612, -0.417951, 0.178795] m

and the orientation is:

R =
[ 0.258819   0.965926   0.000000 ]
[ 0.965926  -0.258819  -0.000000 ]
[ 0.000000   0.000000  -1.000000 ]

You can quickly check that R^T R = I and det(R) = 1. If these fail, you probably mixed transform order, used degrees inside the core, or confused standard DH with modified DH.

Universal Robots FK calculation example - source: Universal Robots
Universal Robots FK calculation example - source: Universal Robots

Python from Scratch

The Python code below intentionally avoids robotics libraries. We define RotX, RotY, RotZ, Trans, then build DH from them. For beginners, this is better than immediately calling robot.fkine(q), because every transform remains visible.

import numpy as np

def rotx(alpha: float) -> np.ndarray:
    c, s = np.cos(alpha), np.sin(alpha)
    return np.array([
        [1, 0, 0, 0],
        [0, c, -s, 0],
        [0, s, c, 0],
        [0, 0, 0, 1],
    ], dtype=float)

def roty(beta: float) -> np.ndarray:
    c, s = np.cos(beta), np.sin(beta)
    return np.array([
        [c, 0, s, 0],
        [0, 1, 0, 0],
        [-s, 0, c, 0],
        [0, 0, 0, 1],
    ], dtype=float)

def rotz(theta: float) -> np.ndarray:
    c, s = np.cos(theta), np.sin(theta)
    return np.array([
        [c, -s, 0, 0],
        [s, c, 0, 0],
        [0, 0, 1, 0],
        [0, 0, 0, 1],
    ], dtype=float)

def trans(x: float, y: float, z: float) -> np.ndarray:
    T = np.eye(4)
    T[:3, 3] = [x, y, z]
    return T

def dh_standard(a: float, d: float, alpha: float, theta: float) -> np.ndarray:
    return rotz(theta) @ trans(0, 0, d) @ trans(a, 0, 0) @ rotx(alpha)

UR5_DH = [
    (0.0,      0.089159,  np.pi / 2),
    (-0.425,   0.0,       0.0),
    (-0.39225, 0.0,       0.0),
    (0.0,      0.10915,   np.pi / 2),
    (0.0,      0.09465,  -np.pi / 2),
    (0.0,      0.0823,    0.0),
]

def forward_kinematics(q: np.ndarray) -> np.ndarray:
    if q.shape != (6,):
        raise ValueError("UR5 expects q with shape (6,)")

    T = np.eye(4)
    for (a, d, alpha), theta in zip(UR5_DH, q):
        T = T @ dh_standard(a, d, alpha, theta)
    return T

if __name__ == "__main__":
    q = np.deg2rad([30, -60, 90, -120, -90, 45])
    T = forward_kinematics(q)
    np.set_printoptions(precision=6, suppress=True)
    print(T)

Expected output:

[[ 0.258819  0.965926  0.       -0.505612]
 [ 0.965926 -0.258819 -0.       -0.417951]
 [ 0.        0.       -1.        0.178795]
 [ 0.        0.        0.        1.      ]]

Before moving to C++, run this checklist:

Check How
Radians np.deg2rad() only at the input boundary
Valid rotation np.allclose(R.T @ R, I)
Determinant np.linalg.det(R) near 1.0
Units all a, d and p values are meters
Multiplication order T = T @ A_i, not A_i @ T

Production C++ with Eigen::Isometry3d

In C++, we want a clearer API than a notebook script. A compact structure is DHJoint::transform(theta) plus forwardKinematics(q) returning Eigen::Isometry3d.

// include/arm_kinematics/kinematics.hpp
#pragma once

#include <array>
#include <numbers>
#include <Eigen/Geometry>

namespace arm_kinematics {

struct DHJoint {
  double a;
  double d;
  double alpha;

  Eigen::Isometry3d transform(double theta) const;
};

class UR5Kinematics {
 public:
  Eigen::Isometry3d forwardKinematics(const Eigen::Vector<double, 6>& q) const;

 private:
  static constexpr std::array<DHJoint, 6> dh_ = {{
      {0.0,      0.089159,  std::numbers::pi / 2.0},
      {-0.425,   0.0,       0.0},
      {-0.39225, 0.0,       0.0},
      {0.0,      0.10915,   std::numbers::pi / 2.0},
      {0.0,      0.09465,  -std::numbers::pi / 2.0},
      {0.0,      0.0823,    0.0},
  }};
};

}  // namespace arm_kinematics
// src/kinematics.cpp
#include "arm_kinematics/kinematics.hpp"

#include <cmath>

namespace arm_kinematics {

Eigen::Isometry3d DHJoint::transform(double theta) const {
  const double c = std::cos(theta);
  const double s = std::sin(theta);
  const double ca = std::cos(alpha);
  const double sa = std::sin(alpha);

  Eigen::Matrix4d m;
  m << c, -s * ca,  s * sa, a * c,
       s,  c * ca, -c * sa, a * s,
       0,       sa,      ca,     d,
       0,        0,       0,     1;

  Eigen::Isometry3d T = Eigen::Isometry3d::Identity();
  T.matrix() = m;
  return T;
}

Eigen::Isometry3d UR5Kinematics::forwardKinematics(
    const Eigen::Vector<double, 6>& q) const {
  Eigen::Isometry3d T = Eigen::Isometry3d::Identity();
  for (int i = 0; i < 6; ++i) {
    T = T * dh_[i].transform(q[i]);
  }
  return T;
}

}  // namespace arm_kinematics

Isometry3d is better than exposing a raw Matrix4d because the type communicates intent: this is a rigid transform, not an arbitrary matrix. Inside DHJoint::transform(), the matrix is assigned directly to make the DH convention unambiguous.

CMake and GoogleTest

Minimal CMakeLists.txt:

cmake_minimum_required(VERSION 3.22)
project(arm_fk LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(Eigen3 3.4 REQUIRED NO_MODULE)

add_library(arm_kinematics src/kinematics.cpp)
target_include_directories(arm_kinematics PUBLIC include)
target_link_libraries(arm_kinematics PUBLIC Eigen3::Eigen)

include(FetchContent)
FetchContent_Declare(
  googletest
  URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)

enable_testing()

add_executable(fk_test tests/fk_test.cpp)
target_link_libraries(fk_test PRIVATE arm_kinematics GTest::gtest_main)

include(GoogleTest)
gtest_discover_tests(fk_test)

Unit test:

// tests/fk_test.cpp
#include "arm_kinematics/kinematics.hpp"

#include <cmath>
#include <numbers>
#include <gtest/gtest.h>

using arm_kinematics::UR5Kinematics;

TEST(UR5ForwardKinematics, MatchesHandComputedPose) {
  UR5Kinematics fk;

  Eigen::Vector<double, 6> q;
  constexpr double pi = std::numbers::pi;
  q << pi / 6.0, -pi / 3.0, pi / 2.0,
       -2.0 * pi / 3.0, -pi / 2.0, pi / 4.0;

  const Eigen::Isometry3d T = fk.forwardKinematics(q);

  Eigen::Matrix4d expected;
  expected << 0.258819,  0.965926,  0.000000, -0.505612,
              0.965926, -0.258819, -0.000000, -0.417951,
              0.000000,  0.000000, -1.000000,  0.178795,
              0.000000,  0.000000,  0.000000,  1.000000;

  EXPECT_TRUE(T.matrix().isApprox(expected, 1e-6));
}

TEST(UR5ForwardKinematics, RotationIsValid) {
  UR5Kinematics fk;
  Eigen::Vector<double, 6> q;
  q << 0.2, -1.1, 1.4, -0.7, 0.9, -0.3;

  const Eigen::Matrix3d R = fk.forwardKinematics(q).rotation();
  EXPECT_TRUE((R.transpose() * R).isApprox(Eigen::Matrix3d::Identity(), 1e-12));
  EXPECT_NEAR(R.determinant(), 1.0, 1e-12);
}

Build and run:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build -V

If the first test fails by a fraction of a millimeter, inspect d1. Some older UR5 packages use 0.08946, while the UR table used here lists 0.089159. A 0.301 mm difference in d1 is enough to move the expected pose.

UR5 FK example in a plane view - source: Universal Robots
UR5 FK example in a plane view - source: Universal Robots

Validate with Robotics Toolbox

Robotics Toolbox for Python can build a DHRobot from the same table and call fkine(). This is a good validation step because it uses the same standard DH convention but a different implementation.

import numpy as np
from roboticstoolbox import DHRobot, RevoluteDH

ur5 = DHRobot(
    [
        RevoluteDH(d=0.089159, a=0.0,      alpha=np.pi / 2),
        RevoluteDH(d=0.0,      a=-0.425,   alpha=0.0),
        RevoluteDH(d=0.0,      a=-0.39225, alpha=0.0),
        RevoluteDH(d=0.10915,  a=0.0,      alpha=np.pi / 2),
        RevoluteDH(d=0.09465,  a=0.0,      alpha=-np.pi / 2),
        RevoluteDH(d=0.0823,   a=0.0,      alpha=0.0),
    ],
    name="UR5_nominal_UR_DH",
)

q = np.deg2rad([30, -60, 90, -120, -90, 45])
T = ur5.fkine(q).A

expected = np.array([
    [0.258819,  0.965926,  0.0, -0.505612],
    [0.965926, -0.258819,  0.0, -0.417951],
    [0.0,       0.0,      -1.0,  0.178795],
    [0.0,       0.0,       0.0,  1.0],
])

np.testing.assert_allclose(T, expected, atol=1e-6)
print(T)

You can also try rtb.models.DH.UR5(), but print its DH table first. The Robotics Toolbox documentation states that UR5 uses standard DH and SI units, but packaged parameters can be rounded or tied to a specific model revision. For a tutorial, a manually constructed DHRobot keeps every number identical across the article, Python and C++.

Validate with Pinocchio 3.x

Pinocchio is usually introduced through URDF, dynamics, Jacobians and planning stacks. Its FK implementation is production-grade as well. In Pinocchio 3.x, the core pattern is:

pin.forwardKinematics(model, data, q)
pin.updateFramePlacements(model, data)
T = data.oMf[frame_id]

To validate the exact DH table from this article, the snippet below builds a minimal Pinocchio model: every joint is revolute around z, and the fixed segment after each joint is TransZ(d) * TransX(a) * RotX(alpha). The tool0 frame is attached after joint 6.

import numpy as np
import pinocchio as pin

def dh_fixed(a, d, alpha):
    R = pin.rpy.rpyToMatrix(alpha, 0.0, 0.0)  # RotX(alpha)
    p = np.array([a, 0.0, d])
    return pin.SE3(R, p)

dh = [
    (0.0,      0.089159,  np.pi / 2),
    (-0.425,   0.0,       0.0),
    (-0.39225, 0.0,       0.0),
    (0.0,      0.10915,   np.pi / 2),
    (0.0,      0.09465,  -np.pi / 2),
    (0.0,      0.0823,    0.0),
]

model = pin.Model()
parent = 0
joint_ids = []

for i in range(6):
    placement = pin.SE3.Identity() if i == 0 else dh_fixed(*dh[i - 1])
    joint_id = model.addJoint(parent, pin.JointModelRZ(), placement, f"joint_{i + 1}")
    joint_ids.append(joint_id)
    parent = joint_id

tool_frame = pin.Frame(
    "tool0",
    joint_ids[-1],
    0,
    dh_fixed(*dh[-1]),
    pin.FrameType.OP_FRAME,
)
frame_id = model.addFrame(tool_frame)

data = model.createData()
q = np.deg2rad([30, -60, 90, -120, -90, 45])

pin.forwardKinematics(model, data, q)
pin.updateFramePlacements(model, data)
T = data.oMf[frame_id].homogeneous

expected = np.array([
    [0.258819,  0.965926,  0.0, -0.505612],
    [0.965926, -0.258819,  0.0, -0.417951],
    [0.0,       0.0,      -1.0,  0.178795],
    [0.0,       0.0,       0.0,  1.0],
])
np.testing.assert_allclose(T, expected, atol=1e-6)
print(T)

With a real URDF, the code becomes shorter:

import pinocchio as pin
from pinocchio.robot_wrapper import RobotWrapper

robot = RobotWrapper.BuildFromURDF("ur5.urdf", package_dirs=["."])
q = robot.model.neutralConfiguration
pin.forwardKinematics(robot.model, robot.data, q)
pin.updateFramePlacements(robot.model, robot.data)
tool_id = robot.model.getFrameId("tool0")
print(robot.data.oMf[tool_id].homogeneous)

Do not compare a URDF matrix with a DH matrix blindly. UR URDF models often contain base_link, base, flange, tool0, visual frames and collision frames. These are not guaranteed to coincide with the frames you assigned in standard DH. Before declaring either model wrong, print the frame names and draw the frame tree.

Common FK Mistakes

The first mistake is using degrees in the kinematics core. A UI may accept degrees and a teach pendant may display degrees, but the model should receive radians. Convert only at the boundary.

The second mistake is reversing multiplication order. With the convention in this article, write T = T * A_i. If you write T = A_i * T, the final pose belongs to a different robot.

The third mistake is mixing standard DH and modified DH. They share the symbols a, d, alpha and theta, but use different transform order and frame placement. A standard DH table inside a modified DH class can look almost right at one pose and be completely wrong at another.

The fourth mistake is forgetting the tool offset. FK to the flange is not FK to the welding tip. A 150 mm tool makes the error obvious as soon as the wrist rotates.

The final mistake is validating only one pose. One configuration can hide a sign error. Test qz, the pose from this article, and a few random vectors. For random vectors, check the rotation validity and cross-check Python versus C++.

Conclusion

Forward kinematics is not a side formula in a controller. It is the central geometric measurement function: from joint encoder values, the controller knows where the TCP is. If FK is correct, IK, Jacobians, MoveL and jogging have a clean base. If FK is wrong, everything downstream is only compensating for symptoms.

Outside this series, ROS2 Control Hardware Interface shows how kinematics connects to the driver layer, while MoveIt 2 Motion Planning shows how the same geometric model is used for obstacle-aware planning.

The next article solves inverse kinematics: from T_base_tcp back to q, including multiple solutions, joint limits and numerical solvers. After that, the Jacobian gives the velocity relation qdot -> twist, which is the basis for servo jogging and singularity handling.

NT

Nguyễn Anh Tuấn

Robotics & AI Engineer. Building VnRobo — sharing knowledge about robot learning, VLA models, and automation.

Khám phá VnRobo

Related Posts

Mô hình cánh tay robot: joints, links, URDF, DH, SE(3)
manipulation

Mô hình cánh tay robot: joints, links, URDF, DH, SE(3)

6/13/202616 min read
NT
Inverse Kinematics: analytical vs numerical IK
manipulation

Inverse Kinematics: analytical vs numerical IK

6/13/202614 min read
NT
Jacobian, singularity và resolved-rate control
manipulation

Jacobian, singularity và resolved-rate control

6/13/202614 min read
NT