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