From f465e61be4df49f16f60a9fe9c39f92e5b61ab77 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 23 Dec 2025 15:33:59 +1100 Subject: [PATCH] Updated documentation --- docs/reference/ImageMorph.rst | 46 ++++++++++++++- src/PIL/ImageMorph.py | 108 +++++++++++++++++++++++++--------- 2 files changed, 122 insertions(+), 32 deletions(-) diff --git a/docs/reference/ImageMorph.rst b/docs/reference/ImageMorph.rst index 30b89a54df5..77b96058a7d 100644 --- a/docs/reference/ImageMorph.rst +++ b/docs/reference/ImageMorph.rst @@ -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: diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index bd70aff7b48..2d8cdd8edb2 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -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 = { @@ -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 @@ -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)] @@ -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 = [] @@ -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:] @@ -193,18 +210,30 @@ 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) @@ -212,7 +241,7 @@ def apply(self, image: Image.Image) -> tuple[int, Image.Image]: 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 @@ -220,8 +249,12 @@ 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) @@ -232,10 +265,13 @@ 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" @@ -243,7 +279,12 @@ def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]: 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()) @@ -253,7 +294,12 @@ 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) @@ -261,5 +307,9 @@ def save_lut(self, filename: str) -> None: 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