OMES

Como crear ejecutables en Python 🐍 | PyInstaller – OpenCV – Mediapipe (Parte 2)

En el anterior tutorial hablamos de una introducción a Pyinstaller 🐍. Esta es la segunda parte en la cual nos sumergiremos en sus opciones de configuración tales como: –onefile, –add-data, –windowed e incluso cómo personalizar el ícono de tu aplicación. En los ejemplos que llevaremos a cabo estaremos usando aplicaciones de visión por computador que ya hemos visto en tutoriales pasados, así que ¡vamos a por ello!.

Antes de pasar a los proyectos que vamos a llevar a cabo, recordemos un poco qué es PyInstaller y las opciones de configuración que emplearemos.

1. PyInstaller

En muchas ocasiones, una vez que hemos cumplido con la realización de un programa, queremos distribuirlo. Sin embargo, el instalar las dependencias de nuestro programa en cada computador en el que vaya a ser usado, puede no ser lo más eficiente. Es allí donde aparece PyInstaller.

Este es un módulo para Python que permite convertir programas en ejecutables para diferentes plataformas, como Windows (.exe), macOS (.dmg) y GNU/Linux. Esto le permitirá al usuario correr la aplicación mediante su ejecutable sin haber instalado el intérprete de Python u otros módulos.

NOTA: También ha sido probado en sistemas operativos como AIX, Solaris, FreeBSD y OpenBSD. Pero hay que tomar en cuenta que no realizan pruebas continuas sobre ellos.

1.1 Comandos de configuración de PyInstaller

A continuación se listarán los comandos que se utilizarán a lo largo de este tutorial. Recuerda que puedes obtener más información sobre todas las opciones de configuración de PyInstaller en su documentación, por lo que te recomiendo echarle un vistazo.

-D, –onedir

Al usarlo, PyInstaller creará una carpeta de salida que contendrá todos los archivos necesarios para ejecutar la aplicación. Dentro de este directorio se incluirá el archivo ejecutable de la aplicación, los recursos y dependencias requeridos. (Comando por defecto).

-F, –onefile

Al usarlo, PyInstaller generará un solo archivo ejecutable que contendrá toda la aplicación y sus dependencias. En lugar de generar una carpeta con múltiples archivos, como lo hace la opción «–onedir», «–onefile» comprime todo en un solo archivo ejecutable.

-w, –windowed

Al emplearlo, le estamos indicando a PyInstaller que la aplicación debe ejecutarse en modo ventana, sin mostrar la consola o terminal. Esto significa que la aplicación se ejecutará con una interfaz gráfica de usuario (GUI).

–add-data

Se utiliza para incluir datos o recursos adicionales (como archivos de configuración, imágenes, entre otros) en la aplicación empaquetada o el ejecutable. Estos datos adicionales pueden ser archivos, directorios o recursos que la aplicación necesita para funcionar correctamente. Esta puede utilizarse varias veces en la línea de comandos.

Su sintaxis es: --add-data "ruta_origen;ruta_destino"

Donde:

–icon

Se usa para incorporar una imagen al ícono del ejecutable. Su uso está dedicado a darle identidad visual a la aplicación.

¡Manos a la obra! A usar pyinstaller

Recuerda que la introducción a PyInstaller y el primer proyecto lo realizamos en el anterior post.

2. Proyecto 2: GUI + Detección Facial

Para nuestro segundo proyecto vamos a tener que instalar algunos módulos tales como:

Instalación de PIL, OpenCV e Imutils:

pip install pillow

pip install opencv-contrib-python

pip install imutils

Y en cuanto al código, vamos a utilizar el que desarrollamos en ELIGIENDO VIDEO DE ENTRADA ? + DETECCIÓN FACIAL ? | GUI con Tkinter y OpenCV en Python. Que es el siguiente, gui_video.py:

from tkinter import *
from tkinter import filedialog
from PIL import Image
from PIL import ImageTk
import cv2
import imutils

faceClassif = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")

def video_de_entrada():
    global cap
    if selected.get() == 1:
        path_video = filedialog.askopenfilename(filetypes = [
            ("all video format", ".mp4"),
            ("all video format", ".avi")])
        if len(path_video) > 0:
            btnEnd.configure(state="active")
            rad1.configure(state="disabled")
            rad2.configure(state="disabled")

            pathInputVideo = "..." + path_video[-20:]
            lblInfoVideoPath.configure(text=pathInputVideo)
            cap = cv2.VideoCapture(path_video)
            visualizar()
    if selected.get() == 2:
        btnEnd.configure(state="active")
        rad1.configure(state="disabled")
        rad2.configure(state="disabled")
        lblInfoVideoPath.configure(text="")
        cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
        visualizar()

def visualizar():
    global cap
    ret, frame = cap.read()
    if ret == True:
        frame = imutils.resize(frame, width=640)
        frame = deteccion_facilal(frame)
        frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        im = Image.fromarray(frame)
        img = ImageTk.PhotoImage(image=im)

        lblVideo.configure(image=img)
        lblVideo.image = img
        lblVideo.after(10, visualizar)
    else:
        lblVideo.image = ""
        lblInfoVideoPath.configure(text="")
        rad1.configure(state="active")
        rad2.configure(state="active")
        selected.set(0)
        btnEnd.configure(state="disabled")
        cap.release()

def deteccion_facilal(frame):
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    faces = faceClassif.detectMultiScale(gray, 1.3, 5)
    for (x, y, w, h) in faces:
        frame = cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
    return frame

def finalizar_limpiar():
    lblVideo.image = ""
    lblInfoVideoPath.configure(text="")
    rad1.configure(state="active")
    rad2.configure(state="active")
    selected.set(0)
    cap.release()

cap = None
root = Tk()

lblInfo1 = Label(root, text="VIDEO DE ENTRADA", font="bold")
lblInfo1.grid(column=0, row=0, columnspan=2)

selected = IntVar()
rad1 = Radiobutton(root, text="Elegir video", width=20, value=1, variable=selected, command=video_de_entrada)
rad2 = Radiobutton(root, text="Video en directo", width=20, value=2, variable=selected, command=video_de_entrada)
rad1.grid(column=0, row=1)
rad2.grid(column=1, row=1)

lblInfoVideoPath = Label(root, text="", width=20)
lblInfoVideoPath.grid(column=0, row=2)

lblVideo = Label(root)
lblVideo.grid(column=0, row=3, columnspan=2)

btnEnd = Button(root, text="Finalizar visualización y limpiar", state="disabled", command=finalizar_limpiar)
btnEnd.grid(column=0, row=4, columnspan=2, pady=10)

root.mainloop()

Además del código también usaremos una imagen llamada icono.ico, que nos servirá para modificar el ícono de nuestro archivo ejecutable. Nuestra imagen será la siguiente:

2.1 Crear ejecutable en directorio + Añadir archivos + Asignarle un ícono

Si usamos el programa tal y como lo tenemos en el anterior punto, obtendremos un error de lectura debido al archivo .xml que tratamos de leer. Esto no solo puede pasar con este tipo de archivos, sino con cualquier otro archivo extra que necesites para tu programa. Así que lo que haremos para solucionarlo es ubicarlo en el mismo directorio que nuestro archivo .py.

Pero, ¿dónde puedo encontrar el archivo haarcascade_frontalface_default.xml?

Podemos hacerlo de dos maneras. Veamos:

  1. Vamos a buscar donde está instalado OpenCV, es decir cv2. Una vez encontrado nos dirigimos a la carpeta data, y allí veremos listados algunos archivos .xml que corresponden a distintos detectores que nos ofrece esta librería, entonces escogemos haarcascade_frontalface_default.xml. Si estás usando entornos virtuales, probablemente podrás encontrarlo en: tuEntornoVirtual\Lib\site-packages\cv2\data.
  2. Otra forma es dirigirnos al repositorio oficial de OpenCV en Github y descargarnos el archivo, este es el link: https://github.com/opencv/opencv/blob/4.x/data/haarcascades/haarcascade_frontalface_default.xml

Una vez que tenemos el archivo, lo vamos a colocar en el mismo directorio que nuestro .py. Tendríamos algo así:

Ahora tendremos que modificar la línea 8 del programa, ya que el .xml ahora está en nuestro directorio, entonces lo cambiaremos por:

faceClassif = cv2.CascadeClassifier("haarcascade_frontalface_default.xml")

¡A generar el ejecutable con PyInstaller!

Y ahora sí, podremos generar nuestro ejecutable. Para ello, en el terminal, nos ubicamos en la dirección donde se encuentren los archivos y usaremos la siguiente línea:

pyinstaller --icon=./icono.ico --add-data "haarcascade_frontalface_default.xml;." ./gui_video.py

Entonces estamos indicando con –icon que usaremos la imagen icono.ico como ícono para nuestro ejecutable. Con –add-data agregamos archivos de datos (en este caso el detector facial) que necesita nuestro programa para poder funcionar. Finalmente, ubicamos ./gui_video.py que es nuestro programa principal.

Obtendremos lo siguiente:

A la derecha tenemos nuestro terminal y vemos la línea usada para generar el ejecutable. Mientras que a la izquierda tenemos nuestro directorio, en donde a más de nuestros archivos iniciales tenemos las carpetas build, dist y un archivo .spec. Para ir por nuestro ejecutable tendremos que ir la carpeta dist, luego a la carpeta con el nombre: gui_video (o el nombre de tu archivo principal, a menos que lo cambies). Dentro de esta última, bajamos hasta encontrar el ejecutable:

Podemos ver como gui_video.exe tiene el ícono que le habíamos asignado. Pero además, debajo de este, aparece el detector facial, es decir, el archivo haarcascade_frontalface_default.xml. Entonces ahora damos doble clic sobre el ejecutable, y obtenemos:

Como podemos ver, ya se está ejecutando nuestro archivo. Si lo probamos, veremos que funciona bastante bien.

Quitar el intérprete de Python al momento de ejecutar el archivo .exe

Algo que podemos notar es que detrás de nuestra GUI aparece una ventana, que se trata del intérprete de Python. Si quisiéramos ocultar dicha ventana, podríamos añadir una opción de configuración adicional al momento de generar el ejecutable con PyInstaller. Se trata de windowed.

Para lograrlo debemos borrar todo lo que PyInstaller generó en la carpeta y en el terminal ubicaremos:

pyinstaller --icon=./icono.ico --windowed --add-data "haarcascade_frontalface_default.xml;." ./gui_video.py

Al ubicar –windowed, generaremos nuevamente el ejecutable y ejecutarlo, obtendremos:

Como podemos observar en a imagen, ya no aparece el intérprete de Python al fondo de la GUI.

2.2 Crear ejecutable en un solo archivo + Añadir archivos + Asignarle un ícono

Si intentamos usar el mismo código, junto con los archivos que teníamos en el punto anterior para crear un solo archivo ejecutable con PyInstaller, vamos a obtener un error. Este va a ser ocasionado nuevamente por el archivo .xml. Pero esto puede pasar con cualquier otro archivo adicional.

Para solucionar esto vamos a tener que añadir una función en nuestro programa. Veamos:

from PIL import ImageTk
import cv2
import imutils
import os
import sys

def resourse_path(relative_path):
	try:
		base_path = sys._MEIPASS
	except:
		base_path = os.path.abspath(".")
	return os.path.join(base_path, relative_path)

file_path = resourse_path("haarcascade_frontalface_default.xml")
print(file_path)
faceClassif = cv2.CascadeClassifier(file_path)

La función get_resource_path tiene como objetivo obtener la ruta absoluta de un archivo (en nuestro caso el XML), y con ello evitar errores de lectura. Esta función puede servirte no solo en este caso, sino también para otros archivos adicionales que necesite tu aplicación.

Entonces dentro de esta función usamos: sys._MEIPASS. Que es una variable especial que PyInstaller define automáticamente cuando se ejecuta el programa desde el ejecutable. Esta representa la ruta del directorio temporal de los recursos (esto lo maneja internamente PyInstaller), cuando se ejecuta el archivo ejecutable.

Por otro lado, si es que no se corre el programa desde el ejecutable, simplemente no se usará sys.__MEIPASS sino que se tomará la ubicación del archivo en la carpeta.

Finalmente, se concatena cualquiera de estas dos alternativas con el nombre del archivo y de este modo estaremos obteniendo la ruta absoluta completa.

Una vez que lo tenemos listos, vamos a proceder a generar el archivo ejecutable, para ello usaremos:

pyinstaller --icon=./icono.ico --onefile --add-data "haarcascade_frontalface_default.xml;." ./gui_video.py

NOTA: Puedes añadir aquí también la opción de configuración –windowed.

Al momento de visualizar tendremos lo siguiente:

Como se corrió desde el archivo ejecutable, y como añadimos en el programa que se imprima el path que nos generó la función get_resource_path, podemos ver este resultado detrás de la GUI. En este caso muestra el path de un archivo temporal.

3. Proyecto 3: Conteo de Parpadeos

Para nuestro tercer proyecto vamos a tener que instalar Mediapipe, mediante:

pip install mediapipe

Y en cuanto al código usaremos el desarrollado en: 👁️ CONTADOR DE PARPADEOS 👁️ | Python – MediaPipe Face Mesh – OpenCV

Además, he separado el programa principal en dos archivos .py. Esto para mostrar que cuando se trata de archivos de Python, PyInstaller los incluye automáticamente en el ejecutable, pero si son otro tipo de archivos, como vimos en la sección anterior. Será mejor usar la función get_resource_path.

Entonces tenemos a utils.py:

import numpy as np

def eye_aspect_ratio(coordinates):
     d_A = np.linalg.norm(np.array(coordinates[1]) - np.array(coordinates[5]))
     d_B = np.linalg.norm(np.array(coordinates[2]) - np.array(coordinates[4]))
     d_C = np.linalg.norm(np.array(coordinates[0]) - np.array(coordinates[3]))

     return (d_A + d_B) / (2 * d_C)

Y al programa principal, blink_counter.py:

import cv2
import mediapipe as mp
import numpy as np
from collections import deque
from utils import eye_aspect_ratio

def drawing_output(frame, coordinates_left_eye, coordinates_right_eye, blink_counter):
     aux_image = np.zeros(frame.shape, np.uint8)
     contours1 = np.array([coordinates_left_eye])
     contours2 = np.array([coordinates_right_eye])
     cv2.fillPoly(aux_image, pts=[contours1], color=(255, 0, 0))
     cv2.fillPoly(aux_image, pts=[contours2], color=(255, 0, 0))
     output = cv2.addWeighted(frame, 1, aux_image, 0.7, 1)

     cv2.rectangle(output, (0, 0), (200, 50), (255, 0, 0), -1)
     cv2.rectangle(output, (202, 0), (265, 50), (255, 0, 0),2)
     cv2.putText(output, "Num. Parpadeos:", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2)
     cv2.putText(output, "{}".format(blink_counter), (220, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (128, 0, 250), 2)
     
     return output

#cap = cv2.VideoCapture('video_0001.mp4')
cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)

mp_face_mesh = mp.solutions.face_mesh
index_left_eye = [33, 160, 158, 133, 153, 144]
index_right_eye = [362, 385, 387, 263, 373, 380]
EAR_THRESH = 0.26
NUM_FRAMES = 2
aux_counter = 0
blink_counter = 0

with mp_face_mesh.FaceMesh(
     static_image_mode=False,
     max_num_faces=1) as face_mesh:

     while True:
          ret, frame = cap.read()
          if ret == False:
               break
          frame = cv2.flip(frame, 1)
          height, width, _ = frame.shape
          frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
          results = face_mesh.process(frame_rgb)

          coordinates_left_eye = []
          coordinates_right_eye = []

          if results.multi_face_landmarks is not None:
               for face_landmarks in results.multi_face_landmarks:
                    for index in index_left_eye:
                         x = int(face_landmarks.landmark[index].x * width)
                         y = int(face_landmarks.landmark[index].y * height)
                         coordinates_left_eye.append([x, y])
                         cv2.circle(frame, (x, y), 2, (0, 255, 255), 1)
                         cv2.circle(frame, (x, y), 1, (128, 0, 250), 1)
                    for index in index_right_eye:
                         x = int(face_landmarks.landmark[index].x * width)
                         y = int(face_landmarks.landmark[index].y * height)
                         coordinates_right_eye.append([x, y])
                         cv2.circle(frame, (x, y), 2, (128, 0, 250), 1)
                         cv2.circle(frame, (x, y), 1, (0, 255, 255), 1)
               ear_left_eye = eye_aspect_ratio(coordinates_left_eye)
               ear_right_eye = eye_aspect_ratio(coordinates_right_eye)
               ear = (ear_left_eye + ear_right_eye)/2

               # Ojos cerrados
               if ear < EAR_THRESH:
                    aux_counter += 1
               else:
                    if aux_counter >= NUM_FRAMES:
                         aux_counter = 0
                         blink_counter += 1                
               frame = drawing_output(frame, coordinates_left_eye, coordinates_right_eye, blink_counter)
               
          cv2.imshow("Frame", frame)
          k = cv2.waitKey(1) & 0xFF
          if k == 27:
               break
cap.release()
cv2.destroyAllWindows()

Para generar el ejecutable, como anteriormente lo hicimos. Nos ubicaremos en el mismo directorio en donde está nuestro programa. Y lo generaremos con:

pyinstaller ./blink_counter.py

Entonces lo obtendremos junto con sus dependencias en un directorio.

Si intentamos correr el ejecutable, veremos que no se podrá hacer. Así que para corregir este comportamiento vamos a hacer lo siguiente:

  1. Procedemos a buscar donde se encuentra instalado Mediapipe. Si estás usando entornos virtuales, probablemente podrás encontrarlo en: tuEntornoVirtual\Lib\site-packages\mediapipe. Una vez allí copiaremos la carpeta modules.
  2. Ahora volveremos a la carpeta donde se encuentra nuestro ejecutable junto con sus dependencias y buscamos la carpeta mediapipe. Es allí donde pegamos la carpeta modules.

Este proceso nos permitirá usar las soluciones de mediapie. Ahora sí, podemos probar el ejecutable:

¡Y ya lo tenemos!.

Hemos llegado al final de este tutorial, el cual espero que haya sido de utilidad. ¡Nos vemos el siguiente blog post!.

Oh, y te dejo la documentación de PyInstaller, así como la fuente que usé para construir el tutorial:

📜 Documentación:
🔗 https://pypi.org/project/pyinstaller/
🔗https://github.com/pyinstaller/pyinstaller

📚 Referencias:
🔗 https://docs.hektorprofe.net/python/distribucion/pyinstaller/
🔗 https://stackoverflow.com/questions/7674790/bundling-data-files-with-pyinstaller-onefile
🔗 https://github.com/google/mediapipe/issues/2162

Salir de la versión móvil