import tkinter as tk from tkinter import filedialog, messagebox, scrolledtext, ttk from PIL import Image, ImageTk, ImageGrab import threading import os import numpy as np import pandas as pd import cv2 import tempfile import io import json import sys import time # 시간 측정용 from datetime import datetime # 폴더명 생성용 import subprocess # 폴더 열기용 import win32clipboard import random # resource_path 함수는 변경 없이 그대로 사용합니다. def resource_path(relative_path): """ PyInstaller에 의해 임시 폴더에 생성된 리소스의 절대 경로를 반환합니다. """ try: base_path = sys._MEIPASS except Exception: base_path = os.path.abspath(".") return os.path.join(base_path, relative_path) class WD14TaggerGUI: def __init__(self, root): self.root = root self.root.title("WD14 Auto Prompt Generator (aiart) v2") self.root.minsize(680, 800) self.root.resizable(True, True) self.SETTINGS_FILE = "tagger_app_settings.json" # --- 변수 초기화 --- self.current_image_path = None self.session = None self.tags_df = None self.is_generating = False self.save_counter = 1 self.last_generated_image = None self.temp_image_path = os.path.join(tempfile.gettempdir(), "wd14_temp_generated.png") self.save_session_folder = datetime.now().strftime('%Y%m%d_%H%M') # Tkinter 변수 self.general_threshold_var = tk.DoubleVar(value=0.5) self.char_threshold_var = tk.DoubleVar(value=0.85) self.rm_c_var = tk.BooleanVar() self.rm_color_var = tk.BooleanVar() self.rm_clothes_var = tk.BooleanVar() self.webui_mode_var = tk.BooleanVar() self.instant_inference_var = tk.BooleanVar(value=False) self.show_removed_tags_var = tk.BooleanVar(value=True) self.show_ignore_tags_var = tk.BooleanVar(value=False) self.prompt_active_var = tk.BooleanVar(value=False) self.expand_ui_var = tk.BooleanVar(value=False) self.sampler_var = tk.StringVar() self.resolution_var = tk.StringVar() self.steps_var = tk.StringVar(value="28") self.scale_var = tk.StringVar(value="5.5") self.seed_var = tk.StringVar(value="-1") self.cfg_rescale_var = tk.StringVar(value="0.25") self.nai_token_var = tk.StringVar() self.model_var = tk.StringVar() self.auto_save_var = tk.BooleanVar(value=True) self.instant_generate_var = tk.BooleanVar(value=False) self.auto_rating_tag_var = tk.BooleanVar(value=True) self.character_prompts = [] self.char_check_vars = [tk.BooleanVar(value=False) for _ in range(3)] self.char_check_vars[0].set(True) self.last_generated_seed = random.randint(0, 9999999999) self.init_filter_data() self.setup_ui() self.load_model() self.show_initial_preview() self.setup_drag_drop() self.load_settings() self.root.protocol("WM_DELETE_WINDOW", self.on_closing) self.root.bind('', self.handle_paste) def _create_thumbnail(self, pil_image, size=512): """PIL 이미지를 받아 흰 배경의 정사각형 썸네일을 생성합니다.""" # 1. 원본 이미지의 너비와 높이 중 더 큰 쪽을 기준으로 정사각형 캔버스 생성 bg_size = max(pil_image.size) thumbnail_bg = Image.new('RGB', (bg_size, bg_size), 'white') # 2. 캔버스 중앙에 원본 이미지 붙여넣기 paste_x = (bg_size - pil_image.width) // 2 paste_y = (bg_size - pil_image.height) // 2 thumbnail_bg.paste(pil_image, (paste_x, paste_y)) # 3. 최종 썸네일 크기로 리사이즈 thumbnail_bg.thumbnail((size, size), Image.Resampling.LANCZOS) return thumbnail_bg def _update_timer(self): """생성 버튼의 타이머를 업데이트합니다.""" if self.is_generating: elapsed = time.time() - self.generation_start_time self.generation_btn.config(text=f"생성 중: {int(elapsed)}s") self.root.after(1000, self._update_timer) # 1초마다 반복 def _copy_image_to_clipboard(self): """생성된 이미지를 클립보드에 복사합니다 (Windows 전용).""" if self.last_generated_image is None: messagebox.showinfo("정보", "복사할 이미지가 없습니다.") return if win32clipboard is None: messagebox.showerror("오류", "이미지 복사 기능을 사용하려면 'pywin32' 라이브러리가 필요합니다.\n(pip install pywin32)") return if sys.platform == "win32": try: output = io.BytesIO() self.last_generated_image.convert("RGB").save(output, "BMP") data = output.getvalue()[14:] output.close() win32clipboard.OpenClipboard() win32clipboard.EmptyClipboard() win32clipboard.SetClipboardData(win32clipboard.CF_DIB, data) win32clipboard.CloseClipboard() #messagebox.showinfo("성공", "이미지를 클립보드에 복사했습니다.") except Exception as e: messagebox.showerror("복사 실패", f"이미지 복사 중 오류 발생: {e}") else: messagebox.showinfo("정보", "이미지 복사 기능은 Windows에서만 지원됩니다.") def _copy_file_object_to_clipboard(self): """임시 저장된 이미지 파일을 클립보드에 파일 객체로 복사합니다 (Windows 전용).""" if not os.path.exists(self.temp_image_path): messagebox.showinfo("정보", "복사할 파일이 없습니다.") return if sys.platform == "win32": try: command = f"powershell -command \"Get-Item '{self.temp_image_path}' | Set-Clipboard\"" subprocess.run(command, check=True, shell=True, creationflags=subprocess.CREATE_NO_WINDOW) #messagebox.showinfo("성공", "파일을 클립보드에 복사했습니다.") except Exception as e: messagebox.showerror("복사 실패", f"파일 복사 중 오류 발생: {e}") else: messagebox.showinfo("정보", "파일 복사 기능은 Windows에서만 지원됩니다.") def _show_context_menu(self, event): """이미지 레이블 위에서 우클릭 시 컨텍스트 메뉴를 표시합니다.""" if self.last_generated_image: self.image_context_menu.tk_popup(event.x_root, event.y_root) def _save_image_manual(self): """(수정됨) 이미지를 자동 저장 폴더에 순차적으로 저장합니다.""" if self.last_generated_image is None: messagebox.showinfo("정보", "저장할 이미지가 없습니다.") return try: save_dir = os.path.join("Output_WD14", self.save_session_folder) os.makedirs(save_dir, exist_ok=True) file_path = os.path.join(save_dir, f"{self.save_counter:05d}.png") self.last_generated_image.save(file_path, "PNG") #messagebox.showinfo("성공", f"이미지를 {file_path}에 저장했습니다.") self.save_counter += 1 self.save_btn.config(state=tk.DISABLED) # 저장 후 버튼 비활성화 except Exception as e: messagebox.showerror("저장 실패", f"이미지 저장 중 오류 발생: {e}") def _open_save_folder(self): """자동 저장 폴더를 엽니다.""" session_path = os.path.join("Output_WD14", self.save_session_folder) base_path = "Output_WD14" try: if os.path.isdir(session_path): path_to_open = session_path elif os.path.isdir(base_path): path_to_open = base_path else: messagebox.showinfo("정보", f"'{base_path}' 폴더를 찾을 수 없습니다.") return if sys.platform == "win32": os.startfile(os.path.realpath(path_to_open)) elif sys.platform == "darwin": subprocess.Popen(["open", os.path.realpath(path_to_open)]) else: subprocess.Popen(["xdg-open", os.path.realpath(path_to_open)]) except Exception as e: messagebox.showerror("오류", f"폴더를 여는 중 오류 발생: {e}") # Step 2 & 3 & 4의 로직을 처리할 메서드 def toggle_expand_ui(self): """체크박스 상태에 따라 우측 UI와 '즉시 생성' 버튼의 가시성을 제어합니다.""" main_frame = self.right_pane.master if self.expand_ui_var.get(): main_frame.grid_columnconfigure(1, weight=1, minsize=400) self.right_pane.grid(row=0, column=1, sticky="nsew") self.root.minsize(1220, 800) # '즉시 생성' 체크박스 표시 self.instant_generate_cb.pack(side=tk.LEFT, padx=(5,0)) else: self.right_pane.grid_forget() main_frame.grid_columnconfigure(1, weight=0, minsize=0) self.root.minsize(680, 800) # '즉시 생성' 체크박스 숨김 self.instant_generate_cb.pack_forget() # Step 3의 로직을 처리할 메서드 def show_generation_preview(self): """우측 패널에 512x512 흰색 이미지를 표시합니다.""" try: white_image = Image.new('RGB', (512, 512), 'white') photo = ImageTk.PhotoImage(white_image) # PhotoImage 객체에 대한 참조를 유지하여 가비지 컬렉션을 방지 self.generation_image_label.image = photo self.generation_image_label.config(image=photo) except Exception as e: self.generation_image_label.config(text=f"이미지 표시 오류: {e}") def random_seed(self): if int(self.seed_var.get()) == -1: self.seed_var.set(str(self.last_generated_seed)) else: self.seed_var.set("-1") def setup_ui(self): # ... (기존 setup_ui의 시작 부분은 동일) ... self.root.grid_columnconfigure(0, weight=1) self.root.grid_rowconfigure(0, weight=1) main_frame = ttk.Frame(self.root, padding="10") main_frame.grid(row=0, column=0, sticky="nsew") main_frame.grid_columnconfigure(0, weight=1) main_frame.grid_columnconfigure(1, weight=1, minsize=400) main_frame.grid_rowconfigure(0, weight=1) # --- 좌측 UI 패널 --- left_pane = ttk.Frame(main_frame) left_pane.grid(row=0, column=0, sticky="nsew") left_pane.grid_columnconfigure(0, weight=1) left_pane.grid_rowconfigure(2, weight=4) left_pane.grid_rowconfigure(3, weight=3) # ... (좌측 패널의 모든 UI 구성은 기존과 동일) ... load_frame = ttk.Frame(left_pane) load_frame.grid(row=0, column=0, sticky="ew", pady=(0, 10)) self.load_btn = ttk.Button(load_frame, text="이미지 불러오기", command=self.load_image) self.load_btn.pack(side="left", padx=(0, 5)) self.clipboard_btn = ttk.Button(load_frame, text="이미지 불러오기 (클립보드)", command=self.load_from_clipboard) self.clipboard_btn.pack(side="left") self.expand_ui_cb = ttk.Checkbutton(load_frame, text="생성 UI 확장", variable=self.expand_ui_var, command=self.toggle_expand_ui) self.expand_ui_cb.pack(side="left", padx=(10, 0)) content_frame = ttk.Frame(left_pane) content_frame.grid(row=1, column=0, sticky="new") content_frame.grid_columnconfigure(1, weight=1) self.preview_frame = ttk.LabelFrame(content_frame, text="미리보기", padding="10") self.preview_frame.grid(row=0, column=0, sticky="n", padx=(0, 10)) self.image_label = ttk.Label(self.preview_frame) self.image_label.grid(row=0, column=0) settings_frame = ttk.LabelFrame(content_frame, text="설정", padding="10") settings_frame.grid(row=0, column=1, sticky="nsew") settings_frame.grid_columnconfigure(1, weight=1) threshold_frame = ttk.Frame(settings_frame) threshold_frame.grid(row=0, column=0, columnspan=2, sticky="ew") threshold_frame.grid_columnconfigure(1, weight=1) ttk.Label(threshold_frame, text="태그 임계값:").grid(row=0, column=0, sticky="w") ttk.Spinbox(threshold_frame, from_=0.0, to=1.0, increment=0.05, textvariable=self.general_threshold_var, width=15).grid(row=0, column=1, sticky="we", pady=(0, 5)) extract_control_frame = ttk.Frame(settings_frame) extract_control_frame.grid(row=1, column=0, columnspan=2, pady=5, sticky="ew") self.extract_btn = ttk.Button(extract_control_frame, text="태그 추출", command=self.extract_tags, state='disabled') self.extract_btn.pack(side=tk.LEFT, padx=(0, 10)) self.instant_inference_cb = ttk.Checkbutton(extract_control_frame, text="즉시 추론", variable=self.instant_inference_var) self.instant_inference_cb.pack(side=tk.LEFT) # '즉시 생성' 체크박스 생성 (초기에는 숨겨짐) self.instant_generate_cb = ttk.Checkbutton(extract_control_frame, text="즉시 생성", variable=self.instant_generate_var) filter_frame = ttk.LabelFrame(settings_frame, text="태그 필터링", padding="5") filter_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(5, 0)) ttk.Checkbutton(filter_frame, text="캐릭터 특징+이름 제거", variable=self.rm_c_var).pack(anchor="w") ttk.Checkbutton(filter_frame, text="의류 색상 제거", variable=self.rm_color_var).pack(anchor="w") ttk.Checkbutton(filter_frame, text="WEBUI/Comfy 모드", variable=self.webui_mode_var).pack(anchor="w") prompt_outer_frame = ttk.Frame(settings_frame) prompt_outer_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(10, 0)) prompt_outer_frame.grid_columnconfigure(0, weight=1) self.prompt_active_cb = ttk.Checkbutton(prompt_outer_frame, text="선행/후행 프롬프트 활성", variable=self.prompt_active_var, command=self.toggle_prompts) self.prompt_active_cb.grid(row=0, column=0, sticky="w") self.prompt_notebook = ttk.Notebook(prompt_outer_frame) prefix_frame = ttk.Frame(self.prompt_notebook, padding=5) self.prompt_notebook.add(prefix_frame, text="선행 프롬프트") self.prefix_text = scrolledtext.ScrolledText(prefix_frame, height=3, width=30) self.prefix_text.pack(fill="both", expand=True) suffix_frame = ttk.Frame(self.prompt_notebook, padding=5) self.prompt_notebook.add(suffix_frame, text="후행 프롬프트") self.suffix_text = scrolledtext.ScrolledText(suffix_frame, height=3, width=30) self.suffix_text.pack(fill="both", expand=True) self.toggle_prompts() result_frame = ttk.LabelFrame(left_pane, text="추출된 태그", padding="10") result_frame.grid(row=2, column=0, sticky="nsew", pady=(10, 0)) result_frame.grid_columnconfigure(0, weight=1) result_frame.grid_rowconfigure(0, weight=3) result_frame.grid_rowconfigure(3, weight=2) self.result_text = scrolledtext.ScrolledText(result_frame, height=7, wrap=tk.WORD) self.result_text.grid(row=0, column=0, sticky="nsew") self.result_text.tag_config("prompt", foreground="grey") copy_frame = ttk.Frame(result_frame) copy_frame.grid(row=1, column=0, pady=(10, 5), sticky="w") ttk.Button(copy_frame, text="전체 복사", command=self.copy_all_tags).pack(side="left", padx=(0, 5)) ttk.Button(copy_frame, text="2번 태그부터 복사", command=self.copy_from_second).pack(side="left", padx=5) ttk.Button(copy_frame, text="3번 태그부터 복사", command=self.copy_from_third).pack(side="left", padx=5) auto_rating_cb = ttk.Checkbutton(copy_frame, text="nsfw/safe 태그 자동 반영", variable=self.auto_rating_tag_var) auto_rating_cb.pack(side="left", padx=10) self.removed_tags_cb = ttk.Checkbutton(result_frame, text="제거된 태그 보기", variable=self.show_removed_tags_var, command=self.toggle_removed_tags) self.removed_tags_cb.grid(row=2, column=0, sticky="w", pady=(10, 2)) self.removed_tags_text = scrolledtext.ScrolledText(result_frame, height=4, wrap=tk.WORD, state=tk.DISABLED) self.removed_tags_text.tag_config("ignored", foreground="red") self.toggle_removed_tags() bottom_frame = ttk.Frame(left_pane) bottom_frame.grid(row=3, column=0, sticky="nsew", pady=(10, 0)) bottom_frame.grid_columnconfigure(0, weight=1) bottom_frame.grid_rowconfigure(1, weight=1) bottom_control_frame = ttk.Frame(bottom_frame) bottom_control_frame.grid(row=0, column=0, sticky="ew") self.ignore_tags_cb = ttk.Checkbutton(bottom_control_frame, text="무시할 태그 편집 (쉼표로 구분)", variable=self.show_ignore_tags_var, command=self.toggle_ignore_tags) self.ignore_tags_cb.pack(side="left", anchor="w") self.save_settings_btn = ttk.Button(bottom_control_frame, text="설정 저장", command=self.save_settings) self.save_settings_btn.pack(side="right", anchor="e") self.ignore_tags_frame = ttk.Frame(bottom_frame) self.ignore_tags_frame.grid_columnconfigure(0, weight=1) self.ignore_tags_frame.grid_rowconfigure(0, weight=1) self.ignore_tags_text = scrolledtext.ScrolledText(self.ignore_tags_frame, height=4, wrap=tk.WORD) self.ignore_tags_text.grid(row=0, column=0, sticky="nsew") self.toggle_ignore_tags() # --- 우측 UI 패널 --- self.right_pane = ttk.Frame(main_frame) self.right_pane.grid_rowconfigure(0, weight=1) self.right_pane.grid_rowconfigure(2, weight=0) self.right_pane.grid_columnconfigure(0, weight=1) generation_preview_frame = ttk.LabelFrame(self.right_pane, text="생성 미리보기", padding="5") generation_preview_frame.grid(row=0, column=0, sticky="nsew") generation_preview_frame.grid_columnconfigure(0, weight=1) generation_preview_frame.grid_rowconfigure(0, weight=1) self.generation_image_label = ttk.Label(generation_preview_frame, anchor="center") self.generation_image_label.grid(row=0, column=0, sticky="nsew") self.generation_image_label.bind("", self._show_context_menu) # 우클릭 이벤트 바인딩 # 컨텍스트 메뉴 생성 self.image_context_menu = tk.Menu(self.root, tearoff=0) self.image_context_menu.add_command(label="이미지를 클립보드에 복사", command=self._copy_image_to_clipboard) self.image_context_menu.add_command(label="파일을 클립보드에 복사", command=self._copy_file_object_to_clipboard) self.image_context_menu.add_command(label="기본 뷰어로 이미지 열기", command=self._show_image_with_pil) # generation_button_frame 구성 부분 수정 generation_button_frame = ttk.Frame(self.right_pane) generation_button_frame.grid(row=1, column=0, sticky="ew", pady=(5, 0), padx=10) self.folder_btn = ttk.Button(generation_button_frame, text="📁", width=2, command=self._open_save_folder) self.folder_btn.pack(side="right") self.save_btn = ttk.Button(generation_button_frame, text="Save 💾", width=8, command=self._save_image_manual) self.save_btn.pack(side="right", padx=(5, 0)) self.copy_btn = ttk.Button(generation_button_frame, text=" Copy 🗃️", width=8, command=self._copy_image_to_clipboard) self.copy_btn.pack(side="right", padx=(5, 0)) self.generation_btn = ttk.Button(generation_button_frame, text="Generation", command=self.start_generation) self.generation_btn.pack(side="left", fill="x", expand=True) # --- Notebook 및 탭 설정 --- self.settings_notebook = ttk.Notebook(self.right_pane) self.settings_notebook.grid(row=2, column=0, sticky="nsew", pady=(5, 0), padx=10) # <<<<<<< 핵심 변경점: 모든 탭 프레임을 먼저 생성 >>>>>>>>> basic_settings_tab = ttk.Frame(self.settings_notebook, padding="10") char_prompt_tab = ttk.Frame(self.settings_notebook, padding=(10, 10, 10, 0)) account_settings_tab = ttk.Frame(self.settings_notebook, padding="10") # <<<<<<< 핵심 변경점: 원하는 순서대로 .add()만 사용 >>>>>>>>> self.settings_notebook.add(basic_settings_tab, text="기본설정") self.settings_notebook.add(char_prompt_tab, text="캐릭터 프롬프트") self.settings_notebook.add(account_settings_tab, text="NAI설정") # --- 이제 각 탭의 내용을 채웁니다 --- # 1. '기본설정' 탭 내용 구성 # ... ('기본설정' 탭의 모든 위젯 코드는 여기에 그대로 유지) ... basic_settings_tab.grid_columnconfigure(1, weight=1); basic_settings_tab.grid_columnconfigure(3, weight=1); basic_settings_tab.grid_columnconfigure(5, weight=1) sampler_list = ["k_euler + native", "k_euler + karras", "k_euler + exponential", "k_euler_ancestral + native", "k_euler_ancestral + karras","k_euler_ancestral + exponential", "k_dpmpp_2s_ancestral + native", "k_dpmpp_2s_ancestral + karras", "k_dpmpp_2s_ancestral + exponential", "k_dpmpp_sde + native", "k_dpmpp_sde + karras", "k_dpmpp_sde + exponential", "k_dpmpp_2m + native", "k_dpmpp_2m + karras", "k_dpmpp_2m + exponential","k_dpmpp_2m_sde + native", "k_dpmpp_2m_sde + karras", "k_dpmpp_2m_sde + exponential"] ttk.Label(basic_settings_tab, text="Sampler:").grid(row=0, column=0, sticky="w", padx=(0, 5)); sampler_cb = ttk.Combobox(basic_settings_tab, values=sampler_list, textvariable=self.sampler_var, state="readonly", width=25); sampler_cb.grid(row=0, column=1, sticky="ew"); sampler_cb.set(sampler_list[4]) ttk.Label(basic_settings_tab, text="Steps:").grid(row=0, column=2, sticky="w", padx=(10, 5)); ttk.Entry(basic_settings_tab, textvariable=self.steps_var, width=5).grid(row=0, column=3, sticky="ew") ttk.Label(basic_settings_tab, text="Scale:").grid(row=0, column=4, sticky="w", padx=(10, 5)); ttk.Entry(basic_settings_tab, textvariable=self.scale_var, width=5).grid(row=0, column=5, sticky="ew") resolution_list = ["1024 x 1024", "960 x 1088", "896 x 1152", "832 x 1216", "1088 x 960", "1152 x 896", "1216 x 832", "자동 해상도"] ttk.Label(basic_settings_tab, text="Resolution:").grid(row=1, column=0, sticky="w", pady=(5, 0), padx=(0, 5)); resolution_cb = ttk.Combobox(basic_settings_tab, values=resolution_list, textvariable=self.resolution_var, state="readonly", width=15); resolution_cb.grid(row=1, column=1, sticky="ew", pady=(5, 0)); resolution_cb.set(resolution_list[2]) ttk.Label(basic_settings_tab, text="CFG Rescale:").grid(row=1, column=2, sticky="w", pady=(5, 0), padx=(10, 5)); ttk.Entry(basic_settings_tab, textvariable=self.cfg_rescale_var, width=5).grid(row=1, column=3, sticky="ew", pady=(5, 0)) ttk.Label(basic_settings_tab, text="Seed:").grid(row=1, column=4, sticky="w", pady=(5,0), padx=(10, 5)); seed_frame = ttk.Frame(basic_settings_tab); seed_frame.grid(row=1, column=5, sticky="ew", pady=(5,0)); seed_frame.grid_columnconfigure(0, weight=1); ttk.Entry(seed_frame, textvariable=self.seed_var).pack(side="left", fill="x", expand=True); ttk.Button(seed_frame, text="🎲", width=2, command=self.random_seed).pack(side="right", padx=(5,0)) uc_header_frame = ttk.Frame(basic_settings_tab) uc_header_frame.grid(row=2, column=0, columnspan=6, sticky="ew", pady=(8, 2)) # 2. 컨테이너 프레임 내부에 기존 레이블과 신규 체크박스 배치 ttk.Label(uc_header_frame, text="Negative Prompt (UC):").pack(side="left") auto_save_cb = ttk.Checkbutton(uc_header_frame, text="NAI 생성 이미지를 자동으로 저장", variable=self.auto_save_var) auto_save_cb.pack(side="right") # UC 입력창은 다음 행(row=3)에 배치 self.uc_text = scrolledtext.ScrolledText(basic_settings_tab, height=4, wrap=tk.WORD) self.uc_text.grid(row=3, column=0, columnspan=6, sticky="nsew") # 2. '캐릭터 프롬프트' 탭 내용 구성 char_prompt_tab.grid_columnconfigure(0, weight=1) for i in range(3): # 각 캐릭터 라인을 담을 프레임 char_frame = ttk.Frame(char_prompt_tab, padding=(0, 0, 0, 5)) # <<<<<<< Step 1: 정적 그리드 배치 >>>>>>>>> char_frame.grid(row=i, column=0, sticky="ew", pady=2) # 즉시 고정 배치 # <<<<<<< Step 3: 단일 행 레이아웃 및 3:2 비율 설정 >>>>>>>>> # char_frame.grid_columnconfigure(2, weight=3) # P 입력창 가로 비율 # char_frame.grid_columnconfigure(4, weight=2) # UC 입력창 가로 비율 # 위젯 생성 check_btn = ttk.Checkbutton(char_frame, text=f"C{i+1}", variable=self.char_check_vars[i], command=self.update_character_layout) # style 제거 prompt_label = ttk.Label(char_frame, text="P:") prompt_text = scrolledtext.ScrolledText(char_frame, height=1, wrap=tk.WORD, width=46) uc_label = ttk.Label(char_frame, text="UC:") uc_text = scrolledtext.ScrolledText(char_frame, height=1, wrap=tk.WORD, width=24) # 위젯 배치 (단일 행 구조) check_btn.grid(row=0, column=0, sticky="w", padx=(0, 5)) prompt_label.grid(row=0, column=1, sticky="w", padx=(0, 2)) # <<<<<<< Step 3: sticky="ew"로 수정 >>>>>>>>> prompt_text.grid(row=0, column=2, sticky="ew") uc_label.grid(row=0, column=3, sticky="w", padx=(5, 2)) uc_text.grid(row=0, column=4, sticky="ew") # sticky="ew"로 수정 self.character_prompts.append({ "frame": char_frame, "check_var": self.char_check_vars[i], "prompt_widget": prompt_text, "uc_widget": uc_text }) # 3. '계정설정' 탭 내용 구성 token_frame = ttk.LabelFrame(account_settings_tab, text="Novel AI 영구 토큰 입력") token_frame.pack(fill="x", expand=False, padx=5, pady=5) token_frame.grid_columnconfigure(0, weight=1) token_entry = ttk.Entry(token_frame, textvariable=self.nai_token_var, show="*") token_entry.grid(row=0, column=0, sticky="ew", padx=(5,5), pady=5) verify_btn = ttk.Button(token_frame, text="검증", command=self.verify_token) verify_btn.grid(row=0, column=1, sticky="e", padx=(0,5), pady=5) model_frame = ttk.LabelFrame(account_settings_tab, text="모델 설정") model_frame.pack(fill="x", expand=False, padx=5, pady=(10, 5)) model_frame.grid_columnconfigure(0, weight=1) model_list = ["nai-diffusion-4-5-full"] model_cb = ttk.Combobox(model_frame, values=model_list, textvariable=self.model_var, state="disabled") model_cb.grid(row=0, column=0, sticky="ew", padx=5, pady=5) model_cb.set(model_list[0]) # --- UI 초기 상태 설정 --- self.show_generation_preview() self.update_character_layout() self.toggle_expand_ui() def update_character_layout(self): for p_info in self.character_prompts: if p_info['check_var'].get(): # 체크된 경우: 입력창 활성화 p_info['prompt_widget'].config(state=tk.NORMAL) p_info['uc_widget'].config(state=tk.NORMAL) else: # 체크 해제된 경우: 입력창 비활성화 p_info['prompt_widget'].config(state=tk.DISABLED) p_info['uc_widget'].config(state=tk.DISABLED) def verify_token(self): """[검증] 버튼 클릭 시 토큰 검증 스레드를 시작하는 메서드""" token = self.nai_token_var.get() if not token: messagebox.showwarning("입력 오류", "토큰을 입력해주세요.") return # 검증 시작을 알림 (선택 사항) self.root.config(cursor="watch") # 별도 스레드에서 API 요청 실행 thread = threading.Thread(target=self._verify_token_thread, args=(token,)) thread.daemon = True thread.start() def _verify_token_thread(self, token): """(스레드에서 실행) 실제 API 요청 및 검증 로직""" import requests try: response = requests.get( "https://api.novelai.net/user/subscription", headers={"Authorization": f"Bearer {token}"}, timeout=5 # 5초 이상 응답 없으면 타임아웃 ) response.raise_for_status() # 200번대 코드가 아니면 예외 발생 data_dict = response.json() # Opus 등급(unlimitedMaxPriority) 구독 여부 확인 if data_dict.get('perks', {}).get('unlimitedMaxPriority', False): result_message = "Opus 등급 구독이 확인되었습니다." result_type = "info" else: result_message = "유효한 토큰이나 Opus 등급 구독이 아닙니다." result_type = "warning" except requests.exceptions.HTTPError as e: if e.response.status_code == 401: result_message = "인증 실패: 유효하지 않은 토큰입니다." else: result_message = f"HTTP 오류 발생: {e.response.status_code}" result_type = "error" except requests.exceptions.RequestException as e: result_message = f"네트워크 오류: API 서버에 연결할 수 없습니다.\n{e}" result_type = "error" except Exception as e: result_message = f"알 수 없는 오류 발생: {e}" result_type = "error" # GUI 업데이트는 메인 스레드에서 안전하게 처리 self.root.after(0, self._update_verification_result, result_type, result_message) def _update_verification_result(self, result_type, message): """(메인 스레드에서 실행) 검증 결과를 messagebox로 표시""" self.root.config(cursor="") # 커서 원래대로 if result_type == "info": messagebox.showinfo("검증 성공", message) elif result_type == "warning": messagebox.showwarning("검증 확인", message) else: # error messagebox.showerror("검증 실패", message) def init_filter_data(self): try: from tagbag import bag_of_tags, clothes_list from character_dictionary import character_dictionary as cd self.bag_of_tags = bag_of_tags self.clothes_list = clothes_list self.character_keys = list(cd.keys()) if isinstance(cd, dict) else [] except ImportError as e: print(f"⚠ NAIA 파일 import 실패: {e}") print("기본 데이터를 사용합니다.") self.bag_of_tags = [ 'smile', 'blush', 'open_mouth', 'closed_eyes', 'wink', 'frown', 'twintails', 'ponytail', 'braid', 'long_hair', 'short_hair', 'large_breasts', 'small_breasts', 'cleavage', 'navel' ] self.clothes_list = [ 'dress', 'skirt', 'shirt', 'jacket', 'uniform', 'school_uniform' ] self.character_keys = [] self.colors = ['black', 'white', 'blond', 'silver', 'gray', 'yellow', 'blue', 'purple', 'red', 'pink', 'brown', 'orange', 'green', 'aqua', 'gradient'] def toggle_removed_tags(self): if self.show_removed_tags_var.get(): self.removed_tags_text.grid(row=3, column=0, sticky="nsew", pady=(5, 0)) else: self.removed_tags_text.grid_forget() def toggle_ignore_tags(self): if self.show_ignore_tags_var.get(): self.ignore_tags_frame.grid(row=1, column=0, sticky="nsew") else: self.ignore_tags_frame.grid_forget() def toggle_prompts(self): if self.prompt_active_var.get(): self.prompt_notebook.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0)) else: self.prompt_notebook.grid_forget() def on_closing(self): self.save_settings() self.root.destroy() def save_settings(self): """현재 UI의 모든 설정을 JSON 파일에 저장합니다.""" settings = { # 좌측 패널 설정 'general_threshold': self.general_threshold_var.get(), 'char_threshold': self.char_threshold_var.get(), 'rm_c': self.rm_c_var.get(), 'rm_color': self.rm_color_var.get(), 'rm_clothes': self.rm_clothes_var.get(), 'webui_mode': self.webui_mode_var.get(), 'instant_inference': self.instant_inference_var.get(), 'ignore_tags': self.ignore_tags_text.get(1.0, tk.END).strip(), 'show_removed_tags': self.show_removed_tags_var.get(), 'show_ignore_tags': self.show_ignore_tags_var.get(), 'prompt_active': self.prompt_active_var.get(), 'prefix_prompt': self.prefix_text.get(1.0, tk.END).strip(), 'suffix_prompt': self.suffix_text.get(1.0, tk.END).strip(), # 우측 패널 (생성 UI) 설정 'expand_ui': self.expand_ui_var.get(), 'sampler': self.sampler_var.get(), 'resolution': self.resolution_var.get(), 'steps': self.steps_var.get(), 'scale': self.scale_var.get(), 'seed': self.seed_var.get(), 'cfg_rescale': self.cfg_rescale_var.get(), 'uc_prompt': self.uc_text.get(1.0, tk.END).strip(), 'nai_token': self.nai_token_var.get() } settings['auto_save'] = self.auto_save_var.get() settings['auto_rating_tag'] = self.auto_rating_tag_var.get() # 캐릭터 프롬프트 설정 저장 char_prompts_data = [] for p_info in self.character_prompts: char_prompts_data.append({ "checked": p_info['check_var'].get(), "prompt": p_info['prompt_widget'].get(1.0, tk.END).strip(), "uc": p_info['uc_widget'].get(1.0, tk.END).strip() }) settings['character_prompts'] = char_prompts_data try: with open(self.SETTINGS_FILE, 'w', encoding='utf-8') as f: json.dump(settings, f, indent=4) except Exception as e: messagebox.showerror("저장 실패", f"설정 저장 중 오류가 발생했습니다: {e}") def load_settings(self): """JSON 파일에서 설정을 불러와 UI에 적용합니다.""" try: if os.path.exists(self.SETTINGS_FILE): with open(self.SETTINGS_FILE, 'r', encoding='utf-8') as f: settings = json.load(f) # 좌측 패널 설정 불러오기 self.general_threshold_var.set(settings.get('general_threshold', 0.5)) self.char_threshold_var.set(settings.get('char_threshold', 0.85)) self.rm_c_var.set(settings.get('rm_c', False)) self.rm_color_var.set(settings.get('rm_color', False)) self.rm_clothes_var.set(settings.get('rm_clothes', False)) self.webui_mode_var.set(settings.get('webui_mode', False)) self.instant_inference_var.set(settings.get('instant_inference', False)) self.ignore_tags_text.delete(1.0, tk.END) self.ignore_tags_text.insert(1.0, settings.get('ignore_tags', '')) self.show_removed_tags_var.set(settings.get('show_removed_tags', True)) self.show_ignore_tags_var.set(settings.get('show_ignore_tags', False)) self.prompt_active_var.set(settings.get('prompt_active', False)) self.prefix_text.delete(1.0, tk.END) self.prefix_text.insert(1.0, settings.get('prefix_prompt', '')) self.suffix_text.delete(1.0, tk.END) self.suffix_text.insert(1.0, settings.get('suffix_prompt', '')) # 우측 패널 (생성 UI) 설정 불러오기 self.expand_ui_var.set(settings.get('expand_ui', False)) self.sampler_var.set(settings.get('sampler', 'k_euler_ancestral + karras')) self.resolution_var.set(settings.get('resolution', '896 x 1152')) self.steps_var.set(settings.get('steps', '28')) self.scale_var.set(settings.get('scale', '5.8')) self.seed_var.set(settings.get('seed', '-1')) self.cfg_rescale_var.set(settings.get('cfg_rescale', '0.25')) self.uc_text.delete(1.0, tk.END) self.uc_text.insert(1.0, settings.get('uc_prompt', '')) self.nai_token_var.set(settings.get('nai_token', '')) self.auto_save_var.set(settings.get('auto_save', True)) self.auto_rating_tag_var.set(settings.get('auto_rating_tag', True)) # 캐릭터 프롬프트 설정 불러오기 char_prompts_data = settings.get('character_prompts', []) for i, data in enumerate(char_prompts_data): if i < len(self.character_prompts): p_info = self.character_prompts[i] p_info['check_var'].set(data.get('checked', i==0)) # 기본값은 첫번째만 True p_info['prompt_widget'].delete(1.0, tk.END) p_info['prompt_widget'].insert(1.0, data.get('prompt', '')) p_info['uc_widget'].delete(1.0, tk.END) p_info['uc_widget'].insert(1.0, data.get('uc', '')) # UI 상태 업데이트 self.toggle_removed_tags() self.toggle_ignore_tags() self.toggle_prompts() self.update_character_layout() self.toggle_expand_ui() # 저장된 값에 따라 확장 UI 상태 결정 except Exception as e: messagebox.showwarning("설정 로드 실패", f"설정 파일({self.SETTINGS_FILE})을 불러오는 데 실패했습니다: {e}") def handle_paste(self, event): try: clipboard_image = ImageGrab.grabclipboard() if isinstance(clipboard_image, Image.Image): self.load_from_clipboard() return "break" except (ValueError, TypeError): pass except Exception as e: print(f"클립보드 이미지 확인 중 오류: {e}") try: clipboard_text = self.root.clipboard_get() if isinstance(clipboard_text, str) and clipboard_text.startswith(('http://', 'https://')): self._load_image_from_url(clipboard_text) return "break" image_extensions = ('.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff') if isinstance(clipboard_text, str) and clipboard_text.lower().endswith(image_extensions): path = clipboard_text.strip('"') if os.path.exists(path): self._load_image_from_path(path) return "break" except tk.TclError: pass except Exception as e: messagebox.showerror("붙여넣기 오류", f"처리 중 오류 발생: {e}") return "break" return def _load_image_from_url(self, url): try: img = self.download_image_from_url(url) temp_dir = tempfile.gettempdir() temp_file = os.path.join(temp_dir, f"wd14_url_{os.getpid()}.png") img.save(temp_file) self.current_image_path = temp_file self.show_preview(temp_file) if self.session is not None: self.extract_btn.config(state='normal') if self.instant_inference_var.get(): self.extract_tags() except Exception as e: messagebox.showerror("오류", f"웹 이미지 로드 실패: {str(e)}") def load_model(self): try: import onnxruntime as ort model_path = resource_path("model.onnx") tags_path = resource_path("selected_tags.csv") self.session = ort.InferenceSession(model_path, providers=['CPUExecutionProvider']) self.tags_df = pd.read_csv(tags_path) except Exception as e: messagebox.showerror("오류", f"모델 로드 실패: {str(e)}") def show_initial_preview(self): try: white_image = Image.new('RGB', (290, 290), '#F0F0F0') photo = ImageTk.PhotoImage(white_image) self.image_label.config(image=photo, text="이미지를 Drag & Drop\n또는 붙여넣기(Ctrl+V) 해주세요", compound="center", foreground="gray") self.image_label.image = photo except Exception as e: self.image_label.config(text="이미지를 Drag & Drop 해주세요") def setup_drag_drop(self): try: from tkinterdnd2 import TkinterDnD, DND_ALL self.TkdndVersion = TkinterDnD._require(self.root) self.image_label.drop_target_register(DND_ALL) self.image_label.dnd_bind('<>', self.on_drop) self.image_label.dnd_bind('<>', self.on_drag_enter) self.image_label.dnd_bind('<>', self.on_drag_leave) except ImportError: print("tkinterdnd2가 설치되지 않음. pip install tkinterdnd2로 설치하면 드래그앤드롭을 사용할 수 있습니다.") except Exception as e: print(f"Drag & Drop 초기화 오류: {e}") def on_drag_enter(self, event): try: self.preview_frame.config(text="미리보기 (이미지를 놓아주세요!)") except: pass def on_drag_leave(self, event): try: self.preview_frame.config(text="미리보기") except: pass def download_image_from_url(self, url): import requests response = requests.get(url) response.raise_for_status() return Image.open(io.BytesIO(response.content)) def on_drop(self, event): try: if not isinstance(event, str): file_path_or_url = event.data.strip("{}") else: file_path_or_url = event if not file_path_or_url: messagebox.showwarning("경고", "드롭된 데이터를 읽을 수 없습니다.") self.preview_frame.config(text="미리보기") return if file_path_or_url.startswith(('http://', 'https://')): self._load_image_from_url(file_path_or_url) self.preview_frame.config(text="미리보기") return else: if file_path_or_url.startswith(('blob:')): messagebox.showwarning("Blob URL 오류", "NAI 홈페이지에서 생성한 이미지는 즉시 복사할 수 없습니다.\n이미지를 다운로드한 후 드래그 & 드롭해주세요.") self.preview_frame.config(text="미리보기") return file_path_or_url = file_path_or_url.replace("\\", '/') if os.path.exists(file_path_or_url): self.current_image_path = file_path_or_url self.show_preview(file_path_or_url) if self.session is not None: self.extract_btn.config(state='normal') if self.instant_inference_var.get(): self.extract_tags() self.preview_frame.config(text="미리보기") else: messagebox.showerror("오류", f"유효하지 않은 파일 경로입니다: '{file_path_or_url}'") self.preview_frame.config(text="미리보기") except Exception as e: messagebox.showerror("오류", f"파일 드롭 오류: {str(e)}") self.preview_frame.config(text="미리보기") def _load_image_from_path(self, path): if os.path.exists(path): self.current_image_path = path self.show_preview(path) if self.session is not None: self.extract_btn.config(state='normal') if self.instant_inference_var.get(): self.extract_tags() else: messagebox.showwarning("경로 오류", f"파일을 찾을 수 없습니다: {path}") def load_from_clipboard(self): try: image = ImageGrab.grabclipboard() if isinstance(image, Image.Image): temp_dir = tempfile.gettempdir() temp_file = os.path.join(temp_dir, f"wd14_clipboard_{os.getpid()}.png") if image.mode == 'RGBA': image = image.convert('RGB') image.save(temp_file, 'PNG') self.current_image_path = temp_file self.show_preview(temp_file) if self.session is not None: self.extract_btn.config(state='normal') if self.instant_inference_var.get(): self.extract_tags() else: messagebox.showinfo("정보", "클립보드에 이미지가 없습니다.") except Exception as e: messagebox.showerror("오류", f"클립보드 불러오기 오류: {str(e)}") def load_image(self): file_path = filedialog.askopenfilename( title="이미지 선택", filetypes=[("이미지 파일", "*.jpg *.jpeg *.png *.bmp *.tiff *.webp")] ) if file_path: self.current_image_path = file_path self.show_preview(file_path) if self.session is not None: self.extract_btn.config(state='normal') if self.instant_inference_var.get(): self.extract_tags() def show_preview(self, image_path): try: image = Image.open(image_path).convert('RGB') size = max(image.size) square_image = Image.new('RGB', (size, size), 'white') paste_x = (size - image.width) // 2 paste_y = (size - image.height) // 2 square_image.paste(image, (paste_x, paste_y)) square_image.thumbnail((290, 290), Image.Resampling.LANCZOS) photo = ImageTk.PhotoImage(square_image) self.image_label.config(image=photo, text="") self.image_label.image = photo except Exception as e: self.image_label.config(text=f"이미지 로드 실패: {str(e)}") def extract_tags(self): if not self.current_image_path or not self.session: return self.extract_btn.config(state='disabled', text='추출 중...') self.result_text.delete(1.0, tk.END) self.result_text.insert(tk.END, "태그 추출 중...") self.removed_tags_text.config(state=tk.NORMAL) self.removed_tags_text.delete(1.0, tk.END) self.removed_tags_text.config(state=tk.DISABLED) thread = threading.Thread(target=self._extract_tags_thread) thread.daemon = True thread.start() def _extract_tags_thread(self): try: input_image = Image.open(self.current_image_path) _, height, _, _ = self.session.get_inputs()[0].shape image = input_image.convert('RGBA') new_image = Image.new('RGBA', image.size, 'WHITE') new_image.paste(image, mask=image) image = new_image.convert('RGB') image = np.asarray(image)[:, :, ::-1] image = self.make_square(image, height) image = self.smart_resize(image, height) image = image.astype(np.float32) image = np.expand_dims(image, 0) input_name = self.session.get_inputs()[0].name label_name = self.session.get_outputs()[0].name confidents = self.session.run([label_name], {input_name: image})[0] tags_with_conf = [] for i, conf in enumerate(confidents[0]): if i < len(self.tags_df): tags_with_conf.append((self.tags_df.iloc[i]['name'], float(conf))) rating_tags = dict(tags_with_conf[:4]) general_tags = dict(tags_with_conf[4:]) best_rating = max(rating_tags, key=rating_tags.get) if rating_tags else None result_tuple = self._format_results(general_tags, best_rating) # best_rating 전달 self.root.after(0, self._update_results, result_tuple) except Exception as e: error_msg = f"태그 추출 오류: {str(e)}" self.root.after(0, self._update_results, ((None, error_msg, ""))) def apply_tag_filters(self, tags_list, best_rating=None): original_tags = tags_list.copy() tags_to_process = [tag.replace('_', ' ') for tag in original_tags] final_indices = list(range(len(tags_to_process))) user_removed_originals = [] other_removed_originals = [] ignore_list_str = self.ignore_tags_text.get(1.0, tk.END).strip() if ignore_list_str: user_ignored_tags = {tag.strip().replace('_', ' ') for tag in ignore_list_str.split(',') if tag.strip()} indices_to_remove = {i for i in final_indices if tags_to_process[i] in user_ignored_tags} for i in indices_to_remove: user_removed_originals.append(original_tags[i]) final_indices = [i for i in final_indices if i not in indices_to_remove] if self.rm_c_var.get(): indices_to_remove = {i for i in final_indices if tags_to_process[i] in self.bag_of_tags or self._is_character_name(tags_to_process[i])} for i in indices_to_remove: other_removed_originals.append(original_tags[i]) final_indices = [i for i in final_indices if i not in indices_to_remove] if self.rm_color_var.get(): indices_to_remove = {i for i in final_indices if any(color+" " in tags_to_process[i] for color in self.colors) and " eyes" not in tags_to_process[i] and " hair" not in tags_to_process[i] and " pupils" not in tags_to_process[i]} for i in indices_to_remove: other_removed_originals.append(original_tags[i]) final_indices = [i for i in final_indices if i not in indices_to_remove] if self.rm_clothes_var.get(): indices_to_remove = {i for i in final_indices if tags_to_process[i] in self.clothes_list and tags_to_process[i] not in self.bag_of_tags} for i in indices_to_remove: other_removed_originals.append(original_tags[i]) final_indices = [i for i in final_indices if i not in indices_to_remove] final_tags_with_space = [tags_to_process[i] for i in final_indices] if self.auto_rating_tag_var.get() and best_rating: prefix_str = self.prefix_text.get(1.0, tk.END) suffix_str = self.suffix_text.get(1.0, tk.END) if not self.webui_mode_var.get(): best_rating = "rating:" + best_rating tag_to_add = None if best_rating in ["rating:explicit", "rating:questionable", "questionable", "explicit"]: if "nsfw" not in prefix_str and "nsfw" not in suffix_str: tag_to_add = best_rating +", nsfw" elif best_rating in ["rating:general", "general"]: if "safe" not in prefix_str and "safe" not in suffix_str: tag_to_add = best_rating +", safe" else: tag_to_add = best_rating # nsfw/safe 태그 추가 if tag_to_add: person_tags = {'1girl', '2girls', '3girls', '4girls', '5girls', '6+girls', '1boy', '2boys', '3boys', '4boys', '5boys', '6+boys'} insert_pos = 0 for i, tag in enumerate(final_tags_with_space): if tag in person_tags: insert_pos = i + 1 break final_tags_with_space.insert(insert_pos, tag_to_add) if self.webui_mode_var.get(): final_tags_formatted = [tag.replace('(', '\\(').replace(')', '\\)') for tag in final_tags_with_space] else: final_tags_formatted = final_tags_with_space final_tags_with_type = [(tag, 'original') for tag in final_tags_formatted] if self.prompt_active_var.get(): prefix_str = self.prefix_text.get(1.0, tk.END).strip() if prefix_str: prefix_tags = [t.strip() for t in prefix_str.split(',') if t.strip()] prefix_tags_with_type = [(tag, 'prompt') for tag in prefix_tags] person_tags = {'1girl', '2girls', '3girls', '4girls', '5girls', '6+girls', '1boy', '2boys', '3boys', '4boys', '5boys', '6+boys'} last_person_tag_index = -1 search_range = min(len(final_tags_with_type), 4) for i in range(search_range): if final_tags_with_type[i][0] in person_tags: last_person_tag_index = i if last_person_tag_index != -1: final_tags_with_type[last_person_tag_index+1:last_person_tag_index+1] = prefix_tags_with_type else: final_tags_with_type[0:0] = prefix_tags_with_type suffix_str = self.suffix_text.get(1.0, tk.END).strip() if suffix_str: suffix_tags = [t.strip() for t in suffix_str.split(',') if t.strip()] suffix_tags_with_type = [(tag, 'prompt') for tag in suffix_tags] final_tags_with_type.extend(suffix_tags_with_type) return final_tags_with_type, user_removed_originals, list(set(other_removed_originals)) def _is_character_name(self, tag): if hasattr(self, 'character_keys') and tag in self.character_keys: return True if ('_' in tag and any(c.isupper() for c in tag) and not tag.startswith('1') and not any(word in tag.lower() for word in ['girl', 'boy', 'solo', 'multiple']) and not any(word in tag.lower() for word in ['hair', 'eye', 'dress', 'shirt']) and len(tag) > 3): return True return False def _format_results(self, tags, best_rating=None): general_threshold = self.general_threshold_var.get() char_threshold = self.char_threshold_var.get() person_tags = {'1girl', '2girls', '3girls', '4girls', '5girls', '6+girls', '1boy', '2boys', '3boys', '4boys', '5boys', '6+boys', 'multiple_girls', 'multiple_boys', 'solo', 'no_humans'} filtered_tags = [] for tag, conf in tags.items(): if tag in person_tags: if conf >= 0.9: filtered_tags.append(tag) elif any(c.isupper() for c in tag) and '_' in tag and conf >= char_threshold: filtered_tags.append(tag) elif conf >= general_threshold: filtered_tags.append(tag) if len(filtered_tags) > 99: filtered_tags = filtered_tags[:99] final_tags_with_type, user_removed, other_removed = self.apply_tag_filters(filtered_tags, best_rating) removed_for_display = [] for tag in user_removed: removed_for_display.append((tag, 'ignored')) for tag in other_removed: if tag not in user_removed: removed_for_display.append((tag, 'other')) return final_tags_with_type, removed_for_display def copy_all_tags(self): content = self.result_text.get(1.0, tk.END).strip() if content: self.root.clipboard_clear() self.root.clipboard_append(content) else: messagebox.showwarning("복사 실패", "복사할 태그가 없습니다.") def copy_from_second(self): self._copy_from_index(1, "2번 태그부터") def copy_from_third(self): self._copy_from_index(2, "3번 태그부터") def _copy_from_index(self, start_index, description): content = self.result_text.get(1.0, tk.END).strip() if content: tags_list = [tag.strip() for tag in content.split(',')] if len(tags_list) > start_index: selected_tags = tags_list[start_index:] result = ', '.join(selected_tags) self.root.clipboard_clear() self.root.clipboard_append(result) else: messagebox.showwarning("복사 실패", f"태그가 {start_index + 1}개 미만입니다.") else: messagebox.showwarning("복사 실패", "복사할 태그가 없습니다.") def _update_results(self, result_tuple): main_tags_info, removed_tags_info = result_tuple[0], result_tuple[1] if len(result_tuple) == 3: # 에러가 아닌 정상 결과일 경우 main_tags_info, removed_tags_info = result_tuple[1], result_tuple[2] # 메인 태그 박스 업데이트 self.result_text.delete(1.0, tk.END) if isinstance(main_tags_info, list): for i, (tag, tag_type) in enumerate(main_tags_info): if i > 0: self.result_text.insert(tk.END, ', ') if tag_type == 'prompt': self.result_text.insert(tk.END, tag, 'prompt') else: self.result_text.insert(tk.END, tag) else: # 오류 메시지 처리 self.result_text.insert(tk.END, main_tags_info) self.removed_tags_text.config(state=tk.NORMAL) self.removed_tags_text.delete(1.0, tk.END) if removed_tags_info: for i, (tag, tag_type) in enumerate(removed_tags_info): display_tag = tag.replace('_', ' ') if i > 0: self.removed_tags_text.insert(tk.END, ', ') if tag_type == 'ignored': self.removed_tags_text.insert(tk.END, display_tag, 'ignored') else: self.removed_tags_text.insert(tk.END, display_tag) self.removed_tags_text.config(state=tk.DISABLED) self.extract_btn.config(state='normal', text='태그 추출') if (self.instant_generate_var.get() and self.expand_ui_var.get() and self.is_generating == False): self.start_generation() def make_square(self, img, target_size): old_size = img.shape[:2] desired_size = max(old_size) desired_size = max(desired_size, target_size) delta_w = desired_size - old_size[1] delta_h = desired_size - old_size[0] top, bottom = delta_h // 2, delta_h - (delta_h // 2) left, right = delta_w // 2, delta_w - (delta_w // 2) color = [255, 255, 255] new_im = cv2.copyMakeBorder( img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color ) return new_im def smart_resize(self, img, size): if img.shape[0] > size: img = cv2.resize(img, (size, size), interpolation=cv2.INTER_AREA) elif img.shape[0] < size: img = cv2.resize(img, (size, size), interpolation=cv2.INTER_CUBIC) return img def find_max_resolution(self, width, height, max_pixels=1048576, multiple_of=64): """가로세로 비율을 유지하며 최대 픽셀 수에 맞는 최대 해상도를 찾습니다.""" ratio = width / height max_width = int((max_pixels * ratio)**0.5) max_height = int((max_pixels / ratio)**0.5) max_width = (max_width // multiple_of) * multiple_of max_height = (max_height // multiple_of) * multiple_of while max_width * max_height > max_pixels: max_width -= multiple_of max_height = int(max_width / ratio) max_height = (max_height // multiple_of) * multiple_of return (max_width, max_height) def start_generation(self): if not self.nai_token_var.get(): messagebox.showerror("토큰 오류", "NAI 설정 탭에서 API 토큰을 먼저 입력해주세요.") return if not self.result_text.get(1.0, tk.END).strip(): messagebox.showerror("프롬프트 오류", "좌측 '추출된 태그'에 프롬프트가 없습니다.") return # --- 수정된 부분 --- self.is_generating = True self.generation_start_time = time.time() self.generation_btn.config(state=tk.DISABLED) self.save_btn.config(state=tk.NORMAL) self.generation_image_label.config(image=None, text="🎨 이미지 생성 중...") self._update_timer() # 타이머 시작 thread = threading.Thread(target=self._generation_thread) thread.daemon = True thread.start() def _generation_thread(self): """(백그라운드 스레드) 파라미터 수집, API 요청 및 결과 반환""" try: # 1. 파라미터 수집 및 전처리 access_token = self.nai_token_var.get() model_name = self.model_var.get() main_prompt = self.result_text.get(1.0, tk.END).strip() negative_prompt = self.uc_text.get(1.0, tk.END).strip() # 해상도 처리 resolution_str = self.resolution_var.get() if resolution_str == "자동 해상도": if self.current_image_path and os.path.exists(self.current_image_path): with Image.open(self.current_image_path) as img: w, h = img.size width, height = self.find_max_resolution(w, h) else: # 불러온 이미지가 없으면 1:1 비율로 계산 width, height = self.find_max_resolution(1024, 1024) else: parts = resolution_str.split(' x ') width, height = int(parts[0]), int(parts[1]) # 샘플러 및 스케줄러 분리 sampler_parts = self.sampler_var.get().split(' + ') sampler = sampler_parts[0] noise_schedule = sampler_parts[1] if len(sampler_parts) > 1 else 'karras' # 기본값 seed = int(self.seed_var.get()) if seed == -1: seed = random.randint(0, 9999999999) self.last_generated_seed = seed # 2. API Payload 구성 parameters = { "width": width, "height": height, "n_samples": 1, "seed": seed, "extra_noise_seed": seed, "sampler": sampler, "steps": int(self.steps_var.get()), "scale": float(self.scale_var.get()), "negative_prompt": negative_prompt, "cfg_rescale": float(self.cfg_rescale_var.get()), "noise_schedule": noise_schedule, } data = { "input": main_prompt, "model": model_name, "action": "generate", "parameters": parameters } # V4 특화 설정 if 'nai-diffusion-4' in model_name: data['parameters'].update({ 'params_version': 3, 'add_original_image': True, 'legacy': False, 'legacy_uc': False, 'autoSmea': True, 'prefer_brownian': True, 'ucPreset': 0, 'use_coords': False, 'v4_negative_prompt': {'caption': {'base_caption': negative_prompt, 'char_captions': []}, 'legacy_uc': False}, 'v4_prompt': {'caption': {'base_caption': main_prompt, 'char_captions': []}, 'use_coords': False, 'use_order': True} }) # 활성화된 캐릭터 프롬프트 처리 active_chars = [p for p in self.character_prompts if p['check_var'].get()] if active_chars: for char_info in active_chars: char_prompt = char_info['prompt_widget'].get(1.0, tk.END).strip() char_uc = char_info['uc_widget'].get(1.0, tk.END).strip() char_v4_prompt = {'char_caption': char_prompt, 'centers': [{'x': 0.5, 'y': 0.5}]} char_v4_uc = {'char_caption': char_uc, 'centers': [{'x': 0.5, 'y': 0.5}]} data['parameters']['v4_prompt']['caption']['char_captions'].append(char_v4_prompt) data['parameters']['v4_negative_prompt']['caption']['char_captions'].append(char_v4_uc) # 3. API 요청 import requests response = requests.post( "https://image.novelai.net/ai/generate-image", headers={"Authorization": f"Bearer {access_token}"}, json=data, timeout=180 ) response.raise_for_status() # 4. 이미지 처리 image = self._process_nai_response(response.content) self.root.after(0, self._update_generation_result, image) except Exception as e: self.root.after(0, self._update_generation_result, e) def _process_nai_response(self, content): """NAI 응답(zip)을 처리하여 PIL Image 객체를 반환합니다.""" import zipfile try: zipped = zipfile.ZipFile(io.BytesIO(content)) image_bytes = zipped.read(zipped.infolist()[0]) image = Image.open(io.BytesIO(image_bytes)) return image except Exception as e: raise Exception(f"응답 데이터 처리 실패: {e}") def start_generation(self): # ... (기존 검증 로직) ... self.is_generating = True self.generation_start_time = time.time() self.generation_btn.config(state=tk.DISABLED) self.save_btn.config(state=tk.NORMAL) # <<<< 생성 시작 시 저장 버튼 활성화 self.generation_image_label.config(image=None, text="🎨 이미지 생성 중...") self._update_timer() thread = threading.Thread(target=self._generation_thread) thread.daemon = True thread.start() def _update_generation_result(self, result): self.is_generating = False self.generation_btn.config(state=tk.NORMAL, text="Generation") if isinstance(result, Image.Image): self.last_generated_image = result # 원본 이미지 보관 self.last_generated_image.save(self.temp_image_path, "PNG") # 임시 파일로 저장 if self.auto_save_var.get(): try: save_dir = os.path.join("Output_WD14", self.save_session_folder) os.makedirs(save_dir, exist_ok=True) file_path = os.path.join(save_dir, f"{self.save_counter:05d}.png") self.last_generated_image.save(file_path, "PNG") self.save_counter += 1 except Exception as e: print(f"자동 저장 실패: {e}") thumbnail = self._create_thumbnail(result) photo = ImageTk.PhotoImage(thumbnail) self.generation_image_label.config(image=photo, text="") self.generation_image_label.image = photo elif isinstance(result, Exception): self.show_generation_preview() messagebox.showerror("생성 실패", f"이미지 생성 중 오류가 발생했습니다:\n{result}") def _show_image_with_pil(self): """PIL 라이브러리를 사용하여 마지막으로 생성된 이미지를 기본 이미지 뷰어로 엽니다.""" if self.last_generated_image: try: self.last_generated_image.show() except Exception as e: messagebox.showerror("오류", f"이미지를 여는 중 오류가 발생했습니다: {e}") else: messagebox.showinfo("정보", "표시할 이미지가 없습니다.") def main(): try: from tkinterdnd2 import TkinterDnD root = TkinterDnD.Tk() except ImportError: print("tkinterdnd2가 설치되지 않아 Drag & Drop 기능이 비활성화됩니다.") root = tk.Tk() app = WD14TaggerGUI(root) root.mainloop() if __name__ == "__main__": main()