Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ellipsoid_coords function #5263

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

ksugar
Copy link

@ksugar ksugar commented Mar 9, 2021

Description

This PR adds the draw3d.ellipsoid_coords function that is analogous to the draw.ellipse function.
This function returns the voxel coordinates of an ellipsoid, while the existing function draw3d.ellipsoid returns the 3d ndarray that renders an ellipsoid.
This function accepts the rotations in each axis (i.e. rot_x, rot_y and rot_z).
It also supports the anisotropic resolution by introducing the spacing parameter.

A variation of this function is implemented and used in the following preprint.

Tracking cell lineages in 3D by incremental deep learning. Ko Sugawara, Cagri Cevrim, Michalis Averof
bioRxiv 2021.02.26.432552; doi: https://doi.org/10.1101/2021.02.26.432552

Original implementation used in the preprint:
https://github.com/elephant-track/elephant-server/blob/v0.1.0/elephant-core/elephant/util/ellipsoid.py

Checklist

  • Docstrings for all functions

  • Gallery example in ./doc/examples (new features only)
    I am not sure if I should add the example.

  • Benchmark in ./benchmarks, if your changes aren't covered by an
    existing benchmark
    not applicable

  • Unit tests

  • Clean style in the spirit of PEP8

For reviewers

  • Check that the PR title is short, concise, and will make sense 1 year
    later.
  • Check that new functions are imported in corresponding __init__.py.
  • Check that new features, API changes, and deprecations are mentioned in
    doc/release/release_dev.rst.

@pep8speaks
Copy link

pep8speaks commented Mar 9, 2021

Hello @ksugar! Thanks for updating this PR. We checked the lines you've touched for PEP 8 issues, and found:

Line 217:56: W504 line break after binary operator
Line 220:56: W504 line break after binary operator

Comment last updated at 2021-03-16 15:29:13 UTC

skimage/draw/draw3d.py Outdated Show resolved Hide resolved
ksugar added a commit to ksugar/scikit-image that referenced this pull request Mar 11, 2021
based on the following review:
scikit-image#5263 (comment)

Co-authored-by: Juan Nunez-Iglesias <juan.nunez-iglesias@monash.edu>
@jni
Copy link
Member

jni commented Mar 11, 2021

Thanks for the update @ksugar! Now all that's missing is updating the tests to match 😬 😂, and as far as I'm concerned it's ready to go! 🎉

Copy link
Member

@jni jni left a comment

Choose a reason for hiding this comment

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

Pending getting the tests working, I'm happy with the code here. Thanks @ksugar! @scikit-image/core, can someone else take a look? 🙏

@ksugar
Copy link
Author

ksugar commented Mar 11, 2021

Hi @jni , thank you for your review! I am working on updating the docstrings and the tests.
Actually, I also want to make the code more general.
In my case, it is helpful if the function accepts the rotation matrix itself, rather than the Euler angles only.
I will introduce helper functions that calculate the rotation matrix from the Euler angles and the yaw-pitch-roll system.
The usage would be like this:

dd, rr, cc = ellipsoid_coords((5., 5., 5.), (3., 3., 3.), rotation=euler_to_rotation(0., 0., np.pi / 2))

Could you wait a little until I update the code in that way? Thanks!

@jni
Copy link
Member

jni commented Mar 11, 2021

@ksugar all good. I wonder though, whether a rotation matrix is too many degrees of freedom — indeed, a matrix may or may not be a rotation matrix. I'm wondering whether we should support both APIs: rotation=<angles>, and rotation_matrix=<angles>. What do you think?

@ksugar
Copy link
Author

ksugar commented Mar 11, 2021

@jni You have a point! Then, how about the interface like this?

def ellipsoid_coords(..., rotation=<angles>, rotation_matrix=<angles>, geometry='euler', ...):

, where rotation will be ignored if rotation_matrix is defined. geometry will support euler or ypr (yaw-pitch-roll) at the moment. The ypr option would be helpful if the user wants to rotate the ellipsoid along the original axes.

📝 Additional note: The motivation to support rotation_matrix is based on my personal use case. I obtained the rotation matrix after some user interactions on GUI, which can be decomposed to the angles in multiple ways as you mentioned. It would not be a good idea to decompose and re-compose the rotation matrix.

@emmanuelle
Copy link
Member

Hi @ksugar just a question about the API: I'm not sure why we need two functions here, ellipsoid and ellipsoid_coords? Wouldn't it be possible to adapt the ellipsoid function with more parameters instead?

@ksugar
Copy link
Author

ksugar commented Mar 11, 2021

Hi @emmanuelle ellipsoid_coords returns the voxel coordinates of an ellipsoid, whileellipsoid returns the 3d ndarray that renders an ellipsoid. This behavior of ellipsoid is different from the draw functions implemented in 2d (e.g. ellipse, circle, disk), which return the pixel coordinates. It would be possible to integrate ellipsoid_coords with ellipsoid by introducing a flag parameter but I am not sure if it is a good idea for two reasons. First, each function returns different type of values (3d ndarray or coordinates). Second, ellipsoid already has a flag levelset that determines how to render the ellipsoid and an additional flag can make the interface and the code complicated.

@ksugar
Copy link
Author

ksugar commented Mar 11, 2021

@emmanuelle @jni Please give me advice or comments on whether it would be better to combine ellipsoid_coords and ellipsoid or to keep them separated. Thanks!

@jni
Copy link
Member

jni commented Mar 12, 2021

@ksugar you're absolutely right that ellipsoid was the wrong API for draw, and I don't think we should harmonize the APIs. I think medium term we can deprecate ellipsoid and replace it with ellipsoid coords, but that deprecation can happen after this PR. @emmanuelle do you agree?

@ksugar

, where rotation will be ignored if rotation_matrix is defined. geometry will support euler or ypr (yaw-pitch-roll) at the moment. The ypr option would be helpful if the user wants to rotate the ellipsoid along the original axes.

My suggestion is that we leave the geometry option out of this PR and maybe try to add it to an upcoming PR. Maybe even the rotation_matrix option. It's good to keep PRs small and focused and not doing too many things or too many fancy things with the API, because complicated APIs tend to generate more discussion and PRs can get stalled for a long time. I think if you only have rotation we'll get it in very quickly — and rotation_matrix is a non-breaking change that can be discussed independently. ypr is yet another extra thing that will generate even more discussion.

Other than that, rotation being ignored if rotation_matrix is not None is pretty standard and I totally support that API. So up to you whether you want to do that in this PR or a separate one.

@ksugar
Copy link
Author

ksugar commented Mar 14, 2021

@jni thank you for your feedback and suggestions! I see your point that it is better to start from the minimal implementation. I will leave the rotation_matrix for the next PR.

I found that we need to specify the order of rotations (e.g. z-x-z) to define the Euler angles. It is also required to specify if the rotations are intrinsic or extrinsic, which determine if the rotations occur about the rotating axes or the original axes. Actually, these factors made me confused to introduce the geometry option. Now, I think what I actually need to do is specifying these two parameters, rotation_order and is_intrinsic. I implemented them and updated the docstrings and tests. I also renamed rotation to rotation_angles so that it will not conflict with rotation_matrix in future.

@jni
Copy link
Member

jni commented Mar 15, 2021

Wow, lots of updates, thanks @ksugar! 😅

Some further comments:

  • my personal preference would be to name the arguments angles=, order=, and intrinsic=. Perhaps someone else from @scikit-image/core can weigh in. The public API is the most important thing to get right, everything else can come later!
  • this is probably a bit more controversial and arguable, but I just want to throw it out there: I would suggest that "order" should contain axes by number rather than name, since NumPy arrays don't actually have an order. So order='zyx' would actually be order=(0, 1, 2). This adds an extra mental operation for people coming from the mathematics side, but it removes the potential for confusing axis ordering — right now someone must dig into the docstring to find that 0 = z, 1 = y, and 2 = x. Ahead of time, people probably expect that the axes are ordered as xyz, so they need to jump a mental hoop anyway: might as well make it one that aligns with our data.
  • (totally optional) now that Add nD support to several geometric transform classes #5188 is merged, I wonder if you can extend the implementation there (skimage.transform._geometric._euler_rotation_matrix(angles, axes)) to take in intrinsic and use that. What do you think?

@rfezzani
Copy link
Member

I 👍 @jni suggested changes, I would also make order default to None corresponding to the (0, 1, 2).

@ksugar
Copy link
Author

ksugar commented Mar 16, 2021

Hi @jni thank you for your feedback! _euler_rotation(axis, angle) In your PR #5188 generates a base rotation matrix in a smart way! It's amazing! I would like to adopt your function instead of my current implementation.

I would like to confirm about the _euler_rotation_matrix(angles, axes). The order of rotations is different from what I expected.

R = np.eye(dim)
for i, angle in zip(axes, angles):
    R = R @ _euler_rotation(i, angle)

will generate R = R_0 @ R_1 @ R_2. If it is applied to a vector (e.g. R @ V), the rotations will be applied in the order R_2 -> R_1 -> R_0 (from right to left), which is opposite from my expectation.

R = np.eye(3)
if intrinsic:
    for i, angle in zip(axes, angles):
        R = _euler_rotation(i, angle) @ R
    R = R.T
else:
    for i, angle in zip(axes, angles):
        R = _euler_rotation(i, angle).T @ R

works perfectly in my case, where the transpose operation .T is required to convert from the rotations for axes to for positions.

I would also like to use the term axes instead of order to avoid complication.

@ksugar
Copy link
Author

ksugar commented Mar 16, 2021

@rfezzani I will follow the same convention as @jni, thanks for your feedback!

@jni
Copy link
Member

jni commented Mar 16, 2021

Hahaha "your function is amazing! btw, it is also wrong." 😂

I think you are almost certainly right @ksugar! 🤦 Would be great if @grlee77, @stefanv or @rfezzani could check the above, as I'm a total newcomer to 3D rotations and they make my head hurt. 😕 But by my reading, we did get it wrong in #5188, even after much discussion. 😂

Thanks @ksugar!

@ksugar
Copy link
Author

ksugar commented Mar 17, 2021

@jni actually your code is great 👍 I'm learning a lot of techniques from your code!
I agree that dealing with 3D rotations is a tough work... ellipsoid_coords() is now working as a test component of the PR #5188. It's nice we work on the complementary topics at the same time!

If this change is approved by the members and _euler_rotation_matrix(angles, axes) is updated, I will replace the following part from

if intrinsic:
    for i, angle in zip(axes, angles):
        R = _euler_rotation(i, angle) @ R
    R = R.T

to

if intrinsic:
    R = _euler_rotation_matrix(angles, axes).T

@jni
Copy link
Member

jni commented Mar 23, 2021

kind ping to @stefanv @grlee77 @rfezzani, maybe @JDWarner, if someone could check our math for 3D Euler rotations it would be much appreciated! See discussion starting at #5263 (comment)

@rfezzani
Copy link
Member

At first, I agreed with @ksugar, rotations should applied from left to right. But to convince myself, I wanted to compare skimage result with open3D:

>>> import copy
>>> import open3d as o3d
>>> from skimage.transform._geometric import _euler_rotation_matrix
>>> angles = (np.pi/2,0,np.pi/4)
>>> mesh = o3d.geometry.TriangleMesh.create_coordinate_frame()
>>> mesh_r_o3d = copy.deepcopy(mesh).translate((2,0,0))
>>> mesh_r_skimage = copy.deepcopy(mesh).translate((4,0,0))
>>> mesh_r_o3d.rotate(mesh.get_rotation_matrix_from_xyz(angles))
TriangleMesh with 1134 points and 2240 triangles.
>>> mesh_r_skimage.rotate(_euler_rotation_matrix(angles))
TriangleMesh with 1134 points and 2240 triangles.
>>> o3d.visualization.draw_geometries([mesh, mesh_r_o3d, mesh_r_skimage])
>>> np.array_equmesh.get_rotation_matrix_from_xyz(angles)
np.array_equal(  np.array_equiv(  
>>> np.array_equal(mesh.get_rotation_matrix_from_xyz(angles), _euler_rotation_matrix(angles))
True

The results are equal!
Capture du 2021-03-25 11-32-07

@jni
Copy link
Member

jni commented Mar 25, 2021

@rfezzani Thank you! Just to be absolutely clear, you're using the function from this PR, right? 😅

@ksugar do you want to make those changes you proposed above? Then we can probably get this merged! 🎉

@rfezzani
Copy link
Member

@rfezzani Thank you! Just to be absolutely clear, you're using the function from this PR, right?

No @jni, I am using main branch 😕

@ksugar
Copy link
Author

ksugar commented Mar 26, 2021

@rfezzani @jni it is great to compare the output with Open3D! I checked the source code of Open3D and its implementations are exactly same as what we can find in the main branch. Eigen::Matrix3d RotationMatrix{X, Y, Z}(double radians) is equivalent to skimage.transform._geometric._euler_rotation(axis, angle). Eigen::Matrix3d Geometry3D::GetRotationMatrixFromXYZ is equivalent to skimage.transform._geometric._euler_rotation_matrix(angles, axes).

I would like to confirm the assumptions in the implementations.

  1. Will _euler_rotation(axis, angle) and _euler_rotation_matrix(angles, axes) rotate points or standard basis vectors?
  2. Is the axis/axes ordering 0 = x, 1 = y, and 2 = z or 0 = z, 1 = y, and 2 = x?

If _euler_rotation(axis, angle) and _euler_rotation_matrix(angles, axes) rotate points and the assumed ordering is 0 = x, 1 = y, and 2 = z, they are the same as Open3D and I think the implementations in the main branch is perfect!

On the other hand, I was assuming that the functions rotate standard basis vectors and the ordering is 0 = x, 1 = y, and 2 = z, which I think is the reason of incompatibility. I am happy with following the convention implemented in Open3D.

About the order of matrices in calculation, as far as I understand, the following explains it.
If we define X, Y and Z as the rotation matrices that rotate standard basis vectors, a X>Y>Z rotation matrix to rotate points is described below. To convert a rotation matrix from for standard basis vectors to for points, we need to transpose it.

R = (Z @ Y @ X).T = X.T @ Y.T @ Z.T

If we define X', Y' and Z' as the rotation matrices that rotate points, the above equation can be described as below.

R = (Z @ Y @ X).T = X.T @ Y.T @ Z.T = X' @ Y' @ Z'

@stefanv
Copy link
Member

stefanv commented Feb 7, 2023

I don't have the background knowledge to comment on correctness. @jni are you able to do one more round of review so we can get this in?

I wonder if one of our resident scipy rotation experts—@pmla, @nmayorov, @adbugger, or @evbernardes—can help out?

@ksugar
Copy link
Author

ksugar commented Feb 10, 2023

@evbernardes regarding the following point, what I thought was drawn in the attached figure.

I still don't see why the transposed/inverse rotations are used though, or what's the relationship of it with "rotations of points".

If we rotate the axes with a rotation matrix $R$, the coordinate of the point in the new space $(x', y', z')$ can be expressed with the coordinate in the original space $(x, y, z)$ as below? I am not quite sure if I understand it. I would be grateful if you could tell me if I have misunderstood something.

image

@evbernardes
Copy link

evbernardes commented Feb 10, 2023

@ksugar if understood correctly, it depends how you are defining the rotation matrix. A rotation matrix is just a representation of a rotation linear transformation, that could be define either from the inertial frame to the body frame, or from the body frame to the inertial frame.

I think by putting an inversion inside of the implementation could add some confusion, and I'd argue this is one of the reasons while just taking a matrix directly (or a Scipy Rotation instance) is a better solution.

Edit.: Of course, it depends if a certain standard is assumed for this application, but I can't comment on that since I'm not familiar with it!

@evbernardes
Copy link

I'm more used to defining the rotations from the body frame to the inertial frame.

This means that, for example, defining a normal vector to some body along the body z axis, it would always look like (0 , 0, 1) in the body frame, and applying the rotation to it would express how you'd see it in the inertial frame.

@evbernardes
Copy link

I forgot to mention: if the rotation matrix as the linear transformation that transforms one vector to the rotated version of the same vector, a unit vector that defines an axis is still just a vector, so there's nothing special about it, I think. And to rotate a point, depends also on how you define the point, but points can also be described by displacement vectors.

Hope that makes sense with your application!

ksugar and others added 7 commits April 5, 2023 13:51
draw3d.ellipsoid_coords is a  function analogous to draw.ellipse.
This function returns the voxel coordinates of an ellipsoid, while
the existing function draw3d.ellipsoid returns a 3d ndarray that
renders an ellipsoid.
based on the following review:
scikit-image#5263 (comment)

Co-authored-by: Juan Nunez-Iglesias <juan.nunez-iglesias@monash.edu>
- properly formulate rotations based on rotation_angles, rotation_order
- rotation_matrix support
- fix and add test cases
- remove rotation_matrix from arguments of ellipsoid_coords
- update to use skimage.transform._geometric._euler_rotation() to generate a rotation matrix
- update docstrings and tests
- use .T instead of np.linalg.inv to compute a inverse of a rotation matrix
- use R to represent a rotation matrix
This commit is a part of the last commit.
- update to use skimage.transform._geometric._euler_rotation() to generate a rotation matrix
- update docstrings
- use .T instead of np.linalg.inv to compute a inverse of a rotation matrix
- use R to represent a rotation matrix
@stefanv
Copy link
Member

stefanv commented Apr 5, 2023

@ksugar Are you happy with where this is at, given @evbernardes's comments above? I think we are ready to merge, and can get this into the 0.21 release.

- use scipy.spatial.transform.Rotation.from_eular to create a rotation matrix in draw3d.ellipsoid_coords()
@ksugar
Copy link
Author

ksugar commented Apr 6, 2023

@stefanv thank you for the updates and I'm glad to hear that this PR is going to be merged soon!
Based on the feedback from @evbernardes , I looked into scipy.spatial.transform.Rotation and found that it's suitable for draw3d.ellipsoid_coords. For the moment, I just replaced some lines by using scipy.spatial.transform.Rotation. Please have a look at the latest commit.
Supporting the full features of scipy.spatial.transform.Rotation will be one of the future tasks.
I'm happy with merging either at the latest commit or at the previous one.

@stefanv
Copy link
Member

stefanv commented Apr 6, 2023

This could make a nice example for the gallery, if you're up for it.

Meanwhile, I'll push a few touch-ups and get this merged.

@stefanv
Copy link
Member

stefanv commented Apr 6, 2023

Ran into some more questions; also @jni may have thoughts:

  • Is it correct to rescale the center with spacing?
  • Does it make sense to have spacing in this function?
  • Introducing axis names here is rather confusion; most of us understand NumPy axis conventions, but they're not tied to X, Y, and Z.
  • Is the axis argument necessary? Can't those angles be set to zero instead?
  • Can we simplify the code by operating on all axes in a loop, or by vectorizing the operation? See, e.g.:
    d_lim, r_lim, c_lim = np.ogrid[:float(bounding_shape[0]),
                                   :float(bounding_shape[1]),
                                   :float(bounding_shape[2])]
    d_org, r_org, c_org = scaled_center - upper_left_bottom
    d_rad, r_rad, c_rad = axis_lengths
    conversion_matrix = R.T @ np.diag(spacing)
    d, r, c = (d_lim - d_org), (r_lim - r_org), (c_lim - c_org)
    distances = (
        ((d * conversion_matrix[0, 0]
          + r * conversion_matrix[0, 1]
          + c * conversion_matrix[0, 2]) / d_rad) ** 2 +
        ((d * conversion_matrix[1, 0]
          + r * conversion_matrix[1, 1]
          + c * conversion_matrix[1, 2]) / r_rad) ** 2 +
        ((d * conversion_matrix[2, 0]
          + r * conversion_matrix[2, 1]
          + c * conversion_matrix[2, 2]) / c_rad) ** 2
    )

Ideally, we would not be repeating the code for three axes.

So, while we're close on this PR, I'm going to take this off the 0.21 milestone, and we can get it into 0.22. We're on an increased release cadence now, so shouldn't take too long.

@evbernardes
Copy link

Happy I was able to help! Here are some extra thoughts:

* Introducing axis names here is rather confusion; most of us understand NumPy axis conventions, but they're not tied to X, Y, and Z.

I think there are two advantages of using axis by their names x, y, z:

  1. There is no confusion between not knowing if its (0, 1, 2) or (1, 2, 3)
  2. It's the convention already used by SciPy.

If the focus on Scikits is to be extensions of SciPy, I'd argue that consistency with SciPy should be a priority over consistency with NumPy, don't you think?


# Generate a rotation matrix. The order of the elements needs to be
# reversed so that it follows the (z, y, x) order.
R = Rotation.from_euler(seq, angles).as_matrix()[::-1, ::-1]

Choose a reason for hiding this comment

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

What is this inversion doing exactly? And why is axes_str defined as ZYX instead of XYZ?
Are there two definitions just inverting one another?

Copy link
Member

Choose a reason for hiding this comment

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

Typically, axis 0 corresponds not to X but to Z. Think, e.g., of an image represented as an array: (M, N) means (Y, X), upside down, sort of. You see the confusion :)

Choose a reason for hiding this comment

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

I see, this is because of the application for images then!

:float(bounding_shape[2])]
d_org, r_org, c_org = scaled_center - upper_left_bottom
d_rad, r_rad, c_rad = axis_lengths
conversion_matrix = R.T @ np.diag(spacing)

Choose a reason for hiding this comment

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

And why is the inverse of the rotation used here?

Copy link
Member

Choose a reason for hiding this comment

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

Probably because of the above.

Copy link
Member

Choose a reason for hiding this comment

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

I woke up last night, realizing that I had lied to you. It gets more complicated: scikit-image, in a regrettable but somewhat rational early API decision, chose to adopt the XYZ coordinate system for transformations. So that's layered on top of everything. We'll fix that in v2.

Choose a reason for hiding this comment

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

I woke up last night, realizing that I had lied to you.

Good to know this doesn't only happen to me!

Choose a reason for hiding this comment

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

Here's an idea, for a future implementation.

What about a new scikit.Rotation class that inherits from scipy.spatial.transform.Rotation class, but only changes the apply method in order to invert which axes to use (XYZ or ZXY), and as_euler/from_euler to use your preferred standard of 0,1,2 instead of X,Y,Z?

This shouldn't be too time-consuming to implement with inheritance and would allow everyone to use whatever they prefer using (matrices, quaternions or angles).

f'len(axis_lengths) should be 3 but got {len(axis_lengths)}')
axis_lengths = np.array(axis_lengths)

if angles is None:

Choose a reason for hiding this comment

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

Btw, you don't have to use rotation matrices. You can just define the Rotation object directly and use Rotation.apply() which is a lot more efficient.

@stefanv
Copy link
Member

stefanv commented Apr 7, 2023

If the focus on Scikits is to be extensions of SciPy, I'd argue that consistency with SciPy should be a priority over consistency with NumPy, don't you think?

I think SciPy is making a mistake here. When working with arrays, we understand what axes 0, 1, and 2 are; but after two decades if doing that, if you ask me which ones represent X, Y, and Z, I wouldn't know. It depends on how you view your array?

@evbernardes
Copy link

If the focus on Scikits is to be extensions of SciPy, I'd argue that consistency with SciPy should be a priority over consistency with NumPy, don't you think?

I think SciPy is making a mistake here. When working with arrays, we understand what axes 0, 1, and 2 are; but after two decades if doing that, if you ask me which ones represent X, Y, and Z, I wouldn't know. It depends on how you view your array?

I see, personally I'd say it's best to have it consistently, since they are most likely being used together. But ofc, this is up to you

@evbernardes
Copy link

I still think it's best to use rotation matrices directly (or the rotation object) as an input though, since it introduces a possible very inefficient step of first decomposing the rotation into ZYX angles and then converting it to rotation matrices again

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

9 participants