#!/usr/bin/env python3 from __future__ import annotations import argparse import csv import re import sys from dataclasses import dataclass from pathlib import Path import cv2 import numpy as np from PIL import Image EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"} @dataclass class Result: path: Path category: str sharpness: float white: float black: float brightness: float reason: str def natural_key(path: Path) -> list[object]: return [ int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", path.name) ] def load_gray(path: Path) -> np.ndarray: with Image.open(path) as source: source = source.convert("L") gray = np.asarray(source) height, width = gray.shape maximum_dimension = 1600 if max(width, height) > maximum_dimension: scale = maximum_dimension / max(width, height) gray = cv2.resize( gray, ( max(1, round(width * scale)), max(1, round(height * scale)), ), interpolation=cv2.INTER_AREA, ) return gray def analyze(path: Path) -> tuple[dict[str, float] | None, str]: try: gray = load_gray(path) except Exception as error: return None, f"illisible: {error}" return { "sharpness": float(cv2.Laplacian(gray, cv2.CV_64F).var()), "white": float(np.count_nonzero(gray >= 250) / gray.size * 100), "black": float(np.count_nonzero(gray <= 5) / gray.size * 100), "brightness": float(gray.mean()), }, "" def classify(path: Path, metrics: dict[str, float] | None, error: str) -> Result: if metrics is None: return Result(path, "mauvaises", 0.0, 0.0, 0.0, 0.0, error) sharpness = metrics["sharpness"] white = metrics["white"] black = metrics["black"] brightness = metrics["brightness"] reasons: list[str] = [] if sharpness < 100: reasons.append("très floue") elif sharpness < 170: reasons.append("floue possible") if white > 20: reasons.append("très surexposée") elif white > 8: reasons.append("surexposition possible") if black > 35: reasons.append("très sombre") elif black > 15: reasons.append("ombres bouchées possibles") if brightness > 225 or brightness < 25: reasons.append("luminosité extrême") severe = ( sharpness < 100 or white > 20 or black > 35 or brightness > 225 or brightness < 25 ) if severe: category = "mauvaises" elif reasons: category = "suspectes" else: category = "bonnes" return Result( path, category, sharpness, white, black, brightness, "; ".join(reasons) or "ok", ) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("images", type=Path) parser.add_argument("--output", type=Path, required=True) parser.add_argument( "--compatibility-links", action="store_true", help="crée des vues par liens symboliques sans supprimer de contenu existant", ) args = parser.parse_args() images_dir = args.images.expanduser().resolve() output_dir = args.output.expanduser().resolve() excluded_roots = { output_dir, images_dir / "scan3d", images_dir / "tri_resultat", images_dir / "test_colmap", images_dir / "colmap_complet", } images: list[Path] = [] for path in images_dir.rglob("*"): if not path.is_file() or path.is_symlink(): continue if path.suffix.lower() not in EXTENSIONS: continue if any(root == path or root in path.parents for root in excluded_roots): continue images.append(path) images.sort(key=natural_key) if not images: print("Aucune image trouvée.", file=sys.stderr) return 1 print(f"{len(images)} images trouvées.") results: list[Result] = [] for index, path in enumerate(images, 1): metrics, error = analyze(path) results.append(classify(path, metrics, error)) print( f"\rAnalyse {index}/{len(images)} : {path.name[:55]:55s}", end="", flush=True, ) print() output_dir.mkdir(parents=True, exist_ok=True) report_path = output_dir / "resultats.csv" if report_path.exists(): print( f"Rapport déjà présent, aucun fichier modifié : {report_path}", file=sys.stderr, ) return 2 if args.compatibility_links: for category in ("bonnes", "suspectes", "mauvaises"): (output_dir / category).mkdir(parents=True, exist_ok=True) # Exclusive creation makes the analysis/report default non-destructive even # when two invocations target the same output directory. with report_path.open( "x", encoding="utf-8", newline="" ) as csv_file: writer = csv.writer(csv_file) writer.writerow( [ "fichier", "categorie", "netteté", "blanc_pct", "noir_pct", "luminosité", "raison", ] ) counters = {"bonnes": 0, "suspectes": 0, "mauvaises": 0} for result in results: counters[result.category] += 1 if args.compatibility_links: link_name = f"{counters[result.category]:06d}_{result.path.name}" link_path = output_dir / result.category / link_name if link_path.exists() or link_path.is_symlink(): print( f"Lien existant conservé : {link_path}", file=sys.stderr ) else: # Compatibility output references originals; it never moves # or deletes immutable source files. link_path.symlink_to(result.path.resolve()) writer.writerow( [ str(result.path), result.category, f"{result.sharpness:.2f}", f"{result.white:.2f}", f"{result.black:.2f}", f"{result.brightness:.2f}", result.reason, ] ) for category in ("bonnes", "suspectes", "mauvaises"): count = sum(result.category == category for result in results) print(f"{category}: {count}") return 0 if __name__ == "__main__": raise SystemExit(main())