From bb64f19bd523e3943d5154b8ab30c81d940e1326 Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Mon, 2 Jul 2018 15:19:40 -0400 Subject: [PATCH 01/15] ENH: Add FileBasedImage.serialize() --- nibabel/filebasedimages.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nibabel/filebasedimages.py b/nibabel/filebasedimages.py index 7cc5b10648..2726e8afe2 100644 --- a/nibabel/filebasedimages.py +++ b/nibabel/filebasedimages.py @@ -511,3 +511,9 @@ def path_maybe_image(klass, filename, sniff=None, sniff_max=1024): if sniff is None or len(sniff[0]) < klass._meta_sniff_len: return False, sniff return klass.header_class.may_contain_header(sniff[0]), sniff + + def serialize(self): + bio = io.BytesIO() + file_map = self.make_file_map({'image': bio, 'header': bio}) + self.to_file_map(file_map) + return bio.getvalue() From 75ed05ad2e20544050ec65179dfa0c1ab9f9fe30 Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Tue, 17 Jul 2018 16:14:14 -0400 Subject: [PATCH 02/15] DOC: FileBasedImage.serialize.__doc__ --- nibabel/filebasedimages.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/nibabel/filebasedimages.py b/nibabel/filebasedimages.py index 2726e8afe2..1058bdbc24 100644 --- a/nibabel/filebasedimages.py +++ b/nibabel/filebasedimages.py @@ -513,6 +513,18 @@ def path_maybe_image(klass, filename, sniff=None, sniff_max=1024): return klass.header_class.may_contain_header(sniff[0]), sniff def serialize(self): + """ Return a ``bytes`` object with the contents of the file that would + be written if the image were saved. + + Parameters + ---------- + None + + Returns + ------- + bytes + Serialized image + """ bio = io.BytesIO() file_map = self.make_file_map({'image': bio, 'header': bio}) self.to_file_map(file_map) From c278a94ffee8af45e2e333134b2547166dc092ef Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Tue, 17 Jul 2018 16:39:04 -0400 Subject: [PATCH 03/15] FIX: Import io --- nibabel/filebasedimages.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nibabel/filebasedimages.py b/nibabel/filebasedimages.py index 1058bdbc24..470afe5204 100644 --- a/nibabel/filebasedimages.py +++ b/nibabel/filebasedimages.py @@ -8,6 +8,7 @@ ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## ''' Common interface for any image format--volume or surface, binary or xml.''' +import io from copy import deepcopy from six import string_types from .fileholders import FileHolder From ca60a1946a416789deda245a3029a69e5c20e023 Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Tue, 17 Jul 2018 16:39:29 -0400 Subject: [PATCH 04/15] ENH: Test serialize via an API test mixin --- nibabel/tests/test_filebasedimages.py | 7 ++++--- nibabel/tests/test_image_api.py | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/nibabel/tests/test_filebasedimages.py b/nibabel/tests/test_filebasedimages.py index 9a6f8b3db7..0758ed0f5d 100644 --- a/nibabel/tests/test_filebasedimages.py +++ b/nibabel/tests/test_filebasedimages.py @@ -5,9 +5,9 @@ import numpy as np -from nibabel.filebasedimages import FileBasedHeader, FileBasedImage +from ..filebasedimages import FileBasedHeader, FileBasedImage -from nibabel.tests.test_image_api import GenericImageAPI +from .test_image_api import GenericImageAPI, SerializeMixin from nose.tools import (assert_true, assert_false, assert_equal, assert_not_equal) @@ -50,7 +50,8 @@ def set_data_dtype(self, dtype): self.arr = self.arr.astype(dtype) -class TestFBImageAPI(GenericImageAPI): +class TestFBImageAPI(GenericImageAPI, + SerializeMixin): """ Validation for FileBasedImage instances """ # A callable returning an image from ``image_maker(data, header)`` diff --git a/nibabel/tests/test_image_api.py b/nibabel/tests/test_image_api.py index ad8ff1c7f6..a65ee0cc61 100644 --- a/nibabel/tests/test_image_api.py +++ b/nibabel/tests/test_image_api.py @@ -493,6 +493,19 @@ def validate_affine_deprecated(self, imaker, params): assert_true(aff is img.get_affine()) +class SerializeMixin(object): + + def validate_serialize(self, imaker, params): + img = imaker() + serialized = img.serialize() + with InTemporaryDirectory(): + fname = 'img' + self.standard_extension + img.to_filename(fname) + with open(fname, 'rb') as fobj: + file_contents = fobj.read() + assert serialized == file_contents + + class LoadImageAPI(GenericImageAPI, DataInterfaceMixin, AffineMixin, @@ -613,7 +626,7 @@ class TestNifti1PairAPI(TestSpm99AnalyzeAPI): can_save = True -class TestNifti1API(TestNifti1PairAPI): +class TestNifti1API(TestNifti1PairAPI, SerializeMixin): klass = image_maker = Nifti1Image standard_extension = '.nii' @@ -660,7 +673,7 @@ def loader(self, fname): # standard_extension = '.v' -class TestMGHAPI(ImageHeaderAPI): +class TestMGHAPI(ImageHeaderAPI, SerializeMixin): klass = image_maker = MGHImage example_shapes = ((2, 3, 4), (2, 3, 4, 5)) # MGH can only do >= 3D has_scaling = True From 38fd9fc1497b683d03a6dd7ce1aef0ba58f59c8e Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Tue, 7 Aug 2018 10:46:51 -0400 Subject: [PATCH 05/15] RF: Rename serialize to to_bytes, add from_bytes --- nibabel/filebasedimages.py | 51 ++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/nibabel/filebasedimages.py b/nibabel/filebasedimages.py index 470afe5204..d6ca4d810d 100644 --- a/nibabel/filebasedimages.py +++ b/nibabel/filebasedimages.py @@ -260,6 +260,21 @@ def from_filename(klass, filename): file_map = klass.filespec_to_file_map(filename) return klass.from_file_map(file_map) + @classmethod + def from_bytes(klass, bstring): + """ Construct image from a byte string + + Class method + + Parameters + ---------- + bstring : bytes + Byte string containing the on-disk representation of an image + """ + bio = io.BytesIO(bstring) + file_map = self.make_file_map({'image': bio, 'header': bio}) + return klass.from_file_map(file_map) + @classmethod def from_file_map(klass, file_map): raise NotImplementedError @@ -334,6 +349,24 @@ def to_filename(self, filename): self.file_map = self.filespec_to_file_map(filename) self.to_file_map() + def to_bytes(self): + """ Return a ``bytes`` object with the contents of the file that would + be written if the image were saved. + + Parameters + ---------- + None + + Returns + ------- + bytes + Serialized image + """ + bio = io.BytesIO() + file_map = self.make_file_map({'image': bio, 'header': bio}) + self.to_file_map(file_map) + return bio.getvalue() + @deprecate_with_version('to_filespec method is deprecated.\n' 'Please use the "to_filename" method instead.', '1.0', '3.0') @@ -512,21 +545,3 @@ def path_maybe_image(klass, filename, sniff=None, sniff_max=1024): if sniff is None or len(sniff[0]) < klass._meta_sniff_len: return False, sniff return klass.header_class.may_contain_header(sniff[0]), sniff - - def serialize(self): - """ Return a ``bytes`` object with the contents of the file that would - be written if the image were saved. - - Parameters - ---------- - None - - Returns - ------- - bytes - Serialized image - """ - bio = io.BytesIO() - file_map = self.make_file_map({'image': bio, 'header': bio}) - self.to_file_map(file_map) - return bio.getvalue() From b729779c90db30d9ef1cec185f92a3d56628912f Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Fri, 10 Aug 2018 22:33:07 -0400 Subject: [PATCH 06/15] RF: Factor SerializableImage --- nibabel/filebasedimages.py | 109 ++++++++++++++++++++++---------- nibabel/freesurfer/mghformat.py | 3 +- nibabel/nifti1.py | 3 +- nibabel/tests/test_image_api.py | 25 +++++++- 4 files changed, 102 insertions(+), 38 deletions(-) diff --git a/nibabel/filebasedimages.py b/nibabel/filebasedimages.py index d6ca4d810d..05bfa06295 100644 --- a/nibabel/filebasedimages.py +++ b/nibabel/filebasedimages.py @@ -260,21 +260,6 @@ def from_filename(klass, filename): file_map = klass.filespec_to_file_map(filename) return klass.from_file_map(file_map) - @classmethod - def from_bytes(klass, bstring): - """ Construct image from a byte string - - Class method - - Parameters - ---------- - bstring : bytes - Byte string containing the on-disk representation of an image - """ - bio = io.BytesIO(bstring) - file_map = self.make_file_map({'image': bio, 'header': bio}) - return klass.from_file_map(file_map) - @classmethod def from_file_map(klass, file_map): raise NotImplementedError @@ -349,24 +334,6 @@ def to_filename(self, filename): self.file_map = self.filespec_to_file_map(filename) self.to_file_map() - def to_bytes(self): - """ Return a ``bytes`` object with the contents of the file that would - be written if the image were saved. - - Parameters - ---------- - None - - Returns - ------- - bytes - Serialized image - """ - bio = io.BytesIO() - file_map = self.make_file_map({'image': bio, 'header': bio}) - self.to_file_map(file_map) - return bio.getvalue() - @deprecate_with_version('to_filespec method is deprecated.\n' 'Please use the "to_filename" method instead.', '1.0', '3.0') @@ -545,3 +512,79 @@ def path_maybe_image(klass, filename, sniff=None, sniff_max=1024): if sniff is None or len(sniff[0]) < klass._meta_sniff_len: return False, sniff return klass.header_class.may_contain_header(sniff[0]), sniff + + +class SerializableImage(FileBasedImage): + ''' + Abstract image class for (de)serializing images to/from byte strings. + + The class doesn't define any image properties. + + It has: + + methods: + + * .to_bytes() - serialize image to byte string + + classmethods: + + * from_bytes(bytestring) - make instance by deserializing a byte string + + The following properties should hold: + + * ``klass.from_bytes(bstr).to_bytes() == bstr`` + * if ``img = orig.__class__.from_bytes(orig.to_bytes())``, then + ``img.header == orig.header`` and ``img.get_data() == orig.get_data()`` + + Further, for images that are single files on disk, the following methods of loading + the image must be equivalent: + + img = klass.from_filename(fname) + + with open(fname, 'rb') as fobj: + img = klass.from_bytes(fobj.read()) + + And the following methods of saving a file must be equivalent: + + img.to_filename(fname) + + with open(fname, 'wb') as fobj: + fobj.write(img.to_bytes()) + + Images that consist of separate header and data files will generally + place the header with the data, but if the header is not of fixed + size and does not define its own size, a new format may need to be + defined. + ''' + @classmethod + def from_bytes(klass, bytestring): + """ Construct image from a byte string + + Class method + + Parameters + ---------- + bstring : bytes + Byte string containing the on-disk representation of an image + """ + bio = io.BytesIO(bstring) + file_map = klass.make_file_map({'image': bio, 'header': bio}) + return klass.from_file_map(file_map) + + def to_bytes(self): + """ Return a ``bytes`` object with the contents of the file that would + be written if the image were saved. + + Parameters + ---------- + None + + Returns + ------- + bytes + Serialized image + """ + bio = io.BytesIO() + file_map = self.make_file_map({'image': bio, 'header': bio}) + self.to_file_map(file_map) + return bio.getvalue() diff --git a/nibabel/freesurfer/mghformat.py b/nibabel/freesurfer/mghformat.py index 8ff783a865..37bc82cfb3 100644 --- a/nibabel/freesurfer/mghformat.py +++ b/nibabel/freesurfer/mghformat.py @@ -16,6 +16,7 @@ from ..affines import voxel_sizes, from_matvec from ..volumeutils import (array_to_file, array_from_file, endian_codes, Recoder) +from ..filebasedimages import SerializableImage from ..spatialimages import HeaderDataError, SpatialImage from ..fileholders import FileHolder from ..arrayproxy import ArrayProxy, reshape_dataobj @@ -503,7 +504,7 @@ def __setitem__(self, item, value): super(MGHHeader, self).__setitem__(item, value) -class MGHImage(SpatialImage): +class MGHImage(SpatialImage, SerializableImage): """ Class for MGH format image """ header_class = MGHHeader diff --git a/nibabel/nifti1.py b/nibabel/nifti1.py index e844936aaf..e12cb6543a 100644 --- a/nibabel/nifti1.py +++ b/nibabel/nifti1.py @@ -19,6 +19,7 @@ import numpy.linalg as npl from .py3k import asstr +from .filebasedimages import SerializableImage from .volumeutils import Recoder, make_dt_codes, endian_codes from .spatialimages import HeaderDataError, ImageFileError from .batteryrunners import Report @@ -1758,7 +1759,7 @@ class Nifti1PairHeader(Nifti1Header): is_single = False -class Nifti1Pair(analyze.AnalyzeImage): +class Nifti1Pair(analyze.AnalyzeImage, SerializableImage): """ Class for NIfTI1 format image, header pair """ header_class = Nifti1PairHeader diff --git a/nibabel/tests/test_image_api.py b/nibabel/tests/test_image_api.py index a65ee0cc61..e3105b682c 100644 --- a/nibabel/tests/test_image_api.py +++ b/nibabel/tests/test_image_api.py @@ -494,10 +494,9 @@ def validate_affine_deprecated(self, imaker, params): class SerializeMixin(object): - - def validate_serialize(self, imaker, params): + def validate_to_bytes(self, imaker, params): img = imaker() - serialized = img.serialize() + serialized = img.to_bytes() with InTemporaryDirectory(): fname = 'img' + self.standard_extension img.to_filename(fname) @@ -505,6 +504,26 @@ def validate_serialize(self, imaker, params): file_contents = fobj.read() assert serialized == file_contents + def validate_from_bytes(self, imaker, params): + for img_params in self.example_images: + img_a = self.klass.from_filename(img_params['fname']) + with open(img_params['fname'], 'rb') as fobj: + img_b = self.klass.from_bytes(fobj.read()) + + assert img_a.header == img_b.header + assert np.array_equal(img_a.get_data(), img_b.get_data()) + + def validate_round_trip(self, imaker, params): + for img_params in self.example_images: + img_a = self.klass.from_filename(img_params['fname']) + bytes_a = img_a.to_bytes() + + img_b = self.klass.from_bytes(bytes_a) + + assert img_b.to_bytes() == bytes_a + assert img_a.header == img_b.header + assert np.array_equal(img_a.get_data(), img_b.get_data()) + class LoadImageAPI(GenericImageAPI, DataInterfaceMixin, From c943e9d8b5a3399ca7957d3e65b4b254c25c06b3 Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Mon, 13 Aug 2018 22:08:07 -0400 Subject: [PATCH 07/15] TEST: Generate file when no examples available --- nibabel/filebasedimages.py | 2 +- nibabel/tests/test_filebasedimages.py | 13 +++++++-- nibabel/tests/test_image_api.py | 40 +++++++++++++++++---------- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/nibabel/filebasedimages.py b/nibabel/filebasedimages.py index 05bfa06295..bfbe50349b 100644 --- a/nibabel/filebasedimages.py +++ b/nibabel/filebasedimages.py @@ -567,7 +567,7 @@ def from_bytes(klass, bytestring): bstring : bytes Byte string containing the on-disk representation of an image """ - bio = io.BytesIO(bstring) + bio = io.BytesIO(bytestring) file_map = klass.make_file_map({'image': bio, 'header': bio}) return klass.from_file_map(file_map) diff --git a/nibabel/tests/test_filebasedimages.py b/nibabel/tests/test_filebasedimages.py index 0758ed0f5d..0bab751e29 100644 --- a/nibabel/tests/test_filebasedimages.py +++ b/nibabel/tests/test_filebasedimages.py @@ -5,7 +5,7 @@ import numpy as np -from ..filebasedimages import FileBasedHeader, FileBasedImage +from ..filebasedimages import FileBasedHeader, FileBasedImage, SerializableImage from .test_image_api import GenericImageAPI, SerializeMixin @@ -50,8 +50,11 @@ def set_data_dtype(self, dtype): self.arr = self.arr.astype(dtype) -class TestFBImageAPI(GenericImageAPI, - SerializeMixin): +class SerializableNumpyImage(FBNumpyImage, SerializableImage): + pass + + +class TestFBImageAPI(GenericImageAPI): """ Validation for FileBasedImage instances """ # A callable returning an image from ``image_maker(data, header)`` @@ -81,6 +84,10 @@ def obj_params(self): yield func, params +class TestSerializableImageAPI(TestFBImageAPI, SerializeMixin): + image_maker = SerializableNumpyImage + + def test_filebased_header(): # Test stuff about the default FileBasedHeader diff --git a/nibabel/tests/test_image_api.py b/nibabel/tests/test_image_api.py index e3105b682c..3a1b4f4e87 100644 --- a/nibabel/tests/test_image_api.py +++ b/nibabel/tests/test_image_api.py @@ -505,24 +505,36 @@ def validate_to_bytes(self, imaker, params): assert serialized == file_contents def validate_from_bytes(self, imaker, params): - for img_params in self.example_images: - img_a = self.klass.from_filename(img_params['fname']) - with open(img_params['fname'], 'rb') as fobj: - img_b = self.klass.from_bytes(fobj.read()) + img = imaker() + with InTemporaryDirectory(): + fname = 'img' + self.standard_extension + img.to_filename(fname) - assert img_a.header == img_b.header - assert np.array_equal(img_a.get_data(), img_b.get_data()) + all_images = list(getattr(self, 'example_images', [])) + [{'fname': fname}] + for img_params in all_images: + img_a = self.klass.from_filename(img_params['fname']) + with open(img_params['fname'], 'rb') as fobj: + img_b = self.klass.from_bytes(fobj.read()) - def validate_round_trip(self, imaker, params): - for img_params in self.example_images: - img_a = self.klass.from_filename(img_params['fname']) - bytes_a = img_a.to_bytes() + assert img_a.header == img_b.header + assert np.array_equal(img_a.get_data(), img_b.get_data()) + + def validate_to_from_bytes(self, imaker, params): + img = imaker() + with InTemporaryDirectory(): + fname = 'img' + self.standard_extension + img.to_filename(fname) + + all_images = list(getattr(self, 'example_images', [])) + [{'fname': fname}] + for img_params in all_images: + img_a = self.klass.from_filename(img_params['fname']) + bytes_a = img_a.to_bytes() - img_b = self.klass.from_bytes(bytes_a) + img_b = self.klass.from_bytes(bytes_a) - assert img_b.to_bytes() == bytes_a - assert img_a.header == img_b.header - assert np.array_equal(img_a.get_data(), img_b.get_data()) + assert img_b.to_bytes() == bytes_a + assert img_a.header == img_b.header + assert np.array_equal(img_a.get_data(), img_b.get_data()) class LoadImageAPI(GenericImageAPI, From 96bc5a0214ca4ffa251d3ca12e365b61d8540abe Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Mon, 13 Aug 2018 22:29:31 -0400 Subject: [PATCH 08/15] TEST: klass sometimes missing, equality sometimes undefined --- nibabel/tests/test_image_api.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/nibabel/tests/test_image_api.py b/nibabel/tests/test_image_api.py index 3a1b4f4e87..e64b8c9663 100644 --- a/nibabel/tests/test_image_api.py +++ b/nibabel/tests/test_image_api.py @@ -506,36 +506,55 @@ def validate_to_bytes(self, imaker, params): def validate_from_bytes(self, imaker, params): img = imaker() + klass = getattr(self, 'klass', img.__class__) with InTemporaryDirectory(): fname = 'img' + self.standard_extension img.to_filename(fname) all_images = list(getattr(self, 'example_images', [])) + [{'fname': fname}] for img_params in all_images: - img_a = self.klass.from_filename(img_params['fname']) + img_a = klass.from_filename(img_params['fname']) with open(img_params['fname'], 'rb') as fobj: - img_b = self.klass.from_bytes(fobj.read()) + img_b = klass.from_bytes(fobj.read()) - assert img_a.header == img_b.header + assert self._header_eq(img_a.header, img_b.header) assert np.array_equal(img_a.get_data(), img_b.get_data()) def validate_to_from_bytes(self, imaker, params): img = imaker() + klass = getattr(self, 'klass', img.__class__) with InTemporaryDirectory(): fname = 'img' + self.standard_extension img.to_filename(fname) all_images = list(getattr(self, 'example_images', [])) + [{'fname': fname}] for img_params in all_images: - img_a = self.klass.from_filename(img_params['fname']) + img_a = klass.from_filename(img_params['fname']) bytes_a = img_a.to_bytes() - img_b = self.klass.from_bytes(bytes_a) + img_b = klass.from_bytes(bytes_a) assert img_b.to_bytes() == bytes_a - assert img_a.header == img_b.header + assert self._header_eq(img_a.header, img_b.header) assert np.array_equal(img_a.get_data(), img_b.get_data()) + @staticmethod + def _header_eq(header_a, header_b): + """ Quick-and-dirty header equality check + + Abstract classes may have undefined equality, in which case test for + same type + """ + not_implemented = False + header_eq = True + try: + header_eq = header_a == header_b + except NotImplementedError: + header_eq = type(header_a) == type(header_b) + + return header_eq + + class LoadImageAPI(GenericImageAPI, DataInterfaceMixin, From 08a7bab4fd6a2de4a923e2e7af2d08d3bc797c2c Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Mon, 13 Aug 2018 22:46:38 -0400 Subject: [PATCH 09/15] ENH: Add to/from_bytes interface to GiftiImage --- nibabel/__init__.py | 1 + nibabel/gifti/gifti.py | 7 +++++-- nibabel/tests/test_image_api.py | 7 +++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/nibabel/__init__.py b/nibabel/__init__.py index fca22ccc99..d8c877d206 100644 --- a/nibabel/__init__.py +++ b/nibabel/__init__.py @@ -65,6 +65,7 @@ def setup_test(): from .minc1 import Minc1Image from .minc2 import Minc2Image from .cifti2 import Cifti2Header, Cifti2Image +from .gifti import GiftiImage # Deprecated backwards compatiblity for MINC1 from .deprecated import ModuleProxy as _ModuleProxy minc = _ModuleProxy('nibabel.minc') diff --git a/nibabel/gifti/gifti.py b/nibabel/gifti/gifti.py index 997ba78523..22d6449e9a 100644 --- a/nibabel/gifti/gifti.py +++ b/nibabel/gifti/gifti.py @@ -18,7 +18,7 @@ import numpy as np from .. import xmlutils as xml -from ..filebasedimages import FileBasedImage +from ..filebasedimages import SerializableImage from ..nifti1 import data_type_codes, xform_codes, intent_codes from .util import (array_index_order_codes, gifti_encoding_codes, gifti_endian_codes, KIND2FMT) @@ -534,7 +534,7 @@ def metadata(self): return self.meta.metadata -class GiftiImage(xml.XmlSerializable, FileBasedImage): +class GiftiImage(xml.XmlSerializable, SerializableImage): """ GIFTI image object The Gifti spec suggests using the following suffixes to your @@ -724,6 +724,9 @@ def to_xml(self, enc='utf-8'): """ + xml.XmlSerializable.to_xml(self, enc) + # Avoid the indirection of going through to_file_map + to_bytes = to_xml + def to_file_map(self, file_map=None): """ Save the current image to the specified file_map diff --git a/nibabel/tests/test_image_api.py b/nibabel/tests/test_image_api.py index e64b8c9663..01d406b4e6 100644 --- a/nibabel/tests/test_image_api.py +++ b/nibabel/tests/test_image_api.py @@ -38,6 +38,7 @@ from .. import (AnalyzeImage, Spm99AnalyzeImage, Spm2AnalyzeImage, Nifti1Pair, Nifti1Image, Nifti2Pair, Nifti2Image, + GiftiImage, MGHImage, Minc1Image, Minc2Image, is_proxy) from ..spatialimages import SpatialImage from .. import minc1, minc2, parrec, brikhead @@ -731,6 +732,12 @@ class TestMGHAPI(ImageHeaderAPI, SerializeMixin): standard_extension = '.mgh' +class TestGiftiAPI(LoadImageAPI, SerializeMixin): + klass = image_maker = GiftiImage + can_save = True + standard_extension = '.gii' + + class TestAFNIAPI(LoadImageAPI): loader = brikhead.load klass = image_maker = brikhead.AFNIImage From 372ac68bc4c09259cb46d584e63c321a91a05982 Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Tue, 14 Aug 2018 09:32:01 -0400 Subject: [PATCH 10/15] TEST: Delete images so mmapped files can be removed --- nibabel/tests/test_image_api.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nibabel/tests/test_image_api.py b/nibabel/tests/test_image_api.py index 01d406b4e6..ac24c4415f 100644 --- a/nibabel/tests/test_image_api.py +++ b/nibabel/tests/test_image_api.py @@ -520,6 +520,8 @@ def validate_from_bytes(self, imaker, params): assert self._header_eq(img_a.header, img_b.header) assert np.array_equal(img_a.get_data(), img_b.get_data()) + del img_a + del img_b def validate_to_from_bytes(self, imaker, params): img = imaker() @@ -538,6 +540,8 @@ def validate_to_from_bytes(self, imaker, params): assert img_b.to_bytes() == bytes_a assert self._header_eq(img_a.header, img_b.header) assert np.array_equal(img_a.get_data(), img_b.get_data()) + del img_a + del img_b @staticmethod def _header_eq(header_a, header_b): From e197b72c05150aa8ec35af91e6d498270187b67b Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Wed, 26 Sep 2018 12:08:59 -0400 Subject: [PATCH 11/15] DOC: Improve docstring --- nibabel/filebasedimages.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/nibabel/filebasedimages.py b/nibabel/filebasedimages.py index bfbe50349b..6940e83eb4 100644 --- a/nibabel/filebasedimages.py +++ b/nibabel/filebasedimages.py @@ -79,8 +79,8 @@ class FileBasedImage(object): methods: - * .get_header() (deprecated, use header property instead) - * .to_filename(fname) - writes data to filename(s) derived from + * get_header() (deprecated, use header property instead) + * to_filename(fname) - writes data to filename(s) derived from ``fname``, where the derivation may differ between formats. * to_file_map() - save image to files with which the image is already associated. @@ -524,21 +524,27 @@ class SerializableImage(FileBasedImage): methods: - * .to_bytes() - serialize image to byte string + * to_bytes() - serialize image to byte string classmethods: * from_bytes(bytestring) - make instance by deserializing a byte string - The following properties should hold: + Loading from byte strings should provide round-trip equivalence: - * ``klass.from_bytes(bstr).to_bytes() == bstr`` - * if ``img = orig.__class__.from_bytes(orig.to_bytes())``, then - ``img.header == orig.header`` and ``img.get_data() == orig.get_data()`` + .. code:: python + + img_a = klass.from_bytes(bstr) + img_b = klass.from_bytes(img_a.to_bytes()) + + np.allclose(img_a.get_fdata(), img_b.get_fdata()) + np.allclose(img_a.affine, img_b.affine) Further, for images that are single files on disk, the following methods of loading the image must be equivalent: + .. code:: python + img = klass.from_filename(fname) with open(fname, 'rb') as fobj: @@ -546,15 +552,15 @@ class SerializableImage(FileBasedImage): And the following methods of saving a file must be equivalent: + .. code:: python + img.to_filename(fname) with open(fname, 'wb') as fobj: fobj.write(img.to_bytes()) - Images that consist of separate header and data files will generally - place the header with the data, but if the header is not of fixed - size and does not define its own size, a new format may need to be - defined. + Images that consist of separate header and data files (e.g., Analyze + images) currently do not support this interface. ''' @classmethod def from_bytes(klass, bytestring): From eae0342626468084e38b7308110e24059aeff54d Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Thu, 20 Jun 2019 07:58:58 -0400 Subject: [PATCH 12/15] FIX: Nifti1Image is serializable, not Nifti1Pair --- nibabel/nifti1.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nibabel/nifti1.py b/nibabel/nifti1.py index e12cb6543a..a050195234 100644 --- a/nibabel/nifti1.py +++ b/nibabel/nifti1.py @@ -1759,7 +1759,7 @@ class Nifti1PairHeader(Nifti1Header): is_single = False -class Nifti1Pair(analyze.AnalyzeImage, SerializableImage): +class Nifti1Pair(analyze.AnalyzeImage): """ Class for NIfTI1 format image, header pair """ header_class = Nifti1PairHeader @@ -2026,7 +2026,7 @@ def as_reoriented(self, ornt): return img -class Nifti1Image(Nifti1Pair): +class Nifti1Image(Nifti1Pair, SerializableImage): """ Class for single file NIfTI1 format image """ header_class = Nifti1Header From 30bea56bf5dbb412a5e7b48085d3aef388cf2d3f Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Thu, 20 Jun 2019 08:01:01 -0400 Subject: [PATCH 13/15] ENH: Check for multi-file images, to ensure well-defined behavior --- nibabel/filebasedimages.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nibabel/filebasedimages.py b/nibabel/filebasedimages.py index 6940e83eb4..b9898cc496 100644 --- a/nibabel/filebasedimages.py +++ b/nibabel/filebasedimages.py @@ -573,6 +573,8 @@ def from_bytes(klass, bytestring): bstring : bytes Byte string containing the on-disk representation of an image """ + if len(klass.files_types) > 1: + raise NotImplementedError("from_bytes is undefined for multi-file images") bio = io.BytesIO(bytestring) file_map = klass.make_file_map({'image': bio, 'header': bio}) return klass.from_file_map(file_map) @@ -590,6 +592,8 @@ def to_bytes(self): bytes Serialized image """ + if len(self.__class__.files_types) > 1: + raise NotImplementedError("to_bytes() is undefined for multi-file images") bio = io.BytesIO() file_map = self.make_file_map({'image': bio, 'header': bio}) self.to_file_map(file_map) From 562fac8a4b7357c232b3e39ad3b4a2c7556b8e32 Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Thu, 20 Jun 2019 08:21:59 -0400 Subject: [PATCH 14/15] TEST: Move special case header equality check into TestSerializableImageAPI --- nibabel/tests/test_filebasedimages.py | 6 ++++++ nibabel/tests/test_image_api.py | 16 +++++----------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/nibabel/tests/test_filebasedimages.py b/nibabel/tests/test_filebasedimages.py index 0bab751e29..c9d256edbb 100644 --- a/nibabel/tests/test_filebasedimages.py +++ b/nibabel/tests/test_filebasedimages.py @@ -87,6 +87,12 @@ def obj_params(self): class TestSerializableImageAPI(TestFBImageAPI, SerializeMixin): image_maker = SerializableNumpyImage + @staticmethod + def _header_eq(header_a, header_b): + """ FileBasedHeader is an abstract class, so __eq__ is undefined. + Checking for the same header type is sufficient, here. """ + return type(header_a) == type(header_b) == FileBasedHeader + def test_filebased_header(): # Test stuff about the default FileBasedHeader diff --git a/nibabel/tests/test_image_api.py b/nibabel/tests/test_image_api.py index ac24c4415f..ac2a2428c4 100644 --- a/nibabel/tests/test_image_api.py +++ b/nibabel/tests/test_image_api.py @@ -545,19 +545,13 @@ def validate_to_from_bytes(self, imaker, params): @staticmethod def _header_eq(header_a, header_b): - """ Quick-and-dirty header equality check + """ Header equality check that can be overridden by a subclass of this test - Abstract classes may have undefined equality, in which case test for - same type + This allows us to retain the same tests above when testing an image that uses an + abstract class as a header, namely when testing the FileBasedImage API, which + raises a NotImplementedError for __eq__ """ - not_implemented = False - header_eq = True - try: - header_eq = header_a == header_b - except NotImplementedError: - header_eq = type(header_a) == type(header_b) - - return header_eq + return header_a == header_b From f829919bf682cfd0d0cd5fccad92c29ed4b3fd07 Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Thu, 20 Jun 2019 10:44:17 -0400 Subject: [PATCH 15/15] STY/DOC: Note about multi-file images, newline --- nibabel/filebasedimages.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nibabel/filebasedimages.py b/nibabel/filebasedimages.py index b9898cc496..c17701bc2e 100644 --- a/nibabel/filebasedimages.py +++ b/nibabel/filebasedimages.py @@ -561,7 +561,10 @@ class SerializableImage(FileBasedImage): Images that consist of separate header and data files (e.g., Analyze images) currently do not support this interface. + For multi-file images, ``to_bytes()`` and ``from_bytes()`` must be + overridden, and any encoding details should be documented. ''' + @classmethod def from_bytes(klass, bytestring): """ Construct image from a byte string