Files
SREBOT/BOT/scoreboard.py
T
deploy f9cced2b39 fix: use squadron_short for win/loss comparison in scoreboard
squadron_raw from old saved replays still has raw tags (-DSPLA-) but
winning_team is now stripped (DSPLA) by _strip_tag in process_session.
squadron_short is set by per-team DB resolution so it matches for both
old and new replays.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:26:18 +00:00

1272 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
scoreboard.py
Generates scoreboard images for autologging match results.
Renders a styled PNG with blurred map backgrounds, player stats, vehicle icons,
team compositions, win/loss records, and squadron point diffs using PIL.
"""
# Standard Library Imports
import asyncio
import cProfile
import io
import logging
import os
import pstats
import re
from datetime import datetime, timezone
from functools import lru_cache
from pathlib import Path
# Third-Party Library Imports
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from PIL.Image import Resampling
# Local Module Imports
from . import SHARED_DIR
# Toggle for data_parser dependency
# Set to False to avoid importing data_parser and show all vehicles as "?" in team composition
USE_DATA_PARSER = True
if USE_DATA_PARSER:
from data_parser import count_unit_types, apply_vehicle_name_filters
else:
def count_unit_types(internal_name_list):
"""Fallback: return all vehicles as unknown type."""
player_count = len([v for v in internal_name_list if v != "MEOW"])
return {"?": player_count} if player_count > 0 else {}
def apply_vehicle_name_filters(name):
return name
BASE_DIR = Path(__file__).resolve().parent
MAPS_DIR = SHARED_DIR / "MAPS"
ICON_BASE_DIR = SHARED_DIR / "ICONS"
TEXT_FONT_PATH = SHARED_DIR / "FONTS" / "arial_unicode_ms.otf"
def _normalize_squad_key(value: str | None) -> str:
"""Casefold and trim identifiers so scoreboard + prefs align."""
if not value:
return ""
return re.sub(r"\s+", " ", value).strip().casefold()
FONTS = {}
def load_fonts(base_width):
"""
Load and cache all fonts for scoreboard rendering.
Re-uses cache if already loaded.
"""
global FONTS
if FONTS:
return FONTS
# main text fonts (measurable)
FONTS = {
"title": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.04)),
"team": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.03)),
"body": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.0175)),
"stat": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.022)),
"comp": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.018)),
"winloss": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.023)),
"info": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.016)),
"small": ImageFont.truetype(TEXT_FONT_PATH, int(base_width * 0.014)),
}
return FONTS
# ──────────────────────────────────────────────────────────────────────────────────────────────
# Icon Caching Functions
# ──────────────────────────────────────────────────────────────────────────────────────────────
@lru_cache(maxsize=200)
def _load_icon_cached(icon_path_str: str, size_tuple: tuple, resample_mode: int):
"""
Internal cached function. Loads icon from disk and resizes.
Uses only hashable types for lru_cache compatibility.
LRU cache with maxsize=200 automatically evicts least-used icons.
"""
icon_path = Path(icon_path_str)
# Load and convert to RGBA
img = Image.open(icon_path).convert("RGBA")
# Resize if size specified
if size_tuple:
img = img.resize(size_tuple, resample_mode)
return img
def load_cached_icon(icon_path, size=None, resample_filter=Image.Resampling.LANCZOS):
"""
Load and cache icon images for scoreboard rendering.
LRU cache with maxsize=200 automatically evicts least-used icons.
Args:
icon_path: Path object or string to icon file
size: Optional (width, height) tuple for resizing
resample_filter: Resampling filter for resize (default: LANCZOS)
Returns:
PIL Image object in RGBA mode
"""
# Convert to hashable types for lru_cache
path_str = str(icon_path)
size_tuple = tuple(size) if size else None
resample_int = int(resample_filter)
return _load_icon_cached(path_str, size_tuple, resample_int)
@lru_cache(maxsize=20)
def _load_map_background_cached(map_path_str: str, blur_radius: int):
"""
Internal cached function. Loads map background and applies blur.
LRU cache with maxsize=20 stores up to 20 blurred map backgrounds.
"""
map_path = Path(map_path_str)
# Load, convert, and blur
background = Image.open(map_path).convert("RGBA")
background = background.filter(ImageFilter.GaussianBlur(radius=blur_radius))
return background
def load_cached_map_background(map_path, blur_radius=2):
"""
Load and cache map background images with blur applied.
LRU cache with maxsize=20 prevents excessive memory usage.
Args:
map_path: Path to map background image
blur_radius: Gaussian blur radius to apply
Returns:
PIL Image object in RGBA mode with blur applied
"""
return _load_map_background_cached(str(map_path), blur_radius)
def get_pts_color(value: int) -> tuple[int,int,int,int]:
"""
Color gradient based on points:
1500 = green, 1600 = yellow, 1750 = orange,
1850 = red-orange, 1900+ = red.
Below 1500 = green.
"""
if value is None:
return (180, 180, 180, 255) # grey
# Green for anything below 1500
if value < 1500:
value = 1500
# Phase 1: 1500-1600 green → yellow
if value < 1600:
progress = (value - 1500) / 100.0
r = int(255 * progress) # 0 → 255
g = 255
b = 0
# Phase 2: 1600-1750 yellow → orange
elif value < 1750:
progress = (value - 1600) / 150.0
r = 255
g = int(255 - (115 * progress)) # 255 → 140
b = 0
# Phase 3: 1750-1850 orange → red-orange
elif value < 1850:
progress = (value - 1750) / 100.0
r = 255
g = int(140 - (80 * progress)) # 140 → 60
b = 0
# Phase 4: 1850+ red-orange → red
else:
if value > 1900:
value = 1900
progress = (value - 1850) / 50.0
r = 255
g = int(60 - (60 * progress)) # 60 → 0
b = 0
return (r, g, b, 255)
def get_gradient_color(win_rate):
"""
Calculate color gradient from red to yellow to lime green based on win percentage.
0% = Red (255, 0, 0), 50% = Yellow (255, 255, 0), 100% = Lime Green (0, 255, 0)
Transitions smoothly in 1% intervals
"""
win_rate = max(0, min(100, win_rate))
if win_rate <= 50:
red = 255
green = int(255 * (win_rate / 50))
blue = 0
else:
red = int(255 * (1 - (win_rate - 50) / 50))
green = 255
blue = 0
return (red, green, blue, 255)
def make_vignette(width, height, base_alpha=140, max_alpha=175, power=4):
"""Build a radial alpha mask for a vignette overlay.
Args:
width: Image width in pixels.
height: Image height in pixels.
base_alpha: Minimum alpha at the center (0-255).
max_alpha: Maximum alpha at the edges (0-255).
power: Exponent controlling vignette falloff curve.
Returns:
A PIL Image in "L" mode containing the alpha mask.
"""
base_alpha = max(0, min(255, base_alpha))
max_alpha = max(0, min(255, max_alpha))
if max_alpha < base_alpha:
max_alpha = base_alpha
y, x = np.ogrid[:height, :width]
cx, cy = width / 2.0, height / 2.0
dx = (x - cx) / cx
dy = (y - cy) / cy
d = np.sqrt(dx*dx + dy*dy)
d = np.clip(d, 0, 1)
alpha = base_alpha + (max_alpha - base_alpha) * (d ** power)
alpha = np.clip(alpha, 0, 255).astype(np.uint8)
return Image.fromarray(alpha, mode="L")
# ──────────────────────────────────────────────────────────────────────────────────────────────
# 1) Synchronous helper that does all the heavy PIL/math work and saves to disk.
# (Run this on a worker thread via asyncio.to_thread.)
# ──────────────────────────────────────────────────────────────────────────────────────────────
def _create_scoreboard_sync(match_details,
winning_team,
team1_details,
team2_details,
map_file,
output_path,
bar_color="",
diffs=None, WL=None, is_draw=False):
"""CPU-bound routine that renders the full scoreboard image and saves it.
Loads the map background, builds a vignette gradient, draws all text/icons
for both teams, resizes/compresses, and writes the final PNG.
Args:
match_details: Dict with match metadata (utc_timestamp, session_id).
winning_team: Squadron short name of the winner.
team1_details: Dict with "squadron" and "players" list for team 1.
team2_details: Dict with "squadron" and "players" list for team 2.
map_file: Map display name (e.g. "Abandoned Factory").
output_path: Filesystem path to write the output PNG.
bar_color: Color hint for the header bar ("win", "loss", or "").
diffs: Squadron point diffs dict, keyed by squadron name.
WL: Win/loss record dict, keyed by squadron name.
is_draw: Whether the match ended in a draw.
"""
# ── A) Figure out paths & background
map_file_clean = re.sub(r"^\s*\[[^]]+\]\s*", "", map_file)
map_name = map_file_clean
map_file_clean = map_file_clean.replace(" ", "_")
target = f"{map_file_clean}.jpg"
# look for any file in MAPS whose name matches target, ignoring case
try:
candidates = {f.name for f in MAPS_DIR.iterdir() if f.is_file()}
except FileNotFoundError:
map_image_path = str(MAPS_DIR / target)
else:
match = next((fn for fn in candidates if fn.lower() == target.lower()), None)
if match:
map_image_path = str(MAPS_DIR / match)
else:
map_image_path = str(MAPS_DIR / target)
# Load base background (with caching)
blur_power = 2
try:
background = load_cached_map_background(map_image_path, blur_radius=blur_power)
except Exception as e:
logging.error(f"[Scoreboard] Failed to open map image {map_image_path}: {e}")
raise
bg_width, bg_height = background.size
margin = 0
# ── B) Build vignette overlay (NumPy vectorized)
alpha_band = make_vignette(bg_width, bg_height, base_alpha=140, max_alpha=175, power=4)
# Build a black overlay with that alpha gradient
overlay = Image.new("RGBA", (bg_width, bg_height), (0, 0, 0, 0))
black_rgb = Image.new("RGBA", (bg_width, bg_height), (0, 0, 0, 255))
overlay = Image.composite(black_rgb, overlay, alpha_band)
draw = ImageDraw.Draw(overlay)
BODY_FONT_SIZE = int(bg_width * 0.0175)
STAT_FONT_SIZE = int(bg_width * 0.022)
# ── C) Load fonts
fonts = load_fonts(bg_width)
font_title = fonts["title"]
font_team = fonts["team"]
font_body = fonts["body"]
stat_font = fonts["stat"]
comp_font = fonts["comp"]
winloss_font = fonts["winloss"]
info_font = fonts["info"]
small_font = fonts["small"]
resample_filter = Image.Resampling.LANCZOS
normalized_diffs = {}
if isinstance(diffs, dict) and diffs:
for key, value in diffs.items():
norm_key = _normalize_squad_key(key)
if norm_key and norm_key not in normalized_diffs:
normalized_diffs[norm_key] = (key, value)
# ── D) Draw match_details (timestamp + session ID) in top-right
padding = 15
ts_epoch = int(match_details["utc_timestamp"])
dt_utc = datetime.fromtimestamp(ts_epoch, tz=timezone.utc)
ts_text = dt_utc.strftime("%H:%M:%S - %Y-%m-%d UTC")
sid_text = f"{match_details['session_id']}"
ts_bbox = draw.textbbox((0,0), ts_text, font=info_font)
x_ts = bg_width - margin - (ts_bbox[2] - ts_bbox[0]) - padding - 10
y_ts = margin + padding + 10
draw.text((x_ts, y_ts), ts_text, font=info_font, fill=(200,200,200,255))
sid_bbox = draw.textbbox((0,0), sid_text, font=info_font)
x_id = bg_width - margin - (sid_bbox[2] - sid_bbox[0]) - padding - 10
y_id = y_ts + (ts_bbox[3] - ts_bbox[1]) + 20
draw.text((x_id, y_id), sid_text, font=info_font, fill=(200,200,200,255))
received_raw = match_details.get("received_unix")
if received_raw is not None:
try:
delay = int(received_raw) - ts_epoch
except (TypeError, ValueError):
delay = None
if delay is not None and delay >= 60:
d_hr, rem = divmod(delay, 3600)
d_min, d_sec = divmod(rem, 60)
delay_text = f"TTL: {d_hr:02d}:{d_min:02d}:{d_sec:02d}"
delay_color = (255, 60, 60, 255) if delay > 300 else (200, 200, 200, 255)
delay_bbox = draw.textbbox((0, 0), delay_text, font=info_font)
x_delay = bg_width - margin - (delay_bbox[2] - delay_bbox[0]) - padding - 10
y_delay = y_id + (sid_bbox[3] - sid_bbox[1]) + 20
draw.text((x_delay, y_delay), delay_text, font=info_font, fill=delay_color)
# ── E) Draw top titles (map name + winner)
title_text = map_name
win_text = "DRAW" if is_draw else f"Winner - {winning_team}"
y = 50
# Centered map-name
title_bbox = draw.textbbox((0,0), title_text, font=font_title)
title_width = title_bbox[2] - title_bbox[0]
x_center = (bg_width - title_width) // 2
draw.text((x_center, y), title_text, font=font_title, fill=(255,255,255,255))
title_height = title_bbox[3] - title_bbox[1]
y += title_height + 40
win_loss_data = WL or {}
# Squadrons for each team
team1_squadron = team1_details.get("squadron", "Unknown")
team2_squadron = team2_details.get("squadron", "Unknown")
# Pull stats directly from the WL dict instead of hitting storage
stats1 = win_loss_data.get(team1_squadron, {"wins": 0, "losses": 0})
stats2 = win_loss_data.get(team2_squadron, {"wins": 0, "losses": 0})
# Unpack for drawing
team1_wins, team1_losses = stats1["wins"], stats1["losses"]
team2_wins, team2_losses = stats2["wins"], stats2["losses"]
# Store winloss data as raw values: (win_str, loss_str, percent_str, fill_color)
team1_total = team1_wins + team1_losses
if team1_total > 0:
team1_win_rate = (team1_wins / team1_total) * 100
team1_winloss_data = (str(team1_wins), str(team1_losses), f"{team1_win_rate:.0f}%", get_gradient_color(team1_win_rate))
else:
team1_winloss_data = None
team2_total = team2_wins + team2_losses
if team2_total > 0:
team2_win_rate = (team2_wins / team2_total) * 100
team2_winloss_data = (str(team2_wins), str(team2_losses), f"{team2_win_rate:.0f}%", get_gradient_color(team2_win_rate))
else:
team2_winloss_data = None
# Centered winner text (not drawn — winner/draw is conveyed by team name colors)
win_bbox = draw.textbbox((0,0), win_text, font=font_title)
win_height = win_bbox[3] - win_bbox[1]
y_start = y + win_height + 60 # Back to normal spacing since winloss moved to icon level
# ── F) Compute layout for two team columns
x_start = margin + 45
gap_between = 0
col_width = (bg_width - (x_start * 2) - gap_between) // 2
def draw_team(idx, team_data, start_x, start_y, section_width, buffer):
"""Draw one team's block onto the scoreboard canvas.
Renders squadron header, player rows (nick, vehicle icon, stats),
team composition, and W/L record. Layout is mirrored for team 2.
Args:
idx: Team index (1 or 2). Team 2 is drawn right-aligned.
team_data: Dict with "squadron", "players", and optional metadata.
start_x: Left x-coordinate of the team column.
start_y: Top y-coordinate to begin drawing.
section_width: Available pixel width for this team's column.
buffer: Shared list collecting the running y-position for layout.
"""
flipped = (idx == 2)
Username_fill = (250, 227, 200, 255)
Living_vehicle_fill = (255, 255, 255, 255)
Dead_vehicle_fill = (200, 200, 200, 255)
Positive_Points_fill = (60, 255, 60, 255)
Negative_Points_fill = (255, 60, 60, 255)
Unknown_Points_fill = (200, 200, 200, 255)
# --- 1) Squadron header & points ---
# squadron_raw: used for win/loss comparison against winning_team (raw replay value)
# squadron_short: resolved clean name used for display only
squadron_raw = team_data.get("squadron", "Unknown")
squadron_short = team_data.get("squadron_short") or squadron_raw
squadron_long = team_data.get("squadron_long", "Unknown")
matched_diff_source = None
squad_diffs = None
if normalized_diffs:
for candidate in (squadron_long, squadron_short):
norm_candidate = _normalize_squad_key(candidate)
match = normalized_diffs.get(norm_candidate)
if match:
matched_diff_source, squad_diffs = match
break
diff_keys = list(diffs.keys()) if isinstance(diffs, dict) else None
points_diff = {}
current_points = {}
sq_string = ""
sq_points_fill = (200, 200, 200, 255) # neutral grey
if squad_diffs:
points_diff = squad_diffs.get("points_diff", {})
current_points = squad_diffs.get("current_points", {})
diff_total = squad_diffs.get("diff_total", 0)
sq_points = int(diff_total)
if sq_points > 0:
sq_string, sq_points_fill = f"+{sq_points}", Positive_Points_fill
elif sq_points < 0:
sq_string, sq_points_fill = str(sq_points), Negative_Points_fill
else:
sq_string, sq_points_fill = "0", Unknown_Points_fill
squad_bbox = draw.textbbox((0,0), squadron_short, font=font_team)
squad_width = squad_bbox[2] - squad_bbox[0]
squad_height = squad_bbox[3] - squad_bbox[1]
header_y = start_y - 10
text = squadron_short
# Yellow color for draws (255, 255, 0, 255)
Draw_fill = (255, 255, 0, 255)
if is_draw:
fill = Draw_fill
else:
fill = Positive_Points_fill if squadron_short == winning_team else Negative_Points_fill
gap = 30
if not flipped:
name_x = start_x
pts_x = start_x
if diffs:
pts_x = name_x + squad_width + gap
else:
name_x = start_x + section_width - squad_width
pts_x = start_x
if diffs:
sq_bbox = draw.textbbox((0,0), sq_string, font=font_team)
sq_width = sq_bbox[2] - sq_bbox[0]
pts_x = name_x - gap - sq_width
draw.text((name_x, header_y), text, font=font_team, fill=fill)
if diffs:
draw.text((pts_x, header_y), sq_string, font=font_team, fill=sq_points_fill)
# --- 2) Comp notation below header ---
notation_list = count_unit_types([p["vehicle"] for p in team_data.get("players", [])])
comp_order = [
("F", "Fighters"),
("B", "Bombers"),
("H", "Helicopters"),
("L", "Light"),
("T", "Tanks"),
("AA", "AA"),
("?", "?")
]
comp_y = header_y + squad_height + 40
if not flipped:
# Build left→right
comp_x = start_x + 5
first = True
for code, _ in comp_order:
cnt = notation_list.get(code, 0)
if cnt > 0:
txt = f"{cnt}{code}"
if not first:
sep = "/ "
sep_w = draw.textbbox((0, 0), sep, font=comp_font)[2]
draw.text((comp_x, comp_y), sep, font=comp_font, fill=(255, 255, 255, 255))
comp_x += sep_w + 5
draw.text((comp_x, comp_y), txt, font=comp_font, fill=(255, 255, 255, 255))
txt_w = draw.textbbox((0, 0), txt, font=comp_font)[2]
comp_x += txt_w + 15
first = False
else:
# Build a single string, then rightalign
codes_drawn = []
for code, _ in comp_order:
cnt = notation_list.get(code, 0)
if cnt > 0:
codes_drawn.append(f"{cnt}{code}")
if codes_drawn:
full_comp_str = " / ".join(codes_drawn)
full_w = draw.textbbox((0, 0), full_comp_str, font=comp_font)[2]
comp_x = start_x + section_width - 5 - full_w
draw.text((comp_x, comp_y), full_comp_str, font=comp_font, fill=(255, 255, 255, 255))
# else: nothing to draw
# --- 3) Column headers with icons ---
if not flipped:
columns = ["", "Air", "Ground", "Assists", "Deaths", "Caps"]
else:
columns = ["", "Caps", "Deaths", "Assists", "Ground", "Air"]
num_stat_cols = len(columns) - 1
stat_area_width= int(section_width * 0.32)
stat_start = start_x + section_width - stat_area_width + buffer
col_positions = [start_x] + [
stat_start + int(i * stat_area_width / num_stat_cols)
for i in range(num_stat_cols)
]
if flipped:
col_positions = [
start_x + (section_width - (x - start_x)) for x in col_positions
]
ICON_SIZE = int(STAT_FONT_SIZE * 1.1)
base_icon_map = {
"Air": ICON_BASE_DIR / "fighter_icon.png",
"Ground": ICON_BASE_DIR / "tank_icon.png",
"Assists": ICON_BASE_DIR / "assists_icon.png",
"Deaths": ICON_BASE_DIR / "deaths_icon.png",
"Caps": ICON_BASE_DIR / "cap_icon.png"
}
if not flipped:
cols_to_draw = columns
pos_to_draw = col_positions
else:
cols_to_draw = list(reversed(columns))
pos_to_draw = list(reversed(col_positions))
for i, name in enumerate(cols_to_draw):
icon_file = base_icon_map.get(name)
if not icon_file:
continue
header_x = pos_to_draw[i]
try:
icon_img = load_cached_icon(icon_file, (ICON_SIZE, ICON_SIZE), resample_filter)
header_icon_y = header_y + 90
overlay.paste(icon_img, (header_x - 15, header_icon_y), icon_img)
except Exception:
pass
row_height = ICON_SIZE + 30
y_offset = header_y + row_height + 60
# --- 4) Player rows
players_sorted = sorted(
team_data.get("players", []),
key=lambda p: int(p.get("score", 0)), reverse=True
)
for player in players_sorted:
uid = str(player.get("uid"))
pts_str = ""
pts_fill = (200, 200, 200, 255) # neutral
pts = None
c_pts = None
if squad_diffs: # only if this team was actually tracked
pts = points_diff.get(uid)
c_pts = current_points.get(uid)
if pts is None:
pts_str = "???" # tracked, but this player missing in snapshot
pts_fill = Unknown_Points_fill
elif pts > 0:
pts_str = f"+{pts}"
pts_fill = Positive_Points_fill
elif pts < 0:
pts_str = str(pts)
pts_fill = Negative_Points_fill
else:
pts_str = "0"
pts_fill = Unknown_Points_fill
else:
# not tracked at all by this guild → leave blank
pts_str = ""
# --- Prepare icon image ---
if player.get("vehicle", "") == "DISCONNECTED":
ICON_PATH = ICON_BASE_DIR / "disconnected.png"
else:
ICON_PATH = ICON_BASE_DIR / "VEHICLES" / f"{player.get('vehicle','').lower()}.png"
icon_display_size = int(BODY_FONT_SIZE * 3.0)
size_tuple = (icon_display_size, icon_display_size)
try:
vicon = load_cached_icon(ICON_PATH, size_tuple, resample_filter)
except Exception:
# If vehicle icon fails to load (invalid vehicle), use not_found icon as fallback
try:
vicon = load_cached_icon(
ICON_BASE_DIR / "not_found.png",
size_tuple,
resample_filter
)
except Exception:
vicon = None
# --- Name & (c_pts) formatting (no inline font mixing) ---
# Prefer fake_nick if present, otherwise fall back to nick
name_raw = (player.get("fake_nick") or player.get("nick") or "").strip()
# Strip platform suffixes
name_raw = name_raw.replace("@live", "").replace("@psn", "")
vehicle_new = player.get("vehicle_new", "DISCONNECTED")
# Handle None, empty string, or missing value
if not vehicle_new:
vehicle_new = "DISCONNECTED"
else:
vehicle_new = apply_vehicle_name_filters(vehicle_new)
show_pts = bool(diffs) # only show when diffs data is present
# compute name/vehicle metrics using only the base name (no c_pts)
name_bbox = draw.textbbox((0,0), name_raw, font=font_body)
name_w = name_bbox[2] - name_bbox[0]
name_h = name_bbox[3] - name_bbox[1]
vehicle_bbox = draw.textbbox((0,0), vehicle_new, font=font_body)
vehicle_w = vehicle_bbox[2] - vehicle_bbox[0]
vehicle_h = vehicle_bbox[3] - vehicle_bbox[1]
identity_w = max(name_w, vehicle_w)
identity_h = name_h + 5 + vehicle_h
row_height = max(identity_h, int(BODY_FONT_SIZE * 2.70))
text_y = y_offset + (row_height - identity_h) // 2
icon_y = y_offset + (row_height - int(BODY_FONT_SIZE * 2.35)) // 2
player_name_y_offset = 12
vehicle_name_y_offset = 4
# === Draw icon + name + vehicle ===
if not flipped:
icon_x = start_x
if vicon:
overlay.paste(vicon, (icon_x, icon_y + 3), vicon)
text_x = icon_x + icon_display_size + 15
# draw name
draw.text((text_x, text_y-player_name_y_offset), name_raw, font=font_body, fill=Username_fill)
if show_pts and c_pts is not None:
pts_text = f"({c_pts})"
pts_bbox = draw.textbbox((0,0), pts_text, font=small_font)
pts_w = pts_bbox[2] - pts_bbox[0]
pts_h = pts_bbox[3] - pts_bbox[1]
pts_x = text_x + name_w + 8
pts_y = (text_y + (name_h - pts_h) // 2) - player_name_y_offset
draw.text((pts_x, pts_y), pts_text, font=small_font, fill=get_pts_color(c_pts))
# vehicle (left side)
vehicle_y = (text_y + name_h + 10) - vehicle_name_y_offset
player_dead = (int(player.get("deaths", 0)) > 0)
draw.text(
(text_x, vehicle_y),
vehicle_new,
font=font_body,
fill=Dead_vehicle_fill if player_dead or vehicle_new == "DISCONNECTED"
else Living_vehicle_fill
)
else:
# flipped: icon on the right, name right-aligned against it
icon_x = start_x + section_width - icon_display_size - 5
if vicon:
overlay.paste(vicon, (icon_x, icon_y + 3), vicon)
text_x = icon_x - name_w - 15
# draw name
draw.text((text_x, text_y-player_name_y_offset), name_raw, font=font_body, fill=Username_fill)
# draw (c_pts) in smaller font to the LEFT of the name
if show_pts and c_pts is not None:
pts_text = f"({c_pts})"
pts_bbox = draw.textbbox((0,0), pts_text, font=small_font)
pts_w = pts_bbox[2] - pts_bbox[0]
pts_h = pts_bbox[3] - pts_bbox[1]
pts_x = text_x - 8 - pts_w
pts_y = (text_y + (name_h - pts_h) // 2) - player_name_y_offset
draw.text((pts_x, pts_y), pts_text, font=small_font, fill=get_pts_color(c_pts))
# vehicle (right side)
vehicle_y = (text_y + name_h + 10) - vehicle_name_y_offset
vehicle_x = icon_x - vehicle_w - 15
player_dead = (int(player.get("deaths", 0)) > 0)
draw.text(
(vehicle_x, vehicle_y),
vehicle_new,
font=font_body,
fill=Dead_vehicle_fill if player_dead or vehicle_new == "DISCONNECTED"
else Living_vehicle_fill
)
if diffs:
# 1) measure your text
stat_bbox = draw.textbbox((0, 0), pts_str, font=stat_font)
stat_w = stat_bbox[2] - stat_bbox[0]
stat_h = stat_bbox[3] - stat_bbox[1]
pts_string_offset = 0 # positive is down
# 2) vertical centering
pts_y = (y_offset + (row_height - stat_h) // 2) + pts_string_offset
# 3) compute your rightedge anchor
if not flipped:
anchor_x = col_positions[1] - 35
pts_x_draw = anchor_x
# 4b) draw
draw.text((pts_x_draw, pts_y), pts_str, font=stat_font, fill=pts_fill, anchor="ra")
else:
# flipped side stays as before
pts_x_draw = col_positions[1] + 65
# 4b) draw
draw.text((pts_x_draw, pts_y), pts_str, font=stat_font, fill=pts_fill)
# Draw the five stat columns
stats = [
player.get("air_kills", 0),
player.get("ground_kills", 0),
player.get("assists", 0),
player.get("deaths", 0),
player.get("captures", 0)
]
base_labels = ["Air", "Ground", "Assists", "Deaths", "Caps"]
if not flipped:
labels = base_labels
positions = col_positions[1:]
else:
labels = base_labels
positions = list(reversed(col_positions[1:]))
for val, x_pos, label in zip(stats, positions, labels):
try:
num = int(val)
except Exception:
num = 0
if label in ("Air","Ground") and num > 0:
fill_color = (60, 255, 60, 255)
elif label == "Deaths" and num > 0:
fill_color = (255, 20, 20, 255)
elif label == "Caps" and num > 0:
fill_color = (255, 255, 0, 255)
elif label == "Assists" and num > 0:
fill_color = (230, 150, 90, 255)
else:
fill_color = (255, 255, 255, 255)
num_str = str(num)
num_bbox= draw.textbbox((0,0), num_str, font=stat_font)
num_h = num_bbox[3] - num_bbox[1]
stat_y = y_offset + (row_height - num_h) // 2
draw.text((x_pos, stat_y), num_str, font=stat_font, fill=fill_color)
y_offset += row_height + 15
# ── G) Draw separator line and both teams side-by-side
dx = 0
dy_top = -50
dy_bottom = 50
if bar_color == "win":
bar_color_fill = (60, 255, 60, 255)
elif bar_color == "loss":
bar_color_fill = (255, 60, 60, 255)
elif bar_color == "draw":
bar_color_fill = (255, 255, 0, 255)
else:
bar_color_fill = (255, 255, 255, 255)
sep_x = x_start + col_width + gap_between // 2 + dx
sep_y1 = y_start + dy_top
sep_y2 = bg_height - margin - dy_bottom
draw.line([(sep_x, sep_y1), (sep_x, sep_y2)],
fill=bar_color_fill,
width=5)
draw_team(
idx=1,
team_data=team1_details,
start_x=x_start + 10,
start_y=y_start - 130,
section_width=col_width,
buffer=-10
)
draw_team(
idx=2,
team_data=team2_details,
start_x=x_start + col_width + gap_between,
start_y=y_start - 130,
section_width=col_width,
buffer=33
)
# Draw winloss data in center area between title and icons
icon_level_y = y_start - 130 - 5 # Slightly down from previous position
center_x = bg_width // 2
def _draw_winloss(draw, x_start, y, win_num, loss_num, percent_part, pct_fill):
"""Draw colored W-L-% text starting at x_start. Returns nothing."""
cx = x_start
# Win number + W (green)
draw.text((cx, y), win_num, font=winloss_font, fill=(0, 255, 0, 255))
cx += draw.textbbox((0,0), win_num, font=winloss_font)[2]
draw.text((cx, y), "W", font=winloss_font, fill=(0, 255, 0, 255))
cx += draw.textbbox((0,0), "W", font=winloss_font)[2]
# Dash (grey)
draw.text((cx, y), " - ", font=winloss_font, fill=(200, 200, 200, 255))
cx += draw.textbbox((0,0), " - ", font=winloss_font)[2]
# Loss number + L (red)
draw.text((cx, y), loss_num, font=winloss_font, fill=(255, 60, 60, 255))
cx += draw.textbbox((0,0), loss_num, font=winloss_font)[2]
draw.text((cx, y), "L", font=winloss_font, fill=(255, 60, 60, 255))
cx += draw.textbbox((0,0), "L", font=winloss_font)[2]
# Dash (grey)
draw.text((cx, y), " - ", font=winloss_font, fill=(200, 200, 200, 255))
cx += draw.textbbox((0,0), " - ", font=winloss_font)[2]
# Percentage (color depends on win rate)
draw.text((cx, y), percent_part, font=winloss_font, fill=pct_fill)
if team1_winloss_data:
win_num, loss_num, percent_part, wl_fill = team1_winloss_data
total_text = f"{win_num}W - {loss_num}L - {percent_part}"
total_bbox = draw.textbbox((0,0), total_text, font=winloss_font)
total_width = total_bbox[2] - total_bbox[0]
_draw_winloss(draw, center_x - total_width - 30, icon_level_y, win_num, loss_num, percent_part, wl_fill)
if team2_winloss_data:
win_num, loss_num, percent_part, wl_fill = team2_winloss_data
_draw_winloss(draw, center_x + 30, icon_level_y, win_num, loss_num, percent_part, wl_fill)
# ── H) Composite overlay onto background, downsample, and save
final_img = Image.alpha_composite(background, overlay)
# Lower is more compression, think of it like what percentage of the W / H to keep
compression_level: float = 0.42
w, h = final_img.size
new_size = (int(w * compression_level), int(h * compression_level))
resized = final_img.resize(new_size, resample=Resampling.LANCZOS)
# Convert to RGB to remove unused alpha channel (~25% size reduction)
resized = resized.convert("RGB")
try:
resized.save(output_path, format="PNG", compress_level=1, optimize=False)
except Exception as e:
logging.error(f"[Scoreboard] ✗ Failed to save to {output_path}: {e}")
raise
# ──────────────────────────────────────────────────────────────────────────────────────────────
# 2) Async wrapper that simply offloads the above helper to a thread
# ──────────────────────────────────────────────────────────────────────────────────────────────
async def create_scoreboard(match_details,
winning_team,
team1_details,
team2_details,
map_file,
output_path,
bar_color="",
diffs=None,
WL=None,
is_draw=False):
"""Async entry point that offloads scoreboard rendering to a worker thread.
Args:
match_details: Dict with match metadata (utc_timestamp, session_id).
winning_team: Squadron short name of the winner.
team1_details: Dict with "squadron" and "players" list for team 1.
team2_details: Dict with "squadron" and "players" list for team 2.
map_file: Map display name (e.g. "Abandoned Factory").
output_path: Filesystem path to write the output PNG.
bar_color: Color hint for the header bar ("win", "loss", or "").
diffs: Squadron point diffs dict, keyed by squadron name.
WL: Win/loss record dict, keyed by squadron name.
is_draw: Whether the match ended in a draw.
Raises:
Exception: Re-raised from _create_scoreboard_sync on render failure.
"""
# Ensure the parent folder is present
base_dir = os.path.dirname(output_path)
os.makedirs(base_dir, exist_ok=True)
try:
await asyncio.to_thread(_create_scoreboard_sync,
match_details,
winning_team,
team1_details,
team2_details,
map_file,
output_path,
bar_color,
diffs,
WL,
is_draw
)
except Exception as e:
logging.error(f"[Scoreboard] create_scoreboard_sync failed: {e}")
raise
# Example usage:
async def test():
"""Generate a test scoreboard image with hardcoded sample match data."""
team1 = {
"squadron":
"AVR",
"players": [{
"uid": 140943953,
"nick": "\u0410VR",
"index": 5,
"vehicle": "ussr_pantsyr_s1",
"vehicle_new": "Meow",
"air_kills": 1,
"ground_kills": 0,
"assists": 0,
"deaths": 0,
"captures": 0,
"score": 320
}, {
"uid": 145639262,
"nick": "bullpuppy\u30c5",
"index": 6,
"vehicle": "DISCONNECTED",
"vehicle_new": "DISCONNECTED",
"air_kills": 0,
"ground_kills": 1,
"assists": 1,
"deaths": 1,
"captures": 0,
"score": 184
}, {
"uid": 154923412,
"nick": "\u041e\u0448\u0438\u0431\u043a\u0430\u30c5",
"index": 7,
"vehicle": "germ_leopard_2a7v",
"vehicle_new": "Leopard 2A7V",
"air_kills": 0,
"ground_kills": 2,
"assists": 2,
"deaths": 1,
"captures": 0,
"score": 600
}, {
"uid": 17424877,
"nick": "\u0396\u039b\u039f",
"index": 8,
"vehicle": "ef_2000_block_10",
"vehicle_new": "EF-2000",
"air_kills": 0,
"ground_kills": 0,
"assists": 0,
"deaths": 0,
"captures": 0,
"score": 0
}, {
"uid": 25721576,
"nick": "1N1ck",
"index": 9,
"vehicle": "rafale_c_f3",
"vehicle_new": "Rafale C F3",
"air_kills": 1,
"ground_kills": 0,
"assists": 0,
"deaths": 1,
"captures": 0,
"score": 355
}, {
"uid": 63482105,
"nick": "link_54",
"index": 11,
"vehicle": "rafale_c_f3",
"vehicle_new": "Rafale C F3",
"air_kills": 1,
"ground_kills": 0,
"assists": 0,
"deaths": 0,
"captures": 0,
"score": 284
}, {
"uid": 66489055,
"nick": "BlackSkr1pt",
"index": 12,
"vehicle": "us_m1a2_abrams_v2",
"vehicle_new": "M1A2 Sep",
"air_kills": 0,
"ground_kills": 1,
"assists": 0,
"deaths": 0,
"captures": 1,
"score": 620
}, {
"uid": 66490901,
"nick":
"\u0412\u0435\u0440\u0442\u043e\u0448\u043b\u044e\u0445\u0430",
"index": 13,
"vehicle": "spitfire_lf_mk9e_weisman",
"vehicle_new": "Weizman's Spitfire LF Mk.IXe",
"air_kills": 0,
"ground_kills": 0,
"assists": 1,
"deaths": 1,
"captures": 0,
"score": 269
}]
}
team2 = {
"squadron":
"VCoM",
"players": [{
"uid": 116628911,
"nick": "FrostChicken",
"index": 0,
"vehicle": "sw_leopard_2a6nl",
"vehicle_new": "Leopard 2A6NL",
"air_kills": 0,
"ground_kills": 0,
"assists": 0,
"deaths": 1,
"captures": 0,
"score": 80
}, {
"uid": 124076506,
"nick": "AstroZeus6571@live",
"index": 1,
"vehicle": "saab_jas39c",
"vehicle_new": "JAS39C",
"air_kills": 1,
"ground_kills": 0,
"assists": 2,
"deaths": 1,
"captures": 0,
"score": 336
}, {
"uid": 125095780,
"nick": "HitNRunTitan@live",
"index": 2,
"vehicle": "f_15e",
"vehicle_new": "F-15E",
"air_kills": 0,
"ground_kills": 0,
"assists": 0,
"deaths": 1,
"captures": 0,
"score": 59
}, {
"uid": 134108969,
"nick": "MoneylessMonkey",
"index": 3,
"vehicle": "us_m1a1_hc_abrams",
"vehicle_new": "M1A1 HC",
"air_kills": 0,
"ground_kills": 0,
"assists": 0,
"deaths": 1,
"captures": 0,
"score": 80
}, {
"uid": 136002299,
"nick": "DaddyDabbin3112@live",
"index": 4,
"vehicle": "f_15e",
"vehicle_new": "F-15E",
"air_kills": 0,
"ground_kills": 0,
"assists": 0,
"deaths": 1,
"captures": 0,
"score": 60
}, {
"uid": 32616799,
"nick": "ViRuSSoNy",
"index": 10,
"vehicle": "it_otomatic",
"vehicle_new": "Otomatic",
"air_kills": 0,
"ground_kills": 0,
"assists": 0,
"deaths": 1,
"captures": 0,
"score": 69
}, {
"uid": 7429595,
"nick": "farfadet12",
"index": 14,
"vehicle": "ef_2000_fgr4",
"vehicle_new": "FG-2000",
"air_kills": 0,
"ground_kills": 0,
"assists": 0,
"deaths": 1,
"captures": 0,
"score": 0
}, {
"uid": 91889275,
"nick": "noel_nootje@psn",
"index": 15,
"vehicle": "il_merkava_mk_4m",
"vehicle_new": "Tan-SAM Kai (TEL)",
"air_kills": 0,
"ground_kills": 1,
"assists": 0,
"deaths": 1,
"captures": 0,
"score": 366
}]
}
BASE_DIR = Path(__file__).resolve().parent
output_dir = BASE_DIR / "TEST_IN_ME"
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "output.png")
# Call the async create_scoreboard, which offloads work correctly
await create_scoreboard(
match_details={"utc_timestamp": "1746424038", "session_id": "4acb2a60017e0f6", "received_unix": 1746424428},
winning_team="AVR",
team1_details=team1,
team2_details=team2,
map_file="Abandoned FaCtory",
output_path=output_path,
bar_color="win",
diffs=[],
WL={},
is_draw=False
)
img_dim = Image.open(output_path).size
file_size_bytes = os.path.getsize(output_path)
size = file_size_bytes / 1024
unit = "KB"
if size > 1024:
size /= 1024
unit = "MB"
img_size = f"{size:.2f} {unit}"
logging.info(f"Test complete. Check the generated image at {output_path} : Image Dimensions: {img_dim} : Image Size: {img_size}")
def profile_async(coro):
"""Run an async coroutine under cProfile and print the top 20 slowest calls.
Args:
coro: An awaitable coroutine object to profile (passed to asyncio.run).
"""
pr = cProfile.Profile()
pr.enable()
try:
asyncio.run(coro)
finally:
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats("cumulative")
ps.print_stats(20) # show top 20 slowest
print(f"\n[Profiler results]\n{s.getvalue()}")
if __name__ == "__main__":
profile_async(test()) # run the test function with profiling
#asyncio.run(test()) # run the test function normally without profiling