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

Limit current adaptive mask method to brain mask #1060

Merged
merged 22 commits into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion tedana/tests/test_decay.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@
def testdata1():
tes = np.array([14.5, 38.5, 62.5])
in_files = [op.join(get_test_data_path(), f"echo{i + 1}.nii.gz") for i in range(3)]
mask_file = op.join(get_test_data_path(), "mask.nii.gz")
data, _ = io.load_data(in_files, n_echos=len(tes))
mask, adaptive_mask = utils.make_adaptive_mask(data, methods=["dropout", "decay"])
mask, adaptive_mask = utils.make_adaptive_mask(
data,
mask=mask_file,
methods=["dropout", "decay"],
)
fittype = "loglin"
data_dict = {
"data": data,
Expand Down
7 changes: 6 additions & 1 deletion tedana/tests/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ def testdata1():
"""Data used for tests of the metrics module."""
tes = np.array([14.5, 38.5, 62.5])
in_files = [op.join(get_test_data_path(), f"echo{i + 1}.nii.gz") for i in range(3)]
mask_file = op.join(get_test_data_path(), "mask.nii.gz")
data_cat, ref_img = io.load_data(in_files, n_echos=len(tes))
_, adaptive_mask = utils.make_adaptive_mask(data_cat, methods=["dropout", "decay"])
_, adaptive_mask = utils.make_adaptive_mask(
data_cat,
mask=mask_file,
methods=["dropout", "decay"],
)
data_optcom = np.mean(data_cat, axis=1)
mixing = np.random.random((data_optcom.shape[1], 50))
io_generator = io.OutputGenerator(ref_img)
Expand Down
65 changes: 36 additions & 29 deletions tedana/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,69 +77,78 @@ def test_reshape_niimg():
def test_make_adaptive_mask():
"""Test tedana.utils.make_adaptive_mask with different methods."""
# load data make masks
mask_file = pjoin(datadir, "mask.nii.gz")
data = io.load_data(fnames, n_echos=len(tes))[0]

# Just dropout method
mask, masksum = utils.make_adaptive_mask(data, threshold=1, methods=["dropout"])
mask, masksum = utils.make_adaptive_mask(
data,
mask=mask_file,
threshold=1,
methods=["dropout"],
)

assert mask.shape == masksum.shape == (64350,)
assert np.allclose(mask, (masksum >= 1).astype(bool))
assert mask.sum() == 50786
assert mask.sum() == 49451
vals, counts = np.unique(masksum, return_counts=True)
assert np.allclose(vals, np.array([0, 1, 2, 3]))
assert np.allclose(counts, np.array([13564, 3977, 5060, 41749]))
assert np.allclose(counts, np.array([14899, 3589, 5276, 40586]))

# Just decay method
mask, masksum = utils.make_adaptive_mask(data, threshold=1, methods=["decay"])
mask, masksum = utils.make_adaptive_mask(
data,
mask=mask_file,
threshold=1,
methods=["decay"],
)

assert mask.shape == masksum.shape == (64350,)
assert np.allclose(mask, (masksum >= 1).astype(bool))
assert mask.sum() == 64350 # This method can't flag first echo as bad
assert mask.sum() == 61954 # This method can't flag first echo as bad
vals, counts = np.unique(masksum, return_counts=True)
assert np.allclose(vals, np.array([1, 2, 3]))
assert np.allclose(counts, np.array([5666, 6552, 52132]))
assert np.allclose(vals, np.array([0, 1, 2, 3]))
assert np.allclose(counts, np.array([2396, 3578, 6295, 52081]))

# Dropout and decay methods combined
mask, masksum = utils.make_adaptive_mask(data, threshold=1, methods=["dropout", "decay"])
mask, masksum = utils.make_adaptive_mask(
data,
mask=mask_file,
threshold=1,
methods=["dropout", "decay"],
)

assert mask.shape == masksum.shape == (64350,)
assert np.allclose(mask, (masksum >= 1).astype(bool))
assert mask.sum() == 50786
assert mask.sum() == 49451
vals, counts = np.unique(masksum, return_counts=True)
assert np.allclose(vals, np.array([0, 1, 2, 3]))
assert np.allclose(counts, np.array([13564, 4959, 5349, 40478]))
assert np.allclose(counts, np.array([14899, 4319, 5730, 39402]))

# Adding "none" should have no effect
mask, masksum = utils.make_adaptive_mask(
data, threshold=1, methods=["dropout", "decay", "none"]
data,
mask=mask_file,
threshold=1,
methods=["dropout", "decay", "none"],
)

assert mask.shape == masksum.shape == (64350,)
assert np.allclose(mask, (masksum >= 1).astype(bool))
assert mask.sum() == 50786
assert mask.sum() == 49451
vals, counts = np.unique(masksum, return_counts=True)
assert np.allclose(vals, np.array([0, 1, 2, 3]))
assert np.allclose(counts, np.array([13564, 4959, 5349, 40478]))
assert np.allclose(counts, np.array([14899, 4319, 5730, 39402]))

# Just "none"
mask, masksum = utils.make_adaptive_mask(data, threshold=1, methods=["none"])
mask, masksum = utils.make_adaptive_mask(data, mask=mask_file, threshold=1, methods=["none"])

assert mask.shape == masksum.shape == (64350,)
assert np.allclose(mask, (masksum >= 1).astype(bool))
assert mask.sum() == 64350
assert mask.sum() == 61954
vals, counts = np.unique(masksum, return_counts=True)
assert np.allclose(vals, np.array([3]))
assert np.allclose(counts, np.array([64350]))

# test user-defined mask
# TODO: Add mask file with no bad voxels to test against
mask, masksum = utils.make_adaptive_mask(
data,
mask=pjoin(datadir, "mask.nii.gz"),
threshold=3,
methods=["dropout", "decay"],
)
assert np.allclose(mask, (masksum >= 3).astype(bool))
assert np.allclose(vals, np.array([0, 3]))
assert np.allclose(counts, np.array([2396, 61954]))


# SMOKE TESTS
Expand Down Expand Up @@ -178,8 +187,6 @@ def test_smoke_make_adaptive_mask():
data = np.random.random((n_samples, n_echos, n_times))
mask = np.random.randint(2, size=n_samples)

assert utils.make_adaptive_mask(data, methods=["dropout", "decay"]) is not None
# functions with mask
assert utils.make_adaptive_mask(data, mask=mask, methods=["dropout", "decay"]) is not None


Expand Down
49 changes: 26 additions & 23 deletions tedana/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,18 @@ def reshape_niimg(data):
return fdata


def make_adaptive_mask(data, mask=None, threshold=1, methods=["dropout"]):
def make_adaptive_mask(data, mask, threshold=1, methods=["dropout"]):
"""Make map of `data` specifying longest echo a voxel can be sampled with.

Parameters
----------
data : (S x E x T) array_like
Multi-echo data array, where `S` is samples, `E` is echos, and `T` is time.
mask : :obj:`str` or img_like, optional
Binary mask for voxels to consider in TE Dependent ANAlysis. Default is
to generate mask from data with good signal across echoes
mask : :obj:`str` or img_like
Binary mask for voxels to consider in TE Dependent ANAlysis.
This must be provided, as the mask is used to identify exemplar voxels.
Without a mask limiting the voxels to consider,
the adaptive mask will generally select voxels outside the brain as exemplars.
threshold : :obj:`int`, optional
Minimum echo count to retain in the mask. Default is 1, which is
equivalent not thresholding.
Expand All @@ -81,6 +83,7 @@ def make_adaptive_mask(data, mask=None, threshold=1, methods=["dropout"]):
Dropout

Remove voxels with relatively low mean magnitudes from the mask.

This method uses distributions of values across the mask.
Therefore, it is sensitive to the quality of the mask.
A bad mask may result in a bad adaptive mask.
Expand Down Expand Up @@ -121,6 +124,9 @@ def make_adaptive_mask(data, mask=None, threshold=1, methods=["dropout"]):
f"An adaptive mask was then generated using the {'+'.join(methods)} method(s), "
"in which each voxel's value reflects the number of echoes with 'good' data."
)
mask = reshape_niimg(mask).astype(bool)
data = data[mask, :, :]

if (methods is None) or (len(methods) == 1 and methods[0].lower() == "none"):
LGR.warning(
"No methods provided for adaptive mask generation. "
Expand Down Expand Up @@ -180,6 +186,8 @@ def make_adaptive_mask(data, mask=None, threshold=1, methods=["dropout"]):
if lthrs.ndim > 1:
lthrs = lthrs[:, lthrs.sum(axis=0).argmax()]

LGR.info("Echo-wise intensity thresholds for adaptive mask: %s", lthrs)

# determine samples where absolute value is greater than echo-specific thresholds
# and count # of echos that pass criterion
dropout_adaptive_mask = (np.abs(echo_means) > lthrs).sum(axis=-1)
Expand All @@ -196,26 +204,21 @@ def make_adaptive_mask(data, mask=None, threshold=1, methods=["dropout"]):
# Retain the most conservative of the selected adaptive mask estimates
adaptive_mask = np.minimum.reduce(adaptive_masks)

if mask is None:
# make it a boolean mask to (where we have at least `threshold` echoes with good signal)
mask = (adaptive_mask >= threshold).astype(bool)
# TODO: Use visual report to make checking the reduced mask easier
if np.any(adaptive_mask < threshold):
n_bad_voxels = np.sum(adaptive_mask < threshold)
LGR.warning(
f"{n_bad_voxels} voxels in user-defined mask do not have good signal. "
"Removing voxels from mask."
)
adaptive_mask[adaptive_mask < threshold] = 0
else:
# if the user has supplied a binary mask
mask = reshape_niimg(mask).astype(bool)
adaptive_mask = adaptive_mask * mask
# reduce mask based on adaptive_mask
# TODO: Use visual report to make checking the reduced mask easier
if np.any(adaptive_mask[mask] < threshold):
n_bad_voxels = np.sum(adaptive_mask[mask] < threshold)
LGR.warning(
f"{n_bad_voxels} voxels in user-defined mask do not have good "
"signal. Removing voxels from mask."
)
adaptive_mask[adaptive_mask < threshold] = 0
mask = adaptive_mask.astype(bool)

return mask, adaptive_mask

modified_mask = adaptive_mask.astype(bool)

adaptive_mask = unmask(adaptive_mask, mask)
modified_mask = unmask(modified_mask, mask)

return modified_mask, adaptive_mask


def unmask(data, mask):
Expand Down
9 changes: 7 additions & 2 deletions tedana/workflows/t2smap.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import sys

import numpy as np
from nilearn.masking import compute_epi_mask
from scipy import stats
from threadpoolctl import threadpool_limits

Expand Down Expand Up @@ -284,11 +285,15 @@ def t2smap_workflow(
config="auto",
make_figures=False,
)
n_samp, n_echos, n_vols = catd.shape
n_echos = catd.shape[1]
LGR.debug(f"Resulting data shape: {catd.shape}")

if mask is None:
LGR.info("Computing adaptive mask")
LGR.info(
"Computing initial mask from the first echo using nilearn's compute_epi_mask function."
)
first_echo_img = io.new_nii_like(io_generator.reference_img, catd[:, 0, :])
mask = compute_epi_mask(first_echo_img)
else:
LGR.info("Using user-defined mask")
mask, masksum = utils.make_adaptive_mask(
Expand Down