""" Diffusion Pipeline Service Orchestrates the complete image enhancement pipeline: 1. Load and preprocess input image 2. Run Stable Diffusion img2img with optional LoRA 3. Apply upscaling 4. Apply post-processing effects """ import logging from pathlib import Path from typing import Optional, Callable import torch from PIL import Image from diffusers import ( StableDiffusionImg2ImgPipeline, AutoencoderKL, DPMSolverMultistepScheduler ) from backend.config import settings, get_lora_path from backend.services.upscaler import upscale_image from backend.services.postprocess import postprocess_image logger = logging.getLogger(__name__) class DiffusionPipelineManager: """ Manages the Stable Diffusion pipeline for image enhancement This class handles lazy loading of models and provides a clean interface for image-to-image enhancement. """ def __init__(self): self.pipe = None self.lora_loaded = False self.device = "cuda" if torch.cuda.is_available() else "cpu" logger.info(f"Pipeline will use device: {self.device}") logger.info(f"CUDA available: {torch.cuda.is_available()}") if torch.cuda.is_available(): logger.info(f"CUDA device: {torch.cuda.get_device_name(0)}") def load_pipeline(self): """ Load the Stable Diffusion img2img pipeline with optimizations This method initializes the Stable Diffusion pipeline with: - Mixed precision for faster inference - Memory-efficient attention - Optional VAE for better image quality """ if self.pipe is not None: return logger.info("Loading Stable Diffusion 1.5 pipeline...") try: # Determine dtype based on device dtype = torch.float32 # Always use float32 for CPU compatibility logger.info(f"Loading model with dtype: {dtype}, device: {self.device}") # Initialize the pipeline (SD 1.5) - let diffusers handle all components self.pipe = StableDiffusionImg2ImgPipeline.from_pretrained( settings.BASE_MODEL, torch_dtype=dtype, safety_checker=None, requires_safety_checker=False, low_cpu_mem_usage=False # Load everything properly ) # Optimize scheduler self.pipe.scheduler = DPMSolverMultistepScheduler.from_config( self.pipe.scheduler.config ) # Move to device self.pipe = self.pipe.to(self.device) # Enable memory optimizations if self.device == "cuda": if settings.ENABLE_ATTENTION_SLICING: self.pipe.enable_attention_slicing() logger.info("Enabled attention slicing") if settings.ENABLE_VAE_SLICING: self.pipe.enable_vae_slicing() logger.info("Enabled VAE slicing") logger.info("Pipeline loaded successfully") logger.info(f"Model: {settings.BASE_MODEL}") logger.info(f"Device: {self.device}") except Exception as e: logger.error(f"Failed to load pipeline: {e}", exc_info=True) raise def load_lora(self, lora_path: Path): """ Load a custom LoRA adapter into the pipeline Args: lora_path: Path to the LoRA weights file (.safetensors) """ if self.pipe is None: self.load_pipeline() try: logger.info(f"Loading LoRA from: {lora_path}") self.pipe.load_lora_weights(str(lora_path.parent), weight_name=lora_path.name) self.lora_loaded = True logger.info("LoRA loaded successfully") except Exception as e: logger.warning(f"Failed to load LoRA (continuing without it): {e}") self.lora_loaded = False def enhance( self, image: Image.Image, prompt: str, negative_prompt: str, strength: float, guidance_scale: float, num_inference_steps: int = 30, progress_callback: Optional[Callable[[int, str], None]] = None ) -> Image.Image: """ Run image-to-image enhancement Args: image: Input PIL Image prompt: Positive prompt negative_prompt: Negative prompt strength: Denoising strength (0-1) guidance_scale: CFG scale num_inference_steps: Number of diffusion steps progress_callback: Optional callback for progress updates Returns: Enhanced PIL Image """ if self.pipe is None: if progress_callback: progress_callback(30, "Loading AI model...") self.load_pipeline() if progress_callback: progress_callback(40, "Preprocessing image...") logger.info(f"Running enhancement - Strength: {strength}, Guidance: {guidance_scale}") # Ensure image is in RGB mode if image.mode != "RGB": image = image.convert("RGB") # Resize if too large max_size = settings.MAX_IMAGE_SIZE if max(image.size) > max_size: ratio = max_size / max(image.size) new_size = tuple(int(dim * ratio) for dim in image.size) image = image.resize(new_size, Image.LANCZOS) logger.info(f"Resized input to: {new_size}") if progress_callback: progress_callback(50, f"Starting generation ({num_inference_steps} steps)...") # Callback to track diffusion progress def step_callback(pipe, step_index, timestep, callback_kwargs): if progress_callback: # Calculate progress: 50-85% range mapped to steps progress = 50 + int((step_index / num_inference_steps) * 35) progress_callback(progress, f"Step {step_index+1}/{num_inference_steps}") return callback_kwargs # Run inference with torch.inference_mode(): result = self.pipe( prompt=prompt, negative_prompt=negative_prompt, image=image, strength=strength, guidance_scale=guidance_scale, num_inference_steps=num_inference_steps, callback_on_step_end=step_callback if progress_callback else None ).images[0] if progress_callback: progress_callback(85, "Finalizing...") return result def unload(self): """ Unload the pipeline to free memory """ if self.pipe is not None: del self.pipe self.pipe = None self.lora_loaded = False torch.cuda.empty_cache() logger.info("Pipeline unloaded") # Global pipeline instance (singleton pattern) _pipeline_manager = None def get_pipeline_manager() -> DiffusionPipelineManager: """ Get or create the global pipeline manager instance Returns: DiffusionPipelineManager instance """ global _pipeline_manager if _pipeline_manager is None: _pipeline_manager = DiffusionPipelineManager() return _pipeline_manager def preprocess_image(image_path: Path) -> Image.Image: """ Load and preprocess an input image This function: - Loads the image - Converts to RGB - Normalizes exposure/gamma if needed Args: image_path: Path to input image Returns: Preprocessed PIL Image """ logger.info(f"Loading image from: {image_path}") image = Image.open(image_path) # Convert to RGB if necessary if image.mode != "RGB": image = image.convert("RGB") # Optional: Add gamma/exposure normalization here # This would analyze the histogram and adjust brightness return image def enhance_image( input_image_path: Path, output_path: Path, strength: float = settings.IMG2IMG_STRENGTH, guidance_scale: float = settings.GUIDANCE_SCALE, custom_prompt: Optional[str] = None, use_upscaler: bool = True, use_postprocess: bool = True, progress_callback: Optional[Callable[[int, str], None]] = None ) -> Path: """ Complete image enhancement pipeline This is the main entry point for enhancing images. It orchestrates: 1. Image preprocessing 2. Stable Diffusion img2img with optional LoRA 3. Upscaling (optional) 4. Post-processing (optional) Args: input_image_path: Path to input image output_path: Path to save enhanced image strength: Image-to-image strength guidance_scale: CFG scale custom_prompt: Optional custom prompt use_upscaler: Whether to apply upscaling use_postprocess: Whether to apply post-processing progress_callback: Optional callback for progress updates Returns: Path to the saved enhanced image """ try: # Step 1: Load and preprocess if progress_callback: progress_callback(25, "Loading and preprocessing image...") logger.info("Step 1/4: Preprocessing image") image = preprocess_image(input_image_path) # Step 2: Get pipeline and load LoRA if available logger.info("Step 2/4: Running diffusion model") pipeline = get_pipeline_manager() lora_path = get_lora_path() if lora_path and not pipeline.lora_loaded: pipeline.load_lora(lora_path) # Determine prompt prompt = custom_prompt if custom_prompt else settings.DEFAULT_PROMPT # Run enhancement enhanced = pipeline.enhance( image=image, prompt=prompt, negative_prompt=settings.NEGATIVE_PROMPT, strength=strength, guidance_scale=guidance_scale, num_inference_steps=settings.NUM_INFERENCE_STEPS, progress_callback=progress_callback ) # Step 3: Upscaling if use_upscaler: if progress_callback: progress_callback(92, "Upscaling image...") logger.info("Step 3/4: Upscaling image") enhanced = upscale_image(enhanced, scale=settings.UPSCALE_FACTOR) else: logger.info("Step 3/4: Skipping upscaling") # Step 4: Post-processing if use_postprocess: if progress_callback: progress_callback(94, "Applying final touches...") logger.info("Step 4/4: Applying post-processing") enhanced = postprocess_image(enhanced) else: logger.info("Step 4/4: Skipping post-processing") # Save result enhanced.save(output_path, quality=95, optimize=True) logger.info(f"Enhanced image saved to: {output_path}") return output_path except Exception as e: logger.error(f"Enhancement pipeline failed: {e}", exc_info=True) raise