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

140
backend/camera.py Normal file
View File

@@ -0,0 +1,140 @@
import io
import logging
import subprocess
import time
from datetime import datetime
from pathlib import Path
from backend.config import DOSSIER_PHOTOS, charger_config
log = logging.getLogger("photobooth.camera")
# Essayer d'importer gphoto2, sinon mode simulation
try:
import gphoto2 as gp
GPHOTO2_DISPONIBLE = True
except ImportError:
GPHOTO2_DISPONIBLE = False
log.warning("python-gphoto2 non installe, mode simulation active")
class Camera:
"""Controle de l'appareil DSLR via gphoto2."""
def __init__(self):
self.camera = None
self.contexte = None
self.connectee = False
def connecter(self) -> bool:
"""Connecte l'appareil photo."""
if not GPHOTO2_DISPONIBLE:
log.info("Mode simulation : camera virtuelle connectee")
self.connectee = True
return True
try:
self.contexte = gp.Context()
self.camera = gp.Camera()
self.camera.init(self.contexte)
self.connectee = True
log.info("Camera DSLR connectee")
return True
except gp.GPhoto2Error as e:
log.error(f"Erreur connexion camera : {e}")
self.connectee = False
return False
def deconnecter(self):
"""Deconnecte l'appareil photo."""
if self.camera and GPHOTO2_DISPONIBLE:
try:
self.camera.exit(self.contexte)
except gp.GPhoto2Error:
pass
self.camera = None
self.contexte = None
self.connectee = False
log.info("Camera deconnectee")
def capturer(self) -> Path | None:
"""Capture une photo et retourne le chemin du fichier."""
if not self.connectee:
log.error("Camera non connectee")
return None
horodatage = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
nom_fichier = f"photo_{horodatage}.jpg"
chemin_dest = DOSSIER_PHOTOS / nom_fichier
if not GPHOTO2_DISPONIBLE:
return self._capture_simulation(chemin_dest)
try:
chemin_camera = self.camera.capture(gp.GP_CAPTURE_IMAGE, self.contexte)
fichier_camera = self.camera.file_get(
chemin_camera.folder, chemin_camera.name, gp.GP_FILE_TYPE_NORMAL, self.contexte
)
fichier_camera.save(str(chemin_dest))
log.info(f"Photo capturee : {chemin_dest}")
return chemin_dest
except gp.GPhoto2Error as e:
log.error(f"Erreur capture : {e}")
return None
def preview(self) -> bytes | None:
"""Capture un apercu live (preview) en JPEG."""
if not self.connectee:
return None
if not GPHOTO2_DISPONIBLE:
return self._preview_simulation()
try:
fichier = self.camera.capture_preview(self.contexte)
donnees = fichier.get_data_and_size()
return bytes(donnees)
except gp.GPhoto2Error as e:
log.error(f"Erreur preview : {e}")
return None
def lister_appareils(self) -> list[str]:
"""Liste les appareils photo detectes."""
if not GPHOTO2_DISPONIBLE:
return ["[Simulation] Camera virtuelle"]
try:
appareils = gp.Camera.autodetect(self.contexte or gp.Context())
return [f"{nom} ({port})" for nom, port in appareils]
except gp.GPhoto2Error:
return []
def _capture_simulation(self, chemin: Path) -> Path:
"""Genere une image de test en mode simulation."""
from PIL import Image, ImageDraw, ImageFont
img = Image.new("RGB", (1920, 1280), color=(40, 40, 40))
draw = ImageDraw.Draw(img)
texte = f"SIMULATION\n{datetime.now().strftime('%H:%M:%S')}"
draw.text((960, 640), texte, fill=(255, 255, 255), anchor="mm")
# Dessiner un cadre
draw.rectangle([50, 50, 1870, 1230], outline=(233, 30, 99), width=4)
img.save(chemin, "JPEG", quality=95)
log.info(f"Photo simulation : {chemin}")
return chemin
def _preview_simulation(self) -> bytes:
"""Genere un apercu de test en mode simulation."""
from PIL import Image, ImageDraw
img = Image.new("RGB", (640, 480), color=(30, 30, 30))
draw = ImageDraw.Draw(img)
draw.text((320, 240), "PREVIEW", fill=(200, 200, 200), anchor="mm")
draw.ellipse([300, 220, 340, 260], outline=(233, 30, 99), width=2)
buf = io.BytesIO()
img.save(buf, "JPEG", quality=70)
return buf.getvalue()
# Instance globale
camera = Camera()