Spaces:
Sleeping
Sleeping
| """ | |
| 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": {"<part_name>": <count>, ...} | |
| }, | |
| ... | |
| ] | |
| 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: <x1 y1 x2 y2> 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). | |
| # - "Inutile" : ce média ne montre pas la pièce réclamée. | |
| ROLE_DOMMAGES = "Dommages" | |
| ROLE_PARTIES_VISIBLES = "Parties Visibles" | |
| ROLE_INUTILE = "Inutile" | |
| 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" / "Inutile") 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 "Inutile" (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), "Inutile" 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_INUTILE | |
| 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_INUTILE | |
| 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) |