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