diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b084f30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,105 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE / Editor specific files +.idea/ +.vscode/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# pytype static analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 8f65127..63787b6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -215,6 +215,38 @@ Refer to the provided Jupyter notebooks for complete examples and results: ![Example Output](imgs/example.png) +## 💻 Web UI Demo + +To make anomaly generation more interactive, we provide a Gradio-based web interface. + +**Installation:** +```bash +pip install gradio +pip install huggingface_hub==0.24.5 +``` + +**How to Launch:** +```bash +python web_demo.py +``` + +This will start a local web server. Open the provided URL in your browser to access the UI. + +**Features:** +- **Interactive Prompting:** Dynamically create and preview prompts using templates. +- **Real-time Generation:** Upload a reference image and generate anomaly variations on the fly. +- **Flexible Masking:** Use your own mask or let the UI generate a random one for you. +- **Advanced Controls:** Easily tweak parameters like guidance strength, attention scaling, and random seeds. +- **Instant Visualization:** View the final image, attention maps, and generation metadata all in one place. + +*We recommend preparing an image like `imgs/web_ui_demo.png` to showcase your UI.* + +
+Web UI Demo screenshot + +![Web UI Demo](imgs/web_ui_demo.png) +
+ ## 🛠️ Todo List - [ ] Colab demo. - [ ] HuggingFace demo. diff --git a/docs/imgs/web_ui_demo.png b/docs/imgs/web_ui_demo.png new file mode 100644 index 0000000..d4dc02b Binary files /dev/null and b/docs/imgs/web_ui_demo.png differ diff --git a/env.yml b/env.yml index 839fe2e..b951c17 100644 --- a/env.yml +++ b/env.yml @@ -141,7 +141,7 @@ dependencies: - bleach==6.1.0 - blobfile==2.1.1 - click==8.1.7 - - clip==1.0 + - clip==0.2.0 - colorama==0.4.6 - comm==0.2.2 - contourpy==1.2.1 @@ -201,7 +201,6 @@ dependencies: - matplotlib-inline==0.1.7 - mdurl==0.1.2 - mistune==3.0.2 - - mmcv==2.2.0 - mmengine==0.10.4 - model-index==0.1.11 - multidict==6.0.5 @@ -217,10 +216,10 @@ dependencies: - notebook-shim==0.2.4 - ogb==1.3.6 - open-clip-torch==2.10.1 + - open-clip==1.0.1 - opencv-python==4.8.1.78 - opendatalab==0.0.10 - openmim==0.3.9 - - openxlab==0.1.1 - ordered-set==4.1.0 - oss2==2.17.0 - outdated==0.2.2 @@ -252,7 +251,6 @@ dependencies: - pyzmq==26.0.3 - referencing==0.35.1 - regex==2023.6.3 - - requests==2.28.2 - rfc3339-validator==0.1.4 - rfc3986-validator==0.1.1 - rich==13.4.2 @@ -278,7 +276,6 @@ dependencies: - tokenizers==0.13.3 - tomli==2.0.1 - tornado==6.4.1 - - tqdm==4.65.2 - traitlets==5.14.3 - transformers==4.29.2 - types-python-dateutil==2.9.0.20240316 diff --git a/run.py b/run.py index c0fdca8..73f2b14 100644 --- a/run.py +++ b/run.py @@ -1,6 +1,5 @@ import pprint -from typing import List - +from typing import List, Optional, Dict, Tuple, Callable import pyrallis import torch from PIL import Image @@ -39,18 +38,33 @@ def get_indices_to_alter(stable, prompt: str) -> List[int]: return token_indices -def get_indices_to_alter_new(stable, prompt: str, tokens) -> List[int]: - token_idx_to_word = {idx: stable.tokenizer.decode(t) - for idx, t in enumerate(stable.tokenizer(prompt)['input_ids']) - if 0 < idx < len(stable.tokenizer(prompt)['input_ids']) - 1} +def get_indices_to_alter_new(stable, prompt: str, tokens_str: str) -> Tuple[List[int], Dict[int, str]]: + """ + Parses the prompt to get a map of token indices to words, + and identifies the indices to alter based on user input. + Returns the list of indices to alter and the full token map. + """ + token_ids = stable.tokenizer(prompt)['input_ids'] + token_idx_to_word = { + idx: stable.tokenizer.decode(t) + for idx, t in enumerate(token_ids) + if 0 < idx < len(token_ids) - 1 + } pprint.pprint(token_idx_to_word) - # token_indices = input("Please enter the a comma-separated list indices of the tokens you wish to " - # "alter (e.g., 2,5): ") - # pprint.pprint(token_indices) - token_indices = tokens - token_indices = [int(i) for i in token_indices.split(",")] - print(f"Altering tokens: {[token_idx_to_word[i] for i in token_indices]}") - return token_indices + + # The web UI passes a number, which we convert to a string. + if isinstance(tokens_str, (int, float)): + tokens_str = str(int(tokens_str)) + + try: + # Handle comma-separated strings for indices + token_indices = [int(i.strip()) for i in tokens_str.split(",") if i.strip()] + except (ValueError, AttributeError) as e: + print(f"Warning: Could not parse token indices '{tokens_str}'. Error: {e}. Defaulting to empty list.") + token_indices = [] + + print(f"Altering tokens: {[token_idx_to_word.get(i, '') for i in token_indices]}") + return token_indices, token_idx_to_word def run_on_prompt(prompt: List[str], @@ -150,13 +164,13 @@ def run_on_prompt_and_masked_image(prompt: List[str], mask_image: str, seed: torch.Generator, config: RunConfig, - normal_prompt, detailed_prompt, - - img_prompt = None, - abnormal_img = None, - clip_loss = None) -> Image.Image: + img_prompt=None, + abnormal_img=None, + clip_loss=None, + callback: Optional[Callable] = None, + callback_steps: Optional[int] = 1) -> Image.Image: if controller is not None: ptp_utils.register_attention_control(model, controller) outputs, image_latents = model(prompt=prompt, @@ -182,7 +196,9 @@ def run_on_prompt_and_masked_image(prompt: List[str], normal_prompt=normal_prompt, abnormal_img=abnormal_img, detailed_prompt=detailed_prompt, - clip_loss=clip_loss) + clip_loss=clip_loss, + callback=callback, + callback_steps=callback_steps) # image = outputs.images[0] # return image, image_latents return outputs[0], image_latents diff --git a/utils/vis_utils.py b/utils/vis_utils.py index 0813bb4..d3f4143 100644 --- a/utils/vis_utils.py +++ b/utils/vis_utils.py @@ -23,15 +23,20 @@ def show_cross_attention(prompt: str, attention_maps = aggregate_attention(attention_store, res, from_where, True, select).detach().cpu() images = [] - # show spatial attention for indices of tokens to strengthen + # show spatial attention for all tokens for i in range(len(tokens)): + if i == 0 or i == len(tokens) - 1: # Skip start and end tokens + continue image = attention_maps[:, :, i] + image = show_image_relevance(image, orig_image) + image = image.astype(np.uint8) + image = np.array(Image.fromarray(image).resize((res ** 2, res ** 2))) + token_text = decoder(int(tokens[i])) + # Add a visual indicator for altered tokens if i in indices_to_alter: - image = show_image_relevance(image, orig_image) - image = image.astype(np.uint8) - image = np.array(Image.fromarray(image).resize((res ** 2, res ** 2))) - image = ptp_utils.text_under_image(image, decoder(int(tokens[i]))) - images.append(image) + token_text = f"*{token_text}*" + image = ptp_utils.text_under_image(image, token_text) + images.append(image) ptp_utils.view_images(np.stack(images, axis=0)) # TODO diff --git a/web_demo.py b/web_demo.py new file mode 100644 index 0000000..29cd4fa --- /dev/null +++ b/web_demo.py @@ -0,0 +1,807 @@ +import gradio as gr +import torch +import traceback +import torch +from PIL import Image +import sys +import os +import time +from typing import List, Dict, Optional, Tuple +import json +from datetime import datetime + +import numpy as np +from scipy.ndimage import binary_dilation, binary_erosion +import cv2 + +# 添加系统路径 +sys.path.append(".") +sys.path.append("..") + +# 导入必要的模块 +from clip_pipeline_attend_and_excite import RelationalAttendAndExcitePipeline +from config import RunConfig +from run import run_on_prompt_and_masked_image, get_indices_to_alter_new +from utils.ptp_utils import AttentionStore +from utils import vis_utils + +# English Translations +localization_texts = { + "en": { + "title": "Anomaly Any", + "description": "This is a demo for generating anomaly images. You can specify the object type and anomaly description, and the model will generate an image with the specified anomaly.", + "step1_title": "Step 1: Define Prompts via Templates", + "type_label": "Type", + "type_placeholder": "e.g., hazelnut", + "normal_prompt_template_label": "Normal Prompt Template", + "normal_prompt_template_placeholder": "e.g., a photo of a {type}", + "prompt_template_label": "Anomaly Prompt Template", + "prompt_template_placeholder": "e.g., a photo of a {type} with a crack on it", + "detailed_prompt_template_label": "Detailed Prompt Template", + "detailed_prompt_template_placeholder": "e.g., a photo of a {type} with a large, jagged crack exposing the interior.", + "prompt_preview_header": "✨ Prompt Previews", + "normal_prompt_preview_label": "Normal Prompt Preview", + "anomaly_prompt_preview_label": "Anomaly Prompt Preview", + "detailed_prompt_preview_label": "Detailed Prompt Preview", + "step2_title": "Step 2: Upload Reference Image (Normal)", + "masking_options": "Optional: Masking", + "mask_image": "Mask Image (Optional)", + "mask_ratio": "Mask Size (%)", + "generate_mask": "Generate Random Mask", + "advanced_settings": "Optional: Advanced Settings", + "token_indices": "Token Indices", + "token_indices_placeholder": "e.g., 8 or 5,6,7", + "random_seed": "Random Seed", + "image_guidance_strength": "Image Guidance Strength", + "scale_factor": "Scale Factor for Attention", + "threshold_step": "Threshold at Step {}", + "max_iter": "Max Iterations to Alter", + "use_standard_sd": "Use Standard SD (for comparison)", + "generate_btn": "Generate Anomaly Image", + "output_results": "Output", + "generated_image": "Generated Image", + "attention_map": "Attention Map", + "generation_info": "Generation Info", + "quick_examples": "Quick Examples", + "generation_time": "Generation Time: {:.2f} seconds", + "used_prompt": "Used Prompt: {}", + "token_indices_info": "Token Indices: {}", + "error_prefix": "Error: {}", + "processing": "Processing...", + "ready": "Ready", + "model_loading": "Loading model...", + "model_loaded": "Model loaded successfully!", + "random_mask": "Random Mask Generation", + "mask_ratio": "Mask Size Ratio", + "generate_mask": "Generate Random Mask", + "mask_generated": "Random mask generated successfully!", + "token_attention_maps": "Token Attention Maps", + } +} + +# Global Variables +NUM_DIFFUSION_STEPS = 200 +GUIDANCE_SCALE = 12.5 +MAX_NUM_WORDS = 77 +device = torch.device('cuda:0') if torch.cuda.is_available() else torch.device('cpu') + +# Custom CSS Styles +custom_css = """ +/* Overall style optimization */ +.gradio-container { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; + max-width: 1400px !important; + margin: auto !important; +} + +/* Title style */ +h1 { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + font-weight: 800; + margin-bottom: 0.5em; +} + +/* Card style */ +.gr-group { + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; + background: rgba(255, 255, 255, 0.6); + backdrop-filter: blur(10px); + transition: all 0.3s ease; +} + +.gr-group:hover { + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); +} + +/* Button style */ +.gr-button-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; + border: none !important; + color: white !important; + font-weight: 600 !important; + padding: 12px 24px !important; + border-radius: 8px !important; + transition: all 0.3s ease !important; +} + +.gr-button-primary:hover { + transform: translateY(-2px) !important; + box-shadow: 0 8px 24px rgba(102, 126, 234, 0.4) !important; +} + +/* Input box style */ +.gr-textbox, .gr-number, .gr-slider { + border-radius: 8px !important; + border: 1px solid rgba(0, 0, 0, 0.1) !important; + transition: all 0.3s ease !important; +} + +.gr-textbox:focus, .gr-number:focus { + border-color: #667eea !important; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1) !important; +} + +/* Image upload area */ +.gr-image { + border-radius: 12px !important; + overflow: hidden !important; + border: 2px dashed rgba(0, 0, 0, 0.2) !important; + transition: all 0.3s ease !important; +} + +.gr-image:hover { + border-color: #667eea !important; +} + +/* Accordion style */ +.gr-accordion { + border-radius: 12px !important; + border: 1px solid rgba(0, 0, 0, 0.1) !important; + overflow: hidden !important; + margin-top: 16px !important; +} + +.gr-accordion-header { + background: rgba(255, 255, 255, 0.8) !important; + padding: 12px 16px !important; +} + +/* Progress bar style */ +.progress-bar { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; +} + +/* Label style */ +label { + font-weight: 600 !important; + color: #334155 !important; + margin-bottom: 8px !important; +} + +/* Example style */ +.gr-examples { + border-radius: 12px !important; + background: rgba(255, 255, 255, 0.6) !important; + padding: 16px !important; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .gr-group { + background: rgba(30, 30, 30, 0.6); + border-color: rgba(255, 255, 255, 0.1); + } + + label { + color: #e2e8f0 !important; + } +} + +/* Responsive design */ +@media (max-width: 768px) { + .gradio-container { + padding: 16px !important; + } + + .gr-group { + padding: 12px; + } +} + +/* Animation effect */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.gr-group, .gr-image, .gr-textbox { + animation: fadeIn 0.5s ease-out; +} + +#attention_gallery_centered { + margin-left: auto; + margin-right: auto; + max-width: 90%; /* Ensure it doesn't overflow small screens, adjust as needed */ +} +/* Center images within each gallery item */ +#attention_gallery_centered .thumbnail-item { + display: flex; + justify-content: center; /* Horizontally center the image within the item */ + align-items: center; /* Vertically center the image (optional, but good for consistency) */ +} +#attention_gallery_centered .thumbnail-item > img { + max-width: 100%; /* Ensure image does not overflow its container */ + max-height: 100%; + object-fit: contain; /* Already set in component, but good to be explicit */ +} + +/* Make seed input and button look more integrated */ +#seed_row .gr-number { + border-top-right-radius: 0px !important; + border-bottom-right-radius: 0px !important; + border-right-width: 0px !important; /* Remove right border */ +} +#seed_row button { + border-top-left-radius: 0px !important; + border-bottom-left-radius: 0px !important; + margin-left: -1px; /* Overlap slightly to merge borders */ +} +#seed_row .gr-number input { + text-align: left !important; /* Ensure number input text is left-aligned */ +} +""" + +def _generate_shape(width, height): + """Helper function to generate a random base shape.""" + mask = np.zeros((height, width), dtype=np.uint8) + shape_type = np.random.choice(['ellipse', 'polygon', 'line']) + + if shape_type == 'ellipse': + num_ellipses = np.random.randint(2, 5) + for _ in range(num_ellipses): + center_x, center_y = np.random.randint(0, width), np.random.randint(0, height) + is_elongated = np.random.rand() > 0.5 + if is_elongated: + axis_1, axis_2 = np.random.randint(width // 10, width // 2), np.random.randint(5, 20) + if np.random.rand() > 0.5: # Randomly swap axes + axis_1, axis_2 = axis_2, axis_1 + else: + axis_1, axis_2 = np.random.randint(width // 8, width // 4), np.random.randint(height // 8, height // 4) + angle = np.random.randint(0, 360) + cv2.ellipse(mask, (center_x, center_y), (axis_1, axis_2), angle, 0, 360, 255, -1) + + elif shape_type == 'polygon': + num_vertices = np.random.randint(4, 10) + vertices = np.random.randint(0, width, (num_vertices, 2)) + # Ensure vertices are ordered to form a non-self-intersecting polygon + center = np.mean(vertices, axis=0) + angles = np.arctan2(vertices[:, 1] - center[1], vertices[:, 0] - center[0]) + vertices = vertices[np.argsort(angles)] + cv2.fillPoly(mask, [vertices], 255) + + elif shape_type == 'line': + num_lines = np.random.randint(3, 8) + for _ in range(num_lines): + x1, y1 = np.random.randint(0, width), np.random.randint(0, height) + x2, y2 = np.random.randint(0, width), np.random.randint(0, height) + thickness = np.random.randint(5, 25) + cv2.line(mask, (x1, y1), (x2, y2), 255, thickness) + + return mask + +def generate_random_mask(mask_ratio_percent: float) -> Tuple[Image.Image, str]: + """ + Generates a random, complex mask with a precise area ratio. + """ + try: + mask_ratio = mask_ratio_percent / 100.0 + width, height = 512, 512 + + # 1. Generate a base shape using one of the random strategies + base_mask = _generate_shape(width, height) + + # 2. Ensure single connected component + num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(base_mask, 4, cv2.CV_32S) + if num_labels <= 1: return generate_random_mask(mask_ratio_percent) # Retry if empty + largest_label = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA]) + mask = np.where(labels == largest_label, 255, 0).astype(np.uint8) + + # 3. Precisely scale the mask to the target ratio + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if not contours: return generate_random_mask(mask_ratio_percent) # Retry + + cnt = max(contours, key=cv2.contourArea) + current_area = cv2.contourArea(cnt) + target_area = width * height * mask_ratio + + if current_area > 10: # Ensure base shape is not too small + scale_factor = np.sqrt(target_area / current_area) + M = cv2.moments(cnt) + cx = int(M['m10'] / M['m00']) if M['m00'] != 0 else width // 2 + cy = int(M['m01'] / M['m00']) if M['m00'] != 0 else height // 2 + + cnt_final = ((cnt - [cx, cy]) * scale_factor + [cx, cy]).astype(np.int32) + + # 4. Draw final mask and apply post-processing + final_mask = np.zeros((height, width), dtype=np.uint8) + cv2.drawContours(final_mask, [cnt_final], -1, 255, -1) + + # Add random noise/blur for more natural edges + kernel_size = np.random.randint(7, 21) // 2 * 2 + 1 + final_mask = cv2.GaussianBlur(final_mask, (kernel_size, kernel_size), 0) + _, final_mask = cv2.threshold(final_mask, np.random.randint(100, 150), 255, cv2.THRESH_BINARY) + + final_area = np.sum(final_mask > 0) + info_text = f"Mask generated successfully! Target: {mask_ratio_percent:.2f}%, Actual: {100 * final_area / (width * height):.2f}%" + + return Image.fromarray(final_mask, mode='L'), info_text + else: + return generate_random_mask(mask_ratio_percent) # Retry for better base shape + + except Exception as e: + return None, f"Error generating mask: {str(e)}" + +def load_examples(): + """Loads a hardcoded list of examples.""" + + base_dir = os.path.abspath(os.path.dirname(__file__)) + example_image_dir = os.path.join(base_dir, "example_image") + example_web_dir = os.path.join(base_dir, "example_web") + mask_dir = os.path.join(example_image_dir, "fg_mask") + default_normal_template = "a photo of a {type}" + + examples = [ + [ + "hazelnut", + default_normal_template, + "a photo of a {type} with a crack on it", + "a photo of a {type} with a large, jagged crack exposing the interior.", + 8, + os.path.join(example_image_dir, "000.png"), + os.path.join(mask_dir, "000.png"), + 14291 + ], + [ + "table", + default_normal_template, + "a photo of a {type} that is faded", + "a photo of a {type} with areas of discoloration or lightening due to prolonged sun exposure", + 8, + os.path.join(example_web_dir, "table.jpg"), + None, + 45678 + ], + [ + "road", + default_normal_template, + "a photo of a {type} with cracks", + "a photo of a {type} with visible fractures and deteriorating surface", + 7, + os.path.join(example_image_dir, "road.png"), + None, + 56789 + ] + ] + + # Verify that files exist to prevent Gradio from erroring on missing files + verified_examples = [] + for ex in examples: + image_path = ex[5] # Index adjusted for new normal_prompt_template + mask_path = ex[6] # Index adjusted + if image_path and os.path.exists(image_path): + if mask_path and not os.path.exists(mask_path): + print(f"Warning: Mask file not found for example {ex[0]}: {mask_path}. Setting mask to None.") + ex[6] = None # Index adjusted + verified_examples.append(ex) + else: + print(f"Warning: Image file not found for example {ex[0]}: {image_path}. Skipping example.") + + return verified_examples + +def get_text(key: str) -> str: + """Get text from translation dictionary""" + return localization_texts["en"].get(key, key) + +# Initialize model +print("Loading model...") +stable = RelationalAttendAndExcitePipeline.from_pretrained( + "runwayml/stable-diffusion-v1-5", + safety_checker=None +).to(device) +tokenizer = stable.tokenizer +print("Model loaded successfully!") + +def run_and_display(prompts: List[str], + controller: AttentionStore, + indices_to_alter: List[int], + init_image: Optional[Image.Image], + init_image_guidance_scale: float, + mask_image: Optional[str], + generator: torch.Generator, + run_standard_sd: bool = False, + scale_factor: int = 20, + thresholds: Dict[int, float] = {0: 0.05, 10: 0.5, 20: 0.8}, + max_iter_to_alter: int = 25, + normal_prompt: str = "", + detailed_prompt: str = "", + progress: gr.Progress = None) -> Tuple[Image.Image, torch.Tensor]: + """Run anomaly generation pipeline""" + + # Progress callback + def progress_callback(step, timestep, latents): + if progress is not None: + progress(step / NUM_DIFFUSION_STEPS, desc=f"Diffusion Step {step}/{NUM_DIFFUSION_STEPS}") + + config = RunConfig( + prompt=prompts[0], + run_standard_sd=run_standard_sd, + scale_factor=scale_factor, + thresholds=thresholds, + max_iter_to_alter=max_iter_to_alter, + n_inference_steps=NUM_DIFFUSION_STEPS, + guidance_scale=GUIDANCE_SCALE + ) + + image, image_latent = run_on_prompt_and_masked_image( + model=stable, + prompt=prompts, + controller=controller, + token_indices=indices_to_alter, + init_image=init_image, + init_image_guidance_scale=init_image_guidance_scale, + mask_image=mask_image, + seed=generator, + config=config, + normal_prompt=normal_prompt, + detailed_prompt=detailed_prompt, + callback=progress_callback, + callback_steps=1 + ) + + return image, image_latent + +def generate_anomaly_image( + type_str: str, + normal_prompt_template: str, + prompt_template: str, + detailed_prompt_template: str, + token_indices_str: str, + normal_image: Image.Image, + mask_image: Optional[Image.Image], + seed: int, + init_image_guidance_scale: float, + scale_factor: int, + threshold_0: float, + threshold_10: float, + threshold_20: float, + max_iter_to_alter: int, + use_standard_sd: bool, + progress=gr.Progress() +) -> Tuple[Image.Image, Image.Image, str]: + """Generate anomaly image main function""" + + try: + progress(0, desc="Initializing...") + + # Build prompt + prompt = prompt_template.format(type=type_str) + normal_prompt = normal_prompt_template.format(type=type_str) + detailed_prompt = detailed_prompt_template.format(type=type_str) + + # Handle guidance image + if normal_image is None: + return None, None, "Error: Please upload a normal guidance image" + + # Resize image + normal_image = normal_image.convert("RGB") + normal_image.thumbnail((512, 512)) + + # Handle mask image + mask_path = None + if mask_image is not None: + mask_path = f"temp_mask_{datetime.now().timestamp()}.png" + mask_image = mask_image.convert("L") + mask_image.thumbnail((512, 512)) + mask_image.save(mask_path) + + progress(0.05, desc="Parsing Tokens...") + + # Get token indices + indices_to_alter, token_map = get_indices_to_alter_new(stable, prompt, str(token_indices_str)) # Ensure it's a string + + # Set random seed + generator = torch.Generator(device).manual_seed(seed) + + # Create controller + controller = AttentionStore() + + # Set thresholds + thresholds = { + 0: threshold_0, + 10: threshold_10, + 20: threshold_20 + } + + # Record start time + start_time = time.time() + + # Generate image + image, image_latent = run_and_display( + prompts=[prompt], + controller=controller, + indices_to_alter=indices_to_alter, + init_image=normal_image, + init_image_guidance_scale=init_image_guidance_scale, + mask_image=mask_path, + generator=generator, + run_standard_sd=use_standard_sd, + scale_factor=scale_factor, + thresholds=thresholds, + max_iter_to_alter=max_iter_to_alter, + normal_prompt=normal_prompt, + detailed_prompt=detailed_prompt, + progress=progress + ) + + progress(0.9, desc="Generating Attention Maps...") + + # Generate attention map + attention_images = vis_utils.show_cross_attention( + attention_store=controller, + prompt=prompt, + tokenizer=tokenizer, + res=16, + from_where=("up", "down", "mid"), + indices_to_alter=indices_to_alter, + orig_image=image + ) + + # Clean up temporary files + if mask_path and os.path.exists(mask_path): + os.remove(mask_path) + + # Calculate generation time + generation_time = time.time() - start_time + + progress(1.0, desc="Ready") + + # Format token map for display + token_map_str = "\n".join([f"{idx}: {word}" for idx, word in token_map.items()]) + + info_text = f""" +Generation Successful! +Generation Time: {generation_time:.2f} seconds +Used Prompt: {prompt} +Altered Token Indices: {token_indices_str} + +--- +**Token Map:** +{token_map_str} +""" + + return image, attention_images, info_text + + except Exception as e: + traceback.print_exc() + return None, None, f"Error: {str(e)}" + +def update_prompt_previews(type_str, normal_prompt_template, prompt_template, detailed_template): + """Dynamically update the prompt preview textboxes.""" + try: + if not type_str or not type_str.strip(): + raise ValueError("Type is empty") + normal_prompt = normal_prompt_template.format(type=type_str) + anomaly_prompt = prompt_template.format(type=type_str) + detailed_prompt = detailed_template.format(type=type_str) + except (KeyError, IndexError, ValueError) as e: + normal_prompt = "Waiting for valid type..." + anomaly_prompt = f"Error: {e}" + detailed_prompt = f"Error: {e}" + return normal_prompt, anomaly_prompt, detailed_prompt + +def build_ui(): + # --- Custom CSS --- + custom_css = """ + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap'); + .gradio-container { + font-family: 'Inter', sans-serif; + max-width: 1280px !important; + margin: auto !important; + } + .gr-button {white-space: nowrap;} + #refresh-seed-btn {max-width: 2.5em; min-width: 2.5em !important;} + .gr-group {border-radius: 10px !important; box-shadow: 0 2px 5px rgba(0,0,0,0.05) !important;} + h1, h3 {letter-spacing: -0.5px;} + """ + + default_type = "hazelnut" + default_normal_prompt_template = "a photo of a {type}" + default_prompt_template = "a photo of a {type} with a crack on it" + default_detailed_template = "a photo of a {type} with a large, jagged crack exposing the interior." + default_token_indices = "8" + default_mask_ratio = 5.0 # Corresponds to slider's initial value if it were 0-100, but it's 0.1-25 + + with gr.Blocks(css=custom_css, theme=gr.themes.Soft()) as demo: + gr.Markdown(f"# {get_text('title')}\n\n{get_text('description')}") + + with gr.Row(equal_height=False): + # --- Input Column --- + with gr.Column(scale=1): + with gr.Group(): + gr.Markdown(f"### {get_text('step1_title')}") + type_textbox = gr.Textbox( + label=get_text("type_label"), + placeholder=get_text("type_placeholder"), + value=default_type + ) + normal_prompt_template_textbox = gr.Textbox( + label=get_text("normal_prompt_template_label"), + placeholder=get_text("normal_prompt_template_placeholder"), + value=default_normal_prompt_template + ) + prompt_template_textbox = gr.Textbox( + label=get_text("prompt_template_label"), + placeholder=get_text("prompt_template_placeholder"), + value=default_prompt_template + ) + detailed_prompt_template_textbox = gr.Textbox( + label=get_text("detailed_prompt_template_label"), + placeholder=get_text("detailed_prompt_template_placeholder"), + value=default_detailed_template + ) + + with gr.Group(): + gr.Markdown(f"### {get_text('prompt_preview_header')}") + normal_prompt_preview_display = gr.Textbox(label=get_text('normal_prompt_preview_label'), interactive=False) + anomaly_prompt_preview_display = gr.Textbox(label=get_text('anomaly_prompt_preview_label'), interactive=False) + detailed_prompt_preview_display = gr.Textbox(label=get_text('detailed_prompt_preview_label'), interactive=False) + + with gr.Group(): + gr.Markdown(f"### {get_text('step2_title')}") + normal_image_input = gr.Image(label=get_text("normal_image"), type="pil") # Changed from normal_image to step2_title for label consistency + + with gr.Accordion(get_text("masking_options"), open=False): + mask_image_input = gr.Image(label=get_text("mask_image"), type="pil") + with gr.Row(): + mask_ratio_slider = gr.Slider(label=get_text("mask_ratio"), minimum=0.1, maximum=25.0, value=default_mask_ratio, step=0.1) + generate_mask_button = gr.Button(get_text("generate_mask"), variant="secondary", size="sm") + mask_info_display = gr.Textbox(label="Mask Info", visible=False, interactive=False) # Renamed from mask_info for clarity + + with gr.Accordion(get_text("advanced_settings"), open=False): + token_indices_textbox = gr.Number( + label=get_text("token_indices"), + value=int(default_token_indices) if default_token_indices else 0, # Convert default string to int + precision=0, # Integer input + step=1, # Increment/decrement by 1 + minimum=0 # Assuming token indices are non-negative + ) + with gr.Row(variant='compact'): + with gr.Row(elem_id="seed_row", variant='compact'): # Use compact variant + seed_number = gr.Number( + label=get_text("random_seed"), + value=np.random.randint(0, 2**32 - 1), + precision=0, + elem_id="seed_input", + scale=4 # Adjust scale + ) + random_seed_button = gr.Button("🎲", elem_id="random_seed_button_inline", scale=1, min_width=40) # Adjust scale and min_width + guidance_slider = gr.Slider(label=get_text("image_guidance_strength"), minimum=0.1, maximum=1.0, value=0.3, step=0.05) + scale_slider = gr.Slider(label=get_text("scale_factor"), minimum=10, maximum=100, value=20, step=5) + threshold_0_slider = gr.Slider(label=get_text("threshold_step").format(0), minimum=0.01, maximum=0.5, value=0.05, step=0.01) + threshold_10_slider = gr.Slider(label=get_text("threshold_step").format(10), minimum=0.1, maximum=0.9, value=0.5, step=0.05) + threshold_20_slider = gr.Slider(label=get_text("threshold_step").format(20), minimum=0.1, maximum=0.9, value=0.8, step=0.05) + max_iter_slider = gr.Slider(label=get_text("max_iter"), minimum=5, maximum=50, value=25, step=5) + standard_sd_checkbox = gr.Checkbox(label=get_text("use_standard_sd"), value=False) + + with gr.Row(): + clear_button = gr.Button("Clear") + generate_button = gr.Button(get_text("generate_btn"), variant="primary", scale=2) + + # --- Output Column --- + with gr.Column(scale=1): + gr.Markdown(f"### {get_text('output_results')}") + main_anomaly_image_display = gr.Image(label=get_text("generated_image"), type="pil", interactive=False) + attention_map_gallery = gr.Gallery( + label=get_text("token_attention_maps"), + columns=2, + object_fit="contain", + height=450, + elem_id="attention_gallery_centered" # For CSS centering + ) + info_textbox_display = gr.Textbox(label=get_text("generation_info"), lines=5, interactive=False) # Renamed + + gr.Markdown(f"### {get_text('quick_examples')}") + + # Define component lists for clarity and reusability + all_template_inputs = [type_textbox, normal_prompt_template_textbox, prompt_template_textbox, detailed_prompt_template_textbox] + preview_outputs = [normal_prompt_preview_display, anomaly_prompt_preview_display, detailed_prompt_preview_display] + + main_inputs = all_template_inputs + [ + token_indices_textbox, normal_image_input, mask_image_input, seed_number + ] + adv_inputs = [ + guidance_slider, scale_slider, threshold_0_slider, threshold_10_slider, + threshold_20_slider, max_iter_slider, standard_sd_checkbox + ] + generation_outputs = [main_anomaly_image_display, attention_map_gallery, info_textbox_display] + + example_loader_inputs = all_template_inputs + [token_indices_textbox, normal_image_input, mask_image_input, seed_number] + + gr.Examples( + examples=load_examples(), + inputs=example_loader_inputs, + label=None, + examples_per_page=5 + ) + + # --- Event Handlers --- + for t_input in all_template_inputs: + t_input.change(update_prompt_previews, inputs=all_template_inputs, outputs=preview_outputs, queue=False) + + demo.load(update_prompt_previews, inputs=all_template_inputs, outputs=preview_outputs, queue=False) + + generate_button.click( + fn=generate_anomaly_image, + inputs=main_inputs + adv_inputs, + outputs=generation_outputs, + api_name="generate" + ) + + def clear_all_fn(): + new_seed = np.random.randint(0, 2**32 - 1) + initial_normal_preview, initial_anomaly_preview, initial_detailed_preview = update_prompt_previews( + default_type, default_normal_prompt_template, default_prompt_template, default_detailed_template + ) + return ( + default_type, default_normal_prompt_template, default_prompt_template, default_detailed_template, + default_token_indices, None, None, default_mask_ratio, new_seed, # Main inputs (type, templates, token, images, mask_ratio, seed) + None, None, "", # Output fields (output_image, attention_map, info_textbox) + "", # mask_info_display + initial_normal_preview, initial_anomaly_preview, initial_detailed_preview # Preview fields + ) + + components_to_clear = main_inputs[:-1] + [mask_ratio_slider] + [main_inputs[-1]] + generation_outputs + [mask_info_display] + preview_outputs + # main_inputs includes seed_number at the end. mask_ratio_slider is separate. + # Order for clear_all_fn outputs: + # type_textbox, normal_prompt_template_textbox, prompt_template_textbox, detailed_prompt_template_textbox, + # token_indices_textbox, normal_image_input, mask_image_input, mask_ratio_slider, seed_number, + # output_image_display, attention_map_gallery, info_textbox_display, + # mask_info_display, + # normal_prompt_preview_display, anomaly_prompt_preview_display, detailed_prompt_preview_display + + clear_button.click( + fn=clear_all_fn, + inputs=None, + outputs=components_to_clear + ) + + random_seed_button.click(lambda: gr.update(value=np.random.randint(0, 2**32 - 1)), outputs=seed_number) + generate_mask_button.click(generate_random_mask, inputs=[mask_ratio_slider], outputs=[mask_image_input, mask_info_display]) + + return demo + +if __name__ == "__main__": + torch.cuda.empty_cache() + + app_demo = build_ui() + app_demo.queue(max_size=3) + app_demo.launch( + server_name="0.0.0.0", + server_port=7860, + share=True, + debug=False, + show_error=True, + favicon_path=None + ) \ No newline at end of file