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

Por Administrador

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:

  • 芦ruta_origen禄 es la ubicaci贸n del archivo, directorio o recurso que deseas incluir en el ejecutable.
  • 芦ruta_destino禄 es la ubicaci贸n relativa dentro del ejecutable donde deseas que se coloque el archivo o recurso.

–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