diff --git a/nibabel/__init__.py b/nibabel/__init__.py index 2df9a1c534..779f6e8587 100644 --- a/nibabel/__init__.py +++ b/nibabel/__init__.py @@ -61,7 +61,7 @@ from .orientations import (io_orientation, orientation_affine, flip_axis, OrientationError, apply_orientation, aff2axcodes) -from .imageclasses import class_map, ext_map +from .imageclasses import class_map, ext_map, all_image_classes from . import trackvis from . import mriutils diff --git a/nibabel/analyze.py b/nibabel/analyze.py index 982da58be9..ee0d127a24 100644 --- a/nibabel/analyze.py +++ b/nibabel/analyze.py @@ -892,13 +892,28 @@ def _chk_pixdims(hdr, fix=False): rep.fix_msg = ' and '.join(fmsgs) return hdr, rep + @classmethod + def may_contain_header(klass, binaryblock): + if len(binaryblock) < klass.sizeof_hdr: + return False + + hdr_struct = np.ndarray(shape=(), dtype=header_dtype, + buffer=binaryblock[:klass.sizeof_hdr]) + bs_hdr_struct = hdr_struct.byteswap() + return 348 in (hdr_struct['sizeof_hdr'], bs_hdr_struct['sizeof_hdr']) + class AnalyzeImage(SpatialImage): """ Class for basic Analyze format image """ header_class = AnalyzeHeader + _meta_sniff_len = header_class.sizeof_hdr files_types = (('image', '.img'), ('header', '.hdr')) - _compressed_exts = ('.gz', '.bz2') + valid_exts = ('.img', '.hdr') + _compressed_suffixes = ('.gz', '.bz2') + + makeable = True + rw = True ImageArrayProxy = ArrayProxy diff --git a/nibabel/ecat.py b/nibabel/ecat.py index 666a2d843f..495d9ab6c5 100644 --- a/nibabel/ecat.py +++ b/nibabel/ecat.py @@ -732,6 +732,7 @@ class EcatImage(SpatialImage): """ _header = EcatHeader header_class = _header + valid_exts = ('.v',) _subheader = EcatSubHeader files_types = (('image', '.v'), ('header', '.v')) diff --git a/nibabel/freesurfer/mghformat.py b/nibabel/freesurfer/mghformat.py index e84f8e2319..f63875b2c6 100644 --- a/nibabel/freesurfer/mghformat.py +++ b/nibabel/freesurfer/mghformat.py @@ -454,13 +454,18 @@ def writeftr_to(self, fileobj): fileobj.write(ftr_nd.tostring()) +# Register .mgz extension as compressed @ImageOpener.register_ext_from_image('.mgz', ImageOpener.gz_def) class MGHImage(SpatialImage): """ Class for MGH format image """ header_class = MGHHeader + valid_exts = ('.mgh',) files_types = (('image', '.mgh'),) - _compressed_exts = (('.gz',)) + _compressed_suffixes = () + + makeable = True + rw = True ImageArrayProxy = ArrayProxy diff --git a/nibabel/freesurfer/tests/test_mghformat.py b/nibabel/freesurfer/tests/test_mghformat.py index c174e3ef3e..cf7d26f20a 100644 --- a/nibabel/freesurfer/tests/test_mghformat.py +++ b/nibabel/freesurfer/tests/test_mghformat.py @@ -145,7 +145,7 @@ def test_filename_exts(): # and the default affine matrix (Note the "None") img = MGHImage(v, None) # Check if these extensions allow round trip - for ext in ('.mgh', '.mgz', '.mgh.gz'): + for ext in ('.mgh', '.mgz'): with InTemporaryDirectory(): fname = 'tmpname' + ext save(img, fname) diff --git a/nibabel/imageclasses.py b/nibabel/imageclasses.py index 31a219482c..20e67c06ef 100644 --- a/nibabel/imageclasses.py +++ b/nibabel/imageclasses.py @@ -7,11 +7,15 @@ # ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## ''' Define supported image classes and names ''' +import warnings + from .analyze import AnalyzeImage from .spm99analyze import Spm99AnalyzeImage from .spm2analyze import Spm2AnalyzeImage from .nifti1 import Nifti1Pair, Nifti1Image +from .nifti2 import Nifti2Pair, Nifti2Image from .minc1 import Minc1Image +from .minc2 import Minc2Image from .freesurfer import MGHImage from .parrec import PARRECImage from .volumeutils import Recoder @@ -19,57 +23,76 @@ _, have_scipy, _ = optional_package('scipy') -# mapping of names to classes and class functionality +# Ordered by the load/save priority. +all_image_classes = [Nifti1Pair, Nifti1Image, Nifti2Pair, Nifti2Image, + Spm2AnalyzeImage, Spm99AnalyzeImage, AnalyzeImage, + Minc1Image, Minc2Image, MGHImage, + PARRECImage] + + +# DEPRECATED: mapping of names to classes and class functionality +class ClassMapDict(dict): + def __getitem__(self, *args, **kwargs): + warnings.warn("class_map is deprecated.", DeprecationWarning, + stacklevel=2) + return super(ClassMapDict, self).__getitem__(*args, **kwargs) + +class_map = ClassMapDict( + analyze={'class': AnalyzeImage, # Image class + 'ext': '.img', # characteristic image extension + 'has_affine': False, # class can store an affine + 'makeable': True, # empty image can be easily made in memory + 'rw': True}, # image can be written + spm99analyze={'class': Spm99AnalyzeImage, + 'ext': '.img', + 'has_affine': True, + 'makeable': True, + 'rw': have_scipy}, + spm2analyze={'class': Spm2AnalyzeImage, + 'ext': '.img', + 'has_affine': True, + 'makeable': True, + 'rw': have_scipy}, + nifti_pair={'class': Nifti1Pair, + 'ext': '.img', + 'has_affine': True, + 'makeable': True, + 'rw': True}, + nifti_single={'class': Nifti1Image, + 'ext': '.nii', + 'has_affine': True, + 'makeable': True, + 'rw': True}, + minc={'class': Minc1Image, + 'ext': '.mnc', + 'has_affine': True, + 'makeable': True, + 'rw': False}, + mgh={'class': MGHImage, + 'ext': '.mgh', + 'has_affine': True, + 'makeable': True, + 'rw': True}, + mgz={'class': MGHImage, + 'ext': '.mgz', + 'has_affine': True, + 'makeable': True, + 'rw': True}, + par={'class': PARRECImage, + 'ext': '.par', + 'has_affine': True, + 'makeable': False, + 'rw': False}) + -class_map = { - 'analyze': {'class': AnalyzeImage, # Image class - 'ext': '.img', # characteristic image extension - 'has_affine': False, # class can store an affine - 'makeable': True, # empty image can be easily made in memory - 'rw': True}, # image can be written - 'spm99analyze': {'class': Spm99AnalyzeImage, - 'ext': '.img', - 'has_affine': True, - 'makeable': True, - 'rw': have_scipy}, - 'spm2analyze': {'class': Spm2AnalyzeImage, - 'ext': '.img', - 'has_affine': True, - 'makeable': True, - 'rw': have_scipy}, - 'nifti_pair': {'class': Nifti1Pair, - 'ext': '.img', - 'has_affine': True, - 'makeable': True, - 'rw': True}, - 'nifti_single': {'class': Nifti1Image, - 'ext': '.nii', - 'has_affine': True, - 'makeable': True, - 'rw': True}, - 'minc': {'class': Minc1Image, - 'ext': '.mnc', - 'has_affine': True, - 'makeable': True, - 'rw': False}, - 'mgh': {'class': MGHImage, - 'ext': '.mgh', - 'has_affine': True, - 'makeable': True, - 'rw': True}, - 'mgz': {'class': MGHImage, - 'ext': '.mgz', - 'has_affine': True, - 'makeable': True, - 'rw': True}, - 'par': {'class': PARRECImage, - 'ext': '.par', - 'has_affine': True, - 'makeable': False, - 'rw': False}} +class ExtMapRecoder(Recoder): + def __getitem__(self, *args, **kwargs): + warnings.warn("ext_map is deprecated.", DeprecationWarning, + stacklevel=2) + return super(ExtMapRecoder, self).__getitem__(*args, **kwargs) # mapping of extensions to default image class names -ext_map = Recoder(( +ext_map = ExtMapRecoder(( ('nifti_single', '.nii'), ('nifti_pair', '.img', '.hdr'), ('minc', '.mnc'), diff --git a/nibabel/loadsave.py b/nibabel/loadsave.py index 018907d7bb..f4ce286f61 100644 --- a/nibabel/loadsave.py +++ b/nibabel/loadsave.py @@ -10,18 +10,12 @@ """ Utilities to load and save image objects """ import numpy as np +import warnings -from .filename_parser import types_filenames, splitext_addext +from .filename_parser import splitext_addext from .openers import ImageOpener -from .analyze import AnalyzeImage -from .spm2analyze import Spm2AnalyzeImage -from .nifti1 import Nifti1Image, Nifti1Pair, header_dtype as ni1_hdr_dtype -from .nifti2 import Nifti2Image, Nifti2Pair -from .minc1 import Minc1Image -from .minc2 import Minc2Image -from .freesurfer import MGHImage from .spatialimages import ImageFileError -from .imageclasses import class_map, ext_map +from .imageclasses import all_image_classes from .arrayproxy import is_proxy @@ -40,9 +34,17 @@ def load(filename, **kwargs): img : ``SpatialImage`` Image of guessed type ''' - return guessed_image_type(filename).from_filename(filename, **kwargs) + sniff = None + for image_klass in all_image_classes: + is_valid, sniff = image_klass.path_maybe_image(filename, sniff) + if is_valid: + return image_klass.from_filename(filename, **kwargs) + raise ImageFileError('Cannot work out file type of "%s"' % + filename) + +@np.deprecate def guessed_image_type(filename): """ Guess image type from file `filename` @@ -56,39 +58,16 @@ def guessed_image_type(filename): image_class : class Class corresponding to guessed image type """ - froot, ext, trailing = splitext_addext(filename, ('.gz', '.bz2')) - lext = ext.lower() - try: - img_type = ext_map[lext] - except KeyError: - raise ImageFileError('Cannot work out file type of "%s"' % - filename) - if lext in ('.mgh', '.mgz', '.par'): - klass = class_map[img_type]['class'] - elif lext == '.mnc': - # Look for HDF5 signature for MINC2 - # https://www.hdfgroup.org/HDF5/doc/H5.format.html - with ImageOpener(filename) as fobj: - signature = fobj.read(4) - klass = Minc2Image if signature == b'\211HDF' else Minc1Image - elif lext == '.nii': - with ImageOpener(filename) as fobj: - binaryblock = fobj.read(348) - ft = which_analyze_type(binaryblock) - klass = Nifti2Image if ft == 'nifti2' else Nifti1Image - else: # might be nifti 1 or 2 pair or analyze of some sort - files_types = (('image', '.img'), ('header', '.hdr')) - filenames = types_filenames(filename, files_types) - with ImageOpener(filenames['header']) as fobj: - binaryblock = fobj.read(348) - ft = which_analyze_type(binaryblock) - if ft == 'nifti2': - klass = Nifti2Pair - elif ft == 'nifti1': - klass = Nifti1Pair - else: - klass = Spm2AnalyzeImage - return klass + warnings.warn('guessed_image_type is deprecated', DeprecationWarning, + stacklevel=2) + sniff = None + for image_klass in all_image_classes: + is_valid, sniff = image_klass.path_maybe_image(filename, sniff) + if is_valid: + return image_klass + + raise ImageFileError('Cannot work out file type of "%s"' % + filename) def save(img, filename): @@ -105,25 +84,38 @@ def save(img, filename): ------- None ''' + + # Save the type as expected try: img.to_filename(filename) except ImageFileError: pass else: return + + # Be nice to users by making common implicit conversions froot, ext, trailing = splitext_addext(filename, ('.gz', '.bz2')) + lext = ext.lower() + # Special-case Nifti singles and Pairs - if type(img) == Nifti1Image and ext in ('.img', '.hdr'): + # Inline imports, as this module really shouldn't reference any image type + from .nifti1 import Nifti1Image, Nifti1Pair + from .nifti2 import Nifti2Image, Nifti2Pair + if type(img) == Nifti1Image and lext in ('.img', '.hdr'): klass = Nifti1Pair - elif type(img) == Nifti2Image and ext in ('.img', '.hdr'): + elif type(img) == Nifti2Image and lext in ('.img', '.hdr'): klass = Nifti2Pair - elif type(img) == Nifti1Pair and ext == '.nii': + elif type(img) == Nifti1Pair and lext == '.nii': klass = Nifti1Image - elif type(img) == Nifti2Pair and ext == '.nii': + elif type(img) == Nifti2Pair and lext == '.nii': klass = Nifti2Image - else: - img_type = ext_map[ext] - klass = class_map[img_type]['class'] + else: # arbitrary conversion + valid_klasses = [klass for klass in all_image_classes + if ext in klass.valid_exts] + if not valid_klasses: # if list is empty + raise ImageFileError('Cannot work out file type of "%s"' % + filename) + klass = valid_klasses[0] converted = klass.from_image(img) converted.to_filename(filename) @@ -214,6 +206,7 @@ def read_img_data(img, prefer='scaled'): return hdr.raw_data_from_fileobj(fileobj) +@np.deprecate def which_analyze_type(binaryblock): """ Is `binaryblock` from NIfTI1, NIfTI2 or Analyze header? @@ -241,13 +234,16 @@ def which_analyze_type(binaryblock): * if ``sizeof_hdr`` is 348 or byteswapped 348 assume Analyze * Return None """ - hdr = np.ndarray(shape=(), dtype=ni1_hdr_dtype, buffer=binaryblock) - bs_hdr = hdr.byteswap() - sizeof_hdr = hdr['sizeof_hdr'] - bs_sizeof_hdr = bs_hdr['sizeof_hdr'] + warnings.warn('which_analyze_type is deprecated', DeprecationWarning, + stacklevel=2) + from .nifti1 import header_dtype + hdr_struct = np.ndarray(shape=(), dtype=header_dtype, buffer=binaryblock) + bs_hdr_struct = hdr_struct.byteswap() + sizeof_hdr = hdr_struct['sizeof_hdr'] + bs_sizeof_hdr = bs_hdr_struct['sizeof_hdr'] if 540 in (sizeof_hdr, bs_sizeof_hdr): return 'nifti2' - if hdr['magic'] in (b'ni1', b'n+1'): + if hdr_struct['magic'] in (b'ni1', b'n+1'): return 'nifti1' if 348 in (sizeof_hdr, bs_sizeof_hdr): return 'analyze' diff --git a/nibabel/minc1.py b/nibabel/minc1.py index d646397ee5..8a155712df 100644 --- a/nibabel/minc1.py +++ b/nibabel/minc1.py @@ -279,6 +279,12 @@ def data_from_fileobj(self, fileobj): raise NotImplementedError +class Minc1Header(MincHeader): + @classmethod + def may_contain_header(klass, binaryblock): + return binaryblock[:4] == b'CDF\x01' + + class Minc1Image(SpatialImage): ''' Class for MINC1 format images @@ -286,9 +292,14 @@ class Minc1Image(SpatialImage): MINC header type - and reads the relevant information from the MINC file on load. ''' - header_class = MincHeader + header_class = Minc1Header + _meta_sniff_len = 4 + valid_exts = ('.mnc',) files_types = (('image', '.mnc'),) - _compressed_exts = ('.gz', '.bz2') + _compressed_suffixes = ('.gz', '.bz2') + + makeable = True + rw = False ImageArrayProxy = MincImageArrayProxy diff --git a/nibabel/minc2.py b/nibabel/minc2.py index a8a69ebd23..393fa02180 100644 --- a/nibabel/minc2.py +++ b/nibabel/minc2.py @@ -30,7 +30,7 @@ from .optpkg import optional_package h5py, have_h5py, setup_module = optional_package('h5py') -from .minc1 import Minc1File, Minc1Image, MincError +from .minc1 import Minc1File, MincHeader, Minc1Image, MincError class Hdf5Bunch(object): @@ -134,6 +134,12 @@ def get_scaled_data(self, sliceobj=()): return self._normalize(raw_data, sliceobj) +class Minc2Header(MincHeader): + @classmethod + def may_contain_header(klass, binaryblock): + return binaryblock[:4] == b'\211HDF' + + class Minc2Image(Minc1Image): ''' Class for MINC2 images @@ -142,7 +148,8 @@ class Minc2Image(Minc1Image): the MINC file on load. ''' # MINC2 does not do compressed whole files - _compressed_exts = () + _compressed_suffixes = () + header_class = Minc2Header @classmethod def from_file_map(klass, file_map): diff --git a/nibabel/nifti1.py b/nibabel/nifti1.py index 82e4531f66..fc188d9201 100644 --- a/nibabel/nifti1.py +++ b/nibabel/nifti1.py @@ -1611,6 +1611,15 @@ def _chk_xform_code(klass, code_type, hdr, fix): rep.fix_msg = 'setting to 0' return hdr, rep + @classmethod + def may_contain_header(klass, binaryblock): + if len(binaryblock) < klass.sizeof_hdr: + return False + + hdr_struct = np.ndarray(shape=(), dtype=header_dtype, + buffer=binaryblock[:klass.sizeof_hdr]) + return hdr_struct['magic'] in (b'ni1', b'n+1') + class Nifti1PairHeader(Nifti1Header): ''' Class for NIfTI1 pair header ''' @@ -1622,6 +1631,8 @@ class Nifti1Pair(analyze.AnalyzeImage): """ Class for NIfTI1 format image, header pair """ header_class = Nifti1PairHeader + _meta_sniff_len = header_class.sizeof_hdr + rw = True def __init__(self, dataobj, affine, header=None, extra=None, file_map=None): @@ -1845,6 +1856,7 @@ class Nifti1Image(Nifti1Pair): """ Class for single file NIfTI1 format image """ header_class = Nifti1Header + valid_exts = ('.nii',) files_types = (('image', '.nii'),) @staticmethod diff --git a/nibabel/nifti2.py b/nibabel/nifti2.py index 89fe3345e3..b2f4be0054 100644 --- a/nibabel/nifti2.py +++ b/nibabel/nifti2.py @@ -221,6 +221,16 @@ def _chk_eol_check(hdr, fix=False): rep.fix_msg = 'setting EOL check to 13, 10, 26, 10' return hdr, rep + @classmethod + def may_contain_header(klass, binaryblock): + if len(binaryblock) < klass.sizeof_hdr: + return False + + hdr_struct = np.ndarray(shape=(), dtype=header_dtype, + buffer=binaryblock[:klass.sizeof_hdr]) + bs_hdr_struct = hdr_struct.byteswap() + return 540 in (hdr_struct['sizeof_hdr'], bs_hdr_struct['sizeof_hdr']) + class Nifti2PairHeader(Nifti2Header): ''' Class for NIfTI2 pair header ''' @@ -232,12 +242,14 @@ class Nifti2Pair(Nifti1Pair): """ Class for NIfTI2 format image, header pair """ header_class = Nifti2PairHeader + _meta_sniff_len = header_class.sizeof_hdr class Nifti2Image(Nifti1Image): """ Class for single file NIfTI2 format image """ header_class = Nifti2Header + _meta_sniff_len = header_class.sizeof_hdr def load(filename): diff --git a/nibabel/openers.py b/nibabel/openers.py index eb1c8d3708..26bbb09d7b 100644 --- a/nibabel/openers.py +++ b/nibabel/openers.py @@ -155,12 +155,15 @@ class ImageOpener(Opener): attributes, via the `register_ex_from_images`. The class can therefore change state when image classes are defined. """ + compress_ext_map = Opener.compress_ext_map.copy() @classmethod def register_ext_from_image(opener_klass, ext, func_def): """Decorator for adding extension / opener_function associations. - Should be used to decorate classes. + Should be used to decorate classes. Updates ImageOpener class with + desired extension / opener association. Updates decorated class by + adding ```ext``` to ```klass.alternate_exts```. Parameters ---------- @@ -176,12 +179,12 @@ def register_ext_from_image(opener_klass, ext, func_def): Returns ------- - opener_klass, with a side-effect of updating the ImageOpener class - with the desired extension / opener association. + opener_klass """ def decorate(klass): assert ext not in opener_klass.compress_ext_map, \ "Cannot redefine extension-function mappings." opener_klass.compress_ext_map[ext] = func_def + klass.valid_exts += (ext,) return klass return decorate diff --git a/nibabel/parrec.py b/nibabel/parrec.py index ef4c11c698..cfbc77b1db 100644 --- a/nibabel/parrec.py +++ b/nibabel/parrec.py @@ -1020,8 +1020,12 @@ def get_sorted_slice_indices(self): class PARRECImage(SpatialImage): """PAR/REC image""" header_class = PARRECHeader + valid_exts = ('.rec', '.par') files_types = (('image', '.rec'), ('header', '.par')) + makeable = False + rw = False + ImageArrayProxy = PARRECArrayProxy @classmethod diff --git a/nibabel/spatialimages.py b/nibabel/spatialimages.py index eb4befa077..521f8cf307 100644 --- a/nibabel/spatialimages.py +++ b/nibabel/spatialimages.py @@ -141,8 +141,10 @@ import numpy as np -from .filename_parser import types_filenames, TypesFilenamesError +from .filename_parser import (types_filenames, TypesFilenamesError, + splitext_addext) from .fileholders import FileHolder +from .openers import ImageOpener from .volumeutils import shape_zoom_affine @@ -319,11 +321,16 @@ class ImageFileError(Exception): class SpatialImage(object): + ''' Template class for images ''' header_class = Header + _meta_sniff_len = 0 files_types = (('image', None),) - _compressed_exts = () + valid_exts = () + _compressed_suffixes = () + + makeable = True # Used in test code + rw = True # Used in test code - ''' Template class for images ''' def __init__(self, dataobj, affine, header=None, extra=None, file_map=None): ''' Initialize image @@ -745,7 +752,7 @@ def filespec_to_file_map(klass, filespec): try: filenames = types_filenames( filespec, klass.files_types, - trailing_suffixes=klass._compressed_exts) + trailing_suffixes=klass._compressed_suffixes) except TypesFilenamesError: raise ImageFileError( 'Filespec "{0}" does not look right for class {1}'.format( @@ -866,6 +873,105 @@ def from_image(klass, img): klass.header_class.from_header(img.header), extra=img.extra.copy()) + @classmethod + def _sniff_meta_for(klass, filename, sniff_nbytes, sniff=None): + """ Sniff metadata for image represented by `filename` + + Parameters + ---------- + filename : str + Filename for an image, or an image header (metadata) file. + If `filename` points to an image data file, and the image type has + a separate "header" file, we work out the name of the header file, + and read from that instead of `filename`. + sniff_nbytes : int + Number of bytes to read from the image or metadata file + sniff : (bytes, fname), optional + The result of a previous call to `_sniff_meta_for`. If fname + matches the computed header file name, `sniff` is returned without + rereading the file. + + Returns + ------- + sniff : None or (bytes, fname) + None if we could not read the image or metadata file. `sniff[0]` + is either length `sniff_nbytes` or the length of the image / + metadata file, whichever is the shorter. `fname` is the name of + the sniffed file. + """ + froot, ext, trailing = splitext_addext(filename, + klass._compressed_suffixes) + # Determine the metadata location + t_fnames = types_filenames( + filename, + klass.files_types, + trailing_suffixes=klass._compressed_suffixes) + meta_fname = t_fnames.get('header', filename) + + # Do not re-sniff if it would be from the same file + if sniff is not None and sniff[1] == meta_fname: + return sniff + + # Attempt to sniff from metadata location + try: + with ImageOpener(meta_fname, 'rb') as fobj: + binaryblock = fobj.read(sniff_nbytes) + except IOError: + return None + return (binaryblock, meta_fname) + + @classmethod + def path_maybe_image(klass, filename, sniff=None, sniff_max=1024): + """ Return True if `filename` may be image matching this class + + Parameters + ---------- + filename : str + Filename for an image, or an image header (metadata) file. + If `filename` points to an image data file, and the image type has + a separate "header" file, we work out the name of the header file, + and read from that instead of `filename`. + sniff : None or (bytes, filename), optional + Bytes content read from a previous call to this method, on another + class, with metadata filename. This allows us to read metadata + bytes once from the image or header, and pass this read set of + bytes to other image classes, therefore saving a repeat read of the + metadata. `filename` is used to validate that metadata would be + read from the same file, re-reading if not. None forces this + method to read the metadata. + sniff_max : int, optional + The maximum number of bytes to read from the metadata. If the + metadata file is long enough, we read this many bytes from the + file, otherwise we read to the end of the file. Longer values + sniff more of the metadata / image file, making it more likely that + the returned sniff will be useful for later calls to + ``path_maybe_image`` for other image classes. + + Returns + ------- + maybe_image : bool + True if `filename` may be valid for an image of this class. + sniff : None or (bytes, filename) + Read bytes content from found metadata. May be None if the file + does not appear to have useful metadata. + """ + froot, ext, trailing = splitext_addext(filename, + klass._compressed_suffixes) + if ext.lower() not in klass.valid_exts: + return False, sniff + if not hasattr(klass.header_class, 'may_contain_header'): + return True, sniff + + # Force re-sniff on too-short sniff + if sniff is not None and len(sniff[0]) < klass._meta_sniff_len: + sniff = None + sniff = klass._sniff_meta_for(filename, + max(klass._meta_sniff_len, sniff_max), + sniff) + 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 __getitem__(self): ''' No slicing or dictionary interface for images ''' diff --git a/nibabel/spm2analyze.py b/nibabel/spm2analyze.py index 28d04bc7a1..7ab93d4514 100644 --- a/nibabel/spm2analyze.py +++ b/nibabel/spm2analyze.py @@ -113,12 +113,22 @@ def get_slope_inter(self): return slope, inter return None, None + @classmethod + def may_contain_header(klass, binaryblock): + if len(binaryblock) < klass.sizeof_hdr: + return False + + hdr_struct = np.ndarray(shape=(), dtype=header_dtype, + buffer=binaryblock[:klass.sizeof_hdr]) + bs_hdr_struct = hdr_struct.byteswap() + return (binaryblock[344:348] not in (b'ni1\x00', b'n+1\x00') and + 348 in (hdr_struct['sizeof_hdr'], bs_hdr_struct['sizeof_hdr'])) + class Spm2AnalyzeImage(spm99.Spm99AnalyzeImage): """ Class for SPM2 variant of basic Analyze image """ header_class = Spm2AnalyzeHeader - load = Spm2AnalyzeImage.load save = Spm2AnalyzeImage.instance_to_filename diff --git a/nibabel/spm99analyze.py b/nibabel/spm99analyze.py index f3d565e41d..fdf6c2d31a 100644 --- a/nibabel/spm99analyze.py +++ b/nibabel/spm99analyze.py @@ -17,6 +17,8 @@ from .batteryrunners import Report from . import analyze # module import from .keywordonly import kw_only_meth +from .optpkg import optional_package +have_scipy = optional_package('scipy')[1] ''' Support subtle variations of SPM version of Analyze ''' header_key_dtd = analyze.header_key_dtd @@ -237,6 +239,9 @@ class Spm99AnalyzeImage(analyze.AnalyzeImage): files_types = (('image', '.img'), ('header', '.hdr'), ('mat', '.mat')) + has_affine = True + makeable = True + rw = have_scipy @classmethod @kw_only_meth(1) diff --git a/nibabel/tests/test_analyze.py b/nibabel/tests/test_analyze.py index b567bc3c21..4a0a4180ab 100644 --- a/nibabel/tests/test_analyze.py +++ b/nibabel/tests/test_analyze.py @@ -141,7 +141,7 @@ def test_log_checks(self): # magic hdr = HC() with suppress_warnings(): - hdr['sizeof_hdr'] = 350 # severity 30 + hdr['sizeof_hdr'] = 350 # severity 30 fhdr, message, raiser = self.log_chk(hdr, 30) assert_equal(fhdr['sizeof_hdr'], self.sizeof_hdr) assert_equal(message, @@ -159,6 +159,7 @@ def test_log_checks(self): fhdr, message, raiser = self.log_chk(hdr, 40) assert_equal(message, 'data code -1 not recognized; ' 'not attempting fix') + assert_raises(*raiser) # datatype not supported hdr['datatype'] = 255 # severity 40 diff --git a/nibabel/tests/test_files_interface.py b/nibabel/tests/test_files_interface.py index 2c0bdfff0f..2137de8d09 100644 --- a/nibabel/tests/test_files_interface.py +++ b/nibabel/tests/test_files_interface.py @@ -12,7 +12,7 @@ import numpy as np -from .. import class_map, Nifti1Image, Nifti1Pair, MGHImage +from .. import Nifti1Image, Nifti1Pair, MGHImage, all_image_classes from ..externals.six import BytesIO from ..fileholders import FileHolderError @@ -25,15 +25,14 @@ def test_files_images(): # test files creation in image classes arr = np.zeros((2,3,4)) aff = np.eye(4) - for img_def in class_map.values(): - klass = img_def['class'] + for klass in all_image_classes: file_map = klass.make_file_map() for key, value in file_map.items(): assert_equal(value.filename, None) assert_equal(value.fileobj, None) assert_equal(value.pos, 0) # If we can't create new images in memory without loading, bail here - if not img_def['makeable']: + if not klass.makeable: continue # MGHImage accepts only a few datatypes # so we force a type change to float32 @@ -83,22 +82,21 @@ def test_files_interface(): def test_round_trip(): - # write an image to files - data = np.arange(24, dtype='i4').reshape((2,3,4)) - aff = np.eye(4) - klasses = [val['class'] for key, val in class_map.items() - if val['rw']] - for klass in klasses: - file_map = klass.make_file_map() - for key in file_map: - file_map[key].fileobj = BytesIO() - img = klass(data, aff) - img.file_map = file_map - img.to_file_map() - # read it back again from the written files - img2 = klass.from_file_map(file_map) - assert_array_equal(img2.get_data(), data) - # write, read it again - img2.to_file_map() - img3 = klass.from_file_map(file_map) - assert_array_equal(img3.get_data(), data) + # write an image to files + data = np.arange(24, dtype='i4').reshape((2, 3, 4)) + aff = np.eye(4) + klasses = filter(lambda klass: klass.rw, all_image_classes) + for klass in klasses: + file_map = klass.make_file_map() + for key in file_map: + file_map[key].fileobj = BytesIO() + img = klass(data, aff) + img.file_map = file_map + img.to_file_map() + # read it back again from the written files + img2 = klass.from_file_map(file_map) + assert_array_equal(img2.get_data(), data) + # write, read it again + img2.to_file_map() + img3 = klass.from_file_map(file_map) + assert_array_equal(img3.get_data(), data) diff --git a/nibabel/tests/test_image_api.py b/nibabel/tests/test_image_api.py index fed9f85d5b..f5a081fd8b 100644 --- a/nibabel/tests/test_image_api.py +++ b/nibabel/tests/test_image_api.py @@ -24,6 +24,7 @@ import warnings from functools import partial +from ..externals.six import string_types import numpy as np @@ -283,11 +284,21 @@ class LoadImageAPI(GenericImageAPI): # Sequence of dictionaries, where dictionaries have keys # 'fname" in addition to keys for ``params`` (see obj_params docstring) example_images = () + # Class of images to be tested + klass = None def obj_params(self): for img_params in self.example_images: yield lambda : self.loader(img_params['fname']), img_params + def validate_path_maybe_image(self, imaker, params): + for img_params in self.example_images: + test, sniff = self.klass.path_maybe_image(img_params['fname']) + assert_true(isinstance(test, bool)) + if sniff is not None: + assert isinstance(sniff[0], bytes) + assert isinstance(sniff[1], string_types) + class MakeImageAPI(LoadImageAPI): """ Validation for images we can make with ``func(data, affine, header)`` @@ -346,48 +357,48 @@ def header_maker(self): class TestAnalyzeAPI(ImageHeaderAPI): """ General image validation API instantiated for Analyze images """ - image_maker = AnalyzeImage + klass = image_maker = AnalyzeImage has_scaling = False can_save = True standard_extension = '.img' class TestSpatialImageAPI(TestAnalyzeAPI): - image_maker = SpatialImage + klass = image_maker = SpatialImage can_save = False class TestSpm99AnalyzeAPI(TestAnalyzeAPI): # SPM-type analyze need scipy for mat file IO - image_maker = Spm99AnalyzeImage + klass = image_maker = Spm99AnalyzeImage has_scaling = True can_save = have_scipy class TestSpm2AnalyzeAPI(TestSpm99AnalyzeAPI): - image_maker = Spm2AnalyzeImage + klass = image_maker = Spm2AnalyzeImage class TestNifti1PairAPI(TestSpm99AnalyzeAPI): - image_maker = Nifti1Pair + klass = image_maker = Nifti1Pair can_save = True class TestNifti1API(TestNifti1PairAPI): - image_maker = Nifti1Image + klass = image_maker = Nifti1Image standard_extension = '.nii' class TestNifti2PairAPI(TestNifti1PairAPI): - image_maker = Nifti2Pair + klass = image_maker = Nifti2Pair class TestNifti2API(TestNifti1API): - image_maker = Nifti2Image + klass = image_maker = Nifti2Image class TestMinc1API(ImageHeaderAPI): - image_maker = Minc1Image + klass = image_maker = Minc1Image loader = minc1.load example_images = MINC1_EXAMPLE_IMAGES @@ -397,7 +408,7 @@ def __init__(self): if not have_h5py: raise SkipTest('Need h5py for these tests') - image_maker = Minc2Image + klass = image_maker = Minc2Image loader = minc2.load example_images = MINC2_EXAMPLE_IMAGES @@ -406,6 +417,7 @@ class TestPARRECAPI(LoadImageAPI): def loader(self, fname): return parrec.load(fname) + klass = parrec.PARRECImage example_images = PARREC_EXAMPLE_IMAGES @@ -418,7 +430,7 @@ def loader(self, fname): class TestMGHAPI(ImageHeaderAPI): - image_maker = MGHImage + klass = image_maker = MGHImage example_shapes = ((2, 3, 4), (2, 3, 4, 5)) # MGH can only do >= 3D has_scaling = True can_save = True diff --git a/nibabel/tests/test_image_load_save.py b/nibabel/tests/test_image_load_save.py index 7ade7d09c3..362c15313b 100644 --- a/nibabel/tests/test_image_load_save.py +++ b/nibabel/tests/test_image_load_save.py @@ -8,17 +8,14 @@ ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## ''' Tests for loader function ''' from __future__ import division, print_function, absolute_import -from os.path import join as pjoin, dirname +from ..externals.six import BytesIO + import shutil +from os.path import dirname, join as pjoin from tempfile import mkdtemp -from ..externals.six import BytesIO import numpy as np -# If we don't have scipy, then we cannot write SPM format files -from ..optpkg import optional_package -_, have_scipy, _ = optional_package('scipy') - from .. import analyze as ana from .. import spm99analyze as spm99 from .. import spm2analyze as spm2 @@ -26,15 +23,15 @@ from .. import loadsave as nils from .. import (Nifti1Image, Nifti1Header, Nifti1Pair, Nifti2Image, Nifti2Pair, Minc1Image, Minc2Image, Spm2AnalyzeImage, Spm99AnalyzeImage, - AnalyzeImage, MGHImage, class_map) - + AnalyzeImage, MGHImage, all_image_classes) from ..tmpdirs import InTemporaryDirectory - from ..volumeutils import native_code, swapped_code +from ..optpkg import optional_package from numpy.testing import assert_array_equal, assert_array_almost_equal -from nose.tools import assert_true, assert_equal, assert_raises +from nose.tools import assert_true, assert_equal +_, have_scipy, _ = optional_package('scipy') # No scipy=>no SPM-format writing DATA_PATH = pjoin(dirname(__file__), 'data') MGH_DATA_PATH = pjoin(dirname(__file__), '..', 'freesurfer', 'tests', 'data') @@ -53,16 +50,14 @@ def test_conversion(): affine = np.diag([1, 2, 3, 1]) for npt in np.float32, np.int16: data = np.arange(np.prod(shape), dtype=npt).reshape(shape) - for r_class_def in class_map.values(): - r_class = r_class_def['class'] - if not r_class_def['makeable']: + for r_class in all_image_classes: + if not r_class.makeable: continue img = r_class(data, affine) img.set_data_dtype(npt) - for w_class_def in class_map.values(): - if not w_class_def['makeable']: + for w_class in all_image_classes: + if not w_class.makeable: continue - w_class = w_class_def['class'] img2 = w_class.from_image(img) assert_array_equal(img2.get_data(), data) assert_array_equal(img2.affine, affine) diff --git a/nibabel/tests/test_image_types.py b/nibabel/tests/test_image_types.py new file mode 100644 index 0000000000..b6ca7ea938 --- /dev/null +++ b/nibabel/tests/test_image_types.py @@ -0,0 +1,128 @@ +# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*- +# vi: set ft=python sts=4 ts=4 sw=4 et: +### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## +# +# See COPYING file distributed along with the NiBabel package for the +# copyright and license terms. +# +### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## +''' Tests for is_image / may_contain_header functions ''' +from __future__ import division, print_function, absolute_import + +import copy +from os.path import dirname, basename, join as pjoin + +import numpy as np + +from .. import (Nifti1Image, Nifti1Header, Nifti1Pair, + Nifti2Image, Nifti2Header, Nifti2Pair, + AnalyzeImage, AnalyzeHeader, + Minc1Image, Minc2Image, + Spm2AnalyzeImage, Spm99AnalyzeImage, + MGHImage, all_image_classes) + +from nose.tools import assert_true + +DATA_PATH = pjoin(dirname(__file__), 'data') + + +def test_sniff_and_guessed_image_type(img_klasses=all_image_classes): + # Loop over all test cases: + # * whether a sniff is provided or not + # * randomizing the order of image classes + # * over all known image types + + # For each, we expect: + # * When the file matches the expected class, things should + # either work, or fail if we're doing bad stuff. + # * When the file is a mismatch, the functions should not throw. + def test_image_class(img_path, expected_img_klass): + """ Compare an image of one image class to all others. + + The function should make sure that it loads the image with the expected + class, but failing when given a bad sniff (when the sniff is used).""" + + def check_img(img_path, img_klass, sniff_mode, sniff, expect_success, + msg): + """Embedded function to do the actual checks expected.""" + + if sniff_mode == 'no_sniff': + # Don't pass any sniff--not even "None" + is_img, new_sniff = img_klass.path_maybe_image(img_path) + elif sniff_mode in ('empty', 'irrelevant', 'bad_sniff'): + # Add img_path to binaryblock sniff parameters + is_img, new_sniff = img_klass.path_maybe_image( + img_path, (sniff, img_path)) + else: + # Pass a sniff, but don't reuse across images. + is_img, new_sniff = img_klass.path_maybe_image(img_path, sniff) + + if expect_success: + # Check that the sniff returned is appropriate. + new_msg = '%s returned sniff==None (%s)' % (img_klass.__name__, + msg) + expected_sizeof_hdr = getattr(img_klass.header_class, + 'sizeof_hdr', 0) + current_sizeof_hdr = 0 if new_sniff is None else \ + len(new_sniff[0]) + assert_true(current_sizeof_hdr >= expected_sizeof_hdr, new_msg) + + # Check that the image type was recognized. + new_msg = '%s (%s) image is%s a %s image.' % ( + basename(img_path), + msg, + '' if is_img else ' not', + img_klass.__name__) + assert_true(is_img, new_msg) + + if sniff_mode == 'vanilla': + return new_sniff + else: + return sniff + + sizeof_hdr = getattr(expected_img_klass.header_class, 'sizeof_hdr', 0) + + for sniff_mode, sniff in dict( + vanilla=None, # use the sniff of the previous item + no_sniff=None, # Don't pass a sniff + none=None, # pass None as the sniff, should query in fn + empty=b'', # pass an empty sniff, should query in fn + irrelevant=b'a' * (sizeof_hdr - 1), # A too-small sniff, query + bad_sniff=b'a' * sizeof_hdr, # Bad sniff, should fail + ).items(): + + for klass in img_klasses: + if klass == expected_img_klass: + # Class will load unless you pass a bad sniff, + # or the header ignores the sniff + expect_success = (sniff_mode != 'bad_sniff' or + sizeof_hdr == 0) + else: + expect_success = False # Not sure the relationships + + # Reuse the sniff... but it will only change for some + # sniff_mode values. + msg = '%s/ %s/ %s' % (expected_img_klass.__name__, sniff_mode, + str(expect_success)) + sniff = check_img(img_path, klass, sniff_mode=sniff_mode, + sniff=sniff, expect_success=expect_success, + msg=msg) + + # Test whether we can guess the image type from example files + for img_filename, image_klass in [('example4d.nii.gz', Nifti1Image), + ('nifti1.hdr', Nifti1Pair), + ('example_nifti2.nii.gz', Nifti2Image), + ('nifti2.hdr', Nifti2Pair), + ('tiny.mnc', Minc1Image), + ('small.mnc', Minc2Image), + ('test.mgz', MGHImage), + ('analyze.hdr', Spm2AnalyzeImage)]: + # print('Testing: %s %s' % (img_filename, image_klass.__name__)) + test_image_class(pjoin(DATA_PATH, img_filename), image_klass) + + +def test_sniff_and_guessed_image_type_randomized(): + """Re-test image classes, but in a randomized order.""" + img_klasses = copy.copy(all_image_classes) + np.random.shuffle(img_klasses) + test_sniff_and_guessed_image_type(img_klasses=img_klasses) diff --git a/nibabel/tests/test_openers.py b/nibabel/tests/test_openers.py index 3bd5ccb98c..08f9730ace 100644 --- a/nibabel/tests/test_openers.py +++ b/nibabel/tests/test_openers.py @@ -57,6 +57,7 @@ def test_Opener(): # mode is gently ignored fobj = Opener(obj, mode='r') + def test_Opener_various(): # Check we can do all sorts of files here message = b"Oh what a giveaway" @@ -84,12 +85,16 @@ def test_Opener_various(): # Just check there is a fileno assert_not_equal(fobj.fileno(), 0) + def test_BinOpener(): with error_warnings(): assert_raises(DeprecationWarning, BinOpener, 'test.txt', 'r') + class TestImageOpener: + valid_exts = () + def setUp(self): self.compress_ext_map = ImageOpener.compress_ext_map.copy() @@ -115,12 +120,16 @@ def file_opener(fileish, mode): dec(self.__class__) assert_equal(n_associations + 1, len(ImageOpener.compress_ext_map)) assert_true('.foo' in ImageOpener.compress_ext_map) + assert_true('.foo' in self.valid_exts) with InTemporaryDirectory(): with ImageOpener('test.foo', 'w'): pass assert_true(os.path.exists('test.foo')) + # Check this doesn't add anything to parent + assert_false('.foo' in Opener.compress_ext_map) + def test_file_like_wrapper(): # Test wrapper using BytesIO (full API) diff --git a/nibabel/tests/test_spatialimages.py b/nibabel/tests/test_spatialimages.py index d2d6d0a93e..7b684d1fd2 100644 --- a/nibabel/tests/test_spatialimages.py +++ b/nibabel/tests/test_spatialimages.py @@ -371,9 +371,11 @@ def test_load_mmap(self): back_img = func(param1, **kwargs) back_data = back_img.get_data() if expected_mode is None: - assert_false(isinstance(back_data, np.memmap)) + assert_false(isinstance(back_data, np.memmap), + 'Should not be a %s' % img_klass.__name__) else: - assert_true(isinstance(back_data, np.memmap)) + assert_true(isinstance(back_data, np.memmap), + 'Not a %s' % img_klass.__name__) if self.check_mmap_mode: assert_equal(back_data.mode, expected_mode) del back_img, back_data