"""Tests unitaires pour pipeline.deskew. Tests sans dépendance GPU. Génère des images synthétiques en code + utilise les images du cache pour les cas réels. """ from __future__ import annotations import math from pathlib import Path import numpy as np import pytest from PIL import Image from pipeline.deskew import ( MAX_ANGLE_DEG, MIN_ANGLE_DEG, NEAR_HORIZONTAL_BAND, deskew_image, detect_skew_angle, ) # ============================================================ # Helpers : fabriquer une image synthétique avec des lignes # ============================================================ def _make_grid_image(w: int = 800, h: int = 1000, n_lines: int = 30, angle_deg: float = 0.0) -> Image.Image: """Crée une image blanche avec `n_lines` lignes horizontales équi-réparties, optionnellement tournée d'un angle donné. Parfaite pour tester le détecteur. """ arr = np.ones((h, w), dtype=np.uint8) * 255 for i in range(1, n_lines + 1): y = int(i * h / (n_lines + 1)) arr[y - 1:y + 1, 50:w - 50] = 0 # ligne horizontale noire de 2 px img = Image.fromarray(arr, mode="L") if angle_deg != 0.0: # PIL.rotate : angle positif = sens trigonométrique (= anti-horaire) # On veut tester avec notre convention (positif = horaire) donc # on inverse ici pour cohérence avec detect_skew_angle img = img.rotate(-angle_deg, resample=Image.Resampling.BICUBIC, expand=False, fillcolor="white") return img.convert("RGB") # ============================================================ # Tests de détection # ============================================================ class TestDetectSkewAngle: def test_image_parfaitement_droite(self): img = _make_grid_image() angle = detect_skew_angle(img) assert abs(angle) < 0.1, f"image droite doit donner ~0°, got {angle}" @pytest.mark.parametrize("input_angle", [1.0, 2.0, -3.0, 4.0]) def test_detecte_angles_modérés(self, input_angle): """Sur notre image synthétique (30 lignes), la sensibilité est ~1°. Sur de vraies fiches OGC avec 300+ lignes de tableaux, la sensibilité descend à 0.3° (cf. test réel sur OGC 1 : +0.91° détecté). """ img = _make_grid_image(angle_deg=input_angle) detected = detect_skew_angle(img) assert abs(detected - input_angle) < 0.5, \ f"attendu ~{input_angle}°, détecté {detected}°" def test_image_sans_lignes_retourne_zero(self): # Image totalement uniforme → aucune ligne détectable arr = np.ones((500, 500), dtype=np.uint8) * 255 img = Image.fromarray(arr, mode="L").convert("RGB") assert detect_skew_angle(img) == 0.0 def test_angle_extrême_rejeté(self): # Une rotation de 45° dépasse MAX_ANGLE_DEG → on refuse de corriger img = _make_grid_image(angle_deg=45.0) detected = detect_skew_angle(img) # Soit 0.0 (pas de lignes quasi-horizontales à ±15°), soit borné assert abs(detected) < MAX_ANGLE_DEG or detected == 0.0 # ============================================================ # Tests de correction (deskew_image) # ============================================================ class TestDeskewImage: def test_image_droite_inchangée(self): img = _make_grid_image() rotated, applied = deskew_image(img) assert applied == 0.0 # Identité bit à bit assert np.array_equal(np.array(rotated), np.array(img)) def test_image_inclinée_corrigée(self): img = _make_grid_image(angle_deg=2.0) rotated, applied = deskew_image(img) # On attend qu'on applique un angle proche de 2° (convention positive) assert abs(applied) > MIN_ANGLE_DEG, \ f"devrait corriger, got applied={applied}" # Après rotation, l'angle résiduel doit être très faible residual = detect_skew_angle(rotated) assert abs(residual) < 0.5, \ f"angle résiduel trop grand après correction : {residual}°" def test_seuil_min_angle_respecté(self): # Un skew juste sous le seuil ne doit pas être corrigé img = _make_grid_image(angle_deg=MIN_ANGLE_DEG / 2) _, applied = deskew_image(img) assert applied == 0.0 def test_angle_forcé(self): """On peut forcer un angle arbitraire indépendamment de la détection.""" img = _make_grid_image() # droit rotated, applied = deskew_image(img, angle=5.0) assert applied == 5.0 # Taille conservée assert rotated.size == img.size # ============================================================ # Tests avec fixtures réelles (si cache dispo) # ============================================================ class TestOnRealCachedPages: """Ces tests s'exécutent seulement si le cache d'images existe.""" @pytest.fixture def cached_pages(self): paths = sorted(Path(".cache/images").glob("*/page_01.png")) if not paths: pytest.skip("pas de cache d'images disponible") return paths def test_detection_ne_crash_pas(self, cached_pages): """Sur toutes les pages cachées, detect_skew_angle ne doit pas planter.""" for p in cached_pages[:5]: # limite pour la vitesse img = Image.open(p) angle = detect_skew_angle(img) assert isinstance(angle, float) assert abs(angle) <= MAX_ANGLE_DEG