feat(pipeline): extraction OGC via Qwen2.5-VL-3B

Pipeline modulaire remplaçant le monolithe extract_ogc.py (conservé
en legacy pour comparaison).

Modules :
- ingest.py      : PDF → PNG 300dpi avec cache par SHA256
- ocr_qwen.py    : wrapper singleton Qwen2.5-VL-3B (bfloat16, ~7 Go VRAM)
- ocr_glm.py     : wrapper GLM-OCR 0.9B (alternatif, conservé)
- classify.py    : détection type de page + routing par index standard
                   (ordre des 6 pages OGC → -50% d'appels OCR)
- prompts.py     : JSON schemas par type (recueil, concertation 1/2/2/2,
                   preuves) + mots-clés de classification
- checkboxes.py  : détection Accord/Désaccord par densité de pixels
                   (inner-frac 0.35, 17/17 corrects sur échantillon vérifié ;
                   GLM-OCR et Qwen échouent sur les checkboxes, cf.
                   scratch/test_prompt_crop_v2.py)
- extract.py     : orchestration 1 dossier (ingest → classify → OCR →
                   parse JSON tolérant aux boucles + validation ATIH)
- persist.py     : sauvegarde JSON + metadata (pipeline_version,
                   ocr_model, timestamp)
- cli.py         : `python -m pipeline.cli <pdf|dir>`

Temps mesuré : ~35s/dossier (6 pages) sur RTX 5070.

Qwen2.5-VL-3B retenu après comparaison avec GLM-OCR 0.9B, GOT-OCR2.0,
Surya, PaddleOCR (cf. scratch/). Il extrait correctement dp_libelle,
praticien_conseil et les 4 GHM/GHS là où les autres échouent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-24 15:05:40 +02:00
parent ddebd8dfbf
commit ed4d9bd765
10 changed files with 704 additions and 0 deletions

87
pipeline/checkboxes.py Normal file
View File

@@ -0,0 +1,87 @@
"""Détection de cases à cocher par analyse de densité de pixels sombres.
GLM-OCR ne sait pas distinguer une case cochée d'une case vide (A/B test, cf.
test_prompt_crop_v2.py). On reprend l'approche déterministe du pipeline legacy :
cropper la case à coord relative, compter les pixels sombres, comparer les
densités entre les deux cases pour trancher.
"""
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from PIL import Image
DARK_THRESHOLD = 128 # pixels < 128 (0-255) comptés comme sombres
AMBIGU_MARGIN = 0.01 # différence minimale de densité pour trancher
# (calibré pour couvrir OGC 69 où diff=+0.010)
INNER_FRAC = 0.35 # on exclut 35% de marge pour ignorer le cadre et
# n'analyser que le centre de la case (calibré sur
# OGC 7/27/55/86 : 4/4 corrects)
@dataclass
class CheckboxZones:
"""Coordonnées relatives (x1,y1,x2,y2) dans [0,1] des deux cases."""
accord: tuple[float, float, float, float]
desaccord: tuple[float, float, float, float]
# Zones englobant la case entière (cadre compris). On utilisera INNER_FRAC
# pour n'analyser que le centre et ignorer le cadre.
RECUEIL_ACCORD_DESACCORD = CheckboxZones(
accord= (0.588, 0.838, 0.622, 0.860),
desaccord= (0.588, 0.858, 0.622, 0.880),
)
# Zones pour la page concertation_2 (maintien / retour / autre groupage)
# À calibrer si besoin plus tard
CONCERTATION_2_DECISION = CheckboxZones(
accord= (0.035, 0.270, 0.060, 0.290), # maintien avis contrôleur
desaccord= (0.280, 0.270, 0.305, 0.290), # retour groupage DIM
)
def dark_ratio(image: Image.Image, zone: tuple[float, float, float, float],
inner_frac: float = INNER_FRAC) -> float:
"""Fraction de pixels sombres au centre de la zone (cadre exclu).
On crope la zone puis on retire une marge de `inner_frac` de chaque côté
pour ne garder que le contenu central (croix éventuelle).
"""
w, h = image.size
x1, y1, x2, y2 = zone
px1, py1 = int(x1*w), int(y1*h)
px2, py2 = int(x2*w), int(y2*h)
dx = int((px2 - px1) * inner_frac)
dy = int((py2 - py1) * inner_frac)
crop = image.crop((px1 + dx, py1 + dy, px2 - dx, py2 - dy))
gray = np.array(crop.convert("L"))
return float(np.mean(gray < DARK_THRESHOLD))
def detect_accord_desaccord(
image_path: str | Path,
zones: CheckboxZones = RECUEIL_ACCORD_DESACCORD,
) -> dict:
"""Retourne la décision et les ratios bruts pour debug/audit.
Convention : "accord" si la case Accord est plus remplie, "désaccord" si
la case Désaccord est plus remplie, "ambigu" si l'écart est trop faible.
"""
img = Image.open(image_path)
r_acc = dark_ratio(img, zones.accord)
r_des = dark_ratio(img, zones.desaccord)
diff = r_acc - r_des
if abs(diff) < AMBIGU_MARGIN:
decision = "ambigu"
elif diff > 0:
decision = "accord"
else:
decision = "désaccord"
return {
"decision": decision,
"ratio_accord": round(r_acc, 4),
"ratio_desaccord": round(r_des, 4),
"diff": round(diff, 4),
}