Spaces:
Sleeping
Sleeping
| #!/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") | |