ASCII-арт анимация с использованием OpenCV

PythonPythonBeginner
Практиковаться сейчас

💡 Этот учебник переведен с английского с помощью ИИ. Чтобы просмотреть оригинал, вы можете перейти на английский оригинал

Введение

В этом проекте мы будем использовать OpenCV для обработки изображений и видео с целью создания ASCII-арт анимаций.

  • Компиляция OpenCV
  • Обработка изображений и видео с использованием OpenCV
  • Принципы преобразования изображений в ASCII-арт
  • Демонические потоки
  • Позиционирование курсора и управляющие последовательности

👀 Предпросмотр

Предпросмотр ASCII-арт анимации

🎯 Задачи

В этом проекте вы научитесь:

  • Преобразовывать изображения и видео в ASCII-арт анимации с использованием OpenCV.
  • Воспроизводить ASCII-арт анимации в терминале с использованием позиционирования курсора и управляющих последовательностей.
  • Экспортировать и загружать данные ASCII-арт анимации.

🏆 Достижения

После завершения этого проекта вы сможете:

  • Использовать OpenCV для обработки изображений и видео.
  • Преобразовывать изображения в ASCII-арт.
  • Воспроизводить ASCII-арт анимации.
  • Экспортировать и загружать данные ASCII-арт анимации.

Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL python(("Python")) -.-> python/FileHandlingGroup(["File Handling"]) python(("Python")) -.-> python/FunctionsGroup(["Functions"]) python(("Python")) -.-> python/ObjectOrientedProgrammingGroup(["Object-Oriented Programming"]) python(("Python")) -.-> python/ModulesandPackagesGroup(["Modules and Packages"]) python(("Python")) -.-> python/AdvancedTopicsGroup(["Advanced Topics"]) 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{{"ASCII-арт анимация с использованием OpenCV"}} python/importing_modules -.-> lab-298850{{"ASCII-арт анимация с использованием OpenCV"}} python/using_packages -.-> lab-298850{{"ASCII-арт анимация с использованием OpenCV"}} python/standard_libraries -.-> lab-298850{{"ASCII-арт анимация с использованием OpenCV"}} python/classes_objects -.-> lab-298850{{"ASCII-арт анимация с использованием OpenCV"}} python/inheritance -.-> lab-298850{{"ASCII-арт анимация с использованием OpenCV"}} python/file_reading_writing -.-> lab-298850{{"ASCII-арт анимация с использованием OpenCV"}} python/file_operations -.-> lab-298850{{"ASCII-арт анимация с использованием OpenCV"}} python/threading_multiprocessing -.-> lab-298850{{"ASCII-арт анимация с использованием OpenCV"}} end

Создание файла

Каждый должен понимать, что видео на самом деле представляет собой последовательность изображений. Поэтому основной принцип преобразования видео в ASCII-анимацию заключается в преобразовании изображений в ASCII-арт.

Вот простое объяснение принципа преобразования изображений в ASCII-арт: сначала изображение преобразуется в черно-белое (оттенки серого), где каждый пиксель содержит только информацию о яркости (представленную значениями от 0 до 255). Затем мы создаем ограниченный набор символов, где каждый символ соответствует определенному диапазону значений яркости. Затем мы можем представить каждый пиксель соответствующим символом на основе этой соответствия и информации о яркости пикселя, таким образом создавая ASCII-арт.

Для того чтобы ASCII-анимация была полезной, ее нужно уметь воспроизводить. Самый простой и примитивный способ сделать это - открыть текстовый файл с ASCII-анимацией в текстовом редакторе и многократно нажимать клавишу PageDown. Однако этот подход слишком прост и примитивен, и совсем не элегантен.

Вместо этого мы можем воспроизводить ASCII-анимацию в терминале, выводя по одному кадру за раз. Однако у этого подхода есть серьезный недостаток: во время воспроизведения вы заметите, что полоса прокрутки справа в терминале становится все меньше (если она есть); после воспроизведения, если вы прокрутите терминал вверх, вы увидите только ранее выведенный ASCII-арт, а вся история команд до воспроизведения будет вытеснена. Решение этой проблемы будет предложено позже в этом проекте.

Создайте файл CLIPlayVideo.py в директории ~/project.

cd ~/project
touch CLIPlayVideo.py

Затем установите модуль opencv-python:

sudo pip install opencv-python

Создание класса CharFrame

Для того чтобы не забыть, импортируйте необходимые пакеты:

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

Последний модуль, pyprind, предоставляет индикатор прогресса для отображения. Поскольку преобразование видео в анимацию из символов является длительным процессом, нам нужен индикатор прогресса, чтобы видеть ход выполнения и приблизительное оставшееся время, что даст более интуитивное понимание статуса программы. Установите его следующим образом:

sudo pip3 install pyprind

В этом проекте, помимо преобразования видеофайлов в анимации из символов и их воспроизведения, мы также добавили функциональность преобразования изображений в ASCII-арт. Поэтому в нашей программе есть три класса: CharFrame, I2Char и V2Char, причем последние два класса наследуются от первого.

Класс 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

Атрибут ascii_char можно настроить в соответствии с видео, которое вы хотите преобразовать.

Метод pixelToChar() принимает один параметр, luminance, который получает информацию о яркости пикселя. Следует отметить, что в выражении в операторе return метода используется значение 256. Хотя диапазон яркости пикселя составляет 0~255, замена 256 на 255 в этом выражении может вызвать исключение IndexError.

Метод convert() имеет один позиционный и три необязательных параметра. Параметр img принимает объект типа numpy.ndarray, который возвращает OpenCV при открытии изображения. Аналогично, кадры видео, полученные позже с использованием OpenCV, также будут иметь этот тип. Параметр limitSize принимает кортеж, представляющий максимальную ширину и высоту изображения. Параметр fill указывает, нужно ли заполнить ширину изображения до максимальной ширины пробелами, а параметр wrap указывает, нужно ли добавлять разрыв строки в конце каждой строки.

img.shape возвращает кортеж, содержащий количество строк (высота), столбцов (ширина) и цветовых каналов изображения. Если изображение в градациях серого, то количество цветовых каналов не включается.

Функция cv2.resize() используется для изменения размера изображения. Первый параметр - это объект numpy.ndarray, а второй - желаемая ширина и высота измененного изображения. Параметр interpolation задает метод интерполяции. Есть несколько доступных методов интерполяции, как объясняется на официальном сайте OpenCV:

Предпочтительные методы интерполяции: cv2.INTER_AREA для уменьшения и cv2.INTER_CUBIC (медленный) и cv2.INTER_LINEAR для увеличения. По умолчанию для всех операций изменения размера используется метод интерполяции cv2.INTER_LINEAR.

img[i,j] возвращает список значений BGR пикселя в i-й строке и j-м столбце изображения или значение яркости соответствующего пикселя для изображений в градациях серого.

✨ Проверить решение и практиковаться

Создание класса I2Char

Вот класс I2Char, который наследуется от 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() считывает изображение и возвращает объект numpy.ndarray. Первый параметр - это путь к изображению, которое нужно открыть, а второй параметр задает способ открытия изображения и может иметь следующие три значения:

  • cv2.IMREAD_COLOR: Загружает цветное изображение, игнорируя альфа-канал.
  • cv2.IMREAD_GRAYSCALE: Загружает изображение в оттенках серого.
  • cv2.IMREAD_UNCHANGED: Загружает изображение с включенным альфа-каналом.

Метод show() принимает параметр, указывающий, какой выходной поток использовать. 1 представляет стандартный вывод sys.stdout, а 2 представляет стандартный поток ошибок sys.stderr. sys.stdout.fileno() и sys.stderr.fileno() соответственно возвращают файловый дескриптор для стандартного вывода и стандартного потока ошибок. os.isatty(fd) возвращает логическое значение, указывающее, открыт ли файловый дескриптор fd и подключен ли он к терминалу (tty-устройству).

✨ Проверить решение и практиковаться

Создание класса V2Char

Теперь наше внимание уделим классу V2Char, который наследуется от класса CharFrame.

Сначала подумаем о нашем классе. Одним из его атрибутов является charVideo, который представляет собой список, используемый для хранения всех данных анимации из символов.

Затем у нас есть два основных метода: один - это метод genCharVideo(), который преобразует видеофайл в анимацию из символов, а другой - метод play(), который воспроизводит анимацию из символов.

Кроме того, так как преобразование видео в анимацию из символов является длительным процессом, мы можем экспортировать данные анимации из символов из charVideo для удобства дальнейшего воспроизведения. Это означает, что нам нужны методы экспорта и загрузки, а именно метод export() и метод load().

Класс также нуждается в методе инициализации, который принимает в качестве параметра путь к файлу, который нужно прочитать. Если файл - это экспортированный текстовый файл, он вызовет метод load() для загрузки данных в атрибут charVideo. В противном случае он будет рассматриваться как видеофайл, и будет вызван метод genCharVideo() для преобразования видео в анимацию из символов и сохранения ее в атрибуте charVideo.

class V2Char(CharFrame):

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

Далее идет метод 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

Метод cv2.VideoCapture() используется для чтения видеофайла, и возвращаемый объект присваивается переменной cap.

Используя метод cap.get(), мы можем получить свойства видео, например, cap.get(3) и cap.get(4) возвращают ширину и высоту видео, а cap.get(5) возвращает частоту кадров видео. cap.get(7) возвращает общее количество кадров в видео.

timeInterval хранит интервал времени воспроизведения, используемый для того, чтобы частота кадров анимации из символов была такой же, как у исходного видео.

pyprind.prog_bar() - это генератор, который выводит индикатор прогресса в терминале при итерации.

cap.read() читает следующий кадр видео. Он возвращает кортеж из двух элементов. Первый элемент - это логическое значение, указывающее, был ли кадр прочитан правильно, а второй элемент - это объект numpy.ndarray, содержащий данные кадра.

cv2.cvtColor() используется для преобразования цветового пространства изображения. Первый параметр - это объект изображения, а второй параметр указывает тип преобразования. В OpenCV есть более 150 преобразований цветовых пространств. Здесь мы используем преобразование из цветного в черно-белое cv2.COLOR_BGR2GRAY.

os.get_terminal_size() возвращает текущее количество столбцов (ширину) и строк (высоту) терминала. Мы установили параметр fill в True и не установили параметр wrap, поэтому он по умолчанию равен False. В терминале, если напечатанные символы превышают ширину одной строки, терминал автоматически перенесет отображение.

Наконец, не забудьте освободить ресурсы с помощью cap.release().

Далее идет метод play(). Как упоминалось ранее, чтобы предотвратить заполнение терминала бесполезными символами после воспроизведения анимации из символов, мы можем использовать управляющие последовательности для позиционирования курсора.

Мы можем сделать следующее: после вывода каждого кадра переместить курсор в начало воспроизведения, и следующий кадр будет выведен с этой позиции и автоматически перезапишет предыдущее содержимое. Повторять этот процесс до завершения воспроизведения, затем очистить последний выведенный кадр, чтобы терминал не был заполнен ASCII-артом.

Вот ряд управляющих последовательностей для позиционирования курсора (некоторые терминалы могут не поддерживать некоторые управляющие последовательности), взятых из книги The Linux Command Line:

Управляющая последовательность Действие
\033[l;cH Переместить курсор на строку l, столбец c.
\033[nA Переместить курсор вверх на n строк.
\033[nB Переместить курсор вниз на n строк.
\033[nC Переместить курсор вперед на n символов.
\033[nD Переместить курсор назад на n символов.
\033[2J Очистить экран и переместить курсор в позицию (0, 0).
\033[K Очистить от текущей позиции курсора до конца текущей строки.
\033[s Сохранить текущую позицию курсора.
\033[u Восстановить предыдущую сохраненную позицию курсора.

Есть еще одна проблема: как остановить воспроизведение посередине? Конечно, вы можете нажать Ctrl + C, но в таком случае программа не сможет выполнить никаких операций по очистке, и терминал будет заполнен кучей бесполезных символов.

Мы спроектировали это так: когда начинается воспроизведение анимации из символов, запускается демонический поток, который ожидает ввода пользователя. Как только он получает ввод, он останавливает воспроизведение анимации из символов.

Вот два момента, на которые нужно обратить внимание:

  1. Не используйте метод input() для приема ввода символов.
  2. Нельзя использовать обычные потоки.

Что касается первого пункта, если вы хотите остановить воспроизведение, нажав любой символ, то не следует использовать input(), иначе вам придется нажимать Enter, чтобы остановить воспроизведение, или нажать другой символ, а затем Enter, чтобы остановить воспроизведение. В любом случае, использование input() неудобно. Лучшее решение - использовать что-то вроде метода getchar() в языке C. Однако Python не предоставляет аналогичного метода, и альтернативное решение будет предложено в коде позже.

Что касается второго пункта, нужно понимать, что если любой порожденный поток все еще работает, основной поток не завершится, если только порожденный поток не установлен как демонический. Поэтому, если мы используем обычный поток и пользователь не остановит его посередине, он будет продолжать работать до завершения анимации, а затем будет работать вечно, пока пользователь не введет какой-либо символ. Если бы мы могли вручную убить этот порожденный поток при завершении воспроизведения, это не было бы проблемой. Однако Python не предоставляет метода для убийства потоков. Поэтому мы можем только установить этот порожденный поток как демонический. Когда основной поток завершается, в программе будет работать только демонический поток, и демонический поток будет автоматически убит при выходе из программы.

Полный код класса V2Char выглядит следующим образом:

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)
✨ Проверить решение и практиковаться

Тестирование и запуск

Введите следующий код ниже предыдущего кода, и этот код можно использовать как скрипт для преобразования видео в 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()

Введите следующую команду, чтобы преобразовать файл ~/project/BadApple.mp4 в ASCII-анимацию, экспортировать ее в файл и воспроизвести преобразованную ASCII-анимацию (процесс кодирования может занять несколько минут, будьте терпеливы).

cd ~/project
python3 CLIPlayVideo.py BadApple.mp4 -e
ASCII-анимация BadApple

Затем, чтобы воспроизвести анимацию снова без повторного преобразования, вы можете напрямую прочитать экспортированный файл с ASCII-артом. Предположим, что файл называется charvideo.txt:

python3 CLIPlayVideo.py charvideo.txt
✨ Проверить решение и практиковаться

Итоги

В этом проекте используется OpenCV для обработки изображений и видео. Операции с OpenCV, упомянутые здесь, должны быть только началом для всех. Если вы хотите углубиться в эту тему, вам следует больше обращаться к официальной документации. Используя демонический поток, надеемся, что все поймут, что такое демонический поток и в чем он отличается от обычного потока. Наконец, мы также узнали о позиционировании курсора и управляющих последовательностях. Хотя это может не быть очень полезно, это все еще интересная вещь, с помощью которой можно сделать много разных действий. Вы можете поэкспериментировать с этим больше.