import io import logging import subprocess 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") # 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") 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 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 self._preview_webcam_fallback = None self.preview_dslr_ok = True # True si le dernier preview DSLR a reussi def connecter(self, source=None) -> bool: """Connecte la camera. source: 'gphoto2', 'webcam:0', 'webcam:2', etc.""" self.deconnecter() 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.camera = gp.Camera() self.camera.init() 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 self.mode = "webcam" log.info(f"Webcam connectee : /dev/video{index}") return True log.error(f"Impossible d'ouvrir /dev/video{index}") return False def deconnecter(self): """Deconnecte l'appareil photo.""" if self._preview_webcam_fallback: self._preview_webcam_fallback.release() self._preview_webcam_fallback = None if self.camera and GPHOTO2_DISPONIBLE: try: self.camera.exit() 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: """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 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: from PIL import Image as PILImage for attempt in range(3): try: chemin_camera = self.camera.capture(gp.GP_CAPTURE_IMAGE) fichier_camera = gp.CameraFile() self.camera.file_get( chemin_camera.folder, chemin_camera.name, gp.GP_FILE_TYPE_NORMAL, fichier_camera ) # Sauvegarder le fichier brut temporairement tmp_path = str(chemin_dest) + ".tmp" fichier_camera.save(tmp_path) # Redimensionner (max 4000px de large, JPEG 92%) img = PILImage.open(tmp_path) if img.width > 4000: ratio = 4000 / img.width img = img.resize((4000, int(img.height * ratio)), PILImage.LANCZOS) img.save(str(chemin_dest), "JPEG", quality=92) Path(tmp_path).unlink(missing_ok=True) log.info(f"Photo capturee (DSLR) : {chemin_dest} ({img.width}x{img.height})") return chemin_dest except gp.GPhoto2Error as e: if "I/O in progress" in str(e) and attempt < 2: log.warning(f"DSLR occupe, retry {attempt + 1}/3...") time.sleep(0.5) continue 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 self.mode == "webcam": return self._preview_webcam() elif self.mode == "gphoto2": data = self._preview_gphoto2() if data: self.preview_dslr_ok = True return data self.preview_dslr_ok = False # Fallback webcam pour le preview si le DSLR ne supporte pas le live view if not self._preview_webcam_fallback: self._init_preview_fallback() if self._preview_webcam_fallback: ret, frame = self._preview_webcam_fallback.read() if ret: _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 70]) return buf.tobytes() return self._preview_simulation() else: return self._preview_simulation() def _init_preview_fallback(self): """Ouvre une webcam pour le preview quand le DSLR ne supporte pas le live view.""" webcams = lister_webcams() for wc in webcams: cap = cv2.VideoCapture(wc["index"]) if cap.isOpened(): cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) self._preview_webcam_fallback = cap log.info(f"Preview fallback webcam : {wc['nom']}") return log.warning("Aucune webcam disponible pour le preview fallback") def _preview_gphoto2(self) -> bytes | None: try: fichier = self.camera.capture_preview() donnees = fichier.get_data_and_size() return bytes(donnees) except gp.GPhoto2Error as e: log.error(f"Erreur preview DSLR : {e}") return None 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 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: dslrs = gp.Camera.autodetect() 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.""" 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") 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()