PDF-Extractor / pipeline /preprocess.py
Archie0099's picture
Add PDF extractor app and Dockerfile
b9d34d8 verified
Raw
History Blame Contribute Delete
6.32 kB
"""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