# coding: utf-8 # /*########################################################################## # # Copyright (c) 2004-2017 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # # ###########################################################################*/ """Utility functions, toolbars and actions to create profile on images and stacks of images""" __authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno"] __license__ = "MIT" __date__ = "24/04/2017" import numpy from silx.image.bilinear import BilinearImage from .. import icons from .. import qt from . import items from .Colors import cursorColorForColormap from .PlotActions import PlotAction from .PlotToolButtons import ProfileToolButton from .ProfileMainWindow import ProfileMainWindow from silx.utils.decorators import deprecated def _alignedFullProfile(data, origin, scale, position, roiWidth, axis): """Get a profile along one axis on a stack of images :param numpy.ndarray data: 3D volume (stack of 2D images) The first dimension is the image index. :param origin: Origin of image in plot (ox, oy) :param scale: Scale of image in plot (sx, sy) :param float position: Position of profile line in plot coords on the axis orthogonal to the profile direction. :param int roiWidth: Width of the profile in image pixels. :param int axis: 0 for horizontal profile, 1 for vertical. :return: profile image + effective ROI area corners in plot coords """ assert axis in (0, 1) assert len(data.shape) == 3 # Convert from plot to image coords imgPos = int((position - origin[1 - axis]) / scale[1 - axis]) if axis == 1: # Vertical profile # Transpose image to always do a horizontal profile data = numpy.transpose(data, (0, 2, 1)) nimages, height, width = data.shape roiWidth = min(height, roiWidth) # Clip roi width to image size # Get [start, end[ coords of the roi in the data start = int(int(imgPos) + 0.5 - roiWidth / 2.) start = min(max(0, start), height - roiWidth) end = start + roiWidth if start < height and end > 0: profile = data[:, max(0, start):min(end, height), :].mean( axis=1, dtype=numpy.float32) else: profile = numpy.zeros((nimages, width), dtype=numpy.float32) # Compute effective ROI in plot coords profileBounds = numpy.array( (0, width, width, 0), dtype=numpy.float32) * scale[axis] + origin[axis] roiBounds = numpy.array( (start, start, end, end), dtype=numpy.float32) * scale[1 - axis] + origin[1 - axis] if axis == 0: # Horizontal profile area = profileBounds, roiBounds else: # vertical profile area = roiBounds, profileBounds return profile, area def _alignedPartialProfile(data, rowRange, colRange, axis): """Mean of a rectangular region (ROI) of a stack of images along a given axis. Returned values and all parameters are in image coordinates. :param numpy.ndarray data: 3D volume (stack of 2D images) The first dimension is the image index. :param rowRange: [min, max[ of ROI rows (upper bound excluded). :type rowRange: 2-tuple of int (min, max) with min < max :param colRange: [min, max[ of ROI columns (upper bound excluded). :type colRange: 2-tuple of int (min, max) with min < max :param int axis: The axis along which to take the profile of the ROI. 0: Sum rows along columns. 1: Sum columns along rows. :return: Profile image along the ROI as the mean of the intersection of the ROI and the image. """ assert axis in (0, 1) assert len(data.shape) == 3 assert rowRange[0] < rowRange[1] assert colRange[0] < colRange[1] nimages, height, width = data.shape # Range aligned with the integration direction profileRange = colRange if axis == 0 else rowRange profileLength = abs(profileRange[1] - profileRange[0]) # Subset of the image to use as intersection of ROI and image rowStart = min(max(0, rowRange[0]), height) rowEnd = min(max(0, rowRange[1]), height) colStart = min(max(0, colRange[0]), width) colEnd = min(max(0, colRange[1]), width) imgProfile = numpy.mean(data[:, rowStart:rowEnd, colStart:colEnd], axis=axis + 1, dtype=numpy.float32) # Profile including out of bound area profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32) # Place imgProfile in full profile offset = - min(0, profileRange[0]) profile[:, offset:offset + imgProfile.shape[1]] = imgProfile return profile def createProfile(roiInfo, currentData, origin, scale, lineWidth): """Create the profile line for the the given image. :param roiInfo: information about the ROI: start point, end point and type ("X", "Y", "D") :param numpy.ndarray currentData: the 2D image or the 3D stack of images on which we compute the profile. :param origin: (ox, oy) the offset from origin :type origin: 2-tuple of float :param scale: (sx, sy) the scale to use :type scale: 2-tuple of float :param int lineWidth: width of the profile line :return: `profile, area, profileName, xLabel`, where: - profile is a 2D array of the profiles of the stack of images. For a single image, the profile is a curve, so this parameter has a shape *(1, len(curve))* - area is a tuple of two 1D arrays with 4 values each. They represent the effective ROI area corners in plot coords. - profileName is a string describing the ROI, meant to be used as title of the profile plot - xLabel is a string describing the meaning of the X axis on the profile plot ("rows", "columns", "distance") :rtype: tuple(ndarray, (ndarray, ndarray), str, str) """ if currentData is None or roiInfo is None or lineWidth is None: raise ValueError("createProfile called with invalide arguments") # force 3D data (stack of images) if len(currentData.shape) == 2: currentData3D = currentData.reshape((1,) + currentData.shape) elif len(currentData.shape) == 3: currentData3D = currentData roiWidth = max(1, lineWidth) roiStart, roiEnd, lineProjectionMode = roiInfo if lineProjectionMode == 'X': # Horizontal profile on the whole image profile, area = _alignedFullProfile(currentData3D, origin, scale, roiStart[1], roiWidth, axis=0) yMin, yMax = min(area[1]), max(area[1]) - 1 if roiWidth <= 1: profileName = 'Y = %g' % yMin else: profileName = 'Y = [%g, %g]' % (yMin, yMax) xLabel = 'Columns' elif lineProjectionMode == 'Y': # Vertical profile on the whole image profile, area = _alignedFullProfile(currentData3D, origin, scale, roiStart[0], roiWidth, axis=1) xMin, xMax = min(area[0]), max(area[0]) - 1 if roiWidth <= 1: profileName = 'X = %g' % xMin else: profileName = 'X = [%g, %g]' % (xMin, xMax) xLabel = 'Rows' else: # Free line profile # Convert start and end points in image coords as (row, col) startPt = ((roiStart[1] - origin[1]) / scale[1], (roiStart[0] - origin[0]) / scale[0]) endPt = ((roiEnd[1] - origin[1]) / scale[1], (roiEnd[0] - origin[0]) / scale[0]) if (int(startPt[0]) == int(endPt[0]) or int(startPt[1]) == int(endPt[1])): # Profile is aligned with one of the axes # Convert to int startPt = int(startPt[0]), int(startPt[1]) endPt = int(endPt[0]), int(endPt[1]) # Ensure startPt <= endPt if startPt[0] > endPt[0] or startPt[1] > endPt[1]: startPt, endPt = endPt, startPt if startPt[0] == endPt[0]: # Row aligned rowRange = (int(startPt[0] + 0.5 - 0.5 * roiWidth), int(startPt[0] + 0.5 + 0.5 * roiWidth)) colRange = startPt[1], endPt[1] + 1 profile = _alignedPartialProfile(currentData3D, rowRange, colRange, axis=0) else: # Column aligned rowRange = startPt[0], endPt[0] + 1 colRange = (int(startPt[1] + 0.5 - 0.5 * roiWidth), int(startPt[1] + 0.5 + 0.5 * roiWidth)) profile = _alignedPartialProfile(currentData3D, rowRange, colRange, axis=1) # Convert ranges to plot coords to draw ROI area area = ( numpy.array( (colRange[0], colRange[1], colRange[1], colRange[0]), dtype=numpy.float32) * scale[0] + origin[0], numpy.array( (rowRange[0], rowRange[0], rowRange[1], rowRange[1]), dtype=numpy.float32) * scale[1] + origin[1]) else: # General case: use bilinear interpolation # Ensure startPt <= endPt if (startPt[1] > endPt[1] or ( startPt[1] == endPt[1] and startPt[0] > endPt[0])): startPt, endPt = endPt, startPt profile = [] for slice_idx in range(currentData3D.shape[0]): bilinear = BilinearImage(currentData3D[slice_idx, :, :]) profile.append(bilinear.profile_line( (startPt[0] - 0.5, startPt[1] - 0.5), (endPt[0] - 0.5, endPt[1] - 0.5), roiWidth)) profile = numpy.array(profile) # Extend ROI with half a pixel on each end, and # Convert back to plot coords (x, y) length = numpy.sqrt((endPt[0] - startPt[0]) ** 2 + (endPt[1] - startPt[1]) ** 2) dRow = (endPt[0] - startPt[0]) / length dCol = (endPt[1] - startPt[1]) / length # Extend ROI with half a pixel on each end startPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol endPt = endPt[0] + 0.5 * dRow, endPt[1] + 0.5 * dCol # Rotate deltas by 90 degrees to apply line width dRow, dCol = dCol, -dRow area = ( numpy.array((startPt[1] - 0.5 * roiWidth * dCol, startPt[1] + 0.5 * roiWidth * dCol, endPt[1] + 0.5 * roiWidth * dCol, endPt[1] - 0.5 * roiWidth * dCol), dtype=numpy.float32) * scale[0] + origin[0], numpy.array((startPt[0] - 0.5 * roiWidth * dRow, startPt[0] + 0.5 * roiWidth * dRow, endPt[0] + 0.5 * roiWidth * dRow, endPt[0] - 0.5 * roiWidth * dRow), dtype=numpy.float32) * scale[1] + origin[1]) y0, x0 = startPt y1, x1 = endPt if x1 == x0 or y1 == y0: profileName = 'From (%g, %g) to (%g, %g)' % (x0, y0, x1, y1) else: m = (y1 - y0) / (x1 - x0) b = y0 - m * x0 profileName = 'y = %g * x %+g ; width=%d' % (m, b, roiWidth) xLabel = 'Distance' return profile, area, profileName, xLabel # ProfileToolBar ############################################################## class ProfileToolBar(qt.QToolBar): """QToolBar providing profile tools operating on a :class:`PlotWindow`. Attributes: - plot: Associated :class:`PlotWindow` on which the profile line is drawn. - actionGroup: :class:`QActionGroup` of available actions. To run the following sample code, a QApplication must be initialized. First, create a PlotWindow and add a :class:`ProfileToolBar`. >>> from silx.gui.plot import PlotWindow >>> from silx.gui.plot.Profile import ProfileToolBar >>> plot = PlotWindow() # Create a PlotWindow >>> toolBar = ProfileToolBar(plot=plot) # Create a profile toolbar >>> plot.addToolBar(toolBar) # Add it to plot >>> plot.show() # To display the PlotWindow with the profile toolbar :param plot: :class:`PlotWindow` instance on which to operate. :param profileWindow: Plot widget instance where to display the profile curve or None to create one. :param str title: See :class:`QToolBar`. :param parent: See :class:`QToolBar`. """ # TODO Make it a QActionGroup instead of a QToolBar _POLYGON_LEGEND = '__ProfileToolBar_ROI_Polygon' def __init__(self, parent=None, plot=None, profileWindow=None, title='Profile Selection'): super(ProfileToolBar, self).__init__(title, parent) assert plot is not None self.plot = plot self._overlayColor = None self._defaultOverlayColor = 'red' # update when active image change self._roiInfo = None # Store start and end points and type of ROI self._profileWindow = profileWindow """User provided plot widget in which the profile curve is plotted. None if no custom profile plot was provided.""" self._profileMainWindow = None """Main window providing 2 profile plot widgets for 1D or 2D profiles. The window provides two public methods - :meth:`setProfileDimensions` - :meth:`getPlot`: return handle on the actual plot widget currently being used None if the user specified a custom profile plot window. """ if self._profileWindow is None: self._profileMainWindow = ProfileMainWindow(self) # Actions self.browseAction = qt.QAction( icons.getQIcon('normal'), 'Browsing Mode', None) self.browseAction.setToolTip( 'Enables zooming interaction mode') self.browseAction.setCheckable(True) self.browseAction.triggered[bool].connect(self._browseActionTriggered) self.hLineAction = qt.QAction( icons.getQIcon('shape-horizontal'), 'Horizontal Profile Mode', None) self.hLineAction.setToolTip( 'Enables horizontal profile selection mode') self.hLineAction.setCheckable(True) self.hLineAction.toggled[bool].connect(self._hLineActionToggled) self.vLineAction = qt.QAction( icons.getQIcon('shape-vertical'), 'Vertical Profile Mode', None) self.vLineAction.setToolTip( 'Enables vertical profile selection mode') self.vLineAction.setCheckable(True) self.vLineAction.toggled[bool].connect(self._vLineActionToggled) self.lineAction = qt.QAction( icons.getQIcon('shape-diagonal'), 'Free Line Profile Mode', None) self.lineAction.setToolTip( 'Enables line profile selection mode') self.lineAction.setCheckable(True) self.lineAction.toggled[bool].connect(self._lineActionToggled) self.clearAction = qt.QAction( icons.getQIcon('profile-clear'), 'Clear Profile', None) self.clearAction.setToolTip( 'Clear the profile Region of interest') self.clearAction.setCheckable(False) self.clearAction.triggered.connect(self.clearProfile) # ActionGroup self.actionGroup = qt.QActionGroup(self) self.actionGroup.addAction(self.browseAction) self.actionGroup.addAction(self.hLineAction) self.actionGroup.addAction(self.vLineAction) self.actionGroup.addAction(self.lineAction) self.browseAction.setChecked(True) # Add actions to ToolBar self.addAction(self.browseAction) self.addAction(self.hLineAction) self.addAction(self.vLineAction) self.addAction(self.lineAction) self.addAction(self.clearAction) # Add width spin box to toolbar self.addWidget(qt.QLabel('W:')) self.lineWidthSpinBox = qt.QSpinBox(self) self.lineWidthSpinBox.setRange(0, 1000) self.lineWidthSpinBox.setValue(1) self.lineWidthSpinBox.valueChanged[int].connect( self._lineWidthSpinBoxValueChangedSlot) self.addWidget(self.lineWidthSpinBox) self.plot.sigInteractiveModeChanged.connect( self._interactiveModeChanged) # Enable toolbar only if there is an active image self.setEnabled(self.plot.getActiveImage(just_legend=True) is not None) self.plot.sigActiveImageChanged.connect( self._activeImageChanged) # listen to the profile window signals to clear profile polygon on close if self.getProfileMainWindow() is not None: self.getProfileMainWindow().sigClose.connect(self.clearProfile) @property @deprecated(replacement="getProfilePlot", since_version="0.5.0") def profileWindow(self): return self.getProfilePlot() def getProfilePlot(self): """Return plot widget in which the profile curve or the profile image is plotted. """ if self.getProfileMainWindow() is not None: return self.getProfileMainWindow().getPlot() # in case the user provided a custom plot for profiles return self._profileWindow def getProfileMainWindow(self): """Return window containing the profile curve widget. This can return *None* if a custom profile plot window was specified in the constructor. """ return self._profileMainWindow def _activeImageChanged(self, previous, legend): """Handle active image change: toggle enabled toolbar, update curve""" self.setEnabled(legend is not None) if legend is not None: # Update default profile color activeImage = self.plot.getActiveImage() if isinstance(activeImage, items.ColormapMixIn): self._defaultOverlayColor = cursorColorForColormap( activeImage.getColormap()['name']) else: self._defaultOverlayColor = 'black' self.updateProfile() def _lineWidthSpinBoxValueChangedSlot(self, value): """Listen to ROI width widget to refresh ROI and profile""" self.updateProfile() def _interactiveModeChanged(self, source): """Handle plot interactive mode changed: If changed from elsewhere, disable drawing tool """ if source is not self: self.browseAction.setChecked(True) def _hLineActionToggled(self, checked): """Handle horizontal line profile action toggle""" if checked: self.plot.setInteractiveMode('draw', shape='hline', color=None, source=self) self.plot.sigPlotSignal.connect(self._plotWindowSlot) else: self.plot.sigPlotSignal.disconnect(self._plotWindowSlot) def _vLineActionToggled(self, checked): """Handle vertical line profile action toggle""" if checked: self.plot.setInteractiveMode('draw', shape='vline', color=None, source=self) self.plot.sigPlotSignal.connect(self._plotWindowSlot) else: self.plot.sigPlotSignal.disconnect(self._plotWindowSlot) def _lineActionToggled(self, checked): """Handle line profile action toggle""" if checked: self.plot.setInteractiveMode('draw', shape='line', color=None, source=self) self.plot.sigPlotSignal.connect(self._plotWindowSlot) else: self.plot.sigPlotSignal.disconnect(self._plotWindowSlot) def _browseActionTriggered(self, checked): """Handle browse action mode triggered by user.""" if checked: self.clearProfile() self.plot.setInteractiveMode('zoom', source=self) if self.getProfileMainWindow() is not None: self.getProfileMainWindow().hide() def _plotWindowSlot(self, event): """Listen to Plot to handle drawing events to refresh ROI and profile. """ if event['event'] not in ('drawingProgress', 'drawingFinished'): return checkedAction = self.actionGroup.checkedAction() if checkedAction == self.hLineAction: lineProjectionMode = 'X' elif checkedAction == self.vLineAction: lineProjectionMode = 'Y' elif checkedAction == self.lineAction: lineProjectionMode = 'D' else: return roiStart, roiEnd = event['points'][0], event['points'][1] self._roiInfo = roiStart, roiEnd, lineProjectionMode self.updateProfile() @property def overlayColor(self): """The color to use for the ROI. If set to None (the default), the overlay color is adapted to the active image colormap and changes if the active image colormap changes. """ return self._overlayColor or self._defaultOverlayColor @overlayColor.setter def overlayColor(self, color): self._overlayColor = color self.updateProfile() def clearProfile(self): """Remove profile curve and profile area.""" self._roiInfo = None self.updateProfile() def updateProfile(self): """Update the displayed profile and profile ROI. This uses the current active image of the plot and the current ROI. """ image = self.plot.getActiveImage() if image is None: return # Clean previous profile area, and previous curve self.plot.remove(self._POLYGON_LEGEND, kind='item') self.getProfilePlot().clear() self.getProfilePlot().setGraphTitle('') self.getProfilePlot().setGraphXLabel('X') self.getProfilePlot().setGraphYLabel('Y') self._createProfile(currentData=image.getData(copy=False), origin=image.getOrigin(), scale=image.getScale(), colormap=None, # Not used for 2D data z=image.getZValue()) def _createProfile(self, currentData, origin, scale, colormap, z): """Create the profile line for the the given image. :param numpy.ndarray currentData: the image or the stack of images on which we compute the profile :param origin: (ox, oy) the offset from origin :type origin: 2-tuple of float :param scale: (sx, sy) the scale to use :type scale: 2-tuple of float :param dict colormap: The colormap to use :param int z: The z layer of the image """ if self._roiInfo is None: return profile, area, profileName, xLabel = createProfile( roiInfo=self._roiInfo, currentData=currentData, origin=origin, scale=scale, lineWidth=self.lineWidthSpinBox.value()) self.getProfilePlot().setGraphTitle(profileName) dataIs3D = len(currentData.shape) > 2 if dataIs3D: self.getProfilePlot().addImage(profile, legend=profileName, xlabel=xLabel, ylabel="Frame index (depth)", colormap=colormap) else: coords = numpy.arange(len(profile[0]), dtype=numpy.float32) self.getProfilePlot().addCurve(coords, profile[0], legend=profileName, xlabel=xLabel, color=self.overlayColor) self.plot.addItem(area[0], area[1], legend=self._POLYGON_LEGEND, color=self.overlayColor, shape='polygon', fill=True, replace=False, z=z + 1) self._showProfileMainWindow() def _showProfileMainWindow(self): """If profile window was created by this toolbar, try to avoid overlapping with the toolbar's parent window. """ profileMainWindow = self.getProfileMainWindow() if profileMainWindow is not None: winGeom = self.window().frameGeometry() qapp = qt.QApplication.instance() screenGeom = qapp.desktop().availableGeometry(self) spaceOnLeftSide = winGeom.left() spaceOnRightSide = screenGeom.width() - winGeom.right() profileWindowWidth = profileMainWindow.frameGeometry().width() if (profileWindowWidth < spaceOnRightSide or spaceOnRightSide > spaceOnLeftSide): # Place profile on the right profileMainWindow.move(winGeom.right(), winGeom.top()) else: # Not enough place on the right, place profile on the left profileMainWindow.move( max(0, winGeom.left() - profileWindowWidth), winGeom.top()) profileMainWindow.show() else: self.getProfilePlot().show() def hideProfileWindow(self): """Hide profile window. """ # this method is currently only used by StackView when the perspective # is changed if self.getProfileMainWindow() is not None: self.getProfileMainWindow().hide() class Profile3DToolBar(ProfileToolBar): def __init__(self, parent=None, plot=None, title='Profile Selection'): """QToolBar providing profile tools for an image or a stack of images. :param parent: the parent QWidget :param plot: :class:`PlotWindow` instance on which to operate. :param str title: See :class:`QToolBar`. :param parent: See :class:`QToolBar`. """ # TODO: add param profileWindow (specify the plot used for profiles) super(Profile3DToolBar, self).__init__(parent=parent, plot=plot, title=title) self.profile3dAction = ProfileToolButton( parent=self, plot=self.plot) self.profile3dAction.computeProfileIn2D() self.profile3dAction.setVisible(True) self.addWidget(self.profile3dAction) self.profile3dAction.sigDimensionChanged.connect(self._setProfileType) # create the 3D toolbar self._profileType = None self._setProfileType(2) def _setProfileType(self, dimensions): """Set the profile type: "1D" for a curve (profile on a single image) or "2D" for an image (profile on a stack of images). :param int dimensions: 1 for a "1D" profile or 2 for a "2D" profile """ # fixme this assumes that we created _profileMainWindow self._profileType = "1D" if dimensions == 1 else "2D" self.getProfileMainWindow().setProfileType(self._profileType) self.updateProfile() def updateProfile(self): """Method overloaded from :class:`ProfileToolBar`, to pass the stack of images instead of just the active image. In 1D profile mode, use the regular parent method. """ if self._profileType == "1D": super(Profile3DToolBar, self).updateProfile() elif self._profileType == "2D": stackData = self.plot.getCurrentView(copy=False, returnNumpyArray=True) if stackData is None: return self.plot.remove(self._POLYGON_LEGEND, kind='item') self.getProfilePlot().clear() self.getProfilePlot().setGraphTitle('') self.getProfilePlot().setGraphXLabel('X') self.getProfilePlot().setGraphYLabel('Y') self._createProfile(currentData=stackData[0], origin=stackData[1]['origin'], scale=stackData[1]['scale'], colormap=stackData[1]['colormap'], z=stackData[1]['z']) else: raise ValueError( "Profile type must be 1D or 2D, not %s" % self._profileType)