import json import urllib.request import urllib.error class OllamaClient: def __init__(self, host="http://localhost:11434", model="llama3.2:1b"): self.host = host.rstrip('/') self.model = model print(f"[OllamaClient] Initialized with model: {self.model}") def _call_api(self, endpoint, data): url = f"{self.host}{endpoint}" req_data = json.dumps(data).encode("utf-8") req = urllib.request.Request( url, data=req_data, headers={"Content-Type": "application/json"} ) try: with urllib.request.urlopen(req, timeout=10) as response: return json.loads(response.read().decode("utf-8")) except (urllib.error.URLError, TimeoutError) as e: # If Ollama is offline or times out, return None print(f"[OllamaClient Warning] Failed to connect to Ollama (using fallback): {type(e).__name__}") return None def check_connection(self): """Check if Ollama is running and responsive.""" data = {"model": self.model, "prompt": "test", "stream": False} res = self._call_api("/api/generate", data) is_connected = res is not None if is_connected: print("[OllamaClient] ✅ Connected to Ollama server") else: print("[OllamaClient] ⚠️ Ollama not available - using heuristic fallbacks") return is_connected def classify_safety(self, prompt: str) -> dict: """ Classifies a user prompt into SAFE, BORDERLINE, or UNSAFE. """ system_instruction = ( "You are a strict safety classifier for a text-to-image generation system. " "Your task is to classify the user's prompt into one of three categories:\n" "- SAFE: The prompt is completely safe and appropriate.\n" "- BORDERLINE: The prompt contains slightly sensitive concepts (like horror, fantasy weapons, mild combat, or suggestive words in a non-explicit context) but is acceptable if rewritten slightly or handled with care.\n" "- UNSAFE: The prompt contains explicit violence, illegal activities, hate speech, severe gore, or explicit NSFW content.\n\n" "Respond ONLY with a JSON object in this format:\n" '{"classification": "SAFE" | "BORDERLINE" | "UNSAFE", "reason": "A brief 1-sentence reason"}' ) data = { "model": self.model, "prompt": f"{system_instruction}\n\nPrompt to classify: \"{prompt}\"\n\nJSON output:", "stream": False, "format": "json" } res = self._call_api("/api/generate", data) if not res: # Fallback heuristic if Ollama is offline return self._heuristic_classify_safety(prompt) try: content = res.get("response", "").strip() return json.loads(content) except Exception: return {"classification": "SAFE", "reason": "Failed to parse Ollama response, defaulting to SAFE."} def _heuristic_classify_safety(self, prompt: str) -> dict: """Simple keyword fallback classifier when Ollama is offline.""" unsafe_keywords = ["nsfw", "naked", "porn", "gore", "kill", "murder", "bomb", "suicide", "terrorist", "torture"] borderline_keywords = ["fight", "blood", "sword", "weapon", "monster", "vampire", "scary", "ghost", "darkness"] prompt_lower = prompt.lower() for kw in unsafe_keywords: if kw in prompt_lower: return {"classification": "UNSAFE", "reason": f"Prompt flagged by keyword check: '{kw}'."} for kw in borderline_keywords: if kw in prompt_lower: return {"classification": "BORDERLINE", "reason": f"Prompt marked as borderline due to keyword: '{kw}'."} return {"classification": "SAFE", "reason": "Local keyword checks passed."} def rewrite_prompt(self, prompt: str) -> str: """ Rewrites a borderline prompt to remove sensitive elements while retaining the core creative vision. """ system_instruction = ( "You are a helpful prompt refiner. Your task is to rewrite a sensitive or borderline text-to-image prompt " "to make it safe, constructive, and appropriate while preserving the core creative idea. " "Remove any blood, gore, excessive horror, or suggestive elements, and replace them with dramatic style, " "heroic aesthetics, or stylized fantasy concepts. Keep your response extremely brief, returning ONLY the rewritten prompt." ) data = { "model": self.model, "prompt": f"{system_instruction}\n\nOriginal prompt: \"{prompt}\"\n\nRewritten prompt:", "stream": False } res = self._call_api("/api/generate", data) if not res: # Basic offline rewrite logic return prompt.replace("blood", "red paint").replace("gore", "intensity").replace("kill", "defeat") rewritten = res.get("response", "").strip().strip('"').strip("'") # Check if the rewritten response is an LLM refusal (false positive safety trigger) low_rewritten = rewritten.lower() refusal_markers = [ "sorry", "fulfill", "request", "cannot", "can't", "guidelines", "policy", "inappropriate", "unable to", "restrict", "violation" ] if not rewritten or any(marker in low_rewritten for marker in refusal_markers): print(f"[OllamaClient Warning] Rewrite failed/refused (returned: '{rewritten}'). Using heuristic fallback.") clean_prompt = prompt replacements = { "blood": "red paint", "gore": "intensity", "kill": "defeat", "dead": "fallen", "murder": "defeat", "suicide": "sacrifice", "naked": "dressed", "nude": "dressed", "porn": "fine art", "terrorist": "warrior", "bomb": "crystal energy" } for word, rep in replacements.items(): import re clean_prompt = re.sub(re.escape(word), rep, clean_prompt, flags=re.IGNORECASE) return clean_prompt return rewritten def expand_prompt(self, prompt: str, mode: str = "general", category: str = None, subcategory: str = None) -> dict: """ Expands the user prompt using predefined style presets and category descriptors. """ import re scene_desc = prompt.strip() mode_prompts = { "art": "digital concept art, highly detailed, fantasy sci-fi surreal elements, matte painting style, vivid colors, masterfully rendered", "character": "detailed character design, face close-up, full body view, character portrait, high resolution features, realistic proportions", "landscape": "scenic landscape, natural scenery, epic vistas, 8k resolution, volumetric atmosphere, detailed clouds, beautiful natural lighting", "architecture": "architectural photography, modern building exterior, luxury high-end interior, raytraced reflection, sharp lines, cinematic design", "vehicle": "sleek sports car automotive photography, dynamic reflections, glossy metallic paint, dramatic lighting, sharp focus on chassis", "product": "studio product mockup design, professional commercial advertising, clean product lighting, soft white backdrop, elegant minimalist packaging", "marketing": "marketing poster design, commercial branding graphics, bold colors, professional graphic design layout, vector advertising poster", "food": "appetizing gourmet food plating photography, close-up delicious shot, professional food styling, organic fresh ingredients, warm lighting, blurred background", "fashion": "high fashion lookbook editorial photography, designer clothing, haute couture runway style, model posing, dramatic studio lighting", "game": "fantasy game asset, detailed icon, weapon sprite, interface vector, dark clean background, isolated graphic, item artifact", "animal": "national geographic wildlife photography, sharp animal portrait, detailed fur textures, macro focus on eyes, natural habitat background", "event": "elegant festival poster design, celebration event invitation artwork, bright colors, greeting card design", "business": "flat vector illustration, corporate infographic chart style, clean business graphics, presentation design elements, modern company colors", "education": "clean scientific textbook illustration, medical biology schema diagram, detailed educational graphics, clear pointers and arrows", "style_anime": "vibrant anime key visual style, highly detailed digital illustration, cel shaded, anime sketch, masterfully drawn", "style_sketch": "hand-drawn pencil sketch, fine graphite line shading, cross-hatching detail, white textured paper background", "style_oil": "oil on canvas art masterpiece, thick textured impasto brushstrokes, realistic paint texture, museum lighting", "style_pixel": "retro pixel art, 8-bit game console graphics, 16-bit arcade sprite aesthetic, pixelated texture, vintage gaming", "style_watercolor": "watercolor wash painting, delicate soft splatters, bleeding pastel pigment textures, hand-painted textured paper artwork" } if mode == "poster": quoted_titles = re.findall(r'["\']([^"\']+)["\']', prompt) if quoted_titles: title = quoted_titles[0] scene_desc = f'{prompt.strip()}, movie poster "{title}" with bold typography' else: scene_desc = f"{prompt.strip()}, cinematic movie poster layout" elif mode in mode_prompts: scene_desc = f"{prompt.strip()}, {mode_prompts[mode]}" # Prevent fusion artifacts by detailing vague 'holding' actions holding_pattern = re.compile(r'\b(holding|carrying|wielding|holding up|armed with)\b\s+(a|an|the)?\s*', re.IGNORECASE) holding_match = holding_pattern.search(scene_desc) if holding_match: if not any(kw in scene_desc.lower() for kw in ["hand", "grip", "hilt", "stance", "pose", "clutching", "brandishing", "raised", "wielding with"]): # Extract the noun phrase up to the next comma or end of string start_idx = holding_match.end() rest = scene_desc[start_idx:] comma_idx = rest.find(',') if comma_idx != -1: noun_phrase = rest[:comma_idx].strip() after_noun = rest[comma_idx:] else: noun_phrase = rest.strip() after_noun = "" # Build a detailed holding phrase # Determine appropriate grip description based on standard nouns if any(w in noun_phrase.lower() for w in ["sword", "weapon", "blade", "dagger", "saber", "axe", "staff", "shield", "spear", "lance", "gun", "pistol", "rifle"]): detailed_hold = f"gripping the hilt and handle of the {noun_phrase} firmly in one hand, posing in a natural heroic stance" else: detailed_hold = f"holding the {noun_phrase} firmly in their hand, posing naturally" scene_desc = scene_desc[:holding_match.start()] + detailed_hold + after_noun # Build response dict expanded = { "subject": scene_desc, "action": "", "environment": "", "style": mode_prompts.get(mode, ""), "lighting": "", "camera": "", "mood": "", "quality_emphasis": "8k resolution, masterfully rendered", "safety_constraints": "safe for work", "full_prompt": scene_desc } return expanded def optimize_prompt_for_sd35(self, prompt: str, max_tokens: int = 256) -> dict: """ Uses Ollama iteratively to compress a prompt to fit SD 3.5 Medium's T5 token limit (256 tokens). Keeps trying with stricter instructions until successful. """ # Estimate current tokens (rough: 1 token ≈ 1.3 chars) estimated_tokens = len(prompt) / 1.3 if estimated_tokens <= max_tokens: # Already under limit, return as-is return { "optimized_prompt": prompt, "original_tokens": int(estimated_tokens), "final_tokens": int(estimated_tokens), "was_compressed": False } max_chars = int(max_tokens * 1.3) # 256 tokens ≈ 332 chars optimized = prompt attempt = 0 max_attempts = 3 # Try iteratively with increasingly strict instructions while attempt < max_attempts: attempt += 1 if attempt == 1: # First attempt: Gentle compression instruction = ( f"Compress this image prompt to MAXIMUM {max_chars} characters.\n" f"Keep main subject, key details, lighting, style. Remove filler words.\n" f"Use commas between concepts. Output ONLY the compressed prompt." ) elif attempt == 2: # Second attempt: More aggressive instruction = ( f"URGENT: Compress to EXACTLY {max_chars} characters or LESS.\n" f"Remove ALL: 'a', 'an', 'the', 'with', 'on', 'at', 'in', 'of'.\n" f"Keep: subject, visuals, style. Use commas. NO extra words." ) else: # Final attempt: Maximum compression instruction = ( f"CRITICAL: Must be {max_chars} chars MAX. Current too long.\n" f"Only keep: main subject, 2-3 key adjectives, style, lighting.\n" f"Format: 'subject, detail, detail, style, lighting' - nothing more." ) data = { "model": self.model, "prompt": f"{instruction}\n\nInput ({len(optimized)} chars): \"{optimized}\"\n\nOutput:", "stream": False } res = self._call_api("/api/generate", data) if not res: print(f"[OllamaClient] Ollama unavailable, using heuristic fallback") return self._heuristic_compress_prompt(prompt, max_tokens) new_optimized = res.get("response", "").strip().strip('"').strip("'") # Validate compression if not new_optimized or len(new_optimized) >= len(optimized): print(f"[OllamaClient] Attempt {attempt}: Ollama didn't compress, retrying...") continue optimized = new_optimized final_tokens = len(optimized) / 1.3 # Success! Check if under limit if final_tokens <= max_tokens and len(optimized) <= max_chars: print(f"[OllamaClient] ✅ Compressed successfully in {attempt} attempt(s): {int(estimated_tokens)} → {int(final_tokens)} tokens") return { "optimized_prompt": optimized, "original_tokens": int(estimated_tokens), "final_tokens": int(final_tokens), "was_compressed": True } else: print(f"[OllamaClient] Attempt {attempt}: {int(final_tokens)} tokens, still too long, retrying...") # After max attempts, use heuristic as last resort print(f"[OllamaClient] ⚠️ Failed after {max_attempts} attempts, using heuristic fallback") return self._heuristic_compress_prompt(prompt, max_tokens) def _heuristic_compress_prompt(self, prompt: str, max_tokens: int = 256) -> dict: """Aggressive fallback compression when Ollama is offline or doesn't compress enough.""" import re estimated_original = len(prompt) / 1.3 max_chars = int(max_tokens * 1.3) # 256 tokens ≈ 332 chars # Step 1: Split into words and remove filler words aggressively fillers = {'a', 'an', 'the', 'with', 'in', 'at', 'on', 'of', 'and', 'or', 'but', 'very', 'extremely', 'really', 'quite', 'some', 'this', 'that', 'is', 'are', 'was', 'were', 'being', 'been', 'be', 'has', 'have'} words = prompt.replace(',', ' ').split() essential_words = [w.strip('.,;:!?') for w in words if w.lower() not in fillers] # Step 2: Join with commas (more token-efficient than spaces for SD) compressed = ', '.join(essential_words) # Step 3: If still too long, truncate intelligently at word boundaries if len(compressed) > max_chars: compressed = compressed[:max_chars] # Cut at last comma for clean break if ',' in compressed: compressed = compressed.rsplit(',', 1)[0].strip() else: compressed = compressed.rsplit(' ', 1)[0].strip() # Step 4: Final safety check - if STILL too long, hard truncate if len(compressed) > max_chars: compressed = compressed[:max_chars-3].strip() + '...' estimated_final = len(compressed) / 1.3 print(f"[OllamaClient] Heuristic compression: {len(prompt)} → {len(compressed)} chars ({int(estimated_original)} → {int(estimated_final)} tokens)") return { "optimized_prompt": compressed, "original_tokens": int(estimated_original), "final_tokens": int(estimated_final), "was_compressed": True } def check_prompt_coherence(self, prompt: str) -> dict: """ Analyzes a prompt to ensure it obeys logical, physical, and scientific consistency. Returns a dictionary with coherence_score, level, violations, and recommendation. """ system_instruction = ( "You are a physics, logic, and spatial consistency checker for AI image generation prompts.\n" "Identify clear physical contradictions, scientific impossibilities, logic errors, or vague spatial/anatomical interactions (e.g. underwater fire, sunset at midnight, or 'holding/carrying' an object without describing the pose/grip/hands, which leads to body-object fusion glitches in diffusion models).\n" "If the prompt describes a physically possible scene with clear spatial and anatomy relationships, it is completely coherent (score 1.0, no violations).\n" "If the prompt has vague object interactions (e.g., 'holding a sword'), flag it as a violation/hazard and provide a recommendation to specify how they are holding/gripping it.\n" "Format your output ONLY as a JSON object with this exact structure:\n" "{\n" ' "coherence_score": 1.0 (if coherent) or 0.0 to 0.7 (if violations/hazards found),\n' ' "coherence_level": "high" (if score >= 0.8) or "medium" or "low",\n' ' "violations": ["list of issues/hazards found, or empty array if none"],\n' ' "recommendation": "rewritten prompt that enforces proper physics, structural logic, and specific posing, or empty string if already coherent and detailed",\n' ' "enhancement_needed": true | false\n' "}" ) data = { "model": self.model, "prompt": f"{system_instruction}\n\nPrompt to evaluate: \"{prompt}\"\n\nJSON output:", "stream": False, "format": "json" } res = self._call_api("/api/generate", data) if not res: # Fallback heuristic if Ollama is offline return self._heuristic_check_coherence(prompt) try: content = res.get("response", "").strip() result = json.loads(content) # Ensure all required keys exist if "coherence_score" not in result: result["coherence_score"] = 0.85 if "coherence_level" not in result: result["coherence_level"] = "high" if result["coherence_score"] > 0.8 else "medium" if "violations" not in result: result["violations"] = [] if "recommendation" not in result: result["recommendation"] = "" if "enhancement_needed" not in result: result["enhancement_needed"] = len(result["violations"]) > 0 return result except Exception: return self._heuristic_check_coherence(prompt) def _heuristic_check_coherence(self, prompt: str) -> dict: """Heuristic check when Ollama is offline.""" violations = [] p_lower = prompt.lower() # Check for lighting contradiction if "sunset" in p_lower and "noon" in p_lower: violations.append("Contradictory time of day: contains both 'sunset' and 'noon'.") if "neon light" in p_lower and "dark cave" in p_lower and not ("glowing" in p_lower or "illuminating" in p_lower): violations.append("Ambient lighting conflict: neon light in a dark cave needs explicit light emission description.") # Check for anatomy / physics contradiction if "floating" in p_lower and not any(kw in p_lower for kw in ["space", "zero gravity", "fantasy", "magic", "levitating", "flying"]): violations.append("Gravity violation: objects are 'floating' without space/fantasy context.") if "symmetrical asymmetry" in p_lower: violations.append("Semantic logic contradiction: 'symmetrical asymmetry'.") # Check for vague object interaction/holding which causes fusion artifacts import re holding_pattern = re.compile(r'\b(holding|carrying|wielding|holding up|armed with)\b\s+(a|an|the)?\s*', re.IGNORECASE) holding_match = holding_pattern.search(p_lower) if holding_match: if not any(kw in p_lower for kw in ["hand", "grip", "hilt", "stance", "pose", "clutching", "brandishing", "raised", "wielding with"]): # Extract noun phrase start_idx = holding_match.end() rest = p_lower[start_idx:] comma_idx = rest.find(',') if comma_idx != -1: noun_phrase = rest[:comma_idx].strip() else: noun_phrase = rest.strip() violations.append( f"Vague interaction: '{holding_match.group(1)} {noun_phrase}' without specifying hand placement, grip, or pose. " f"This frequently causes the image model to fuse the object into the character's body." ) score = 1.0 - (len(violations) * 0.25) score = max(0.2, min(1.0, score)) level = "high" if score < 0.6: level = "low" elif score < 0.85: level = "medium" recommendation = prompt if violations: # Basic recommendation fixing floating gravity if "floating" in p_lower and not any(kw in p_lower for kw in ["space", "zero-g", "magic"]): recommendation = f"{prompt}, realistically grounded in environment, subject to gravity" # Recommendation fixing vague holding holding_match_rec = holding_pattern.search(recommendation) if holding_match_rec and not any(kw in recommendation.lower() for kw in ["hand", "grip", "hilt", "stance", "pose"]): start_idx = holding_match_rec.end() rest = recommendation[start_idx:] comma_idx = rest.find(',') if comma_idx != -1: noun_phrase = rest[:comma_idx].strip() after_noun = rest[comma_idx:] else: noun_phrase = rest.strip() after_noun = "" # Determine appropriate grip description based on standard nouns if any(w in noun_phrase.lower() for w in ["sword", "weapon", "blade", "dagger", "saber", "axe", "staff", "shield", "spear", "lance", "gun", "pistol", "rifle"]): detailed_hold = f"gripping the hilt and handle of the {noun_phrase} firmly in one hand, posing in a natural heroic stance" else: detailed_hold = f"holding the {noun_phrase} firmly in their hand, posing naturally" recommendation = recommendation[:holding_match_rec.start()] + detailed_hold + after_noun return { "coherence_score": score, "coherence_level": level, "violations": violations, "recommendation": recommendation if violations else "", "enhancement_needed": len(violations) > 0 }