diff --git a/pyproject.toml b/pyproject.toml index b54781a..471a1f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,9 @@ dynamic = ["version"] dependencies = [ "click >=8.1", "numpy >=1.26, <=1.26.4", + "itk", "dipy >=1.9", + "pandas", "HD_BET @ https://github.com/brain-microstructure-exploration-tools/HD-BET/archive/refs/tags/v1.0.0.zip#sha256=d48908854207b839552f2059c9cf2a48819b847bc1eb0ea4445d1d589471a1f5", ] diff --git a/src/abcdmicro/dwi.py b/src/abcdmicro/dwi.py new file mode 100644 index 0000000..4d5cb62 --- /dev/null +++ b/src/abcdmicro/dwi.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from abcdmicro.event import AbcdEvent +from abcdmicro.resource import BvalResource, BvecResource, VolumeResource + + +@dataclass +class Dwi: + """An ABCD diffusion weighted image.""" + + event: AbcdEvent + """The ABCD event associated with this DWI.""" + + volume: VolumeResource + """The DWI image volume.""" + + bval: BvalResource + """The DWI b-values""" + + bvec: BvecResource + """The DWI b-vectors""" diff --git a/src/abcdmicro/event.py b/src/abcdmicro/event.py new file mode 100644 index 0000000..8a84e17 --- /dev/null +++ b/src/abcdmicro/event.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar + +import pandas as pd + + +@dataclass +class AbcdEvent: + """An ABCD event -- a particular subject and time point from a particular ABCD data release.""" + + subject_id: str + """The subject GUID defined in the NIMH Data Archive, for example 'NDAR_INV00U4FTRU'""" + + eventname: str + """The ABCD Study event name, for example 'baseline_year_1_arm_1'""" + + image_download_path: Path + """Path to the ABCD image download root directory. This would be the directory that + contains `fmriresults01/abcd-mproc-release5/` with some compressed images in there""" + + tabular_data_path: Path + """Path to the extracted ABCD tabular data directory. This would contain subdirectories + like `core/mental-health/` with csv tables inside them.""" + + abcd_version: str + """Version of the ABCD dataset release, for example '5.1'.""" + + _tables: ClassVar[dict[str, dict[str, pd.DataFrame]]] = {} + """A mapping (ABCD version string) -> (relative table path) -> (loaded table)""" + + def get_table(self, table_relative_path: str) -> pd.DataFrame: + """Get a table, loading it from disk if it hasn't already been loaded. + + Args: + table_relative_path: The relative path of the table from the table root directory. + Example: 'core/mental-health/mh_p_pss.csv' + + Returns: The loaded table as a pandas DataFrame, + with subject ID and eventname as a multi-index. + """ + if self.abcd_version not in self._tables: + self._tables[self.abcd_version] = {} + path_to_table_mapping = self._tables[self.abcd_version] + if table_relative_path not in path_to_table_mapping: + table = pd.read_csv( + self.tabular_data_path / table_relative_path, + index_col=["src_subject_id", "eventname"], + ) + path_to_table_mapping[table_relative_path] = table + else: + table = path_to_table_mapping[table_relative_path] + return table diff --git a/src/abcdmicro/io.py b/src/abcdmicro/io.py new file mode 100644 index 0000000..e7898e4 --- /dev/null +++ b/src/abcdmicro/io.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import itk +import numpy as np +from dipy.io.gradients import read_bvals_bvecs +from numpy.typing import NDArray + +from abcdmicro.resource import ( + BvalResource, + BvecResource, + InMemoryBvalResource, + InMemoryBvecResource, + InMemoryVolumeResource, + VolumeResource, +) + + +@dataclass +class NiftiVolumeResrouce(VolumeResource): + """A volume or volume stack that is saved to disk in the nifti file format.""" + + path: Path + """Path to the underlying volume nifti file""" + + def load(self) -> InMemoryVolumeResource: + return InMemoryVolumeResource(itk.imread(self.path)) + + def get_array(self) -> NDArray[Any]: + return self.load().get_array() + + def get_metadata(self) -> dict[Any, Any]: + return self.load().get_metadata() + + +@dataclass +class FslBvalResource(BvalResource): + """A b-value list that is saved to disk in the FSL text file format.""" + + path: Path + """Path to the underlying bval txt file""" + + def load(self) -> InMemoryBvalResource: + bvals_array, _ = read_bvals_bvecs(self.path, None) + return InMemoryBvalResource(bvals_array) + + def get(self) -> NDArray[np.floating]: + return self.load().get() + + +@dataclass +class FslBvecResource(BvecResource): + """A b-vector list that is saved to disk in the FSL text file format.""" + + path: Path + """Path to the underlying bvec txt file""" + + def load(self) -> InMemoryBvecResource: + _, bvecs_array = read_bvals_bvecs(None, self.path) + return InMemoryBvecResource(bvecs_array) + + def get(self) -> NDArray[np.floating]: + return self.load().get() diff --git a/src/abcdmicro/resource.py b/src/abcdmicro/resource.py new file mode 100644 index 0000000..a6f8ab6 --- /dev/null +++ b/src/abcdmicro/resource.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + +import itk +import numpy as np +from numpy.typing import NDArray + + +class VolumeResource(ABC): + """Base class for resources representing a volume or volume stack. + An n-D array where n >= 3 and where three of the dimensions are spatial + and have associated header information describing a patient coordinate system.""" + + @abstractmethod + def get_array(self) -> NDArray[Any]: + """Get the underlying volume data array""" + + @abstractmethod + def get_metadata(self) -> dict[Any, Any]: + """Get the volume image metadata""" + + +@dataclass +class InMemoryVolumeResource(VolumeResource): + """A volume resource that is loaded into memory. + An n-D array where n >= 3 and where three of the dimensions are spatial + and have associated header information describing a patient coordinate system.""" + + image: itk.Image + """The underlying ITK image of the volume""" + + def get_array(self) -> NDArray[Any]: + return itk.array_view_from_image(self.image) + + def get_metadata(self) -> dict[Any, Any]: + return dict(self.image) + + +class BvalResource(ABC): + """Base class for resources representing a list of b-values associated with a 4D DWI + volume stack.""" + + @abstractmethod + def get(self) -> NDArray[np.floating]: + """Get the underlying array of b-values""" + + +@dataclass +class InMemoryBvalResource(BvalResource): + """A b-value list that is loaded into memory.""" + + array: NDArray[np.floating] + """The underlying array of b-values""" + + def get(self) -> NDArray[np.floating]: + return self.array + + +class BvecResource(ABC): + """Base class for resources representing a list of b-vectors associated with a 4D DWI + volume stack.""" + + @abstractmethod + def get(self) -> NDArray[np.floating]: + """Get the underlying array of b-vectors""" + + +@dataclass +class InMemoryBvecResource(BvecResource): + """A b-vector list that is loaded into memory.""" + + array: NDArray[np.floating] + """The underlying array of b-vectors""" + + def get(self) -> NDArray[np.floating]: + return self.array diff --git a/tests/test_event.py b/tests/test_event.py new file mode 100644 index 0000000..1c75066 --- /dev/null +++ b/tests/test_event.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + +from abcdmicro.event import AbcdEvent + + +def test_create_event(): + AbcdEvent( + subject_id="NDAR_INV00U4FTRU", + eventname="baseline_year_1_arm_1", + image_download_path=Path("/this/is/a/path/for/images"), + tabular_data_path=Path("/this/is/a/path/for/tables"), + abcd_version="5.1", + ) diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 0000000..1b8da0b --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import numpy as np +import pytest +from dipy.io.image import save_nifti + +from abcdmicro.io import NiftiVolumeResrouce + + +@pytest.fixture() +def bval_array(): + return np.array([500.0, 1000.0, 200.0]) + + +@pytest.fixture() +def bvec_array(): + return np.array( + [ + [ + 1.0, + 1.0, + 1.0, + ], + [2.0, 0.0, -4.0], + ] + ) + + +@pytest.fixture() +def volume_array(): + rng = np.random.default_rng(1337) + return rng.random(size=(3, 4, 5, 6), dtype=float) + + +@pytest.mark.filterwarnings("ignore:builtin type [sS]wig.* has no __module__ attribute") +def test_nifti_volume_resource(volume_array): + with tempfile.TemporaryDirectory() as tmpdir: + volume_file = Path(tmpdir) / "volume_file.nii" + save_nifti( + fname=volume_file, + data=volume_array, + affine=np.array( + [ + [1.35, 0, 0, 0], + [0, 1.45, 0, 0], + [0, 0, 1.55, 0], + [0, 0, 0, 1], + ] + ), + ) + volume_resource = NiftiVolumeResrouce(path=volume_file) + assert np.allclose(volume_resource.get_array(), volume_array) diff --git a/tests/test_resource.py b/tests/test_resource.py new file mode 100644 index 0000000..ed3c98c --- /dev/null +++ b/tests/test_resource.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import itk +import numpy as np +import pytest + +from abcdmicro.resource import ( + BvalResource, + BvecResource, + InMemoryBvalResource, + InMemoryBvecResource, + InMemoryVolumeResource, + VolumeResource, +) + + +def test_bval_abstractness(): + with pytest.raises(TypeError): + BvalResource() # type: ignore[abstract] + + +def test_bvec_abstractness(): + with pytest.raises(TypeError): + BvecResource() # type: ignore[abstract] + + +def test_volume_abstractness(): + with pytest.raises(TypeError): + VolumeResource() # type: ignore[abstract] + + +@pytest.fixture() +def bval_array(): + return np.array([500.0, 1000.0, 200.0]) + + +@pytest.fixture() +def bvec_array(): + return np.array( + [ + [ + 1.0, + 1.0, + 1.0, + ], + [2.0, 0.0, -4.0], + ] + ) + + +@pytest.fixture() +def volume_array(): + rng = np.random.default_rng(1337) + return rng.random(size=(3, 4, 5, 6), dtype=float) + + +def test_bval_inmemory_get(bval_array): + bval = InMemoryBvalResource(array=bval_array) + assert (bval.get() == bval_array).all() + + +def test_bvec_inmemory_get(bvec_array): + bvec = InMemoryBvecResource(array=bvec_array) + assert (bvec.get() == bvec_array).all() + + +@pytest.mark.filterwarnings("ignore:builtin type [sS]wig.* has no __module__ attribute") +def test_volume_inmemory_get_array(volume_array): + vol = InMemoryVolumeResource(image=itk.image_from_array(volume_array)) + assert (vol.get_array() == volume_array).all() + + +def test_volume_inmemory_get_metadata(volume_array): + image = itk.image_from_array(volume_array) + image["bleh"] = "some_info" + vol = InMemoryVolumeResource(image=image) + assert vol.get_metadata()["bleh"] == "some_info"