diff --git a/nibabel/brikhead.py b/nibabel/brikhead.py new file mode 100644 index 0000000000..9e521e61b6 --- /dev/null +++ b/nibabel/brikhead.py @@ -0,0 +1,627 @@ +# 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. +# +### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## +""" +Class for reading AFNI BRIK/HEAD datasets + +See https://afni.nimh.nih.gov/pub/dist/doc/program_help/README.attributes.html +for information on what is required to have a valid BRIK/HEAD dataset. + +Unless otherwise noted, descriptions AFNI attributes in the code refer to this +document. + +Notes +----- + +In the AFNI HEAD file, the first two values of the attribute DATASET_RANK +determine the shape of the data array stored in the corresponding BRIK file. +The first value, DATASET_RANK[0], must be set to 3 denoting a 3D image. The +second value, DATASET_RANK[1], determines how many "sub-bricks" (in AFNI +parlance) / volumes there are along the fourth (traditionally, but not +exclusively) time axis. Thus, DATASET_RANK[1] will (at least as far as I (RM) +am aware) always be >= 1. This permits sub-brick indexing common in AFNI +programs (e.g., example4d+orig'[0]'). +""" +from __future__ import print_function, division + +from copy import deepcopy +import os +import re + +import numpy as np +from six import string_types + +from .arrayproxy import ArrayProxy +from .fileslice import strided_scalar +from .keywordonly import kw_only_meth +from .spatialimages import ( + SpatialImage, + SpatialHeader, + HeaderDataError, + ImageDataError +) +from .volumeutils import Recoder + +# used for doc-tests +filepath = os.path.dirname(os.path.realpath(__file__)) +datadir = os.path.realpath(os.path.join(filepath, 'tests/data')) + +_attr_dic = { + 'string': str, + 'integer': int, + 'float': float +} + +_endian_dict = { + 'LSB_FIRST': '<', + 'MSB_FIRST': '>', +} + +_dtype_dict = { + 0: 'B', + 1: 'h', + 3: 'f', + 5: 'D', +} + +space_codes = Recoder(( + (0, 'unknown', ''), + (1, 'scanner', 'ORIG'), + (3, 'talairach', 'TLRC'), + (4, 'mni', 'MNI')), fields=('code', 'label', 'space')) + + +class AFNIImageError(ImageDataError): + """Error when reading AFNI BRIK files""" + + +class AFNIHeaderError(HeaderDataError): + """Error when reading AFNI HEAD file""" + + +DATA_OFFSET = 0 +TYPE_RE = re.compile('type\s*=\s*(string|integer|float)-attribute\s*\n') +NAME_RE = re.compile('name\s*=\s*(\w+)\s*\n') + + +def _unpack_var(var): + """ + Parses key : value pair from `var` + + Parameters + ---------- + var : str + Entry from HEAD file + + Returns + ------- + name : str + Name of attribute + value : object + Value of attribute + + Examples + -------- + >>> var = "type = integer-attribute\\nname = BRICK_TYPES\\ncount = 1\\n1\\n" + >>> name, attr = _unpack_var(var) + >>> print(name, attr) + BRICK_TYPES 1 + >>> var = "type = string-attribute\\nname = TEMPLATE_SPACE\\ncount = 5\\n'ORIG~" + >>> name, attr = _unpack_var(var) + >>> print(name, attr) + TEMPLATE_SPACE ORIG + """ + + err_msg = ('Please check HEAD file to ensure it is AFNI compliant. ' + 'Offending attribute:\n%s' % var) + atype, aname = TYPE_RE.findall(var), NAME_RE.findall(var) + if len(atype) != 1: + raise AFNIHeaderError('Invalid attribute type entry in HEAD file. ' + '%s' % err_msg) + if len(aname) != 1: + raise AFNIHeaderError('Invalid attribute name entry in HEAD file. ' + '%s' % err_msg) + atype = _attr_dic.get(atype[0], str) + attr = ' '.join(var.strip().splitlines()[3:]) + if atype is not str: + try: + attr = [atype(f) for f in attr.split()] + except ValueError: + raise AFNIHeaderError('Failed to read variable from HEAD file due ' + 'to improper type casting. %s' % err_msg) + else: + # AFNI string attributes will always start with open single quote and + # end with a tilde (NUL). These attributes CANNOT contain tildes (so + # stripping is safe), but can contain single quotes (so we replace) + attr = attr.replace('\'', '', 1).rstrip('~') + + return aname[0], attr[0] if len(attr) == 1 else attr + + +def _get_datatype(info): + """ + Gets datatype of BRIK file associated with HEAD file yielding `info` + + Parameters + ---------- + info : dict + As obtained by :func:`parse_AFNI_header` + + Returns + ------- + dt : np.dtype + Datatype of BRIK file associated with HEAD + + Notes + ----- + ``BYTEORDER_STRING`` may be absent, signifying platform native byte order, + or contain one of "LSB_FIRST" or "MSB_FIRST". + + ``BRICK_TYPES`` gives the storage data type for each sub-brick, with + 0=uint, 1=int16, 3=float32, 5=complex64 (see ``_dtype_dict``). This should + generally be the same value for each sub-brick in the dataset. + """ + bo = info['BYTEORDER_STRING'] + bt = info['BRICK_TYPES'] + if isinstance(bt, list): + if np.unique(bt).size > 1: + raise AFNIImageError('Can\'t load file with multiple data types.') + bt = bt[0] + bo = _endian_dict.get(bo, '=') + bt = _dtype_dict.get(bt, None) + if bt is None: + raise AFNIImageError('Can\'t deduce image data type.') + return np.dtype(bo + bt) + + +def parse_AFNI_header(fobj): + """ + Parses `fobj` to extract information from HEAD file + + Parameters + ---------- + fobj : file-like object + AFNI HEAD file object or filename. If file object, should + implement at least ``read`` + + Returns + ------- + info : dict + Dictionary containing AFNI-style key:value pairs from HEAD file + + Examples + -------- + >>> fname = os.path.join(datadir, 'example4d+orig.HEAD') + >>> info = parse_AFNI_header(fname) + >>> print(info['BYTEORDER_STRING']) + LSB_FIRST + >>> print(info['BRICK_TYPES']) + [1, 1, 1] + """ + # edge case for being fed a filename instead of a file object + if isinstance(fobj, string_types): + with open(fobj, 'rt') as src: + return parse_AFNI_header(src) + # unpack variables in HEAD file + head = fobj.read().split('\n\n') + return {key: value for key, value in map(_unpack_var, head)} + + +class AFNIArrayProxy(ArrayProxy): + """ Proxy object for AFNI image array. + + Attributes + ---------- + scaling : np.ndarray + Scaling factor (one factor per volume/sub-brick) for data. Default is + None + """ + + @kw_only_meth(2) + def __init__(self, file_like, header, mmap=True, keep_file_open=None): + """ + Initialize AFNI array proxy + + Parameters + ---------- + file_like : file-like object + File-like object or filename. If file-like object, should implement + at least ``read`` and ``seek``. + header : ``AFNIHeader`` object + mmap : {True, False, 'c', 'r'}, optional, keyword only + `mmap` controls the use of numpy memory mapping for reading data. + If False, do not try numpy ``memmap`` for data array. If one of + {'c', 'r'}, try numpy memmap with ``mode=mmap``. A `mmap` value of + True gives the same behavior as ``mmap='c'``. If `file_like` + cannot be memory-mapped, ignore `mmap` value and read array from + file. + keep_file_open : { None, 'auto', True, False }, optional, keyword only + `keep_file_open` controls whether a new file handle is created + every time the image is accessed, or a single file handle is + created and used for the lifetime of this ``ArrayProxy``. If + ``True``, a single file handle is created and used. If ``False``, + a new file handle is created every time the image is accessed. If + ``'auto'``, and the optional ``indexed_gzip`` dependency is + present, a single file handle is created and persisted. If + ``indexed_gzip`` is not available, behavior is the same as if + ``keep_file_open is False``. If ``file_like`` refers to an open + file handle, this setting has no effect. The default value + (``None``) will result in the value of + ``nibabel.arrayproxy.KEEP_FILE_OPEN_DEFAULT` being used. + """ + super(AFNIArrayProxy, self).__init__(file_like, + header, + mmap=mmap, + keep_file_open=keep_file_open) + self._scaling = header.get_data_scaling() + + @property + def scaling(self): + return self._scaling + + def __array__(self): + raw_data = self.get_unscaled() + # datatype may change if applying self._scaling + return raw_data if self.scaling is None else raw_data * self.scaling + + def __getitem__(self, slicer): + raw_data = super(AFNIArrayProxy, self).__getitem__(slicer) + # apply volume specific scaling (may change datatype!) + if self.scaling is not None: + fake_data = strided_scalar(self._shape) + _, scaling = np.broadcast_arrays(fake_data, self.scaling) + raw_data = raw_data * scaling[slicer] + return raw_data + + +class AFNIHeader(SpatialHeader): + """Class for AFNI header""" + + def __init__(self, info): + """ + Initialize AFNI header object + + Parameters + ---------- + info : dict + Information from HEAD file as obtained by :func:`parse_AFNI_header` + + Examples + -------- + >>> fname = os.path.join(datadir, 'example4d+orig.HEAD') + >>> header = AFNIHeader(parse_AFNI_header(fname)) + >>> header.get_data_dtype() + dtype('int16') + >>> header.get_zooms() + (3.0, 3.0, 3.0, 3.0) + >>> header.get_data_shape() + (33, 41, 25, 3) + """ + self.info = info + dt = _get_datatype(self.info) + super(AFNIHeader, self).__init__(data_dtype=dt, + shape=self._calc_data_shape(), + zooms=self._calc_zooms()) + + @classmethod + def from_header(klass, header=None): + if header is None: + raise AFNIHeaderError('Cannot create AFNIHeader from nothing.') + if type(header) == klass: + return header.copy() + raise AFNIHeaderError('Cannot create AFNIHeader from non-AFNIHeader.') + + @classmethod + def from_fileobj(klass, fileobj): + info = parse_AFNI_header(fileobj) + return klass(info) + + def copy(self): + return AFNIHeader(deepcopy(self.info)) + + def _calc_data_shape(self): + """ + Calculate the output shape of the image data + + Returns length 3 tuple for 3D image, length 4 tuple for 4D. + + Returns + ------- + (x, y, z, t) : tuple of int + + Notes + ----- + ``DATASET_RANK[0]`` gives number of spatial dimensions (and apparently + must be 3). ``DATASET_RANK[1]`` gives the number of sub-bricks. + ``DATASET_DIMENSIONS`` is length 3, giving the number of voxels in i, + j, k. + """ + dset_rank = self.info['DATASET_RANK'] + shape = tuple(self.info['DATASET_DIMENSIONS'][:dset_rank[0]]) + n_vols = dset_rank[1] + return shape + (n_vols,) + + def _calc_zooms(self): + """ + Get image zooms from header data + + Spatial axes are first three indices, time axis is last index. If + dataset is not a time series the last value will be zero. + + Returns + ------- + zooms : tuple + + Notes + ----- + Gets zooms from attributes ``DELTA`` and ``TAXIS_FLOATS``. + + ``DELTA`` gives (x,y,z) voxel sizes. + + ``TAXIS_FLOATS`` should be length 5, with first entry giving "Time + origin", and second giving "Time step (TR)". + """ + xyz_step = tuple(np.abs(self.info['DELTA'])) + t_step = self.info.get('TAXIS_FLOATS', (0, 0,)) + if len(t_step) > 0: + t_step = (t_step[1],) + return xyz_step + t_step + + def get_space(self): + """ + Return label for anatomical space to which this dataset is aligned. + + Returns + ------- + space : str + AFNI "space" designation; one of [ORIG, ANAT, TLRC, MNI] + + Notes + ----- + There appears to be documentation for these spaces at + https://afni.nimh.nih.gov/pub/dist/atlases/elsedemo/AFNI_atlas_spaces.niml + """ + listed_space = self.info.get('TEMPLATE_SPACE', 0) + space = space_codes.space[listed_space] + return space + + def get_affine(self): + """ + Returns affine of dataset + + Examples + -------- + >>> fname = os.path.join(datadir, 'example4d+orig.HEAD') + >>> header = AFNIHeader(parse_AFNI_header(fname)) + >>> header.get_affine() + array([[ -3. , -0. , -0. , 49.5 ], + [ -0. , -3. , -0. , 82.312 ], + [ 0. , 0. , 3. , -52.3511], + [ 0. , 0. , 0. , 1. ]]) + """ + # AFNI default is RAI- == LPS+ == DICOM order. We need to flip RA sign + # to align with nibabel RAS+ system + affine = np.asarray(self.info['IJK_TO_DICOM_REAL']).reshape(3, 4) + affine = np.row_stack((affine * [[-1], [-1], [1]], + [0, 0, 0, 1])) + return affine + + def get_data_scaling(self): + """ + AFNI applies volume-specific data scaling + + Examples + -------- + >>> fname = os.path.join(datadir, 'scaled+tlrc.HEAD') + >>> header = AFNIHeader(parse_AFNI_header(fname)) + >>> header.get_data_scaling() + array([ 3.88336300e-08]) + """ + # BRICK_FLOAT_FACS has one value per sub-brick, such that the scaled + # values for sub-brick array [n] are the values read from disk * + # BRICK_FLOAT_FACS[n] + floatfacs = self.info.get('BRICK_FLOAT_FACS', None) + if floatfacs is None or not np.any(floatfacs): + return None + scale = np.ones(self.info['DATASET_RANK'][1]) + floatfacs = np.atleast_1d(floatfacs) + scale[floatfacs.nonzero()] = floatfacs[floatfacs.nonzero()] + return scale + + def get_slope_inter(self): + """ + Use `self.get_data_scaling()` instead + + Holdover because ``AFNIArrayProxy`` (inheriting from ``ArrayProxy``) + requires this functionality so as to not error. + """ + return None, None + + def get_data_offset(self): + """Data offset in BRIK file + + Offset is always 0. + """ + return DATA_OFFSET + + def get_volume_labels(self): + """ + Returns volume labels + + Returns + ------- + labels : list of str + Labels for volumes along fourth dimension + + Examples + -------- + >>> header = AFNIHeader(parse_AFNI_header(os.path.join(datadir, 'example4d+orig.HEAD'))) + >>> header.get_volume_labels() + ['#0', '#1', '#2'] + """ + labels = self.info.get('BRICK_LABS', None) + if labels is not None: + labels = labels.split('~') + return labels + + +class AFNIImage(SpatialImage): + """ + AFNI Image file + + Can be loaded from either the BRIK or HEAD file (but MUST specify one!) + + Examples + -------- + >>> import nibabel as nib + >>> brik = nib.load(os.path.join(datadir, 'example4d+orig.BRIK.gz')) + >>> brik.shape + (33, 41, 25, 3) + >>> brik.affine + array([[ -3. , -0. , -0. , 49.5 ], + [ -0. , -3. , -0. , 82.312 ], + [ 0. , 0. , 3. , -52.3511], + [ 0. , 0. , 0. , 1. ]]) + >>> head = load(os.path.join(datadir, 'example4d+orig.HEAD')) + >>> np.array_equal(head.get_data(), brik.get_data()) + True + """ + + header_class = AFNIHeader + valid_exts = ('.brik', '.head') + files_types = (('image', '.brik'), ('header', '.head')) + _compressed_suffixes = ('.gz', '.bz2', '.Z') + makeable = False + rw = False + ImageArrayProxy = AFNIArrayProxy + + @classmethod + @kw_only_meth(1) + def from_file_map(klass, file_map, mmap=True, keep_file_open=None): + """ + Creates an AFNIImage instance from `file_map` + + Parameters + ---------- + file_map : dict + dict with keys ``image, header`` and values being fileholder + objects for the respective BRIK and HEAD files + mmap : {True, False, 'c', 'r'}, optional, keyword only + `mmap` controls the use of numpy memory mapping for reading image + array data. If False, do not try numpy ``memmap`` for data array. + If one of {'c', 'r'}, try numpy memmap with ``mode=mmap``. A + `mmap` value of True gives the same behavior as ``mmap='c'``. If + image data file cannot be memory-mapped, ignore `mmap` value and + read array from file. + keep_file_open : {None, 'auto', True, False}, optional, keyword only + `keep_file_open` controls whether a new file handle is created + every time the image is accessed, or a single file handle is + created and used for the lifetime of this ``ArrayProxy``. If + ``True``, a single file handle is created and used. If ``False``, + a new file handle is created every time the image is accessed. If + ``'auto'``, and the optional ``indexed_gzip`` dependency is + present, a single file handle is created and persisted. If + ``indexed_gzip`` is not available, behavior is the same as if + ``keep_file_open is False``. If ``file_like`` refers to an open + file handle, this setting has no effect. The default value + (``None``) will result in the value of + ``nibabel.arrayproxy.KEEP_FILE_OPEN_DEFAULT` being used. + """ + with file_map['header'].get_prepare_fileobj('rt') as hdr_fobj: + hdr = klass.header_class.from_fileobj(hdr_fobj) + imgf = file_map['image'].fileobj + imgf = file_map['image'].filename if imgf is None else imgf + data = klass.ImageArrayProxy(imgf, hdr.copy(), mmap=mmap, + keep_file_open=keep_file_open) + return klass(data, hdr.get_affine(), header=hdr, extra=None, + file_map=file_map) + + @classmethod + @kw_only_meth(1) + def from_filename(klass, filename, mmap=True, keep_file_open=None): + """ + Creates an AFNIImage instance from `filename` + + Parameters + ---------- + filename : str + Path to BRIK or HEAD file to be loaded + mmap : {True, False, 'c', 'r'}, optional, keyword only + `mmap` controls the use of numpy memory mapping for reading image + array data. If False, do not try numpy ``memmap`` for data array. + If one of {'c', 'r'}, try numpy memmap with ``mode=mmap``. A + `mmap` value of True gives the same behavior as ``mmap='c'``. If + image data file cannot be memory-mapped, ignore `mmap` value and + read array from file. + keep_file_open : {None, 'auto', True, False}, optional, keyword only + `keep_file_open` controls whether a new file handle is created + every time the image is accessed, or a single file handle is + created and used for the lifetime of this ``ArrayProxy``. If + ``True``, a single file handle is created and used. If ``False``, + a new file handle is created every time the image is accessed. If + ``'auto'``, and the optional ``indexed_gzip`` dependency is + present, a single file handle is created and persisted. If + ``indexed_gzip`` is not available, behavior is the same as if + ``keep_file_open is False``. If ``file_like`` refers to an open + file handle, this setting has no effect. The default value + (``None``) will result in the value of + ``nibabel.arrayproxy.KEEP_FILE_OPEN_DEFAULT` being used. + """ + file_map = klass.filespec_to_file_map(filename) + return klass.from_file_map(file_map, mmap=mmap, + keep_file_open=keep_file_open) + + @classmethod + def filespec_to_file_map(klass, filespec): + """ + Make `file_map` from filename `filespec` + + AFNI BRIK files can be compressed, but HEAD files cannot - see + afni.nimh.nih.gov/pub/dist/doc/program_help/README.compression.html. + Thus, if you have AFNI files my_image.HEAD and my_image.BRIK.gz and you + want to load the AFNI BRIK / HEAD pair, you can specify: + * The HEAD filename - e.g., my_image.HEAD + * The BRIK filename w/o compressed extension - e.g., my_image.BRIK + * The full BRIK filename - e.g., my_image.BRIK.gz + + Parameters + ---------- + filespec : str + Filename that might be for this image file type. + + Returns + ------- + file_map : dict + dict with keys ``image`` and ``header`` where values are fileholder + objects for the respective BRIK and HEAD files + + Raises + ------ + ImageFileError + If `filespec` is not recognizable as being a filename for this + image type. + """ + file_map = super(AFNIImage, klass).filespec_to_file_map(filespec) + # check for AFNI-specific BRIK/HEAD compression idiosyncrasies + for key, fholder in file_map.items(): + fname = fholder.filename + if key == 'header' and not os.path.exists(fname): + for ext in klass._compressed_suffixes: + fname = fname[:-len(ext)] if fname.endswith(ext) else fname + elif key == 'image' and not os.path.exists(fname): + for ext in klass._compressed_suffixes: + if os.path.exists(fname + ext): + fname += ext + break + file_map[key].filename = fname + return file_map + + load = from_filename + + +load = AFNIImage.load diff --git a/nibabel/imageclasses.py b/nibabel/imageclasses.py index f136a070be..c1a0b7133a 100644 --- a/nibabel/imageclasses.py +++ b/nibabel/imageclasses.py @@ -9,6 +9,7 @@ ''' Define supported image classes and names ''' from .analyze import AnalyzeImage +from .brikhead import AFNIImage from .cifti2 import Cifti2Image from .freesurfer import MGHImage from .gifti import GiftiImage @@ -31,7 +32,7 @@ Cifti2Image, Nifti2Image, # Cifti2 before Nifti2 Spm2AnalyzeImage, Spm99AnalyzeImage, AnalyzeImage, Minc1Image, Minc2Image, MGHImage, - PARRECImage, GiftiImage] + PARRECImage, GiftiImage, AFNIImage] # DEPRECATED: mapping of names to classes and class functionality @@ -88,7 +89,12 @@ def __getitem__(self, *args, **kwargs): 'ext': '.par', 'has_affine': True, 'makeable': False, - 'rw': False}) + 'rw': False}, + afni={'class': AFNIImage, + 'ext': '.brik', + 'has_affine': True, + 'makeable': False, + 'rw': False}) class ExtMapRecoder(Recoder): @@ -107,6 +113,7 @@ def __getitem__(self, *args, **kwargs): ('mgh', '.mgh'), ('mgz', '.mgz'), ('par', '.par'), + ('brik', '.brik') )) # Image classes known to require spatial axes to be first in index ordering. @@ -114,7 +121,7 @@ def __getitem__(self, *args, **kwargs): # here. KNOWN_SPATIAL_FIRST = (Nifti1Pair, Nifti1Image, Nifti2Pair, Nifti2Image, Spm2AnalyzeImage, Spm99AnalyzeImage, AnalyzeImage, - MGHImage, PARRECImage) + MGHImage, PARRECImage, AFNIImage) def spatial_axes_first(img): diff --git a/nibabel/tests/data/bad_attribute+orig.HEAD b/nibabel/tests/data/bad_attribute+orig.HEAD new file mode 100644 index 0000000000..95fbdeb309 --- /dev/null +++ b/nibabel/tests/data/bad_attribute+orig.HEAD @@ -0,0 +1,133 @@ + +type = string-attribute +name = DATASET_NAME +count = 5 +'none~ + +type = string-attribute +name = TYPESTRING +count = 15 +'3DIM_HEAD_ANAT~ + +type = string-attribute +name = IDCODE_STRING +count = 27 +'AFN_-zxZ0OyZs8eEtm9syGBNdA~ + +type = string-attribute +name = IDCODE_DATE +count = 25 +'Sun Oct 1 21:13:09 2017~ + +type = integer-attribute +name = SCENE_DATA +count = 8 + 0 2 0 -999 -999 + -999 -999 -999 + +type = string-attribute +name = LABEL_1 +count = 5 +'none~ + +type = string-attribute +name = LABEL_2 +count = 5 +'none~ + +type = integer-attribute +name = ORIENT_SPECIFIC +count = 3 + 0 3 4 + +type = float-attribute +name = ORIGIN +count = 3 + -49.5 -82.312 -52.3511 + +type = float-attribute +name = DELTA +count = 3 + 3 3 3 + +type = float-attribute +name = IJK_TO_DICOM +count = 12 + 3 0 0 -49.5 0 + 3 0 -82.312 0 0 + 3 -52.3511 + +type = float-attribute +name = IJK_TO_DICOM_REAL +count = 12 + 3 0 0 -49.5 0 + 3 0 -82.312 0 0 + 3 -52.3511 + +type = float-attribute +name = BRICK_STATS +count = 6 + 0 13722 0 10051 0 + 9968 + +type = integer-attribute +name = TAXIS_NUMS +count = 8 + 3 25 77002 -999 -999 + -999 -999 -999 + +type = float-attribute +name = TAXIS_FLOATS +count = 8 + 0 3 0 -52.3511 3 + -999999 -999999 -999999 + +type = float-attribute +name = TAXIS_OFFSETS +count = 25 + 0.3260869 1.826087 0.3913043 1.891304 0.4565217 + 1.956521 0.5217391 2.021739 0.5869564 2.086956 + 0.6521738 2.152174 0.7173912 2.217391 0.7826086 + 2.282609 0.8478259 2.347826 0.9130433 2.413044 + 0.9782607 2.478261 1.043478 2.543479 1.108696 + +type = integer-attribute +name = DATASET_RANK +count = 8 + 3 3 0 0 0 + 0 0 0 + +type = integer-attribute +name = DATASET_DIMENSIONS +count = 5 + 33 41 25 0 0 + +type = integer-attribute +name = BRICK_TYPES +count = 3 + 1 1 1 + +type = float-attribute +name = BRICK_FLOAT_FACS +count = 3 + 0 0 0 + +type = string-attribute +name = TEMPLATE_SPACE +count = 5 +'ORIG~ + +type = integer-attribute +name = INT_CMAP +count = 1 + 0 + +type = integer-attribute +name = BYTEORDER_STRING +count = 10 +'LSB_FIRST~ + +type = string-attribute +name = BRICK_LABS +count = 9 +'#0~#1~#2~ diff --git a/nibabel/tests/data/bad_datatype+orig.HEAD b/nibabel/tests/data/bad_datatype+orig.HEAD new file mode 100644 index 0000000000..27b3a56abb --- /dev/null +++ b/nibabel/tests/data/bad_datatype+orig.HEAD @@ -0,0 +1,133 @@ + +type = string-attribute +name = DATASET_NAME +count = 5 +'none~ + +type = string-attribute +name = TYPESTRING +count = 15 +'3DIM_HEAD_ANAT~ + +type = string-attribute +name = IDCODE_STRING +count = 27 +'AFN_-zxZ0OyZs8eEtm9syGBNdA~ + +type = string-attribute +name = IDCODE_DATE +count = 25 +'Sun Oct 1 21:13:09 2017~ + +type = integer-attribute +name = SCENE_DATA +count = 8 + 0 2 0 -999 -999 + -999 -999 -999 + +type = string-attribute +name = LABEL_1 +count = 5 +'none~ + +type = string-attribute +name = LABEL_2 +count = 5 +'none~ + +type = integer-attribute +name = ORIENT_SPECIFIC +count = 3 + 0 3 4 + +type = float-attribute +name = ORIGIN +count = 3 + -49.5 -82.312 -52.3511 + +type = float-attribute +name = DELTA +count = 3 + 3 3 3 + +type = float-attribute +name = IJK_TO_DICOM +count = 12 + 3 0 0 -49.5 0 + 3 0 -82.312 0 0 + 3 -52.3511 + +type = float-attribute +name = IJK_TO_DICOM_REAL +count = 12 + 3 0 0 -49.5 0 + 3 0 -82.312 0 0 + 3 -52.3511 + +type = float-attribute +name = BRICK_STATS +count = 6 + 0 13722 0 10051 0 + 9968 + +type = integer-attribute +name = TAXIS_NUMS +count = 8 + 3 25 77002 -999 -999 + -999 -999 -999 + +type = float-attribute +name = TAXIS_FLOATS +count = 8 + 0 3 0 -52.3511 3 + -999999 -999999 -999999 + +type = float-attribute +name = TAXIS_OFFSETS +count = 25 + 0.3260869 1.826087 0.3913043 1.891304 0.4565217 + 1.956521 0.5217391 2.021739 0.5869564 2.086956 + 0.6521738 2.152174 0.7173912 2.217391 0.7826086 + 2.282609 0.8478259 2.347826 0.9130433 2.413044 + 0.9782607 2.478261 1.043478 2.543479 1.108696 + +type = integer-attribute +name = DATASET_RANK +count = 8 + 3 3 0 0 0 + 0 0 0 + +type = integer-attribute +name = DATASET_DIMENSIONS +count = 5 + 33 41 25 0 0 + +type = integer-attribute +name = BRICK_TYPES +count = 3 + 1 3 5 + +type = float-attribute +name = BRICK_FLOAT_FACS +count = 3 + 0 0 0 + +type = string-attribute +name = TEMPLATE_SPACE +count = 5 +'ORIG~ + +type = integer-attribute +name = INT_CMAP +count = 1 + 0 + +type = string-attribute +name = BYTEORDER_STRING +count = 10 +'LSB_FIRST~ + +type = string-attribute +name = BRICK_LABS +count = 9 +'#0~#1~#2~ diff --git a/nibabel/tests/data/example4d+orig.BRIK.gz b/nibabel/tests/data/example4d+orig.BRIK.gz new file mode 100644 index 0000000000..79296cb94a Binary files /dev/null and b/nibabel/tests/data/example4d+orig.BRIK.gz differ diff --git a/nibabel/tests/data/example4d+orig.HEAD b/nibabel/tests/data/example4d+orig.HEAD new file mode 100644 index 0000000000..a43b839d0a --- /dev/null +++ b/nibabel/tests/data/example4d+orig.HEAD @@ -0,0 +1,133 @@ + +type = string-attribute +name = DATASET_NAME +count = 5 +'none~ + +type = string-attribute +name = TYPESTRING +count = 15 +'3DIM_HEAD_ANAT~ + +type = string-attribute +name = IDCODE_STRING +count = 27 +'AFN_-zxZ0OyZs8eEtm9syGBNdA~ + +type = string-attribute +name = IDCODE_DATE +count = 25 +'Sun Oct 1 21:13:09 2017~ + +type = integer-attribute +name = SCENE_DATA +count = 8 + 0 2 0 -999 -999 + -999 -999 -999 + +type = string-attribute +name = LABEL_1 +count = 5 +'none~ + +type = string-attribute +name = LABEL_2 +count = 5 +'none~ + +type = integer-attribute +name = ORIENT_SPECIFIC +count = 3 + 0 3 4 + +type = float-attribute +name = ORIGIN +count = 3 + -49.5 -82.312 -52.3511 + +type = float-attribute +name = DELTA +count = 3 + 3 3 3 + +type = float-attribute +name = IJK_TO_DICOM +count = 12 + 3 0 0 -49.5 0 + 3 0 -82.312 0 0 + 3 -52.3511 + +type = float-attribute +name = IJK_TO_DICOM_REAL +count = 12 + 3 0 0 -49.5 0 + 3 0 -82.312 0 0 + 3 -52.3511 + +type = float-attribute +name = BRICK_STATS +count = 6 + 0 13722 0 10051 0 + 9968 + +type = integer-attribute +name = TAXIS_NUMS +count = 8 + 3 25 77002 -999 -999 + -999 -999 -999 + +type = float-attribute +name = TAXIS_FLOATS +count = 8 + 0 3 0 -52.3511 3 + -999999 -999999 -999999 + +type = float-attribute +name = TAXIS_OFFSETS +count = 25 + 0.3260869 1.826087 0.3913043 1.891304 0.4565217 + 1.956521 0.5217391 2.021739 0.5869564 2.086956 + 0.6521738 2.152174 0.7173912 2.217391 0.7826086 + 2.282609 0.8478259 2.347826 0.9130433 2.413044 + 0.9782607 2.478261 1.043478 2.543479 1.108696 + +type = integer-attribute +name = DATASET_RANK +count = 8 + 3 3 0 0 0 + 0 0 0 + +type = integer-attribute +name = DATASET_DIMENSIONS +count = 5 + 33 41 25 0 0 + +type = integer-attribute +name = BRICK_TYPES +count = 3 + 1 1 1 + +type = float-attribute +name = BRICK_FLOAT_FACS +count = 3 + 0 0 0 + +type = string-attribute +name = TEMPLATE_SPACE +count = 5 +'ORIG~ + +type = integer-attribute +name = INT_CMAP +count = 1 + 0 + +type = string-attribute +name = BYTEORDER_STRING +count = 10 +'LSB_FIRST~ + +type = string-attribute +name = BRICK_LABS +count = 9 +'#0~#1~#2~ diff --git a/nibabel/tests/data/scaled+tlrc.BRIK b/nibabel/tests/data/scaled+tlrc.BRIK new file mode 100644 index 0000000000..4bec3547ee Binary files /dev/null and b/nibabel/tests/data/scaled+tlrc.BRIK differ diff --git a/nibabel/tests/data/scaled+tlrc.HEAD b/nibabel/tests/data/scaled+tlrc.HEAD new file mode 100644 index 0000000000..a13b054e2d --- /dev/null +++ b/nibabel/tests/data/scaled+tlrc.HEAD @@ -0,0 +1,116 @@ + +type = string-attribute +name = TYPESTRING +count = 15 +'3DIM_HEAD_ANAT~ + +type = string-attribute +name = IDCODE_STRING +count = 27 +'AFN_vLKn9e5VumKelWXNeq4SWA~ + +type = string-attribute +name = IDCODE_DATE +count = 25 +'Tue Jan 23 20:05:10 2018~ + +type = integer-attribute +name = SCENE_DATA +count = 8 + 2 2 0 -999 -999 + -999 -999 -999 + +type = string-attribute +name = LABEL_1 +count = 5 +'zyxt~ + +type = string-attribute +name = LABEL_2 +count = 5 +'zyxt~ + +type = string-attribute +name = DATASET_NAME +count = 5 +'zyxt~ + +type = integer-attribute +name = ORIENT_SPECIFIC +count = 3 + 1 2 4 + +type = float-attribute +name = ORIGIN +count = 3 + 66 87 -54 + +type = float-attribute +name = DELTA +count = 3 + -3 -3 3 + +type = float-attribute +name = IJK_TO_DICOM +count = 12 + -3 0 0 66 0 + -3 0 87 0 0 + 3 -54 + +type = float-attribute +name = IJK_TO_DICOM_REAL +count = 12 + -3 0 0 66 0 + -3 0 87 0 0 + 3 -54 + +type = float-attribute +name = BRICK_STATS +count = 2 + 1.941682e-07 0.001272461 + +type = integer-attribute +name = DATASET_RANK +count = 8 + 3 1 0 0 0 + 0 0 0 + +type = integer-attribute +name = DATASET_DIMENSIONS +count = 5 + 47 54 43 0 0 + +type = integer-attribute +name = BRICK_TYPES +count = 1 + 1 + +type = float-attribute +name = BRICK_FLOAT_FACS +count = 1 + 3.883363e-08 + +type = string-attribute +name = BRICK_LABS +count = 3 +'#0~ + +type = string-attribute +name = BRICK_KEYWORDS +count = 1 +'~ + +type = string-attribute +name = TEMPLATE_SPACE +count = 5 +'TLRC~ + +type = integer-attribute +name = INT_CMAP +count = 1 + 0 + +type = string-attribute +name = BYTEORDER_STRING +count = 10 +'LSB_FIRST~ diff --git a/nibabel/tests/test_brikhead.py b/nibabel/tests/test_brikhead.py new file mode 100644 index 0000000000..c1632c06c2 --- /dev/null +++ b/nibabel/tests/test_brikhead.py @@ -0,0 +1,150 @@ +# 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. +# +### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ## +from __future__ import division, print_function, absolute_import + +from os.path import join as pjoin + +import numpy as np + +from .. import load, Nifti1Image +from .. import brikhead + +from nose.tools import (assert_true, assert_equal, assert_raises) +from numpy.testing import assert_array_equal +from ..testing import data_path + +from .test_fileslice import slicer_samples +from .test_helpers import assert_data_similar + +EXAMPLE_IMAGES = [ + dict( + head=pjoin(data_path, 'example4d+orig.HEAD'), + fname=pjoin(data_path, 'example4d+orig.BRIK.gz'), + shape=(33, 41, 25, 3), + dtype=np.int16, + affine=np.array([[-3.0,0,0,49.5], + [0,-3.0,0,82.312], + [0,0,3.0,-52.3511], + [0,0,0,1.0]]), + zooms=(3., 3., 3., 3.), + data_summary=dict( + min=0, + max=13722, + mean=4266.76024636), + is_proxy=True, + space='ORIG', + labels=['#0', '#1', '#2'], + scaling=None), + dict( + head=pjoin(data_path, 'scaled+tlrc.HEAD'), + fname=pjoin(data_path, 'scaled+tlrc.BRIK'), + shape=(47, 54, 43, 1.), + dtype=np.int16, + affine=np.array([[3.0,0,0,-66.], + [0,3.0,0,-87.], + [0,0,3.0,-54.], + [0,0,0,1.0]]), + zooms=(3., 3., 3., 0.), + data_summary=dict( + min=1.9416814999999998e-07, + max=0.0012724615542099998, + mean=0.00023919645351876782), + is_proxy=True, + space='TLRC', + labels=['#0'], + scaling=np.array([ 3.88336300e-08]), + ) +] + +EXAMPLE_BAD_IMAGES = [ + dict( + head=pjoin(data_path, 'bad_datatype+orig.HEAD'), + err=brikhead.AFNIImageError + ), + dict( + head=pjoin(data_path, 'bad_attribute+orig.HEAD'), + err=brikhead.AFNIHeaderError + ) +] + +class TestAFNIHeader(object): + module = brikhead + test_files = EXAMPLE_IMAGES + + def test_makehead(self): + for tp in self.test_files: + head1 = self.module.AFNIHeader.from_fileobj(tp['head']) + head2 = self.module.AFNIHeader.from_header(head1) + assert_equal(head1, head2) + with assert_raises(self.module.AFNIHeaderError): + self.module.AFNIHeader.from_header(header=None) + with assert_raises(self.module.AFNIHeaderError): + self.module.AFNIHeader.from_header(tp['fname']) + + +class TestAFNIImage(object): + module = brikhead + test_files = EXAMPLE_IMAGES + + def test_brikheadfile(self): + for tp in self.test_files: + brik = self.module.load(tp['fname']) + assert_equal(brik.get_data_dtype().type, tp['dtype']) + assert_equal(brik.shape, tp['shape']) + assert_equal(brik.header.get_zooms(), tp['zooms']) + assert_array_equal(brik.affine, tp['affine']) + assert_equal(brik.header.get_space(), tp['space']) + data = brik.get_data() + assert_equal(data.shape, tp['shape']) + assert_array_equal(brik.dataobj.scaling, tp['scaling']) + assert_equal(brik.header.get_volume_labels(), tp['labels']) + + def test_load(self): + # Check highest level load of brikhead works + for tp in self.test_files: + img = self.module.load(tp['head']) + data = img.get_data() + assert_equal(data.shape, tp['shape']) + # min, max, mean values + assert_data_similar(data, tp) + # check if file can be converted to nifti + ni_img = Nifti1Image.from_image(img) + assert_array_equal(ni_img.affine, tp['affine']) + assert_array_equal(ni_img.get_data(), data) + + def test_array_proxy_slicing(self): + # Test slicing of array proxy + for tp in self.test_files: + img = self.module.load(tp['fname']) + arr = img.get_data() + prox = img.dataobj + assert_true(prox.is_proxy) + for sliceobj in slicer_samples(img.shape): + assert_array_equal(arr[sliceobj], prox[sliceobj]) + + +class TestBadFiles(object): + module = brikhead + test_files = EXAMPLE_BAD_IMAGES + + def test_brikheadfile(self): + for tp in self.test_files: + with assert_raises(tp['err']): + self.module.load(tp['head']) + + +class TestBadVars(object): + module = brikhead + vars = ['type = badtype-attribute\nname = BRICK_TYPES\ncount = 1\n1\n', + 'type = integer-attribute\ncount = 1\n1\n'] + + def test_unpack_var(self): + for var in self.vars: + with assert_raises(self.module.AFNIHeaderError): + self.module._unpack_var(var) diff --git a/nibabel/tests/test_image_api.py b/nibabel/tests/test_image_api.py index 8ee7c22cc7..01b9ff4fdb 100644 --- a/nibabel/tests/test_image_api.py +++ b/nibabel/tests/test_image_api.py @@ -39,7 +39,7 @@ Nifti1Pair, Nifti1Image, Nifti2Pair, Nifti2Image, MGHImage, Minc1Image, Minc2Image, is_proxy) from ..spatialimages import SpatialImage -from .. import minc1, minc2, parrec +from .. import minc1, minc2, parrec, brikhead from nose import SkipTest from nose.tools import (assert_true, assert_false, assert_raises, assert_equal) @@ -54,7 +54,7 @@ from .test_minc1 import EXAMPLE_IMAGES as MINC1_EXAMPLE_IMAGES from .test_minc2 import EXAMPLE_IMAGES as MINC2_EXAMPLE_IMAGES from .test_parrec import EXAMPLE_IMAGES as PARREC_EXAMPLE_IMAGES - +from .test_brikhead import EXAMPLE_IMAGES as AFNI_EXAMPLE_IMAGES class GenericImageAPI(ValidateAPI): """ General image validation API """ @@ -596,3 +596,9 @@ class TestMGHAPI(ImageHeaderAPI): has_scaling = True can_save = True standard_extension = '.mgh' + + +class TestAFNIAPI(LoadImageAPI): + loader = brikhead.load + klass = image_maker = brikhead.AFNIImage + example_images = AFNI_EXAMPLE_IMAGES