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:
2026-04-07 15:51:21 +02:00
parent 8418d481a5
commit 85ab7b8153
3 changed files with 176 additions and 45 deletions

View File

@@ -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}")

View File

@@ -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 ---

View File

@@ -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 {