Pythonで作る画像ビューア&編集アプリ(Tkinter×Pillow)
拡大縮小・回転・明るさ/コントラスト調整・グレースケール・Undo/Redo無限段・ドラッグ&ドロップまで対応した画像ビューア兼編集アプリを作ります。動作する完全なソースコードを、機能ごとに分けて解説します。
1. このアプリでできること
このアプリは、単なる画像ビューアではなく、簡単な画像編集もできる実用的なツールです。Pythonの tkinter でGUIを、Pillow(PIL)で画像処理を担当します。
複数画像の閲覧
同じフォルダ内の画像を「前へ/次へ」で切り替えられます。
拡大・縮小・回転
ボタンとスライダーでサイズと角度を自由に変更できます。
明るさ・コントラスト
スライダーでリアルタイムに補正結果を確認できます。
Undo / Redo 無限段
編集履歴を自由に行き来。スライダー値もまとめて復元します。
ドラッグ&ドロップ
ウィンドウに画像を放り込むだけで開けます。
グレースケール
ワンクリックで白黒化。元に戻すのも簡単です。
2. 必要なライブラリとインストール
tkinter は標準ライブラリなので追加不要です。画像処理の Pillow と、ドラッグ&ドロップ用の tkinterdnd2 をインストールします。
pip install pillow tkinterdnd2
ドラッグ&ドロップは tkinterdnd2 が必要ですが、本記事のコードは「未インストールでも、開くボタンからは使える」ようにフォールバックを入れてあります。まずは Pillow だけでも試せます。
Pillow の基本的な使い方を先に押さえたい人は、よく使うPythonライブラリ50選や、よりシンプルな画像エディタ(基本版)も参考になります。
3. 設計の考え方(重要)
画像編集アプリで最初に決めるべきは「編集結果をどう持つか」です。回転した画像にさらに明るさを掛けて…と編集を上書きし続けると、Undoや再調整のたびに画質が劣化し、状態の管理も複雑になります。
そこで本アプリは、元画像(original_image)は絶対に変更せず、回転角・拡大率・明るさ・コントラスト・白黒化といった「パラメータ」だけを保持します。表示するたびに、元画像へパラメータを順番に適用して表示用画像を作り直す方式です。
① 何度補正しても元画質から再計算するので劣化しない ② Undo/Redoが「画像のコピー」ではなく「数個のパラメータ」の保存で済むため軽い ③ スライダー値もまとめて復元できる。シンプルなのに実務的です。
4. 完成版ソースコード(全文)
まずは全体です。長く見えますが、ほとんどがUIの組み立てと、各ボタン・スライダーの処理です。次の章で機能ごとに切り出して解説します。
image_viewer.pyimport os
import tkinter as tk
from tkinter import filedialog
from PIL import Image, ImageTk, ImageEnhance
# ドラッグ&ドロップは tkinterdnd2 が必要(未導入でも開くボタンは使える)
try:
from tkinterdnd2 import TkinterDnD, DND_FILES
DND_AVAILABLE = True
except ImportError:
DND_AVAILABLE = False
EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif")
class ImageViewer:
def __init__(self, root):
self.root = root
self.root.title("画像ビューア&簡易編集アプリ")
self.root.configure(bg="white")
# 状態
self.image_paths = [] # 同じフォルダ内の画像一覧
self.index = 0
self.original_image = None # 読み込んだ元画像(変更しない)
self.display_image = None # 表示・保存用
self.undo_stack = []
self.redo_stack = []
# 編集パラメータ
self.angle_var = tk.DoubleVar(value=0)
self.brightness_var = tk.DoubleVar(value=1.0)
self.contrast_var = tk.DoubleVar(value=1.0)
self.scale = 1.0
self.grayscale = False
self._build_ui()
if DND_AVAILABLE:
self.root.drop_target_register(DND_FILES)
self.root.dnd_bind("<<Drop>>", self.on_drop)
# ---------- UI 構築 ----------
def _build_ui(self):
bar = tk.Frame(self.root, bg="white")
bar.pack(fill="x", padx=10, pady=(10, 0))
buttons = [
("開く", self.open_dialog), ("◀ 前へ", self.prev_image), ("次へ ▶", self.next_image),
("+拡大", lambda: self.zoom(1.1)), ("-縮小", lambda: self.zoom(0.9)),
("グレースケール", self.toggle_grayscale),
("↶ Undo", self.undo), ("↷ Redo", self.redo), ("保存", self.save_image),
]
for col, (text, cmd) in enumerate(buttons):
tk.Button(bar, text=text, command=cmd).grid(row=0, column=col, padx=3)
sliders = tk.Frame(self.root, bg="white")
sliders.pack(fill="x", padx=10, pady=5)
self._add_slider(sliders, "回転", self.angle_var, -180, 180, 1, 0)
self._add_slider(sliders, "明るさ", self.brightness_var, 0.1, 2.0, 0.1, 1)
self._add_slider(sliders, "コントラスト", self.contrast_var, 0.1, 2.0, 0.1, 2)
sliders.columnconfigure(1, weight=1)
self.canvas = tk.Label(self.root, bg="white")
self.canvas.pack(padx=10, pady=10)
def _add_slider(self, parent, label, var, lo, hi, res, row):
tk.Label(parent, text=label, bg="white", width=10, anchor="e").grid(row=row, column=0, sticky="e")
scale = tk.Scale(parent, from_=lo, to=hi, resolution=res, orient="horizontal",
variable=var, command=lambda e: self.apply_adjustments())
scale.grid(row=row, column=1, sticky="we")
# スライダーを離した瞬間に履歴を保存(Undo/Redo対象にする)
scale.bind("<ButtonRelease-1>", lambda e: self.push_state())
# ---------- 画像の読み込み ----------
def open_dialog(self):
path = filedialog.askopenfilename(filetypes=[("画像ファイル", "*.png *.jpg *.jpeg *.bmp *.gif")])
if path:
self.load_folder(path)
self.load_image(path)
def load_folder(self, path):
folder = os.path.dirname(path)
self.image_paths = sorted(
os.path.join(folder, f) for f in os.listdir(folder) if f.lower().endswith(EXTS)
)
self.index = self.image_paths.index(path) if path in self.image_paths else 0
def load_image(self, path):
self.original_image = Image.open(path).convert("RGB")
self.reset_adjustments()
self.undo_stack = [self.current_state()] # 履歴を初期化
self.redo_stack = []
self.apply_adjustments()
def reset_adjustments(self):
self.angle_var.set(0)
self.brightness_var.set(1.0)
self.contrast_var.set(1.0)
self.scale = 1.0
self.grayscale = False
# ---------- 補正の一括適用と表示 ----------
def apply_adjustments(self):
if self.original_image is None:
return
img = self.original_image
img = img.rotate(-self.angle_var.get(), expand=True) # 回転
if self.scale != 1.0: # 拡大縮小
w, h = img.size
img = img.resize((max(1, int(w * self.scale)), max(1, int(h * self.scale))))
img = ImageEnhance.Brightness(img).enhance(self.brightness_var.get()) # 明るさ
img = ImageEnhance.Contrast(img).enhance(self.contrast_var.get()) # コントラスト
if self.grayscale: # グレースケール
img = img.convert("L").convert("RGB")
self.display_image = img
self.update_canvas()
def update_canvas(self):
self.tk_image = ImageTk.PhotoImage(self.display_image)
self.canvas.config(image=self.tk_image)
# ---------- 編集ボタン ----------
def zoom(self, rate):
self.scale *= rate
self.apply_adjustments()
self.push_state()
def toggle_grayscale(self):
self.grayscale = not self.grayscale
self.apply_adjustments()
self.push_state()
# ---------- 前へ / 次へ ----------
def prev_image(self):
if self.image_paths:
self.index = (self.index - 1) % len(self.image_paths)
self.load_image(self.image_paths[self.index])
def next_image(self):
if self.image_paths:
self.index = (self.index + 1) % len(self.image_paths)
self.load_image(self.image_paths[self.index])
# ---------- Undo / Redo ----------
def current_state(self):
return {
"angle": self.angle_var.get(), "brightness": self.brightness_var.get(),
"contrast": self.contrast_var.get(), "scale": self.scale, "grayscale": self.grayscale,
}
def push_state(self):
# 直前と同じ状態なら積まない(スライダーの連続発火対策)
if self.undo_stack and self.undo_stack[-1] == self.current_state():
return
self.undo_stack.append(self.current_state())
self.redo_stack = []
def restore(self, state):
self.angle_var.set(state["angle"])
self.brightness_var.set(state["brightness"])
self.contrast_var.set(state["contrast"])
self.scale = state["scale"]
self.grayscale = state["grayscale"]
self.apply_adjustments()
def undo(self):
if len(self.undo_stack) > 1:
self.redo_stack.append(self.undo_stack.pop())
self.restore(self.undo_stack[-1])
def redo(self):
if self.redo_stack:
state = self.redo_stack.pop()
self.undo_stack.append(state)
self.restore(state)
# ---------- ドラッグ&ドロップ ----------
def on_drop(self, event):
path = event.data.strip("{}") # 空白を含むパスは {} で囲まれる
if os.path.isfile(path):
self.load_folder(path)
self.load_image(path)
# ---------- 保存 ----------
def save_image(self):
if self.display_image is None:
return
path = filedialog.asksaveasfilename(
defaultextension=".png", filetypes=[("PNG", "*.png"), ("JPEG", "*.jpg")])
if path:
self.display_image.save(path)
if __name__ == "__main__":
root = TkinterDnD.Tk() if DND_AVAILABLE else tk.Tk()
app = ImageViewer(root)
root.mainloop()
5. 機能ごとのコード解説
5-1. 画像の読み込みとフォルダ一覧
画像を1枚開いたら、同じフォルダ内の画像も一覧化しておくのがポイントです。これで「前へ/次へ」での切り替えが自然にできます。
def load_folder(self, path):
folder = os.path.dirname(path)
self.image_paths = sorted(
os.path.join(folder, f) for f in os.listdir(folder) if f.lower().endswith(EXTS)
)
self.index = self.image_paths.index(path) if path in self.image_paths else 0
def load_image(self, path):
self.original_image = Image.open(path).convert("RGB")
self.reset_adjustments()
self.undo_stack = [self.current_state()]
self.redo_stack = []
self.apply_adjustments()
Image.open() で読み込み、convert("RGB") でモードを揃えています。元画像は original_image に保管し、以降は触りません。読み込み時に履歴(undo_stack)も初期状態1件でリセットします。
5-2. 補正の一括適用と表示
このアプリの心臓部です。表示するたびに、元画像にパラメータを順番に適用して表示用画像を作り直します。
def apply_adjustments(self):
if self.original_image is None:
return
img = self.original_image
img = img.rotate(-self.angle_var.get(), expand=True)
if self.scale != 1.0:
w, h = img.size
img = img.resize((max(1, int(w * self.scale)), max(1, int(h * self.scale))))
img = ImageEnhance.Brightness(img).enhance(self.brightness_var.get())
img = ImageEnhance.Contrast(img).enhance(self.contrast_var.get())
if self.grayscale:
img = img.convert("L").convert("RGB")
self.display_image = img
self.update_canvas()
def update_canvas(self):
self.tk_image = ImageTk.PhotoImage(self.display_image)
self.canvas.config(image=self.tk_image)
ImageTk.PhotoImage は self.tk_image のようにインスタンス変数として保持しないと、ガベージコレクションで消えて画像が真っ白になります。tkinter初心者が必ず一度はハマる落とし穴です。
5-3. 回転・拡大縮小・明るさ・コントラスト
回転は rotate(-angle, expand=True)。expand=True にすると、回転後に画像がはみ出さず全体が表示されます。拡大縮小は scale 倍率を掛けるだけ。明るさ・コントラストは Pillow の ImageEnhance を使います。
img = ImageEnhance.Brightness(img).enhance(self.brightness_var.get())
img = ImageEnhance.Contrast(img).enhance(self.contrast_var.get())
enhance(1.0) が元のまま、1.0 より大きいと強く、小さいと弱くなります。スライダーの command から apply_adjustments() を呼んでいるので、つまみを動かすたびにリアルタイムで反映されます。
5-4. グレースケール変換
if self.grayscale:
img = img.convert("L").convert("RGB")
convert("L") で白黒(グレースケール)にし、すぐ convert("RGB") に戻しています。RGBに戻すことで、その後の明るさ補正など他の処理と干渉しません。toggle_grayscale() で True/False を切り替えるだけなので、白黒化の解除も一発です。
5-5. Undo / Redo(無限段)
本アプリのUndo/Redoは、画像そのものではなく「パラメータの組」をスタックに積みます。軽いうえに、スライダー値まで正確に復元できます。
def push_state(self):
if self.undo_stack and self.undo_stack[-1] == self.current_state():
return # 直前と同じなら積まない
self.undo_stack.append(self.current_state())
self.redo_stack = []
def undo(self):
if len(self.undo_stack) > 1:
self.redo_stack.append(self.undo_stack.pop())
self.restore(self.undo_stack[-1])
「いま積んだ状態」と「直前の状態」が同じならスキップすることで、スライダーの連続発火による無駄な履歴を防いでいます。undo_stack の先頭(読み込み直後の状態)は常に残すため、len > 1 を条件にしています。
5-6. ドラッグ&ドロップ対応
if DND_AVAILABLE:
self.root.drop_target_register(DND_FILES)
self.root.dnd_bind("<<Drop>>", self.on_drop)
def on_drop(self, event):
path = event.data.strip("{}") # 空白を含むパスは {} で囲まれる
if os.path.isfile(path):
self.load_folder(path)
self.load_image(path)
tkinterdnd2 を使うと、ウィンドウにファイルを放り込んだときに <<Drop>> イベントが飛んできます。Windowsでは空白を含むパスが波括弧 {} で囲まれて渡るため、strip("{}") で外しています。起動時に TkinterDnD.Tk() を使う点にも注意してください。
6. つまずきやすいポイント
- 画像が真っ白になる:
PhotoImageをローカル変数にすると消えます。必ずself.tk_imageのように保持しましょう。 - ドラッグ&ドロップが効かない:
tkinterdnd2が未導入か、tk.Tk()ではなくTkinterDnD.Tk()で起動できているか確認を。 - 大きな画像で動作が重い:高解像度の写真は、表示前に一度ほどよいサイズへ縮小しておくと軽くなります。
- 回転で画像が切れる:
rotate()にexpand=Trueを付け忘れていないか確認しましょう。
7. 発展・関連アプリ
動かせたら、次のような拡張に挑戦してみましょう。
- セピア・ネガポジ反転などのフィルター追加(
ImageOpsが便利) - 文字入れ・透かし(
ImageDraw/ImageFont) - 複数画像の一括リサイズ・形式変換
もっとシンプルな構成から学びたい場合は画像エディタ(基本版)を、ほかのGUIアプリに挑戦したい場合は中級者向けアプリ100本もどうぞ。使用ライブラリの整理にはライブラリ一覧が役立ちます。
8. 快適に学習するためのPC環境
画像処理は、特に高解像度の写真を扱うとメモリとCPUを使います。回転やリサイズをスライダーでリアルタイムに反映するため、非力なPCだと操作がもたつくことがあります。
これからPythonでGUIや画像処理の学習を進めるなら、CPUは Core i5 以上、メモリは 16GB 以上、SSD搭載のPCがあると快適です。学習用PCの選び方はPython学習におすすめのPCで用途別にまとめています。
当サイトは現在、特定の製品へのアフィリエイトリンクは掲載していません。PC紹介は学習のしやすさという観点での一般的な目安です。
9. よくある質問(FAQ)
Q. 画像が真っ白で表示されません。
ImageTk.PhotoImage を self.tk_image のようにインスタンス変数として保持していないと、ガベージコレクションで破棄され、画像が表示されません。ローカル変数にせず、必ずインスタンスに参照を残してください。tkinterで画像を扱うときの定番のハマりどころです。
Q. tkinterdnd2 が無いとドラッグ&ドロップは使えませんか。
ドラッグ&ドロップには tkinterdnd2 が必要ですが、本記事のコードは未導入でもエラーにならず「開く」ボタンから利用できます。pip install tkinterdnd2 で導入し、起動を TkinterDnD.Tk() にするとD&Dが有効になります。
Q. 対応している画像形式は何ですか。
Pillowが扱える形式(PNG・JPEG・BMP・GIFなど)に対応しています。本記事のフォルダ一覧機能は .png / .jpg / .jpeg / .bmp / .gif を対象にしているので、ほかの形式も並べたい場合は定数 EXTS に拡張子を追加してください。
Q. 編集した画像を元のファイルに上書き保存できますか。
save_image は保存ダイアログで別名保存する作りです。上書きしたい場合は読み込み時のパスをそのまま save() へ渡すよう変更できますが、元画像が失われるため、安全のため別名保存をおすすめします。
Q. 大きな画像で動作が重いです。
高解像度の写真は、表示前に一度ほどよいサイズへ縮小しておくと軽くなります。apply_adjustments の中で、表示用に最大幅を制限する処理を足すのも有効です(元画像は保持したままなので画質は劣化しません)。
Q. Undo / Redo はどこまで戻せますか。
回転・拡大率・明るさ・コントラスト・白黒化といったパラメータの履歴をすべてスタックに積むため、メモリが許す限り何段でも戻せます。画像そのものをコピーして積まない設計なので、無限段でも軽量に動きます。
Q. セピアやネガポジ反転などのフィルターを追加したいです。
Pillowの ImageOps を使うと簡単です。ネガポジ反転は ImageOps.invert(img)、セピアはグレースケール化したあと各画素に茶系の色を乗せる方法が定番です。apply_adjustments の最後にフィルター処理を足し、ボタンやフラグで切り替えるようにすれば、本記事のパラメータ方式にそのまま組み込め、Undo / Redo にも自然に対応できます。文字入れをしたいときは ImageDraw と ImageFont が便利です。
10. 参考リンク(公式ドキュメント)
本記事で使ったライブラリの一次情報です。各メソッドの詳細な引数や、さらに高度な画像処理は公式ドキュメントを参照してください。
- Pillow(PIL Fork)公式ドキュメント — 画像の読み込み・変換・保存
- Pillow ImageEnhance リファレンス — 明るさ・コントラスト補正
- tkinterdnd2(PyPI 公式ページ) — ドラッグ&ドロップ対応
- tkinter — GUIツールキット(Python公式・標準ライブラリ)
11. まとめ
Tkinter+Pillowで、画像ビューア兼簡易編集アプリを作りました。設計のカギは、元画像を変更せず、パラメータだけを保持して毎回作り直すこと。これにより、画質を落とさずに何度でも再調整でき、Undo/Redoも「数個のパラメータの保存」という軽い実装で済みました。
拡大縮小・回転・補正・グレースケール・Undo/Redo・ドラッグ&ドロップと、実用アプリに必要な要素がひと通り詰まっています。コードと画面が直結しているので、機能を1つずつ足しながら、自分だけの画像編集アプリに育てていけます。
本記事のコードはMITライセンスで自由に利用できます。実際に動作する構成をもとに解説していますが、環境によって挙動が異なる場合があるため、お手元での動作確認をおすすめします。なお本記事は、生成AIを活用して下書きし、運営者が内容を確認・編集したうえで公開しています。