Skip to content

Commit c533624

Browse files
authored
Merge pull request #380 from rapidsai/branch-22.08
[gpuCI] Forward-merge branch-22.08 to branch-22.10 [skip gpuci]
2 parents a279721 + c834039 commit c533624

File tree

4 files changed

+413
-27
lines changed

4 files changed

+413
-27
lines changed

python/cucim/src/cucim/skimage/morphology/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from ._skeletonize import thin
1+
from ._skeletonize import medial_axis, thin
22
from .binary import (binary_closing, binary_dilation, binary_erosion,
33
binary_opening)
44
from .footprints import (ball, cube, diamond, disk, octagon, octahedron,
@@ -32,4 +32,5 @@
3232
"remove_small_objects",
3333
"remove_small_holes",
3434
"thin",
35+
"medial_axis",
3536
]
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import numpy as np
2+
3+
# medial axis lookup tables (independent of image content)
4+
#
5+
# Note: lookup table generated using scikit-image code from
6+
# https://github.com/scikit-image/scikit-image/blob/38b595d60befe3a0b4c0742995b9737200a079c6/skimage/morphology/_skeletonize.py#L449-L458 # noqa
7+
8+
lookup_table = np.array(
9+
[
10+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1,
11+
0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
12+
0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0,
13+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1,
14+
0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
15+
0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0,
16+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0,
17+
0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
18+
0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
19+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0,
20+
1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
21+
0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
22+
0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
23+
1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0,
24+
0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
25+
0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
26+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1,
27+
1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
28+
0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0,
29+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0,
30+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
31+
0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0,
32+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0,
33+
0, 0, 0, 0, 0, 0
34+
],
35+
dtype=bool,
36+
)
37+
38+
39+
cornerness_table = np.array(
40+
[
41+
9, 8, 8, 7, 8, 7, 7, 6, 8, 7, 7, 6, 7, 6, 6, 5, 8, 7, 7, 6, 7, 6,
42+
6, 5, 7, 6, 6, 5, 6, 5, 5, 4, 8, 7, 7, 6, 7, 6, 6, 5, 7, 6, 6, 5,
43+
6, 5, 5, 4, 7, 6, 6, 5, 6, 5, 5, 4, 6, 5, 5, 4, 5, 4, 4, 3, 8, 7,
44+
7, 6, 7, 6, 6, 5, 7, 6, 6, 5, 6, 5, 5, 4, 7, 6, 6, 5, 6, 5, 5, 4,
45+
6, 5, 5, 4, 5, 4, 4, 3, 7, 6, 6, 5, 6, 5, 5, 4, 6, 5, 5, 4, 5, 4,
46+
4, 3, 6, 5, 5, 4, 5, 4, 4, 3, 5, 4, 4, 3, 4, 3, 3, 2, 8, 7, 7, 6,
47+
7, 6, 6, 5, 7, 6, 6, 5, 6, 5, 5, 4, 7, 6, 6, 5, 6, 5, 5, 4, 6, 5,
48+
5, 4, 5, 4, 4, 3, 7, 6, 6, 5, 6, 5, 5, 4, 6, 5, 5, 4, 5, 4, 4, 3,
49+
6, 5, 5, 4, 5, 4, 4, 3, 5, 4, 4, 3, 4, 3, 3, 2, 7, 6, 6, 5, 6, 5,
50+
5, 4, 6, 5, 5, 4, 5, 4, 4, 3, 6, 5, 5, 4, 5, 4, 4, 3, 5, 4, 4, 3,
51+
4, 3, 3, 2, 6, 5, 5, 4, 5, 4, 4, 3, 5, 4, 4, 3, 4, 3, 3, 2, 5, 4,
52+
4, 3, 4, 3, 3, 2, 4, 3, 3, 2, 3, 2, 2, 1, 8, 7, 7, 6, 7, 6, 6, 5,
53+
7, 6, 6, 5, 6, 5, 5, 4, 7, 6, 6, 5, 6, 5, 5, 4, 6, 5, 5, 4, 5, 4,
54+
4, 3, 7, 6, 6, 5, 6, 5, 5, 4, 6, 5, 5, 4, 5, 4, 4, 3, 6, 5, 5, 4,
55+
5, 4, 4, 3, 5, 4, 4, 3, 4, 3, 3, 2, 7, 6, 6, 5, 6, 5, 5, 4, 6, 5,
56+
5, 4, 5, 4, 4, 3, 6, 5, 5, 4, 5, 4, 4, 3, 5, 4, 4, 3, 4, 3, 3, 2,
57+
6, 5, 5, 4, 5, 4, 4, 3, 5, 4, 4, 3, 4, 3, 3, 2, 5, 4, 4, 3, 4, 3,
58+
3, 2, 4, 3, 3, 2, 3, 2, 2, 1, 7, 6, 6, 5, 6, 5, 5, 4, 6, 5, 5, 4,
59+
5, 4, 4, 3, 6, 5, 5, 4, 5, 4, 4, 3, 5, 4, 4, 3, 4, 3, 3, 2, 6, 5,
60+
5, 4, 5, 4, 4, 3, 5, 4, 4, 3, 4, 3, 3, 2, 5, 4, 4, 3, 4, 3, 3, 2,
61+
4, 3, 3, 2, 3, 2, 2, 1, 6, 5, 5, 4, 5, 4, 4, 3, 5, 4, 4, 3, 4, 3,
62+
3, 2, 5, 4, 4, 3, 4, 3, 3, 2, 4, 3, 3, 2, 3, 2, 2, 1, 5, 4, 4, 3,
63+
4, 3, 3, 2, 4, 3, 3, 2, 3, 2, 2, 1, 4, 3, 3, 2, 3, 2, 2, 1, 3, 2,
64+
2, 1, 2, 1, 1, 0
65+
],
66+
dtype=np.uint8,
67+
)

python/cucim/src/cucim/skimage/morphology/_skeletonize.py

Lines changed: 224 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import warnings
2+
13
import cupy as cp
24
import cucim.skimage._vendored.ndimage as ndi
35
import numpy as np
46

7+
from cucim.core.operations.morphology import distance_transform_edt
8+
59
from .._shared.utils import check_nD, deprecate_kwarg
10+
from ._medial_axis_lookup import \
11+
cornerness_table as _medial_axis_cornerness_table
12+
from ._medial_axis_lookup import lookup_table as _medial_axis_lookup_table
613

714
# --------- Skeletonization and thinning based on Guo and Hall 1989 ---------
815

@@ -39,7 +46,7 @@
3946
0, 0, 0, 0, 0, 0, 0, 0, 0], dtype=bool)
4047

4148

42-
@deprecate_kwarg({'max_iter': 'max_num_iter'}, removed_version="23.02.00",
49+
@deprecate_kwarg({"max_iter": "max_num_iter"}, removed_version="23.02.00",
4350
deprecated_version="22.02.00")
4451
def thin(image, max_num_iter=None):
4552
"""
@@ -131,7 +138,7 @@ def thin(image, max_num_iter=None):
131138
# perform the two "subiterations" described in the paper
132139
for lut in [G123_LUT, G123P_LUT]:
133140
# correlate image with neighborhood mask
134-
N = ndi.correlate(skel, mask, mode='constant')
141+
N = ndi.correlate(skel, mask, mode="constant")
135142
# take deletion decision from this subiteration's LUT
136143
D = cp.take(lut, N)
137144
# perform deletion
@@ -141,3 +148,218 @@ def thin(image, max_num_iter=None):
141148
num_iter += 1
142149

143150
return skel.astype(bool)
151+
152+
153+
# --------- Skeletonization by medial axis transform --------
154+
155+
156+
def _get_tiebreaker(n, random_seed):
157+
# CuPy generator doesn't currently have the permutation method, so
158+
# fall back to cp.random.permutation instead.
159+
cp.random.seed(random_seed)
160+
if n < 2 << 31:
161+
dtype = np.int32
162+
else:
163+
dtype = np.intp
164+
tiebreaker = cp.random.permutation(cp.arange(n, dtype=dtype))
165+
return tiebreaker
166+
167+
168+
def medial_axis(image, mask=None, return_distance=False, *, random_state=None):
169+
"""Compute the medial axis transform of a binary image.
170+
171+
Parameters
172+
----------
173+
image : binary ndarray, shape (M, N)
174+
The image of the shape to be skeletonized.
175+
mask : binary ndarray, shape (M, N), optional
176+
If a mask is given, only those elements in `image` with a true
177+
value in `mask` are used for computing the medial axis.
178+
return_distance : bool, optional
179+
If true, the distance transform is returned as well as the skeleton.
180+
random_state : {None, int, `numpy.random.Generator`}, optional
181+
If `random_state` is None the `numpy.random.Generator` singleton is
182+
used.
183+
If `random_state` is an int, a new ``Generator`` instance is used,
184+
seeded with `random_state`.
185+
If `random_state` is already a ``Generator`` instance then that
186+
instance is used.
187+
188+
.. versionadded:: 0.19
189+
190+
Returns
191+
-------
192+
out : ndarray of bools
193+
Medial axis transform of the image
194+
dist : ndarray of ints, optional
195+
Distance transform of the image (only returned if `return_distance`
196+
is True)
197+
198+
See Also
199+
--------
200+
skeletonize
201+
202+
Notes
203+
-----
204+
This algorithm computes the medial axis transform of an image
205+
as the ridges of its distance transform.
206+
207+
The different steps of the algorithm are as follows
208+
* A lookup table is used, that assigns 0 or 1 to each configuration of
209+
the 3x3 binary square, whether the central pixel should be removed
210+
or kept. We want a point to be removed if it has more than one neighbor
211+
and if removing it does not change the number of connected components.
212+
213+
* The distance transform to the background is computed, as well as
214+
the cornerness of the pixel.
215+
216+
* The foreground (value of 1) points are ordered by
217+
the distance transform, then the cornerness.
218+
219+
* A cython function is called to reduce the image to its skeleton. It
220+
processes pixels in the order determined at the previous step, and
221+
removes or maintains a pixel according to the lookup table. Because
222+
of the ordering, it is possible to process all pixels in only one
223+
pass.
224+
225+
Examples
226+
--------
227+
>>> square = np.zeros((7, 7), dtype=np.uint8)
228+
>>> square[1:-1, 2:-2] = 1
229+
>>> square
230+
array([[0, 0, 0, 0, 0, 0, 0],
231+
[0, 0, 1, 1, 1, 0, 0],
232+
[0, 0, 1, 1, 1, 0, 0],
233+
[0, 0, 1, 1, 1, 0, 0],
234+
[0, 0, 1, 1, 1, 0, 0],
235+
[0, 0, 1, 1, 1, 0, 0],
236+
[0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
237+
>>> medial_axis(square).astype(np.uint8)
238+
array([[0, 0, 0, 0, 0, 0, 0],
239+
[0, 0, 1, 0, 1, 0, 0],
240+
[0, 0, 0, 1, 0, 0, 0],
241+
[0, 0, 0, 1, 0, 0, 0],
242+
[0, 0, 0, 1, 0, 0, 0],
243+
[0, 0, 1, 0, 1, 0, 0],
244+
[0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
245+
246+
"""
247+
try:
248+
from skimage.morphology._skeletonize_cy import _skeletonize_loop
249+
except ImportError as e:
250+
warnings.warn(
251+
"Could not find required private skimage Cython function:\n"
252+
"\tskimage.morphology._skeletonize_cy._skeletonize_loop\n"
253+
)
254+
raise e
255+
256+
if mask is None:
257+
# masked_image is modified in-place later so make a copy of the input
258+
masked_image = image.astype(bool, copy=True)
259+
else:
260+
masked_image = image.astype(bool, copy=True)
261+
masked_image[~mask] = False
262+
263+
# Load precomputed lookup table based on three conditions:
264+
# 1. Keep only positive pixels
265+
# AND
266+
# 2. Keep if removing the pixel results in a different connectivity
267+
# (if the number of connected components is different with and
268+
# without the central pixel)
269+
# OR
270+
# 3. Keep if # pixels in neighborhood is 2 or less
271+
# Note that this table is independent of the image
272+
table = _medial_axis_lookup_table
273+
274+
# Build distance transform
275+
distance = distance_transform_edt(masked_image)
276+
if return_distance:
277+
store_distance = distance.copy()
278+
279+
# Corners
280+
# The processing order along the edge is critical to the shape of the
281+
# resulting skeleton: if you process a corner first, that corner will
282+
# be eroded and the skeleton will miss the arm from that corner. Pixels
283+
# with fewer neighbors are more "cornery" and should be processed last.
284+
# We use a cornerness_table lookup table where the score of a
285+
# configuration is the number of background (0-value) pixels in the
286+
# 3x3 neighborhood
287+
cornerness_table = cp.asarray(_medial_axis_cornerness_table)
288+
corner_score = _table_lookup(masked_image, cornerness_table)
289+
290+
# Define arrays for inner loop
291+
distance = distance[masked_image]
292+
i, j = cp.where(masked_image)
293+
294+
# Determine the order in which pixels are processed.
295+
# We use a random # for tiebreaking. Assign each pixel in the image a
296+
# predictable, random # so that masking doesn't affect arbitrary choices
297+
# of skeletons
298+
tiebreaker = _get_tiebreaker(n=distance.size, random_seed=random_state)
299+
order = cp.lexsort(
300+
cp.stack(
301+
(tiebreaker, corner_score[masked_image], distance),
302+
axis=0
303+
)
304+
)
305+
306+
# Call _skeletonize_loop on the CPU. It requies a single pass over the
307+
# full array using a specific pixel order, so cannot be run multithreaded!
308+
order = cp.asnumpy(order.astype(cp.int32, copy=False))
309+
table = cp.asnumpy(table.astype(cp.uint8, copy=False))
310+
i = cp.asnumpy(i).astype(dtype=np.intp, copy=False)
311+
j = cp.asnumpy(j).astype(dtype=np.intp, copy=False)
312+
result = cp.asnumpy(masked_image)
313+
# Remove pixels not belonging to the medial axis
314+
_skeletonize_loop(result.view(np.uint8), i, j, order, table)
315+
result = cp.asarray(result.view(bool), dtype=bool)
316+
317+
if mask is not None:
318+
result[~mask] = image[~mask]
319+
if return_distance:
320+
return result, store_distance
321+
else:
322+
return result
323+
324+
325+
def _table_lookup(image, table):
326+
"""
327+
Perform a morphological transform on an image, directed by its
328+
neighbors
329+
330+
Parameters
331+
----------
332+
image : ndarray
333+
A binary image
334+
table : ndarray
335+
A 512-element table giving the transform of each pixel given
336+
the values of that pixel and its 8-connected neighbors.
337+
338+
Returns
339+
-------
340+
result : ndarray of same shape as `image`
341+
Transformed image
342+
343+
Notes
344+
-----
345+
The pixels are numbered like this::
346+
347+
0 1 2
348+
3 4 5
349+
6 7 8
350+
351+
The index at a pixel is the sum of 2**<pixel-number> for pixels
352+
that evaluate to true.
353+
"""
354+
#
355+
# We accumulate into the indexer to get the index into the table
356+
# at each point in the image
357+
#
358+
# max possible value of indexer is 512, so just use int16 dtype
359+
kernel = cp.array(
360+
[[256, 128, 64], [32, 16, 8], [4, 2, 1]],
361+
dtype=cp.int16
362+
)
363+
indexer = ndi.convolve(image, kernel, output=np.int16, mode="constant")
364+
image = table[indexer]
365+
return image

0 commit comments

Comments
 (0)