もしかしてだけど、あれって日本最古のアスキーアートだったんじゃないの?

はじめに

img2charによる変換例 左の画像はMidjourneyで生成したものです

1986年、パソコン通信は黎明期でASCIInetがまだ実験運用の時代でした。その頃の私はグラフィック(おそらくモノクロの有名歌手の画像)を自作プログラムでASCII文字の絵にして、あるパソコン通信のBBS(電子掲示板)に投稿しました。

あれから約40年。当時のアルゴリズムをGoで再実装し、CLIツール img2char として公開しました。

この記事では、当時の背景と img2char のアルゴリズムを紹介します。

1986年のパソコンとBBS

1986年はPC-VAN(1986年4月実験開始)やNIFTY-Serve(1987年4月開始)が立ち上がり始めた、日本のパソコン通信黎明期です。アスキーネット(ASCII-NET)は前年の1985年5月に実験サービスを開始しており、草の根BBSも各地で産声を上げていました。通信速度は300bpsから1200bps。BBSに書き込めるのはテキストだけの時代でした。グラフィックスを書き込むなんてとんでもない時代でした1

当時のパソコンのグラフィック事情も触れておきます。1986年時点では、画像をファイルとして保存・交換する汎用フォーマットはまだ普及していませんでした。グラフィックを描くといえば、BASICのLINE文やPAINT文を使ってプログラムとして記述するのが主流です。私自身、PAINT文を使わずに画像の上端から下端まで(縦200ドットしかない画面です)をLINE文だけで1ラインずつ塗りつぶし、モノクロ画像を描くことに当時凝っていました。グラフィックは「描く」ものであって「ファイルで送る」ものではなかったのです。

しかもBASICは機種ごとに方言が異なり、あるマシンで書いたプログラムが別のマシンでそのまま動くことはまずありません。グラフィック描画プログラムをBBSで流通させるのも現実的ではありませんでした。

そこで思いついたのが、「ASCII文字絵にすれば遅いパソコン通信でも送れるんじゃね?」です。

Sharp X1 ── 骨の髄までハードを極めたマシン

Sharp X1は1982年に発売された8ビットパソコンです。テレビ事業で培ったシャープの技術が随所に活かされた、ハードウェア設計の塊のようなマシンでした。

当時のZ80 CPUはメモリ空間が64KBしかなく、NECのPC-8801などではこの空間にメインRAMとグラフィックVRAM(GVRAM)を重ねて配置し、バンク切り替えでアクセスしていました。一方X1は、Z80のI/O命令が実行時にアドレスバスの上位8ビット(A8〜A15)にもレジスタの値を出力するという、公式マニュアルには記載されているものの目立たない仕様を活用しました。I/O空間を実質64KBに広げ、そこにGVRAMを配置したのです。メモリ空間64KBは丸ごとRAMとして使え、GVRAMへはI/O命令で直接アクセスできます。

この設計のおかげで、BASICからでもグラフィックVRAM上の画像データを読み出すことができました。グラフィックVRAM上の画像を8x8ドット単位で読み取り、キャラクタROMの各文字パターンと比較する。一致度が最も高い文字を選ぶ。このアルゴリズムをBASICで実装しました2

アルゴリズムの現代版 ── img2char

img2char は、あの当時のアルゴリズムをGoで忠実に再現したCLIツールです。処理は3段階で構成されています。

  1. フォントパターンの準備 ── 95個のASCII印字可能文字の8x8ビットマップを用意する
  2. 画像のブロック分割 ── 入力画像を8x8ピクセルのブロックに分割する
  3. パターンマッチング ── 各ブロックとフォントパターンのハミング距離を計算し、最も近い文字を選ぶ

フォントテーブルの構築

95個の印字可能ASCII文字(スペース 0x20 からチルダ 0x7E まで)について、それぞれ8x8ドットのビットマップパターンを持っています。

// font8x8Basic contains 8x8 bitmap patterns for 95 printable ASCII characters (0x20–0x7E).
var font8x8Basic = [95][8]byte{
    // 0x20 ' '
    {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
    // 0x21 '!'
    {0x18, 0x3C, 0x3C, 0x18, 0x18, 0x00, 0x18, 0x00},
    // 0x22 '"'
    {0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
    // ... 95文字分のパターンが続く
    // 0x41 'A'
    {0x0C, 0x1E, 0x33, 0x33, 0x3F, 0x33, 0x33, 0x00},
    // 0x7E '~'
    {0x6E, 0x3B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
}

各バイトが8x8ビットマップの1行に対応し、LSB(最下位ビット)が左端のピクセルです。たとえば A のパターンを展開すると、以下のようになります。

(2進数はLSB=左端ピクセルから表記)
0x0C = 00110000 → ..##....
0x1E = 01111000 → .####...
0x33 = 11001100 → ##..##..
0x33 = 11001100 → ##..##..
0x3F = 11111100 → ######..
0x33 = 11001100 → ##..##..
0x33 = 11001100 → ##..##..
0x00 = 00000000 → ........

8バイトで1文字の形が表現されています。高速な比較のため uint64 にパックします。

func packFont(pattern [8]byte) uint64 {
    var v uint64
    for i := 0; i < 8; i++ {
        v |= uint64(pattern[i]) << (i * 8)
    }
    return v
}

X1に搭載されていたHu-BASICの整数型は16ビット(2バイト)なので、当時は8バイトのパターンを2バイトずつ4回のXORループで比較していました。現代のGoでは8バイトを1つの64ビット整数に詰め込むことで、後述のXOR演算が一発で実行できます。

8x8ブロックとフォントパターンの照合

ここが核心部分です。画像から切り出した8x8ブロックを、95文字すべてのフォントパターンと比較し、最も「形が近い」文字を見つけます。

func matchChar(block [8]byte, fontTable [95]uint64) byte {
    blockVal := packBlock(block)
    bestDist := 65 // max possible is 64
    bestIdx := 0
    for i := 0; i < 95; i++ {
        dist := bits.OnesCount64(blockVal ^ fontTable[i])
        if dist < bestDist {
            bestDist = dist
            bestIdx = i
        }
    }
    return byte(0x20 + bestIdx)
}

blockVal ^ fontTable[i] のXOR演算で、ブロックとフォントパターンのビットが異なる位置を検出します。bits.OnesCount64 でその異なるビット数(ハミング距離)を数えます。64ビット中の不一致ビット数が最小の文字を選ぶ、シンプルかつ強力な方式です。

使い方

入力画像は640x200または320x200ピクセルの二値化済みPNGである必要があります。前処理用のシェルスクリプトが付属しています。

# 前処理(ImageMagickでリサイズ+二値化)
./convert.sh input.jpg 640 200

# ASCII文字に変換
img2char input_640x200.png

640x200の画像は80x25文字、320x200なら40x25文字に変換されます。当時は320x200モードの40x25文字でやっていました。

今回、同じロジックで似たような画像を変換してみましたが、なかなかいけています(冒頭の画像を参照)。

もしかして......日本最古のASCII Artなんじゃないの?

当時の変換結果をパソコン通信のBBSに投稿したところ、ちょっとした話題になりました。テキストしか表示できない端末に、文字だけで画像が浮かび上がるのですから、当時としては驚きだったようです。40x25文字分の解像度しかありませんが、元の画像の輪郭や特徴が文字の組み合わせで再現される様子は、見た人を楽しませるものでした。

ここで少し大胆なことを言わせてください。

文字で画像を表現する試み自体は古く、1898年のタイプライターアートにまで遡ります。コンピューターによる自動変換も、1967年にベル研究所のケネス・ノウルトンがIBM 7094で実現しています。ただしそれは巨大なメインフレームでの話です。

日本に目を向けると、記録に残る最古の「アスキーアート」は1986年6月20日にアスキーネット上で若林泰志が投稿した顔文字 (^_^) とされています。2ちゃんねるでAAが花開くのは1999年以降。1986年当時、手作業でAA(アスキーアート)を描く文化はまだ広まっていませんでした。

そんな時代に、8ビットパソコンで画像をASCII文字に変換してBBSに投稿していたわけです。

もしかして、もしかしてだけど、これって日本最古のアスキーアートなんじゃないの?(どぶろっく風)

......まあ、証拠は当時のBBSのログとともに電子の海に消えてしまいましたが。

まとめ

img2char は、1986年のSharp X1で生まれたアイデアを現代のGoで再実装したツールです。

  • 当時のパソコン通信の制約(テキストのみ)が生んだ発想
  • PCGという強力なハードウェアをあえて使わず、ASCII文字だけで勝負した選択
  • 8x8ドットブロックとフォントパターンのハミング距離による照合という、シンプルだが効果的なアルゴリズム

40年前のBASICプログラムが64ビット整数のXOR演算に姿を変えても、やっていることは同じです。画像の形を文字の形で近似する。それだけのことが、なぜか人を惹きつけます。

興味を持っていただけたら、ぜひ試してみてください。

参考

  1. そもそも画像をファイルに書き出すフォーマットさえ標準的なものはなかった。

  2. X1にはPCG(Programmable Character Generator)が搭載されており、文字のフォントパターンを自由に書き換えられました。任意のドットパターンを「文字」として表示でき、グラフィックスに近い表現が可能でした。しかしBBSに投稿するならPCGは使えません。相手の画面にPCGのデータは存在せず、表示できるのは標準のASCII文字だけだからです。