ANSI artをつくろう
この記事は、LAPRAS Advent Calendar 2022の1日目の記事です
LAPRAS 株式会社でSREをしているyktakaha4と申します 🐧
本当は仕事関係のアウトプットをしようと思っていたのですが、直近で思ったより色々とやっていたので、今回は趣味的な話にしようと思います
普段の仕事っぽい話もよければご覧ください
なお、本記事には古い世代のPCについての記述が多く含まれますが、
私はWindows Meからインターネットに触れた世代のため、誤りが多分に含まれる可能性があります
あしからず🦵
ANSI artってなんだ
みなさんは ANSI art というものを知っていますでしょうか
現在普及しているWindowsやMacbookはGUIによる入出力や画像、動画等を容易に表示できますが、
それより前の世代のコンピューター(PC/AT互換機)は性能上の理由からCUIが主流だったため、着色した文字や記号を用いて画面を構成する技術や、そうした技法を用いた表現が活発におこなわれていたそうです
文字を使って絵を表現する方法としては、日本ではアスキーアートがより有名かと思いますが、利用されていた環境の制約から以下の点で差異があり、それがそのまま特徴になっているように思います
- 画面サイズ
- フォントや1文字あたりの縦横比
- 文字色や背景色の設定可否
- 表現可能な色数、色種
当時のBBSの雰囲気は、以下のサイトにアクセスすると知ることができます
こちらは、昔実際に米国はインディアナ州のダイアルアップ回線上で運用されていたものをenigma-bbsというNode.js製のBBSソフトウェアで再現したものだそうです
アクセスすると、最高にクールなトップページが表示されます
bbs.undercurrents.ioのトップページ
前述した通りこれはBBSソフトウェアとして稼働しているため、telnetでも接続ができるようになっています
telnet bbs.undercurrents.io 8088
ただし、私が普段使っているターミナルだとフォント等の差異からコレジャナイ的なビジュアルで表示されます
telnetでアクセスしたbbs.undercurrents.ioのトップページ
また、現代においてもPabloDrawなどの専用のソフトウェアを使えばANSI artを描くことができ、今も活発に投稿がなされているようです
ものすごい縦長(これも当時から続く手法的なものなのでしょうか?)のものがあったり、細密画のようなものがあったり、現代にしかあり得ないような人物が描画されていたりなど見ていて飽きません
上記は2022年現在に作られたものだそうですが、本サイトでは90年代に作られたものも閲覧できます
当時の映画や音楽に言及したものがあったりしておもしろいです
ANSI artを理解する
ANSI artがどんなものかわかったところで、早速作ってみましょう
が、私は絵が描けないので 任意の画像からANSI artとして読み込み可能なファイルを出力する
というところをゴールにしたいと思います
具体的には、ANSI artを読み込んでpngファイルを出力する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エスケープシーケンス(後述)を用いて描画をおこなうのがデファクトスタンダードであるようでした
理由としては、特にCUI環境における表示端末のデファクトスタンダードとして参照されているVT100がこの仕様に(概ね)準拠しており、互換性の観点から広く使われるようになったものと思われます
また、この端末の標準の表示幅が80桁であることから、ANSI artもそれを踏襲した幅で作るとよりそれっぽく作ることができそうです
ANSIエスケープシーケンスについて
ANSIエスケープシーケンス(ANSI escape code)については、CLIツールを作ったことがある方なら馴染みがあるかもしれません
例えばCLIのタスクマネージャーであるhtopコマンドを使うと、画面にいい感じに色が付きスクロール無しでリアルタイムに表示が更新されますが、これはANSIエスケープシーケンスを使って実現されています
https://htop.dev/ より引用
エスケープシーケンスの仕様は、長期に渡って拡張されながら利用されてきたことに伴い複雑化しているようですが、ANSI artを描くのにあたって必要となる知識はそこまで多くありません
文字色・背景色の変更や文字装飾は SGR (Select Graphic Rendition) parameters
というシーケンスパラメータを用いることで実現できます
例として、シアンの文字色と黄色の背景色を用いて描画をおこないたい場合は、以下のようにします
printfコマンドはANSIエスケープシーケンスを概ね解釈可能なため、手元で簡単に試すことができます
printf '\033[36mCyan with \033[43mYellow\n'
着色の様子
その他の指定方法は以下がわかりやすいです
なお、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 より引用
なお、このコードはASCIIコードの範囲外のため、Unicode等の標準的な文字コード環境では文字化けのように表示されてしまいますが、専用のフォントを用意すればCUI環境でも描画ができます
ただ、今回はpngファイル変換はansiloveコマンドにておこなうため本フォントは特に用いず、ANSファイルを作成する際にグラデーションは上記文字コードを出力するように実装すればOKとなります
ASCII artを作ってみる
具体的な仕様がわかったので、実装してみます
仕様は以下のようになりました
- 任意の画像を読み込む
- 横幅80ピクセルにあわせて縦横比を維持して縮小
- 前述したVT100にあわせた幅のため
- 色々試した結果、一度半分くらいのサイズに落としてから目標サイズに縮小した方が出力結果がよかったのでそのようにした
- 縦幅を50%にする
- 最終的に文字(≒長方形の画素)として描画されるため
- ピクセルごとに色を選択し、文字出力する
- 黒、赤、緑、黄、青、マゼンタ、シアン、白のいずれかを利用する
- そのピクセルが1番近い色と2番目に近い色を選択(再近傍探索)し、その差がわずかの場合には網掛け文字を使って表示する
- 出力結果を
.ANS
ファイルとして保存する
実装には、弊社の主要言語でもあるPythonを用います
画像処理には opencv-python、色の選択にはSciPyを利用しました
SciPyには配列から最近傍探索をおこなうための scipy.spatial.KDTree.queryというメソッドが用意されているため、今回はこちらを使います
k
を指定すると返却される最近傍の数を増やせます
スクリプトは以下のようになりました
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を用いてサイバーパンクな画像を生成し、変換をかけてみました
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にしてみます
先程の画像と違って実写に近いため、背景が白飛びしたりしていてちょっと分かりづらいですが、
8色の中から色を選んでいると考えればこんなものかもしれません
歩道にいたゴリラくんをAIでリファインしたもののANSI art
以下、改善できそうな点です
- 出力サイズの圧縮
- 同じ色が連続する箇所について現在はピクセル分スペース文字を出力しているが、カーソル移動のエスケープシーケンス
\033[nC
をうまく使えばバイト数を減らせそう
- 同じ色が連続する箇所について現在はピクセル分スペース文字を出力しているが、カーソル移動のエスケープシーケンス
- 罫線文字の利用
- 現在は
1ピクセルごとに色を選択
というアプローチで生成しているが、これを一定範囲ごとに区切って利用する文字を選択
のようなアプローチを取るようにするとよりそれっぽくなりそう
- 現在は
- 色の選択
- 単純に8色でなく、画像内の頻出色のグラデーション+特徴的ないくつかの色で構成するとそれっぽくなりそう
おわりに
私はWebアプリケーションのSREなので、普段はDjangoのテストコードを書いたりファイル操作などの書き捨ての作業をPythonで書くことが多いですが、
今回のようなユースケースだと、Pythonを使うメリットがより感じられてよいなと思いました
明日はCSのhiroseさんが仕事っぽい話を書くようです。お楽しみに
Discussion