Init photobooth - borne photo evenementielle RPi4

Backend FastAPI complet : camera gphoto2, filtres/overlays Pillow,
chroma key OpenCV, multi-shot/collage, GIF, impression CUPS, email SMTP,
QR code. Frontend web vanilla (HTML/CSS/JS) pour Chromium kiosk.
Menu admin cache (appui long coin ecran). Toutes les fonctionnalites
activables/desactivables. Scripts install + systemd pour RPi4.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 21:46:31 +01:00
commit c8e4ec80c1
29 changed files with 2854 additions and 0 deletions

142
backend/effects.py Normal file
View File

@@ -0,0 +1,142 @@
import logging
from io import BytesIO
from pathlib import Path
from PIL import Image, ImageEnhance, ImageFilter, ImageOps
from backend.config import DOSSIER_OVERLAYS, DOSSIER_EXPORTS, DOSSIER_FONDS
log = logging.getLogger("photobooth.effects")
# Filtres disponibles
FILTRES = {
"original": "Original",
"nb": "Noir & Blanc",
"sepia": "Sepia",
"vintage": "Vintage",
"contraste": "Contraste fort",
"lumineux": "Lumineux",
"chaud": "Tons chauds",
"froid": "Tons froids",
"flou_artistique": "Flou artistique",
}
def appliquer_filtre(chemin_photo: Path, filtre: str) -> Path:
"""Applique un filtre a une photo et sauvegarde dans exports."""
img = Image.open(chemin_photo)
if filtre == "nb":
img = ImageOps.grayscale(img).convert("RGB")
elif filtre == "sepia":
img = ImageOps.grayscale(img)
img = ImageOps.colorize(img, black="#704214", white="#f0e0c0")
elif filtre == "vintage":
img = ImageOps.grayscale(img)
img = ImageOps.colorize(img, black="#2b1700", white="#f5deb3")
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(0.85)
elif filtre == "contraste":
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(1.5)
elif filtre == "lumineux":
enhancer = ImageEnhance.Brightness(img)
img = enhancer.enhance(1.3)
elif filtre == "chaud":
r, g, b = img.split()
r = r.point(lambda x: min(255, x + 20))
b = b.point(lambda x: max(0, x - 20))
img = Image.merge("RGB", (r, g, b))
elif filtre == "froid":
r, g, b = img.split()
r = r.point(lambda x: max(0, x - 20))
b = b.point(lambda x: min(255, x + 20))
img = Image.merge("RGB", (r, g, b))
elif filtre == "flou_artistique":
img = img.filter(ImageFilter.GaussianBlur(radius=2))
nom_export = f"{chemin_photo.stem}_{filtre}.jpg"
chemin_export = DOSSIER_EXPORTS / nom_export
img.save(chemin_export, "JPEG", quality=95)
log.info(f"Filtre '{filtre}' applique : {chemin_export}")
return chemin_export
def appliquer_overlay(chemin_photo: Path, nom_overlay: str) -> Path:
"""Applique un overlay PNG par-dessus la photo."""
chemin_overlay = DOSSIER_OVERLAYS / nom_overlay
if not chemin_overlay.exists():
log.error(f"Overlay introuvable : {chemin_overlay}")
return chemin_photo
photo = Image.open(chemin_photo).convert("RGBA")
overlay = Image.open(chemin_overlay).convert("RGBA")
overlay = overlay.resize(photo.size, Image.LANCZOS)
composite = Image.alpha_composite(photo, overlay)
resultat = composite.convert("RGB")
nom_export = f"{chemin_photo.stem}_overlay.jpg"
chemin_export = DOSSIER_EXPORTS / nom_export
resultat.save(chemin_export, "JPEG", quality=95)
log.info(f"Overlay '{nom_overlay}' applique : {chemin_export}")
return chemin_export
def chroma_key(chemin_photo: Path, nom_fond: str | None = None,
couleur_cle: str = "#00ff00", tolerance: int = 40) -> Path:
"""Remplace le fond vert par un fond choisi."""
try:
import cv2
import numpy as np
except ImportError:
log.error("OpenCV non installe, chroma key indisponible")
return chemin_photo
img = cv2.imread(str(chemin_photo))
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
# Plage de vert pour le chroma key
vert_bas = np.array([35 - tolerance // 2, 50, 50])
vert_haut = np.array([85 + tolerance // 2, 255, 255])
masque = cv2.inRange(hsv, vert_bas, vert_haut)
# Adoucir le masque
masque = cv2.GaussianBlur(masque, (5, 5), 0)
# Charger le fond
if nom_fond:
chemin_fond = DOSSIER_FONDS / nom_fond
if chemin_fond.exists():
fond = cv2.imread(str(chemin_fond))
fond = cv2.resize(fond, (img.shape[1], img.shape[0]))
else:
fond = np.full_like(img, (200, 200, 200))
else:
fond = np.full_like(img, (200, 200, 200))
# Combiner
masque_inv = cv2.bitwise_not(masque)
premier_plan = cv2.bitwise_and(img, img, mask=masque_inv)
arriere_plan = cv2.bitwise_and(fond, fond, mask=masque)
resultat = cv2.add(premier_plan, arriere_plan)
nom_export = f"{chemin_photo.stem}_chroma.jpg"
chemin_export = DOSSIER_EXPORTS / nom_export
cv2.imwrite(str(chemin_export), resultat)
log.info(f"Chroma key applique : {chemin_export}")
return chemin_export
def lister_overlays() -> list[str]:
"""Liste les overlays disponibles."""
if not DOSSIER_OVERLAYS.exists():
return []
return [f.name for f in DOSSIER_OVERLAYS.iterdir() if f.suffix.lower() == ".png"]
def lister_fonds() -> list[str]:
"""Liste les fonds disponibles pour le chroma key."""
if not DOSSIER_FONDS.exists():
return []
extensions = {".jpg", ".jpeg", ".png"}
return [f.name for f in DOSSIER_FONDS.iterdir() if f.suffix.lower() in extensions]