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:
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")
|
||||
Reference in New Issue
Block a user