Créer des Interfaces Utilisateur Modernes pour le Traitement d'Images en Python avec CustomTkinter et OpenCV
Applications à Interface Graphique Moderne pour la Vision par Ordinateur en Python Introduction En tant qu'ingénieur en vision par ordinateur, je suis grand fan des visualisations interactives. Presque quotidiennement, je m'occupe de tâches liées au traitement d'images, et souvent, j'ai besoin de retour visuel pour prendre des décisions. Dans cet article, nous allons explorer comment ajouter des éléments interactifs simples à une application OpenCV, puis construire une interface utilisateur moderne en utilisant CustomTkinter. Prélude – Prérequis Pour suivre ce tutoriel, il est recommandé de configurer votre environnement local avec uv et d'installer les packages suivants : bash uv add numpy opencv-python pillow customtkinter Objectif Notre application devrait utiliser le flux de votre webcam et permettre à l'utilisateur de sélectionner différents types de filtres qui seront appliqués en temps réel au flux. L'esquisse d'une interface utilisateur potentielle pourrait ressembler à cela : Fenêtre principale divisée en deux cadres : Un cadre gauche pour la sélection des filtres. Un cadre droit pour l'affichage de l'image traitée. Interface Graphique Simple avec OpenCV Commençons par un exemple simple où nous récupérons les images de la webcam et les affichons dans une fenêtre OpenCV : ```python import cv2 cap = cv2.VideoCapture(0) while True: ret, frame = cap.read() if not ret: break cv2.imshow("Video Feed", frame) key = cv2.waitKey(1) & 0xFF if key == ord('q'): break cap.release() cv2.destroyAllWindows() ``` Ajout d'Interactivité via les touches du Clavier Une première étape d'interactivité consiste à utiliser des touches du clavier pour cycling entre différents filtres. Par exemple, la touche "1" peut activer le mode normal, et la touche "2" peut activer le mode niveaux de gris : ```python filter_type = "normal" while True: # Récupération de l'image ret, frame = cap.read() if not ret: break if filter_type == "grayscale": frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) elif filter_type == "normal": pass if key == ord('1'): filter_type = "normal" if key == ord('2'): filter_type = "grayscale" # Affichage du nom du filtre print(filter_type, frame.shape) # Ajout d'une bordure noire en bas border_height = 50 border_color = (0, 0, 0) frame = cv2.copyMakeBorder(frame, 0, border_height, 0, 0, cv2.BORDER_CONSTANT, value=border_color) # Affichage du nom du filtre cv2.putText( frame, filter_type, (frame.shape[1] // 2 - 50, frame.shape[0] - border_height // 2 + 10), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2, cv2.LINE_AA, ) cv2.imshow("Video Feed", frame) key = cv2.waitKey(1) & 0xFF if key == ord('q'): break cap.release() cv2.destroyAllWindows() ``` Ajout d'Interactivité via des Sliders OpenCV OpenCV offre également un élément de base de barre de défilement (trackbar) : ```python filter_types = ["normal", "grayscale"] win_name = "Webcam Stream" cv2.namedWindow(win_name) cv2.createTrackbar( "Filter", win_name, 0, len(filter_types) - 1, lambda _: None, ) def update_frame(): ret, frame = cap.read() if not ret: return filter_id = cv2.getTrackbarPos("Filter", win_name) filter_type = filter_types[filter_id] if filter_type == "grayscale": frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) elif filter_type == "normal": pass cv2.imshow("Video Feed", frame) while True: update_frame() key = cv2.waitKey(1) & 0xFF if key == ord('q'): break cap.release() cv2.destroyAllWindows() ``` Construction d'une Interface Utilisateur Moderne avec CustomTkinter Pour un design plus moderne, utilisons CustomTkinter. Commençons par créer une classe App avec deux cadres : un pour la sélection des filtres et l'autre pour l'affichage des images. ```python import customtkinter import cv2 import numpy as np from PIL import Image import queue import threading class App(customtkinter.CTk): def init(self) -> None: super().init() self.title("Webcam Stream") self.geometry("800x600") self.filter_var = customtkinter.IntVar(value=0) # Cadre pour les filtres self.filters_frame = customtkinter.CTkFrame(self) self.filters_frame.pack(side="left", fill="both", expand=False, padx=10, pady=10) # Cadre pour l'affichage de l'image self.image_frame = customtkinter.CTkFrame(self) self.image_frame.pack(side="right", fill="both", expand=True, padx=10, pady=10) self.image_display = CTkImageDisplay(self.image_frame) self.image_display.pack(fill="both", expand=True, padx=10, pady=10) # Création des boutons radio pour la sélection des filtres self.filter_var = customtkinter.IntVar(value=0) for filter_id, filter_type in enumerate(filter_types): rb_filter = customtkinter.CTkRadioButton( self.filters_frame, text=filter_type.capitalize(), variable=self.filter_var, value=filter_id, ) rb_filter.pack(padx=10, pady=10) if filter_id == 0: rb_filter.select() # Initialisation de la webcam et démarrage du thread self.queue = queue.Queue(maxsize=1) self.webcam_thread = threading.Thread(target=self.run_webcam_loop, daemon=True) self.webcam_thread.start() # Mise à jour de l'image toutes les 16 ms (environ 60 FPS) self.frame_loop_dt_ms = 16 self.after(self.frame_loop_dt_ms, self._update_frame) def _update_frame(self) -> None: """ Met à jour l'image affichée. """ try: frame = self.queue.get_nowait() self.image_display.set_frame(frame) except queue.Empty: pass self.after(self.frame_loop_dt_ms, self._update_frame) def run_webcam_loop(self) -> None: """ Ligne d'exécution de la webcam dans un thread séparé. """ self.cap = cv2.VideoCapture(0) if not self.cap.isOpened(): return while True: ret, frame = self.cap.read() if not ret: break filter_id = self.filter_var.get() filter_type = filter_types[filter_id] if filter_type == "grayscale": frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) elif filter_type == "blur": frame = cv2.GaussianBlur(frame, ksize=(15, 15), sigmaX=0) elif filter_type == "threshold": gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) _, frame = cv2.threshold(gray, thresh=127, maxval=255, type=cv2.THRESH_BINARY) elif filter_type == "canny": frame = cv2.Canny(frame, threshold1=100, threshold2=200) elif filter_type == "sobel": frame = cv2.Sobel(frame, ddepth=cv2.CV_64F, dx=1, dy=0, ksize=5) elif filter_type == "laplacian": frame = cv2.Laplacian(frame, ddepth=cv2.CV_64F) elif filter_type == "normal": pass if frame.dtype != np.uint8: cv2.normalize(frame, frame, 0, 255, cv2.NORM_MINMAX) frame = frame.astype(np.uint8) if len(frame.shape) == 2: frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) self.queue.put(frame) class CTkImageDisplay(customtkinter.CTkLabel): """ Composant personnalisé pour afficher les images OpenCV. """ def __init__(self, master, **kwargs) -> None: self._textvariable = customtkinter.StringVar(master, "Loading...") super().__init__(master, textvariable=self._textvariable, image=None, **kwargs) def set_frame(self, frame: np.ndarray) -> None: """ Définit le cadre à afficher. Args: frame: Le nouveau cadre à afficher, en format OpenCV (BGR). """ target_width, target_height = frame.shape[1], frame.shape[0] # Conversion de l'image au format PIL frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frame_pil = Image.fromarray(frame_rgb, "RGB") ctk_image = customtkinter.CTkImage( light_image=frame_pil, dark_image=frame_pil, size=(target_width, target_height), ) self.configure(image=ctk_image, text="") self._textvariable.set("") filter_types = ["normal", "grayscale", "blur", "threshold", "canny", "sobel", "laplacian"] app = App() app.mainloop() ``` Multithreading et Synchronisation L'application fonctionne, mais elle présente des problèmes de performance et de responsivité. Pour résoudre cela, séparons le traitement des images de l'interface utilisateur. Utilisons un thread séparé pour le flux de la webcam et une file d'attente pour synchroniser les données : ```python class App(customtkinter.CTk): def init(self) -> None: super().init() self.title("Webcam Stream") self.geometry("800x600") self.filter_var = customtkinter.IntVar(value=0) # Cadre pour les filtres self.filters_frame = customtkinter.CTkFrame(self) self.filters_frame.pack(side="left", fill="both", expand=False, padx=10, pady=10) # Cadre pour l'affichage de l'image self.image_frame = customtkinter.CTkFrame(self) self.image_frame.pack(side="right", fill="both", expand=True, padx=10, pady=10) self.image_display = CTkImageDisplay(self.image_frame) self.image_display.pack(fill="both", expand=True, padx=10, pady=10) # Crée les boutons radio pour chaque type de filtre for filter_id, filter_type in enumerate(filter_types): rb_filter = customtkinter.CTkRadioButton( self.filters_frame, text=filter_type.capitalize(), variable=self.filter_var, value=filter_id, ) rb_filter.pack(padx=10, pady=10) if filter_id == 0: rb_filter.select() # Initialisation de la caméra et de la file d'attente self.queue = queue.Queue(maxsize=1) self.webcam_thread = threading.Thread(target=self.run_webcam_loop, daemon=True) self.webcam_thread.start() # Mise à jour de l'image toutes les 16 ms self.frame_loop_dt_ms = 16 self.after(self.frame_loop_dt_ms, self._update_frame) def _update_frame(self) -> None: """ Met à jour l'image affichée. """ try: frame = self.queue.get_nowait() self.image_display.set_frame(frame) except queue.Empty: pass self.after(self.frame_loop_dt_ms, self._update_frame) def run_webcam_loop(self) -> None: """ Exécute la boucle de la webcam dans un thread séparé. """ self.cap = cv2.VideoCapture(0) if not self.cap.isOpened(): return while True: ret, frame = self.cap.read() if not ret: break # Application du filtre filter_id = self.filter_var.get() filter_type = filter_types[filter_id] if filter_type == "grayscale": frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) elif filter_type == "blur": frame = cv2.GaussianBlur(frame, ksize=(15, 15), sigmaX=0) elif filter_type == "threshold": gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) _, frame = cv2.threshold(gray, thresh=127, maxval=255, type=cv2.THRESH_BINARY) elif filter_type == "canny": frame = cv2.Canny(frame, threshold1=100, threshold2=200) elif filter_type == "sobel": frame = cv2.Sobel(frame, ddepth=cv2.CV_64F, dx=1, dy=0, ksize=5) elif filter_type == "laplacian": frame = cv2.Laplacian(frame, ddepth=cv2.CV_64F) elif filter_type == "normal": pass if frame.dtype != np.uint8: cv2.normalize(frame, frame, 0, 255, cv2.NORM_MINMAX) frame = frame.astype(np.uint8) if len(frame.shape) == 2: frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) # Ajout du frame à la file d'attente self.queue.put(frame) class CTkImageDisplay(customtkinter.CTkLabel): """ Composant personnalisé pour afficher les images OpenCV. """ def __init__(self, master, **kwargs) -> None: self._textvariable = customtkinter.StringVar(master, "Loading...") super().__init__(master, textvariable=self._textvariable, image=None, **kwargs) def set_frame(self, frame: np.ndarray) -> None: """ Définit le cadre à afficher. Args: frame: Le nouveau cadre à afficher, en format OpenCV (BGR). """ target_width, target_height = frame.shape[1], frame.shape[0] # Conversion en format PIL frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frame_pil = Image.fromarray(frame_rgb, "RGB") ctk_image = customtkinter.CTkImage( light_image=frame_pil, dark_image=frame_pil, size=(target_width, target_height), ) self.configure(image=ctk_image, text="") self._textvariable.set("") filter_types = ["normal", "grayscale", "blur", "threshold", "canny", "sobel", "laplacian"] app = App() app.mainloop() ``` Conclusion La combinaison d'un framework d'interface utilisateur comme Tkinter avec OpenCV permet de développer des applications modernes et interactives pour la vision par ordinateur. En faisant tourner le traitement des images dans un thread séparé et en utilisant une file d'attente pour synchroniser les données, nous avons réussi à créer une expérience utilisateur fluide et réactive. Vous pouvez trouver une version nettoyée et plus modulaire de ce tutoriel dans le dépôt GitHub suivant : Dépôt GitHub N'hésitez pas à me faire part de vos créations si vous utilisez cette approche. Bonne continuation ! Évaluation de l'Industrie Ce tutoriel est particulièrement pertinent pour les ingénieurs en vision par ordinateur et les développeurs de systèmes embarqués. Il présente des solutions pratiques pour l'itération rapide sur des pipelines de traitement d'images, tout en offrant un design moderne grâce à CustomTkinter. Cette approche améliore significativement l'expérience utilisateur, rendant les applications plus conviviales et performantes. L'utilisation de multithreading pour séparer le traitement des images de l'interface utilisateur est un bon exemple de pratique de conception robuste, adaptée aux besoins actuels de la technologie de vision par ordinateur. profil de l'Entreprise L'auteur de ce tutoriel, Florian Tramèr, est un ingénieur expert en vision par ordinateur et en développement de logiciels. Il a travaillé sur des projets variés, allant de la recherche académique à des applications industrielles, contribuant ainsi à la diffusion des meilleures pratiques dans le domaine. Son dépôt GitHub, mentionné précédemment, est un excellent point de départ pour ceux qui souhaitent explorer davantage les possibilités offertes par Python pour la vision par ordinateur et l'interface utilisateur.