Aplicaciones GUI modernas para la visión por computadora en Python

Soy un gran admirador de las visualizaciones interactivas. Como ingeniero de visión por computadora, trato casi a diario con las tareas relacionadas con el procesamiento de imágenes y la mayoría de las veces estoy iterando sobre un problema en el que necesito retroalimentación visual para tomar decisiones. Pensemos en una tubería de procesamiento de imágenes muy simple con un solo paso que tiene algunos parámetros para transformar una imagen:

Ejemplo de procesamiento de tuberías con visualización faltante de la salida

¿Cómo sabes qué parámetros ajustar? ¿La tubería funciona como se esperaba? Sin visualizar su salida, es posible que se pierda algunas ideas clave y tome decisiones sub óptimas.

A veces, simplemente mostrar la imagen de salida y/o algunas métricas calculadas pueden ser suficientes para iterar en los parámetros. Pero me he encontrado en muchas situaciones en las que una herramienta sería inmensamente útil para iterar de manera rápida e interactiva en mi tubería. Entonces, en este artículo, le mostraré cómo trabajar con elementos interactivos incorporados simples de OpenCV así como cómo construir interfaces de usuario más modernas para proyectos de visión por computadora utilizando customtkinter.

Requisitos previos

Si desea seguir, le recomiendo que configure su entorno local con uva e instalar los siguientes paquetes:

uv add numpy opencv-Python pillow customtkinter

Meta

Antes de sumergirnos en el código del proyecto, describamos rápidamente lo que queremos construir. La aplicación debe usar la alimentación de la cámara web y permitir al usuario seleccionar diferentes tipos de filtros que se aplicarán a la secuencia. La imagen procesada debe mostrarse en tiempo real en la ventana. Un bosquejo aproximado de una interfaz de usuario potencial se vería de la siguiente manera:

OpenCV – GUI

Comencemos con un bucle simple que obtiene marcos de su cámara web y los muestra en una ventana OpenCV.

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()

Entrada de teclado

La forma más simple de agregar interactividad aquí es agregar entradas de teclado. Por ejemplo, podemos recorrer diferentes filtros con las claves numéricas.

...

filter_type = "normal"

while True:
    ...

    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"
        
    ...

Ahora puede cambiar entre la imagen normal y la versión de la escala de grises presionando las teclas numéricas 1 y 2. También agregamos rápidamente una leyenda a la imagen para que podamos ver el nombre del filtro que estamos aplicando.

Ahora debemos tener cuidado aquí: si observa la forma del marco después del filtro, notará que la dimensionalidad de la matriz de cuadros ha cambiado. Recuerde que se ordenan las matrices de imágenes de OpenCV HWC (altura, ancho, color) con color como Bgran (verde, azul, rojo), por lo que la imagen 640 × 480 de mi cámara web tiene forma (480, 640, 3).

print(filter_type, frame.shape)
# normal (480, 640, 3)
# grayscale (480, 640)

Ahora, debido a que la operación de la escala de grises genera una imagen de un solo canal, se cae la dimensión del color. Si ahora queremos dibujar en la parte superior de esta imagen, necesitamos especificar un color de canal único para la imagen de la escala de grises o volvemos a convertir esa imagen en el original. Bgran formato. La segunda opción es un poco más limpia porque podemos unificar la anotación de la imagen.

if filter_type == "grayscale":
    frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
elif filter_type == "normal":
    pass

if len(frame.shape) == 2: # Convert grayscale to BGR
    frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)

Subtítulo

Quiero agregar un borde negro en la parte inferior de la imagen, encima del cual se mostrará el nombre del filtro. Podemos hacer uso del copyMakeBorder Funciona para rellenar la imagen con un color de borde en la parte inferior. Entonces podemos agregar el texto en la parte superior de este borde.

# Add a black border at the bottom of the frame
border_height = 50
border_color = (0, 0, 0)
frame = cv2.copyMakeBorder(frame, 0, border_height, 0, 0, cv2.BORDER_CONSTANT, value=border_color)

# Show the filter name
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,
)

Así es como debe verse la salida, y puede cambiar entre el modo normal y en la escala de grises y los marcos se subtitularán en consecuencia.

Controles deslizantes

Ahora, en lugar de usar el teclado como método de entrada, OpenCV ofrece un elemento UI de control deslizante de trackbar básico. El TrackBar debe inicializarse al comienzo del script. Necesitamos hacer referencia a la misma ventana que mostraremos nuestras imágenes más tarde, por lo que crearé una variable para el nombre de la ventana. Usando este nombre, podemos crear el TrackBar y dejar que sea un selector para el índice en la lista de filtros.

filter_types = ["normal", "grayscale"]

win_name = "Webcam Stream"
cv2.namedWindow(win_name)

tb_filter = "Filter"
# def createTrackbar(trackbarName: str, windowName: str, value: int, count: int, onChange: _typing.Callable[[int], None]) -> None: ...
cv2.createTrackbar(
    tb_filter,
    win_name,
    0,
    len(filter_types) - 1,
    lambda _: None,
)

Observe cómo usamos un Lambda vacío para el onChange devolución de llamada, obtendremos el valor manualmente en el bucle. Todo lo demás permanecerá igual.

while True:
    ...

    # Get the selected filter type
    filter_id = cv2.getTrackbarPos(tb_filter, win_name)
    filter_type = filter_types[filter_id]

    ...

Y voilà, tenemos una barra de seguimiento para seleccionar nuestro filtro.

Ahora también podemos agregar fácilmente más filtros fácilmente extendiendo nuestra lista e implementando cada paso de procesamiento.

filter_types = [
    "normal",
    "grayscale",
    "blur",
    "threshold",
    "canny",
    "sobel",
    "laplacian",
]

...

    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)
        _, thresholded_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:
        # Scale the frame to uint8 if necessary
        cv2.normalize(frame, frame, 0, 255, cv2.NORM_MINMAX)
        frame = frame.astype(np.uint8)

GUI moderna con CustomTkinter

Ahora no sé sobre ti, pero la interfaz de usuario actual no se ve muy moderno a mí. No me malinterpreten, hay algo de belleza en el estilo de la interfaz, pero prefiero diseños más limpios y modernos. Además, ya estamos al límite de lo que Opencvv Ofertas fuera de la caja en términos de elementos de la interfaz de usuario. Sí, sin botones, campos de texto, menores desplegables, casillas de verificación o botones de radio y sin diseños personalizados. Entonces, veamos cómo podemos transformar el aspecto y la experiencia del usuario de esta aplicación básica a una fresca y limpia.

Entonces, para comenzar, primero necesitamos crear una clase para nuestra aplicación. Creamos dos cuadros: el primero contiene nuestra selección de filtro en el lado izquierdo y el segundo envuelve la pantalla de la imagen. Por ahora, comencemos con un simple texto de marcador de posición. Desafortunadamente, no hay un componente de OpenCV fuera de la caja desde CustomTkinter directamente, por lo que tendremos que construir rápidamente el nuestro en los próximos pasos. Pero primero terminemos el diseño básico de la interfaz de usuario.

import customtkinter


class App(customtkinter.CTk):
    def __init__(self) -> None:
        super().__init__()

        self.title("Webcam Stream")
        self.geometry("800x600")

        self.filter_var = customtkinter.IntVar(value=0)

        # Frame for filters
        self.filters_frame = customtkinter.CTkFrame(self)
        self.filters_frame.pack(side="left", fill="both", expand=False, padx=10, pady=10)

        # Frame for image display
        self.image_frame = customtkinter.CTkFrame(self)
        self.image_frame.pack(side="right", fill="both", expand=True, padx=10, pady=10)

        self.image_display = customtkinter.CTkLabel(self.image_frame, text="Loading...")
        self.image_display.pack(fill="both", expand=True, padx=10, pady=10)

app = App()
app.mainloop()

Filtrar botones de radio

Ahora que el esqueleto está construido, podemos comenzar a completar nuestros componentes. Para el lado izquierdo, usaré la misma lista de filter_types Para llenar un grupo de botones de radio para seleccionar el filtro.

        # Create radio buttons for each filter type
        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()

Componente de visualización de imágenes

Ahora podemos comenzar en la parte interesante, cómo hacer que nuestros marcos OpenCV se muestren en el componente de la imagen. Debido a que no hay un componente incorporado, creemos el nuestro basado en el CTKLabel. Esto nos permite mostrar un texto de carga mientras se inicia la transmisión de cámara web.

...

class CTkImageDisplay(customtkinter.CTkLabel):
    """
    A reusable ctk widget widget to display opencv images.
    """

    def __init__(
        self,
        master: Any,
    ) -> None:
        self._textvariable = customtkinter.StringVar(master, "Loading...")
        super().__init__(
            master,
            textvariable=self._textvariable,
            image=None,
        )

...

class App(customtkinter.CTk):
    def __init__(self) -> None:
        ...

        self.image_display = CTkImageDisplay(self.image_frame)
        self.image_display.pack(fill="both", expand=True, padx=10, pady=10) 

Hasta ahora, nada ha cambiado, simplemente cambiamos la etiqueta existente con nuestra implementación de clase personalizada. En nuestro CTKImageDisplay clase podemos definir una función para mostrar una imagen en el componente, llamémosla set_frame.

import cv2
import numpy.typing as npt
from PIL import Image

class CTkImageDisplay(customtkinter.CTkLabel):
    ...

    def set_frame(self, frame: npt.NDArray) -> None:
        """
        Set the frame to be displayed in the widget.

        Args:
            frame: The new frame to display, in opencv format (BGR).
        """
        target_width, target_height = frame.shape[1], frame.shape[0]

        # Convert the frame to PIL Image format
        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("")

Digamos esto. Primero necesitamos saber qué tan grande será nuestro componente de imagen, podemos extraer esa información de la propiedad de forma de nuestra matriz de imágenes. Para mostrar la imagen en tkinternecesitamos una almohada Image Tipo, no podemos usar directamente la matriz OpenCV. Para convertir una matriz OpenCV en almohada, primero debemos convertir el espacio de color desde Bgran a RGB Y luego podemos usar el Image.fromarray función para crear el objeto de imagen de la almohada. A continuación, podemos crear una CTKImage, donde usamos la misma imagen sin importar el tema y establecer el tamaño de acuerdo con nuestro marco. Finalmente podemos usar el método Configurar para establecer la imagen en nuestra trama. Al final, también restablecemos la variable de texto para eliminar el “Cargando…” Texto, aunque teóricamente estaría oculto detrás de la imagen.

Para probar esto rápidamente, podemos establecer la primera imagen de nuestra cámara web en el constructor. (Veremos en un segundo por qué esto no es una buena idea)

class App(customtkinter.CTk):
    def __init__(self) -> None:
        ...
        
        cap = cv2.VideoCapture(0)
        _, frame0 = cap.read()
        self.image_display.set_frame(frame0)

Si ejecuta esto, notará que la ventana tarda un poco más en aparecer, pero después de un breve retraso, debería ver una imagen estática desde su cámara web.

NOTA: Si no tiene una cámara web lista, también puede usar un archivo de video local pasando la ruta del archivo a la cv2.VideoCapture Llamada de constructor.

Ahora esto no es muy emocionante, ya que el marco aún no se actualiza. Así que veamos qué sucede si intentamos hacer esto ingenuamente.

class App(customtkinter.CTk):
    def __init__(self) -> None:
        ...

        cap = cv2.VideoCapture(0)
        while True:
            ret, frame = cap.read()
            if not ret:
                break

            self.image_display.set_frame(frame)

Casi lo mismo que antes, excepto que ahora ejecutamos el bucle de cuadro como lo hicimos en el capítulo anterior con la GUI OpenCV. Si ejecuta esto, verá … exactamente nada. ¡La ventana nunca aparece, ya que estamos creando un bucle infinito en el constructor de la aplicación! Esta es también la razón por la cual el programa solo apareció después de un retraso en el ejemplo anterior, la apertura de la transmisión de la cámara web es una operación de bloqueo, y el bucle de eventos para la ventana no puede ejecutarse, por lo que aún no aparece.

Así que solucionemos esto agregando una implementación ligeramente mejor que permite que el bucle de eventos GUI se ejecute mientras también actualizamos el marco de vez en cuando. Podemos usar el after método de tkinter para programar una llamada de función mientras produce el proceso durante el tiempo de espera.


        ...

        self.cap = cv2.VideoCapture(0)
        self.after(10, self.update_frame)

    def update_frame(self) -> None:
        """
        Update the displayed frame.
        """
        
        ret, frame = self.cap.read()
        if not ret:
            return
        
        self.image_display.set_frame(frame)

        self.after(10, self.update_frame)

Así que ahora todavía configuramos la transmisión web de la cámara web en el constructor, por lo que aún no hemos resuelto ese problema. Pero al menos podemos ver un flujo continuo de marcos en nuestro componente de imagen.

Aplicación de filtros

Ahora que el bucle de cuadro se está ejecutando. Podemos volver a implementar nuestros filtros desde el principio y aplicarlos a nuestra transmisión de cámara web. En la función update_frame, podemos verificar la variable de filtro actual y aplicar la función de filtro correspondiente.

    def update_frame(self) -> None:
        ...
        
        # Get the selected filter type
        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:
            # Scale the frame to uint8 if necessary
            cv2.normalize(frame, frame, 0, 255, cv2.NORM_MINMAX)
            frame = frame.astype(np.uint8)
        if len(frame.shape) == 2:  # Convert grayscale to BGR
            frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
        
        self.image_display.set_frame(frame)

        self.after(10, self.update_frame)

Y ahora volvemos a la funcionalidad completa de la aplicación, ¡puede seleccionar cualquier filtro en el lado izquierdo y se aplicará en tiempo real a la alimentación de la cámara web!

Múltiples lectura y sincronización

Ahora, aunque la aplicación se ejecuta como está, hay algunos problemas con la forma en que ejecutamos nuestro bucle de cuadro. Actualmente todo se ejecuta en un solo hilo, el hilo de la GUI principal. Es por eso que al principio, no vemos aparecer la ventana, nuestra inicialización de la cámara web bloquea el hilo principal. Ahora imagine que si hiciéramos un procesamiento de imágenes más pesado, tal vez ejecutar las imágenes a través de la red neuronal, no quisiera que su interfaz de usuario siempre se bloquee mientras la red ejecuta una inferencia. ¡Esto conducirá a una experiencia de usuario muy no respondida al hacer clic en los elementos de la interfaz de usuario!

Una mejor manera de manejar esto en nuestra aplicación es Separe el procesamiento de la imagen de la interfaz de usuario. En general, esta es casi siempre una buena idea separar su lógica de GUI de cualquier tipo de procesamiento no trivial. Entonces, en nuestro caso, ejecutaremos un hilo separado que sea responsable del bucle de imagen. Leerá los marcos de la transmisión de la cámara web y aplicará los filtros.

NOTA: Los hilos de Python no son “real” Hilos en el sentido de que no tienen la capacidad de ejecutarse en diferentes núcleos lógicos de CPU y, por lo tanto, en realidad correr en paralelo. En Python multithreading, el contexto cambiará entre los hilos, pero debido a la GIL, el bloqueo del intérprete global, un solo proceso de Python solo puede ejecutar un hilo físico. Si quieres “real” procesamiento paralelo, deberá usar multiprocesamiento. Dado que nuestro proceso aquí no está atado a CPU, sino en realidad I/O BoundMúltiple lectura es suficiente.

class App(customtkinter.CTk):
    def __init__(self) -> None:
        ...

        self.webcam_thread = threading.Thread(target=self.run_webcam_loop, daemon=True)
        self.webcam_thread.start()

    def run_webcam_loop(self) -> None:
        """
        Run the webcam loop in a separate thread.
        """
        self.cap = cv2.VideoCapture(0)
        if not self.cap.isOpened():
            return

        while True:
            ret, frame = self.cap.read()
            if not ret:
                break

            # Filters
            ...

            self.image_display.set_frame(frame)

Si ejecuta esto, ahora verá que nuestra ventana se abre inmediatamente e incluso vemos nuestro texto de carga mientras se abre la transmisión de la cámara web. Sin embargo, tan pronto como comienza la corriente, los marcos comienzan a parpadear. Dependiendo de muchos factores, puede experimentar diferentes artefactos visuales o errores en esta etapa.

Advertencia: imagen intermitente

Ahora, ¿por qué está sucediendo esto? El problema es que estamos tratando simultáneamente de actualizar el nuevo cuadro, mientras que el bucle de actualización interno de la interfaz de usuario podría estar utilizando la información de la matriz para dibujarla en la pantalla. Ambos compiten por la misma matriz de cuadros.

Por lo general, no es una buena idea actualizar directamente los elementos de la interfaz de usuario de un hilo diferente, en algunos marcos esto podría evitarse y aumentará las excepciones. En Tkinterpodemos hacerlo, pero obtendremos resultados extraños. Necesitamos algún tipo de sincronización entre nuestros hilos. Ahí es donde el Queue entra en juego.

Probablemente esté familiarizado con las colas de la tienda de comestibles o parques temáticos. El concepto de la cola aquí es muy similar: el primer elemento que entra en la cola también deja primero (Fprimero Inorte Fprimero OUtah).

En este caso, en realidad solo queremos una cola con un solo elemento, una sola cola de tragamonedas. La implementación de la cola en Python es de hilolo que significa que podemos poner y conseguir Objetos de la cola de diferentes hilos. Perfecto para nuestro caso de uso, el hilo de procesamiento colocará las matrices de imagen en la cola y el hilo de la GUI intentará obtener un elemento, pero no bloquear si la cola está vacía.

class App(customtkinter.CTk):
    def __init__(self) -> None:
        ...

        self.queue = queue.Queue(maxsize=1)

        self.webcam_thread = threading.Thread(target=self.run_webcam_loop, daemon=True)
        self.webcam_thread.start()

        self.frame_loop_dt_ms = 16  # ~60 FPS
        self.after(self.frame_loop_dt_ms, self._update_frame)
    
    def _update_frame(self) -> None:
        """
        Update the frame in the image display widget.
        """
        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:
        ...

        while True:
            ...

            self.queue.put(frame)

Observe cómo movemos la llamada directa al set_frame función desde el bucle webcam que se ejecuta en su propio hilo a la _update_frame función que se ejecuta en el hilo principal, programado repetidamente en 16 ms intervalos.

Aquí es importante usar el get_nowait Funcionar en el hilo principal, de lo contrario, si usamos la función Get, estaríamos bloqueando allí. Esta llamada lo hace no bloquepero plantea un queue.Empty Excepción si no hay elemento para buscar, por lo que tenemos que atrapar esto e ignorarlo. En el bucle webcam, podemos usar la función de bloqueo de bloqueo porque no importa que nosotros bloquear el run_webcam_loopno hay nada más que necesite correr allí.

Y ahora todo se está ejecutando como se esperaba, ¡no más marcos intermitentes!

Conclusión

Combinando un marco de UI como Tkinter con Opencvv nos permite crear aplicaciones de aspecto moderno con una interfaz de usuario gráfica interactiva. Debido a la interfaz de usuario que se ejecuta en el hilo principal, ejecutamos el procesamiento de la imagen en un hilo separado y sincronizamos los datos entre los hilos utilizando una cola de ranura única. Puede encontrar una versión limpia de esta demostración en el repositorio a continuación con una estructura más modular. Avísame si construyes algo interesante con este enfoque. ¡Cuidarse!



Consulte el código fuente completo en el repositorio de GitHub:

https://github.com/trflorian/ctk-opencv