OpenCV を使った ASCII アートアニメーション

PythonBeginner
オンラインで実践に進む

はじめに

このプロジェクトでは、OpenCV を使用して画像と動画を処理し、ASCII アートアニメーションを作成します。

  • OpenCV のコンパイル
  • OpenCV を使用した画像と動画の処理
  • 画像を ASCII アートに変換する原理
  • デーモンスレッド
  • カーソル位置決めとエスケープエンコーディング

👀 プレビュー

ASCII アートアニメーションのプレビュー

🎯 タスク

このプロジェクトでは、以下を学習します。

  • OpenCV を使用して画像と動画を ASCII アートアニメーションに変換する方法。
  • カーソル位置決めとエスケープエンコーディングを使用して、ターミナルで ASCII アートアニメーションを再生する方法。
  • ASCII アートアニメーションデータをエクスポートし、ロードする方法。

🏆 達成目標

このプロジェクトを完了した後、以下ができるようになります。

  • OpenCV を使用して画像と動画を処理する。
  • 画像を ASCII アートに変換する。
  • ASCII アートアニメーションを再生する。
  • ASCII アートアニメーションデータをエクスポートし、ロードする。

ファイルの作成

皆さんは、動画が実際には一連の画像であることを理解しているはずです。したがって、動画を ASCII アニメーションに変換する基本原理は、画像を ASCII アートに変換することです。

ここで、画像を ASCII アートに変換する背後にある原理を簡単に説明します。まず、画像をグレースケール画像に変換します。このとき、各ピクセルには明るさ情報のみが含まれ(0 - 255 の値で表されます)、その後、限られた文字セットを作成し、各文字を明るさの範囲に対応付けます。この対応関係とピクセルの明るさ情報に基づいて、各ピクセルを対応する文字で表すことができ、これにより ASCII アートが作成されます。

ASCII アニメーションが意味を持つためには、再生可能である必要があります。最も単純な方法は、ASCII アニメーションのテキストファイルをテキストエディタで開き、PageDown キーを繰り返し押すことです。しかし、この方法は非常に単純で粗雑で、まったくエレガントではありません。

その代わりに、一度に 1 フレームずつ出力することで、ターミナルで 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 の 3 つのクラスがあり、後者の 2 つのクラスは最初のクラスを継承しています。

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() メソッドは 1 つのパラメータ luminance を取り、これはピクセルの明るさ情報を受け取ります。このメソッドの return 文の式では 256 という値が使われていることに注意してください。ピクセルの明るさ範囲は 0~255 ですが、この式で 256255 に変更すると IndexError 例外が発生する可能性があります。

convert() メソッドには 1 つの位置引数と 3 つのオプション引数があります。img パラメータは numpy.ndarray 型のオブジェクトを受け取ります。これは、OpenCV が画像を開いたときに返すオブジェクトです。同様に、後で OpenCV を使って取得する動画のフレームもこの型になります。limitSize パラメータは、画像の最大幅と高さを表すタプルを受け取ります。fill パラメータは、画像の幅を最大幅まで空白文字で埋めるかどうかを示し、wrap パラメータは、各行の末尾に改行を追加するかどうかを示します。

img.shape は、画像の行数(高さ)、列数(幅)、およびカラーチャンネル数を含むタプルを返します。画像がグレースケールの場合は、カラーチャンネル数は含まれません。

cv2.resize() 関数は、画像のサイズを変更するために使用されます。最初のパラメータは numpy.ndarray オブジェクトで、2 番目はリサイズ後の画像の希望の幅と高さです。interpolation パラメータは補間方法を指定します。いくつかの補間方法が利用可能で、OpenCV 公式ウェブサイトに説明があります。

好ましい補間方法は、縮小には cv2.INTER_AREA、拡大には cv2.INTER_CUBIC(低速)と cv2.INTER_LINEAR です。デフォルトでは、すべてのリサイズ目的に cv2.INTER_LINEAR が使用されます。

img[i,j] は、画像の i 行 j 列のピクセルの BGR 値のリストを返すか、グレースケール画像の場合は対応するピクセルの明るさ値を返します。

✨ 解答を確認して練習

I2Char クラスの作成

以下は CharFrame を継承した I2Char クラスです。

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 オブジェクトを返します。最初のパラメータは開く画像のパスで、2 番目のパラメータは画像の開き方を指定し、以下の 3 つの値を取ることができます。

  • 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 クラスの作成

次に焦点を当てるのは、CharFrame クラスを継承した V2Char クラスです。

まず、このクラスについて考えてみましょう。その属性の 1 つは charVideo で、これは文字アニメーションのすべてのデータを格納するためのリストです。

次に、2 つの主要なメソッドがあります。1 つは genCharVideo() メソッドで、動画ファイルを文字アニメーションに変換します。もう 1 つは 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) ## 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() は動画の次のフレームを読み込みます。これは 2 つの要素を持つタプルを返します。最初の要素はフレームが正しく読み込まれたかどうかを示すブール値で、2 番目の要素はフレームのデータを含む numpy.ndarray です。

cv2.cvtColor() は画像の色空間を変換するために使用されます。最初のパラメータは画像オブジェクトで、2 番目のパラメータは変換の種類を示します。OpenCV には 150 以上の色空間変換があります。ここでは、色からグレーへの変換 cv2.COLOR_BGR2GRAY を使用しています。

os.get_terminal_size() は現在のターミナルの列数(幅)と行数(高さ)を返します。fill パラメータを True に設定し、wrap パラメータを設定していないので、デフォルトで False になります。ターミナルでは、印刷された文字が 1 行の幅を超えると、ターミナルが自動的に折り返し表示します。

最後に、cap.release() でリソースを解放するのを忘れないでください。

次は play() メソッドです。前述のように、文字アニメーションの再生後にターミナルが無駄な文字で埋まらないようにするために、カーソル位置決めのエスケープコードを使用することができます。

このようにすることができます。各フレームを出力した後、カーソルを再生の開始位置に移動し、次のフレームはこの位置から出力され、自動的に前の内容を上書きします。このプロセスを再生が完了するまで繰り返し、最後に出力されたフレームをクリアすると、ターミナルが文字アートで埋まることはありません。

以下は一連のカーソル位置決めのエスケープコードです(一部のターミナルは一部のエスケープコードをサポートしていない場合があります)。これらは The Linux Command Line から引用したものです。

エスケープコード アクション
\033[l;cH カーソルを lc 列に移動します。
\033[nA カーソルを n 行上に移動します。
\033[nB カーソルを n 行下に移動します。
\033[nC カーソルを n 文字前に移動します。
\033[nD カーソルを n 文字後ろに移動します。
\033[2J 画面をクリアし、カーソルを (0, 0) に移動します。
\033[K カーソル位置から現在の行の末尾までをクリアします。
\033[s 現在のカーソル位置を保存します。
\033[u 前に保存したカーソル位置を復元します。

もう 1 つの問題は、途中で再生を停止するにはどうすればよいかです。もちろん、Ctrl + C を押すことができますが、この方法では、プログラムはクリーンアップ作業を行うことができず、ターミナルはたくさんの無駄な文字で埋まってしまいます。

私たちはこのように設計しました。文字アニメーションの再生が開始されると、ユーザー入力を待つデーモンスレッドが開始されます。入力を受け取ると、文字アニメーションの再生が停止します。

ここで注意すべきことが 2 点あります。

  1. input() メソッドを使用して文字入力を受け取らないでください。
  2. 通常のスレッドを使用することはできません。

1 点目について、任意の文字を押して再生を停止したい場合は、input() を使用しないでください。そうしないと、再生を停止するには Enter を押すか、別の文字を押してから Enter を押す必要があります。要するに、input() を使用するのは快適ではありません。最善の解決策は、C 言語の getchar() メソッドのようなものを使用することです。しかし、Python は同様のメソッドを提供していません。後でコードで代替策を提供します。

2 点目について、派生スレッドがデーモンスレッドとして設定されていない限り、派生スレッドが実行中であれば、メインスレッドは終了しないことを理解する必要があります。したがって、通常のスレッドを使用し、ユーザーが途中で停止しない場合、アニメーションが終了するまで実行し続け、その後、ユーザーが任意の文字を入力するまで永久に実行されます。再生が完了したときにこの派生スレッドを手動で殺すことができれば問題ありません。しかし、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
BadApple の ASCII アニメーション

その後、再度変換することなく再生するには、エクスポートされた ASCII アートファイルを直接読み込むことができます。ファイルが charvideo.txt であると仮定します。

python3 CLIPlayVideo.py charvideo.txt
✨ 解答を確認して練習

まとめ

このプロジェクトでは、OpenCV を使用して画像と動画を処理しています。ここで紹介した OpenCV の操作は、皆さんにとってはきっかけに過ぎません。もっと深く掘り下げたい場合は、公式ドキュメントをもっと見るべきです。デーモンスレッドを使用することで、皆さんはデーモンスレッドとは何か、通常のスレッドとどのように異なるかを理解できたと思います。最後に、カーソル位置決めとエスケープコードについても学びました。これはあまり役に立たないかもしれませんが、面白いことであり、たくさんのことに利用できます。もっと試してみるといいでしょう。