Files
Aivanov_scan_ogc/pipeline/checkboxes.py
Dom 7d45018139 feat(extract): normaliser ghs_injustifie en 0/1 (P2)
Qwen renvoie typiquement le libellé complet `0 SE 1 2 3 4 ATU FFM FSD`
dans le champ ghs_injustifie alors qu'une seule valeur 0/1 est attendue.
Ajout de `pipeline.checkboxes.parse_ghs_injustifie` qui extrait le
premier chiffre 0/1 via regex, ou "" si illisible.

Post-traitement appliqué à chaque extraction recueil et aux 18 JSONs
V2 existants (10 fichiers corrigés en place — les 8 autres avaient
déjà ghs_injustifie absent ou vide).

Note sur les 7 cases SE1-4/ATU/FFM/FSD : zones trop petites pour être
calibrées à l'œil et aucun cas positif (`ghs_injustifie=1`) dans
l'échantillon 2018 pour valider visuellement. La détection est en
placeholder, à recalibrer sur un cas positif réel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:54:16 +02:00

121 lines
4.4 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
)
# 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),
}