Skip to content

Commit 88f286a

Browse files
committed
Improve canny edge detector documentation
1 parent 791deb4 commit 88f286a

1 file changed

Lines changed: 291 additions & 0 deletions

File tree

computer_vision/CannyEdge.py

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
"""
2+
Canny Edge Detector - It is used to identify edges in images.
3+
Implementation of the Canny Edge Detection algorithm using NumPy.
4+
5+
https://en.wikipedia.org/wiki/Canny_edge_detector
6+
"""
7+
8+
from math import pi
9+
10+
import cv2
11+
import numpy as np
12+
13+
14+
def grayscale(image: np.ndarray) -> np.ndarray:
15+
"""
16+
To convert RGB -> grayscale using luminance weights.
17+
"""
18+
return np.dot(image[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8)
19+
#gray = 0.299R+0.587G+0.114B
20+
#np.uint8 = converts values to 8-bit integers(0-255)
21+
22+
23+
def gaussian_kernel(kernel_size: int = 5, sigma: float = 1.4) -> np.ndarray:
24+
"""
25+
To generate a Gaussian kernel.
26+
A typical 5*5 sized matrix with standard deviation as 1.4
27+
- gaussian_kernel(3).shape - (3, 3)
28+
"""
29+
if kernel_size % 2 == 0:
30+
raise ValueError("kernel's size must be odd")
31+
#as we need a center
32+
33+
center = kernel_size // 2
34+
35+
x, y = np.mgrid[-center : center + 1, -center : center + 1]
36+
#assigns weights, center gets largest while farther pixels get smaller values.
37+
kernel = (1 / (2 * pi * sigma**2)) * np.exp(
38+
-((x**2 + y**2) / (2 * sigma**2))
39+
)
40+
41+
return kernel / kernel.sum()
42+
43+
44+
def convolve(image: np.ndarray, kernel: np.ndarray) -> np.ndarray:
45+
"""
46+
Apply convolution to image.
47+
48+
- image = np.ones((5, 5))
49+
- kernel = np.ones((3, 3))
50+
- convolve(image, kernel).shape -> (5, 5)
51+
"""
52+
image_height, image_width = image.shape
53+
kernel_size = kernel.shape[0]
54+
padding = kernel_size // 2
55+
56+
padded_image = np.pad(image, padding, mode="constant")
57+
#convolution near borders needs neighbors
58+
output = np.zeros_like(image, dtype=np.float64)
59+
60+
for row in range(image_height):
61+
for column in range(image_width):
62+
region = padded_image[row : row + kernel_size,column : column + kernel_size]
63+
64+
output[row, column] = np.sum(region * kernel)
65+
#multiply and add
66+
return output
67+
68+
69+
def gaussian_blur(image: np.ndarray,kernel_size: int = 5,sigma: float = 1.4,) -> np.ndarray:
70+
"""
71+
Blurring image using Gaussian filter.
72+
73+
- image = np.ones((5, 5))
74+
- gaussian_blur(image).shape
75+
(5, 5)
76+
"""
77+
kernel = gaussian_kernel(kernel_size, sigma)
78+
79+
return convolve(image, kernel)
80+
81+
82+
def sobel_gradients(image: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
83+
"""
84+
Calculate Sobel gradients.
85+
86+
- image = np.ones((5, 5))
87+
- magnitude, direction = sobel_gradients(image)
88+
- magnitude.shape
89+
(5, 5)
90+
"""
91+
sobel_x = np.array(
92+
[
93+
[-1, 0, 1],
94+
[-2, 0, 2],
95+
[-1, 0, 1],
96+
]
97+
)
98+
99+
sobel_y = np.array(
100+
[
101+
[1, 2, 1],
102+
[0, 0, 0],
103+
[-1, -2, -1],
104+
]
105+
)
106+
107+
gradient_x = convolve(image, sobel_x)
108+
gradient_y = convolve(image, sobel_y)
109+
110+
gradient_magnitude = np.hypot(gradient_x, gradient_y)#edge strength
111+
112+
gradient_magnitude = (gradient_magnitude / gradient_magnitude.max()) * 255
113+
#normalize values to 0-255
114+
gradient_direction = np.arctan2(gradient_y, gradient_x)
115+
#computes edge direction angle, just tan^-1(Gy/Gx)
116+
return gradient_magnitude, gradient_direction
117+
118+
119+
def non_maximum_suppression(magnitude: np.ndarray,direction: np.ndarray,) -> np.ndarray:
120+
"""
121+
Suppress non-maximum gradient values.
122+
123+
- image = np.ones((5, 5))
124+
- direction = np.zeros((5, 5))
125+
- non_maximum_suppression(image, direction).shape
126+
(5, 5)
127+
"""
128+
image_height, image_width = magnitude.shape
129+
130+
suppressed = np.zeros((image_height, image_width), dtype=np.float64)
131+
132+
angle = direction * 180.0 / np.pi
133+
angle[angle < 0] += 180
134+
135+
for row in range(1, image_height - 1):
136+
for column in range(1, image_width - 1):
137+
neighbor_1 = 255
138+
neighbor_2 = 255
139+
140+
current_angle = angle[row, column]
141+
142+
if (0 <= current_angle < 22.5 or 157.5 <= current_angle <= 180):
143+
neighbor_1 = magnitude[row, column + 1]
144+
neighbor_2 = magnitude[row, column - 1]
145+
146+
elif 22.5 <= current_angle < 67.5:
147+
neighbor_1 = magnitude[row + 1, column - 1]
148+
neighbor_2 = magnitude[row - 1, column + 1]
149+
150+
elif 67.5 <= current_angle < 112.5:
151+
neighbor_1 = magnitude[row + 1, column]
152+
neighbor_2 = magnitude[row - 1, column]
153+
154+
elif 112.5 <= current_angle < 157.5:
155+
neighbor_1 = magnitude[row - 1, column - 1]
156+
neighbor_2 = magnitude[row + 1, column + 1]
157+
158+
if (magnitude[row, column] >= neighbor_1 and magnitude[row, column] >= neighbor_2):
159+
suppressed[row, column] = magnitude[row, column]
160+
161+
return suppressed
162+
163+
164+
def double_threshold(image: np.ndarray,low_threshold_ratio: float = 0.05,high_threshold_ratio: float = 0.15,) -> tuple[np.ndarray, int, int]:
165+
"""
166+
Apply double thresholding.
167+
To separate strong edges from weak edges.
168+
- image = np.array([[100, 200]])
169+
- thresholded, weak, strong = double_threshold(image)
170+
- thresholded.shape -> (1, 2)
171+
"""
172+
if low_threshold_ratio >= high_threshold_ratio:
173+
raise ValueError(
174+
"low_threshold_ratio must be smaller than high_threshold_ratio"
175+
)
176+
177+
high_threshold = image.max() * high_threshold_ratio
178+
low_threshold = high_threshold * low_threshold_ratio
179+
180+
image_height, image_width = image.shape
181+
182+
result = np.zeros((image_height, image_width), dtype=np.uint8)
183+
184+
weak = 75
185+
strong = 255
186+
187+
strong_row, strong_column = np.where(image >= high_threshold)
188+
189+
weak_row, weak_column = np.where(
190+
(image >= low_threshold) & (image < high_threshold)
191+
)
192+
193+
result[strong_row, strong_column] = strong
194+
result[weak_row, weak_column] = weak
195+
196+
return result, weak, strong
197+
198+
199+
def hysteresis(image: np.ndarray,weak: int,strong: int = 255,) -> np.ndarray:
200+
"""
201+
Track edges using hysteresis.
202+
203+
- image = np.array([[255, 75]])
204+
- hysteresis(image, 75).shape -> (1, 2)
205+
"""
206+
image_height, image_width = image.shape
207+
208+
for row in range(1, image_height - 1):
209+
for column in range(1, image_width - 1):
210+
if image[row, column] == weak:
211+
if (
212+
(image[row + 1, column - 1] == strong)
213+
or (image[row + 1, column] == strong)
214+
or (image[row + 1, column + 1] == strong)
215+
or (image[row, column - 1] == strong)
216+
or (image[row, column + 1] == strong)
217+
or (image[row - 1, column - 1] == strong)
218+
or (image[row - 1, column] == strong)
219+
or (image[row - 1, column + 1] == strong)
220+
):
221+
image[row, column] = strong
222+
else:
223+
image[row, column] = 0
224+
225+
return image
226+
227+
228+
class CannyEdgeDetector:
229+
"""
230+
Canny Edge Detector implementation.
231+
"""
232+
233+
def __init__(self,kernel_size: int = 5,sigma: float = 1.4,low_threshold_ratio: float = 0.05,high_threshold_ratio: float = 0.15,) -> None:
234+
self.kernel_size = kernel_size
235+
self.sigma = sigma
236+
self.low_threshold_ratio = low_threshold_ratio
237+
self.high_threshold_ratio = high_threshold_ratio
238+
239+
def detect(self, image_path: str) -> np.ndarray:
240+
"""
241+
Detect edges in image.
242+
243+
Args:
244+
image_path: Path to image
245+
246+
Returns:
247+
Edge detected image
248+
"""
249+
image = cv2.imread(image_path)
250+
251+
if image is None:
252+
raise ValueError(f"Unable to read image at {image_path}")
253+
254+
gray_image = grayscale(image)
255+
256+
blurred_image = gaussian_blur(
257+
gray_image,
258+
self.kernel_size,
259+
self.sigma,
260+
)
261+
262+
gradient_magnitude, gradient_direction = sobel_gradients(
263+
blurred_image
264+
)
265+
266+
suppressed_image = non_maximum_suppression(
267+
gradient_magnitude,
268+
gradient_direction,
269+
)
270+
271+
thresholded_image, weak, strong = double_threshold(
272+
suppressed_image,
273+
self.low_threshold_ratio,
274+
self.high_threshold_ratio,
275+
)
276+
277+
final_image = hysteresis(
278+
thresholded_image,
279+
weak,
280+
strong,
281+
)
282+
283+
return final_image.astype(np.uint8)
284+
285+
286+
if __name__ == "__main__":
287+
detector = CannyEdgeDetector()
288+
289+
detected_edges = detector.detect("Screenshot 2026-05-11 065624.png")
290+
291+
cv2.imwrite("canny_edges.jpg", detected_edges)

0 commit comments

Comments
 (0)