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>
126 lines
4.4 KiB
Python
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.")
|