diff --git a/scilpy/image/tests/test_volume_operations.py b/scilpy/image/tests/test_volume_operations.py index 3772167e7..f9e73849c 100644 --- a/scilpy/image/tests/test_volume_operations.py +++ b/scilpy/image/tests/test_volume_operations.py @@ -9,12 +9,13 @@ from numpy.testing import assert_equal, assert_almost_equal from scilpy import SCILPY_HOME -from scilpy.image.volume_operations import (apply_transform, compute_snr, +from scilpy.image.volume_operations import (apply_transform, + compute_distance_map, compute_snr, crop_volume, flip_volume, - merge_metrics, normalize_metric, - resample_volume, register_image, mask_data_with_default_cube, - compute_distance_map) + merge_metrics, normalize_metric, + resample_volume, reshape_volume, + register_image) from scilpy.io.fetcher import fetch_data, get_testing_files_dict from scilpy.image.utils import compute_nifti_bounding_box @@ -202,6 +203,67 @@ def test_resample_volume(): assert resampled_img.affine[0, 0] == 3 +def test_reshape_volume_pad(): + # 3D img + img = nib.Nifti1Image( + np.arange(1, (3**3)+1).reshape((3, 3, 3)).astype(float), + np.eye(4)) + + # 1) Reshaping to 4x4x4, padding with 0 + reshaped_img = reshape_volume(img, (4, 4, 4)) + + assert_equal(reshaped_img.affine[:, -1], [-1, -1, -1, 1]) + assert_equal(reshaped_img.get_fdata()[0, 0, 0], 0) + + # 2) Reshaping to 4x4x4, padding with -1 + reshaped_img = reshape_volume(img, (4, 4, 4), mode='constant', + cval=-1) + assert_equal(reshaped_img.get_fdata()[0, 0, 0], -1) + + # 3) Reshaping to 4x4x4, padding with edge + reshaped_img = reshape_volume(img, (4, 4, 4), mode='edge') + assert_equal(reshaped_img.get_fdata()[0, 0, 0], 1) + + # 4D img (2 "stacked" 3D volumes) + img = nib.Nifti1Image( + np.arange(1, ((3**3) * 2)+1).reshape((3, 3, 3, 2)).astype(float), + np.eye(4)) + + # 2) Reshaping to 5x5x5, padding with 0 + reshaped_img = reshape_volume(img, (5, 5, 5)) + assert_equal(reshaped_img.get_fdata()[0, 0, 0, 0], 0) + + +def test_reshape_volume_crop(): + # 3D img + img = nib.Nifti1Image( + np.arange(1, (3**3)+1).reshape((3, 3, 3)).astype(float), + np.eye(4)) + + # 1) Cropping to 1x1x1 + reshaped_img = reshape_volume(img, (1, 1, 1)) + assert_equal(reshaped_img.get_fdata().shape, (1, 1, 1)) + assert_equal(reshaped_img.affine[:, -1], [1, 1, 1, 1]) + assert_equal(reshaped_img.get_fdata()[0, 0, 0], 14) + + # 2) Cropping to 2x2x2 + reshaped_img = reshape_volume(img, (2, 2, 2)) + assert_equal(reshaped_img.get_fdata().shape, (2, 2, 2)) + assert_equal(reshaped_img.affine[:, -1], [0, 0, 0, 1]) + assert_equal(reshaped_img.get_fdata()[0, 0, 0], 1) + + # 4D img + img = nib.Nifti1Image( + np.arange(1, ((3**3) * 2)+1).reshape((3, 3, 3, 2)).astype(float), + np.eye(4)) + + # 2) Cropping to 2x2x2 + reshaped_img = reshape_volume(img, (2, 2, 2)) + assert_equal(reshaped_img.get_fdata().shape, (2, 2, 2, 2)) + assert_equal(reshaped_img.affine[:, -1], [0, 0, 0, 1]) + assert_equal(reshaped_img.get_fdata()[0, 0, 0, 0], 1) + + def test_normalize_metric_basic(): metric = np.array([1, 2, 3, 4, 5]) expected_output = np.array([0., 0.25, 0.5, 0.75, 1.]) diff --git a/scilpy/image/volume_operations.py b/scilpy/image/volume_operations.py index 56b2731e1..5075d37d0 100644 --- a/scilpy/image/volume_operations.py +++ b/scilpy/image/volume_operations.py @@ -604,6 +604,81 @@ def resample_volume(img, ref_img=None, volume_shape=None, iso_min=False, return nib.Nifti1Image(data2.astype(data.dtype), affine2) +def reshape_volume( + img, volume_shape, mode='constant', cval=0 +): + """ Reshape a volume to a specified shape by padding or cropping. The + new volume is centered wrt the old volume in world space. + + Parameters + ---------- + img : nib.Nifti1Image + The input image. + volume_shape : tuple of 3 ints + The desired shape of the volume. + mode : str, optional + Padding mode. See np.pad for more information. Default is 'constant'. + cval: float, optional + Value to use for padding when mode is 'constant'. Default is 0. + + Returns + ------- + reshaped_img : nib.Nifti1Image + The reshaped image. + """ + + data = img.get_fdata(dtype=np.float32) + affine = img.affine + + # Compute the difference between the desired shape and the current shape + diff = (np.array(volume_shape) - np.array(data.shape[:3])) // 2 + + # Compute the offset to center the data + offset = (np.array(volume_shape) - np.array(data.shape[:3])) % 2 + + # Compute the padding values (before and after) for all axes + pad_width = np.zeros((len(data.shape), 2), dtype=int) + for i in range(3): + pad_width[i, 0] = int(max(0, diff[i] + offset[i])) + pad_width[i, 1] = int(max(0, diff[i])) + + # If dealing with 4D data, do not pad the last dimension + if len(data.shape) == 4: + pad_width[3, :] = [0, 0] + + # Pad the data + kwargs = { + 'mode': mode, + } + # Add constant_values only if mode is 'constant' + # Otherwise, it will raise an error + if mode == 'constant': + kwargs['constant_values'] = cval + padded_data = np.pad(data, pad_width, **kwargs) + + # Compute the cropping values (before and after) for all axes + crop_width = np.zeros((len(data.shape), 2)) + for i in range(3): + crop_width[i, 0] = -diff[i] - offset[i] + crop_width[i, 1] = np.ceil(padded_data.shape[i] + diff[i]) + + # If dealing with 4D data, do not crop the last dimension + if len(data.shape) == 4: + crop_width[3, :] = [0, data.shape[3]] + + # Crop the data + cropped_data = crop( + padded_data, np.maximum(0, crop_width[:, 0]).astype(int), + crop_width[:, 1].astype(int)) + + # Compute the new affine + translation = voxel_to_world(crop_width[:, 0], affine) + new_affine = np.copy(affine) + new_affine[0:3, 3] = translation[0:3] + + return nib.Nifti1Image(cropped_data, new_affine) + + def mask_data_with_default_cube(data): """Masks data outside a default cube (Cube: data.shape/3 centered) diff --git a/scripts/legacy/scil_reshape_to_reference.py b/scripts/legacy/scil_reshape_to_reference.py index 1caf858f8..f460081c8 100755 --- a/scripts/legacy/scil_reshape_to_reference.py +++ b/scripts/legacy/scil_reshape_to_reference.py @@ -2,11 +2,11 @@ # -*- coding: utf-8 -*- from scilpy.io.deprecator import deprecate_script -from scripts.scil_volume_reshape_to_reference import main as new_main +from scripts.scil_volume_reslice_to_reference import main as new_main DEPRECATION_MSG = """ -This script has been renamed scil_volume_reshape_to_reference.py. +This script has been renamed scil_volume_reslice_to_reference.py. Please change your existing pipelines accordingly. """ diff --git a/scripts/legacy/scil_volume_reshape_to_reference.py b/scripts/legacy/scil_volume_reshape_to_reference.py new file mode 100755 index 000000000..1df623cf2 --- /dev/null +++ b/scripts/legacy/scil_volume_reshape_to_reference.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from scilpy.io.deprecator import deprecate_script +from scripts.scil_volume_reslice_to_reference import main as new_main + + +DEPRECATION_MSG = """ +This script has been renamed scil_volume_reslice_to_reference.py. +Please change your existing pipelines accordingly. +""" + + +@deprecate_script("scil_volume_reshape_to_reference.py", + DEPRECATION_MSG, '2.0.0') +def main(): + new_main() + + +if __name__ == "__main__": + main() diff --git a/scripts/scil_volume_crop.py b/scripts/scil_volume_crop.py index 74aadfd19..1f8ffb657 100755 --- a/scripts/scil_volume_crop.py +++ b/scripts/scil_volume_crop.py @@ -10,6 +10,14 @@ it's looking for non-zero data. Therefore, you should validate the results on other types of images that haven't been masked. +To: + - interpolate/reslice to an arbitrary voxel size, use + scil_volume_resample.py. + - pad or crop the volume to match the desired shape, use + scil_volume_reshape.py. + - reshape a volume to match the resolution of another, use + scil_volume_reslice_to_reference.py. + Formerly: scil_crop_volume.py """ diff --git a/scripts/scil_volume_resample.py b/scripts/scil_volume_resample.py index d8ac113fa..cf1f74d64 100755 --- a/scripts/scil_volume_resample.py +++ b/scripts/scil_volume_resample.py @@ -5,6 +5,14 @@ Script to resample a dataset to match the resolution of another reference dataset or to the resolution specified as in argument. +This script will reslice the volume to match the desired shape. + +To: + - pad or crop the volume to match the desired shape, use + scil_volume_reshape.py. + - reslice a volume to match the shape of another, use + scil_volume_reslice_to_reference.py. + - crop a volume to remove empty space, use scil_volume_crop.py. Formerly: scil_resample_volume.py """ diff --git a/scripts/scil_volume_reshape.py b/scripts/scil_volume_reshape.py new file mode 100644 index 000000000..cb2a4f722 --- /dev/null +++ b/scripts/scil_volume_reshape.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Script to reshape a volume to match the resolution of another +reference volume or to the resolution specified as in argument. The resulting +volume will be centered in world space with respect to the reference volume or +the specified resolution. + +This script will pad or crop the volume to match the desired shape. +To + - interpolate/reslice to an arbitrary voxel size, use + scil_volume_resample.py. + - reslice a volume to match the shape of another, use + scil_volume_reslice_to_reference.py. + - crop a volume to constrain the field of view, use scil_volume_crop.py. +""" + +import argparse +import logging + +import nibabel as nib + +from scilpy.io.utils import (add_verbose_arg, add_overwrite_arg, + assert_inputs_exist, assert_outputs_exist) +from scilpy.image.volume_operations import reshape_volume + + +def _build_arg_parser(): + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawTextHelpFormatter) + + p.add_argument('in_image', + help='Path of the input volume.') + p.add_argument('out_image', + help='Path of the resampled volume.') + + res_group = p.add_mutually_exclusive_group(required=True) + res_group.add_argument( + '--ref', + help='Reference volume to resample to.') + res_group.add_argument( + '--volume_size', nargs='+', type=int, + help='Sets the size for the volume. If the value is set to is Y, ' + 'it will resample to a shape of Y x Y x Y.') + + p.add_argument( + '--mode', default='constant', + choices=['constant', 'edge', 'wrap', 'reflect'], + help="Padding mode.\nconstant: pads with a constant value.\n" + "edge: repeats the edge value.\nDefaults to [%(default)s].") + p.add_argument('--constant_value', type=float, default=0, + help='Value to use for padding when mode is constant.') + + add_verbose_arg(p) + add_overwrite_arg(p) + + return p + + +def main(): + parser = _build_arg_parser() + args = parser.parse_args() + logging.getLogger().setLevel(logging.getLevelName(args.verbose)) + + # Checking args + assert_inputs_exist(parser, args.in_image, args.ref) + assert_outputs_exist(parser, args, args.out_image) + + if args.volume_size and (not len(args.volume_size) == 1 and + not len(args.volume_size) == 3): + parser.error('--volume_size takes in either 1 or 3 arguments.') + + logging.info('Loading raw data from %s', args.in_image) + + img = nib.load(args.in_image) + + ref_img = None + if args.ref: + ref_img = nib.load(args.ref) + volume_shape = ref_img.shape[:3] + else: + if len(args.volume_size) == 1: + volume_shape = [args.volume_size[0]] * 3 + else: + volume_shape = args.volume_size + + # Resampling volume + reshaped_img = reshape_volume(img, volume_shape, + mode=args.mode, + cval=args.constant_value) + + # Saving results + logging.info('Saving reshaped data to %s', args.out_image) + nib.save(reshaped_img, args.out_image) + + +if __name__ == '__main__': + main() diff --git a/scripts/scil_volume_reshape_to_reference.py b/scripts/scil_volume_reslice_to_reference.py similarity index 89% rename from scripts/scil_volume_reshape_to_reference.py rename to scripts/scil_volume_reslice_to_reference.py index bb96a0775..36bdc1d56 100755 --- a/scripts/scil_volume_reshape_to_reference.py +++ b/scripts/scil_volume_reslice_to_reference.py @@ -9,6 +9,13 @@ >>> scil_volume_reshape_to_reference.py wmparc.mgz t1.nii.gz wmparc_t1.nii.gz\\ --interpolation nearest +To + - pad or crop the volume to match the desired shape, use + scil_volume_reshape.py. + - interpolate/reslice to an arbitrary voxel size, use + scil_volume_resample.py. + - crop a volume to remove empty space, use scil_volume_crop.py. + Formerly: scil_reshape_to_reference.py """ diff --git a/scripts/tests/test_volume_reshape.py b/scripts/tests/test_volume_reshape.py new file mode 100644 index 000000000..1a7755657 --- /dev/null +++ b/scripts/tests/test_volume_reshape.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import tempfile + +from scilpy import SCILPY_HOME +from scilpy.io.fetcher import fetch_data, get_testing_files_dict + +# If they already exist, this only takes 5 seconds (check md5sum) +fetch_data(get_testing_files_dict(), keys=['others.zip']) +tmp_dir = tempfile.TemporaryDirectory() +in_img = os.path.join(SCILPY_HOME, 'others', 'fa.nii.gz') +# fa.nii.gz has a size of 111x133x109 + + +def test_help_option(script_runner): + ret = script_runner.run('scil_volume_reshape.py', '--help') + assert ret.success + + +def test_execution_crop(script_runner, monkeypatch): + monkeypatch.chdir(os.path.expanduser(tmp_dir.name)) + ret = script_runner.run('scil_volume_reshape.py', in_img, + 'fa_reshape.nii.gz', '--volume_size', '90', + '-f') + assert ret.success + + +def test_execution_pad(script_runner, monkeypatch): + monkeypatch.chdir(os.path.expanduser(tmp_dir.name)) + ret = script_runner.run('scil_volume_reshape.py', in_img, + 'fa_reshape.nii.gz', '--volume_size', '150', + '-f') + assert ret.success + + +def test_execution_full_size(script_runner, monkeypatch): + monkeypatch.chdir(os.path.expanduser(tmp_dir.name)) + ret = script_runner.run('scil_volume_reshape.py', in_img, + 'fa_reshape.nii.gz', '--volume_size', + '164', '164', '164', '-f') + assert ret.success + + +def test_execution_ref(script_runner, monkeypatch): + monkeypatch.chdir(os.path.expanduser(tmp_dir.name)) + ref = os.path.join(SCILPY_HOME, 'others', 'fa_resample.nii.gz') + ret = script_runner.run('scil_volume_reshape.py', in_img, + 'fa_reshape.nii.gz', '--ref', ref, '-f') + assert ret.success diff --git a/scripts/tests/test_volume_reshape_to_reference.py b/scripts/tests/test_volume_reslice_to_reference.py similarity index 78% rename from scripts/tests/test_volume_reshape_to_reference.py rename to scripts/tests/test_volume_reslice_to_reference.py index c4debefb7..9c62c48f0 100644 --- a/scripts/tests/test_volume_reshape_to_reference.py +++ b/scripts/tests/test_volume_reslice_to_reference.py @@ -13,7 +13,7 @@ def test_help_option(script_runner): - ret = script_runner.run('scil_volume_reshape_to_reference.py', '--help') + ret = script_runner.run('scil_volume_reslice_to_reference.py', '--help') assert ret.success @@ -21,8 +21,8 @@ def test_execution_others(script_runner, monkeypatch): monkeypatch.chdir(os.path.expanduser(tmp_dir.name)) in_img = os.path.join(SCILPY_HOME, 'others', 't1_crop.nii.gz') in_ref = os.path.join(SCILPY_HOME, 'others', 't1.nii.gz') - ret = script_runner.run('scil_volume_reshape_to_reference.py', in_img, - in_ref, 't1_reshape.nii.gz', + ret = script_runner.run('scil_volume_reslice_to_reference.py', in_img, + in_ref, 't1_reslice.nii.gz', '--interpolation', 'nearest') assert ret.success @@ -31,7 +31,7 @@ def test_execution_4D(script_runner, monkeypatch): monkeypatch.chdir(os.path.expanduser(tmp_dir.name)) in_img = os.path.join(SCILPY_HOME, 'commit_amico', 'dwi.nii.gz') in_ref = os.path.join(SCILPY_HOME, 'others', 't1.nii.gz') - ret = script_runner.run('scil_volume_reshape_to_reference.py', in_img, - in_ref, 'dwi_reshape.nii.gz', + ret = script_runner.run('scil_volume_reslice_to_reference.py', in_img, + in_ref, 'dwi_reslice.nii.gz', '--interpolation', 'nearest') assert ret.success