diff --git a/pipeline/extract.py b/pipeline/extract.py index 2302ba1..8bbde55 100644 --- a/pipeline/extract.py +++ b/pipeline/extract.py @@ -11,7 +11,8 @@ from .prompts import ( PAGE_TYPES, PROMPT_HEADER, 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 @@ -108,6 +109,23 @@ def parse_json_output(raw: str) -> dict | None: 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: """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: img = Image.open(image_path) 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_path = image_path.parent / f"{image_path.stem}_recodage.png" 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 # à cocher (cf. scratch/test_prompt_crop_v2.py). 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["_checkbox_debug"] = cb # ratios + diff pour audit # ghs_injustifie : Qwen renvoie parfois "0 SE 1 2 3 4 ATU FFM FSD" diff --git a/pipeline/ui_overlay.py b/pipeline/ui_overlay.py index f15fb79..005292d 100644 --- a/pipeline/ui_overlay.py +++ b/pipeline/ui_overlay.py @@ -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}") diff --git a/pipeline/zones_config.py b/pipeline/zones_config.py new file mode 100644 index 0000000..85b1b21 --- /dev/null +++ b/pipeline/zones_config.py @@ -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))