From 48f54ab70a7ddfbb151b9ecc56a919473bf25cb4 Mon Sep 17 00:00:00 2001 From: Jules Date: Thu, 25 Jun 2026 19:11:10 +0200 Subject: [PATCH] =?UTF-8?q?Robustesse=20DSLR=20+=20=C3=A9clairage=20visage?= =?UTF-8?q?=20+=20fix=20pellicule=20cadre=20impos=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - camera.py: fix fuite FD USB sur échec reconnexion (gp.Camera non libéré), cleanup PTP session avant init, catch toutes exceptions preview/capture, deconnecter() avec timeout thread + force del pour libérer le port USB - main.py: thread preview anti-crash (outer try/except), reconnexion DSLR même si preview actif, notification camera_erreur après échec capture, intégration module éclairage (analyse luminosité visage via WS) - printer.py: mode pellicule (-2up) ignoré par le cadre imposé événement - eclairage.py: nouveau module analyse luminosité visage (Haar + HSV) - camera.js: indicateur éclairage temps réel (score/100 + visages) Co-Authored-By: Claude Opus 4.6 --- backend/camera.py | 53 +++++++++++++++----- backend/eclairage.py | 114 ++++++++++++++++++++++++++++++++++++++++++ backend/main.py | 42 ++++++++++++---- backend/printer.py | 5 +- frontend/js/camera.js | 31 ++++++++++++ 5 files changed, 219 insertions(+), 26 deletions(-) create mode 100644 backend/eclairage.py diff --git a/backend/camera.py b/backend/camera.py index 3b8a786..2e9f1c6 100644 --- a/backend/camera.py +++ b/backend/camera.py @@ -79,19 +79,31 @@ class Camera: if source == "gphoto2" or source is None: if GPHOTO2_DISPONIBLE: + cam = None try: - self.camera = gp.Camera() - self.camera.init() - # Desactiver la mise en veille auto du DSLR (sinon il se deconnecte tout seul) + cam = gp.Camera() + try: + cam.exit() + except Exception: + pass + cam.init() + self.camera = cam self._desactiver_veille_init() - # Viewfinder activé AVANT de rendre la caméra visible au thread preview self._activer_viewfinder_init() self.connectee = True self.mode = "gphoto2" + self.preview_dslr_ok = True log.info("Camera DSLR connectee (LiveView actif)") return True - except gp.GPhoto2Error as e: + except Exception as e: log.warning(f"Pas de DSLR : {e}") + if cam is not None and cam is not self.camera: + try: + cam.exit() + except Exception: + pass + del cam + self.camera = None # Pas de DSLR disponible : mode erreur, pas de fallback log.warning("Aucun DSLR disponible, camera non connectee") @@ -116,25 +128,30 @@ class Camera: def deconnecter(self, timeout: float = 3.0): """Deconnecte l'appareil photo avec timeout pour eviter le blocage PTP.""" - if self.camera and GPHOTO2_DISPONIBLE: + cam_ref = self.camera + ctx_ref = self.contexte + if cam_ref and GPHOTO2_DISPONIBLE: def _exit(): try: - self.camera.exit() + cam_ref.exit() except Exception: pass t = threading.Thread(target=_exit, daemon=True) t.start() t.join(timeout=timeout) if t.is_alive(): - log.warning("camera.exit() bloque — abandon (le FD sera libere au GC)") + log.warning("camera.exit() bloque — force del camera/contexte pour liberer le FD USB") if self.webcam: self.webcam.release() self.camera = None self.contexte = None + del cam_ref + del ctx_ref self.webcam = None self.webcam_index = -1 self.connectee = False self.mode = "simulation" + self.preview_dslr_ok = False log.info("Camera deconnectee") def capturer(self) -> Path | None: @@ -160,13 +177,16 @@ class Camera: for attempt in range(3): try: with self._gp_lock: - # Désactiver LiveView : miroir abaissé → AF phase-détection Canon disponible + if not self.camera: + log.error("Camera None pendant capture") + self.preview_dslr_ok = False + return None try: cfg = self.camera.get_config() vf = cfg.get_child_by_name("viewfinder") vf.set_value(0) self.camera.set_config(cfg) - time.sleep(0.2) # Laisser le miroir descendre + time.sleep(0.2) except Exception: pass log.info(f"Déclenchement capture DSLR (tentative {attempt+1}/3)") @@ -177,8 +197,8 @@ class Camera: ) tmp_path = str(chemin_dest) + ".tmp" fichier_camera.save(tmp_path) - self.activer_viewfinder() # Miroir relevé immédiatement après la prise - self._chauffer_liveview() # Absorbe la stabilisation LiveView ici (spinner visible), pas sur le countdown suivant + self.activer_viewfinder() + self._chauffer_liveview() from PIL import ImageOps as PILImageOps img = PILImageOps.exif_transpose(PILImage.open(tmp_path)) if img.width > 4000: @@ -194,6 +214,11 @@ class Camera: time.sleep(0.5) continue log.error(f"Erreur capture DSLR : {e}") + self.preview_dslr_ok = False + return None + except Exception as e: + log.error(f"Erreur fatale capture DSLR : {e}") + self.preview_dslr_ok = False return None def _chauffer_liveview(self, tentatives: int = 8, pause: float = 0.15): @@ -308,6 +333,8 @@ class Camera: def _preview_gphoto2(self) -> bytes | None: try: with self._gp_lock: + if not self.camera: + return None fichier = self.camera.capture_preview() donnees = bytes(fichier.get_data_and_size()) img = cv2.imdecode(np.frombuffer(donnees, dtype=np.uint8), cv2.IMREAD_COLOR) @@ -318,7 +345,7 @@ class Camera: _, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 75]) return buf.tobytes() return donnees - except gp.GPhoto2Error as e: + except Exception as e: log.error(f"Erreur preview DSLR : {e}") return None diff --git a/backend/eclairage.py b/backend/eclairage.py new file mode 100644 index 0000000..0fce36b --- /dev/null +++ b/backend/eclairage.py @@ -0,0 +1,114 @@ +"""Analyse de luminosité sur les visages détectés dans le preview DSLR. + +Produit une note d'éclairage (0-100) et une recommandation (allumer/ok/sombre). +Échantillonne toutes les ~3 secondes pour ne pas charger le CPU. +""" + +import logging +import time +from pathlib import Path + +import cv2 +import numpy as np + +log = logging.getLogger("photobooth.eclairage") + +_CASCADE_CANDIDATES = [ + "/usr/share/opencv4/haarcascades/haarcascade_frontalface_default.xml", + "/usr/share/opencv/haarcascades/haarcascade_frontalface_default.xml", + "/usr/local/share/opencv4/haarcascades/haarcascade_frontalface_default.xml", +] +try: + _CASCADE_PATH = cv2.data.haarcascades + "haarcascade_frontalface_default.xml" +except AttributeError: + _CASCADE_PATH = next((p for p in _CASCADE_CANDIDATES if Path(p).exists()), _CASCADE_CANDIDATES[0]) +_face_cascade = cv2.CascadeClassifier(_CASCADE_PATH) + +SEUIL_SOMBRE = 45 +SEUIL_CORRECT = 65 + +_dernier_analyse: float = 0 +_INTERVALLE = 3.0 +_dernier_resultat: dict | None = None + + +def analyser_frame(jpeg_data: bytes) -> dict | None: + """Analyse une frame JPEG du preview. + + Retourne None si l'intervalle n'est pas écoulé (throttle). + Sinon retourne: + { + "score": int 0-100, + "action": "allumer" | "attention" | "ok", + "visages": int, + "detail": str, + } + """ + global _dernier_analyse, _dernier_resultat + + now = time.monotonic() + if now - _dernier_analyse < _INTERVALLE: + return None + _dernier_analyse = now + + try: + arr = np.frombuffer(jpeg_data, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if img is None: + return None + + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + faces = _face_cascade.detectMultiScale( + gray, scaleFactor=1.2, minNeighbors=4, minSize=(60, 60) + ) + + if len(faces) == 0: + _dernier_resultat = { + "score": -1, + "action": "no_face", + "visages": 0, + "detail": "Aucun visage détecté", + } + return _dernier_resultat + + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + scores = [] + + for (x, y, w, h) in faces: + pad_x = int(w * 0.1) + pad_y = int(h * 0.1) + x1 = max(0, x + pad_x) + y1 = max(0, y + pad_y) + x2 = min(img.shape[1], x + w - pad_x) + y2 = min(img.shape[0], y + h - pad_y) + roi_v = hsv[y1:y2, x1:x2, 2] + score = int(np.mean(roi_v) / 2.55) + scores.append(score) + + score_moyen = int(np.mean(scores)) + + if score_moyen < SEUIL_SOMBRE: + action = "allumer" + detail = f"Visages sombres ({score_moyen}/100)" + elif score_moyen < SEUIL_CORRECT: + action = "attention" + detail = f"Éclairage limite ({score_moyen}/100)" + else: + action = "ok" + detail = f"Éclairage correct ({score_moyen}/100)" + + _dernier_resultat = { + "score": score_moyen, + "action": action, + "visages": len(faces), + "detail": detail, + } + return _dernier_resultat + + except Exception as e: + log.debug(f"Erreur analyse éclairage : {e}") + return None + + +def dernier_resultat() -> dict | None: + return _dernier_resultat diff --git a/backend/main.py b/backend/main.py index 53062bd..3ed6fcb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -36,6 +36,7 @@ from backend.evenements import ( lister_cadres_event, set_cadre_event, cadre_actif_pour_impression, ) from backend.evenements import DOSSIER_EVENEMENTS +from backend.eclairage import analyser_frame as analyser_eclairage, dernier_resultat as dernier_eclairage logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") log = logging.getLogger("photobooth") @@ -63,17 +64,21 @@ def _thread_preview(): Tourne TOUJOURS quand le DSLR est connecte pour maintenir le miroir leve.""" global _derniere_frame_preview while True: - if capture_en_cours or camera.mode != "gphoto2" or not camera.connectee: - time.sleep(0.05) - continue try: - donnees = camera.preview() - with _preview_lock: - _derniere_frame_preview = donnees - except Exception: - with _preview_lock: - _derniere_frame_preview = None - time.sleep(0.033) # ~30 fps max + if capture_en_cours or camera.mode != "gphoto2" or not camera.connectee: + time.sleep(0.05) + continue + try: + donnees = camera.preview() + with _preview_lock: + _derniere_frame_preview = donnees + except Exception: + with _preview_lock: + _derniere_frame_preview = None + time.sleep(0.033) # ~30 fps max + except Exception as e: + log.error(f"_thread_preview crash: {e}") + time.sleep(1) async def _pusher_preview(): @@ -103,6 +108,10 @@ async def _pusher_preview(): loop = asyncio.get_event_loop() b64 = await loop.run_in_executor(None, lambda d=donnees: base64.b64encode(d).decode("ascii")) await diffuser_ws({"type": "preview", "image": f"data:image/jpeg;base64,{b64}"}) + if not capture_en_cours: + eclairage = await loop.run_in_executor(None, analyser_eclairage, donnees) + if eclairage is not None: + await diffuser_ws({"type": "eclairage", **eclairage}) def _usb_reset_canon(): """Reset USB ioctl du Canon EOS (vendor 04a9) pour débloquer un port stall.""" @@ -206,7 +215,7 @@ async def surveiller_dslr(): _echecs_connexion = 0 else: _dslr_erreurs += 1 - if _dslr_erreurs >= 3 and not _preview_actif: + if _dslr_erreurs >= 3: log.warning(f"camera_erreur : DSLR preview KO depuis {_dslr_erreurs * 5}s, reconnexion forcee...") _dslr_erreurs = 0 _echecs_connexion += 1 @@ -409,6 +418,9 @@ async def api_capturer(): capture_en_cours = False log.info(f"Capture terminée : {chemin}") if chemin is None: + if camera.mode == "gphoto2" and not camera.preview_dslr_ok: + log.warning("Capture echouee + preview KO → reconnexion DSLR imminente") + await diffuser_ws({"type": "camera_erreur", "message": "Erreur capture — reconnexion en cours"}) return JSONResponse({"erreur": "Echec capture"}, status_code=500) nom = chemin.name @@ -547,6 +559,14 @@ async def api_camera_statut(): } +@app.get("/api/eclairage") +async def api_eclairage(): + resultat = dernier_eclairage() + if resultat is None: + return {"score": -1, "action": "no_data", "detail": "Aucune analyse disponible"} + return resultat + + @app.get("/api/camera/debug") async def api_camera_debug(): """Diagnostic complet de la caméra (preview, viewfinder, erreurs gphoto2).""" diff --git a/backend/printer.py b/backend/printer.py index ea9b2b6..710d9ae 100644 --- a/backend/printer.py +++ b/backend/printer.py @@ -258,14 +258,15 @@ def imprimer( largeur, hauteur = fmt["px"] fmt_base = format_papier.replace("-2up", "") - cadre = cadre_override if cadre_override is not None else conf_imp.get("cadres_actifs", {}).get(fmt_base) + is_strip = "-2up" in format_papier + cadre = cadre_override if cadre_override is not None else (None if is_strip else conf_imp.get("cadres_actifs", {}).get(fmt_base)) orientation = conf_imp.get("orientations", {}).get(fmt_base, "portrait") if orientation == "paysage" and hauteur > largeur: largeur, hauteur = hauteur, largeur event_id = config.get("evenement", {}).get("event_id") - if event_id and cadre_override is None: + if event_id and cadre_override is None and not is_strip: from backend.evenements import cadre_actif_pour_impression ev_cadre, ev_mode = cadre_actif_pour_impression(fmt_base) if ev_mode == "impose" and ev_cadre: diff --git a/frontend/js/camera.js b/frontend/js/camera.js index 44798c6..1a4b3a2 100644 --- a/frontend/js/camera.js +++ b/frontend/js/camera.js @@ -173,6 +173,37 @@ wsOnMessage('camera_ok', () => { afficherErreurPreview(false); }); +wsOnMessage('eclairage', (msg) => { + if (captureEnCours) return; + let el = document.getElementById('indicateur-eclairage'); + if (!el) { + el = document.createElement('div'); + el.id = 'indicateur-eclairage'; + el.style.cssText = 'position:fixed;top:12px;left:12px;padding:8px 16px;border-radius:12px;font-size:.9rem;font-weight:600;z-index:90;transition:all .3s;pointer-events:none;display:flex;align-items:center;gap:8px;'; + document.body.appendChild(el); + } + const score = msg.score >= 0 ? msg.score : '?'; + const faces = msg.visages || 0; + if (msg.action === 'allumer') { + el.style.background = 'rgba(239,68,68,.9)'; + el.style.color = '#fff'; + el.innerHTML = `☀️ ALLUMER ${score}/100 · ${faces} visage${faces > 1 ? 's' : ''}`; + } else if (msg.action === 'attention') { + el.style.background = 'rgba(245,158,11,.85)'; + el.style.color = '#fff'; + el.innerHTML = `⚠️ Faible ${score}/100 · ${faces} visage${faces > 1 ? 's' : ''}`; + } else if (msg.action === 'ok') { + el.style.background = 'rgba(34,197,94,.7)'; + el.style.color = '#fff'; + el.innerHTML = `✓ ${score}/100 · ${faces} visage${faces > 1 ? 's' : ''}`; + } else if (msg.action === 'no_face') { + el.style.background = 'rgba(100,100,120,.6)'; + el.style.color = '#ccc'; + el.innerHTML = `Aucun visage`; + } + el.style.display = ''; +}); + // --- Capture --- async function lancerCapture() {