Campus-AI / poster_compositor.py
realruneet's picture
Update poster_compositor.py
4a36079 verified
#!/usr/bin/env python3
"""
Campus-AI β€” Poster Compositor v5
CounciL Strategic Systems
Smart quiet-zone detection + PIL typography
"""
import os, textwrap, requests
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
FONTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "fonts")
FONT_URLS = {
"Montserrat-ExtraBold": "https://github.com/JulietaUla/Montserrat/raw/master/fonts/ttf/Montserrat-ExtraBold.ttf",
"Montserrat-Bold": "https://github.com/JulietaUla/Montserrat/raw/master/fonts/ttf/Montserrat-Bold.ttf",
"Montserrat-Medium": "https://github.com/JulietaUla/Montserrat/raw/master/fonts/ttf/Montserrat-Medium.ttf",
"Montserrat-Regular": "https://github.com/JulietaUla/Montserrat/raw/master/fonts/ttf/Montserrat-Regular.ttf",
"Playfair-Bold": "https://github.com/google/fonts/raw/main/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf",
"Playfair-Regular": "https://github.com/google/fonts/raw/main/ofl/playfairdisplay/PlayfairDisplay-Italic%5Bwght%5D.ttf",
}
def ensure_fonts():
os.makedirs(FONTS_DIR, exist_ok=True)
for name, url in FONT_URLS.items():
path = os.path.join(FONTS_DIR, f"{name}.ttf")
if not os.path.exists(path):
print(f" Downloading font: {name}…")
try:
r = requests.get(url, timeout=30)
r.raise_for_status()
with open(path, "wb") as f:
f.write(r.content)
except Exception as e:
print(f" Warning: could not download {name}: {e}")
def _font(name: str, size: int) -> ImageFont.FreeTypeFont:
path = os.path.join(FONTS_DIR, f"{name}.ttf")
if os.path.exists(path):
return ImageFont.truetype(path, size)
try:
return ImageFont.truetype("arial.ttf", size)
except OSError:
return ImageFont.load_default()
def _measure(text: str, font) -> tuple:
bb = font.getbbox(text)
return bb[2] - bb[0], bb[3] - bb[1]
def _hex_to_rgb(hex_color: str) -> tuple:
h = hex_color.lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
# ── Zone detection ────────────────────────────────────────────────────────────
def _quiet_zone(img: Image.Image, n: int = 6):
edges = np.array(img.convert("L").filter(ImageFilter.FIND_EDGES), dtype=np.float32)
h = img.size[1]
zone_h = h // n
scores = []
for i in range(n):
ys, ye = i * zone_h, min((i + 1) * zone_h, h)
scores.append((i, ys, ye, float(np.mean(edges[ys:ye, :]))))
scores.sort(key=lambda x: x[3])
_, ys, ye, _ = scores[0]
yc = (ys + ye) // 2
pos = "top" if yc / h < 0.35 else ("bottom" if yc / h > 0.65 else "middle")
return yc, ys, ye, pos
# ── Gradient overlay ──────────────────────────────────────────────────────────
def _local_gradient(img: Image.Image, ys: int, ye: int,
intensity: float = 0.88) -> Image.Image:
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
w, h = img.size
feather = 80
top = max(0, ys - feather)
bottom = min(h, ye + feather)
for y in range(top, bottom):
if y < ys:
a = int(210 * intensity * (y - top) / max(1, ys - top))
elif y > ye:
a = int(210 * intensity * (1 - (y - ye) / max(1, bottom - ye)))
else:
a = int(210 * intensity)
draw.line([(0, y), (w, y)], fill=(0, 0, 0, min(a, 215)))
result = Image.alpha_composite(img.convert("RGBA"), overlay)
return result.convert("RGB")
# ── Text helpers ──────────────────────────────────────────────────────────────
def _shadow_text(draw, xy, text, font, fill="#FFFFFF",
shadow="#000000", offset=4, anchor="lt"):
x, y = xy
draw.text((x + offset, y + offset), text, font=font, fill=(0, 0, 0, 200), anchor=anchor)
draw.text((x + offset // 2, y + offset // 2), text, font=font, fill=(0, 0, 0, 120), anchor=anchor)
draw.text((x, y), text, font=font, fill=fill,
stroke_width=2, stroke_fill=shadow, anchor=anchor)
def _pill_text(draw, xy, text, font, text_fill="#FFFFFF",
bg=(0, 0, 0, 170), pad=14, anchor="lt"):
bb = font.getbbox(text, anchor=anchor)
x, y = xy
draw.rounded_rectangle(
[x + bb[0] - pad, y + bb[1] - pad // 2,
x + bb[2] + pad, y + bb[3] + pad // 2],
radius=10, fill=bg,
)
draw.text((x, y), text, font=font, fill=text_fill, anchor=anchor)
# ── Layout renderers ──────────────────────────────────────────────────────────
def _layout_modern(draw, w, h, title, subtitle, date, venue, organizer,
accent_rgb, start_y):
GAP = 18
cy = start_y
ac = f"#{accent_rgb[0]:02x}{accent_rgb[1]:02x}{accent_rgb[2]:02x}"
if organizer:
fo = _font("Montserrat-Medium", 20)
_pill_text(draw, (w // 2, 28), organizer.upper(), fo,
text_fill="#FFFFFF", bg=(0, 0, 0, 180), anchor="mt")
draw.rectangle([w // 2 - 80, cy - 14, w // 2 + 80, cy - 11], fill=accent_rgb)
ts = 66 if len(title) < 18 else 50 if len(title) < 28 else 40
ft = _font("Montserrat-ExtraBold", ts)
ww = 18 if ts >= 56 else 22
for line in textwrap.wrap(title.upper(), width=ww):
_shadow_text(draw, (w // 2, cy), line, ft, anchor="mt")
_, lh = _measure(line, ft)
cy += lh + 10
cy += GAP
if subtitle:
fs = _font("Playfair-Regular", 26)
_shadow_text(draw, (w // 2, cy), subtitle, fs, fill=ac, anchor="mt")
_, sh = _measure(subtitle, fs)
cy += sh + GAP
if date or venue:
fi = _font("Montserrat-Medium", 18)
info = " Β· ".join(filter(None, [date, venue]))
_pill_text(draw, (w // 2, cy), info, fi, anchor="mt")
draw.rectangle([0, h - 6, w, h], fill=accent_rgb)
def _layout_bold(draw, w, h, title, subtitle, date, venue, organizer,
accent_rgb, start_y):
GAP, LEFT = 20, 48
cy = start_y
ac = f"#{accent_rgb[0]:02x}{accent_rgb[1]:02x}{accent_rgb[2]:02x}"
draw.rectangle([0, 0, 8, h], fill=accent_rgb)
if organizer:
fo = _font("Montserrat-Bold", 17)
_pill_text(draw, (LEFT, 28), organizer.upper(), fo,
text_fill=ac, bg=(0, 0, 0, 210))
ts = 72 if len(title) < 15 else 56 if len(title) < 24 else 42
ft = _font("Montserrat-ExtraBold", ts)
for line in textwrap.wrap(title.upper(), width=14 if ts >= 56 else 18):
_shadow_text(draw, (LEFT, cy), line, ft, offset=5, anchor="lt")
_, lh = _measure(line, ft)
cy += lh + 8
draw.rectangle([LEFT, cy + 6, LEFT + 60, cy + 9], fill=accent_rgb)
cy += GAP + 14
if subtitle:
fs = _font("Playfair-Regular", 24)
draw.text((LEFT, cy), subtitle, font=fs, fill=ac)
_, sh = _measure(subtitle, fs)
cy += sh + GAP
if date or venue:
fi = _font("Montserrat-Regular", 18)
for part in filter(None, [date, venue]):
draw.text((LEFT, cy), part, font=fi, fill="#DDDDDD")
cy += 28
def _layout_elegant(draw, w, h, title, subtitle, date, venue, organizer,
accent_rgb, start_y):
GAP, LW = 18, 140
cy = start_y
ac = f"#{accent_rgb[0]:02x}{accent_rgb[1]:02x}{accent_rgb[2]:02x}"
draw.rectangle([w // 2 - LW, 44, w // 2 + LW, 46], fill=accent_rgb)
if organizer:
fo = _font("Montserrat-Medium", 18)
draw.text((w // 2, 58), organizer, font=fo, fill="#EEEEEE", anchor="mt")
draw.rectangle([w // 2 - LW, 86, w // 2 + LW, 88], fill=accent_rgb)
draw.rectangle([0, 0, 6, h], fill=accent_rgb)
draw.rectangle([w - 6, 0, w, h], fill=accent_rgb)
ts = 58 if len(title) < 20 else 44 if len(title) < 30 else 36
ft = _font("Playfair-Bold", ts)
for line in textwrap.wrap(title, width=22 if ts >= 44 else 28):
_shadow_text(draw, (w // 2, cy), line, ft, offset=3, anchor="mt")
_, lh = _measure(line, ft)
cy += lh + 12
draw.rectangle([w // 2 - 60, cy + 4, w // 2 + 60, cy + 6], fill=accent_rgb)
cy += GAP + 10
if subtitle:
fs = _font("Playfair-Regular", 24)
draw.text((w // 2, cy), f"β€” {subtitle} β€”", font=fs, fill=ac, anchor="mt")
_, sh = _measure(subtitle, fs)
cy += sh + GAP
if date or venue:
fi = _font("Montserrat-Regular", 17)
info = " Β· ".join(filter(None, [date, venue]))
draw.text((w // 2, cy), info, font=fi, fill="#CCCCCC", anchor="mt")
draw.rectangle([w // 2 - LW, h - 46, w // 2 + LW, h - 44], fill=accent_rgb)
draw.rectangle([w // 2 - LW + 24, h - 32, w // 2 + LW - 24, h - 30], fill=accent_rgb)
# ── Estimate text block height ────────────────────────────────────────────────
def _estimate_height(w, title, subtitle, date, venue, organizer, style):
total = 0
ts = 66 if len(title) < 18 else 50 if len(title) < 28 else 40
fname = "Montserrat-ExtraBold" if style != "elegant" else "Playfair-Bold"
ft = _font(fname, ts)
ww = 18 if ts >= 50 else 22
for line in textwrap.wrap(title, width=ww):
_, lh = _measure(line, ft)
total += lh + 10
total += 28
if subtitle: total += 44
if date: total += 28
if venue: total += 28
return total + 40
# ── Main entry point ──────────────────────────────────────────────────────────
def composite_poster(
artwork: Image.Image,
title: str,
subtitle: str = "",
date: str = "",
venue: str = "",
organizer: str = "",
accent_color: str = "#FFD700",
style: str = "modern",
) -> Image.Image:
ensure_fonts()
img = artwork.copy().convert("RGB")
w, h = img.size
accent_rgb = _hex_to_rgb(accent_color)
est_h = _estimate_height(w, title, subtitle, date, venue, organizer, style)
yc, ys, ye, pos = _quiet_zone(img)
if (ye - ys) < est_h:
extra = est_h - (ye - ys)
ys = max(0, ys - extra // 2)
ye = min(h, ye + extra // 2)
img = _local_gradient(img, ys, ye)
overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
text_y = max(ys + 20, yc - est_h // 2)
if style == "bold":
_layout_bold(draw, w, h, title, subtitle, date, venue,
organizer, accent_rgb, text_y)
elif style == "elegant":
_layout_elegant(draw, w, h, title, subtitle, date, venue,
organizer, accent_rgb, text_y)
else:
_layout_modern(draw, w, h, title, subtitle, date, venue,
organizer, accent_rgb, text_y)
img = Image.alpha_composite(img.convert("RGBA"), overlay)
return img.convert("RGB")