""" Croisement d'une réclamation client avec les photos d'entrée du véhicule. Entrée : - le JSON de détection des pièces visibles par photo (sortie YOLO de détection de pièces), au format : [ { "image_path": "...", "image_size": {"height": ..., "width": ...}, "detected_parts": {"": , ...} }, ... ] Une pièce est considérée "visible" sur une photo si sa clé est présente dans detected_parts (count >= 1). - une phrase client au format quasi fixe, ex: "Le client rapporte des éraflures sur son pare-choc avant" "Le client rapporte un micro-impact en haut de l'aile arrière droite" Pipeline : 1. Un petit LLM texte (Qwen2.5-0.5B-Instruct) extrait {type_dommage, zone} depuis la phrase. On utilise un modèle dédié plus léger que le VLM 3B pour cette tâche purement textuelle (plus rapide sur CPU, et ça évite de mélanger les deux usages dans le même modèle). 2. La zone extraite (en français libre) est mappée vers une entrée de PARTS_LIST (la nomenclature snake_case avec préfixe left_/right_ utilisée par le détecteur de pièces) via un dictionnaire de correspondance. Les zones non couvertes explicitement par la liste (ex: "aile") sont mappées vers la pièce existante la plus proche physiquement. 3. On filtre, parmi les photos d'entrée déjà analysées, celles où cette pièce apparaît dans detected_parts. 4. Sur CHAQUE photo filtrée : a. YOLO (modèle de détection d'objets généraliste, classe "car"/ "truck") localise le véhicule dans l'image et fournit une bbox. On agrandit légèrement cette bbox puis on CROPPE l'image dessus avant de la transmettre au VLM — l'idée est d'éviter de faire analyser par Qwen toute la photo (fond, sol, autres véhicules, etc.) quand seule la voiture nous intéresse. Si YOLO ne détecte aucun véhicule, on retombe sur l'image entière. b. Le crop est redimensionné si nécessaire (plafond de résolution) avant d'être transmis au VLM, pour limiter le nombre de tokens visuels générés par le processor — c'est ce nombre de tokens qui dicte le coût de calcul du VLM, pas la qualité visuelle perçue. c. Qwen2.5-VL-3B (VLM) est interrogé sur ce CROP (redimensionné), spécifiquement sur la présence du dommage signalé (rayure, éraflure, impact, etc.) dans la zone concernée. d. La bbox de dommage renvoyée par le VLM (normalisée dans l'espace de l'image envoyée au VLM, donc après resize) est remappée dans l'espace de l'image ORIGINALE pour l'annotation finale. 5. Agrégation : dès qu'une photo dit "oui" -> résultat global OUI, avec la photo où le dommage a été repéré. Sinon -> résultat global NON, avec la liste des photos qui couvraient la zone (pour vérification à l'œil nu). """ import gc import json import os import re from pathlib import Path from datetime import datetime, timezone import cv2 from google import genai from google.genai import types import torch from PIL import Image, ImageDraw, ImageFont from transformers import AutoModelForCausalLM, AutoTokenizer from ultralytics import YOLO # S'assure que torch utilise bien tous les cœurs disponibles. torch.set_num_threads(os.cpu_count() or 1) TEXT_EXTRACT_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct" TEXT_EXTRACT_MAX_NEW_TOKENS = 80 GEMINI_MODEL_ID = "gemini-3.5-flash" GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") # OPTIM #2 : plafond de résolution ADAPTATIF. # Le plafond est donc calculé en fonction du plus grand côté de l'image # D'ORIGINE : plus l'image source est grande, plus on autorise une résolution finale élevée MAX_VLM_IMAGE_SIDE_FLOOR = 768 # plafond minimum MAX_VLM_IMAGE_SIDE_CEIL = 3000 # plafond maximum MAX_VLM_IMAGE_SIDE_RATIO = 1.00 # Modèle YOLO générique (poids pré-entraînés COCO) utilisé uniquement pour # localiser le véhicule dans la photo et la recadrer avant l'appel au VLM. YOLO_VEHICLE_MODEL_PATH = "yolov8n.pt" YOLO_VEHICLE_CLASS_NAMES = {"car", "truck", "bus", "van"} # Marge ajoutée autour de la bbox détectée par YOLO, en fraction de sa # largeur/hauteur, pour ne pas couper des parties du véhicule (rétro, etc.) YOLO_BBOX_MARGIN_RATIO = 0.08 YOLO_CONF_THRESHOLD = 0.30 # Résolution d'inférence YOLO. On n'a besoin que de localiser grossièrement un véhicule, pas d'une précision pixel-perfect YOLO_INFERENCE_IMGSZ = 416 # ─── PARTS_LIST ────────────────────────────────────────────────────────────── # Nomenclature des pièces telle que produite par le détecteur de pièces PARTS_LIST = [ "hood", "roof", "windshield", "back-windshield", "grille", "license-plate", "front-bumper", "back-bumper", "trunk", "left_front-window", "right_front-window", "left_back-window", "right_back-window", "left_front-wheel", "right_front-wheel", "left_back-wheel", "right_back-wheel", "left_fender", "right_fender", "left_quarter-panel", "right_quarter-panel", "left_front-door", "right_front-door", "left_back-door", "right_back-door", "left_rocker-panel", "right_rocker-panel", "left_headlight", "right_headlight", "left_tail-light", "right_tail-light", "left_mirror", "right_mirror", ] # ─── CHARGEMENT DU MODÈLE QWEN-VL ────── _gemini_client = None def get_gemini_client(): global _gemini_client if _gemini_client is None: if not GEMINI_API_KEY: raise EnvironmentError( "GEMINI_API_KEY non définie. " "Exportez-la avant de lancer le script : export GEMINI_API_KEY=..." ) _gemini_client = genai.Client(api_key=GEMINI_API_KEY) print(f"✅ Client Gemini initialisé ({GEMINI_MODEL_ID})") return _gemini_client def free_gemini_client(): """Gemini est une API distante, pas de mémoire locale à libérer.""" global _gemini_client _gemini_client = None # ─── CHARGEMENT DU MODÈLE YOLO (localisation véhicule pour recadrage) ────── _yolo_model = None def get_yolo_model(): global _yolo_model if _yolo_model is None: _yolo_model = YOLO(YOLO_VEHICLE_MODEL_PATH) return _yolo_model def detect_vehicle_bbox(image_path): """ Détecte le véhicule principal dans l'image avec YOLO et retourne sa bbox élargie [x1, y1, x2, y2] en pixels dans l'espace de l'image ORIGINALE. Retourne None si aucun véhicule n'est détecté (l'appelant doit alors retomber sur l'image entière). Si plusieurs véhicules sont détectés, on garde celui dont la boîte estla plus grande. """ model = get_yolo_model() results = model.predict( source=image_path, conf=YOLO_CONF_THRESHOLD, imgsz=YOLO_INFERENCE_IMGSZ, verbose=False, ) if not results: return None result = results[0] names = result.names best_box = None best_area = 0.0 for box in result.boxes: class_id = int(box.cls.item()) class_name = names.get(class_id, "") if class_name not in YOLO_VEHICLE_CLASS_NAMES: continue x1, y1, x2, y2 = [float(v) for v in box.xyxy[0].tolist()] area = (x2 - x1) * (y2 - y1) if area > best_area: best_area = area best_box = (x1, y1, x2, y2) if best_box is None: return None x1, y1, x2, y2 = best_box img = Image.open(image_path) img_w, img_h = img.size box_w = x2 - x1 box_h = y2 - y1 margin_x = box_w * YOLO_BBOX_MARGIN_RATIO margin_y = box_h * YOLO_BBOX_MARGIN_RATIO x1 = max(0, x1 - margin_x) y1 = max(0, y1 - margin_y) x2 = min(img_w, x2 + margin_x) y2 = min(img_h, y2 + margin_y) return [round(x1), round(y1), round(x2), round(y2)] def crop_image_to_vehicle(image_path): """ Recadre l'image sur le véhicule détecté par YOLO. """ img = Image.open(image_path).convert("RGB") vehicle_bbox = detect_vehicle_bbox(image_path) if vehicle_bbox is None: print(" ⚠ Aucun véhicule détecté par YOLO -> image entière utilisée") return img, None x1, y1, x2, y2 = vehicle_bbox cropped = img.crop((x1, y1, x2, y2)) print(f" ✂ Recadrage YOLO sur le véhicule : bbox={vehicle_bbox} " f"({img.size} -> {cropped.size})") return cropped, vehicle_bbox def resize_for_vlm(img): w, h = img.size longest = max(w, h) target_max_side = max( MAX_VLM_IMAGE_SIDE_FLOOR, min(MAX_VLM_IMAGE_SIDE_CEIL, longest * MAX_VLM_IMAGE_SIDE_RATIO), ) if longest <= target_max_side: return img, 1.0 scale = target_max_side / longest new_w = max(1, round(w * scale)) new_h = max(1, round(h * scale)) resized = img.resize((new_w, new_h), Image.LANCZOS) return resized, scale # Dictionnaire de correspondance zone # Les clés sont des sous-chaînes recherchées dans la zone extraite. # On mappe les zones non couvertes explicitement vers la pièce existante la plus proche physiquement ZONE_FR_TO_PART = { # Pare-chocs (pièce centrale, pas de préfixe gauche/droite) "pare-choc avant": "front-bumper", "pare choc avant": "front-bumper", "pare-choc arriere": "back-bumper", "pare choc arriere": "back-bumper", # Pare-choc(s) SANS précision avant/arrière (ex: "le pare-chocs est # abîmé") -> on ne devine PAS lequel des deux : on couvre les DEUX # (avant ET arrière) plutôt que de tomber sur "zone non spécifiée" # (qui élargirait la vérification à toute la voiture au lieu de la # restreindre aux pare-chocs). Placées après les variantes avant/arrière # ci-dessus : peu importe, le tri par longueur de clé (_ZONE_KEYS_SORTED) # garantit que "pare-choc avant"/"pare-choc arriere" sont essayées avant. "pare-choc": ["front-bumper", "back-bumper"], "pare choc": ["front-bumper", "back-bumper"], "pare-chocs": ["front-bumper", "back-bumper"], "pare chocs": ["front-bumper", "back-bumper"], # Capot / coffre / toit "capot": "hood", "coffre": "trunk", "malle arriere": "trunk", "toit": "roof", # Plaque d'immatriculation / calandre "plaque d'immatriculation": "license-plate", "plaque immatriculation": "license-plate", "calandre": "grille", # Portières "portiere avant gauche": "left_front-door", "porte avant gauche": "left_front-door", "portiere avant droite": "right_front-door", "porte avant droite": "right_front-door", "portiere arriere gauche": "left_back-door", "porte arriere gauche": "left_back-door", "portiere arriere droite": "right_back-door", "porte arriere droite": "right_back-door", # Porte(s)/portière(s) SANS précision de côté NI d'axe avant/arrière # (ex: "une porte est enfoncée") -> comme pour le pare-choc, on ne # devine pas laquelle : on couvre les 4 portes plutôt que d'élargir la # vérification à toute la voiture. "portiere": ["left_front-door", "right_front-door", "left_back-door", "right_back-door"], "porte": ["left_front-door", "right_front-door", "left_back-door", "right_back-door"], # Rétroviseurs "retroviseur gauche": "left_mirror", "retro gauche": "left_mirror", "retroviseur droit": "right_mirror", "retro droit": "right_mirror", # Phares / feux "phare gauche": "left_headlight", "phare avant gauche": "left_headlight", "phare droit": "right_headlight", "phare avant droit": "right_headlight", "feu arriere gauche": "left_tail-light", "feu gauche": "left_tail-light", "feu arriere droit": "right_tail-light", "feu droit": "right_tail-light", # Roues "roue avant gauche": "left_front-wheel", "roue avant droite": "right_front-wheel", "roue arriere gauche": "left_back-wheel", "roue arriere droite": "right_back-wheel", # Vitres / pare-brise "pare-brise avant": "windshield", "pare brise avant": "windshield", "pare-brise": "windshield", "pare brise": "windshield", "pare-brise arriere": "back-windshield", "pare brise arriere": "back-windshield", "lunette arriere": "back-windshield", "vitre avant gauche": "left_front-window", "vitre avant droite": "right_front-window", "vitre arriere gauche": "left_back-window", "vitre arriere droite": "right_back-window", # Bas de caisse "bas de caisse gauche": "left_rocker-panel", "bas de caisse droit": "right_rocker-panel", # Ailes "aile avant gauche": "left_fender", "aile avant droite": "right_fender", "aile arriere gauche": "left_quarter-panel", "aile arriere droite": "right_quarter-panel", "aile gauche": "left_fender", "aile droite": "right_fender", # Précision PARTIELLE : un seul axe mentionné (avant/arrière/gauche/ # droite seul, sans partie plus précise) -> retraitement mail : on # n'élargit PAS à toute la voiture (ça, c'est le cas "aucune précision" # géré par map_zone_to_part -> None), on cible un GROUPE de pièces # cohérent avec l'axe indiqué. Ces clés sont volontairement courtes # donc triées en dernier par _ZONE_KEYS_SORTED : un motif plus précis # ci-dessus (ex: "aile avant gauche") est toujours essayé avant. "avant": "front-bumper", "arriere": "back-bumper", "droite": ["right_fender", "right_front-door", "right_back-door"], "gauche": ["left_fender", "left_front-door", "left_back-door"], } # Tri par longueur de clé décroissante : on veut matcher "aile arriere droite" avant un éventuel motif plus générique qui s'y trouverait inclus. _ZONE_KEYS_SORTED = sorted(ZONE_FR_TO_PART.keys(), key=len, reverse=True) # ─── CORRESPONDANCE CODE PIÈCE (nomenclature CV) -> LIBELLÉ FRANÇAIS ───────── # Utilisé pour afficher un libellé lisible (colonne AppSheet "Localisation # Carrosserie") plutôt que le code technique interne (ex: "front-bumper"). CODE_TO_FR_LABEL = { "front-bumper": "Pare-choc avant", "back-bumper": "Pare-choc arrière", "hood": "Capot", "trunk": "Coffre", "roof": "Toit", "license-plate": "Plaque d'immatriculation", "grille": "Calandre", "windshield": "Pare-brise avant", "back-windshield": "Pare-brise arrière", "left_front-door": "Portière avant gauche", "right_front-door": "Portière avant droite", "left_back-door": "Portière arrière gauche", "right_back-door": "Portière arrière droite", "left_mirror": "Rétroviseur gauche", "right_mirror": "Rétroviseur droit", "left_headlight": "Phare avant gauche", "right_headlight": "Phare avant droit", "left_tail-light": "Feu arrière gauche", "right_tail-light": "Feu arrière droit", "left_front-wheel": "Roue avant gauche", "right_front-wheel": "Roue avant droite", "left_back-wheel": "Roue arrière gauche", "right_back-wheel": "Roue arrière droite", "left_front-window": "Vitre avant gauche", "right_front-window": "Vitre avant droite", "left_back-window": "Vitre arrière gauche", "right_back-window": "Vitre arrière droite", "left_rocker-panel": "Bas de caisse gauche", "right_rocker-panel": "Bas de caisse droit", "left_fender": "Aile avant gauche", "right_fender": "Aile avant droite", "left_quarter-panel": "Aile arrière gauche", "right_quarter-panel": "Aile arrière droite", } EXTRACTION_PROMPT_TEMPLATE = """Tu es un extracteur d'informations strict. On te donne une phrase décrivant une réclamation client sur un véhicule. Extrait UNIQUEMENT deux informations : - type_dommage : le type de dommage signalé (ex: rayure, éraflure, impact, bosse, fissure), en un ou deux mots, en français, au singulier - zone : la zone du véhicule concernée, en français, avec position (avant/arrière) et côté (gauche/droite) si mentionnés Réponds STRICTEMENT en JSON, sur une seule ligne, sans aucun texte avant ou après, au format : {{"type_dommage": "...", "zone": "..."}} Phrase : "{phrase}" JSON :""" DAMAGE_CHECK_PROMPT_TEMPLATE = """You are a strict automotive damage inspector analyzing a vehicle entry photo. The client reported: "{type_dommage}" on the "{zone_en}". You must work in TWO STRICT STEPS. Do not skip ahead to a verdict before finishing Step 1. ─── STEP 1 — LOCATE ────────────────────────────────────────────────────────── Find the "{zone_en}" in this image. Identify the SMALLEST bounding box that tightly contains just that part (not the whole panel, not neighboring parts, not the wheel or wheel well unless they ARE the part in question). Output this box as: locate_box: as fractions of image width/height (e.g. 0.10 0.60 0.45 0.85). All four values between 0.0 and 1.0. ─── STEP 2 — INSPECT ONLY THAT BOX ────────────────────────────────────────── Now mentally zoom into ONLY the region you defined in locate_box. Ignore everything outside it — reflections or marks elsewhere in the image are NOT relevant to this judgment, even if they are visually similar. Before concluding, you MUST rule out these common false positives WITHIN that region only: 1. LIGHT REFLECTIONS — bright streaks, halos, and white lines that follow a light source and change with viewing angle. A real scratch is a continuous, narrow, matte or dark line that does NOT follow a light source. 2. PANEL LINES & BODY CONTOURS — factory edges, door gaps, bumper moldings, and aerodynamic creases. They are sharp, symmetrical, and uniform. 3. DIRT / DUST PATCHES — diffuse, irregular light patches with no defined edge. 4. WET SURFACE ARTIFACTS — water droplets, wet reflections, glare. 5. CAMERA ARTIFACTS — overexposed pixels, lens flare, low-light noise. 6. REFLECTED ENVIRONMENT OBJECTS — sky, windows, doorways, ceiling structures, or other objects reflected in glossy paint appear as light patches whose SHAPE matches a recognizable external object (a window pane, a strip of sky, a light fixture, a doorway) rather than a mark on the paint itself. This is the single most common false positive on dark, glossy panels. Signs it's a reflection, not damage: - edges are soft or follow a gradient, not sharply and irregularly cut - the patch is smooth and uniformly bright/colored (white, pale blue, pale grey) with no internal texture or granularity - the shape resembles a recognizable external structure (rectangle of a window, straight edge of a wall, strip of sky) - there is NO accompanying paint disruption: no matte texture, no scuffing, no exposed primer/plastic color underneath A TRUE damage mark inside that region satisfies ALL of: ✓ Has a defined, irregular edge inconsistent with factory body lines ✓ Is localized to a specific small area within the region ✓ Is NOT aligned with or explained by a visible light source ✓ Is NOT explained by a reflected object (sky, window, structure — see point 6 above) ✓ Appears as material disruption: paint removal, scuffing, deformation, or a matte/dark mark clearly distinct from the surrounding clean paint ✓ Is DULLER or more matte than the surrounding paint, or shows a different material color (grey plastic, primer, dust) — real damage almost never looks brighter, glossier, or smoother than the paint around it. If the candidate mark is brighter, shinier, or more uniform than its surroundings, treat it as a reflection, not damage, regardless of its shape or position. IMPORTANT — reflections and real damage often coexist in the same small area. Their presence together is NOT a reason to answer "unsure". Treat them as separate objects in the same region: - If you can identify a reflection/glare AND a separate mark that meets the criteria above, answer "yes" — the reflection does not invalidate the mark next to it. - If everything you see is fully explained by light/reflection/reflected objects alone, answer "no". - Reserve "unsure" strictly for cases where the region itself cannot be seen clearly (too dark, blurry, occluded, or extreme glare hiding the surface) — not for cases where you can see the surface clearly but feel uncertain about classifying a mark you can see. Before finalizing your answer, ask yourself explicitly: "If I mentally removed all reflections, glare, and reflected objects from this image, would this mark still be visible as a physical disruption of the paint?" If its visibility depends on the viewing angle, the light source, or it matches the shape of something in the surrounding environment, answer "no", not "yes". ─── STEP 3 — PINPOINT THE DAMAGE BOX ──────────────────────────────────────── If damage_visible is "yes", you must now draw a SECOND, much smaller box: damage_box. This box must tightly hug ONLY the visible mark itself (the scuff, scratch, chip, dent) — not the surrounding clean paint, not the whole panel section, not the locate_box area. Hard constraints on damage_box: - damage_box MUST be a strict subset of locate_box (fully contained inside it). - damage_box MUST be SIGNIFICANTLY smaller than locate_box — as a rough guide, its area should be well under 20% of locate_box's area, often much less (a scratch or scuff is usually a thin line or small patch, not a large region). - If there are multiple separate marks, draw damage_box around the single most obvious / clearest one only — do not try to enclose all of them in one large box. - Never output a damage_box that is the same size as, or close in size to, locate_box. If you cannot pinpoint a small region tighter than that, answer "unsure" instead of "yes". ─── ANSWER FORMAT ──────────────────────────────────────────────────────────── Answer in EXACTLY this format, four lines, nothing before, nothing after, no markdown, no bullet points, no extra commentary outside these four lines: locate_box: x1 y1 x2 y2 damage_visible: yes reason: short 5-10 word reason damage_box: x1 y1 x2 y2 EXAMPLE of a correctly formatted answer: locate_box: 0.42 0.55 0.78 0.92 damage_visible: yes reason: small scuff and paint chip on bumper edge damage_box: 0.58 0.68 0.64 0.74 Strict formatting rules for every line: - ALL FOUR numbers on locate_box and damage_box lines are FRACTIONS of image width/height, strictly between 0.0 and 1.0. NEVER pixel coordinates. A value like 612 or 644 is WRONG — these are pixel counts, not fractions, and will be rejected. If you are unsure of the fraction, estimate it as (pixel position) / (image width or height) before writing it down. - Each box line must contain EXACTLY four space-separated decimal numbers between 0.0 and 1.0, and nothing else — no units, no parentheses, no words, no trailing punctuation. - damage_visible must be exactly one of: yes / no / unsure (lowercase, no other words). - reason must be a single line, 5 to 10 words, no line breaks. - If damage_visible is "no" or "unsure", write exactly: damage_box: none""" _text_model = None _text_tokenizer = None # ─── CHARGEMENT DU MODÈLE TEXTE ──────────────────────────────── def get_text_extract_model(): global _text_model, _text_tokenizer if _text_model is None: print(f"⏳ Chargement du modèle d'extraction texte '{TEXT_EXTRACT_MODEL_ID}'…") _text_model = AutoModelForCausalLM.from_pretrained( TEXT_EXTRACT_MODEL_ID, device_map="cpu", ) _text_tokenizer = AutoTokenizer.from_pretrained(TEXT_EXTRACT_MODEL_ID) return _text_model, _text_tokenizer def free_text_extract_model(): """ Libère le petit modèle texte de la mémoire. """ global _text_model, _text_tokenizer if _text_model is not None: del _text_model del _text_tokenizer _text_model = None _text_tokenizer = None gc.collect() print("🧹 Modèle d'extraction texte libéré de la mémoire") # ─── ÉTAPE 1 : EXTRACTION {type_dommage, zone} DEPUIS LA PHRASE CLIENT ────── def extract_damage_report(client_message: str) -> dict: """ Extrait {"type_dommage": ..., "zone": ...} depuis la phrase client via Qwen2.5-0.5B-Instruct. Lève une exception explicite si le JSON renvoyé est invalide, plutôt que de continuer avec des données silencieusement fausses. """ model, tokenizer = get_text_extract_model() prompt = EXTRACTION_PROMPT_TEMPLATE.format(phrase=client_message) messages = [{"role": "user", "content": prompt}] text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, ) inputs = tokenizer(text, return_tensors="pt").to(model.device) with torch.no_grad(): outputs = model.generate( **inputs, max_new_tokens=TEXT_EXTRACT_MAX_NEW_TOKENS, do_sample=False, ) response = tokenizer.decode( outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True, ).strip() # Le modèle peut parfois entourer le JSON de texte ou de balises markdown # malgré la consigne -> on extrait la première accolade ouvrante/fermante. match = re.search(r"\{.*\}", response, re.DOTALL) if not match: raise ValueError( f"Extraction échouée : aucun JSON trouvé dans la réponse du modèle.\n" f"Réponse brute : {response!r}" ) try: parsed = json.loads(match.group(0)) except json.JSONDecodeError as e: raise ValueError(f"Extraction échouée : JSON invalide.\nRéponse brute : {response!r}") from e if "type_dommage" not in parsed or "zone" not in parsed: raise ValueError(f"Extraction échouée : clés manquantes dans le JSON.\nReçu : {parsed!r}") return { "type_dommage": str(parsed["type_dommage"]).strip(), "zone": str(parsed["zone"]).strip(), "raw_response": response, } # ─── ÉTAPE 2 : MAPPING ZONE (FR) -> PARTS_LIST ────────────────────────────── def _normalize_fr(text): """Minuscule + suppression des accents courants, pour un matching robuste.""" text = text.lower().strip() replacements = { "é": "e", "è": "e", "ê": "e", "ë": "e", "à": "a", "â": "a", "î": "i", "ï": "i", "ô": "o", "û": "u", "ù": "u", "ü": "u", "ç": "c", } for src, dst in replacements.items(): text = text.replace(src, dst) return text def map_zone_to_part(zone_fr): """ Mappe une zone en français libre vers une liste de pièces de PARTS_LIST. - Zone précise (ex: "aile avant gauche") -> liste d'une seule pièce. - Zone partiellement précise, un seul axe (ex: "à droite" seul) -> liste de plusieurs pièces (groupe cohérent avec l'axe indiqué). Retourne None si aucune correspondance trouvée (à traiter explicitement par l'appelant, pas de fallback silencieux -> vérification élargie à toute la voiture). """ normalized = _normalize_fr(zone_fr) for key in _ZONE_KEYS_SORTED: if key in normalized: value = ZONE_FR_TO_PART[key] return list(value) if isinstance(value, list) else [value] return None # ─── ÉTAPE 3 : FILTRAGE DES PHOTOS COUVRANT LA ZONE ───────────────────────── def load_vehicle_results(json_path): """ Recharge le JSON produit par le détecteur de pièces. Format attendu : [ {"image_path": ..., "image_size": {...}, "detected_parts": {...}}, ... ] Retourné tel quel (liste de dicts). """ with open(json_path, "r", encoding="utf-8") as f: return json.load(f) def filter_images_covering_part(vehicle_results: list, parts) -> list: """ Retourne la liste des chemins d'images où AU MOINS UNE des pièces de `parts` apparaît dans detected_parts (count >= 1). `parts` accepte une liste (cas courant : précision partielle -> plusieurs pièces à couvrir en OR) ou une simple chaîne (compat rétro). """ if isinstance(parts, str): parts = [parts] matching = [] for entry in vehicle_results: detected_parts = entry.get("detected_parts", {}) if any(detected_parts.get(part, 0) >= 1 for part in parts): matching.append(entry["image_path"]) return matching def get_image_entry(vehicle_results: list, image_path: str) -> dict: """Retrouve l'entrée complète (dict) correspondant à un image_path donné.""" needle = Path(image_path).resolve() for entry in vehicle_results: if Path(entry["image_path"]).resolve() == needle: return entry # Fallback : comparaison directe de chaîne si la résolution de chemin échoue # (ex: chemins relatifs à une racine de données différente de cwd). for entry in vehicle_results: if entry["image_path"] == image_path: return entry return {} # ─── FALLBACK VIDÉO : L'AVANT DU VÉHICULE EST TOUJOURS FILMÉ À L'ENTRÉE ──── # Les vidéos d'entrée cadrent systématiquement l'avant du véhicule. Si la # zone signalée par le client concerne l'avant et qu'aucune photo ne # confirme le dommage, on échantillonne quelques frames des vidéos # disponibles et on les inspecte exactement comme des photos. VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm"} MAX_VIDEO_FRAMES_PER_VIDEO = 3 def extract_video_frames(videos_dir, max_frames_per_video=MAX_VIDEO_FRAMES_PER_VIDEO): """ Échantillonne jusqu'à `max_frames_per_video` frames, répartis uniformément dans la durée, pour chaque vidéo de `videos_dir`, et les sauvegarde en JPEG dans un dossier "video_frames/" à côté de `videos_dir`. Ignore silencieusement les vidéos illisibles (fichier corrompu, codec absent) plutôt que de faire échouer tout le pipeline. """ videos_dir = Path(videos_dir) if not videos_dir.exists(): return [] frames_dir = videos_dir.parent / "video_frames" frames_dir.mkdir(parents=True, exist_ok=True) frame_paths = [] for video_path in sorted(videos_dir.iterdir()): if video_path.suffix.lower() not in VIDEO_EXTENSIONS: continue cap = cv2.VideoCapture(str(video_path)) if not cap.isOpened(): print(f" ⚠ Vidéo illisible, ignorée : {video_path.name}") continue total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) if total_frames <= 0: cap.release() continue n = min(max_frames_per_video, total_frames) indices = [round((i + 1) * total_frames / (n + 1)) for i in range(n)] for idx, frame_idx in enumerate(indices): cap.set(cv2.CAP_PROP_POS_FRAMES, min(frame_idx, total_frames - 1)) ok, frame = cap.read() if not ok: continue out_path = frames_dir / f"{video_path.stem}_frame{idx}.jpg" cv2.imwrite(str(out_path), frame) frame_paths.append(str(out_path)) cap.release() return frame_paths # ─── ÉTAPE 4 : VÉRIFICATION VISUELLE DU DOMMAGE SUR LES PHOTOS FILTRÉES ───── _DAMAGE_LINE_RE = re.compile( r"locate_box\s*:\s*(.+?)\s*[\r\n]+\s*damage_visible\s*:\s*(yes|no|unsure)\s*[\r\n]+\s*reason\s*:\s*(.+?)\s*[\r\n]+\s*damage_box\s*:\s*(.+)", re.IGNORECASE | re.DOTALL, ) _BOX_RE = re.compile(r"([0-9]*\.?[0-9]+)\s+([0-9]*\.?[0-9]+)\s+([0-9]*\.?[0-9]+)\s+([0-9]*\.?[0-9]+)") def _remap_box_crop_to_original(box_norm_crop, vehicle_bbox, crop_size, original_size, vlm_scale=1.0): """ Remappe une bbox normalisée dans l'espace de l'image envoyée AU VLM """ x1n, y1n, x2n, y2n = box_norm_crop crop_w, crop_h = crop_size # Coordonnées en pixels dans l'espace du crop YOLO (avant resize VLM) : # les fractions normalisées s'appliquent directement à crop_size. cx1 = x1n * crop_w cy1 = y1n * crop_h cx2 = x2n * crop_w cy2 = y2n * crop_h if vehicle_bbox is None: # Le "crop" était déjà l'image entière -> crop_size == original_size return [round(cx1), round(cy1), round(cx2), round(cy2)] vx1, vy1, _, _ = vehicle_bbox return [round(vx1 + cx1), round(vy1 + cy1), round(vx1 + cx2), round(vy1 + cy2)] import io def check_damage_on_image(image_path, type_dommage, zone_en): """ Même interface qu'avant. YOLO crop conservé. Appel VLM remplacé par Gemini API. """ client = get_gemini_client() cropped_img, vehicle_bbox = crop_image_to_vehicle(image_path) original_size = Image.open(image_path).size crop_size = cropped_img.size # Gemini accepte des résolutions élevées, on garde quand même un plafond # raisonnable pour limiter les coûts (tokens visuels facturés côté Google). vlm_input_img, vlm_scale = resize_for_vlm(cropped_img) if vlm_scale != 1.0: print(f" 📐 Resize avant Gemini : {cropped_img.size} -> " f"{vlm_input_img.size} (scale={vlm_scale:.3f})") # Conversion PIL -> bytes pour l'API Gemini buf = io.BytesIO() vlm_input_img.save(buf, format="JPEG", quality=90) image_bytes = buf.getvalue() prompt = DAMAGE_CHECK_PROMPT_TEMPLATE.format( type_dommage=type_dommage, zone_en=zone_en, ) # Désactivation des filtres de sécurité sur les catégories non pertinentes # (Gemini peut bloquer des images de dommages auto si les seuils sont trop agressifs) safety_settings = [ types.SafetySetting( category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold=types.HarmBlockThreshold.BLOCK_NONE, ), types.SafetySetting( category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, threshold=types.HarmBlockThreshold.BLOCK_NONE, ), ] try: response = client.models.generate_content( model=GEMINI_MODEL_ID, contents=[ types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg"), prompt, ], config=types.GenerateContentConfig( temperature=0, max_output_tokens=4096, safety_settings=safety_settings, thinking_config=types.ThinkingConfig(thinking_level="medium"), ), ) raw = response.text.strip() except Exception as e: print(f" ❌ Erreur Gemini API : {e!r}") return { "image_path": image_path, "damage_visible": None, "reason": f"Gemini API error: {e!r}", "damage_box_norm": None, "vehicle_bbox": vehicle_bbox, "raw_response": "", "parse_error": True, } # --- Parsing identique à l'original --- match = _DAMAGE_LINE_RE.search(raw) if match is None: finish_reason = response.candidates[0].finish_reason if response.candidates else None print(f" ⚠ parse_error, finish_reason={finish_reason}") if not match: return { "image_path": image_path, "damage_visible": None, "reason": None, "damage_box_norm": None, "vehicle_bbox": vehicle_bbox, "raw_response": raw, "parse_error": True, } locate_box_str, answer, reason, box_str = match.groups() answer_clean = answer.strip().lower() damage_visible = True if answer_clean == "yes" else (False if answer_clean == "no" else None) damage_box_original_px = None if damage_visible is True: box_match = _BOX_RE.search(box_str) if box_match: vals = [float(v) for v in box_match.groups()] # Filet de sécurité : si une valeur dépasse 1.0, c'est probablement # un pixel et non une fraction -> on la renormalise par la taille # de l'image envoyée au VLM (vlm_input_img), x par la largeur, y par # la hauteur. if any(v > 1.0 for v in vals): vw, vh = vlm_input_img.size x1, y1, x2, y2 = vals x1 = x1 / vw if x1 > 1.0 else x1 x2 = x2 / vw if x2 > 1.0 else x2 y1 = y1 / vh if y1 > 1.0 else y1 y2 = y2 / vh if y2 > 1.0 else y2 vals = [x1, y1, x2, y2] print(f" ⚠ damage_box renormalisé (valeurs pixel détectées) -> {vals}") if all(0.0 <= v <= 1.0 for v in vals) and vals[0] < vals[2] and vals[1] < vals[3]: damage_box_original_px = _remap_box_crop_to_original( box_norm_crop=vals, vehicle_bbox=vehicle_bbox, crop_size=crop_size, original_size=original_size, vlm_scale=vlm_scale, ) else: print(f" ⚠ damage_box toujours incohérent après renormalisation : {vals}") return { "image_path": image_path, "damage_visible": damage_visible, "reason": reason.strip(), "locate_box_raw": locate_box_str.strip(), "damage_box_original_px": damage_box_original_px, "vehicle_bbox": vehicle_bbox, "raw_response": raw, "parse_error": False, } def annotate_damage_image(image_path, vehicle_bbox, part, type_dommage, reason, damage_box_original_px, output_dir): """ Annote l'IMAGE ORIGINALE. - Si le VLM a retourné une damage_box (déjà remappée en pixels dans l'espace de l'image originale) : rectangle rouge précis sur la zone endommagée. - Sinon fallback sur la bbox véhicule détectée par YOLO. Dans les deux cas on dessine aussi la bbox véhicule (YOLO) en bleu discret pour le contexte, si disponible. """ img = Image.open(image_path).convert("RGB") img_w, img_h = img.size draw = ImageDraw.Draw(img) # ── Rectangle bleu discret : véhicule (contexte, issu de YOLO) ────────── if vehicle_bbox: vx1, vy1, vx2, vy2 = [round(v) for v in vehicle_bbox] draw.rectangle([vx1, vy1, vx2, vy2], outline=(30, 120, 220), width=2) # ── Rectangle rouge : zone du dommage ──────────────────────────────────── if damage_box_original_px: x1, y1, x2, y2 = damage_box_original_px print(f" bbox dommage (VLM, remappée) : [{x1}, {y1}, {x2}, {y2}]") elif vehicle_bbox: x1, y1, x2, y2 = [round(v) for v in vehicle_bbox] print(f" bbox dommage : fallback bbox véhicule (YOLO)") else: print(f" ⚠ Aucune bbox disponible (ni VLM ni YOLO) — rectangle d'annotation omis") x1, y1, x2, y2 = None, None, None, None if x1 is not None: draw.rectangle([x1, y1, x2, y2], outline=(220, 30, 30), width=4) # ── Labels ─────────────────────────────────────────────────────────────── label = f"⚠ {type_dommage} — {part}" reason_text = f"({reason})" if reason else "" try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 22) font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 17) except IOError: font = font_small = ImageFont.load_default() pad = 6 if x1 is not None: # 1) Mesurer les tailles de texte AVANT de positionner quoi que ce soit label_bbox = draw.textbbox((0, 0), label, font=font) label_w = label_bbox[2] - label_bbox[0] label_h = label_bbox[3] - label_bbox[1] reason_bbox = draw.textbbox((0, 0), reason_text, font=font_small) if reason_text else (0, 0, 0, 0) reason_w = reason_bbox[2] - reason_bbox[0] reason_h = (reason_bbox[3] - reason_bbox[1]) if reason_text else 0 block_w = max(label_w, reason_w) + 2 * pad block_h = label_h + (reason_h + pad if reason_text else 0) + 4 * pad # 2) Centrer horizontalement sur la boîte de dommage, puis clamp box_cx = (x1 + x2) / 2 text_x = box_cx - block_w / 2 text_x = max(pad, min(text_x, img_w - block_w - pad)) # 3) Placer au-dessus si la place le permet, sinon en dessous if y1 - block_h - pad >= 0: text_y = y1 - block_h - pad elif y2 + block_h + pad <= img_h: text_y = y2 + pad else: text_y = pad # dernier recours : coin haut de l'image # 4) Dessiner le fond + texte du label principal lb = draw.textbbox((text_x, text_y), label, font=font) draw.rectangle([lb[0]-pad, lb[1]-pad, lb[2]+pad, lb[3]+pad], fill=(220, 30, 30)) draw.text((text_x, text_y), label, fill="white", font=font) if reason_text: ry = lb[3] + pad + 2 rb = draw.textbbox((text_x, ry), reason_text, font=font_small) draw.rectangle([rb[0]-pad, rb[1]-pad, rb[2]+pad, rb[3]+pad], fill=(60, 60, 60)) draw.text((text_x, ry), reason_text, fill="white", font=font_small) # ── Sauvegarde ─────────────────────────────────────────────────────────── out_dir = Path(output_dir) out_dir.mkdir(parents=True, exist_ok=True) stem = Path(image_path).stem out_path = out_dir / f"{stem}_damage_annotated.jpg" img.save(out_path, quality=92) print(f" 🖼 Image annotée sauvegardée -> {out_path}") return str(out_path) # ─── PIPELINE COMPLET ──────────────────────────────────────────────────────── # Zone non spécifiée par le client (ou non reconnue par ZONE_FR_TO_PART) : # on sécurise le retraitement automatique en élargissant la recherche du # dommage à TOUTES les photos d'entrée disponibles, plutôt que d'abandonner # la vérification (mapped_part reste None -> traduit par l'appelant en # "Non Spécifié" côté AppSheet, colonne "Localisation Carrosserie"). UNSPECIFIED_ZONE_VLM_LABEL = "vehicle body (exact area not specified by the customer)" UNSPECIFIED_ZONE_DISPLAY = "zone non spécifiée" def process_claim(client_message, vehicle_results_json, videos_dir=None): """ Pipeline complet : phrase client -> extraction -> mapping -> filtrage -> vérification visuelle (YOLO crop + resize + VLM) -> verdict global. Trois niveaux de précision pour la zone extraite (cf. retraitement mail) : 1. Précise (ex: "aile avant gauche") -> une seule pièce. 2. Partielle, un seul axe (ex: "à droite") -> un GROUPE de pièces cohérent avec l'axe indiqué (cf. ZONE_FR_TO_PART). 3. Non reconnue / non spécifiée -> la vérification n'est PAS abandonnée : elle est élargie à TOUTES les photos d'entrée disponibles (mapped_part=None). `videos_dir` (optionnel) : si la zone concerne l'AVANT du véhicule et qu'aucun dommage n'est confirmé sur les photos filtrées, les vidéos de ce dossier sont automatiquement échantillonnées (extract_video_frames) et inspectées en complément avant de conclure — les vidéos d'entrée cadrent toujours l'avant du véhicule. """ print(f"\n📝 Message client : {client_message!r}") extraction = extract_damage_report(client_message) print(f" Extrait : type_dommage={extraction['type_dommage']!r}, " f"zone={extraction['zone']!r}") parts = map_zone_to_part(extraction["zone"]) vehicle_results = load_vehicle_results(vehicle_results_json) zone_concerns_front = "avant" in _normalize_fr(extraction["zone"]) if parts is not None: print(f" Mappé vers la/les pièce(s) : {parts!r}") candidate_images = filter_images_covering_part(vehicle_results, parts) zone_for_annotation = "/".join(parts) else: print(f" ⚠ Zone {extraction['zone']!r} non reconnue/non spécifiée -> " f"vérification élargie à toutes les photos d'entrée disponibles.") candidate_images = [entry["image_path"] for entry in vehicle_results] zone_for_annotation = UNSPECIFIED_ZONE_DISPLAY def _zone_for_image(image_path): """Zone (anglais) à transmettre au VLM pour CETTE photo précise : restreinte aux pièces du groupe réellement visibles sur cette photo (plus précis pour l'étape LOCATE du prompt qu'un groupe entier).""" if parts is None: return UNSPECIFIED_ZONE_VLM_LABEL entry = get_image_entry(vehicle_results, image_path) detected_here = entry.get("detected_parts", {}) matched = [p for p in parts if detected_here.get(p, 0) >= 1] return " or ".join(matched or parts) # On n'abandonne la vérification (NO_PHOTO_AVAILABLE) que si aucune # photo ne couvre la zone ET qu'un fallback vidéo n'est pas possible. if not candidate_images and not (zone_concerns_front and videos_dir): result = { "client_message": client_message, "extraction": extraction, "mapped_part": parts, "status": "NO_PHOTO_AVAILABLE", "message": ( f"Aucune photo d'entrée ne couvre la/les pièce(s) '{parts}'. " f"Vérification à l'œil nu nécessaire, aucune photo disponible." if parts is not None else "Aucune photo d'entrée disponible pour vérifier la réclamation " "(zone non spécifiée)." ), } print(f" ⚠ {result['message']}") return result print(f" {len(candidate_images)} photo(s) à vérifier : {candidate_images}" if candidate_images else " Aucune photo ne couvre la zone -> vérification vidéo uniquement.") checks = [] for image_path in candidate_images: zone_for_this_image = _zone_for_image(image_path) print(f" 🔍 Vérification sur {image_path}… (zone={zone_for_this_image})") check = check_damage_on_image(image_path, extraction["type_dommage"], zone_for_this_image) checks.append(check) print(f" -> damage_visible={check['damage_visible']} " f"({check['reason'] or 'parse error'})") parse_errors = [c for c in checks if c.get("parse_error")] if parse_errors: for c in parse_errors: print(f"--- RAW for {c['image_path']} ---\n{c['raw_response']}\n---") # Agrégation : dès qu'une photo dit "oui" -> OUI global (règle validée). positive_matches = [c for c in checks if c["damage_visible"] is True] used_video_fallback = False if not positive_matches and zone_concerns_front and videos_dir: video_frames = extract_video_frames(videos_dir) if video_frames: used_video_fallback = True print(f" 🎥 Dommage non trouvé sur photo, zone concernant l'avant -> " f"inspection de {len(video_frames)} frame(s) vidéo en complément.") for frame_path in video_frames: check = check_damage_on_image(frame_path, extraction["type_dommage"], "front-bumper") check["from_video"] = True checks.append(check) candidate_images.append(frame_path) print(f" -> (vidéo) damage_visible={check['damage_visible']} " f"({check['reason'] or 'parse error'})") positive_matches = [c for c in checks if c["damage_visible"] is True] if positive_matches: status = "DAMAGE_ALREADY_PRESENT" verdict = "OUI" evidence_images = [c["image_path"] for c in positive_matches] # ── Annotation des images positives ── annotated_images = [] for c in positive_matches: ann_path = annotate_damage_image( image_path=c["image_path"], vehicle_bbox=c.get("vehicle_bbox"), part=zone_for_annotation, type_dommage=extraction["type_dommage"], reason=c.get("reason"), damage_box_original_px=c.get("damage_box_original_px"), output_dir=Path(vehicle_results_json).parent / "annotated", ) annotated_images.append(ann_path) message = ( f"Dommage déjà présent à l'entrée. Identifié sur : " f"{', '.join(Path(p).name for p in evidence_images)}" + (" (dont vidéo(s) d'entrée)" if any(c.get("from_video") for c in positive_matches) else "") ) else: annotated_images = [] status = "DAMAGE_NOT_FOUND" verdict = "NON" evidence_images = candidate_images # à vérifier à l'œil nu message = ( f"Dommage non identifié sur les photos d'entrée disponibles" f"{' ni sur les vidéos' if used_video_fallback else ''}. " f"Photos à vérifier à l'œil nu " f"({'zone couverte' if parts is not None else 'zone non spécifiée, toutes les photos disponibles vérifiées'}) : " f"{', '.join(Path(p).name for p in evidence_images)}" ) print(f"\n ➡ Verdict : {verdict} — {message}") return { "client_message": client_message, "extraction": extraction, "mapped_part": parts, "candidate_images": candidate_images, "checks": checks, "status": status, "verdict": verdict, "evidence_images": evidence_images, "annotated_images": annotated_images, "message": message, } # ─── MULTI-DÉGÂTS : DÉCOUPAGE PUIS TRAITEMENT UN PAR UN ──────────────────── SPLIT_DAMAGES_PROMPT_TEMPLATE = """Tu es un assistant qui découpe une réclamation client en dommages distincts. Réclamation : "{claim_message}" Si cette réclamation décrit PLUSIEURS dommages distincts (zones et/ou types de dommage différents), renvoie une liste avec UNE phrase atomique par dommage, en français, reprenant fidèlement le type de dommage et la zone concernée pour chacun. Si elle ne décrit qu'UN SEUL dommage, renvoie une liste contenant cette unique phrase (inchangée). Réponds STRICTEMENT en JSON, sans aucun texte avant ou après, au format : {{"damages": ["phrase 1", "phrase 2", ...]}}""" def split_claim_into_damages(client_message: str) -> list: """ Découpe une réclamation en une liste de phrases atomiques, une par dommage distinct, via Gemini. Ne lève jamais d'exception : en cas d'échec (API indisponible, JSON invalide, liste vide), retombe sur [client_message] pour ne jamais perdre la réclamation d'origine. """ client = get_gemini_client() prompt = SPLIT_DAMAGES_PROMPT_TEMPLATE.format(claim_message=client_message) try: response = client.models.generate_content( model=GEMINI_MODEL_ID, contents=prompt, config=types.GenerateContentConfig( response_mime_type="application/json", temperature=0.0, ), ) data = json.loads(response.text.strip()) damages = [str(d).strip() for d in data.get("damages", []) if str(d).strip()] if damages: return damages except Exception as e: print(f" ⚠ Découpage multi-dommages échoué ({e!r}) -> traité comme un seul dommage.") return [client_message] def _dedup_preserve_order(seq): seen = set() out = [] for x in seq: if x not in seen: seen.add(x) out.append(x) return out def process_multi_claim(client_message, vehicle_results_json, videos_dir=None): """ Découpe la réclamation en dommages distincts (split_claim_into_damages) puis traite CHACUN, un après l'autre, avec process_claim() — et agrège le tout en un résultat unique de même forme que process_claim() (mêmes clés, + "damages" avec le détail par dommage), pour rester compatible avec les consommateurs existants d'un résultat process_claim() : une seule ligne Reclamations par réclamation côté AppSheet/Sheet. Règles d'agrégation : - verdict global "OUI" dès qu'AU MOINS UN dommage est confirmé déjà présent à l'entrée (réclamation rejetée) ; "NON" sinon. - mapped_part global = None si AU MOINS UN dommage n'a pas pu être localisé précisément (préserve le comportement des appelants qui testent `mapped_part is None` pour élargir aux vidéos, etc.). """ damages = split_claim_into_damages(client_message) print(f"\n📋 {len(damages)} dommage(s) distinct(s) détecté(s) dans la réclamation.") results = [] for i, damage_message in enumerate(damages, start=1): print(f"\n── Dommage {i}/{len(damages)} ──") results.append(process_claim(damage_message, vehicle_results_json, videos_dir=videos_dir)) verdicts = [r.get("verdict") for r in results] verdict = "OUI" if "OUI" in verdicts else "NON" mapped_parts_list = [r.get("mapped_part") for r in results] mapped_part = None if any(mp is None for mp in mapped_parts_list) else mapped_parts_list candidate_images = _dedup_preserve_order(sum((r.get("candidate_images", []) for r in results), [])) annotated_images = _dedup_preserve_order(sum((r.get("annotated_images", []) for r in results), [])) evidence_images = _dedup_preserve_order(sum((r.get("evidence_images", []) for r in results), [])) message = "\n".join( f"[Dommage {i}/{len(results)} — {r['extraction']['zone']}] {r['message']}" for i, r in enumerate(results, start=1) ) status = "DAMAGE_ALREADY_PRESENT" if verdict == "OUI" else ( results[0]["status"] if len(results) == 1 else "DAMAGE_NOT_FOUND" ) return { "client_message": client_message, "damages": results, "mapped_part": mapped_part, "candidate_images": candidate_images, "status": status, "verdict": verdict, "evidence_images": evidence_images, "annotated_images": annotated_images, "message": message, } def save_claim_result(result, vehicle_id, output_dir): out_dir = Path(output_dir) out_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") out_path = out_dir / f"{vehicle_id}_claim_{timestamp}.json" with open(out_path, "w", encoding="utf-8") as f: json.dump(result, f, indent=2, ensure_ascii=False) print(f"\n💾 Résultat de la réclamation sauvegardé -> {out_path}") return out_path # ─── RÔLE DES MÉDIAS (table AppSheet "Medias Cameras", colonne "Role Media") ── # Valeurs possibles de l'enum "Role Media" : # - "Dommages" : le dommage réclamé est confirmé visible sur ce média. # - "Parties Visibles" : ce média montre la pièce réclamée, mais sans dommage # confirmé dessus (utile pour visualiser l'état du # véhicule à l'entrée). # - "Non Utile" : ce média ne montre pas la pièce réclamée. ROLE_DOMMAGES = "Dommages" ROLE_PARTIES_VISIBLES = "Parties Visibles" ROLE_NON_UTILE = "Non Utile" def build_media_role_resolver(claim_result: dict): """ À partir du résultat de process_claim()/process_multi_claim(), construit une fonction `resolver(file_id, is_image) -> role` qui donne le rôle ("Dommages" / "Parties Visibles" / "Non Utile") d'un média d'entrée à partir de son ID Drive (utilisé comme nom de fichier local par drive_enrichment.py / drive_lookup.py, ex: "abcd1234.png") et de son type (photo ou vidéo). Règles : - Photo : "Dommages" si un dommage y a été confirmé (damage_visible=True sur au moins un "check" qui ne vient pas d'une frame vidéo) ; sinon "Parties Visibles" si elle couvre la pièce réclamée (présente dans candidate_images d'au moins un dommage) ; sinon "Non Utile" (ne montre pas la pièce réclamée par la réclamation). - Vidéo : suit la même logique que leur upload dans Preuves / Parties Visibles (finalize_claim.executer_bilan_final / report_builder.construire_bilan_final) : "Dommages" si le verdict global est OUI, "Parties Visibles" si la zone n'a pas pu être précisée (mapped_part=None -> toutes les vidéos sont ajoutées pour vérification à l'oeil nu), "Non Utile" sinon. """ sous_resultats = claim_result.get("damages") or [claim_result] photos_avec_dommage = set() photos_visibles = set() for r in sous_resultats: for check in r.get("checks", []): if check.get("from_video"): continue file_id = Path(check["image_path"]).stem photos_visibles.add(file_id) if check.get("damage_visible") is True: photos_avec_dommage.add(file_id) for chemin_image in r.get("candidate_images", []): photos_visibles.add(Path(chemin_image).stem) if claim_result.get("verdict") == "OUI": role_video = ROLE_DOMMAGES elif claim_result.get("mapped_part") is None: role_video = ROLE_PARTIES_VISIBLES else: role_video = ROLE_NON_UTILE def resolver(file_id: str, is_image: bool) -> str: if not is_image: return role_video if file_id in photos_avec_dommage: return ROLE_DOMMAGES if file_id in photos_visibles: return ROLE_PARTIES_VISIBLES return ROLE_NON_UTILE return resolver # ─── TRAITEMENT EN LOT, MODÈLES CHARGÉS UNE SEULE FOIS ────────── def process_claims_batch(claims, output_dir): """ Traite plusieurs réclamations à la suite DANS LE MÊME PROCESS PYTHON. """ results = [] for vehicle_id, client_message, vehicle_results_json in claims: result = process_multi_claim(client_message, vehicle_results_json) save_claim_result(result, vehicle_id, output_dir) results.append(result) return results if __name__ == "__main__": vehicle_id = "vehicule_demo_001" out_path = "Strat2/output/Model_Strat2" vehicle_json_path = "Strat2/output/Model_Strat2/vehicle_parts_detection.json" client_message = "Le client rapporte des éraflures sur son pare-choc arrière" result = process_claim(client_message, vehicle_json_path) save_claim_result(result, vehicle_id, out_path)