| import cv2 |
| import numpy as np |
| from typing import Union |
|
|
|
|
| class Preprocessor: |
| """Stage 0: Image preprocessing for BOM engineering drawings.""" |
|
|
| def load_image(self, path: str) -> np.ndarray: |
| """Load image from path and return as grayscale uint8 array. |
| |
| Args: |
| path: File path to image (PNG, JPG, TIFF supported). |
| |
| Returns: |
| Grayscale numpy array (uint8, 0-255). |
| |
| Raises: |
| ValueError: If image cannot be read. |
| """ |
| img = cv2.imread(path, cv2.IMREAD_GRAYSCALE) |
| if img is None: |
| raise ValueError(f"Cannot read image: {path}") |
| return img |
|
|
| def binarize(self, img: np.ndarray, method: str = "adaptive") -> np.ndarray: |
| """Binarize grayscale image. |
| |
| Args: |
| img: Grayscale numpy array. |
| method: "adaptive" | "otsu" | "none" |
| |
| Returns: |
| Binary image with pixel values 0 or 255. |
| """ |
| if method == "adaptive": |
| return cv2.adaptiveThreshold( |
| img, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, |
| cv2.THRESH_BINARY, blockSize=15, C=4 |
| ) |
| elif method == "otsu": |
| _, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) |
| return binary |
| elif method == "none": |
| if len(img.shape) == 3: |
| return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
| return img |
| else: |
| raise ValueError(f"Unknown binarize method: {method}. Use 'adaptive', 'otsu', or 'none'.") |
|
|
| def denoise(self, img: np.ndarray, kernel_size: int = 2) -> np.ndarray: |
| """Apply morphological closing to fill broken thin lines. |
| |
| Args: |
| img: Binary or grayscale image. |
| kernel_size: Size of the morphological kernel (keep small to preserve thin lines). |
| |
| Returns: |
| Denoised image. |
| """ |
| kernel = cv2.getStructuringElement( |
| cv2.MORPH_ELLIPSE, (kernel_size, kernel_size) |
| ) |
| return cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel) |
|
|
| def resize_if_needed(self, img: np.ndarray, max_dim: int = 4096) -> np.ndarray: |
| """Resize image if its largest dimension exceeds max_dim, preserving aspect ratio. |
| |
| Args: |
| img: Input image. |
| max_dim: Maximum allowed dimension in pixels. |
| |
| Returns: |
| Resized image (or original if already within bounds). |
| """ |
| h, w = img.shape[:2] |
| largest = max(h, w) |
| if largest <= max_dim: |
| return img |
| scale = max_dim / largest |
| new_w = int(w * scale) |
| new_h = int(h * scale) |
| return cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA) |
|
|
| def suppress_text_noise(self, img: np.ndarray, max_area: int = 180) -> np.ndarray: |
| """Remove small isolated strokes (text labels) from a binary image. |
| |
| Uses connected-component analysis to erase blobs that are small and |
| elongated — the typical signature of text characters — while preserving |
| larger, rounder component symbols. |
| |
| Args: |
| img: Binary image (white background, black strokes). |
| max_area: Components with pixel area below this threshold AND |
| aspect ratio > 1.3 are treated as text and erased. |
| |
| Returns: |
| Binary image with text-like blobs replaced by white. |
| """ |
| inv = cv2.bitwise_not(img) |
| n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(inv, connectivity=8) |
| result = img.copy() |
| for lbl in range(1, n_labels): |
| area = int(stats[lbl, cv2.CC_STAT_AREA]) |
| w = int(stats[lbl, cv2.CC_STAT_WIDTH]) |
| h = int(stats[lbl, cv2.CC_STAT_HEIGHT]) |
| if w == 0 or h == 0: |
| continue |
| aspect = max(w, h) / min(w, h) |
| if area < max_area and aspect > 1.3: |
| result[labels == lbl] = 255 |
| return result |
|
|
| def dilate_strokes(self, img: np.ndarray, kernel_size: int = 5) -> np.ndarray: |
| """Thicken black strokes in a binary image by eroding (strokes are black on white bg). |
| |
| Useful when template uses thin stylized lines but drawing uses bold strokes. |
| |
| Args: |
| img: Binary image (white background, black strokes). |
| kernel_size: Size of dilation kernel. |
| |
| Returns: |
| Image with thickened strokes. |
| """ |
| kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) |
| return cv2.erode(img, kernel) |
|
|
| def clahe_enhance( |
| self, img: np.ndarray, clip_limit: float = 2.0, tile_size: int = 8 |
| ) -> np.ndarray: |
| """Apply CLAHE (Contrast Limited Adaptive Histogram Equalization). |
| |
| Improves local contrast in drawings with uneven line density — faint |
| strokes that are washed out globally become visible after CLAHE. |
| Applied to grayscale images BEFORE binarization. |
| |
| Args: |
| img: Grayscale uint8 image. |
| clip_limit: Threshold for contrast limiting (higher = stronger). |
| tile_size: Grid size in pixels for local histogram computation. |
| |
| Returns: |
| Contrast-enhanced grayscale image. |
| """ |
| clahe = cv2.createCLAHE( |
| clipLimit=clip_limit, tileGridSize=(tile_size, tile_size) |
| ) |
| return clahe.apply(img.astype(np.uint8)) |
|
|
| def normalize_strokes( |
| self, img: np.ndarray, target_width: int = 2 |
| ) -> np.ndarray: |
| """Normalize stroke width via thinning + uniform dilation. |
| |
| Engineering drawings may scan at different DPIs, producing thick or |
| thin lines. This normalizes all strokes to `target_width` pixels: |
| 1. Morphological thinning (iterative erosion to approximate skeleton) |
| 2. Dilation to the desired target width |
| |
| This makes NCC matching invariant to line-width variation between the |
| template and the drawing, which is a common source of false negatives. |
| |
| Args: |
| img: Binary image (white background, black strokes). |
| target_width: Desired uniform stroke width in pixels. |
| |
| Returns: |
| Binary image with normalized stroke width. |
| """ |
| inv = cv2.bitwise_not(img) |
|
|
| |
| |
| kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3)) |
| prev = np.zeros_like(inv) |
| for _ in range(20): |
| eroded = cv2.erode(inv, kernel) |
| temp = cv2.dilate(eroded, kernel) |
| diff = cv2.subtract(inv, temp) |
| skeleton = cv2.bitwise_or(prev, diff) |
| inv = eroded.copy() |
| prev = skeleton.copy() |
| if cv2.countNonZero(inv) == 0: |
| break |
|
|
| |
| if target_width > 1: |
| kw = target_width * 2 - 1 |
| dk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kw, kw)) |
| skeleton = cv2.dilate(skeleton, dk) |
|
|
| return cv2.bitwise_not(skeleton) |
|
|
| def preprocess( |
| self, |
| img_or_path: Union[str, np.ndarray], |
| binarize_method: str = "adaptive", |
| denoise: bool = True, |
| dilate_strokes: int = 0, |
| clahe: bool = False, |
| normalize_stroke_width: int = 0, |
| ) -> dict: |
| """Full preprocessing pipeline. |
| |
| Args: |
| img_or_path: File path string or numpy array. |
| binarize_method: Binarization method passed to self.binarize(). |
| denoise: Whether to apply morphological denoising. |
| |
| Returns: |
| Dict with keys: |
| "original": original grayscale numpy array |
| "processed": preprocessed numpy array |
| "scale_factor": downscale ratio applied (1.0 if no resize) |
| """ |
| if isinstance(img_or_path, str): |
| original = self.load_image(img_or_path) |
| elif isinstance(img_or_path, np.ndarray): |
| if len(img_or_path.shape) == 3: |
| original = cv2.cvtColor(img_or_path, cv2.COLOR_RGB2GRAY) |
| else: |
| original = img_or_path.copy() |
| else: |
| raise ValueError("img_or_path must be a file path string or numpy array.") |
|
|
| resized = self.resize_if_needed(original) |
| h_orig, w_orig = original.shape[:2] |
| h_res, w_res = resized.shape[:2] |
| scale_factor = h_res / h_orig if h_orig > 0 else 1.0 |
|
|
| |
| to_binarize = self.clahe_enhance(resized) if clahe else resized |
|
|
| processed = self.binarize(to_binarize, method=binarize_method) |
| if denoise: |
| processed = self.denoise(processed) |
| if dilate_strokes > 0: |
| processed = self.dilate_strokes(processed, kernel_size=dilate_strokes) |
| if normalize_stroke_width > 0: |
| processed = self.normalize_strokes(processed, target_width=normalize_stroke_width) |
|
|
| return { |
| "original": original, |
| "processed": processed, |
| "scale_factor": scale_factor, |
| } |
|
|