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>
88 lines
3.1 KiB
Python
88 lines
3.1 KiB
Python
"""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),
|
|
}
|