| """ |
| Image Correction Service |
| Analyzes images for spelling mistakes and visual issues, then regenerates corrected versions. |
| """ |
|
|
| import os |
| import sys |
| import logging |
| import time |
|
|
| |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
|
|
| import json |
| import uuid |
| from datetime import datetime |
| from typing import Dict, Any, Optional, Tuple |
|
|
| from config import settings |
| from services.llm import llm_service |
| from services.image import image_service |
|
|
| |
| logging.basicConfig( |
| level=logging.INFO, |
| format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', |
| datefmt='%Y-%m-%d %H:%M:%S' |
| ) |
| logger = logging.getLogger("correction_service") |
|
|
| |
| try: |
| from services.database import db_service |
| except ImportError: |
| db_service = None |
|
|
| |
| try: |
| from services.r2_storage import get_r2_storage |
| r2_storage_available = True |
| except ImportError: |
| r2_storage_available = False |
|
|
|
|
| class CorrectionService: |
| """Service for analyzing and correcting generated ad images.""" |
| |
| def __init__(self): |
| """Initialize the correction service.""" |
| self.output_dir = settings.output_dir |
| os.makedirs(self.output_dir, exist_ok=True) |
| |
| def _should_save_locally(self) -> bool: |
| """ |
| Determine if images should be saved locally based on environment settings. |
| |
| Returns: |
| True if images should be saved locally, False otherwise |
| """ |
| |
| if settings.environment.lower() == "production": |
| return settings.save_images_locally |
| |
| return True |
| |
| def _save_image_locally(self, image_bytes: bytes, filename: str) -> Optional[str]: |
| """ |
| Conditionally save image locally based on environment settings. |
| |
| Args: |
| image_bytes: Image data to save |
| filename: Filename for the image |
| |
| Returns: |
| Filepath if saved, None otherwise |
| """ |
| if not self._should_save_locally(): |
| return None |
| |
| try: |
| filepath = os.path.join(self.output_dir, filename) |
| os.makedirs(os.path.dirname(filepath), exist_ok=True) |
| with open(filepath, "wb") as f: |
| f.write(image_bytes) |
| return filepath |
| except Exception as e: |
| print(f"Warning: Failed to save image locally: {e}") |
| return None |
| |
| async def analyze_image( |
| self, |
| image_bytes: bytes, |
| original_prompt: Optional[str] = None, |
| ) -> Dict[str, Any]: |
| """ |
| Analyze an image for spelling mistakes and visual issues using vision API. |
| |
| Args: |
| image_bytes: Image file bytes to analyze |
| original_prompt: Optional original generation prompt for context |
| |
| Returns: |
| Analysis results dictionary |
| """ |
| start_time = time.time() |
| logger.info("=" * 60) |
| logger.info("Starting image analysis") |
| logger.info(f"Image size: {len(image_bytes)} bytes") |
| logger.info(f"Original prompt provided: {original_prompt is not None}") |
| |
| system_prompt = """You are an expert image quality analyst specializing in ad creatives. |
| Your task is to carefully analyze images for: |
| 1. Spelling mistakes in any visible text |
| 2. Visual composition issues (layout, positioning, balance) |
| 3. Color and contrast problems |
| 4. Lighting issues |
| 5. Overall quality and professionalism |
| |
| Be thorough and specific in your analysis.""" |
| |
| analysis_prompt = """Please analyze this ad creative image in detail. |
| |
| Focus on: |
| 1. TEXT & SPELLING: Extract all visible text and check for spelling mistakes, typos, or grammatical errors. List each error with the detected text and what it should be. |
| |
| 2. VISUAL ISSUES: Analyze the visual composition, including: |
| - Layout and positioning of elements |
| - Color choices and contrast |
| - Lighting and shadows |
| - Overall balance and composition |
| - Any visual elements that look unprofessional or could be improved |
| |
| 3. QUALITY ASSESSMENT: Provide an overall quality score (1-10) and identify the most critical issues that need correction. |
| |
| Be specific and actionable in your feedback.""" |
| |
| if original_prompt: |
| analysis_prompt += f"\n\nOriginal generation prompt: {original_prompt}" |
| logger.debug(f"Original prompt: {original_prompt[:100]}..." if len(original_prompt) > 100 else f"Original prompt: {original_prompt}") |
| |
| try: |
| logger.info("Calling vision API for image analysis...") |
| analysis_text = await llm_service.analyze_image_with_vision( |
| image_bytes=image_bytes, |
| analysis_prompt=analysis_prompt, |
| system_prompt=system_prompt, |
| ) |
| |
| elapsed_time = time.time() - start_time |
| logger.info(f"β Image analysis completed successfully in {elapsed_time:.2f}s") |
| logger.info(f"Analysis length: {len(analysis_text)} characters") |
| logger.debug(f"Analysis preview: {analysis_text[:200]}..." if len(analysis_text) > 200 else f"Analysis: {analysis_text}") |
| |
| return { |
| "analysis": analysis_text, |
| "status": "success", |
| } |
| except Exception as e: |
| elapsed_time = time.time() - start_time |
| logger.error(f"β Image analysis failed after {elapsed_time:.2f}s: {str(e)}") |
| logger.exception("Full error traceback:") |
| return { |
| "analysis": None, |
| "status": "error", |
| "error": str(e), |
| } |
| |
| async def generate_correction_json( |
| self, |
| analysis: str, |
| original_prompt: Optional[str] = None, |
| user_instructions: Optional[str] = None, |
| ) -> Dict[str, Any]: |
| """ |
| Generate structured JSON correction prompt from analysis. |
| |
| Args: |
| analysis: Vision API analysis text |
| original_prompt: Optional original generation prompt |
| user_instructions: Optional user-specified instructions |
| |
| Returns: |
| Correction JSON dictionary |
| """ |
| start_time = time.time() |
| logger.info("=" * 60) |
| logger.info("Generating correction JSON") |
| logger.info(f"User instructions provided: {user_instructions is not None}") |
| logger.info(f"Original prompt provided: {original_prompt is not None}") |
| logger.info(f"Analysis length: {len(analysis)} characters") |
| |
| if user_instructions: |
| logger.info(f"Using user-specified correction mode") |
| logger.debug(f"User instructions: {user_instructions}") |
| |
| system_prompt = """You are an expert prompt engineer specializing in image generation. |
| Your task is to create a corrected image generation prompt based on analysis feedback. |
| Generate a structured JSON response with spelling corrections and visual improvements.""" |
| |
| if user_instructions: |
| |
| correction_prompt = f"""The user wants to make a SPECIFIC, MINIMAL correction to an existing image using image-to-image generation. |
| The model will preserve 95% of the original image - only the specific change requested should be mentioned. |
| |
| User's correction request: {user_instructions} |
| |
| Original image prompt (for reference only - DO NOT recreate the image): {original_prompt or "Not provided"} |
| |
| Create a JSON response with this exact structure: |
| {{ |
| "spelling_corrections": [ |
| {{ |
| "detected": "incorrect text found in image", |
| "corrected": "corrected text", |
| "context": "where it appears in the image" |
| }} |
| ], |
| "visual_corrections": [ |
| {{ |
| "issue": "description of visual issue", |
| "suggestion": "specific improvement suggestion", |
| "priority": "high|medium|low" |
| }} |
| ], |
| "corrected_prompt": "MINIMAL prompt that ONLY specifies the exact change requested" |
| }} |
| |
| CRITICAL INSTRUCTIONS FOR corrected_prompt: |
| - The corrected_prompt must be MINIMAL and FOCUSED - only mention the specific change |
| - DO NOT describe the entire image or recreate it |
| - DO NOT change anything except what the user specified |
| - CRITICAL: The model will preserve 95% of the original image - only mention the ONE specific change |
| - IMPORTANT: Start with "Remove" or "Delete" for removal requests, "Change" for replacements |
| - For text removal: Use format like "Remove the text 'TEXT_TO_REMOVE'" or "Delete 'TEXT_TO_REMOVE'" |
| - For text changes: Use format like "Change text 'OLD' to 'NEW'" or "Replace 'X' with 'Y'" |
| - For visual changes: Use format like "Make colors brighter" or "Adjust lighting to be softer" |
| - Keep it under 15 words if possible - be extremely concise |
| - DO NOT mention any other elements, colors, layout, or composition - they will be preserved automatically |
| |
| Respond with valid JSON only, no markdown formatting.""" |
| else: |
| |
| correction_prompt = f"""Based on this image analysis, generate a structured correction plan: |
| |
| {analysis} |
| |
| Create a JSON response with this exact structure: |
| {{ |
| "spelling_corrections": [ |
| {{ |
| "detected": "incorrect text found in image", |
| "corrected": "corrected text", |
| "context": "where it appears in the image" |
| }} |
| ], |
| "visual_corrections": [ |
| {{ |
| "issue": "description of visual issue", |
| "suggestion": "specific improvement suggestion", |
| "priority": "high|medium|low" |
| }} |
| ], |
| "corrected_prompt": "Complete corrected image generation prompt that addresses all issues" |
| }} |
| |
| Important guidelines: |
| - The corrected_prompt should be a complete, ready-to-use image generation prompt |
| - Include all necessary visual details to fix the issues |
| - Maintain the original creative intent while fixing problems |
| - Be specific about text corrections, colors, composition, lighting, etc. |
| - If no original_prompt is provided, infer the creative intent from the analysis |
| |
| Respond with valid JSON only, no markdown formatting.""" |
| |
| if original_prompt and not user_instructions: |
| correction_prompt += f"\n\nOriginal prompt for reference:\n{original_prompt}" |
| |
| try: |
| logger.info("Calling LLM to generate correction JSON...") |
| correction_json = await llm_service.generate_json( |
| prompt=correction_prompt, |
| system_prompt=system_prompt, |
| temperature=0.3, |
| ) |
| |
| |
| if not isinstance(correction_json, dict): |
| raise ValueError("Correction response is not a dictionary") |
| |
| |
| if "corrected_prompt" not in correction_json: |
| raise ValueError("Missing 'corrected_prompt' in correction JSON") |
| |
| if "spelling_corrections" not in correction_json: |
| correction_json["spelling_corrections"] = [] |
| |
| if "visual_corrections" not in correction_json: |
| correction_json["visual_corrections"] = [] |
| |
| elapsed_time = time.time() - start_time |
| logger.info(f"β Correction JSON generated successfully in {elapsed_time:.2f}s") |
| logger.info(f"Spelling corrections found: {len(correction_json.get('spelling_corrections', []))}") |
| logger.info(f"Visual corrections found: {len(correction_json.get('visual_corrections', []))}") |
| logger.info(f"Corrected prompt length: {len(correction_json.get('corrected_prompt', ''))} characters") |
| logger.debug(f"Corrected prompt: {correction_json.get('corrected_prompt', '')}") |
| |
| if correction_json.get('spelling_corrections'): |
| logger.info("Spelling corrections:") |
| for i, correction in enumerate(correction_json['spelling_corrections'], 1): |
| logger.info(f" {i}. '{correction.get('detected', 'N/A')}' β '{correction.get('corrected', 'N/A')}'") |
| |
| if correction_json.get('visual_corrections'): |
| logger.info("Visual corrections:") |
| for i, correction in enumerate(correction_json['visual_corrections'], 1): |
| logger.info(f" {i}. [{correction.get('priority', 'N/A')}] {correction.get('issue', 'N/A')}") |
| |
| return { |
| "corrections": correction_json, |
| "status": "success", |
| } |
| except Exception as e: |
| elapsed_time = time.time() - start_time |
| logger.error(f"β Correction JSON generation failed after {elapsed_time:.2f}s: {str(e)}") |
| logger.exception("Full error traceback:") |
| return { |
| "corrections": None, |
| "status": "error", |
| "error": str(e), |
| } |
| |
| async def regenerate_image( |
| self, |
| corrected_prompt: str, |
| original_image_url: str, |
| width: int = 1024, |
| height: int = 1024, |
| user_instructions: Optional[str] = None, |
| ) -> Tuple[Optional[bytes], Optional[str], Optional[str]]: |
| """ |
| Regenerate image using nano-banana-pro model with corrected prompt and original image URL. |
| Uses minimal changes when user provides specific instructions. |
| |
| Args: |
| corrected_prompt: Corrected image generation prompt |
| original_image_url: Original image URL for image-to-image generation |
| width: Image width |
| height: Image height |
| user_instructions: Optional user instructions to determine strength |
| |
| Returns: |
| Tuple of (image_bytes, model_used, image_url) |
| """ |
| start_time = time.time() |
| logger.info("=" * 60) |
| logger.info("Regenerating image with corrections") |
| logger.info(f"Image dimensions: {width}x{height}") |
| logger.info(f"Original image URL: {original_image_url}") |
| logger.info(f"User instructions provided: {user_instructions is not None}") |
| logger.info(f"Model: nano-banana (image-to-image)") |
| logger.debug(f"Corrected prompt: {corrected_prompt}") |
| |
| try: |
| |
| |
| if user_instructions: |
| |
| |
| focused_prompt = corrected_prompt |
| logger.info("Using focused prompt for user-specified corrections") |
| else: |
| |
| focused_prompt = corrected_prompt |
| logger.info("Using full corrected prompt for auto-analysis") |
| |
| logger.info("Calling image service to generate corrected image...") |
| logger.info("Using minimal prompt to preserve original image (guidance_scale not supported by nano-banana)") |
| image_bytes, model_used, image_url = await image_service.generate( |
| prompt=focused_prompt, |
| model_key="nano-banana", |
| width=width, |
| height=height, |
| image_url=original_image_url, |
| ) |
| |
| if image_bytes: |
| elapsed_time = time.time() - start_time |
| logger.info(f"β Image regeneration completed successfully in {elapsed_time:.2f}s") |
| logger.info(f"Generated image size: {len(image_bytes)} bytes") |
| logger.info(f"Model used: {model_used}") |
| logger.info(f"Generated image URL: {image_url}") |
| return image_bytes, model_used, image_url |
| else: |
| elapsed_time = time.time() - start_time |
| logger.error(f"β Image regeneration returned no image data after {elapsed_time:.2f}s") |
| return None, None, None |
| except Exception as e: |
| elapsed_time = time.time() - start_time |
| logger.error(f"β Image regeneration failed after {elapsed_time:.2f}s: {str(e)}") |
| logger.exception("Full error traceback:") |
| return None, None, None |
| |
| async def correct_image( |
| self, |
| image_bytes: bytes, |
| image_url: str, |
| original_prompt: Optional[str] = None, |
| width: int = 1024, |
| height: int = 1024, |
| niche: Optional[str] = None, |
| user_instructions: Optional[str] = None, |
| auto_analyze: bool = False, |
| ) -> Dict[str, Any]: |
| """ |
| Complete correction workflow: analyze, generate corrections, and regenerate. |
| |
| Args: |
| image_bytes: Original image bytes to correct (for analysis) |
| image_url: Original image URL (for image-to-image generation) |
| original_prompt: Optional original generation prompt |
| width: Image width for regeneration |
| height: Image height for regeneration |
| niche: Optional niche name for filename generation |
| user_instructions: Optional user-specified correction instructions |
| auto_analyze: Whether to automatically analyze the image |
| |
| Returns: |
| Complete correction result dictionary |
| """ |
| workflow_start_time = time.time() |
| logger.info("=" * 80) |
| logger.info("CORRECTION WORKFLOW STARTED") |
| logger.info("=" * 80) |
| logger.info(f"Niche: {niche or 'N/A'}") |
| logger.info(f"Image dimensions: {width}x{height}") |
| logger.info(f"Original image URL: {image_url}") |
| logger.info(f"Original image size: {len(image_bytes)} bytes") |
| logger.info(f"Auto-analyze: {auto_analyze}") |
| logger.info(f"User instructions provided: {user_instructions is not None}") |
| if user_instructions: |
| logger.info(f"User instructions: {user_instructions}") |
| if original_prompt: |
| logger.info(f"Original prompt: {original_prompt[:100]}..." if len(original_prompt) > 100 else f"Original prompt: {original_prompt}") |
| |
| result = { |
| "status": "pending", |
| "analysis": None, |
| "corrections": None, |
| "corrected_image": None, |
| "error": None, |
| } |
| |
| |
| analysis_text = None |
| if user_instructions: |
| |
| logger.info("STEP 1: Using user-specified corrections (skipping auto-analysis)") |
| logger.info(f"User instructions: {user_instructions}") |
| analysis_text = f"User requested corrections: {user_instructions}" |
| elif auto_analyze: |
| logger.info("STEP 1: Analyzing image for issues...") |
| analysis_result = await self.analyze_image( |
| image_bytes=image_bytes, |
| original_prompt=original_prompt, |
| ) |
| |
| if analysis_result["status"] != "success": |
| elapsed_time = time.time() - workflow_start_time |
| logger.error(f"β Correction workflow failed at analysis step after {elapsed_time:.2f}s") |
| result["status"] = "error" |
| result["error"] = analysis_result.get("error", "Image analysis failed") |
| return result |
| |
| analysis_text = analysis_result["analysis"] |
| result["analysis"] = analysis_text |
| logger.info("β Step 1 completed: Image analysis successful") |
| else: |
| |
| elapsed_time = time.time() - workflow_start_time |
| logger.error(f"β Correction workflow failed: No analysis or user instructions provided (after {elapsed_time:.2f}s)") |
| result["status"] = "error" |
| result["error"] = "Either user_instructions or auto_analyze must be provided" |
| return result |
| |
| |
| logger.info("STEP 2: Generating correction plan...") |
| correction_result = await self.generate_correction_json( |
| analysis=analysis_text, |
| original_prompt=original_prompt, |
| user_instructions=user_instructions, |
| ) |
| |
| if correction_result["status"] != "success": |
| elapsed_time = time.time() - workflow_start_time |
| logger.error(f"β Correction workflow failed at correction generation step after {elapsed_time:.2f}s") |
| result["status"] = "error" |
| result["error"] = correction_result.get("error", "Correction generation failed") |
| return result |
| |
| result["corrections"] = correction_result["corrections"] |
| corrected_prompt = correction_result["corrections"]["corrected_prompt"] |
| logger.info("β Step 2 completed: Correction JSON generated successfully") |
| |
| |
| logger.info("STEP 3: Regenerating image with corrections...") |
| image_bytes_new, model_used, image_url_new = await self.regenerate_image( |
| corrected_prompt=corrected_prompt, |
| original_image_url=image_url, |
| width=width, |
| height=height, |
| user_instructions=user_instructions, |
| ) |
| |
| if not image_bytes_new: |
| elapsed_time = time.time() - workflow_start_time |
| logger.error(f"β Correction workflow failed at image regeneration step after {elapsed_time:.2f}s") |
| result["status"] = "error" |
| result["error"] = "Image regeneration failed" |
| return result |
| |
| logger.info("β Step 3 completed: Image regeneration successful") |
| |
| |
| logger.info("STEP 4: Saving corrected image...") |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| unique_id = uuid.uuid4().hex[:8] |
| niche_prefix = niche or "corrected" |
| filename = f"{niche_prefix}_corrected_{timestamp}_{unique_id}.png" |
| filepath = os.path.join("assets", "generated", filename) |
| |
| logger.info(f"Generated filename: {filename}") |
| |
| |
| r2_url = None |
| if r2_storage_available: |
| try: |
| logger.info("Uploading corrected image to R2 storage...") |
| r2_storage = get_r2_storage() |
| if r2_storage: |
| r2_url = r2_storage.upload_image( |
| image_bytes=image_bytes_new, |
| filename=filename, |
| niche=niche, |
| ) |
| logger.info(f"β Corrected image uploaded to R2: {r2_url}") |
| else: |
| logger.warning("R2 storage not available, skipping upload") |
| except Exception as e: |
| logger.warning(f"Failed to upload to R2: {e}. Saving locally as backup.") |
| logger.exception("R2 upload error details:") |
| else: |
| logger.info("R2 storage not available, skipping upload") |
| |
| |
| save_start = time.time() |
| filepath = self._save_image_locally(image_bytes_new, filename) |
| if filepath: |
| save_time = time.time() - save_start |
| logger.info(f"β Image saved locally: {filepath} (took {save_time:.2f}s)") |
| else: |
| logger.info("Image not saved locally (based on environment settings)") |
| |
| |
| final_image_url = r2_url or image_url_new |
| logger.info(f"Final image URL: {final_image_url}") |
| |
| result["corrected_image"] = { |
| "filename": filename, |
| "filepath": filepath, |
| "image_url": final_image_url, |
| "r2_url": r2_url, |
| "model_used": model_used, |
| "corrected_prompt": corrected_prompt, |
| } |
| result["status"] = "success" |
| |
| |
| result["_db_metadata"] = { |
| "filename": filename, |
| "image_url": final_image_url, |
| "r2_url": r2_url, |
| "model_used": model_used, |
| "corrected_prompt": corrected_prompt, |
| } |
| |
| total_time = time.time() - workflow_start_time |
| logger.info("=" * 80) |
| logger.info("β CORRECTION WORKFLOW COMPLETED SUCCESSFULLY") |
| logger.info(f"Total workflow time: {total_time:.2f}s") |
| logger.info(f"Corrected image filename: {filename}") |
| logger.info(f"Corrected image URL: {final_image_url}") |
| logger.info(f"Model used: {model_used}") |
| logger.info("=" * 80) |
| |
| return result |
|
|
|
|
| |
| correction_service = CorrectionService() |
|
|