Skip to content

Improve documentation of orientations module #1418

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

Open
dkuegler opened this issue May 20, 2025 · 2 comments
Open

Improve documentation of orientations module #1418

dkuegler opened this issue May 20, 2025 · 2 comments

Comments

@dkuegler
Copy link

I believe there is a bug in nibabel.orientations.ornt_transform.

In specific it seems to me, that all other functions follow the format flip first then reorder as can best be seen in lia below.

Some examples

from nibabel.orientations import *
import numpy as np
ras_affine = np.eye(4)
ras_ornt = io_orientation(ras_affine).astype(int)
print("RAS:", ras_ornt.tolist())

RAS: [[0, 1], [1, 1], [2, 1]]

lia_ornt = axcodes2ornt("LIA").astype(int)
print("LIA:", lia_ornt.tolist())

LIA: [[0, -1], [2, -1], [1, 1]]

ras2lia = ornt_transform(ras_ornt, lia_ornt).astype(int)
print("RAS2LIA:", ras2lia.tolist())

RAS2LIA: [[0, -1], [2, 1], [1, -1]]

But it seems to me ornt_transform and also its test (explicitly test 2 in nibabel.test.test_orientations.test_ornt_transform in

assert_array_equal(
).

ras2lia should 1+2. flip the first and second axes, and 3. reoder swapping 2nd and 3rd axes. Instead ras2lia flips 1 and 3 and swaps 2 and 3.

Furthermore, I do not understand, why ras2lia is different than just lia. That seems wrong as ras is besically the Null transform, it does not do anything.

Similar observations for ras2asl

asl_ornt = axcodes2ornt("ASL").astype(int)
print("ASL:", asl_ornt.tolist())

ASL: [[1, 1], [2, 1], [0, -1]]

ras2asl = ornt_transform(lia_ornt, asl_ornt).astype(int)
print("RAS2ASL:", ras2asl.tolist())

RAS2ASL: [[2, 1], [1, -1], [0, 1]]

Finally, if we just concatenate ras2lia and lia2ras, we should be back in ras (noop).

ras2ras = ornt_transform(ras_ornt, ras_ornt).astype(int)
print("RAS2RAS:", ras2ras.tolist())

RAS2RAS: [[0, 1], [1, 1], [2, 1]]

This is correct

print("RAS2RAS via LIA:", ornt_transform(
    ornt_transform(ras_ornt, lia_ornt), 
    ornt_transform(lia_ornt, ras_ornt)).astype(int).tolist())

RAS2RAS via LIA: [[0, 1], [1, -1], [2, -1]]

This is not correct

@effigies
Copy link
Member

Orientations are always a bit backwards of how I want to think of them, and I think you're probably having that happen to you as well. The orientation describes a transform from data array indices onto world coordinates, which are conventionally RAS+. When you load an LIA matrix, the orientation tells you how to map from LIA to RAS, not RAS to LIA.

In [1]: from nibabel.orientations import *

In [2]: import numpy as np

In [3]: ras = io_orientation(np.eye(4))

In [4]: lia = io_orientation([[-1, 0, 0, 0], [0, 0, 1, 0], [0, -1, 0, 0], [0, 0, 0, 1]])

In [5]: ras
Out[5]: 
array([[0., 1.],
       [1., 1.],
       [2., 1.]])

In [6]: lia
Out[6]: 
array([[ 0., -1.],
       [ 2., -1.],
       [ 1.,  1.]])

In [7]: ornt_transform(ras, lia)
Out[7]: 
array([[ 0., -1.],
       [ 2.,  1.],
       [ 1., -1.]])

In [8]: ornt_transform(lia, ras)
Out[8]: 
array([[ 0., -1.],
       [ 2., -1.],
       [ 1.,  1.]])

The general use of orientations is to reorient images to RAS:

img = nb.load(...)
orig_to_ras = nb.orientations.io_orientation(img.affine)
ras_img = img.as_reoriented(orig_to_ras)

Here, we don't really care what the original orientation was (though we can inspect it with ornt2axcodes()), it just always takes us to RAS.

If I want to target a different orientation:

lpi_to_ras = nb.orientations.axcodes2ornt('LPI")
orig_to_lpi = nb.orientations.ornt_transform(orig_to_ras, lpi_to_ras)
lpi_img = img.as_reoriented(orig_to_lpi)

We definitely could have written this as the inverse, mapping from RAS to the data axes. I'm not sure if there are algorithmic advantages to doing it the way we have, or this direction just fit @matthew-brett's brain when he wrote it. In any case, I don't think they were ever really meant to be worked with directly, so the choice seems arbitrary.

@dkuegler
Copy link
Author

Thank you! this comment has helped me better understand how orientations work. I would heavily recommend especially the documentation to ornt_transform to be updated to maybe include some of this information.

I have implemented a inv_ornt function in my project now (see below) that can help achieve the effects that I want.

def inv_ornt(ornt: npt.NDArray[int]) -> npt.NDArray[int]:
    """
    Invert the nibabel ornt-transform.

    Parameters
    ----------
    ornt : (n,2) numpy array
        The transform describing reordering and flipping operations.

    Returns
    -------
    ornt : (n,2) numpy array
        The inverse transform describing reordering and flipping operations.

    See Also
    --------
    nibabel.orientations.inv_ornt_aff, nibabel.orientations.io_orientation
        The `inv_ornt` function is equivalent to `io_orientation(inv_ornt_aff(ornt, (any,) * ornt.shape[0]))`.
    """
    result = np.empty_like(ornt)
    result[:, 0] = np.argsort(ornt[:, 0])
    result[:, 1] = ornt[ornt[:, 0].astype(int), 1]
    return result

With ornt_transform(ornt_of_img1, inv_ornt(ornt_of_img2)) -- this solves my issues in these cases and I can use the numbers in the resulting ornt to achieve what I want.

@effigies effigies changed the title Bug in nibabel.orientations.ornt_transform Improve documentation of orientations module May 27, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants