Animation d'art ASCII avec OpenCV

PythonPythonBeginner
Pratiquer maintenant

💡 Ce tutoriel est traduit par l'IA à partir de la version anglaise. Pour voir la version originale, vous pouvez cliquer ici

Introduction

Dans ce projet, nous allons utiliser OpenCV pour traiter des images et des vidéos afin de créer des animations d'art ASCII.

  • Compilation d'OpenCV
  • Traitement d'images et de vidéos avec OpenCV
  • Principes de conversion d'images en art ASCII
  • Threads de type démon (daemon threads)
  • Positionnement du curseur et encodage d'échappement

👀 Aperçu

Aperçu de l'animation d'art ASCII

🎯 Tâches

Dans ce projet, vous allez apprendre :

  • Comment convertir des images et des vidéos en animations d'art ASCII en utilisant OpenCV.
  • Comment jouer des animations d'art ASCII dans le terminal en utilisant le positionnement du curseur et l'encodage d'échappement.
  • Comment exporter et charger des données d'animation d'art ASCII.

🏆 Réalisations

Après avoir terminé ce projet, vous serez en mesure de :

  • Utiliser OpenCV pour traiter des images et des vidéos.
  • Convertir des images en art ASCII.
  • Jouer des animations d'art ASCII.
  • Exporter et charger des données d'animation d'art ASCII.

Création d'un fichier

Chacun devrait comprendre qu'une vidéo est en réalité une série d'images. Ainsi, le principe de base de la conversion d'une vidéo en animation ASCII consiste à convertir des images en art ASCII.

Voici une explication simple du principe de conversion d'images en art ASCII : Tout d'abord, l'image est convertie en une image en niveaux de gris, où chaque pixel ne contient que des informations de luminosité (représentées par des valeurs de 0 à 255). Ensuite, nous créons un ensemble limité de caractères, où chaque caractère correspond à une plage de valeurs de luminosité. Nous pouvons alors représenter chaque pixel par le caractère correspondant en fonction de cette correspondance et des informations de luminosité du pixel, créant ainsi de l'art ASCII.

Pour que l'animation ASCII ait un sens, elle doit être jouable. La manière la plus brute et simpliste de faire cela consiste à ouvrir le fichier texte de l'animation ASCII dans un éditeur de texte et à appuyer à plusieurs reprises sur la touche PageDown. Cependant, cette approche est vraiment trop simple et brute, et loin d'être élégante.

Au lieu de cela, nous pouvons jouer l'animation ASCII dans le terminal en affichant une image à la fois. Cependant, cette approche a un inconvénient majeur : pendant la lecture, vous remarquerez que la barre de défilement à droite du terminal devient de plus en plus petite (si elle existe) ; après la lecture, si vous faites défiler le terminal vers le haut, tout ce que vous voyez est l'art ASCII précédemment affiché, et tout l'historique des commandes avant la lecture est poussé hors de vue. Une solution à ce problème sera présentée plus tard dans ce projet.

Créez un fichier CLIPlayVideo.py dans le répertoire ~/project.

cd ~/project
touch CLIPlayVideo.py

Ensuite, installez le module opencv-python :

sudo pip install opencv-python

Création de la classe CharFrame

Pour ne pas oublier, importez les packages nécessaires :

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

Le dernier module, pyprind, fournit une barre de progression à afficher. Étant donné que la conversion d'une vidéo en animation de caractères est un processus long, nous avons besoin d'une barre de progression pour voir l'avancement et le temps restant approximatif, afin d'avoir une compréhension plus intuitive de l'état du programme. Installez - le comme suit :

sudo pip3 install pyprind

Dans ce projet, en plus de convertir des fichiers vidéo en animations de caractères et de les jouer, nous avons également ajouté la fonctionnalité de convertir des fichiers image en art de caractères. Par conséquent, dans la conception de notre programme, nous avons trois classes : CharFrame, I2Char et V2Char, les deux dernières classes héritant de la première.

La classe CharFrame :

class CharFrame:

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

    ## Map pixels to characters
    def pixelToChar(self, luminance):
        return self.ascii_char[int(luminance/256*len(self.ascii_char))]

    ## Convert a regular frame to an ASCII character frame
    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

L'attribut ascii_char peut être ajusté en fonction de la vidéo que vous souhaitez convertir.

La méthode pixelToChar() prend un seul paramètre, luminance, qui reçoit les informations de luminosité du pixel. Il convient de noter que l'expression dans l'instruction return de la méthode utilise la valeur 256. Bien que la plage de luminosité du pixel soit 0~255, changer 256 en 255 dans cette expression peut entraîner une exception IndexError.

La méthode convert() a un paramètre positionnel et trois paramètres optionnels. Le paramètre img reçoit un objet de type numpy.ndarray, qui est l'objet retourné par OpenCV lors de l'ouverture d'une image. De même, les images de la vidéo obtenues plus tard avec OpenCV seront également de ce type. Le paramètre limitSize accepte un tuple qui représente la largeur et la hauteur maximales de l'image. Le paramètre fill indique s'il faut remplir la largeur de l'image jusqu'à la largeur maximale avec des espaces, et le paramètre wrap indique s'il faut ajouter un saut de ligne à la fin de chaque ligne.

img.shape retourne un tuple contenant le nombre de lignes (hauteur), de colonnes (largeur) et de canaux de couleur de l'image. Si l'image est en niveaux de gris, elle n'inclut pas le nombre de canaux de couleur.

La fonction cv2.resize() est utilisée pour redimensionner l'image. Le premier paramètre est l'objet numpy.ndarray, et le deuxième est la largeur et la hauteur souhaitées de l'image redimensionnée. Le paramètre interpolation spécifie la méthode d'interpolation. Plusieurs méthodes d'interpolation sont disponibles, comme expliqué sur le site officiel d'OpenCV :

Les méthodes d'interpolation préférées sont cv2.INTER_AREA pour la réduction et cv2.INTER_CUBIC (lente) et cv2.INTER_LINEAR pour l'agrandissement. Par défaut, la méthode d'interpolation utilisée est cv2.INTER_LINEAR pour toutes les opérations de redimensionnement.

img[i,j] retourne une liste des valeurs BGR du pixel à la i - ème ligne et à la j - ème colonne de l'image, ou la valeur de luminosité du pixel correspondant pour les images en niveaux de gris.

✨ Vérifier la solution et pratiquer

Création de la classe I2Char

Voici la classe I2Char qui hérite 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() lit une image et retourne un objet numpy.ndarray. Le premier paramètre est le chemin de l'image à ouvrir, et le deuxième paramètre spécifie la manière d'ouvrir l'image et peut avoir les trois valeurs suivantes :

  • cv2.IMREAD_COLOR : Charge une image en couleur, en ignorant le canal alpha.
  • cv2.IMREAD_GRAYSCALE : Charge l'image en mode niveaux de gris.
  • cv2.IMREAD_UNCHANGED : Charge l'image en incluant le canal alpha.

La méthode show() accepte un paramètre indiquant quel flux de sortie utiliser. 1 représente la sortie standard sys.stdout, et 2 représente la sortie d'erreur standard sys.stderr. sys.stdout.fileno() et sys.stderr.fileno() retournent respectivement le descripteur de fichier pour la sortie standard et la sortie d'erreur standard. os.isatty(fd) retourne une valeur booléenne indiquant si le descripteur de fichier fd est ouvert et connecté à un périphérique tty.

✨ Vérifier la solution et pratiquer

Création de la classe V2Char

Ensuite, notre attention se porte sur la classe V2Char, qui hérite de la classe CharFrame.

Tout d'abord, réfléchissons à notre classe. L'un de ses attributs est charVideo, qui est une liste utilisée pour stocker toutes les données de l'animation de caractères.

Ensuite, nous avons deux méthodes principales : l'une est la méthode genCharVideo(), qui convertit un fichier vidéo en une animation de caractères, et l'autre est la méthode play(), qui joue l'animation de caractères.

De plus, étant donné que la conversion d'une vidéo en animation de caractères est un processus long, nous pouvons exporter les données de l'animation de caractères depuis charVideo pour faciliter la lecture future. Cela signifie que nous avons besoin de méthodes d'exportation et de chargement, à savoir la méthode export() et la méthode load().

La classe a également besoin d'une méthode d'initialisation qui prend le chemin du fichier à lire comme paramètre. Si le fichier est un fichier txt exporté, elle appellera la méthode load() pour charger les données dans l'attribut charVideo. Sinon, il sera traité comme un fichier vidéo et appellera la méthode genCharVideo() pour convertir la vidéo en une animation de caractères et la stocker dans l'attribut charVideo.

class V2Char(CharFrame):

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

Voici ensuite la méthode 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

La méthode cv2.VideoCapture() est utilisée pour lire le fichier vidéo, et l'objet retourné est assigné à cap.

En utilisant la méthode cap.get(), nous pouvons obtenir les propriétés de la vidéo, par exemple cap.get(3) et cap.get(4) qui retournent la largeur et la hauteur de la vidéo, et cap.get(5) qui retourne le taux de trames de la vidéo. cap.get(7) retourne le nombre total de trames de la vidéo.

timeInterval stocke l'intervalle de temps de lecture, utilisé pour que le taux de trames de l'animation de caractères soit le même que celui de la vidéo originale.

pyprind.prog_bar() est un générateur qui affiche une barre de progression dans le terminal lors de l'itération.

cap.read() lit la trame suivante de la vidéo. Elle retourne un tuple avec deux éléments. Le premier élément est une valeur booléenne indiquant si la trame a été lue correctement, et le deuxième élément est un numpy.ndarray contenant les données de la trame.

cv2.cvtColor() est utilisé pour convertir l'espace de couleur de l'image. Le premier paramètre est l'objet image, et le deuxième paramètre indique le type de conversion. Il y a plus de 150 conversions d'espaces de couleur dans OpenCV. Ici, nous utilisons la conversion de couleur en niveaux de gris cv2.COLOR_BGR2GRAY.

os.get_terminal_size() retourne le nombre de colonnes (largeur) et le nombre de lignes (hauteur) du terminal actuel. Nous avons défini le paramètre fill sur True et n'avons pas défini le paramètre wrap, donc il prend la valeur par défaut False. Dans le terminal, si les caractères imprimés dépassent la largeur d'une ligne, le terminal effectuera automatiquement un retour à la ligne.

Enfin, n'oubliez pas de libérer la ressource avec cap.release().

Voici ensuite la méthode play(). Comme mentionné précédemment, afin d'empêcher le terminal d'être rempli de caractères inutiles après avoir joué l'animation de caractères, nous pouvons utiliser des codes d'échappement de positionnement du curseur.

Nous pouvons faire cela : après avoir affiché chaque trame, déplacer le curseur au début de la lecture, et la prochaine trame sera affichée à partir de cette position et écrasera automatiquement le contenu précédent. Répétez ce processus jusqu'à la fin de la lecture, puis effacez la dernière trame affichée, de sorte que le terminal ne sera pas rempli d'art de caractères.

Voici une série de codes d'échappement de positionnement du curseur (certains terminaux peuvent ne pas prendre en charge certains codes d'échappement), tirés de The Linux Command Line :

Code d'échappement Action
\033[l;cH Déplace le curseur à la ligne l, colonne c.
\033[nA Déplace le curseur vers le haut de n lignes.
\033[nB Déplace le curseur vers le bas de n lignes.
\033[nC Déplace le curseur vers l'avant de n caractères.
\033[nD Déplace le curseur vers l'arrière de n caractères.
\033[2J Efface l'écran et déplace le curseur à (0, 0).
\033[K Efface depuis la position du curseur jusqu'à la fin de la ligne actuelle.
\033[s Enregistre la position actuelle du curseur.
\033[u Restaure la position précédemment enregistrée du curseur.

Il y a un autre problème : comment arrêter la lecture en cours? Bien sûr, vous pouvez appuyer sur Ctrl + C, mais de cette façon, le programme ne peut effectuer aucun nettoyage et le terminal sera rempli d'un tas de caractères inutiles.

Nous l'avons conçu de cette manière : lorsque l'animation de caractères commence à jouer, elle lance un thread de type démon (daemon thread) qui attend une entrée utilisateur. Dès qu'il reçoit une entrée, il arrête de jouer l'animation de caractères.

Il y a deux choses à noter ici :

  1. N'utilisez pas la méthode input() pour recevoir une entrée de caractères.
  2. Ne pouvez pas utiliser des threads normaux.

Pour le premier point, si vous voulez arrêter la lecture en appuyant sur n'importe quel caractère, vous ne devriez pas utiliser input(), sinon vous devrez soit appuyer sur Entrée pour arrêter la lecture, soit appuyer sur un autre caractère puis sur Entrée pour arrêter la lecture. En résumé, utiliser input() n'est pas pratique. La meilleure solution est d'utiliser quelque chose de similaire à la méthode getchar() en langage C. Cependant, Python ne fournit pas de méthode similaire, et une solution de rechange sera fournie dans le code plus tard.

Pour le deuxième point, nous devons comprendre que si tout thread dérivé est toujours en cours d'exécution, le thread principal ne se terminera pas à moins que le thread dérivé soit défini comme un thread de type démon. Donc, si nous utilisons un thread normal et que l'utilisateur ne l'arrête pas en cours de route, il continuera à s'exécuter jusqu'à la fin de l'animation, puis il s'exécutera indéfiniment jusqu'à ce que l'utilisateur entre n'importe quel caractère. Si nous pouvions tuer manuellement ce thread dérivé lorsque la lecture est terminée, ce ne serait pas un problème. Cependant, Python ne fournit pas de méthode pour tuer des threads. Par conséquent, nous ne pouvons que définir ce thread dérivé comme un thread de type démon. Lorsque le thread principal se termine, le programme n'aura plus qu'un thread de type démon en cours d'exécution, et le thread de type démon sera tué automatiquement lorsque le programme se terminera.

Le code complet de la classe V2Char est le suivant :

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)
✨ Vérifier la solution et pratiquer

Test et exécution

Tapez le code suivant sous le code précédent. Ce code peut être utilisé comme un fichier de script pour convertir une vidéo en art 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()

Entrez la commande suivante pour convertir le fichier ~/project/BadApple.mp4 en animation ASCII, l'exporter sous forme de fichier et jouer l'animation ASCII convertie (le processus d'encodage peut prendre quelques minutes, soyez patient).

cd ~/project
python3 CLIPlayVideo.py BadApple.mp4 -e
Animation ASCII de BadApple

Ensuite, pour la rejouer sans la convertir à nouveau, vous pouvez directement lire le fichier d'art ASCII exporté. Supposons que le fichier soit charvideo.txt :

python3 CLIPlayVideo.py charvideo.txt
✨ Vérifier la solution et pratiquer

Résumé

Ce projet utilise OpenCV pour traiter les images et les vidéos. Les opérations d'OpenCV mentionnées ici ne devraient être que le début pour tout le monde. Si vous souhaitez approfondir vos connaissances, vous devriez consulter plus souvent la documentation officielle. En utilisant un thread de type démon (daemon thread), il est supposé que tout le monde puisse comprendre ce qu'est un thread de type démon et en quoi il diffère d'un thread normal. Enfin, nous avons également appris le positionnement du curseur et les codes d'échappement. Bien que cela puisse ne pas être très utile, c'est tout de même une chose intéressante qui peut être utilisée pour faire de nombreuses choses. Vous pouvez vous amuser avec plus souvent.