プログラムでおかえしできるかな

Python、フェイジョア、日常のあれこれでお返し、元SEの隠居生活。

画像ビューアの作り方(Treeviewに画像と疑似チェックボックス)【Python】

このエントリーをはてなブックマークに追加

Python Tkinter の Treeview ウィジェットを用いて、画像ビューアを作成しました。

画像は Tkinter でサポートしている GIF、PNG に加え JPEG、WebP も表示します。
JPEG、WebP の表示と画像の情報取得には pillow を使用しています。

チェックボックスにチェックを付けてプレビューすることもできます。

ドラッグアンドドロップにも対応しています。

また、アプリ(exe)にファイルをドラッグアンドドロップすると、アプリが起動し画像を表示します。

画像表示の仕方や疑似チェックボックスの作り方、アプリの作り方をサンプルコードも交えて説明します。

アプリ(exe)も提供しています。

目次

◆アプリのサンプル画像

▽画像のサムネイルと画像情報を表示します
 ブロガーの方はブログの画像を保存して余計な情報が出ていないか確認することができますよ。

※撮影条件を表示できるようにしました。
更新:2022-12-12

◆機能・特長

◆記事の構成

こちらから目的の章へ移動できます。

更新:2022-12-12

◆Treeviewで画像を表示

Treeview に画像のサムネイルを表示する方法を説明します。

Treeview で画像を表示するには、ツリーカラムに表示します。
表形式の要素の中に画像を埋め込むことはできません。

ツリーカラムに画像を表示する機能は、そもそもファイルやフォルダをツー表示する時にアイコンを表示するために用意された機能なのではないかと思われます。

Tkinter で扱える画像ファイルの種類はPGM、PPM、GIF、PNG です。
メジャーな JPEG を扱うには、pillow パッケージが良く使われます。
ここでも pillow を使用して JPEG、WebP の画像も表示します。

◇画像を表示するには

画像を表示するには pillow のインストールと画像の読み込みを実装します。

◇画像オブジェクトの作成

Tkinter で扱う画像は、PhotoImage クラスのオブジェクトとして作成します。

  • Tkinter だけを使用する場合(PGM、PPM、GIF、PNG)

    • img = PhotoImage(file="sample.gif")
  • Tkinter と pillow を使用する場合(PGM、PPM、GIF、PNGJPEG、WebPなど)

    • img = Image.open("sample.jpg") (pillowのImageオブジェクト作成)
      img = ImageTk.PhotoImage(img) (TkinterのPhotoImageに変換)
      どちらも pillow のクラスです

    • または、ImageTk.Photoimage(file="sample.jpg")
      ※縮小などを考慮するとpillowで加工してからTk用に変換した方が良い

さらに、画像ビューアとしてはサムネイルを表示させるため、元の画像を 150 ✖ 150 に変換します。 変換には thumbnail メソッドを使用します。
thumbnail メソッドは、アスペクト比を保ったまま変換します。
ただし、拡大はしません。

Treeview のツリーカラムは必ず左寄せで表示されます。
そのため、画像サイズに違いがあるとチェックボックス用のテキストの位置がずれてしまいます。
ずれないようにするため、画像のサイズを統一します。
ここでは、160 ✖ 160 の透明な画像を作成し、そこに縮小した表示したい画像を貼り付けます。

【処理】

  1. 画像ファイルを開く Image.open()
  2. 縮小 thumbnail()
  3. 透明イメージの作成 Image.new()
  4. 余白の計算
  5. 貼り付け paste()

【コード】

        # 画像の取得
        image1 = Image.open(file_name)

        # 縮小
        image1.thumbnail((150, 150), Image.BICUBIC)

        # サムネイルの大きさを統一(そうしないとチェックボックスの位置がまちまちになるため)
        # ベース画像の作成と縮小画像の貼り付け(中央寄せ)
        base_image = Image.new('RGBA', (160, 160), (255, 0, 0, 0))  # 透明なものにしないとgifの色が変わる
        horizontal = int((base_image.size[0] - image1.size[0]) / 2)
        vertical = int((base_image.size[1] - image1.size[1]) / 2)
        base_image.paste(image1, (horizontal, vertical))
        image1 = base_image

◇画像を含めてデータを追加

画像を含めてデータを追加する場合、insert() メソッドを使用します。
既に存在する行に対して画像を指定するには、item() メソッドを使用します。
ここでは、insert メソッドについて説明します。

  • 【構文】treeview.insert(parent, index, iid=None, オプション)
  • サンプル
    【構文】treeview.insert("", tk.END, image=画像オブジェクト, values=列データ (リスト))

  • 引数

    • parent:"" で最上位
    • index:tk.END で最後尾に追加
    • iid:=None(省略している)で iid の作成をお任せ
      "I001" から順に割り当てる
  • オプション
    • image:画像(PhotoImage)オブジェクトを指定
    • values:列データ(リスト)を指定
    • text:ツリーカラムのテキストを指定
    • tgs:タグを指定
    • open:子アイテムの展開を指定

【コード】

コードでは、列データに row、画像に images[i]、タグ(背景色の切替)に tags1、ツリー列の文字(チェックボックスの文字) self.check_str["umcheck"] を指定してデータ追加しています。

    # 要素の追加(image=はツリー列の画像、text=はツリー列の文字(疑似チェックボックス))
    iid = tree1.insert("", tk.END, values=row, tags=tags1, 
                        image=images[i], text=self.check_str["uncheck"])     # Treeviewに1行分のデータを設定

◇ダイアログでプレビュー

画像をダイアログでプレビューできるように対応しました。
マウスの右ボタンでダブルクリックするとダイアログを表示します。

pillow のイメージオブジェクトであれば、show() メソッドで表示することができます。
この時、画像ファイルに割りついているアプリが起動します。

本アプリでは、show() メソッドを用いず、Tkinter のダイアログを使用してプレビューします。
画像は Toplevel オブジェクトにオプションで画像を指定したラベルウィジェットを配置するだけで表示できます。

マウスでダブルクリックされた行は、イベント情報からY座標(event.y)を取得し、identify_row() メソッドで iid を取得します。

【処理】

  1. ダブルクリックした行を特定
  2. Treeview からパスを取得
    パスは第1列にあり改行されているので改行を削除
  3. モードレスでダイアログ作成
  4. PhotoImage オブジェクト作成
  5. ラベルのオプション image を使用してラベルを追加して pack

【コード】

▽バインド

        self.treeview1.bind("<Double 3>", self.preview_image)  # マウスを右ダブルクリックしたときの動作

▽ダイアログでプレビュー

    def preview_image(self, event=None, path=""):
        if event:
            rowid = self.treeview1.identify_row(event.y)    # マウスの座標から対象の行を取得する
            path1 = self.treeview1.item(rowid)["values"][0].replace("\n", "")   # ファイル名取得
        else:
            path1 = path

        # ダイアログ表示
        dialog = tk.Toplevel(self)      # モードレスダイアログの作成
        dialog.title("Preview")         # タイトル
        self.d_images.append(ImageTk.PhotoImage(file=path1))    # 複数表示する時のために画像を残す
        label1 = tk.Label(dialog, image=self.d_images[-1])      # 最後のものを表示
        label1.pack()
修正:2021-12-22

◇画像の Exif 情報、GPS 情報、撮影条件の取得方法

pillow には Exif 情報を取得するメソッド getexif() があります。
これは PIL.Image.Exif オブジェクトを返します。
PIL.Image.Exif オブジェクトは collections.abc.MutableMapping ( 辞書のようなもの ) を継承しています。
 ※_getexif()メソッドは古いメソッド(戻り値はdict)

Exif 情報はキー (TAGID) が数字なのでそのままでは読みずらく、理解しやすい文字に変換します。
変換には、pillow パッケージの ExifTags モジュールにある TAGS を使用します。

GPS 情報は、Exif 情報のタグ ID 34853 が該当します。
その内容を取得するには、PIL.Image.Exif オブジェクトの get_ifd() メソッドを使用します。
GPS 情報もキーが数字なので Exif 情報と同様に変換して読みやすくします。
こちらは、pillow パッケージの ExifTags モジュールにある GPSTAGS を使用します。

▽タグの例

TAGID 略称 日本語の説明 英語の説明
256 ImageWidth 画像の幅 Image width
306 ModifyDate ファイル変更日時 File change date and time
34853 GPSInfoIFDPointer GPS タグ GPS IFD pointer

【処理】

  1. Exif のタグを取得 getexif()
  2. TAGS で読みやすい文に変換
  3. 改行でつないで表示用文字列を作成
  4. GPS 情報を取得 get_ifd(34853)
  5. GPSTAGS で読みやすい文に変換
  6. 改行でつないで表示用文字列を作成

【コード】

from PIL.ExifTags import TAGS, GPSTAGS  # Exifタグ情報

    # 画像の取得
    image1 = Image.open(file_name)

    # Exif情報の取得
    exif_dict = image1.getexif()
    exif = [TAGS.get(k, "Unknown")+ f": {str(v)}" for k, v in exif_dict.items()]
    exif_str = "\n".join(exif)
    # GPS情報の取得
    gps_dict = exif_dict.get_ifd(34853)
    gps = [GPSTAGS.get(k, "Unknown") + f": {str(v)}" for k, v in gps_dict.items()]
    gps_str = "\n".join(gps)

◎撮影条件の取得方法

解説は別記事を参照してください 📔 ◆撮影条件の取得方法 - PillowでExif情報の撮影条件を取得【Python】 🔗
更新:2022-12-12

◆Treeviewに疑似チェックボックスの実装

ファイル選択などのためにチェックボックスを用意する方法を説明します。

Treeview にはチェックボックスを表示する機能やCheckbutton(チェックボックス)ウィジェットをデータに埋め込む機能はありません。

したがって、チェックボックスの機能を実現するには、疑似的にチェックボックスを作成します。

そのうち2つの方法を示します。(他にもあるかもしれませんがそこまで調査していません)
どちらも、チェック状態の画像(文字)とアンチェック状態の画像(文字)を用意してそれを切り替えることでチェックボックスを実現します。

どちらも表部分ではなくツリーカラムで実装します。

本アプリはツリー部分のイメージを画像のサムネイルに使用しているのでイメージで対応ができません。
それで、文字で実装します。

考え方は簡単でチェックボックス表示をトグルする動作を用意して操作にバインドします。

トグル動作では、item() メソッドでチェックボックスの表示を切り替えます。

  • 【構文】treeview.item(item, option=None, オプション)
  • サンプル
    【構文】treeview.item("I001", image=画像オブジェクト)

  • 引数

    • item:アイテムID
    • option:オプション名を指定するとオプション内容が返ります
      Noneの場合はすべてのオプション
  • オプション
    • image:ツリーカラムに表示する画像(PhotoImage)オブジェクトを指定
    • text:ツリーカラムのテキストを指定

マウスでクリックされた行は、イベント情報からY座標(event.y)を取得し、identify_row() メソッドで iid を取得します。

【コード】

本アプリでは、文字(textオプション)を使用してチェックボックス状態の変化を表現しています。

▽バインド

    # bind
    self.treeview1.bind("<Button 1>", self.togle_checkbox) # マウスを左クリックしたときの動作

▽トグル動作

    check_str = {"uncheck":"☐", "checked":"☑"}    # ☐☑☒チェックボックス用文字


    def togle_checkbox(self, event=None):
        rowid = self.treeview1.identify_row(event.y)    # マウスの座標から対象の行を取得する
        if self.treeview1.item(rowid, text=None) == self.check_str["uncheck"]:
            self.treeview1.item(rowid, text=self.check_str["checked"])
        else:
            self.treeview1.item(rowid, text=self.check_str["uncheck"])

◆ダブルクリック時の注意

Tkinter でマウスのダブルクリックとシングルクリックを別の関数にバインドしている場合、次のような注意が必要です。

Tkinter では、ダブルクリックのイベントが発生した場合、シングルクリックのイベントも発生します。

したがって、それぞれのイベントにバインドした関数があり、動作がダブルクリックの場合には、シングルクリックにバインドした関数も動作することに注意が必要です。

その場合の解決方法は3つ

  1. 何もしない

    クリック用関数の動作がダブルクリック時に見えないことが多いため
    例:クリックでカーソル位置を指定、ダブルクリックで文字選択

  2. クリック動作を元に戻す

    ダブルクリック用関数でシングルクリック動作を元に戻す

  3. ダブルクリックの前半かどうか判断する

    シングルクリックイベントでダブルクリックイベントの発生を待ち、
    ダブルクリックが発生してから共通の処理(中身は分ける)を実行する
    ※ほとんどのユーザはこの遅延は煩わしい
    ソースコードにはこの対応方法がコメントで残してあります。

本アプリでは開発中に2、3の方法を試しましたが、ユーザーが許容できる見た目ではないと判断して、ダブルクリックをマウスの右ボタンに変えました。

Tkinterの使い方

Tkinter(ティーキンター)は Python が標準で提供している GUI パッケージです。
他のプログラムの開発ツールで提供されているようなルック&フィールな GUI ツールではありません。
従って、画面を構成するには、すべてコードを書く必要があります。

Tkinter を使用するには import が必要です。

   import tkinter as tk

慣例的に tk と別名を付けるようです。

◇初歩的な使い方

ウィンドウを出すだけのサンプルです。
Python を起動して次の命令を入れるとウィンドウが出てきてアプリらしさをちょっとだけ体験できます。

import tkinter as tk     #tkと省略することが慣例のようです  
win = tk.Tk()               #ウィンドウの作成と表示  
win.title("Hello, World")    #タイトル設定  
win.geometry("400x300")      #ウィンドウサイズ設定  
lbl = tk.Label(win, text="ラベル")    #ラベルウィジェットの作成  
lbl.pack()                  #ラベルウィジェットの配置  
win.mainloop()              #イベント待ち  

Tkinterの使用ウィジェット

Tkinter がサポートする GUI の部品をウィジェット (widget) と呼びます。

ここでは次のウィジェットを使用しています。
これらの使い方も説明します。

◇使用ウィジェット

ウィジェットは基本的にインスタンスを作成し、表示テキストや表示方法などを指定して使用します。

今回使用しているウィジェットです。

パッケージ ウィジェット 用途 見た目
tkinter Frame
フレーム
ウィジェットの受け皿
tkinter Label
ラベル
文字の表示
tkinter Button
ボタン
押すと処理が動くボタン
tkinter Scrollbar
スクロールバー
画面をスクロール
tkinter.ttk Treeview
ツリービュー
ツリー表示やリスト表示
ここではリスト表示で使用

ウィジェットに変数を関連付ける

ウィジェットの値と連動して変更される変数をウィジェット変数と呼びます。
今回は、ファイルパス用リストボックス、文字コード選択用コンボボックス、メッセージ表示用ラベルにウィジェット変数を関連付けます。

ウィジェット変数とウィジェットを関連付けるには、ウィジェットの属性 variable, textvariable にウィジェット変数を指定します。
ウィジェット変数のコンストラクタに value= 引数で初期値を与えられます。
ウィジェット変数の値の読み取りは get() メソッドで、値の書き込みは set() メソッドで行います。

他に、DoubleVar, BooleanVar があります。

【コード】
        self.msg = tk.StringVar(value="msg")
        self.lbl_msg = tk.Label(parent
                                , textvariable=self.msg
                                , justify=tk.LEFT
                                , font=("Fixedsys", 11)
                                , relief=tk.RIDGE
                                , anchor=tk.W)

◇画面表示の特徴

画面表示には次の特徴があります。

◇画面構成 - フレーム構造

プログラムを作るにあたって、どのような画面にするか決めなくてはなりません。画面構成を考えるということですね。
画面を構成するというのは、GUI 部品(ウィジェット)を画面に配置することです。
ウィジェットは設定したテキストに合わせてサイズが決まるので、そのまま使用しても画面構成上問題ないものと、広げないと見栄えが良くないものが出てきます。
ウィジェットを広げる場合、他のウィジェットとの位置関係で思ったように配置できないことがあります。
そのようなときに Frame ウィジェットを使用して区分けをして希望する位置に配置します。
引き出しの整理に使うトレイのようなイメージでしょうか。

今回の画面構成です。

  • u_frame(frame)
    • btn_f_sel(Button)
    • btn_select_all(Button)
    • btn_deselection(Button)
    • btn_preview(Button)
    • lbl_msg(Label)
  • b_frame(frame)
    • frame1(Frame)
      • treeview1(Treeview)
      • h_scrlbar(Scrollbar)
      • v_scrlbar(Scrollbar)

※「u_frame」などはプログラムで使用した変数名です。
※図形の中の矢印はこの後説明するオプション fill の方向です。

入力をする部分と出力をする部分で大きくフレームを分けています。
結果を表示する部分は余った領域をすべて使用してできるだけ大きく表示させるためです。

ウィジェットの配置 - pack

ウィジェットインスタンスを作成した後に配置 (pack) をしないと表示されません。
配置には他に grid と place がありますが、基本的な pack を使用します。
pack とは、スーツケースに荷物をパッキングするようなイメージなんじゃないかなと勝手に思っています。

ウィジェットを pack すると side オプションで指定された方向から配置します。
その時に配置した方向と直行する方向には可能な限りの領域を割り当てます。
例えば、デフォルトの TOP で配置すると直行する横方向に可能なだけ領域を確保します。横一杯になるということですね。
ただし、これは領域の話で、実際のウィジェットの表示は他のオプションに依ります。

  • 【構文】ウィジェット.pack(オプション1 = 設定値, オプション2 = 設定値,・・・)

  • 【主なオプション】

    オプション 説明 設定値
    side 次の方向から詰め込む TOP:上(デフォルト)、BOTTOM:下、LEFT:左、RIGHT:右
    fill 割り当て領域内での引き伸ばし NONE:なし(デフォルト)、X:水平方向、Y:垂直方向、BOTH:両方向
    expand 割り当て領域の詰め込み方向への拡大 True:する、False:しない(デフォルト)


その他のオプション

オプション 説明 設定値
after 指定ウィジェットの後に配置 他のウィジェット
before 指定ウィジェットの前に配置 他のウィジェット
anchor 割り当て領域内の配置位置 NW,N,NE,W,CENTER,E,SW,S,SE(方角)
in 配置する親
ipadx,ipady 内部に空ける間隔 間隔(単位無しはピクセル)
padx,pady 外側に空ける間隔 間隔(単位無しはピクセル)

pack で「よく表示されない」という問題が発生しますが、私もそうでした、pack の順番を変えてあげることで解決する場合があるようです。

必ずではないようですが、配置の時に次のことを考慮しておくと良いみたいです。

  • expand オプションを True に指定して pack するウィジェットは、後から pack する。

ウィジェットの pack についてはこちらのサイトが詳しいです。

◇動作をウィジェットに割り当てる - バインド

ウィジェットで何か操作した場合にそれに連動して動作するメソッドを割り当てることができます。
この動作(イベント)とメソッドを割り当てることをバインドと呼びます。
イベントには、キー入力可能なウィジェットでキー入力した場合やマウス操作した場合、コンボボックスで選択肢を選択した場合などがあります。

◎マウスバインドキーバインド

コンボボックスはエントリーを継承しているのでキー操作後に動作する処理を割り当てられます。
コンボボックスで Enter を押した時にCSV ファイルを読み込むようにバインドします。

マウスの左ボタンのクリックと右ボタンのダブルクリックに対して、それぞれチェックボックスのトグルと画像のプレビューを割り当てます。

修正:2021-12-22
  • 【構文】ウィジェット.bind(シーケンス, コールバック関数, 追加フラグ)

  • 引数

    • シーケンス:イベント名(例:<Return>, <Button-1>)
    • コールバック関数:関数には event 引数が必要
      ウィジェットの command オプションと共通の場合は、関数の引数定義を event=None とする
    • 追加フラグウィジェットに対してイベントを置き換えるか追加するか
      デフォルトは置き換え(引数を省略した場合)

▽バインド

        # bind
        self.treeview1.bind("<Button 1>", self.togle_checkbox) # マウスを左クリックしたときの動作
        self.treeview1.bind("<Double 3>", self.preview_image)  # マウスを右ダブルクリックしたときの動作

※引数が必要な場合はラムダ式を使用します。(詳細は省略)

▽コールバック関数

    def togle_checkbox(self, event=None):
        """
        チェックボックスの状態を反転
        """

    def preview_image(self, event=None, path=""):
        """
        画像のプレビュー
        ダイアログ表示

ドラッグアンドドロップの実装(TkinterDnD2の使い方)

解説は別記事を参照してください ■ドラッグアンドドロップの実装(TkinterDnD2の使い方) - CSV viewerアプリの作り方(ドラッグアンドドロップ)【Python】

◆表の実装(Treeviewウィジェットの使い方)

解説は別記事を参照してください ■表の実装(Treeviewウィジェットの使い方) - CSV viewerアプリの作り方(ドラッグアンドドロップ)【Python】

◆スクロールバーの設置

解説は別記事を参照してください ■スクロールバーの設置 - CSV viewerアプリの作り方(ドラッグアンドドロップ)【Python】

◆ファイル選択ダイアログ

解説は別記事を参照してください ■ファイル選択ダイアログ - CSV viewerアプリの作り方(ドラッグアンドドロップ)【Python】

◆必要なパッケージ

◇必要な Python パッケージ

  • pillow
  • TkinterDnD2

TkinterPythonの標準パッケージです。

◆全体のソース

全体のソースはこちらから取得できます。
取得先:GitHub juu7g/Python-Image-Viewer

◆バイナリ(アプリ)

pyinstaller でバイナリ( exe ) ファイルを作成しました。

  • 作成コマンド: pyinstaller -F -w --additional-hooks-dir . ファイル名
    ※実行には hook-tkinterdnd2.py ファイルが必要です(作り方は別記事の「pyinstaller使用時の注意」を参照)

◇バイナリ取得先

バイナリも公開します。
こちらから取得してください。Github からダウンロード

◇使い方

  • インストール

    ダウンロードした zip ファイルを任意のフォルダで解凍します

  • 実行

  • 操作

    • ドラッグアンドドロップでの操作
    • ファイル選択での操作
      • ファイル選択ボタンをクリックしファイルを選択
    • 画像の表示
      • 表示したい画像の行で右ボタンでダブルクリックします
      • 表のチェックボックスをオン(行をクリックで切り替わる)にして「プレビュー」ボタンをクリック
    • 画像の選択(チェックボックスのオン/オフ)
      • 選択したい画像の行でマウスクリック
        再度クリックすると選択解除
      • 「すべて選択」ボタンをクリックするとすべての画像を選択
      • 「選択解除」ボタンをクリックするとすべての画像の選択を解除
  • 画面の説明

    • 指定したファイルのサムネイルとファイル情報を表形式で表示します
    • サムネイル、ファイル名、画像の幅、高さ、ファイルサイズ、Exif情報(あれば)、撮影条件(あれば)、GPS情報(あれば)を表示します 更新:2022-12-12
  • アンインストール

    解凍したファイルをすべて削除します

◇使用上の注意

制限事項があります。

  • 制限事項
    • 表示できるのはファイルの拡張子が次のものだけです
      png, jpg, gif, webp
    • プレビューしたウィンドウを開いたままメインウィンドウで別の画像を開いた場合、プレビューウィンドウの画像が消えます

■更新情報

  • 2022-12-12
    • Exifの撮影条件を表示
追加:2022-12-12

◆さいごに

画像ツールのベースにするために画像ビューアを作成しました。

はじめは画像無しでファイラーにしようかと思ったのですが、画像用ファイラーで画像が見えないのは使えないなと思い、画像も表示することにしました。

Tkinter の Treeview で表の中に画像を埋め込むことができないと分かり、調べるとツリーカラムに表示できると分かり対応しました。

難しくないと思ったのですが、なかなか画像が出ず、列幅の設定や画像オブジェクトの持ち方などを試行錯誤して表示できるようになりました。
この記事を読んだ方がそんな苦労をしないことを祈ります。

基本的な機能を紹介されているサイトは多いです。いつも参考にさせていただいて助かっています。
でも、組み合わせるとまた別の苦労が始まります。
そんな苦労の一助になれば幸いです。

あわせて読みたい 📔 PillowでExif情報の撮影条件を取得【Python】 🔗
更新:2022-12-12

◇ご注意

本記事は次のバージョンの下で動作した内容を元に記述しています。

  • Python 3.8.5
  • Pillow 8.3.0
  • TkinterDnD2 0.3.0

◇免責事項

ご利用に際しては、『免責事項』をご確認ください。
お気づきの点がございましたら『お問い合わせ』からお問い合わせください。
追加:2022-12-12

◆参考

投稿: 、更新: