Spaces:
Runtime error
Runtime error
File size: 6,318 Bytes
b9d34d8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | """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
|