Skip to content

Fix non-deterministic axis for 180-deg rotations (#366) - #372

Open
TabishShahMohsin wants to merge 9 commits into
dfki-ric:mainfrom
TabishShahMohsin:fix-180-deg-axis-deterministic
Open

Fix non-deterministic axis for 180-deg rotations (#366)#372
TabishShahMohsin wants to merge 9 commits into
dfki-ric:mainfrom
TabishShahMohsin:fix-180-deg-axis-deterministic

Conversation

@TabishShahMohsin

Copy link
Copy Markdown

Ensure norm_axis_angle and norm_axis_angles always return a deterministic rotation axis for angles of exactly pi by flipping negative leading non-zero vector components. Adds unit tests.

Fixes #366

Ensure norm_axis_angle and norm_axis_angles always return a deterministic rotation axis for angles of exactly pi by flipping negative leading non-zero vector components. Adds unit tests.
Copilot AI lite review requested due to automatic review settings August 14, 2026 11:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses Issue #366 by making norm_axis_angle and norm_axis_angles return a deterministic axis for 180° (π rad) rotations, removing the sign ambiguity between axis and -axis at π. It also adds unit tests to validate the deterministic behavior in both scalar and batch APIs.

Changes:

  • Canonicalize the rotation axis sign at angle π so the first non-zero component is positive.
  • Add scalar and batch unit tests covering negative-axis inputs at π.
  • Update normalization behavior documentation/tests for the deterministic π-axis rule.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
pytransform3d/rotations/_axis_angle.py Adds deterministic axis sign normalization for π rotations in norm_axis_angle.
pytransform3d/batch_rotations/_axis_angle.py Adds deterministic axis sign normalization for π rotations in norm_axis_angles.
pytransform3d/rotations/test/test_axis_angle.py Adds unit test for deterministic axis output at π in scalar normalization.
pytransform3d/batch_rotations/test/test_batch_rotations.py Adds unit test for deterministic axis output at π in batch normalization.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +97 to +107
# Issue #366: Make axis deterministic for 180 degree rotations
# the first non-zero component of axis should be positive.
if np.isclose(angle, np.pi):
is_zero = np.isclose(res[:3], 0.0)

non_zero_indices = np.where(~is_zero)[0]

if len(non_zero_indices) > 0:
first_non_zero_val = res[non_zero_indices[0]]
if first_non_zero_val < 0.0:
res[:3] *= -1.0
Comment thread pytransform3d/rotations/_axis_angle.py Outdated
Comment on lines +97 to +99
# Issue #366: Make axis deterministic for 180 degree rotations
# the first non-zero component of axis should be positive.
if np.isclose(angle, np.pi):

# Issue #366: Make axis deterministic for 180 degree rotations
# the first non-zero component of axis should be positive.
pi_mask = np.isclose(res[..., 3], np.pi) & rot_mask

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I thought too. Please change the condition. isclose matches too broadly.

Comment on lines +289 to +298
def test_norm_axis_angle_180_degrees_deterministic():
a1 = np.array([-1.0, 0.0, 0.0, np.pi])
res1 = pr.norm_axis_angle(a1)
np.testing.assert_array_almost_equal(res1[:3], [1.0, 0.0, 0.0])
a2 = np.array([0.0, -1.0, 0.0, np.pi])
res2 = pr.norm_axis_angle(a2)
np.testing.assert_array_almost_equal(res2[:3], [0.0, 1.0, 0.0])
a3 = np.array([0.0, 0.0, -1.0, np.pi])
res3 = pr.norm_axis_angle(a3)
np.testing.assert_array_almost_equal(res3[:3], [0.0, 0.0, 1.0])
Comment thread pytransform3d/batch_rotations/test/test_batch_rotations.py Outdated
@AlexanderFabisch

Copy link
Copy Markdown
Member

Hi @TabishShahMohsin , thanks for the contribution. I don't know why copilot decided to do the review, but I will start mine now. :)

@AlexanderFabisch AlexanderFabisch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are my comments.


# Issue #366: Make axis deterministic for 180 degree rotations
# the first non-zero component of axis should be positive.
pi_mask = np.isclose(res[..., 3], np.pi) & rot_mask

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I thought too. Please change the condition. isclose matches too broadly.

Comment thread pytransform3d/rotations/_axis_angle.py Outdated

# Issue #366: Make axis deterministic for 180 degree rotations
# the first non-zero component of axis should be positive.
if np.isclose(angle, np.pi):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. isclose matches to broadly. The modification should just apply to exactly pi.

if np.any(pi_mask):
axes = res[pi_mask, :3]
is_zero = np.isclose(axes, 0.0)
first_non_zero_idx = np.argmax(~is_zero, axis=1)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Can we rename this to first_non_zero_idx_per_entry or something similar?
  2. What if the input is not a 2d array, but generally an N-dimensional array, e.g., an array of an array of axis-angle representations? axis=1 should be replaced by axis=-1.
  3. What if the axis is [0, 0, 0]? This is an edge case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have corrected the axis=1 bug, and changed the variable names.



def test_norm_axis_angle_180_degrees_deterministic_batch():
A = np.array([

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd add additional tests for [0, 0, 0] and random axes.

axes = res[pi_mask, :3]
is_zero = np.isclose(axes, 0.0)
first_non_zero_idx = np.argmax(~is_zero, axis=1)
row_indices = np.arange(len(axes))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we just use : instead of np.arange?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to use this for (4, )

# the first non-zero component of axis should be positive.
pi_mask = np.isclose(res[..., 3], np.pi) & rot_mask
if np.any(pi_mask):
axes = res[pi_mask, :3]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable name axes is very generic. These are not all axes, but the axes for which the rotation is pi. I'd suggest renaming to axes_with_pi_rotation.

pi_mask = np.isclose(res[..., 3], np.pi) & rot_mask
if np.any(pi_mask):
axes = res[pi_mask, :3]
is_zero = np.isclose(axes, 0.0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not entirely sure whether we want to use np.isclose here. At least the default threshold might not be appropriate. Which threshold does it use?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed batch_rotations/_matrix.py line 51 (which comes from #43 ):
angle_close_to_pi = np.abs(angles - np.pi) < 1e-4
Also "tolerance=1e-6" is used at many many places, so I have added the same.

pbr.smooth_quaternion_trajectory(np.zeros((0, 4)))


def test_norm_axis_angle_180_degrees_deterministic_batch():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also add test cases with other array shapes:

  • shape (4,)
  • shape (N, M, 4)

)


def test_norm_axis_angle_180_degrees_deterministic():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, please add tests for [0,0,0] and random axes.

@TabishShahMohsin TabishShahMohsin Aug 26, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! I've added many test cases and tried to make them consistent with other test functions.

Comment thread pytransform3d/rotations/_axis_angle.py Outdated
# the first non-zero component of axis should be positive.
if np.isclose(angle, np.pi):
is_zero = np.isclose(res[:3], 0.0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't put a blank line here. The only purpose of is_zero is to compute non_zero_indices, so they are conceptually connected and, hence, should be so spatially.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have now removed it.

@TabishShahMohsin
TabishShahMohsin force-pushed the fix-180-deg-axis-deterministic branch from baa577c to cfb9a7a Compare August 25, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

norm_axis_angles and norm_axis_angle should produce deterministic axis for 180 degree rotations

3 participants