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) <noreply@anthropic.com>
This commit is contained in:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user