ターミナルで画像を表示する Sixel Graphics について
はじめに
エンジニアの皆さんの中には、一日のほとんどをターミナルに引きこもって暮らしている方も多いのではないでしょうか?
多くの作業においてターミナルを中心に行うようにすることで、日常作業のほとんどの操作をキーボードで完結することができ、また工夫次第でスクリプティングによって自動化できる範囲も広がるので慣れるととても快適です。
一方、どうしても文字ベースの入出力を中心に発展してきたターミナルは画像の扱いが弱点になります。それでも実は、一部のターミナルでは画像を表示できることはご存じでしょうか?
例えば、libsixel (homebrew) を使うと、img2sixel
というコマンドを用いてターミナル中にインラインで画像表示を行うことができます。
img2sixel
による画像表示の例
単発の画像表示だけだとなかなか使いどころが限られてしまいますが、工夫次第では画像版 ls
ともいえる lsix のような、なかなか実用的なアプリも作ることもできます。
今回の記事では、Sixel についての背景や、Sixel でどのように画像表示を行っているのかの説明を中心に行っていきたいと思います。どのような入力によって画像を扱うことができるのか知っておくことで、単に完成品のコマンドを使うだけではなく、Sixel を利用したアプリケーションを作るのに役立つのではないかと思います。
Sixel の歴史と今
ターミナルで画像を扱うというのはとても先進的な技術に映るかもしれませんが、実は Sixel の誕生は非常に古く、いにしえの VT 端末 の時代から存在していたものです。
とはいっても登場当時の Sixel の役割というのはおそらく比較的単純な模様やカスタムの文字フォントを扱うような目的で使われていたと思われ、写真のような高画質の画像を扱うことはあまり想定していなかったと考えます。[1]
それが、昨今の PC の性能向上によりカラー画像を含めて一瞬で描画ができるようになり Sixel の新たな応用が広がってきたというわけです。現代の PC であれば Sixel で Youtube 動画を再生し たり Windows XP を動かし たりすることさえ可能です。
より詳しくは、こちらの Qiita 記事 や libsixel (github) をご参照ください。
Sixel が利用できる端末
残念ながら、GNOME Terminal (Ubuntu) や Terminal.app (MacOS) のようなデフォルトで搭載されているターミナルでは sixel のサポートが無いことが多いですが、ある程度高機能なターミナルでは sixel が使えるものが多くあります。例えば以下などが挙げられます。
ちなみに、本記事の検証では WezTerm を使用しています。
ほかのターミナルの対応状況については、Are We Sixel Yet などをチェックしてみると良いでしょう。
Sixel のしくみ
ここからは Sixel の画像表示の仕組みについて説明します。
Sixel はおおまかに言えば画像表示に必要な動作を手続き的にエンコーディングしたデータをエスケープシーケンスに埋めこんだようなものになっています。以下、詳しく見ていきましょう。
Escape Sequence としての基本構造
Sixel は以下のようなエスケープシーケンスを端末が解釈することによって処理されます[2]。
<ESC>Pq<payload><ESC>\
<ESC>
は 0x1b
の 1 バイトを表わします。P
, q
\
は通常の ASCII 文字です。
<payload>
の部分が画像データをエンコードした部分で印字可能な ASCII 文字列が使われます。
このようなエスケープシーケンスを標準出力等から端末に書きだすことで、端末が認識して画像として描画してくれるというわけです。
以下の部分で、<payload>
がどのように解釈・描画されるのかという部分について説明していきます。
描画単位
Sixel という名前は "six pixels" に由来する[3]もので、その名の通り Sixel のデータは 6 ピクセルを描画単位としています。
具体的には、図に示すような 6 x 1 の縦長の長方形で示される領域が描画単位になります。
Sixel における描画単位
各ピクセルについて塗るか塗らないかという情報を 6 bit の整数で表わします。このとき、一番上にある pixel が最下位 bit, 一番下の pixel が最上位 bit に対応するようにします。
この 6 bit の整数 (0 から 63 の範囲) に 63 を足すことで 63 (ASCII: ?
) から 126 (ASCII: ~
) のいずれかの整数を得ることができて、これは ASCII で印字可能な文字の範囲になっています。
このようにしてひとつの "sixel" (6 ピクセルの領域) を 1 文字で表わすことができて、これを順に並べていくことで様々な描画パターンを表現することができます。
描画の順番
次に、上で説明した "sixel" がどのような順番で描画されていくのかという点について説明していきます。
基本的に "sixel" は左から右に描画されていきます。1 行のテキストが左から右に描画されていくのと同じように考えると良いでしょう。例として、
}G}
という Sixel データを考えてみましょう。余計に足していた 63 を引いて bit を復元すると、以下のようになります。
character | ASCII | ASCII - 63 | binary |
---|---|---|---|
} |
125 | 62 | 0b111110 |
G |
71 | 8 | 0b001000 |
したがって、これを左から右に描画すると以下のようになります。
同じ行内での描画の順番
同じ要領で左から右に広げていくことで 6 x n の領域を塗ることはできますが、縦幅を増やすにはどうしたら良いでしょうか?
こちらもテキストの描画の類推で考えると、改行にあたる操作を考えれば良いことがわかります。
Sixel でもテキストの改行における CR (\r
), CRLF (\r\n
) のそれぞれに対応するような "改行" 操作があって、それぞれ $
, -
の文字で表わされます。具体的には次のような操作になります。
-
$
は描画位置を一番左までリセットする。縦の位置は変わらない。 -
-
は描画位置を一番左までリセットする。同時に縦も 1行 (6 pixel) 下にずらす。
図で表現すると以下のような形です。
「改行」操作を行ったときの描画の順番
この時点では $
が何のためにあるのかわかりにくいかと思いますが、以下で見るように同じ行を別の色で塗り重ねるために使われます。
色指定
これまでは単色で構成されるパターンを塗っていくことだけを考えてきましたが、Sixel ではカラー表示も利用することができます。
Sixel では基本的に 0番から 255 番までの color register というものが用意されていて、この color register に色を登録しておいて、描画するときにどの color register を使うかを選ぶという方法で色指定を実現します。
具体例で説明すると、以下のようなコマンドで色を登録することができます。
#3;2;30;40;50
これは、以下のような意味で解釈されます。
- 3 番のレジスタに
- RGB のカラースキームで (数字の 2 が RGBの意味になる)
- R=30%, G=40%, B=50% の色を登録する。
色の具体的な指定が 0 - 255 の数値ではなく、0% - 100% のパーセンテージで表わされることに少し注意が必要です。
登録した色を使うには次のように指定します。
#3
これは、次回以降の描画で 3 番の color register に登録された色を使うという意味になります。
動作例
最後にこれまで説明してきたコマンドを用いて HI
という文字列を画像をして描画する例を紹介します。以下が使用する Sixel データです。なお、読みやすさのために空白・改行が入れてありますが実際には不要です。
<ESC>Pq
#1;2;100;100;0
#2;2;0;100;0
#1~~@@vv@@~~@@~~$
#2??}}GG}}??}}??-
#1@@@@@@@@@@@@@@
<ESC>\
はじめに 1 番のレジスタに黄色, 2 番のレジスタに緑を登録したあと、これらの色を切りかえて描画を行っています。1 行目は 2 色で塗りわける必要があるので、$
コマンドを使用して 2 周しています。
以下に、どのような順番で色が塗られていくか示したアニメーションを示します。
なお、このアニメーションの作成には今回学習・説明用に Python で実装した以下のコードを使用しました。Sixel の各コマンド実行ごとにピクセルの状態を matplotlib で描画してアニメーションとして再生できるようにしてあります。
ほかの機能
今回紹介しなかった Sixel の機能として、!
による同一 sixel のリピートや "
による raster attribute の指定というものがあります。これらについては以下をご参照ください。
Sixel 以外の画像表示プロトコル
これまで見てきたように Sixel を用いて画像表示をサポートすることができますが、エンコーディングの方法や描画に必要な手数の多さなど写真や動画のような大きなデータ向けにはやや効率が良くないのではないかと思われる部分も目につきます。
このような事情もあってか、近年では Sixel 以外の画像プロトコルが新たに生まれてきています。これらの新しいプロトコルについても少し紹介しておきましょう。今後はこのような新しいプロトコルが普及していくのかもしれません。
iTerm Image Protocol
iTerm Image Protocol は、iTerm2 をオリジナルとして登場した画像プロトコルで WezTerm
でも実装されて います。
iTerm Image Protocol は以下のような書式を持ちます。
<ESC>]1337;File=<optional arguments>:<base-64 encoded file contents><BEL>
書式の読み方について
<ESC>
は 0x1b
の 1 バイト、<BEL>
は 0x0a
の 1 バイトで、]
, 1337
, ;
, File
等は ASCII 文字列としてそのまま入力します。
<optional arguments>
の部分は key1=val1;key2=val2;...
という形式で key-value ペアを並べたもので、画像の表示オプションを制御するのに使います。
Sixel のように画像の内容を仮想的なプリンタの動きにエンコードしたりする必要はなく、base64 エンコーディングした画像データを直接入力するだけで良いというのが特徴と言えるでしょう。
画像プロトコルを利用するアプリ開発者視点としては libsixel のようなライブラリに頼らずに画像を Sixel 化するのはとても大変なので、base64 エンコーディングして端末に流すだけで良いというのはかなり使いやすさが向上していると思います。
より具体的な使用方法については、imgcat のソース (bash) などを読んでみると良いでしょう。
Kitty Graphics Protocol
Kitty という端末エミュレータを起源とするグラフィクスプロトコルも提案されています。WezTerm でも一部サポートしていますが、挙動は安定していないようです[4]。
こちらのプロトコルは以下のような書式です。
<ESC>_G<control data>;<payload><ESC>\
書式の読み方について
<ESC>
は 0x1b
の 1 バイトで、_G
, ;
, \
の部分は ASCII 文字列です。
<control data>
は表示オプションを制御する ,
区切りの key-value ペアを指定します。
<payload>
には画像データを base64 エンコーディングしたものを入力します。
エスケープシーケンスとしての開始・終了記号こそ違いますが、構造としては iTerm Image Protocol と似ています。仕様の詳細までは把握していないのですが、iTerm Image Protocol よりも制御できる項目が多く、こちらのほうがより高機能なグラフィクスプロトコルであるという印象を受けます。
termpdf.py
や hologram.nvim
など、Kitty Graphics Protocol を前提にしたアプリもいくつか開発されています。
おわりに
今回の記事では、端末上の画像表示のためのプロトコルである Sixel を紹介し、動作のモデルについて説明を行いました。また、Sixel 以外の新しいグラフィクスプロトコルについても少し紹介を行いました。
ぜひターミナル上でのグラフィクス表示をうまく活用して快適なターミナル生活を送る助けにしていただければと思います。さらには、これらの知識を活かしてグラフィクスプロトコルを活用した新たなアプリを生み出すきっかけとなればとても嬉しく思います。
-
実際の VT端末で img2sixel を使ったという面白い動画 (https://www.youtube.com/watch?v=0SasrQ7pnbA) がありますが、1枚の画像表示に90秒ぐらいかかっています。おそらく、色数が多い画像であればもっと時間がかかるでしょう... ↩︎
-
ここでの形式はやや単純化したもので、実際には
P
とq
の間に<P1>;<P2>;<P3>;
という形式でパラメータを指定することができます。詳しくは次のリファレンス等を参照してください。 https://vt100.net/docs/vt3xx-gp/chapter14.html ↩︎ -
Wikipedia: https://en.wikipedia.org/wiki/Sixel ↩︎
-
例えば、Issue のコメント (https://github.com/wez/wezterm/issues/986#issuecomment-1823228895) などで kitty との動作の違いが見えることがわかります ↩︎
Discussion