Skip to content

Commit 980f678

Browse files
authored
Merge pull request #644 from effigies/enh/serialize
ENH: Add SerializableImage class with to/from_bytes methods
2 parents 94b3048 + f829919 commit 980f678

File tree

7 files changed

+188
-10
lines changed

7 files changed

+188
-10
lines changed

nibabel/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def setup_test():
6565
from .minc1 import Minc1Image
6666
from .minc2 import Minc2Image
6767
from .cifti2 import Cifti2Header, Cifti2Image
68+
from .gifti import GiftiImage
6869
# Deprecated backwards compatiblity for MINC1
6970
from .deprecated import ModuleProxy as _ModuleProxy
7071
minc = _ModuleProxy('nibabel.minc')

nibabel/filebasedimages.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
99
''' Common interface for any image format--volume or surface, binary or xml.'''
1010

11+
import io
1112
from copy import deepcopy
1213
from six import string_types
1314
from .fileholders import FileHolder
@@ -78,8 +79,8 @@ class FileBasedImage(object):
7879
7980
methods:
8081
81-
* .get_header() (deprecated, use header property instead)
82-
* .to_filename(fname) - writes data to filename(s) derived from
82+
* get_header() (deprecated, use header property instead)
83+
* to_filename(fname) - writes data to filename(s) derived from
8384
``fname``, where the derivation may differ between formats.
8485
* to_file_map() - save image to files with which the image is already
8586
associated.
@@ -511,3 +512,92 @@ def path_maybe_image(klass, filename, sniff=None, sniff_max=1024):
511512
if sniff is None or len(sniff[0]) < klass._meta_sniff_len:
512513
return False, sniff
513514
return klass.header_class.may_contain_header(sniff[0]), sniff
515+
516+
517+
class SerializableImage(FileBasedImage):
518+
'''
519+
Abstract image class for (de)serializing images to/from byte strings.
520+
521+
The class doesn't define any image properties.
522+
523+
It has:
524+
525+
methods:
526+
527+
* to_bytes() - serialize image to byte string
528+
529+
classmethods:
530+
531+
* from_bytes(bytestring) - make instance by deserializing a byte string
532+
533+
Loading from byte strings should provide round-trip equivalence:
534+
535+
.. code:: python
536+
537+
img_a = klass.from_bytes(bstr)
538+
img_b = klass.from_bytes(img_a.to_bytes())
539+
540+
np.allclose(img_a.get_fdata(), img_b.get_fdata())
541+
np.allclose(img_a.affine, img_b.affine)
542+
543+
Further, for images that are single files on disk, the following methods of loading
544+
the image must be equivalent:
545+
546+
.. code:: python
547+
548+
img = klass.from_filename(fname)
549+
550+
with open(fname, 'rb') as fobj:
551+
img = klass.from_bytes(fobj.read())
552+
553+
And the following methods of saving a file must be equivalent:
554+
555+
.. code:: python
556+
557+
img.to_filename(fname)
558+
559+
with open(fname, 'wb') as fobj:
560+
fobj.write(img.to_bytes())
561+
562+
Images that consist of separate header and data files (e.g., Analyze
563+
images) currently do not support this interface.
564+
For multi-file images, ``to_bytes()`` and ``from_bytes()`` must be
565+
overridden, and any encoding details should be documented.
566+
'''
567+
568+
@classmethod
569+
def from_bytes(klass, bytestring):
570+
""" Construct image from a byte string
571+
572+
Class method
573+
574+
Parameters
575+
----------
576+
bstring : bytes
577+
Byte string containing the on-disk representation of an image
578+
"""
579+
if len(klass.files_types) > 1:
580+
raise NotImplementedError("from_bytes is undefined for multi-file images")
581+
bio = io.BytesIO(bytestring)
582+
file_map = klass.make_file_map({'image': bio, 'header': bio})
583+
return klass.from_file_map(file_map)
584+
585+
def to_bytes(self):
586+
""" Return a ``bytes`` object with the contents of the file that would
587+
be written if the image were saved.
588+
589+
Parameters
590+
----------
591+
None
592+
593+
Returns
594+
-------
595+
bytes
596+
Serialized image
597+
"""
598+
if len(self.__class__.files_types) > 1:
599+
raise NotImplementedError("to_bytes() is undefined for multi-file images")
600+
bio = io.BytesIO()
601+
file_map = self.make_file_map({'image': bio, 'header': bio})
602+
self.to_file_map(file_map)
603+
return bio.getvalue()

nibabel/freesurfer/mghformat.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ..affines import voxel_sizes, from_matvec
1717
from ..volumeutils import (array_to_file, array_from_file, endian_codes,
1818
Recoder)
19+
from ..filebasedimages import SerializableImage
1920
from ..spatialimages import HeaderDataError, SpatialImage
2021
from ..fileholders import FileHolder
2122
from ..arrayproxy import ArrayProxy, reshape_dataobj
@@ -503,7 +504,7 @@ def __setitem__(self, item, value):
503504
super(MGHHeader, self).__setitem__(item, value)
504505

505506

506-
class MGHImage(SpatialImage):
507+
class MGHImage(SpatialImage, SerializableImage):
507508
""" Class for MGH format image
508509
"""
509510
header_class = MGHHeader

nibabel/gifti/gifti.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import numpy as np
1919

2020
from .. import xmlutils as xml
21-
from ..filebasedimages import FileBasedImage
21+
from ..filebasedimages import SerializableImage
2222
from ..nifti1 import data_type_codes, xform_codes, intent_codes
2323
from .util import (array_index_order_codes, gifti_encoding_codes,
2424
gifti_endian_codes, KIND2FMT)
@@ -534,7 +534,7 @@ def metadata(self):
534534
return self.meta.metadata
535535

536536

537-
class GiftiImage(xml.XmlSerializable, FileBasedImage):
537+
class GiftiImage(xml.XmlSerializable, SerializableImage):
538538
""" GIFTI image object
539539
540540
The Gifti spec suggests using the following suffixes to your
@@ -724,6 +724,9 @@ def to_xml(self, enc='utf-8'):
724724
<!DOCTYPE GIFTI SYSTEM "http://www.nitrc.org/frs/download.php/115/gifti.dtd">
725725
""" + xml.XmlSerializable.to_xml(self, enc)
726726

727+
# Avoid the indirection of going through to_file_map
728+
to_bytes = to_xml
729+
727730
def to_file_map(self, file_map=None):
728731
""" Save the current image to the specified file_map
729732

nibabel/nifti1.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import numpy.linalg as npl
2020

2121
from .py3k import asstr
22+
from .filebasedimages import SerializableImage
2223
from .volumeutils import Recoder, make_dt_codes, endian_codes
2324
from .spatialimages import HeaderDataError, ImageFileError
2425
from .batteryrunners import Report
@@ -2025,7 +2026,7 @@ def as_reoriented(self, ornt):
20252026
return img
20262027

20272028

2028-
class Nifti1Image(Nifti1Pair):
2029+
class Nifti1Image(Nifti1Pair, SerializableImage):
20292030
""" Class for single file NIfTI1 format image
20302031
"""
20312032
header_class = Nifti1Header

nibabel/tests/test_filebasedimages.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55

66
import numpy as np
77

8-
from nibabel.filebasedimages import FileBasedHeader, FileBasedImage
8+
from ..filebasedimages import FileBasedHeader, FileBasedImage, SerializableImage
99

10-
from nibabel.tests.test_image_api import GenericImageAPI
10+
from .test_image_api import GenericImageAPI, SerializeMixin
1111

1212
from nose.tools import (assert_true, assert_false, assert_equal,
1313
assert_not_equal)
@@ -50,6 +50,10 @@ def set_data_dtype(self, dtype):
5050
self.arr = self.arr.astype(dtype)
5151

5252

53+
class SerializableNumpyImage(FBNumpyImage, SerializableImage):
54+
pass
55+
56+
5357
class TestFBImageAPI(GenericImageAPI):
5458
""" Validation for FileBasedImage instances
5559
"""
@@ -80,6 +84,16 @@ def obj_params(self):
8084
yield func, params
8185

8286

87+
class TestSerializableImageAPI(TestFBImageAPI, SerializeMixin):
88+
image_maker = SerializableNumpyImage
89+
90+
@staticmethod
91+
def _header_eq(header_a, header_b):
92+
""" FileBasedHeader is an abstract class, so __eq__ is undefined.
93+
Checking for the same header type is sufficient, here. """
94+
return type(header_a) == type(header_b) == FileBasedHeader
95+
96+
8397
def test_filebased_header():
8498
# Test stuff about the default FileBasedHeader
8599

nibabel/tests/test_image_api.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
from .. import (AnalyzeImage, Spm99AnalyzeImage, Spm2AnalyzeImage,
4040
Nifti1Pair, Nifti1Image, Nifti2Pair, Nifti2Image,
41+
GiftiImage,
4142
MGHImage, Minc1Image, Minc2Image, is_proxy)
4243
from ..spatialimages import SpatialImage
4344
from .. import minc1, minc2, parrec, brikhead
@@ -493,6 +494,67 @@ def validate_affine_deprecated(self, imaker, params):
493494
assert_true(aff is img.get_affine())
494495

495496

497+
class SerializeMixin(object):
498+
def validate_to_bytes(self, imaker, params):
499+
img = imaker()
500+
serialized = img.to_bytes()
501+
with InTemporaryDirectory():
502+
fname = 'img' + self.standard_extension
503+
img.to_filename(fname)
504+
with open(fname, 'rb') as fobj:
505+
file_contents = fobj.read()
506+
assert serialized == file_contents
507+
508+
def validate_from_bytes(self, imaker, params):
509+
img = imaker()
510+
klass = getattr(self, 'klass', img.__class__)
511+
with InTemporaryDirectory():
512+
fname = 'img' + self.standard_extension
513+
img.to_filename(fname)
514+
515+
all_images = list(getattr(self, 'example_images', [])) + [{'fname': fname}]
516+
for img_params in all_images:
517+
img_a = klass.from_filename(img_params['fname'])
518+
with open(img_params['fname'], 'rb') as fobj:
519+
img_b = klass.from_bytes(fobj.read())
520+
521+
assert self._header_eq(img_a.header, img_b.header)
522+
assert np.array_equal(img_a.get_data(), img_b.get_data())
523+
del img_a
524+
del img_b
525+
526+
def validate_to_from_bytes(self, imaker, params):
527+
img = imaker()
528+
klass = getattr(self, 'klass', img.__class__)
529+
with InTemporaryDirectory():
530+
fname = 'img' + self.standard_extension
531+
img.to_filename(fname)
532+
533+
all_images = list(getattr(self, 'example_images', [])) + [{'fname': fname}]
534+
for img_params in all_images:
535+
img_a = klass.from_filename(img_params['fname'])
536+
bytes_a = img_a.to_bytes()
537+
538+
img_b = klass.from_bytes(bytes_a)
539+
540+
assert img_b.to_bytes() == bytes_a
541+
assert self._header_eq(img_a.header, img_b.header)
542+
assert np.array_equal(img_a.get_data(), img_b.get_data())
543+
del img_a
544+
del img_b
545+
546+
@staticmethod
547+
def _header_eq(header_a, header_b):
548+
""" Header equality check that can be overridden by a subclass of this test
549+
550+
This allows us to retain the same tests above when testing an image that uses an
551+
abstract class as a header, namely when testing the FileBasedImage API, which
552+
raises a NotImplementedError for __eq__
553+
"""
554+
return header_a == header_b
555+
556+
557+
496558
class LoadImageAPI(GenericImageAPI,
497559
DataInterfaceMixin,
498560
AffineMixin,
@@ -613,7 +675,7 @@ class TestNifti1PairAPI(TestSpm99AnalyzeAPI):
613675
can_save = True
614676

615677

616-
class TestNifti1API(TestNifti1PairAPI):
678+
class TestNifti1API(TestNifti1PairAPI, SerializeMixin):
617679
klass = image_maker = Nifti1Image
618680
standard_extension = '.nii'
619681

@@ -660,14 +722,20 @@ def loader(self, fname):
660722
# standard_extension = '.v'
661723

662724

663-
class TestMGHAPI(ImageHeaderAPI):
725+
class TestMGHAPI(ImageHeaderAPI, SerializeMixin):
664726
klass = image_maker = MGHImage
665727
example_shapes = ((2, 3, 4), (2, 3, 4, 5)) # MGH can only do >= 3D
666728
has_scaling = True
667729
can_save = True
668730
standard_extension = '.mgh'
669731

670732

733+
class TestGiftiAPI(LoadImageAPI, SerializeMixin):
734+
klass = image_maker = GiftiImage
735+
can_save = True
736+
standard_extension = '.gii'
737+
738+
671739
class TestAFNIAPI(LoadImageAPI):
672740
loader = brikhead.load
673741
klass = image_maker = brikhead.AFNIImage

0 commit comments

Comments
 (0)