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.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user