Skip to content

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

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

TabishShahMohsin wants to merge 12 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 thread pytransform3d/rotations/_axis_angle.py
Comment thread pytransform3d/rotations/_axis_angle.py Outdated
Comment thread pytransform3d/batch_rotations/_axis_angle.py Outdated
Comment thread pytransform3d/rotations/test/test_axis_angle.py Outdated
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.

Comment thread pytransform3d/batch_rotations/_axis_angle.py Outdated
Comment thread pytransform3d/rotations/_axis_angle.py Outdated
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.

@AlexanderFabisch AlexanderFabisch Aug 27, 2026

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.

Thanks. Could you check [0, 0, 0, pi] explicitly? Does it return [1, 0, 0, 0] as suggested in the docstring?

Comment thread pytransform3d/batch_rotations/test/test_batch_rotations.py
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, )

@AlexanderFabisch AlexanderFabisch Aug 27, 2026

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.

But does it work for, e.g., 3d arrays? Would first_non_zero_axis_components = axes_with_pi_rotation[..., first_non_zero_idx_per_entry] work? (Maybe not.) I just feel there should be a simpler solution than arange...

Comment thread pytransform3d/batch_rotations/_axis_angle.py Outdated
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.

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 think this matches too broadly. There is still valuable information in the sign of the rotation axis when the angle is within the range of 1e-4 or 1e-6 of pi. This might be critical in applications, in which high numerical precision is required.

1e-4 was specifically selected to bound the error of that conversion function. We would have to do a similar analysis for this case: measure the minimum of the angle between the normalized axes before and after normalization and abs(this value - pi) (or something similar). However, I think it is perfectly safe to just check angle == pi.

Comment thread pytransform3d/batch_rotations/test/test_batch_rotations.py
Comment thread pytransform3d/rotations/test/test_axis_angle.py
Comment thread pytransform3d/rotations/_axis_angle.py Outdated
@TabishShahMohsin
TabishShahMohsin force-pushed the fix-180-deg-axis-deterministic branch from baa577c to cfb9a7a Compare August 25, 2026 19:40
for axis in axes:
a_random = np.hstack((axis, [np.pi]))
res_random = pbr.norm_axis_angles(a_random)
assert_array_almost_equal(res_random, pr.norm_axis_angle(a_random))

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.

What if we flip the axis signs? Does it still produce the same axis consistently?

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 add this check.

# [0, 0, 0]
a_zero_axis = np.array([0.0, 0.0, 0.0, np.pi])
res_zero_axis = pbr.norm_axis_angles(a_zero_axis)
assert_array_almost_equal(res_zero_axis, pr.norm_axis_angle(a_zero_axis))

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 test does not make a lot of sense. Which axis-angle representation do we expect here exactly?

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 docstring says [1, 0, 0, 0]. Is it the case here?

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.

Yes

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.

Then please write this explicitly. It is currently a circular test.

# (4,)
a_single = np.array([-1.0, 0.0, 0.0, np.pi])
res_single = pbr.norm_axis_angles(a_single)
assert_array_almost_equal(res_single, pr.norm_axis_angle(a_single))

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.

Which result do we expect here?

@TabishShahMohsin TabishShahMohsin Sep 2, 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.

[1.0, 0.0, 0.0, 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.

Then please state this explicitly in the test. It is currently a circular test.

Comment thread pytransform3d/rotations/_axis_angle.py Outdated
-------
a : array, shape (4,)
Axis of rotation and rotation angle: (x, y, z, angle). The length
of the axis vector is 1 and the angle is in [0, pi). No rotation

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 correct the range to [0, pi] as we now return angles pi explicitly.

@TabishShahMohsin

Copy link
Copy Markdown
Author

Thanks for the thorough review and patience.


# Issue #366: Make axis deterministic for 180 degree rotations
# the first non-zero component of axis should be positive.
pi_mask = (np.abs(res[..., 3] - np.pi) < tolerance) & 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.

Checking res[..., 3] == np.pi seems to be the most reasonable solution. There seems to be no good argument for a broader range defined by threshold.


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

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.

Checking angle == np.pi seems to be the most reasonable solution. There seems to be no good argument for a broader range defined by threshold.

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.

Exact floating comparison is fragile. It can break even on mathematically equivalent expressions.

pi = np.pi 
for i in range(1, 50):
  a = (pi / i) * i
  if a!=pi:
    print(i)

This prints 25 and 49.

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 believe this is more a problem of the previous processing steps, not something that we have to or can fix here.

The problem is: When the computation is exact, accepting a broader range at this check messes up the result. It might result in a slightly wrong axis.

assert_array_almost_equal(res_random, pr.norm_axis_angle(a_random))
a_random = np.hstack((-axis, [np.pi]))
res_random = pbr.norm_axis_angles(a_random)
assert_array_almost_equal(res_random, pr.norm_axis_angle(a_random))

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 test is still circular. I'd convert both the unnormalized and the normalized axis-angle representation to quaternion or rotation matrix and check whether they are identical.

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 though that it so to make it consistent with the existing tests, have added the test.

res_random = pbr.norm_axis_angles(a_random)

# scalar vs batch consistency check
assert_array_almost_equal(res_random, pr.norm_axis_angle(a_random))

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 check still does not make any sense to me. Both the expected and the actual value go through exactly the same function.

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