Pythonで画像Viewerを作る

python

 Pythonで、OpenCVとtkinterを使った簡易画像ビューアーを作ってみました。コードとしてはそれほど長くなく、作れました。ビューアーとして最低限の動きはしてくれると思います。最後にコードも載せています。コピペで動くと思います。

目的

 ちょっとraw現像をPythonでしたくなったのですが、恐ろしくパラメータが多く、その調整用にどうしてもビューアーが欲しくなって作ってみました。以前の記事でもTimelapse動画調整用のUIを作ったことがあったので、それをベースに仕上げました。

 細かい動きはそちらにも解説を載せています。

Viewerの作り

・画像を表示させる。
・画像をスクロールさせる。マウスドラッグできる。
・画像の拡大縮小ができる。マウスホイールでもできる。
・画像にグリッド線を表示させる。
・ヒストグラムを表示させる。
・見ている範囲を保存できる。

 これらを満たすことを目指して作りました。まぁ普通の機能ですね。ひとしきりできます。見た目はこんな感じ。少しダサいのはしょうがない。

レイアウト

 フレームを3つに分けてレイアウトし、上側に画像、中段にスクロール、下段にヒストグラムと拡大縮小のバーを置くことにしました。将来的には中段に画像情報、下段にはほかの画像調整パラメータの操作。というのを構想しています。

画像の表示とスクロール

 これは一般的なCanvasを置きました。このキャンバスには、マウスダウン、ドラッグ、Ctrl+ホイールのイベントを取るために、3つのbindを行いました。ほかにもいろいろ取れそうでしたが、必要最小限にとどめます。

self.canvas = tk.Canvas(fm_upper, width=WIDTH, height=HEIGHT)
self.canvas.bind("<Button-1>", self.click)
self.canvas.bind("<B1-Motion>", self.drag)
self.canvas.bind("<Control-MouseWheel>", self.wheel)
self.canvas.pack(side=tk.LEFT)

 このbind関数でアクションとコールバック関数を指定します。このコールバック関数はそれぞれ機能としては単純なものをマッピングしています。後述します。

 今回細かく画像を観察したいこともあったので、高倍率で表示できるようにしたかったです。そのためにスクロール用に巨大な画像を用意するわけにもいかなかったので、必要な箇所を拡縮、切り出しで使う方針にしました。そのためscaleを2本縦横に置き、スクロールも自作することにしました。画像の真下にきれいにscaleを置けなかったので、中段フレームを用意しました。スクロール専用なので、各種表示系(ラベル、値)はOFFにしています。

# 縦スクロールバー
self.Var_shifty = tk.Scale(fm_upper, showvalue=False, orient="v", command=self.scrl_callback)
self.Var_shifty.pack(fill=tk.Y, side=tk.RIGHT)

# 横スクロールバー
self.Var_shiftx = tk.Scale(fm_mid, showvalue=False, orient="h", command=self.scrl_callback)
self.Var_shiftx.pack(fill=tk.X)

showvalue=Falseで値が非表示になります。label=も付けていません。縦横はorientfillの設定が異なるだけです。これでスクロールバーとして機能させます。

 マウスを使ったスクロールですが、いわゆるグラブ移動をさせます。とはいえやってることは単純で、マウスイベントを取り出して、

self.canvas.bind("<Button-1>", self.click)
self.canvas.bind("<B1-Motion>", self.drag)

# マウスダウン
def click(self, event):
    self.posx = event.x
    self.posy = event.y

# マウスムーブ
def drag(self, event):
    dx = event.x - self.posx
    dy = event.y - self.posy
    self.Var_shiftx.set(self.Var_shiftx.get() + dx)
    self.Var_shifty.set(self.Var_shifty.get() + dy)
    self.click(event)

 マウスが落ちた座標をevent.x/yでとって置き、ムーブした先の座標とのΔを求め、その値を現在のスクロールバーの値にオフセットさせるだけです。このスクロールバーへのsetでスクロールバーの変化イベントが発生して、その値で画像再描画され、スクロールします。このsetで設定する値の上下限はあらかじめセットした値でクリップされるので、それを気にする必要はないです。

 この上下限ですが、表示中の画像の幅、高が変わると、スクロール可能幅も変化するので、再計算が必要です。これは

# スクロール限界の計算
fm = (WIDTH - w*self.Var_scale.get())/2
if fm < 0:
    fm = -fm
self.Var_shiftx["from"] = -fm
self.Var_shiftx["to"] = fm

 ここで計算しています。表示Canvasの幅から倍率変更後の画像サイズを計算して、その差分だけ画像が動く遊びができます。それを左右に割り振るために半分にしています。

画像の拡大縮小

 これはシンプルにバーで動かせるようにします。

self.Var_scale = tk.Scale(fm_ctrl, label='拡大率', orient="v", from_=0.1, to=16.0, resolution=0.1, command=self.scrl_callback)

 これで0.1刻みで0.1倍~16倍まで動かせるようにします。またCtrl+マウスホイールのイベントを見張って、拡大縮小の関数を呼びます。

self.canvas.bind("<Control-MouseWheel>", self.wheel)

# マウスホイール
def wheel(self, event):
    if event.delta > 0:     # 向きのみ検出
        self.Var_scale.set(self.Var_scale.get()*2)
    else:
        self.Var_scale.set(self.Var_scale.get()/2)

 この関数ではホイールの向きしか検出しません。どうもevent.deltaが正で上方向の回転、負で下方向の回転になるようなので、それに連動させて、現在の倍率の2倍、1/2倍の値をバーに設定しています。これも先のスクロールと同じで、バーに変化イベントが起こるので勝手に再描画されます。回転機能はおまけです。

描画画像の作成

 OpenCVにアフィン変換をさせているだけです。

affine = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), self.Var_angle.get(), self.Var_scale.get())
affine[0, 2] += self.Var_shiftx.get() + (WIDTH - w) // 2
affine[1, 2] += self.Var_shifty.get() + (HEIGHT - h) // 2
return cv2.warpAffine(image, affine, (WIDTH, HEIGHT), flags=cv2.INTER_NEAREST)

 角度調整バー、倍率調整バーの値をもとに回転行列を求めて、その行列のシフト成分にスクロールバーから取得した描画位置ずらしの成分をオフセットさせています。ちょっと図を描かないと足しこむ値の妥当性がわからないかもしれませんが、図を描けばわかると思います。アフィン変換の詳細は長くなるので割愛します。この時画素の補間はINTER_NEARESTを設定しています。ビューアーなので、拡大したときに変に補間は入らないのが正解だと思います。

グリッド表示

 水平を確認するためにグリッドを表示させています。が、OFFにしたくなることもありそうなのでその切り替えをチェックボックスでやっています。

# グリッド表示・非表示
self.Chkbox = tk.BooleanVar()
self.Chkbox.set(True)
chkbtn = tk.Checkbutton(fm_mid, variable=self.Chkbox, text='グリッドを表示する', command=self.redraw)
chkbtn.pack(side=tk.RIGHT)

 値の変化時には再描画させるだけのシンプルな作りです。初期値はTrueです。一度BooleanVarオブジェクトを作っておかないと、値が取れない仕組みのようです。Checkbuttonのインスタンスchkbtnから.set/getができませんでした。

 グリッド自体は

if self.Chkbox.get():
    self.canvas.create_line(w / 2, 0, w / 2, h)
    self.canvas.create_line(0, h / 2, w, h / 2)

 で始点、終点の座標でcanvasに2本十字を描画することで実現させています。色が気に入らないようであれば、

self.canvas.create_line(w / 2, 0, w / 2, h, fill='red')

 な感じで色も入れられるようです。

画像読み込みとヒストグラムの作成

 最後ゴールをraw現像に置いているので、ヒストグラム表示は必須かなぁと思って付けました。ただグラフをtk上に置く手段がわからなかったので、グラフをビットマップにしたうえで画像として張り付けています。なので少し回りくどいです。やり方教えて。

 その画像の作り方ですが、

# 引数で渡された画像のヒストグラム画像を返す関数
def makehist(self, img):
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    hist, _ = np.histogram(gray.ravel(), 256, [0, 256])
    hist = (hist*128/np.max(hist)).astype(np.uint8)
    histimg = np.zeros((129, 256), dtype=np.uint8)
    for i in range(256):
        for j in range(hist[i]):
            histimg[128-j][i] = 255
    return histimg

 で作っています。グレースケールに変換したのち、numpyのhistogram関数を呼んでヒストグラムを作ったのちに、最大値を128に正規化した8bitの配列にしています。その後、真っ黒画像に対して、各画素値の頻度だけ画像の下から255を埋めていく、といったベタな関数です。for文が2重になっていて遅そうですが、いいやり方が思いつきませんでした。やり方教えて。

 これを画像ファイルを開けた時に1度だけ実行します。今回のビューアーは画素値を変更させないので、ヒストグラムは変化しないので。

# ファイルを開くアクション
def open_file(self):
    filename = tk.filedialog.askopenfilename(filetypes=[('JPEG file', '.jpg')])
    if filename != "":      # キャンセルが押されると名前が空のようだ
        self.img = cv2.imread(filename)
        self.histTk = ImageTk.PhotoImage(Image.fromarray(self.makehist(self.img)), master=self.canvas2)
        self.canvas2.create_image(0, 0, image=self.histTk)
        self.winfo_toplevel().title(filename)   # タイトルにファイル名表示
        self.redraw()

 このファイルオープン時に呼びます。いわゆるコモンダイアログ的な、見慣れたインターフェースでファイルを選ぶことができます。

 合わせてタイトルにファイル名を表示させています。自分自身のtitleなのですが、回りくどくwinfo_toplevelとたどらないと取れない仕組みはイマイチよくわかりません。継承できないのかな。クラス図が見てみたい。

 後はおまけでSaveがあります。Canvasに書かれている画像を保存するだけです。グリッドは無しで。最後にコードを載せます。コピペで動くと思います。

スポンサーリンク

コード

import tkinter as tk
import tkinter.filedialog
import cv2
from PIL import ImageTk, Image
import numpy as np

WIDTH = 800
HEIGHT = 500

class viewerGUI(tk.Frame):
    def __init__(self, master=None):
        super().__init__(master)
        self.img = np.zeros((32, 32, 3), np.uint8)  # 初期表示用画像

        # ----------------------# 上側のフレーム(画像表示部)#----------------------#
        fm_upper = tk.Frame(master)
        fm_upper.pack(fill=tk.X, side=tk.TOP)

        self.canvas = tk.Canvas(fm_upper, width=WIDTH, height=HEIGHT)
        self.canvas.bind("<Button-1>", self.click)
        self.canvas.bind("<B1-Motion>", self.drag)
        self.canvas.bind("<Control-MouseWheel>", self.wheel)
        self.canvas.pack(side=tk.LEFT)

        self.image_Tk = ImageTk.PhotoImage(Image.fromarray(self.img), master=self.canvas)  # 表示画像の生成

        # 縦スクロールバー
        self.Var_shifty = tk.Scale(fm_upper, showvalue=False, orient="v", command=self.scrl_callback)
        self.Var_shifty.pack(fill=tk.Y, side=tk.RIGHT)

        # ----------------------#  真ん中のフレーム # ---------------------- #
        fm_mid = tk.Frame(master)
        fm_mid.pack(fill=tk.X, side=tk.TOP)

        # 横スクロールバー
        self.Var_shiftx = tk.Scale(fm_mid, showvalue=False, orient="h", command=self.scrl_callback)
        self.Var_shiftx.pack(fill=tk.X)

        # グリッド表示・非表示
        self.Chkbox = tk.BooleanVar()
        self.Chkbox.set(True)
        chkbtn = tk.Checkbutton(fm_mid, variable=self.Chkbox, text='グリッドを表示する', command=self.redraw)
        chkbtn.pack(side=tk.RIGHT)

        # ----------------------# 下側のフレーム #----------------------#
        fm_ctrl = tk.Frame(master)
        fm_ctrl.pack(fill=tk.X, side=tk.TOP)

        # 拡大縮小・回転
        self.Var_scale = tk.Scale(fm_ctrl, label='拡大率', orient="v", from_=0.1, to=16.0, resolution=0.1, command=self.scrl_callback)
        self.Var_angle = tk.Scale(fm_ctrl, label='回転角度', orient="v", from_=-180, to=180, command=self.scrl_callback)
        self.Var_angle.pack(fill=tk.Y, side=tk.LEFT)
        self.Var_scale.pack(fill=tk.Y, side=tk.LEFT)
        self.Var_scale.set(1.0)

        # ヒストグラム表示用
        self.canvas2 = tk.Canvas(fm_ctrl, width=256, height=128)
        self.canvas2.pack(side=tk.RIGHT)

        # ----------------------# メニュー #----------------------#
        men = tk.Menu(master)
        menu_file = tk.Menu(master, tearoff=0)
        men.add_cascade(label='ファイル', menu=menu_file)
        menu_file.add_command(label='ファイルを開く', command=self.open_file)
        menu_file.add_separator()
        menu_file.add_command(label='名前を付けて保存', command=self.save_file)
        root.config(menu=men)

    # マウスダウン
    def click(self, event):
        self.posx = event.x
        self.posy = event.y

    # マウスムーブ
    def drag(self, event):
        dx = event.x - self.posx
        dy = event.y - self.posy
        self.Var_shiftx.set(self.Var_shiftx.get() + dx)
        self.Var_shifty.set(self.Var_shifty.get() + dy)
        self.click(event)

    # マウスホイール
    def wheel(self, event):
        if event.delta > 0:     # 向きのみ検出
            self.Var_scale.set(self.Var_scale.get()*2)
        else:
            self.Var_scale.set(self.Var_scale.get()/2)

    # 画像再表示
    def redraw(self):
        self.scrl_callback(0)

    # スクロールバーが変化したときに呼ばれるコールバック関数
    def scrl_callback(self, val):
        tmp = self.convert_preview(self.img)
        w = WIDTH
        h = HEIGHT
        image_rgb = cv2.cvtColor(cv2.resize(tmp, (w, h)), cv2.COLOR_BGR2RGB)
        self.image_Tk = ImageTk.PhotoImage(Image.fromarray(image_rgb), master=self.canvas)
        self.canvas.create_image(0, 0, image=self.image_Tk, anchor='nw')
        if self.Chkbox.get():
            self.canvas.create_line(w / 2, 0, w / 2, h, fill='red')
            self.canvas.create_line(0, h / 2, w, h / 2, fill='red')

    # 回転シフト拡大を行う処理本体
    def convert_preview(self, image):
        h, w = image.shape[:2]

        # スクロール限界の計算
        fm = (WIDTH - w*self.Var_scale.get())/2
        if fm < 0:
            fm = -fm
        self.Var_shiftx["from"] = -fm
        self.Var_shiftx["to"] = fm

        fm = (HEIGHT - h*self.Var_scale.get())/2
        if fm < 0:
            fm = -fm
        self.Var_shifty["from"] = -fm
        self.Var_shifty["to"] = fm

        affine = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), self.Var_angle.get(), self.Var_scale.get())
        affine[0, 2] += self.Var_shiftx.get() + (WIDTH - w) // 2
        affine[1, 2] += self.Var_shifty.get() + (HEIGHT - h) // 2
        return cv2.warpAffine(image, affine, (WIDTH, HEIGHT), flags=cv2.INTER_NEAREST)

    # 引数で渡された画像のヒストグラム画像を返す関数
    def makehist(self, img):
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        hist, _ = np.histogram(gray.ravel(), 256, [0, 256])
        hist = (hist*128/np.max(hist)).astype(np.uint8)
        histimg = np.zeros((129, 256), dtype=np.uint8)
        for i in range(256):
            for j in range(hist[i]):
                histimg[128-j][i] = 255
        return histimg

    # ファイルを開くアクション
    def open_file(self):
        filename = tk.filedialog.askopenfilename(filetypes=[('JPEG file', '.jpg')])
        if filename != "":      # キャンセルが押されると名前が空のようだ
            self.img = cv2.imread(filename)
            self.histTk = ImageTk.PhotoImage(Image.fromarray(self.makehist(self.img)), master=self.canvas2)
            self.canvas2.create_image(0, 0, image=self.histTk, anchor='nw')
            self.winfo_toplevel().title(filename)   # タイトルにファイル名表示
            self.redraw()

    # ファイルを保存するアクション
    def save_file(self):
        filename = tk.filedialog.asksaveasfilename(filetypes=[("JPEG file", ".jpg")])
        if filename != "":      # キャンセルが押されると名前が空のようだ
            cv2.imwrite(filename+".jpg", self.convert_preview(self.img))


if __name__ == '__main__':
    root = tk.Tk()
    gui = viewerGUI(master=root)
    gui.mainloop()

まとめ

 Python上でtkinterとOpenCV使って、簡易画像ビューアーを作ってみました。やはりさほど長いコードを書くことなく、160行で画像ビューアーができました。使えそうだと思った方はコードをコピペしてもらって、適当に編集してみてください。それほどまどろっこしいコードにはなっていないと思います。

 今後raw現像パラメータを調整するために活用していきたいと思います。

python画像処理
スポンサーリンク
キャンプ工学

コメント

タイトルとURLをコピーしました