中級・GUIアプリ

Pythonで作る画像ビューア&編集アプリ(Tkinter×Pillow)

拡大縮小・回転・明るさ/コントラスト調整・グレースケール・Undo/Redo無限段・ドラッグ&ドロップまで対応した画像ビューア兼編集アプリを作ります。動作する完全なソースコードを、機能ごとに分けて解説します。

⏱️ 所要時間: 約40分 🎯 難易度: 中級 📦 Pillow / tkinterdnd2

1. このアプリでできること

このアプリは、単なる画像ビューアではなく、簡単な画像編集もできる実用的なツールです。Pythonの tkinter でGUIを、Pillow(PIL)で画像処理を担当します。

🖼️

複数画像の閲覧

同じフォルダ内の画像を「前へ/次へ」で切り替えられます。

🔍

拡大・縮小・回転

ボタンとスライダーでサイズと角度を自由に変更できます。

🎚️

明るさ・コントラスト

スライダーでリアルタイムに補正結果を確認できます。

↩️

Undo / Redo 無限段

編集履歴を自由に行き来。スライダー値もまとめて復元します。

🖱️

ドラッグ&ドロップ

ウィンドウに画像を放り込むだけで開けます。

グレースケール

ワンクリックで白黒化。元に戻すのも簡単です。

2. 必要なライブラリとインストール

tkinter は標準ライブラリなので追加不要です。画像処理の Pillow と、ドラッグ&ドロップ用の tkinterdnd2 をインストールします。

pip install pillow tkinterdnd2
ℹ️
tkinterdnd2 が無くても動きます

ドラッグ&ドロップは tkinterdnd2 が必要ですが、本記事のコードは「未インストールでも、開くボタンからは使える」ようにフォールバックを入れてあります。まずは Pillow だけでも試せます。

Pillow の基本的な使い方を先に押さえたい人は、よく使うPythonライブラリ50選や、よりシンプルな画像エディタ(基本版)も参考になります。

3. 設計の考え方(重要)

画像編集アプリで最初に決めるべきは「編集結果をどう持つか」です。回転した画像にさらに明るさを掛けて…と編集を上書きし続けると、Undoや再調整のたびに画質が劣化し、状態の管理も複雑になります。

そこで本アプリは、元画像(original_image)は絶対に変更せず、回転角・拡大率・明るさ・コントラスト・白黒化といった「パラメータ」だけを保持します。表示するたびに、元画像へパラメータを順番に適用して表示用画像を作り直す方式です。

💡
この方式のメリット

① 何度補正しても元画質から再計算するので劣化しない ② Undo/Redoが「画像のコピー」ではなく「数個のパラメータ」の保存で済むため軽い ③ スライダー値もまとめて復元できる。シンプルなのに実務的です。

4. 完成版ソースコード(全文)

まずは全体です。長く見えますが、ほとんどがUIの組み立てと、各ボタン・スライダーの処理です。次の章で機能ごとに切り出して解説します。

image_viewer.py
import 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)
⚠️
PhotoImageは変数に保持する

ImageTk.PhotoImageself.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.PhotoImageself.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 にも自然に対応できます。文字入れをしたいときは ImageDrawImageFont が便利です。

10. 参考リンク(公式ドキュメント)

本記事で使ったライブラリの一次情報です。各メソッドの詳細な引数や、さらに高度な画像処理は公式ドキュメントを参照してください。

11. まとめ

Tkinter+Pillowで、画像ビューア兼簡易編集アプリを作りました。設計のカギは、元画像を変更せず、パラメータだけを保持して毎回作り直すこと。これにより、画質を落とさずに何度でも再調整でき、Undo/Redoも「数個のパラメータの保存」という軽い実装で済みました。

拡大縮小・回転・補正・グレースケール・Undo/Redo・ドラッグ&ドロップと、実用アプリに必要な要素がひと通り詰まっています。コードと画面が直結しているので、機能を1つずつ足しながら、自分だけの画像編集アプリに育てていけます。

掲載コードについて

本記事のコードはMITライセンスで自由に利用できます。実際に動作する構成をもとに解説していますが、環境によって挙動が異なる場合があるため、お手元での動作確認をおすすめします。なお本記事は、生成AIを活用して下書きし、運営者が内容を確認・編集したうえで公開しています。