Spaces:
Runtime error
Runtime error
| """Image preprocessing utilities for the OCR pipeline. | |
| All functions operate on OpenCV-style ndarrays. ``preprocess_image`` always | |
| returns a 3-channel BGR uint8 image so that PaddleOCR accepts it directly. | |
| """ | |
| import cv2 | |
| import numpy as np | |
| def estimate_skew_angle(gray: np.ndarray) -> float: | |
| """Estimate the page skew angle (degrees) of a 2D uint8 grayscale image. | |
| Uses the minimum-area rectangle enclosing the foreground (dark) pixels and | |
| the OpenCV >= 4.5 angle convention (0, 90]. Clamped to ``[-15, 15]``; returns | |
| ``0.0`` when there isn't enough foreground to estimate reliably. | |
| """ | |
| if gray is None or gray.ndim != 2: | |
| return 0.0 | |
| if gray.dtype != np.uint8: | |
| gray = gray.astype(np.uint8) | |
| h, w = gray.shape[:2] | |
| if h == 0 or w == 0: | |
| return 0.0 | |
| # Otsu on the inverted image: dark glyph pixels become nonzero coordinates. | |
| _, thresh = cv2.threshold( | |
| gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU | |
| ) | |
| coords = cv2.findNonZero(thresh) | |
| if coords is None or len(coords) < 10: | |
| return 0.0 | |
| angle = cv2.minAreaRect(coords)[-1] | |
| # OpenCV >= 4.5 returns the angle in (0, 90]; map to a small signed | |
| # deviation from horizontal in (-45, 45]. | |
| if angle > 45: | |
| angle -= 90.0 | |
| return float(max(-15.0, min(15.0, angle))) | |
| def _rotate(gray: np.ndarray, angle: float) -> np.ndarray: | |
| """Rotate a 2D grayscale image by ``angle`` degrees, white-filling borders.""" | |
| h, w = gray.shape[:2] | |
| rot_mat = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0) | |
| return cv2.warpAffine( | |
| gray, rot_mat, (w, h), | |
| flags=cv2.INTER_CUBIC, | |
| borderMode=cv2.BORDER_CONSTANT, | |
| borderValue=255, | |
| ) | |
| def deskew(gray: np.ndarray) -> np.ndarray: | |
| """Estimate and correct small page skew on a 2D uint8 grayscale image. | |
| Only small angles are corrected (estimate clamped to ``[-15, 15]``) to avoid | |
| catastrophic rotations on noisy inputs. Rotation fills exposed borders white. | |
| """ | |
| if gray is None or gray.ndim != 2: | |
| raise ValueError("deskew expects a 2D grayscale uint8 array") | |
| if gray.dtype != np.uint8: | |
| gray = gray.astype(np.uint8) | |
| if gray.shape[0] == 0 or gray.shape[1] == 0: | |
| return gray | |
| angle = estimate_skew_angle(gray) | |
| if abs(angle) < 0.1: | |
| return gray | |
| return _rotate(gray, angle) | |
| def _sauvola_binarize(gray: np.ndarray) -> np.ndarray: | |
| """Binarize a grayscale image with Sauvola's local thresholding. | |
| Sauvola adapts the threshold per-pixel using the local mean and standard | |
| deviation, which handles uneven lighting and faint text far better than a | |
| single global or simple adaptive-Gaussian threshold β the usual choice for | |
| document OCR. Falls back to adaptive-Gaussian if scikit-image is missing. | |
| """ | |
| try: | |
| from skimage.filters import threshold_sauvola | |
| # window_size must be odd; 25 suits body text at ~300 DPI. | |
| thresh = threshold_sauvola(gray, window_size=25, k=0.2) | |
| binary = (gray > thresh).astype(np.uint8) * 255 | |
| return binary | |
| except Exception: | |
| return cv2.adaptiveThreshold( | |
| gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, | |
| cv2.THRESH_BINARY, blockSize=31, C=15, | |
| ) | |
| def preprocess_image( | |
| img_bgr: np.ndarray, *, mode: str, binarize: bool | |
| ) -> np.ndarray: | |
| """Preprocess a BGR image for OCR and return a 3-channel BGR uint8 image. | |
| Steps (in order): | |
| 1. Convert to grayscale. | |
| 2. Deskew (small-angle correction with white border). | |
| 3. CLAHE contrast equalization (rescues faint / unevenly-lit scans). | |
| 4. Denoise via ``cv2.fastNlMeansDenoising`` ONLY when ``mode == "max"``. | |
| 5. Sauvola binarization ONLY when ``binarize`` is True. | |
| 6. Pad a white border so text touching the page edge is still detected. | |
| The result is always converted back to 3-channel BGR so PaddleOCR accepts | |
| it; this function never returns a 2D array. | |
| Parameters | |
| ---------- | |
| img_bgr: | |
| HxWx3 BGR uint8 image. | |
| mode: | |
| ``"max"`` or ``"fast"``. | |
| binarize: | |
| Whether to apply Sauvola binarization. | |
| """ | |
| if img_bgr is None: | |
| raise ValueError("preprocess_image received None") | |
| if img_bgr.dtype != np.uint8: | |
| img_bgr = img_bgr.astype(np.uint8) | |
| # 1. Grayscale. | |
| if img_bgr.ndim == 2: | |
| gray = img_bgr | |
| elif img_bgr.ndim == 3 and img_bgr.shape[2] == 3: | |
| gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) | |
| elif img_bgr.ndim == 3 and img_bgr.shape[2] == 4: | |
| gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGRA2GRAY) | |
| else: | |
| raise ValueError("preprocess_image expects a BGR(A) or grayscale image") | |
| # 2. Deskew β estimate the angle once so we can both correct it and decide | |
| # whether sharpening is safe below. | |
| skew = estimate_skew_angle(gray) | |
| if abs(skew) >= 0.1: | |
| gray = _rotate(gray, skew) | |
| # 3. CLAHE β local contrast equalization. Mild clip so clean renders are | |
| # barely touched while faint/grey scans get a real lift. | |
| clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) | |
| gray = clahe.apply(gray) | |
| # 4. Denoise (only in max mode β it is the slow step). | |
| if mode == "max": | |
| gray = cv2.fastNlMeansDenoising(gray, h=10) | |
| # 4b. Unsharp-mask sharpening to crisp thin strokes β improves glyph | |
| # separation (e.g. capital "I" vs lowercase "l") on soft scans, which | |
| # measurably helped a real ID-card scan. ONLY when the page is near-flat: | |
| # on a heavily-skewed page, deskew's rotation leaves interpolation blur | |
| # that sharpening would amplify, hurting recognition. Benchmarked: gated | |
| # this way it helps flat scans with no regression on rotated pages. | |
| if abs(skew) < 4.0: | |
| _blur = cv2.GaussianBlur(gray, (0, 0), 3) | |
| gray = cv2.addWeighted(gray, 1.5, _blur, -0.5, 0) | |
| # 5. Sauvola binarize (only when requested). | |
| if binarize: | |
| gray = _sauvola_binarize(gray) | |
| # 6. White border padding so edge-touching text isn't clipped by detection. | |
| gray = cv2.copyMakeBorder( | |
| gray, 16, 16, 16, 16, cv2.BORDER_CONSTANT, value=255 | |
| ) | |
| # Always return 3-channel BGR uint8. | |
| bgr = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) | |
| return bgr | |