From 85ab7b8153f1b75133d7d449ffa1867d615c4fa1 Mon Sep 17 00:00:00 2001 From: Jules Date: Tue, 7 Apr 2026 15:51:21 +0200 Subject: [PATCH] Support webcam (OpenCV) + optimisation tactile - Camera: support webcam v4l2 via OpenCV en plus de gphoto2 - API: selection de source camera (webcam:0, gphoto2, etc.) - CSS: boutons agrandis pour usage tactile (min 48px) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/camera.py | 189 ++++++++++++++++++++++++++++++++++------- backend/main.py | 8 +- frontend/css/style.css | 24 +++--- 3 files changed, 176 insertions(+), 45 deletions(-) diff --git a/backend/camera.py b/backend/camera.py index 4163a5e..89b5c28 100644 --- a/backend/camera.py +++ b/backend/camera.py @@ -5,6 +5,8 @@ import time from datetime import datetime from pathlib import Path +import cv2 + from backend.config import DOSSIER_PHOTOS, charger_config log = logging.getLogger("photobooth.camera") @@ -15,35 +17,100 @@ try: GPHOTO2_DISPONIBLE = True except ImportError: GPHOTO2_DISPONIBLE = False - log.warning("python-gphoto2 non installe, mode simulation active") + log.warning("python-gphoto2 non installe") + + +def lister_webcams() -> list[dict]: + """Liste les webcams disponibles via v4l2.""" + webcams = [] + try: + result = subprocess.run( + ["v4l2-ctl", "--list-devices"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + lines = result.stdout.strip().split("\n") + nom = "" + for line in lines: + if not line.startswith("\t"): + nom = line.strip().rstrip(":") + elif "/dev/video" in line: + dev = line.strip() + # Ne garder que les devices principaux (pas metadata) + idx = int(dev.replace("/dev/video", "")) + cap = cv2.VideoCapture(idx) + if cap.isOpened(): + cap.release() + webcams.append({"nom": nom, "device": dev, "index": idx}) + nom = "" # Eviter les doublons + except (FileNotFoundError, subprocess.TimeoutExpired): + # v4l2-ctl non installe, fallback + for i in range(4): + cap = cv2.VideoCapture(i) + if cap.isOpened(): + cap.release() + webcams.append({"nom": f"Camera {i}", "device": f"/dev/video{i}", "index": i}) + return webcams class Camera: - """Controle de l'appareil DSLR via gphoto2.""" + """Controle camera : DSLR (gphoto2), webcam (OpenCV) ou simulation.""" def __init__(self): self.camera = None self.contexte = None self.connectee = False + self.mode = "simulation" # "gphoto2", "webcam", "simulation" + self.webcam = None + self.webcam_index = -1 - def connecter(self) -> bool: - """Connecte l'appareil photo.""" - if not GPHOTO2_DISPONIBLE: - log.info("Mode simulation : camera virtuelle connectee") - self.connectee = True - return True + def connecter(self, source=None) -> bool: + """Connecte la camera. source: 'gphoto2', 'webcam:0', 'webcam:2', etc.""" + self.deconnecter() - try: - self.contexte = gp.Context() - self.camera = gp.Camera() - self.camera.init(self.contexte) + if source and source.startswith("webcam:"): + idx = int(source.split(":")[1]) + return self._connecter_webcam(idx) + + if source == "gphoto2" or source is None: + if GPHOTO2_DISPONIBLE: + try: + self.contexte = gp.Context() + self.camera = gp.Camera() + self.camera.init(self.contexte) + self.connectee = True + self.mode = "gphoto2" + log.info("Camera DSLR connectee") + return True + except gp.GPhoto2Error as e: + log.warning(f"Pas de DSLR : {e}") + + # Fallback webcam si pas de source specifique + if source is None: + webcams = lister_webcams() + if webcams: + return self._connecter_webcam(webcams[0]["index"]) + + # Fallback simulation + log.info("Mode simulation active") + self.connectee = True + self.mode = "simulation" + return True + + def _connecter_webcam(self, index: int) -> bool: + """Connecte une webcam via OpenCV.""" + cap = cv2.VideoCapture(index) + if cap.isOpened(): + cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080) + self.webcam = cap + self.webcam_index = index self.connectee = True - log.info("Camera DSLR connectee") + self.mode = "webcam" + log.info(f"Webcam connectee : /dev/video{index}") return True - except gp.GPhoto2Error as e: - log.error(f"Erreur connexion camera : {e}") - self.connectee = False - return False + log.error(f"Impossible d'ouvrir /dev/video{index}") + return False def deconnecter(self): """Deconnecte l'appareil photo.""" @@ -52,9 +119,14 @@ class Camera: self.camera.exit(self.contexte) except gp.GPhoto2Error: pass + if self.webcam: + self.webcam.release() self.camera = None self.contexte = None + self.webcam = None + self.webcam_index = -1 self.connectee = False + self.mode = "simulation" log.info("Camera deconnectee") def capturer(self) -> Path | None: @@ -67,47 +139,101 @@ class Camera: nom_fichier = f"photo_{horodatage}.jpg" chemin_dest = DOSSIER_PHOTOS / nom_fichier - if not GPHOTO2_DISPONIBLE: + if self.mode == "webcam": + return self._capture_webcam(chemin_dest) + elif self.mode == "gphoto2": + return self._capture_gphoto2(chemin_dest) + else: return self._capture_simulation(chemin_dest) + def _capture_gphoto2(self, chemin_dest: Path) -> Path | None: 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}") + log.info(f"Photo capturee (DSLR) : {chemin_dest}") return chemin_dest except gp.GPhoto2Error as e: - log.error(f"Erreur capture : {e}") + log.error(f"Erreur capture DSLR : {e}") return None + def _capture_webcam(self, chemin_dest: Path) -> Path | None: + if not self.webcam or not self.webcam.isOpened(): + return None + ret, frame = self.webcam.read() + if ret: + cv2.imwrite(str(chemin_dest), frame, [cv2.IMWRITE_JPEG_QUALITY, 95]) + log.info(f"Photo capturee (webcam) : {chemin_dest}") + return chemin_dest + log.error("Erreur capture webcam") + return None + def preview(self) -> bytes | None: """Capture un apercu live (preview) en JPEG.""" if not self.connectee: return None - if not GPHOTO2_DISPONIBLE: + if self.mode == "webcam": + return self._preview_webcam() + elif self.mode == "gphoto2": + return self._preview_gphoto2() + else: return self._preview_simulation() + def _preview_gphoto2(self) -> bytes | None: 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}") + log.error(f"Erreur preview DSLR : {e}") return None - def lister_appareils(self) -> list[str]: - """Liste les appareils photo detectes.""" - if not GPHOTO2_DISPONIBLE: - return ["[Simulation] Camera virtuelle"] + def _preview_webcam(self) -> bytes | None: + if not self.webcam or not self.webcam.isOpened(): + return None + ret, frame = self.webcam.read() + if ret: + _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 70]) + return buf.tobytes() + return None - try: - appareils = gp.Camera.autodetect(self.contexte or gp.Context()) - return [f"{nom} ({port})" for nom, port in appareils] - except gp.GPhoto2Error: - return [] + def lister_appareils(self) -> list[dict]: + """Liste tous les appareils disponibles (DSLR + webcams).""" + appareils = [] + + # Webcams + for wc in lister_webcams(): + appareils.append({ + "nom": wc["nom"], + "source": f"webcam:{wc['index']}", + "type": "webcam" + }) + + # DSLR via gphoto2 + if GPHOTO2_DISPONIBLE: + try: + ctx = self.contexte or gp.Context() + dslrs = gp.Camera.autodetect(ctx) + for nom, port in dslrs: + appareils.append({ + "nom": f"{nom} ({port})", + "source": "gphoto2", + "type": "dslr" + }) + except gp.GPhoto2Error: + pass + + if not appareils: + appareils.append({ + "nom": "Simulation", + "source": "simulation", + "type": "simulation" + }) + + return appareils def _capture_simulation(self, chemin: Path) -> Path: """Genere une image de test en mode simulation.""" @@ -117,7 +243,6 @@ class Camera: 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}") diff --git a/backend/main.py b/backend/main.py index 1a0a1dd..9711949 100644 --- a/backend/main.py +++ b/backend/main.py @@ -103,15 +103,17 @@ async def api_preview(): async def api_camera_statut(): return { "connectee": camera.connectee, + "mode": camera.mode, "appareils": camera.lister_appareils(), } @app.post("/api/camera/reconnecter") -async def api_camera_reconnecter(): +async def api_camera_reconnecter(body: dict = {}): camera.deconnecter() - ok = camera.connecter() - return {"connectee": ok} + source = body.get("source") + ok = camera.connecter(source=source) + return {"connectee": ok, "mode": camera.mode} # --- API Effets --- diff --git a/frontend/css/style.css b/frontend/css/style.css index 15f9a1d..47db76b 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -109,12 +109,12 @@ html, body { position: absolute; bottom: 12px; right: 12px; - width: 40px; - height: 40px; + width: 56px; + height: 56px; display: flex; align-items: center; justify-content: center; - font-size: 1.2rem; + font-size: 1.5rem; color: rgba(255,255,255,0.15); cursor: pointer; border-radius: 50%; @@ -381,12 +381,13 @@ html, body { background: var(--fond-carte); border: 2px solid transparent; border-radius: 12px; - padding: 0.6rem 1.2rem; + padding: 1rem 1.5rem; color: var(--texte); - font-size: 0.9rem; + font-size: 1.1rem; cursor: pointer; transition: var(--transition); white-space: nowrap; + min-height: 48px; } .btn-filtre.actif, .btn-overlay.actif { @@ -467,8 +468,8 @@ html, body { } .btn-exemplaire { - width: 48px; - height: 48px; + width: 56px; + height: 56px; border-radius: 50%; border: 2px solid var(--primaire); background: transparent; @@ -614,9 +615,10 @@ html, body { color: var(--texte-secondaire); border: 2px solid var(--texte-secondaire); border-radius: 12px; - padding: 0.6rem 1.5rem; - font-size: 1rem; + padding: 1rem 2rem; + font-size: 1.1rem; cursor: pointer; + min-height: 48px; } .btn-fermer { @@ -625,7 +627,9 @@ html, body { color: var(--texte); font-size: 2rem; cursor: pointer; - padding: 0.5rem; + padding: 1rem; + min-width: 48px; + min-height: 48px; } .btn-danger {