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,8 @@
"""
json-to-metadata : Script de gestion des métadonnées EXIF pour photos argentiques.
Ce package permet de lire un fichier JSON généré par Exif Notes et d'écrire
les métadonnées EXIF dans les fichiers images correspondants.
"""
__version__ = "0.1.0"

323
json_to_metadata/cli.py Normal file
View File

@@ -0,0 +1,323 @@
"""
Interface en ligne de commande pour json-to-metadata.
Ce module fournit le point d'entrée CLI pour le script de gestion
des métadonnées EXIF.
"""
import argparse
import logging
import sys
from pathlib import Path
from . import __version__
from .exif_writer import (
ExiftoolError,
ExiftoolNotFoundError,
check_exiftool_available,
write_exif_with_fallback,
)
from .json_parser import ValidationError, load_json, validate_roll
from .metadata_mapper import map_frame_to_exif
from .xmp_writer import write_xmp_sidecar
# Configuration du logging
logger = logging.getLogger('json_to_metadata')
def setup_logging(verbose: bool = False) -> None:
"""
Configure le système de logging.
Args:
verbose: Si True, active le niveau DEBUG.
"""
level = logging.DEBUG if verbose else logging.INFO
format_str = '%(levelname)s: %(message)s'
if verbose:
format_str = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
logging.basicConfig(
level=level,
format=format_str,
handlers=[logging.StreamHandler(sys.stderr)]
)
def find_image_for_frame(
frame: dict,
directory: Path,
roll_id: str | int
) -> Path | None:
"""
Recherche le fichier image correspondant à un frame.
Essaie plusieurs conventions de nommage courantes.
Args:
frame: Dictionnaire du frame.
directory: Répertoire contenant les images.
roll_id: Identifiant du roll.
Returns:
Chemin vers l'image trouvée, ou None si non trouvée.
"""
frame_id = frame.get('id', '')
# Extensions d'images supportées
extensions = ['.tif', '.tiff', '.jpg', '.jpeg', '.avif', '.heic', '.webp', '.png']
# Patterns de noms à essayer
patterns = [
f"{frame_id}", # 1, 2, 3...
f"{frame_id:02d}" if isinstance(frame_id, int) else frame_id, # 01, 02...
f"{frame_id:03d}" if isinstance(frame_id, int) else frame_id, # 001, 002...
f"frame_{frame_id}", # frame_1, frame_2...
f"frame{frame_id}", # frame1, frame2...
f"{roll_id}_{frame_id}", # roll_frame
f"img_{frame_id}", # img_1, img_2...
f"IMG_{frame_id}", # IMG_1, IMG_2...
]
for pattern in patterns:
for ext in extensions:
# Essayer en minuscule et majuscule
for name in [f"{pattern}{ext}", f"{pattern}{ext.upper()}"]:
filepath = directory / name
if filepath.exists():
return filepath
return None
def process_roll(
roll: dict,
image_dir: Path,
dry_run: bool = False,
force_xmp: bool = False
) -> tuple[int, int, int]:
"""
Traite un roll complet : valide et écrit les métadonnées pour chaque frame.
Args:
roll: Dictionnaire du roll validé.
image_dir: Répertoire contenant les images.
dry_run: Mode simulation.
force_xmp: Force la création de fichiers XMP.
Returns:
Tuple (succès, échecs, images non trouvées).
"""
success_count = 0
failure_count = 0
not_found_count = 0
roll_id = roll.get('id', 'unknown')
frames = roll.get('frames', [])
logger.info(f"Traitement du roll '{roll_id}' : {len(frames)} frames")
for frame in frames:
frame_id = frame.get('id', '?')
# Rechercher l'image
image_path = find_image_for_frame(frame, image_dir, roll_id)
if image_path is None:
logger.warning(f"Image non trouvée pour le frame {frame_id}")
not_found_count += 1
continue
logger.debug(f"Image trouvée : {image_path.name}")
# Mapper les métadonnées
try:
tags = map_frame_to_exif(frame, roll)
except Exception as e:
logger.error(f"Erreur de mapping pour le frame {frame_id} : {e}")
failure_count += 1
continue
if not tags:
logger.warning(f"Aucune métadonnée à écrire pour le frame {frame_id}")
continue
# Écrire les métadonnées
try:
if force_xmp:
xmp_path = write_xmp_sidecar(image_path, tags, dry_run)
if xmp_path:
success_count += 1
logger.info(f"Frame {frame_id} : XMP créé ({image_path.name})")
else:
success, method = write_exif_with_fallback(
image_path,
tags,
lambda p, t, d: write_xmp_sidecar(p, t, d),
dry_run
)
if success:
success_count += 1
logger.info(f"Frame {frame_id} : métadonnées écrites via {method}")
else:
failure_count += 1
logger.error(f"Frame {frame_id} : échec de l'écriture")
except ExiftoolError as e:
logger.error(f"Erreur exiftool pour le frame {frame_id} : {e}")
failure_count += 1
except IOError as e:
logger.error(f"Erreur I/O pour le frame {frame_id} : {e}")
failure_count += 1
return success_count, failure_count, not_found_count
def main(args: list[str] | None = None) -> int:
"""
Point d'entrée principal du CLI.
Args:
args: Arguments de ligne de commande (utilise sys.argv si None).
Returns:
Code de retour (0 = succès, 1 = erreur).
"""
parser = argparse.ArgumentParser(
prog='json-to-metadata',
description='Écrit les métadonnées EXIF depuis un fichier JSON Exif Notes.',
epilog='Exemple : python -m json_to_metadata roll.json -d ./images'
)
parser.add_argument(
'json_file',
type=Path,
help='Chemin vers le fichier JSON Exif Notes'
)
parser.add_argument(
'-d', '--directory',
type=Path,
default=None,
help='Répertoire contenant les images (défaut : même répertoire que le JSON)'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Mode verbeux (niveau DEBUG)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Affiche les actions sans les exécuter'
)
parser.add_argument(
'--force-xmp',
action='store_true',
help='Force la création de fichiers XMP même pour les formats supportant EXIF'
)
parser.add_argument(
'--version',
action='version',
version=f'%(prog)s {__version__}'
)
parsed_args = parser.parse_args(args)
# Configuration du logging
setup_logging(parsed_args.verbose)
# Vérification de exiftool (sauf si force-xmp)
if not parsed_args.force_xmp:
try:
check_exiftool_available()
except ExiftoolNotFoundError as e:
logger.error(str(e))
return 1
# Chargement et validation du JSON
try:
data = load_json(parsed_args.json_file)
except FileNotFoundError as e:
logger.error(str(e))
return 1
except Exception as e:
logger.error(f"Erreur lors du chargement du JSON : {e}")
return 1
# Le JSON peut contenir un roll unique ou une liste de rolls
if isinstance(data, list):
rolls = data
elif isinstance(data, dict):
# Si c'est un objet avec une clé 'rolls', l'utiliser
if 'rolls' in data:
rolls = data['rolls']
else:
# Sinon, c'est un roll unique
rolls = [data]
else:
logger.error("Format JSON non reconnu : attendu un objet ou une liste")
return 1
# Validation des rolls
valid_rolls = []
for i, roll in enumerate(rolls):
try:
validate_roll(roll)
valid_rolls.append(roll)
except ValidationError as e:
logger.error(f"Roll {i + 1} invalide : {e}")
if not valid_rolls:
logger.error("Aucun roll valide trouvé dans le fichier JSON")
return 1
# Déterminer le répertoire des images
if parsed_args.directory:
image_dir = parsed_args.directory
else:
image_dir = parsed_args.json_file.parent
if not image_dir.is_dir():
logger.error(f"Répertoire d'images invalide : {image_dir}")
return 1
logger.info(f"Répertoire des images : {image_dir}")
if parsed_args.dry_run:
logger.info("=== MODE DRY-RUN : aucune modification ne sera effectuée ===")
# Traitement de chaque roll
total_success = 0
total_failure = 0
total_not_found = 0
for roll in valid_rolls:
success, failure, not_found = process_roll(
roll,
image_dir,
dry_run=parsed_args.dry_run,
force_xmp=parsed_args.force_xmp
)
total_success += success
total_failure += failure
total_not_found += not_found
# Résumé
logger.info("=" * 50)
logger.info(f"Résumé : {total_success} succès, {total_failure} échecs, "
f"{total_not_found} images non trouvées")
if total_failure > 0:
return 1
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,320 @@
"""
Module d'écriture des métadonnées EXIF via exiftool.
Ce module gère l'écriture des tags EXIF dans les fichiers images
en utilisant l'outil en ligne de commande exiftool.
"""
import logging
import shutil
import subprocess
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Extensions supportant l'EXIF intégré
EMBEDDED_EXIF_EXTENSIONS = {'.tif', '.tiff', '.jpg', '.jpeg'}
# Extensions avec support partiel (tentative d'intégration)
PARTIAL_SUPPORT_EXTENSIONS = {'.avif', '.heic', '.heif', '.webp'}
# Extensions nécessitant obligatoirement un fichier XMP sidecar
XMP_ONLY_EXTENSIONS = {'.dng', '.cr2', '.cr3', '.nef', '.arw', '.orf', '.rw2', '.pef', '.raw'}
class ExiftoolError(Exception):
"""Exception levée lors d'une erreur exiftool."""
pass
class ExiftoolNotFoundError(ExiftoolError):
"""Exception levée quand exiftool n'est pas installé."""
pass
def check_exiftool_available() -> bool:
"""
Vérifie que exiftool est installé et accessible.
Returns:
True si exiftool est disponible.
Raises:
ExiftoolNotFoundError: Si exiftool n'est pas trouvé.
"""
if shutil.which('exiftool') is None:
raise ExiftoolNotFoundError(
"exiftool n'est pas installé ou n'est pas dans le PATH.\n"
"Installation :\n"
" - Arch Linux : sudo pacman -S perl-image-exiftool\n"
" - Ubuntu/Debian : sudo apt install libimage-exiftool-perl\n"
" - macOS : brew install exiftool\n"
" - Windows : https://exiftool.org/"
)
# Vérifier que ça fonctionne
try:
result = subprocess.run(
['exiftool', '-ver'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
version = result.stdout.strip()
logger.debug(f"exiftool version {version} détecté")
return True
except subprocess.TimeoutExpired:
raise ExiftoolError("exiftool ne répond pas (timeout)")
except Exception as e:
raise ExiftoolError(f"Erreur lors de la vérification de exiftool : {e}")
return False
def supports_embedded_exif(filepath: Path) -> bool:
"""
Détermine si le format du fichier supporte les métadonnées EXIF intégrées.
Args:
filepath: Chemin vers le fichier image.
Returns:
True si le format supporte l'EXIF intégré, False sinon.
"""
ext = filepath.suffix.lower()
return ext in EMBEDDED_EXIF_EXTENSIONS
def supports_partial_exif(filepath: Path) -> bool:
"""
Détermine si le format a un support partiel des métadonnées.
Ces formats peuvent accepter les métadonnées mais avec des limitations.
Args:
filepath: Chemin vers le fichier image.
Returns:
True si le format a un support partiel.
"""
ext = filepath.suffix.lower()
return ext in PARTIAL_SUPPORT_EXTENSIONS
def write_exif_to_file(filepath: Path, tags: dict, dry_run: bool = False) -> bool:
"""
Écrit les tags EXIF dans un fichier image via exiftool.
Args:
filepath: Chemin vers le fichier image.
tags: Dictionnaire de tags EXIF à écrire.
dry_run: Si True, affiche les commandes sans les exécuter.
Returns:
True si l'écriture a réussi.
Raises:
ExiftoolError: Si l'écriture échoue.
FileNotFoundError: Si le fichier n'existe pas.
"""
filepath = Path(filepath)
if not filepath.exists():
raise FileNotFoundError(f"Fichier image introuvable : {filepath}")
if not tags:
logger.warning(f"Aucun tag à écrire pour {filepath}")
return True
# Construire les arguments exiftool
args = _build_exiftool_args(tags)
# Ajouter les options de base
cmd = [
'exiftool',
'-overwrite_original', # Ne pas créer de backup
'-charset', 'utf8', # Support UTF-8
]
cmd.extend(args)
cmd.append(str(filepath))
logger.debug(f"Commande exiftool : {' '.join(cmd)}")
if dry_run:
logger.info(f"[DRY-RUN] exiftool sur {filepath.name} avec {len(tags)} tags")
return True
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
error_msg = result.stderr.strip() or result.stdout.strip()
raise ExiftoolError(f"Échec de l'écriture EXIF : {error_msg}")
# Vérifier le message de succès
if '1 image files updated' in result.stdout:
logger.info(f"Métadonnées écrites avec succès dans {filepath.name}")
return True
elif 'Warning' in result.stderr:
logger.warning(f"Avertissements lors de l'écriture : {result.stderr}")
return True
else:
logger.debug(f"Sortie exiftool : {result.stdout}")
return True
except subprocess.TimeoutExpired:
raise ExiftoolError(f"Timeout lors de l'écriture dans {filepath}")
except Exception as e:
raise ExiftoolError(f"Erreur lors de l'écriture EXIF : {e}")
def write_exif_with_fallback(
filepath: Path,
tags: dict,
xmp_writer_func: callable,
dry_run: bool = False
) -> tuple[bool, str]:
"""
Tente d'écrire les métadonnées EXIF avec fallback sur XMP sidecar.
Pour les formats avec support partiel (AVIF, HEIC, WebP), tente
d'abord l'écriture directe, puis crée un XMP sidecar en cas d'échec.
Args:
filepath: Chemin vers le fichier image.
tags: Dictionnaire de tags EXIF.
xmp_writer_func: Fonction pour écrire le fichier XMP sidecar.
dry_run: Mode simulation.
Returns:
Tuple (succès, méthode utilisée) où méthode est 'exif' ou 'xmp'.
"""
filepath = Path(filepath)
# Formats supportant pleinement l'EXIF intégré
if supports_embedded_exif(filepath):
success = write_exif_to_file(filepath, tags, dry_run)
return (success, 'exif')
# Formats avec support partiel : tentative puis fallback
if supports_partial_exif(filepath):
try:
success = write_exif_to_file(filepath, tags, dry_run)
if success:
return (True, 'exif')
except ExiftoolError as e:
logger.warning(
f"Échec de l'écriture EXIF pour {filepath.name} : {e}. "
f"Création d'un fichier XMP sidecar."
)
# Fallback : création du fichier XMP sidecar
try:
xmp_path = xmp_writer_func(filepath, tags, dry_run)
if xmp_path:
return (True, 'xmp')
except Exception as e:
logger.error(f"Échec de la création du XMP sidecar : {e}")
return (False, 'none')
return (False, 'none')
def _build_exiftool_args(tags: dict) -> list[str]:
"""
Construit les arguments de ligne de commande pour exiftool.
Args:
tags: Dictionnaire de tags EXIF.
Returns:
Liste d'arguments pour exiftool.
"""
args = []
for tag, value in tags.items():
if value is None:
continue
# Formatage de la valeur selon le type
formatted_value = _format_tag_value(tag, value)
# Construction de l'argument
args.append(f'-{tag}={formatted_value}')
return args
def _format_tag_value(tag: str, value: Any) -> str:
"""
Formate une valeur de tag pour exiftool.
Args:
tag: Nom du tag.
value: Valeur à formater.
Returns:
Valeur formatée en chaîne.
"""
# Gestion des types spéciaux
if isinstance(value, bool):
return '1' if value else '0'
if isinstance(value, float):
# Garder une précision raisonnable
if value == int(value):
return str(int(value))
return f'{value:.6g}'
if isinstance(value, (list, tuple)):
return ', '.join(str(v) for v in value)
return str(value)
def read_exif_from_file(filepath: Path) -> dict:
"""
Lit les métadonnées EXIF d'un fichier image.
Utile pour vérifier les métadonnées après écriture.
Args:
filepath: Chemin vers le fichier image.
Returns:
Dictionnaire des tags EXIF.
Raises:
ExiftoolError: Si la lecture échoue.
"""
filepath = Path(filepath)
if not filepath.exists():
raise FileNotFoundError(f"Fichier image introuvable : {filepath}")
try:
result = subprocess.run(
['exiftool', '-json', '-charset', 'utf8', str(filepath)],
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
raise ExiftoolError(f"Échec de la lecture EXIF : {result.stderr}")
import json
data = json.loads(result.stdout)
return data[0] if data else {}
except subprocess.TimeoutExpired:
raise ExiftoolError(f"Timeout lors de la lecture de {filepath}")
except json.JSONDecodeError as e:
raise ExiftoolError(f"Erreur de parsing JSON : {e}")

View File

@@ -0,0 +1,261 @@
"""
Module de lecture et validation des fichiers JSON Exif Notes.
Ce module fournit les fonctions nécessaires pour charger et valider
les fichiers JSON exportés par l'application Exif Notes.
"""
import json
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class ValidationError(Exception):
"""Exception levée lors d'une erreur de validation du JSON."""
pass
def load_json(filepath: Path) -> dict:
"""
Charge et parse un fichier JSON.
Args:
filepath: Chemin vers le fichier JSON à charger.
Returns:
Le contenu du fichier JSON sous forme de dictionnaire.
Raises:
FileNotFoundError: Si le fichier n'existe pas.
json.JSONDecodeError: Si le fichier n'est pas un JSON valide.
"""
filepath = Path(filepath)
if not filepath.exists():
raise FileNotFoundError(f"Fichier JSON introuvable : {filepath}")
logger.info(f"Chargement du fichier JSON : {filepath}")
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
logger.debug(f"JSON chargé avec succès : {len(str(data))} caractères")
return data
def validate_roll(data: dict) -> bool:
"""
Valide la structure d'un roll (pellicule) dans le JSON.
Vérifie la présence des champs obligatoires et leur type.
Args:
data: Dictionnaire représentant un roll.
Returns:
True si le roll est valide.
Raises:
ValidationError: Si la validation échoue.
"""
required_fields = ['id', 'frames']
for field in required_fields:
if field not in data:
raise ValidationError(f"Champ obligatoire manquant dans le roll : '{field}'")
# Validation de l'ID
if not isinstance(data['id'], (int, str)):
raise ValidationError(f"Le champ 'id' doit être un entier ou une chaîne, "
f"reçu : {type(data['id']).__name__}")
# Validation des frames
if not isinstance(data['frames'], list):
raise ValidationError(f"Le champ 'frames' doit être une liste, "
f"reçu : {type(data['frames']).__name__}")
if len(data['frames']) == 0:
raise ValidationError("Le roll ne contient aucun frame")
# Validation de la caméra (optionnel mais recommandé)
if 'camera' in data and data['camera'] is not None:
_validate_camera(data['camera'])
# Validation du film stock (optionnel)
if 'filmStock' in data and data['filmStock'] is not None:
_validate_film_stock(data['filmStock'])
# Validation de l'ISO (optionnel)
if 'iso' in data and data['iso'] is not None:
if not isinstance(data['iso'], (int, float)):
raise ValidationError(f"Le champ 'iso' doit être numérique, "
f"reçu : {type(data['iso']).__name__}")
# Validation de chaque frame
for i, frame in enumerate(data['frames']):
try:
validate_frame(frame)
except ValidationError as e:
raise ValidationError(f"Erreur dans le frame {i + 1} : {e}")
logger.info(f"Roll validé avec succès : {len(data['frames'])} frames")
return True
def validate_frame(frame: dict) -> bool:
"""
Valide un frame individuel.
Vérifie la présence des champs obligatoires et le format des données.
Args:
frame: Dictionnaire représentant un frame.
Returns:
True si le frame est valide.
Raises:
ValidationError: Si la validation échoue.
"""
if not isinstance(frame, dict):
raise ValidationError(f"Le frame doit être un dictionnaire, "
f"reçu : {type(frame).__name__}")
# Champs obligatoires
required_fields = ['id']
for field in required_fields:
if field not in frame:
raise ValidationError(f"Champ obligatoire manquant : '{field}'")
# Validation de l'ID du frame
if not isinstance(frame['id'], (int, str)):
raise ValidationError("Le champ 'id' doit être un entier ou une chaîne")
# Validation de la date (optionnel mais important)
if 'date' in frame and frame['date'] is not None:
_validate_date(frame['date'])
# Validation des coordonnées GPS (optionnel)
if 'location' in frame and frame['location'] is not None:
_validate_location(frame['location'])
# Validation de l'ouverture (optionnel)
if 'aperture' in frame and frame['aperture'] is not None:
_validate_aperture(frame['aperture'])
# Validation de la vitesse d'obturation (optionnel)
if 'shutter' in frame and frame['shutter'] is not None:
_validate_shutter(frame['shutter'])
# Validation de la focale (optionnel)
if 'focalLength' in frame and frame['focalLength'] is not None:
if not isinstance(frame['focalLength'], (int, float)):
raise ValidationError("Le champ 'focalLength' doit être numérique")
return True
def _validate_camera(camera: Any) -> None:
"""Valide les données de l'appareil photo."""
if not isinstance(camera, dict):
raise ValidationError("Le champ 'camera' doit être un dictionnaire")
# Make et model sont optionnels mais doivent être des chaînes si présents
if 'make' in camera and camera['make'] is not None:
if not isinstance(camera['make'], str):
raise ValidationError("camera.make doit être une chaîne")
if 'model' in camera and camera['model'] is not None:
if not isinstance(camera['model'], str):
raise ValidationError("camera.model doit être une chaîne")
def _validate_film_stock(film_stock: Any) -> None:
"""Valide les données du film."""
if not isinstance(film_stock, dict):
raise ValidationError("Le champ 'filmStock' doit être un dictionnaire")
if 'make' in film_stock and film_stock['make'] is not None:
if not isinstance(film_stock['make'], str):
raise ValidationError("filmStock.make doit être une chaîne")
if 'model' in film_stock and film_stock['model'] is not None:
if not isinstance(film_stock['model'], str):
raise ValidationError("filmStock.model doit être une chaîne")
def _validate_date(date: Any) -> None:
"""Valide le format de la date."""
if not isinstance(date, str):
raise ValidationError(f"La date doit être une chaîne, reçu : {type(date).__name__}")
# On accepte les formats ISO 8601 courants
# La validation complète sera faite lors du parsing
def _validate_location(location: Any) -> None:
"""Valide les coordonnées GPS."""
if not isinstance(location, dict):
raise ValidationError("Le champ 'location' doit être un dictionnaire")
if 'latitude' in location and location['latitude'] is not None:
if not isinstance(location['latitude'], (int, float)):
raise ValidationError("latitude doit être numérique")
if not -90 <= location['latitude'] <= 90:
raise ValidationError(f"latitude hors limites : {location['latitude']}")
if 'longitude' in location and location['longitude'] is not None:
if not isinstance(location['longitude'], (int, float)):
raise ValidationError("longitude doit être numérique")
if not -180 <= location['longitude'] <= 180:
raise ValidationError(f"longitude hors limites : {location['longitude']}")
def _validate_aperture(aperture: Any) -> None:
"""Valide l'ouverture."""
if isinstance(aperture, (int, float)):
if aperture <= 0:
raise ValidationError(f"L'ouverture doit être positive : {aperture}")
elif isinstance(aperture, str):
# Format accepté : "f/2.8" ou "2.8"
value = aperture.lower().replace('f/', '').replace('f', '')
try:
float(value)
except ValueError:
raise ValidationError(f"Format d'ouverture invalide : {aperture}")
else:
raise ValidationError("L'ouverture doit être numérique ou une chaîne")
def _validate_shutter(shutter: Any) -> None:
"""Valide la vitesse d'obturation."""
if isinstance(shutter, (int, float)):
if shutter <= 0:
raise ValidationError(f"La vitesse doit être positive : {shutter}")
elif isinstance(shutter, str):
# Mode Bulb : accepté tel quel
if shutter.lower() in ('b', 'bulb'):
return
# Formats acceptés : "1/125", "1/125s", "2s", "2", "2""
# Supprime les suffixes de temps (s, ", '')
value = shutter.lower().replace('"', '').replace("'", '').replace('s', '').strip()
if '/' in value:
parts = value.split('/')
if len(parts) != 2:
raise ValidationError(f"Format de vitesse invalide : {shutter}")
try:
float(parts[0])
float(parts[1])
except ValueError:
raise ValidationError(f"Format de vitesse invalide : {shutter}")
else:
try:
float(value)
except ValueError:
raise ValidationError(f"Format de vitesse invalide : {shutter}")
else:
raise ValidationError("La vitesse doit être numérique ou une chaîne")

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)

View File

@@ -0,0 +1,254 @@
"""
Module de génération des fichiers XMP sidecar.
Ce module crée des fichiers XMP sidecar pour les images dont le format
ne supporte pas les métadonnées EXIF intégrées.
"""
import logging
from datetime import datetime
from pathlib import Path
from typing import Any
from xml.sax.saxutils import escape
logger = logging.getLogger(__name__)
# Template XMP de base
XMP_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="json-to-metadata">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:exif="http://ns.adobe.com/exif/1.0/"
xmlns:tiff="http://ns.adobe.com/tiff/1.0/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:aux="http://ns.adobe.com/exif/1.0/aux/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
{properties}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>'''
def generate_xmp_content(tags: dict) -> str:
"""
Génère le contenu XML d'un fichier XMP à partir des tags EXIF.
Args:
tags: Dictionnaire de tags EXIF.
Returns:
Contenu XML du fichier XMP.
"""
properties = []
for tag, value in tags.items():
if value is None:
continue
xmp_property = _convert_exif_to_xmp(tag, value)
if xmp_property:
properties.append(xmp_property)
# Ajouter la date de modification
now = datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
properties.append(f' <xmp:ModifyDate>{now}</xmp:ModifyDate>')
properties_str = '\n'.join(properties)
return XMP_TEMPLATE.format(properties=properties_str)
def write_xmp_sidecar(
image_path: Path,
tags: dict,
dry_run: bool = False
) -> Path:
"""
Crée un fichier XMP sidecar pour une image.
Le fichier XMP est créé avec le même nom que l'image mais avec
l'extension .xmp.
Args:
image_path: Chemin vers le fichier image.
tags: Dictionnaire de tags EXIF.
dry_run: Si True, affiche les actions sans les exécuter.
Returns:
Chemin vers le fichier XMP créé.
Raises:
IOError: Si l'écriture échoue.
"""
image_path = Path(image_path)
xmp_path = image_path.with_suffix('.xmp')
logger.debug(f"Génération du fichier XMP : {xmp_path}")
xmp_content = generate_xmp_content(tags)
if dry_run:
logger.info(f"[DRY-RUN] Création de {xmp_path.name}")
return xmp_path
try:
with open(xmp_path, 'w', encoding='utf-8') as f:
f.write(xmp_content)
logger.info(f"Fichier XMP créé : {xmp_path.name}")
return xmp_path
except IOError as e:
logger.error(f"Impossible de créer le fichier XMP : {e}")
raise
def _convert_exif_to_xmp(tag: str, value: Any) -> str | None:
"""
Convertit un tag EXIF en propriété XMP.
Args:
tag: Nom du tag EXIF.
value: Valeur du tag.
Returns:
Ligne XML pour la propriété XMP, ou None si non supporté.
"""
# Échapper les caractères spéciaux XML
str_value = escape(str(value))
# Mapping des tags simples (valeur string directe)
simple_tags = {
'Make': 'tiff:Make',
'Model': 'tiff:Model',
'LensMake': 'aux:LensMake',
'LensModel': 'aux:Lens',
'ShutterSpeedValue': 'exif:ShutterSpeedValue',
'GPSLatitude': 'exif:GPSLatitude',
'GPSLatitudeRef': 'exif:GPSLatitudeRef',
'GPSLongitude': 'exif:GPSLongitude',
'GPSLongitudeRef': 'exif:GPSLongitudeRef',
'UserComment': 'exif:UserComment',
'LightSource': 'exif:LightSource',
'ImageNumber': 'exif:ImageNumber',
}
if tag in simple_tags:
xmp_tag = simple_tags[tag]
return f' <{xmp_tag}>{str_value}</{xmp_tag}>'
# Tags avec format rationnel
rational_tags = {
'ExposureTime': 'exif:ExposureTime',
'FNumber': 'exif:FNumber',
'ApertureValue': 'exif:ApertureValue',
'FocalLength': 'exif:FocalLength',
'ExposureCompensation': 'exif:ExposureCompensation',
'GPSAltitude': 'exif:GPSAltitude',
}
if tag in rational_tags:
xmp_tag = rational_tags[tag]
return f' <{xmp_tag}>{_format_rational(value)}</{xmp_tag}>'
# Tags avec format spécial
if tag == 'ISO':
return f' <exif:ISOSpeedRatings>{_format_seq(value)}</exif:ISOSpeedRatings>'
if tag == 'DateTimeOriginal':
return f' <xmp:CreateDate>{_format_xmp_date(str(value))}</xmp:CreateDate>'
if tag == 'CreateDate':
date_val = _format_xmp_date(str(value))
return f' <photoshop:DateCreated>{date_val}</photoshop:DateCreated>'
if tag == 'GPSAltitudeRef':
ref_value = '0' if 'Above' in str(value) else '1'
return f' <exif:GPSAltitudeRef>{ref_value}</exif:GPSAltitudeRef>'
if tag == 'ImageDescription':
return f' <dc:description>{_format_alt(str_value)}</dc:description>'
if tag == 'Flash':
return f' {_format_flash(value)}'
# Tags XMP personnalisés (déjà préfixés)
if tag.startswith('XMP-'):
# Convertir XMP-dc:Description en dc:description
clean_tag = tag.replace('XMP-', '')
return f' <{clean_tag}>{_format_alt(str_value)}</{clean_tag}>'
logger.debug(f"Tag EXIF non mappé vers XMP : {tag}")
return None
def _format_rational(value: Any) -> str:
"""Formate une valeur en rationnel XMP."""
if isinstance(value, int):
return str(value)
if isinstance(value, float):
# Pour les temps d'exposition comme 1/125
if value < 1 and value > 0:
denominator = round(1 / value)
return f"1/{denominator}"
# Valeurs entières flottantes
if value == int(value):
return str(int(value))
return str(value)
return str(value)
def _format_seq(value: Any) -> str:
"""Formate une valeur en séquence RDF."""
return f'''
<rdf:Seq>
<rdf:li>{value}</rdf:li>
</rdf:Seq>
'''
def _format_alt(value: str) -> str:
"""Formate une valeur en alternative RDF (pour les textes multilingues)."""
return f'''
<rdf:Alt>
<rdf:li xml:lang="x-default">{value}</rdf:li>
</rdf:Alt>
'''
def _format_xmp_date(value: str) -> str:
"""
Convertit une date EXIF en format XMP.
EXIF: 2024:03:15 14:30:00
XMP: 2024-03-15T14:30:00
"""
if 'T' in value:
# Déjà au format ISO
return value
# Convertir le format EXIF
try:
parts = value.replace(':', '-', 2).split(' ')
if len(parts) == 2:
return f"{parts[0]}T{parts[1]}"
return parts[0]
except Exception:
return value
def _format_flash(value: Any) -> str:
"""
Formate la valeur du flash pour XMP.
Retourne une structure Flash complète.
"""
fired = bool(value) if not isinstance(value, bool) else value
return f'''
<exif:Flash>
<rdf:Description>
<exif:Fired>{'True' if fired else 'False'}</exif:Fired>
<exif:Mode>0</exif:Mode>
</rdf:Description>
</exif:Flash>
'''.strip()