Files
Aivanov_scan_ogc/tests/test_deskew.py
Dom 3a87751444 test: couvrir les modules purs du pipeline (96 nouveaux tests)
Suite de tests unitaires pour tous les modules pipeline qui ne dépendent
pas du VLM — utiles pour garantir la non-régression après refactor et
servir de spec vivante de chaque fonction.

Fichiers :
- tests/test_json_utils.py   (20 tests) : parse_json_output + toutes les
  stratégies de récupération (fences, virgules manquantes, boucles vides,
  fermeture JSON, fallback _raw/_parse_error)
- tests/test_deskew.py       (11 tests) : détection Hough + correction,
  image synthétique + fixtures cache réel
- tests/test_checkboxes.py   (17 tests) : parse_ghs_injustifie,
  dark_ratio, inner_frac, et ground truth visuel sur 17 dossiers
  (mapping hash→OGC résolu au runtime pour éviter les constantes fragiles)
- tests/test_validation.py   (18 tests) : _check_cim10/ccam/ghm/ghs,
  cross-checks GHM↔GHS, annotate sur JSON vide et complet,
  preservation de l'input (copie défensive)
- tests/test_schema.py       (8 tests)  : clean_dossier retire les champs
  debug, préserve les champs métier, compacte la validation, ne modifie
  pas l'input
- tests/test_zones_config.py (8 tests)  : load/save round-trip, merge
  avec defaults, résilience JSON corrompu, get_zone

Total : 107 tests, 5.1 s d'exécution, tous passent. Aucune dépendance
GPU, s'exécutent en CI.

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

141 lines
5.4 KiB
Python

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