"""processing.py — Filter implementations and noise generation.""" import numpy as np import cv2 from skimage.restoration import denoise_nl_means # ── Human-readable descriptions ─────────────────────────────────────────────── FILTER_DESCRIPTIONS = { "Gaussian Blur": ( "Convolves the image with a 2-D Gaussian kernel. " "Each output pixel is a weighted average of its neighbours, " "with closer pixels weighted more heavily. " "Excellent at removing Gaussian noise; blurs edges." ), "Median Filter": ( "Replaces each pixel with the median of its neighbourhood. " "Non-linear — highly effective against salt-and-pepper noise " "because single outliers are outvoted by their neighbours. " "Preserves edges better than Gaussian blur." ), "Bilateral Filter": ( "Like Gaussian blur but adds an intensity-range weight: " "pixels that differ strongly in colour are excluded from the average, " "even if they are spatially close. " "Smooths flat regions while keeping sharp edges intact." ), "Box (Mean) Filter": ( "Simplest filter: each output pixel is the plain average of a square neighbourhood. " "Fast but blurs edges and smears salt-and-pepper noise. " "Useful baseline for comparison." ), "Non-local Means": ( "Compares small image patches across a search window. " "Pixels whose surrounding patch looks similar contribute more to the output. " "State-of-the-art for Gaussian noise; very effective on textures. " "Computationally expensive." ), } # ── Filter dispatcher ───────────────────────────────────────────────────────── def apply_filter(image: np.ndarray, filter_name: str, params: dict) -> np.ndarray: """Apply the selected filter and return the result as uint8 RGB.""" img = image.copy() if img.dtype != np.uint8: img = np.clip(img, 0, 255).astype(np.uint8) if filter_name == "Gaussian Blur": ksize = _odd(params.get("ksize", 7)) sigma = params.get("sigma", 1.5) out = cv2.GaussianBlur(img, (ksize, ksize), sigma) elif filter_name == "Median Filter": ksize = _odd(params.get("ksize", 5)) out = cv2.medianBlur(img, ksize) elif filter_name == "Bilateral Filter": d = params.get("d", 9) sigma_color = params.get("sigma_color", 75) sigma_space = params.get("sigma_space", 75) out = cv2.bilateralFilter(img, d, sigma_color, sigma_space) elif filter_name == "Box (Mean) Filter": ksize = _odd(params.get("ksize", 7)) out = cv2.blur(img, (ksize, ksize)) elif filter_name == "Non-local Means": h = params.get("h", 10) template_size = _odd(params.get("template_size", 7)) search_size = _odd(params.get("search_size", 21)) # cv2 NLM (float32, then back) img_f = img.astype(np.float32) / 255.0 # Use skimage NLM for better results denoised = denoise_nl_means( img_f, h=h / 100.0, patch_size=template_size, patch_distance=search_size // 2, channel_axis=-1, fast_mode=True, ) out = np.clip(denoised * 255, 0, 255).astype(np.uint8) else: raise ValueError(f"Unknown filter: {filter_name}") return out # ── Noise addition ──────────────────────────────────────────────────────────── def add_synthetic_noise(image: np.ndarray, noise_type: str, level: int) -> np.ndarray: """Add synthetic noise to an image and return uint8.""" img = image.astype(np.float32) if noise_type == "Gaussian": sigma = level noise = np.random.normal(0, sigma, img.shape) img = img + noise elif noise_type == "Salt & Pepper": prob = level / 1000.0 # e.g. level=25 → 2.5% corruption mask = np.random.random(img.shape[:2]) img[mask < prob / 2] = 0.0 img[mask > 1 - prob / 2] = 255.0 elif noise_type == "Speckle": noise = np.random.normal(0, level / 100.0, img.shape) img = img + img * noise return np.clip(img, 0, 255).astype(np.uint8) # ── Helpers ─────────────────────────────────────────────────────────────────── def _odd(n: int) -> int: """Ensure n is an odd positive integer (required by OpenCV kernels).""" n = max(3, int(n)) return n if n % 2 == 1 else n + 1