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:
@@ -7,55 +7,80 @@ from backend.config import DOSSIER_EXPORTS, charger_config
|
|||||||
|
|
||||||
log = logging.getLogger("photobooth.collage")
|
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]
|
images = [Image.open(p) for p in photos]
|
||||||
|
|
||||||
# Uniformiser la largeur
|
# Calculer la taille de chaque cellule dans le format 10x15
|
||||||
largeur = min(img.width for img in images)
|
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 = []
|
images_redim = []
|
||||||
for img in images:
|
for img in images:
|
||||||
ratio = largeur / img.width
|
img_crop = _crop_center(img, ratio_cellule)
|
||||||
hauteur = int(img.height * ratio)
|
img_redim = img_crop.resize((largeur_cellule, hauteur_cellule), Image.LANCZOS)
|
||||||
images_redim.append(img.resize((largeur, hauteur), Image.LANCZOS))
|
images_redim.append(img_redim)
|
||||||
|
|
||||||
hauteur_totale = sum(img.height for img in images_redim) + marge * (len(images_redim) + 1)
|
# Assembler sur le canvas 10x15
|
||||||
largeur_totale = largeur + marge * 2
|
strip = Image.new("RGB", (LARGEUR_10x15, HAUTEUR_10x15), couleur_fond)
|
||||||
|
|
||||||
strip = Image.new("RGB", (largeur_totale, hauteur_totale), couleur_fond)
|
|
||||||
y = marge
|
y = marge
|
||||||
for img in images_redim:
|
for img in images_redim:
|
||||||
strip.paste(img, (marge, y))
|
strip.paste(img, (marge, y))
|
||||||
y += img.height + marge
|
y += hauteur_cellule + marge
|
||||||
|
|
||||||
nom = f"strip_{photos[0].stem}.jpg"
|
nom = f"strip_{photos[0].stem}.jpg"
|
||||||
chemin = DOSSIER_EXPORTS / nom
|
chemin = DOSSIER_EXPORTS / nom
|
||||||
strip.save(chemin, "JPEG", quality=95)
|
strip.save(chemin, "JPEG", quality=95, dpi=(300, 300))
|
||||||
log.info(f"Strip cree : {chemin}")
|
log.info(f"Strip 10x15 cree : {chemin} ({nb} photos)")
|
||||||
return chemin
|
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:
|
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]
|
images = [Image.open(p) for p in photos]
|
||||||
|
|
||||||
# Taille uniforme pour chaque cellule
|
lignes = (nb + colonnes - 1) // colonnes
|
||||||
largeur_cellule = min(img.width for img in images)
|
|
||||||
hauteur_cellule = min(img.height for img in images)
|
|
||||||
|
|
||||||
|
# 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 = []
|
images_redim = []
|
||||||
for img in images:
|
for img in images:
|
||||||
img_crop = ImageDraw.Draw(img)
|
img_crop = _crop_center(img, ratio_cellule)
|
||||||
img_redim = img.resize((largeur_cellule, hauteur_cellule), Image.LANCZOS)
|
img_redim = img_crop.resize((largeur_cellule, hauteur_cellule), Image.LANCZOS)
|
||||||
images_redim.append(img_redim)
|
images_redim.append(img_redim)
|
||||||
|
|
||||||
lignes = (len(images_redim) + colonnes - 1) // colonnes
|
# Assembler sur le canvas 10x15
|
||||||
largeur_totale = colonnes * largeur_cellule + (colonnes + 1) * marge
|
collage = Image.new("RGB", (LARGEUR_10x15, HAUTEUR_10x15), couleur_fond)
|
||||||
hauteur_totale = lignes * hauteur_cellule + (lignes + 1) * marge
|
|
||||||
|
|
||||||
collage = Image.new("RGB", (largeur_totale, hauteur_totale), couleur_fond)
|
|
||||||
|
|
||||||
for i, img in enumerate(images_redim):
|
for i, img in enumerate(images_redim):
|
||||||
col = i % colonnes
|
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"
|
nom = f"collage_{photos[0].stem}.jpg"
|
||||||
chemin = DOSSIER_EXPORTS / nom
|
chemin = DOSSIER_EXPORTS / nom
|
||||||
collage.save(chemin, "JPEG", quality=95)
|
collage.save(chemin, "JPEG", quality=95, dpi=(300, 300))
|
||||||
log.info(f"Collage cree : {chemin}")
|
log.info(f"Collage 10x15 cree : {chemin} ({nb} photos, {colonnes}x{lignes})")
|
||||||
return chemin
|
return chemin
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -17,7 +17,7 @@ from backend.camera import camera
|
|||||||
from backend.gallery import lister_photos, compter_photos, supprimer_photo, vider_galerie
|
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.effects import appliquer_filtre, appliquer_overlay, chroma_key, lister_overlays, lister_fonds, FILTRES
|
||||||
from backend.collage import creer_strip, creer_collage
|
from backend.collage import creer_strip, creer_collage
|
||||||
from backend.gif_maker import creer_gif
|
|
||||||
from backend.printer import lister_imprimantes, imprimer
|
from backend.printer import lister_imprimantes, imprimer
|
||||||
from backend.mailer import envoyer_photo
|
from backend.mailer import envoyer_photo
|
||||||
from backend.qrcode_gen import generer_qr, qr_galerie
|
from backend.qrcode_gen import generer_qr, qr_galerie
|
||||||
@@ -163,7 +163,7 @@ async def api_fonds():
|
|||||||
return lister_fonds()
|
return lister_fonds()
|
||||||
|
|
||||||
|
|
||||||
# --- API Collage / GIF ---
|
# --- API Collage ---
|
||||||
|
|
||||||
@app.post("/api/strip")
|
@app.post("/api/strip")
|
||||||
async def api_strip(donnees: dict):
|
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}"}
|
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 ---
|
# --- API Impression ---
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ fastapi==0.115.0
|
|||||||
uvicorn[standard]==0.30.0
|
uvicorn[standard]==0.30.0
|
||||||
python-gphoto2==2.5.0
|
python-gphoto2==2.5.0
|
||||||
Pillow==10.4.0
|
Pillow==10.4.0
|
||||||
imageio==2.35.0
|
|
||||||
opencv-python-headless==4.10.0.84
|
opencv-python-headless==4.10.0.84
|
||||||
qrcode[pil]==7.4.2
|
qrcode[pil]==7.4.2
|
||||||
pycups==2.0.4
|
pycups==2.0.4
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"fonctionnalites": {
|
"fonctionnalites": {
|
||||||
"photo_simple": true,
|
"photo_simple": true,
|
||||||
"multi_shot": true,
|
"multi_shot": true,
|
||||||
"gif": true,
|
|
||||||
"filtres": true,
|
"filtres": true,
|
||||||
"overlays": true,
|
"overlays": true,
|
||||||
"chroma_key": false,
|
"chroma_key": false,
|
||||||
@@ -46,11 +45,6 @@
|
|||||||
"mode": "strip",
|
"mode": "strip",
|
||||||
"delai_entre_photos": 2
|
"delai_entre_photos": 2
|
||||||
},
|
},
|
||||||
"gif": {
|
|
||||||
"nombre_frames": 4,
|
|
||||||
"delai_entre_frames": 0.8,
|
|
||||||
"duree_frame_ms": 300
|
|
||||||
},
|
|
||||||
"chroma_key": {
|
"chroma_key": {
|
||||||
"couleur": "#00ff00",
|
"couleur": "#00ff00",
|
||||||
"tolerance": 40,
|
"tolerance": 40,
|
||||||
|
|||||||
@@ -34,10 +34,6 @@
|
|||||||
<div class="mode-icone">🎴</div>
|
<div class="mode-icone">🎴</div>
|
||||||
<span>Multi-shot</span>
|
<span>Multi-shot</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-mode" data-mode="gif" id="btn-gif">
|
|
||||||
<div class="mode-icone">🎦</div>
|
|
||||||
<span>GIF</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-retour" onclick="allerA('accueil')">Retour</button>
|
<button class="btn-retour" onclick="allerA('accueil')">Retour</button>
|
||||||
</section>
|
</section>
|
||||||
@@ -143,7 +139,6 @@
|
|||||||
<div class="toggle-groupe">
|
<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-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-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-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-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>
|
<label class="toggle"><input type="checkbox" id="tog-chroma-key"><span class="toggle-slider"></span> Chroma key (fond vert)</label>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
const TOGGLES_MAP = {
|
const TOGGLES_MAP = {
|
||||||
'tog-photo-simple': 'photo_simple',
|
'tog-photo-simple': 'photo_simple',
|
||||||
'tog-multi-shot': 'multi_shot',
|
'tog-multi-shot': 'multi_shot',
|
||||||
'tog-gif': 'gif',
|
|
||||||
'tog-filtres': 'filtres',
|
'tog-filtres': 'filtres',
|
||||||
'tog-overlays': 'overlays',
|
'tog-overlays': 'overlays',
|
||||||
'tog-chroma-key': 'chroma_key',
|
'tog-chroma-key': 'chroma_key',
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ function appliquerConfig() {
|
|||||||
|
|
||||||
// Visibilite des modes
|
// Visibilite des modes
|
||||||
toggleVisible('btn-multi', fonc.multi_shot);
|
toggleVisible('btn-multi', fonc.multi_shot);
|
||||||
toggleVisible('btn-gif', fonc.gif);
|
|
||||||
toggleVisible('btn-imprimer', fonc.impression);
|
toggleVisible('btn-imprimer', fonc.impression);
|
||||||
toggleVisible('btn-email', fonc.email);
|
toggleVisible('btn-email', fonc.email);
|
||||||
toggleVisible('btn-qr', fonc.qr_code);
|
toggleVisible('btn-qr', fonc.qr_code);
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ async function lancerCapture() {
|
|||||||
function getNombrePhotos() {
|
function getNombrePhotos() {
|
||||||
if (modeActuel === 'simple') return 1;
|
if (modeActuel === 'simple') return 1;
|
||||||
if (modeActuel === 'multi') return config.multi_shot?.nombre_photos || 3;
|
if (modeActuel === 'multi') return config.multi_shot?.nombre_photos || 3;
|
||||||
if (modeActuel === 'gif') return config.gif?.nombre_frames || 4;
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,14 +108,7 @@ async function traiterCapture() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modeActuel === 'gif') {
|
if (modeActuel === 'multi') {
|
||||||
// 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') {
|
|
||||||
// Creer le strip/collage
|
// Creer le strip/collage
|
||||||
const mode = config.multi_shot?.mode || 'strip';
|
const mode = config.multi_shot?.mode || 'strip';
|
||||||
let resultat;
|
let resultat;
|
||||||
|
|||||||
Reference in New Issue
Block a user