Skip to content

Commit 4bd0027

Browse files
authored
Merge pull request #1082 from effigies/enh/image_dtype_arg
NF: Pass dtype to Analyze-like images at initialization/serialization, warn on creation of NIfTI images with 64-bit ints (API change)
2 parents 1a34056 + fa66516 commit 4bd0027

16 files changed

+206
-43
lines changed

nibabel/analyze.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -914,12 +914,15 @@ class AnalyzeImage(SpatialImage):
914914
ImageArrayProxy = ArrayProxy
915915

916916
def __init__(self, dataobj, affine, header=None,
917-
extra=None, file_map=None):
917+
extra=None, file_map=None, dtype=None):
918918
super(AnalyzeImage, self).__init__(
919919
dataobj, affine, header, extra, file_map)
920920
# Reset consumable values
921921
self._header.set_data_offset(0)
922922
self._header.set_slope_inter(None, None)
923+
924+
if dtype is not None:
925+
self.set_data_dtype(dtype)
923926
__init__.__doc__ = SpatialImage.__init__.__doc__
924927

925928
def get_data_dtype(self):
@@ -989,23 +992,29 @@ def _get_fileholders(file_map):
989992
"""
990993
return file_map['header'], file_map['image']
991994

992-
def to_file_map(self, file_map=None):
995+
def to_file_map(self, file_map=None, dtype=None):
993996
""" Write image to `file_map` or contained ``self.file_map``
994997
995998
Parameters
996999
----------
9971000
file_map : None or mapping, optional
9981001
files mapping. If None (default) use object's ``file_map``
9991002
attribute instead
1003+
dtype : dtype-like, optional
1004+
The on-disk data type to coerce the data array.
10001005
"""
10011006
if file_map is None:
10021007
file_map = self.file_map
10031008
data = np.asanyarray(self.dataobj)
10041009
self.update_header()
10051010
hdr = self._header
1006-
out_dtype = self.get_data_dtype()
10071011
# Store consumable values for later restore
10081012
offset = hdr.get_data_offset()
1013+
data_dtype = hdr.get_data_dtype()
1014+
# Override dtype conditionally
1015+
if dtype is not None:
1016+
hdr.set_data_dtype(dtype)
1017+
out_dtype = hdr.get_data_dtype()
10091018
# Scalars of slope, offset to get immutable values
10101019
slope = hdr['scl_slope'].item() if hdr.has_data_slope else np.nan
10111020
inter = hdr['scl_inter'].item() if hdr.has_data_intercept else np.nan
@@ -1045,6 +1054,7 @@ def to_file_map(self, file_map=None):
10451054
self.file_map = file_map
10461055
# Restore any changed consumable values
10471056
hdr.set_data_offset(offset)
1057+
hdr.set_data_dtype(data_dtype)
10481058
if hdr.has_data_slope:
10491059
hdr['scl_slope'] = slope
10501060
if hdr.has_data_intercept:

nibabel/arrayproxy.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,17 @@ def reshape_dataobj(obj, shape):
412412
"""
413413
return (obj.reshape(shape) if hasattr(obj, 'reshape')
414414
else np.reshape(obj, shape))
415+
416+
417+
def get_obj_dtype(obj):
418+
""" Get the effective dtype of an array-like object """
419+
if is_proxy(obj):
420+
# Read and potentially apply scaling to one value
421+
idx = (0,) * len(obj.shape)
422+
return obj[idx].dtype
423+
elif hasattr(obj, "dtype"):
424+
# Trust the dtype (probably an ndarray)
425+
return obj.dtype
426+
else:
427+
# Coerce; this could be expensive but we don't know what we can do with it
428+
return np.asanyarray(obj).dtype

nibabel/cifti2/cifti2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,7 +1460,7 @@ def from_image(klass, img):
14601460
return img
14611461
raise NotImplementedError
14621462

1463-
def to_file_map(self, file_map=None):
1463+
def to_file_map(self, file_map=None, dtype=None):
14641464
""" Write image to `file_map` or contained ``self.file_map``
14651465
14661466
Parameters
@@ -1493,7 +1493,7 @@ def to_file_map(self, file_map=None):
14931493
# If qform not set, reset pixdim values so Nifti2 does not complain
14941494
if header['qform_code'] == 0:
14951495
header['pixdim'][:4] = 1
1496-
img = Nifti2Image(data, None, header)
1496+
img = Nifti2Image(data, None, header, dtype=dtype)
14971497
img.to_file_map(file_map or self.file_map)
14981498

14991499
def update_headers(self):

nibabel/filebasedimages.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -299,24 +299,26 @@ def filespec_to_file_map(klass, filespec):
299299
file_map[key] = FileHolder(filename=fname)
300300
return file_map
301301

302-
def to_filename(self, filename):
303-
""" Write image to files implied by filename string
302+
def to_filename(self, filename, **kwargs):
303+
r""" Write image to files implied by filename string
304304
305305
Parameters
306306
----------
307307
filename : str or os.PathLike
308308
filename to which to save image. We will parse `filename`
309309
with ``filespec_to_file_map`` to work out names for image,
310310
header etc.
311+
\*\*kwargs : keyword arguments
312+
Keyword arguments to format-specific save
311313
312314
Returns
313315
-------
314316
None
315317
"""
316318
self.file_map = self.filespec_to_file_map(filename)
317-
self.to_file_map()
319+
self.to_file_map(**kwargs)
318320

319-
def to_file_map(self, file_map=None):
321+
def to_file_map(self, file_map=None, **kwargs):
320322
raise NotImplementedError
321323

322324
@classmethod
@@ -552,13 +554,14 @@ def from_bytes(klass, bytestring):
552554
file_map = klass.make_file_map({'image': bio, 'header': bio})
553555
return klass.from_file_map(file_map)
554556

555-
def to_bytes(self):
556-
""" Return a ``bytes`` object with the contents of the file that would
557+
def to_bytes(self, **kwargs):
558+
r""" Return a ``bytes`` object with the contents of the file that would
557559
be written if the image were saved.
558560
559561
Parameters
560562
----------
561-
None
563+
\*\*kwargs : keyword arguments
564+
Keyword arguments that may be passed to ``img.to_file_map()``
562565
563566
Returns
564567
-------
@@ -569,5 +572,5 @@ def to_bytes(self):
569572
raise NotImplementedError("to_bytes() is undefined for multi-file images")
570573
bio = io.BytesIO()
571574
file_map = self.make_file_map({'image': bio, 'header': bio})
572-
self.to_file_map(file_map)
575+
self.to_file_map(file_map, **kwargs)
573576
return bio.getvalue()

nibabel/funcs.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def squeeze_image(img):
3535
--------
3636
>>> import nibabel as nf
3737
>>> shape = (10,20,30,1,1)
38-
>>> data = np.arange(np.prod(shape)).reshape(shape)
38+
>>> data = np.arange(np.prod(shape), dtype='int32').reshape(shape)
3939
>>> affine = np.eye(4)
4040
>>> img = nf.Nifti1Image(data, affine)
4141
>>> img.shape == (10, 20, 30, 1, 1)
@@ -47,7 +47,7 @@ def squeeze_image(img):
4747
If the data are 3D then last dimensions of 1 are ignored
4848
4949
>>> shape = (10,1,1)
50-
>>> data = np.arange(np.prod(shape)).reshape(shape)
50+
>>> data = np.arange(np.prod(shape), dtype='int32').reshape(shape)
5151
>>> img = nf.ni1.Nifti1Image(data, affine)
5252
>>> img.shape == (10, 1, 1)
5353
True

nibabel/loadsave.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,17 @@ def guessed_image_type(filename):
130130
raise ImageFileError(f'Cannot work out file type of "{filename}"')
131131

132132

133-
def save(img, filename):
134-
""" Save an image to file adapting format to `filename`
133+
def save(img, filename, **kwargs):
134+
r""" Save an image to file adapting format to `filename`
135135
136136
Parameters
137137
----------
138138
img : ``SpatialImage``
139139
image to save
140140
filename : str or os.PathLike
141141
filename (often implying filenames) to which to save `img`.
142+
\*\*kwargs : keyword arguments
143+
Keyword arguments to format-specific save
142144
143145
Returns
144146
-------
@@ -148,7 +150,7 @@ def save(img, filename):
148150

149151
# Save the type as expected
150152
try:
151-
img.to_filename(filename)
153+
img.to_filename(filename, **kwargs)
152154
except ImageFileError:
153155
pass
154156
else:
@@ -196,7 +198,7 @@ def save(img, filename):
196198
# Here, we either have a klass or a converted image.
197199
if converted is None:
198200
converted = klass.from_image(img)
199-
converted.to_filename(filename)
201+
converted.to_filename(filename, **kwargs)
200202

201203

202204
@deprecate_with_version('read_img_data deprecated. '

nibabel/nifti1.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import numpy.linalg as npl
1818
from numpy.compat.py3k import asstr
1919

20+
from .arrayproxy import get_obj_dtype
2021
from .optpkg import optional_package
2122
from .filebasedimages import SerializableImage
2223
from .volumeutils import Recoder, make_dt_codes, endian_codes
@@ -881,6 +882,51 @@ def set_data_shape(self, shape):
881882
shape = (-1, 1, 1) + shape[3:]
882883
super(Nifti1Header, self).set_data_shape(shape)
883884

885+
def set_data_dtype(self, datatype):
886+
""" Set numpy dtype for data from code or dtype or type
887+
888+
Using :py:class:`int` or ``"int"`` is disallowed, as these types
889+
will be interpreted as ``np.int64``, which is almost never desired.
890+
``np.int64`` is permitted for those intent on making poor choices.
891+
892+
Examples
893+
--------
894+
>>> hdr = Nifti1Header()
895+
>>> hdr.set_data_dtype(np.uint8)
896+
>>> hdr.get_data_dtype()
897+
dtype('uint8')
898+
>>> hdr.set_data_dtype(np.dtype(np.uint8))
899+
>>> hdr.get_data_dtype()
900+
dtype('uint8')
901+
>>> hdr.set_data_dtype('implausible') #doctest: +IGNORE_EXCEPTION_DETAIL
902+
Traceback (most recent call last):
903+
...
904+
HeaderDataError: data dtype "implausible" not recognized
905+
>>> hdr.set_data_dtype('none') #doctest: +IGNORE_EXCEPTION_DETAIL
906+
Traceback (most recent call last):
907+
...
908+
HeaderDataError: data dtype "none" known but not supported
909+
>>> hdr.set_data_dtype(np.void) #doctest: +IGNORE_EXCEPTION_DETAIL
910+
Traceback (most recent call last):
911+
...
912+
HeaderDataError: data dtype "<type 'numpy.void'>" known but not supported
913+
>>> hdr.set_data_dtype('int') #doctest: +IGNORE_EXCEPTION_DETAIL
914+
Traceback (most recent call last):
915+
...
916+
ValueError: Invalid data type 'int'. Specify a sized integer, e.g., 'uint8' or numpy.int16.
917+
>>> hdr.set_data_dtype(int) #doctest: +IGNORE_EXCEPTION_DETAIL
918+
Traceback (most recent call last):
919+
...
920+
ValueError: Invalid data type 'int'. Specify a sized integer, e.g., 'uint8' or numpy.int16.
921+
>>> hdr.set_data_dtype('int64')
922+
>>> hdr.get_data_dtype() == np.dtype('int64')
923+
True
924+
"""
925+
if not isinstance(datatype, np.dtype) and datatype in (int, "int"):
926+
raise ValueError(f"Invalid data type {datatype!r}. Specify a sized integer, "
927+
"e.g., 'uint8' or numpy.int16.")
928+
super().set_data_dtype(datatype)
929+
884930
def get_qform_quaternion(self):
885931
""" Compute quaternion from b, c, d of quaternion
886932
@@ -1754,12 +1800,27 @@ class Nifti1Pair(analyze.AnalyzeImage):
17541800
rw = True
17551801

17561802
def __init__(self, dataobj, affine, header=None,
1757-
extra=None, file_map=None):
1803+
extra=None, file_map=None, dtype=None):
1804+
# Special carve-out for 64 bit integers
1805+
# See GitHub issues
1806+
# * https://github.com/nipy/nibabel/issues/1046
1807+
# * https://github.com/nipy/nibabel/issues/1089
1808+
# This only applies to NIfTI because the parent Analyze formats did
1809+
# not support 64-bit integer data, so `set_data_dtype(int64)` would
1810+
# already fail.
1811+
danger_dts = (np.dtype("int64"), np.dtype("uint64"))
1812+
if header is None and dtype is None and get_obj_dtype(dataobj) in danger_dts:
1813+
msg = (f"Image data has type {dataobj.dtype}, which may cause "
1814+
"incompatibilities with other tools. This will error in "
1815+
"NiBabel 5.0. This warning can be silenced "
1816+
f"by passing the dtype argument to {self.__class__.__name__}().")
1817+
warnings.warn(msg, FutureWarning, stacklevel=2)
17581818
super(Nifti1Pair, self).__init__(dataobj,
17591819
affine,
17601820
header,
17611821
extra,
1762-
file_map)
1822+
file_map,
1823+
dtype)
17631824
# Force set of s/q form when header is None unless affine is also None
17641825
if header is None and affine is not None:
17651826
self._affine2header()
@@ -1865,7 +1926,7 @@ def set_qform(self, affine, code=None, strip_shears=True, **kwargs):
18651926
18661927
Examples
18671928
--------
1868-
>>> data = np.arange(24).reshape((2,3,4))
1929+
>>> data = np.arange(24, dtype='f4').reshape((2,3,4))
18691930
>>> aff = np.diag([2, 3, 4, 1])
18701931
>>> img = Nifti1Pair(data, aff)
18711932
>>> img.get_qform()
@@ -1948,7 +2009,7 @@ def set_sform(self, affine, code=None, **kwargs):
19482009
19492010
Examples
19502011
--------
1951-
>>> data = np.arange(24).reshape((2,3,4))
2012+
>>> data = np.arange(24, dtype='f4').reshape((2,3,4))
19522013
>>> aff = np.diag([2, 3, 4, 1])
19532014
>>> img = Nifti1Pair(data, aff)
19542015
>>> img.get_sform()

nibabel/spm99analyze.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ def from_file_map(klass, file_map, *, mmap=True, keep_file_open=None):
308308
ret._affine = np.dot(ret._affine, to_111)
309309
return ret
310310

311-
def to_file_map(self, file_map=None):
311+
def to_file_map(self, file_map=None, dtype=None):
312312
""" Write image to `file_map` or contained ``self.file_map``
313313
314314
Extends Analyze ``to_file_map`` method by writing ``mat`` file
@@ -321,7 +321,7 @@ def to_file_map(self, file_map=None):
321321
"""
322322
if file_map is None:
323323
file_map = self.file_map
324-
super(Spm99AnalyzeImage, self).to_file_map(file_map)
324+
super(Spm99AnalyzeImage, self).to_file_map(file_map, dtype=dtype)
325325
mat = self._affine
326326
if mat is None:
327327
return

nibabel/tests/test_analyze.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,12 @@ def assert_set_dtype(dt_spec, np_dtype):
290290
# Test aliases to Python types
291291
assert_set_dtype(float, np.float64) # float64 always supported
292292
np_sys_int = np.dtype(int).type # int could be 32 or 64 bit
293-
if np_sys_int in self.supported_np_types: # no int64 for Analyze
293+
if issubclass(self.header_class, Nifti1Header):
294+
# We don't allow int aliases in Nifti
295+
with pytest.raises(ValueError):
296+
hdr = self.header_class()
297+
hdr.set_data_dtype(int)
298+
elif np_sys_int in self.supported_np_types: # no int64 for Analyze
294299
assert_set_dtype(int, np_sys_int)
295300
hdr = self.header_class()
296301
for inp in all_unsupported_types:
@@ -759,6 +764,20 @@ def test_affine_44(self):
759764
with pytest.raises(ValueError):
760765
IC(data, np.diag([2, 3, 4]))
761766

767+
def test_dtype_init_arg(self):
768+
# data_dtype can be set by argument in absence of header
769+
img_klass = self.image_class
770+
arr = np.arange(24, dtype=np.int16).reshape((2, 3, 4))
771+
aff = np.eye(4)
772+
for dtype in self.supported_np_types:
773+
img = img_klass(arr, aff, dtype=dtype)
774+
assert img.get_data_dtype() == dtype
775+
# It can also override the header dtype
776+
hdr = img.header
777+
for dtype in self.supported_np_types:
778+
img = img_klass(arr, aff, hdr, dtype=dtype)
779+
assert img.get_data_dtype() == dtype
780+
762781
def test_offset_to_zero(self):
763782
# Check offset is always set to zero when creating images
764783
img_klass = self.image_class
@@ -873,6 +892,21 @@ def test_no_finite_values(self):
873892
img_back = self.image_class.from_file_map(fm)
874893
assert_array_equal(img_back.dataobj, 0)
875894

895+
def test_dtype_to_filename_arg(self):
896+
# data_dtype can be set by argument in absence of header
897+
img_klass = self.image_class
898+
arr = np.arange(24, dtype=np.int16).reshape((2, 3, 4))
899+
aff = np.eye(4)
900+
img = img_klass(arr, aff)
901+
fname = 'test' + img_klass.files_types[0][1]
902+
with InTemporaryDirectory():
903+
for dtype in self.supported_np_types:
904+
img.to_filename(fname, dtype=dtype)
905+
new_img = img_klass.from_filename(fname)
906+
assert new_img.get_data_dtype() == dtype
907+
# data_type is reset after write
908+
assert img.get_data_dtype() == np.int16
909+
876910

877911
def test_unsupported():
878912
# analyze does not support uint32

0 commit comments

Comments
 (0)