codekingpro/portable-devtools
115k
1# A binary morphology add-on for the Python Imaging Library
2#
3# History:
4# 2014-06-04 Initial version.
5#
6# Copyright (c) 2014 Dov Grobgeld <dov.grobgeld@gmail.com>
7from __future__ import annotations
8
9import re
10
11from . import Image, _imagingmorph
12
13LUT_SIZE = 1 << 9
14
15# fmt: off
16ROTATION_MATRIX = [
17 6, 3, 0,
18 7, 4, 1,
19 8, 5, 2,
20]
21MIRROR_MATRIX = [
22 2, 1, 0,
23 5, 4, 3,
24 8, 7, 6,
25]
26# fmt: on
27
28
29class LutBuilder:
30 """A class for building a MorphLut from a descriptive language
31
32 The input patterns is a list of a strings sequences like these::
33
34 4:(...
35 .1.
36 111)->1
37
38 (whitespaces including linebreaks are ignored). The option 4
39 describes a series of symmetry operations (in this case a
40 4-rotation), the pattern is described by:
41
42 - . or X - Ignore
43 - 1 - Pixel is on
44 - 0 - Pixel is off
45
46 The result of the operation is described after "->" string.
47
48 The default is to return the current pixel value, which is
49 returned if no other match is found.
50
51 Operations:
52
53 - 4 - 4 way rotation
54 - N - Negate
55 - 1 - Dummy op for no other operation (an op must always be given)
56 - M - Mirroring
57
58 Example::
59
60 lb = LutBuilder(patterns = ["4:(... .1. 111)->1"])
61 lut = lb.build_lut()
62
63 """
64
65 def __init__(
66 self, patterns: list[str] | None = None, op_name: str | None = None
67 ) -> None:
68 """
69 :param patterns: A list of input patterns, or None.
70 :param op_name: The name of a known pattern. One of "corner", "dilation4",
71 "dilation8", "erosion4", "erosion8" or "edge".
72 :exception Exception: If the op_name is not recognized.
73 """
74 self.lut: bytearray | None = None
75 if op_name is not None:
76 known_patterns = {
77 "corner": ["1:(... ... ...)->0", "4:(00. 01. ...)->1"],
78 "dilation4": ["4:(... .0. .1.)->1"],
79 "dilation8": ["4:(... .0. .1.)->1", "4:(... .0. ..1)->1"],
80 "erosion4": ["4:(... .1. .0.)->0"],
81 "erosion8": ["4:(... .1. .0.)->0", "4:(... .1. ..0)->0"],
82 "edge": [
83 "1:(... ... ...)->0",
84 "4:(.0. .1. ...)->1",
85 "4:(01. .1. ...)->1",
86 ],
87 }
88 if op_name not in known_patterns:
89 msg = f"Unknown pattern {op_name}!"
90 raise Exception(msg)
91
92 self.patterns = known_patterns[op_name]
93 elif patterns is not None:
94 self.patterns = patterns
95 else:
96 self.patterns = []
97
98 def add_patterns(self, patterns: list[str]) -> None:
99 """
100 Append to list of patterns.
101
102 :param patterns: Additional patterns.
103 """
104 self.patterns += patterns
105
106 def build_default_lut(self) -> bytearray:
107 """
108 Set the current LUT, and return it.
109
110 This is the default LUT that patterns will be applied against when building.
111 """
112 symbols = [0, 1]
113 m = 1 << 4 # pos of current pixel
114 self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE))
115 return self.lut
116
117 def get_lut(self) -> bytearray | None:
118 """
119 Returns the current LUT
120 """
121 return self.lut
122
123 def _string_permute(self, pattern: str, permutation: list[int]) -> str:
124 """Takes a pattern and a permutation and returns the
125 string permuted according to the permutation list.
126 """
127 assert len(permutation) == 9
128 return "".join(pattern[p] for p in permutation)
129
130 def _pattern_permute(
131 self, basic_pattern: str, options: str, basic_result: int
132 ) -> list[tuple[str, int]]:
133 """Takes a basic pattern and its result and clones
134 the pattern according to the modifications described in the $options
135 parameter. It returns a list of all cloned patterns."""
136 patterns = [(basic_pattern, basic_result)]
137
138 # rotations
139 if "4" in options:
140 res = patterns[-1][1]
141 for i in range(4):
142 patterns.append(
143 (self._string_permute(patterns[-1][0], ROTATION_MATRIX), res)
144 )
145 # mirror
146 if "M" in options:
147 n = len(patterns)
148 for pattern, res in patterns[:n]:
149 patterns.append((self._string_permute(pattern, MIRROR_MATRIX), res))
150
151 # negate
152 if "N" in options:
153 n = len(patterns)
154 for pattern, res in patterns[:n]:
155 # Swap 0 and 1
156 pattern = pattern.replace("0", "Z").replace("1", "0").replace("Z", "1")
157 res = 1 - int(res)
158 patterns.append((pattern, res))
159
160 return patterns
161
162 def build_lut(self) -> bytearray:
163 """Compile all patterns into a morphology LUT, and return it.
164
165 This is the data to be passed into MorphOp."""
166 self.build_default_lut()
167 assert self.lut is not None
168 patterns = []
169
170 # Parse and create symmetries of the patterns strings
171 for p in self.patterns:
172 m = re.search(r"(\w):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", ""))
173 if not m:
174 msg = 'Syntax error in pattern "' + p + '"'
175 raise Exception(msg)
176 options = m.group(1)
177 pattern = m.group(2)
178 result = int(m.group(3))
179
180 # Get rid of spaces
181 pattern = pattern.replace(" ", "").replace("\n", "")
182
183 patterns += self._pattern_permute(pattern, options, result)
184
185 # Compile the patterns into regular expressions for speed
186 compiled_patterns = []
187 for pattern in patterns:
188 p = pattern[0].replace(".", "X").replace("X", "[01]")
189 compiled_patterns.append((re.compile(p), pattern[1]))
190
191 # Step through table and find patterns that match.
192 # Note that all the patterns are searched. The last one found takes priority
193 for i in range(LUT_SIZE):
194 # Build the bit pattern
195 bitpattern = bin(i)[2:]
196 bitpattern = ("0" * (9 - len(bitpattern)) + bitpattern)[::-1]
197
198 for pattern, r in compiled_patterns:
199 if pattern.match(bitpattern):
200 self.lut[i] = [0, 1][r]
201
202 return self.lut
203
204
205class MorphOp:
206 """A class for binary morphological operators"""
207
208 def __init__(
209 self,
210 lut: bytearray | None = None,
211 op_name: str | None = None,
212 patterns: list[str] | None = None,
213 ) -> None:
214 """Create a binary morphological operator.
215
216 If the LUT is not provided, then it is built using LutBuilder from the op_name
217 or the patterns.
218
219 :param lut: The LUT data.
220 :param patterns: A list of input patterns, or None.
221 :param op_name: The name of a known pattern. One of "corner", "dilation4",
222 "dilation8", "erosion4", "erosion8", "edge".
223 :exception Exception: If the op_name is not recognized.
224 """
225 if patterns is None and op_name is None:
226 self.lut = lut
227 else:
228 self.lut = LutBuilder(patterns, op_name).build_lut()
229
230 def apply(self, image: Image.Image) -> tuple[int, Image.Image]:
231 """Run a single morphological operation on an image.
232
233 Returns a tuple of the number of changed pixels and the
234 morphed image.
235
236 :param image: A 1-mode or L-mode image.
237 :exception Exception: If the current operator is None.
238 :exception ValueError: If the image is not 1 or L mode."""
239 if self.lut is None:
240 msg = "No operator loaded"
241 raise Exception(msg)
242
243 if image.mode not in ("1", "L"):
244 msg = "Image mode must be 1 or L"
245 raise ValueError(msg)
246 outimage = Image.new(image.mode, image.size)
247 count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim())
248 return count, outimage
249
250 def match(self, image: Image.Image) -> list[tuple[int, int]]:
251 """Get a list of coordinates matching the morphological operation on
252 an image.
253
254 Returns a list of tuples of (x,y) coordinates of all matching pixels. See
255 :ref:`coordinate-system`.
256
257 :param image: A 1-mode or L-mode image.
258 :exception Exception: If the current operator is None.
259 :exception ValueError: If the image is not 1 or L mode."""
260 if self.lut is None:
261 msg = "No operator loaded"
262 raise Exception(msg)
263
264 if image.mode not in ("1", "L"):
265 msg = "Image mode must be 1 or L"
266 raise ValueError(msg)
267 return _imagingmorph.match(bytes(self.lut), image.getim())
268
269 def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]:
270 """Get a list of all turned on pixels in a 1 or L mode image.
271
272 Returns a list of tuples of (x,y) coordinates of all non-empty pixels. See
273 :ref:`coordinate-system`.
274
275 :param image: A 1-mode or L-mode image.
276 :exception ValueError: If the image is not 1 or L mode."""
277
278 if image.mode not in ("1", "L"):
279 msg = "Image mode must be 1 or L"
280 raise ValueError(msg)
281 return _imagingmorph.get_on_pixels(image.getim())
282
283 def load_lut(self, filename: str) -> None:
284 """
285 Load an operator from an mrl file
286
287 :param filename: The file to read from.
288 :exception Exception: If the length of the file data is not 512.
289 """
290 with open(filename, "rb") as f:
291 self.lut = bytearray(f.read())
292
293 if len(self.lut) != LUT_SIZE:
294 self.lut = None
295 msg = "Wrong size operator file!"
296 raise Exception(msg)
297
298 def save_lut(self, filename: str) -> None:
299 """
300 Save an operator to an mrl file.
301
302 :param filename: The destination file.
303 :exception Exception: If the current operator is None.
304 """
305 if self.lut is None:
306 msg = "No operator loaded"
307 raise Exception(msg)
308 with open(filename, "wb") as f:
309 f.write(self.lut)
310
311 def set_lut(self, lut: bytearray | None) -> None:
312 """
313 Set the LUT from an external source
314
315 :param lut: A new LUT.
316 """
317 self.lut = lut
318 