"""
AI-powered Meme Generator
- Pick a local image or image URL
- Generate captions with OpenAI (optional)
- Render meme-style text (top/bottom) on the image and save
Dependencies:
pip install openai pillow requests python-dotenv
"""
import os
import textwrap
import requests
from io import BytesIO
from typing import List, Optional
from PIL import Image, ImageDraw, ImageFont, ImageOps
# Optional: OpenAI
try:
import openai
OPENAI_OK = True
except Exception:
OPENAI_OK = False
# Optional .env loader
try:
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
# If OPENAI_API_KEY in env, set key for openai package
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if OPENAI_API_KEY and OPENAI_OK:
openai.api_key = OPENAI_API_KEY
# ---------------------------
# Config
# ---------------------------
DEFAULT_FONT = None # if None, use PIL default (or set path to .ttf)
FONT_SIZE_RATIO = 0.07 # fraction of image height to use as font size for main caption
STROKE_WIDTH_RATIO = 0.008 # stroke width relative to image height
TOP_BOTTOM_MARGIN_RATIO = 0.03 # vertical margin as fraction of image height
MAX_LINES = 3 # maximum lines of text for top/bottom each
# Some offline caption templates (fallback)
OFFLINE_TEMPLATES = [
"When you finally fix the bug and the build passes",
"Me: I'll sleep early tonight\nAlso me at 3 AM:",
"That feeling when coffee kicks in",
"POV: You open the fridge and forget what you wanted",
"When your code works on the first run",
"Expectation vs Reality",
"When someone says 'just restart it'",
"When the meeting could have been an email",
"I don't always test my code, but when I do, I do it in production",
"When you say you'll 'quickly' refactor"
]
# ---------------------------
# Utilities: Image loading
# ---------------------------
def load_image_from_path_or_url(path_or_url: str) -> Image.Image:
"""Load image from filesystem path or HTTP(S) URL."""
if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
resp = requests.get(path_or_url, timeout=15)
resp.raise_for_status()
return Image.open(BytesIO(resp.content)).convert("RGBA")
else:
return Image.open(path_or_url).convert("RGBA")
# ---------------------------
# AI caption generation
# ---------------------------
def generate_captions_ai(prompt_context: str, n: int = 6, engine: str = "gpt-4") -> List[str]:
"""
Use OpenAI to generate meme caption suggestions.
- prompt_context: short description of the image or theme
- n: number of suggestions
"""
if not OPENAI_API_KEY or not OPENAI_OK:
raise RuntimeError("OpenAI SDK not available or OPENAI_API_KEY not set.")
# Compose a concise system/user prompt to ask for short, meme-style captions.
system = (
"You are a creative meme caption generator. "
"Given a short description of an image or theme, produce short, humorous, internet-style captions. "
"Return an array of captions without numbering. Keep each caption to 1-2 lines. Avoid offensive content."
)
user = (
f"Image description / theme: {prompt_context}\n\n"
"Produce exactly {} short caption suggestions (1-2 lines each). Use casual, meme-friendly tone."
.format(n)
)
# Use Chat Completions API (ChatCompletion) if available
try:
resp = openai.ChatCompletion.create(
model="gpt-4",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user}
],
max_tokens=400,
temperature=0.8,
n=1,
)
content = resp["choices"][0]["message"]["content"].strip()
# Try to split into lines or bullets
captions = []
for line in content.splitlines():
line = line.strip()
if not line:
continue
# strip bullet numbers
if line[0].isdigit() and (line[1:3] == "." or line[1:2] == ")"):
line = line.split(".", 1)[-1].strip()
if line.startswith("-") or line.startswith("•"):
line = line[1:].strip()
captions.append(line)
# If we have fewer than requested, split by ';' or ' / '
if len(captions) < n:
parts = [p.strip() for p in content.replace("\n", ";").split(";") if p.strip()]
captions = parts[:n]
return captions[:n]
except Exception as e:
# fallback: raise to caller
raise RuntimeError(f"OpenAI request failed: {e}")
# ---------------------------
# Text rendering on image (classic meme style)
# ---------------------------
def select_font(size_px: int) -> ImageFont.FreeTypeFont:
"""Try to load Impact-like font or fallback to default PIL font."""
# Common Impact paths (Windows). You can ship an included ttf with your blog repo.
paths = [
"Impact.ttf", # local copy
"/usr/share/fonts/truetype/impact/Impact.ttf",
"/usr/share/fonts/truetype/msttcorefonts/Impact.ttf",
"/Library/Fonts/Impact.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
]
if DEFAULT_FONT:
paths.insert(0, DEFAULT_FONT)
for p in paths:
try:
if os.path.exists(p):
return ImageFont.truetype(p, size_px)
except Exception:
continue
# Fallback
return ImageFont.load_default()
def draw_text_with_stroke(draw: ImageDraw.Draw, position, text, font, fill="white", stroke_fill="black", stroke_width=2, align="center"):
"""Draw text with stroke (outline) for good readability."""
x, y = position
# Pillow >=8 supports stroke parameters; but to be robust we do manual strokes around offsets
try:
draw.text((x, y), text, font=font, fill=fill, stroke_width=stroke_width, stroke_fill=stroke_fill, anchor="ms", align=align)
except TypeError:
# manual stroke (8 neighbors)
for dx in range(-stroke_width, stroke_width+1):
for dy in range(-stroke_width, stroke_width+1):
if dx == 0 and dy == 0:
continue
draw.text((x+dx, y+dy), text, font=font, fill=stroke_fill, anchor="ms", align=align)
draw.text((x, y), text, font=font, fill=fill, anchor="ms", align=align)
def wrap_text_for_width(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]:
"""Wrap text into lines that fit into max_width using the provided font."""
lines = []
# try naive wrap
wrapper = textwrap.TextWrapper(width=60)
words = text.split()
if not words:
return []
# greedy algorithm: keep adding words until width exceeded
cur = words[0]
for w in words[1:]:
test = cur + " " + w
if font.getsize(test)[0] <= max_width:
cur = test
else:
lines.append(cur)
cur = w
lines.append(cur)
# If there are still too many lines, try to compress line breaks (reduce number of lines)
return lines
def render_meme(image: Image.Image, top_text: Optional[str], bottom_text: Optional[str], out_path: str):
"""
Render top and/or bottom text on the image in meme style and save to out_path.
- top_text and bottom_text can be multi-line; function will wrap to fit.
"""
img = image.convert("RGBA")
w, h = img.size
draw = ImageDraw.Draw(img)
# Decide font sizes relative to image height
font_size = max(14, int(h * FONT_SIZE_RATIO))
stroke_w = max(1, int(h * STROKE_WIDTH_RATIO))
font = select_font(font_size)
# available text width is image width minus margins
max_text_width = int(w * 0.92)
def draw_block(text, y_anchor):
if not text:
return
# Wrap into lines
lines = []
# naive wrapping tries smaller font if too many lines
words = text.splitlines() if "\n" in text else [text]
# flatten multiple lines and re-wrap each
combined = " ".join(l.strip() for l in words if l.strip())
# iterative approach: try to wrap and if too many lines, reduce font
curr_font = font
for attempt in range(4):
# greedily wrap to fit
lines = wrap_text_for_width(combined, curr_font, max_text_width)
if len(lines) <= MAX_LINES:
break
# reduce font size and retry
fs = max(12, int(curr_font.size * 0.9))
curr_font = select_font(fs)
# compute total height for block
line_heights = [curr_font.getsize(line)[1] for line in lines]
block_h = sum(line_heights) + (len(lines)-1) * 4
# starting y depending on anchor ("top" or "bottom")
if y_anchor == "top":
y = int(h * TOP_BOTTOM_MARGIN_RATIO) + curr_font.getsize(lines[0])[1]//2
else: # bottom
y = h - int(h * TOP_BOTTOM_MARGIN_RATIO) - block_h + curr_font.getsize(lines[0])[1]//2
# draw each line centered
for line in lines:
x = w // 2
draw_text_with_stroke(draw, (x, y), line, font=curr_font, fill="white", stroke_fill="black", stroke_width=stroke_w, align="center")
y += curr_font.getsize(line)[1] + 4
draw_block(top_text, "top")
draw_block(bottom_text, "bottom")
# Convert back and save
final = img.convert("RGB")
final.save(out_path, quality=95)
return out_path
# ---------------------------
# Small CLI / Interactive prompt
# ---------------------------
def choose_from_list(prompt: str, options: List[str]) -> int:
"""Let user pick an index from options on the console."""
print(prompt)
for i, o in enumerate(options, start=1):
print(f"{i}. {o}")
while True:
try:
sel = input("Choose number (or 'c' to cancel): ").strip()
if sel.lower() == "c":
return -1
idx = int(sel)-1
if 0 <= idx < len(options):
return idx
except Exception:
pass
print("Invalid choice. Try again.")
def main_interactive():
print("=== AI-Powered Meme Generator ===")
print("Enter local image path, or an image URL (http/https).")
path = input("Image path or URL: ").strip()
if not path:
print("No image provided — exiting.")
return
try:
img = load_image_from_path_or_url(path)
except Exception as e:
print("Failed to load image:", e)
return
use_ai = False
if OPENAI_API_KEY and OPENAI_OK:
ans = input("Generate AI captions? (y/N): ").strip().lower()
use_ai = ans == "y"
else:
print("OpenAI not configured — using offline caption templates.")
captions = []
if use_ai:
desc = input("Describe the image/theme briefly (or press Enter to auto-describe): ").strip()
if not desc:
desc = "A funny photo suitable for memes (describe notable objects / people / mood)."
try:
captions = generate_captions_ai(desc, n=8)
except Exception as e:
print("AI caption generation failed:", e)
print("Falling back to offline templates.")
captions = OFFLINE_TEMPLATES.copy()
else:
captions = OFFLINE_TEMPLATES.copy()
# Show captions and pick or let user type
idx = choose_from_list("Choose a caption or pick 0 to enter your own:", ["[Type custom caption]"] + captions)
if idx == -1:
print("Cancelled.")
return
if idx == 0:
custom = input("Enter custom caption (use '\\n' for line break): ")
chosen = custom
else:
chosen = captions[idx-1]
# Choose top/bottom or both
pos_choice = input("Place caption at (1) Top, (2) Bottom, (3) Both? [2]: ").strip() or "2"
if pos_choice not in ("1","2","3"):
pos_choice = "2"
if pos_choice == "1":
top_text = chosen
bottom_text = None
elif pos_choice == "2":
top_text = None
bottom_text = chosen
else:
# Ask for a second caption or reuse
second_idx = choose_from_list("Pick another caption for the other position or type custom:", ["[Type custom]"] + captions)
if second_idx == -1:
print("Cancelled.")
return
if second_idx == 0:
second = input("Second caption: ")
else:
second = captions[second_idx-1]
top_text = chosen
bottom_text = second
# Save file
out_name = input("Output filename (default: meme_output.jpg): ").strip() or "meme_output.jpg"
try:
out_path = render_meme(img, top_text, bottom_text, out_name)
print("Saved meme to", out_path)
except Exception as e:
print("Failed to render meme:", e)
if __name__ == "__main__":
main_interactive()
No comments:
Post a Comment