Files

492 lines
17 KiB
Python

"""
Image-to-Image Prompt Expander Node
Platform-aware prompt generation for image-to-image workflows
"""
import torch
from typing import Tuple, Optional
from .llm_backend import LLMBackend
from .img2img_expansion_engine import ImageToImageExpander
from .platforms import get_platform_list, get_platform_config
from .utils import save_prompts_to_file, parse_keywords
from .qwen3_vl_backend import caption_with_qwen3_vl
class ImageToImagePromptExpander:
"""
Advanced Image-to-Image prompt expansion with vision model and platform awareness
1. Analyzes input image with vision model
2. Takes user's change request
3. Generates platform-optimized prompt
"""
def __init__(self):
self.expander = ImageToImageExpander()
self.type = "image_to_image_expansion"
self.output_dir = "output/img2img_prompts"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
# Image input
"image": ("IMAGE",),
# Change request
"change_request": ("STRING", {
"multiline": True,
"default": "change dress to red, add sunset lighting",
"placeholder": "Describe what to change (or leave empty to enhance)"
}),
# Platform selection
"target_platform": ([
"flux",
"sd_xl",
"wan22",
"hunyuan_image",
"qwen_image",
"qwen_image_edit"
], {
"default": "flux"
}),
# Vision model settings
"use_vision_model": ("BOOLEAN", {
"default": True
}),
"vision_backend": ([
"lm_studio",
"ollama",
"qwen3_vl"
], {
"default": "lm_studio",
"tooltip": (
"lm_studio: Uses currently loaded model\n"
"ollama: Uses currently loaded model\n"
"qwen3_vl: Auto-detects local Qwen3-VL model"
)
}),
"vision_endpoint": ("STRING", {
"default": "http://localhost:1234/v1",
"multiline": False,
"tooltip": "API endpoint or custom model path (qwen3_vl)"
}),
# Expansion model
"expansion_backend": ([
"lm_studio",
"ollama",
"qwen3_vl"
], {
"default": "lm_studio",
"tooltip": (
"lm_studio: Uses currently loaded model\n"
"ollama: Uses currently loaded model\n"
"qwen3_vl: Auto-detects local Qwen3-VL model"
)
}),
"expansion_endpoint": ("STRING", {
"default": "http://localhost:1234/v1",
"multiline": False,
"tooltip": "API endpoint or custom model path (qwen3_vl)"
}),
"temperature": ("FLOAT", {
"default": 0.6,
"min": 0.1,
"max": 2.0,
"step": 0.1
}),
# Aesthetic controls
"art_style": ([
"auto",
"none",
"photorealistic",
"digital art",
"oil painting",
"watercolor",
"anime",
"sketch",
"3D render",
"illustration",
"concept art"
], {
"default": "auto"
}),
"lighting_type": ([
"auto",
"none",
"natural lighting",
"studio lighting",
"soft lighting",
"dramatic lighting",
"golden hour",
"blue hour",
"rim lighting",
"volumetric lighting"
], {
"default": "auto"
}),
"composition": ([
"auto",
"none",
"rule of thirds",
"centered",
"symmetrical",
"golden ratio",
"dynamic",
"minimalist"
], {
"default": "auto"
}),
"color_palette": ([
"auto",
"none",
"vibrant",
"muted",
"monochrome",
"warm",
"cool",
"pastel",
"high contrast"
], {
"default": "auto"
}),
"mood": ([
"auto",
"none",
"serene",
"dramatic",
"mysterious",
"cheerful",
"melancholic",
"epic",
"intimate"
], {
"default": "auto"
}),
"detail_level": ([
"auto",
"standard",
"highly detailed",
"intricate",
"simplified",
"minimalist"
], {
"default": "auto"
}),
"positive_keywords": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "Additional keywords, LoRA triggers"
}),
"negative_keywords": ("STRING", {
"default": "",
"multiline": True
}),
# Output
"save_to_file": ("BOOLEAN", {
"default": False
}),
"filename_base": ("STRING", {
"default": "img2img_prompt",
"multiline": False
})
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("positive_prompt", "negative_prompt", "image_description", "status")
FUNCTION = "expand_img2img_prompt"
CATEGORY = "Eric Prompt Enhancers"
OUTPUT_NODE = True
def expand_img2img_prompt(
self,
image: torch.Tensor,
change_request: str,
target_platform: str,
use_vision_model: bool,
vision_backend: str,
vision_endpoint: str,
expansion_backend: str,
expansion_endpoint: str,
temperature: float,
art_style: str,
lighting_type: str,
composition: str,
color_palette: str,
mood: str,
detail_level: str,
positive_keywords: str,
negative_keywords: str,
save_to_file: bool,
filename_base: str
) -> Tuple[str, str, str, str]:
"""Main processing function"""
try:
# STEP 1: Analyze image with vision model
if use_vision_model:
image_desc_result = self._analyze_image_for_editing(
image,
vision_backend,
vision_endpoint,
temperature
)
if not image_desc_result["success"]:
return (
change_request,
"",
f"ERROR: {image_desc_result['error']}",
f"❌ Vision model failed"
)
image_description = image_desc_result["description"]
else:
image_description = "[Vision analysis skipped]"
# STEP 2: Gather aesthetic controls
aesthetic_controls = {}
if art_style not in ["auto", "none"]:
aesthetic_controls["art_style"] = art_style
if lighting_type not in ["auto", "none"]:
aesthetic_controls["lighting_type"] = lighting_type
if composition not in ["auto", "none"]:
aesthetic_controls["composition"] = composition
if color_palette not in ["auto", "none"]:
aesthetic_controls["color_palette"] = color_palette
if mood not in ["auto", "none"]:
aesthetic_controls["mood"] = mood
if detail_level not in ["auto", "standard"]:
aesthetic_controls["detail_level"] = detail_level
# STEP 3: Build expansion prompts
pos_kw_list = parse_keywords(positive_keywords)
neg_kw_list = parse_keywords(negative_keywords)
system_prompt, user_prompt, breakdown_dict = self.expander.expand_img2img_prompt(
image_description=image_description,
change_request=change_request,
platform=target_platform,
aesthetic_controls=aesthetic_controls,
custom_negatives=neg_kw_list
)
# STEP 4: Call expansion LLM (model_name auto-detected)
expansion_llm = LLMBackend(
backend_type=expansion_backend,
endpoint=expansion_endpoint,
model_name=None, # Auto-detect for all backends
temperature=temperature
)
response = expansion_llm.send_prompt(
system_prompt=system_prompt,
user_prompt=user_prompt,
max_tokens=500 # Platform-optimized lengths
)
if not response["success"]:
# Fallback: use basic combination
platform_config = get_platform_config(target_platform)
fallback = f"{image_description}, {change_request}" if change_request else image_description
enhanced_prompt = fallback
else:
enhanced_prompt = self.expander.parse_llm_response(response["response"])
# STEP 5: Add required keywords
if pos_kw_list:
enhanced_prompt += f", {', '.join(pos_kw_list)}"
# STEP 6: Generate negative prompt
negative_prompt = self.expander.generate_negative_prompt(
platform=target_platform,
custom_negatives=neg_kw_list
)
# STEP 7: Save if requested
if save_to_file:
metadata = {
"type": "image-to-image",
"platform": target_platform,
"platform_name": breakdown_dict.get("platform_name"),
"vision_model": vision_backend if use_vision_model else "none",
"expansion_model": expansion_llm.model_name or "auto-detected",
"image_description": image_description,
"change_request": change_request
}
breakdown_text = self._format_breakdown(breakdown_dict)
save_result = save_prompts_to_file(
positive_prompt=enhanced_prompt,
negative_prompt=negative_prompt,
breakdown=breakdown_text,
metadata=metadata,
filename_base=filename_base,
output_dir=self.output_dir
)
file_status = f"💾 Saved" if save_result["success"] else "⚠️ Save failed"
else:
file_status = "Not saved"
platform_name = get_platform_config(target_platform)["name"]
status = f"✅ Image-to-Image | Platform: {platform_name} | {file_status}"
return (
enhanced_prompt,
negative_prompt,
image_description,
status
)
except Exception as e:
import traceback
error_detail = traceback.format_exc()
print(f"ERROR in img2img expansion: {error_detail}")
return (
change_request,
"",
f"ERROR: {str(e)}",
f"❌ {str(e)}"
)
def _analyze_image_for_editing(
self,
image: torch.Tensor,
backend: str,
endpoint: str,
temperature: float
) -> dict:
"""Analyze image with focus on edit-relevant details"""
try:
# Vision prompt focused on structure and key elements
vision_system_prompt = """You are an expert at analyzing images for image-to-image generation.
Describe this image focusing on:
- Main subject(s): appearance, pose, expression
- Background/setting: environment, objects
- Style: artistic style, medium (photo/painting/etc)
- Lighting: type, direction, quality
- Colors: palette, saturation, temperature
- Composition: layout, focus, depth
- Overall mood/atmosphere
Be detailed but concise. Focus on elements that define the image structure.
Output ONLY the description."""
vision_user_prompt = "Describe this image in detail for image-to-image generation."
# Call vision model (reuse from img2vid)
from .image_to_video_node import ImageToVideoPromptExpander
img2vid_node = ImageToVideoPromptExpander()
pil_image = img2vid_node._tensor_to_pil(image)
if backend == "qwen3_vl":
# Use endpoint for custom model path
model_spec = endpoint if endpoint != "http://localhost:1234/v1" else None
qwen_result = caption_with_qwen3_vl(
image=pil_image,
prompt=vision_user_prompt,
system_prompt=vision_system_prompt,
model_spec=model_spec,
backend_hint=None,
max_new_tokens=768,
temperature=temperature,
)
if not qwen_result.get("success"):
return {
"success": False,
"error": qwen_result.get("error", "Qwen3-VL caption failed")
}
description = qwen_result.get("caption", "").strip()
return {
"success": True,
"description": description
}
img_base64 = img2vid_node._pil_to_base64(pil_image)
# Call LM Studio/Ollama (model_name auto-detected)
llm = LLMBackend(
backend_type=backend,
endpoint=endpoint,
model_name=None, # Auto-detect for all backends
temperature=temperature
)
if backend == "lm_studio":
response = img2vid_node._call_vision_lm_studio(
llm, vision_system_prompt, vision_user_prompt, img_base64
)
elif backend == "ollama":
response = img2vid_node._call_vision_ollama(
llm, vision_system_prompt, vision_user_prompt, img_base64
)
else:
return {"success": False, "error": "Unknown backend"}
if not response["success"]:
return {"success": False, "error": response["error"]}
description = response["response"].strip()
return {
"success": True,
"description": description
}
except Exception as e:
return {
"success": False,
"error": f"Image analysis failed: {str(e)}"
}
def _format_breakdown(self, breakdown_dict: dict) -> str:
"""Format breakdown for output"""
lines = [
"=" * 60,
"IMAGE-TO-IMAGE PROMPT EXPANSION",
"=" * 60,
f"\nTarget Platform: {breakdown_dict.get('platform_name', 'N/A')}",
f"Prompting Style: {breakdown_dict.get('prompt_style', 'N/A')}",
f"Optimal Length: {breakdown_dict.get('optimal_length', 'N/A')}",
"\nIMAGE DESCRIPTION:",
breakdown_dict.get('image_description', 'N/A'),
"\nCHANGE REQUEST:",
breakdown_dict.get('change_request', 'None'),
]
if breakdown_dict.get('aesthetic_controls'):
lines.append("\nAESTHETIC CONTROLS:")
for key, value in breakdown_dict['aesthetic_controls'].items():
lines.append(f" - {key.replace('_', ' ').title()}: {value}")
lines.append("\n" + "=" * 60)
return "\n".join(lines)