Animación de arte ASCII con OpenCV

PythonPythonBeginner
Practicar Ahora

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

En este proyecto, utilizaremos OpenCV para procesar imágenes y videos y crear animaciones de arte ASCII.

  • Compilación de OpenCV
  • Procesamiento de imágenes y videos utilizando OpenCV
  • Principios de conversión de imágenes a arte ASCII
  • Hilos demonio (Daemon threads)
  • Posicionamiento del cursor y codificación de escape

👀 Vista previa

Vista previa de animación de arte ASCII

🎯 Tareas

En este proyecto, aprenderás:

  • Cómo convertir imágenes y videos en animaciones de arte ASCII utilizando OpenCV.
  • Cómo reproducir animaciones de arte ASCII en la terminal utilizando posicionamiento del cursor y codificación de escape.
  • Cómo exportar y cargar datos de animación de arte ASCII.

🏆 Logros

Después de completar este proyecto, podrás:

  • Utilizar OpenCV para procesar imágenes y videos.
  • Convertir imágenes en arte ASCII.
  • Reproducir animaciones de arte ASCII.
  • Exportar y cargar datos de animación de arte ASCII.

Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL python(("Python")) -.-> python/FileHandlingGroup(["File Handling"]) python(("Python")) -.-> python/AdvancedTopicsGroup(["Advanced Topics"]) python(("Python")) -.-> python/FunctionsGroup(["Functions"]) python(("Python")) -.-> python/ModulesandPackagesGroup(["Modules and Packages"]) python(("Python")) -.-> python/ObjectOrientedProgrammingGroup(["Object-Oriented Programming"]) python/FunctionsGroup -.-> python/arguments_return("Arguments and Return Values") python/ModulesandPackagesGroup -.-> python/importing_modules("Importing Modules") python/ModulesandPackagesGroup -.-> python/using_packages("Using Packages") python/ModulesandPackagesGroup -.-> python/standard_libraries("Common Standard Libraries") python/ObjectOrientedProgrammingGroup -.-> python/classes_objects("Classes and Objects") python/ObjectOrientedProgrammingGroup -.-> python/inheritance("Inheritance") python/FileHandlingGroup -.-> python/file_reading_writing("Reading and Writing Files") python/FileHandlingGroup -.-> python/file_operations("File Operations") python/AdvancedTopicsGroup -.-> python/threading_multiprocessing("Multithreading and Multiprocessing") subgraph Lab Skills python/arguments_return -.-> lab-298850{{"Animación de arte ASCII con OpenCV"}} python/importing_modules -.-> lab-298850{{"Animación de arte ASCII con OpenCV"}} python/using_packages -.-> lab-298850{{"Animación de arte ASCII con OpenCV"}} python/standard_libraries -.-> lab-298850{{"Animación de arte ASCII con OpenCV"}} python/classes_objects -.-> lab-298850{{"Animación de arte ASCII con OpenCV"}} python/inheritance -.-> lab-298850{{"Animación de arte ASCII con OpenCV"}} python/file_reading_writing -.-> lab-298850{{"Animación de arte ASCII con OpenCV"}} python/file_operations -.-> lab-298850{{"Animación de arte ASCII con OpenCV"}} python/threading_multiprocessing -.-> lab-298850{{"Animación de arte ASCII con OpenCV"}} end

Creación de un archivo

Todos deben entender que un video es en realidad una serie de imágenes, por lo tanto, el principio básico de convertir un video en una animación ASCII es convertir imágenes en arte ASCII.

A continuación, una simple explicación del principio detrás de la conversión de imágenes en arte ASCII: Primero, la imagen se convierte en una imagen en escala de grises, donde cada píxel solo contiene información de brillo (representada por valores del 0 al 255). Luego, creamos un conjunto limitado de caracteres, donde cada carácter corresponde a un rango de valores de brillo. Entonces, podemos representar cada píxel con el carácter correspondiente basado en esta correspondencia y la información de brillo del píxel, creando así arte ASCII.

Para que una animación ASCII tenga sentido, debe ser reproducible. La forma más cruda y simplista de hacer esto es abrir el archivo de texto de la animación ASCII en un editor de texto y presionar repetidamente la tecla PageDown. Sin embargo, este enfoque es realmente demasiado simple y crudo, y nada elegante.

En cambio, podemos reproducir la animación ASCII en la terminal mostrando un cuadro (frame) a la vez. Sin embargo, este enfoque tiene una gran desventaja: durante la reproducción, notarás que la barra de desplazamiento en el lado derecho de la terminal se vuelve cada vez más pequeña (si existe); después de la reproducción, si desplazas hacia arriba en la terminal, todo lo que verás es el arte ASCII previamente mostrado, y todo el historial de comandos antes de la reproducción se desplaza hacia fuera. Se proporcionará una solución a este problema más adelante en este proyecto.

Crea un archivo CLIPlayVideo.py en el directorio ~/project.

cd ~/project
touch CLIPlayVideo.py

Luego, instala el módulo opencv-python:

sudo pip install opencv-python

Creación de la clase CharFrame

Para no olvidarlo, importa los paquetes necesarios:

import sys
import os
import time
import threading
import termios
import tty
import cv2
import pyprind

El último módulo, pyprind, proporciona una barra de progreso para mostrar. Dado que convertir un video en una animación de caracteres es un proceso que consume tiempo, necesitamos una barra de progreso para ver el progreso y el tiempo restante aproximado, lo que nos brinda una comprensión más intuitiva del estado del programa. Instálalo de la siguiente manera:

sudo pip3 install pyprind

En este proyecto, además de convertir archivos de video en animaciones de caracteres y reproducirlas, también agregamos la funcionalidad de convertir archivos de imagen en arte de caracteres. Por lo tanto, en el diseño de nuestro programa, tenemos tres clases: CharFrame, I2Char y V2Char, siendo las dos últimas clases heredadas de la primera.

La clase CharFrame:

class CharFrame:

    ascii_char = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,\"^`'. "

    ## Mapear píxeles a caracteres
    def pixelToChar(self, luminance):
        return self.ascii_char[int(luminance/256*len(self.ascii_char))]

    ## Convertir un frame normal a un frame de caracteres ASCII
    def convert(self, img, limitSize=-1, fill=False, wrap=False):
        if limitSize!= -1 and (img.shape[0] > limitSize[1] or img.shape[1] > limitSize[0]):
            img = cv2.resize(img, limitSize, interpolation=cv2.INTER_AREA)
        ascii_frame = ''
        blank = ''
        if fill:
            blank += ' '*(limitSize[0]-img.shape[1])
        if wrap:
            blank += '\n'
        for i in range(img.shape[0]):
            for j in range(img.shape[1]):
                ascii_frame += self.pixelToChar(img[i,j])
            ascii_frame += blank
        return ascii_frame

El atributo ascii_char se puede ajustar según el video que desees convertir.

El método pixelToChar() toma un solo parámetro, luminance, que recibe la información de brillo del píxel. Cabe destacar que la expresión en la instrucción return del método utiliza el valor 256. Aunque el rango de brillo del píxel es 0~255, cambiar 256 a 255 en esta expresión puede causar una excepción IndexError.

El método convert() tiene un parámetro posicional y tres parámetros opcionales. El parámetro img recibe un objeto de tipo numpy.ndarray, que es el objeto devuelto por OpenCV al abrir una imagen. Del mismo modo, los frames del video obtenidos más adelante con OpenCV también serán de este tipo. El parámetro limitSize acepta una tupla que representa el ancho y la altura máximos de la imagen. El parámetro fill indica si se debe llenar el ancho de la imagen hasta el ancho máximo con espacios en blanco, y el parámetro wrap indica si se debe agregar un salto de línea al final de cada fila.

img.shape devuelve una tupla que contiene el número de filas (altura), columnas (ancho) y canales de color de la imagen. Si la imagen está en escala de grises, no incluye el número de canales de color.

La función cv2.resize() se utiliza para cambiar el tamaño de la imagen. El primer parámetro es el objeto numpy.ndarray, y el segundo es el ancho y la altura deseados de la imagen redimensionada. El parámetro interpolation especifica el método de interpolación. Hay varios métodos de interpolación disponibles, como se explica en el sitio web oficial de OpenCV:

Los métodos de interpolación preferibles son cv2.INTER_AREA para reducir y cv2.INTER_CUBIC (lento) y cv2.INTER_LINEAR para ampliar. Por defecto, el método de interpolación utilizado es cv2.INTER_LINEAR para todos los fines de redimensionamiento.

img[i,j] devuelve una lista de los valores BGR del píxel en la fila i y la columna j de la imagen, o el valor de brillo del píxel correspondiente para imágenes en escala de grises.

✨ Revisar Solución y Practicar

Creación de la clase I2Char

A continuación, se muestra la clase I2Char que hereda de CharFrame:

class I2Char(CharFrame):

    result = None

    def __init__(self, path, limitSize=-1, fill=False, wrap=False):
        self.genCharImage(path, limitSize, fill, wrap)

    def genCharImage(self, path, limitSize=-1, fill=False, wrap=False):
        img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
        if img is None:
            return
        self.result = self.convert(img, limitSize, fill, wrap)

    def show(self, stream = 2):
        if self.result is None:
            return
        if stream == 1 and os.isatty(sys.stdout.fileno()):
            self.streamOut = sys.stdout.write
            self.streamFlush = sys.stdout.flush
        elif stream == 2 and os.isatty(sys.stderr.fileno()):
            self.streamOut = sys.stderr.write
            self.streamFlush = sys.stderr.flush
        elif hasattr(stream, 'write'):
            self.streamOut = stream.write
            self.streamFlush = stream.flush
        self.streamOut(self.result)
        self.streamFlush()
        self.streamOut('\n')

cv2.imread() lee una imagen y devuelve un objeto numpy.ndarray. El primer parámetro es la ruta de la imagen a abrir, y el segundo parámetro especifica la forma en que se abre la imagen y puede tener los siguientes tres valores:

  • cv2.IMREAD_COLOR: Carga una imagen en color, ignorando el canal alfa.
  • cv2.IMREAD_GRAYSCALE: Carga la imagen en modo de escala de grises.
  • cv2.IMREAD_UNCHANGED: Carga la imagen incluyendo el canal alfa.

El método show() acepta un parámetro que indica qué flujo de salida utilizar. 1 representa la salida estándar sys.stdout, y 2 representa la salida de error estándar sys.stderr. sys.stdout.fileno() y sys.stderr.fileno() devuelven respectivamente el descriptor de archivo para la salida estándar y la salida de error estándar. os.isatty(fd) devuelve un valor booleano que indica si el descriptor de archivo fd está abierto y conectado a un dispositivo tty.

✨ Revisar Solución y Practicar

Creación de la clase V2Char

A continuación, nuestro foco se centra en la clase V2Char, que hereda de la clase CharFrame.

Primero, pensemos en nuestra clase. Uno de sus atributos es charVideo, que es una lista utilizada para almacenar todos los datos de la animación de caracteres.

Luego, tenemos dos métodos principales: uno es el método genCharVideo(), que convierte un archivo de video en una animación de caracteres, y el otro es el método play(), que reproduce la animación de caracteres.

Además, dado que la conversión de video a animación de caracteres es un proceso que consume tiempo, podemos exportar los datos de la animación de caracteres de charVideo para facilitar la reproducción futura. Esto significa que necesitamos métodos de exportación y carga, es decir, el método export() y el método load().

La clase también necesita un método de inicialización que tome como parámetro la ruta del archivo a leer. Si el archivo es un archivo txt exportado, llamará al método load() para cargar los datos en el atributo charVideo. De lo contrario, se tratará como un archivo de video y llamará al método genCharVideo() para convertir el video en una animación de caracteres y almacenarla en el atributo charVideo.

class V2Char(CharFrame):

    def __init__(self, path):
        if path.endswith('txt'):
            self.load(path)
        else:
            self.genCharVideo(path)

A continuación, está el método genCharVideo():

def genCharVideo(self, filepath):
    self.charVideo = []
    cap = cv2.VideoCapture(filepath) ## Use the `cv2.VideoCapture()` method to read the video file
    self.timeInterval = round(1/cap.get(5), 3) ## Get the frame rate of the video
    nf = int(cap.get(7)) ## Get the total number of frames in the video
    print('Generate char video, please wait...')
    for i in pyprind.prog_bar(range(nf)):
        rawFrame = cv2.cvtColor(cap.read()[1], cv2.COLOR_BGR2GRAY)
        frame = self.convert(rawFrame, os.get_terminal_size(), fill=True) ## Convert the raw frame into a character frame
        self.charVideo.append(frame)
    cap.release() ## Release the resource

El método cv2.VideoCapture() se utiliza para leer el archivo de video, y el objeto devuelto se asigna a cap.

Utilizando el método cap.get(), podemos obtener las propiedades del video, como cap.get(3) y cap.get(4) que devuelven el ancho y la altura del video, y cap.get(5) que devuelve la tasa de fotogramas del video. cap.get(7) devuelve el número total de fotogramas del video.

timeInterval almacena el intervalo de tiempo de reproducción, utilizado para hacer que la tasa de fotogramas de la animación de caracteres sea la misma que la del video original.

pyprind.prog_bar() es un generador que muestra una barra de progreso en la terminal al iterar.

cap.read() lee el siguiente fotograma del video. Devuelve una tupla con dos elementos. El primer elemento es un valor booleano que indica si el fotograma se leyó correctamente, y el segundo elemento es un numpy.ndarray que contiene los datos del fotograma.

cv2.cvtColor() se utiliza para convertir el espacio de color de la imagen. El primer parámetro es el objeto de la imagen, y el segundo parámetro indica el tipo de conversión. Hay más de 150 conversiones de espacio de color en OpenCV. Aquí, utilizamos la conversión de color a escala de grises cv2.COLOR_BGR2GRAY.

os.get_terminal_size() devuelve el número de columnas (ancho) y el número de filas (altura) del terminal actual. Establecemos el parámetro fill en True y no establecimos el parámetro wrap, por lo que su valor predeterminado es False. En el terminal, si los caracteres impresos exceden el ancho de una línea, el terminal automáticamente ajustará la visualización.

Finalmente, no olvides liberar el recurso con cap.release().

A continuación, está el método play(). Como se mencionó anteriormente, para evitar que el terminal se llene de caracteres inútiles después de reproducir la animación de caracteres, podemos utilizar códigos de escape de posicionamiento del cursor.

Podemos hacer lo siguiente: después de mostrar cada fotograma, mover el cursor al inicio de la reproducción, y el siguiente fotograma se mostrará desde esta posición y sobrescribirá automáticamente el contenido anterior. Repite este proceso hasta que la reproducción se complete, luego borra el último fotograma mostrado, para que el terminal no se llene de arte de caracteres.

A continuación, se muestra una serie de códigos de escape de posicionamiento del cursor (algunos terminales pueden no admitir algunos códigos de escape), tomados de The Linux Command Line:

Código de Escape Acción
\033[l;cH Mueve el cursor a la línea l, columna c.
\033[nA Mueve el cursor hacia arriba n líneas.
\033[nB Mueve el cursor hacia abajo n líneas.
\033[nC Mueve el cursor hacia adelante n caracteres.
\033[nD Mueve el cursor hacia atrás n caracteres.
\033[2J Borra la pantalla y mueve el cursor a (0, 0).
\033[K Borra desde la posición del cursor hasta el final de la línea actual.
\033[s Guarda la posición actual del cursor.
\033[u Restaura la posición anterior guardada del cursor.

Hay otro problema, ¿cómo detener la reproducción a mitad del camino? Por supuesto, puedes presionar Ctrl + C, pero de esta manera, el programa no puede realizar ningún trabajo de limpieza y el terminal se llenará de una serie de caracteres inútiles.

Lo diseñamos de la siguiente manera: cuando comienza la reproducción de la animación de caracteres, se inicia un hilo demonio que espera la entrada del usuario. Una vez que recibe una entrada, detiene la reproducción de la animación de caracteres.

Hay dos cosas a tener en cuenta aquí:

  1. No uses el método input() para recibir entrada de caracteres.
  2. No se pueden usar hilos normales.

En cuanto al primer punto, si deseas detener la reproducción presionando cualquier carácter, entonces no debes usar input(), de lo contrario, tendrás que presionar Enter para detener la reproducción, o presionar otro carácter y luego presionar Enter para detener la reproducción. En resumen, usar input() no es cómodo. La mejor solución es usar algo similar al método getchar() en el lenguaje C. Sin embargo, Python no proporciona un método similar, y se proporcionará una solución alternativa en el código más adelante.

En cuanto al segundo punto, debemos entender que si cualquier hilo derivado todavía está en ejecución, el hilo principal no saldrá a menos que el hilo derivado se establezca como un hilo demonio. Entonces, si usamos un hilo normal y el usuario no lo detiene a mitad del camino, seguirá ejecutándose hasta que la animación termine, y luego se ejecutará para siempre hasta que el usuario ingrese cualquier carácter. Si pudiéramos matar manualmente este hilo derivado cuando se complete la reproducción, no sería un problema. Sin embargo, Python no proporciona un método para matar hilos. Por lo tanto, solo podemos establecer este hilo derivado como un hilo demonio. Cuando el hilo principal sale, el programa solo tendrá un hilo demonio en ejecución, y el hilo demonio se eliminará automáticamente cuando el programa salga.

El código completo de la clase V2Char es el siguiente:

class V2Char(CharFrame):

    charVideo = []
    timeInterval = 0.033

    def __init__(self, path):
        if path.endswith('txt'):
            self.load(path)
        else:
            self.genCharVideo(path)

    def genCharVideo(self, filepath):
        self.charVideo = []
        cap = cv2.VideoCapture(filepath)
        self.timeInterval = round(1/cap.get(5), 3)
        nf = int(cap.get(7))
        print('Generate char video, please wait...')
        for i in pyprind.prog_bar(range(nf)):
            rawFrame = cv2.cvtColor(cap.read()[1], cv2.COLOR_BGR2GRAY)
            frame = self.convert(rawFrame, os.get_terminal_size(), fill=True)
            self.charVideo.append(frame)
        cap.release()

    def export(self, filepath):
        if not self.charVideo:
            return
        with open(filepath,'w') as f:
            for frame in self.charVideo:
                f.write(frame + '\n') ## Add a newline character to separate each frame

    def load(self, filepath):
        self.charVideo = []
        for i in  open(filepath): ## Each line is a frame
            self.charVideo.append(i[:-1])

    def play(self, stream = 1):
        ## Bug:
        ## Cursor positioning escape codes are incompatible with Windows
        if not self.charVideo:
            return
        if stream == 1 and os.isatty(sys.stdout.fileno()): ## If it's a standard output and its file descriptor refers to a terminal
            self.streamOut = sys.stdout.write
            self.streamFlush = sys.stdout.flush
        elif stream == 2 and os.isatty(sys.stderr.fileno()): ## If it's a standard error output and its file descriptor refers to a terminal
            self.streamOut = sys.stderr.write
            self.streamFlush = sys.stderr.flush
        elif hasattr(stream, 'write'): ## If it has a write attribute
            self.streamOut = stream.write
            self.streamFlush = stream.flush

        old_settings = None
        breakflag = None
        fd = sys.stdin.fileno() ## Get the file descriptor of the standard input

        def getChar():
            nonlocal breakflag
            nonlocal old_settings
            old_settings = termios.tcgetattr(fd) ## Save the attributes of the standard input
            tty.setraw(sys.stdin.fileno()) ## Set the standard input to raw mode
            ch = sys.stdin.read(1) ## Read a character
            breakflag = True if ch else False

        ## Create a thread
        getchar = threading.Thread(target=getChar)
        getchar.daemon = True ## Set it as a daemon thread
        getchar.start() ## Start the daemon thread
        rows = len(self.charVideo[0])//os.get_terminal_size()[0] ## Number of rows in the output character art
        for frame in self.charVideo:
            if breakflag is True: ## Exit the loop if input is received
                break
            self.streamOut(frame)
            self.streamFlush()
            time.sleep(self.timeInterval)
            self.streamOut('\033[{}A\r'.format(rows-1)) ## Move up `rows-1` lines to the start
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) ## Restore the standard input to its original attributes
        self.streamOut('\033[{}B\033[K'.format(rows-1)) ## Move down `rows-1` lines to the last line and clear it
        for i in range(rows-1): ## Clear all lines of the last frame from the second last line onwards
            self.streamOut('\033[1A')
            self.streamOut('\r\033[K')
        info = 'User interrupt!\n' if breakflag else 'Finished!\n'
        self.streamOut(info)
✨ Revisar Solución y Practicar

Pruebas y ejecución

Escribe el siguiente código debajo del código anterior, y este código se puede utilizar como un archivo de script para convertir un video en arte ASCII.

if __name__ == '__main__':
    import argparse
    ## Set command line arguments
    parser = argparse.ArgumentParser()
    parser.add_argument('file',
                        help='Video file or charvideo file')
    parser.add_argument('-e', '--export', nargs='?', const='charvideo.txt',
                        help='Export charvideo file')
    ## Get arguments
    args = parser.parse_args()
    v2char = V2Char(args.file)
    if args.export:
        v2char.export(args.export)
    v2char.play()

Ingresa el siguiente comando para convertir el archivo ~/project/BadApple.mp4 en una animación ASCII, exportarlo como un archivo y reproducir la animación ASCII convertida (el proceso de codificación puede tardar unos minutos, por favor, ten paciencia).

cd ~/project
python3 CLIPlayVideo.py BadApple.mp4 -e
Animación ASCII de BadApple

Después, para reproducirla nuevamente sin convertirla de nuevo, puedes leer directamente el archivo de arte ASCII exportado. Supongamos que el archivo es charvideo.txt:

python3 CLIPlayVideo.py charvideo.txt
✨ Revisar Solución y Practicar

Resumen

Este proyecto utiliza OpenCV para procesar imágenes y videos. Las operaciones de OpenCV mencionadas aquí solo deben ser el comienzo para todos. Si deseas profundizar más, debes consultar más la documentación oficial. Al utilizar un hilo demonio, se espera que todos puedan entender qué es un hilo demonio y cómo se diferencia de un hilo normal. Finalmente, también aprendimos sobre el posicionamiento del cursor y los códigos de escape. Aunque esto puede no ser muy útil, sigue siendo algo interesante que se puede utilizar para hacer muchas cosas. Puedes experimentar más con ello.