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>
This commit is contained in:
Dom
2026-04-24 23:07:29 +02:00
parent b47f5c47e0
commit 6c8184cc03
2 changed files with 148 additions and 3 deletions

125
pipeline/deskew.py Normal file
View File

@@ -0,0 +1,125 @@
"""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.")

View File

@@ -1,10 +1,16 @@
"""PDF → images PNG 300 dpi avec cache par hash SHA256.""" """PDF → images PNG 300 dpi avec cache par hash SHA256.
Applique optionnellement un deskew automatique (redressement) sur chaque page
pour corriger le biais d'inclinaison des scans. Voir pipeline/deskew.py.
"""
import hashlib import hashlib
import os import os
from pathlib import Path from pathlib import Path
from pdf2image import convert_from_path from pdf2image import convert_from_path
from PIL import Image from PIL import Image
from .deskew import deskew_image, MIN_ANGLE_DEG
DEFAULT_DPI = 300 DEFAULT_DPI = 300
CACHE_ROOT = Path(".cache/images") CACHE_ROOT = Path(".cache/images")
@@ -18,23 +24,37 @@ def pdf_hash(pdf_path: str) -> str:
return h.hexdigest()[:16] return h.hexdigest()[:16]
def pdf_to_images(pdf_path: str, dpi: int = DEFAULT_DPI, cache_root: Path = CACHE_ROOT) -> list[Path]: def pdf_to_images(pdf_path: str, dpi: int = DEFAULT_DPI,
cache_root: Path = CACHE_ROOT,
deskew: bool = True) -> list[Path]:
"""Convertit un PDF en PNG 300 dpi. Retourne la liste des chemins (1 par page). """Convertit un PDF en PNG 300 dpi. Retourne la liste des chemins (1 par page).
Le cache est indexé par hash du PDF : un PDF inchangé n'est jamais reconverti. Le cache est indexé par hash du PDF : un PDF inchangé n'est jamais reconverti.
Avec `deskew=True` (défaut), chaque page est redressée si son angle de skew
dépasse le seuil défini dans `pipeline.deskew.MIN_ANGLE_DEG` (0.3°). L'angle
appliqué est persisté dans un fichier `<page>.skew` à côté (pour audit).
""" """
cache_root = Path(cache_root) cache_root = Path(cache_root)
h = pdf_hash(pdf_path) h = pdf_hash(pdf_path)
out_dir = cache_root / h out_dir = cache_root / h
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
existing = sorted(out_dir.glob("page_*.png")) # Le glob est strict pour ne pas attraper les crops intermédiaires
# (page_XX_recodage.png, etc.)
existing = sorted(p for p in out_dir.glob("page_*.png")
if p.stem.replace("page_", "").isdigit())
if existing: if existing:
return existing return existing
pages = convert_from_path(pdf_path, dpi) pages = convert_from_path(pdf_path, dpi)
paths = [] paths = []
for i, img in enumerate(pages, 1): for i, img in enumerate(pages, 1):
if deskew:
img, applied = deskew_image(img)
if applied != 0.0:
# Trace d'audit : on note l'angle corrigé
(out_dir / f"page_{i:02d}.skew").write_text(f"{applied:.3f}\n")
p = out_dir / f"page_{i:02d}.png" p = out_dir / f"page_{i:02d}.png"
img.save(p, "PNG", optimize=True) img.save(p, "PNG", optimize=True)
paths.append(p) paths.append(p)