Source code for silx.gui.plot.tools.profile.core

# /*##########################################################################
#
# Copyright (c) 2018-2023 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.
#
# ###########################################################################*/
"""This module define core objects for profile tools.
"""

from __future__ import annotations

__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno", "V. Valls"]
__license__ = "MIT"
__date__ = "17/04/2020"

import typing
import numpy
import weakref

from silx.image.bilinear import BilinearImage
from silx.gui import qt
from silx.gui import colors
import silx.gui.plot.items


[docs] class CurveProfileData(typing.NamedTuple): coords: numpy.ndarray profile: numpy.ndarray title: str xLabel: str yLabel: str
class RgbaProfileData(typing.NamedTuple): coords: numpy.ndarray profile: numpy.ndarray profile_r: numpy.ndarray profile_g: numpy.ndarray profile_b: numpy.ndarray profile_a: numpy.ndarray title: str xLabel: str yLabel: str
[docs] class ImageProfileData(typing.NamedTuple): coords: numpy.ndarray profile: numpy.ndarray title: str xLabel: str yLabel: str colormap: colors.Colormap
class CurveProfileDesc(typing.NamedTuple): profile: numpy.ndarray name: typing.Optional[str] = None color: typing.Optional[str] = None class CurvesProfileData(typing.NamedTuple): coords: numpy.ndarray profiles: typing.List[CurveProfileDesc] title: str xLabel: str yLabel: str
[docs] class ProfileRoiMixIn: """Base mix-in for ROI which can be used to select a profile. This mix-in have to be applied to a :class:`~silx.gui.plot.items.roi.RegionOfInterest` in order to be usable by a :class:`~silx.gui.plot.tools.profile.manager.ProfileManager`. """ ITEM_KIND = None """Define the plot item which can be used with this profile ROI""" sigProfilePropertyChanged = qt.Signal() """Emitted when a property of this profile have changed""" sigPlotItemChanged = qt.Signal() """Emitted when the plot item linked to this profile have changed""" def __init__(self, parent=None): self.__profileWindow = None self.__profileManager = None self.__plotItem = None self.setName("Profile") self.setEditable(True) self.setSelectable(True)
[docs] def invalidateProfile(self): """Must be called by the implementation when the profile have to be recomputed.""" profileManager = self.getProfileManager() if profileManager is not None: profileManager.requestUpdateProfile(self)
[docs] def invalidateProperties(self): """Must be called when a property of the profile have changed.""" self.sigProfilePropertyChanged.emit()
def _setPlotItem(self, plotItem): """Specify the plot item to use with this profile :param `~silx.gui.plot.items.Item` plotItem: A plot item """ previousPlotItem = self.getPlotItem() if previousPlotItem is plotItem: return self.__plotItem = weakref.ref(plotItem) self.sigPlotItemChanged.emit()
[docs] def getPlotItem(self): """Returns the plot item used by this profile :rtype: `~silx.gui.plot.items.Item` """ if self.__plotItem is None: return None plotItem = self.__plotItem() if plotItem is None: self.__plotItem = None return plotItem
def _setProfileManager(self, profileManager): self.__profileManager = profileManager
[docs] def getProfileManager(self): """ Returns the profile manager connected to this ROI. :rtype: ~silx.gui.plot.tools.profile.manager.ProfileManager """ return self.__profileManager
[docs] def getProfileWindow(self): """ Returns the windows associated to this ROI, else None. :rtype: ProfileWindow """ return self.__profileWindow
[docs] def setProfileWindow(self, profileWindow): """ Associate a window to this ROI. Can be None. :param ProfileWindow profileWindow: A main window to display the profile. """ if profileWindow is self.__profileWindow: return if self.__profileWindow is not None: self.__profileWindow.sigClose.disconnect(self.__profileWindowAboutToClose) self.__profileWindow.setRoiProfile(None) self.__profileWindow = profileWindow if self.__profileWindow is not None: self.__profileWindow.sigClose.connect(self.__profileWindowAboutToClose) self.__profileWindow.setRoiProfile(self)
def __profileWindowAboutToClose(self): profileManager = self.getProfileManager() roiManager = profileManager.getRoiManager() try: roiManager.removeRoi(self) except ValueError: pass
[docs] def computeProfile( self, item: silx.gui.plot.items.Item ) -> typing.Union[ CurveProfileData, ImageProfileData, RgbaProfileData, CurvesProfileData ]: """ Compute the profile which will be displayed. This method is not called from the main Qt thread, but from a thread pool. :param item: A plot item """ raise NotImplementedError()
def _alignedFullProfile(data, origin, scale, position, roiWidth, axis, method): """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. :param str method: method to compute the profile. Can be 'mean' or 'sum' or 'none' :return: profile image + effective ROI area corners in plot coords """ assert axis in (0, 1) assert len(data.shape) == 3 assert method in ("mean", "sum", "none") # 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.0) start = min(max(0, start), height - roiWidth) end = start + roiWidth if method == "none": profile = None else: if start < height and end > 0: if method == "mean": fct = numpy.mean elif method == "sum": fct = numpy.sum else: raise ValueError("method not managed") profile = fct(data[:, max(0, start) : min(end, height), :], axis=1).astype( 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, method): """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. :param str method: method to compute the profile. Can be 'mean' or 'sum' :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] assert method in ("mean", "sum") 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) if method == "mean": _fct = numpy.mean elif method == "sum": _fct = numpy.sum else: raise ValueError("method not managed") imgProfile = _fct( 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, method): """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 :param str method: method to compute the profile. Can be 'mean' or 'sum' or 'none': to compute everything except the profile :return: `coords, profile, area, profileName, xLabel`, where: - coords is the X coordinate to use to display the profile - 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 the label for X in the profile window :rtype: tuple(ndarray,ndarray,(ndarray,ndarray),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, method=method ) if method == "none": coords = None else: coords = numpy.arange(len(profile[0]), dtype=numpy.float32) coords = coords * scale[0] + origin[0] yMin, yMax = min(area[1]), max(area[1]) - 1 if roiWidth <= 1: profileName = "{ylabel} = %g" % yMin else: profileName = "{ylabel} = [%g, %g]" % (yMin, yMax) xLabel = "{xlabel}" elif lineProjectionMode == "Y": # Vertical profile on the whole image profile, area = _alignedFullProfile( currentData3D, origin, scale, roiStart[0], roiWidth, axis=1, method=method ) if method == "none": coords = None else: coords = numpy.arange(len(profile[0]), dtype=numpy.float32) coords = coords * scale[1] + origin[1] xMin, xMax = min(area[0]), max(area[0]) - 1 if roiWidth <= 1: profileName = "{xlabel} = %g" % xMin else: profileName = "{xlabel} = [%g, %g]" % (xMin, xMax) xLabel = "{ylabel}" 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 if method == "none": profile = None else: profile = _alignedPartialProfile( currentData3D, rowRange, colRange, axis=0, method=method ) 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), ) if method == "none": profile = None else: profile = _alignedPartialProfile( currentData3D, rowRange, colRange, axis=1, method=method ) # 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 if method == "none": profile = None else: 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, method=method, ) ) 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 roiStartPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol roiEndPt = 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( ( roiStartPt[1] - 0.5 * roiWidth * dCol, roiStartPt[1] + 0.5 * roiWidth * dCol, roiEndPt[1] + 0.5 * roiWidth * dCol, roiEndPt[1] - 0.5 * roiWidth * dCol, ), dtype=numpy.float32, ) * scale[0] + origin[0], numpy.array( ( roiStartPt[0] - 0.5 * roiWidth * dRow, roiStartPt[0] + 0.5 * roiWidth * dRow, roiEndPt[0] + 0.5 * roiWidth * dRow, roiEndPt[0] - 0.5 * roiWidth * dRow, ), dtype=numpy.float32, ) * scale[1] + origin[1], ) # Convert start and end points back to plot coords y0 = startPt[0] * scale[1] + origin[1] x0 = startPt[1] * scale[0] + origin[0] y1 = endPt[0] * scale[1] + origin[1] x1 = endPt[1] * scale[0] + origin[0] if startPt[1] == endPt[1]: profileName = "{xlabel} = %g; {ylabel} = [%g, %g]" % (x0, y0, y1) if method == "none": coords = None else: coords = numpy.arange(len(profile[0]), dtype=numpy.float32) coords = coords * scale[1] + y0 xLabel = "{ylabel}" elif startPt[0] == endPt[0]: profileName = "{ylabel} = %g; {xlabel} = [%g, %g]" % (y0, x0, x1) if method == "none": coords = None else: coords = numpy.arange(len(profile[0]), dtype=numpy.float32) coords = coords * scale[0] + x0 xLabel = "{xlabel}" else: m = (y1 - y0) / (x1 - x0) b = y0 - m * x0 profileName = "{ylabel} = %g * {xlabel} %+g" % (m, b) if method == "none": coords = None else: coords = numpy.linspace( x0, x1, len(profile[0]), endpoint=True, dtype=numpy.float32 ) xLabel = "{xlabel}" return coords, profile, area, profileName, xLabel