"""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), }