class Thing(props.SyncableHasProperties):
        crange = props.Bounds(ndims=1, clamped=False)
        drange = props.Bounds(ndims=1, clamped=False)
        linklo = props.Boolean(default=True)

        def __init__(self, *args, **kwargs):
            props.SyncableHasProperties.__init__(self, *args, **kwargs)
            parent = self.getParent()

            if parent is not None:
                self.addListener('linklo', str(id(self)), self.__linkloChanged)
                if self.linklo: self.__linkloChanged()

        def __linkloChanged(self, *a):
            self.__updateLink(self.linklo, 0)

        def __updateLink(self, val, idx):

            drangePV = self.drange.getPropertyValueList()[idx]
            crangePV = self.crange.getPropertyValueList()[idx]

            if props.propValsAreBound(drangePV, crangePV) == val:
                return

            props.bindPropVals(drangePV,
                               crangePV,
                               bindval=True,
                               bindatt=False,
                               unbind=not val)

            if val:
                crangePV.set(drangePV.get())
    class Thing(props.HasProperties):

        myobject     = props.Object()
        mybool       = props.Boolean()
        myint        = props.Int()
        myreal       = props.Real()
        mypercentage = props.Percentage()
        mystring     = props.String()
        mychoice     = props.Choice(('1', '2', '3', '4', '5'))
        myfilepath   = props.FilePath()
        mylist       = props.List()
        mycolour     = props.Colour()
        mycolourmap  = props.ColourMap()
        mybounds     = props.Bounds(ndims=2)
        mypoint      = props.Point(ndims=2)
        myarray      = props.Array()
Esempio n. 3
0
class LightBoxCanvasOpts(SliceCanvasOpts):
    """The ``LightBoxCanvasOpts`` class defines the display settings
    available on :class:`.LightBoxCanvas` instances.
    """


    sliceSpacing = props.Real(clamped=True,
                              minval=0.1,
                              maxval=30.0,
                              default=1.0)
    """This property controls the spacing between slices in the display
    coordinate system.
    """


    ncols = props.Int(clamped=True, minval=1, maxval=100, default=5)
    """This property controls the number of slices to be displayed on a
    single row.
    """


    nrows = props.Int(clamped=True, minval=1, maxval=100, default=4)
    """This property controls the number of rows to be displayed on the
    canvas.
    """


    topRow = props.Int(clamped=True, minval=0, maxval=20, default=0)
    """This property controls the (0-indexed) row to be displayed at the top
    of the canvas, thus providing the ability to scroll through the slices.
    """


    zrange = props.Bounds(ndims=1)
    """This property controls the range, in display coordinates, of the slices
    to be displayed.
    """


    showGridLines = props.Boolean(default=False)
    """If ``True``, grid lines are drawn between the displayed slices. """


    highlightSlice = props.Boolean(default=False)
    """If ``True``, a box will be drawn around the slice containing the current
Esempio n. 4
0
class VectorOpts(niftiopts.NiftiOpts):
    """The ``VectorOpts`` class is the base class for :class:`LineVectorOpts`,
    :class:`RGBVectorOpts`, :class:`.TensorOpts`, and :class:`.SHOpts`. It
    contains display settings which are common to each of them.


    *A note on orientation*


    The :attr:`orientFlip` property allows you to flip the left-right
    orientation of line vectors, tensors, and SH functions. This option is
    necessary, because different tools may output vector data in different
    ways, depending on the image orientation.


    For images which are stored radiologically (with the X axis increasaing
    from right to left), the FSL tools (e.g. `dtifit`) will generate vectors
    which are oriented according to the voxel coordinate system. However, for
    neurologically stored images (X axis increasing from left to right), FSL
    tools generate vectors which are radiologically oriented, and thus are
    inverted with respect to the X axis in the voxel coordinate system.
    Therefore, in order to correctly display vectors from such an image, we
    must flip each vector about the X axis.


    This issue is also applicable to ``tensor`` and ``sh`` overlays.
    """


    xColour = props.Colour(default=(1.0, 0.0, 0.0))
    """Colour used to represent the X vector magnitude."""


    yColour = props.Colour(default=(0.0, 1.0, 0.0))
    """Colour used to represent the Y vector magnitude."""


    zColour = props.Colour(default=(0.0, 0.0, 1.0))
    """Colour used to represent the Z vector magnitude."""


    suppressX = props.Boolean(default=False)
    """Do not use the X vector magnitude to colour vectors."""


    suppressY = props.Boolean(default=False)
    """Do not use the Y vector magnitude to colour vectors."""


    suppressZ = props.Boolean(default=False)
    """Do not use the Z vector magnitude to colour vectors."""


    suppressMode = props.Choice(('white', 'black', 'transparent'))
    """How vector direction colours should be suppressed. """


    orientFlip = props.Boolean(default=True)
    """If ``True``, individual vectors are flipped along the x-axis. This
    property is only applicable to the :class:`.LineVectorOpts`,
    :class:`.TensorOpts`, and :class:`.SHOpts` classes. See the
    :meth:`.NiftiOpts.getTransform` method for more information.

    This value defaults to ``True`` for images which have a neurological
    storage order, and ``False`` for radiological images.
    """


    cmap = props.ColourMap()
    """If an image is selected as the :attr:`colourImage`, this colour map
    is used to colour the vector voxels.
    """


    colourImage = props.Choice()
    """Colour vector voxels by the values contained in this image. Any image which
    is in the :class:`.OverlayList`, and which has the same voxel dimensions as
    the vector image can be selected for modulation. If a ``colourImage`` is
    selected, the :attr:`xColour`, :attr:`yColour`, :attr:`zColour`,
    :attr:`suppressX`, :attr:`suppressY`, and :attr:`suppressZ` properties are
    all ignored.
    """


    modulateImage  = props.Choice()
    """Modulate the vector colour brightness by another image. Any image which
    is in the :class:`.OverlayList`, and which has the same voxel dimensions as
    the vector image can be selected for modulation.
    """


    clipImage = props.Choice()
    """Clip voxels from the vector image according to another image. Any image
    which is in the :class:`.OverlayList`, and which has the same voxel
    dimensions as the vector image can be selected for clipping. The
    :attr:`clippingRange` dictates the value below which vector voxels are
    clipped.
    """


    clippingRange = props.Bounds(ndims=1)
    """Hide voxels for which the :attr:`clipImage` value is outside of this
    range.
    """


    modulateRange = props.Bounds(ndims=1)
    """Data range used in brightness modulation, when a :attr:`modulateImage`
    is in use.
    """


    def __init__(self, image, *args, **kwargs):
        """Create a ``VectorOpts`` instance for the given image.  All
        arguments are passed through to the :class:`.NiftiOpts`
        constructor.
        """

        # The orientFlip property defaults to True
        # for neurologically stored images. We
        # give it this vale before calling __init__,
        # because  if this VectorOptse instance has
        # a parent, we want to inherit the parent's
        # value.
        self.orientFlip = image.isNeurological()

        niftiopts.NiftiOpts.__init__(self, image, *args, **kwargs)

        self.__registered = self.getParent() is not None

        if self.__registered:

            self.overlayList.addListener('overlays',
                                         self.name,
                                         self.__overlayListChanged)
            self            .addListener('clipImage',
                                         self.name,
                                         self.__clipImageChanged)
            self            .addListener('modulateImage',
                                         self.name,
                                         self.__modulateImageChanged)

            if not self.isSyncedToParent('modulateImage'):
                self.__refreshAuxImage('modulateImage')
            if not self.isSyncedToParent('clipImage'):
                self.__refreshAuxImage('clipImage')
            if not self.isSyncedToParent('colourImage'):
                self.__refreshAuxImage('colourImage')

        else:
            self.__overlayListChanged()
            self.__clipImageChanged()
            self.__modulateImageChanged()


    def destroy(self):
        """Removes some property listeners, and calls the
        :meth:`.NiftiOpts.destroy` method.
        """
        if self.__registered:
            self.overlayList.removeListener('overlays',      self.name)
            self            .removeListener('clipImage',     self.name)
            self            .removeListener('modulateImage', self.name)

        niftiopts.NiftiOpts.destroy(self)


    def __clipImageChanged(self, *a):
        """Called when the :attr:`clipImage` property changes. Updates
        the range of the :attr:`clippingRange` property.
        """

        image = self.clipImage

        if image is None:
            self.clippingRange.xmin = 0
            self.clippingRange.xmax = 1
            self.clippingRange.x    = [0, 1]
            return

        minval, maxval = image.dataRange

        # Clipping works with <= and >=, so
        # we add an offset allowing the user
        # to configure the overlay such that
        # no voxels are clipped.
        distance = (maxval - minval) / 100.0

        self.clippingRange.xmin =  minval - distance
        self.clippingRange.xmax =  maxval + distance
        self.clippingRange.x    = [minval, maxval + distance]


    def __modulateImageChanged(self, *a):
        """Called when the :attr:`modulateImage` property changes. Updates
        the range of the :attr:`modulateRange` property.
        """

        image = self.modulateImage

        if image is None: minval, maxval = 0, 1
        else:             minval, maxval = image.dataRange

        self.modulateRange.xmin = minval
        self.modulateRange.xmax = maxval
        self.modulateRange.x    = [minval, maxval]


    def __overlayListChanged(self, *a):
        """Called when the overlay list changes. Updates the :attr:`modulateImage`,
        :attr:`colourImage` and :attr:`clipImage` properties so that they
        contain a list of overlays which could be used to modulate the vector
        image.
        """

        overlays = self.displayCtx.getOrderedOverlays()

        # the image for this VectorOpts
        # instance has been removed
        if self.overlay not in overlays:
            return

        self.__refreshAuxImage('modulateImage')
        self.__refreshAuxImage('clipImage')
        self.__refreshAuxImage('colourImage')


    def __refreshAuxImage(self, imageName):
        """Updates the named image property (:attr:`modulateImage`,
        :attr:`colourImage` or :attr:`clipImage`) so that it contains a list
        of overlays which could be used to modulate the vector image.
        """

        prop     = self.getProp(imageName)
        val      = getattr(self, imageName)
        overlays = self.displayCtx.getOrderedOverlays()

        options = [None]

        for overlay in overlays:

            # It doesn't make sense to
            # modulate/clip/colour the
            # image by itself.
            if overlay is self.overlay:
                continue

            # The modulate/clip/colour
            # images must be images.
            if not isinstance(overlay, fslimage.Image):
                continue

            options.append(overlay)

        prop.setChoices(options, instance=self)

        if val in options: setattr(self, imageName, val)
        else:              setattr(self, imageName, None)
Esempio n. 5
0
class DisplayOpts(props.SyncableHasProperties, actions.ActionProvider):
    """The ``DisplayOpts`` class contains overlay type specific display
    settings. ``DisplayOpts`` instances are managed by :class:`Display`
    instances.


    The ``DisplayOpts`` class is not meant to be created directly - it is a
    base class for type specific implementations (e.g. the :class:`.VolumeOpts`
    class).


    The following attributes are available on all ``DisplayOpts`` instances:


    =============== ======================================================
    ``overlay``     The overlay object
    ``display``     The :class:`Display` instance that created this
                    ``DisplayOpts`` instance.
    ``overlayType`` The value of the :attr:`Display.overlayType` property
                    corresponding to the type of this ``DisplayOpts``
                    instance.
    ``overlayList`` The :class:`.OverlayList` instance, which contains all
                    overlays.
    ``displayCtx``  The :class:`.DisplayContext` instance which is
                    responsible for all ``Display`` and ``DisplayOpts``
                    instances.
    ``name``        A unique name for this ``DisplayOpts`` instance.
    =============== ======================================================
    """


    bounds = props.Bounds(ndims=3)
    """Specifies a bounding box in the display coordinate system which is big
    enough to contain the overlay described by this ``DisplayOpts``
    instance.

    The values in this ``bounds`` property must be updated by ``DisplayOpts``
    subclasses whenever the spatial representation of their overlay changes.
    """


    def __init__(
            self,
            overlay,
            display,
            overlayList,
            displayCtx,
            **kwargs):
        """Create a ``DisplayOpts`` object.

        :arg overlay:     The overlay associated with this ``DisplayOpts``
                          instance.

        :arg display:     The :class:`Display` instance which owns this
                          ``DisplayOpts`` instance.

        :arg overlayList: The :class:`.OverlayList` which contains all
                          overlays.

        :arg displayCtx:  A :class:`.DisplayContext` instance describing
                          how the overlays are to be displayed.
        """

        self.__overlay     = overlay
        self.__display     = display
        self.__overlayList = overlayList
        self.__displayCtx  = displayCtx
        self.__overlayType = display.overlayType
        self.__name        = '{}_{}'.format(type(self).__name__, id(self))

        props.SyncableHasProperties.__init__(self, **kwargs)
        actions.ActionProvider     .__init__(self)

        log.debug('{}.init [DC: {}] ({})'.format(
            type(self).__name__, id(displayCtx), id(self)))


    def __del__(self):
        """Prints a log message."""
        if log:
            log.debug('{}.del ({})'.format(type(self).__name__, id(self)))


    def destroy(self):
        """This method must be called when this ``DisplayOpts`` instance
        is no longer needed.

        If a subclass overrides this method, the subclass implementation
        must call this method, **after** performing its own clean up.
        """
        actions.ActionProvider.destroy(self)

        self.detachAllFromParent()

        self.__overlay     = None
        self.__display     = None
        self.__overlayList = None
        self.__displayCtx  = None


    @classmethod
    def getVolumeProps(cls):
        """Intended to be overridden by sub-classes as needed.  Returns a list
        of property names which control the currently displayed
        volume/timepoint for 4D overlays. The default implementation returns
        an empty list.
        """
        return []


    @property
    def overlay(self):
        """Return the overlay associated with this ``DisplayOpts`` object.
        """
        return self.__overlay


    @property
    def display(self):
        """Return the :class:`.Display` that is managing this
        ``DisplayOpts`` object.
        """
        return self.__display


    @property
    def overlayList(self):
        """Return the :class:`.OverlayList` that contains all overlays.
        """
        return self.__overlayList


    @property
    def displayCtx(self):
        """Return the :class:`.DisplayContext` that is managing this
        ``DisplayOpts`` object.
        """
        return self.__displayCtx


    @property
    def overlayType(self):
        """Return the type of this ``DisplayOpts`` object (the value of
        :attr:`Display.overlayType`).
        """
        return self.__overlayType


    @property
    def name(self):
        """Return the name of this ``DisplayOpts`` object. """
        return self.__name


    @property
    def referenceImage(self):
        """Return the reference image associated with this ``DisplayOpts``
        instance.

        Some non-volumetric overlay types (e.g. the :class:`.Mesh` -
        see :class:`.MeshOpts`) may have a *reference* :class:`.Nifti` instance
        associated with them, allowing the overlay to be localised in the
        coordinate space defined by the :class:`.Nifti`. The
        :class:`.DisplayOpts` sub-class which corresponds to
        such non-volumetric overlays should override this method to return
        that reference image.

        :class:`.DisplayOpts` sub-classes which are associated with volumetric
        overlays (i.e. :class:`.Nifti` instances) do not need to override
        this method - in this case, the overlay itself is considered to be
        its own reference image, and is returned by the base-class
        implementation of this method.

        .. note:: The reference :class:`.Nifti` instance returned by
                  sub-class implementations of this method must be in
                  the :class:`.OverlayList`.
        """

        if isinstance(self.overlay, fslimage.Nifti):
            return self.overlay
        return None


    @deprecated.deprecated('0.14.3', '1.0.0', 'Use referenceImage instead')
    def getReferenceImage(self):
        """Deprecated - use :meth:`referenceImage` instead. """
        return self.referenceImage


    def getLabels(self):
        """Generates some orientation labels for the overlay associated with
        this ``DisplayOpts`` instance.

        If the overlay is not a ``Nifti`` instance, or does not have a
        reference image set, the labels will represent an unknown orientation.

        Returns a tuple containing:

          - The ``(xlo, ylo, zlo, xhi, yhi, zhi)`` labels
          - The ``(xorient, yorient, zorient)`` orientations (see
            :meth:`.Image.getOrientation`)
        """

        refImage = self.referenceImage

        if refImage is None:
            return ('??????', [constants.ORIENT_UNKNOWN] * 3)

        opts = self.displayCtx.getOpts(refImage)

        xorient = None
        yorient = None
        zorient = None

        # If we are displaying in voxels/scaled voxels,
        # and this image is not the current display
        # image, then we do not show anatomical
        # orientation labels, as there's no guarantee
        # that all of the loaded overlays are in the
        # same orientation, and it can get confusing.
        if opts.transform in ('id', 'pixdim', 'pixdim-flip') and \
           self.displayCtx.displaySpace != refImage:
            xlo = 'Xmin'
            xhi = 'Xmax'
            ylo = 'Ymin'
            yhi = 'Ymax'
            zlo = 'Zmin'
            zhi = 'Zmax'

        # Otherwise we assume that all images
        # are aligned to each other, so we
        # estimate the current image's orientation
        # in the display coordinate system
        else:

            xform   = opts.getTransform('display', 'world')
            xorient = refImage.getOrientation(0, xform)
            yorient = refImage.getOrientation(1, xform)
            zorient = refImage.getOrientation(2, xform)

            xlo     = strings.anatomy['Nifti', 'lowshort',  xorient]
            ylo     = strings.anatomy['Nifti', 'lowshort',  yorient]
            zlo     = strings.anatomy['Nifti', 'lowshort',  zorient]
            xhi     = strings.anatomy['Nifti', 'highshort', xorient]
            yhi     = strings.anatomy['Nifti', 'highshort', yorient]
            zhi     = strings.anatomy['Nifti', 'highshort', zorient]

        return ((xlo, ylo, zlo, xhi, yhi, zhi),
                (xorient, yorient, zorient))
Esempio n. 6
0
class ColourMapOpts(object):
    """The ``ColourMapOpts`` class is a mixin for use with
    :class:`.DisplayOpts` sub-classes. It provides properties and logic
    for displaying overlays which are coloured according to some data values.
    See the :class:`.MeshOpts` and :class:`.VolumeOpts` classes for examples
    of classes which inherit from this class.


    To use the ``ColourMapOpts`` class, you must:

      1. Define your class to inherit from both :class:`.DisplayOpts` and
         ``ColourMapOpts``::

             class MyOpts(DisplayOpts, ColourMapOpts):
                 ...

      2. Call the ``ColourMapOpts.__init__`` method *after*
         :meth:`.DisplayOpts.__init__`::

             def __init__(self, *args, **kwargs):
                 DisplayOpts.__init__(self, *args, **kwargs)
                 ColourMapOpts.__init__(self)

      3. Implement the :meth:`getDataRange` and (if necessary)
         :meth:`getClippingRange` methods.

      4. Call :meth:`updateDataRange` whenever the data driving the colouring
         changes.


    The ``ColourMapOpts`` class links the :attr:`.Display.brightness` and
    :attr:`.Display.contrast` properties to its own :attr:`displayRange`
    property, so changes in either of the former will result in a change to
    the latter, and vice versa. This relationship is defined by the
    :func:`~.colourmaps.displayRangeToBricon` and
    :func:`~.colourmaps.briconToDisplayRange` functions, in the
    :mod:`.colourmaps` module.


    ``ColourMapOpts`` instances provide the following methods:

    .. autosummary::
       :nosignatures:

       updateDataRange
       getDataRange
       getClippingRange
    """


    displayRange = props.Bounds(ndims=1, clamped=False)
    """Values which map to the minimum and maximum colour map colours.

    .. note:: The values that this property can take are unbound because of
              the interaction between it and the :attr:`.Display.brightness`
              and :attr:`.Display.contrast` properties.  The
              :attr:`displayRange` and :attr:`clippingRange` properties are
              not clamped (they can take values outside of their
              minimum/maximum values) because the data range for large NIFTI
              images may not be known, and may change as more data is read
              from disk.
    """


    clippingRange = props.Bounds(ndims=1, clamped=False)
    """Values outside of this range are not shown.  Clipping works as follows:

     - Values less than or equal to the minimum clipping value are
       clipped.

     - Values greater than or equal to the maximum clipping value are
       clipped.

    Because of this, a small amount of padding is added to the low and high
    clipping range limits, to make it possible for all values to be
    displayed.
    """


    invertClipping = props.Boolean(default=False)
    """If ``True``, the behaviour of :attr:`clippingRange` is inverted, i.e.
    values inside the clipping range are clipped, instead of those outside
    the clipping range.
    """


    cmap = props.ColourMap()
    """The colour map, a :class:`matplotlib.colors.Colourmap` instance."""


    gamma = props.Real(minval=-1, maxval=1, clamped=True, default=0)
    """Gamma correction factor - exponentially weights the :attr:`cmap`
    and :attr:`negCmap` towards the low or high ends.

    This property takes values between -1 and +1. The exponential weight
    that should actually be used to apply gamma correction should be derived
    as follows:

      - -1 corresponds to a gamma of 0.01
      -  0 corresponds to a gamma of 1
      - +1 corresponds to a gamma of 10

    The :meth:`realGamma` method will apply this scaling and return the
    exponent to be used.
    """


    cmapResolution = props.Int(minval=2, maxval=1024, default=256)
    """Resolution for the colour map, i.e. the number of colours to use. """


    interpolateCmaps = props.Boolean(default=False)
    """If ``True``, the colour maps are applied using linear interpolation.
    Otherwise they are applied using nearest neighbour interpolation.
    """


    negativeCmap = props.ColourMap()
    """A second colour map, used if :attr:`useNegativeCmap` is ``True``.
    When active, the :attr:`cmap` is used to colour positive values, and
    the :attr:`negativeCmap` is used to colour negative values.
    """


    useNegativeCmap = props.Boolean(default=False)
    """When ``True``, the :attr:`cmap` is used to colour positive values,
    and the :attr:`negativeCmap` is used to colour negative values.
    When this property is enabled, the minimum value for both the
    :attr:`displayRange` and :attr:`clippingRange` is set to zero. Both
    ranges are applied to positive values, and negated/inverted for negative
    values.

    .. note:: When this property is set to ``True``, the
              :attr:`.Display.brightness` and :attr:`.Display.contrast`
              properties are disabled, as managing the interaction between
              them would be far too complicated.
    """


    invert = props.Boolean(default=False)
    """Use an inverted version of the current colour map (see the :attr:`cmap`
    property).
    """


    linkLowRanges = props.Boolean(default=True)
    """If ``True``, the low bounds on both the :attr:`displayRange` and
    :attr:`clippingRange` ranges will be linked together.
    """


    linkHighRanges = props.Boolean(default=False)
    """If ``True``, the high bounds on both the :attr:`displayRange` and
    :attr:`clippingRange` ranges will be linked together.
    """


    @staticmethod
    def realGamma(gamma):
        """Return the value of ``gamma`` property, scaled appropriately.
        for use as an exponent.
        """

        # a gamma in the range [-1, 0]
        # gets scaled to [0.01, 1]
        if gamma < 0:
            return (gamma + 1.01) * 0.99

        # a gamma in the range [0, 1]
        # gets scaled to [1, 10]
        else:
            return 1 + 9 * gamma


    def __init__(self):
        """Create a ``ColourMapOpts`` instance. This must be called
        *after* the :meth:`.DisplayOpts.__init__` method.
        """

        # The displayRange property of every child ColourMapOpts
        # instance is linked to the corresponding
        # Display.brightness/contrast properties, so changes
        # in one are reflected in the other. This interaction
        # complicates the relationship between parent and child
        # ColourMapOpts instances, so we only implement it on
        # children.
        #
        # NOTE: This means that if we use a parent-less
        #       DisplayContext for display, this bricon-display
        #       range relationship will break.
        #
        self.__registered = self.getParent() is not None

        if self.__registered:

            name    = self.getColourMapOptsListenerName()
            display = self.display

            display    .addListener('brightness',
                                    name,
                                    self.__briconChanged,
                                    immediate=True)
            display    .addListener('contrast',
                                    name,
                                    self.__briconChanged,
                                    immediate=True)
            self       .addListener('displayRange',
                                    name,
                                    self.__displayRangeChanged,
                                    immediate=True)
            self       .addListener('useNegativeCmap',
                                    name,
                                    self.__useNegativeCmapChanged,
                                    immediate=True)
            self       .addListener('linkLowRanges',
                                    name,
                                    self.__linkLowRangesChanged,
                                    immediate=True)
            self       .addListener('linkHighRanges',
                                    name,
                                    self.__linkHighRangesChanged,
                                    immediate=True)

            # Because displayRange and bri/con are intrinsically
            # linked, it makes no sense to let the user sync/unsync
            # them independently. So here we are binding the boolean
            # sync properties which control whether the dRange/bricon
            # properties are synced with their parent. So when one
            # property is synced/unsynced, the other ones are too.
            self.bindProps(self   .getSyncPropertyName('displayRange'),
                           display,
                           display.getSyncPropertyName('brightness'))
            self.bindProps(self   .getSyncPropertyName('displayRange'),
                           display,
                           display.getSyncPropertyName('contrast'))

            # If useNegativeCmap, linkLowRanges or linkHighRanges
            # have been set to True (this will happen if they
            # are true on the parent VolumeOpts instance), make
            # sure the property / listener states are up to date.
            if self.linkLowRanges:   self.__linkLowRangesChanged()
            if self.linkHighRanges:  self.__linkHighRangesChanged()
            if self.useNegativeCmap:
                self.__useNegativeCmapChanged(updateDataRange=False)

        # If this is the parent ColourMapOpts
        # instance, its properties need to be
        # initialised. Child instance properties
        # should inherit the current parent
        # values, unless they are not synced
        # to the parent.
        if (not self.__registered) or \
           (not self.isSyncedToParent('displayRange')):
            self.updateDataRange(False, False)


    def getColourMapOptsListenerName(self):
        """Returns the name used by this ``ColourMapOpts`` instance for
        registering internal property listeners.

        Sibling ``ColourMapOpts``
        instances need to toggle each other's property listeners (see the
        :meth:`__toggleListeners` method), so they use this method to
        retrieve each other's listener names.
        """
        return 'ColourMapOpts_{}'.format(id(self))


    def destroy(self):
        """Must be called when this ``ColourMapOpts`` is no longer needed,
        and before :meth:`.DisplayOpts.destroy` is called. Removes property
        listeners.
        """

        if not self.__registered:
            return

        display = self.display
        name    = self.getColourMapOptsListenerName()

        display.removeListener('brightness',      name)
        display.removeListener('contrast',        name)
        self   .removeListener('displayRange',    name)
        self   .removeListener('useNegativeCmap', name)
        self   .removeListener('linkLowRanges',   name)
        self   .removeListener('linkHighRanges',  name)

        self.unbindProps(self   .getSyncPropertyName('displayRange'),
                         display,
                         display.getSyncPropertyName('brightness'))
        self.unbindProps(self   .getSyncPropertyName('displayRange'),
                         display,
                         display.getSyncPropertyName('contrast'))

        self.__linkRangesChanged(False, 0)
        self.__linkRangesChanged(False, 1)


    def getDataRange(self):
        """Must be overridden by sub-classes. Must return the range of the
        data used for colouring as a ``(min, max)`` tuple.  Note that, even

        if there is no effective data range, you should return two different
        values for ``min`` and ``max`` (e.g. ``(0, 1)``), because otherwise
        the relationship between the :attr:`displayRange` and the
        :attr:`.Display.brightness` and :attr:`.Display.contrast` properties
        will be corrupted.
        """

        raise NotImplementedError('ColourMapOpts.getDataRange must be '
                                  'implemented by sub-classes.')


    def getClippingRange(self):
        """Can be overridden by sub-classes if necessary. If the clipping
        range is always the same as the data range, this method does not
        need to be overridden.

        Otherwise, if the clipping range differs from the data range
        (see e.g. the :attr:`.VolumeOpts.clipImage` property), this method
        must return the clipping range as a ``(min, max)`` tuple.

        When a sub-class implementation wishes to use the default clipping
        range/behaviour, it should return the value returned by this
        base-class implementation.
        """
        return None


    @actions.action
    def resetDisplayRange(self):
        """Resets the :attr:`displayRange` and :attr:`clippingRange` to their
        initial values.
        """
        self.updateDataRange(True, True)


    def updateDataRange(self, resetDR=True, resetCR=True):
        """Must be called by sub-classes whenever the ranges of the underlying
        data or clipping values change.  Configures the minimum/maximum bounds
        of the :attr:`displayRange` and :attr:`clippingRange` properties.

        :arg resetDR: If ``True`` (the default), the :attr:`displayRange`
                      property will be reset to the data range returned
                      by :meth:`getDataRange`. Otherwise the existing
                      value will be preserved.

        :arg resetCR: If ``True`` (the default), the :attr:`clippingRange`
                      property will be reset to the clipping range returned
                      by :meth:`getClippingRange`. Otherwise the existing
                      value will be preserved.

        Note that both of these flags will be ignored if the existing low/high
        :attr:`displayRange`/:attr:`clippingRange` values and limits are equal
        to each other.
        """

        dataMin, dataMax = self.getDataRange()
        clipRange        = self.getClippingRange()

        absolute = self.useNegativeCmap
        drmin    = dataMin
        drmax    = dataMax

        if absolute:
            drmin = min((0,            abs(dataMin)))
            drmax = max((abs(dataMin), abs(dataMax)))

        if clipRange is not None: crmin, crmax = clipRange
        else:                     crmin, crmax = drmin, drmax

        # Clipping works on >= and <=, so we add
        # a small offset to the display range limits
        # (which are equal to the clipping limiits)
        # so the user can configure the scene such
        # that no values are clipped.
        droff  = abs(drmax - drmin) / 100.0
        croff  = abs(crmax - crmin) / 100.0
        crmin -= croff
        crmax += croff
        drmin -= droff
        drmax += droff

        # Execute on the PV call queue,
        # so that property updates occur
        # in the correct order.
        def doUpdate():

            # If display/clipping limit range
            # is 0, we assume that they haven't
            # yet been set
            drUnset = (self.displayRange .xmin == self.displayRange .xmax and
                       self.displayRange .xlo  == self.displayRange .xhi)
            crUnset = (self.clippingRange.xmin == self.clippingRange.xmax and
                       self.clippingRange.xlo  == self.clippingRange.xhi)
            crGrow  =  self.clippingRange.xhi  == self.clippingRange.xmax
            drUnset =  resetDR or drUnset
            crUnset =  resetCR or crUnset

            log.debug('[{}] Updating range limits [dr: {} - {}, ''cr: '
                      '{} - {}]'.format(id(self), drmin, drmax, crmin, crmax))

            self.displayRange .xlim = drmin, drmax
            self.clippingRange.xlim = crmin, crmax

            # If the ranges have not yet been set,
            # initialise them to the min/max.
            # Also, if the high clipping range
            # was previously equal to the max
            # clipping range, keep that relationship,
            # otherwise high values will be clipped.
            if drUnset: self.displayRange .x   = drmin + droff, dataMax
            if crUnset: self.clippingRange.x   = crmin + croff, crmax
            if crGrow:  self.clippingRange.xhi = crmax

            # If using absolute range values, the low
            # display/clipping should be set to 0
            if absolute and self.displayRange .xlo < 0:
                self.displayRange.xlo  = 0
            if absolute and self.clippingRange.xlo < 0:
                self.clippingRange.xlo = 0

        props.safeCall(doUpdate)


    def __toggleListeners(self, enable=True):
        """This method enables/disables the property listeners which
        are registered on the :attr:`displayRange` and
        :attr:`.Display.brightness`/:attr:`.Display.contrast`/properties.

        Because these properties are linked via the
        :meth:`__displayRangeChanged` and :meth:`__briconChanged` methods,
        we need to be careful about avoiding recursive callbacks.

        Furthermore, because the properties of both :class:`ColourMapOpts` and
        :class:`.Display` instances are possibly synchronised to a parent
        instance (which in turn is synchronised to other children), we need to
        make sure that the property listeners on these other sibling instances
        are not called when our own property values change. So this method
        disables/enables the property listeners on all sibling
        ``ColourMapOpts`` and ``Display`` instances.
        """

        parent = self.getParent()

        # this is the parent instance
        if parent is None:
            return

        # The parent.getChildren() method will
        # contain this ColourMapOpts instance,
        # so the below loop toggles listeners
        # for this instance and all of the other
        # children of the parent
        peers  = parent.getChildren()

        for peer in peers:

            name = peer.getColourMapOptsListenerName()
            bri  = peer.display.hasListener('brightness',   name)
            con  = peer.display.hasListener('contrast',     name)
            dr   = peer        .hasListener('displayRange', name)

            if enable:
                if bri: peer.display.enableListener('brightness',   name)
                if con: peer.display.enableListener('contrast',     name)
                if dr:  peer        .enableListener('displayRange', name)
            else:
                if bri: peer.display.disableListener('brightness',   name)
                if con: peer.display.disableListener('contrast',     name)
                if dr:  peer        .disableListener('displayRange', name)


    def __briconChanged(self, *a):
        """Called when the ``brightness``/``contrast`` properties of the
        :class:`.Display` instance change.

        Updates the :attr:`displayRange` property accordingly.

        See :func:`.colourmaps.briconToDisplayRange`.
        """

        dataRange = self.getDataRange()

        dlo, dhi = fslcm.briconToDisplayRange(
            dataRange,
            self.display.brightness / 100.0,
            self.display.contrast   / 100.0)

        self.__toggleListeners(False)
        self.displayRange.x = [dlo, dhi]
        self.__toggleListeners(True)


    def __displayRangeChanged(self, *a):
        """Called when the `attr:`displayRange` property changes.

        Updates the :attr:`.Display.brightness` and :attr:`.Display.contrast`
        properties accordingly.

        See :func:`.colourmaps.displayRangeToBricon`.
        """

        if self.useNegativeCmap:
            return

        dataRange = self.getDataRange()

        brightness, contrast = fslcm.displayRangeToBricon(
            dataRange, self.displayRange.x)

        self.__toggleListeners(False)

        # update bricon
        self.display.brightness = brightness * 100
        self.display.contrast   = contrast   * 100

        self.__toggleListeners(True)


    def __useNegativeCmapChanged(self, *a, **kwa):
        """Called when the :attr:`useNegativeCmap` property changes.
        Enables/disables the :attr:`.Display.brightness` and
        :attr:`.Display.contrast` properties, and calls
        :meth:`updateDataRange`.

        :arg updateDatRange: Must be passed as a keyword argument.
                             If ``True`` (the default), calls
                             :meth:`updateDataRange`.
        """

        if self.useNegativeCmap:
            self.display.disableProperty('brightness')
            self.display.disableProperty('contrast')
        else:
            self.display.enableProperty('brightness')
            self.display.enableProperty('contrast')

        if kwa.pop('updateDataRange', True):
            self.updateDataRange(resetDR=False, resetCR=False)


    def __linkLowRangesChanged(self, *a):
        """Called when the :attr:`linkLowRanges` property changes. Calls the
        :meth:`__linkRangesChanged` method.
        """
        self.__linkRangesChanged(self.linkLowRanges, 0)


    def __linkHighRangesChanged(self, *a):
        """Called when the :attr:`linkHighRanges` property changes. Calls the
        :meth:`__linkRangesChanged` method.
        """
        self.__linkRangesChanged(self.linkHighRanges, 1)


    def __linkRangesChanged(self, val, idx):
        """Called when either the :attr:`linkLowRanges` or
        :attr:`linkHighRanges` properties change. Binds/unbinds the specified
        range properties together.

        :arg val: Boolean indicating whether the range values should be
                  linked or unlinked.

        :arg idx: Range value index - 0 corresponds to the low range value,
                  and 1 to the high range value.
        """

        dRangePV = self.displayRange .getPropertyValueList()[idx]
        cRangePV = self.clippingRange.getPropertyValueList()[idx]

        if props.propValsAreBound(dRangePV, cRangePV) == val:
            return

        props.bindPropVals(dRangePV,
                           cRangePV,
                           bindval=True,
                           bindatt=False,
                           unbind=not val)

        if val:
            cRangePV.set(dRangePV.get())
Esempio n. 7
0
class SliceCanvasOpts(props.HasProperties):
    """The ``SliceCanvasOpts`` class defines all of the display settings
    for a :class:`.SliceCanvas`.
    """

    pos = props.Point(ndims=3)
    """The currently displayed position.

    The ``pos.x`` and ``pos.y`` positions denote the position of a *cursor*,
    which is highlighted with crosshairs (see the :attr:`showCursor`
    property). The ``pos.z`` position specifies the currently displayed slice.
    """

    zoom = props.Percentage(minval=100.0,
                            maxval=5000.0,
                            default=100.0,
                            clamped=False)
    """The :attr:`.DisplayContext.bounds` are divided by this zoom
    factor to produce the canvas display bounds.
    """

    displayBounds = props.Bounds(ndims=2, clamped=False)
    """The display bound x/y values specify the horizontal/vertical display
    range of the canvas, in display coordinates. This may be a larger area
    than the size of the displayed overlays, as it is adjusted to preserve
    the aspect ratio.
    """

    showCursor = props.Boolean(default=True)
    """If ``False``, the crosshairs which show the current cursor location
    will not be drawn.
    """

    cursorGap = props.Boolean(default=False)
    """If ``True``, and the currently selected overlay is a :class:`.Nifti`
    instance, a gap will be shown at the cursor centre (i.e. the current
    voxel).
    """

    zax = props.Choice((0, 1, 2),
                       alternates=[['x', 'X'], ['y', 'Y'], ['z', 'Z']],
                       allowStr=True)
    """The display coordinate system axis to be used as the screen *depth*
    axis. The :meth:`xax` and :meth:`yax` attributes are derived from this
    property:

     - If ``zax == 0``, ``xax, yax == 1, 2``
     - If ``zax == 1``, ``xax, yax == 0, 2``
     - If ``zax == 2``, ``xax, yax == 0, 1``
    """

    invertX = props.Boolean(default=False)
    """If ``True``, the display is inverted along the X (horizontal screen)
    axis.
    """

    invertY = props.Boolean(default=False)
    """If ``True``, the display is inverted along the Y (vertical screen)
    axis.
    """

    cursorColour = props.Colour(default=(0, 1, 0))
    """Canvas cursor colour."""

    bgColour = props.Colour(default=(0, 0, 0))
    """Canvas background colour."""

    renderMode = props.Choice(('onscreen', 'offscreen', 'prerender'))
    """How the :class:`.GLObject` instances are rendered to the canvas.

    See the :class:`.SliceCanvas` for more details.
    """

    highDpi = props.Boolean(default=False)
    """If FSLeyes is being displayed on a high-DPI screen, try to display
    the scene at full resolution.
    """
    def __init__(self):
        """Create a ``SliceCanvasOpts`` instance. """

        self.__name = '{}_{}'.format(type(self).__name__, id(self))
        self.__xax = 0
        self.__yax = 0

        self.addListener('zax', self.__name, self.__zaxChanged, immediate=True)
        self.__zaxChanged()

    def __zaxChanged(self, *a):
        """Calle when the :attr:`zax` property changes. Derives the
        ``xax`` and ``yax`` values.
        """

        dims = list(range(3))
        dims.pop(self.zax)
        self.__xax = dims[0]
        self.__yax = dims[1]

    @property
    def xax(self):
        """The display coordinate system axis which maps to the X (horizontal)
        canvas axis.
        """
        return self.__xax

    @property
    def yax(self):
        """The display coordinate system axis which maps to the Y (vertical)
        canvas axis.
        """
        return self.__yax
Esempio n. 8
0
class OrthoCropProfile(orthoviewprofile.OrthoViewProfile):
    """The ``OrthoViewProfile`` class is a :class:`.Profile` for the
    :class:`.OrthoPanel` class, which allows the user to define a cropping
    region for :class:`.Image` overlays.


    Ther ``OrthoCropProfile`` displays a cropping, or ROI, region on the
    ``OrthoPanel`` canvases, relative to the for the currently selected
    image, using :class:`.Rect` annotations. Mouse handlers are also
    defined, allowing the user to adjust the box.


    Once the user has selected a cropping region, the related
    :class:`.CropImagePanel` allows him/her to create a cropped copy of the
    image.


    The ``OrthoCropProfile`` class defines one mode, in addition to those
    inherited from the :class:`.OrthoViewProfile` class:


    ======== ===================================================
    ``crop`` Clicking and dragging allows the user to change the
             boundaries of a cropping region.
    ======== ===================================================


    .. note:: The crop overlay will only be shown if the
              :attr:`.DisplayContext.displaySpace` is set to the currently
              selected overlay. The :class:`.CropImagePanel` uses a
              :class:`.DisplaySpaceWarning` to inform the user.
    """

    cropBox = props.Bounds(ndims=3, real=False, minDistance=1)
    """This property keeps track of the current low/high limits
    of the cropping region, in the voxel coordinate system of the
    currently selected overlay.
    """
    def __init__(self, viewPanel, overlayList, displayCtx):
        """Create an ``OrthoCropProfile``.

        :arg viewPanel:    An :class:`.OrthoPanel` instance.
        :arg overlayList:  The :class:`.OverlayList` instance.
        :arg displayCtx:   The :class:`.DisplayContext` instance.
        """

        orthoviewprofile.OrthoViewProfile.__init__(self, viewPanel,
                                                   overlayList, displayCtx,
                                                   ['crop'])
        self.mode = 'crop'

        # The currently selected overlay,
        # and the one for which the cropping
        # box is being shown/modified.
        self.__overlay = None

        # A cache of { overlay : cropBox }
        # which stores the last cropping
        # box for a given overlay. This
        # is used to cache boxes if the
        # user selects a different overlay
        # while the crop profile is active.
        self.__cachedCrops = {}

        # axis:   one of 0, 1, or 2 (X, Y, or Z) -
        #         the voxel axis of the crop box
        #         that is being adjusted
        #
        # limits: one of 0 or 1 (lo or hi) - the
        #         low/high limit of the crop box
        #         that is being adjusted
        #
        # These fields are set when the
        # user is dragging a crop box
        # boundary
        self.__dragAxis = None
        self.__dragLimit = None

        self.__xcanvas = viewPanel.getXCanvas()
        self.__ycanvas = viewPanel.getYCanvas()
        self.__zcanvas = viewPanel.getZCanvas()

        # A rectangle is displayed on
        # each of the canvases, showing
        # the current cropping box.
        self.__xrect = annotations.Rect(self.__xcanvas.getAnnotations(),
                                        (0, 0),
                                        0,
                                        0,
                                        colour=(0.3, 0.3, 1.0),
                                        filled=True)
        self.__yrect = annotations.Rect(self.__ycanvas.getAnnotations(),
                                        (0, 0),
                                        0,
                                        0,
                                        colour=(0.3, 0.3, 1.0),
                                        filled=True)
        self.__zrect = annotations.Rect(self.__zcanvas.getAnnotations(),
                                        (0, 0),
                                        0,
                                        0,
                                        colour=(0.3, 0.3, 1.0),
                                        filled=True)

        displayCtx.addListener('displaySpace', self.name,
                               self.__displaySpaceChanged)
        displayCtx.addListener('selectedOverlay', self.name,
                               self.__selectedOverlayChanged)
        overlayList.addListener('overlays', self.name,
                                self.__selectedOverlayChanged)
        self.addListener('cropBox', self.name, self.__cropBoxChanged)

        self.__xcanvas.getAnnotations().obj(self.__xrect, hold=True)
        self.__ycanvas.getAnnotations().obj(self.__yrect, hold=True)
        self.__zcanvas.getAnnotations().obj(self.__zrect, hold=True)

        self.robustfov.enabled = fslplatform.fsldir is not None

        self.__selectedOverlayChanged()

    def destroy(self):
        """Must be called when this ``OrthoCropProfile`` is no longer
        needed. Removes property listeners and does some other clean up.
        """

        self.overlayList.removeListener('overlays', self.name)
        self.displayCtx.removeListener('selectedOverlay', self.name)
        self.displayCtx.removeListener('displaySpace', self.name)
        self.removeListener('cropBox', self.name)

        self.__xcanvas.getAnnotations().dequeue(self.__xrect, hold=True)
        self.__ycanvas.getAnnotations().dequeue(self.__yrect, hold=True)
        self.__zcanvas.getAnnotations().dequeue(self.__zrect, hold=True)

        orthoviewprofile.OrthoViewProfile.destroy(self)

    @actions.action
    def robustfov(self):
        """Call ``robustfov`` for the current overlay and set the
        :attr:`cropBox` based on the result.
        """

        if self.__overlay is None:
            return

        try:
            result = callfsl.callFSL('robustfov', '-i',
                                     self.__overlay.dataSource)

            # robustfov returns two lines, the last
            # of which contains the limits, as:
            #
            #    xmin xlen ymin ylen zmin zlen
            limits = list(result.strip().split('\n')[-1].split())
            limits = [float(l) for l in limits]

            # Convert the lens to maxes
            limits[1] += limits[0]
            limits[3] += limits[2]
            limits[5] += limits[4]
            self.cropBox[:] = limits

        except Exception as e:
            log.warning('Call to robustfov failed: {}'.format(str(e)))

    def __deregisterOverlay(self):
        """Called by :meth:`__selectedOverlayChanged`. Clears references
        associated with the previously selected overlay, if necessary.
        """

        if self.__overlay is None:
            return

        self.__cachedCrops[self.__overlay] = list(self.cropBox)
        self.__overlay = None

    def __registerOverlay(self, overlay):
        """Called by :meth:`__selectedOverlayChanged`. Sets up
        references associated with the given (newly selected) overlay.
        """
        self.__overlay = overlay

    def __selectedOverlayChanged(self, *a):
        """Called when the :attr:`.DisplayContext.selectedOverlay` changes.
        If the overlay is a :class:`.Image` instance, it is set as the
        :attr:`.DisplayContext.displaySpace` reference, and the
        :attr:`cropBox` is configured to be relative to the newly selected
        overlay.
        """
        overlay = self.displayCtx.getSelectedOverlay()

        if overlay is self.__overlay:
            return

        self.__deregisterOverlay()

        enabled = isinstance(overlay, fslimage.Image)

        self.__xrect.enabled = enabled
        self.__yrect.enabled = enabled
        self.__zrect.enabled = enabled

        if not enabled:
            return

        self.__registerOverlay(overlay)

        shape = overlay.shape[:3]
        crop = self.__cachedCrops.get(overlay, None)

        if crop is None:
            crop = [0, shape[0], 0, shape[1], 0, shape[2]]

        with props.suppress(self, 'cropBox', notify=True):
            self.cropBox.xmin = 0
            self.cropBox.ymin = 0
            self.cropBox.zmin = 0
            self.cropBox.xmax = shape[0]
            self.cropBox.ymax = shape[1]
            self.cropBox.zmax = shape[2]
            self.cropBox = crop

    def __displaySpaceChanged(self, *a):
        """Called when the :attr:`.DisplayContext.displaySpace` changes.
        Resets the :attr:`cropBox`.
        """
        cropBox = self.cropBox
        cropBox.xlo = self.cropBox.xmin
        cropBox.ylo = self.cropBox.ymin
        cropBox.zlo = self.cropBox.zmin
        cropBox.xhi = self.cropBox.xmax
        cropBox.yhi = self.cropBox.ymax
        cropBox.zhi = self.cropBox.zmax

    def __cropBoxChanged(self, *a):
        """Called when the :attr:`cropBox` changes. Updates the :class:`.Rect`
        annotations on the :class:`.OrthoPanel` canvases.
        """

        xlo, xhi = self.cropBox.x
        ylo, yhi = self.cropBox.y
        zlo, zhi = self.cropBox.z

        xlo -= 0.5
        ylo -= 0.5
        zlo -= 0.5
        xhi -= 0.5
        yhi -= 0.5
        zhi -= 0.5
        coords = np.array([[xlo, ylo, zlo], [xlo, ylo, zhi], [xlo, yhi, zlo],
                           [xlo, yhi, zhi], [xhi, ylo, zlo], [xhi, ylo, zhi],
                           [xhi, yhi, zlo], [xhi, yhi, zhi]])

        opts = self.displayCtx.getOpts(self.__overlay)
        coords = opts.transformCoords(coords, 'voxel', 'display')

        mins = coords.min(axis=0)
        maxs = coords.max(axis=0)
        pads = (maxs - mins) * 0.01

        self.__xrect.xy = mins[1], mins[2]
        self.__xrect.w = maxs[1] - mins[1]
        self.__xrect.h = maxs[2] - mins[2]
        self.__xrect.zmin = mins[0] - pads[0]
        self.__xrect.zmax = maxs[0] + pads[0]

        self.__yrect.xy = mins[0], mins[2]
        self.__yrect.w = maxs[0] - mins[0]
        self.__yrect.h = maxs[2] - mins[2]
        self.__yrect.zmin = mins[1] - pads[1]
        self.__yrect.zmax = maxs[1] + pads[1]

        self.__zrect.xy = mins[0], mins[1]
        self.__zrect.w = maxs[0] - mins[0]
        self.__zrect.h = maxs[1] - mins[1]
        self.__zrect.zmin = mins[2] - pads[2]
        self.__zrect.zmax = maxs[2] + pads[2]

        # TODO Don't do this if you don't need to
        self.__xcanvas.Refresh()
        self.__ycanvas.Refresh()
        self.__zcanvas.Refresh()

    def __getVoxel(self, overlay, canvasPos):
        """Called by the mouse down/drag handlers. Figures out the voxel
        in the currently selected overlay which corresponds to the
        given canvas position.
        """

        shape = overlay.shape[:3]
        vox = self.displayCtx.getOpts(overlay).getVoxel(canvasPos,
                                                        clip=False,
                                                        vround=False)

        vox = np.ceil(vox)

        # The getVoxel method will return out of
        # bounds voxels (because we asked it to),
        # so we need to clamp to the image shape
        for i, (v, s) in enumerate(zip(vox, shape)):
            if v < 0: vox[i] = 0
            elif v >= s: vox[i] = s

        return vox

    def _cropModeLeftMouseDown(self, ev, canvas, mousePos, canvasPos):
        """Called on mouse down events. Calculates the nearest crop box
        boundary to the mouse click, adjusts the boundary accordingly,
        and saves the boundary/axis information for subsequent drag
        events (see :meth:`_cropModeLeftMouseDrag`).
        """

        copts = canvas.opts
        overlay = self.__overlay

        if overlay is None:
            return

        # What canvas was the click on?
        if copts.zax == 0: hax, vax = 1, 2
        elif copts.zax == 1: hax, vax = 0, 2
        elif copts.zax == 2: hax, vax = 0, 1

        # Figure out the distances from
        # the mouse click  to each crop
        # box boundary on the clicked
        # canvas
        vox = self.__getVoxel(overlay, canvasPos)
        hlo, hhi = self.cropBox.getLo(hax), self.cropBox.getHi(hax)
        vlo, vhi = self.cropBox.getLo(vax), self.cropBox.getHi(vax)

        # We compare the click voxel
        # coords with each of the x/y
        # lo/hi crop box boundaries
        boundaries = np.array([[hlo, vox[vax]], [hhi, vox[vax]],
                               [vox[hax], vlo], [vox[hax], vhi]])

        # In case the voxel is out of bounds,
        # make sure that the crop box boundary
        # coordinates are actually in the crop
        # box (or on an edge).
        boundaries[:, 0] = np.clip(boundaries[:, 0], hlo, hhi)
        boundaries[:, 1] = np.clip(boundaries[:, 1], vlo, vhi)

        # As the display space is set to
        # this overlay, the display coordinate
        # system is equivalent to the scaled
        # voxel coordinate system of the
        # overlay. So we can just multiply the
        # 2D voxel coordinates by the
        # corresponding pixdims to get the
        # distances in the display coordinate
        # system.
        pixdim = overlay.pixdim[:3]
        scVox = [vox[hax] * pixdim[hax], vox[vax] * pixdim[vax]]
        boundaries[:, 0] = boundaries[:, 0] * pixdim[hax]
        boundaries[:, 1] = boundaries[:, 1] * pixdim[vax]

        # Calculate distance from click to
        # crop boundaries, and figure out
        # the screen axis (x/y) and limit
        # (lo/hi) to be dragged.
        dists = (boundaries - scVox)**2
        dists = np.sqrt(np.sum(dists, axis=1))
        axis, limit = np.unravel_index(np.argmin(dists), (2, 2))
        voxAxis = [hax, vax][axis]

        axis = int(axis)
        limit = int(limit)

        # Save these for the mouse drag handler
        self.__dragAxis = voxAxis
        self.__dragLimit = limit

        # Update the crop box and location
        with props.suppress(self, 'cropBox', notify=True):
            self.cropBox.setLimit(voxAxis, limit, vox[voxAxis])

        self.displayCtx.location = canvasPos

    def _cropModeLeftMouseDrag(self, ev, canvas, mousePos, canvasPos):
        """Called on left mouse drags. Updates the :attr:`cropBox` boudary
        which was clicked on (see :meth:`_cropModeLeftMouseDown`), so it
        follows the mouse location.
        """

        if self.__overlay is None or self.__dragAxis is None:
            return

        box = self.cropBox
        axis = self.__dragAxis
        limit = self.__dragLimit
        oppLimit = 1 - limit
        vox = self.__getVoxel(self.__overlay, canvasPos)

        newval = vox[axis]
        oppval = box.getLimit(axis, oppLimit)

        if limit == 0 and newval >= oppval: newval = oppval - 1
        elif limit == 1 and newval <= oppval: newval = oppval + 1

        with props.suppress(self, 'cropBox', notify=True):
            self.cropBox.setLimit(axis, limit, newval)

        self.displayCtx.location = canvasPos

    def _cropModeLeftMouseUp(self, ev, canvas, mousePos, canvasPos):
        """Called on left mouse up events. Clears references used by the
        mouse down/drag handlers.
        """

        if self.__overlay is None or self.__dragAxis is None:
            return

        self.__dragAxis = None
        self.__dragLimit = None
Esempio n. 9
0
class MaskOpts(volumeopts.NiftiOpts):
    """The ``MaskOpts`` class defines settings for displaying an
    :class:`.Image` overlay as a binary mask.
    """

    threshold = props.Bounds(ndims=1)
    """The mask threshold range - values outside of this range are not
    displayed.
    """

    invert = props.Boolean(default=False)
    """If ``True``, the :attr:`threshold` range is inverted - values
    inside the range are not shown, and values outside of the range are shown.
    """

    colour = props.Colour()
    """The mask colour."""
    def __init__(self, overlay, *args, **kwargs):
        """Create a ``MaskOpts`` instance for the given overlay. All arguments
        are passed through to the :class:`.NiftiOpts` constructor.
        """

        #################
        # This is a hack.
        #################

        # Mask images are rendered using GLMask, which
        # inherits from GLVolume. The latter assumes
        # that the DisplayOpts instance passed to it
        # has the following attributes (see the
        # VolumeOpts class). So we're adding dummy
        # attributes to make the GLVolume rendering
        # code happy.
        #
        # TODO Write independent GLMask rendering routines
        # instead of using the GLVolume implementations

        dataMin, dataMax = overlay.dataRange
        dRangeLen = abs(dataMax - dataMin)
        dMinDistance = dRangeLen / 100.0

        self.clippingRange = (dataMin - 1, dataMax + 1)
        self.interpolation = 'none'
        self.invertClipping = False
        self.useNegativeCmap = False
        self.clipImage = None

        self.threshold.xmin = dataMin - dMinDistance
        self.threshold.xmax = dataMax + dMinDistance
        self.threshold.xlo = dataMin + dMinDistance
        self.threshold.xhi = dataMax + dMinDistance

        volumeopts.NiftiOpts.__init__(self, overlay, *args, **kwargs)

        overlay.register(self.name,
                         self.__dataRangeChanged,
                         topic='dataRange',
                         runOnIdle=True)

        # The master MaskOpts instance makes
        # sure that colour[3] and Display.alpha
        # are consistent w.r.t. each other.
        self.__registered = self.getParent() is None
        if self.__registered:
            self.display.addListener('alpha',
                                     self.name,
                                     self.__alphaChanged,
                                     immediate=True)
            self.addListener('colour',
                             self.name,
                             self.__colourChanged,
                             immediate=True)

    def destroy(self):
        """Removes some property listeners and calls
        :meth:`.NitfiOpts.destroy`.
        """

        self.overlay.deregister(self.name, topic='dataRange')

        if self.__registered:
            self.display.removeListener('alpha', self.name)
            self.removeListener('colour', self.name)

        volumeopts.NiftiOpts.destroy(self)

    def __dataRangeChanged(self, *a):
        """Called when the :attr:`~fsl.data.image.Image.dataRange` changes.
        Updates the :attr:`threshold` limits.
        """
        dmin, dmax = self.overlay.dataRange
        dRangeLen = abs(dmax - dmin)
        dminDistance = dRangeLen / 100.0

        self.threshold.xmin = dmin - dminDistance
        self.threshold.xmax = dmax + dminDistance

        # If the threshold was
        # previously unset, grow it
        if self.threshold.x == (0, 0):
            self.threshold.x = (0, dmax + dminDistance)

    def __colourChanged(self, *a):
        """Called when :attr:`.colour` changes. Updates :attr:`.Display.alpha`
        from the alpha component.
        """

        alpha = self.colour[3] * 100

        log.debug('Propagating MaskOpts.colour[3] to '
                  'Display.alpha [{}]'.format(alpha))

        with props.skip(self.display, 'alpha', self.name):
            self.display.alpha = alpha

    def __alphaChanged(self, *a):
        """Called when :attr:`.Display.alpha` changes. Updates the alpha
        component of :attr:`.colour`.
        """

        alpha = self.display.alpha / 100.0
        r, g, b, _ = self.colour

        log.debug('Propagating Display.alpha to MaskOpts.'
                  'colour[3] [{}]'.format(alpha))

        with props.skip(self, 'colour', self.name):
            self.colour = r, g, b, alpha
Esempio n. 10
0
class DisplayContext(props.SyncableHasProperties):
    """A ``DisplayContext`` instance contains a number of properties defining
    how the overlays in an :class:`.OverlayList` are to be displayed, and
    related contextual information.


    A ``DisplayContext`` instance is responsible for creating and destroying
    :class:`.Display` instances for every overlay in the
    ``OverlayList``. These ``Display`` instances, and the corresponding
    :class:`.DisplayOpts` instances (which, in turn, are created/destroyed by
    ``Display`` instances) can be accessed with the :meth:`getDisplay` and
    :meth:`getOpts` method respectively.


    A number of other useful methods are provided by a ``DisplayContext``
    instance:

    .. autosummary::
       :nosignatures:

        getDisplay
        getOpts
        getReferenceImage
        displayToWorld
        worldToDisplay
        displaySpaceIsRadiological
        selectOverlay
        getSelectedOverlay
        getOverlayOrder
        getOrderedOverlays
        freeze
        freezeOverlay
        thawOverlay
        defaultDisplaySpace
        detachDisplaySpace
    """

    selectedOverlay = props.Int(minval=0, default=0, clamped=True)
    """Index of the currently 'selected' overlay.

    .. note:: The value of this index is in relation to the
              :class:`.OverlayList`, rather than to the :attr:`overlayOrder`
              list.

              If you're interested in the currently selected overlay, you must
              also listen for changes to the :attr:`.OverlayList.images` list
              as, if the list changes, the :attr:`selectedOverlay` index may
              not change, but the overlay to which it points may be different.
    """

    location = props.Point(ndims=3)
    """The location property contains the currently selected 3D location (xyz)
    in the display coordinate system. Different ``DisplayContext`` instances
    may be using different display coordinate systems - see the
    :attr:`displaySpace` property.
    """

    worldLocation = props.Point(ndims=3)
    """The location property contains the currently selected 3D location (xyz)
    in the world coordinate system. Whenever the :attr:`location` changes, it
    gets transformed into the world coordinate system, and propagated to this
    property. The location of different ``DisplayContext`` instances is
    synchronised through this property.

    .. note:: If any :attr:`.NiftiOpts.transform` properties have been modified
              independently of the :attr:`displaySpace`, this value will be
              invalid.
    """

    bounds = props.Bounds(ndims=3)
    """This property contains the min/max values of a bounding box (in display
    coordinates) which is big enough to contain all of the overlays in the
    :class:`.OverlayList`.

    .. warning:: This property shouid be treated as read-only.
    """

    overlayOrder = props.List(props.Int())
    """A list of indices into the :attr:`.OverlayList.overlays`
    list, defining the order in which the overlays are to be displayed.

    See the :meth:`getOrderedOverlays` method.
    """

    overlayGroups = props.List()
    """A list of :class:`.OverlayGroup` instances, each of which defines
    a group of overlays which share display properties.
    """

    syncOverlayDisplay = props.Boolean(default=True)
    """If this ``DisplayContext`` instance has a parent (see
    :mod:`props.syncable`), and this property is ``True``, the properties of
    the :class:`.Display` and :class:`.DisplayOpts` instances for every
    overlay managed by this ``DisplayContext`` instance will be synchronised
    to those of the parent instance. Otherwise, the display properties for
    every overlay will be unsynchronised from the parent.

    Synchronisation of the following properties between child and parent
    ``DisplayContext`` instances is also controlled by this flag:

      - :attr:`displaySpace`
      - :attr:`bounds`
      - :attr:`radioOrientation`

    .. note:: This property is accessed by the :class:`.Display` class, in its
              constructor, and when it creates new :class:`.DisplayOpts`
              instances, to set initial sync states.
    """

    displaySpace = props.Choice(('world', ))
    """The *space* in which overlays are displayed. This property defines the
    display coordinate system for this ``DisplayContext``. When it is changed,
    the :attr:`.NiftiOpts.transform` property of all :class:`.Nifti` overlays
    in the :class:`.OverlayList` is updated. It has two settings, described
    below. The options for this property are dynamically added by
    :meth:`__updateDisplaySpaceOptions`.

    1. **World** space (a.k.a. ``'world'``)

       All :class:`.Nifti` overlays are displayed in the space defined by
       their affine transformation matrix - the :attr:`.NiftiOpts.transform`
       property for every ``Nifti`` overlay is set to ``affine``.

    2. **Reference image** space

       A single :class:`.Nifti` overlay is selected as a *reference* image,
       and is displayed in scaled voxel space (with a potential L/R flip for
       neurological images - its :attr:`.NiftiOpts.transform` is set to
       ``pixdim-flip``). All other ``Nifti`` overlays are transformed into
       this reference space - their :attr:`.NiftiOpts.transform` property is
       set to ``reference``, which results in them being transformed into the
       scaled voxel space of the reference image.

    .. note:: The :attr:`.NiftiOpts.transform` property of any
              :class:`.Nifti` overlay can be set independently of this
              property. However, whenever *this* property changes, it will
              change the ``transform`` property for every ``Nifti``, in the
              manner described above.

    The :meth:`defaultDisplaySpace` can be used to control how the
    ``displaySpace`` is initialised.
    """

    radioOrientation = props.Boolean(default=True)
    """If ``True``, 2D views will show images in radiological convention
    (i.e.subject left on the right of the display). Otherwise, they will be
    shown in neurological convention (subject left on the left).

    .. note:: This setting is not enforced by the ``DisplayContext``. It is
              the responsibility of the :class:`.OrthoPanel` and
              :class:`.LightBoxPanel` (and other potential future 2D view
              panels) to implement the flip.
    """

    autoDisplay = props.Boolean(default=False)
    """If ``True``, whenever an overlay is added to the :class:`.OverlayList`,
    the :mod:`autodisplay` module will be used to automatically configure
    its display settings. Note that the ``DisplayContext`` does not perform
    this configuration - this flag is used by other modules (e.g. the
    :class:`.OverlayListPanel` and the :class:`.OpenFileAction`).
    """

    loadInMemory = props.Boolean(default=False)
    """If ``True``, all :class:`.Image` instances will be loaded into memory,
    regardless of their size. Otherwise (the default), large compressed
    ``Image`` overlays may be kept on disk.


    .. note:: Changing the value of this property will not affect existing
              ``Image`` overlays.


    .. note:: This property may end up being used in a more general sense by
              any code which needs to decide whether to do things in a more
              or less memory-intensive manner.
    """
    def __init__(self, overlayList, parent=None, defaultDs='ref', **kwargs):
        """Create a ``DisplayContext``.

        :arg overlayList: An :class:`.OverlayList` instance.

        :arg parent:      Another ``DisplayContext`` instance to be used
                          as the parent of this instance, passed to the
                          :class:`.SyncableHasProperties` constructor.

        :arg defaultDs:   Initial value for the :meth:`defaultDisplaySpace`.
                          Either ``'ref'`` or ``'world'``. If ``'ref'`` (the
                          default), when overlays are added to an empty list,
                          the :attr:`displaySpace` will be set to the first
                          :class:`.Nifti` overlay. Otherwise (``'world'``),
                          the display space will be set to ``'world'``.

        All other arguments are passed through to the ``SyncableHasProperties``
        constructor, in addition to the following:

          - The ``syncOverlayDisplay`` and ``location`` properties
            are added to the ``nobind`` argument

          - The ``selectedOverlay``, ``overlayGroups``,
            ``autoDisplay`` and ``loadInMemory`` properties
            are added to the ``nounbind`` argument.
        """

        kwargs = dict(kwargs)

        nobind = kwargs.pop('nobind', [])
        nounbind = kwargs.pop('nounbind', [])

        nobind.extend(['syncOverlayDisplay', 'location', 'bounds'])
        nounbind.extend([
            'selectedOverlay', 'overlayGroups', 'autoDisplay', 'loadInMemory'
        ])

        kwargs['parent'] = parent
        kwargs['nobind'] = nobind
        kwargs['nounbind'] = nounbind
        kwargs['state'] = {'overlayOrder': False}

        props.SyncableHasProperties.__init__(self, **kwargs)

        self.__overlayList = overlayList
        self.__name = '{}_{}'.format(self.__class__.__name__, id(self))
        self.__child = parent is not None

        # When the first overlay(s) is/are
        # added, the display space may get
        # set either to a reference image,
        # or to world. The defaultDisplaySpace
        # controls this behaviour.
        self.defaultDisplaySpace = defaultDs

        # The overlayOrder is unsynced by
        # default, but we will inherit the
        # current parent value.
        if self.__child: self.overlayOrder[:] = parent.overlayOrder[:]
        else: self.overlayOrder[:] = range(len(overlayList))

        # If this is the first child DC, we
        # need to initialise the display
        # space and location. If there is
        # already a child DC, then we have
        # (probably) inherited initial
        # settings.
        if self.__child:
            self.__initDS = (len(parent.getChildren()) - 1) == 0

        # This dict contains the Display
        # objects for every overlay in
        # the overlay list, as
        # {Overlay : Display} mappings
        self.__displays = {}

        overlayList.addListener('overlays',
                                self.__name,
                                self.__overlayListChanged,
                                immediate=True)

        if self.__child:
            self.addListener('syncOverlayDisplay', self.__name,
                             self.__syncOverlayDisplayChanged)
            self.addListener('displaySpace',
                             self.__name,
                             self.__displaySpaceChanged,
                             immediate=True)
            self.addListener('location',
                             self.__name,
                             self.__locationChanged,
                             immediate=True)
            self.addListener('worldLocation',
                             self.__name,
                             self.__worldLocationChanged,
                             immediate=True)

        # The overlayListChanged method
        # is important - check it out
        self.__overlayListChanged()

        log.debug('{}.init ({})'.format(type(self).__name__, id(self)))

    def __del__(self):
        """Prints a log message."""
        if log:
            log.debug('{}.del ({})'.format(type(self).__name__, id(self)))

    def destroy(self):
        """This method must be called when this ``DisplayContext`` is no
        longer needed.

        When a ``DisplayContext`` is destroyed, all of the :class:`.Display`
        instances managed by it are destroyed as well.
        """

        self.detachAllFromParent()

        overlayList = self.__overlayList
        displays = self.__displays

        self.__overlayList = None
        self.__displays = None

        overlayList.removeListener('overlays', self.__name)

        if self.__child:
            self.removeListener('syncOverlayDisplay', self.__name)
            self.removeListener('displaySpace', self.__name)
            self.removeListener('location', self.__name)
            self.removeListener('worldLocation', self.__name)

        for overlay, display in displays.items():
            display.destroy()

    def destroyed(self):
        """Returns ``True`` if this ``DisplayContext`` has been, or is being,
        destroyed, ``False`` otherwise.
        """
        return self.__overlayList is None

    def getDisplay(self, overlay, overlayType=None):
        """Returns the :class:`.Display` instance for the specified overlay
        (or overlay index).

        If the overlay is not in the ``OverlayList``, an
        :exc:`InvalidOverlayError` is raised.  Otheriwse, if a
        :class:`Display` object does not exist for the given overlay, one is
        created.

        If this ``DisplayContext`` has been destroyed, a ``ValueError`` is
        raised.

        :arg overlay:     The overlay to retrieve a ``Display`` instance for,
                          or an index into the ``OverlayList``.

        :arg overlayType: If a ``Display`` instance for the specified
                          ``overlay`` does not exist, one is created - in
                          this case, the specified ``overlayType`` is passed
                          to the :class:`.Display` constructor.
        """

        if overlay is None:
            raise ValueError('No overlay specified')

        if self.destroyed():
            raise ValueError('DisplayContext has been destroyed')

        if overlay not in self.__overlayList:
            raise InvalidOverlayError('Overlay {} is not in '
                                      'list'.format(overlay.name))

        if isinstance(overlay, int):
            overlay = self.__overlayList[overlay]

        try:
            display = self.__displays[overlay]

        except KeyError:

            if not self.__child:
                dParent = None
            else:
                dParent = self.getParent().getDisplay(overlay, overlayType)
                if overlayType is None:
                    overlayType = dParent.overlayType

            from .display import Display

            display = Display(overlay,
                              self.__overlayList,
                              self,
                              parent=dParent,
                              overlayType=overlayType)
            self.__displays[overlay] = display

        return display

    def getOpts(self, overlay, overlayType=None):
        """Returns the :class:`.DisplayOpts` instance associated with the
        specified overlay.  See :meth:`getDisplay` and :meth:`.Display.opts`
        for more details.
        """

        if overlay is None:
            raise ValueError('No overlay specified')

        if self.destroyed():
            raise ValueError('DisplayContext has been destroyed')

        if overlay not in self.__overlayList:
            raise InvalidOverlayError('Overlay {} is not in '
                                      'list'.format(overlay.name))

        return self.getDisplay(overlay, overlayType).opts

    def getReferenceImage(self, overlay):
        """Convenience method which returns the reference image associated
        with the given overlay, or ``None`` if there is no reference image.

        See the :class:`.DisplayOpts.referenceImage` method.
        """
        if overlay is None:
            return None

        return self.getOpts(overlay).referenceImage

    def displayToWorld(self, dloc):
        """Transforms the given coordinates from the display coordinate
        system into the world coordinate system.

        .. warning:: If any :attr:`.NiftiOpts.transform` properties have
                     been modified manually, this method will return invalid
                     results.
        """

        displaySpace = self.displaySpace

        if displaySpace == 'world' or len(self.__overlayList) == 0:
            return dloc

        opts = self.getOpts(displaySpace)

        return opts.transformCoords(dloc, 'display', 'world')

    def worldToDisplay(self, wloc):
        """Transforms the given coordinates from the world coordinate
        system into the display coordinate system.

        .. warning:: If any :attr:`.NiftiOpts.transform` properties have
                     been modified manually, this method will return invalid
                     results.
        """

        displaySpace = self.displaySpace

        if displaySpace == 'world' or len(self.__overlayList) == 0:
            return wloc

        opts = self.getOpts(displaySpace)

        return opts.transformCoords(wloc, 'world', 'display')

    def displaySpaceIsRadiological(self):
        """Returns ``True`` if the current :attr:`displaySpace` aligns with
        a radiological orientation. A radiological orientation is one in
        which anatomical right is shown on the left of the screen, i.e.:

          - The X axis corresponds to right -> left
          - The Y axis corresponds to posterior -> anterior
          - The Z axis corresponds to inferior -> superior
        """

        if len(self.__overlayList) == 0:
            return True

        space = self.displaySpace

        # Display space is either 'world', or an image.
        # We assume that 'world' is an RAS coordinate
        # system which, if transferred directly to a
        # display coordinate system, would result in a
        # neurological view (left on left, right on
        # right).
        if space == 'world':
            return False
        else:
            opts = self.getOpts(space)
            xform = opts.getTransform('pixdim-flip', 'display')

            return npla.det(xform) > 0

    def selectOverlay(self, overlay):
        """Selects the specified ``overlay``. Raises an :exc:`IndexError` if
        the overlay is not in the list.

        If you want to select an overlay by its index in the ``OverlayList``,
        you can just assign to the :attr:`selectedOverlay` property directly.
        """
        self.selectedOverlay = self.__overlayList.index(overlay)

    def getSelectedOverlay(self):
        """Returns the currently selected overlay object,
        or ``None`` if there are no overlays.
        """
        if len(self.__overlayList) == 0: return None
        if self.selectedOverlay >= len(self.__overlayList): return None

        return self.__overlayList[self.selectedOverlay]

    def getOverlayOrder(self, overlay):
        """Returns the order in which the given overlay (or an index into
        the :class:`.OverlayList` list) should be displayed
        (see the :attr:`overlayOrder` property).

        Raises an :exc:`IndexError` if the overlay is not in the list.
        """
        self.__syncOverlayOrder()

        if not isinstance(overlay, int):
            overlay = self.__overlayList.index(overlay)

        return self.overlayOrder.index(overlay)

    def getOrderedOverlays(self):
        """Returns a list of overlay objects from the :class:`.OverlayList`
        list, sorted into the order that they should be displayed, as defined
        by the :attr:`overlayOrder` property.
        """
        self.__syncOverlayOrder()

        return [self.__overlayList[idx] for idx in self.overlayOrder]

    @contextlib.contextmanager
    def freeze(self, overlay):
        """This method can be used as a context manager to suppress
        notification for all :class:`.Display` and :class:`.DisplayOpts`
        properties related to the given ``overlay``::

            with displayCtx.freeze(overlay):
                # Do stuff which might trigger unwanted
                # Display/DisplayOpts notifications

        See :meth:`freezeOverlay` and :meth:`thawOverlay`.
        """
        self.freezeOverlay(overlay)

        try:
            yield

        finally:
            self.thawOverlay(overlay)

    def freezeOverlay(self, overlay):
        """Suppresses notification for all :class:`.Display` and
        :class:`.DisplayOpts` properties associated with the given ``overlay``.
        Call :meth:`.thawOverlay` to re-enable notification.

        See also the :meth:`freeze` method, which can be used as a context
        manager to automatically call this method and ``thawOverlay``.
        """
        if self.__child:
            self.getParent().freezeOverlay(overlay)
            return

        dctxs = [self] + self.getChildren()

        for dctx in dctxs:
            display = dctx.getDisplay(overlay)
            opts = display.opts

            display.disableAllNotification()
            opts.disableAllNotification()

    def thawOverlay(self, overlay):
        """Enables notification for all :class:`.Display` and
        :class:`.DisplayOpts` properties associated with the given ``overlay``.
        """

        if self.__child:
            self.getParent().thawOverlay(overlay)
            return
        dctxs = [self] + self.getChildren()

        for dctx in dctxs:
            display = dctx.getDisplay(overlay)
            opts = display.opts

            display.enableAllNotification()
            opts.enableAllNotification()

    @property
    def defaultDisplaySpace(self):
        """This property controls how the :attr:`displaySpace` is initialised
        when overlays are added to a previously empty :class:`.OverlayList`.
        If the ``defaultDisplaySpace`` is set to ``'ref'``, the
        ``displaySpace`` will be initialised to the first :class:`.Nifti`
        overlay. Otherwise (the ``defaultDisplaySpace`` is set to ``'world'``),
        the ``displaySpace`` will be set to ``'world'``.
        """
        return self.__defaultDisplaySpace

    @defaultDisplaySpace.setter
    def defaultDisplaySpace(self, ds):
        """Sets the :meth:`defaultDisplaySpace`.

        :arg ds: Either ``'ref'`` or ``'world'``.
        """
        if ds not in ('world', 'ref'):
            raise ValueError('Invalid default display space: {}'.format(ds))
        self.__defaultDisplaySpace = ds

    def detachDisplaySpace(self):
        """Detaches the :attr:`displaySpace` and :attr:`bounds` properties,
        and all related :class:`.DisplayOpts` properties, from the parent
        ``DisplayContext``.

        This allows this ``DisplayContext`` to use a display coordinate
        system that is completely independent from other instances, and is not
        affected by changes to the parent properties.

        This is an irreversible operation.
        """

        self.detachFromParent('displaySpace')
        self.detachFromParent('bounds')

        for ovl in self.__overlayList:

            opts = self.getOpts(ovl)

            opts.detachFromParent('bounds')

            if isinstance(ovl, fslimage.Nifti):
                opts.detachFromParent('transform')

    def __overlayListChanged(self, *a):
        """Called when the :attr:`.OverlayList.overlays` property
        changes.

        Ensures that a :class:`.Display` and :class:`.DisplayOpts` object
        exists for every image, updates the :attr:`bounds` property, makes
        sure that the :attr:`overlayOrder` property is consistent, and updates
        constraints on the :attr:`selectedOverlay` property.
        """

        # Discard all Display instances
        # which refer to overlays that
        # are no longer in the list
        for overlay in list(self.__displays.keys()):
            if overlay not in self.__overlayList:

                display = self.__displays.pop(overlay)
                opts = display.opts

                display.removeListener('overlayType', self.__name)
                opts.removeListener('bounds', self.__name)

                # The display instance will destroy the
                # opts instance, so we don't do it here
                display.destroy()

        # Ensure that a Display object exists
        # for every overlay in the list
        for overlay in self.__overlayList:

            ovlType = self.__overlayList.initOverlayType(overlay)

            # The getDisplay method
            # will create a Display object
            # if one does not already exist
            display = self.getDisplay(overlay, ovlType)
            opts = display.opts

            # Register a listener on the overlay type,
            # because when it changes, the DisplayOpts
            # instance will change, and we will need
            # to re-register the DisplayOpts.bounds
            # listener (see the next statement)
            display.addListener('overlayType',
                                self.__name,
                                self.__overlayListChanged,
                                overwrite=True)

            # Register a listener on the DisplayOpts.bounds
            # property for every overlay - if the display
            # bounds for any overlay changes, we need to
            # update our own bounds property. This is only
            # done on child DCs, as the parent DC bounds
            # only gets used for synchronisation
            if self.__child:
                opts.addListener('bounds',
                                 self.__name,
                                 self.__overlayBoundsChanged,
                                 overwrite=True)

                # If detachDisplaySpace has been called,
                # make sure the opts bounds (and related)
                # properties are also detached
                if not self.canBeSyncedToParent('displaySpace'):
                    opts.detachFromParent('bounds')
                    if isinstance(overlay, fslimage.Nifti):
                        opts.detachFromParent('transform')

        # Ensure that the displaySpace
        # property options are in sync
        # with the overlay list.
        self.__updateDisplaySpaceOptions()

        # Stuff which only needs to
        # be done on the parent DC
        if not self.__child:

            # Limit the selectedOverlay property
            # so it cannot take a value greater
            # than len(overlayList)-1. selectedOverlay
            # is always synchronised, so we only
            # need to do this on the parent DC.
            nOverlays = len(self.__overlayList)
            if nOverlays > 0:
                self.setAttribute('selectedOverlay', 'maxval', nOverlays - 1)
            else:
                self.setAttribute('selectedOverlay', 'maxval', 0)

            return

        # Ensure that the overlayOrder
        # property is valid
        self.__syncOverlayOrder()

        # If the overlay list was empty,
        # and is now non-empty, we need
        # to initialise the display space
        # and the display location
        initDS        = self.__initDS                      and \
                        np.all(np.isclose(self.bounds, 0)) and \
                        len(self.__overlayList) > 0
        self.__initDS = len(self.__overlayList) == 0

        # Initialise the display space. We
        # have to do this before updating
        # image transforms, and updating
        # the display bounds
        if initDS:

            displaySpace = 'world'

            if self.defaultDisplaySpace == 'ref':
                for overlay in self.__overlayList:
                    if isinstance(overlay, fslimage.Nifti):
                        displaySpace = overlay
                        break

            with props.skip(self, 'displaySpace', self.__name):
                self.displaySpace = displaySpace

        # Initialise the transform property
        # of any Image overlays which have
        # just been added to the list,
        oldList = self.__overlayList.getLastValue('overlays')[:]
        for overlay in self.__overlayList:
            if isinstance(overlay, fslimage.Nifti) and \
               (overlay not in oldList):
                self.__setTransform(overlay)

        # Ensure that the bounds
        # property is accurate
        self.__updateBounds()

        # Initialise the display location to
        # the centre of the display bounds
        if initDS:
            b = self.bounds
            self.location.xyz = [
                b.xlo + b.xlen / 2.0, b.ylo + b.ylen / 2.0,
                b.zlo + b.zlen / 2.0
            ]
            self.__propagateLocation('world')
        else:
            self.__propagateLocation('display')

    def __updateDisplaySpaceOptions(self):
        """Updates the :attr:`displaySpace` property so it is synchronised with
        the current contents of the :class:`.OverlayList`

        This method is called by the :meth:`__overlayListChanged` method.
        """

        choiceProp = self.getProp('displaySpace')
        choices = []

        for overlay in self.__overlayList:
            if isinstance(overlay, fslimage.Nifti):
                choices.append(overlay)

        choices.append('world')

        choiceProp.setChoices(choices, instance=self)

    def __setTransform(self, image):
        """Sets the :attr:`.NiftiOpts.transform` property associated with
        the given :class:`.Nifti` overlay to a sensible value, given the
        current value of the :attr:`.displaySpace` property.

        Called by the :meth:`__displaySpaceChanged` method, and by
        :meth:`__overlayListChanged` for any :class:`.Image` overlays which
        have been newly added to the :class:`.OverlayList`.

        :arg image: An :class:`.Image` overlay.
        """

        space = self.displaySpace
        opts = self.getOpts(image)

        # Disable notification of the bounds
        # property so the __overlayBoundsChanged
        # method does not get called. Use
        # ignoreInvalid, because this method might
        # get called before we have registered a
        # listener on the bounds property.
        with props.skip(opts, 'bounds', self.__name, ignoreInvalid=True):
            if space == 'world': opts.transform = 'affine'
            elif image is space: opts.transform = 'pixdim-flip'
            else: opts.transform = 'reference'

    def __displaySpaceChanged(self, *a):
        """Called when the :attr:`displaySpace` property changes. Updates the
        :attr:`.NiftiOpts.transform` property for all :class:`.Nifti`
        overlays in the :class:`.OverlayList`.
        """

        selectedOverlay = self.getSelectedOverlay()

        if selectedOverlay is None:
            return

        # Update the transform property of all
        # Image overlays to put them into the
        # new display space
        for overlay in self.__overlayList:

            if not isinstance(overlay, fslimage.Nifti):
                continue

            self.__setTransform(overlay)

        # Update the display world bounds,
        # and then update the location
        self.__updateBounds()

        # Make sure that the location is
        # kept in the same place, relative
        # to the world coordinate system
        self.__propagateLocation('display')

    def __syncOverlayOrder(self):
        """Ensures that the :attr:`overlayOrder` property is up to date
        with respect to the :class:`.OverlayList`.
        """

        if len(self.overlayOrder) == len(self.__overlayList):
            return

        #
        # NOTE: The following logic assumes that operations
        #       which modify the overlay list will only do
        #       one of the following:
        #
        #        - Adding one or more overlays to the end of the list
        #        - Removing one or more overlays from the list
        #
        # More complex overlay list modifications
        # will cause this code to break.

        oldList = self.__overlayList.getLastValue('overlays')[:]
        oldOrder = self.overlayOrder[:]

        # If the overlay order was just the
        # list order, preserve that ordering
        if self.overlayOrder[:] == list(range(len(oldList))):
            self.overlayOrder[:] = list(range(len(self.__overlayList)))

        # If overlays have been added to
        # the overlay list, add indices
        # for them to the overlayOrder list
        elif len(self.overlayOrder) < len(self.__overlayList):

            newOrder = []
            newOverlayIdx = len(oldList)

            # The order of existing overlays is preserved,
            # and all new overlays added to the end of the
            # overlay order.
            for overlay in self.__overlayList:

                if overlay in oldList:
                    newOrder.append(oldOrder[oldList.index(overlay)])
                else:
                    newOrder.append(newOverlayIdx)
                    newOverlayIdx += 1

            self.overlayOrder[:] = newOrder

        # Otherwise, if overlays have been
        # removed from the overlay list ...
        elif len(self.overlayOrder) > len(self.__overlayList):

            # Remove the corresponding indices
            # from the overlayOrder list
            for i, overlay in enumerate(oldList):
                if overlay not in self.__overlayList:
                    oldOrder.remove(i)

            # Re-generate new indices,
            # preserving the order of
            # the remaining overlays
            newOrder = [sorted(oldOrder).index(idx) for idx in oldOrder]
            self.overlayOrder[:] = newOrder

    def __overlayBoundsChanged(self, value, valid, opts, name):
        """Called when the :attr:`.DisplayOpts.bounds` property of any
        overlay changes. Updates the :attr:`bounds` property and preserves
        the display :attr:`location` in terms of the :attr:`worldLocation`.
        """

        # This method might get called
        # after DisplayOpts instance
        # has been destroyed
        if opts.display is None:
            return

        # Update the display context bounds
        # to take into account any changes
        # to individual overlay bounds.
        # Inhibit notification on the location
        # property - it will be updated properly
        # below
        self.__updateBounds()

        # Make sure the display location
        # is consistent w.r.t. the world
        # coordinate location
        self.__propagateLocation('display')

    def __syncOverlayDisplayChanged(self, *a):
        """Called when the :attr:`syncOverlayDisplay` property
        changes.

        Synchronises or unsychronises the :class:`.Display` and
        :class:`.DisplayOpts` instances for every overlay to/from their
        parent instances.
        """

        dcProps = ['displaySpace', 'bounds', 'radioOrientation']

        if self.syncOverlayDisplay:
            for p in dcProps:
                if self.canBeSyncedToParent(p):
                    self.syncToParent(p)

        else:
            for p in dcProps:
                if self.canBeUnsyncedFromParent(p):
                    self.unsyncFromParent(p)

        for display in self.__displays.values():

            opts = display.opts

            if self.syncOverlayDisplay:
                display.syncAllToParent()
                opts.syncAllToParent()
            else:
                display.unsyncAllFromParent()
                opts.unsyncAllFromParent()

    def __updateBounds(self, *a):
        """Called when the overlay list changes, or when any overlay display
        transform is changed. Updates the :attr:`bounds` property so that it
        is big enough to contain all of the overlays (as defined by their
        :attr:`.DisplayOpts.bounds` properties).
        """

        if len(self.__overlayList) == 0:
            minBounds = [0.0, 0.0, 0.0]
            maxBounds = [0.0, 0.0, 0.0]

        else:
            minBounds = 3 * [sys.float_info.max]
            maxBounds = 3 * [-sys.float_info.max]

        for ovl in self.__overlayList:

            display = self.__displays[ovl]
            opts = display.opts
            lo = opts.bounds.getLo()
            hi = opts.bounds.getHi()

            for ax in range(3):

                if lo[ax] < minBounds[ax]: minBounds[ax] = lo[ax]
                if hi[ax] > maxBounds[ax]: maxBounds[ax] = hi[ax]

        self.bounds[:] = [
            minBounds[0], maxBounds[0], minBounds[1], maxBounds[1],
            minBounds[2], maxBounds[2]
        ]

        # Update the constraints on the location
        # property to be aligned with the new bounds
        with props.suppress(self, 'location'):
            self.location.setLimits(0, self.bounds.xlo, self.bounds.xhi)
            self.location.setLimits(1, self.bounds.ylo, self.bounds.yhi)
            self.location.setLimits(2, self.bounds.zlo, self.bounds.zhi)

    def __locationChanged(self, *a):
        """Called when the :attr:`location` property changes. Propagates
        the new location to the :attr:`worldLocation` property.
        """
        self.__propagateLocation('world')

    def __worldLocationChanged(self, *a):
        """Called when the :attr:`worldLocation` property changes. Propagates
        the new location to the :attr:`location` property.
        """

        self.__propagateLocation('display')

    def __propagateLocation(self, dest):
        """Called by the :meth:`__locationChanged` and
        :meth:`__worldLocationChanged` methods. The ``dest`` argument may be
        either ``'world'`` (the ``worldLocation`` is updated from the
        ``location``), or ``'display'`` (vice-versa).
        """

        if self.displaySpace == 'world':
            if dest == 'world':
                with props.skip(self, 'worldLocation', self.__name):
                    self.worldLocation = self.location
            else:
                with props.skip(self, 'location', self.__name):
                    self.location = self.worldLocation
            return

        ref = self.displaySpace
        opts = self.getOpts(ref)

        if dest == 'world':
            with props.skip(self, 'location', self.__name):
                self.worldLocation = opts.transformCoords(
                    self.location, 'display', 'world')
        else:
            with props.skip(self, 'worldLocation', self.__name):
                self.location = opts.transformCoords(self.worldLocation,
                                                     'world', 'display')
Esempio n. 11
0
class NiftiOpts(fsldisplay.DisplayOpts):
    """The ``NiftiOpts`` class describes how a :class:`.Nifti` overlay
    should be displayed.


    ``NiftiOpts`` is the base class for a number of :class:`.DisplayOpts`
    sub-classes - it contains display options which are common to all overlay
    types that represent a NIFTI image.
    """

    volume = props.Int(minval=0, maxval=0, default=0, clamped=True)
    """If the ``Image`` has more than 3 dimensions, the current volume to
    display. The volume dimension is controlled by the :attr:`volumeDim`
    property.
    """

    volumeDim = props.Int(minval=0, maxval=5, default=0, clamped=True)
    """For images with more than three dimensions, this property controls
    the dimension that the :attr:`volume` property indexes into. When the
    ``volumeDim`` changes, the ``volume`` for the previous ``volumeDim``
    is fixed at its last value, and used for subsequent lookups.
    """

    transform = props.Choice(
        ('affine', 'pixdim', 'pixdim-flip', 'id', 'reference'),
        default='pixdim-flip')
    """This property defines how the overlay should be transformd into
    the display coordinate system. See the
    :ref:`note on coordinate systems <volumeopts-coordinate-systems>`
    for important information regarding this property.
    """

    displayXform = props.Array(dtype=np.float64,
                               shape=(4, 4),
                               resizable=False,
                               default=[[1, 0, 0, 0], [0, 1, 0, 0],
                                        [0, 0, 1, 0], [0, 0, 0, 1]])
    """A custom transformation matrix which is concatenated on to the voxel ->
    world transformation of the :class:`.Nifti` overlay.

    This transform is intended for temporary changes to the overlay display
    (when :attr:`.DisplayContext.displaySpace` ``== 'world'``) - changes to it
    will *not* result in the ::attr:`.DisplayContext.bounds` being updated.

    If you change the ``displayXform``, make sure to change it back to an
    identity matrix when you are done.
    """

    enableOverrideDataRange = props.Boolean(default=False)
    """By default, the :attr:`.Image.dataRange` property is used to set
    display and clipping ranges. However, if this property is ``True``,
    the :attr:`overrideDataRange` is used instead.

    ..note:: The point of this property is to make it easier to display images
             with a very large data range driven by outliers. On platforms
             which do not support floating point textures, these images are
             impossible to display unless they are normalised according to
             a smaller data range. See the
             :meth:`.Texture3D.__determineTextureType` method for some more
             details.
    """

    overrideDataRange = props.Bounds(ndims=1, clamped=False)
    """Data range used in place of the :attr:`.Image.dataRange` if the
    :attr:`enableOverrideDataRange` property is ``True``.
    """
    def __init__(self, *args, **kwargs):
        """Create a ``NiftiOpts`` instance.

        All arguments are passed through to the :class:`.DisplayOpts`
        constructor.
        """

        nounbind = kwargs.get('nounbind', [])
        nobind = kwargs.get('nobind', [])

        nounbind.append('overrideDataRange')
        nounbind.append('enableOverrideDataRange')
        nobind.append('displayXform')

        kwargs['nounbind'] = nounbind
        kwargs['nobind'] = nobind

        fsldisplay.DisplayOpts.__init__(self, *args, **kwargs)

        self.__child = self.getParent() is not None

        if self.__child:

            # is this a >3D volume?
            ndims = self.overlay.ndim

            # We store indices for every dimension
            # past the XYZ dims. Whenever the volumeDim
            # changes, we cache the index for the old
            # dimensions, and restore the index for the
            # new dimension.
            self.setAttribute('volumeDim', 'maxval', max(0, ndims - 4))
            self.setAttribute('volume', 'cache', [0] * (ndims - 3))

            if ndims <= 3:
                self.setAttribute('volume', 'maxval', 0)

            self.overlay.register(self.name,
                                  self.__overlayTransformChanged,
                                  topic='transform')
            self.addListener('volumeDim',
                             self.name,
                             self.__volumeDimChanged,
                             immediate=True)
            self.addListener('transform',
                             self.name,
                             self.__transformChanged,
                             immediate=True)
            self.addListener('displayXform',
                             self.name,
                             self.__displayXformChanged,
                             immediate=True)
            self.displayCtx.addListener('displaySpace',
                                        self.name,
                                        self.__displaySpaceChanged,
                                        immediate=True)

            # The display<->* transformation matrices
            # are created in the _setupTransforms method.
            # The __displaySpaceChanged method registers
            # a listener with the current display space
            # (if it is an overlay)
            self.__xforms = {}
            self.__dsOverlay = None
            self.__setupTransforms()
            self.__transformChanged()
            self.__volumeDimChanged()

    def destroy(self):
        """Calls the :meth:`.DisplayOpts.destroy` method. """

        if self.__child:
            self.overlay.deregister(self.name, topic='transform')
            self.displayCtx.removeListener('displaySpace', self.name)
            self.removeListener('volumeDim', self.name)
            self.removeListener('transform', self.name)
            self.removeListener('displayXform', self.name)

            if self.__dsOverlay is not None:
                self.__dsOverlay.deregister(self.name, topic='transform')
                self.__dsOverlay = None

        fsldisplay.DisplayOpts.destroy(self)

    def __toggleSiblingListeners(self, enable=True):
        """Enables/disables the ``volumeDim`` listeners of sibling
        ``NiftiOpts`` instances. This is used by the :meth:`__volumeDimChanged`
        method to avoid nastiness.
        """
        for s in self.getParent().getChildren():
            if s is not self:
                if enable: s.enableListener('volumeDim', s.name)
                else: s.disableListener('volumeDim', s.name)

    def __volumeDimChanged(self, *a):
        """Called when the :attr:`volumeDim` changes. Saves the value of
        ``volume`` for the last ``volumeDim``, and restores the previous
        value of ``volume`` for the new ``volumeDim``.
        """

        if self.overlay.ndim <= 3:
            return

        # Here we disable volumeDim listeners on all
        # sibling instances, then save/restore the
        # volume value and properties asynchronously,
        # then re-enable the slblings.  This is a
        # horrible means of ensuring that only the
        # first VolumeOpts instance (out of a set of
        # synchronised instances) updates the volume
        # value and properties. The other instances
        # will be updated through synchronisation.
        # This is necessary because subsequent
        # instances would corrupt the update made by
        # the first instance.
        #
        # A nicer way to do things like this would be
        # nice.
        def update():

            oldVolume = self.volume
            oldVolumeDim = self.getLastValue('volumeDim')

            if oldVolumeDim is None:
                oldVolumeDim = 0

            cache = list(self.getAttribute('volume', 'cache'))
            cache[oldVolumeDim] = oldVolume
            newVolume = cache[self.volumeDim]
            newVolumeLim = self.overlay.shape[self.volumeDim + 3] - 1

            self.setAttribute('volume', 'maxval', newVolumeLim)
            self.setAttribute('volume', 'cache', cache)
            self.volume = newVolume

        self.__toggleSiblingListeners(False)
        props.safeCall(update)
        props.safeCall(self.__toggleSiblingListeners, True)

    def __overlayTransformChanged(self, *a):
        """Called when the :class:`.Nifti` overlay sends a notification
        on the ``'transform'`` topic, indicating that its voxel->world
        transformation matrix has been updated.
        """
        self.__setupTransforms()
        self.__transformChanged()

    def __displaySpaceTransformChanged(self, *a):
        """Called when the :attr:`.DisplayContext.displaySpace` is a
        :class:`.Nifti`  overlay, and its :attr:`.Nifti.voxToWorldMat`
        changes. Updates the transformation matrices for this image.
        """
        self.__setupTransforms()
        self.__transformChanged()

    def __transformChanged(self, *a):
        """Called when the :attr:`transform` property changes.

        Calculates the min/max values of a 3D bounding box, in the display
        coordinate system, which is big enough to contain the image. Sets the
        :attr:`.DisplayOpts.bounds` property accordingly.
        """

        lo, hi = affine.axisBounds(self.overlay.shape[:3],
                                   self.getTransform('voxel', 'display'))

        self.bounds[:] = [lo[0], hi[0], lo[1], hi[1], lo[2], hi[2]]

    def __displaySpaceChanged(self, *a):
        """Called when the :attr:`.DisplayContext.displaySpace` property
        changes.  Re-generates transformation matrices, and re-calculates
        the display :attr:`bounds` (via calls to :meth:`__setupTransforms` and
        :meth:`__transformChanged`).
        """

        displaySpace = self.displayCtx.displaySpace

        if self.__dsOverlay is not None:
            self.__dsOverlay.deregister(self.name, topic='transform')
            self.__dsOverlay = None

        # Register a listener on the display space reference
        # image, because when its voxToWorldMat changes, we
        # need to update our *toref and refto* transforms.
        if isinstance(displaySpace, fslimage.Nifti) and \
           displaySpace is not self.overlay:
            self.__dsOverlay = displaySpace
            self.__dsOverlay.register(self.name,
                                      self.__displaySpaceTransformChanged,
                                      topic='transform')

        self.__setupTransforms()
        if self.transform == 'reference':
            self.__transformChanged()

    def __displayXformChanged(self, *a):
        """Called when the :attr:`displayXform` property changes. Updates
        the transformation matrices and :attr:`bounds` accordingly.

        Critically, when the :attr:`displayXform` property changes, the
        :class:`.DisplayContext` is *not* notified. This is because
        the ``displayXform`` is intended for temporary changes.
        """

        # The displayXform is intended as a temporary
        # transformation for display purposes - the
        # DisplayOpts.bounds property gets updated when
        # it changes, but we don't want the
        # DisplayContext.bounds property to be updated.
        # So we suppress all notification while
        # updating the transformation matrices.
        with self.displayCtx.freeze(self.overlay):
            self.__setupTransforms()
            self.__transformChanged()

    def __setupTransforms(self):
        """Calculates transformation matrices between all of the possible
        spaces in which the overlay may be displayed.

        These matrices are accessible via the :meth:`getTransform` method.
        """

        image = self.overlay
        shape = np.array(image.shape[:3])

        voxToIdMat = np.eye(4)
        voxToPixdimMat = np.diag(list(image.pixdim[:3]) + [1.0])
        voxToPixFlipMat = image.voxToScaledVoxMat
        voxToWorldMat = image.voxToWorldMat
        voxToWorldMat = affine.concat(self.displayXform, voxToWorldMat)
        ds = self.displayCtx.displaySpace

        # The reference transforms depend
        # on the value of displaySpace
        if ds == 'world':
            voxToRefMat = voxToWorldMat
        elif ds is self.overlay:
            voxToRefMat = voxToPixFlipMat
        else:
            voxToRefMat = affine.concat(ds.voxToScaledVoxMat, ds.worldToVoxMat,
                                        voxToWorldMat)

        # When going from voxels to textures,
        # we add 0.5 to centre the voxel (see
        # the note on coordinate systems at
        # the top of this file).
        voxToTexMat = affine.scaleOffsetXform(tuple(1.0 / shape),
                                              tuple(0.5 / shape))

        idToVoxMat = affine.invert(voxToIdMat)
        idToPixdimMat = affine.concat(voxToPixdimMat, idToVoxMat)
        idToPixFlipMat = affine.concat(voxToPixFlipMat, idToVoxMat)
        idToWorldMat = affine.concat(voxToWorldMat, idToVoxMat)
        idToRefMat = affine.concat(voxToRefMat, idToVoxMat)
        idToTexMat = affine.concat(voxToTexMat, idToVoxMat)

        pixdimToVoxMat = affine.invert(voxToPixdimMat)
        pixdimToIdMat = affine.concat(voxToIdMat, pixdimToVoxMat)
        pixdimToPixFlipMat = affine.concat(voxToPixFlipMat, pixdimToVoxMat)
        pixdimToWorldMat = affine.concat(voxToWorldMat, pixdimToVoxMat)
        pixdimToRefMat = affine.concat(voxToRefMat, pixdimToVoxMat)
        pixdimToTexMat = affine.concat(voxToTexMat, pixdimToVoxMat)

        pixFlipToVoxMat = affine.invert(voxToPixFlipMat)
        pixFlipToIdMat = affine.concat(voxToIdMat, pixFlipToVoxMat)
        pixFlipToPixdimMat = affine.concat(voxToPixdimMat, pixFlipToVoxMat)
        pixFlipToWorldMat = affine.concat(voxToWorldMat, pixFlipToVoxMat)
        pixFlipToRefMat = affine.concat(voxToRefMat, pixFlipToVoxMat)
        pixFlipToTexMat = affine.concat(voxToTexMat, pixFlipToVoxMat)

        worldToVoxMat = affine.invert(voxToWorldMat)
        worldToIdMat = affine.concat(voxToIdMat, worldToVoxMat)
        worldToPixdimMat = affine.concat(voxToPixdimMat, worldToVoxMat)
        worldToPixFlipMat = affine.concat(voxToPixFlipMat, worldToVoxMat)
        worldToRefMat = affine.concat(voxToRefMat, worldToVoxMat)
        worldToTexMat = affine.concat(voxToTexMat, worldToVoxMat)

        refToVoxMat = affine.invert(voxToRefMat)
        refToIdMat = affine.concat(voxToIdMat, refToVoxMat)
        refToPixdimMat = affine.concat(voxToPixdimMat, refToVoxMat)
        refToPixFlipMat = affine.concat(voxToPixFlipMat, refToVoxMat)
        refToWorldMat = affine.concat(voxToWorldMat, refToVoxMat)
        refToTexMat = affine.concat(voxToTexMat, refToVoxMat)

        texToVoxMat = affine.invert(voxToTexMat)
        texToIdMat = affine.concat(voxToIdMat, texToVoxMat)
        texToPixdimMat = affine.concat(voxToPixdimMat, texToVoxMat)
        texToPixFlipMat = affine.concat(voxToPixFlipMat, texToVoxMat)
        texToWorldMat = affine.concat(voxToWorldMat, texToVoxMat)
        texToRefMat = affine.concat(voxToRefMat, texToVoxMat)

        self.__xforms['id', 'id'] = np.eye(4)
        self.__xforms['id', 'pixdim'] = idToPixdimMat
        self.__xforms['id', 'pixdim-flip'] = idToPixFlipMat
        self.__xforms['id', 'affine'] = idToWorldMat
        self.__xforms['id', 'reference'] = idToRefMat
        self.__xforms['id', 'texture'] = idToTexMat

        self.__xforms['pixdim', 'pixdim'] = np.eye(4)
        self.__xforms['pixdim', 'id'] = pixdimToIdMat
        self.__xforms['pixdim', 'pixdim-flip'] = pixdimToPixFlipMat
        self.__xforms['pixdim', 'affine'] = pixdimToWorldMat
        self.__xforms['pixdim', 'reference'] = pixdimToRefMat
        self.__xforms['pixdim', 'texture'] = pixdimToTexMat

        self.__xforms['pixdim-flip', 'pixdim-flip'] = np.eye(4)
        self.__xforms['pixdim-flip', 'id'] = pixFlipToIdMat
        self.__xforms['pixdim-flip', 'pixdim'] = pixFlipToPixdimMat
        self.__xforms['pixdim-flip', 'affine'] = pixFlipToWorldMat
        self.__xforms['pixdim-flip', 'reference'] = pixFlipToRefMat
        self.__xforms['pixdim-flip', 'texture'] = pixFlipToTexMat

        self.__xforms['affine', 'affine'] = np.eye(4)
        self.__xforms['affine', 'id'] = worldToIdMat
        self.__xforms['affine', 'pixdim'] = worldToPixdimMat
        self.__xforms['affine', 'pixdim-flip'] = worldToPixFlipMat
        self.__xforms['affine', 'reference'] = worldToRefMat
        self.__xforms['affine', 'texture'] = worldToTexMat

        self.__xforms['reference', 'reference'] = np.eye(4)
        self.__xforms['reference', 'id'] = refToIdMat
        self.__xforms['reference', 'pixdim'] = refToPixdimMat
        self.__xforms['reference', 'pixdim-flip'] = refToPixFlipMat
        self.__xforms['reference', 'affine'] = refToWorldMat
        self.__xforms['reference', 'texture'] = refToTexMat

        self.__xforms['texture', 'texture'] = np.eye(4)
        self.__xforms['texture', 'id'] = texToIdMat
        self.__xforms['texture', 'pixdim'] = texToPixdimMat
        self.__xforms['texture', 'pixdim-flip'] = texToPixFlipMat
        self.__xforms['texture', 'affine'] = texToWorldMat
        self.__xforms['texture', 'reference'] = texToRefMat

    @classmethod
    def getVolumeProps(cls):
        """Overrides :meth:`DisplayOpts.getVolumeProps`. Returns a list
        of property names which control the displayed volume/timepoint.
        """
        return ['volume', 'volumeDim']

    def getTransform(self, from_, to, xform=None):
        """Return a matrix which may be used to transform coordinates
        from ``from_`` to ``to``. Valid values for ``from_`` and ``to``
        are:


        =============== ======================================================
        ``id``          Voxel coordinates

        ``voxel``       Equivalent to ``id``.

        ``pixdim``      Voxel coordinates, scaled by voxel dimensions

        ``pixdim-flip`` Voxel coordinates, scaled by voxel dimensions, and
                        with the X axis flipped if the affine matrix has
                        a positivie determinant. If the affine matrix does
                        not have a positive determinant, this is equivalent to
                        ``pixdim``.

        ``pixflip``     Equivalent to ``pixdim-flip``.

        ``affine``      World coordinates, as defined by the NIFTI
                        ``qform``/``sform``. See :attr:`.Image.voxToWorldMat`.

        ``world``       Equivalent to ``affine``.

        ``reference``   ``pixdim-flip`` coordinates of the reference image
                        specified by the :attr:`.DisplayContext.displaySpace`
                        attribute. If the ``displaySpace`` is set to
                        ``'world'``, this is equivalent to ``affine``.

        ``ref``         Equivalent to ``reference``.

        ``display``     Equivalent to the current value of :attr:`transform`.

        ``texture``     Voxel coordinates scaled to lie between 0.0 and 1.0,
                        suitable for looking up voxel values when stored as
                        an OpenGL texture.
        =============== ======================================================


        If the ``xform`` parameter is provided, and one of ``from_`` or ``to``
        is ``display``, the value of ``xform`` is used instead of the current
        value of :attr:`transform`.
        """

        if not self.__child:
            raise RuntimeError('getTransform cannot be called on '
                               'a parent NiftiOpts instance')

        if xform is None:
            xform = self.transform

        if from_ == 'display': from_ = xform
        elif from_ == 'world': from_ = 'affine'
        elif from_ == 'voxel': from_ = 'id'
        elif from_ == 'pixflip': from_ = 'pixdim-flip'
        elif from_ == 'ref': from_ = 'reference'

        if to == 'display': to = xform
        elif to == 'world': to = 'affine'
        elif to == 'voxel': to = 'id'
        elif to == 'pixflip': to = 'pixdim-flip'
        elif to == 'ref': to = 'reference'

        return self.__xforms[from_, to]

    def roundVoxels(self, voxels, daxes=None, roundOther=False):
        """Round the given voxel coordinates to integers. This is a
        surprisingly complicated operation.

        FSLeyes and the NIFTI standard map integer voxel coordinates to the
        voxel centre. For example, a voxel [3, 4, 5] fills the space::

            [2.5-3.5, 3.5-4.5, 4.5-5.5].


        So all we need to do is round to the nearest integer. But there are a
        few problems with breaking ties when rounding...


        The numpy.round function breaks ties (e.g. 7.5) by rounding to the
        nearest *even* integer, which can cause funky behaviour.  So instead
        of using numpy.round, we take floor(x+0.5), to force consistent
        behaviour (i.e. always rounding central values up).


        The next problem is that we have to round the voxel coordaintes
        carefully, depending on the orientation of the voxel axis w.r.t. the
        display axis. We want to round in the same direction in the display
        coordinate system, regardless of the voxel orientation. So we need to
        check the orientation of the voxel axis, and round down or up
        accordingly.


        This is to handle scenarios where we have two anatomically aligned
        images, but with opposing storage orders (e.g. one stored
        neurologically, and one stored radiologically). If we have such
        images, and the display location is on a voxel boundary, we want the
        voxel coordinates for one image to be rounded in the same anatomical
        direction (i.e. the same direction in the display coordinate
        system). Otherwise the same display location will map to mis-aligned
        voxels in the two images, because the voxel coordinate rounding will
        move in anatomically opposing directions.


        This method also prevents coordinates that are close to 0 from being
        set to -1, and coordinates that are close to the axis size from being
        set to (size + 1). In other words, voxel coordinates which are on the
        low or high boundaries will be rounded so as to be valid voxel
        coordinates.

        :arg voxels:     A ``(N, 3)`` ``numpy`` array containing the voxel
                         coordinates to be rounded.

        :arg daxes:      Display coordinate system axes along which to round
                         the coordinates (defaults to all axes).

        :arg roundOther: If ``True``, any voxel axes which are not in
                         ``daxes`` will still be rounded, but not with an
                         orientation-specific rounding convention.

        :returns:    The ``voxels``, rounded appropriately.
        """

        if not self.__child:
            raise RuntimeError('roundVoxels cannot be called on '
                               'a parent NiftiOpts instance')

        if daxes is None:
            daxes = list(range(3))

        shape = self.overlay.shape[:3]
        ornts = self.overlay.axisMapping(self.getTransform('display', 'voxel'))

        # We start by truncating the precision
        # of the coordinates, so that values
        # which are very close to voxel midpoints
        # (e.g. 0.49999), get rounded to 0.5.
        voxels = np.round(voxels, decimals=3)

        # Keep track of the voxel axes that
        # have had the rounding treatment
        roundedAxes = []

        for dax in daxes:

            ornt = ornts[dax]
            vax = abs(ornt) - 1
            vals = voxels[:, vax]

            roundedAxes.append(vax)

            # Identify values which are close
            # to the low or high bounds - we
            # will clamp them after rounding.
            #
            # This is a third rounding problem
            # which is not documented above -
            # we clamp low/high values to avoid
            # them under/overflowing in the
            # floor/ceil operations below
            closeLow = np.isclose(vals, -0.5)
            closeHigh = np.isclose(vals, shape[vax] - 0.5)

            # Round in a direction which is
            # dictated by the image orientation
            if ornt < 0: vals = np.floor(vals + 0.5)
            else: vals = np.ceil(vals - 0.5)

            # Clamp low/high voxel coordinates
            vals[closeLow] = 0
            vals[closeHigh] = shape[vax] - 1

            voxels[:, vax] = vals

        # If the roundOther flag is true,
        # we round all other voxel axes
        # in a more conventional manner
        # (but still using floor(v + 0.5)
        # rather than round to avoid
        # annoying numpy even/odd behaviour).
        if roundOther:
            for vax in range(3):
                if vax not in roundedAxes:
                    voxels[:, vax] = np.floor(voxels[:, vax] + 0.5)

        return voxels

    def transformCoords(self,
                        coords,
                        from_,
                        to_,
                        vround=False,
                        vector=False,
                        pre=None,
                        post=None):
        """Transforms the given coordinates from ``from_`` to ``to_``.

        The ``from_`` and ``to_`` parameters must be those accepted by the
        :meth:`getTransform` method.

        :arg coords: Coordinates to transform
        :arg from_:  Space to transform from
        :arg to_:    Space to transform to
        :arg vround: If ``True``, and ``to_ in ('voxel', 'id)``, the
                     transformed coordinates are rounded to the nearest
                     integer.
        :arg vector: Defaults to ``False``. If ``True``, the coordinates
                     are treated as vectors.
        :arg pre:    Transformation to apply before the ``from_``-to-``to``
                     transformation.
        :arg post:   Transformation to apply after the ``from_``-to-``to``
                     transformation.
        """

        if not self.__child:
            raise RuntimeError('transformCoords cannot be called on '
                               'a parent NiftiOpts instance')

        xform = self.getTransform(from_, to_)

        if pre is not None: xform = affine.concat(xform, pre)
        if post is not None: xform = affine.concat(post, xform)

        coords = np.array(coords)
        coords = affine.transform(coords, xform, vector=vector)

        # Round to integer voxel coordinates?
        if to_ in ('voxel', 'id') and vround:
            coords = self.roundVoxels(coords)

        return coords

    def getVoxel(self, xyz=None, clip=True, vround=True):
        """Calculates and returns the voxel coordinates corresponding to the
        given location (assumed to be in the display coordinate system) for
        the :class:`.Nifti` associated with this ``NiftiOpts`` instance..

        :arg xyz:    Display space location to convert to voxels. If not
                     provided, the current :attr:`.DisplayContext.location`
                     is used.

        :arg clip:   If ``False``, and the transformed coordinates are out of
                     the voxel coordinate bounds, the coordinates returned
                     anyway. Defaults to ``True``.


        :arg vround: If ``True``, the returned voxel coordinates are rounded
                     to the nearest integer. Otherwise they may be fractional.


        :returns:    ``None`` if the location is outside of the image bounds,
                     unless ``clip=False``.
        """

        if not self.__child:
            raise RuntimeError('getVoxel cannot be called on '
                               'a parent NiftiOpts instance')

        if xyz is not None: x, y, z = xyz
        else: x, y, z = self.displayCtx.location.xyz

        overlay = self.overlay
        vox = self.transformCoords([[x, y, z]],
                                   'display',
                                   'voxel',
                                   vround=vround)[0]

        if vround:
            vox = [int(v) for v in vox]

        if not clip:
            return vox

        for ax in (0, 1, 2):
            if vox[ax] < 0 or vox[ax] >= overlay.shape[ax]:
                return None

        return vox

    def index(self, slc=None, atVolume=True):
        """Given a slice object ``slc``, which indexes into the X, Y, and Z
        dimensions, fills it to slice every dimension of the image, using
        the current :attr:`volume` and :attr:`volumeDim`, and saved values
        for the other volume dimensions.

        :arg slc:      Something which can slice the first three dimensions
                       of the image. If ``None``, defaults to ``[:, :, :]``.

        :arg atVolume: If ``True``, the returned slice will index the current
                       :attr:`volume` of the current :attr:`volumeDim`.
                       Otherwise the returned slice will index across the whole
                       :attr:`volumeDim`.
        """

        if slc is None:
            slc = [slice(None), slice(None), slice(None)]

        if self.overlay.ndim <= 3:
            return tuple(slc)

        newSlc = [None] * self.overlay.ndim
        newSlc[:3] = slc
        newSlc[3:] = self.getAttribute('volume', 'cache')

        vdim = self.volumeDim + 3

        if atVolume: newSlc[vdim] = self.volume
        else: newSlc[vdim] = slice(None)

        return tuple(newSlc)
Esempio n. 12
0
class HistogramSeries(dataseries.DataSeries):
    """A ``HistogramSeries`` generates histogram data from an :class:`.Image`
    overlay.
    """

    nbins = props.Int(minval=10, maxval=1000, default=100, clamped=False)
    """Number of bins to use in the histogram. This value is overridden
    by the :attr:`autoBin` setting.
    """

    autoBin = props.Boolean(default=True)
    """If ``True``, the number of bins used for each :class:`HistogramSeries`
    is calculated automatically. Otherwise, :attr:`HistogramSeries.nbins` bins
    are used.
    """

    ignoreZeros = props.Boolean(default=True)
    """If ``True``, zeros are excluded from the calculated histogram. """

    includeOutliers = props.Boolean(default=False)
    """If ``True``, values which are outside of the :attr:`dataRange` are
    included in the histogram end bins.
    """

    dataRange = props.Bounds(ndims=1, clamped=False)
    """Specifies the range of data which should be included in the histogram.
    See the :attr:`includeOutliers` property.
    """

    showOverlay = props.Boolean(default=False)
    """If ``True``, a mask :class:`.ProxyImage` overlay is added to the
    :class:`.OverlayList`, which highlights the voxels that have been
    included in the histogram. The mask image is managed by the
    :class:`.HistogramProfile` instance, which manages histogram plot
    interaction.
    """

    showOverlayRange = props.Bounds(ndims=1)
    """Data range to display with the :attr:`.showOverlay` mask. """
    def __init__(self, overlay, displayCtx, overlayList):
        """Create a ``HistogramSeries``.

        :arg overlay:     The :class:`.Image` overlay to calculate a histogram
                          for.

        :arg displayCtx:  The :class:`.DisplayContext` instance.

        :arg overlayList: The :class:`.OverlayList` instance.
        """

        log.debug('New HistogramSeries instance for {} '.format(overlay.name))

        dataseries.DataSeries.__init__(self, overlay)

        self.__name = '{}_{}'.format(type(self).__name__, id(self))
        self.__displayCtx = displayCtx
        self.__overlayList = overlayList
        self.__display = displayCtx.getDisplay(overlay)
        self.__opts = displayCtx.getOpts(overlay)

        self.__nvals = 0
        self.__finiteData = np.array([])
        self.__xdata = np.array([])
        self.__ydata = np.array([])
        self.__nonZeroData = np.array([])
        self.__clippedFiniteData = np.array([])
        self.__clippedNonZeroData = np.array([])
        self.__volCache = cache.Cache(maxsize=10)
        self.__histCache = cache.Cache(maxsize=100)

        self.__display.addListener('overlayType', self.__name,
                                   self.__overlayTypeChanged)
        self.__opts.addListener('volume', self.__name, self.__volumeChanged)
        self.addListener('dataRange', self.__name, self.__dataRangeChanged)
        self.addListener('nbins', self.__name, self.__histPropsChanged)
        self.addListener('autoBin', self.__name, self.__histPropsChanged)
        self.addListener('ignoreZeros', self.__name, self.__histPropsChanged)
        self.addListener('includeOutliers', self.__name,
                         self.__histPropsChanged)

        # volumeChanged performs initial histogram-
        # related calculations for the current volume
        # (whether it is 3D or 4D)
        self.__volumeChanged()

    def destroy(self):
        """This needs to be called when this ``HistogramSeries`` instance
        is no longer being used.
        """

        self.__display.removeListener('overlayType', self.__name)
        self.__opts.removeListener('volume', self.__name)
        self.removeListener('nbins', self.__name)
        self.removeListener('ignoreZeros', self.__name)
        self.removeListener('includeOutliers', self.__name)
        self.removeListener('dataRange', self.__name)
        self.removeListener('nbins', self.__name)

        self.__volCache.clear()
        self.__histCache.clear()
        self.__volCache = None
        self.__histCache = None
        self.__opts = None
        self.__display = None

    def redrawProperties(self):
        """Overrides :meth:`.DataSeries.redrawProperties`. The
        ``HistogramSeries`` data does not need to be re-plotted when the
        :attr:`showOverlay` or :attr:`showOverlayRange` properties change.
        """

        propNames = dataseries.DataSeries.redrawProperties(self)

        propNames.remove('showOverlay')
        propNames.remove('showOverlayRange')

        return propNames

    def getData(self):
        """Overrides :meth:`.DataSeries.getData`.

        Returns  a tuple containing the ``(x, y)`` histogram data.
        """

        return self.__xdata, self.__ydata

    def getVertexData(self):
        """Returns a ``numpy`` array of shape ``(N, 2)``, which contains a
        set of "vertices" which can be used to display the histogram data
        as a filled polygon.
        """

        x, y = self.getData()
        verts = np.zeros((len(x) * 2, 2), dtype=x.dtype)

        verts[:, 0] = x.repeat(2)
        verts[1:-1, 1] = y.repeat(2)

        return verts

    def getNumHistogramValues(self):
        """Returns the number of values which were used in calculating the
        histogram.
        """
        return self.__nvals

    def __overlayTypeChanged(self, *a):
        """Called when the :attr:`.Display.overlayType` changes. When this
        happens, the :class:`.DisplayOpts` instance associated with the
        overlay gets destroyed and recreated. This method de-registers
        and re-registers property listeners as needed.
        """
        oldOpts = self.__opts
        newOpts = self.__displayCtx.getOpts(self.overlay)
        self.__opts = newOpts

        oldOpts.removeListener('volume', self.__name)
        newOpts.addListener('volume', self.__name, self.__volumeChanged)

    def __volumeChanged(self, *args, **kwargs):
        """Called when the :attr:`volume` property changes, and also by the
        :meth:`__init__` method.

        Re-calculates some things for the new overlay volume.
        """

        opts = self.__opts
        overlay = self.overlay

        # We cache the following for each volume
        # so they don't need to be recalculated:
        #  - finite data
        #  - non-zero data
        #  - finite minimum
        #  - finite maximum
        #
        # The cache size is restricted (see its
        # creation in __init__) so we don't blow
        # out RAM
        volkey = (opts.volumeDim, opts.volume)
        volprops = self.__volCache.get(volkey, None)

        if volprops is None:
            log.debug('Volume changed {} - extracting '
                      'finite/non-zero data'.format(volkey))
            finData = overlay[opts.index()]
            finData = finData[np.isfinite(finData)]
            nzData = finData[finData != 0]
            dmin = finData.min()
            dmax = finData.max()
            self.__volCache.put(volkey, (finData, nzData, dmin, dmax))
        else:
            log.debug('Volume changed {} - got finite/'
                      'non-zero data from cache'.format(volkey))
            finData, nzData, dmin, dmax = volprops

        dist = (dmax - dmin) / 10000.0

        with props.suppressAll(self):

            self.dataRange.xmin = dmin
            self.dataRange.xmax = dmax + dist
            self.dataRange.xlo = dmin
            self.dataRange.xhi = dmax + dist
            self.nbins = autoBin(nzData, self.dataRange.x)

            self.__finiteData = finData
            self.__nonZeroData = nzData

            self.__dataRangeChanged()

        with props.skip(self, 'dataRange', self.__name):
            self.propNotify('dataRange')

    def __dataRangeChanged(self, *args, **kwargs):
        """Called when the :attr:`dataRange` property changes, and also by the
        :meth:`__initProperties` and :meth:`__volumeChanged` methods.
        """

        finData = self.__finiteData
        nzData = self.__nonZeroData

        self.__clippedFiniteData = finData[(finData >= self.dataRange.xlo)
                                           & (finData < self.dataRange.xhi)]
        self.__clippedNonZeroData = nzData[(nzData >= self.dataRange.xlo)
                                           & (nzData < self.dataRange.xhi)]

        with props.suppress(self, 'showOverlayRange', notify=True):

            dlo, dhi = self.dataRange.x
            dist = (dhi - dlo) / 10000.0

            needsInit = np.all(np.isclose(self.showOverlayRange.x, [0, 0]))

            self.showOverlayRange.xmin = dlo - dist
            self.showOverlayRange.xmax = dhi + dist

            if needsInit or not self.showOverlay:
                self.showOverlayRange.xlo = dlo
                self.showOverlayRange.xhi = dhi
            else:
                self.showOverlayRange.xlo = max(dlo, self.showOverlayRange.xlo)
                self.showOverlayRange.xhi = min(dhi, self.showOverlayRange.xhi)

        self.__histPropsChanged()

    def __histPropsChanged(self, *a):
        """Called internally, and when any histogram settings change.
        Re-calculates the histogram data.
        """

        log.debug('Calculating histogram for '
                  'overlay {}'.format(self.overlay.name))

        status.update('Calculating histogram for '
                      'overlay {}'.format(self.overlay.name))

        if np.isclose(self.dataRange.xhi, self.dataRange.xlo):
            self.__xdata = np.array([])
            self.__ydata = np.array([])
            self.__nvals = 0
            return

        if self.ignoreZeros:
            if self.includeOutliers: data = self.__nonZeroData
            else: data = self.__clippedNonZeroData
        else:
            if self.includeOutliers: data = self.__finiteData
            else: data = self.__clippedFiniteData

        # Figure out the number of bins to use
        if self.autoBin: nbins = autoBin(data, self.dataRange.x)
        else: nbins = self.nbins

        # nbins is unclamped, but
        # we don't allow < 10
        if nbins < 10:
            nbins = 10

        # Update the nbins property
        with props.skip(self, 'nbins', self.__name):
            self.nbins = nbins

        # We cache calculated bins and counts
        # for each combination of parameters,
        # as histogram calculation can take
        # time.
        hrange = (self.dataRange.xlo, self.dataRange.xhi)
        drange = (self.dataRange.xmin, self.dataRange.xmax)
        histkey = ((self.__opts.volumeDim, self.__opts.volume),
                   self.includeOutliers, hrange, drange, self.nbins)
        cached = self.__histCache.get(histkey, None)

        if cached is not None:
            histX, histY, nvals = cached
        else:
            histX, histY, nvals = histogram(data, self.nbins, hrange, drange,
                                            self.includeOutliers, True)
            self.__histCache.put(histkey, (histX, histY, nvals))

        self.__xdata = histX
        self.__ydata = histY
        self.__nvals = nvals

        status.update('Histogram for {} calculated.'.format(self.overlay.name))

        log.debug('Calculated histogram for overlay '
                  '{} (number of values: {}, number '
                  'of bins: {})'.format(self.overlay.name, self.__nvals,
                                        self.nbins))
Esempio n. 13
0
class MaskOpts(volumeopts.NiftiOpts):
    """The ``MaskOpts`` class defines settings for displaying an
    :class:`.Image` overlay as a binary mask.
    """

    threshold = props.Bounds(ndims=1)
    """The mask threshold range - values outside of this range are not
    displayed.
    """

    invert = props.Boolean(default=False)
    """If ``True``, the :attr:`threshold` range is inverted - values
    inside the range are not shown, and values outside of the range are shown.
    """

    colour = props.Colour()
    """The mask colour."""

    outline = props.Boolean(default=False)
    """If ``True`` only the outline of the mask will be shown.  If ``False``,
    the filled mask will be displayed.
    """

    outlineWidth = props.Int(minval=0, maxval=10, default=1, clamped=True)
    """Width of mask outline, if :attr:``outline` is ``True``.  This value is
    in terms of pixels.
    """

    interpolation = copy.copy(volumeopts.VolumeOpts.interpolation)

    def __init__(self, overlay, *args, **kwargs):
        """Create a ``MaskOpts`` instance for the given overlay. All arguments
        are passed through to the :class:`.NiftiOpts` constructor.
        """

        # We need GL >= 2.1 for
        # spline interpolation
        if float(fslplatform.glVersion) < 2.1:
            interp = self.getProp('interpolation')
            interp.removeChoice('spline', instance=self)
            interp.updateChoice('linear', instance=self, newAlt=['spline'])

        kwargs['nounbind'] = ['interpolation']

        # Initialise threshold from data reange. Do
        # this before __init__, in case we need to
        # inherit settings from the parent instance
        dmin, dmax = overlay.dataRange
        dlen = dmax - dmin
        doff = dlen / 100.0

        self.threshold.xmin = dmin - doff
        self.threshold.xmax = dmax + doff
        self.threshold.xlo = dmin + doff
        self.threshold.xhi = dmax + doff

        volumeopts.NiftiOpts.__init__(self, overlay, *args, **kwargs)

        overlay.register(self.name,
                         self.__dataRangeChanged,
                         topic='dataRange',
                         runOnIdle=True)

        # The master MaskOpts instance makes
        # sure that colour[3] and Display.alpha
        # are consistent w.r.t. each other.
        self.__registered = self.getParent() is None
        if self.__registered:
            self.display.addListener('alpha',
                                     self.name,
                                     self.__alphaChanged,
                                     immediate=True)
            self.addListener('colour',
                             self.name,
                             self.__colourChanged,
                             immediate=True)

    def destroy(self):
        """Removes some property listeners and calls
        :meth:`.NitfiOpts.destroy`.
        """

        self.overlay.deregister(self.name, topic='dataRange')

        if self.__registered:
            self.display.removeListener('alpha', self.name)
            self.removeListener('colour', self.name)

        volumeopts.NiftiOpts.destroy(self)

    def __dataRangeChanged(self, *a):
        """Called when the :attr:`~fsl.data.image.Image.dataRange` changes.
        Updates the :attr:`threshold` limits.
        """
        dmin, dmax = self.overlay.dataRange
        dRangeLen = abs(dmax - dmin)
        dminDistance = dRangeLen / 100.0

        self.threshold.xmin = dmin - dminDistance
        self.threshold.xmax = dmax + dminDistance

        # If the threshold was
        # previously unset, grow it
        if self.threshold.x == (0, 0):
            self.threshold.x = (0, dmax + dminDistance)

    def __colourChanged(self, *a):
        """Called when :attr:`.colour` changes. Updates :attr:`.Display.alpha`
        from the alpha component.
        """

        alpha = self.colour[3] * 100

        log.debug('Propagating MaskOpts.colour[3] to '
                  'Display.alpha [{}]'.format(alpha))

        with props.skip(self.display, 'alpha', self.name):
            self.display.alpha = alpha

    def __alphaChanged(self, *a):
        """Called when :attr:`.Display.alpha` changes. Updates the alpha
        component of :attr:`.colour`.
        """

        alpha = self.display.alpha / 100.0
        r, g, b, _ = self.colour

        log.debug('Propagating Display.alpha to MaskOpts.'
                  'colour[3] [{}]'.format(alpha))

        with props.skip(self, 'colour', self.name):
            self.colour = r, g, b, alpha
Esempio n. 14
0
class ColourBarCanvas(props.HasProperties):
    """Contains logic to render a colour bar as an OpenGL texture.
    """

    cmap = props.ColourMap()
    """The :mod:`matplotlib` colour map to use."""

    negativeCmap = props.ColourMap()
    """Negative colour map to use, if :attr:`useNegativeCmap` is ``True``."""

    useNegativeCmap = props.Boolean(default=False)
    """Whether or not to use the :attr:`negativeCmap`.
    """

    cmapResolution = props.Int(minval=2, maxval=1024, default=256)
    """Number of discrete colours to use in the colour bar. """

    invert = props.Boolean(default=False)
    """Invert the colour map(s). """

    vrange = props.Bounds(ndims=1)
    """The minimum/maximum values to display."""

    label = props.String()
    """A label to display under the centre of the colour bar."""

    orientation = props.Choice(('horizontal', 'vertical'))
    """Whether the colour bar should be vertical or horizontal. """

    labelSide = props.Choice(('top-left', 'bottom-right'))
    """Whether the colour bar labels should be on the top/left, or bottom/right
    of the colour bar (depending upon whether the colour bar orientation is
    horizontal/vertical).
    """

    textColour = props.Colour(default=(1, 1, 1, 1))
    """Colour to use for the colour bar label. """

    bgColour = props.Colour(default=(0, 0, 0, 1))
    """Colour to use for the background. """
    def __init__(self):
        """Adds a few listeners to the properties of this object, to update
        the colour bar when they change.
        """

        self._tex = None
        self._name = '{}_{}'.format(self.__class__.__name__, id(self))

        self.addGlobalListener(self._name, self.__updateTexture)

    def __updateTexture(self, *a):
        self._genColourBarTexture()
        self.Refresh()

    def _initGL(self):
        """Called automatically by the OpenGL canvas target superclass (see the
        :class:`.WXGLCanvasTarget` and :class:`.OSMesaCanvasTarget` for
        details).

        Generates the colour bar texture.
        """
        self._genColourBarTexture()

    def destroy(self):
        """Should be called when this ``ColourBarCanvas`` is no longer needed.
        Destroys the :class:`.Texture2D` instance used to render the colour
        bar.
        """
        self.removeGlobalListener(self._name)
        self._tex.destroy()
        self._tex = None

    def _genColourBarTexture(self):
        """Generates a texture containing an image of the colour bar,
        according to the current property values.
        """

        if not self._setGLContext():
            return

        w, h = self.GetSize()

        if w < 50 or h < 50:
            return

        if self.orientation == 'horizontal':
            if self.labelSide == 'top-left': labelSide = 'top'
            else: labelSide = 'bottom'
        else:
            if self.labelSide == 'top-left': labelSide = 'left'
            else: labelSide = 'right'

        if self.cmap is None:
            bitmap = np.zeros((w, h, 4), dtype=np.uint8)
        else:

            if self.useNegativeCmap:
                negCmap = self.negativeCmap
                ticks = [0.0, 0.49, 0.51, 1.0]
                ticklabels = [
                    '{:0.2f}'.format(-self.vrange.xhi),
                    '{:0.2f}'.format(-self.vrange.xlo),
                    '{:0.2f}'.format(self.vrange.xlo),
                    '{:0.2f}'.format(self.vrange.xhi)
                ]
                tickalign = ['left', 'right', 'left', 'right']
            else:
                negCmap = None
                ticks = [0.0, 1.0]
                tickalign = ['left', 'right']
                ticklabels = [
                    '{:0.2f}'.format(self.vrange.xlo),
                    '{:0.2f}'.format(self.vrange.xhi)
                ]

            bitmap = cbarbmp.colourBarBitmap(
                cmap=self.cmap,
                negCmap=negCmap,
                invert=self.invert,
                ticks=ticks,
                ticklabels=ticklabels,
                tickalign=tickalign,
                width=w,
                height=h,
                label=self.label,
                orientation=self.orientation,
                labelside=labelSide,
                textColour=self.textColour,
                cmapResolution=self.cmapResolution)

        if self._tex is None:
            self._tex = textures.Texture2D(
                '{}_{}'.format(type(self).__name__, id(self)), gl.GL_LINEAR)

        # The bitmap has shape W*H*4, but the
        # Texture2D instance needs it in shape
        # 4*W*H
        bitmap = np.fliplr(bitmap).transpose([2, 0, 1])

        self._tex.setData(bitmap)
        self._tex.refresh()

    def _draw(self):
        """Renders the colour bar texture using all available canvas space."""

        if self._tex is None or not self._setGLContext():
            return

        width, height = self.GetSize()

        # viewport
        gl.glViewport(0, 0, width, height)
        gl.glMatrixMode(gl.GL_PROJECTION)
        gl.glLoadIdentity()
        gl.glOrtho(0, 1, 0, 1, -1, 1)
        gl.glMatrixMode(gl.GL_MODELVIEW)
        gl.glLoadIdentity()

        gl.glClearColor(*self.bgColour)
        gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
        gl.glEnable(gl.GL_BLEND)
        gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA)
        gl.glShadeModel(gl.GL_FLAT)

        self._tex.drawOnBounds(0, 0, 1, 0, 1, 0, 1)
Esempio n. 15
0
class ImageHistogramSeries(HistogramSeries):
    """An ``ImageHistogramSeries`` instance manages generation of histogram
    data for an :class:`.Image` overlay.
    """


    showOverlay = props.Boolean(default=False)
    """If ``True``, a mask :class:`.ProxyImage` overlay is added to the
    :class:`.OverlayList`, which highlights the voxels that have been
    included in the histogram. The mask image is managed by the
    :class:`.HistogramProfile` instance, which manages histogram plot
    interaction.
    """


    showOverlayRange = props.Bounds(ndims=1)
    """Data range to display with the :attr:`.showOverlay` mask. """


    def __init__(self, *args, **kwargs):
        """Create an ``ImageHistogramSeries``. All arguments are passed
        through to :meth:`HistogramSeries.__init__`.
        """

        HistogramSeries.__init__(self, *args, **kwargs)

        self.__display = self.displayCtx.getDisplay(self.overlay)
        self.__opts    = self.displayCtx.getOpts(   self.overlay)

        self.__display.addListener('overlayType',
                                   self.name,
                                   self.__overlayTypeChanged)
        self.__opts   .addListener('volume',
                                   self.name,
                                   self.__volumeChanged)
        self.__opts   .addListener('volumeDim',
                                   self.name,
                                   self.__volumeChanged)

        self.__volumeChanged()


    def destroy(self):
        """Must be called when this ``ImageHistogramSeries`` is no longer
        needed. Removes some property listeners, and calls
        :meth:`HistogramSeries.destroy`.
        """

        HistogramSeries.destroy(self)

        self.__display.removeListener('overlayType', self.name)
        self.__opts   .removeListener('volume',      self.name)
        self.__opts   .removeListener('volumeDim',   self.name)


    def redrawProperties(self):
        """Overrides :meth:`.DataSeries.redrawProperties`. The
        ``HistogramSeries`` data does not need to be re-plotted when the
        :attr:`showOverlay` or :attr:`showOverlayRange` properties change.
        """

        propNames = dataseries.DataSeries.redrawProperties(self)

        propNames.remove('showOverlay')
        propNames.remove('showOverlayRange')

        return propNames


    def onDataRangeChange(self):
        """Overrides :meth:`HistogramSeries.onDataRangeChange`. Makes sure
        that the :attr:`showOverlayRange` limits are synced to the
        :attr:`HistogramSeries.dataRange`.
        """
        with props.suppress(self, 'showOverlayRange', notify=True):

            dlo, dhi = self.dataRange.x
            dist     = (dhi - dlo) / 10000.0

            needsInit = np.all(np.isclose(self.showOverlayRange.x, [0, 0]))

            self.showOverlayRange.xmin = dlo - dist
            self.showOverlayRange.xmax = dhi + dist

            if needsInit or not self.showOverlay:
                self.showOverlayRange.xlo = dlo
                self.showOverlayRange.xhi = dhi
            else:
                self.showOverlayRange.xlo = max(dlo, self.showOverlayRange.xlo)
                self.showOverlayRange.xhi = min(dhi, self.showOverlayRange.xhi)


    def __volumeChanged(self, *args, **kwargs):
        """Called when the :attr:`volume` property changes, and also by the
        :meth:`__init__` method.

        Passes the data to the :meth:`HistogramSeries.setHistogramData` method.
        """

        opts    = self.__opts
        overlay = self.overlay
        volkey  = (opts.volumeDim, opts.volume)

        self.setHistogramData(overlay[opts.index()], volkey)


    def __overlayTypeChanged(self, *a):
        """Called when the :attr:`.Display.overlayType` changes. When this
        happens, the :class:`.DisplayOpts` instance associated with the
        overlay gets destroyed and recreated. This method de-registers
        and re-registers property listeners as needed.
        """
        oldOpts     = self.__opts
        newOpts     = self.displayCtx.getOpts(self.overlay)
        self.__opts = newOpts

        oldOpts.removeListener('volume',    self.name)
        oldOpts.removeListener('volumeDim', self.name)
        newOpts.addListener(   'volume',    self.name, self.__volumeChanged)
        newOpts.addListener(   'volumeDim', self.name, self.__volumeChanged)
Esempio n. 16
0
class HistogramSeries(dataseries.DataSeries):
    """A ``HistogramSeries`` generates histogram data from an overlay. It is
    the base class for the :class:`ImageHistogramSeriess` and
    :class:`MeshHistogramSeries` classes.
    """


    nbins = props.Int(minval=10, maxval=1000, default=100, clamped=False)
    """Number of bins to use in the histogram. This value is overridden
    by the :attr:`autoBin` setting.
    """


    autoBin = props.Boolean(default=True)
    """If ``True``, the number of bins used for each :class:`HistogramSeries`
    is calculated automatically. Otherwise, :attr:`HistogramSeries.nbins` bins
    are used.
    """


    ignoreZeros = props.Boolean(default=True)
    """If ``True``, zeros are excluded from the calculated histogram. """


    includeOutliers = props.Boolean(default=False)
    """If ``True``, values which are outside of the :attr:`dataRange` are
    included in the histogram end bins.
    """


    dataRange = props.Bounds(ndims=1, clamped=False)
    """Specifies the range of data which should be included in the histogram.
    See the :attr:`includeOutliers` property.
    """


    def __init__(self, overlay, overlayList, displayCtx, plotPanel):
        """Create a ``HistogramSeries``.

        :arg overlay:     The overlay from which the data to be plotted is
                          retrieved.
        :arg overlayList: The :class:`.OverlayList` instance.
        :arg displayCtx:  The :class:`.DisplayContext` instance.
        :arg plotPanel:   The :class:`.HistogramPanel` that owns this
                          ``HistogramSeries``.
        """

        log.debug('New HistogramSeries instance for {} '.format(overlay.name))

        dataseries.DataSeries.__init__(
            self, overlay, overlayList, displayCtx, plotPanel)

        self.__nvals              = 0
        self.__dataKey            = None
        self.__xdata              = np.array([])
        self.__ydata              = np.array([])
        self.__finiteData         = np.array([])
        self.__nonZeroData        = np.array([])
        self.__clippedFiniteData  = np.array([])
        self.__clippedNonZeroData = np.array([])
        self.__dataCache          = cache.Cache(maxsize=10)
        self.__histCache          = cache.Cache(maxsize=100)

        self.addListener('dataRange',       self.name, self.__dataRangeChanged)
        self.addListener('nbins',           self.name, self.__histPropsChanged)
        self.addListener('autoBin',         self.name, self.__histPropsChanged)
        self.addListener('ignoreZeros',     self.name, self.__histPropsChanged)
        self.addListener('includeOutliers', self.name, self.__histPropsChanged)


    def destroy(self):
        """This needs to be called when this ``HistogramSeries`` instance
        is no longer being used.
        """

        self.removeListener('nbins',           self.name)
        self.removeListener('ignoreZeros',     self.name)
        self.removeListener('includeOutliers', self.name)
        self.removeListener('dataRange',       self.name)
        self.removeListener('nbins',           self.name)

        self.__dataCache.clear()
        self.__histCache.clear()
        self.__dataCache          = None
        self.__histCache          = None
        self.__nvals              = 0
        self.__dataKey            = None
        self.__xdata              = None
        self.__ydata              = None
        self.__finiteData         = None
        self.__nonZeroData        = None
        self.__clippedFiniteData  = None
        self.__clippedNonZeroData = None
        dataseries.DataSeries.destroy(self)


    def setHistogramData(self, data, key):
        """Must be called by sub-classes whenever the underlying histogram data
        changes.

        :arg data: A ``numpy`` array containing the data that the histogram is
                   to be calculated on. Pass in ``None``  to indicate that
                   there is currently no histogram data.

        :arg key:  Something which identifies the ``data``, and can be used as
                   a ``dict`` key.
        """

        if data is None:
            self.__nvals              = 0
            self.__dataKey            = None
            self.__xdata              = np.array([])
            self.__ydata              = np.array([])
            self.__finiteData         = np.array([])
            self.__nonZeroData        = np.array([])
            self.__clippedFiniteData  = np.array([])
            self.__clippedNonZeroData = np.array([])

            # force the panel to refresh
            with props.skip(self, 'dataRange', self.name):
                self.propNotify('dataRange')
            return

        # We cache the following data, based
        # on the provided key, so they don't
        # need to be recalculated:
        #  - finite data
        #  - non-zero data
        #  - finite minimum
        #  - finite maximum
        #
        # The cache size is restricted (see its
        # creation in __init__) so we don't blow
        # out RAM
        cached = self.__dataCache.get(key, None)

        if cached is None:

            log.debug('New histogram data {} - extracting '
                      'finite/non-zero data'.format(key))

            finData = data[np.isfinite(data)]
            nzData  = finData[finData != 0]
            dmin    = finData.min()
            dmax    = finData.max()

            self.__dataCache.put(key, (finData, nzData, dmin, dmax))
        else:
            log.debug('Got histogram data {} from cache'.format(key))
            finData, nzData, dmin, dmax = cached

        # The upper bound on the dataRange
        # is exclusive, so we initialise it
        # to a bit more than the data max.
        dist = (dmax - dmin) / 10000.0

        with props.suppressAll(self):

            self.dataRange.xmin = dmin
            self.dataRange.xmax = dmax + dist
            self.dataRange.xlo  = dmin
            self.dataRange.xhi  = dmax + dist
            self.nbins          = autoBin(nzData, self.dataRange.x)

            self.__dataKey     = key
            self.__finiteData  = finData
            self.__nonZeroData = nzData

            self.__dataRangeChanged()

        with props.skip(self, 'dataRange', self.name):
            self.propNotify('dataRange')


    def onDataRangeChange(self):
        """May be implemented by sub-classes. Is called when the
        :attr:`dataRange` changes.
        """
        pass


    def getData(self):
        """Overrides :meth:`.DataSeries.getData`.

        Returns  a tuple containing the ``(x, y)`` histogram data.
        """

        return self.__xdata, self.__ydata


    def getVertexData(self):
        """Returns a ``numpy`` array of shape ``(N, 2)``, which contains a
        set of "vertices" which can be used to display the histogram data
        as a filled polygon.
        """

        x, y = self.getData()

        if x is None or y is None:
            return None

        verts = np.zeros((len(x) * 2, 2), dtype=x.dtype)

        verts[  :,   0] = x.repeat(2)
        verts[ 1:-1, 1] = y.repeat(2)

        return verts


    def getNumHistogramValues(self):
        """Returns the number of values which were used in calculating the
        histogram.
        """
        return self.__nvals


    def __dataRangeChanged(self, *args, **kwargs):
        """Called when the :attr:`dataRange` property changes, and also by the
        :meth:`__initProperties` and :meth:`__volumeChanged` methods.
        """

        finData = self.__finiteData
        nzData  = self.__nonZeroData

        self.__clippedFiniteData  = finData[(finData >= self.dataRange.xlo) &
                                            (finData <  self.dataRange.xhi)]
        self.__clippedNonZeroData = nzData[ (nzData  >= self.dataRange.xlo) &
                                            (nzData  <  self.dataRange.xhi)]

        self.onDataRangeChange()
        self.__histPropsChanged()


    def __histPropsChanged(self, *a):
        """Called internally, and when any histogram settings change.
        Re-calculates the histogram data.
        """

        log.debug('Calculating histogram for '
                  'overlay {}'.format(self.overlay.name))

        status.update('Calculating histogram for '
                      'overlay {}'.format(self.overlay.name))

        if np.isclose(self.dataRange.xhi, self.dataRange.xlo):
            self.__xdata = np.array([])
            self.__ydata = np.array([])
            self.__nvals = 0
            return

        if self.ignoreZeros:
            if self.includeOutliers: data = self.__nonZeroData
            else:                    data = self.__clippedNonZeroData
        else:
            if self.includeOutliers: data = self.__finiteData
            else:                    data = self.__clippedFiniteData

        # Figure out the number of bins to use
        if self.autoBin: nbins = autoBin(data, self.dataRange.x)
        else:            nbins = self.nbins

        # nbins is unclamped, but
        # we don't allow < 10
        if nbins < 10:
            nbins = 10

        # Update the nbins property
        with props.skip(self, 'nbins', self.name):
            self.nbins = nbins

        # We cache calculated bins and counts
        # for each combination of parameters,
        # as histogram calculation can take
        # time.
        hrange  = (self.dataRange.xlo,  self.dataRange.xhi)
        drange  = (self.dataRange.xmin, self.dataRange.xmax)
        histkey = (self.__dataKey,
                   self.includeOutliers,
                   self.ignoreZeros,
                   hrange,
                   drange,
                   self.nbins)
        cached  = self.__histCache.get(histkey, None)

        if cached is not None:
            histX, histY, nvals = cached
        else:
            histX, histY, nvals = histogram(data,
                                            self.nbins,
                                            hrange,
                                            drange,
                                            self.includeOutliers,
                                            True)
            self.__histCache.put(histkey, (histX, histY, nvals))

        self.__xdata = histX
        self.__ydata = histY
        self.__nvals = nvals

        status.update('Histogram for {} calculated.'.format(
            self.overlay.name))

        log.debug('Calculated histogram for overlay '
                  '{} (number of values: {}, number '
                  'of bins: {})'.format(
                      self.overlay.name,
                      self.__nvals,
                      self.nbins))