晴歩雨描

晴れた日は外に出て歩き、雨の日は部屋で絵を描く

Claude)Python)「写真オブジェクト除去アプリ」作成。シミ消しにも使える。

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()