""" Service d'extraction EXIF — Pillow + piexif """ import logging import io from datetime import datetime from pathlib import Path from typing import Any import piexif from PIL import Image as PILImage from PIL.ExifTags import TAGS, GPSTAGS from app.services.storage_backend import get_storage_backend logger = logging.getLogger(__name__) 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) async def extract_exif(file_path: str) -> dict: """ Extrait toutes les métadonnées EXIF d'une image. Supporte Local et S3 via StorageBackend. """ 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: # Lecture via le backend backend = get_storage_backend() image_bytes = await backend.get_bytes(file_path) # ── Lecture EXIF brute via piexif ───────────────────── try: exif_data = piexif.load(image_bytes) except Exception: # Image sans EXIF 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(io.BytesIO(image_bytes)) 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