feat(ui): calibration visuelle des zones via dessin à la souris

Nouveau module pipeline/zones_config.py : charge les zones d'extraction
depuis un fichier zones_config.json (coordonnées relatives 0-1), avec
fallback sur les constantes Python. Config partagée entre :
- pipeline/extract.py (crop colonne Recodage)
- pipeline/checkboxes.py (cases Accord/Désaccord)

Zones configurables aujourd'hui (page recueil) :
- codage_reco (crop zonal pour le second passage VLM)
- accord_checkbox / desaccord_checkbox (densité de pixels)

Mode "🔧 Calibration zones" ajouté dans pipeline/ui_overlay.py :
- Sélection d'un PDF de référence (idéalement bien cadré)
- Canvas interactif (streamlit-drawable-canvas) avec les zones
  existantes pré-dessinées en rouge
- Dessin/déplacement/redimensionnement à la souris
- Saisie d'un nom et description par zone
- Sauvegarde en JSON (ou OGC_ZONES_CONFIG si défini)

Permet au métier (Khalid) de recalibrer les zones sans toucher au code,
par exemple si le formulaire ATIH évolue ou si les scans sont d'un autre
établissement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-24 23:07:59 +02:00
parent c0b0cd9b87
commit 1255468676
3 changed files with 251 additions and 3 deletions

View File

@@ -11,7 +11,8 @@ from .prompts import (
PAGE_TYPES, PROMPT_HEADER, PAGE_TYPES, PROMPT_HEADER,
SCHEMA_RECUEIL_RECODAGE, RECUEIL_RECODAGE_ZONE, SCHEMA_RECUEIL_RECODAGE, RECUEIL_RECODAGE_ZONE,
) )
from .checkboxes import detect_accord_desaccord, RECUEIL_ACCORD_DESACCORD, parse_ghs_injustifie from .checkboxes import detect_accord_desaccord, RECUEIL_ACCORD_DESACCORD, parse_ghs_injustifie, CheckboxZones
from .zones_config import load_config, get_zone
from .validation import annotate as validate_annotate from .validation import annotate as validate_annotate
@@ -108,6 +109,23 @@ def parse_json_output(raw: str) -> dict | None:
return {"_raw": raw, "_parse_error": str(e)} return {"_raw": raw, "_parse_error": str(e)}
def _recueil_zones() -> tuple[tuple, CheckboxZones]:
"""Charge les zones configurables pour la page recueil.
Retourne (recodage_zone, accord_desaccord_zones). Si la config n'a pas
d'entrée, on retombe sur les constantes compilées.
"""
cfg = load_config()
reco = get_zone("recueil", "codage_reco", cfg) or RECUEIL_RECODAGE_ZONE
acc = get_zone("recueil", "accord_checkbox", cfg)
des = get_zone("recueil", "desaccord_checkbox", cfg)
if acc and des:
cb = CheckboxZones(accord=acc, desaccord=des)
else:
cb = RECUEIL_ACCORD_DESACCORD
return reco, cb
def _extract_recodage_crop(image_path: Path, ocr: QwenVLOCR) -> dict | None: def _extract_recodage_crop(image_path: Path, ocr: QwenVLOCR) -> dict | None:
"""Second passage VLM sur le crop zonal de la colonne Recodage. """Second passage VLM sur le crop zonal de la colonne Recodage.
@@ -122,7 +140,8 @@ def _extract_recodage_crop(image_path: Path, ocr: QwenVLOCR) -> dict | None:
try: try:
img = Image.open(image_path) img = Image.open(image_path)
w, h = img.size w, h = img.size
x1, y1, x2, y2 = RECUEIL_RECODAGE_ZONE reco_zone, _ = _recueil_zones()
x1, y1, x2, y2 = reco_zone
crop = img.crop((int(x1 * w), int(y1 * h), int(x2 * w), int(y2 * h))) crop = img.crop((int(x1 * w), int(y1 * h), int(x2 * w), int(y2 * h)))
crop_path = image_path.parent / f"{image_path.stem}_recodage.png" crop_path = image_path.parent / f"{image_path.stem}_recodage.png"
crop.save(crop_path) crop.save(crop_path)
@@ -267,7 +286,8 @@ def extract_dossier(pdf_path: str | Path, verbose: bool = True,
# sur la fiche recueil. GLM-OCR / Qwen ne lisent pas les cases # sur la fiche recueil. GLM-OCR / Qwen ne lisent pas les cases
# à cocher (cf. scratch/test_prompt_crop_v2.py). # à cocher (cf. scratch/test_prompt_crop_v2.py).
if ptype == "recueil" and isinstance(parsed, dict): if ptype == "recueil" and isinstance(parsed, dict):
cb = detect_accord_desaccord(img_path, RECUEIL_ACCORD_DESACCORD) _, cb_zones = _recueil_zones()
cb = detect_accord_desaccord(img_path, cb_zones)
parsed["accord_desaccord"] = cb["decision"] parsed["accord_desaccord"] = cb["decision"]
parsed["_checkbox_debug"] = cb # ratios + diff pour audit parsed["_checkbox_debug"] = cb # ratios + diff pour audit
# ghs_injustifie : Qwen renvoie parfois "0 SE 1 2 3 4 ATU FFM FSD" # ghs_injustifie : Qwen renvoie parfois "0 SE 1 2 3 4 ATU FFM FSD"

View File

@@ -28,6 +28,13 @@ import streamlit as st
from PIL import Image from PIL import Image
from pipeline.ingest import pdf_to_images from pipeline.ingest import pdf_to_images
from pipeline.zones_config import load_config, save_config, DEFAULT_CONFIG_PATH
try:
from streamlit_drawable_canvas import st_canvas
_HAS_CANVAS = True
except ImportError:
_HAS_CANVAS = False
# ============================================================ # ============================================================
@@ -268,6 +275,130 @@ def render_page_editor(name: str, ptype: str, extract: dict, gold: dict | None):
st.code(page_meta.get("ocr_raw", ""), language="json") st.code(page_meta.get("ocr_raw", ""), language="json")
def render_calibration_page():
"""Mode 'Calibration zones' : dessine des rectangles à la souris sur une
image de référence, sauvegarde dans pipeline/zones_config.json."""
st.header("🔧 Calibration des zones")
if not _HAS_CANVAS:
st.error(
"Le package `streamlit-drawable-canvas` n'est pas installé.\n"
"Installe-le avec : `pip install streamlit-drawable-canvas`"
)
return
pdfs = list_pdfs()
if not pdfs:
st.error("Aucun PDF disponible pour la calibration")
return
col_ctrl, _ = st.columns([1, 3])
with col_ctrl:
ref_name = st.selectbox(
"PDF de référence (bien cadré)",
[p.stem for p in pdfs], key="calib_pdf",
)
page_type = st.selectbox(
"Type de page", ["recueil"],
help="Aujourd'hui seule la page recueil a des zones configurables",
)
# Page numéro selon le type (recueil = page 1)
page_num = {"recueil": 1}.get(page_type, 1)
ref_pdf = next(p for p in pdfs if p.stem == ref_name)
img_path = pdf_to_images(str(ref_pdf))[page_num - 1]
img = Image.open(img_path)
img_w, img_h = img.size
# Charger config existante et préparer les zones
cfg = load_config()
existing_zones = cfg.get(page_type, {})
# On scale l'image pour tenir dans le canvas (largeur ~900 px max)
canvas_w = 900
scale = canvas_w / img_w
canvas_h = int(img_h * scale)
# Préparer les rectangles initiaux depuis la config
initial_rects = []
for zone_name, z in existing_zones.items():
if not isinstance(z, dict): continue
initial_rects.append({
"type": "rect",
"left": z["x1"] * canvas_w,
"top": z["y1"] * canvas_h,
"width": (z["x2"] - z["x1"]) * canvas_w,
"height": (z["y2"] - z["y1"]) * canvas_h,
"fill": "rgba(255, 100, 100, 0.15)",
"stroke": "red",
"strokeWidth": 2,
"label_name": zone_name,
})
st.caption(
"💡 Dessine un rectangle par zone à la souris. Les zones existantes "
"apparaissent déjà pré-dessinées. Tu peux les modifier (drag), "
"en ajouter, ou en supprimer (touche Suppr) puis cliquer sur "
"**Sauvegarder**."
)
drawing_mode = st.radio(
"Mode", ["rect", "transform"], horizontal=True,
format_func=lambda x: {"rect": "✏️ Dessiner", "transform": "🖱 Sélectionner / Déplacer"}[x],
key="calib_drawing_mode",
)
canvas_result = st_canvas(
fill_color="rgba(255, 100, 100, 0.15)",
stroke_width=2,
stroke_color="red",
background_image=img,
update_streamlit=True,
width=canvas_w,
height=canvas_h,
drawing_mode=drawing_mode,
initial_drawing={"objects": initial_rects, "version": "5.2.1"},
key="calib_canvas",
)
# Reconstituer la config à partir des rectangles dessinés
rects = (canvas_result.json_data or {}).get("objects", []) if canvas_result.json_data else []
st.markdown("### Zones détectées")
if not rects:
st.info("Aucun rectangle dessiné.")
return
new_zones = {}
for i, r in enumerate(rects):
if r.get("type") != "rect":
continue
# Récupérer le nom existant si présent, sinon demander
default_name = r.get("label_name") or f"zone_{i+1}"
name = st.text_input(
f"Nom de la zone {i+1}",
value=default_name, key=f"calib_name_{i}",
)
x1 = r["left"] / canvas_w
y1 = r["top"] / canvas_h
x2 = x1 + r["width"] / canvas_w
y2 = y1 + r["height"] / canvas_h
desc = existing_zones.get(name, {}).get("description", "")
desc = st.text_input(
f"Description (optionnel)", value=desc, key=f"calib_desc_{i}",
)
st.caption(f"Coords relatives : ({x1:.3f}, {y1:.3f}) → ({x2:.3f}, {y2:.3f})")
new_zones[name] = {"x1": round(x1, 4), "y1": round(y1, 4),
"x2": round(x2, 4), "y2": round(y2, 4),
"description": desc}
if st.button("💾 Sauvegarder la configuration", type="primary"):
cfg[page_type] = new_zones
path = save_config(cfg)
st.success(f"Configuration sauvegardée : {path}")
st.json(new_zones)
def main(): def main():
st.set_page_config(page_title="OGC Overlay", layout="wide") st.set_page_config(page_title="OGC Overlay", layout="wide")
@@ -280,6 +411,13 @@ def main():
st.title("🩺 Extraction OGC — review & gold set") st.title("🩺 Extraction OGC — review & gold set")
# Sélecteur de mode en haut de sidebar
with st.sidebar:
mode = st.radio("Mode", ["📋 Review dossier", "🔧 Calibration zones"])
if mode == "🔧 Calibration zones":
render_calibration_page()
return
pdfs = list_pdfs() pdfs = list_pdfs()
if not pdfs: if not pdfs:
st.error(f"Aucun PDF trouvé dans {PDF_DIR}") st.error(f"Aucun PDF trouvé dans {PDF_DIR}")

90
pipeline/zones_config.py Normal file
View File

@@ -0,0 +1,90 @@
"""Configuration des zones d'extraction éditable via l'overlay UI.
Les coordonnées sont relatives (0..1) dans l'image source. Elles sont chargées
au démarrage du pipeline et utilisées à la place des constantes en dur dans
`pipeline/prompts.py` et `pipeline/checkboxes.py` — avec fallback sur ces
constantes si la config n'est pas présente, pour ne pas casser l'existant.
Structure :
{
"recueil": {
"codage_reco": {"x1":0.77, "y1":0.330, "x2":0.97, "y2":0.490, "description":"..."},
"accord_checkbox": {"x1":..., "y1":..., "x2":..., "y2":..., "description":"..."},
"desaccord_checkbox":{...}
},
"concertation_2": {...}
}
Un fichier unique `zones_config.json` à la racine du projet, ou au chemin pointé
par la variable d'env `OGC_ZONES_CONFIG`.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
DEFAULT_CONFIG_PATH = Path(
os.environ.get("OGC_ZONES_CONFIG", "zones_config.json")
)
# Zones par défaut, identiques aux constantes actuelles dans prompts.py et
# checkboxes.py. Sert de fallback et de "mise à jour initiale" quand le
# fichier n'existe pas encore.
DEFAULTS: dict = {
"recueil": {
"codage_reco": {
"x1": 0.77, "y1": 0.330, "x2": 0.97, "y2": 0.490,
"description": "Colonne Recodage (DP / DR / DAS) — exclut le bloc Actes",
},
"accord_checkbox": {
"x1": 0.588, "y1": 0.838, "x2": 0.622, "y2": 0.860,
"description": "Case à cocher 'Accord'",
},
"desaccord_checkbox": {
"x1": 0.588, "y1": 0.858, "x2": 0.622, "y2": 0.880,
"description": "Case à cocher 'Désaccord'",
},
},
}
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> dict:
"""Charge la config JSON, ou retourne les defaults si absente."""
if not path.exists():
return _deep_copy(DEFAULTS)
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return _deep_copy(DEFAULTS)
# Merge : les defaults sont une base, la config utilisateur vient par-dessus
merged = _deep_copy(DEFAULTS)
for page, zones in raw.items():
merged.setdefault(page, {}).update(zones)
return merged
def save_config(cfg: dict, path: Path = DEFAULT_CONFIG_PATH) -> Path:
path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def get_zone(page_type: str, zone_name: str,
config: dict | None = None) -> tuple[float, float, float, float] | None:
"""Récupère une zone depuis la config ou les defaults.
Retourne (x1, y1, x2, y2) ou None si inconnue.
"""
cfg = config or load_config()
z = cfg.get(page_type, {}).get(zone_name)
if not isinstance(z, dict):
return None
try:
return (float(z["x1"]), float(z["y1"]), float(z["x2"]), float(z["y2"]))
except (KeyError, ValueError, TypeError):
return None
def _deep_copy(d: dict) -> dict:
return json.loads(json.dumps(d))