177 lines
5.9 KiB
Python
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
|