見出し画像

Pythonで画像ビューワーを作ってみた

関西に野鳥撮影が好きな友人がいるのですが、鳥を見つけると高速で連写するので1日撮影すると、2000-3000枚は軽く超えるそうです。なので、自宅に戻ってからが大変。その中からベストショットを見つける作業は、さながら砂漠に落とした針を見つけるような、地道な作業になるようです

そこで、少しでもその作業を効率化できないかと思っているそうで、まずは画像認識を使って鳥が写っていない「失敗写真」を削除するプログラムを作りたいのだそうな

画像処理の話で面白そうなので私も一緒に作業をすることに。。まずは試しに、下記のようなPythonのプログラムを作ってみました。ChatGPT(4o : 有料版)を使って、作成、デバッグを行っています

概略の要件定義と、Pythonのスクリプトはこんな感じです。Windows10の環境では、1フォルダに421個以上のファイルが存在すると、サムネイルが正常に表示されなくなるというバグがありますので、直し方の分かる方、教えていただけると嬉しいです。ChatGPTで試行錯誤したのですが、うまくいきませんでした。。(汗)

プロジェクト名

画像サムネイルビューアアプリ

概要

フォルダ内の画像ファイルをサムネイル形式で一覧表示し、選択した画像を拡大表示できるデスクトップアプリケーション。

機能要件

  • フォルダ内の画像をサムネイルで一覧表示

  • フォルダのツリー構造を表示し、選択可能

  • サムネイルをクリックして画像を拡大表示

  • 拡大画像のズーム機能(マウスホイール対応)

  • 拡大画像のドラッグ操作

  • ダブルクリックで画像のリセット表示

非機能要件

  • Pythonベースのシンプルなデスクトップアプリ

  • 対応フォーマット: JPG, PNG, BMP, TIF, TIFF, RAW


import os
from tkinter import Tk, Frame, Label, Scrollbar, Canvas, Y, BOTH, LEFT, RIGHT, TOP, BOTTOM, X
from tkinter import ttk, filedialog
from PIL import Image, ImageTk
import rawpy
import time

class ThumbnailApp:
    def __init__(self, root):
        self.root = root
        self.root.title("フォルダとサムネイルビューア")
        self.root.geometry("1920x1080+0+0")  # ウィンドウの位置とサイズを指定

        # 初期ズーム倍率とオフセット
        self.zoom_scale = 1.0
        self.offset_x = 0
        self.offset_y = 0

        # ドラッグ用変数
        self.drag_start_x = None
        self.drag_start_y = None

        # 上部: フォルダー選択ボタン
        self.button_frame = Frame(root)
        self.button_frame.pack(side=TOP, fill=X)

        self.folder_button = ttk.Button(self.button_frame, text="フォルダーを選択", command=self.select_folder)
        self.folder_button.pack(side=LEFT, padx=10, pady=5)

        # 上部: サムネイル表示エリア
        self.thumbnail_frame = Frame(root, bg="white", height=150)
        self.thumbnail_frame.pack(side=TOP, fill=X)

        self.canvas = Canvas(self.thumbnail_frame, bg="white", height=150)
        self.scrollbar_thumbnail = Scrollbar(self.thumbnail_frame, orient="horizontal", command=self.canvas.xview)
        self.scrollbar_thumbnail.pack(side=BOTTOM, fill=X)

        self.canvas.pack(side=TOP, fill=BOTH, expand=True)
        self.canvas.configure(xscrollcommand=self.scrollbar_thumbnail.set)

        self.inner_frame = Frame(self.canvas, bg="white")
        self.canvas.create_window((0, 0), window=self.inner_frame, anchor="nw")
        self.inner_frame.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))

        # 左側: フォルダツリービュー
        self.tree_frame = Frame(root, width=300)
        self.tree_frame.pack(side=LEFT, fill=Y)

        self.folder_tree = ttk.Treeview(self.tree_frame)
        self.folder_tree.pack(side=LEFT, fill=Y, expand=True)

        self.scrollbar = Scrollbar(self.tree_frame, orient="vertical", command=self.folder_tree.yview)
        self.scrollbar.pack(side=RIGHT, fill=Y)
        self.folder_tree.configure(yscrollcommand=self.scrollbar.set)

        # 右側: 拡大画像表示エリア
        self.large_image_frame = Frame(root, bg="gray")
        self.large_image_frame.pack(side=RIGHT, fill=BOTH, expand=True)

        self.large_image_label = Label(self.large_image_frame, bg="gray")
        self.large_image_label.pack(expand=True)

        # ツリーイベントをバインド
        self.folder_tree.bind("<<TreeviewSelect>>", self.on_folder_select)

        # 選択されたファイルの管理
        self.selected_files = set()
        self.current_displayed_frame = None

        # 現在表示されている画像
        self.current_image = None
        self.current_image_path = None

        # イベントのバインド
        self.large_image_label.bind("<MouseWheel>", self.on_zoom)
        self.large_image_label.bind("<ButtonPress-1>", self.on_drag_start)
        self.large_image_label.bind("<B1-Motion>", self.on_drag_move)
        self.large_image_label.bind("<Double-1>", self.reset_zoom)

        # 初回のフォルダ選択
        self.select_folder()

    def select_folder(self):
        folder_path = filedialog.askdirectory(title="フォルダを選択")
        if folder_path:
            # ツリービューをクリアして再構築
            for item in self.folder_tree.get_children():
                self.folder_tree.delete(item)

            root_node = self.folder_tree.insert("", "end", text=folder_path, open=True)
            self.load_folders(root_node, folder_path)

            # フォルダ選択後にサムネイルを表示
            self.display_thumbnails(folder_path)

    def load_folders(self, parent, path):
        try:
            for folder_name in os.listdir(path):
                folder_path = os.path.join(path, folder_name)
                if os.path.isdir(folder_path):
                    node = self.folder_tree.insert(parent, "end", text=folder_name, open=False)
                    self.load_folders(node, folder_path)
        except PermissionError:
            pass

    def on_folder_select(self, event):
        selected_item = self.folder_tree.selection()[0]
        folder_path = self.get_full_path(selected_item)
        self.display_thumbnails(folder_path)

    def get_full_path(self, node):
        path = self.folder_tree.item(node, "text")
        parent_node = self.folder_tree.parent(node)
        while parent_node:
            parent_text = self.folder_tree.item(parent_node, "text")
            path = os.path.join(parent_text, path)
            parent_node = self.folder_tree.parent(parent_node)
        return path

    def display_thumbnails(self, folder_path):
        for widget in self.inner_frame.winfo_children():
            widget.destroy()

        self.selected_files.clear()
        self.current_displayed_frame = None
        self.current_image = None
        self.current_image_path = None

        supported_extensions = [".jpg", ".jpeg", ".bmp", ".png", ".tif", ".tiff", ".raw"]
        image_files = [f for f in os.listdir(folder_path) if os.path.splitext(f.lower())[1] in supported_extensions]

        if not image_files:
            Label(self.inner_frame, text="画像ファイルが見つかりません。").pack()
            self.clear_large_image()
            return

        for i, image_file in enumerate(image_files):
            img_path = os.path.join(folder_path, image_file)
            img = self.open_image(img_path)
            if img:
                img.thumbnail((100, 100))
                img_tk = ImageTk.PhotoImage(img)

                frame = Frame(self.inner_frame, relief="solid", borderwidth=1, bg="white", padx=5, pady=5)
                frame.grid(row=0, column=i, padx=10, pady=10)

                thumbnail_label = Label(frame, image=img_tk, bg="white")
                thumbnail_label.image = img_tk
                thumbnail_label.pack()

                filename_label = Label(frame, text=image_file, wraplength=100, justify="center", bg="white")
                filename_label.pack()

                self.bind_click_events(frame, img_path)

                # 最初の画像に赤枠を付け、拡大画像として表示
                if i == 0:
                    frame.config(highlightbackground="red", highlightthickness=2)
                    self.current_displayed_frame = frame
                    self.display_large_image(img_path)

    def clear_large_image(self):
        self.large_image_label.config(image='', text="")
        self.large_image_label.image = None
        self.current_image = None
        self.current_image_path = None

    def open_image(self, img_path):
        try:
            ext = os.path.splitext(img_path.lower())[1]
            if ext == ".raw":
                with rawpy.imread(img_path) as raw:
                    img = raw.postprocess()
                    return Image.fromarray(img)
            else:
                return Image.open(img_path)
        except Exception as e:
            print(f"画像を開く際にエラーが発生しました ({img_path}): {e}")
            return None

    def bind_click_events(self, widget, file_path):
        def right_click(event):
            if file_path in self.selected_files:
                self.selected_files.remove(file_path)
                widget.config(bg="white")
            else:
                self.selected_files.add(file_path)
                widget.config(bg="lightblue")
            print("選択されたファイル:", self.selected_files)

        def left_click(event):
            if self.current_displayed_frame and self.current_displayed_frame != widget:
                self.current_displayed_frame.config(highlightbackground="black", highlightthickness=0)
            widget.config(highlightbackground="red", highlightthickness=2)
            self.current_displayed_frame = widget
            self.display_large_image(file_path)

        widget.bind("<Button-3>", right_click)
        widget.bind("<Button-1>", left_click)
        for child in widget.winfo_children():
            child.bind("<Button-3>", right_click)
            child.bind("<Button-1>", left_click)

    def display_large_image(self, img_path):
        try:
            img = self.open_image(img_path)
            if img:
                self.current_image_path = img_path
                self.current_image = img
                self.fit_to_window()
        except Exception as e:
            print(f"画像を開く際にエラーが発生しました: {e}")

    def fit_to_window(self):
        if self.current_image:
            frame_width = self.large_image_frame.winfo_width()
            frame_height = self.large_image_frame.winfo_height()
            img_width, img_height = self.current_image.size

            scale_x = frame_width / img_width
            scale_y = frame_height / img_height
            self.zoom_scale = min(scale_x, scale_y)

            self.offset_x = (frame_width - img_width * self.zoom_scale) // 2
            self.offset_y = (frame_height - img_height * self.zoom_scale) // 2
            self.show_image()

    def show_image(self):
        if self.current_image:
            # スケーリングした画像を生成
            scaled_width = int(self.current_image.size[0] * self.zoom_scale)
            scaled_height = int(self.current_image.size[1] * self.zoom_scale)
            img_resized = self.current_image.resize((scaled_width, scaled_height), Image.LANCZOS)

            # Tkinter 用の画像に変換
            img_tk = ImageTk.PhotoImage(img_resized)

            # 画像を配置
            self.large_image_label.config(image=img_tk)
            self.large_image_label.image = img_tk
            self.large_image_label.place(x=self.offset_x, y=self.offset_y)

    def on_zoom(self, event):
        # 画像の中心位置(画面上の座標)を計算
        frame_width = self.large_image_frame.winfo_width()
        frame_height = self.large_image_frame.winfo_height()
        center_x = frame_width / 2
        center_y = frame_height / 2

        # 中心点の画像内のピクセル座標を計算
        center_x_in_image = (center_x - self.offset_x) / self.zoom_scale
        center_y_in_image = (center_y - self.offset_y) / self.zoom_scale

        # ズーム倍率を計算
        zoom_factor = 1.1 if event.delta > 0 else 0.9
        new_zoom_scale = self.zoom_scale * zoom_factor

        # 新しいオフセットを計算(中心を維持)
        self.offset_x = center_x - center_x_in_image * new_zoom_scale
        self.offset_y = center_y - center_y_in_image * new_zoom_scale

        # ズーム倍率を更新
        self.zoom_scale = new_zoom_scale

        # 画像を再描画
        self.show_image()

    def reset_zoom_center(self):
        if hasattr(self, 'last_wheel_time') and time.time() - self.last_wheel_time > 0.2:
            self.zoom_center_x = None
            self.zoom_center_y = None

    def reset_zoom(self, event=None):
        # ウィンドウと画像のサイズを取得
        frame_width = self.large_image_frame.winfo_width()
        frame_height = self.large_image_frame.winfo_height()
        img_width, img_height = self.current_image.size

        # フィッティングするズーム倍率を計算
        scale_x = frame_width / img_width
        scale_y = frame_height / img_height
        self.zoom_scale = min(scale_x, scale_y)

        # フィッティング後のオフセットを計算
        self.offset_x = (frame_width - img_width * self.zoom_scale) // 2
        self.offset_y = (frame_height - img_height * self.zoom_scale) // 2

        # 画像を再描画
        self.show_image()
    
    def on_drag_start(self, event):
        # マウスのグローバル座標を保存
        self.drag_start_x = event.x_root
        self.drag_start_y = event.y_root

    def on_drag_move(self, event):
        # マウス移動量を計算(グローバル座標で)
        dx = (event.x_root - self.drag_start_x) / self.zoom_scale
        dy = (event.y_root - self.drag_start_y) / self.zoom_scale

        # オフセットを更新
        self.offset_x += dx
        self.offset_y += dy

        # 現在の座標を次回用に保存
        self.drag_start_x = event.x_root
        self.drag_start_y = event.y_root

        # 画像を再描画
        self.show_image()

if __name__ == "__main__":
    root = Tk()
    app = ThumbnailApp(root)
    root.mainloop()

詳細な要件定義も書いておきますね。

プロジェクト名

画像サムネイルビューアアプリ

概要

選択されたフォルダ内の画像をサムネイル形式で表示し、直感的な操作で拡大画像を確認できるデスクトップアプリケーション。画像整理や簡易閲覧を目的としたツールです。


機能要件

1. フォルダ選択機能

  • 任意のフォルダを選択可能。

  • フォルダツリー構造を表示し、サブフォルダにアクセス可能。

2. サムネイル表示機能

  • フォルダ内の画像をサムネイル形式で表示。

  • サムネイルには画像名を表示。

  • 対応フォーマット:
    JPG, JPEG, BMP, PNG, TIF, TIFF, RAW。

3. 画像選択と表示機能

  • サムネイルを左クリックで拡大画像を右側エリアに表示。

  • サムネイルを右クリックで選択状態に(複数選択対応)。

4. 拡大画像操作機能

  • ズーム: マウスホイールで拡大縮小。

  • ドラッグ: 拡大画像をマウスで移動。

  • リセット: ダブルクリックで拡大画像を元のサイズにリセット。

5. エラー処理

  • 読み込みエラーや非対応フォーマットの場合に通知を表示。

  • アクセス権がないフォルダの例外処理を実装。


非機能要件

1. パフォーマンス

  • 最大500枚のサムネイルをスムーズに表示。

  • RAW画像処理は若干時間を要するが許容範囲内。

2. 動作環境

  • 対応OS: Windows, macOS

  • 必要ライブラリ:
    Tkinter, Pillow, rawpy

3. ユーザーインターフェース

  • シンプルかつ直感的に操作可能なGUI。

  • 全画面表示対応で快適な閲覧。


技術スタック

  • プログラミング言語: Python 3.8以上

  • ライブラリ:

    • Tkinter: GUI構築

    • Pillow: 画像操作

    • rawpy: RAW画像の読み込み

いいなと思ったら応援しよう!