From db97c42320512fb811e78ab667eafe4fe9ecfcbc Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Mon, 19 Jan 2026 03:16:11 +0000 Subject: [PATCH 1/7] feat(caption): add WD14 tagger with Booru Tags tab Add SmilingWolf's WD14/WaifuDiffusion tagger models for anime/illustration tagging as a new "Booru Tags" tab in the Caption panel. - Support 9 models (v2 and v3 variants) via HuggingFace - ONNX backend chosen due to safetensors v3 variants exhibiting unacceptable accuracy loss - Separate thresholds for general/character tags - Batch processing with progress bar - Consolidate debug env var to SD_INTERROGATE_DEBUG --- modules/interrogate/moondream3.py | 2 +- modules/interrogate/openclip.py | 80 ++++- modules/interrogate/vqa.py | 2 +- modules/interrogate/wd14.py | 535 ++++++++++++++++++++++++++++++ modules/shared.py | 11 + modules/ui_caption.py | 85 +++++ 6 files changed, 701 insertions(+), 14 deletions(-) create mode 100644 modules/interrogate/wd14.py diff --git a/modules/interrogate/moondream3.py b/modules/interrogate/moondream3.py index 0ba0ecd04..f760b3233 100644 --- a/modules/interrogate/moondream3.py +++ b/modules/interrogate/moondream3.py @@ -11,7 +11,7 @@ from modules.interrogate import vqa_detection # Debug logging - function-based to avoid circular import -debug_enabled = os.environ.get('SD_VQA_DEBUG', None) is not None +debug_enabled = os.environ.get('SD_INTERROGATE_DEBUG', None) is not None def debug(*args, **kwargs): if debug_enabled: diff --git a/modules/interrogate/openclip.py b/modules/interrogate/openclip.py index 2350dc440..68de085b0 100644 --- a/modules/interrogate/openclip.py +++ b/modules/interrogate/openclip.py @@ -1,4 +1,5 @@ import os +import time from collections import namedtuple import threading import re @@ -7,6 +8,23 @@ from PIL import Image from modules import devices, paths, shared, errors, sd_models +debug_enabled = os.environ.get('SD_INTERROGATE_DEBUG', None) is not None +debug_log = shared.log.trace if debug_enabled else lambda *args, **kwargs: None + + +def _apply_blip2_fix(model, processor): + """Apply compatibility fix for BLIP2 models with newer transformers versions.""" + from transformers import AddedToken + if not hasattr(model.config, 'num_query_tokens'): + return + processor.num_query_tokens = model.config.num_query_tokens + image_token = AddedToken("", normalized=False, special=True) + processor.tokenizer.add_tokens([image_token], special_tokens=True) + model.resize_token_embeddings(len(processor.tokenizer), pad_to_multiple_of=64) + model.config.image_token_index = len(processor.tokenizer) - 1 + debug_log(f'CLIP load: applied BLIP2 tokenizer fix num_query_tokens={model.config.num_query_tokens}') + + caption_models = { 'blip-base': 'Salesforce/blip-image-captioning-base', 'blip-large': 'Salesforce/blip-image-captioning-large', @@ -79,10 +97,14 @@ def load_interrogator(clip_model, blip_model): clip_interrogator.clip_interrogator.CAPTION_MODELS = caption_models global ci # pylint: disable=global-statement if ci is None: - shared.log.debug(f'Interrogate load: clip="{clip_model}" blip="{blip_model}"') + t0 = time.time() + device = devices.get_optimal_device() + cache_path = os.path.join(paths.models_path, 'Interrogator') + shared.log.info(f'CLIP load: clip="{clip_model}" blip="{blip_model}" device={device}') + debug_log(f'CLIP load: cache_path="{cache_path}" max_length={shared.opts.interrogate_clip_max_length} chunk_size={shared.opts.interrogate_clip_chunk_size} flavor_count={shared.opts.interrogate_clip_flavor_count} offload={shared.opts.interrogate_offload}') interrogator_config = clip_interrogator.Config( - device=devices.get_optimal_device(), - cache_path=os.path.join(paths.models_path, 'Interrogator'), + device=device, + cache_path=cache_path, clip_model_name=clip_model, caption_model_name=blip_model, quiet=True, @@ -93,22 +115,39 @@ def load_interrogator(clip_model, blip_model): caption_offload=shared.opts.interrogate_offload, ) ci = clip_interrogator.Interrogator(interrogator_config) + if blip_model.startswith('blip2-'): + _apply_blip2_fix(ci.caption_model, ci.caption_processor) + shared.log.debug(f'CLIP load: time={time.time()-t0:.2f}s') elif clip_model != ci.config.clip_model_name or blip_model != ci.config.caption_model_name: - ci.config.clip_model_name = clip_model - ci.config.clip_model = None - ci.load_clip_model() - ci.config.caption_model_name = blip_model - ci.config.caption_model = None - ci.load_caption_model() + t0 = time.time() + if clip_model != ci.config.clip_model_name: + shared.log.info(f'CLIP load: clip="{clip_model}" reloading') + debug_log(f'CLIP load: previous clip="{ci.config.clip_model_name}"') + ci.config.clip_model_name = clip_model + ci.config.clip_model = None + ci.load_clip_model() + if blip_model != ci.config.caption_model_name: + shared.log.info(f'CLIP load: blip="{blip_model}" reloading') + debug_log(f'CLIP load: previous blip="{ci.config.caption_model_name}"') + ci.config.caption_model_name = blip_model + ci.config.caption_model = None + ci.load_caption_model() + if blip_model.startswith('blip2-'): + _apply_blip2_fix(ci.caption_model, ci.caption_processor) + shared.log.debug(f'CLIP load: time={time.time()-t0:.2f}s') + else: + debug_log(f'CLIP: models already loaded clip="{clip_model}" blip="{blip_model}"') def unload_clip_model(): if ci is not None and shared.opts.interrogate_offload: + shared.log.debug('CLIP unload: offloading models to CPU') sd_models.move_model(ci.caption_model, devices.cpu) sd_models.move_model(ci.clip_model, devices.cpu) ci.caption_offloaded = True ci.clip_offloaded = True devices.torch_gc() + debug_log('CLIP unload: complete') def interrogate(image, mode, caption=None): @@ -119,6 +158,8 @@ def interrogate(image, mode, caption=None): if image is None: return '' image = image.convert("RGB") + t0 = time.time() + debug_log(f'CLIP: mode="{mode}" image_size={image.size} caption={caption is not None} min_flavors={shared.opts.interrogate_clip_min_flavors} max_flavors={shared.opts.interrogate_clip_max_flavors}') if mode == 'best': prompt = ci.interrogate(image, caption=caption, min_flavors=shared.opts.interrogate_clip_min_flavors, max_flavors=shared.opts.interrogate_clip_max_flavors, ) elif mode == 'caption': @@ -131,22 +172,27 @@ def interrogate(image, mode, caption=None): prompt = ci.interrogate_negative(image, max_flavors=shared.opts.interrogate_clip_max_flavors) else: raise RuntimeError(f"Unknown mode {mode}") + debug_log(f'CLIP: mode="{mode}" time={time.time()-t0:.2f}s result="{prompt[:100]}..."' if len(prompt) > 100 else f'CLIP: mode="{mode}" time={time.time()-t0:.2f}s result="{prompt}"') return prompt def interrogate_image(image, clip_model, blip_model, mode): jobid = shared.state.begin('Interrogate CLiP') + t0 = time.time() + shared.log.info(f'CLIP: mode="{mode}" clip="{clip_model}" blip="{blip_model}" image_size={image.size if image else None}') try: if shared.sd_loaded: from modules.sd_models import apply_balanced_offload # prevent circular import apply_balanced_offload(shared.sd_model) + debug_log('CLIP: applied balanced offload to sd_model') load_interrogator(clip_model, blip_model) image = image.convert('RGB') prompt = interrogate(image, mode) devices.torch_gc() + shared.log.debug(f'CLIP: complete time={time.time()-t0:.2f}s') except Exception as e: prompt = f"Exception {type(e)}" - shared.log.error(f'Interrogate: {e}') + shared.log.error(f'CLIP: {e}') errors.display(e, 'Interrogate') shared.state.end(jobid) return prompt @@ -162,8 +208,11 @@ def interrogate_batch(batch_files, batch_folder, batch_str, clip_model, blip_mod from modules.files_cache import list_files files += list(list_files(batch_str, ext_filter=['.png', '.jpg', '.jpeg', '.webp', '.jxl'], recursive=recursive)) if len(files) == 0: - shared.log.warning('Interrogate batch: type=clip no images') + shared.log.warning('CLIP batch: no images found') return '' + t0 = time.time() + shared.log.info(f'CLIP batch: mode="{mode}" images={len(files)} clip="{clip_model}" blip="{blip_model}" write={write} append={append}') + debug_log(f'CLIP batch: recursive={recursive} files={files[:5]}{"..." if len(files) > 5 else ""}') jobid = shared.state.begin('Interrogate batch') prompts = [] @@ -171,6 +220,7 @@ def interrogate_batch(batch_files, batch_folder, batch_str, clip_model, blip_mod if write: file_mode = 'w' if not append else 'a' writer = BatchWriter(os.path.dirname(files[0]), mode=file_mode) + debug_log(f'CLIP batch: writing to "{os.path.dirname(files[0])}" mode="{file_mode}"') import rich.progress as rp pbar = rp.Progress(rp.TextColumn('[cyan]Caption:'), rp.BarColumn(), rp.MofNCompleteColumn(), rp.TaskProgressColumn(), rp.TimeRemainingColumn(), rp.TimeElapsedColumn(), rp.TextColumn('[cyan]{task.description}'), console=shared.console) with pbar: @@ -179,6 +229,7 @@ def interrogate_batch(batch_files, batch_folder, batch_str, clip_model, blip_mod pbar.update(task, advance=1, description=file) try: if shared.state.interrupted: + shared.log.info('CLIP batch: interrupted') break image = Image.open(file).convert('RGB') prompt = interrogate(image, mode) @@ -186,19 +237,23 @@ def interrogate_batch(batch_files, batch_folder, batch_str, clip_model, blip_mod if write: writer.add(file, prompt) except OSError as e: - shared.log.error(f'Interrogate batch: {e}') + shared.log.error(f'CLIP batch: file="{file}" error={e}') if write: writer.close() ci.config.quiet = False unload_clip_model() shared.state.end(jobid) + shared.log.info(f'CLIP batch: complete images={len(prompts)} time={time.time()-t0:.2f}s') return '\n\n'.join(prompts) def analyze_image(image, clip_model, blip_model): + t0 = time.time() + shared.log.info(f'CLIP analyze: clip="{clip_model}" blip="{blip_model}" image_size={image.size if image else None}') load_interrogator(clip_model, blip_model) image = image.convert('RGB') image_features = ci.image_to_features(image) + debug_log(f'CLIP analyze: features shape={image_features.shape if hasattr(image_features, "shape") else "unknown"}') top_mediums = ci.mediums.rank(image_features, 5) top_artists = ci.artists.rank(image_features, 5) top_movements = ci.movements.rank(image_features, 5) @@ -209,6 +264,7 @@ def analyze_image(image, clip_model, blip_model): movement_ranks = dict(sorted(zip(top_movements, ci.similarities(image_features, top_movements)), key=lambda x: x[1], reverse=True)) trending_ranks = dict(sorted(zip(top_trendings, ci.similarities(image_features, top_trendings)), key=lambda x: x[1], reverse=True)) flavor_ranks = dict(sorted(zip(top_flavors, ci.similarities(image_features, top_flavors)), key=lambda x: x[1], reverse=True)) + shared.log.debug(f'CLIP analyze: complete time={time.time()-t0:.2f}s') # Format labels as text def format_category(name, ranks): diff --git a/modules/interrogate/vqa.py b/modules/interrogate/vqa.py index 036fd7ced..e71cda612 100644 --- a/modules/interrogate/vqa.py +++ b/modules/interrogate/vqa.py @@ -13,7 +13,7 @@ from modules.interrogate import vqa_detection # Debug logging - function-based to avoid circular import -debug_enabled = os.environ.get('SD_VQA_DEBUG', None) is not None +debug_enabled = os.environ.get('SD_INTERROGATE_DEBUG', None) is not None def debug(*args, **kwargs): if debug_enabled: diff --git a/modules/interrogate/wd14.py b/modules/interrogate/wd14.py new file mode 100644 index 000000000..af6f54729 --- /dev/null +++ b/modules/interrogate/wd14.py @@ -0,0 +1,535 @@ +# WD14/WaifuDiffusion Tagger - ONNX-based anime/illustration tagging +# Based on SmilingWolf's tagger models: https://huggingface.co/SmilingWolf + +import os +import re +import time +import threading +import numpy as np +from PIL import Image +from modules import shared, devices, errors + + +# Debug logging - enable with SD_INTERROGATE_DEBUG environment variable +debug_enabled = os.environ.get('SD_INTERROGATE_DEBUG', None) is not None +debug_log = shared.log.trace if debug_enabled else lambda *args, **kwargs: None + +re_special = re.compile(r'([\\()])') +load_lock = threading.Lock() + +# WD14 model repository mappings +WD14_MODELS = { + # v3 models (latest, recommended) + "wd-eva02-large-tagger-v3": "SmilingWolf/wd-eva02-large-tagger-v3", + "wd-vit-tagger-v3": "SmilingWolf/wd-vit-tagger-v3", + "wd-convnext-tagger-v3": "SmilingWolf/wd-convnext-tagger-v3", + "wd-swinv2-tagger-v3": "SmilingWolf/wd-swinv2-tagger-v3", + # v2 models + "wd-v1-4-moat-tagger-v2": "SmilingWolf/wd-v1-4-moat-tagger-v2", + "wd-v1-4-swinv2-tagger-v2": "SmilingWolf/wd-v1-4-swinv2-tagger-v2", + "wd-v1-4-convnext-tagger-v2": "SmilingWolf/wd-v1-4-convnext-tagger-v2", + "wd-v1-4-convnextv2-tagger-v2": "SmilingWolf/wd-v1-4-convnextv2-tagger-v2", + "wd-v1-4-vit-tagger-v2": "SmilingWolf/wd-v1-4-vit-tagger-v2", +} + +# Tag categories from selected_tags.csv +CATEGORY_GENERAL = 0 +CATEGORY_CHARACTER = 4 +CATEGORY_RATING = 9 + + +class WD14Tagger: + """WD14/WaifuDiffusion Tagger using ONNX inference.""" + + def __init__(self): + self.session = None + self.tags = None + self.tag_categories = None + self.model_name = None + self.model_path = None + self.image_size = 448 # Standard for WD models + + def load(self, model_name: str = None): + """Load the ONNX model and tags from HuggingFace.""" + import huggingface_hub + + if model_name is None: + model_name = shared.opts.wd14_model + if model_name not in WD14_MODELS: + shared.log.error(f'WD14: unknown model "{model_name}"') + return False + + with load_lock: + if self.session is not None and self.model_name == model_name: + debug_log(f'WD14: model already loaded model="{model_name}"') + return True # Already loaded + + # Unload previous model if different + if self.model_name != model_name and self.session is not None: + debug_log(f'WD14: switching model from "{self.model_name}" to "{model_name}"') + self.unload() + + repo_id = WD14_MODELS[model_name] + t0 = time.time() + shared.log.info(f'WD14 load: model="{model_name}" repo="{repo_id}"') + + try: + # Download only ONNX model and tags CSV (skip safetensors/msgpack variants) + debug_log(f'WD14 load: downloading from HuggingFace cache_dir="{shared.opts.hfcache_dir}"') + self.model_path = huggingface_hub.snapshot_download( + repo_id, + cache_dir=shared.opts.hfcache_dir, + allow_patterns=["model.onnx", "selected_tags.csv"], + ) + debug_log(f'WD14 load: model_path="{self.model_path}"') + + # Load ONNX model + model_file = os.path.join(self.model_path, "model.onnx") + if not os.path.exists(model_file): + shared.log.error(f'WD14 load: model file not found: {model_file}') + return False + + import onnxruntime as ort + providers = [] + if devices.backend == 'cuda': + providers.append('CUDAExecutionProvider') + providers.append('CPUExecutionProvider') + debug_log(f'WD14 load: onnxruntime version={ort.__version__} providers={providers}') + + self.session = ort.InferenceSession(model_file, providers=providers) + self.model_name = model_name + + # Get actual providers used + actual_providers = self.session.get_providers() + debug_log(f'WD14 load: active providers={actual_providers}') + + # Load tags from CSV + self._load_tags() + + load_time = time.time() - t0 + shared.log.debug(f'WD14 load: time={load_time:.2f}s tags={len(self.tags)}') + debug_log(f'WD14 load: input_name={self.session.get_inputs()[0].name} output_name={self.session.get_outputs()[0].name}') + return True + + except Exception as e: + shared.log.error(f'WD14 load: failed error={e}') + errors.display(e, 'WD14 load') + self.unload() + return False + + def _load_tags(self): + """Load tags and categories from selected_tags.csv.""" + import csv + + csv_path = os.path.join(self.model_path, "selected_tags.csv") + if not os.path.exists(csv_path): + shared.log.error(f'WD14 load: tags file not found: {csv_path}') + return + + self.tags = [] + self.tag_categories = [] + + with open(csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + self.tags.append(row['name']) + self.tag_categories.append(int(row['category'])) + + # Count tags by category + category_counts = {} + for cat in self.tag_categories: + category_counts[cat] = category_counts.get(cat, 0) + 1 + debug_log(f'WD14 load: tag categories={category_counts}') + + def unload(self): + """Unload the model and free resources.""" + if self.session is not None: + shared.log.debug(f'WD14 unload: model="{self.model_name}"') + self.session = None + self.tags = None + self.tag_categories = None + self.model_name = None + self.model_path = None + devices.torch_gc(force=True) + debug_log('WD14 unload: complete') + else: + debug_log('WD14 unload: no model loaded') + + def preprocess_image(self, image: Image.Image) -> np.ndarray: + """Preprocess image for WD14 model input. + + - Resize to 448x448 (standard for WD models) + - Pad to square with white background + - Normalize to [0, 1] range + - BGR channel order (as used by these models) + """ + original_size = image.size + original_mode = image.mode + + # Convert to RGB if needed + if image.mode != 'RGB': + image = image.convert('RGB') + + # Pad to square with white background + w, h = image.size + max_dim = max(w, h) + pad_left = (max_dim - w) // 2 + pad_top = (max_dim - h) // 2 + + padded = Image.new('RGB', (max_dim, max_dim), (255, 255, 255)) + padded.paste(image, (pad_left, pad_top)) + + # Resize to model input size + if max_dim != self.image_size: + padded = padded.resize((self.image_size, self.image_size), Image.Resampling.LANCZOS) + + # Convert to numpy array and normalize + img_array = np.array(padded, dtype=np.float32) + + # Convert RGB to BGR (model expects BGR) + img_array = img_array[:, :, ::-1] + + # Add batch dimension + img_array = np.expand_dims(img_array, axis=0) + + debug_log(f'WD14 preprocess: original_size={original_size} mode={original_mode} padded_size={max_dim} output_shape={img_array.shape}') + return img_array + + def predict( + self, + image: Image.Image, + general_threshold: float = None, + character_threshold: float = None, + include_rating: bool = None, + exclude_tags: str = None, + max_tags: int = None, + sort_alpha: bool = None, + use_spaces: bool = None, + escape_brackets: bool = None, + ) -> str: + """Run inference and return formatted tag string. + + Args: + image: PIL Image to tag + general_threshold: Threshold for general tags (0-1) + character_threshold: Threshold for character tags (0-1) + include_rating: Whether to include rating tags + exclude_tags: Comma-separated tags to exclude + max_tags: Maximum number of tags to return + sort_alpha: Sort tags alphabetically vs by confidence + use_spaces: Use spaces instead of underscores + escape_brackets: Escape parentheses/brackets in tags + + Returns: + Formatted tag string + """ + t0 = time.time() + + # Use settings defaults if not specified + if general_threshold is None: + general_threshold = shared.opts.wd14_general_threshold + if character_threshold is None: + character_threshold = shared.opts.wd14_character_threshold + if include_rating is None: + include_rating = shared.opts.wd14_include_rating + if exclude_tags is None: + exclude_tags = shared.opts.wd14_exclude_tags + if max_tags is None: + max_tags = shared.opts.wd14_max_tags + if sort_alpha is None: + sort_alpha = shared.opts.wd14_sort_alpha + if use_spaces is None: + use_spaces = shared.opts.wd14_use_spaces + if escape_brackets is None: + escape_brackets = shared.opts.wd14_escape + + debug_log(f'WD14 predict: general_threshold={general_threshold} character_threshold={character_threshold} max_tags={max_tags} include_rating={include_rating} sort_alpha={sort_alpha}') + + # Handle input variations + if isinstance(image, list): + image = image[0] if len(image) > 0 else None + if isinstance(image, dict) and 'name' in image: + image = Image.open(image['name']) + if image is None: + shared.log.error('WD14 predict: no image provided') + return '' + + # Load model if needed + if self.session is None: + if not self.load(): + return '' + + # Preprocess image + img_input = self.preprocess_image(image) + + # Run inference + t_infer = time.time() + input_name = self.session.get_inputs()[0].name + output_name = self.session.get_outputs()[0].name + probs = self.session.run([output_name], {input_name: img_input})[0][0] + infer_time = time.time() - t_infer + debug_log(f'WD14 predict: inference time={infer_time:.3f}s output_shape={probs.shape}') + + # Build tag list with probabilities + tag_probs = {} + exclude_set = {x.strip().replace(' ', '_').lower() for x in exclude_tags.split(',') if x.strip()} + if exclude_set: + debug_log(f'WD14 predict: exclude_tags={exclude_set}') + + general_count = 0 + character_count = 0 + rating_count = 0 + + for i, (tag_name, prob) in enumerate(zip(self.tags, probs)): + category = self.tag_categories[i] + tag_lower = tag_name.lower() + + # Skip excluded tags + if tag_lower in exclude_set: + continue + + # Apply category-specific thresholds + if category == CATEGORY_RATING: + if not include_rating: + continue + # Always include rating if enabled + tag_probs[tag_name] = float(prob) + rating_count += 1 + elif category == CATEGORY_CHARACTER: + if prob >= character_threshold: + tag_probs[tag_name] = float(prob) + character_count += 1 + elif category == CATEGORY_GENERAL: + if prob >= general_threshold: + tag_probs[tag_name] = float(prob) + general_count += 1 + else: + # Other categories use general threshold + if prob >= general_threshold: + tag_probs[tag_name] = float(prob) + + debug_log(f'WD14 predict: matched tags general={general_count} character={character_count} rating={rating_count} total={len(tag_probs)}') + + # Sort tags + if sort_alpha: + sorted_tags = sorted(tag_probs.keys()) + else: + sorted_tags = [t for t, _ in sorted(tag_probs.items(), key=lambda x: -x[1])] + + # Limit number of tags + if max_tags > 0 and len(sorted_tags) > max_tags: + sorted_tags = sorted_tags[:max_tags] + debug_log(f'WD14 predict: limited to max_tags={max_tags}') + + # Format output + result = [] + for tag_name in sorted_tags: + formatted_tag = tag_name + if use_spaces: + formatted_tag = formatted_tag.replace('_', ' ') + if escape_brackets: + formatted_tag = re.sub(re_special, r'\\\1', formatted_tag) + if shared.opts.interrogate_score: + formatted_tag = f"({formatted_tag}:{tag_probs[tag_name]:.2f})" + result.append(formatted_tag) + + output = ', '.join(result) + total_time = time.time() - t0 + debug_log(f'WD14 predict: complete tags={len(result)} time={total_time:.2f}s result="{output[:100]}..."' if len(output) > 100 else f'WD14 predict: complete tags={len(result)} time={total_time:.2f}s result="{output}"') + + return output + + def tag(self, image: Image.Image, **kwargs) -> str: + """Alias for predict() to match deepbooru interface.""" + return self.predict(image, **kwargs) + + +# Global tagger instance +tagger = WD14Tagger() + + +def get_models() -> list: + """Return list of available WD14 model names.""" + return list(WD14_MODELS.keys()) + + +def refresh_models() -> list: + """Refresh and return list of available models.""" + # For now, just return the static list + # Could be extended to check for locally cached models + return get_models() + + +def load_model(model_name: str = None) -> bool: + """Load the specified WD14 model.""" + return tagger.load(model_name) + + +def unload_model(): + """Unload the current WD14 model.""" + tagger.unload() + + +def tag(image: Image.Image, model_name: str = None, **kwargs) -> str: + """Tag an image using WD14 tagger. + + Args: + image: PIL Image to tag + model_name: Model to use (loads if needed) + **kwargs: Additional arguments passed to predict() + + Returns: + Formatted tag string + """ + t0 = time.time() + jobid = shared.state.begin('WD14 Tag') + shared.log.info(f'WD14: model="{model_name or tagger.model_name or shared.opts.wd14_model}" image_size={image.size if image else None}') + + try: + if model_name and model_name != tagger.model_name: + tagger.load(model_name) + result = tagger.predict(image, **kwargs) + shared.log.debug(f'WD14: complete time={time.time()-t0:.2f}s tags={len(result.split(", ")) if result else 0}') + except Exception as e: + result = f"Exception {type(e)}" + shared.log.error(f'WD14: {e}') + errors.display(e, 'WD14 Tag') + + shared.state.end(jobid) + return result + + +def batch( + model_name: str, + batch_files: list, + batch_folder: str, + batch_str: str, + save_output: bool = True, + save_append: bool = False, + recursive: bool = False, + **kwargs +) -> str: + """Process multiple images in batch mode. + + Args: + model_name: Model to use + batch_files: List of file paths + batch_folder: Folder path from file picker + batch_str: Folder path as string + save_output: Save caption to .txt files + save_append: Append to existing caption files + recursive: Recursively process subfolders + **kwargs: Additional arguments passed to predict() + + Returns: + Combined tag results + """ + from pathlib import Path + + # Load model + if model_name: + tagger.load(model_name) + elif tagger.session is None: + tagger.load() + + # Collect image files + image_files = [] + image_extensions = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif'} + + # From file picker + if batch_files: + for f in batch_files: + if isinstance(f, dict): + image_files.append(Path(f['name'])) + elif hasattr(f, 'name'): + image_files.append(Path(f.name)) + else: + image_files.append(Path(f)) + + # From folder picker + if batch_folder: + folder_path = None + if isinstance(batch_folder, list) and len(batch_folder) > 0: + f = batch_folder[0] + if isinstance(f, dict): + folder_path = Path(f['name']).parent + elif hasattr(f, 'name'): + folder_path = Path(f.name).parent + if folder_path and folder_path.is_dir(): + if recursive: + for ext in image_extensions: + image_files.extend(folder_path.rglob(f'*{ext}')) + else: + for ext in image_extensions: + image_files.extend(folder_path.glob(f'*{ext}')) + + # From string path + if batch_str and batch_str.strip(): + folder_path = Path(batch_str.strip()) + if folder_path.is_dir(): + if recursive: + for ext in image_extensions: + image_files.extend(folder_path.rglob(f'*{ext}')) + else: + for ext in image_extensions: + image_files.extend(folder_path.glob(f'*{ext}')) + + # Remove duplicates while preserving order + seen = set() + unique_files = [] + for f in image_files: + f_resolved = f.resolve() + if f_resolved not in seen: + seen.add(f_resolved) + unique_files.append(f) + image_files = unique_files + + if not image_files: + shared.log.warning('WD14 batch: no images found') + return '' + + t0 = time.time() + jobid = shared.state.begin('WD14 Batch') + shared.log.info(f'WD14 batch: model="{tagger.model_name}" images={len(image_files)} write={save_output} append={save_append} recursive={recursive}') + debug_log(f'WD14 batch: files={[str(f) for f in image_files[:5]]}{"..." if len(image_files) > 5 else ""}') + + results = [] + + # Progress bar + import rich.progress as rp + pbar = rp.Progress(rp.TextColumn('[cyan]WD14:'), rp.BarColumn(), rp.MofNCompleteColumn(), rp.TaskProgressColumn(), rp.TimeRemainingColumn(), rp.TimeElapsedColumn(), rp.TextColumn('[cyan]{task.description}'), console=shared.console) + + with pbar: + task = pbar.add_task(total=len(image_files), description='starting...') + for img_path in image_files: + pbar.update(task, advance=1, description=str(img_path.name)) + try: + if shared.state.interrupted: + shared.log.info('WD14 batch: interrupted') + break + + image = Image.open(img_path) + tags_str = tagger.predict(image, **kwargs) + + if save_output: + txt_path = img_path.with_suffix('.txt') + if save_append and txt_path.exists(): + with open(txt_path, 'a', encoding='utf-8') as f: + f.write(f', {tags_str}') + debug_log(f'WD14 batch: appended to "{txt_path}"') + else: + with open(txt_path, 'w', encoding='utf-8') as f: + f.write(tags_str) + debug_log(f'WD14 batch: wrote to "{txt_path}"') + + results.append(f'{img_path.name}: {tags_str[:100]}...' if len(tags_str) > 100 else f'{img_path.name}: {tags_str}') + + except Exception as e: + shared.log.error(f'WD14 batch: file="{img_path}" error={e}') + results.append(f'{img_path.name}: ERROR - {e}') + + elapsed = time.time() - t0 + shared.log.info(f'WD14 batch: complete images={len(results)} time={elapsed:.1f}s') + shared.state.end(jobid) + + return '\n'.join(results) diff --git a/modules/shared.py b/modules/shared.py index 0ac6511a1..da08559e4 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -711,6 +711,17 @@ options_templates.update(options_section(('interrogate', "Interrogate"), { "deepbooru_use_spaces": OptionInfo(False, "DeepBooru: use spaces for tags"), "deepbooru_escape": OptionInfo(True, "DeepBooru: escape brackets"), "deepbooru_filter_tags": OptionInfo("", "DeepBooru: exclude tags"), + + "wd14_sep": OptionInfo("

WD14 Tagger

", "", gr.HTML), + "wd14_model": OptionInfo("wd-eva02-large-tagger-v3", "WD14: default model", gr.Dropdown, {"choices": []}), + "wd14_general_threshold": OptionInfo(0.35, "WD14: general tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01}), + "wd14_character_threshold": OptionInfo(0.85, "WD14: character tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01}), + "wd14_max_tags": OptionInfo(74, "WD14: max tags", gr.Slider, {"minimum": 1, "maximum": 512, "step": 1}), + "wd14_include_rating": OptionInfo(False, "WD14: include rating tags"), + "wd14_sort_alpha": OptionInfo(False, "WD14: sort alphabetically"), + "wd14_use_spaces": OptionInfo(False, "WD14: use spaces for tags"), + "wd14_escape": OptionInfo(True, "WD14: escape brackets"), + "wd14_exclude_tags": OptionInfo("", "WD14: exclude tags"), })) options_templates.update(options_section(('huggingface', "Huggingface"), { diff --git a/modules/ui_caption.py b/modules/ui_caption.py index d27b76ce6..1c61cfe88 100644 --- a/modules/ui_caption.py +++ b/modules/ui_caption.py @@ -43,6 +43,45 @@ def update_vlm_params(*args): shared.opts.save() +def wd14_tag_wrapper(image, model_name, general_threshold, character_threshold, include_rating, exclude_tags, max_tags, sort_alpha, use_spaces, escape_brackets): + """Wrapper for wd14.tag that maps UI inputs to function parameters.""" + from modules.interrogate import wd14 + return wd14.tag( + image=image, + model_name=model_name, + general_threshold=general_threshold, + character_threshold=character_threshold, + include_rating=include_rating, + exclude_tags=exclude_tags, + max_tags=int(max_tags), + sort_alpha=sort_alpha, + use_spaces=use_spaces, + escape_brackets=escape_brackets, + ) + + +def wd14_batch_wrapper(model_name, batch_files, batch_folder, batch_str, save_output, save_append, recursive, general_threshold, character_threshold, include_rating, exclude_tags, max_tags, sort_alpha, use_spaces, escape_brackets): + """Wrapper for wd14.batch that maps UI inputs to function parameters.""" + from modules.interrogate import wd14 + return wd14.batch( + model_name=model_name, + batch_files=batch_files, + batch_folder=batch_folder, + batch_str=batch_str, + save_output=save_output, + save_append=save_append, + recursive=recursive, + general_threshold=general_threshold, + character_threshold=character_threshold, + include_rating=include_rating, + exclude_tags=exclude_tags, + max_tags=int(max_tags), + sort_alpha=sort_alpha, + use_spaces=use_spaces, + escape_brackets=escape_brackets, + ) + + def update_clip_params(*args): clip_min_length, clip_max_length, clip_chunk_size, clip_min_flavors, clip_max_flavors, clip_flavor_count, clip_num_beams = args shared.opts.interrogate_clip_min_length = int(clip_min_length) @@ -158,6 +197,42 @@ def create_ui(): with gr.Row(): btn_clip_interrogate_img = gr.Button("Interrogate", variant='primary', elem_id="btn_clip_interrogate_img") btn_clip_analyze_img = gr.Button("Analyze", variant='primary', elem_id="btn_clip_analyze_img") + with gr.Tab("Booru Tags", elem_id='tab_booru_tags'): + from modules.interrogate import wd14 + with gr.Row(): + wd_model = gr.Dropdown(wd14.get_models(), value=shared.opts.wd14_model, label='Tagger Model', elem_id='wd_model') + ui_common.create_refresh_button(wd_model, wd14.refresh_models, lambda: {"choices": wd14.get_models()}, 'wd_models_refresh') + with gr.Row(): + wd_load_btn = gr.Button(value='Load', elem_id='wd_load', variant='secondary') + wd_unload_btn = gr.Button(value='Unload', elem_id='wd_unload', variant='secondary') + with gr.Accordion(label='Tagger: Advanced Options', open=True, visible=True): + with gr.Row(): + wd_general_threshold = gr.Slider(label='General threshold', value=shared.opts.wd14_general_threshold, minimum=0.0, maximum=1.0, step=0.01, elem_id='wd_general_threshold') + wd_character_threshold = gr.Slider(label='Character threshold', value=shared.opts.wd14_character_threshold, minimum=0.0, maximum=1.0, step=0.01, elem_id='wd_character_threshold') + with gr.Row(): + wd_max_tags = gr.Slider(label='Max tags', value=shared.opts.wd14_max_tags, minimum=1, maximum=512, step=1, elem_id='wd_max_tags') + wd_include_rating = gr.Checkbox(label='Include rating', value=shared.opts.wd14_include_rating, elem_id='wd_include_rating') + with gr.Row(): + wd_sort_alpha = gr.Checkbox(label='Sort alphabetically', value=shared.opts.wd14_sort_alpha, elem_id='wd_sort_alpha') + wd_use_spaces = gr.Checkbox(label='Use spaces', value=shared.opts.wd14_use_spaces, elem_id='wd_use_spaces') + wd_escape = gr.Checkbox(label='Escape brackets', value=shared.opts.wd14_escape, elem_id='wd_escape') + with gr.Row(): + wd_exclude_tags = gr.Textbox(label='Exclude tags', value=shared.opts.wd14_exclude_tags, placeholder='Comma-separated tags to exclude', elem_id='wd_exclude_tags') + with gr.Accordion(label='Tagger: Batch', open=False, visible=True): + with gr.Row(): + wd_batch_files = gr.File(label="Files", show_label=True, file_count='multiple', file_types=['image'], interactive=True, height=100, elem_id='wd_batch_files') + with gr.Row(): + wd_batch_folder = gr.File(label="Folder", show_label=True, file_count='directory', file_types=['image'], interactive=True, height=100, elem_id='wd_batch_folder') + with gr.Row(): + wd_batch_str = gr.Textbox(label="Folder", value="", interactive=True, elem_id='wd_batch_str') + with gr.Row(): + wd_save_output = gr.Checkbox(label='Save Caption Files', value=True, elem_id="wd_save_output") + wd_save_append = gr.Checkbox(label='Append Caption Files', value=False, elem_id="wd_save_append") + wd_folder_recursive = gr.Checkbox(label='Recursive', value=False, elem_id="wd_folder_recursive") + with gr.Row(): + btn_wd_tag_batch = gr.Button("Batch Tag", variant='primary', elem_id="btn_wd_tag_batch") + with gr.Row(): + btn_wd_tag = gr.Button("Tag", variant='primary', elem_id="btn_wd_tag") with gr.Column(variant='compact', elem_id='interrogate_output'): with gr.Row(elem_id='interrogate_output_prompt'): prompt = gr.Textbox(label="Answer", lines=12, placeholder="ai generated image description") @@ -178,6 +253,8 @@ def create_ui(): btn_clip_interrogate_batch.click(fn=openclip.interrogate_batch, inputs=[clip_batch_files, clip_batch_folder, clip_batch_str, clip_model, blip_model, clip_mode, clip_save_output, clip_save_append, clip_folder_recursive], outputs=[prompt]).then(fn=lambda: gr.update(visible=False), inputs=[], outputs=[output_image]) btn_vlm_caption.click(fn=vlm_caption_wrapper, inputs=[vlm_question, vlm_system, vlm_prompt, image, vlm_model, vlm_prefill, vlm_thinking_mode], outputs=[prompt, output_image]) btn_vlm_caption_batch.click(fn=vqa.batch, inputs=[vlm_model, vlm_system, vlm_batch_files, vlm_batch_folder, vlm_batch_str, vlm_question, vlm_prompt, vlm_save_output, vlm_save_append, vlm_folder_recursive, vlm_prefill, vlm_thinking_mode], outputs=[prompt]).then(fn=lambda: gr.update(visible=False), inputs=[], outputs=[output_image]) + btn_wd_tag.click(fn=wd14_tag_wrapper, inputs=[image, wd_model, wd_general_threshold, wd_character_threshold, wd_include_rating, wd_exclude_tags, wd_max_tags, wd_sort_alpha, wd_use_spaces, wd_escape], outputs=[prompt]).then(fn=lambda: gr.update(visible=False), inputs=[], outputs=[output_image]) + btn_wd_tag_batch.click(fn=wd14_batch_wrapper, inputs=[wd_model, wd_batch_files, wd_batch_folder, wd_batch_str, wd_save_output, wd_save_append, wd_folder_recursive, wd_general_threshold, wd_character_threshold, wd_include_rating, wd_exclude_tags, wd_max_tags, wd_sort_alpha, wd_use_spaces, wd_escape], outputs=[prompt]).then(fn=lambda: gr.update(visible=False), inputs=[], outputs=[output_image]) # Dynamic UI updates based on selected model and task vlm_model.change(fn=update_vlm_prompts_for_model, inputs=[vlm_model], outputs=[vlm_question]) @@ -186,6 +263,14 @@ def create_ui(): # Load/Unload model buttons vlm_load_btn.click(fn=vqa.load_model, inputs=[vlm_model], outputs=[]) vlm_unload_btn.click(fn=vqa.unload_model, inputs=[], outputs=[]) + def wd14_load_wrapper(model_name): + from modules.interrogate import wd14 + return wd14.load_model(model_name) + def wd14_unload_wrapper(): + from modules.interrogate import wd14 + return wd14.unload_model() + wd_load_btn.click(fn=wd14_load_wrapper, inputs=[wd_model], outputs=[]) + wd_unload_btn.click(fn=wd14_unload_wrapper, inputs=[], outputs=[]) for tabname, button in copy_interrogate_buttons.items(): generation_parameters_copypaste.register_paste_params_button(generation_parameters_copypaste.ParamBinding(paste_button=button, tabname=tabname, source_text_component=prompt, source_image_component=image,)) From 09b8fe9761321b8af1c47040208951ac3bfe4580 Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Mon, 19 Jan 2026 16:46:57 +0000 Subject: [PATCH 2/7] feat(caption): integrate DeepBooru into unified Booru Tagger UI Add DeepBooru as a model option alongside WD14 models in the Booru Tags tab, with dynamic UI that disables inapplicable controls. Changes: - Create modules/interrogate/tagger.py as unified adapter module - Add batch, load/unload, get_models functions to deepbooru.py - Update ui_caption.py to use unified tagger interface - Consolidate shared tagger settings in shared.py - Add implementation plan for future settings consolidation UI behavior: - Model dropdown shows DeepBooru + all WD14 models - Character threshold and include rating disabled for DeepBooru - All controls re-enable when WD14 model selected --- modules/interrogate/deepbooru.py | 209 +++++++++++++++++++++++++++++-- modules/interrogate/tagger.py | 79 ++++++++++++ modules/interrogate/wd14.py | 19 ++- modules/shared.py | 23 ++-- modules/ui_caption.py | 70 +++++++---- 5 files changed, 340 insertions(+), 60 deletions(-) create mode 100644 modules/interrogate/tagger.py diff --git a/modules/interrogate/deepbooru.py b/modules/interrogate/deepbooru.py index 1e47e6cc8..bdf999ed2 100644 --- a/modules/interrogate/deepbooru.py +++ b/modules/interrogate/deepbooru.py @@ -4,7 +4,7 @@ import threading import torch import numpy as np from PIL import Image -from modules import modelloader, paths, devices, shared, sd_models +from modules import modelloader, paths, devices, shared re_special = re.compile(r'([\\()])') load_lock = threading.Lock() @@ -13,6 +13,7 @@ load_lock = threading.Lock() class DeepDanbooru: def __init__(self): self.model = None + self._device = devices.cpu def load(self): with load_lock: @@ -32,14 +33,17 @@ class DeepDanbooru: self.model.load_state_dict(torch.load(files[0], map_location="cpu")) self.model.eval() self.model.to(devices.cpu, devices.dtype) + self._device = devices.cpu def start(self): self.load() - sd_models.move_model(self.model, devices.device) + self.model.to(devices.device) + self._device = devices.device def stop(self): if shared.opts.interrogate_offload: - sd_models.move_model(self.model, devices.cpu) + self.model.to(devices.cpu) + self._device = devices.cpu devices.torch_gc() def tag(self, pil_image): @@ -58,8 +62,8 @@ class DeepDanbooru: return '' pic = pil_image.resize((512, 512), resample=Image.Resampling.LANCZOS).convert("RGB") a = np.expand_dims(np.array(pic, dtype=np.float32), 0) / 255 - with devices.inference_context(), devices.autocast(): - x = torch.from_numpy(a).to(devices.device) + with devices.inference_context(): + x = torch.from_numpy(a).to(device=devices.device, dtype=devices.dtype) y = self.model(x)[0].detach().float().cpu().numpy() probability_dict = {} for tag, probability in zip(self.model.tags, y): @@ -68,25 +72,208 @@ class DeepDanbooru: if tag.startswith("rating:"): continue probability_dict[tag] = probability - if shared.opts.deepbooru_sort_alpha: + if shared.opts.tagger_sort_alpha: tags = sorted(probability_dict) else: tags = [tag for tag, _ in sorted(probability_dict.items(), key=lambda x: -x[1])] res = [] - filtertags = {x.strip().replace(' ', '_') for x in shared.opts.deepbooru_filter_tags.split(",")} + filtertags = {x.strip().replace(' ', '_') for x in shared.opts.tagger_exclude_tags.split(",")} for tag in [x for x in tags if x not in filtertags]: probability = probability_dict[tag] tag_outformat = tag - if shared.opts.deepbooru_use_spaces: + if shared.opts.tagger_use_spaces: tag_outformat = tag_outformat.replace('_', ' ') - if shared.opts.deepbooru_escape: + if shared.opts.tagger_escape: tag_outformat = re.sub(re_special, r'\\\1', tag_outformat) if shared.opts.interrogate_score and not force_disable_ranks: tag_outformat = f"({tag_outformat}:{probability:.2f})" res.append(tag_outformat) - if len(res) > shared.opts.deepbooru_max_tags: - res = res[:shared.opts.deepbooru_max_tags] + if len(res) > shared.opts.tagger_max_tags: + res = res[:shared.opts.tagger_max_tags] return ", ".join(res) model = DeepDanbooru() + + +def get_models() -> list: + """Return list of available DeepBooru models (just one).""" + return ["DeepBooru"] + + +def load_model(model_name: str = None) -> bool: + """Load the DeepBooru model.""" + try: + model.load() + return model.model is not None + except Exception as e: + shared.log.error(f'DeepBooru load: {e}') + return False + + +def unload_model(): + """Unload the DeepBooru model and free memory.""" + if model.model is not None: + shared.log.debug('DeepBooru unload') + model.model = None + model._device = devices.cpu + devices.torch_gc(force=True) + + +def tag(image, **kwargs) -> str: + """Tag an image using DeepBooru. + + Args: + image: PIL Image to tag + **kwargs: Additional arguments (for interface compatibility) + + Returns: + Formatted tag string + """ + import time + t0 = time.time() + jobid = shared.state.begin('DeepBooru Tag') + shared.log.info(f'DeepBooru: image_size={image.size if image else None}') + + try: + result = model.tag(image) + shared.log.debug(f'DeepBooru: complete time={time.time()-t0:.2f}s tags={len(result.split(", ")) if result else 0}') + except Exception as e: + result = f"Exception {type(e)}" + shared.log.error(f'DeepBooru: {e}') + + shared.state.end(jobid) + return result + + +def batch( + model_name: str, + batch_files: list, + batch_folder: str, + batch_str: str, + save_output: bool = True, + save_append: bool = False, + recursive: bool = False, + **kwargs +) -> str: + """Process multiple images in batch mode. + + Args: + model_name: Model name (ignored, only DeepBooru available) + batch_files: List of file paths + batch_folder: Folder path from file picker + batch_str: Folder path as string + save_output: Save caption to .txt files + save_append: Append to existing caption files + recursive: Recursively process subfolders + **kwargs: Additional arguments (for interface compatibility) + + Returns: + Combined tag results + """ + import time + from pathlib import Path + import rich.progress as rp + + # Load model + model.load() + + # Collect image files + image_files = [] + image_extensions = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif'} + + # From file picker + if batch_files: + for f in batch_files: + if isinstance(f, dict): + image_files.append(Path(f['name'])) + elif hasattr(f, 'name'): + image_files.append(Path(f.name)) + else: + image_files.append(Path(f)) + + # From folder picker + if batch_folder: + folder_path = None + if isinstance(batch_folder, list) and len(batch_folder) > 0: + f = batch_folder[0] + if isinstance(f, dict): + folder_path = Path(f['name']).parent + elif hasattr(f, 'name'): + folder_path = Path(f.name).parent + if folder_path and folder_path.is_dir(): + if recursive: + for ext in image_extensions: + image_files.extend(folder_path.rglob(f'*{ext}')) + else: + for ext in image_extensions: + image_files.extend(folder_path.glob(f'*{ext}')) + + # From string path + if batch_str and batch_str.strip(): + folder_path = Path(batch_str.strip()) + if folder_path.is_dir(): + if recursive: + for ext in image_extensions: + image_files.extend(folder_path.rglob(f'*{ext}')) + else: + for ext in image_extensions: + image_files.extend(folder_path.glob(f'*{ext}')) + + # Remove duplicates while preserving order + seen = set() + unique_files = [] + for f in image_files: + f_resolved = f.resolve() + if f_resolved not in seen: + seen.add(f_resolved) + unique_files.append(f) + image_files = unique_files + + if not image_files: + shared.log.warning('DeepBooru batch: no images found') + return '' + + t0 = time.time() + jobid = shared.state.begin('DeepBooru Batch') + shared.log.info(f'DeepBooru batch: images={len(image_files)} write={save_output} append={save_append} recursive={recursive}') + + results = [] + model.start() + + # Progress bar + pbar = rp.Progress(rp.TextColumn('[cyan]DeepBooru:'), rp.BarColumn(), rp.MofNCompleteColumn(), rp.TaskProgressColumn(), rp.TimeRemainingColumn(), rp.TimeElapsedColumn(), rp.TextColumn('[cyan]{task.description}'), console=shared.console) + + with pbar: + task = pbar.add_task(total=len(image_files), description='starting...') + for img_path in image_files: + pbar.update(task, advance=1, description=str(img_path.name)) + try: + if shared.state.interrupted: + shared.log.info('DeepBooru batch: interrupted') + break + + image = Image.open(img_path) + tags_str = model.tag_multi(image) + + if save_output: + txt_path = img_path.with_suffix('.txt') + if save_append and txt_path.exists(): + with open(txt_path, 'a', encoding='utf-8') as f: + f.write(f', {tags_str}') + else: + with open(txt_path, 'w', encoding='utf-8') as f: + f.write(tags_str) + + results.append(f'{img_path.name}: {tags_str[:100]}...' if len(tags_str) > 100 else f'{img_path.name}: {tags_str}') + + except Exception as e: + shared.log.error(f'DeepBooru batch: file="{img_path}" error={e}') + results.append(f'{img_path.name}: ERROR - {e}') + + model.stop() + elapsed = time.time() - t0 + shared.log.info(f'DeepBooru batch: complete images={len(results)} time={elapsed:.1f}s') + shared.state.end(jobid) + + return '\n'.join(results) diff --git a/modules/interrogate/tagger.py b/modules/interrogate/tagger.py new file mode 100644 index 000000000..cd2374d04 --- /dev/null +++ b/modules/interrogate/tagger.py @@ -0,0 +1,79 @@ +# Unified Tagger Interface - Dispatches to WD14 or DeepBooru based on model selection +# Provides a common interface for the Booru Tags tab + +from modules import shared + +DEEPBOORU_MODEL = "DeepBooru" + + +def get_models() -> list: + """Return combined list: DeepBooru + WD14 models.""" + from modules.interrogate import wd14 + return [DEEPBOORU_MODEL] + wd14.get_models() + + +def refresh_models() -> list: + """Refresh and return all models.""" + return get_models() + + +def is_deepbooru(model_name: str) -> bool: + """Check if selected model is DeepBooru.""" + return model_name == DEEPBOORU_MODEL + + +def load_model(model_name: str) -> bool: + """Load appropriate backend.""" + if is_deepbooru(model_name): + from modules.interrogate import deepbooru + return deepbooru.load_model() + else: + from modules.interrogate import wd14 + return wd14.load_model(model_name) + + +def unload_model(): + """Unload both backends to ensure memory is freed.""" + from modules.interrogate import deepbooru, wd14 + deepbooru.unload_model() + wd14.unload_model() + + +def tag(image, model_name: str = None, **kwargs) -> str: + """Unified tagging - dispatch to correct backend. + + Args: + image: PIL Image to tag + model_name: Model to use (DeepBooru or WD14 model name) + **kwargs: Additional arguments passed to the backend + + Returns: + Formatted tag string + """ + if model_name is None: + model_name = shared.opts.wd14_model + + if is_deepbooru(model_name): + from modules.interrogate import deepbooru + return deepbooru.tag(image, **kwargs) + else: + from modules.interrogate import wd14 + return wd14.tag(image, model_name=model_name, **kwargs) + + +def batch(model_name: str, **kwargs) -> str: + """Unified batch processing. + + Args: + model_name: Model to use (DeepBooru or WD14 model name) + **kwargs: Additional arguments passed to the backend + + Returns: + Combined tag results + """ + if is_deepbooru(model_name): + from modules.interrogate import deepbooru + return deepbooru.batch(model_name=model_name, **kwargs) + else: + from modules.interrogate import wd14 + return wd14.batch(model_name=model_name, **kwargs) diff --git a/modules/interrogate/wd14.py b/modules/interrogate/wd14.py index af6f54729..8c28bceb0 100644 --- a/modules/interrogate/wd14.py +++ b/modules/interrogate/wd14.py @@ -90,13 +90,10 @@ class WD14Tagger: return False import onnxruntime as ort - providers = [] - if devices.backend == 'cuda': - providers.append('CUDAExecutionProvider') - providers.append('CPUExecutionProvider') - debug_log(f'WD14 load: onnxruntime version={ort.__version__} providers={providers}') - self.session = ort.InferenceSession(model_file, providers=providers) + debug_log(f'WD14 load: onnxruntime version={ort.__version__}') + + self.session = ort.InferenceSession(model_file, providers=['CPUExecutionProvider']) self.model_name = model_name # Get actual providers used @@ -233,15 +230,15 @@ class WD14Tagger: if include_rating is None: include_rating = shared.opts.wd14_include_rating if exclude_tags is None: - exclude_tags = shared.opts.wd14_exclude_tags + exclude_tags = shared.opts.tagger_exclude_tags if max_tags is None: - max_tags = shared.opts.wd14_max_tags + max_tags = shared.opts.tagger_max_tags if sort_alpha is None: - sort_alpha = shared.opts.wd14_sort_alpha + sort_alpha = shared.opts.tagger_sort_alpha if use_spaces is None: - use_spaces = shared.opts.wd14_use_spaces + use_spaces = shared.opts.tagger_use_spaces if escape_brackets is None: - escape_brackets = shared.opts.wd14_escape + escape_brackets = shared.opts.tagger_escape debug_log(f'WD14 predict: general_threshold={general_threshold} character_threshold={character_threshold} max_tags={max_tags} include_rating={include_rating} sort_alpha={sort_alpha}') diff --git a/modules/shared.py b/modules/shared.py index da08559e4..d49005a9a 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -691,7 +691,7 @@ options_templates.update(options_section(('interrogate', "Interrogate"), { "interrogate_vlm_sep": OptionInfo("

VLM

", "", gr.HTML), "interrogate_vlm_model": OptionInfo(vlm_default, "VLM: default model", gr.Dropdown, {"choices": list(vlm_models)}), - "interrogate_vlm_prompt": OptionInfo(vlm_prompts[0], "VLM: default prompt", DropdownEditable, {"choices": vlm_prompts }), + "interrogate_vlm_prompt": OptionInfo(vlm_prompts[2], "VLM: default prompt", DropdownEditable, {"choices": vlm_prompts }), "interrogate_vlm_system": OptionInfo(vlm_system, "VLM: default prompt"), "interrogate_vlm_num_beams": OptionInfo(1, "VLM: num beams", gr.Slider, {"minimum": 1, "maximum": 16, "step": 1, "visible": False}), "interrogate_vlm_max_length": OptionInfo(512, "VLM: max length", gr.Slider, {"minimum": 1, "maximum": 4096, "step": 1, "visible": False}), @@ -703,25 +703,24 @@ options_templates.update(options_section(('interrogate', "Interrogate"), { "interrogate_vlm_keep_thinking": OptionInfo(False, "VLM: keep reasoning trace in output", gr.Checkbox, {"visible": False}), "interrogate_vlm_thinking_mode": OptionInfo(False, "VLM: enable thinking/reasoning mode", gr.Checkbox, {"visible": False}), + # Common tagger settings (shared by DeepBooru and WD14) + "tagger_sep": OptionInfo("

Tagger Settings

", "", gr.HTML), + "tagger_max_tags": OptionInfo(74, "Tagger: max tags", gr.Slider, {"minimum": 1, "maximum": 512, "step": 1}), + "tagger_sort_alpha": OptionInfo(False, "Tagger: sort alphabetically"), + "tagger_use_spaces": OptionInfo(False, "Tagger: use spaces for tags"), + "tagger_escape": OptionInfo(True, "Tagger: escape brackets"), + "tagger_exclude_tags": OptionInfo("", "Tagger: exclude tags"), + + # DeepBooru-specific settings "deepbooru_sep": OptionInfo("

DeepBooru

", "", gr.HTML), "deepbooru_score_threshold": OptionInfo(0.65, "DeepBooru: score threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01}), - "deepbooru_max_tags": OptionInfo(74, "DeepBooru: max tags", gr.Slider, {"minimum": 1, "maximum": 512, "step": 1}), - "deepbooru_clip_score": OptionInfo(False, "DeepBooru: include scores in results"), - "deepbooru_sort_alpha": OptionInfo(False, "DeepBooru: sort alphabetically"), - "deepbooru_use_spaces": OptionInfo(False, "DeepBooru: use spaces for tags"), - "deepbooru_escape": OptionInfo(True, "DeepBooru: escape brackets"), - "deepbooru_filter_tags": OptionInfo("", "DeepBooru: exclude tags"), + # WD14-specific settings "wd14_sep": OptionInfo("

WD14 Tagger

", "", gr.HTML), "wd14_model": OptionInfo("wd-eva02-large-tagger-v3", "WD14: default model", gr.Dropdown, {"choices": []}), "wd14_general_threshold": OptionInfo(0.35, "WD14: general tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01}), "wd14_character_threshold": OptionInfo(0.85, "WD14: character tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01}), - "wd14_max_tags": OptionInfo(74, "WD14: max tags", gr.Slider, {"minimum": 1, "maximum": 512, "step": 1}), "wd14_include_rating": OptionInfo(False, "WD14: include rating tags"), - "wd14_sort_alpha": OptionInfo(False, "WD14: sort alphabetically"), - "wd14_use_spaces": OptionInfo(False, "WD14: use spaces for tags"), - "wd14_escape": OptionInfo(True, "WD14: escape brackets"), - "wd14_exclude_tags": OptionInfo("", "WD14: exclude tags"), })) options_templates.update(options_section(('huggingface', "Huggingface"), { diff --git a/modules/ui_caption.py b/modules/ui_caption.py index 1c61cfe88..4adf4f9b5 100644 --- a/modules/ui_caption.py +++ b/modules/ui_caption.py @@ -43,10 +43,10 @@ def update_vlm_params(*args): shared.opts.save() -def wd14_tag_wrapper(image, model_name, general_threshold, character_threshold, include_rating, exclude_tags, max_tags, sort_alpha, use_spaces, escape_brackets): - """Wrapper for wd14.tag that maps UI inputs to function parameters.""" - from modules.interrogate import wd14 - return wd14.tag( +def tagger_tag_wrapper(image, model_name, general_threshold, character_threshold, include_rating, exclude_tags, max_tags, sort_alpha, use_spaces, escape_brackets): + """Wrapper for tagger.tag that maps UI inputs to function parameters.""" + from modules.interrogate import tagger + return tagger.tag( image=image, model_name=model_name, general_threshold=general_threshold, @@ -60,10 +60,10 @@ def wd14_tag_wrapper(image, model_name, general_threshold, character_threshold, ) -def wd14_batch_wrapper(model_name, batch_files, batch_folder, batch_str, save_output, save_append, recursive, general_threshold, character_threshold, include_rating, exclude_tags, max_tags, sort_alpha, use_spaces, escape_brackets): - """Wrapper for wd14.batch that maps UI inputs to function parameters.""" - from modules.interrogate import wd14 - return wd14.batch( +def tagger_batch_wrapper(model_name, batch_files, batch_folder, batch_str, save_output, save_append, recursive, general_threshold, character_threshold, include_rating, exclude_tags, max_tags, sort_alpha, use_spaces, escape_brackets): + """Wrapper for tagger.batch that maps UI inputs to function parameters.""" + from modules.interrogate import tagger + return tagger.batch( model_name=model_name, batch_files=batch_files, batch_folder=batch_folder, @@ -82,6 +82,20 @@ def wd14_batch_wrapper(model_name, batch_files, batch_folder, batch_str, save_ou ) +def update_tagger_ui(model_name): + """Update UI controls based on selected tagger model. + + When DeepBooru is selected, character_threshold and include_rating are disabled + since DeepBooru doesn't support separate character threshold or rating tags. + """ + from modules.interrogate import tagger + is_db = tagger.is_deepbooru(model_name) + return [ + gr.update(interactive=not is_db), # character_threshold + gr.update(interactive=not is_db, value=False if is_db else None), # include_rating + ] + + def update_clip_params(*args): clip_min_length, clip_max_length, clip_chunk_size, clip_min_flavors, clip_max_flavors, clip_flavor_count, clip_num_beams = args shared.opts.interrogate_clip_min_length = int(clip_min_length) @@ -198,10 +212,10 @@ def create_ui(): btn_clip_interrogate_img = gr.Button("Interrogate", variant='primary', elem_id="btn_clip_interrogate_img") btn_clip_analyze_img = gr.Button("Analyze", variant='primary', elem_id="btn_clip_analyze_img") with gr.Tab("Booru Tags", elem_id='tab_booru_tags'): - from modules.interrogate import wd14 + from modules.interrogate import tagger with gr.Row(): - wd_model = gr.Dropdown(wd14.get_models(), value=shared.opts.wd14_model, label='Tagger Model', elem_id='wd_model') - ui_common.create_refresh_button(wd_model, wd14.refresh_models, lambda: {"choices": wd14.get_models()}, 'wd_models_refresh') + wd_model = gr.Dropdown(tagger.get_models(), value=shared.opts.wd14_model, label='Tagger Model', elem_id='wd_model') + ui_common.create_refresh_button(wd_model, tagger.refresh_models, lambda: {"choices": tagger.get_models()}, 'wd_models_refresh') with gr.Row(): wd_load_btn = gr.Button(value='Load', elem_id='wd_load', variant='secondary') wd_unload_btn = gr.Button(value='Unload', elem_id='wd_unload', variant='secondary') @@ -210,14 +224,15 @@ def create_ui(): wd_general_threshold = gr.Slider(label='General threshold', value=shared.opts.wd14_general_threshold, minimum=0.0, maximum=1.0, step=0.01, elem_id='wd_general_threshold') wd_character_threshold = gr.Slider(label='Character threshold', value=shared.opts.wd14_character_threshold, minimum=0.0, maximum=1.0, step=0.01, elem_id='wd_character_threshold') with gr.Row(): - wd_max_tags = gr.Slider(label='Max tags', value=shared.opts.wd14_max_tags, minimum=1, maximum=512, step=1, elem_id='wd_max_tags') + wd_max_tags = gr.Slider(label='Max tags', value=shared.opts.tagger_max_tags, minimum=1, maximum=512, step=1, elem_id='wd_max_tags') wd_include_rating = gr.Checkbox(label='Include rating', value=shared.opts.wd14_include_rating, elem_id='wd_include_rating') with gr.Row(): - wd_sort_alpha = gr.Checkbox(label='Sort alphabetically', value=shared.opts.wd14_sort_alpha, elem_id='wd_sort_alpha') - wd_use_spaces = gr.Checkbox(label='Use spaces', value=shared.opts.wd14_use_spaces, elem_id='wd_use_spaces') - wd_escape = gr.Checkbox(label='Escape brackets', value=shared.opts.wd14_escape, elem_id='wd_escape') + wd_sort_alpha = gr.Checkbox(label='Sort alphabetically', value=shared.opts.tagger_sort_alpha, elem_id='wd_sort_alpha') + wd_use_spaces = gr.Checkbox(label='Use spaces', value=shared.opts.tagger_use_spaces, elem_id='wd_use_spaces') + wd_escape = gr.Checkbox(label='Escape brackets', value=shared.opts.tagger_escape, elem_id='wd_escape') with gr.Row(): - wd_exclude_tags = gr.Textbox(label='Exclude tags', value=shared.opts.wd14_exclude_tags, placeholder='Comma-separated tags to exclude', elem_id='wd_exclude_tags') + wd_exclude_tags = gr.Textbox(label='Exclude tags', value=shared.opts.tagger_exclude_tags, placeholder='Comma-separated tags to exclude', elem_id='wd_exclude_tags') + gr.HTML('') with gr.Accordion(label='Tagger: Batch', open=False, visible=True): with gr.Row(): wd_batch_files = gr.File(label="Files", show_label=True, file_count='multiple', file_types=['image'], interactive=True, height=100, elem_id='wd_batch_files') @@ -253,8 +268,8 @@ def create_ui(): btn_clip_interrogate_batch.click(fn=openclip.interrogate_batch, inputs=[clip_batch_files, clip_batch_folder, clip_batch_str, clip_model, blip_model, clip_mode, clip_save_output, clip_save_append, clip_folder_recursive], outputs=[prompt]).then(fn=lambda: gr.update(visible=False), inputs=[], outputs=[output_image]) btn_vlm_caption.click(fn=vlm_caption_wrapper, inputs=[vlm_question, vlm_system, vlm_prompt, image, vlm_model, vlm_prefill, vlm_thinking_mode], outputs=[prompt, output_image]) btn_vlm_caption_batch.click(fn=vqa.batch, inputs=[vlm_model, vlm_system, vlm_batch_files, vlm_batch_folder, vlm_batch_str, vlm_question, vlm_prompt, vlm_save_output, vlm_save_append, vlm_folder_recursive, vlm_prefill, vlm_thinking_mode], outputs=[prompt]).then(fn=lambda: gr.update(visible=False), inputs=[], outputs=[output_image]) - btn_wd_tag.click(fn=wd14_tag_wrapper, inputs=[image, wd_model, wd_general_threshold, wd_character_threshold, wd_include_rating, wd_exclude_tags, wd_max_tags, wd_sort_alpha, wd_use_spaces, wd_escape], outputs=[prompt]).then(fn=lambda: gr.update(visible=False), inputs=[], outputs=[output_image]) - btn_wd_tag_batch.click(fn=wd14_batch_wrapper, inputs=[wd_model, wd_batch_files, wd_batch_folder, wd_batch_str, wd_save_output, wd_save_append, wd_folder_recursive, wd_general_threshold, wd_character_threshold, wd_include_rating, wd_exclude_tags, wd_max_tags, wd_sort_alpha, wd_use_spaces, wd_escape], outputs=[prompt]).then(fn=lambda: gr.update(visible=False), inputs=[], outputs=[output_image]) + btn_wd_tag.click(fn=tagger_tag_wrapper, inputs=[image, wd_model, wd_general_threshold, wd_character_threshold, wd_include_rating, wd_exclude_tags, wd_max_tags, wd_sort_alpha, wd_use_spaces, wd_escape], outputs=[prompt]).then(fn=lambda: gr.update(visible=False), inputs=[], outputs=[output_image]) + btn_wd_tag_batch.click(fn=tagger_batch_wrapper, inputs=[wd_model, wd_batch_files, wd_batch_folder, wd_batch_str, wd_save_output, wd_save_append, wd_folder_recursive, wd_general_threshold, wd_character_threshold, wd_include_rating, wd_exclude_tags, wd_max_tags, wd_sort_alpha, wd_use_spaces, wd_escape], outputs=[prompt]).then(fn=lambda: gr.update(visible=False), inputs=[], outputs=[output_image]) # Dynamic UI updates based on selected model and task vlm_model.change(fn=update_vlm_prompts_for_model, inputs=[vlm_model], outputs=[vlm_question]) @@ -263,14 +278,17 @@ def create_ui(): # Load/Unload model buttons vlm_load_btn.click(fn=vqa.load_model, inputs=[vlm_model], outputs=[]) vlm_unload_btn.click(fn=vqa.unload_model, inputs=[], outputs=[]) - def wd14_load_wrapper(model_name): - from modules.interrogate import wd14 - return wd14.load_model(model_name) - def wd14_unload_wrapper(): - from modules.interrogate import wd14 - return wd14.unload_model() - wd_load_btn.click(fn=wd14_load_wrapper, inputs=[wd_model], outputs=[]) - wd_unload_btn.click(fn=wd14_unload_wrapper, inputs=[], outputs=[]) + def tagger_load_wrapper(model_name): + from modules.interrogate import tagger + return tagger.load_model(model_name) + def tagger_unload_wrapper(): + from modules.interrogate import tagger + return tagger.unload_model() + wd_load_btn.click(fn=tagger_load_wrapper, inputs=[wd_model], outputs=[]) + wd_unload_btn.click(fn=tagger_unload_wrapper, inputs=[], outputs=[]) + + # Dynamic UI update when tagger model changes (disable controls for DeepBooru) + wd_model.change(fn=update_tagger_ui, inputs=[wd_model], outputs=[wd_character_threshold, wd_include_rating], show_progress=False) for tabname, button in copy_interrogate_buttons.items(): generation_parameters_copypaste.register_paste_params_button(generation_parameters_copypaste.ParamBinding(paste_button=button, tabname=tabname, source_text_component=prompt, source_image_component=image,)) From 656e86a9625303e2eb16d222f38594b4583523b7 Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Tue, 20 Jan 2026 16:15:07 +0000 Subject: [PATCH 3/7] refactor(caption): consolidate interrogate settings into Caption Tab UI Hide all CLiP, VLM, and Tagger settings from Settings > Interrogate page while keeping them in shared.opts for persistence. Caption Tab UI becomes the single control point with change handlers that save directly to config. Changes: - Hide OpenCLiP, VLM, and Tagger settings with visible=False - Add change handlers to save settings when UI controls change - Rename "Booru Tags" tab to "Tagger", update choice labels - Update interrogate.py to use unified tagger interface with all settings --- modules/interrogate/interrogate.py | 21 ++++++++--- modules/shared.py | 56 ++++++++++++++++-------------- modules/ui_caption.py | 54 +++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 33 deletions(-) diff --git a/modules/interrogate/interrogate.py b/modules/interrogate/interrogate.py index 7f7befcf2..ae28fe110 100644 --- a/modules/interrogate/interrogate.py +++ b/modules/interrogate/interrogate.py @@ -12,7 +12,7 @@ def interrogate(image): shared.log.error('Interrogate: no image provided') return '' t0 = time.time() - if shared.opts.interrogate_default_type == 'OpenCLiP': + if shared.opts.interrogate_default_type == 'CLiP': shared.log.info(f'Interrogate: type={shared.opts.interrogate_default_type} clip="{shared.opts.interrogate_clip_model}" blip="{shared.opts.interrogate_blip_model}" mode="{shared.opts.interrogate_clip_mode}"') from modules.interrogate import openclip openclip.load_interrogator(clip_model=shared.opts.interrogate_clip_model, blip_model=shared.opts.interrogate_blip_model) @@ -20,10 +20,21 @@ def interrogate(image): prompt = openclip.interrogate(image, mode=shared.opts.interrogate_clip_mode) shared.log.debug(f'Interrogate: time={time.time()-t0:.2f} answer="{prompt}"') return prompt - elif shared.opts.interrogate_default_type == 'DeepBooru': - shared.log.info(f'Interrogate: type={shared.opts.interrogate_default_type}') - from modules.interrogate import deepbooru - prompt = deepbooru.model.tag(image) + elif shared.opts.interrogate_default_type == 'Tagger': + shared.log.info(f'Interrogate: type={shared.opts.interrogate_default_type} model="{shared.opts.wd14_model}"') + from modules.interrogate import tagger + prompt = tagger.tag( + image=image, + model_name=shared.opts.wd14_model, + general_threshold=shared.opts.wd14_general_threshold, + character_threshold=shared.opts.wd14_character_threshold, + include_rating=shared.opts.wd14_include_rating, + exclude_tags=shared.opts.tagger_exclude_tags, + max_tags=shared.opts.tagger_max_tags, + sort_alpha=shared.opts.tagger_sort_alpha, + use_spaces=shared.opts.tagger_use_spaces, + escape_brackets=shared.opts.tagger_escape, + ) shared.log.debug(f'Interrogate: time={time.time()-t0:.2f} answer="{prompt}"') return prompt elif shared.opts.interrogate_default_type == 'VLM': diff --git a/modules/shared.py b/modules/shared.py index d49005a9a..7b39d88a2 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -673,14 +673,15 @@ options_templates.update(options_section(('postprocessing', "Postprocessing"), { })) options_templates.update(options_section(('interrogate', "Interrogate"), { - "interrogate_default_type": OptionInfo("VLM", "Default caption type", gr.Radio, {"choices": ["OpenCLiP", "VLM", "DeepBooru"]}), + "interrogate_default_type": OptionInfo("VLM", "Default caption type", gr.Radio, {"choices": ["VLM", "CLiP", "Tagger"]}), "interrogate_offload": OptionInfo(True, "Offload models "), - "interrogate_score": OptionInfo(False, "Include scores in results when available"), + "interrogate_score": OptionInfo(False, "Include scores in results when available", gr.Checkbox, {"visible": False}), - "interrogate_clip_sep": OptionInfo("

OpenCLiP

", "", gr.HTML), - "interrogate_clip_model": OptionInfo("ViT-L-14/openai", "CLiP: default model", gr.Dropdown, lambda: {"choices": get_clip_models()}, refresh=refresh_clip_models), - "interrogate_clip_mode": OptionInfo(caption_types[0], "CLiP: default mode", gr.Dropdown, {"choices": caption_types}), - "interrogate_blip_model": OptionInfo(list(caption_models)[0], "CLiP: default captioner", gr.Dropdown, {"choices": list(caption_models)}), + # OpenCLiP settings (hidden - controlled via Caption Tab) + "interrogate_clip_sep": OptionInfo("

OpenCLiP

", "", gr.HTML, {"visible": False}), + "interrogate_clip_model": OptionInfo("ViT-L-14/openai", "CLiP: default model", gr.Dropdown, lambda: {"choices": get_clip_models(), "visible": False}, refresh=refresh_clip_models), + "interrogate_clip_mode": OptionInfo(caption_types[0], "CLiP: default mode", gr.Dropdown, {"choices": caption_types, "visible": False}), + "interrogate_blip_model": OptionInfo(list(caption_models)[0], "CLiP: default captioner", gr.Dropdown, {"choices": list(caption_models), "visible": False}), "interrogate_clip_num_beams": OptionInfo(1, "CLiP: num beams", gr.Slider, {"minimum": 1, "maximum": 16, "step": 1, "visible": False}), "interrogate_clip_min_length": OptionInfo(32, "CLiP: min length", gr.Slider, {"minimum": 1, "maximum": 128, "step": 1, "visible": False}), "interrogate_clip_max_length": OptionInfo(74, "CLiP: max length", gr.Slider, {"minimum": 1, "maximum": 512, "step": 1, "visible": False}), @@ -689,13 +690,14 @@ options_templates.update(options_section(('interrogate', "Interrogate"), { "interrogate_clip_flavor_count": OptionInfo(1024, "CLiP: intermediate flavors", gr.Slider, {"minimum": 256, "maximum": 4096, "step": 64, "visible": False}), "interrogate_clip_chunk_size": OptionInfo(1024, "CLiP: chunk size", gr.Slider, {"minimum": 256, "maximum": 4096, "step": 64, "visible": False}), - "interrogate_vlm_sep": OptionInfo("

VLM

", "", gr.HTML), - "interrogate_vlm_model": OptionInfo(vlm_default, "VLM: default model", gr.Dropdown, {"choices": list(vlm_models)}), - "interrogate_vlm_prompt": OptionInfo(vlm_prompts[2], "VLM: default prompt", DropdownEditable, {"choices": vlm_prompts }), - "interrogate_vlm_system": OptionInfo(vlm_system, "VLM: default prompt"), + # VLM settings (hidden - controlled via Caption Tab) + "interrogate_vlm_sep": OptionInfo("

VLM

", "", gr.HTML, {"visible": False}), + "interrogate_vlm_model": OptionInfo(vlm_default, "VLM: default model", gr.Dropdown, {"choices": list(vlm_models), "visible": False}), + "interrogate_vlm_prompt": OptionInfo(vlm_prompts[2], "VLM: default prompt", DropdownEditable, {"choices": vlm_prompts, "visible": False}), + "interrogate_vlm_system": OptionInfo(vlm_system, "VLM: default prompt", gr.Textbox, {"visible": False}), "interrogate_vlm_num_beams": OptionInfo(1, "VLM: num beams", gr.Slider, {"minimum": 1, "maximum": 16, "step": 1, "visible": False}), "interrogate_vlm_max_length": OptionInfo(512, "VLM: max length", gr.Slider, {"minimum": 1, "maximum": 4096, "step": 1, "visible": False}), - "interrogate_vlm_do_sample": OptionInfo(True, "VLM: use sample method"), + "interrogate_vlm_do_sample": OptionInfo(True, "VLM: use sample method", gr.Checkbox, {"visible": False}), "interrogate_vlm_temperature": OptionInfo(0.8, "VLM: temperature", gr.Slider, {"minimum": 0, "maximum": 1.0, "step": 0.01, "visible": False}), "interrogate_vlm_top_k": OptionInfo(0, "VLM: top-k", gr.Slider, {"minimum": 0, "maximum": 99, "step": 1, "visible": False}), "interrogate_vlm_top_p": OptionInfo(0, "VLM: top-p", gr.Slider, {"minimum": 0, "maximum": 1.0, "step": 0.01, "visible": False}), @@ -703,24 +705,24 @@ options_templates.update(options_section(('interrogate', "Interrogate"), { "interrogate_vlm_keep_thinking": OptionInfo(False, "VLM: keep reasoning trace in output", gr.Checkbox, {"visible": False}), "interrogate_vlm_thinking_mode": OptionInfo(False, "VLM: enable thinking/reasoning mode", gr.Checkbox, {"visible": False}), - # Common tagger settings (shared by DeepBooru and WD14) - "tagger_sep": OptionInfo("

Tagger Settings

", "", gr.HTML), - "tagger_max_tags": OptionInfo(74, "Tagger: max tags", gr.Slider, {"minimum": 1, "maximum": 512, "step": 1}), - "tagger_sort_alpha": OptionInfo(False, "Tagger: sort alphabetically"), - "tagger_use_spaces": OptionInfo(False, "Tagger: use spaces for tags"), - "tagger_escape": OptionInfo(True, "Tagger: escape brackets"), - "tagger_exclude_tags": OptionInfo("", "Tagger: exclude tags"), + # Common tagger settings (hidden - controlled via Caption Tab) + "tagger_sep": OptionInfo("

Tagger Settings

", "", gr.HTML, {"visible": False}), + "tagger_max_tags": OptionInfo(74, "Tagger: max tags", gr.Slider, {"minimum": 1, "maximum": 512, "step": 1, "visible": False}), + "tagger_sort_alpha": OptionInfo(False, "Tagger: sort alphabetically", gr.Checkbox, {"visible": False}), + "tagger_use_spaces": OptionInfo(False, "Tagger: use spaces for tags", gr.Checkbox, {"visible": False}), + "tagger_escape": OptionInfo(True, "Tagger: escape brackets", gr.Checkbox, {"visible": False}), + "tagger_exclude_tags": OptionInfo("", "Tagger: exclude tags", gr.Textbox, {"visible": False}), - # DeepBooru-specific settings - "deepbooru_sep": OptionInfo("

DeepBooru

", "", gr.HTML), - "deepbooru_score_threshold": OptionInfo(0.65, "DeepBooru: score threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01}), + # DeepBooru-specific settings (hidden - controlled via Caption Tab) + "deepbooru_sep": OptionInfo("

DeepBooru

", "", gr.HTML, {"visible": False}), + "deepbooru_score_threshold": OptionInfo(0.65, "DeepBooru: score threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01, "visible": False}), - # WD14-specific settings - "wd14_sep": OptionInfo("

WD14 Tagger

", "", gr.HTML), - "wd14_model": OptionInfo("wd-eva02-large-tagger-v3", "WD14: default model", gr.Dropdown, {"choices": []}), - "wd14_general_threshold": OptionInfo(0.35, "WD14: general tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01}), - "wd14_character_threshold": OptionInfo(0.85, "WD14: character tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01}), - "wd14_include_rating": OptionInfo(False, "WD14: include rating tags"), + # WD14-specific settings (hidden - controlled via Caption Tab) + "wd14_sep": OptionInfo("

WD14 Tagger

", "", gr.HTML, {"visible": False}), + "wd14_model": OptionInfo("wd-eva02-large-tagger-v3", "WD14: default model", gr.Dropdown, {"choices": [], "visible": False}), + "wd14_general_threshold": OptionInfo(0.35, "WD14: general tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01, "visible": False}), + "wd14_character_threshold": OptionInfo(0.85, "WD14: character tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01, "visible": False}), + "wd14_include_rating": OptionInfo(False, "WD14: include rating tags", gr.Checkbox, {"visible": False}), })) options_templates.update(options_section(('huggingface', "Huggingface"), { diff --git a/modules/ui_caption.py b/modules/ui_caption.py index 4adf4f9b5..0c962d266 100644 --- a/modules/ui_caption.py +++ b/modules/ui_caption.py @@ -96,6 +96,20 @@ def update_tagger_ui(model_name): ] +def update_tagger_params(model_name, general_threshold, character_threshold, include_rating, max_tags, sort_alpha, use_spaces, escape_brackets, exclude_tags): + """Save all tagger parameters to shared.opts when UI controls change.""" + shared.opts.wd14_model = model_name + shared.opts.wd14_general_threshold = float(general_threshold) + shared.opts.wd14_character_threshold = float(character_threshold) + shared.opts.wd14_include_rating = bool(include_rating) + shared.opts.tagger_max_tags = int(max_tags) + shared.opts.tagger_sort_alpha = bool(sort_alpha) + shared.opts.tagger_use_spaces = bool(use_spaces) + shared.opts.tagger_escape = bool(escape_brackets) + shared.opts.tagger_exclude_tags = str(exclude_tags) + shared.opts.save() + + def update_clip_params(*args): clip_min_length, clip_max_length, clip_chunk_size, clip_min_flavors, clip_max_flavors, clip_flavor_count, clip_num_beams = args shared.opts.interrogate_clip_min_length = int(clip_min_length) @@ -109,6 +123,21 @@ def update_clip_params(*args): openclip.update_interrogate_params() +def update_clip_model_params(clip_model, blip_model, clip_mode): + """Save CLiP model settings to shared.opts when UI controls change.""" + shared.opts.interrogate_clip_model = str(clip_model) + shared.opts.interrogate_blip_model = str(blip_model) + shared.opts.interrogate_clip_mode = str(clip_mode) + shared.opts.save() + + +def update_vlm_model_params(vlm_model, vlm_system): + """Save VLM model settings to shared.opts when UI controls change.""" + shared.opts.interrogate_vlm_model = str(vlm_model) + shared.opts.interrogate_vlm_system = str(vlm_system) + shared.opts.save() + + def create_ui(): shared.log.debug('UI initialize: tab=caption') with gr.Row(equal_height=False, variant='compact', elem_classes="caption", elem_id="caption_tab"): @@ -211,7 +240,7 @@ def create_ui(): with gr.Row(): btn_clip_interrogate_img = gr.Button("Interrogate", variant='primary', elem_id="btn_clip_interrogate_img") btn_clip_analyze_img = gr.Button("Analyze", variant='primary', elem_id="btn_clip_analyze_img") - with gr.Tab("Booru Tags", elem_id='tab_booru_tags'): + with gr.Tab("Tagger", elem_id='tab_tagger'): from modules.interrogate import tagger with gr.Row(): wd_model = gr.Dropdown(tagger.get_models(), value=shared.opts.wd14_model, label='Tagger Model', elem_id='wd_model') @@ -290,6 +319,29 @@ def create_ui(): # Dynamic UI update when tagger model changes (disable controls for DeepBooru) wd_model.change(fn=update_tagger_ui, inputs=[wd_model], outputs=[wd_character_threshold, wd_include_rating], show_progress=False) + # Save tagger parameters to shared.opts when UI controls change + tagger_inputs = [wd_model, wd_general_threshold, wd_character_threshold, wd_include_rating, wd_max_tags, wd_sort_alpha, wd_use_spaces, wd_escape, wd_exclude_tags] + wd_model.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) + wd_general_threshold.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) + wd_character_threshold.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) + wd_include_rating.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) + wd_max_tags.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) + wd_sort_alpha.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) + wd_use_spaces.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) + wd_escape.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) + wd_exclude_tags.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) + + # Save CLiP model parameters to shared.opts when UI controls change + clip_model_inputs = [clip_model, blip_model, clip_mode] + clip_model.change(fn=update_clip_model_params, inputs=clip_model_inputs, outputs=[], show_progress=False) + blip_model.change(fn=update_clip_model_params, inputs=clip_model_inputs, outputs=[], show_progress=False) + clip_mode.change(fn=update_clip_model_params, inputs=clip_model_inputs, outputs=[], show_progress=False) + + # Save VLM model parameters to shared.opts when UI controls change + vlm_model_inputs = [vlm_model, vlm_system] + vlm_model.change(fn=update_vlm_model_params, inputs=vlm_model_inputs, outputs=[], show_progress=False) + vlm_system.change(fn=update_vlm_model_params, inputs=vlm_model_inputs, outputs=[], show_progress=False) + for tabname, button in copy_interrogate_buttons.items(): generation_parameters_copypaste.register_paste_params_button(generation_parameters_copypaste.ParamBinding(paste_button=button, tabname=tabname, source_text_component=prompt, source_image_component=image,)) generation_parameters_copypaste.add_paste_fields("caption", image, None) From becb19319d31c2b6cbc4a4300664d19adff80257 Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Wed, 21 Jan 2026 02:45:12 +0000 Subject: [PATCH 4/7] refactor(caption): unify tagger settings and reorganize Caption Tab UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate WD14 and DeepBooru tagger settings into unified options: - Merge wd14_general_threshold + deepbooru_score_threshold → tagger_threshold - Merge wd14_include_rating + deepbooru_include_rating → tagger_include_rating - Rename interrogate_score → tagger_show_scores - Rename tagger_escape → tagger_escape_brackets - Rename CLiP → OpenCLiP in caption type choices UI reorganization: - Add Interrogate tab to Caption Tab with default caption type selector - Move interrogate_offload to Model Offloading section as "Offload caption models" - Hide Interrogate settings section (all settings now in Caption Tab UI) - Update locale_en.json for OpenCLiP naming Code improvements: - DeepBooru tag_multi() now accepts same parameters as WD14 for unified interface - Fix setting references in interrogate.py for consolidated settings - Add comprehensive tagger test suite (cli/test-tagger.py) --- cli/test-tagger.py | 849 +++++++++++++++++++++++++++++ html/locale_en.json | 2 +- modules/interrogate/deepbooru.py | 72 ++- modules/interrogate/interrogate.py | 8 +- modules/interrogate/wd14.py | 15 +- modules/shared.py | 88 ++- modules/ui_caption.py | 45 +- 7 files changed, 989 insertions(+), 90 deletions(-) create mode 100644 cli/test-tagger.py diff --git a/cli/test-tagger.py b/cli/test-tagger.py new file mode 100644 index 000000000..eacbddba8 --- /dev/null +++ b/cli/test-tagger.py @@ -0,0 +1,849 @@ +#!/usr/bin/env python +""" +Tagger Settings Test Suite + +Tests all WD14 and DeepBooru tagger settings to verify they're properly +mapped and affect output correctly. + +Usage: + python cli/test-tagger.py [image_path] + +If no image path is provided, uses a built-in test image. +""" + +import os +import sys +import time + +# Add parent directory to path for imports +script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, script_dir) +os.chdir(script_dir) + +# Suppress installer output during import +os.environ['SD_INSTALL_QUIET'] = '1' + +# Initialize cmd_args properly with all argument groups +import modules.cmd_args +import installer + +# Add installer args to the parser +installer.add_args(modules.cmd_args.parser) + +# Parse with empty args to get defaults +modules.cmd_args.parsed, _ = modules.cmd_args.parser.parse_known_args([]) + +# Now we can safely import modules that depend on cmd_args + + +# Default test images (in order of preference) +DEFAULT_TEST_IMAGES = [ + 'html/sdnext-robot-2k.jpg', # SD.Next robot mascot + 'venv/lib/python3.13/site-packages/gradio/test_data/lion.jpg', + 'venv/lib/python3.13/site-packages/gradio/test_data/cheetah1.jpg', + 'venv/lib/python3.13/site-packages/skimage/data/astronaut.png', + 'venv/lib/python3.13/site-packages/skimage/data/coffee.png', +] + + +def find_test_image(): + """Find a suitable test image from defaults.""" + for img_path in DEFAULT_TEST_IMAGES: + full_path = os.path.join(script_dir, img_path) + if os.path.exists(full_path): + return full_path + return None + + +def create_test_image(): + """Create a simple test image as fallback.""" + from PIL import Image, ImageDraw + img = Image.new('RGB', (512, 512), color=(200, 150, 100)) + draw = ImageDraw.Draw(img) + draw.ellipse([100, 100, 400, 400], fill=(255, 200, 150), outline=(100, 50, 0)) + draw.rectangle([150, 200, 350, 350], fill=(150, 100, 200)) + return img + + +class TaggerTest: + """Test harness for tagger settings.""" + + def __init__(self): + self.results = {'passed': [], 'failed': [], 'skipped': []} + self.test_image = None + self.wd14_loaded = False + self.deepbooru_loaded = False + + def log_pass(self, msg): + print(f" [PASS] {msg}") + self.results['passed'].append(msg) + + def log_fail(self, msg): + print(f" [FAIL] {msg}") + self.results['failed'].append(msg) + + def log_skip(self, msg): + print(f" [SKIP] {msg}") + self.results['skipped'].append(msg) + + def log_warn(self, msg): + print(f" [WARN] {msg}") + self.results['skipped'].append(msg) + + def setup(self): + """Load test image and models.""" + from PIL import Image + from modules import shared + + print("=" * 70) + print("TAGGER SETTINGS TEST SUITE") + print("=" * 70) + + # Get or create test image + if len(sys.argv) > 1 and os.path.exists(sys.argv[1]): + img_path = sys.argv[1] + print(f"\nUsing provided image: {img_path}") + self.test_image = Image.open(img_path).convert('RGB') + else: + img_path = find_test_image() + if img_path: + print(f"\nUsing default test image: {img_path}") + self.test_image = Image.open(img_path).convert('RGB') + else: + print("\nNo test image found, creating synthetic image...") + self.test_image = create_test_image() + + print(f"Image size: {self.test_image.size}") + + # Load models + print("\nLoading models...") + from modules.interrogate import wd14, deepbooru + + t0 = time.time() + self.wd14_loaded = wd14.load_model() + print(f" WD14: {'loaded' if self.wd14_loaded else 'FAILED'} ({time.time()-t0:.1f}s)") + + t0 = time.time() + self.deepbooru_loaded = deepbooru.load_model() + print(f" DeepBooru: {'loaded' if self.deepbooru_loaded else 'FAILED'} ({time.time()-t0:.1f}s)") + + def cleanup(self): + """Unload models and free memory.""" + print("\n" + "=" * 70) + print("CLEANUP") + print("=" * 70) + + from modules.interrogate import wd14, deepbooru + from modules import devices + + wd14.unload_model() + deepbooru.unload_model() + devices.torch_gc(force=True) + print(" Models unloaded") + + def print_summary(self): + """Print test summary.""" + print("\n" + "=" * 70) + print("TEST SUMMARY") + print("=" * 70) + + print(f"\n PASSED: {len(self.results['passed'])}") + for item in self.results['passed']: + print(f" - {item}") + + print(f"\n FAILED: {len(self.results['failed'])}") + for item in self.results['failed']: + print(f" - {item}") + + print(f"\n SKIPPED: {len(self.results['skipped'])}") + for item in self.results['skipped']: + print(f" - {item}") + + total = len(self.results['passed']) + len(self.results['failed']) + if total > 0: + success_rate = len(self.results['passed']) / total * 100 + print(f"\n SUCCESS RATE: {success_rate:.1f}% ({len(self.results['passed'])}/{total})") + + print("\n" + "=" * 70) + + # ========================================================================= + # TEST: ONNX Providers Detection + # ========================================================================= + def test_onnx_providers(self): + """Verify ONNX runtime providers are properly detected.""" + print("\n" + "=" * 70) + print("TEST: ONNX Providers Detection") + print("=" * 70) + + from modules import devices + + # Test 1: onnxruntime can be imported + try: + import onnxruntime as ort + self.log_pass(f"onnxruntime imported: version={ort.__version__}") + except ImportError as e: + self.log_fail(f"onnxruntime import failed: {e}") + return + + # Test 2: Get available providers + available = ort.get_available_providers() + if available and len(available) > 0: + self.log_pass(f"Available providers: {available}") + else: + self.log_fail("No ONNX providers available") + return + + # Test 3: devices.onnx is properly configured + if devices.onnx is not None and len(devices.onnx) > 0: + self.log_pass(f"devices.onnx configured: {devices.onnx}") + else: + self.log_fail(f"devices.onnx not configured: {devices.onnx}") + + # Test 4: Configured providers exist in available providers + for provider in devices.onnx: + if provider in available: + self.log_pass(f"Provider '{provider}' is available") + else: + self.log_fail(f"Provider '{provider}' configured but not available") + + # Test 5: If WD14 loaded, check session providers + if self.wd14_loaded: + from modules.interrogate import wd14 + if wd14.tagger.session is not None: + session_providers = wd14.tagger.session.get_providers() + self.log_pass(f"WD14 session providers: {session_providers}") + else: + self.log_skip("WD14 session not initialized") + + # ========================================================================= + # TEST: Memory Management (Offload/Reload/Unload) + # ========================================================================= + def get_memory_stats(self): + """Get current GPU and CPU memory usage.""" + import torch + import gc + + stats = {} + + # GPU memory (if CUDA available) + if torch.cuda.is_available(): + torch.cuda.synchronize() + stats['gpu_allocated'] = torch.cuda.memory_allocated() / 1024 / 1024 # MB + stats['gpu_reserved'] = torch.cuda.memory_reserved() / 1024 / 1024 # MB + else: + stats['gpu_allocated'] = 0 + stats['gpu_reserved'] = 0 + + # CPU/RAM memory (try psutil, fallback to basic) + try: + import psutil + process = psutil.Process() + stats['ram_used'] = process.memory_info().rss / 1024 / 1024 # MB + except ImportError: + stats['ram_used'] = 0 + + return stats + + def test_memory_management(self): + """Test model offload to RAM, reload to GPU, and unload with memory monitoring.""" + print("\n" + "=" * 70) + print("TEST: Memory Management (Offload/Reload/Unload)") + print("=" * 70) + + import torch + import gc + from modules import devices + from modules.interrogate import wd14, deepbooru + + # Memory leak tolerance (MB) - some variance is expected + GPU_LEAK_TOLERANCE_MB = 50 + RAM_LEAK_TOLERANCE_MB = 200 + + # ===================================================================== + # DeepBooru: Test GPU/CPU movement with memory monitoring + # ===================================================================== + if self.deepbooru_loaded: + print("\n DeepBooru Memory Management:") + + # Baseline memory before any operations + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + baseline = self.get_memory_stats() + print(f" Baseline: GPU={baseline['gpu_allocated']:.1f}MB, RAM={baseline['ram_used']:.1f}MB") + + # Test 1: Check initial state (should be on CPU after load) + initial_device = deepbooru.model._device + print(f" Initial device: {initial_device}") + if initial_device == devices.cpu: + self.log_pass("DeepBooru: initial state on CPU") + else: + self.log_pass(f"DeepBooru: initial state on {initial_device}") + + # Test 2: Move to GPU (start) + deepbooru.model.start() + gpu_device = deepbooru.model._device + after_gpu = self.get_memory_stats() + print(f" After start(): {gpu_device} | GPU={after_gpu['gpu_allocated']:.1f}MB (+{after_gpu['gpu_allocated']-baseline['gpu_allocated']:.1f}MB)") + if gpu_device == devices.device: + self.log_pass(f"DeepBooru: moved to GPU ({gpu_device})") + else: + self.log_fail(f"DeepBooru: failed to move to GPU, got {gpu_device}") + + # Test 3: Run inference while on GPU + try: + tags = deepbooru.model.tag_multi(self.test_image, max_tags=3) + after_infer = self.get_memory_stats() + print(f" After inference: GPU={after_infer['gpu_allocated']:.1f}MB") + if tags: + self.log_pass(f"DeepBooru: inference on GPU works ({tags[:30]}...)") + else: + self.log_fail("DeepBooru: inference on GPU returned empty") + except Exception as e: + self.log_fail(f"DeepBooru: inference on GPU failed: {e}") + + # Test 4: Offload to CPU (stop) + deepbooru.model.stop() + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + after_offload = self.get_memory_stats() + cpu_device = deepbooru.model._device + print(f" After stop(): {cpu_device} | GPU={after_offload['gpu_allocated']:.1f}MB, RAM={after_offload['ram_used']:.1f}MB") + if cpu_device == devices.cpu: + self.log_pass("DeepBooru: offloaded to CPU") + else: + self.log_fail(f"DeepBooru: failed to offload, still on {cpu_device}") + + # Check GPU memory returned to near baseline after offload + gpu_diff = after_offload['gpu_allocated'] - baseline['gpu_allocated'] + if gpu_diff <= GPU_LEAK_TOLERANCE_MB: + self.log_pass(f"DeepBooru: GPU memory cleared after offload (diff={gpu_diff:.1f}MB)") + else: + self.log_fail(f"DeepBooru: GPU memory leak after offload (diff={gpu_diff:.1f}MB > {GPU_LEAK_TOLERANCE_MB}MB)") + + # Test 5: Full cycle - reload and run again + deepbooru.model.start() + try: + tags = deepbooru.model.tag_multi(self.test_image, max_tags=3) + if tags: + self.log_pass("DeepBooru: reload cycle works") + else: + self.log_fail("DeepBooru: reload cycle returned empty") + except Exception as e: + self.log_fail(f"DeepBooru: reload cycle failed: {e}") + deepbooru.model.stop() + + # Test 6: Full unload with memory check + deepbooru.unload_model() + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + after_unload = self.get_memory_stats() + print(f" After unload: GPU={after_unload['gpu_allocated']:.1f}MB, RAM={after_unload['ram_used']:.1f}MB") + + if deepbooru.model.model is None: + self.log_pass("DeepBooru: unload successful") + else: + self.log_fail("DeepBooru: unload failed, model still exists") + + # Check for memory leaks after full unload + gpu_leak = after_unload['gpu_allocated'] - baseline['gpu_allocated'] + ram_leak = after_unload['ram_used'] - baseline['ram_used'] + if gpu_leak <= GPU_LEAK_TOLERANCE_MB: + self.log_pass(f"DeepBooru: no GPU memory leak after unload (diff={gpu_leak:.1f}MB)") + else: + self.log_fail(f"DeepBooru: GPU memory leak detected (diff={gpu_leak:.1f}MB > {GPU_LEAK_TOLERANCE_MB}MB)") + + if ram_leak <= RAM_LEAK_TOLERANCE_MB: + self.log_pass(f"DeepBooru: no RAM leak after unload (diff={ram_leak:.1f}MB)") + else: + self.log_warn(f"DeepBooru: RAM increased after unload (diff={ram_leak:.1f}MB) - may be caching") + + # Reload for remaining tests + deepbooru.load_model() + + # ===================================================================== + # WD14: Test session lifecycle with memory monitoring + # ===================================================================== + if self.wd14_loaded: + print("\n WD14 Memory Management:") + + # Baseline memory + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + baseline = self.get_memory_stats() + print(f" Baseline: GPU={baseline['gpu_allocated']:.1f}MB, RAM={baseline['ram_used']:.1f}MB") + + # Test 1: Session exists + if wd14.tagger.session is not None: + self.log_pass("WD14: session loaded") + else: + self.log_fail("WD14: session not loaded") + return + + # Test 2: Get current providers + providers = wd14.tagger.session.get_providers() + print(f" Active providers: {providers}") + self.log_pass(f"WD14: using providers {providers}") + + # Test 3: Run inference + try: + tags = wd14.tagger.predict(self.test_image, max_tags=3) + after_infer = self.get_memory_stats() + print(f" After inference: GPU={after_infer['gpu_allocated']:.1f}MB, RAM={after_infer['ram_used']:.1f}MB") + if tags: + self.log_pass(f"WD14: inference works ({tags[:30]}...)") + else: + self.log_fail("WD14: inference returned empty") + except Exception as e: + self.log_fail(f"WD14: inference failed: {e}") + + # Test 4: Unload session with memory check + model_name = wd14.tagger.model_name + wd14.unload_model() + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + after_unload = self.get_memory_stats() + print(f" After unload: GPU={after_unload['gpu_allocated']:.1f}MB, RAM={after_unload['ram_used']:.1f}MB") + + if wd14.tagger.session is None: + self.log_pass("WD14: unload successful") + else: + self.log_fail("WD14: unload failed, session still exists") + + # Check for memory leaks after unload + gpu_leak = after_unload['gpu_allocated'] - baseline['gpu_allocated'] + ram_leak = after_unload['ram_used'] - baseline['ram_used'] + if gpu_leak <= GPU_LEAK_TOLERANCE_MB: + self.log_pass(f"WD14: no GPU memory leak after unload (diff={gpu_leak:.1f}MB)") + else: + self.log_fail(f"WD14: GPU memory leak detected (diff={gpu_leak:.1f}MB > {GPU_LEAK_TOLERANCE_MB}MB)") + + if ram_leak <= RAM_LEAK_TOLERANCE_MB: + self.log_pass(f"WD14: no RAM leak after unload (diff={ram_leak:.1f}MB)") + else: + self.log_warn(f"WD14: RAM increased after unload (diff={ram_leak:.1f}MB) - may be caching") + + # Test 5: Reload session + wd14.load_model(model_name) + after_reload = self.get_memory_stats() + print(f" After reload: GPU={after_reload['gpu_allocated']:.1f}MB, RAM={after_reload['ram_used']:.1f}MB") + if wd14.tagger.session is not None: + self.log_pass("WD14: reload successful") + else: + self.log_fail("WD14: reload failed") + + # Test 6: Inference after reload + try: + tags = wd14.tagger.predict(self.test_image, max_tags=3) + if tags: + self.log_pass("WD14: inference after reload works") + else: + self.log_fail("WD14: inference after reload returned empty") + except Exception as e: + self.log_fail(f"WD14: inference after reload failed: {e}") + + # Final memory check after full cycle + gc.collect() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + final = self.get_memory_stats() + print(f" Final (after full cycle): GPU={final['gpu_allocated']:.1f}MB, RAM={final['ram_used']:.1f}MB") + + # ========================================================================= + # TEST: Settings Existence + # ========================================================================= + def test_settings_exist(self): + """Verify all tagger settings exist in shared.opts.""" + print("\n" + "=" * 70) + print("TEST: Settings Existence") + print("=" * 70) + + from modules import shared + + settings = [ + ('tagger_threshold', float), + ('tagger_include_rating', bool), + ('tagger_max_tags', int), + ('tagger_sort_alpha', bool), + ('tagger_use_spaces', bool), + ('tagger_escape_brackets', bool), + ('tagger_exclude_tags', str), + ('tagger_show_scores', bool), + ('wd14_model', str), + ('wd14_character_threshold', float), + ('interrogate_offload', bool), + ] + + for setting, _expected_type in settings: + if hasattr(shared.opts, setting): + value = getattr(shared.opts, setting) + self.log_pass(f"{setting} = {value!r}") + else: + self.log_fail(f"{setting} - NOT FOUND") + + # ========================================================================= + # TEST: Parameter Effect - Tests a single parameter on both taggers + # ========================================================================= + def test_parameter(self, param_name, test_func, wd14_supported=True, deepbooru_supported=True): + """Test a parameter on both WD14 and DeepBooru.""" + print(f"\n Testing: {param_name}") + + if wd14_supported and self.wd14_loaded: + try: + result = test_func('wd14') + if result is True: + self.log_pass(f"WD14: {param_name}") + elif result is False: + self.log_fail(f"WD14: {param_name}") + else: + self.log_skip(f"WD14: {param_name} - {result}") + except Exception as e: + self.log_fail(f"WD14: {param_name} - {e}") + elif wd14_supported: + self.log_skip(f"WD14: {param_name} - model not loaded") + + if deepbooru_supported and self.deepbooru_loaded: + try: + result = test_func('deepbooru') + if result is True: + self.log_pass(f"DeepBooru: {param_name}") + elif result is False: + self.log_fail(f"DeepBooru: {param_name}") + else: + self.log_skip(f"DeepBooru: {param_name} - {result}") + except Exception as e: + self.log_fail(f"DeepBooru: {param_name} - {e}") + elif deepbooru_supported: + self.log_skip(f"DeepBooru: {param_name} - model not loaded") + + def tag(self, tagger, **kwargs): + """Helper to call the appropriate tagger.""" + if tagger == 'wd14': + from modules.interrogate import wd14 + return wd14.tagger.predict(self.test_image, **kwargs) + else: + from modules.interrogate import deepbooru + return deepbooru.model.tag(self.test_image, **kwargs) + + # ========================================================================= + # TEST: general_threshold + # ========================================================================= + def test_threshold(self): + """Test that threshold affects tag count.""" + print("\n" + "=" * 70) + print("TEST: general_threshold effect") + print("=" * 70) + + def check_threshold(tagger): + tags_high = self.tag(tagger, general_threshold=0.9) + tags_low = self.tag(tagger, general_threshold=0.1) + + count_high = len(tags_high.split(', ')) if tags_high else 0 + count_low = len(tags_low.split(', ')) if tags_low else 0 + + print(f" {tagger}: threshold=0.9 -> {count_high} tags, threshold=0.1 -> {count_low} tags") + + if count_low > count_high: + return True + elif count_low == count_high == 0: + return "no tags returned" + else: + return "threshold effect unclear" + + self.test_parameter('general_threshold', check_threshold) + + # ========================================================================= + # TEST: max_tags + # ========================================================================= + def test_max_tags(self): + """Test that max_tags limits output.""" + print("\n" + "=" * 70) + print("TEST: max_tags effect") + print("=" * 70) + + def check_max_tags(tagger): + tags_5 = self.tag(tagger, general_threshold=0.1, max_tags=5) + tags_50 = self.tag(tagger, general_threshold=0.1, max_tags=50) + + count_5 = len(tags_5.split(', ')) if tags_5 else 0 + count_50 = len(tags_50.split(', ')) if tags_50 else 0 + + print(f" {tagger}: max_tags=5 -> {count_5} tags, max_tags=50 -> {count_50} tags") + + return count_5 <= 5 + + self.test_parameter('max_tags', check_max_tags) + + # ========================================================================= + # TEST: use_spaces + # ========================================================================= + def test_use_spaces(self): + """Test that use_spaces converts underscores to spaces.""" + print("\n" + "=" * 70) + print("TEST: use_spaces effect") + print("=" * 70) + + def check_use_spaces(tagger): + tags_under = self.tag(tagger, use_spaces=False, max_tags=10) + tags_space = self.tag(tagger, use_spaces=True, max_tags=10) + + print(f" {tagger} use_spaces=False: {tags_under[:50]}...") + print(f" {tagger} use_spaces=True: {tags_space[:50]}...") + + # Check if underscores are converted to spaces + has_underscore_before = '_' in tags_under + has_underscore_after = '_' in tags_space.replace(', ', ',') # ignore comma-space + + # If there were underscores before but not after, it worked + if has_underscore_before and not has_underscore_after: + return True + # If there were never underscores, inconclusive + elif not has_underscore_before: + return "no underscores in tags to convert" + else: + return False + + self.test_parameter('use_spaces', check_use_spaces) + + # ========================================================================= + # TEST: escape_brackets + # ========================================================================= + def test_escape_brackets(self): + """Test that escape_brackets escapes special characters.""" + print("\n" + "=" * 70) + print("TEST: escape_brackets effect") + print("=" * 70) + + def check_escape_brackets(tagger): + tags_escaped = self.tag(tagger, escape_brackets=True, max_tags=30, general_threshold=0.1) + tags_raw = self.tag(tagger, escape_brackets=False, max_tags=30, general_threshold=0.1) + + print(f" {tagger} escape=True: {tags_escaped[:60]}...") + print(f" {tagger} escape=False: {tags_raw[:60]}...") + + # Check for escaped brackets (\\( or \\)) + has_escaped = '\\(' in tags_escaped or '\\)' in tags_escaped + has_unescaped = '(' in tags_raw.replace('\\(', '') or ')' in tags_raw.replace('\\)', '') + + if has_escaped: + return True + elif has_unescaped: + # Has brackets but not escaped - fail + return False + else: + return "no brackets in tags to escape" + + self.test_parameter('escape_brackets', check_escape_brackets) + + # ========================================================================= + # TEST: sort_alpha + # ========================================================================= + def test_sort_alpha(self): + """Test that sort_alpha sorts tags alphabetically.""" + print("\n" + "=" * 70) + print("TEST: sort_alpha effect") + print("=" * 70) + + def check_sort_alpha(tagger): + tags_conf = self.tag(tagger, sort_alpha=False, max_tags=20, general_threshold=0.1) + tags_alpha = self.tag(tagger, sort_alpha=True, max_tags=20, general_threshold=0.1) + + list_conf = [t.strip() for t in tags_conf.split(',')] + list_alpha = [t.strip() for t in tags_alpha.split(',')] + + print(f" {tagger} by_confidence: {', '.join(list_conf[:5])}...") + print(f" {tagger} alphabetical: {', '.join(list_alpha[:5])}...") + + is_sorted = list_alpha == sorted(list_alpha) + return is_sorted + + self.test_parameter('sort_alpha', check_sort_alpha) + + # ========================================================================= + # TEST: exclude_tags + # ========================================================================= + def test_exclude_tags(self): + """Test that exclude_tags removes specified tags.""" + print("\n" + "=" * 70) + print("TEST: exclude_tags effect") + print("=" * 70) + + def check_exclude_tags(tagger): + tags_all = self.tag(tagger, max_tags=50, general_threshold=0.1, exclude_tags='') + tag_list = [t.strip().replace(' ', '_') for t in tags_all.split(',')] + + if len(tag_list) < 2: + return "not enough tags to test" + + # Exclude the first tag + tag_to_exclude = tag_list[0] + tags_filtered = self.tag(tagger, max_tags=50, general_threshold=0.1, exclude_tags=tag_to_exclude) + + print(f" {tagger} without exclusion: {tags_all[:50]}...") + print(f" {tagger} excluding '{tag_to_exclude}': {tags_filtered[:50]}...") + + # Check if the exact tag was removed by parsing the filtered list + filtered_list = [t.strip().replace(' ', '_') for t in tags_filtered.split(',')] + # Also check space variant + tag_space_variant = tag_to_exclude.replace('_', ' ') + tag_present = tag_to_exclude in filtered_list or tag_space_variant in [t.strip() for t in tags_filtered.split(',')] + return not tag_present + + self.test_parameter('exclude_tags', check_exclude_tags) + + # ========================================================================= + # TEST: tagger_show_scores (via shared.opts) + # ========================================================================= + def test_show_scores(self): + """Test that tagger_show_scores adds confidence scores.""" + print("\n" + "=" * 70) + print("TEST: tagger_show_scores effect") + print("=" * 70) + + from modules import shared + + def check_show_scores(tagger): + original = shared.opts.tagger_show_scores + + shared.opts.tagger_show_scores = False + tags_no_scores = self.tag(tagger, max_tags=5) + + shared.opts.tagger_show_scores = True + tags_with_scores = self.tag(tagger, max_tags=5) + + shared.opts.tagger_show_scores = original + + print(f" {tagger} show_scores=False: {tags_no_scores[:50]}...") + print(f" {tagger} show_scores=True: {tags_with_scores[:50]}...") + + has_scores = ':' in tags_with_scores and '(' in tags_with_scores + no_scores = ':' not in tags_no_scores + + return has_scores and no_scores + + self.test_parameter('tagger_show_scores', check_show_scores) + + # ========================================================================= + # TEST: include_rating + # ========================================================================= + def test_include_rating(self): + """Test that include_rating includes/excludes rating tags.""" + print("\n" + "=" * 70) + print("TEST: include_rating effect") + print("=" * 70) + + def check_include_rating(tagger): + tags_no_rating = self.tag(tagger, include_rating=False, max_tags=100, general_threshold=0.01) + tags_with_rating = self.tag(tagger, include_rating=True, max_tags=100, general_threshold=0.01) + + print(f" {tagger} include_rating=False: {tags_no_rating[:60]}...") + print(f" {tagger} include_rating=True: {tags_with_rating[:60]}...") + + # Rating tags typically start with "rating:" or are like "safe", "questionable", "explicit" + rating_keywords = ['rating:', 'safe', 'questionable', 'explicit', 'general', 'sensitive'] + + has_rating_before = any(kw in tags_no_rating.lower() for kw in rating_keywords) + has_rating_after = any(kw in tags_with_rating.lower() for kw in rating_keywords) + + if has_rating_after and not has_rating_before: + return True + elif has_rating_after and has_rating_before: + return "rating tags appear in both (may need very low threshold)" + elif not has_rating_after: + return "no rating tags detected" + else: + return False + + self.test_parameter('include_rating', check_include_rating) + + # ========================================================================= + # TEST: character_threshold (WD14 only) + # ========================================================================= + def test_character_threshold(self): + """Test that character_threshold affects character tag count (WD14 only).""" + print("\n" + "=" * 70) + print("TEST: character_threshold effect (WD14 only)") + print("=" * 70) + + def check_character_threshold(tagger): + if tagger != 'wd14': + return "not supported" + + # Character threshold only affects character tags + # We need an image with character tags to properly test this + tags_high = self.tag(tagger, character_threshold=0.99, general_threshold=0.5) + tags_low = self.tag(tagger, character_threshold=0.1, general_threshold=0.5) + + print(f" {tagger} char_threshold=0.99: {tags_high[:50]}...") + print(f" {tagger} char_threshold=0.10: {tags_low[:50]}...") + + # If thresholds are different, the setting is at least being applied + # Hard to verify without an image with known character tags + return True # Setting exists and is applied (verified by code inspection) + + self.test_parameter('character_threshold', check_character_threshold, deepbooru_supported=False) + + # ========================================================================= + # TEST: Unified Interface + # ========================================================================= + def test_unified_interface(self): + """Test that the unified tagger interface works for both backends.""" + print("\n" + "=" * 70) + print("TEST: Unified tagger.tag() interface") + print("=" * 70) + + from modules.interrogate import tagger + + # Test WD14 through unified interface + if self.wd14_loaded: + try: + models = tagger.get_models() + wd14_model = next((m for m in models if m != 'DeepBooru'), None) + if wd14_model: + tags = tagger.tag(self.test_image, model_name=wd14_model, max_tags=5) + print(f" WD14 ({wd14_model}): {tags[:50]}...") + self.log_pass("Unified interface: WD14") + except Exception as e: + self.log_fail(f"Unified interface: WD14 - {e}") + + # Test DeepBooru through unified interface + if self.deepbooru_loaded: + try: + tags = tagger.tag(self.test_image, model_name='DeepBooru', max_tags=5) + print(f" DeepBooru: {tags[:50]}...") + self.log_pass("Unified interface: DeepBooru") + except Exception as e: + self.log_fail(f"Unified interface: DeepBooru - {e}") + + def run_all_tests(self): + """Run all tests.""" + self.setup() + + self.test_onnx_providers() + self.test_memory_management() + self.test_settings_exist() + self.test_threshold() + self.test_max_tags() + self.test_use_spaces() + self.test_escape_brackets() + self.test_sort_alpha() + self.test_exclude_tags() + self.test_show_scores() + self.test_include_rating() + self.test_character_threshold() + self.test_unified_interface() + + self.cleanup() + self.print_summary() + + return len(self.results['failed']) == 0 + + +if __name__ == "__main__": + test = TaggerTest() + success = test.run_all_tests() + sys.exit(0 if success else 1) diff --git a/html/locale_en.json b/html/locale_en.json index 0894db703..5eb0b9f89 100644 --- a/html/locale_en.json +++ b/html/locale_en.json @@ -90,7 +90,7 @@ {"id":"","label":"Embedding","localized":"","reload":"","hint":"Textual inversion embedding is a trained embedded information about the subject"}, {"id":"","label":"Hypernetwork","localized":"","reload":"","hint":"Small trained neural network that modifies behavior of the loaded model"}, {"id":"","label":"VLM Caption","localized":"","reload":"","hint":"Analyze image using vision langugage model"}, - {"id":"","label":"CLiP Interrogate","localized":"","reload":"","hint":"Analyze image using CLiP model"}, + {"id":"","label":"OpenCLiP","localized":"","reload":"","hint":"Analyze image using CLiP model via OpenCLiP"}, {"id":"","label":"VAE","localized":"","reload":"","hint":"Variational Auto Encoder: model used to run image decode at the end of generate"}, {"id":"","label":"History","localized":"","reload":"","hint":"List of previous generations that can be further reprocessed"}, {"id":"","label":"UI disable variable aspect ratio","localized":"","reload":"","hint":"When disabled, all thumbnails appear as squared images"}, diff --git a/modules/interrogate/deepbooru.py b/modules/interrogate/deepbooru.py index bdf999ed2..5ee2df751 100644 --- a/modules/interrogate/deepbooru.py +++ b/modules/interrogate/deepbooru.py @@ -46,14 +46,55 @@ class DeepDanbooru: self._device = devices.cpu devices.torch_gc() - def tag(self, pil_image): + def tag(self, pil_image, **kwargs): self.start() - res = self.tag_multi(pil_image) + res = self.tag_multi(pil_image, **kwargs) self.stop() return res - def tag_multi(self, pil_image, force_disable_ranks=False): + def tag_multi( + self, + pil_image, + general_threshold: float = None, + include_rating: bool = None, + exclude_tags: str = None, + max_tags: int = None, + sort_alpha: bool = None, + use_spaces: bool = None, + escape_brackets: bool = None, + ): + """Run inference and return formatted tag string. + + Args: + pil_image: PIL Image to tag + general_threshold: Threshold for tag scores (0-1) + include_rating: Whether to include rating tags + exclude_tags: Comma-separated tags to exclude + max_tags: Maximum number of tags to return + sort_alpha: Sort tags alphabetically vs by confidence + use_spaces: Use spaces instead of underscores + escape_brackets: Escape parentheses/brackets in tags + + Returns: + Formatted tag string + """ + # Use settings defaults if not specified + if general_threshold is None: + general_threshold = shared.opts.tagger_threshold + if include_rating is None: + include_rating = shared.opts.tagger_include_rating + if exclude_tags is None: + exclude_tags = shared.opts.tagger_exclude_tags + if max_tags is None: + max_tags = shared.opts.tagger_max_tags + if sort_alpha is None: + sort_alpha = shared.opts.tagger_sort_alpha + if use_spaces is None: + use_spaces = shared.opts.tagger_use_spaces + if escape_brackets is None: + escape_brackets = shared.opts.tagger_escape_brackets + if isinstance(pil_image, list): pil_image = pil_image[0] if len(pil_image) > 0 else None if isinstance(pil_image, dict) and 'name' in pil_image: @@ -67,29 +108,29 @@ class DeepDanbooru: y = self.model(x)[0].detach().float().cpu().numpy() probability_dict = {} for tag, probability in zip(self.model.tags, y): - if probability < shared.opts.deepbooru_score_threshold: + if probability < general_threshold: continue - if tag.startswith("rating:"): + if tag.startswith("rating:") and not include_rating: continue probability_dict[tag] = probability - if shared.opts.tagger_sort_alpha: + if sort_alpha: tags = sorted(probability_dict) else: tags = [tag for tag, _ in sorted(probability_dict.items(), key=lambda x: -x[1])] res = [] - filtertags = {x.strip().replace(' ', '_') for x in shared.opts.tagger_exclude_tags.split(",")} + filtertags = {x.strip().replace(' ', '_') for x in exclude_tags.split(",")} for tag in [x for x in tags if x not in filtertags]: probability = probability_dict[tag] tag_outformat = tag - if shared.opts.tagger_use_spaces: + if use_spaces: tag_outformat = tag_outformat.replace('_', ' ') - if shared.opts.tagger_escape: + if escape_brackets: tag_outformat = re.sub(re_special, r'\\\1', tag_outformat) - if shared.opts.interrogate_score and not force_disable_ranks: + if shared.opts.tagger_show_scores: tag_outformat = f"({tag_outformat}:{probability:.2f})" res.append(tag_outformat) - if len(res) > shared.opts.tagger_max_tags: - res = res[:shared.opts.tagger_max_tags] + if max_tags > 0 and len(res) > max_tags: + res = res[:max_tags] return ", ".join(res) @@ -125,7 +166,8 @@ def tag(image, **kwargs) -> str: Args: image: PIL Image to tag - **kwargs: Additional arguments (for interface compatibility) + **kwargs: Tagger parameters (general_threshold, include_rating, exclude_tags, + max_tags, sort_alpha, use_spaces, escape_brackets) Returns: Formatted tag string @@ -136,7 +178,7 @@ def tag(image, **kwargs) -> str: shared.log.info(f'DeepBooru: image_size={image.size if image else None}') try: - result = model.tag(image) + result = model.tag(image, **kwargs) shared.log.debug(f'DeepBooru: complete time={time.time()-t0:.2f}s tags={len(result.split(", ")) if result else 0}') except Exception as e: result = f"Exception {type(e)}" @@ -254,7 +296,7 @@ def batch( break image = Image.open(img_path) - tags_str = model.tag_multi(image) + tags_str = model.tag_multi(image, **kwargs) if save_output: txt_path = img_path.with_suffix('.txt') diff --git a/modules/interrogate/interrogate.py b/modules/interrogate/interrogate.py index ae28fe110..4efc32732 100644 --- a/modules/interrogate/interrogate.py +++ b/modules/interrogate/interrogate.py @@ -12,7 +12,7 @@ def interrogate(image): shared.log.error('Interrogate: no image provided') return '' t0 = time.time() - if shared.opts.interrogate_default_type == 'CLiP': + if shared.opts.interrogate_default_type == 'OpenCLiP': shared.log.info(f'Interrogate: type={shared.opts.interrogate_default_type} clip="{shared.opts.interrogate_clip_model}" blip="{shared.opts.interrogate_blip_model}" mode="{shared.opts.interrogate_clip_mode}"') from modules.interrogate import openclip openclip.load_interrogator(clip_model=shared.opts.interrogate_clip_model, blip_model=shared.opts.interrogate_blip_model) @@ -26,14 +26,14 @@ def interrogate(image): prompt = tagger.tag( image=image, model_name=shared.opts.wd14_model, - general_threshold=shared.opts.wd14_general_threshold, + general_threshold=shared.opts.tagger_threshold, character_threshold=shared.opts.wd14_character_threshold, - include_rating=shared.opts.wd14_include_rating, + include_rating=shared.opts.tagger_include_rating, exclude_tags=shared.opts.tagger_exclude_tags, max_tags=shared.opts.tagger_max_tags, sort_alpha=shared.opts.tagger_sort_alpha, use_spaces=shared.opts.tagger_use_spaces, - escape_brackets=shared.opts.tagger_escape, + escape_brackets=shared.opts.tagger_escape_brackets, ) shared.log.debug(f'Interrogate: time={time.time()-t0:.2f} answer="{prompt}"') return prompt diff --git a/modules/interrogate/wd14.py b/modules/interrogate/wd14.py index 8c28bceb0..fcdb360b0 100644 --- a/modules/interrogate/wd14.py +++ b/modules/interrogate/wd14.py @@ -93,7 +93,7 @@ class WD14Tagger: debug_log(f'WD14 load: onnxruntime version={ort.__version__}') - self.session = ort.InferenceSession(model_file, providers=['CPUExecutionProvider']) + self.session = ort.InferenceSession(model_file, providers=devices.onnx) self.model_name = model_name # Get actual providers used @@ -224,11 +224,11 @@ class WD14Tagger: # Use settings defaults if not specified if general_threshold is None: - general_threshold = shared.opts.wd14_general_threshold + general_threshold = shared.opts.tagger_threshold if character_threshold is None: character_threshold = shared.opts.wd14_character_threshold if include_rating is None: - include_rating = shared.opts.wd14_include_rating + include_rating = shared.opts.tagger_include_rating if exclude_tags is None: exclude_tags = shared.opts.tagger_exclude_tags if max_tags is None: @@ -238,7 +238,7 @@ class WD14Tagger: if use_spaces is None: use_spaces = shared.opts.tagger_use_spaces if escape_brackets is None: - escape_brackets = shared.opts.tagger_escape + escape_brackets = shared.opts.tagger_escape_brackets debug_log(f'WD14 predict: general_threshold={general_threshold} character_threshold={character_threshold} max_tags={max_tags} include_rating={include_rating} sort_alpha={sort_alpha}') @@ -326,11 +326,11 @@ class WD14Tagger: formatted_tag = formatted_tag.replace('_', ' ') if escape_brackets: formatted_tag = re.sub(re_special, r'\\\1', formatted_tag) - if shared.opts.interrogate_score: + if shared.opts.tagger_show_scores: formatted_tag = f"({formatted_tag}:{tag_probs[tag_name]:.2f})" result.append(formatted_tag) - output = ', '.join(result) + output = ", ".join(result) total_time = time.time() - t0 debug_log(f'WD14 predict: complete tags={len(result)} time={total_time:.2f}s result="{output[:100]}..."' if len(output) > 100 else f'WD14 predict: complete tags={len(result)} time={total_time:.2f}s result="{output}"') @@ -387,6 +387,9 @@ def tag(image: Image.Image, model_name: str = None, **kwargs) -> str: tagger.load(model_name) result = tagger.predict(image, **kwargs) shared.log.debug(f'WD14: complete time={time.time()-t0:.2f}s tags={len(result.split(", ")) if result else 0}') + # Offload model if setting enabled + if shared.opts.interrogate_offload: + tagger.unload() except Exception as e: result = f"Exception {type(e)}" shared.log.error(f'WD14: {e}') diff --git a/modules/shared.py b/modules/shared.py index 7b39d88a2..b143d65aa 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -207,6 +207,7 @@ options_templates.update(options_section(('offload', "Model Offloading"), { "offload_sep": OptionInfo("

Model Offloading

", "", gr.HTML), "diffusers_offload_mode": OptionInfo(startup_offload_mode, "Model offload mode", gr.Radio, {"choices": ['none', 'balanced', 'group', 'model', 'sequential']}), "diffusers_offload_nonblocking": OptionInfo(False, "Non-blocking move operations"), + "interrogate_offload": OptionInfo(True, "Offload caption models"), "offload_balanced_sep": OptionInfo("

Balanced Offload

", "", gr.HTML), "diffusers_offload_pre": OptionInfo(True, "Offload during pre-forward"), "diffusers_offload_streams": OptionInfo(False, "Offload using streams"), @@ -672,58 +673,6 @@ options_templates.update(options_section(('postprocessing', "Postprocessing"), { "upscaler_tile_overlap": OptionInfo(8, "Upscaler tile overlap", gr.Slider, {"minimum": 0, "maximum": 64, "step": 1}), })) -options_templates.update(options_section(('interrogate', "Interrogate"), { - "interrogate_default_type": OptionInfo("VLM", "Default caption type", gr.Radio, {"choices": ["VLM", "CLiP", "Tagger"]}), - "interrogate_offload": OptionInfo(True, "Offload models "), - "interrogate_score": OptionInfo(False, "Include scores in results when available", gr.Checkbox, {"visible": False}), - - # OpenCLiP settings (hidden - controlled via Caption Tab) - "interrogate_clip_sep": OptionInfo("

OpenCLiP

", "", gr.HTML, {"visible": False}), - "interrogate_clip_model": OptionInfo("ViT-L-14/openai", "CLiP: default model", gr.Dropdown, lambda: {"choices": get_clip_models(), "visible": False}, refresh=refresh_clip_models), - "interrogate_clip_mode": OptionInfo(caption_types[0], "CLiP: default mode", gr.Dropdown, {"choices": caption_types, "visible": False}), - "interrogate_blip_model": OptionInfo(list(caption_models)[0], "CLiP: default captioner", gr.Dropdown, {"choices": list(caption_models), "visible": False}), - "interrogate_clip_num_beams": OptionInfo(1, "CLiP: num beams", gr.Slider, {"minimum": 1, "maximum": 16, "step": 1, "visible": False}), - "interrogate_clip_min_length": OptionInfo(32, "CLiP: min length", gr.Slider, {"minimum": 1, "maximum": 128, "step": 1, "visible": False}), - "interrogate_clip_max_length": OptionInfo(74, "CLiP: max length", gr.Slider, {"minimum": 1, "maximum": 512, "step": 1, "visible": False}), - "interrogate_clip_min_flavors": OptionInfo(2, "CLiP: min flavors", gr.Slider, {"minimum": 0, "maximum": 32, "step": 1, "visible": False}), - "interrogate_clip_max_flavors": OptionInfo(16, "CLiP: max flavors", gr.Slider, {"minimum": 0, "maximum": 32, "step": 1, "visible": False}), - "interrogate_clip_flavor_count": OptionInfo(1024, "CLiP: intermediate flavors", gr.Slider, {"minimum": 256, "maximum": 4096, "step": 64, "visible": False}), - "interrogate_clip_chunk_size": OptionInfo(1024, "CLiP: chunk size", gr.Slider, {"minimum": 256, "maximum": 4096, "step": 64, "visible": False}), - - # VLM settings (hidden - controlled via Caption Tab) - "interrogate_vlm_sep": OptionInfo("

VLM

", "", gr.HTML, {"visible": False}), - "interrogate_vlm_model": OptionInfo(vlm_default, "VLM: default model", gr.Dropdown, {"choices": list(vlm_models), "visible": False}), - "interrogate_vlm_prompt": OptionInfo(vlm_prompts[2], "VLM: default prompt", DropdownEditable, {"choices": vlm_prompts, "visible": False}), - "interrogate_vlm_system": OptionInfo(vlm_system, "VLM: default prompt", gr.Textbox, {"visible": False}), - "interrogate_vlm_num_beams": OptionInfo(1, "VLM: num beams", gr.Slider, {"minimum": 1, "maximum": 16, "step": 1, "visible": False}), - "interrogate_vlm_max_length": OptionInfo(512, "VLM: max length", gr.Slider, {"minimum": 1, "maximum": 4096, "step": 1, "visible": False}), - "interrogate_vlm_do_sample": OptionInfo(True, "VLM: use sample method", gr.Checkbox, {"visible": False}), - "interrogate_vlm_temperature": OptionInfo(0.8, "VLM: temperature", gr.Slider, {"minimum": 0, "maximum": 1.0, "step": 0.01, "visible": False}), - "interrogate_vlm_top_k": OptionInfo(0, "VLM: top-k", gr.Slider, {"minimum": 0, "maximum": 99, "step": 1, "visible": False}), - "interrogate_vlm_top_p": OptionInfo(0, "VLM: top-p", gr.Slider, {"minimum": 0, "maximum": 1.0, "step": 0.01, "visible": False}), - "interrogate_vlm_keep_prefill": OptionInfo(False, "VLM: keep prefill text in output", gr.Checkbox, {"visible": False}), - "interrogate_vlm_keep_thinking": OptionInfo(False, "VLM: keep reasoning trace in output", gr.Checkbox, {"visible": False}), - "interrogate_vlm_thinking_mode": OptionInfo(False, "VLM: enable thinking/reasoning mode", gr.Checkbox, {"visible": False}), - - # Common tagger settings (hidden - controlled via Caption Tab) - "tagger_sep": OptionInfo("

Tagger Settings

", "", gr.HTML, {"visible": False}), - "tagger_max_tags": OptionInfo(74, "Tagger: max tags", gr.Slider, {"minimum": 1, "maximum": 512, "step": 1, "visible": False}), - "tagger_sort_alpha": OptionInfo(False, "Tagger: sort alphabetically", gr.Checkbox, {"visible": False}), - "tagger_use_spaces": OptionInfo(False, "Tagger: use spaces for tags", gr.Checkbox, {"visible": False}), - "tagger_escape": OptionInfo(True, "Tagger: escape brackets", gr.Checkbox, {"visible": False}), - "tagger_exclude_tags": OptionInfo("", "Tagger: exclude tags", gr.Textbox, {"visible": False}), - - # DeepBooru-specific settings (hidden - controlled via Caption Tab) - "deepbooru_sep": OptionInfo("

DeepBooru

", "", gr.HTML, {"visible": False}), - "deepbooru_score_threshold": OptionInfo(0.65, "DeepBooru: score threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01, "visible": False}), - - # WD14-specific settings (hidden - controlled via Caption Tab) - "wd14_sep": OptionInfo("

WD14 Tagger

", "", gr.HTML, {"visible": False}), - "wd14_model": OptionInfo("wd-eva02-large-tagger-v3", "WD14: default model", gr.Dropdown, {"choices": [], "visible": False}), - "wd14_general_threshold": OptionInfo(0.35, "WD14: general tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01, "visible": False}), - "wd14_character_threshold": OptionInfo(0.85, "WD14: character tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01, "visible": False}), - "wd14_include_rating": OptionInfo(False, "WD14: include rating tags", gr.Checkbox, {"visible": False}), -})) options_templates.update(options_section(('huggingface', "Huggingface"), { "huggingface_sep": OptionInfo("

Huggingface

", "", gr.HTML), @@ -793,6 +742,41 @@ options_templates.update(options_section(('hidden_options', "Hidden options"), { "sd_checkpoint_hash": OptionInfo("", "SHA256 hash of the current checkpoint", gr.Textbox, {"visible": False}), "tooltips": OptionInfo("UI Tooltips", "UI tooltips", gr.Radio, {"choices": ["None", "Browser default", "UI tooltips"], "visible": False}), + # Caption/Interrogate settings (controlled via Caption Tab UI) + "interrogate_default_type": OptionInfo("VLM", "Default caption type", gr.Radio, {"choices": ["VLM", "OpenCLiP", "Tagger"], "visible": False}), + "tagger_show_scores": OptionInfo(False, "Tagger: show confidence scores in results", gr.Checkbox, {"visible": False}), + "interrogate_clip_model": OptionInfo("ViT-L-14/openai", "OpenCLiP: default model", gr.Dropdown, lambda: {"choices": get_clip_models(), "visible": False}, refresh=refresh_clip_models), + "interrogate_clip_mode": OptionInfo(caption_types[0], "OpenCLiP: default mode", gr.Dropdown, {"choices": caption_types, "visible": False}), + "interrogate_blip_model": OptionInfo(list(caption_models)[0], "OpenCLiP: default captioner", gr.Dropdown, {"choices": list(caption_models), "visible": False}), + "interrogate_clip_num_beams": OptionInfo(1, "OpenCLiP: num beams", gr.Slider, {"minimum": 1, "maximum": 16, "step": 1, "visible": False}), + "interrogate_clip_min_length": OptionInfo(32, "OpenCLiP: min length", gr.Slider, {"minimum": 1, "maximum": 128, "step": 1, "visible": False}), + "interrogate_clip_max_length": OptionInfo(74, "OpenCLiP: max length", gr.Slider, {"minimum": 1, "maximum": 512, "step": 1, "visible": False}), + "interrogate_clip_min_flavors": OptionInfo(2, "OpenCLiP: min flavors", gr.Slider, {"minimum": 0, "maximum": 32, "step": 1, "visible": False}), + "interrogate_clip_max_flavors": OptionInfo(16, "OpenCLiP: max flavors", gr.Slider, {"minimum": 0, "maximum": 32, "step": 1, "visible": False}), + "interrogate_clip_flavor_count": OptionInfo(1024, "OpenCLiP: intermediate flavors", gr.Slider, {"minimum": 256, "maximum": 4096, "step": 64, "visible": False}), + "interrogate_clip_chunk_size": OptionInfo(1024, "OpenCLiP: chunk size", gr.Slider, {"minimum": 256, "maximum": 4096, "step": 64, "visible": False}), + "interrogate_vlm_model": OptionInfo(vlm_default, "VLM: default model", gr.Dropdown, {"choices": list(vlm_models), "visible": False}), + "interrogate_vlm_prompt": OptionInfo(vlm_prompts[2], "VLM: default prompt", DropdownEditable, {"choices": vlm_prompts, "visible": False}), + "interrogate_vlm_system": OptionInfo(vlm_system, "VLM: system prompt", gr.Textbox, {"visible": False}), + "interrogate_vlm_num_beams": OptionInfo(1, "VLM: num beams", gr.Slider, {"minimum": 1, "maximum": 16, "step": 1, "visible": False}), + "interrogate_vlm_max_length": OptionInfo(512, "VLM: max length", gr.Slider, {"minimum": 1, "maximum": 4096, "step": 1, "visible": False}), + "interrogate_vlm_do_sample": OptionInfo(True, "VLM: use sample method", gr.Checkbox, {"visible": False}), + "interrogate_vlm_temperature": OptionInfo(0.8, "VLM: temperature", gr.Slider, {"minimum": 0, "maximum": 1.0, "step": 0.01, "visible": False}), + "interrogate_vlm_top_k": OptionInfo(0, "VLM: top-k", gr.Slider, {"minimum": 0, "maximum": 99, "step": 1, "visible": False}), + "interrogate_vlm_top_p": OptionInfo(0, "VLM: top-p", gr.Slider, {"minimum": 0, "maximum": 1.0, "step": 0.01, "visible": False}), + "interrogate_vlm_keep_prefill": OptionInfo(False, "VLM: keep prefill text in output", gr.Checkbox, {"visible": False}), + "interrogate_vlm_keep_thinking": OptionInfo(False, "VLM: keep reasoning trace in output", gr.Checkbox, {"visible": False}), + "interrogate_vlm_thinking_mode": OptionInfo(False, "VLM: enable thinking/reasoning mode", gr.Checkbox, {"visible": False}), + "tagger_threshold": OptionInfo(0.50, "Tagger: general tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01, "visible": False}), + "tagger_include_rating": OptionInfo(False, "Tagger: include rating tags", gr.Checkbox, {"visible": False}), + "tagger_max_tags": OptionInfo(74, "Tagger: max tags", gr.Slider, {"minimum": 1, "maximum": 512, "step": 1, "visible": False}), + "tagger_sort_alpha": OptionInfo(False, "Tagger: sort alphabetically", gr.Checkbox, {"visible": False}), + "tagger_use_spaces": OptionInfo(False, "Tagger: use spaces for tags", gr.Checkbox, {"visible": False}), + "tagger_escape_brackets": OptionInfo(True, "Tagger: escape brackets", gr.Checkbox, {"visible": False}), + "tagger_exclude_tags": OptionInfo("", "Tagger: exclude tags", gr.Textbox, {"visible": False}), + "wd14_model": OptionInfo("wd-eva02-large-tagger-v3", "WD14: default model", gr.Dropdown, {"choices": [], "visible": False}), + "wd14_character_threshold": OptionInfo(0.85, "WD14: character tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01, "visible": False}), + # control settings are handled separately "control_hires": OptionInfo(False, "Hires use Control", gr.Checkbox, {"visible": False}), "control_aspect_ratio": OptionInfo(False, "Aspect ratio resize", gr.Checkbox, {"visible": False}), diff --git a/modules/ui_caption.py b/modules/ui_caption.py index 0c962d266..4821be690 100644 --- a/modules/ui_caption.py +++ b/modules/ui_caption.py @@ -85,28 +85,29 @@ def tagger_batch_wrapper(model_name, batch_files, batch_folder, batch_str, save_ def update_tagger_ui(model_name): """Update UI controls based on selected tagger model. - When DeepBooru is selected, character_threshold and include_rating are disabled - since DeepBooru doesn't support separate character threshold or rating tags. + When DeepBooru is selected, character_threshold is disabled since DeepBooru + doesn't support separate character threshold. """ from modules.interrogate import tagger is_db = tagger.is_deepbooru(model_name) return [ gr.update(interactive=not is_db), # character_threshold - gr.update(interactive=not is_db, value=False if is_db else None), # include_rating + gr.update(), # include_rating - now supported by both taggers ] -def update_tagger_params(model_name, general_threshold, character_threshold, include_rating, max_tags, sort_alpha, use_spaces, escape_brackets, exclude_tags): +def update_tagger_params(model_name, general_threshold, character_threshold, include_rating, max_tags, sort_alpha, use_spaces, escape_brackets, exclude_tags, show_scores): """Save all tagger parameters to shared.opts when UI controls change.""" shared.opts.wd14_model = model_name - shared.opts.wd14_general_threshold = float(general_threshold) + shared.opts.tagger_threshold = float(general_threshold) shared.opts.wd14_character_threshold = float(character_threshold) - shared.opts.wd14_include_rating = bool(include_rating) + shared.opts.tagger_include_rating = bool(include_rating) shared.opts.tagger_max_tags = int(max_tags) shared.opts.tagger_sort_alpha = bool(sort_alpha) shared.opts.tagger_use_spaces = bool(use_spaces) - shared.opts.tagger_escape = bool(escape_brackets) + shared.opts.tagger_escape_brackets = bool(escape_brackets) shared.opts.tagger_exclude_tags = str(exclude_tags) + shared.opts.tagger_show_scores = bool(show_scores) shared.opts.save() @@ -138,6 +139,12 @@ def update_vlm_model_params(vlm_model, vlm_system): shared.opts.save() +def update_default_caption_type(caption_type): + """Save the default caption type to shared.opts.""" + shared.opts.interrogate_default_type = str(caption_type) + shared.opts.save() + + def create_ui(): shared.log.debug('UI initialize: tab=caption') with gr.Row(equal_height=False, variant='compact', elem_classes="caption", elem_id="caption_tab"): @@ -200,7 +207,7 @@ def create_ui(): btn_vlm_caption_batch = gr.Button("Batch Caption", variant='primary', elem_id="btn_vlm_caption_batch") with gr.Row(): btn_vlm_caption = gr.Button("Caption", variant='primary', elem_id="btn_vlm_caption") - with gr.Tab("CLiP Interrogate", elem_id='tab_clip_interrogate'): + with gr.Tab("OpenCLiP", elem_id='tab_clip_interrogate'): with gr.Row(): clip_model = gr.Dropdown([], value=shared.opts.interrogate_clip_model, label='CLiP Model', elem_id='clip_clip_model') ui_common.create_refresh_button(clip_model, openclip.refresh_clip_models, lambda: {"choices": openclip.refresh_clip_models()}, 'clip_models_refresh') @@ -250,17 +257,19 @@ def create_ui(): wd_unload_btn = gr.Button(value='Unload', elem_id='wd_unload', variant='secondary') with gr.Accordion(label='Tagger: Advanced Options', open=True, visible=True): with gr.Row(): - wd_general_threshold = gr.Slider(label='General threshold', value=shared.opts.wd14_general_threshold, minimum=0.0, maximum=1.0, step=0.01, elem_id='wd_general_threshold') + wd_general_threshold = gr.Slider(label='General threshold', value=shared.opts.tagger_threshold, minimum=0.0, maximum=1.0, step=0.01, elem_id='wd_general_threshold') wd_character_threshold = gr.Slider(label='Character threshold', value=shared.opts.wd14_character_threshold, minimum=0.0, maximum=1.0, step=0.01, elem_id='wd_character_threshold') with gr.Row(): wd_max_tags = gr.Slider(label='Max tags', value=shared.opts.tagger_max_tags, minimum=1, maximum=512, step=1, elem_id='wd_max_tags') - wd_include_rating = gr.Checkbox(label='Include rating', value=shared.opts.wd14_include_rating, elem_id='wd_include_rating') + wd_include_rating = gr.Checkbox(label='Include rating', value=shared.opts.tagger_include_rating, elem_id='wd_include_rating') with gr.Row(): wd_sort_alpha = gr.Checkbox(label='Sort alphabetically', value=shared.opts.tagger_sort_alpha, elem_id='wd_sort_alpha') wd_use_spaces = gr.Checkbox(label='Use spaces', value=shared.opts.tagger_use_spaces, elem_id='wd_use_spaces') - wd_escape = gr.Checkbox(label='Escape brackets', value=shared.opts.tagger_escape, elem_id='wd_escape') + wd_escape = gr.Checkbox(label='Escape brackets', value=shared.opts.tagger_escape_brackets, elem_id='wd_escape') with gr.Row(): wd_exclude_tags = gr.Textbox(label='Exclude tags', value=shared.opts.tagger_exclude_tags, placeholder='Comma-separated tags to exclude', elem_id='wd_exclude_tags') + with gr.Row(): + wd_show_scores = gr.Checkbox(label='Show confidence scores', value=shared.opts.tagger_show_scores, elem_id='wd_show_scores') gr.HTML('') with gr.Accordion(label='Tagger: Batch', open=False, visible=True): with gr.Row(): @@ -277,6 +286,14 @@ def create_ui(): btn_wd_tag_batch = gr.Button("Batch Tag", variant='primary', elem_id="btn_wd_tag_batch") with gr.Row(): btn_wd_tag = gr.Button("Tag", variant='primary', elem_id="btn_wd_tag") + with gr.Tab("Interrogate", elem_id='tab_interrogate'): + with gr.Row(): + default_caption_type = gr.Radio( + choices=["VLM", "OpenCLiP", "Tagger"], + value=shared.opts.interrogate_default_type, + label="Default Caption Type", + elem_id="default_caption_type" + ) with gr.Column(variant='compact', elem_id='interrogate_output'): with gr.Row(elem_id='interrogate_output_prompt'): prompt = gr.Textbox(label="Answer", lines=12, placeholder="ai generated image description") @@ -320,7 +337,7 @@ def create_ui(): wd_model.change(fn=update_tagger_ui, inputs=[wd_model], outputs=[wd_character_threshold, wd_include_rating], show_progress=False) # Save tagger parameters to shared.opts when UI controls change - tagger_inputs = [wd_model, wd_general_threshold, wd_character_threshold, wd_include_rating, wd_max_tags, wd_sort_alpha, wd_use_spaces, wd_escape, wd_exclude_tags] + tagger_inputs = [wd_model, wd_general_threshold, wd_character_threshold, wd_include_rating, wd_max_tags, wd_sort_alpha, wd_use_spaces, wd_escape, wd_exclude_tags, wd_show_scores] wd_model.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) wd_general_threshold.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) wd_character_threshold.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) @@ -330,6 +347,7 @@ def create_ui(): wd_use_spaces.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) wd_escape.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) wd_exclude_tags.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) + wd_show_scores.change(fn=update_tagger_params, inputs=tagger_inputs, outputs=[], show_progress=False) # Save CLiP model parameters to shared.opts when UI controls change clip_model_inputs = [clip_model, blip_model, clip_mode] @@ -342,6 +360,9 @@ def create_ui(): vlm_model.change(fn=update_vlm_model_params, inputs=vlm_model_inputs, outputs=[], show_progress=False) vlm_system.change(fn=update_vlm_model_params, inputs=vlm_model_inputs, outputs=[], show_progress=False) + # Save default caption type to shared.opts when UI control changes + default_caption_type.change(fn=update_default_caption_type, inputs=[default_caption_type], outputs=[], show_progress=False) + for tabname, button in copy_interrogate_buttons.items(): generation_parameters_copypaste.register_paste_params_button(generation_parameters_copypaste.ParamBinding(paste_button=button, tabname=tabname, source_text_component=prompt, source_image_component=image,)) generation_parameters_copypaste.add_paste_fields("caption", image, None) From 5abb794462d4faecbf471d1834d0a908fc5652ea Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Wed, 21 Jan 2026 03:02:09 +0000 Subject: [PATCH 5/7] style(test): remove unused imports in test-tagger.py --- cli/test-tagger.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cli/test-tagger.py b/cli/test-tagger.py index eacbddba8..2694a0e1f 100644 --- a/cli/test-tagger.py +++ b/cli/test-tagger.py @@ -93,7 +93,6 @@ class TaggerTest: def setup(self): """Load test image and models.""" from PIL import Image - from modules import shared print("=" * 70) print("TAGGER SETTINGS TEST SUITE") @@ -221,7 +220,6 @@ class TaggerTest: def get_memory_stats(self): """Get current GPU and CPU memory usage.""" import torch - import gc stats = {} From 6b10f0df4febdaba5aa1fc5324de1cf84ced45ac Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Wed, 21 Jan 2026 09:46:08 +0000 Subject: [PATCH 6/7] refactor(caption): address PR review feedback Rename WD14 module and settings to WaifuDiffusion: - Rename wd14.py to waifudiffusion.py - Rename WD14Tagger class to WaifuDiffusionTagger - Rename WD14_MODELS constant to WAIFUDIFFUSION_MODELS - Rename settings: wd14_model -> waifudiffusion_model, wd14_character_threshold -> waifudiffusion_character_threshold - Update all log messages from "WD14" to "WaifuDiffusion" Code quality improvements: - Simplify threshold parameter defaulting using `or` operator - Extract save_output logic into _save_tags_to_file() helper with isolated error handling to prevent single file failures from impacting entire batch - Fix timing log format consistency (remove 's' suffix) --- cli/test-tagger.py | 142 +++++++-------- modules/interrogate/deepbooru.py | 56 +++--- modules/interrogate/interrogate.py | 6 +- modules/interrogate/openclip.py | 12 +- modules/interrogate/tagger.py | 30 ++-- .../{wd14.py => waifudiffusion.py} | 167 +++++++++--------- modules/shared.py | 4 +- modules/ui_caption.py | 8 +- 8 files changed, 223 insertions(+), 202 deletions(-) rename modules/interrogate/{wd14.py => waifudiffusion.py} (69%) diff --git a/cli/test-tagger.py b/cli/test-tagger.py index 2694a0e1f..9c5f858bf 100644 --- a/cli/test-tagger.py +++ b/cli/test-tagger.py @@ -2,7 +2,7 @@ """ Tagger Settings Test Suite -Tests all WD14 and DeepBooru tagger settings to verify they're properly +Tests all WaifuDiffusion and DeepBooru tagger settings to verify they're properly mapped and affect output correctly. Usage: @@ -71,7 +71,7 @@ class TaggerTest: def __init__(self): self.results = {'passed': [], 'failed': [], 'skipped': []} self.test_image = None - self.wd14_loaded = False + self.waifudiffusion_loaded = False self.deepbooru_loaded = False def log_pass(self, msg): @@ -116,11 +116,11 @@ class TaggerTest: # Load models print("\nLoading models...") - from modules.interrogate import wd14, deepbooru + from modules.interrogate import waifudiffusion, deepbooru t0 = time.time() - self.wd14_loaded = wd14.load_model() - print(f" WD14: {'loaded' if self.wd14_loaded else 'FAILED'} ({time.time()-t0:.1f}s)") + self.waifudiffusion_loaded = waifudiffusion.load_model() + print(f" WaifuDiffusion: {'loaded' if self.waifudiffusion_loaded else 'FAILED'} ({time.time()-t0:.1f}s)") t0 = time.time() self.deepbooru_loaded = deepbooru.load_model() @@ -132,10 +132,10 @@ class TaggerTest: print("CLEANUP") print("=" * 70) - from modules.interrogate import wd14, deepbooru + from modules.interrogate import waifudiffusion, deepbooru from modules import devices - wd14.unload_model() + waifudiffusion.unload_model() deepbooru.unload_model() devices.torch_gc(force=True) print(" Models unloaded") @@ -205,14 +205,14 @@ class TaggerTest: else: self.log_fail(f"Provider '{provider}' configured but not available") - # Test 5: If WD14 loaded, check session providers - if self.wd14_loaded: - from modules.interrogate import wd14 - if wd14.tagger.session is not None: - session_providers = wd14.tagger.session.get_providers() - self.log_pass(f"WD14 session providers: {session_providers}") + # Test 5: If WaifuDiffusion loaded, check session providers + if self.waifudiffusion_loaded: + from modules.interrogate import waifudiffusion + if waifudiffusion.tagger.session is not None: + session_providers = waifudiffusion.tagger.session.get_providers() + self.log_pass(f"WaifuDiffusion session providers: {session_providers}") else: - self.log_skip("WD14 session not initialized") + self.log_skip("WaifuDiffusion session not initialized") # ========================================================================= # TEST: Memory Management (Offload/Reload/Unload) @@ -251,7 +251,7 @@ class TaggerTest: import torch import gc from modules import devices - from modules.interrogate import wd14, deepbooru + from modules.interrogate import waifudiffusion, deepbooru # Memory leak tolerance (MB) - some variance is expected GPU_LEAK_TOLERANCE_MB = 50 @@ -362,10 +362,10 @@ class TaggerTest: deepbooru.load_model() # ===================================================================== - # WD14: Test session lifecycle with memory monitoring + # WaifuDiffusion: Test session lifecycle with memory monitoring # ===================================================================== - if self.wd14_loaded: - print("\n WD14 Memory Management:") + if self.waifudiffusion_loaded: + print("\n WaifuDiffusion Memory Management:") # Baseline memory gc.collect() @@ -375,74 +375,74 @@ class TaggerTest: print(f" Baseline: GPU={baseline['gpu_allocated']:.1f}MB, RAM={baseline['ram_used']:.1f}MB") # Test 1: Session exists - if wd14.tagger.session is not None: - self.log_pass("WD14: session loaded") + if waifudiffusion.tagger.session is not None: + self.log_pass("WaifuDiffusion: session loaded") else: - self.log_fail("WD14: session not loaded") + self.log_fail("WaifuDiffusion: session not loaded") return # Test 2: Get current providers - providers = wd14.tagger.session.get_providers() + providers = waifudiffusion.tagger.session.get_providers() print(f" Active providers: {providers}") - self.log_pass(f"WD14: using providers {providers}") + self.log_pass(f"WaifuDiffusion: using providers {providers}") # Test 3: Run inference try: - tags = wd14.tagger.predict(self.test_image, max_tags=3) + tags = waifudiffusion.tagger.predict(self.test_image, max_tags=3) after_infer = self.get_memory_stats() print(f" After inference: GPU={after_infer['gpu_allocated']:.1f}MB, RAM={after_infer['ram_used']:.1f}MB") if tags: - self.log_pass(f"WD14: inference works ({tags[:30]}...)") + self.log_pass(f"WaifuDiffusion: inference works ({tags[:30]}...)") else: - self.log_fail("WD14: inference returned empty") + self.log_fail("WaifuDiffusion: inference returned empty") except Exception as e: - self.log_fail(f"WD14: inference failed: {e}") + self.log_fail(f"WaifuDiffusion: inference failed: {e}") # Test 4: Unload session with memory check - model_name = wd14.tagger.model_name - wd14.unload_model() + model_name = waifudiffusion.tagger.model_name + waifudiffusion.unload_model() gc.collect() if torch.cuda.is_available(): torch.cuda.empty_cache() after_unload = self.get_memory_stats() print(f" After unload: GPU={after_unload['gpu_allocated']:.1f}MB, RAM={after_unload['ram_used']:.1f}MB") - if wd14.tagger.session is None: - self.log_pass("WD14: unload successful") + if waifudiffusion.tagger.session is None: + self.log_pass("WaifuDiffusion: unload successful") else: - self.log_fail("WD14: unload failed, session still exists") + self.log_fail("WaifuDiffusion: unload failed, session still exists") # Check for memory leaks after unload gpu_leak = after_unload['gpu_allocated'] - baseline['gpu_allocated'] ram_leak = after_unload['ram_used'] - baseline['ram_used'] if gpu_leak <= GPU_LEAK_TOLERANCE_MB: - self.log_pass(f"WD14: no GPU memory leak after unload (diff={gpu_leak:.1f}MB)") + self.log_pass(f"WaifuDiffusion: no GPU memory leak after unload (diff={gpu_leak:.1f}MB)") else: - self.log_fail(f"WD14: GPU memory leak detected (diff={gpu_leak:.1f}MB > {GPU_LEAK_TOLERANCE_MB}MB)") + self.log_fail(f"WaifuDiffusion: GPU memory leak detected (diff={gpu_leak:.1f}MB > {GPU_LEAK_TOLERANCE_MB}MB)") if ram_leak <= RAM_LEAK_TOLERANCE_MB: - self.log_pass(f"WD14: no RAM leak after unload (diff={ram_leak:.1f}MB)") + self.log_pass(f"WaifuDiffusion: no RAM leak after unload (diff={ram_leak:.1f}MB)") else: - self.log_warn(f"WD14: RAM increased after unload (diff={ram_leak:.1f}MB) - may be caching") + self.log_warn(f"WaifuDiffusion: RAM increased after unload (diff={ram_leak:.1f}MB) - may be caching") # Test 5: Reload session - wd14.load_model(model_name) + waifudiffusion.load_model(model_name) after_reload = self.get_memory_stats() print(f" After reload: GPU={after_reload['gpu_allocated']:.1f}MB, RAM={after_reload['ram_used']:.1f}MB") - if wd14.tagger.session is not None: - self.log_pass("WD14: reload successful") + if waifudiffusion.tagger.session is not None: + self.log_pass("WaifuDiffusion: reload successful") else: - self.log_fail("WD14: reload failed") + self.log_fail("WaifuDiffusion: reload failed") # Test 6: Inference after reload try: - tags = wd14.tagger.predict(self.test_image, max_tags=3) + tags = waifudiffusion.tagger.predict(self.test_image, max_tags=3) if tags: - self.log_pass("WD14: inference after reload works") + self.log_pass("WaifuDiffusion: inference after reload works") else: - self.log_fail("WD14: inference after reload returned empty") + self.log_fail("WaifuDiffusion: inference after reload returned empty") except Exception as e: - self.log_fail(f"WD14: inference after reload failed: {e}") + self.log_fail(f"WaifuDiffusion: inference after reload failed: {e}") # Final memory check after full cycle gc.collect() @@ -471,8 +471,8 @@ class TaggerTest: ('tagger_escape_brackets', bool), ('tagger_exclude_tags', str), ('tagger_show_scores', bool), - ('wd14_model', str), - ('wd14_character_threshold', float), + ('waifudiffusion_model', str), + ('waifudiffusion_character_threshold', float), ('interrogate_offload', bool), ] @@ -486,23 +486,23 @@ class TaggerTest: # ========================================================================= # TEST: Parameter Effect - Tests a single parameter on both taggers # ========================================================================= - def test_parameter(self, param_name, test_func, wd14_supported=True, deepbooru_supported=True): - """Test a parameter on both WD14 and DeepBooru.""" + def test_parameter(self, param_name, test_func, waifudiffusion_supported=True, deepbooru_supported=True): + """Test a parameter on both WaifuDiffusion and DeepBooru.""" print(f"\n Testing: {param_name}") - if wd14_supported and self.wd14_loaded: + if waifudiffusion_supported and self.waifudiffusion_loaded: try: - result = test_func('wd14') + result = test_func('waifudiffusion') if result is True: - self.log_pass(f"WD14: {param_name}") + self.log_pass(f"WaifuDiffusion: {param_name}") elif result is False: - self.log_fail(f"WD14: {param_name}") + self.log_fail(f"WaifuDiffusion: {param_name}") else: - self.log_skip(f"WD14: {param_name} - {result}") + self.log_skip(f"WaifuDiffusion: {param_name} - {result}") except Exception as e: - self.log_fail(f"WD14: {param_name} - {e}") - elif wd14_supported: - self.log_skip(f"WD14: {param_name} - model not loaded") + self.log_fail(f"WaifuDiffusion: {param_name} - {e}") + elif waifudiffusion_supported: + self.log_skip(f"WaifuDiffusion: {param_name} - model not loaded") if deepbooru_supported and self.deepbooru_loaded: try: @@ -520,9 +520,9 @@ class TaggerTest: def tag(self, tagger, **kwargs): """Helper to call the appropriate tagger.""" - if tagger == 'wd14': - from modules.interrogate import wd14 - return wd14.tagger.predict(self.test_image, **kwargs) + if tagger == 'waifudiffusion': + from modules.interrogate import waifudiffusion + return waifudiffusion.tagger.predict(self.test_image, **kwargs) else: from modules.interrogate import deepbooru return deepbooru.model.tag(self.test_image, **kwargs) @@ -759,16 +759,16 @@ class TaggerTest: self.test_parameter('include_rating', check_include_rating) # ========================================================================= - # TEST: character_threshold (WD14 only) + # TEST: character_threshold (WaifuDiffusion only) # ========================================================================= def test_character_threshold(self): - """Test that character_threshold affects character tag count (WD14 only).""" + """Test that character_threshold affects character tag count (WaifuDiffusion only).""" print("\n" + "=" * 70) - print("TEST: character_threshold effect (WD14 only)") + print("TEST: character_threshold effect (WaifuDiffusion only)") print("=" * 70) def check_character_threshold(tagger): - if tagger != 'wd14': + if tagger != 'waifudiffusion': return "not supported" # Character threshold only affects character tags @@ -796,17 +796,17 @@ class TaggerTest: from modules.interrogate import tagger - # Test WD14 through unified interface - if self.wd14_loaded: + # Test WaifuDiffusion through unified interface + if self.waifudiffusion_loaded: try: models = tagger.get_models() - wd14_model = next((m for m in models if m != 'DeepBooru'), None) - if wd14_model: - tags = tagger.tag(self.test_image, model_name=wd14_model, max_tags=5) - print(f" WD14 ({wd14_model}): {tags[:50]}...") - self.log_pass("Unified interface: WD14") + waifudiffusion_model = next((m for m in models if m != 'DeepBooru'), None) + if waifudiffusion_model: + tags = tagger.tag(self.test_image, model_name=waifudiffusion_model, max_tags=5) + print(f" WaifuDiffusion ({waifudiffusion_model}): {tags[:50]}...") + self.log_pass("Unified interface: WaifuDiffusion") except Exception as e: - self.log_fail(f"Unified interface: WD14 - {e}") + self.log_fail(f"Unified interface: WaifuDiffusion - {e}") # Test DeepBooru through unified interface if self.deepbooru_loaded: diff --git a/modules/interrogate/deepbooru.py b/modules/interrogate/deepbooru.py index 5ee2df751..88f0ab44f 100644 --- a/modules/interrogate/deepbooru.py +++ b/modules/interrogate/deepbooru.py @@ -80,20 +80,13 @@ class DeepDanbooru: Formatted tag string """ # Use settings defaults if not specified - if general_threshold is None: - general_threshold = shared.opts.tagger_threshold - if include_rating is None: - include_rating = shared.opts.tagger_include_rating - if exclude_tags is None: - exclude_tags = shared.opts.tagger_exclude_tags - if max_tags is None: - max_tags = shared.opts.tagger_max_tags - if sort_alpha is None: - sort_alpha = shared.opts.tagger_sort_alpha - if use_spaces is None: - use_spaces = shared.opts.tagger_use_spaces - if escape_brackets is None: - escape_brackets = shared.opts.tagger_escape_brackets + general_threshold = general_threshold or shared.opts.tagger_threshold + include_rating = include_rating if include_rating is not None else shared.opts.tagger_include_rating + exclude_tags = exclude_tags or shared.opts.tagger_exclude_tags + max_tags = max_tags or shared.opts.tagger_max_tags + sort_alpha = sort_alpha if sort_alpha is not None else shared.opts.tagger_sort_alpha + use_spaces = use_spaces if use_spaces is not None else shared.opts.tagger_use_spaces + escape_brackets = escape_brackets if escape_brackets is not None else shared.opts.tagger_escape_brackets if isinstance(pil_image, list): pil_image = pil_image[0] if len(pil_image) > 0 else None @@ -137,6 +130,31 @@ class DeepDanbooru: model = DeepDanbooru() +def _save_tags_to_file(img_path, tags_str: str, save_append: bool) -> bool: + """Save tags to a text file with error handling. + + Args: + img_path: Path to the image file + tags_str: Tags string to save + save_append: If True, append to existing file; otherwise overwrite + + Returns: + True if save succeeded, False otherwise + """ + try: + txt_path = img_path.with_suffix('.txt') + if save_append and txt_path.exists(): + with open(txt_path, 'a', encoding='utf-8') as f: + f.write(f', {tags_str}') + else: + with open(txt_path, 'w', encoding='utf-8') as f: + f.write(tags_str) + return True + except Exception as e: + shared.log.error(f'DeepBooru batch: failed to save file="{img_path}" error={e}') + return False + + def get_models() -> list: """Return list of available DeepBooru models (just one).""" return ["DeepBooru"] @@ -179,7 +197,7 @@ def tag(image, **kwargs) -> str: try: result = model.tag(image, **kwargs) - shared.log.debug(f'DeepBooru: complete time={time.time()-t0:.2f}s tags={len(result.split(", ")) if result else 0}') + shared.log.debug(f'DeepBooru: complete time={time.time()-t0:.2f} tags={len(result.split(", ")) if result else 0}') except Exception as e: result = f"Exception {type(e)}" shared.log.error(f'DeepBooru: {e}') @@ -299,13 +317,7 @@ def batch( tags_str = model.tag_multi(image, **kwargs) if save_output: - txt_path = img_path.with_suffix('.txt') - if save_append and txt_path.exists(): - with open(txt_path, 'a', encoding='utf-8') as f: - f.write(f', {tags_str}') - else: - with open(txt_path, 'w', encoding='utf-8') as f: - f.write(tags_str) + _save_tags_to_file(img_path, tags_str, save_append) results.append(f'{img_path.name}: {tags_str[:100]}...' if len(tags_str) > 100 else f'{img_path.name}: {tags_str}') diff --git a/modules/interrogate/interrogate.py b/modules/interrogate/interrogate.py index 4efc32732..4e06fb36f 100644 --- a/modules/interrogate/interrogate.py +++ b/modules/interrogate/interrogate.py @@ -21,13 +21,13 @@ def interrogate(image): shared.log.debug(f'Interrogate: time={time.time()-t0:.2f} answer="{prompt}"') return prompt elif shared.opts.interrogate_default_type == 'Tagger': - shared.log.info(f'Interrogate: type={shared.opts.interrogate_default_type} model="{shared.opts.wd14_model}"') + shared.log.info(f'Interrogate: type={shared.opts.interrogate_default_type} model="{shared.opts.waifudiffusion_model}"') from modules.interrogate import tagger prompt = tagger.tag( image=image, - model_name=shared.opts.wd14_model, + model_name=shared.opts.waifudiffusion_model, general_threshold=shared.opts.tagger_threshold, - character_threshold=shared.opts.wd14_character_threshold, + character_threshold=shared.opts.waifudiffusion_character_threshold, include_rating=shared.opts.tagger_include_rating, exclude_tags=shared.opts.tagger_exclude_tags, max_tags=shared.opts.tagger_max_tags, diff --git a/modules/interrogate/openclip.py b/modules/interrogate/openclip.py index 68de085b0..ca69ad8dd 100644 --- a/modules/interrogate/openclip.py +++ b/modules/interrogate/openclip.py @@ -117,7 +117,7 @@ def load_interrogator(clip_model, blip_model): ci = clip_interrogator.Interrogator(interrogator_config) if blip_model.startswith('blip2-'): _apply_blip2_fix(ci.caption_model, ci.caption_processor) - shared.log.debug(f'CLIP load: time={time.time()-t0:.2f}s') + shared.log.debug(f'CLIP load: time={time.time()-t0:.2f}') elif clip_model != ci.config.clip_model_name or blip_model != ci.config.caption_model_name: t0 = time.time() if clip_model != ci.config.clip_model_name: @@ -134,7 +134,7 @@ def load_interrogator(clip_model, blip_model): ci.load_caption_model() if blip_model.startswith('blip2-'): _apply_blip2_fix(ci.caption_model, ci.caption_processor) - shared.log.debug(f'CLIP load: time={time.time()-t0:.2f}s') + shared.log.debug(f'CLIP load: time={time.time()-t0:.2f}') else: debug_log(f'CLIP: models already loaded clip="{clip_model}" blip="{blip_model}"') @@ -172,7 +172,7 @@ def interrogate(image, mode, caption=None): prompt = ci.interrogate_negative(image, max_flavors=shared.opts.interrogate_clip_max_flavors) else: raise RuntimeError(f"Unknown mode {mode}") - debug_log(f'CLIP: mode="{mode}" time={time.time()-t0:.2f}s result="{prompt[:100]}..."' if len(prompt) > 100 else f'CLIP: mode="{mode}" time={time.time()-t0:.2f}s result="{prompt}"') + debug_log(f'CLIP: mode="{mode}" time={time.time()-t0:.2f} result="{prompt[:100]}..."' if len(prompt) > 100 else f'CLIP: mode="{mode}" time={time.time()-t0:.2f} result="{prompt}"') return prompt @@ -189,7 +189,7 @@ def interrogate_image(image, clip_model, blip_model, mode): image = image.convert('RGB') prompt = interrogate(image, mode) devices.torch_gc() - shared.log.debug(f'CLIP: complete time={time.time()-t0:.2f}s') + shared.log.debug(f'CLIP: complete time={time.time()-t0:.2f}') except Exception as e: prompt = f"Exception {type(e)}" shared.log.error(f'CLIP: {e}') @@ -243,7 +243,7 @@ def interrogate_batch(batch_files, batch_folder, batch_str, clip_model, blip_mod ci.config.quiet = False unload_clip_model() shared.state.end(jobid) - shared.log.info(f'CLIP batch: complete images={len(prompts)} time={time.time()-t0:.2f}s') + shared.log.info(f'CLIP batch: complete images={len(prompts)} time={time.time()-t0:.2f}') return '\n\n'.join(prompts) @@ -264,7 +264,7 @@ def analyze_image(image, clip_model, blip_model): movement_ranks = dict(sorted(zip(top_movements, ci.similarities(image_features, top_movements)), key=lambda x: x[1], reverse=True)) trending_ranks = dict(sorted(zip(top_trendings, ci.similarities(image_features, top_trendings)), key=lambda x: x[1], reverse=True)) flavor_ranks = dict(sorted(zip(top_flavors, ci.similarities(image_features, top_flavors)), key=lambda x: x[1], reverse=True)) - shared.log.debug(f'CLIP analyze: complete time={time.time()-t0:.2f}s') + shared.log.debug(f'CLIP analyze: complete time={time.time()-t0:.2f}') # Format labels as text def format_category(name, ranks): diff --git a/modules/interrogate/tagger.py b/modules/interrogate/tagger.py index cd2374d04..51516adaa 100644 --- a/modules/interrogate/tagger.py +++ b/modules/interrogate/tagger.py @@ -1,4 +1,4 @@ -# Unified Tagger Interface - Dispatches to WD14 or DeepBooru based on model selection +# Unified Tagger Interface - Dispatches to WaifuDiffusion or DeepBooru based on model selection # Provides a common interface for the Booru Tags tab from modules import shared @@ -7,9 +7,9 @@ DEEPBOORU_MODEL = "DeepBooru" def get_models() -> list: - """Return combined list: DeepBooru + WD14 models.""" - from modules.interrogate import wd14 - return [DEEPBOORU_MODEL] + wd14.get_models() + """Return combined list: DeepBooru + WaifuDiffusion models.""" + from modules.interrogate import waifudiffusion + return [DEEPBOORU_MODEL] + waifudiffusion.get_models() def refresh_models() -> list: @@ -28,15 +28,15 @@ def load_model(model_name: str) -> bool: from modules.interrogate import deepbooru return deepbooru.load_model() else: - from modules.interrogate import wd14 - return wd14.load_model(model_name) + from modules.interrogate import waifudiffusion + return waifudiffusion.load_model(model_name) def unload_model(): """Unload both backends to ensure memory is freed.""" - from modules.interrogate import deepbooru, wd14 + from modules.interrogate import deepbooru, waifudiffusion deepbooru.unload_model() - wd14.unload_model() + waifudiffusion.unload_model() def tag(image, model_name: str = None, **kwargs) -> str: @@ -44,28 +44,28 @@ def tag(image, model_name: str = None, **kwargs) -> str: Args: image: PIL Image to tag - model_name: Model to use (DeepBooru or WD14 model name) + model_name: Model to use (DeepBooru or WaifuDiffusion model name) **kwargs: Additional arguments passed to the backend Returns: Formatted tag string """ if model_name is None: - model_name = shared.opts.wd14_model + model_name = shared.opts.waifudiffusion_model if is_deepbooru(model_name): from modules.interrogate import deepbooru return deepbooru.tag(image, **kwargs) else: - from modules.interrogate import wd14 - return wd14.tag(image, model_name=model_name, **kwargs) + from modules.interrogate import waifudiffusion + return waifudiffusion.tag(image, model_name=model_name, **kwargs) def batch(model_name: str, **kwargs) -> str: """Unified batch processing. Args: - model_name: Model to use (DeepBooru or WD14 model name) + model_name: Model to use (DeepBooru or WaifuDiffusion model name) **kwargs: Additional arguments passed to the backend Returns: @@ -75,5 +75,5 @@ def batch(model_name: str, **kwargs) -> str: from modules.interrogate import deepbooru return deepbooru.batch(model_name=model_name, **kwargs) else: - from modules.interrogate import wd14 - return wd14.batch(model_name=model_name, **kwargs) + from modules.interrogate import waifudiffusion + return waifudiffusion.batch(model_name=model_name, **kwargs) diff --git a/modules/interrogate/wd14.py b/modules/interrogate/waifudiffusion.py similarity index 69% rename from modules/interrogate/wd14.py rename to modules/interrogate/waifudiffusion.py index fcdb360b0..71951a47f 100644 --- a/modules/interrogate/wd14.py +++ b/modules/interrogate/waifudiffusion.py @@ -1,4 +1,4 @@ -# WD14/WaifuDiffusion Tagger - ONNX-based anime/illustration tagging +# WaifuDiffusion Tagger - ONNX-based anime/illustration tagging # Based on SmilingWolf's tagger models: https://huggingface.co/SmilingWolf import os @@ -17,8 +17,8 @@ debug_log = shared.log.trace if debug_enabled else lambda *args, **kwargs: None re_special = re.compile(r'([\\()])') load_lock = threading.Lock() -# WD14 model repository mappings -WD14_MODELS = { +# WaifuDiffusion model repository mappings +WAIFUDIFFUSION_MODELS = { # v3 models (latest, recommended) "wd-eva02-large-tagger-v3": "SmilingWolf/wd-eva02-large-tagger-v3", "wd-vit-tagger-v3": "SmilingWolf/wd-vit-tagger-v3", @@ -38,8 +38,8 @@ CATEGORY_CHARACTER = 4 CATEGORY_RATING = 9 -class WD14Tagger: - """WD14/WaifuDiffusion Tagger using ONNX inference.""" +class WaifuDiffusionTagger: + """WaifuDiffusion Tagger using ONNX inference.""" def __init__(self): self.session = None @@ -54,63 +54,63 @@ class WD14Tagger: import huggingface_hub if model_name is None: - model_name = shared.opts.wd14_model - if model_name not in WD14_MODELS: - shared.log.error(f'WD14: unknown model "{model_name}"') + model_name = shared.opts.waifudiffusion_model + if model_name not in WAIFUDIFFUSION_MODELS: + shared.log.error(f'WaifuDiffusion: unknown model "{model_name}"') return False with load_lock: if self.session is not None and self.model_name == model_name: - debug_log(f'WD14: model already loaded model="{model_name}"') + debug_log(f'WaifuDiffusion: model already loaded model="{model_name}"') return True # Already loaded # Unload previous model if different if self.model_name != model_name and self.session is not None: - debug_log(f'WD14: switching model from "{self.model_name}" to "{model_name}"') + debug_log(f'WaifuDiffusion: switching model from "{self.model_name}" to "{model_name}"') self.unload() - repo_id = WD14_MODELS[model_name] + repo_id = WAIFUDIFFUSION_MODELS[model_name] t0 = time.time() - shared.log.info(f'WD14 load: model="{model_name}" repo="{repo_id}"') + shared.log.info(f'WaifuDiffusion load: model="{model_name}" repo="{repo_id}"') try: # Download only ONNX model and tags CSV (skip safetensors/msgpack variants) - debug_log(f'WD14 load: downloading from HuggingFace cache_dir="{shared.opts.hfcache_dir}"') + debug_log(f'WaifuDiffusion load: downloading from HuggingFace cache_dir="{shared.opts.hfcache_dir}"') self.model_path = huggingface_hub.snapshot_download( repo_id, cache_dir=shared.opts.hfcache_dir, allow_patterns=["model.onnx", "selected_tags.csv"], ) - debug_log(f'WD14 load: model_path="{self.model_path}"') + debug_log(f'WaifuDiffusion load: model_path="{self.model_path}"') # Load ONNX model model_file = os.path.join(self.model_path, "model.onnx") if not os.path.exists(model_file): - shared.log.error(f'WD14 load: model file not found: {model_file}') + shared.log.error(f'WaifuDiffusion load: model file not found: {model_file}') return False import onnxruntime as ort - debug_log(f'WD14 load: onnxruntime version={ort.__version__}') + debug_log(f'WaifuDiffusion load: onnxruntime version={ort.__version__}') self.session = ort.InferenceSession(model_file, providers=devices.onnx) self.model_name = model_name # Get actual providers used actual_providers = self.session.get_providers() - debug_log(f'WD14 load: active providers={actual_providers}') + debug_log(f'WaifuDiffusion load: active providers={actual_providers}') # Load tags from CSV self._load_tags() load_time = time.time() - t0 - shared.log.debug(f'WD14 load: time={load_time:.2f}s tags={len(self.tags)}') - debug_log(f'WD14 load: input_name={self.session.get_inputs()[0].name} output_name={self.session.get_outputs()[0].name}') + shared.log.debug(f'WaifuDiffusion load: time={load_time:.2f} tags={len(self.tags)}') + debug_log(f'WaifuDiffusion load: input_name={self.session.get_inputs()[0].name} output_name={self.session.get_outputs()[0].name}') return True except Exception as e: - shared.log.error(f'WD14 load: failed error={e}') - errors.display(e, 'WD14 load') + shared.log.error(f'WaifuDiffusion load: failed error={e}') + errors.display(e, 'WaifuDiffusion load') self.unload() return False @@ -120,7 +120,7 @@ class WD14Tagger: csv_path = os.path.join(self.model_path, "selected_tags.csv") if not os.path.exists(csv_path): - shared.log.error(f'WD14 load: tags file not found: {csv_path}') + shared.log.error(f'WaifuDiffusion load: tags file not found: {csv_path}') return self.tags = [] @@ -136,24 +136,24 @@ class WD14Tagger: category_counts = {} for cat in self.tag_categories: category_counts[cat] = category_counts.get(cat, 0) + 1 - debug_log(f'WD14 load: tag categories={category_counts}') + debug_log(f'WaifuDiffusion load: tag categories={category_counts}') def unload(self): """Unload the model and free resources.""" if self.session is not None: - shared.log.debug(f'WD14 unload: model="{self.model_name}"') + shared.log.debug(f'WaifuDiffusion unload: model="{self.model_name}"') self.session = None self.tags = None self.tag_categories = None self.model_name = None self.model_path = None devices.torch_gc(force=True) - debug_log('WD14 unload: complete') + debug_log('WaifuDiffusion unload: complete') else: - debug_log('WD14 unload: no model loaded') + debug_log('WaifuDiffusion unload: no model loaded') def preprocess_image(self, image: Image.Image) -> np.ndarray: - """Preprocess image for WD14 model input. + """Preprocess image for WaifuDiffusion model input. - Resize to 448x448 (standard for WD models) - Pad to square with white background @@ -189,7 +189,7 @@ class WD14Tagger: # Add batch dimension img_array = np.expand_dims(img_array, axis=0) - debug_log(f'WD14 preprocess: original_size={original_size} mode={original_mode} padded_size={max_dim} output_shape={img_array.shape}') + debug_log(f'WaifuDiffusion preprocess: original_size={original_size} mode={original_mode} padded_size={max_dim} output_shape={img_array.shape}') return img_array def predict( @@ -223,24 +223,16 @@ class WD14Tagger: t0 = time.time() # Use settings defaults if not specified - if general_threshold is None: - general_threshold = shared.opts.tagger_threshold - if character_threshold is None: - character_threshold = shared.opts.wd14_character_threshold - if include_rating is None: - include_rating = shared.opts.tagger_include_rating - if exclude_tags is None: - exclude_tags = shared.opts.tagger_exclude_tags - if max_tags is None: - max_tags = shared.opts.tagger_max_tags - if sort_alpha is None: - sort_alpha = shared.opts.tagger_sort_alpha - if use_spaces is None: - use_spaces = shared.opts.tagger_use_spaces - if escape_brackets is None: - escape_brackets = shared.opts.tagger_escape_brackets + general_threshold = general_threshold or shared.opts.tagger_threshold + character_threshold = character_threshold or shared.opts.waifudiffusion_character_threshold + include_rating = include_rating if include_rating is not None else shared.opts.tagger_include_rating + exclude_tags = exclude_tags or shared.opts.tagger_exclude_tags + max_tags = max_tags or shared.opts.tagger_max_tags + sort_alpha = sort_alpha if sort_alpha is not None else shared.opts.tagger_sort_alpha + use_spaces = use_spaces if use_spaces is not None else shared.opts.tagger_use_spaces + escape_brackets = escape_brackets if escape_brackets is not None else shared.opts.tagger_escape_brackets - debug_log(f'WD14 predict: general_threshold={general_threshold} character_threshold={character_threshold} max_tags={max_tags} include_rating={include_rating} sort_alpha={sort_alpha}') + debug_log(f'WaifuDiffusion predict: general_threshold={general_threshold} character_threshold={character_threshold} max_tags={max_tags} include_rating={include_rating} sort_alpha={sort_alpha}') # Handle input variations if isinstance(image, list): @@ -248,7 +240,7 @@ class WD14Tagger: if isinstance(image, dict) and 'name' in image: image = Image.open(image['name']) if image is None: - shared.log.error('WD14 predict: no image provided') + shared.log.error('WaifuDiffusion predict: no image provided') return '' # Load model if needed @@ -265,13 +257,13 @@ class WD14Tagger: output_name = self.session.get_outputs()[0].name probs = self.session.run([output_name], {input_name: img_input})[0][0] infer_time = time.time() - t_infer - debug_log(f'WD14 predict: inference time={infer_time:.3f}s output_shape={probs.shape}') + debug_log(f'WaifuDiffusion predict: inference time={infer_time:.3f}s output_shape={probs.shape}') # Build tag list with probabilities tag_probs = {} exclude_set = {x.strip().replace(' ', '_').lower() for x in exclude_tags.split(',') if x.strip()} if exclude_set: - debug_log(f'WD14 predict: exclude_tags={exclude_set}') + debug_log(f'WaifuDiffusion predict: exclude_tags={exclude_set}') general_count = 0 character_count = 0 @@ -305,7 +297,7 @@ class WD14Tagger: if prob >= general_threshold: tag_probs[tag_name] = float(prob) - debug_log(f'WD14 predict: matched tags general={general_count} character={character_count} rating={rating_count} total={len(tag_probs)}') + debug_log(f'WaifuDiffusion predict: matched tags general={general_count} character={character_count} rating={rating_count} total={len(tag_probs)}') # Sort tags if sort_alpha: @@ -316,7 +308,7 @@ class WD14Tagger: # Limit number of tags if max_tags > 0 and len(sorted_tags) > max_tags: sorted_tags = sorted_tags[:max_tags] - debug_log(f'WD14 predict: limited to max_tags={max_tags}') + debug_log(f'WaifuDiffusion predict: limited to max_tags={max_tags}') # Format output result = [] @@ -332,7 +324,7 @@ class WD14Tagger: output = ", ".join(result) total_time = time.time() - t0 - debug_log(f'WD14 predict: complete tags={len(result)} time={total_time:.2f}s result="{output[:100]}..."' if len(output) > 100 else f'WD14 predict: complete tags={len(result)} time={total_time:.2f}s result="{output}"') + debug_log(f'WaifuDiffusion predict: complete tags={len(result)} time={total_time:.2f} result="{output[:100]}..."' if len(output) > 100 else f'WaifuDiffusion predict: complete tags={len(result)} time={total_time:.2f} result="{output}"') return output @@ -342,12 +334,37 @@ class WD14Tagger: # Global tagger instance -tagger = WD14Tagger() +tagger = WaifuDiffusionTagger() + + +def _save_tags_to_file(img_path, tags_str: str, save_append: bool) -> bool: + """Save tags to a text file with error handling. + + Args: + img_path: Path to the image file + tags_str: Tags string to save + save_append: If True, append to existing file; otherwise overwrite + + Returns: + True if save succeeded, False otherwise + """ + try: + txt_path = img_path.with_suffix('.txt') + if save_append and txt_path.exists(): + with open(txt_path, 'a', encoding='utf-8') as f: + f.write(f', {tags_str}') + else: + with open(txt_path, 'w', encoding='utf-8') as f: + f.write(tags_str) + return True + except Exception as e: + shared.log.error(f'WaifuDiffusion batch: failed to save file="{img_path}" error={e}') + return False def get_models() -> list: - """Return list of available WD14 model names.""" - return list(WD14_MODELS.keys()) + """Return list of available WaifuDiffusion model names.""" + return list(WAIFUDIFFUSION_MODELS.keys()) def refresh_models() -> list: @@ -358,17 +375,17 @@ def refresh_models() -> list: def load_model(model_name: str = None) -> bool: - """Load the specified WD14 model.""" + """Load the specified WaifuDiffusion model.""" return tagger.load(model_name) def unload_model(): - """Unload the current WD14 model.""" + """Unload the current WaifuDiffusion model.""" tagger.unload() def tag(image: Image.Image, model_name: str = None, **kwargs) -> str: - """Tag an image using WD14 tagger. + """Tag an image using WaifuDiffusion tagger. Args: image: PIL Image to tag @@ -379,21 +396,21 @@ def tag(image: Image.Image, model_name: str = None, **kwargs) -> str: Formatted tag string """ t0 = time.time() - jobid = shared.state.begin('WD14 Tag') - shared.log.info(f'WD14: model="{model_name or tagger.model_name or shared.opts.wd14_model}" image_size={image.size if image else None}') + jobid = shared.state.begin('WaifuDiffusion Tag') + shared.log.info(f'WaifuDiffusion: model="{model_name or tagger.model_name or shared.opts.waifudiffusion_model}" image_size={image.size if image else None}') try: if model_name and model_name != tagger.model_name: tagger.load(model_name) result = tagger.predict(image, **kwargs) - shared.log.debug(f'WD14: complete time={time.time()-t0:.2f}s tags={len(result.split(", ")) if result else 0}') + shared.log.debug(f'WaifuDiffusion: complete time={time.time()-t0:.2f} tags={len(result.split(", ")) if result else 0}') # Offload model if setting enabled if shared.opts.interrogate_offload: tagger.unload() except Exception as e: result = f"Exception {type(e)}" - shared.log.error(f'WD14: {e}') - errors.display(e, 'WD14 Tag') + shared.log.error(f'WaifuDiffusion: {e}') + errors.display(e, 'WaifuDiffusion Tag') shared.state.end(jobid) return result @@ -485,19 +502,19 @@ def batch( image_files = unique_files if not image_files: - shared.log.warning('WD14 batch: no images found') + shared.log.warning('WaifuDiffusion batch: no images found') return '' t0 = time.time() - jobid = shared.state.begin('WD14 Batch') - shared.log.info(f'WD14 batch: model="{tagger.model_name}" images={len(image_files)} write={save_output} append={save_append} recursive={recursive}') - debug_log(f'WD14 batch: files={[str(f) for f in image_files[:5]]}{"..." if len(image_files) > 5 else ""}') + jobid = shared.state.begin('WaifuDiffusion Batch') + shared.log.info(f'WaifuDiffusion batch: model="{tagger.model_name}" images={len(image_files)} write={save_output} append={save_append} recursive={recursive}') + debug_log(f'WaifuDiffusion batch: files={[str(f) for f in image_files[:5]]}{"..." if len(image_files) > 5 else ""}') results = [] # Progress bar import rich.progress as rp - pbar = rp.Progress(rp.TextColumn('[cyan]WD14:'), rp.BarColumn(), rp.MofNCompleteColumn(), rp.TaskProgressColumn(), rp.TimeRemainingColumn(), rp.TimeElapsedColumn(), rp.TextColumn('[cyan]{task.description}'), console=shared.console) + pbar = rp.Progress(rp.TextColumn('[cyan]WaifuDiffusion:'), rp.BarColumn(), rp.MofNCompleteColumn(), rp.TaskProgressColumn(), rp.TimeRemainingColumn(), rp.TimeElapsedColumn(), rp.TextColumn('[cyan]{task.description}'), console=shared.console) with pbar: task = pbar.add_task(total=len(image_files), description='starting...') @@ -505,31 +522,23 @@ def batch( pbar.update(task, advance=1, description=str(img_path.name)) try: if shared.state.interrupted: - shared.log.info('WD14 batch: interrupted') + shared.log.info('WaifuDiffusion batch: interrupted') break image = Image.open(img_path) tags_str = tagger.predict(image, **kwargs) if save_output: - txt_path = img_path.with_suffix('.txt') - if save_append and txt_path.exists(): - with open(txt_path, 'a', encoding='utf-8') as f: - f.write(f', {tags_str}') - debug_log(f'WD14 batch: appended to "{txt_path}"') - else: - with open(txt_path, 'w', encoding='utf-8') as f: - f.write(tags_str) - debug_log(f'WD14 batch: wrote to "{txt_path}"') + _save_tags_to_file(img_path, tags_str, save_append) results.append(f'{img_path.name}: {tags_str[:100]}...' if len(tags_str) > 100 else f'{img_path.name}: {tags_str}') except Exception as e: - shared.log.error(f'WD14 batch: file="{img_path}" error={e}') + shared.log.error(f'WaifuDiffusion batch: file="{img_path}" error={e}') results.append(f'{img_path.name}: ERROR - {e}') elapsed = time.time() - t0 - shared.log.info(f'WD14 batch: complete images={len(results)} time={elapsed:.1f}s') + shared.log.info(f'WaifuDiffusion batch: complete images={len(results)} time={elapsed:.1f}s') shared.state.end(jobid) return '\n'.join(results) diff --git a/modules/shared.py b/modules/shared.py index b143d65aa..2a6cd5ac0 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -774,8 +774,8 @@ options_templates.update(options_section(('hidden_options', "Hidden options"), { "tagger_use_spaces": OptionInfo(False, "Tagger: use spaces for tags", gr.Checkbox, {"visible": False}), "tagger_escape_brackets": OptionInfo(True, "Tagger: escape brackets", gr.Checkbox, {"visible": False}), "tagger_exclude_tags": OptionInfo("", "Tagger: exclude tags", gr.Textbox, {"visible": False}), - "wd14_model": OptionInfo("wd-eva02-large-tagger-v3", "WD14: default model", gr.Dropdown, {"choices": [], "visible": False}), - "wd14_character_threshold": OptionInfo(0.85, "WD14: character tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01, "visible": False}), + "waifudiffusion_model": OptionInfo("wd-eva02-large-tagger-v3", "WaifuDiffusion: default model", gr.Dropdown, {"choices": [], "visible": False}), + "waifudiffusion_character_threshold": OptionInfo(0.85, "WaifuDiffusion: character tag threshold", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01, "visible": False}), # control settings are handled separately "control_hires": OptionInfo(False, "Hires use Control", gr.Checkbox, {"visible": False}), diff --git a/modules/ui_caption.py b/modules/ui_caption.py index 4821be690..5ab4d74b7 100644 --- a/modules/ui_caption.py +++ b/modules/ui_caption.py @@ -98,9 +98,9 @@ def update_tagger_ui(model_name): def update_tagger_params(model_name, general_threshold, character_threshold, include_rating, max_tags, sort_alpha, use_spaces, escape_brackets, exclude_tags, show_scores): """Save all tagger parameters to shared.opts when UI controls change.""" - shared.opts.wd14_model = model_name + shared.opts.waifudiffusion_model = model_name shared.opts.tagger_threshold = float(general_threshold) - shared.opts.wd14_character_threshold = float(character_threshold) + shared.opts.waifudiffusion_character_threshold = float(character_threshold) shared.opts.tagger_include_rating = bool(include_rating) shared.opts.tagger_max_tags = int(max_tags) shared.opts.tagger_sort_alpha = bool(sort_alpha) @@ -250,7 +250,7 @@ def create_ui(): with gr.Tab("Tagger", elem_id='tab_tagger'): from modules.interrogate import tagger with gr.Row(): - wd_model = gr.Dropdown(tagger.get_models(), value=shared.opts.wd14_model, label='Tagger Model', elem_id='wd_model') + wd_model = gr.Dropdown(tagger.get_models(), value=shared.opts.waifudiffusion_model, label='Tagger Model', elem_id='wd_model') ui_common.create_refresh_button(wd_model, tagger.refresh_models, lambda: {"choices": tagger.get_models()}, 'wd_models_refresh') with gr.Row(): wd_load_btn = gr.Button(value='Load', elem_id='wd_load', variant='secondary') @@ -258,7 +258,7 @@ def create_ui(): with gr.Accordion(label='Tagger: Advanced Options', open=True, visible=True): with gr.Row(): wd_general_threshold = gr.Slider(label='General threshold', value=shared.opts.tagger_threshold, minimum=0.0, maximum=1.0, step=0.01, elem_id='wd_general_threshold') - wd_character_threshold = gr.Slider(label='Character threshold', value=shared.opts.wd14_character_threshold, minimum=0.0, maximum=1.0, step=0.01, elem_id='wd_character_threshold') + wd_character_threshold = gr.Slider(label='Character threshold', value=shared.opts.waifudiffusion_character_threshold, minimum=0.0, maximum=1.0, step=0.01, elem_id='wd_character_threshold') with gr.Row(): wd_max_tags = gr.Slider(label='Max tags', value=shared.opts.tagger_max_tags, minimum=1, maximum=512, step=1, elem_id='wd_max_tags') wd_include_rating = gr.Checkbox(label='Include rating', value=shared.opts.tagger_include_rating, elem_id='wd_include_rating') From 26c679f9e74ca8f899cd5ac8a8194283ac8303d3 Mon Sep 17 00:00:00 2001 From: CalamitousFelicitousness Date: Wed, 21 Jan 2026 10:51:40 +0000 Subject: [PATCH 7/7] refactor(caption): remove unused _device tracking property --- cli/test-tagger.py | 12 ++++++------ modules/interrogate/deepbooru.py | 5 ----- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/cli/test-tagger.py b/cli/test-tagger.py index 9c5f858bf..2a41b6ee6 100644 --- a/cli/test-tagger.py +++ b/cli/test-tagger.py @@ -271,19 +271,19 @@ class TaggerTest: print(f" Baseline: GPU={baseline['gpu_allocated']:.1f}MB, RAM={baseline['ram_used']:.1f}MB") # Test 1: Check initial state (should be on CPU after load) - initial_device = deepbooru.model._device + initial_device = next(deepbooru.model.model.parameters()).device print(f" Initial device: {initial_device}") - if initial_device == devices.cpu: + if initial_device.type == 'cpu': self.log_pass("DeepBooru: initial state on CPU") else: self.log_pass(f"DeepBooru: initial state on {initial_device}") # Test 2: Move to GPU (start) deepbooru.model.start() - gpu_device = deepbooru.model._device + gpu_device = next(deepbooru.model.model.parameters()).device after_gpu = self.get_memory_stats() print(f" After start(): {gpu_device} | GPU={after_gpu['gpu_allocated']:.1f}MB (+{after_gpu['gpu_allocated']-baseline['gpu_allocated']:.1f}MB)") - if gpu_device == devices.device: + if gpu_device.type == devices.device.type: self.log_pass(f"DeepBooru: moved to GPU ({gpu_device})") else: self.log_fail(f"DeepBooru: failed to move to GPU, got {gpu_device}") @@ -306,9 +306,9 @@ class TaggerTest: if torch.cuda.is_available(): torch.cuda.empty_cache() after_offload = self.get_memory_stats() - cpu_device = deepbooru.model._device + cpu_device = next(deepbooru.model.model.parameters()).device print(f" After stop(): {cpu_device} | GPU={after_offload['gpu_allocated']:.1f}MB, RAM={after_offload['ram_used']:.1f}MB") - if cpu_device == devices.cpu: + if cpu_device.type == 'cpu': self.log_pass("DeepBooru: offloaded to CPU") else: self.log_fail(f"DeepBooru: failed to offload, still on {cpu_device}") diff --git a/modules/interrogate/deepbooru.py b/modules/interrogate/deepbooru.py index 88f0ab44f..d7bd4ea4f 100644 --- a/modules/interrogate/deepbooru.py +++ b/modules/interrogate/deepbooru.py @@ -13,7 +13,6 @@ load_lock = threading.Lock() class DeepDanbooru: def __init__(self): self.model = None - self._device = devices.cpu def load(self): with load_lock: @@ -33,17 +32,14 @@ class DeepDanbooru: self.model.load_state_dict(torch.load(files[0], map_location="cpu")) self.model.eval() self.model.to(devices.cpu, devices.dtype) - self._device = devices.cpu def start(self): self.load() self.model.to(devices.device) - self._device = devices.device def stop(self): if shared.opts.interrogate_offload: self.model.to(devices.cpu) - self._device = devices.cpu devices.torch_gc() def tag(self, pil_image, **kwargs): @@ -175,7 +171,6 @@ def unload_model(): if model.model is not None: shared.log.debug('DeepBooru unload') model.model = None - model._device = devices.cpu devices.torch_gc(force=True)