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:
@@ -28,6 +28,13 @@ import streamlit as st
|
||||
from PIL import Image
|
||||
|
||||
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")
|
||||
|
||||
|
||||
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():
|
||||
st.set_page_config(page_title="OGC Overlay", layout="wide")
|
||||
|
||||
@@ -280,6 +411,13 @@ def main():
|
||||
|
||||
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()
|
||||
if not pdfs:
|
||||
st.error(f"Aucun PDF trouvé dans {PDF_DIR}")
|
||||
|
||||
Reference in New Issue
Block a user