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:
386
json_to_metadata/metadata_mapper.py
Normal file
386
json_to_metadata/metadata_mapper.py
Normal 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)
|
||||
Reference in New Issue
Block a user