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:
8
json_to_metadata/__init__.py
Normal file
8
json_to_metadata/__init__.py
Normal 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
323
json_to_metadata/cli.py
Normal 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())
|
||||
320
json_to_metadata/exif_writer.py
Normal file
320
json_to_metadata/exif_writer.py
Normal 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}")
|
||||
261
json_to_metadata/json_parser.py
Normal file
261
json_to_metadata/json_parser.py
Normal 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")
|
||||
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)
|
||||
254
json_to_metadata/xmp_writer.py
Normal file
254
json_to_metadata/xmp_writer.py
Normal 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()
|
||||
Reference in New Issue
Block a user