使用 OpenCV 创建 ASCII 艺术动画

PythonPythonBeginner
立即练习

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

在这个项目中,我们将使用OpenCV来处理图像和视频,以创建ASCII艺术动画。

  • OpenCV编译
  • 使用OpenCV进行图像和视频处理
  • 图像转ASCII艺术的原理
  • 守护线程
  • 光标定位和转义编码

👀 预览

ASCII艺术动画预览

🎯 任务

在这个项目中,你将学习:

  • 如何使用OpenCV将图像和视频转换为ASCII艺术动画。
  • 如何使用光标定位和转义编码在终端中播放ASCII艺术动画。
  • 如何导出和加载ASCII艺术动画数据。

🏆 成果

完成这个项目后,你将能够:

  • 使用OpenCV处理图像和视频。
  • 将图像转换为ASCII艺术。
  • 播放ASCII艺术动画。
  • 导出和加载ASCII艺术动画数据。

Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL python(("Python")) -.-> python/FunctionsGroup(["Functions"]) python(("Python")) -.-> python/ModulesandPackagesGroup(["Modules and Packages"]) python(("Python")) -.-> python/ObjectOrientedProgrammingGroup(["Object-Oriented Programming"]) python(("Python")) -.-> python/FileHandlingGroup(["File Handling"]) 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{{"使用 OpenCV 创建 ASCII 艺术动画"}} python/importing_modules -.-> lab-298850{{"使用 OpenCV 创建 ASCII 艺术动画"}} python/using_packages -.-> lab-298850{{"使用 OpenCV 创建 ASCII 艺术动画"}} python/standard_libraries -.-> lab-298850{{"使用 OpenCV 创建 ASCII 艺术动画"}} python/classes_objects -.-> lab-298850{{"使用 OpenCV 创建 ASCII 艺术动画"}} python/inheritance -.-> lab-298850{{"使用 OpenCV 创建 ASCII 艺术动画"}} python/file_reading_writing -.-> lab-298850{{"使用 OpenCV 创建 ASCII 艺术动画"}} python/file_operations -.-> lab-298850{{"使用 OpenCV 创建 ASCII 艺术动画"}} python/threading_multiprocessing -.-> lab-298850{{"使用 OpenCV 创建 ASCII 艺术动画"}} end

创建文件

每个人都应该明白,视频实际上是一系列图像,所以将视频转换为ASCII动画的基本原理就是将图像转换为ASCII艺术。

以下是将图像转换为ASCII艺术背后原理的简单解释:首先,将图像转换为灰度图像,其中每个像素仅包含亮度信息(由0 - 255的值表示)。然后,我们创建一个有限的字符集,其中每个字符对应一个亮度值范围。然后,我们可以根据这种对应关系以及像素的亮度信息,用相应的字符表示每个像素,从而创建ASCII艺术。

要使ASCII动画有意义,它需要是可播放的。最粗略和简单的方法是在文本编辑器中打开ASCII动画文本文件,然后反复按下PageDown键。然而,这种方法真的太简单和粗糙了,一点也不优雅。

相反,我们可以通过一次输出一帧在终端中播放ASCII动画。然而,这种方法有一个主要缺点:在播放过程中,你会注意到终端右侧的滚动条会变得越来越小(如果它存在的话);播放后,如果你在终端中向上滚动,你看到的只是之前输出的ASCII艺术,而播放前的所有命令历史都被推出了。本项目后面将提供这个问题的解决方案。

~/project目录中创建一个CLIPlayVideo.py文件。

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

在这个项目中,除了将视频文件转换为字符动画并播放之外,我们还添加了将图像文件转换为字符艺术的功能。因此,在我们的程序设计中,有三个类:CharFrameI2CharV2Char,后两个类继承自第一个类。

CharFrame类:

class CharFrame:

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

    ## 将像素映射到字符
    def pixelToChar(self, luminance):
        return self.ascii_char[int(luminance/256*len(self.ascii_char))]

    ## 将普通帧转换为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

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]返回图像第i行第j列像素的BGR值列表,对于灰度图像则返回相应像素的亮度值。

✨ 查看解决方案并练习

创建I2Char类

以下是继承自CharFrameI2Char类:

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:加载彩色图像,忽略alpha通道。
  • cv2.IMREAD_GRAYSCALE:以灰度模式加载图像。
  • cv2.IMREAD_UNCHANGED:加载包含alpha通道的图像。

show()方法接受一个参数,指示使用哪个输出流。1表示标准输出sys.stdout2表示标准错误输出sys.stderrsys.stdout.fileno()sys.stderr.fileno()分别返回标准输出和标准错误输出的文件描述符。os.isatty(fd)返回一个布尔值,表示文件描述符fd是否打开并连接到一个tty设备。

✨ 查看解决方案并练习

创建V2Char类

接下来我们关注V2Char类,它继承自CharFrame类。

首先,思考一下我们的类。它有一个属性charVideo,这是一个列表,用于存储字符动画的所有数据。

然后我们有两个主要方法:一个是genCharVideo()方法,用于将视频文件转换为字符动画;另一个是play()方法,用于播放字符动画。

此外,由于从视频转换为字符动画是一个耗时的过程,我们可以从charVideo中导出字符动画数据,以便将来播放。这意味着我们需要导出和加载方法,即export()方法和load()方法。

该类还需要一个初始化方法,它以要读取的文件路径作为参数。如果文件是导出的txt文件,它将调用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) ## 使用`cv2.VideoCapture()`方法读取视频文件
    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() ## 释放资源

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()方法。如前所述,为了防止在播放字符动画后终端被无用字符填满,我们可以使用光标定位转义码。

我们可以这样做:在输出每一帧后,将光标移动到播放的开头,下一帧将从这个位置输出并自动覆盖上一帧的内容。重复这个过程直到播放完成,然后清除最后输出的帧,这样终端就不会被字符艺术填满。

以下是一系列光标定位转义码(某些终端可能不支持某些转义码),取自《Linux命令行》:

转义码 操作
\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()不太方便。最好的解决方案是使用类似于C语言中getchar()的方法。然而,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') ## 添加换行符以分隔每一帧

    def load(self, filepath):
        self.charVideo = []
        for i in  open(filepath): ## 每一行是一帧
            self.charVideo.append(i[:-1])

    def play(self, stream = 1):
        ## 问题:
        ## 光标定位转义码与Windows不兼容
        if not self.charVideo:
            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'): ## 如果它有一个write属性
            self.streamOut = stream.write
            self.streamFlush = stream.flush

        old_settings = None
        breakflag = None
        fd = sys.stdin.fileno() ## 获取标准输入的文件描述符

        def getChar():
            nonlocal breakflag
            nonlocal old_settings
            old_settings = termios.tcgetattr(fd) ## 保存标准输入的属性
            tty.setraw(sys.stdin.fileno()) ## 将标准输入设置为原始模式
            ch = sys.stdin.read(1) ## 读取一个字符
            breakflag = True if ch else False

        ## 创建一个线程
        getchar = threading.Thread(target=getChar)
        getchar.daemon = True ## 将其设置为守护线程
        getchar.start() ## 启动守护线程
        rows = len(self.charVideo[0])//os.get_terminal_size()[0] ## 输出字符艺术中的行数
        for frame in self.charVideo:
            if breakflag is True: ## 如果接收到输入则退出循环
                break
            self.streamOut(frame)
            self.streamFlush()
            time.sleep(self.timeInterval)
            self.streamOut('\033[{}A\r'.format(rows-1)) ## 向上移动`rows-1`行到开头
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) ## 将标准输入恢复到其原始属性
        self.streamOut('\033[{}B\033[K'.format(rows-1)) ## 向下移动`rows-1`行到最后一行并清除它
        for i in range(rows-1): ## 从倒数第二行开始清除最后一帧的所有行
            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
    ## 设置命令行参数
    parser = argparse.ArgumentParser()
    parser.add_argument('file',
                        help='视频文件或字符视频文件')
    parser.add_argument('-e', '--export', nargs='?', const='charvideo.txt',
                        help='导出字符视频文件')
    ## 获取参数
    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
BadApple的ASCII动画

之后,若要再次播放而无需重新转换,可直接读取导出的ASCII艺术文件。假设文件为charvideo.txt

python3 CLIPlayVideo.py charvideo.txt
✨ 查看解决方案并练习

总结

本项目使用OpenCV来处理图像和视频。这里提到的OpenCV操作对大家来说应该只是个开始。如果想深入探究,应该多查看官方文档。通过使用守护线程,相信大家能够理解什么是守护线程以及它与普通线程有何不同。最后,我们还了解了光标定位和转义码。虽然这可能不太实用,但它仍然是一件有趣的事情,可用于做很多事情。你可以多去尝试一下。