Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import io | |
| import numpy as np | |
| from PIL import ( | |
| Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageFont, | |
| ) | |
| from visualization.cards.card_data import _fmt_val | |
| from visualization.cards.poster_templates import TEMPLATES, THEMES | |
| from utils.logger import logger | |
| # --------------------------------------------------------------------------- | |
| # Font helper | |
| # --------------------------------------------------------------------------- | |
| def _font(size: int) -> ImageFont.ImageFont: | |
| try: | |
| return ImageFont.load_default(size=size) | |
| except TypeError: | |
| return ImageFont.load_default() | |
| # --------------------------------------------------------------------------- | |
| # Color helpers | |
| # --------------------------------------------------------------------------- | |
| def _rgba(rgb: tuple, alpha: int = 255) -> tuple: | |
| return rgb + (alpha,) | |
| # --------------------------------------------------------------------------- | |
| # Glow drawing primitives | |
| # --------------------------------------------------------------------------- | |
| def _glow_rect( | |
| base: Image.Image, | |
| x: int, y: int, w: int, h: int, | |
| color: tuple, | |
| border: int = 1, | |
| glow_radius: int = 10, | |
| glow_alpha: int = 65, | |
| ) -> None: | |
| """Draw a glowing border rectangle directly onto an RGBA base.""" | |
| layer = Image.new("RGBA", base.size, (0, 0, 0, 0)) | |
| ld = ImageDraw.Draw(layer) | |
| for i in range(glow_radius, 0, -2): | |
| a = int(glow_alpha * (1 - i / glow_radius) ** 1.5) | |
| ld.rectangle( | |
| [x - i, y - i, x + w + i, y + h + i], | |
| outline=_rgba(color, a), width=1, | |
| ) | |
| layer = layer.filter(ImageFilter.GaussianBlur(radius=3)) | |
| base.alpha_composite(layer) | |
| bd = ImageDraw.Draw(base) | |
| bd.rectangle([x, y, x + w, y + h], outline=_rgba(color, 210), width=border) | |
| def _glow_line( | |
| base: Image.Image, | |
| xy: list, | |
| color: tuple, | |
| width: int = 1, | |
| glow_radius: int = 5, | |
| glow_alpha: int = 55, | |
| ) -> None: | |
| """Draw a glowing line onto an RGBA base.""" | |
| layer = Image.new("RGBA", base.size, (0, 0, 0, 0)) | |
| ld = ImageDraw.Draw(layer) | |
| ld.line(xy, fill=_rgba(color, glow_alpha), width=width + glow_radius) | |
| layer = layer.filter(ImageFilter.GaussianBlur(radius=glow_radius // 2)) | |
| base.alpha_composite(layer) | |
| bd = ImageDraw.Draw(base) | |
| bd.line(xy, fill=_rgba(color, 200), width=width) | |
| def _hud_corners( | |
| base: Image.Image, | |
| x: int, y: int, w: int, h: int, | |
| color: tuple, | |
| size: int = 20, | |
| thickness: int = 1, | |
| ) -> None: | |
| """Draw four L-shaped HUD corner brackets.""" | |
| bd = ImageDraw.Draw(base) | |
| segs = [ | |
| [(x, y + size), (x, y), (x + size, y)], | |
| [(x + w - size, y), (x + w, y), (x + w, y + size)], | |
| [(x, y + h - size), (x, y + h), (x + size, y + h)], | |
| [(x + w - size, y + h), (x + w, y + h), (x + w, y + h - size)], | |
| ] | |
| for pts in segs: | |
| bd.line(pts, fill=_rgba(color, 200), width=thickness) | |
| # --------------------------------------------------------------------------- | |
| # Background | |
| # --------------------------------------------------------------------------- | |
| def build_background(W: int, H: int, theme: dict) -> Image.Image: | |
| """ | |
| Procedural dark background: | |
| - Dark navy base with subtle noise grain | |
| - Corner-weighted vignette | |
| - Scattered faint star particles | |
| """ | |
| rng = np.random.default_rng(seed=42) | |
| noise = rng.integers(0, 14, (H, W, 3), dtype=np.uint8) | |
| base_c = np.array(theme["bg"], dtype=np.int16) | |
| arr = np.clip(base_c + noise, 0, 255).astype(np.uint8) | |
| # Radial vignette | |
| cy, cx = H / 2.0, W / 2.0 | |
| Y, X = np.mgrid[0:H, 0:W].astype(np.float32) | |
| dist = np.sqrt(((X - cx) / cx) ** 2 + ((Y - cy) / cy) ** 2) | |
| vig = np.clip(1.0 - theme["vignette"] * dist, 0.25, 1.0) | |
| arr = (arr * vig[:, :, np.newaxis]).clip(0, 255).astype(np.uint8) | |
| img = Image.fromarray(arr, "RGB").convert("RGBA") | |
| # Faint star particles | |
| draw = ImageDraw.Draw(img) | |
| rng2 = np.random.default_rng(seed=7) | |
| for _ in range(150): | |
| sx = int(rng2.integers(0, W)) | |
| sy = int(rng2.integers(0, H)) | |
| br = int(rng2.integers(35, 110)) | |
| sz = int(rng2.choice([1, 1, 1, 2])) | |
| draw.ellipse( | |
| [sx, sy, sx + sz, sy + sz], | |
| fill=(br, int(br * 1.05), int(br * 1.25), br), | |
| ) | |
| return img | |
| # --------------------------------------------------------------------------- | |
| # Header | |
| # --------------------------------------------------------------------------- | |
| def _draw_header(base: Image.Image, payload: dict, zone: tuple, theme: dict) -> None: | |
| x, y, w, h = zone | |
| bd = ImageDraw.Draw(base) | |
| bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel"], 248)) | |
| # Bottom glow line | |
| _glow_line(base, [(x, y + h - 1), (x + w, y + h - 1)], | |
| theme["accent_cyan"], width=1, glow_radius=5, glow_alpha=90) | |
| bd2 = ImageDraw.Draw(base) | |
| # Brand + type label (left column) | |
| bd2.text((x + 24, y + 12), "KASPER", | |
| font=_font(12), fill=_rgba(theme["accent_cyan"], 230)) | |
| card_type = str(payload.get("card_type", "")).upper() | |
| type_label = { | |
| "HITTER": "HITTER REPORT", | |
| "PITCHER": "PITCHER REPORT", | |
| "GAME_SUMMARY": "GAME SUMMARY", | |
| }.get(card_type, "SCOUTING REPORT") | |
| bd2.text((x + 24, y + 30), type_label, | |
| font=_font(8), fill=_rgba(theme["text_dim"], 190)) | |
| # Player name (large, left) | |
| name = str(payload.get("player_name", "—")).upper() | |
| bd2.text((x + 24, y + 50), name, | |
| font=_font(30), fill=_rgba(theme["text_primary"], 255)) | |
| # Team + timeframe (right) | |
| team = str(payload.get("team", "—")).upper() | |
| tf = str(payload.get("timeframe", "")).replace("_", " ").upper() | |
| bd2.text((x + w - 22, y + 12), team, | |
| font=_font(12), fill=_rgba(theme["text_secondary"], 200), anchor="ra") | |
| bd2.text((x + w - 22, y + 30), tf, | |
| font=_font(8), fill=_rgba(theme["text_dim"], 170), anchor="ra") | |
| # --------------------------------------------------------------------------- | |
| # Player image processing | |
| # --------------------------------------------------------------------------- | |
| def _remove_background(img: Image.Image) -> Image.Image: | |
| """Attempt background removal with rembg; return original on any failure.""" | |
| try: | |
| from rembg import remove | |
| return remove(img) | |
| except ImportError: | |
| logger.warning("[poster] rembg not installed — skipping bg removal") | |
| return img | |
| except Exception as exc: | |
| logger.warning("[poster] rembg failed: %s", exc) | |
| return img | |
| def _apply_glow(img: Image.Image, color: tuple, radius: int = 28) -> Image.Image: | |
| """ | |
| Add a coloured glow halo around a player cutout (RGBA input). | |
| Returns a larger image with padding so the glow bleeds outward. | |
| """ | |
| W, H = img.size | |
| pad = radius * 2 | |
| padded = Image.new("RGBA", (W + 2 * pad, H + 2 * pad), (0, 0, 0, 0)) | |
| padded.paste(img, (pad, pad), img) | |
| alpha = padded.split()[3] | |
| glow_layer = Image.new("RGBA", padded.size, _rgba(color, 0)) | |
| glow_layer.putalpha(alpha) | |
| glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=radius)) | |
| combined = Image.new("RGBA", padded.size, (0, 0, 0, 0)) | |
| combined = Image.alpha_composite(combined, glow_layer) | |
| combined = Image.alpha_composite(combined, padded) | |
| return combined | |
| def _apply_rim_light(img: Image.Image, color: tuple, width: int = 4) -> Image.Image: | |
| """Add a bright rim highlight on the silhouette edge. RGBA input/output.""" | |
| alpha = img.split()[3] | |
| dilated = alpha.filter(ImageFilter.MaxFilter(width * 2 + 1)) | |
| rim_mask = ImageChops.subtract(dilated, alpha) | |
| rim_mask = ImageEnhance.Brightness(Image.fromarray( | |
| np.array(rim_mask) | |
| )).enhance(1.3) | |
| rim = Image.new("RGBA", img.size, _rgba(color, 0)) | |
| rim.putalpha(rim_mask) | |
| result = img.copy() | |
| result = Image.alpha_composite(result, rim) | |
| return result | |
| def process_player_image( | |
| player_img: Image.Image | None, | |
| zone_wh: tuple[int, int], | |
| theme: dict, | |
| remove_bg: bool = True, | |
| ) -> Image.Image | None: | |
| """ | |
| Full player image pipeline: | |
| 1. Convert to RGBA | |
| 2. Optionally remove background | |
| 3. Resize to fit zone | |
| 4. Apply rim light (on clean silhouette) | |
| 5. Apply glow (expands canvas) | |
| Returns processed RGBA image. | |
| """ | |
| if player_img is None: | |
| return None | |
| try: | |
| img = player_img.convert("RGBA") | |
| if remove_bg: | |
| img = _remove_background(img) | |
| zw, zh = zone_wh | |
| img.thumbnail((zw, zh), Image.LANCZOS) | |
| img = _apply_rim_light(img, theme["accent_cyan"], width=3) | |
| img = _apply_glow(img, theme["accent_cyan"], radius=26) | |
| return img | |
| except Exception as exc: | |
| logger.warning("[poster] player image processing failed: %s", exc) | |
| return None | |
| def _paste_player( | |
| base: Image.Image, | |
| player_img: Image.Image | None, | |
| zone: tuple, | |
| theme: dict, | |
| ) -> None: | |
| """Paste processed player image into zone, bottom-centre aligned.""" | |
| x, y, w, h = zone | |
| # Subtle zone tint | |
| overlay = Image.new("RGBA", base.size, (0, 0, 0, 0)) | |
| ImageDraw.Draw(overlay).rectangle( | |
| [x, y, x + w, y + h], fill=_rgba(theme["panel"], 35) | |
| ) | |
| base.alpha_composite(overlay) | |
| # HUD corner brackets | |
| _hud_corners(base, x + 10, y + 10, w - 20, h - 20, | |
| theme["accent_cyan"], size=22, thickness=1) | |
| if player_img is None: | |
| bd = ImageDraw.Draw(base) | |
| bd.multiline_text( | |
| (x + w // 2, y + h // 2), | |
| "NO\nIMAGE", | |
| font=_font(20), fill=_rgba(theme["text_dim"], 100), | |
| anchor="mm", align="center", | |
| ) | |
| return | |
| pw, ph = player_img.size | |
| paste_x = x + (w - pw) // 2 | |
| paste_y = y + h - ph + 40 # slight upward pull to keep full glow visible | |
| # Use a full-canvas temp so PIL clips out-of-bounds naturally | |
| temp = Image.new("RGBA", base.size, (0, 0, 0, 0)) | |
| temp.paste(player_img, (paste_x, paste_y)) | |
| base.alpha_composite(temp) | |
| # --------------------------------------------------------------------------- | |
| # Metric box | |
| # --------------------------------------------------------------------------- | |
| def _draw_metric_box( | |
| base: Image.Image, | |
| zone: tuple, | |
| label: str, | |
| value: str, | |
| sub: str, | |
| theme: dict, | |
| accent: tuple | None = None, | |
| ) -> None: | |
| x, y, w, h = zone | |
| accent = accent or theme["accent_cyan"] | |
| bd = ImageDraw.Draw(base) | |
| bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel_alt"], 245)) | |
| _glow_rect(base, x, y, w, h, accent, border=1, glow_radius=10, glow_alpha=60) | |
| # Left accent stripe | |
| bd2 = ImageDraw.Draw(base) | |
| bd2.rectangle([x, y, x + 3, y + h], fill=_rgba(accent, 200)) | |
| # Label (top-left) | |
| bd2.text((x + 14, y + 11), label.upper(), | |
| font=_font(9), fill=_rgba(theme["text_dim"], 200)) | |
| # Value (centred, large) | |
| bd2.text((x + w // 2, y + h // 2 + 4), str(value), | |
| font=_font(34), fill=_rgba(theme["text_primary"], 255), anchor="mm") | |
| # Sub (bottom-right) | |
| if sub: | |
| bd2.text((x + w - 10, y + h - 10), sub, | |
| font=_font(8), fill=_rgba(accent, 150), anchor="rb") | |
| # --------------------------------------------------------------------------- | |
| # Chart panel | |
| # --------------------------------------------------------------------------- | |
| def _draw_chart_panel( | |
| base: Image.Image, | |
| chart_img: Image.Image | None, | |
| zone: tuple, | |
| theme: dict, | |
| ) -> None: | |
| x, y, w, h = zone | |
| bd = ImageDraw.Draw(base) | |
| bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel"], 245)) | |
| _glow_rect(base, x, y, w, h, theme["accent_cyan"], border=1, glow_radius=7, glow_alpha=40) | |
| if chart_img is None: | |
| ImageDraw.Draw(base).text( | |
| (x + w // 2, y + h // 2), "DATA UNAVAILABLE", | |
| font=_font(10), fill=_rgba(theme["text_dim"], 140), anchor="mm", | |
| ) | |
| return | |
| inner_w, inner_h = w - 8, h - 8 | |
| resized = chart_img.resize((inner_w, inner_h), Image.LANCZOS).convert("RGBA") | |
| temp = Image.new("RGBA", base.size, (0, 0, 0, 0)) | |
| temp.paste(resized, (x + 4, y + 4)) | |
| base.alpha_composite(temp) | |
| # --------------------------------------------------------------------------- | |
| # Stat row (full width) | |
| # --------------------------------------------------------------------------- | |
| def _draw_stat_row( | |
| base: Image.Image, | |
| items: list[tuple[str, str]], | |
| zone: tuple, | |
| theme: dict, | |
| ) -> None: | |
| x, y, w, h = zone | |
| bd = ImageDraw.Draw(base) | |
| bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel"], 245)) | |
| _glow_line(base, [(x, y), (x + w, y)], | |
| theme["accent_cyan"], width=1, glow_radius=4, glow_alpha=60) | |
| _glow_line(base, [(x, y + h), (x + w, y + h)], | |
| theme["accent_cyan"], width=1, glow_radius=4, glow_alpha=60) | |
| n = max(len(items), 1) | |
| cell_w = w // n | |
| for i, (label, value) in enumerate(items): | |
| cx = x + i * cell_w + cell_w // 2 | |
| if i > 0: | |
| ImageDraw.Draw(base).line( | |
| [(x + i * cell_w, y + 10), (x + i * cell_w, y + h - 10)], | |
| fill=_rgba(theme["border_subtle"], 100), width=1, | |
| ) | |
| bd2 = ImageDraw.Draw(base) | |
| bd2.text((cx, y + 20), label.upper(), | |
| font=_font(9), fill=_rgba(theme["text_dim"], 190), anchor="mm") | |
| bd2.text((cx, y + 62), str(value), | |
| font=_font(18), fill=_rgba(theme["text_primary"], 240), anchor="mm") | |
| # --------------------------------------------------------------------------- | |
| # Readout panel | |
| # --------------------------------------------------------------------------- | |
| def _draw_readout_panel( | |
| base: Image.Image, | |
| lines: list[str], | |
| zone: tuple, | |
| theme: dict, | |
| ) -> None: | |
| x, y, w, h = zone | |
| bd = ImageDraw.Draw(base) | |
| bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel"], 230)) | |
| _glow_line(base, [(x, y), (x + w, y)], | |
| theme["accent_cyan"], width=1, glow_radius=4, glow_alpha=60) | |
| # Left accent bar | |
| ImageDraw.Draw(base).rectangle( | |
| [x, y, x + 3, y + h], fill=_rgba(theme["accent_cyan"], 180) | |
| ) | |
| bd2 = ImageDraw.Draw(base) | |
| bd2.text((x + 16, y + 14), "KASPER READOUT", | |
| font=_font(10), fill=_rgba(theme["accent_cyan"], 230)) | |
| for i, line in enumerate(lines[:5]): | |
| ty = y + 40 + i * 48 | |
| bd2.text((x + 14, ty), "›", | |
| font=_font(13), fill=_rgba(theme["accent_cyan"], 160)) | |
| bd2.text((x + 30, ty), str(line), | |
| font=_font(11), fill=_rgba(theme["text_secondary"], 200)) | |
| # --------------------------------------------------------------------------- | |
| # Alert box | |
| # --------------------------------------------------------------------------- | |
| def _draw_alert_box( | |
| base: Image.Image, | |
| text: str, | |
| label: str, | |
| zone: tuple, | |
| theme: dict, | |
| ) -> None: | |
| x, y, w, h = zone | |
| bd = ImageDraw.Draw(base) | |
| bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel_alert"], 245)) | |
| _glow_rect(base, x, y, w, h, theme["accent_red"], | |
| border=1, glow_radius=12, glow_alpha=70) | |
| # Top accent bar | |
| ImageDraw.Draw(base).rectangle( | |
| [x, y, x + w, y + 4], fill=_rgba(theme["accent_red"], 180) | |
| ) | |
| bd2 = ImageDraw.Draw(base) | |
| bd2.text((x + 14, y + 16), ("⚡ " + label.upper()), | |
| font=_font(10), fill=_rgba(theme["accent_red"], 240)) | |
| # Simple word-wrap | |
| words = str(text).split() | |
| max_chars = (w - 28) // 7 | |
| lines: list[str] = [] | |
| cur = "" | |
| for word in words: | |
| test = (cur + " " + word).strip() | |
| if len(test) <= max_chars: | |
| cur = test | |
| else: | |
| if cur: | |
| lines.append(cur) | |
| cur = word | |
| if cur: | |
| lines.append(cur) | |
| for i, ln in enumerate(lines[:4]): | |
| bd2.text((x + 14, y + 36 + i * 30), ln, | |
| font=_font(11), fill=_rgba(theme["text_primary"], 215)) | |
| # --------------------------------------------------------------------------- | |
| # Rolling strip | |
| # --------------------------------------------------------------------------- | |
| def _draw_rolling_strip( | |
| base: Image.Image, | |
| items: list[tuple[str, str]], | |
| zone: tuple, | |
| theme: dict, | |
| ) -> None: | |
| x, y, w, h = zone | |
| bd = ImageDraw.Draw(base) | |
| bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel_alt"], 235)) | |
| _glow_rect(base, x, y, w, h, theme["accent_amber"], | |
| border=1, glow_radius=6, glow_alpha=40) | |
| n = max(len(items), 1) | |
| cell_w = w // n | |
| for i, (label, value) in enumerate(items): | |
| cx = x + i * cell_w + cell_w // 2 | |
| if i > 0: | |
| ImageDraw.Draw(base).line( | |
| [(x + i * cell_w, y + 8), (x + i * cell_w, y + h - 8)], | |
| fill=_rgba(theme["border_subtle"], 80), width=1, | |
| ) | |
| bd2 = ImageDraw.Draw(base) | |
| bd2.text((cx, y + 16), label.upper(), | |
| font=_font(8), fill=_rgba(theme["text_dim"], 180), anchor="mm") | |
| bd2.text((cx, y + 56), str(value), | |
| font=_font(14), fill=_rgba(theme["accent_amber"], 220), anchor="mm") | |
| # --------------------------------------------------------------------------- | |
| # Footer | |
| # --------------------------------------------------------------------------- | |
| def _draw_footer(base: Image.Image, zone: tuple, theme: dict) -> None: | |
| x, y, w, h = zone | |
| bd = ImageDraw.Draw(base) | |
| bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel"], 210)) | |
| _glow_line(base, [(x, y), (x + w, y)], | |
| theme["accent_cyan"], width=1, glow_radius=4, glow_alpha=45) | |
| bd2 = ImageDraw.Draw(base) | |
| bd2.text((x + 24, y + 16), "KASPER", | |
| font=_font(13), fill=_rgba(theme["accent_cyan"], 210)) | |
| bd2.text((x + w // 2, y + 18), | |
| "Statistical estimates only. Alpha release. Not financial advice.", | |
| font=_font(9), fill=_rgba(theme["text_dim"], 155), anchor="mm") | |
| bd2.text((x + w - 22, y + 16), "kasper.ai", | |
| font=_font(9), fill=_rgba(theme["text_dim"], 130), anchor="ra") | |
| # --------------------------------------------------------------------------- | |
| # Final post-processing effects | |
| # --------------------------------------------------------------------------- | |
| def _apply_final_effects(base: Image.Image) -> Image.Image: | |
| """Subtle vignette + very light grain pass.""" | |
| W, H = base.size | |
| # Radial vignette overlay | |
| vig = Image.new("RGBA", (W, H), (0, 0, 0, 0)) | |
| vd = ImageDraw.Draw(vig) | |
| cx, cy = W // 2, H // 2 | |
| for i in range(28): | |
| t = i / 28.0 | |
| alpha = int(100 * (1 - t) ** 2) | |
| rx = int(cx * (1.0 + 0.25 * (1 + t))) | |
| ry = int(cy * (1.0 + 0.25 * (1 + t))) | |
| vd.ellipse( | |
| [cx - rx, cy - ry, cx + rx, cy + ry], | |
| outline=(0, 0, 0, alpha), | |
| width=max(1, int(W * 0.035)), | |
| ) | |
| vig = vig.filter(ImageFilter.GaussianBlur(radius=38)) | |
| base.alpha_composite(vig) | |
| return base | |
| # --------------------------------------------------------------------------- | |
| # Export | |
| # --------------------------------------------------------------------------- | |
| def _export(img: Image.Image, fmt: str) -> bytes: | |
| buf = io.BytesIO() | |
| out = img.convert("RGB") | |
| if fmt == "JPG": | |
| out.save(buf, format="JPEG", quality=94) | |
| else: | |
| out.save(buf, format="PNG") | |
| buf.seek(0) | |
| return buf.getvalue() | |
| # --------------------------------------------------------------------------- | |
| # Public render entry points | |
| # --------------------------------------------------------------------------- | |
| def render_hitter_poster( | |
| payload: dict, | |
| player_img: Image.Image | None = None, | |
| fmt: str = "PNG", | |
| ) -> bytes: | |
| """Render a full 1024×1536 Kasper Report hitter poster.""" | |
| from visualization.cards.card_charts import build_ev_la_chart | |
| tmpl = TEMPLATES["kasper_report"] | |
| theme = THEMES[tmpl["theme"]] | |
| W, H = tmpl["canvas"]["w"], tmpl["canvas"]["h"] | |
| zones = tmpl["zones"] | |
| base = build_background(W, H, theme) | |
| # Player image | |
| z = zones["player_image"] | |
| proc = process_player_image(player_img, (z[2], z[3]), theme, remove_bg=(player_img is not None)) | |
| _paste_player(base, proc, z, theme) | |
| # Header | |
| _draw_header(base, payload, zones["header"], theme) | |
| # Metric boxes | |
| summary = payload.get("summary", {}) | |
| metrics = payload.get("metrics", {}) | |
| baseline = payload.get("baseline", {}) | |
| metric_defs = [ | |
| ("EV90", _fmt_val(summary.get("ev90"), "float") + " mph", | |
| "Exit Velocity 90th Pct", theme["accent_cyan"]), | |
| ("BARREL%", _fmt_val(summary.get("barrel_rate"), "pct"), | |
| "Barrel Rate", theme["accent_amber"]), | |
| ("HR PROB", _fmt_val(baseline.get("hr_prob"), "pct"), | |
| "HR Probability", theme["accent_red"]), | |
| ] | |
| for (label, value, sub, accent), zk in zip( | |
| metric_defs, ["metric_1", "metric_2", "metric_3"] | |
| ): | |
| _draw_metric_box(base, zones[zk], label, value, sub, theme, accent=accent) | |
| # Chart | |
| chart = build_ev_la_chart( | |
| payload.get("windowed_df"), payload.get("player_name", ""), | |
| w_px=360, h_px=252, | |
| ) | |
| _draw_chart_panel(base, chart, zones["chart"], theme) | |
| # Stat row | |
| _draw_stat_row(base, [ | |
| ("CONTACT+", f"{float(metrics.get('contact_plus', 0)):.0f}"), | |
| ("HR SHAPE+", f"{float(metrics.get('hr_shape_plus', 0)):.0f}"), | |
| ("DAMAGE+", f"{float(metrics.get('damage_zone_plus',0)):.0f}"), | |
| ("HARD HIT%", _fmt_val(summary.get("hard_hit_rate"), "pct")), | |
| ("xwOBA", _fmt_val(summary.get("xwoba"), "float")), | |
| ], zones["stat_row"], theme) | |
| # Readout | |
| readout = payload.get("readout", []) | |
| _draw_readout_panel(base, readout, zones["readout"], theme) | |
| # Alert | |
| alert_text = readout[0] if readout else "No significant patterns in selected window." | |
| _draw_alert_box(base, alert_text, "Key Insight", zones["alert"], theme) | |
| # Rolling strip | |
| rolling = payload.get("rolling", {}) | |
| _draw_rolling_strip(base, [ | |
| ("5G EV90", _fmt_val(rolling.get("ev90_5g"), "float") + " mph"), | |
| ("5G BBL%", _fmt_val(rolling.get("barrel_5g"), "pct")), | |
| ("5G HR%", _fmt_val(rolling.get("hr_rate_5g"), "pct")), | |
| ], zones["rolling"], theme) | |
| _draw_footer(base, zones["footer"], theme) | |
| base = _apply_final_effects(base) | |
| return _export(base, fmt) | |
| def render_game_summary_poster(payload: dict, fmt: str = "PNG") -> bytes: | |
| """Render a full 1024×1536 Kasper HUD game summary poster.""" | |
| tmpl = TEMPLATES["kasper_report"] | |
| theme = THEMES[tmpl["theme"]] | |
| W, H = 1024, 1536 | |
| away_team = str(payload.get("away_team", "—")).upper() | |
| home_team = str(payload.get("home_team", "—")).upper() | |
| away_score = payload.get("away_score") | |
| home_score = payload.get("home_score") | |
| game_date = str(payload.get("game_date", ""))[:10] | |
| hitters = payload.get("hitters", []) | |
| base = build_background(W, H, theme) | |
| # Header — reuse with game-summary-specific payload fields | |
| header_payload = { | |
| "card_type": "game_summary", | |
| "player_name": f"{away_team} @ {home_team}", | |
| "team": game_date, | |
| "timeframe": "", | |
| } | |
| _draw_header(base, header_payload, (0, 0, 1024, 110), theme) | |
| # Score banner | |
| _draw_score_banner( | |
| base, away_team, home_team, away_score, home_score, game_date, | |
| (16, 122, 992, 170), theme, | |
| ) | |
| # Cyan divider between banner and table | |
| _glow_line(base, [(0, 293), (1024, 293)], | |
| theme["accent_cyan"], width=1, glow_radius=6, glow_alpha=80) | |
| # Hitter table | |
| _draw_game_hitter_table_poster(base, hitters, (16, 308, 992, 860), theme) | |
| # Stat strip — runs/hits if available | |
| _draw_stat_row(base, [ | |
| ("AWAY R", "—" if away_score is None else str(int(float(away_score)))), | |
| ("AWAY H", "—" if payload.get("away_hits") is None | |
| else str(int(float(payload["away_hits"])))), | |
| ("HOME R", "—" if home_score is None else str(int(float(home_score)))), | |
| ], (0, 1176, 1024, 96), theme) | |
| _draw_footer(base, (0, 1454, 1024, 82), theme) | |
| base = _apply_final_effects(base) | |
| return _export(base, fmt) | |
| # --------------------------------------------------------------------------- | |
| # Game Summary helpers | |
| # --------------------------------------------------------------------------- | |
| def _draw_score_banner( | |
| base: Image.Image, | |
| away_team: str, | |
| home_team: str, | |
| away_score, | |
| home_score, | |
| game_date: str, | |
| zone: tuple, | |
| theme: dict, | |
| ) -> None: | |
| x, y, w, h = zone | |
| # Panel background | |
| ImageDraw.Draw(base).rectangle( | |
| [x, y, x + w, y + h], fill=_rgba(theme["panel_alt"], 248) | |
| ) | |
| _glow_rect(base, x, y, w, h, theme["accent_cyan"], border=1, glow_radius=14, glow_alpha=70) | |
| _hud_corners(base, x, y, w, h, theme["accent_cyan"], size=24, thickness=2) | |
| bd = ImageDraw.Draw(base) | |
| mid_y = y + h // 2 | |
| cx = x + w // 2 | |
| # Away team name — left-aligned | |
| bd.text((x + 30, mid_y), str(away_team or "—"), | |
| font=_font(36), fill=_rgba(theme["text_primary"], 240), anchor="lm") | |
| # Home team name — right-aligned | |
| bd.text((x + w - 30, mid_y), str(home_team or "—"), | |
| font=_font(36), fill=_rgba(theme["text_primary"], 240), anchor="rm") | |
| # Scores in center | |
| away_s = "—" if away_score is None else str(int(float(away_score))) | |
| home_s = "—" if home_score is None else str(int(float(home_score))) | |
| bd.text((cx - 70, mid_y), away_s, | |
| font=_font(60), fill=_rgba(theme["accent_amber"], 255), anchor="rm") | |
| bd.text((cx, mid_y), "vs", | |
| font=_font(18), fill=_rgba(theme["text_dim"], 180), anchor="mm") | |
| bd.text((cx + 70, mid_y), home_s, | |
| font=_font(60), fill=_rgba(theme["accent_amber"], 255), anchor="lm") | |
| # Date — bottom-center | |
| if game_date: | |
| bd.text((cx, y + h - 10), game_date, | |
| font=_font(9), fill=_rgba(theme["text_dim"], 160), anchor="mb") | |
| def _draw_game_hitter_table_poster( | |
| base: Image.Image, | |
| hitters: list, | |
| zone: tuple, | |
| theme: dict, | |
| ) -> None: | |
| x, y, w, h = zone | |
| # Outer panel | |
| ImageDraw.Draw(base).rectangle( | |
| [x, y, x + w, y + h], fill=_rgba(theme["panel"], 245) | |
| ) | |
| _glow_rect(base, x, y, w, h, theme["accent_cyan"], border=1, glow_radius=10, glow_alpha=50) | |
| _hud_corners(base, x, y, w, h, theme["accent_cyan"], size=22, thickness=1) | |
| bd = ImageDraw.Draw(base) | |
| # "TOP HITTERS" label with left accent stripe | |
| ImageDraw.Draw(base).rectangle( | |
| [x, y, x + 3, y + 44], fill=_rgba(theme["accent_cyan"], 200) | |
| ) | |
| bd.text((x + 18, y + 14), "TOP HITTERS", | |
| font=_font(12), fill=_rgba(theme["accent_cyan"], 230)) | |
| # Column header row | |
| header_y = y + 52 | |
| # (label, x_pos, anchor) | |
| col_specs = [ | |
| ("PLAYER", x + 30, "lt"), | |
| ("HR", x + 390, "rt"), | |
| ("HITS", x + 530, "rt"), | |
| ("EV90", x + 680, "rt"), | |
| ("BARRELS", x + 840, "rt"), | |
| ] | |
| for col_label, cx_, anchor in col_specs: | |
| bd.text((cx_, header_y), col_label, | |
| font=_font(10), fill=_rgba(theme["text_dim"], 200), anchor=anchor) | |
| # Thin cyan line under headers | |
| _glow_line(base, [(x + 16, header_y + 18), (x + w - 16, header_y + 18)], | |
| theme["accent_cyan"], width=1, glow_radius=3, glow_alpha=50) | |
| # Data rows | |
| row_h = 72 | |
| row_y = header_y + 26 | |
| row_list = (hitters or [])[:10] | |
| if not row_list: | |
| ImageDraw.Draw(base).text( | |
| (x + w // 2, row_y + 40), "No hitter data for this game", | |
| font=_font(12), fill=_rgba(theme["text_dim"], 140), anchor="mm", | |
| ) | |
| return | |
| for i, row in enumerate(row_list): | |
| ry = row_y + i * row_h | |
| row_fill = theme["panel"] if i % 2 == 0 else theme["panel_alt"] | |
| ImageDraw.Draw(base).rectangle( | |
| [x + 4, ry, x + w - 4, ry + row_h - 2], fill=_rgba(row_fill, 220) | |
| ) | |
| _glow_line(base, [(x + 16, ry), (x + w - 16, ry)], | |
| theme["border_subtle"], width=1, glow_radius=2, glow_alpha=40) | |
| text_y = ry + row_h // 2 | |
| name_str = str(row.get("player_name", "—"))[:24] | |
| hr_str = _fmt_val(row.get("hr"), "int") | |
| hits_str = _fmt_val(row.get("hits"), "int") | |
| ev90_str = _fmt_val(row.get("ev90"), "float") | |
| barrels_str = _fmt_val(row.get("barrels"), "int") | |
| bd2 = ImageDraw.Draw(base) | |
| bd2.text((x + 30, text_y), name_str, font=_font(14), | |
| fill=_rgba(theme["text_primary"], 230), anchor="lm") | |
| bd2.text((x + 390, text_y), hr_str, font=_font(14), | |
| fill=_rgba(theme["text_primary"], 220), anchor="rm") | |
| bd2.text((x + 530, text_y), hits_str, font=_font(14), | |
| fill=_rgba(theme["text_primary"], 220), anchor="rm") | |
| bd2.text((x + 680, text_y), ev90_str, font=_font(14), | |
| fill=_rgba(theme["text_primary"], 220), anchor="rm") | |
| bd2.text((x + 840, text_y), barrels_str, font=_font(14), | |
| fill=_rgba(theme["text_primary"], 220), anchor="rm") | |
| def render_pitcher_poster( | |
| payload: dict, | |
| player_img: Image.Image | None = None, | |
| fmt: str = "PNG", | |
| ) -> bytes: | |
| """Render a full 1024×1536 Kasper Report pitcher poster.""" | |
| from visualization.cards.card_charts import build_pitcher_damage_grid | |
| tmpl = TEMPLATES["kasper_report"] | |
| theme = THEMES[tmpl["theme"]] | |
| W, H = tmpl["canvas"]["w"], tmpl["canvas"]["h"] | |
| zones = tmpl["zones"] | |
| base = build_background(W, H, theme) | |
| z = zones["player_image"] | |
| proc = process_player_image(player_img, (z[2], z[3]), theme, remove_bg=(player_img is not None)) | |
| _paste_player(base, proc, z, theme) | |
| _draw_header(base, payload, zones["header"], theme) | |
| summary = payload.get("summary", {}) | |
| metrics = payload.get("metrics", {}) | |
| metric_defs = [ | |
| ("VELO", _fmt_val(summary.get("avg_release_speed"), "float") + " mph", | |
| "Avg Release Speed", theme["accent_cyan"]), | |
| ("STUFF+", f"{float(metrics.get('stuff_plus', 0)):.0f}", | |
| "Stuff Grade", theme["accent_amber"]), | |
| ("SWSTR%", _fmt_val(summary.get("swstr_rate"), "pct"), | |
| "Swinging Strike Rate", theme["accent_red"]), | |
| ] | |
| for (label, value, sub, accent), zk in zip( | |
| metric_defs, ["metric_1", "metric_2", "metric_3"] | |
| ): | |
| _draw_metric_box(base, zones[zk], label, value, sub, theme, accent=accent) | |
| chart = build_pitcher_damage_grid( | |
| payload.get("windowed_df"), payload.get("player_name", ""), | |
| w_px=360, h_px=252, | |
| ) | |
| _draw_chart_panel(base, chart, zones["chart"], theme) | |
| _draw_stat_row(base, [ | |
| ("COMMAND+", f"{float(metrics.get('command_plus', 0)):.0f}"), | |
| ("DAMAGE+", f"{float(metrics.get('damage_zone_plus',0)):.0f}"), | |
| ("EV ALLOW", _fmt_val(summary.get("ev_allowed"), "float") + " mph"), | |
| ("BBL ALLOW", _fmt_val(summary.get("barrel_rate_allowed"), "pct")), | |
| ("SPIN", _fmt_val(summary.get("avg_release_spin_rate"), "int") + " rpm"), | |
| ], zones["stat_row"], theme) | |
| readout = payload.get("readout", []) | |
| _draw_readout_panel(base, readout, zones["readout"], theme) | |
| alert_text = readout[0] if readout else "No significant patterns in selected window." | |
| _draw_alert_box(base, alert_text, "Key Insight", zones["alert"], theme) | |
| rolling = payload.get("rolling", {}) | |
| _draw_rolling_strip(base, [ | |
| ("5G VELO", _fmt_val(rolling.get("velo_5g"), "float") + " mph"), | |
| ("5G EV ALL", _fmt_val(rolling.get("ev_allowed_5g"), "float") + " mph"), | |
| ("5G BBL ALL", _fmt_val(rolling.get("barrel_5g"), "pct")), | |
| ], zones["rolling"], theme) | |
| _draw_footer(base, zones["footer"], theme) | |
| base = _apply_final_effects(base) | |
| return _export(base, fmt) | |