2026_MLB_Model / visualization /cards /card_renderer.py
Syntrex's picture
Card Lab V1: downloadable player card generator replaces Matchups tab
0b1a395
raw
history blame
13.9 kB
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)