動画を読み込んで画像に分解し 類似画像検索してシーンごとにグループを分けて音声なしのグループごとの動画を再作成する / 基準画像に類似した画像を探す 類似画像を移動する
初めに
次のサイトを参考にしました
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()









ディスカッション
コメント一覧
まだ、コメントがありません