Files
Aivanov_scan_ogc/pipeline/deskew.py
Dom 6c8184cc03 feat(deskew): correction automatique du skew au chargement des PDFs
Nouveau module pipeline/deskew.py basé sur cv2.HoughLinesP :
- détecte les lignes quasi-horizontales (±15° de l'horizontale)
- prend la médiane de leurs angles (robuste aux outliers)
- seuils : |angle|>0.3° pour corriger, |angle|>10° = suspect (on ne corrige pas)
- PIL.rotate() avec BICUBIC + fillcolor blanc, sans expand

Intégré dans pipeline/ingest.py (paramètre `deskew=True` par défaut).
L'angle appliqué est tracé dans un fichier `page_XX.skew` à côté de
l'image, pour audit.

Mesuré sur les 18 dossiers de l'échantillon 2018 CARC : seule OGC 1 a
un skew au-dessus du seuil (+0.91°), les 17 autres sont déjà droits.
Le deskew corrige OGC 1 en 0.00° résiduel (vérif visuelle en-tête OK).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:07:29 +02:00

126 lines
4.4 KiB
Python

"""Détection d'angle de skew + redressement automatique des pages scannées.
Technique : Hough Transform sur les lignes détectées par Canny, puis moyenne
des angles des lignes « quasi horizontales » (±15° par rapport à l'horizontale).
Les fiches OGC ont énormément de traits de tableau → signal très fort.
Seuil : on ne corrige que si |angle| > `MIN_ANGLE_DEG` (0.3° par défaut) pour
éviter de toucher les scans déjà bien cadrés et introduire du bruit inutile.
"""
from __future__ import annotations
from pathlib import Path
from typing import Tuple
import numpy as np
from PIL import Image
try:
import cv2 # type: ignore
_HAS_CV2 = True
except ImportError:
_HAS_CV2 = False
MIN_ANGLE_DEG = 0.3 # en-dessous, on ne corrige pas
MAX_ANGLE_DEG = 10.0 # au-dessus, c'est anormal → suspect, on ne corrige pas
NEAR_HORIZONTAL_BAND = 15.0 # degrés : bande autour de l'horizontale pour filtrer
def detect_skew_angle(img: Image.Image) -> float:
"""Retourne l'angle de skew en degrés (positif = tourné dans le sens
des aiguilles d'une montre) à appliquer pour redresser l'image.
Si aucune ligne horizontale n'est trouvée, retourne 0.0.
Si l'angle détecté est hors [-MAX_ANGLE_DEG, +MAX_ANGLE_DEG], retourne 0.0
(probablement une erreur de détection, on ne corrige pas).
"""
if not _HAS_CV2:
return 0.0
gray = np.array(img.convert("L"))
# Réduire l'image pour accélérer (max 1500 px de large)
h, w = gray.shape
if w > 1500:
scale = 1500 / w
gray = cv2.resize(gray, (1500, int(h * scale)), interpolation=cv2.INTER_AREA)
# Canny edges — paramètres standards documents
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
# Hough Lines probabiliste : rapide et robuste
lines = cv2.HoughLinesP(
edges, rho=1, theta=np.pi / 180, threshold=200,
minLineLength=gray.shape[1] // 4, # au moins 25% de la largeur
maxLineGap=20,
)
if lines is None or len(lines) == 0:
return 0.0
# Calculer l'angle de chaque ligne en degrés
angles = []
for line in lines:
x1, y1, x2, y2 = line[0]
if x2 == x1:
continue # ligne verticale, ignorée
angle = np.degrees(np.arctan2(y2 - y1, x2 - x1))
# On ne garde que les lignes proches de l'horizontale
if abs(angle) < NEAR_HORIZONTAL_BAND:
angles.append(angle)
if not angles:
return 0.0
# Moyenne robuste : médiane plutôt que mean, moins sensible aux outliers
angle = float(np.median(angles))
if abs(angle) > MAX_ANGLE_DEG:
return 0.0 # suspect → on ne corrige pas
return angle
def deskew_image(img: Image.Image,
angle: float | None = None,
min_angle: float = MIN_ANGLE_DEG) -> Tuple[Image.Image, float]:
"""Redresse une image si le skew détecté dépasse `min_angle`.
Retourne (image_eventuellement_rotee, angle_applique).
Si |angle| < min_angle, retourne l'image inchangée et angle=0.0.
"""
if angle is None:
angle = detect_skew_angle(img)
if abs(angle) < min_angle:
return img, 0.0
# PIL.Image.rotate : positive angle = counter-clockwise
# detect_skew retourne positif = clockwise → on inverse pour PIL
rotated = img.rotate(
angle,
resample=Image.Resampling.BICUBIC,
expand=False,
fillcolor="white",
)
return rotated, angle
def deskew_file(src: Path, dst: Path | None = None,
min_angle: float = MIN_ANGLE_DEG) -> float:
"""Version fichier → fichier. Écrase `src` si `dst` est None.
Retourne l'angle appliqué (0.0 si pas de rotation)."""
img = Image.open(src)
rotated, angle = deskew_image(img, min_angle=min_angle)
out = dst or src
rotated.save(out, "PNG", optimize=True)
return angle
if __name__ == "__main__":
import sys
import glob
paths = [Path(p) for p in (sys.argv[1:] or sorted(glob.glob(".cache/images/*/page_01.png")))]
print(f"Deskew sur {len(paths)} images (seuil={MIN_ANGLE_DEG}°)...")
total_corrected = 0
for p in paths:
angle = detect_skew_angle(Image.open(p))
mark = "" if abs(angle) >= MIN_ANGLE_DEG else "·"
if abs(angle) >= MIN_ANGLE_DEG:
total_corrected += 1
print(f" {mark} {p} : {angle:+.2f}°")
print(f"\n{total_corrected}/{len(paths)} images auraient besoin d'un redressement.")