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