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

[ENH] Add emc-related helper functions - images #77

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3bee02d
[ENH] Add a series of general-purpose and emc-related image-handling …
Feb 11, 2020
88a6e80
Update dmriprep/interfaces/images.py
dPys Feb 25, 2020
bb4cc79
Update dmriprep/interfaces/images.py
dPys Feb 25, 2020
1e9c015
Update dmriprep/utils/images.py
dPys Feb 25, 2020
e4ea798
Update dmriprep/utils/images.py
dPys Feb 25, 2020
e982d6f
Update dmriprep/utils/images.py
dPys Feb 25, 2020
3588947
Update dmriprep/utils/images.py
dPys Feb 25, 2020
1544a55
Update dmriprep/interfaces/images.py
dPys Mar 17, 2020
b6e917e
Update dmriprep/utils/images.py
dPys Mar 17, 2020
a888d73
Update dmriprep/utils/images.py
dPys Mar 17, 2020
d872094
[ENH] Add docstring and first half of unit tests
Mar 17, 2020
eb43043
Address Sider complaint
Mar 17, 2020
abf5186
[DOC] Add doctests for all functions using HARDI data
Mar 24, 2020
25ecfc0
Merge branch 'master' into miscellaneous_emc_helper_functions_images
dPys Mar 24, 2020
02f82ce
Remove unused Pathlib import
dPys Mar 24, 2020
bb6c9a5
Add extra blank line to make sidecar happy
dPys Mar 24, 2020
56213a0
Fix whitespace (again)
dPys Mar 24, 2020
5e64dfa
Update dmriprep/utils/images.py
dPys Mar 24, 2020
85a285a
[ENH] Add error handling to
Mar 24, 2020
b13d57b
omit prune_b0s helper function, save for later PR with signal prediction
Mar 24, 2020
7767498
Implements summarize_images -- a function to merge the functionality …
Mar 24, 2020
947a5ca
Implements summarize_images -- a function to merge the functionality …
Mar 24, 2020
7403652
Add error-handling for save_3d_to_4d and save_4d_to_3d
Mar 24, 2020
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
77 changes: 20 additions & 57 deletions dmriprep/interfaces/images.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
"""Image tools interfaces."""
import numpy as np
import nibabel as nb
from nipype.utils.filemanip import fname_presuffix
from dmriprep.utils.images import rescale_b0, median, match_transforms, extract_b0
dPys marked this conversation as resolved.
Show resolved Hide resolved
from nipype import logging
from nipype.interfaces.base import (
traits, TraitedSpec, BaseInterfaceInputSpec, SimpleInterface, File
traits, TraitedSpec, BaseInterfaceInputSpec, SimpleInterface, File,
InputMultiObject, OutputMultiObject
)

LOGGER = logging.getLogger('nipype.interface')
Expand Down Expand Up @@ -45,24 +44,6 @@ def _run_interface(self, runtime):
return runtime


def extract_b0(in_file, b0_ixs, newpath=None):
"""Extract the *b0* volumes from a DWI dataset."""
out_file = fname_presuffix(
in_file, suffix='_b0', newpath=newpath)

img = nb.load(in_file)
data = img.get_fdata(dtype='float32')

b0 = data[..., b0_ixs]

hdr = img.header.copy()
hdr.set_data_shape(b0.shape)
hdr.set_xyzt_units('mm')
hdr.set_data_dtype(np.float32)
nb.Nifti1Image(b0, img.affine, hdr).to_filename(out_file)
return out_file


class _RescaleB0InputSpec(BaseInterfaceInputSpec):
in_file = File(exists=True, mandatory=True, desc='b0s file')
mask_file = File(exists=True, mandatory=True, desc='mask file')
Expand Down Expand Up @@ -103,43 +84,25 @@ def _run_interface(self, runtime):
return runtime


def rescale_b0(in_file, mask_file, newpath=None):
"""Rescale the input volumes using the median signal intensity."""
out_file = fname_presuffix(
in_file, suffix='_rescaled_b0', newpath=newpath)

img = nb.load(in_file)
if img.dataobj.ndim == 3:
return in_file

data = img.get_fdata(dtype='float32')
mask_img = nb.load(mask_file)
mask_data = mask_img.get_fdata(dtype='float32')
class _MatchTransformsInputSpec(BaseInterfaceInputSpec):
b0_indices = traits.List(mandatory=True)
dwi_files = InputMultiObject(File(exists=True), mandatory=True)
transforms = InputMultiObject(File(exists=True), mandatory=True)

median_signal = np.median(data[mask_data > 0, ...], axis=0)
rescaled_data = 1000 * data / median_signal
hdr = img.header.copy()
nb.Nifti1Image(rescaled_data, img.affine, hdr).to_filename(out_file)
return out_file

class _MatchTransformsOutputSpec(TraitedSpec):
transforms = OutputMultiObject(File(exists=True), mandatory=True)

def median(in_file, newpath=None):
"""Average a 4D dataset across the last dimension using median."""
out_file = fname_presuffix(
in_file, suffix='_b0ref', newpath=newpath)

img = nb.load(in_file)
if img.dataobj.ndim == 3:
return in_file
if img.shape[-1] == 1:
nb.squeeze_image(img).to_filename(out_file)
return out_file

median_data = np.median(img.get_fdata(dtype='float32'),
axis=-1)
class MatchTransforms(SimpleInterface):
"""
Interface for mapping the `match_transforms` function across lists of inputs.
"""
input_spec = MatchTransformsInputSpec
output_spec = MatchTransformsOutputSpec
dPys marked this conversation as resolved.
Show resolved Hide resolved

hdr = img.header.copy()
hdr.set_xyzt_units('mm')
hdr.set_data_dtype(np.float32)
nb.Nifti1Image(median_data, img.affine, hdr).to_filename(out_file)
return out_file
def _run_interface(self, runtime):
self._results["transforms"] = match_transforms(
self.inputs.dwi_files, self.inputs.transforms, self.inputs.b0_indices
)
return runtime
177 changes: 177 additions & 0 deletions dmriprep/utils/images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import numpy as np
import nibabel as nb
from nipype.utils.filemanip import fname_presuffix


def extract_b0(in_file, b0_ixs, newpath=None):
dPys marked this conversation as resolved.
Show resolved Hide resolved
"""Extract the *b0* volumes from a DWI dataset."""
out_file = fname_presuffix(in_file, suffix="_b0", newpath=newpath)

img = nb.load(in_file)
data = img.get_fdata(dtype="float32")

b0 = data[..., b0_ixs]

hdr = img.header.copy()
hdr.set_data_shape(b0.shape)
hdr.set_xyzt_units("mm")
hdr.set_data_dtype(np.float32)
nb.Nifti1Image(b0, img.affine, hdr).to_filename(out_file)
return out_file


def rescale_b0(in_file, mask_file, newpath=None):
"""Rescale the input volumes using the median signal intensity."""
out_file = fname_presuffix(in_file, suffix="_rescaled_b0", newpath=newpath)

img = nb.load(in_file)
if img.dataobj.ndim == 3:
return in_file

data = img.get_fdata(dtype="float32")
mask_img = nb.load(mask_file)
mask_data = mask_img.get_fdata(dtype="float32")

median_signal = np.median(data[mask_data > 0, ...], axis=0)
rescaled_data = 1000 * data / median_signal
hdr = img.header.copy()
nb.Nifti1Image(rescaled_data, img.affine, hdr).to_filename(out_file)
return out_file


def median(in_file, newpath=None):
dPys marked this conversation as resolved.
Show resolved Hide resolved
"""Average a 4D dataset across the last dimension using median."""
out_file = fname_presuffix(in_file, suffix="_b0ref", newpath=newpath)

img = nb.load(in_file)
if img.dataobj.ndim == 3:
return in_file
if img.shape[-1] == 1:
nb.squeeze_image(img).to_filename(out_file)
return out_file

median_data = np.median(img.get_fdata(), axis=-1)

hdr = img.header.copy()
hdr.set_xyzt_units("mm")
nb.Nifti1Image(median_data, img.affine, hdr).to_filename(out_file)
return out_file


def average_images(images, out_path=None):
dPys marked this conversation as resolved.
Show resolved Hide resolved
"""Average a 4D dataset across the last dimension using mean."""
from nilearn.image import mean_img

average_img = mean_img([nb.load(img) for img in images])
if out_path is None:
out_path = fname_presuffix(
images[0], use_ext=False, suffix="_mean.nii.gz"
)
average_img.to_filename(out_path)
return out_path


def quick_load_images(image_list, dtype=np.float32):
dPys marked this conversation as resolved.
Show resolved Hide resolved
"""Load 3D volumes from a list of file paths into a 4D array."""
example_img = nb.load(image_list[0])
num_images = len(image_list)
output_matrix = np.zeros(tuple(example_img.shape) + (num_images,), dtype=dtype)
for image_num, image_path in enumerate(image_list):
output_matrix[..., image_num] = nb.load(image_path).get_fdata(dtype=dtype)
return output_matrix
dPys marked this conversation as resolved.
Show resolved Hide resolved


def match_transforms(dwi_files, transforms, b0_indices):
dPys marked this conversation as resolved.
Show resolved Hide resolved
"""Arranges the order of a list of affine transforms to correspond with that of
each individual dwi volume file, accounting for the indices of B0s. A helper
function for EMC."""
dPys marked this conversation as resolved.
Show resolved Hide resolved
num_dwis = len(dwi_files)
num_transforms = len(transforms)

if num_dwis == num_transforms:
return transforms

# Do sanity checks
if not len(transforms) == len(b0_indices):
raise Exception("number of transforms does not match number of b0 images")

# Create a list of which emc affines go with each of the split images
nearest_affines = []
for index in range(num_dwis):
nearest_b0_num = np.argmin(np.abs(index - np.array(b0_indices)))
this_transform = transforms[nearest_b0_num]
nearest_affines.append(this_transform)

return nearest_affines


def save_4d_to_3d(in_file):
"""Split a 4D dataset along the last dimension into multiple 3D volumes."""
files_3d = nb.four_to_three(nb.load(in_file))
dPys marked this conversation as resolved.
Show resolved Hide resolved
out_files = []
for i, file_3d in enumerate(files_3d):
out_file = fname_presuffix(in_file, suffix="_tmp_{}".format(i))
file_3d.to_filename(out_file)
out_files.append(out_file)
del files_3d
return out_files


def prune_b0s_from_dwis(in_files, b0_ixs):
"""Remove *b0* volume files from a complete list of DWI volume files."""
if in_files[0].endswith("_warped.nii.gz"):
dPys marked this conversation as resolved.
Show resolved Hide resolved
out_files = [
i
for j, i in enumerate(
sorted(
in_files, key=lambda x: int(x.split("_")[-2].split(".nii.gz")[0])
)
)
if j not in b0_ixs
]
else:
out_files = [
i
for j, i in enumerate(
sorted(
in_files, key=lambda x: int(x.split("_")[-1].split(".nii.gz")[0])
)
)
if j not in b0_ixs
]
return out_files


def save_3d_to_4d(in_files):
"""Concatenate a list of 3D volumes into a 4D output."""
img_4d = nb.funcs.concat_images([nb.load(img_3d) for img_3d in in_files])
out_file = fname_presuffix(in_files[0], suffix="_merged")
img_4d.to_filename(out_file)
del img_4d
dPys marked this conversation as resolved.
Show resolved Hide resolved
return out_file
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should contribute this to Nipype. Why don't we start a module of nibabel interfaces with these functions and also upstream https://github.com/poldracklab/niworkflows/blob/master/niworkflows/interfaces/nibabel.py ?

WDYT @effigies ?

I think the more interfaces with nibabel handling we have screened by Chris, the better.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why the function exists. It doesn't seem to be being called. And written without unnecessary boilerplate, it's quite short:

def save_3d_to_4d(in_files):
    out_file = fname_presuffix(in_files[0], suffix="_merged")
    nb.concat_images(in_files).to_filename(out_file)
    return out_file

If something this small seems worth upstreaming, sure, why not?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, this is a nice, general purpose function that could be used in a lot of scenarios. It is basically a pure python equivalent to fslmerge?

@effigies -- this function is referenced here. We are in the process of incorporating a much larger WIP piecemeal, for which this is a critical helper function.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're using it in a Function interface then you need to import nibabel in the function body.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's included in a general import list, along with numpy and a few others, for all Function nodes in the relevant workflow. See: https://github.com/nipreps/dmriprep/pull/62/files#diff-2ae94ced15c1b8a730ecb0ebdd0f532eR692-R701

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If something this small seems worth upstreaming, sure, why not?

Yep, I think having nibabel interfaces handy with NiPype would be super-useful for everyone and with the big plus of your eyeballs for the time you keep tabs on nibabel and nipype (hopefully long from my egoistic pov).

Copy link
Collaborator Author

@dPys dPys Mar 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've kept these in this PR for now until nipreps/niworkflows#489 gets merged. Also, the doctests here are specific to dmriprep's fixtures so they will need to be tweaked for tests in niworkflows. @oesteban @effigies



def decompose_affine(affine):
dPys marked this conversation as resolved.
Show resolved Hide resolved
"""Takes an transformation affine matrix A and determines
rotations and translations."""
dPys marked this conversation as resolved.
Show resolved Hide resolved

def rang(b):
a = min(max(b, -1), 1)
return a

Ry = np.arcsin(A[0, 2])
# Rx = np.arcsin(A[1, 2] / np.cos(Ry))
# Rz = np.arccos(A[0, 1] / np.sin(Ry))

if (abs(Ry) - np.pi / 2) ** 2 < 1e-9:
Rx = 0
Rz = np.arctan2(-rang(A[1, 0]), rang(-A[2, 0] / A[0, 2]))
else:
c = np.cos(Ry)
Rx = np.arctan2(rang(A[1, 2] / c), rang(A[2, 2] / c))
Rz = np.arctan2(rang(A[0, 1] / c), rang(A[0, 0] / c))

rotations = [Rx, Ry, Rz]
translations = [A[0, 3], A[1, 3], A[2, 3]]

return rotations, translations