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画像の読み込み