from __future__ import annotations import io from PIL import Image, ImageDraw, ImageFont from visualization.cards.card_data import PALETTE, _clamp, _fmt_val from utils.logger import logger CARD_W = 800 CARD_H = 1350 def _rgb(hex_str: str) -> tuple[int, int, int]: h = hex_str.lstrip("#") return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) def _score_color(score) -> str: if score is None: return PALETTE["text_secondary"] s = float(score) if s >= 80: return PALETTE["accent_green"] if s >= 65: return PALETTE["accent_yellow"] if s >= 45: return PALETTE["text_secondary"] return PALETTE["accent_red"] def _dq_color(dq: str) -> str: return { "full": PALETTE["accent_green"], "partial": PALETTE["accent_yellow"], "limited": PALETTE["accent_red"], }.get(dq, PALETTE["text_secondary"]) def _font(size: int) -> ImageFont.ImageFont: try: return ImageFont.load_default(size=size) except TypeError: return ImageFont.load_default() def _rect(draw: ImageDraw.ImageDraw, x, y, w, h, fill, radius=8): draw.rounded_rectangle([x, y, x + w, y + h], radius=radius, fill=_rgb(fill)) def _text(draw: ImageDraw.ImageDraw, txt, x, y, size, color, anchor="lt"): draw.text((x, y), str(txt), fill=_rgb(color), font=_font(size), anchor=anchor) def _paste_chart(img: Image.Image, chart_img, x, y, w, h): if chart_img is None: draw = ImageDraw.Draw(img) _rect(draw, x, y, w, h, PALETTE["panel_alt"]) _text(draw, "Data unavailable", x + w // 2, y + h // 2, 12, PALETTE["text_dim"], anchor="mm") return chart_resized = chart_img.resize((w, h), Image.LANCZOS) img.paste(chart_resized, (x, y)) # --------------------------------------------------------------------------- # Section drawers # --------------------------------------------------------------------------- def _draw_header(draw: ImageDraw.ImageDraw, payload: dict): """y 0..178""" _rect(draw, 0, 0, CARD_W, 178, PALETTE["panel"]) player_name = payload.get("player_name", "—") team = payload.get("team", "—") timeframe = str(payload.get("timeframe", "—")).replace("_", " ") card_type = str(payload.get("card_type", "")).lower() dq = payload.get("data_quality", "limited") # Player name (large) _text(draw, player_name, 24, 22, 26, PALETTE["text_primary"]) # Team _text(draw, team, 24, 60, 15, PALETTE["text_secondary"]) # Timeframe _text(draw, timeframe, 24, 82, 13, PALETTE["text_dim"]) # KASPER brand top-right _text(draw, "KASPER", CARD_W - 24, 22, 15, PALETTE["accent_blue"], anchor="ra") # Card type badge type_labels = { "hitter": "HITTER CARD", "pitcher": "PITCHER CARD", "game_summary": "GAME SUMMARY", } type_label = type_labels.get(card_type, card_type.upper()) _rect(draw, CARD_W - 154, 48, 130, 24, PALETTE["panel_alt"], radius=12) _text(draw, type_label, CARD_W - 89, 60, 10, PALETTE["accent_blue"], anchor="mm") # Data quality badge dq_labels = {"full": "FULL DATA", "partial": "PARTIAL", "limited": "LIMITED"} dq_label = dq_labels.get(dq, dq.upper()) _rect(draw, CARD_W - 154, 80, 130, 22, PALETTE["panel_alt"], radius=11) _text(draw, dq_label, CARD_W - 89, 91, 10, _dq_color(dq), anchor="mm") # Bottom divider draw.line([(0, 173), (CARD_W, 173)], fill=_rgb("#1e293b"), width=1) def _draw_metrics_grid(draw: ImageDraw.ImageDraw, metrics: dict, card_type: str, y_start: int = 188): """4 tiles 2×2. Priority 1 — never omit.""" if card_type == "pitcher": labels = ["STUFF+", "COMMAND+", "DAMAGE ZONE+", "BULLPEN LOAD"] keys = ["stuff_plus", "command_plus", "damage_zone_plus", "bullpen_fatigue"] else: labels = ["CONTACT+", "HR SHAPE+", "DAMAGE ZONE+", "CONFIDENCE"] keys = ["contact_plus", "hr_shape_plus", "damage_zone_plus", "ball_flight_confidence"] margin = 12 gap = 12 tile_w = (CARD_W - 2 * margin - gap) // 2 tile_h = 95 for i, (label, key) in enumerate(zip(labels, keys)): col = i % 2 row = i // 2 x = margin + col * (tile_w + gap) y = y_start + row * (tile_h + gap) _rect(draw, x, y, tile_w, tile_h, PALETTE["panel_alt"]) score = metrics.get(key) score_str = f"{float(score):.0f}" if score is not None else "—" _text(draw, score_str, x + tile_w // 2, y + 30, 34, _score_color(score), anchor="mm") _text(draw, label, x + tile_w // 2, y + 74, 10, PALETTE["text_dim"], anchor="mm") def _draw_stat_bar(draw: ImageDraw.ImageDraw, summary: dict, card_type: str, y_start: int = 416): """5 cells one row. Priority 1.""" _rect(draw, 0, y_start, CARD_W, 86, PALETTE["panel"]) if card_type == "pitcher": items = [ ("VELO", _fmt_val(summary.get("avg_release_speed"), "float") + " mph"), ("SPIN", _fmt_val(summary.get("avg_release_spin_rate"), "int") + " rpm"), ("EV ALLOW", _fmt_val(summary.get("ev_allowed"), "float") + " mph"), ("BBL ALLOW", _fmt_val(summary.get("barrel_rate_allowed"), "pct")), ("SWSTR%", _fmt_val(summary.get("swstr_rate"), "pct")), ] else: items = [ ("EV90", _fmt_val(summary.get("ev90"), "float") + " mph"), ("BARREL%", _fmt_val(summary.get("barrel_rate"), "pct")), ("HARD HIT%", _fmt_val(summary.get("hard_hit_rate"), "pct")), ("xwOBA", _fmt_val(summary.get("xwoba"), "float")), ("AVG LA", _fmt_val(summary.get("avg_launch_angle"), "float") + "°"), ] cell_w = CARD_W // 5 for i, (label, val) in enumerate(items): cx = i * cell_w + cell_w // 2 _text(draw, label, cx, y_start + 14, 10, PALETTE["text_dim"], anchor="mm") _text(draw, val, cx, y_start + 50, 15, PALETTE["text_primary"], anchor="mm") def _draw_prob_row(draw: ImageDraw.ImageDraw, baseline: dict, y_start: int = 514): """Hitter only: HR% | Hit% | TB2P%. Priority 1.""" _rect(draw, 0, y_start, CARD_W, 66, PALETTE["panel_alt"]) items = [ ("HR PROB", _fmt_val(baseline.get("hr_prob"), "pct")), ("HIT PROB", _fmt_val(baseline.get("hit_prob"), "pct")), ("TB2P PROB", _fmt_val(baseline.get("tb2p_prob"), "pct")), ] cell_w = CARD_W // 3 for i, (label, val) in enumerate(items): cx = i * cell_w + cell_w // 2 _text(draw, label, cx, y_start + 12, 10, PALETTE["text_dim"], anchor="mm") _text(draw, val, cx, y_start + 42, 17, PALETTE["text_primary"], anchor="mm") def _draw_readout(draw: ImageDraw.ImageDraw, readout_lines: list, y_start: int): """Readout panel with left accent bar. Priority 2.""" visible = [str(l) for l in readout_lines if l][:4] panel_h = 36 + len(visible) * 36 + 16 _rect(draw, 12, y_start, CARD_W - 24, panel_h, PALETTE["panel"]) draw.rectangle([12, y_start, 18, y_start + panel_h], fill=_rgb(PALETTE["accent_blue"])) _text(draw, "KASPER READOUT", 28, y_start + 12, 10, PALETTE["accent_blue"]) for i, line in enumerate(visible): y = y_start + 40 + i * 36 _text(draw, f"› {line}", 28, y, 11, PALETTE["text_secondary"]) return y_start + panel_h + 8 def _draw_rolling_strip(draw: ImageDraw.ImageDraw, rolling: dict, card_type: str, y_start: int): """3-cell rolling strip. Priority 4 — reduced if tight.""" _rect(draw, 0, y_start, CARD_W, 66, PALETTE["panel"]) if card_type == "pitcher": items = [ ("5G VELO", _fmt_val(rolling.get("velo_5g"), "float") + " mph"), ("5G EV ALLOW", _fmt_val(rolling.get("ev_allowed_5g"), "float") + " mph"), ("5G BBL ALLOW", _fmt_val(rolling.get("barrel_5g"), "pct")), ] else: items = [ ("5G EV90", _fmt_val(rolling.get("ev90_5g"), "float") + " mph"), ("5G BARREL%", _fmt_val(rolling.get("barrel_5g"), "pct")), ("5G HR%", _fmt_val(rolling.get("hr_rate_5g"), "pct")), ] cell_w = CARD_W // 3 for i, (label, val) in enumerate(items): cx = i * cell_w + cell_w // 2 _text(draw, label, cx, y_start + 12, 10, PALETTE["text_dim"], anchor="mm") _text(draw, val, cx, y_start + 42, 15, PALETTE["text_primary"], anchor="mm") def _draw_footer(draw: ImageDraw.ImageDraw, y_start: int = 1290): _text(draw, "KASPER", 24, y_start + 12, 16, PALETTE["accent_blue"]) _text(draw, "Statistical estimates only. Alpha release. Not financial advice.", CARD_W // 2, y_start + 14, 10, PALETTE["text_dim"], anchor="mm") _text(draw, "kasper.ai", CARD_W - 24, y_start + 12, 11, PALETTE["text_dim"], anchor="ra") def _draw_game_hitter_table(draw: ImageDraw.ImageDraw, hitters: list, y_start: int = 200) -> int: col_xs = [24, 260, 360, 460, 580] headers = ["PLAYER", "HR", "HITS", "EV90", "BARRELS"] _text(draw, "TOP HITTERS", 24, y_start, 11, PALETTE["accent_blue"]) y = y_start + 26 for i, h in enumerate(headers): _text(draw, h, col_xs[i], y, 10, PALETTE["text_dim"]) y += 20 draw.line([(24, y), (CARD_W - 24, y)], fill=_rgb("#1e293b"), width=1) y += 8 for row in hitters[:8]: name = str(row.get("player_name", "—"))[:22] _text(draw, name, col_xs[0], y, 12, PALETTE["text_primary"]) _text(draw, _fmt_val(row.get("hr"), "int"), col_xs[1], y, 12, PALETTE["text_primary"]) _text(draw, _fmt_val(row.get("hits"), "int"), col_xs[2], y, 12, PALETTE["text_primary"]) _text(draw, _fmt_val(row.get("ev90"), "float"), col_xs[3], y, 12, PALETTE["text_primary"]) _text(draw, _fmt_val(row.get("barrels"),"int"), col_xs[4], y, 12, PALETTE["text_primary"]) y += 34 return y # --------------------------------------------------------------------------- # Export helper # --------------------------------------------------------------------------- def _export(img: Image.Image, fmt: str) -> bytes: buf = io.BytesIO() if fmt == "JPG": img.convert("RGB").save(buf, format="JPEG", quality=92) else: img.save(buf, format="PNG") buf.seek(0) return buf.getvalue() # --------------------------------------------------------------------------- # Card entry points # --------------------------------------------------------------------------- def render_hitter_card(payload: dict, fmt: str = "PNG") -> bytes: from visualization.cards.card_charts import build_ev_la_chart, build_spray_chart img = Image.new("RGB", (CARD_W, CARD_H), _rgb(PALETTE["bg"])) draw = ImageDraw.Draw(img) windowed_df = payload.get("windowed_df") player_name = payload.get("player_name", "") # Build charts (priority 2) ev_la_chart = build_ev_la_chart(windowed_df, player_name, w_px=382, h_px=228) spray_chart = build_spray_chart(windowed_df, player_name, w_px=382, h_px=228) # Draw in priority order _draw_header(draw, payload) # y 0..178 _draw_metrics_grid(draw, payload.get("metrics", {}), "hitter", 188) # y 188..408 _draw_stat_bar(draw, payload.get("summary", {}), "hitter", 416) # y 416..502 _draw_prob_row(draw, payload.get("baseline", {}), 514) # y 514..580 # Chart row y 592..820 _paste_chart(img, ev_la_chart, 12, 592, 382, 228) _paste_chart(img, spray_chart, 406, 592, 382, 228) readout_end = _draw_readout(draw, payload.get("readout", []), 832) # priority 2 _draw_rolling_strip(draw, payload.get("rolling", {}), "hitter", max(readout_end, 1060)) _draw_footer(draw, 1290) return _export(img, fmt) def render_pitcher_card(payload: dict, fmt: str = "PNG") -> bytes: from visualization.cards.card_charts import build_pitcher_damage_grid img = Image.new("RGB", (CARD_W, CARD_H), _rgb(PALETTE["bg"])) draw = ImageDraw.Draw(img) windowed_df = payload.get("windowed_df") player_name = payload.get("player_name", "") damage_chart = build_pitcher_damage_grid(windowed_df, player_name, w_px=776, h_px=228) _draw_header(draw, payload) _draw_metrics_grid(draw, payload.get("metrics", {}), "pitcher", 188) _draw_stat_bar(draw, payload.get("summary", {}), "pitcher", 416) # Full-width chart row y 514..742 _paste_chart(img, damage_chart, 12, 514, 776, 228) readout_end = _draw_readout(draw, payload.get("readout", []), 754) _draw_rolling_strip(draw, payload.get("rolling", {}), "pitcher", max(readout_end, 1000)) _draw_footer(draw, 1290) return _export(img, fmt) def render_game_summary_card(payload: dict, fmt: str = "PNG") -> bytes: img = Image.new("RGB", (CARD_W, CARD_H), _rgb(PALETTE["bg"])) draw = ImageDraw.Draw(img) _draw_header(draw, payload) # Score banner y 190..270 away = payload.get("away_team", "—") home = payload.get("home_team", "—") away_s = _fmt_val(payload.get("away_score"), "int") home_s = _fmt_val(payload.get("home_score"), "int") _rect(draw, 12, 190, CARD_W - 24, 78, PALETTE["panel_alt"]) _text(draw, away, 36, 204, 18, PALETTE["text_primary"]) _text(draw, away_s, 260, 204, 26, PALETTE["accent_yellow"]) _text(draw, "—", CARD_W // 2, 217, 16, PALETTE["text_dim"], anchor="mm") _text(draw, home_s, CARD_W - 260, 204, 26, PALETTE["accent_yellow"]) _text(draw, home, CARD_W - 36, 204, 18, PALETTE["text_primary"], anchor="ra") _draw_game_hitter_table(draw, payload.get("hitters", []), y_start=285) _draw_footer(draw, 1290) return _export(img, fmt)