Spaces:
Sleeping
Card Lab: Kasper Report poster renderer (1024×1536 HUD template)
Browse filesRemoves 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 +1 -53
- requirements.txt +1 -0
- visualization/card_lab_page.py +29 -16
- visualization/cards/poster_renderer.py +728 -0
- visualization/cards/poster_templates.py +65 -0
|
@@ -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 |
-
|
| 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":
|
|
@@ -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
|
|
@@ -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 |
-
|
| 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 =
|
| 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 =
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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"),
|
|
@@ -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)
|
|
@@ -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 |
+
}
|