"""Vérifie si une image a déjà été utilisée pour l'entraînement/validation du CRNN (annotator/annotations/annotations.json), afin de choisir des photos réellement inédites pour les tests de démonstration. Compare par hash perceptuel (pHash) pour détecter aussi les quasi-doublons (même écran re-photographié, recadré, recompressé) — pas seulement les noms de fichiers identiques. Usage : python tools/check_seen_image.py chemin/vers/photo.jpg [autre.jpg ...] """ import argparse import json import os import sys import numpy as np from PIL import Image REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) ANNOTATIONS_FILE = os.path.join(REPO_ROOT, "annotator", "annotations", "annotations.json") HASH_SIZE = 16 SIMILARITY_THRESHOLD = 10 # distance de Hamming ; en dessous = quasi-identique def phash(image_path: str, hash_size: int = HASH_SIZE) -> np.ndarray: img = Image.open(image_path).convert("L").resize( (hash_size + 1, hash_size), Image.LANCZOS ) pixels = np.asarray(img, dtype=np.float32) diff = pixels[:, 1:] > pixels[:, :-1] return diff.flatten() def hamming_distance(a: np.ndarray, b: np.ndarray) -> int: return int(np.count_nonzero(a != b)) def _index_repo_filenames() -> dict: """Indexe une fois tous les fichiers image du dépôt par nom de fichier, pour retrouver les sources annotées même quand `image_path` ne pointe plus vers un chemin valide (dossiers déplacés/renommés).""" exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tiff"} index: dict = {} skip_dirs = {".git", ".env", "__pycache__", "node_modules", "dataset_lines", "dataset_doctr", "crops", "outputs", "outputs_test", "photos"} for root, dirs, files in os.walk(REPO_ROOT): dirs[:] = [d for d in dirs if d not in skip_dirs] for f in files: if os.path.splitext(f)[1].lower() in exts: index.setdefault(f, os.path.join(root, f)) return index def build_reference_hashes() -> dict: """Calcule les hash de toutes les images sources annotées (introuvables localement sont ignorées silencieusement).""" if not os.path.exists(ANNOTATIONS_FILE): raise FileNotFoundError(f"Introuvable : {ANNOTATIONS_FILE}") ann = json.load(open(ANNOTATIONS_FILE, encoding="utf-8")) filename_index = _index_repo_filenames() ann_dir = os.path.dirname(ANNOTATIONS_FILE) refs = {} for name, entry in ann.items(): rel_path = entry.get("image_path", "") candidates = [ os.path.normpath(os.path.join(ann_dir, rel_path)) if rel_path else None, os.path.normpath(os.path.join(REPO_ROOT, "images", name)), filename_index.get(name), ] for c in candidates: if c and os.path.exists(c): try: refs[name] = phash(c) except Exception: pass break return refs def check_image(path: str, refs: dict) -> None: try: h = phash(path) except Exception as e: print(f"{path} : impossible de lire l'image ({e})") return best_name, best_dist = None, None for name, ref_hash in refs.items(): d = hamming_distance(h, ref_hash) if best_dist is None or d < best_dist: best_dist, best_name = d, name if best_dist is not None and best_dist <= SIMILARITY_THRESHOLD: print(f"{os.path.basename(path)} : DEJA VU (proche de '{best_name}', " f"distance={best_dist}/{len(h)}) -> ne pas utiliser pour tester la generalisation.") else: dist_info = f"(plus proche : '{best_name}', distance={best_dist})" if best_name else "" print(f"{os.path.basename(path)} : INEDITE {dist_info} -> bon candidat pour le test.") def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("images", nargs="+", help="Chemins des images à vérifier") args = parser.parse_args() refs = build_reference_hashes() print(f"{len(refs)} images de référence indexées (annotations.json).\n") for path in args.images: check_image(path, refs) if __name__ == "__main__": main()