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

[MAINT] Store 1 timepoint for 4D generating a report with the maskers #3935

Merged
merged 28 commits into from
Nov 16, 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/latest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Enhancements
- :bdg-success:`API` Allow passing Pandas Series of image filenames to :class:`~nilearn.glm.second_level.SecondLevelModel` (:gh:`4070` by `Rémi Gau`_).
- :bdg-info:`Plotting` Allow setting ``vmin`` in :func:`~nilearn.plotting.plot_glass_brain` and :func:`~nilearn.plotting.plot_stat_map` (:gh:`3993` by `Michelle Wang`_).
- :bdg-success:`API` Support passing t and F contrasts to :func:`~nilearn.glm.compute_contrast` that that have fewer columns than the number of estimated parameters. Remaining columns are padded with zero (:gh:`4067` by `Rémi Gau`_).
- :bdg-dark:`Code` Multi-subject maskers' ``generate_report`` method no longer fails with 5D data but instead shows report of first subject. User can index input list to show report for different subjects (:gh:`3935` by `Yasmin Mzayek`_).

Changes
-------
Expand Down
80 changes: 30 additions & 50 deletions nilearn/glm/tests/test_second_level.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,39 +598,34 @@ def confounds():
)


def test_fmri_inputs(tmp_path, rng, confounds):
def test_fmri_inputs(
tmp_path, rng, confounds, shape_3d_default, shape_4d_default
):
# Test processing of FMRI inputs
# prepare fake data
p, q = 80, 10
X = rng.standard_normal(size=(p, q))
shapes = ((7, 8, 9, 10),)
mask, FUNCFILE, _ = write_fake_fmri_data_and_design(
shapes, file_path=tmp_path
mask, niimg, des = generate_fake_fmri_data_and_design(
[shape_4d_default], 1
)
FUNCFILE = FUNCFILE[0]
func_img = load(FUNCFILE)
T = func_img.shape[-1]
des = pd.DataFrame(np.ones((T, 1)), columns=["a"])
des_fname = str(tmp_path / "design.csv")
des.to_csv(des_fname)

# prepare correct input first level models
flm = FirstLevelModel(subject_label="01").fit(
FUNCFILE, design_matrices=des
)
flm = FirstLevelModel(subject_label="01").fit(niimg, design_matrices=des)

# prepare correct input dataframe and lists
shapes = (SHAPE,)
_, FUNCFILE, _ = write_fake_fmri_data_and_design(
shapes, file_path=tmp_path
)
FUNCFILE = FUNCFILE[0]

p, q = 80, 10
X = rng.standard_normal(size=(p, q))
sdes = pd.DataFrame(X[:3, :3], columns=["intercept", "b", "c"])

# smoke tests with correct input
flms = [flm, flm, flm]

shape_3d = [shape_3d_default + (1,)]
_, FUNCFILE, _ = write_fake_fmri_data_and_design(
shape_3d, file_path=tmp_path
)
FUNCFILE = FUNCFILE[0]
niimgs = [FUNCFILE, FUNCFILE, FUNCFILE]
niimg_4d = concat_imgs(niimgs)

# First level models as input
SecondLevelModel(mask_img=mask).fit(flms)
SecondLevelModel().fit(flms)
Expand All @@ -639,11 +634,9 @@ def test_fmri_inputs(tmp_path, rng, confounds):
SecondLevelModel().fit(flms, None, sdes)

# niimgs as input
niimgs = [FUNCFILE, FUNCFILE, FUNCFILE]
SecondLevelModel().fit(niimgs, None, sdes)

# 4d niimg as input
niimg_4d = concat_imgs(niimgs)
SecondLevelModel().fit(niimg_4d, None, sdes)


Expand Down Expand Up @@ -787,40 +780,27 @@ def test_fmri_img_inputs_errors(tmp_path, confounds):


def test_fmri_inputs_for_non_parametric_inference_errors(
tmp_path, rng, confounds
tmp_path, rng, confounds, shape_3d_default, shape_4d_default
):
# Test processing of FMRI inputs

# prepare fake data
p, q = 80, 10
X = rng.standard_normal(size=(p, q))
shapes = ((7, 8, 9, 10),)
_, func_file, _ = write_fake_fmri_data_and_design(
shapes, file_path=tmp_path
)

func_file = func_file[0]

func_img = load(func_file)
T = func_img.shape[-1]
des = pd.DataFrame(np.ones((T, 1)), columns=["a"])
des_fname = str(tmp_path / "design.csv")
des.to_csv(des_fname)
_, niimg, des = generate_fake_fmri_data_and_design([shape_4d_default], 1)

# prepare correct input first level models
flm = FirstLevelModel(subject_label="01").fit(
func_file, design_matrices=des
)
flm = FirstLevelModel(subject_label="01").fit(niimg, design_matrices=des)

# prepare correct input dataframe and lists
shapes = (SHAPE,)
_, func_file, _ = write_fake_fmri_data_and_design(
shapes, file_path=tmp_path
)
func_file = func_file[0]
p, q = 80, 10
X = rng.standard_normal(size=(p, q))
sdes = pd.DataFrame(X[:3, :3], columns=["intercept", "b", "c"])

niimgs = [func_file, func_file, func_file]
shape_3d = [shape_3d_default + (1,)]
_, FUNCFILE, _ = write_fake_fmri_data_and_design(
shape_3d, file_path=tmp_path
)
FUNCFILE = FUNCFILE[0]
niimgs = [FUNCFILE, FUNCFILE, FUNCFILE]
niimg_4d = concat_imgs(niimgs)
sdes = pd.DataFrame(X[:3, :3], columns=["intercept", "b", "c"])

# test missing second-level contrast
match = "No second-level contrast is specified."
Expand All @@ -840,7 +820,7 @@ def test_fmri_inputs_for_non_parametric_inference_errors(

# test list of less than two niimgs
with pytest.raises(TypeError, match="at least two"):
non_parametric_inference([func_file])
non_parametric_inference([FUNCFILE])

# test niimgs requirements
with pytest.raises(ValueError, match="require a design matrix"):
Expand Down
2 changes: 2 additions & 0 deletions nilearn/maskers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""The :mod:`nilearn.maskers` contains masker objects."""

from ._utils import compute_middle_image
from .base_masker import BaseMasker
from .multi_nifti_labels_masker import MultiNiftiLabelsMasker
from .multi_nifti_maps_masker import MultiNiftiMapsMasker
Expand All @@ -18,4 +19,5 @@
"NiftiMapsMasker",
"MultiNiftiMapsMasker",
"NiftiSpheresMasker",
"compute_middle_image",
]
24 changes: 24 additions & 0 deletions nilearn/maskers/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from nilearn import image


def _check_dims(imgs):
# check dims of one image if given a list
if isinstance(imgs, list):
im = imgs[0]
dim = image.load_img(im).shape
# in case of 4D (timeseries) + 1D (subjects) return first subject
if len(dim) == 4:
return im, dim + (1,)
else:
return imgs, dim + (1,)
else:
dim = image.load_img(imgs).shape
return imgs, dim


def compute_middle_image(img):
"""Compute middle image of timeseries (4D data)."""
img, dim = _check_dims(img)
if len(dim) == 4 or len(dim) == 5:
img = image.index_img(img, dim[-1] // 2)
return img, len(dim)
12 changes: 10 additions & 2 deletions nilearn/maskers/multi_nifti_masker.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from joblib import Memory, Parallel, delayed

from nilearn import _utils, image, masking
from nilearn.maskers import compute_middle_image
from nilearn.maskers.nifti_masker import NiftiMasker, _filter_and_mask


Expand Down Expand Up @@ -240,8 +241,15 @@ def fit(self, imgs=None, y=None):

self._reporting_data = None
if self.reports: # save inputs for reporting
imgs = imgs[0] if isinstance(imgs, list) else imgs
self._reporting_data = {"images": imgs, "mask": self.mask_img_}
self._reporting_data = {
"mask": self.mask_img_,
"dim": None,
"images": imgs,
}
if imgs is not None:
imgs, dims = compute_middle_image(imgs)
self._reporting_data["images"] = imgs
self._reporting_data["dim"] = dims

# If resampling is requested, resample the mask as well.
# Resampling: allows the user to change the affine, the shape or both.
Expand Down
24 changes: 17 additions & 7 deletions nilearn/maskers/nifti_labels_masker.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from joblib import Memory

from nilearn import _utils, image, masking
from nilearn.maskers import compute_middle_image
from nilearn.maskers.base_masker import BaseMasker, _filter_and_extract


Expand Down Expand Up @@ -290,12 +291,16 @@
self._report_content['summary'] = regions_summary

img = self._reporting_data['img']

# If we have a func image to show in the report, use it
if img is not None:
dim = image.load_img(img).shape
if len(dim) == 4:
# compute middle image from 4D series for plotting
img = image.index_img(img, dim[-1] // 2)
if self._reporting_data["dim"] == 5:
msg = (

Check warning on line 298 in nilearn/maskers/nifti_labels_masker.py

View check run for this annotation

Codecov / codecov/patch

nilearn/maskers/nifti_labels_masker.py#L298

Added line #L298 was not covered by tests
"A list of 4D subject images were provided to fit. "
"Only first subject is shown in the report."
)
warnings.warn(msg)
self._report_content['warning_message'] = msg

Check warning on line 303 in nilearn/maskers/nifti_labels_masker.py

View check run for this annotation

Codecov / codecov/patch

nilearn/maskers/nifti_labels_masker.py#L302-L303

Added lines #L302 - L303 were not covered by tests
display = plotting.plot_img(
img,
black_bg=False,
Expand Down Expand Up @@ -411,10 +416,15 @@

if self.reports:
self._reporting_data = {
'labels_image': self._resampled_labels_img_,
'mask': self.mask_img_,
'img': imgs,
"labels_image": self._resampled_labels_img_,
"mask": self.mask_img_,
"dim": None,
"img": imgs,
}
if imgs is not None:
imgs, dims = compute_middle_image(imgs)
self._reporting_data["img"] = imgs
self._reporting_data["dim"] = dims
else:
self._reporting_data = None

Expand Down
18 changes: 13 additions & 5 deletions nilearn/maskers/nifti_maps_masker.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from joblib import Memory

from nilearn import _utils, image
from nilearn.maskers import compute_middle_image
from nilearn.maskers.base_masker import BaseMasker, _filter_and_extract


Expand Down Expand Up @@ -326,11 +327,13 @@
display.close()
return embeded_images

dim = image.load_img(img).shape
if len(dim) == 4:
# compute middle image from 4D series for plotting
img = image.index_img(img, dim[-1] // 2)

if self._reporting_data["dim"] == 5:
msg = (

Check warning on line 331 in nilearn/maskers/nifti_maps_masker.py

View check run for this annotation

Codecov / codecov/patch

nilearn/maskers/nifti_maps_masker.py#L331

Added line #L331 was not covered by tests
"A list of 4D subject images were provided to fit. "
"Only first subject is shown in the report."
)
warnings.warn(msg)
self._report_content["warning_message"] = msg

Check warning on line 336 in nilearn/maskers/nifti_maps_masker.py

View check run for this annotation

Codecov / codecov/patch

nilearn/maskers/nifti_maps_masker.py#L335-L336

Added lines #L335 - L336 were not covered by tests
# Find the cut coordinates
cut_coords = [
plotting.find_xyz_cut_coords(image.index_img(maps_image, i))
Expand Down Expand Up @@ -425,8 +428,13 @@
self._reporting_data = {
"maps_image": self.maps_img_,
"mask": self.mask_img_,
"dim": None,
"img": imgs,
}
if imgs is not None:
imgs, dims = compute_middle_image(imgs)
self._reporting_data["img"] = imgs
self._reporting_data["dim"] = dims
else:
self._reporting_data = None

Expand Down
32 changes: 19 additions & 13 deletions nilearn/maskers/nifti_masker.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from joblib import Memory

from nilearn import _utils, image, masking
from nilearn.maskers import compute_middle_image
from nilearn.maskers.base_masker import BaseMasker, _filter_and_extract


Expand Down Expand Up @@ -338,21 +339,21 @@
img = self._reporting_data["images"]
mask = self._reporting_data["mask"]

if img is not None:
dim = image.load_img(img).shape
if len(dim) == 4:
# compute middle image from 4D series for plotting
img = image.index_img(img, dim[-1] // 2)

else: # images were not provided to fit
if img is None: # images were not provided to fit
msg = (
"No image provided to fit in NiftiMasker. "
"Setting image to mask for reporting."
)
warnings.warn(msg)
self._report_content["warning_message"] = msg
img = mask

if self._reporting_data["dim"] == 5:
msg = (

Check warning on line 351 in nilearn/maskers/nifti_masker.py

View check run for this annotation

Codecov / codecov/patch

nilearn/maskers/nifti_masker.py#L351

Added line #L351 was not covered by tests
"A list of 4D subject images were provided to fit. "
"Only first subject is shown in the report."
)
warnings.warn(msg)
self._report_content["warning_message"] = msg

Check warning on line 356 in nilearn/maskers/nifti_masker.py

View check run for this annotation

Codecov / codecov/patch

nilearn/maskers/nifti_masker.py#L355-L356

Added lines #L355 - L356 were not covered by tests
# create display of retained input mask, image
# for visual comparison
init_display = plotting.plot_img(
Expand All @@ -376,13 +377,9 @@
self._report_content["description"] += self._overlay_text

# create display of resampled NiftiImage and mask
# assuming that resampl_img has same dim as img
resampl_img, resampl_mask = self._reporting_data["transform"]
if resampl_img is None: # images were not provided to fit
resampl_img = resampl_mask
elif len(dim) == 4:
# compute middle image from 4D series for plotting
resampl_img = image.index_img(resampl_img, dim[-1] // 2)

final_display = plotting.plot_img(
resampl_img,
Expand Down Expand Up @@ -442,7 +439,15 @@
self.mask_img_ = _utils.check_niimg_3d(self.mask_img)

if self.reports: # save inputs for reporting
self._reporting_data = {"images": imgs, "mask": self.mask_img_}
self._reporting_data = {
"mask": self.mask_img_,
"dim": None,
"images": imgs,
}
if imgs is not None:
imgs, dims = compute_middle_image(imgs)
self._reporting_data["images"] = imgs
self._reporting_data["dim"] = dims
else:
self._reporting_data = None

Expand Down Expand Up @@ -485,6 +490,7 @@
copy=False,
interpolation="nearest",
)
resampl_imgs, _ = compute_middle_image(resampl_imgs)
else: # imgs not provided to fit
resampl_imgs = None

Expand Down