"""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 ) # Zones des 7 cases SE 1 / 2 / 3 / 4 / ATU / FFM / FSD (page recueil, en bas). # TODO : recalibrer avec des vrais cas positifs — sur 18 dossiers de # l'échantillon 2018, aucune case n'est cochée (`ghs_injustifie = 0` partout) # donc impossible de valider visuellement la détection. Laissé désactivé. GHS_INJUSTIFIE_CHECKBOXES: dict[str, tuple[float, float, float, float]] = { # placeholder — à recalibrer quand un cas positif sera observé } 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 parse_ghs_injustifie(raw: str) -> str: """Extrait la valeur 0/1 du champ ghs_injustifie depuis la sortie OCR brute. Qwen tend à recopier le libellé complet `0 SE 1 2 3 4 ATU FFM FSD` au lieu du seul chiffre. On prend le premier caractère qui est 0 ou 1 et on ignore le reste (les chiffres 1/2/3/4 qui suivent « SE » sont des numéros de case, pas la valeur du flag). """ if raw is None: return "" s = str(raw).strip() if not s: return "" # Si déjà propre (juste "0" ou "1"), retour direct if s in ("0", "1"): return s # Prendre le premier chiffre trouvé qui soit 0 ou 1, en ignorant tout # le reste (en particulier les "SE 1 2 3 4…" qui suivent) import re as _re m = _re.match(r"\s*([01])\b", s) if m: return m.group(1) return "" # illisible / format inattendu 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), }