Source code for stouputils.data_science.data_processing.image.canny


# Imports
from .common import Any, NDArray, check_image, cv2, np


# Functions
[docs] def canny_image( image: NDArray[Any], threshold1: float, threshold2: float, aperture_size: int = 3, sigma: float = 3, stop_at_nms: bool = False, ignore_dtype: bool = False, ) -> NDArray[Any]: """ Apply Canny edge detection to an image. Args: image (NDArray[Any]): Image to apply Canny edge detection threshold1 (float): First threshold for hysteresis (between 0 and 1) threshold2 (float): Second threshold for hysteresis (between 0 and 1) aperture_size (int): Aperture size for Sobel operator (3, 5, or 7) sigma (float): Standard deviation for Gaussian blur stop_at_nms (bool): Stop at non-maximum suppression step (don't apply thresholding) ignore_dtype (bool): Ignore the dtype check Returns: NDArray[Any]: Image with Canny edge detection applied >>> ## Basic tests >>> image = np.array([[100, 150, 200], [50, 125, 175], [25, 75, 225]]) >>> edges = canny_image(image.astype(np.uint8), 0.1, 0.2) >>> edges.shape == image.shape[:2] # Canny returns single channel True >>> set(np.unique(edges).tolist()) <= {0, 255} # Only contains 0 and 255 True >>> rgb = np.random.randint(0, 256, (3,3,3), dtype=np.uint8) >>> edges_rgb = canny_image(rgb, 0.1, 0.2) >>> edges_rgb.shape == rgb.shape[:2] # Canny returns single channel True >>> ## Test invalid inputs >>> canny_image("not an image", 0.1, 0.2) Traceback (most recent call last): ... AssertionError: Image must be a numpy array >>> canny_image(image.astype(np.uint8), 1.5, 0.2) Traceback (most recent call last): ... AssertionError: threshold1 must be between 0 and 1, got 1.5 """ # Check input data check_image(image, ignore_dtype=ignore_dtype) assert 0 <= threshold1 <= 1, f"threshold1 must be between 0 and 1, got {threshold1}" assert 0 <= threshold2 <= 1, f"threshold2 must be between 0 and 1, got {threshold2}" assert aperture_size in (3, 5, 7), f"aperture_size must be 3, 5 or 7, got {aperture_size}" # Convert to grayscale if needed if len(image.shape) > 2: image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # Convert thresholds to 0-255 range t1: int = int(threshold1 * 255) t2: int = int(threshold2 * 255) if not stop_at_nms: # Apply full Canny edge detection return cv2.Canny(image, t1, t2, apertureSize=aperture_size) else: # Manual implementation up to non-maximum suppression # 1. Apply Gaussian blur to reduce noise blurred: NDArray[Any] = cv2.GaussianBlur(image, (5, 5), sigma) # 2. Calculate gradients using Sobel # Use constant value 6 for 64-bit float depth (CV_64F) gx: NDArray[Any] = cv2.Sobel(blurred, 6, 1, 0, ksize=aperture_size) gy: NDArray[Any] = cv2.Sobel(blurred, 6, 0, 1, ksize=aperture_size) # Calculate gradient magnitude and direction magnitude: NDArray[Any] = np.sqrt(gx**2 + gy**2) direction: NDArray[Any] = np.arctan2(gy, gx) * 180 / np.pi # 3. Non-maximum suppression nms: NDArray[Any] = np.zeros_like(magnitude, dtype=np.uint8) height, width = magnitude.shape for i in range(1, height - 1): for j in range(1, width - 1): # Get gradient direction angle: float = direction[i, j] if angle < 0: angle += 180 # Round to 0, 45, 90, or 135 degrees if (0 <= angle < 22.5) or (157.5 <= angle <= 180): neighbors = [magnitude[i, j-1], magnitude[i, j+1]] elif 22.5 <= angle < 67.5: neighbors = [magnitude[i-1, j-1], magnitude[i+1, j+1]] elif 67.5 <= angle < 112.5: neighbors = [magnitude[i-1, j], magnitude[i+1, j]] else: # 112.5 <= angle < 157.5 neighbors = [magnitude[i-1, j+1], magnitude[i+1, j-1]] # Check if current pixel is local maximum if magnitude[i, j] >= max(neighbors): nms[i, j] = np.uint8(min(magnitude[i, j], 255)) return nms