""" Naari Studio VTON - Preprocessing Module ========================================= Shared image preprocessing utilities for all engines. v3.1.0 - Enhanced with background artifact fixes Author: AnahataSri (Naari Studio) License: MIT """ import logging from typing import Tuple, Optional from PIL import Image, ImageStat, ImageFilter import numpy as np from config import MAX_IMAGE_SIZE, DIFFUSION_SIZE logger = logging.getLogger(__name__) # ============================================================================= # IMAGE VALIDATION # ============================================================================= def validate_image(image: Image.Image, name: str = "Image") -> Tuple[bool, str]: """ Validate an input image. Args: image: PIL Image to validate name: Name for error messages Returns: Tuple of (is_valid, error_message) """ if image is None: return False, f"❌ {name} is required" # Check if it's a valid PIL Image try: width, height = image.size except: return False, f"❌ {name} is not a valid image" # Check minimum size if width < 64 or height < 64: return False, f"❌ {name} is too small (minimum 64x64)" # Check maximum size if width > 4096 or height > 4096: return False, f"❌ {name} is too large (maximum 4096x4096)" return True, "" def validate_inputs( person_image: Image.Image, garment_image: Image.Image ) -> Tuple[bool, str]: """ Validate both person and garment images. Args: person_image: PIL Image of person garment_image: PIL Image of garment Returns: Tuple of (is_valid, error_message) """ is_valid, msg = validate_image(person_image, "Person image") if not is_valid: return False, msg is_valid, msg = validate_image(garment_image, "Garment image") if not is_valid: return False, msg return True, "" # ============================================================================= # BACKGROUND ARTIFACT FIX # ============================================================================= def detect_dark_background(image: Image.Image, threshold: int = 60) -> bool: """ Detect if image has a predominantly dark background. Dark backgrounds can be misinterpreted as hair by the model. Args: image: PIL Image to analyze threshold: Brightness threshold (0-255). Below this is considered dark. Returns: True if background appears dark """ # Sample edges to check background width, height = image.size # Sample strips from edges (10% of width/height) edge_width = max(10, width // 10) edge_height = max(10, height // 10) # Get edge regions top = image.crop((0, 0, width, edge_height)) bottom = image.crop((0, height - edge_height, width, height)) left = image.crop((0, 0, edge_width, height)) right = image.crop((width - edge_width, 0, width, height)) # Calculate average brightness of edges edge_brightnesses = [] for edge in [top, bottom, left, right]: stat = ImageStat.Stat(edge.convert('L')) edge_brightnesses.append(stat.mean[0]) avg_edge_brightness = sum(edge_brightnesses) / len(edge_brightnesses) logger.info(f"Edge brightness: {avg_edge_brightness:.1f}") return avg_edge_brightness < threshold def replace_dark_background( image: Image.Image, target_color: Tuple[int, int, int] = (230, 230, 230), edge_threshold: int = 60 ) -> Image.Image: """ Replace dark background with a neutral light color. This prevents the model from misinterpreting dark backgrounds as hair. Args: image: PIL Image to process target_color: RGB color to use for background (default: light gray) edge_threshold: Brightness threshold for edge detection Returns: Image with replaced background """ # Convert to RGB if needed if image.mode != 'RGB': image = image.convert('RGB') # Create a simple edge-based mask # This is a lightweight approach that doesn't require heavy models img_array = np.array(image) # Convert to grayscale for brightness detection gray = np.array(image.convert('L')) # Create mask where pixels are darker than threshold dark_mask = gray < edge_threshold # Dilate edges slightly to ensure full coverage from scipy import ndimage dark_mask = ndimage.binary_dilation(dark_mask, iterations=2) # Only replace pixels that are likely background (edges + dark) width, height = image.size edge_margin = min(width, height) // 8 # Create edge mask edge_mask = np.zeros_like(dark_mask) edge_mask[:edge_margin, :] = True # Top edge_mask[-edge_margin:, :] = True # Bottom edge_mask[:, :edge_margin] = True # Left edge_mask[:, -edge_margin:] = True # Right # Combine: only replace dark pixels at edges replace_mask = dark_mask & edge_mask # Replace background result = img_array.copy() result[replace_mask] = target_color logger.info(f"Replaced {replace_mask.sum()} background pixels") return Image.fromarray(result) # ============================================================================= # IMAGE NORMALIZATION # ============================================================================= def normalize_image( image: Image.Image, max_size: int = None, target_mode: str = "RGB", fix_background: bool = True ) -> Image.Image: """ Normalize an image for processing. - Converts to target color mode - Resizes if larger than max_size while maintaining aspect ratio - Optionally fixes dark background artifacts Args: image: PIL Image to normalize max_size: Maximum dimension (uses config default if None) target_mode: Target color mode (default RGB) fix_background: Whether to fix dark background artifacts Returns: Normalized PIL Image """ max_size = max_size or MAX_IMAGE_SIZE # Convert color mode if image.mode != target_mode: image = image.convert(target_mode) # Fix dark background if enabled if fix_background: if detect_dark_background(image): logger.info("Dark background detected, applying fix") try: image = replace_dark_background(image) except Exception as e: logger.warning(f"Background fix failed: {e}, continuing without fix") # Resize if too large width, height = image.size if max(width, height) > max_size: ratio = max_size / max(width, height) new_width = int(width * ratio) new_height = int(height * ratio) image = image.resize((new_width, new_height), Image.LANCZOS) logger.info(f"Resized image from {width}x{height} to {new_width}x{new_height}") return image def resize_for_diffusion( image: Image.Image, target_size: int = None ) -> Image.Image: """ Resize image for diffusion models. Ensures dimensions are multiples of 8 for VAE compatibility. Args: image: PIL Image to resize target_size: Target max dimension (uses config default if None) Returns: Resized PIL Image with dimensions divisible by 8 """ target_size = target_size or DIFFUSION_SIZE width, height = image.size # Calculate new size maintaining aspect ratio if max(width, height) > target_size: ratio = target_size / max(width, height) width = int(width * ratio) height = int(height * ratio) # Round to nearest multiple of 8 width = (width // 8) * 8 height = (height // 8) * 8 # Ensure minimum size width = max(width, 64) height = max(height, 64) return image.resize((width, height), Image.LANCZOS) # ============================================================================= # INPUT PREPROCESSING # ============================================================================= def preprocess_inputs( person_image: Image.Image, garment_image: Image.Image, fix_background: bool = True ) -> Tuple[Image.Image, Image.Image, str]: """ Preprocess person and garment images for try-on. This is the main preprocessing function that: 1. Validates both images 2. Normalizes color modes 3. Fixes dark background artifacts (person image only) 4. Resizes to appropriate dimensions Args: person_image: PIL Image of person garment_image: PIL Image of garment fix_background: Whether to apply background artifact fixes Returns: Tuple of (processed_person, processed_garment, status_message) If validation fails, returns (None, None, error_message) """ # Validate is_valid, error_msg = validate_inputs(person_image, garment_image) if not is_valid: return None, None, error_msg # Normalize person image with background fix person_processed = normalize_image(person_image, fix_background=fix_background) # Normalize garment image without background fix (not needed) garment_processed = normalize_image(garment_image, fix_background=False) logger.info(f"Preprocessed: person={person_processed.size}, garment={garment_processed.size}") return person_processed, garment_processed, "✅ Images preprocessed" # ============================================================================= # NUMPY CONVERSION UTILITIES # ============================================================================= def pil_to_numpy(image: Image.Image) -> np.ndarray: """Convert PIL Image to numpy array.""" return np.array(image) def numpy_to_pil(array: np.ndarray) -> Image.Image: """Convert numpy array to PIL Image.""" return Image.fromarray(array.astype(np.uint8)) def ensure_pil(image) -> Optional[Image.Image]: """ Ensure input is a PIL Image. Handles numpy arrays and existing PIL Images. Args: image: Input (numpy array or PIL Image) Returns: PIL Image or None if conversion fails """ if image is None: return None if isinstance(image, Image.Image): return image if isinstance(image, np.ndarray): return numpy_to_pil(image) logger.warning(f"Unknown image type: {type(image)}") return None