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>
141 lines
5.4 KiB
Python
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
|