
はじめに
ターミナルに画像を表示できるコマンドがないか調べてて、PuTTYでは無理なようで、諦めようとした時に、
「そういえば、昔、画像をAAにするツールがあったような…」
と、ネットで調べてjp2aを発見。
jp2a は JPEGをASCIIアートに変換するC言語製のプログラムで、Linuxユーザーの間では古くから知られています。
このツールの実装方法についてAIと議論して内容が面白かったので記事にしてみます。


jp2aの歴史
-
登場時期
jp2a は2000年代前半にはすでに存在しており、今もGitHub上でソースコードが公開されています。
→ jp2a GitHubリポジトリ -
実装言語
C言語。依存は最低限で、JPEGデコードには libjpeg を利用。
当時は「PythonやRustで遊ぶ」よりも、Cで直接ライブラリを叩いて処理速度を出すのが当然の選択でした。 -
利用シーン
・ネタや遊びとして、画像をターミナルにASCIIで表示する
・BBSやIRCなどで「スクショを貼る代わりにASCIIに変換して共有」
・Xが使えないサーバーでの“遊び心”あるビューア
仕組みの概要
jp2a
がやっていることはシンプルです。
-
画像の読み込み
libjpeg
を使ってJPEG画像を展開。 -
リサイズ(ブロック化) 出力する幅(文字数)に応じて画像を縮小。 文字の縦横比(セルは正方形ではない)を考慮してアスペクト補正。
-
輝度の計算 各ブロックの明るさ(輝度平均)を計算。
-
文字マッピング 明るさに応じて、濃淡の異なる文字に対応付け。 例:
' .:-=+*#%@'
(左ほど薄い/右ほど濃い) -
色付け(オプション) 平均色を求めて、ANSIカラーコードを挿入。 →
jp2a --colors
でフルカラー出力が可能。 -
出力
printf
でターミナルに行単位で描画。
疑似コードで見るアルゴリズム
Python風に書くと以下のような流れになります。
ramp = " .:-=+*#%@"
def image_to_ascii(img, width=100, aspect=0.5):
# 1. リサイズ
h = int(img.height * (width / img.width) * aspect)
img = img.resize((width, h))
# 2. 輝度を計算
arr = np.asarray(img.convert("L"))
# 3. 明るさを文字にマッピング
out = []
for row in arr:
line = "".join(ramp[pixel * (len(ramp)-1) // 255] for pixel in row)
out.append(line)
return "\n".join(out)
AIとの対話
いい質問!仕組みは大きく3系統あります。ざっくり図解するとこう👇
- 文字で“それっぽく”描く方式(AA変換)
- 端末の“画像描画プロトコル”を使う方式(本物の画像)
- 端末ウィンドウの外側に直接描くハック(w3mimgdisplay系)
1) 文字で描く(jp2a / chafa のAA)
- 画像を読み込む → リサイズ → 輝度や色を解析
- ピクセルの明るさを “字形の黒さ” に近い文字へマッピング
例:
' .,:;i1tfLCG08@'
(左ほど薄い/右ほど濃い) - 文字セルの縦横比を考慮して補正(等幅フォント前提)
--colors
等があれば、各文字に ANSIカラーを付ける(256色/TrueColor)- 繊細さが必要なら誤差拡散ディザ(Floyd–Steinberg 等)で粗さを均す
超ミニ実装イメージ(擬似コード):
ramp = " .,:;i1tfLCG08@" # 濃淡ランプ
for y in range(0, H, cell_h):
row = ""
for x in range(0, W, cell_w):
patch = img[y:y+cell_h, x:x+cell_w]
lum = patch.mean() # 輝度の平均
ch = ramp[int(lum/255* (len(ramp)-1))]
row += ch
print(row)
※ フォントが等幅でないと崩れるのはこの方式の宿命。
2) 端末の画像プロトコルを使う(WezTerm/Kitty/iTerm2)
端末が**「特殊なエスケープシーケンス」**を解釈して、本物のビットマップを描画します。代表は:
- iTerm2 inline images(OSC 1337 /
imgcat
) - Kitty Graphics Protocol(
kitty +kitten icat
) - WezTerm は上記互換を実装(
wezterm imgcat
)
流れ:
- 送信側が画像を base64 にして、
ESC ] 1337 ; File=... : <base64> BEL
のようなOSCで送信 - 端末がデータを受け取り、セル座標/ピクセル指定に従ってウィンドウに貼る
- スクロール・再描画のたびに端末が内部バッファから再レンダリング
超簡易例(iTerm2系の概念図):
\x1b]1337;File=name=img.jpg;width=auto;height=auto;preserveAspectRatio=1:<BASE64> \x07
利点:綺麗・速い・崩れない。 欠点:端末が対応してないと出ない(PuTTYがダメな理由がこれ)。
3) 端末“の外”に描く(w3mimgdisplay 等)
w3mimgdisplay
は X11/Wayland へ直接描画し、ターミナルのウィンドウ上に合成する- 端末は座標だけ渡し、描画は外部プロセスが担当 長所:古い端末でも“見える”場合あり 短所:環境依存が強く、SSH越し等でズレやすい
どれを使う?
- 実用・再現性:WezTerm/Kittyの画像プロトコル(崩れない)
- ネタ・軽量:
jp2a
/chafa
(AAで楽しむ) - どこでも妥協:
chafa
(Unicode+色でけっこう見える)
余談:WezTermのゼロ割り
imgcat
のサイズ計算で セル情報が0 を返すとゼロ割り→panic、が稀にあります。
回避は「素のシェルで実行」「サイズ指定(--pixel-width
)」「ローカル実行にパイプ」。
ssh user@host 'cat img.jpg' | wezterm imgcat - # ローカル端末で解釈
必要なら、Kitty / iTerm2 / WezTerm 向けの最小サンプル(生のエスケープを吐くPython/シェル)も用意するよ。 どの方式を記事に載せる?それに合わせて“動く最小例”を渡すね。
画像1ブロック辺りをピクセル配置を解析?
上下左右のピクセル密度を解析して、適切な文字にを置き換える
という処理をやってる感じかな
ほぼその理解でOK!👍 AA変換はだいたい次の流れです(実装ごとに差はあるけど本質は同じ)。
基本アルゴリズム
- 画像を縮小
- 1文字=1セル(幅×高さ)相当になるようにリサイズ。 ※等幅フォントの縦横比補正が超重要(セルは正方形じゃない)。
- ブロックごとに統計を取る(あなたの言う「1文字のブロック」)
- そのブロックの 輝度平均/中央値 を計算 → “濃さ”に変換
- 文字ごとに「見かけの黒さ(インク量)」を用意した 濃淡ランプ
例:
' .:-=+*#%@'
(左ほど薄い→右ほど濃い) - 濃さに最も近い文字を選ぶ
- 色を付ける(任意)
--colors
系ではブロックの平均色を前景色/背景色に割り当て(ANSI 256色やTrueColor)
きれいに見せる工夫(よくある拡張)
- エッジ/方向性も見る:Sobel等で勾配を出し、
/ \ | _
など向きのある文字を選ぶ - ディザリング(Floyd–Steinberg 等)で階調不足を補い、ムラ感を減らす
- 文字セット最適化:フォント実測から“実効濃度”を作り、ランプをチューニング
- ブロック文字・点字:
█
,▀
,▄
や Braille(⠿) を使うと解像感UP - ガンマ補正:人間の知覚に合わせて明るさを非線形でマッピング
超ミニ擬似コード
ramp = " .:-=+*#%@"
cell_w, cell_h = 2, 4 # 文字の縦横比に合わせる
for y in range(0, H, cell_h):
line = []
for x in range(0, W, cell_w):
patch = img[y:y+cell_h, x:x+cell_w]
lum = patch.mean() # 輝度平均
idx = int(lum/255 * (len(ramp)-1))
line.append(ramp[idx])
print("".join(line))
実用TIP
- 文字幅に合わせて**
--width/--height
**を調整(jp2a --width=100
など) - 方向性まで拾いたいなら
chafa
が強い(Unicode多用でリッチ)
要するに:「1セル=ミニ画像」→“濃さ/向き”から最適な字を置く、という理解で合ってます。いい目線!
計算アルゴリズムも、やり方によって処理速度に時間がかなり開きが出そうだし
最適化とかやりだすと一週間単位で時間がかかるかも
わかる、その沼に入ると一週間コース😇 「理屈は掴んだ、でも時間はかけたくない」向けに、“とりあえず動く・速い・短い”最小実装を出します。最適化は後回しでOK。
いますぐ使える最小実装(Pillow+NumPy)
- 1文字=1セルでPillowのリサイズに平均化を丸投げ → 速い
- NumPyで一括マッピング → Pythonループ最小化
# img2ascii.py
import argparse
import numpy as np
from PIL import Image
RAMP = " .:-=+*#%@"
def img_to_ascii(path, width=100, ramp=RAMP, aspect=0.5):
img = Image.open(path).convert("L")
w, h = img.size
# 文字は縦長に見えるので、行数はアスペクト補正で計算
new_w = width
new_h = max(1, int(h * (new_w / w) * aspect))
# リサイズ時にPillowが平均化してくれるので、ここが「ブロック平均」代わり
img = img.resize((new_w, new_h), Image.BILINEAR)
arr = np.asarray(img, dtype=np.uint8)
# ramp区切りを事前計算して一括割り当て(速い)
ramp_levels = np.linspace(0, 256, num=len(ramp), endpoint=True)
idx = np.minimum(len(ramp)-1, np.digitize(arr, ramp_levels) - 1)
chars = np.array(list(ramp), dtype="<U1")[idx]
# 行ごとに結合
lines = ["".join(row) for row in chars]
return "\n".join(lines)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("image")
ap.add_argument("-w", "--width", type=int, default=100, help="出力の文字幅")
ap.add_argument("--ramp", default=RAMP, help="濃淡ランプ(薄→濃)")
ap.add_argument("--aspect", type=float, default=0.5, help="文字の縦横比補正(0.45〜0.6)")
args = ap.parse_args()
print(img_to_ascii(args.image, args.width, args.ramp, args.aspect))
使い方
pip install pillow numpy
python img2ascii.py wez-bg.jpg -w 120 > out.txt
-w
で横幅だけ指定 → 高さは自動計算--aspect
はフォントに合わせて微調整(WezTermなら 0.5 前後)
“ほどよい最適化”の指針(必要になったら)
- ディザリングで階調不足のムラを軽減:Pillowの
Image.FLOYDSTEINBERG
で先に減色→ASCII化 - 方向性文字(
/ \ | _
など)を混ぜるなら、Sobelで勾配→マスクして上書き - Unicodeブロック/点字を使うと情報量アップ(
▀▄█
や ⢀〜⣿) - 速度は Pillowリサイズ+NumPyベクトル化で十分速い(数ms〜十数ms/枚@中サイズ)
結論
- まずはこのスクリプトで 「やってみた/出力を記事に貼った」 まで行くのがコスパ最強。
- 深掘りは「見た目をもう少し良くしたくなった時」に、上の最適化から一個ずつ足せばOK。
「色付き(ANSI TrueColor)版」や「ブロック文字版」も要るなら、次の一手として追加コードを出すよ。
かなり昔からあるアプリだし
そう、その推測バッチリです 👍
jp2a
は C 言語製で、2000年代初頭にはすでに存在していたはず。
リポジトリも GitHub に残っていて(→ jp2a GitHub)、ソースは C。
なんで C 言語?
- 当時(20年前)は Python や Rust じゃなく C が標準。
- 「画像読み込み → ピクセル処理 → ANSI出力」という単純処理でも、リアルタイムに大きなJPEGを処理するには速度が必要。
- JPEGライブラリ(libjpeg)を直接叩くのも C が自然だった。
実装の流れ(C でやる場合)
- libjpeg を使って JPEG デコード。
- 画像を縮小(ブロック化)してグレースケールを計算。
- 各ブロックの輝度を ramp(濃淡文字列)にマッピング。
- ANSIカラー指定(24bit対応)を文字出力に挿入。
printf
で行ごとに描画。
当時の工夫ポイント
- 速度最優先 → 単純な平均値 or 中央値で済ませる。
- 依存最小 → libjpeg+標準Cで動くようにする。
- 移植性 → Linux の端末(xterm)を前提、ANSIコードに対応していれば他でもOK。
📌 なので「jp2aの人はCでゴリゴリ最適化」→ だから今でも爆速。 Pythonで同じことをやると(Pillow+NumPyで楽だけど)速度はCには敵わないですね。
👉 もし「C言語でASCIIアート変換の最小サンプルコード」を見てみたいですか?
💬 コメント