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 metric computations #35

Merged
merged 14 commits into from
Oct 6, 2023
85 changes: 47 additions & 38 deletions .test_durations
Original file line number Diff line number Diff line change
@@ -1,40 +1,49 @@
{
"afids_utils/tests/test_afids.py::TestAfidPosition::test_invalid_label": 1.5986094260006212,
"afids_utils/tests/test_afids.py::TestAfidPosition::test_mismatched_desc": 1.5739799150032923,
"afids_utils/tests/test_afids.py::TestAfidPosition::test_valid_position": 1.1084958290011855,
"afids_utils/tests/test_afids.py::TestAfidSet::test_incomplete_afid_set": 4.651805094999872,
"afids_utils/tests/test_afids.py::TestAfidSet::test_repeated_afid_set": 1.3796272199979285,
"afids_utils/tests/test_afids.py::TestAfidSet::test_repeated_incomplete_afid_set": 6.080547227997158,
"afids_utils/tests/test_afids.py::TestAfidSet::test_valid_afid_set": 51.511764702005166,
"afids_utils/tests/test_afids.py::TestAfidsCore::test_invalid_get_afid": 0.7857620539980417,
"afids_utils/tests/test_afids.py::TestAfidsCore::test_valid_get_afid": 0.1838964219969057,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_invalid_desc": 1.3152757729985751,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_invalid_ext": 0.661583460001566,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_invalid_ext_save": 0.8456456480016641,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_invalid_fpath": 0.008225836001656717,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_invalid_label_range": 0.009448067998164333,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_save_invalid_coord_system": 50.10975799600055,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_update_coord_system": 33.518917009998404,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_valid_load": 0.26611604800564237,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_valid_save": 0.029876015996705974,
"afids_utils/tests/test_ext.py::TestLoadFcsv::test_fcsv_invalid_header": 0.008544232998247026,
"afids_utils/tests/test_ext.py::TestLoadFcsv::test_get_valid_metadata": 0.0205319340020651,
"afids_utils/tests/test_ext.py::TestLoadFcsv::test_invalid_num_coord": 0.5626236889984284,
"afids_utils/tests/test_ext.py::TestLoadFcsv::test_invalid_str_coord": 0.5904255919958814,
"afids_utils/tests/test_ext.py::TestLoadFcsv::test_valid_get_afids": 0.10215866700309562,
"afids_utils/tests/test_ext.py::TestLoadJson::test_json_get_valid_metadata": 0.08162234699557303,
"afids_utils/tests/test_ext.py::TestLoadJson::test_json_invalid_num_coord": 1.443131824999,
"afids_utils/tests/test_ext.py::TestLoadJson::test_json_invalid_str_coord": 2.202139752000221,
"afids_utils/tests/test_ext.py::TestLoadJson::test_json_valid_get_afids": 0.12152767200313974,
"afids_utils/tests/test_ext.py::TestSaveFcsv::test_save_fcsv_invalid_template": 54.09031262999997,
"afids_utils/tests/test_ext.py::TestSaveFcsv::test_save_fcsv_valid_template": 32.03766425299909,
"afids_utils/tests/test_ext.py::TestSaveJson::test_save_json_invalid_template": 51.262277298999834,
"afids_utils/tests/test_ext.py::TestSaveJson::test_save_json_valid_template": 53.486199479997595,
"afids_utils/tests/test_transforms.py::TestAfidRoundTripConvert::test_round_trip_voxel": 2.1786141890006547,
"afids_utils/tests/test_transforms.py::TestAfidRoundTripConvert::test_round_trip_world": 2.380555871000979,
"afids_utils/tests/test_transforms.py::TestAfidVoxel2World::test_voxel_to_world_xfm": 2.0250180780021765,
"afids_utils/tests/test_transforms.py::TestAfidWorld2Voxel::test_world_to_voxel_xfm": 2.554042482999648,
"afids_utils/tests/test_transforms.py::TestXfmCoordSystem::test_invalid_new_coord_system": 49.37416817799749,
"afids_utils/tests/test_transforms.py::TestXfmCoordSystem::test_same_coord_system": 51.746356387997366,
"afids_utils/tests/test_transforms.py::TestXfmCoordSystem::test_valid_new_coord_system": 32.088717203998385
"afids_utils/tests/test_afids.py::TestAfidPosition::test_invalid_label": 1.082949646995985,
"afids_utils/tests/test_afids.py::TestAfidPosition::test_mismatched_desc": 1.5433632999993279,
"afids_utils/tests/test_afids.py::TestAfidPosition::test_valid_position": 0.9362819089983532,
"afids_utils/tests/test_afids.py::TestAfidSet::test_incomplete_afid_set": 4.048548017995927,
"afids_utils/tests/test_afids.py::TestAfidSet::test_repeated_afid_set": 1.4251556620001793,
"afids_utils/tests/test_afids.py::TestAfidSet::test_repeated_incomplete_afid_set": 8.614593324000452,
"afids_utils/tests/test_afids.py::TestAfidSet::test_valid_afid_set": 85.18883144600113,
"afids_utils/tests/test_afids.py::TestAfidsCore::test_invalid_get_afid": 0.8760242210009892,
"afids_utils/tests/test_afids.py::TestAfidsCore::test_valid_get_afid": 0.3255669280042639,
"afids_utils/tests/test_afids.py::TestAfidsDistance::test_diff_labels": 2.706472030000441,
"afids_utils/tests/test_afids.py::TestAfidsDistance::test_get_invalid_component": 1.1454269339992607,
"afids_utils/tests/test_afids.py::TestAfidsDistance::test_get_valid_component": 1.9680292630000622,
"afids_utils/tests/test_afids.py::TestAfidsDistance::test_same_labels": 1.2918747140029154,
"afids_utils/tests/test_afids.py::TestAfidsDistanceSet::test_mismatched_coords": 1.4601025899974047,
"afids_utils/tests/test_afids.py::TestAfidsDistanceSet::test_valid_afid_set": 1.572549672000605,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_invalid_desc": 1.3358541470006458,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_invalid_ext": 0.9598912920009752,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_invalid_ext_save": 1.3974526370002422,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_invalid_fpath": 0.0026423479976074304,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_invalid_label_range": 0.007629069001268363,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_save_invalid_coord_system": 86.44556413199825,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_update_coord_system": 51.975112327003444,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_valid_load": 0.4179820609970193,
"afids_utils/tests/test_afids.py::TestAfidsIO::test_valid_save": 0.03539930400074809,
"afids_utils/tests/test_ext.py::TestLoadFcsv::test_fcsv_invalid_header": 0.013324665997060947,
"afids_utils/tests/test_ext.py::TestLoadFcsv::test_get_valid_metadata": 0.0688332290010294,
"afids_utils/tests/test_ext.py::TestLoadFcsv::test_invalid_num_coord": 0.7997038069988776,
"afids_utils/tests/test_ext.py::TestLoadFcsv::test_invalid_str_coord": 0.6864443630038295,
"afids_utils/tests/test_ext.py::TestLoadFcsv::test_valid_get_afids": 0.14701206499739783,
"afids_utils/tests/test_ext.py::TestLoadJson::test_json_get_valid_metadata": 0.15163621600004262,
"afids_utils/tests/test_ext.py::TestLoadJson::test_json_invalid_num_coord": 1.447392177000438,
"afids_utils/tests/test_ext.py::TestLoadJson::test_json_invalid_str_coord": 1.4554196120006964,
"afids_utils/tests/test_ext.py::TestLoadJson::test_json_valid_get_afids": 0.16255972699946142,
"afids_utils/tests/test_ext.py::TestSaveFcsv::test_save_fcsv_invalid_template": 84.34551077300057,
"afids_utils/tests/test_ext.py::TestSaveFcsv::test_save_fcsv_valid_template": 75.04615621900302,
"afids_utils/tests/test_ext.py::TestSaveJson::test_save_json_invalid_template": 49.527494823996676,
"afids_utils/tests/test_ext.py::TestSaveJson::test_save_json_valid_template": 87.55355340100141,
"afids_utils/tests/test_metrics.py::TestMeanAfidSet::test_mismatched_coords": 85.333282055999,
"afids_utils/tests/test_metrics.py::TestMeanAfidSet::test_valid_afid_sets": 47.018927186996734,
"afids_utils/tests/test_metrics.py::TestMeanDistances::test_valid_afid_sets": 1.519686671999807,
"afids_utils/tests/test_transforms.py::TestAfidRoundTripConvert::test_round_trip_voxel": 4.898585754999658,
"afids_utils/tests/test_transforms.py::TestAfidRoundTripConvert::test_round_trip_world": 5.104490708003141,
"afids_utils/tests/test_transforms.py::TestAfidVoxel2World::test_voxel_to_world_xfm": 3.091197613000986,
"afids_utils/tests/test_transforms.py::TestAfidWorld2Voxel::test_world_to_voxel_xfm": 4.492286779001006,
"afids_utils/tests/test_transforms.py::TestXfmCoordSystem::test_invalid_new_coord_system": 78.8182095359989,
"afids_utils/tests/test_transforms.py::TestXfmCoordSystem::test_same_coord_system": 81.89801163200173,
"afids_utils/tests/test_transforms.py::TestXfmCoordSystem::test_valid_new_coord_system": 47.28752830599842
}
103 changes: 102 additions & 1 deletion afids_utils/afids.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import json
import warnings
from collections.abc import Iterable
from importlib import resources
from os import PathLike
Expand Down Expand Up @@ -259,7 +260,7 @@

Returns
-------
afid_position
AfidPosition
Spatial position of Afid (as class AfidPosition)

Raises
Expand All @@ -273,3 +274,103 @@
raise InvalidFiducialError(f"AFID label {label} is not valid")

return self.afids[label - 1]


@attrs.define()
class AfidDistance:
"""Class to store distances between two ``AfidPosition`` objects

Parameters
----------
afid_position1
An AfidPosition object containing floating-point spatial coordinates
(x, y, z)

afid_position2
Other AfidPosition object containing floating-point spatial
coordinates (x, y, z) to compute distance against
"""

afid_position1: AfidPosition = attrs.field()
afid_position2: AfidPosition = attrs.field()

def __attrs_post_init__(self):
# Always throw warning if label/desc don't match between AFIDs
if (self.afid_position1.label, self.afid_position1.desc) != (

Check warning on line 299 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L299

Added line #L299 was not covered by tests
self.afid_position2.label,
self.afid_position2.desc,
):
warnings.simplefilter("always", category=UserWarning)
warnings.warn(

Check warning on line 304 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L303-L304

Added lines #L303 - L304 were not covered by tests
"Computing distances between non-corresponding AFIDs"
)

@property
def x(self):
"""Floating-point distance between AFIDs along x-axis"""
return self.afid_position1.x - self.afid_position2.x

Check warning on line 311 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L311

Added line #L311 was not covered by tests

@property
def y(self):
"""Floating-point distance between AFIDs along y-axis"""
return self.afid_position1.y - self.afid_position2.y

Check warning on line 316 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L316

Added line #L316 was not covered by tests

@property
def z(self):
"""Floating-point distance between AFIDs along z-axis"""
return self.afid_position1.z - self.afid_position2.z

Check warning on line 321 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L321

Added line #L321 was not covered by tests

@property
def distance(self):
"""Floating-point distance between a pair of AFIDs"""
return (self.x**2 + self.y**2 + self.z**2) ** 0.5

Check warning on line 326 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L326

Added line #L326 was not covered by tests

def get(self, component: str):
"""Return value of specified component"""
valid_components = ["x", "y", "z", "distance"]

Check warning on line 330 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L330

Added line #L330 was not covered by tests

if component in valid_components:
return getattr(self, component)

Check warning on line 333 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L332-L333

Added lines #L332 - L333 were not covered by tests
else:
raise ValueError(f"Invalid component '{component}'")

Check warning on line 335 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L335

Added line #L335 was not covered by tests


@attrs.define()
class AfidDistanceSet:
"""Class to store distances between a pair of valid ``AfidSet`` objects

Parameters
----------
afid_set1
One set of anatomical fiducials containing coordinates and metadata

afid_set2
Another set of anatomical fiducials containing coordinates and metadata
"""

afid_set1: AfidSet = attrs.field()
afid_set2: AfidSet = attrs.field()

@property
def afids(self):
"""List of distances of corresponding AFIDs between the two ``AfidSet``
objects

Raises
------
ValueError
If coordinate systems are mismatched between ``AfidSet`` objects
"""
# Check if the coordinate systems match
if self.afid_set1.coord_system != self.afid_set2.coord_system:
raise ValueError("Mismatched coordinate systems")

Check warning on line 366 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L365-L366

Added lines #L365 - L366 were not covered by tests

# Compute distances between AfidSets
distances = [

Check warning on line 369 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L369

Added line #L369 was not covered by tests
AfidDistance(afid_set1_position, afid_set2_position)
for afid_set1_position, afid_set2_position in zip(
self.afid_set1.afids, self.afid_set2.afids
)
]

return distances

Check warning on line 376 in afids_utils/afids.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/afids.py#L376

Added line #L376 was not covered by tests
107 changes: 107 additions & 0 deletions afids_utils/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Methods for computing various metrics pertaining to AFIDs"""
from __future__ import annotations

import statistics as stats

from afids_utils.afids import AfidDistanceSet, AfidPosition, AfidSet


def mean_afid_sets(afid_sets: list[AfidSet]) -> AfidSet:
"""Calculate the average spatial coordinates for corresponding AFIDs
within a list of ``AfidSet`` objects.

Parameters
----------
afid_sets
List of ``AfidSet`` to compute mean from

Returns
-------
AfidSet
Object containing mean spatial components for each AFID

Raises
------
ValueError
If input list does not consist of all ``AfidSet`` objects or if there
are different coordinate systems in provided list of ``AfidSet``
objects
"""
# Check if coordinate systems are all the same
if not all(
afid_set.coord_system == afid_sets[0].coord_system
for afid_set in afid_sets
):
raise ValueError(
"Mismatched coordinate system in provided list of AfidSet"
)

mean_afid_set = AfidSet(

Check warning on line 39 in afids_utils/metrics.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/metrics.py#L39

Added line #L39 was not covered by tests
slicer_version="Unknown",
coord_system=afid_sets[0].coord_system,
afids=[
AfidPosition(
label=afid.label,
x=stats.mean(
[afid_set.afids[idx].x for afid_set in afid_sets]
),
y=stats.mean(
[afid_set.afids[idx].y for afid_set in afid_sets]
),
z=stats.mean(
[afid_set.afids[idx].z for afid_set in afid_sets]
),
desc=afid.desc,
)
for idx, afid in enumerate(afid_sets[0].afids)
],
)

return mean_afid_set

Check warning on line 60 in afids_utils/metrics.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/metrics.py#L60

Added line #L60 was not covered by tests


def mean_distances(
kaitj marked this conversation as resolved.
Show resolved Hide resolved
afid_sets: list[AfidSet],
template_afid_set: AfidSet,
component: str = "distance",
) -> list[float]:
"""Calculate the average distance for a given spatial component betweeen
a collection of ``AfidSet`` objects and a common / template ``AfidSet``.

Parameters
----------
afid_sets
List of ``AfidSet`` objects to compute distances with

template_afid_set
Template / common ``AfidSet`` to compute distances against

component
Spatial component to compute - if "distance" will compute Euclidean
distance (default: "distance")

Returns
-------
list[float]
List of average distances along each spatial component and Euclidean
distance

"""
# Compute distances
afid_distance_sets = [

Check warning on line 91 in afids_utils/metrics.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/metrics.py#L91

Added line #L91 was not covered by tests
AfidDistanceSet(afid_set1=afid_set, afid_set2=template_afid_set)
for afid_set in afid_sets
]

# Compute mean distance for each AFID
mean_component = [

Check warning on line 97 in afids_utils/metrics.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/metrics.py#L97

Added line #L97 was not covered by tests
stats.mean(
[
afid_distance_set.afids[idx].get(component)
for afid_distance_set in afid_distance_sets
]
)
for idx in range(len(afid_distance_sets[0].afids))
]

return mean_component

Check warning on line 107 in afids_utils/metrics.py

View check run for this annotation

Codecov / codecov/patch

afids_utils/metrics.py#L107

Added line #L107 was not covered by tests
2 changes: 1 addition & 1 deletion afids_utils/tests/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def afid_sets(
randomize_header: bool = True,
) -> AfidSet:
slicer_version = draw(st.from_regex(r"\d+\.\d+"))
coord_system = draw(st.sampled_from(["RAS", "LPS", "0", "1"]))
coord_system = draw(st.sampled_from(["RAS", "LPS"]))

# Set (in)valid number of Afid coordinates in a list
afid_pos: list[AfidPosition] = []
Expand Down
Loading