"""OpenCV preprocessing pipeline for raster-to-vector conversion.""" import cv2 import numpy as np def decode_image(raw_bytes: bytes) -> np.ndarray: """Decode raw image bytes into a BGR numpy array.""" buf = np.frombuffer(raw_bytes, dtype=np.uint8) img = cv2.imdecode(buf, cv2.IMREAD_COLOR) if img is None: raise ValueError("Failed to decode image from provided bytes") return img def to_grayscale(img: np.ndarray) -> np.ndarray: """Convert BGR image to single-channel grayscale.""" if len(img.shape) == 2: return img return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) def denoise( img: np.ndarray, d: int = 9, sigma_color: float = 75.0, sigma_space: float = 75.0, ) -> np.ndarray: """Apply bilateral filter for edge-preserving denoising.""" return cv2.bilateralFilter(img, d, sigma_color, sigma_space) def enhance_contrast( img: np.ndarray, clip_limit: float = 2.0, tile_grid_size: tuple[int, int] = (8, 8), ) -> np.ndarray: """Apply CLAHE contrast enhancement.""" clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size) return clahe.apply(img) def threshold( img: np.ndarray, manual_thresh: int | None = None, ) -> np.ndarray: """Apply thresholding — Otsu auto by default, manual override if provided.""" if manual_thresh is not None: _, result = cv2.threshold(img, manual_thresh, 255, cv2.THRESH_BINARY) else: _, result = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) return result def edge_detect( img: np.ndarray, low: int = 50, high: int = 150, ) -> np.ndarray: """Apply Canny edge detection.""" return cv2.Canny(img, low, high) def morphological_ops( img: np.ndarray, kernel_size: int = 3, dilate_iterations: int = 1, erode_iterations: int = 1, ) -> np.ndarray: """Apply dilation then erosion (closing-style) to clean up binary image.""" kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_size, kernel_size)) result = cv2.dilate(img, kernel, iterations=dilate_iterations) result = cv2.erode(result, kernel, iterations=erode_iterations) return result def preprocess( raw_bytes: bytes, params: dict | None = None, ) -> np.ndarray: """Run the full preprocessing pipeline on raw image bytes. Stages: decode → grayscale → denoise → contrast → threshold → morphological ops. Edge detection is optional (enabled via params["edge_detect"] = True). All stage parameters are tunable via the params dict. Keys: denoise_d, denoise_sigma_color, denoise_sigma_space, clahe_clip_limit, clahe_tile_grid_size, threshold_manual, edge_detect (bool), edge_low, edge_high, morph_kernel_size, morph_dilate_iterations, morph_erode_iterations """ p = params or {} img = decode_image(raw_bytes) img = to_grayscale(img) img = denoise( img, d=p.get("denoise_d", 9), sigma_color=p.get("denoise_sigma_color", 75.0), sigma_space=p.get("denoise_sigma_space", 75.0), ) img = enhance_contrast( img, clip_limit=p.get("clahe_clip_limit", 2.0), tile_grid_size=p.get("clahe_tile_grid_size", (8, 8)), ) img = threshold( img, manual_thresh=p.get("threshold_manual"), ) if p.get("edge_detect", False): img = edge_detect( img, low=p.get("edge_low", 50), high=p.get("edge_high", 150), ) img = morphological_ops( img, kernel_size=p.get("morph_kernel_size", 3), dilate_iterations=p.get("morph_dilate_iterations", 1), erode_iterations=p.get("morph_erode_iterations", 1), ) return img