"""
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",
]
)