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>
This commit is contained in:
140
tests/test_deskew.py
Normal file
140
tests/test_deskew.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user