Skip to content

Commit 2cfc530

Browse files
committed
✨ Skeletonization of image using Zhang-Suen Algorithm
1 parent a71618f commit 2cfc530

File tree

1 file changed

+153
-0
lines changed

1 file changed

+153
-0
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# @Author: @joydipb01
2+
# @File: skeletonization_operation.py
3+
# @Time: 2025-10-03 13:45 IST
4+
5+
import numpy as np
6+
from PIL import Image
7+
from pathlib import Path
8+
9+
10+
def rgb_to_gray(rgb: np.ndarray) -> np.ndarray:
11+
"""
12+
Return gray image from rgb image
13+
14+
>>> rgb_to_gray(np.array([[[127, 255, 0]]]))
15+
array([[187.6453]])
16+
>>> rgb_to_gray(np.array([[[0, 0, 0]]]))
17+
array([[0.]])
18+
>>> rgb_to_gray(np.array([[[2, 4, 1]]]))
19+
array([[3.0598]])
20+
>>> rgb_to_gray(np.array([[[26, 255, 14], [5, 147, 20], [1, 200, 0]]]))
21+
array([[159.0524, 90.0635, 117.6989]])
22+
"""
23+
r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
24+
return 0.2989 * r + 0.5870 * g + 0.1140 * b
25+
26+
27+
def gray_to_binary(gray: np.ndarray) -> np.ndarray:
28+
"""
29+
Return binary image from gray image
30+
31+
>>> gray_to_binary(np.array([[127, 255, 0]]))
32+
array([[False, True, False]])
33+
>>> gray_to_binary(np.array([[0]]))
34+
array([[False]])
35+
>>> gray_to_binary(np.array([[26.2409, 4.9315, 1.4729]]))
36+
array([[False, False, False]])
37+
>>> gray_to_binary(np.array([[26, 255, 14], [5, 147, 20], [1, 200, 0]]))
38+
array([[False, True, False],
39+
[False, True, False],
40+
[False, True, False]])
41+
"""
42+
return (gray > 127) & (gray <= 255)
43+
44+
45+
def neighbours(image: np.ndarray, x: int, y: int) -> list:
46+
"""
47+
Return 8-neighbours of point (x, y), in clockwise order
48+
49+
>>> neighbours(1, 1, np.array([[True, True, False], [True, False, False], [False, True, False]]))
50+
[np.True_, np.False_, np.False_, np.False_, np.True_, np.False_, np.True_, np.True_]
51+
>>> neighbours(1, 2, np.array([[True, True, False, True], [True, False, False, True], [False, True, False, True]]))
52+
[np.False_, np.True_, np.True_, np.True_, np.False_, np.True_, np.False_, np.True_]
53+
"""
54+
img = image
55+
return [
56+
img[x-1][y], img[x-1][y+1], img[x][y+1], img[x+1][y+1],
57+
img[x+1][y], img[x+1][y-1], img[x][y-1], img[x-1][y-1]
58+
]
59+
60+
61+
def transitions(neighbors: list) -> int:
62+
"""
63+
Count 0->1 transitions in the neighborhood
64+
65+
>>> transitions([np.False_, np.True_, np.True_, np.False_, np.True_, np.False_, np.False_, np.False_])
66+
2
67+
>>> transitions([np.True_, np.True_, np.True_, np.True_, np.True_, np.True_, np.True_, np.True_])
68+
0
69+
>>> transitions([np.False_, np.False_, np.False_, np.False_, np.False_, np.False_, np.False_, np.False_])
70+
0
71+
>>> transitions([np.False_, np.True_, np.False_, np.True_, np.False_, np.True_, np.False_, np.True_])
72+
4
73+
>>> transitions([np.True_, np.False_, np.True_, np.False_, np.True_, np.False_, np.True_, np.False_])
74+
4
75+
"""
76+
n = neighbors + [neighbors[0]]
77+
return int(sum((n1 == 0 and n2 == 1) for n1, n2 in zip(n, n[1:])))
78+
79+
80+
def skeletonize_image(image: np.ndarray) -> np.ndarray:
81+
"""
82+
Apply Zhang-Suen thinning to binary image for skeletonization.
83+
Source: https://rstudio-pubs-static.s3.amazonaws.com/302782_e337cfbc5ad24922bae96ca5977f4da8.html
84+
85+
>>> skeletonize_image(np.array([[np.False_, np.True_, np.False_],
86+
... [np.True_, np.True_, np.True_],
87+
... [np.False_, np.True_, np.False_]]))
88+
array([[False, True, False],
89+
[ True, True, True],
90+
[False, True, False]])
91+
>>> skeletonize_image(np.array([[np.False_, np.False_, np.False_],
92+
... [np.False_, np.True_, np.False_],
93+
... [np.False_, np.False_, np.False_]]))
94+
array([[False, False, False],
95+
[False, True, False],
96+
[False, False, False]])
97+
"""
98+
img = image.copy()
99+
changing1 = changing2 = True
100+
101+
while changing1 or changing2:
102+
103+
# Step 1: Points to be removed in the first sub-iteration
104+
changing1 = []
105+
rows, cols = img.shape
106+
for x in range(1, rows - 1):
107+
for y in range(1, cols - 1):
108+
P = img[x][y]
109+
if P != 1:
110+
continue
111+
neighbours_list = neighbours(img, x, y)
112+
total_transitions = transitions(neighbours_list)
113+
N = sum(neighbours_list)
114+
if (2 <= N <= 6 and
115+
total_transitions == 1 and
116+
neighbours_list[0] * neighbours_list[2] * neighbours_list[4] == 0 and
117+
neighbours_list[2] * neighbours_list[4] * neighbours_list[6] == 0):
118+
changing1.append((x, y))
119+
for x, y in changing1:
120+
img[x][y] = 0
121+
122+
# Step 2: Points to be removed in the second sub-iteration
123+
changing2 = []
124+
for x in range(1, rows - 1):
125+
for y in range(1, cols - 1):
126+
P = img[x][y]
127+
if P != 1:
128+
continue
129+
neighbours_list = neighbours(img, x, y)
130+
total_transitions = transitions(neighbours_list)
131+
N = sum(neighbours_list)
132+
if (2 <= N <= 6 and
133+
total_transitions == 1 and
134+
neighbours_list[0] * neighbours_list[2] * neighbours_list[6] == 0 and
135+
neighbours_list[0] * neighbours_list[4] * neighbours_list[6] == 0):
136+
changing2.append((x, y))
137+
for x, y in changing2:
138+
img[x][y] = 0
139+
140+
return img
141+
142+
143+
if __name__ == "__main__":
144+
# Read original image
145+
lena_path = Path(__file__).resolve().parent.parent / "image_data" / "lena.jpg"
146+
lena = np.array(Image.open(lena_path))
147+
148+
# Apply skeletonization operation to a binary image
149+
output = skeletonize_image(gray_to_binary(rgb_to_gray(lena)))
150+
151+
# Save the output image
152+
pil_img = Image.fromarray(output).convert("RGB")
153+
pil_img.save("result_skeleton.png")

0 commit comments

Comments
 (0)