Source code for machinevisiontoolbox.ImageBlobs

"""
Detection and description of 2D blob (connected region) features in images.
"""

import copy
import subprocess
import sys
import tempfile
import webbrowser
from collections import UserList, namedtuple
from dataclasses import dataclass
from typing import Any, cast

import cv2
import matplotlib.pyplot as plt
import numpy as np
import scipy as sp
from ansitable import ANSITable, Column
from spatialmath import SE2, base
from spatialmath.base import isscalar, plot_box, plot_point, plot_polygon

from machinevisiontoolbox.base import plot_labelbox
from machinevisiontoolbox.decorators import array_result, scalar_result

"""
NOTES

Defines two key classes:

- ``Blob`` is a simple container for the parameters of a single blob.
- ``Blobs`` is a ``UserList`` of ``Blob`` instances.  It behaves like a list
    and each element of the list is a ``Blob`` instance.  A single element ``Blobs``
    instance represents a single blob.  A ``Blobs`` instance has many additional
    attributes compared to a ``Blob`` instance, and these are derived from the 
    ``Blob`` instances in the list.
"""


[docs] @dataclass class Blob: """Container for the parameters of a single blob. A :class:`Blob` instance holds geometric, moment, and hierarchy data for a single blob region. A set of blobs is represented by a :class:`Blobs` instance which acts like a list of :class:`Blob` instances. """ id: int bbox: np.ndarray moments: Any touch: bool perimeter: np.ndarray a: float b: float orientation: float children: list[Any] parent: Any uc: float vc: float level: int color: Any perimeter_length: float contourpoint: np.ndarray circularity: float | None = None def __str__(self) -> str: """Create a compact string representation of the Blob object :return: compact string representation :rtype: str """ return f"Blob(id={self.id}, area={self.moments.m00:.2g}, color={self.color}, parent={self.parent.id if self.parent else None})" def __repr__(self) -> str: return str(self) def print(self) -> str: """Create a detailed string representation of the Blob object :return: detailed string representation :rtype: str The string representation includes all the attributes of the Blob object. """ l = [f"{key}: {value}" for key, value in self.__dict__.items()] return "\n".join(l)
[docs] class Blobs(UserList): # lgtm[py/missing-equals] _image = [] # keep image saved for each Blobs object def __init__( self, image: Any = None, kulpa: bool = True, binaryImage: bool = False, **kwargs: Any, ) -> None: """ Find blobs and compute their attributes :param image: image to use, defaults to None :type image: :class:`Image`, optional :param kulpa: apply Kulpa's correction factor to circularity, defaults to True :type kulpa: bool, optional :param binaryImage: if True, the input image is treated as a binary image for the purpose of moment calculation, otherwise greyscale moments are computed, defaults to False :type binaryImage: bool, optional Uses OpenCV functions ``findContours`` to find a hierarchy of regions represented by their contours, and ``boundingRect``, ``moments`` to compute moments, perimeters, centroids etc. A region is defined as a connected group of non-zero pixels, the particular values **do not matter**. This class behaves like a list and each element of the list is a blob represented by a :class:`Blob` instance .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> img = Image.Read('sharks.png') >>> blobs = img.blobs() >>> len(blobs) >>> blobs[0] >>> blobs.area The list can be indexed, sliced or used as an iterator in a for loop or comprehension, for example: .. code-block:: python for blob in blobs: # do a thing areas = [blob.area for blob in blobs] However the last line can also be written as: .. code-block:: python areas = blobs.area since all methods return a scalar if applied to a single blob: .. code-block:: python blobs[1].area or a list if applied to multiple blobs: .. code-block:: python blobs.area A blob has many attributes: Geometric attributes .. list-table:: :header-rows: 1 :widths: 30 70 * - Property - Description * - :meth:`area` - The area of the blob. * - :meth:`bbox` - The bounding box of the blob. * - :meth:`umin`, :meth:`umax` - The horizontal extent of the bounding box. * - :meth:`vmin`, :meth:`vmax` - The vertical extent of the bounding box. * - :meth:`touch` - True if the blob touches the border of the image. * - :meth:`fillfactor` - Ratio of blob area to bounding box area. * - :meth:`bboxarea` - Area of the bounding box. Moment attributes .. list-table:: :header-rows: 1 :widths: 30 70 * - Property - Description * - :meth:`u`, :meth:`v` - The centroid (center of mass) of the blob. * - :meth:`orientation` - Orientation of the equivalent ellipse. * - :meth:`a, b` - The equivalent ellipse radii. * - :meth:`aligned_box` - A rotated bounding box with sides parallel to the axes of the equivalent ellipse. * - :meth:`moments` - The moments of the blob including central and normalised values up to 3rd order. * - :meth:`humoments` - Seven Hu moment invariants (invariant to position, orientation and scale). Boundary and shape attributes .. list-table:: :header-rows: 1 :widths: 30 70 * - Property - Description * - :meth:`contour_point` - A point on the contour of the blob. * - :meth:`perimeter` - A 2xN array of points on the perimeter of the blob. * - :meth:`perimeter_length` - The perimeter length of the blob. * - :meth:`circularity` - The circularity of the blob. * - :meth:`perimeter_approx` - A polygonal approximation to the perimeter. * - :meth:`perimeter_hull` - The convex hull of the perimeter. * - :meth:`MEC` - The minimum enclosing circle. * - :meth:`MER` - The minimum enclosing rectangle. Hierarchy and region attributes .. list-table:: :header-rows: 1 :widths: 30 70 * - Property - Description * - :meth:`color` - The value of pixels within the blob. * - :meth:`children` - A list of references to child :class:`Blob` instances. * - :meth:`parent` - A reference to the parent :class:`Blob` instance, or None if no parent. * - :meth:`level` - The depth of the blob in the region tree. * - :meth:`dotfile` - Write a GraphViz dot file representing the blob hierarchy. .. note:: ``findContours`` can give surprising results for small images: - The perimeter length is computed between the mid points of the pixels, and the OpenCV function ``arcLength`` seems to underestimate the perimeter even more. The perimeter length is not the same as the number of pixels in the contour. - The area will be less than the number of pixels in the blob, because the area is computed from the moments of the blob, which are computed from the contour, see above. :references: - |RVC3|, Section 12.1.2.1. :seealso: :meth:`filter` :meth:`sort` `opencv.moments <https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html#ga556a180f43cab22649c23ada36a8a139>`_, `opencv.boundingRect <https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html#ga103fcbda2f540f3ef1c042d6a9b35ac7>`_, `opencv.findContours <https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html#gadf1ad6a0b82947fa1fe3c3d497f260e0>`_ """ super().__init__(self) if image is None: # initialise empty Blobs # Blobs() return self._image = image # keep reference to original image image = image.mono() # get all the contours contours, hierarchy = cv2.findContours( image=(image._A > 0).astype(np.uint8), mode=cv2.RETR_TREE, method=cv2.CHAIN_APPROX_NONE, ) if len(contours) == 0: return # for N blobs, each with a perimeter of P_i points (i=0...N-1) # - contours is a tuple of N ndarrays of shape (P_i,1,2) # - hierarchy is a (1,N,4) array self._contours_raw = contours # save original contours from OpenCV # change contours to list of 2xN array contours = [c[:, 0, :] for c in contours] self._hierarchy_raw = hierarchy # save original hierarchy from OpenCV # change hierarchy from a (1,N,4) to (N,4) # the elements of each row are: # 0: index of next contour at same level, # 1: index of previous contour at same level, # 2: index of first child, # 3: index of parent hierarchy = hierarchy[0, :, :] # drop the first singleton dimension parents = hierarchy[:, 3] ## Collect blob data in first pass, then instantiate with all fields runts = 0 blob_params_list = [] # List of dicts, will be used to instantiate Blobs for i, (contour, hier) in enumerate(zip(contours, hierarchy)): ## First pass: compute basic geometry and moments blob_id = i ## bounding box: umin, vmin, width, height u1, v1, w, h = cv2.boundingRect(array=contour) u2 = u1 + w - 1 v2 = v1 + h - 1 bbox = np.r_[u1, u2, v1, v2] touch = u1 == 0 or v1 == 0 or u2 == image.umax or v2 == image.vmax ## children: gets list of children for each contour based on hierarchy parent_idx = hier[3] children = [] child = hier[2] while child != -1: children.append(child) child = hierarchy[child, 0] pp = contour[0, :] color = image._A[pp[1], pp[0]] ## perimeter, the contour is not closed perimeter = contour.T perimeter_length = cv2.arcLength(curve=contour, closed=False) contourpoint = contour.T[:, 0] ## moments: get moments as a dictionary for each contour moments = cv2.moments(array=contour, binaryImage=binaryImage) ## For a single set pixel OpenCV returns all moments as zero, let's fix it if moments["m00"] == 0: runts += 1 # Raw moments for uv in contour: for p, q in [ (0, 0), (1, 0), (0, 1), (2, 0), (1, 1), (0, 2), (3, 0), (2, 1), (1, 2), (0, 3), ]: u = uv[0] v = uv[1] moments[f"m{p}{q}"] += u**p * v**q # Central moments moments["mu10"] = moments["mu01"] = 0 moments["mu20"] = moments["m20"] - moments["m10"] ** 2 / moments["m00"] moments["mu02"] = moments["m02"] - moments["m01"] ** 2 / moments["m00"] moments["mu11"] = ( moments["m11"] - moments["m10"] * moments["m01"] / moments["m00"] ) # Third-order central moments moments["mu30"] = ( moments["m30"] - 3 * moments["m20"] * moments["m10"] / moments["m00"] + 2 * (moments["m10"] ** 3) / (moments["m00"] ** 2) ) moments["mu03"] = ( moments["m03"] - 3 * moments["m02"] * moments["m01"] / moments["m00"] + 2 * (moments["m01"] ** 3) / (moments["m00"] ** 2) ) moments["mu21"] = ( moments["m21"] - 2 * moments["m11"] * moments["m10"] / moments["m00"] - moments["m20"] * moments["m01"] / moments["m00"] + 2 * (moments["m10"] ** 2 * moments["m01"]) / (moments["m00"] ** 2) ) moments["mu12"] = ( moments["m12"] - 2 * moments["m11"] * moments["m01"] / moments["m00"] - moments["m02"] * moments["m10"] / moments["m00"] + 2 * (moments["m01"] ** 2 * moments["m10"]) / (moments["m00"] ** 2) ) # Normalised central moments moments["nu20"] = moments["mu20"] / (moments["m00"] ** (1 + (2 / 2))) moments["nu02"] = moments["mu02"] / (moments["m00"] ** (1 + (2 / 2))) moments["nu11"] = moments["mu11"] / (moments["m00"] ** (1 + (2 / 2))) # Third-order normalised moments moments["nu30"] = moments["mu30"] / (moments["m00"] ** (1 + (3 / 2))) moments["nu03"] = moments["mu03"] / (moments["m00"] ** (1 + (3 / 2))) moments["nu21"] = moments["mu21"] / (moments["m00"] ** (1 + (3 / 2))) moments["nu12"] = moments["mu12"] / (moments["m00"] ** (1 + (3 / 2))) # Initialize with basic geometry; derived fields will be added in later passes blob_params = { "id": blob_id, "bbox": bbox, "touch": touch, "perimeter": perimeter, "perimeter_length": perimeter_length, "contourpoint": contourpoint, "color": color, "moments": moments, "parent": parent_idx, # Keep as index; will convert to Blob reference later "children": children, # Keep as indices; will convert to Blob references later } blob_params_list.append(blob_params) ## second pass: equivalent ellipse for blob_params, child_indices in zip( blob_params_list, [bp["children"] for bp in blob_params_list] ): ## Moment hierarchy: subtract moments of children from parent M = blob_params["moments"] for child_idx in child_indices: # subtract moments of the child M = { key: M[key] - blob_params_list[child_idx]["moments"][key] for key in M } blob_params["moments"] = M ## Centroid if M["m00"] == 0: blob_params["uc"] = 0.0 blob_params["vc"] = 0.0 blob_params["a"] = 0.0 blob_params["b"] = 0.0 blob_params["orientation"] = 0.0 blob_params["circularity"] = None else: blob_params["uc"] = M["m10"] / M["m00"] blob_params["vc"] = M["m01"] / M["m00"] ## Equivalent ellipse J = np.array([[M["mu20"], M["mu11"]], [M["mu11"], M["mu02"]]]) e, X = np.linalg.eig(J) blob_params["a"] = 2.0 * np.sqrt(e.max() / M["m00"]) blob_params["b"] = 2.0 * np.sqrt(e.min() / M["m00"]) # Find eigenvector for largest eigenvalue k = np.argmax(e) x = X[:, k] blob_params["orientation"] = np.arctan2(x[1], x[0]) ## Circularity: apply Kulpa's correction factor # apply Kulpa's correction factor when computing circularity # should have max 1 circularity for circle, < 1 for non-circles # * Area and perimeter measurement of blobs in discrete binary pictures. # Z.Kulpa. Comput. Graph. Image Process., 6:434-451, 1977. # * Methods to Estimate Areas and Perimeters of Blob-like Objects: a # Comparison. Proc. IAPR Workshop on Machine Vision Applications., # December 13-15, 1994, Kawasaki, Japan # L. Yang, F. Albregtsen, T. Loennestad, P. Groettum if kulpa is True: kfactor = np.pi / 8.0 * (1.0 + np.sqrt(2.0)) elif isinstance(kulpa, (int, float)): kfactor = kulpa else: kfactor = 1.0 if blob_params["perimeter_length"] > 0: blob_params["circularity"] = ( 4.0 * np.pi * M["m00"] / (blob_params["perimeter_length"] * kfactor) ** 2 ) else: blob_params["circularity"] = None # Convert moments dict to named tuple for easier access M = blob_params["moments"] blob_params["moments"] = namedtuple("moment_tuple", M.keys())(*M.values()) ## third pass, region tree coloring and creating Blob objects # level 0 is a parent, level > 0 is a child levels: list[int | None] = [None] * len(blob_params_list) while any([level is None for level in levels]): # while some uncolored for idx, blob_params in enumerate(blob_params_list): if levels[idx] is None: if blob_params["parent"] == -1: levels[idx] = 0 # root level elif levels[blob_params["parent"]] is not None: # one higher than parent's depth levels[idx] = levels[blob_params["parent"]] + 1 # Create Blob objects from blob_params_list allblobs = [] for idx, blob_params in enumerate(blob_params_list): blob_params["level"] = levels[idx] blob = Blob(**blob_params) allblobs.append(blob) self.filter(**kwargs) for blob in allblobs: if blob.parent != -1: blob.parent = allblobs[blob.parent] else: blob.parent = None if len(blob.children) > 0: blob.children = [allblobs[i] for i in blob.children] ## if runts > 0: print(f"blobs: found {runts} runt blob{'s' if runts > 1 else ''}") self.data = allblobs return def filter( self, area: Any = None, circularity: Any = None, color: bool | None = None, touch: bool | None = None, aspect: Any = None, ) -> "Blobs": """ Filter blobs :param area: area minimum or range, defaults to None :type area: scalar or array_like(2), optional :param circularity: circularity minimum or range, defaults to None :type circularity: scalar or array_like(2), optional :param color: color/polarity to accept, defaults to None :type color: bool, optional :param touch: blob touch status to accept, defaults to None :type touch: bool, optional :param aspect: aspect ratio minimum or range, defaults to None :type aspect: scalar or array_like(2), optional :return: set of filtered blobs :rtype: :class:`Blobs` Return a set of blobs that match the filter criteria. ================= ========================================= Parameter Description ================= ========================================= ``"area"`` Blob area ``"circularity"`` Blob circularity ``"aspect"`` Aspect ratio of equivalent ellipse ``"touch"`` Blob edge touch status ================= ========================================= The filter parameter arguments are: - a scalar, representing the minimum acceptable value - a array_like(2), representing minimum and maximum acceptable value Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> img = Image.Read('sharks.png') >>> blobs = img.blobs() >>> blobs >>> blobs.filter(area=10_000) >>> blobs.filter(area=10_000, circularity=0.3) .. warning:: Filtering can destroy the hierarchy of the blobs, deleting parents and children in the blob tree. A blob may have references to parents and children that are not in the filtered set. :references: - |RVC3|, Section 12.1.2.1. :seealso: :meth:`sort` """ mask = [] if area is not None: _area = self.area if isscalar(area): mask.append(_area >= area) elif len(area) == 2: mask.append(_area >= area[0]) mask.append(_area <= area[1]) if circularity is not None: _circularity = self.circularity if isscalar(circularity): mask.append(_circularity >= circularity) elif len(circularity) == 2: mask.append(_circularity >= circularity[0]) mask.append(_circularity <= circularity[1]) if aspect is not None: _aspect = self.aspect if isscalar(aspect): mask.append(_aspect >= aspect) elif len(circularity) == 2: mask.append(_aspect >= aspect[0]) mask.append(_aspect <= aspect[1]) if color is not None: _color = self.color mask.append(_color == color) if touch is not None: _touch = self.touch mask.append(_touch == touch) m = np.array(mask).all(axis=0) return self[m] # type: ignore[arg-type] def sort(self, by: str = "area", reverse: bool = False) -> "Blobs": # type: ignore[override] """ Sort blobs :param by: parameter to sort on, defaults to "area" :type by: str, optional :param reverse: sort in ascending order, defaults to False :type reverse: bool, optional :return: set of sorted blobs :rtype: :class:`Blobs` Return a blobs object where the blobs are sorted according to the sort parameter: ================= ========================================= Parameter Description ================= ========================================= ``"area"`` Blob area ``"circularity"`` Blob circularity ``"perimeter"`` Blob external perimeter length ``"aspect"`` Aspect ratio of equivalent ellipse ``"touch"`` Blob edge touch status ================= ========================================= Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> img = Image.Read('sharks.png') >>> blobs = img.blobs() >>> blobs.sort() :references: - |RVC3|, Section 12.1.2.1. :seealso: :meth:`filter` """ k: np.ndarray if by == "area": k = np.argsort(self.area) elif by == "circularity": k = np.argsort(self.circularity) elif by == "perimeter": k = np.argsort(self.perimeter_length) elif by == "aspect": k = np.argsort(self.aspect) elif by == "touch": k = np.argsort(self.touch) else: raise ValueError(f"unknown sort key: {by!r}") if reverse: k = k[::-1] return self[k] def __getitem__( # type: ignore[override] self, i: int | slice | list[int] | tuple[int, ...] | np.ndarray ) -> "Blobs": new = Blobs() new._image = self._image if isinstance(i, (int, slice)): data = self.data[i] if not isinstance(data, list): data = [data] new.data = data elif isinstance(i, (list, tuple)): new.data = [self.data[k] for k in i] elif isinstance(i, np.ndarray): # numpy thing if np.issubdtype(i.dtype, np.integer): new.data = [self.data[k] for k in i] elif np.issubdtype(i.dtype, bool) and len(i) == len(self): new.data = [self.data[k] for k in range(len(i)) if i[k]] return new def __repr__(self) -> str: return f"Blobs(nblobs={len(self.data)})" def __str__(self) -> str: # s = "" for i, blob in enumerate(self): s += f"{i}: # area={blob.area:.1f} @ ({blob.uc:.1f}, {blob.vc:.1f}), # touch={blob.touch}, orient={blob.orientation * 180 / np.pi:.1f}°, # aspect={blob.aspect:.2f}, circularity={blob.circularity:.2f}, # parent={blob._parent}\n" # return s table = ANSITable( Column("id"), Column("parent"), Column("centroid"), Column("area", fmt="{:.3g}"), Column("touch"), Column("perim", fmt="{:.1f}"), Column("circul", fmt="{:.3f}"), Column("orient", fmt="{:.1f}°"), Column("aspect", fmt="{:.3g}"), border="thin", ) for b in self.data: table.row( b.id, b.parent.id if b.parent else -1, f"{b.uc:.1f}, {b.vc:.1f}", b.moments.m00, b.touch, b.perimeter_length, b.circularity, np.rad2deg(b.orientation), b.b / b.a, ) return str(table) @property @scalar_result def area(self) -> Any: """ Area of the blob :return: area in pixels :rtype: int Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].area >>> blobs.area """ return [b.moments.m00 for b in self.data] @property @scalar_result def u(self) -> Any: """ u-coordinate of the blob centroid :return: u-coordinate (horizontal) :rtype: float Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].u >>> blobs.u :seealso: :meth:`v` :meth:`centroid` """ return [b.uc for b in self.data] @property @scalar_result def v(self) -> Any: """ v-coordinate of the blob centroid :return: v-coordinate (vertical) :rtype: float Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].v >>> blobs.v :seealso: :meth:`u` :meth:`centroid` """ return [b.vc for b in self.data] @property @array_result def centroid(self) -> Any: """ Centroid of blob :return: centroid of the blob :rtype: 2-tuple Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].bboxarea >>> blobs.bboxarea :seealso: :meth:`u` :meth:`v` :meth:`moments` """ return [(b.uc, b.vc) for b in self.data] @property @array_result def p(self) -> Any: """ Centroid point of blob :return: centroid of the blob :rtype: 2-tuple Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].bboxarea >>> blobs.bboxarea :seealso: :meth:`u` :meth:`v` """ return [(b.uc, b.vc) for b in self.data] @property @array_result def bbox(self) -> Any: """ Bounding box :return: bounding :rtype: ndarray(4) The axis-aligned bounding box is a 1D array [umin, umax, vmin, vmax]. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].bbox >>> blobs.bbox .. note:: The bounding box is the smallest box with vertical and horizontal edges that fully encloses the blob. :seealso: :meth:`umin` :meth:`vmin` :meth:`umax` :meth:`umax`, """ return [b.bbox for b in self.data] @property @scalar_result def umin(self) -> Any: """ Minimum u-axis extent :return: maximum u-coordinate of the blob :rtype: int Returns the u-coordinate of the left side of the bounding box. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].umin >>> blobs.umin :seealso: :meth:`umax` :meth:`bbox` """ return [b.bbox[0] for b in self.data] @property @scalar_result def umax(self) -> Any: """ Maximum u-axis extent :return: maximum u-coordinate of the blob :rtype: int Returns the u-coordinate of the right side of the bounding box. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].umin >>> blobs.umin :seealso: :meth:`umin` :meth:`bbox` """ return [b.bbox[0] + b.bbox[2] for b in self.data] @property @scalar_result def vmin(self) -> Any: """ Maximum v-axis extent :return: maximum v-coordinate of the blob :rtype: int Returns the v-coordinate of the top side of the bounding box. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].vmin >>> blobs.vmin :seealso: :meth:`vmax` :meth:`bbox` """ return [b.bbox[0] for b in self.data] @property @scalar_result def vmax(self) -> Any: """ Minimum v-axis extent :return: maximum v-coordinate of the blob :rtype: int Returns the v-coordinate of the bottom side of the bounding box. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].vmax >>> blobs.vmax :seealso: :meth:`vmin` :meth:`bbox` """ return [b.bbox[1] + b.bbox[3] for b in self.data] @property @scalar_result def bboxarea(self) -> Any: """ Area of the bounding box :return: area of the bounding box in pixels :rtype: int Return the area of the bounding box which is invariant to blob position. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].bboxarea >>> blobs.bboxarea .. note:: The bounding box is the smallest box with vertical and horizontal edges that fully encloses the blob. :seealso: :meth:`bbox` :meth:`area` :meth:`fillfactor` """ return [b.bbox[2] * b.bbox[3] for b in self.data] @property @scalar_result def fillfactor(self) -> Any: r""" Fill factor, ratio of area to bounding box area :return: fill factor :rtype: int Return the ratio, :math:`\le 1`, of the blob area to the area of the bounding box. This is a simple shape metric which is invariant to blob position and scale. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].fillfactor >>> blobs.fillfactor .. note:: The bounding box is the smallest box with vertical and horizontal edges that fully encloses the blob. :seealso: :meth:`bbox` """ return [b.moments.m00 / (b.bbox[2] * b.bbox[3]) for b in self.data] @property @scalar_result def a(self) -> Any: """ Radius of equivalent ellipse :return: largest ellipse radius :rtype: float Returns the major axis length which is invariant to blob position and orientation. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].a >>> blobs.a :seealso: :meth:`b` :meth:`aspect` """ return [b.a for b in self.data] @property @scalar_result def b(self) -> Any: """ Radius of equivalent ellipse :return: smallest ellipse radius :rtype: float Returns the minor axis length which is invariant to blob position and orientation. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].b >>> blobs.b :seealso: :meth:`a` :meth:`aspect` """ return [b.b for b in self.data] @property @scalar_result def aspect(self) -> Any: r""" Blob aspect ratio :return: ratio of equivalent ellipse axes :rtype: float Returns the ratio of equivalent ellipse axis lengths, :math:`<1`, which is invariant to blob position, orientation and scale. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].aspect >>> blobs.aspect :seealso: :func:`a` :meth:`b` """ return [b.b / b.a for b in self.data] @property @scalar_result def orientation(self) -> Any: """ Blob orientation :return: Orientation of equivalent ellipse (in radians) :rtype: float Returns the orientation of equivalent ellipse major axis with respect to the horizontal axis, which is invariant to blob position and scale. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].orientation >>> blobs.orientation """ return [b.orientation for b in self.data] @property @scalar_result def touch(self) -> Any: """ Blob edge touch status :return: blob touches the edge of the image :rtype: bool Returns true if the blob touches the edge of the image. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].touch >>> blobs.touch """ return [b.touch for b in self.data] @property @scalar_result def level(self) -> Any: """ Blob level in hierarchy :return: blob level in hierarchy :rtype: int Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('multiblobs.png') >>> blobs = im.blobs() >>> blobs[2].level >>> blobs.level :seealso: :meth:`color` :meth:`parent` :meth:`children` :meth:`dotfile` """ return [b.level for b in self.data] @property @scalar_result def color(self) -> Any: """ Blob color :return: blob color :rtype: int Blob color in a binary image. This is inferred from the level in the blob hierarchy. The background blob is black (0), the first-level child blobs are white (1), etc. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('multiblobs.png') >>> blobs = im.blobs() >>> blobs[2].color >>> blobs.color :seealso: :meth:`level` :meth:`parent` :meth:`children` """ return [b.level & 1 for b in self.data] @property @scalar_result def parent(self) -> Any: """ Parent blob :return: index of this blob's parent :rtype: int Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('multiblobs.png') >>> blobs = im.blobs() >>> print(blobs) >>> blobs[5].parent >>> blobs[6].parent A parent of -1 is the image background. :seealso: :meth:`id` :meth:`children` :meth:`level` :meth:`dotfile` """ return [b.parent.id if b.parent is not None else -1 for b in self.data] @property @scalar_result def id(self) -> Any: """ Blob id number :return: index of this blob :rtype: int Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('multiblobs.png') >>> blobs = im.blobs() >>> print(blobs) >>> blobs[5].id >>> blobs[6].id :seealso: :meth:`parent` :meth:`children` :meth:`level` :meth:`dotfile` """ return [b.id for b in self.data] @property @array_result def children(self) -> Any: """ Child blobs :return: list of indices of this blob's children :rtype: list of int Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('multiblobs.png') >>> blobs = im.blobs() >>> blobs[5].children :seealso: :meth:`parent` :meth:`level` :meth:`dotfile` """ return [[c.id for c in b.children] for b in self.data] @property @array_result def moments(self) -> Any: """ Moments of blobs :return: moments of blobs :rtype: named tuple or list of named tuples Compute multiple moments of each blob and return them as a named tuple with attributes ========================== =============================================================================== Moment type attribute name ========================== =============================================================================== 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 >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].moments.m00 >>> blobs[0].moments.m10 :seealso: :meth:`centroid` :meth:`humoments` """ return [b.moments for b in self.data] @array_result def humoments(self) -> Any: """ Hu image moment invariants of blobs :return: Hu image moments :rtype: ndarray(7) or ndarray(N,7) Computes the seven Hu image moment invariants of the image. These are a robust shape descriptor that is invariant to position, orientation and scale. Example: .. runblock:: pycon :precision: 4 >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].humoments() :seealso: :meth:`moments` """ def hu(b: Any) -> np.ndarray: m = b.moments phi = np.empty((7,)) phi[0] = m.nu20 + m.nu02 phi[1] = (m.nu20 - m.nu02) ** 2 + 4 * m.nu11**2 phi[2] = (m.nu30 - 3 * m.nu12) ** 2 + (3 * m.nu21 - m.nu03) ** 2 phi[3] = (m.nu30 + m.nu12) ** 2 + (m.nu21 + m.nu03) ** 2 phi[4] = (m.nu30 - 3 * m.nu12) * (m.nu30 + m.nu12) * ( (m.nu30 + m.nu12) ** 2 - 3 * (m.nu21 + m.nu03) ** 2 ) + (3 * m.nu21 - m.nu03) * (m.nu21 + m.nu03) * ( 3 * (m.nu30 + m.nu12) ** 2 - (m.nu21 + m.nu03) ** 2 ) phi[5] = (m.nu20 - m.nu02) * ( (m.nu30 + m.nu12) ** 2 - (m.nu21 + m.nu03) ** 2 ) + 4 * m.nu11 * (m.nu30 + m.nu12) * (m.nu21 + m.nu03) phi[6] = (3 * m.nu21 - m.nu03) * (m.nu30 + m.nu12) * ( (m.nu30 + m.nu12) ** 2 - 3 * (m.nu21 + m.nu03) ** 2 ) + (3 * m.nu12 - m.nu30) * (m.nu21 + m.nu03) * ( 3 * (m.nu30 + m.nu12) ** 2 - (m.nu21 + m.nu03) ** 2 ) return phi return np.array([hu(b) for b in self.data]) @property @scalar_result def perimeter_length(self) -> Any: """ Perimeter length of the blob :return: perimeter length in pixels :rtype: float Return the length of the blob's external perimeter. This is an 8-way connected chain of edge pixels. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> import numpy as np >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].perimeter_length >>> blobs.perimeter_length .. note:: The length of the internal perimeter is found from summing the external perimeter of each child blob. :seealso: :meth:`perimeter` :meth:`children` """ return [b.perimeter_length for b in self.data] @property @scalar_result def circularity(self) -> Any: r""" Blob circularity :return: circularity :rtype: float Circularity, computed as :math:`\rho = \frac{A}{4 \pi p^2} \le 1`. Circularity is one for a circular blob and < 1 for all other shapes, approaching zero for a line. For a single pixel blob, with zero ``perimeter_length`` it is ``None``. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].circularity >>> blobs.circularity .. note:: Kulpa's correction factor is applied to account for edge discretization: - Area and perimeter measurement of blobs in discrete binary pictures. Z.Kulpa. Comput. Graph. Image Process., 6:434-451, 1977. - Methods to Estimate Areas and Perimeters of Blob-like Objects: a Comparison. Proc. IAPR Workshop on Machine Vision Applications., December 13-15, 1994, Kawasaki, Japan L. Yang, F. Albregtsen, T. Loennestad, P. Groettum :seealso: :meth:`area` :meth:`perimeter_length` """ return [b.circularity for b in self.data] @property @array_result def perimeter(self) -> Any: """ Perimeter of the blob :return: Perimeter, one point per column :rtype: ndarray(2,N) Return the coordinates of the pixels that form the blob's external perimeter. This is an 8-way connected chain of edge pixels. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].perimeter.shape >>> np.set_printoptions(threshold=10) >>> blobs[0].perimeter >>> blobs.perimeter .. plot:: from machinevisiontoolbox import Image im = Image.Read("shark2.png") im.disp(darken=True) blobs = im.blobs() for blob in blobs: plt.plot(blob.perimeter[0], blob.perimeter[1], 'y-', linewidth=2) .. note:: The perimeter is not closed, that is, the first and last point are not the same. :seealso: :meth:`perimeter_approx` :meth:`perimeter_hull` :meth:`plot_perimeter` :meth:`polar` """ return [b.perimeter for b in self.data] @array_result def perimeter_approx(self, epsilon: int | None = None) -> Any: """ Approximate perimeter of blob :param epsilon: maximum distance between the original curve and its approximation, default is exact contour :type epsilon: int :return: Perimeter, one point per column :rtype: ndarray(2,N) or list of ndarray(2,N) The result is a low-order polygonal approximation to the original perimeter. Increasing ``epsilon`` reduces the number of perimeter points. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].perimeter.shape >>> blobs[0].perimeter_approx(5).shape >>> np.set_printoptions(threshold=10) >>> blobs[0].perimeter_approx(5) which in this case has reduced the number of perimeter points from 471 to 15. To compute parameters of the area enclosed by the approximated perimeter we can first convert it to a :class:`~spatialmath.geom2d.Polygon2` object: .. runblock:: pycon :exclude: 1-3 >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> from spatialmath import Polygon2 >>> poly = Polygon2(blobs[0].perimeter_approx(5), close=True) >>> poly.area() >>> poly.moment(1, 0) # first moment .. plot:: from machinevisiontoolbox import Image im = Image.Read("shark2.png") im.disp(darken=True) blobs = im.blobs() for blob in blobs: perim = blob.perimeter_approx(5) plt.plot(perim[0], perim[1], 'y.-') .. note:: The perimeter is not closed, that is, the first and last point are not the same. :seealso: :meth:`plot_perimeter` :meth:`perimeter` :meth:`perimeter_hull` :meth:`polar` `cv2.approxPolyDP <https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html#ga0012a5fdaea70b8a9970165d98722b4c>`_ """ perimeters = [] for b in self.data: assert epsilon is not None perimeter = cv2.approxPolyDP( curve=np.ascontiguousarray(b.perimeter.T), epsilon=float(epsilon), closed=False, ) # result is Nx1x2 perimeters.append(np.squeeze(perimeter).T) return perimeters @array_result def perimeter_hull(self, clockwise: bool = True) -> Any: """ Convex hull of blob's perimeter :param clockwise: direction of travel for computing the hull, defaults to clockwise :type clockwise: bool :return: Perimeter, one point per column :rtype: ndarray(2,N) or list of ndarray(2,N) The result is a convex perimeter that minimally contains the blob. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs[0].perimeter.shape >>> blobs[0].perimeter_hull(5).shape >>> np.set_printoptions(threshold=10) >>> blobs[0].perimeter_hull() To compute parameters of the area enclosed by the convex hull we can first convert it to a :class:`~spatialmath.geom2d.Polygon2` object: .. runblock:: pycon :exclude: 1-3 >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> from spatialmath import Polygon2 >>> poly = Polygon2(blobs[0].perimeter_hull(), close=True) >>> poly.area() >>> poly.moment(1, 0) # first moment .. plot:: from machinevisiontoolbox import Image im = Image.Read("shark2.png") im.disp(darken=True) blobs = im.blobs() for blob in blobs: perim = blob.perimeter_hull() plt.plot(perim[0], perim[1], 'y.-') .. note:: The perimeter is not closed, that is, the first and last point are not the same. :seealso: :meth:`plot_perimeter` :meth:`perimeter` :meth:`perimeter_approx` :meth:`perimeter_approx` :meth:`polar` `cv2.convexHull <https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html#ga014b28e56cb8854c0de4a211cb2be656>`_ """ perimeters = [] for b in self.data: perimeter = cv2.convexHull( points=self.perimeter.T, returnPoints=True, clockwise=clockwise ) perimeters.append(np.squeeze(perimeter).T) return perimeters @property @array_result def MEC(self) -> Any: """ Minimum enclosing circle of blob :return: Parameters of the minimum enclosing circle :rtype: ndarray(3) or list of ndarray(3) The minimum enclosing circle is the smallest circle that can fully contain the blob. It touches the blob at at least three points. Each circle is specified by a 3-tuple (uc, vc, r). Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs.MEC .. plot:: from machinevisiontoolbox import Image im = Image.Read("shark2.png") im.disp(darken=True) blobs = im.blobs() blobs.plot_MEC('y') :seealso: :meth:`plot_MEC` :meth:`MER` `cv2.minEnclosingCircle <https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html#ga8ce13c24081bbc7151e9326f412190f1>`_ """ mecs = [] for b in self.data: uv, r = cv2.minEnclosingCircle(b.perimeter.T) mecs.append(np.array([*uv, r])) return mecs @property @array_result def MER(self) -> Any: """ Minimum enclosing rectangle of blob :return: Parameters of the minimum enclosing rectangle :rtype: ndarray(5) or list of ndarray(5) The minimum enclosing rectangle is the smallest rectangle that can fully contain the blob. It is specified by a 5-tuple (uc, vc, w, h, theta) where (uc, vc) is the center, (w, h) is the width and height, and theta is the orientation. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs.MER .. plot:: from machinevisiontoolbox import Image im = Image.Read("shark2.png") im.disp(darken=True) blobs = im.blobs() blobs.plot_MER('y') :seealso: :meth:`plot_MER` :meth:`MEC` `cv2.minAreaRect <https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html#ga3d476a3417130ae5154aea421ca7ead9>`_ """ mers = [] for b in self.data: uv, wh, theta = cv2.minAreaRect(b.perimeter.T) mers.append(np.array([*uv, *wh, theta])) return mers @array_result def polar(self, N: int = 400) -> Any: r""" Boundary in polar coordinate form :param N: number of points around perimeter, defaults to 400 :type N: int, optional :return: Contour, one point per column :rtype: ndarray(2,N) Returns a polar representation of the boundary with respect to the centroid. Each boundary point is represented by a column :math:`(r, \theta)`. The polar profile can be used for scale and orientation invariant matching of shapes. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> p = blobs[0].polar() >>> p.shape .. note:: The points are evenly spaced around the perimeter but are not evenly spaced in subtended angle. :seealso: :meth:`polarmatch` :meth:`perimeter` """ def polarfunc(b: Any) -> tuple[np.ndarray, np.ndarray]: contour = np.array(b.perimeter) - np.c_[b.p].T r = np.sqrt(np.sum(contour**2, axis=0)) theta = -np.arctan2(contour[1, :], contour[0, :]) s = np.linspace(0, 1, len(r)) si = np.linspace(0, 1, N) f_r = sp.interpolate.interp1d(s, r) f_theta = sp.interpolate.interp1d(s, theta) return f_r(si), f_theta(si) return [polarfunc(b) for b in self] def polarmatch(self, target: int) -> tuple[list[float], np.ndarray]: r""" Compare polar profiles :param target: the blob index to match against :type target: int :return: similarity and orientation offset :rtype: ndarray(N), ndarray(N) Performs cross correlation between the polar profiles of blobs. All blobs are matched against blob index ``target``. Blob index ``target`` is included in the results. There are two return values: 1. Similarity is a 1D array, one entry per blob, where a value of one indicates maximum similarity irrespective of orientation and scale. 2. Orientation offset is a 1D array, one entry per blob, is the relative orientation of blobs with respect to the ``target`` blob. The ``target`` blob has an orientation offset of 0.5. These values lie in the range [0, 1), equivalent to :math:`[0, 2\pi)` and wraps around. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> blobs.polarmatch(1) .. note:: - Can be considered as matching two functions defined over :math:`S^1`. - Orientation is obtained by cross-correlation of the polar-angle profile. :seealso: :meth:`polar` :meth:`contour` """ # assert(numrows(r1) == numrows(r2), 'r1 and r2 must have same number of rows'); R = [] for i in range(len(self)): # get the radius profile r = self[i].polar()[0, :] # normalize to zero mean and unit variance r -= r.mean() r /= np.std(r) R.append(r) R = np.array(R) # on row per blob boundary n = R.shape[1] # get the target profile target_profile = R[target, :] # cross correlate, with wrapping out = sp.ndimage.correlate1d(R, target_profile, axis=1, mode="wrap") / n idx = np.argmax(out, axis=1) return [out[k, idx[k]] for k in range(len(self))], idx / n def plot_box(self, **kwargs: Any) -> None: """ Plot a bounding box for the blob using Matplotlib :param kwargs: arguments passed to ``plot_box`` Plot the bounding box of a blob or blobs on the current Matplotlib axes. Example: >>> from machinevisiontoolbox import Image >>> im = Image.Read("sharks.png") >>> blobs = im.blobs() >>> blobs[:3].plot_box(color="g") >>> blobs[3].plot_box(color="r", linewidth=4) .. plot:: from machinevisiontoolbox import Image im = Image.Read("sharks.png") im.disp() blobs = im.blobs() blobs[:3].plot_box(color='g') blobs[3].plot_box(color='r', linewidth=4) :seealso: :meth:`plot_labelbox` :meth:`plot_centroid` :meth:`plot_perimeter` :func:`~machinevisiontoolbox.base.graphics.plot_box` """ for blob in self: plot_box(lrbt=blob.bbox, **kwargs) def plot_labelbox(self, label: str | None = None, **kwargs: Any) -> None: """ Plot a labelled bounding box of blobs using Matplotlib :param label: label to be displayed on the bounding box, defaults to blob id :type label: str, optional :param kwargs: arguments passed to ``plot_labelbox`` Plot a labelled bounding box for every blob described by this object. By default, blobs are labeled by their blob id. Example: >>> from machinevisiontoolbox import Image >>> im = Image.Read("sharks.png") >>> blobs = im.blobs() >>> blobs[:3].plot_labelbox(color="yellow") >>> blobs[3].plot_labelbox(color="lightblue", linewidth=2, label="3") .. plot:: from machinevisiontoolbox import Image im = Image.Read("sharks.png") im.disp() blobs = im.blobs() blobs[:3].plot_labelbox(color="yellow") blobs[3].plot_labelbox(color="lightblue", linewidth=2, label="3") :seealso: :meth:`plot_box` :meth:`plot_centroid` :meth:`plot_perimeter` :func:`~machinevisiontoolbox.base.graphics.plot_labelbox` """ for blob in self: if label is None: text = f"{blob.id}" else: text = label plot_labelbox(text=text, lrbt=blob.bbox, **kwargs) def plot_centroid(self, label: bool = False, **kwargs: Any) -> None: """ Plot the centroid of blobs using Matplotlib :param label: add a sequential numeric label to each point, defaults to False :type label: bool :param kwargs: other arguments passed to ``plot_point`` Plot the major and minor axes of a blob or blobs on the current Matplotlib axes. If no marker style is given then it will be an overlaid "o" and "x" in blue. Example: >>> from machinevisiontoolbox import Image >>> im = Image.Read("sharks.png") >>> blobs = im.blobs() >>> blobs[:3].plot_centroid() >>> blobs[3].plot_centroid(marker="P", markeredgecolor="lightsteelblue", markerfacecolor="w", fillstyle="full") .. plot:: from machinevisiontoolbox import Image im = Image.Read("sharks.png") im.disp() blobs = im.blobs() blobs[:3].plot_centroid() blobs[3].plot_centroid(marker="P", markeredgecolor="red", markerfacecolor="w", fillstyle="full") :seealso: :meth:`plot_box` :meth:`plot_perimeter` :func:`~machinevisiontoolbox.base.graphics.plot_point` """ if label: text = "{:d}" else: text = "" if "marker" not in kwargs: kwargs["marker"] = ["bx", "bo"] kwargs["fillstyle"] = "none" for i, blob in enumerate(self): plot_point(pos=blob.centroid, text=text.format(i), **kwargs) def plot_perimeter( self, show: str = "full", epsilon: int | None = None, clockwise: bool = True, **kwargs: Any, ) -> None: """ Plot the perimeter of blobs using Matplotlib :param show: type of perimeter to plot, "full" (default), "approx" or "hull" :type show: str :param epsilon: maximum distance between the original curve and its approximation, default is exact contour :type epsilon: int (only for ``show="approx"``) :param clockwise: direction of travel for computing the hull, defaults to clockwise :type clockwise: bool (only for ``show="hull"``) :param kwargs: line style parameters passed to ``plot`` Plots the perimeter of blob or blobs on the current Matplotlib axes. Example: >>> from machinevisiontoolbox import Image >>> im = Image.Read("sharks.png") >>> blobs = im.blobs() >>> blobs[:3].plot_perimeter(color="red") >>> blobs[3].plot_perimeter(show="hull", color="orange", linewidth=3) .. plot:: from machinevisiontoolbox import Image im = Image.Read("sharks.png") im.disp() blobs = im.blobs() blobs[:3].plot_perimeter(color="red") blobs[3].plot_perimeter(show="hull", color="orange", linewidth=3) :seealso: :meth:`perimeter` :meth:`perimeter_approx` :meth:`perimeter_hull` :meth:`plot_box` :meth:`plot_centroid` """ if show == "full": perims = self.perimeter elif show == "approx": perims = self.perimeter_approx(epsilon=epsilon) elif show == "hull": perims = self.perimeter_hull(clockwise=clockwise) else: raise ValueError("unknown perimeter type") if not isinstance(perims, list): perims = [perims] for perim in perims: plt.plot(perim[0], perim[1], **kwargs) def plot_ellipse(self, **kwargs: Any) -> None: """ Plot the equivalent ellipses of blobs using Matplotlib :param kwargs: line style parameters passed to ``plot`` Plots the equivalent ellipses of blob or blobs on the current Matplotlib axes. Example: >>> from machinevisiontoolbox import Image >>> im = Image.Read("sharks.png") >>> blobs = im.blobs() >>> blobs[:3].plot_ellipse(color="yellow") >>> blobs[3].plot_ellipse(color="green", linestyle="--", linewidth=3) .. plot:: from machinevisiontoolbox import Image im = Image.Read("sharks.png") im.disp() blobs = im.blobs() blobs[:3].plot_ellipse(color="yellow") blobs[3].plot_ellipse(color="green", linestyle="--", linewidth=3) :seealso: :meth:`plot_axes` :meth:`plot_box` :meth:`plot_centroid` :func:`~spatialmath.base.plot_ellipse` """ for blob in self: m = blob.moments # fmt: off J = np.array([ [m.mu20, m.mu11], [m.mu11, m.mu02]]) # fmt: on base.plot_ellipse( 4 * J / m.m00, centre=blob.centroid, inverted=True, **kwargs ) def blob_frame(self) -> SE2: """ Transformation from blob coordinate frame to image frame :return: Homogeneous transformation :rtype: :class:`~spatialmath.SE2` Returns the SE(2) transformation that maps point coordinates in the blob coordinate frame (origin at the centroid, x- and y-axes aligned with the major and minor ellipse axes) to their coordinate in the image coordinate frame. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('sharks.png') >>> blobs = im.blobs() >>> blobs.blob_frame() :seealso: :meth:`centroid` :meth:`orientation` """ frames = SE2.Empty() for blob in self: frames.append(SE2(blob.uc, blob.vc, blob.orientation)) return frames def plot_axes(self, **kwargs: Any) -> None: """ Plot equivalent ellipse axes of blobs using Matplotlib :param kwargs: line style parameters passed to ``plot`` Plot the major and minor axes of a blob or blobs on the current Matplotlib axes. These are the axes of the equivalent ellipse and the intersection point is the blob's centroid. Example: >>> from machinevisiontoolbox import Image >>> im = Image.Read("sharks.png") >>> blobs = im.blobs() >>> blobs[:3].plot_axes(color="blue") >>> blobs[3].plot_axes(color="green", linewidth=3) .. plot:: from machinevisiontoolbox import Image im = Image.Read("sharks.png") im.disp() blobs = im.blobs() blobs[:3].plot_axes(color="blue") blobs[3].plot_axes(color="green", linewidth=3) :seealso: :meth:`plot_ellipse` :meth:`plot_box` :meth:`plot_centroid` """ for blob in self: T = SE2(blob.uc, blob.vc, blob.orientation) # fmt: off a_axis = np.array( # major axis is parallel to x-axis [ [blob.a, -blob.a], [0, 0], ] ) b_axis = np.array( # minor axis is parallel to y-axis [ [0, 0], [blob.b, -blob.b], ] ) # fmt: on p = np.asarray(T * a_axis) plt.plot(p[0, :], p[1, :], **kwargs) p = np.asarray(T * b_axis) plt.plot(p[0, :], p[1, :], **kwargs) @array_result def aligned_box(self) -> Any: """ Compute rectangle aligned with ellipse axes for blobs :return: tuple of area, centroid, vertices of the aligned box :rtype: tuple or list of tuples Compute the minimal enclosing box whose sides are parallel to the axes of the equivalent ellipse. Return a list of vertices (not closed) and a list of box centroids. Example: .. runblock:: pycon :precision: 4 >>> from machinevisiontoolbox import Image >>> im = Image.Read("sharks.png") >>> blobs = im.blobs() >>> blobs[0].aligned_box() # downward shark .. plot:: from machinevisiontoolbox import Image im = Image.Read("sharks.png") im.disp() :seealso: :meth:`plot_aligned_box` :meth:`plot_axes` :meth:`plot_box` :meth:`plot_centroid` """ boxes = [] for blob in self: T = SE2(blob.uc, blob.vc, blob.orientation) # transform perimeter to centroid coordinate frame p = T.inv() * blob.perimeter xmin = p[0, :].min() xmax = p[0, :].max() ymin = p[1, :].min() ymax = p[1, :].max() v = np.array( [[xmin, ymin], [xmax, ymin], [xmax, ymax], [xmin, ymax], [xmin, ymin]] ).T boxes.append( ( (xmax - xmin) * (ymax - ymin), T * np.array([(xmin + xmax) / 2, (ymin + ymax) / 2]), T * v, ) ) return boxes def plot_aligned_box(self, **kwargs: Any) -> None: """ Plot aligned rectangles of blobs using Matplotlib :param kwargs: line style parameters passed to ``plot`` Compute the minimal enclosing box whose sides are parallel to the axes of the equivalent ellipse. Return a list of vertices (not closed) and a list of box centroids. Highlights the perimeter of a blob or blobs on the current plot. Example: >>> from machinevisiontoolbox import Image >>> im = Image.Read("sharks.png") >>> blobs = im.blobs() >>> blobs[:3].plot_aligned_box(color="red") >>> blobs[3].plot_aligned_box(color="yellow", linestyle="--", linewidth=3) .. plot:: from machinevisiontoolbox import Image im = Image.Read("sharks.png") im.disp() blobs = im.blobs() blobs[:3].plot_aligned_box(color="red") blobs[3].plot_aligned_box(color="yellow", linestyle="--", linewidth=3) :seealso: :meth:`plot_box` :meth:`plot_MER`:meth:`plot_centroid` """ boxes = self.aligned_box() if not isinstance(boxes, list): boxes = [boxes] for box in boxes: base.plot_polygon(box[2], close=True, **kwargs) def plot_MEC(self, **kwargs: Any) -> None: """ Plot minimum enclosing circles of blobs using Matplotlib :param kwargs: line style parameters passed to ``plot`` Plots the minimum enclosing circles of blob or blobs on the current Matplotlib axes. Example: >>> from machinevisiontoolbox import Image >>> im = Image.Read("sharks.png") >>> blobs = im.blobs() >>> blobs[:3].plot_MEC(color="cyan") >>> blobs[3].plot_MEC(color="magenta", linestyle="--", linewidth=3) .. plot:: from machinevisiontoolbox import Image im = Image.Read("sharks.png") im.disp() blobs = im.blobs() blobs[:3].plot_MEC(color="cyan") blobs[3].plot_MEC(color="magenta", linestyle="--", linewidth=3) :seealso: :meth:`MEC` :meth:`plot_MER` :meth:`plot_box` :meth:`plot_centroid` """ mecs = self.MEC if not isinstance(mecs, list): mecs = [mecs] for mec in mecs: base.plot_circle(mec[2], mec[:2], **kwargs) def plot_MER(self, **kwargs: Any) -> None: """ Plot minimum enclosing rectangles of blobs using Matplotlib :param kwargs: line style parameters passed to ``plot`` Plots the minimum enclosing rectangles of blob or blobs on the current Matplotlib axes. Example: >>> from machinevisiontoolbox import Image >>> im = Image.Read("sharks.png") >>> blobs = im.blobs() >>> blobs[:3].plot_MER(color="cyan") >>> blobs[3].plot_MER(color="magenta", linestyle="--", linewidth=3) .. plot:: from machinevisiontoolbox import Image im = Image.Read("sharks.png") im.disp() blobs = im.blobs() blobs[:3].plot_MER(color="cyan") blobs[3].plot_MER(color="magenta", linestyle="--", linewidth=3) :seealso: :meth:`MER` :meth:`MEC` :meth:`plot_box` :meth:`plot_aligned_box` :meth:`plot_centroid` """ mers = self.MER if not isinstance(mers, list): mers = [mers] for mer in mers: w, h = mer[2:4] # define box, clockwise from top right box = np.array([[w, h], [w, -h], [-w, -h], [-w, h], [w, h]]).T / 2.0 T = SE2(float(mer[0]), float(mer[1]), float(np.radians(mer[4]))) box = np.asarray(T * box) base.plot_polygon(box[:2, :], **kwargs) def label_image(self, image: Any = None) -> Any: """ Create label image from blobs :param image: image to draw into, defaults to new image :type image: :class:`Image`, optional :return: greyscale label image :rtype: :class:`Image` The perimeter information from the blobs is used to generate a greyscale label image where the greyvalue of each region corresponds to the blob index. Example: >>> from machinevisiontoolbox import Image >>> im = Image.Read("sharks.png") >>> blobs = im.blobs() >>> labels = blobs.label_image() >>> labels.disp(colorbar=True) .. plot:: from machinevisiontoolbox import Image im = Image.Read("sharks.png") im.disp() blobs = im.blobs() labels = blobs.label_image() labels.disp(colorbar=True) .. note:: The label image is reconstituted from the OpenCV contours that are saved within the :class:`Blobs` object. :seealso: :meth:`~machinevisiontoolbox.ImageSpatial.labels_binary` """ if image is None: image = self._image # TODO check contours, icont, colors, etc are valid # done because we squeezed hierarchy from a (1,M,4) to an (M,4) earlier labels = np.zeros(image.shape[:2], dtype=np.uint8) for i in range(len(self)): # TODO figure out how to draw alpha/transparencies? cv2.drawContours( image=labels, contours=self._contours_raw, contourIdx=i, color=i + 1, thickness=-1, # fill the contour hierarchy=self._hierarchy_raw, ) return image.__class__(labels) def dotfile( self, filename: Any = None, direction: str | None = None, show: bool = False ) -> None: """ Create a GraphViz dot file :param filename: filename to save graph to, defaults to None :type filename: str, optional :param direction: graph drawing direction, defaults to top to bottom :type direction: str, optional :param show: compile the graph and display in browser tab, defaults to False :type show: bool, optional Creates the specified file which contains the `GraphViz <https://graphviz.org>`_ code to represent the blob hierarchy as a directed graph. By default output is to the console. .. note:: If ``filename`` is a file object then the file will *not* be closed after the GraphViz model is written. :seealso: :meth:`child` :meth:`parent` :meth:`level` """ if show: # create the temporary dotfile filename = tempfile.TemporaryFile(mode="w") if filename is None: f = sys.stdout elif isinstance(filename, str): f = open(filename, "w") else: f = filename print("digraph {", file=f) if direction is not None: print(f"rankdir = {direction}", file=f) # add the nodes including name and position for id, blob in enumerate(self): print(' "{:d}"'.format(id), file=f) print( ' "{:d}" -> "{:d}"'.format(blob.parent if blob.parent else -1, id), file=f, ) print("}", file=f) if show: # rewind the dot file, create PDF file in the filesystem, run dot f.seek(0) pdffile = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) subprocess.run( ["dot", "-Tpdf"], stdin=cast(Any, f), stdout=pdffile, check=True, ) # open the PDF file in browser (hopefully portable), then cleanup webbrowser.open(f"file://{pdffile.name}") else: if filename is None or isinstance(filename, str): f.close() # noqa
class ImageBlobsMixin: def blobs(self, **kwargs) -> Blobs: """ Find and describe blobs in image :return: blobs in the image :rtype: :class:`Blobs` Find all blobs in the image and return an object that contains geometric information about them. The object behaves like a list so it can be indexed and sliced. Example: .. runblock:: pycon >>> from machinevisiontoolbox import Image >>> im = Image.Read('shark2.png') >>> blobs = im.blobs() >>> type(blobs) >>> len(blobs) >>> print(blobs) :references: - |RVC3|, Section 12.1.2.1. :seealso: :class:`Blobs` :class:`Blob` """ # TODO do the feature extraction here # each blob is a named tuple?? # This could be applied to MSERs return Blobs(self, **kwargs) if __name__ == "__main__": from pathlib import Path import pytest pytest.main( [ str(Path(__file__).parent.parent.parent / "tests" / "test_image_blobs.py"), "-v", ] )