"""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.")