Edited at

Julia / Jupyter での画像表示を実装する


ポイント


  • Jupyter 上でどうやって画像を表示するのか?

  • Jupyter が対応していないフォーマットの画像を表示するには?

  • 値型やメタプログラミングは、どういう場面で用いられるのか?

この記事の内容を実装したパッケージ

https://github.com/Lirimy/ImageContainers.jl


はじめに

この記事が想定している状況は、例えば、 Julia から gnuplot や ffmpeg などにデータを送り、得られた画像や動画を Jupyter 上でシームレスに表示して確認したい、といったプロセスである。

この点で、Images.jl, ImageView.jl とは少し状況が異なる。

これらのパッケージは、画像を色の配列として保持している。

その上で、画像自体を編集・加工することができる。

ここでは、目的を Jupyter 上での画像表示のみに絞ることにする。

一方で、色の配列だけではなく、 PNG, SVG などの画像も直接取り扱えるようにしよう。


Jupyter での画像表示

自分で定義した型のオブジェクトを Jupyter 上で表示することを考えよう。

これは、Julia の特徴である多重ディスパッチを利用して、独自の型に対する Base.show 関数を定義すればよい。

# PNG のデータをそのまま詰め込む

structPNGContainer
content
end

# PNGContainer 型のオブジェクトに対して呼ばれる
# 出力形式を MIME で指定する
function Base.show(io::IO, ::MIME"image/png", c::PNGContainer)
write(io, c.content)
end

# 実際に PNGContainer 型のオブジェクトをつくると、 Jupyter 上で表示される
c = open("sample.png") do io
PNGContainer(read(io))
end

Base.show の第一引数 io に書き込まれたデータが IJulia.jl, Jupyter ホストを介してブラウザに届き、画像が表示される。


様々なフォーマットに対応させる

前節では、PNG の表示ができるようになった。

当然ながら、 SVG なども同じように表示させたいだろう。

しかし、多重ディスパッチでは、「型」に対して呼ばれるメソッドが決まるのであって、「値」によって処理を分岐させることができない。

だからといって、 SVGContainer などと次々に型をつくっていくのも不毛だろう。

これを解決するのが値型である。

structImageContainer{format}

content
end

function Base.show(io::IO, ::MIME"image/png", c::ImageContainer{:png})
write(io, c.content)
end

function Base.show(io::IO, ::MIME"image/svg+xml", c::ImageContainer{:svg})
write(io, c.content)
end

c = open("sample.svg") do io
ImageContainer{:svg}(read(io))
end

ここでは、画像フォーマットに応じたシンボルによって1ImageContainer は別の型だとみなされる。

したがって、多重ディスパッチが効くため、表示させたいすべての画像フォーマットに対して Base.show を繰り返し記述すれば、目的は達せられることになる。


メタ・プログラミング!

さて前節は、似たようなことを繰り返し記述するのが面倒だ、という話であった。

これこそメタプログラミングの出番であろう。

const mimetexts = Dict(

:png => "image/png",
:svg => "image/svg+xml",
:jpg => "image/jpeg",
:jpeg => "image/jpeg"
)

# function Base.show(io::IO, ::MIME"image/png", c::ImageContainer{:png})
# write(io, c.content)
# end

for (fmt, mime) in mimetexts
@eval function Base.show(io::IO, ::@MIME_str($mime), c::ImageContainer{$(QuoteNode(fmt))})
write(io, c.content)
end
end



  • @eval マクロで、表現がグローバルスコープで評価(実行)される


  • $val で、変数が評価される(補間というらしい)


  • MIMEシングルトン2で、評価に備えて @MIME_str が用意されている


補足:シンボルの評価を逃れるには?

先の例で、 フォーマットを表す変数 fmt には、中にシンボルが入っている。

このため、$fmt が評価されるとエラーが出てしまう。

評価後にシンボルを残すには、どうすればよいだろうか?

# 評価後に ImageContainer{:png} を得たい

fmt = :png
@eval ImageContainer{$fmt}
#ERROR: UndefVarError: png not defined


方法0:QuoteNode

コメントで @antimon2 さんに教えていただきました。

@eval ImageContainer{$(QuoteNode(fmt))}

#ImageContainer{:png}

https://docs.julialang.org/en/v1/manual/metaprogramming/#QuoteNode-1


方法1:配列に隠す

配列の要素に隠し、配列を評価したあとで、中から要素を取り出せばよい。

@eval ImageContainer{$[fmt]...}

#ImageContainer{:png}

参考:Metaprogramming in JuliaLang 10 / 38


方法2:実体をつくる

実はこちらの方を先に思いつき、後から方法1が見つかった。

結局使わなかったが、もったいないので共有して供養したい。

dummy = ImageContainer{fmt}(UInt8[])

@eval typeof($dummy)
#ImageContainer{:png}


HTML に画像を埋め込む

ここまでで、 PNG / SVG / JPEG の表示は実装できた。

これができたのは、実は IJulia.jl がそれらの MIME タイプを素直に受け取ってくれるためである。

それでは、 IJulia.jl が対応していない画像フォーマットは表示できないのだろうか。

別にそんなことはなく、 MIME("text/html") にして、HTML タグを介して出力すれば事足りる。


画像データの渡し方

次に検討すべきなのは、画像データの送り方である。

方法は2つある。


  1. 一時ファイルに書き出し、リンクする

  2. Base64 エンコードして直接出力する

1 はよく使われる方法で、実際に Plots.jl/animation.jl ではそのように実装されている。

いろいろ考えた結果、今回は 2 を採用する。

2 を採用する理由としては、 Jupyter Notebook のファイルに出力画像が埋め込まれることだろうか。

画像の管理が不要になり、 Gist などで Notebook の共有が気軽にできるようになる。

逆に Notebook のファイルサイズが増えたり、バージョン管理が重くなったりするデメリットもある。

(セミコロンで出力を抑制すれば防げるが)


Base64 エンコード

Base64 とは、バイナリデータをアルファベットなどに対応させ、テキストとして取り扱えるようにするエンコード方式である。

今回は、画像データを Base64 エンコードして、 HTML タグ内に

<img src="data:image/gif;base64,R0lGODdhgALgAeYAAP...isQADs=" />

などとして埋め込む。

Base64.Base64EncodePipe を通すとエンコードしてくれるので、そのまま実装しよう。

using Base64

function Base.show(io::IO, ::MIME"text/html", c::ImageContainer{:gif})

write(io, "<img src=\"data:image/gif;base64,")

ioenc = Base64EncodePipe(io)
write(ioenc, c.content)
close(ioenc)

write(io, "\" />")
end

上のコードで、 GIF アニメも再生されることを確認できた。

これと同じ方法で、 BMP / GIF / MP4 の表示が可能となる。

--

Base64 の参考ページ

base64ってなんぞ??理解のために実装してみた

バイナリ 6 bit を1文字 1 byte = 8 bit に伸長するので、データサイズは 33% 増加するようだ。


画像を変換してから表示する

最後に、色の配列 Matrix{<:Color} などの、ブラウザで直接表示できない形式の画像を、適当な形式に変換して表示しよう。

色の配列 Matrix{<:Color} ならば、 FileIO.save で好きな形式に変換3できる。

下のコードでは、色の配列を BMP 形式に変換し、 Base64 エンコードしてから HTML タグで囲んで出力している。

using FileIO

function Base.show(io::IO, ::MIME"text/html", c::ImageContainer{:jlc})
write(io, "<img src=\"data:image/bmp;base64,")

ioenc = Base64EncodePipe(io)
save(Stream(format"BMP", ioenc), c.content)
close(ioenc)

write(io, "\" />")
end

この流れを応用すれば、ブラウザが表示・再生できる形式に変換できるならば、どんなデータでも Jupyter 上で取り扱うことができるだろう。


補足:出力フォーマットの選択

先ほどのコードで、出力フォーマットを BMP にした理由を説明する。

下のベンチマークで、フォーマットによる処理時間を計測した。

using ColorTypes

io = IOBuffer()
c = ImageContainer{:jlc}(rand(RGB, 1000, 1000))
@time Base.show(io, MIME("text/html"), c)

このベンチマークでは、ランダムな画素によって構成されている画像で計測した。

最も圧縮しにくいケースではあるが、例えばイジングモデルの表示など、それに近い状況は多い。

第一択と考えていた PNG は、無圧縮の形式に対して10倍以上遅い。

第二択の TIFF は、 Safari 以外の主要なブラウザが対応していなかった。

最近では環境依存などの問題も少なそうなので、 BMP を採用した。

フォーマット
処理時間

BMP
0.07 sec

TIFF
0.07 sec

PNG
0.95 sec

Web ブラウザの画像フォーマット対応状況

https://en.wikipedia.org/wiki/Comparison_of_web_browsers#Image_format_support





  1. 画像フォーマットを表すような「値」が望ましいのだが、値型の仕様で文字列は利用できないため、「値」としてシンボルを用いた 



  2. フィールドを持たない複合型で、インスタンスが1つしかない 



  3. 内部で ImageMagick を呼び出している