Initial commit: script de gestion des métadonnées EXIF pour photos argentiques

Script Python qui lit un fichier JSON généré par Exif Notes et écrit
les métadonnées EXIF dans les fichiers images (TIFF, JPEG) ou crée
des fichiers XMP sidecar pour les formats non supportés.

Fonctionnalités :
- Validation des fichiers JSON Exif Notes
- Mapping complet JSON → tags EXIF standard
- Écriture EXIF via exiftool avec fallback XMP
- Support des formats de vitesse (1/125, 2", B/Bulb)
- Support des dates ISO avec ou sans secondes
- CLI avec options --dry-run, --verbose, --force-xmp

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 16:22:12 +01:00
commit 25bd8bc961
14 changed files with 2715 additions and 0 deletions

View File

@@ -0,0 +1,386 @@
"""
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)