-
Notifications
You must be signed in to change notification settings - Fork 54
ENH: Add nibabel-based split and merge interfaces #489
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
Changes from 21 commits
0de7374
bfcd29c
30ddf03
5e3c93c
40f6de1
f3572e6
49ff6bd
87f1cb3
b4fcdb7
f51f510
9aa0655
5a31b01
53db81a
1c27f20
05724d5
7263d03
03ebb6d
9446d47
0ae5351
fc42351
1d12dd3
d657546
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,27 +1,34 @@ | ||
# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- | ||
# vi: set ft=python sts=4 ts=4 sw=4 et: | ||
"""Nibabel-based interfaces.""" | ||
from pathlib import Path | ||
import numpy as np | ||
import nibabel as nb | ||
from nipype import logging | ||
from nipype.utils.filemanip import fname_presuffix | ||
from nipype.interfaces.base import ( | ||
traits, TraitedSpec, BaseInterfaceInputSpec, File, | ||
SimpleInterface | ||
traits, | ||
TraitedSpec, | ||
BaseInterfaceInputSpec, | ||
File, | ||
SimpleInterface, | ||
OutputMultiObject, | ||
InputMultiObject, | ||
) | ||
|
||
IFLOGGER = logging.getLogger('nipype.interface') | ||
IFLOGGER = logging.getLogger("nipype.interface") | ||
|
||
|
||
class _ApplyMaskInputSpec(BaseInterfaceInputSpec): | ||
in_file = File(exists=True, mandatory=True, desc='an image') | ||
in_mask = File(exists=True, mandatory=True, desc='a mask') | ||
threshold = traits.Float(0.5, usedefault=True, | ||
desc='a threshold to the mask, if it is nonbinary') | ||
in_file = File(exists=True, mandatory=True, desc="an image") | ||
in_mask = File(exists=True, mandatory=True, desc="a mask") | ||
threshold = traits.Float( | ||
0.5, usedefault=True, desc="a threshold to the mask, if it is nonbinary" | ||
) | ||
|
||
|
||
class _ApplyMaskOutputSpec(TraitedSpec): | ||
out_file = File(exists=True, desc='masked file') | ||
out_file = File(exists=True, desc="masked file") | ||
|
||
|
||
class ApplyMask(SimpleInterface): | ||
|
@@ -35,8 +42,9 @@ def _run_interface(self, runtime): | |
msknii = nb.load(self.inputs.in_mask) | ||
msk = msknii.get_fdata() > self.inputs.threshold | ||
|
||
self._results['out_file'] = fname_presuffix( | ||
self.inputs.in_file, suffix='_masked', newpath=runtime.cwd) | ||
self._results["out_file"] = fname_presuffix( | ||
self.inputs.in_file, suffix="_masked", newpath=runtime.cwd | ||
) | ||
|
||
if img.dataobj.shape[:3] != msk.shape: | ||
raise ValueError("Image and mask sizes do not match.") | ||
|
@@ -48,19 +56,18 @@ def _run_interface(self, runtime): | |
msk = msk[..., np.newaxis] | ||
|
||
masked = img.__class__(img.dataobj * msk, None, img.header) | ||
masked.to_filename(self._results['out_file']) | ||
masked.to_filename(self._results["out_file"]) | ||
return runtime | ||
|
||
|
||
class _BinarizeInputSpec(BaseInterfaceInputSpec): | ||
in_file = File(exists=True, mandatory=True, desc='input image') | ||
thresh_low = traits.Float(mandatory=True, | ||
desc='non-inclusive lower threshold') | ||
in_file = File(exists=True, mandatory=True, desc="input image") | ||
thresh_low = traits.Float(mandatory=True, desc="non-inclusive lower threshold") | ||
|
||
|
||
class _BinarizeOutputSpec(TraitedSpec): | ||
out_file = File(exists=True, desc='masked file') | ||
out_mask = File(exists=True, desc='output mask') | ||
out_file = File(exists=True, desc="masked file") | ||
out_mask = File(exists=True, desc="output mask") | ||
|
||
|
||
class Binarize(SimpleInterface): | ||
|
@@ -72,20 +79,115 @@ class Binarize(SimpleInterface): | |
def _run_interface(self, runtime): | ||
img = nb.load(self.inputs.in_file) | ||
|
||
self._results['out_file'] = fname_presuffix( | ||
self.inputs.in_file, suffix='_masked', newpath=runtime.cwd) | ||
self._results['out_mask'] = fname_presuffix( | ||
self.inputs.in_file, suffix='_mask', newpath=runtime.cwd) | ||
self._results["out_file"] = fname_presuffix( | ||
self.inputs.in_file, suffix="_masked", newpath=runtime.cwd | ||
) | ||
self._results["out_mask"] = fname_presuffix( | ||
self.inputs.in_file, suffix="_mask", newpath=runtime.cwd | ||
) | ||
|
||
data = img.get_fdata() | ||
mask = data > self.inputs.thresh_low | ||
data[~mask] = 0.0 | ||
masked = img.__class__(data, img.affine, img.header) | ||
masked.to_filename(self._results['out_file']) | ||
masked.to_filename(self._results["out_file"]) | ||
|
||
img.header.set_data_dtype('uint8') | ||
maskimg = img.__class__(mask.astype('uint8'), img.affine, | ||
img.header) | ||
maskimg.to_filename(self._results['out_mask']) | ||
img.header.set_data_dtype("uint8") | ||
maskimg = img.__class__(mask.astype("uint8"), img.affine, img.header) | ||
maskimg.to_filename(self._results["out_mask"]) | ||
|
||
return runtime | ||
|
||
dPys marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
class _SplitSeriesInputSpec(BaseInterfaceInputSpec): | ||
in_file = File(exists=True, mandatory=True, desc="input 4d image") | ||
allow_3D = traits.Bool( | ||
False, usedefault=True, desc="do not fail if a 3D volume is passed in" | ||
) | ||
|
||
oesteban marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
class _SplitSeriesOutputSpec(TraitedSpec): | ||
out_files = OutputMultiObject(File(exists=True), desc="output list of 3d images") | ||
|
||
|
||
class SplitSeries(SimpleInterface): | ||
"""Split a 4D dataset along the last dimension into a series of 3D volumes.""" | ||
|
||
input_spec = _SplitSeriesInputSpec | ||
output_spec = _SplitSeriesOutputSpec | ||
|
||
def _run_interface(self, runtime): | ||
filenii = nb.squeeze_image(nb.load(self.inputs.in_file)) | ||
filenii = filenii.__class__( | ||
np.squeeze(filenii.dataobj), filenii.affine, filenii.header | ||
) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The reason that non-terminal 1-dimensions are left in place by What's the use case that you're taking care of here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, the use case I'm contemplating is separating out the three components of deformation fields and other model-based nonlinear transforms. There is one example of this in the tests. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Found it. I guess I would think splitting along the fifth dimension is a different task from splitting along the fourth, but I see that it's convenient to have a single interface. I'm not sure that risking dropping meaningful dimensions is a good idea. What about forcing to 4D like: extra_dims = tuple(dim for dim in img.shape[3:] if dim > 1) or (1,)
if len(extra_dims) != 1:
raise ValueError("Invalid shape")
img = img.__class__(img.dataobj.reshape(img.shape[:3] + extra_dims),
img.affine, img.header) This coerces a 3D image to (x, y, z, 1) and a 4+D image to (x, y, z, n) assuming that dimensions 4-7 are all 1, n or absent. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it guaranteed that the spatial dimensions will not be affected on that reshape? If so, I'm down with this solution. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trivial dimensions have no effect on indexing, whether it's C- or Fortran ordered. If you don't change the order, it's fine. |
||
ndim = filenii.dataobj.ndim | ||
if ndim != 4: | ||
if self.inputs.allow_3D and ndim == 3: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is odd, as the above will coerce a valid 4D There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is that odd? It is indeed a 3D volume, right? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. for instance, there are a fair number of T1w images in OpenNeuro with (x, y, z, 1) dimensions. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is a 4D series. You should be able to split it into one 3D volume without special casing it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess the above snippet you wrote would make this particular use-case a standard one? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would say checking for 3D volumes should happen before reshaping the image. |
||
out_file = str( | ||
Path( | ||
fname_presuffix(self.inputs.in_file, suffix=f"_idx-000") | ||
).absolute() | ||
) | ||
self._results["out_files"] = out_file | ||
filenii.to_filename(out_file) | ||
return runtime | ||
raise RuntimeError( | ||
f"Input image <{self.inputs.in_file}> is {ndim}D " | ||
f"({'x'.join(['%d' % s for s in filenii.shape])})." | ||
) | ||
|
||
files_3d = nb.four_to_three(filenii) | ||
self._results["out_files"] = [] | ||
in_file = self.inputs.in_file | ||
for i, file_3d in enumerate(files_3d): | ||
out_file = str( | ||
Path(fname_presuffix(in_file, suffix=f"_idx-{i:03}")).absolute() | ||
) | ||
file_3d.to_filename(out_file) | ||
self._results["out_files"].append(out_file) | ||
|
||
return runtime | ||
|
||
|
||
class _MergeSeriesInputSpec(BaseInterfaceInputSpec): | ||
in_files = InputMultiObject( | ||
File(exists=True, mandatory=True, desc="input list of 3d images") | ||
) | ||
allow_4D = traits.Bool( | ||
True, usedefault=True, desc="whether 4D images are allowed to be concatenated" | ||
) | ||
|
||
oesteban marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
class _MergeSeriesOutputSpec(TraitedSpec): | ||
out_file = File(exists=True, desc="output 4d image") | ||
|
||
|
||
class MergeSeries(SimpleInterface): | ||
"""Merge a series of 3D volumes along the last dimension into a single 4D image.""" | ||
|
||
input_spec = _MergeSeriesInputSpec | ||
output_spec = _MergeSeriesOutputSpec | ||
|
||
def _run_interface(self, runtime): | ||
nii_list = [] | ||
for f in self.inputs.in_files: | ||
filenii = nb.squeeze_image(nb.load(f)) | ||
ndim = filenii.dataobj.ndim | ||
if ndim == 3: | ||
nii_list.append(filenii) | ||
continue | ||
elif self.inputs.allow_4D and ndim == 4: | ||
nii_list += nb.four_to_three(filenii) | ||
continue | ||
else: | ||
raise ValueError( | ||
"Input image has an incorrect number of dimensions" f" ({ndim})." | ||
) | ||
|
||
img_4d = nb.concat_images(nii_list) | ||
out_file = fname_presuffix(self.inputs.in_files[0], suffix="_merged") | ||
img_4d.to_filename(out_file) | ||
|
||
self._results["out_file"] = out_file | ||
return runtime |
Uh oh!
There was an error while loading. Please reload this page.