Fix non-deterministic axis for 180-deg rotations (#366) - #372
Fix non-deterministic axis for 180-deg rotations (#366)#372TabishShahMohsin wants to merge 9 commits into
Conversation
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.
There was a problem hiding this comment.
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.
| # 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 |
| # 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 |
There was a problem hiding this comment.
This is what I thought too. Please change the condition. isclose matches too broadly.
| 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]) |
|
Hi @TabishShahMohsin , thanks for the contribution. I don't know why copilot decided to do the review, but I will start mine now. :) |
AlexanderFabisch
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
This is what I thought too. Please change the condition. isclose matches too broadly.
|
|
||
| # 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): |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
- Can we rename this to
first_non_zero_idx_per_entryor something similar? - 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=1should be replaced byaxis=-1. - What if the axis is
[0, 0, 0]? This is an edge case.
There was a problem hiding this comment.
I have corrected the axis=1 bug, and changed the variable names.
|
|
||
|
|
||
| def test_norm_axis_angle_180_degrees_deterministic_batch(): | ||
| A = np.array([ |
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
Why don't we just use : instead of np.arange?
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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(): |
There was a problem hiding this comment.
Please also add test cases with other array shapes:
- shape (4,)
- shape (N, M, 4)
| ) | ||
|
|
||
|
|
||
| def test_norm_axis_angle_180_degrees_deterministic(): |
There was a problem hiding this comment.
Same here, please add tests for [0,0,0] and random axes.
There was a problem hiding this comment.
Done! I've added many test cases and tried to make them consistent with other test functions.
| # the first non-zero component of axis should be positive. | ||
| if np.isclose(angle, np.pi): | ||
| is_zero = np.isclose(res[:3], 0.0) | ||
|
|
There was a problem hiding this comment.
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.
baa577c to
cfb9a7a
Compare
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