Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions docs/reference/ImageMorph.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,50 @@
:py:mod:`~PIL.ImageMorph` module
================================

The :py:mod:`~PIL.ImageMorph` module provides morphology operations on images.
The :py:mod:`~PIL.ImageMorph` module allows `morphology`_ operators ("MorphOp") to be
applied to L mode images::

.. automodule:: PIL.ImageMorph
from PIL import Image, ImageMorph
img = Image.open("Tests/images/hopper.bw")
mop = ImageMorph.MorphOp(op_name="erosion4")
count, imgOut = mop.apply(img)
imgOut.show()

.. _morphology: https://en.wikipedia.org/wiki/Mathematical_morphology

In addition to applying operators, you can also analyse images.

You can inspect an image in isolation to determine which pixels are non-empty::

print(mop.get_on_pixels(img)) # [(0, 0), (1, 0), (2, 0), ...]

Or you can retrieve a list of pixels that match the operator. This is the number of
pixels that will be non-empty after the operator is applied::

coords = mop.match(img)
print(coords) # [(17, 1), (18, 1), (34, 1), ...]
print(len(coords)) # 550

imgOut = mop.apply(img)[1]
print(len(mop.get_on_pixels(imgOut))) # 550

If you would like more customized operators, you can pass patterns to the MorphOp
class::

mop = ImageMorph.MorphOp(patterns=["1:(... ... ...)->0", "4:(00. 01. ...)->1"])

Or you can pass lookup table ("LUT") data directly. This LUT data can be constructed
with the :py:class:`~PIL.ImageMorph.LutBuilder`::

builder = ImageMorph.LutBuilder()
mop = ImageMorph.MorphOp(lut=builder.build_lut())

.. autoclass:: LutBuilder
:members:
:undoc-members:
:show-inheritance:

.. autoclass:: MorphOp
:members:
:undoc-members:
:show-inheritance:
:noindex:
108 changes: 79 additions & 29 deletions src/PIL/ImageMorph.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,12 @@ class LutBuilder:
def __init__(
self, patterns: list[str] | None = None, op_name: str | None = None
) -> None:
if patterns is not None:
self.patterns = patterns
else:
self.patterns = []
"""
:param patterns: A list of input patterns, or None.
:param op_name: The name of a known pattern. One of "corner", "dilation4",
"dilation8", "erosion4", "erosion8" or "edge".
:exception Exception: If the op_name is not recognized.
"""
self.lut: bytearray | None = None
if op_name is not None:
known_patterns = {
Expand All @@ -88,20 +90,37 @@ def __init__(
raise Exception(msg)

self.patterns = known_patterns[op_name]
elif patterns is not None:
self.patterns = patterns
else:
self.patterns = []

def add_patterns(self, patterns: list[str]) -> None:
"""
Append to list of patterns.

:param patterns: Additional patterns.
"""
self.patterns += patterns

def build_default_lut(self) -> None:
"""
Set the current LUT

This is the default LUT that patterns will be applied against when building.
"""
symbols = [0, 1]
m = 1 << 4 # pos of current pixel
self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE))

def get_lut(self) -> bytearray | None:
"""
Returns the current LUT
"""
return self.lut

def _string_permute(self, pattern: str, permutation: list[int]) -> str:
"""string_permute takes a pattern and a permutation and returns the
"""Takes a pattern and a permutation and returns the
string permuted according to the permutation list.
"""
assert len(permutation) == 9
Expand All @@ -110,7 +129,7 @@ def _string_permute(self, pattern: str, permutation: list[int]) -> str:
def _pattern_permute(
self, basic_pattern: str, options: str, basic_result: int
) -> list[tuple[str, int]]:
"""pattern_permute takes a basic pattern and its result and clones
"""Takes a basic pattern and its result and clones
the pattern according to the modifications described in the $options
parameter. It returns a list of all cloned patterns."""
patterns = [(basic_pattern, basic_result)]
Expand Down Expand Up @@ -140,10 +159,9 @@ def _pattern_permute(
return patterns

def build_lut(self) -> bytearray:
"""Compile all patterns into a morphology lut.
"""Compile all patterns into a morphology LUT, and returns it.

TBD :Build based on (file) morphlut:modify_lut
"""
This is the data to be passed into MorphOp."""
self.build_default_lut()
assert self.lut is not None
patterns = []
Expand All @@ -163,15 +181,14 @@ def build_lut(self) -> bytearray:

patterns += self._pattern_permute(pattern, options, result)

# compile the patterns into regular expressions for speed
# Compile the patterns into regular expressions for speed
compiled_patterns = []
for pattern in patterns:
p = pattern[0].replace(".", "X").replace("X", "[01]")
compiled_patterns.append((re.compile(p), pattern[1]))

# Step through table and find patterns that match.
# Note that all the patterns are searched. The last one
# caught overrides
# Note that all the patterns are searched. The last one found takes priority
for i in range(LUT_SIZE):
# Build the bit pattern
bitpattern = bin(i)[2:]
Expand All @@ -193,35 +210,51 @@ def __init__(
op_name: str | None = None,
patterns: list[str] | None = None,
) -> None:
"""Create a binary morphological operator"""
self.lut = lut
if op_name is not None:
self.lut = LutBuilder(op_name=op_name).build_lut()
elif patterns is not None:
self.lut = LutBuilder(patterns=patterns).build_lut()
"""Create a binary morphological operator.

If the LUT is not provided, then it is built using LutBuilder from the op_name
or the patterns.

:param lut: The LUT data.
:param patterns: A list of input patterns, or None.
:param op_name: The name of a known pattern. One of "corner", "dilation4",
"dilation8", "erosion4", "erosion8", "edge".
:exception Exception: If the op_name is not recognized.
"""
if patterns is None and op_name is None:
self.lut = lut
else:
self.lut = LutBuilder(patterns, op_name).build_lut()

def apply(self, image: Image.Image) -> tuple[int, Image.Image]:
"""Run a single morphological operation on an image
"""Run a single morphological operation on an image.

Returns a tuple of the number of changed pixels and the
morphed image"""
morphed image.

:exception Exception: If the current operator is None.
:exception ValueError: If the image is not L mode."""
if self.lut is None:
msg = "No operator loaded"
raise Exception(msg)

if image.mode != "L":
msg = "Image mode must be L"
raise ValueError(msg)
outimage = Image.new(image.mode, image.size, None)
outimage = Image.new(image.mode, image.size)
count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim())
return count, outimage

def match(self, image: Image.Image) -> list[tuple[int, int]]:
"""Get a list of coordinates matching the morphological operation on
an image.

Returns a list of tuples of (x,y) coordinates
of all matching pixels. See :ref:`coordinate-system`."""
Returns a list of tuples of (x,y) coordinates of all matching pixels. See
:ref:`coordinate-system`.

:param image: An L-mode image.
:exception Exception: If the current operator is None.
:exception ValueError: If the image is not L mode."""
if self.lut is None:
msg = "No operator loaded"
raise Exception(msg)
Expand All @@ -232,18 +265,26 @@ def match(self, image: Image.Image) -> list[tuple[int, int]]:
return _imagingmorph.match(bytes(self.lut), image.getim())

def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]:
"""Get a list of all turned on pixels in a binary image
"""Get a list of all turned on pixels in a grayscale image

Returns a list of tuples of (x,y) coordinates of all non-empty pixels. See
:ref:`coordinate-system`.

Returns a list of tuples of (x,y) coordinates
of all matching pixels. See :ref:`coordinate-system`."""
:param image: An L-mode image.
:exception ValueError: If the image is not L mode."""

if image.mode != "L":
msg = "Image mode must be L"
raise ValueError(msg)
return _imagingmorph.get_on_pixels(image.getim())

def load_lut(self, filename: str) -> None:
"""Load an operator from an mrl file"""
"""
Load an operator from an mrl file

:param filename: The file to read from.
:exception Exception: If the length of the file data is not 512.
"""
with open(filename, "rb") as f:
self.lut = bytearray(f.read())

Expand All @@ -253,13 +294,22 @@ def load_lut(self, filename: str) -> None:
raise Exception(msg)

def save_lut(self, filename: str) -> None:
"""Save an operator to an mrl file"""
"""
Save an operator to an mrl file.

:param filename: The destination file.
:exception Exception: If the current operator is None.
"""
if self.lut is None:
msg = "No operator loaded"
raise Exception(msg)
with open(filename, "wb") as f:
f.write(self.lut)

def set_lut(self, lut: bytearray | None) -> None:
"""Set the lut from an external source"""
"""
Set the LUT from an external source

:param lut: A new LUT.
"""
self.lut = lut
Loading