Source code for machinevisiontoolbox.ImageWholeFeatures

"""
Whole-image feature computation: moments, Hu invariants, histograms, and entropy.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Literal

import cv2
import matplotlib.pyplot as plt
import numpy as np
import scipy as sp
from matplotlib.patches import Polygon
from matplotlib.ticker import ScalarFormatter
from spatialmath import SE3, base

if TYPE_CHECKING:
    from machinevisiontoolbox._image_typing import _ImageBase

from machinevisiontoolbox.base import findpeaks, findpeaks2d
from machinevisiontoolbox.base.imageio import set_window_title
from machinevisiontoolbox.mvtb_types import *


class ImageWholeFeaturesMixin(_ImageBase if TYPE_CHECKING else object):

    # ------------------ scalar statistics ----------------------------- #

    def sum(self, **kwargs: Any) -> np.generic | np.ndarray:
        r"""
        Sum of all pixels

        :param kwargs: additional keyword arguments passed to :func:`numpy.nansum`
        :type kwargs: dict
        :return: sum

        Computes the sum of pixels in the image:

        .. math::

            \sum_{uvc} I_{uvc}

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('flowers1.png')
            >>> img.sum()  # sum over all planes
            >>> img.sum(axis=(0,1))  # per-plane sum

        .. note::
            - The return value type is the same as the image type.
            - NaN values are ignored in the sum
            - For ``axis`` remember the axes are in NumPy order: 0=rows, 1=columns, 2=planes.

        :seealso: :func:`numpy.sum` :meth:`numnan` :meth:`~~machinevisiontoolbox.ImageWholeFeatures.ImageWholeFeaturesMixin.mpq`
            :meth:`~machinevisiontoolbox.ImageWholeFeatures.ImageWholeFeaturesMixin.npq`
            :meth:`~machinevisiontoolbox.ImageWholeFeatures.ImageWholeFeaturesMixin.upq`
        """
        return np.nansum(self._A, **kwargs)

    def min(self, **kwargs: Any) -> np.generic | np.ndarray:
        """
        Minimum value of all pixels

        :param kwargs: additional keyword arguments passed to :func:`numpy.nanmin`
        :type kwargs: dict
        :return: minimum value

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('flowers1.png')
            >>> img.min() # min over all planes
            >>> img.min(axis=(0,1))  # per-plane min

        .. note::
            - The return value type is the same as the image type.
            - NaN values are ignored
            - For ``axis`` remember the axes are in NumPy order: 0=rows, 1=columns, 2=planes.

        :seealso: :meth:`stats` :meth:`hist` :meth:`Hough` :meth:`max` :func:`numpy.nanmin` :meth:`numnan`
        """
        return np.nanmin(self._A, **kwargs)

    def max(self, **kwargs: Any) -> np.generic | np.ndarray:
        """
        Maximum value of all pixels

        :param kwargs: additional keyword arguments passed to :func:`numpy.nanmax`
        :type kwargs: dict
        :return: maximum value

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('flowers1.png')
            >>> img.max()  # max over all planes
            >>> img.max(axis=(0,1))  # per-plane max

        .. note::
            - The return value type is the same as the image type.
            - NaN values are ignored
            - For ``axis`` remember the axes are in NumPy order: 0=rows, 1=columns, 2=planes.

        :seealso: :meth:`stats` :meth:`hist` :meth:`min` :func:`numpy.nanmax` :meth:`numnan`
        """
        return np.nanmax(self._A, **kwargs)

    def mean(self, **kwargs: Any) -> np.generic | np.ndarray:
        """
        Mean value of all pixels

        :param kwargs: additional keyword arguments passed to :func:`numpy.nanmean`
        :type kwargs: dict
        :return: mean value

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('flowers1.png')
            >>> img.mean()  # mean over all planes
            >>> img.mean(axis=(0,1))  # per-plane mean

        .. note::
            - The return value type is the same as the image type.
            - NaN values are ignored
            - For ``axis`` remember the axes are in NumPy order: 0=rows, 1=columns, 2=planes.

        :seealso: :meth:`stats` :meth:`hist` :meth:`Hough` :meth:`std` :meth:`median` :func:`numpy.nanmean` :meth:`numnan`
        """
        return np.nanmean(self._A, **kwargs)

    def std(self, **kwargs: Any) -> np.generic | np.ndarray:
        """
        Standard deviation of all pixels

        :param kwargs: additional keyword arguments passed to :func:`numpy.nanstd`
        :type kwargs: dict
        :return: standard deviation value

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('flowers1.png')
            >>> img.std()  # std over all planes
            >>> img.std(axis=(0,1))  # per-plane std

        .. note::
            - The return value type is the same as the image type.
            - NaN values are ignored
            - For ``axis`` remember the axes are in NumPy order: 0=rows, 1=columns, 2=planes.

        :seealso: :meth:`stats` :meth:`hist` :meth:`mean` :meth:`var` :func:`numpy.nanstd` :meth:`numnan`
        """
        return np.nanstd(self._A, **kwargs)

    def var(self, **kwargs: Any) -> np.generic | np.ndarray:
        """
        Variance of all pixels

        :param kwargs: additional keyword arguments passed to :func:`numpy.nanvar`
        :type kwargs: dict
        :return: variance value

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('flowers1.png')
            >>> img.var()  # variance over all planes
            >>> img.var(axis=(0,1))  # per-plane variance

        .. note::
            - The return value type is the same as the image type.
            - NaN values are ignored
            - For ``axis`` remember the axes are in NumPy order: 0=rows, 1=columns, 2=planes.

        :seealso: :meth:`stats` :meth:`hist` :meth:`std` :func:`numpy.nanvar` :meth:`numnan`
        """
        return np.nanvar(self._A, **kwargs)

    def median(self, **kwargs: Any) -> np.generic | np.ndarray:
        """
        Median value of all pixels

        :param kwargs: additional keyword arguments passed to :func:`numpy.nanmedian`
        :type kwargs: dict
        :return: median value

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('flowers1.png')
            >>> img.median()  # median over all planes
            >>> img.median(axis=(0,1))  # per-plane median

        .. note::
            - The return value type is the same as the image type.
            - NaN values are ignored
            - For ``axis`` remember the axes are in NumPy order: 0=rows, 1=columns, 2=planes.

        :seealso: :meth:`stats` :meth:`hist` :meth:`mean` :meth:`std` :func:`numpy.nanmedian` :meth:`numnan`
        """
        return np.nanmedian(self._A, **kwargs)

    @staticmethod
    def _plane_stats(plane: np.ndarray) -> dict[str, float | int]:
        return {
            "min": float(np.nanmin(plane)),
            "max": float(np.nanmax(plane)),
            "mean": float(np.nanmean(plane)),
            "sdev": float(np.nanstd(plane)),
            "median": float(np.nanmedian(plane)),
            "nnan": int(np.sum(np.isnan(plane))),
            "ninf": int(np.sum(np.isinf(plane))),
        }

    @staticmethod
    def _format_stats(stats: dict[str, float | int]) -> str:
        s = (
            f"span=[{stats['min']:g}, {stats['max']:g}]; "
            f"mean={stats['mean']:g}, "
            f"𝜎={stats['sdev']:g}; "
            f"median={stats['median']:g}"
        )
        nnan = stats["nnan"]
        ninf = stats["ninf"]
        if nnan + ninf > 0:
            s += " (contains "
            if nnan > 0:
                s += f"{nnan}xNaN{'s' if nnan > 1 else ''}"
            if ninf > 0:
                s += f" {ninf}xInf{'s' if ninf > 1 else ''}"
            s += ")"
        return s

    @property
    def stats(self) -> dict[str, Any]:
        """
        Pixel value statistics

        :return: statistics dictionary; for greyscale keys are ``min``, ``max``,
            ``mean``, ``sdev``, ``median``, ``nnan``, ``ninf``; for color images
            returns a dictionary mapping plane name to that same dictionary
        :rtype: dict

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('street.png')
            >>> img.stats
            >>> img = Image.Read('flowers1.png')
            >>> img.stats

        .. note::

            - Returns statistics as a dictionary and does not print.
            - NaN values are ignored for scalar statistics and reported via
              ``nnan`` and ``ninf`` counts.

        :seealso: :meth:`printstats` :meth:`hist` :meth:`min` :meth:`max` :meth:`mean` :meth:`std` :meth:`median`
        """

        if self.iscolor and self.colororder is not None:
            all_stats = {}
            for k, v in sorted(self.colororder.items(), key=lambda x: x[1]):
                all_stats[k] = self._plane_stats(self._A[..., v])
            return all_stats
        else:
            return self._plane_stats(self._A)

    def printstats(self) -> None:
        """
        Print pixel value statistics

        Prints a concise summary of image statistics.

        :seealso: :meth:`stats`
        """

        stats = self.stats
        if self.iscolor and self.colororder is not None:
            colororder = self.colororder
            for k in sorted(stats.keys(), key=lambda x: colororder[x]):
                print(f"{k:s}: {self._format_stats(stats[k])}")
        else:
            print(self._format_stats(stats))

    # ------------------ histogram ------------------------------------- #
    def hist(
        self,
        nbins: int = 256,
        sorted: bool | str = False,
        span: Literal["dtype"] | tuple[float, float] | None = "dtype",
        clip: bool = False,
        opt: str | None = None,
    ) -> "Histogram":
        """
        Image histogram

        :param nbins: number of histogram bins, defaults to 256
        :type nbins: int, optional
        :param sorted: sort bins by count rather than value, defaults to False
        :type sorted: bool
        :param span: histogram span definition, defaults to ``'dtype'``;
            ``'dtype'`` means full span of image dtype if integer or [0,1] for float, ``None`` means finite data
            min/max, ``(a,b)`` means explicit range
        :type span: str, tuple(float, float), None
        :param clip: clip out-of-span values to span endpoints before binning,
            defaults to False. If False, out-of-span values are ignored.
        :type clip: bool, optional
        :param opt: deprecated histogram option string, use ``sorted`` instead;
            supported legacy value is ``'sorted'``
        :type opt: str, optional
        :return: histogram of image
        :rtype: :class:`~machinevisiontoolbox.ImageWholeFeatures.Histogram`

        Returns an object that summarizes the distribution of
        pixel values in each color plane.

        Example:

            .. code-block:: python

                from machinevisiontoolbox import Image
                img = Image.Read('street.png')
                type(hist)
                hist = img.hist()
                hist
                hist.plot()


        .. plot::

            from machinevisiontoolbox import Image
            img = Image.Read('street.png')
            img.hist().plot()

        Example:

            .. code-block:: python

                from machinevisiontoolbox import Image
                img = Image.Read('flowers1.png')
                hist = img.hist()
                hist
                hist.plot(style='stack')


        .. plot::

            from machinevisiontoolbox import Image
            img = Image.Read('flowers1.png')
            img.hist().plot(style='stack')

        Example:

            .. code-block:: python

                from machinevisiontoolbox import Image
                img = Image.Read('flowers1.png')
                hist = img.hist()
                hist
                hist.plot(style='overlay')


        .. plot::

            from machinevisiontoolbox import Image
            img = Image.Read('flowers1.png')
            img.hist().plot(style='overlay')

        .. note::
                        - Histogram range is controlled by ``span``.
                        - ``span='dtype'`` uses full datatype span (for example uint8: 0-255,
                            float: 0.0-1.0).
                        - ``span=None`` uses the finite min/max of image data.
                        - Values outside ``span`` are ignored unless ``clip=True``,
                            in which case they are clipped to the span endpoints.
                        - For floating point images all NaN and Inf values are removed before
                            computing the histogram.

        :references:
            - |RVC3|, Section 14.4.3.

        .. important:: Histogram is computed using ``numpy.histogram`` independently for each plane.

        :seealso:
            :meth:`stats`
            :class:`~machinevisiontoolbox.ImageWholeFeatures.Histogram`
            :func:`numpy.histogram`
        """

        return Histogram(
            self,
            nbins=nbins,
            sorted=sorted,
            span=span,
            clip=clip,
            opt=opt,
        )

    @property
    def x(self) -> np.ndarray:
        """
        Histogram bin values

        :return: array of left-hand bin values
        :rtype: ndarray(N)
        """
        return self.hist().x

    @property
    def h(self) -> np.ndarray:
        """
        Histogram count values

        :return: array of histogram count values
        :rtype: ndarray(N) or ndarray(N,P)
        """
        return self.hist().h

    @property
    def cdf(self) -> np.ndarray:
        """
        Cumulative histogram values

        :return: array of cumulative histogram values
        :rtype: ndarray(N) or ndarray(N,P)
        """
        return self.hist().cdf

    @property
    def ncdf(self) -> np.ndarray:
        """
        Normalised cumulative histogram values

        :return: array of normalised cumulative histogram values
        :rtype: ndarray(N) or ndarray(N,P)
        """
        return self.hist().ncdf

    def peaks(self, **kwargs: Any) -> np.ndarray | list[np.ndarray]:
        """
        Histogram peaks

        :param kwargs: parameters passed to histogram peak finder
        :return: positions of histogram peaks
        :rtype: ndarray(M), list of ndarray
        """
        return self.hist().peaks(**kwargs)

    # ------------------ moments --------------------------------------- #

    def mpq(self, p: int, q: int) -> float:
        r"""
        Image moments

        :param p: u exponent
        :type p: int
        :param q: v exponent
        :type q: int
        :return: moment
        :type: scalar

        Computes the pq'th moment of the image:

        .. math::

            m(I) = \sum_{uv} I_{uv} u^p v^q

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('shark1.png')
            >>> img.mpq(1, 0)

        .. note::
            - Supports single channel images only.
            - ``mpq(0, 0)`` is the same as ``sum()`` but less efficient.

        :references:
            - |RVC3|, Section 12.1.3.4.

        :seealso: :meth:`sum` :meth:`npq` :meth:`upq`
        """

        if not isinstance(p, int) or not isinstance(q, int):
            raise TypeError(p, "p, q must be an int")

        im = self.mono()._A
        X, Y = self.meshgrid()
        return np.sum(im * (X**p) * (Y**q))

    def upq(self, p: int, q: int) -> float:
        r"""
        Central image moments

        :param p: u exponent
        :type p: int
        :param q: v exponent
        :type q: int
        :return: moment
        :type: scalar

        Computes the pq'th central moment of the image:

        .. math::

            \mu(I) = \sum_{uv} I_{uv} (u-u_0)^p (v-v_0)^q

        where :math:`u_0 = m_{10}(I) / m_{00}(I)` and :math:`v_0 = m_{01}(I) / m_{00}(I)`.

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('shark1.png')
            >>> img.upq(2, 2)

        .. note::
            - The central moments are invariant to translation.
            - Supports single channel images only.

        :references:
            - |RVC3|, Section 12.1.3.4.

        :seealso: :meth:`sum` :meth:`mpq` :meth:`upq`
        """

        if not isinstance(p, int) or not isinstance(q, int):
            raise TypeError(p, "p, q must be an int")

        m00 = self.mpq(0, 0)
        xc = self.mpq(1, 0) / m00
        yc = self.mpq(0, 1) / m00

        im = self.mono()._A
        X, Y = self.meshgrid()

        return np.sum(im * ((X - xc) ** p) * ((Y - yc) ** q))

    def npq(self, p: int, q: int) -> float:
        r"""
        Normalized central image moments

        :param p: u exponent
        :type p: int
        :param q: v exponent
        :type q: int
        :return: moment
        :type: scalar

        Computes the pq'th normalized central moment of the image:

        .. math::

            \nu(I) = \frac{\mu_{pq}(I)}{m_{00}(I)} = \frac{1}{m_{00}(I)} \sum_{uv} I_{uv} (u-u_0)^p (v-v_0)^q

        where :math:`u_0 = m_{10}(I) / m_{00}(I)` and :math:`v_0 = m_{01}(I) / m_{00}(I)`.

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('shark1.png')
            >>> img.npq(2, 2)

        .. note::
            - The normalized central moments are invariant to translation and
              scale.
            - Supports single channel images only.

        :references:
            - |RVC3|, Section 12.1.3.4.

        :seealso: :meth:`sum` :meth:`mpq` :meth:`upq`
        """
        if not isinstance(p, int) or not isinstance(q, int):
            raise TypeError(p, "p, q must be an int")
        if (p + q) < 2:
            raise ValueError(p + q, "normalized moments only valid for p+q >= 2")

        g = (p + q) / 2 + 1

        return self.upq(p, q) / self.mpq(0, 0) ** g

    def moments(self, binary: bool = False) -> dict[str, float]:
        """
        Image moments

        :param binary: if True, all non-zero pixels are treated as 1's
        :type binary: bool
        :return: image moments
        :type: dict

        Compute multiple moments of the image and return them as a dict

        ==========================  ===============================================================================
        Moment type                 dict keys
        ==========================  ===============================================================================
        moments                     ``m00`` ``m10`` ``m01`` ``m20`` ``m11`` ``m02`` ``m30`` ``m21`` ``m12`` ``m03``
        central moments             ``mu20`` ``mu11`` ``mu02`` ``mu30`` ``mu21`` ``mu12`` ``mu03`` |
        normalized central moments  ``nu20`` ``nu11`` ``nu02`` ``nu30`` ``nu21`` ``nu12`` ``nu03`` |
        ==========================  ===============================================================================

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('shark1.png')
            >>> img.moments()

        .. note::
            - Converts a color image to greyscale.

        :references:
            - |RVC3|, Section 12.1.3.4.

        .. important:: Uses OpenCV function ``cv2.moments`` which accepts single-channel, CV_8U, CV_16U, CV_16S, CV_32F or CV_64F images (colour images are automatically converted to greyscale).

        :seealso: :meth:`mpq` :meth:`npq` :meth:`upq` `opencv.moments <https://docs.opencv.org/4.x/d8/d23/classcv_1_1Moments.html>`_
        """
        return cv2.moments(array=self.mono().to_int(), binaryImage=binary)

    def humoments(self) -> np.ndarray:
        """
        Hu image moment invariants

        :return: Hu image moments
        :rtype: ndarray(7)

        Computes the seven Hu image moment invariants of the image.

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> img = Image.Read('shark1.png', dtype='float')
            >>> img.humoments()

        .. note::
            - Image is assumed to be a binary image of a single connected
              region
            - These invariants are a function of object shape and are invariant
              to position, orientation and scale.

        :references:
            - M-K. Hu, Visual pattern recognition by moment invariants. IRE
              Trans. on Information Theory, IT-8:pp. 179-187, 1962.
            - |RVC3|, Section 12.1.3.6.

        .. important:: Uses OpenCV functions ``cv2.moments`` and ``cv2.HuMoments`` which accept single-channel, CV_8U, CV_16U, CV_16S, CV_32F or CV_64F images.

        :seealso: :meth:`moments` `opencv.HuMoments <https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html#gab001db45c1f1af6cbdbe64df04c4e944>`_
        """

        # TODO check for binary image

        self._opencv_type_check(
            self._A, "single-channel", "CV_8U", "CV_16U", "CV_16S", "CV_32F", "CV_64F"
        )
        moments = cv2.moments(array=self._A)
        hu = cv2.HuMoments(m=moments)
        return hu.flatten()

    # ------------------ pixel values --------------------------------- #

    def nonzero(self) -> np.ndarray:
        """
        Find non-zero pixel values as 2D coordinates

        :return: coordinates of non-zero pixels
        :rtype: ndarray(2,N)

        The (u,v) coordinates are given as columns of the returned array.

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> import numpy as np
            >>> pix = np.zeros((10,10)); pix[2,3]=10; pix[4,5]=11; pix[6,7]=12
            >>> img = Image(pix)
            >>> img.nonzero()

        :references:
            - |RVC3|, Section 12.1.3.2.

        :seealso: :meth:`flatnonzero`
        """
        v, u = np.nonzero(self._A)
        return np.vstack((u, v))

    def flatnonzero(self) -> np.ndarray:
        """
        Find non-zero pixel values as 1D indices

        :return: index of non-zero pixels
        :rtype: ndarray(N)

        The coordinates are given as 1D indices into a flattened version of
        the image.

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> import numpy as np
            >>> pix = np.zeros((10,10)); pix[2,3]=10; pix[4,5]=11; pix[6,7]=12
            >>> img = Image(pix)
            >>> img.flatnonzero()
            >>> img.view1d()[23]

        :seealso: :meth:`view1d` :meth:`nonzero`
        """
        return np.flatnonzero(self._A)

    def peak2d(
        self,
        npeaks: int = 2,
        scale: int = 1,
        interp: bool = False,
        positive: bool = True,
    ) -> tuple[np.ndarray, np.ndarray]:
        r"""
        Find local maxima in image

        :param npeaks: number of peaks to return, defaults to 2
        :type npeaks: int, optional
        :param scale: scale of peaks to consider
        :type scale: int
        :param interp:  interpolate the peak positions, defaults to False
        :type interp: bool, optional
        :param positive:  only select peaks that are positive, defaults to False
        :type positive: bool, optional
        :return: peak magnitude and positions
        :rtype: ndarray(npeaks), ndarray(2,npeaks)

        Find the positions of the local maxima in the image.  A local maxima
        is the largest value within a sliding window of width
        :math:`2 \mathtt{scale}+1`.

        Example:

        .. runblock:: pycon

            >>> from machinevisiontoolbox import Image
            >>> import numpy as np
            >>> peak = np.array([[10, 20, 30], [40, 50, 45], [15, 20, 30]])
            >>> img = Image(np.pad(peak, 3, constant_values=10))
            >>> img.A
            >>> img.peak2d(interp=True)

        .. note::
            - Edges elements will never be returned as maxima.
            - To find minima, use ``peak2d(-image)``.
            - The ``interp`` option fits points in the neighbourhood about the
              peak with a paraboloid and its peak position is returned.

        :seealso: :meth:`~machinevisiontoolbox.base.findpeaks.findpeaks2d`
        """

        ret = findpeaks2d(self._A, npeaks=npeaks, scale=scale, interp=interp)
        return ret[:, -1], ret[:, :2].T


[docs] class Histogram: def __init__( self, image: ImageWholeFeaturesMixin, nbins: int = 256, sorted: bool | str = False, span: Literal["dtype"] | tuple[float, float] | None = "dtype", clip: bool = False, opt: str | None = None, ) -> None: """ Create histogram instance :param image: image to histogram :type image: Image :param nbins: number of histogram bins, defaults to 256 :type nbins: int, optional :param sorted: sort bins by count rather than value, defaults to False :type sorted: bool :param span: histogram span definition, defaults to ``'dtype'``; ``'dtype'`` means full span of image type, ``None`` means finite data min/max, ``(a,b)`` means explicit range :type span: str, tuple(float, float), None :param clip: clip out-of-span values to span endpoints before binning, defaults to False. If False, out-of-span values are ignored. :type clip: bool, optional :param opt: deprecated histogram option string, use ``sorted`` instead; supported legacy value is ``'sorted'`` :type opt: str, optional Create :class:`Histogram` instance by computing the histogram over each image plane. :seealso: :meth:`~machinevisiontoolbox.ImageFeatures.ImageFeaturesMixin.hist` """ if nbins <= 0: raise ValueError("nbins must be > 0") import warnings if isinstance(sorted, str): if sorted == "sorted": warnings.warn( "Deprecated in 1.1.0: use hist(sorted=True) instead of hist(sorted='sorted').", DeprecationWarning, stacklevel=2, ) sorted = True else: raise ValueError("sorted string value must be 'sorted'") if opt is not None: if opt != "sorted": raise ValueError("opt value must be 'sorted'") warnings.warn( "Deprecated in 1.1.0: use hist(sorted=True) instead of hist(opt='sorted').", DeprecationWarning, stacklevel=2, ) sorted = True if not isinstance(sorted, bool): raise TypeError("sorted must be bool") if not isinstance(clip, bool): raise TypeError("clip must be bool") if span == "dtype": if np.issubdtype(image.dtype, np.bool_): xrange = (0.0, 1.0) elif np.issubdtype(image.dtype, np.integer): dtype_info = np.iinfo(image.dtype) xrange = (float(dtype_info.min), float(dtype_info.max)) else: xrange = (0.0, 1.0) elif span is None: values = image.A.reshape(-1) if np.issubdtype(values.dtype, np.floating): values = values[np.isfinite(values)] if values.size == 0: raise ValueError("cannot determine span from empty finite image data") xrange = (float(np.min(values)), float(np.max(values))) elif isinstance(span, tuple) and len(span) == 2: xrange = (float(span[0]), float(span[1])) else: raise ValueError("span must be 'dtype', None or a 2-tuple") if not np.isfinite(xrange[0]) or not np.isfinite(xrange[1]): raise ValueError("span limits must be finite") if xrange[1] <= xrange[0]: raise ValueError("span max must be greater than span min") x = np.linspace(xrange[0], xrange[1], nbins, endpoint=True) hc = [] for i in range(image.nplanes): if image.nplanes == 1: plane = image.A else: plane = image.A[..., i] values = plane.reshape(-1) if np.issubdtype(values.dtype, np.floating): values = values[np.isfinite(values)] if clip: values = np.clip(values, xrange[0], xrange[1]) h, _ = np.histogram(values, bins=nbins, range=xrange) hc.append(h.astype(np.int64)) hs = np.column_stack(hc) self.nplanes = hs.shape[1] self._sorted = sorted if self._sorted: if self.nplanes != 1: raise ValueError("sorted histogram is only supported for mono images") order = np.argsort(hs[:, 0])[::-1] hs = hs[order, :] x = np.arange(nbins, dtype=float) if self.nplanes == 1: self._h = hs[:, 0] else: self._h = hs self._x = x.flatten() self._xrange = xrange self.isfloat = image.isfloat self.colordict = image.colororder # 'hist', 'h cdf normcdf x') def __str__(self) -> str: """ Histogram summary as a string :return: concise summary of histogram :rtype: str Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Random(256) >>> h = im.hist(100) >>> print(h) """ s = f"histogram with {len(self.x)} bins" if self.nplanes > 1: s += f" x {self._h.shape[1]} planes" s += f": xrange {self.x[0]} - {self.x[-1]}, yrange {int(np.min(self._h))} - {int(np.max(self._h))}" return s def __repr__(self) -> str: """ Print histogram summary :return: print concise summary of histogram :rtype: str Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Random(256) >>> h = im.hist(100) >>> h """ return f"Histogram(nbins={len(self.x)}, nplanes={self.nplanes}, xrange=({self.x[0]}, {self.x[-1]}), yrange=({int(np.min(self._h))}, {int(np.max(self._h))}))" @property def x(self) -> np.ndarray: """ Histogram bin values :return: array of left-hand bin values :rtype: ndarray(N) Bin :math:`i` contains grey values in the range :math:`[x_{[i]}, x_{[i+1]})`. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> import numpy as np >>> hist = Image.Read('flowers1.png').hist() >>> with np.printoptions(threshold=10): >>> hist.x """ return self._x @property def h(self) -> np.ndarray: """ Histogram count values :return: array of histogram count values :rtype: ndarray(N) or ndarray(N,P) For a greyscale image this is a 1D array, for a multiplane (color) image this is a 2D array with the histograms of each plane as columns. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> import numpy as np >>> hist = Image.Read('flowers1.png').hist() >>> with np.printoptions(threshold=10): >>> hist.h """ return self._h @property def cdf(self) -> np.ndarray: """ Cumulative histogram values :return: array of cumulative histogram count values :rtype: ndarray(N) or ndarray(N,P) For a greyscale image this is a 1D array, for a multiplane (color) image this is a 2D array with the cumulative histograms of each plane as columns. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> import numpy as np >>> hist = Image.Read('flowers1.png').hist() >>> with np.printoptions(threshold=10): >>> hist.cdf """ return np.cumsum(self._h, axis=0) @property def ncdf(self) -> np.ndarray: """ Normalized cumulative histogram values :return: array of normalized cumulative histogram count values :rtype: ndarray(N) or ndarray(N,P) For a greyscale image this is a 1D array, for a multiplane (color) image this is a 2D array with the normalized cumulative histograms of each plane as columns. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> import numpy as np >>> hist = Image.Read('flowers1.png').hist() >>> with np.printoptions(threshold=10): >>> hist.ncdf """ y = np.cumsum(self._h, axis=0) if self.nplanes == 1: y = y / y[-1] else: y = y / y[-1, :] return y def plot( self, type="frequency", block=False, filled=None, stats=True, style="stack", cursor=False, alpha=0.5, title=None, log=False, bar=None, **kwargs, ): """ Plot histogram :param type: histogram type, one of: 'frequency' [default], 'cdf', 'ncdf' :type type: str, optional :param block: hold plot, defaults to False :type block: bool, optional :param filled: use a filled stairs plot, defaults to True for frequency plot, False for other plots :type filled: bool, optional :param stats: draw vertical lines for mean (solid) and median (dashed), defaults to True :type stats: bool, optional :param style: Style for multiple plots, one of: 'stack' [default], 'overlay' :type style: str, optional :param cursor: enable interactive data cursor for stacked line plots, defaults to False. Cursor is ignored for ``overlay`` style. :type cursor: bool, optional :param alpha: transparency for overlay plot, defaults to 0.5 :type alpha: float, optional :param title: plot title, defaults to None :type title: str, optional :param log: use logarithmic y-axis, defaults to False :type log: bool, optional :param kwargs: additional keyword arguments passed to Matplotlib plotting functions, ``plt.stairs`` for ``solid=True`` and ``plt.plot`` for ``solid=False``, and ``plt.Polygon`` for ``style='overlay'`` :raises ValueError: invalid histogram type :raises ValueError: cannot use overlay style for 1-channel histogram Plots the histogram using Matplotlib. For a color image, the histograms of each plane are plotted separately. The ``type`` option selects the type of histogram to plot: ``frequency``, ``cdf`` or ``ncdf`` (normalized cumulative in the range 0 to 1). The ``style`` option selects the style for plotting multiple planes: - ``stack``: plot each plane in a separate subplot. The ``cursor`` option enables an interactive data cursor which displays the histogram values at the cursor position. The ``filled`` option selects whether to use a filled stairs plot. - ``overlay``: plot all planes in the same axes with different colors. The ``alpha`` option controls the transparency of the bars in the ''overlay'' style. The ``log`` option uses a logarithmic scale for the y-axis, which can be useful for visualizing histograms with a large dynamic range, zero values are ignored in log plots. """ x = self._x[:] import warnings if bar is not None: warnings.warn( "Deprecated in 1.1.0: use solid= instead of bar=.", DeprecationWarning, stacklevel=2, ) if filled is None: filled = bar if type == "frequency": y = self.h maxy = np.max(y) ylabel1 = "frequency" ylabel2 = "frequency" if filled is None: filled = True elif type in ("cdf", "cumulative"): y = self.cdf maxy = np.max(y[-1, :]) ylabel1 = "cumulative frequency" ylabel2 = "cumulative frequency" elif type in ("ncdf", "normalized"): y = self.ncdf ylabel1 = "norm. cumulative freq." ylabel2 = "normalized cumulative frequency" maxy = 1 else: raise ValueError("unknown type") if self.nplanes == 1: y = y[..., np.newaxis] hist_counts = self.h if self.nplanes == 1: hist_counts = hist_counts[..., np.newaxis] def plane_stats(counts: np.ndarray) -> tuple[float | None, float | None]: total = float(np.sum(counts)) if total <= 0: return None, None mean = float(np.sum(self.x * counts) / total) cdf = np.cumsum(counts) median_idx = int(np.searchsorted(cdf, 0.5 * total, side="left")) median = float(self.x[min(median_idx, len(self.x) - 1)]) return mean, median if self._sorted: xrange = (self.x[0], self.x[-1]) xlabel = "bin rank" else: xrange = (self._xrange[0], self._xrange[1]) xlabel = "pixel value" colors: list[str] = [] if self.colordict is not None: colors = list(self.colordict.keys()) n = len(colors) # ylabel1 += ' (' + ','.join(colors) + ')' else: n = 1 if style == "overlay": raise ValueError("cannot use overlay style for monochrome image") if self._sorted and style == "overlay": raise ValueError("cannot use overlay style for sorted histogram") if style == "stack": fig, axes = plt.subplots(n, 1) axes = np.atleast_1d(axes) for i, ax in enumerate(axes): bin_width = x[1] - x[0] ax.stairs( y[:, i], np.append(x, x[-1] + bin_width), fill=filled, **kwargs ) ax.grid() if n == 1: ax.set_ylabel(ylabel1) else: ax.set_ylabel(ylabel1 + " (" + colors[i] + ")") ax.set_xlim(*xrange) ax.set_ylim(0, maxy) ax.yaxis.set_major_formatter( ScalarFormatter(useOffset=False, useMathText=True) ) if stats: mean, median = plane_stats(hist_counts[:, i]) if mean is not None and median is not None: ax.axvline( mean, color="k", linestyle="-", linewidth=1.0, label="mean", ) ax.axvline( median, color="k", linestyle="--", linewidth=1.0, label="median", ) ax.legend(loc="best") if log: ax.set_yscale("log") axes[-1].set_xlabel(xlabel) if cursor: x_interp = np.asarray(x, dtype=float) xpad = 0.01 * (x_interp[-1] - x_interp[0]) cursor_lines = [] cursor_markers = [] cursor_labels = [] for i, ax in enumerate(axes): y0 = float(y[0, i]) vline = ax.axvline( x_interp[0], color="k", linestyle="--", linewidth=0.8, alpha=0.8, visible=False, ) marker = ax.plot( [x_interp[0]], [y0], marker="o", markersize=4, color="k", linestyle="None", visible=False, )[0] label = ax.text( x_interp[0], y0, "", fontsize="small", visible=False, bbox={"facecolor": "white", "alpha": 0.7, "edgecolor": "none"}, ) cursor_lines.append(vline) cursor_markers.append(marker) cursor_labels.append(label) def on_move(event): if event.xdata is None or event.inaxes not in axes: for vline, marker, label in zip( cursor_lines, cursor_markers, cursor_labels ): vline.set_visible(False) marker.set_visible(False) label.set_visible(False) fig.canvas.draw_idle() return xv = float(np.clip(event.xdata, x_interp[0], x_interp[-1])) for i, (vline, marker, label) in enumerate( zip(cursor_lines, cursor_markers, cursor_labels) ): yv = float(np.interp(xv, x_interp, y[:, i])) vline.set_xdata([xv, xv]) marker.set_data([xv], [yv]) label.set_position((xv + xpad, yv)) label.set_text(f"{yv:.3g}") vline.set_visible(True) marker.set_visible(True) label.set_visible(True) fig.canvas.draw_idle() fig.canvas.mpl_connect("motion_notify_event", on_move) elif style == "overlay": x = np.r_[xrange[0], x, xrange[1]] _, ax = plt.subplots(1, 1) patchcolor = [] goodcolors = [c for c in "rgbykcm"] if self.colordict is None: self.colordict = {c: i for i, c in enumerate(goodcolors[:n])} colors = list(self.colordict.keys()) patchcolor = [c.lower() for c in colors] else: for color, i in self.colordict.items(): if color.lower() in "rgbykcm": patchcolor.append(color.lower()) else: patchcolor.append(goodcolors.pop(0)) for i in range(n): yi = np.r_[0, y[:, i], 0] p1 = np.array([x, yi]).T poly1 = Polygon( p1, closed=True, facecolor=patchcolor[i], alpha=alpha, label=colors[i], **kwargs, ) ax.add_patch(poly1) if stats: mean, median = plane_stats(np.sum(hist_counts, axis=1)) if mean is not None and median is not None: ax.axvline( mean, color="k", linestyle="-", linewidth=1.0, label="mean", ) ax.axvline( median, color="k", linestyle="--", linewidth=1.0, label="median", ) ax.set_xlim(*xrange) ax.set_ylim(0, maxy) ax.yaxis.set_major_formatter( ScalarFormatter(useOffset=False, useMathText=True) ) ax.set_xlabel(xlabel) ax.set_ylabel(ylabel2) if log: ax.set_yscale("log") ax.grid(True) ax.legend(loc="best") else: raise ValueError("unknown style") if title is not None: set_window_title(title) plt.show(block=block) def peaks(self, **kwargs: Any) -> np.ndarray | list[np.ndarray]: """ Histogram peaks :param kwargs: parameters passed to :func:`~machinevisiontoolbox.base.findpeaks.findpeaks` :return: positions of histogram peaks :rtype: ndarray(M), list of ndarray For a greyscale image return an array of grey values corresponding to local maxima. For a color image return a list of arrays of grey values corresponding to local maxima in each plane. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> import numpy as np >>> hist = Image.Read('street.png').hist() >>> hist.peaks(scale=20) :seealso: :func:`~machinevisiontoolbox.base.findpeaks.findpeaks` """ if self.nplanes == 1: # greyscale image x = findpeaks(self.h, self.x, **kwargs) return x[0] else: xp = [] for i in range(self.nplanes): x = findpeaks(self.h[:, i], self.x, **kwargs) xp.append(x[0]) return xp
# # helper function that was part of hist() in the Matlab toolbox # # TODO consider moving this to ImpageProcessingBase.py # def plothist(self, title=None, block=False, **kwargs): # """ # plot first image histogram as a line plot (TODO as poly) # NOTE convenient, but maybe not a great solution because we then need to # duplicate all the plotting options as for idisp? # """ # if title is None: # title = self[0].filename # hist = self[0].hist(**kwargs) # x = hist[0].x # h = hist[0].h # fig, ax = plt.subplots() # # line plot histogram style # if self.iscolor: # ax.plot(x[:, 0], h[:, 0], 'b', alpha=0.8) # ax.plot(x[:, 1], h[:, 1], 'g', alpha=0.8) # ax.plot(x[:, 2], h[:, 2], 'r', alpha=0.8) # else: # ax.plot(hist[0].x, hist[0].h, 'k', alpha=0.7) # # polygon histogram style # polygon_style = False # if polygon_style: # if self.iscolor: # from matplotlib.patches import Polygon # # TODO make sure pb goes to bottom of axes at the edges: # pb = np.stack((x[:, 0], h[:, 0]), axis=1) # polyb = Polygon(pb, # closed=True, # facecolor='b', # linestyle='-', # alpha=0.75) # ax.add_patch(polyb) # pg = np.stack((x[:, 1], h[:, 1]), axis=1) # polyg = Polygon(pg, # closed=True, # facecolor='g', # linestyle='-', # alpha=0.75) # ax.add_patch(polyg) # pr = np.stack((x[:, 2], h[:, 2]), axis=1) # polyr = Polygon(pr, # closed=True, # facecolor='r', # linestyle='-', # alpha=0.75) # ax.add_patch(polyr) # # addpatch seems to require a plot, so hack is to plot null and # # make alpha=0 # ax.plot(0, 0, alpha=0) # else: # from matplotlib.patches import Polygon # p = np.hstack((x, h)) # poly = Polygon(p, # closed=True, # facecolor='k', # linestyle='-', # alpha=0.5) # ax.add_patch(poly) # ax.plot(0, 0, alpha=0) # ax.set_ylabel('count') # ax.set_xlabel('bin') # ax.grid() # ax.set_title(title) # plt.show(block=block) # # him = im[2].hist() # # fig, ax = plt.subplots() # # ax.plot(him[i].x[:, 0], him[i].h[:, 0], 'b') # # ax.plot(him[i].x[:, 1], him[i].h[:, 1], 'g') # # ax.plot(him[i].x[:, 2], him[i].h[:, 2], 'r') # # plt.show() if __name__ == "__main__": from pathlib import Path import pytest pytest.main( [ str( Path(__file__).parent.parent.parent / "tests" / "test_image_whole_features.py" ), "-v", ] )