New Features: - SID_PromptCompose node with Standard and Enhance with AI modes - SID_PromptSynthesis node for VLM-based prompt generation - SID_ImageAnalysis node for comprehensive image analysis - Text LLM support (Qwen2.5 0.5B-7B) for prompt enhancement - PixAI tagger integration Documentation: - docs/image_tokenization_spec.md - Full technical specification for tokenization → classification → assembly pipeline - docs/implementation_plan.md - 8-phase implementation roadmap - docs/SID_PromptCompose.md - Node usage documentation Infrastructure: - core/compose/ module with base, pipeline, standard, llm generators - core/log.py centralized logging - Config files for Qwen2.5 text models (YAML) - Web assets for ComfyUI integration This commit establishes the foundation for rewriting the compose pipeline based on the tokenization specification. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
419 lines
16 KiB
Python
419 lines
16 KiB
Python
"""Standard (NLP) prompt composition with structured output schema."""
|
|
|
|
import re
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
from .base import (
|
|
BaseComposeGenerator, ComposeConfig, ComposeResult, ComposeMode,
|
|
ComposeAnalytics
|
|
)
|
|
|
|
|
|
class StandardGenerator(BaseComposeGenerator):
|
|
"""
|
|
NLP-based prompt generator with structured output schema.
|
|
|
|
Output Schema (canonical format for image generation):
|
|
1. Quality Boosters - Technical quality terms
|
|
2. Subject - Who/what is the main subject
|
|
3. Subject Details - Appearance, clothing, accessories
|
|
4. Action/Pose - What they're doing
|
|
5. Environment/Scene - Setting, location, background
|
|
6. Lighting - Light quality, direction, mood
|
|
7. Style/Medium - Artistic style, medium
|
|
8. Composition - Framing, camera angle
|
|
9. Technical Parameters - Camera/lens settings
|
|
|
|
Strategy:
|
|
- Use caption (VLM or Florence) as BASE - always available from Analysis
|
|
- Parse caption to extract content for each section
|
|
- Enhance sections with tags that add missing details
|
|
- Output full descriptive sentences per section
|
|
"""
|
|
|
|
# Gender detection
|
|
FEMALE_TAGS = {'girl', 'woman', 'female', '1girl', 'lady', 'she'}
|
|
MALE_TAGS = {'boy', 'man', 'male', '1boy', 'he', 'guy'}
|
|
|
|
# Quality boosters vocabulary
|
|
QUALITY_TERMS = [
|
|
'masterpiece', 'best quality', 'high resolution', 'highly detailed',
|
|
'professional', 'stunning', 'beautiful', 'elegant', 'exquisite',
|
|
'8k', '4k', 'uhd', 'high definition', 'sharp focus', 'crisp',
|
|
]
|
|
|
|
# Tags to skip as too generic
|
|
SKIP_TAGS = {
|
|
'lips', 'nose', 'face', 'skin', 'eyes', 'solo', '1girl', '1boy',
|
|
'realistic', 'photorealistic', 'photo', 'photograph',
|
|
'bare arms', 'bare shoulders', 'arms',
|
|
}
|
|
|
|
def generate(self, metadata: Dict) -> ComposeResult:
|
|
"""Generate structured prompt following canonical schema."""
|
|
analytics = ComposeAnalytics()
|
|
|
|
# 1. Get base caption (VLM preferred, Florence fallback)
|
|
base_caption = self._get_best_caption(metadata)
|
|
base_caption = self.filter_philosophical(base_caption)
|
|
analytics.florence_used = bool(base_caption)
|
|
if base_caption:
|
|
analytics.florence_sections_used = ["base_caption"]
|
|
|
|
# 2. Extract and classify tags
|
|
tags, tag_sources = self.extract_tags_with_tracking(metadata, analytics)
|
|
classified = self.classify_tags(tags)
|
|
|
|
for category, category_tags in classified.items():
|
|
if category_tags:
|
|
analytics.categories_used[category] = category_tags
|
|
|
|
# 3. Detect gender for pronouns
|
|
gender = self._detect_gender(tags, classified)
|
|
|
|
# 4. Extract sections from caption and enhance with tags
|
|
sections = self._extract_sections(base_caption, classified, tags, metadata, gender)
|
|
|
|
# 5. Build final prompt in canonical order
|
|
prompt = self._build_canonical_prompt(sections)
|
|
|
|
# Finalize analytics
|
|
analytics.finalize()
|
|
|
|
return ComposeResult(
|
|
prompt=prompt,
|
|
mode=ComposeMode.STANDARD,
|
|
style=self.style_config.style_name,
|
|
categories_used=list(analytics.categories_used.keys()),
|
|
analytics=analytics,
|
|
)
|
|
|
|
def _get_best_caption(self, metadata: Dict) -> str:
|
|
"""Get caption based on caption_source setting."""
|
|
source = self.caption_source
|
|
|
|
if source == "Tags Only":
|
|
return ''
|
|
|
|
if source == "Synthesis (VLM)":
|
|
return metadata.get('vlm_description', '')
|
|
|
|
if source == "Analysis (Florence)":
|
|
return self._get_florence_caption(metadata)
|
|
|
|
# Auto - VLM > Florence
|
|
vlm_desc = metadata.get('vlm_description', '')
|
|
if vlm_desc and len(vlm_desc) > 100:
|
|
return vlm_desc
|
|
|
|
return self._get_florence_caption(metadata)
|
|
|
|
def _get_florence_caption(self, metadata: Dict) -> str:
|
|
"""Get best Florence caption from metadata."""
|
|
for key in ['florence_description', 'florence_mixed_caption_plus',
|
|
'florence_mixed_caption', 'florence_caption']:
|
|
caption = metadata.get(key, '')
|
|
if caption and len(caption) > 50:
|
|
if '\n\n' in caption:
|
|
caption = caption.split('\n\n')[0]
|
|
return caption
|
|
return ''
|
|
|
|
def _detect_gender(self, tags: Dict[str, float], classified: Dict[str, List[str]]) -> str:
|
|
"""Detect gender from tags."""
|
|
all_text = ' '.join(list(tags.keys()) + classified.get('subject', []))
|
|
all_lower = all_text.lower()
|
|
|
|
if any(f in all_lower for f in self.FEMALE_TAGS):
|
|
return 'female'
|
|
if any(m in all_lower for m in self.MALE_TAGS):
|
|
return 'male'
|
|
return 'neutral'
|
|
|
|
def _get_pronouns(self, gender: str) -> Tuple[str, str, str]:
|
|
"""Get pronouns based on gender."""
|
|
if gender == 'female':
|
|
return ('She', 'Her', 'her')
|
|
elif gender == 'male':
|
|
return ('He', 'His', 'him')
|
|
return ('They', 'Their', 'them')
|
|
|
|
def _extract_sections(self, caption: str, classified: Dict[str, List[str]],
|
|
tags: Dict[str, float], metadata: Dict, gender: str) -> Dict[str, str]:
|
|
"""Extract content into canonical schema sections."""
|
|
sections = {
|
|
'quality': '',
|
|
'subject': '',
|
|
'subject_details': '',
|
|
'action': '',
|
|
'environment': '',
|
|
'lighting': '',
|
|
'style': '',
|
|
'composition': '',
|
|
'technical': '',
|
|
}
|
|
|
|
caption_lower = caption.lower() if caption else ''
|
|
subj, poss, _ = self._get_pronouns(gender)
|
|
|
|
# === QUALITY BOOSTERS ===
|
|
quality_parts = []
|
|
# Check if quality terms are in caption or add defaults
|
|
for term in self.QUALITY_TERMS:
|
|
if term in caption_lower:
|
|
quality_parts.append(term)
|
|
if not quality_parts:
|
|
quality_parts = ['professional photograph', 'high quality', 'detailed']
|
|
sections['quality'] = ', '.join(quality_parts)
|
|
|
|
# === SUBJECT ===
|
|
subject_parts = []
|
|
# Extract subject from caption
|
|
subject_match = re.search(
|
|
r'\b((?:a |an )?(?:young |mature |elderly |beautiful |attractive )?'
|
|
r'(?:asian |indian |european |african |latin |japanese |korean |chinese )?'
|
|
r'(?:woman|girl|man|boy|lady|gentleman|person))',
|
|
caption, re.I
|
|
)
|
|
if subject_match:
|
|
subject_parts.append(subject_match.group(1).strip())
|
|
else:
|
|
# Build from tags
|
|
ethnicity = classified.get('ethnicity', [])
|
|
subject = classified.get('subject', [])
|
|
parts = []
|
|
if ethnicity:
|
|
parts.append(ethnicity[0])
|
|
if subject:
|
|
s = subject[0]
|
|
if s.startswith('1'):
|
|
s = s[1:]
|
|
parts.append(s)
|
|
if parts:
|
|
subj_str = ' '.join(parts)
|
|
article = 'An' if subj_str[0].lower() in 'aeiou' else 'A'
|
|
subject_parts.append(f"{article} {subj_str}")
|
|
else:
|
|
subject_parts.append("A person")
|
|
|
|
sections['subject'] = subject_parts[0] if subject_parts else ''
|
|
|
|
# === SUBJECT DETAILS ===
|
|
details_parts = []
|
|
|
|
# Hair from caption or tags
|
|
hair_match = re.search(r'((?:long|short|medium|curly|straight|wavy)?\s*(?:black|brown|blonde|red|white|gray)\s*hair)', caption, re.I)
|
|
if hair_match:
|
|
details_parts.append(f"{subj} has {hair_match.group(1).strip()}")
|
|
elif classified.get('hair'):
|
|
details_parts.append(f"{subj} has {self._join_natural(classified['hair'])}")
|
|
|
|
# Eyes from caption or tags
|
|
eyes_match = re.search(r'((?:brown|blue|green|hazel|dark|light)\s*eyes)', caption, re.I)
|
|
if eyes_match:
|
|
details_parts.append(f"{poss.lower()} eyes are {eyes_match.group(1).strip()}")
|
|
elif classified.get('eyes'):
|
|
details_parts.append(f"{poss.lower()} eyes are {classified['eyes'][0]}")
|
|
|
|
# Skin/complexion from caption
|
|
skin_match = re.search(r'((?:fair|pale|tan|olive|dark|brown|warm|smooth)\s*(?:skin|complexion|skin tone))', caption, re.I)
|
|
if skin_match:
|
|
details_parts.append(f"{subj} has {skin_match.group(1).strip()}")
|
|
|
|
# Clothing from caption or tags
|
|
clothing_match = re.search(r'(?:wearing|dressed in|in)\s+(?:a\s+)?([^,\.]+(?:dress|saree|sari|outfit|suit|shirt|blouse|gown|robe)[^,\.]*)', caption, re.I)
|
|
if clothing_match:
|
|
details_parts.append(f"{subj} is wearing {clothing_match.group(1).strip()}")
|
|
elif classified.get('clothing'):
|
|
details_parts.append(f"{subj} is wearing {self._join_natural(classified['clothing'])}")
|
|
|
|
# Accessories from caption or tags
|
|
acc_match = re.search(r'(?:wearing|with|adorned with)\s+(?:a\s+)?([^,\.]*(?:jewelry|necklace|bracelet|earring|ring|watch)[^,\.]*)', caption, re.I)
|
|
if acc_match:
|
|
details_parts.append(f"{subj} is adorned with {acc_match.group(1).strip()}")
|
|
elif classified.get('accessories'):
|
|
acc_not_in_caption = [a for a in classified['accessories'] if a.lower() not in caption_lower]
|
|
if acc_not_in_caption:
|
|
details_parts.append(f"{subj} is adorned with {self._join_natural(acc_not_in_caption)}")
|
|
|
|
sections['subject_details'] = '. '.join(details_parts) + '.' if details_parts else ''
|
|
|
|
# === ACTION/POSE ===
|
|
action_parts = []
|
|
action_match = re.search(
|
|
r'(?:is\s+)?((?:standing|sitting|posing|lying|walking|leaning|looking|reclining|kneeling)[^,\.]*)',
|
|
caption, re.I
|
|
)
|
|
if action_match:
|
|
action_parts.append(f"{subj} is {action_match.group(1).strip()}")
|
|
elif classified.get('pose'):
|
|
action_parts.append(f"{subj} is {classified['pose'][0]}")
|
|
|
|
# Expression
|
|
expr_match = re.search(r'((?:smiling|serious|pensive|confident|relaxed|sensual|playful)\s*(?:expression)?)', caption, re.I)
|
|
if expr_match:
|
|
action_parts.append(f"{subj} has a {expr_match.group(1).strip()}")
|
|
elif classified.get('expression'):
|
|
action_parts.append(f"{subj} has a {classified['expression'][0]} expression")
|
|
|
|
# Gaze
|
|
gaze_match = re.search(r'(looking\s+(?:at the camera|directly|away|to the side|down|up)[^,\.]*)', caption, re.I)
|
|
if gaze_match:
|
|
action_parts.append(gaze_match.group(1).strip())
|
|
elif classified.get('gaze'):
|
|
action_parts.append(classified['gaze'][0])
|
|
|
|
sections['action'] = '. '.join(action_parts) + '.' if action_parts else ''
|
|
|
|
# === ENVIRONMENT/SCENE ===
|
|
env_parts = []
|
|
# Location from caption
|
|
env_match = re.search(
|
|
r'(?:in|at|against|before)\s+(?:a\s+)?([^,\.]*(?:room|studio|outdoor|indoor|background|setting|location|beach|forest|city|street|garden)[^,\.]*)',
|
|
caption, re.I
|
|
)
|
|
if env_match:
|
|
env_parts.append(f"The scene is set in {env_match.group(1).strip()}")
|
|
elif classified.get('environment'):
|
|
env_parts.append(f"The scene is set {classified['environment'][0]}")
|
|
|
|
# Background details
|
|
bg_match = re.search(r'(?:background|backdrop)\s+(?:is\s+)?([^,\.]+)', caption, re.I)
|
|
if bg_match:
|
|
env_parts.append(f"The background features {bg_match.group(1).strip()}")
|
|
|
|
sections['environment'] = '. '.join(env_parts) + '.' if env_parts else ''
|
|
|
|
# === LIGHTING ===
|
|
light_parts = []
|
|
light_match = re.search(
|
|
r'((?:soft|warm|dim|bright|natural|harsh|dramatic|low[- ]?key|high[- ]?key|ambient|diffused)[^,\.]*light[^,\.]*)',
|
|
caption, re.I
|
|
)
|
|
if light_match:
|
|
light_parts.append(f"The lighting is {light_match.group(1).strip()}")
|
|
elif classified.get('lighting'):
|
|
light_parts.append(f"The lighting features {self._join_natural(classified['lighting'])}")
|
|
|
|
# From photography analysis
|
|
photo = self.get_photography_info(metadata)
|
|
if photo:
|
|
if photo.get('low key', 0) > 0.7 or photo.get('low_key', 0) > 0.7:
|
|
if 'low key' not in caption_lower and 'low-key' not in ' '.join(light_parts).lower():
|
|
light_parts.append("Low-key lighting creates dramatic shadows")
|
|
if photo.get('warm', 0) > 0.7 or photo.get('warm tones', 0) > 0.7:
|
|
if 'warm' not in caption_lower and 'warm' not in ' '.join(light_parts).lower():
|
|
light_parts.append("Warm tones enhance the atmosphere")
|
|
|
|
# Atmosphere/mood
|
|
if classified.get('atmosphere'):
|
|
light_parts.append(f"The mood is {classified['atmosphere'][0]}")
|
|
|
|
sections['lighting'] = '. '.join(light_parts) + '.' if light_parts else ''
|
|
|
|
# === STYLE/MEDIUM ===
|
|
style_parts = []
|
|
style_match = re.search(
|
|
r'((?:photograph|portrait|cinematic|artistic|editorial|fashion|fine art)[^,\.]*)',
|
|
caption, re.I
|
|
)
|
|
if style_match:
|
|
style_parts.append(style_match.group(1).strip().capitalize())
|
|
elif classified.get('style'):
|
|
style_parts.append(classified['style'][0].capitalize())
|
|
else:
|
|
style_parts.append("Professional photograph")
|
|
|
|
sections['style'] = '. '.join(style_parts) + '.' if style_parts else ''
|
|
|
|
# === COMPOSITION ===
|
|
comp_parts = []
|
|
# Camera angle/shot type
|
|
shot_match = re.search(
|
|
r'((?:close[- ]?up|medium shot|full body|cowboy shot|portrait|wide shot|headshot)[^,\.]*)',
|
|
caption, re.I
|
|
)
|
|
if shot_match:
|
|
comp_parts.append(shot_match.group(1).strip().capitalize())
|
|
elif classified.get('camera'):
|
|
comp_parts.append(classified['camera'][0].capitalize())
|
|
|
|
# From composition analysis
|
|
comp_info = self.get_composition_info(metadata)
|
|
if comp_info:
|
|
if comp_info.get('centered subject', 0) > 0.7 or comp_info.get('centered_subject', 0) > 0.7:
|
|
comp_parts.append("Subject centered in frame")
|
|
if comp_info.get('rule of thirds', 0) > 0.6:
|
|
comp_parts.append("Rule of thirds composition")
|
|
|
|
sections['composition'] = '. '.join(comp_parts) + '.' if comp_parts else ''
|
|
|
|
# === TECHNICAL PARAMETERS ===
|
|
tech_parts = []
|
|
# Depth of field, bokeh, focus
|
|
if 'bokeh' in caption_lower or classified.get('focus'):
|
|
focus_terms = []
|
|
if 'bokeh' in caption_lower:
|
|
focus_terms.append('creamy bokeh')
|
|
if classified.get('focus'):
|
|
focus_terms.extend([f for f in classified['focus'] if f.lower() not in caption_lower])
|
|
if focus_terms:
|
|
tech_parts.append(f"Shallow depth of field with {self._join_natural(focus_terms)}")
|
|
|
|
# Sharp focus
|
|
if 'sharp' in caption_lower or 'crisp' in caption_lower:
|
|
tech_parts.append("Tack-sharp focus on subject")
|
|
|
|
sections['technical'] = '. '.join(tech_parts) + '.' if tech_parts else ''
|
|
|
|
return sections
|
|
|
|
def _build_canonical_prompt(self, sections: Dict[str, str]) -> str:
|
|
"""Build final prompt in canonical order with full sentences."""
|
|
parts = []
|
|
|
|
# 1. Quality Boosters
|
|
if sections['quality']:
|
|
parts.append(sections['quality'])
|
|
|
|
# 2. Subject
|
|
if sections['subject']:
|
|
parts.append(sections['subject'])
|
|
|
|
# 3. Subject Details
|
|
if sections['subject_details']:
|
|
parts.append(sections['subject_details'])
|
|
|
|
# 4. Action/Pose
|
|
if sections['action']:
|
|
parts.append(sections['action'])
|
|
|
|
# 5. Environment/Scene
|
|
if sections['environment']:
|
|
parts.append(sections['environment'])
|
|
|
|
# 6. Lighting
|
|
if sections['lighting']:
|
|
parts.append(sections['lighting'])
|
|
|
|
# 7. Style/Medium
|
|
if sections['style']:
|
|
parts.append(sections['style'])
|
|
|
|
# 8. Composition
|
|
if sections['composition']:
|
|
parts.append(sections['composition'])
|
|
|
|
# 9. Technical Parameters
|
|
if sections['technical']:
|
|
parts.append(sections['technical'])
|
|
|
|
# Join with spaces (each section already ends with period)
|
|
prompt = ' '.join(parts)
|
|
|
|
# Clean up any double periods or spaces
|
|
prompt = re.sub(r'\.+', '.', prompt)
|
|
prompt = re.sub(r'\s+', ' ', prompt)
|
|
|
|
return prompt.strip()
|