👾

ANSI artをつくろう

2022/11/30に公開約14,300字

この記事は、LAPRAS Advent Calendar 2022の1日目の記事です

https://qiita.com/advent-calendar/2022/lapras


LAPRAS 株式会社でSREをしているyktakaha4と申します 🐧
本当は仕事関係のアウトプットをしようと思っていたのですが、直近で思ったより色々とやっていたので、今回は趣味的な話にしようと思います

普段の仕事っぽい話もよければご覧ください

https://zenn.dev/yktakaha4/articles/synthetic_monitoring_with_ur_and_tf

https://speakerdeck.com/yktakaha4/python-social-authdexue-bu-oauth2-dot-0ren-ke-kodohuroniokeruyi-chang-xi-henodui-chu

なお、本記事には古い世代のPCについての記述が多く含まれますが、
私はWindows Meからインターネットに触れた世代のため、誤りが多分に含まれる可能性があります

あしからず🦵

ANSI artってなんだ

みなさんは ANSI art というものを知っていますでしょうか

https://www.youtube.com/watch?v=y901Yk2-ub4

現在普及しているWindowsやMacbookはGUIによる入出力や画像、動画等を容易に表示できますが、
それより前の世代のコンピューター(PC/AT互換機)は性能上の理由からCUIが主流だったため、着色した文字や記号を用いて画面を構成する技術や、そうした技法を用いた表現が活発におこなわれていたそうです

https://ja.wikipedia.org/wiki/PC/AT互換機

文字を使って絵を表現する方法としては、日本ではアスキーアートがより有名かと思いますが、利用されていた環境の制約から以下の点で差異があり、それがそのまま特徴になっているように思います

  • 画面サイズ
  • フォントや1文字あたりの縦横比
  • 文字色や背景色の設定可否
  • 表現可能な色数、色種

当時のBBSの雰囲気は、以下のサイトにアクセスすると知ることができます
こちらは、昔実際に米国はインディアナ州のダイアルアップ回線上で運用されていたものをenigma-bbsというNode.js製のBBSソフトウェアで再現したものだそうです

https://bbs.undercurrents.io/

アクセスすると、最高にクールなトップページが表示されます


bbs.undercurrents.ioのトップページ

前述した通りこれはBBSソフトウェアとして稼働しているため、telnetでも接続ができるようになっています

telnet bbs.undercurrents.io 8088

ただし、私が普段使っているターミナルだとフォント等の差異からコレジャナイ的なビジュアルで表示されます


telnetでアクセスしたbbs.undercurrents.ioのトップページ

また、現代においてもPabloDrawなどの専用のソフトウェアを使えばANSI artを描くことができ、今も活発に投稿がなされているようです

ものすごい縦長(これも当時から続く手法的なものなのでしょうか?)のものがあったり、細密画のようなものがあったり、現代にしかあり得ないような人物が描画されていたりなど見ていて飽きません

https://16colo.rs/pack/mist0822/

上記は2022年現在に作られたものだそうですが、本サイトでは90年代に作られたものも閲覧できます
当時の映画や音楽に言及したものがあったりしておもしろいです

https://16colo.rs/pack/aaa-8991/

ANSI artを理解する

ANSI artがどんなものかわかったところで、早速作ってみましょう
が、私は絵が描けないので 任意の画像からANSI artとして読み込み可能なファイルを出力する というところをゴールにしたいと思います

具体的には、ANSI artを読み込んでpngファイルを出力するansiloveというコマンドがあるので、こちらで読み込めるファイルを作ることを目指します

https://github.com/ansilove/ansilove

インターネットでANSI artについて調べたところ、以下のことがわかりました

.ANS ファイル形式について

ANSI artは、特定の文字を画面に順番に表示しているだけなので、基本的にはただのテキストファイルですが、着色や文字装飾を一定のルールに基づいておこなう必要があります
前述したansiloveをmanコマンドで見てみると、一口にANSI artと言っても様々な形式のものが存在することがわかります

-t type     Specify input file type.
            Valid types are:
            ans     ANSi (ANSI escape sequences: ANSI X3.64 standard)
            adf     Artworx format, supporting custom character sets and palettes
            bin     Binary format (raw memory copy of text mode video memory)
            idf     iCE Draw format, supporting custom character sets and palettes
            pcb     PCBoard Bulletin Board System (BBS) own file format
            tnd     TundraDraw format, supporting 24-bit color mode
            xb      XBin format, supporting custom character sets and palettes

この中で、ANSI X3.64 standard という規格で定められたANSIエスケープシーケンス(後述)を用いて描画をおこなうのがデファクトスタンダードであるようでした

https://noah.org/python/pexpect/ANSI-X3.64.htm

理由としては、特にCUI環境における表示端末のデファクトスタンダードとして参照されているVT100がこの仕様に(概ね)準拠しており、互換性の観点から広く使われるようになったものと思われます

https://ja.wikipedia.org/wiki/VT100

また、この端末の標準の表示幅が80桁であることから、ANSI artもそれを踏襲した幅で作るとよりそれっぽく作ることができそうです

ANSIエスケープシーケンスについて

ANSIエスケープシーケンス(ANSI escape code)については、CLIツールを作ったことがある方なら馴染みがあるかもしれません

https://en.wikipedia.org/wiki/ANSI_escape_code

例えばCLIのタスクマネージャーであるhtopコマンドを使うと、画面にいい感じに色が付きスクロール無しでリアルタイムに表示が更新されますが、これはANSIエスケープシーケンスを使って実現されています


https://htop.dev/ より引用

エスケープシーケンスの仕様は、長期に渡って拡張されながら利用されてきたことに伴い複雑化しているようですが、ANSI artを描くのにあたって必要となる知識はそこまで多くありません
文字色・背景色の変更や文字装飾は SGR (Select Graphic Rendition) parameters というシーケンスパラメータを用いることで実現できます

https://en.wikipedia.org/wiki/ANSI_escape_code#SGR

例として、シアンの文字色と黄色の背景色を用いて描画をおこないたい場合は、以下のようにします
printfコマンドはANSIエスケープシーケンスを概ね解釈可能なため、手元で簡単に試すことができます

printf '\033[36mCyan with \033[43mYellow\n'


着色の様子

その他の指定方法は以下がわかりやすいです

https://qiita.com/PruneMazui/items/8a023347772620025ad6

なお、ANSI artとしては拡張記法も使って細かく色指定をおこなっているものも多く見受けられましたが、
今回は入門ということで、標準で定められている8色(黒、赤、緑、黄、青、マゼンタ、シアン、白)のみを用いて描画をおこなうこととします

Code page 437について

ANSI artに特徴的、かつ魅力的な要素として、以下のようなグラデーションの表現があります

前述したようにANSI artは基本的にテキストファイルのため、気になった表現があればファイルを開けばどのように描画をしているかがわかります
ただ、catやlessコマンドだと非ASCII文字が強調表示され見づらいのでバイナリエディタ等で開くのがオススメです

当該箇所をodコマンドで見てみたところ、以下のようになっていました

順に見ていきましょう
\033[0;36m設定をリセット後、文字色をシアン指定する で、 次に \333\262\261\260 が続き、 \033[0;1m により 設定をリセット後、太字指定する 後、 3 N と文字描画をおこなっています

この中で、\333\262\261\260 がグラデーションの箇所になっていますが、これは制御コードではなく、Code page 437という文字セットで定義されている網掛け文字になります
ASCIIコードをベースに、PC/AT互換機向けに独自拡張をおこなったものだそうです

https://en.wikipedia.org/wiki/Code_page_437

以下の赤枠線で示す箇所が網掛け文字です。絵文字のようなものも定義されており可愛らしいです


https://en.wikipedia.org/wiki/Code_page_437 より引用

なお、このコードはASCIIコードの範囲外のため、Unicode等の標準的な文字コード環境では文字化けのように表示されてしまいますが、専用のフォントを用意すればCUI環境でも描画ができます

https://www.dafont.com/font-comment.php?file=perfect_dos_vga_437

ただ、今回はpngファイル変換はansiloveコマンドにておこなうため本フォントは特に用いず、ANSファイルを作成する際にグラデーションは上記文字コードを出力するように実装すればOKとなります

ASCII artを作ってみる

具体的な仕様がわかったので、実装してみます
仕様は以下のようになりました

  • 任意の画像を読み込む
  • 横幅80ピクセルにあわせて縦横比を維持して縮小
    • 前述したVT100にあわせた幅のため
    • 色々試した結果、一度半分くらいのサイズに落としてから目標サイズに縮小した方が出力結果がよかったのでそのようにした
  • 縦幅を50%にする
    • 最終的に文字(≒長方形の画素)として描画されるため
  • ピクセルごとに色を選択し、文字出力する
    • 黒、赤、緑、黄、青、マゼンタ、シアン、白のいずれかを利用する
    • そのピクセルが1番近い色と2番目に近い色を選択(再近傍探索)し、その差がわずかの場合には網掛け文字を使って表示する
  • 出力結果を .ANS ファイルとして保存する

実装には、弊社の主要言語でもあるPythonを用います
画像処理には opencv-python、色の選択にはSciPyを利用しました

https://github.com/opencv/opencv-python

https://scipy.org/

SciPyには配列から最近傍探索をおこなうための scipy.spatial.KDTree.queryというメソッドが用意されているため、今回はこちらを使います
k を指定すると返却される最近傍の数を増やせます

https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.KDTree.query.html#scipy.spatial.KDTree.query

スクリプトは以下のようになりました

main.py
from os import makedirs
from os.path import dirname, join

import cv2
import numpy as np
import scipy.spatial as sp
import sys


def main():
    SP = '\x20'
    D1 = '\xb0'
    D2 = '\xb1'
    D3 = '\xb2'

    DIFF_D1 = 7.5
    DIFF_D2 = DIFF_D1*2
    DIFF_D3 = DIFF_D2*2

    zoom = 0.5
    columns = 80
    y_adjust = 0.5

    # 元画像の読み込み
    base_dir = dirname(__file__)
    dist_dir = join(base_dir, "dist")
    try:
        makedirs(dist_dir)
    except FileExistsError:
        pass

    # 読み込み
    if len(sys.argv) != 2:
        raise Exception('error: file name not specified')
    input_file = sys.argv[1]
    img = cv2.imread(join(base_dir, input_file))

    print(f'input: {input_file}')

    # カラーパレットを調整
    b, g, r = cv2.split(img)
    b_min = np.amin(b)
    b_mid = np.median(b)
    b_max = np.amax(b)
    g_min = np.amin(g)
    g_mid = np.median(g)
    g_max = np.amax(g)
    r_min = np.amin(r)
    r_mid = np.median(r)
    r_max = np.amax(r)

    print('colors:')
    print(f'\t{b_min=}, {b_mid=}, {b_max=}\n\t{g_min=}, {g_mid=}, {g_max=}\n\t{r_min=}, {r_mid=}, {r_max=}')

    main_colors = [
        (b_min, g_min, r_min, '30', '40'),
        (b_min, g_min, r_max, '31', '41'),
        (b_min, g_max, r_min, '32', '42'),
        (b_min, g_max, r_max, '33', '43'),
        (b_max, g_min, r_min, '34', '44'),
        (b_max, g_min, r_max, '35', '45'),
        (b_max, g_max, r_min, '36', '46'),
        (b_max, g_max, r_max, '37', '47'),
    ]

    def sgr_color(n):
        return f'\033[{n}m'

    def ansi_code(
        color_main,
        color_sub,
        distance_main,
        distance_sub,
        color_current_main,
        color_current_sub,
    ):
        ret = ''

        # 背景色をメインカラーで描画
        if color_main != color_current_main:
            # 異なる場合はカラーチェンジする
            ret += sgr_color(main_colors[color_main][4])

        dis = abs(distance_main - distance_sub)

        if dis >= DIFF_D3:
            # 2色の差がとても大きいので1色に丸める
            # 背景色のみ表示のため空白文字
            ret += SP
        else:
            # 前景色をサブカラーで描画
            if color_sub != color_current_sub:
                # 異なる場合はカラーチェンジする
                ret += sgr_color(main_colors[color_main][3])

            # 2色の差がそこそこなので2色で描画
            if dis < DIFF_D1:
                # 2色の差が小さい
                ret += D1
            elif dis < DIFF_D2:
                # 2色の差がほどほど:
                ret += D2
            else:
                # 2色の差が大きい
                ret += D3

        return ret

    # 目標のドットサイズに変換する
    h, w, _ = img.shape
    img = cv2.resize(img, (int(w * zoom), int(h * zoom)))
    img = cv2.resize(img, (columns, int(columns*y_adjust)))
    print(f'original size: {h=}, {w=}')

    # 変換後の各ドットに対して以下を決定する
    # https://sethsara.medium.com/change-pixel-colors-of-an-image-to-nearest-solid-color-with-python-and-opencv-33f7d6e6e20d
    h, w, _ = img.shape
    print(f'resized: {h=}, {w=}')
    chars = []
    current_main = -1
    current_sub = -1
    for py in range(0, h):
        for px in range(0, w):
            # 最も近い色と、次に近い色を抽出
            input_color = (img[py][px][0], img[py][px][1], img[py][px][2])
            tree = sp.KDTree([(b, g, r) for b, g, r, _, _ in main_colors])
            distance, result = tree.query(input_color, k=2)
            nearest_color = main_colors[result[0]]

            chars.append(ansi_code(
                color_main=result[0],
                color_sub=result[1],
                distance_main=distance[0],
                distance_sub=distance[1],
                color_current_main=current_main,
                color_current_sub=current_sub,
            ))
            current_main = result[0]
            current_sub = result[1]

            img[py][px][0] = nearest_color[0]
            img[py][px][1] = nearest_color[1]
            img[py][px][2] = nearest_color[2]

    ans_file = join(dist_dir, 'out.ans')
    with open(ans_file, 'wb') as f:
        f.write(''.join(chars).encode('latin-1'))

    cv2.imwrite(join(dist_dir, "out.png"), img)

    print(f'done: {ans_file}')


main()

コードが書けたので早速試してみます
今回は、replicate.com というサイトでStable diffusionを用いてサイバーパンクな画像を生成し、変換をかけてみました

https://replicate.com/stability-ai/stable-diffusion

cyberpunk girl portrait というプロンプトで以下のような画像を生成しました


cyberpunk girl portrait

この画像を作ったスクリプトに読み込ませます

$ poetry run python main.py sample.png
input: sample.png
colors:
        b_min=0, b_mid=88.0, b_max=255
        g_min=0, g_mid=55.0, g_max=255
        r_min=0, r_mid=224.0, r_max=255
original size: h=512, w=512
resized: h=40, w=80
done: /path-to-script/dist/out.ans

odコマンドで開いてみると、なんとなく出力できていそうです

$ od -tc dist/out.ans | head
0000000 033   [   4   1   m
0000020                                                         033   [
0000040   4   4   m                         033   [   3   4   m 260 033
0000060   [   4   6   m     033   [   4   4   m 033   [   3   4   m 261
0000100         033   [   4   0   m             033   [   4   4   m
0000120     033   [   4   0   m                     261 033   [   4   6
0000140   m 261 033   [   4   4   m 033   [   3   4   m 262     033   [
0000160   4   6   m 033   [   3   6   m 262 260 033   [   4   4   m
0000200 033   [   3   4   m 260             260 033   [   4   0   m 033
0000220   [   3   0   m 261     033   [   4   4   m     262     033   [

このファイルをansiloveコマンドに読み込み、png画像化してみます

$ ansilove dist/out.ans
Input File: dist/out.ans
Output File: dist/out.ans.png
Font: 80x25
Bits: 8
Columns: 80

File dist/out.ans does not have a SAUCE record.

Processed in 0.004614 seconds.

その画像がこちらです
髪や口元、鼻などで網掛けが使われていて、それっぽい感じになっているような気がします


cyberpunk girl portraitのANSI art

他の画像でも試してみましょう
同僚のnaga3がツイートしていた、歩道にいたゴリラくんをAIでリファインしたものをANSI artにしてみます

https://twitter.com/naga3/status/1596453576649629697?s=20&t=TkBFAdcU0x3HMjFi3PJL8g

先程の画像と違って実写に近いため、背景が白飛びしたりしていてちょっと分かりづらいですが、
8色の中から色を選んでいると考えればこんなものかもしれません


歩道にいたゴリラくんをAIでリファインしたもののANSI art

以下、改善できそうな点です

  • 出力サイズの圧縮
    • 同じ色が連続する箇所について現在はピクセル分スペース文字を出力しているが、カーソル移動のエスケープシーケンス \033[nC をうまく使えばバイト数を減らせそう
  • 罫線文字の利用
    • 現在は 1ピクセルごとに色を選択 というアプローチで生成しているが、これを 一定範囲ごとに区切って利用する文字を選択 のようなアプローチを取るようにするとよりそれっぽくなりそう
  • 色の選択
    • 単純に8色でなく、画像内の頻出色のグラデーション+特徴的ないくつかの色で構成するとそれっぽくなりそう

おわりに

私はWebアプリケーションのSREなので、普段はDjangoのテストコードを書いたりファイル操作などの書き捨ての作業をPythonで書くことが多いですが、
今回のようなユースケースだと、Pythonを使うメリットがより感じられてよいなと思いました

明日はCSのhiroseさんが仕事っぽい話を書くようです。お楽しみに

https://qiita.com/advent-calendar/2022/lapras

Discussion

ログインするとコメントできます