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
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
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.
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.
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.
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.
J'avais il y a quelques années réalisé un photobooth, afin de permettre aux convives de…
Détecter les voies de circulation est une étape incontournable pour la réalisation d’un système de…
Vous avez commencé un projet de vision par ordinateur ou de traitement d'images ? Alors,…
Dans l’article d’aujourd’hui, nous allons nous intéresser à la lecture des plaques d’immatriculation des voitures.…
Nous avons vu la semaine dernière comment utiliser tesseract dans un invité de commande. Nous…
Dans l’article d’aujourd’hui, nous allons voir comment installer et utiliser Tesseract pour reconnaitre des caractères…