動画を読み込んで画像に分解し 類似画像検索してシーンごとにグループを分けて音声なしのグループごとの動画を再作成する / 基準画像に類似した画像を探す 類似画像を移動する

2026年4月26日

初めに

次のサイトを参考にしました

https://note.com/tora_no_oya/n/na875d41885be

pip install Pillow imagehash

イメージ

プログラム

import os
import threading
import shutil
import configparser
from tkinter import (Tk, filedialog, messagebox, Spinbox, PanedWindow, Canvas, StringVar)
from tkinter import ttk
from PIL import Image, ImageTk
import imagehash
import subprocess
import copy

INI_FILE = os.path.splitext(os.path.basename(__file__))[0] + ".ini"

def find_duplicate_images(directory, threshold_distance, progress_callback):
    hashes = {}
    image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp'}

    files_to_process = []
    for root, dirs, files in os.walk(directory):
        for f in files:
            ext = os.path.splitext(f)[1].lower()
            if ext in image_extensions:
                files_to_process.append(os.path.join(root, f))

    total_files = len(files_to_process)
    if total_files == 0:
        return []

    for i, filepath in enumerate(files_to_process):
        try:
            with Image.open(filepath) as img:
                h = imagehash.phash(img.convert('RGB'))
                hashes[filepath] = h
            progress_callback(f"画像を分析中: {i+1}/{total_files}", (i+1)/total_files * 50)
        except:
            pass

    duplicates = []
    checked = set()
    items = list(hashes.items())

    for i in range(len(items)):
        f1, h1 = items[i]
        if f1 in checked:
            continue

        group = [(f1, h1)]
        for j in range(i+1, len(items)):
            f2, h2 = items[j]
            if f2 in checked:
                continue
            if h1 - h2 <= threshold_distance:
                group.append((f2, h2))
                checked.add(f2)

        if len(group) > 1:
            duplicates.append(group)
            checked.add(f1)

        progress_callback(f"画像を比較中: {i+1}/{len(items)}", 50 + (i+1)/len(items) * 50)

    return duplicates


class App(Tk):
    def __init__(self):
        super().__init__()
        self.title("類似画像検索")
        self.geometry("1300x700")

        self.current_directory = ""
        self.duplicates = []
        self.group_history = []
        self.current_group_filepaths = []
        self.thumbnail_images = []
        self.move_log = []
        self.ffmpeg_path = ""
        self.video_path = ""
        self.similarity_percent = "95"
        self.thumb_size = "120"

        self.output_format = StringVar(value="mp4")

        style = ttk.Style()
        style.configure("Big.TButton", font=("Meiryo", 12), padding=10)

        self.load_ini()

        settings_top = ttk.Frame(self, padding=10)
        settings_top.pack(fill="x")

        ttk.Label(settings_top, text="フォルダ:").pack(side="left")
        self.dir_entry = ttk.Entry(settings_top, width=45)
        self.dir_entry.pack(side="left", padx=5)
        if self.current_directory:
            self.dir_entry.insert(0, self.current_directory)

        ttk.Button(settings_top, text="参照", style="Big.TButton",
                   command=self.select_directory).pack(side="left", padx=5)

        ttk.Label(settings_top, text="類似度(%):").pack(side="left", padx=(20, 5))
        self.similarity_spinbox = Spinbox(settings_top, from_=0, to=100, width=6, justify="center")
        self.similarity_spinbox.delete(0, "end")
        self.similarity_spinbox.insert(0, self.similarity_percent)
        self.similarity_spinbox.config(font=("Meiryo", 14))
        self.similarity_spinbox.pack(side="left")

        ttk.Label(settings_top, text="サムネイル:").pack(side="left", padx=(20, 5))
        self.thumb_size_spinbox = Spinbox(
            settings_top, from_=60, to=240, width=6, justify="center",
            command=self.on_thumb_size_change
        )
        self.thumb_size_spinbox.delete(0, "end")
        self.thumb_size_spinbox.insert(0, self.thumb_size)
        self.thumb_size_spinbox.config(font=("Meiryo", 14))
        self.thumb_size_spinbox.pack(side="left")

        ttk.Label(settings_top, text="ffmpeg.exe:").pack(side="left", padx=(20, 5))
        self.ffmpeg_entry = ttk.Entry(settings_top, width=40)
        self.ffmpeg_entry.pack(side="left", padx=5)
        if self.ffmpeg_path:
            self.ffmpeg_entry.insert(0, self.ffmpeg_path)

        ttk.Button(settings_top, text="参照", style="Big.TButton",
                   command=self.select_ffmpeg).pack(side="left", padx=5)

        settings_bottom = ttk.Frame(self, padding=10)
        settings_bottom.pack(fill="x")

        ttk.Button(settings_bottom, text="検索開始", style="Big.TButton",
                   command=self.start_search).pack(side="left", padx=10)

        ttk.Button(settings_bottom, text="フォルダ分け", style="Big.TButton",
                   command=self.auto_group_folders).pack(side="left", padx=5)

        ttk.Button(settings_bottom, text="元に戻す(フォルダ)", style="Big.TButton",
                   command=self.undo_group_folders).pack(side="left", padx=5)

        ttk.Button(settings_bottom, text="選択画像を削除", style="Big.TButton",
                   command=self.delete_selected_file).pack(side="left", padx=10)

        ttk.Button(settings_bottom, text="動画を読み込む", style="Big.TButton",
                   command=self.select_video).pack(side="left", padx=10)

        ttk.Button(settings_bottom, text="動画→画像分割", style="Big.TButton",
                   command=self.split_video).pack(side="left", padx=10)

        ttk.Button(settings_bottom, text="グループ動画作成", style="Big.TButton",
                   command=self.create_group_videos).pack(side="left", padx=10)

        # ★ quit() → destroy() に変更(フリーズ防止)
        ttk.Button(settings_bottom, text="終了", style="Big.TButton",
                   command=self.destroy).pack(side="left", padx=10)

        progress_frame = ttk.Frame(self, padding=(10, 0))
        progress_frame.pack(fill="x")

        self.progress_label = ttk.Label(progress_frame, text="待機中")
        self.progress_label.pack(fill="x")

        self.progress_bar = ttk.Progressbar(progress_frame, mode="determinate")
        self.progress_bar.pack(fill="x")

        paned = PanedWindow(self, orient="horizontal")
        paned.pack(expand=True, fill="both", padx=10, pady=10)

        left = ttk.Frame(paned)
        paned.add(left, width=400)

        merge_frame = ttk.Frame(left)
        merge_frame.pack(fill="x", anchor="n", pady=5)

        self.selected_group_label = ttk.Label(merge_frame, text="選択中のグループ:なし")
        self.selected_group_label.pack(fill="x", pady=2)

        ttk.Button(merge_frame, text="選択したグループに下のグループを統合",
                   command=self.merge_down_to_up).pack(fill="x", pady=2)

        ttk.Button(merge_frame, text="選択したグループに上のグループを統合",
                   command=self.merge_up_to_down).pack(fill="x", pady=2)

        ttk.Button(merge_frame, text="グループ統合を元に戻す",
                   command=self.undo_merge_groups).pack(fill="x", pady=2)

        # ★ 出力形式選択(表示されるように Frame で囲む)
        fmt_frame = ttk.LabelFrame(merge_frame, text="出力形式")
        fmt_frame.pack(fill="x", pady=5)

        ttk.Radiobutton(fmt_frame, text="MP4", variable=self.output_format, value="mp4").pack(anchor="w")
        ttk.Radiobutton(fmt_frame, text="GIF", variable=self.output_format, value="gif").pack(anchor="w")

        self.tree = ttk.Treeview(
            left,
            columns=("filepath", "similarity", "distance"),
            show="tree headings"
        )
        self.tree.heading("#0", text="グループ / ファイル名")
        self.tree.heading("similarity", text="類似度(%)")
        self.tree.heading("distance", text="距離")
        self.tree.column("filepath", width=0, stretch=False)
        self.tree.column("similarity", width=100, anchor="center")
        self.tree.column("distance", width=60, anchor="center")

        vsb = ttk.Scrollbar(left, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=vsb.set)

        self.tree.pack(side="left", expand=True, fill="both")
        vsb.pack(side="right", fill="y")

        self.tree.bind("<<TreeviewSelect>>", self.on_tree_select)
        self.tree.bind("<Double-1>", self.on_tree_double_click)

        right = ttk.Frame(paned)
        paned.add(right)

        self.image_label = ttk.Label(right, text="画像を選択してください", anchor="center")
        self.image_label.pack(expand=True, fill="both")

        self.info_label = ttk.Label(right, text="", wraplength=500, justify="left")
        self.info_label.pack(fill="x", pady=5)

        thumb_container = ttk.Frame(right)
        thumb_container.pack(expand=True, fill="both")

        self.thumb_canvas = Canvas(thumb_container)
        self.thumb_scroll = ttk.Scrollbar(thumb_container, orient="vertical",
                                          command=self.thumb_canvas.yview)
        self.thumb_frame = ttk.Frame(self.thumb_canvas)

        self.thumb_canvas.create_window((0, 0), window=self.thumb_frame, anchor="nw")
        self.thumb_canvas.configure(yscrollcommand=self.thumb_scroll.set)

        self.thumb_canvas.pack(side="left", expand=True, fill="both")
        self.thumb_scroll.pack(side="right", fill="y")

        self.thumb_frame.bind(
            "<Configure>",
            lambda e: self.thumb_canvas.configure(scrollregion=self.thumb_canvas.bbox("all"))
        )

        self.thumb_canvas.bind("<Configure>", self.on_thumb_canvas_resize)
    # -------------------------------------------------------------------------
    # ini 読み込み / 保存
    # -------------------------------------------------------------------------
    def load_ini(self):
        config = configparser.ConfigParser()
        if os.path.exists(INI_FILE):
            config.read(INI_FILE, encoding="utf-8")

        self.current_directory = config.get("settings", "directory", fallback="")
        self.similarity_percent = config.get("settings", "similarity", fallback="95")
        self.thumb_size = config.get("settings", "thumb_size", fallback="120")
        self.ffmpeg_path = config.get("settings", "ffmpeg", fallback="")

    def save_ini(self):
        config = configparser.ConfigParser()
        config["settings"] = {
            "directory": self.current_directory,
            "similarity": self.similarity_spinbox.get(),
            "thumb_size": self.thumb_size_spinbox.get(),
            "ffmpeg": self.ffmpeg_entry.get()
        }
        with open(INI_FILE, "w", encoding="utf-8") as f:
            config.write(f)

    # -------------------------------------------------------------------------
    # TreeView 再構築
    # -------------------------------------------------------------------------
    def rebuild_treeview(self):
        self.tree.delete(*self.tree.get_children())

        for i, group in enumerate(self.duplicates, start=1):
            gid = self.tree.insert(
                "",
                "end",
                text=f"グループ {i} ({len(group)}枚)",
                open=False,
                values=("", "", "")
            )

            rep_hash = group[0][1]
            for filepath, h in group:
                dist = h - rep_hash
                sim = max(0, min(100, int(round(100 - (dist/64)*100))))
                name = os.path.basename(filepath)

                self.tree.insert(
                    gid, "end",
                    text=f"  {name}",
                    values=(filepath, f"{sim}%", dist)
                )

    # -------------------------------------------------------------------------
    # 選択されたグループ番号を取得
    # -------------------------------------------------------------------------
    def get_selected_group_index(self):
        sel = self.tree.selection()
        if not sel:
            return None

        item = sel[0]
        parent = self.tree.parent(item)

        if parent != "":
            return None

        text = self.tree.item(item, "text")
        try:
            return int(text.split()[1])
        except:
            return None

    # -------------------------------------------------------------------------
    # 統合前の状態を保存(Undo 用)
    # -------------------------------------------------------------------------
    def save_group_state(self):
        self.group_history.append(copy.deepcopy(self.duplicates))

    # -------------------------------------------------------------------------
    # グループ統合 Undo
    # -------------------------------------------------------------------------
    def undo_merge_groups(self):
        if not self.group_history:
            messagebox.showinfo("情報", "元に戻す履歴がありません")
            return

        self.duplicates = self.group_history.pop()
        self.rebuild_treeview()
        messagebox.showinfo("完了", "グループ統合を元に戻しました")

    # -------------------------------------------------------------------------
    # 下のグループを上に統合
    # -------------------------------------------------------------------------
    def merge_down_to_up(self):
        idx = self.get_selected_group_index()
        if idx is None:
            messagebox.showinfo("情報", "グループ名を選択してください")
            return

        if idx >= len(self.duplicates):
            messagebox.showinfo("情報", "下のグループがありません")
            return

        self.save_group_state()

        upper = idx - 1
        lower = idx

        self.duplicates[upper].extend(self.duplicates[lower])
        del self.duplicates[lower]

        self.rebuild_treeview()

    # -------------------------------------------------------------------------
    # 上のグループを下に統合
    # -------------------------------------------------------------------------
    def merge_up_to_down(self):
        idx = self.get_selected_group_index()
        if idx is None:
            messagebox.showinfo("情報", "グループ名を選択してください")
            return

        if idx <= 1:
            messagebox.showinfo("情報", "上のグループがありません")
            return

        self.save_group_state()

        lower = idx - 1
        upper = idx - 2

        self.duplicates[lower].extend(self.duplicates[upper])
        del self.duplicates[upper]

        self.rebuild_treeview()

    # -------------------------------------------------------------------------
    # フォルダ / ffmpeg / 動画 選択
    # -------------------------------------------------------------------------
    def select_directory(self):
        d = filedialog.askdirectory()
        if d:
            self.dir_entry.delete(0, "end")
            self.dir_entry.insert(0, d)
            self.current_directory = d
            self.save_ini()

    def select_ffmpeg(self):
        f = filedialog.askopenfilename(
            title="ffmpeg.exe を選択",
            filetypes=[("ffmpeg.exe", "*.exe")]
        )
        if f:
            self.ffmpeg_entry.delete(0, "end")
            self.ffmpeg_entry.insert(0, f)
            self.save_ini()

    def select_video(self):
        path = filedialog.askopenfilename(
            title="動画ファイルを選択",
            filetypes=[("動画ファイル", "*.mp4;*.mov;*.avi;*.mkv;*.webm")]
        )
        if path:
            self.video_path = path
            messagebox.showinfo("動画読み込み", f"動画を読み込みました:\n{path}")

    # -------------------------------------------------------------------------
    # 類似画像検索
    # -------------------------------------------------------------------------
    def start_search(self):
        directory = self.dir_entry.get()
        if not os.path.isdir(directory):
            messagebox.showerror("エラー", "フォルダを選択してください")
            return

        try:
            sim = int(self.similarity_spinbox.get())
        except:
            messagebox.showerror("エラー", "類似度は0〜100で入力してください")
            return

        threshold_distance = round((100 - sim) / 100 * 64)

        self.current_directory = directory
        self.save_ini()

        self.tree.delete(*self.tree.get_children())
        self.image_label.config(text="検索中...", image="")
        self.info_label.config(text="")
        self.current_group_filepaths = []
        self.duplicates = []
        self.group_history = []
        self.move_log = []

        for w in self.thumb_frame.winfo_children():
            w.destroy()

        threading.Thread(
            target=self.run_search_thread,
            args=(directory, threshold_distance),
            daemon=True
        ).start()

    def run_search_thread(self, directory, threshold_distance):
        try:
            d = find_duplicate_images(directory, threshold_distance, self.update_progress)
            self.after(0, self.display_results, d)
        except Exception as e:
            self.after(0, messagebox.showerror, "エラー", str(e))
        finally:
            self.after(0, self.search_complete)

    def update_progress(self, msg, val):
        self.progress_label.config(text=msg)
        self.progress_bar["value"] = val

    def search_complete(self):
        self.progress_label.config(text="待機中")
        self.progress_bar["value"] = 0

    # -------------------------------------------------------------------------
    # 結果表示
    # -------------------------------------------------------------------------
    def display_results(self, duplicates):
        self.duplicates = duplicates

        if not duplicates:
            messagebox.showinfo("結果", "類似画像は見つかりませんでした")
            return

        self.rebuild_treeview()

    # -------------------------------------------------------------------------
    # サムネイル表示
    # -------------------------------------------------------------------------
    def show_thumbnails(self, filepaths):
        for w in self.thumb_frame.winfo_children():
            w.destroy()

        self.thumbnail_images = []
        self.current_group_filepaths = filepaths

        try:
            size = int(self.thumb_size_spinbox.get())
        except:
            size = 120

        canvas_width = max(1, self.thumb_canvas.winfo_width())
        col_width = size + 20
        columns = max(1, canvas_width // col_width)

        for i, fp in enumerate(filepaths):
            try:
                img = Image.open(fp)
                img.thumbnail((size, size))
                photo = ImageTk.PhotoImage(img)
                self.thumbnail_images.append(photo)

                lbl = ttk.Label(self.thumb_frame, image=photo,
                                text=os.path.basename(fp), compound="top")
                lbl.grid(row=i // columns, column=i % columns, padx=5, pady=5)

                lbl.bind("<Button-1>", lambda e, f=fp: self.on_thumbnail_click(f))

            except:
                lbl = ttk.Label(self.thumb_frame, text="読み込み不可")
                lbl.grid(row=i // columns, column=i % columns, padx=5, pady=5)

    def on_thumb_canvas_resize(self, event):
        if self.current_group_filepaths:
            self.show_thumbnails(self.current_group_filepaths)

    def on_thumbnail_click(self, filepath):
        self.show_large_image(filepath)
        self.info_label.config(text=filepath)

    # -------------------------------------------------------------------------
    # 大きい画像表示
    # -------------------------------------------------------------------------
    def show_large_image(self, filepath):
        try:
            img = Image.open(filepath)
            img.thumbnail((600, 600))
            photo = ImageTk.PhotoImage(img)
            self.image_label.config(image=photo, text="")
            self.image_label.image = photo
        except Exception as e:
            self.image_label.config(text=str(e), image="")
            self.image_label.image = None

    # -------------------------------------------------------------------------
    # TreeView 選択(シングルクリックでは展開しない)
    # -------------------------------------------------------------------------
    def on_tree_select(self, event):
        sel = self.tree.selection()
        if not sel:
            return

        item = sel[0]
        parent = self.tree.parent(item)

        if parent == "":
            children = self.tree.get_children(item)
            fps = [self.tree.item(c, "values")[0] for c in children]

            self.selected_group_label.config(text=f"選択中のグループ:{self.tree.item(item,'text')}")
            self.show_thumbnails(fps)
            self.image_label.config(image="", text="")
            self.info_label.config(text="グループ内の画像")
            return

        fp, sim, dist = self.tree.item(item, "values")
        self.show_large_image(fp)
        self.info_label.config(text=f"{fp}\n類似度: {sim}  距離: {dist}")

    # -------------------------------------------------------------------------
    # ダブルクリックで展開/折りたたみ
    # -------------------------------------------------------------------------
    def on_tree_double_click(self, event):
        item = self.tree.identify_row(event.y)
        if not item:
            return

        parent = self.tree.parent(item)

        if parent == "":
            is_open = self.tree.item(item, "open")
            self.tree.item(item, open=not is_open)

    # -------------------------------------------------------------------------
    # サムネイルサイズ変更
    # -------------------------------------------------------------------------
    def on_thumb_size_change(self):
        if self.current_group_filepaths:
            self.show_thumbnails(self.current_group_filepaths)
        self.save_ini()

    # -------------------------------------------------------------------------
    # フォルダ分け
    # -------------------------------------------------------------------------
    def auto_group_folders(self):
        if not self.current_directory or not self.duplicates:
            messagebox.showinfo("情報", "まず検索してください")
            return

        base = self.current_directory
        self.move_log = []

        try:
            for i, group in enumerate(self.duplicates, start=1):
                folder = os.path.join(base, f"group_{i:03d}")
                os.makedirs(folder, exist_ok=True)

                for fp, _ in group:
                    if not os.path.exists(fp):
                        continue

                    dst = os.path.join(folder, os.path.basename(fp))
                    self.move_log.append((fp, dst))
                    shutil.move(fp, dst)

            messagebox.showinfo("完了", "フォルダ分けしました")

        except Exception as e:
            messagebox.showerror("エラー", str(e))

    # -------------------------------------------------------------------------
    # Undo(フォルダ分け)
    # -------------------------------------------------------------------------
    def undo_group_folders(self):
        if not self.move_log:
            messagebox.showinfo("情報", "元に戻す記録がありません")
            return

        try:
            for src_before, dst_after in reversed(self.move_log):
                if os.path.exists(dst_after):
                    os.makedirs(os.path.dirname(src_before), exist_ok=True)
                    shutil.move(dst_after, src_before)

            self.move_log = []
            messagebox.showinfo("完了", "元に戻しました")

        except Exception as e:
            messagebox.showerror("エラー", str(e))

    # -------------------------------------------------------------------------
    # 選択画像を削除
    # -------------------------------------------------------------------------
    def delete_selected_file(self):
        sel = self.tree.selection()
        if not sel:
            messagebox.showinfo("情報", "削除する画像を選択してください")
            return

        item = sel[0]
        parent = self.tree.parent(item)

        if parent == "":
            messagebox.showerror("エラー", "グループ全体は削除できません")
            return

        fp, sim, dist = self.tree.item(item, "values")

        if not os.path.exists(fp):
            messagebox.showerror("エラー", "ファイルが存在しません")
            return

        if messagebox.askyesno("確認", f"本当に削除しますか?\n{fp}"):
            try:
                os.remove(fp)
                self.tree.delete(item)
                self.image_label.config(image="", text="削除されました")
                self.info_label.config(text="")

                for w in self.thumb_frame.winfo_children():
                    w.destroy()

            except Exception as e:
                messagebox.showerror("エラー", str(e))

    # -------------------------------------------------------------------------
    # グループ動画作成(MP4 / GIF 対応)
    # -------------------------------------------------------------------------
    def create_group_videos(self):
        ffmpeg = self.ffmpeg_entry.get()
        if not os.path.isfile(ffmpeg):
            messagebox.showerror("エラー", "ffmpeg.exe のパスが正しくありません")
            return

        if not self.duplicates:
            messagebox.showinfo("情報", "まず検索してください")
            return

        if not self.video_path:
            messagebox.showerror("エラー", "元動画が読み込まれていません")
            return

        video_dir = os.path.dirname(self.video_path)
        total_groups = len(self.duplicates)

        for i, group in enumerate(self.duplicates, start=1):

            progress = int((i - 1) / total_groups * 100)
            self.progress_label.config(text=f"グループ動画作成中 {i}/{total_groups}")
            self.progress_bar["value"] = progress
            self.update()

            group_folder = os.path.join(video_dir, f"group_{i:03d}_frames")
            os.makedirs(group_folder, exist_ok=True)

            # PNG 連番保存
            for idx, (fp, _) in enumerate(group, start=1):
                try:
                    img = Image.open(fp).convert("RGB")
                    out_path = os.path.join(group_folder, f"{idx:06d}.png")
                    img.save(out_path)
                except Exception as e:
                    print("変換エラー:", e)

            fmt = self.output_format.get()

            if fmt == "mp4":
                out_video = os.path.join(video_dir, f"group_{i:03d}.mp4")
                cmd = [
                    ffmpeg,
                    "-y",
                    "-framerate", "30",
                    "-i", "%06d.png",
                    "-pix_fmt", "yuv420p",
                    out_video
                ]

            elif fmt == "gif":
                out_video = os.path.join(video_dir, f"group_{i:03d}.gif")
                cmd = [
                    ffmpeg,
                    "-y",
                    "-framerate", "15",
                    "-i", "%06d.png",
                    "-loop", "0",
                    out_video
                ]

            try:
                subprocess.run(cmd, cwd=group_folder, check=True)
            except Exception as e:
                messagebox.showerror("エラー", str(e))
                return

            # ★ 中間フォルダ削除
            shutil.rmtree(group_folder, ignore_errors=True)

        self.progress_label.config(text="グループ動画作成 完了")
        self.progress_bar["value"] = 100
        messagebox.showinfo("完了", "グループ動画を作成しました")

    # -------------------------------------------------------------------------
    # ffmpeg 動画 → 画像分割
    # -------------------------------------------------------------------------
    def split_video(self):
        ffmpeg = self.ffmpeg_entry.get()
        if not os.path.isfile(ffmpeg):
            messagebox.showerror("エラー", "ffmpeg.exe のパスが正しくありません")
            return

        if not self.video_path:
            messagebox.showerror("エラー", "動画が読み込まれていません")
            return

        if not self.current_directory:
            messagebox.showerror("エラー", "類似画像フォルダが設定されていません")
            return

        base_name = os.path.splitext(os.path.basename(self.video_path))[0]
        out_dir = os.path.join(self.current_directory, "video_frames", base_name)
        os.makedirs(out_dir, exist_ok=True)

        output_pattern = os.path.join(out_dir, "%06d.png")

        cmd = [
            ffmpeg,
            "-i", self.video_path,
            output_pattern
        ]

        try:
            self.progress_label.config(text="動画を画像に分割中...")
            self.progress_bar["value"] = 0
            self.update()

            subprocess.run(cmd, check=True)

            self.progress_label.config(text="完了")
            self.progress_bar["value"] = 100

            messagebox.showinfo("完了", f"画像を分割しました:\n{out_dir}")

        except Exception as e:
            messagebox.showerror("エラー", str(e))


# -------------------------------------------------------------------------
# 実行
# -------------------------------------------------------------------------
if __name__ == "__main__":
    app = App()
    app.mainloop()

基準画像に類似した画像を探す 類似画像を移動する

プログラム

import os
import sys
import shutil
import threading
import configparser
from tkinter import Tk, filedialog, messagebox, Spinbox, Canvas
from tkinter import ttk
from PIL import Image, ImageTk
import imagehash

# ---------------------------------------------------------
# INI ファイル名をプログラム名と同じにする
# ---------------------------------------------------------
PROGRAM_NAME = os.path.splitext(os.path.basename(sys.argv[0]))[0]
INI_FILE = PROGRAM_NAME + ".ini"


def calc_phash(filepath):
    with Image.open(filepath) as img:
        return imagehash.phash(img.convert("RGB"))


class App(Tk):
    def __init__(self):
        super().__init__()
        self.title("複数基準画像で類似画像検索")
        self.geometry("1200x700")

        # 状態
        self.target_images = []      # 基準画像(複数)
        self.search_directory = ""
        self.move_directory = ""
        self.similarity_percent = "95"

        self.results = []            # 検索結果
        self.target_hashes = []      # 基準画像のハッシュ

        self.load_ini()

        style = ttk.Style()
        style.configure("Big.TButton", font=("Meiryo", 12), padding=10)

        # ---------------------------------------------------------
        # 基準画像サムネイル表示エリア
        # ---------------------------------------------------------
        thumb_frame = ttk.Frame(self, padding=10)
        thumb_frame.pack(fill="x")

        self.base_canvas = Canvas(thumb_frame, height=140)
        self.base_canvas.pack(side="left", fill="x", expand=True)

        self.base_scroll = ttk.Scrollbar(
            thumb_frame, orient="horizontal", command=self.base_canvas.xview
        )
        self.base_scroll.pack(side="bottom", fill="x")

        self.base_canvas.configure(xscrollcommand=self.base_scroll.set)

        self.base_inner = ttk.Frame(self.base_canvas)
        self.base_canvas.create_window((0, 0), window=self.base_inner, anchor="nw")

        self.base_inner.bind(
            "<Configure>",
            lambda e: self.base_canvas.configure(scrollregion=self.base_canvas.bbox("all"))
        )

        self.base_images = []  # サムネイル保持用

        # ---------------------------------------------------------
        # 上段 UI(2段構成)
        # ---------------------------------------------------------
        top_frame = ttk.Frame(self, padding=10)
        top_frame.pack(fill="x")

        # 1段目:基準画像追加・検索フォルダ
        row1 = ttk.Frame(top_frame)
        row1.pack(fill="x", pady=5)

        ttk.Button(row1, text="基準画像を追加", style="Big.TButton",
                   command=self.select_target_images).pack(side="left", padx=5)

        ttk.Label(row1, text="検索フォルダ:").pack(side="left", padx=(20, 5))
        self.dir_entry = ttk.Entry(row1, width=45)
        self.dir_entry.pack(side="left", padx=5)
        if self.search_directory:
            self.dir_entry.insert(0, self.search_directory)

        ttk.Button(row1, text="参照", style="Big.TButton",
                   command=self.select_directory).pack(side="left", padx=5)

        # 2段目:移動先フォルダ・類似度
        row2 = ttk.Frame(top_frame)
        row2.pack(fill="x", pady=5)

        ttk.Label(row2, text="移動先フォルダ:").pack(side="left")
        self.move_entry = ttk.Entry(row2, width=45)
        self.move_entry.pack(side="left", padx=5)
        if self.move_directory:
            self.move_entry.insert(0, self.move_directory)

        ttk.Button(row2, text="参照", style="Big.TButton",
                   command=self.select_move_directory).pack(side="left", padx=5)

        ttk.Label(row2, text="類似度(%):").pack(side="left", padx=(20, 5))
        self.similarity_spinbox = Spinbox(
            row2, from_=0, to=100, width=8, justify="center",
            font=("Meiryo", 18)
        )
        self.similarity_spinbox.delete(0, "end")
        self.similarity_spinbox.insert(0, self.similarity_percent)
        self.similarity_spinbox.pack(side="left")

        # ---------------------------------------------------------
        # 操作ボタン(2段目)
        # ---------------------------------------------------------
        button_frame = ttk.Frame(self, padding=10)
        button_frame.pack(fill="x")

        ttk.Button(button_frame, text="検索開始", style="Big.TButton",
                   command=self.start_search).pack(side="left", padx=10)

        ttk.Button(button_frame, text="類似画像を移動", style="Big.TButton",
                   command=self.move_results).pack(side="left", padx=10)

        # ---------------------------------------------------------
        # 進捗
        # ---------------------------------------------------------
        progress_frame = ttk.Frame(self, padding=(10, 0))
        progress_frame.pack(fill="x")

        self.progress_label = ttk.Label(progress_frame, text="待機中")
        self.progress_label.pack(fill="x")

        self.progress_bar = ttk.Progressbar(progress_frame, mode="determinate")
        self.progress_bar.pack(fill="x")

        # ---------------------------------------------------------
        # メインエリア(左:検索結果サムネイル / 右:プレビュー)
        # ---------------------------------------------------------
        main = ttk.Frame(self)
        main.pack(expand=True, fill="both")

        # 左:検索結果サムネイル
        left = ttk.Frame(main)
        left.pack(side="left", fill="both", expand=True)

        self.result_canvas = Canvas(left)
        self.result_canvas.pack(side="left", fill="both", expand=True)

        self.result_scroll = ttk.Scrollbar(left, orient="vertical",
                                           command=self.result_canvas.yview)
        self.result_scroll.pack(side="right", fill="y")

        self.result_canvas.configure(yscrollcommand=self.result_scroll.set)

        self.result_inner = ttk.Frame(self.result_canvas)
        self.result_canvas.create_window((0, 0), window=self.result_inner, anchor="nw")

        self.result_inner.bind(
            "<Configure>",
            lambda e: self.result_canvas.configure(scrollregion=self.result_canvas.bbox("all"))
        )

        self.result_images = []  # 検索結果サムネイル保持用

        # 右:大きいプレビュー
        right = ttk.Frame(main)
        right.pack(side="left", fill="both", expand=True)

        self.image_label = ttk.Label(right, text="画像を選択してください", anchor="center")
        self.image_label.pack(expand=True, fill="both")

        self.info_label = ttk.Label(right, text="", wraplength=400, justify="left")
        self.info_label.pack(fill="x", pady=5)

        # 起動時に基準画像サムネイルを表示
        self.show_base_thumbnails()
    # ---------------------------------------------------------
    # ini 読み込み / 保存
    # ---------------------------------------------------------
    def load_ini(self):
        config = configparser.ConfigParser()
        if os.path.exists(INI_FILE):
            config.read(INI_FILE, encoding="utf-8")

        imgs = config.get("settings", "target_images", fallback="")
        self.target_images = imgs.split(",") if imgs else []

        self.search_directory = config.get("settings", "directory", fallback="")
        self.move_directory = config.get("settings", "move_directory", fallback="")
        self.similarity_percent = config.get("settings", "similarity", fallback="95")

    def save_ini(self):
        config = configparser.ConfigParser()
        config["settings"] = {
            "target_images": ",".join(self.target_images),
            "directory": self.dir_entry.get(),
            "move_directory": self.move_entry.get(),
            "similarity": self.similarity_spinbox.get()
        }
        with open(INI_FILE, "w", encoding="utf-8") as f:
            config.write(f)

    # ---------------------------------------------------------
    # 基準画像選択(複数)
    # ---------------------------------------------------------
    def select_target_images(self):
        files = filedialog.askopenfilenames(
            title="基準画像を選択(複数可)",
            filetypes=[("画像ファイル", "*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.webp")]
        )
        if not files:
            return

        self.target_images.extend(list(files))
        self.target_images = list(dict.fromkeys(self.target_images))  # 重複削除
        self.save_ini()
        self.show_base_thumbnails()

    # ---------------------------------------------------------
    # 基準画像サムネイル表示(削除対応)
    # ---------------------------------------------------------
    def show_base_thumbnails(self):
        for w in self.base_inner.winfo_children():
            w.destroy()

        self.base_images = []
        size = 120

        for i, fp in enumerate(self.target_images):
            try:
                img = Image.open(fp)
                img.thumbnail((size, size))
                photo = ImageTk.PhotoImage(img)
                self.base_images.append(photo)

                lbl = ttk.Label(self.base_inner, image=photo,
                                text=os.path.basename(fp), compound="top")
                lbl.grid(row=0, column=i, padx=5, pady=5)

                # クリックで削除
                lbl.bind("<Button-1>", lambda e, f=fp: self.delete_base_image(f))

            except:
                lbl = ttk.Label(self.base_inner, text="読み込み不可")
                lbl.grid(row=0, column=i, padx=5, pady=5)

    # ---------------------------------------------------------
    # 基準画像削除
    # ---------------------------------------------------------
    def delete_base_image(self, filepath):
        if filepath in self.target_images:
            self.target_images.remove(filepath)
            self.save_ini()
            self.show_base_thumbnails()

    # ---------------------------------------------------------
    # フォルダ選択
    # ---------------------------------------------------------
    def select_directory(self):
        d = filedialog.askdirectory()
        if d:
            self.dir_entry.delete(0, "end")
            self.dir_entry.insert(0, d)
            self.save_ini()

    def select_move_directory(self):
        d = filedialog.askdirectory()
        if d:
            self.move_entry.delete(0, "end")
            self.move_entry.insert(0, d)
            self.save_ini()

    # ---------------------------------------------------------
    # 検索開始
    # ---------------------------------------------------------
    def start_search(self):
        if not self.target_images:
            messagebox.showerror("エラー", "基準画像を選択してください")
            return

        directory = self.dir_entry.get()
        if not os.path.isdir(directory):
            messagebox.showerror("エラー", "検索フォルダを選択してください")
            return

        try:
            sim = int(self.similarity_spinbox.get())
        except:
            messagebox.showerror("エラー", "類似度は0〜100で入力してください")
            return

        threshold_distance = round((100 - sim) / 100 * 64)

        self.save_ini()

        threading.Thread(
            target=self.run_search_thread,
            args=(directory, threshold_distance),
            daemon=True
        ).start()

    # ---------------------------------------------------------
    # 検索処理(複数基準画像対応)
    # ---------------------------------------------------------
    def run_search_thread(self, directory, threshold_distance):
        try:
            # 基準画像のハッシュを作成
            self.target_hashes = []
            for fp in self.target_images:
                try:
                    self.target_hashes.append(calc_phash(fp))
                except:
                    pass

            if not self.target_hashes:
                messagebox.showerror("エラー", "基準画像の読み込みに失敗しました")
                return

            image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp'}
            files = []

            for root, dirs, fs in os.walk(directory):
                for f in fs:
                    if os.path.splitext(f)[1].lower() in image_extensions:
                        files.append(os.path.join(root, f))

            total = len(files)
            self.results = []

            for i, fp in enumerate(files):
                try:
                    h = calc_phash(fp)

                    hit = False
                    best_dist = 999

                    for th in self.target_hashes:
                        dist = th - h
                        if dist < best_dist:
                            best_dist = dist
                        if dist <= threshold_distance:
                            hit = True

                    if hit:
                        sim = max(0, min(100, int(round(100 - (best_dist / 64) * 100))))
                        self.results.append((fp, sim, best_dist))

                except:
                    pass

                self.progress_label.config(text=f"検索中: {i+1}/{total}")
                self.progress_bar["value"] = (i+1) / total * 100

            self.after(0, self.display_results)

        except Exception as e:
            messagebox.showerror("エラー", str(e))

        finally:
            self.progress_label.config(text="待機中")
            self.progress_bar["value"] = 0

    # ---------------------------------------------------------
    # 検索結果サムネイル表示
    # ---------------------------------------------------------
    def display_results(self):
        for w in self.result_inner.winfo_children():
            w.destroy()

        self.result_images = []

        if not self.results:
            messagebox.showinfo("結果", "類似画像は見つかりませんでした")
            return

        size = 140
        columns = 4

        for i, (fp, sim, dist) in enumerate(self.results):
            try:
                img = Image.open(fp)
                img.thumbnail((size, size))
                photo = ImageTk.PhotoImage(img)
                self.result_images.append(photo)

                frame = ttk.Frame(self.result_inner)
                frame.grid(row=i // columns, column=i % columns, padx=10, pady=10)

                lbl = ttk.Label(frame, image=photo)
                lbl.pack()

                lbl.bind("<Button-1>", lambda e, f=fp: self.show_preview(f))

                ttk.Label(frame, text=f"{sim}%").pack()

            except:
                pass

    # ---------------------------------------------------------
    # プレビュー表示
    # ---------------------------------------------------------
    def show_preview(self, filepath):
        try:
            img = Image.open(filepath)
            img.thumbnail((600, 600))
            photo = ImageTk.PhotoImage(img)
            self.image_label.config(image=photo, text="")
            self.image_label.image = photo
        except:
            self.image_label.config(text="読み込みエラー", image="")
            self.image_label.image = None

        self.info_label.config(text=filepath)

    # ---------------------------------------------------------
    # 類似画像の移動
    # ---------------------------------------------------------
    def move_results(self):
        if not self.results:
            messagebox.showinfo("情報", "移動する画像がありません")
            return

        move_dir = self.move_entry.get()
        if not os.path.isdir(move_dir):
            messagebox.showerror("エラー", "移動先フォルダが正しくありません")
            return

        moved = 0

        for fp, sim, dist in self.results:
            if os.path.exists(fp):
                shutil.move(fp, os.path.join(move_dir, os.path.basename(fp)))
                moved += 1

        messagebox.showinfo("完了", f"{moved} 枚の類似画像を移動しました")


if __name__ == "__main__":
    app = App()
    app.mainloop()

Python,画像

Posted by eightban