Skip to content

Commit 44d535c

Browse files
committed
Add Frenet spiral examples
1 parent 41607a1 commit 44d535c

4 files changed

Lines changed: 618 additions & 1 deletion

File tree

examples/lqi_spiral_frenet_ex.py

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
"""
2+
Logarithmic Quaternion Interpolation (LQI) along a cylindrical helix oriented
3+
with the Frenet frame.
4+
5+
Pipeline:
6+
1. Define a cylindrical helix p(u) = (r*cos(u), r*sin(u), b*u) with analytical
7+
derivatives so the Frenet frame can be evaluated in closed form. The radius
8+
is constant, so curvature and torsion are constant along the curve.
9+
2. Sample a sparse set of waypoints along the helix and compute the Frenet
10+
frame [tangent, normal, binormal] at each waypoint.
11+
3. Convert each Frenet rotation matrix to a unit quaternion.
12+
4. Feed the (time, quaternion) waypoints to LogQuaternionInterpolation to
13+
obtain a smooth C^2 orientation trajectory. LQI splines the rotation
14+
vector r(u) = theta(u) * n_hat(u) as a single 3D quantity (in contrast
15+
with mLQI, which splines theta and (X, Y, Z) on separate channels).
16+
5. Evaluate the position helix and the interpolated orientation at a dense
17+
set of times and visualize the resulting tool frames side by side with the
18+
ground-truth Frenet frames.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import matplotlib.pyplot as plt
24+
import numpy as np
25+
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (registers 3D projection)
26+
27+
from interpolatepy.frenet_frame import compute_trajectory_frames
28+
from interpolatepy.frenet_frame import plot_frames
29+
from interpolatepy.log_quat import LogQuaternionInterpolation
30+
from interpolatepy.quat_core import Quaternion
31+
from interpolatepy.quat_visualization import QuaternionTrajectoryVisualizer
32+
33+
34+
def cylindrical_helix_with_derivatives(
35+
u: float, r: float = 1.0, b: float = 0.3
36+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
37+
"""
38+
Cylindrical helix (constant radius) position and first two derivatives.
39+
40+
p(u) = ( r*cos(u), r*sin(u), b*u )
41+
dp/du = (-r*sin(u), r*cos(u), b )
42+
d2p/du2 = (-r*cos(u), -r*sin(u), 0 )
43+
"""
44+
cos_u = np.cos(u)
45+
sin_u = np.sin(u)
46+
47+
p = np.array([r * cos_u, r * sin_u, b * u])
48+
dp_du = np.array([-r * sin_u, r * cos_u, b])
49+
d2p_du2 = np.array([-r * cos_u, -r * sin_u, 0.0])
50+
51+
return p, dp_du, d2p_du2
52+
53+
54+
def frame_to_quaternion(frame: np.ndarray) -> Quaternion:
55+
"""
56+
Convert a 3x3 frame whose columns are [tangent, normal, binormal] into a
57+
unit quaternion. The frame matrix is itself the rotation matrix from the
58+
local Frenet basis to the world basis, so we can hand it directly to
59+
Quaternion.from_rotation_matrix.
60+
"""
61+
return Quaternion.from_rotation_matrix(frame).unit()
62+
63+
64+
def build_waypoints(
65+
n_waypoints: int = 10,
66+
u_min: float = 0.5,
67+
u_max: float = 6.0 * np.pi,
68+
r: float = 1.0,
69+
b: float = 0.3,
70+
) -> tuple[np.ndarray, list[Quaternion], np.ndarray, np.ndarray]:
71+
"""
72+
Sample the helix at n_waypoints values of u, compute Frenet frames there,
73+
and return (times, quaternions, waypoint_positions, waypoint_frames).
74+
75+
Times are taken equal to the parameter u so the orientation evolution
76+
matches the curve parameterization.
77+
"""
78+
u_waypoints = np.linspace(u_min, u_max, n_waypoints)
79+
80+
def helix_func(u: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
81+
return cylindrical_helix_with_derivatives(u, r=r, b=b)
82+
83+
positions, frames = compute_trajectory_frames(helix_func, u_waypoints)
84+
85+
quaternions = [frame_to_quaternion(frames[i]) for i in range(len(u_waypoints))]
86+
87+
return u_waypoints, quaternions, positions, frames
88+
89+
90+
def evaluate_interpolated_trajectory(
91+
lqi: LogQuaternionInterpolation,
92+
u_dense: np.ndarray,
93+
r: float,
94+
b: float,
95+
) -> tuple[np.ndarray, np.ndarray, list[Quaternion]]:
96+
"""
97+
Evaluate the helix positions, the LQI-interpolated orientation frames,
98+
and the underlying interpolated quaternions at the dense parameter values.
99+
"""
100+
dense_positions = np.zeros((len(u_dense), 3))
101+
dense_frames = np.zeros((len(u_dense), 3, 3))
102+
dense_quaternions: list[Quaternion] = []
103+
104+
for i, u in enumerate(u_dense):
105+
dense_positions[i], _, _ = cylindrical_helix_with_derivatives(u, r=r, b=b)
106+
q = lqi.evaluate(u)
107+
dense_frames[i] = q.to_rotation_matrix()
108+
dense_quaternions.append(q)
109+
110+
return dense_positions, dense_frames, dense_quaternions
111+
112+
113+
def angular_error_deg(frame_truth: np.ndarray, frame_est: np.ndarray) -> float:
114+
"""
115+
Geodesic angle (in degrees) between two rotation matrices.
116+
"""
117+
r = frame_truth.T @ frame_est
118+
cos_angle = np.clip(0.5 * (np.trace(r) - 1.0), -1.0, 1.0)
119+
return np.degrees(np.arccos(cos_angle))
120+
121+
122+
def main() -> None:
123+
print("LQI on a Cylindrical Helix with Frenet Frame Waypoints")
124+
print("=" * 60)
125+
126+
# Helix parameters
127+
r = 1.0 # constant radius
128+
b = 0.3 # vertical rise per radian
129+
u_min = 0.5
130+
u_max = 6.0 * np.pi
131+
n_waypoints = 1000
132+
133+
# Sample waypoints + their Frenet frames.
134+
times, quaternions, wp_positions, wp_frames = build_waypoints(
135+
n_waypoints=n_waypoints,
136+
u_min=u_min,
137+
u_max=u_max,
138+
r=r,
139+
b=b,
140+
)
141+
142+
print(f"Sampled {n_waypoints} Frenet waypoints in u ∈ [{u_min:.2f}, {u_max:.2f}]")
143+
print(f"Time/parameter range used for LQI: [{times[0]:.3f}, {times[-1]:.3f}]")
144+
145+
# Build the LQI interpolator over the waypoint orientations.
146+
lqi = LogQuaternionInterpolation(
147+
time_points=times,
148+
quaternions=quaternions,
149+
degree=3,
150+
)
151+
152+
# Dense evaluation of position + interpolated orientation.
153+
n_dense = 240
154+
u_dense = np.linspace(u_min, u_max, n_dense)
155+
dense_positions, dense_frames_lqi, dense_quaternions_lqi = (
156+
evaluate_interpolated_trajectory(lqi, u_dense, r=r, b=b)
157+
)
158+
159+
# Ground-truth Frenet frames on the same dense grid, for comparison.
160+
def helix_func(u: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
161+
return cylindrical_helix_with_derivatives(u, r=r, b=b)
162+
163+
_, dense_frames_truth = compute_trajectory_frames(helix_func, u_dense)
164+
165+
# Report worst-case angular deviation between interpolated orientation and
166+
# the analytical Frenet frame.
167+
errors = np.array([
168+
angular_error_deg(dense_frames_truth[i], dense_frames_lqi[i])
169+
for i in range(n_dense)
170+
])
171+
print(f"Mean angular error vs Frenet truth: {errors.mean():.3f} deg")
172+
print(f"Max angular error vs Frenet truth: {errors.max():.3f} deg")
173+
174+
# ------------------------------------------------------------------
175+
# Visualization
176+
# ------------------------------------------------------------------
177+
# Cap the number of waypoint markers actually drawn so the plots stay
178+
# readable when n_waypoints is large (e.g. 1000). The interpolator still
179+
# uses all of them — this only affects the visual overlay.
180+
max_shown_waypoints = 30
181+
if n_waypoints > max_shown_waypoints:
182+
show_idx = np.linspace(0, n_waypoints - 1, max_shown_waypoints, dtype=int)
183+
else:
184+
show_idx = np.arange(n_waypoints)
185+
wp_positions_shown = wp_positions[show_idx]
186+
times_shown = times[show_idx]
187+
quaternions_shown = [quaternions[i] for i in show_idx]
188+
189+
fig = plt.figure(figsize=(16, 7))
190+
191+
# Left panel: waypoints + interpolated LQI tool frames.
192+
ax_left = fig.add_subplot(121, projection="3d")
193+
plot_frames(ax_left, dense_positions, dense_frames_lqi, scale=0.6, skip=12)
194+
ax_left.scatter(
195+
wp_positions_shown[:, 0],
196+
wp_positions_shown[:, 1],
197+
wp_positions_shown[:, 2],
198+
color="magenta",
199+
s=40,
200+
depthshade=False,
201+
label=f"LQI waypoints ({len(show_idx)} of {n_waypoints} shown)",
202+
)
203+
ax_left.set_title("Helix + LQI-interpolated frames")
204+
ax_left.set_xlabel("x")
205+
ax_left.set_ylabel("y")
206+
ax_left.set_zlabel("z")
207+
ax_left.legend(loc="upper left")
208+
209+
# Right panel: ground-truth analytical Frenet frames on the dense grid.
210+
ax_right = fig.add_subplot(122, projection="3d")
211+
plot_frames(ax_right, dense_positions, dense_frames_truth, scale=0.6, skip=12)
212+
ax_right.set_title("Analytical Frenet frames (reference)")
213+
ax_right.set_xlabel("x")
214+
ax_right.set_ylabel("y")
215+
ax_right.set_zlabel("z")
216+
217+
for ax in (ax_left, ax_right):
218+
ax.set_box_aspect([1, 1, 1])
219+
220+
plt.tight_layout()
221+
222+
# Quaternion trajectory in stereographic (MRP) projection space.
223+
# This shows the orientation evolution in the *rotation* space rather than
224+
# in the cartesian position space, with the input waypoints highlighted.
225+
visualizer = QuaternionTrajectoryVisualizer()
226+
visualizer.plot_3d_trajectory(
227+
dense_quaternions_lqi,
228+
waypoints=quaternions_shown,
229+
waypoint_times=list(times_shown),
230+
title="LQI quaternion trajectory (stereographic MRP projection)",
231+
color="purple",
232+
line_width=2.5,
233+
point_size=15,
234+
waypoint_color="magenta",
235+
show_waypoint_labels=False,
236+
figsize=(10, 8),
237+
)
238+
239+
# Bottom: angular error along the curve.
240+
_fig2, ax_err = plt.subplots(figsize=(10, 3.5))
241+
ax_err.plot(u_dense, errors, color="purple", linewidth=2.0)
242+
ax_err.fill_between(u_dense, errors, alpha=0.25, color="purple")
243+
ax_err.set_xlabel("Parameter u")
244+
ax_err.set_ylabel("Angular error [deg]")
245+
ax_err.set_title("Geodesic deviation: LQI orientation vs Frenet truth")
246+
ax_err.grid(True, alpha=0.3)
247+
plt.tight_layout()
248+
249+
# ------------------------------------------------------------------
250+
# Rotation-vector decomposition produced internally by LQI.
251+
# LQI splines a single 3D vector r(u) = theta(u) * n_hat(u). We plot
252+
# both the raw components r_x, r_y, r_z and the (theta, n_hat)
253+
# decomposition recovered from |r| and r/|r|.
254+
# ------------------------------------------------------------------
255+
r_dense = np.array([lqi.bspline_interpolator.evaluate(u) for u in u_dense])
256+
r_wp = np.array([lqi.bspline_interpolator.evaluate(u) for u in times_shown])
257+
258+
theta_dense = np.linalg.norm(r_dense, axis=1)
259+
theta_wp = np.linalg.norm(r_wp, axis=1)
260+
261+
# Unit axis from r / |r|, with a safe fallback near theta ~ 0.
262+
eps_axis = 1e-12
263+
safe_theta_dense = np.where(theta_dense > eps_axis, theta_dense, 1.0)
264+
nhat_dense = r_dense / safe_theta_dense[:, None]
265+
nhat_dense[theta_dense <= eps_axis] = np.array([1.0, 0.0, 0.0])
266+
267+
safe_theta_wp = np.where(theta_wp > eps_axis, theta_wp, 1.0)
268+
nhat_wp = r_wp / safe_theta_wp[:, None]
269+
nhat_wp[theta_wp <= eps_axis] = np.array([1.0, 0.0, 0.0])
270+
271+
_fig3, axes3 = plt.subplots(4, 1, figsize=(11, 9), sharex=True)
272+
component_labels = [r"$\theta = \|r\|$ [rad]", r"$\hat n_x$", r"$\hat n_y$", r"$\hat n_z$"]
273+
component_data = [theta_dense, nhat_dense[:, 0], nhat_dense[:, 1], nhat_dense[:, 2]]
274+
waypoint_data = [theta_wp, nhat_wp[:, 0], nhat_wp[:, 1], nhat_wp[:, 2]]
275+
component_colors = ["tab:orange", "tab:red", "tab:green", "tab:blue"]
276+
277+
for ax, label, curve, wp_vals, c in zip(
278+
axes3, component_labels, component_data, waypoint_data, component_colors
279+
):
280+
ax.plot(u_dense, curve, color=c, linewidth=2.0, label="LQI")
281+
ax.scatter(times_shown, wp_vals, color="magenta", s=25, zorder=5, label="waypoints")
282+
ax.set_ylabel(label)
283+
ax.grid(True, alpha=0.3)
284+
ax.legend(loc="upper right", fontsize=8)
285+
286+
axes3[-1].set_xlabel("Parameter u")
287+
axes3[0].set_title(
288+
r"LQI internal state recovered from $r(u) = \theta(u)\,\hat n(u)$"
289+
)
290+
plt.tight_layout()
291+
292+
# Raw rotation-vector components r_x, r_y, r_z (what LQI actually splines).
293+
_fig3b, axes3b = plt.subplots(3, 1, figsize=(11, 7), sharex=True)
294+
raw_labels = [r"$r_x$", r"$r_y$", r"$r_z$"]
295+
raw_colors = ["tab:red", "tab:green", "tab:blue"]
296+
for ax, label, k, c in zip(axes3b, raw_labels, range(3), raw_colors):
297+
ax.plot(u_dense, r_dense[:, k], color=c, linewidth=2.0, label="LQI")
298+
ax.scatter(times_shown, r_wp[:, k], color="magenta", s=25, zorder=5, label="waypoints")
299+
ax.set_ylabel(label)
300+
ax.grid(True, alpha=0.3)
301+
ax.legend(loc="upper right", fontsize=8)
302+
axes3b[-1].set_xlabel("Parameter u")
303+
axes3b[0].set_title(r"LQI spline state: rotation-vector components $r(u)$")
304+
plt.tight_layout()
305+
306+
# ------------------------------------------------------------------
307+
# Physical angular velocity (omega) and acceleration (alpha) in 3D.
308+
# ------------------------------------------------------------------
309+
omega = np.zeros((n_dense, 3))
310+
alpha = np.zeros((n_dense, 3))
311+
for i, u in enumerate(u_dense):
312+
omega[i], alpha[i] = lqi.get_physical_kinematics(u)
313+
314+
omega_norm = np.linalg.norm(omega, axis=1)
315+
alpha_norm = np.linalg.norm(alpha, axis=1)
316+
317+
_fig4, (ax_w, ax_a) = plt.subplots(2, 1, figsize=(11, 7), sharex=True)
318+
319+
for k, axis_name, c in zip(range(3), ("x", "y", "z"), ("tab:red", "tab:green", "tab:blue")):
320+
ax_w.plot(u_dense, omega[:, k], color=c, linewidth=1.8, label=rf"$\omega_{axis_name}$")
321+
ax_a.plot(u_dense, alpha[:, k], color=c, linewidth=1.8, label=rf"$\alpha_{axis_name}$")
322+
323+
ax_w.plot(u_dense, omega_norm, color="black", linewidth=1.2, linestyle="--", label=r"$\|\omega\|$")
324+
ax_a.plot(u_dense, alpha_norm, color="black", linewidth=1.2, linestyle="--", label=r"$\|\alpha\|$")
325+
326+
ax_w.set_ylabel(r"Angular velocity $\omega$ [rad/u]")
327+
ax_w.set_title("Physical angular velocity and acceleration from LQI")
328+
ax_w.legend(loc="upper right", ncol=4, fontsize=9)
329+
ax_w.grid(True, alpha=0.3)
330+
331+
ax_a.set_ylabel(r"Angular acceleration $\alpha$ [rad/u$^2$]")
332+
ax_a.set_xlabel("Parameter u")
333+
ax_a.legend(loc="upper right", ncol=4, fontsize=9)
334+
ax_a.grid(True, alpha=0.3)
335+
336+
plt.tight_layout()
337+
338+
plt.show()
339+
340+
341+
if __name__ == "__main__":
342+
main()

0 commit comments

Comments
 (0)