# -*- coding: utf-8 -*-
"""
TIF module provides interface to read and write tif image files.
Note
----
This module relies on the tifffile library.
"""
__author__ = 'Christoph Kirst <christoph.kirst.ck@gmail.com>, Charly Rousseau <charly.rousseau@icm-institute.org>'
__license__ = 'GPLv3 - GNU General Public License v3 (see LICENSE.txt)'
__copyright__ = 'Copyright © 2020 by Christoph Kirst'
__webpage__ = 'https://idisco.info'
__download__ = 'https://www.github.com/ChristophKirst/ClearMap2'
import warnings
from functools import cached_property
import numpy as np
from tifffile import tifffile
from ClearMap.IO.Source import Source as AbstractSource
from ClearMap.IO.Source import VirtualSource as AbstractVirtualSource
import ClearMap.IO.Slice as cmp_clicing
from ClearMap.Utils.Lazy import lazyattr
###############################################################################
# ## Source class
###############################################################################
[docs]
class Source(AbstractSource):
"""Class to handle a tif file source
Note
----
Its assumed that the image data is stored in a series of the tif file.
.. warning:: It is also assumed that the last 3 dimensions are the image dimensions in the order z,y,x.
"""
def __init__(self, location, series=0, multi_file=False):
try:
self._tif = tifffile.TiffFile(location, multifile=multi_file)
except TypeError: # TODO: filter with message
self._tif = tifffile.TiffFile(location, _multifile=multi_file) # Versions >= 2020.9.30
# FIXME: to see if read or write mode, check 1) if the file exists, 2) if there is metadata in the file
self._series = series
self.multi_file = multi_file
if not self.series_mode and not self.pages_mode:
raise ValueError(f'Unknown metadata type {self._metadata_type}')
@property
def name(self):
return "Tif-Source"
@lazyattr
def series(self):
return self._tif.series[self._series]
@property
def is_clearmap(self):
return self._tif.is_shaped # Likely written by clearmap if not more metadata
@property
def axes_order(self):
md = self.metadata(info=['shape'])
return md['order']
@cached_property
def series_mode(self):
return (self._tif._multifile or
self._metadata_type == 'imagej_metadata' or
self._metadata_type == 'shaped_metadata' or
self._metadata_type is None)
@cached_property
def pages_mode(self):
return self._metadata_type == 'ome_metadata'
@cached_property
def shape(self):
try:
shape_ = shape_from_tif(self.tif_shape)
except ValueError as err:
print(f'ERROR transposing shape of {self._tif.filename} ({self.tif_shape}) from {self.axes_order} to ZYX order!')
raise err
return shape_
@cached_property
def tif_shape(self):
if self.series_mode:
shape = self._parse_series_shape()
else:
shape = self._parse_pages_shape()
# Remove empty dimensions
shape = tuple(dim for dim in shape if dim != 1)
return shape
def _parse_series_shape(self):
return self.series.shape
def _parse_pages_shape(self):
_shape = self._tif.pages[0].shape # The 2D shape of the image
n_pages = len(self._tif.pages)
if n_pages > 1:
_shape = (n_pages,) + _shape
return _shape
@cached_property
def dtype(self):
if self.series_mode:
return self.series.dtype
else:
return self._tif.pages[0].dtype
@property
def location(self):
return self._tif._fh.path
@location.setter
def location(self, value):
if value != self.location:
self._tif = tifffile.TiffFile(value, multifile=False)
@property
def array(self, processes=None):
array = self._tif.asarray(maxworkers=processes)
return self.to_clearmap_order(array)
@cached_property
def element_strides(self):
"""The strides of the array elements.
Returns
-------
strides : tuple
Strides of the array elements.
Note
----
The strides of the elements module itemsize instead of bytes.
"""
memmap = self.as_memmap()
return tuple(s // memmap.itemsize for s in memmap.strides)
def __getitem__(self, slicing, processes=None):
ndim = self.ndim
if ndim >= 3:
slicing = cmp_clicing.unpack_slicing(slicing, ndim) # matches dimensions for slicing (may assume space at the end)
slicing_z = slicing[-1]
if isinstance(slicing_z, (int, np.int64)):
slicing_z = int(slicing_z)
array = self._tif.asarray(key=slicing_z, maxworkers=processes)
array = self.to_clearmap_order(array)
slicing_xy = (Ellipsis,) + slicing[-3: -1] # Assumes that the last dimensions are space and in the order z,y,x ??
if len(array.shape) > len(self._tif.pages[0].shape): # FIXME: is self._tif.pages[0].shape used for series mode?
slicing_xy = slicing_xy + (slice(None), )
return array[slicing_xy]
else:
array = self._tif.asarray(maxworkers=processes)
array = self.to_clearmap_order(array)
return array[slicing]
def __setitem__(self, *args):
memmap = self.as_memmap()
memmap.__setitem__(*args)
[docs]
def to_clearmap_order(self, array):
try:
transposed = tif_to_clearmap_order(array, self.axes_order)
except ValueError as err:
print(f'ERROR transposing array {self._tif.filename} with shape {array.shape} '
f'from {self.axes_order} to ClearMap (ZYX) order!')
raise err
return transposed
@cached_property
def _metadata_type(self):
populated_metadata = [f'{t}_metadata' for t in self._tif.flags
if getattr(self._tif, f'{t}_metadata', None) is not None]
if not populated_metadata:
return None
elif len(populated_metadata) > 1:
warnings.warn(f'Multiple metadata types found in tif file {self.location}!; metadata: {populated_metadata}')
return populated_metadata[0]
[docs]
def as_memmap(self):
try:
return self.to_clearmap_order(tifffile.memmap(self.location))
except ValueError as err:
raise ValueError(f'The tif file {self.location} cannot be memmaped!; {err}')
[docs]
def as_virtual(self):
return VirtualSource(source=self)
[docs]
def as_real(self):
return self
[docs]
def as_buffer(self):
return self.as_memmap()
# #### Formatting ###
def __str__(self):
try:
name = self.name or ''
shape = repr((self.shape,)) if self.shape else ''
dtype = f'[{self.dtype}]' if self.dtype else ''
order = f'|{self.order}|' if self.order else ''
location = f'{self.location}' if self.location else ''
location = location if len(location) <= 100 else location[:50] + '...' + location[-50:]
except TypeError:
name = shape = dtype = order = location = ''
return f'{name}{shape}{dtype}{order}{location}'
[docs]
class VirtualSource(AbstractVirtualSource):
def __init__(self, source=None, shape=None, dtype=None, order=None, location=None, name=None):
super(VirtualSource, self).__init__(source=source, shape=shape, dtype=dtype, order=order, location=location, name=name)
if isinstance(source, Source):
self.multi_file = source.multi_file
self.series = source._series
@property
def name(self):
return 'Virtual-Tif-Source'
[docs]
def as_virtual(self):
return self
[docs]
def as_real(self):
return Source(location=self.location, series=self.series, multi_file=self.multi_file)
[docs]
def as_buffer(self):
return self.as_real().as_buffer()
###############################################################################
# ## TIF Parsers
###############################################################################
###############################################################################
# ## IO functionality
###############################################################################
[docs]
def is_tif(source):
"""Checks if this is a TIF source"""
if isinstance(source, Source):
return True
if isinstance(source, str):
try:
Source(source)
except tifffile.TiffFileError: # Do not catch missing file or permission errors
return False
return True
return False
[docs]
def read(source, slicing=None, sink=None, **args):
"""Read data from a tif file.
Arguments
---------
source : str or TIF class
The name of the tif file or a TIF class.
slicing : slice, Slice or None
An optional sub-slice to consider.
Returns
-------
data : array
The image data in the tif file.
"""
if not isinstance(source, Source):
source = Source(source)
if slicing is None:
return source.array
else:
return source[slicing]
[docs]
def write(sink, data, **args):
"""Write data to a tif file
Arguments
---------
sink : str
The name of the tif file.
Returns
-------
sink : str
The name of the tif file.
"""
try:
data = array_to_tif(data)
except ValueError as err:
raise ValueError(f'Cannot write array to tif file {sink}!; {err}')
# TODO: add axes order 'XYZ(C)' to metadata
try:
tifffile.imsave(sink, data, **args) # noqa
except AttributeError:
tifffile.imwrite(sink, data, **args)
return sink
[docs]
def create(location=None, shape=None, dtype=None, mode=None, as_source=True, **kwargs):
"""Create a memory map.
Arguments
---------
location : str
The filename of the memory mapped array.
shape : tuple or None
The shape of the memory map to create.
dtype : dtype
The data type of the memory map.
mode : 'r', 'w', 'w+', None
The mode to open the memory map.
as_source : bool
If True, return as Source class.
Returns
-------
memmap : np.memmap
The memory map.
Note
----
By default memmaps are initialized as fortran contiguous if order is None.
"""
if shape is None:
raise ValueError('Shape for new tif file must be given!')
try:
shape = shape_to_tif(shape)
except ValueError as err:
print(f'ERROR: could not transpose shape of {location} ({shape}) to ZYX order!; {err}')
raise err
mode = 'r+' if mode == 'w+' or mode is None else mode
dtype = 'float64' if dtype is None else dtype
memmap = tifffile.memmap(location, shape=shape, dtype=dtype, mode=mode)
if as_source:
return Source(location)
else:
return memmap
################################################################################
# ### Array axes order
################################################################################
[docs]
def map_axes(source_order, dest_order, ndim=None):
"""
Generate a mapping of axes from source order to destination order.
The mapping is a tuple of integers that can be used with np.transpose to reorder the axes of an array.
Parameters
----------
source_order : str
The order of the axes in the source array (e.g., 'ZYX').
dest_order : str
The desired order of axes in the output array (e.g., 'XYZ').
Returns
-------
tuple
A tuple of integers that can be used with np.transpose to reorder the axes of an array.
"""
if not ndim:
ndim = len(source_order)
if isinstance(source_order, (tuple, list)):
source_order = ''.join(source_order)
if isinstance(dest_order, (tuple, list)):
dest_order = ''.join(dest_order)
source_order = source_order.upper()
dest_order = dest_order.upper()
if 'C' in source_order and 'C' not in dest_order:
warnings.warn('Color channel is present in source order but not explicitly in destination order, '
'assuming it should be moved to the end of the array.')
dest_order += 'C'
elif 'C' in dest_order and 'C' not in source_order:
raise ValueError('Cannot add color channel to array without color channel.')
# if order were given with too much information, discard by least significant
for dim in 'TCZ':
if ((ndim < len(source_order) and dim in source_order) or
(ndim < len(dest_order) and dim in dest_order)):
source_order = source_order.replace(dim, '')
dest_order = dest_order.replace(dim, '')
if len(source_order) != len(dest_order):
raise ValueError(f'Source and destination order must have the same number of axes.'
f' Source order: {source_order}, Destination order: {dest_order}')
return tuple(source_order.index(axis) for axis in dest_order)
[docs]
def transpose_array(array, source_order, dest_order):
"""
Transpose the axes on array from source_order to dest_order.
Parameters
----------
array : np.ndarray
The array to transpose
source_order : str
The order of the axes in the array (with letters representing the axes)
dest_order : str
The desired order of axes in the output array (with letters representing the axes)
Returns
-------
The transposed array
"""
transposition = map_axes(source_order, dest_order, ndim=array.ndim)
return np.transpose(array, transposition)
[docs]
def tif_to_clearmap_order(array, array_order='XYZ'):
"""
Transpose the axes on array to the ClearMap order ('ZYX')
Parameters
----------
array : np.ndarray
The array to transpose
array_order : str
The order of the input array (with letters representing the axes)
Returns
-------
The transposed array
"""
return transpose_array(array, array_order, 'ZYX')
[docs]
def reorder_shape(shape, order, dest_order):
"""
Rearrange the shape tuple to match the given order.
Parameters
----------
shape : tuple
The original shape of the array
order : str
The order of the axes in the array (e.g., 'ZYX' or 'CZYX')
dest_order : str
The desired order of axes in the output array (e.g., 'XYZ' or 'XYZC')
Returns
-------
tuple
The shape rearranged to match the given order
"""
axes_mapping = map_axes(order, dest_order, ndim=len(shape))
rearranged_shape = tuple(shape[ax] for ax in axes_mapping)
return rearranged_shape
[docs]
def shape_from_tif(shape, order='XYZ', dest_order='ZYX'): # FIXME: extract CLEARMAP_ORDER and TIF_ORDER to constants
"""
Rearrange the shape tuple to match the 'XYZ' (or 'XYZC') order based on the given axes order.
Parameters
----------
shape : tuple
The original shape of the array.
order : str
The order of the axes in the array (e.g., 'ZYX' or 'CZYX'). Default is 'XYZ' (Default tiff order).
dest_order : str
The desired order of axes in the output array (e.g., 'XYZ' or 'XYZC'). Default is 'ZYX' (ClearMap array order).
Returns
-------
tuple
The shape rearranged to match the 'XYZ' (or 'XYZC') order.
"""
return reorder_shape(shape, order, dest_order)
[docs]
def shape_to_tif(shape, order='ZYX', dest_order='XYZ'):
"""
Rearrange the shape tuple to match the 'XYZ' (or 'XYZC') order based on the given axes order.
Parameters
----------
shape : tuple
The original shape of the array.
order : str
The order of the axes in the array (e.g., 'ZYX' or 'CZYX'). Default is 'ZYX' (ClearMap array order).
dest_order : str
The desired order of axes in the output array (e.g., 'XYZ' or 'XYZC'). Default is 'XYZ' (Default tiff order).
Returns
-------
tuple
The shape rearranged to match the 'XYZ' (or 'XYZC') order.
"""
return reorder_shape(shape, order, dest_order)
#
# def shape_from_tif(shape):
# ndim = len(shape)
# shape = shape[:max(0, ndim-3)] + shape[-3:][::-1] # FIXME: rewrite for color and use in array_from_tiff too
# return shape
#
#
# def array_from_tif(array): # to ZYXC orientation
# ndim = array.ndim
# n_colors = min(array.shape) # valid if rgb
# if ndim == 4 and n_colors in (3, 4): # Put color last for RGB images
# col_idx = array.shape.index(n_colors)
#
# new_axes = list(range(ndim))
# new_axes.pop(col_idx)
# new_axes = new_axes[::-1]
# new_axes.append(col_idx)
# array = np.transpose(array, new_axes)
# else:
# axes = list(range(ndim))
# axes = axes[:max(0, ndim-3)] + axes[-3:][::-1]
# array = array.transpose(axes)
# return array
# def array_from_tif(array): # to ZYXC orientation
# return tif_to_clearmap_order(array)
[docs]
def array_to_tif(array, source_order='ZYX'):
return transpose_array(array, source_order, 'XYZ')
################################################################################
# ### Tests
################################################################################
def _test():
import ClearMap.Tests.Files as tfs
import ClearMap.IO.TIF as tif
filename = tfs.filename('tif_2d')
t = tif.Source(location=filename)
print(t)
filename = tfs.filename('tif_2d_color')
t = tif.Source(location=filename)
print(t)
d = tif.read(filename)
print(d.shape)
v = t.as_virtual()
print(v)
q = v.as_real()
print(q)