Implement PIL text overlay for hero images
- Add Pillow dependency for image processing - Remove text instructions from fal.ai prompts (generate clean images) - Add semi-transparent dark background box behind text for readability - Overlay full title text with white text and black outline - Add proper line spacing between text lines - Fix FAL_KEY environment variable setup - Add image URL logging to console output during batch processing - Remove unused h2-prompts filemain
parent
8379313f51
commit
be03594fc7
|
|
@ -33,6 +33,7 @@ beautifulsoup4==4.14.2
|
|||
# AI/ML
|
||||
openai==2.5.0
|
||||
fal-client==0.9.1
|
||||
Pillow>=10.0.0
|
||||
# Testing
|
||||
pytest==8.4.2
|
||||
pytest-asyncio==0.21.1
|
||||
|
|
|
|||
|
|
@ -120,9 +120,8 @@ def test_image_generation(project_id: int):
|
|||
try:
|
||||
# Show the prompt that will be used
|
||||
theme = image_generator.get_theme_prompt(project_id)
|
||||
title_short = truncate_title(article.title, 4)
|
||||
hero_prompt = f"{theme} Text: '{title_short}' in clean simple uppercase letters, positioned in middle of image."
|
||||
click.echo(f" Prompt: {hero_prompt}")
|
||||
click.echo(f" Prompt: {theme}")
|
||||
click.echo(f" Title (will be overlaid): {article.title}")
|
||||
|
||||
hero_image = image_generator.generate_hero_image(
|
||||
project_id=project_id,
|
||||
|
|
@ -234,9 +233,8 @@ def test_image_generation(project_id: int):
|
|||
try:
|
||||
# Show the prompt that will be used
|
||||
theme = image_generator.get_theme_prompt(project_id)
|
||||
title_short = truncate_title(article.title, 4)
|
||||
hero_prompt = f"{theme} Text: '{title_short}' in clean simple uppercase letters, positioned in middle of image."
|
||||
click.echo(f" Prompt: {hero_prompt}")
|
||||
click.echo(f" Prompt: {theme}")
|
||||
click.echo(f" Title (will be overlaid): {article.title}")
|
||||
|
||||
hero_image = image_generator.generate_hero_image(
|
||||
project_id=project_id,
|
||||
|
|
|
|||
|
|
@ -482,7 +482,7 @@ class BatchProcessor:
|
|||
img_url = upload_image_to_storage(storage_client, site, content_image, file_path)
|
||||
if img_url:
|
||||
content_image_urls.append(img_url)
|
||||
click.echo(f"{prefix} Content image {i+1}/{num_images} uploaded")
|
||||
click.echo(f"{prefix} Content image {i+1}/{num_images} uploaded: {img_url}")
|
||||
except Exception as e:
|
||||
click.echo(f"{prefix} Content image {i+1} generation failed: {e}")
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import os
|
|||
import re
|
||||
import random
|
||||
import logging
|
||||
import io
|
||||
import requests
|
||||
from typing import Optional, Tuple
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import fal_client
|
||||
from src.generation.ai_client import AIClient, PromptManager
|
||||
from src.database.repositories import ProjectRepository
|
||||
|
|
@ -75,7 +77,7 @@ class ImageGenerator:
|
|||
theme_prompt, _ = self.ai_client.generate_completion(
|
||||
prompt=user_prompt,
|
||||
system_message=system_msg,
|
||||
max_tokens=200,
|
||||
max_tokens=300,
|
||||
temperature=0.7
|
||||
)
|
||||
|
||||
|
|
@ -86,6 +88,94 @@ class ImageGenerator:
|
|||
logger.info(f"Generated theme prompt for project {project_id}")
|
||||
return project.image_theme_prompt
|
||||
|
||||
def _overlay_text_on_image(
|
||||
self,
|
||||
image_bytes: bytes,
|
||||
text: str,
|
||||
width: int,
|
||||
height: int
|
||||
) -> bytes:
|
||||
"""Overlay text on image using PIL"""
|
||||
img = Image.open(io.BytesIO(image_bytes))
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
try:
|
||||
font_size = width // 15
|
||||
font = ImageFont.truetype("arial.ttf", font_size)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Wrap text to fit within 80% of image width
|
||||
max_width = int(width * 0.8)
|
||||
words = text.split()
|
||||
lines = []
|
||||
current_line = []
|
||||
|
||||
for word in words:
|
||||
test_line = " ".join(current_line + [word])
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||
if bbox[2] - bbox[0] <= max_width:
|
||||
current_line.append(word)
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(" ".join(current_line))
|
||||
current_line = [word]
|
||||
if current_line:
|
||||
lines.append(" ".join(current_line))
|
||||
|
||||
# Calculate text position (centered)
|
||||
line_height = draw.textbbox((0, 0), "A", font=font)[3] - draw.textbbox((0, 0), "A", font=font)[1]
|
||||
line_spacing = int(line_height * 1.3) # Add 30% spacing between lines
|
||||
total_height = len(lines) * line_spacing
|
||||
y_start = (height - total_height) // 2
|
||||
|
||||
# Calculate bounding box for all text
|
||||
max_line_width = 0
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
line_width = bbox[2] - bbox[0]
|
||||
if line_width > max_line_width:
|
||||
max_line_width = line_width
|
||||
|
||||
# Draw semi-transparent dark background box
|
||||
padding = 20
|
||||
box_x1 = (width - max_line_width) // 2 - padding
|
||||
box_y1 = y_start - padding
|
||||
box_x2 = (width + max_line_width) // 2 + padding
|
||||
box_y2 = y_start + total_height + padding
|
||||
|
||||
overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))
|
||||
overlay_draw = ImageDraw.Draw(overlay)
|
||||
overlay_draw.rectangle([box_x1, box_y1, box_x2, box_y2], fill=(0, 0, 0, 180))
|
||||
img = Image.alpha_composite(img, overlay)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw each line
|
||||
y = y_start
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
x = (width - text_width) // 2
|
||||
|
||||
# Draw text with black outline for contrast
|
||||
for adj in range(-2, 3):
|
||||
for adj2 in range(-2, 3):
|
||||
draw.text((x + adj, y + adj2), line, font=font, fill="black")
|
||||
draw.text((x, y), line, font=font, fill="white")
|
||||
y += line_spacing
|
||||
|
||||
# Convert back to RGB for JPEG
|
||||
if img.mode == 'RGBA':
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Save to bytes
|
||||
output = io.BytesIO()
|
||||
img.save(output, format='JPEG', quality=95)
|
||||
return output.getvalue()
|
||||
|
||||
def generate_hero_image(
|
||||
self,
|
||||
project_id: int,
|
||||
|
|
@ -100,8 +190,7 @@ class ImageGenerator:
|
|||
|
||||
try:
|
||||
theme = self.get_theme_prompt(project_id)
|
||||
title_short = truncate_title(title, 4)
|
||||
prompt = f"{theme} Text: '{title_short}' in clean simple uppercase letters, positioned in middle of image."
|
||||
prompt = theme
|
||||
|
||||
logger.info(f"Generating hero image with prompt: {prompt}")
|
||||
|
||||
|
|
@ -141,7 +230,11 @@ class ImageGenerator:
|
|||
logger.info(f"Downloading hero image from URL: {image_url}")
|
||||
response = requests.get(image_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
image_bytes = response.content
|
||||
|
||||
# Overlay text on image
|
||||
image_bytes = self._overlay_text_on_image(image_bytes, title, width, height)
|
||||
return image_bytes
|
||||
|
||||
logger.error(f"No image returned from fal.ai. Response: {result}")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
h2_prompts = {
|
||||
"general": "Clean, professional illustration related to {h2_topic}, modern business style, simple geometric shapes, corporate color palette, minimalist design, high-quality vector art style",
|
||||
|
||||
"technical": "Technical diagram or infographic about {h2_topic}, clean lines, professional schematic style, industrial design, blue and gray tones, modern technical illustration",
|
||||
|
||||
"process": "Step-by-step process visualization for {h2_topic}, clean flowchart style, professional arrows and connections, corporate color scheme, modern infographic design",
|
||||
|
||||
"benefits": "Professional icon-based illustration showing {h2_topic}, clean symbol design, business-friendly colors, modern flat design style, organized layout"
|
||||
}
|
||||
Loading…
Reference in New Issue