ocr-layoutxlm-backend / src /preprocessing /image_preprocessor.py
Goubaa
feat: backend FastAPI OCR+LayoutXLM — model loaded from HF Hub
63874d7
Raw
History Blame Contribute Delete
14.2 kB
"""
Pipeline de prétraitement d'images pour OCR multi-langue.
Étapes : PDF/Image → Normalisation → Niveaux de gris →
Débruitage → Deskewing → Binarisation adaptative
Optimisé pour documents contenant du texte arabe cursif,
français et anglais — photos de qualité variable acceptées.
"""
import io
import logging
from pathlib import Path
from typing import Union, Optional
import cv2
import numpy as np
from PIL import Image
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Point d'entrée principal
# ─────────────────────────────────────────────────────────────────────────────
def preprocess_for_ocr(
file_input,
dpi: int = 300,
page_index: int = 0,
denoise: bool = True,
deskew: bool = True,
binarize: bool = True,
return_pil: bool = True,
) -> Union[Image.Image, np.ndarray]:
"""
Pipeline complet de prétraitement : charge le fichier puis applique
niveaux de gris → débruitage → correction d'inclinaison → binarisation.
Args:
file_input : UploadedFile Streamlit, chemin (str/Path), bytes ou PIL.Image.
dpi : Résolution pour la conversion PDF (300 recommandé pour l'OCR).
page_index : Numéro de page à traiter (PDF multi-pages).
denoise : Activer la réduction de bruit.
deskew : Activer la correction automatique d'inclinaison.
binarize : Activer la binarisation adaptative.
return_pil : True → retourne PIL.Image ; False → numpy uint8.
Returns:
Image prétraitée prête pour docTR / PaddleOCR / EasyOCR.
"""
# ── Chargement ────────────────────────────────────────────────────────────
pages = _load_document(file_input, dpi=dpi)
img = pages[min(page_index, len(pages) - 1)].copy()
# ── 1. Normalisation de la résolution (upscale si trop petite) ───────────
img = _normalize_resolution(img, min_width=1200)
# ── 2. Niveaux de gris ───────────────────────────────────────────────────
gray = _to_grayscale(img)
# ── 3. Débruitage (paramètres conservateurs pour l'arabe cursif) ─────────
if denoise:
gray = _denoise(gray)
# ── 4. Correction d'inclinaison ──────────────────────────────────────────
if deskew:
gray = _deskew(gray)
# ── 5. Binarisation adaptative ───────────────────────────────────────────
result = _binarize(gray) if binarize else gray
return _cv2_to_pil(result) if return_pil else result
def get_preview_pair(
file_input,
dpi: int = 300,
page_index: int = 0,
) -> tuple:
"""
Retourne (original_pil, preprocessed_pil) pour affichage côte à côte.
Args:
file_input : Source du fichier.
dpi : Résolution DPI pour PDF.
page_index : Page à utiliser.
Returns:
(PIL.Image original, PIL.Image prétraitée)
"""
pages = _load_document(file_input, dpi=dpi)
raw = pages[min(page_index, len(pages) - 1)]
original_pil = _cv2_to_pil(raw)
preprocessed_pil = preprocess_for_ocr(
file_input, dpi=dpi, page_index=page_index, return_pil=True
)
return original_pil, preprocessed_pil
# ─────────────────────────────────────────────────────────────────────────────
# Chargement multi-format
# ─────────────────────────────────────────────────────────────────────────────
def _load_document(file_input, dpi: int = 300) -> list:
"""
Charge un document et retourne une liste de pages (numpy BGR).
Formats supportés : JPEG, PNG, TIFF, BMP, PDF.
"""
# PIL.Image direct
if isinstance(file_input, Image.Image):
return [_pil_to_cv2(file_input)]
# numpy array direct
if isinstance(file_input, np.ndarray):
return [file_input]
# Lire les bytes selon la source
if hasattr(file_input, "read"):
# UploadedFile Streamlit ou file-like object
raw_bytes = file_input.read()
filename = getattr(file_input, "name", "file.bin")
try:
file_input.seek(0) # Rembobiner pour usage ultérieur dans l'app
except Exception:
pass
elif isinstance(file_input, (str, Path)):
filename = str(file_input)
with open(file_input, "rb") as f:
raw_bytes = f.read()
elif isinstance(file_input, bytes):
raw_bytes = file_input
filename = "file.bin"
else:
raise TypeError(f"Type d'entrée non supporté : {type(file_input)}")
ext = Path(filename).suffix.lower()
# ── PDF → images haute résolution ─────────────────────────────────────────
if ext == ".pdf":
try:
from pdf2image import convert_from_bytes
pil_pages = convert_from_bytes(raw_bytes, dpi=dpi)
return [_pil_to_cv2(p) for p in pil_pages]
except ImportError:
raise ImportError(
"pdf2image est requis pour les PDF.\n"
"Installez-le avec : pip install pdf2image\n"
"Sous Windows, téléchargez aussi Poppler : "
"https://github.com/oschwartz10612/poppler-windows/releases"
)
# ── Image standard (JPEG, PNG, TIFF, BMP…) ────────────────────────────────
arr = np.frombuffer(raw_bytes, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
raise ValueError(
f"Impossible de décoder l'image '{filename}'. "
"Vérifiez que le fichier n'est pas corrompu."
)
return [img]
# ─────────────────────────────────────────────────────────────────────────────
# Étapes du pipeline
# ─────────────────────────────────────────────────────────────────────────────
def _normalize_resolution(
img: np.ndarray,
min_width: int = 1400,
max_width: int = 2400,
) -> np.ndarray:
"""
Maintient la largeur de l'image dans [min_width, max_width].
- Upscale si trop petite (< min_width) : améliore la précision OCR.
· LANCZOS4 si scale > 1.5 (meilleure conservation des arêtes de chars)
· CUBIC sinon (plus rapide, suffisant pour facteur faible)
- Downscale si trop grande (> max_width) : réduit la RAM et le temps
de traitement sur CPU — INTER_AREA est optimal pour réduire.
Seuils relevés à 1400/2400 px (vs 1000/1800) pour réduire le CER
sur les factures avec petits caractères (numéros de ligne, montants).
Consommation mémoire : ≈ 280 Mo max sur CPU 8 Go.
"""
h, w = img.shape[:2]
if w < min_width:
scale = min_width / w
interp = cv2.INTER_LANCZOS4 if scale > 1.5 else cv2.INTER_CUBIC
img = cv2.resize(
img,
(int(w * scale), int(h * scale)),
interpolation=interp,
)
elif w > max_width:
scale = max_width / w
img = cv2.resize(
img,
(int(w * scale), int(h * scale)),
interpolation=cv2.INTER_AREA,
)
return img
def _to_grayscale(img: np.ndarray) -> np.ndarray:
"""Convertit BGR → niveaux de gris (no-op si déjà en gris)."""
if len(img.shape) == 2:
return img
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
def _denoise(gray: np.ndarray) -> np.ndarray:
"""
Réduction du bruit par filtre Non-Local Means.
Paramètres conservateurs (h=7) pour préserver :
- Les connexions entre lettres arabes (cursif)
- Les diacritiques (harakat, shadda, tanwin…)
- Les petits détails des caractères français accentués
Un h trop élevé (>15) estompe ces détails et dégrade l'OCR arabe.
"""
return cv2.fastNlMeansDenoising(
gray,
h=7, # Force du filtre — conservateur pour l'arabe
templateWindowSize=7,
searchWindowSize=21,
)
def _deskew(gray: np.ndarray) -> np.ndarray:
"""
Correction automatique de l'inclinaison du document.
Algorithme :
1. Binarisation Otsu temporaire (rapide)
2. Détection des lignes de texte via Hough probabiliste
3. Angle médian des lignes quasi-horizontales
4. Rotation de correction si l'angle est > 0.3° (seuil de tolérance)
Fonctionne pour l'arabe car les lignes de base sont horizontales
quelle que soit la direction d'écriture (RTL).
"""
angle = _estimate_skew_angle(gray)
if abs(angle) < 0.3:
return gray # Inclinaison négligeable → pas de traitement
logger.debug("Deskew : correction de %.2f°", angle)
return _rotate_image(gray, angle)
def _estimate_skew_angle(gray: np.ndarray) -> float:
"""
Estime l'angle d'inclinaison dominant (en degrés).
Retourne 0.0 si aucune ligne exploitable n'est détectée.
"""
# Binarisation grossière pour la détection de contours
_, binary = cv2.threshold(
gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
)
edges = cv2.Canny(binary, 50, 150, apertureSize=3)
# Longueur minimale de ligne = 25 % de la largeur (ignore le bruit)
min_len = gray.shape[1] // 4
lines = cv2.HoughLinesP(
edges,
rho=1,
theta=np.pi / 180,
threshold=80,
minLineLength=min_len,
maxLineGap=20,
)
if lines is None or len(lines) == 0:
return 0.0
angles = []
for line in lines:
x1, y1, x2, y2 = line[0]
dx = x2 - x1
if dx == 0:
continue
angle = np.degrees(np.arctan2(y2 - y1, dx))
# Garder uniquement les lignes proches de l'horizontale (±15°)
if -15.0 < angle < 15.0:
angles.append(angle)
if not angles:
return 0.0
# Médiane pour être robuste face aux lignes aberrantes
return float(np.median(angles))
def _rotate_image(gray: np.ndarray, angle: float) -> np.ndarray:
"""
Rotation autour du centre de l'image, fond blanc.
Les nouvelles dimensions sont recalculées pour éviter le rognage.
"""
h, w = gray.shape[:2]
cx, cy = w // 2, h // 2
M = cv2.getRotationMatrix2D((cx, cy), angle, scale=1.0)
cos_a = abs(M[0, 0])
sin_a = abs(M[0, 1])
new_w = int(h * sin_a + w * cos_a)
new_h = int(h * cos_a + w * sin_a)
# Ajuster la translation pour centrer l'image dans les nouvelles dimensions
M[0, 2] += (new_w - w) / 2
M[1, 2] += (new_h - h) / 2
return cv2.warpAffine(
gray, M, (new_w, new_h),
flags=cv2.INTER_CUBIC,
borderMode=cv2.BORDER_CONSTANT,
borderValue=255, # Fond blanc
)
def _binarize(gray: np.ndarray) -> np.ndarray:
"""
Binarisation adaptative gaussienne.
Avantages sur Otsu global :
- Gère les ombres portées et l'éclairage non uniforme (photos de documents)
- Préserve les zones de texte foncé sur fond clair ET clair sur fond foncé
- blockSize=31 : fenêtre suffisamment grande pour les ombres diffuses
- C=11 : soustraction de constante locale pour affiner le seuil
Pour l'arabe :
- La binarisation adaptative respecte mieux les diacritiques (points,
shadda, tanwin) que la binarisation globale qui les efface parfois.
- Pré-lissage léger (GaussianBlur 3×3) pour éviter l'amplification
du bruit dans les zones uniformes avant seuillage.
"""
# Lissage préalable pour réduire le bruit haute fréquence
blurred = cv2.GaussianBlur(gray, (3, 3), 0)
binary = cv2.adaptiveThreshold(
blurred,
maxValue=255,
adaptiveMethod=cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
thresholdType=cv2.THRESH_BINARY,
blockSize=31,
C=11,
)
return binary
# ─────────────────────────────────────────────────────────────────────────────
# Utilitaires de conversion PIL ↔ OpenCV
# ─────────────────────────────────────────────────────────────────────────────
def _pil_to_cv2(pil_img: Image.Image) -> np.ndarray:
"""PIL.Image (RGB) → numpy BGR pour OpenCV."""
rgb = np.array(pil_img.convert("RGB"))
return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
def _cv2_to_pil(img: np.ndarray) -> Image.Image:
"""numpy BGR ou niveaux de gris → PIL.Image."""
if len(img.shape) == 2:
return Image.fromarray(img, mode="L")
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
return Image.fromarray(rgb)