1# emacs: -*- mode: python-mode; py-indent-offset: 4; indent-tabs-mode: nil -*-
2# vi: set ft=python sts=4 ts=4 sw=4 et:
3### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
4#
5#   See COPYING file distributed along with the NiBabel package for the
6#   copyright and license terms.
7#
8### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ##
9''' Very simple spatial image class
10
11The image class maintains the association between a 3D (or greater)
12array, and an affine transform that maps voxel coordinates to some real
13world space.  It also has a ``header`` - some standard set of meta-data
14that is specific to the image format - and ``extra`` - a dictionary
15container for any other metadata.
16
17It has attributes:
18
19   * extra
20
21methods:
22
23   * .get_data()
24   * .get_affine()
25   * .get_header()
26   * .set_shape(shape)
27   * .to_filename(fname) - writes data to filename(s) derived from
28     ``fname``, where the derivation may differ between formats.
29   * to_file_map() - save image to files with which the image is already
30     associated.
31   * .get_shape() (Deprecated)
32
33properties:
34
35   * shape
36
37classmethods:
38
39   * from_filename(fname) - make instance by loading from filename
40   * instance_to_filename(img, fname) - save ``img`` instance to
41     filename ``fname``.
42
43There are several ways of writing data.
44=======================================
45
46There is the usual way, which is the default::
47
48    img.to_filename(fname)
49
50and that is, to take the data encapsulated by the image and cast it to
51the datatype the header expects, setting any available header scaling
52into the header to help the data match.
53
54You can load the data into an image from file with::
55
56   img.from_filename(fname)
57
58The image stores its associated files in its ``files`` attribute.  In
59order to just save an image, for which you know there is an associated
60filename, or other storage, you can do::
61
62   img.to_file_map()
63
64You can get the data out again with of::
65
66    img.get_data()
67
68Less commonly, for some image types that support it, you might want to
69fetch out the unscaled array via the header::
70
71    unscaled_data = img.get_unscaled_data()
72
73Analyze-type images (including nifti) support this, but others may not
74(MINC, for example).
75
76Sometimes you might to avoid any loss of precision by making the
77data type the same as the input::
78
79    hdr = img.get_header()
80    hdr.set_data_dtype(data.dtype)
81    img.to_filename(fname)
82
83Files interface
84===============
85
86The image has an attribute ``file_map``.  This is a mapping, that has keys
87corresponding to the file types that an image needs for storage.  For
88example, the Analyze data format needs an ``image`` and a ``header``
89file type for storage:
90
91   >>> import nibabel as nib
92   >>> data = np.arange(24, dtype='f4').reshape((2,3,4))
93   >>> img = nib.AnalyzeImage(data, np.eye(4))
94   >>> sorted(img.file_map)
95   ['header', 'image']
96
97The values of ``file_map`` are not in fact files but objects with
98attributes ``filename``, ``fileobj`` and ``pos``.
99
100The reason for this interface, is that the contents of files has to
101contain enough information so that an existing image instance can save
102itself back to the files pointed to in ``file_map``.  When a file holder
103holds active file-like objects, then these may be affected by the
104initial file read; in this case, the contains file-like objects need to
105carry the position at which a write (with ``to_files``) should place the
106data.  The ``file_map`` contents should therefore be such, that this will
107work:
108
109   >>> # write an image to files
110   >>> from StringIO import StringIO #23dt : BytesIO
111   >>> file_map = nib.AnalyzeImage.make_file_map()
112   >>> file_map['image'].fileobj = StringIO() #23dt : BytesIO
113   >>> file_map['header'].fileobj = StringIO() #23dt : BytesIO
114   >>> img = nib.AnalyzeImage(data, np.eye(4))
115   >>> img.file_map = file_map
116   >>> img.to_file_map()
117   >>> # read it back again from the written files
118   >>> img2 = nib.AnalyzeImage.from_file_map(file_map)
119   >>> np.all(img2.get_data() == data)
120   True
121   >>> # write, read it again
122   >>> img2.to_file_map()
123   >>> img3 = nib.AnalyzeImage.from_file_map(file_map)
124   >>> np.all(img3.get_data() == data)
125   True
126
127'''
128
129import warnings
130
131import numpy as np
132
133from .filename_parser import types_filenames, TypesFilenamesError
134from .fileholders import FileHolder
135from .volumeutils import shape_zoom_affine
136
137
138class HeaderDataError(Exception):
139    ''' Class to indicate error in getting or setting header data '''
140    pass
141
142
143class HeaderTypeError(Exception):
144    ''' Class to indicate error in parameters into header functions '''
145    pass
146
147
148class Header(object):
149    ''' Template class to implement header protocol '''
150    default_x_flip = True
151
152    def __init__(self,
153                 data_dtype=np.float32,
154                 shape=(0,),
155                 zooms=None):
156        self.set_data_dtype(data_dtype)
157        self._zooms = ()
158        self.set_data_shape(shape)
159        if not zooms is None:
160            self.set_zooms(zooms)
161
162    @classmethod
163    def from_header(klass, header=None):
164        if header is None:
165            return klass()
166        # I can't do isinstance here because it is not necessarily true
167        # that a subclass has exactly the same interface as it's parent
168        # - for example Nifti1Images inherit from Analyze, but have
169        # different field names
170        if type(header) == klass:
171            return header.copy()
172        return klass(header.get_data_dtype(),
173                     header.get_data_shape(),
174                     header.get_zooms())
175
176    @classmethod
177    def from_fileobj(klass, fileobj):
178        raise NotImplementedError
179
180    def write_to(self, fileobj):
181        raise NotImplementedError
182
183    def __eq__(self, other):
184        return ((self.get_data_dtype(),
185                 self.get_data_shape(),
186                 self.get_zooms()) ==
187                (other.get_data_dtype(),
188                 other.get_data_shape(),
189                 other.get_zooms()))
190
191    def __ne__(self, other):
192        return not self == other
193
194    def copy(self):
195        ''' Copy object to independent representation
196
197        The copy should not be affected by any changes to the original
198        object.
199        '''
200        return self.__class__(self._dtype, self._shape, self._zooms)
201
202    def get_data_dtype(self):
203        return self._dtype
204
205    def set_data_dtype(self, dtype):
206        self._dtype = np.dtype(dtype)
207
208    def get_data_shape(self):
209        return self._shape
210
211    def set_data_shape(self, shape):
212        ndim = len(shape)
213        if ndim == 0:
214            self._shape = (0,)
215            self._zooms = (1.0,)
216            return
217        self._shape = tuple([int(s) for s in shape])
218        # set any unset zooms to 1.0
219        nzs = min(len(self._zooms), ndim)
220        self._zooms = self._zooms[:nzs] + (1.0,) * (ndim-nzs)
221
222    def get_zooms(self):
223        return self._zooms
224
225    def set_zooms(self, zooms):
226        zooms = tuple([float(z) for z in zooms])
227        shape = self.get_data_shape()
228        ndim = len(shape)
229        if len(zooms) != ndim:
230            raise HeaderDataError('Expecting %d zoom values for ndim %d'
231                                  % (ndim, ndim))
232        if len([z for z in zooms if z < 0]):
233            raise HeaderDataError('zooms must be positive')
234        self._zooms = zooms
235
236    def get_base_affine(self):
237        shape = self.get_data_shape()
238        zooms = self.get_zooms()
239        return shape_zoom_affine(shape, zooms,
240                                 self.default_x_flip)
241
242    get_default_affine = get_base_affine
243
244    def data_to_fileobj(self, data, fileobj):
245        ''' Write image data to file in fortran order '''
246        dtype = self.get_data_dtype()
247        fileobj.write(data.astype(dtype).tostring(order='F'))
248
249    def data_from_fileobj(self, fileobj):
250        ''' Read data in fortran order '''
251        dtype = self.get_data_dtype()
252        shape = self.get_data_shape()
253        data_size = int(np.prod(shape) * dtype.itemsize)
254        data_bytes = fileobj.read(data_size)
255        return np.ndarray(shape, dtype, data_bytes, order='F')
256
257
258class ImageDataError(Exception):
259    pass
260
261
262class ImageFileError(Exception):
263    pass
264
265
266class SpatialImage(object):
267    header_class = Header
268    files_types = (('image', None),)
269    _compressed_exts = ()
270
271    ''' Template class for images '''
272    def __init__(self, data, affine, header=None,
273                 extra=None, file_map=None):
274        ''' Initialize image
275
276        The image is a combination of (array, affine matrix, header), with
277        optional metadata in `extra`, and filename / file-like objects contained
278        in the `file_map` mapping.
279
280        Parameters
281        ----------
282        data : object
283           image data.  It should be some object that retuns an array
284           from ``np.asanyarray``.  It should have a ``shape`` attribute or
285           property
286        affine : None or (4,4) array-like
287           homogenous affine giving relationship between voxel coordinates and
288           world coordinates.  Affine can also be None.  In this case,
289           ``obj.get_affine()`` also returns None, and the affine as written to
290           disk will depend on the file format.
291        header : None or mapping or header instance, optional
292           metadata for this image format
293        extra : None or mapping, optional
294           metadata to associate with image that cannot be stored in the
295           metadata of this image type
296        file_map : mapping, optional
297           mapping giving file information for this image format
298        '''
299        self._data = data
300        if not affine is None:
301            # Check that affine is array-like 4,4.  Maybe this is too strict at
302            # this abstract level, but so far I think all image formats we know
303            # do need 4,4.
304            # Copy affine to  isolate from environment.  Specify float type to
305            # avoid surprising integer rounding when setting values into affine
306            affine = np.array(affine, dtype=np.float64, copy=True)
307            if not affine.shape == (4,4):
308                raise ValueError('Affine should be shape 4,4')
309        self._affine = affine
310        if extra is None:
311            extra = {}
312        self.extra = extra
313        self._header = self.header_class.from_header(header)
314        # if header not specified, get data type from input array
315        if header is None:
316            if hasattr(data, 'dtype'):
317                self._header.set_data_dtype(data.dtype)
318        # make header correspond with image and affine
319        self.update_header()
320        if file_map is None:
321            file_map = self.__class__.make_file_map()
322        self.file_map = file_map
323        self._load_cache = None
324
325    def update_header(self):
326        ''' Update header from information in image'''
327        self._header.set_data_shape(self._data.shape)
328
329    def __str__(self):
330        shape = self.shape
331        affine = self.get_affine()
332        return '\n'.join((
333                str(self.__class__),
334                'data shape %s' % (shape,),
335                'affine: ',
336                '%s' % affine,
337                'metadata:',
338                '%s' % self._header))
339
340    def get_data(self):
341        return np.asanyarray(self._data)
342
343    @property
344    def shape(self):
345        return self._data.shape
346
347    def get_shape(self):
348        """ Return shape for image
349
350        This function deprecated; please use the ``shape`` property instead
351        """
352        warnings.warn('Please use the shape property instead of get_shape',
353                      DeprecationWarning,
354                      stacklevel=2)
355        return self.shape
356
357    def get_data_dtype(self):
358        return self._header.get_data_dtype()
359
360    def set_data_dtype(self, dtype):
361        self._header.set_data_dtype(dtype)
362
363    def get_affine(self):
364        return self._affine
365
366    def get_header(self):
367        return self._header
368
369    def get_filename(self):
370        ''' Fetch the image filename
371
372        Parameters
373        ----------
374        None
375
376        Returns
377        -------
378        fname : None or str
379           Returns None if there is no filename, or a filename string.
380           If an image may have several filenames assoctiated with it
381           (e.g Analyze ``.img, .hdr`` pair) then we return the more
382           characteristic filename (the ``.img`` filename in the case of
383           Analyze')
384        '''
385        # which filename is returned depends on the ordering of the
386        # 'files_types' class attribute - we return the name
387        # corresponding to the first in that tuple
388        characteristic_type = self.files_types[0][0]
389        return self.file_map[characteristic_type].filename
390
391    def set_filename(self, filename):
392        ''' Sets the files in the object from a given filename
393
394        The different image formats may check whether the filename has
395        an extension characteristic of the format, and raise an error if
396        not.
397
398        Parameters
399        ----------
400        filename : str
401           If the image format only has one file associated with it,
402           this will be the only filename set into the image
403           ``.file_map`` attribute. Otherwise, the image instance will
404           try and guess the other filenames from this given filename.
405        '''
406        self.file_map = self.__class__.filespec_to_file_map(filename)
407
408    @classmethod
409    def from_filename(klass, filename):
410        file_map = klass.filespec_to_file_map(filename)
411        return klass.from_file_map(file_map)
412
413    @classmethod
414    def from_filespec(klass, filespec):
415        warnings.warn('``from_filespec`` class method is deprecated\n'
416                      'Please use the ``from_filename`` class method '
417                      'instead',
418                      DeprecationWarning, stacklevel=2)
419        klass.from_filename(filespec)
420
421    @classmethod
422    def from_file_map(klass, file_map):
423        raise NotImplementedError
424
425    @classmethod
426    def from_files(klass, file_map):
427        warnings.warn('``from_files`` class method is deprecated\n'
428                      'Please use the ``from_file_map`` class method '
429                      'instead',
430                      DeprecationWarning, stacklevel=2)
431        return klass.from_file_map(file_map)
432
433    @classmethod
434    def filespec_to_file_map(klass, filespec):
435        try:
436            filenames = types_filenames(filespec,
437                                        klass.files_types,
438                                        trailing_suffixes=klass._compressed_exts)
439        except TypesFilenamesError:
440            raise ImageFileError('Filespec "%s" does not look right for '
441                             'class %s ' % (filespec, klass))
442        file_map = {}
443        for key, fname in filenames.items():
444            file_map[key] = FileHolder(filename=fname)
445        return file_map
446
447    @classmethod
448    def filespec_to_files(klass, filespec):
449        warnings.warn('``filespec_to_files`` class method is deprecated\n'
450                      'Please use the ``filespec_to_file_map`` class method '
451                      'instead',
452                      DeprecationWarning, stacklevel=2)
453        return klass.filespec_to_file_map(filespec)
454
455    def to_filename(self, filename):
456        ''' Write image to files implied by filename string
457
458        Parameters
459        ----------
460        filename : str
461           filename to which to save image.  We will parse `filename`
462           with ``filespec_to_file_map`` to work out names for image,
463           header etc.
464
465        Returns
466        -------
467        None
468        '''
469        self.file_map = self.filespec_to_file_map(filename)
470        self.to_file_map()
471
472    def to_filespec(self, filename):
473        warnings.warn('``to_filespec`` is deprecated, please '
474                      'use ``to_filename`` instead',
475                      DeprecationWarning, stacklevel=2)
476        self.to_filename(filename)
477
478    def to_file_map(self, file_map=None):
479        raise NotImplementedError
480
481    def to_files(self, file_map=None):
482        warnings.warn('``to_files`` method is deprecated\n'
483                      'Please use the ``to_file_map`` method '
484                      'instead',
485                      DeprecationWarning, stacklevel=2)
486        self.to_file_map(file_map)
487
488    @classmethod
489    def make_file_map(klass, mapping=None):
490        ''' Class method to make files holder for this image type
491
492        Parameters
493        ----------
494        mapping : None or mapping, optional
495           mapping with keys corresponding to image file types (such as
496           'image', 'header' etc, depending on image class) and values
497           that are filenames or file-like.  Default is None
498
499        Returns
500        -------
501        file_map : dict
502           dict with string keys given by first entry in tuples in
503           sequence klass.files_types, and values of type FileHolder,
504           where FileHolder objects have default values, other than
505           those given by `mapping`
506        '''
507        if mapping is None:
508            mapping = {}
509        file_map = {}
510        for key, ext in klass.files_types:
511            file_map[key] = FileHolder()
512            mapval = mapping.get(key, None)
513            if isinstance(mapval, basestring):
514                file_map[key].filename = mapval
515            elif hasattr(mapval, 'tell'):
516                file_map[key].fileobj = mapval
517        return file_map
518
519    @classmethod
520    def load(klass, filename):
521        return klass.from_filename(filename)
522
523    @classmethod
524    def instance_to_filename(klass, img, filename):
525        ''' Save `img` in our own format, to name implied by `filename`
526
527        This is a class method
528
529        Parameters
530        ----------
531        img : ``spatialimage`` instance
532           In fact, an object with the API of ``spatialimage`` -
533           specifically ``get_data``, ``get_affine``, ``get_header`` and
534           ``extra``.
535        filename : str
536           Filename, implying name to which to save image.
537        '''
538        img = klass.from_image(img)
539        img.to_filename(filename)
540
541    @classmethod
542    def from_image(klass, img):
543        ''' Class method to create new instance of own class from `img`
544
545        Parameters
546        ----------
547        img : ``spatialimage`` instance
548           In fact, an object with the API of ``spatialimage`` -
549           specifically ``get_data``, ``get_affine``, ``get_header`` and
550           ``extra``.
551
552        Returns
553        -------
554        cimg : ``spatialimage`` instance
555           Image, of our own class
556        '''
557        return klass(img.get_data(),
558                     img.get_affine(),
559                     klass.header_class.from_header(img.get_header()),
560                     extra=img.extra.copy())
561
562