Syntrex Claude Sonnet 4.6 commited on
Commit
edd5c8e
·
1 Parent(s): f0c3149

Card Lab: Kasper Report poster renderer (1024×1536 HUD template)

Browse files

Removes page lock. Introduces template-based poster system:

poster_templates.py
- THEMES dict: kasper_hud color palette (bg, panels, cyan/amber/red accents)
- TEMPLATES dict: kasper_report layout with named zones (x,y,w,h)

poster_renderer.py
- build_background(): procedural dark navy + noise grain + star particles + vignette
- process_player_image(): rembg bg removal (graceful fallback) → rim light → glow halo
- _glow_rect() / _glow_line(): blurred glow layers composited under crisp borders
- _hud_corners(): L-bracket corner decorations
- Zone drawers: header, metric boxes, chart panel, stat row, readout, alert, rolling, footer
- _apply_final_effects(): radial vignette post-pass
- render_hitter_poster() / render_pitcher_poster(): full pipeline entry points

card_lab_page.py
- Player photo upload (optional PNG/JPG, rembg removes bg automatically)
- Hitter/Pitcher now call poster renderer; Game Summary retains classic card renderer
- Signature updated: _gen_hitter_bytes / _gen_pitcher_bytes accept player_pil arg

requirements.txt: adds rembg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

app.py CHANGED
@@ -3119,57 +3119,6 @@ def render_alpha_release() -> None:
3119
  )
3120
 
3121
 
3122
- # ---------------------------------------------------------------------------
3123
- # Page lock system
3124
- # Add pages here to require a password before rendering.
3125
- # Key = sidebar label, Value = st.secrets key holding the password.
3126
- # ---------------------------------------------------------------------------
3127
- LOCKED_PAGES: dict[str, str] = {
3128
- "Card Lab": "MASTER_PASSWORD",
3129
- }
3130
-
3131
-
3132
- def _render_page_lock(page_name: str) -> bool:
3133
- """
3134
- Gate a locked page behind a password prompt.
3135
-
3136
- Returns True → caller should render the page normally.
3137
- Returns False → lock screen was shown; caller must NOT render the page.
3138
-
3139
- Unlock state is stored in st.session_state so the user only enters the
3140
- password once per session.
3141
- """
3142
- secret_key = LOCKED_PAGES.get(page_name)
3143
- if not secret_key:
3144
- return True # page not locked
3145
-
3146
- session_key = f"_unlocked_{page_name}"
3147
- if st.session_state.get(session_key):
3148
- return True # already unlocked this session
3149
-
3150
- # Retrieve master password from secrets
3151
- try:
3152
- master_pw = st.secrets[secret_key]
3153
- except (KeyError, FileNotFoundError):
3154
- st.error(
3155
- f"Secret `{secret_key}` not found. "
3156
- "Add it to Hugging Face Space secrets before using this page."
3157
- )
3158
- st.stop()
3159
-
3160
- # Lock screen
3161
- st.markdown("## 🔒 Under Construction")
3162
- st.caption("This page is password-protected.")
3163
- entered = st.text_input("Password", type="password", key=f"_lock_input_{page_name}")
3164
- if st.button("Enter", key=f"_lock_btn_{page_name}"):
3165
- if entered == master_pw:
3166
- st.session_state[session_key] = True
3167
- st.rerun()
3168
- else:
3169
- st.error("Incorrect password.")
3170
- return False
3171
-
3172
-
3173
  def main() -> None:
3174
  render_header()
3175
 
@@ -3195,8 +3144,7 @@ def main() -> None:
3195
  elif page == "Props":
3196
  render_props(load_statcast_recent(), conn=conn)
3197
  elif page == "Card Lab":
3198
- if _render_page_lock("Card Lab"):
3199
- render_card_lab(conn=conn)
3200
  elif page == "Betting":
3201
  render_betting()
3202
  elif page == "Bet Tracker":
 
3119
  )
3120
 
3121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3122
  def main() -> None:
3123
  render_header()
3124
 
 
3144
  elif page == "Props":
3145
  render_props(load_statcast_recent(), conn=conn)
3146
  elif page == "Card Lab":
3147
+ render_card_lab(conn=conn)
 
3148
  elif page == "Betting":
3149
  render_betting()
3150
  elif page == "Bet Tracker":
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
  streamlit==1.39.0
 
2
  Pillow
3
  matplotlib
4
  pandas==2.2.3
 
1
  streamlit==1.39.0
2
+ rembg
3
  Pillow
4
  matplotlib
5
  pandas==2.2.3
visualization/card_lab_page.py CHANGED
@@ -1,8 +1,10 @@
1
  from __future__ import annotations
2
 
 
3
  import re
4
 
5
  import streamlit as st
 
6
  from sqlalchemy import text
7
 
8
  from visualization.cards.card_data import (
@@ -10,11 +12,8 @@ from visualization.cards.card_data import (
10
  build_pitcher_card_data,
11
  build_game_summary_card_data,
12
  )
13
- from visualization.cards.card_renderer import (
14
- render_hitter_card,
15
- render_pitcher_card,
16
- render_game_summary_card,
17
- )
18
  from visualization.cards.card_queries import (
19
  get_card_lab_hitters,
20
  get_card_lab_pitchers,
@@ -54,7 +53,7 @@ def _cached_pitchers(_conn, year):
54
  # Card generation functions — button-click only, no caching
55
  # ---------------------------------------------------------------------------
56
 
57
- def _gen_hitter_bytes(conn, player_name, mode, year, date, start_date, end_date, fmt):
58
  windowed_df = get_player_card_window_df(
59
  conn, player_name, "Hitter", mode=mode, year=year,
60
  date=date, start_date=start_date, end_date=end_date,
@@ -65,11 +64,11 @@ def _gen_hitter_bytes(conn, player_name, mode, year, date, start_date, end_date,
65
  player_name, windowed_df, mode=mode, year=year,
66
  date=date, start_date=start_date, end_date=end_date,
67
  )
68
- img_bytes = render_hitter_card(payload, fmt)
69
  return img_bytes, payload.get("timeframe", ""), payload.get("data_quality", "")
70
 
71
 
72
- def _gen_pitcher_bytes(conn, player_name, pitcher_id, mode, year, date, start_date, end_date, fmt):
73
  windowed_df = get_player_card_window_df(
74
  conn, player_name, "Pitcher", mode=mode, year=year,
75
  date=date, start_date=start_date, end_date=end_date,
@@ -81,7 +80,7 @@ def _gen_pitcher_bytes(conn, player_name, pitcher_id, mode, year, date, start_da
81
  player_name, windowed_df, mode=mode, year=year,
82
  date=date, start_date=start_date, end_date=end_date,
83
  )
84
- img_bytes = render_pitcher_card(payload, fmt)
85
  return img_bytes, payload.get("timeframe", ""), payload.get("data_quality", "")
86
 
87
 
@@ -106,11 +105,26 @@ def _gen_game_bytes(conn, game_pk, away_team, home_team, away_score, home_score,
106
 
107
  def render_card_lab(conn) -> None:
108
  st.subheader("Kasper Card Lab")
109
- st.caption("Generate downloadable Kasper player cards.")
110
 
111
  # ---- Card type ----
112
  card_type = st.radio("Card Type", ["Hitter", "Pitcher", "Game Summary"], horizontal=True)
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  # ---- Timeframe controls (Hitter / Pitcher only) ----
115
  date = start_date = end_date = None
116
  year = None
@@ -210,7 +224,6 @@ def render_card_lab(conn) -> None:
210
  format_func=lambda n: "Full Game" if n == "Full Game" else normalize_name(n),
211
  key="cl_game_player",
212
  )
213
- # Explicit session state binding to ensure rerender picks up selection
214
  st.session_state["cl_game_player_selected"] = gp_sel
215
  player_name = None if gp_sel == "Full Game" else gp_sel
216
 
@@ -231,25 +244,25 @@ def render_card_lab(conn) -> None:
231
 
232
  if card_type == "Hitter":
233
  status.info("Querying warehouse data...")
234
- status.info("Building charts...")
235
  img_bytes, tf, dq = _gen_hitter_bytes(
236
- conn, player_name, mode_key, year, date, start_date, end_date, fmt
237
  )
238
  st.session_state["card_player"] = player_name or "unknown"
239
  st.session_state["card_timeframe"] = tf
240
 
241
  elif card_type == "Pitcher":
242
  status.info("Querying warehouse data...")
243
- status.info("Building charts...")
244
  img_bytes, tf, dq = _gen_pitcher_bytes(
245
- conn, player_name, pitcher_id, mode_key, year, date, start_date, end_date, fmt
246
  )
247
  st.session_state["card_player"] = player_name or "unknown"
248
  st.session_state["card_timeframe"] = tf
249
 
250
  else:
251
  status.info("Querying warehouse data...")
252
- status.info("Building charts...")
253
  img_bytes, tf = _gen_game_bytes(
254
  conn,
255
  game_pk=selected_game_row.get("game_pk"),
 
1
  from __future__ import annotations
2
 
3
+ import io
4
  import re
5
 
6
  import streamlit as st
7
+ from PIL import Image
8
  from sqlalchemy import text
9
 
10
  from visualization.cards.card_data import (
 
12
  build_pitcher_card_data,
13
  build_game_summary_card_data,
14
  )
15
+ from visualization.cards.card_renderer import render_game_summary_card
16
+ from visualization.cards.poster_renderer import render_hitter_poster, render_pitcher_poster
 
 
 
17
  from visualization.cards.card_queries import (
18
  get_card_lab_hitters,
19
  get_card_lab_pitchers,
 
53
  # Card generation functions — button-click only, no caching
54
  # ---------------------------------------------------------------------------
55
 
56
+ def _gen_hitter_bytes(conn, player_name, mode, year, date, start_date, end_date, fmt, player_pil):
57
  windowed_df = get_player_card_window_df(
58
  conn, player_name, "Hitter", mode=mode, year=year,
59
  date=date, start_date=start_date, end_date=end_date,
 
64
  player_name, windowed_df, mode=mode, year=year,
65
  date=date, start_date=start_date, end_date=end_date,
66
  )
67
+ img_bytes = render_hitter_poster(payload, player_img=player_pil, fmt=fmt)
68
  return img_bytes, payload.get("timeframe", ""), payload.get("data_quality", "")
69
 
70
 
71
+ def _gen_pitcher_bytes(conn, player_name, pitcher_id, mode, year, date, start_date, end_date, fmt, player_pil):
72
  windowed_df = get_player_card_window_df(
73
  conn, player_name, "Pitcher", mode=mode, year=year,
74
  date=date, start_date=start_date, end_date=end_date,
 
80
  player_name, windowed_df, mode=mode, year=year,
81
  date=date, start_date=start_date, end_date=end_date,
82
  )
83
+ img_bytes = render_pitcher_poster(payload, player_img=player_pil, fmt=fmt)
84
  return img_bytes, payload.get("timeframe", ""), payload.get("data_quality", "")
85
 
86
 
 
105
 
106
  def render_card_lab(conn) -> None:
107
  st.subheader("Kasper Card Lab")
108
+ st.caption("Generate downloadable Kasper scouting report posters.")
109
 
110
  # ---- Card type ----
111
  card_type = st.radio("Card Type", ["Hitter", "Pitcher", "Game Summary"], horizontal=True)
112
 
113
+ # ---- Player photo upload (Hitter / Pitcher only) ----
114
+ player_pil: Image.Image | None = None
115
+ if card_type in ("Hitter", "Pitcher"):
116
+ uploaded = st.file_uploader(
117
+ "Player Photo (optional — PNG/JPG, background auto-removed)",
118
+ type=["png", "jpg", "jpeg"],
119
+ key="cl_photo",
120
+ )
121
+ if uploaded is not None:
122
+ try:
123
+ player_pil = Image.open(io.BytesIO(uploaded.read()))
124
+ except Exception as exc:
125
+ st.warning(f"Could not load uploaded image: {exc}")
126
+ player_pil = None
127
+
128
  # ---- Timeframe controls (Hitter / Pitcher only) ----
129
  date = start_date = end_date = None
130
  year = None
 
224
  format_func=lambda n: "Full Game" if n == "Full Game" else normalize_name(n),
225
  key="cl_game_player",
226
  )
 
227
  st.session_state["cl_game_player_selected"] = gp_sel
228
  player_name = None if gp_sel == "Full Game" else gp_sel
229
 
 
244
 
245
  if card_type == "Hitter":
246
  status.info("Querying warehouse data...")
247
+ status.info("Building poster...")
248
  img_bytes, tf, dq = _gen_hitter_bytes(
249
+ conn, player_name, mode_key, year, date, start_date, end_date, fmt, player_pil
250
  )
251
  st.session_state["card_player"] = player_name or "unknown"
252
  st.session_state["card_timeframe"] = tf
253
 
254
  elif card_type == "Pitcher":
255
  status.info("Querying warehouse data...")
256
+ status.info("Building poster...")
257
  img_bytes, tf, dq = _gen_pitcher_bytes(
258
+ conn, player_name, pitcher_id, mode_key, year, date, start_date, end_date, fmt, player_pil
259
  )
260
  st.session_state["card_player"] = player_name or "unknown"
261
  st.session_state["card_timeframe"] = tf
262
 
263
  else:
264
  status.info("Querying warehouse data...")
265
+ status.info("Building summary card...")
266
  img_bytes, tf = _gen_game_bytes(
267
  conn,
268
  game_pk=selected_game_row.get("game_pk"),
visualization/cards/poster_renderer.py ADDED
@@ -0,0 +1,728 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+
5
+ import numpy as np
6
+ from PIL import (
7
+ Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageFont,
8
+ )
9
+
10
+ from visualization.cards.card_data import _fmt_val
11
+ from visualization.cards.poster_templates import TEMPLATES, THEMES
12
+ from utils.logger import logger
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Font helper
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def _font(size: int) -> ImageFont.ImageFont:
20
+ try:
21
+ return ImageFont.load_default(size=size)
22
+ except TypeError:
23
+ return ImageFont.load_default()
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Color helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def _rgba(rgb: tuple, alpha: int = 255) -> tuple:
31
+ return rgb + (alpha,)
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Glow drawing primitives
36
+ # ---------------------------------------------------------------------------
37
+
38
+ def _glow_rect(
39
+ base: Image.Image,
40
+ x: int, y: int, w: int, h: int,
41
+ color: tuple,
42
+ border: int = 1,
43
+ glow_radius: int = 10,
44
+ glow_alpha: int = 65,
45
+ ) -> None:
46
+ """Draw a glowing border rectangle directly onto an RGBA base."""
47
+ layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
48
+ ld = ImageDraw.Draw(layer)
49
+ for i in range(glow_radius, 0, -2):
50
+ a = int(glow_alpha * (1 - i / glow_radius) ** 1.5)
51
+ ld.rectangle(
52
+ [x - i, y - i, x + w + i, y + h + i],
53
+ outline=_rgba(color, a), width=1,
54
+ )
55
+ layer = layer.filter(ImageFilter.GaussianBlur(radius=3))
56
+ base.alpha_composite(layer)
57
+ bd = ImageDraw.Draw(base)
58
+ bd.rectangle([x, y, x + w, y + h], outline=_rgba(color, 210), width=border)
59
+
60
+
61
+ def _glow_line(
62
+ base: Image.Image,
63
+ xy: list,
64
+ color: tuple,
65
+ width: int = 1,
66
+ glow_radius: int = 5,
67
+ glow_alpha: int = 55,
68
+ ) -> None:
69
+ """Draw a glowing line onto an RGBA base."""
70
+ layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
71
+ ld = ImageDraw.Draw(layer)
72
+ ld.line(xy, fill=_rgba(color, glow_alpha), width=width + glow_radius)
73
+ layer = layer.filter(ImageFilter.GaussianBlur(radius=glow_radius // 2))
74
+ base.alpha_composite(layer)
75
+ bd = ImageDraw.Draw(base)
76
+ bd.line(xy, fill=_rgba(color, 200), width=width)
77
+
78
+
79
+ def _hud_corners(
80
+ base: Image.Image,
81
+ x: int, y: int, w: int, h: int,
82
+ color: tuple,
83
+ size: int = 20,
84
+ thickness: int = 1,
85
+ ) -> None:
86
+ """Draw four L-shaped HUD corner brackets."""
87
+ bd = ImageDraw.Draw(base)
88
+ segs = [
89
+ [(x, y + size), (x, y), (x + size, y)],
90
+ [(x + w - size, y), (x + w, y), (x + w, y + size)],
91
+ [(x, y + h - size), (x, y + h), (x + size, y + h)],
92
+ [(x + w - size, y + h), (x + w, y + h), (x + w, y + h - size)],
93
+ ]
94
+ for pts in segs:
95
+ bd.line(pts, fill=_rgba(color, 200), width=thickness)
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Background
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def build_background(W: int, H: int, theme: dict) -> Image.Image:
103
+ """
104
+ Procedural dark background:
105
+ - Dark navy base with subtle noise grain
106
+ - Corner-weighted vignette
107
+ - Scattered faint star particles
108
+ """
109
+ rng = np.random.default_rng(seed=42)
110
+ noise = rng.integers(0, 14, (H, W, 3), dtype=np.uint8)
111
+ base_c = np.array(theme["bg"], dtype=np.int16)
112
+ arr = np.clip(base_c + noise, 0, 255).astype(np.uint8)
113
+
114
+ # Radial vignette
115
+ cy, cx = H / 2.0, W / 2.0
116
+ Y, X = np.mgrid[0:H, 0:W].astype(np.float32)
117
+ dist = np.sqrt(((X - cx) / cx) ** 2 + ((Y - cy) / cy) ** 2)
118
+ vig = np.clip(1.0 - theme["vignette"] * dist, 0.25, 1.0)
119
+ arr = (arr * vig[:, :, np.newaxis]).clip(0, 255).astype(np.uint8)
120
+
121
+ img = Image.fromarray(arr, "RGB").convert("RGBA")
122
+
123
+ # Faint star particles
124
+ draw = ImageDraw.Draw(img)
125
+ rng2 = np.random.default_rng(seed=7)
126
+ for _ in range(150):
127
+ sx = int(rng2.integers(0, W))
128
+ sy = int(rng2.integers(0, H))
129
+ br = int(rng2.integers(35, 110))
130
+ sz = int(rng2.choice([1, 1, 1, 2]))
131
+ draw.ellipse(
132
+ [sx, sy, sx + sz, sy + sz],
133
+ fill=(br, int(br * 1.05), int(br * 1.25), br),
134
+ )
135
+
136
+ return img
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Header
141
+ # ---------------------------------------------------------------------------
142
+
143
+ def _draw_header(base: Image.Image, payload: dict, zone: tuple, theme: dict) -> None:
144
+ x, y, w, h = zone
145
+ bd = ImageDraw.Draw(base)
146
+ bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel"], 248))
147
+
148
+ # Bottom glow line
149
+ _glow_line(base, [(x, y + h - 1), (x + w, y + h - 1)],
150
+ theme["accent_cyan"], width=1, glow_radius=5, glow_alpha=90)
151
+
152
+ bd2 = ImageDraw.Draw(base)
153
+
154
+ # Brand + type label (left column)
155
+ bd2.text((x + 24, y + 12), "KASPER",
156
+ font=_font(12), fill=_rgba(theme["accent_cyan"], 230))
157
+ card_type = str(payload.get("card_type", "")).upper()
158
+ type_label = {"HITTER": "HITTER REPORT", "PITCHER": "PITCHER REPORT"}.get(
159
+ card_type, "SCOUTING REPORT"
160
+ )
161
+ bd2.text((x + 24, y + 30), type_label,
162
+ font=_font(8), fill=_rgba(theme["text_dim"], 190))
163
+
164
+ # Player name (large, left)
165
+ name = str(payload.get("player_name", "—")).upper()
166
+ bd2.text((x + 24, y + 50), name,
167
+ font=_font(30), fill=_rgba(theme["text_primary"], 255))
168
+
169
+ # Team + timeframe (right)
170
+ team = str(payload.get("team", "—")).upper()
171
+ tf = str(payload.get("timeframe", "")).replace("_", " ").upper()
172
+ bd2.text((x + w - 22, y + 12), team,
173
+ font=_font(12), fill=_rgba(theme["text_secondary"], 200), anchor="ra")
174
+ bd2.text((x + w - 22, y + 30), tf,
175
+ font=_font(8), fill=_rgba(theme["text_dim"], 170), anchor="ra")
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Player image processing
180
+ # ---------------------------------------------------------------------------
181
+
182
+ def _remove_background(img: Image.Image) -> Image.Image:
183
+ """Attempt background removal with rembg; return original on any failure."""
184
+ try:
185
+ from rembg import remove
186
+ return remove(img)
187
+ except ImportError:
188
+ logger.warning("[poster] rembg not installed — skipping bg removal")
189
+ return img
190
+ except Exception as exc:
191
+ logger.warning("[poster] rembg failed: %s", exc)
192
+ return img
193
+
194
+
195
+ def _apply_glow(img: Image.Image, color: tuple, radius: int = 28) -> Image.Image:
196
+ """
197
+ Add a coloured glow halo around a player cutout (RGBA input).
198
+ Returns a larger image with padding so the glow bleeds outward.
199
+ """
200
+ W, H = img.size
201
+ pad = radius * 2
202
+ padded = Image.new("RGBA", (W + 2 * pad, H + 2 * pad), (0, 0, 0, 0))
203
+ padded.paste(img, (pad, pad), img)
204
+
205
+ alpha = padded.split()[3]
206
+ glow_layer = Image.new("RGBA", padded.size, _rgba(color, 0))
207
+ glow_layer.putalpha(alpha)
208
+ glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=radius))
209
+
210
+ combined = Image.new("RGBA", padded.size, (0, 0, 0, 0))
211
+ combined = Image.alpha_composite(combined, glow_layer)
212
+ combined = Image.alpha_composite(combined, padded)
213
+ return combined
214
+
215
+
216
+ def _apply_rim_light(img: Image.Image, color: tuple, width: int = 4) -> Image.Image:
217
+ """Add a bright rim highlight on the silhouette edge. RGBA input/output."""
218
+ alpha = img.split()[3]
219
+ dilated = alpha.filter(ImageFilter.MaxFilter(width * 2 + 1))
220
+ rim_mask = ImageChops.subtract(dilated, alpha)
221
+ rim_mask = ImageEnhance.Brightness(Image.fromarray(
222
+ np.array(rim_mask)
223
+ )).enhance(1.3)
224
+
225
+ rim = Image.new("RGBA", img.size, _rgba(color, 0))
226
+ rim.putalpha(rim_mask)
227
+
228
+ result = img.copy()
229
+ result = Image.alpha_composite(result, rim)
230
+ return result
231
+
232
+
233
+ def process_player_image(
234
+ player_img: Image.Image | None,
235
+ zone_wh: tuple[int, int],
236
+ theme: dict,
237
+ remove_bg: bool = True,
238
+ ) -> Image.Image | None:
239
+ """
240
+ Full player image pipeline:
241
+ 1. Convert to RGBA
242
+ 2. Optionally remove background
243
+ 3. Resize to fit zone
244
+ 4. Apply rim light (on clean silhouette)
245
+ 5. Apply glow (expands canvas)
246
+ Returns processed RGBA image.
247
+ """
248
+ if player_img is None:
249
+ return None
250
+ try:
251
+ img = player_img.convert("RGBA")
252
+ if remove_bg:
253
+ img = _remove_background(img)
254
+ zw, zh = zone_wh
255
+ img.thumbnail((zw, zh), Image.LANCZOS)
256
+ img = _apply_rim_light(img, theme["accent_cyan"], width=3)
257
+ img = _apply_glow(img, theme["accent_cyan"], radius=26)
258
+ return img
259
+ except Exception as exc:
260
+ logger.warning("[poster] player image processing failed: %s", exc)
261
+ return None
262
+
263
+
264
+ def _paste_player(
265
+ base: Image.Image,
266
+ player_img: Image.Image | None,
267
+ zone: tuple,
268
+ theme: dict,
269
+ ) -> None:
270
+ """Paste processed player image into zone, bottom-centre aligned."""
271
+ x, y, w, h = zone
272
+
273
+ # Subtle zone tint
274
+ overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
275
+ ImageDraw.Draw(overlay).rectangle(
276
+ [x, y, x + w, y + h], fill=_rgba(theme["panel"], 35)
277
+ )
278
+ base.alpha_composite(overlay)
279
+
280
+ # HUD corner brackets
281
+ _hud_corners(base, x + 10, y + 10, w - 20, h - 20,
282
+ theme["accent_cyan"], size=22, thickness=1)
283
+
284
+ if player_img is None:
285
+ bd = ImageDraw.Draw(base)
286
+ bd.multiline_text(
287
+ (x + w // 2, y + h // 2),
288
+ "UPLOAD\nPLAYER\nPHOTO",
289
+ font=_font(20), fill=_rgba(theme["text_dim"], 100),
290
+ anchor="mm", align="center",
291
+ )
292
+ return
293
+
294
+ pw, ph = player_img.size
295
+ paste_x = x + (w - pw) // 2
296
+ paste_y = y + h - ph + 40 # slight upward pull to keep full glow visible
297
+
298
+ # Use a full-canvas temp so PIL clips out-of-bounds naturally
299
+ temp = Image.new("RGBA", base.size, (0, 0, 0, 0))
300
+ temp.paste(player_img, (paste_x, paste_y))
301
+ base.alpha_composite(temp)
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Metric box
306
+ # ---------------------------------------------------------------------------
307
+
308
+ def _draw_metric_box(
309
+ base: Image.Image,
310
+ zone: tuple,
311
+ label: str,
312
+ value: str,
313
+ sub: str,
314
+ theme: dict,
315
+ accent: tuple | None = None,
316
+ ) -> None:
317
+ x, y, w, h = zone
318
+ accent = accent or theme["accent_cyan"]
319
+
320
+ bd = ImageDraw.Draw(base)
321
+ bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel_alt"], 245))
322
+ _glow_rect(base, x, y, w, h, accent, border=1, glow_radius=10, glow_alpha=60)
323
+
324
+ # Left accent stripe
325
+ bd2 = ImageDraw.Draw(base)
326
+ bd2.rectangle([x, y, x + 3, y + h], fill=_rgba(accent, 200))
327
+
328
+ # Label (top-left)
329
+ bd2.text((x + 14, y + 11), label.upper(),
330
+ font=_font(9), fill=_rgba(theme["text_dim"], 200))
331
+
332
+ # Value (centred, large)
333
+ bd2.text((x + w // 2, y + h // 2 + 4), str(value),
334
+ font=_font(34), fill=_rgba(theme["text_primary"], 255), anchor="mm")
335
+
336
+ # Sub (bottom-right)
337
+ if sub:
338
+ bd2.text((x + w - 10, y + h - 10), sub,
339
+ font=_font(8), fill=_rgba(accent, 150), anchor="rb")
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Chart panel
344
+ # ---------------------------------------------------------------------------
345
+
346
+ def _draw_chart_panel(
347
+ base: Image.Image,
348
+ chart_img: Image.Image | None,
349
+ zone: tuple,
350
+ theme: dict,
351
+ ) -> None:
352
+ x, y, w, h = zone
353
+ bd = ImageDraw.Draw(base)
354
+ bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel"], 245))
355
+ _glow_rect(base, x, y, w, h, theme["accent_cyan"], border=1, glow_radius=7, glow_alpha=40)
356
+
357
+ if chart_img is None:
358
+ ImageDraw.Draw(base).text(
359
+ (x + w // 2, y + h // 2), "DATA UNAVAILABLE",
360
+ font=_font(10), fill=_rgba(theme["text_dim"], 140), anchor="mm",
361
+ )
362
+ return
363
+
364
+ inner_w, inner_h = w - 8, h - 8
365
+ resized = chart_img.resize((inner_w, inner_h), Image.LANCZOS).convert("RGBA")
366
+ temp = Image.new("RGBA", base.size, (0, 0, 0, 0))
367
+ temp.paste(resized, (x + 4, y + 4))
368
+ base.alpha_composite(temp)
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # Stat row (full width)
373
+ # ---------------------------------------------------------------------------
374
+
375
+ def _draw_stat_row(
376
+ base: Image.Image,
377
+ items: list[tuple[str, str]],
378
+ zone: tuple,
379
+ theme: dict,
380
+ ) -> None:
381
+ x, y, w, h = zone
382
+ bd = ImageDraw.Draw(base)
383
+ bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel"], 245))
384
+ _glow_line(base, [(x, y), (x + w, y)],
385
+ theme["accent_cyan"], width=1, glow_radius=4, glow_alpha=60)
386
+ _glow_line(base, [(x, y + h), (x + w, y + h)],
387
+ theme["accent_cyan"], width=1, glow_radius=4, glow_alpha=60)
388
+
389
+ n = max(len(items), 1)
390
+ cell_w = w // n
391
+ for i, (label, value) in enumerate(items):
392
+ cx = x + i * cell_w + cell_w // 2
393
+ if i > 0:
394
+ ImageDraw.Draw(base).line(
395
+ [(x + i * cell_w, y + 10), (x + i * cell_w, y + h - 10)],
396
+ fill=_rgba(theme["border_subtle"], 100), width=1,
397
+ )
398
+ bd2 = ImageDraw.Draw(base)
399
+ bd2.text((cx, y + 20), label.upper(),
400
+ font=_font(9), fill=_rgba(theme["text_dim"], 190), anchor="mm")
401
+ bd2.text((cx, y + 62), str(value),
402
+ font=_font(18), fill=_rgba(theme["text_primary"], 240), anchor="mm")
403
+
404
+
405
+ # ---------------------------------------------------------------------------
406
+ # Readout panel
407
+ # ---------------------------------------------------------------------------
408
+
409
+ def _draw_readout_panel(
410
+ base: Image.Image,
411
+ lines: list[str],
412
+ zone: tuple,
413
+ theme: dict,
414
+ ) -> None:
415
+ x, y, w, h = zone
416
+ bd = ImageDraw.Draw(base)
417
+ bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel"], 230))
418
+ _glow_line(base, [(x, y), (x + w, y)],
419
+ theme["accent_cyan"], width=1, glow_radius=4, glow_alpha=60)
420
+
421
+ # Left accent bar
422
+ ImageDraw.Draw(base).rectangle(
423
+ [x, y, x + 3, y + h], fill=_rgba(theme["accent_cyan"], 180)
424
+ )
425
+
426
+ bd2 = ImageDraw.Draw(base)
427
+ bd2.text((x + 16, y + 14), "KASPER READOUT",
428
+ font=_font(10), fill=_rgba(theme["accent_cyan"], 230))
429
+
430
+ for i, line in enumerate(lines[:5]):
431
+ ty = y + 40 + i * 48
432
+ bd2.text((x + 14, ty), "›",
433
+ font=_font(13), fill=_rgba(theme["accent_cyan"], 160))
434
+ bd2.text((x + 30, ty), str(line),
435
+ font=_font(11), fill=_rgba(theme["text_secondary"], 200))
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # Alert box
440
+ # ---------------------------------------------------------------------------
441
+
442
+ def _draw_alert_box(
443
+ base: Image.Image,
444
+ text: str,
445
+ label: str,
446
+ zone: tuple,
447
+ theme: dict,
448
+ ) -> None:
449
+ x, y, w, h = zone
450
+ bd = ImageDraw.Draw(base)
451
+ bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel_alert"], 245))
452
+ _glow_rect(base, x, y, w, h, theme["accent_red"],
453
+ border=1, glow_radius=12, glow_alpha=70)
454
+
455
+ # Top accent bar
456
+ ImageDraw.Draw(base).rectangle(
457
+ [x, y, x + w, y + 4], fill=_rgba(theme["accent_red"], 180)
458
+ )
459
+
460
+ bd2 = ImageDraw.Draw(base)
461
+ bd2.text((x + 14, y + 16), ("⚡ " + label.upper()),
462
+ font=_font(10), fill=_rgba(theme["accent_red"], 240))
463
+
464
+ # Simple word-wrap
465
+ words = str(text).split()
466
+ max_chars = (w - 28) // 7
467
+ lines: list[str] = []
468
+ cur = ""
469
+ for word in words:
470
+ test = (cur + " " + word).strip()
471
+ if len(test) <= max_chars:
472
+ cur = test
473
+ else:
474
+ if cur:
475
+ lines.append(cur)
476
+ cur = word
477
+ if cur:
478
+ lines.append(cur)
479
+
480
+ for i, ln in enumerate(lines[:4]):
481
+ bd2.text((x + 14, y + 36 + i * 30), ln,
482
+ font=_font(11), fill=_rgba(theme["text_primary"], 215))
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # Rolling strip
487
+ # ---------------------------------------------------------------------------
488
+
489
+ def _draw_rolling_strip(
490
+ base: Image.Image,
491
+ items: list[tuple[str, str]],
492
+ zone: tuple,
493
+ theme: dict,
494
+ ) -> None:
495
+ x, y, w, h = zone
496
+ bd = ImageDraw.Draw(base)
497
+ bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel_alt"], 235))
498
+ _glow_rect(base, x, y, w, h, theme["accent_amber"],
499
+ border=1, glow_radius=6, glow_alpha=40)
500
+
501
+ n = max(len(items), 1)
502
+ cell_w = w // n
503
+ for i, (label, value) in enumerate(items):
504
+ cx = x + i * cell_w + cell_w // 2
505
+ if i > 0:
506
+ ImageDraw.Draw(base).line(
507
+ [(x + i * cell_w, y + 8), (x + i * cell_w, y + h - 8)],
508
+ fill=_rgba(theme["border_subtle"], 80), width=1,
509
+ )
510
+ bd2 = ImageDraw.Draw(base)
511
+ bd2.text((cx, y + 16), label.upper(),
512
+ font=_font(8), fill=_rgba(theme["text_dim"], 180), anchor="mm")
513
+ bd2.text((cx, y + 56), str(value),
514
+ font=_font(14), fill=_rgba(theme["accent_amber"], 220), anchor="mm")
515
+
516
+
517
+ # ---------------------------------------------------------------------------
518
+ # Footer
519
+ # ---------------------------------------------------------------------------
520
+
521
+ def _draw_footer(base: Image.Image, zone: tuple, theme: dict) -> None:
522
+ x, y, w, h = zone
523
+ bd = ImageDraw.Draw(base)
524
+ bd.rectangle([x, y, x + w, y + h], fill=_rgba(theme["panel"], 210))
525
+ _glow_line(base, [(x, y), (x + w, y)],
526
+ theme["accent_cyan"], width=1, glow_radius=4, glow_alpha=45)
527
+
528
+ bd2 = ImageDraw.Draw(base)
529
+ bd2.text((x + 24, y + 16), "KASPER",
530
+ font=_font(13), fill=_rgba(theme["accent_cyan"], 210))
531
+ bd2.text((x + w // 2, y + 18),
532
+ "Statistical estimates only. Alpha release. Not financial advice.",
533
+ font=_font(9), fill=_rgba(theme["text_dim"], 155), anchor="mm")
534
+ bd2.text((x + w - 22, y + 16), "kasper.ai",
535
+ font=_font(9), fill=_rgba(theme["text_dim"], 130), anchor="ra")
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # Final post-processing effects
540
+ # ---------------------------------------------------------------------------
541
+
542
+ def _apply_final_effects(base: Image.Image) -> Image.Image:
543
+ """Subtle vignette + very light grain pass."""
544
+ W, H = base.size
545
+
546
+ # Radial vignette overlay
547
+ vig = Image.new("RGBA", (W, H), (0, 0, 0, 0))
548
+ vd = ImageDraw.Draw(vig)
549
+ cx, cy = W // 2, H // 2
550
+ for i in range(28):
551
+ t = i / 28.0
552
+ alpha = int(100 * (1 - t) ** 2)
553
+ rx = int(cx * (1.0 + 0.25 * (1 + t)))
554
+ ry = int(cy * (1.0 + 0.25 * (1 + t)))
555
+ vd.ellipse(
556
+ [cx - rx, cy - ry, cx + rx, cy + ry],
557
+ outline=(0, 0, 0, alpha),
558
+ width=max(1, int(W * 0.035)),
559
+ )
560
+ vig = vig.filter(ImageFilter.GaussianBlur(radius=38))
561
+ base.alpha_composite(vig)
562
+
563
+ return base
564
+
565
+
566
+ # ---------------------------------------------------------------------------
567
+ # Export
568
+ # ---------------------------------------------------------------------------
569
+
570
+ def _export(img: Image.Image, fmt: str) -> bytes:
571
+ buf = io.BytesIO()
572
+ out = img.convert("RGB")
573
+ if fmt == "JPG":
574
+ out.save(buf, format="JPEG", quality=94)
575
+ else:
576
+ out.save(buf, format="PNG")
577
+ buf.seek(0)
578
+ return buf.getvalue()
579
+
580
+
581
+ # ---------------------------------------------------------------------------
582
+ # Public render entry points
583
+ # ---------------------------------------------------------------------------
584
+
585
+ def render_hitter_poster(
586
+ payload: dict,
587
+ player_img: Image.Image | None = None,
588
+ fmt: str = "PNG",
589
+ ) -> bytes:
590
+ """Render a full 1024×1536 Kasper Report hitter poster."""
591
+ from visualization.cards.card_charts import build_ev_la_chart
592
+
593
+ tmpl = TEMPLATES["kasper_report"]
594
+ theme = THEMES[tmpl["theme"]]
595
+ W, H = tmpl["canvas"]["w"], tmpl["canvas"]["h"]
596
+ zones = tmpl["zones"]
597
+
598
+ base = build_background(W, H, theme)
599
+
600
+ # Player image
601
+ z = zones["player_image"]
602
+ proc = process_player_image(player_img, (z[2], z[3]), theme, remove_bg=(player_img is not None))
603
+ _paste_player(base, proc, z, theme)
604
+
605
+ # Header
606
+ _draw_header(base, payload, zones["header"], theme)
607
+
608
+ # Metric boxes
609
+ summary = payload.get("summary", {})
610
+ metrics = payload.get("metrics", {})
611
+ baseline = payload.get("baseline", {})
612
+
613
+ metric_defs = [
614
+ ("EV90", _fmt_val(summary.get("ev90"), "float") + " mph",
615
+ "Exit Velocity 90th Pct", theme["accent_cyan"]),
616
+ ("BARREL%", _fmt_val(summary.get("barrel_rate"), "pct"),
617
+ "Barrel Rate", theme["accent_amber"]),
618
+ ("HR PROB", _fmt_val(baseline.get("hr_prob"), "pct"),
619
+ "HR Probability", theme["accent_red"]),
620
+ ]
621
+ for (label, value, sub, accent), zk in zip(
622
+ metric_defs, ["metric_1", "metric_2", "metric_3"]
623
+ ):
624
+ _draw_metric_box(base, zones[zk], label, value, sub, theme, accent=accent)
625
+
626
+ # Chart
627
+ chart = build_ev_la_chart(
628
+ payload.get("windowed_df"), payload.get("player_name", ""),
629
+ w_px=360, h_px=252,
630
+ )
631
+ _draw_chart_panel(base, chart, zones["chart"], theme)
632
+
633
+ # Stat row
634
+ _draw_stat_row(base, [
635
+ ("CONTACT+", f"{float(metrics.get('contact_plus', 0)):.0f}"),
636
+ ("HR SHAPE+", f"{float(metrics.get('hr_shape_plus', 0)):.0f}"),
637
+ ("DAMAGE+", f"{float(metrics.get('damage_zone_plus',0)):.0f}"),
638
+ ("HARD HIT%", _fmt_val(summary.get("hard_hit_rate"), "pct")),
639
+ ("xwOBA", _fmt_val(summary.get("xwoba"), "float")),
640
+ ], zones["stat_row"], theme)
641
+
642
+ # Readout
643
+ readout = payload.get("readout", [])
644
+ _draw_readout_panel(base, readout, zones["readout"], theme)
645
+
646
+ # Alert
647
+ alert_text = readout[0] if readout else "No significant patterns in selected window."
648
+ _draw_alert_box(base, alert_text, "Key Insight", zones["alert"], theme)
649
+
650
+ # Rolling strip
651
+ rolling = payload.get("rolling", {})
652
+ _draw_rolling_strip(base, [
653
+ ("5G EV90", _fmt_val(rolling.get("ev90_5g"), "float") + " mph"),
654
+ ("5G BBL%", _fmt_val(rolling.get("barrel_5g"), "pct")),
655
+ ("5G HR%", _fmt_val(rolling.get("hr_rate_5g"), "pct")),
656
+ ], zones["rolling"], theme)
657
+
658
+ _draw_footer(base, zones["footer"], theme)
659
+ base = _apply_final_effects(base)
660
+ return _export(base, fmt)
661
+
662
+
663
+ def render_pitcher_poster(
664
+ payload: dict,
665
+ player_img: Image.Image | None = None,
666
+ fmt: str = "PNG",
667
+ ) -> bytes:
668
+ """Render a full 1024×1536 Kasper Report pitcher poster."""
669
+ from visualization.cards.card_charts import build_pitcher_damage_grid
670
+
671
+ tmpl = TEMPLATES["kasper_report"]
672
+ theme = THEMES[tmpl["theme"]]
673
+ W, H = tmpl["canvas"]["w"], tmpl["canvas"]["h"]
674
+ zones = tmpl["zones"]
675
+
676
+ base = build_background(W, H, theme)
677
+
678
+ z = zones["player_image"]
679
+ proc = process_player_image(player_img, (z[2], z[3]), theme, remove_bg=(player_img is not None))
680
+ _paste_player(base, proc, z, theme)
681
+
682
+ _draw_header(base, payload, zones["header"], theme)
683
+
684
+ summary = payload.get("summary", {})
685
+ metrics = payload.get("metrics", {})
686
+
687
+ metric_defs = [
688
+ ("VELO", _fmt_val(summary.get("avg_release_speed"), "float") + " mph",
689
+ "Avg Release Speed", theme["accent_cyan"]),
690
+ ("STUFF+", f"{float(metrics.get('stuff_plus', 0)):.0f}",
691
+ "Stuff Grade", theme["accent_amber"]),
692
+ ("SWSTR%", _fmt_val(summary.get("swstr_rate"), "pct"),
693
+ "Swinging Strike Rate", theme["accent_red"]),
694
+ ]
695
+ for (label, value, sub, accent), zk in zip(
696
+ metric_defs, ["metric_1", "metric_2", "metric_3"]
697
+ ):
698
+ _draw_metric_box(base, zones[zk], label, value, sub, theme, accent=accent)
699
+
700
+ chart = build_pitcher_damage_grid(
701
+ payload.get("windowed_df"), payload.get("player_name", ""),
702
+ w_px=360, h_px=252,
703
+ )
704
+ _draw_chart_panel(base, chart, zones["chart"], theme)
705
+
706
+ _draw_stat_row(base, [
707
+ ("COMMAND+", f"{float(metrics.get('command_plus', 0)):.0f}"),
708
+ ("DAMAGE+", f"{float(metrics.get('damage_zone_plus',0)):.0f}"),
709
+ ("EV ALLOW", _fmt_val(summary.get("ev_allowed"), "float") + " mph"),
710
+ ("BBL ALLOW", _fmt_val(summary.get("barrel_rate_allowed"), "pct")),
711
+ ("SPIN", _fmt_val(summary.get("avg_release_spin_rate"), "int") + " rpm"),
712
+ ], zones["stat_row"], theme)
713
+
714
+ readout = payload.get("readout", [])
715
+ _draw_readout_panel(base, readout, zones["readout"], theme)
716
+ alert_text = readout[0] if readout else "No significant patterns in selected window."
717
+ _draw_alert_box(base, alert_text, "Key Insight", zones["alert"], theme)
718
+
719
+ rolling = payload.get("rolling", {})
720
+ _draw_rolling_strip(base, [
721
+ ("5G VELO", _fmt_val(rolling.get("velo_5g"), "float") + " mph"),
722
+ ("5G EV ALL", _fmt_val(rolling.get("ev_allowed_5g"), "float") + " mph"),
723
+ ("5G BBL ALL", _fmt_val(rolling.get("barrel_5g"), "pct")),
724
+ ], zones["rolling"], theme)
725
+
726
+ _draw_footer(base, zones["footer"], theme)
727
+ base = _apply_final_effects(base)
728
+ return _export(base, fmt)
visualization/cards/poster_templates.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ # ---------------------------------------------------------------------------
4
+ # Canvas
5
+ # ---------------------------------------------------------------------------
6
+ CANVAS_W = 1024
7
+ CANVAS_H = 1536
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Themes — all colours as (R, G, B) tuples, ready for PIL
11
+ # ---------------------------------------------------------------------------
12
+ THEMES: dict[str, dict] = {
13
+ "kasper_hud": {
14
+ # Backgrounds
15
+ "bg": (6, 8, 15),
16
+ "panel": (11, 15, 28),
17
+ "panel_alt": (15, 21, 40),
18
+ "panel_alert": (20, 6, 8),
19
+ "border_subtle": (30, 42, 69),
20
+ # Accent glow colours
21
+ "accent_cyan": (0, 212, 255),
22
+ "accent_amber": (245, 158, 11),
23
+ "accent_red": (255, 45, 85),
24
+ # Text
25
+ "text_primary": (232, 244, 253),
26
+ "text_secondary": (126, 168, 196),
27
+ "text_dim": (58, 90, 114),
28
+ # Effect tuning
29
+ "glow_intensity": 0.6,
30
+ "vignette": 0.40,
31
+ },
32
+ }
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Templates — zones defined as (x, y, w, h) pixel rectangles
36
+ # ---------------------------------------------------------------------------
37
+ TEMPLATES: dict[str, dict] = {
38
+ "kasper_report": {
39
+ "name": "Kasper Report",
40
+ "theme": "kasper_hud",
41
+ "canvas": {"w": CANVAS_W, "h": CANVAS_H},
42
+ "zones": {
43
+ # Full-width header strip
44
+ "header": (0, 0, 1024, 110),
45
+ # Large player image — left / mid-left
46
+ "player_image": (0, 110, 620, 830),
47
+ # Three HUD metric boxes — right column
48
+ "metric_1": (644, 128, 368, 108),
49
+ "metric_2": (644, 252, 368, 108),
50
+ "metric_3": (644, 376, 368, 108),
51
+ # Chart panel below metrics
52
+ "chart": (644, 500, 368, 260),
53
+ # Full-width stat row
54
+ "stat_row": (0, 948, 1024, 96),
55
+ # Readout — left / lower
56
+ "readout": (0, 1054, 612, 302),
57
+ # Alert box — right / lower
58
+ "alert": (632, 1054, 380, 180),
59
+ # Rolling strip — right / below alert
60
+ "rolling": (632, 1248, 380, 94),
61
+ # Footer strip
62
+ "footer": (0, 1454, 1024, 82),
63
+ },
64
+ },
65
+ }