🐎

OpenCVで画像をAA風にしてみた

2024/08/03に公開

こんにちは

mitsuiJaoです
PythonでAA風にするプログラムを作りました。誰かに見せたいとかじゃないけど自分が生きた証としてこの記事を残します🌟

実行結果 (遠目から見たほうがいいよ)

今回AAを作成したコードは、勉強の意を含め他の方のコードは直接使用してないです。(もちろん、パーツ事には参考にしていますが)

概要

画像または動画を入力し、プロンプトに文字列を出力します。OpenCVでの簡単な実装になっているのでコードも単純なものになっています。

Gitにもリポジトリありますぞよ

ソースはいっちゃん最後に貼っときます

ライブラリ

opencv-python       4.7.0.68
numpy               1.24.1
Pillow              9.4.0

pillowはnoudo.pyのみ使用してます

ファイル

AA
├ aa.py
└ main.py

これとは別にmain.pyで使う文字列を生成するためにnouo.pyを作成しました。
文字列を生成するのにnoudo.pyは必要ではないので同じディレクトリには必要ないです。

中身

AA風の画像を作るにあたって、AAで表示させる文字を決定しなければいけません。具体的には単位文字当たりの線の濃度、言い換えるとその文字がどの程度黒いかを順番に並べる必要があります。

noudo.py

実行すると

['`', '.', "'", '-', ':', ',', '"', '_', '^', '~', '<', ';', '>', '!', '*', '=', '/', '\\', '+', 'L', 'r', '|', '?', 'c', ')', '(', '7', 'v', 'T', '{', 'z', 'J', ']', 's', 'i', 'x', 'Y', '}', '1', 'f', 'F', 'l', 'n', 'C', 'u', 't', 'I', '3', 'o', '2', '5', '[', 'E', 'P', 'j', 'K', 'S', 'y', 'V', 'Z', 'h', 'e', 'a', 'k', 'X', 'U', 'w', '4', 'p', 'b', '9', 'A', 'H', '6', 'm', 'D', 'd', 'G', 'O', 'q', 'R', '#', 'B', 'W', '8', '$', 'N', '%', 'M', '0', 'Q', '&', 'g', '@'] 94

が出力されました。確かに文字がだんだん黒くなってますね~

  1. Pillowで1文字の画像を生成
  2. その画像をcv2に変換
  3. グレイスケール化
  4. 黒のドットが含まれる数をnumpyでカウント

よく考えたらcv2に変換しなくてもできるかなーって思いました。

OpenCVでも同様な処理はできたのですが、どうもカスみたいなフォントしか使えないらしいので、フォントのパスを指定すればそのフォントが使えるというPillowを使いました。

今回はWindows標準のconsola.ttfを使用しました。Windows標準のターミナル上で出力するので、ターミナルで使用しているフォント使うが賢明かもしれないです。僕は特に調べなかったです。

(調べたらOpenCVで外部のフォント、使えるようです)
https://qiita.com/hon_no_mushi/items/b139df3dcf6559404b3f


なにこれ笑教科書フォントかよ笑

aa.py

AA風の文字列を生成するdraw()関数が入ってます。入力はOpenCVで読み込まれたndarray型、返り値はstr型です。
さっきのnoudo.pyで出力された内容をそのままコピペしてassetとしてハードコーディングしてます。毎回処理する必要ないので妥当です(迫真)

またpngなどRGBチャネル以外に透明チャネルがある場合、その部分を(0, 0, 0)で埋め、黒で出力するようにしています。でもこれうまくいったりいかなかったりです。

フローとして

  1. 画像をグレイスケール化
  2. y軸を0.5倍にリサイズ(ターミナルでの文字が縦横比2:1のため)
  3. x軸がOOKISA定数になるようにリサイズ
  4. 各ピクセルをassetの要素数で割る、すなわち255段階のクレイスケールを要素に対応するように
  5. 適宜改行文字\nを入れながら、各ピクセルに対応したassetを結果文字列に追加

こんな感じです。ソースを見ると処理は割と単純で、改めてndarrayの偉大さを感じますな。

また、assetは英大文字小文字数字記号をすべて使用すると94文字になり、全体が白くなりすぎる?気がするので数を減らしたものをいくつか用意しています。適宜使い分けるといいかもしれないです。

あとndaarayのスライス?みたいなやつコロンだけ使うやつなんなんですかね。。?
[:, :, 3]の部分、二次元だと理解できるんですけど、三次元になると途端に意味わからなくなります。

main.py

aa.pyを呼び出す側です。一応読み取り可ファイルとして
動画は"gif", "mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v", "3gp", "3g2"
画像は"jpg", "jpeg", "png", "bmp", "tiff"
の配列を使用してますが、あくまで動画か画像かを判定しているまでです。

読み込んだら画像はそのままdraw()に渡し、動画の場合は1フレームごとにdraw()に渡して出力しています。

また動画の場合、出力するごとにカーソルをdraw()に含まれた\nの数カーソルを上に戻しています。print(AA+"\033["+str(row)+"A")この部分ですね。具体的には"\033[nA"をprintするとn行分だけカーソルを上に移動することができます。ここではAAに含まれる\nの数が該当します。
このようにすることで、連続してバーバー出力されずに上書きされて出力できます。何より見た目がきれいで良きです。
"q"かctrl+cを押すと終了できます。

結果

画像

動画(gif)

By Janke - Own work, CC BY-SA 2.5, https://commons.wikimedia.org/w/index.php?curid=433430

おわり

とまあこんな感じです。やっぱりOpenCVは偉大ですな。独自のデータ型じゃなくてndarrayで操作できるのが何よりつよつよです。

今回初めてZennを使ってみました。noteはマークダウンが弱かったし、qiitaはオワコンみたいなこと言われてたのでZennを使いました。
アイキャッチで絵文字が使えるのはZennしかないユニークなとこだし粋でいいですね。
UIも洗練されてていい感じです。おしゃんなかフェeに来たみたいです。

日記みたいに緩く書ければと思います。テスト勉強頑張ろうね、自分

読んでいただきたいありがとうございました。

ソース

noudo.py
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import cv2
import string
np.set_printoptions(linewidth=10000)
np.set_printoptions(threshold=np.inf)

def pil2cv(imgCV):
    ''' PIL型 -> OpenCV型 '''
    new_image = np.array(imgCV, dtype=np.uint8)
    return new_image

def noudo(asset):
    img = Image.new("L", (30, 50), "black")
    draw = ImageDraw.Draw(img)
    font = ImageFont.truetype("consola.ttf", 50)
    draw.text((0, 0), asset, "white", font=font)
    cv2img = pil2cv(img)
    a, cv2img = cv2.threshold(cv2img, 100, 255, cv2.THRESH_BINARY)
    return np.count_nonzero(cv2img)

word = [i for i in string.ascii_lowercase+string.ascii_uppercase+string.digits+string.punctuation]
result = {}
for i in word:
    n = noudo(i)
    result[i] = n

sorted = sorted(result.items(), key=lambda x:x[1])
l = [i[0] for i in sorted]
print(l, len(l))
aa.py
import cv2
import numpy as np
np.set_printoptions(linewidth=500)
np.set_printoptions(threshold=np.inf)
# aaset = ['`', '.', "'", '-', ':', ',', '"', '_', '^', '~', '<', ';', '>', '!', '*', '=', '/', '\\', '+', 'L', 'r', '|', '?', 'c', ')', '(', '7', 'v', 'T', '{', 'z', 'J', ']', 's', 'i', 'x', 'Y', '}', '1', 'f', 'F', 'l', 'n', 'C', 'u', 't', 'I', '3', 'o', '2', '5', '[', 'E', 'P', 'j', 'K', 'S', 'y', 'V', 'Z', 'h', 'e', 'a', 'k', 'X', 'U', 'w', '4', 'p', 'b', '9', 'A', 'H', '6', 'm', 'D', 'd', 'G', 'O', 'q', 'R', '#', 'B', 'W', '8', '$', 'N', '%', 'M', '0', 'Q', '&', 'g', '@']
# aaset = ['.', '-', ',', '_', '~', ';', '!', '=', '\\', 'L', '|', 'c', '(', 'v', '{', 'J', 's', 'x', '}', 'f', 'l', 'C', 't', '3', '2', '[', 'P', 'K', 'y', 'Z', 'e', 'k', 'U', '4', 'b', 'A', '6', 'D', 'G', 'q', '#', 'W', '$', '%', '0', '&', '@']
# aaset = ['.', ',', '~', '!', '\\', '|', '(', '{', 's', '}', 'l', 't', '2', 'P', 'y', 'e', 'U', 'b', '6', 'G', '#', '$', '0', '@']
aaset = ['.', '~', '\\', '(', 's', 'l', '2', 'y', 'U', '6', '#', '0']

OOKISA = 100
DETAIL = len(aaset) -1

def drow(img):
    if img.shape[2] == 4:
        alpha_channel = img[:, :, 3]
        mask = (alpha_channel == 0)
        # そのピクセルのRGBチャネルを0に設定する
        img[mask] = [0, 0, 0, 0]

    img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    img = cv2.resize(img, None, fx=1, fy=0.5)
    xyratio = img.shape[1] / OOKISA
    img = cv2.resize(img, (OOKISA, int(img.shape[0]/xyratio)))
    monoratio = 255 / DETAIL
    img = img / monoratio

    result = ""
    for i in range(img.shape[0]):
        for j in range(img.shape[1]):
            tmp = round(img[i][j])
            if img[i][j] == 0:
                result += " "
            else:
                result += aaset[tmp]
        result += "\n"

    return result
main.py
import cv2
import sys
import aa
import os

path = "PATH"
ext = os.path.splitext(path)[1]
ext = ext[1:]
video = ["gif", "mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v", "3gp", "3g2"]
image = ["jpg", "jpeg", "png", "bmp", "tiff"]

if ext in video:
    cap = cv2.VideoCapture(path)

    if not cap.isOpened():
        sys.exit()

    row = 0
    f = True
    while True:
        try:
            ret, frame = cap.read()
            if ret:
                cv2.imshow('image', frame)
                AA = aa.drow(frame)
                if f:
                    row = AA.count("\n")+1
                    f = False

                print(AA+"\033["+str(row)+"A")
                if cv2.waitKey(50) & 0xFF == ord('q'):
                    raise KeyboardInterrupt
            else:
                cap.set(cv2.CAP_PROP_POS_FRAMES, 0)

        except KeyboardInterrupt:
            # subprocess.run(["cls"], shell=True)
            print("\033["+str(row)+"B")
            print("quit")
            break
else:
    img = cv2.imread(path)
    AA = aa.drow(img)
    print(AA)

cv2.destroyAllWindows()

Discussion