python

Bokehで画像をプロットする方法

2022-08-28

Matplotlibで画像をプロットする場合はPillowの画像オブジェクトをimshow()関数に与えるだけでよかったのが、Bokehの場合は少しコツが必要です。

ということでBokehで画像をプロットする方法について、記事にまとめておきました。

Matplotlibで画像を表示する方法

本題に入る前に、Bokehとの違いを見てほしいので、まずはMatplotlibを使った画像の表示方法を確認しておきます。PillowのImageオブジェクトをimshow()関数に渡せばイッパツでプロットすることができました。

from matplotlib import pyplot as plt
from PIL import Image

IMAGE_PATH = 'cat.jpg'
with Image.open(IMAGE_PATH) as im:
    plt.imshow(im)

Bokehのimage_url()関数を使ってプロット

Bokehの場合は、bokeh.plotting.figure.Figure.image_url()関数に画像パスを渡して画像を表示することができます。

from bokeh.plotting import figure, show
from bokeh.io import output_file
from PIL import Image

# Jupyter上ではローカルに保存されているイメージを
# URL参照で表示することができないため
# ファイルに出力してから表示しないといけない
output_file('plot.html')

# Pillowを使って画像を読み込み、の幅と高さを取得
with Image.open('cat.jpeg') as im:
    w, h = im.width, im.height

# 画像サイズに合わせた幅と高さのプロット作成
p = figure(width=w, height=h, x_range=(0, w), y_range=(0, h))

# 画像URLまたは相対パス文字列を渡すとプロットされる
# 画像のY座標は上から下に対し、Bokehの座標は下から上であるので
# y座標(描画の起点)は画像の高さを指定する必要がある
p.image_url(url=['cat.jpeg'], x=0, y=h)

show(p)

画像パスを渡すだけでプロットできちゃうんだからBokehのほうが画像プロット簡単じゃんとか思ちゃいそうなところですが、実はいろいろと利用できるシーンに制限があって苦労させられます。

苦労1つ目ですが、画像のサイズに応じてキャンバスサイズが調節されないので、figure()呼び出し時に適切なキャンバスサイズを指定する必要があります。適切なサイズを指定するためだけに、PillowやOpenCVなどを使って画像ファイルをロードすることになります。(縦横比気にしないとかはみ出してもいい場合は問題ない)

苦労2つ目は、Figure.image_url()はhttps://などのURL形式が前提のため、Jupyter上でローカルファイルをプロットすることができない、という制限です。Bokehを使うメインシチュエーションはローカル環境でのデータサイエンスじゃないかと思います。この制約はけっこう痛いですよね。

と、いうことでこれら苦労2つを考えると、次に紹介する方法が現実的なんじゃないかと思います。

Bokehのimage_rgba()関数を使ってプロット

bokeh.plotting.Figure.image_rgba()関数を使うと、Jupyter上でも画像をプロット表示することができます。

image_rgba()関数は画像データを直接渡すことになるワケですが、データの形式がRGBAを表現するUINT32の配列しか受け付けてくれません。

Numpyを使ってデータをいぢいぢすることになるワケですが、にわかデータサイエンティストであるケンヂまるは何時間も悩む事になりました。。

from bokeh.plotting import figure, show
from bokeh.io import output_notebook
from PIL import Image
import numpy as np

# Jupyter内にプロットする場合は最初に呼び出し
output_notebook()

# PILイメージとして読み込む
pil_im = Image.open('cat.jpeg')
# RGBからRGBAに変換
pil_im_rgba = pil_im.convert('RGBA')
# PILイメージからndarrayに変換
im_rgba = np.asarray(pil_im_rgba)

# (高さ, 幅, [R,G,B,A])の配列から(高さ, 幅, RGBA)の配列に変換
im_uint32 = im_rgba.view(np.uint32).reshape(im_rgba.shape[:2])

# 画像の高さと幅を取得
h, w = im_rgba.shape[:2]
# 画像サイズと同じキャンバスを作成
p = figure(width=w, height=h, x_range=(0, w), y_range=(0, h))
# 画像をサイズ通りに描画
p.image_rgba(image=[im_uint32], x=0, y=0, dw=w, dh=h)

show(p)

プログラムの解説をします。

Jupyter上にプロット出力するために、output_notebook()関数を呼び出しています。

PILイメージとして読み込んだJPEG画像はRGB形式なので、RGBA形式に変換します。

さらにRGBA形式のPILイメージをndarrayに変換します。

さてここで、ndarrayは色情報[255, 255, 255, 255]という形式になっています。つまり色情報は型がuint8で長さが4の配列です。

Figure.image_rgba()関数は色情報がuint32のリテラルであることが前提なので、このままでは渡せません。

そこでndarray.view()関数を使って、(高さ, 幅, uint8×4配列)の形式から(高さ, 横, uint32×1値)の形式に変換しています。これでようやく、image_rgba()関数が受け付けてくれる形式になりました。

uint8×4配列からuint32×1値への変更イメージ

ということで情報をいぢいぢしたうえでimage_rgba()関数に渡すと、Jupyter上に表示することができました。(画像はDataSpellで実行したもの)

image_rgba()関数でプロットしたとき画像が逆さという問題

image_rgba()関数を使うと、Jupyter内に画像をプロットできました。けれども画像が上下逆さなんです。

逆さになる理由は、画像が左上が原点(0, 0)として扱われるのに対し、Bokehは左下が原点として扱われるためです。

Bokehのimage_rgba()のパラメータ設定では対処できなそうなので、渡してやる画像そのものを逆さまに加工します。

画像データを逆さまにするにはnumpy.flip()関数を使います。

この関数は指定した軸の順番を反転というもので、こんな感じで使います。

[[0, 0, 0], [1, 1, 1], [2, 2, 2]]のデータが[[2, 2, 2], [1, 1, 1], [0, 0, 0]]になりました。

これを画像情報に使えば、上下や左右を反転することができます。

# 画像情報を上下反転
im = np.flip(im_uint32, 0)

p2 = figure(width=500, height=int(500*h/w), x_range=(0, w), y_range=(0, h))
p2.image_rgba(image=[im], x=0, y=0, dw=w, dh=h)

show(p2)

それらしくプロットすることができました。

プロットの目盛りが上下逆という問題

厳密にいうとまだ問題が残っています。画像のピクセルというのは左上が座標(0, 0)ですが、プロットされた目盛りは左下が座標(0, 0)になっています。この状態だと、目盛りの情報を活用したい場合に問題になります。

例えば猫顔認証システムを作りたい人がいたとします。きっとその人は、猫顔を識別するシステムを作る過程でデータ集めが必要になります。そう、猫の顔部分の画像です。

猫顔の位置を目盛りの位置で確認すると、上360、下280、左440、右530の範囲を切り抜けば、猫の凛々しい顔画像が得られるはずと思いきや、見当違いの部位の画像になってしまいます。

これが目盛り情報が正しくないことの弊害です。

そこで正しい目盛り情報になるように、上端の座標が0から始まるようにBokehのプロットを修正します。

p3 = figure(width=500, height=int(500*h/w), x_range=(0, w), y_range=(h, 0))
p3.image_rgba(image=[im], x=0, y=h, dw=w, dh=h)

show(p3)

やっていることは、figureを作成する際にy_range=(0, h)としていた部分をy_range=(h, 0)と指定順を変えただけです。これで目盛りの始点と終点が逆の位置関係になります。

改めて正しい目盛り情報を参考に猫顔範囲を確認すると、上は400で、下は490であることがわかりました。

正しい範囲情報を使って切り抜くと、きちんと凛々しい猫顔部分の画像を得ることができました。

-python

© 2024 ヂまるBlog