Quaternion interpolation¶
Quaternions avoid Euler-angle singularities, but introduce a double cover: q
and -q represent the same orientation. InterpolatePy's trajectory classes
choose consistent signs across keyframes.
Construct and convert orientations¶
import numpy as np
from interpolatepy import Quaternion
identity = Quaternion.identity()
about_z = Quaternion.from_angle_axis(
np.pi / 2.0, np.array([0.0, 0.0, 1.0])
)
from_rpy = Quaternion.from_euler_angles(roll=0.2, pitch=0.3, yaw=0.4)
matrix = from_rpy.to_rotation_matrix()
roll, pitch, yaw = from_rpy.to_euler_angles()
axis, angle = about_z.to_axis_angle()
assert matrix.shape == (3, 3)
assert np.isclose(angle, np.pi / 2.0)
assert np.allclose(axis, [0.0, 0.0, 1.0])
Notice the asymmetric naming: from_angle_axis(angle, axis) accepts angle
first, while to_axis_angle() returns axis first.
Normalize arbitrary components with .unit() before treating them as a
rotation.
SLERP between two orientations¶
import numpy as np
from interpolatepy import Quaternion
start = Quaternion.identity()
end = Quaternion.from_angle_axis(np.pi, np.array([0.0, 0.0, 1.0]))
halfway = start.slerp(end, 0.5)
axis, angle = halfway.to_axis_angle()
assert np.isclose(angle, np.pi / 2.0)
The interpolation parameter is dimensionless and lies between zero and one.
QuaternionSpline keyframes¶
from interpolatepy import Quaternion
from interpolatepy import QuaternionSpline
times = [0.0, 1.0, 2.0, 3.0, 4.0]
orientations = [
Quaternion.identity(),
Quaternion.from_euler_angles(0.1, 0.2, 0.1),
Quaternion.from_euler_angles(0.3, 0.5, 0.4),
Quaternion.from_euler_angles(0.2, 0.7, 0.8),
Quaternion.from_euler_angles(0.0, 0.4, 1.0),
]
spline = QuaternionSpline(times, orientations, interpolation_method="auto")
q = spline.evaluate(2.5)
omega = spline.evaluate_velocity(2.5)
alpha = spline.evaluate_acceleration(2.5)
The available method strings are "slerp", "squad", and "auto". In
SQUAD or automatic mode, boundary segments use SLERP and eligible interior
segments use SQUAD. With too few keyframes for SQUAD, evaluation falls back to
SLERP.
interpolate_at_time() also returns a status code for legacy callers;
evaluate() is the protocol-compatible interface.
SquadC2¶
Use SquadC2 when zero-clamped boundaries and smoother angular derivatives are
important:
import numpy as np
from interpolatepy import Quaternion
from interpolatepy import SquadC2
times = [0.0, 1.0, 2.0, 3.0]
orientations = [
Quaternion.identity(),
Quaternion.from_angle_axis(0.4, np.array([1.0, 0.0, 0.0])),
Quaternion.from_angle_axis(0.8, np.array([0.0, 1.0, 0.0])),
Quaternion.from_angle_axis(1.0, np.array([0.0, 0.0, 1.0])),
]
spline = SquadC2(times, orientations)
t = 1.5
q = spline.evaluate(t)
omega = spline.evaluate_velocity(t)
alpha = spline.evaluate_acceleration(t)
assert omega.shape == (3,)
assert alpha.shape == (3,)
Input quaternions are normalized by default. validate_continuity controls the
implementation's construction-time continuity diagnostics.
Logarithmic quaternion interpolation¶
LQI unwraps keyframes into continuous rotation vectors and interpolates those vectors with a B-spline:
import numpy as np
from interpolatepy import LogQuaternionInterpolation
from interpolatepy import Quaternion
times = [0.0, 2.0]
orientations = [
Quaternion.identity(),
Quaternion.from_angle_axis(1.2, np.array([0.0, 0.0, 1.0])),
]
interpolator = LogQuaternionInterpolation(times, orientations, degree=5)
q = interpolator.evaluate(1.0)
rotation_vector_rate = interpolator.evaluate_velocity(1.0)
assert rotation_vector_rate.shape == (3,)
Degrees 3, 4, and 5 all accept two or more keyframes in 3.2.0.
On the Python backend, distinguish rotation-vector derivatives from physical angular kinematics:
import numpy as np
from interpolatepy import Quaternion
from interpolatepy.quaternion.logarithmic import LogQuaternionInterpolation
times = [0.0, 1.0, 2.0]
orientations = [
Quaternion.identity(),
Quaternion.from_euler_angles(0.2, 0.3, 0.1),
Quaternion.from_euler_angles(0.5, 0.4, 0.7),
]
interpolator = LogQuaternionInterpolation(times, orientations)
omega, alpha = interpolator.get_physical_kinematics(1.0)
assert omega.shape == (3,)
assert alpha.shape == (3,)
get_physical_kinematics() and acceleration boundary arguments are currently
Python-backend helpers. Force the fallback for code that relies on them.
Modified logarithmic interpolation¶
mLQI splines the angle and axis separately. Its internal derivative vector has four components: angle plus three axis components.
import numpy as np
from interpolatepy import ModifiedLogQuaternionInterpolation
from interpolatepy import Quaternion
times = [0.0, 1.0, 2.0]
orientations = [
Quaternion.identity(),
Quaternion.from_angle_axis(0.5, np.array([1.0, 0.0, 0.0])),
Quaternion.from_angle_axis(1.0, np.array([0.0, 1.0, 0.0])),
]
interpolator = ModifiedLogQuaternionInterpolation(
times, orientations, degree=3, normalize_axis=True
)
q = interpolator.evaluate(0.75)
coordinate_rate = interpolator.evaluate_velocity(0.75)
assert coordinate_rate.shape == (4,)
Keep normalize_axis=True unless the interpolated axis is already known to
stay unit length.
SPRING minimum-curvature interpolation¶
SpringQuaternionInterpolation implements SPRING (Spherical Interpolation
using Numerical Gradient descent) from Dam, Koch, and Lillholm's 1998 report
Quaternions, Interpolation and Animation. It starts with piecewise SLERP and
numerically relaxes the in-between frames to minimize tangential curvature on
the unit-quaternion sphere. Keyframes remain fixed.
import numpy as np
from interpolatepy import Quaternion
from interpolatepy import SpringConfig
from interpolatepy import SpringQuaternionInterpolation
times = [0.0, 1.0, 2.0, 3.0]
orientations = [
Quaternion.identity(),
Quaternion.from_angle_axis(0.8, np.array([1.0, 0.0, 0.0])),
Quaternion.from_angle_axis(1.0, np.array([0.0, 1.0, 0.0])),
Quaternion.from_angle_axis(1.2, np.array([0.0, 0.0, 1.0])),
]
spring = SpringQuaternionInterpolation(
times,
orientations,
SpringConfig(num_samples=81, iterations=300),
)
q = spring.evaluate(1.5)
omega = spring.evaluate_velocity(1.5)
alpha = spring.evaluate_acceleration(1.5)
assert np.isclose(q.norm(), 1.0)
assert spring.final_energy <= spring.initial_energy
SPRING is a global, iterative method: changing one keyframe can affect the
whole curve, and increasing num_samples or iterations increases setup
cost. Intermediate values are SLERP evaluations of the optimized discrete
frames; angular derivatives are numerical estimates. The default three-level
solver refines a coarse curve approximately fivefold at each level. Each level
starts from the previous level's result, but only the original keyframes are
held fixed. Set refinement_levels=1 for a one-stage solve.
Deviation from the report
The report's multi-step minimization states that "the frames computed in
the first step are used as key frames in the second step". This library
does not freeze them: coarse levels are only an initial guess. The report
introduces the freeze because plain gradient descent propagates keyframe
information one neighbor per iteration, and calls the resulting uneven
velocity "a weakness in our implementation, but we expect that a more
robust algorithm with better convergence properties will yield nicer
velocity graphs". With the freeze in place the final grid is stationary
only in the subspace of newly inserted frames, at an energy measurably
above the minimum of the same discrete objective, and the gap grows with
num_samples.
Refinement uses the actual spacing between selected frames when computing second differences. Convergence is checked on the final grid before starting coarse optimization, so an already-converged curve is preserved. If refinement increases curvature energy on the final grid, the final stage restarts from the original piecewise-SLERP curve.
The compiled extension runs SPRING in C++ automatically when HAS_CPP is
true. Set INTERPOLATEPY_NO_CPP=1 to select the NumPy reference implementation.
refinement_sample_counts and stage_energy_history are immutable tuples for
inspecting convergence. energy_history is the final-stage history, while
initial_energy and final_energy compare the original piecewise-SLERP curve
and the final optimized curve on the same final grid.
Run the visual and timing comparison against multiple shooting, LQI, mLQI, piecewise SLERP, SQUAD, and SQUAD-C2:
The example plots orientation paths, physical angular speed, and the
accumulated angular-acceleration energy, then repeats the last measure in a
second figure for the smoothest methods alone. Angular speed is derived from the
sampled orientations, not from each method's evaluate_velocity(), because the
logarithmic interpolators return derivatives of their own coordinates there; the
result agrees with their get_physical_kinematics() to 1e-5 rad/s. It reports
median construction and
1,000-evaluation batch times separately and labels the active backend. Treat
the numbers as measurements of the current machine, not universal performance
claims.
A faster solver for the same discrete SPRING problem¶
Use SpringConfig(solver="gauss_newton") to accelerate SPRING without
changing its sample lattice, chord-based parametrization, curvature weights,
norm penalty, or final starting state:
import numpy as np
from interpolatepy import Quaternion
from interpolatepy import SpringConfig
from interpolatepy import SpringQuaternionInterpolation
times = [0.0, 1.0, 2.0, 3.0]
orientations = [
Quaternion.identity(),
Quaternion.from_angle_axis(0.8, np.array([1.0, 0.0, 0.0])),
Quaternion.from_angle_axis(1.0, np.array([0.0, 1.0, 0.0])),
Quaternion.from_angle_axis(1.2, np.array([0.0, 0.0, 1.0])),
]
spring = SpringQuaternionInterpolation(
times, orientations,
SpringConfig(num_samples=1001, solver="gauss_newton"),
)
print(spring.converged, spring.stage_gradient_norms[-1])
The original "gradient_descent" solver remains the default. Both options
run identical coarse solves; only the final-grid minimizer changes.
Gauss-Newton uses analytic Jacobians of the same weighted tangential-curvature
and norm-penalty residuals. Each residual involves at most three neighboring
quaternions, so its normal matrix has a fixed bandwidth. A banded factorization
avoids a dense solve. The number of optimization variables still depends on
the sample count: this is not the continuous multiple-shooting algorithm below.
Both solvers cache each grid's curvature coefficients and skip gradient accumulation for rejected line-search trials, except when a near-roundoff acceptance decision needs the gradient. The Python implementation also batches the existing log/exp SLERP operations. These implementation optimizations retain the objective, iteration budgets, stopping tolerances, and step-acceptance rules.
converged checks the Euclidean norm of the gradient over every non-keyframe
sample on the final grid, before output normalization. It does not imply that
all coarse solves converged or that a global minimum was found. Inspect
stage_gradient_norms for each level. energy_history includes the full
optimization energy, including the norm penalty; final_energy reports
curvature after normalizing the output quaternions.
Two solvers need not agree after the same finite iteration count. Compare them
after convergence, and measure the actual angular difference. The optional
final_iterations overrides only the final-grid budget without changing the
coarse stages. With this override, iterations_run may exceed iterations.
Setting it to None (the default) retains the original shared budget; 0
disables final-grid updates. This makes an equal-input comparison possible
without inadvertently changing the coarse solves when increasing the final
solve budget.
uv run python examples/spring_solver_benchmark.py
INTERPOLATEPY_NO_CPP=1 uv run python examples/spring_solver_benchmark.py
The benchmark uses identical inputs and a common final gradient tolerance. It verifies identical coarse histories and starting energy, and measures orientation differences at 2,001 times. It reports a speedup only when both solvers converge and satisfy the specified angular-agreement limit. This checks numerical agreement for that case, not universal uniqueness of the nonconvex minimum. The continuous shooting comparison below is a different experiment and does not establish equivalence to discrete SPRING.
Multiple shooting for natural Riemannian cubics¶
ShootingQuaternionInterpolation solves a continuous rotation interpolation
problem with only nine unknowns per keyframe interval, independently of the
number of output frames. It is available in C++ and NumPy/SciPy with the same
public API:
from interpolatepy import Quaternion, ShootingConfig, ShootingQuaternionInterpolation
times = [0.0, 1.0, 2.0, 3.0]
orientations = [
Quaternion.identity(),
Quaternion.from_euler_angles(0.9, 0.1, 0.2),
Quaternion.from_euler_angles(0.2, 1.1, 0.5),
Quaternion.from_euler_angles(-0.4, 0.3, 1.4),
]
curve = ShootingQuaternionInterpolation(times, orientations, ShootingConfig(tolerance=1e-8))
q = curve.evaluate(1.5)
omega = curve.evaluate_velocity(1.5) # body angular velocity, rad/s
alpha = curve.evaluate_acceleration(1.5) # body angular acceleration, rad/s^2
sample_times, samples = curve.generate_trajectory(1001) # no new solve
assert curve.num_variables == 27
assert curve.residual_norm <= curve.config.tolerance
The bi-invariant Riemannian cubic equation is
V''' + [V, V''] = 0, or after integration,
V'' + [V, V'] = C on each interval. Here q' = q V, and the
pure-quaternion bracket is twice the vector cross product. See
Riemannian cubics in quadratic matrix Lie groups
for the continuous cubic equations.
For a segment of duration h, the implementation uses local time
u = (t - t_i)/h and unknowns (v_0, a_0, c) = (h V_0, h^2 V'_0, h^3 C).
It integrates q_u = q v, v_u = a, a_u = c - 2 cross(v, a).
Each segment starts at its prescribed keyframe. Quaternion-log endpoint
residuals enforce orientation matching; derivative residuals match physical
velocity and acceleration across knots. Zero acceleration at both outer
endpoints supplies the natural boundary conditions. Constants c may differ
between segments. With K keyframes this gives a square 9*(K-1) system,
solved by sparse Newton with backtracking and analytic ODE sensitivities.
RK4 starts at integration_steps=16 steps per segment. Each converged solve
is checked by reintegration on a finer grid, refining up to
max_integration_steps=1024. tolerance bounds the maximum component of the
dimensionless matching residual (orientation uses half-angle quaternion logs;
derivatives use local time scaling), not a uniform physical-unit error bound
on the entire curve. max_iterations=30 limits accepted Newton steps across
all grids. Nonconvergence or failed accuracy verification raises RuntimeError;
increase the appropriate limit or inspect difficult/near-antipodal data.
iterations_run, integration_steps, and residual_norm report the solve;
acceleration_energy approximates the integral of squared physical angular
acceleration. Cached integration states provide evaluation and derivatives
without finite differences or another optimization.
This is a local stationary curve of the continuous squared covariant acceleration functional with free endpoint velocities. It is not guaranteed to be the global minimizer, and its objective differs from sampled SPRING. Construction can be faster than dense SPRING with the compiled backend; coarse SPRING or closed-form SQUAD can still be cheaper. The Python reference has additional integration-loop overhead. Measure your actual workload:
uv run python examples/shooting_quaternion_benchmark.py
INTERPOLATEPY_NO_CPP=1 uv run python examples/shooting_quaternion_benchmark.py
Compare orientations correctly¶
Component equality rejects the equivalent pair q and -q. For unit
quaternions, use the absolute dot product:
from interpolatepy import Quaternion
q1 = Quaternion.identity()
q2 = -q1
same_orientation = abs(q1.dot_prod(q2)) > 1.0 - 1e-9
assert same_orientation
Choosing a method¶
| Need | Method |
|---|---|
| One segment | Quaternion.slerp() |
| Simple keyframe API | QuaternionSpline |
| Zero-clamped smooth SQUAD construction | SquadC2 |
| Global numerical minimum-curvature curve | SpringQuaternionInterpolation |
| Continuous natural cubic with analytic angular derivatives | ShootingQuaternionInterpolation |
| Continuous rotation-vector representation | LQI |
| Separate angle/axis state | mLQI |
Rotations close to 180 degrees are intrinsically branch-sensitive. Inspect the actual orientation path, angular velocity, and angular acceleration for the chosen keyframes rather than selecting solely by method name.