Imago/app/services/exif_service.py

177 lines
5.9 KiB
Python

"""
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