Fix non-deterministic axis for 180-deg rotations (#366) - #372
TabishShahMohsin wants to merge 12 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.
|
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.
| 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.
There was a problem hiding this comment.
Thanks. Could you check [0, 0, 0, pi] explicitly? Does it return [1, 0, 0, 0] as suggested in the docstring?
| 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, )
There was a problem hiding this comment.
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...
| 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.
There was a problem hiding this comment.
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.
baa577c to
cfb9a7a
Compare
| 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)) |
There was a problem hiding this comment.
What if we flip the axis signs? Does it still produce the same axis consistently?
| # [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)) |
There was a problem hiding this comment.
The test does not make a lot of sense. Which axis-angle representation do we expect here exactly?
There was a problem hiding this comment.
The docstring says [1, 0, 0, 0]. Is it the case here?
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
Which result do we expect here?
There was a problem hiding this comment.
[1.0, 0.0, 0.0, np.pi]
There was a problem hiding this comment.
Then please state this explicitly in the test. It is currently a circular test.
| ------- | ||
| 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 |
There was a problem hiding this comment.
Please correct the range to [0, pi] as we now return angles pi explicitly.
…as well for test_batch_rotations.py
|
Thanks for the thorough review and patience. |
…eral values for non random tests
|
|
||
| # 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 |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
This check still does not make any sense to me. Both the expected and the actual value go through exactly the same function.
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