""" Z-Image utility functions for ComfyUI-AI-Photography-Toolkit. Provides config loading, caching, and output cleaning utilities. """ import os import re import json import hashlib from pathlib import Path from typing import Any, Optional import yaml import numpy as np # ============================================================================= # CONFIG LOADING # ============================================================================= _CONFIG_CACHE: dict[str, Any] = {} def get_config_path() -> Path: """Get the path to the config directory.""" return Path(__file__).parent.parent / "config" def load_zimage_config(force_reload: bool = False) -> dict[str, Any]: """ Load the Z-Image generator configuration from YAML. Args: force_reload: Force reload from disk even if cached Returns: Configuration dictionary """ global _CONFIG_CACHE cache_key = "zimage_generator" if not force_reload and cache_key in _CONFIG_CACHE: return _CONFIG_CACHE[cache_key] config_path = get_config_path() / "zimage_generator_config.yaml" if not config_path.exists(): raise FileNotFoundError(f"Config file not found: {config_path}") with open(config_path, "r", encoding="utf-8") as f: config = yaml.safe_load(f) _CONFIG_CACHE[cache_key] = config return config def get_shot_framings() -> dict[str, dict]: """Get all shot framing definitions.""" config = load_zimage_config() return config.get("shot_framings", {}) def get_photography_genres() -> dict[str, dict]: """Get all photography genre definitions, flattened.""" config = load_zimage_config() genres = config.get("photography_genres", {}) # Flatten the nested structure flattened = {} for category, category_genres in genres.items(): for code, genre_data in category_genres.items(): genre_data["category"] = category flattened[code] = genre_data return flattened def get_genre_options() -> list[str]: """Get list of genre codes for UI dropdown.""" genres = get_photography_genres() return list(genres.keys()) def get_attribute_schema(attribute_name: str) -> dict[str, Any]: """Get the schema for a specific attribute.""" config = load_zimage_config() attributes = config.get("attributes", {}) return attributes.get(attribute_name, {}) def get_content_detail_schema(level: str) -> dict[str, Any]: """Get the attribute schema for a specific content detail level.""" config = load_zimage_config() levels = config.get("content_detail_levels", {}) return levels.get(level, levels.get("standard", {})) def get_prompt_template(stage: str) -> str: """Get the prompt template for a specific stage.""" config = load_zimage_config() templates = config.get("prompt_templates", {}) stage_template = templates.get(stage, {}) return stage_template.get("system", "") def get_focus_override_config(override_name: str) -> Optional[dict]: """Get configuration for a focus override.""" config = load_zimage_config() overrides = config.get("focus_overrides", {}) return overrides.get(override_name) def get_detail_level_config(level: str) -> dict[str, Any]: """Get configuration for a detail level.""" config = load_zimage_config() levels = config.get("detail_levels", {}) return levels.get(level, levels.get("Standard", {})) def get_zimage_settings() -> dict[str, Any]: """Get Z-Image recommended settings.""" config = load_zimage_config() return config.get("zimage_settings", {}) # ============================================================================= # OUTPUT CLEANING # ============================================================================= def clean_zimage_output(text: str, max_length: int = 2000) -> str: """ Clean LLM output for Z-Image compatibility. Args: text: Raw LLM output max_length: Maximum character length (0 = unlimited) Returns: Cleaned text suitable for Z-Image """ if not text: return "" # Remove thinking tags text = re.sub(r".*?", "", text, flags=re.DOTALL) text = re.sub(r".*?", "", text, flags=re.DOTALL) # Remove markdown code blocks text = re.sub(r"```[\s\S]*?```", "", text) text = re.sub(r"`[^`]+`", "", text) # Remove common prefixes prefixes_to_remove = [ r"^Here['']?s? (?:is )?(?:the |a |an )?(?:enhanced |detailed |generated )?prompt[:\s]*", r"^(?:The )?(?:enhanced |detailed |generated )?prompt[:\s]*", r"^Sure[,!]? (?:here['']?s? )?(?:the |a )?prompt[:\s]*", r"^I['']?ve (?:generated|created|written)[:\s]*", ] for prefix in prefixes_to_remove: text = re.sub(prefix, "", text, flags=re.IGNORECASE) # Remove meta-tags that Z-Image doesn't need meta_tags = [ r"\b8[kK]\b", r"\bmasterpiece\b", r"\bbest quality\b", r"\bhigh quality\b", r"\bultra quality\b", r"\b4[kK]\b", r"\bUHD\b", r"\bhighly detailed\b", r"\bsuper detailed\b", ] for tag in meta_tags: text = re.sub(tag + r",?\s*", "", text, flags=re.IGNORECASE) # Clean up whitespace text = re.sub(r"\s+", " ", text) text = text.strip() # Remove leading/trailing commas text = text.strip(",").strip() # Clean up double commas text = re.sub(r",\s*,", ",", text) # Truncate if needed (at sentence boundary) if max_length > 0 and len(text) > max_length: text = truncate_at_sentence(text, max_length) return text def truncate_at_sentence(text: str, max_length: int) -> str: """ Truncate text at the nearest sentence boundary before max_length. Args: text: Text to truncate max_length: Maximum length Returns: Truncated text ending at a sentence boundary """ if len(text) <= max_length: return text # Find sentence boundaries before max_length truncated = text[:max_length] # Look for sentence-ending punctuation last_period = truncated.rfind(". ") last_exclaim = truncated.rfind("! ") last_question = truncated.rfind("? ") # Find the latest sentence boundary boundary = max(last_period, last_exclaim, last_question) if boundary > max_length * 0.5: # Only use if at least halfway through return truncated[:boundary + 1].strip() # Fallback: truncate at last space last_space = truncated.rfind(" ") if last_space > max_length * 0.7: return truncated[:last_space].strip() + "..." return truncated.strip() + "..." # ============================================================================= # CACHING UTILITIES (Persistent Disk Cache) # ============================================================================= # In-memory cache for fast repeated access within session _MEMORY_CACHE: dict[str, Any] = {} _MAX_MEMORY_CACHE_SIZE = 50 # Disk cache settings _MAX_DISK_CACHE_SIZE = 500 # Max number of cached entries on disk _CACHE_VERSION = "1" # Increment to invalidate old caches def get_cache_dir() -> Path: """Get the cache directory path, creating it if needed.""" cache_dir = Path(__file__).parent.parent / "cache" / "prompts" cache_dir.mkdir(parents=True, exist_ok=True) return cache_dir def hash_image_tensor(image_tensor) -> str: """ Generate a hash from an image tensor for caching. Args: image_tensor: ComfyUI image tensor (B, H, W, C) Returns: Hex string hash of the image """ # Take first image if batch if len(image_tensor.shape) == 4: img_np = image_tensor[0].cpu().numpy() else: img_np = image_tensor.cpu().numpy() # Downsample for faster hashing (every 4th pixel) downsampled = img_np[::4, ::4, :] # Create hash from bytes img_bytes = downsampled.tobytes() return hashlib.md5(img_bytes).hexdigest() def get_cache_key( image_hash: str, seed: int, **params ) -> str: """ Generate a cache key from image hash and parameters. Args: image_hash: Hash of the input image seed: Random seed **params: All other parameters that affect output Returns: Cache key string """ # Sort params for consistent ordering sorted_params = sorted(params.items()) param_str = json.dumps(sorted_params, sort_keys=True) param_hash = hashlib.md5(param_str.encode()).hexdigest()[:8] return f"v{_CACHE_VERSION}_{image_hash}_{seed}_{param_hash}" def _get_cache_file_path(cache_key: str) -> Path: """Get the file path for a cache entry.""" # Use first 2 chars as subdirectory for better file distribution subdir = cache_key[:2] if len(cache_key) >= 2 else "00" cache_dir = get_cache_dir() / subdir cache_dir.mkdir(parents=True, exist_ok=True) return cache_dir / f"{cache_key}.json" def get_cached_output(cache_key: str) -> Optional[dict]: """ Get cached output if it exists (checks memory first, then disk). Args: cache_key: The cache key to look up Returns: Cached output dict or None if not found """ global _MEMORY_CACHE # Check memory cache first (fast path) if cache_key in _MEMORY_CACHE: return _MEMORY_CACHE[cache_key] # Check disk cache cache_file = _get_cache_file_path(cache_key) if cache_file.exists(): try: with open(cache_file, "r", encoding="utf-8") as f: data = json.load(f) # Validate cache version if data.get("_cache_version") == _CACHE_VERSION: output = data.get("output") # Store in memory cache for faster subsequent access _MEMORY_CACHE[cache_key] = output return output else: # Old cache version, delete it cache_file.unlink(missing_ok=True) except (json.JSONDecodeError, IOError): # Corrupted cache file, delete it cache_file.unlink(missing_ok=True) return None def set_cached_output(cache_key: str, output: dict) -> None: """ Cache an output result (both memory and disk). Args: cache_key: The cache key output: The output dict to cache """ global _MEMORY_CACHE # Store in memory cache if len(_MEMORY_CACHE) >= _MAX_MEMORY_CACHE_SIZE: # Remove oldest entries (first 20%) keys_to_remove = list(_MEMORY_CACHE.keys())[:_MAX_MEMORY_CACHE_SIZE // 5] for key in keys_to_remove: del _MEMORY_CACHE[key] _MEMORY_CACHE[cache_key] = output # Store on disk cache_file = _get_cache_file_path(cache_key) try: cache_data = { "_cache_version": _CACHE_VERSION, "_created": str(Path(cache_file).stat().st_mtime if cache_file.exists() else "new"), "output": output } with open(cache_file, "w", encoding="utf-8") as f: json.dump(cache_data, f, indent=2) # Cleanup old cache files if needed _cleanup_disk_cache() except IOError as e: # Log but don't fail if disk cache write fails print(f"Warning: Failed to write cache file: {e}") def _cleanup_disk_cache() -> None: """Remove oldest cache files if over limit.""" cache_dir = get_cache_dir() # Get all cache files with their modification times cache_files = [] for subdir in cache_dir.iterdir(): if subdir.is_dir(): for cache_file in subdir.glob("*.json"): try: mtime = cache_file.stat().st_mtime cache_files.append((cache_file, mtime)) except OSError: pass # If over limit, remove oldest files if len(cache_files) > _MAX_DISK_CACHE_SIZE: # Sort by modification time (oldest first) cache_files.sort(key=lambda x: x[1]) # Remove oldest 20% files_to_remove = cache_files[:len(cache_files) // 5] for cache_file, _ in files_to_remove: try: cache_file.unlink(missing_ok=True) except OSError: pass def clear_cache(memory_only: bool = False) -> int: """ Clear the output cache. Args: memory_only: If True, only clear memory cache. If False, clear both. Returns: Number of entries cleared """ global _MEMORY_CACHE count = len(_MEMORY_CACHE) _MEMORY_CACHE = {} if not memory_only: cache_dir = get_cache_dir() if cache_dir.exists(): for subdir in cache_dir.iterdir(): if subdir.is_dir(): for cache_file in subdir.glob("*.json"): try: cache_file.unlink() count += 1 except OSError: pass return count def get_cache_stats() -> dict[str, Any]: """Get cache statistics.""" cache_dir = get_cache_dir() disk_count = 0 disk_size = 0 if cache_dir.exists(): for subdir in cache_dir.iterdir(): if subdir.is_dir(): for cache_file in subdir.glob("*.json"): try: disk_count += 1 disk_size += cache_file.stat().st_size except OSError: pass return { "memory_entries": len(_MEMORY_CACHE), "memory_max": _MAX_MEMORY_CACHE_SIZE, "disk_entries": disk_count, "disk_max": _MAX_DISK_CACHE_SIZE, "disk_size_mb": round(disk_size / (1024 * 1024), 2), "cache_dir": str(cache_dir), } # ============================================================================= # IMAGE UTILITIES # ============================================================================= def get_image_metadata(image_tensor) -> dict[str, Any]: """ Extract metadata from an image tensor. Args: image_tensor: ComfyUI image tensor (B, H, W, C) Returns: Dictionary with image metadata """ # Get dimensions if len(image_tensor.shape) == 4: batch, height, width, channels = image_tensor.shape else: height, width, channels = image_tensor.shape batch = 1 # Calculate aspect ratio aspect_decimal = width / height # Determine orientation if aspect_decimal > 1.05: orientation = "landscape" elif aspect_decimal < 0.95: orientation = "portrait" else: orientation = "square" # Find common aspect ratio aspect_ratio = find_aspect_ratio(width, height) # Calculate megapixels megapixels = (width * height) / 1_000_000 return { "width": width, "height": height, "channels": channels, "batch_size": batch, "aspect_ratio": aspect_ratio, "aspect_decimal": round(aspect_decimal, 2), "orientation": orientation, "megapixels": round(megapixels, 2), } def find_aspect_ratio(width: int, height: int) -> str: """Find the closest common aspect ratio.""" from math import gcd divisor = gcd(width, height) w = width // divisor h = height // divisor # Common aspect ratios to match common_ratios = { (1, 1): "1:1", (4, 3): "4:3", (3, 4): "3:4", (16, 9): "16:9", (9, 16): "9:16", (3, 2): "3:2", (2, 3): "2:3", (4, 5): "4:5", (5, 4): "5:4", (21, 9): "21:9", (9, 21): "9:21", } # Check for exact match if (w, h) in common_ratios: return common_ratios[(w, h)] # Find closest match target_ratio = width / height closest = None min_diff = float("inf") for (rw, rh), name in common_ratios.items(): ratio = rw / rh diff = abs(ratio - target_ratio) if diff < min_diff: min_diff = diff closest = name # If very close to a common ratio, use it if min_diff < 0.05: return closest # Otherwise return simplified ratio return f"{w}:{h}" def get_zimage_recommendations(image_metadata: dict) -> dict[str, Any]: """ Generate Z-Image recommendations based on image metadata. Args: image_metadata: Output from get_image_metadata() Returns: Dictionary with Z-Image recommendations """ settings = get_zimage_settings() width = image_metadata["width"] height = image_metadata["height"] orientation = image_metadata["orientation"] # Get optimal resolution based on orientation if orientation == "portrait": optimal = settings["optimal_resolution"]["portrait"] elif orientation == "landscape": optimal = settings["optimal_resolution"]["landscape"] else: optimal = settings["optimal_resolution"]["native"] optimal_w, optimal_h = optimal # Check if resize is needed resize_needed = (width != optimal_w or height != optimal_h) # Determine resize direction if resize_needed: if width * height > optimal_w * optimal_h: resize_direction = "downscale" else: resize_direction = "upscale" else: resize_direction = "none" # Check aspect ratio compatibility current_aspect = width / height optimal_aspect = optimal_w / optimal_h aspect_ratio_ok = abs(current_aspect - optimal_aspect) < 0.1 # Estimate quality based on resolution total_pixels = width * height if total_pixels >= 1_000_000: quality = "high" elif total_pixels >= 500_000: quality = "medium" else: quality = "low" return { "optimal_resolution": optimal, "current_compatible": not resize_needed, "resize_needed": resize_needed, "resize_direction": resize_direction, "resize_method": "lanczos", "aspect_ratio_ok": aspect_ratio_ok, "nearest_native": f"{optimal_w}x{optimal_h} ({image_metadata['aspect_ratio']})", "quality_estimate": quality, "suggested_settings": { "steps": settings["inference"]["steps"], "guidance_scale": settings["inference"]["guidance_scale"], "max_sequence_length": settings["inference"]["max_sequence_length"], }, } # ============================================================================= # ATTRIBUTE SCHEMA BUILDERS # ============================================================================= def build_attribute_schema_for_scene( shot_framing: str, genre: str, content_detail: str = "standard" ) -> dict[str, list[str]]: """ Build the attribute schema to extract based on scene classification. Args: shot_framing: Shot framing code (e.g., "CU", "FS") genre: Photography genre code (e.g., "PRT", "FSH") content_detail: Content detail level Returns: Dictionary mapping attribute categories to fields to extract """ config = load_zimage_config() shot_framings = config.get("shot_framings", {}) # Get base attributes from shot framing framing_config = shot_framings.get(shot_framing, {}) base_attributes = framing_config.get("attributes", []) # Get content detail level config detail_config = get_content_detail_schema(content_detail) # Build schema based on scene type schema = {} # Always include subject for people photography genres = get_photography_genres() genre_config = genres.get(genre, {}) genre_category = genre_config.get("category", "") if genre_category == "people": schema["subject"] = ["gender", "age_range"] # Add attributes based on shot framing for attr in base_attributes: if attr == "face": schema["face"] = ["shape", "angle", "tilt"] elif attr == "eyes": schema["eyes"] = ["color", "shape", "state", "gaze", "expression", "makeup"] elif attr == "skin": schema["skin"] = ["tone", "undertone", "texture", "condition"] elif attr == "lips": schema["lips"] = ["shape", "color", "state"] elif attr == "expression": schema["expression"] = ["overall", "intensity"] elif attr == "hair": schema["hair"] = ["color", "length", "texture", "style", "state"] elif attr == "body_upper": schema["body_upper"] = detail_config.get("body", ["build", "posture"]) schema["clothing_upper"] = detail_config.get("clothing", ["type", "color"]) elif attr == "body" or attr == "clothing_full": schema["body"] = detail_config.get("body", ["build", "posture"]) if content_detail in ["detailed", "explicit"]: schema["body_exposure"] = ["chest", "stomach", "back", "thigh", "buttocks"] schema["clothing_full"] = detail_config.get("clothing", ["type", "color"]) elif attr == "pose_upper": schema["pose_upper"] = ["shoulders", "arms", "hands", "body_angle"] elif attr == "pose_full": schema["pose_full"] = ["stance", "weight", "legs", "hips", "dynamic"] if content_detail in ["detailed", "explicit"]: schema["pose_full"].append("sensuality") elif attr == "environment": schema["environment"] = ["setting_type", "location"] elif attr == "lighting": schema["lighting"] = ["type", "source", "direction", "quality", "color_temperature"] elif attr == "accessories": schema["accessories"] = ["necklace", "earrings", "glasses"] # Always include lighting and background for most shots if "lighting" not in schema and shot_framing not in ["ECU", "BCU"]: schema["lighting"] = ["type", "direction", "quality"] if "background" not in schema and shot_framing not in ["ECU"]: schema["background"] = ["clarity", "complexity", "color"] return schema def format_attribute_schema_for_prompt(schema: dict[str, list[str]]) -> str: """ Format an attribute schema as text for inclusion in a prompt. Args: schema: Attribute schema from build_attribute_schema_for_scene() Returns: Formatted text describing the schema """ config = load_zimage_config() all_attributes = config.get("attributes", {}) lines = ["Extract the following attributes:\n"] for category, fields in schema.items(): lines.append(f"\n{category}:") category_config = all_attributes.get(category, {}) for field in fields: field_config = category_config.get(field, {}) if isinstance(field_config, dict): options = field_config.get("options", []) if options: options_str = ", ".join(options[:5]) if len(options) > 5: options_str += ", ..." lines.append(f" - {field}: [{options_str}]") else: lines.append(f" - {field}: (describe)") else: lines.append(f" - {field}: (describe)") return "\n".join(lines)