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

Convert between LPS/RAS coordinate systems #32

Merged
merged 4 commits into from
Sep 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion afids_utils/afids.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,25 @@ def _validate_afids(

@attrs.define
class AfidVoxel:
"""Class for Afid voxel position"""
"""Class for Afid voxel position

Parameters
----------
label
Unique label for AFID

i
Spatial position along i-axis

j
Spatial position along j-axis

k
Spatial position along k-axis

desc
Description for AFID (e.g. AC, PC)
"""

label: int = attrs.field()
i: int = attrs.field()
Expand Down
1 change: 0 additions & 1 deletion afids_utils/tests/test_afids.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@ class TestAfidsIO:
)
def test_valid_load(
self,
human_mappings: list[dict[str, str]],
valid_fcsv_file: PathLike[str],
label: int,
):
Expand Down
65 changes: 55 additions & 10 deletions afids_utils/tests/test_transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
from numpy.typing import NDArray

import afids_utils.tests.strategies as af_st
from afids_utils.afids import AfidPosition, AfidVoxel
from afids_utils.transforms import voxel_to_world, world_to_voxel
import afids_utils.transforms as af_xfm
from afids_utils.afids import AfidPosition, AfidSet, AfidVoxel


class TestAfidWorld2Voxel:
Expand All @@ -21,7 +21,7 @@ class TestAfidWorld2Voxel:
def test_world_to_voxel_xfm(
self, afid_position: AfidPosition, nii_affine: NDArray[np.float_]
):
afid_voxel = world_to_voxel(afid_position, nii_affine)
afid_voxel = af_xfm.world_to_voxel(afid_position, nii_affine)

assert isinstance(afid_voxel, AfidVoxel)
# Have to assert specific int dtype
Expand All @@ -38,7 +38,7 @@ def test_invalid_world_type(
self, afid_voxel: AfidVoxel, nii_affine: NDArray[np.float_]
):
with pytest.raises(TypeError, match="Not an AfidPosition.*"):
world_to_voxel(afid_voxel, nii_affine)
af_xfm.world_to_voxel(afid_voxel, nii_affine)


class TestAfidVoxel2World:
Expand All @@ -50,7 +50,7 @@ class TestAfidVoxel2World:
def test_voxel_to_world_xfm(
self, afid_voxel: AfidVoxel, nii_affine: NDArray[np.float_]
):
afid_position = voxel_to_world(afid_voxel, nii_affine)
afid_position = af_xfm.voxel_to_world(afid_voxel, nii_affine)

assert isinstance(afid_position, AfidPosition)
# Have to assert specific float dtype
Expand All @@ -69,7 +69,7 @@ def test_invalid_voxel_type(
self, afid_position: AfidPosition, nii_affine: NDArray[np.float_]
):
with pytest.raises(TypeError, match="Not an AfidVoxel.*"):
voxel_to_world(afid_position, nii_affine)
af_xfm.voxel_to_world(afid_position, nii_affine)


class TestAfidRoundTripConvert:
Expand All @@ -83,8 +83,8 @@ class TestAfidRoundTripConvert:
def test_round_trip_world(
self, afid_position: AfidPosition, nii_affine: NDArray[np.float_]
):
afid_voxel = world_to_voxel(afid_position, nii_affine)
afid_position_approx = voxel_to_world(afid_voxel, nii_affine)
afid_voxel = af_xfm.world_to_voxel(afid_position, nii_affine)
afid_position_approx = af_xfm.voxel_to_world(afid_voxel, nii_affine)
print(afid_position, afid_position_approx, nii_affine)

# Check to see if round-trip approximates to within 10mm
Expand All @@ -101,10 +101,55 @@ def test_round_trip_world(
def test_round_trip_voxel(
self, afid_voxel: AfidVoxel, nii_affine: NDArray[np.float_]
):
afid_world = voxel_to_world(afid_voxel, nii_affine)
afid_voxel_approx = world_to_voxel(afid_world, nii_affine)
afid_world = af_xfm.voxel_to_world(afid_voxel, nii_affine)
afid_voxel_approx = af_xfm.world_to_voxel(afid_world, nii_affine)

# Check to see if round-trip approximates to within 2 voxels
assert afid_voxel_approx.i == pytest.approx(afid_voxel.i, abs=2)
assert afid_voxel_approx.j == pytest.approx(afid_voxel.j, abs=2)
assert afid_voxel_approx.k == pytest.approx(afid_voxel.k, abs=2)


class TestXfmCoordSystem:
@given(afid_set=af_st.afid_sets())
@settings(
deadline=400,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_invalid_new_coord_system(self, afid_set: AfidSet):
with pytest.raises(
ValueError, match=r"Unrecognized coordinate system.*"
):
af_xfm.xfm_coord_system(afid_set, new_coord_system="invalid")

@given(afid_set=af_st.afid_sets(randomize_header=False))
@settings(
deadline=400,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_same_coord_system(self, afid_set: AfidSet):
assert afid_set == af_xfm.xfm_coord_system(
afid_set, new_coord_system="LPS"
)

@given(afid_set=af_st.afid_sets(randomize_header=False))
@settings(
deadline=400,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_valid_new_coord_system(self, afid_set):
new_afid_set = af_xfm.xfm_coord_system(afid_set)

assert isinstance(new_afid_set, AfidSet)
assert new_afid_set.coord_system == "RAS"

for old_afid, new_afid in zip(afid_set.afids, new_afid_set.afids):
assert (
new_afid.x,
new_afid.y,
new_afid.z,
) == (
-old_afid.x,
-old_afid.y,
old_afid.z,
)
49 changes: 44 additions & 5 deletions afids_utils/transforms.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
"""Methods for transforming between different coordinate systems"""
from __future__ import annotations

import attrs
import numpy as np
from numpy.typing import NDArray

from afids_utils.afids import AfidPosition, AfidVoxel
from afids_utils.afids import AfidPosition, AfidSet, AfidVoxel


def world_to_voxel(
afid_world: AfidPosition,
nii_affine: NDArray[np.float_],
) -> AfidVoxel:
"""
Transform fiducials from world coordinates to voxel coordinates
"""Transform fiducials from world coordinates to voxel coordinates

Parameters
----------
Expand Down Expand Up @@ -54,8 +54,7 @@ def voxel_to_world(
afid_voxel: AfidVoxel,
nii_affine: NDArray[np.float_],
) -> AfidPosition:
"""
Transform fiducials from world coordinates to voxel coordinates
"""Transform fiducials from world coordinates to voxel coordinates

Parameters
----------
Expand Down Expand Up @@ -88,3 +87,43 @@ def voxel_to_world(
z=world_pos[2],
desc=afid_voxel.desc,
)


def xfm_coord_system(
afid_set: AfidSet, new_coord_system: str = "RAS"
) -> AfidSet:
"""Convert AFID set between LPS and RAS coordinates

Parameters
----------
afid_set
Object containing valid AfidSet

new_coord_system
Convert AFID set to defined coordinate system (default: 'RAS')

Returns
-------
AfidSet
Object containing AFIDs stored in defined coordinate system

Raises
------
ValueError
If invalid coordinate system
"""
if new_coord_system not in ["RAS", "LPS"]:
raise ValueError(
"Unrecognized coordinate system - please select RAS or LPS"
)

if afid_set.coord_system == new_coord_system:
kaitj marked this conversation as resolved.
Show resolved Hide resolved
return afid_set

# Create copy and update AFIDs for new coordinate system
new_afids = [
attrs.evolve(afid, x=-afid.x, y=-afid.y) for afid in afid_set.afids
]
return attrs.evolve(
afid_set, coord_system=new_coord_system, afids=new_afids
)
5 changes: 5 additions & 0 deletions docs/api/afids.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
.. autoclass:: afids_utils.afids.AfidPosition
:members:
:exclude-members: label, desc, x, y, z
```

```{eval-rst}
.. autoclass:: afids_utils.afids.AfidVoxel
:members:
:exclude-members: label, desc, i, j, k
```

```{eval-rst}
Expand Down