Posted at

macの初期ターミナルでも画像を表示したい

More than 1 year has passed since last update.

Livesense Advent Calendar 5日目の記事です。


はじめに

皆さんはターミナルに何を使ってるでしょうか?

macユーザーの方はiTerm 2を使ってる人も結構いるんじゃないかと思います

iTerm 2は画像が開けるという噂を聞きました

iTerm2でターミナルに画像を表示する。

しかし自分が使ってるターミナルはデフォルトのやつなので開くことができません

このままだとiTermに負けた気がして気が気ではありません

なのでテキストベースで画像をターミナル上に表示するものをGo言語で書いてみました


いざ作成


用意するもの


  • aalib


    • ffplayのASCIIモードとかで使われているやつ



  • github.com/syohex/go-aalib


    • Go言語用のaalib wrapper



  • 使い慣れたエディタ

  • バグにぶつかってもくじけない心

github.com/syohex/go-aalib のREADMEによればこんな感じで画像を食わせると動くらしいです

package main

import "github.com/syohex/go-aalib"
import "image"
import _ "image/png"
import "fmt"
import "os"

func main() {
file, err := os.Open("sample.png")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer file.Close()

// Decode the image.
goPng, _, err := image.Decode(file)
if err != nil {
fmt.Println(err)
os.Exit(1)
}

handle, _ := aalib.Init(80, 60, aalib.AA_NORMAL_MASK)
handle.PutImage(goPng)
handle.Render(nil, 0, 0, 96, 96)
aaStr := handle.Text()
fmt.Println(aaStr)
}


go-aalibを動かしてみる

$ file sample.png 

sample.png: PNG image data, 978 x 1145, 8-bit/color RGBA, non-interlaced
$ go run main.go
fatal error: unexpected signal during runtime execution
[signal SIGSEGV: segmentation violation code=0x1 addr=0x18c8040 pc=0x7f09140f4963]

runtime stack:
runtime.throw(0x4efcd8, 0x2a)
/usr/local/go/src/runtime/panic.go:566 +0x95
runtime.sigpanic()
/usr/local/go/src/runtime/sigpanic_unix.go:12 +0x2cc
runtime.asmcgocall(0xc42004dd50, 0xc4200580c0)
/usr/local/go/src/runtime/asm_amd64.s:594 +0x70
runtime.cgocall(0x7ffe564f34b0, 0x766600, 0x7ffe564f34a0)
/usr/local/go/src/runtime/cgocall.go:115 +0xed

goroutine 1 [syscall, locked to thread]:
runtime.cgocall(0x4b1ad0, 0xc42004ddd0, 0x0)
/usr/local/go/src/runtime/cgocall.go:131 +0x110 fp=0xc42004dd80 sp=0xc42004dd40
github.com/syohex/go-aalib._Cfunc_aa_putpixel(0x18a7530, 0x34300000000, 0xffff)
??:0 +0x45 fp=0xc42004ddd0 sp=0xc42004dd80
github.com/syohex/go-aalib.(*Handle).PutPixel(0xc42002c000, 0x0, 0x343, 0x759ae0, 0xc420682ab3)
/go/src/github.com/syohex/go-aalib/aalib.go:105 +0xb2 fp=0xc42004de20 sp=0xc42004ddd0
github.com/syohex/go-aalib.(*Handle).PutImage(0xc42002c000, 0x75a620, 0xc42008e200)
/go/src/github.com/syohex/go-aalib/aalib.go:157 +0xc4 fp=0xc42004de80 sp=0xc42004de20
main.main()
/root/go/src/github.com/ieee0824/test/main.go:32 +0x13c fp=0xc42004df48 sp=0xc42004de80
runtime.main()
/usr/local/go/src/runtime/proc.go:183 +0x1f4 fp=0xc42004dfa0 sp=0xc42004df48
runtime.goexit()
/usr/local/go/src/runtime/asm_amd64.s:2086 +0x1 fp=0xc42004dfa8 sp=0xc42004dfa0

goroutine 17 [syscall, locked to thread]:
runtime.goexit()
/usr/local/go/src/runtime/asm_amd64.s:2086 +0x1
exit status 2

どうやら大きすぎる画像を食わせるとcgoのあたりで死んでしまうようです


方針を変えよう

偉い人は言いました 動くコードがなければ作ればいいじゃない

というわけでpure goなaalibぽいのを作っていきます


処理方法


  1. 画像のコントラストを上げる

  2. 強めにSharpeningフィルタをかける

  3. グレースケール画像に変換する

  4. 2x2 ドットごとに分割、それぞれで 2x2 ドットの内容を反映した文字を算出する

  5. 算出された文字の色を元画像から決定する

  6. 出来上がった文字列をターミナル上に表示する


画像のコントラスト上げるところからSharpeningフィルタまでの実装

github.com/disintegration/imaging パッケージを利用することで難なく実現できます

filteredImage := imaging.AdjustContrast(imaging.Sharpen(img, 50), 10)


画像のグレースケール変換

画像のグレースケール変換は以下のような関数を用いて行います

func ConvertGray(img image.Image) (*image.Gray16, error) {

bounds := img.Bounds()
dest := image.NewGray16(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
c := color.Gray16Model.Convert(img.At(x, y))
gray, ok := c.(color.Gray16)
if !ok {
return nil, errors.New("color is miss match")
}
dest.Set(x, y, gray)
}
}
return dest, nil
}


Pixel情報に対応した文字を生成する

以下のようなループを作りbuffer変数に2x2のブロックを取り出します

画像はグレースケールになっているので輝度情報を取り出します

y座標が y+=2 と進んでいくのに対して x座標が x++ になっているのは表示される文字が半角文字なので縦横の比率を相殺するためです

var buffer = [2][2]uint16{}

for y := gray.Bounds().Min.Y; y < gray.Bounds().Max.Y; y += 2 {
for x := gray.Bounds().Min.X; x < gray.Bounds().Max.X; x++ {
buffer[0] = [2]uint16{gray.At(x, y).(color.Gray16).Y, gray.At(x+1, y).(color.Gray16).Y}
buffer[1] = [2]uint16{gray.At(x, y+1).(color.Gray16).Y, gray.At(x+1, y+1).(color.Gray16).Y}
}

}

2x2のブロックの輝度情報が取り出せたら対応する文字列に変換します

輝度情報は以下のようにしてグループ分けします

func checkColorGroup(n uint16) uint16 {

if n > ((0xffff * 2) / 3) {
// 明るい
return 2
} else if n > ((0xffff * 1) / 3) {
// 中間
return 1
}
// 暗い
return 0
}

4ブロックぶんは以下のように処理します

こうすることでuint16の中に4Pixel分の情報を綺麗に収めることができます

var p [2][2]uint16

var pattern uint16
for y := 0; y < 2; y++ {
for x := 0; x < 2; x++ {
pattern |= checkColorGroup(p[y][x])
pattern <<= 4
}
}

出来上がった情報を予め作った対応表に当てはめることで文字を取り出すことができます

var sampleChar = map[uint16]string{

0x0000: "`",
0x0001: ".",
/* 長いので間は省略 */
0x2112: "#",
0x2121: "#",
0x2122: "#",
0x2211: "#",
0x2212: "#",
0x2221: "#",
0x2222: "#",
}

func ConvertString(p [2][2]uint16) string {
var pattern uint16
for y := 0; y < 2; y++ {
for x := 0; x < 2; x++ {
pattern |= checkColorGroup(p[y][x])
pattern <<= 4
}
}
return sampleChar[pattern]
}


文字に色を付ける

色付けは github.com/aybabtme/rgbterm を使います

対応する座標の色を文字に割り当てます

r, g, b, _ := originalImage.At(x, y).RGBA()

pStr := rgbterm.FgString(ConvertString(pictBlock), uint8(r>>8), uint8(g>>8), uint8(b>>8))

以上のアルゴリズムを組み込んだものが github.com/ieee0824/goaa になります


実行してみる

実行用のプログラム例は以下のようになります

実行するときは ⌘ + -とかで文字サイズを小さくすると良いでしょう


main.go

package main

import (
"fmt"
"image"
_ "image/jpeg"
"log"
"os"
"strings"

"github.com/ieee0824/goaa"
)

func main() {
file, err := os.Open("test.jpg")
if err != nil {
log.Fatalln(err)
}
img, _, err := image.Decode(file)
if err != nil {
log.Fatalln(err)
}
imgstr, err := goaa.ConvertASCII(img)
if err != nil {
log.Fatalln(err)
}

s := strings.Join(imgstr, "\n")
fmt.Println(s)
}



実行結果

サンプル画像は以下のようになります

* フェルメール

* 真珠の耳飾りの少女

* 地理学者

* レナ


真珠の耳飾りの少女

スクリーンショット 2016-12-03 15.13.11.png


地理学者

スクリーンショット 2016-12-03 15.11.47.png


レナ

スクリーンショット 2016-12-03 15.12.30.png

暗くはなりましたが画像の表示には成功しました


さらにもう一歩

画像が表示されましたね

ただターミナルで画像が表示できるだけだと普通なので更にもう一歩進展させたいと思います

動画を再生しちゃいましょう


動画再生までの流れ

ざっくりした再生までの流れは以下のようになります


  1. FFMPEGで動画のframeを分割する

  2. 分割されたframeをgoaaを使ってテキストにエンコードする

  3. エンコードしたテキストをgzipに固めて配信できるようにする

  4. クライアントはgzipに固められたファイルをダウンロードしてデコードしてターミナルに表示する


実際に作ってみた

HLSフォーマットみたいにStreaming再生できればいいなと思って動画を分割して1つのフォーマットとしました

名付けてString Live Streaming(SLS)です

Streamingでテキスト動画を再生したかったのですが実際に作ってみると完全な解像度で再生しようとするとターミナルの描画速度が激重で仕方なく解像度を落としたりとそれどころではなくなりました

とりあえず静止画を変換するフォーマットのままだと画像の差分更新ができないのでデータ構造を見直して差分更新をできるようにしたり色情報をつけることを諦めたりしました

実際に再生したのがこちらです

https://youtu.be/A_FZ5ArIObM

もともとの動画のソースはこちらになります

http://www1.nhk.or.jp/creative/material/97/D0002022083_00000.html

なんとなく再現できてそうですね

ちなみに実装は破壊的変更が発生したので一旦masterと別にslsブランチに置いてあります

https://github.com/ieee0824/goaa/tree/sls


課題

何と言っても課題は処理の重さです

レンダリングが遅いので高解像度が実現できないのとチャンクの切り替わりで一瞬固まるのでなんとかしたいです

また、色を切り捨ててしまったのでそのうち色付き再生でリトライしたいですね