class MyObj(props.HasProperties): myinto = props.Int() myrealo = props.Real() myintc = props.Int( minval=0, maxval=100, clamped=True) myrealc = props.Real(minval=0.0, maxval=1.0, clamped=True)
class LineVectorOpts(VectorOpts): """The ``LineVectorOpts`` class contains settings for displaying vector images, using a line to represent the vector value at each voxel. """ lineWidth = props.Real(minval=0.1, maxval=10, default=1, clamped=True) """Width of the line in pixels. """ directed = props.Boolean(default=False) """If ``True``, the vector data is interpreted as directed. Otherwise, the vector data is assumed to be undirected. """ unitLength = props.Boolean(default=True) """If ``True``, each vector is scaled so that it has a length of ``1 * lengthScale`` (or 0.5 if ``directed`` is ``True``). """ lengthScale = props.Percentage(minval=10, maxval=500, default=100) """Length scaling factor. """ def __init__(self, *args, **kwargs): """Create a ``LineVectorOpts`` instance. All arguments are passed through to the :class:`VectorOpts` constructor. """ kwargs['nounbind'] = ['directed', 'unitLength', 'lengthScale'] VectorOpts.__init__(self, *args, **kwargs)
class MyObj(props.HasProperties): unbounded = props.Real() unbounded_default = props.Real(default=10) bounded = props.Real(minval=0, maxval=10) bounded_min = props.Real(minval=0) bounded_max = props.Real(maxval=10) bounded_clamped = props.Real(clamped=True, minval=0, maxval=10) bounded_min_clamped = props.Real(clamped=True, minval=0) bounded_max_clamped = props.Real(clamped=True, maxval=10)
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()
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
class DataSeries(props.HasProperties): """A ``DataSeries`` instance encapsulates some data to be plotted by a :class:`PlotPanel`, with the data extracted from an overlay in the :class:`.OverlayList`. Sub-class implementations must: - Accept an overlay object, :class:`.OverlayList`, :class:`.DisplayContext`, and :class:`.PlotPanel` in their ``__init__`` method, and pass these through to :meth:`.DataSeries.__init__`. - Override the :meth:`getData` method - Override the :meth:`redrawProperties` method if necessary The overlay is accessible as an instance attribute, confusingly called ``overlay``. .. note:: Some ``DataSeries`` instances may not be associated with an overlay (e.g. series imported loaded a text file). In this case, the ``overlay`` attribute will be ``None``. Each``DataSeries`` instance is plotted as a line, with the line style defined by properties on the ``DataSeries`` instance, such as :attr:`colour`, :attr:`lineWidth` etc. """ colour = props.Colour() """Line colour. """ enabled = props.Boolean(default=True) """Draw or not draw?""" alpha = props.Real(minval=0.0, maxval=1.0, default=1.0, clamped=True) """Line transparency.""" label = props.String() """Line label (used in the plot legend).""" lineWidth = props.Choice((0.5, 1, 2, 3, 4, 5)) """Line width. """ lineStyle = props.Choice( ('-', '--', '-.', ':', (0, (5, 7)), (0, (1, 7)), (0, (4, 10, 1, 10)), (0, (4, 1, 1, 1, 1, 1)), (0, (4, 1, 4, 1, 1, 1)))) """Line style. See https://matplotlib.org/gallery/lines_bars_and_markers/linestyles.html """ def __init__(self, overlay, overlayList, displayCtx, plotPanel): """Create a ``DataSeries``. :arg overlay: The overlay from which the data to be plotted is retrieved. May be ``None``. :arg overlayList: The :class:`.OverlayList` instance. :arg displayCtx: The :class:`.DisplayContext` instance. :arg plotPanel: The :class:`.PlotPanel` that owns this ``DataSeries``. """ self.__name = '{}_{}'.format(type(self).__name__, id(self)) self.__overlay = overlay self.__overlayList = overlayList self.__displayCtx = displayCtx self.__plotPanel = plotPanel self.setData([], []) 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 __hash__(self): """Returns a hash for this ``DataSeries`` instance.""" return hash(id(self)) @property def name(self): """Returns a unique name for this ``DataSeries`` instance. """ return self.__name @property def overlay(self): """Returns the overlay associated with this ``DataSeries`` instance. """ return self.__overlay @property def overlayList(self): """Returns the :class:`.OverlayList`. """ return self.__overlayList @property def displayCtx(self): """Returns the :class:`.DisplayContext`. """ return self.__displayCtx @property def plotPanel(self): """Returns the :class:`.PlotPanel` that owns this ``DataSeries`` instance. """ return self.__plotPanel def destroy(self): """This method must be called when this ``DataSeries`` instance is no longer needed. This implementation may be overridden by sub-classes which need to perform any clean-up operations. Sub-class implementations should call this implementation. """ self.__overlay = None self.__overlayList = None self.__displayCtx = None self.__plotPanel = None def redrawProperties(self): """Returns a list of all properties which, when their values change, should result in this ``DataSeries`` being re-plotted. This method may be overridden by sub-classes. """ return self.getAllProperties()[0] def extraSeries(self): """Some ``DataSeries`` types have additional ``DataSeries`` associated with them (see e.g. the :class:`.FEATTimeSeries` class). This method can be overridden to return a list of these extra ``DataSeries`` instances. The default implementation returns an empty list. """ return [] def setData(self, xdata, ydata): """Set the data to be plotted. This method is irrelevant if a ``DataSeries`` sub-class has overridden :meth:`getData`. """ self.__xdata = xdata self.__ydata = ydata def getData(self): """This method should be overridden by sub-classes. It must return the data to be plotted, as a tuple of the form: ``(xdata, ydata)`` where ``xdata`` and ``ydata`` are sequences containing the x/y data to be plotted. The default implementation returns the data that has been set via the :meth:`setData` method. """ return self.__xdata, self.__ydata
class MeshOpts(cmapopts.ColourMapOpts, fsldisplay.DisplayOpts): """The ``MeshOpts`` class defines settings for displaying :class:`.Mesh` overlays. See also the :class:`.GiftiOpts` and :class:`.FreesurferOpts` sub-classes. """ colour = props.Colour() """The mesh colour. """ outline = props.Boolean(default=False) """If ``True``, an outline of the mesh is shown. Otherwise a cross- section of the mesh is filled. """ outlineWidth = props.Real(minval=0.1, maxval=10, default=2, clamped=False) """If :attr:`outline` is ``True``, this property defines the width of the outline in pixels. """ showName = props.Boolean(default=False) """If ``True``, the mesh name is shown alongside it. .. note:: Not implemented yet, and maybe never will be. """ discardClipped = props.Boolean(default=False) """Flag which controls clipping. When the mesh is coloured according to some data (the :attr:`vertexData` property), vertices with a data value outside of the clipping range are either discarded (not drawn), or they are still drawn, but not according to the data, rather with the flat :attr:`colour`. """ vertexSet = props.Choice((None, )) """May be populated with the names of files which contain different vertex sets for the :class:`.Mesh` object. """ vertexData = props.Choice((None, )) """May be populated with the names of files which contain data associated with each vertex in the mesh, that can be used to colour the mesh. When some vertex data has been succsessfully loaded, it can be accessed via the :meth:`getVertexData` method. """ vertexDataIndex = props.Int(minval=0, maxval=0, default=0, clamped=True) """If :attr:`vertexData` is loaded, and has multiple data points per vertex (e.g. time series), this property controls the index into the data. """ refImage = props.Choice() """A reference :class:`.Image` instance which the mesh coordinates are in terms of. For example, if this :class:`.Mesh` represents the segmentation of a sub-cortical region from a T1 image, you would set the ``refImage`` to that T1 image. Any :class:`.Image` instance in the :class:`.OverlayList` may be chosen as the reference image. """ useLut = props.Boolean(default=False) """If ``True``, and if some :attr:`vertexData` is loaded, the :attr:`lut` is used to colour vertex values instead of the :attr:`cmap` and :attr:`negativeCmap`. """ lut = props.Choice() """If :attr:`useLut` is ``True``, a :class:`.LookupTable` is used to colour vertex data instead of the :attr:`cmap`/:attr:`negativeCmap`. """ # This property is implicitly tightly-coupled to # the NiftiOpts.getTransform method - the choices # defined in this property are assumed to be valid # inputs to that method (with the exception of # ``'torig'``). coordSpace = props.Choice( ('torig', 'affine', 'pixdim', 'pixdim-flip', 'id'), default='pixdim-flip') """If :attr:`refImage` is not ``None``, this property defines the reference image coordinate space in which the mesh coordinates are defined (i.e. voxels, scaled voxels, or world coordinates). =============== ========================================================= ``affine`` The mesh coordinates are defined in the reference image world coordinate system. ``torig`` Equivalent to ``'affine'``, except for :class:`.FreesurferOpts` sub-classes. ``id`` The mesh coordinates are defined in the reference image voxel coordinate system. ``pixdim`` The mesh coordinates are defined in the reference image voxel coordinate system, scaled by the voxel pixdims. ``pixdim-flip`` The mesh coordinates are defined in the reference image voxel coordinate system, scaled by the voxel pixdims. If the reference image transformation matrix has a positive determinant, the X axis is flipped. =============== ========================================================= The default value is ``pixdim-flip``, as this is the coordinate system used in the VTK sub-cortical segmentation model files output by FIRST. See also the :ref:`note on coordinate systems <volumeopts-coordinate-systems>`, and the :meth:`.NiftiOpts.getTransform` method. """ wireframe = props.Boolean(default=False) """3D only. If ``True``, the mesh is rendered as a wireframe. """ def __init__(self, overlay, *args, **kwargs): """Create a ``MeshOpts`` instance. All arguments are passed through to the :class:`.DisplayOpts` constructor. """ # Set a default colour colour = genMeshColour(overlay) self.colour = np.concatenate((colour, [1.0])) # ColourMapOpts.linkLowRanges defaults to # True, which is annoying for surfaces. self.linkLowRanges = False # A copy of the refImage property # value is kept here so, when it # changes, we can de-register from # the previous one. self.__oldRefImage = None # When the vertexData property is # changed, the data (and its min/max) # is loaded and stored in these # attributes. See the __vertexDataChanged # method. self.__vertexData = None self.__vertexDataRange = None nounbind = kwargs.get('nounbind', []) nounbind.extend(['refImage', 'coordSpace', 'vertexData', 'vertexSet']) kwargs['nounbind'] = nounbind fsldisplay.DisplayOpts.__init__(self, overlay, *args, **kwargs) cmapopts.ColourMapOpts.__init__(self) self.__registered = self.getParent() is not None # Load all vertex data and vertex # sets on the parent opts instance if not self.__registered: self.addVertexSetOptions(overlay.vertexSets()) self.addVertexDataOptions(overlay.vertexDataSets()) # The master MeshOpts instance is just a # sync-slave, so we only need to register # property listeners on child instances else: self.overlayList.addListener('overlays', self.name, self.__overlayListChanged, immediate=True) self.addListener('refImage', self.name, self.__refImageChanged, immediate=True) self.addListener('coordSpace', self.name, self.__coordSpaceChanged, immediate=True) # We need to keep colour[3] # keeps colour[3] and Display.alpha # consistent w.r.t. each other (see # also MaskOpts) self.display.addListener('alpha', self.name, self.__alphaChanged, immediate=True) self.addListener('colour', self.name, self.__colourChanged, immediate=True) self.addListener('vertexData', self.name, self.__vertexDataChanged, immediate=True) self.addListener('vertexSet', self.name, self.__vertexSetChanged, immediate=True) overlay.register(self.name, self.__overlayVerticesChanged, 'vertices') self.__overlayListChanged() self.__updateBounds() # If we have inherited values from a # parent instance, make sure the vertex # data (if set) is initialised self.__vertexDataChanged() # If a reference image has not # been set on the parent MeshOpts # instance, see if there is a # suitable one in the overlay list. if self.refImage is None: self.refImage = fsloverlay.findMeshReferenceImage( self.overlayList, self.overlay) def destroy(self): """Removes some property listeners, and calls the :meth:`.DisplayOpts.destroy` method. """ if self.__registered: self.overlayList.removeListener('overlays', self.name) self.display.removeListener('alpha', self.name) self.removeListener('colour', self.name) self.overlay.deregister(self.name, 'vertices') for overlay in self.overlayList: # An error could be raised if the # DC has been/is being destroyed try: display = self.displayCtx.getDisplay(overlay) opts = self.displayCtx.getOpts(overlay) display.removeListener('name', self.name) if overlay is self.refImage: opts.removeListener('transform', self.name) except Exception: pass self.__oldRefImage = None self.__vertexData = None cmapopts.ColourMapOpts.destroy(self) fsldisplay.DisplayOpts.destroy(self) @classmethod def getVolumeProps(cls): """Overrides :meth:`DisplayOpts.getVolumeProps`. Returns a list of property names which control the displayed volume/timepoint. """ return ['vertexDataIndex'] def getDataRange(self): """Overrides the :meth:`.ColourMapOpts.getDisplayRange` method. Returns the display range of the currently selected :attr:`vertexData`, or ``(0, 1)`` if none is selected. """ if self.__vertexDataRange is None: return (0, 1) else: return self.__vertexDataRange def getVertexData(self): """Returns the :attr:`.MeshOpts.vertexData`, if some is loaded. Returns ``None`` otherwise. """ return self.__vertexData def vertexDataLen(self): """Returns the length (number of data points per vertex) of the currently selected :attr:`vertexData`, or ``0`` if no vertex data is selected. """ if self.__vertexData is None: return 0 elif len(self.__vertexData.shape) == 1: return 1 else: return self.__vertexData.shape[1] def addVertexDataOptions(self, paths): """Adds the given sequence of paths as options to the :attr:`vertexData` property. It is assumed that the paths refer to valid vertex data files for the overlay associated with this ``MeshOpts`` instance. """ vdataProp = self.getProp('vertexData') newPaths = paths paths = vdataProp.getChoices(instance=self) paths = paths + [p for p in newPaths if p not in paths] vdataProp.setChoices(paths, instance=self) def addVertexSetOptions(self, paths): """Adds the given sequence of paths as options to the :attr:`vertexSet` property. It is assumed that the paths refer to valid vertex files for the overlay associated with this ``MeshOpts`` instance. """ vsetProp = self.getProp('vertexSet') newPaths = paths paths = vsetProp.getChoices(instance=self) paths = paths + [p for p in newPaths if p not in paths] vsetProp.setChoices(paths, instance=self) def getConstantColour(self): """Returns the current :attr::`colour`, adjusted according to the current :attr:`.Display.brightness`, :attr:`.Display.contrast`, and :attr:`.Display.alpha`. """ display = self.display # Only apply bricon if there is no vertex data assigned if self.vertexData is None: brightness = display.brightness / 100.0 contrast = display.contrast / 100.0 else: brightness = 0.5 contrast = 0.5 colour = list( fslcmaps.applyBricon(self.colour[:3], brightness, contrast)) colour.append(display.alpha / 100.0) return colour @property def referenceImage(self): """Overrides :meth:`.DisplayOpts.referenceImage`. If a :attr:`refImage` is selected, it is returned. Otherwise,``None`` is returned. """ return self.refImage @deprecated.deprecated('0.22.3', '1.0.0', 'Use getTransform instead') def getCoordSpaceTransform(self): """Returns a transformation matrix which can be used to transform the :class:`.Mesh` vertex coordinates into the display coordinate system. If no :attr:`refImage` is selected, this method returns an identity transformation. """ if self.refImage is None or self.refImage not in self.overlayList: return np.eye(4) opts = self.displayCtx.getOpts(self.refImage) return opts.getTransform(self.coordSpace, opts.transform) def getVertex(self, xyz=None): """Returns an integer identifying the index of the mesh vertex that coresponds to the given ``xyz`` location, :arg xyz: Location to convert to a vertex index. If not provided, the current :class:`.DisplayContext.location` is used. """ # TODO return vertex closest to the point, # within some configurabe tolerance? if xyz is None: xyz = self.displayCtx.location.xyz xyz = self.transformCoords(xyz, 'display', 'mesh') vert = None vidx = self.displayCtx.vertexIndex if vidx >= 0 and vidx <= self.overlay.nvertices: vert = self.overlay.vertices[vidx, :] if vert is not None and np.all(np.isclose(vert, xyz)): return vidx else: return None def normaliseSpace(self, space): """Used by :meth:`transformCoords` and :meth:`getTransform` to normalise their ``from_`` and ``to`` parameters. """ if space not in ('world', 'display', 'mesh'): raise ValueError('Invalid space: {}'.format(space)) if space == 'mesh': space = self.coordSpace if space == 'torig': space = 'affine' return space def transformCoords(self, coords, from_, to, *args, **kwargs): """Transforms the given ``coords`` from ``from_`` to ``to``. :arg coords: Coordinates to transform. :arg from_: Space that the coordinates are in :arg to: Space to transform the coordinates to All other parameters are passed through to the :meth:`.NiftiOpts.transformCoords` method of the reference image ``DisplayOpts``. The following values are accepted for the ``from_`` and ``to`` parameters: - ``'world'``: World coordinate system - ``'display'`` Display coordinate system - ``'mesh'`` The coordinate system of this mesh. """ from_ = self.normaliseSpace(from_) to = self.normaliseSpace(to) if self.refImage is None: return coords opts = self.displayCtx.getOpts(self.refImage) return opts.transformCoords(coords, from_, to, *args, **kwargs) def getTransform(self, from_, to): """Return a matrix which may be used to transform coordinates from ``from_`` to ``to``. The following values are accepted for the ``from_`` and ``to`` parameters: - ``'world'``: World coordinate system - ``'display'`` Display coordinate system - ``'mesh'`` The coordinate system of this mesh. """ from_ = self.normaliseSpace(from_) to = self.normaliseSpace(to) if self.refImage is None: return np.eye(4) opts = self.displayCtx.getOpts(self.refImage) return opts.getTransform(from_, to) def __transformChanged(self, value, valid, ctx, name): """Called when the :attr:`.NiftiOpts.transform` property of the current :attr:`refImage` changes. Calls :meth:`__updateBounds`. """ self.__updateBounds() def __coordSpaceChanged(self, *a): """Called when the :attr:`coordSpace` property changes. Calls :meth:`__updateBounds`. """ self.__updateBounds() def __refImageChanged(self, *a): """Called when the :attr:`refImage` property changes. If a new reference image has been specified, removes listeners from the old one (if necessary), and adds listeners to the :attr:`.NiftiOpts.transform` property associated with the new image. Calls :meth:`__updateBounds`. """ # TODO You are not tracking changes to the # refImage overlay type - if this changes, # you will need to re-bind to the transform # property of the new DisplayOpts instance if self.__oldRefImage is not None and \ self.__oldRefImage in self.overlayList: opts = self.displayCtx.getOpts(self.__oldRefImage) opts.removeListener('transform', self.name) self.__oldRefImage = self.refImage if self.refImage is not None: opts = self.displayCtx.getOpts(self.refImage) opts.addListener('transform', self.name, self.__transformChanged, immediate=True) self.__updateBounds() def __updateBounds(self): """Called whenever any of the :attr:`refImage`, :attr:`coordSpace`, or :attr:`transform` properties change. Updates the :attr:`.DisplayOpts.bounds` property accordingly. """ lo, hi = self.overlay.bounds xform = self.getTransform('mesh', 'display') lohi = transform.transform([lo, hi], xform) lohi.sort(axis=0) lo, hi = lohi[0, :], lohi[1, :] oldBounds = self.bounds self.bounds = [lo[0], hi[0], lo[1], hi[1], lo[2], hi[2]] if np.all(np.isclose(oldBounds, self.bounds)): self.propNotify('bounds') def __overlayListChanged(self, *a): """Called when the overlay list changes. Updates the :attr:`refImage` property so that it contains a list of overlays which can be associated with the mesh. """ imgProp = self.getProp('refImage') imgVal = self.refImage overlays = self.displayCtx.getOrderedOverlays() # the overlay for this MeshOpts # instance has been removed if self.overlay not in overlays: self.overlayList.removeListener('overlays', self.name) return imgOptions = [None] for overlay in overlays: # The overlay must be a Nifti instance. if not isinstance(overlay, fslimage.Nifti): continue imgOptions.append(overlay) display = self.displayCtx.getDisplay(overlay) display.addListener('name', self.name, self.__overlayListChanged, overwrite=True) # The previous refImage may have # been removed from the overlay list if imgVal in imgOptions: self.refImage = imgVal else: self.refImage = None imgProp.setChoices(imgOptions, instance=self) def __overlayVerticesChanged(self, *a): """Called when the :attr:`.Mesh.vertices` change. Makes sure that the :attr:`vertexSet` attribute is synchronised. """ vset = self.overlay.selectedVertices() vsprop = self.getProp('vertexSet') if vset not in vsprop.getChoices(instance=self): self.addVertexSetOptions([vset]) self.vertexSet = vset def __vertexSetChanged(self, *a): """Called when the :attr:`.MeshOpts.vertexSet` property changes. Updates the current vertex set on the :class:`.Mesh` overlay, and the overlay bounds. """ if self.vertexSet not in self.overlay.vertexSets(): self.overlay.loadVertices(self.vertexSet) else: with self.overlay.skip(self.name, 'vertices'): self.overlay.vertices = self.vertexSet self.__updateBounds() def __vertexDataChanged(self, *a): """Called when the :attr:`vertexData` property changes. Attempts to load the data if possible. The data may subsequently be retrieved via the :meth:`getVertexData` method. """ vdata = None vdataRange = None overlay = self.overlay vdfile = self.vertexData try: if vdfile is not None: if vdfile not in overlay.vertexDataSets(): log.debug('Loading vertex data: {}'.format(vdfile)) vdata = overlay.loadVertexData(vdfile) else: vdata = overlay.getVertexData(vdfile) vdataRange = np.nanmin(vdata), np.nanmax(vdata) if len(vdata.shape) == 1: vdata = vdata.reshape(-1, 1) except Exception as e: # TODO show a warning log.warning('Unable to load vertex data from {}: {}'.format( vdfile, e, exc_info=True)) vdata = None vdataRange = None self.__vertexData = vdata self.__vertexDataRange = vdataRange if vdata is not None: npoints = vdata.shape[1] else: npoints = 1 self.vertexDataIndex = 0 self.setAttribute('vertexDataIndex', 'maxval', npoints - 1) self.updateDataRange() 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 MeshOpts.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 MeshOpts.' 'colour[3] [{}]'.format(alpha)) with props.skip(self, 'colour', self.name): self.colour = r, g, b, alpha
class DataSeries(props.HasProperties): """A ``DataSeries`` instance encapsulates some data to be plotted by a :class:`PlotPanel`, with the data extracted from an overlay in the :class:`.OverlayList`. Sub-class implementations must: - Accept an overlay object in their ``__init__`` method - Pass this overlay to meth:`.DataSeries.__init__` - Override the :meth:`getData` method - Override the :meth:`redrawProperties` method if necessary The overlay is accessible as an instance attribute, confusingly called ``overlay``. .. note:: Some ``DataSeries`` instances may not be associated with an overlay (e.g. series imported loaded a text file). In this case, the ``overlay`` attribute will be ``None``. Each``DataSeries`` instance is plotted as a line, with the line style defined by properties on the ``DataSeries`` instance, such as :attr:`colour`, :attr:`lineWidth` etc. """ colour = props.Colour() """Line colour. """ enabled = props.Boolean(default=True) """Draw or not draw?""" alpha = props.Real(minval=0.0, maxval=1.0, default=1.0, clamped=True) """Line transparency.""" label = props.String() """Line label (used in the plot legend).""" lineWidth = props.Choice((0.5, 1, 2, 3, 4, 5)) """Line width. """ lineStyle = props.Choice(('-', '--', '-.', ':')) """Line style. """ def __init__(self, overlay): """Create a ``DataSeries``. :arg overlay: The overlay from which the data to be plotted is retrieved. May be ``None``. """ self.__overlay = overlay self.setData([], []) 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 __hash__(self): """Returns a hash for this ``DataSeries`` instance.""" return hash(id(self)) @property def overlay(self): """Returns the overlay associated with this ``DataSeries`` instance. """ return self.__overlay def destroy(self): """This method must be called when this ``DataSeries`` instance is no longer needed. This implementation does nothing, but it should be overridden by sub-classes which need to perform any clean-up operations. """ pass def redrawProperties(self): """Returns a list of all properties which, when their values change, should result in this ``DataSeries`` being re-plotted. This method may be overridden by sub-classes. """ return self.getAllProperties()[0] def setData(self, xdata, ydata): """Set the data to be plotted. This method is irrelevant if a ``DataSeries`` sub-class has overridden :meth:`getData`. """ self.__xdata = xdata self.__ydata = ydata def getData(self): """This method should be overridden by sub-classes. It must return the data to be plotted, as a tuple of the form: ``(xdata, ydata)`` where ``xdata`` and ``ydata`` are sequences containing the x/y data to be plotted. The default implementation returns the data that has been set via the :meth:`setData` method. """ return self.__xdata, self.__ydata
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())
class Scene3DCanvasOpts(props.HasProperties): """The ``Scene3DCanvasOpts`` class defines the display settings available on :class:`.Scene3DCanvas` instances. """ pos = copy.copy(SliceCanvasOpts.pos) """Current cursor position in the display coordinate system. The dimensions are in the same ordering as the display coordinate system, in contrast to the :attr:`SliceCanvasOpts.pos` property. """ showCursor = copy.copy(SliceCanvasOpts.showCursor) cursorColour = copy.copy(SliceCanvasOpts.cursorColour) bgColour = copy.copy(SliceCanvasOpts.bgColour) zoom = copy.copy(SliceCanvasOpts.zoom) highDpi = copy.copy(SliceCanvasOpts.highDpi) showLegend = props.Boolean(default=True) """If ``True``, an orientation guide will be shown on the canvas. """ legendColour = props.Colour(default=(0, 1, 0)) """Colour to use for the legend text.""" labelSize = props.Int(minval=4, maxval=96, default=12, clamped=True) """Font size used for the legend labels. """ occlusion = props.Boolean(default=True) """If ``True``, objects closer to the camera will occlude objects further away. Toggles ``gl.DEPTH_TEST``. """ light = props.Boolean(default=True) """If ``True``, a lighting effect is applied to compatible overlays in the scene. """ showLight = props.Boolean(default=False) """If ``True``, a point is drawn at the current light position. """ lightPos = props.Point(ndims=3) """Defines the light position in the display coordinate system. This property contains a set of three rotation values, in degrees. The lighting model uses a point source which is located a fixed distance away from the display coordinate system centre - the distance is set by the :attr:`lightDistance` property. The lightPos property defines how the light is rotated with respect to the centre of the display coordinate system. The :meth:`.Scene3DCanvas.lightPos` method can be used to calculate the actual position of the light in the display coordinate system. """ lightDistance = props.Real(minval=0.5, maxval=10, default=2) """Distance of the light source from the centre of the display coordinate system. This is used as a multiplicative factor - a value of 2 set the light source a distance of twice the length of the display bounding box from the bounding box centre. """ offset = props.Point(ndims=2) """An offset, in X/Y pixels normalised to the range ``[-1, 1]``, from the centre of the ``Scene3DCanvas``. """ rotation = props.Array(dtype=np.float64, shape=(3, 3), resizable=False, default=[[1, 0, 0], [0, 1, 0], [0, 0, 1]]) """A rotation matrix which defines the current ``Scene3DCanvas`` view
class Volume3DOpts(object): """The ``Volume3DOpts`` class is a mix-in for use with :class:`.DisplayOpts` classes. It defines display properties used for ray-cast based rendering of :class:`.Image` overlays. The properties in this class are tightly coupled to the ray-casting implementation used by the :class:`.GLVolume` class - see its documentation for details. """ blendFactor = props.Real(minval=0.001, maxval=1, default=0.1) """Controls how much each sampled point on each ray contributes to the final colour. """ numSteps = props.Int(minval=25, maxval=500, default=100, clamped=False) """Specifies the maximum number of samples to acquire in the rendering of each pixel of the 3D scene. This corresponds to the number of iterations of the ray-casting loop. .. note:: In a low performance environment, the actual number of steps may differ from this value - use the :meth:`getNumSteps` method to get the number of steps that are actually executed. """ numInnerSteps = props.Int(minval=1, maxval=100, default=10, clamped=True) """Only used in low performance environments. Specifies the number of ray-casting steps to execute in a single iteration on the GPU, as part of an outer loop which is running on the CPU. See the :class:`.GLVolume` class documentation for more details on the rendering process. .. warning:: The maximum number of iterations that can be performed within an ARB fragment program is implementation-dependent. Too high a value may result in errors or a corrupted view. See the :class:`.GLVolume` class for details. """ resolution = props.Int(minval=10, maxval=100, default=100, clamped=True) """Only used in low performance environments. Specifies the resolution of the off-screen buffer to which the volume is rendered, as a percentage of the screen resolution. See the :class:`.GLVolume` class documentation for more details. """ smoothing = props.Int(minval=0, maxval=10, default=0, clamped=True) """Amount of smoothing to apply to the rendered volume - this setting controls the smoothing filter radius, in pixels. """ numClipPlanes = props.Int(minval=0, maxval=5, default=0, clamped=True) """Number of active clip planes. """ showClipPlanes = props.Boolean(default=False) """If ``True``, wirframes depicting the active clipping planes will be drawn. """ clipMode = props.Choice(('intersection', 'union', 'complement')) """This setting controls how the active clip planes are combined. - ``intersection`` clips the intersection of all planes - ``union`` clips the union of all planes - ``complement`` clips the complement of all planes """ clipPosition = props.List(props.Percentage(minval=0, maxval=100, clamped=True), minlen=10, maxlen=10) """Centre of clip-plane rotation, as a distance from the volume centre - 0.5 is centre. """ clipAzimuth = props.List(props.Real(minval=-180, maxval=180, clamped=True), minlen=10, maxlen=10) """Rotation (degrees) of the clip plane about the Z axis, in the display coordinate system. """ clipInclination = props.List(props.Real(minval=-180, maxval=180, clamped=True), minlen=10, maxlen=10) """Rotation (degrees) of the clip plane about the Y axis in the display coordinate system. """ def __init__(self): """Create a :class:`Volume3DOpts` instance. """ # If we're in an X11/SSh session, # step down the quality so it's # a bit faster. if fslplatform.inSSHSession: self.numSteps = 60 self.resolution = 70 self.blendFactor = 0.3 # If we're in GL14, restrict the # maximum possible amount of # smoothing, as GL14 fragment # programs cannot be too large. if float(fslplatform.glVersion) < 2.1: smooth = self.getProp('smoothing') smooth.setAttribute(self, 'maxval', 6) self.clipPosition[:] = 10 * [50] self.clipAzimuth[:] = 10 * [0] self.clipInclination[:] = 10 * [0] # Give convenient initial values for # the first three clipping planes self.clipInclination[1] = 90 self.clipAzimuth[1] = 0 self.clipInclination[2] = 90 self.clipAzimuth[2] = 90 def destroy(self): """Does nothing. """ pass @property @deprecated.deprecated('0.17.0', '1.0.0', 'Dithering is automatically calculated') def dithering(self): """Deprecated.""" pass def getNumSteps(self): """Return the value of the :attr:`numSteps` property, possibly adjusted according to the the :attr:`numInnerSteps` property. The result of this method should be used instead of the value of the :attr:`numSteps` property. See the :class:`.GLVolume` class for more details. """ if float(fslplatform.glVersion) >= 2.1: return self.numSteps outer = self.getNumOuterSteps() return int(outer * self.numInnerSteps) def getNumOuterSteps(self): """Returns the number of iterations for the outer ray-casting loop. See the :class:`.GLVolume` class for more details. """ total = self.numSteps inner = self.numInnerSteps outer = np.ceil(total / float(inner)) return int(outer) def calculateRayCastSettings(self, view=None, proj=None): """Calculates various parameters required for 3D ray-cast rendering (see the :class:`.GLVolume` class). :arg view: Transformation matrix which transforms from model coordinates to view coordinates (i.e. the GL view matrix). :arg proj: Transformation matrix which transforms from view coordinates to normalised device coordinates (i.e. the GL projection matrix). Returns a tuple containing: - A vector defining the amount by which to move along a ray in a single iteration of the ray-casting algorithm. This can be added directly to the volume texture coordinates. - A transformation matrix which transforms from image texture coordinates into the display coordinate system. .. note:: This method will raise an error if called on a ``GLImageObject`` which is managing an overlay that is not associated with a :class:`.Volume3DOpts` instance. """ if view is None: view = np.eye(4) if proj is None: proj = np.eye(4) # In GL, the camera position # is initially pointing in # the -z direction. eye = [0, 0, -1] target = [0, 0, 1] # We take this initial camera # configuration, and transform # it by the inverse modelview # matrix t2dmat = self.getTransform('texture', 'display') xform = transform.concat(view, t2dmat) ixform = transform.invert(xform) eye = transform.transform(eye, ixform, vector=True) target = transform.transform(target, ixform, vector=True) # Direction that the 'camera' is # pointing, normalied to unit length cdir = transform.normalise(eye - target) # Calculate the length of one step # along the camera direction in a # single iteration of the ray-cast # loop. Multiply by sqrt(3) so that # the maximum number of steps will # be reached across the longest axis # of the image texture cube. rayStep = np.sqrt(3) * cdir / self.getNumSteps() # A transformation matrix which can # transform image texture coordinates # into the corresponding screen # (normalised device) coordinates. # This allows the fragment shader to # convert an image texture coordinate # into a relative depth value. # # The projection matrix puts depth into # [-1, 1], but we want it in [0, 1] zscale = transform.scaleOffsetXform([1, 1, 0.5], [0, 0, 0.5]) xform = transform.concat(zscale, proj, xform) return rayStep, xform def get3DClipPlane(self, planeIdx): """A convenience method which calculates a point-vector description of the specified clipping plane. ``planeIdx`` is an index into the :attr:`clipPosition`, :attr:`clipAzimuth`, and :attr:`clipInclination`, properties. Returns the clip plane at the given ``planeIdx`` as an origin and normal vector, in the display coordinate system.. """ pos = self.clipPosition[planeIdx] azimuth = self.clipAzimuth[planeIdx] incline = self.clipInclination[planeIdx] b = self.bounds pos = pos / 100.0 azimuth = azimuth * np.pi / 180.0 incline = incline * np.pi / 180.0 xmid = b.xlo + 0.5 * b.xlen ymid = b.ylo + 0.5 * b.ylen zmid = b.zlo + 0.5 * b.zlen centre = [xmid, ymid, zmid] normal = [0, 0, -1] rot1 = transform.axisAnglesToRotMat(incline, 0, 0) rot2 = transform.axisAnglesToRotMat(0, 0, azimuth) rotation = transform.concat(rot2, rot1) normal = transform.transformNormal(normal, rotation) normal = transform.normalise(normal) offset = (pos - 0.5) * max((b.xlen, b.ylen, b.zlen)) origin = centre + normal * offset return origin, normal
class ComplexPowerSpectrumSeries(VoxelPowerSpectrumSeries): """This class is the frequency-spectrum equivalent of the :class:`.ComplexTimeSeries` class - see it for more details. """ plotReal = props.Boolean(default=True) plotImaginary = props.Boolean(default=False) plotMagnitude = props.Boolean(default=False) plotPhase = props.Boolean(default=False) zeroOrderPhaseCorrection = props.Real(default=0) """Apply zero order phase correction to the power spectrum of the complex data. """ firstOrderPhaseCorrection = props.Real(default=0) """Apply first order phase correction to the power spectrum of the complex data. """ def __init__(self, overlay, overlayList, displayCtx, plotPanel): """Create a ``ComplexPowerSpectrumSeries``. All arguments are passed through to the :class:`VoxelPowerSpectrumSeries` constructor. """ VoxelPowerSpectrumSeries.__init__(self, overlay, overlayList, displayCtx, plotPanel) # Separate DataSeries for the imaginary/ # magnitude/phase signals, returned by # the extraSeries method self.__imagps = ImaginaryPowerSpectrumSeries(self, overlay, overlayList, displayCtx, plotPanel) self.__magps = MagnitudePowerSpectrumSeries(self, overlay, overlayList, displayCtx, plotPanel) self.__phaseps = PhasePowerSpectrumSeries(self, overlay, overlayList, displayCtx, plotPanel) for ps in (self.__imagps, self.__magps, self.__phaseps): ps.colour = fslcm.randomDarkColour() ps.bindProps('alpha', self) ps.bindProps('lineWidth', self) ps.bindProps('lineStyle', self) def makeLabelBase(self): """Returns a string to be used as the label prefix for this ``ComplexPowerSpectrumSeries`` instance, and for the imaginary, magnitude, and phase child series. """ return VoxelPowerSpectrumSeries.makeLabel(self) def makeLabel(self): """Returns a label to use for this data series. """ return '{} ({})'.format(self.makeLabelBase(), strings.labels[self]) def getData(self, component='real'): """If :attr:`plotReal` is true, returns the real component of the power spectrum of the data at the current voxel. Otherwise returns ``(None, None)``. Every time this method is called, the power spectrum is retrieved (see the :class:`VoxelPowerSpectrumSeries` class), phase correction is applied if set, andthe data is normalised, if set. A tuple containing the ``(xdata, ydata)`` is returned, with ``ydata`` containing the requested ``component`` ( ``'real'``, ``'imaginary'``, ``'magnitude'``, or ``'phase'``). This method is called by the :class:`ImaginarySpectrumPowerSeries`, :class:`MagnitudeSpectrumPowerSeries`, and :class:`PhasePowerSpectrumPowerSeries` instances that are associated with this data series. """ if ((component == 'real') and (not self.plotReal)) or \ ((component == 'imaginary') and (not self.plotImaginary)) or \ ((component == 'magnitude') and (not self.plotMagnitude)) or \ ((component == 'phase') and (not self.plotPhase)): return None, None # See VoxelPowerSpectrumSeries - the data # is already fourier-transformed ydata = self.dataAtCurrentVoxel() if ydata is None: return None, None # All of the calculations below are repeated # for each real/imag/mag/phase series that # gets plotted. But keeping the code together # and clean is currently more important than # performance, as there is not really any # performance hit. overlay = self.overlay xdata = calcFrequencies(overlay.shape[3], self.sampleTime, overlay.dtype) if self.zeroOrderPhaseCorrection != 0 or \ self.firstOrderPhaseCorrection != 0: ydata = phaseCorrection(ydata, xdata, self.zeroOrderPhaseCorrection, self.firstOrderPhaseCorrection) # Normalise magnitude, real, imaginary # components with respect to magnitude. # Normalise phase independently. if self.varNorm: mag = magnitude(ydata) mr = mag.min(), mag.max() if component == 'phase': ydata = normalise(phase(ydata)) elif component == 'magnitude': ydata = normalise(mag) elif component == 'real': ydata = normalise(ydata.real, *mr) elif component == 'imaginary': ydata = normalise(ydata.imag, *mr) elif component == 'real': ydata = ydata.real elif component == 'imaginary': ydata = ydata.imag elif component == 'magnitude': ydata = magnitude(ydata) elif component == 'phase': ydata = phase(ydata) return xdata, ydata def extraSeries(self): """Returns a list of additional series to be plotted, based on the values of the :attr:`plotImaginary`, :attr:`plotMagnitude` and :attr:`plotPhase` properties. """ extras = [] if self.plotImaginary: extras.append(self.__imagps) if self.plotMagnitude: extras.append(self.__magps) if self.plotPhase: extras.append(self.__phaseps) return extras
class SHOpts(vectoropts.VectorOpts): """The ``SHOpts`` is used for rendering class for rendering :class:`.Image` instances which contain fibre orientation distributions (FODs) in the form of spherical harmonic (SH) coefficients. A ``SHOpts`` instance will be used for ``Image`` overlays with a :attr:`.Displaty.overlayType` set to ``'sh'``. A collection of pre-calculated SH basis function parameters are stored in the ``assets/sh/`` directory. Depending on the SH order that was used in the fibre orientation, and the desired display resolution (controlled by :attr:`shResolution`), a different set of parameters needs to be used. The :meth:`getSHParameters` method will load and return the corrrect set of parameters. """ shResolution = props.Int(minval=3, maxval=10, default=5) """Resolution of the sphere used to display the FODs at each voxel. The value is equal to the number of iterations that an isocahedron, starting with 12 vertices, is tessellated. The resulting number of vertices is as follows: ==================== ================== Number of iterations Number of vertices 3 92 4 162 5 252 6 362 7 492 8 642 9 812 10 1002 ==================== ================== """ shOrder = props.Choice(allowStr=True) """Maximum spherical harmonic order to visualise. This is populated in :meth:`__init__`. """ size = props.Percentage(minval=10, maxval=500, default=100) """Display size - this is simply a linear scaling factor. """ lighting = props.Boolean(default=False) """Apply a simple directional lighting model to the FODs. """ radiusThreshold = props.Real(minval=0.0, maxval=1.0, default=0.05) """FODs with a maximum radius that is below this threshold are not shown. """ colourMode = props.Choice(('direction', 'radius')) """How to colour each FOD. This property is overridden if the :attr:`.VectorOpts.colourImage` is set. - ``'direction'`` The vertices of an FOD are coloured according to their x/y/z location (see :attr:`xColour`, :attr:`yColour`, and :attr:`zColour`). - ``'radius'`` The vertices of an FOD are coloured according to their distance from the FOD centre (see :attr:`colourMap`). """ def __init__(self, *args, **kwargs): vectoropts.VectorOpts.__init__(self, *args, **kwargs) ncoefs = self.overlay.shape[3] shType, maxOrder = SH_COEFFICIENT_TYPE.get(ncoefs) if shType is None: raise ValueError('{} does not look like a SH ' 'image'.format(self.overlay.name)) self.__maxOrder = maxOrder self.__shType = shType # If this Opts instance has a parent, # the shOrder choices will be inherited if self.getParent() is None: if shType == 'sym': vizOrders = range(0, self.__maxOrder + 1, 2) elif shType == 'asym': vizOrders = range(0, self.__maxOrder + 1) self.getProp('shOrder').setChoices(list(vizOrders), instance=self) self.shOrder = vizOrders[-1] @property def shType(self): """Returns either ``'sym'`` or ``'asym'``, depending on the type of the SH coefficients contained in the file. """ return self.__shType @property def maxOrder(self): """Returns the maximum SH order that was used to generate the coefficients of the SH image. """ return self.__maxOrder def getSHParameters(self): """Load and return a ``numpy`` array containing pre-calculated SH function parameters for the curert maximum SH order and display resolution. The returned array has the shape ``(N, C)``, where ``N`` is the number of vertices used to represent each FOD, and ``C`` is the number of SH coefficients. """ # TODO Adjust matrix if shOrder is # less than its maximum possible # value for this image. # # Also, calculate the normal vectors. resolution = self.shResolution ncoefs = self.overlay.shape[3] order = self.shOrder ftype, _ = SH_COEFFICIENT_TYPE[ncoefs] fname = op.join(fsleyes.assetDir, 'assets', 'sh', '{}_coef_{}_{}.txt'.format(ftype, resolution, order)) params = np.loadtxt(fname) if len(params.shape) == 1: params = params.reshape((-1, 1)) return params def getVertices(self): """Loads and returns a ``numpy`` array of shape ``(N, 3)``, containing ``N`` vertices of a tessellated sphere. """ fname = op.join(fsleyes.assetDir, 'assets', 'sh', 'vert_{}.txt'.format(self.shResolution)) return np.loadtxt(fname) def getIndices(self): """Loads and returns a 1D ``numpy`` array, containing indices into the vertex array, specifying the order in which they are to be drawn as triangles. """ fname = op.join(fsleyes.assetDir, 'assets', 'sh', 'face_{}.txt'.format(self.shResolution)) return np.loadtxt(fname).flatten()
class ComplexPowerSpectrumSeries(VoxelPowerSpectrumSeries): """This class is the frequency-spectrum equivalent of the :class:`.ComplexTimeSeries` class - see it for more details. """ plotReal = props.Boolean(default=True) plotImaginary = props.Boolean(default=False) plotMagnitude = props.Boolean(default=False) plotPhase = props.Boolean(default=False) zeroOrderPhaseCorrection = props.Real(default=0) """Apply zero order phase correction to the power spectrum of the complex data. """ firstOrderPhaseCorrection = props.Real(default=0) """Apply first order phase correction to the power spectrum of the complex data. """ def __init__(self, overlay, overlayList, displayCtx, plotPanel): """Create a ``ComplexPowerSpectrumSeries``. All arguments are passed through to the :class:`VoxelPowerSpectrumSeries` constructor. """ VoxelPowerSpectrumSeries.__init__(self, overlay, overlayList, displayCtx, plotPanel) self.__cachedData = (None, None) self.__imagps = ImaginaryPowerSpectrumSeries(self, overlay, overlayList, displayCtx, plotPanel) self.__magps = MagnitudePowerSpectrumSeries(self, overlay, overlayList, displayCtx, plotPanel) self.__phaseps = PhasePowerSpectrumSeries(self, overlay, overlayList, displayCtx, plotPanel) for ps in (self.__imagps, self.__magps, self.__phaseps): ps.colour = fslcm.randomDarkColour() ps.bindProps('alpha', self) ps.bindProps('lineWidth', self) ps.bindProps('lineStyle', self) def makeLabel(self): """Returns a string representation of this ``ComplexPowerSpectrumSeries`` instance. """ return '{} ({})'.format(VoxelPowerSpectrumSeries.makeLabel(self), strings.labels[self]) @property def cachedData(self): """Returns the currently cached data (see :meth:`getData`). """ return self.__cachedData def getData(self): """If :attr:`plotReal` is true, returns the real component of the power spectrum of the data at the current voxel. Otherwise returns ``(None, None)``. Every time this method is called, the power spectrum is calculated, phase correction is applied, and a reference to the resulting complex power spectrum (and frequencies) is saved; it is accessible via the :meth:`cachedData` property, for use by the :class:`ImaginaryPowerSpectrumSeries`, :class:`MagnitudePowerSpectrumSeries`, and :class:`PhasePowerSpectrumSeries`. """ xdata, ydata = VoxelPowerSpectrumSeries.getData(self) if self.zeroOrderPhaseCorrection != 0 or \ self.firstOrderPhaseCorrection != 0: ydata = phaseCorrection(ydata, xdata, self.zeroOrderPhaseCorrection, self.firstOrderPhaseCorrection) # Note that we're assuming that this # ComplexPowerSpectrumSeries.getData # method will be called before the # corresponding call(s) to the # Imaginary/Magnitude/Phase series # methods. self.__cachedData = xdata, ydata if not self.plotReal: return None, None if ydata is not None: ydata = ydata.real return xdata, ydata def extraSeries(self): """Returns a list of additional series to be plotted, based on the values of the :attr:`plotImaginary`, :attr:`plotMagnitude` and :attr:`plotPhase` properties. """ extras = [] if self.plotImaginary: extras.append(self.__imagps) if self.plotMagnitude: extras.append(self.__magps) if self.plotPhase: extras.append(self.__phaseps) return extras
class MeshOpts(cmapopts.ColourMapOpts, fsldisplay.DisplayOpts): """The ``MeshOpts`` class defines settings for displaying :class:`.Mesh` overlays. See also the :class:`.GiftiOpts` and :class:`.FreesurferOpts` sub-classes. """ colour = props.Colour() """The mesh colour. """ outline = props.Boolean(default=False) """If ``True``, an outline of the mesh is shown. Otherwise a cross- section of the mesh is filled. """ outlineWidth = props.Real(minval=0.1, maxval=10, default=2, clamped=False) """If :attr:`outline` is ``True``, this property defines the width of the outline in pixels. """ showName = props.Boolean(default=False) """If ``True``, the mesh name is shown alongside it. .. note:: Not implemented yet, and maybe never will be. """ discardClipped = props.Boolean(default=False) """Flag which controls clipping. When the mesh is coloured according to some data (the :attr:`vertexData` property), vertices with a data value outside of the clipping range are either discarded (not drawn), or they are still drawn, but not according to the data, rather with the flat :attr:`colour`. """ vertexSet = props.Choice((None, )) """May be populated with the names of files which contain different vertex sets for the :class:`.Mesh` object. """ vertexData = props.Choice((None, )) """May be populated with the names of files which contain data associated with each vertex in the mesh, that can be used to colour the mesh. When some vertex data has been succsessfully loaded, it can be accessed via the :meth:`getVertexData` method. """ vertexDataIndex = props.Int(minval=0, maxval=0, default=0, clamped=True) """If :attr:`vertexData` is loaded, and has multiple data points per vertex (e.g. time series), this property controls the index into the data. """ modulateData = props.Choice((None, )) """Populated with the same files available for the :attr:`vertexData` attribute. Used to apply the :attr:`.ColourMapOpts.modulateAlpha` setting. .. note:: There is currently no support for indexing into multi- dimensional modulate data (e.g. time points). A separate ``modulateDataIndex`` property may be added in the future. """ refImage = props.Choice() """A reference :class:`.Image` instance which the mesh coordinates are in terms of. For example, if this :class:`.Mesh` represents the segmentation of a sub-cortical region from a T1 image, you would set the ``refImage`` to that T1 image. Any :class:`.Image` instance in the :class:`.OverlayList` may be chosen as the reference image. """ useLut = props.Boolean(default=False) """If ``True``, and if some :attr:`vertexData` is loaded, the :attr:`lut` is used to colour vertex values instead of the :attr:`cmap` and :attr:`negativeCmap`. """ lut = props.Choice() """If :attr:`useLut` is ``True``, a :class:`.LookupTable` is used to colour vertex data instead of the :attr:`cmap`/:attr:`negativeCmap`. """ # This property is implicitly tightly-coupled to # the NiftiOpts.getTransform method - the choices # defined in this property are assumed to be valid # inputs to that method (with the exception of # ``'torig'``). coordSpace = props.Choice( ('affine', 'pixdim', 'pixdim-flip', 'id', 'torig'), default='pixdim-flip') """If :attr:`refImage` is not ``None``, this property defines the reference image coordinate space in which the mesh coordinates are defined (i.e. voxels, scaled voxels, or world coordinates). =============== ========================================================= ``affine`` The mesh coordinates are defined in the reference image world coordinate system. ``id`` The mesh coordinates are defined in the reference image voxel coordinate system. ``pixdim`` The mesh coordinates are defined in the reference image voxel coordinate system, scaled by the voxel pixdims. ``pixdim-flip`` The mesh coordinates are defined in the reference image voxel coordinate system, scaled by the voxel pixdims. If the reference image transformation matrix has a positive determinant, the X axis is flipped. ``torig`` The mesh coordinates are defined in the Freesurfer "Torig" / "vox2ras-tkr" coordnie system. =============== ========================================================= The default value is ``pixdim-flip``, as this is the coordinate system used in the VTK sub-cortical segmentation model files output by FIRST. See also the :ref:`note on coordinate systems <volumeopts-coordinate-systems>`, and the :meth:`.NiftiOpts.getTransform` method. """ flatShading = props.Boolean(default=False) """3D only. If ``True``, colours between vertices are not interpolated - each triangle is coloured with the colour assigned to the first vertex. Only has an effect when the mesh is being coloured with vertex data. """ wireframe = props.Boolean(default=False) """3D only. If ``True``, the mesh is rendered as a wireframe. """ def __init__(self, overlay, *args, **kwargs): """Create a ``MeshOpts`` instance. All other arguments are passed through to the :class:`.DisplayOpts` constructor. """ # Set a default colour colour = genMeshColour(overlay) self.colour = np.concatenate((colour, [1.0])) # ColourMapOpts.linkLowRanges defaults to # True, which is annoying for surfaces. self.linkLowRanges = False # A copy of the refImage property # value is kept here so, when it # changes, we can de-register from # the previous one. self.__oldRefImage = None # When the vertexData/modulateData properties # are changed, the data (and its min/max) # is loaded and stored in these # attributes. See the __vdataChanged # method. # # Keys used are 'vertex' and 'modulate' self.__vdata = {} self.__vdataRange = {} nounbind = kwargs.get('nounbind', []) nounbind.extend([ 'refImage', 'coordSpace', 'vertexData', 'vertexSet', 'modulateData' ]) kwargs['nounbind'] = nounbind fsldisplay.DisplayOpts.__init__(self, overlay, *args, **kwargs) cmapopts.ColourMapOpts.__init__(self) self.__registered = self.getParent() is not None # Load all vertex data and vertex # sets on the parent opts instance if not self.__registered: self.addVertexSetOptions(overlay.vertexSets()) self.addVertexDataOptions(overlay.vertexDataSets()) # The master MeshOpts instance is just a # sync-slave, so we only need to register # property listeners on child instances else: self.overlayList.addListener('overlays', self.name, self.__overlayListChanged, immediate=True) self.addListener('refImage', self.name, self.__refImageChanged, immediate=True) self.addListener('coordSpace', self.name, self.__coordSpaceChanged, immediate=True) # We need to keep colour[3] # keeps colour[3] and Display.alpha # consistent w.r.t. each other (see # also MaskOpts) self.display.addListener('alpha', self.name, self.__alphaChanged, immediate=True) self.addListener('colour', self.name, self.__colourChanged, immediate=True) self.addListener('vertexData', self.name, self.__vdataChanged, immediate=True) self.addListener('modulateData', self.name, self.__vdataChanged, immediate=True) self.addListener('vertexSet', self.name, self.__vertexSetChanged, immediate=True) overlay.register(self.name, self.__overlayVerticesChanged, 'vertices') self.__overlayListChanged() self.__updateBounds() self.__refImageChanged() # If we have inherited values from a # parent instance, make sure the vertex/ # modulate data (if set) is initialised if self.vertexData is not None: self.__vdataChanged(self.vertexData, None, None, 'vertexData') if self.modulateData is not None: self.__vdataChanged(self.modulateData, None, None, 'modulateData') # If a reference image has not # been set on the parent MeshOpts # instance, see if there is a # suitable one in the overlay list. if self.refImage is None: self.refImage = fsloverlay.findMeshReferenceImage( self.overlayList, self.overlay) def destroy(self): """Removes some property listeners, and calls the :meth:`.DisplayOpts.destroy` method. """ if self.__registered: self.overlayList.removeListener('overlays', self.name) self.display.removeListener('alpha', self.name) self.removeListener('colour', self.name) self.overlay.deregister(self.name, 'vertices') for overlay in self.overlayList: # An error could be raised if the # DC has been/is being destroyed try: display = self.displayCtx.getDisplay(overlay) opts = self.displayCtx.getOpts(overlay) display.removeListener('name', self.name) if overlay is self.refImage: opts.removeListener('transform', self.name) except Exception: pass self.__oldRefImage = None self.__vdata = None cmapopts.ColourMapOpts.destroy(self) fsldisplay.DisplayOpts.destroy(self) @classmethod def getVolumeProps(cls): """Overrides :meth:`DisplayOpts.getVolumeProps`. Returns a list of property names which control the displayed volume/timepoint. """ return ['vertexDataIndex'] def getDataRange(self): """Overrides the :meth:`.ColourMapOpts.getDisplayRange` method. Returns the display range of the currently selected :attr:`vertexData`, or ``(0, 1)`` if none is selected. """ vdata = self.__vdataRange.get('vertex') if vdata is None: return (0, 1) else: return vdata def getModulateRange(self): """Overrides the :meth:`.ColourMapOpts.getModulateRange` method. Returns the display range of the currently selected :attr:`vertexData`, or ``None`` if none is selected. """ return self.__vdataRange.get('modulate') def getVertexData(self, vdtype='vertex'): """Returns the :attr:`.MeshOpts.vertexData` or :attr:`modulateData` , if some is loaded. Returns ``None`` otherwise. """ return self.__vdata.get(vdtype) def vertexDataLen(self): """Returns the length (number of data points per vertex) of the currently selected :attr:`vertexData`, or ``0`` if no vertex data is selected. """ vdata = self.__vdata.get('vertex') if vdata is None: return 0 elif len(vdata.shape) == 1: return 1 else: return vdata.shape[1] def addVertexData(self, key, data): """Adds the given data as a vertex data set to the :class:`.Mesh` overlay associated with this ``MeshOpts``. :arg key: A unique key to identify the data. If a vertex data set with the key already exists, a unique one is generated and returned. :arg data: ``numpy`` array containing per-vertex data. :returns: The key used to identify the data (typically equal to ``key``) """ count = 1 origKey = key sets = self.overlay.vertexDataSets() # generate a unique key for the # vertex data if one with the # given key already exists while key in sets: key = '{} [{}]'.format(origKey, count) count = count + 1 self.overlay.addVertexData(key, data) self.addVertexDataOptions([key]) return key def addVertexDataOptions(self, paths): """Adds the given sequence of paths as options to the :attr:`vertexData` property. It is assumed that the paths refer to valid vertex data files for the overlay associated with this ``MeshOpts`` instance. """ vdataProp = self.getProp('vertexData') mdataProp = self.getProp('modulateData') newPaths = paths paths = vdataProp.getChoices(instance=self) paths = paths + [p for p in newPaths if p not in paths] vdataProp.setChoices(paths, instance=self) mdataProp.setChoices(paths, instance=self) def addVertexSetOptions(self, paths): """Adds the given sequence of paths as options to the :attr:`vertexSet` property. It is assumed that the paths refer to valid vertex files for the overlay associated with this ``MeshOpts`` instance. """ vsetProp = self.getProp('vertexSet') newPaths = paths paths = vsetProp.getChoices(instance=self) paths = paths + [p for p in newPaths if p not in paths] vsetProp.setChoices(paths, instance=self) def getConstantColour(self): """Returns the current :attr::`colour`, adjusted according to the current :attr:`.Display.brightness`, :attr:`.Display.contrast`, and :attr:`.Display.alpha`. """ display = self.display # Only apply bricon if there is no vertex data assigned if self.vertexData is None: brightness = display.brightness / 100.0 contrast = display.contrast / 100.0 else: brightness = 0.5 contrast = 0.5 colour = list( fslcmaps.applyBricon(self.colour[:3], brightness, contrast)) colour.append(display.alpha / 100.0) return colour @property def referenceImage(self): """Overrides :meth:`.DisplayOpts.referenceImage`. If a :attr:`refImage` is selected, it is returned. Otherwise,``None`` is returned. """ return self.refImage @deprecated.deprecated('0.22.3', '1.0.0', 'Use getTransform instead') def getCoordSpaceTransform(self): """Returns a transformation matrix which can be used to transform the :class:`.Mesh` vertex coordinates into the display coordinate system. If no :attr:`refImage` is selected, this method returns an identity transformation. """ if self.refImage is None or self.refImage not in self.overlayList: return np.eye(4) opts = self.displayCtx.getOpts(self.refImage) return opts.getTransform(self.coordSpace, opts.transform) def getVertex(self, xyz=None, tol=1): """Returns an integer identifying the index of the mesh vertex that coresponds to the given ``xyz`` location, assumed to be specified in the display coordinate system. :arg xyz: Location to convert to a vertex index. If not provided, the current :class:`.DisplayContext.location` is used. :arg tol: Tolerance in mesh coordinate system units - if ``xyz`` is farther than ``tol`` to any vertex, ``None`` is returned. Pass in ``None`` to always return the nearest vertex. """ if xyz is None: xyz = self.displayCtx.location xyz = self.transformCoords(xyz, 'display', 'mesh') xyz = np.asarray(xyz).reshape(1, 3) dist, vidx = self.overlay.trimesh.nearest.vertex(xyz) dist = dist[0] vidx = vidx[0] if tol is not None and dist > tol: return None else: return vidx def normaliseSpace(self, space): """Used by :meth:`transformCoords` and :meth:`getTransform` to normalise their ``from_`` and ``to`` parameters. """ if space not in ('world', 'display', 'mesh', 'voxel', 'id'): raise ValueError('Invalid space: {}'.format(space)) if space == 'mesh': space = self.coordSpace if space == 'torig': space = 'affine' return space def transformCoords(self, coords, from_, to, *args, **kwargs): """Transforms the given ``coords`` from ``from_`` to ``to``. :arg coords: Coordinates to transform. :arg from_: Space that the coordinates are in :arg to: Space to transform the coordinates to All other parameters are passed through to the :meth:`.NiftiOpts.transformCoords` method of the reference image ``DisplayOpts``. The following values are accepted for the ``from_`` and ``to`` parameters: - ``'world'``: World coordinate system - ``'display'`` Display coordinate system - ``'mesh'`` The coordinate system of this mesh. - ``'voxel'``: The voxel coordinate system of the reference image - ``'id'``: Equivalent to ``'voxel'``. """ nfrom_ = self.normaliseSpace(from_) nto = self.normaliseSpace(to) ref = self.refImage pre = None post = None if ref is None: return coords if from_ == 'mesh' and self.coordSpace == 'torig': pre = affine.concat(ref.getAffine('voxel', 'world'), affine.invert(fslmgh.voxToSurfMat(ref))) if to == 'mesh' and self.coordSpace == 'torig': post = affine.concat(fslmgh.voxToSurfMat(ref), ref.getAffine('world', 'voxel')) opts = self.displayCtx.getOpts(ref) return opts.transformCoords(coords, nfrom_, nto, pre=pre, post=post, **kwargs) def getTransform(self, from_, to): """Return a matrix which may be used to transform coordinates from ``from_`` to ``to``. If the :attr:`refImage` property is not set, an identity matrix is returned. The following values are accepted for the ``from_`` and ``to`` parameters: - ``'world'``: World coordinate system - ``'display'``: Display coordinate system - ``'mesh'``: The coordinate system of this mesh. - ``'voxel'``: The voxel coordinate system of the reference image - ``'id'``: Equivalent to ``'voxel'``. """ nfrom_ = self.normaliseSpace(from_) nto = self.normaliseSpace(to) ref = self.refImage if ref is None: return np.eye(4) opts = self.displayCtx.getOpts(ref) xform = opts.getTransform(nfrom_, nto) if from_ == 'mesh' and self.coordSpace == 'torig': surfToVox = affine.invert(fslmgh.voxToSurfMat(ref)) xform = affine.concat(xform, ref.getAffine('voxel', 'world'), surfToVox) if to == 'mesh' and self.coordSpace == 'torig': voxToSurf = fslmgh.voxToSurfMat(ref) xform = affine.concat(voxToSurf, ref.getAffine('world', 'voxel'), xform) return xform def __transformChanged(self, value, valid, ctx, name): """Called when the :attr:`.NiftiOpts.transform` property of the current :attr:`refImage` changes. Calls :meth:`__updateBounds`. """ self.__updateBounds() def __coordSpaceChanged(self, *a): """Called when the :attr:`coordSpace` property changes. Calls :meth:`__updateBounds`. """ self.__updateBounds() def __refImageChanged(self, *a): """Called when the :attr:`refImage` property changes. If a new reference image has been specified, removes listeners from the old one (if necessary), and adds listeners to the :attr:`.NiftiOpts.transform` property associated with the new image. Calls :meth:`__updateBounds`. """ # TODO You are not tracking changes to the # refImage overlay type - if this changes, # you will need to re-bind to the transform # property of the new DisplayOpts instance if self.__oldRefImage is not None and \ self.__oldRefImage in self.overlayList: opts = self.displayCtx.getOpts(self.__oldRefImage) opts.removeListener('transform', self.name) self.__oldRefImage = self.refImage if self.refImage is not None: opts = self.displayCtx.getOpts(self.refImage) opts.addListener('transform', self.name, self.__transformChanged, immediate=True) self.__updateBounds() def __updateBounds(self): """Called whenever any of the :attr:`refImage`, :attr:`coordSpace`, or :attr:`transform` properties change. Updates the :attr:`.DisplayOpts.bounds` property accordingly. """ # create a bounding box for the # overlay vertices in their # native coordinate system lo, hi = self.overlay.bounds xlo, ylo, zlo = lo xhi, yhi, zhi = hi # Transform the bounding box # into display coordinates xform = self.getTransform('mesh', 'display') bbox = list(it.product(*zip(lo, hi))) bbox = affine.transform(bbox, xform) # re-calculate the min/max bounds x = np.sort(bbox[:, 0]) y = np.sort(bbox[:, 1]) z = np.sort(bbox[:, 2]) xlo, xhi = x.min(), x.max() ylo, yhi = y.min(), y.max() zlo, zhi = z.min(), z.max() oldBounds = self.bounds self.bounds = [xlo, xhi, ylo, yhi, zlo, zhi] if np.all(np.isclose(oldBounds, self.bounds)): self.propNotify('bounds') def __overlayListChanged(self, *a): """Called when the overlay list changes. Updates the :attr:`refImage` property so that it contains a list of overlays which can be associated with the mesh. """ imgProp = self.getProp('refImage') imgVal = self.refImage overlays = self.displayCtx.getOrderedOverlays() # the overlay for this MeshOpts # instance has been removed if self.overlay not in overlays: self.overlayList.removeListener('overlays', self.name) return imgOptions = [None] for overlay in overlays: # The overlay must be a Nifti instance. if not isinstance(overlay, fslimage.Nifti): continue imgOptions.append(overlay) display = self.displayCtx.getDisplay(overlay) display.addListener('name', self.name, self.__overlayListChanged, overwrite=True) # The previous refImage may have # been removed from the overlay list if imgVal in imgOptions: self.refImage = imgVal else: self.refImage = None imgProp.setChoices(imgOptions, instance=self) def __overlayVerticesChanged(self, *a): """Called when the :attr:`.Mesh.vertices` change. Makes sure that the :attr:`vertexSet` attribute is synchronised. """ vset = self.overlay.selectedVertices() vsprop = self.getProp('vertexSet') if vset not in vsprop.getChoices(instance=self): self.addVertexSetOptions([vset]) self.vertexSet = vset def __vertexSetChanged(self, *a): """Called when the :attr:`.MeshOpts.vertexSet` property changes. Updates the current vertex set on the :class:`.Mesh` overlay, and the overlay bounds. """ if self.vertexSet not in self.overlay.vertexSets(): self.overlay.loadVertices(self.vertexSet) else: with self.overlay.skip(self.name, 'vertices'): self.overlay.vertices = self.vertexSet self.__updateBounds() def __vdataChanged(self, value, valid, ctx, name): """Called when the :attr:`vertexData` or :attr:`modulateData` properties changes. Attempts to load the data if possible. The data may subsequently be retrieved via the :meth:`getVertexData` method. """ vdata = None vdataRange = None overlay = self.overlay vdfile = value if name == 'vertexData': key = 'vertex' elif name == 'modulateData': key = 'modulate' else: raise RuntimeError() try: if vdfile is not None: if vdfile not in overlay.vertexDataSets(): log.debug('Loading vertex data: {}'.format(vdfile)) vdata = overlay.loadVertexData(vdfile) else: vdata = overlay.getVertexData(vdfile) vdataRange = np.nanmin(vdata), np.nanmax(vdata) if len(vdata.shape) == 1: vdata = vdata.reshape(-1, 1) vdata = dutils.makeWriteable(vdata) except Exception as e: # TODO show a warning log.warning('Unable to load vertex data from {}: {}'.format( vdfile, e, exc_info=True)) vdata = None vdataRange = None self.__vdata[key] = vdata self.__vdataRange[key] = vdataRange if key == 'vertex': if vdata is not None: npoints = vdata.shape[1] else: npoints = 1 self.vertexDataIndex = 0 self.setAttribute('vertexDataIndex', 'maxval', npoints - 1) # if modulate data has changed, # don't update display/clipping # ranges (unless modulateData is # None, meaning that it is using # vertexData) if key == 'vertex': drange = True mrange = self.modulateData is None # and vice versa else: drange = False mrange = True self.updateDataRange(drange, drange, mrange) def __colourChanged(self, *a): """Called when :attr:`.colour` changes. Updates :attr:`.Display.alpha` from the alpha component. """ # modulateAlpha may cause the # alpha property to be disabled if not self.display.propertyIsEnabled('alpha'): return alpha = self.colour[3] * 100 log.debug('Propagating MeshOpts.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 MeshOpts.' 'colour[3] [{}]'.format(alpha)) with props.skip(self, 'colour', self.name): self.colour = r, g, b, alpha
class LabelOpts(volumeopts.NiftiOpts): """The ``LabelOpts`` class defines settings for displaying :class:`.Image` overlays as label images., such as anatomical atlas images, tissue segmentation images, and so on. """ lut = props.Choice() """The :class:`.LookupTable` used to colour each label. """ outline = props.Boolean(default=False) """If ``True`` only the outline of contiguous regions with the same label value will be shown. If ``False``, contiguous regions will be filled. """ outlineWidth = props.Real(minval=0, maxval=1, default=0.25, clamped=True) """Width of labelled region outlines, if :attr:``outline` is ``True``. This value is in terms of the image voxels - a value of 1 will result in an outline that is one voxel wide. """ showNames = props.Boolean(default=False) """If ``True``, region names (as defined by the current :class:`.LookupTable`) will be shown alongside each labelled region. .. note:: Not implemented yet. """ def __init__(self, overlay, *args, **kwargs): """Create a ``LabelOpts`` instance for the specified ``overlay``. All arguments are passed through to the :class:`.NiftiOpts` constructor. """ # Some FSL tools will set the nifti aux_file # field to the name of a colour map - Check # to see if this is the case (again, before # calling __init__, so we don't clobber any # existing values). aux_file = overlay.strval('aux_file').lower() if aux_file.startswith('mgh'): aux_file = 'mgh-cma-freesurfer' # Check to see if any registered lookup table # has an ID that starts with the aux_file value. # Default to random lut if aux_file is empty, # or does not correspond to a registered lut. lut = 'random' if aux_file != '': luts = colourmaps.getLookupTables() luts = [l.key for l in luts if l.key.startswith(aux_file)] if len(luts) == 1: lut = luts[0] self.lut = lut volumeopts.NiftiOpts.__init__(self, overlay, *args, **kwargs)