Animação em Arte ASCII com OpenCV

PythonBeginner
Pratique Agora

Introdução

Neste projeto, usaremos OpenCV para processar imagens e vídeos para criar animações de arte ASCII.

  • Compilação do OpenCV
  • Processamento de imagens e vídeos usando OpenCV
  • Princípios da conversão de imagem para arte ASCII
  • Daemon threads (threads daemon)
  • Posicionamento do cursor e codificação de escape

👀 Pré-visualização

Pré-visualização da animação de arte ASCII

🎯 Tarefas

Neste projeto, você aprenderá:

  • Como converter imagens e vídeos em animações de arte ASCII usando OpenCV.
  • Como reproduzir animações de arte ASCII no terminal usando posicionamento do cursor e codificação de escape.
  • Como exportar e carregar dados de animação de arte ASCII.

🏆 Conquistas

Após concluir este projeto, você será capaz de:

  • Usar OpenCV para processar imagens e vídeos.
  • Converter imagens em arte ASCII.
  • Reproduzir animações de arte ASCII.
  • Exportar e carregar dados de animação de arte ASCII.

Criando o Arquivo

Todos devem entender que um vídeo é, na verdade, uma série de imagens, portanto, o princípio básico de converter um vídeo em uma animação ASCII é converter imagens em arte ASCII.

Aqui está uma explicação simples do princípio por trás da conversão de imagens em arte ASCII: Primeiro, a imagem é convertida em uma imagem em tons de cinza, onde cada pixel contém apenas informações de brilho (representadas pelos valores 0-255). Em seguida, criamos um conjunto limitado de caracteres, onde cada caractere corresponde a uma faixa de valores de brilho. Podemos então representar cada pixel com o caractere correspondente com base nessa correspondência e nas informações de brilho do pixel, criando assim arte ASCII.

Para que a animação ASCII seja significativa, ela precisa ser reproduzível. A maneira mais grosseira e simplista de fazer isso é abrir o arquivo de texto da animação ASCII em um editor de texto e pressionar repetidamente a tecla PageDown. No entanto, essa abordagem é realmente muito simples e grosseira, e nada elegante.

Em vez disso, podemos reproduzir a animação ASCII no terminal, exibindo um quadro por vez. No entanto, essa abordagem tem uma grande desvantagem: durante a reprodução, você notará que a barra de rolagem no lado direito do terminal fica cada vez menor (se existir); após a reprodução, se você rolar para cima no terminal, tudo o que verá é a arte ASCII exibida anteriormente, e todo o histórico de comandos antes da reprodução é empurrado para fora. Uma solução para esse problema será fornecida mais tarde neste projeto.

Crie um arquivo CLIPlayVideo.py no diretório ~/project.

cd ~/project
touch CLIPlayVideo.py

Em seguida, instale os módulos opencv-python:

sudo pip install opencv-python
✨ Verificar Solução e Praticar

Criando a classe CharFrame

Para evitar esquecimentos, importe os pacotes necessários:

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

O último módulo, pyprind, fornece uma barra de progresso para exibição. Como converter um vídeo em uma animação de caracteres é um processo demorado, precisamos de uma barra de progresso para visualizar o progresso e o tempo restante aproximado, fornecendo uma compreensão mais intuitiva do status do programa. Instale-o da seguinte forma:

sudo pip3 install pyprind

Neste projeto, além de converter arquivos de vídeo em animações de caracteres e reproduzi-los, também adicionamos a funcionalidade de converter arquivos de imagem em arte de caracteres. Portanto, em nosso projeto de programa, temos três classes: CharFrame, I2Char e V2Char, com as duas últimas classes herdando da primeira classe.

A classe CharFrame:

class CharFrame:

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

    ## Mapear pixels para caracteres
    def pixelToChar(self, luminance):
        return self.ascii_char[int(luminance/256*len(self.ascii_char))]

    ## Converter um quadro regular em um quadro 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

O atributo ascii_char pode ser ajustado de acordo com o vídeo que você deseja converter.

O método pixelToChar() recebe um único parâmetro, luminance, que recebe as informações de brilho do pixel. Deve-se notar que a expressão na instrução return do método usa o valor 256. Embora a faixa de brilho do pixel seja 0~255, alterar 256 para 255 nesta expressão pode causar uma exceção IndexError.

O método convert() tem um parâmetro posicional e três parâmetros opcionais. O parâmetro img recebe um objeto do tipo numpy.ndarray, que é o objeto retornado pelo OpenCV ao abrir uma imagem. Da mesma forma, os quadros do vídeo obtidos posteriormente usando OpenCV também serão desse tipo. O parâmetro limitSize aceita uma tupla que representa a largura e altura máximas da imagem. O parâmetro fill indica se deve preencher a largura da imagem com espaços até a largura máxima, e o parâmetro wrap indica se deve adicionar uma quebra de linha no final de cada linha.

img.shape retorna uma tupla contendo o número de linhas (altura), colunas (largura) e canais de cor da imagem. Se a imagem for em tons de cinza, ela não inclui o número de canais de cor.

A função cv2.resize() é usada para redimensionar a imagem. O primeiro parâmetro é o objeto numpy.ndarray, e o segundo é a largura e altura desejadas da imagem redimensionada. O parâmetro interpolation especifica o método de interpolação. Vários métodos de interpolação estão disponíveis, conforme explicado no site oficial do OpenCV:

Os métodos de interpolação preferíveis são cv2.INTER_AREA para encolher e cv2.INTER_CUBIC (lento) & cv2.INTER_LINEAR para ampliar. Por padrão, o método de interpolação usado é cv2.INTER_LINEAR para todos os fins de redimensionamento.

img[i,j] retorna uma lista dos valores BGR do pixel na i-ésima linha e j-ésima coluna da imagem, ou o valor de brilho do pixel correspondente para imagens em tons de cinza.

✨ Verificar Solução e Praticar

Criando a classe I2Char

Aqui está a classe I2Char que herda 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() lê uma imagem e retorna um objeto numpy.ndarray. O primeiro parâmetro é o caminho para a imagem a ser aberta, e o segundo parâmetro especifica a forma como a imagem é aberta e pode ter os três valores a seguir:

  • cv2.IMREAD_COLOR: Carrega uma imagem colorida, ignorando o canal alfa.
  • cv2.IMREAD_GRAYSCALE: Carrega a imagem no modo de escala de cinza.
  • cv2.IMREAD_UNCHANGED: Carrega a imagem com o canal alfa incluído.

O método show() aceita um parâmetro indicando qual fluxo de saída usar. 1 representa a saída padrão sys.stdout, e 2 representa a saída de erro padrão sys.stderr. sys.stdout.fileno() e sys.stderr.fileno() retornam, respectivamente, o descritor de arquivo para a saída padrão e a saída de erro padrão. os.isatty(fd) retorna um valor booleano indicando se o descritor de arquivo fd está aberto e conectado a um dispositivo tty.

✨ Verificar Solução e Praticar

Criar a classe V2Char

Então, nosso foco está na classe V2Char, que herda da classe CharFrame.

Primeiro, vamos pensar sobre nossa classe. Um de seus atributos é charVideo, que é uma lista usada para armazenar todos os dados para a animação de caracteres.

Então, temos dois métodos principais: um é o método genCharVideo(), que converte um arquivo de vídeo em uma animação de caracteres, e o outro é o método play(), que reproduz a animação de caracteres.

Além disso, como a conversão de vídeo para animação de caracteres é um processo demorado, podemos exportar os dados da animação de caracteres de charVideo para facilitar a reprodução futura. Isso significa que precisamos de métodos de exportação e carregamento, ou seja, o método export() e o método load().

A classe também precisa de um método de inicialização que recebe o caminho do arquivo a ser lido como um parâmetro. Se o arquivo for um arquivo txt exportado, ele chamará o método load() para carregar os dados no atributo charVideo. Caso contrário, ele será tratado como um arquivo de vídeo e chamará o método genCharVideo() para converter o vídeo em uma animação de caracteres e armazená-lo no atributo charVideo.

class V2Char(CharFrame):

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

Em seguida, vem o método genCharVideo():

def genCharVideo(self, filepath):
    self.charVideo = []
    cap = cv2.VideoCapture(filepath) ## Use o método `cv2.VideoCapture()` para ler o arquivo de vídeo
    self.timeInterval = round(1/cap.get(5), 3) ## Obtenha a taxa de quadros do vídeo
    nf = int(cap.get(7)) ## Obtenha o número total de quadros no vídeo
    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) ## Converta o quadro bruto em um quadro de caracteres
        self.charVideo.append(frame)
    cap.release() ## Libere o recurso

O método cv2.VideoCapture() é usado para ler o arquivo de vídeo, e o objeto retornado é atribuído a cap.

Usando o método cap.get(), podemos obter as propriedades do vídeo, como cap.get(3) e cap.get(4) que retornam a largura e a altura do vídeo, e cap.get(5) que retorna a taxa de quadros do vídeo. cap.get(7) retorna o número total de quadros no vídeo.

timeInterval armazena o intervalo de tempo de reprodução, usado para tornar a taxa de quadros da animação de caracteres igual à do vídeo original.

pyprind.prog_bar() é um gerador que gera uma barra de progresso no terminal durante a iteração.

cap.read() lê o próximo quadro do vídeo. Ele retorna uma tupla com dois elementos. O primeiro elemento é um valor booleano indicando se o quadro foi lido corretamente, e o segundo elemento é um numpy.ndarray contendo os dados do quadro.

cv2.cvtColor() é usado para converter o espaço de cores da imagem. O primeiro parâmetro é o objeto da imagem, e o segundo parâmetro indica o tipo de conversão. Existem mais de 150 conversões de espaço de cores no OpenCV. Aqui, usamos a conversão de cor para cinza cv2.COLOR_BGR2GRAY.

os.get_terminal_size() retorna a contagem de colunas (largura) e a contagem de linhas (altura) do terminal atual. Definimos o parâmetro fill como True e não definimos o parâmetro wrap, então ele assume o padrão False. No terminal, se os caracteres impressos excederem a largura de uma linha, o terminal irá automaticamente quebrar a exibição.

Finalmente, não se esqueça de liberar o recurso com cap.release().

Em seguida, vem o método play(). Como mencionado anteriormente, para evitar que o terminal seja preenchido com caracteres inúteis após a reprodução da animação de caracteres, podemos usar códigos de escape de posicionamento do cursor.

Podemos fazer isso: após a saída de cada quadro, mover o cursor para o início da reprodução, e o próximo quadro será exibido a partir desta posição e substituirá automaticamente o conteúdo anterior. Repita este processo até que a reprodução seja concluída, em seguida, limpe o último quadro que foi exibido, para que o terminal não seja preenchido com arte de caracteres.

Aqui está uma série de códigos de escape de posicionamento do cursor (alguns terminais podem não suportar alguns códigos de escape), retirados de The Linux Command Line:

Código de Escape Ação
\033[l;cH Move o cursor para a linha l, coluna c.
\033[nA Move o cursor para cima n linhas.
\033[nB Move o cursor para baixo n linhas.
\033[nC Move o cursor para frente n caracteres.
\033[nD Move o cursor para trás n caracteres.
\033[2J Limpa a tela e move o cursor para (0, 0).
\033[K Limpa da posição do cursor até o final da linha atual.
\033[s Salva a posição atual do cursor.
\033[u Restaura a posição do cursor salva anteriormente.

Há outro problema, como parar a reprodução no meio do caminho? Claro, você pode pressionar Ctrl + C, mas dessa forma, o programa não pode fazer nenhum trabalho de limpeza e o terminal será preenchido com um monte de caracteres inúteis.

Nós o projetamos desta forma: quando a animação de caracteres começa a ser reproduzida, ela inicia uma thread daemon que espera pela entrada do usuário. Assim que recebe a entrada, ela para de reproduzir a animação de caracteres.

Há duas coisas a serem observadas aqui:

  1. Não use o método input() para receber a entrada de caracteres.
  2. Não pode usar threads normais.

Para o primeiro ponto, se você deseja parar a reprodução pressionando qualquer caractere, então você não deve usar input(), caso contrário, você terá que pressionar Enter para parar a reprodução, ou pressionar outro caractere e, em seguida, pressionar Enter para parar a reprodução. Em suma, usar input() não é confortável. A melhor solução é usar algo semelhante ao método getchar() na linguagem C. No entanto, o Python não fornece um método semelhante, e uma solução alternativa será fornecida no código posterior.

Para o segundo ponto, precisamos entender que, se alguma thread derivada ainda estiver em execução, a thread principal não sairá, a menos que a thread derivada seja definida como uma thread daemon. Portanto, se usarmos uma thread normal e o usuário não a interromper no meio do caminho, ela continuará a ser executada até que a animação termine, e então ela será executada para sempre até que o usuário insira qualquer caractere. Se pudermos matar manualmente esta thread derivada quando a reprodução for concluída, não haverá problema. No entanto, o Python não fornece um método para matar threads. Portanto, só podemos definir esta thread derivada como uma thread daemon. Quando a thread principal sair, o programa terá apenas uma thread daemon em execução, e a thread daemon será morta automaticamente quando o programa sair.

O código completo para a classe V2Char é o seguinte:

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') ## Adicione um caractere de nova linha para separar cada quadro

    def load(self, filepath):
        self.charVideo = []
        for i in  open(filepath): ## Cada linha é um quadro
            self.charVideo.append(i[:-1])

    def play(self, stream = 1):
        ## Bug:
        ## Códigos de escape de posicionamento do cursor são incompatíveis com o Windows
        if not self.charVideo:
            return
        if stream == 1 and os.isatty(sys.stdout.fileno()): ## Se for uma saída padrão e seu descritor de arquivo se referir a um terminal
            self.streamOut = sys.stdout.write
            self.streamFlush = sys.stdout.flush
        elif stream == 2 and os.isatty(sys.stderr.fileno()): ## Se for uma saída de erro padrão e seu descritor de arquivo se referir a um terminal
            self.streamOut = sys.stderr.write
            self.streamFlush = sys.stderr.flush
        elif hasattr(stream, 'write'): ## Se tiver um atributo de escrita
            self.streamOut = stream.write
            self.streamFlush = stream.flush

        old_settings = None
        breakflag = None
        fd = sys.stdin.fileno() ## Obtenha o descritor de arquivo da entrada padrão

        def getChar():
            nonlocal breakflag
            nonlocal old_settings
            old_settings = termios.tcgetattr(fd) ## Salve os atributos da entrada padrão
            tty.setraw(sys.stdin.fileno()) ## Defina a entrada padrão para o modo raw
            ch = sys.stdin.read(1) ## Leia um caractere
            breakflag = True if ch else False

        ## Crie uma thread
        getchar = threading.Thread(target=getChar)
        getchar.daemon = True ## Defina como uma thread daemon
        getchar.start() ## Inicie a thread daemon
        rows = len(self.charVideo[0])//os.get_terminal_size()[0] ## Número de linhas na arte de caracteres de saída
        for frame in self.charVideo:
            if breakflag is True: ## Saia do loop se a entrada for recebida
                break
            self.streamOut(frame)
            self.streamFlush()
            time.sleep(self.timeInterval)
            self.streamOut('\033[{}A\r'.format(rows-1)) ## Mova para cima `rows-1` linhas para o início
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) ## Restaure a entrada padrão para seus atributos originais
        self.streamOut('\033[{}B\033[K'.format(rows-1)) ## Mova para baixo `rows-1` linhas para a última linha e limpe-a
        for i in range(rows-1): ## Limpe todas as linhas do último quadro a partir da penúltima linha
            self.streamOut('\033[1A')
            self.streamOut('\r\033[K')
        info = 'Interrupção do usuário!\n' if breakflag else 'Concluído!\n'
        self.streamOut(info)
✨ Verificar Solução e Praticar

Testando e Executando

Digite o seguinte código abaixo do código anterior, e este código pode ser usado como um arquivo de script para converter vídeo em 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()

Insira o seguinte comando para converter o arquivo ~/project/BadApple.mp4 em animação ASCII, exportá-lo como um arquivo e reproduzir a animação ASCII convertida (o processo de codificação pode levar alguns minutos, por favor, seja paciente).

cd ~/project
python3 CLIPlayVideo.py BadApple.mp4 -e
ASCII animation of BadApple

Posteriormente, para reproduzi-lo novamente sem convertê-lo novamente, você pode ler diretamente o arquivo de arte ASCII exportado. Suponha que o arquivo seja charvideo.txt:

python3 CLIPlayVideo.py charvideo.txt
✨ Verificar Solução e Praticar

Resumo

Este projeto usa OpenCV para processar imagens e vídeos. As operações do OpenCV mencionadas aqui devem ser apenas o começo para todos. Se você deseja se aprofundar, deve consultar mais a documentação oficial. Ao usar uma thread daemon, acredita-se que todos possam entender o que é uma thread daemon e como ela é diferente de uma thread regular. Finalmente, também aprendemos sobre posicionamento do cursor e códigos de escape. Embora isso possa não ser muito útil, ainda é algo interessante que pode ser usado para fazer muitas coisas. Você pode experimentar mais com isso.