"""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))