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: Pad or crop volume using scil_volume_reshape.py #1019

Merged
merged 10 commits into from
Oct 8, 2024
70 changes: 66 additions & 4 deletions scilpy/image/tests/test_volume_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
AntoineTheb marked this conversation as resolved.
Show resolved Hide resolved
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.])
Expand Down
75 changes: 75 additions & 0 deletions scilpy/image/volume_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

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

How hard would it be to have an option to be centered on the data's barycenter?
Would it be useful to allow to pad or crop even if the brain is not exactly center?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it could be a nice option but kind of out of scope for this PR. One way to do this without any modification would be to use scil_volume_crop, then pad to the desired shape.


# 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)

Expand Down
4 changes: 2 additions & 2 deletions scripts/legacy/scil_reshape_to_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand Down
21 changes: 21 additions & 0 deletions scripts/legacy/scil_volume_reshape_to_reference.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 8 additions & 0 deletions scripts/scil_volume_crop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand Down
8 changes: 8 additions & 0 deletions scripts/scil_volume_resample.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand Down
99 changes: 99 additions & 0 deletions scripts/scil_volume_reshape.py
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand Down
51 changes: 51 additions & 0 deletions scripts/tests/test_volume_reshape.py
Original file line number Diff line number Diff line change
@@ -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
Loading