Coverage for /usr/local/lib/python3.10/site-packages/spam/filters/morphologicalOperations.py: 100%
120 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-24 17:26 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-24 17:26 +0000
1# Library of SPAM morphological functions.
2# Copyright (C) 2020 SPAM Contributors
3#
4# This program is free software: you can redistribute it and/or modify it
5# under the terms of the GNU General Public License as published by the Free
6# Software Foundation, either version 3 of the License, or (at your option)
7# any later version.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
12# more details.
13#
14# You should have received a copy of the GNU General Public License along with
15# this program. If not, see <http://www.gnu.org/licenses/>.
17import multiprocessing
19import numpy
20import spam.helpers
22try:
23 multiprocessing.set_start_method("fork")
24except RuntimeError:
25 pass
27import progressbar
28import scipy.ndimage
29import skimage.morphology
30import spam.label
32# operations on greyscale images
34# Global number of processes
35nProcessesDefault = multiprocessing.cpu_count()
38def greyDilation(im, nBytes=1):
39 """
40 This function applies a dilation on a grey scale image
42 Parameters
43 -----------
44 im: numpy array
45 The input image (greyscale)
46 nBytes: int, default=1
47 Number of bytes used to substitute the values on the border.
49 Returns
50 --------
51 numpy array
52 The dilated image
53 """
54 # Step 1: check type and dimension
55 dim = len(im.shape)
56 # Step 2: Determine substitution value
57 sub = 2 ** (8 * nBytes) - 1
58 # Step 3: apply dilation # x y z
59 outputIm = im # +0 0 0
60 outputIm = numpy.maximum(outputIm, spam.helpers.singleShift(im, 1, 0, sub=sub)) # +1 0 0
61 outputIm = numpy.maximum(outputIm, spam.helpers.singleShift(im, -1, 0, sub=sub)) # -1 0 0
62 outputIm = numpy.maximum(outputIm, spam.helpers.singleShift(im, 1, 1, sub=sub)) # +0 1 0
63 outputIm = numpy.maximum(outputIm, spam.helpers.singleShift(im, -1, 1, sub=sub)) # +0 -1 0
64 if dim == 3:
65 outputIm = numpy.maximum(outputIm, spam.helpers.singleShift(im, 1, 2, sub=sub)) # 0 0 1
66 outputIm = numpy.maximum(outputIm, spam.helpers.singleShift(im, -1, 2, sub=sub)) # 0 0 -1
67 return outputIm
70def greyErosion(im, nBytes=1):
71 """
72 This function applies a erosion on a grey scale image
74 Parameters
75 -----------
76 im: numpy array
77 The input image (greyscale)
78 nBytes: int, default=1
79 Number of bytes used to substitute the values on the border.
81 Returns
82 --------
83 numpy array
84 The eroded image
85 """
86 # Step 1: check type and dimension
87 dim = len(im.shape)
88 # Step 2: Determine substitution value
89 sub = 2 ** (8 * nBytes) - 1
90 # Step 1: apply erosion # x y z
91 outputIm = im # 0 0 0
92 outputIm = numpy.minimum(outputIm, spam.helpers.singleShift(im, 1, 0, sub=sub)) # 1 0 0
93 outputIm = numpy.minimum(outputIm, spam.helpers.singleShift(im, -1, 0, sub=sub)) # -1 0 0
94 outputIm = numpy.minimum(outputIm, spam.helpers.singleShift(im, 1, 1, sub=sub)) # 0 1 0
95 outputIm = numpy.minimum(outputIm, spam.helpers.singleShift(im, -1, 1, sub=sub)) # 0 -1 0
96 if dim == 3:
97 outputIm = numpy.minimum(outputIm, spam.helpers.singleShift(im, 1, 2, sub=sub)) # 0 0 1
98 outputIm = numpy.minimum(outputIm, spam.helpers.singleShift(im, -1, 2, sub=sub)) # 0 0 -1
99 return outputIm
102def greyMorphologicalGradient(im, nBytes=1):
103 """
104 This function applies a morphological gradient on a grey scale image
106 Parameters
107 -----------
108 im: numpy array
109 The input image (greyscale)
110 nBytes: int, default=1
111 Number of bytes used to substitute the values on the border.
113 Returns
114 --------
115 numpy array
116 The morphologycal gradient of the image
117 """
118 return greyDilation(im, nBytes=nBytes) - im
121# operation on binary images
124def binaryDilation(im, sub=False):
125 """
126 This function applies a dilation on a binary scale image
128 Parameters
129 -----------
130 im: numpy array
131 The input image (greyscale)
132 sub: bool, default=False
133 Subtitute value.
135 Returns
136 --------
137 numpy array
138 The dilated image
139 """
140 # Step 0: import as bool
141 im = im.astype(bool)
142 # Step 1: check type and dimension
143 dim = len(im.shape)
144 # Step 1: apply dilation # x y z
145 outputIm = im # 0 0 0
146 outputIm = outputIm | spam.helpers.singleShift(im, 1, 0, sub=sub) # 1 0 0
147 outputIm = outputIm | spam.helpers.singleShift(im, -1, 0, sub=sub) # -1 0 0
148 outputIm = outputIm | spam.helpers.singleShift(im, 1, 1, sub=sub) # 0 1 0
149 outputIm = outputIm | spam.helpers.singleShift(im, -1, 1, sub=sub) # 0 -1 0
150 if dim == 3:
151 outputIm = outputIm | spam.helpers.singleShift(im, 1, 2, sub=sub) # 0 0 1
152 outputIm = outputIm | spam.helpers.singleShift(im, -1, 2, sub=sub) # 0 0 -1
153 return numpy.array(outputIm).astype("<u1")
156def binaryErosion(im, sub=False):
157 """
158 This function applies a erosion on a binary scale image
160 Parameters
161 -----------
162 im: numpy array
163 The input image (greyscale)
164 sub: bool, default=False
165 Substitute value.
167 Returns
168 --------
169 numpy array
170 The eroded image
171 """
172 # Step 1: apply erosion with dilation --> erosion = ! dilation( ! image )
173 return numpy.logical_not(binaryDilation(numpy.logical_not(im), sub=sub)).astype("<u1")
176def binaryMorphologicalGradient(im, sub=False):
177 """
178 This function applies a morphological gradient on a binary scale image
180 Parameters
181 -----------
182 im: numpy array
183 The input image (greyscale)
184 nBytes: int, default=False
185 Number of bytes used to substitute the values on the border.
187 Returns
188 --------
189 numpy array
190 The morphologycal gradient of the image
191 """
192 return (numpy.logical_xor(binaryDilation(im, sub=sub), im)).astype("<u1")
195def binaryGeodesicReconstruction(im, marker, dmax=None, verbose=False):
196 """
197 Calculate the geodesic reconstruction of a binary image with a given marker
199 Parameters
200 -----------
201 im: numpy.array
202 The input binary image
204 marker: numpy.array or list
205 If numpy array: direct input of the marker (must be the size of im)
206 If list: description of the plans of the image considered as the marker
207 | ``[1, 0]`` plan defined by all voxels at ``x1=0``
208 | ``[0, -1]`` plan defined by all voxels at ``x0=x0_max``
209 | ``[0, 0, 2, 5]`` plans defined by all voxels at ``x0=0`` and ``x2=5``
211 dmax: int, default=None
212 The maximum geodesic distance. If None, the reconstruction is complete.
214 verbose: bool, default=False
215 Verbose mode
217 Returns
218 --------
219 numpy.array
220 The reconstructed image
221 """
222 from spam.errors import InputError
224 # Step 1: Define marker
225 if isinstance(marker, list):
226 # marker based on list of plans
227 if len(marker) % 2:
228 raise InputError("marker", explanation="len(marker) must be a multiple of 2")
230 plans = marker[:]
231 marker = numpy.zeros(im.shape, dtype=bool)
232 for i in range(len(plans) // 2):
233 direction = plans[2 * i]
234 distance = plans[2 * i + 1]
235 if len(im.shape) == 2:
236 if direction == 0:
237 marker[distance, :] = im[distance, :]
238 elif direction == 1:
239 marker[:, distance] = im[:, distance]
240 else:
241 raise InputError("marker", explanation=f"Wrong marker plan direction {direction}")
243 elif len(im.shape) == 3:
244 if direction == 0:
245 marker[distance, :, :] = im[distance, :, :]
246 elif direction == 1:
247 marker[:, distance, :] = im[:, distance, :]
248 elif direction == 2:
249 marker[:, :, distance] = im[:, :, distance]
250 else:
251 raise InputError("marker", explanation=f"Wrong marker plan direction {direction}")
253 else:
254 raise InputError("marker", explanation=f"Image dimension should be 2 or 3, not {len(im.shape)}")
256 if verbose:
257 print(f"binaryGeodesicReconstruction: marker -> set plan in direction {direction} at distance {distance}")
259 elif isinstance(marker, numpy.ndarray):
260 # direct input of the marker
261 if im.shape != marker.shape:
262 raise InputError("marker", explanation="im and marker must have same shape")
263 else:
264 raise InputError("marker", explanation="must be a numpy array or a list")
266 # Step 2: first dilation and intersection
267 r1 = binaryDilation(marker) & im
268 r2 = r1
269 r1 = binaryDilation(r2) & im
270 d = 1
271 dmax = numpy.inf if dmax is None else dmax
272 if verbose:
273 print(f"binaryGeodesicReconstruction: geodesic distance = {d} (sum = {r1.sum()} & {r2.sum()})")
275 # binary dilation until:
276 # geodesic distance reach dmax
277 # reconstuction complete
278 while not numpy.array_equal(r1, r2) and d < dmax:
279 r2 = r1
280 r1 = binaryDilation(r2) & im
281 d += 1
282 if verbose:
283 print(f"binaryGeodesicReconstruction: geodesic distance = {d} (sum = {r1.sum()} & {r2.sum()})")
285 return r1 # send the reconstructed image
288def directionalErosion(bwIm, vect, a, c, nProcesses=nProcessesDefault, verbose=False):
289 """
290 This functions performs direction erosion over the binarized image using
291 an ellipsoidal structuring element over a range of directions. It is highly
292 recommended that the structuring element is slightly smaller than the
293 expected particle (50% smaller in each axis is a fair guess)
295 Parameters
296 -----------
297 bwIm : 3D numpy array
298 Binarized image to perform the erosion
300 vect : list of n elements, each element correspond to a 1X3 array of floats
301 List of directional vectors for the structuring element
303 a : int or float
304 Length of the secondary semi-axis of the structuring element in px
306 c : int or float
307 Lenght of the principal semi-axis of the structuring element in px
309 nProcesses : integer (optional, default = nProcessesDefault)
310 Number of processes for multiprocessing
311 Default = number of CPUs in the system
313 verbose : boolean, optional (Default = False)
314 True for printing the evolution of the process
315 False for not printing the evolution of process
317 Returns
318 --------
319 imEroded : 3D boolean array
320 Booean array with the result of the erosion
322 Note
323 -----
324 Taken from https://sbrisard.github.io/posts/20150930-orientation_correlations_among_rice_grains-06.html
326 """
328 # Check if the directional vector input is a list
329 if isinstance(vect, list) is False:
330 print("spam.contacts.directionalErosion: The directional vector must be a list")
331 return
333 numberOfJobs = len(vect)
334 imEroded = numpy.zeros(bwIm.shape)
336 # Function for directionalErosion
337 global _multiprocessingDirectionalErosion
339 def _multiprocessingDirectionalErosion(job):
340 maxDim = numpy.max([a, c])
341 spheroid = spam.kalisphera.makeBlurryNoisySpheroid(
342 [maxDim, maxDim, maxDim], [numpy.floor(maxDim / 2), numpy.floor(maxDim / 2), numpy.floor(maxDim / 2)], [a, c], vect[job], background=0, foreground=1
343 )
344 imEroded_i = scipy.ndimage.binary_erosion(bwIm, structure=spheroid)
345 return imEroded_i
347 if verbose:
348 widgets = [progressbar.FormatLabel(""), " ", progressbar.Bar(), " ", progressbar.AdaptiveETA()]
349 pbar = progressbar.ProgressBar(widgets=widgets, maxval=numberOfJobs)
350 pbar.start()
351 finishedNodes = 0
353 # Run multiprocessing
354 with multiprocessing.Pool(processes=nProcesses) as pool:
355 for returns in pool.imap_unordered(_multiprocessingDirectionalErosion, range(0, numberOfJobs)):
356 if verbose:
357 finishedNodes += 1
358 widgets[0] = progressbar.FormatLabel("{}/{} ".format(finishedNodes, numberOfJobs))
359 pbar.update(finishedNodes)
360 imEroded = imEroded + returns
361 pool.close()
362 pool.join()
364 if verbose:
365 pbar.finish()
367 return imEroded
370def morphologicalReconstruction(im, selem=skimage.morphology.ball(1)):
371 """
372 This functions performs a morphological reconstruction (greyscale opening followed by greyscale closing).
373 The ouput image presents less variability in the greyvalues inside each phase, without modifying the original
374 shape of the objects of the image.
375 -
377 Parameters
378 -----------
379 im : 3D numpy array
380 Greyscale image to perform the reconstuction
382 selem : structuring element, optional
383 Structuring element
384 Default = None
386 Returns
387 --------
388 imReconstructed : 3D boolean array
389 Greyscale image after the reconstuction
391 """
393 # Perform the opening
394 imOpen = scipy.ndimage.grey_opening(im, footprint=selem)
395 # Perform the closing
396 imReconstructed = (scipy.ndimage.grey_closing(imOpen, footprint=selem)).astype(numpy.float32)
398 return imReconstructed