Skip to content

Module pyastrobee.trajectories.quaternion_interpolation

Determining an orientation trajectory between two quaternions with angular velocity boundary conditions

This is different from SLERP because SLERP will find the shortest path along the unit 4D hypersphere (by traveling along a great circle), but this implies that we cannot dictate the direction of the angular velocity vector during this interpolation

This method will instead create a 5th order polynomial to specify the boundary conditions on angular velocity (and technically angular acceleration as well, since we only need a 3rd order polynomial to specify BCs on the first derivatives).

There is no guarantee that the quaternions will be normalized, but we can do that after the interpolation process. The interpolation does not result in significant deviations (< 5%) from unit-norm quaternions, so this should not significantly affect the orientation representation

See "Orientation Planning in Task Space using Quaternion Polynomials" DOI: 10.1109/ROBIO.2017.8324769 for more info Note: this paper uses WXYZ quaternions

View Source
"""Determining an orientation trajectory between two quaternions with angular velocity boundary conditions

This is different from SLERP because SLERP will find the shortest path along the unit 4D hypersphere

(by traveling along a great circle), but this implies that we cannot dictate the direction of the angular

velocity vector during this interpolation

This method will instead create a 5th order polynomial to specify the boundary conditions on angular velocity

(and technically angular acceleration as well, since we only need a 3rd order polynomial to specify BCs on

the first derivatives).

There is no guarantee that the quaternions will be normalized, but we can do that after the interpolation process.

The interpolation does not result in significant deviations (< 5%) from unit-norm quaternions, so this should not

significantly affect the orientation representation

See "Orientation Planning in Task Space using Quaternion Polynomials" DOI: 10.1109/ROBIO.2017.8324769 for more info

Note: this paper uses WXYZ quaternions

"""

import numpy as np

import numpy.typing as npt

from pyastrobee.utils.quaternions import (

    quats_to_angular_velocities,

    wxyz_to_xyzw,

    xyzw_to_wxyz,

)

def quaternion_interpolation_with_bcs(

    qi: npt.ArrayLike,

    qf: npt.ArrayLike,

    wi: npt.ArrayLike,

    wf: npt.ArrayLike,

    dwi: npt.ArrayLike,

    dwf: npt.ArrayLike,

    duration: float,

    n: int,

) -> np.ndarray:

    """Generate a sequence of quaternions between two orientations with angular velocity boundary conditions

    - This implementation is a slightly modified version of "Orientation Planning in Task Space using Quaternion

      Polynomials" DOI: 10.1109/ROBIO.2017.8324769, Algorithm 1

    Args:

        qi (npt.ArrayLike): Initial XYZW quaternion, shape (4,)

        qf (npt.ArrayLike): Final XYZW quaternion, shape (4,)

        wi (npt.ArrayLike): Initial inertial-frame angular velocity, shape (3,)

        wf (npt.ArrayLike): Final inertial-frame angular velocity, shape (3,)

        dwi (npt.ArrayLike): Initial inertial-frame angular acceleration, shape (3,)

        dwf (npt.ArrayLike): Final inertial-frame angular acceleration, shape (3,)

        duration (float): Trajectory duration, seconds

        n (int): Number of timesteps

    Returns:

        np.ndarray: Sequence of XYZW quaternions, shape (n, 4)

    """

    # NOTE The algorithm in the paper uses WXYZ quaternions, so we'll need to convert back and forth

    qi = xyzw_to_wxyz(qi)

    qf = xyzw_to_wxyz(qf)

    wi = np.asarray(wi)

    wf = np.asarray(wf)

    dwi = np.asarray(dwi)

    dwf = np.asarray(dwf)

    # Ensure shortest path interpolation

    if _dot(qi, qf) < 0:

        qf = -qf

    # First and second derivatives of quaternion magnitude should be 0

    # (in theory quaternion norms should always be fixed at 1, but this interpolation

    # does not necessarily guarantee this. But, it generally stays within 5% of norm 1)

    dNi, ddNi, dNf, ddNf = (0, 0, 0, 0)

    # Get the first and second derivatives of the quaternion at the starting/ending points

    dqi = _get_dq(wi, dNi, qi)

    ddqi = _get_ddq(wi, dwi, dNi, ddNi, qi)

    dqf = _get_dq(wf, dNf, qf)

    ddqf = _get_ddq(wf, dwf, dNf, ddNf, qf)

    # Get the polynomial coefficients

    p = _fifth_order_quat_poly_coeffs(qi, qf, dqi, dqf, ddqi, ddqf, duration)

    # Linearly sample along this polynomial to get the interpolated quaternions

    taus = np.linspace(0, 1, n, endpoint=True)

    wxyz_quats = _interpolate_along_quat_poly(p, taus)

    # Convert back to XYZW for compatibility with the rest of the repository

    quats = wxyz_to_xyzw(wxyz_quats)

    # Normalize, since this process does not guarantee norm 1 quaternions

    quats /= np.linalg.norm(quats, axis=1).reshape(-1, 1)

    return quats

# Below are all helper functions based on the equations from the reference paper

# These are prefixed by an underscore to imply that they should NOT be imported outside this file

# Mainly, this is because the paper deals with WXYZ quaternions and I didn't want to convert

# all of the math for the intermediate steps

def _fifth_order_quat_poly_coeffs(

    qi: np.ndarray,

    qf: np.ndarray,

    dqi: np.ndarray,

    dqf: np.ndarray,

    ddqi: np.ndarray,

    ddqf: np.ndarray,

    T: float,

) -> np.ndarray:

    """Get the coefficients for a fifth-order polynomial in quaternion space

    See Equation 13 in the reference paper

    """

    return np.row_stack(

        [

            qi,

            3 * qi + dqi * T,

            (ddqi * T**2 + 6 * dqi * T + 12 * qi) / 2,

            qf,

            3 * qf - dqf * T,

            (ddqf * T**2 - 6 * dqf * T + 12 * qf) / 2,

        ]

    )

def _interpolate_along_quat_poly(p: np.ndarray, taus: np.ndarray):

    """Interpolate along a polynomial defined by coefficients p at percents tau

    See Equation 12 in the reference paper

    """

    # Convert taus to a column vector for proper broadcasting

    taus = np.atleast_2d(taus)

    if taus.shape[0] == 1:

        taus = taus.T

    # Output will be (n, 4) where n is the number of taus to interpolate at

    return (1 - taus) ** 3 * (p[0] + p[1] * taus + p[2] * taus**2) + (taus**3) * (

        p[3] + p[4] * (1 - taus) + p[5] * (1 - taus) ** 2

    )

def _get_dq(w: np.ndarray, dN: float, q: np.ndarray) -> np.ndarray:

    """Derivative of WXYZ quaternion (Equation 21)

    Args:

        w (np.ndarray): Inertial frame angular velocity, shape (3,)

        dN (float): Derivative of quaternion norm

        q (np.ndarray): Current WXYZ quaternion, shape (4,)

    Returns:

        np.ndarray: Quaternion derivative, shape (4,)

    """

    return _multiply(_pure((1 / 2) * w + dN), q)

def _get_ddq(

    w: np.ndarray, dw: np.ndarray, dN: float, ddN: float, q: np.ndarray

) -> np.ndarray:

    """Second derivative of WXYZ quaternion (Equation 22)

    Args:

        w (np.ndarray): Inertial frame angular velocity, shape (3,)

        dw (np.ndarray): Derivative of angular velocity, shape (3,)

        dN (float): Derivative of quaternion norm

        ddN (float): Second derivative of quaternion norm

        q (np.ndarray): Current WXYZ quaternion, shape (4,)

    Returns:

        np.ndarray: Quaternion derivative, shape (4,)

    """

    return _multiply(_pure((1 / 2) * dw + dN * w - (1 / 4) * _squared_norm(w) + ddN), q)

def _dot(q1, q2):

    """Dot product between two quaternions (Equation 4)"""

    return np.dot(q1, q2)

def _conj(q):

    """Conjugate of WXYZ quaternion (Between Eqns. 4/5)"""

    return np.array([1, -1, -1, -1]) * q

def _inv(q):

    """Inverse of a WXYZ quaternion (Between Eqns. 4/5)"""

    N = np.linalg.norm(q)

    return (1 / (N**2)) * _conj(q)

def _pure(v):

    """Pure WXYZ quaternion representation from vector component (Between Eqns. 7/8)"""

    return np.concatenate([[0], v])

def _multiply(q1, q2):

    """Multiplication of two WXYZ quaternions (Equation 3)"""

    w1, x1, y1, z1 = q1

    w2, x2, y2, z2 = q2

    return np.array(

        [

            w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2,

            w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,

            w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2,

            w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2,

        ]

    )

def _squared_norm(v):

    """Squared vector norm"""

    return np.dot(v, v)

# UNUSED functions below

# These were based on the equations in the paper or implemented manually (such as some of the additional derivatives)

# Leaving these juse in case they are helpful in the future

def _get_q_from_curve(p, tau):

    # Equation 12

    return (1 - tau) ** 3 * (p[0] + p[1] * tau + p[2] * tau**2) + (tau**3) * (

        p[3] + p[4] * (1 - tau) + p[5] * (1 - tau) ** 2

    )

def _get_dq_from_curve(p, tau):

    # Derivative of equation 12 wrt time

    return (

        -3 * (1 - tau) ** 2 * (p[0] + p[1] * tau + p[2] * tau**2)

        + (1 - tau) ** 3 * (p[1] + 2 * p[2] * tau)

        + 3 * tau**2 * (p[3] + p[4] * (1 - tau) + p[5] * (1 - tau) ** 2)

        + tau**3 * (-p[4] - 2 * p[5] * (1 - tau))

    )

def _get_ddq_from_curve(p, tau):

    # Second derivative of equation 12 wrt time

    return (

        6 * (1 - tau) * (p[0] + p[1] * tau + p[2] * tau**2)

        - 6 * (1 - tau) ** 2 * (p[1] + 2 * p[2] * tau)

        + (1 - tau) ** 3 * 2 * p[2]

        + 6 * tau * (p[3] + p[4] * (1 - tau) + p[5] * (1 - tau) ** 2)

        + 6 * tau**2 * (-p[4] - 2 * p[5] * (1 - tau))

        + tau**3 * 2 * p[5]

    )

def _get_N(q):

    """Quaternion norm (should be 1 ideally)"""

    return np.linalg.norm(q)

def _get_w(q, dq):

    # Equation 6, part 2

    return (2 * dq * _inv(q))[1:]  # Index the vector part of pure quat

def _get_dw(q, dq, ddq):

    # Equation 7, part 2

    q_inv = _inv(q)

    return (2 * ddq * q_inv - 2 * (dq * q_inv) ** 2)[

        1:

    ]  # Index the vector part of pure quat

def _main():

    import matplotlib.pyplot as plt  # pylint: disable=import-outside-toplevel

    # Calculate and plot an example interpolation

    ti = 0  # Initial time

    tf = 5  # Final time

    T = tf - ti  # Duration

    qi = np.array([0, 0, 0, 1])  # Initial quaternion

    wi = 0.1 * np.random.rand(3)  # Initial ang vel

    dwi = np.zeros(3)  # Initial ang accel

    qf = np.random.rand(4)  # Final quaternion (pre-normalization)

    qf /= np.linalg.norm(qf)  # Normalize

    wf = 0.1 * np.random.rand(3)  # Final angular velocity

    dwf = np.zeros(3)  # Final angular acceleration

    dt = 1 / 350  # Timestep (set to the pybullet physics timestep we're using)

    n = round(T / dt)  # Number of timesteps

    qs = quaternion_interpolation_with_bcs(qi, qf, wi, wf, dwi, dwf, T, n)

    ws = quats_to_angular_velocities(qs, dt)

    dws = np.gradient(ws, dt, axis=0)

    # Plot the quaternions, angular velocities, and angular accelerations

    fig = plt.figure()

    subfigs = fig.subfigures(1, 3)

    left = subfigs[0].subplots(1, 4)

    middle = subfigs[1].subplots(1, 3)

    right = subfigs[2].subplots(1, 3)

    x_axis = range(qs.shape[0])

    q_labels = ["qw", "qx", "qy", "qz"]

    w_labels = ["wx", "wy", "wz"]

    dw_labels = ["ax", "ay", "az"]

    x_label = "Time"

    for i, ax in enumerate(left):

        ax.plot(x_axis, qs[:, i])

        ax.set_title(q_labels[i])

        ax.set_xlabel(x_label)

    for i, ax in enumerate(middle):

        ax.plot(x_axis, ws[:, i])

        ax.set_title(w_labels[i])

        ax.set_xlabel(x_label)

    for i, ax in enumerate(right):

        ax.plot(x_axis, dws[:, i])

        ax.set_title(dw_labels[i])

        ax.set_xlabel(x_label)

    plt.show()

if __name__ == "__main__":

    _main()

Functions

quaternion_interpolation_with_bcs

def quaternion_interpolation_with_bcs(
    qi: Union[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]], numpy._typing._nested_sequence._NestedSequence[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]]], bool, int, float, complex, str, bytes, numpy._typing._nested_sequence._NestedSequence[Union[bool, int, float, complex, str, bytes]]],
    qf: Union[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]], numpy._typing._nested_sequence._NestedSequence[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]]], bool, int, float, complex, str, bytes, numpy._typing._nested_sequence._NestedSequence[Union[bool, int, float, complex, str, bytes]]],
    wi: Union[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]], numpy._typing._nested_sequence._NestedSequence[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]]], bool, int, float, complex, str, bytes, numpy._typing._nested_sequence._NestedSequence[Union[bool, int, float, complex, str, bytes]]],
    wf: Union[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]], numpy._typing._nested_sequence._NestedSequence[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]]], bool, int, float, complex, str, bytes, numpy._typing._nested_sequence._NestedSequence[Union[bool, int, float, complex, str, bytes]]],
    dwi: Union[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]], numpy._typing._nested_sequence._NestedSequence[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]]], bool, int, float, complex, str, bytes, numpy._typing._nested_sequence._NestedSequence[Union[bool, int, float, complex, str, bytes]]],
    dwf: Union[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]], numpy._typing._nested_sequence._NestedSequence[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]]], bool, int, float, complex, str, bytes, numpy._typing._nested_sequence._NestedSequence[Union[bool, int, float, complex, str, bytes]]],
    duration: float,
    n: int
) -> numpy.ndarray

Generate a sequence of quaternions between two orientations with angular velocity boundary conditions

  • This implementation is a slightly modified version of "Orientation Planning in Task Space using Quaternion Polynomials" DOI: 10.1109/ROBIO.2017.8324769, Algorithm 1

Parameters:

Name Type Description Default
qi npt.ArrayLike Initial XYZW quaternion, shape (4,) None
qf npt.ArrayLike Final XYZW quaternion, shape (4,) None
wi npt.ArrayLike Initial inertial-frame angular velocity, shape (3,) None
wf npt.ArrayLike Final inertial-frame angular velocity, shape (3,) None
dwi npt.ArrayLike Initial inertial-frame angular acceleration, shape (3,) None
dwf npt.ArrayLike Final inertial-frame angular acceleration, shape (3,) None
duration float Trajectory duration, seconds None
n int Number of timesteps None

Returns:

Type Description
np.ndarray Sequence of XYZW quaternions, shape (n, 4)
View Source
def quaternion_interpolation_with_bcs(

    qi: npt.ArrayLike,

    qf: npt.ArrayLike,

    wi: npt.ArrayLike,

    wf: npt.ArrayLike,

    dwi: npt.ArrayLike,

    dwf: npt.ArrayLike,

    duration: float,

    n: int,

) -> np.ndarray:

    """Generate a sequence of quaternions between two orientations with angular velocity boundary conditions

    - This implementation is a slightly modified version of "Orientation Planning in Task Space using Quaternion

      Polynomials" DOI: 10.1109/ROBIO.2017.8324769, Algorithm 1

    Args:

        qi (npt.ArrayLike): Initial XYZW quaternion, shape (4,)

        qf (npt.ArrayLike): Final XYZW quaternion, shape (4,)

        wi (npt.ArrayLike): Initial inertial-frame angular velocity, shape (3,)

        wf (npt.ArrayLike): Final inertial-frame angular velocity, shape (3,)

        dwi (npt.ArrayLike): Initial inertial-frame angular acceleration, shape (3,)

        dwf (npt.ArrayLike): Final inertial-frame angular acceleration, shape (3,)

        duration (float): Trajectory duration, seconds

        n (int): Number of timesteps

    Returns:

        np.ndarray: Sequence of XYZW quaternions, shape (n, 4)

    """

    # NOTE The algorithm in the paper uses WXYZ quaternions, so we'll need to convert back and forth

    qi = xyzw_to_wxyz(qi)

    qf = xyzw_to_wxyz(qf)

    wi = np.asarray(wi)

    wf = np.asarray(wf)

    dwi = np.asarray(dwi)

    dwf = np.asarray(dwf)

    # Ensure shortest path interpolation

    if _dot(qi, qf) < 0:

        qf = -qf

    # First and second derivatives of quaternion magnitude should be 0

    # (in theory quaternion norms should always be fixed at 1, but this interpolation

    # does not necessarily guarantee this. But, it generally stays within 5% of norm 1)

    dNi, ddNi, dNf, ddNf = (0, 0, 0, 0)

    # Get the first and second derivatives of the quaternion at the starting/ending points

    dqi = _get_dq(wi, dNi, qi)

    ddqi = _get_ddq(wi, dwi, dNi, ddNi, qi)

    dqf = _get_dq(wf, dNf, qf)

    ddqf = _get_ddq(wf, dwf, dNf, ddNf, qf)

    # Get the polynomial coefficients

    p = _fifth_order_quat_poly_coeffs(qi, qf, dqi, dqf, ddqi, ddqf, duration)

    # Linearly sample along this polynomial to get the interpolated quaternions

    taus = np.linspace(0, 1, n, endpoint=True)

    wxyz_quats = _interpolate_along_quat_poly(p, taus)

    # Convert back to XYZW for compatibility with the rest of the repository

    quats = wxyz_to_xyzw(wxyz_quats)

    # Normalize, since this process does not guarantee norm 1 quaternions

    quats /= np.linalg.norm(quats, axis=1).reshape(-1, 1)

    return quats