""" Module de mapping des données JSON vers les tags EXIF. Ce module convertit les données du format JSON Exif Notes vers les tags EXIF standard utilisables par exiftool. """ import logging from datetime import datetime from typing import Any from zoneinfo import ZoneInfo logger = logging.getLogger(__name__) # Fuseau horaire par défaut pour les dates DEFAULT_TIMEZONE = ZoneInfo("Europe/Paris") # Mapping des sources de lumière vers les valeurs EXIF standard LIGHT_SOURCE_MAP = { 'daylight': 1, 'lumière du jour': 1, 'fluorescent': 2, 'tungsten': 3, 'incandescent': 3, 'flash': 4, 'fine weather': 9, 'beau temps': 9, 'cloudy': 10, 'nuageux': 10, 'shade': 11, 'ombre': 11, 'daylight fluorescent': 12, 'day white fluorescent': 13, 'cool white fluorescent': 14, 'white fluorescent': 15, 'warm white fluorescent': 16, 'standard light a': 17, 'standard light b': 18, 'standard light c': 19, 'iso studio tungsten': 24, 'other': 255, 'autre': 255, } def map_frame_to_exif(frame: dict, roll: dict) -> dict: """ Convertit un frame et son roll parent en dictionnaire de tags EXIF. Args: frame: Dictionnaire représentant un frame individuel. roll: Dictionnaire représentant le roll parent. Returns: Dictionnaire de tags EXIF prêts pour exiftool. """ tags = {} # Informations de l'appareil photo (depuis le roll) camera = roll.get('camera') or {} if camera.get('make'): tags['Make'] = camera['make'] if camera.get('model'): tags['Model'] = camera['model'] # Informations de l'objectif (depuis le roll ou le frame) lens = frame.get('lens') or roll.get('lens') or {} if lens.get('make'): tags['LensMake'] = lens['make'] if lens.get('model'): tags['LensModel'] = lens['model'] elif lens.get('make') and lens.get('model'): tags['LensModel'] = f"{lens['make']} {lens['model']}" # ISO (depuis le roll) if roll.get('iso') is not None: tags['ISO'] = int(roll['iso']) # Date et heure if frame.get('date'): try: dt = parse_date(frame['date']) date_str = dt.strftime('%Y:%m:%d %H:%M:%S') tags['DateTimeOriginal'] = date_str tags['CreateDate'] = date_str # Offset du fuseau horaire offset = dt.strftime('%z') if offset: offset_formatted = f"{offset[:3]}:{offset[3:]}" tags['OffsetTimeOriginal'] = offset_formatted except (ValueError, TypeError) as e: logger.warning(f"Impossible de parser la date '{frame['date']}' : {e}") # Vitesse d'obturation if frame.get('shutter'): try: exposure_time = parse_shutter_speed(frame['shutter']) if exposure_time is not None: tags['ExposureTime'] = exposure_time tags['ShutterSpeedValue'] = frame['shutter'] except ValueError as e: logger.warning(f"Vitesse d'obturation invalide '{frame['shutter']}' : {e}") # Ouverture if frame.get('aperture') is not None: try: aperture = parse_aperture(frame['aperture']) tags['FNumber'] = aperture tags['ApertureValue'] = aperture except ValueError as e: logger.warning(f"Ouverture invalide '{frame['aperture']}' : {e}") # Focale if frame.get('focalLength') is not None: tags['FocalLength'] = float(frame['focalLength']) # Coordonnées GPS if frame.get('location'): location = frame['location'] lat = location.get('latitude') lon = location.get('longitude') if lat is not None and lon is not None: gps_tags = format_gps_for_exif(lat, lon) tags.update(gps_tags) # Altitude GPS (optionnel) if location.get('altitude') is not None: alt = float(location['altitude']) tags['GPSAltitude'] = abs(alt) tags['GPSAltitudeRef'] = 'Below Sea Level' if alt < 0 else 'Above Sea Level' # Note / Description if frame.get('note'): tags['ImageDescription'] = frame['note'] tags['UserComment'] = frame['note'] # Flash if frame.get('flashUsed') is not None: # Valeurs EXIF Flash : 0 = non déclenché, 1 = déclenché tags['Flash'] = 1 if frame['flashUsed'] else 0 # Source de lumière if frame.get('lightSource'): light_value = _parse_light_source(frame['lightSource']) if light_value is not None: tags['LightSource'] = light_value # Film stock (métadonnées personnalisées) film_stock = roll.get('filmStock') or {} film_info = _format_film_stock(film_stock) if film_info: # Ajout dans un tag XMP personnalisé tags['XMP-dc:Description'] = film_info # Aussi dans le sujet pour la compatibilité if 'ImageDescription' in tags: tags['ImageDescription'] = f"{tags['ImageDescription']} | Film: {film_info}" else: tags['ImageDescription'] = f"Film: {film_info}" # Numéro de frame if frame.get('id') is not None: tags['ImageNumber'] = frame['id'] # Compensation d'exposition if frame.get('exposureComp') is not None: tags['ExposureCompensation'] = float(frame['exposureComp']) logger.debug(f"Mapping terminé : {len(tags)} tags générés") return tags def parse_shutter_speed(shutter: Any) -> float | None: """ Convertit une vitesse d'obturation en valeur décimale. Args: shutter: Vitesse sous forme de fraction ("1/125"), nombre, ou "B" pour bulb. Returns: Temps d'exposition en secondes, ou None pour le mode Bulb. Raises: ValueError: Si le format est invalide. Examples: >>> parse_shutter_speed("1/125") 0.008 >>> parse_shutter_speed("1/1000") 0.001 >>> parse_shutter_speed("2") 2.0 >>> parse_shutter_speed('2"') 2.0 >>> parse_shutter_speed("B") None >>> parse_shutter_speed(0.5) 0.5 """ if isinstance(shutter, (int, float)): return float(shutter) if not isinstance(shutter, str): raise ValueError(f"Format de vitesse non supporté : {type(shutter)}") # Mode Bulb : pas de valeur numérique if shutter.lower() in ('b', 'bulb'): return None # Nettoyage de la chaîne (supprime s, ", ') value = shutter.lower().replace('s', '').replace('"', '').replace("'", '').strip() if '/' in value: parts = value.split('/') if len(parts) != 2: raise ValueError(f"Format de fraction invalide : {shutter}") try: numerator = float(parts[0]) denominator = float(parts[1]) if denominator == 0: raise ValueError("Division par zéro") return numerator / denominator except (ValueError, ZeroDivisionError) as e: raise ValueError(f"Impossible de parser '{shutter}' : {e}") else: try: return float(value) except ValueError: raise ValueError(f"Format de vitesse invalide : {shutter}") def parse_aperture(aperture: Any) -> float: """ Convertit une ouverture en valeur numérique f-stop. Args: aperture: Ouverture sous forme de nombre ou chaîne ("f/2.8", "2.8"). Returns: Valeur f-stop. Raises: ValueError: Si le format est invalide. Examples: >>> parse_aperture("f/2.8") 2.8 >>> parse_aperture("f2.8") 2.8 >>> parse_aperture(5.6) 5.6 """ if isinstance(aperture, (int, float)): return float(aperture) if not isinstance(aperture, str): raise ValueError(f"Format d'ouverture non supporté : {type(aperture)}") # Nettoyage : supprime "f/", "f" et les espaces value = aperture.lower().replace('f/', '').replace('f', '').strip() try: return float(value) except ValueError: raise ValueError(f"Format d'ouverture invalide : {aperture}") def parse_date(date_str: str) -> datetime: """ Parse une date ISO 8601 avec gestion du fuseau horaire. Args: date_str: Date au format ISO 8601. Returns: Objet datetime avec fuseau horaire. Raises: ValueError: Si le format est invalide. Examples: >>> parse_date("2024-03-15T14:30:00") datetime(2024, 3, 15, 14, 30, tzinfo=ZoneInfo('Europe/Paris')) >>> parse_date("2024-03-15T14:30:00+02:00") datetime(2024, 3, 15, 14, 30, tzinfo=timezone(timedelta(hours=2))) """ if not isinstance(date_str, str): raise ValueError(f"La date doit être une chaîne : {type(date_str)}") # Formats à essayer formats = [ '%Y-%m-%dT%H:%M:%S%z', # ISO avec timezone offset '%Y-%m-%dT%H:%M:%S.%f%z', # ISO avec millisecondes et timezone '%Y-%m-%dT%H:%M%z', # ISO sans secondes avec timezone '%Y-%m-%dT%H:%M:%S', # ISO sans timezone '%Y-%m-%dT%H:%M:%S.%f', # ISO avec millisecondes '%Y-%m-%dT%H:%M', # ISO sans secondes '%Y-%m-%d %H:%M:%S', # Format simple '%Y-%m-%d %H:%M', # Format simple sans secondes '%Y-%m-%d', # Date seule ] # Normalisation du format timezone (remplace Z par +00:00) normalized = date_str.replace('Z', '+00:00') for fmt in formats: try: dt = datetime.strptime(normalized, fmt) # Si pas de timezone, utiliser le fuseau par défaut if dt.tzinfo is None: dt = dt.replace(tzinfo=DEFAULT_TIMEZONE) return dt except ValueError: continue raise ValueError(f"Format de date non reconnu : {date_str}") def format_gps_for_exif(lat: float, lon: float) -> dict: """ Formate les coordonnées GPS pour exiftool. Args: lat: Latitude en degrés décimaux (-90 à 90). lon: Longitude en degrés décimaux (-180 à 180). Returns: Dictionnaire avec les tags GPS formatés. Examples: >>> format_gps_for_exif(48.8584, 2.2945) {'GPSLatitude': 48.8584, 'GPSLatitudeRef': 'N', 'GPSLongitude': 2.2945, 'GPSLongitudeRef': 'E'} """ tags = {} # Latitude tags['GPSLatitude'] = abs(lat) tags['GPSLatitudeRef'] = 'N' if lat >= 0 else 'S' # Longitude tags['GPSLongitude'] = abs(lon) tags['GPSLongitudeRef'] = 'E' if lon >= 0 else 'W' return tags def _parse_light_source(light_source: Any) -> int | None: """ Convertit une source de lumière en valeur EXIF. Args: light_source: Source de lumière (chaîne ou entier). Returns: Valeur EXIF ou None si non reconnu. """ if isinstance(light_source, int): return light_source if isinstance(light_source, str): key = light_source.lower().strip() return LIGHT_SOURCE_MAP.get(key) return None def _format_film_stock(film_stock: dict) -> str: """ Formate les informations du film pour inclusion dans les métadonnées. Args: film_stock: Dictionnaire avec make et model du film. Returns: Chaîne formatée ou chaîne vide. """ parts = [] if film_stock.get('make'): parts.append(film_stock['make']) if film_stock.get('model'): parts.append(film_stock['model']) return ' '.join(parts)