Skip to main content
`Ctrl`+`K`

# 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

    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

    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

    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)

        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)

        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
            if plotItem is None:
                self.__plotItem = None
            else:
                self.__plotItem = weakref.ref(plotItem)
            self.sigPlotItemChanged.emit()

        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

        def getProfileManager(self):
            """
            Returns the profile manager connected to this ROI.

            :rtype: ~silx.gui.plot.tools.profile.manager.ProfileManager
            """
            return self.__profileManager

        def getProfileWindow(self):
            """
            Returns the windows associated to this ROI, else None.

            :rtype: ProfileWindow
            """
            return self.__profileWindow

        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

        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