""" Service d'extraction EXIF — Pillow + piexif """ import logging from datetime import datetime from pathlib import Path from typing import Any logger = logging.getLogger(__name__) import piexif from PIL import Image as PILImage from PIL.ExifTags import TAGS, GPSTAGS def _dms_to_decimal(dms: tuple, ref: str) -> float | None: """Convertit les coordonnées GPS DMS (degrés/minutes/secondes) en décimal.""" try: degrees = dms[0][0] / dms[0][1] minutes = dms[1][0] / dms[1][1] seconds = dms[2][0] / dms[2][1] decimal = degrees + minutes / 60 + seconds / 3600 if ref in ("S", "W"): decimal = -decimal return round(decimal, 7) except Exception: return None def _parse_rational(value) -> str | None: """Convertit un rationnel EXIF en chaîne lisible.""" try: if isinstance(value, tuple) and len(value) == 2: num, den = value if den == 0: return None return f"{num}/{den}" return str(value) except Exception: return None def _safe_str(value: Any) -> str | None: """Décode les bytes en string si nécessaire.""" if value is None: return None if isinstance(value, bytes): return value.decode("utf-8", errors="ignore").strip("\x00") return str(value) def extract_exif(file_path: str) -> dict: """ Extrait toutes les métadonnées EXIF d'une image. Retourne un dict structuré avec les données parsées. """ result = { "raw": {}, "make": None, "model": None, "lens": None, "taken_at": None, "gps_lat": None, "gps_lon": None, "altitude": None, "iso": None, "aperture": None, "shutter": None, "focal": None, "flash": None, "orientation": None, "software": None, } try: path = Path(file_path) if not path.exists(): return result # ── Lecture EXIF brute via piexif ───────────────────── try: exif_data = piexif.load(str(path)) except Exception: # JPEG sans EXIF, PNG, etc. return result raw_dict = {} # ── IFD 0 (Image principale) ────────────────────────── ifd0 = exif_data.get("0th", {}) result["make"] = _safe_str(ifd0.get(piexif.ImageIFD.Make)) result["model"] = _safe_str(ifd0.get(piexif.ImageIFD.Model)) result["software"] = _safe_str(ifd0.get(piexif.ImageIFD.Software)) result["orientation"] = ifd0.get(piexif.ImageIFD.Orientation) # ── EXIF IFD ────────────────────────────────────────── exif = exif_data.get("Exif", {}) # Date de prise de vue taken_raw = _safe_str(exif.get(piexif.ExifIFD.DateTimeOriginal)) if taken_raw: try: result["taken_at"] = datetime.strptime(taken_raw, "%Y:%m:%d %H:%M:%S") except ValueError: pass # Paramètres de prise de vue iso_val = exif.get(piexif.ExifIFD.ISOSpeedRatings) result["iso"] = int(iso_val) if iso_val else None aperture_val = exif.get(piexif.ExifIFD.FNumber) if aperture_val: try: f = aperture_val[0] / aperture_val[1] result["aperture"] = f"f/{f:.1f}" except Exception: pass shutter_val = exif.get(piexif.ExifIFD.ExposureTime) if shutter_val: result["shutter"] = _parse_rational(shutter_val) focal_val = exif.get(piexif.ExifIFD.FocalLength) if focal_val: try: f = focal_val[0] / focal_val[1] result["focal"] = f"{f:.0f}mm" except Exception: pass flash_val = exif.get(piexif.ExifIFD.Flash) result["flash"] = bool(flash_val & 1) if flash_val is not None else None lens_val = _safe_str(exif.get(piexif.ExifIFD.LensModel)) result["lens"] = lens_val # ── GPS IFD ─────────────────────────────────────────── gps = exif_data.get("GPS", {}) if gps: lat_val = gps.get(piexif.GPSIFD.GPSLatitude) lat_ref = _safe_str(gps.get(piexif.GPSIFD.GPSLatitudeRef)) lon_val = gps.get(piexif.GPSIFD.GPSLongitude) lon_ref = _safe_str(gps.get(piexif.GPSIFD.GPSLongitudeRef)) if lat_val and lat_ref: result["gps_lat"] = _dms_to_decimal(lat_val, lat_ref) if lon_val and lon_ref: result["gps_lon"] = _dms_to_decimal(lon_val, lon_ref) alt_val = gps.get(piexif.GPSIFD.GPSAltitude) if alt_val: try: result["altitude"] = round(alt_val[0] / alt_val[1], 2) except Exception: pass # ── Dict brut lisible (TAGS humains) ────────────────── with PILImage.open(path) as img: raw_exif = img._getexif() if raw_exif: for tag_id, val in raw_exif.items(): tag = TAGS.get(tag_id, str(tag_id)) if isinstance(val, bytes): val = val.decode("utf-8", errors="ignore") elif isinstance(val, tuple): val = list(val) raw_dict[tag] = val result["raw"] = raw_dict except Exception as e: logger.error("exif.extraction_error", extra={"file": file_path, "error": str(e)}) return result