Python

Pythonで作る!Tkinter画像ビューア&簡易編集アプリ完全解説 (App002)

tyamada

PythonでGUIアプリを作ってみたい。でも、なにから作ればいいのかわからない……。
そんな方におすすめなのが「画像ビューア&簡易編集アプリ」です。

Pythonは、初心者でも始めやすく、応用範囲が広いプログラミング言語です。
特にGUIアプリ開発や画像処理を学ぶ場合、TkinterとPillowの組み合わせは学習効率が非常に高く、実用性のあるアプリも短時間で作成できます。

この記事では、Pythonを使った「Tkinter画像ビューア&簡易編集アプリ」の作り方から機能の解説、学習メリット、そして快適に学習するためのPC環境まで詳しく紹介します。


1. アプリの概要と特徴

今回作成するアプリは、単なる画像ビューアではなく、簡単な画像編集機能も備えた実用的なツールです。

主な機能

  • 複数画像の閲覧
    前へ / 次へボタンで同じフォルダ内の画像を切り替え
  • 拡大・縮小・任意角度回転
    スライダーとボタンで操作可能
  • 明るさ・コントラスト調整
    リアルタイムで編集結果を確認可能
  • グレースケール変換
    ワンクリックで白黒化
  • Undo / Redo 無限段
    編集履歴を自由に行き来
  • ドラッグ&ドロップ対応
    ファイルをウィンドウに放り込むだけで自動で開く

これにより、日常の画像確認から簡単な加工まで幅広く利用できます。

クリックしてソースコードを全て表示
import tkinter as tk
from tkinter import filedialog, ttk, messagebox
from tkinterdnd2 import DND_FILES, TkinterDnD
from PIL import Image, ImageTk, ImageEnhance
import os
import glob


class ImageViewer:
    def __init__(self, root):
        self.root = root
        self.root.title("画像ビューア")
        self.root.geometry("900x650")
        self.root.configure(bg="white")

        # 現在の状態
        self.current_image = None
        self.display_image = None
        self.image_path = None
        self.image_list = []

        # Undo/Redo(画像+スライダー値)
        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.build_menu()
        self.build_ui()

        # ドラッグ&ドロップ
        self.root.drop_target_register(DND_FILES)
        self.root.dnd_bind("<<Drop>>", self.drop_files)

    # ======================================================
    # メニューバー
    # ======================================================
    def build_menu(self):
        menubar = tk.Menu(self.root)

        filemenu = tk.Menu(menubar, tearoff=0)
        filemenu.add_command(label="開く", command=self.open_file)
        filemenu.add_command(label="保存", command=self.save_image)
        filemenu.add_separator()
        filemenu.add_command(label="終了", command=self.root.quit)
        menubar.add_cascade(label="ファイル", menu=filemenu)

        self.root.config(menu=menubar)

    # ======================================================
    # UI構築
    # ======================================================
    def build_ui(self):
        # 画像表示
        self.canvas = tk.Label(self.root, bg="white")
        self.canvas.pack(fill=tk.BOTH, expand=True)

        ctrl = tk.Frame(self.root, bg="white")
        ctrl.pack(fill=tk.X, pady=5)

        ttk.Button(ctrl, text="前へ", command=self.prev_image).pack(side=tk.LEFT, padx=5)
        ttk.Button(ctrl, text="次へ", command=self.next_image).pack(side=tk.LEFT, padx=5)

        ttk.Button(ctrl, text="縮小", command=lambda: self.scale_image(0.8)).pack(side=tk.LEFT, padx=10)
        ttk.Button(ctrl, text="拡大", command=lambda: self.scale_image(1.2)).pack(side=tk.LEFT, padx=10)
        ttk.Button(ctrl, text="グレースケール", command=self.to_gray).pack(side=tk.LEFT, padx=10)

        # Undo/Redo
        ttk.Button(ctrl, text="Undo", command=self.undo).pack(side=tk.RIGHT, padx=10)
        ttk.Button(ctrl, text="Redo", command=self.redo).pack(side=tk.RIGHT, padx=10)

        # 角度
        rotate_frame = tk.Frame(self.root, bg="white")
        rotate_frame.pack(fill=tk.X)
        tk.Label(rotate_frame, text="角度", bg="white").pack(side=tk.LEFT)
        tk.Scale(rotate_frame, variable=self.angle_var, from_=-180, to=180,
                 orient="horizontal", command=self.rotate_image).pack(fill=tk.X, padx=10)

        # 明るさ
        bright_frame = tk.Frame(self.root, bg="white")
        bright_frame.pack(fill=tk.X)
        tk.Label(bright_frame, text="明るさ", bg="white").pack(side=tk.LEFT)
        tk.Scale(bright_frame, variable=self.brightness_var, from_=0.1, to=2.0,
                 resolution=0.1, orient="horizontal",
                 command=self.update_brightness).pack(fill=tk.X, padx=10)

        # コントラスト
        contrast_frame = tk.Frame(self.root, bg="white")
        contrast_frame.pack(fill=tk.X)
        tk.Label(contrast_frame, text="コントラスト", bg="white").pack(side=tk.LEFT)
        tk.Scale(contrast_frame, variable=self.contrast_var, from_=0.1, to=2.0,
                 resolution=0.1, orient="horizontal",
                 command=self.update_contrast).pack(fill=tk.X, padx=10)

    # ======================================================
    # D&D
    # ======================================================
    def drop_files(self, event):
        path = event.data.strip("{}")
        path = os.path.abspath(path)
        self.load_image(path)
        self.make_file_list(path)

    # ======================================================
    # ファイルを開く
    # ======================================================
    def open_file(self):
        path = filedialog.askopenfilename(
            filetypes=[("画像ファイル", "*.png;*.jpg;*.jpeg;*.bmp")]
        )
        if path:
            path = os.path.abspath(path)
            self.load_image(path)
            self.make_file_list(path)

    # ======================================================
    # 同じフォルダの画像一覧
    # ======================================================
    def make_file_list(self, path):
        folder = os.path.dirname(os.path.abspath(path))
        exts = ("*.png", "*.jpg", "*.jpeg", "*.bmp")
        files = []
        for e in exts:
            files.extend(glob.glob(os.path.join(folder, e)))
        self.image_list = sorted(os.path.abspath(f) for f in files)

    # ======================================================
    # 保存
    # ======================================================
    def save_image(self):
        if self.display_image is None:
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".png",
            filetypes=[("PNG", "*.png"), ("JPEG", "*.jpg"), ("BMP", "*.bmp")]
        )
        if path:
            self.display_image.save(path)
            messagebox.showinfo("保存", "保存しました。")

    # ======================================================
    # 前へ / 次へ
    # ======================================================
    def prev_image(self):
        if not self.image_list:
            return
        idx = self.image_list.index(self.image_path)
        idx = (idx - 1) % len(self.image_list)
        self.load_image(self.image_list[idx])

    def next_image(self):
        if not self.image_list:
            return
        idx = self.image_list.index(self.image_path)
        idx = (idx + 1) % len(self.image_list)
        self.load_image(self.image_list[idx])

    # ======================================================
    # Undo / Redo(画像 + スライダー値保存)
    # ======================================================
    def push_undo(self):
        if self.display_image is not None:
            state = {
                "image": self.display_image.copy(),
                "angle": self.angle_var.get(),
                "brightness": self.brightness_var.get(),
                "contrast": self.contrast_var.get(),
            }
            self.undo_stack.append(state)
            self.redo_stack.clear()

    def undo(self):
        if not self.undo_stack:
            return

        # 今の状態を redo へ
        cur_state = {
            "image": self.display_image.copy(),
            "angle": self.angle_var.get(),
            "brightness": self.brightness_var.get(),
            "contrast": self.contrast_var.get(),
        }
        self.redo_stack.append(cur_state)

        # 復元
        state = self.undo_stack.pop()
        self.display_image = state["image"]
        self.angle_var.set(state["angle"])
        self.brightness_var.set(state["brightness"])
        self.contrast_var.set(state["contrast"])
        self.show_image()

    def redo(self):
        if not self.redo_stack:
            return

        # 今の状態を undo へ
        cur_state = {
            "image": self.display_image.copy(),
            "angle": self.angle_var.get(),
            "brightness": self.brightness_var.get(),
            "contrast": self.contrast_var.get(),
        }
        self.undo_stack.append(cur_state)

        # 復元
        state = self.redo_stack.pop()
        self.display_image = state["image"]
        self.angle_var.set(state["angle"])
        self.brightness_var.set(state["brightness"])
        self.contrast_var.set(state["contrast"])
        self.show_image()

    # ======================================================
    # 画像読み込み
    # ======================================================
    def load_image(self, path):
        path = os.path.abspath(path)
        self.image_path = path
        self.current_image = Image.open(path)
        self.display_image = self.current_image.copy()

        # Undo/Redo 初期化
        self.undo_stack.clear()
        self.redo_stack.clear()

        # スライダー初期化
        self.angle_var.set(0)
        self.brightness_var.set(1.0)
        self.contrast_var.set(1.0)

        self.show_image()

    # ======================================================
    # 表示処理
    # ======================================================
    def show_image(self):
        if self.display_image is None:
            return
        img = self.display_image.copy()

        # 表示領域に収める
        w, h = img.size
        max_w, max_h = 850, 550
        r = min(max_w / w, max_h / h, 1)
        img = img.resize((int(w * r), int(h * r)))

        self.tk_img = ImageTk.PhotoImage(img)
        self.canvas.config(image=self.tk_img)

    # ======================================================
    # 編集機能
    # ======================================================
    def scale_image(self, rate):
        if self.display_image:
            self.push_undo()
            w, h = self.display_image.size
            self.display_image = self.display_image.resize((int(w * rate), int(h * rate)))
            self.show_image()

    def to_gray(self):
        if self.display_image:
            self.push_undo()
            self.display_image = self.display_image.convert("L").convert("RGB")
            self.show_image()

    def rotate_image(self, event=None):
        if self.current_image:
            self.push_undo()
            angle = self.angle_var.get()
            self.display_image = self.current_image.rotate(-angle, expand=True)
            self.show_image()

    def update_brightness(self, event=None):
        if self.current_image:
            self.push_undo()
            enhancer = ImageEnhance.Brightness(self.current_image)
            self.display_image = enhancer.enhance(self.brightness_var.get())
            self.show_image()

    def update_contrast(self, event=None):
        if self.current_image:
            self.push_undo()
            enhancer = ImageEnhance.Contrast(self.current_image)
            self.display_image = enhancer.enhance(self.contrast_var.get())
            self.show_image()


# ======================================================
# メイン
# ======================================================
root = TkinterDnD.Tk()
app = ImageViewer(root)
root.mainloop()

2. アプリの使い方

2-1. 画像を開く

  • メニューバー「ファイル → 開く」で任意の画像を選択
  • もしくは、ウィンドウに画像ファイルをドラッグ&ドロップ
  • 同じフォルダ内の他画像も自動でリスト化され、前へ / 次へボタンで切り替え可能

2-2. 画像編集

  • 拡大 / 縮小:倍率ボタンでサイズ変更
  • 回転:スライダーで任意角度に回転
  • 明るさ / コントラスト:スライダーで調整
  • グレースケール:ボタンひとつで白黒化
  • Undo / Redo:編集履歴を無限に戻したり進めたり可能

これらの操作はリアルタイムで反映されるため、画像加工の感覚を体験しながら学習できます。


3. ソースコードの概要

アプリ全体は ImageViewer クラス にまとめられています。
ここでは、各機能のコード例とその解説を示します。


3-1. クラス構造

class ImageViewer:
    def __init__(self, root):
        self.root = root
        self.root.title("画像ビューア")
        self.root.configure(bg="white")
  • クラスでアプリ全体を管理
  • root にTkinterウィンドウを渡してUIを構築
  • 背景色を白に設定し、見やすいUIに

3-2. 画像読み込み

def load_image(self, path):
    self.current_image = Image.open(path)
    self.display_image = self.current_image.copy()
    self.update_canvas()
  • Pillow の Image.open() で画像を読み込み
  • display_image にコピーを作ることで編集用と元画像を分ける
  • update_canvas() で画面に表示

ポイント
コピーを使うことでUndo/Redoや各種補正を安全に適用可能。

画像:画像読み込み直後のスクリーンショットを挿入

3-3. 画像表示

def update_canvas(self):
    self.tk_image = ImageTk.PhotoImage(self.display_image)
    self.canvas.config(image=self.tk_image)
  • ImageTk.PhotoImage でTkinterに表示可能な形式に変換
  • Label ウィジェットの image に設定するだけで簡単に表示
  • スライダー操作や回転後も同じ関数で再描画可能

3-4. 拡大 / 縮小

def scale_image(self, rate):
    w, h = self.display_image.size
    self.display_image = self.display_image.resize((int(w*rate), int(h*rate)))
    self.update_canvas()
  • resize() を使い、倍率 rate に応じてサイズ変更
  • Undo/Redoでは元のサイズ情報も保存しておく
画像:拡大縮小操作中のスクリーンショットを挿入

3-5. 回転

def rotate_image(self):
    angle = self.angle_var.get()
    self.display_image = self.current_image.rotate(-angle, expand=True)
    self.update_canvas()
  • スライダーの値で回転角を取得
  • expand=True で回転後も全体が表示される
  • Undo/Redo では角度情報も保存して復元可能

3-6. 明るさ・コントラスト

enhancer = ImageEnhance.Brightness(self.current_image)
self.display_image = enhancer.enhance(self.brightness_var.get())

enhancer = ImageEnhance.Contrast(self.display_image)
self.display_image = enhancer.enhance(self.contrast_var.get())
  • ImageEnhance を使い、明るさ・コントラストを個別に補正
  • スライダー値と連動しリアルタイムで変更
  • Undo/Redoではスライダー値も保存して正確に復元

3-7. グレースケール変換

def convert_grayscale(self):
    self.display_image = self.display_image.convert("L").convert("RGB")
    self.update_canvas()
  • convert("L") で白黒化
  • さらに convert("RGB") に戻すことで、他編集機能と干渉せず使用可能

3-8. Undo / Redo

def save_state(self):
    self.undo_stack.append({
        "image": self.display_image.copy(),
        "angle": self.angle_var.get(),
        "brightness": self.brightness_var.get(),
        "contrast": self.contrast_var.get()
    })

def undo(self):
    if self.undo_stack:
        state = self.undo_stack.pop()
        self.redo_stack.append(state)
        self.restore_state(state)

def restore_state(self, state):
    self.display_image = state["image"].copy()
    self.angle_var.set(state["angle"])
    self.brightness_var.set(state["brightness"])
    self.contrast_var.set(state["contrast"])
    self.update_canvas()
  • 編集前の状態をスタックに保存
  • Undo/Redoでは画像とスライダー値を復元
  • 無限段対応なので何回でも戻せる

3-9. ドラッグ&ドロップ対応

self.root.drop_target_register(DND_FILES)
self.root.dnd_bind("<<Drop>>", self.drop_files)
  • tkinterdnd2 を使用
  • 複数ファイルもドラッグ&ドロップで開ける
  • ファイルパス取得後、load_image() に渡して表示

3-10. 保存機能

def save_image(self):
    path = filedialog.asksaveasfilename(filetypes=[("PNG", "*.png"), ("JPEG", "*.jpg")])
    if path:
        self.display_image.save(path)
  • ファイルダイアログで保存先を指定
  • Pillow の save() で形式を選択可能
  • 編集後の画像を手軽に出力できる

このアプリを作ることで、Python初心者は次のようなスキルを効率的に身につけられます。

  1. コードと画面が直結
    → 変更を即座に確認できるため理解が早い
  2. Tkinterの基本と応用
    → ウィジェット・レイアウト・イベント処理の実践的学習
  3. Pillowによる画像処理
    → 実際の画像編集を通じて、画像形式や補正の仕組みを体感
  4. Undo / Redo実装
    → 履歴管理や状態復元など、実務的な設計スキルを習得
  5. D&Dや複数画像処理の経験
    → GUIアプリ開発に必要なイベント駆動型設計を学べる

これらの経験は、他のGUIアプリ開発やデータ分析ツール制作にも応用可能です。


5. 学習を快適にするPC環境

GUIアプリや画像処理を快適に学ぶには、性能の十分なPCを用意しておくと効率が格段に上がります。

推奨スペック

項目推奨
CPUCore i5以上
メモリ16GB以上
ストレージSSD 512GB以上
ディスプレイFHD以上

ノートPC例

  • 持ち運び可能で学習しやすい
  • 高解像度ディスプレイでGUI操作が快適

デスクトップPC例

  • 大画面で複数画像を同時に操作可能
  • メモリやストレージを増設して本格的な学習環境に

性能が高めのPCを使うことで、スライダー操作や画像回転などのリアルタイム処理もストレスなく行えるため、学習効率や集中力が向上します。


6. アプリの拡張アイデア

学習用として作成したこのアプリは、以下のような拡張も可能です。

  • 文字入れ・透かし追加
  • フィルター効果(セピア・ネガポジ反転)
  • 複数画像の一括サイズ変更
  • ファイル形式の変換
  • GUIデザインの改善(テーマ・アイコン変更)

こうした拡張を行うことで、Python GUIアプリ制作の応用力も自然に高まります。


7. まとめ

  • Tkinter + Pillowで、画像ビューア&簡易編集アプリを作ることは、Python学習に最適
  • Undo / Redoやスライダー連動など、実務的な設計スキルも習得可能
  • GUIアプリを作ることで、コードと操作画面が直結し理解が深まる
  • 高性能PCを使うと、操作もスムーズになり学習効率が向上
  • 作ったアプリは、自分で機能追加やカスタマイズも可能で成長体験としても優秀

この記事を参考に、PythonでのGUIアプリ開発や画像処理学習を始めてみましょう。
少しずつ機能を拡張しながら、自分だけのオリジナルアプリを作る楽しみも広がります。


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

ABOUT ME
tyamada
tyamada
普通の会社員(平)
記事URLをコピーしました