diff --git a/afids_utils/afids.py b/afids_utils/afids.py index 7fac0dc8..f53a372a 100644 --- a/afids_utils/afids.py +++ b/afids_utils/afids.py @@ -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() diff --git a/afids_utils/tests/test_afids.py b/afids_utils/tests/test_afids.py index 01c974da..926dfdbb 100644 --- a/afids_utils/tests/test_afids.py +++ b/afids_utils/tests/test_afids.py @@ -164,7 +164,6 @@ class TestAfidsIO: ) def test_valid_load( self, - human_mappings: list[dict[str, str]], valid_fcsv_file: PathLike[str], label: int, ): diff --git a/afids_utils/tests/test_transforms.py b/afids_utils/tests/test_transforms.py index 855d6466..b0df0960 100644 --- a/afids_utils/tests/test_transforms.py +++ b/afids_utils/tests/test_transforms.py @@ -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: @@ -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 @@ -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: @@ -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 @@ -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: @@ -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 @@ -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, + ) diff --git a/afids_utils/transforms.py b/afids_utils/transforms.py index bd2d5918..98327275 100644 --- a/afids_utils/transforms.py +++ b/afids_utils/transforms.py @@ -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 ---------- @@ -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 ---------- @@ -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: + 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 + ) diff --git a/docs/api/afids.md b/docs/api/afids.md index 6f0dfc90..7b3a66e9 100644 --- a/docs/api/afids.md +++ b/docs/api/afids.md @@ -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}