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 MyObj(props.HasProperties): unbounded = props.Int() required = props.Int(required=True, allowInvalid=False) unbounded_default = props.Int(default=10) bounded = props.Int(minval=0, maxval=10) bounded_min = props.Int(minval=0) bounded_max = props.Int(maxval=10) bounded_clamped = props.Int(clamped=True, minval=0, maxval=10) bounded_min_clamped = props.Int(clamped=True, minval=0) bounded_max_clamped = props.Int(clamped=True, maxval=10)
class TensorOpts(vectoropts.VectorOpts): """The ``TensorOpts`` class defines options for displaying :class:`.GLTensor` instances. """ lighting = props.Boolean(default=True) """Enables a basic lighting model on the tensor ellipsoids. """ tensorResolution = props.Int(minval=4, maxval=20, default=10) """Tensor ellipsoid resolution - this value controls the number of vertices used to represent each tensor. It is ultimately passed to the :func:`.routines.unitSphere` function. """ tensorScale = props.Percentage(minval=50, maxval=600, default=100) """Scaling factor - by default, the tensor ellipsoids are scaled such that the biggest tensor (as defined by the first principal eigenvalue) fits inside a voxel. This parameter can be used to adjust this scale. """ def __init__(self, *args, **kwargs): """Create a ``TensorOpts`` instance. All arguments are passed through to :meth:`.VectorOpts.__init__`. """ vectoropts.VectorOpts.__init__(self, *args, **kwargs)
class MyObj(props.HasProperties): unbounded = props.Int() unbounded_default = props.Int(default=10) bounded = props.Int(minval=0, maxval=10) bounded_min = props.Int(minval=0) bounded_max = props.Int(maxval=10) bounded_clamped = props.Int(clamped=True, minval=0, maxval=10) bounded_min_clamped = props.Int(clamped=True, minval=0) bounded_max_clamped = props.Int(clamped=True, maxval=10)
class LabelOpts(niftiopts.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.Int(minval=0, maxval=10, default=1, clamped=True) """Width of labelled region outlines, if :attr:``outline` is ``True``. This value is in terms of pixels. """ 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 = 'freesurfercolorlut' # 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 niftiopts.NiftiOpts.__init__(self, overlay, *args, **kwargs)
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 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 Foo(Boo): myint = props.Int()
class Foo(props.SyncableHasProperties): int1 = props.Int() int2 = props.Int() int3 = props.Int()
class ColourBarCanvas(props.HasProperties): """Contains logic to render a colour bar as an OpenGL texture. """ cmap = props.ColourMap() """The :mod:`matplotlib` colour map to use.""" negativeCmap = props.ColourMap() """Negative colour map to use, if :attr:`useNegativeCmap` is ``True``.""" useNegativeCmap = props.Boolean(default=False) """Whether or not to use the :attr:`negativeCmap`. """ cmapResolution = props.Int(minval=2, maxval=1024, default=256) """Number of discrete colours to use in the colour bar. """ invert = props.Boolean(default=False) """Invert the colour map(s). """ vrange = props.Bounds(ndims=1) """The minimum/maximum values to display.""" label = props.String() """A label to display under the centre of the colour bar.""" orientation = props.Choice(('horizontal', 'vertical')) """Whether the colour bar should be vertical or horizontal. """ labelSide = props.Choice(('top-left', 'bottom-right')) """Whether the colour bar labels should be on the top/left, or bottom/right of the colour bar (depending upon whether the colour bar orientation is horizontal/vertical). """ textColour = props.Colour(default=(1, 1, 1, 1)) """Colour to use for the colour bar label. """ bgColour = props.Colour(default=(0, 0, 0, 1)) """Colour to use for the background. """ def __init__(self): """Adds a few listeners to the properties of this object, to update the colour bar when they change. """ self._tex = None self._name = '{}_{}'.format(self.__class__.__name__, id(self)) self.addGlobalListener(self._name, self.__updateTexture) def __updateTexture(self, *a): self._genColourBarTexture() self.Refresh() def _initGL(self): """Called automatically by the OpenGL canvas target superclass (see the :class:`.WXGLCanvasTarget` and :class:`.OSMesaCanvasTarget` for details). Generates the colour bar texture. """ self._genColourBarTexture() def destroy(self): """Should be called when this ``ColourBarCanvas`` is no longer needed. Destroys the :class:`.Texture2D` instance used to render the colour bar. """ self.removeGlobalListener(self._name) self._tex.destroy() self._tex = None def _genColourBarTexture(self): """Generates a texture containing an image of the colour bar, according to the current property values. """ if not self._setGLContext(): return w, h = self.GetSize() if w < 50 or h < 50: return if self.orientation == 'horizontal': if self.labelSide == 'top-left': labelSide = 'top' else: labelSide = 'bottom' else: if self.labelSide == 'top-left': labelSide = 'left' else: labelSide = 'right' if self.cmap is None: bitmap = np.zeros((w, h, 4), dtype=np.uint8) else: if self.useNegativeCmap: negCmap = self.negativeCmap ticks = [0.0, 0.49, 0.51, 1.0] ticklabels = [ '{:0.2f}'.format(-self.vrange.xhi), '{:0.2f}'.format(-self.vrange.xlo), '{:0.2f}'.format(self.vrange.xlo), '{:0.2f}'.format(self.vrange.xhi) ] tickalign = ['left', 'right', 'left', 'right'] else: negCmap = None ticks = [0.0, 1.0] tickalign = ['left', 'right'] ticklabels = [ '{:0.2f}'.format(self.vrange.xlo), '{:0.2f}'.format(self.vrange.xhi) ] bitmap = cbarbmp.colourBarBitmap( cmap=self.cmap, negCmap=negCmap, invert=self.invert, ticks=ticks, ticklabels=ticklabels, tickalign=tickalign, width=w, height=h, label=self.label, orientation=self.orientation, labelside=labelSide, textColour=self.textColour, cmapResolution=self.cmapResolution) if self._tex is None: self._tex = textures.Texture2D( '{}_{}'.format(type(self).__name__, id(self)), gl.GL_LINEAR) # The bitmap has shape W*H*4, but the # Texture2D instance needs it in shape # 4*W*H bitmap = np.fliplr(bitmap).transpose([2, 0, 1]) self._tex.setData(bitmap) self._tex.refresh() def _draw(self): """Renders the colour bar texture using all available canvas space.""" if self._tex is None or not self._setGLContext(): return width, height = self.GetSize() # viewport gl.glViewport(0, 0, width, height) gl.glMatrixMode(gl.GL_PROJECTION) gl.glLoadIdentity() gl.glOrtho(0, 1, 0, 1, -1, 1) gl.glMatrixMode(gl.GL_MODELVIEW) gl.glLoadIdentity() gl.glClearColor(*self.bgColour) gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) gl.glEnable(gl.GL_BLEND) gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) gl.glShadeModel(gl.GL_FLAT) self._tex.drawOnBounds(0, 0, 1, 0, 1, 0, 1)
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 HistogramSeries(dataseries.DataSeries): """A ``HistogramSeries`` generates histogram data from an overlay. It is the base class for the :class:`ImageHistogramSeriess` and :class:`MeshHistogramSeries` classes. """ nbins = props.Int(minval=10, maxval=1000, default=100, clamped=False) """Number of bins to use in the histogram. This value is overridden by the :attr:`autoBin` setting. """ autoBin = props.Boolean(default=True) """If ``True``, the number of bins used for each :class:`HistogramSeries` is calculated automatically. Otherwise, :attr:`HistogramSeries.nbins` bins are used. """ ignoreZeros = props.Boolean(default=True) """If ``True``, zeros are excluded from the calculated histogram. """ includeOutliers = props.Boolean(default=False) """If ``True``, values which are outside of the :attr:`dataRange` are included in the histogram end bins. """ dataRange = props.Bounds(ndims=1, clamped=False) """Specifies the range of data which should be included in the histogram. See the :attr:`includeOutliers` property. """ def __init__(self, overlay, overlayList, displayCtx, plotPanel): """Create a ``HistogramSeries``. :arg overlay: The overlay from which the data to be plotted is retrieved. :arg overlayList: The :class:`.OverlayList` instance. :arg displayCtx: The :class:`.DisplayContext` instance. :arg plotPanel: The :class:`.HistogramPanel` that owns this ``HistogramSeries``. """ log.debug('New HistogramSeries instance for {} '.format(overlay.name)) dataseries.DataSeries.__init__( self, overlay, overlayList, displayCtx, plotPanel) self.__nvals = 0 self.__dataKey = None self.__xdata = np.array([]) self.__ydata = np.array([]) self.__finiteData = np.array([]) self.__nonZeroData = np.array([]) self.__clippedFiniteData = np.array([]) self.__clippedNonZeroData = np.array([]) self.__dataCache = cache.Cache(maxsize=10) self.__histCache = cache.Cache(maxsize=100) self.addListener('dataRange', self.name, self.__dataRangeChanged) self.addListener('nbins', self.name, self.__histPropsChanged) self.addListener('autoBin', self.name, self.__histPropsChanged) self.addListener('ignoreZeros', self.name, self.__histPropsChanged) self.addListener('includeOutliers', self.name, self.__histPropsChanged) def destroy(self): """This needs to be called when this ``HistogramSeries`` instance is no longer being used. """ self.removeListener('nbins', self.name) self.removeListener('ignoreZeros', self.name) self.removeListener('includeOutliers', self.name) self.removeListener('dataRange', self.name) self.removeListener('nbins', self.name) self.__dataCache.clear() self.__histCache.clear() self.__dataCache = None self.__histCache = None self.__nvals = 0 self.__dataKey = None self.__xdata = None self.__ydata = None self.__finiteData = None self.__nonZeroData = None self.__clippedFiniteData = None self.__clippedNonZeroData = None dataseries.DataSeries.destroy(self) def setHistogramData(self, data, key): """Must be called by sub-classes whenever the underlying histogram data changes. :arg data: A ``numpy`` array containing the data that the histogram is to be calculated on. Pass in ``None`` to indicate that there is currently no histogram data. :arg key: Something which identifies the ``data``, and can be used as a ``dict`` key. """ if data is None: self.__nvals = 0 self.__dataKey = None self.__xdata = np.array([]) self.__ydata = np.array([]) self.__finiteData = np.array([]) self.__nonZeroData = np.array([]) self.__clippedFiniteData = np.array([]) self.__clippedNonZeroData = np.array([]) # force the panel to refresh with props.skip(self, 'dataRange', self.name): self.propNotify('dataRange') return # We cache the following data, based # on the provided key, so they don't # need to be recalculated: # - finite data # - non-zero data # - finite minimum # - finite maximum # # The cache size is restricted (see its # creation in __init__) so we don't blow # out RAM cached = self.__dataCache.get(key, None) if cached is None: log.debug('New histogram data {} - extracting ' 'finite/non-zero data'.format(key)) finData = data[np.isfinite(data)] nzData = finData[finData != 0] dmin = finData.min() dmax = finData.max() self.__dataCache.put(key, (finData, nzData, dmin, dmax)) else: log.debug('Got histogram data {} from cache'.format(key)) finData, nzData, dmin, dmax = cached # The upper bound on the dataRange # is exclusive, so we initialise it # to a bit more than the data max. dist = (dmax - dmin) / 10000.0 with props.suppressAll(self): self.dataRange.xmin = dmin self.dataRange.xmax = dmax + dist self.dataRange.xlo = dmin self.dataRange.xhi = dmax + dist self.nbins = autoBin(nzData, self.dataRange.x) self.__dataKey = key self.__finiteData = finData self.__nonZeroData = nzData self.__dataRangeChanged() with props.skip(self, 'dataRange', self.name): self.propNotify('dataRange') def onDataRangeChange(self): """May be implemented by sub-classes. Is called when the :attr:`dataRange` changes. """ pass def getData(self): """Overrides :meth:`.DataSeries.getData`. Returns a tuple containing the ``(x, y)`` histogram data. """ return self.__xdata, self.__ydata def getVertexData(self): """Returns a ``numpy`` array of shape ``(N, 2)``, which contains a set of "vertices" which can be used to display the histogram data as a filled polygon. """ x, y = self.getData() if x is None or y is None: return None verts = np.zeros((len(x) * 2, 2), dtype=x.dtype) verts[ :, 0] = x.repeat(2) verts[ 1:-1, 1] = y.repeat(2) return verts def getNumHistogramValues(self): """Returns the number of values which were used in calculating the histogram. """ return self.__nvals def __dataRangeChanged(self, *args, **kwargs): """Called when the :attr:`dataRange` property changes, and also by the :meth:`__initProperties` and :meth:`__volumeChanged` methods. """ finData = self.__finiteData nzData = self.__nonZeroData self.__clippedFiniteData = finData[(finData >= self.dataRange.xlo) & (finData < self.dataRange.xhi)] self.__clippedNonZeroData = nzData[ (nzData >= self.dataRange.xlo) & (nzData < self.dataRange.xhi)] self.onDataRangeChange() self.__histPropsChanged() def __histPropsChanged(self, *a): """Called internally, and when any histogram settings change. Re-calculates the histogram data. """ log.debug('Calculating histogram for ' 'overlay {}'.format(self.overlay.name)) status.update('Calculating histogram for ' 'overlay {}'.format(self.overlay.name)) if np.isclose(self.dataRange.xhi, self.dataRange.xlo): self.__xdata = np.array([]) self.__ydata = np.array([]) self.__nvals = 0 return if self.ignoreZeros: if self.includeOutliers: data = self.__nonZeroData else: data = self.__clippedNonZeroData else: if self.includeOutliers: data = self.__finiteData else: data = self.__clippedFiniteData # Figure out the number of bins to use if self.autoBin: nbins = autoBin(data, self.dataRange.x) else: nbins = self.nbins # nbins is unclamped, but # we don't allow < 10 if nbins < 10: nbins = 10 # Update the nbins property with props.skip(self, 'nbins', self.name): self.nbins = nbins # We cache calculated bins and counts # for each combination of parameters, # as histogram calculation can take # time. hrange = (self.dataRange.xlo, self.dataRange.xhi) drange = (self.dataRange.xmin, self.dataRange.xmax) histkey = (self.__dataKey, self.includeOutliers, self.ignoreZeros, hrange, drange, self.nbins) cached = self.__histCache.get(histkey, None) if cached is not None: histX, histY, nvals = cached else: histX, histY, nvals = histogram(data, self.nbins, hrange, drange, self.includeOutliers, True) self.__histCache.put(histkey, (histX, histY, nvals)) self.__xdata = histX self.__ydata = histY self.__nvals = nvals status.update('Histogram for {} calculated.'.format( self.overlay.name)) log.debug('Calculated histogram for overlay ' '{} (number of values: {}, number ' 'of bins: {})'.format( self.overlay.name, self.__nvals, self.nbins))
class MaskOpts(volumeopts.NiftiOpts): """The ``MaskOpts`` class defines settings for displaying an :class:`.Image` overlay as a binary mask. """ threshold = props.Bounds(ndims=1) """The mask threshold range - values outside of this range are not displayed. """ invert = props.Boolean(default=False) """If ``True``, the :attr:`threshold` range is inverted - values inside the range are not shown, and values outside of the range are shown. """ colour = props.Colour() """The mask colour.""" outline = props.Boolean(default=False) """If ``True`` only the outline of the mask will be shown. If ``False``, the filled mask will be displayed. """ outlineWidth = props.Int(minval=0, maxval=10, default=1, clamped=True) """Width of mask outline, if :attr:``outline` is ``True``. This value is in terms of pixels. """ interpolation = copy.copy(volumeopts.VolumeOpts.interpolation) def __init__(self, overlay, *args, **kwargs): """Create a ``MaskOpts`` instance for the given overlay. All arguments are passed through to the :class:`.NiftiOpts` constructor. """ # We need GL >= 2.1 for # spline interpolation if float(fslplatform.glVersion) < 2.1: interp = self.getProp('interpolation') interp.removeChoice('spline', instance=self) interp.updateChoice('linear', instance=self, newAlt=['spline']) kwargs['nounbind'] = ['interpolation'] # Initialise threshold from data reange. Do # this before __init__, in case we need to # inherit settings from the parent instance dmin, dmax = overlay.dataRange dlen = dmax - dmin doff = dlen / 100.0 self.threshold.xmin = dmin - doff self.threshold.xmax = dmax + doff self.threshold.xlo = dmin + doff self.threshold.xhi = dmax + doff volumeopts.NiftiOpts.__init__(self, overlay, *args, **kwargs) overlay.register(self.name, self.__dataRangeChanged, topic='dataRange', runOnIdle=True) # The master MaskOpts instance makes # sure that colour[3] and Display.alpha # are consistent w.r.t. each other. self.__registered = self.getParent() is None if self.__registered: self.display.addListener('alpha', self.name, self.__alphaChanged, immediate=True) self.addListener('colour', self.name, self.__colourChanged, immediate=True) def destroy(self): """Removes some property listeners and calls :meth:`.NitfiOpts.destroy`. """ self.overlay.deregister(self.name, topic='dataRange') if self.__registered: self.display.removeListener('alpha', self.name) self.removeListener('colour', self.name) volumeopts.NiftiOpts.destroy(self) def __dataRangeChanged(self, *a): """Called when the :attr:`~fsl.data.image.Image.dataRange` changes. Updates the :attr:`threshold` limits. """ dmin, dmax = self.overlay.dataRange dRangeLen = abs(dmax - dmin) dminDistance = dRangeLen / 100.0 self.threshold.xmin = dmin - dminDistance self.threshold.xmax = dmax + dminDistance # If the threshold was # previously unset, grow it if self.threshold.x == (0, 0): self.threshold.x = (0, dmax + dminDistance) def __colourChanged(self, *a): """Called when :attr:`.colour` changes. Updates :attr:`.Display.alpha` from the alpha component. """ alpha = self.colour[3] * 100 log.debug('Propagating MaskOpts.colour[3] to ' 'Display.alpha [{}]'.format(alpha)) with props.skip(self.display, 'alpha', self.name): self.display.alpha = alpha def __alphaChanged(self, *a): """Called when :attr:`.Display.alpha` changes. Updates the alpha component of :attr:`.colour`. """ alpha = self.display.alpha / 100.0 r, g, b, _ = self.colour log.debug('Propagating Display.alpha to MaskOpts.' 'colour[3] [{}]'.format(alpha)) with props.skip(self, 'colour', self.name): self.colour = r, g, b, alpha
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 MyObj(props.HasProperties): myint = props.Int() mybool = props.Boolean(default=False) mystr = props.String()
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 Foo(props.SyncableHasProperties): myint = props.Int(default=0)
class CanvasPanel(viewpanel.ViewPanel): """The ``CanvasPanel`` class is a :class:`.ViewPanel` which is the base class for all panels which display overlays using ``OpenGL`` (e.g. the :class:`.OrthoPanel` and the :class:`.LightBoxPanel`). A ``CanvasPanel`` instance uses a :class:`.SceneOpts` instance to control much of its functionality. The ``SceneOpts`` instance used by a ``CanvasPanel`` can be accessed via the :meth:`sceneOpts` property. The ``CanvasPanel`` class contains settings and functionality common to all sub-classes, including *movie mode* (see :attr:`movieMode`), the ability to show a colour bar (a :class:`.ColourBarPanel`; see :attr:`.SceneOpts.showColourBar`), and a number of actions. **Sub-class implementations** Sub-classes of the ``CanvasPanel`` must do the following: 1. Add their content to the panel that is accessible via the :meth:`contentPanel` property (see the note on :ref:`adding content <canvaspanel-adding-content>`). 2. Override the :meth:`getGLCanvases` method. 3. Call the :meth:`centrePanelLayout` method in their ``__init__`` method. 4. Override the :meth:`centrePanelLayout` method if any custom layout is necessary. **Actions** The following actions are available through a ``CanvasPanel`` (see the :mod:`.actions` module): .. autosummary:: :nosignatures: screenshot movieGif showCommandLineArgs toggleMovieMode toggleDisplaySync toggleOverlayList toggleOverlayInfo toggleAtlasPanel toggleDisplayToolBar toggleDisplayPanel toggleCanvasSettingsPanel toggleLocationPanel toggleClusterPanel toggleLookupTablePanel toggleClassificationPanel .. _canvaspanel-adding-content: **Adding content** To support colour bar and screenshot functionality, the ``CanvasPanel`` uses a hierarchy of ``wx.Panel`` instances, depicted in the following containment hierarchy diagram: .. graphviz:: digraph canvasPanel { graph [size=""]; node [style="filled", shape="box", fillcolor="#ddffdd", fontname="sans"]; rankdir="BT"; 1 [label="CanvasPanel"]; 2 [label="Centre panel"]; 3 [label="Custom content (for complex layouts)"]; 4 [label="Container panel"]; 5 [label="ColourBarPanel"]; 6 [label="Content panel"]; 7 [label="Content added by sub-classes"]; 2 -> 1; 3 -> 2; 4 -> 2; 5 -> 4; 6 -> 4; 7 -> 6; } As depicted in the diagram, sub-classes need to add their content to the *content panel*. This panel is accessible via the :meth:`contentPanel` property. The *centre panel* is the :meth:`.ViewPanel.centrePanel`. The *container panel* is also available, via :meth:`containerPanel`. Everything in the container panel will appear in screenshots (see the :meth:`screenshot` method). The :meth:`centrePanelLayout` method lays out the centre panel, using the :meth:`layoutContainerPanel` method to lay out the colour bar and the content panel. The ``centrePanelLayout`` method simply adds the canvas container directly to the centre panel. Sub-classes which have more advanced layout requirements (e.g. the :class:`.LightBoxPanel` needs a scrollbar) may override the :meth:`centrePanelLayout` method to implement their own layout. These sub-class implementations must: 1. Call the :meth:`layoutContainerPanel` method. 2. Add the container panel (accessed via :meth:`containerPanel`) to the centre panel (accessed via :meth:`centrePanel`). 3. Add any other custom content to the centre panel. """ syncLocation = props.Boolean(default=True) """If ``True`` (the default), the :attr:`.DisplayContext.location` for this ``CanvasPanel`` is linked to the master ``DisplayContext`` location. """ syncOverlayOrder = props.Boolean(default=True) """If ``True`` (the default), the :attr:`.DisplayContext.overlayOrder` for this ``CanvasPanel`` is linked to the master ``DisplayContext`` overlay order. """ syncOverlayDisplay = props.Boolean(default=True) """If ``True`` (the default), the properties of the :class:`.Display` and :class:`.DisplayOpts` instances for every overlay, as managed by the :attr:`.DisplayContext` for this ``CanvasPanel``, are linked to the properties of all ``Display`` and ``DisplayOpts`` instances managed by the master ``DisplayContext`` instance. """ movieMode = props.Boolean(default=False) """If ``True``, and the currently selected overlay (see :attr:`.DisplayContext.selectedOverlay`) is a :class:`.Image` instance with its display managed by a :class:`.VolumeOpts` instance, the displayed volume is changed periodically, according to the :attr:`movieRate` property. The update is performed on the main application thread via ``wx.CallLater``. """ movieRate = props.Int(minval=10, maxval=500, default=400, clamped=True) """The movie update rate in milliseconds. The value of this property is inverted so that a high value corresponds to a fast rate, which makes more sense when displayed as an option to the user. """ movieAxis = props.Choice((0, 1, 2, 3), default=3) """Axis along which the movie should be played, relative to the currently selected :class:`.Image`. """ def __init__(self, parent, overlayList, displayCtx, frame, sceneOpts): """Create a ``CanvasPanel``. :arg parent: The :mod:`wx` parent object. :arg overlayList: The :class:`.OverlayList` instance. :arg displayCtx: The :class:`.DisplayContext` instance. :arg sceneOpts: A :class:`.SceneOpts` instance for this ``CanvasPanel`` - must be created by sub-classes. """ viewpanel.ViewPanel.__init__(self, parent, overlayList, displayCtx, frame) self.__opts = sceneOpts # Use this name for listener registration, # in case subclasses use the FSLeyesPanel.name self.__name = 'CanvasPanel_{}'.format(self.name) # Bind the sync* properties of this # CanvasPanel to the corresponding # properties on the DisplayContext # instance. if displayCtx.getParent() is not None: self.bindProps('syncLocation', displayCtx, displayCtx.getSyncPropertyName('worldLocation')) self.bindProps('syncOverlayOrder', displayCtx, displayCtx.getSyncPropertyName('overlayOrder')) self.bindProps('syncOverlayDisplay', displayCtx) # If the displayCtx instance does not # have a parent, this means that it is # a top level instance else: self.disableProperty('syncLocation') self.disableProperty('syncOverlayOrder') self.disableProperty('syncOverlayDisplay') import fsleyes.actions.moviegif as moviegif self.centrePanel = wx.Panel(self) self.__containerPanel = wx.Panel(self.centrePanel) self.__contentPanel = wx.Panel(self.__containerPanel) self.__movieGifAction = moviegif.MovieGifAction( overlayList, displayCtx, self) self.toggleMovieMode.bindProps('toggled', self, 'movieMode') self.toggleDisplaySync.bindProps('toggled', self, 'syncOverlayDisplay') self.movieGif.bindProps('enabled', self.__movieGifAction) # the __movieModeChanged method is called # when movieMode changes, but also when # the movie axis, overlay list, or selected # overlay changes. This is because, if movie # mode is on, but no overlay, or an # incompatible overlay, is selected, the # movie loop stops. So it needs to be # re-started if/when a compatible overlay is # selected. self.__movieRunning = False self.addListener('movieMode', self.__name, self.__movieModeChanged) self.addListener('movieAxis', self.__name, self.__movieModeChanged) self.overlayList.addListener('overlays', self.__name, self.__movieModeChanged) self.displayCtx.addListener('selectedOverlay', self.__name, self.__movieModeChanged) # Canvas/colour bar layout is managed # in the layoutContainerPanel method self.__colourBar = None self.__opts.addListener('colourBarLocation', self.__name, self.__colourBarPropsChanged) self.__opts.addListener('showColourBar', self.__name, self.__colourBarPropsChanged) self.__opts.addListener('bgColour', self.__name, self.__bgfgColourChanged) self.__opts.addListener('fgColour', self.__name, self.__bgfgColourChanged) idle.idle(self.__bgfgColourChanged) def destroy(self): """Makes sure that any remaining control panels are destroyed cleanly, and calls :meth:`.ViewPanel.destroy`. """ if self.__colourBar is not None: self.__colourBar.destroy() self.removeListener('movieMode', self.__name) self.removeListener('movieAxis', self.__name) self.overlayList.removeListener('overlays', self.__name) self.displayCtx.removeListener('selectedOverlay', self.__name) self.sceneOpts.removeListener('colourBarLocation', self.__name) self.sceneOpts.removeListener('showColourBar', self.__name) self.sceneOpts.removeListener('bgColour', self.__name) self.sceneOpts.removeListener('fgColour', self.__name) self.__movieGifAction.destroy() self.__opts = None self.__movieGifAction = None viewpanel.ViewPanel.destroy(self) @actions.action def screenshot(self): """Takes a screenshot of the currently displayed scene on this ``CanvasPanel``. See the :class:`.ScreenshotAction`. """ from fsleyes.actions.screenshot import ScreenshotAction ScreenshotAction(self.overlayList, self.displayCtx, self)() @actions.action def movieGif(self): """Generates an animated GIF of the currently displayed scene and movie mode settings on this ``CanvasPanel``. See the :class:`.MovieGifAction`. """ self.__movieGifAction() @actions.action def showCommandLineArgs(self): """Shows the command line arguments which can be used to re-create the currently displayed scene. See the :class:`.ShowCommandLineAction` class. """ from fsleyes.actions.showcommandline import ShowCommandLineAction ShowCommandLineAction(self.overlayList, self.displayCtx, self)() @actions.action def applyCommandLineArgs(self): """Shows the command line arguments which can be used to re-create the currently displayed scene. See the :class:`.ApplyCommandLineAction` class. """ from fsleyes.actions.applycommandline import ApplyCommandLineAction ApplyCommandLineAction(self.overlayList, self.displayCtx, self)() @actions.toggleAction def toggleMovieMode(self): """Toggles the value of :attr:`movieMode`. """ # The state of this action gets bound to # the movieMode attribute in __init__ pass @actions.toggleAction def toggleDisplaySync(self): """Toggles the value of :attr:`syncOverlayDisplay`. """ # The state of this action gets bound to # the syncOverlayDisplay attribute in __init__ pass @actions.toggleControlAction(overlaylistpanel.OverlayListPanel) def toggleOverlayList(self): """Toggles an :class:`.OverlayListPanel`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(overlaylistpanel.OverlayListPanel, location=wx.BOTTOM) @actions.toggleControlAction(overlayinfopanel.OverlayInfoPanel) def toggleOverlayInfo(self, floatPane=False): """Toggles an :class:`.OverlayInfoPanel`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(overlayinfopanel.OverlayInfoPanel, location=wx.RIGHT, floatPane=floatPane) @actions.toggleControlAction(atlaspanel.AtlasPanel) def toggleAtlasPanel(self): """Toggles an :class:`.AtlasPanel`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(atlaspanel.AtlasPanel, location=wx.BOTTOM) @actions.toggleControlAction(overlaydisplaytoolbar.OverlayDisplayToolBar) def toggleDisplayToolBar(self): """Toggles an :class:`.OverlayDisplayToolBar`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(overlaydisplaytoolbar.OverlayDisplayToolBar, viewPanel=self) @actions.toggleControlAction(overlaydisplaypanel.OverlayDisplayPanel) def toggleDisplayPanel(self, floatPane=False): """Toggles an :class:`.OverlayDisplayPanel`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(overlaydisplaypanel.OverlayDisplayPanel, floatPane=floatPane, location=wx.LEFT) @actions.toggleControlAction(canvassettingspanel.CanvasSettingsPanel) def toggleCanvasSettingsPanel(self, floatPane=False): """Toggles a :class:`.CanvasSettingsPanel`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(canvassettingspanel.CanvasSettingsPanel, canvasPanel=self, floatPane=floatPane, location=wx.LEFT) @actions.toggleControlAction(locationpanel.LocationPanel) def toggleLocationPanel(self): """Toggles a :class:`.LocationPanel`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(locationpanel.LocationPanel, location=wx.BOTTOM) @actions.toggleControlAction(clusterpanel.ClusterPanel) def toggleClusterPanel(self): """Toggles a :class:`.ClusterPanel`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(clusterpanel.ClusterPanel, location=wx.TOP) @actions.toggleControlAction(lookuptablepanel.LookupTablePanel) def toggleLookupTablePanel(self): """Toggles a :class:`.LookupTablePanel`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(lookuptablepanel.LookupTablePanel, location=wx.RIGHT) @actions.toggleControlAction(melclasspanel.MelodicClassificationPanel) def toggleClassificationPanel(self): """Toggles a :class:`.MelodicClassificationPanel`. See :meth:`.ViewPanel.togglePanel`. """ self.togglePanel(melclasspanel.MelodicClassificationPanel, location=wx.RIGHT, canvasPanel=self) @property def sceneOpts(self): """Returns the :class:`.SceneOpts` instance used by this ``CanvasPanel``. """ return self.__opts @property def contentPanel(self): """Returns the ``wx.Panel`` to which sub-classes must add their content. See the note on :ref:`adding content <canvaspanel-adding-content>`. """ return self.__contentPanel @property def containerPanel(self): """Returns the ``wx.Panel`` which contains the :class:`.ColourBarPanel` if it is being displayed, and the content panel. See the note on :ref:`adding content <canvaspanel-adding-content>`. """ return self.__containerPanel @property def colourBarCanvas(self): """If a colour bar is being displayed, this method returns the :class:`.ColourBarCanvas` instance which is used by the :class:`.ColourBarPanel` to render the colour bar. Otherwise, ``None`` is returned. """ if self.__colourBar is not None: return self.__colourBar.getCanvas() return None @deprecation.deprecated(deprecated_in='0.16.0', removed_in='1.0.0', details='Use sceneOpts instead') def getSceneOptions(self): """Returns the :class:`.SceneOpts` instance used by this ``CanvasPanel``. """ return self.__opts @deprecation.deprecated(deprecated_in='0.16.0', removed_in='1.0.0', details='Use contentPanel instead') def getContentPanel(self): """Returns the ``wx.Panel`` to which sub-classes must add their content. See the note on :ref:`adding content <canvaspanel-adding-content>`. """ return self.__contentPanel @deprecation.deprecated(deprecated_in='0.16.0', removed_in='1.0.0', details='Use containerPanel instead') def getContainerPanel(self): """Returns the ``wx.Panel`` which contains the :class:`.ColourBarPanel` if it is being displayed, and the content panel. See the note on :ref:`adding content <canvaspanel-adding-content>`. """ return self.__containerPanel @deprecation.deprecated(deprecated_in='0.16.0', removed_in='1.0.0', details='Use colourBarCanvas instead') def getColourBarCanvas(self): """If a colour bar is being displayed, this method returns the :class:`.ColourBarCanvas` instance which is used by the :class:`.ColourBarPanel` to render the colour bar. Otherwise, ``None`` is returned. """ if self.__colourBar is not None: return self.__colourBar.getCanvas() return None def getGLCanvases(self): """This method must be overridden by subclasses, and must return a list containing all :class:`.SliceCanvas` instances which are being displayed. """ raise NotImplementedError('getGLCanvases has not been implemented ' 'by {}'.format(type(self).__name__)) def centrePanelLayout(self): """Lays out the centre panel. This method may be overridden by sub-classes which need more advanced layout logic. See the note on :ref:`adding content <canvaspanel-adding-content>` """ self.layoutContainerPanel() sizer = wx.BoxSizer(wx.HORIZONTAL) sizer.Add(self.__containerPanel, flag=wx.EXPAND, proportion=1) self.centrePanel.SetSizer(sizer) self.PostSizeEvent() def layoutContainerPanel(self): """Creates a ``wx.Sizer``, and uses it to lay out the colour bar panel and canvas panel. The sizer object is returned. This method is used by the default :meth:`centrePanelLayout` method, and is available for custom sub-class implementations to use. """ sopts = self.sceneOpts if not sopts.showColourBar: if self.__colourBar is not None: sopts.unbindProps('colourBarLabelSide', self.__colourBar, 'labelSide') self.__colourBar.destroy() self.__colourBar.Destroy() self.__colourBar = None sizer = wx.BoxSizer(wx.HORIZONTAL) sizer.Add(self.__contentPanel, flag=wx.EXPAND, proportion=1) self.__containerPanel.SetSizer(sizer) return if self.__colourBar is None: self.__colourBar = colourbarpanel.ColourBarPanel( self.__containerPanel, self.overlayList, self.displayCtx, self.frame) bg = sopts.bgColour fg = sopts.fgColour self.__colourBar.getCanvas().textColour = fg self.__colourBar.getCanvas().bgColour = bg sopts.bindProps('colourBarLabelSide', self.__colourBar, 'labelSide') if sopts.colourBarLocation in ('top', 'bottom'): self.__colourBar.orientation = 'horizontal' elif sopts.colourBarLocation in ('left', 'right'): self.__colourBar.orientation = 'vertical' if sopts.colourBarLocation in ('top', 'bottom'): sizer = wx.BoxSizer(wx.VERTICAL) else: sizer = wx.BoxSizer(wx.HORIZONTAL) if sopts.colourBarLocation in ('top', 'left'): sizer.Add(self.__colourBar, flag=wx.EXPAND) sizer.Add(self.__contentPanel, flag=wx.EXPAND, proportion=1) else: sizer.Add(self.__contentPanel, flag=wx.EXPAND, proportion=1) sizer.Add(self.__colourBar, flag=wx.EXPAND) self.__containerPanel.SetSizer(sizer) def __colourBarPropsChanged(self, *a): """Called when any colour bar display properties are changed (see :class:`.SceneOpts`). Calls :meth:`canvasPanelLayout`. """ self.centrePanelLayout() def __bgfgColourChanged(self, *a, **kwa): """Called when the :class:`.SceneOpts.bgColour` or :class:`.SceneOpts.fgColour` properties change. Updates background/foreground colours. The :attr:`.SliceCanvasOpts.bgColour` properties are bound to ``SceneOpts.bgColour``,(see :meth:`.HasProperties.bindProps`), so we don't need to manually update them. :arg refresh: Must be passed as a keyword argument. If ``True`` (the default), this ``OrthoPanel`` is refreshed. """ refresh = kwa.pop('refresh', True) sceneOpts = self.sceneOpts cpanel = self.contentPanel canvases = self.getGLCanvases() bg = sceneOpts.bgColour fg = sceneOpts.fgColour cpanel.SetBackgroundColour([c * 255 for c in bg]) cpanel.SetForegroundColour([c * 255 for c in fg]) if self.__colourBar is not None: cbCanvas = self.__colourBar.getCanvas() cbCanvas.textColour = fg cbCanvas.bgColour = bg canvases.append(cbCanvas) if refresh: self.Refresh() self.Update() def __movieModeChanged(self, *a): """Called when the :attr:`movieMode` property changes. If it has been enabled, calls :meth:`__movieUpdate`, to start the movie loop. """ # The fsl.utils.idle idle loop timeout # defaults to 200 milliseconds, which can # cause delays in frame updates. So when # movie mode is on, we bump up the rate. def startMovie(): idle.setIdleTimeout(10) if not self.__movieLoop(startLoop=True): idle.setIdleTimeout(None) # The __movieModeChanged method is called # on the props event queue. Here we make # sure that __movieLoop() is called *off* # the props event queue, by calling it from # the idle loop. if self.movieMode: idle.idle(startMovie) else: idle.setIdleTimeout(None) def __movieLoop(self, startLoop=False): """Manages the triggering of the next movie frame. This method is called by :meth:`__movieModeChanged` when :attr:`movieMode` changes and when the selected overlay changes, and also by :meth:`__syncMovieRefresh` and :meth:`__unsyncMovieRefresh` while the movie loop is running, to trigger the next frame. :arg startLoop: This is set to ``True`` when called from :meth:`__movieModeChanged`. If ``True``, and the movie loop is already running, this method does nothing. """ # Movie loop is already running, nothing to do. if startLoop and self.__movieRunning: return True # Attempt to show the next frame - # __movieFrame returns True if the # movie is continuing, False if it # has ended. self.__movieRunning = self.__movieFrame() return self.__movieRunning def canRunMovie(self, overlay, opts): """Returns ``True`` or ``False``, depending on whether movie mode is possible with the given z`overlay`` and ``opts``. """ import fsl.data.image as fslimage import fsl.data.mesh as fslmesh axis = self.movieAxis # 3D movies are good for all overlays if axis < 3: return True # 4D Nifti images are all good if isinstance(overlay, fslimage.Nifti) and \ len(overlay.shape) > 3 and \ overlay.shape[3] > 1 and \ isinstance(opts, displayctx.VolumeOpts): return True # Mesh surfaces with N-D # vertex data are all good if isinstance(overlay, fslmesh.TriangleMesh) and \ opts.vertexDataLen() > 1: return True return False def getMovieFrame(self, overlay, opts): """Returns the current movie frame for the given overlay. A movie frame is typically a sequentially increasing number in some minimum/maximum range, e.g. a voxel or volume index. This method may be overridden by sub-classes for custom behaviour (e.g. the :class:`.Scene3DPanel`). """ axis = self.movieAxis def nifti(): if axis < 3: return opts.getVoxel(vround=False)[axis] else: return opts.volume def mesh(): if axis < 3: return other() else: return opts.vertexDataIndex def other(): return self.displayCtx.location.getPos(axis) import fsl.data.image as fslimage import fsl.data.mesh as fslmesh if isinstance(overlay, fslimage.Nifti): return nifti() elif isinstance(overlay, fslmesh.TriangleMesh): return mesh() else: return other() def doMovieUpdate(self, overlay, opts): """Called by :meth:`__movieFrame`. Updates the properties on the given ``opts`` instance to move forward one frame in the movie. This method may be overridden by sub-classes for custom behaviour (e.g. the :class:`.Scene3DPanel`). :returns: A value which identifies the current movie frame. This may be a volume or voxel index, or a world coordinate location on one axis. """ axis = self.movieAxis def nifti(): limit = overlay.shape[axis] # This method has been called off the props # event queue (see __movieModeChanged). # Therefore, all listeners on the opts.volume # or DisplayContext.location properties # should be called immediately, in these # assignments. # # When the movie axis == 3 (time), this means # that image texture refreshes should be # triggered and, after the opts.volume # assignment, all affected GLObjects should # return ready() == False. if axis == 3: if opts.volume >= limit - 1: opts.volume = 0 else: opts.volume += 1 frame = opts.volume else: voxel = opts.getVoxel() if voxel[axis] >= limit - 1: voxel[axis] = 0 else: voxel[axis] += 1 self.displayCtx.location = opts.transformCoords( voxel, 'voxel', 'display') frame = voxel[axis] return frame def mesh(): if axis == 3: limit = opts.vertexDataLen() val = opts.vertexDataIndex if val >= limit - 1: val = 0 else: val += 1 opts.vertexDataIndex = val return val else: return other() def other(): bmin, bmax = opts.bounds.getRange(axis) delta = (bmax - bmin) / 75.0 pos = self.displayCtx.location.getPos(axis) if pos >= bmax: pos = bmin else: pos = pos + delta self.displayCtx.location.setPos(axis, pos) return pos import fsl.data.image as fslimage import fsl.data.mesh as fslmesh if isinstance(overlay, fslimage.Nifti): frame = nifti() elif isinstance(overlay, fslmesh.TriangleMesh): frame = mesh() else: frame = other() return frame def __movieFrame(self): """Called by :meth:`__movieLoop`. If the currently selected overlay (see :attr:`.DisplayContext.selectedOverlay`) is a 4D :class:`.Image` being displayed as a ``volume`` (see the :class:`.VolumeOpts` class), the :attr:`.NiftiOpts.volume` property is incremented and all GL canvases in this ``CanvasPanel`` are refreshed. :returns: ``True`` if the movie loop was started, ``False`` otherwise. """ from . import scene3dpanel if self.destroyed(): return False if not self.movieMode: return False overlay = self.displayCtx.getSelectedOverlay() canvases = self.getGLCanvases() if overlay is None: return False opts = self.displayCtx.getOpts(overlay) if not self.canRunMovie(overlay, opts): return False # We want the canvas refreshes to be # synchronised. So we 'freeze' them # while changing the image volume, and # then refresh them all afterwards. for c in canvases: c.FreezeDraw() c.FreezeSwapBuffers() self.doMovieUpdate(overlay, opts) # Now we get refs to *all* GLObjects managed # by every canvas - we have to wait until # they are all ready to be drawn before we # can refresh the canvases. Note that this # is only necessary when the movie axis == 3 globjs = [c.getGLObject(o) for c in canvases for o in self.overlayList] globjs = [g for g in globjs if g is not None] def allReady(): return all([g.ready() for g in globjs]) # Figure out the movie rate - the # number of seconds to wait until # triggering the next frame. rate = self.movieRate rateMin = self.getAttribute('movieRate', 'minval') rateMax = self.getAttribute('movieRate', 'maxval') # Special case/hack - if this is a Scene3DPanel, # and the movie axis is X/Y/Z, we always # use a fast rate. Instead, the Scene3dPanel # will increase/decrease the rotation angle # to speed up/slow down the movie instead. if isinstance(self, scene3dpanel.Scene3DPanel) and self.movieAxis < 3: rate = rateMax rate = (rateMin + (rateMax - rate)) / 1000.0 # The canvas refreshes are performed by the # __syncMovieRefresh or __unsyncMovieRefresh # methods. Gallium seems to have a problem # with separate renders/buffer swaps, so we # have to use a shitty unsynchronised update # routine. # # TODO Ideally, figure out a refresh # regime that works across all # drivers. Failing this, make # this switch user controllable. renderer = fslplatform.glRenderer.lower() unsyncRenderers = ['gallium', 'mesa dri intel(r)'] useSync = not any([r in renderer for r in unsyncRenderers]) if useSync: update = self.__syncMovieRefresh else: update = self.__unsyncMovieRefresh # Refresh the canvases when all # GLObjects are ready to be drawn. idle.idleWhen(update, allReady, canvases, rate, pollTime=rate / 10) return True def __unsyncMovieRefresh(self, canvases, rate): """Called by :meth:`__movieUpdate`. Updates all canvases in an unsynchronised manner. Ideally all canvases should be drawn off-screen (i.e. rendered to the back buffer), and then all refreshed together (back and front buffers swapped). Unfortunately some OpenGL drivers seem to have trouble with this approach, and require drawing and front/back buffer swaps to be done at the same time. This method is used for those drivers. :arg canvases: List of canvases to update. It is assumed that ``FreezeDraw`` and ``FreezeSwapBuffers`` has been called on every canvas. :arg rate: Delay to trigger the next movie update. """ for c in canvases: c.ThawDraw() c.ThawSwapBuffers() c.Refresh() idle.idle(self.__movieLoop, after=rate) def __syncMovieRefresh(self, canvases, rate): """Updates all canvases in a synchronised manner. All canvases are refreshed, and then the front/back buffers are swapped on each of them. :arg canvases: List of canvases to update. It is assumed that ``FreezeDraw`` and ``FreezeSwapBuffers`` has been called on every canvas. :arg rate: Delay to trigger the next movie update. """ for c in canvases: c.ThawDraw() c.Refresh() for c in canvases: c.ThawSwapBuffers() c.SwapBuffers() idle.idle(self.__movieLoop, after=rate)
class HistogramSeries(dataseries.DataSeries): """A ``HistogramSeries`` generates histogram data from an :class:`.Image` overlay. """ nbins = props.Int(minval=10, maxval=1000, default=100, clamped=False) """Number of bins to use in the histogram. This value is overridden by the :attr:`autoBin` setting. """ autoBin = props.Boolean(default=True) """If ``True``, the number of bins used for each :class:`HistogramSeries` is calculated automatically. Otherwise, :attr:`HistogramSeries.nbins` bins are used. """ ignoreZeros = props.Boolean(default=True) """If ``True``, zeros are excluded from the calculated histogram. """ includeOutliers = props.Boolean(default=False) """If ``True``, values which are outside of the :attr:`dataRange` are included in the histogram end bins. """ dataRange = props.Bounds(ndims=1, clamped=False) """Specifies the range of data which should be included in the histogram. See the :attr:`includeOutliers` property. """ showOverlay = props.Boolean(default=False) """If ``True``, a mask :class:`.ProxyImage` overlay is added to the :class:`.OverlayList`, which highlights the voxels that have been included in the histogram. The mask image is managed by the :class:`.HistogramProfile` instance, which manages histogram plot interaction. """ showOverlayRange = props.Bounds(ndims=1) """Data range to display with the :attr:`.showOverlay` mask. """ def __init__(self, overlay, displayCtx, overlayList): """Create a ``HistogramSeries``. :arg overlay: The :class:`.Image` overlay to calculate a histogram for. :arg displayCtx: The :class:`.DisplayContext` instance. :arg overlayList: The :class:`.OverlayList` instance. """ log.debug('New HistogramSeries instance for {} '.format(overlay.name)) dataseries.DataSeries.__init__(self, overlay) self.__name = '{}_{}'.format(type(self).__name__, id(self)) self.__displayCtx = displayCtx self.__overlayList = overlayList self.__display = displayCtx.getDisplay(overlay) self.__opts = displayCtx.getOpts(overlay) self.__nvals = 0 self.__finiteData = np.array([]) self.__xdata = np.array([]) self.__ydata = np.array([]) self.__nonZeroData = np.array([]) self.__clippedFiniteData = np.array([]) self.__clippedNonZeroData = np.array([]) self.__volCache = cache.Cache(maxsize=10) self.__histCache = cache.Cache(maxsize=100) self.__display.addListener('overlayType', self.__name, self.__overlayTypeChanged) self.__opts.addListener('volume', self.__name, self.__volumeChanged) self.addListener('dataRange', self.__name, self.__dataRangeChanged) self.addListener('nbins', self.__name, self.__histPropsChanged) self.addListener('autoBin', self.__name, self.__histPropsChanged) self.addListener('ignoreZeros', self.__name, self.__histPropsChanged) self.addListener('includeOutliers', self.__name, self.__histPropsChanged) # volumeChanged performs initial histogram- # related calculations for the current volume # (whether it is 3D or 4D) self.__volumeChanged() def destroy(self): """This needs to be called when this ``HistogramSeries`` instance is no longer being used. """ self.__display.removeListener('overlayType', self.__name) self.__opts.removeListener('volume', self.__name) self.removeListener('nbins', self.__name) self.removeListener('ignoreZeros', self.__name) self.removeListener('includeOutliers', self.__name) self.removeListener('dataRange', self.__name) self.removeListener('nbins', self.__name) self.__volCache.clear() self.__histCache.clear() self.__volCache = None self.__histCache = None self.__opts = None self.__display = None def redrawProperties(self): """Overrides :meth:`.DataSeries.redrawProperties`. The ``HistogramSeries`` data does not need to be re-plotted when the :attr:`showOverlay` or :attr:`showOverlayRange` properties change. """ propNames = dataseries.DataSeries.redrawProperties(self) propNames.remove('showOverlay') propNames.remove('showOverlayRange') return propNames def getData(self): """Overrides :meth:`.DataSeries.getData`. Returns a tuple containing the ``(x, y)`` histogram data. """ return self.__xdata, self.__ydata def getVertexData(self): """Returns a ``numpy`` array of shape ``(N, 2)``, which contains a set of "vertices" which can be used to display the histogram data as a filled polygon. """ x, y = self.getData() verts = np.zeros((len(x) * 2, 2), dtype=x.dtype) verts[:, 0] = x.repeat(2) verts[1:-1, 1] = y.repeat(2) return verts def getNumHistogramValues(self): """Returns the number of values which were used in calculating the histogram. """ return self.__nvals def __overlayTypeChanged(self, *a): """Called when the :attr:`.Display.overlayType` changes. When this happens, the :class:`.DisplayOpts` instance associated with the overlay gets destroyed and recreated. This method de-registers and re-registers property listeners as needed. """ oldOpts = self.__opts newOpts = self.__displayCtx.getOpts(self.overlay) self.__opts = newOpts oldOpts.removeListener('volume', self.__name) newOpts.addListener('volume', self.__name, self.__volumeChanged) def __volumeChanged(self, *args, **kwargs): """Called when the :attr:`volume` property changes, and also by the :meth:`__init__` method. Re-calculates some things for the new overlay volume. """ opts = self.__opts overlay = self.overlay # We cache the following for each volume # so they don't need to be recalculated: # - finite data # - non-zero data # - finite minimum # - finite maximum # # The cache size is restricted (see its # creation in __init__) so we don't blow # out RAM volkey = (opts.volumeDim, opts.volume) volprops = self.__volCache.get(volkey, None) if volprops is None: log.debug('Volume changed {} - extracting ' 'finite/non-zero data'.format(volkey)) finData = overlay[opts.index()] finData = finData[np.isfinite(finData)] nzData = finData[finData != 0] dmin = finData.min() dmax = finData.max() self.__volCache.put(volkey, (finData, nzData, dmin, dmax)) else: log.debug('Volume changed {} - got finite/' 'non-zero data from cache'.format(volkey)) finData, nzData, dmin, dmax = volprops dist = (dmax - dmin) / 10000.0 with props.suppressAll(self): self.dataRange.xmin = dmin self.dataRange.xmax = dmax + dist self.dataRange.xlo = dmin self.dataRange.xhi = dmax + dist self.nbins = autoBin(nzData, self.dataRange.x) self.__finiteData = finData self.__nonZeroData = nzData self.__dataRangeChanged() with props.skip(self, 'dataRange', self.__name): self.propNotify('dataRange') def __dataRangeChanged(self, *args, **kwargs): """Called when the :attr:`dataRange` property changes, and also by the :meth:`__initProperties` and :meth:`__volumeChanged` methods. """ finData = self.__finiteData nzData = self.__nonZeroData self.__clippedFiniteData = finData[(finData >= self.dataRange.xlo) & (finData < self.dataRange.xhi)] self.__clippedNonZeroData = nzData[(nzData >= self.dataRange.xlo) & (nzData < self.dataRange.xhi)] with props.suppress(self, 'showOverlayRange', notify=True): dlo, dhi = self.dataRange.x dist = (dhi - dlo) / 10000.0 needsInit = np.all(np.isclose(self.showOverlayRange.x, [0, 0])) self.showOverlayRange.xmin = dlo - dist self.showOverlayRange.xmax = dhi + dist if needsInit or not self.showOverlay: self.showOverlayRange.xlo = dlo self.showOverlayRange.xhi = dhi else: self.showOverlayRange.xlo = max(dlo, self.showOverlayRange.xlo) self.showOverlayRange.xhi = min(dhi, self.showOverlayRange.xhi) self.__histPropsChanged() def __histPropsChanged(self, *a): """Called internally, and when any histogram settings change. Re-calculates the histogram data. """ log.debug('Calculating histogram for ' 'overlay {}'.format(self.overlay.name)) status.update('Calculating histogram for ' 'overlay {}'.format(self.overlay.name)) if np.isclose(self.dataRange.xhi, self.dataRange.xlo): self.__xdata = np.array([]) self.__ydata = np.array([]) self.__nvals = 0 return if self.ignoreZeros: if self.includeOutliers: data = self.__nonZeroData else: data = self.__clippedNonZeroData else: if self.includeOutliers: data = self.__finiteData else: data = self.__clippedFiniteData # Figure out the number of bins to use if self.autoBin: nbins = autoBin(data, self.dataRange.x) else: nbins = self.nbins # nbins is unclamped, but # we don't allow < 10 if nbins < 10: nbins = 10 # Update the nbins property with props.skip(self, 'nbins', self.__name): self.nbins = nbins # We cache calculated bins and counts # for each combination of parameters, # as histogram calculation can take # time. hrange = (self.dataRange.xlo, self.dataRange.xhi) drange = (self.dataRange.xmin, self.dataRange.xmax) histkey = ((self.__opts.volumeDim, self.__opts.volume), self.includeOutliers, hrange, drange, self.nbins) cached = self.__histCache.get(histkey, None) if cached is not None: histX, histY, nvals = cached else: histX, histY, nvals = histogram(data, self.nbins, hrange, drange, self.includeOutliers, True) self.__histCache.put(histkey, (histX, histY, nvals)) self.__xdata = histX self.__ydata = histY self.__nvals = nvals status.update('Histogram for {} calculated.'.format(self.overlay.name)) log.debug('Calculated histogram for overlay ' '{} (number of values: {}, number ' 'of bins: {})'.format(self.overlay.name, self.__nvals, self.nbins))
class NiftiOpts(fsldisplay.DisplayOpts): """The ``NiftiOpts`` class describes how a :class:`.Nifti` overlay should be displayed. ``NiftiOpts`` is the base class for a number of :class:`.DisplayOpts` sub-classes - it contains display options which are common to all overlay types that represent a NIFTI image. """ volume = props.Int(minval=0, maxval=0, default=0, clamped=True) """If the ``Image`` has more than 3 dimensions, the current volume to display. The volume dimension is controlled by the :attr:`volumeDim` property. """ volumeDim = props.Int(minval=0, maxval=5, default=0, clamped=True) """For images with more than three dimensions, this property controls the dimension that the :attr:`volume` property indexes into. When the ``volumeDim`` changes, the ``volume`` for the previous ``volumeDim`` is fixed at its last value, and used for subsequent lookups. """ transform = props.Choice( ('affine', 'pixdim', 'pixdim-flip', 'id', 'reference'), default='pixdim-flip') """This property defines how the overlay should be transformd into the display coordinate system. See the :ref:`note on coordinate systems <volumeopts-coordinate-systems>` for important information regarding this property. """ displayXform = props.Array(dtype=np.float64, shape=(4, 4), resizable=False, default=[[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) """A custom transformation matrix which is concatenated on to the voxel -> world transformation of the :class:`.Nifti` overlay. This transform is intended for temporary changes to the overlay display (when :attr:`.DisplayContext.displaySpace` ``== 'world'``) - changes to it will *not* result in the ::attr:`.DisplayContext.bounds` being updated. If you change the ``displayXform``, make sure to change it back to an identity matrix when you are done. """ enableOverrideDataRange = props.Boolean(default=False) """By default, the :attr:`.Image.dataRange` property is used to set display and clipping ranges. However, if this property is ``True``, the :attr:`overrideDataRange` is used instead. ..note:: The point of this property is to make it easier to display images with a very large data range driven by outliers. On platforms which do not support floating point textures, these images are impossible to display unless they are normalised according to a smaller data range. See the :meth:`.Texture3D.__determineTextureType` method for some more details. """ overrideDataRange = props.Bounds(ndims=1, clamped=False) """Data range used in place of the :attr:`.Image.dataRange` if the :attr:`enableOverrideDataRange` property is ``True``. """ def __init__(self, *args, **kwargs): """Create a ``NiftiOpts`` instance. All arguments are passed through to the :class:`.DisplayOpts` constructor. """ nounbind = kwargs.get('nounbind', []) nobind = kwargs.get('nobind', []) nounbind.append('overrideDataRange') nounbind.append('enableOverrideDataRange') nobind.append('displayXform') kwargs['nounbind'] = nounbind kwargs['nobind'] = nobind fsldisplay.DisplayOpts.__init__(self, *args, **kwargs) self.__child = self.getParent() is not None if self.__child: # is this a >3D volume? ndims = self.overlay.ndim # We store indices for every dimension # past the XYZ dims. Whenever the volumeDim # changes, we cache the index for the old # dimensions, and restore the index for the # new dimension. self.setAttribute('volumeDim', 'maxval', max(0, ndims - 4)) self.setAttribute('volume', 'cache', [0] * (ndims - 3)) if ndims <= 3: self.setAttribute('volume', 'maxval', 0) self.overlay.register(self.name, self.__overlayTransformChanged, topic='transform') self.addListener('volumeDim', self.name, self.__volumeDimChanged, immediate=True) self.addListener('transform', self.name, self.__transformChanged, immediate=True) self.addListener('displayXform', self.name, self.__displayXformChanged, immediate=True) self.displayCtx.addListener('displaySpace', self.name, self.__displaySpaceChanged, immediate=True) # The display<->* transformation matrices # are created in the _setupTransforms method. # The __displaySpaceChanged method registers # a listener with the current display space # (if it is an overlay) self.__xforms = {} self.__dsOverlay = None self.__setupTransforms() self.__transformChanged() self.__volumeDimChanged() def destroy(self): """Calls the :meth:`.DisplayOpts.destroy` method. """ if self.__child: self.overlay.deregister(self.name, topic='transform') self.displayCtx.removeListener('displaySpace', self.name) self.removeListener('volumeDim', self.name) self.removeListener('transform', self.name) self.removeListener('displayXform', self.name) if self.__dsOverlay is not None: self.__dsOverlay.deregister(self.name, topic='transform') self.__dsOverlay = None fsldisplay.DisplayOpts.destroy(self) def __toggleSiblingListeners(self, enable=True): """Enables/disables the ``volumeDim`` listeners of sibling ``NiftiOpts`` instances. This is used by the :meth:`__volumeDimChanged` method to avoid nastiness. """ for s in self.getParent().getChildren(): if s is not self: if enable: s.enableListener('volumeDim', s.name) else: s.disableListener('volumeDim', s.name) def __volumeDimChanged(self, *a): """Called when the :attr:`volumeDim` changes. Saves the value of ``volume`` for the last ``volumeDim``, and restores the previous value of ``volume`` for the new ``volumeDim``. """ if self.overlay.ndim <= 3: return # Here we disable volumeDim listeners on all # sibling instances, then save/restore the # volume value and properties asynchronously, # then re-enable the slblings. This is a # horrible means of ensuring that only the # first VolumeOpts instance (out of a set of # synchronised instances) updates the volume # value and properties. The other instances # will be updated through synchronisation. # This is necessary because subsequent # instances would corrupt the update made by # the first instance. # # A nicer way to do things like this would be # nice. def update(): oldVolume = self.volume oldVolumeDim = self.getLastValue('volumeDim') if oldVolumeDim is None: oldVolumeDim = 0 cache = list(self.getAttribute('volume', 'cache')) cache[oldVolumeDim] = oldVolume newVolume = cache[self.volumeDim] newVolumeLim = self.overlay.shape[self.volumeDim + 3] - 1 self.setAttribute('volume', 'maxval', newVolumeLim) self.setAttribute('volume', 'cache', cache) self.volume = newVolume self.__toggleSiblingListeners(False) props.safeCall(update) props.safeCall(self.__toggleSiblingListeners, True) def __overlayTransformChanged(self, *a): """Called when the :class:`.Nifti` overlay sends a notification on the ``'transform'`` topic, indicating that its voxel->world transformation matrix has been updated. """ self.__setupTransforms() self.__transformChanged() def __displaySpaceTransformChanged(self, *a): """Called when the :attr:`.DisplayContext.displaySpace` is a :class:`.Nifti` overlay, and its :attr:`.Nifti.voxToWorldMat` changes. Updates the transformation matrices for this image. """ self.__setupTransforms() self.__transformChanged() def __transformChanged(self, *a): """Called when the :attr:`transform` property changes. Calculates the min/max values of a 3D bounding box, in the display coordinate system, which is big enough to contain the image. Sets the :attr:`.DisplayOpts.bounds` property accordingly. """ lo, hi = affine.axisBounds(self.overlay.shape[:3], self.getTransform('voxel', 'display')) self.bounds[:] = [lo[0], hi[0], lo[1], hi[1], lo[2], hi[2]] def __displaySpaceChanged(self, *a): """Called when the :attr:`.DisplayContext.displaySpace` property changes. Re-generates transformation matrices, and re-calculates the display :attr:`bounds` (via calls to :meth:`__setupTransforms` and :meth:`__transformChanged`). """ displaySpace = self.displayCtx.displaySpace if self.__dsOverlay is not None: self.__dsOverlay.deregister(self.name, topic='transform') self.__dsOverlay = None # Register a listener on the display space reference # image, because when its voxToWorldMat changes, we # need to update our *toref and refto* transforms. if isinstance(displaySpace, fslimage.Nifti) and \ displaySpace is not self.overlay: self.__dsOverlay = displaySpace self.__dsOverlay.register(self.name, self.__displaySpaceTransformChanged, topic='transform') self.__setupTransforms() if self.transform == 'reference': self.__transformChanged() def __displayXformChanged(self, *a): """Called when the :attr:`displayXform` property changes. Updates the transformation matrices and :attr:`bounds` accordingly. Critically, when the :attr:`displayXform` property changes, the :class:`.DisplayContext` is *not* notified. This is because the ``displayXform`` is intended for temporary changes. """ # The displayXform is intended as a temporary # transformation for display purposes - the # DisplayOpts.bounds property gets updated when # it changes, but we don't want the # DisplayContext.bounds property to be updated. # So we suppress all notification while # updating the transformation matrices. with self.displayCtx.freeze(self.overlay): self.__setupTransforms() self.__transformChanged() def __setupTransforms(self): """Calculates transformation matrices between all of the possible spaces in which the overlay may be displayed. These matrices are accessible via the :meth:`getTransform` method. """ image = self.overlay shape = np.array(image.shape[:3]) voxToIdMat = np.eye(4) voxToPixdimMat = np.diag(list(image.pixdim[:3]) + [1.0]) voxToPixFlipMat = image.voxToScaledVoxMat voxToWorldMat = image.voxToWorldMat voxToWorldMat = affine.concat(self.displayXform, voxToWorldMat) ds = self.displayCtx.displaySpace # The reference transforms depend # on the value of displaySpace if ds == 'world': voxToRefMat = voxToWorldMat elif ds is self.overlay: voxToRefMat = voxToPixFlipMat else: voxToRefMat = affine.concat(ds.voxToScaledVoxMat, ds.worldToVoxMat, voxToWorldMat) # When going from voxels to textures, # we add 0.5 to centre the voxel (see # the note on coordinate systems at # the top of this file). voxToTexMat = affine.scaleOffsetXform(tuple(1.0 / shape), tuple(0.5 / shape)) idToVoxMat = affine.invert(voxToIdMat) idToPixdimMat = affine.concat(voxToPixdimMat, idToVoxMat) idToPixFlipMat = affine.concat(voxToPixFlipMat, idToVoxMat) idToWorldMat = affine.concat(voxToWorldMat, idToVoxMat) idToRefMat = affine.concat(voxToRefMat, idToVoxMat) idToTexMat = affine.concat(voxToTexMat, idToVoxMat) pixdimToVoxMat = affine.invert(voxToPixdimMat) pixdimToIdMat = affine.concat(voxToIdMat, pixdimToVoxMat) pixdimToPixFlipMat = affine.concat(voxToPixFlipMat, pixdimToVoxMat) pixdimToWorldMat = affine.concat(voxToWorldMat, pixdimToVoxMat) pixdimToRefMat = affine.concat(voxToRefMat, pixdimToVoxMat) pixdimToTexMat = affine.concat(voxToTexMat, pixdimToVoxMat) pixFlipToVoxMat = affine.invert(voxToPixFlipMat) pixFlipToIdMat = affine.concat(voxToIdMat, pixFlipToVoxMat) pixFlipToPixdimMat = affine.concat(voxToPixdimMat, pixFlipToVoxMat) pixFlipToWorldMat = affine.concat(voxToWorldMat, pixFlipToVoxMat) pixFlipToRefMat = affine.concat(voxToRefMat, pixFlipToVoxMat) pixFlipToTexMat = affine.concat(voxToTexMat, pixFlipToVoxMat) worldToVoxMat = affine.invert(voxToWorldMat) worldToIdMat = affine.concat(voxToIdMat, worldToVoxMat) worldToPixdimMat = affine.concat(voxToPixdimMat, worldToVoxMat) worldToPixFlipMat = affine.concat(voxToPixFlipMat, worldToVoxMat) worldToRefMat = affine.concat(voxToRefMat, worldToVoxMat) worldToTexMat = affine.concat(voxToTexMat, worldToVoxMat) refToVoxMat = affine.invert(voxToRefMat) refToIdMat = affine.concat(voxToIdMat, refToVoxMat) refToPixdimMat = affine.concat(voxToPixdimMat, refToVoxMat) refToPixFlipMat = affine.concat(voxToPixFlipMat, refToVoxMat) refToWorldMat = affine.concat(voxToWorldMat, refToVoxMat) refToTexMat = affine.concat(voxToTexMat, refToVoxMat) texToVoxMat = affine.invert(voxToTexMat) texToIdMat = affine.concat(voxToIdMat, texToVoxMat) texToPixdimMat = affine.concat(voxToPixdimMat, texToVoxMat) texToPixFlipMat = affine.concat(voxToPixFlipMat, texToVoxMat) texToWorldMat = affine.concat(voxToWorldMat, texToVoxMat) texToRefMat = affine.concat(voxToRefMat, texToVoxMat) self.__xforms['id', 'id'] = np.eye(4) self.__xforms['id', 'pixdim'] = idToPixdimMat self.__xforms['id', 'pixdim-flip'] = idToPixFlipMat self.__xforms['id', 'affine'] = idToWorldMat self.__xforms['id', 'reference'] = idToRefMat self.__xforms['id', 'texture'] = idToTexMat self.__xforms['pixdim', 'pixdim'] = np.eye(4) self.__xforms['pixdim', 'id'] = pixdimToIdMat self.__xforms['pixdim', 'pixdim-flip'] = pixdimToPixFlipMat self.__xforms['pixdim', 'affine'] = pixdimToWorldMat self.__xforms['pixdim', 'reference'] = pixdimToRefMat self.__xforms['pixdim', 'texture'] = pixdimToTexMat self.__xforms['pixdim-flip', 'pixdim-flip'] = np.eye(4) self.__xforms['pixdim-flip', 'id'] = pixFlipToIdMat self.__xforms['pixdim-flip', 'pixdim'] = pixFlipToPixdimMat self.__xforms['pixdim-flip', 'affine'] = pixFlipToWorldMat self.__xforms['pixdim-flip', 'reference'] = pixFlipToRefMat self.__xforms['pixdim-flip', 'texture'] = pixFlipToTexMat self.__xforms['affine', 'affine'] = np.eye(4) self.__xforms['affine', 'id'] = worldToIdMat self.__xforms['affine', 'pixdim'] = worldToPixdimMat self.__xforms['affine', 'pixdim-flip'] = worldToPixFlipMat self.__xforms['affine', 'reference'] = worldToRefMat self.__xforms['affine', 'texture'] = worldToTexMat self.__xforms['reference', 'reference'] = np.eye(4) self.__xforms['reference', 'id'] = refToIdMat self.__xforms['reference', 'pixdim'] = refToPixdimMat self.__xforms['reference', 'pixdim-flip'] = refToPixFlipMat self.__xforms['reference', 'affine'] = refToWorldMat self.__xforms['reference', 'texture'] = refToTexMat self.__xforms['texture', 'texture'] = np.eye(4) self.__xforms['texture', 'id'] = texToIdMat self.__xforms['texture', 'pixdim'] = texToPixdimMat self.__xforms['texture', 'pixdim-flip'] = texToPixFlipMat self.__xforms['texture', 'affine'] = texToWorldMat self.__xforms['texture', 'reference'] = texToRefMat @classmethod def getVolumeProps(cls): """Overrides :meth:`DisplayOpts.getVolumeProps`. Returns a list of property names which control the displayed volume/timepoint. """ return ['volume', 'volumeDim'] def getTransform(self, from_, to, xform=None): """Return a matrix which may be used to transform coordinates from ``from_`` to ``to``. Valid values for ``from_`` and ``to`` are: =============== ====================================================== ``id`` Voxel coordinates ``voxel`` Equivalent to ``id``. ``pixdim`` Voxel coordinates, scaled by voxel dimensions ``pixdim-flip`` Voxel coordinates, scaled by voxel dimensions, and with the X axis flipped if the affine matrix has a positivie determinant. If the affine matrix does not have a positive determinant, this is equivalent to ``pixdim``. ``pixflip`` Equivalent to ``pixdim-flip``. ``affine`` World coordinates, as defined by the NIFTI ``qform``/``sform``. See :attr:`.Image.voxToWorldMat`. ``world`` Equivalent to ``affine``. ``reference`` ``pixdim-flip`` coordinates of the reference image specified by the :attr:`.DisplayContext.displaySpace` attribute. If the ``displaySpace`` is set to ``'world'``, this is equivalent to ``affine``. ``ref`` Equivalent to ``reference``. ``display`` Equivalent to the current value of :attr:`transform`. ``texture`` Voxel coordinates scaled to lie between 0.0 and 1.0, suitable for looking up voxel values when stored as an OpenGL texture. =============== ====================================================== If the ``xform`` parameter is provided, and one of ``from_`` or ``to`` is ``display``, the value of ``xform`` is used instead of the current value of :attr:`transform`. """ if not self.__child: raise RuntimeError('getTransform cannot be called on ' 'a parent NiftiOpts instance') if xform is None: xform = self.transform if from_ == 'display': from_ = xform elif from_ == 'world': from_ = 'affine' elif from_ == 'voxel': from_ = 'id' elif from_ == 'pixflip': from_ = 'pixdim-flip' elif from_ == 'ref': from_ = 'reference' if to == 'display': to = xform elif to == 'world': to = 'affine' elif to == 'voxel': to = 'id' elif to == 'pixflip': to = 'pixdim-flip' elif to == 'ref': to = 'reference' return self.__xforms[from_, to] def roundVoxels(self, voxels, daxes=None, roundOther=False): """Round the given voxel coordinates to integers. This is a surprisingly complicated operation. FSLeyes and the NIFTI standard map integer voxel coordinates to the voxel centre. For example, a voxel [3, 4, 5] fills the space:: [2.5-3.5, 3.5-4.5, 4.5-5.5]. So all we need to do is round to the nearest integer. But there are a few problems with breaking ties when rounding... The numpy.round function breaks ties (e.g. 7.5) by rounding to the nearest *even* integer, which can cause funky behaviour. So instead of using numpy.round, we take floor(x+0.5), to force consistent behaviour (i.e. always rounding central values up). The next problem is that we have to round the voxel coordaintes carefully, depending on the orientation of the voxel axis w.r.t. the display axis. We want to round in the same direction in the display coordinate system, regardless of the voxel orientation. So we need to check the orientation of the voxel axis, and round down or up accordingly. This is to handle scenarios where we have two anatomically aligned images, but with opposing storage orders (e.g. one stored neurologically, and one stored radiologically). If we have such images, and the display location is on a voxel boundary, we want the voxel coordinates for one image to be rounded in the same anatomical direction (i.e. the same direction in the display coordinate system). Otherwise the same display location will map to mis-aligned voxels in the two images, because the voxel coordinate rounding will move in anatomically opposing directions. This method also prevents coordinates that are close to 0 from being set to -1, and coordinates that are close to the axis size from being set to (size + 1). In other words, voxel coordinates which are on the low or high boundaries will be rounded so as to be valid voxel coordinates. :arg voxels: A ``(N, 3)`` ``numpy`` array containing the voxel coordinates to be rounded. :arg daxes: Display coordinate system axes along which to round the coordinates (defaults to all axes). :arg roundOther: If ``True``, any voxel axes which are not in ``daxes`` will still be rounded, but not with an orientation-specific rounding convention. :returns: The ``voxels``, rounded appropriately. """ if not self.__child: raise RuntimeError('roundVoxels cannot be called on ' 'a parent NiftiOpts instance') if daxes is None: daxes = list(range(3)) shape = self.overlay.shape[:3] ornts = self.overlay.axisMapping(self.getTransform('display', 'voxel')) # We start by truncating the precision # of the coordinates, so that values # which are very close to voxel midpoints # (e.g. 0.49999), get rounded to 0.5. voxels = np.round(voxels, decimals=3) # Keep track of the voxel axes that # have had the rounding treatment roundedAxes = [] for dax in daxes: ornt = ornts[dax] vax = abs(ornt) - 1 vals = voxels[:, vax] roundedAxes.append(vax) # Identify values which are close # to the low or high bounds - we # will clamp them after rounding. # # This is a third rounding problem # which is not documented above - # we clamp low/high values to avoid # them under/overflowing in the # floor/ceil operations below closeLow = np.isclose(vals, -0.5) closeHigh = np.isclose(vals, shape[vax] - 0.5) # Round in a direction which is # dictated by the image orientation if ornt < 0: vals = np.floor(vals + 0.5) else: vals = np.ceil(vals - 0.5) # Clamp low/high voxel coordinates vals[closeLow] = 0 vals[closeHigh] = shape[vax] - 1 voxels[:, vax] = vals # If the roundOther flag is true, # we round all other voxel axes # in a more conventional manner # (but still using floor(v + 0.5) # rather than round to avoid # annoying numpy even/odd behaviour). if roundOther: for vax in range(3): if vax not in roundedAxes: voxels[:, vax] = np.floor(voxels[:, vax] + 0.5) return voxels def transformCoords(self, coords, from_, to_, vround=False, vector=False, pre=None, post=None): """Transforms the given coordinates from ``from_`` to ``to_``. The ``from_`` and ``to_`` parameters must be those accepted by the :meth:`getTransform` method. :arg coords: Coordinates to transform :arg from_: Space to transform from :arg to_: Space to transform to :arg vround: If ``True``, and ``to_ in ('voxel', 'id)``, the transformed coordinates are rounded to the nearest integer. :arg vector: Defaults to ``False``. If ``True``, the coordinates are treated as vectors. :arg pre: Transformation to apply before the ``from_``-to-``to`` transformation. :arg post: Transformation to apply after the ``from_``-to-``to`` transformation. """ if not self.__child: raise RuntimeError('transformCoords cannot be called on ' 'a parent NiftiOpts instance') xform = self.getTransform(from_, to_) if pre is not None: xform = affine.concat(xform, pre) if post is not None: xform = affine.concat(post, xform) coords = np.array(coords) coords = affine.transform(coords, xform, vector=vector) # Round to integer voxel coordinates? if to_ in ('voxel', 'id') and vround: coords = self.roundVoxels(coords) return coords def getVoxel(self, xyz=None, clip=True, vround=True): """Calculates and returns the voxel coordinates corresponding to the given location (assumed to be in the display coordinate system) for the :class:`.Nifti` associated with this ``NiftiOpts`` instance.. :arg xyz: Display space location to convert to voxels. If not provided, the current :attr:`.DisplayContext.location` is used. :arg clip: If ``False``, and the transformed coordinates are out of the voxel coordinate bounds, the coordinates returned anyway. Defaults to ``True``. :arg vround: If ``True``, the returned voxel coordinates are rounded to the nearest integer. Otherwise they may be fractional. :returns: ``None`` if the location is outside of the image bounds, unless ``clip=False``. """ if not self.__child: raise RuntimeError('getVoxel cannot be called on ' 'a parent NiftiOpts instance') if xyz is not None: x, y, z = xyz else: x, y, z = self.displayCtx.location.xyz overlay = self.overlay vox = self.transformCoords([[x, y, z]], 'display', 'voxel', vround=vround)[0] if vround: vox = [int(v) for v in vox] if not clip: return vox for ax in (0, 1, 2): if vox[ax] < 0 or vox[ax] >= overlay.shape[ax]: return None return vox def index(self, slc=None, atVolume=True): """Given a slice object ``slc``, which indexes into the X, Y, and Z dimensions, fills it to slice every dimension of the image, using the current :attr:`volume` and :attr:`volumeDim`, and saved values for the other volume dimensions. :arg slc: Something which can slice the first three dimensions of the image. If ``None``, defaults to ``[:, :, :]``. :arg atVolume: If ``True``, the returned slice will index the current :attr:`volume` of the current :attr:`volumeDim`. Otherwise the returned slice will index across the whole :attr:`volumeDim`. """ if slc is None: slc = [slice(None), slice(None), slice(None)] if self.overlay.ndim <= 3: return tuple(slc) newSlc = [None] * self.overlay.ndim newSlc[:3] = slc newSlc[3:] = self.getAttribute('volume', 'cache') vdim = self.volumeDim + 3 if atVolume: newSlc[vdim] = self.volume else: newSlc[vdim] = slice(None) return tuple(newSlc)
class Foo(props.SyncableHasProperties): int1 = props.Int(default=0) int2 = props.Int(default=0)
class SceneOpts(props.HasProperties): """The ``SceneOpts`` class defines settings which are used by :class:`.CanvasPanel` instances. Several of the properties of the ``SceneOpts`` class are defined in the :class:`.SliceCanvasOpts` class, so see its documentation for more details. """ showCursor = copy.copy(canvasopts.SliceCanvasOpts.showCursor) zoom = copy.copy(canvasopts.SliceCanvasOpts.zoom) bgColour = copy.copy(canvasopts.SliceCanvasOpts.bgColour) cursorColour = copy.copy(canvasopts.SliceCanvasOpts.cursorColour) renderMode = copy.copy(canvasopts.SliceCanvasOpts.renderMode) highDpi = copy.copy(canvasopts.SliceCanvasOpts.highDpi) fgColour = props.Colour(default=(1, 1, 1)) """Colour to use for foreground items (e.g. labels). .. note:: This colour is automatically updated whenever the :attr:`.bgColour` is changed. But it can be modified independently. """ showColourBar = props.Boolean(default=False) """If ``True``, and it is possible to do so, a colour bar is shown on the scene. """ colourBarLocation = props.Choice(('top', 'bottom', 'left', 'right')) """This property controls the location of the colour bar, if it is being shown. """ colourBarLabelSide = props.Choice(('top-left', 'bottom-right')) """This property controls the location of the colour bar labels, relative to the colour bar, if it is being shown. """ colourBarSize = props.Percentage(default=100) """Size of the major axis of the colour bar, as a proportion of the available space. """ labelSize = props.Int(minval=4, maxval=96, default=12, clamped=True) """Font size used for any labels drawn on the canvas, including orthographic labels, and colour bar labels. """ # NOTE: If you change the maximum performance value, # make sure you update all references to # performance because, for example, the # OrthoEditProfile does numerical comparisons # to it. performance = props.Choice((1, 2, 3), default=3, allowStr=True) """User controllable performance setting. This property is linked to the :attr:`renderMode` property. Setting this property to a low value will result in faster rendering time, at the cost of increased memory usage and poorer rendering quality. See the :meth:`__onPerformanceChange` method. """ movieSyncRefresh = props.Boolean(default=True) """Whether, when in movie mode, to synchronise the refresh for GL canvases. This is not possible in some platforms/environments. See :attr:`.CanvasPanel.movieSyncRefresh`. """ def __init__(self, panel): """Create a ``SceneOpts`` instance. This method simply links the :attr:`performance` property to the :attr:`renderMode` property. """ self.__panel = panel self.__name = '{}_{}'.format(type(self).__name__, id(self)) self.movieSyncRefresh = self.defaultMovieSyncRefresh self.addListener('performance', self.__name, self._onPerformanceChange) self.addListener('bgColour', self.__name, self.__onBgColourChange) self._onPerformanceChange() @property def defaultMovieSyncRefresh(self): """In movie mode, the canvas refreshes are performed by the __syncMovieRefresh or __unsyncMovieRefresh methods of the CanvasPanel class. Some platforms/GL drivers/environments seem to have a problem with separate renders/buffer swaps, so we have to use a shitty unsynchronised update routine. These heuristics are not perfect - the movieSyncRefresh property can therefore be overridden by the user. """ renderer = fslgl.GL_RENDERER.lower() unsyncRenderers = ['gallium', 'mesa dri intel(r)'] unsync = any([r in renderer for r in unsyncRenderers]) return not unsync @property def panel(self): """Return a reference to the ``CanvasPanel`` that owns this ``SceneOpts`` instance. """ return self.__panel def _onPerformanceChange(self, *a): """Called when the :attr:`performance` property changes. This method must be overridden by sub-classes to change the values of the :attr:`renderMode` property according to the new performance setting. """ raise NotImplementedError('The _onPerformanceChange method must' 'be implemented by sub-classes') def __onBgColourChange(self, *a): """Called when the background colour changes. Updates the :attr:`fgColour` to a complementary colour. """ self.fgColour = fslcm.complementaryColour(self.bgColour)
class OrthoOpts(sceneopts.SceneOpts): """The ``OrthoOpts`` class is used by :class:`.OrthoPanel` instances to manage their display settings. .. note:: While the ``OrthoOpts`` class has :attr:`xzoom`, :attr:`yzoom`, and :attr:`zzoom`, properties which control the zoom levels on each canvas independently, ``OrthoOpts`` class also inherits a ``zoom`` property from the :class:`.SceneOpts` class. This *global* zoom property can be used to adjust all canvas zoom levels simultaneously. """ cursorGap = copy.copy(canvasopts.SliceCanvasOpts.cursorGap) showXCanvas = props.Boolean(default=True) """Toggles display of the X canvas.""" showYCanvas = props.Boolean(default=True) """Toggles display of the Y canvas.""" showZCanvas = props.Boolean(default=True) """Toggles display of the Z canvas.""" showLabels = props.Boolean(default=True) """If ``True``, labels showing anatomical orientation are displayed on each of the canvases. """ labelSize = props.Int(minval=4, maxval=96, default=14, clamped=True) """Label font size.""" layout = props.Choice(('horizontal', 'vertical', 'grid')) """How should we lay out each of the three canvases?""" xzoom = copy.copy(sceneopts.SceneOpts.zoom) """Controls zoom on the X canvas.""" yzoom = copy.copy(sceneopts.SceneOpts.zoom) """Controls zoom on the Y canvas.""" zzoom = copy.copy(sceneopts.SceneOpts.zoom) """Controls zoom on the Z canvas. """ def __init__(self, *args, **kwargs): """Create an ``OrthoOpts`` instance. All arguments are passed through to the :class:`.SceneOpts` constructor. This method sets up a binding from the :attr:`.SceneOpts.zoom` property to the :attr:`xzoom`, :attr:`yzoom`, and :attr:`zzoom` properties - see :meth:`__onZoom`. """ sceneopts.SceneOpts.__init__(self, *args, **kwargs) name = '{}_{}'.format(type(self).__name__, id(self)) self.addListener('zoom', name, self.__onZoom) def __onZoom(self, *a): """Called when the :attr:`.SceneOpts.zoom` property changes. Propagates the change to the :attr:`xzoom`, :attr:`yzoom`, and :attr:`zzoom` properties. """ self.xzoom = self.zoom self.yzoom = self.zoom self.zzoom = self.zoom def _onPerformanceChange(self, *a): """Overrides :meth:`.SceneOpts._onPerformanceChange`. Changes the value of the :attr:`renderMode` property according to the performance setting. """ if self.performance == 3: self.renderMode = 'onscreen' elif self.performance == 2: self.renderMode = 'offscreen' elif self.performance == 1: self.renderMode = 'prerender' log.debug('Performance settings changed: ' 'renderMode={}'.format(self.renderMode))
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 ColourBar(props.HasProperties, notifier.Notifier): """A ``ColourBar`` is an object which listens to the properties of a :class:`.ColourMapOpts` instance, and automatically generates a colour bar bitmap representing the current colour map properties. Whenever the colour bar is refreshed, a notification is emitted via the :class:`.Notifier` interface. """ orientation = props.Choice(('horizontal', 'vertical')) """Whether the colour bar should be vertical or horizontal. """ labelSide = props.Choice(('top-left', 'bottom-right')) """Whether the colour bar labels should be on the top/left, or bottom/right of the colour bar (depending upon whether the colour bar orientation is horizontal/vertical). """ textColour = props.Colour(default=(1, 1, 1, 1)) """Colour to use for the colour bar label. """ bgColour = props.Colour(default=(0, 0, 0, 1)) """Colour to use for the background. """ showLabel = props.Boolean(default=True) """Toggle the colour bar label (the :attr:`.Display.name` property). """ showTicks = props.Boolean(default=True) """Toggle the tick labels (the :attr:`.ColourMapOpts.displayRange`). """ fontSize = props.Int(minval=4, maxval=96, default=12) """Size of the font used for the text on the colour bar.""" def __init__(self, overlayList, displayCtx): """Create a ``ColourBar``. :arg overlayList: The :class:`.OverlayList`. :arg displayCtx: The :class:`.DisplayContext`. """ self.__overlayList = overlayList self.__displayCtx = displayCtx self.__name = '{}_{}'.format(type(self).__name__, id(self)) overlayList.addListener('overlays', self.name, self.__selectedOverlayChanged) displayCtx .addListener('selectedOverlay', self.name, self.__selectedOverlayChanged) self.addGlobalListener(self.name, self.__clearColourBar) self.__opts = None self.__display = None self.__size = (None, None, None) self.__colourBar = None self.__selectedOverlayChanged() @property def name(self): """Return the name of this ColourBar, used internally for registering property listeners. """ return self.__name def destroy(self): """Must be called when this ``ColourBar`` is no longer needed. Removes all registered listeners from the :class:`.OverlayList`, :class:`.DisplayContext`, and foom individual overlays. """ self.__overlayList.removeListener('overlays', self.name) self.__displayCtx .removeListener('selectedOverlay', self.name) self.__deregisterOverlay() def __selectedOverlayChanged(self, *a): """Called when the :class:`.OverlayList` or the :attr:`.DisplayContext.selectedOverlay` changes. If the newly selected overlay is being displayed with a :class:`.ColourMapOpts` instance, various property listeners are registered, and the colour bar is refreshed. """ self.__deregisterOverlay() self.__registerOverlay() self.__clearColourBar() def __deregisterOverlay(self): """Called when the selected overlay changes. De-registers property listeners from any previously-registered :class:`.ColourMapOpts` instance. """ if self.__opts is None: return try: opts = self.__opts display = self.__display opts .removeListener('displayRange', self.name) opts .removeListener('cmap', self.name) opts .removeListener('negativeCmap', self.name) opts .removeListener('useNegativeCmap', self.name) opts .removeListener('invert', self.name) opts .removeListener('gamma', self.name) opts .removeListener('cmapResolution', self.name) display.removeListener('name', self.name) except fsldc.InvalidOverlayError: pass self.__opts = None self.__display = None def __registerOverlay(self): """Called when the selected overlay changes. Registers property listeners with the :class:`.ColourMapOpts` instance associated with the newly selected overlay. """ overlay = self.__displayCtx.getSelectedOverlay() if overlay is None: return False display = self.__displayCtx.getDisplay(overlay) opts = display.opts if not isinstance(opts, cmapopts.ColourMapOpts): return False self.__opts = opts self.__display = display opts .addListener('displayRange', self.name, self.__clearColourBar) opts .addListener('cmap', self.name, self.__clearColourBar) opts .addListener('negativeCmap', self.name, self.__clearColourBar) opts .addListener('useNegativeCmap', self.name, self.__clearColourBar) opts .addListener('invert', self.name, self.__clearColourBar) opts .addListener('cmapResolution', self.name, self.__clearColourBar) opts .addListener('gamma', self.name, self.__clearColourBar) display.addListener('name', self.name, self.__clearColourBar) return True def __clearColourBar(self, *a): """Clears any previously generated colour bar bitmap. """ self.__colourBar = None self.notify() def colourBar(self, w, h, scale=1): """Returns a bitmap containing the rendered colour bar, rendering it if necessary. :arg w: Width in pixels :arg h: Height in pixels :arg scale: DPI scaling factor, if applicable. """ if self.__opts is None: return None if w < 20: w = 20 if h < 20: h = 20 if (w, h, scale) == self.__size and self.__colourBar is not None: return self.__colourBar display = self.__display opts = self.__opts cmap = opts.cmap negCmap = opts.negativeCmap useNegCmap = opts.useNegativeCmap cmapResolution = opts.cmapResolution gamma = opts.realGamma(opts.gamma) invert = opts.invert dmin, dmax = opts.displayRange.x label = display.name if self.orientation == 'horizontal': if self.labelSide == 'top-left': labelSide = 'top' else: labelSide = 'bottom' else: if self.labelSide == 'top-left': labelSide = 'left' else: labelSide = 'right' if useNegCmap and dmin == 0.0: ticks = [0.0, 0.5, 1.0] ticklabels = ['{:0.3G}'.format(-dmax), '{:0.3G}'.format( dmin), '{:0.3G}'.format( dmax)] tickalign = ['left', 'center', 'right'] elif useNegCmap: ticks = [0.0, 0.49, 0.51, 1.0] ticklabels = ['{:0.3G}'.format(-dmax), '{:0.3G}'.format(-dmin), '{:0.3G}'.format( dmin), '{:0.3G}'.format( dmax)] tickalign = ['left', 'right', 'left', 'right'] else: negCmap = None ticks = [0.0, 1.0] tickalign = ['left', 'right'] ticklabels = ['{:0.3G}'.format(dmin), '{:0.3G}'.format(dmax)] ticks = np.array(ticks) ticks[np.isclose(ticks , 0)] = 0 if not self.showLabel: label = None if not self.showTicks: ticks = None ticklabels = None bitmap = cbarbmp.colourBarBitmap( cmap=cmap, negCmap=negCmap, invert=invert, gamma=gamma, ticks=ticks, ticklabels=ticklabels, tickalign=tickalign, width=w, height=h, label=label, scale=scale, orientation=self.orientation, labelside=labelSide, textColour=self.textColour, fontsize=self.fontSize, bgColour=self.bgColour, cmapResolution=cmapResolution) self.__size = (w, h, scale) self.__colourBar = bitmap return bitmap
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 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 DisplayContext(props.SyncableHasProperties): """A ``DisplayContext`` instance contains a number of properties defining how the overlays in an :class:`.OverlayList` are to be displayed, and related contextual information. A ``DisplayContext`` instance is responsible for creating and destroying :class:`.Display` instances for every overlay in the ``OverlayList``. These ``Display`` instances, and the corresponding :class:`.DisplayOpts` instances (which, in turn, are created/destroyed by ``Display`` instances) can be accessed with the :meth:`getDisplay` and :meth:`getOpts` method respectively. A number of other useful methods are provided by a ``DisplayContext`` instance: .. autosummary:: :nosignatures: getDisplay getOpts getReferenceImage displayToWorld worldToDisplay displaySpaceIsRadiological selectOverlay getSelectedOverlay getOverlayOrder getOrderedOverlays freeze freezeOverlay thawOverlay defaultDisplaySpace detachDisplaySpace """ selectedOverlay = props.Int(minval=0, default=0, clamped=True) """Index of the currently 'selected' overlay. .. note:: The value of this index is in relation to the :class:`.OverlayList`, rather than to the :attr:`overlayOrder` list. If you're interested in the currently selected overlay, you must also listen for changes to the :attr:`.OverlayList.images` list as, if the list changes, the :attr:`selectedOverlay` index may not change, but the overlay to which it points may be different. """ location = props.Point(ndims=3) """The location property contains the currently selected 3D location (xyz) in the display coordinate system. Different ``DisplayContext`` instances may be using different display coordinate systems - see the :attr:`displaySpace` property. """ worldLocation = props.Point(ndims=3) """The location property contains the currently selected 3D location (xyz) in the world coordinate system. Whenever the :attr:`location` changes, it gets transformed into the world coordinate system, and propagated to this property. The location of different ``DisplayContext`` instances is synchronised through this property. .. note:: If any :attr:`.NiftiOpts.transform` properties have been modified independently of the :attr:`displaySpace`, this value will be invalid. """ bounds = props.Bounds(ndims=3) """This property contains the min/max values of a bounding box (in display coordinates) which is big enough to contain all of the overlays in the :class:`.OverlayList`. .. warning:: This property shouid be treated as read-only. """ overlayOrder = props.List(props.Int()) """A list of indices into the :attr:`.OverlayList.overlays` list, defining the order in which the overlays are to be displayed. See the :meth:`getOrderedOverlays` method. """ overlayGroups = props.List() """A list of :class:`.OverlayGroup` instances, each of which defines a group of overlays which share display properties. """ syncOverlayDisplay = props.Boolean(default=True) """If this ``DisplayContext`` instance has a parent (see :mod:`props.syncable`), and this property is ``True``, the properties of the :class:`.Display` and :class:`.DisplayOpts` instances for every overlay managed by this ``DisplayContext`` instance will be synchronised to those of the parent instance. Otherwise, the display properties for every overlay will be unsynchronised from the parent. Synchronisation of the following properties between child and parent ``DisplayContext`` instances is also controlled by this flag: - :attr:`displaySpace` - :attr:`bounds` - :attr:`radioOrientation` .. note:: This property is accessed by the :class:`.Display` class, in its constructor, and when it creates new :class:`.DisplayOpts` instances, to set initial sync states. """ displaySpace = props.Choice(('world', )) """The *space* in which overlays are displayed. This property defines the display coordinate system for this ``DisplayContext``. When it is changed, the :attr:`.NiftiOpts.transform` property of all :class:`.Nifti` overlays in the :class:`.OverlayList` is updated. It has two settings, described below. The options for this property are dynamically added by :meth:`__updateDisplaySpaceOptions`. 1. **World** space (a.k.a. ``'world'``) All :class:`.Nifti` overlays are displayed in the space defined by their affine transformation matrix - the :attr:`.NiftiOpts.transform` property for every ``Nifti`` overlay is set to ``affine``. 2. **Reference image** space A single :class:`.Nifti` overlay is selected as a *reference* image, and is displayed in scaled voxel space (with a potential L/R flip for neurological images - its :attr:`.NiftiOpts.transform` is set to ``pixdim-flip``). All other ``Nifti`` overlays are transformed into this reference space - their :attr:`.NiftiOpts.transform` property is set to ``reference``, which results in them being transformed into the scaled voxel space of the reference image. .. note:: The :attr:`.NiftiOpts.transform` property of any :class:`.Nifti` overlay can be set independently of this property. However, whenever *this* property changes, it will change the ``transform`` property for every ``Nifti``, in the manner described above. The :meth:`defaultDisplaySpace` can be used to control how the ``displaySpace`` is initialised. """ radioOrientation = props.Boolean(default=True) """If ``True``, 2D views will show images in radiological convention (i.e.subject left on the right of the display). Otherwise, they will be shown in neurological convention (subject left on the left). .. note:: This setting is not enforced by the ``DisplayContext``. It is the responsibility of the :class:`.OrthoPanel` and :class:`.LightBoxPanel` (and other potential future 2D view panels) to implement the flip. """ autoDisplay = props.Boolean(default=False) """If ``True``, whenever an overlay is added to the :class:`.OverlayList`, the :mod:`autodisplay` module will be used to automatically configure its display settings. Note that the ``DisplayContext`` does not perform this configuration - this flag is used by other modules (e.g. the :class:`.OverlayListPanel` and the :class:`.OpenFileAction`). """ loadInMemory = props.Boolean(default=False) """If ``True``, all :class:`.Image` instances will be loaded into memory, regardless of their size. Otherwise (the default), large compressed ``Image`` overlays may be kept on disk. .. note:: Changing the value of this property will not affect existing ``Image`` overlays. .. note:: This property may end up being used in a more general sense by any code which needs to decide whether to do things in a more or less memory-intensive manner. """ def __init__(self, overlayList, parent=None, defaultDs='ref', **kwargs): """Create a ``DisplayContext``. :arg overlayList: An :class:`.OverlayList` instance. :arg parent: Another ``DisplayContext`` instance to be used as the parent of this instance, passed to the :class:`.SyncableHasProperties` constructor. :arg defaultDs: Initial value for the :meth:`defaultDisplaySpace`. Either ``'ref'`` or ``'world'``. If ``'ref'`` (the default), when overlays are added to an empty list, the :attr:`displaySpace` will be set to the first :class:`.Nifti` overlay. Otherwise (``'world'``), the display space will be set to ``'world'``. All other arguments are passed through to the ``SyncableHasProperties`` constructor, in addition to the following: - The ``syncOverlayDisplay`` and ``location`` properties are added to the ``nobind`` argument - The ``selectedOverlay``, ``overlayGroups``, ``autoDisplay`` and ``loadInMemory`` properties are added to the ``nounbind`` argument. """ kwargs = dict(kwargs) nobind = kwargs.pop('nobind', []) nounbind = kwargs.pop('nounbind', []) nobind.extend(['syncOverlayDisplay', 'location', 'bounds']) nounbind.extend([ 'selectedOverlay', 'overlayGroups', 'autoDisplay', 'loadInMemory' ]) kwargs['parent'] = parent kwargs['nobind'] = nobind kwargs['nounbind'] = nounbind kwargs['state'] = {'overlayOrder': False} props.SyncableHasProperties.__init__(self, **kwargs) self.__overlayList = overlayList self.__name = '{}_{}'.format(self.__class__.__name__, id(self)) self.__child = parent is not None # When the first overlay(s) is/are # added, the display space may get # set either to a reference image, # or to world. The defaultDisplaySpace # controls this behaviour. self.defaultDisplaySpace = defaultDs # The overlayOrder is unsynced by # default, but we will inherit the # current parent value. if self.__child: self.overlayOrder[:] = parent.overlayOrder[:] else: self.overlayOrder[:] = range(len(overlayList)) # If this is the first child DC, we # need to initialise the display # space and location. If there is # already a child DC, then we have # (probably) inherited initial # settings. if self.__child: self.__initDS = (len(parent.getChildren()) - 1) == 0 # This dict contains the Display # objects for every overlay in # the overlay list, as # {Overlay : Display} mappings self.__displays = {} overlayList.addListener('overlays', self.__name, self.__overlayListChanged, immediate=True) if self.__child: self.addListener('syncOverlayDisplay', self.__name, self.__syncOverlayDisplayChanged) self.addListener('displaySpace', self.__name, self.__displaySpaceChanged, immediate=True) self.addListener('location', self.__name, self.__locationChanged, immediate=True) self.addListener('worldLocation', self.__name, self.__worldLocationChanged, immediate=True) # The overlayListChanged method # is important - check it out self.__overlayListChanged() log.debug('{}.init ({})'.format(type(self).__name__, id(self))) def __del__(self): """Prints a log message.""" if log: log.debug('{}.del ({})'.format(type(self).__name__, id(self))) def destroy(self): """This method must be called when this ``DisplayContext`` is no longer needed. When a ``DisplayContext`` is destroyed, all of the :class:`.Display` instances managed by it are destroyed as well. """ self.detachAllFromParent() overlayList = self.__overlayList displays = self.__displays self.__overlayList = None self.__displays = None overlayList.removeListener('overlays', self.__name) if self.__child: self.removeListener('syncOverlayDisplay', self.__name) self.removeListener('displaySpace', self.__name) self.removeListener('location', self.__name) self.removeListener('worldLocation', self.__name) for overlay, display in displays.items(): display.destroy() def destroyed(self): """Returns ``True`` if this ``DisplayContext`` has been, or is being, destroyed, ``False`` otherwise. """ return self.__overlayList is None def getDisplay(self, overlay, overlayType=None): """Returns the :class:`.Display` instance for the specified overlay (or overlay index). If the overlay is not in the ``OverlayList``, an :exc:`InvalidOverlayError` is raised. Otheriwse, if a :class:`Display` object does not exist for the given overlay, one is created. If this ``DisplayContext`` has been destroyed, a ``ValueError`` is raised. :arg overlay: The overlay to retrieve a ``Display`` instance for, or an index into the ``OverlayList``. :arg overlayType: If a ``Display`` instance for the specified ``overlay`` does not exist, one is created - in this case, the specified ``overlayType`` is passed to the :class:`.Display` constructor. """ if overlay is None: raise ValueError('No overlay specified') if self.destroyed(): raise ValueError('DisplayContext has been destroyed') if overlay not in self.__overlayList: raise InvalidOverlayError('Overlay {} is not in ' 'list'.format(overlay.name)) if isinstance(overlay, int): overlay = self.__overlayList[overlay] try: display = self.__displays[overlay] except KeyError: if not self.__child: dParent = None else: dParent = self.getParent().getDisplay(overlay, overlayType) if overlayType is None: overlayType = dParent.overlayType from .display import Display display = Display(overlay, self.__overlayList, self, parent=dParent, overlayType=overlayType) self.__displays[overlay] = display return display def getOpts(self, overlay, overlayType=None): """Returns the :class:`.DisplayOpts` instance associated with the specified overlay. See :meth:`getDisplay` and :meth:`.Display.opts` for more details. """ if overlay is None: raise ValueError('No overlay specified') if self.destroyed(): raise ValueError('DisplayContext has been destroyed') if overlay not in self.__overlayList: raise InvalidOverlayError('Overlay {} is not in ' 'list'.format(overlay.name)) return self.getDisplay(overlay, overlayType).opts def getReferenceImage(self, overlay): """Convenience method which returns the reference image associated with the given overlay, or ``None`` if there is no reference image. See the :class:`.DisplayOpts.referenceImage` method. """ if overlay is None: return None return self.getOpts(overlay).referenceImage def displayToWorld(self, dloc): """Transforms the given coordinates from the display coordinate system into the world coordinate system. .. warning:: If any :attr:`.NiftiOpts.transform` properties have been modified manually, this method will return invalid results. """ displaySpace = self.displaySpace if displaySpace == 'world' or len(self.__overlayList) == 0: return dloc opts = self.getOpts(displaySpace) return opts.transformCoords(dloc, 'display', 'world') def worldToDisplay(self, wloc): """Transforms the given coordinates from the world coordinate system into the display coordinate system. .. warning:: If any :attr:`.NiftiOpts.transform` properties have been modified manually, this method will return invalid results. """ displaySpace = self.displaySpace if displaySpace == 'world' or len(self.__overlayList) == 0: return wloc opts = self.getOpts(displaySpace) return opts.transformCoords(wloc, 'world', 'display') def displaySpaceIsRadiological(self): """Returns ``True`` if the current :attr:`displaySpace` aligns with a radiological orientation. A radiological orientation is one in which anatomical right is shown on the left of the screen, i.e.: - The X axis corresponds to right -> left - The Y axis corresponds to posterior -> anterior - The Z axis corresponds to inferior -> superior """ if len(self.__overlayList) == 0: return True space = self.displaySpace # Display space is either 'world', or an image. # We assume that 'world' is an RAS coordinate # system which, if transferred directly to a # display coordinate system, would result in a # neurological view (left on left, right on # right). if space == 'world': return False else: opts = self.getOpts(space) xform = opts.getTransform('pixdim-flip', 'display') return npla.det(xform) > 0 def selectOverlay(self, overlay): """Selects the specified ``overlay``. Raises an :exc:`IndexError` if the overlay is not in the list. If you want to select an overlay by its index in the ``OverlayList``, you can just assign to the :attr:`selectedOverlay` property directly. """ self.selectedOverlay = self.__overlayList.index(overlay) def getSelectedOverlay(self): """Returns the currently selected overlay object, or ``None`` if there are no overlays. """ if len(self.__overlayList) == 0: return None if self.selectedOverlay >= len(self.__overlayList): return None return self.__overlayList[self.selectedOverlay] def getOverlayOrder(self, overlay): """Returns the order in which the given overlay (or an index into the :class:`.OverlayList` list) should be displayed (see the :attr:`overlayOrder` property). Raises an :exc:`IndexError` if the overlay is not in the list. """ self.__syncOverlayOrder() if not isinstance(overlay, int): overlay = self.__overlayList.index(overlay) return self.overlayOrder.index(overlay) def getOrderedOverlays(self): """Returns a list of overlay objects from the :class:`.OverlayList` list, sorted into the order that they should be displayed, as defined by the :attr:`overlayOrder` property. """ self.__syncOverlayOrder() return [self.__overlayList[idx] for idx in self.overlayOrder] @contextlib.contextmanager def freeze(self, overlay): """This method can be used as a context manager to suppress notification for all :class:`.Display` and :class:`.DisplayOpts` properties related to the given ``overlay``:: with displayCtx.freeze(overlay): # Do stuff which might trigger unwanted # Display/DisplayOpts notifications See :meth:`freezeOverlay` and :meth:`thawOverlay`. """ self.freezeOverlay(overlay) try: yield finally: self.thawOverlay(overlay) def freezeOverlay(self, overlay): """Suppresses notification for all :class:`.Display` and :class:`.DisplayOpts` properties associated with the given ``overlay``. Call :meth:`.thawOverlay` to re-enable notification. See also the :meth:`freeze` method, which can be used as a context manager to automatically call this method and ``thawOverlay``. """ if self.__child: self.getParent().freezeOverlay(overlay) return dctxs = [self] + self.getChildren() for dctx in dctxs: display = dctx.getDisplay(overlay) opts = display.opts display.disableAllNotification() opts.disableAllNotification() def thawOverlay(self, overlay): """Enables notification for all :class:`.Display` and :class:`.DisplayOpts` properties associated with the given ``overlay``. """ if self.__child: self.getParent().thawOverlay(overlay) return dctxs = [self] + self.getChildren() for dctx in dctxs: display = dctx.getDisplay(overlay) opts = display.opts display.enableAllNotification() opts.enableAllNotification() @property def defaultDisplaySpace(self): """This property controls how the :attr:`displaySpace` is initialised when overlays are added to a previously empty :class:`.OverlayList`. If the ``defaultDisplaySpace`` is set to ``'ref'``, the ``displaySpace`` will be initialised to the first :class:`.Nifti` overlay. Otherwise (the ``defaultDisplaySpace`` is set to ``'world'``), the ``displaySpace`` will be set to ``'world'``. """ return self.__defaultDisplaySpace @defaultDisplaySpace.setter def defaultDisplaySpace(self, ds): """Sets the :meth:`defaultDisplaySpace`. :arg ds: Either ``'ref'`` or ``'world'``. """ if ds not in ('world', 'ref'): raise ValueError('Invalid default display space: {}'.format(ds)) self.__defaultDisplaySpace = ds def detachDisplaySpace(self): """Detaches the :attr:`displaySpace` and :attr:`bounds` properties, and all related :class:`.DisplayOpts` properties, from the parent ``DisplayContext``. This allows this ``DisplayContext`` to use a display coordinate system that is completely independent from other instances, and is not affected by changes to the parent properties. This is an irreversible operation. """ self.detachFromParent('displaySpace') self.detachFromParent('bounds') for ovl in self.__overlayList: opts = self.getOpts(ovl) opts.detachFromParent('bounds') if isinstance(ovl, fslimage.Nifti): opts.detachFromParent('transform') def __overlayListChanged(self, *a): """Called when the :attr:`.OverlayList.overlays` property changes. Ensures that a :class:`.Display` and :class:`.DisplayOpts` object exists for every image, updates the :attr:`bounds` property, makes sure that the :attr:`overlayOrder` property is consistent, and updates constraints on the :attr:`selectedOverlay` property. """ # Discard all Display instances # which refer to overlays that # are no longer in the list for overlay in list(self.__displays.keys()): if overlay not in self.__overlayList: display = self.__displays.pop(overlay) opts = display.opts display.removeListener('overlayType', self.__name) opts.removeListener('bounds', self.__name) # The display instance will destroy the # opts instance, so we don't do it here display.destroy() # Ensure that a Display object exists # for every overlay in the list for overlay in self.__overlayList: ovlType = self.__overlayList.initOverlayType(overlay) # The getDisplay method # will create a Display object # if one does not already exist display = self.getDisplay(overlay, ovlType) opts = display.opts # Register a listener on the overlay type, # because when it changes, the DisplayOpts # instance will change, and we will need # to re-register the DisplayOpts.bounds # listener (see the next statement) display.addListener('overlayType', self.__name, self.__overlayListChanged, overwrite=True) # Register a listener on the DisplayOpts.bounds # property for every overlay - if the display # bounds for any overlay changes, we need to # update our own bounds property. This is only # done on child DCs, as the parent DC bounds # only gets used for synchronisation if self.__child: opts.addListener('bounds', self.__name, self.__overlayBoundsChanged, overwrite=True) # If detachDisplaySpace has been called, # make sure the opts bounds (and related) # properties are also detached if not self.canBeSyncedToParent('displaySpace'): opts.detachFromParent('bounds') if isinstance(overlay, fslimage.Nifti): opts.detachFromParent('transform') # Ensure that the displaySpace # property options are in sync # with the overlay list. self.__updateDisplaySpaceOptions() # Stuff which only needs to # be done on the parent DC if not self.__child: # Limit the selectedOverlay property # so it cannot take a value greater # than len(overlayList)-1. selectedOverlay # is always synchronised, so we only # need to do this on the parent DC. nOverlays = len(self.__overlayList) if nOverlays > 0: self.setAttribute('selectedOverlay', 'maxval', nOverlays - 1) else: self.setAttribute('selectedOverlay', 'maxval', 0) return # Ensure that the overlayOrder # property is valid self.__syncOverlayOrder() # If the overlay list was empty, # and is now non-empty, we need # to initialise the display space # and the display location initDS = self.__initDS and \ np.all(np.isclose(self.bounds, 0)) and \ len(self.__overlayList) > 0 self.__initDS = len(self.__overlayList) == 0 # Initialise the display space. We # have to do this before updating # image transforms, and updating # the display bounds if initDS: displaySpace = 'world' if self.defaultDisplaySpace == 'ref': for overlay in self.__overlayList: if isinstance(overlay, fslimage.Nifti): displaySpace = overlay break with props.skip(self, 'displaySpace', self.__name): self.displaySpace = displaySpace # Initialise the transform property # of any Image overlays which have # just been added to the list, oldList = self.__overlayList.getLastValue('overlays')[:] for overlay in self.__overlayList: if isinstance(overlay, fslimage.Nifti) and \ (overlay not in oldList): self.__setTransform(overlay) # Ensure that the bounds # property is accurate self.__updateBounds() # Initialise the display location to # the centre of the display bounds if initDS: b = self.bounds self.location.xyz = [ b.xlo + b.xlen / 2.0, b.ylo + b.ylen / 2.0, b.zlo + b.zlen / 2.0 ] self.__propagateLocation('world') else: self.__propagateLocation('display') def __updateDisplaySpaceOptions(self): """Updates the :attr:`displaySpace` property so it is synchronised with the current contents of the :class:`.OverlayList` This method is called by the :meth:`__overlayListChanged` method. """ choiceProp = self.getProp('displaySpace') choices = [] for overlay in self.__overlayList: if isinstance(overlay, fslimage.Nifti): choices.append(overlay) choices.append('world') choiceProp.setChoices(choices, instance=self) def __setTransform(self, image): """Sets the :attr:`.NiftiOpts.transform` property associated with the given :class:`.Nifti` overlay to a sensible value, given the current value of the :attr:`.displaySpace` property. Called by the :meth:`__displaySpaceChanged` method, and by :meth:`__overlayListChanged` for any :class:`.Image` overlays which have been newly added to the :class:`.OverlayList`. :arg image: An :class:`.Image` overlay. """ space = self.displaySpace opts = self.getOpts(image) # Disable notification of the bounds # property so the __overlayBoundsChanged # method does not get called. Use # ignoreInvalid, because this method might # get called before we have registered a # listener on the bounds property. with props.skip(opts, 'bounds', self.__name, ignoreInvalid=True): if space == 'world': opts.transform = 'affine' elif image is space: opts.transform = 'pixdim-flip' else: opts.transform = 'reference' def __displaySpaceChanged(self, *a): """Called when the :attr:`displaySpace` property changes. Updates the :attr:`.NiftiOpts.transform` property for all :class:`.Nifti` overlays in the :class:`.OverlayList`. """ selectedOverlay = self.getSelectedOverlay() if selectedOverlay is None: return # Update the transform property of all # Image overlays to put them into the # new display space for overlay in self.__overlayList: if not isinstance(overlay, fslimage.Nifti): continue self.__setTransform(overlay) # Update the display world bounds, # and then update the location self.__updateBounds() # Make sure that the location is # kept in the same place, relative # to the world coordinate system self.__propagateLocation('display') def __syncOverlayOrder(self): """Ensures that the :attr:`overlayOrder` property is up to date with respect to the :class:`.OverlayList`. """ if len(self.overlayOrder) == len(self.__overlayList): return # # NOTE: The following logic assumes that operations # which modify the overlay list will only do # one of the following: # # - Adding one or more overlays to the end of the list # - Removing one or more overlays from the list # # More complex overlay list modifications # will cause this code to break. oldList = self.__overlayList.getLastValue('overlays')[:] oldOrder = self.overlayOrder[:] # If the overlay order was just the # list order, preserve that ordering if self.overlayOrder[:] == list(range(len(oldList))): self.overlayOrder[:] = list(range(len(self.__overlayList))) # If overlays have been added to # the overlay list, add indices # for them to the overlayOrder list elif len(self.overlayOrder) < len(self.__overlayList): newOrder = [] newOverlayIdx = len(oldList) # The order of existing overlays is preserved, # and all new overlays added to the end of the # overlay order. for overlay in self.__overlayList: if overlay in oldList: newOrder.append(oldOrder[oldList.index(overlay)]) else: newOrder.append(newOverlayIdx) newOverlayIdx += 1 self.overlayOrder[:] = newOrder # Otherwise, if overlays have been # removed from the overlay list ... elif len(self.overlayOrder) > len(self.__overlayList): # Remove the corresponding indices # from the overlayOrder list for i, overlay in enumerate(oldList): if overlay not in self.__overlayList: oldOrder.remove(i) # Re-generate new indices, # preserving the order of # the remaining overlays newOrder = [sorted(oldOrder).index(idx) for idx in oldOrder] self.overlayOrder[:] = newOrder def __overlayBoundsChanged(self, value, valid, opts, name): """Called when the :attr:`.DisplayOpts.bounds` property of any overlay changes. Updates the :attr:`bounds` property and preserves the display :attr:`location` in terms of the :attr:`worldLocation`. """ # This method might get called # after DisplayOpts instance # has been destroyed if opts.display is None: return # Update the display context bounds # to take into account any changes # to individual overlay bounds. # Inhibit notification on the location # property - it will be updated properly # below self.__updateBounds() # Make sure the display location # is consistent w.r.t. the world # coordinate location self.__propagateLocation('display') def __syncOverlayDisplayChanged(self, *a): """Called when the :attr:`syncOverlayDisplay` property changes. Synchronises or unsychronises the :class:`.Display` and :class:`.DisplayOpts` instances for every overlay to/from their parent instances. """ dcProps = ['displaySpace', 'bounds', 'radioOrientation'] if self.syncOverlayDisplay: for p in dcProps: if self.canBeSyncedToParent(p): self.syncToParent(p) else: for p in dcProps: if self.canBeUnsyncedFromParent(p): self.unsyncFromParent(p) for display in self.__displays.values(): opts = display.opts if self.syncOverlayDisplay: display.syncAllToParent() opts.syncAllToParent() else: display.unsyncAllFromParent() opts.unsyncAllFromParent() def __updateBounds(self, *a): """Called when the overlay list changes, or when any overlay display transform is changed. Updates the :attr:`bounds` property so that it is big enough to contain all of the overlays (as defined by their :attr:`.DisplayOpts.bounds` properties). """ if len(self.__overlayList) == 0: minBounds = [0.0, 0.0, 0.0] maxBounds = [0.0, 0.0, 0.0] else: minBounds = 3 * [sys.float_info.max] maxBounds = 3 * [-sys.float_info.max] for ovl in self.__overlayList: display = self.__displays[ovl] opts = display.opts lo = opts.bounds.getLo() hi = opts.bounds.getHi() for ax in range(3): if lo[ax] < minBounds[ax]: minBounds[ax] = lo[ax] if hi[ax] > maxBounds[ax]: maxBounds[ax] = hi[ax] self.bounds[:] = [ minBounds[0], maxBounds[0], minBounds[1], maxBounds[1], minBounds[2], maxBounds[2] ] # Update the constraints on the location # property to be aligned with the new bounds with props.suppress(self, 'location'): self.location.setLimits(0, self.bounds.xlo, self.bounds.xhi) self.location.setLimits(1, self.bounds.ylo, self.bounds.yhi) self.location.setLimits(2, self.bounds.zlo, self.bounds.zhi) def __locationChanged(self, *a): """Called when the :attr:`location` property changes. Propagates the new location to the :attr:`worldLocation` property. """ self.__propagateLocation('world') def __worldLocationChanged(self, *a): """Called when the :attr:`worldLocation` property changes. Propagates the new location to the :attr:`location` property. """ self.__propagateLocation('display') def __propagateLocation(self, dest): """Called by the :meth:`__locationChanged` and :meth:`__worldLocationChanged` methods. The ``dest`` argument may be either ``'world'`` (the ``worldLocation`` is updated from the ``location``), or ``'display'`` (vice-versa). """ if self.displaySpace == 'world': if dest == 'world': with props.skip(self, 'worldLocation', self.__name): self.worldLocation = self.location else: with props.skip(self, 'location', self.__name): self.location = self.worldLocation return ref = self.displaySpace opts = self.getOpts(ref) if dest == 'world': with props.skip(self, 'location', self.__name): self.worldLocation = opts.transformCoords( self.location, 'display', 'world') else: with props.skip(self, 'worldLocation', self.__name): self.location = opts.transformCoords(self.worldLocation, 'world', 'display')