Projet

[Étape 2] Création d’un photobooth – Logiciel

Nous avons préparé la semaine dernière toute la partie matérielle du photobooth. Maintenant nous allons nous attaquer au logiciel.

Notre projet photobooth aura la structure suivante:

Photobooth/
├── camera.py
├── config.py
├── display.py
├── gpio_handler.py
├── main.py

Le projet Photobooth s’appuie sur plusieurs fichiers aux rôles complémentaires. main.py orchestre les étapes du photobooth, du flux vidéo à la prise de vue. camera.py pilote la caméra et prépare les images. display.py gère leur affichage avec pygame, et gpio_handler.py relie le bouton physique et le flash au programme. Les paramètres communs sont centralisés dans config.py.

Passons maintenant à l’étude de chaque fichier

Photobooth – main.py

Main.py est le fichier d’entrée de notre programme. Son rôle est d’orchestrer les différentes étapes de notre photobooth.

import time
from datetime import datetime
from enum import Enum, auto
from pathlib import Path
from typing import Optional
import cv2

import config
from camera import Camera
from display import Display
from gpio_handler import GPIOHandler

Nous retrouvons aux lignes 1 à 6 les bibliothèques nécessaires à l’orchestration des étapes et à la sauvegarde des images et aux lignes 8 à 11 l’import des autres fichiers de notre projet

class State(Enum):
 PREVIEW = auto()
 COUNTDOWN = auto()
 CAPTURE = auto()
 REVIEW = auto()

La classe State permet de définir les quatre états du Photobooth : aperçu en direct (PREVIEW), compte à rebours (COUNTDOWN), prise de photo (CAPTURE) et affichage de la photo (REVIEW).

def save_photo(frame) -> Optional[Path]:
 try:
  save_dir = Path(config.SAVE_DIR)
  save_dir.mkdir(parents=True, exist_ok=True)
  
  filename = datetime.now().strftime("%Y%m%d_%H%M%S") + f".{config.PHOTO_FORMAT}"
  path = save_dir / filename
  cv2.imwrite(str(path), frame)
 except Exception as e:
  print(f" Erreur lors de la sauvegarde : {e}")

Nous créons ensuite la fonction save_photo qui nous permet d’enregistrer (l.26) dans le dossier fourni (l.21-22) les photos avec un nom (l.24) de la forme Année_Mois_Jour_Heure.jpg

Passons maintenant à notre fonction principale

def run():
 camera = Camera()
 display = Display()
 gpio = GPIOHandler()

 camera.start()

 state = State.PREVIEW
 countdown_end = 0.0  
 review_end = 0.0 
 captured_frame = None 
 flash_active = False

Nous initialisons nos classes aux lignes 33 à 35, puis nous démarrons la caméra à la ligne 37. Ensuite, nous préparons les variables nécessaires au déroulement du photobooth : state commence en mode prévisualisation, countdown_end et review_end serviront à gérer la durée des étapes, captured_frame stockera la photo prise et flash_active indiquera si le flash est allumé.

Passons maintenant à notre boucle

 while True:

  button_pressed = gpio.consume_press() 

  camera.wait_for_frame()
  surf_data = camera.surf_data

  now = time.monotonic()

A chaque itération, on vérifie si le bouton est appuyé (l. 47), on attend une nouvelle image (l. 49-50) et on mets à jour l’heure (l. 52) pour calculer les durées du compte à rebours et de l’affichage de la photo

En fonction de l’état dans lequel nous nous trouvons, différentes actions seront réalisées.

  if state == State.PREVIEW:
   if surf_data is not None:
    display.show_frame(surf_data)
   if button_pressed:
    if not flash_active:
     gpio.flash_on()
     flash_active = True
    countdown_end = now + config.COUNTDOWN
    state = State.COUNTDOWN

Dans l’état de prévisualisation, nous affichons l’image capturée par la caméra (l. 55-56) et si le bouton est pressé (l. 57), nous activons le flash (l. 58-60), calculons le temps de fin du compte a rebours (l. 61) et passons notre état à compte à rebours (l. 62).

  elif state == State.COUNTDOWN:
   remaining = int(countdown_end - now) + 1
   if surf_data is not None:
    if remaining > 0:
     display.show_countdown(surf_data, remaining)
    else:
     state = State.CAPTURE

Lorsque l’on est dans l’état compte a rebours, nous calculons combien de temps reste avant la prise de photo (l. 65). Si ce temps est supérieur à 0 nous l’affichons (l.67 – 68) sinon nous passons à l’étape de capture (l. 70)

  elif state == State.CAPTURE:
   display.show_flash()
   time.sleep(config.FLASH_ON_DELAY)
   captured_frame = camera.snapshot()
   if captured_frame is not None:
    save_photo(captured_frame)
   review_end = now + config.REVIEW_DURATION
   state = State.REVIEW

Lorsque nous sommes dans l’état capture, nous affichons le flash à l’image pendant un certain temps (l. 73-74), puis nous capturons et enregistrons la photo (l. 75-77). Enfin, nous calculons le temps d’affichage de l’image capturée (l. 78), puis nous changeons l’état à review (l. 79)

  elif state == State.REVIEW:
   if flash_active:
    gpio.flash_off()
    flash_active = False
   if captured_frame is not None:
    display.show_review(captured_frame)
   if now >= review_end:
    state = State.PREVIEW

Enfin, dans l’état d’affichage de la photo, nous éteignons le flash (l. 82-84), nous affichons l’image (l. 86) et si le temps d’affichage est passé (l. 87), nous repassons à l’état de prévisualisation (l. 88)

  display.flip()
  display.tick(config.CAMERA_FPS)

A la fin de chaque boucle, on met à l’écran l’image préparée (l.91) et nous utilisons display.tick pour limiter la fréquence de la boucle

if __name__ == "__main__":
 run()

Nos deux dernières lignes du fichier main.py permet de lancer run() si le fichier est exécuté directement

Photobooth – camera.py

Le fichier camera.py permet de récupérer les images de la caméra et d’effectuer les opérations nécessaires à leurs affichages

import threading
import cv2
import numpy as np
from picamera2 import Picamera2
import config

Nous commençons par importer les bibliothèques nécessaires, à savoir threading pour gérer la parallélisation des tâches, opencv et numpy pour la manipulation d’images, picamera2 pour récupérer les images, et config qui nous permet de stocker la configuration.

class Camera:

 def __init__(self):
  self._picam2 = Picamera2()
  config_obj = self._picam2.create_preview_configuration(
   main={
    "size": (config.CAMERA_WIDTH, config.CAMERA_HEIGHT),
    "format": "RGB888",
   }
  )
  self._picam2.configure(config_obj)

  autofocus_controls = {}
  if config.CAMERA_AUTOFOCUS:
   from libcamera import controls as libcontrols
   autofocus_controls = {}
   autofocus_controls["AfMode"] = libcontrols.AfModeEnum.Continuous
   self._picam2.set_controls(autofocus_controls)

  try:
   self._picam2.start()
  except Exception as exc:
   print("[Caméra] Picamera2 start failed:", exc)
   

  self._surf_data = None
  self.full_frame = None
  self.lock = threading.Lock()
  self.new_frame = threading.Event()
  self.running = False
  self.thread = threading.Thread(target=self.capture_loop, daemon=True)

Nous déclarons ensuite la classe Camera et réalisons son initialisation. L’objet qui contrôle la caméra est créé à la ligne 10 puis nous préparons la configuration d’aperçu en prenant en compte la résolution attendue ( l. 13) et son format (l. 14). Cette configuration est appliquée à la caméra ligne 17. Dans notre cas d’application, la caméra est dotée d’un autofocus, que nous activons aux lignes 19 à 24. Enfin nous essayons de démarrer la caméra aux lignes 26 à 29.

Enfin, nous préparons les variables pour stocker les états de la caméra : _surf_data et full_frame qui commenceront vides, puis contiendront respectivement l’aperçu traité et la dernière image complète. Lock (l. 34) nous permet de protéger ces images contre les accès simultanés et Event (l. 35) signale l’arrivée d’une nouvelle image. La variable running nous permet de contrôler la boucle de capture. Finalement, thread (l. 37) prépare un fil d’exécution qui lancera capture_loop.

 def start(self):
  self.running = True
  self.thread.start()

 def stop(self):
  self.running = False
  self.thread.join(timeout=2)
  try:
   self._picam2.stop()
  except Exception:
   pass

Les deux fonctions suivantes nous permettent de lancer le traitement des images (l. 39-41) et de l’arrêter (l. 43-49)

 def wait_for_frame(self, timeout: float = 0.05) -> bool:
  return self.new_frame.wait(timeout)

La fonction wait_for_frame, utilisée dans la boucle principale du main permet de nous assurer qu’une nouvelle image est capturée.

 def to_surf_data(self, frame):
  preview = cv2.resize(frame, (config.PREVIEW_WIDTH, config.PREVIEW_HEIGHT), interpolation=cv2.INTER_LINEAR)
  rgb = cv2.cvtColor(preview, cv2.COLOR_BGR2RGB)
  return np.ascontiguousarray(rgb.swapaxes(0, 1))

 @property
 def surf_data(self):
  self.new_frame.clear()
  with self.lock:
   return self._surf_data.copy() if self._surf_data is not None else None

La fonction to_surf_data nous permet de convertir l’image capturée en un tableau (l. 57) pouvant être affiché par pygame. L’image est d’abord réduite (l. 55) afin de permettre un affichage rapide, puis on change l’espace colorimétrique à la ligne 56. surf_data permet de stocker la nouvelle image à afficher.

 def snapshot(self):
  with self.lock:
   return self.full_frame.copy() if self.full_frame is not None else None

 def capture_loop(self):
  while self.running:
   frame = self._picam2.capture_array()
   frame = cv2.flip(frame, 1)
   surf_data = self.to_surf_data(frame)
   with self.lock:
    self.full_frame = frame
    self._surf_data = surf_data
   self.new_frame.set()

La fonction snapshot permet de capturer l’image en bonne qualité pour l’enregistrement.

Enfin la fonction capture_loop, appelée dans le thread, nous permet de capturer les images (l .71), de la pivoter (l. 72) pour avoir un effet miroir et de la convertir pour Pygame (l. 73). L’image capturée et l’image réduite sont ensuite stockée dans les variavles self.full_frame et self._surf_data.

Photobooth – display.py

Maintenant que nous avons les images, nous pouvons passer à l’affichage avec le fichier display.py qui gère l’affichage avec la bibliothèque Pygame

import numpy as np
import cv2
import pygame
import config


class Display:

 def __init__(self):
  pygame.init()

  flags = pygame.FULLSCREEN | pygame.HWSURFACE | pygame.DOUBLEBUF
  self._screen = pygame.display.set_mode((0, 0), flags)
  pygame.display.set_caption("PhotoBooth")
  
  self.cx, self.cy = self._screen.get_rect().center
 
  self._font_big   = pygame.font.SysFont(None, 300)
  self._font_small = pygame.font.SysFont(None, 80)
  self._clock      = pygame.time.Clock()

Nous importons les bibliothèques nécessaires (l. 1-4) puis passons à l’initialisation de la classe Display.

Nous commençons par initialiser Pygame, puis nous créons une fenêtre en plein écran (l. 12) à la résolution actuelle (l. 13). Le centre de l’écran est calculé à la ligne 16 afin d’afficher le décompte lors de la prise de vue avec une grande taille de texte (l. 18) et d’afficher, lorsqu’une image est sauvegardée, un message avec une police plus petite (l. 19).

Une horloge est définie à la ligne 20 pour limiter la fréquence d’affichage, puis une surface aux dimensions de l’aperçu permettant de d’afficher les images.

 def show_frame(self, surf_data):
  pygame.surfarray.blit_array(self._surf, surf_data)
  pygame.transform.scale(self._surf, self._screen.get_size(), self._screen)

 def show_countdown(self, surf_data, number: int):
  self.show_frame(surf_data)
  label  = self._font_big.render(str(number), True, (255, 255, 255))
  self._screen.blit(label,  label.get_rect(center=(self.cx, self.cy)))

 def show_flash(self):
  self._screen.fill((255, 255, 255))
  pygame.display.flip()

 def show_review(self, bgr_frame):
  preview   = cv2.resize(bgr_frame, (config.PREVIEW_WIDTH, config.PREVIEW_HEIGHT),
          interpolation=cv2.INTER_LINEAR)
  rgb       = cv2.cvtColor(preview, cv2.COLOR_BGR2RGB)
  surf_data = np.ascontiguousarray(rgb.swapaxes(0, 1))
  self.show_frame(surf_data)
  msg    = self._font_small.render("Photo sauvegardée !", True, (255, 255, 255))
  self._screen.blit(msg,    msg.get_rect(center=(self.cx, self.cy - 80)))

Nous définissons ensuite quatre fonctions permettant de gérer l’affichage. Show_frame affiche l’image capturée sur la surface. Show_countdown appelle la fonction show_frame et ajoute le nombre du compte à rebours. Show_flash, qui est appelé lors de la prise de vue, permet de remplir la surface de blanc, permettant ainsi de simuler un flash. Enfin, la fonction show_review est utilisée pour afficher l’image capturée. Cette image est préprocessée et le message « Photo sauvegardée ! » est intégré à l’affichage.

 def flip(self):
  pygame.display.flip()

 def tick(self, fps: int = 30):
  self._clock.tick(fps)

 def close(self):
  pygame.quit()

La fonction flip affiche à l’écran l’image qui vient d’être préparée. tick(fps) limite la vitesse de la boucle au nombre d’images par seconde indiqué, 30 par défaut. Enfin, la fonction close arrête Pygame et libère ses ressources.

 def should_quit(self) -> bool:
  for event in pygame.event.get():
   if event.type == pygame.QUIT:
    return True
  return False

Pour finir avec Pygame, la fonction should_quit permet de détecter si l’utilisateur ferme la fenêtre et ainsi fermer le programme entier.

Photobooth – gpio_handler.py

Passons enfin à la gestion du bouton déclencheur et du flash.

import time
import config

from gpiozero import Button, OutputDevice,Device
from gpiozero.pins.lgpio import LGPIOFactory

Device.pin_factory = LGPIOFactory()

Nous importons les bibliothèques nécessaires puis nous indiquons à gpiozero d’utiliser lgpio (l. 7) comme interface pour accéder aux broches GPIO du Raspberry Pi.

class GPIOHandler:
 def __init__(self):
  self._button_pressed = False

  self.btn   = Button(config.BUTTON_PIN, bounce_time=config.BUTTON_BOUNCE, pull_up=True)
  self.flash = OutputDevice(config.FLASH_PIN, active_high=True, initial_value=False) \
      if config.FLASH_PIN is not None else None
  self.btn.when_pressed = self._on_button_press

Nous initialisons ensuite notre classe GPIOHandler. Tout d’abord nous définissons la variable _button_pressed (l.11), puis nous déclarons notre bouton (l. 13) et notre flash (l. 14). Enfin, nous associons la fonction _on_button_press à l’évènement d’appuie du bouton (l. 15).

 def consume_press(self) -> bool:
  if self._button_pressed:
   self._button_pressed = False
   return True
  return False

La fonction consume_press permet d’indiquer à main si l’on a appuyé sur le bouton, puis si il a été enclenché, repasse _button_pressed à False.

 def flash_on(self):
  self.flash_pulse()

 def flash_off(self):
  self.flash_pulse()

 def flash_pulse(self):
  if self.flash:
   self.flash.on()
   time.sleep(config.FLASH_PULSE_DURATION)
   self.flash.off()

Les fonctions flash_on et flash_off sont appelées par main, pour allumer et éteindre le flash. Lorsque ces fonctions sont appelées, la fonction flash_pulse est exécutée. Son rôle est d’envoyer une impulsion (l .33) sur le GPIO, d’attendre un certain temps (l .34), puis d’arrêter l’impulsion (l .35), ce qui permet de simuler l’appui physique sur le bouton de la télécommande.

 def close(self):
  self.btn.close()
  if self.flash:
   self.flash.close()

 def _on_button_press(self, channel=None):
  self._button_pressed = True

Enfin, la fonction close permet d’arrêter proprement les traitements GPIO à la fermeture du programme, et la fonction _on_button_press permet de stocker l’événement d’appui sur le bouton.

Photobooth – config.py

Le fichier config.py nous permet de stocker toutes les réglagles du photomaton.

# Caméra 
CAMERA_INDEX = 0  
CAMERA_FPS = 30
CAMERA_AUTOFOCUS = True
CAMERA_WIDTH = 3840
CAMERA_HEIGHT = 2160 

# Écran 
DISPLAY_WIDTH = 1920 
DISPLAY_HEIGHT = 1080

# Preview 
PREVIEW_WIDTH = 960
PREVIEW_HEIGHT = 720

Nous retrouvons d’abord les réglages de la caméra : son index, le nombre d’images par seconde que nous voulons capturer, la présence d’autofocus et sa résolution maximale. Nous trouvons ensuite la résolution de l’écran (l. 8-10) et la résolution d’affichage (l. 13-14). Nous réduisons volontairement la résolution d’affichage afin d’éviter les saccades éventuelles lors de la lecture des images.

# GPIO 
BUTTON_PIN = 17 
FLASH_PIN = 27  
BUTTON_BOUNCE = 0.05  
FLASH_PULSE_DURATION = 0.05  
FLASH_ON_DELAY = 0.1  

# Comportement 
COUNTDOWN = 3  
REVIEW_DURATION = 3 

# Sauvegarde 
SAVE_DIR = "/home/pi/photos"
PHOTO_FORMAT = "jpg"
JPEG_QUALITY = 100

Nous retrouvons ensuite les paramètres pour les GPIO : les broches où sont branchés le bouton (l. 17) et le flash (l. 18), le délai anti-rebond pour le bouton (l. 19), le délai d’appui pour le flash (l. 20), et le délai avant de déclencher le flash lors de l’appui sur le bouton (l. 21).
Les variables pour le décompte (l. 24) et pour la durée d’affichage de l’image sauvegardée (l. 25) figurent dans la partie Comportement.
Finalement, les paramètres pour la sauvegarde des photos sont indiqués.


Voici ce qui conclut l’ensemble du programme Python de notre Photobooth. La semaine prochaine, nous verrons comment installer facilement notre projet sur un Raspberry Pi et le faire démarrer automatiquement au lancement du Photobooth. Si vous souhaitez retrouver les différentes parties du code, elles sont disponible sur le github de Pymotion.

Florent

Recent Posts

[Étape 1] Création d’un photobooth – Hardware

J'avais il y a quelques années réalisé un photobooth, afin de permettre aux convives de…

2 semaines ago

Conduite autonome : comment détecter les lignes d’une route

Détecter les voies de circulation est une étape incontournable pour la réalisation d’un système de…

4 semaines ago

Installez OpenCV 4 sur macOs

Vous avez commencé un projet de vision par ordinateur ou de traitement d'images ? Alors,…

3 mois ago

Détecter et Lire une plaque d’immatriculation

Dans l’article d’aujourd’hui, nous allons nous intéresser à la lecture des plaques d’immatriculation des voitures.…

3 mois ago

Reconnaissance de caractères: utiliser Tesseract avec OpenCV

Nous avons vu la semaine dernière comment utiliser tesseract dans un invité de commande. Nous…

3 mois ago

Utiliser Tesseract pour la reconnaissance de caractères

Dans l’article d’aujourd’hui, nous allons voir comment installer et utiliser Tesseract pour reconnaitre des caractères…

3 mois ago