Pythonで作る直感操作可能な画像編集アプリ【初心者向け解説付き】

プログラミング

はじめに

PythonとTkinterを使って、簡単に操作できる画像編集アプリを作ってみました。
このアプリは、ドラッグ操作で背景画像や文字・スタンプを自由に配置できることが特徴です。
さらに、Undo/Redoや初期化機能、文字やスタンプの拡大・回転も対応しているので、自由に編集できます。



主な機能の詳細

1. 背景画像操作

  • 画像を開くとキャンバスに表示されます
  • マウスドラッグで移動
  • キャンバス外に移動した部分は、保存時には自動的に切り取られます(不要な余白を含まない)

2. 文字・スタンプの追加

  • 文字追加:フォント、サイズ、色を指定可能
  • スタンプ追加:png/jpg画像を自由に貼り付け
  • ドラッグ操作で配置
  • Shiftキー + ドラッグ → 拡大縮小
  • Ctrlキー + ドラッグ → 回転

3. 編集機能

  • 明るさ、コントラスト、彩度をスライダーで調整
  • 回転(90°)、左右反転
  • ぼかし、シャープ、モノクロ、セピアなどのエフェクト

4. 操作補助

  • Undo / Redo:操作の取り消し・やり直し
  • 戻る:一つ前の状態に戻る
  • 初期化:キャンバスを白紙状態に戻す
  • 確定配置:文字やスタンプを画像に統合して保存可能

アプリの画面と操作

画面構成

  • 上部:キャンバス(編集領域)
  • 下部:操作ボタン・スライダー
  • シンプルで大きめのボタンで直感的に操作可能

操作手順例

  1. 「開く」ボタンで編集したい画像を選択
  2. 「文字追加」でタイトル文字を入力
  3. マウスで文字をドラッグして配置
  4. Shiftキーを押しながら文字をドラッグ → 大きさ調整
  5. Ctrlキーを押しながら文字をドラッグ → 回転
  6. 「スタンプ追加」で png 画像を追加
  7. 明るさや彩度スライダーで画像を調整
  8. Undo/Redoで操作をやり直す
  9. 「保存」ボタンで最終的な画像を保存

技術的ポイント

  • Tkinter:GUI作成
  • Pillow:画像操作・加工
  • ImageTk.PhotoImage:Tkinterに画像を表示
  • オブジェクト管理:文字やスタンプをリストで保持してドラッグ操作に対応
  • キャンバス外切り取り:保存時にキャンバスサイズに合わせて新しい画像を作成

Pythonコードの工夫

  • Undo/Redoは 履歴管理で最大20ステップ
  • 文字・スタンプは オブジェクト辞書で位置・角度・拡大率を管理
  • 保存時には キャンバスサイズに合わせて余白切り取り
  • シンプルなUIで操作が迷わないように設計

import tkinter as tk
from tkinter import filedialog, simpledialog, messagebox
from PIL import Image, ImageTk, ImageEnhance, ImageFilter, ImageDraw, ImageFont

# ---------------------------
# グローバル変数
# ---------------------------
img = None
img_display = None
tk_img = None
history = []
redo_stack = []
MAX_DISPLAY_SIZE = (800, 600)
MAX_HISTORY = 20

temp_objects = []
drag_data = {"item": None, "x":0, "y":0, "mode":None, "type":None}
last_state = None  # 一つ前に戻る用
img_offset = [0,0]  # 背景画像の移動用
CANVAS_SIZE = (800, 600)

# ---------------------------
# 画像操作
# ---------------------------
def open_image():
    global img, img_display, history, redo_stack, temp_objects, last_state, img_offset
    path = filedialog.askopenfilename(filetypes=[("画像ファイル","*.png;*.jpg;*.jpeg")])
    if path:
        img = Image.open(path).convert("RGBA")
        img_display = img.copy()
        history = [img.copy()]
        redo_stack.clear()
        temp_objects.clear()
        last_state = img_display.copy()
        img_offset[:] = [0,0]
        update_canvas()

def save_image():
    commit_objects()  # 保存前に確定
    if img_display:
        path = filedialog.asksaveasfilename(defaultextension=".png",
                                            filetypes=[("PNG","*.png"),("JPEG","*.jpg;*.jpeg")])
        if path:
            img_display.save(path)
            messagebox.showinfo("保存", f"画像を保存しました\n{path}")

def add_history():
    global history, redo_stack, last_state
    history.append(img_display.copy())
    if len(history) > MAX_HISTORY:
        history.pop(0)
    redo_stack.clear()
    last_state = img_display.copy()

def undo():
    global img_display, img_offset
    if len(history) > 1:
        redo_stack.append(history.pop())
        img_display = history[-1].copy()
        img_offset[:] = [0,0]
        update_canvas()

def redo():
    global img_display
    if redo_stack:
        img_display = redo_stack.pop()
        history.append(img_display.copy())
        update_canvas()

def revert_last():
    global img_display, last_state
    if last_state:
        img_display, last_state = last_state, img_display.copy()
        update_canvas()

def reset_all():
    global img, img_display, history, redo_stack, temp_objects, last_state, img_offset
    img_display = Image.new("RGBA", CANVAS_SIZE, "white")
    img = img_display.copy()
    history = [img.copy()]
    redo_stack.clear()
    temp_objects.clear()
    last_state = img_display.copy()
    img_offset[:] = [0,0]
    update_canvas()

# ---------------------------
# 編集関数
# ---------------------------
def adjust_brightness(val):
    global img_display
    if img:
        enhancer = ImageEnhance.Brightness(img)
        img_display = enhancer.enhance(float(val))
        add_history()
        update_canvas()

def adjust_contrast(val):
    global img_display
    if img:
        enhancer = ImageEnhance.Contrast(img)
        img_display = enhancer.enhance(float(val))
        add_history()
        update_canvas()

def adjust_color(val):
    global img_display
    if img:
        enhancer = ImageEnhance.Color(img)
        img_display = enhancer.enhance(float(val))
        add_history()
        update_canvas()

def rotate_left():
    global img_display, img
    if img_display:
        img_display = img_display.rotate(90, expand=True)
        img = img_display.copy()
        add_history()
        update_canvas()

def flip_horizontal():
    global img_display, img
    if img_display:
        img_display = img_display.transpose(Image.FLIP_LEFT_RIGHT)
        img = img_display.copy()
        add_history()
        update_canvas()

def apply_blur():
    global img_display, img
    if img_display:
        img_display = img_display.filter(ImageFilter.GaussianBlur(2))
        img = img_display.copy()
        add_history()
        update_canvas()

def apply_sharpen():
    global img_display, img
    if img_display:
        img_display = img_display.filter(ImageFilter.SHARPEN)
        img = img_display.copy()
        add_history()
        update_canvas()

def apply_grayscale():
    global img_display, img
    if img_display:
        img_display = img_display.convert("L").convert("RGBA")
        img = img_display.copy()
        add_history()
        update_canvas()

def apply_sepia():
    global img_display, img
    if img_display:
        sepia_img = img_display.convert("RGB")
        pixels = sepia_img.load()
        for y in range(sepia_img.height):
            for x in range(sepia_img.width):
                r,g,b = pixels[x,y]
                tr = int(0.393*r+0.769*g+0.189*b)
                tg = int(0.349*r+0.686*g+0.168*b)
                tb = int(0.272*r+0.534*g+0.131*b)
                pixels[x,y]=(min(tr,255),min(tg,255),min(tb,255))
        img_display = sepia_img.convert("RGBA")
        img = img_display.copy()
        add_history()
        update_canvas()

# ---------------------------
# Canvas 更新
# ---------------------------
def update_canvas():
    global tk_img
    if img_display:
        display_img = img_display.copy()
        display_img.thumbnail(MAX_DISPLAY_SIZE)
        tk_img = ImageTk.PhotoImage(display_img)
        canvas.delete("all")
        canvas.create_image(img_offset[0], img_offset[1], anchor="nw", image=tk_img)
        for obj in temp_objects:
            if obj["type"]=="text":
                obj_id = canvas.create_text(obj["x"], obj["y"], text=obj["content"]["text"],
                                            font=obj["content"]["font"], fill=obj["content"]["fill"])
                obj["id"] = obj_id
            elif obj["type"]=="stamp":
                obj_id = canvas.create_image(obj["x"], obj["y"], image=obj["tk"], anchor="nw")
                obj["id"] = obj_id

# ---------------------------
# 文字・スタンプ追加
# ---------------------------
def add_text():
    text = simpledialog.askstring("文字追加","文字を入力")
    if text:
        size = simpledialog.askinteger("サイズ","文字サイズを入力",initialvalue=30)
        color = simpledialog.askstring("色","文字色を入力",initialvalue="red")
        font = ("Arial", size)
        temp_objects.append({"type":"text","content":{"text":text,"font":font,"fill":color},"x":50,"y":50,"id":None,"angle":0,"scale":1.0})
        update_canvas()

def add_stamp():
    path = filedialog.askopenfilename(filetypes=[("画像ファイル","*.png;*.jpg;*.jpeg")])
    if path:
        img_s = Image.open(path).convert("RGBA")
        tk_s = ImageTk.PhotoImage(img_s)
        temp_objects.append({"type":"stamp","content":{"img":img_s},"tk":tk_s,"x":50,"y":50,"id":None,"angle":0,"scale":1.0})
        update_canvas()

# ---------------------------
# Canvas マウス操作
# ---------------------------
def on_click(event):
    global drag_data
    mod = "move"
    if event.state & 0x0001:
        mod = "scale"
    elif event.state & 0x0004:
        mod = "rotate"
    drag_data["mode"] = mod
    drag_data["item"] = None
    # 文字/スタンプ判定
    for obj in temp_objects[::-1]:
        x, y = obj["x"], obj["y"]
        if obj["type"]=="text":
            if abs(event.x - x)<50 and abs(event.y - y)<30:
                drag_data.update({"item":obj,"x":event.x,"y":event.y,"type":"obj"})
                return
        elif obj["type"]=="stamp":
            w,h = obj["tk"].width(), obj["tk"].height()
            if x<=event.x<=x+w and y<=event.y<=y+h:
                drag_data.update({"item":obj,"x":event.x,"y":event.y,"type":"obj"})
                return
    # 背景画像判定
    if img_display:
        drag_data.update({"item":"bg","x":event.x,"y":event.y,"type":"bg"})

def on_drag(event):
    if drag_data["item"]:
        dx = event.x - drag_data["x"]
        dy = event.y - drag_data["y"]
        if drag_data["type"]=="obj":
            obj = drag_data["item"]
            mode = drag_data["mode"]
            if mode=="move":
                obj["x"] += dx
                obj["y"] += dy
            elif mode=="scale":
                obj["scale"] *= 1 + dy/100.0
                if obj["scale"]<0.1: obj["scale"]=0.1
            elif mode=="rotate":
                obj["angle"] += dx
        elif drag_data["type"]=="bg":
            img_offset[0] += dx
            img_offset[1] += dy
        drag_data["x"] = event.x
        drag_data["y"] = event.y
        update_canvas()

def on_release(event):
    drag_data["item"] = None
    drag_data["mode"] = None

def commit_objects():
    global img_display, img, temp_objects, img_offset
    # キャンバスサイズで新規作成
    canvas_w, canvas_h = CANVAS_SIZE
    new_img = Image.new("RGBA", (canvas_w, canvas_h), "white")
    # 背景画像をキャンバス内に貼る(オフセット適用)
    new_img.paste(img_display, (img_offset[0], img_offset[1]), img_display)
    # 文字・スタンプを貼る
    draw = ImageDraw.Draw(new_img)
    for obj in temp_objects:
        if obj["type"]=="text":
            fnt = ImageFont.truetype("arial.ttf", int(obj["content"]["font"][1]*obj["scale"]))
            draw.text((obj["x"], obj["y"]), obj["content"]["text"], fill=obj["content"]["fill"], font=fnt)
        elif obj["type"]=="stamp":
            im = obj["content"]["img"].copy()
            if obj["scale"] != 1.0:
                w,h = im.size
                im = im.resize((int(w*obj["scale"]), int(h*obj["scale"])), Image.ANTIALIAS)
            if obj["angle"] != 0:
                im = im.rotate(obj["angle"], expand=True)
            new_img.paste(im, (obj["x"], obj["y"]), im)
    # 保存用に更新
    img_display = new_img
    img = img_display.copy()
    temp_objects.clear()
    img_offset[:] = [0,0]
    add_history()
    update_canvas()

# ---------------------------
# GUI作成
# ---------------------------
root = tk.Tk()
root.title("直感操作できる画像編集アプリ")

canvas = tk.Canvas(root, width=CANVAS_SIZE[0], height=CANVAS_SIZE[1], bg="gray")
canvas.pack(padx=10,pady=10)
canvas.bind("<Button-1>", on_click)
canvas.bind("<B1-Motion>", on_drag)
canvas.bind("<ButtonRelease-1>", on_release)

frame = tk.Frame(root)
frame.pack(pady=5)

font_btn = ("Arial", 16)

# ファイル操作
tk.Button(frame, text="開く", font=font_btn, command=open_image, width=10).grid(row=0,column=0,padx=3)
tk.Button(frame, text="保存", font=font_btn, command=save_image, width=10).grid(row=0,column=1,padx=3)
tk.Button(frame, text="Undo", font=font_btn, command=undo, width=10).grid(row=0,column=2,padx=3)
tk.Button(frame, text="Redo", font=font_btn, command=redo, width=10).grid(row=0,column=3,padx=3)
tk.Button(frame, text="戻る", font=font_btn, command=revert_last, width=10).grid(row=0,column=4,padx=3)
tk.Button(frame, text="初期化", font=font_btn, command=reset_all, width=10).grid(row=0,column=5,padx=3)

# 文字/スタンプ
tk.Button(frame, text="文字追加", font=font_btn, command=add_text, width=10).grid(row=1,column=0,padx=3)
tk.Button(frame, text="スタンプ追加", font=font_btn, command=add_stamp, width=10).grid(row=1,column=1,padx=3)
tk.Button(frame, text="確定配置", font=font_btn, command=commit_objects, width=10).grid(row=1,column=2,padx=3)

# スライダー
tk.Label(frame,text="明るさ", font=font_btn).grid(row=2,column=0)
tk.Scale(frame, from_=0.1,to=2.0,resolution=0.1,orient="horizontal",command=adjust_brightness,length=200).grid(row=2,column=1)
tk.Label(frame,text="コントラスト", font=font_btn).grid(row=2,column=2)
tk.Scale(frame, from_=0.1,to=2.0,resolution=0.1,orient="horizontal",command=adjust_contrast,length=200).grid(row=2,column=3)
tk.Label(frame,text="彩度", font=font_btn).grid(row=3,column=0)
tk.Scale(frame, from_=0.0,to=2.0,resolution=0.1,orient="horizontal",command=adjust_color,length=200).grid(row=3,column=1)

# エフェクト
tk.Button(frame, text="90°回転", font=font_btn, command=rotate_left, width=10).grid(row=4,column=0,padx=3)
tk.Button(frame, text="左右反転", font=font_btn, command=flip_horizontal, width=10).grid(row=4,column=1,padx=3)
tk.Button(frame, text="ぼかし", font=font_btn, command=apply_blur, width=10).grid(row=4,column=2,padx=3)
tk.Button(frame, text="シャープ", font=font_btn, command=apply_sharpen, width=10).grid(row=4,column=3,padx=3)
tk.Button(frame, text="モノクロ", font=font_btn, command=apply_grayscale, width=10).grid(row=5,column=0,padx=3)
tk.Button(frame, text="セピア", font=font_btn, command=apply_sepia, width=10).grid(row=5,column=1,padx=3)

root.mainloop()

ダウンロード

アプリのexeファイルは以下からダウンロード可能です。

  • Windows用 exe

注意:exeと同じフォルダにスタンプ画像やフォントファイルを置くと正しく動作します。

タイトルとURLをコピーしました