【Linux】 Linus TorvaldsがRISC-Vパッチに激怒!その理由とLinuxカーネル開発現場のリアル

はじめに

ターミナルに画像を表示できるコマンドがないか調べてて、PuTTYでは無理なようで、諦めようとした時に、

「そういえば、昔、画像をAAにするツールがあったような…」

と、ネットで調べてjp2aを発見。

jp2aJPEGをASCIIアートに変換するC言語製のプログラムで、Linuxユーザーの間では古くから知られています。

このツールの実装方法についてAIと議論して内容が面白かったので記事にしてみます。

jp2aの歴史

  • 登場時期
    jp2a は2000年代前半にはすでに存在しており、今もGitHub上でソースコードが公開されています。
    jp2a GitHubリポジトリ

  • 実装言語
    C言語。依存は最低限で、JPEGデコードには libjpeg を利用。
    当時は「PythonやRustで遊ぶ」よりも、Cで直接ライブラリを叩いて処理速度を出すのが当然の選択でした。

  • 利用シーン
    ・ネタや遊びとして、画像をターミナルにASCIIで表示する
    ・BBSやIRCなどで「スクショを貼る代わりにASCIIに変換して共有」
    ・Xが使えないサーバーでの“遊び心”あるビューア

仕組みの概要

jp2a がやっていることはシンプルです。

  1. 画像の読み込み libjpeg を使ってJPEG画像を展開。

  2. リサイズ(ブロック化) 出力する幅(文字数)に応じて画像を縮小。 文字の縦横比(セルは正方形ではない)を考慮してアスペクト補正。

  3. 輝度の計算 各ブロックの明るさ(輝度平均)を計算。

  4. 文字マッピング 明るさに応じて、濃淡の異なる文字に対応付け。 例:' .:-=+*#%@'(左ほど薄い/右ほど濃い)

  5. 色付け(オプション) 平均色を求めて、ANSIカラーコードを挿入。 → jp2a --colors でフルカラー出力が可能。

  6. 出力 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との対話


user icon
どういう仕組みで実装してるのだろう

ai icon

いい質問!仕組みは大きく3系統あります。ざっくり図解するとこう👇

  1. 文字で“それっぽく”描く方式(AA変換)
  2. 端末の“画像描画プロトコル”を使う方式(本物の画像)
  3. 端末ウィンドウの外側に直接描くハック(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 Protocolkitty +kitten icat
  • WezTerm は上記互換を実装(wezterm imgcat

流れ:

  1. 送信側が画像を base64 にして、 ESC ] 1337 ; File=... : <base64> BEL のようなOSCで送信
  2. 端末がデータを受け取り、セル座標/ピクセル指定に従ってウィンドウに貼る
  3. スクロール・再描画のたびに端末が内部バッファから再レンダリング

超簡易例(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/シェル)も用意するよ。 どの方式を記事に載せる?それに合わせて“動く最小例”を渡すね。


user icon
テキストの1文字のブロックサイズで、画像分割して
画像1ブロック辺りをピクセル配置を解析?
上下左右のピクセル密度を解析して、適切な文字にを置き換える
という処理をやってる感じかな

ai icon

ほぼその理解でOK!👍 AA変換はだいたい次の流れです(実装ごとに差はあるけど本質は同じ)。

基本アルゴリズム

  1. 画像を縮小
  • 1文字=1セル(幅×高さ)相当になるようにリサイズ。 ※等幅フォントの縦横比補正が超重要(セルは正方形じゃない)。
  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セル=ミニ画像」→“濃さ/向き”から最適な字を置く、という理解で合ってます。いい目線!


user icon
理屈は分かるけど、コードは書けない、いや、書けるけど物凄く時間がかかりそう
計算アルゴリズムも、やり方によって処理速度に時間がかなり開きが出そうだし
最適化とかやりだすと一週間単位で時間がかかるかも

ai icon

わかる、その沼に入ると一週間コース😇 「理屈は掴んだ、でも時間はかけたくない」向けに、“とりあえず動く・速い・短い”最小実装を出します。最適化は後回しで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)版」や「ブロック文字版」も要るなら、次の一手として追加コードを出すよ。


user icon
多分、jp2aを実装した人は、C言語でやってますよね?
かなり昔からあるアプリだし

ai icon

そう、その推測バッチリです 👍

jp2a は C 言語製で、2000年代初頭にはすでに存在していたはず。 リポジトリも GitHub に残っていて(→ jp2a GitHub)、ソースは C。


なんで C 言語?

  • 当時(20年前)は Python や Rust じゃなく C が標準
  • 「画像読み込み → ピクセル処理 → ANSI出力」という単純処理でも、リアルタイムに大きなJPEGを処理するには速度が必要
  • JPEGライブラリ(libjpeg)を直接叩くのも C が自然だった。

実装の流れ(C でやる場合)

  1. libjpeg を使って JPEG デコード。
  2. 画像を縮小(ブロック化)してグレースケールを計算。
  3. 各ブロックの輝度を ramp(濃淡文字列)にマッピング。
  4. ANSIカラー指定(24bit対応)を文字出力に挿入。
  5. printf で行ごとに描画。

当時の工夫ポイント

  • 速度最優先 → 単純な平均値 or 中央値で済ませる。
  • 依存最小 → libjpeg+標準Cで動くようにする。
  • 移植性 → Linux の端末(xterm)を前提、ANSIコードに対応していれば他でもOK。

📌 なので「jp2aの人はCでゴリゴリ最適化」→ だから今でも爆速。 Pythonで同じことをやると(Pillow+NumPyで楽だけど)速度はCには敵わないですね。


👉 もし「C言語でASCIIアート変換の最小サンプルコード」を見てみたいですか?