Supprimer GIF, multi-shot en format 10x15cm

- Suppression du mode GIF (gif_maker.py, routes, UI)
- Multi-shot (strip/collage) produit maintenant des images 10x15cm
  (1200x1800px @ 300dpi) pret pour impression sublimation
- Recadrage intelligent (crop centre) pour remplir les cellules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 22:55:49 +01:00
parent c8e4ec80c1
commit 3b3271a32f
9 changed files with 56 additions and 106 deletions

View File

@@ -7,55 +7,80 @@ from backend.config import DOSSIER_EXPORTS, charger_config
log = logging.getLogger("photobooth.collage")
# Format 10x15cm a 300dpi
LARGEUR_10x15 = 1200
HAUTEUR_10x15 = 1800
def creer_strip(photos: list[Path], marge: int = 20, couleur_fond: str = "#ffffff") -> Path:
"""Cree une bande verticale (strip) avec les photos."""
def _crop_center(img: Image.Image, ratio_cible: float) -> Image.Image:
"""Recadre une image au centre pour correspondre au ratio cible."""
ratio_img = img.width / img.height
if ratio_img > ratio_cible:
# Trop large, couper les cotes
nouvelle_largeur = int(img.height * ratio_cible)
offset = (img.width - nouvelle_largeur) // 2
return img.crop((offset, 0, offset + nouvelle_largeur, img.height))
else:
# Trop haut, couper haut/bas
nouvelle_hauteur = int(img.width / ratio_cible)
offset = (img.height - nouvelle_hauteur) // 2
return img.crop((0, offset, img.width, offset + nouvelle_hauteur))
def creer_strip(photos: list[Path], marge: int = 30, couleur_fond: str = "#ffffff") -> Path:
"""Cree une bande verticale (strip) au format 10x15cm."""
nb = len(photos)
images = [Image.open(p) for p in photos]
# Uniformiser la largeur
largeur = min(img.width for img in images)
# Calculer la taille de chaque cellule dans le format 10x15
espace_disponible_h = HAUTEUR_10x15 - marge * (nb + 1)
hauteur_cellule = espace_disponible_h // nb
largeur_cellule = LARGEUR_10x15 - marge * 2
ratio_cellule = largeur_cellule / hauteur_cellule
# Recadrer et redimensionner chaque photo
images_redim = []
for img in images:
ratio = largeur / img.width
hauteur = int(img.height * ratio)
images_redim.append(img.resize((largeur, hauteur), Image.LANCZOS))
img_crop = _crop_center(img, ratio_cellule)
img_redim = img_crop.resize((largeur_cellule, hauteur_cellule), Image.LANCZOS)
images_redim.append(img_redim)
hauteur_totale = sum(img.height for img in images_redim) + marge * (len(images_redim) + 1)
largeur_totale = largeur + marge * 2
strip = Image.new("RGB", (largeur_totale, hauteur_totale), couleur_fond)
# Assembler sur le canvas 10x15
strip = Image.new("RGB", (LARGEUR_10x15, HAUTEUR_10x15), couleur_fond)
y = marge
for img in images_redim:
strip.paste(img, (marge, y))
y += img.height + marge
y += hauteur_cellule + marge
nom = f"strip_{photos[0].stem}.jpg"
chemin = DOSSIER_EXPORTS / nom
strip.save(chemin, "JPEG", quality=95)
log.info(f"Strip cree : {chemin}")
strip.save(chemin, "JPEG", quality=95, dpi=(300, 300))
log.info(f"Strip 10x15 cree : {chemin} ({nb} photos)")
return chemin
def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 15,
def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 25,
couleur_fond: str = "#ffffff") -> Path:
"""Cree un collage en grille avec les photos."""
"""Cree un collage en grille au format 10x15cm."""
nb = len(photos)
images = [Image.open(p) for p in photos]
# Taille uniforme pour chaque cellule
largeur_cellule = min(img.width for img in images)
hauteur_cellule = min(img.height for img in images)
lignes = (nb + colonnes - 1) // colonnes
# Calculer la taille de chaque cellule
largeur_cellule = (LARGEUR_10x15 - marge * (colonnes + 1)) // colonnes
hauteur_cellule = (HAUTEUR_10x15 - marge * (lignes + 1)) // lignes
ratio_cellule = largeur_cellule / hauteur_cellule
# Recadrer et redimensionner
images_redim = []
for img in images:
img_crop = ImageDraw.Draw(img)
img_redim = img.resize((largeur_cellule, hauteur_cellule), Image.LANCZOS)
img_crop = _crop_center(img, ratio_cellule)
img_redim = img_crop.resize((largeur_cellule, hauteur_cellule), Image.LANCZOS)
images_redim.append(img_redim)
lignes = (len(images_redim) + colonnes - 1) // colonnes
largeur_totale = colonnes * largeur_cellule + (colonnes + 1) * marge
hauteur_totale = lignes * hauteur_cellule + (lignes + 1) * marge
collage = Image.new("RGB", (largeur_totale, hauteur_totale), couleur_fond)
# Assembler sur le canvas 10x15
collage = Image.new("RGB", (LARGEUR_10x15, HAUTEUR_10x15), couleur_fond)
for i, img in enumerate(images_redim):
col = i % colonnes
@@ -66,6 +91,6 @@ def creer_collage(photos: list[Path], colonnes: int = 2, marge: int = 15,
nom = f"collage_{photos[0].stem}.jpg"
chemin = DOSSIER_EXPORTS / nom
collage.save(chemin, "JPEG", quality=95)
log.info(f"Collage cree : {chemin}")
collage.save(chemin, "JPEG", quality=95, dpi=(300, 300))
log.info(f"Collage 10x15 cree : {chemin} ({nb} photos, {colonnes}x{lignes})")
return chemin

View File

@@ -1,41 +0,0 @@
import logging
from pathlib import Path
from PIL import Image
from backend.config import DOSSIER_EXPORTS, charger_config
log = logging.getLogger("photobooth.gif")
def creer_gif(photos: list[Path], duree_frame_ms: int | None = None) -> Path:
"""Cree un GIF anime a partir d'une liste de photos."""
config = charger_config()
if duree_frame_ms is None:
duree_frame_ms = config.get("gif", {}).get("duree_frame_ms", 300)
images = []
for p in photos:
img = Image.open(p).convert("RGB")
# Redimensionner pour un GIF leger
img.thumbnail((800, 600), Image.LANCZOS)
images.append(img)
if not images:
log.error("Aucune image pour le GIF")
return None
nom = f"gif_{photos[0].stem}.gif"
chemin = DOSSIER_EXPORTS / nom
images[0].save(
chemin,
save_all=True,
append_images=images[1:],
duration=duree_frame_ms,
loop=0,
optimize=True,
)
log.info(f"GIF cree : {chemin} ({len(images)} frames, {duree_frame_ms}ms)")
return chemin

View File

@@ -17,7 +17,7 @@ from backend.camera import camera
from backend.gallery import lister_photos, compter_photos, supprimer_photo, vider_galerie
from backend.effects import appliquer_filtre, appliquer_overlay, chroma_key, lister_overlays, lister_fonds, FILTRES
from backend.collage import creer_strip, creer_collage
from backend.gif_maker import creer_gif
from backend.printer import lister_imprimantes, imprimer
from backend.mailer import envoyer_photo
from backend.qrcode_gen import generer_qr, qr_galerie
@@ -163,7 +163,7 @@ async def api_fonds():
return lister_fonds()
# --- API Collage / GIF ---
# --- API Collage ---
@app.post("/api/strip")
async def api_strip(donnees: dict):
@@ -188,18 +188,6 @@ async def api_collage(donnees: dict):
return {"nom": chemin.name, "chemin": f"/data/exports/{chemin.name}"}
@app.post("/api/gif")
async def api_gif(donnees: dict):
noms = donnees.get("photos", [])
chemins = [DOSSIER_PHOTOS / n for n in noms]
for c in chemins:
if not c.exists():
return JSONResponse({"erreur": f"Photo introuvable : {c.name}"}, status_code=404)
chemin = creer_gif(chemins)
if chemin is None:
return JSONResponse({"erreur": "Echec creation GIF"}, status_code=500)
return {"nom": chemin.name, "chemin": f"/data/exports/{chemin.name}"}
# --- API Impression ---

View File

@@ -2,7 +2,6 @@ fastapi==0.115.0
uvicorn[standard]==0.30.0
python-gphoto2==2.5.0
Pillow==10.4.0
imageio==2.35.0
opencv-python-headless==4.10.0.84
qrcode[pil]==7.4.2
pycups==2.0.4

View File

@@ -9,7 +9,6 @@
"fonctionnalites": {
"photo_simple": true,
"multi_shot": true,
"gif": true,
"filtres": true,
"overlays": true,
"chroma_key": false,
@@ -46,11 +45,6 @@
"mode": "strip",
"delai_entre_photos": 2
},
"gif": {
"nombre_frames": 4,
"delai_entre_frames": 0.8,
"duree_frame_ms": 300
},
"chroma_key": {
"couleur": "#00ff00",
"tolerance": 40,

View File

@@ -34,10 +34,6 @@
<div class="mode-icone">&#127924;</div>
<span>Multi-shot</span>
</button>
<button class="btn-mode" data-mode="gif" id="btn-gif">
<div class="mode-icone">&#127910;</div>
<span>GIF</span>
</button>
</div>
<button class="btn-retour" onclick="allerA('accueil')">Retour</button>
</section>
@@ -143,7 +139,6 @@
<div class="toggle-groupe">
<label class="toggle"><input type="checkbox" id="tog-photo-simple" checked><span class="toggle-slider"></span> Photo simple</label>
<label class="toggle"><input type="checkbox" id="tog-multi-shot"><span class="toggle-slider"></span> Multi-shot</label>
<label class="toggle"><input type="checkbox" id="tog-gif"><span class="toggle-slider"></span> GIF anime</label>
<label class="toggle"><input type="checkbox" id="tog-filtres"><span class="toggle-slider"></span> Filtres</label>
<label class="toggle"><input type="checkbox" id="tog-overlays"><span class="toggle-slider"></span> Overlays / Cadres</label>
<label class="toggle"><input type="checkbox" id="tog-chroma-key"><span class="toggle-slider"></span> Chroma key (fond vert)</label>

View File

@@ -4,7 +4,6 @@
const TOGGLES_MAP = {
'tog-photo-simple': 'photo_simple',
'tog-multi-shot': 'multi_shot',
'tog-gif': 'gif',
'tog-filtres': 'filtres',
'tog-overlays': 'overlays',
'tog-chroma-key': 'chroma_key',

View File

@@ -30,7 +30,6 @@ function appliquerConfig() {
// Visibilite des modes
toggleVisible('btn-multi', fonc.multi_shot);
toggleVisible('btn-gif', fonc.gif);
toggleVisible('btn-imprimer', fonc.impression);
toggleVisible('btn-email', fonc.email);
toggleVisible('btn-qr', fonc.qr_code);

View File

@@ -71,7 +71,6 @@ async function lancerCapture() {
function getNombrePhotos() {
if (modeActuel === 'simple') return 1;
if (modeActuel === 'multi') return config.multi_shot?.nombre_photos || 3;
if (modeActuel === 'gif') return config.gif?.nombre_frames || 4;
return 1;
}
@@ -109,14 +108,7 @@ async function traiterCapture() {
return;
}
if (modeActuel === 'gif') {
// Creer le GIF
const resultat = await apiPost('/api/gif', { photos: photosSession });
if (resultat.nom) {
photoFinale = resultat.nom;
afficherPreviewPhoto('/data/exports/' + resultat.nom);
}
} else if (modeActuel === 'multi') {
if (modeActuel === 'multi') {
// Creer le strip/collage
const mode = config.multi_shot?.mode || 'strip';
let resultat;