#
# imagetexture.py - The ImageTexture and ImageTexture2D class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`ImageTexture` and :class:`.ImageTexture2D`
classes, :class:`.Texture3D` and :class:`.Texture2D` classes for storing an
:class:`.Image` instance.
"""
import logging
import contextlib
import collections.abc as abc
import numpy as np
import fsl.transform.affine as affine
import fsl.data.imagewrapper as imagewrapper
import fsleyes_widgets as fwidgets
from . import data as texdata
from . import texture2d
from . import texture3d
log = logging.getLogger(__name__)
[docs]def createImageTexture(name, image, *args, **kwargs):
"""Creates and returns an appropriate texture type (either
:class:`ImageTexture` or :class:`ImageTexture2D`) for the given image.
"""
ndims = texdata.numTextureDims(image.shape[:3])
if ndims == 3: return ImageTexture( name, image, *args, **kwargs)
else: return ImageTexture2D(name, image, *args, **kwargs)
[docs]class ImageTextureBase:
"""Base class shared by the :class:`ImageTexture` and
:class:`ImageTexture2D` classes. Contains logic for retrieving a
specific volume from a 3D + time or 2D + time :class:`.Image`, and
for retrieving a specific channel from an RGB(A) ``Image``.
"""
[docs] @staticmethod
def validateShape(image, texnvals, texndims):
"""Called by :meth:`__init__`. Makes sure that the specified texture
settings (number of dimensions, and number of values per texture
element) are compatible with the image.
:arg image: :class:`.Image`
:arg texnvals: Number of values per texture element
:arg texndims: Number of texture dimensions
:raises: :exc:`RuntimeError` if the texture properties are not
compatible with the image
"""
imgnvals = image.nvals
imgndims = len(image.shape)
# Anything goes for single-
# valued textures
if texnvals == 1:
return
# For multi-valued 4D textures,
# the image must have a shape
# of the form:
# (x, y, z, [1, [1, [1, [1, ]]]] nvals)
if imgnvals == 1:
expShape = list(image.shape[:3])
expShape += [1] * (imgndims - 3)
expShape[-1] = texnvals
if list(image.shape) != expShape:
raise RuntimeError(
'Data shape mismatch: texture size {} requested for '
'image shape {}'.format(texnvals, image.shape))
# Or must be a structured, i.e.
# RGB(A) array with the correct
# number of values per voxel.
elif imgnvals != texnvals:
raise RuntimeError(
'Data shape mismatch: texture size {} requested for '
'image with nvals {}'.format(texnvals, imgnvals))
[docs] def __init__(self, image, nvals, ndims):
"""Create an ``ImageTextureBase``
:arg image: The :class:`.Image`
:arg nvals: Number of values per texture element
:arg ndims: Number of texture dimensions
"""
self.validateShape(image, nvals, ndims)
self.__name = 'ImageTextureBase_{}'.format(id(self))
self.__image = image
self.__volume = None
self.__channel = None
self.__image.register(self.__name,
self.__imageDataChanged,
'data',
runOnIdle=True)
[docs] def destroy(self):
"""Must be called when this ``ImageTextureBase`` is no longer needed.
"""
self.__image.deregister(self.__name, 'data')
self.__image = None
@property
def image(self):
"""Returns the :class:`.Image` managed by this ``ImageTextureBase``.
"""
return self.__image
@property
def volume(self):
"""For :class:`.Image` instances with more than three dimensions,
specifies the indices for the fourth and above dimensions with which
to extract the 3D texture data. If the image has four dimensions, this
may be a scalar, otherwise it must be a sequence of
(``Image.ndim - 3``) the correct length.
"""
return self.__volume
@volume.setter
def volume(self, volume):
"""Set the current volume. """
self.set(volume=volume)
@property
def channel(self):
"""For :class:`.Image` instances with multiple values per voxel, such
as ``RGB24`` or ``RGBA32`` images, this option allows the channel
to be selected.
"""
return self.__channel
@channel.setter
def channel(self, channel):
"""Set the current channel. """
self.set(channel=channel)
[docs] def prepareSetArgs(self, **kwargs):
"""Called by sub-classes in their :meth:`.Texture2D.set`/
:meth:`.Texture3D.set` override.
Prepares arguments to be passed through to the underlying
``set`` method. This method accepts any parameters that are accepted
by :meth:`.Texture3D.set`, plus the following:
=============== ======================================================
``volume`` See :meth:`volume`.
``channel`` See :meth:`channel`.
``volRefresh`` If ``True`` (the default), the texture data will be
refreshed even if the ``volume`` and ``channel``
parameters haven't changed. Otherwise, if ``volume``
and ``channel`` haven't changed, the texture will not
be refreshed.
=============== ======================================================
:returns: ``True`` if any settings have changed and the
``ImageTexture`` is to be refreshed , ``False`` otherwise.
"""
kwargs .pop('data', None)
normRange = kwargs.pop('normaliseRange', None)
channel = kwargs.pop('channel', self.__channel)
volume = kwargs.pop('volume', self.__volume)
volRefresh = kwargs.pop('volRefresh', True)
image = self.image
nvals = self.nvals
ndims = image.ndim
if len(image.shape) == 3: volume = None
if image.nvals == 1: channel = None
if normRange is None:
normRange = image.dataRange
if ndims == 3 or nvals > 1:
volume = None
else:
if volume is None and self.__volume is None:
volume = [0] * (ndims - 3)
elif not isinstance(volume, abc.Sequence):
volume = [volume]
if len(volume) != ndims - 3:
raise ValueError('Invalid volume indices for {} '
'dims: {}'.format(ndims, volume))
if (not volRefresh) and \
(volume == self.__volume) and \
(channel == self.__channel):
return kwargs
self.__volume = volume
self.__channel = channel
data = self.__getData(volume, channel)
data = self.shapeData(data)
kwargs['data'] = data
kwargs['normaliseRange'] = normRange
return kwargs
def __getData(self, volume, channel):
"""Extracts data from the :class:`.Image` for use as texture data.
For textures with multiple values per element (either by volume, or by
channel), the data is arranged appropriately, i.e. with the value as
the first dimension.
:arg volume: Volume index/indices, for images with more than three
dimensions.
:arg channel: Channel, for RGB(A) images.
"""
image = self.image
slc = [slice(None), slice(None), slice(None)]
if volume is not None:
slc += volume
if channel is None: data = self.image[ tuple(slc)]
else: data = self.image.data[channel][tuple(slc)]
# For single-valued textures,
# we don't need to do anything
if self.nvals == 1:
return data
# Multi-valued texture with a
# single-valued 4D image - we
# assume that each volume
# corresponds to a channel
elif image.nvals == 1 and volume is None:
data = data.transpose((3, 0, 1, 2))
# Multi-valued RGB(A) texture
# with a multi-valued image -
# cast/reshape the image data
elif image.nvals > 1 and channel is None:
data = np.ndarray(buffer=data.data,
shape=[self.nvals] + list(data.shape),
order='F',
dtype=np.uint8)
return data
def __imageDataChanged(self, image, topic, sliceobj):
"""Called when the :class:`.Image` notifies about a data changes.
Triggers an image texture refresh via a call to :meth:`set`.
:arg image: The ``Image`` instance
:arg topic: The string ``'data'``
:arg sliceobj: Slice object specifying the portion of the image
that was changed.
"""
# TODO If the change has caused the image
# data range to change, and texture
# data normalisation is on, you have
# to refresh the full texture.
#
# The Image instance does follow up
# data change notifications with a
# data range notification; perhaps
# you can use this somehow.
# If the data change was performed using
# normal array indexing, we can just replace
# that part of the image texture.
if isinstance(sliceobj, tuple):
# Get the new data, and calculate an
# offset into the full image from the
# slice object.
data = np.array(image[sliceobj])
offset = imagewrapper.sliceObjToSliceTuple(sliceobj, image.shape)
offset = [o[0] for o in offset]
# Make sure the data/offset are
# compatible with 2D textures
data = self.shapeData(data, oldShape=image.shape)
offset[:3] = affine.transform(
offset[:3], self.texCoordXform(image.shape))
log.debug('%s data changed - refreshing part of '
'texture (offset: %s, size: %s)',
image.name, offset, data.shape)
self.patchData(data, offset)
# Otherwise (boolean array indexing) we have
# to replace the whole image texture.
else:
log.debug('%s data changed - refreshing '
'full texture', image.name)
self.set()
[docs]class ImageTexture(ImageTextureBase, texture3d.Texture3D):
"""The ``ImageTexture`` class contains the logic required to create and
manage a 3D texture which represents a :class:`.Image` instance.
Once created, the :class:`.Image` instance is available as an attribute of
an ``ImageTexture`` object, called ``image``. See the :class:`.Texture3D`
documentation for more details.
For multi-valued (e.g. RGB) textures, the :class:`.Texture3D` class
requires data to be passed as a ``(C, X, Y, Z)`` array (for ``C`` values).
If an ``ImageTexture`` is created with an image of type
``NIFTI_TYPE_RGB24`` or ``NIFTI_TYPE_RGBA32``, it will take care of
re-arranging the image data so that it has the shape required by the
``Texture3D`` class.
"""
threadedDefault = None
"""Default value used for the ``threaded`` argument passed to
:meth:`__init__`. When this is set to ``None``, the default value will be
the value of :func:`.fsleyes_widgets.haveGui`.
"""
[docs] @classmethod
@contextlib.contextmanager
def enableThreading(cls, enable=True):
"""Context manager which can be used to temporarily set the
default value of the ``threaded`` argument passedto :meth:`__init__`.
"""
oldval = ImageTexture.threadedDefault
ImageTexture.threadedDefault = enable
try:
yield
finally:
ImageTexture.threadedDefault = oldval
[docs] def __init__(self,
name,
image,
**kwargs):
"""Create an ``ImageTexture``. A listener is added to the
:attr:`.Image.data` property, so that the texture data can be
refreshed whenever the image data changes - see the
:meth:`__imageDataChanged` method.
:arg name: A name for this ``imageTexure``.
:arg image: The :class:`.Image` instance.
:arg volume: Initial volume index/indices, for >3D images.
All other arguments are passed through to the
:meth:`.Texture3D.__init__` method, and thus used as initial texture
settings.
.. note:: The default value of the ``threaded`` parameter is set to
the value of :attr:`threadedDefault`.
"""
nvals = kwargs.get('nvals', 1)
kwargs['nvals'] = nvals
kwargs['scales'] = image.pixdim[:3]
kwargs['border'] = [0, 0, 0, 0]
kwargs['threaded'] = kwargs.get('threaded',
ImageTexture.threadedDefault)
if kwargs['threaded'] is None:
kwargs['threaded'] = fwidgets.haveGui()
ImageTextureBase .__init__(self, image, nvals, 3)
texture3d.Texture3D.__init__(self, name, **kwargs)
[docs] def destroy(self):
"""Must be called when this ``ImageTexture`` is no longer needed."""
texture3d.Texture3D.destroy(self)
ImageTextureBase .destroy(self)
[docs] def set(self, **kwargs):
"""Overrides :meth:`.Texture3D.set`. Passes all arguments through the
:meth:`prepareSetArgs` method, then passes them on to
:meth:`.Texture3D.set`.
:returns: ``True`` if any settings have changed and the
``ImageTexture`` is to be refreshed , ``False`` otherwise.
"""
return texture3d.Texture3D.set(self, **self.prepareSetArgs(**kwargs))
[docs]class ImageTexture2D(ImageTextureBase, texture2d.Texture2D):
"""The ``ImageTexture2D`` class is the 2D analogue of the
:class:`ImageTexture` class, for managing a 2D texture which represents an
:class:`.Image` instance.
"""
[docs] def __init__(self,
name,
image,
**kwargs):
"""Create an ``ImageTexture2D``. """
nvals = kwargs.get('nvals', 1)
kwargs['nvals'] = nvals
kwargs['border'] = [0, 0, 0, 0]
kwargs['scales'] = image.pixdim[:3]
ImageTextureBase .__init__(self, image, nvals, 2)
texture2d.Texture2D.__init__(self, name, **kwargs)
[docs] def destroy(self):
"""Must be called when this ``ImageTexture2D`` is no longer needed. """
texture2d.Texture2D.destroy(self)
ImageTextureBase .destroy(self)
[docs] def set(self, **kwargs):
"""Overrides :meth:`.Texture2D.set`. Passes all arguments through the
:meth:`prepareSetArgs` method, then passes them on to
:meth:`.Texture2D.set`.
:returns: ``True`` if any settings have changed and the
``ImageTexture`` is to be refreshed , ``False`` otherwise.
"""
return texture2d.Texture2D.set(self, **self.prepareSetArgs(**kwargs))