Skip to content

Commit b678de0

Browse files
Merge pull request #1225 from pollen-robotics/fix/prevent-yaw-interpolation-through-the-back
Fix : Prevent yaw from being interpolated through 180° when using goto.
2 parents 3be62bf + 9e324b9 commit b678de0

3 files changed

Lines changed: 95 additions & 9 deletions

File tree

src/reachy_mini/motion/goto.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ def evaluate(
5757
interp_time = time_trajectory(t / self.duration, method=self.method)
5858

5959
interp_head_pose = linear_pose_interpolation(
60-
self.start_head_pose, self.target_head_pose, interp_time
60+
self.start_head_pose,
61+
self.target_head_pose,
62+
interp_time,
63+
yaw_as_scalar=True,
6164
)
6265
interp_antennas_joint = (
6366
self.start_antennas

src/reachy_mini/utils/interpolation.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,39 @@ def f(t: float) -> npt.NDArray[np.float64]:
5656

5757

5858
def linear_pose_interpolation(
59-
start_pose: npt.NDArray[np.float64], target_pose: npt.NDArray[np.float64], t: float
59+
start_pose: npt.NDArray[np.float64],
60+
target_pose: npt.NDArray[np.float64],
61+
t: float,
62+
yaw_as_scalar: bool = False,
6063
) -> npt.NDArray[np.float64]:
61-
"""Linearly interpolate between two poses in 6D space."""
64+
"""Linearly interpolate between two poses in 6D space.
65+
66+
Use `yaw_as_scalar` to interpolate yaw as a signed scalar Euler angle instead of along the SO(3) geodesic.
67+
This keeps the path through the front rather than taking the shortest 3D rotation through +-180° (the back).
68+
"""
6269
# Extract rotations
6370
rot_start = R.from_matrix(start_pose[:3, :3])
6471
rot_end = R.from_matrix(target_pose[:3, :3])
6572

66-
# Compute relative rotation q_rel such that rot_start * q_rel = rot_end
67-
q_rel = rot_start.inv() * rot_end
68-
# Convert to rotation vector (axis-angle)
69-
rotvec_rel = q_rel.as_rotvec()
70-
# Scale the rotation vector by t (allows t<0 or >1 for overshoot)
71-
rot_interp = (rot_start * R.from_rotvec(rotvec_rel * t)).as_matrix()
73+
if yaw_as_scalar:
74+
# Factor yaw out (outermost factor) and interpolate it as a signed scalar,
75+
yaw_start = rot_start.as_euler("xyz")[2]
76+
yaw_end = rot_end.as_euler("xyz")[2]
77+
yaw_interp = yaw_start + (yaw_end - yaw_start) * t
78+
res_start = R.from_euler("z", -yaw_start) * rot_start
79+
res_end = R.from_euler("z", -yaw_end) * rot_end
80+
# SLERPing only the pitch/roll residual.
81+
rotvec_rel = (res_start.inv() * res_end).as_rotvec()
82+
res_interp = res_start * R.from_rotvec(rotvec_rel * t)
83+
rot_interp = (R.from_euler("z", yaw_interp) * res_interp).as_matrix()
84+
else:
85+
# Geodesic (shortest-path) SLERP via rotation vector.
86+
# Compute relative rotation q_rel such that rot_start * q_rel = rot_end
87+
q_rel = rot_start.inv() * rot_end
88+
# Convert to rotation vector (axis-angle)
89+
rotvec_rel = q_rel.as_rotvec()
90+
# Scale the rotation vector by t (allows t<0 or >1 for overshoot)
91+
rot_interp = (rot_start * R.from_rotvec(rotvec_rel * t)).as_matrix()
7292

7393
# Extract translations
7494
pos_start = start_pose[:3, 3]
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Tests for pose interpolation, in particular the yaw_as_scalar routing fix."""
2+
3+
import numpy as np
4+
from scipy.spatial.transform import Rotation as R
5+
6+
from reachy_mini.utils import create_head_pose
7+
from reachy_mini.utils.interpolation import linear_pose_interpolation
8+
9+
10+
def _world_yaw_deg(pose):
11+
return R.from_matrix(pose[:3, :3]).as_euler("xyz", degrees=True)[2]
12+
13+
14+
def test_yaw_as_scalar_routes_through_front_not_back():
15+
"""look-left(+120) -> look-right(-120) must route through 0, never the back (+-180).
16+
17+
Regression for the SLERP geodesic taking the short 3D path around +-180 deg, which
18+
the bounded +-160 deg body yaw cannot follow (causing a discontinuous body_yaw flip).
19+
"""
20+
start = create_head_pose(yaw=120, degrees=True)
21+
end = create_head_pose(yaw=-120, degrees=True)
22+
23+
yaws = [
24+
_world_yaw_deg(
25+
linear_pose_interpolation(start, end, i / 60, yaw_as_scalar=True)
26+
)
27+
for i in range(61)
28+
]
29+
30+
# never swings out toward +-180 (the back)
31+
assert max(abs(y) for y in yaws) <= 121.0
32+
# passes through the front (~0 deg)
33+
assert min(abs(y) for y in yaws) <= 5.0
34+
# smooth: no large per-frame jump from a back crossover
35+
assert max(abs(b - a) for a, b in zip(yaws, yaws[1:])) < 10.0
36+
37+
38+
def test_yaw_as_scalar_matches_slerp_without_back_crossing():
39+
"""Outside the cross-the-back case, yaw_as_scalar is identical to the geodesic SLERP."""
40+
# same-side sweep with pitch/roll, body must move but no back crossing
41+
start = create_head_pose(yaw=130, pitch=15, roll=10, degrees=True)
42+
end = create_head_pose(yaw=70, pitch=15, roll=10, degrees=True)
43+
44+
for i in range(21):
45+
t = i / 20
46+
np.testing.assert_allclose(
47+
linear_pose_interpolation(start, end, t, yaw_as_scalar=True),
48+
linear_pose_interpolation(start, end, t),
49+
atol=1e-9,
50+
)
51+
52+
53+
def test_yaw_as_scalar_endpoints_exact():
54+
"""t=0 and t=1 reproduce the start and target poses exactly."""
55+
start = create_head_pose(yaw=120, pitch=5, degrees=True)
56+
end = create_head_pose(yaw=-120, roll=8, degrees=True)
57+
58+
np.testing.assert_allclose(
59+
linear_pose_interpolation(start, end, 0.0, yaw_as_scalar=True), start, atol=1e-9
60+
)
61+
np.testing.assert_allclose(
62+
linear_pose_interpolation(start, end, 1.0, yaw_as_scalar=True), end, atol=1e-9
63+
)

0 commit comments

Comments
 (0)