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 datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
from backend.config import DOSSIER_PHOTOS, charger_config
|
from backend.config import DOSSIER_PHOTOS, charger_config
|
||||||
|
|
||||||
log = logging.getLogger("photobooth.camera")
|
log = logging.getLogger("photobooth.camera")
|
||||||
@@ -15,34 +17,99 @@ try:
|
|||||||
GPHOTO2_DISPONIBLE = True
|
GPHOTO2_DISPONIBLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
GPHOTO2_DISPONIBLE = False
|
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:
|
class Camera:
|
||||||
"""Controle de l'appareil DSLR via gphoto2."""
|
"""Controle camera : DSLR (gphoto2), webcam (OpenCV) ou simulation."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.camera = None
|
self.camera = None
|
||||||
self.contexte = None
|
self.contexte = None
|
||||||
self.connectee = False
|
self.connectee = False
|
||||||
|
self.mode = "simulation" # "gphoto2", "webcam", "simulation"
|
||||||
|
self.webcam = None
|
||||||
|
self.webcam_index = -1
|
||||||
|
|
||||||
def connecter(self) -> bool:
|
def connecter(self, source=None) -> bool:
|
||||||
"""Connecte l'appareil photo."""
|
"""Connecte la camera. source: 'gphoto2', 'webcam:0', 'webcam:2', etc."""
|
||||||
if not GPHOTO2_DISPONIBLE:
|
self.deconnecter()
|
||||||
log.info("Mode simulation : camera virtuelle connectee")
|
|
||||||
self.connectee = True
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
self.contexte = gp.Context()
|
self.contexte = gp.Context()
|
||||||
self.camera = gp.Camera()
|
self.camera = gp.Camera()
|
||||||
self.camera.init(self.contexte)
|
self.camera.init(self.contexte)
|
||||||
self.connectee = True
|
self.connectee = True
|
||||||
|
self.mode = "gphoto2"
|
||||||
log.info("Camera DSLR connectee")
|
log.info("Camera DSLR connectee")
|
||||||
return True
|
return True
|
||||||
except gp.GPhoto2Error as e:
|
except gp.GPhoto2Error as e:
|
||||||
log.error(f"Erreur connexion camera : {e}")
|
log.warning(f"Pas de DSLR : {e}")
|
||||||
self.connectee = False
|
|
||||||
|
# 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
|
return False
|
||||||
|
|
||||||
def deconnecter(self):
|
def deconnecter(self):
|
||||||
@@ -52,9 +119,14 @@ class Camera:
|
|||||||
self.camera.exit(self.contexte)
|
self.camera.exit(self.contexte)
|
||||||
except gp.GPhoto2Error:
|
except gp.GPhoto2Error:
|
||||||
pass
|
pass
|
||||||
|
if self.webcam:
|
||||||
|
self.webcam.release()
|
||||||
self.camera = None
|
self.camera = None
|
||||||
self.contexte = None
|
self.contexte = None
|
||||||
|
self.webcam = None
|
||||||
|
self.webcam_index = -1
|
||||||
self.connectee = False
|
self.connectee = False
|
||||||
|
self.mode = "simulation"
|
||||||
log.info("Camera deconnectee")
|
log.info("Camera deconnectee")
|
||||||
|
|
||||||
def capturer(self) -> Path | None:
|
def capturer(self) -> Path | None:
|
||||||
@@ -67,19 +139,35 @@ class Camera:
|
|||||||
nom_fichier = f"photo_{horodatage}.jpg"
|
nom_fichier = f"photo_{horodatage}.jpg"
|
||||||
chemin_dest = DOSSIER_PHOTOS / nom_fichier
|
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)
|
return self._capture_simulation(chemin_dest)
|
||||||
|
|
||||||
|
def _capture_gphoto2(self, chemin_dest: Path) -> Path | None:
|
||||||
try:
|
try:
|
||||||
chemin_camera = self.camera.capture(gp.GP_CAPTURE_IMAGE, self.contexte)
|
chemin_camera = self.camera.capture(gp.GP_CAPTURE_IMAGE, self.contexte)
|
||||||
fichier_camera = self.camera.file_get(
|
fichier_camera = self.camera.file_get(
|
||||||
chemin_camera.folder, chemin_camera.name, gp.GP_FILE_TYPE_NORMAL, self.contexte
|
chemin_camera.folder, chemin_camera.name, gp.GP_FILE_TYPE_NORMAL, self.contexte
|
||||||
)
|
)
|
||||||
fichier_camera.save(str(chemin_dest))
|
fichier_camera.save(str(chemin_dest))
|
||||||
log.info(f"Photo capturee : {chemin_dest}")
|
log.info(f"Photo capturee (DSLR) : {chemin_dest}")
|
||||||
return chemin_dest
|
return chemin_dest
|
||||||
except gp.GPhoto2Error as e:
|
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
|
return None
|
||||||
|
|
||||||
def preview(self) -> bytes | None:
|
def preview(self) -> bytes | None:
|
||||||
@@ -87,27 +175,65 @@ class Camera:
|
|||||||
if not self.connectee:
|
if not self.connectee:
|
||||||
return None
|
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()
|
return self._preview_simulation()
|
||||||
|
|
||||||
|
def _preview_gphoto2(self) -> bytes | None:
|
||||||
try:
|
try:
|
||||||
fichier = self.camera.capture_preview(self.contexte)
|
fichier = self.camera.capture_preview(self.contexte)
|
||||||
donnees = fichier.get_data_and_size()
|
donnees = fichier.get_data_and_size()
|
||||||
return bytes(donnees)
|
return bytes(donnees)
|
||||||
except gp.GPhoto2Error as e:
|
except gp.GPhoto2Error as e:
|
||||||
log.error(f"Erreur preview : {e}")
|
log.error(f"Erreur preview DSLR : {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def lister_appareils(self) -> list[str]:
|
def _preview_webcam(self) -> bytes | None:
|
||||||
"""Liste les appareils photo detectes."""
|
if not self.webcam or not self.webcam.isOpened():
|
||||||
if not GPHOTO2_DISPONIBLE:
|
return None
|
||||||
return ["[Simulation] Camera virtuelle"]
|
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:
|
try:
|
||||||
appareils = gp.Camera.autodetect(self.contexte or gp.Context())
|
ctx = self.contexte or gp.Context()
|
||||||
return [f"{nom} ({port})" for nom, port in appareils]
|
dslrs = gp.Camera.autodetect(ctx)
|
||||||
|
for nom, port in dslrs:
|
||||||
|
appareils.append({
|
||||||
|
"nom": f"{nom} ({port})",
|
||||||
|
"source": "gphoto2",
|
||||||
|
"type": "dslr"
|
||||||
|
})
|
||||||
except gp.GPhoto2Error:
|
except gp.GPhoto2Error:
|
||||||
return []
|
pass
|
||||||
|
|
||||||
|
if not appareils:
|
||||||
|
appareils.append({
|
||||||
|
"nom": "Simulation",
|
||||||
|
"source": "simulation",
|
||||||
|
"type": "simulation"
|
||||||
|
})
|
||||||
|
|
||||||
|
return appareils
|
||||||
|
|
||||||
def _capture_simulation(self, chemin: Path) -> Path:
|
def _capture_simulation(self, chemin: Path) -> Path:
|
||||||
"""Genere une image de test en mode simulation."""
|
"""Genere une image de test en mode simulation."""
|
||||||
@@ -117,7 +243,6 @@ class Camera:
|
|||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
texte = f"SIMULATION\n{datetime.now().strftime('%H:%M:%S')}"
|
texte = f"SIMULATION\n{datetime.now().strftime('%H:%M:%S')}"
|
||||||
draw.text((960, 640), texte, fill=(255, 255, 255), anchor="mm")
|
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)
|
draw.rectangle([50, 50, 1870, 1230], outline=(233, 30, 99), width=4)
|
||||||
img.save(chemin, "JPEG", quality=95)
|
img.save(chemin, "JPEG", quality=95)
|
||||||
log.info(f"Photo simulation : {chemin}")
|
log.info(f"Photo simulation : {chemin}")
|
||||||
|
|||||||
@@ -103,15 +103,17 @@ async def api_preview():
|
|||||||
async def api_camera_statut():
|
async def api_camera_statut():
|
||||||
return {
|
return {
|
||||||
"connectee": camera.connectee,
|
"connectee": camera.connectee,
|
||||||
|
"mode": camera.mode,
|
||||||
"appareils": camera.lister_appareils(),
|
"appareils": camera.lister_appareils(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/camera/reconnecter")
|
@app.post("/api/camera/reconnecter")
|
||||||
async def api_camera_reconnecter():
|
async def api_camera_reconnecter(body: dict = {}):
|
||||||
camera.deconnecter()
|
camera.deconnecter()
|
||||||
ok = camera.connecter()
|
source = body.get("source")
|
||||||
return {"connectee": ok}
|
ok = camera.connecter(source=source)
|
||||||
|
return {"connectee": ok, "mode": camera.mode}
|
||||||
|
|
||||||
|
|
||||||
# --- API Effets ---
|
# --- API Effets ---
|
||||||
|
|||||||
@@ -109,12 +109,12 @@ html, body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
width: 40px;
|
width: 56px;
|
||||||
height: 40px;
|
height: 56px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 1.2rem;
|
font-size: 1.5rem;
|
||||||
color: rgba(255,255,255,0.15);
|
color: rgba(255,255,255,0.15);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -381,12 +381,13 @@ html, body {
|
|||||||
background: var(--fond-carte);
|
background: var(--fond-carte);
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.6rem 1.2rem;
|
padding: 1rem 1.5rem;
|
||||||
color: var(--texte);
|
color: var(--texte);
|
||||||
font-size: 0.9rem;
|
font-size: 1.1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-filtre.actif, .btn-overlay.actif {
|
.btn-filtre.actif, .btn-overlay.actif {
|
||||||
@@ -467,8 +468,8 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-exemplaire {
|
.btn-exemplaire {
|
||||||
width: 48px;
|
width: 56px;
|
||||||
height: 48px;
|
height: 56px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid var(--primaire);
|
border: 2px solid var(--primaire);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -614,9 +615,10 @@ html, body {
|
|||||||
color: var(--texte-secondaire);
|
color: var(--texte-secondaire);
|
||||||
border: 2px solid var(--texte-secondaire);
|
border: 2px solid var(--texte-secondaire);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.6rem 1.5rem;
|
padding: 1rem 2rem;
|
||||||
font-size: 1rem;
|
font-size: 1.1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-fermer {
|
.btn-fermer {
|
||||||
@@ -625,7 +627,9 @@ html, body {
|
|||||||
color: var(--texte);
|
color: var(--texte);
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.5rem;
|
padding: 1rem;
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
|
|||||||
Reference in New Issue
Block a user