diff --git a/.travis.yml b/.travis.yml
index 99368c9..11e3828 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -21,7 +21,7 @@ matrix:
           env: TEST_UNIT=0 TEST_STYLE=1
         #
         - python: "2.7"
-          env: TEST_UNIT=1 TEST_INSTALL=1
+          env: TEST_UNIT=1 TEST_INSTALL=1 IS_LEGACY=1
         - python: "3.4"
           env: TEST_UNIT=1 TEST_FULL=1
         - python: "3.5"
@@ -91,6 +91,11 @@ install:
        easy_install -q simpleitk; true;
        conda install -y astropy;
       fi;
+    # Install more deps on legacy py
+    - if [ "${IS_LEGACY}" == "1" ]; then
+        pip install enum34 futures;
+      fi;
+    
     # Install imageio, use installed version on only one machine
     - if [ "${TEST_INSTALL}" == "1" ]; then
        python setup.py build_with_fi install > ${REDIRECT_TO};
diff --git a/imageio/core/functions.py b/imageio/core/functions.py
index 2771fb3..581bc9e 100644
--- a/imageio/core/functions.py
+++ b/imageio/core/functions.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # imageio is distributed under the terms of the (new) BSD License.
 
-""" 
+"""
 These functions represent imageio's main interface for the user. They
 provide a common API to read and write image data for a large
 variety of formats. All read and write functions accept keyword
@@ -70,10 +70,10 @@ from .. import formats
 
 def help(name=None):
     """ help(name=None)
-    
+
     Print the documentation of the format specified by name, or a list
-    of supported formats if name is omitted. 
-    
+    of supported formats if name is omitted.
+
     Parameters
     ----------
     name : str
@@ -91,14 +91,15 @@ def help(name=None):
 
 def get_reader(uri, format=None, mode='?', **kwargs):
     """ get_reader(uri, format=None, mode='?', **kwargs)
-    
+
     Returns a :class:`.Reader` object which can be used to read data
     and meta data from the specified file.
-    
+
     Parameters
     ----------
-    uri : {str, bytes, file}
-        The resource to load the image from, e.g. a filename, http address or
+    uri : {str, pathlib.Path, bytes, file}
+        The resource to load the image from, e.g. a filename, pathlib.Path,
+        http address or
         file object, see the docs for more info.
     format : str
         The format to use to read the file. By default imageio selects
@@ -110,11 +111,11 @@ def get_reader(uri, format=None, mode='?', **kwargs):
     kwargs : ...
         Further keyword arguments are passed to the reader. See :func:`.help`
         to see what arguments are available for a particular format.
-    """ 
-    
+    """
+
     # Create request object
     request = Request(uri, 'r' + mode, **kwargs)
-    
+
     # Get format
     if format is not None:
         format = formats[format]
@@ -123,21 +124,22 @@ def get_reader(uri, format=None, mode='?', **kwargs):
     if format is None:
         raise ValueError('Could not find a format to read the specified file '
                          'in mode %r' % mode)
-    
+
     # Return its reader object
     return format.get_reader(request)
 
 
 def get_writer(uri, format=None, mode='?', **kwargs):
     """ get_writer(uri, format=None, mode='?', **kwargs)
-    
+
     Returns a :class:`.Writer` object which can be used to write data
     and meta data to the specified file.
-    
+
     Parameters
     ----------
-    uri : {str, file}
-        The resource to write the image to, e.g. a filename or file object,
+    uri : {str, pathlib.Path, file}
+        The resource to write the image to, e.g. a filename, pathlib.Path
+        or file object,
         see the docs for more info.
     format : str
         The format to use to write the file. By default imageio selects
@@ -149,15 +151,15 @@ def get_writer(uri, format=None, mode='?', **kwargs):
     kwargs : ...
         Further keyword arguments are passed to the writer. See :func:`.help`
         to see what arguments are available for a particular format.
-    """ 
-    
+    """
+
     # Signal extension when returning as bytes, needed by e.g. ffmpeg
     if uri == RETURN_BYTES and isinstance(format, str):
         uri = RETURN_BYTES + '.' + format.strip('. ')
-    
+
     # Create request object
     request = Request(uri, 'w' + mode, **kwargs)
-    
+
     # Get format
     if format is not None:
         format = formats[format]
@@ -166,7 +168,7 @@ def get_writer(uri, format=None, mode='?', **kwargs):
     if format is None:
         raise ValueError('Could not find a format to write the specified file '
                          'in mode %r' % mode)
-    
+
     # Return its writer object
     return format.get_writer(request)
 
@@ -175,17 +177,18 @@ def get_writer(uri, format=None, mode='?', **kwargs):
 
 def imread(uri, format=None, **kwargs):
     """ imread(uri, format=None, **kwargs)
-    
+
     Reads an image from the specified file. Returns a numpy array, which
     comes with a dict of meta data at its 'meta' attribute.
-    
+
     Note that the image data is returned as-is, and may not always have
     a dtype of uint8 (and thus may differ from what e.g. PIL returns).
-    
+
     Parameters
     ----------
-    uri : {str, bytes, file}
-        The resource to load the image from, e.g. a filename, http address or
+    uri : {str, pathlib.Path, bytes, file}
+        The resource to load the image from, e.g. a filename, pathlib.Path,
+        http address or
         file object, see the docs for more info.
     format : str
         The format to use to read the file. By default imageio selects
@@ -193,8 +196,8 @@ def imread(uri, format=None, **kwargs):
     kwargs : ...
         Further keyword arguments are passed to the reader. See :func:`.help`
         to see what arguments are available for a particular format.
-    """ 
-    
+    """
+
     # Get reader and read first
     reader = read(uri, format, 'i', **kwargs)
     with reader:
@@ -203,13 +206,14 @@ def imread(uri, format=None, **kwargs):
 
 def imwrite(uri, im, format=None, **kwargs):
     """ imwrite(uri, im, format=None, **kwargs)
-    
+
     Write an image to the specified file.
-    
+
     Parameters
     ----------
-    uri : {str, file}
-        The resource to write the image to, e.g. a filename or file object,
+    uri : {str, pathlib.Path, file}
+        The resource to write the image to, e.g. a filename, pathlib.Path
+        or file object,
         see the docs for more info.
     im : numpy.ndarray
         The image data. Must be NxM, NxMx3 or NxMx4.
@@ -219,8 +223,8 @@ def imwrite(uri, im, format=None, **kwargs):
     kwargs : ...
         Further keyword arguments are passed to the writer. See :func:`.help`
         to see what arguments are available for a particular format.
-    """ 
-    
+    """
+
     # Test image
     if isinstance(im, np.ndarray):
         if im.ndim == 2:
@@ -231,12 +235,12 @@ def imwrite(uri, im, format=None, **kwargs):
             raise ValueError('Image must be 2D (grayscale, RGB, or RGBA).')
     else:
         raise ValueError('Image must be a numpy array.')
-    
+
     # Get writer and write first
     writer = get_writer(uri, format, 'i', **kwargs)
     with writer:
         writer.append_data(im)
-    
+
     # Return a result if there is any
     return writer.request.get_result()
 
@@ -245,14 +249,15 @@ def imwrite(uri, im, format=None, **kwargs):
 
 def mimread(uri, format=None, memtest=True, **kwargs):
     """ mimread(uri, format=None, memtest=True, **kwargs)
-    
+
     Reads multiple images from the specified file. Returns a list of
     numpy arrays, each with a dict of meta data at its 'meta' attribute.
-    
+
     Parameters
     ----------
-    uri : {str, bytes, file}
-        The resource to load the images from, e.g. a filename, http address or
+    uri : {str, pathlib.Path, bytes, file}
+        The resource to load the images from, e.g. a filename,pathlib.Path,
+        http address or
         file object, see the docs for more info.
     format : str
         The format to use to read the file. By default imageio selects
@@ -266,11 +271,11 @@ def mimread(uri, format=None, memtest=True, **kwargs):
     kwargs : ...
         Further keyword arguments are passed to the reader. See :func:`.help`
         to see what arguments are available for a particular format.
-    """ 
-    
+    """
+
     # Get reader
     reader = read(uri, format, 'I', **kwargs)
-    
+
     # Read
     ims = []
     nbytes = 0
@@ -283,19 +288,20 @@ def mimread(uri, format=None, memtest=True, **kwargs):
             raise RuntimeError('imageio.mimread() has read over 256 MiB of '
                                'image data.\nStopped to avoid memory problems.'
                                ' Use imageio.get_reader() or memtest=False.')
-    
+
     return ims
 
 
 def mimwrite(uri, ims, format=None, **kwargs):
     """ mimwrite(uri, ims, format=None, **kwargs)
-    
+
     Write multiple images to the specified file.
-    
+
     Parameters
     ----------
-    uri : {str, file}
-        The resource to write the images to, e.g. a filename or file object,
+    uri : {str, pathlib.Path, file}
+        The resource to write the images to, e.g. a filename, pathlib.Path
+        or file object,
         see the docs for more info.
     ims : sequence of numpy arrays
         The image data. Each array must be NxM, NxMx3 or NxMx4.
@@ -305,17 +311,16 @@ def mimwrite(uri, ims, format=None, **kwargs):
     kwargs : ...
         Further keyword arguments are passed to the writer. See :func:`.help`
         to see what arguments are available for a particular format.
-    """ 
-    
+    """
     # Get writer
     writer = get_writer(uri, format, 'I', **kwargs)
     written = 0
     
     with writer:
-        
+
         # Iterate over images (ims may be a generator)
         for im in ims:
-            
+
             # Test image
             if isinstance(im, np.ndarray):
                 if im.ndim == 2:
@@ -327,7 +332,7 @@ def mimwrite(uri, ims, format=None, **kwargs):
                                      '(grayscale, RGB, or RGBA).')
             else:
                 raise ValueError('Image must be a numpy array.')
-            
+
             # Add image
             writer.append_data(im)
             written += 1
@@ -345,14 +350,15 @@ def mimwrite(uri, ims, format=None, **kwargs):
 
 def volread(uri, format=None, **kwargs):
     """ volread(uri, format=None, **kwargs)
-    
+
     Reads a volume from the specified file. Returns a numpy array, which
     comes with a dict of meta data at its 'meta' attribute.
-    
+
     Parameters
     ----------
-    uri : {str, bytes, file}
-        The resource to load the volume from, e.g. a filename, http address or
+    uri : {str, pathlib.Path, bytes, file}
+        The resource to load the volume from, e.g. a filename, pathlib.Path,
+        http address or
         file object, see the docs for more info.
     format : str
         The format to use to read the file. By default imageio selects
@@ -360,8 +366,8 @@ def volread(uri, format=None, **kwargs):
     kwargs : ...
         Further keyword arguments are passed to the reader. See :func:`.help`
         to see what arguments are available for a particular format.
-    """ 
-    
+    """
+
     # Get reader and read first
     reader = read(uri, format, 'v', **kwargs)
     with reader:
@@ -370,13 +376,14 @@ def volread(uri, format=None, **kwargs):
 
 def volwrite(uri, im, format=None, **kwargs):
     """ volwrite(uri, vol, format=None, **kwargs)
-    
+
     Write a volume to the specified file.
-    
+
     Parameters
     ----------
-    uri : {str, file}
-        The resource to write the image to, e.g. a filename or file object,
+    uri : {str, pathlib.Path, file}
+        The resource to write the image to, e.g. a filename, pathlib.Path
+        or file object,
         see the docs for more info.
     vol : numpy.ndarray
         The image data. Must be NxMxL (or NxMxLxK if each voxel is a tuple).
@@ -386,8 +393,8 @@ def volwrite(uri, im, format=None, **kwargs):
     kwargs : ...
         Further keyword arguments are passed to the writer. See :func:`.help`
         to see what arguments are available for a particular format.
-    """ 
-    
+    """
+
     # Test image
     if isinstance(im, np.ndarray):
         if im.ndim == 3:
@@ -399,12 +406,12 @@ def volwrite(uri, im, format=None, **kwargs):
                              'a tuple.')
     else:
         raise ValueError('Image must be a numpy array.')
-    
+
     # Get writer and write first
     writer = get_writer(uri, format, 'v', **kwargs)
     with writer:
         writer.append_data(im)
-    
+
     # Return a result if there is any
     return writer.request.get_result()
 
@@ -413,14 +420,15 @@ def volwrite(uri, im, format=None, **kwargs):
 
 def mvolread(uri, format=None, memtest=True, **kwargs):
     """ mvolread(uri, format=None, memtest=True, **kwargs)
-    
+
     Reads multiple volumes from the specified file. Returns a list of
     numpy arrays, each with a dict of meta data at its 'meta' attribute.
-    
+
     Parameters
     ----------
-    uri : {str, bytes, file}
-        The resource to load the volumes from, e.g. a filename, http address or
+    uri : {str, pathlib.Path, bytes, file}
+        The resource to load the volumes from, e.g. a filename, pathlib.Path,
+        http address or
         file object, see the docs for more info.
     format : str
         The format to use to read the file. By default imageio selects
@@ -434,11 +442,11 @@ def mvolread(uri, format=None, memtest=True, **kwargs):
     kwargs : ...
         Further keyword arguments are passed to the reader. See :func:`.help`
         to see what arguments are available for a particular format.
-    """ 
-    
+    """
+
     # Get reader and read all
     reader = read(uri, format, 'V', **kwargs)
-    
+
     ims = []
     nbytes = 0
     for im in reader:
@@ -450,19 +458,20 @@ def mvolread(uri, format=None, memtest=True, **kwargs):
             raise RuntimeError('imageio.mvolread() has read over 1 GiB of '
                                'image data.\nStopped to avoid memory problems.'
                                ' Use imageio.get_reader() or memtest=False.')
-    
+
     return ims
 
 
 def mvolwrite(uri, ims, format=None, **kwargs):
     """ mvolwrite(uri, vols, format=None, **kwargs)
-    
+
     Write multiple volumes to the specified file.
-    
+
     Parameters
     ----------
-    uri : {str, file}
-        The resource to write the volumes to, e.g. a filename or file object,
+    uri : {str, pathlib.Path, file}
+        The resource to write the volumes to, e.g. a filename, pathlib.Path
+        or file object,
         see the docs for more info.
     ims : sequence of numpy arrays
         The image data. Each array must be NxMxL (or NxMxLxK if each
@@ -473,17 +482,17 @@ def mvolwrite(uri, ims, format=None, **kwargs):
     kwargs : ...
         Further keyword arguments are passed to the writer. See :func:`.help`
         to see what arguments are available for a particular format.
-    """ 
-    
+    """
+
     # Get writer
     writer = get_writer(uri, format, 'V', **kwargs)
     written = 0
     
     with writer:
-        
+
         # Iterate over images (ims may be a generator)
         for im in ims:
-            
+
             # Test image
             if isinstance(im, np.ndarray):
                 if im.ndim == 3:
@@ -495,7 +504,7 @@ def mvolwrite(uri, ims, format=None, **kwargs):
                                      'a tuple.')
             else:
                 raise ValueError('Image must be a numpy array.')
-            
+
             # Add image
             writer.append_data(im)
             written += 1
diff --git a/imageio/core/request.py b/imageio/core/request.py
index e7cf533..d8e2221 100644
--- a/imageio/core/request.py
+++ b/imageio/core/request.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # imageio is distributed under the terms of the (new) BSD License.
 
-""" 
+"""
 Definition of the Request object, which acts as a kind of bridge between
 what the user wants and what the plugins can.
 """
@@ -17,6 +17,13 @@ import shutil
 
 from ..core import string_types, binary_type, urlopen, get_remote_file
 
+if sys.version_info < (3,):
+    FileNotFoundError = OSError
+try:
+    from pathlib import Path
+except ImportError:
+    Path = None
+
 # URI types
 URI_BYTES = 1
 URI_FILE = 2
@@ -37,10 +44,10 @@ EXAMPLE_IMAGES = {
     'checkerboard.png': 'Black and white image of a chekerboard',
     'clock.png': 'Photo of a clock with motion blur (Stefan van der Walt)',
     'coffee.png': 'Image of a cup of coffee (Rachel Michetti)',
-    
+
     'chelsea.png': 'Image of Stefan\'s cat',
     'wikkie.png': 'Image of Almar\'s cat',
-    
+
     'coins.png': 'Image showing greek coins from Pompeii',
     'horse.png': 'Image showing the silhouette of a horse (Andreas Preuss)',
     'hubble_deep_field.png': 'Photograph taken by Hubble telescope (NASA)',
@@ -48,7 +55,7 @@ EXAMPLE_IMAGES = {
     'moon.png': 'Image showing a portion of the surface of the moon',
     'page.png': 'A scanned page of text',
     'text.png': 'A photograph of handdrawn text',
-    
+
     'chelsea.zip': 'The chelsea.png in a zipfile (for testing)',
     'newtonscradle.gif': 'Animated GIF of a newton\'s cradle',
     'cockatoo.mp4': 'Video file of a cockatoo',
@@ -58,14 +65,14 @@ EXAMPLE_IMAGES = {
 
 class Request(object):
     """ Request(uri, mode, **kwargs)
-    
+
     Represents a request for reading or saving an image resource. This
     object wraps information to that request and acts as an interface
     for the plugins to several resources; it allows the user to read
     from filenames, files, http, zipfiles, raw bytes, etc., but offer
     a simple interface to the plugins via ``get_file()`` and
     ``get_local_filename()``.
-    
+
     For each read/write operation a single Request instance is used and passed
     to the can_read/can_write method of a format, and subsequently to
     the Reader/Writer class. This allows rudimentary passing of
@@ -82,29 +89,29 @@ class Request(object):
         "i" for an image, "I" for multiple images, "v" for a volume,
         "V" for multiple volumes, "?" for don't care.
     """
-    
+
     def __init__(self, uri, mode, **kwargs):
-        
-        # General        
+
+        # General
         self._uri_type = None
         self._filename = None
         self._extension = None
         self._kwargs = kwargs
         self._result = None         # Some write actions may have a result
-        
+
         # To handle the user-side
         self._filename_zip = None   # not None if a zipfile is used
         self._bytes = None          # Incoming bytes
         self._zipfile = None        # To store a zipfile instance (if used)
-        
+
         # To handle the plugin side
         self._file = None               # To store the file instance
         self._filename_local = None     # not None if using tempfile on this FS
         self._firstbytes = None         # For easy header parsing
-        
+
         # To store formats that may be able to fulfil this request
         #self._potential_formats = []
-        
+
         # Check mode
         self._mode = mode
         if not isinstance(mode, string_types):
@@ -115,7 +122,7 @@ class Request(object):
             raise ValueError('Request requires mode[0] to be "r" or "w"')
         if mode[1] not in 'iIvV?':
             raise ValueError('Request requires mode[1] to be in "iIvV?"')
-        
+
         # Parse what was given
         self._parse_uri(uri)
         
@@ -133,7 +140,7 @@ class Request(object):
         py3k = sys.version_info[0] == 3
         is_read_request = self.mode[0] == 'r'
         is_write_request = self.mode[0] == 'w'
-        
+
         if isinstance(uri, string_types):
             # Explicit
             if uri.startswith('imageio:'):
@@ -190,6 +197,9 @@ class Request(object):
             self._uri_type = URI_BYTES
             self._filename = '<bytes>'
             self._bytes = uri
+        elif Path is not None and isinstance(uri, Path):
+            self._uri_type = URI_FILENAME
+            self._filename = str(uri)
         # Files
         elif is_read_request:
             if hasattr(uri, 'read') and hasattr(uri, 'close'):
@@ -201,35 +211,35 @@ class Request(object):
                 self._uri_type = URI_FILE
                 self._filename = '<file>'
                 self._file = uri
-        
+
         # Expand user dir
         if self._uri_type == URI_FILENAME and self._filename.startswith('~'):
             self._filename = os.path.expanduser(self._filename)
-        
+
         # Check if a zipfile
         if self._uri_type == URI_FILENAME:
             # Search for zip extension followed by a path separater
             for needle in ['.zip/', '.zip\\']:
                 zip_i = self._filename.lower().find(needle)
-                if zip_i > 0:                    
+                if zip_i > 0:
                     zip_i += 4
                     self._uri_type = URI_ZIPPED
-                    self._filename_zip = (self._filename[:zip_i], 
+                    self._filename_zip = (self._filename[:zip_i],
                                           self._filename[zip_i:].lstrip('/\\'))
                     break
-        
+
         # Check if we could read it
         if self._uri_type is None:
             uri_r = repr(uri)
             if len(uri_r) > 60:
                 uri_r = uri_r[:57] + '...'
             raise IOError("Cannot understand given URI: %s." % uri_r)
-        
+
         # Check if this is supported
         noWriting = [URI_HTTP, URI_FTP]
         if is_write_request and self._uri_type in noWriting:
             raise IOError('imageio does not support writing to http/ftp.')
-        
+
         # Deprecated way to load standard images, give a sensible error message
         if is_read_request and self._uri_type in [URI_FILENAME, URI_ZIPPED]:
             fn = self._filename
@@ -239,16 +249,16 @@ class Request(object):
                 raise IOError('No such file: %r. This file looks like one of '
                               'the standard images, but from imageio 2.1, '
                               'standard images have to be specified using '
-                              '"imageio:%s".' % (fn, fn)) 
-        
-        # Make filename absolute 
+                              '"imageio:%s".' % (fn, fn))
+
+        # Make filename absolute
         if self._uri_type in [URI_FILENAME, URI_ZIPPED]:
             if self._filename_zip:
                 self._filename_zip = (os.path.abspath(self._filename_zip[0]),
                                       self._filename_zip[1])
             else:
                 self._filename = os.path.abspath(self._filename)
-        
+
         # Check whether file name is valid
         if self._uri_type in [URI_FILENAME, URI_ZIPPED]:
             fn = self._filename
@@ -257,13 +267,14 @@ class Request(object):
             if is_read_request:
                 # Reading: check that the file exists (but is allowed a dir)
                 if not os.path.exists(fn):
-                    raise IOError("No such file: '%s'" % fn)
+                    raise FileNotFoundError("No such file: '%s'" % fn)
             else:
                 # Writing: check that the directory to write to does exist
                 dn = os.path.dirname(fn)
                 if not os.path.exists(dn):
-                    raise IOError("The directory %r does not exist" % dn)
-    
+                    raise FileNotFoundError("The directory %r does not exist"
+                                            % dn)
+
     @property
     def filename(self):
         """ The uri for which reading/saving was requested. This
@@ -272,7 +283,7 @@ class Request(object):
         but use ``get_file()`` or ``get_local_filename()`` instead.
         """
         return self._filename
-    
+
     @property
     def extension(self):
         """ The (lowercase) extension of the requested filename.
@@ -290,46 +301,46 @@ class Request(object):
         "V" for multiple volumes, "?" for don't care.
         """
         return self._mode
-    
+
     @property
     def kwargs(self):
         """ The dict of keyword arguments supplied by the user.
         """
         return self._kwargs
-    
+
     ## For obtaining data
-    
+
     def get_file(self):
         """ get_file()
         Get a file object for the resource associated with this request.
         If this is a reading request, the file is in read mode,
         otherwise in write mode. This method is not thread safe. Plugins
         do not need to close the file when done.
-        
+
         This is the preferred way to read/write the data. But if a
         format cannot handle file-like objects, they should use
         ``get_local_filename()``.
         """
         want_to_write = self.mode[0] == 'w'
-        
+
         # Is there already a file?
-        # Either _uri_type == URI_FILE, or we already opened the file, 
+        # Either _uri_type == URI_FILE, or we already opened the file,
         # e.g. by using firstbytes
         if self._file is not None:
             return self._file
-        
+
         if self._uri_type == URI_BYTES:
-            if want_to_write:                          
+            if want_to_write:
                 self._file = BytesIO()
             else:
                 self._file = BytesIO(self._bytes)
-        
+
         elif self._uri_type == URI_FILENAME:
             if want_to_write:
                 self._file = open(self.filename, 'wb')
             else:
                 self._file = open(self.filename, 'rb')
-        
+
         elif self._uri_type == URI_ZIPPED:
             # Get the correct filename
             filename, name = self._filename_zip
@@ -340,21 +351,21 @@ class Request(object):
                 # Open zipfile and open new file object for specific file
                 self._zipfile = zipfile.ZipFile(filename, 'r')
                 self._file = self._zipfile.open(name, 'r')
-        
+
         elif self._uri_type in [URI_HTTP or URI_FTP]:
             assert not want_to_write  # This should have been tested in init
             self._file = urlopen(self.filename, timeout=5)
             fix_HTTPResponse(self._file)
-        
+
         return self._file
-    
+
     def get_local_filename(self):
         """ get_local_filename()
         If the filename is an existing file on this filesystem, return
         that. Otherwise a temporary file is created on the local file
         system which can be used by the format to read from or write to.
         """
-        
+
         if self._uri_type == URI_FILENAME:
             return self._filename
         else:
@@ -369,27 +380,27 @@ class Request(object):
                 with open(self._filename_local, 'wb') as file:
                     shutil.copyfileobj(self.get_file(), file)
             return self._filename_local
-    
+
     def finish(self):
         """ finish()
         For internal use (called when the context of the reader/writer
         exits). Finishes this request. Close open files and process
         results.
         """
-        
+
         # Init
         bytes = None
-        
+
         # Collect bytes from temp file
         if self.mode[0] == 'w' and self._filename_local:
             with open(self._filename_local, 'rb') as file:
                 bytes = file.read()
-        
+
         # Collect bytes from BytesIO file object.
         written = (self.mode[0] == 'w') and self._file
         if written and self._uri_type in [URI_BYTES, URI_ZIPPED]:
             bytes = self._file.getvalue()
-        
+
         # Close open files that we know of (and are responsible for)
         if self._file and self._uri_type != URI_FILE:
             self._file.close()
@@ -404,7 +415,7 @@ class Request(object):
             except Exception:  # pragma: no cover
                 pass
             self._filename_local = None
-        
+
         # Handle bytes that we collected
         if bytes is not None:
             if self._uri_type == URI_BYTES:
@@ -413,26 +424,26 @@ class Request(object):
                 zf = zipfile.ZipFile(self._filename_zip[0], 'a')
                 zf.writestr(self._filename_zip[1], bytes)
                 zf.close()
-        
+
         # Detach so gc can clean even if a reference of self lingers
         self._bytes = None
-    
+
     def get_result(self):
         """ For internal use. In some situations a write action can have
         a result (bytes data). That is obtained with this function.
         """
         self._result, res = None, self._result
         return res
-    
+
     @property
     def firstbytes(self):
-        """ The first 256 bytes of the file. These can be used to 
+        """ The first 256 bytes of the file. These can be used to
         parse the header to determine the file-format.
         """
         if self._firstbytes is None:
             self._read_first_bytes()
         return self._firstbytes
-    
+
     def _read_first_bytes(self, N=256):
         if self._bytes is not None:
             self._firstbytes = self._bytes[:N]
@@ -466,7 +477,7 @@ class Request(object):
 
 def read_n_bytes(f, N):
     """ read_n_bytes(file, n)
-    
+
     Read n bytes from the given file, or less if the file has less
     bytes. Returns zero bytes if the file is closed.
     """
@@ -485,27 +496,27 @@ def fix_HTTPResponse(f):
     to use the file object.
     """
     count = [0]
-    
+
     def read(n=None):
         res = ori_read(n)
         count[0] += len(res)
         return res
-    
+
     def tell():
         return count[0]
-    
+
     def seek(i, mode=0):
         if not (mode == 0 and i == count[0]):
             ori_seek(i, mode)
-    
+
     def fail_seek(i, mode=0):
         raise RuntimeError('No seeking allowed!')
-    
+
     # Note, there is currently no protection from wrapping an object more than
     # once, it will (probably) work though, because closures.
     ori_read = f.read
     ori_seek = f.seek if hasattr(f, 'seek') else fail_seek
-    
+
     f.read = read
     f.tell = tell
     f.seek = seek
diff --git a/imageio/plugins/_tifffile.py b/imageio/plugins/_tifffile.py
index e868b18..c827205 100644
--- a/imageio/plugins/_tifffile.py
+++ b/imageio/plugins/_tifffile.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#! /usr/bin/python3
 # -*- coding: utf-8 -*-
 # tifffile.py
 
@@ -31,26 +31,27 @@
 # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 # POSSIBILITY OF SUCH DAMAGE.
 
-"""Read image and meta data from (bio)TIFF files. Save numpy arrays as TIFF.
+"""Read image and meta data from (bio) TIFF® files. Save numpy arrays as TIFF.
 
 Image and metadata can be read from TIFF, BigTIFF, OME-TIFF, STK, LSM, NIH,
 SGI, ImageJ, MicroManager, FluoView, SEQ and GEL files.
-Only a subset of the TIFF specification is supported, mainly uncompressed
-and losslessly compressed 2**(0 to 6) bit integer, 16, 32 and 64-bit float,
-grayscale and RGB(A) images, which are commonly used in bio-scientific imaging.
-Specifically, reading JPEG and CCITT compressed image data, chroma subsampling,
-or EXIF, IPTC, GPS, and XMP metadata is not implemented. Only primary info
-records are read for STK, FluoView, MicroManager, and NIH Image formats.
-
-TIFF, the Tagged Image File Format aka Thousands of Incompatible File Formats,
-is under the control of Adobe Systems. BigTIFF allows for files greater than
-4 GB. STK, LSM, FluoView, SGI, SEQ, GEL, and OME-TIFF, are custom extensions
+
+Tifffile is not a general purpose TIFF library. Only a subset of the TIFF
+specification is supported, mainly uncompressed and losslessly compressed
+2**(0 to 6) bit integer, 16, 32 and 64-bit float, grayscale and RGB(A) images,
+which are commonly used in bio-scientific imaging. Specifically, reading image
+trees defined via SubIFDs, JPEG and CCITT compression, chroma subsampling,
+or IPTC and XMP metadata are not implemented.
+
+TIFF®, the tagged Image File Format, is a trademark and under control of
+Adobe Systems Incorporated. BigTIFF allows for files greater than 4 GB.
+STK, LSM, FluoView, SGI, SEQ, GEL, and OME-TIFF, are custom extensions
 defined by Molecular Devices (Universal Imaging Corporation), Carl Zeiss
 MicroImaging, Olympus, Silicon Graphics International, Media Cybernetics,
 Molecular Dynamics, and the Open Microscopy Environment consortium
 respectively.
 
-For command line usage run `python tifffile.py --help`
+For command line usage run C{python -m tifffile --help}
 
 :Author:
   `Christoph Gohlke <http://www.lfd.uci.edu/~gohlke/>`_
@@ -58,18 +59,74 @@ For command line usage run `python tifffile.py --help`
 :Organization:
   Laboratory for Fluorescence Dynamics, University of California, Irvine
 
-:Version: 2017.01.12
+:Version: 2017.09.29
 
 Requirements
 ------------
-* `CPython 2.7 or 3.5 <http://www.python.org>`_ (64-bit recommended)
-* `Numpy 1.11 <http://www.numpy.org>`_
-* `Matplotlib 1.5 <http://www.matplotlib.org>`_ (optional for plotting)
+* `CPython 3.6 64-bit <http://www.python.org>`_
+* `Numpy 1.13 <http://www.numpy.org>`_
+* `Matplotlib 2.0 <http://www.matplotlib.org>`_ (optional for plotting)
 * `Tifffile.c 2017.01.10 <http://www.lfd.uci.edu/~gohlke/>`_
   (recommended for faster decoding of PackBits and LZW encoded strings)
 
 Revisions
 ---------
+2017.09.29 (tentative)
+    Many backwards incompatible changes improving speed and resource usage:
+    Pass 2268 tests.
+    Add detail argument to __str__ function. Remove info functions.
+    Fix potential issue correcting offsets of large LSM files with positions.
+    Remove TiffFile iterator interface; use TiffFile.pages instead.
+    Do not make tag values available as TiffPage attributes.
+    Use str (not bytes) type for tag and metadata strings (WIP).
+    Use documented standard tag and value names (WIP).
+    Use enums for some documented TIFF tag values.
+    Remove 'memmap' and 'tmpfile' options; use out='memmap' instead.
+    Add option to specify output in asarray functions.
+    Add option to concurrently decode image strips or tiles using threads.
+    Add TiffPage.asrgb function (WIP).
+    Do not apply colormap in asarray.
+    Remove 'colormapped', 'rgbonly', and 'scale_mdgel' options from asarray.
+    Consolidate metadata in TiffFile _metadata functions.
+    Remove non-tag metadata properties from TiffPage.
+    Add function to convert LSM to tiled BIN files.
+    Align image data in file.
+    Make TiffPage.dtype a numpy.dtype.
+    Add 'ndim' and 'size' properties to TiffPage and TiffPageSeries.
+    Allow imsave to write non-BigTIFF files up to ~4 GB.
+    Only read one page for shaped series if possible.
+    Add memmap function to create memory-mapped array stored in TIFF file.
+    Add option to save empty arrays to TIFF files.
+    Add option to save truncated TIFF files.
+    Allow single tile images to be saved contiguously.
+    Add optional movie mode for files with uniform pages.
+    Lazy load pages.
+    Use lightweight TiffFrame for IFDs sharing properties with key TiffPage.
+    Move module constants to 'TIFF' namespace (speed up module import).
+    Remove 'fastij' option from TiffFile.
+    Remove 'pages' parameter from TiffFile.
+    Remove TIFFfile alias.
+    Deprecate Python 2.
+    Require enum34 and futures packages on Python 2.7.
+    Remove Record class and return all metadata as dict instead.
+    Add functions to parse STK, MetaSeries, ScanImage, SVS, Pilatus metadata.
+    Read tags from EXIF and GPS IFDs.
+    Use pformat for tag and metadata values.
+    Fix reading some UIC tags (bug fix).
+    Do not modify input array in imshow (bug fix).
+    Fix Python implementation of unpack_ints.
+2017.05.23
+    Pass 1961 tests.
+    Write correct number of sample_format values (bug fix).
+    Use Adobe deflate code to write ZIP compressed files.
+    Add option to pass tag values as packed binary data for writing.
+    Defer tag validation to attribute access.
+    Use property instead of lazyattr decorator for simple expressions.
+2017.03.17
+    Write IFDs and tag values on word boundaries.
+    Read ScanImage metadata.
+    Remove is_rgb and is_indexed attributes from TiffFile.
+    Create files used by doctests.
 2017.01.12
     Read Zeiss SEM metadata.
     Read OME-TIFF with invalid references to external files.
@@ -122,14 +179,14 @@ Revisions
     Do not color-map ImageJ hyperstacks (backwards incompatible).
     Towards supporting Leica SCN.
 2015.09.25
-    Read images with reversed bit order (fill_order is lsb2msb).
+    Read images with reversed bit order (FillOrder is LSB2MSB).
 2015.09.21
     Read RGB OME-TIFF.
     Warn about malformed OME-XML.
 2015.09.16
     Detect some corrupted ImageJ metadata.
     Better axes labels for 'shaped' files.
-    Do not create TiffTags for default values.
+    Do not create TiffTag for default values.
     Chroma subsampling is not supported.
     Memory-map data in TiffPageSeries if possible (optional).
 2015.08.17
@@ -138,7 +195,7 @@ Revisions
     Read and write LZMA compressed data.
     Specify datetime when saving (optional).
     Save tiled and color-mapped images (optional).
-    Ignore void byte_counts and offsets if possible.
+    Ignore void bytecounts and offsets if possible.
     Ignore bogus image_depth tag created by ISS Vista software.
     Decode floating point horizontal differencing (not tiled).
     Save image data contiguously if possible.
@@ -148,7 +205,7 @@ Revisions
     Try to read incomplete tiles.
     Open file dialog if no filename is passed on command line.
     Ignore errors when decoding OME-XML.
-    Rename decoder functions (backwards incompatible)
+    Rename decoder functions (backwards incompatible).
 2014.08.24
     TiffWriter class for incremental writing images.
     Simplify examples.
@@ -218,44 +275,49 @@ Other Python packages and modules for reading bio-scientific TIFF files:
 
 Acknowledgements
 ----------------
-*   Egor Zindy, University of Manchester, for cz_lsm_scan_info specifics.
-*   Wim Lewis for a bug fix and some read_cz_lsm functions.
+*   Egor Zindy, University of Manchester, for lsm_scan_info specifics.
+*   Wim Lewis for a bug fix and some LSM functions.
 *   Hadrien Mary for help on reading MicroManager files.
 *   Christian Kliche for help writing tiled and color-mapped files.
 
 References
 ----------
-(1) TIFF 6.0 Specification and Supplements. Adobe Systems Incorporated.
+1)  TIFF 6.0 Specification and Supplements. Adobe Systems Incorporated.
     http://partners.adobe.com/public/developer/tiff/
-(2) TIFF File Format FAQ. http://www.awaresystems.be/imaging/tiff/faq.html
-(3) MetaMorph Stack (STK) Image File Format.
+2)  TIFF File Format FAQ. http://www.awaresystems.be/imaging/tiff/faq.html
+3)  MetaMorph Stack (STK) Image File Format.
     http://support.meta.moleculardevices.com/docs/t10243.pdf
-(4) Image File Format Description LSM 5/7 Release 6.0 (ZEN 2010).
+4)  Image File Format Description LSM 5/7 Release 6.0 (ZEN 2010).
     Carl Zeiss MicroImaging GmbH. BioSciences. May 10, 2011
-(5) File Format Description - LSM 5xx Release 2.0.
-    http://ibb.gsf.de/homepage/karsten.rodenacker/IDL/Lsmfile.doc
-(6) The OME-TIFF format.
+5)  The OME-TIFF format.
     http://www.openmicroscopy.org/site/support/file-formats/ome-tiff
-(7) UltraQuant(r) Version 6.0 for Windows Start-Up Guide.
+6)  UltraQuant(r) Version 6.0 for Windows Start-Up Guide.
     http://www.ultralum.com/images%20ultralum/pdf/UQStart%20Up%20Guide.pdf
-(8) Micro-Manager File Formats.
+7)  Micro-Manager File Formats.
     http://www.micro-manager.org/wiki/Micro-Manager_File_Formats
-(9) Tags for TIFF and Related Specifications. Digital Preservation.
+8)  Tags for TIFF and Related Specifications. Digital Preservation.
     http://www.digitalpreservation.gov/formats/content/tiff_tags.shtml
+9)  ScanImage BigTiff Specification - ScanImage 2016.
+    http://scanimage.vidriotechnologies.com/display/SI2016/
+    ScanImage+BigTiff+Specification
+10) CIPA DC-008-2016: Exchangeable image file format for digital still cameras:
+    Exif Version 2.31.
+    http://www.cipa.jp/std/documents/e/DC-008-Translation-2016-E.pdf
 
 Examples
 --------
+>>> # write and read numpy array
 >>> data = numpy.random.rand(5, 301, 219)
 >>> imsave('temp.tif', data)
-
 >>> image = imread('temp.tif')
 >>> numpy.testing.assert_array_equal(image, data)
 
+>>> # iterate over pages and tags
 >>> with TiffFile('temp.tif') as tif:
 ...     images = tif.asarray()
-...     for page in tif:
+...     for page in tif.pages:
 ...         for tag in page.tags.values():
-...             t = tag.name, tag.value
+...             _ = tag.name, tag.value
 ...         image = page.asarray()
 
 """
@@ -264,19 +326,24 @@ from __future__ import division, print_function
 
 import sys
 import os
+import io
 import re
 import glob
 import math
 import zlib
 import time
 import json
+import enum
 import struct
 import warnings
 import tempfile
 import datetime
+import threading
 import collections
-from fractions import Fraction
-from xml.etree import cElementTree as etree
+import multiprocessing
+import concurrent.futures
+# from fractions import Fraction  # delay import
+# from xml.etree import cElementTree as etree  # delay import
 
 import numpy
 
@@ -288,16 +355,67 @@ except ImportError:
     except ImportError:
         lzma = None
 
-__version__ = '2017.01.12'
+__version__ = '2017.09.29'
 __docformat__ = 'restructuredtext en'
 __all__ = (
-    'imsave', 'imread', 'imshow', 'TiffFile', 'TiffWriter', 'TiffSequence',
-    # utility functions used in oiffile and czifile
-    'FileHandle', 'lazyattr', 'natural_sorted', 'decode_lzw', 'stripnull')
+    'imsave', 'imread', 'imshow', 'memmap',
+    'TiffFile', 'TiffWriter', 'TiffSequence',
+    # utility functions used by oiffile or czifile
+    'FileHandle', 'lazyattr', 'natural_sorted', 'decode_lzw', 'stripnull',
+    'create_output', 'repeat_nd', 'format_size', 'product')
+
+
+def imread(files, **kwargs):
+    """Return image data from TIFF file(s) as numpy array.
+
+    Refer to the TiffFile class and member functions for documentation.
+
+    Parameters
+    ----------
+    files : str, binary stream, or sequence
+        File name, seekable binary stream, glob pattern, or sequence of
+        file names.
+    kwargs : dict
+        Parameters 'multifile' and 'is_ome' are passed to the TiffFile class.
+        The 'pattern' parameter is passed to the TiffSequence class.
+        Other parameters are passed to the asarray functions.
+        The first image series is returned if no arguments are provided.
+
+    Examples
+    --------
+    >>> # get image from first page
+    >>> imsave('temp.tif', numpy.random.rand(3, 4, 301, 219))
+    >>> im = imread('temp.tif', key=0)
+    >>> im.shape
+    (4, 301, 219)
+
+    >>> # get images from sequence of files
+    >>> ims = imread(['temp.tif', 'temp.tif'])
+    >>> ims.shape
+    (2, 3, 4, 301, 219)
+
+    """
+    kwargs_file = parse_kwargs(kwargs, 'multifile', 'is_ome')
+    kwargs_seq = parse_kwargs(kwargs, 'pattern')
+
+    if isinstance(files, basestring) and any(i in files for i in '?*'):
+        files = glob.glob(files)
+    if not files:
+        raise ValueError('no files found')
+    if not hasattr(files, 'seek') and len(files) == 1:
+        files = files[0]
 
+    if isinstance(files, basestring) or hasattr(files, 'seek'):
+        with TiffFile(files, **kwargs_file) as tif:
+            return tif.asarray(**kwargs)
+    else:
+        with TiffSequence(files, **kwargs_seq) as imseq:
+            return imseq.asarray(**kwargs)
 
-def imsave(file, data, **kwargs):
-    """Write image data to TIFF file.
+
+def imsave(file, data=None, shape=None, dtype=None, bigsize=2**32-2**25,
+           **kwargs):
+    """Write numpy array to TIFF file.
 
     Refer to the TiffWriter class and member functions for documentation.
 
@@ -308,86 +426,204 @@ def imsave(file, data, **kwargs):
     data : array_like
         Input image. The last dimensions are assumed to be image depth,
         height, width, and samples.
+        If data is None, an empty array of the specified shape and dtype is
+        saved to file.
+    shape : tuple
+        If data is None, shape of an empty array to save to the file.
+    dtype : numpy.dtype
+        If data is None, data-type of an empty array to save to the file.
+    bigsize : int
+        Create a BigTIFF file if the size of data in bytes is larger than
+        this threshold and 'imagej' or 'truncate' are not enabled.
+        By default, the threshold is 4 GB minus 32 MB reserved for metadata.
+        Use the 'bigtiff' parameter to explicitly specify the type of
+        file created.
     kwargs : dict
         Parameters 'append', 'byteorder', 'bigtiff', 'software', and 'imagej',
-        are passed to the TiffWriter class.
-        Parameters 'photometric', 'planarconfig', 'resolution', 'compress',
-        'colormap', 'tile', 'description', 'datetime', 'metadata', 'contiguous'
-        and 'extratags' are passed to the TiffWriter.save function.
+        are passed to TiffWriter().
+        Other parameters are passed to TiffWriter.save().
+
+    Returns
+    -------
+    If the image data are written contiguously, return offset and bytecount
+    of image data in the file.
 
     Examples
     --------
+    >>> # save a RGB image
+    >>> data = numpy.random.randint(0, 255, (256, 256, 3), 'uint8')
+    >>> imsave('temp.tif', data, photometric='rgb')
+
+    >>> # save a random array and metadata, using compression
     >>> data = numpy.random.rand(2, 5, 3, 301, 219)
     >>> imsave('temp.tif', data, compress=6, metadata={'axes': 'TZCYX'})
 
     """
     tifargs = parse_kwargs(kwargs, 'append', 'bigtiff', 'byteorder',
                            'software', 'imagej')
-
-    if 'bigtiff' not in tifargs and 'imagej' not in tifargs and (
-            data.size*data.dtype.itemsize > 2000*2**20):
+    if data is None:
+        size = product(shape) * numpy.dtype(dtype).itemsize
+    else:
+        try:
+            size = data.nbytes
+        except Exception:
+            size = 0
+    if size > bigsize and 'bigtiff' not in tifargs and not (
+            tifargs.get('imagej', False) or tifargs.get('truncate', False)):
         tifargs['bigtiff'] = True
 
     with TiffWriter(file, **tifargs) as tif:
-        tif.save(data, **kwargs)
+        return tif.save(data, shape, dtype, **kwargs)
+
+
+def memmap(filename, shape=None, dtype=None, page=None, series=0, mode='r+',
+           **kwargs):
+    """Return memory-mapped numpy array stored in TIFF file.
+
+    Memory-mapping requires data stored in native byte order, without tiling,
+    compression, predictors, etc.
+    If shape and dtype are provided, existing files will be overwritten or
+    appended to depending on the 'append' parameter.
+    Otherwise the image data of a specified page or series in an existing
+    file will be memory-mapped. By default, the image data of the first page
+    series is memory-mapped.
+    Call flush() to write any changes in the array to the file.
+    Raise ValueError if the image data in the file is not memory-mappable
+
+    Parameters
+    ----------
+    filename : str
+        Name of the TIFF file which stores the array.
+    shape : tuple
+        Shape of the empty array.
+    dtype : numpy.dtype
+        Data-type of the empty array.
+    page : int
+        Index of the page which image data to memory-map.
+    series : int
+        Index of the page series which image data to memory-map.
+    mode : {'r+', 'r', 'c'}, optional
+        The file open mode. Default is to open existing file for reading and
+        writing ('r+').
+    kwargs : dict
+        Additional parameters passed to imsave() or TiffFile().
+
+    Examples
+    --------
+    >>> # create an empty TIFF file and write to memory-mapped image
+    >>> im = memmap('temp.tif', shape=(256, 256), dtype='float32')
+    >>> im[255, 255] = 1.0
+    >>> im.flush()
+    >>> im.shape, im.dtype
+    ((256, 256), dtype('float32'))
+    >>> del im
+
+    >>> # memory-map image data in a TIFF file
+    >>> im = memmap('temp.tif', page=0)
+    >>> im[255, 255]
+    1.0
+
+    """
+    if shape is not None and dtype is not None:
+        # create a new, empty array
+        kwargs.update(data=None, shape=shape, dtype=dtype, returnoffset=True,
+                      align=TIFF.ALLOCATIONGRANULARITY)
+        result = imsave(filename, **kwargs)
+        if result is None:
+            # TODO: fail before creating file or writing data
+            raise ValueError("image data is not memory-mappable")
+        offset = result[0]
+    else:
+        # use existing file
+        with TiffFile(filename, **kwargs) as tif:
+            if page is not None:
+                page = tif.pages[page]
+                if not page.is_memmappable:
+                    raise ValueError("image data is not memory-mappable")
+                offset, _ = page.is_contiguous
+                shape = page.shape
+                dtype = page.dtype
+            else:
+                series = tif.series[series]
+                if series.offset is None:
+                    raise ValueError("image data is not memory-mappable")
+                shape = series.shape
+                dtype = series.dtype
+                offset = series.offset
+    return numpy.memmap(filename, dtype, mode, offset, shape, 'C')
+
+
+class lazyattr(object):
+    """Attribute whose value is computed on first access."""
+    # TODO: help() doesn't work
+    __slots__ = ('func',)
+
+    def __init__(self, func):
+        self.func = func
+        # self.__name__ = func.__name__
+        # self.__doc__ = func.__doc__
+        # self.lock = threading.RLock()
+
+    def __get__(self, instance, owner):
+        # with self.lock:
+        if instance is None:
+            return self
+        try:
+            value = self.func(instance)
+        except AttributeError as e:
+            raise RuntimeError(e)
+        if value is NotImplemented:
+            return getattr(super(owner, instance), self.func.__name__)
+        setattr(instance, self.func.__name__, value)
+        return value
 
 
 class TiffWriter(object):
-    """Write image data to TIFF file.
+    """Write numpy arrays to TIFF file.
 
     TiffWriter instances must be closed using the 'close' method, which is
     automatically called when using the 'with' context manager.
 
+    TiffWriter's main purpose is saving nD numpy array's as TIFF,
+    not to create any possible TIFF format. Specifically, JPEG compression,
+    SubIFDs, ExifIFD, or GPSIFD tags are not supported.
+
     Examples
     --------
+    >>> # successively append images to BigTIFF file
     >>> data = numpy.random.rand(2, 5, 3, 301, 219)
     >>> with TiffWriter('temp.tif', bigtiff=True) as tif:
     ...     for i in range(data.shape[0]):
     ...         tif.save(data[i], compress=6)
 
     """
-    TYPES = {'B': 1, 's': 2, 'H': 3, 'I': 4, '2I': 5, 'b': 6,
-             'h': 8, 'i': 9, '2i': 10, 'f': 11, 'd': 12, 'Q': 16, 'q': 17}
-    TAGS = {
-        'new_subfile_type': 254, 'subfile_type': 255,
-        'image_width': 256, 'image_length': 257, 'bits_per_sample': 258,
-        'compression': 259, 'photometric': 262, 'document_name': 269,
-        'image_description': 270, 'strip_offsets': 273, 'orientation': 274,
-        'samples_per_pixel': 277, 'rows_per_strip': 278,
-        'strip_byte_counts': 279, 'x_resolution': 282, 'y_resolution': 283,
-        'planar_configuration': 284, 'page_name': 285, 'resolution_unit': 296,
-        'software': 305, 'datetime': 306, 'predictor': 317, 'color_map': 320,
-        'tile_width': 322, 'tile_length': 323, 'tile_offsets': 324,
-        'tile_byte_counts': 325, 'extra_samples': 338, 'sample_format': 339,
-        'smin_sample_value': 340, 'smax_sample_value': 341,
-        'image_depth': 32997, 'tile_depth': 32998}
-
-    def __init__(self, file, append=False, bigtiff=False, byteorder=None,
-                 software='tifffile.py', imagej=False):
+    def __init__(self, file, bigtiff=False, byteorder=None,
+                 software='tifffile.py', append=False, imagej=False):
         """Open a TIFF file for writing.
 
-        Existing files are overwritten by default.
-        Use bigtiff=True when creating files larger than 2 GB.
+        An empty TIFF file is created if the file does not exist, else the
+        file is overwritten with an empty empty TIFF file unless 'append'
+        is true. Use bigtiff=True when creating files larger than 4 GB.
 
         Parameters
         ----------
         file : str, binary stream, or FileHandle
             File name or writable binary stream, such as a open file
             or BytesIO.
-            The file is created if it does not exist.
-        append : bool
-            If True and 'file' is an existing standard TIFF file, image data
-            and tags are appended to the file.
-            Appending data may corrupt specifically formatted TIFF files
-            such as LSM, STK, ImageJ, NIH, or FluoView.
         bigtiff : bool
             If True, the BigTIFF format is used.
         byteorder : {'<', '>'}
             The endianness of the data in the file.
-            By default this is the system's native byte order.
+            By default, this is the system's native byte order.
         software : str
             Name of the software used to create the file.
             Saved with the first page in the file only.
+            Must be 7-bit ASCII.
+        append : bool
+            If True and 'file' is an existing standard TIFF file, image data
+            and tags are appended to the file.
+            Appending data may corrupt specifically formatted TIFF files
+            such as LSM, STK, ImageJ, NIH, or FluoView.
         imagej : bool
             If True, write an ImageJ hyperstack compatible file.
             This format can handle data types uint8, uint16, or float32 and
@@ -405,16 +641,15 @@ class TiffWriter(object):
                 with FileHandle(file, mode='rb', size=0) as fh:
                     pos = fh.tell()
                     try:
-                        with TiffFile(fh, pages=[0]) as tif:
+                        with TiffFile(fh) as tif:
                             if (append != 'force' and
-                                any(getattr(tif, 'is_'+a) for a in
-                                    ('lsm', 'stk', 'imagej', 'nih', 'fluoview',
-                                     'micromanager'))):
-                                raise ValueError("contains metadata")
+                                    any(getattr(tif, 'is_'+a) for a in (
+                                        'lsm', 'stk', 'imagej', 'nih',
+                                        'fluoview', 'micromanager'))):
+                                raise ValueError("file contains metadata")
                             byteorder = tif.byteorder
                             bigtiff = tif.is_bigtiff
-                            imagej = tif.is_imagej
-                            self._ifd_offset = tif._ifd_offset
+                            self._ifdoffset = tif.pages.next_page_offset
                             if tif.pages:
                                 software = None
                     except Exception as e:
@@ -424,45 +659,45 @@ class TiffWriter(object):
             except (IOError, FileNotFoundError):
                 append = False
 
-        if byteorder not in (None, '<', '>'):
-            raise ValueError("invalid byteorder %s" % byteorder)
-        if byteorder is None:
+        if byteorder in (None, '='):
             byteorder = '<' if sys.byteorder == 'little' else '>'
+        elif byteorder not in ('<', '>'):
+            raise ValueError("invalid byteorder %s" % byteorder)
         if imagej and bigtiff:
-            warnings.warn("writing incompatible bigtiff ImageJ")
+            warnings.warn("writing incompatible BigTIFF ImageJ")
 
         self._byteorder = byteorder
         self._software = software
         self._imagej = bool(imagej)
+        self._truncate = False
         self._metadata = None
         self._colormap = None
 
-        self._description_offset = 0
-        self._description_len_offset = 0
-        self._description_len = 0
-
+        self._descriptionoffset = 0
+        self._descriptionlen = 0
+        self._descriptionlenoffset = 0
         self._tags = None
         self._shape = None  # normalized shape of data in consecutive pages
-        self._data_shape = None  # shape of data in consecutive pages
-        self._data_dtype = None  # data type
-        self._data_offset = None  # offset to data
-        self._data_byte_counts = None  # byte counts per plane
-        self._tag_offsets = None  # strip or tile offset tag code
+        self._datashape = None  # shape of data in consecutive pages
+        self._datadtype = None  # data type
+        self._dataoffset = None  # offset to data
+        self._databytecounts = None  # byte counts per plane
+        self._tagoffsets = None  # strip or tile offset tag code
 
         if bigtiff:
             self._bigtiff = True
-            self._offset_size = 8
-            self._tag_size = 20
-            self._numtag_format = 'Q'
-            self._offset_format = 'Q'
-            self._value_format = '8s'
+            self._offsetsize = 8
+            self._tagsize = 20
+            self._tagnoformat = 'Q'
+            self._offsetformat = 'Q'
+            self._valueformat = '8s'
         else:
             self._bigtiff = False
-            self._offset_size = 4
-            self._tag_size = 12
-            self._numtag_format = 'H'
-            self._offset_format = 'I'
-            self._value_format = '4s'
+            self._offsetsize = 4
+            self._tagsize = 12
+            self._tagnoformat = 'H'
+            self._offsetformat = 'I'
+            self._valueformat = '4s'
 
         if append:
             self._fh = FileHandle(file, mode='r+b', size=0)
@@ -475,77 +710,100 @@ class TiffWriter(object):
             else:
                 self._fh.write(struct.pack(byteorder+'H', 42))
             # first IFD
-            self._ifd_offset = self._fh.tell()
-            self._fh.write(struct.pack(byteorder+self._offset_format, 0))
+            self._ifdoffset = self._fh.tell()
+            self._fh.write(struct.pack(byteorder+self._offsetformat, 0))
 
-    def save(self, data, photometric=None, planarconfig=None, tile=None,
-             contiguous=True, compress=0, colormap=None,
-             description=None, datetime=None, resolution=None,
+    def save(self, data=None, shape=None, dtype=None, returnoffset=False,
+             photometric=None, planarconfig=None, tile=None,
+             contiguous=True, align=16, truncate=False, compress=0,
+             colormap=None, description=None, datetime=None, resolution=None,
              metadata={}, extratags=()):
-        """Write image data and tags to TIFF file.
-
+        """Write numpy array and tags to TIFF file.
+
+        The data shape's last dimensions are assumed to be image depth,
+        height (length), width, and samples.
+        If a colormap is provided, the data's dtype must be uint8 or uint16
+        and the data values are indices into the last dimension of the
+        colormap.
+        If shape and dtype are specified, an empty array is saved.
+        This option can not be used with compression or multiple tiles.
         Image data are written in one stripe per plane by default.
         Dimensions larger than 2 to 4 (depending on photometric mode, planar
         configuration, and SGI mode) are flattened and saved as separate pages.
-        The 'sample_format' and 'bits_per_sample' tags are derived from
+        The 'SampleFormat' and 'BitsPerSample' tags are derived from
         the data type.
 
         Parameters
         ----------
-        data : numpy.ndarray
-            Input image. The last dimensions are assumed to be image depth,
-            height (length), width, and samples.
-            If a colormap is provided, the dtype must be uint8 or uint16 and
-            the data values are indices into the last dimension of the
-            colormap.
-        photometric : {'minisblack', 'miniswhite', 'rgb', 'palette', 'cfa'}
+        data : numpy.ndarray or None
+            Input image array.
+        shape : tuple or None
+            Shape of the empty array to save. Used only if data is None.
+        dtype : numpy.dtype or None
+            Data-type of the empty array to save. Used only if data is None.
+        returnoffset : bool
+            If True and the image data in the file is memory-mappable, return
+            the offset and number of bytes of the image data in the file.
+        photometric : {'MINISBLACK', 'MINISWHITE', 'RGB', 'PALETTE', 'CFA'}
             The color space of the image data.
-            By default this setting is inferred from the data shape and the
+            By default, this setting is inferred from the data shape and the
             value of colormap.
             For CFA images, DNG tags must be specified in extratags.
-        planarconfig : {'contig', 'planar'}
+        planarconfig : {'CONTIG', 'SEPARATE'}
             Specifies if samples are stored contiguous or in separate planes.
-            By default this setting is inferred from the data shape.
+            By default, this setting is inferred from the data shape.
             If this parameter is set, extra samples are used to store grayscale
             images.
-            'contig': last dimension contains samples.
-            'planar': third last dimension contains samples.
+            'CONTIG': last dimension contains samples.
+            'SEPARATE': third last dimension contains samples.
         tile : tuple of int
             The shape (depth, length, width) of image tiles to write.
             If None (default), image data are written in one stripe per plane.
             The tile length and width must be a multiple of 16.
-            If the tile depth is provided, the SGI image_depth and tile_depth
-            tags are used to save volume data. Few software can read the
-            SGI format, e.g. MeVisLab.
+            If the tile depth is provided, the SGI ImageDepth and TileDepth
+            tags are used to save volume data.
+            Unless a single tile is used, tiles cannot be used to write
+            contiguous files.
+            Few software can read the SGI format, e.g. MeVisLab.
         contiguous : bool
             If True (default) and the data and parameters are compatible with
-            previous ones, if any, the data are stored contiguously after
-            the previous one. Parameters 'photometric' and 'planarconfig' are
-            ignored.
-        compress : int or 'lzma'
+            previous ones, if any, the image data are stored contiguously after
+            the previous one. Parameters 'photometric' and 'planarconfig'
+            are ignored. Parameters 'description', datetime', and 'extratags'
+            are written to the first page of a contiguous series only.
+        align : int
+            Byte boundary on which to align the image data in the file.
+            Default 16. Use mmap.ALLOCATIONGRANULARITY for memory-mapped data.
+            Following contiguous writes are not aligned.
+        truncate : bool
+            If True, only write the first page including shape metadata if
+            possible (uncompressed, contiguous, not tiled).
+            Other TIFF readers will only be able to read part of the data.
+        compress : int or 'LZMA'
             Values from 0 to 9 controlling the level of zlib compression.
             If 0, data are written uncompressed (default).
             Compression cannot be used to write contiguous files.
-            If 'lzma', LZMA compression is used, which is not available on
+            If 'LZMA', LZMA compression is used, which is not available on
             all platforms.
         colormap : numpy.ndarray
             RGB color values for the corresponding data value.
             Must be of shape (3, 2**(data.itemsize*8)) and dtype uint16.
         description : str
-            The subject of the image. Saved with the first page only.
-            Cannot be used with the ImageJ format.
+            The subject of the image. Must be 7-bit ASCII. Cannot be used with
+            the ImageJ format. Saved with the first page only.
         datetime : datetime
-            Date and time of image creation. Saved with the first page only.
-            If None (default), the current date and time is used.
+            Date and time of image creation. If None (default), the current
+            date and time is used. Saved with the first page only.
         resolution : (float, float[, str]) or ((int, int), (int, int)[, str])
             X and Y resolutions in pixels per resolution unit as float or
-            rational numbers.
-            A third, optional parameter specifies the resolution unit,
-            which must be None (default for ImageJ), 'inch' (default), or 'cm'.
+            rational numbers. A third, optional parameter specifies the
+            resolution unit, which must be None (default for ImageJ),
+            'INCH' (default), or 'CENTIMETER'.
         metadata : dict
             Additional meta data to be saved along with shape information
-            in JSON or ImageJ formats in an image_description tag.
-            If None, do not write a second image_description tag.
+            in JSON or ImageJ formats in an ImageDescription tag.
+            If None, do not write a second ImageDescription tag.
+            Strings must be 7-bit ASCII. Saved with the first page only.
         extratags : sequence of tuples
             Additional tags as [(code, dtype, count, value, writeonce)].
 
@@ -555,9 +813,12 @@ class TiffWriter(object):
                 Data type of items in 'value' in Python struct format.
                 One of B, s, H, I, 2I, b, h, i, 2i, f, d, Q, or q.
             count : int
-                Number of data values. Not used for string values.
+                Number of data values. Not used for string or byte string
+                values.
             value : sequence
                 'Count' values compatible with 'dtype'.
+                Byte strings must contain count values of dtype packed as
+                binary data.
             writeonce : bool
                 If True, the tag is written to the first page only.
 
@@ -565,55 +826,85 @@ class TiffWriter(object):
         # TODO: refactor this function
         fh = self._fh
         byteorder = self._byteorder
-        numtag_format = self._numtag_format
-        value_format = self._value_format
-        offset_format = self._offset_format
-        offset_size = self._offset_size
-        tag_size = self._tag_size
 
-        data = numpy.asarray(data, dtype=byteorder+data.dtype.char, order='C')
-        if data.size == 0:
-            raise ValueError("can not save empty array")
+        if data is None:
+            if compress:
+                raise ValueError("can not save compressed empty file")
+            datashape = shape
+            datadtype = numpy.dtype(dtype).newbyteorder(byteorder)
+            datadtypechar = datadtype.char
+            data = None
+        else:
+            data = numpy.asarray(data, byteorder+data.dtype.char, 'C')
+            if data.size == 0:
+                raise ValueError("can not save empty array")
+            datashape = data.shape
+            datadtype = data.dtype
+            datadtypechar = data.dtype.char
+
+        returnoffset = returnoffset and datadtype.isnative
+        datasize = product(datashape) * datadtype.itemsize
 
         # just append contiguous data if possible
-        if self._data_shape:
-            if (not contiguous or
-                    self._data_shape[1:] != data.shape or
-                    self._data_dtype != data.dtype or
-                    (compress and self._tags) or
-                    tile or
-                    not numpy.array_equal(colormap, self._colormap)):
+        self._truncate = bool(truncate)
+        if self._datashape:
+            if (not contiguous
+                    or self._datashape[1:] != datashape
+                    or self._datadtype != datadtype
+                    or (compress and self._tags)
+                    or tile
+                    or not numpy.array_equal(colormap, self._colormap)):
                 # incompatible shape, dtype, compression mode, or colormap
                 self._write_remaining_pages()
                 self._write_image_description()
-                self._description_offset = 0
-                self._description_len_offset = 0
-                self._data_shape = None
+                self._truncate = False
+                self._descriptionoffset = 0
+                self._descriptionlenoffset = 0
+                self._datashape = None
                 self._colormap = None
                 if self._imagej:
                     raise ValueError(
                         "ImageJ does not support non-contiguous data")
             else:
                 # consecutive mode
-                self._data_shape = (self._data_shape[0] + 1,) + data.shape
+                self._datashape = (self._datashape[0] + 1,) + datashape
                 if not compress:
                     # write contiguous data, write ifds/tags later
-                    fh.write_array(data)
+                    offset = fh.tell()
+                    if data is None:
+                        fh.write_empty(datasize)
+                    else:
+                        fh.write_array(data)
+                    if returnoffset:
+                        return offset, datasize
                     return
 
-        if photometric not in (None, 'minisblack', 'miniswhite',
-                               'rgb', 'palette', 'cfa'):
-            raise ValueError("invalid photometric %s" % photometric)
-        if planarconfig not in (None, 'contig', 'planar'):
-            raise ValueError("invalid planarconfig %s" % planarconfig)
+        input_shape = datashape
+        tagnoformat = self._tagnoformat
+        valueformat = self._valueformat
+        offsetformat = self._offsetformat
+        offsetsize = self._offsetsize
+        tagsize = self._tagsize
+
+        MINISBLACK = TIFF.PHOTOMETRIC.MINISBLACK
+        RGB = TIFF.PHOTOMETRIC.RGB
+        CFA = TIFF.PHOTOMETRIC.CFA
+        PALETTE = TIFF.PHOTOMETRIC.PALETTE
+        CONTIG = TIFF.PLANARCONFIG.CONTIG
+        SEPARATE = TIFF.PLANARCONFIG.SEPARATE
+
+        if photometric is not None:
+            photometric = enumarg(TIFF.PHOTOMETRIC, photometric)
+        if planarconfig:
+            planarconfig = enumarg(TIFF.PLANARCONFIG, planarconfig)
 
         # prepare compression
         if not compress:
             compress = False
-            compress_tag = 1
-        elif compress == 'lzma':
+            compresstag = 1
+        elif compress == 'LZMA':
             compress = lzma.compress
-            compress_tag = 34925
+            compresstag = 34925
             if self._imagej:
                 raise ValueError("ImageJ can not handle LZMA compression")
         elif not 0 <= compress <= 9:
@@ -621,7 +912,7 @@ class TiffWriter(object):
         elif compress:
             def compress(data, level=compress):
                 return zlib.compress(data, level)
-            compress_tag = 32946
+            compresstag = 8
 
         # prepare ImageJ format
         if self._imagej:
@@ -629,32 +920,32 @@ class TiffWriter(object):
                 warnings.warn("not writing description to ImageJ file")
                 description = None
             volume = False
-            if data.dtype.char not in 'BHhf':
-                raise ValueError("ImageJ does not support data type '%s'"
-                                 % data.dtype.char)
-            ijrgb = photometric == 'rgb' if photometric else None
-            if data.dtype.char not in 'B':
+            if datadtypechar not in 'BHhf':
+                raise ValueError(
+                    "ImageJ does not support data type '%s'" % datadtypechar)
+            ijrgb = photometric == RGB if photometric else None
+            if datadtypechar not in 'B':
                 ijrgb = False
-            ijshape = imagej_shape(data.shape, ijrgb)
+            ijshape = imagej_shape(datashape, ijrgb)
             if ijshape[-1] in (3, 4):
-                photometric = 'rgb'
-                if data.dtype.char not in 'B':
+                photometric = RGB
+                if datadtypechar not in 'B':
                     raise ValueError("ImageJ does not support data type '%s' "
-                                     "for RGB" % data.dtype.char)
+                                     "for RGB" % datadtypechar)
             elif photometric is None:
-                photometric = 'minisblack'
+                photometric = MINISBLACK
                 planarconfig = None
-            if planarconfig == 'planar':
+            if planarconfig == SEPARATE:
                 raise ValueError("ImageJ does not support planar images")
             else:
-                planarconfig = 'contig' if ijrgb else None
+                planarconfig = CONTIG if ijrgb else None
 
         # verify colormap and indices
         if colormap is not None:
-            if data.dtype.char not in 'BH':
+            if datadtypechar not in 'BH':
                 raise ValueError("invalid data dtype for palette mode")
             colormap = numpy.asarray(colormap, dtype=byteorder+'H')
-            if colormap.shape != (3, 2**(data.itemsize * 8)):
+            if colormap.shape != (3, 2**(datadtype.itemsize * 8)):
                 raise ValueError("invalid color map shape")
             self._colormap = colormap
 
@@ -671,82 +962,75 @@ class TiffWriter(object):
 
         # normalize data shape to 5D or 6D, depending on volume:
         #   (pages, planar_samples, [depth,] height, width, contig_samples)
-        data_shape = data.shape
-
-        if photometric == 'rgb':
-            data = reshape_nd(data, 3)
-        else:
-            data = reshape_nd(data, 2)
-
-        shape = data.shape
+        datashape = reshape_nd(datashape, 3 if photometric == RGB else 2)
+        shape = datashape
+        ndim = len(datashape)
 
         samplesperpixel = 1
         extrasamples = 0
-        if volume and data.ndim < 3:
+        if volume and ndim < 3:
             volume = False
         if colormap is not None:
-            photometric = 'palette'
+            photometric = PALETTE
             planarconfig = None
         if photometric is None:
-            photometric = 'minisblack'
-            if planarconfig == 'contig':
-                if data.ndim > 2 and shape[-1] in (3, 4):
-                    photometric = 'rgb'
-            elif planarconfig == 'planar':
-                if volume and data.ndim > 3 and shape[-4] in (3, 4):
-                    photometric = 'rgb'
-                elif data.ndim > 2 and shape[-3] in (3, 4):
-                    photometric = 'rgb'
-            elif data.ndim > 2 and shape[-1] in (3, 4):
-                photometric = 'rgb'
+            photometric = MINISBLACK
+            if planarconfig == CONTIG:
+                if ndim > 2 and shape[-1] in (3, 4):
+                    photometric = RGB
+            elif planarconfig == SEPARATE:
+                if volume and ndim > 3 and shape[-4] in (3, 4):
+                    photometric = RGB
+                elif ndim > 2 and shape[-3] in (3, 4):
+                    photometric = RGB
+            elif ndim > 2 and shape[-1] in (3, 4):
+                photometric = RGB
             elif self._imagej:
-                photometric = 'minisblack'
-            elif volume and data.ndim > 3 and shape[-4] in (3, 4):
-                photometric = 'rgb'
-            elif data.ndim > 2 and shape[-3] in (3, 4):
-                photometric = 'rgb'
+                photometric = MINISBLACK
+            elif volume and ndim > 3 and shape[-4] in (3, 4):
+                photometric = RGB
+            elif ndim > 2 and shape[-3] in (3, 4):
+                photometric = RGB
         if planarconfig and len(shape) <= (3 if volume else 2):
             planarconfig = None
-            photometric = 'minisblack'
-        if photometric == 'rgb':
+            photometric = MINISBLACK
+        if photometric == RGB:
             if len(shape) < 3:
                 raise ValueError("not a RGB(A) image")
             if len(shape) < 4:
                 volume = False
             if planarconfig is None:
                 if shape[-1] in (3, 4):
-                    planarconfig = 'contig'
+                    planarconfig = CONTIG
                 elif shape[-4 if volume else -3] in (3, 4):
-                    planarconfig = 'planar'
+                    planarconfig = SEPARATE
                 elif shape[-1] > shape[-4 if volume else -3]:
-                    planarconfig = 'planar'
+                    planarconfig = SEPARATE
                 else:
-                    planarconfig = 'contig'
-            if planarconfig == 'contig':
-                data = data.reshape((-1, 1) + shape[(-4 if volume else -3):])
-                samplesperpixel = data.shape[-1]
+                    planarconfig = CONTIG
+            if planarconfig == CONTIG:
+                datashape = (-1, 1) + shape[(-4 if volume else -3):]
+                samplesperpixel = datashape[-1]
             else:
-                data = data.reshape(
-                    (-1,) + shape[(-4 if volume else -3):] + (1,))
-                samplesperpixel = data.shape[1]
+                datashape = (-1,) + shape[(-4 if volume else -3):] + (1,)
+                samplesperpixel = datashape[1]
             if samplesperpixel > 3:
                 extrasamples = samplesperpixel - 3
-        elif photometric == 'cfa':
+        elif photometric == CFA:
             if len(shape) != 2:
                 raise ValueError("invalid CFA image")
             volume = False
             planarconfig = None
-            data = data.reshape((-1, 1) + shape[-2:] + (1,))
+            datashape = (-1, 1) + shape[-2:] + (1,)
             if 50706 not in (et[0] for et in extratags):
                 raise ValueError("must specify DNG tags for CFA image")
         elif planarconfig and len(shape) > (3 if volume else 2):
-            if planarconfig == 'contig':
-                data = data.reshape((-1, 1) + shape[(-4 if volume else -3):])
-                samplesperpixel = data.shape[-1]
+            if planarconfig == CONTIG:
+                datashape = (-1, 1) + shape[(-4 if volume else -3):]
+                samplesperpixel = datashape[-1]
             else:
-                data = data.reshape(
-                    (-1,) + shape[(-4 if volume else -3):] + (1,))
-                samplesperpixel = data.shape[1]
+                datashape = (-1,) + shape[(-4 if volume else -3):] + (1,)
+                samplesperpixel = datashape[1]
             extrasamples = samplesperpixel - 1
         else:
             planarconfig = None
@@ -755,34 +1039,38 @@ class TiffWriter(object):
                 shape = shape[:-1]
             if len(shape) < 3:
                 volume = False
-            data = data.reshape(
-                (-1, 1) + shape[(-3 if volume else -2):] + (1,))
+            datashape = (-1, 1) + shape[(-3 if volume else -2):] + (1,)
 
         # normalize shape to 6D
-        assert len(data.shape) in (5, 6)
-        if len(data.shape) == 5:
-            data = data.reshape(data.shape[:2] + (1,) + data.shape[2:])
-        shape = data.shape
+        assert len(datashape) in (5, 6)
+        if len(datashape) == 5:
+            datashape = datashape[:2] + (1,) + datashape[2:]
+        if datashape[0] == -1:
+            s0 = product(input_shape) // product(datashape[1:])
+            datashape = (s0,) + datashape[1:]
+        shape = datashape
+        if data is not None:
+            data = data.reshape(shape)
 
         if tile and not volume:
             tile = (1, tile[-2], tile[-1])
 
-        if photometric == 'palette':
+        if photometric == PALETTE:
             if (samplesperpixel != 1 or extrasamples or
                     shape[1] != 1 or shape[-1] != 1):
                 raise ValueError("invalid data shape for palette mode")
 
-        if photometric == 'rgb' and samplesperpixel == 2:
+        if photometric == RGB and samplesperpixel == 2:
             raise ValueError("not a RGB image (samplesperpixel=2)")
 
         bytestr = bytes if sys.version[0] == '2' else (
-            lambda x: bytes(x, 'utf-8') if isinstance(x, str) else x)
+            lambda x: bytes(x, 'ascii') if isinstance(x, str) else x)
         tags = []  # list of (code, ifdentry, ifdvalue, writeonce)
 
-        strip_or_tile = 'tile' if tile else 'strip'
-        tag_byte_counts = TiffWriter.TAGS[strip_or_tile + '_byte_counts']
-        tag_offsets = TiffWriter.TAGS[strip_or_tile + '_offsets']
-        self._tag_offsets = tag_offsets
+        strip_or_tile = 'Tile' if tile else 'Strip'
+        tagbytecounts = TIFF.TAG_NAMES[strip_or_tile + 'ByteCounts']
+        tag_offsets = TIFF.TAG_NAMES[strip_or_tile + 'Offsets']
+        self._tagoffsets = tag_offsets
 
         def pack(fmt, *val):
             return struct.pack(byteorder+fmt, *val)
@@ -790,13 +1078,15 @@ class TiffWriter(object):
         def addtag(code, dtype, count, value, writeonce=False):
             # Compute ifdentry & ifdvalue bytes from code, dtype, count, value
             # Append (code, ifdentry, ifdvalue, writeonce) to tags list
-            code = int(TiffWriter.TAGS.get(code, code))
+            code = int(TIFF.TAG_NAMES.get(code, code))
             try:
-                tifftype = TiffWriter.TYPES[dtype]
+                tifftype = TIFF.DATA_DTYPES[dtype]
             except KeyError:
                 raise ValueError("unknown dtype %s" % dtype)
             rawcount = count
+
             if dtype == 's':
+                # strings
                 value = bytestr(value) + b'\0'
                 count = rawcount = len(value)
                 rawcount = value.find(b'\0\0')
@@ -805,25 +1095,35 @@ class TiffWriter(object):
                 else:
                     rawcount += 1  # length of string without buffer
                 value = (value,)
+            elif isinstance(value, bytes):
+                # packed binary data
+                dtsize = struct.calcsize(dtype)
+                if len(value) % dtsize:
+                    raise ValueError('invalid packed binary data')
+                count = len(value) // dtsize
             if len(dtype) > 1:
                 count *= int(dtype[:-1])
                 dtype = dtype[-1]
             ifdentry = [pack('HH', code, tifftype),
-                        pack(offset_format, rawcount)]
+                        pack(offsetformat, rawcount)]
             ifdvalue = None
-            if struct.calcsize(dtype) * count <= offset_size:
+            if struct.calcsize(dtype) * count <= offsetsize:
                 # value(s) can be written directly
-                if count == 1:
+                if isinstance(value, bytes):
+                    ifdentry.append(pack(valueformat, value))
+                elif count == 1:
                     if isinstance(value, (tuple, list, numpy.ndarray)):
                         value = value[0]
-                    ifdentry.append(pack(value_format, pack(dtype, value)))
+                    ifdentry.append(pack(valueformat, pack(dtype, value)))
                 else:
-                    ifdentry.append(pack(value_format,
+                    ifdentry.append(pack(valueformat,
                                          pack(str(count)+dtype, *value)))
             else:
                 # use offset to value(s)
-                ifdentry.append(pack(offset_format, 0))
-                if isinstance(value, numpy.ndarray):
+                ifdentry.append(pack(offsetformat, 0))
+                if isinstance(value, bytes):
+                    ifdvalue = value
+                elif isinstance(value, numpy.ndarray):
                     assert value.size == count
                     assert value.dtype.char == dtype
                     ifdvalue = value.tostring()
@@ -835,6 +1135,7 @@ class TiffWriter(object):
 
         def rational(arg, max_denominator=1000000):
             # return nominator and denominator from float or two integers
+            from fractions import Fraction  # delayed import
             try:
                 f = Fraction.from_float(arg)
             except TypeError:
@@ -844,94 +1145,103 @@ class TiffWriter(object):
 
         if description:
             # user provided description
-            addtag('image_description', 's', 0, description, writeonce=True)
+            addtag('ImageDescription', 's', 0, description, writeonce=True)
 
-        # write shape and metadata to image_description
-        self._metadata = {} if not metadata else metadata
+        # write shape and metadata to ImageDescription
+        self._metadata = {} if not metadata else metadata.copy()
         if self._imagej:
             description = imagej_description(
-                data_shape, shape[-1] in (3, 4), self._colormap is not None,
+                input_shape, shape[-1] in (3, 4), self._colormap is not None,
                 **self._metadata)
         elif metadata or metadata == {}:
-            description = image_description(
-                data_shape, self._colormap is not None, **self._metadata)
+            if self._truncate:
+                self._metadata.update(truncated=True)
+            description = json_description(input_shape, **self._metadata)
         else:
             description = None
         if description:
-            # add 32 bytes buffer
+            # add 64 bytes buffer
             # the image description might be updated later with the final shape
-            description += b'\0'*32
-            self._description_len = len(description)
-            addtag('image_description', 's', 0, description, writeonce=True)
+            description = str2bytes(description, 'ascii')
+            description += b'\0'*64
+            self._descriptionlen = len(description)
+            addtag('ImageDescription', 's', 0, description, writeonce=True)
 
         if self._software:
-            addtag('software', 's', 0, self._software, writeonce=True)
+            addtag('Software', 's', 0, self._software, writeonce=True)
             self._software = None  # only save to first page in file
         if datetime is None:
             datetime = self._now()
-        addtag('datetime', 's', 0, datetime.strftime("%Y:%m:%d %H:%M:%S"),
+        addtag('DateTime', 's', 0, datetime.strftime("%Y:%m:%d %H:%M:%S"),
                writeonce=True)
-        addtag('compression', 'H', 1, compress_tag)
-        addtag('image_width', 'I', 1, shape[-2])
-        addtag('image_length', 'I', 1, shape[-3])
+        addtag('Compression', 'H', 1, compresstag)
+        addtag('ImageWidth', 'I', 1, shape[-2])
+        addtag('ImageLength', 'I', 1, shape[-3])
         if tile:
-            addtag('tile_width', 'I', 1, tile[-1])
-            addtag('tile_length', 'I', 1, tile[-2])
+            addtag('TileWidth', 'I', 1, tile[-1])
+            addtag('TileLength', 'I', 1, tile[-2])
             if tile[0] > 1:
-                addtag('image_depth', 'I', 1, shape[-4])
-                addtag('tile_depth', 'I', 1, tile[0])
-        addtag('new_subfile_type', 'I', 1, 0)
-        addtag('sample_format', 'H', 1,
-               {'u': 1, 'i': 2, 'f': 3, 'c': 6}[data.dtype.kind])
-        addtag('photometric', 'H', 1, {'miniswhite': 0, 'minisblack': 1,
-                                       'rgb': 2, 'palette': 3,
-                                       'cfa': 32803}[photometric])
+                addtag('ImageDepth', 'I', 1, shape[-4])
+                addtag('TileDepth', 'I', 1, tile[0])
+        addtag('NewSubfileType', 'I', 1, 0)
+        sampleformat = {'u': 1, 'i': 2, 'f': 3, 'c': 6}[datadtype.kind]
+        addtag('SampleFormat', 'H', samplesperpixel,
+               (sampleformat,) * samplesperpixel)
+        addtag('PhotometricInterpretation', 'H', 1, photometric.value)
         if colormap is not None:
-            addtag('color_map', 'H', colormap.size, colormap)
-        addtag('samples_per_pixel', 'H', 1, samplesperpixel)
+            addtag('ColorMap', 'H', colormap.size, colormap)
+        addtag('SamplesPerPixel', 'H', 1, samplesperpixel)
         if planarconfig and samplesperpixel > 1:
-            addtag('planar_configuration', 'H', 1, 1
-                   if planarconfig == 'contig' else 2)
-            addtag('bits_per_sample', 'H', samplesperpixel,
-                   (data.dtype.itemsize * 8,) * samplesperpixel)
+            addtag('PlanarConfiguration', 'H', 1, planarconfig.value)
+            addtag('BitsPerSample', 'H', samplesperpixel,
+                   (datadtype.itemsize * 8,) * samplesperpixel)
         else:
-            addtag('bits_per_sample', 'H', 1, data.dtype.itemsize * 8)
+            addtag('BitsPerSample', 'H', 1, datadtype.itemsize * 8)
         if extrasamples:
-            if photometric == 'rgb' and extrasamples == 1:
-                addtag('extra_samples', 'H', 1, 1)  # associated alpha channel
+            if photometric == RGB and extrasamples == 1:
+                addtag('ExtraSamples', 'H', 1, 1)  # associated alpha channel
             else:
-                addtag('extra_samples', 'H', extrasamples, (0,) * extrasamples)
+                addtag('ExtraSamples', 'H', extrasamples, (0,) * extrasamples)
         if resolution:
-            addtag('x_resolution', '2I', 1, rational(resolution[0]))
-            addtag('y_resolution', '2I', 1, rational(resolution[1]))
+            addtag('XResolution', '2I', 1, rational(resolution[0]))
+            addtag('YResolution', '2I', 1, rational(resolution[1]))
             if len(resolution) > 2:
-                resolution_unit = {None: 1, 'inch': 2, 'cm': 3}[resolution[2]]
+                unit = resolution[2]
+                if unit is not None:
+                    unit = unit.upper()
+                unit = {None: 1, 'INCH': 2, 'CM': 3, 'CENTIMETER': 3}[unit]
             elif self._imagej:
-                resolution_unit = 1
+                unit = 1
             else:
-                resolution_unit = 2
-            addtag('resolution_unit', 'H', 1, resolution_unit)
+                unit = 2
+            addtag('ResolutionUnit', 'H', 1, unit)
         if not tile:
-            addtag('rows_per_strip', 'I', 1, shape[-3])  # * shape[-4]
+            addtag('RowsPerStrip', 'I', 1, shape[-3])  # * shape[-4]
 
+        contiguous = not compress
         if tile:
             # use one chunk per tile per plane
             tiles = ((shape[2] + tile[0] - 1) // tile[0],
                      (shape[3] + tile[1] - 1) // tile[1],
                      (shape[4] + tile[2] - 1) // tile[2])
             numtiles = product(tiles) * shape[1]
-            strip_byte_counts = [
-                product(tile) * shape[-1] * data.dtype.itemsize] * numtiles
-            addtag(tag_byte_counts, offset_format, numtiles, strip_byte_counts)
-            addtag(tag_offsets, offset_format, numtiles, [0] * numtiles)
-            # allocate tile buffer
-            chunk = numpy.empty(tile + (shape[-1],), dtype=data.dtype)
+            stripbytecounts = [
+                product(tile) * shape[-1] * datadtype.itemsize] * numtiles
+            addtag(tagbytecounts, offsetformat, numtiles, stripbytecounts)
+            addtag(tag_offsets, offsetformat, numtiles, [0] * numtiles)
+            contiguous = contiguous and product(tiles) == 1
+            if not contiguous:
+                # allocate tile buffer
+                chunk = numpy.empty(tile + (shape[-1],), dtype=datadtype)
         else:
             # use one strip per plane
-            strip_byte_counts = [
-                data[0, 0].size * data.dtype.itemsize] * shape[1]
-            addtag(tag_byte_counts, offset_format, shape[1], strip_byte_counts)
-            addtag(tag_offsets, offset_format, shape[1], [0] * shape[1])
+            stripbytecounts = [
+                product(datashape[2:]) * datadtype.itemsize] * shape[1]
+            addtag(tagbytecounts, offsetformat, shape[1], stripbytecounts)
+            addtag(tag_offsets, offsetformat, shape[1], [0] * shape[1])
+
+        if data is None and not contiguous:
+            raise ValueError("can not write non-contiguous empty file")
 
         # add extra tags from user
         for t in extratags:
@@ -943,48 +1253,64 @@ class TiffWriter(object):
         tags = sorted(tags, key=lambda x: x[0])
 
         if not (self._bigtiff or self._imagej) and (
-                fh.tell() + data.size*data.dtype.itemsize > 2**31-1):
+                fh.tell() + datasize > 2**31-1):
             raise ValueError("data too large for standard TIFF file")
 
-        # if not compressed or tiled, write the first ifd and then all data
-        # contiguously; else, write all ifds and data interleaved
-        for pageindex in range(shape[0] if (compress or tile) else 1):
+        # if not compressed or multi-tiled, write the first ifd and then
+        # all data contiguously; else, write all ifds and data interleaved
+        for pageindex in range(1 if contiguous else shape[0]):
             # update pointer at ifd_offset
             pos = fh.tell()
-            fh.seek(self._ifd_offset)
-            fh.write(pack(offset_format, pos))
+            if pos % 2:
+                # location of IFD must begin on a word boundary
+                fh.write(b'\0')
+                pos += 1
+            fh.seek(self._ifdoffset)
+            fh.write(pack(offsetformat, pos))
             fh.seek(pos)
 
             # write ifdentries
-            fh.write(pack(numtag_format, len(tags)))
+            fh.write(pack(tagnoformat, len(tags)))
             tag_offset = fh.tell()
             fh.write(b''.join(t[1] for t in tags))
-            self._ifd_offset = fh.tell()
-            fh.write(pack(offset_format, 0))  # offset to next IFD
+            self._ifdoffset = fh.tell()
+            fh.write(pack(offsetformat, 0))  # offset to next IFD
 
             # write tag values and patch offsets in ifdentries, if necessary
             for tagindex, tag in enumerate(tags):
                 if tag[2]:
                     pos = fh.tell()
-                    fh.seek(tag_offset + tagindex*tag_size + offset_size + 4)
-                    fh.write(pack(offset_format, pos))
+                    if pos % 2:
+                        # tag value is expected to begin on word boundary
+                        fh.write(b'\0')
+                        pos += 1
+                    fh.seek(tag_offset + tagindex*tagsize + offsetsize + 4)
+                    fh.write(pack(offsetformat, pos))
                     fh.seek(pos)
                     if tag[0] == tag_offsets:
-                        strip_offsets_offset = pos
-                    elif tag[0] == tag_byte_counts:
-                        strip_byte_counts_offset = pos
+                        stripoffsetsoffset = pos
+                    elif tag[0] == tagbytecounts:
+                        strip_bytecounts_offset = pos
                     elif tag[0] == 270 and tag[2].endswith(b'\0\0\0\0'):
                         # image description buffer
-                        self._description_offset = pos
-                        self._description_len_offset = (
-                            tag_offset + tagindex * tag_size + 4)
+                        self._descriptionoffset = pos
+                        self._descriptionlenoffset = (
+                            tag_offset + tagindex * tagsize + 4)
                     fh.write(tag[2])
 
             # write image data
             data_offset = fh.tell()
+            skip = align - data_offset % align
+            fh.seek(skip, 1)
+            data_offset += skip
             if compress:
-                strip_byte_counts = []
-            if tile:
+                stripbytecounts = []
+            if contiguous:
+                if data is None:
+                    fh.write_empty(datasize)
+                else:
+                    fh.write_array(data)
+            elif tile:
                 for plane in data[pageindex]:
                     for tz in range(tiles[0]):
                         for ty in range(tiles[1]):
@@ -999,7 +1325,7 @@ class TiffWriter(object):
                                     tx*tile[2]:tx*tile[2]+c2]
                                 if compress:
                                     t = compress(chunk)
-                                    strip_byte_counts.append(len(t))
+                                    stripbytecounts.append(len(t))
                                     fh.write(t)
                                 else:
                                     fh.write_array(chunk)
@@ -1007,35 +1333,32 @@ class TiffWriter(object):
             elif compress:
                 for plane in data[pageindex]:
                     plane = compress(plane)
-                    strip_byte_counts.append(len(plane))
+                    stripbytecounts.append(len(plane))
                     fh.write(plane)
-            else:
-                fh.write_array(data)
 
-            # update strip/tile offsets and byte_counts if necessary
+            # update strip/tile offsets and bytecounts if necessary
             pos = fh.tell()
             for tagindex, tag in enumerate(tags):
                 if tag[0] == tag_offsets:  # strip/tile offsets
                     if tag[2]:
-                        fh.seek(strip_offsets_offset)
+                        fh.seek(stripoffsetsoffset)
                         strip_offset = data_offset
-                        for size in strip_byte_counts:
-                            fh.write(pack(offset_format, strip_offset))
+                        for size in stripbytecounts:
+                            fh.write(pack(offsetformat, strip_offset))
                             strip_offset += size
                     else:
-                        fh.seek(tag_offset + tagindex*tag_size +
-                                offset_size + 4)
-                        fh.write(pack(offset_format, data_offset))
-                elif tag[0] == tag_byte_counts:  # strip/tile byte_counts
+                        fh.seek(tag_offset + tagindex*tagsize + offsetsize + 4)
+                        fh.write(pack(offsetformat, data_offset))
+                elif tag[0] == tagbytecounts:  # strip/tile bytecounts
                     if compress:
                         if tag[2]:
-                            fh.seek(strip_byte_counts_offset)
-                            for size in strip_byte_counts:
-                                fh.write(pack(offset_format, size))
+                            fh.seek(strip_bytecounts_offset)
+                            for size in stripbytecounts:
+                                fh.write(pack(offsetformat, size))
                         else:
-                            fh.seek(tag_offset + tagindex*tag_size +
-                                    offset_size + 4)
-                            fh.write(pack(offset_format, strip_byte_counts[0]))
+                            fh.seek(tag_offset + tagindex*tagsize +
+                                    offsetsize + 4)
+                            fh.write(pack(offsetformat, stripbytecounts[0]))
                     break
             fh.seek(pos)
             fh.flush()
@@ -1044,129 +1367,142 @@ class TiffWriter(object):
             if pageindex == 0:
                 tags = [tag for tag in tags if not tag[-1]]
 
-        # if uncompressed, write remaining ifds/tags later
-        if not (compress or tile):
-            self._tags = tags
-
         self._shape = shape
-        self._data_shape = (1,) + data_shape
-        self._data_dtype = data.dtype
-        self._data_offset = data_offset
-        self._data_byte_counts = strip_byte_counts
+        self._datashape = (1,) + input_shape
+        self._datadtype = datadtype
+        self._dataoffset = data_offset
+        self._databytecounts = stripbytecounts
+
+        if contiguous:
+            # write remaining ifds/tags later
+            self._tags = tags
+            # return offset and size of image data
+            if returnoffset:
+                return data_offset, sum(stripbytecounts)
 
     def _write_remaining_pages(self):
         """Write outstanding IFDs and tags to file."""
-        if not self._tags:
+        if not self._tags or self._truncate:
             return
 
         fh = self._fh
         byteorder = self._byteorder
-        numtag_format = self._numtag_format
-        offset_format = self._offset_format
-        offset_size = self._offset_size
-        tag_size = self._tag_size
-        data_offset = self._data_offset
-        page_data_size = sum(self._data_byte_counts)
-        tag_bytes = b''.join(t[1] for t in self._tags)
-        numpages = self._shape[0] * self._data_shape[0] - 1
+        offsetformat = self._offsetformat
+        offsetsize = self._offsetsize
+        tagnoformat = self._tagnoformat
+        tagsize = self._tagsize
+        dataoffset = self._dataoffset
+        pagedatasize = sum(self._databytecounts)
+        pageno = self._shape[0] * self._datashape[0] - 1
+
+        def pack(fmt, *val):
+            return struct.pack(byteorder+fmt, *val)
 
+        # construct template IFD in memory
+        # need to patch offsets to next IFD and data before writing to disk
+        ifd = io.BytesIO()
+        ifd.write(pack(tagnoformat, len(self._tags)))
+        tagoffset = ifd.tell()
+        ifd.write(b''.join(t[1] for t in self._tags))
+        ifdoffset = ifd.tell()
+        ifd.write(pack(offsetformat, 0))  # offset to next IFD
+        # tag values
+        for tagindex, tag in enumerate(self._tags):
+            offset2value = tagoffset + tagindex*tagsize + offsetsize + 4
+            if tag[2]:
+                pos = ifd.tell()
+                if pos % 2:  # tag value is expected to begin on word boundary
+                    ifd.write(b'\0')
+                    pos += 1
+                ifd.seek(offset2value)
+                ifd.write(pack(offsetformat, pos + fh.tell()))
+                ifd.seek(pos)
+                ifd.write(tag[2])
+                if tag[0] == self._tagoffsets:
+                    # save strip/tile offsets for later updates
+                    stripoffset2offset = offset2value
+                    stripoffset2value = pos
+            elif tag[0] == self._tagoffsets:
+                # save strip/tile offsets for later updates
+                stripoffset2offset = None
+                stripoffset2value = offset2value
+        # size to word boundary
+        if ifd.tell() % 2:
+            ifd.write(b'\0')
+
+        # check if all IFDs fit in file
         pos = fh.tell()
-        if not self._bigtiff and pos + len(tag_bytes) * numpages > 2**32 - 256:
+        if not self._bigtiff and pos + ifd.tell() * pageno > 2**32 - 256:
             if self._imagej:
                 warnings.warn("truncating ImageJ file")
                 return
-            raise ValueError("data too large for non-bigtiff file")
-
-        def pack(fmt, *val):
-            return struct.pack(byteorder+fmt, *val)
-
-        for _ in range(numpages):
-            # update pointer at ifd_offset
-            pos = fh.tell()
-            fh.seek(self._ifd_offset)
-            fh.write(pack(offset_format, pos))
-            fh.seek(pos)
-
-            # write ifd entries
-            fh.write(pack(numtag_format, len(self._tags)))
-            tag_offset = fh.tell()
-            fh.write(tag_bytes)
-            self._ifd_offset = fh.tell()
-            fh.write(pack(offset_format, 0))  # offset to next IFD
-
-            # offset to image data
-            data_offset += page_data_size
-
-            # write tag values and patch offsets in ifdentries, if necessary
-            for tagindex, tag in enumerate(self._tags):
-                if tag[2]:
-                    pos = fh.tell()
-                    fh.seek(tag_offset + tagindex*tag_size + offset_size + 4)
-                    fh.write(pack(offset_format, pos))
-                    fh.seek(pos)
-                    if tag[0] == self._tag_offsets:
-                        strip_offsets_offset = pos
-                    fh.write(tag[2])
+            raise ValueError("data too large for non-BigTIFF file")
 
-            # update strip/tile offsets if necessary
+        for _ in range(pageno):
+            # update pointer at IFD offset
             pos = fh.tell()
-            for tagindex, tag in enumerate(self._tags):
-                if tag[0] == self._tag_offsets:  # strip/tile offsets
-                    if tag[2]:
-                        fh.seek(strip_offsets_offset)
-                        strip_offset = data_offset
-                        for size in self._data_byte_counts:
-                            fh.write(pack(offset_format, strip_offset))
-                            strip_offset += size
-                    else:
-                        fh.seek(tag_offset + tagindex*tag_size +
-                                offset_size + 4)
-                        fh.write(pack(offset_format, data_offset))
-                    break
+            fh.seek(self._ifdoffset)
+            fh.write(pack(offsetformat, pos))
             fh.seek(pos)
+            self._ifdoffset = pos + ifdoffset
+            # update strip/tile offsets in IFD
+            dataoffset += pagedatasize  # offset to image data
+            if stripoffset2offset is None:
+                ifd.seek(stripoffset2value)
+                ifd.write(pack(offsetformat, dataoffset))
+            else:
+                ifd.seek(stripoffset2offset)
+                ifd.write(pack(offsetformat, pos + stripoffset2value))
+                ifd.seek(stripoffset2value)
+                stripoffset = dataoffset
+                for size in self._databytecounts:
+                    ifd.write(pack(offsetformat, stripoffset))
+                    stripoffset += size
+            # write ifd entry
+            fh.write(ifd.getvalue())
 
         self._tags = None
-        self._data_dtype = None
-        self._data_offset = None
-        self._data_byte_counts = None
+        self._datadtype = None
+        self._dataoffset = None
+        self._databytecounts = None
         # do not reset _shape or _data_shape
 
     def _write_image_description(self):
-        """Write meta data to image_description tag."""
-        if (not self._data_shape or self._data_shape[0] == 1 or
-                self._description_offset <= 0):
+        """Write meta data to ImageDescription tag."""
+        if (not self._datashape or self._datashape[0] == 1 or
+                self._descriptionoffset <= 0):
             return
 
         colormapped = self._colormap is not None
         if self._imagej:
             isrgb = self._shape[-1] in (3, 4)
             description = imagej_description(
-                self._data_shape, isrgb, colormapped, **self._metadata)
+                self._datashape, isrgb, colormapped, **self._metadata)
         else:
-            description = image_description(
-                self._data_shape, colormapped, **self._metadata)
+            description = json_description(self._datashape, **self._metadata)
 
         # rewrite description and its length to file
-        description = description[:self._description_len-1]
+        description = description.encode('utf-8')
+        description = description[:self._descriptionlen-1]
         pos = self._fh.tell()
-        self._fh.seek(self._description_offset)
+        self._fh.seek(self._descriptionoffset)
         self._fh.write(description)
-        self._fh.seek(self._description_len_offset)
-        self._fh.write(struct.pack(self._byteorder+self._offset_format,
+        self._fh.seek(self._descriptionlenoffset)
+        self._fh.write(struct.pack(self._byteorder+self._offsetformat,
                                    len(description)+1))
         self._fh.seek(pos)
 
-        self._description_offset = 0
-        self._description_len_offset = 0
-        self._description_len = 0
+        self._descriptionoffset = 0
+        self._descriptionlenoffset = 0
+        self._descriptionlen = 0
 
     def _now(self):
         """Return current date and time."""
         return datetime.datetime.now()
 
-    def close(self, truncate=False):
-        """Write remaining pages (if not truncate) and close file handle."""
-        if not truncate:
+    def close(self):
+        """Write remaining pages and close file handle."""
+        if not self._truncate:
             self._write_remaining_pages()
         self._write_image_description()
         self._fh.close()
@@ -1178,98 +1514,44 @@ class TiffWriter(object):
         self.close()
 
 
-def imread(files, **kwargs):
-    """Return image data from TIFF file(s) as numpy array.
-
-    Refer to the TiffFile class and member functions for documentation.
-
-    Parameters
-    ----------
-    files : str, binary stream, or sequence
-        File name, seekable binary stream, glob pattern, or sequence of
-        file names.
-    kwargs : dict
-        Parameters 'multifile', 'multifile_close', 'pages', 'fastij', and
-        'is_ome' are passed to the TiffFile class.
-        The 'pattern' parameter is passed to the TiffSequence class.
-        Other parameters are passed to the asarray functions.
-        The first image series is returned if no arguments are provided.
-
-    Examples
-    --------
-    >>> imsave('temp.tif', numpy.random.rand(3, 4, 301, 219))
-    >>> im = imread('temp.tif', key=0)
-    >>> im.shape
-    (4, 301, 219)
-    >>> ims = imread(['temp.tif', 'temp.tif'])
-    >>> ims.shape
-    (2, 3, 4, 301, 219)
-
-    """
-    kwargs_file = parse_kwargs(kwargs, 'multifile', 'multifile_close',
-                               'pages', 'fastij', 'is_ome')
-    kwargs_seq = parse_kwargs(kwargs, 'pattern')
-
-    if isinstance(files, basestring) and any(i in files for i in '?*'):
-        files = glob.glob(files)
-    if not files:
-        raise ValueError('no files found')
-    if not hasattr(files, 'seek') and len(files) == 1:
-        files = files[0]
-
-    if isinstance(files, basestring) or hasattr(files, 'seek'):
-        with TiffFile(files, **kwargs_file) as tif:
-            return tif.asarray(**kwargs)
-    else:
-        with TiffSequence(files, **kwargs_seq) as imseq:
-            return imseq.asarray(**kwargs)
-
-
-class lazyattr(object):
-    """Lazy object attribute whose value is computed on first access."""
-    __slots__ = ('func',)
-
-    def __init__(self, func):
-        self.func = func
-
-    def __get__(self, instance, owner):
-        if instance is None:
-            return self
-        value = self.func(instance)
-        if value is NotImplemented:
-            return getattr(super(owner, instance), self.func.__name__)
-        setattr(instance, self.func.__name__, value)
-        return value
-
-
 class TiffFile(object):
-    """Read image and metadata from TIFF, STK, LSM, and FluoView files.
+    """Read image and metadata from TIFF file.
 
     TiffFile instances must be closed using the 'close' method, which is
     automatically called when using the 'with' context manager.
 
     Attributes
     ----------
-    pages : list of TiffPage
-        All TIFF pages in file.
+    pages : TiffPages
+        Sequence of TIFF pages in file.
     series : list of TiffPageSeries
-        TIFF pages with compatible shapes and types.
-    micromanager_metadata: dict
-        Extra MicroManager non-TIFF metadata in the file, if exists.
+        Sequences of closely related TIFF pages. These are computed
+        from OME, LSM, ImageJ, etc. metadata or based on similarity
+        of page properties such as shape, dtype, compression, etc.
+    byteorder : '>', '<'
+        The endianness of data in the file.
+        '>': big-endian (Motorola).
+        '>': little-endian (Intel).
+    is_flag : bool
+        If True, file is of a certain format.
+        Flags are: bigtiff, movie, shaped, ome, imagej, stk, lsm, fluoview,
+        nih, vista, 'micromanager, metaseries, mdgel, mediacy, tvips, fei,
+        sem, scn, svs, scanimage, andor, epics, pilatus.
 
     All attributes are read-only.
 
     Examples
     --------
+    >>> # read image array from TIFF file
+    >>> imsave('temp.tif', numpy.random.rand(5, 301, 219))
     >>> with TiffFile('temp.tif') as tif:
     ...     data = tif.asarray()
-    ...     data.shape
+    >>> data.shape
     (5, 301, 219)
 
     """
     def __init__(self, arg, name=None, offset=None, size=None,
-                 multifile=True, multifile_close=True, pages=None,
-                 fastij=True, is_ome=None):
+                 multifile=True, movie=None, **kwargs):
         """Initialize instance from file.
 
         Parameters
@@ -1280,45 +1562,90 @@ class TiffFile(object):
         name : str
             Optional name of file in case 'arg' is a file handle.
         offset : int
-            Optional start position of embedded file. By default this is
+            Optional start position of embedded file. By default, this is
             the current file position.
         size : int
-            Optional size of embedded file. By default this is the number
+            Optional size of embedded file. By default, this is the number
             of bytes from the 'offset' to the end of the file.
         multifile : bool
             If True (default), series may include pages from multiple files.
             Currently applies to OME-TIFF only.
-        multifile_close : bool
-            If True (default), keep the handles of other files in multifile
-            series closed. This is inefficient when few files refer to
-            many pages. If False, the C runtime may run out of resources.
-        pages : sequence of int
-            Indices of the pages to read. If None (default) all pages are read.
-            Can be used to read only the first page with pages=[0].
-            Specifying pages might invalidate series based on metadata.
-        fastij : bool
-            If True (default), try to use only the metadata from the first page
-            of ImageJ files. Significantly speeds up loading movies with
-            thousands of pages.
-        is_ome : bool
-            If False, disable processing of OME-XML metadata.
+        movie : bool
+            If True, assume that later pages differ from first page only by
+            data offsets and bytecounts. Significantly increases speed and
+            reduces memory usage when reading movies with thousands of pages.
+            Enabling this for non-movie files will result in data corruption
+            or crashes. Python 3 only.
+        kwargs : bool
+            'is_ome': If False, disable processing of OME-XML metadata.
 
         """
-        if is_ome is False:
-            self.is_ome = False
+        if 'fastij' in kwargs:
+            del kwargs['fastij']
+            raise DeprecationWarning("The fastij option will be removed.")
+        for key, value in kwargs.items():
+            if key[:3] == 'is_' and key[3:] in TIFF.FILE_FLAGS:
+                if value is not None and not value:
+                    setattr(self, key, bool(value))
+            else:
+                raise TypeError(
+                    "got an unexpected keyword argument '%s'" % key)
 
-        self._fh = FileHandle(arg, mode='rb',
-                              name=name, offset=offset, size=size)
-        self.offset_size = None
-        self.pages = []
+        fh = FileHandle(arg, mode='rb', name=name, offset=offset, size=size)
+        self._fh = fh
         self._multifile = bool(multifile)
-        self._multifile_close = bool(multifile_close)
-        self._files = {self._fh.name: self}  # cache of TiffFiles
-        self._ifd_offset = 0  # offset to offset of next IFD
+        self._files = {fh.name: self}  # cache of TiffFiles
         try:
-            self._fromfile(pages, fastij)
+            fh.seek(0)
+            try:
+                byteorder = {b'II': '<', b'MM': '>'}[fh.read(2)]
+            except KeyError:
+                raise ValueError("invalid TIFF file")
+            sys_byteorder = {'big': '>', 'little': '<'}[sys.byteorder]
+            self.is_native = byteorder == sys_byteorder
+
+            version = struct.unpack(byteorder+'H', fh.read(2))[0]
+            if version == 43:
+                # BigTiff
+                self.is_bigtiff = True
+                offsetsize, zero = struct.unpack(byteorder+'HH', fh.read(4))
+                if zero or offsetsize != 8:
+                    raise ValueError("invalid BigTIFF file")
+                self.byteorder = byteorder
+                self.offsetsize = 8
+                self.offsetformat = byteorder+'Q'
+                self.tagnosize = 8
+                self.tagnoformat = byteorder+'Q'
+                self.tagsize = 20
+                self.tagformat1 = byteorder+'HH'
+                self.tagformat2 = byteorder+'Q8s'
+            elif version == 42:
+                self.is_bigtiff = False
+                self.byteorder = byteorder
+                self.offsetsize = 4
+                self.offsetformat = byteorder+'I'
+                self.tagnosize = 2
+                self.tagnoformat = byteorder+'H'
+                self.tagsize = 12
+                self.tagformat1 = byteorder+'HH'
+                self.tagformat2 = byteorder+'I4s'
+            else:
+                raise ValueError("not a TIFF file")
+
+            # file handle is at offset to offset to first page
+            self.pages = TiffPages(self)
+
+            if self.is_lsm and (self.filehandle.size >= 2**32 or
+                                self.pages[0].compression != 1 or
+                                self.pages[1].compression != 1):
+                self._lsm_load_pages()
+                self._lsm_fix_strip_offsets()
+                self._lsm_fix_strip_bytecounts()
+            elif movie:
+                self.pages.useframes = True
+
         except Exception:
-            self._fh.close()
+            fh.close()
             raise
 
     @property
@@ -1331,112 +1658,24 @@ class TiffFile(object):
         """Return name of file handle."""
         return self._fh.name
 
+    @lazyattr
+    def fstat(self):
+        """Return status of file handle as stat_result object."""
+        try:
+            return os.fstat(self._fh.fileno())
+        except Exception:  # io.UnsupportedOperation
+            return None
+
     def close(self):
         """Close open file handle(s)."""
         for tif in self._files.values():
-            tif._fh.close()
+            tif.filehandle.close()
         self._files = {}
 
-    def _fromfile(self, pages=None, fastij=True):
-        """Read TIFF header and all page records from file."""
-        self._fh.seek(0)
-        try:
-            self.byteorder = {b'II': '<', b'MM': '>'}[self._fh.read(2)]
-        except KeyError:
-            raise ValueError("invalid TIFF file")
-        self._is_native = self.byteorder == {'big': '>',
-                                             'little': '<'}[sys.byteorder]
-        version = struct.unpack(self.byteorder+'H', self._fh.read(2))[0]
-        if version == 43:
-            # BigTiff
-            self.offset_size, zero = struct.unpack(self.byteorder+'HH',
-                                                   self._fh.read(4))
-            if zero or self.offset_size != 8:
-                raise ValueError("invalid BigTIFF file")
-        elif version == 42:
-            self.offset_size = 4
-        else:
-            raise ValueError("not a TIFF file")
-
-        self._ifd_offset = self._fh.tell()
-
-        self.pages = []
-        pageindex = -1
-        while True:
-            pageindex += 1
-            skip = pages and pageindex not in pages
-            try:
-                page = TiffPage(self, skip)
-            except StopIteration:
-                break
-            if skip:
-                continue
-            self.pages.append(page)
-            if fastij:
-                if page._patch_imagej():
-                    break  # only read the first page of ImageJ files
-                fastij = False
-
-        # TiffPage() leaves the file cursor at offset to offset of next IFD
-        self._ifd_offset = self._fh.tell()
-
-        if self.is_micromanager:
-            # MicroManager files contain metadata not stored in TIFF tags.
-            self.micromanager_metadata = read_micromanager_metadata(self._fh)
-
-        if self.is_lsm:
-            self._fix_lsm_strip_offsets()
-            self._fix_lsm_strip_byte_counts()
-
-    def _fix_lsm_strip_offsets(self):
-        """Unwrap strip offsets for LSM files greater than 4 GB."""
-        # each series and position require separate unwrapping (undocumented)
-        for series in self.series:
-            positions = 1
-            for i in 0, 1:
-                if series.axes[i] in 'PM':
-                    positions *= series.shape[i]
-            positions = len(series.pages) // positions
-            for i, page in enumerate(series.pages):
-                if not i % positions:
-                    wrap = 0
-                    previous_offset = 0
-                strip_offsets = []
-                for current_offset in page.strip_offsets:
-                    if current_offset < previous_offset:
-                        wrap += 2**32
-                    strip_offsets.append(current_offset + wrap)
-                    previous_offset = current_offset
-                page.strip_offsets = tuple(strip_offsets)
-
-    def _fix_lsm_strip_byte_counts(self):
-        """Set strip_byte_counts to size of compressed data.
-
-        The strip_byte_counts tag in LSM files contains the number of bytes
-        for the uncompressed data.
-
-        """
-        if not self.pages:
-            return
-        strips = {}
-        for page in self.pages:
-            assert len(page.strip_offsets) == len(page.strip_byte_counts)
-            for offset, bytecount in zip(page.strip_offsets,
-                                         page.strip_byte_counts):
-                strips[offset] = bytecount
-        offsets = sorted(strips.keys())
-        offsets.append(min(offsets[-1] + strips[offsets[-1]], self._fh.size))
-        for i, offset in enumerate(offsets[:-1]):
-            strips[offset] = min(strips[offset], offsets[i+1] - offset)
-        for page in self.pages:
-            if page.compression:
-                page.strip_byte_counts = tuple(
-                    strips[offset] for offset in page.strip_offsets)
-
-    def asarray(self, key=None, series=None, memmap=False, tempdir=None):
+    def asarray(self, key=None, series=None, out=None, maxworkers=1):
         """Return image data from multiple TIFF pages as numpy array.
 
-        By default the first image series is returned.
+        By default, the data from the first series is returned.
 
         Parameters
         ----------
@@ -1444,12 +1683,19 @@ class TiffFile(object):
             Defines which pages to return as array.
         series : int or TiffPageSeries
             Defines which series of pages to return as array.
-        memmap : bool
-            If True, return an read-only array stored in a binary file on disk
-            if possible. The TIFF file is used if possible, else a temporary
-            file is created.
-        tempdir : str
-            The directory where the memory-mapped file will be created.
+        out : numpy.ndarray, str, or file-like object; optional
+            Buffer where image data will be saved.
+            If numpy.ndarray, a writable array of compatible dtype and shape.
+            If str or open file, the file name or file object used to
+            create a memory-map to an array stored in a binary file on disk.
+        maxworkers : int
+            Maximum number of threads to concurrently get data from pages.
+            Default is 1. If None, up to half the CPU cores are used.
+            Reading data from file is limited to a single thread.
+            Using multiple threads can significantly speed up this function
+            if the bottleneck is decoding compressed data.
+            If the bottleneck is I/O or pure Python code, using multiple
+            threads might be detrimental.
 
         """
         if not self.pages:
@@ -1467,7 +1713,7 @@ class TiffFile(object):
 
         if key is None:
             pass
-        elif isinstance(key, int):
+        elif isinstance(key, inttypes):
             pages = [pages[key]]
         elif isinstance(key, slice):
             pages = pages[key]
@@ -1476,76 +1722,31 @@ class TiffFile(object):
         else:
             raise TypeError("key must be an int, slice, or sequence")
 
-        if not len(pages):
+        if not pages:
             raise ValueError("no pages selected")
 
         if self.is_nih:
-            if pages[0].is_indexed:
-                result = stack_pages(pages, colormapped=False, squeeze=False)
-                result = apply_colormap(result, pages[0].color_map)
-            else:
-                result = stack_pages(pages, memmap=memmap, tempdir=tempdir,
-                                     colormapped=False, squeeze=False)
-        elif len(pages) == 1:
-            result = pages[0].asarray(memmap=memmap)
-        elif self.is_ome:
-            assert not self.is_indexed, "color mapping disabled for ome-tiff"
-            if any(p is None for p in pages):
-                # zero out missing pages
-                firstpage = next(p for p in pages if p)
-                nopage = numpy.zeros_like(
-                    firstpage.asarray(memmap=False))
-            if memmap:
-                with tempfile.NamedTemporaryFile() as fh:
-                    result = numpy.memmap(fh, series.dtype, shape=series.shape)
-                    result = result.reshape(-1)
-            else:
-                result = numpy.empty(series.shape, series.dtype).reshape(-1)
-            index = 0
-
-            class KeepOpen:
-                # keep Tiff files open between consecutive pages
-                def __init__(self, parent, close):
-                    self.master = parent
-                    self.parent = parent
-                    self._close = close
-
-                def open(self, page):
-                    if self._close and page and page.parent != self.parent:
-                        if self.parent != self.master:
-                            self.parent.filehandle.close()
-                        self.parent = page.parent
-                        self.parent.filehandle.open()
-
-                def close(self):
-                    if self._close and self.parent != self.master:
-                        self.parent.filehandle.close()
-
-            keep = KeepOpen(self, self._multifile_close)
-            for page in pages:
-                keep.open(page)
-                if page:
-                    a = page.asarray(memmap=False, colormapped=False,
-                                     reopen=False)
-                else:
-                    a = nopage
-                try:
-                    result[index:index + a.size] = a.reshape(-1)
-                except ValueError as e:
-                    warnings.warn("ome-tiff: %s" % e)
-                    break
-                index += a.size
-            keep.close()
+            result = stack_pages(pages, out=out, maxworkers=maxworkers,
+                                 squeeze=False)
         elif key is None and series and series.offset:
-            if memmap:
+            if out == 'memmap' and pages[0].is_memmappable:
                 result = self.filehandle.memmap_array(
                     series.dtype, series.shape, series.offset)
             else:
+                if out is not None:
+                    out = create_output(out, series.shape, series.dtype)
                 self.filehandle.seek(series.offset)
-                result = self.filehandle.read_array(
-                    series.dtype, product(series.shape))
+                i = product(series.shape)
+                result = self.filehandle.read_array(series.dtype, i, out=out)
+                if not self.is_native:
+                    result.byteswap(True)
+        elif len(pages) == 1:
+            result = pages[0].asarray(out=out)
         else:
-            result = stack_pages(pages, memmap=memmap, tempdir=tempdir)
+            result = stack_pages(pages, out=out, maxworkers=maxworkers)
+
+        if result is None:
+            return
 
         if key is None:
             try:
@@ -1567,150 +1768,262 @@ class TiffFile(object):
 
     @lazyattr
     def series(self):
-        """Return pages with compatible properties as TiffPageSeries."""
+        """Return related pages as TiffPageSeries.
+
+        Side effect: after calling this function, TiffFile.pages might contain
+        TiffPage and TiffFrame instances.
+
+        """
         if not self.pages:
             return []
 
+        useframes = self.pages.useframes
+        keyframe = self.pages.keyframe
         series = []
-        if self.is_ome:
-            series = self._ome_series()
-        elif self.is_fluoview:
-            series = self._fluoview_series()
-        elif self.is_lsm:
-            series = self._lsm_series()
-        elif self.is_imagej:
-            series = self._imagej_series()
-        elif self.is_nih:
-            series = self._nih_series()
-
+        for name in 'ome imagej lsm fluoview nih mdgel shaped'.split():
+            if getattr(self, 'is_' + name, False):
+                series = getattr(self, '_%s_series' % name)()
+                break
         if not series:
-            # generic detection of series
-            shapes = []
-            pages = {}
-            index = 0
-            for page in self.pages:
-                if not page.shape:
-                    continue
-                if page.is_shaped:
-                    index += 1  # shape starts a new series
-                shape = page.shape + (index, page.axes,
-                                      page.compression in TIFF_DECOMPESSORS)
-                if shape in pages:
-                    pages[shape].append(page)
-                else:
-                    shapes.append(shape)
-                    pages[shape] = [page]
-            series = []
-            for s in shapes:
-                shape = ((len(pages[s]),) + s[:-3] if len(pages[s]) > 1
-                         else s[:-3])
-                axes = (('I' + s[-2]) if len(pages[s]) > 1 else s[-2])
-                page0 = pages[s][0]
-                if page0.is_shaped:
-                    metadata = image_description_dict(page0.is_shaped)
-                    reshape = metadata['shape']
-                    if 'axes' in metadata:
-                        reaxes = metadata['axes']
-                        if len(reaxes) == len(reshape):
-                            axes = reaxes
-                            shape = reshape
-                        else:
-                            warnings.warn("axes do not match shape")
-                    try:
-                        axes = reshape_axes(axes, shape, reshape)
-                        shape = reshape
-                    except ValueError as e:
-                        warnings.warn(str(e))
-                series.append(
-                    TiffPageSeries(pages[s], shape, page0.dtype, axes))
-
-        for i, s in enumerate(series):
-            s.index = i
+            self.pages.useframes = useframes
+            self.pages.keyframe = keyframe
+            series = self._generic_series()
 
         # remove empty series, e.g. in MD Gel files
         series = [s for s in series if sum(s.shape) > 0]
+
+        for i, s in enumerate(series):
+            s.index = i
         return series
 
-    def _fluoview_series(self):
-        """Return image series in FluoView file."""
-        page0 = self.pages[0]
-        dims = {
-            b'X': 'X', b'Y': 'Y', b'Z': 'Z', b'T': 'T',
-            b'WAVELENGTH': 'C', b'TIME': 'T', b'XY': 'R',
-            b'EVENT': 'V', b'EXPOSURE': 'L'}
-        mmhd = list(reversed(page0.mm_header.dimensions))
-        axes = ''.join(dims.get(i[0].strip().upper(), 'Q')
-                       for i in mmhd if i[1] > 1)
-        shape = tuple(int(i[1]) for i in mmhd if i[1] > 1)
-        return [TiffPageSeries(self.pages, shape, page0.dtype, axes)]
+    def _generic_series(self):
+        """Return image series in file."""
+        if self.pages.useframes:
+            # movie mode
+            page = self.pages[0]
+            shape = page.shape
+            axes = page.axes
+            if len(self.pages) > 1:
+                shape = (len(self.pages),) + shape
+                axes = 'I' + axes
+            return [TiffPageSeries(self.pages[:], shape, page.dtype, axes,
+                                   stype='movie')]
+
+        self.pages.clear(False)
+        self.pages.load()
+        result = []
+        keys = []
+        series = {}
+        compressions = TIFF.DECOMPESSORS
+        for page in self.pages:
+            if not page.shape:
+                continue
+            key = page.shape + (page.axes, page.compression in compressions)
+            if key in series:
+                series[key].append(page)
+            else:
+                keys.append(key)
+                series[key] = [page]
+        for key in keys:
+            pages = series[key]
+            page = pages[0]
+            shape = page.shape
+            axes = page.axes
+            if len(pages) > 1:
+                shape = (len(pages),) + shape
+                axes = 'I' + axes
+            result.append(TiffPageSeries(pages, shape, page.dtype, axes,
+                                         stype='Generic'))
+
+        return result
+
+    def _shaped_series(self):
+        """Return image series in "shaped" file."""
+        pages = self.pages
+        pages.useframes = True
+        lenpages = len(pages)
+
+        def append_series(series, pages, axes, shape, reshape, name):
+            page = pages[0]
+            if not axes:
+                shape = page.shape
+                axes = page.axes
+                if len(pages) > 1:
+                    shape = (len(pages),) + shape
+                    axes = 'Q' + axes
+            size = product(shape)
+            resize = product(reshape)
+            if page.is_contiguous and resize > size and resize % size == 0:
+                # truncated file
+                axes = 'Q' + axes
+                shape = (resize // size,) + shape
+            try:
+                axes = reshape_axes(axes, shape, reshape)
+                shape = reshape
+            except ValueError as e:
+                warnings.warn(str(e))
+            series.append(TiffPageSeries(pages, shape, page.dtype, axes,
+                                         name=name, stype='Shaped'))
+
+        keyframe = axes = shape = reshape = name = None
+        series = []
+        index = 0
+        while True:
+            if index >= lenpages:
+                break
+            # new keyframe; start of new series
+            pages.keyframe = index
+            keyframe = pages[index]
+            if not keyframe.is_shaped:
+                warnings.warn("invalid shape metadata or corrupted file")
+                return
+            # read metadata
+            axes = None
+            shape = None
+            metadata = json_description_metadata(keyframe.is_shaped)
+            name = metadata.get('name', '')
+            reshape = metadata['shape']
+            truncated = metadata.get('truncated', False)
+            if 'axes' in metadata:
+                axes = metadata['axes']
+                if len(axes) == len(reshape):
+                    shape = reshape
+                else:
+                    axes = ''
+                    warnings.warn("axes do not match shape")
+            # skip pages if possible
+            spages = [keyframe]
+            size = product(reshape)
+            npages, mod = divmod(size, product(keyframe.shape))
+            if mod:
+                warnings.warn("series shape not matching page shape")
+                return
+            if 1 < npages <= lenpages - index:
+                size *= keyframe._dtype.itemsize
+                if truncated:
+                    npages = 1
+                elif not (keyframe.is_final and
+                          keyframe.offset + size < pages[index+1].offset):
+                    # need to read all pages for series
+                    for j in range(index+1, index+npages):
+                        page = pages[j]
+                        page.keyframe = keyframe
+                        spages.append(page)
+            append_series(series, spages, axes, shape, reshape, name)
+            index += npages
 
-    def _lsm_series(self):
-        """Return image series in LSM file."""
-        page0 = self.pages[0]
-        lsmi = page0.cz_lsm_info
-        axes = CZ_SCAN_TYPES[lsmi.scan_type]
-        if page0.is_rgb:
-            axes = axes.replace('C', '').replace('XY', 'XYC')
-        if hasattr(lsmi, 'dimension_p') and lsmi.dimension_p > 1:
-            axes += 'P'
-        if hasattr(lsmi, 'dimension_m') and lsmi.dimension_m > 1:
-            axes += 'M'
-        axes = axes[::-1]
-        shape = tuple(getattr(lsmi, CZ_DIMENSIONS[i]) for i in axes)
-        pages = [p for p in self.pages if not p.is_reduced]
-        dtype = pages[0].dtype
-        series = [TiffPageSeries(pages, shape, dtype, axes)]
-        if len(pages) != len(self.pages):  # reduced RGB pages
-            pages = [p for p in self.pages if p.is_reduced]
-            cp = 1
-            i = 0
-            while cp < len(pages) and i < len(shape)-2:
-                cp *= shape[i]
-                i += 1
-            shape = shape[:i] + pages[0].shape
-            axes = axes[:i] + 'CYX'
-            dtype = pages[0].dtype
-            series.append(TiffPageSeries(pages, shape, dtype, axes))
         return series
 
     def _imagej_series(self):
         """Return image series in ImageJ file."""
         # ImageJ's dimension order is always TZCYXS
         # TODO: fix loading of color, composite or palette images
+        self.pages.useframes = True
+        self.pages.keyframe = 0
+
+        ij = self.imagej_metadata
+        pages = self.pages
+        page = pages[0]
+
+        def is_hyperstack():
+            # ImageJ hyperstack store all image metadata in the first page and
+            # image data is stored contiguously before the second page, if any.
+            if not page.is_final:
+                return False
+            images = ij.get('images', 0)
+            if images <= 1:
+                return False
+            offset, count = page.is_contiguous
+            if (count != product(page.shape) * page.bitspersample // 8
+                    or offset + count*images > self.filehandle.size):
+                raise ValueError()
+            # check that next page is stored after data
+            if len(pages) > 1 and offset + count*images > pages[1].offset:
+                return False
+            return True
+
+        try:
+            hyperstack = is_hyperstack()
+        except ValueError:
+            warnings.warn("invalid ImageJ metadata or corrupted file")
+            return
+        if hyperstack:
+            # no need to read other pages
+            pages = [page]
+        else:
+            self.pages.load()
+
         shape = []
         axes = []
-        page0 = self.pages[0]
-        ij = page0.imagej_tags
         if 'frames' in ij:
             shape.append(ij['frames'])
             axes.append('T')
         if 'slices' in ij:
             shape.append(ij['slices'])
             axes.append('Z')
-        if 'channels' in ij and not (self.is_rgb and not
+        if 'channels' in ij and not (page.photometric == 2 and not
                                      ij.get('hyperstack', False)):
             shape.append(ij['channels'])
             axes.append('C')
-        remain = ij.get('images', len(self.pages)) // (product(shape)
-                                                       if shape else 1)
+        remain = ij.get('images', len(pages))//(product(shape) if shape else 1)
         if remain > 1:
             shape.append(remain)
             axes.append('I')
-        if page0.axes[0] == 'I':
+        if page.axes[0] == 'I':
             # contiguous multiple images
-            shape.extend(page0.shape[1:])
-            axes.extend(page0.axes[1:])
-        elif page0.axes[:2] == 'SI':
+            shape.extend(page.shape[1:])
+            axes.extend(page.axes[1:])
+        elif page.axes[:2] == 'SI':
             # color-mapped contiguous multiple images
-            shape = page0.shape[0:1] + tuple(shape) + page0.shape[2:]
-            axes = list(page0.axes[0]) + axes + list(page0.axes[2:])
+            shape = page.shape[0:1] + tuple(shape) + page.shape[2:]
+            axes = list(page.axes[0]) + axes + list(page.axes[2:])
+        else:
+            shape.extend(page.shape)
+            axes.extend(page.axes)
+        return [TiffPageSeries(pages, shape, page.dtype, axes, stype='ImageJ')]
+
+    def _fluoview_series(self):
+        """Return image series in FluoView file."""
+        self.pages.useframes = True
+        self.pages.keyframe = 0
+        self.pages.load()
+        mm = self.fluoview_metadata
+        mmhd = list(reversed(mm['Dimensions']))
+        axes = ''.join(TIFF.MM_DIMENSIONS.get(i[0].upper(), 'Q')
+                       for i in mmhd if i[1] > 1)
+        shape = tuple(int(i[1]) for i in mmhd if i[1] > 1)
+        return [TiffPageSeries(self.pages, shape, self.pages[0].dtype, axes,
+                               name=mm['ImageName'], stype='FluoView')]
+
+    def _mdgel_series(self):
+        """Return image series in MD Gel file."""
+        # only a single page, scaled according to metadata in second page
+        self.pages.useframes = False
+        self.pages.keyframe = 0
+        self.pages.load()
+        md = self.mdgel_metadata
+        if md['FileTag'] in (2, 128):
+            dtype = numpy.dtype('float32')
+            scale = md['ScalePixel']
+            scale = scale[0] / scale[1]  # rational
+            if md['FileTag'] == 2:
+                # squary root data format
+                def transform(a):
+                    return a.astype('float32')**2 * scale
+            else:
+                def transform(a):
+                    return a.astype('float32') * scale
         else:
-            shape.extend(page0.shape)
-            axes.extend(page0.axes)
-        return [TiffPageSeries(self.pages, shape, page0.dtype, axes)]
+            transform = None
+        page = self.pages[0]
+        return [TiffPageSeries([page], page.shape, dtype, page.axes,
+                               transform=transform, stype='MDGel')]
 
     def _nih_series(self):
         """Return image series in NIH file."""
+        self.pages.useframes = True
+        self.pages.keyframe = 0
+        self.pages.load()
         page0 = self.pages[0]
         if len(self.pages) == 1:
             shape = page0.shape
@@ -1718,18 +2031,29 @@ class TiffFile(object):
         else:
             shape = (len(self.pages),) + page0.shape
             axes = 'I' + page0.axes
-        return [TiffPageSeries(self.pages, shape, page0.dtype, axes)]
+        return [
+            TiffPageSeries(self.pages, shape, page0.dtype, axes, stype='NIH')]
 
     def _ome_series(self):
         """Return image series in OME-TIFF file(s)."""
-        omexml = self.pages[0].tags['image_description'].value
+        from xml.etree import cElementTree as etree  # delayed import
+        omexml = self.pages[0].description
         try:
             root = etree.fromstring(omexml)
         except etree.ParseError as e:
-            # TODO: test this
+            # TODO: test badly encoded ome-xml
             warnings.warn("ome-xml: %s" % e)
-            omexml = omexml.decode('utf-8', 'ignore').encode('utf-8')
-            root = etree.fromstring(omexml)
+            try:
+                # might work on Python 2
+                omexml = omexml.decode('utf-8', 'ignore').encode('utf-8')
+                root = etree.fromstring(omexml)
+            except Exception:
+                return
+
+        self.pages.useframes = True
+        self.pages.keyframe = 0
+        self.pages.load()
+
         uuid = root.attrib.get('UUID', None)
         self._files = {uuid: self}
         dirname = self._fh.dirname
@@ -1751,7 +2075,7 @@ class TiffFile(object):
                                     continue
                                 axis = along.tag[-1]
                                 newaxis = along.attrib.get('Type', 'other')
-                                newaxis = AXES_LABELS[newaxis]
+                                newaxis = TIFF.AXES_LABELS[newaxis]
                                 if 'Start' in along.attrib:
                                     step = float(along.attrib.get('Step', 1))
                                     start = float(along.attrib['Start'])
@@ -1761,25 +2085,42 @@ class TiffFile(object):
                                     labels = [label.text for label in along
                                               if label.tag.endswith('Label')]
                                 modulo[axis] = (newaxis, labels)
+
             if not element.tag.endswith('Image'):
                 continue
+
+            attr = element.attrib
+            name = attr.get('Name', None)
+
             for pixels in element:
                 if not pixels.tag.endswith('Pixels'):
                     continue
-                atr = pixels.attrib
-                dtype = atr.get('Type', None)
-                axes = ''.join(reversed(atr['DimensionOrder']))
-                shape = list(int(atr['Size'+ax]) for ax in axes)
+                attr = pixels.attrib
+                dtype = attr.get('PixelType', None)
+                axes = ''.join(reversed(attr['DimensionOrder']))
+                shape = list(int(attr['Size'+ax]) for ax in axes)
                 size = product(shape[:-2])
-                ifds = [None] * (size // self.pages[0].samples_per_pixel)
+                ifds = None
+                spp = 1  # samples per pixel
                 for data in pixels:
+                    if data.tag.endswith('Channel'):
+                        attr = data.attrib
+                        if ifds is None:
+                            spp = int(attr.get('SamplesPerPixel', spp))
+                            ifds = [None] * (size // spp)
+                        elif int(attr.get('SamplesPerPixel', 1)) != spp:
+                            raise ValueError(
+                                "Can't handle differing SamplesPerPixel")
+                        continue
+                    if ifds is None:
+                        ifds = [None] * (size // spp)
                     if not data.tag.endswith('TiffData'):
                         continue
-                    atr = data.attrib
-                    ifd = int(atr.get('IFD', 0))
-                    num = int(atr.get('NumPlanes', 1 if 'IFD' in atr else 0))
-                    num = int(atr.get('PlaneCount', num))
-                    idx = [int(atr.get('First'+ax, 0)) for ax in axes[:-2]]
+                    attr = data.attrib
+                    ifd = int(attr.get('IFD', 0))
+                    num = int(attr.get('NumPlanes', 1 if 'IFD' in attr else 0))
+                    num = int(attr.get('PlaneCount', num))
+                    idx = [int(attr.get('First'+ax, 0)) for ax in axes[:-2]]
                     try:
                         idx = numpy.ravel_multi_index(idx, shape[:-2])
                     except ValueError:
@@ -1797,14 +2138,15 @@ class TiffFile(object):
                             fname = uuid.attrib['FileName']
                             try:
                                 tif = TiffFile(os.path.join(dirname, fname))
+                                tif.pages.useframes = True
+                                tif.pages.keyframe = 0
+                                tif.pages.load()
                             except (IOError, FileNotFoundError, ValueError):
-                                # TODO: close open file handle
                                 warnings.warn(
                                     "ome-xml: failed to read '%s'" % fname)
                                 break
                             self._files[uuid.text] = tif
-                            if self._multifile_close:
-                                tif.close()
+                            tif.close()
                         pages = self._files[uuid.text].pages
                         try:
                             for i in range(num if num else len(pages)):
@@ -1820,11 +2162,34 @@ class TiffFile(object):
                                 ifds[idx + i] = pages[ifd + i]
                         except IndexError:
                             warnings.warn("ome-xml: index out of range")
+
                 if all(i is None for i in ifds):
                     # skip images without data
                     continue
-                dtype = next(i for i in ifds if i).dtype
-                series.append(TiffPageSeries(ifds, shape, dtype, axes, self))
+
+                # set a keyframe on all ifds
+                keyframe = None
+                for i in ifds:
+                    # try find a TiffPage
+                    if i and i == i.keyframe:
+                        keyframe = i
+                        break
+                if not keyframe:
+                    # reload a TiffPage from file
+                    for i, keyframe in enumerate(ifds):
+                        if keyframe:
+                            keyframe.parent.pages.keyframe = keyframe.index
+                            keyframe = keyframe.parent.pages[keyframe.index]
+                            ifds[i] = keyframe
+                            break
+                for i in ifds:
+                    if i is not None:
+                        i.keyframe = keyframe
+
+                dtype = keyframe.dtype
+                series.append(
+                    TiffPageSeries(ifds, shape, dtype, axes, parent=self,
+                                   name=name, stype='OME'))
         for serie in series:
             shape = list(serie.shape)
             for axis, (newaxis, labels) in modulo.items():
@@ -1842,17 +2207,145 @@ class TiffFile(object):
             serie.shape, serie.axes = squeeze_axes(serie.shape, serie.axes)
         return series
 
-    def __len__(self):
-        """Return number of image pages in file."""
-        return len(self.pages)
+    def _lsm_series(self):
+        """Return main image series in LSM file. Skip thumbnails."""
+        lsmi = self.lsm_metadata
+        axes = TIFF.CZ_LSMINFO_SCANTYPE[lsmi['ScanType']]
+        if self.pages[0].photometric == 2:  # RGB; more than one channel
+            axes = axes.replace('C', '').replace('XY', 'XYC')
+        if lsmi.get('DimensionP', 0) > 1:
+            axes += 'P'
+        if lsmi.get('DimensionM', 0) > 1:
+            axes += 'M'
+        axes = axes[::-1]
+        shape = tuple(int(lsmi[TIFF.CZ_LSMINFO_DIMENSIONS[i]]) for i in axes)
+        name = lsmi.get('Name', '')
+        self.pages.keyframe = 0
+        pages = self.pages[::2]
+        dtype = pages[0].dtype
+        series = [TiffPageSeries(pages, shape, dtype, axes, name=name,
+                                 stype='LSM')]
 
-    def __getitem__(self, key):
-        """Return specified page."""
-        return self.pages[key]
+        if self.pages[1].is_reduced:
+            self.pages.keyframe = 1
+            pages = self.pages[1::2]
+            dtype = pages[0].dtype
+            cp, i = 1, 0
+            while cp < len(pages) and i < len(shape)-2:
+                cp *= shape[i]
+                i += 1
+            shape = shape[:i] + pages[0].shape
+            axes = axes[:i] + 'CYX'
+            series.append(TiffPageSeries(pages, shape, dtype, axes, name=name,
+                                         stype='LSMreduced'))
 
-    def __iter__(self):
-        """Return iterator over pages."""
-        return iter(self.pages)
+        return series
+
+    def _lsm_load_pages(self):
+        """Load all pages from LSM file."""
+        self.pages.cache = True
+        self.pages.useframes = True
+        # second series: thumbnails
+        self.pages.keyframe = 1
+        keyframe = self.pages[1]
+        for page in self.pages[1::2]:
+            page.keyframe = keyframe
+        # first series: data
+        self.pages.keyframe = 0
+        keyframe = self.pages[0]
+        for page in self.pages[::2]:
+            page.keyframe = keyframe
+
+    def _lsm_fix_strip_offsets(self):
+        """Unwrap strip offsets for LSM files greater than 4 GB.
+
+        Each series and position require separate unwrapping (undocumented).
+
+        """
+        if self.filehandle.size < 2**32:
+            return
+
+        pages = self.pages
+        npages = len(pages)
+        series = self.series[0]
+        axes = series.axes
+
+        # find positions
+        positions = 1
+        for i in 0, 1:
+            if series.axes[i] in 'PM':
+                positions *= series.shape[i]
+
+        # make time axis first
+        if positions > 1:
+            ntimes = 0
+            for i in 1, 2:
+                if axes[i] == 'T':
+                    ntimes = series.shape[i]
+                    break
+            if ntimes:
+                div, mod = divmod(npages, 2*positions*ntimes)
+                assert mod == 0
+                shape = (positions, ntimes, div, 2)
+                indices = numpy.arange(product(shape)).reshape(shape)
+                indices = numpy.moveaxis(indices, 1, 0)
+        else:
+            indices = numpy.arange(npages).reshape(-1, 2)
+
+        # images of reduced page might be stored first
+        if pages[0].dataoffsets[0] > pages[1].dataoffsets[0]:
+            indices = indices[..., ::-1]
+
+        # unwrap offsets
+        wrap = 0
+        previousoffset = 0
+        for i in indices.flat:
+            page = pages[i]
+            dataoffsets = []
+            for currentoffset in page.dataoffsets:
+                if currentoffset < previousoffset:
+                    wrap += 2**32
+                dataoffsets.append(currentoffset + wrap)
+                previousoffset = currentoffset
+            page.dataoffsets = tuple(dataoffsets)
+
+    def _lsm_fix_strip_bytecounts(self):
+        """Set databytecounts to size of compressed data.
+
+        The StripByteCounts tag in LSM files contains the number of bytes
+        for the uncompressed data.
+
+        """
+        pages = self.pages
+        if pages[0].compression == 1:
+            return
+        # sort pages by first strip offset
+        pages = sorted(pages, key=lambda p: p.dataoffsets[0])
+        npages = len(pages) - 1
+        for i, page in enumerate(pages):
+            if page.index % 2:
+                continue
+            offsets = page.dataoffsets
+            bytecounts = page.databytecounts
+            if i < npages:
+                lastoffset = pages[i+1].dataoffsets[0]
+            else:
+                # LZW compressed strips might be longer than uncompressed
+                lastoffset = min(offsets[-1] + 2*bytecounts[-1], self._fh.size)
+            offsets = offsets + (lastoffset,)
+            page.databytecounts = tuple(offsets[j+1] - offsets[j]
+                                        for j in range(len(bytecounts)))
+
+    def __getattr__(self, name):
+        """Return 'is_flag' attributes from first page."""
+        if name[3:] in TIFF.FILE_FLAGS:
+            if not self.pages:
+                return False
+            value = bool(getattr(self.pages[0], name))
+            setattr(self, name, value)
+            return value
+        raise AttributeError("'%s' object has no attribute '%s'" %
+                             (self.__class__.__name__, name))
 
     def __enter__(self):
         return self
@@ -1860,171 +2353,520 @@ class TiffFile(object):
     def __exit__(self, exc_type, exc_value, traceback):
         self.close()
 
-    def __str__(self):
-        """Return string containing information about file."""
-        result = [
-            "TIFF file: %s" % self._fh.name,
+    def __str__(self, detail=0):
+        """Return string containing information about file.
+
+        The detail parameter specifies the level of detail returned:
+
+        0: file only.
+        1: all series, first page of series and its tags.
+        2: large tag values and file metadata.
+        3: all pages.
+
+        """
+        info = [
+            "TiffFile '%s'" % snipstr(self._fh.name, 32),
             format_size(self._fh.size),
-            {'<': 'little endian', '>': 'big endian'}[self.byteorder]]
+            {'<': 'LittleEndian', '>': 'BigEndian'}[self.byteorder]]
         if self.is_bigtiff:
-            result.append("bigtiff")
-        attrs = ['mdgel', 'mediacy', 'stk', 'lsm', 'vista', 'imagej',
-                 'fluoview', 'micromanager', 'nih', 'ome', 'scn', 'tvips',
-                 'fei', 'sem']
-        result.extend((attr for attr in attrs if getattr(self, 'is_' + attr)))
+            info.append('BigTiff')
+        info.append('|'.join(f.upper() for f in self.flags))
         if len(self.pages) > 1:
-            result.append("%i pages" % len(self.pages))
+            info.append('%i Pages' % len(self.pages))
         if len(self.series) > 1:
-            result.append("%i series" % len(self.series))
+            info.append('%i Series' % len(self.series))
         if len(self._files) > 1:
-            result.append("%i files" % (len(self._files)))
-        return ", ".join(result)
-
-    def info(self, series=None, pages=None):
-        """Return string with detailed information about file."""
-        if series is None:
-            series = self.series
+            info.append('%i Files' % (len(self._files)))
+        info = '  '.join(info)
+        if detail <= 0:
+            return info
+        info = [info]
+        info.append('\n'.join(str(s) for s in self.series))
+        if detail >= 3:
+            info.extend((TiffPage.__str__(p, detail=detail)
+                         for p in self.pages
+                         if p is not None))
         else:
-            series = [self.series[i] for i in sequence(series)]
+            info.extend((TiffPage.__str__(s.pages[0], detail=detail)
+                         for s in self.series
+                         if s.pages[0] is not None))
+        if detail >= 2:
+            for name in sorted(self.flags):
+                if hasattr(self, name + '_metadata'):
+                    m = getattr(self, name + '_metadata')
+                    if m:
+                        info.append(
+                            "%s_METADATA\n%s" % (name.upper(), pformat(m)))
+        return '\n\n'.join(info).replace('\n\n\n', '\n\n')
 
-        result = [str(self)]
-        for s in series:
-            result.append(str(s))
-            if pages is None:
-                result.append(s.pages[0].info())
-
-        if pages is not None:
-            if pages == 'all':
-                pages = self.pages
-            else:
-                pages = [self.pages[i] for i in sequence(pages)]
-            for p in pages:
-                result.append(p.info())
-
-        return '\n\n'.join(result)
+    @lazyattr
+    def flags(self):
+        """Return set of file flags."""
+        return set(name.lower() for name in sorted(TIFF.FILE_FLAGS)
+                   if getattr(self, 'is_' + name))
 
     @lazyattr
-    def fstat(self):
+    def is_mdgel(self):
+        """File has MD Gel format."""
         try:
-            return os.fstat(self._fh.fileno())
-        except Exception:  # io.UnsupportedOperation
-            return None
+            return self.pages[0].is_mdgel or self.pages[1].is_mdgel
+        except IndexError:
+            return False
+
+    @property
+    def is_movie(self):
+        """Return if file is a movie."""
+        return self.pages.useframes
 
     @lazyattr
-    def is_bigtiff(self):
-        """File has BigTIFF format."""
-        return self.offset_size != 4
+    def shaped_metadata(self):
+        """Return Tifffile metadata from JSON descriptions as dicts."""
+        if not self.is_shaped:
+            return
+        return tuple(json_description_metadata(s.pages[0].is_shaped)
+                     for s in self.series if s.stype.lower() == 'shaped')
 
     @lazyattr
-    def is_rgb(self):
-        """File contains only RGB images."""
-        return self.pages and all(p.is_rgb for p in self.pages)
+    def ome_metadata(self):
+        """Return OME XML as dict."""
+        if not self.is_ome:
+            return
+        return xml2dict(self.pages[0].description)
 
     @lazyattr
-    def is_indexed(self):
-        """File contains only indexed images."""
-        return self.pages and all(p.is_indexed for p in self.pages)
+    def lsm_metadata(self):
+        """Return LSM metadata from CZ_LSMINFO tag as dict."""
+        if not self.is_lsm:
+            return
+        return self.pages[0].tags['CZ_LSMINFO'].value
 
     @lazyattr
-    def is_mdgel(self):
-        """File has MD Gel format."""
-        return any(p.is_mdgel for p in self.pages)
+    def stk_metadata(self):
+        """Return STK metadata from UIC tags as dict."""
+        if not self.is_stk:
+            return
+        page = self.pages[0]
+        tags = page.tags
+        result = {}
+        result['NumberPlanes'] = tags['UIC2tag'].count
+        if page.description:
+            result['PlaneDescriptions'] = page.description.split('\0')
+            # result['plane_descriptions'] = stk_description_metadata(
+            #    page.image_description)
+        if 'UIC1tag' in tags:
+            result.update(tags['UIC1tag'].value)
+        if 'UIC3tag' in tags:
+            result.update(tags['UIC3tag'].value)  # wavelengths
+        if 'UIC4tag' in tags:
+            result.update(tags['UIC4tag'].value)  # override uic1 tags
+        uic2tag = tags['UIC2tag'].value
+        result['ZDistance'] = uic2tag['ZDistance']
+        result['TimeCreated'] = uic2tag['TimeCreated']
+        result['TimeModified'] = uic2tag['TimeModified']
+        try:
+            result['DatetimeCreated'] = numpy.array(
+                [julian_datetime(*dt) for dt in
+                 zip(uic2tag['DateCreated'], uic2tag['TimeCreated'])],
+                dtype='datetime64[ns]')
+            result['DatetimeModified'] = numpy.array(
+                [julian_datetime(*dt) for dt in
+                 zip(uic2tag['DateModified'], uic2tag['TimeModified'])],
+                dtype='datetime64[ns]')
+        except ValueError as e:
+            warnings.warn("stk_metadata: %s" % e)
+        return result
 
     @lazyattr
-    def is_mediacy(self):
-        """File was created by Media Cybernetics software."""
-        return any(p.is_mediacy for p in self.pages)
+    def imagej_metadata(self):
+        """Return consolidated ImageJ metadata as dict."""
+        if not self.is_imagej:
+            return
+        page = self.pages[0]
+        result = imagej_description_metadata(page.is_imagej)
+        if 'IJMetadata' in page.tags:
+            try:
+                result.update(page.tags['IJMetadata'].value)
+            except Exception:
+                pass
+        return result
 
     @lazyattr
-    def is_stk(self):
-        """File has MetaMorph STK format."""
-        return self.pages and all(p.is_stk for p in self.pages)
+    def fluoview_metadata(self):
+        """Return consolidated FluoView metadata as dict."""
+        if not self.is_fluoview:
+            return
+        result = {}
+        page = self.pages[0]
+        result.update(page.tags['MM_Header'].value)
+        # TODO: read stamps from all pages
+        result['Stamp'] = page.tags['MM_Stamp'].value
+        # skip parsing image description; not reliable
+        # try:
+        #     t = fluoview_description_metadata(page.image_description)
+        #     if t is not None:
+        #         result['ImageDescription'] = t
+        # except Exception as e:
+        #     warnings.warn(
+        #         "failed to read FluoView image description: %s" % e)
+        return result
 
     @lazyattr
-    def is_lsm(self):
-        """File was created by Carl Zeiss software."""
-        return len(self.pages) and self.pages[0].is_lsm
+    def nih_metadata(self):
+        """Return NIH Image metadata from NIHImageHeader tag as dict."""
+        if not self.is_nih:
+            return
+        return self.pages[0].tags['NIHImageHeader'].value
 
     @lazyattr
-    def is_vista(self):
-        """File was created by ISS Vista."""
-        return len(self.pages) and self.pages[0].is_vista
+    def fei_metadata(self):
+        """Return FEI metadata from SFEG or HELIOS tags as dict."""
+        if not self.is_fei:
+            return
+        tags = self.pages[0].tags
+        if 'FEI_SFEG' in tags:
+            return tags['FEI_SFEG'].value
+        if 'FEI_HELIOS' in tags:
+            return tags['FEI_HELIOS'].value
 
     @lazyattr
-    def is_imagej(self):
-        """File has ImageJ format."""
-        return len(self.pages) and self.pages[0].is_imagej
+    def sem_metadata(self):
+        """Return SEM metadata from CZ_SEM tag as dict."""
+        if not self.is_sem:
+            return
+        return self.pages[0].tags['CZ_SEM'].value
 
     @lazyattr
-    def is_micromanager(self):
-        """File was created by MicroManager."""
-        return len(self.pages) and self.pages[0].is_micromanager
+    def mdgel_metadata(self):
+        """Return consolidated metadata from MD GEL tags as dict."""
+        for page in self.pages[:2]:
+            if 'MDFileTag' in page.tags:
+                tags = page.tags
+                break
+        else:
+            return
+        result = {}
+        for code in range(33445, 33453):
+            name = TIFF.TAGS[code]
+            if name not in tags:
+                continue
+            result[name[2:]] = tags[name].value
+        return result
 
     @lazyattr
-    def is_nih(self):
-        """File has NIH Image format."""
-        return len(self.pages) and self.pages[0].is_nih
+    def andor_metadata(self):
+        """Return Andor tags as dict."""
+        return self.pages[0].andor_tags
 
     @lazyattr
-    def is_fluoview(self):
-        """File was created by Olympus FluoView."""
-        return len(self.pages) and self.pages[0].is_fluoview
+    def epics_metadata(self):
+        """Return EPICS areaDetector tags as dict."""
+        return self.pages[0].epics_tags
 
     @lazyattr
-    def is_ome(self):
-        """File has OME-TIFF format."""
-        return len(self.pages) and self.pages[0].is_ome
+    def tvips_metadata(self):
+        """Return TVIPS tag as dict."""
+        if not self.is_tvips:
+            return
+        return self.pages[0].tags['TVIPS'].value
 
     @lazyattr
-    def is_scn(self):
-        """File has Leica SCN format."""
-        return len(self.pages) and self.pages[0].is_scn
+    def metaseries_metadata(self):
+        """Return MetaSeries metadata from image description as dict."""
+        if not self.is_metaseries:
+            return
+        return metaseries_description_metadata(self.pages[0].description)
 
     @lazyattr
-    def is_tvips(self):
-        """File was created using EM-MENU software."""
-        return len(self.pages) and self.pages[0].is_tvips
+    def pilatus_metadata(self):
+        """Return Pilatus metadata from image description as dict."""
+        if not self.is_pilatus:
+            return
+        return pilatus_description_metadata(self.pages[0].description)
 
     @lazyattr
-    def is_fei(self):
-        """File was created using FEI software."""
-        return len(self.pages) and self.pages[0].is_fei
+    def micromanager_metadata(self):
+        """Return consolidated MicroManager metadata as dict."""
+        if not self.is_micromanager:
+            return
+        # from file header
+        result = read_micromanager_metadata(self._fh)
+        # from tag
+        result.update(self.pages[0].tags['MicroManagerMetadata'].value)
+        return result
 
     @lazyattr
-    def is_sem(self):
-        """File contains Zeiss SEM metadata."""
-        return len(self.pages) and self.pages[0].is_sem
+    def scanimage_metadata(self):
+        """Return ScanImage non-varying frame and ROI metadata as dict."""
+        if not self.is_scanimage:
+            return
+        result = {}
+        try:
+            framedata, roidata = read_scanimage_metadata(self._fh)
+            result['FrameData'] = framedata
+            result.update(roidata)
+        except ValueError:
+            pass
+        # TODO: scanimage_artist_metadata
+        try:
+            result['Description'] = scanimage_description_metadata(
+                self.pages[0].description)
+        except Exception as e:
+            warnings.warn("scanimage_description_metadata failed: %s" % e)
+        return result
+
+
+class TiffPages(object):
+    """Sequence of TIFF image file directories."""
+    def __init__(self, parent):
+        """Initialize instance from file. Read first TiffPage from file.
+
+        The file position must be at an offset to an offset to a TiffPage.
+
+        """
+        self.parent = parent
+        self.pages = []  # cache of TiffPages, TiffFrames, or their offsets
+        self.complete = False  # True if offsets to all pages were read
+        self._tiffpage = TiffPage  # class for reading tiff pages
+        self._keyframe = None
+        self._cache = True
+
+        # read offset to first page
+        fh = parent.filehandle
+        self._nextpageoffset = fh.tell()
+        offset = struct.unpack(parent.offsetformat,
+                               fh.read(parent.offsetsize))[0]
+
+        if offset == 0:
+            # warnings.warn("file contains no pages")
+            self.complete = True
+            return
+        if offset >= fh.size:
+            warnings.warn("invalid page offset (%i)" % offset)
+            self.complete = True
+            return
+
+        # always read and cache first page
+        fh.seek(offset)
+        page = TiffPage(parent, index=0)
+        self.pages.append(page)
+        self._keyframe = page
+
+    @property
+    def cache(self):
+        """Return if pages/frames are currenly being cached."""
+        return self._cache
+
+    @cache.setter
+    def cache(self, value):
+        """Enable or disable caching of pages/frames. Clear cache if False."""
+        value = bool(value)
+        if self._cache and not value:
+            self.clear()
+        self._cache = value
+
+    @property
+    def useframes(self):
+        """Return if currently using TiffFrame (True) or TiffPage (False)."""
+        return self._tiffpage == TiffFrame and TiffFrame is not TiffPage
+
+    @useframes.setter
+    def useframes(self, value):
+        """Set to use TiffFrame (True) or TiffPage (False)."""
+        self._tiffpage = TiffFrame if value else TiffPage
+
+    @property
+    def keyframe(self):
+        """Return index of current keyframe."""
+        return self._keyframe.index
+
+    @keyframe.setter
+    def keyframe(self, index):
+        """Set current keyframe. Load TiffPage from file if necessary."""
+        if self.complete or 0 <= index < len(self.pages):
+            page = self.pages[index]
+            if isinstance(page, TiffPage):
+                self._keyframe = page
+                return
+            elif isinstance(page, TiffFrame):
+                # remove existing frame
+                self.pages[index] = page.offset
+        # load TiffPage from file
+        useframes = self.useframes
+        self._tiffpage = TiffPage
+        self._keyframe = self[index]
+        self.useframes = useframes
+
+    @property
+    def next_page_offset(self):
+        """Return offset where offset to a new page can be stored."""
+        if not self.complete:
+            self._seek(-1)
+        return self._nextpageoffset
+
+    def load(self):
+        """Read all remaining pages from file."""
+        fh = self.parent.filehandle
+        keyframe = self._keyframe
+        pages = self.pages
+        if not self.complete:
+            self._seek(-1)
+        for i, page in enumerate(pages):
+            if isinstance(page, inttypes):
+                fh.seek(page)
+                page = self._tiffpage(self.parent, index=i, keyframe=keyframe)
+                pages[i] = page
+
+    def clear(self, fully=True):
+        """Delete all but first page from cache. Set keyframe to first page."""
+        pages = self.pages
+        if not self._cache or len(pages) < 1:
+            return
+        self._keyframe = pages[0]
+        if fully:
+            # delete all but first TiffPage/TiffFrame
+            for i, page in enumerate(pages[1:]):
+                if not isinstance(page, inttypes):
+                    pages[i+1] = page.offset
+        elif TiffFrame is not TiffPage:
+            # delete only TiffFrames
+            for i, page in enumerate(pages):
+                if isinstance(page, TiffFrame):
+                    pages[i] = page.offset
+
+    def _seek(self, index):
+        """Seek file to offset of specified page."""
+        pages = self.pages
+        if not pages:
+            return
+
+        fh = self.parent.filehandle
+        if fh.closed:
+            raise RuntimeError("FileHandle is closed")
+
+        if self.complete or 0 <= index < len(pages):
+            page = pages[index]
+            offset = page if isinstance(page, inttypes) else page.offset
+            fh.seek(offset)
+            return
+
+        offsetformat = self.parent.offsetformat
+        offsetsize = self.parent.offsetsize
+        tagnoformat = self.parent.tagnoformat
+        tagnosize = self.parent.tagnosize
+        tagsize = self.parent.tagsize
+        unpack = struct.unpack
+
+        page = pages[-1]
+        offset = page if isinstance(page, inttypes) else page.offset
+
+        while True:
+            # read offsets to pages from file until index is reached
+            fh.seek(offset)
+            # skip tags
+            try:
+                tagno = unpack(tagnoformat, fh.read(tagnosize))[0]
+                if tagno > 4096:
+                    raise ValueError("suspicious number of tags")
+            except Exception:
+                warnings.warn("corrupted tag list at offset %i" % offset)
+                del pages[-1]
+                self.complete = True
+                break
+            self._nextpageoffset = offset + tagnosize + tagno * tagsize
+            fh.seek(self._nextpageoffset)
+
+            # read offset to next page
+            offset = unpack(offsetformat, fh.read(offsetsize))[0]
+            if offset == 0:
+                self.complete = True
+                break
+            if offset >= fh.size:
+                warnings.warn("invalid page offset (%i)" % offset)
+                self.complete = True
+                break
+
+            pages.append(offset)
+            if 0 <= index < len(pages):
+                break
+
+        if index >= len(pages):
+            raise IndexError('list index out of range')
+
+        page = pages[index]
+        fh.seek(page if isinstance(page, inttypes) else page.offset)
+
+    def __bool__(self):
+        """Return True if file contains any pages."""
+        return len(self.pages) > 0
+
+    def __len__(self):
+        """Return number of pages in file."""
+        if not self.complete:
+            self._seek(-1)
+        return len(self.pages)
+
+    def __getitem__(self, key):
+        """Return specified page(s) from cache or file."""
+        pages = self.pages
+        if not pages:
+            raise IndexError('list index out of range')
+        if key is 0:
+            return pages[key]
+
+        if isinstance(key, slice):
+            start, stop, _ = key.indices(2**31)
+            if not self.complete and max(stop, start) > len(pages):
+                self._seek(-1)
+            return [self[i] for i in range(*key.indices(len(pages)))]
+
+        if self.complete and key >= len(pages):
+            raise IndexError('list index out of range')
+
+        try:
+            page = pages[key]
+        except IndexError:
+            page = 0
+        if not isinstance(page, inttypes):
+            return page
+
+        self._seek(key)
+        page = self._tiffpage(self.parent, index=key, keyframe=self._keyframe)
+        if self._cache:
+            pages[key] = page
+        return page
+
+    def __iter__(self):
+        """Return iterator over all pages."""
+        i = 0
+        while True:
+            try:
+                yield self[i]
+                i += 1
+            except IndexError:
+                break
 
 
 class TiffPage(object):
-    """A TIFF image file directory (IFD).
+    """TIFF image file directory (IFD).
 
     Attributes
     ----------
     index : int
         Index of page in file.
-    dtype : str {TIFF_SAMPLE_DTYPES}
-        Data type of image, color-mapped if applicable.
+    dtype : numpy.dtype or None
+        Data type of the image in IFD.
     shape : tuple
-        Dimensions of the image array in TIFF page,
-        color-mapped and with extra samples if applicable.
+        Dimensions of the image in IFD.
     axes : str
         Axes label codes:
         'X' width, 'Y' height, 'S' sample, 'I' image series|page|plane,
         'Z' depth, 'C' color|em-wavelength|channel, 'E' ex-wavelength|lambda,
         'T' time, 'R' region|tile, 'A' angle, 'P' phase, 'H' lifetime,
         'L' exposure, 'V' event, 'Q' unknown, '_' missing
-    tags : TiffTags
-        Dictionary of tags in page.
-        Tag values are also directly accessible as attributes.
-    color_map : numpy.ndarray
+    tags : dict
+        Dictionary of tags in IFD. {tag.name: TiffTag}
+    colormap : numpy.ndarray
         Color look up table, if exists.
-    cz_lsm_scan_info: Record(dict)
-        LSM scan info attributes, if exists.
-    imagej_tags: Record(dict)
-        Consolidated ImageJ description and metadata tags, if exists.
-    uic_tags: Record(dict)
-        Consolidated MetaMorph STK/UIC tags, if exists.
 
     All attributes are read-only.
 
@@ -2032,568 +2874,441 @@ class TiffPage(object):
     -----
     The internal, normalized '_shape' attribute is 6 dimensional:
 
-    0. number planes/images  (stk, ij).
-    1. planar samples_per_pixel.
-    2. image_depth Z  (sgi).
-    3. image_length Y.
-    4. image_width X.
-    5. contig samples_per_pixel.
+    0 : number planes/images  (stk, ij).
+    1 : planar samplesperpixel.
+    2 : imagedepth Z  (sgi).
+    3 : imagelength Y.
+    4 : imagewidth X.
+    5 : contig samplesperpixel.
 
     """
-    def __init__(self, parent, skip=False):
+    # default properties; will be updated from tags
+    imagewidth = 0
+    imagelength = 0
+    imagedepth = 1
+    tilewidth = 0
+    tilelength = 0
+    tiledepth = 1
+    bitspersample = 1
+    samplesperpixel = 1
+    sampleformat = 1
+    rowsperstrip = 2**32-1
+    compression = 1
+    planarconfig = 1
+    fillorder = 1
+    photometric = 0
+    predictor = 1
+    extrasamples = 1
+    colormap = None
+    software = ''
+    description = ''
+    description1 = ''
+
+    def __init__(self, parent, index, keyframe=None):
         """Initialize instance from file.
 
-        If skip, seek to next IFD offset without reading tags.
+        The file handle position must be at offset to a valid IFD.
 
         """
         self.parent = parent
-
-        self.index = len(parent.pages)
-        self.shape = self._shape = ()
-        self.dtype = self._dtype = None
+        self.index = index
+        self.shape = ()
+        self._shape = ()
+        self.dtype = None
+        self._dtype = None
         self.axes = ""
-        self.tags = TiffTags()
-        self._offset = 0  # offset to this IDF
-
-        self._fromfile(skip)
-        if skip:
-            return
-        self._process_tags()
+        self.tags = {}
 
-    def _fromfile(self, skip=False):
-        """Read TIFF IFD structure and its tags from file.
+        self.dataoffsets = ()
+        self.databytecounts = ()
 
-        The file cursor is left at the storage position of the offset to the
-        next IFD (if any).
-
-        Raises StopIteration if offset (first bytes read) is 0
-        or a corrupted page list is encountered.
-
-        """
-        fh = self.parent.filehandle
-        byteorder = self.parent.byteorder
-        offset_size = self.parent.offset_size
-        pos = fh.tell()
-
-        # read offset to this IFD
-        fmt = {4: 'I', 8: 'Q'}[offset_size]
-        offset = struct.unpack(byteorder + fmt, fh.read(offset_size))[0]
-        if not offset:
-            fh.seek(pos)
-            raise StopIteration()
-        if offset >= fh.size:
-            warnings.warn("invalid page offset > file size")
-            fh.seek(pos)
-            raise StopIteration()
-        self._offset = offset
-
-        # read standard tags
-        fh.seek(offset)
-        fmt, size, tagsize = {4: ('H', 2, 12), 8: ('Q', 8, 20)}[offset_size]
+        # read TIFF IFD structure and its tags from file
+        fh = parent.filehandle
+        self.offset = fh.tell()  # offset to this IDF
         try:
-            numtags = struct.unpack(byteorder + fmt, fh.read(size))[0]
-            if numtags > 4096:
+            tagno = struct.unpack(parent.tagnoformat,
+                                  fh.read(parent.tagnosize))[0]
+            if tagno > 4096:
                 raise ValueError("suspicious number of tags")
         except Exception:
-            warnings.warn("corrupted page list at offset %i" % offset)
-            fh.seek(pos)
-            raise StopIteration()
-
-        if skip:
-            fh.seek(offset + size + numtags * tagsize)
-            return
+            raise ValueError("corrupted tag list at offset %i" % self.offset)
 
+        tagsize = parent.tagsize
+        data = fh.read(tagsize * tagno)
         tags = self.tags
-        tagcode = 0
-        for _ in range(numtags):
+        index = -tagsize
+        for _ in range(tagno):
+            index += tagsize
             try:
-                tag = TiffTag(self.parent)
+                tag = TiffTag(self.parent, data[index:index+tagsize])
             except TiffTag.Error as e:
                 warnings.warn(str(e))
                 continue
-            if tagcode > tag.code:
-                # expected for early LSM and tifffile versions
-                warnings.warn("tags are not ordered by code")
-            tagcode = tag.code
-            if tag.name not in tags:
-                tags[tag.name] = tag
+            tagname = tag.name
+            if tagname not in tags:
+                name = tagname
+                tags[name] = tag
             else:
                 # some files contain multiple tags with same code
-                # e.g. MicroManager files contain two image_description tags
+                # e.g. MicroManager files contain two ImageDescription tags
                 i = 1
                 while True:
-                    name = "%s_%i" % (tag.name, i)
+                    name = "%s%i" % (tagname, i)
                     if name not in tags:
                         tags[name] = tag
                         break
+            name = TIFF.TAG_ATTRIBUTES.get(name, '')
+            if name:
+                setattr(self, name, tag.value)
+
+        if not tags:
+            return  # found in FIBICS
 
-        pos = fh.tell()  # where offset to next IFD is stored
+        # consolidate private tags; remove them from self.tags
+        if self.is_andor:
+            self.andor_tags
+        elif self.is_epics:
+            self.epics_tags
 
         if self.is_lsm or (self.index and self.parent.is_lsm):
             # correct non standard LSM bitspersample tags
-            self.tags['bits_per_sample']._fix_lsm_bitspersample(self)
-
-        if self.is_lsm:
-            # read LSM info subrecords
-            for name, reader in CZ_LSM_INFO_READERS.items():
-                try:
-                    offset = self.cz_lsm_info['offset_'+name]
-                except KeyError:
-                    continue
-                if offset < 8:
-                    # older LSM revision
-                    continue
-                fh.seek(offset)
-                try:
-                    setattr(self, 'cz_lsm_'+name, reader(fh))
-                except ValueError:
-                    pass
-        elif self.is_stk and 'uic1tag' in tags and not tags['uic1tag'].value:
-            # read uic1tag now that plane count is known
-            uic1tag = tags['uic1tag']
-            fh.seek(uic1tag.value_offset)
-            tags['uic1tag'].value = Record(
-                read_uic1tag(fh, byteorder, uic1tag.dtype, uic1tag.count,
-                             tags['uic2tag'].count))
-
-        fh.seek(pos)
-
-    def _process_tags(self):
-        """Validate standard tags and initialize attributes.
-
-        Raise ValueError if tag values are not supported.
-
-        """
-        tags = self.tags
-        for code, (name, default, dtype, count, validate) in TIFF_TAGS.items():
-            if name in tags:
-                #tags[name] = TiffTag(code, dtype=dtype, count=count,
-                #                     value=default, name=name)
-                if validate:
-                    try:
-                        if tags[name].count == 1:
-                            setattr(self, name, validate[tags[name].value])
-                        else:
-                            setattr(self, name, tuple(
-                                validate[value] for value in tags[name].value))
-                    except KeyError:
-                        raise ValueError("%s.value (%s) not supported" %
-                                         (name, tags[name].value))
-            elif default is not None:
-                setattr(self, name, validate[default] if validate else default)
+            self.tags['BitsPerSample']._fix_lsm_bitspersample(self)
+
+        if self.is_vista or (self.index and self.parent.is_vista):
+            # ISS Vista writes wrong ImageDepth tag
+            self.imagedepth = 1
+
+        if self.is_stk and 'UIC1tag' in tags and not tags['UIC1tag'].value:
+            # read UIC1tag now that plane count is known
+            uic1tag = tags['UIC1tag']
+            fh.seek(uic1tag.valueoffset)
+            tags['UIC1tag'].value = read_uic1tag(
+                fh, self.parent.byteorder, uic1tag.dtype,
+                uic1tag.count, None, tags['UIC2tag'].count)
+
+        if 'IJMetadata' in tags:
+            # decode IJMetadata tag
+            try:
+                tags['IJMetadata'].value = imagej_metadata(
+                    tags['IJMetadata'].value,
+                    tags['IJMetadataByteCounts'].value,
+                    self.parent.byteorder)
+            except Exception as e:
+                warnings.warn(str(e))
 
-        if 'bits_per_sample' in tags:
-            tag = tags['bits_per_sample']
+        if 'BitsPerSample' in tags:
+            tag = tags['BitsPerSample']
             if tag.count == 1:
-                self.bits_per_sample = tag.value
+                self.bitspersample = tag.value
             else:
                 # LSM might list more items than samples_per_pixel
-                value = tag.value[:self.samples_per_pixel]
+                value = tag.value[:self.samplesperpixel]
                 if any((v-value[0] for v in value)):
-                    self.bits_per_sample = value
+                    self.bitspersample = value
                 else:
-                    self.bits_per_sample = value[0]
+                    self.bitspersample = value[0]
 
-        if 'sample_format' in tags:
-            tag = tags['sample_format']
+        if 'SampleFormat' in tags:
+            tag = tags['SampleFormat']
             if tag.count == 1:
-                self.sample_format = TIFF_SAMPLE_FORMATS[tag.value]
+                self.sampleformat = tag.value
             else:
-                value = tag.value[:self.samples_per_pixel]
+                value = tag.value[:self.samplesperpixel]
                 if any((v-value[0] for v in value)):
-                    self.sample_format = [TIFF_SAMPLE_FORMATS[v]
-                                          for v in value]
+                    self.sampleformat = value
                 else:
-                    self.sample_format = TIFF_SAMPLE_FORMATS[value[0]]
-
-        if 'photometric' not in tags:
-            self.photometric = None
-
-        if 'image_length' in tags:
-            if 'rows_per_strip' not in tags:
-                self.rows_per_strip = self.image_length
-            self.strips_per_image = int(math.floor(
-                float(self.image_length + self.rows_per_strip - 1) /
-                self.rows_per_strip))
-        else:
-            self.strips_per_image = 0
-
-        key = (self.sample_format, self.bits_per_sample)
-        self.dtype = self._dtype = TIFF_SAMPLE_DTYPES.get(key, None)
-
-        if 'image_length' not in self.tags or 'image_width' not in self.tags:
-            # some GEL file pages are missing image data
-            self.image_length = 0
-            self.image_width = 0
-            self.image_depth = 0
-            self.strip_offsets = 0
-            self._shape = ()
-            self.shape = ()
-            self.axes = ''
-
-        if self.is_vista or self.parent.is_vista:
-            # ISS Vista writes wrong image_depth tag
-            self.image_depth = 1
-
-        if self.is_indexed:
-            self.dtype = self.tags['color_map'].dtype[1]
-            self.color_map = numpy.array(self.color_map, self.dtype)
-            dmax = self.color_map.max()
-            if dmax < 256:
-                self.dtype = numpy.uint8
-                self.color_map = self.color_map.astype(self.dtype)
-            #else:
-            #    self.dtype = numpy.uint8
-            #    self.color_map >>= 8
-            #    self.color_map = self.color_map.astype(self.dtype)
-            # TODO: support other photometric modes than RGB
-            self.color_map.shape = (3, -1)
+                    self.sampleformat = value[0]
+
+        if 'ImageLength' in tags:
+            if 'RowsPerStrip' not in tags or tags['RowsPerStrip'].count > 1:
+                self.rowsperstrip = self.imagelength
+            # self.stripsperimage = int(math.floor(
+            #    float(self.imagelength + self.rowsperstrip - 1) /
+            #    self.rowsperstrip))
+
+        # determine dtype
+        dtype = self.sampleformat, self.bitspersample
+        dtype = TIFF.SAMPLE_DTYPES.get(dtype, None)
+        if dtype is not None:
+            dtype = numpy.dtype(dtype)
+        self.dtype = self._dtype = dtype
 
         # determine shape of data
-        image_length = self.image_length
-        image_width = self.image_width
-        image_depth = self.image_depth
-        samples_per_pixel = self.samples_per_pixel
+        imagelength = self.imagelength
+        imagewidth = self.imagewidth
+        imagedepth = self.imagedepth
+        samplesperpixel = self.samplesperpixel
 
         if self.is_stk:
-            assert self.image_depth == 1
-            planes = self.tags['uic2tag'].count
-            if self.is_contig:
-                self._shape = (planes, 1, 1, image_length, image_width,
-                               samples_per_pixel)
-                if samples_per_pixel == 1:
-                    self.shape = (planes, image_length, image_width)
+            assert self.imagedepth == 1
+            uictag = tags['UIC2tag'].value
+            planes = tags['UIC2tag'].count
+            if self.planarconfig == 1:
+                self._shape = (
+                    planes, 1, 1, imagelength, imagewidth, samplesperpixel)
+                if samplesperpixel == 1:
+                    self.shape = (planes, imagelength, imagewidth)
                     self.axes = 'YX'
                 else:
-                    self.shape = (planes, image_length, image_width,
-                                  samples_per_pixel)
+                    self.shape = (
+                        planes, imagelength, imagewidth, samplesperpixel)
                     self.axes = 'YXS'
             else:
-                self._shape = (planes, samples_per_pixel, 1, image_length,
-                               image_width, 1)
-                if samples_per_pixel == 1:
-                    self.shape = (planes, image_length, image_width)
+                self._shape = (
+                    planes, samplesperpixel, 1, imagelength, imagewidth, 1)
+                if samplesperpixel == 1:
+                    self.shape = (planes, imagelength, imagewidth)
                     self.axes = 'YX'
                 else:
-                    self.shape = (planes, samples_per_pixel, image_length,
-                                  image_width)
+                    self.shape = (
+                        planes, samplesperpixel, imagelength, imagewidth)
                     self.axes = 'SYX'
             # detect type of series
             if planes == 1:
                 self.shape = self.shape[1:]
-            elif numpy.all(self.uic2tag.z_distance != 0):
+            elif numpy.all(uictag['ZDistance'] != 0):
                 self.axes = 'Z' + self.axes
-            elif numpy.all(numpy.diff(self.uic2tag.time_created) != 0):
+            elif numpy.all(numpy.diff(uictag['TimeCreated']) != 0):
                 self.axes = 'T' + self.axes
             else:
                 self.axes = 'I' + self.axes
-            # DISABLED
-            if self.is_indexed:
-                assert False, "color mapping disabled for stk"
-                if self.color_map.shape[1] >= 2**self.bits_per_sample:
-                    if image_depth == 1:
-                        self.shape = (planes, image_length, image_width,
-                                      self.color_map.shape[0])
-                    else:
-                        self.shape = (planes, image_depth, image_length,
-                                      image_width, self.color_map.shape[0])
-                    self.axes = self.axes + 'S'
-                else:
-                    warnings.warn("palette cannot be applied")
-                    self.is_indexed = False
-        elif self.is_indexed:
-            samples = 1
-            if 'extra_samples' in self.tags:
-                samples += self.tags['extra_samples'].count
-            if self.is_contig:
-                self._shape = (1, 1, image_depth, image_length, image_width,
-                               samples)
-            else:
-                self._shape = (1, samples, image_depth, image_length,
-                               image_width, 1)
-            if self.color_map.shape[1] >= 2**self.bits_per_sample:
-                if image_depth == 1:
-                    self.shape = (image_length, image_width,
-                                  self.color_map.shape[0])
-                    self.axes = 'YXS'
-                else:
-                    self.shape = (image_depth, image_length, image_width,
-                                  self.color_map.shape[0])
-                    self.axes = 'ZYXS'
-            else:
-                warnings.warn("palette cannot be applied")
-                self.is_indexed = False
-                if image_depth == 1:
-                    self.shape = (image_length, image_width)
-                    self.axes = 'YX'
-                else:
-                    self.shape = (image_depth, image_length, image_width)
-                    self.axes = 'ZYX'
-        elif self.is_rgb or samples_per_pixel > 1:
-            if self.is_contig:
-                self._shape = (1, 1, image_depth, image_length, image_width,
-                               samples_per_pixel)
-                if image_depth == 1:
-                    self.shape = (image_length, image_width, samples_per_pixel)
+        elif self.photometric == 2 or samplesperpixel > 1:  # PHOTOMETRIC.RGB
+            if self.planarconfig == 1:
+                self._shape = (
+                    1, 1, imagedepth, imagelength, imagewidth, samplesperpixel)
+                if imagedepth == 1:
+                    self.shape = (imagelength, imagewidth, samplesperpixel)
                     self.axes = 'YXS'
                 else:
-                    self.shape = (image_depth, image_length, image_width,
-                                  samples_per_pixel)
+                    self.shape = (
+                        imagedepth, imagelength, imagewidth, samplesperpixel)
                     self.axes = 'ZYXS'
             else:
-                self._shape = (1, samples_per_pixel, image_depth,
-                               image_length, image_width, 1)
-                if image_depth == 1:
-                    self.shape = (samples_per_pixel, image_length, image_width)
+                self._shape = (1, samplesperpixel, imagedepth,
+                               imagelength, imagewidth, 1)
+                if imagedepth == 1:
+                    self.shape = (samplesperpixel, imagelength, imagewidth)
                     self.axes = 'SYX'
                 else:
-                    self.shape = (samples_per_pixel, image_depth,
-                                  image_length, image_width)
+                    self.shape = (
+                        samplesperpixel, imagedepth, imagelength, imagewidth)
                     self.axes = 'SZYX'
-            if False and self.is_rgb and 'extra_samples' in self.tags:
-                # DISABLED: only use RGB and first alpha channel if exists
-                extra_samples = self.extra_samples
-                if self.tags['extra_samples'].count == 1:
-                    extra_samples = (extra_samples,)
-                for exs in extra_samples:
-                    if exs in ('unassalpha', 'assocalpha', 'unspecified'):
-                        if self.is_contig:
-                            self.shape = self.shape[:-1] + (4,)
-                        else:
-                            self.shape = (4,) + self.shape[1:]
-                        break
         else:
-            self._shape = (1, 1, image_depth, image_length, image_width, 1)
-            if image_depth == 1:
-                self.shape = (image_length, image_width)
+            self._shape = (1, 1, imagedepth, imagelength, imagewidth, 1)
+            if imagedepth == 1:
+                self.shape = (imagelength, imagewidth)
                 self.axes = 'YX'
             else:
-                self.shape = (image_depth, image_length, image_width)
+                self.shape = (imagedepth, imagelength, imagewidth)
                 self.axes = 'ZYX'
-        if not self.compression and 'strip_byte_counts' not in tags:
-            self.strip_byte_counts = (
-                product(self.shape) * (self.bits_per_sample // 8),)
 
+        # dataoffsets and databytecounts
+        if 'TileOffsets' in tags:
+            self.dataoffsets = tags['TileOffsets'].value
+        elif 'StripOffsets' in tags:
+            self.dataoffsets = tags['StripOffsets'].value
+        else:
+            self.dataoffsets = (0,)
+
+        if 'TileByteCounts' in tags:
+            self.databytecounts = tags['TileByteCounts'].value
+        elif 'StripByteCounts' in tags:
+            self.databytecounts = tags['StripByteCounts'].value
+        elif self.compression == 1:
+            self.databytecounts = (
+                product(self.shape) * (self.bitspersample // 8),)
+        else:
+            raise ValueError("ByteCounts not found")
         assert len(self.shape) == len(self.axes)
 
-    def _patch_imagej(self):
-        """Return if ImageJ data are contiguous and adjust page attributes.
-
-        Patch 'strip_offsets' and 'strip_byte_counts' tags to span the
-        complete contiguous data.
-
-        ImageJ stores all image metadata in the first page and image data is
-        stored contiguously before the second page, if any. No need to
-        read other pages.
-
-        """
-        if not self.is_imagej or not self.is_contiguous or self.parent.is_ome:
-            return
-        images = self.imagej_tags.get('images', 0)
-        if images <= 1:
-            return
-        offset, count = self.is_contiguous
-        shape = self.shape
-        if self.is_indexed:
-            shape = shape[:-1]
-
-        fh = self.parent.filehandle
-        if (count != product(shape) * self.bits_per_sample // 8 or
-                offset + count*images > fh.size):
-            self.is_imagej = False
-            warnings.warn("invalid ImageJ metadata or corrupted file")
-            return
-
-        # check that next page is stored after data
-        byteorder = self.parent.byteorder
-        offset_size = self.parent.offset_size
-        pos = fh.tell()
-        fmt = {4: 'I', 8: 'Q'}[offset_size]
-        nextpage = struct.unpack(byteorder + fmt, fh.read(offset_size))[0]
-        fh.seek(pos)
-        if nextpage and offset + count*images > nextpage:
-            return
-
-        # patch metadata
-        pre = 'tile' if self.is_tiled else 'strip'
-        self.tags[pre+'_offsets'].value = (offset,)
-        self.tags[pre+'_byte_counts'].value = (count * images,)
-        self.shape = (images,) + self.shape
-        self._shape = (images,) + self._shape[1:]
-        self.axes = 'I' + self.axes
-        return True
-
-    def asarray(self, squeeze=True, colormapped=True, rgbonly=False,
-                scale_mdgel=False, memmap=False, reopen=True,
-                maxsize=64*2**30):
+    def asarray(self, out=None, squeeze=True, lock=None, reopen=True,
+                maxsize=64*2**30, validate=True):
         """Read image data from file and return as numpy array.
 
         Raise ValueError if format is unsupported.
-        If any of 'squeeze', 'colormapped', or 'rgbonly' are not the default,
-        the shape of the returned array might be different from the page shape.
 
         Parameters
         ----------
+        out : numpy.ndarray, str, or file-like object; optional
+            Buffer where image data will be saved.
+            If numpy.ndarray, a writable array of compatible dtype and shape.
+            If str or open file, the file name or file object used to
+            create a memory-map to an array stored in a binary file on disk.
         squeeze : bool
             If True, all length-1 dimensions (except X and Y) are
-            squeezed out from result.
-        colormapped : bool
-            If True, color mapping is applied for palette-indexed images.
-        rgbonly : bool
-            If True, return RGB(A) image without additional extra samples.
-        memmap : bool
-            If True, use numpy.memmap to read arrays from file if possible.
-            For use on 64-bit systems and files with few huge contiguous data.
+            squeezed out from the array.
+            If False, the shape of the returned array might be different from
+            the page.shape.
+        lock : {RLock, NullContext}
+            A reentrant lock used to syncronize reads from file.
+            If None (default), the lock of the parent's filehandle is used.
         reopen : bool
-            If True and the parent file handle is closed, the file is
-            temporarily re-opened (and closed if no exception occurs).
-        scale_mdgel : bool
-            If True, MD Gel data will be scaled according to the private
-            metadata in the second TIFF page. The dtype will be float32.
+            If True (default) and the parent file handle is closed, the file
+            is temporarily re-opened and closed if no exception occurs.
         maxsize: int or None
             Maximum size of data before a ValueError is raised.
             Can be used to catch DOS. Default: 64 GB.
+        validate : bool
+            If True (default), validate various parameters.
+            If None, only validate parameters and return None.
 
         """
-        if not self._shape:
+        self_ = self
+        self = self.keyframe  # self or keyframe
+
+        if not self._shape or product(self._shape) == 0:
             return
-        if maxsize and product(self._shape) > maxsize:
-            raise ValueError("data is too large %s" % str(self._shape))
-
-        if self.dtype is None:
-            raise ValueError("data type not supported: %s%i" % (
-                self.sample_format, self.bits_per_sample))
-        if self.compression not in TIFF_DECOMPESSORS:
-            raise ValueError("cannot decompress %s" % self.compression)
-        if 'sample_format' in self.tags:
-            tag = self.tags['sample_format']
-            if tag.count != 1 and any((i-tag.value[0] for i in tag.value)):
-                raise ValueError("sample formats do not match %s" % tag.value)
-
-        if self.is_chroma_subsampled:
-            # TODO: implement chroma subsampling
-            raise NotImplementedError("chroma subsampling not supported")
 
-        fh = self.parent.filehandle
-        closed = fh.closed
-        if closed:
-            if reopen:
-                fh.open()
-            else:
-                raise IOError("file handle is closed")
+        tags = self.tags
+
+        if validate or validate is None:
+            if maxsize and product(self._shape) > maxsize:
+                raise ValueError("data is too large %s" % str(self._shape))
+            if self.dtype is None:
+                raise ValueError("data type not supported: %s%i" % (
+                    self.sampleformat, self.bitspersample))
+            if self.compression not in TIFF.DECOMPESSORS:
+                raise ValueError(
+                    "can not decompress %s" % self.compression.name)
+            if 'SampleFormat' in tags:
+                tag = tags['SampleFormat']
+                if tag.count != 1 and any((i-tag.value[0] for i in tag.value)):
+                    raise ValueError(
+                        "sample formats do not match %s" % tag.value)
+            if self.is_chroma_subsampled:
+                # TODO: implement chroma subsampling
+                raise NotImplementedError("chroma subsampling not supported")
+            if validate is None:
+                return
+
+        fh = self_.parent.filehandle
+        lock = fh.lock if lock is None else lock
+        with lock:
+            closed = fh.closed
+            if closed:
+                if reopen:
+                    fh.open()
+                else:
+                    raise IOError("file handle is closed")
 
         dtype = self._dtype
         shape = self._shape
-        image_width = self.image_width
-        image_length = self.image_length
-        image_depth = self.image_depth
-        typecode = self.parent.byteorder + dtype
-        bits_per_sample = self.bits_per_sample
-        lsb2msb = self.fill_order == 'lsb2msb'
-
-        byte_counts, offsets = self._byte_counts_offsets
-
-        if self.is_tiled:
-            tile_width = self.tile_width
-            tile_length = self.tile_length
-            tile_depth = self.tile_depth if 'tile_depth' in self.tags else 1
-            tw = (image_width + tile_width - 1) // tile_width
-            tl = (image_length + tile_length - 1) // tile_length
-            td = (image_depth + tile_depth - 1) // tile_depth
+        imagewidth = self.imagewidth
+        imagelength = self.imagelength
+        imagedepth = self.imagedepth
+        bitspersample = self.bitspersample
+        typecode = self.parent.byteorder + dtype.char
+        lsb2msb = self.fillorder == 2
+        offsets, bytecounts = self_.offsets_bytecounts
+        istiled = self.is_tiled
+
+        if istiled:
+            tilewidth = self.tilewidth
+            tilelength = self.tilelength
+            tiledepth = self.tiledepth
+            tw = (imagewidth + tilewidth - 1) // tilewidth
+            tl = (imagelength + tilelength - 1) // tilelength
+            td = (imagedepth + tiledepth - 1) // tiledepth
             shape = (shape[0], shape[1],
-                     td*tile_depth, tl*tile_length, tw*tile_width, shape[-1])
-            tile_shape = (tile_depth, tile_length, tile_width, shape[-1])
-            runlen = tile_width
+                     td*tiledepth, tl*tilelength, tw*tilewidth, shape[-1])
+            tileshape = (tiledepth, tilelength, tilewidth, shape[-1])
+            runlen = tilewidth
         else:
-            runlen = image_width
+            runlen = imagewidth
 
-        if memmap and self._is_memmappable(rgbonly, colormapped):
-            result = fh.memmap_array(typecode, shape, offset=offsets[0])
+        if out == 'memmap' and self.is_memmappable:
+            with lock:
+                result = fh.memmap_array(typecode, shape, offset=offsets[0])
         elif self.is_contiguous:
-            fh.seek(offsets[0])
-            result = fh.read_array(typecode, product(shape))
-            result = result.astype('=' + dtype)
+            isnative = self.parent.is_native
+            if out is not None:
+                isnative = True
+                out = create_output(out, shape, dtype)
+            with lock:
+                fh.seek(offsets[0])
+                result = fh.read_array(typecode, product(shape), out=out)
+            if not isnative:
+                result = result.astype('=' + dtype.char)
             if lsb2msb:
                 reverse_bitorder(result)
         else:
-            if self.is_contig:
-                runlen *= self.samples_per_pixel
-            if bits_per_sample in (8, 16, 32, 64, 128):
-                if (bits_per_sample * runlen) % 8:
+            result = create_output(out, shape, dtype)
+            if self.planarconfig == 1:
+                runlen *= self.samplesperpixel
+            if bitspersample in (8, 16, 32, 64, 128):
+                if (bitspersample * runlen) % 8:
                     raise ValueError("data and sample size mismatch")
 
                 def unpack(x, typecode=typecode):
-                    if self.predictor == 'float':
+                    if self.predictor == 3:  # PREDICTOR.FLOATINGPOINT
                         # the floating point horizontal differencing decoder
                         # needs the raw byte order
-                        typecode = dtype
+                        typecode = dtype.char
                     try:
                         return numpy.fromstring(x, typecode)
                     except ValueError as e:
                         # strips may be missing EOI
-                        warnings.warn("unpack: %s" % e)
-                        xlen = ((len(x) // (bits_per_sample // 8)) *
-                                (bits_per_sample // 8))
+                        # warnings.warn("unpack: %s" % e)
+                        xlen = ((len(x) // (bitspersample // 8)) *
+                                (bitspersample // 8))
                         return numpy.fromstring(x[:xlen], typecode)
 
-            elif isinstance(bits_per_sample, tuple):
+            elif isinstance(bitspersample, tuple):
                 def unpack(x):
-                    return unpack_rgb(x, typecode, bits_per_sample)
+                    return unpack_rgb(x, typecode, bitspersample)
             else:
                 def unpack(x):
-                    return unpack_ints(x, typecode, bits_per_sample, runlen)
+                    return unpack_ints(x, typecode, bitspersample, runlen)
 
-            decompress = TIFF_DECOMPESSORS[self.compression]
-            if self.compression == 'jpeg':
-                table = self.jpeg_tables if 'jpeg_tables' in self.tags else b''
+            decompress = TIFF.DECOMPESSORS[self.compression]
+            if self.compression == 7:  # COMPRESSION.JPEG
+                if 'JPEGTables' in tags:
+                    table = tags['JPEGTables'].value
+                else:
+                    table = b''
 
                 def decompress(x):
                     return decode_jpeg(x, table, self.photometric)
 
-            if self.is_tiled:
-                result = numpy.empty(shape, dtype)
+            if istiled:
                 tw, tl, td, pl = 0, 0, 0, 0
-                for offset, bytecount in zip(offsets, byte_counts):
-                    fh.seek(offset)
-                    tile = fh.read(bytecount)
+                for tile in buffered_read(fh, lock, offsets, bytecounts):
                     if lsb2msb:
                         tile = reverse_bitorder(tile)
                     tile = decompress(tile)
                     tile = unpack(tile)
                     try:
-                        tile.shape = tile_shape
+                        tile.shape = tileshape
                     except ValueError:
                         # incomplete tiles; see gdal issue #1179
                         warnings.warn("invalid tile data")
-                        t = numpy.zeros(tile_shape, dtype).reshape(-1)
+                        t = numpy.zeros(tileshape, dtype).reshape(-1)
                         s = min(tile.size, t.size)
                         t[:s] = tile[:s]
-                        tile = t.reshape(tile_shape)
-                    if self.predictor == 'horizontal':
+                        tile = t.reshape(tileshape)
+                    if self.predictor == 2:  # PREDICTOR.HORIZONTAL
                         numpy.cumsum(tile, axis=-2, dtype=dtype, out=tile)
-                    elif self.predictor == 'float':
+                    elif self.predictor == 3:  # PREDICTOR.FLOATINGPOINT
                         raise NotImplementedError()
-                    result[0, pl, td:td+tile_depth,
-                           tl:tl+tile_length, tw:tw+tile_width, :] = tile
+                    result[0, pl, td:td+tiledepth,
+                           tl:tl+tilelength, tw:tw+tilewidth, :] = tile
                     del tile
-                    tw += tile_width
+                    tw += tilewidth
                     if tw >= shape[4]:
-                        tw, tl = 0, tl + tile_length
+                        tw, tl = 0, tl + tilelength
                         if tl >= shape[3]:
-                            tl, td = 0, td + tile_depth
+                            tl, td = 0, td + tiledepth
                             if td >= shape[2]:
                                 td, pl = 0, pl + 1
                 result = result[...,
-                                :image_depth, :image_length, :image_width, :]
+                                :imagedepth, :imagelength, :imagewidth, :]
             else:
-                strip_size = self.rows_per_strip * self.image_width
-                if self.planar_configuration == 'contig':
-                    strip_size *= self.samples_per_pixel
-                result = numpy.empty(shape, dtype).reshape(-1)
+                strip_size = self.rowsperstrip * self.imagewidth
+                if self.planarconfig == 1:
+                    strip_size *= self.samplesperpixel
+                result = result.reshape(-1)
                 index = 0
-                for offset, bytecount in zip(offsets, byte_counts):
-                    fh.seek(offset)
-                    strip = fh.read(bytecount)
+                for strip in buffered_read(fh, lock, offsets, bytecounts):
                     if lsb2msb:
                         strip = reverse_bitorder(strip)
                     strip = decompress(strip)
@@ -2606,35 +3321,13 @@ class TiffPage(object):
 
         result.shape = self._shape
 
-        if self.predictor and not (self.is_tiled and not self.is_contiguous):
-            if self.parent.is_lsm and not self.compression:
+        if self.predictor != 1 and not (istiled and not self.is_contiguous):
+            if self.parent.is_lsm and self.compression == 1:
                 pass  # work around bug in LSM510 software
-            elif self.predictor == 'horizontal':
+            elif self.predictor == 2:  # PREDICTOR.HORIZONTAL
                 numpy.cumsum(result, axis=-2, dtype=dtype, out=result)
-            elif self.predictor == 'float':
+            elif self.predictor == 3:  # PREDICTOR.FLOATINGPOINT
                 result = decode_floats(result)
-        if colormapped and self.is_indexed:
-            if self.color_map.shape[1] >= 2**bits_per_sample:
-                # FluoView and LSM might fail here
-                result = apply_colormap(result[:, 0:1, :, :, :, 0:1],
-                                        self.color_map)
-        elif rgbonly and self.is_rgb and 'extra_samples' in self.tags:
-            # return only RGB and first alpha channel if exists
-            extra_samples = self.extra_samples
-            if self.tags['extra_samples'].count == 1:
-                extra_samples = (extra_samples,)
-            for i, exs in enumerate(extra_samples):
-                if exs in ('unassalpha', 'assocalpha', 'unspecified'):
-                    if self.is_contig:
-                        result = result[..., [0, 1, 2, 3+i]]
-                    else:
-                        result = result[:, [0, 1, 2, 3+i]]
-                    break
-            else:
-                if self.is_contig:
-                    result = result[..., :3]
-                else:
-                    result = result[:, :3]
 
         if squeeze:
             try:
@@ -2643,445 +3336,599 @@ class TiffPage(object):
                 warnings.warn("failed to reshape from %s to %s" % (
                     str(result.shape), str(self.shape)))
 
-        if scale_mdgel and self.parent.is_mdgel:
-            # MD Gel stores private metadata in the second page
-            tags = self.parent.pages[1]
-            if tags.md_file_tag in (2, 128):
-                scale = tags.md_scale_pixel
-                scale = scale[0] / scale[1]  # rational
-                result = result.astype('float32')
-                if tags.md_file_tag == 2:
-                    result **= 2  # squary root data format
-                result *= scale
-
         if closed:
             # TODO: file should remain open if an exception occurred above
             fh.close()
         return result
 
-    @lazyattr
-    def _byte_counts_offsets(self):
-        """Return simplified byte_counts and offsets."""
-        if 'tile_offsets' in self.tags:
-            byte_counts = self.tile_byte_counts
-            offsets = self.tile_offsets
-        else:
-            byte_counts = self.strip_byte_counts
-            offsets = self.strip_offsets
-
-        j = 0
-        for i, (b, o) in enumerate(zip(byte_counts, offsets)):
-            if b > 0 and o > 0:
-                if i > j:
-                    byte_counts[j] = b
-                    offsets[j] = o
-                j += 1
-            elif b > 0 >= o:
-                raise ValueError("invalid offset")
+    def asrgb(self, uint8=False, alpha=None, colormap=None,
+              dmin=None, dmax=None, *args, **kwargs):
+        """Return image data as RGB(A).
+
+        Work in progress.
+
+        """
+        data = self.asarray(*args, **kwargs)
+        self = self.keyframe  # self or keyframe
+        photometric = self.photometric
+        PHOTOMETRIC = TIFF.PHOTOMETRIC
+
+        if photometric == PHOTOMETRIC.PALETTE:
+            colormap = self.colormap
+            if (colormap.shape[1] < 2**self.bitspersample or
+                    self.dtype.char not in 'BH'):
+                raise ValueError("can not apply colormap")
+            if uint8:
+                if colormap.max() > 255:
+                    colormap >>= 8
+                colormap = colormap.astype('uint8')
+            if 'S' in self.axes:
+                data = data[..., 0] if self.planarconfig == 1 else data[0]
+            data = apply_colormap(data, colormap)
+
+        elif photometric == PHOTOMETRIC.RGB:
+            if 'ExtraSamples' in self.tags:
+                if alpha is None:
+                    alpha = TIFF.EXTRASAMPLE
+                extrasamples = self.extrasamples
+                if self.tags['ExtraSamples'].count == 1:
+                    extrasamples = (extrasamples,)
+                for i, exs in enumerate(extrasamples):
+                    if exs in alpha:
+                        if self.planarconfig == 1:
+                            data = data[..., [0, 1, 2, 3+i]]
+                        else:
+                            data = data[:, [0, 1, 2, 3+i]]
+                        break
             else:
-                warnings.warn("empty byte count")
-        if j == 0:
-            j = 1
+                if self.planarconfig == 1:
+                    data = data[..., :3]
+                else:
+                    data = data[:, :3]
+            # TODO: convert to uint8
+
+        elif photometric == PHOTOMETRIC.MINISBLACK:
+            raise NotImplementedError()
+        elif photometric == PHOTOMETRIC.MINISWHITE:
+            raise NotImplementedError()
+        elif photometric == PHOTOMETRIC.SEPARATED:
+            raise NotImplementedError()
+        else:
+            raise NotImplementedError()
+        return data
 
-        return byte_counts[:j], offsets[:j]
+    def aspage(self):
+        return self
 
-    def _is_memmappable(self, rgbonly, colormapped):
-        """Return if page's image data in file can be memory-mapped."""
-        return (self.parent.filehandle.is_file and
-                self.is_contiguous and
-                (self.bits_per_sample == 8 or self.parent._is_native) and
-                self.fill_order == 'msb2lsb' and
-                not self.predictor and
-                not self.is_chroma_subsampled and
-                not (rgbonly and 'extra_samples' in self.tags) and
-                not (colormapped and self.is_indexed))
+    @property
+    def keyframe(self):
+        return self
+
+    @keyframe.setter
+    def keyframe(self, index):
+        return
+
+    @lazyattr
+    def offsets_bytecounts(self):
+        """Return simplified offsets and bytecounts."""
+        if self.is_contiguous:
+            offset, byte_count = self.is_contiguous
+            return [offset], [byte_count]
+        return clean_offsets_counts(self.dataoffsets, self.databytecounts)
 
     @lazyattr
     def is_contiguous(self):
         """Return offset and size of contiguous data, else None.
 
-        Excludes prediction, fill_order, and colormapping.
+        Excludes prediction and fill_order.
 
         """
-        if self.compression or self.bits_per_sample not in (8, 16, 32, 64):
+        if (self.compression != 1
+                or self.bitspersample not in (8, 16, 32, 64)):
             return
-        if self.is_tiled:
-            if (self.image_width != self.tile_width or
-                    self.image_length % self.tile_length or
-                    self.tile_width % 16 or self.tile_length % 16):
+        if 'TileWidth' in self.tags:
+            if (self.imagewidth != self.tilewidth or
+                    self.imagelength % self.tilelength or
+                    self.tilewidth % 16 or self.tilelength % 16):
                 return
-            if ('image_depth' in self.tags and 'tile_depth' in self.tags and
-                    (self.image_length != self.tile_length or
-                     self.image_depth % self.tile_depth)):
+            if ('ImageDepth' in self.tags and 'TileDepth' in self.tags and
+                    (self.imagelength != self.tilelength or
+                     self.imagedepth % self.tiledepth)):
                 return
-            offsets = self.tile_offsets
-            byte_counts = self.tile_byte_counts
-        else:
-            offsets = self.strip_offsets
-            byte_counts = self.strip_byte_counts
+
+        offsets = self.dataoffsets
+        bytecounts = self.databytecounts
         if len(offsets) == 1:
-            return offsets[0], byte_counts[0]
-        if self.is_stk or all(offsets[i] + byte_counts[i] == offsets[i+1] or
-                              byte_counts[i+1] == 0  # no data/ignore offset
+            return offsets[0], bytecounts[0]
+        if self.is_stk or all((offsets[i] + bytecounts[i] == offsets[i+1] or
+                               bytecounts[i+1] == 0)  # no data/ignore offset
                               for i in range(len(offsets)-1)):
-            return offsets[0], sum(byte_counts)
+            return offsets[0], sum(bytecounts)
 
-    def __getattr__(self, name):
-        """Return tag value."""
-        if name in self.tags:
-            value = self.tags[name].value
-            setattr(self, name, value)
-            return value
-        raise AttributeError(name)
+    @lazyattr
+    def is_final(self):
+        """Return if page's image data is stored in final form.
 
-    def __str__(self):
-        """Return string containing information about page."""
-        s = ', '.join(s for s in (
-            'x'.join(str(i) for i in self.shape),
-            str(numpy.dtype(self.dtype)),
-            '%s bit' % str(self.bits_per_sample),
-            self.photometric if 'photometric' in self.tags else '',
-            self.compression if self.compression else 'raw',
-            '|'.join(t[3:] for t in (
-                'is_stk', 'is_lsm', 'is_nih', 'is_ome', 'is_imagej',
-                'is_micromanager', 'is_fluoview', 'is_mdgel', 'is_mediacy',
-                'is_scn', 'is_sgi', 'is_reduced', 'is_tiled',
-                'is_contiguous') if getattr(self, t))) if s)
-        return "Page %i: %s" % (self.index, s)
-
-    def info(self):
-        """Return string with detailed information about page."""
-        result = ['\n'.join((str(self), str(self.tags)))]
-        if self.is_indexed:
-            result.append('Color Map: %s, %s' % (self.color_map.shape,
-                                                 self.color_map.dtype))
-        for attr in ('cz_lsm_info', 'cz_lsm_scan_info', 'uic_tags',
-                     'mm_header', 'imagej_tags', 'micromanager_metadata',
-                     'nih_image_header', 'tvips_metadata', 'sfeg_metadata',
-                     'helios_metadata', 'sem_metadata'):
-            if hasattr(self, attr):
-                result.append('\n'.join((
-                    attr.upper(), str(Record(getattr(self, attr))))))
-        if self.is_micromanager:
-            result.append('MICROMANAGER_FILE_METADATA\n%s' %
-                          Record(self.micromanager_metadata))
-        return '\n\n'.join(result)
+        Excludes byte-swapping.
+
+        """
+        return (self.is_contiguous and self.fillorder == 1 and
+                self.predictor == 1 and not self.is_chroma_subsampled)
 
     @lazyattr
-    def uic_tags(self):
-        """Consolidate UIC tags."""
-        if not self.is_stk:
-            raise AttributeError("uic_tags")
+    def is_memmappable(self):
+        """Return if page's image data in file can be memory-mapped."""
+        return (self.parent.filehandle.is_file and self.is_final and
+                (self.bitspersample == 8 or self.parent.is_native) and
+                self.is_contiguous[0] % self.dtype.itemsize == 0)
+
+    def __str__(self, detail=0):
+        """Return string containing information about page."""
+        if self.keyframe != self:
+            return TiffFrame.__str__(self, detail)
+        attr = ''
+        for name in ('memmappable', 'final', 'contiguous'):
+            attr = getattr(self, 'is_'+name)
+            if attr:
+                attr = name.upper()
+                break
+        info = '  '.join(s for s in (
+            'x'.join(str(i) for i in self.shape),
+            '%s%s' % (TIFF.SAMPLEFORMAT(self.sampleformat).name,
+                      self.bitspersample),
+            '|'.join(i for i in (
+                TIFF.PHOTOMETRIC(self.photometric).name,
+                'TILED' if self.is_tiled else '',
+                self.compression.name if self.compression != 1 else '',
+                self.planarconfig.name if self.planarconfig != 1 else '',
+                self.predictor.name if self.predictor != 1 else '',
+                self.fillorder.name if self.fillorder != 1 else '')
+                     if i),  # noqa
+            attr,
+            '|'.join((f.upper() for f in self.flags))
+            ) if s)  # noqa
+        info = "TiffPage %i @%i  %s" % (self.index, self.offset, info)
+        if detail <= 0:
+            return info
+        info = [info]
         tags = self.tags
-        result = Record()
-        result.number_planes = tags['uic2tag'].count
-        if 'image_description' in tags:
-            result.plane_descriptions = self.image_description.split(b'\x00')
-        if 'uic1tag' in tags:
-            result.update(tags['uic1tag'].value)
-        if 'uic3tag' in tags:
-            result.update(tags['uic3tag'].value)  # wavelengths
-        if 'uic4tag' in tags:
-            result.update(tags['uic4tag'].value)  # override uic1 tags
-        uic2tag = tags['uic2tag'].value
-        result.z_distance = uic2tag.z_distance
-        result.time_created = uic2tag.time_created
-        result.time_modified = uic2tag.time_modified
-        try:
-            result.datetime_created = [
-                julian_datetime(*dt) for dt in
-                zip(uic2tag.date_created, uic2tag.time_created)]
-            result.datetime_modified = [
-                julian_datetime(*dt) for dt in
-                zip(uic2tag.date_modified, uic2tag.time_modified)]
-        except ValueError as e:
-            warnings.warn("uic_tags: %s" % e)
-        return result
+        tlines = []
+        vlines = []
+        for tag in sorted(tags.values(), key=lambda x: x.code):
+            value = tag.__str__()
+            tlines.append(value[:TIFF.PRINT_LINE_WIDTH].lstrip())
+            if detail > 1 and len(value) > TIFF.PRINT_LINE_WIDTH:
+                vlines.append("%s\n%s" % (tag.name.upper(),
+                                          pformat(tag.value)))
+        info.append('\n'.join(tlines))
+        if detail > 1:
+            info.append('\n\n'.join(vlines))
+        return '\n\n'.join(info)
 
     @lazyattr
-    def imagej_tags(self):
-        """Consolidate ImageJ metadata."""
-        if not self.is_imagej:
-            raise AttributeError("imagej_tags")
-        result = imagej_description_dict(self.is_imagej)
-        if 'imagej_metadata' in self.tags:
-            try:
-                result.update(imagej_metadata(
-                    self.tags['imagej_metadata'].value,
-                    self.tags['imagej_byte_counts'].value,
-                    self.parent.byteorder))
-            except Exception as e:
-                warnings.warn(str(e))
-        return Record(result)
+    def flags(self):
+        """Return set of flags."""
+        return set((name.lower() for name in sorted(TIFF.FILE_FLAGS)
+                    if getattr(self, 'is_' + name)))
 
-    @lazyattr
-    def is_rgb(self):
-        """Page contains a RGB image."""
-        return ('photometric' in self.tags and
-                self.tags['photometric'].value == 2)
+    @property
+    def ndim(self):
+        """Return number of array dimensions."""
+        return len(self.shape)
 
-    @lazyattr
-    def is_contig(self):
-        """Page contains contiguous image."""
-        if 'planar_configuration' in self.tags:
-            return self.tags['planar_configuration'].value == 1
-        return True
+    @property
+    def size(self):
+        """Return number of elements in array."""
+        return product(self.shape)
 
     @lazyattr
-    def is_indexed(self):
-        """Page contains indexed, palette-colored image.
+    def andor_tags(self):
+        """Return consolidated metadata from Andor tags as dict.
 
-        Disable color-mapping for OME, LSM, STK, and ImageJ hyperstacks.
+        Remove Andor tags from self.tags.
 
         """
-        if (self.is_stk or self.is_lsm or self.parent.is_lsm or
-                self.is_ome or self.parent.is_ome):
-            return False
-        if self.is_imagej:
-            if b'mode' in self.is_imagej:
-                return False
-        elif self.parent.is_imagej:
-            return self.parent.is_indexed
-        return ('photometric' in self.tags and
-                self.tags['photometric'].value == 3)
+        if not self.is_andor:
+            return
+        tags = self.tags
+        result = {'Id': tags['AndorId'].value}
+        for tag in list(self.tags.values()):
+            code = tag.code
+            if not 4864 < code < 5031:
+                continue
+            value = tag.value
+            name = tag.name[5:] if len(tag.name) > 5 else tag.name
+            result[name] = value
+            del tags[tag.name]
+        return result
 
     @lazyattr
+    def epics_tags(self):
+        """Return consolidated metadata from EPICS areaDetector tags as dict.
+
+        Remove areaDetector tags from self.tags.
+
+        """
+        # TODO: obtain test file
+        if not self.is_epics:
+            return
+        result = {}
+        tags = self.tags
+        for tag in list(self.tags.values()):
+            code = tag.code
+            if not 65000 < code < 65500:
+                continue
+            value = tag.value
+            if code == 65000:
+                result['timeStamp'] = float(value)
+            elif code == 65001:
+                result['uniqueID'] = int(value)
+            elif code == 65002:
+                result['epicsTS'] = int(value)
+            elif code == 65003:
+                result['epicsTS'] = int(value)
+            else:
+                key, value = value.split(':')
+                result[key] = astype(value)
+            del tags[tag.name]
+        return result
+
+    @property
     def is_tiled(self):
         """Page contains tiled image."""
-        return 'tile_width' in self.tags
+        return 'TileWidth' in self.tags
 
-    @lazyattr
+    @property
     def is_reduced(self):
         """Page is reduced image of another image."""
-        return ('new_subfile_type' in self.tags and
-                self.tags['new_subfile_type'].value & 1)
+        return ('NewSubfileType' in self.tags and
+                self.tags['NewSubfileType'].value & 1)
 
-    @lazyattr
+    @property
     def is_chroma_subsampled(self):
         """Page contains chroma subsampled image."""
-        return ('ycbcr_subsampling' in self.tags and
-                self.tags['ycbcr_subsampling'].value != (1, 1))
+        return ('YCbCrSubSampling' in self.tags and
+                self.tags['YCbCrSubSampling'].value != (1, 1))
 
     @lazyattr
-    def is_mdgel(self):
-        """Page contains md_file_tag tag."""
-        return 'md_file_tag' in self.tags
+    def is_imagej(self):
+        """Return ImageJ description if exists, else None."""
+        for description in (self.description, self.description1):
+            if not description:
+                return
+            if description[:7] == 'ImageJ=':
+                return description
 
     @lazyattr
+    def is_shaped(self):
+        """Return description containing array shape if exists, else None."""
+        for description in (self.description, self.description1):
+            if not description:
+                return
+            if description[:1] == '{' and '"shape":' in description:
+                return description
+            if description[:6] == 'shape=':
+                return description
+
+    @property
+    def is_mdgel(self):
+        """Page contains MDFileTag tag."""
+        return 'MDFileTag' in self.tags
+
+    @property
     def is_mediacy(self):
         """Page contains Media Cybernetics Id tag."""
-        return ('mc_id' in self.tags and
-                self.tags['mc_id'].value.startswith(b'MC TIFF'))
+        return ('MC_Id' in self.tags and
+                self.tags['MC_Id'].value[:7] == b'MC TIFF')
 
-    @lazyattr
+    @property
     def is_stk(self):
         """Page contains UIC2Tag tag."""
-        return 'uic2tag' in self.tags
+        return 'UIC2tag' in self.tags
 
-    @lazyattr
+    @property
     def is_lsm(self):
-        """Page contains LSM CZ_LSM_INFO tag."""
-        return 'cz_lsm_info' in self.tags
+        """Page contains CZ_LSMINFO tag."""
+        return 'CZ_LSMINFO' in self.tags
 
-    @lazyattr
+    @property
     def is_fluoview(self):
         """Page contains FluoView MM_STAMP tag."""
-        return 'mm_stamp' in self.tags
+        return 'MM_Stamp' in self.tags
 
-    @lazyattr
+    @property
     def is_nih(self):
         """Page contains NIH image header."""
-        return 'nih_image_header' in self.tags
+        return 'NIHImageHeader' in self.tags
 
-    @lazyattr
+    @property
     def is_sgi(self):
         """Page contains SGI image and tile depth tags."""
-        return 'image_depth' in self.tags and 'tile_depth' in self.tags
+        return 'ImageDepth' in self.tags and 'TileDepth' in self.tags
 
-    @lazyattr
+    @property
     def is_vista(self):
         """Software tag is 'ISS Vista'."""
-        return ('software' in self.tags and
-                self.tags['software'].value == b'ISS Vista')
+        return self.software == 'ISS Vista'
 
-    @lazyattr
+    @property
+    def is_metaseries(self):
+        """Page contains MDS MetaSeries metadata in ImageDescription tag."""
+        if self.index > 1 or self.software != 'MetaSeries':
+            return False
+        d = self.description
+        return d.startswith('<MetaData>') and d.endswith('</MetaData>')
+
+    @property
     def is_ome(self):
-        """Page contains OME-XML in image_description tag."""
-        if 'image_description' not in self.tags:
+        """Page contains OME-XML in ImageDescription tag."""
+        if self.index > 1 or not self.description:
             return False
-        d = self.tags['image_description'].value.strip()
-        return d.startswith(b'<?xml version=') and d.endswith(b'</OME>')
+        d = self.description
+        return d[:14] == '<?xml version=' and d[-6:] == '</OME>'
 
-    @lazyattr
+    @property
     def is_scn(self):
-        """Page contains Leica SCN XML in image_description tag."""
-        if 'image_description' not in self.tags:
+        """Page contains Leica SCN XML in ImageDescription tag."""
+        if self.index > 1 or not self.description:
             return False
-        d = self.tags['image_description'].value.strip()
-        return d.startswith(b'<?xml version=') and d.endswith(b'</scn>')
-
-    @lazyattr
-    def is_shaped(self):
-        """Return description containing shape if exists, else None."""
-        if 'image_description' in self.tags:
-            description = self.tags['image_description'].value
-            if b'"shape":' in description or b'shape=(' in description:
-                return description
-        if 'image_description_1' in self.tags:
-            description = self.tags['image_description_1'].value
-            if b'"shape":' in description or b'shape=(' in description:
-                return description
-
-    @lazyattr
-    def is_imagej(self):
-        """Return ImageJ description if exists, else None."""
-        if 'image_description' in self.tags:
-            description = self.tags['image_description'].value
-            if description.startswith(b'ImageJ='):
-                return description
-        if 'image_description_1' in self.tags:
-            # Micromanager
-            description = self.tags['image_description_1'].value
-            if description.startswith(b'ImageJ='):
-                return description
+        d = self.description
+        return d[:14] == '<?xml version=' and d[-6:] == '</scn>'
 
-    @lazyattr
+    @property
     def is_micromanager(self):
         """Page contains Micro-Manager metadata."""
-        return 'micromanager_metadata' in self.tags
+        return 'MicroManagerMetadata' in self.tags
 
-    @lazyattr
+    @property
+    def is_andor(self):
+        """Page contains Andor Technology tags."""
+        return 'AndorId' in self.tags
+
+    @property
+    def is_pilatus(self):
+        """Page contains Pilatus tags."""
+        return (self.software[:8] == 'TVX TIFF' and
+                self.description[:2] == '# ')
+
+    @property
+    def is_epics(self):
+        """Page contains EPICS areaDetector tags."""
+        return self.description == 'EPICS areaDetector'
+
+    @property
     def is_tvips(self):
         """Page contains TVIPS metadata."""
-        return 'tvips_metadata' in self.tags
+        return 'TVIPS' in self.tags
 
-    @lazyattr
+    @property
     def is_fei(self):
         """Page contains SFEG or HELIOS metadata."""
-        return 'sfeg_metadata' in self.tags or 'helios_metadata' in self.tags
+        return 'FEI_SFEG' in self.tags or 'FEI_HELIOS' in self.tags
 
-    @lazyattr
+    @property
     def is_sem(self):
         """Page contains Zeiss SEM metadata."""
-        return 'sem_metadata' in self.tags
+        return 'CZ_SEM' in self.tags
+
+    @property
+    def is_svs(self):
+        """Page contains Aperio metadata."""
+        return self.description[:20] == 'Aperio Image Library'
+
+    @property
+    def is_scanimage(self):
+        """Page contains ScanImage metadata."""
+        return (self.description[:12] == 'state.config' or
+                self.software[:22] == 'SI.LINE_FORMAT_VERSION')
+
+
+class TiffFrame(object):
+    """Lightweight TIFF image file directory (IFD).
+
+    Only a limited number of tag values are read from file, e.g. StripOffsets,
+    and StripByteCounts. Other tag values are assumed to be identical with a
+    specified TiffPage instance, the keyframe.
+
+    This is intended to reduce resource usage and speed up reading data from
+    file, not for introspection of metadata.
+
+    Not compatible with Python 2.
+
+    """
+    __slots__ = ('keyframe', 'parent', 'index', 'offset',
+                 'dataoffsets', 'databytecounts')
+
+    is_mdgel = False
+    tags = {}
+
+    def __init__(self, parent, index, keyframe):
+        """Read specified tags from file.
+
+        The file handle position must be at the offset to a valid IFD.
+
+        """
+        self.keyframe = keyframe
+        self.parent = parent
+        self.index = index
+
+        unpack = struct.unpack
+        fh = parent.filehandle
+        self.offset = fh.tell()
+        try:
+            tagno = unpack(parent.tagnoformat, fh.read(parent.tagnosize))[0]
+            if tagno > 4096:
+                raise ValueError("suspicious number of tags")
+        except Exception:
+            raise ValueError("corrupted page list at offset %i" % self.offset)
+
+        # tags = {}
+        tagcodes = {273, 279, 324, 325}  # TIFF.FRAME_TAGS
+        tagsize = parent.tagsize
+        codeformat = parent.tagformat1[:2]
+
+        data = fh.read(tagsize * tagno)
+        index = -tagsize
+        for _ in range(tagno):
+            index += tagsize
+            code = unpack(codeformat, data[index:index+2])[0]
+            if code not in tagcodes:
+                continue
+            try:
+                tag = TiffTag(parent, data[index:index+tagsize])
+            except TiffTag.Error as e:
+                warnings.warn(str(e))
+                continue
+            if code == 273 or code == 324:
+                setattr(self, 'dataoffsets', tag.value)
+            elif code == 279 or code == 325:
+                setattr(self, 'databytecounts', tag.value)
+            # elif code == 270:
+            #     tagname = tag.name
+            #     if tagname not in tags:
+            #         tags[tagname] = bytes2str(tag.value)
+            #     elif 'ImageDescription1' not in tags:
+            #         tags['ImageDescription1'] = bytes2str(tag.value)
+            # else:
+            #     tags[tag.name] = tag.value
+
+    def aspage(self):
+        """Return TiffPage from file."""
+        self.parent.filehandle.seek(self.offset)
+        return TiffPage(self.parent, index=self.index, keyframe=None)
+
+    def asarray(self, *args, **kwargs):
+        """Read image data from file and return as numpy array."""
+        # TODO: fix TypeError on Python 2
+        #   "TypeError: unbound method asarray() must be called with TiffPage
+        #   instance as first argument (got TiffFrame instance instead)"
+        kwargs['validate'] = False
+        return TiffPage.asarray(self, *args, **kwargs)
+
+    def asrgb(self, *args, **kwargs):
+        """Read image data from file and return RGB image as numpy array."""
+        kwargs['validate'] = False
+        return TiffPage.asrgb(self, *args, **kwargs)
+
+    @property
+    def offsets_bytecounts(self):
+        """Return simplified offsets and bytecounts."""
+        if self.keyframe.is_contiguous:
+            return self.dataoffsets[:1], self.keyframe.is_contiguous[1:]
+        return clean_offsets_counts(self.dataoffsets, self.databytecounts)
+
+    @property
+    def is_contiguous(self):
+        """Return offset and size of contiguous data, else None."""
+        if self.keyframe.is_contiguous:
+            return self.dataoffsets[0], self.keyframe.is_contiguous[1]
+
+    @property
+    def is_memmappable(self):
+        """Return if page's image data in file can be memory-mapped."""
+        return self.keyframe.is_memmappable
+
+    def __getattr__(self, name):
+        """Return attribute from keyframe."""
+        if name in TIFF.FRAME_ATTRS:
+            return getattr(self.keyframe, name)
+        raise AttributeError("'%s' object has no attribute '%s'" %
+                             (self.__class__.__name__, name))
+
+    def __str__(self, detail=0):
+        """Return string containing information about frame."""
+        info = '  '.join(s for s in (
+            'x'.join(str(i) for i in self.shape),
+            str(self.dtype)))
+        return "TiffFrame %i @%i  %s" % (self.index, self.offset, info)
 
 
 class TiffTag(object):
-    """A TIFF tag structure.
+    """TIFF tag structure.
 
     Attributes
     ----------
     name : string
-        Attribute name of tag.
+        Name of tag.
     code : int
         Decimal code of tag.
     dtype : str
-        Datatype of tag data. One of TIFF_DATA_TYPES.
+        Datatype of tag data. One of TIFF DATA_FORMATS.
     count : int
         Number of values.
     value : various types
         Tag data as Python object.
-    value_offset : int
-        Location of value in file, if any.
+    valueoffset : int
+        Location of value in file.
 
     All attributes are read-only.
 
     """
-    __slots__ = ('code', 'name', 'count', 'dtype', 'value', 'value_offset',
-                 '_offset', '_value', '_type')
+    __slots__ = ('code', 'count', 'dtype', 'value', 'valueoffset')
 
     class Error(Exception):
         pass
 
-    def __init__(self, arg, **kwargs):
-        """Initialize instance from file or arguments."""
-        self._offset = None
-        if hasattr(arg, '_fh'):
-            self._fromfile(arg, **kwargs)
-        else:
-            self._fromdata(arg, **kwargs)
-
-    def _fromdata(self, code, dtype, count, value, name=None):
-        """Initialize instance from arguments."""
-        self.code = int(code)
-        self.name = name if name else str(code)
-        self.dtype = TIFF_DATA_TYPES[dtype]
-        self.count = int(count)
-        self.value = value
-        self._value = value
-        self._type = dtype
-
-    def _fromfile(self, parent):
-        """Read tag structure from open file. Advance file cursor."""
+    def __init__(self, parent, tagheader, **kwargs):
+        """Initialize instance from tag header."""
         fh = parent.filehandle
         byteorder = parent.byteorder
-        self._offset = fh.tell()
-        self.value_offset = self._offset + parent.offset_size + 4
-
-        fmt, size = {4: ('HHI4s', 12), 8: ('HHQ8s', 20)}[parent.offset_size]
-        data = fh.read(size)
-        code, dtype = struct.unpack(byteorder + fmt[:2], data[:4])
-        count, value = struct.unpack(byteorder + fmt[2:], data[4:])
-        self._value = value
-        self._type = dtype
-
-        if code in TIFF_TAGS:
-            name, _, _, cout_, _ = TIFF_TAGS[code]
-            if cout_ and cout_ != count:
-                count = cout_
-                warnings.warn("incorrect count for tag '%s'" % name)
-        elif code in CUSTOM_TAGS:
-            name = CUSTOM_TAGS[code][0]
-        else:
-            name = str(code)
+        unpack = struct.unpack
+        offsetsize = parent.offsetsize
+
+        self.valueoffset = fh.tell() + offsetsize + 4
+        code, dtype = unpack(parent.tagformat1, tagheader[:4])
+        count, value = unpack(parent.tagformat2, tagheader[4:])
 
         try:
-            dtype = TIFF_DATA_TYPES[self._type]
+            dtype = TIFF.DATA_FORMATS[dtype]
         except KeyError:
-            raise TiffTag.Error("unknown tag data type %i" % self._type)
+            raise TiffTag.Error("unknown tag data type %i" % dtype)
 
-        fmt = '%s%i%s' % (byteorder, count*int(dtype[0]), dtype[1])
+        fmt = '%s%i%s' % (byteorder, count * int(dtype[0]), dtype[1])
         size = struct.calcsize(fmt)
-        if size > parent.offset_size or code in CUSTOM_TAGS:
-            pos = fh.tell()
-            tof = {4: 'I', 8: 'Q'}[parent.offset_size]
-            self.value_offset = offset = struct.unpack(byteorder+tof, value)[0]
-            if offset < 0 or offset > parent.filehandle.size:
-                raise TiffTag.Error("corrupt file - invalid tag value offset")
-            elif offset < 4:
-                raise TiffTag.Error("corrupt value offset for tag %i" % code)
+        if size > offsetsize or code in TIFF.TAG_READERS:
+            self.valueoffset = offset = unpack(parent.offsetformat, value)[0]
+            if offset < 8 or offset > fh.size - size:
+                raise TiffTag.Error("invalid tag value offset")
+            # if offset % 2:
+            #     warnings.warn("tag value does not begin on word boundary")
             fh.seek(offset)
-            if code in CUSTOM_TAGS:
-                readfunc = CUSTOM_TAGS[code][1]
-                value = readfunc(fh, byteorder, dtype, count)
-                if isinstance(value, dict):  # numpy.core.records.record
-                    value = Record(value)
-            elif code in TIFF_TAGS or dtype[-1] == 's':
-                value = struct.unpack(fmt, fh.read(size))
+            if code in TIFF.TAG_READERS:
+                readfunc = TIFF.TAG_READERS[code]
+                value = readfunc(fh, byteorder, dtype, count, offsetsize)
+            elif code in TIFF.TAGS or dtype[-1] == 's':
+                value = unpack(fmt, fh.read(size))
             else:
-                value = read_numpy(fh, byteorder, dtype, count)
-            fh.seek(pos)
+                value = read_numpy(fh, byteorder, dtype, count, offsetsize)
         else:
-            value = struct.unpack(fmt, value[:size])
-
-        if code not in CUSTOM_TAGS and code not in (
-                273, 279, 324, 325, 530, 531):
-            # scalar value if not strip/tile offsets/byte_counts or subsampling
-            if len(value) == 1:
-                value = value[0]
+            value = unpack(fmt, value[:size])
 
-        if (dtype.endswith('s') and isinstance(value, bytes) and
-                self._type != 7):
+        process = code not in TIFF.TAG_READERS and code not in TIFF.TAG_TUPLE
+        if process and dtype[-1] == 's' and isinstance(value[0], bytes):
             # TIFF ASCII fields can contain multiple strings,
             #   each terminated with a NUL
-            value = stripascii(value)
+            value = bytes2str(stripascii(value[0]).strip())
+        else:
+            if code in TIFF.TAG_ENUM:
+                t = TIFF.TAG_ENUM[code]
+                try:
+                    value = tuple(t(v) for v in value)
+                except ValueError as e:
+                    warnings.warn(str(e))
+            if process:
+                if len(value) == 1:
+                    value = value[0]
 
         self.code = code
-        self.name = name
         self.dtype = dtype
         self.count = count
         self.value = value
 
+    @property
+    def name(self):
+        return TIFF.TAGS.get(self.code, str(self.code))
+
     def _fix_lsm_bitspersample(self, parent):
         """Correct LSM bitspersample tag.
 
@@ -3092,20 +3939,30 @@ class TiffTag(object):
         if self.code == 258 and self.count == 2:
             # TODO: test this case; need example file
             warnings.warn("correcting LSM bitspersample tag")
-            fh = parent.filehandle
-            tof = {4: '<I', 8: '<Q'}[parent.offset_size]
-            self.value_offset = struct.unpack(tof, self._value)[0]
-            fh.seek(self.value_offset)
-            self.value = struct.unpack("<HH", fh.read(4))
-
-    def as_str(self):
-        """Return value as human readable string."""
-        return ((str(self.value).split('\n', 1)[0]) if (self._type != 7)
-                else '<undefined>')
+            tof = parent.offsetformat[parent.offsetsize]
+            self.valueoffset = struct.unpack(tof, self._value)[0]
+            parent.filehandle.seek(self.valueoffset)
+            self.value = struct.unpack("<HH", parent.filehandle.read(4))
 
     def __str__(self):
         """Return string containing information about tag."""
-        return ' '.join(str(getattr(self, s)) for s in self.__slots__)
+        if self.code in TIFF.TAG_ENUM:
+            if self.count == 1:
+                value = TIFF.TAG_ENUM[self.code](self.value).name
+            else:
+                value = tuple(v.name for v in self.value)
+        elif isinstance(self.value, unicode):
+                value = pformat(self.value)
+                value = value.replace(u'\n', u'\\n').replace(u'\r', u'')
+                value = u'"%s"' % value
+        else:
+            value = pformat(self.value, linewidth=False, maxlines=2)
+            value = str(value).split('\n', 1)[0]
+
+        tcode = "%i%s" % (self.count * int(self.dtype[0]), self.dtype[1])
+        line = "TiffTag %i %s  %s @%i  %s" % (
+            self.code, self.name, tcode, self.valueoffset, value)
+        return line
 
 
 class TiffPageSeries(object):
@@ -3125,48 +3982,43 @@ class TiffPageSeries(object):
         Position of image data in file if memory-mappable, else None.
 
     """
-    #__slots__ = 'pages', 'shape', 'dtype', 'axes', 'parent'
-
-    def __init__(self, pages, shape, dtype, axes, parent=None):
-        # TODO? sort pages by page number?
+    def __init__(self, pages, shape, dtype, axes,
+                 parent=None, name=None, transform=None, stype=None):
+        """Initialize instance."""
         self.index = 0
         self.pages = pages
         self.shape = tuple(shape)
         self.axes = ''.join(axes)
         self.dtype = numpy.dtype(dtype)
+        self.stype = stype if stype else ''
+        self.name = name if name else ''
+        self.transform = transform
         if parent:
             self.parent = parent
-        elif len(pages):
+        elif pages:
             self.parent = pages[0].parent
         else:
             self.parent = None
 
-    def asarray(self, memmap=False):
-        """Return image data from series of TIFF pages as numpy array.
-
-        Parameters
-        ----------
-        memmap : bool
-            If True, return an array stored in a binary file on disk
-            if possible.
-
-        """
+    def asarray(self, out=None):
+        """Return image data from series of TIFF pages as numpy array."""
         if self.parent:
-            return self.parent.asarray(series=self, memmap=memmap)
+            result = self.parent.asarray(series=self, out=out)
+            if self.transform is not None:
+                result = self.transform(result)
+            return result
 
     @lazyattr
     def offset(self):
-        """Return offset to memory-mappable data in page series."""
-        if len(self.pages) == 0:
+        """Return offset to series data in file, if any."""
+        if not self.pages:
             return
 
-        rgbonly = False
-        colormapped = self.pages[0].is_indexed
         pos = 0
         for page in self.pages:
             if page is None:
                 return
-            if not page._is_memmappable(rgbonly, colormapped):
+            if not page.is_final:
                 return
             if not pos:
                 pos = page.is_contiguous[0] + page.is_contiguous[1]
@@ -3175,11 +4027,23 @@ class TiffPageSeries(object):
                 return
             pos += page.is_contiguous[1]
 
-        offset = self.pages[0].is_contiguous[0]
-        if (pos != offset + product(self.shape) * self.dtype.itemsize and
-                not self.pages[0].is_imagej):
-            return
-        return offset
+        page = self.pages[0]
+        offset = page.is_contiguous[0]
+        if (page.is_imagej or page.is_shaped) and len(self.pages) == 1:
+            # truncated files
+            return offset
+        if pos == offset + product(self.shape) * self.dtype.itemsize:
+            return offset
+
+    @property
+    def ndim(self):
+        """Return number of array dimensions."""
+        return len(self.shape)
+
+    @property
+    def size(self):
+        """Return number of elements in array."""
+        return int(product(self.shape))
 
     def __len__(self):
         """Return number of TiffPages in series."""
@@ -3195,38 +4059,44 @@ class TiffPageSeries(object):
 
     def __str__(self):
         """Return string with information about series."""
-        s = ', '.join(s for s in (
+        s = '  '.join(s for s in (
+            snipstr("'%s'" % self.name, 20) if self.name else '',
             'x'.join(str(i) for i in self.shape),
-            str(numpy.dtype(self.dtype)),
+            str(self.dtype),
             self.axes,
-            '%i pages' % len(self.pages),
-            ('memmap-offset=%i' % self.offset) if self.offset else
-            'not mem-mappable'))
-        return 'Series %i: %s' % (self.index, s)
+            self.stype,
+            '%i Pages' % len(self.pages),
+            ('Offset=%i' % self.offset) if self.offset else '') if s)
+        return 'TiffPageSeries %i  %s' % (self.index, s)
 
 
 class TiffSequence(object):
-    """Sequence of image files.
+    """Sequence of TIFF files.
 
-    The data shape and dtype of all files must match.
+    The image data in all files must match shape, dtype, etc.
 
     Attributes
     ----------
     files : list
         List of file names.
     shape : tuple
-        Shape of image sequence.
+        Shape of image sequence. Excludes shape of image array.
     axes : str
         Labels of axes in shape.
 
     Examples
     --------
-    >>> tifs = TiffSequence("test.oif.files/*.tif")
-    >>> tifs.shape, tifs.axes
-    ((2, 100), 'CT')
+    >>> # read image stack from sequence of TIFF files
+    >>> imsave('temp_C001T001.tif', numpy.random.rand(64, 64))
+    >>> imsave('temp_C001T002.tif', numpy.random.rand(64, 64))
+    >>> tifs = TiffSequence("temp_C001*.tif")
+    >>> tifs.shape
+    (1, 2)
+    >>> tifs.axes
+    'CT'
     >>> data = tifs.asarray()
     >>> data.shape
-    (2, 100, 256, 256)
+    (1, 2, 64, 64)
 
     """
     _patterns = {
@@ -3259,7 +4129,7 @@ class TiffSequence(object):
         pattern : str
             Regular expression pattern that matches axes names and sequence
             indices in file names.
-            By default this matches Olympus OIF and Leica TIFF series.
+            By default, this matches Olympus OIF and Leica TIFF series.
 
         """
         if isinstance(files, basestring):
@@ -3269,8 +4139,6 @@ class TiffSequence(object):
             raise ValueError("no files found")
         if not isinstance(files[0], basestring):
             raise ValueError("not a file name")
-        #if not os.path.isfile(files[0]):
-        #    raise ValueError("file not found")
         self.files = files
 
         if hasattr(imread, 'asarray'):
@@ -3291,16 +4159,16 @@ class TiffSequence(object):
         except self.ParseError:
             self.axes = 'I'
             self.shape = (len(files),)
-            self._start_index = (0,)
+            self._startindex = (0,)
             self._indices = tuple((i,) for i in range(len(files)))
 
     def __str__(self):
         """Return string with information about image sequence."""
         return "\n".join([
             self.files[0],
-            '* files: %i' % len(self.files),
-            '* axes: %s' % self.axes,
-            '* shape: %s' % str(self.shape)])
+            ' size: %i' % len(self.files),
+            ' axes: %s' % self.axes,
+            ' shape: %s' % str(self.shape)])
 
     def __len__(self):
         return len(self.files)
@@ -3314,10 +4182,9 @@ class TiffSequence(object):
     def close(self):
         pass
 
-    def asarray(self, memmap=False, tempdir=None, *args, **kwargs):
-        """Read image data from all files and return as single numpy array.
+    def asarray(self, out=None, *args, **kwargs):
+        """Read image data from all files and return as numpy array.
 
-        If memmap is True, return an array stored in a binary file on disk.
         The args and kwargs parameters are passed to the imread function.
 
         Raise IndexError or ValueError if image shapes do not match.
@@ -3325,14 +4192,10 @@ class TiffSequence(object):
         """
         im = self.imread(self.files[0], *args, **kwargs)
         shape = self.shape + im.shape
-        if memmap:
-            with tempfile.NamedTemporaryFile(dir=tempdir) as fh:
-                result = numpy.memmap(fh, dtype=im.dtype, shape=shape)
-        else:
-            result = numpy.zeros(shape, dtype=im.dtype)
+        result = create_output(out, shape, dtype=im.dtype)
         result = result.reshape(-1, *im.shape)
         for index, fname in zip(self._indices, self.files):
-            index = [i-j for i, j in zip(index, self._start_index)]
+            index = [i-j for i, j in zip(index, self._startindex)]
             index = numpy.ravel_multi_index(index, self.shape)
             im = self.imread(fname, *args, **kwargs)
             result[index] = im
@@ -3361,88 +4224,15 @@ class TiffSequence(object):
                 raise ValueError("axes do not match within the image sequence")
             indices.append([int(m) for m in matches[1::2] if m])
         shape = tuple(numpy.max(indices, axis=0))
-        start_index = tuple(numpy.min(indices, axis=0))
-        shape = tuple(i-j+1 for i, j in zip(shape, start_index))
+        startindex = tuple(numpy.min(indices, axis=0))
+        shape = tuple(i-j+1 for i, j in zip(shape, startindex))
         if product(shape) != len(self.files):
             warnings.warn("files are missing. Missing data are zeroed")
 
         self.axes = axes.upper()
         self.shape = shape
         self._indices = indices
-        self._start_index = start_index
-
-
-class Record(dict):
-    """Dictionary with attribute access.
-
-    Can also be initialized with numpy.core.records.record.
-
-    """
-    __slots__ = ()
-
-    def __init__(self, arg=None, **kwargs):
-        if kwargs:
-            arg = kwargs
-        elif arg is None:
-            arg = {}
-        try:
-            dict.__init__(self, arg)
-        except (TypeError, ValueError):
-            # numpy records
-            for i, name in enumerate(arg.dtype.names):
-                v = arg[i]
-                self[name] = v if v.dtype.char != 'S' else stripnull(v)
-
-    def __getattr__(self, name):
-        return self[name]
-
-    def __setattr__(self, name, value):
-        self.__setitem__(name, value)
-
-    def __str__(self):
-        """Pretty print Record."""
-        s = []
-        lists = []
-        for k in sorted(self):
-            try:
-                if k.startswith('_'):  # does not work with byte
-                    continue
-            except AttributeError:
-                pass
-            v = self[k]
-            if isinstance(v, (list, tuple)) and len(v):
-                if isinstance(v[0], Record):
-                    lists.append((k, v))
-                    continue
-                elif isinstance(v[0], TiffPage):
-                    v = [i.index for i in v if i]
-            elif isinstance(v, Record):
-                s.append(("* %s:\n%s" % (k, str(v).replace('*', ' *'))))
-                continue
-            s.append(
-                ("* %s: %s" % (k, str(v))).split("\n", 1)[0]
-                [:PRINT_LINE_LEN].rstrip())
-        for k, v in lists:
-            l = []
-            for i, w in enumerate(v):
-                l.append("* %s[%i]\n  %s" % (k, i,
-                                             str(w).replace("\n", "\n  ")))
-            s.append('\n'.join(l))
-        return '\n'.join(s)
-
-
-class TiffTags(Record):
-    """Dictionary of TiffTag with attribute access."""
-
-    def __str__(self):
-        """Return string with information about all tags."""
-        s = []
-        for tag in sorted(self.values(), key=lambda x: x.code):
-            typecode = "%i%s" % (tag.count * int(tag.dtype[0]), tag.dtype[1])
-            line = "* %i %s (%s) %s" % (
-                tag.code, tag.name, typecode, tag.as_str())
-            s.append(line[:PRINT_LINE_LEN].lstrip())
-        return '\n'.join(s)
+        self._startindex = startindex
 
 
 class FileHandle(object):
@@ -3474,7 +4264,7 @@ class FileHandle(object):
     All attributes are read-only.
 
     """
-    __slots__ = ('_fh', '_file', '_mode', '_name', '_dir',
+    __slots__ = ('_fh', '_file', '_mode', '_name', '_dir', '_lock',
                  '_offset', '_size', '_close', 'is_file')
 
     def __init__(self, file, mode='rb', name=None, offset=None, size=None):
@@ -3490,10 +4280,10 @@ class FileHandle(object):
         name : str
             Optional name of file in case 'file' is a binary stream.
         offset : int
-            Optional start position of embedded file. By default this is
+            Optional start position of embedded file. By default, this is
             the current file position.
         size : int
-            Optional size of embedded file. By default this is the number
+            Optional size of embedded file. By default, this is the number
             of bytes from the 'offset' to the end of the file.
 
         """
@@ -3506,6 +4296,7 @@ class FileHandle(object):
         self._size = size
         self._close = True
         self.is_file = False
+        self._lock = NullContext()
         self.open()
 
     def open(self):
@@ -3598,37 +4389,64 @@ class FileHandle(object):
                             offset=self._offset + offset,
                             shape=shape, order=order)
 
-    def read_array(self, dtype, count=-1, sep=""):
+    def read_array(self, dtype, count=-1, sep="", chunksize=2**25, out=None):
         """Return numpy array from file.
 
         Work around numpy issue #2230, "numpy.fromfile does not accept
         StringIO object" https://github.com/numpy/numpy/issues/2230.
 
         """
-        try:
-            return numpy.fromfile(self._fh, dtype, count, sep)
-        except IOError:
-            if count < 0:
-                size = self._size
-            else:
-                size = count * numpy.dtype(dtype).itemsize
-            data = self._fh.read(size)
-            return numpy.fromstring(data, dtype, count, sep)
+        fh = self._fh
+        dtype = numpy.dtype(dtype)
+        size = self._size if count < 0 else count * dtype.itemsize
+
+        if out is None:
+            try:
+                return numpy.fromfile(fh, dtype, count, sep)
+            except IOError:
+                # ByteIO
+                data = fh.read(size)
+                return numpy.fromstring(data, dtype, count, sep)
+
+        # Read data from file in chunks and copy to output array
+        shape = out.shape
+        size = min(out.nbytes, size)
+        out = out.reshape(-1)
+        index = 0
+        while size > 0:
+            data = fh.read(min(chunksize, size))
+            datasize = len(data)
+            if datasize == 0:
+                break
+            size -= datasize
+            data = numpy.fromstring(data, dtype)
+            out[index:index+data.size] = data
+            index += data.size
+
+        if hasattr(out, 'flush'):
+            out.flush()
+        return out.reshape(shape)
 
     def read_record(self, dtype, shape=1, byteorder=None):
         """Return numpy record from file."""
+        rec = numpy.rec
         try:
-            rec = numpy.rec.fromfile(self._fh, dtype, shape,
-                                     byteorder=byteorder)
+            record = rec.fromfile(self._fh, dtype, shape, byteorder=byteorder)
         except Exception:
             dtype = numpy.dtype(dtype)
             if shape is None:
                 shape = self._size // dtype.itemsize
             size = product(sequence(shape)) * dtype.itemsize
             data = self._fh.read(size)
-            return numpy.rec.fromstring(data, dtype, shape,
-                                        byteorder=byteorder)
-        return rec[0] if shape == 1 else rec
+            record = rec.fromstring(data, dtype, shape, byteorder=byteorder)
+        return record[0] if shape == 1 else record
+
+    def write_empty(self, size):
+        """Append size bytes to file. Position must be at end of file."""
+        if size < 1:
+            return
+        self._fh.seek(size-1, 1)
+        self._fh.write(b'\x00')
 
     def write_array(self, data):
         """Write numpy array to binary file."""
@@ -3692,20 +4510,1832 @@ class FileHandle(object):
     def closed(self):
         return self._fh is None
 
+    @property
+    def lock(self):
+        return self._lock
+
+    @lock.setter
+    def lock(self, value):
+        self._lock = threading.RLock() if value else NullContext()
+
+
+class NullContext(object):
+    """Null context manager.
+
+    >>> with NullContext():
+    ...     pass
+
+    """
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        pass
+
+
+class OpenFileCache(object):
+    """Keep files open."""
+
+    __slots__ = ('files', 'past', 'lock', 'size')
+
+    def __init__(self, size, lock=None):
+        """Initialize open file cache."""
+        self.past = []  # FIFO of opened files
+        self.files = {}  # refcounts of opened files
+        self.lock = NullContext() if lock is None else lock
+        self.size = int(size)
+
+    def open(self, filehandle):
+        """Re-open file if necessary."""
+        with self.lock:
+            if filehandle in self.files:
+                self.files[filehandle] += 1
+            elif filehandle.closed:
+                filehandle.open()
+                self.files[filehandle] = 1
+                self.past.append(filehandle)
+
+    def close(self, filehandle):
+        """Close openend file if no longer used."""
+        with self.lock:
+            if filehandle in self.files:
+                self.files[filehandle] -= 1
+                # trim the file cache
+                index = 0
+                size = len(self.past)
+                while size > self.size and index < size:
+                    filehandle = self.past[index]
+                    if self.files[filehandle] == 0:
+                        filehandle.close()
+                        del self.files[filehandle]
+                        del self.past[index]
+                        size -= 1
+                    else:
+                        index += 1
+
+    def clear(self):
+        """Close all opened files if not in use."""
+        with self.lock:
+            for filehandle, refcount in list(self.files.items()):
+                if refcount == 0:
+                    filehandle.close()
+                    del self.files[filehandle]
+                    del self.past[self.past.index(filehandle)]
+
+
+class LazyConst(object):
+    """Class whose attributes are computed on first access from its methods."""
+    def __init__(self, cls):
+        self._cls = cls
+        self.__doc__ = getattr(cls, '__doc__')
+
+    def __getattr__(self, name):
+        func = getattr(self._cls, name)
+        if not callable(func):
+            return func
+        try:
+            value = func()
+        except TypeError:
+            # Python 2 unbound method
+            value = func.__func__()
+        setattr(self, name, value)
+        return value
+
+
+@LazyConst
+class TIFF(object):
+    """Namespace for module constants."""
+
+    def TAGS():
+        # TIFF tag codes and names
+        return {
+            254: 'NewSubfileType',
+            255: 'SubfileType',
+            256: 'ImageWidth',
+            257: 'ImageLength',
+            258: 'BitsPerSample',
+            259: 'Compression',
+            262: 'PhotometricInterpretation',
+            263: 'Threshholding',
+            264: 'CellWidth',
+            265: 'CellLength',
+            266: 'FillOrder',
+            269: 'DocumentName',
+            270: 'ImageDescription',
+            271: 'Make',
+            272: 'Model',
+            273: 'StripOffsets',
+            274: 'Orientation',
+            277: 'SamplesPerPixel',
+            278: 'RowsPerStrip',
+            279: 'StripByteCounts',
+            280: 'MinSampleValue',
+            281: 'MaxSampleValue',
+            282: 'XResolution',
+            283: 'YResolution',
+            284: 'PlanarConfiguration',
+            285: 'PageName',
+            286: 'XPosition',
+            287: 'YPosition',
+            288: 'FreeOffsets',
+            289: 'FreeByteCounts',
+            290: 'GrayResponseUnit',
+            291: 'GrayResponseCurve',
+            292: 'T4Options',
+            293: 'T6Options',
+            296: 'ResolutionUnit',
+            297: 'PageNumber',
+            300: 'ColorResponseUnit',
+            301: 'TransferFunction',
+            305: 'Software',
+            306: 'DateTime',
+            315: 'Artist',
+            316: 'HostComputer',
+            317: 'Predictor',
+            318: 'WhitePoint',
+            319: 'PrimaryChromaticities',
+            320: 'ColorMap',
+            321: 'HalftoneHints',
+            322: 'TileWidth',
+            323: 'TileLength',
+            324: 'TileOffsets',
+            325: 'TileByteCounts',
+            326: 'BadFaxLines',
+            327: 'CleanFaxData',
+            328: 'ConsecutiveBadFaxLines',
+            330: 'SubIFDs',
+            332: 'InkSet',
+            333: 'InkNames',
+            334: 'NumberOfInks',
+            336: 'DotRange',
+            337: 'TargetPrinter',
+            338: 'ExtraSamples',
+            339: 'SampleFormat',
+            340: 'SMinSampleValue',
+            341: 'SMaxSampleValue',
+            342: 'TransferRange',
+            343: 'ClipPath',
+            344: 'XClipPathUnits',
+            345: 'YClipPathUnits',
+            346: 'Indexed',
+            347: 'JPEGTables',
+            351: 'OPIProxy',
+            400: 'GlobalParametersIFD',
+            401: 'ProfileType',
+            402: 'FaxProfile',
+            403: 'CodingMethods',
+            404: 'VersionYear',
+            405: 'ModeNumber',
+            433: 'Decode',
+            434: 'DefaultImageColor',
+            435: 'T82Options',
+            512: 'JPEGProc',
+            513: 'JPEGInterchangeFormat',
+            514: 'JPEGInterchangeFormatLength',
+            515: 'JPEGRestartInterval',
+            517: 'JPEGLosslessPredictors',
+            518: 'JPEGPointTransforms',
+            519: 'JPEGQTables',
+            520: 'JPEGDCTables',
+            521: 'JPEGACTables',
+            529: 'YCbCrCoefficients',
+            530: 'YCbCrSubSampling',
+            531: 'YCbCrPositioning',
+            532: 'ReferenceBlackWhite',
+            559: 'StripRowCounts',
+            700: 'XMP',
+            4864: 'AndorId',  # TODO: Andor Technology 4864 - 5030
+            4869: 'AndorTemperature',
+            4876: 'AndorExposureTime',
+            4878: 'AndorKineticCycleTime',
+            4879: 'AndorAccumulations',
+            4881: 'AndorAcquisitionCycleTime',
+            4882: 'AndorReadoutTime',
+            4884: 'AndorPhotonCounting',
+            4885: 'AndorEmDacLevel',
+            4890: 'AndorFrames',
+            4896: 'AndorHorizontalFlip',
+            4897: 'AndorVerticalFlip',
+            4898: 'AndorClockwise',
+            4899: 'AndorCounterClockwise',
+            4904: 'AndorVerticalClockVoltage',
+            4905: 'AndorVerticalShiftSpeed',
+            4907: 'AndorPreAmpSetting',
+            4908: 'AndorCameraSerial',
+            4911: 'AndorActualTemperature',
+            4912: 'AndorBaselineClamp',
+            4913: 'AndorPrescans',
+            4914: 'AndorModel',
+            4915: 'AndorChipSizeX',
+            4916: 'AndorChipSizeY',
+            4944: 'AndorBaselineOffset',
+            4966: 'AndorSoftwareVersion',
+            # Private tags
+            32781: 'ImageID',
+            32932: 'WangAnnotation',
+            32995: 'Matteing',
+            32996: 'DataType',
+            32997: 'ImageDepth',
+            32998: 'TileDepth',
+            33300: 'ImageFullWidth',
+            33301: 'ImageFullLength',
+            33302: 'TextureFormat',
+            33303: 'TextureWrapModes',
+            33304: 'FieldOfViewCotangent',
+            33305: 'MatrixWorldToScreen',
+            33306: 'MatrixWorldToCamera',
+            33421: 'CFARepeatPatternDim',
+            33422: 'CFAPattern',
+            33432: 'Copyright',
+            33445: 'MDFileTag',
+            33446: 'MDScalePixel',
+            33447: 'MDColorTable',
+            33448: 'MDLabName',
+            33449: 'MDSampleInfo',
+            33450: 'MDPrepDate',
+            33451: 'MDPrepTime',
+            33452: 'MDFileUnits',
+            33550: 'ModelPixelScaleTag',
+            33628: 'UIC1tag',  # Metamorph  Universal Imaging Corp STK
+            33629: 'UIC2tag',
+            33630: 'UIC3tag',
+            33631: 'UIC4tag',
+            33723: 'IPTC',
+            33918: 'INGRPacketDataTag',
+            33919: 'INGRFlagRegisters',
+            33920: 'IrasBTransformationMatrix',
+            33922: 'ModelTiepointTag',
+            34118: 'CZ_SEM',  # Zeiss SEM
+            34122: 'IPLAB',  # number of images
+            34264: 'ModelTransformationTag',
+            34361: 'MM_Header',
+            34362: 'MM_Stamp',
+            34363: 'MM_Unknown',
+            34377: 'Photoshop',
+            34386: 'MM_UserBlock',
+            34412: 'CZ_LSMINFO',
+            34665: 'ExifIFD',
+            34675: 'ICCProfile',
+            34680: 'FEI_SFEG',  #
+            34682: 'FEI_HELIOS',  #
+            34683: 'FEI_TITAN',  #
+            34732: 'ImageLayer',
+            34735: 'GeoKeyDirectoryTag',
+            34736: 'GeoDoubleParamsTag',
+            34737: 'GeoAsciiParamsTag',
+            34853: 'GPSIFD',
+            34908: 'HylaFAXFaxRecvParams',
+            34909: 'HylaFAXFaxSubAddress',
+            34910: 'HylaFAXFaxRecvTime',
+            34911: 'FaxDcs',
+            37439: 'StoNits',
+            37679: 'MODI_TXT',  # Microsoft Office Document Imaging
+            37681: 'MODI_POS',
+            37680: 'MODI_OLE',
+            37706: 'TVIPS',  # offset to TemData structure
+            37707: 'TVIPS1',
+            37708: 'TVIPS2',  # same TemData structure as undefined
+            37724: 'ImageSourceData',
+            40001: 'MC_IpWinScal',  # Media Cybernetics
+            40100: 'MC_IdOld',
+            40965: 'InteroperabilityIFD',
+            42112: 'GDAL_METADATA',
+            42113: 'GDAL_NODATA',
+            43314: 'NIHImageHeader',
+            50215: 'OceScanjobDescription',
+            50216: 'OceApplicationSelector',
+            50217: 'OceIdentificationNumber',
+            50218: 'OceImageLogicCharacteristics',
+            50288: 'MC_Id',  # Media Cybernetics
+            50289: 'MC_XYPosition',
+            50290: 'MC_ZPosition',
+            50291: 'MC_XYCalibration',
+            50292: 'MC_LensCharacteristics',
+            50293: 'MC_ChannelName',
+            50294: 'MC_ExcitationWavelength',
+            50295: 'MC_TimeStamp',
+            50296: 'MC_FrameProperties',
+            50706: 'DNGVersion',
+            50707: 'DNGBackwardVersion',
+            50708: 'UniqueCameraModel',
+            50709: 'LocalizedCameraModel',
+            50710: 'CFAPlaneColor',
+            50711: 'CFALayout',
+            50712: 'LinearizationTable',
+            50713: 'BlackLevelRepeatDim',
+            50714: 'BlackLevel',
+            50715: 'BlackLevelDeltaH',
+            50716: 'BlackLevelDeltaV',
+            50717: 'WhiteLevel',
+            50718: 'DefaultScale',
+            50719: 'DefaultCropOrigin',
+            50720: 'DefaultCropSize',
+            50721: 'ColorMatrix1',
+            50722: 'ColorMatrix2',
+            50723: 'CameraCalibration1',
+            50724: 'CameraCalibration2',
+            50725: 'ReductionMatrix1',
+            50726: 'ReductionMatrix2',
+            50727: 'AnalogBalance',
+            50728: 'AsShotNeutral',
+            50729: 'AsShotWhiteXY',
+            50730: 'BaselineExposure',
+            50731: 'BaselineNoise',
+            50732: 'BaselineSharpness',
+            50733: 'BayerGreenSplit',
+            50734: 'LinearResponseLimit',
+            50735: 'CameraSerialNumber',
+            50736: 'LensInfo',
+            50737: 'ChromaBlurRadius',
+            50738: 'AntiAliasStrength',
+            50739: 'ShadowScale',
+            50740: 'DNGPrivateData',
+            50741: 'MakerNoteSafety',
+            50778: 'CalibrationIlluminant1',
+            50779: 'CalibrationIlluminant2',
+            50780: 'BestQualityScale',
+            50781: 'RawDataUniqueID',
+            50784: 'AliasLayerMetadata',
+            50827: 'OriginalRawFileName',
+            50828: 'OriginalRawFileData',
+            50829: 'ActiveArea',
+            50830: 'MaskedAreas',
+            50831: 'AsShotICCProfile',
+            50832: 'AsShotPreProfileMatrix',
+            50833: 'CurrentICCProfile',
+            50834: 'CurrentPreProfileMatrix',
+            50838: 'IJMetadataByteCounts',
+            50839: 'IJMetadata',
+            51023: 'FibicsXML',  #
+            51123: 'MicroManagerMetadata',
+            65200: 'FlexXML',  #
+            65563: 'PerSample',
+        }
+
+    def TAG_NAMES():
+        return {v: c for c, v in TIFF.TAGS.items()}
+
+    def TAG_READERS():
+        # Map TIFF tag codes to import functions
+        return {
+            320: read_colormap,
+            700: read_bytes,  # read_utf8,
+            34377: read_numpy,
+            33723: read_bytes,
+            34675: read_bytes,
+            33628: read_uic1tag,  # Universal Imaging Corp STK
+            33629: read_uic2tag,
+            33630: read_uic3tag,
+            33631: read_uic4tag,
+            34118: read_cz_sem,  # Carl Zeiss SEM
+            34361: read_mm_header,  # Olympus FluoView
+            34362: read_mm_stamp,
+            34363: read_numpy,  # MM_Unknown
+            34386: read_numpy,  # MM_UserBlock
+            34412: read_cz_lsminfo,  # Carl Zeiss LSM
+            34680: read_fei_metadata,  # S-FEG
+            34682: read_fei_metadata,  # Helios NanoLab
+            37706: read_tvips_header,  # TVIPS EMMENU
+            43314: read_nih_image_header,
+            # 40001: read_bytes,
+            40100: read_bytes,
+            50288: read_bytes,
+            50296: read_bytes,
+            50839: read_bytes,
+            51123: read_json,
+            34665: read_exif_ifd,
+            34853: read_gps_ifd,
+            40965: read_interoperability_ifd
+        }
+
+    def TAG_TUPLE():
+        # Tags whose values must be stored as tuples
+        return frozenset((273, 279, 324, 325, 530, 531))
+
+    def TAG_ATTRIBUTES():
+        #  Map tag codes to TiffPage attribute names
+        return {
+            'ImageWidth': 'imagewidth',
+            'ImageLength': 'imagelength',
+            'BitsPerSample': 'bitspersample',
+            'Compression': 'compression',
+            'PlanarConfiguration': 'planarconfig',
+            'FillOrder': 'fillorder',
+            'PhotometricInterpretation': 'photometric',
+            'ColorMap': 'colormap',
+            'ImageDescription': 'description',
+            'ImageDescription1': 'description1',
+            'SamplesPerPixel': 'samplesperpixel',
+            'RowsPerStrip': 'rowsperstrip',
+            'Software': 'software',
+            'Predictor': 'predictor',
+            'TileWidth': 'tilewidth',
+            'TileLength': 'tilelength',
+            'ExtraSamples': 'extrasamples',
+            'SampleFormat': 'sampleformat',
+            'ImageDepth': 'imagedepth',
+            'TileDepth': 'tiledepth',
+        }
+
+    def TAG_ENUM():
+        return {
+            # 254: TIFF.FILETYPE,
+            255: TIFF.OFILETYPE,
+            259: TIFF.COMPRESSION,
+            262: TIFF.PHOTOMETRIC,
+            263: TIFF.THRESHHOLD,
+            266: TIFF.FILLORDER,
+            274: TIFF.ORIENTATION,
+            284: TIFF.PLANARCONFIG,
+            290: TIFF.GRAYRESPONSEUNIT,
+            # 292: TIFF.GROUP3OPT,
+            # 293: TIFF.GROUP4OPT,
+            296: TIFF.RESUNIT,
+            300: TIFF.COLORRESPONSEUNIT,
+            317: TIFF.PREDICTOR,
+            338: TIFF.EXTRASAMPLE,
+            339: TIFF.SAMPLEFORMAT,
+            # 512: TIFF.JPEGPROC,
+            # 531: TIFF.YCBCRPOSITION,
+        }
+
+    def FILETYPE():
+        class FILETYPE(enum.IntFlag):
+            # Python 3.6 only
+            UNDEFINED = 0
+            REDUCEDIMAGE = 1
+            PAGE = 2
+            MASK = 4
+        return FILETYPE
+
+    def OFILETYPE():
+        class OFILETYPE(enum.IntEnum):
+            UNDEFINED = 0
+            IMAGE = 1
+            REDUCEDIMAGE = 2
+            PAGE = 3
+        return OFILETYPE
+
+    def COMPRESSION():
+        class COMPRESSION(enum.IntEnum):
+            NONE = 1  # Uncompressed
+            CCITTRLE = 2  # CCITT 1D
+            CCITT_T4 = 3  # 'T4/Group 3 Fax',
+            CCITT_T6 = 4  # 'T6/Group 4 Fax',
+            LZW = 5
+            OJPEG = 6  # old-style JPEG
+            JPEG = 7
+            ADOBE_DEFLATE = 8
+            JBIG_BW = 9
+            JBIG_COLOR = 10
+            JPEG_99 = 99
+            KODAK_262 = 262
+            NEXT = 32766
+            SONY_ARW = 32767
+            PACKED_RAW = 32769
+            SAMSUNG_SRW = 32770
+            CCIRLEW = 32771
+            SAMSUNG_SRW2 = 32772
+            PACKBITS = 32773
+            THUNDERSCAN = 32809
+            IT8CTPAD = 32895
+            IT8LW = 32896
+            IT8MP = 32897
+            IT8BL = 32898
+            PIXARFILM = 32908
+            PIXARLOG = 32909
+            DEFLATE = 32946
+            DCS = 32947
+            APERIO_JP2000_YCBC = 33003  # Leica Aperio
+            APERIO_JP2000_RGB = 33005  # Leica Aperio
+            JBIG = 34661
+            SGILOG = 34676
+            SGILOG24 = 34677
+            JPEG2000 = 34712
+            NIKON_NEF = 34713
+            JBIG2 = 34715
+            MDI_BINARY = 34718  # 'Microsoft Document Imaging
+            MDI_PROGRESSIVE = 34719  # 'Microsoft Document Imaging
+            MDI_VECTOR = 34720  # 'Microsoft Document Imaging
+            JPEG_LOSSY = 34892
+            LZMA = 34925
+            OPS_PNG = 34933  # Objective Pathology Services
+            OPS_JPEGXR = 34934  # Objective Pathology Services
+            KODAK_DCR = 65000
+            PENTAX_PEF = 65535
+            # def __bool__(self): return self != 1  # Python 3.6 only
+        return COMPRESSION
+
+    def PHOTOMETRIC():
+        class PHOTOMETRIC(enum.IntEnum):
+            MINISWHITE = 0
+            MINISBLACK = 1
+            RGB = 2
+            PALETTE = 3
+            MASK = 4
+            SEPARATED = 5  # CMYK
+            YCBCR = 6
+            CIELAB = 8
+            ICCLAB = 9
+            ITULAB = 10
+            CFA = 32803  # Color Filter Array
+            LOGL = 32844
+            LOGLUV = 32845
+            LINEAR_RAW = 34892
+        return PHOTOMETRIC
+
+    def THRESHHOLD():
+        class THRESHHOLD(enum.IntEnum):
+            BILEVEL = 1
+            HALFTONE = 2
+            ERRORDIFFUSE = 3
+        return THRESHHOLD
+
+    def FILLORDER():
+        class FILLORDER(enum.IntEnum):
+            MSB2LSB = 1
+            LSB2MSB = 2
+        return FILLORDER
+
+    def ORIENTATION():
+        class ORIENTATION(enum.IntEnum):
+            TOPLEFT = 1
+            TOPRIGHT = 2
+            BOTRIGHT = 3
+            BOTLEFT = 4
+            LEFTTOP = 5
+            RIGHTTOP = 6
+            RIGHTBOT = 7
+            LEFTBOT = 8
+        return ORIENTATION
+
+    def PLANARCONFIG():
+        class PLANARCONFIG(enum.IntEnum):
+            CONTIG = 1
+            SEPARATE = 2
+        return PLANARCONFIG
+
+    def GRAYRESPONSEUNIT():
+        class GRAYRESPONSEUNIT(enum.IntEnum):
+            _10S = 1
+            _100S = 2
+            _1000S = 3
+            _10000S = 4
+            _100000S = 5
+        return GRAYRESPONSEUNIT
+
+    def GROUP4OPT():
+        class GROUP4OPT(enum.IntEnum):
+            UNCOMPRESSED = 2
+        return GROUP4OPT
+
+    def RESUNIT():
+        class RESUNIT(enum.IntEnum):
+            NONE = 1
+            INCH = 2
+            CENTIMETER = 3
+            # def __bool__(self): return self != 1  # Python 3.6 only
+        return RESUNIT
+
+    def COLORRESPONSEUNIT():
+        class COLORRESPONSEUNIT(enum.IntEnum):
+            _10S = 1
+            _100S = 2
+            _1000S = 3
+            _10000S = 4
+            _100000S = 5
+        return COLORRESPONSEUNIT
+
+    def PREDICTOR():
+        class PREDICTOR(enum.IntEnum):
+            NONE = 1
+            HORIZONTAL = 2
+            FLOATINGPOINT = 3
+            # def __bool__(self): return self != 1  # Python 3.6 only
+        return PREDICTOR
+
+    def EXTRASAMPLE():
+        class EXTRASAMPLE(enum.IntEnum):
+            UNSPECIFIED = 0
+            ASSOCALPHA = 1
+            UNASSALPHA = 2
+        return EXTRASAMPLE
+
+    def SAMPLEFORMAT():
+        class SAMPLEFORMAT(enum.IntEnum):
+            UINT = 1
+            INT = 2
+            IEEEFP = 3
+            VOID = 4
+            COMPLEXINT = 5
+            COMPLEXIEEEFP = 6
+        return SAMPLEFORMAT
+
+    def DATATYPES():
+        class DATATYPES(enum.IntEnum):
+            NOTYPE = 0
+            BYTE = 1
+            ASCII = 2
+            SHORT = 3
+            LONG = 4
+            RATIONAL = 5
+            SBYTE = 6
+            UNDEFINED = 7
+            SSHORT = 8
+            SLONG = 9
+            SRATIONAL = 10
+            FLOAT = 11
+            DOUBLE = 12
+            IFD = 13
+            UNICODE = 14
+            COMPLEX = 15
+            LONG8 = 16
+            SLONG8 = 17
+            IFD8 = 18
+        return DATATYPES
+
+    def DATA_FORMATS():
+        # Map TIFF DATATYPES to Python struct formats
+        return {
+            1: '1B',   # BYTE 8-bit unsigned integer.
+            2: '1s',   # ASCII 8-bit byte that contains a 7-bit ASCII code;
+                       #   the last byte must be NULL (binary zero).
+            3: '1H',   # SHORT 16-bit (2-byte) unsigned integer
+            4: '1I',   # LONG 32-bit (4-byte) unsigned integer.
+            5: '2I',   # RATIONAL Two LONGs: the first represents the numerator
+                       #   of a fraction; the second, the denominator.
+            6: '1b',   # SBYTE An 8-bit signed (twos-complement) integer.
+            7: '1p',   # UNDEFINED An 8-bit byte that may contain anything,
+                       #   depending on the definition of the field.
+            8: '1h',   # SSHORT A 16-bit (2-byte) signed (twos-complement)
+                       #   integer.
+            9: '1i',   # SLONG A 32-bit (4-byte) signed (twos-complement)
+                       #   integer.
+            10: '2i',  # SRATIONAL Two SLONGs: the first represents the
+                       #   numerator of a fraction, the second the denominator.
+            11: '1f',  # FLOAT Single precision (4-byte) IEEE format.
+            12: '1d',  # DOUBLE Double precision (8-byte) IEEE format.
+            13: '1I',  # IFD unsigned 4 byte IFD offset.
+            # 14: '',  # UNICODE
+            # 15: '',  # COMPLEX
+            16: '1Q',  # LONG8 unsigned 8 byte integer (BigTiff)
+            17: '1q',  # SLONG8 signed 8 byte integer (BigTiff)
+            18: '1Q',  # IFD8 unsigned 8 byte IFD offset (BigTiff)
+        }
+
+    def DATA_DTYPES():
+        # Map numpy dtypes to TIFF DATATYPES
+        return {'B': 1, 's': 2, 'H': 3, 'I': 4, '2I': 5, 'b': 6,
+                'h': 8, 'i': 9, '2i': 10, 'f': 11, 'd': 12, 'Q': 16, 'q': 17}
+
+    def SAMPLE_DTYPES():
+        # Map TIFF SampleFormats and BitsPerSample to numpy dtype
+        return {
+            (1, 1): '?',  # bitmap
+            (1, 2): 'B',
+            (1, 3): 'B',
+            (1, 4): 'B',
+            (1, 5): 'B',
+            (1, 6): 'B',
+            (1, 7): 'B',
+            (1, 8): 'B',
+            (1, 9): 'H',
+            (1, 10): 'H',
+            (1, 11): 'H',
+            (1, 12): 'H',
+            (1, 13): 'H',
+            (1, 14): 'H',
+            (1, 15): 'H',
+            (1, 16): 'H',
+            (1, 17): 'I',
+            (1, 18): 'I',
+            (1, 19): 'I',
+            (1, 20): 'I',
+            (1, 21): 'I',
+            (1, 22): 'I',
+            (1, 23): 'I',
+            (1, 24): 'I',
+            (1, 25): 'I',
+            (1, 26): 'I',
+            (1, 27): 'I',
+            (1, 28): 'I',
+            (1, 29): 'I',
+            (1, 30): 'I',
+            (1, 31): 'I',
+            (1, 32): 'I',
+            (1, 64): 'Q',
+            (2, 8): 'b',
+            (2, 16): 'h',
+            (2, 32): 'i',
+            (2, 64): 'q',
+            (3, 16): 'e',
+            (3, 32): 'f',
+            (3, 64): 'd',
+            (6, 64): 'F',
+            (6, 128): 'D',
+            (1, (5, 6, 5)): 'B',
+        }
+
+    def DECOMPESSORS():
+        decompressors = {
+            None: identityfunc,
+            1: identityfunc,
+            5: decode_lzw,
+            # 7: decode_jpeg,
+            8: zlib.decompress,
+            32946: zlib.decompress,
+            32773: decode_packbits,
+        }
+        if lzma:
+            decompressors[34925] = lzma.decompress
+        return decompressors
+
+    def FRAME_ATTRS():
+        # Attributes that a TiffFrame shares with its keyframe
+        return set('shape ndim size dtype axes is_final'.split())
+
+    def FILE_FLAGS():
+        # TiffFile and TiffPage 'is_\*' attributes
+        exclude = set('reduced final memmappable contiguous '
+                      'chroma_subsampled'.split())
+        return set(a[3:] for a in dir(TiffPage)
+                   if a[:3] == 'is_' and a[3:] not in exclude)
+
+    def FILE_EXTENSIONS():
+        # TIFF file extensions
+        return tuple('tif tiff ome.tif lsm stk '
+                     'gel seq svs bif tf8 tf2 btf'.split())
+
+    def FILEOPEN_FILTER():
+        # String for use in Windows File Open box
+        return [("%s files" % ext.upper(), "*.%s" % ext)
+                for ext in TIFF.FILE_EXTENSIONS] + [("allfiles", "*")]
+
+    def AXES_LABELS():
+        # TODO: is there a standard for character axes labels?
+        axes = {
+            'X': 'width',
+            'Y': 'height',
+            'Z': 'depth',
+            'S': 'sample',  # rgb(a)
+            'I': 'series',  # general sequence, plane, page, IFD
+            'T': 'time',
+            'C': 'channel',  # color, emission wavelength
+            'A': 'angle',
+            'P': 'phase',  # formerly F    # P is Position in LSM!
+            'R': 'tile',  # region, point, mosaic
+            'H': 'lifetime',  # histogram
+            'E': 'lambda',  # excitation wavelength
+            'L': 'exposure',  # lux
+            'V': 'event',
+            'Q': 'other',
+            'M': 'mosaic',  # LSM 6
+        }
+        axes.update(dict((v, k) for k, v in axes.items()))
+        return axes
+
+    def ANDOR_TAGS():
+        # Andor Technology tags #4864 - 5030
+        return set(range(4864, 5030))
+
+    def EXIF_TAGS():
+        return {
+            33434: 'ExposureTime',
+            33437: 'FNumber',
+            34850: 'ExposureProgram',
+            34852: 'SpectralSensitivity',
+            34855: 'ISOSpeedRatings',
+            34856: 'OECF',
+            34858: 'TimeZoneOffset',
+            34859: 'SelfTimerMode',
+            34864: 'SensitivityType',
+            34865: 'StandardOutputSensitivity',
+            34866: 'RecommendedExposureIndex',
+            34867: 'ISOSpeed',
+            34868: 'ISOSpeedLatitudeyyy',
+            34869: 'ISOSpeedLatitudezzz',
+            36864: 'ExifVersion',
+            36867: 'DateTimeOriginal',
+            36868: 'DateTimeDigitized',
+            36873: 'GooglePlusUploadCode',
+            36880: 'OffsetTime',
+            36881: 'OffsetTimeOriginal',
+            36882: 'OffsetTimeDigitized',
+            37121: 'ComponentsConfiguration',
+            37122: 'CompressedBitsPerPixel',
+            37377: 'ShutterSpeedValue',
+            37378: 'ApertureValue',
+            37379: 'BrightnessValue',
+            37380: 'ExposureBiasValue',
+            37381: 'MaxApertureValue',
+            37382: 'SubjectDistance',
+            37383: 'MeteringMode',
+            37384: 'LightSource',
+            37385: 'Flash',
+            37386: 'FocalLength',
+            37393: 'ImageNumber',
+            37394: 'SecurityClassification',
+            37395: 'ImageHistory',
+            37396: 'SubjectArea',
+            37500: 'MakerNote',
+            37510: 'UserComment',
+            37520: 'SubsecTime',
+            37521: 'SubsecTimeOriginal',
+            37522: 'SubsecTimeDigitized',
+            37888: 'Temperature',
+            37889: 'Humidity',
+            37890: 'Pressure',
+            37891: 'WaterDepth',
+            37892: 'Acceleration',
+            37893: 'CameraElevationAngle',
+            40960: 'FlashpixVersion',
+            40961: 'ColorSpace',
+            40962: 'PixelXDimension',
+            40963: 'PixelYDimension',
+            40964: 'RelatedSoundFile',
+            41483: 'FlashEnergy',
+            41484: 'SpatialFrequencyResponse',
+            41486: 'FocalPlaneXResolution',
+            41487: 'FocalPlaneYResolution',
+            41488: 'FocalPlaneResolutionUnit',
+            41492: 'SubjectLocation',
+            41493: 'ExposureIndex',
+            41495: 'SensingMethod',
+            41728: 'FileSource',
+            41729: 'SceneType',
+            41730: 'CFAPattern',
+            41985: 'CustomRendered',
+            41986: 'ExposureMode',
+            41987: 'WhiteBalance',
+            41988: 'DigitalZoomRatio',
+            41989: 'FocalLengthIn35mmFilm',
+            41990: 'SceneCaptureType',
+            41991: 'GainControl',
+            41992: 'Contrast',
+            41993: 'Saturation',
+            41994: 'Sharpness',
+            41995: 'DeviceSettingDescription',
+            41996: 'SubjectDistanceRange',
+            42016: 'ImageUniqueID',
+            42032: 'CameraOwnerName',
+            42033: 'BodySerialNumber',
+            42034: 'LensSpecification',
+            42035: 'LensMake',
+            42036: 'LensModel',
+            42037: 'LensSerialNumber',
+            42240: 'Gamma',
+            59932: 'Padding',
+            59933: 'OffsetSchema',
+            65000: 'OwnerName',
+            65001: 'SerialNumber',
+            65002: 'Lens',
+            65100: 'RawFile',
+            65101: 'Converter',
+            65102: 'WhiteBalance',
+            65105: 'Exposure',
+            65106: 'Shadows',
+            65107: 'Brightness',
+            65108: 'Contrast',
+            65109: 'Saturation',
+            65110: 'Sharpness',
+            65111: 'Smoothness',
+            65112: 'MoireFilter',
+        }
+
+    def GPS_TAGS():
+        return {
+            0: 'GPSVersionID',
+            1: 'GPSLatitudeRef',
+            2: 'GPSLatitude',
+            3: 'GPSLongitudeRef',
+            4: 'GPSLongitude',
+            5: 'GPSAltitudeRef',
+            6: 'GPSAltitude',
+            7: 'GPSTimeStamp',
+            8: 'GPSSatellites',
+            9: 'GPSStatus',
+            10: 'GPSMeasureMode',
+            11: 'GPSDOP',
+            12: 'GPSSpeedRef',
+            13: 'GPSSpeed',
+            14: 'GPSTrackRef',
+            15: 'GPSTrack',
+            16: 'GPSImgDirectionRef',
+            17: 'GPSImgDirection',
+            18: 'GPSMapDatum',
+            19: 'GPSDestLatitudeRef',
+            20: 'GPSDestLatitude',
+            21: 'GPSDestLongitudeRef',
+            22: 'GPSDestLongitude',
+            23: 'GPSDestBearingRef',
+            24: 'GPSDestBearing',
+            25: 'GPSDestDistanceRef',
+            26: 'GPSDestDistance',
+            27: 'GPSProcessingMethod',
+            28: 'GPSAreaInformation',
+            29: 'GPSDateStamp',
+            30: 'GPSDifferential',
+            31: 'GPSHPositioningError',
+        }
+
+    def IOP_TAGS():
+        return {
+            1: 'InteroperabilityIndex',
+            2: 'InteroperabilityVersion',
+            4096: 'RelatedImageFileFormat',
+            4097: 'RelatedImageWidth',
+            4098: 'RelatedImageLength',
+        }
+
+    def CZ_LSMINFO():
+        return [
+            ('MagicNumber', 'u4'),
+            ('StructureSize', 'i4'),
+            ('DimensionX', 'i4'),
+            ('DimensionY', 'i4'),
+            ('DimensionZ', 'i4'),
+            ('DimensionChannels', 'i4'),
+            ('DimensionTime', 'i4'),
+            ('DataType', 'i4'),  # DATATYPES
+            ('ThumbnailX', 'i4'),
+            ('ThumbnailY', 'i4'),
+            ('VoxelSizeX', 'f8'),
+            ('VoxelSizeY', 'f8'),
+            ('VoxelSizeZ', 'f8'),
+            ('OriginX', 'f8'),
+            ('OriginY', 'f8'),
+            ('OriginZ', 'f8'),
+            ('ScanType', 'u2'),
+            ('SpectralScan', 'u2'),
+            ('TypeOfData', 'u4'),  # TYPEOFDATA
+            ('OffsetVectorOverlay', 'u4'),
+            ('OffsetInputLut', 'u4'),
+            ('OffsetOutputLut', 'u4'),
+            ('OffsetChannelColors', 'u4'),
+            ('TimeIntervall', 'f8'),
+            ('OffsetChannelDataTypes', 'u4'),
+            ('OffsetScanInformation', 'u4'),  # SCANINFO
+            ('OffsetKsData', 'u4'),
+            ('OffsetTimeStamps', 'u4'),
+            ('OffsetEventList', 'u4'),
+            ('OffsetRoi', 'u4'),
+            ('OffsetBleachRoi', 'u4'),
+            ('OffsetNextRecording', 'u4'),
+            # LSM 2.0 ends here
+            ('DisplayAspectX', 'f8'),
+            ('DisplayAspectY', 'f8'),
+            ('DisplayAspectZ', 'f8'),
+            ('DisplayAspectTime', 'f8'),
+            ('OffsetMeanOfRoisOverlay', 'u4'),
+            ('OffsetTopoIsolineOverlay', 'u4'),
+            ('OffsetTopoProfileOverlay', 'u4'),
+            ('OffsetLinescanOverlay', 'u4'),
+            ('ToolbarFlags', 'u4'),
+            ('OffsetChannelWavelength', 'u4'),
+            ('OffsetChannelFactors', 'u4'),
+            ('ObjectiveSphereCorrection', 'f8'),
+            ('OffsetUnmixParameters', 'u4'),
+            # LSM 3.2, 4.0 end here
+            ('OffsetAcquisitionParameters', 'u4'),
+            ('OffsetCharacteristics', 'u4'),
+            ('OffsetPalette', 'u4'),
+            ('TimeDifferenceX', 'f8'),
+            ('TimeDifferenceY', 'f8'),
+            ('TimeDifferenceZ', 'f8'),
+            ('InternalUse1', 'u4'),
+            ('DimensionP', 'i4'),
+            ('DimensionM', 'i4'),
+            ('DimensionsReserved', '16i4'),
+            ('OffsetTilePositions', 'u4'),
+            ('', '9u4'),  # Reserved
+            ('OffsetPositions', 'u4'),
+            # ('', '21u4'),  # must be 0
+        ]
+
+    def CZ_LSMINFO_READERS():
+        # Import functions for CZ_LSMINFO sub-records
+        # TODO: read more CZ_LSMINFO sub-records
+        return {
+            'ScanInformation': read_lsm_scaninfo,
+            'TimeStamps': read_lsm_timestamps,
+            'EventList': read_lsm_eventlist,
+            'ChannelColors': read_lsm_channelcolors,
+            'Positions': read_lsm_floatpairs,
+            'TilePositions': read_lsm_floatpairs,
+            'VectorOverlay': None,
+            'InputLut': None,
+            'OutputLut': None,
+            'TimeIntervall': None,
+            'ChannelDataTypes': None,
+            'KsData': None,
+            'Roi': None,
+            'BleachRoi': None,
+            'NextRecording': None,
+            'MeanOfRoisOverlay': None,
+            'TopoIsolineOverlay': None,
+            'TopoProfileOverlay': None,
+            'ChannelWavelength': None,
+            'SphereCorrection': None,
+            'ChannelFactors': None,
+            'UnmixParameters': None,
+            'AcquisitionParameters': None,
+            'Characteristics': None,
+        }
+
+    def CZ_LSMINFO_SCANTYPE():
+        # Map CZ_LSMINFO.ScanType to dimension order
+        return {
+            0: 'XYZCT',  # 'Stack' normal x-y-z-scan
+            1: 'XYZCT',  # 'Z-Scan' x-z-plane Y=1
+            2: 'XYZCT',  # 'Line'
+            3: 'XYTCZ',  # 'Time Series Plane' time series x-y  XYCTZ ? Z=1
+            4: 'XYZTC',  # 'Time Series z-Scan' time series x-z
+            5: 'XYTCZ',  # 'Time Series Mean-of-ROIs'
+            6: 'XYZTC',  # 'Time Series Stack' time series x-y-z
+            7: 'XYCTZ',  # Spline Scan
+            8: 'XYCZT',  # Spline Plane x-z
+            9: 'XYTCZ',  # Time Series Spline Plane x-z
+            10: 'XYZCT',  # 'Time Series Point' point mode
+        }
+
+    def CZ_LSMINFO_DIMENSIONS():
+        # Map dimension codes to CZ_LSMINFO attribute
+        return {
+            'X': 'DimensionX',
+            'Y': 'DimensionY',
+            'Z': 'DimensionZ',
+            'C': 'DimensionChannels',
+            'T': 'DimensionTime',
+            'P': 'DimensionP',
+            'M': 'DimensionM',
+        }
+
+    def CZ_LSMINFO_DATATYPES():
+        # Description of CZ_LSMINFO.DataType
+        return {
+            0: 'varying data types',
+            1: '8 bit unsigned integer',
+            2: '12 bit unsigned integer',
+            5: '32 bit float',
+        }
+
+    def CZ_LSMINFO_TYPEOFDATA():
+        # Description of CZ_LSMINFO.TypeOfData
+        return {
+            0: 'Original scan data',
+            1: 'Calculated data',
+            2: '3D reconstruction',
+            3: 'Topography height map',
+        }
+
+    def CZ_LSMINFO_SCANINFO_ARRAYS():
+        return {
+            0x20000000: 'Tracks',
+            0x30000000: 'Lasers',
+            0x60000000: 'DetectionChannels',
+            0x80000000: 'IlluminationChannels',
+            0xa0000000: 'BeamSplitters',
+            0xc0000000: 'DataChannels',
+            0x11000000: 'Timers',
+            0x13000000: 'Markers',
+        }
+
+    def CZ_LSMINFO_SCANINFO_STRUCTS():
+        return {
+            # 0x10000000: "Recording",
+            0x40000000: 'Track',
+            0x50000000: 'Laser',
+            0x70000000: 'DetectionChannel',
+            0x90000000: 'IlluminationChannel',
+            0xb0000000: 'BeamSplitter',
+            0xd0000000: 'DataChannel',
+            0x12000000: 'Timer',
+            0x14000000: 'Marker',
+        }
+
+    def CZ_LSMINFO_SCANINFO_ATTRIBUTES():
+        return {
+            # Recording
+            0x10000001: 'Name',
+            0x10000002: 'Description',
+            0x10000003: 'Notes',
+            0x10000004: 'Objective',
+            0x10000005: 'ProcessingSummary',
+            0x10000006: 'SpecialScanMode',
+            0x10000007: 'ScanType',
+            0x10000008: 'ScanMode',
+            0x10000009: 'NumberOfStacks',
+            0x1000000a: 'LinesPerPlane',
+            0x1000000b: 'SamplesPerLine',
+            0x1000000c: 'PlanesPerVolume',
+            0x1000000d: 'ImagesWidth',
+            0x1000000e: 'ImagesHeight',
+            0x1000000f: 'ImagesNumberPlanes',
+            0x10000010: 'ImagesNumberStacks',
+            0x10000011: 'ImagesNumberChannels',
+            0x10000012: 'LinscanXySize',
+            0x10000013: 'ScanDirection',
+            0x10000014: 'TimeSeries',
+            0x10000015: 'OriginalScanData',
+            0x10000016: 'ZoomX',
+            0x10000017: 'ZoomY',
+            0x10000018: 'ZoomZ',
+            0x10000019: 'Sample0X',
+            0x1000001a: 'Sample0Y',
+            0x1000001b: 'Sample0Z',
+            0x1000001c: 'SampleSpacing',
+            0x1000001d: 'LineSpacing',
+            0x1000001e: 'PlaneSpacing',
+            0x1000001f: 'PlaneWidth',
+            0x10000020: 'PlaneHeight',
+            0x10000021: 'VolumeDepth',
+            0x10000023: 'Nutation',
+            0x10000034: 'Rotation',
+            0x10000035: 'Precession',
+            0x10000036: 'Sample0time',
+            0x10000037: 'StartScanTriggerIn',
+            0x10000038: 'StartScanTriggerOut',
+            0x10000039: 'StartScanEvent',
+            0x10000040: 'StartScanTime',
+            0x10000041: 'StopScanTriggerIn',
+            0x10000042: 'StopScanTriggerOut',
+            0x10000043: 'StopScanEvent',
+            0x10000044: 'StopScanTime',
+            0x10000045: 'UseRois',
+            0x10000046: 'UseReducedMemoryRois',
+            0x10000047: 'User',
+            0x10000048: 'UseBcCorrection',
+            0x10000049: 'PositionBcCorrection1',
+            0x10000050: 'PositionBcCorrection2',
+            0x10000051: 'InterpolationY',
+            0x10000052: 'CameraBinning',
+            0x10000053: 'CameraSupersampling',
+            0x10000054: 'CameraFrameWidth',
+            0x10000055: 'CameraFrameHeight',
+            0x10000056: 'CameraOffsetX',
+            0x10000057: 'CameraOffsetY',
+            0x10000059: 'RtBinning',
+            0x1000005a: 'RtFrameWidth',
+            0x1000005b: 'RtFrameHeight',
+            0x1000005c: 'RtRegionWidth',
+            0x1000005d: 'RtRegionHeight',
+            0x1000005e: 'RtOffsetX',
+            0x1000005f: 'RtOffsetY',
+            0x10000060: 'RtZoom',
+            0x10000061: 'RtLinePeriod',
+            0x10000062: 'Prescan',
+            0x10000063: 'ScanDirectionZ',
+            # Track
+            0x40000001: 'MultiplexType',  # 0 After Line; 1 After Frame
+            0x40000002: 'MultiplexOrder',
+            0x40000003: 'SamplingMode',  # 0 Sample; 1 Line Avg; 2 Frame Avg
+            0x40000004: 'SamplingMethod',  # 1 Mean; 2 Sum
+            0x40000005: 'SamplingNumber',
+            0x40000006: 'Acquire',
+            0x40000007: 'SampleObservationTime',
+            0x4000000b: 'TimeBetweenStacks',
+            0x4000000c: 'Name',
+            0x4000000d: 'Collimator1Name',
+            0x4000000e: 'Collimator1Position',
+            0x4000000f: 'Collimator2Name',
+            0x40000010: 'Collimator2Position',
+            0x40000011: 'IsBleachTrack',
+            0x40000012: 'IsBleachAfterScanNumber',
+            0x40000013: 'BleachScanNumber',
+            0x40000014: 'TriggerIn',
+            0x40000015: 'TriggerOut',
+            0x40000016: 'IsRatioTrack',
+            0x40000017: 'BleachCount',
+            0x40000018: 'SpiCenterWavelength',
+            0x40000019: 'PixelTime',
+            0x40000021: 'CondensorFrontlens',
+            0x40000023: 'FieldStopValue',
+            0x40000024: 'IdCondensorAperture',
+            0x40000025: 'CondensorAperture',
+            0x40000026: 'IdCondensorRevolver',
+            0x40000027: 'CondensorFilter',
+            0x40000028: 'IdTransmissionFilter1',
+            0x40000029: 'IdTransmission1',
+            0x40000030: 'IdTransmissionFilter2',
+            0x40000031: 'IdTransmission2',
+            0x40000032: 'RepeatBleach',
+            0x40000033: 'EnableSpotBleachPos',
+            0x40000034: 'SpotBleachPosx',
+            0x40000035: 'SpotBleachPosy',
+            0x40000036: 'SpotBleachPosz',
+            0x40000037: 'IdTubelens',
+            0x40000038: 'IdTubelensPosition',
+            0x40000039: 'TransmittedLight',
+            0x4000003a: 'ReflectedLight',
+            0x4000003b: 'SimultanGrabAndBleach',
+            0x4000003c: 'BleachPixelTime',
+            # Laser
+            0x50000001: 'Name',
+            0x50000002: 'Acquire',
+            0x50000003: 'Power',
+            # DetectionChannel
+            0x70000001: 'IntegrationMode',
+            0x70000002: 'SpecialMode',
+            0x70000003: 'DetectorGainFirst',
+            0x70000004: 'DetectorGainLast',
+            0x70000005: 'AmplifierGainFirst',
+            0x70000006: 'AmplifierGainLast',
+            0x70000007: 'AmplifierOffsFirst',
+            0x70000008: 'AmplifierOffsLast',
+            0x70000009: 'PinholeDiameter',
+            0x7000000a: 'CountingTrigger',
+            0x7000000b: 'Acquire',
+            0x7000000c: 'PointDetectorName',
+            0x7000000d: 'AmplifierName',
+            0x7000000e: 'PinholeName',
+            0x7000000f: 'FilterSetName',
+            0x70000010: 'FilterName',
+            0x70000013: 'IntegratorName',
+            0x70000014: 'ChannelName',
+            0x70000015: 'DetectorGainBc1',
+            0x70000016: 'DetectorGainBc2',
+            0x70000017: 'AmplifierGainBc1',
+            0x70000018: 'AmplifierGainBc2',
+            0x70000019: 'AmplifierOffsetBc1',
+            0x70000020: 'AmplifierOffsetBc2',
+            0x70000021: 'SpectralScanChannels',
+            0x70000022: 'SpiWavelengthStart',
+            0x70000023: 'SpiWavelengthStop',
+            0x70000026: 'DyeName',
+            0x70000027: 'DyeFolder',
+            # IlluminationChannel
+            0x90000001: 'Name',
+            0x90000002: 'Power',
+            0x90000003: 'Wavelength',
+            0x90000004: 'Aquire',
+            0x90000005: 'DetchannelName',
+            0x90000006: 'PowerBc1',
+            0x90000007: 'PowerBc2',
+            # BeamSplitter
+            0xb0000001: 'FilterSet',
+            0xb0000002: 'Filter',
+            0xb0000003: 'Name',
+            # DataChannel
+            0xd0000001: 'Name',
+            0xd0000003: 'Acquire',
+            0xd0000004: 'Color',
+            0xd0000005: 'SampleType',
+            0xd0000006: 'BitsPerSample',
+            0xd0000007: 'RatioType',
+            0xd0000008: 'RatioTrack1',
+            0xd0000009: 'RatioTrack2',
+            0xd000000a: 'RatioChannel1',
+            0xd000000b: 'RatioChannel2',
+            0xd000000c: 'RatioConst1',
+            0xd000000d: 'RatioConst2',
+            0xd000000e: 'RatioConst3',
+            0xd000000f: 'RatioConst4',
+            0xd0000010: 'RatioConst5',
+            0xd0000011: 'RatioConst6',
+            0xd0000012: 'RatioFirstImages1',
+            0xd0000013: 'RatioFirstImages2',
+            0xd0000014: 'DyeName',
+            0xd0000015: 'DyeFolder',
+            0xd0000016: 'Spectrum',
+            0xd0000017: 'Acquire',
+            # Timer
+            0x12000001: 'Name',
+            0x12000002: 'Description',
+            0x12000003: 'Interval',
+            0x12000004: 'TriggerIn',
+            0x12000005: 'TriggerOut',
+            0x12000006: 'ActivationTime',
+            0x12000007: 'ActivationNumber',
+            # Marker
+            0x14000001: 'Name',
+            0x14000002: 'Description',
+            0x14000003: 'TriggerIn',
+            0x14000004: 'TriggerOut',
+        }
+
+    def NIH_IMAGE_HEADER():
+        return [
+            ('FileID', 'a8'),
+            ('nLines', 'i2'),
+            ('PixelsPerLine', 'i2'),
+            ('Version', 'i2'),
+            ('OldLutMode', 'i2'),
+            ('OldnColors', 'i2'),
+            ('Colors', 'u1', (3, 32)),
+            ('OldColorStart', 'i2'),
+            ('ColorWidth', 'i2'),
+            ('ExtraColors', 'u2', (6, 3)),
+            ('nExtraColors', 'i2'),
+            ('ForegroundIndex', 'i2'),
+            ('BackgroundIndex', 'i2'),
+            ('XScale', 'f8'),
+            ('Unused2', 'i2'),
+            ('Unused3', 'i2'),
+            ('UnitsID', 'i2'),  # NIH_UNITS_TYPE
+            ('p1', [('x', 'i2'), ('y', 'i2')]),
+            ('p2', [('x', 'i2'), ('y', 'i2')]),
+            ('CurveFitType', 'i2'),  # NIH_CURVEFIT_TYPE
+            ('nCoefficients', 'i2'),
+            ('Coeff', 'f8', 6),
+            ('UMsize', 'u1'),
+            ('UM', 'a15'),
+            ('UnusedBoolean', 'u1'),
+            ('BinaryPic', 'b1'),
+            ('SliceStart', 'i2'),
+            ('SliceEnd', 'i2'),
+            ('ScaleMagnification', 'f4'),
+            ('nSlices', 'i2'),
+            ('SliceSpacing', 'f4'),
+            ('CurrentSlice', 'i2'),
+            ('FrameInterval', 'f4'),
+            ('PixelAspectRatio', 'f4'),
+            ('ColorStart', 'i2'),
+            ('ColorEnd', 'i2'),
+            ('nColors', 'i2'),
+            ('Fill1', '3u2'),
+            ('Fill2', '3u2'),
+            ('Table', 'u1'),  # NIH_COLORTABLE_TYPE
+            ('LutMode', 'u1'),  # NIH_LUTMODE_TYPE
+            ('InvertedTable', 'b1'),
+            ('ZeroClip', 'b1'),
+            ('XUnitSize', 'u1'),
+            ('XUnit', 'a11'),
+            ('StackType', 'i2'),  # NIH_STACKTYPE_TYPE
+            # ('UnusedBytes', 'u1', 200)
+        ]
+
+    def NIH_COLORTABLE_TYPE():
+        return ('CustomTable', 'AppleDefault', 'Pseudo20', 'Pseudo32',
+                'Rainbow', 'Fire1', 'Fire2', 'Ice', 'Grays', 'Spectrum')
+
+    def NIH_LUTMODE_TYPE():
+        return ('PseudoColor', 'OldAppleDefault', 'OldSpectrum', 'GrayScale',
+                'ColorLut', 'CustomGrayscale')
+
+    def NIH_CURVEFIT_TYPE():
+        return ('StraightLine', 'Poly2', 'Poly3', 'Poly4', 'Poly5', 'ExpoFit',
+                'PowerFit', 'LogFit', 'RodbardFit', 'SpareFit1',
+                'Uncalibrated', 'UncalibratedOD')
+
+    def NIH_UNITS_TYPE():
+        return ('Nanometers', 'Micrometers', 'Millimeters', 'Centimeters',
+                'Meters', 'Kilometers', 'Inches', 'Feet', 'Miles', 'Pixels',
+                'OtherUnits')
+
+    def NIH_STACKTYPE_TYPE():
+        return ('VolumeStack', 'RGBStack', 'MovieStack', 'HSVStack')
+
+    def TVIPS_HEADER_V1():
+        # TVIPS TemData structure from EMMENU Help file
+        return [
+            ('Version', 'i4'),
+            ('CommentV1', 'a80'),
+            ('HighTension', 'i4'),
+            ('SphericalAberration', 'i4'),
+            ('IlluminationAperture', 'i4'),
+            ('Magnification', 'i4'),
+            ('PostMagnification', 'i4'),
+            ('FocalLength', 'i4'),
+            ('Defocus', 'i4'),
+            ('Astigmatism', 'i4'),
+            ('AstigmatismDirection', 'i4'),
+            ('BiprismVoltage', 'i4'),
+            ('SpecimenTiltAngle', 'i4'),
+            ('SpecimenTiltDirection', 'i4'),
+            ('IlluminationTiltDirection', 'i4'),
+            ('IlluminationTiltAngle', 'i4'),
+            ('ImageMode', 'i4'),
+            ('EnergySpread', 'i4'),
+            ('ChromaticAberration', 'i4'),
+            ('ShutterType', 'i4'),
+            ('DefocusSpread', 'i4'),
+            ('CcdNumber', 'i4'),
+            ('CcdSize', 'i4'),
+            ('OffsetXV1', 'i4'),
+            ('OffsetYV1', 'i4'),
+            ('PhysicalPixelSize', 'i4'),
+            ('Binning', 'i4'),
+            ('ReadoutSpeed', 'i4'),
+            ('GainV1', 'i4'),
+            ('SensitivityV1', 'i4'),
+            ('ExposureTimeV1', 'i4'),
+            ('FlatCorrected', 'i4'),
+            ('DeadPxCorrected', 'i4'),
+            ('ImageMean', 'i4'),
+            ('ImageStd', 'i4'),
+            ('DisplacementX', 'i4'),
+            ('DisplacementY', 'i4'),
+            ('DateV1', 'i4'),
+            ('TimeV1', 'i4'),
+            ('ImageMin', 'i4'),
+            ('ImageMax', 'i4'),
+            ('ImageStatisticsQuality', 'i4'),
+        ]
+
+    def TVIPS_HEADER_V2():
+        return [
+            ('ImageName', 'V160'),  # utf16
+            ('ImageFolder', 'V160'),
+            ('ImageSizeX', 'i4'),
+            ('ImageSizeY', 'i4'),
+            ('ImageSizeZ', 'i4'),
+            ('ImageSizeE', 'i4'),
+            ('ImageDataType', 'i4'),
+            ('Date', 'i4'),
+            ('Time', 'i4'),
+            ('Comment', 'V1024'),
+            ('ImageHistory', 'V1024'),
+            ('Scaling', '16f4'),
+            ('ImageStatistics', '16c16'),
+            ('ImageType', 'i4'),
+            ('ImageDisplaType', 'i4'),
+            ('PixelSizeX', 'f4'),  # distance between two px in x, [nm]
+            ('PixelSizeY', 'f4'),  # distance between two px in y, [nm]
+            ('ImageDistanceZ', 'f4'),
+            ('ImageDistanceE', 'f4'),
+            ('ImageMisc', '32f4'),
+            ('TemType', 'V160'),
+            ('TemHighTension', 'f4'),
+            ('TemAberrations', '32f4'),
+            ('TemEnergy', '32f4'),
+            ('TemMode', 'i4'),
+            ('TemMagnification', 'f4'),
+            ('TemMagnificationCorrection', 'f4'),
+            ('PostMagnification', 'f4'),
+            ('TemStageType', 'i4'),
+            ('TemStagePosition', '5f4'),  # x, y, z, a, b
+            ('TemImageShift', '2f4'),
+            ('TemBeamShift', '2f4'),
+            ('TemBeamTilt', '2f4'),
+            ('TilingParameters', '7f4'),  # 0: tiling? 1:x 2:y 3: max x
+                                          # 4: max y 5: overlap x 6: overlap y
+            ('TemIllumination', '3f4'),  # 0: spotsize 1: intensity
+            ('TemShutter', 'i4'),
+            ('TemMisc', '32f4'),
+            ('CameraType', 'V160'),
+            ('PhysicalPixelSizeX', 'f4'),
+            ('PhysicalPixelSizeY', 'f4'),
+            ('OffsetX', 'i4'),
+            ('OffsetY', 'i4'),
+            ('BinningX', 'i4'),
+            ('BinningY', 'i4'),
+            ('ExposureTime', 'f4'),
+            ('Gain', 'f4'),
+            ('ReadoutRate', 'f4'),
+            ('FlatfieldDescription', 'V160'),
+            ('Sensitivity', 'f4'),
+            ('Dose', 'f4'),
+            ('CamMisc', '32f4'),
+            ('FeiMicroscopeInformation', 'V1024'),
+            ('FeiSpecimenInformation', 'V1024'),
+            ('Magic', 'u4'),
+        ]
+
+    def MM_HEADER():
+        # Olympus FluoView MM_Header
+        MM_DIMENSION = [
+            ('Name', 'a16'),
+            ('Size', 'i4'),
+            ('Origin', 'f8'),
+            ('Resolution', 'f8'),
+            ('Unit', 'a64')]
+        return [
+            ('HeaderFlag', 'i2'),
+            ('ImageType', 'u1'),
+            ('ImageName', 'a257'),
+            ('OffsetData', 'u4'),
+            ('PaletteSize', 'i4'),
+            ('OffsetPalette0', 'u4'),
+            ('OffsetPalette1', 'u4'),
+            ('CommentSize', 'i4'),
+            ('OffsetComment', 'u4'),
+            ('Dimensions', MM_DIMENSION, 10),
+            ('OffsetPosition', 'u4'),
+            ('MapType', 'i2'),
+            ('MapMin', 'f8'),
+            ('MapMax', 'f8'),
+            ('MinValue', 'f8'),
+            ('MaxValue', 'f8'),
+            ('OffsetMap', 'u4'),
+            ('Gamma', 'f8'),
+            ('Offset', 'f8'),
+            ('GrayChannel', MM_DIMENSION),
+            ('OffsetThumbnail', 'u4'),
+            ('VoiceField', 'i4'),
+            ('OffsetVoiceField', 'u4'),
+        ]
+
+    def MM_DIMENSIONS():
+        # Map FluoView MM_Header.Dimensions to axes characters
+        return {
+            'X': 'X',
+            'Y': 'Y',
+            'Z': 'Z',
+            'T': 'T',
+            'CH': 'C',
+            'WAVELENGTH': 'C',
+            'TIME': 'T',
+            'XY': 'R',
+            'EVENT': 'V',
+            'EXPOSURE': 'L',
+        }
+
+    def UIC_TAGS():
+        # Map Universal Imaging Corporation MetaMorph internal tag ids to
+        # name and type
+        from fractions import Fraction
+
+        return [
+            ('AutoScale', int),
+            ('MinScale', int),
+            ('MaxScale', int),
+            ('SpatialCalibration', int),
+            ('XCalibration', Fraction),
+            ('YCalibration', Fraction),
+            ('CalibrationUnits', str),
+            ('Name', str),
+            ('ThreshState', int),
+            ('ThreshStateRed', int),
+            ('tagid_10', None),  # undefined
+            ('ThreshStateGreen', int),
+            ('ThreshStateBlue', int),
+            ('ThreshStateLo', int),
+            ('ThreshStateHi', int),
+            ('Zoom', int),
+            ('CreateTime', julian_datetime),
+            ('LastSavedTime', julian_datetime),
+            ('currentBuffer', int),
+            ('grayFit', None),
+            ('grayPointCount', None),
+            ('grayX', Fraction),
+            ('grayY', Fraction),
+            ('grayMin', Fraction),
+            ('grayMax', Fraction),
+            ('grayUnitName', str),
+            ('StandardLUT', int),
+            ('wavelength', int),
+            ('StagePosition', '(%i,2,2)u4'),  # N xy positions as fract
+            ('CameraChipOffset', '(%i,2,2)u4'),  # N xy offsets as fract
+            ('OverlayMask', None),
+            ('OverlayCompress', None),
+            ('Overlay', None),
+            ('SpecialOverlayMask', None),
+            ('SpecialOverlayCompress', None),
+            ('SpecialOverlay', None),
+            ('ImageProperty', read_uic_image_property),
+            ('StageLabel', '%ip'),  # N str
+            ('AutoScaleLoInfo', Fraction),
+            ('AutoScaleHiInfo', Fraction),
+            ('AbsoluteZ', '(%i,2)u4'),  # N fractions
+            ('AbsoluteZValid', '(%i,)u4'),  # N long
+            ('Gamma', 'I'),  # 'I' uses offset
+            ('GammaRed', 'I'),
+            ('GammaGreen', 'I'),
+            ('GammaBlue', 'I'),
+            ('CameraBin', '2I'),
+            ('NewLUT', int),
+            ('ImagePropertyEx', None),
+            ('PlaneProperty', int),
+            ('UserLutTable', '(256,3)u1'),
+            ('RedAutoScaleInfo', int),
+            ('RedAutoScaleLoInfo', Fraction),
+            ('RedAutoScaleHiInfo', Fraction),
+            ('RedMinScaleInfo', int),
+            ('RedMaxScaleInfo', int),
+            ('GreenAutoScaleInfo', int),
+            ('GreenAutoScaleLoInfo', Fraction),
+            ('GreenAutoScaleHiInfo', Fraction),
+            ('GreenMinScaleInfo', int),
+            ('GreenMaxScaleInfo', int),
+            ('BlueAutoScaleInfo', int),
+            ('BlueAutoScaleLoInfo', Fraction),
+            ('BlueAutoScaleHiInfo', Fraction),
+            ('BlueMinScaleInfo', int),
+            ('BlueMaxScaleInfo', int),
+            # ('OverlayPlaneColor', read_uic_overlay_plane_color),
+        ]
+
+    def PILATUS_HEADER():
+        # PILATUS CBF Header Specification, Version 1.4
+        # Map key to [value_indices], type
+        return {
+            'Detector': ([slice(1, None)], str),
+            'Pixel_size': ([1, 4], float),
+            'Silicon': ([3], float),
+            'Exposure_time': ([1], float),
+            'Exposure_period': ([1], float),
+            'Tau': ([1], float),
+            'Count_cutoff': ([1], int),
+            'Threshold_setting': ([1], float),
+            'Gain_setting': ([1, 2], str),
+            'N_excluded_pixels': ([1], int),
+            'Excluded_pixels': ([1], str),
+            'Flat_field': ([1], str),
+            'Trim_file': ([1], str),
+            'Image_path': ([1], str),
+            # optional
+            'Wavelength': ([1], float),
+            'Energy_range': ([1, 2], float),
+            'Detector_distance': ([1], float),
+            'Detector_Voffset': ([1], float),
+            'Beam_xy': ([1, 2], float),
+            'Flux': ([1], str),
+            'Filter_transmission': ([1], float),
+            'Start_angle': ([1], float),
+            'Angle_increment': ([1], float),
+            'Detector_2theta': ([1], float),
+            'Polarization': ([1], float),
+            'Alpha': ([1], float),
+            'Kappa': ([1], float),
+            'Phi': ([1], float),
+            'Phi_increment': ([1], float),
+            'Chi': ([1], float),
+            'Chi_increment': ([1], float),
+            'Oscillation_axis': ([slice(1, None)], str),
+            'N_oscillations': ([1], int),
+            'Start_position': ([1], float),
+            'Position_increment': ([1], float),
+            'Shutter_time': ([1], float),
+            'Omega': ([1], float),
+            'Omega_increment': ([1], float)
+        }
+
+    def REVERSE_BITORDER_BYTES():
+        # Bytes with reversed bitorder
+        return (
+            b'\x00\x80@\xc0 \xa0`\xe0\x10\x90P\xd00\xb0p\xf0\x08\x88H\xc8('
+            b'\xa8h\xe8\x18\x98X\xd88\xb8x\xf8\x04\x84D\xc4$\xa4d\xe4\x14'
+            b'\x94T\xd44\xb4t\xf4\x0c\x8cL\xcc,\xacl\xec\x1c\x9c\\\xdc<\xbc|'
+            b'\xfc\x02\x82B\xc2"\xa2b\xe2\x12\x92R\xd22\xb2r\xf2\n\x8aJ\xca*'
+            b'\xaaj\xea\x1a\x9aZ\xda:\xbaz\xfa\x06\x86F\xc6&\xa6f\xe6\x16'
+            b'\x96V\xd66\xb6v\xf6\x0e\x8eN\xce.\xaen\xee\x1e\x9e^\xde>\xbe~'
+            b'\xfe\x01\x81A\xc1!\xa1a\xe1\x11\x91Q\xd11\xb1q\xf1\t\x89I\xc9)'
+            b'\xa9i\xe9\x19\x99Y\xd99\xb9y\xf9\x05\x85E\xc5%\xa5e\xe5\x15'
+            b'\x95U\xd55\xb5u\xf5\r\x8dM\xcd-\xadm\xed\x1d\x9d]\xdd=\xbd}'
+            b'\xfd\x03\x83C\xc3#\xa3c\xe3\x13\x93S\xd33\xb3s\xf3\x0b\x8bK'
+            b'\xcb+\xabk\xeb\x1b\x9b[\xdb;\xbb{\xfb\x07\x87G\xc7\'\xa7g\xe7'
+            b'\x17\x97W\xd77\xb7w\xf7\x0f\x8fO\xcf/\xafo\xef\x1f\x9f_'
+            b'\xdf?\xbf\x7f\xff')
+
+    def REVERSE_BITORDER_ARRAY():
+        # Numpy array of bytes with reversed bitorder
+        return numpy.fromstring(TIFF.REVERSE_BITORDER_BYTES, dtype='uint8')
+
+    def ALLOCATIONGRANULARITY():
+        # alignment for writing contiguous data to TIFF
+        import mmap  # delayed import
+        return mmap.ALLOCATIONGRANULARITY
+
+    # Max line length of printed output
+    PRINT_LINE_WIDTH = 100
+
+    # Max number of lines to print
+    PRINT_MAX_LINES = 512
+
+
+def read_tags(fh, byteorder, offsetsize, tagnames,
+              customtags=None, maxifds=None):
+    """Read tags from chain of IFDs and return as list of dicts.
+
+    The file handle position must be at a valid IFD header.
+
+    """
+    if offsetsize == 4:
+        offsetformat = byteorder+'I'
+        tagnosize = 2
+        tagnoformat = byteorder+'H'
+        tagsize = 12
+        tagformat1 = byteorder+'HH'
+        tagformat2 = byteorder+'I4s'
+    elif offsetsize == 8:
+        offsetformat = byteorder+'Q'
+        tagnosize = 8
+        tagnoformat = byteorder+'Q'
+        tagsize = 20
+        tagformat1 = byteorder+'HH'
+        tagformat2 = byteorder+'Q8s'
+    else:
+        raise ValueError("invalid offset size")
+
+    if customtags is None:
+        customtags = {}
+    if maxifds is None:
+        maxifds = 2**32
+
+    result = []
+    unpack = struct.unpack
+    offset = fh.tell()
+    while len(result) < maxifds:
+        # loop over IFDs
+        try:
+            tagno = unpack(tagnoformat, fh.read(tagnosize))[0]
+            if tagno > 4096:
+                raise ValueError("suspicious number of tags")
+        except Exception:
+            warnings.warn("corrupted tag list at offset %i" % offset)
+            break
+
+        tags = {}
+        data = fh.read(tagsize * tagno)
+        pos = fh.tell()
+        index = 0
+        for _ in range(tagno):
+            code, type_ = unpack(tagformat1, data[index:index+4])
+            count, value = unpack(tagformat2, data[index+4:index+tagsize])
+            index += tagsize
+            name = tagnames.get(code, str(code))
+            try:
+                dtype = TIFF.DATA_FORMATS[type_]
+            except KeyError:
+                raise TiffTag.Error("unknown tag data type %i" % type_)
+
+            fmt = '%s%i%s' % (byteorder, count * int(dtype[0]), dtype[1])
+            size = struct.calcsize(fmt)
+            if size > offsetsize or code in customtags:
+                offset = unpack(offsetformat, value)[0]
+                if offset < 8 or offset > fh.size - size:
+                    raise TiffTag.Error("invalid tag value offset")
+                fh.seek(offset)
+                if code in customtags:
+                    readfunc = customtags[code][1]
+                    value = readfunc(fh, byteorder, dtype, count, offsetsize)
+                elif code in tagnames or dtype[-1] == 's':
+                    value = unpack(fmt, fh.read(size))
+                else:
+                    value = read_numpy(fh, byteorder, dtype, count, offsetsize)
+            else:
+                value = unpack(fmt, value[:size])
+
+            if code not in customtags and code not in TIFF.TAG_TUPLE:
+                if len(value) == 1:
+                    value = value[0]
+            if type_ != 7 and dtype[-1] == 's' and isinstance(value, bytes):
+                # TIFF ASCII fields can contain multiple strings,
+                #   each terminated with a NUL
+                value = bytes2str(stripascii(value))
+
+            tags[name] = value
+
+        result.append(tags)
+        # read offset to next page
+        fh.seek(pos)
+        offset = unpack(offsetformat, fh.read(offsetsize))[0]
+        if offset == 0:
+            break
+        if offset >= fh.size:
+            warnings.warn("invalid page offset (%i)" % offset)
+            break
+        fh.seek(offset)
+
+    if maxifds == 1:
+        result = result[0]
+    return result
+
+
+def read_exif_ifd(fh, byteorder, dtype, count, offsetsize):
+    """Read EXIF tags from file and return as dict."""
+    tags = read_tags(fh, byteorder, offsetsize, TIFF.EXIF_TAGS, maxifds=1)
+    if 'ExifVersion' in tags:
+        tags['ExifVersion'] = bytes2str(tags['ExifVersion'])
+    return tags
+
+
+def read_gps_ifd(fh, byteorder, dtype, count, offsetsize):
+    """Read GPS tags from file and return as dict."""
+    return read_tags(fh, byteorder, offsetsize, TIFF.GPS_TAGS, maxifds=1)
+
 
-def read_bytes(fh, byteorder, dtype, count):
+def read_interoperability_ifd(fh, byteorder, dtype, count, offsetsize):
+    """Read Interoperability tags from file and return as dict."""
+    tag_names = {1: 'InteroperabilityIndex'}
+    return read_tags(fh, byteorder, offsetsize, tag_names, maxifds=1)
+
+
+def read_bytes(fh, byteorder, dtype, count, offsetsize):
     """Read tag data from file and return as byte string."""
     dtype = 'b' if dtype[-1] == 's' else byteorder+dtype[-1]
     return fh.read_array(dtype, count).tostring()
 
 
-def read_numpy(fh, byteorder, dtype, count):
+def read_utf8(fh, byteorder, dtype, count, offsetsize):
+    """Read tag data from file and return as unicode string."""
+    return fh.read(count).decode('utf-8')
+
+
+def read_numpy(fh, byteorder, dtype, count, offsetsize):
     """Read tag data from file and return as numpy array."""
     dtype = 'b' if dtype[-1] == 's' else byteorder+dtype[-1]
     return fh.read_array(dtype, count)
 
 
-def read_json(fh, byteorder, dtype, count):
+def read_colormap(fh, byteorder, dtype, count, offsetsize):
+    """Read ColorMap data from file and return as numpy array."""
+    cmap = fh.read_array(byteorder+dtype[-1], count)
+    cmap.shape = (3, -1)
+    return cmap
+
+
+def read_json(fh, byteorder, dtype, count, offsetsize):
     """Read JSON tag data from file and return as object."""
     data = fh.read(count)
     try:
@@ -3714,20 +6344,28 @@ def read_json(fh, byteorder, dtype, count):
         warnings.warn("invalid JSON '%s'" % data)
 
 
-def read_mm_header(fh, byteorder, dtype, count):
-    """Read MM_HEADER tag from file and return as numpy.rec.array."""
-    return fh.read_record(MM_HEADER, byteorder=byteorder)
+def read_mm_header(fh, byteorder, dtype, count, offsetsize):
+    """Read FluoView mm_header tag from file and return as dict."""
+    mmh = fh.read_record(TIFF.MM_HEADER, byteorder=byteorder)
+    mmh = recarray2dict(mmh)
+    mmh['Dimensions'] = [
+        (bytes2str(d[0]).strip(), d[1], d[2], d[3], bytes2str(d[4]).strip())
+        for d in mmh['Dimensions']]
+    d = mmh['GrayChannel']
+    mmh['GrayChannel'] = (
+        bytes2str(d[0]).strip(), d[1], d[2], d[3], bytes2str(d[4]).strip())
+    return mmh
 
 
-def read_mm_stamp(fh, byteorder, dtype, count):
-    """Read MM_STAMP tag from file and return as numpy.ndarray."""
+def read_mm_stamp(fh, byteorder, dtype, count, offsetsize):
+    """Read FluoView mm_stamp tag from file and return as numpy.ndarray."""
     return fh.read_array(byteorder+'f8', 8)
 
 
-def read_uic1tag(fh, byteorder, dtype, count, plane_count=None):
-    """Read MetaMorph STK UIC1Tag from file and return as dictionary.
+def read_uic1tag(fh, byteorder, dtype, count, offsetsize, planecount=None):
+    """Read MetaMorph STK UIC1Tag from file and return as dict.
 
-    Return empty dictionary if plane_count is unknown.
+    Return empty dictionary if planecount is unknown.
 
     """
     assert dtype in ('2I', '1I') and byteorder == '<'
@@ -3735,53 +6373,52 @@ def read_uic1tag(fh, byteorder, dtype, count, plane_count=None):
     if dtype == '2I':
         # pre MetaMorph 2.5 (not tested)
         values = fh.read_array('<u4', 2*count).reshape(count, 2)
-        result = {'z_distance': values[:, 0] / values[:, 1]}
-    elif plane_count:
+        result = {'ZDistance': values[:, 0] / values[:, 1]}
+    elif planecount:
         for _ in range(count):
             tagid = struct.unpack('<I', fh.read(4))[0]
             if tagid in (28, 29, 37, 40, 41):
                 # silently skip unexpected tags
                 fh.read(4)
                 continue
-            name, value = read_uic_tag(fh, tagid, plane_count, offset=True)
+            name, value = read_uic_tag(fh, tagid, planecount, offset=True)
             result[name] = value
     return result
 
 
-def read_uic2tag(fh, byteorder, dtype, plane_count):
-    """Read MetaMorph STK UIC2Tag from file and return as dictionary."""
+def read_uic2tag(fh, byteorder, dtype, planecount, offsetsize):
+    """Read MetaMorph STK UIC2Tag from file and return as dict."""
     assert dtype == '2I' and byteorder == '<'
-    values = fh.read_array('<u4', 6*plane_count).reshape(plane_count, 6)
+    values = fh.read_array('<u4', 6*planecount).reshape(planecount, 6)
     return {
-        'z_distance': values[:, 0] / values[:, 1],
-        'date_created': values[:, 2],  # julian days
-        'time_created': values[:, 3],  # milliseconds
-        'date_modified': values[:, 4],  # julian days
-        'time_modified': values[:, 5],  # milliseconds
-    }
+        'ZDistance': values[:, 0] / values[:, 1],
+        'DateCreated': values[:, 2],  # julian days
+        'TimeCreated': values[:, 3],  # milliseconds
+        'DateModified': values[:, 4],  # julian days
+        'TimeModified': values[:, 5]}  # milliseconds
 
 
-def read_uic3tag(fh, byteorder, dtype, plane_count):
-    """Read MetaMorph STK UIC3Tag from file and return as dictionary."""
+def read_uic3tag(fh, byteorder, dtype, planecount, offsetsize):
+    """Read MetaMorph STK UIC3Tag from file and return as dict."""
     assert dtype == '2I' and byteorder == '<'
-    values = fh.read_array('<u4', 2*plane_count).reshape(plane_count, 2)
-    return {'wavelengths': values[:, 0] / values[:, 1]}
+    values = fh.read_array('<u4', 2*planecount).reshape(planecount, 2)
+    return {'Wavelengths': values[:, 0] / values[:, 1]}
 
 
-def read_uic4tag(fh, byteorder, dtype, plane_count):
-    """Read MetaMorph STK UIC4Tag from file and return as dictionary."""
+def read_uic4tag(fh, byteorder, dtype, planecount, offsetsize):
+    """Read MetaMorph STK UIC4Tag from file and return as dict."""
     assert dtype == '1I' and byteorder == '<'
     result = {}
     while True:
         tagid = struct.unpack('<H', fh.read(2))[0]
         if tagid == 0:
             break
-        name, value = read_uic_tag(fh, tagid, plane_count, offset=False)
+        name, value = read_uic_tag(fh, tagid, planecount, offset=False)
         result[name] = value
     return result
 
 
-def read_uic_tag(fh, tagid, plane_count, offset):
+def read_uic_tag(fh, tagid, planecount, offset):
     """Read a single UIC tag value from file and return tag name and value.
 
     UIC1Tags use an offset.
@@ -3792,18 +6429,22 @@ def read_uic_tag(fh, tagid, plane_count, offset):
         return value[0] if count == 1 else value
 
     try:
-        name, dtype = UIC_TAGS[tagid]
-    except KeyError:
+        name, dtype = TIFF.UIC_TAGS[tagid]
+    except IndexError:
         # unknown tag
-        return '_tagid_%i' % tagid, read_int()
+        return '_TagId%i' % tagid, read_int()
+
+    Fraction = TIFF.UIC_TAGS[4][1]
 
     if offset:
         pos = fh.tell()
         if dtype not in (int, None):
             off = read_int()
             if off < 8:
-                warnings.warn("invalid offset for uic tag '%s': %i"
-                              % (name, off))
+                if dtype is str:
+                    return name, ''
+                warnings.warn("invalid offset for uic tag '%s': %i" %
+                              (name, off))
                 return name, off
             fh.seek(off)
 
@@ -3829,7 +6470,7 @@ def read_uic_tag(fh, tagid, plane_count, offset):
         size = read_int()
         if 0 <= size < 2**10:
             value = struct.unpack('%is' % size, fh.read(size))[0][:-1]
-            value = stripnull(value)
+            value = bytes2str(stripnull(value))
         elif offset:
             value = ''
             warnings.warn("corrupt string in uic tag '%s'" % name)
@@ -3838,11 +6479,11 @@ def read_uic_tag(fh, tagid, plane_count, offset):
     elif dtype == '%ip':
         # sequence of pascal strings
         value = []
-        for _ in range(plane_count):
+        for _ in range(planecount):
             size = read_int()
             if 0 <= size < 2**10:
                 string = struct.unpack('%is' % size, fh.read(size))[0][:-1]
-                string = stripnull(string)
+                string = bytes2str(stripnull(string))
                 value.append(string)
             elif offset:
                 warnings.warn("corrupt string in uic tag '%s'" % name)
@@ -3852,7 +6493,7 @@ def read_uic_tag(fh, tagid, plane_count, offset):
         # struct or numpy type
         dtype = '<' + dtype
         if '%i' in dtype:
-            dtype = dtype % plane_count
+            dtype = dtype % planecount
         if '(' in dtype:
             # numpy type
             value = fh.read_array(dtype, 1)[0]
@@ -3886,76 +6527,119 @@ def read_uic_image_property(fh):
     return dict(name=name, flags=flags, value=value)
 
 
-def read_cz_lsm_info(fh, byteorder, dtype, count):
-    """Read CS_LSM_INFO tag from file and return as numpy.rec.array."""
+def read_cz_lsminfo(fh, byteorder, dtype, count, offsetsize):
+    """Read CZ_LSMINFO tag from file and return as dict."""
     assert byteorder == '<'
     magic_number, structure_size = struct.unpack('<II', fh.read(8))
     if magic_number not in (50350412, 67127628):
-        raise ValueError("invalid CS_LSM_INFO structure")
+        raise ValueError("invalid CZ_LSMINFO structure")
     fh.seek(-8, 1)
 
-    if structure_size < numpy.dtype(CZ_LSM_INFO).itemsize:
+    if structure_size < numpy.dtype(TIFF.CZ_LSMINFO).itemsize:
         # adjust structure according to structure_size
-        cz_lsm_info = []
+        lsminfo = []
         size = 0
-        for name, dtype in CZ_LSM_INFO:
+        for name, dtype in TIFF.CZ_LSMINFO:
             size += numpy.dtype(dtype).itemsize
             if size > structure_size:
                 break
-            cz_lsm_info.append((name, dtype))
+            lsminfo.append((name, dtype))
     else:
-        cz_lsm_info = CZ_LSM_INFO
+        lsminfo = TIFF.CZ_LSMINFO
 
-    return fh.read_record(cz_lsm_info, byteorder=byteorder)
+    lsminfo = fh.read_record(lsminfo, byteorder=byteorder)
+    lsminfo = recarray2dict(lsminfo)
 
+    # read LSM info subrecords at offsets
+    for name, reader in TIFF.CZ_LSMINFO_READERS.items():
+        if reader is None:
+            continue
+        offset = lsminfo.get('Offset' + name, 0)
+        if offset < 8:
+            continue
+        fh.seek(offset)
+        try:
+            lsminfo[name] = reader(fh)
+        except ValueError:
+            pass
+    return lsminfo
 
-def read_cz_lsm_floatpairs(fh):
+
+def read_lsm_floatpairs(fh):
     """Read LSM sequence of float pairs from file and return as list."""
     size = struct.unpack('<i', fh.read(4))[0]
     return fh.read_array('<2f8', count=size)
 
 
-def read_cz_lsm_positions(fh):
+def read_lsm_positions(fh):
     """Read LSM positions from file and return as list."""
     size = struct.unpack('<I', fh.read(4))[0]
     return fh.read_array('<2f8', count=size)
 
 
-def read_cz_lsm_time_stamps(fh):
+def read_lsm_timestamps(fh):
     """Read LSM time stamps from file and return as list."""
     size, count = struct.unpack('<ii', fh.read(8))
     if size != (8 + 8 * count):
-        raise ValueError("lsm_time_stamps block is too short")
+        warnings.warn("invalid LSM TimeStamps block")
+        return []
     # return struct.unpack('<%dd' % count, fh.read(8*count))
     return fh.read_array('<f8', count=count)
 
 
-def read_cz_lsm_event_list(fh):
+def read_lsm_eventlist(fh):
     """Read LSM events from file and return as list of (time, type, text)."""
     count = struct.unpack('<II', fh.read(8))[1]
     events = []
     while count > 0:
         esize, etime, etype = struct.unpack('<IdI', fh.read(16))
-        etext = stripnull(fh.read(esize - 16))
+        etext = bytes2str(stripnull(fh.read(esize - 16)))
         events.append((etime, etype, etext))
         count -= 1
     return events
 
 
-def read_cz_lsm_scan_info(fh):
-    """Read LSM scan information from file and return as Record."""
-    block = Record()
+def read_lsm_channelcolors(fh):
+    """Read LSM ChannelColors structure from file and return as dict."""
+    result = {'Mono': False, 'Colors': [], 'ColorNames': []}
+    pos = fh.tell()
+    (size, ncolors, nnames,
+     coffset, noffset, mono) = struct.unpack('<IIIIII', fh.read(24))
+    if ncolors != nnames:
+        warnings.warn("invalid LSM ChannelColors structure")
+        return result
+    result['Mono'] = bool(mono)
+    # Colors
+    fh.seek(pos + coffset)
+    colors = fh.read_array('uint8', count=ncolors*4).reshape((ncolors, 4))
+    result['Colors'] = colors.tolist()
+    # ColorNames
+    fh.seek(pos + noffset)
+    buffer = fh.read(size - noffset)
+    names = []
+    while len(buffer) > 4:
+        size = struct.unpack('<I', buffer[:4])[0]
+        names.append(bytes2str(buffer[4:3+size]))
+        buffer = buffer[4+size:]
+    result['ColorNames'] = names
+    return result
+
+
+def read_lsm_scaninfo(fh):
+    """Read LSM ScanInfo structure from file and return as dict."""
+    block = {}
     blocks = [block]
     unpack = struct.unpack
-    if 0x10000000 != struct.unpack('<I', fh.read(4))[0]:
+    if struct.unpack('<I', fh.read(4))[0] != 0x10000000:
         # not a Recording sub block
-        raise ValueError("not a lsm_scan_info structure")
+        warnings.warn("invalid LSM ScanInfo structure")
+        return block
     fh.read(8)
     while True:
         entry, dtype, size = unpack('<III', fh.read(12))
         if dtype == 2:
             # ascii
-            value = stripnull(fh.read(size))
+            value = bytes2str(stripnull(fh.read(size)))
         elif dtype == 4:
             # long
             value = unpack('<i', fh.read(4))[0]
@@ -3964,76 +6648,84 @@ def read_cz_lsm_scan_info(fh):
             value = unpack('<d', fh.read(8))[0]
         else:
             value = 0
-        if entry in CZ_LSM_SCAN_INFO_ARRAYS:
+        if entry in TIFF.CZ_LSMINFO_SCANINFO_ARRAYS:
             blocks.append(block)
-            name = CZ_LSM_SCAN_INFO_ARRAYS[entry]
+            name = TIFF.CZ_LSMINFO_SCANINFO_ARRAYS[entry]
             newobj = []
-            setattr(block, name, newobj)
+            block[name] = newobj
             block = newobj
-        elif entry in CZ_LSM_SCAN_INFO_STRUCTS:
+        elif entry in TIFF.CZ_LSMINFO_SCANINFO_STRUCTS:
             blocks.append(block)
-            newobj = Record()
+            newobj = {}
             block.append(newobj)
             block = newobj
-        elif entry in CZ_LSM_SCAN_INFO_ATTRIBUTES:
-            name = CZ_LSM_SCAN_INFO_ATTRIBUTES[entry]
-            setattr(block, name, value)
+        elif entry in TIFF.CZ_LSMINFO_SCANINFO_ATTRIBUTES:
+            name = TIFF.CZ_LSMINFO_SCANINFO_ATTRIBUTES[entry]
+            block[name] = value
         elif entry == 0xffffffff:
             # end sub block
             block = blocks.pop()
         else:
             # unknown entry
-            setattr(block, "entry_0x%x" % entry, value)
+            block["Entry0x%x" % entry] = value
         if not blocks:
             break
     return block
 
 
-def read_tvips_header(fh, byteorder, dtype, count):
-    """Read TVIPS EM-MENU headers and return as Record."""
-    header = Record(fh.read_record(TVIPS_HEADER_V1, byteorder=byteorder))
-    if header.version == 2:
-        header = Record(fh.read_record(TVIPS_HEADER_V2, byteorder=byteorder))
-        if header.magic != int(0xaaaaaaaa):
-            raise ValueError("invalid TVIPS v2 magic number")
+def read_tvips_header(fh, byteorder, dtype, count, offsetsize):
+    """Read TVIPS EM-MENU headers and return as dict."""
+    result = {}
+    header = fh.read_record(TIFF.TVIPS_HEADER_V1, byteorder=byteorder)
+    for name, typestr in TIFF.TVIPS_HEADER_V1:
+        result[name] = header[name].tolist()
+    if header['Version'] == 2:
+        header = fh.read_record(TIFF.TVIPS_HEADER_V2, byteorder=byteorder)
+        if header['Magic'] != int(0xaaaaaaaa):
+            warnings.warn("invalid TVIPS v2 magic number")
+            return {}
         # decode utf16 strings
-        for name, typestr in TVIPS_HEADER_V2:
+        for name, typestr in TIFF.TVIPS_HEADER_V2:
             if typestr.startswith('V'):
                 s = header[name].tostring().decode('utf16', errors='ignore')
-                header[name] = stripnull(s, null='\0')
+                result[name] = stripnull(s, null='\0')
+            else:
+                result[name] = header[name].tolist()
         # convert nm to m
-        for axis in 'xy':
-            header['physical_pixel_size_' + axis] /= 1e9
-            header['pixel_size_' + axis] /= 1e9
+        for axis in 'XY':
+            header['PhysicalPixelSize' + axis] /= 1e9
+            header['PixelSize' + axis] /= 1e9
     elif header.version != 1:
-        raise ValueError("unknown TVIPS header version")
-    return header
+        warnings.warn("unknown TVIPS header version")
+        return {}
+    return result
 
 
-def read_fei_metadata(fh, byteorder, dtype, count):
-    """Read FEI SFEG/HELIOS headers and return as nested Record."""
-    result = Record()
-    section = Record()
-    for line in fh.read(count).splitlines():
+def read_fei_metadata(fh, byteorder, dtype, count, offsetsize):
+    """Read FEI SFEG/HELIOS headers and return as dict."""
+    result = {}
+    section = {}
+    data = bytes2str(fh.read(count))
+    for line in data.splitlines():
         line = line.strip()
-        if line.startswith(b'['):
-            section = Record()
-            result[bytes2str(line[1:-1])] = section
+        if line.startswith('['):
+            section = {}
+            result[line[1:-1]] = section
             continue
         try:
-            key, value = line.split(b'=')
+            key, value = line.split('=')
         except ValueError:
             continue
-        section[bytes2str(key)] = astype(value)
+        section[key] = astype(value)
     return result
 
 
-def read_sem_metadata(fh, byteorder, dtype, count):
-    """Read Zeiss SEM tag and return as Record."""
-    result = Record({'': ()})
+def read_cz_sem(fh, byteorder, dtype, count, offsetsize):
+    """Read Zeiss SEM tag and return as dict."""
+    result = {'': ()}
     key = None
-    for line in fh.read(count).splitlines():
-        line = line.decode('cp1252')
+    data = bytes2str(fh.read(count))
+    for line in data.splitlines():
         if line.isupper():
             key = line.lower()
         elif key:
@@ -4066,13 +6758,42 @@ def read_sem_metadata(fh, byteorder, dtype, count):
     return result
 
 
-def read_nih_image_header(fh, byteorder, dtype, count):
-    """Read NIH_IMAGE_HEADER tag from file and return as numpy.rec.array."""
-    a = fh.read_record(NIH_IMAGE_HEADER, byteorder=byteorder)
-    a = a.newbyteorder(byteorder)
-    a.xunit = a.xunit[:a._xunit_len]
-    a.um = a.um[:a._um_len]
-    return a
+def read_nih_image_header(fh, byteorder, dtype, count, offsetsize):
+    """Read NIH_IMAGE_HEADER tag from file and return as dict."""
+    a = fh.read_record(TIFF.NIH_IMAGE_HEADER, byteorder=byteorder)
+    a = a.newbyteorder(byteorder)
+    a = recarray2dict(a)
+    a['XUnit'] = a['XUnit'][:a['XUnitSize']]
+    a['UM'] = a['UM'][:a['UMsize']]
+    return a
+
+
+def read_scanimage_metadata(fh):
+    """Read ScanImage BigTIFF v3 static and ROI metadata from open file.
+
+    Return non-varying frame data as dict and ROI group data as JSON.
+
+    The settings can be used to read image data and metadata without parsing
+    the TIFF file.
+
+    Raise ValueError if file does not contain valid ScanImage v3 metadata.
+
+    """
+    fh.seek(0)
+    try:
+        byteorder, version = struct.unpack('<2sH', fh.read(4))
+        if byteorder != b'II' or version != 43:
+            raise Exception
+        fh.seek(16)
+        magic, version, size0, size1 = struct.unpack('<IIII', fh.read(16))
+        if magic != 117637889 or version != 3:
+            raise Exception
+    except Exception:
+        raise ValueError("not a ScanImage BigTIFF v3 file")
+
+    frame_data = matlabstr2py(bytes2str(fh.read(size0)[:-1]))
+    roi_data = read_json(fh, '<', None, size1, None)
+    return frame_data, roi_data
 
 
 def read_micromanager_metadata(fh):
@@ -4080,7 +6801,7 @@ def read_micromanager_metadata(fh):
 
     The settings can be used to read image data without parsing the TIFF file.
 
-    Raise ValueError if file does not contain valid MicroManager metadata.
+    Raise ValueError if the file does not contain valid MicroManager metadata.
 
     """
     fh.seek(0)
@@ -4096,60 +6817,75 @@ def read_micromanager_metadata(fh):
      ) = struct.unpack(byteorder + "IIIIIIII", fh.read(32))
 
     if summary_header != 2355492:
-        raise ValueError("invalid MicroManager summary_header")
-    result['summary'] = read_json(fh, byteorder, None, summary_length)
+        raise ValueError("invalid MicroManager summary header")
+    result['Summary'] = read_json(fh, byteorder, None, summary_length, None)
 
     if index_header != 54773648:
-        raise ValueError("invalid MicroManager index_header")
+        raise ValueError("invalid MicroManager index header")
     fh.seek(index_offset)
     header, count = struct.unpack(byteorder + "II", fh.read(8))
     if header != 3453623:
-        raise ValueError("invalid MicroManager index_header")
+        raise ValueError("invalid MicroManager index header")
     data = struct.unpack(byteorder + "IIIII"*count, fh.read(20*count))
-    result['index_map'] = {
-        'channel': data[::5], 'slice': data[1::5], 'frame': data[2::5],
-        'position': data[3::5], 'offset': data[4::5]}
+    result['IndexMap'] = {'Channel': data[::5],
+                          'Slice': data[1::5],
+                          'Frame': data[2::5],
+                          'Position': data[3::5],
+                          'Offset': data[4::5]}
 
     if display_header != 483765892:
-        raise ValueError("invalid MicroManager display_header")
+        raise ValueError("invalid MicroManager display header")
     fh.seek(display_offset)
     header, count = struct.unpack(byteorder + "II", fh.read(8))
     if header != 347834724:
-        raise ValueError("invalid MicroManager display_header")
-    result['display_settings'] = read_json(fh, byteorder, None, count)
+        raise ValueError("invalid MicroManager display header")
+    result['DisplaySettings'] = read_json(fh, byteorder, None, count, None)
 
     if comments_header != 99384722:
-        raise ValueError("invalid MicroManager comments_header")
+        raise ValueError("invalid MicroManager comments header")
     fh.seek(comments_offset)
     header, count = struct.unpack(byteorder + "II", fh.read(8))
     if header != 84720485:
-        raise ValueError("invalid MicroManager comments_header")
-    result['comments'] = read_json(fh, byteorder, None, count)
+        raise ValueError("invalid MicroManager comments header")
+    result['Comments'] = read_json(fh, byteorder, None, count, None)
 
     return result
 
 
+def read_metaseries_catalog(fh):
+    """Read MetaSeries non-TIFF hint catalog from file.
+
+    Raise ValueError if the file does not contain a valid hint catalog.
+
+    """
+    # TODO: implement read_metaseries_catalog
+    raise NotImplementedError()
+
+
 def imagej_metadata(data, bytecounts, byteorder):
-    """Return dictionary from ImageJ metadata tag value."""
-    _str = str if sys.version_info[0] < 3 else lambda x: str(x, 'cp1252')
+    """Return IJMetadata tag value as dict.
 
-    def read_string(data, byteorder):
-        return _str(stripnull(data[0 if byteorder == '<' else 1::2]))
+    The 'info' string can have multiple formats, e.g. OIF or ScanImage,
+    that might be parsed into dicts using the matlabstr2py or
+    oiffile.SettingsFile functions.
+
+    """
+    def readstring(data, byteorder):
+        return data.decode('utf-16' + {'>': 'be', '<': 'le'}[byteorder])
 
-    def read_double(data, byteorder):
+    def readdouble(data, byteorder):
         return struct.unpack(byteorder+('d' * (len(data) // 8)), data)
 
-    def read_bytes(data, byteorder):
-        #return struct.unpack('b' * len(data), data)
+    def readbytes(data, byteorder):
         return numpy.fromstring(data, 'uint8')
 
     metadata_types = {  # big endian
-        b'info': ('info', read_string),
-        b'labl': ('labels', read_string),
-        b'rang': ('ranges', read_double),
-        b'luts': ('luts', read_bytes),
-        b'roi ': ('roi', read_bytes),
-        b'over': ('overlays', read_bytes)}
+        b'info': ('Info', readstring),
+        b'labl': ('Labels', readstring),
+        b'rang': ('Ranges', readdouble),
+        b'luts': ('LUTs', readbytes),
+        b'roi ': ('ROI', readbytes),
+        b'over': ('Overlays', readbytes)}
     metadata_types.update(  # little endian
         dict((k[::-1], v) for k, v in metadata_types.items()))
 
@@ -4170,7 +6906,7 @@ def imagej_metadata(data, bytecounts, byteorder):
     result = {}
     for mtype, count in zip(header[::2], header[1::2]):
         values = []
-        name, func = metadata_types.get(mtype, (_str(mtype), read_bytes))
+        name, func = metadata_types.get(mtype, (bytes2str(mtype), read_bytes))
         for _ in range(count):
             counter += 1
             pos1 = pos + bytecounts[counter]
@@ -4180,35 +6916,35 @@ def imagej_metadata(data, bytecounts, byteorder):
     return result
 
 
-def imagej_description_dict(description):
-    """Return dictionary from ImageJ image description byte string.
+def imagej_description_metadata(description):
+    """Return metatata from ImageJ image description as dict.
 
     Raise ValueError if not a valid ImageJ description.
 
-    >>> description = b'ImageJ=1.11a\\nimages=510\\nhyperstack=true\\n'
-    >>> imagej_description_dict(description)  # doctest: +SKIP
+    >>> description = 'ImageJ=1.11a\\nimages=510\\nhyperstack=true\\n'
+    >>> imagej_description_metadata(description)  # doctest: +SKIP
     {'ImageJ': '1.11a', 'images': 510, 'hyperstack': True}
 
     """
     def _bool(val):
-        return {b'true': True, b'false': False}[val.lower()]
+        return {'true': True, 'false': False}[val.lower()]
 
-    _str = str if sys.version_info[0] < 3 else lambda x: str(x, 'cp1252')
     result = {}
     for line in description.splitlines():
         try:
-            key, val = line.split(b'=')
+            key, val = line.split('=')
         except Exception:
             continue
         key = key.strip()
         val = val.strip()
-        for dtype in (int, float, _bool, _str):
+        for dtype in (int, float, _bool):
             try:
                 val = dtype(val)
                 break
             except Exception:
                 pass
-        result[_str(key)] = val
+        result[key] = val
+
     if 'ImageJ' not in result:
         raise ValueError("not a ImageJ image description")
     return result
@@ -4216,7 +6952,7 @@ def imagej_description_dict(description):
 
 def imagej_description(shape, rgb=None, colormaped=False, version='1.11a',
                        hyperstack=None, mode=None, loop=None, **kwargs):
-    """Return ImageJ image decription from data shape as byte string.
+    """Return ImageJ image description from data shape.
 
     ImageJ can handle up to 6 dimensions in order TZCYXS.
 
@@ -4240,7 +6976,6 @@ def imagej_description(shape, rgb=None, colormaped=False, version='1.11a',
     append = []
     result.append('images=%i' % product(shape[:-3]))
     if hyperstack is None:
-        #if product(shape[:-3]) > 1:
         hyperstack = True
         append.append('hyperstack=true')
     else:
@@ -4262,7 +6997,7 @@ def imagej_description(shape, rgb=None, colormaped=False, version='1.11a',
     for key, value in kwargs.items():
         append.append('%s=%s' % (key.lower(), value))
 
-    return str2bytes('\n'.join(result + append + ['']))
+    return '\n'.join(result + append + [''])
 
 
 def imagej_shape(shape, rgb=None):
@@ -4286,67 +7021,291 @@ def imagej_shape(shape, rgb=None):
         raise ValueError("invalid ImageJ hyperstack: not a non-RGB image")
     if rgb or shape[-1] == 1:
         return (1, ) * (6 - ndim) + shape
-    else:
-        return (1, ) * (5 - ndim) + shape + (1,)
+    return (1, ) * (5 - ndim) + shape + (1,)
+
+
+def json_description(shape, **metadata):
+    """Return JSON image description from data shape and other meta data.
+
+    Return UTF-8 encoded JSON.
+
+    >>> json_description((256, 256, 3), axes='YXS')  # doctest: +SKIP
+    b'{"shape": [256, 256, 3], "axes": "YXS"}'
+
+    """
+    metadata.update(shape=shape)
+    return json.dumps(metadata)  # .encode('utf-8')
 
 
-def image_description_dict(description):
-    """Return dictionary from image description byte string.
+def json_description_metadata(description):
+    """Return metatata from JSON formated image description as dict.
 
     Raise ValuError if description is of unknown format.
 
-    >>> image_description_dict(b'shape=(256, 256, 3)')
-    {'shape': (256, 256, 3)}
-    >>> description = b'{"shape": [256, 256, 3], "axes": "YXS"}'
-    >>> image_description_dict(description)  # doctest: +SKIP
+    >>> description = '{"shape": [256, 256, 3], "axes": "YXS"}'
+    >>> json_description_metadata(description)  # doctest: +SKIP
     {'shape': [256, 256, 3], 'axes': 'YXS'}
+    >>> json_description_metadata('shape=(256, 256, 3)')
+    {'shape': (256, 256, 3)}
 
     """
-    if description.startswith(b'shape='):
-        # old style 'shaped' description
-        shape = tuple(int(i) for i in description[7:-1].split(b','))
+    if description[:6] == 'shape=':
+        # old style 'shaped' description; not JSON
+        shape = tuple(int(i) for i in description[7:-1].split(','))
         return dict(shape=shape)
-    if description.startswith(b'{') and description.endswith(b'}'):
+    if description[:1] == '{' and description[-1:] == '}':
         # JSON description
-        return json.loads(description.decode('utf-8'))
-    raise ValueError("unknown image description")
+        return json.loads(description)
+    raise ValueError("invalid JSON image description", description)
 
 
-def image_description(shape, colormaped=False, **metadata):
-    """Return image description from data shape and meta data.
+def fluoview_description_metadata(description, ignoresections=None):
+    """Return metatata from FluoView image description as dict.
 
-    Return UTF-8 encoded JSON.
+    The FluoView image description format is unspecified. Expect failures.
 
-    >>> image_description((256, 256, 3), axes='YXS')  # doctest: +SKIP
-    b'{"shape": [256, 256, 3], "axes": "YXS"}'
+    >>> descr = ('[Intensity Mapping]\\nMap Ch0: Range=00000 to 02047\\n'
+    ...          '[Intensity Mapping End]')
+    >>> fluoview_description_metadata(descr)
+    {'Intensity Mapping': {'Map Ch0: Range': '00000 to 02047'}}
 
     """
-    if colormaped:
-        shape = shape + (3,)
-    metadata.update({'shape': shape})
-    return json.dumps(metadata).encode('utf-8')
+    if not description.startswith('['):
+        raise ValueError("invalid FluoView image description")
+    if ignoresections is None:
+        ignoresections = {'Region Info (Fields)', 'Protocol Description'}
+
+    result = {}
+    sections = [result]
+    comment = False
+    for line in description.splitlines():
+        if not comment:
+            line = line.strip()
+        if not line:
+            continue
+        if line[0] == '[':
+            if line[-5:] == ' End]':
+                # close section
+                del sections[-1]
+                section = sections[-1]
+                name = line[1:-5]
+                if comment:
+                    section[name] = '\n'.join(section[name])
+                if name[:4] == 'LUT ':
+                    a = numpy.array(section[name], dtype='uint8')
+                    a.shape = -1, 3
+                    section[name] = a
+                continue
+            # new section
+            comment = False
+            name = line[1:-1]
+            if name[:4] == 'LUT ':
+                section = []
+            elif name in ignoresections:
+                section = []
+                comment = True
+            else:
+                section = {}
+            sections.append(section)
+            result[name] = section
+            continue
+        # add entry
+        if comment:
+            section.append(line)
+            continue
+        line = line.split('=', 1)
+        if len(line) == 1:
+            section[line[0].strip()] = None
+            continue
+        key, value = line
+        if key[:4] == 'RGB ':
+            section.extend(int(rgb) for rgb in value.split())
+        else:
+            section[key.strip()] = astype(value.strip())
+    return result
+
+
+def pilatus_description_metadata(description):
+    """Return metatata from Pilatus image description as dict.
+
+    Return metadata from Pilatus pixel array detectors by Dectris, created
+    by camserver or TVX software.
+
+    >>> pilatus_description_metadata('# Pixel_size 172e-6 m x 172e-6 m')
+    {'Pixel_size': (0.000172, 0.000172)}
+
+    """
+    result = {}
+    if not description.startswith('# '):
+        return result
+    for c in '#:=,()':
+        description = description.replace(c, ' ')
+    for line in description.split('\n'):
+        if line[:2] != '  ':
+            continue
+        line = line.split()
+        name = line[0]
+        if line[0] not in TIFF.PILATUS_HEADER:
+            try:
+                result['DateTime'] = datetime.datetime.strptime(
+                    ' '.join(line), '%Y-%m-%dT%H %M %S.%f')
+            except Exception:
+                result[name] = ' '.join(line[1:])
+            continue
+        indices, dtype = TIFF.PILATUS_HEADER[line[0]]
+        if isinstance(indices[0], slice):
+            # assumes one slice
+            values = line[indices[0]]
+        else:
+            values = [line[i] for i in indices]
+        if dtype is float and values[0] == 'not':
+            values = ['NaN']
+        values = tuple(dtype(v) for v in values)
+        if dtype == str:
+            values = ' '.join(values)
+        elif len(values) == 1:
+            values = values[0]
+        result[name] = values
+    return result
+
+
+def svs_description_metadata(description):
+    """Return metatata from Aperio image description as dict.
+
+    The Aperio image description format is unspecified. Expect failures.
+
+    >>> svs_description_metadata('Aperio Image Library v1.0')
+    {'Aperio Image Library': 'v1.0'}
+
+    """
+    if not description.startswith('Aperio Image Library '):
+        raise ValueError("invalid Aperio image description")
+    result = {}
+    lines = description.split('\n')
+    key, value = lines[0].strip().rsplit(None, 1)  # 'Aperio Image Library'
+    result[key.strip()] = value.strip()
+    if len(lines) == 1:
+        return result
+    items = lines[1].split('|')
+    result[''] = items[0].strip()  # TODO: parse this?
+    for item in items[1:]:
+        key, value = item.split(' = ')
+        result[key.strip()] = astype(value.strip())
+    return result
+
+
+def stk_description_metadata(description):
+    """Return metadata from MetaMorph image description as list of dict.
+
+    The MetaMorph image description format is unspecified. Expect failures.
 
+    """
+    description = description.strip()
+    if not description:
+        return []
+    try:
+        description = bytes2str(description)
+    except UnicodeDecodeError:
+        warnings.warn("failed to parse MetaMorph image description")
+        return []
+    result = []
+    for plane in description.split('\x00'):
+        d = {}
+        for line in plane.split('\r\n'):
+            line = line.split(':', 1)
+            if len(line) > 1:
+                name, value = line
+                d[name.strip()] = astype(value.strip())
+            else:
+                value = line[0].strip()
+                if value:
+                    if '' in d:
+                        d[''].append(value)
+                    else:
+                        d[''] = [value]
+        result.append(d)
+    return result
+
+
+def metaseries_description_metadata(description):
+    """Return metatata from MetaSeries image description as dict."""
+    if not description.startswith('<MetaData>'):
+        raise ValueError("invalid MetaSeries image description")
+
+    from xml.etree import cElementTree as etree  # delayed import
+    root = etree.fromstring(description)
+    types = {'float': float, 'int': int,
+             'bool': lambda x: asbool(x, 'on', 'off')}
+
+    def parse(root, result):
+        # recursive
+        for child in root:
+            attrib = child.attrib
+            if not attrib:
+                result[child.tag] = parse(child, {})
+                continue
+            if 'id' in attrib:
+                i = attrib['id']
+                t = attrib['type']
+                v = attrib['value']
+                if t in types:
+                    result[i] = types[t](v)
+                else:
+                    result[i] = v
+        return result
+
+    adict = parse(root, {})
+    if 'Description' in adict:
+        adict['Description'] = adict['Description'].replace('&#13;&#10;', '\n')
+    return adict
+
+
+def scanimage_description_metadata(description):
+    """Return metatata from ScanImage image description as dict."""
+    return matlabstr2py(description)
 
-def _replace_by(module_function, package=__package__, warn=False):
+
+def scanimage_artist_metadata(artist):
+    """Return metatata from ScanImage artist tag as dict."""
+    try:
+        return json.loads(artist)
+    except ValueError:
+        warnings.warn("invalid JSON '%s'" % artist)
+
+
+def _replace_by(module_function, package=__package__, warn=None, prefix='_'):
     """Try replace decorated function by module.function."""
+    def _warn(e, warn):
+        if warn is None:
+            warn = "\n  Functionality might be degraded or be slow.\n"
+        elif warn is True:
+            warn = ''
+        elif not warn:
+            return
+        warnings.warn("%s%s" % (e, warn))
+
     try:
         from importlib import import_module
-    except ImportError:
-        warnings.warn('could not import module importlib')
-        return lambda func: func
+    except ImportError as e:
+        _warn(e, warn)
+        return identityfunc
 
     def decorate(func, module_function=module_function, warn=warn):
+        module, function = module_function.split('.')
         try:
-            module, function = module_function.split('.')
             if package:
                 module = import_module('.' + module, package=package)
             else:
                 module = import_module(module)
+        except Exception as e:
+            _warn(e, warn)
+            return func
+        try:
             func, oldfunc = getattr(module, function), func
-            globals()['__old_' + func.__name__] = oldfunc
-        except Exception:
-            if warn:
-                warnings.warn("failed to import %s" % module_function)
+        except Exception as e:
+            _warn(e, warn)
+            return func
+        globals()[prefix + func.__name__] = oldfunc
         return func
 
     return decorate
@@ -4393,11 +7352,11 @@ def decode_floats(data):
 
 
 def decode_jpeg(encoded, tables=b'', photometric=None,
-                ycbcr_subsampling=None, ycbcr_positioning=None):
+                ycbcrsubsampling=None, ycbcrpositioning=None):
     """Decode JPEG encoded byte string (using _czifile extension module)."""
     from czifile import _czifile
     image = _czifile.decode_jpeg(encoded, tables)
-    if photometric == 'rgb' and ycbcr_subsampling and ycbcr_positioning:
+    if photometric == 2 and ycbcrsubsampling and ycbcrpositioning:
         # TODO: convert YCbCr to RGB
         pass
     return image.tostring()
@@ -4410,7 +7369,7 @@ def decode_packbits(encoded):
     PackBits is a simple byte-oriented run-length compression scheme.
 
     """
-    func = ord if sys.version[0] == '2' else lambda x: x
+    func = ord if sys.version[0] == '2' else identityfunc
     result = []
     result_extend = result.extend
     i = 0
@@ -4519,6 +7478,9 @@ def decode_lzw(encoded):
 def unpack_ints(data, dtype, itemsize, runlen=0):
     """Decompress byte string to array of integers of any bit size <= 32.
 
+    This Python implementation is slow and only handles itemsizes 1, 2, 4, 8,
+    16, 32, and 64.
+
     Parameters
     ----------
     data : byte str
@@ -4530,6 +7492,13 @@ def unpack_ints(data, dtype, itemsize, runlen=0):
     runlen : int
         Number of consecutive integers, after which to start at next byte.
 
+    Examples
+    --------
+    >>> unpack_ints(b'a', 'B', 1)
+    array([0, 1, 1, 0, 0, 0, 0, 1], dtype=uint8)
+    >>> unpack_ints(b'ab', 'B', 2)
+    array([1, 2, 0, 1, 1, 2, 0, 2], dtype=uint8)
+
     """
     if itemsize == 1:  # bitarray
         data = numpy.fromstring(data, '|B')
@@ -4542,8 +7511,8 @@ def unpack_ints(data, dtype, itemsize, runlen=0):
     dtype = numpy.dtype(dtype)
     if itemsize in (8, 16, 32, 64):
         return numpy.fromstring(data, dtype)
-    if itemsize < 1 or itemsize > 32:
-        raise ValueError("itemsize out of range: %i" % itemsize)
+    if itemsize not in (1, 2, 4, 8, 16, 32):
+        raise ValueError("itemsize not supported: %i" % itemsize)
     if dtype.kind not in "biu":
         raise ValueError("invalid dtype")
 
@@ -4551,8 +7520,8 @@ def unpack_ints(data, dtype, itemsize, runlen=0):
     if itembytes != dtype.itemsize:
         raise ValueError("dtype.itemsize too small")
     if runlen == 0:
-        runlen = len(data) // itembytes
-    skipbits = runlen*itemsize % 8
+        runlen = (8 * len(data)) // itemsize
+    skipbits = runlen * itemsize % 8
     if skipbits:
         skipbits = 8 - skipbits
     shrbits = itembytes*8 - itemsize
@@ -4563,7 +7532,7 @@ def unpack_ints(data, dtype, itemsize, runlen=0):
     l = runlen * (len(data)*8 // (runlen*itemsize + skipbits))
     result = numpy.empty((l,), dtype)
     bitcount = 0
-    for i in range(len(result)):
+    for i in range(l):
         start = bitcount // 8
         s = data[start:start+itembytes]
         try:
@@ -4637,7 +7606,7 @@ def reverse_bitorder(data):
     """Reverse bits in each byte of byte string or numpy array.
 
     Decode data where pixels with lower column values are stored in the
-    lower-order bits of the bytes (fill_order == 'lsb2msb').
+    lower-order bits of the bytes (FillOrder is LSB2MSB).
 
     Parameters
     ----------
@@ -4647,33 +7616,19 @@ def reverse_bitorder(data):
 
     Examples
     --------
-    >>> reverse_bitorder(b'\x01\x64')
-    b'\x80&'
-
+    >>> reverse_bitorder(b'\\x01\\x64')
+    b'\\x80&'
     >>> data = numpy.array([1, 666], dtype='uint16')
     >>> reverse_bitorder(data)
     >>> data
     array([  128, 16473], dtype=uint16)
 
     """
-    table = (
-        b'\x00\x80@\xc0 \xa0`\xe0\x10\x90P\xd00\xb0p\xf0\x08\x88H\xc8(\xa8h'
-        b'\xe8\x18\x98X\xd88\xb8x\xf8\x04\x84D\xc4$\xa4d\xe4\x14\x94T\xd44'
-        b'\xb4t\xf4\x0c\x8cL\xcc,\xacl\xec\x1c\x9c\\\xdc<\xbc|\xfc\x02\x82B'
-        b'\xc2"\xa2b\xe2\x12\x92R\xd22\xb2r\xf2\n\x8aJ\xca*\xaaj\xea\x1a'
-        b'\x9aZ\xda:\xbaz\xfa\x06\x86F\xc6&\xa6f\xe6\x16\x96V\xd66\xb6v\xf6'
-        b'\x0e\x8eN\xce.\xaen\xee\x1e\x9e^\xde>\xbe~\xfe\x01\x81A\xc1!\xa1a'
-        b'\xe1\x11\x91Q\xd11\xb1q\xf1\t\x89I\xc9)\xa9i\xe9\x19\x99Y\xd99'
-        b'\xb9y\xf9\x05\x85E\xc5%\xa5e\xe5\x15\x95U\xd55\xb5u\xf5\r\x8dM'
-        b'\xcd-\xadm\xed\x1d\x9d]\xdd=\xbd}\xfd\x03\x83C\xc3#\xa3c\xe3\x13'
-        b'\x93S\xd33\xb3s\xf3\x0b\x8bK\xcb+\xabk\xeb\x1b\x9b[\xdb;\xbb{\xfb'
-        b'\x07\x87G\xc7\'\xa7g\xe7\x17\x97W\xd77\xb7w\xf7\x0f\x8fO\xcf/\xafo'
-        b'\xef\x1f\x9f_\xdf?\xbf\x7f\xff')
     try:
         view = data.view('uint8')
-        numpy.take(numpy.fromstring(table, dtype='uint8'), view, out=view)
+        numpy.take(TIFF.REVERSE_BITORDER_ARRAY, view, out=view)
     except AttributeError:
-        return data.translate(table)
+        return data.translate(TIFF.REVERSE_BITORDER_BYTES)
     except ValueError:
         raise NotImplementedError("slices of arrays not supported")
 
@@ -4717,30 +7672,66 @@ def reorient(image, orientation):
         Non-squeezed output of asarray() functions.
         Axes -3 and -2 must be image length and width respectively.
     orientation : int or str
-        One of TIFF_ORIENTATIONS keys or values.
+        One of TIFF.ORIENTATION names or values.
 
     """
-    o = TIFF_ORIENTATIONS.get(orientation, orientation)
-    if o == 'top_left':
+    ORIENTATION = TIFF.ORIENTATION
+    orientation = enumarg(ORIENTATION, orientation)
+
+    if orientation == ORIENTATION.TOPLEFT:
         return image
-    elif o == 'top_right':
+    elif orientation == ORIENTATION.TOPRIGHT:
         return image[..., ::-1, :]
-    elif o == 'bottom_left':
+    elif orientation == ORIENTATION.BOTLEFT:
         return image[..., ::-1, :, :]
-    elif o == 'bottom_right':
+    elif orientation == ORIENTATION.BOTRIGHT:
         return image[..., ::-1, ::-1, :]
-    elif o == 'left_top':
+    elif orientation == ORIENTATION.LEFTTOP:
         return numpy.swapaxes(image, -3, -2)
-    elif o == 'right_top':
+    elif orientation == ORIENTATION.RIGHTTOP:
         return numpy.swapaxes(image, -3, -2)[..., ::-1, :]
-    elif o == 'left_bottom':
+    elif orientation == ORIENTATION.RIGHTBOT:
         return numpy.swapaxes(image, -3, -2)[..., ::-1, :, :]
-    elif o == 'right_bottom':
+    elif orientation == ORIENTATION.LEFTBOT:
         return numpy.swapaxes(image, -3, -2)[..., ::-1, ::-1, :]
 
 
-def reshape_nd(image, ndim):
-    """Return image array with at least ndim dimensions.
+def repeat_nd(a, repeats):
+    """Return read-only view into input array with elements repeated.
+
+    Zoom nD image by integer factors using nearest neighbor interpolation
+    (box filter).
+
+    Parameters
+    ----------
+    a : array_like
+        Input array.
+    repeats : sequence of int
+        The number of repetitions to apply along each dimension of input array.
+
+    Example
+    -------
+    >>> repeat_nd([[1, 2], [3, 4]], (2, 2))
+    array([[1, 1, 2, 2],
+           [1, 1, 2, 2],
+           [3, 3, 4, 4],
+           [3, 3, 4, 4]])
+
+    """
+    a = numpy.asarray(a)
+    reshape = []
+    shape = []
+    strides = []
+    for i, j, k in zip(a.strides, a.shape, repeats):
+        shape.extend((j, k))
+        strides.extend((i, 0))
+        reshape.append(j * k)
+    return numpy.lib.stride_tricks.as_strided(
+        a, shape, strides, writeable=False).reshape(reshape)
+
+
+def reshape_nd(data_or_shape, ndim):
+    """Return image array or shape with at least ndim dimensions.
 
     Prepend 1s to image shape as necessary.
 
@@ -4752,12 +7743,16 @@ def reshape_nd(image, ndim):
     (1, 2, 3)
     >>> reshape_nd(numpy.empty((3, 4, 5)), 3).shape
     (3, 4, 5)
+    >>> reshape_nd((2, 3), 3)
+    (1, 2, 3)
 
     """
-    if image.ndim >= ndim:
-        return image
-    image = image.reshape((1,) * (ndim - image.ndim) + image.shape)
-    return image
+    is_shape = isinstance(data_or_shape, tuple)
+    shape = data_or_shape if is_shape else data_or_shape.shape
+    if len(shape) >= ndim:
+        return data_or_shape
+    shape = (1,) * (ndim - len(shape)) + shape
+    return shape if is_shape else data_or_shape.reshape(shape)
 
 
 def squeeze_axes(shape, axes, skip='XY'):
@@ -4800,7 +7795,7 @@ def transpose_axes(image, axes, asaxes='CTZYX'):
     return image
 
 
-def reshape_axes(axes, shape, newshape):
+def reshape_axes(axes, shape, newshape, unknown='Q'):
     """Return axes matching new shape.
 
     Unknown dimensions are labelled 'Q'.
@@ -4815,7 +7810,10 @@ def reshape_axes(axes, shape, newshape):
     newshape = tuple(newshape)
     if len(axes) != len(shape):
         raise ValueError("axes do not match shape")
-    if product(shape) != product(newshape):
+
+    size = product(shape)
+    newsize = product(newshape)
+    if size != newsize:
         raise ValueError("can not reshape %s to %s" % (shape, newshape))
     if not axes or not newshape:
         return ''
@@ -4837,42 +7835,274 @@ def reshape_axes(axes, shape, newshape):
             result.append(axes[i])
             i -= 1
         else:
-            result.append('Q')
+            result.append(unknown)
 
     return ''.join(reversed(result[lendiff:]))
 
 
-def stack_pages(pages, memmap=False, tempdir=None, *args, **kwargs):
+def stack_pages(pages, out=None, maxworkers=1, *args, **kwargs):
     """Read data from sequence of TiffPage and stack them vertically.
 
-    If memmap is True, return an array stored in a binary file on disk.
-    Additional parameters are passsed to the page asarray function.
+    Additional parameters are passsed to the TiffPage.asarray function.
 
     """
-    if len(pages) == 0:
+    npages = len(pages)
+    if npages == 0:
         raise ValueError("no pages")
 
-    if len(pages) == 1:
-        return pages[0].asarray(memmap=memmap, *args, **kwargs)
-
-    data0 = pages[0].asarray(*args, **kwargs)
-    shape = (len(pages),) + data0.shape
-    if memmap:
-        with tempfile.NamedTemporaryFile(dir=tempdir) as fh:
-            data = numpy.memmap(fh, dtype=data0.dtype, shape=shape)
+    if npages == 1:
+        return pages[0].asarray(out=out, *args, **kwargs)
+
+    page0 = next(p for p in pages if p is not None)
+    page0.asarray(validate=None)  # ThreadPoolExecutor swallows exceptions
+    shape = (npages,) + page0.keyframe.shape
+    dtype = page0.keyframe.dtype
+    out = create_output(out, shape, dtype)
+
+    if maxworkers is None:
+        maxworkers = multiprocessing.cpu_count() // 2
+    page0.parent.filehandle.lock = maxworkers > 1
+
+    filecache = OpenFileCache(size=max(4, maxworkers),
+                              lock=page0.parent.filehandle.lock)
+
+    def func(page, index, out=out, filecache=filecache,
+             args=args, kwargs=kwargs):
+        """Read, decode, and copy page data."""
+        if page is not None:
+            filecache.open(page.parent.filehandle)
+            out[index] = page.asarray(lock=filecache.lock, reopen=False,
+                                      validate=False, *args, **kwargs)
+            filecache.close(page.parent.filehandle)
+
+    if maxworkers < 2:
+        for i, page in enumerate(pages):
+            func(page, i)
     else:
-        data = numpy.empty(shape, dtype=data0.dtype)
+        with concurrent.futures.ThreadPoolExecutor(maxworkers) as executor:
+            executor.map(func, pages, range(npages))
 
-    data[0] = data0
-    if memmap:
-        data.flush()
-    del data0
-    for i, page in enumerate(pages[1:]):
-        data[i+1] = page.asarray(*args, **kwargs)
-        if memmap:
-            data.flush()
+    filecache.clear()
+    page0.parent.filehandle.lock = None
 
-    return data
+    return out
+
+
+def clean_offsets_counts(offsets, counts):
+    """Return cleaned offsets and byte counts.
+
+    Remove zero offsets and counts. Use to sanitize _offsets and _bytecounts
+    tag values for strips or tiles.
+
+    """
+    offsets = list(offsets)
+    counts = list(counts)
+    assert len(offsets) == len(counts)
+    j = 0
+    for i, (o, b) in enumerate(zip(offsets, counts)):
+        if o > 0 and b > 0:
+            if i > j:
+                offsets[j] = o
+                counts[j] = b
+            j += 1
+        elif b > 0 and o <= 0:
+            raise ValueError("invalid offset")
+        else:
+            warnings.warn("empty byte count")
+    if j == 0:
+        j = 1
+    return offsets[:j], counts[:j]
+
+
+def buffered_read(fh, lock, offsets, bytecounts, buffersize=2**26):
+    """Return iterator over blocks read from file."""
+    length = len(offsets)
+    i = 0
+    while i < length:
+        data = []
+        with lock:
+            size = 0
+            while size < buffersize and i < length:
+                fh.seek(offsets[i])
+                bytecount = bytecounts[i]
+                data.append(fh.read(bytecount))
+                size += bytecount
+                i += 1
+        for block in data:
+            yield block
+
+
+def create_output(out, shape, dtype, mode='w+', suffix='.memmap'):
+    """Return numpy array where image data of shape and dtype can copied.
+
+    The 'out' parameter may have the following values or types:
+
+    None
+        An empty array of shape and dtype is created and returned.
+    numpy.ndarray
+        An existing writable array of compatible dtype and shape. A view of
+        the same array is returned after verification.
+    'memmap' or 'memmap:tempdir'
+        A memory-map to an array stored in a temporary binary file on disk
+        is created and returned.
+    str or open file
+        The file name or file object used to create a memory-map to an array
+        stored in a binary file on disk. The created memory-mapped array is
+        returned.
+
+    """
+    if out is None:
+        return numpy.zeros(shape, dtype)
+    if isinstance(out, str) and out[:6] == 'memmap':
+        tempdir = out[7:] if len(out) > 7 else None
+        with tempfile.NamedTemporaryFile(dir=tempdir, suffix=suffix) as fh:
+            return numpy.memmap(fh, shape=shape, dtype=dtype, mode=mode)
+    if isinstance(out, numpy.ndarray):
+        if product(shape) != product(out.shape):
+            raise ValueError("incompatible output shape")
+        if not numpy.can_cast(dtype, out.dtype):
+            raise ValueError("incompatible output dtype")
+        return out.reshape(shape)
+    return numpy.memmap(out, shape=shape, dtype=dtype, mode=mode)
+
+
+def matlabstr2py(s):
+    """Return Python object from Matlab string representation.
+
+    Return str, bool, int, float, list (Matlab arrays or cells), or
+    dict (Matlab structures) types.
+
+    Use to access ScanImage metadata.
+
+    >>> matlabstr2py('1')
+    1
+    >>> matlabstr2py("['x y z' true false; 1 2.0 -3e4; NaN Inf @class]")
+    [['x y z', True, False], [1, 2.0, -30000.0], [nan, inf, '@class']]
+    >>> d = matlabstr2py("SI.hChannels.channelType = {'stripe' 'stripe'}\\n"
+    ...                  "SI.hChannels.channelsActive = 2")
+    >>> d['SI.hChannels.channelType']
+    ['stripe', 'stripe']
+
+    """
+    # TODO: handle invalid input
+    # TODO: review unboxing of multidimensional arrays
+
+    def lex(s):
+        # return sequence of tokens from matlab string representation
+        tokens = ['[']
+        while True:
+            t, i = next_token(s)
+            if t is None:
+                break
+            if t == ';':
+                tokens.extend((']', '['))
+            elif t == '[':
+                tokens.extend(('[', '['))
+            elif t == ']':
+                tokens.extend((']', ']'))
+            else:
+                tokens.append(t)
+            s = s[i:]
+        tokens.append(']')
+        return tokens
+
+    def next_token(s):
+        # return next token in matlab string
+        length = len(s)
+        if length == 0:
+            return None, 0
+        i = 0
+        while i < length and s[i] == ' ':
+            i += 1
+        if i == length:
+            return None, i
+        if s[i] in '{[;]}':
+            return s[i], i + 1
+        if s[i] == "'":
+            j = i + 1
+            while j < length and s[j] != "'":
+                j += 1
+            return s[i: j+1], j + 1
+        j = i
+        while j < length and not s[j] in ' {[;]}':
+            j += 1
+        return s[i:j], j
+
+    def value(s, fail=False):
+        # return Python value of token
+        s = s.strip()
+        if not s:
+            return s
+        if len(s) == 1:
+            try:
+                return int(s)
+            except Exception:
+                if fail:
+                    raise ValueError()
+                return s
+        if s[0] == "'":
+            if fail and s[-1] != "'" or "'" in s[1:-1]:
+                raise ValueError()
+            return s[1:-1]
+        if fail and any(i in s for i in " ';[]{}"):
+            raise ValueError()
+        if s[0] == '@':
+            return s
+        if s == 'true':
+            return True
+        if s == 'false':
+            return False
+        if '.' in s or 'e' in s:
+            return float(s)
+        try:
+            return int(s)
+        except Exception:
+            pass
+        try:
+            return float(s)  # nan, inf
+        except Exception:
+            if fail:
+                raise ValueError()
+        return s
+
+    def parse(s):
+        # return Python value from string representation of Matlab value
+        s = s.strip()
+        try:
+            return value(s, fail=True)
+        except ValueError:
+            pass
+        result = add2 = []
+        levels = [add2]
+        for t in lex(s):
+            if t in '[{':
+                add2 = []
+                levels.append(add2)
+            elif t in ']}':
+                x = levels.pop()
+                if len(x) == 1 and isinstance(x[0], list):
+                    x = x[0]
+                add2 = levels[-1]
+                add2.append(x)
+            else:
+                add2.append(value(t))
+        if len(result) == 1 and isinstance(result[0], list):
+            result = result[0]
+        return result
+
+    if '\r' in s or '\n' in s:
+        # structure
+        d = {}
+        for line in s.splitlines():
+            if not line.strip():
+                continue
+            k, v = line.split('=', 1)
+            k = k.strip()
+            if any(c in k for c in " ';[]{}"):
+                continue
+            d[k] = parse(v.strip())
+        return d
+    return parse(s)
 
 
 def stripnull(string, null=b'\x00'):
@@ -4891,7 +8121,7 @@ def stripnull(string, null=b'\x00'):
 
 
 def stripascii(string):
-    """Return string truncated at last byte that is 7bit ASCII.
+    """Return string truncated at last byte that is 7-bit ASCII.
 
     Clean NULL separated and terminated TIFF strings.
 
@@ -4902,35 +8132,88 @@ def stripascii(string):
 
     """
     # TODO: pythonize this
-    ord_ = ord if sys.version_info[0] < 3 else lambda x: x
     i = len(string)
     while i:
         i -= 1
-        if 8 < ord_(string[i]) < 127:
+        if 8 < byte2int(string[i]) < 127:
             break
     else:
         i = -1
     return string[:i+1]
 
 
+def asbool(value, true=(b'true', u'true'), false=(b'false', u'false')):
+    """Return string as bool if possible, else raise TypeError.
+
+    >>> asbool(b' False ')
+    False
+
+    """
+    value = value.strip().lower()
+    if value in true:  # might raise UnicodeWarning/BytesWarning
+        return True
+    if value in false:
+        return False
+    raise TypeError()
+
+
 def astype(value, types=None):
-    """Return argument as one of types if possible."""
+    """Return argument as one of types if possible.
+
+    >>> astype('42')
+    42
+    >>> astype('3.14')
+    3.14
+    >>> astype('True')
+    True
+    >>> astype(b'Neee-Wom')
+    'Neee-Wom'
+
+    """
     if types is None:
-        types = int, float, bytes2str
+        types = int, float, asbool, bytes2str
     for typ in types:
         try:
             return typ(value)
-        except (ValueError, TypeError, UnicodeEncodeError):
+        except (ValueError, AttributeError, TypeError, UnicodeEncodeError):
             pass
     return value
 
 
-def format_size(size):
-    """Return file size as string from byte size."""
-    for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'):
-        if size < 2048:
-            return "%.f %s" % (size, unit)
+def format_size(size, threshold=1536):
+    """Return file size as string from byte size.
+
+    >>> format_size(1234)
+    '1234 B'
+    >>> format_size(12345678901)
+    '11.50 GiB'
+
+    """
+    if size < threshold:
+        return "%i B" % size
+    for unit in ('KiB', 'MiB', 'GiB', 'TiB', 'PiB'):
         size /= 1024.0
+        if size < threshold:
+            return "%.2f %s" % (size, unit)
+
+
+def identityfunc(arg):
+    """Single argument identity function.
+
+    >>> identityfunc('arg')
+    'arg'
+
+    """
+    return arg
+
+
+def nullfunc(*args, **kwargs):
+    """Null function.
+
+    >>> nullfunc('arg', kwarg='kwarg')
+
+    """
+    return
 
 
 def sequence(value):
@@ -4946,13 +8229,14 @@ def sequence(value):
         len(value)
         return value
     except TypeError:
-        return value,
+        return (value,)
 
 
 def product(iterable):
     """Return product of sequence of numbers.
 
     Equivalent of functools.reduce(operator.mul, iterable, 1).
+    Multiplying numpy integers might overflow.
 
     >>> product([2**8, 2**30])
     274877906944
@@ -4977,6 +8261,7 @@ def natural_sorted(iterable):
     """
     def sortkey(x):
         return [(int(c) if c.isdigit() else c) for c in re.split(numbers, x)]
+
     numbers = re.compile(r'(\d+)')
     return sorted(iterable, key=sortkey)
 
@@ -5027,6 +8312,180 @@ def julian_datetime(julianday, milisecond=0):
                              hour, minute, second, milisecond)
 
 
+def byteorder_isnative(byteorder):
+    """Return if byteorder matches the system's byteorder.
+
+    >>> byteorder_isnative('=')
+    True
+
+    """
+    if byteorder == '=' or byteorder == sys.byteorder:
+        return True
+    keys = {'big': '>', 'little': '<'}
+    return keys.get(byteorder, byteorder) == keys[sys.byteorder]
+
+
+def recarray2dict(recarray):
+    """Return numpy.recarray as dict."""
+    # TODO: subarrays
+    result = {}
+    for descr, value in zip(recarray.dtype.descr, recarray):
+        name, dtype = descr[:2]
+        if dtype[1] == 'S':
+            value = bytes2str(stripnull(value))
+        elif value.ndim < 2:
+            value = value.tolist()
+        result[name] = value
+    return result
+
+
+def xml2dict(xml, sanitize=True, prefix=None):
+    """Return XML as dict.
+
+    >>> xml2dict('<?xml version="1.0" ?><root attr="name"><key>1</key></root>')
+    {'root': {'key': 1, 'attr': 'name'}}
+
+    """
+    from collections import defaultdict  # delayed import
+    from xml.etree import cElementTree as etree  # delayed import
+
+    at = tx = ''
+    if prefix:
+        at, tx = prefix
+
+    def astype(value):
+        # return value as int, float, bool, or str
+        for t in (int, float, asbool):
+            try:
+                return t(value)
+            except Exception:
+                pass
+        return value
+
+    def etree2dict(t):
+        # adapted from https://stackoverflow.com/a/10077069/453463
+        key = t.tag
+        if sanitize:
+            key = key.rsplit('}', 1)[-1]
+        d = {key: {} if t.attrib else None}
+        children = list(t)
+        if children:
+            dd = defaultdict(list)
+            for dc in map(etree2dict, children):
+                for k, v in dc.items():
+                    dd[k].append(astype(v))
+            d = {key: {k: astype(v[0]) if len(v) == 1 else astype(v)
+                       for k, v in dd.items()}}
+        if t.attrib:
+            d[key].update((at + k, astype(v)) for k, v in t.attrib.items())
+        if t.text:
+            text = t.text.strip()
+            if children or t.attrib:
+                if text:
+                    d[key][tx + 'value'] = astype(text)
+            else:
+                d[key] = astype(text)
+        return d
+
+    return etree2dict(etree.fromstring(xml))
+
+
+def pformat_xml(arg):
+    """Return pretty formatted XML."""
+    try:
+        import lxml.etree as etree  # delayed import
+        if not isinstance(arg, bytes):
+            arg = arg.encode('utf-8')
+        xml = etree.fromstring(arg)
+        xml = etree.tostring(xml, pretty_print=True, encoding="unicode")
+    except Exception:
+        xml = bytes2str(arg).replace('><', '>\n<').replace('><', '>\n<')
+    return xml.replace('  ', ' ').replace('\t', ' ')
+
+
+def pformat(arg, maxlines=None, linewidth=None, compact=True):
+    """Return pretty formatted representation of object as string."""
+    if maxlines is None:
+        maxlines = TIFF.PRINT_MAX_LINES
+    elif not maxlines:
+        maxlines = 2**32
+    if linewidth is None:
+        linewidth = TIFF.PRINT_LINE_WIDTH
+    elif not linewidth:
+        linewidth = 2**32
+
+    numpy.set_printoptions(threshold=100, linewidth=linewidth)
+
+    if isinstance(arg, basestring):
+        if arg[:5].lower() in ('<?xml', b'<?xml'):
+            arg = pformat_xml(arg)
+        elif isinstance(arg, bytes):
+            try:
+                arg = bytes2str(arg)
+                arg = arg.replace('\r', '\n').replace('\n\n', '\n')
+            except Exception:
+                import binascii  # delayed import
+                import pprint  # delayed import
+                arg = binascii.hexlify(arg)
+                arg = pprint.pformat(arg, width=linewidth)
+                maxlines = min(maxlines, 16)
+        arg = arg.rstrip()
+    elif isinstance(arg, numpy.record):
+        arg = arg.pprint()
+    else:
+        from pprint import pformat  # delayed import
+        compact = {} if sys.version_info[0] == 2 else dict(compact=compact)
+        arg = pformat(arg, width=linewidth, **compact)
+
+    argl = list(arg.splitlines())
+    if len(argl) > maxlines:
+        arg = '\n'.join(argl[:maxlines] +
+                        ['...truncated to %i lines.' % maxlines])
+    return arg
+
+
+def snipstr(string, length=16, ellipse=None):
+    """Return string cut in middle to specified length.
+
+    >>> snipstr('abcdefghijklmnop', 8)
+    'abcd…nop'
+
+    """
+    size = len(string)
+    if size <= length:
+        return string
+    if ellipse is None:
+        if isinstance(string, bytes):
+            ellipse = b'...'
+        else:
+            ellipse = u'\u2026'
+    esize = len(ellipse)
+    if length < esize + 1:
+        return string[:length]
+    if length < esize + 4:
+        return string[:length-esize] + ellipse
+    half = (length - esize) // 2
+    return string[:half + (length-esize) % 2] + ellipse + string[-half:]
+
+
+def enumarg(enum, arg):
+    """Return enum member from its name or value.
+
+    >>> enumarg(TIFF.PHOTOMETRIC, 2)
+    <PHOTOMETRIC.RGB: 2>
+    >>> enumarg(TIFF.PHOTOMETRIC, 'RGB')
+    <PHOTOMETRIC.RGB: 2>
+
+    """
+    try:
+        return enum(arg)
+    except Exception:
+        try:
+            return enum[arg.upper()]
+        except Exception:
+            raise ValueError("invalid argument %s" % arg)
+
+
 def parse_kwargs(kwargs, *keys, **keyvalues):
     """Return dict with keys from keys|keyvals and values from kwargs|keyvals.
 
@@ -5068,958 +8527,85 @@ def update_kwargs(kwargs, **keyvalues):
             kwargs[key] = value
 
 
-class TIFF_SUBFILE_TYPES(object):
-    def __getitem__(self, key):
-        result = []
-        if key & 1:
-            result.append('reduced_image')
-        if key & 2:
-            result.append('page')
-        if key & 4:
-            result.append('mask')
-        return tuple(result)
-
-
-TIFF_PHOTOMETRICS = {
-    0: 'miniswhite',
-    1: 'minisblack',
-    2: 'rgb',
-    3: 'palette',
-    4: 'mask',
-    5: 'separated',  # CMYK
-    6: 'ycbcr',
-    8: 'cielab',
-    9: 'icclab',
-    10: 'itulab',
-    32803: 'cfa',  # Color Filter Array
-    32844: 'logl',
-    32845: 'logluv',
-    34892: 'linear_raw'
-}
-
-TIFF_COMPESSIONS = {
-    1: None,
-    2: 'ccittrle',
-    3: 'ccittfax3',
-    4: 'ccittfax4',
-    5: 'lzw',
-    6: 'ojpeg',
-    7: 'jpeg',
-    8: 'adobe_deflate',
-    9: 't85',
-    10: 't43',
-    32766: 'next',
-    32771: 'ccittrlew',
-    32773: 'packbits',
-    32809: 'thunderscan',
-    32895: 'it8ctpad',
-    32896: 'it8lw',
-    32897: 'it8mp',
-    32898: 'it8bl',
-    32908: 'pixarfilm',
-    32909: 'pixarlog',
-    32946: 'deflate',
-    32947: 'dcs',
-    34661: 'jbig',
-    34676: 'sgilog',
-    34677: 'sgilog24',
-    34712: 'jp2000',
-    34713: 'nef',
-    34925: 'lzma',
-}
-
-TIFF_DECOMPESSORS = {
-    None: lambda x: x,
-    'adobe_deflate': zlib.decompress,
-    'deflate': zlib.decompress,
-    'packbits': decode_packbits,
-    'lzw': decode_lzw,
-    # 'jpeg': decode_jpeg
-}
-
-if lzma:
-    TIFF_DECOMPESSORS['lzma'] = lzma.decompress
-
-TIFF_DATA_TYPES = {
-    1: '1B',   # BYTE 8-bit unsigned integer.
-    2: '1s',   # ASCII 8-bit byte that contains a 7-bit ASCII code;
-               #   the last byte must be NULL (binary zero).
-    3: '1H',   # SHORT 16-bit (2-byte) unsigned integer
-    4: '1I',   # LONG 32-bit (4-byte) unsigned integer.
-    5: '2I',   # RATIONAL Two LONGs: the first represents the numerator of
-               #   a fraction; the second, the denominator.
-    6: '1b',   # SBYTE An 8-bit signed (twos-complement) integer.
-    7: '1s',   # UNDEFINED An 8-bit byte that may contain anything,
-               #   depending on the definition of the field.
-    8: '1h',   # SSHORT A 16-bit (2-byte) signed (twos-complement) integer.
-    9: '1i',   # SLONG A 32-bit (4-byte) signed (twos-complement) integer.
-    10: '2i',  # SRATIONAL Two SLONGs: the first represents the numerator
-               #   of a fraction, the second the denominator.
-    11: '1f',  # FLOAT Single precision (4-byte) IEEE format.
-    12: '1d',  # DOUBLE Double precision (8-byte) IEEE format.
-    13: '1I',  # IFD unsigned 4 byte IFD offset.
-    #14: '',   # UNICODE
-    #15: '',   # COMPLEX
-    16: '1Q',  # LONG8 unsigned 8 byte integer (BigTiff)
-    17: '1q',  # SLONG8 signed 8 byte integer (BigTiff)
-    18: '1Q',  # IFD8 unsigned 8 byte IFD offset (BigTiff)
-}
-
-TIFF_SAMPLE_FORMATS = {
-    1: 'uint',
-    2: 'int',
-    3: 'float',
-    #4: 'void',
-    #5: 'complex_int',
-    6: 'complex',
-}
-
-TIFF_SAMPLE_DTYPES = {
-    ('uint', 1): '?',  # bitmap
-    ('uint', 2): 'B',
-    ('uint', 3): 'B',
-    ('uint', 4): 'B',
-    ('uint', 5): 'B',
-    ('uint', 6): 'B',
-    ('uint', 7): 'B',
-    ('uint', 8): 'B',
-    ('uint', 9): 'H',
-    ('uint', 10): 'H',
-    ('uint', 11): 'H',
-    ('uint', 12): 'H',
-    ('uint', 13): 'H',
-    ('uint', 14): 'H',
-    ('uint', 15): 'H',
-    ('uint', 16): 'H',
-    ('uint', 17): 'I',
-    ('uint', 18): 'I',
-    ('uint', 19): 'I',
-    ('uint', 20): 'I',
-    ('uint', 21): 'I',
-    ('uint', 22): 'I',
-    ('uint', 23): 'I',
-    ('uint', 24): 'I',
-    ('uint', 25): 'I',
-    ('uint', 26): 'I',
-    ('uint', 27): 'I',
-    ('uint', 28): 'I',
-    ('uint', 29): 'I',
-    ('uint', 30): 'I',
-    ('uint', 31): 'I',
-    ('uint', 32): 'I',
-    ('uint', 64): 'Q',
-    ('int', 8): 'b',
-    ('int', 16): 'h',
-    ('int', 32): 'i',
-    ('int', 64): 'q',
-    ('float', 16): 'e',
-    ('float', 32): 'f',
-    ('float', 64): 'd',
-    ('complex', 64): 'F',
-    ('complex', 128): 'D',
-    ('uint', (5, 6, 5)): 'B',
-}
-
-TIFF_ORIENTATIONS = {
-    1: 'top_left',
-    2: 'top_right',
-    3: 'bottom_right',
-    4: 'bottom_left',
-    5: 'left_top',
-    6: 'right_top',
-    7: 'right_bottom',
-    8: 'left_bottom',
-}
-
-# TODO: is there a standard for character axes labels?
-AXES_LABELS = {
-    'X': 'width',
-    'Y': 'height',
-    'Z': 'depth',
-    'S': 'sample',  # rgb(a)
-    'I': 'series',  # general sequence, plane, page, IFD
-    'T': 'time',
-    'C': 'channel',  # color, emission wavelength
-    'A': 'angle',
-    'P': 'phase',  # formerly F    # P is Position in LSM!
-    'R': 'tile',  # region, point, mosaic
-    'H': 'lifetime',  # histogram
-    'E': 'lambda',  # excitation wavelength
-    'L': 'exposure',  # lux
-    'V': 'event',
-    'Q': 'other',
-    'M': 'mosaic',  # LSM 6
-}
-
-AXES_LABELS.update(dict((v, k) for k, v in AXES_LABELS.items()))
-
-# Map OME pixel types to numpy dtype
-OME_PIXEL_TYPES = {
-    'int8': 'i1',
-    'int16': 'i2',
-    'int32': 'i4',
-    'uint8': 'u1',
-    'uint16': 'u2',
-    'uint32': 'u4',
-    'float': 'f4',
-    # 'bit': 'bit',
-    'double': 'f8',
-    'complex': 'c8',
-    'double-complex': 'c16',
-}
-
-# NIH Image PicHeader v1.63
-NIH_IMAGE_HEADER = [
-    ('fileid', 'a8'),
-    ('nlines', 'i2'),
-    ('pixelsperline', 'i2'),
-    ('version', 'i2'),
-    ('oldlutmode', 'i2'),
-    ('oldncolors', 'i2'),
-    ('colors', 'u1', (3, 32)),
-    ('oldcolorstart', 'i2'),
-    ('colorwidth', 'i2'),
-    ('extracolors', 'u2', (6, 3)),
-    ('nextracolors', 'i2'),
-    ('foregroundindex', 'i2'),
-    ('backgroundindex', 'i2'),
-    ('xscale', 'f8'),
-    ('_x0', 'i2'),
-    ('_x1', 'i2'),
-    ('units_t', 'i2'),  # NIH_UNITS_TYPE
-    ('p1', [('x', 'i2'), ('y', 'i2')]),
-    ('p2', [('x', 'i2'), ('y', 'i2')]),
-    ('curvefit_t', 'i2'),  # NIH_CURVEFIT_TYPE
-    ('ncoefficients', 'i2'),
-    ('coeff', 'f8', 6),
-    ('_um_len', 'u1'),
-    ('um', 'a15'),
-    ('_x2', 'u1'),
-    ('binarypic', 'b1'),
-    ('slicestart', 'i2'),
-    ('sliceend', 'i2'),
-    ('scalemagnification', 'f4'),
-    ('nslices', 'i2'),
-    ('slicespacing', 'f4'),
-    ('currentslice', 'i2'),
-    ('frameinterval', 'f4'),
-    ('pixelaspectratio', 'f4'),
-    ('colorstart', 'i2'),
-    ('colorend', 'i2'),
-    ('ncolors', 'i2'),
-    ('fill1', '3u2'),
-    ('fill2', '3u2'),
-    ('colortable_t', 'u1'),  # NIH_COLORTABLE_TYPE
-    ('lutmode_t', 'u1'),  # NIH_LUTMODE_TYPE
-    ('invertedtable', 'b1'),
-    ('zeroclip', 'b1'),
-    ('_xunit_len', 'u1'),
-    ('xunit', 'a11'),
-    ('stacktype_t', 'i2'),  # NIH_STACKTYPE_TYPE
-]
-
-NIH_COLORTABLE_TYPE = (
-    'CustomTable', 'AppleDefault', 'Pseudo20', 'Pseudo32', 'Rainbow',
-    'Fire1', 'Fire2', 'Ice', 'Grays', 'Spectrum')
-
-NIH_LUTMODE_TYPE = (
-    'PseudoColor', 'OldAppleDefault', 'OldSpectrum', 'GrayScale',
-    'ColorLut', 'CustomGrayscale')
-
-NIH_CURVEFIT_TYPE = (
-    'StraightLine', 'Poly2', 'Poly3', 'Poly4', 'Poly5', 'ExpoFit',
-    'PowerFit', 'LogFit', 'RodbardFit', 'SpareFit1', 'Uncalibrated',
-    'UncalibratedOD')
-
-NIH_UNITS_TYPE = (
-    'Nanometers', 'Micrometers', 'Millimeters', 'Centimeters', 'Meters',
-    'Kilometers', 'Inches', 'Feet', 'Miles', 'Pixels', 'OtherUnits')
-
-NIH_STACKTYPE_TYPE = (
-    'VolumeStack', 'RGBStack', 'MovieStack', 'HSVStack')
-
-# Map Universal Imaging Corporation MetaMorph internal tag ids to name and type
-UIC_TAGS = {
-    0: ('auto_scale', int),
-    1: ('min_scale', int),
-    2: ('max_scale', int),
-    3: ('spatial_calibration', int),
-    4: ('x_calibration', Fraction),
-    5: ('y_calibration', Fraction),
-    6: ('calibration_units', str),
-    7: ('name', str),
-    8: ('thresh_state', int),
-    9: ('thresh_state_red', int),
-    10: ('tagid_10', None),  # undefined
-    11: ('thresh_state_green', int),
-    12: ('thresh_state_blue', int),
-    13: ('thresh_state_lo', int),
-    14: ('thresh_state_hi', int),
-    15: ('zoom', int),
-    16: ('create_time', julian_datetime),
-    17: ('last_saved_time', julian_datetime),
-    18: ('current_buffer', int),
-    19: ('gray_fit', None),
-    20: ('gray_point_count', None),
-    21: ('gray_x', Fraction),
-    22: ('gray_y', Fraction),
-    23: ('gray_min', Fraction),
-    24: ('gray_max', Fraction),
-    25: ('gray_unit_name', str),
-    26: ('standard_lut', int),
-    27: ('wavelength', int),
-    28: ('stage_position', '(%i,2,2)u4'),  # N xy positions as fractions
-    29: ('camera_chip_offset', '(%i,2,2)u4'),  # N xy offsets as fractions
-    30: ('overlay_mask', None),
-    31: ('overlay_compress', None),
-    32: ('overlay', None),
-    33: ('special_overlay_mask', None),
-    34: ('special_overlay_compress', None),
-    35: ('special_overlay', None),
-    36: ('image_property', read_uic_image_property),
-    37: ('stage_label', '%ip'),  # N str
-    38: ('autoscale_lo_info', Fraction),
-    39: ('autoscale_hi_info', Fraction),
-    40: ('absolute_z', '(%i,2)u4'),  # N fractions
-    41: ('absolute_z_valid', '(%i,)u4'),  # N long
-    42: ('gamma', int),
-    43: ('gamma_red', int),
-    44: ('gamma_green', int),
-    45: ('gamma_blue', int),
-    46: ('camera_bin', int),
-    47: ('new_lut', int),
-    48: ('image_property_ex', None),
-    49: ('plane_property', int),
-    50: ('user_lut_table', '(256,3)u1'),
-    51: ('red_autoscale_info', int),
-    52: ('red_autoscale_lo_info', Fraction),
-    53: ('red_autoscale_hi_info', Fraction),
-    54: ('red_minscale_info', int),
-    55: ('red_maxscale_info', int),
-    56: ('green_autoscale_info', int),
-    57: ('green_autoscale_lo_info', Fraction),
-    58: ('green_autoscale_hi_info', Fraction),
-    59: ('green_minscale_info', int),
-    60: ('green_maxscale_info', int),
-    61: ('blue_autoscale_info', int),
-    62: ('blue_autoscale_lo_info', Fraction),
-    63: ('blue_autoscale_hi_info', Fraction),
-    64: ('blue_min_scale_info', int),
-    65: ('blue_max_scale_info', int),
-    #66: ('overlay_plane_color', read_uic_overlay_plane_color),
-}
-
-# Olympus FluoView
-MM_DIMENSION = [
-    ('name', 'a16'),
-    ('size', 'i4'),
-    ('origin', 'f8'),
-    ('resolution', 'f8'),
-    ('unit', 'a64'),
-]
-
-MM_HEADER = [
-    ('header_flag', 'i2'),
-    ('image_type', 'u1'),
-    ('image_name', 'a257'),
-    ('offset_data', 'u4'),
-    ('palette_size', 'i4'),
-    ('offset_palette0', 'u4'),
-    ('offset_palette1', 'u4'),
-    ('comment_size', 'i4'),
-    ('offset_comment', 'u4'),
-    ('dimensions', MM_DIMENSION, 10),
-    ('offset_position', 'u4'),
-    ('map_type', 'i2'),
-    ('map_min', 'f8'),
-    ('map_max', 'f8'),
-    ('min_value', 'f8'),
-    ('max_value', 'f8'),
-    ('offset_map', 'u4'),
-    ('gamma', 'f8'),
-    ('offset', 'f8'),
-    ('gray_channel', MM_DIMENSION),
-    ('offset_thumbnail', 'u4'),
-    ('voice_field', 'i4'),
-    ('offset_voice_field', 'u4'),
-]
-
-# Carl Zeiss LSM
-CZ_LSM_INFO = [
-    ('magic_number', 'u4'),
-    ('structure_size', 'i4'),
-    ('dimension_x', 'i4'),
-    ('dimension_y', 'i4'),
-    ('dimension_z', 'i4'),
-    ('dimension_channels', 'i4'),
-    ('dimension_time', 'i4'),
-    ('data_type', 'i4'),  # CZ_DATA_TYPES
-    ('thumbnail_x', 'i4'),
-    ('thumbnail_y', 'i4'),
-    ('voxel_size_x', 'f8'),
-    ('voxel_size_y', 'f8'),
-    ('voxel_size_z', 'f8'),
-    ('origin_x', 'f8'),
-    ('origin_y', 'f8'),
-    ('origin_z', 'f8'),
-    ('scan_type', 'u2'),
-    ('spectral_scan', 'u2'),
-    ('type_of_data', 'u4'),  # CZ_TYPE_OF_DATA
-    ('offset_vector_overlay', 'u4'),
-    ('offset_input_lut', 'u4'),
-    ('offset_output_lut', 'u4'),
-    ('offset_channel_colors', 'u4'),
-    ('time_interval', 'f8'),
-    ('offset_channel_data_types', 'u4'),
-    ('offset_scan_info', 'u4'),  # CZ_LSM_SCAN_INFO
-    ('offset_ks_data', 'u4'),
-    ('offset_time_stamps', 'u4'),
-    ('offset_event_list', 'u4'),
-    ('offset_roi', 'u4'),
-    ('offset_bleach_roi', 'u4'),
-    ('offset_next_recording', 'u4'),
-    # LSM 2.0 ends here
-    ('display_aspect_x', 'f8'),
-    ('display_aspect_y', 'f8'),
-    ('display_aspect_z', 'f8'),
-    ('display_aspect_time', 'f8'),
-    ('offset_mean_of_roi_overlay', 'u4'),
-    ('offset_topo_isoline_overlay', 'u4'),
-    ('offset_topo_profile_overlay', 'u4'),
-    ('offset_linescan_overlay', 'u4'),
-    ('offset_toolbar_flags', 'u4'),
-    ('offset_channel_wavelength', 'u4'),
-    ('offset_channel_factors', 'u4'),
-    ('objective_sphere_correction', 'f8'),
-    ('offset_unmix_parameters', 'u4'),
-    # LSM 3.2, 4.0 end here
-    ('offset_acquisition_parameters', 'u4'),
-    ('offset_characteristics', 'u4'),
-    ('offset_palette', 'u4'),
-    ('time_difference_x', 'f8'),
-    ('time_difference_y', 'f8'),
-    ('time_difference_z', 'f8'),
-    ('internal_use_1', 'u4'),
-    ('dimension_p', 'i4'),
-    ('dimension_m', 'i4'),
-    ('dimensions_reserved', '16i4'),
-    ('offset_tile_positions', 'u4'),
-    ('reserved_1', '9u4'),
-    ('offset_positions', 'u4'),
-    ('reserved_2', '21u4'),  # must be 0
-]
-
-# Import functions for LSM_INFO sub-records
-CZ_LSM_INFO_READERS = {
-    'scan_info': read_cz_lsm_scan_info,
-    'time_stamps': read_cz_lsm_time_stamps,
-    'event_list': read_cz_lsm_event_list,
-    'channel_colors': read_cz_lsm_floatpairs,
-    'positions': read_cz_lsm_floatpairs,
-    'tile_positions': read_cz_lsm_floatpairs,
-}
-
-# Map cz_lsm_info.scan_type to dimension order
-CZ_SCAN_TYPES = {
-    0: 'XYZCT',  # x-y-z scan
-    1: 'XYZCT',  # z scan (x-z plane)
-    2: 'XYZCT',  # line scan
-    3: 'XYTCZ',  # time series x-y
-    4: 'XYZTC',  # time series x-z
-    5: 'XYTCZ',  # time series 'Mean of ROIs'
-    6: 'XYZTC',  # time series x-y-z
-    7: 'XYCTZ',  # spline scan
-    8: 'XYCZT',  # spline scan x-z
-    9: 'XYTCZ',  # time series spline plane x-z
-    10: 'XYZCT',  # point mode
-}
-
-# Map dimension codes to cz_lsm_info attribute
-CZ_DIMENSIONS = {
-    'X': 'dimension_x',
-    'Y': 'dimension_y',
-    'Z': 'dimension_z',
-    'C': 'dimension_channels',
-    'T': 'dimension_time',
-    'P': 'dimension_p',
-    'M': 'dimension_m',
-}
-
-# Description of cz_lsm_info.data_type
-CZ_DATA_TYPES = {
-    0: 'varying data types',
-    1: '8 bit unsigned integer',
-    2: '12 bit unsigned integer',
-    5: '32 bit float',
-}
-
-# Description of cz_lsm_info.type_of_data
-CZ_TYPE_OF_DATA = {
-    0: 'Original scan data',
-    1: 'Calculated data',
-    2: '3D reconstruction',
-    3: 'Topography height map',
-}
-
-CZ_LSM_SCAN_INFO_ARRAYS = {
-    0x20000000: "tracks",
-    0x30000000: "lasers",
-    0x60000000: "detection_channels",
-    0x80000000: "illumination_channels",
-    0xa0000000: "beam_splitters",
-    0xc0000000: "data_channels",
-    0x11000000: "timers",
-    0x13000000: "markers",
-}
-
-CZ_LSM_SCAN_INFO_STRUCTS = {
-    # 0x10000000: "recording",
-    0x40000000: "track",
-    0x50000000: "laser",
-    0x70000000: "detection_channel",
-    0x90000000: "illumination_channel",
-    0xb0000000: "beam_splitter",
-    0xd0000000: "data_channel",
-    0x12000000: "timer",
-    0x14000000: "marker",
-}
-
-CZ_LSM_SCAN_INFO_ATTRIBUTES = {
-    # recording
-    0x10000001: "name",
-    0x10000002: "description",
-    0x10000003: "notes",
-    0x10000004: "objective",
-    0x10000005: "processing_summary",
-    0x10000006: "special_scan_mode",
-    0x10000007: "scan_type",
-    0x10000008: "scan_mode",
-    0x10000009: "number_of_stacks",
-    0x1000000a: "lines_per_plane",
-    0x1000000b: "samples_per_line",
-    0x1000000c: "planes_per_volume",
-    0x1000000d: "images_width",
-    0x1000000e: "images_height",
-    0x1000000f: "images_number_planes",
-    0x10000010: "images_number_stacks",
-    0x10000011: "images_number_channels",
-    0x10000012: "linscan_xy_size",
-    0x10000013: "scan_direction",
-    0x10000014: "time_series",
-    0x10000015: "original_scan_data",
-    0x10000016: "zoom_x",
-    0x10000017: "zoom_y",
-    0x10000018: "zoom_z",
-    0x10000019: "sample_0x",
-    0x1000001a: "sample_0y",
-    0x1000001b: "sample_0z",
-    0x1000001c: "sample_spacing",
-    0x1000001d: "line_spacing",
-    0x1000001e: "plane_spacing",
-    0x1000001f: "plane_width",
-    0x10000020: "plane_height",
-    0x10000021: "volume_depth",
-    0x10000023: "nutation",
-    0x10000034: "rotation",
-    0x10000035: "precession",
-    0x10000036: "sample_0time",
-    0x10000037: "start_scan_trigger_in",
-    0x10000038: "start_scan_trigger_out",
-    0x10000039: "start_scan_event",
-    0x10000040: "start_scan_time",
-    0x10000041: "stop_scan_trigger_in",
-    0x10000042: "stop_scan_trigger_out",
-    0x10000043: "stop_scan_event",
-    0x10000044: "stop_scan_time",
-    0x10000045: "use_rois",
-    0x10000046: "use_reduced_memory_rois",
-    0x10000047: "user",
-    0x10000048: "use_bc_correction",
-    0x10000049: "position_bc_correction1",
-    0x10000050: "position_bc_correction2",
-    0x10000051: "interpolation_y",
-    0x10000052: "camera_binning",
-    0x10000053: "camera_supersampling",
-    0x10000054: "camera_frame_width",
-    0x10000055: "camera_frame_height",
-    0x10000056: "camera_offset_x",
-    0x10000057: "camera_offset_y",
-    0x10000059: "rt_binning",
-    0x1000005a: "rt_frame_width",
-    0x1000005b: "rt_frame_height",
-    0x1000005c: "rt_region_width",
-    0x1000005d: "rt_region_height",
-    0x1000005e: "rt_offset_x",
-    0x1000005f: "rt_offset_y",
-    0x10000060: "rt_zoom",
-    0x10000061: "rt_line_period",
-    0x10000062: "prescan",
-    0x10000063: "scan_direction_z",
-    # track
-    0x40000001: "multiplex_type",  # 0 after line; 1 after frame
-    0x40000002: "multiplex_order",
-    0x40000003: "sampling_mode",  # 0 sample; 1 line average; 2 frame average
-    0x40000004: "sampling_method",  # 1 mean; 2 sum
-    0x40000005: "sampling_number",
-    0x40000006: "acquire",
-    0x40000007: "sample_observation_time",
-    0x4000000b: "time_between_stacks",
-    0x4000000c: "name",
-    0x4000000d: "collimator1_name",
-    0x4000000e: "collimator1_position",
-    0x4000000f: "collimator2_name",
-    0x40000010: "collimator2_position",
-    0x40000011: "is_bleach_track",
-    0x40000012: "is_bleach_after_scan_number",
-    0x40000013: "bleach_scan_number",
-    0x40000014: "trigger_in",
-    0x40000015: "trigger_out",
-    0x40000016: "is_ratio_track",
-    0x40000017: "bleach_count",
-    0x40000018: "spi_center_wavelength",
-    0x40000019: "pixel_time",
-    0x40000021: "condensor_frontlens",
-    0x40000023: "field_stop_value",
-    0x40000024: "id_condensor_aperture",
-    0x40000025: "condensor_aperture",
-    0x40000026: "id_condensor_revolver",
-    0x40000027: "condensor_filter",
-    0x40000028: "id_transmission_filter1",
-    0x40000029: "id_transmission1",
-    0x40000030: "id_transmission_filter2",
-    0x40000031: "id_transmission2",
-    0x40000032: "repeat_bleach",
-    0x40000033: "enable_spot_bleach_pos",
-    0x40000034: "spot_bleach_posx",
-    0x40000035: "spot_bleach_posy",
-    0x40000036: "spot_bleach_posz",
-    0x40000037: "id_tubelens",
-    0x40000038: "id_tubelens_position",
-    0x40000039: "transmitted_light",
-    0x4000003a: "reflected_light",
-    0x4000003b: "simultan_grab_and_bleach",
-    0x4000003c: "bleach_pixel_time",
-    # laser
-    0x50000001: "name",
-    0x50000002: "acquire",
-    0x50000003: "power",
-    # detection_channel
-    0x70000001: "integration_mode",
-    0x70000002: "special_mode",
-    0x70000003: "detector_gain_first",
-    0x70000004: "detector_gain_last",
-    0x70000005: "amplifier_gain_first",
-    0x70000006: "amplifier_gain_last",
-    0x70000007: "amplifier_offs_first",
-    0x70000008: "amplifier_offs_last",
-    0x70000009: "pinhole_diameter",
-    0x7000000a: "counting_trigger",
-    0x7000000b: "acquire",
-    0x7000000c: "point_detector_name",
-    0x7000000d: "amplifier_name",
-    0x7000000e: "pinhole_name",
-    0x7000000f: "filter_set_name",
-    0x70000010: "filter_name",
-    0x70000013: "integrator_name",
-    0x70000014: "channel_name",
-    0x70000015: "detector_gain_bc1",
-    0x70000016: "detector_gain_bc2",
-    0x70000017: "amplifier_gain_bc1",
-    0x70000018: "amplifier_gain_bc2",
-    0x70000019: "amplifier_offset_bc1",
-    0x70000020: "amplifier_offset_bc2",
-    0x70000021: "spectral_scan_channels",
-    0x70000022: "spi_wavelength_start",
-    0x70000023: "spi_wavelength_stop",
-    0x70000026: "dye_name",
-    0x70000027: "dye_folder",
-    # illumination_channel
-    0x90000001: "name",
-    0x90000002: "power",
-    0x90000003: "wavelength",
-    0x90000004: "aquire",
-    0x90000005: "detchannel_name",
-    0x90000006: "power_bc1",
-    0x90000007: "power_bc2",
-    # beam_splitter
-    0xb0000001: "filter_set",
-    0xb0000002: "filter",
-    0xb0000003: "name",
-    # data_channel
-    0xd0000001: "name",
-    0xd0000003: "acquire",
-    0xd0000004: "color",
-    0xd0000005: "sample_type",
-    0xd0000006: "bits_per_sample",
-    0xd0000007: "ratio_type",
-    0xd0000008: "ratio_track1",
-    0xd0000009: "ratio_track2",
-    0xd000000a: "ratio_channel1",
-    0xd000000b: "ratio_channel2",
-    0xd000000c: "ratio_const1",
-    0xd000000d: "ratio_const2",
-    0xd000000e: "ratio_const3",
-    0xd000000f: "ratio_const4",
-    0xd0000010: "ratio_const5",
-    0xd0000011: "ratio_const6",
-    0xd0000012: "ratio_first_images1",
-    0xd0000013: "ratio_first_images2",
-    0xd0000014: "dye_name",
-    0xd0000015: "dye_folder",
-    0xd0000016: "spectrum",
-    0xd0000017: "acquire",
-    # timer
-    0x12000001: "name",
-    0x12000002: "description",
-    0x12000003: "interval",
-    0x12000004: "trigger_in",
-    0x12000005: "trigger_out",
-    0x12000006: "activation_time",
-    0x12000007: "activation_number",
-    # marker
-    0x14000001: "name",
-    0x14000002: "description",
-    0x14000003: "trigger_in",
-    0x14000004: "trigger_out",
-}
-
-# TVIPS metadata from EMMENU Help file
-TVIPS_HEADER_V1 = [
-    ('version', 'i4'),
-    ('comment_v1', 'a80'),
-    ('high_tension', 'i4'),
-    ('spherical_aberration', 'i4'),
-    ('illumination_aperture', 'i4'),
-    ('magnification', 'i4'),
-    ('post-magnification', 'i4'),
-    ('focal_length', 'i4'),
-    ('defocus', 'i4'),
-    ('astigmatism', 'i4'),
-    ('astigmatism_direction', 'i4'),
-    ('biprism_voltage', 'i4'),
-    ('specimen_tilt_angle', 'i4'),
-    ('specimen_tilt_direction', 'i4'),
-    ('illumination_tilt_direction', 'i4'),
-    ('illumination_tilt_angle', 'i4'),
-    ('image_mode', 'i4'),
-    ('energy_spread', 'i4'),
-    ('chromatic_aberration', 'i4'),
-    ('shutter_type', 'i4'),
-    ('defocus_spread', 'i4'),
-    ('ccd_number', 'i4'),
-    ('ccd_size', 'i4'),
-    ('offset_x_v1', 'i4'),
-    ('offset_y_v1', 'i4'),
-    ('physical_pixel_size', 'i4'),
-    ('binning', 'i4'),
-    ('readout_speed', 'i4'),
-    ('gain_v1', 'i4'),
-    ('sensitivity_v1', 'i4'),
-    ('exposure_time_v1', 'i4'),
-    ('flat_corrected', 'i4'),
-    ('dead_px_corrected', 'i4'),
-    ('image_mean', 'i4'),
-    ('image_std', 'i4'),
-    ('displacement_x', 'i4'),
-    ('displacement_y', 'i4'),
-    ('date_v1', 'i4'),
-    ('time_v1', 'i4'),
-    ('image_min', 'i4'),
-    ('image_max', 'i4'),
-    ('image_statistics_quality', 'i4'),
-]
-
-TVIPS_HEADER_V2 = [
-    ('image_name', 'V160'),  # utf16
-    ('image_folder', 'V160'),
-    ('image_size_x', 'i4'),
-    ('image_size_y', 'i4'),
-    ('image_size_z', 'i4'),
-    ('image_size_e', 'i4'),
-    ('image_data_type', 'i4'),
-    ('date', 'i4'),
-    ('time', 'i4'),
-    ('comment', 'V1024'),
-    ('image_history', 'V1024'),
-    ('scaling', '16f4'),
-    ('image_statistics', '16c16'),
-    ('image_type', 'i4'),
-    ('image_display_type', 'i4'),
-    ('pixel_size_x', 'f4'),  # distance between two px in x, [nm]
-    ('pixel_size_y', 'f4'),  # distance between two px in y, [nm]
-    ('image_distance_z', 'f4'),
-    ('image_distance_e', 'f4'),
-    ('image_misc', '32f4'),
-    ('tem_type', 'V160'),
-    ('tem_high_tension', 'f4'),
-    ('tem_aberrations', '32f4'),
-    ('tem_energy', '32f4'),
-    ('tem_mode', 'i4'),
-    ('tem_magnification', 'f4'),
-    ('tem_magnification_correction', 'f4'),
-    ('post_magnification', 'f4'),
-    ('tem_stage_type', 'i4'),
-    ('tem_stage_position', '5f4'),  # x, y, z, a, b
-    ('tem_image_shift', '2f4'),
-    ('tem_beam_shift', '2f4'),
-    ('tem_beam_tilt', '2f4'),
-    ('tiling_parameters', '7f4'),  # 0: tiling? 1:x 2:y 3: max x 4: max y
-                                   # 5: overlap x 6: overlap y
-    ('tem_illumination', '3f4'),  # 0: spotsize 1: intensity
-    ('tem_shutter', 'i4'),
-    ('tem_misc', '32f4'),
-    ('camera_type', 'V160'),
-    ('physical_pixel_size_x', 'f4'),
-    ('physical_pixel_size_y', 'f4'),
-    ('offset_x', 'i4'),
-    ('offset_y', 'i4'),
-    ('binning_x', 'i4'),
-    ('binning_y', 'i4'),
-    ('exposure_time', 'f4'),
-    ('gain', 'f4'),
-    ('readout_rate', 'f4'),
-    ('flatfield_description', 'V160'),
-    ('sensitivity', 'f4'),
-    ('dose', 'f4'),
-    ('cam_misc', '32f4'),
-    ('fei_microscope_information', 'V1024'),
-    ('fei_specimen_information', 'V1024'),
-    ('magic', 'u4'),
-]
-
-# Map TIFF tag code to attribute name, default value, type, count, validator
-TIFF_TAGS = {
-    254: ('new_subfile_type', 0, 4, 1, TIFF_SUBFILE_TYPES()),
-    255: ('subfile_type', None, 3, 1,
-          {0: 'undefined', 1: 'image', 2: 'reduced_image', 3: 'page'}),
-    256: ('image_width', None, 4, 1, None),
-    257: ('image_length', None, 4, 1, None),
-    258: ('bits_per_sample', 1, 3, None, None),
-    259: ('compression', 1, 3, 1, TIFF_COMPESSIONS),
-    262: ('photometric', None, 3, 1, TIFF_PHOTOMETRICS),
-    266: ('fill_order', 1, 3, 1, {1: 'msb2lsb', 2: 'lsb2msb'}),
-    269: ('document_name', None, 2, None, None),
-    270: ('image_description', None, 2, None, None),
-    271: ('make', None, 2, None, None),
-    272: ('model', None, 2, None, None),
-    273: ('strip_offsets', None, 4, None, None),
-    274: ('orientation', 1, 3, 1, TIFF_ORIENTATIONS),
-    277: ('samples_per_pixel', 1, 3, 1, None),
-    278: ('rows_per_strip', 2**32-1, 4, 1, None),
-    279: ('strip_byte_counts', None, 4, None, None),
-    280: ('min_sample_value', None, 3, None, None),
-    281: ('max_sample_value', None, 3, None, None),  # 2**bits_per_sample
-    282: ('x_resolution', None, 5, 1, None),
-    283: ('y_resolution', None, 5, 1, None),
-    284: ('planar_configuration', 1, 3, 1, {1: 'contig', 2: 'separate'}),
-    285: ('page_name', None, 2, None, None),
-    286: ('x_position', None, 5, 1, None),
-    287: ('y_position', None, 5, 1, None),
-    296: ('resolution_unit', 2, 4, 1, {1: None, 2: 'inch', 3: 'centimeter'}),
-    297: ('page_number', None, 3, 2, None),
-    305: ('software', None, 2, None, None),
-    306: ('datetime', None, 2, None, None),
-    315: ('artist', None, 2, None, None),
-    316: ('host_computer', None, 2, None, None),
-    317: ('predictor', 1, 3, 1, {1: None, 2: 'horizontal', 3: 'float'}),
-    318: ('white_point', None, 5, 2, None),
-    319: ('primary_chromaticities', None, 5, 6, None),
-    320: ('color_map', None, 3, None, None),
-    322: ('tile_width', None, 4, 1, None),
-    323: ('tile_length', None, 4, 1, None),
-    324: ('tile_offsets', None, 4, None, None),
-    325: ('tile_byte_counts', None, 4, None, None),
-    330: ('sub_ifds', None, 4, None, None),
-    338: ('extra_samples', None, 3, None,
-          {0: 'unspecified', 1: 'assocalpha', 2: 'unassalpha'}),
-    339: ('sample_format', 1, 3, None, TIFF_SAMPLE_FORMATS),
-    340: ('smin_sample_value', None, None, None, None),
-    341: ('smax_sample_value', None, None, None, None),
-    346: ('indexed', 0, 3, 1, None),
-    347: ('jpeg_tables', None, 7, None, None),
-    530: ('ycbcr_subsampling', (1, 1), 3, 2, None),
-    531: ('ycbcr_positioning', (1, 1), 3, 1, None),
-    532: ('reference_black_white', None, 5, 1, None),
-    32995: ('sgi_matteing', None, None, 1, None),  # use extra_samples
-    32996: ('sgi_datatype', None, None, None, None),  # use sample_format
-    32997: ('image_depth', 1, 4, 1, None),
-    32998: ('tile_depth', None, 4, 1, None),
-    33432: ('copyright', None, 1, None, None),
-    33445: ('md_file_tag', None, 4, 1, None),
-    33446: ('md_scale_pixel', None, 5, 1, None),
-    33447: ('md_color_table', None, 3, None, None),
-    33448: ('md_lab_name', None, 2, None, None),
-    33449: ('md_sample_info', None, 2, None, None),
-    33450: ('md_prep_date', None, 2, None, None),
-    33451: ('md_prep_time', None, 2, None, None),
-    33452: ('md_file_units', None, 2, None, None),
-    33550: ('model_pixel_scale', None, 12, 3, None),
-    33922: ('model_tie_point', None, 12, None, None),
-    34665: ('exif_ifd', None, None, 1, None),
-    34735: ('geo_key_directory', None, 3, None, None),
-    34736: ('geo_double_params', None, 12, None, None),
-    34737: ('geo_ascii_params', None, 2, None, None),
-    34853: ('gps_ifd', None, None, 1, None),
-    37510: ('user_comment', None, None, None, None),
-    42112: ('gdal_metadata', None, 2, None, None),
-    42113: ('gdal_nodata', None, 2, None, None),
-    50289: ('mc_xy_position', None, 12, 2, None),
-    50290: ('mc_z_position', None, 12, 1, None),
-    50291: ('mc_xy_calibration', None, 12, 3, None),
-    50292: ('mc_lens_lem_na_n', None, 12, 3, None),
-    50293: ('mc_channel_name', None, 1, None, None),
-    50294: ('mc_ex_wavelength', None, 12, 1, None),
-    50295: ('mc_time_stamp', None, 12, 1, None),
-    50838: ('imagej_byte_counts', None, None, None, None),
-    51023: ('fibics_xml', None, 2, None, None),
-    65200: ('flex_xml', None, 2, None, None),
-    # code: (attribute name, default value, type, count, validator)
-}
-
-# Map custom TIFF tag codes to attribute names and import functions
-CUSTOM_TAGS = {
-    700: ('xmp', read_bytes),
-    34377: ('photoshop', read_numpy),
-    33723: ('iptc', read_bytes),
-    34675: ('icc_profile', read_bytes),
-    33628: ('uic1tag', read_uic1tag),  # Universal Imaging Corporation STK
-    33629: ('uic2tag', read_uic2tag),
-    33630: ('uic3tag', read_uic3tag),
-    33631: ('uic4tag', read_uic4tag),
-    34118: ('sem_metadata', read_sem_metadata),  # Zeiss SEM
-    34361: ('mm_header', read_mm_header),  # Olympus FluoView
-    34362: ('mm_stamp', read_mm_stamp),
-    34386: ('mm_user_block', read_bytes),
-    34412: ('cz_lsm_info', read_cz_lsm_info),  # Carl Zeiss LSM
-    34680: ('sfeg_metadata', read_fei_metadata),  # S-FEG
-    34682: ('helios_metadata', read_fei_metadata),  # Helios NanoLab
-    37706: ('tvips_metadata', read_tvips_header),  # TVIPS EMMENU
-    43314: ('nih_image_header', read_nih_image_header),
-    # 40001: ('mc_ipwinscal', read_bytes),
-    40100: ('mc_id_old', read_bytes),
-    50288: ('mc_id', read_bytes),
-    50296: ('mc_frame_properties', read_bytes),
-    50839: ('imagej_metadata', read_bytes),
-    51123: ('micromanager_metadata', read_json),
-}
-
-# Max line length of printed output
-PRINT_LINE_LEN = 79
+def lsm2bin(lsmfile, binfile=None, tile=(256, 256), verbose=True):
+    """Convert [MP]TZCYX LSM file to series of BIN files.
+
+    One BIN file containing 'ZCYX' data is created for each position, time,
+    and tile. The position, time, and tile indices are encoded at the end
+    of the filenames.
+
+    """
+    verbose = print_ if verbose else nullfunc
+
+    if binfile is None:
+        binfile = lsmfile
+    elif binfile.lower() == 'none':
+        binfile = None
+    if binfile:
+        binfile += "_(z%ic%iy%ix%i)_m%%ip%%it%%03iy%%ix%%i.bin"
+
+    verbose("\nOpening LSM file... ", end='', flush=True)
+    start_time = time.time()
+
+    with TiffFile(lsmfile) as lsm:
+        if not lsm.is_lsm:
+            verbose("\n", lsm, flush=True)
+            raise ValueError("not a LSM file")
+        series = lsm.series[0]  # first series contains the image data
+        shape = series.shape
+        axes = series.axes
+        dtype = series.dtype
+        size = product(shape) * dtype.itemsize
+
+        verbose("%.3f s" % (time.time() - start_time))
+        # verbose(lsm, flush=True)
+        verbose("Image\n  axes:  %s\n  shape: %s\n  dtype: %s\n  size:  %s"
+                % (axes, shape, dtype, format_size(size)), flush=True)
+        if not series.axes.endswith('TZCYX'):
+            raise ValueError("not a *TZCYX LSM file")
+
+        verbose("Copying image from LSM to BIN files", end='', flush=True)
+        start_time = time.time()
+        tiles = shape[-2] // tile[-2], shape[-1] // tile[-1]
+        if binfile:
+            binfile = binfile % (shape[-4], shape[-3], tile[0], tile[1])
+        shape = (1,) * (7-len(shape)) + shape
+        # cache for ZCYX stacks and output files
+        data = numpy.empty(shape[3:], dtype=dtype)
+        out = numpy.empty((shape[-4], shape[-3], tile[0], tile[1]),
+                          dtype=dtype)
+        # iterate over Tiff pages containing data
+        pages = iter(series.pages)
+        for m in range(shape[0]):  # mosaic axis
+            for p in range(shape[1]):  # position axis
+                for t in range(shape[2]):  # time axis
+                    for z in range(shape[3]):  # z slices
+                        data[z] = next(pages).asarray()
+                    for y in range(tiles[0]):  # tile y
+                        for x in range(tiles[1]):  # tile x
+                            out[:] = data[...,
+                                          y*tile[0]:(y+1)*tile[0],
+                                          x*tile[1]:(x+1)*tile[1]]
+                            if binfile:
+                                out.tofile(binfile % (m, p, t, y, x))
+                            verbose('.', end='', flush=True)
+        verbose(" %.3f s" % (time.time() - start_time))
 
 
 def imshow(data, title=None, vmin=0, vmax=None, cmap=None,
-           bitspersample=None, photometric='rgb', interpolation=None,
-           dpi=96, figure=None, subplot=111, maxdim=8192, **kwargs):
+           bitspersample=None, photometric='RGB',
+           interpolation=None, dpi=96, figure=None, subplot=111, maxdim=32768,
+           **kwargs):
     """Plot n-dimensional images using matplotlib.pyplot.
 
     Return figure, subplot and plot axis.
-    Requires pyplot already imported `from matplotlib import pyplot`.
+    Requires pyplot already imported C{from matplotlib import pyplot}.
 
     Parameters
     ----------
     bitspersample : int or None
         Number of bits per channel in integer RGB images.
-    photometric : {'miniswhite', 'minisblack', 'rgb', or 'palette'}
+    photometric : {'MINISWHITE', 'MINISBLACK', 'RGB', or 'PALETTE'}
         The color space of the image data.
     title : str
         Window and subplot title.
@@ -6033,13 +8619,14 @@ def imshow(data, title=None, vmin=0, vmax=None, cmap=None,
         Arguments for matplotlib.pyplot.imshow.
 
     """
-    #if photometric not in ('miniswhite', 'minisblack', 'rgb', 'palette'):
-    #    raise ValueError("Can not handle %s photometrics" % photometric)
-    # TODO: handle photometric == 'separated' (CMYK)
-    isrgb = photometric in ('rgb', 'palette')
+    isrgb = photometric in ('RGB',)  # 'PALETTE'
+    if isrgb and not (data.shape[-1] in (3, 4) or (
+            data.ndim > 2 and data.shape[-3] in (3, 4))):
+        isrgb = False
+        photometric = 'MINISWHITE'
 
     data = data.squeeze()
-    if photometric in ('miniswhite', 'minisblack'):
+    if photometric in ('MINISWHITE', 'MINISBLACK', None):
         data = reshape_nd(data, 2)
     else:
         data = reshape_nd(data, 3)
@@ -6067,10 +8654,10 @@ def imshow(data, title=None, vmin=0, vmax=None, cmap=None,
     else:
         data = data[..., :maxdim, :maxdim]
 
-    if photometric == 'palette' and isrgb:
+    if photometric == 'PALETTE' and isrgb:
         datamax = data.max()
         if datamax > 255:
-            data >>= 8  # possible precision loss
+            data = data >> 8  # possible precision loss
         data = data.astype('B')
     elif data.dtype.kind in 'ui':
         if not (isrgb and data.dtype.itemsize <= 1) or bitspersample is None:
@@ -6078,27 +8665,29 @@ def imshow(data, title=None, vmin=0, vmax=None, cmap=None,
                 bitspersample = int(math.ceil(math.log(data.max(), 2)))
             except Exception:
                 bitspersample = data.dtype.itemsize * 8
-        elif not isinstance(bitspersample, int):
+        elif not isinstance(bitspersample, inttypes):
             # bitspersample can be tuple, e.g. (5, 6, 5)
             bitspersample = data.dtype.itemsize * 8
         datamax = 2**bitspersample
         if isrgb:
             if bitspersample < 8:
-                data <<= 8 - bitspersample
+                data = data << (8 - bitspersample)
             elif bitspersample > 8:
-                data >>= bitspersample - 8  # precision loss
+                data = data >> (bitspersample - 8)  # precision loss
             data = data.astype('B')
     elif data.dtype.kind == 'f':
         datamax = data.max()
         if isrgb and datamax > 1.0:
             if data.dtype.char == 'd':
                 data = data.astype('f')
-            data /= datamax
+                data /= datamax
+            else:
+                data = data / datamax
     elif data.dtype.kind == 'b':
         datamax = 1
     elif data.dtype.kind == 'c':
-        # TODO: handle complex types
-        raise NotImplementedError("complex type")
+        data = numpy.absolute(data)
+        datamax = data.max()
 
     if not isrgb:
         if vmax is None:
@@ -6127,7 +8716,8 @@ def imshow(data, title=None, vmin=0, vmax=None, cmap=None,
             figure.canvas.manager.window.title(title)
         except Exception:
             pass
-        pyplot.subplots_adjust(bottom=0.03*(dims+2), top=0.9,
+        l = len(title.splitlines()) if title else 1
+        pyplot.subplots_adjust(bottom=0.03*(dims+2), top=0.98-l*0.03,
                                left=0.1, right=0.95, hspace=0.05, wspace=0.0)
     subplot = pyplot.subplot(subplot)
 
@@ -6140,10 +8730,10 @@ def imshow(data, title=None, vmin=0, vmax=None, cmap=None,
 
     if cmap is None:
         if data.dtype.kind in 'ubf' or vmin == 0:
-            cmap = 'cubehelix'
+            cmap = 'viridis'
         else:
             cmap = 'coolwarm'
-        if photometric == 'miniswhite':
+        if photometric == 'MINISWHITE':
             cmap += '_r'
 
     image = pyplot.imshow(data[(0,) * dims].squeeze(), vmin=vmin, vmax=vmax,
@@ -6158,18 +8748,22 @@ def imshow(data, title=None, vmin=0, vmax=None, cmap=None,
         y = int(y + 0.5)
         try:
             if dims:
-                return "%s @ %s [%4i, %4i]" % (cur_ax_dat[1][y, x],
-                                               current, x, y)
-            else:
-                return "%s @ [%4i, %4i]" % (data[y, x], x, y)
+                return "%s @ %s [%4i, %4i]" % (
+                    curaxdat[1][y, x], current, y, x)
+            return "%s @ [%4i, %4i]" % (data[y, x], y, x)
         except IndexError:
-            return ""
+            return ''
+
+    def none(event):
+        return ''
 
-    pyplot.gca().format_coord = format_coord
+    subplot.format_coord = format_coord
+    image.get_cursor_data = none
+    image.format_cursor_data = none
 
     if dims:
         current = list((0,) * dims)
-        cur_ax_dat = [0, data[tuple(current)].squeeze()]
+        curaxdat = [0, data[tuple(current)].squeeze()]
         sliders = [pyplot.Slider(
             pyplot.axes([0.125, 0.03*(axis+1), 0.725, 0.025]),
             'Dimension %i' % axis, 0, data.shape[axis]-1, 0, facecolor='0.5',
@@ -6179,8 +8773,8 @@ def imshow(data, title=None, vmin=0, vmax=None, cmap=None,
 
         def set_image(current, sliders=sliders, data=data):
             # change image and redraw canvas
-            cur_ax_dat[1] = data[tuple(current)].squeeze()
-            image.set_data(cur_ax_dat[1])
+            curaxdat[1] = data[tuple(current)].squeeze()
+            image.set_data(curaxdat[1])
             for ctrl, index in zip(sliders, current):
                 ctrl.eventson = False
                 ctrl.set_val(index)
@@ -6190,7 +8784,7 @@ def imshow(data, title=None, vmin=0, vmax=None, cmap=None,
         def on_changed(index, axis, data=data, current=current):
             # callback function for slider change event
             index = int(round(index))
-            cur_ax_dat[0] = axis
+            curaxdat[0] = axis
             if index == current[axis]:
                 return
             if index >= data.shape[axis]:
@@ -6203,7 +8797,7 @@ def imshow(data, title=None, vmin=0, vmax=None, cmap=None,
         def on_keypressed(event, data=data, current=current):
             # callback function for key press event
             key = event.key
-            axis = cur_ax_dat[0]
+            axis = curaxdat[0]
             if str(key) in '0123456789':
                 on_changed(key, axis)
             elif key == 'right':
@@ -6211,9 +8805,9 @@ def imshow(data, title=None, vmin=0, vmax=None, cmap=None,
             elif key == 'left':
                 on_changed(current[axis] - 1, axis)
             elif key == 'up':
-                cur_ax_dat[0] = 0 if axis == len(data.shape)-1 else axis + 1
+                curaxdat[0] = 0 if axis == len(data.shape)-1 else axis + 1
             elif key == 'down':
-                cur_ax_dat[0] = len(data.shape)-1 if axis == 0 else axis - 1
+                curaxdat[0] = len(data.shape)-1 if axis == 0 else axis - 1
             elif key == 'end':
                 on_changed(data.shape[axis] - 1, axis)
             elif key == 'home':
@@ -6249,10 +8843,14 @@ def askopenfilename(**kwargs):
 
 def main(argv=None):
     """Command line usage main function."""
+    if float(sys.version[0:3]) < 2.7:
+        print("This script requires Python version 2.7 or better.")
+        print("This is Python version %s" % sys.version)
+        return 0
     if argv is None:
         argv = sys.argv
 
-    import optparse
+    import optparse  # TODO: use argparse
 
     parser = optparse.OptionParser(
         usage="usage: %prog [options] path",
@@ -6265,35 +8863,33 @@ def main(argv=None):
         help="display series of pages of same shape")
     opt('--nomultifile', dest='nomultifile', action='store_true',
         default=False, help="do not read OME series from multiple files")
-    opt('--noplot', dest='noplot', action='store_true', default=False,
-        help="do not display images")
+    opt('--noplots', dest='noplots', type='int', default=8,
+        help="maximum number of plots")
     opt('--interpol', dest='interpol', metavar='INTERPOL', default='bilinear',
         help="image interpolation method")
     opt('--dpi', dest='dpi', type='int', default=96,
-        help="set plot resolution")
+        help="plot resolution")
     opt('--vmin', dest='vmin', type='int', default=None,
-        help="set minimum value for colormapping")
+        help="minimum value for colormapping")
     opt('--vmax', dest='vmax', type='int', default=None,
-        help="set maximum value for colormapping")
+        help="maximum value for colormapping")
     opt('--debug', dest='debug', action='store_true', default=False,
         help="raise exception on failures")
     opt('--doctest', dest='doctest', action='store_true', default=False,
         help="runs the docstring examples")
-    opt('-v', '--verbose', dest='verbose', action='store_true', default=True)
-    opt('-q', '--quiet', dest='verbose', action='store_false')
+    opt('-v', '--detail', dest='detail', type='int', default=2)
+    opt('-q', '--quiet', dest='quiet', action='store_true')
 
     settings, path = parser.parse_args()
     path = ' '.join(path)
 
     if settings.doctest:
         import doctest
-        doctest.testmod()
+        doctest.testmod(optionflags=doctest.ELLIPSIS)
         return 0
     if not path:
-        path = askopenfilename(
-            title="Select a TIFF file",
-            filetypes=[("TIF files", "*.tif"), ("LSM files", "*.lsm"),
-                       ("STK files", "*.stk"), ("allfiles", "*")])
+        path = askopenfilename(title="Select a TIFF file",
+                               filetypes=TIFF.FILEOPEN_FILTER)
         if not path:
             parser.error("No file specified")
 
@@ -6303,10 +8899,10 @@ def main(argv=None):
             print('no files match the pattern')
             return 0
         # TODO: handle image sequences
-        #if len(path) == 1:
         path = path[0]
 
-    print("Reading file structure...", end=' ')
+    if not settings.quiet:
+        print("\nReading file structure...", end=' ')
     start = time.time()
     try:
         tif = TiffFile(path, multifile=not settings.nomultifile)
@@ -6316,14 +8912,16 @@ def main(argv=None):
         else:
             print("\n", e)
             sys.exit(0)
-    print("%.3f ms" % ((time.time()-start) * 1e3))
+    if not settings.quiet:
+        print("%.3f ms" % ((time.time()-start) * 1e3))
 
     if tif.is_ome:
         settings.norgb = True
 
     images = []
-    if not settings.noplot:
-        print("Reading image data... ", end=' ')
+    if settings.noplots > 0:
+        if not settings.quiet:
+            print("Reading image data... ", end=' ')
 
         def notnone(x):
             return next(i for i in x if i is not None)
@@ -6332,36 +8930,40 @@ def main(argv=None):
         try:
             if settings.page >= 0:
                 images = [(tif.asarray(key=settings.page),
-                           tif[settings.page])]
+                           tif[settings.page], None)]
             elif settings.series >= 0:
                 images = [(tif.asarray(series=settings.series),
-                           notnone(tif.series[settings.series].pages))]
+                           notnone(tif.series[settings.series].pages),
+                           tif.series[settings.series])]
             else:
                 images = []
-                for i, s in enumerate(tif.series):
+                for i, s in enumerate(tif.series[:settings.noplots]):
                     try:
-                        images.append(
-                            (tif.asarray(series=i), notnone(s.pages)))
+                        images.append((tif.asarray(series=i),
+                                       notnone(s.pages),
+                                       tif.series[i]))
                     except ValueError as e:
-                        images.append((None, notnone(s.pages)))
+                        images.append((None, notnone(s.pages), None))
                         if settings.debug:
                             raise
                         else:
-                            print("\n* series %i failed: %s... " % (i, e),
+                            print("\nSeries %i failed: %s... " % (i, e),
                                   end='')
-            print("%.3f ms" % ((time.time()-start) * 1e3))
+            if not settings.quiet:
+                print("%.3f ms" % ((time.time()-start) * 1e3))
         except Exception as e:
             if settings.debug:
                 raise
             else:
                 print(e)
 
+    if not settings.quiet:
+        print()
+        print(TiffFile.__str__(tif, detail=int(settings.detail)))
+        print()
     tif.close()
-    print()
-    print(tif.info())
-    print()
 
-    if images and not settings.noplot:
+    if images and settings.noplots > 0:
         try:
             import matplotlib
             matplotlib.use('TkAgg')
@@ -6369,54 +8971,88 @@ def main(argv=None):
         except ImportError as e:
             warnings.warn("failed to import matplotlib.\n%s" % e)
         else:
-            for img, page in images:
+            for img, page, series in images:
                 if img is None:
                     continue
                 vmin, vmax = settings.vmin, settings.vmax
-                if 'gdal_nodata' in page.tags:
+                if 'GDAL_NODATA' in page.tags:
                     try:
-                        vmin = numpy.min(img[img > float(page.gdal_nodata)])
+                        vmin = numpy.min(
+                            img[img > float(page.tags['GDAL_NODATA'].value)])
                     except ValueError:
                         pass
-                if page.is_stk:
+                if tif.is_stk:
                     try:
-                        vmin = page.uic_tags['min_scale']
-                        vmax = page.uic_tags['max_scale']
+                        vmin = tif.stk_metadata['MinScale']
+                        vmax = tif.stk_metadata['MaxScale']
                     except KeyError:
                         pass
                     else:
                         if vmax <= vmin:
                             vmin, vmax = settings.vmin, settings.vmax
-                title = "%s\n %s" % (str(tif), str(page))
+                if series:
+                    title = "%s\n%s\n%s" % (str(tif), str(page), str(series))
+                else:
+                    title = "%s\n %s" % (str(tif), str(page))
+                photometric = 'MINISBLACK'
+                if page.photometric not in (3,):
+                    photometric = TIFF.PHOTOMETRIC(page.photometric).name
                 imshow(img, title=title, vmin=vmin, vmax=vmax,
-                       bitspersample=page.bits_per_sample,
-                       photometric=page.photometric,
+                       bitspersample=page.bitspersample,
+                       photometric=photometric,
                        interpolation=settings.interpol,
                        dpi=settings.dpi)
             pyplot.show()
 
 
-TIFFfile = TiffFile  # backwards compatibility
-
-if sys.version_info[0] > 2:
-    basestring = str, bytes
-    unicode = str
-
-    def bytes2str(b):
-        return str(b, 'cp1252')
+if sys.version_info[0] == 2:
+    inttypes = int, long  # noqa
 
-    def str2bytes(s, encoding="latin-1"):
-        return s.encode(encoding)
+    def print_(*args, **kwargs):
+        """Print function with flush support."""
+        flush = kwargs.pop('flush', False)
+        print(*args, **kwargs)
+        if flush:
+            sys.stdout.flush()
 
-else:
-    bytes2str = str
+    def bytes2str(b, encoding=None, errors=None):
+        """Return string from bytes."""
+        return b
 
-    def str2bytes(s):
+    def str2bytes(s, encoding=None):
+        """Return bytes from string."""
         return s
 
+    def byte2int(b):
+        """Return value of byte as int."""
+        return ord(b)
+
     class FileNotFoundError(IOError):
         pass
 
+    TiffFrame = TiffPage  # noqa
+else:
+    inttypes = int
+    basestring = str, bytes
+    unicode = str
+    print_ = print
+
+    def bytes2str(b, encoding=None, errors='strict'):
+        """Return unicode string from encoded bytes."""
+        if encoding is not None:
+            return b.decode(encoding, errors)
+        try:
+            return b.decode('utf-8', errors)
+        except UnicodeDecodeError:
+            return b.decode('cp1252', errors)
+
+    def str2bytes(s, encoding='cp1252'):
+        """Return bytes from unicode string."""
+        return s.encode(encoding)
+
+    def byte2int(b):
+        """Return value of byte as int."""
+        return b
 
 if __name__ == "__main__":
     sys.exit(main())
diff --git a/imageio/plugins/tifffile.py b/imageio/plugins/tifffile.py
index a23215d..1857b3e 100644
--- a/imageio/plugins/tifffile.py
+++ b/imageio/plugins/tifffile.py
@@ -172,19 +172,27 @@ class TiffFormat(Format):
         def _open(self, **kwargs):
             if not _tifffile:
                 load_lib()
-            self._tf = _tifffile.TiffFile(self.request.get_file(), **kwargs)
+            # Allow loading from http; tiffile uses seek, so download first
+            if self.request.filename.startswith(('http://', 'https://')):
+                self._f = f = open(self.request.get_local_filename(), 'rb')
+            else:
+                self._f = None
+                f = self.request.get_file()
+            self._tf = _tifffile.TiffFile(f, **kwargs)
 
             # metadata is the same for all images
             self._meta = {}
 
         def _close(self):
             self._tf.close()
+            if self._f is not None:
+                self._f.close()
         
         def _get_length(self):
             if self.request.mode[1] in 'vV':
                 return 1  # or can there be pages in pages or something?
             else:
-                return len(self._tf)
+                return len(self._tf.pages)
         
         def _get_data(self, index):
             if self.request.mode[1] in 'vV':
@@ -196,16 +204,16 @@ class TiffFormat(Format):
                 meta = self._meta
             else:
                 # Read as 2D image
-                if index < 0 or index >= len(self._tf):
+                if index < 0 or index >= self._get_length():
                     raise IndexError(
                         'Index out of range while reading from tiff file')
-                im = self._tf[index].asarray()
+                im = self._tf.pages[index].asarray()
                 meta = self._meta or self._get_meta_data(index)
             # Return array and empty meta data
             return im, meta
 
         def _get_meta_data(self, index):
-            page = self._tf[index or 0]
+            page = self._tf.pages[index or 0]
             for key in READ_METADATA_KEYS:
                 try:
                     self._meta[key] = getattr(page, key)

update tifffile
[Version 2017.09.29](https://www.lfd.uci.edu/%7Egohlke/code/tifffile.py.html) brought significant updates for `tifffile.py`, e.g. consolidated access to metadata. At the moment, imageio holds at version 2017.01.12; it would be great if this could be updated.  
diff --git a/tests/test_core.py b/tests/test_core.py
index 31d4c7b..707569f 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -19,18 +19,26 @@ from imageio import core
 from imageio.core import Request
 from imageio.core import get_remote_file, IS_PYPY
 
+if sys.version_info < (3,):
+    FileNotFoundError = OSError
+
+try:
+    from pathlib import Path
+except ImportError:
+    Path = None
+
 test_dir = get_test_dir()
 
 
 def test_fetching():
     """ Test fetching of files """
-    
+
     need_internet()
-    
+
     # Clear image files
     if os.path.isdir(test_dir):
-        shutil.rmtree(test_dir)   
-    
+        shutil.rmtree(test_dir)
+
     # This should download the file (force download, because local cache)
     fname1 = get_remote_file('images/chelsea.png', test_dir, True)
     mtime1 = os.path.getmtime(fname1)
@@ -46,7 +54,7 @@ def test_fetching():
     # This should not
     fname5 = get_remote_file('images/chelsea.png', test_dir, '2014-01-01')
     mtime5 = os.path.getmtime(fname4)
-    # 
+    #
     assert os.path.isfile(fname1)
     assert fname1 == fname2
     assert fname1 == fname3
@@ -56,9 +64,9 @@ def test_fetching():
         # weird, but these often fail on my osx VM
         assert mtime1 == mtime2
         assert mtime1 < mtime3
-        assert mtime3 < mtime4  
+        assert mtime3 < mtime4
         assert mtime4 == mtime5
-    
+
     # Test failures
     _urlopen = core.fetching.urlopen
     _chunk_read = core.fetching._chunk_read
@@ -87,37 +95,37 @@ def test_fetching():
 
 
 def test_findlib1():
-    
+
     # Lib name would need to be "libc.so.5", or "libc.so.6", or ...
     # Meh, just skip
     skip('always skip, is tested implicitly anyway')
-    
+
     if not sys.platform.startswith('linux'):
         skip('test on linux only')
-    
+
     # Candidate libs for common lib (note, this runs only on linux)
     dirs, paths = core.findlib.generate_candidate_libs(['libc'])
     assert paths
 
 
 def test_findlib2():
-    
+
     if not sys.platform.startswith('linux'):
         skip('test on linux only')
-    
+
     need_internet()  # need our own version of FI to test this bit
-    
+
     # Candidate libs for common freeimage
     fi_dir = os.path.join(core.appdata_dir('imageio'), 'freeimage')
     if not os.path.isdir(fi_dir):
         os.mkdir(fi_dir)
-    dirs, paths = core.findlib.generate_candidate_libs(['libfreeimage'], 
+    dirs, paths = core.findlib.generate_candidate_libs(['libfreeimage'],
                                                        [fi_dir])
     #assert fi_dir in dirs -> Cannot test: lib may not exist
     assert paths
-    
+
     open(os.path.join(fi_dir, 'notalib.test.so'), 'wb')
-    
+
     # Loading libs
     gllib = ctypes.util.find_library('GL')
     core.load_lib([gllib], [])
@@ -131,7 +139,7 @@ def test_findlib2():
 
 def test_request():
     """ Test request object """
-    
+
     # Check uri-type, this is not a public property, so we test the private
     R = Request('http://example.com', 'ri')
     assert R._uri_type == core.request.URI_HTTP
@@ -161,7 +169,7 @@ def test_request():
     # zip file
     R = Request('~/bar.zip/spam.png', 'wi')
     assert R._uri_type == core.request.URI_ZIPPED
-    
+
     # Test failing inits
     raises(ValueError, Request, '/some/file', None)  # mode must be str
     raises(ValueError, Request, '/some/file', 3)  # mode must be str
@@ -173,11 +181,17 @@ def test_request():
     #
     raises(IOError, Request, ['invalid', 'uri'] * 10, 'ri')  # invalid uri
     raises(IOError, Request, 4, 'ri')  # invalid uri
-    raises(IOError, Request, '/does/not/exist', 'ri')  # reading nonexistent
-    raises(IOError, Request, '/does/not/exist.zip/spam.png', 'ri')  # dito
+    # nonexistent reads
+    raises(FileNotFoundError, Request, '/does/not/exist', 'ri')
+    raises(FileNotFoundError, Request, '/does/not/exist.zip/spam.png', 'ri')
+    if Path is not None:
+        raises(FileNotFoundError, Request, Path('/does/not/exist'), 'ri')
     raises(IOError, Request, 'http://example.com', 'wi')  # no writing here
-    raises(IOError, Request, '/does/not/exist.png', 'wi')  # write dir nonexist
-    
+    # write dir nonexist
+    raises(FileNotFoundError, Request, '/does/not/exist.png', 'wi')
+    if Path is not None:
+        raises(FileNotFoundError, Request, Path('/does/not/exist.png'), 'wi')
+
     # Test auto-download
     R = Request('imageio:chelsea.png', 'ri')
     assert R.filename == get_remote_file('images/chelsea.png')
@@ -188,7 +202,7 @@ def test_request():
 
 
 def test_request_read_sources():
-    
+
     # Make an image available in many ways
     fname = 'images/chelsea.png'
     filename = get_remote_file(fname, test_dir)
@@ -198,13 +212,13 @@ def test_request_read_sources():
     z = ZipFile(os.path.join(test_dir, 'test.zip'), 'w')
     z.writestr(fname, bytes)
     z.close()
-    
+
     has_inet = os.getenv('IMAGEIO_NO_INTERNET', '') not in ('1', 'yes', 'true')
-    
+
     # Read that image from these different sources. Read data from file
     # and from local file (the two main plugin-facing functions)
     for X in range(2):
-        
+
         # Define uris to test. Define inside loop, since we need fresh files
         uris = [filename,
                 os.path.join(test_dir, 'test.zip', fname),
@@ -212,7 +226,7 @@ def test_request_read_sources():
                 open(filename, 'rb')]
         if has_inet:
             uris.append(burl + fname)
-        
+
         for uri in uris:
             R = Request(uri, 'ri')
             first_bytes = R.firstbytes
@@ -229,7 +243,7 @@ def test_request_read_sources():
 
 
 def test_request_save_sources():
-    
+
     # Prepare desinations
     fname = 'images/chelsea.png'
     filename = get_remote_file(fname, test_dir)
@@ -239,7 +253,7 @@ def test_request_save_sources():
     filename2 = os.path.join(test_dir, fname2)
     zipfilename2 = os.path.join(test_dir, 'test.zip')
     file2 = BytesIO()
-    
+
     # Write an image into many different destinations
     # Do once via file and ones via local filename
     for i in range(2):
@@ -248,7 +262,7 @@ def test_request_save_sources():
             if os.path.isfile(xx):
                 os.remove(xx)
         # Write to three destinations
-        for uri in (filename2, 
+        for uri in (filename2,
                     os.path.join(zipfilename2, fname2),
                     file2,
                     imageio.RETURN_BYTES  # This one last to fill `res`
@@ -267,21 +281,21 @@ def test_request_save_sources():
 
 
 def test_request_file_no_seek():
-    
+
     class File:
-        
+
         def read(self, n):
             return b'\x00' * n
-            
+
         def seek(self, i):
             raise IOError('Not supported')
-        
+
         def tell(self):
             raise Exception('Not supported')
-        
+
         def close(self):
             pass
-    
+
     R = Request(File(), 'ri')
     with raises(IOError):
         R.firstbytes
@@ -289,7 +303,7 @@ def test_request_file_no_seek():
 
 def test_util_imagelist():
     meta = {'foo': 3, 'bar': {'spam': 1, 'eggs': 2}}
-    
+
     # Image list
     L = core.util.ImageList(meta)
     assert isinstance(L, list)
@@ -300,7 +314,7 @@ def test_util_imagelist():
 
 def test_util_image():
     meta = {'foo': 3, 'bar': {'spam': 1, 'eggs': 2}}
-    # Image 
+    # Image
     a = np.zeros((10, 10))
     im = core.util.Image(a, meta)
     isinstance(im, np.ndarray)
@@ -377,7 +391,7 @@ def test_util_asarray():
 def test_util_progres_bar(sleep=0):
     """ Test the progress bar """
     # This test can also be run on itself to *see* the result
-    
+
     # Progress bar
     for Progress in (core.StdoutProgressIndicator, core.BaseProgressIndicator):
         B = Progress('test')
@@ -391,7 +405,7 @@ def test_util_progres_bar(sleep=0):
             assert B._progress == i
         B.increase_progress(1)
         assert B._progress == i + 1
-        B.finish()  
+        B.finish()
         assert B.status() == 2
         # Without max
         B.start('Run without max int')
@@ -481,32 +495,32 @@ def test_util_image_as_uint():
 
 
 def test_util_has_has_module():
-    
+
     assert not core.has_module('this_module_does_not_exist')
     assert core.has_module('sys')
 
 
 def test_functions():
     """ Test the user-facing API functions """
-    
+
     # Test help(), it prints stuff, so we just check whether that goes ok
     imageio.help()  # should print overview
     imageio.help('PNG')  # should print about PNG
-    
+
     fname1 = get_remote_file('images/chelsea.png', test_dir)
     fname2 = fname1[:-3] + 'jpg'
     fname3 = fname1[:-3] + 'notavalidext'
     open(fname3, 'wb')
-    
+
     # Test read()
     R1 = imageio.read(fname1)
     R2 = imageio.read(fname1, 'png')
     assert R1.format is R2.format
     # Fail
     raises(ValueError, imageio.read, fname3)  # existing but not readable
-    raises(IOError, imageio.read, 'notexisting.barf')
+    raises(FileNotFoundError, imageio.read, 'notexisting.barf')
     raises(IndexError, imageio.read, fname1, 'notexistingformat')
-    
+
     # Test save()
     W1 = imageio.save(fname2)
     W2 = imageio.save(fname2, 'JPG')
@@ -514,14 +528,15 @@ def test_functions():
     W2.close()
     assert W1.format is W2.format
     # Fail
-    raises(IOError, imageio.save, '~/dirdoesnotexist/wtf.notexistingfile')
-    
+    raises(FileNotFoundError, imageio.save,
+           '~/dirdoesnotexist/wtf.notexistingfile')
+
     # Test imread()
     im1 = imageio.imread(fname1)
     im2 = imageio.imread(fname1, 'png')
     assert im1.shape[2] == 3
     assert np.all(im1 == im2)
-    
+
     # Test imsave()
     if os.path.isfile(fname2):
         os.remove(fname2)
@@ -529,7 +544,7 @@ def test_functions():
     imageio.imsave(fname2, im1[:, :, 0])
     imageio.imsave(fname2, im1)
     assert os.path.isfile(fname2)
-    
+
     # Test mimread()
     fname3 = get_remote_file('images/newtonscradle.gif', test_dir)
     ims = imageio.mimread(fname3)
@@ -540,10 +555,10 @@ def test_functions():
     # Test protection
     with raises(RuntimeError):
         imageio.mimread('imageio:chelsea.png', 'dummy', length=np.inf)
-    
+
     if IS_PYPY:
         return  # no support for npz format :(
-    
+
     # Test mimsave()
     fname5 = fname3[:-4] + '2.npz'
     if os.path.isfile(fname5):
@@ -552,7 +567,7 @@ def test_functions():
     imageio.mimsave(fname5, [im[:, :, 0] for im in ims])
     imageio.mimsave(fname5, ims)
     assert os.path.isfile(fname5)
-    
+
     # Test volread()
     fname4 = get_remote_file('images/stent.npz', test_dir)
     vol = imageio.volread(fname4)
@@ -560,7 +575,7 @@ def test_functions():
     assert vol.shape[0] == 256
     assert vol.shape[1] == 128
     assert vol.shape[2] == 128
-    
+
     # Test volsave()
     volc = np.zeros((10, 10, 10, 3), np.uint8)  # color volume
     fname6 = os.path.join(test_dir, 'images', 'stent2.npz')
@@ -570,13 +585,13 @@ def test_functions():
     imageio.volsave(fname6, volc)
     imageio.volsave(fname6, vol)
     assert os.path.isfile(fname6)
-    
+
     # Test mvolread()
     vols = imageio.mvolread(fname4)
     assert isinstance(vols, list)
     assert len(vols) == 1
     assert vols[0].shape == vol.shape
-    
+
     # Test mvolsave()
     if os.path.isfile(fname6):
         os.remove(fname6)
@@ -584,7 +599,7 @@ def test_functions():
     imageio.mvolsave(fname6, [volc, volc])
     imageio.mvolsave(fname6, vols)
     assert os.path.isfile(fname6)
-    
+
     # Fail for save functions
     raises(ValueError, imageio.imsave, fname2, np.zeros((100, 100, 5)))
     raises(ValueError, imageio.imsave, fname2, 42)
@@ -598,7 +613,7 @@ def test_functions():
 
 def test_example_plugin():
     """ Test the example plugin """
-    
+
     fname = os.path.join(test_dir, 'out.png')
     r = Request('imageio:chelsea.png', 'r?')
     R = imageio.formats['dummy'].get_reader(r)
diff --git a/tests/test_tifffile.py b/tests/test_tifffile.py
index ea7ad7d..a2900df 100644
--- a/tests/test_tifffile.py
+++ b/tests/test_tifffile.py
@@ -65,7 +65,7 @@ def test_tifffile_reading_writing():
 
     # Mixed
     W = imageio.save(filename1)
-    W.set_meta_data({'planarconfig': 'planar'})
+    W.set_meta_data({'planarconfig': 'SEPARATE'})  # was "planar"
     assert W.format.name == 'TIFF'
     W.append_data(im2)
     W.append_data(im2)
@@ -75,8 +75,8 @@ def test_tifffile_reading_writing():
     assert R.format.name == 'TIFF'
     ims = list(R)  # == [im for im in R]
     assert (ims[0] == im2).all()
-    meta = R.get_meta_data()
-    assert meta['orientation'] == 'top_left'
+    # meta = R.get_meta_data()
+    # assert meta['orientation'] == 'top_left'  # not there in later version
     # Fail
     raises(IndexError, R.get_data, -1)
     raises(IndexError, R.get_data, 3)

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-cov pytest-xdist pytest-mock pytest-asyncio
apt-get update apt-get install -y gcc libfreeimage3
3.9
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
coverage==7.8.0
exceptiongroup==1.2.2
execnet==2.1.1
-e git+https://github.com/imageio/imageio.git@b122f914d2b8f6971f958d925b79261fc8df51f8#egg=imageio
iniconfig==2.1.0
numpy==2.0.2
packaging==24.2
pillow==11.1.0
pluggy==1.5.0
pytest==8.3.5
pytest-asyncio==0.26.0
pytest-cov==6.0.0
pytest-mock==3.14.0
pytest-xdist==3.6.1
tomli==2.2.1
typing_extensions==4.13.0

name: imageio
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - execnet==2.1.1
      - iniconfig==2.1.0
      - numpy==2.0.2
      - packaging==24.2
      - pillow==11.1.0
      - pluggy==1.5.0
      - pytest==8.3.5
      - pytest-asyncio==0.26.0
      - pytest-cov==6.0.0
      - pytest-mock==3.14.0
      - pytest-xdist==3.6.1
      - tomli==2.2.1
      - typing-extensions==4.13.0
prefix: /opt/conda/envs/imageio

tests/test_core.py::test_request tests/test_tifffile.py::test_tifffile_reading_writing
tests/test_core.py::test_findlib2 tests/test_core.py::test_util_image tests/test_core.py::test_functions
tests/test_core.py::test_fetching tests/test_core.py::test_request_read_sources tests/test_core.py::test_request_save_sources tests/test_core.py::test_request_file_no_seek tests/test_core.py::test_util_imagelist tests/test_core.py::test_util_dict tests/test_core.py::test_util_get_platform tests/test_core.py::test_util_asarray tests/test_core.py::test_util_progres_bar tests/test_core.py::test_util_image_as_uint tests/test_core.py::test_util_has_has_module tests/test_core.py::test_example_plugin tests/test_tifffile.py::test_tifffile_format
imageio/plugins/tifffile.py imageio/core/functions.py .travis.yml imageio/core/request.py imageio/plugins/_tifffile.py
imageio/plugins/tifffile.py imageio/core/functions.py .travis.yml imageio/core/request.py imageio/plugins/_tifffile.py