From 145368692e85ed46c41db7f02db9579965cb1fcc Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 28 Jan 2026 16:06:56 +0100 Subject: [PATCH] caption --- __pycache__/engine.cpython-312.pyc | Bin 43105 -> 58093 bytes __pycache__/gallery_app.cpython-312.pyc | Bin 92184 -> 116889 bytes engine.py | 395 +++++++++++++++++++++- gallery_app.py | 421 ++++++++++++++++++++++-- requirements.txt | 3 +- 5 files changed, 793 insertions(+), 26 deletions(-) diff --git a/__pycache__/engine.cpython-312.pyc b/__pycache__/engine.cpython-312.pyc index fc732415f29855c5d0f1ae9b1d4ab6770f88818b..b1fbcddf0b03cf370e4a5f55ef7c90a5d3c0319f 100644 GIT binary patch delta 16117 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 delta 4651 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 LzJ+XM+}!^GHT#BE delta 14642 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 aE7dw3%!Vs|6#!GeZ04CKp;=E_=6?W0|B2iH diff --git a/engine.py b/engine.py index c34910c..1a03618 100644 --- a/engine.py +++ b/engine.py @@ -1,6 +1,9 @@ import os import shutil import sqlite3 +import base64 +import requests +from datetime import datetime from contextlib import contextmanager from PIL import Image from io import BytesIO @@ -68,6 +71,27 @@ class SorterEngine: for cat in ["_TRASH", "control", "Default", "Action", "Solo"]: cursor.execute("INSERT OR IGNORE INTO categories VALUES (?)", (cat,)) + # --- CAPTION TABLES --- + # Per-category prompt templates + cursor.execute('''CREATE TABLE IF NOT EXISTS category_prompts + (profile TEXT, category TEXT, prompt_template TEXT, + PRIMARY KEY (profile, category))''') + + # Stored captions + cursor.execute('''CREATE TABLE IF NOT EXISTS image_captions + (image_path TEXT PRIMARY KEY, caption TEXT, model TEXT, + generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + # Caption API settings per profile + cursor.execute('''CREATE TABLE IF NOT EXISTS caption_settings + (profile TEXT PRIMARY KEY, + api_endpoint TEXT DEFAULT 'http://localhost:8080/v1/chat/completions', + model_name TEXT DEFAULT 'local-model', + max_tokens INTEGER DEFAULT 300, + temperature REAL DEFAULT 0.7, + timeout_seconds INTEGER DEFAULT 60, + batch_size INTEGER DEFAULT 4)''') + # --- PERFORMANCE INDEXES --- # Index for staging_area queries filtered by category cursor.execute("CREATE INDEX IF NOT EXISTS idx_staging_category ON staging_area(target_category)") @@ -75,6 +99,8 @@ class SorterEngine: cursor.execute("CREATE INDEX IF NOT EXISTS idx_folder_tags_profile ON folder_tags(profile, folder_path)") # Index for profile_categories lookups cursor.execute("CREATE INDEX IF NOT EXISTS idx_profile_categories ON profile_categories(profile)") + # Index for caption lookups by image path + cursor.execute("CREATE INDEX IF NOT EXISTS idx_image_captions ON image_captions(image_path)") conn.commit() conn.close() @@ -826,4 +852,371 @@ class SorterEngine: ) result = {row[0]: {"cat": row[1], "index": row[2]} for row in cursor.fetchall()} conn.close() - return result \ No newline at end of file + return result + + # --- 8. CAPTION SETTINGS & PROMPTS --- + @staticmethod + def get_caption_settings(profile): + """Get caption API settings for a profile.""" + conn = sqlite3.connect(SorterEngine.DB_PATH) + cursor = conn.cursor() + + # Ensure table exists + cursor.execute('''CREATE TABLE IF NOT EXISTS caption_settings + (profile TEXT PRIMARY KEY, + api_endpoint TEXT DEFAULT 'http://localhost:8080/v1/chat/completions', + model_name TEXT DEFAULT 'local-model', + max_tokens INTEGER DEFAULT 300, + temperature REAL DEFAULT 0.7, + timeout_seconds INTEGER DEFAULT 60, + batch_size INTEGER DEFAULT 4)''') + + cursor.execute("SELECT * FROM caption_settings WHERE profile = ?", (profile,)) + row = cursor.fetchone() + conn.close() + + if row: + return { + "profile": row[0], + "api_endpoint": row[1], + "model_name": row[2], + "max_tokens": row[3], + "temperature": row[4], + "timeout_seconds": row[5], + "batch_size": row[6] + } + else: + # Return defaults + return { + "profile": profile, + "api_endpoint": "http://localhost:8080/v1/chat/completions", + "model_name": "local-model", + "max_tokens": 300, + "temperature": 0.7, + "timeout_seconds": 60, + "batch_size": 4 + } + + @staticmethod + def save_caption_settings(profile, api_endpoint=None, model_name=None, max_tokens=None, + temperature=None, timeout_seconds=None, batch_size=None): + """Save caption API settings for a profile.""" + conn = sqlite3.connect(SorterEngine.DB_PATH) + cursor = conn.cursor() + + # Ensure table exists + cursor.execute('''CREATE TABLE IF NOT EXISTS caption_settings + (profile TEXT PRIMARY KEY, + api_endpoint TEXT DEFAULT 'http://localhost:8080/v1/chat/completions', + model_name TEXT DEFAULT 'local-model', + max_tokens INTEGER DEFAULT 300, + temperature REAL DEFAULT 0.7, + timeout_seconds INTEGER DEFAULT 60, + batch_size INTEGER DEFAULT 4)''') + + # Get existing values + cursor.execute("SELECT * FROM caption_settings WHERE profile = ?", (profile,)) + row = cursor.fetchone() + + if not row: + row = (profile, "http://localhost:8080/v1/chat/completions", "local-model", 300, 0.7, 60, 4) + + new_values = ( + profile, + api_endpoint if api_endpoint is not None else row[1], + model_name if model_name is not None else row[2], + max_tokens if max_tokens is not None else row[3], + temperature if temperature is not None else row[4], + timeout_seconds if timeout_seconds is not None else row[5], + batch_size if batch_size is not None else row[6] + ) + + cursor.execute("INSERT OR REPLACE INTO caption_settings VALUES (?, ?, ?, ?, ?, ?, ?)", new_values) + conn.commit() + conn.close() + + @staticmethod + def get_category_prompt(profile, category): + """Get prompt template for a category.""" + conn = sqlite3.connect(SorterEngine.DB_PATH) + cursor = conn.cursor() + + cursor.execute('''CREATE TABLE IF NOT EXISTS category_prompts + (profile TEXT, category TEXT, prompt_template TEXT, + PRIMARY KEY (profile, category))''') + + cursor.execute( + "SELECT prompt_template FROM category_prompts WHERE profile = ? AND category = ?", + (profile, category) + ) + row = cursor.fetchone() + conn.close() + + if row and row[0]: + return row[0] + else: + # Default prompt + return "Describe this image in detail for training purposes. Include subjects, actions, setting, colors, and composition." + + @staticmethod + def save_category_prompt(profile, category, prompt): + """Save prompt template for a category.""" + conn = sqlite3.connect(SorterEngine.DB_PATH) + cursor = conn.cursor() + + cursor.execute('''CREATE TABLE IF NOT EXISTS category_prompts + (profile TEXT, category TEXT, prompt_template TEXT, + PRIMARY KEY (profile, category))''') + + cursor.execute( + "INSERT OR REPLACE INTO category_prompts VALUES (?, ?, ?)", + (profile, category, prompt) + ) + conn.commit() + conn.close() + + @staticmethod + def get_all_category_prompts(profile): + """Get all prompt templates for a profile.""" + conn = sqlite3.connect(SorterEngine.DB_PATH) + cursor = conn.cursor() + + cursor.execute('''CREATE TABLE IF NOT EXISTS category_prompts + (profile TEXT, category TEXT, prompt_template TEXT, + PRIMARY KEY (profile, category))''') + + cursor.execute( + "SELECT category, prompt_template FROM category_prompts WHERE profile = ?", + (profile,) + ) + result = {row[0]: row[1] for row in cursor.fetchall()} + conn.close() + return result + + # --- 9. CAPTION STORAGE --- + @staticmethod + def save_caption(image_path, caption, model): + """Save a generated caption to the database.""" + conn = sqlite3.connect(SorterEngine.DB_PATH) + cursor = conn.cursor() + + cursor.execute('''CREATE TABLE IF NOT EXISTS image_captions + (image_path TEXT PRIMARY KEY, caption TEXT, model TEXT, + generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + cursor.execute( + "INSERT OR REPLACE INTO image_captions VALUES (?, ?, ?, ?)", + (image_path, caption, model, datetime.now().isoformat()) + ) + conn.commit() + conn.close() + + @staticmethod + def get_caption(image_path): + """Get caption for an image.""" + conn = sqlite3.connect(SorterEngine.DB_PATH) + cursor = conn.cursor() + + cursor.execute('''CREATE TABLE IF NOT EXISTS image_captions + (image_path TEXT PRIMARY KEY, caption TEXT, model TEXT, + generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + cursor.execute( + "SELECT caption, model, generated_at FROM image_captions WHERE image_path = ?", + (image_path,) + ) + row = cursor.fetchone() + conn.close() + + if row: + return {"caption": row[0], "model": row[1], "generated_at": row[2]} + return None + + @staticmethod + def get_captions_batch(image_paths): + """Get captions for multiple images.""" + if not image_paths: + return {} + + conn = sqlite3.connect(SorterEngine.DB_PATH) + cursor = conn.cursor() + + cursor.execute('''CREATE TABLE IF NOT EXISTS image_captions + (image_path TEXT PRIMARY KEY, caption TEXT, model TEXT, + generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + + placeholders = ','.join('?' * len(image_paths)) + cursor.execute( + f"SELECT image_path, caption, model, generated_at FROM image_captions WHERE image_path IN ({placeholders})", + image_paths + ) + result = {row[0]: {"caption": row[1], "model": row[2], "generated_at": row[3]} for row in cursor.fetchall()} + conn.close() + return result + + @staticmethod + def delete_caption(image_path): + """Delete caption for an image.""" + conn = sqlite3.connect(SorterEngine.DB_PATH) + cursor = conn.cursor() + cursor.execute("DELETE FROM image_captions WHERE image_path = ?", (image_path,)) + conn.commit() + conn.close() + + # --- 10. VLLM API CAPTIONING --- + @staticmethod + def caption_image_vllm(image_path, prompt, settings): + """ + Generate caption for an image using VLLM API. + + Args: + image_path: Path to the image file + prompt: Text prompt for captioning + settings: Dict with api_endpoint, model_name, max_tokens, temperature, timeout_seconds + + Returns: + Tuple of (caption_text, error_message). If successful, error is None. + """ + try: + # Read and encode image + with open(image_path, 'rb') as f: + img_bytes = f.read() + b64_image = base64.b64encode(img_bytes).decode('utf-8') + + # Determine MIME type + ext = os.path.splitext(image_path)[1].lower() + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff' + } + mime_type = mime_types.get(ext, 'image/jpeg') + + # Build request payload (OpenAI-compatible format) + payload = { + "model": settings.get('model_name', 'local-model'), + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{b64_image}"}} + ] + }], + "max_tokens": settings.get('max_tokens', 300), + "temperature": settings.get('temperature', 0.7) + } + + # Make API request + response = requests.post( + settings.get('api_endpoint', 'http://localhost:8080/v1/chat/completions'), + json=payload, + timeout=settings.get('timeout_seconds', 60) + ) + response.raise_for_status() + + result = response.json() + caption = result['choices'][0]['message']['content'] + return caption.strip(), None + + except requests.Timeout: + return None, f"API timeout after {settings.get('timeout_seconds', 60)}s" + except requests.RequestException as e: + return None, f"API error: {str(e)}" + except KeyError as e: + return None, f"Invalid API response: missing {str(e)}" + except Exception as e: + return None, f"Error: {str(e)}" + + @staticmethod + def caption_batch_vllm(image_paths, get_prompt_fn, settings, progress_cb=None): + """ + Caption multiple images using VLLM API. + + Args: + image_paths: List of (image_path, category) tuples + get_prompt_fn: Function(category) -> prompt string + settings: Caption settings dict + progress_cb: Optional callback(current, total, status_msg) for progress updates + + Returns: + Dict with results: {"success": count, "failed": count, "captions": {path: caption}} + """ + results = {"success": 0, "failed": 0, "captions": {}, "errors": {}} + total = len(image_paths) + + for i, (image_path, category) in enumerate(image_paths): + if progress_cb: + progress_cb(i, total, f"Captioning {os.path.basename(image_path)}...") + + prompt = get_prompt_fn(category) + caption, error = SorterEngine.caption_image_vllm(image_path, prompt, settings) + + if caption: + # Save to database + SorterEngine.save_caption(image_path, caption, settings.get('model_name', 'local-model')) + results["captions"][image_path] = caption + results["success"] += 1 + else: + # Store error + error_caption = f"[ERROR] {error}" + SorterEngine.save_caption(image_path, error_caption, settings.get('model_name', 'local-model')) + results["errors"][image_path] = error + results["failed"] += 1 + + if progress_cb: + progress_cb(total, total, "Complete!") + + return results + + @staticmethod + def write_caption_sidecar(image_path, caption): + """ + Write caption to a .txt sidecar file next to the image. + + Args: + image_path: Path to the image file + caption: Caption text to write + + Returns: + Path to sidecar file, or None on error + """ + try: + # Create sidecar path (same name, .txt extension) + base_path = os.path.splitext(image_path)[0] + sidecar_path = f"{base_path}.txt" + + with open(sidecar_path, 'w', encoding='utf-8') as f: + f.write(caption) + + # Fix permissions + SorterEngine.fix_permissions(sidecar_path) + + return sidecar_path + except Exception as e: + print(f"Warning: Could not write sidecar for {image_path}: {e}") + return None + + @staticmethod + def read_caption_sidecar(image_path): + """ + Read caption from a .txt sidecar file if it exists. + + Args: + image_path: Path to the image file + + Returns: + Caption text or None if no sidecar exists + """ + try: + base_path = os.path.splitext(image_path)[0] + sidecar_path = f"{base_path}.txt" + + if os.path.exists(sidecar_path): + with open(sidecar_path, 'r', encoding='utf-8') as f: + return f.read().strip() + except Exception: + pass + return None \ No newline at end of file diff --git a/gallery_app.py b/gallery_app.py index b4efe3b..8049725 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -81,6 +81,12 @@ class AppState: # Pairing mode index maps (index -> (main_path, adj_path)) self.pair_index_map: Dict[int, Dict] = {} # {idx: {"main": path, "adj": path}} + # === CAPTION STATE === + self.caption_settings: Dict = {} + self.captioning_in_progress: bool = False + self.caption_on_apply: bool = False # Toggle for captioning during APPLY + self.caption_cache: Set[str] = set() # Paths that have captions + def load_active_profile(self): """Load paths from active profile.""" p_data = self.profiles.get(self.profile_name, {}) @@ -147,6 +153,17 @@ class AppState: self.active_cat = cats[0] return cats + def load_caption_settings(self): + """Load caption settings for current profile.""" + self.caption_settings = SorterEngine.get_caption_settings(self.profile_name) + + def refresh_caption_cache(self, image_paths: List[str] = None): + """Refresh the cache of which images have captions.""" + paths = image_paths or self.all_images + if paths: + captions = SorterEngine.get_captions_batch(paths) + self.caption_cache = set(captions.keys()) + def get_filtered_images(self) -> List[str]: """Get images based on current filter mode.""" if self.filter_mode == "all": @@ -220,28 +237,32 @@ def load_images(): if not os.path.exists(state.source_dir): ui.notify(f"Source not found: {state.source_dir}", type='warning') return - + # Auto-save current tags before switching folders if state.all_images and state.staged_data: saved = SorterEngine.save_folder_tags(state.source_dir, state.profile_name) if saved > 0: ui.notify(f"Auto-saved {saved} tags", type='info') - + # Clear staging area when loading a new folder SorterEngine.clear_staging_area() - + state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) - + # Restore previously saved tags for this folder and profile restored = SorterEngine.restore_folder_tags(state.source_dir, state.all_images, state.profile_name) if restored > 0: ui.notify(f"Restored {restored} tags from previous session", type='info') - + # Reset page if out of bounds if state.page >= state.total_pages: state.page = 0 - + refresh_staged_info() + # Refresh caption cache for loaded images + state.refresh_caption_cache() + # Load caption settings + state.load_caption_settings() refresh_ui() # ========================================== @@ -674,14 +695,42 @@ def action_save_tags(): else: ui.notify("No tags to save", type='info') -def action_apply_page(): +async def action_apply_page(): """Apply staged changes for current page only.""" batch = state.get_current_batch() if not batch: ui.notify("No images on current page", type='warning') return + # Get tagged images and their categories before commit (they'll be moved/copied) + tagged_batch = [] + for img_path in batch: + if img_path in state.staged_data: + info = state.staged_data[img_path] + # Calculate destination path + dest_path = os.path.join(state.output_dir, info['name']) + tagged_batch.append((img_path, info['cat'], dest_path)) + SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode) + + # Caption on apply if enabled + if state.caption_on_apply and tagged_batch: + state.load_caption_settings() + caption_count = 0 + for orig_path, category, dest_path in tagged_batch: + if os.path.exists(dest_path): + prompt = SorterEngine.get_category_prompt(state.profile_name, category) + caption, error = await run.io_bound( + SorterEngine.caption_image_vllm, + dest_path, prompt, state.caption_settings + ) + if caption: + SorterEngine.save_caption(dest_path, caption, state.caption_settings.get('model_name', 'local-model')) + SorterEngine.write_caption_sidecar(dest_path, caption) + caption_count += 1 + if caption_count > 0: + ui.notify(f"Captioned {caption_count} images", type='info') + ui.notify(f"Page processed ({state.batch_mode})", type='positive') # Force disk rescan since files were committed state._last_disk_scan_key = "" @@ -690,6 +739,14 @@ def action_apply_page(): async def action_apply_global(): """Apply all staged changes globally.""" ui.notify("Starting global apply... This may take a while.", type='info') + + # Capture staged data before commit for captioning + staged_before_commit = {} + if state.caption_on_apply: + for img_path, info in state.staged_data.items(): + dest_path = os.path.join(state.output_dir, info['name']) + staged_before_commit[img_path] = {'cat': info['cat'], 'dest': dest_path} + await run.io_bound( SorterEngine.commit_global, state.output_dir, @@ -698,6 +755,29 @@ async def action_apply_global(): state.source_dir, state.profile_name ) + + # Caption on apply if enabled + if state.caption_on_apply and staged_before_commit: + state.load_caption_settings() + ui.notify(f"Captioning {len(staged_before_commit)} images...", type='info') + + caption_count = 0 + for orig_path, info in staged_before_commit.items(): + dest_path = info['dest'] + if os.path.exists(dest_path): + prompt = SorterEngine.get_category_prompt(state.profile_name, info['cat']) + caption, error = await run.io_bound( + SorterEngine.caption_image_vllm, + dest_path, prompt, state.caption_settings + ) + if caption: + SorterEngine.save_caption(dest_path, caption, state.caption_settings.get('model_name', 'local-model')) + SorterEngine.write_caption_sidecar(dest_path, caption) + caption_count += 1 + + if caption_count > 0: + ui.notify(f"Captioned {caption_count} images", type='info') + # Force disk rescan since files were committed state._last_disk_scan_key = "" load_images() @@ -802,20 +882,20 @@ def open_hotkey_dialog(category: str): if cat == category: current_hotkey = hk break - + with ui.dialog() as dialog, ui.card().classes('p-4 bg-gray-800'): ui.label(f'Set Hotkey for "{category}"').classes('font-bold text-white mb-2') - + ui.label('Press a letter key (A-Z) to assign as hotkey').classes('text-gray-400 text-sm mb-4') - + if current_hotkey: ui.label(f'Current: {current_hotkey.upper()}').classes('text-blue-400 mb-2') - + hotkey_input = ui.input( placeholder='Type a letter...', value=current_hotkey or '' ).props('dark outlined dense autofocus').classes('w-full') - + def save_hotkey(): key = hotkey_input.value.lower().strip() if key and len(key) == 1 and key.isalpha(): @@ -823,11 +903,11 @@ def open_hotkey_dialog(category: str): to_remove = [hk for hk, c in state.category_hotkeys.items() if c == category] for hk in to_remove: del state.category_hotkeys[hk] - + # Remove if another category had this hotkey if key in state.category_hotkeys: del state.category_hotkeys[key] - + # Set new hotkey state.category_hotkeys[key] = category ui.notify(f'Hotkey "{key.upper()}" set for {category}', type='positive') @@ -843,7 +923,7 @@ def open_hotkey_dialog(category: str): render_sidebar() else: ui.notify('Please enter a single letter (A-Z)', type='warning') - + with ui.row().classes('w-full justify-end gap-2 mt-4'): ui.button('Clear', on_click=lambda: ( hotkey_input.set_value(''), @@ -851,9 +931,277 @@ def open_hotkey_dialog(category: str): )).props('flat color=grey') ui.button('Cancel', on_click=dialog.close).props('flat') ui.button('Save', on_click=save_hotkey).props('color=green') - + dialog.open() +def open_caption_settings_dialog(): + """Open dialog to configure caption API settings.""" + state.load_caption_settings() + settings = state.caption_settings.copy() + + with ui.dialog() as dialog, ui.card().classes('p-6 bg-gray-800 w-96'): + ui.label('Caption API Settings').classes('text-xl font-bold text-white mb-4') + + api_endpoint = ui.input( + label='API Endpoint', + value=settings.get('api_endpoint', 'http://localhost:8080/v1/chat/completions') + ).props('dark outlined dense').classes('w-full mb-2') + + model_name = ui.input( + label='Model Name', + value=settings.get('model_name', 'local-model') + ).props('dark outlined dense').classes('w-full mb-2') + + max_tokens = ui.number( + label='Max Tokens', + value=settings.get('max_tokens', 300), + min=50, max=2000 + ).props('dark outlined dense').classes('w-full mb-2') + + ui.label('Temperature').classes('text-gray-400 text-sm') + temperature = ui.slider( + min=0, max=1, step=0.1, + value=settings.get('temperature', 0.7) + ).props('color=purple label-always').classes('w-full mb-2') + + timeout = ui.number( + label='Timeout (seconds)', + value=settings.get('timeout_seconds', 60), + min=10, max=300 + ).props('dark outlined dense').classes('w-full mb-2') + + batch_size = ui.number( + label='Batch Size', + value=settings.get('batch_size', 4), + min=1, max=16 + ).props('dark outlined dense').classes('w-full mb-4') + + def save_settings(): + SorterEngine.save_caption_settings( + state.profile_name, + api_endpoint=api_endpoint.value, + model_name=model_name.value, + max_tokens=int(max_tokens.value), + temperature=float(temperature.value), + timeout_seconds=int(timeout.value), + batch_size=int(batch_size.value) + ) + state.load_caption_settings() + ui.notify('Caption settings saved!', type='positive') + dialog.close() + + with ui.row().classes('w-full justify-end gap-2'): + ui.button('Cancel', on_click=dialog.close).props('flat') + ui.button('Save', on_click=save_settings).props('color=purple') + + dialog.open() + +def open_prompt_editor_dialog(): + """Open dialog to edit category prompts.""" + categories = state.get_categories() + prompts = SorterEngine.get_all_category_prompts(state.profile_name) + + with ui.dialog() as dialog, ui.card().classes('p-6 bg-gray-800 w-[600px] max-h-[80vh]'): + ui.label('Category Prompts').classes('text-xl font-bold text-white mb-2') + ui.label('Set custom prompts for each category. Leave empty for default.').classes('text-gray-400 text-sm mb-4') + + default_prompt = "Describe this image in detail for training purposes. Include subjects, actions, setting, colors, and composition." + ui.label(f'Default: "{default_prompt[:60]}..."').classes('text-gray-500 text-xs mb-4') + + # Store text areas for later access + prompt_inputs = {} + + with ui.scroll_area().classes('w-full max-h-96'): + for cat in categories: + current_prompt = prompts.get(cat, '') + with ui.card().classes('w-full p-3 bg-gray-700 mb-2'): + ui.label(cat).classes('font-bold text-purple-400 mb-1') + prompt_inputs[cat] = ui.textarea( + value=current_prompt, + placeholder=default_prompt + ).props('dark outlined dense rows=2').classes('w-full') + + def save_all_prompts(): + for cat, textarea in prompt_inputs.items(): + prompt = textarea.value.strip() + if prompt: + SorterEngine.save_category_prompt(state.profile_name, cat, prompt) + else: + # Clear the prompt to use default + SorterEngine.save_category_prompt(state.profile_name, cat, '') + ui.notify(f'Prompts saved for {len(prompt_inputs)} categories!', type='positive') + dialog.close() + + with ui.row().classes('w-full justify-end gap-2 mt-4'): + ui.button('Cancel', on_click=dialog.close).props('flat') + ui.button('Save All', on_click=save_all_prompts).props('color=purple') + + dialog.open() + +def open_caption_dialog(img_path: str): + """Open dialog to view/edit/generate caption for a single image.""" + existing = SorterEngine.get_caption(img_path) + state.load_caption_settings() + + # Get category for this image + staged_info = state.staged_data.get(img_path) + category = staged_info['cat'] if staged_info else state.active_cat + + with ui.dialog() as dialog, ui.card().classes('p-6 bg-gray-800 w-[500px]'): + ui.label('Image Caption').classes('text-xl font-bold text-white mb-2') + ui.label(os.path.basename(img_path)).classes('text-gray-400 text-sm mb-4 truncate') + + # Thumbnail preview + ui.image(f"/thumbnail?path={img_path}&size=300&q=60").classes('w-full h-48 bg-black rounded mb-4').props('fit=contain') + + # Caption textarea + caption_text = ui.textarea( + label='Caption', + value=existing['caption'] if existing else '', + placeholder='Caption will appear here...' + ).props('dark outlined rows=4').classes('w-full mb-2') + + # Model info + if existing: + ui.label(f"Model: {existing.get('model', 'unknown')} | {existing.get('generated_at', '')}").classes('text-gray-500 text-xs mb-4') + + # Status label for progress + status_label = ui.label('').classes('text-purple-400 text-sm mb-2') + + async def generate_caption(): + status_label.set_text('Generating caption...') + prompt = SorterEngine.get_category_prompt(state.profile_name, category) + + caption, error = await run.io_bound( + SorterEngine.caption_image_vllm, + img_path, prompt, state.caption_settings + ) + + if caption: + caption_text.set_value(caption) + status_label.set_text('Caption generated!') + else: + status_label.set_text(f'Error: {error}') + + def save_caption(): + text = caption_text.value.strip() + if text: + SorterEngine.save_caption(img_path, text, state.caption_settings.get('model_name', 'manual')) + state.caption_cache.add(img_path) + ui.notify('Caption saved!', type='positive') + dialog.close() + refresh_grid_only() + else: + ui.notify('Caption is empty', type='warning') + + def save_with_sidecar(): + text = caption_text.value.strip() + if text: + SorterEngine.save_caption(img_path, text, state.caption_settings.get('model_name', 'manual')) + sidecar_path = SorterEngine.write_caption_sidecar(img_path, text) + state.caption_cache.add(img_path) + if sidecar_path: + ui.notify(f'Caption saved + sidecar written!', type='positive') + else: + ui.notify('Caption saved (sidecar failed)', type='warning') + dialog.close() + refresh_grid_only() + else: + ui.notify('Caption is empty', type='warning') + + with ui.row().classes('w-full justify-between gap-2'): + ui.button('Generate', icon='auto_awesome', on_click=generate_caption).props('color=purple') + with ui.row().classes('gap-2'): + ui.button('Cancel', on_click=dialog.close).props('flat') + ui.button('Save', on_click=save_caption).props('color=green') + ui.button('Save + Sidecar', on_click=save_with_sidecar).props('color=blue').tooltip('Also write .txt file') + + dialog.open() + +async def action_caption_category(): + """Caption all images tagged with the active category.""" + if state.captioning_in_progress: + ui.notify('Captioning already in progress', type='warning') + return + + # Find all images tagged with active category + images_to_caption = [] + for img_path, info in state.staged_data.items(): + if info['cat'] == state.active_cat: + images_to_caption.append((img_path, state.active_cat)) + + if not images_to_caption: + ui.notify(f'No images tagged with {state.active_cat}', type='warning') + return + + state.load_caption_settings() + state.captioning_in_progress = True + + # Create progress dialog + with ui.dialog() as progress_dialog, ui.card().classes('p-6 bg-gray-800 w-96'): + ui.label('Captioning Images...').classes('text-xl font-bold text-white mb-4') + progress_bar = ui.linear_progress(value=0).props('instant-feedback color=purple').classes('w-full mb-2') + progress_label = ui.label('0 / 0').classes('text-gray-400 text-center w-full mb-2') + status_label = ui.label('Starting...').classes('text-purple-400 text-sm text-center w-full') + + cancel_requested = {'value': False} + + def request_cancel(): + cancel_requested['value'] = True + status_label.set_text('Cancelling...') + + ui.button('Cancel', on_click=request_cancel).props('flat color=red').classes('w-full mt-4') + + progress_dialog.open() + + try: + total = len(images_to_caption) + success_count = 0 + fail_count = 0 + + def get_prompt(cat): + return SorterEngine.get_category_prompt(state.profile_name, cat) + + for i, (img_path, category) in enumerate(images_to_caption): + if cancel_requested['value']: + break + + progress_bar.set_value(i / total) + progress_label.set_text(f'{i + 1} / {total}') + status_label.set_text(f'Captioning {os.path.basename(img_path)}...') + + prompt = get_prompt(category) + caption, error = await run.io_bound( + SorterEngine.caption_image_vllm, + img_path, prompt, state.caption_settings + ) + + if caption: + SorterEngine.save_caption(img_path, caption, state.caption_settings.get('model_name', 'local-model')) + state.caption_cache.add(img_path) + success_count += 1 + else: + error_caption = f"[ERROR] {error}" + SorterEngine.save_caption(img_path, error_caption, state.caption_settings.get('model_name', 'local-model')) + fail_count += 1 + + progress_bar.set_value(1) + progress_label.set_text(f'{total} / {total}') + + if cancel_requested['value']: + status_label.set_text(f'Cancelled. {success_count} OK, {fail_count} failed') + else: + status_label.set_text(f'Done! {success_count} OK, {fail_count} failed') + + await asyncio.sleep(1.5) + progress_dialog.close() + + finally: + state.captioning_in_progress = False + refresh_grid_only() + + ui.notify(f'Captioned {success_count}/{total} images', type='positive' if fail_count == 0 else 'warning') + def render_sidebar(): """Render category management sidebar.""" state.sidebar_container.clear() @@ -932,8 +1280,11 @@ def render_sidebar(): .classes('w-full border border-gray-800') # Category Manager (expanded) - ui.label("📂 Categories").classes('text-sm font-bold text-gray-400 mt-2') - + with ui.row().classes('w-full justify-between items-center mt-2'): + ui.label("📂 Categories").classes('text-sm font-bold text-gray-400') + ui.button(icon='edit_note', on_click=open_prompt_editor_dialog) \ + .props('flat dense color=purple size=sm').tooltip('Edit Prompts') + categories = state.get_categories() # Category list with hotkey buttons @@ -1055,6 +1406,7 @@ def render_image_card(img_path: str): """Render individual image card. Uses functools.partial instead of lambdas for better memory efficiency.""" is_staged = img_path in state.staged_data + has_caption = img_path in state.caption_cache thumb_size = 800 card = ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow hover:border-green-500 transition-colors') @@ -1066,8 +1418,16 @@ def render_image_card(img_path: str): # Header with filename and actions with ui.row().classes('w-full justify-between no-wrap mb-1'): - ui.label(os.path.basename(img_path)[:15]).classes('text-xs text-gray-400 truncate') + with ui.row().classes('items-center gap-1'): + ui.label(os.path.basename(img_path)[:15]).classes('text-xs text-gray-400 truncate') + # Caption indicator + if has_caption: + ui.icon('description', size='xs').classes('text-purple-400').tooltip('Has caption') with ui.row().classes('gap-0'): + ui.button( + icon='auto_awesome', + on_click=partial(open_caption_dialog, img_path) + ).props('flat size=sm dense color=purple').tooltip('Caption') ui.button( icon='zoom_in', on_click=partial(open_zoom_dialog, img_path) @@ -1369,20 +1729,25 @@ def build_header(): with ui.button(icon='tune', color='white').props('flat round'): with ui.menu().classes('bg-gray-800 text-white p-4'): ui.label('VIEW SETTINGS').classes('text-xs font-bold mb-2') - + ui.label('Grid Columns:') ui.slider( min=2, max=8, step=1, value=state.grid_cols, on_change=lambda e: (setattr(state, 'grid_cols', e.value), refresh_ui()) ).props('color=green') - + ui.label('Preview Quality:') ui.slider( min=10, max=100, step=10, value=state.preview_quality, on_change=lambda e: (setattr(state, 'preview_quality', e.value), refresh_ui()) ).props('color=green label-always') + + ui.separator().classes('my-2') + ui.label('CAPTION SETTINGS').classes('text-xs font-bold mb-2 text-purple-400') + ui.button('Configure API', icon='api', on_click=open_caption_settings_dialog) \ + .props('flat color=purple').classes('w-full') ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)) \ .props('color=green') @@ -1416,19 +1781,27 @@ def build_main_content(): ui.radio(['Copy', 'Move'], value=state.batch_mode) \ .bind_value(state, 'batch_mode') \ .props('inline dark color=green') - + # Untagged files mode with ui.column(): ui.label('UNTAGGED FILES:').classes('text-gray-500 text-xs font-bold') ui.radio(['Keep', 'Move to Unused', 'Delete'], value=state.cleanup_mode) \ .bind_value(state, 'cleanup_mode') \ .props('inline dark color=green') - + + # Caption options + with ui.column(): + ui.label('CAPTIONING:').classes('text-gray-500 text-xs font-bold') + ui.checkbox('Caption on Apply').bind_value(state, 'caption_on_apply') \ + .props('color=purple dark') + ui.button('CAPTION CATEGORY', icon='auto_awesome', on_click=action_caption_category) \ + .props('outline color=purple') + # Action buttons with ui.row().classes('items-center gap-6'): ui.button('APPLY PAGE', on_click=action_apply_page) \ .props('outline color=white lg') - + with ui.column().classes('items-center'): ui.button('APPLY GLOBAL', on_click=action_apply_global) \ .props('lg color=red-900') diff --git a/requirements.txt b/requirements.txt index 5682ef1..c538cae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ streamlit Pillow -nicegui \ No newline at end of file +nicegui +requests \ No newline at end of file