2日前、JavaScriptで「写真スポッティング編集ツール」を作成した。
今回、Pythonで「写真オブジェクト除去アプリ」を作成。AIツールClaudeに作成してもらった。
写真の中のちょっとした不要物を除去するためのアプリ。除去した後を目立たないように周辺画像から背景画像を類推して補完する。
補完レベルは、最近のAIツールに比較して満足できるレベルではない。実用的には、複雑な背景がある画像などには使えない。青空に小さなゴミが写ってしまった場合や、顔の小さなシミ消しには使えるかもしれないレベル。
【使い方】
- PNGまたはJPEG画像をドラッグ&ドロップでウインドウに取り込む。
- 「円モード」と「自由曲線モード」がある。
- 「円モード」では、マウスでクリックした部分を中心とした円状にオブジェクトを除去する。円のサイズはスライダーで変更できる。
- 「自由曲線モード」では、マウスで除去したい部分を囲む。囲み終わってマウスを離した時点で除去される。
- 除去アルゴリズムに「NS(Navier-Stokes)」と「Telea」があるが、大きな違いはない。
- 画像サイズが大きい場合は、ウインドウ内に入るように縮小表示する。実寸表示に切り替えることもできる。
- 「戻す」や「全て戻す」で、除去したものを元に戻すことができる。
- 「保存」ボタンで、JPEG画像として保存できる。
【Pythonプログラム】
作成されたプログラムを、「写真オブジェクト除去.pyw」(名前は任意)という名前にして、デスクトップに置いておき、ダブルクリックで実行する。
【実行画面サンプル】
以下、サンプル。サンプルの元画像はすべてChatGPTで生成したものをトリミングした。
【Pythonインストール】
Pythonがインストールされていなければ、インストールする。
Pythonのパッケージマネージャー「pip」を使って、必要なライブラリをインターネットから自動的にダウンロードしてインストールする。コマンドプロンプトで以下を実行。
pip install numpy opencv-python pillow torch segmentation-models-pytorch tkinterdnd2
【Pythonプログラムコード】
import os import sys import numpy as np import cv2 from PIL import Image, ImageTk import tkinter as tk from tkinter import ttk, filedialog, messagebox from tkinter.simpledialog import askstring class ObjectRemovalApp: def __init__(self, root): self.root = root self.root.title("オブジェクト除去アプリ") self.root.geometry("1200x800") self.root.configure(bg='#f0f0f0') # アプリの状態を初期化 self.original_image = None self.current_image = None self.displayed_image = None self.tk_image = None self.image_path = None self.history = [] self.drawing = False self.curve_points = [] # モード設定(円または自由曲線) self.mode = "circle" # 初期値は円モード self.circle_radius = 20 # スケール関連変数 self.scale_factor = 1.0 self.is_original_size = False self.scroll_x = 0 self.scroll_y = 0 # UI要素の配置 self.create_ui() # ファイルドロップの設定 self.canvas.drop_target_register('DND_Files') self.canvas.dnd_bind('<<Drop>>', self.on_drop) # キーバインド self.root.bind("<Configure>", self.on_resize) def create_ui(self): # メインフレーム main_frame = ttk.Frame(self.root) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # 左側のコントロールパネル control_panel = ttk.Frame(main_frame, width=200) control_panel.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10)) # モード選択 mode_frame = ttk.LabelFrame(control_panel, text="モード選択") mode_frame.pack(fill=tk.X, pady=5) self.mode_var = tk.StringVar(value="circle") ttk.Radiobutton(mode_frame, text="円モード", variable=self.mode_var, value="circle", command=self.set_circle_mode).pack(anchor=tk.W, padx=5, pady=2) ttk.Radiobutton(mode_frame, text="自由曲線モード", variable=self.mode_var, value="curve", command=self.set_curve_mode).pack(anchor=tk.W, padx=5, pady=2) # 円の半径設定 radius_frame = ttk.LabelFrame(control_panel, text="円の半径") radius_frame.pack(fill=tk.X, pady=5) self.radius_var = tk.IntVar(value=20) self.radius_scale = ttk.Scale(radius_frame, from_=1, to=100, variable=self.radius_var, orient=tk.HORIZONTAL, command=self.update_radius) self.radius_scale.pack(fill=tk.X, padx=5, pady=5) self.radius_label = ttk.Label(radius_frame, text="20 px") self.radius_label.pack(pady=(0, 5)) # アルゴリズム選択 algo_frame = ttk.LabelFrame(control_panel, text="除去アルゴリズム") algo_frame.pack(fill=tk.X, pady=5) self.algo_var = tk.StringVar(value="ns") ttk.Radiobutton(algo_frame, text="NS (高品質)", variable=self.algo_var, value="ns").pack(anchor=tk.W, padx=5, pady=2) ttk.Radiobutton(algo_frame, text="Telea (速度優先)", variable=self.algo_var, value="telea").pack(anchor=tk.W, padx=5, pady=2) # 表示サイズ切替 size_frame = ttk.LabelFrame(control_panel, text="表示サイズ") size_frame.pack(fill=tk.X, pady=5) self.size_button = ttk.Button(size_frame, text="実寸表示に切替", command=self.toggle_size) self.size_button.pack(fill=tk.X, padx=5, pady=5) # 操作ボタン actions_frame = ttk.LabelFrame(control_panel, text="操作") actions_frame.pack(fill=tk.X, pady=5) ttk.Button(actions_frame, text="戻す", command=self.undo).pack(fill=tk.X, padx=5, pady=2) ttk.Button(actions_frame, text="全て戻す", command=self.undo_all).pack(fill=tk.X, padx=5, pady=2) ttk.Button(actions_frame, text="初期状態に戻す", command=self.reset).pack(fill=tk.X, padx=5, pady=2) ttk.Button(actions_frame, text="保存", command=self.save_image).pack(fill=tk.X, padx=5, pady=2) # キャンバスフレームの構成を変更 canvas_frame = ttk.Frame(main_frame) canvas_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) # スクロールバー v_scrollbar = ttk.Scrollbar(canvas_frame, orient=tk.VERTICAL) h_scrollbar = ttk.Scrollbar(canvas_frame, orient=tk.HORIZONTAL) # キャンバス self.canvas = tk.Canvas(canvas_frame, bg="#e0e0e0", highlightthickness=0, xscrollcommand=h_scrollbar.set, yscrollcommand=v_scrollbar.set) # スクロールバーの設定 v_scrollbar.config(command=self.canvas.yview) h_scrollbar.config(command=self.canvas.xview) # ウィジェットの配置 v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X) self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # マウスイベントのバインド self.canvas.bind("<Button-1>", self.on_mouse_down) self.canvas.bind("<B1-Motion>", self.on_mouse_drag) self.canvas.bind("<ButtonRelease-1>", self.on_mouse_up) self.canvas.bind("<Motion>", self.on_mouse_move) # 初期メッセージ表示 self.show_welcome_message() def show_welcome_message(self): self.canvas.create_text( 600, 400, text="PNGまたはJPEG画像をここにドラッグ&ドロップしてください", font=("Helvetica", 14), fill="black" ) def on_drop(self, event): file_path = event.data # WindowsのパスからPythonのパスに変換 file_path = file_path.replace('{', '').replace('}', '') if file_path.startswith('file:///'): file_path = file_path[8:] # ファイルの存在確認と拡張子チェック if not os.path.exists(file_path): messagebox.showerror("エラー", "ファイルが見つかりません") return _, ext = os.path.splitext(file_path.lower()) if ext not in ['.png', '.jpg', '.jpeg']: messagebox.showerror("エラー", "PNGまたはJPEG画像のみサポートしています") return self.load_image(file_path) def load_image(self, file_path): try: # PILで画像を開く self.image_path = file_path self.original_image = Image.open(file_path).convert("RGB") self.current_image = self.original_image.copy() self.history = [] # 画像サイズを計算 self.calculate_scale_factor() # 画像を表示 self.update_display() # タイトル更新 self.root.title(f"オブジェクト除去アプリ - {os.path.basename(file_path)}") except Exception as e: messagebox.showerror("エラー", f"画像の読み込みに失敗しました: {e}") def calculate_scale_factor(self): if self.original_image: # キャンバスサイズを取得 canvas_width = self.canvas.winfo_width() canvas_height = self.canvas.winfo_height() # キャンバスサイズが無効な場合(初期表示時など) if canvas_width <= 1 or canvas_height <= 1: canvas_width = 1000 # デフォルト値 canvas_height = 700 # デフォルト値 # 画像サイズを取得 img_width, img_height = self.original_image.size # 画像がキャンバスに収まる場合 if img_width <= canvas_width and img_height <= canvas_height: self.scale_factor = 1.0 self.is_original_size = True else: # スケールファクターを計算 width_ratio = canvas_width / img_width height_ratio = canvas_height / img_height self.scale_factor = min(width_ratio, height_ratio) * 0.9 # 余白のために0.9をかける self.is_original_size = False def update_display(self): if self.current_image: # キャンバスをクリア self.canvas.delete("all") if self.is_original_size: # 実寸表示 img_width, img_height = self.current_image.size self.displayed_image = self.current_image.copy() # スクロール領域を設定 self.canvas.config(scrollregion=(0, 0, img_width, img_height)) # ボタンテキストを更新 self.size_button.config(text="縮小表示に切替") else: # 縮小表示 img_width, img_height = self.current_image.size new_width = int(img_width * self.scale_factor) new_height = int(img_height * self.scale_factor) self.displayed_image = self.current_image.resize((new_width, new_height), Image.LANCZOS) # スクロールリージョンをリセット self.canvas.config(scrollregion=(0, 0, new_width, new_height)) # ボタンテキストを更新 self.size_button.config(text="実寸表示に切替") # PIL画像をTkinter画像に変換 self.tk_image = ImageTk.PhotoImage(self.displayed_image) # キャンバスに画像を描画 self.canvas.create_image(0, 0, anchor=tk.NW, image=self.tk_image) def toggle_size(self): if self.current_image: self.is_original_size = not self.is_original_size self.update_display() def set_circle_mode(self): self.mode = "circle" def set_curve_mode(self): self.mode = "curve" self.curve_points = [] def update_radius(self, value=None): self.circle_radius = self.radius_var.get() self.radius_label.config(text=f"{self.circle_radius} px") def on_mouse_down(self, event): if not self.current_image: return # キャンバス座標を取得 canvas_x = self.canvas.canvasx(event.x) canvas_y = self.canvas.canvasy(event.y) if self.mode == "circle": # 円モード: マウスダウンで除去を開始 self.remove_object_circle(canvas_x, canvas_y) elif self.mode == "curve": # 曲線モード: 描画開始 self.drawing = True self.curve_points = [(canvas_x, canvas_y)] # 最初の点を描画 self.canvas.create_oval(canvas_x-2, canvas_y-2, canvas_x+2, canvas_y+2, fill="red", tags="curve") def on_mouse_drag(self, event): if not self.current_image or not self.drawing: return # キャンバス座標を取得 canvas_x = self.canvas.canvasx(event.x) canvas_y = self.canvas.canvasy(event.y) if self.mode == "curve": # 曲線モードの場合、点を追加 self.curve_points.append((canvas_x, canvas_y)) # 線を描画 if len(self.curve_points) > 1: x1, y1 = self.curve_points[-2] x2, y2 = self.curve_points[-1] self.canvas.create_line(x1, y1, x2, y2, fill="red", width=2, tags="curve") def on_mouse_up(self, event): if not self.current_image: return # キャンバス座標を取得 canvas_x = self.canvas.canvasx(event.x) canvas_y = self.canvas.canvasy(event.y) if self.mode == "curve" and self.drawing: self.drawing = False # 曲線を閉じる if len(self.curve_points) > 2: # 最後の点と最初の点を結ぶ x1, y1 = self.curve_points[-1] x2, y2 = self.curve_points[0] self.canvas.create_line(x1, y1, x2, y2, fill="red", width=2, tags="curve") # 領域を除去 self.remove_object_curve() # 描画をクリア self.canvas.delete("curve") def on_mouse_move(self, event): if not self.current_image: return # キャンバス座標を取得 canvas_x = self.canvas.canvasx(event.x) canvas_y = self.canvas.canvasy(event.y) if self.mode == "circle": # 円を表示(プレビュー) self.canvas.delete("circle_preview") radius = self.circle_radius if not self.is_original_size: radius = radius * self.scale_factor self.canvas.create_oval( canvas_x - radius, canvas_y - radius, canvas_x + radius, canvas_y + radius, outline="red", width=2, tags="circle_preview" ) def convert_canvas_to_image_coords(self, canvas_x, canvas_y): # キャンバス座標から実際の画像座標に変換 if self.is_original_size: return int(canvas_x), int(canvas_y) else: return int(canvas_x / self.scale_factor), int(canvas_y / self.scale_factor) def remove_object_circle(self, canvas_x, canvas_y): if not self.current_image: return # キャンバス座標から画像座標に変換 img_x, img_y = self.convert_canvas_to_image_coords(canvas_x, canvas_y) # 現在の画像を履歴に保存 self.history.append(self.current_image.copy()) # OpenCVで処理するためにnumpy配列に変換 img_array = np.array(self.current_image) # マスク作成(円) mask = np.zeros((img_array.shape[0], img_array.shape[1]), dtype=np.uint8) cv2.circle(mask, (img_x, img_y), self.circle_radius, 255, -1) # 修復画像を作成 inpainted_img = self.inpaint_image(img_array, mask) # PILイメージに戻す self.current_image = Image.fromarray(inpainted_img) # 表示を更新 self.update_display() def remove_object_curve(self): if not self.current_image or len(self.curve_points) < 3: return # 現在の画像を履歴に保存 self.history.append(self.current_image.copy()) # キャンバス座標から画像座標に変換 image_points = [self.convert_canvas_to_image_coords(x, y) for x, y in self.curve_points] # OpenCVで処理するためにnumpy配列に変換 img_array = np.array(self.current_image) # マスク作成(多角形) mask = np.zeros((img_array.shape[0], img_array.shape[1]), dtype=np.uint8) points = np.array(image_points, dtype=np.int32) cv2.fillPoly(mask, [points], 255) # 修復画像を作成 inpainted_img = self.inpaint_image(img_array, mask) # PILイメージに戻す self.current_image = Image.fromarray(inpainted_img) # 表示を更新 self.update_display() def inpaint_image(self, img_array, mask): try: # 選択されたアルゴリズムを取得 algorithm = self.algo_var.get() # アルゴリズムに応じてinpaintingメソッドを選択 if algorithm == "ns": # NSアルゴリズム return cv2.inpaint(img_array, mask, 7, cv2.INPAINT_NS) else: # Teleaアルゴリズム return cv2.inpaint(img_array, mask, 3, cv2.INPAINT_TELEA) except Exception as e: print(f"修復中にエラーが発生しました: {e}") # エラー時はTeleaを使用 return cv2.inpaint(img_array, mask, 3, cv2.INPAINT_TELEA) def undo(self): if self.history: self.current_image = self.history.pop() self.update_display() def undo_all(self): if self.history: self.current_image = self.history[0] self.history = [] self.update_display() def reset(self): if self.original_image: self.current_image = self.original_image.copy() self.history = [] self.update_display() def save_image(self): if not self.current_image: messagebox.showinfo("情報", "保存する画像がありません") return # 元のファイルパスから情報を取得 original_dir = os.path.dirname(self.image_path) original_name = os.path.basename(self.image_path) name_without_ext = os.path.splitext(original_name)[0] # デフォルトのファイル名を設定 default_filename = f"{name_without_ext}-edited.jpg" # ファイル保存ダイアログを表示 file_path = filedialog.asksaveasfilename( initialdir=original_dir, initialfile=default_filename, defaultextension=".jpg", filetypes=[("JPEG files", "*.jpg")] ) if file_path: try: # 保存 self.current_image.save(file_path, "JPEG", quality=95) # --- コメントアウト --- messagebox.showinfo("成功", "画像を保存しました") except Exception as e: messagebox.showerror("エラー", f"保存中にエラーが発生しました: {e}") def on_resize(self, event): # ウィンドウサイズ変更時に呼ばれる if self.original_image and not self.is_original_size: self.calculate_scale_factor() self.update_display() if __name__ == "__main__": # TkDnDライブラリをインポート try: from tkinterdnd2 import TkinterDnD, DND_FILES root = TkinterDnD.Tk() except ImportError: messagebox.showerror("エラー", "TkinterDnD2ライブラリがインストールされていません。\npip install tkinterdnd2 でインストールしてください。") root = tk.Tk() app = ObjectRemovalApp(root) root.mainloop()