Imago/app/services/exif_service.py
Bruno Charest cc99fea20a
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
Add comprehensive test suite for image processing and related services
- Implement tests for database generator to ensure proper session handling.
- Create tests for EXIF extraction and conversion functions.
- Add tests for image-related endpoints, ensuring proper data retrieval and isolation between clients.
- Develop tests for OCR functionality, including language detection and text extraction.
- Introduce tests for the image processing pipeline, covering success and failure scenarios.
- Validate rate limiting functionality and ensure independent counters for different clients.
- Implement scraper tests to verify HTML content fetching and error handling.
- Add unit tests for various services, including storage and filename generation.
- Establish worker entry point for ARQ to handle background image processing tasks.
2026-02-24 11:22:10 -05:00

174 lines
5.8 KiB
Python

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