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)