Spaces:
Sleeping
Sleeping
| 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) | |