C++でターミナルに画像を表示させる

これはC++ Advent Calendar 2013 23日目の記事です。

目的:

sshでリモートに接続して計算を行い、その結果を図として得たい場合、
図をダウンロードするしかないのですが、
いちいちダウンロードして画像ビューアに移ってという過程を経ずに
ターミナル上で画像を表示させたいということが結構あります(私は)。
ターミナルを選びますが、Sixel Graphicsというものを使えばこれは可能です。
これをC++で利用する方法について書きます。

前提:

ターミナルをSixel Graphics対応のものに変えること。
これが一番ハードル高いのですが、
WindowsならRloginが良いと思います。
http://nanno.dip.jp/softlib/man/rlogin/
他の環境だとtanasinnですかね。
http://zuse.jp/tanasinn/usermanual-ja.html
xtermも最近対応してるみたいですが、256色表示させるにはちょっとパッチが必要です。
利用可能かどうかは実際使ってみればわかります。
Netpbmがインストールされていればpngからppmへの変換、ppmからsixel形式への変換ができますので

pngtopnm hoge.png | pnmquant 256 | ppmtosixel

で画像を表示できるはずです。pnmquantは256色以下に減色させるために必要。

大まかな流れ:

1. 画像を読み込む
2. 256色に減色する
3. 画像をSixel形式に変換する
4. 標準出力に吐く
etc) screen/tmux対応

1.は何でもOKです。せっかくC++なのでBoost.GILでも使いましょうか。
2.はBoost.GILではできないようなので、pngquantのライブラリを使わせてもらうことにしました。
3.ここが一番面倒くさいとこですね。実はNetpbmのppmtosixelをパクればいいという話もありますが劣化版を再発明します。
4.吐くだけ。(Boost.GILに合うioを作りたいのが本音なのですが時間足りないので無視します)
etc)実はそのままではscreen/tmux上で使えません。しかし回避策はあります。


1. Boost.GILで画像を読みRGBA形式でのデータへのポインタを得る。

この程度ならGILを使うまでもないのですが、libpngのC言語インターフェイスを使うくらいなら結局GILを使ったほうが簡単です。

#include<iostream>
#include <boost/gil/gil_all.hpp>
#include <boost/gil/extension/io/png_io.hpp>
using namespace boost::gil;

int main() {
    rgba8_image_t src;
    png_read_image("./img.png", src);
    auto raw = interleaved_view_get_raw_data(view(src));
    return 0;
}

2. 256色に減色する

GILで読んだpngデータはrgba8の透過色含む32bitですので、
Sixel形式にするには256色に減色させてやる必要があります。つまりパレットカラーです。

もう256色な時代じゃないので一応説明を加えておくと、256色表示の場合は1pixelの色情報を8bitで表します。
しかし単純に8bitをrgbで表そうとすると、rgbそれぞれが2-3bitしか使えないので、各色2-4階調の色しか表現できないことになります。
そうではなくて256色表示の場合は、使用する色の情報をパレットとして登録しておき、
パレットの1番目が(R,G,B)=(..., ..., ..,)、2番目が(R,G,B)=(..., ..., ...)というように
256色まであらかじめ色情報を作っておきます。
そうすると、1pixelの情報はパレットへのインデックスを保持してればいいので8bitで256色を表現できます。
パレットデータを画像データに含ませる必要があるので、データのヘッダ部分はサイズが増えますが全体としてはサイズが大きく減ります。

256色に減色させる方法は昔から色々あるのですが、一番簡単な方法はR,G,Bをそれぞれ[0,255]の範囲を等分割して、一番近い色を拾っていく手法でしょうか。
あるいはヒストグラムを作って、多く使われる色から順番に選んでいくとか。
どちらも単純な分、それほど再現性は高くないです。
一番よく使用されている手法はmedian cutというアルゴリズムでしょう。
color quantizationとmedian cutでググればすぐ出てくるのでアルゴリズムの詳細は省略します。
時間があればこれも再発明しても良いのですが、1時間程度でサクッとできそうになかったので、何かいいライブラリないかなーと検索してみたらpngquantのライブラリ(libimagequant)がBSDライセンスで公開されていました。
http://pngquant.org/lib/
C言語のライブラリですが、C++のライブラリを探す時間が惜しいのでこれ使います。
一応、C++Compatibleと書かれているbranchがあったのでgit cloneした後cpp branchにしています。
サイトに書かれているように、ダウンロードしてmake -C libすればlibimagequant.aができるので、これを一緒にリンクするだけです。

#include<iostream>
#include <boost/gil/gil_all.hpp>
#include <boost/gil/extension/io/png_io.hpp>
#include"pngquant/lib/libimagequant.h"
using namespace boost::gil;

int main() {
    rgb8_image_t src;
    png_read_image("./img.png", src);
    auto raw = interleaved_view_get_raw_data(view(src));
    // 256色に減色してパレットを取得
    auto attr  = liq_attr_create();
    auto image = liq_image_create_rgba(attr,raw,src.width(),src.height(),0);
    auto result= liq_quantize_image(attr,image);
    auto pal  = liq_get_palette(result);

    // 256色に減色した画像を再構築する
    auto buffer = std::vector<unsigned char>(src.width()*src.height());
    liq_write_remapped_image(result, image, &buffer[0], buffer.size());

    liq_attr_destroy(attr);
    liq_image_destroy(image);
    liq_result_destroy(result);
    return 0;
}

これで例えばi番目の色情報は

auto color = pal->entries[i];
unsigned char r = color.r;

という具合に取得できるようになりました。

3. 画像をSixel形式に変換する

Sixel形式はWikipediaに簡単な説明があります。
http://en.wikipedia.org/wiki/Sixel
さらにこのwikipediaページに貼られている
Chris Chiesa, All About SIXELs, 29 September 1990
に詳しい説明が載っています。これを見ればできそうです。
あと、困ったときは前述のNetpbmのppmtosixelとかのソースを読めばいいんじゃないかと思います。
まあSixel形式自体はそれほど複雑じゃないです。

Sixelという名前ははsix+pixelから来ているようですが、
その名の通り6つのpixelを1セットにしています。
この6つのpixelは縦方向に並びます。
つまり、

0b0000001
0b0000010
0b0000100

という順番に並べられた3つのsixelデータは

x方向->
1 0 0
0 1 0
0 0 1
0 0 0
0 0 0
0 0 0

と表されるわけです。白黒なら1と0で画像を表現できます。
さらにsixelでは0b000001,0b000010,...という数値に63を足し、
それぞれの数値はprintableな範囲に収めています。
sixelデータは6bitで表現されているので、本来、数値としては0-63までです。
この数値に63というオフセットを加える事で、63-126の範囲、
つまりアスキーコード表で?から~までの範囲になり、すべてのデータが文字として視認できるようになります。
例えば、上記の3つのsixelデータは
@AC
という文字列で表現されます。

以上がSixelデータの本質で、あとはいくつかの制御シーケンスとデータ圧縮の話になります。
最低限の項目に絞って解説します。

Sixelモードに入る。

まず最初に指定しなければならない制御コードは
DCS(Device Control Sequence)という文字コードです。
いわゆるエスケープシーケンスというやつで、
7bit表現で"ESP P"
あるいは十進法で144、8進数では220。
文字列として表現するなら、"\0x1bP"です。

さらに、DCSコードに続いてSixelデータの情報とヘッダを出力します。
フォーマットは

DCS p1;p2;p3;q

p1,p2,p3はoptionalです。
・p1はピクセルアスペクト比を示すらしいのですが、互換性のために残されているらしく通常は0としておきます。
・p2は背景色の指定です。どうでもいい感じがしたので説明は省略します。
・p3は水平方向のピクセル間の距離です。

これらの文字コードが標準出力に出されると、Sixelモードに入ります。
この後にカラーパレットの情報を出力しなければならないのですが、
これについては後述します。

Sixelモードを出る。

Sixelモードを抜けるにはST(String Terminator)を標準出力に出します。
STは7bit表現で"ESP \"、8進数で234です。

行頭に戻る。

sixelを出力する位置を行頭に戻すには、'$'コードを出します。
次の行には移りません。
行頭に戻した状態で、sixelデータを出力していくとすでに描かれたデータを上書きしていきます。

次の行に移る。

次のsixel lineの先頭に移ります。
sixelは6pixelを1セットとしますので、6pixelだけ下にラインに移ることになります。

カラーヘッダ

Sixelモードに入った後、カラー情報を出します。HLSでも書けますがRGBでの指定を説明します。

例えば、
#3;2;30;40;50

と書けば、3番目のパレットカラーを(R,G,B)=(30,40,50)とするという意味になります。
注意点として、数値は0-100までのPercentageです。
2番目の数値"2"はRGB表現であることを示しています。

カラーの指定

sixelデータを出している途中で

#5

とか出力すると5番目のパレットカラーに切り替わります。

例:

<ESC>Pq
#0;2;0;0;0#1;2;100;100;0#2;2;0;100;0
#1~~@@vv@@~~@@~~$
#2??}}GG}}??}}??-
#1!14@
<ESC>\

というsixelデータだと

    ..............
    ..**..**..**..
    ..**..**..**..        . = color 1, yellow
    ..******..**..        * = color 2, green
    ..**..**..**..
    ..**..**..**..
    ..............

という画像になります。


4. 標準出力に吐く(コード例)

色々と忙しくまだきちんとしたコード作ってないのですが、とりあえず動くソースコードを出します。

#include<iostream>
#include <boost/gil/gil_all.hpp>
#include <boost/gil/extension/io/png_io.hpp>
#include"pngquant/lib/libimagequant.h"
using namespace boost::gil;

const char DCS='\220';
const char ST ='\234';

template<class Pallete>
int write_header(Pallete* pal) {
     const int maxval = 100;
     const int palmax = 255;
     const int colnum = 255;

     printf("%c",DCS);
     printf( "0;0;8q" );   // 水平方向のグリッドサイズは8
     printf( "\"1;1\n" );  /* set aspect ratio 1:1 */
     for(int i=0;i<colnum;++i) {
          auto color = pal->entries[i];
          const int r = 1.*color.r/palmax*maxval;;
          const int g = 1.*color.g/palmax*maxval;;
          const int b = 1.*color.b/palmax*maxval;;
          printf("#%d;2;%d;%d;%d\n",i,r,g,b);
     }
     return 0;
}

template<class Image>
int write_image(Image& img, const int width, const int height) {
     auto bufsize = width*height;

     const char selector[] = {
          0b000001+63,
          0b000010+63,
          0b000100+63,
          0b001000+63,
          0b010000+63,
          0b100000+63,
     };

     for(int i=0;i<height;++i) {
          for(int j=0;j<width;++j) {
               int index = img[j+i*width];
               printf("#%d%c",index,selector[i%6]);
          }
          printf("$");
          if(i%6==5) {
               printf("-");
          }
          printf("\n");
     }
}

int write_terminator() {
     printf("%c",ST);
     return 0;
}

int main() {
     rgba8_image_t src;
     png_read_and_convert_image("./hoge.png", src);
     auto raw = interleaved_view_get_raw_data(view(src));

     auto attr  = liq_attr_create();
     auto image = liq_image_create_rgba(attr,raw,src.width(),src.height(),0);
     auto result= liq_quantize_image(attr,image);
     auto pal  = liq_get_palette(result);

     auto buffer = std::vector<unsigned char>(src.width()*src.height());
     liq_write_remapped_image(result, image, &buffer[0], buffer.size());

     write_header(pal);
     write_image(buffer,src.width(),src.height());
     write_terminator();

     liq_attr_destroy(attr);
     liq_image_destroy(image);
     liq_result_destroy(result);
     return 0;
}

実行例:

時間がないときのprintfの偉大なる手軽さを知りました。。すごく直したいです。
本当はデータ圧縮(といってもただのRun Length)とかした方がいいのですが、まずはシンプルにしておきます。
screen対応は・・・需要があれば書きます。Twitterでreplyください。tmuxは実は私はしりません。PysixelというPython版の作者が詳しいのでそちらに聞いたほうが早いかも。