Skip to content

Commit 16cc7f8

Browse files
committed
✨ Introduced pruning morphological operation
1 parent cb6464a commit 16cc7f8

File tree

2 files changed

+189
-1
lines changed

2 files changed

+189
-1
lines changed
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# @Author: @joydipb01
2+
# @File: pruning_operation.py
3+
# @Time: 2025-10-03 19:45 IST
4+
5+
from pathlib import Path
6+
7+
import numpy as np
8+
from PIL import Image
9+
10+
11+
def rgb_to_gray(rgb: np.ndarray) -> np.ndarray:
12+
"""
13+
Return gray image from rgb image
14+
15+
>>> rgb_to_gray(np.array([[[127, 255, 0]]]))
16+
array([[187.6453]])
17+
>>> rgb_to_gray(np.array([[[0, 0, 0]]]))
18+
array([[0.]])
19+
>>> rgb_to_gray(np.array([[[2, 4, 1]]]))
20+
array([[3.0598]])
21+
>>> rgb_to_gray(np.array([[[26, 255, 14], [5, 147, 20], [1, 200, 0]]]))
22+
array([[159.0524, 90.0635, 117.6989]])
23+
"""
24+
r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
25+
return 0.2989 * r + 0.5870 * g + 0.1140 * b
26+
27+
28+
def gray_to_binary(gray: np.ndarray) -> np.ndarray:
29+
"""
30+
Return binary image from gray image
31+
32+
>>> gray_to_binary(np.array([[127, 255, 0]]))
33+
array([[False, True, False]])
34+
>>> gray_to_binary(np.array([[0]]))
35+
array([[False]])
36+
>>> gray_to_binary(np.array([[26.2409, 4.9315, 1.4729]]))
37+
array([[False, False, False]])
38+
>>> gray_to_binary(np.array([[26, 255, 14], [5, 147, 20], [1, 200, 0]]))
39+
array([[False, True, False],
40+
[False, True, False],
41+
[False, True, False]])
42+
"""
43+
return (gray > 127) & (gray <= 255)
44+
45+
46+
def neighbours(image: np.ndarray, x: int, y: int) -> list:
47+
"""
48+
Return 8-neighbours of point (x, y), in clockwise order
49+
50+
>>> neighbours(
51+
... np.array(
52+
... [
53+
... [True, True, False],
54+
... [True, False, False],
55+
... [False, True, False]
56+
... ]
57+
... ), 1, 1
58+
... )
59+
[np.True_, np.False_, np.False_, np.False_, np.True_, np.False_, np.True_, np.True_]
60+
>>> neighbours(
61+
... np.array(
62+
... [
63+
... [True, True, False, True],
64+
... [True, False, False, True],
65+
... [False, True, False, True]
66+
... ]
67+
... ), 1, 2
68+
... )
69+
[np.False_, np.True_, np.True_, np.True_, np.False_, np.True_, np.False_, np.True_]
70+
"""
71+
img = image
72+
73+
neighborhood = [
74+
(-1, 0),
75+
(-1, 1),
76+
(0, 1),
77+
(1, 1),
78+
(1, 0),
79+
(1, -1),
80+
(0, -1),
81+
(-1, -1),
82+
]
83+
84+
neighbour_points = []
85+
86+
for dx, dy in neighborhood:
87+
if 0 <= x + dx < img.shape[0] and 0 <= y + dy < img.shape[1]:
88+
neighbour_points.append(img[x + dx][y + dy])
89+
else:
90+
neighbour_points.append(False)
91+
92+
return neighbour_points
93+
94+
95+
def is_endpoint(image: np.ndarray, x: int, y: int) -> bool:
96+
"""
97+
Check if a pixel is an endpoint based on its 8-neighbors.
98+
99+
An endpoint is defined as a pixel that has exactly one neighboring pixel
100+
that is part of the foreground (True).
101+
102+
>>> is_endpoint(
103+
... np.array(
104+
... [
105+
... [True, True, False],
106+
... [True, False, False],
107+
... [False, True, False]
108+
... ]
109+
... ), 1, 1
110+
... )
111+
False
112+
>>> is_endpoint(
113+
... np.array(
114+
... [
115+
... [True, True, False, True],
116+
... [True, False, False, True],
117+
... [False, True, False, True]
118+
... ]
119+
... ), 2, 3
120+
... )
121+
True
122+
"""
123+
img = image
124+
return int(sum(neighbours(img, x, y))) == 1
125+
126+
127+
def prune_skeletonized_image(
128+
image: np.ndarray, spur_branch_length: int = 50
129+
) -> np.ndarray:
130+
"""
131+
Return pruned image by removing spurious branches of specified length
132+
133+
>>> arr = np.array([
134+
... [False, True, False],
135+
... [False, True, False],
136+
... [False, True, True]
137+
... ])
138+
>>> prune_skeletonized_image(arr, spur_branch_length=1)
139+
array([[False, True, False],
140+
[False, True, False],
141+
[False, True, True]])
142+
>>> arr2 = np.array([
143+
... [False, False, False, False],
144+
... [False, True, True, False],
145+
... [False, False, False, False]
146+
... ])
147+
>>> prune_skeletonized_image(arr2, spur_branch_length=1)
148+
array([[False, False, False, False],
149+
[False, False, False, False],
150+
[False, False, False, False]])
151+
>>> arr3 = np.array([
152+
... [False, True, False],
153+
... [False, True, False],
154+
... [False, True, False]
155+
... ])
156+
>>> prune_skeletonized_image(arr3, spur_branch_length=2)
157+
array([[False, True, False],
158+
[False, True, False],
159+
[False, True, False]])
160+
"""
161+
img = image.copy()
162+
rows, cols = img.shape
163+
164+
for _ in range(spur_branch_length):
165+
endpoints = []
166+
167+
for i in range(1, rows - 1):
168+
for j in range(1, cols - 1):
169+
if img[i][j] and is_endpoint(img, i, j):
170+
endpoints.append((i, j))
171+
for x, y in endpoints:
172+
img[x][y] = False
173+
return img
174+
175+
176+
if __name__ == "__main__":
177+
# Read original (skeletonized) image
178+
skeleton_lena_path = (
179+
Path(__file__).resolve().parent.parent / "image_data" / "skeleton_lena.png"
180+
)
181+
skeleton_lena = np.array(Image.open(skeleton_lena_path))
182+
183+
# Apply pruning operation to a skeletonized image
184+
output = prune_skeletonized_image(gray_to_binary(rgb_to_gray(skeleton_lena)))
185+
186+
# Save the output image
187+
pil_img = Image.fromarray(output).convert("RGB")
188+
pil_img.save("result_pruned.png")

digital_image_processing/morphological_operations/skeletonization_operation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ def skeletonize_image(image: np.ndarray) -> np.ndarray:
203203
lena = np.array(Image.open(lena_path))
204204

205205
# Apply skeletonization operation to a binary image
206-
# Caution: Takes at least 20 seconds to execute
206+
# Caution: Takes at least 30 seconds to execute
207207
output = skeletonize_image(gray_to_binary(rgb_to_gray(lena)))
208208

209209
# Save the output image

0 commit comments

Comments
 (0)