Skip to content

Commit c4e8b97

Browse files
committed
revamp mni module
1 parent bfcda69 commit c4e8b97

35 files changed

+283
-189
lines changed

docs/api/prefs.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
**Preferences**
44

5-
This module can be used to adjust the default MNI template settings that are used internally by all `Brain_Data` operations. By default all operations are performed in **MNI152 2mm space**. Thus any files loaded with be resampled to this space by default. You can control this on a per-file loading basis using the `mask` argument of `Brain_Data`, e.g.
5+
This module can be used to adjust the default MNI template settings that are used internally by all `Brain_Data` operations. For historic reasons, the default MNI template is in the same **[MNI152 2mm space as FSL](https://nist.mni.mcgill.ca/mni-icbm152-non-linear-6th-generation-symmetric-average-brain-stereotaxic-registration-model/)**. Different software use [different versions](https://nist.mni.mcgill.ca/icbm-152-nonlinear-atlases-2009/) `nltools` supports the following additional verisons:
6+
7+
- [`nilearn` MNI152 2009a](https://nilearn.github.io/stable/modules/generated/nilearn.datasets.fetch_icbm152_2009.html#nilearn.datasets.fetch_icbm152_2009)
8+
- [`fmriprep` MNI52 2009c](https://nilearn.github.io/stable/modules/generated/nilearn.datasets.fetch_icbm152_2009.html#nilearn.datasets.fetch_icbm152_2009)
9+
10+
Switching the MNI template uses will affect **all** subsequent operations by resampling data to the chosen space You can control this on a per-file loading basis using the `mask` argument of `Brain_Data`, e.g.
611

712
```python
813
from nltools.data import Brain_Data

nltools/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
"Design_Matrix_Series",
2121
"Simulator",
2222
"MNI_Template",
23-
"resolve_mni_path",
2423
"expand_mask",
2524
"collapse_mask",
2625
"create_sphere",
@@ -32,7 +31,7 @@
3231
from .cross_validation import set_cv
3332
from .data import Brain_Data, Adjacency, Groupby, Design_Matrix, Design_Matrix_Series
3433
from .simulator import Simulator
35-
from .prefs import MNI_Template, resolve_mni_path
34+
from .prefs import MNI_Template
3635
from .version import __version__
3736
from .mask import expand_mask, collapse_mask, create_sphere
3837
from .external import SRM, DetSRM

nltools/data/brain_data.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
)
6666
from nltools.stats import regress as regression
6767
from .adjacency import Adjacency
68-
from nltools.prefs import MNI_Template, resolve_mni_path
68+
from nltools.prefs import MNI_Template
6969
from nilearn.decoding import SearchLight
7070
from pathlib import Path
7171
from h5py import File as h5File
@@ -103,7 +103,7 @@ def __init__(self, data=None, Y=None, X=None, mask=None, **kwargs):
103103
# Setup default or specified nifti masker
104104
if mask is None:
105105
# Load default mask
106-
self.mask = nib.load(resolve_mni_path(MNI_Template)["mask"])
106+
self.mask = nib.load(MNI_Template.mask)
107107
elif isinstance(mask, (str, Path)):
108108
self.mask = nib.load(str(mask))
109109
elif isinstance(mask, nib.Nifti1Image):

nltools/mask.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import os
1212
import nibabel as nib
13-
from nltools.prefs import MNI_Template, resolve_mni_path
13+
from nltools.prefs import MNI_Template
1414
import pandas as pd
1515
import numpy as np
1616
import warnings
@@ -38,7 +38,7 @@ def create_sphere(coordinates, radius=5, mask=None):
3838
raise ValueError("mask is not a nibabel instance or a valid file name")
3939

4040
else:
41-
mask = nib.load(resolve_mni_path(MNI_Template)["mask"])
41+
mask = nib.load(MNI_Template.mask)
4242

4343
def sphere(r, p, mask):
4444
"""create a sphere of given radius at some point p in the brain mask

nltools/plotting.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@
2727
from numpy.fft import fft, fftfreq
2828
from nltools.stats import two_sample_permutation, one_sample_permutation
2929
from nilearn.plotting import plot_glass_brain, plot_stat_map, view_img, view_img_on_surf
30-
from nltools.prefs import MNI_Template, resolve_mni_path
31-
from nltools.utils import attempt_to_import
30+
from nltools.utils import attempt_to_import, get_mni_from_img_resolution
3231
import warnings
3332
import sklearn
3433
import os
@@ -218,7 +217,7 @@ def plot_t_brain(
218217
cut_coords=c,
219218
display_mode=v,
220219
cmap=cmap,
221-
bg_img=resolve_mni_path(MNI_Template)["brain"],
220+
bg_img=get_mni_from_img_resolution(obj, img_type="brain"),
222221
**kwargs,
223222
)
224223
elif how == "glass":
@@ -237,7 +236,7 @@ def plot_t_brain(
237236
cut_coords=c,
238237
display_mode=v,
239238
cmap=cmap,
240-
bg_img=resolve_mni_path(MNI_Template)["brain"],
239+
bg_img=get_mni_from_img_resolution(obj, img_type="brain"),
241240
**kwargs,
242241
)
243242
del obj
@@ -322,7 +321,7 @@ def plot_brain(
322321
cut_coords=c,
323322
display_mode=v,
324323
cmap=cmap,
325-
bg_img=resolve_mni_path(MNI_Template)["brain"],
324+
bg_img=get_mni_from_img_resolution(obj, img_type="brain"),
326325
**kwargs,
327326
)
328327
if save:
@@ -345,7 +344,7 @@ def plot_brain(
345344
cut_coords=c,
346345
display_mode=v,
347346
cmap=cmap,
348-
bg_img=resolve_mni_path(MNI_Template)["brain"],
347+
bg_img=get_mni_from_img_resolution(obj, img_type="brain"),
349348
**kwargs,
350349
)
351350
if save:

nltools/prefs.py

Lines changed: 108 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,148 +1,112 @@
11
import os
2-
from nltools.utils import get_resource_path
3-
4-
__all__ = ["MNI_Template", "resolve_mni_path"]
5-
6-
7-
class MNI_Template_Factory(dict):
8-
"""Class to build the default MNI_Template dictionary. This should never be used
9-
directly, instead just `from nltools.prefs import MNI_Template` and update that
10-
object's attributes to change MNI templates."""
11-
12-
def __init__(
13-
self,
14-
resolution="2mm",
15-
mask_type="with_ventricles",
16-
mask=os.path.join(get_resource_path(), "MNI152_T1_2mm_brain_mask.nii.gz"),
17-
plot=os.path.join(get_resource_path(), "MNI152_T1_2mm.nii.gz"),
18-
brain=os.path.join(get_resource_path(), "MNI152_T1_2mm_brain.nii.gz"),
19-
):
20-
self._resolution = resolution
21-
self._mask_type = mask_type
22-
self._mask = mask
23-
self._plot = plot
24-
self._brain = brain
25-
26-
self.update(
27-
{
28-
"resolution": self.resolution,
29-
"mask_type": self.mask_type,
30-
"mask": self.mask,
31-
"plot": self.plot,
32-
"brain": self.brain,
33-
}
34-
)
35-
36-
@property
37-
def resolution(self):
38-
return self._resolution
39-
40-
@resolution.setter
41-
def resolution(self, resolution):
42-
if isinstance(resolution, (int, float)):
43-
resolution = f"{int(resolution)}mm"
44-
if resolution not in ["2mm", "3mm"]:
45-
raise NotImplementedError(
46-
"Only 2mm and 3mm resolutions are currently supported"
47-
)
48-
self._resolution = resolution
49-
self.update({"resolution": self._resolution})
50-
51-
@property
52-
def mask_type(self):
53-
return self._mask_type
54-
55-
@mask_type.setter
56-
def mask_type(self, mask_type):
57-
if mask_type not in ["with_ventricles", "no_ventricles"]:
58-
raise NotImplementedError(
59-
"Only 'with_ventricles' and 'no_ventricles' mask_types are currently supported"
60-
)
61-
self._mask_type = mask_type
62-
self.update({"mask_type": self._mask_type})
63-
64-
@property
65-
def mask(self):
66-
return self._mask
67-
68-
@mask.setter
69-
def mask(self, mask):
70-
self._mask = mask
71-
self.update({"mask": self._mask})
72-
73-
@property
74-
def plot(self):
75-
return self._plot
76-
77-
@plot.setter
78-
def plot(self, plot):
79-
self._plot = plot
80-
self.update({"plot": self._plot})
81-
82-
@property
83-
def brain(self):
84-
return self._brain
85-
86-
@brain.setter
87-
def brain(self, brain):
88-
self._brain = brain
89-
self.update({"brain": self._brain})
90-
91-
92-
# NOTE: We export this from the module and expect users to interact with it instead of
93-
# the class constructor above
94-
MNI_Template = MNI_Template_Factory()
95-
96-
97-
def resolve_mni_path(MNI_Template):
98-
"""Helper function to resolve MNI path based on MNI_Template prefs setting."""
99-
100-
res = MNI_Template["resolution"]
101-
m = MNI_Template["mask_type"]
102-
if not isinstance(res, str):
103-
raise ValueError("resolution must be provided as a string!")
104-
if not isinstance(m, str):
105-
raise ValueError("mask_type must be provided as a string!")
106-
107-
if res == "3mm":
108-
if m == "with_ventricles":
109-
MNI_Template["mask"] = os.path.join(
110-
get_resource_path(), "MNI152_T1_3mm_brain_mask.nii.gz"
111-
)
112-
elif m == "no_ventricles":
113-
MNI_Template["mask"] = os.path.join(
114-
get_resource_path(), "MNI152_T1_3mm_brain_mask_no_ventricles.nii.gz"
115-
)
116-
else:
117-
raise ValueError(
118-
"Available mask_types are 'with_ventricles' or 'no_ventricles'"
119-
)
120-
121-
MNI_Template["plot"] = os.path.join(get_resource_path(), "MNI152_T1_3mm.nii.gz")
122-
123-
MNI_Template["brain"] = os.path.join(
124-
get_resource_path(), "MNI152_T1_3mm_brain.nii.gz"
2+
from dataclasses import dataclass, field
3+
from typing import Literal
4+
from os.path import dirname, join
5+
6+
__all__ = ["MNI_Template"]
7+
8+
9+
@dataclass
10+
class MNI_Template_Factory:
11+
"""Global MNI template configuration.
12+
13+
This class manages the global MNI template settings used throughout nltools.
14+
Users should interact with the exported MNI_Template instance rather than
15+
creating new instances.
16+
17+
Parameters
18+
----------
19+
template : {'default', 'nilearn', 'fmriprep'}
20+
Template variant to use. Each template represents a different MNI space:
21+
- 'default': Original MNI152 6th generation templates
22+
- 'nilearn': Nilearn's MNI152 templates
23+
- 'fmriprep': fMRIPrep's MNI152NLin2009cAsym templates
24+
resolution : {1, 2, 3}
25+
Resolution in mm. Not all resolutions are available for all templates:
26+
- 'default': 2mm, 3mm
27+
- 'nilearn': 1mm, 2mm, 3mm
28+
- 'fmriprep': 1mm, 2mm
29+
30+
Attributes
31+
----------
32+
mask : str
33+
Path to the brain mask file
34+
brain : str
35+
Path to the brain-extracted image
36+
plot : str
37+
Path to the full T1 image for plotting
38+
39+
Examples
40+
--------
41+
>>> from nltools.prefs import MNI_Template
42+
>>> MNI_Template.template = 'fmriprep'
43+
>>> MNI_Template.resolution = 1
44+
>>> print(MNI_Template.mask)
45+
"""
46+
47+
template: Literal["default", "nilearn", "fmriprep"] = "default"
48+
resolution: Literal[1, 2, 3] = 2
49+
50+
# Auto-populated paths
51+
mask: str = field(init=False)
52+
brain: str = field(init=False)
53+
plot: str = field(init=False)
54+
55+
# Define supported combinations
56+
_supported_combinations = {
57+
"default": [2, 3],
58+
"nilearn": [1, 2, 3],
59+
"fmriprep": [1, 2]
60+
}
61+
62+
def __post_init__(self):
63+
"""Initialize paths after dataclass creation."""
64+
self._validate_and_resolve()
65+
66+
def __setattr__(self, name, value):
67+
"""Custom setter to re-resolve paths when attributes change."""
68+
# Use object.__setattr__ to avoid recursion
69+
object.__setattr__(self, name, value)
70+
# Only resolve paths if we're setting template or resolution
71+
# and the object has been fully initialized
72+
if name in ["template", "resolution"] and hasattr(self, "_validate_and_resolve"):
73+
self._validate_and_resolve()
74+
75+
def __repr__(self) -> str:
76+
return (
77+
f"MNI_Template(template='{self.template}', resolution={self.resolution}mm)\n"
78+
f" mask: {os.path.basename(self.mask)}\n"
79+
f" brain: {os.path.basename(self.brain)}\n"
80+
f" plot: {os.path.basename(self.plot)}"
12581
)
126-
127-
elif res == "2mm":
128-
if m == "with_ventricles":
129-
MNI_Template["mask"] = os.path.join(
130-
get_resource_path(), "MNI152_T1_2mm_brain_mask.nii.gz"
131-
)
132-
elif m == "no_ventricles":
133-
MNI_Template["mask"] = os.path.join(
134-
get_resource_path(), "MNI152_T1_2mm_brain_mask_no_ventricles.nii.gz"
135-
)
136-
else:
82+
83+
def _validate_and_resolve(self):
84+
"""Validate inputs and resolve file paths."""
85+
# Validate resolution is supported for this template
86+
if self.resolution not in self._supported_combinations.get(self.template, []):
13787
raise ValueError(
138-
"Available mask_types are 'with_ventricles' or 'no_ventricles'"
88+
f"Resolution {self.resolution}mm is not supported for template '{self.template}'. "
89+
f"Supported resolutions: {self._supported_combinations[self.template]}"
13990
)
140-
141-
MNI_Template["plot"] = os.path.join(get_resource_path(), "MNI152_T1_2mm.nii.gz")
142-
143-
MNI_Template["brain"] = os.path.join(
144-
get_resource_path(), "MNI152_T1_2mm_brain.nii.gz"
145-
)
146-
else:
147-
raise ValueError("Available templates are '2mm' or '3mm'")
148-
return MNI_Template
91+
92+
# Build paths based on template and resolution
93+
base_path = join(dirname(__file__), "resources", "niftis", self.template)
94+
res_str = f"{self.resolution}mm"
95+
96+
# Set paths following the naming convention
97+
self.mask = join(base_path, f"MNI152_{res_str}_mask.nii.gz")
98+
self.brain = join(base_path, f"MNI152_{res_str}_brain.nii.gz")
99+
self.plot = join(base_path, f"MNI152_{res_str}_T1.nii.gz")
100+
101+
# Verify files exist
102+
for attr, path in [("mask", self.mask), ("brain", self.brain), ("plot", self.plot)]:
103+
if not os.path.exists(path):
104+
raise FileNotFoundError(
105+
f"Template file not found: {path}\n"
106+
f"This suggests an incomplete installation or missing template files."
107+
)
108+
109+
110+
# NOTE: We export this from the module and expect users to interact with it instead of
111+
# the class constructor above
112+
MNI_Template = MNI_Template_Factory()
-21.5 KB
Binary file not shown.
-7.8 KB
Binary file not shown.
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)