Pythonで画像をドラッグ&ドロップ!自動で角丸・透過PNGを作る方法
本記事では、Pythonを使ってGUIアプリケーションを開発し、画像ファイルをドラッグ&ドロップで読み込み、自動的に角丸や円形に加工し、透過PNGとして保存するツールを作成する方法を解説します。
PythonのGUIライブラリであるtkinterと、画像処理ライブラリPillow、そしてドラッグ&ドロップ機能を実現するtkinterdnd2を組み合わせることで、実用的で使いやすいデスクトップアプリケーションが構築できます。本記事で提供するコードを読み解き、カスタマイズすることで、あなた独自の画像処理ツール開発の第一歩を踏み出せるでしょう。
このツールでできること
今回作成するツール「Auto Rounded Transparent Icon Generator」は、以下のような機能を備えています。
- 画像ファイルをGUIウィンドウにドラッグ&ドロップして読み込める
- ファイルダイアログからも画像を選択して読み込める
- 読み込んだ画像を指定したサイズに自動調整(アスペクト比維持)
- 画像の角を丸く加工できる(半径指定可能)
- 画像を完全に円形に切り抜ける
- 角丸・円形加工された画像を透過PNGとして保存
- 出力サイズやファイル容量のプリセットモードを用意
- 処理状況やエラーメッセージをGUI上に表示
ブログ記事やWebサイトで使うアイコン、プレゼンテーション資料用の画像、SNSのプロフィール画像など、様々な用途で役立つこと間違いなしです。
プログラムの概要と目的
このプログラムは、ユーザーがGUI上で直感的に操作できる画像加工ツールを提供することを目的としています。特に、Webサイトやアプリケーション開発において頻繁に必要となる「角丸アイコン」や「透過画像」の手間を省き、効率化を図ります。
GUIはPython標準ライブラリのtkinterを使用し、モダンな外観にするためにttk(Themed Tkinter)を活用しています。ドラッグ&ドロップ機能は、別途tkinterdnd2ライブラリを追加することで実現しています。画像加工の中核部分はPillowライブラリが担い、リサイズ、アルファチャンネル操作、マスク処理による角丸加工、そしてPNG形式での保存を行います。
プログラムの全文
以下が、今回作成するPythonプログラムの全文です。このコードをコピーして、Python環境で実行することができます。
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image, ImageDraw, UnidentifiedImageError
import os
import sys
# tkinterdnd2 をインポート (フォールバック対応)
try:
from tkinterdnd2 import DND_FILES, TkinterDnD
except ImportError:
TkinterDnD = None
print("tkinterdnd2ライブラリが見つかりません。ドラッグアンドドロップ機能は無効になります。", file=sys.stderr)
print("インストールするには 'pip install tkinterdnd2' を実行してください。", file=sys.stderr)
# Pillow のリサンプリングフィルターをバージョン互換性考慮して設定
try:
LANCZOS_RESAMPLE = Image.Resampling.LANCZOS
except AttributeError:
try:
LANCZOS_RESAMPLE = Image.LANCZOS
except AttributeError:
LANCZOS_RESAMPLE = Image.ANTIALIAS # 最悪ANTIALIASを使う
class AutoRoundedTransparentIconGenerator:
def __init__(self, master_root):
self.master = master_root
self.master.title("Auto Rounded Transparent Icon Generator")
self.master.geometry("580x820")
self.style = ttk.Style()
self.style.theme_use('clam')
self.BG_COLOR = "white"
self.PRIMARY_COLOR = "#00529B"
self.SECONDARY_COLOR = "#0078D4"
self.LIGHT_BLUE_BG = "#F0F8FF"
self.TEXT_COLOR_ON_LIGHT_BG = self.PRIMARY_COLOR
self.TEXT_COLOR_ON_DARK_BG = "white"
self.BUTTON_ACTIVE_BG = "#003C75"
self.DISABLED_FG_COLOR = "#A0A0A0"
self.DISABLED_BG_COLOR = "#E0E0E0"
self.ERROR_COLOR = "red"
self.SUCCESS_COLOR = "green"
self.WARNING_COLOR = "#E67E00"
self.FONT_NORMAL = ("Segoe UI", 10)
self.FONT_BOLD = ("Segoe UI Bold", 11)
self.FONT_LARGE = ("Segoe UI", 14)
self.FONT_TITLE = ("Segoe UI Semibold", 16)
self.master.configure(bg=self.BG_COLOR)
self.style.configure("TFrame", background=self.BG_COLOR)
self.style.configure("TLabel", background=self.BG_COLOR, foreground=self.TEXT_COLOR_ON_LIGHT_BG, font=self.FONT_NORMAL)
self.style.configure("TLabelframe", background=self.BG_COLOR, bordercolor=self.SECONDARY_COLOR)
self.style.configure("TLabelframe.Label", background=self.BG_COLOR, foreground=self.PRIMARY_COLOR, font=self.FONT_BOLD)
self.style.configure("TButton", background=self.SECONDARY_COLOR, foreground=self.TEXT_COLOR_ON_DARK_BG,
font=self.FONT_BOLD, padding=6, relief="raised", borderwidth=1, bordercolor=self.PRIMARY_COLOR)
self.style.map("TButton", background=[('active', self.BUTTON_ACTIVE_BG), ('disabled', self.DISABLED_BG_COLOR)],
foreground=[('disabled', self.DISABLED_FG_COLOR)])
self.style.configure("TEntry", fieldbackground="white", foreground=self.TEXT_COLOR_ON_LIGHT_BG, insertcolor=self.TEXT_COLOR_ON_LIGHT_BG,
bordercolor=self.SECONDARY_COLOR, font=self.FONT_NORMAL)
self.style.map("TEntry", bordercolor=[('focus', self.PRIMARY_COLOR)], fieldbackground=[('readonly', "#F0F0F0")])
self.style.configure("TCheckbutton", background=self.BG_COLOR, foreground=self.TEXT_COLOR_ON_LIGHT_BG, font=self.FONT_NORMAL)
self.style.map("TCheckbutton",
indicatorcolor=[('selected', self.PRIMARY_COLOR), ('!selected', self.SECONDARY_COLOR)],
foreground=[('disabled', self.DISABLED_FG_COLOR)])
self.style.configure("TRadiobutton", background=self.BG_COLOR, foreground=self.TEXT_COLOR_ON_LIGHT_BG, font=self.FONT_NORMAL)
self.style.map("TRadiobutton",
indicatorcolor=[('selected', self.PRIMARY_COLOR), ('!selected', self.SECONDARY_COLOR)],
foreground=[('disabled', self.DISABLED_FG_COLOR)])
self.input_path = tk.StringVar()
self.output_path = tk.StringVar()
self.radius_var = tk.IntVar(value=25)
self.make_fully_round_var = tk.BooleanVar(value=False)
self.no_rounding_var = tk.BooleanVar(value=False)
self.MODES = {
"mode1": {"label": "標準 (1MB目標, 512x512px)", "max_size_mb": 1, "max_dims": (512, 512)},
"mode2": {"label": "高品質 (15MB目標, 1024x500px)", "max_size_mb": 15, "max_dims": (1024, 500)},
"custom": {"label": "カスタム (サイズ/容量 制限なし)", "max_size_mb": float('inf'), "max_dims": (float('inf'), float('inf'))}
}
self.output_mode_var = tk.StringVar(value="mode1")
self.dnd_enabled = TkinterDnD is not None
main_frame = ttk.Frame(self.master, padding="10")
main_frame.pack(expand=True, fill=tk.BOTH)
title_label = ttk.Label(main_frame, text="画像変換・角丸ツール", font=self.FONT_TITLE, foreground=self.PRIMARY_COLOR)
title_label.pack(pady=(10,15))
drop_frame_container = tk.Frame(main_frame, bg=self.LIGHT_BLUE_BG,
highlightbackground=self.PRIMARY_COLOR,
highlightthickness=2, relief="solid",
width=500, height=140)
drop_frame_container.pack(pady=(0,10), padx=10, fill="x")
drop_frame_container.pack_propagate(False)
self.drop_label_text = tk.StringVar()
drop_label_message = "ここに画像をドラッグ&ドロップ" if self.dnd_enabled else "ドラッグ&ドロップ無効"
self.drop_label_text.set(drop_label_message + "\nまたは下から選択")
drop_label = tk.Label(drop_frame_container, textvariable=self.drop_label_text,
font=self.FONT_LARGE, wraplength=480, justify="center",
bg=self.LIGHT_BLUE_BG, fg=self.TEXT_COLOR_ON_LIGHT_BG)
drop_label.pack(expand=True, fill="both", padx=10, pady=10)
if self.dnd_enabled:
drop_label.drop_target_register(DND_FILES)
drop_label.dnd_bind('<<Drop>>', self.handle_drop)
drop_frame_container.drop_target_register(DND_FILES)
drop_frame_container.dnd_bind('<<Drop>>', self.handle_drop)
else:
drop_label.config(text="D&D無効: tkinterdnd2未導入\n下からファイルを選択してください")
settings_frame = ttk.Frame(main_frame)
settings_frame.pack(pady=(5,0), padx=5, fill="x")
input_outer_frame = ttk.Frame(settings_frame)
input_outer_frame.pack(pady=(5,2), fill="x")
ttk.Label(input_outer_frame, text="入力画像:").pack(side=tk.LEFT, padx=(0,5))
self.input_entry = ttk.Entry(input_outer_frame, textvariable=self.input_path, width=45, state="readonly")
self.input_entry.pack(side=tk.LEFT, expand=True, fill="x", ipady=2, padx=(0,5))
ttk.Button(input_outer_frame, text="参照", command=self.select_input_file, width=6).pack(side=tk.LEFT)
output_outer_frame = ttk.Frame(settings_frame)
output_outer_frame.pack(pady=(2,5), fill="x")
ttk.Label(output_outer_frame, text="出力先: ").pack(side=tk.LEFT, padx=(0,5))
self.output_entry = ttk.Entry(output_outer_frame, textvariable=self.output_path, width=45, state="readonly")
self.output_entry.pack(side=tk.LEFT, expand=True, fill="x", ipady=2, padx=(0,5))
ttk.Button(output_outer_frame, text="参照", command=self.select_output_file, width=6).pack(side=tk.LEFT)
mode_options_frame = ttk.LabelFrame(main_frame, text="出力モード設定", padding="10")
mode_options_frame.pack(pady=10, padx=10, fill="x")
for mode_key, mode_props in self.MODES.items():
rb = ttk.Radiobutton(mode_options_frame, text=mode_props["label"], variable=self.output_mode_var,
value=mode_key, style="TRadiobutton", command=self.update_ui_states)
rb.pack(anchor=tk.W, pady=2)
self.radius_options_frame = ttk.LabelFrame(main_frame, text="角丸・形状設定", padding="10")
self.radius_options_frame.pack(pady=10, padx=10, fill="x")
self.no_rounding_check = ttk.Checkbutton(self.radius_options_frame, text="角丸処理をしない (元画像のまま出力)",
variable=self.no_rounding_var,
command=self.update_ui_states,
style="TCheckbutton")
self.no_rounding_check.pack(anchor=tk.W, pady=(0,5))
self.fully_round_check = ttk.Checkbutton(self.radius_options_frame, text="完全に丸くする (円形切り抜き)",
variable=self.make_fully_round_var,
command=self.update_ui_states,
style="TCheckbutton")
self.fully_round_check.pack(anchor=tk.W, pady=(0,5))
radius_slider_frame = ttk.Frame(self.radius_options_frame)
radius_slider_frame.pack(fill="x", pady=(0,5))
self.radius_label = ttk.Label(radius_slider_frame, text="角の丸み:", style="TLabel")
self.radius_label.pack(side=tk.LEFT, padx=(0,10))
self.radius_scale = tk.Scale(radius_slider_frame, from_=0, to_=200, orient=tk.HORIZONTAL,
variable=self.radius_var,
bg=self.BG_COLOR, fg=self.TEXT_COLOR_ON_LIGHT_BG, troughcolor=self.SECONDARY_COLOR,
highlightthickness=0, activebackground=self.PRIMARY_COLOR, sliderrelief="flat",
font=self.FONT_NORMAL, length=300)
self.radius_scale.pack(side=tk.LEFT, expand=True, fill="x")
button_frame = ttk.Frame(main_frame)
button_frame.pack(pady=(10,5), padx=10, fill="x")
self.process_button = ttk.Button(button_frame, text="画像を処理", command=self.process_image, style="TButton")
self.process_button.pack(expand=True, fill="x", ipady=5)
self.status_label = ttk.Label(main_frame, text="", font=self.FONT_NORMAL, anchor=tk.CENTER)
self.status_label.pack(pady=(5,10), fill="x")
self.update_process_button_state()
self.update_ui_states()
def update_ui_states(self):
if self.no_rounding_var.get():
self.fully_round_check.config(state=tk.DISABLED)
self.make_fully_round_var.set(False)
self.radius_scale.config(state=tk.DISABLED)
self.radius_label.config(state=tk.DISABLED, foreground=self.DISABLED_FG_COLOR) # Disabled color for label
else:
self.fully_round_check.config(state=tk.NORMAL)
if self.make_fully_round_var.get():
self.radius_scale.config(state=tk.DISABLED)
self.radius_label.config(state=tk.DISABLED, foreground=self.DISABLED_FG_COLOR) # Disabled color
else:
self.radius_scale.config(state=tk.NORMAL)
self.radius_label.config(state=tk.NORMAL, foreground=self.TEXT_COLOR_ON_LIGHT_BG) # Normal color
self.update_process_button_state()
def update_process_button_state(self):
if self.input_path.get() and self.output_path.get():
self.process_button.config(state=tk.NORMAL)
else:
self.process_button.config(state=tk.DISABLED)
def handle_drop(self, event):
if not self.dnd_enabled: return
filepath_raw = event.data
if filepath_raw.startswith('{') and filepath_raw.endswith('}'):
filepath = filepath_raw[1:-1]
else:
filepath = filepath_raw
# Take the first file if multiple are dropped (paths might be space or "} {" separated)
first_filepath = filepath.split('} {')[0].split(' ')[0].strip()
if os.path.isfile(first_filepath):
if self.is_image_file(first_filepath):
self.input_path.set(first_filepath)
self.drop_label_text.set(f"読込: {os.path.basename(first_filepath)}")
self.status_label.config(text="画像が読み込まれました。", foreground=self.PRIMARY_COLOR)
input_dir = os.path.dirname(first_filepath)
base, ext = os.path.splitext(os.path.basename(first_filepath))
default_output_name = f"{base}_processed.png"
self.output_path.set(os.path.join(input_dir, default_output_name))
else:
self.status_label.config(text="エラー: 画像ファイルではありません。", foreground=self.ERROR_COLOR)
messagebox.showerror("エラー", f"{os.path.basename(first_filepath)} は有効な画像ファイルではありません。")
self.reset_drop_label()
else:
self.status_label.config(text="エラー: 有効なファイルではありません。", foreground=self.ERROR_COLOR)
messagebox.showerror("エラー", f"{first_filepath} は有効なファイルパスではありません。")
self.reset_drop_label()
self.update_process_button_state()
self.update_ui_states()
def reset_drop_label(self):
drop_message_base = "ここに画像をドラッグ&ドロップ" if self.dnd_enabled else "D&D無効"
self.drop_label_text.set(drop_message_base + "\nまたは下から選択")
def is_image_file(self, filepath):
try:
with Image.open(filepath) as img_test:
img_test.verify()
with Image.open(filepath) as img_test2: # Reopen after verify
img_test2.format
return True
except (IOError, UnidentifiedImageError, FileNotFoundError, SyntaxError, TypeError, AttributeError) as e:
print(f"File '{filepath}' is not a valid image: {e}", file=sys.stderr)
return False
def select_input_file(self):
filepath = filedialog.askopenfilename(
title="入力画像を選択",
filetypes=(("画像ファイル", "*.png *.jpg *.jpeg *.gif *.bmp *.tiff *.webp"),
("すべてのファイル", "*.*"))
)
if filepath:
if self.is_image_file(filepath):
self.input_path.set(filepath)
self.drop_label_text.set(f"読込: {os.path.basename(filepath)}")
input_dir = os.path.dirname(filepath)
base, ext = os.path.splitext(os.path.basename(filepath))
default_output_name = f"{base}_processed.png"
self.output_path.set(os.path.join(input_dir, default_output_name))
self.status_label.config(text="入力画像を選択しました。", foreground=self.PRIMARY_COLOR)
else:
messagebox.showerror("エラー", "選択されたファイルは有効な画像ファイルではありません。")
self.status_label.config(text="エラー: 無効な画像ファイルです。", foreground=self.ERROR_COLOR)
self.input_path.set("") # Clear invalid input path
self.update_process_button_state()
self.update_ui_states()
def select_output_file(self):
input_filename = os.path.basename(self.input_path.get())
if input_filename:
name, _ = os.path.splitext(input_filename)
default_name = f"{name}_processed.png"
else:
default_name = "processed_image.png"
filepath = filedialog.asksaveasfilename(
title="出力先を選択",
defaultextension=".png",
initialfile=default_name,
filetypes=(("PNG画像", "*.png"), ("すべてのファイル", "*.*"))
)
if filepath:
self.output_path.set(filepath)
self.status_label.config(text="出力先を選択しました。", foreground=self.PRIMARY_COLOR)
self.update_process_button_state()
def resize_image_aspect_fit(self, image, max_width, max_height):
original_width, original_height = image.size
if original_width == 0 or original_height == 0: return image
# Handle cases where one dimension is inf (e.g. for custom mode if only one dim is constrained)
if max_width == float('inf') and max_height == float('inf'): return image
if max_width == float('inf'):
if original_height == 0: return image # Avoid division by zero
max_width = original_width * (max_height / original_height)
if max_height == float('inf'):
if original_width == 0: return image # Avoid division by zero
max_height = original_height * (max_width / original_width)
ratio_w = max_width / original_width
ratio_h = max_height / original_height
scale_factor = min(ratio_w, ratio_h)
new_width = int(original_width * scale_factor)
new_height = int(original_height * scale_factor)
new_width = max(1, new_width)
new_height = max(1, new_height)
if (new_width, new_height) == (original_width, original_height): return image
img_to_resize = image.convert('RGBA') if image.mode not in ('RGBA', 'LA') else image
resized_image = img_to_resize.resize((new_width, new_height), LANCZOS_RESAMPLE)
print(f"Aspect fit resize from {original_width}x{original_height} to {new_width}x{new_height}")
return resized_image
def resize_to_fixed_canvas(self, image, target_canvas_width, target_canvas_height):
img_rgba = image.convert("RGBA") # Ensure RGBA for consistent alpha handling
original_width, original_height = img_rgba.size
if original_width == 0 or original_height == 0:
return Image.new("RGBA", (target_canvas_width, target_canvas_height), (0,0,0,0))
ratio_w = target_canvas_width / original_width
ratio_h = target_canvas_height / original_height
scale_factor = min(ratio_w, ratio_h)
new_width = int(original_width * scale_factor)
new_height = int(original_height * scale_factor)
new_width = max(1, new_width)
new_height = max(1, new_height)
resized_image = img_rgba.resize((new_width, new_height), LANCZOS_RESAMPLE)
final_image = Image.new("RGBA", (target_canvas_width, target_canvas_height), (0,0,0,0)) # Transparent canvas
paste_x = (target_canvas_width - new_width) // 2
paste_y = (target_canvas_height - new_height) // 2
final_image.paste(resized_image, (paste_x, paste_y), resized_image) # Use resized_image's alpha as mask
print(f"Resized from {original_width}x{original_height} to {new_width}x{new_height}, then padded to {target_canvas_width}x{target_canvas_height}")
return final_image
def round_corners(self, image, radius):
image_to_round = image.copy().convert("RGBA")
width, height = image_to_round.size
actual_radius = int(radius) # Ensure radius is integer
if not self.make_fully_round_var.get() and actual_radius > min(width, height) // 2:
actual_radius = min(width, height) // 2
if actual_radius < 0: actual_radius = 0
mask = Image.new('L', (width, height), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, width, height), radius=actual_radius, fill=255)
image_to_round.putalpha(mask)
return image_to_round
def process_image(self):
input_p = self.input_path.get()
output_p = self.output_path.get()
selected_mode_key = self.output_mode_var.get()
current_mode_settings = self.MODES[selected_mode_key]
target_dim_w, target_dim_h = current_mode_settings["max_dims"]
target_max_filesize_mb = current_mode_settings["max_size_mb"]
if not input_p or not output_p:
messagebox.showerror("エラー", "入力画像と出力先を選択してください。")
return
try:
self.status_label.config(text="処理中...", foreground=self.PRIMARY_COLOR)
self.master.update_idletasks()
with Image.open(input_p) as img:
processed_img = img.copy()
# 1. リサイズ処理
if selected_mode_key == "mode1" or selected_mode_key == "mode2":
processed_img = self.resize_to_fixed_canvas(processed_img, int(target_dim_w), int(target_dim_h))
elif selected_mode_key == "custom":
# For custom mode, if dims are not inf, use aspect_fit. Otherwise, no resize.
if target_dim_w != float('inf') or target_dim_h != float('inf'):
processed_img = self.resize_image_aspect_fit(processed_img, target_dim_w, target_dim_h)
# 2. 角丸処理
if not self.no_rounding_var.get():
current_width, current_height = processed_img.size
max_possible_radius = min(current_width, current_height) // 2
actual_radius = 0
if self.make_fully_round_var.get():
actual_radius = max_possible_radius
print(f"完全に丸くするため、半径を {actual_radius} に設定。")
else:
slider_radius_val = self.radius_var.get()
if slider_radius_val > max_possible_radius:
actual_radius = max_possible_radius
self.radius_var.set(actual_radius)
if slider_radius_val != actual_radius and selected_mode_key != "custom":
messagebox.showwarning("半径調整",
f"指定された半径({slider_radius_val}px)は現在の画像サイズ({current_width}x{current_height}px)では大きすぎたため、"
f"最大値の{actual_radius}pxに調整しました。")
else:
actual_radius = slider_radius_val
if actual_radius < 0: actual_radius = 0
if actual_radius > 0 or self.make_fully_round_var.get():
processed_img = self.round_corners(processed_img, actual_radius)
# 3. 保存
if output_p.lower().endswith(".png") and processed_img.mode != 'RGBA':
processed_img = processed_img.convert("RGBA")
processed_img.save(output_p, "PNG", optimize=True, compress_level=9)
final_filesize_bytes = os.path.getsize(output_p)
final_filesize_mb = final_filesize_bytes / (1024 * 1024)
status_message = f"処理完了: {os.path.basename(output_p)} ({final_filesize_mb:.2f}MB) を保存。"
status_color = self.SUCCESS_COLOR
if target_max_filesize_mb != float('inf') and final_filesize_mb > target_max_filesize_mb:
messagebox.showwarning("ファイルサイズ警告",
f"生成されたファイルのサイズ ({final_filesize_mb:.2f}MB) が選択モードの目標 ({target_max_filesize_mb}MB) を超えました。\n"
f"画質を維持するため、このまま保存されました。")
status_message = f"完了(サイズ警告あり): {os.path.basename(output_p)} ({final_filesize_mb:.2f}MB超)。"
status_color = self.WARNING_COLOR
self.status_label.config(text=status_message, foreground=status_color)
self.reset_drop_label()
except FileNotFoundError:
messagebox.showerror("エラー", f"入力ファイルが見つかりません: {input_p}")
self.status_label.config(text="エラー: 入力ファイルが見つかりません。", foreground=self.ERROR_COLOR)
except UnidentifiedImageError:
messagebox.showerror("エラー", f"画像ファイルとして認識できません: {input_p}")
self.status_label.config(text="エラー: 画像ファイル形式が無効です。", foreground=self.ERROR_COLOR)
except IOError as e:
messagebox.showerror("エラー", f"ファイル入出力エラー: {e}\n画像が破損しているかサポート外の形式の可能性があります。")
self.status_label.config(text=f"エラー: ファイルIOエラー ({e})", foreground=self.ERROR_COLOR)
except Exception as e:
import traceback
traceback.print_exc(file=sys.stderr)
messagebox.showerror("予期せぬエラー", f"予期せぬエラーが発生しました: {e}")
self.status_label.config(text=f"予期せぬエラー: {e}", foreground=self.ERROR_COLOR)
finally:
self.update_process_button_state()
self.update_ui_states()
def run(self):
self.master.mainloop()
if __name__ == "__main__":
# Windows 高DPI対応 (try-exceptで囲み、他OSでのエラーを回避)
try:
if sys.platform == "win32":
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1) # DPI_AWARENESS_PER_MONITOR_AWARE
except Exception as e:
print(f"Failed to set DPI awareness: {e}", file=sys.stderr)
if TkinterDnD:
root = TkinterDnD.Tk()
else:
root = tk.Tk()
app = AutoRoundedTransparentIconGenerator(root)
app.run()
このコードは、AutoRoundedTransparentIconGenerator
というクラスの中に全てのGUI要素と画像処理ロジックをカプセル化しています。これにより、コード全体の見通しが良くなり、管理しやすくなっています。
主要な機能とロジック解説
ここでは、プログラムの主要な機能がどのように実装されているかを詳しく見ていきましょう。
GUIの構築 (__init__
メソッド)
- ウィンドウ設定:
python
self.master = master_root
self.master.title("Auto Rounded Transparent Icon Generator")
self.master.geometry("580x820")
ウィンドウタイトルを設定し、初期サイズを指定しています。 - スタイル設定:
python
self.style = ttk.Style()
self.style.theme_use('clam') # モダンなテーマを使用
# 各種ウィジェットのスタイルを詳細に設定
self.style.configure("TFrame", background=self.BG_COLOR)
# ... (他のスタイル設定)
ttk.Style
を使用して、ウィジェットの見た目をカスタマイズしています。clam
テーマは比較的モダンでカスタマイズしやすいテーマです。背景色、文字色、フォント、ボーダーなどを細かく設定し、統一感のあるデザインを目指しています。 - 変数 (
tk.StringVar
,tk.IntVar
,tk.BooleanVar
):
python
self.input_path = tk.StringVar()
self.output_path = tk.StringVar()
self.radius_var = tk.IntVar(value=25)
self.make_fully_round_var = tk.BooleanVar(value=False)
self.no_rounding_var = tk.BooleanVar(value=False)
self.output_mode_var = tk.StringVar(value="mode1")
self.drop_label_text = tk.StringVar()
これらの変数は、GUIウィジェット(エントリー、ラジオボタン、チェックボタン、スケール、ラベル)の値と連動するために使用されます。例えば、self.input_path
の値が変更されると、それに紐づけられたエントリーウィジェットの表示も自動的に更新されます。 - ウィジェットの配置:
ttk.Frame
を使ってウィジェットをグループ化し、pack()
メソッドでウィンドウ内に配置しています。pack()
はウィジェットを自動的に配置してくれるシンプルなレイアウトマネージャーです。expand=True
やfill=tk.BOTH
を使うことで、ウィンドウサイズ変更時にウィジェットが適切に拡大・縮小するように設定しています。 - ドラッグ&ドロップ領域:
python
drop_frame_container = tk.Frame(main_frame, bg=self.LIGHT_BLUE_BG, ...)
# ...
drop_label = tk.Label(drop_frame_container, textvariable=self.drop_label_text, ...)
# ...
if self.dnd_enabled:
drop_label.drop_target_register(DND_FILES)
drop_label.dnd_bind('<<Drop>>', self.handle_drop)
drop_frame_container.drop_target_register(DND_FILES)
drop_frame_container.dnd_bind('<<Drop>>', self.handle_drop)
ドラッグ&ドロップを受け付ける領域は、tk.Frame
とtk.Label
を組み合わせて作成しています。tkinterdnd2
が利用可能な場合は、drop_target_register(DND_FILES)
でファイルドロップのターゲットとして登録し、dnd_bind('<<Drop>>', self.handle_drop)
でドロップイベントが発生した際にhandle_drop
メソッドが呼び出されるように設定しています。
ドラッグ&ドロップ処理 (handle_drop
メソッド)
def handle_drop(self, event):
if not self.dnd_enabled: return
filepath_raw = event.data
# ファイルパスの整形処理
# ...
first_filepath = filepath.split('} {')[0].split(' ')[0].strip()
if os.path.isfile(first_filepath):
if self.is_image_file(first_filepath):
# 画像ファイルの場合の処理
self.input_path.set(first_filepath)
# ... (出力パスの自動設定など)
else:
# 画像ファイルでない場合の処理
messagebox.showerror("エラー", ...)
self.reset_drop_label()
else:
# 無効なファイルパスの場合の処理
messagebox.showerror("エラー", ...)
self.reset_drop_label()
self.update_process_button_state()
self.update_ui_states()
このメソッドは、ファイルがドロップされたときに呼び出されます。event.data
からドロップされたファイルのパスを取得します。複数のファイルがドロップされた場合を考慮し、最初のファイルパスのみを取得する処理を入れています。取得したパスが有効なファイルであり、かつ画像ファイルであることを is_image_file
メソッドで確認し、問題なければ入力パスとして設定し、デフォルトの出力パスを自動生成します。画像ファイルでない場合や無効なパスの場合はエラーメッセージを表示します。
ファイル選択処理 (select_input_file
, select_output_file
メソッド)
def select_input_file(self):
filepath = filedialog.askopenfilename(...)
if filepath:
if self.is_image_file(filepath):
# 画像ファイルの場合の処理
self.input_path.set(filepath)
# ... (出力パスの自動設定など)
else:
# 画像ファイルでない場合の処理
messagebox.showerror("エラー", ...)
self.input_path.set("") # 無効なパスをクリア
self.update_process_button_state()
self.update_ui_states()
def select_output_file(self):
filepath = filedialog.asksaveasfilename(...)
if filepath:
self.output_path.set(filepath)
self.update_process_button_state()
これらのメソッドは、「参照」ボタンがクリックされたときにファイルダイアログを開く処理を実装しています。filedialog.askopenfilename
はファイルを開くダイアログ、filedialog.asksaveasfilename
はファイルを保存するダイアログを表示します。選択されたファイルパスをそれぞれの tk.StringVar
に設定します。入力ファイル選択時には、ドロップ時と同様に画像ファイルかどうかのチェックを行っています。
UIの状態更新 (update_ui_states
, update_process_button_state
メソッド)
def update_ui_states(self):
if self.no_rounding_var.get():
# 角丸処理しない場合、他の角丸設定を無効化
self.fully_round_check.config(state=tk.DISABLED)
self.make_fully_round_var.set(False)
self.radius_scale.config(state=tk.DISABLED)
self.radius_label.config(state=tk.DISABLED, ...)
else:
# 角丸処理する場合、他の設定を有効化
self.fully_round_check.config(state=tk.NORMAL)
if self.make_fully_round_var.get():
# 完全に丸くする場合、半径スライダーを無効化
self.radius_scale.config(state=tk.DISABLED)
self.radius_label.config(state=tk.DISABLED, ...)
else:
# 角丸半径を指定する場合、半径スライダーを有効化
self.radius_scale.config(state=tk.NORMAL)
self.radius_label.config(state=tk.NORMAL, ...)
self.update_process_button_state()
def update_process_button_state(self):
if self.input_path.get() and self.output_path.get():
self.process_button.config(state=tk.NORMAL) # 入力・出力パスがあればボタン有効
else:
self.process_button.config(state=tk.DISABLED) # なければ無効
これらのメソッドは、ユーザーの操作(チェックボックスのON/OFF、ラジオボタンの選択など)に応じて、他のウィジェットの状態(有効/無効)を適切に切り替える役割を担います。例えば、「角丸処理をしない」にチェックが入った場合は、「完全に丸くする」チェックボックスと半径スライダーが無効になります。また、入力ファイルと出力先が両方とも設定されている場合にのみ、「画像を処理」ボタンを有効化します。
画像のリサイズ処理 (resize_image_aspect_fit
, resize_to_fixed_canvas
メソッド)
-
resize_image_aspect_fit(image, max_width, max_height)
:
このメソッドは、元画像のアスペクト比を維持したまま、指定されたmax_width
およびmax_height
の範囲に収まるように画像をリサイズします。主に「カスタム」モードで、サイズ上限を指定した場合に使用されます。幅と高さそれぞれの縮尺率を計算し、小さい方の縮尺率を全体に適用することでアスペクト比を維持します。
“`python
ratio_w = max_width / original_width
ratio_h = max_height / original_height
scale_factor = min(ratio_w, ratio_h) # 小さい方の縮尺率を採用new_width = int(original_width * scale_factor)
new_height = int(original_height * scale_factor)resized_image = img_to_resize.resize((new_width, new_height), LANCZOS_RESAMPLE)
``
resize()` メソッドに計算した新しいサイズとリサンプリングフィルターを指定して実行します。
Pillowの -
resize_to_fixed_canvas(image, target_canvas_width, target_canvas_height)
:
このメソッドは、指定されたtarget_canvas_width
xtarget_canvas_height
の固定キャンバスに画像を収まるようにリサイズし、中央に配置します。余白部分は透明になります。主に「標準」や「高品質」モードのように、出力サイズが固定されている場合に使用されます。
“`python
# … (アスペクト比を維持してリサイズする部分は aspect_fit と同様)final_image = Image.new(“RGBA”, (target_canvas_width, target_canvas_height), (0,0,0,0)) # 透明なキャンバスを作成
paste_x = (target_canvas_width – new_width) // 2
paste_y = (target_canvas_height – new_height) // 2final_image.paste(resized_image, (paste_x, paste_y), resized_image) # 中央に貼り付け、透過情報を利用
``
paste` メソッドの第三引数に貼り付ける画像自身を指定することで、その画像のアルファチャンネル(透過情報)をマスクとして使用し、綺麗に合成できます。
まず指定キャンバスサイズと同じ大きさの<span class="swl-marker mark_pink">完全透明な画像</span>を作成します。次に、元画像をアスペクト比を維持しつつキャンバスサイズ内に収まるようにリサイズします。最後に、リサイズされた画像を透明キャンバスの中央に貼り付けます。
角丸・円形加工処理 (round_corners
メソッド)
def round_corners(self, image, radius):
image_to_round = image.copy().convert("RGBA") # RGBAモードに変換 (アルファチャンネル必須)
width, height = image_to_round.size
actual_radius = int(radius)
# 半径が画像サイズを超えないように調整 (完全に丸くする場合を除く)
if not self.make_fully_round_var.get() and actual_radius > min(width, height) // 2:
actual_radius = min(width, height) // 2
# 半径が負の値にならないように調整
if actual_radius < 0: actual_radius = 0
mask = Image.new('L', (width, height), 0) # グレースケールのマスク画像を生成 (初期値0=完全透明)
draw = ImageDraw.Draw(mask)
# マスク画像に角丸の白い矩形を描画 (白=不透明)
draw.rounded_rectangle((0, 0, width, height), radius=actual_radius, fill=255)
image_to_round.putalpha(mask) # 元画像にマスク画像をアルファチャンネルとして適用
return image_to_round
- RGBA変換: 角丸加工にはアルファチャンネルが必須なので、元画像をRGBAモードに変換します。
- 半径の調整: 指定された半径が画像サイズに対して大きすぎる場合(特に「完全に丸くする」オプションがOFFの場合)、最大可能な半径(画像の短い方の辺の長さの半分)に調整します。これにより、不自然な形状になるのを防ぎます。「完全に丸くする」オプションがONの場合は、自動的に最大可能な半径が使用されます。
- マスク画像の生成: 元画像と同じサイズのグレースケール画像(モード ‘L’)を生成します。初期値は0(黒)で、これは完全な透明を表します。
- 角丸矩形の描画:
ImageDraw.Draw
を使って、生成したマスク画像に角丸の矩形を描画します。rounded_rectangle
メソッドは、指定された座標に角丸の矩形を描画する機能です。radius
引数で角の丸み具合を指定します。fill=255
で白色(グレースケールで255は完全な不透明)で描画します。この白い部分が、最終的に画像が不透明になる部分になります。 - アルファチャンネルとして適用:
image_to_round.putalpha(mask)
メソッドで、生成したマスク画像を元画像のアルファチャンネルとして設定します。マスク画像の輝度(明るさ)が、元画像の対応するピクセルの透明度として解釈されます。白い部分(255)は不透明、黒い部分(0)は透明、灰色の部分は半透明になります。
画像の保存 (process_image
メソッド内)
def process_image(self):
# ... (画像処理ロジック)
# 3. 保存
if output_p.lower().endswith(".png") and processed_img.mode != 'RGBA':
processed_img = processed_img.convert("RGBA") # PNG保存時はRGBA必須
processed_img.save(output_p, "PNG", optimize=True, compress_level=9)
# ... (ファイルサイズチェックとステータス表示)
画像処理が完了したら、結果をファイルに保存します。出力ファイルパスが .png
で終わり、かつ画像モードがRGBAでない場合は、透過情報を保持するためにRGBAモードに変換します。processed_img.save()
メソッドで、ファイルパス、フォーマット(”PNG”)、そしてオプション(optimize=True
, compress_level=9
)を指定して保存します。optimize=True
はファイルサイズを削減しようとし、compress_level=9
は最大の圧縮率を指定します(処理時間はかかります)。
エラーハンドリング
ファイルが見つからない場合 (FileNotFoundError
)、画像ファイルとして認識できない場合 (UnidentifiedImageError
)、ファイル入出力エラー (IOError
) など、処理中に発生しうる様々なエラーを try...except
ブロックで捕捉し、ユーザーに分かりやすいメッセージボックスを表示するようにしています。これにより、アプリケーションの頑健性を高めています。
try:
# ... (画像処理ロジック)
except FileNotFoundError:
messagebox.showerror("エラー", f"入力ファイルが見つかりません: {input_p}")
self.status_label.config(text="エラー: 入力ファイルが見つかりません。", foreground=self.ERROR_COLOR)
except UnidentifiedImageError:
messagebox.showerror("エラー", f"画像ファイルとして認識できません: {input_p}")
self.status_label.config(text="エラー: 画像ファイル形式が無効です。", foreground=self.ERROR_COLOR)
# ... (他の例外処理)
except Exception as e:
# 予期せぬエラー
import traceback
traceback.print_exc(file=sys.stderr)
messagebox.showerror("予期せぬエラー", f"予期せぬエラーが発生しました: {e}")
self.status_label.config(text=f"予期せぬエラー: {e}", foreground=self.ERROR_COLOR)
特に最後の except Exception as e:
は、想定外のエラーが発生した場合にプログラムがクラッシュするのを防ぎ、エラー内容を表示するための汎用的な処理です。traceback.print_exc()
はデバッグ時に役立ちます。
使用されている技術・ライブラリ
このアプリケーションは、主に以下の3つのPythonライブラリに依存しています。それぞれの役割と特徴を整理してみましょう。
ライブラリ名 | 概要 | 主な用途 | 特徴 |
---|---|---|---|
tkinter (標準ライブラリ) | Python標準のGUIツールキット | ウィンドウ、ボタン、ラベル、エントリーなどのGUI部品の作成と配置 | インストール不要、シンプルで使いやすい、クロスプラットフォーム対応 |
Pillow (PIL Fork) | 高機能な画像処理ライブラリ | 画像ファイルの読み込み・保存、リサイズ、切り抜き、色空間変換、アルファチャンネル操作、図形描画 | 豊富な画像形式に対応、高速な画像処理、透過処理やマスク処理も容易 |
tkinterdnd2 | tkinterにドラッグ&ドロップ機能を追加する拡張ライブラリ | ファイルやテキストのGUIへのドラッグ&ドロップ受付 | tkinterのイベントループと統合、直感的なファイル入力を実現 |
これらのライブラリを組み合わせることで、GUIアプリケーション開発、ファイル操作、そして高度な画像処理という、本ツールの実現に必要な全ての要素がカバーされています。
Pillowのリサンプリングフィルターについて
コード中には、Pillowのリサイズ処理において LANCZOS_RESAMPLE
という変数が使われています。これはリサイズ時の補間アルゴリズムを指定するもので、画質に影響します。
# Pillow のリサンプリングフィルターをバージョン互換性考慮して設定
try:
LANCZOS_RESAMPLE = Image.Resampling.LANCZOS # Pillow 9.1.0以降
except AttributeError:
try:
LANCZOS_RESAMPLE = Image.LANCZOS # Pillow 1.0.0以降 (非推奨)
except AttributeError:
LANCZOS_RESAMPLE = Image.ANTIALIAS # それ以前 (非推奨)
Pillowのバージョンによってリサンプリングフィルターの指定方法が変更されたため、互換性を保つためにこのような記述になっています。LANCZOS
フィルターは、一般的に画像の縮小時に高い品質を得られるとされています。他のフィルターには NEAREST
, BILINEAR
, BICUBIC
などがあります。
フィルター名 | 特徴 | 適した用途 |
---|---|---|
NEAREST | 最も高速、ジャギーが出やすい | ドット絵、拡大、厳密なピクセル操作 |
BILINEAR | NEARESTより滑らか、ぼやけやすい | 一般的な縮小(低品質で十分な場合) |
BICUBIC | BILINEARより滑らか、シャープになる傾向 | 一般的な縮小・拡大 |
LANCZOS | 高画質、処理に時間がかかる | 画像の縮小、品質重視 |
ANTIALIAS (非推奨) | LANCZOSと同等 | 旧バージョン互換用 |
特に理由がなければ、LANCZOS
または BICUBIC
を使用するのがおすすめです。
セットアップと実行方法
プログラムを実行するには、Python環境と必要なライブラリが必要です。
まだPythonがインストールされていない場合は、Python公式サイトからダウンロードしてインストールしてください。最新バージョンのPython 3をおすすめします。インストール時には、「Add Python to PATH」のチェックボックスをオンにするのを忘れないでください。
コマンドプロンプトまたはターミナルを開き、以下のコマンドを実行して必要なライブラリをインストールします。
pip install Pillow tkinterdnd2
– `pip install Pillow`: 画像処理ライブラリPillowをインストールします。
– `pip install tkinterdnd2`: ドラッグ&ドロップ機能のためのライブラリをインストールします。tkinter自体はPython標準ライブラリなので、別途インストールする必要はありません。
tkinterdnd2が見つからない場合のフォールバック処理はコードに含まれていますが、ドラッグ&ドロップ機能を利用するにはこのライブラリのインストールが必須です。
上記のプログラムコードをコピーし、`auto_icon_generator.py` のような名前でファイルに保存します。
コマンドプロンプトまたはターミナルを開き、保存したファイルがあるディレクトリに移動して、以下のコマンドを実行します。
python auto_icon_generator.py
これにより、GUIアプリケーションが起動します。
- Windows環境で高DPIディスプレイを使用している場合、GUIの表示が崩れることがあります。コードに含まれているWindows高DPI対応の記述 (
windll.shcore.SetProcessDpiAwareness(1)
) は、これを軽減するためのものです。 - tkinterdnd2のインストールに失敗した場合や、サポートされていない環境では、ドラッグ&ドロップ機能は無効になりますが、ファイルダイアログからの選択は可能です。
使用例・応用例
このツールは、様々なシーンで活用できます。
- **Webサイトのアイコン作成:** ブログ記事のアイキャッチ画像や、サイト内のナビゲーションアイコンなどを手軽に角丸・透過PNGに加工できます。特に、デザインツールを使わずに素早くアイコンを用意したい場合に便利です。
- **プレゼン資料の画像加工:** プレゼンテーションで使用する写真や図形を、統一感のある角丸デザインに加工することで、資料全体の質感を向上させられます。背景を透過させることで、様々な背景色の上に綺麗に配置できます。
- **SNSプロフィール画像:** 正方形の画像を簡単に円形に切り抜くことができるため、SNSのプロフィール画像作成に役立ちます。
- **開発中のアプリアイコン:** 仮のアイコンや、サイズの異なるアイコンバリエーションを素早く生成するのに使えます。
機能の組み合わせ例
- 「標準モード」+「完全に丸くする」: 512x512pxの固定キャンバスに画像を中央配置し、完全に円形に切り抜いた透過PNGアイコンを作成。
- 「高品質モード」+「角の丸み: 50px」: 1024x500pxの固定キャンバスに画像を中央配置し、角を半径50pxで丸めた透過PNGを作成。高解像度での使用や、ファイル容量に比較的余裕がある場合に適しています。
- 「カスタムモード」+「角丸処理をしない」: 元画像のサイズや容量制限なしで、透過PNGに変換したい場合に利用。例えば、透過情報を持たないJPG画像を透過PNGに変換する、といった用途に使えます。
学習者向けのポイント・発展的なトピック
提供されたコードは、PythonでのGUIプログラミングと画像処理の入門として非常に良い教材となります。以下の点に着目してコードを読み解くと、理解が深まります。
- **クラスの構造:** `AutoRoundedTransparentIconGenerator` クラスがどのようにGUI要素と処理ロジックをまとめて管理しているかを確認しましょう。`__init__` メソッドでの初期化、イベントハンドラーメソッド(`handle_drop`, `select_input_file` など)、処理実行メソッド(`process_image`)といった役割分担がどのように行われているかを理解することが重要です。
- **tkinterのイベント駆動型プログラミング:** ボタンクリックやファイルドロップといったイベントが発生したときに、どのように特定のメソッド(イベントハンドラー)が呼び出されるのか(`command` オプションや `dnd_bind` メソッドの使い方)を学びましょう。
- **Pillowでの画像操作:** `Image.open()`, `Image.new()`, `image.convert()`, `image.resize()`, `ImageDraw.Draw()`, `draw.rounded_rectangle()`, `image.putalpha()`, `image.save()` といったPillowの基本的な画像操作メソッドの使い方を理解しましょう。特に、アルファチャンネルを使った透過処理や、マスク画像を使った形状加工のロジックは応用範囲が広いです。
- **状態管理:** `tk.StringVar` や `tk.BooleanVar` といった変数を使って、GUIの状態(入力ファイルパス、チェックボックスの状態など)をどのように管理し、それに応じてUIの表示やボタンの状態を更新しているか(`update_ui_states`, `update_process_button_state` メソッド)を確認しましょう。
- **エラーハンドリング:** `try…except` ブロックを使ったエラー処理がどのように行われているかを学びましょう。ユーザーフレンドリーなアプリケーションには必須の要素です。
発展的なトピック
このツールをベースに、さらに機能を拡張したり、コードを改善したりすることができます。
- **他の画像処理機能の追加:**
- 色調補正(明るさ、コントラスト、彩度など)
- フィルタリング(ぼかし、シャープネスなど)
- テキストやロゴの重ね合わせ
- 画像フォーマット変換オプション(JPG、GIFなど)
Pillowにはこれらの機能を実現するための豊富なメソッドが用意されています。
- **プレビュー機能の実装:** 加工結果を処理前にGUI上で確認できるようにすると、より使い勝手が向上します。Pillowで加工した画像をtkinterで表示するには、`PIL.ImageTk` モジュールを利用するのが一般的です。
- **設定の保存・読み込み:** よく使う設定(角丸半径、出力モードなど)をファイルに保存し、次回起動時に読み込めるようにすると便利です。Pythonの `configparser` モジュールやJSONファイルなどが利用できます。
- **進捗表示:** 画像処理に時間がかかる場合、プログレスバーなどで進捗を表示するとユーザー体験が向上します。tkinterの `ttk.Progressbar` ウィジェットが利用できますが、画像処理を別スレッドで行うなどの工夫が必要になります。
- **処理のキャンセル機能:** 実行中の画像処理を途中で中止できるようにすると、誤操作時などに便利です。
- **複数ファイルのバッチ処理:** 複数の画像を一度にドラッグ&ドロップして、まとめて処理できるように機能を拡張することも可能です。
これらの発展的な機能に挑戦することで、より高度なPythonプログラミングスキルと画像処理の知識を習得できるでしょう。
まとめ
本記事では、Pythonのtkinter、Pillow、tkinterdnd2ライブラリを組み合わせて、画像をドラッグ&ドロップで読み込み、角丸や円形に加工して透過PNGとして保存するGUIツールを作成する方法を解説しました。
コードの解説を通じて、PythonでのGUI構築の基本、Pillowを使った画像のリサイズやアルファチャンネル操作による透過・形状加工のテクニック、そしてドラッグ&ドロップ機能の実装方法について理解を深めていただけたかと思います。
提供したコードはあくまで出発点です。ぜひ、この記事で学んだ知識を活かして、自分好みの機能を追加したり、インターフェースを改善したりして、あなただけのオリジナルの画像処理ツールを開発してみてください。Pythonの豊かなエコシステムを活用すれば、デスクトップアプリケーション開発や画像処理の可能性は大きく広がります。
- tkinterとttkでモダンなGUIを構築
- tkinterdnd2で直感的なドラッグ&ドロップ入力を実現
- Pillowで画像のリサイズ、角丸加工、透過処理を実行
- アルファチャンネルとマスク画像を使った角丸加工の仕組み
- エラーハンドリングでアプリケーションの安定性を向上
- HTMLテーブルで技術要素を分かりやすく整理
この記事が、あなたのPythonによるGUI開発および画像処理の学習の一助となれば幸いです。