kerf-engine/engine/pipeline/preprocessing.py
jlightner c20d6e55b6 test: Implemented full OpenCV preprocessing pipeline (grayscale, bilate…
- engine/pipeline/preprocessing.py
- engine/tests/test_preprocessing.py

GSD-Task: S01/T02
2026-03-26 04:11:01 +00:00

130 lines
3.7 KiB
Python

"""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