Finalize Win Probability screen with Blue/Red VS donuts and Experience gauge. UI Polish complete.
This commit is contained in:
parent
4c384621aa
commit
2ec2f821e4
7 changed files with 470 additions and 224 deletions
|
|
@ -1772,7 +1772,8 @@
|
|||
"0e7eb1b9-03c7-41a6-b211-3ada1d85935c"
|
||||
],
|
||||
"names": [
|
||||
"Spaghetti"
|
||||
"Spaghetti",
|
||||
"Spaghetti CLNM"
|
||||
],
|
||||
"team_history": [
|
||||
"ZTi"
|
||||
|
|
@ -1820,7 +1821,9 @@
|
|||
"names": [
|
||||
"Toga280"
|
||||
],
|
||||
"team_history": [],
|
||||
"team_history": [
|
||||
"F1R3"
|
||||
],
|
||||
"first_season": 1,
|
||||
"last_season": 10
|
||||
},
|
||||
|
|
@ -7797,7 +7800,9 @@
|
|||
"names": [
|
||||
"Java"
|
||||
],
|
||||
"team_history": [],
|
||||
"team_history": [
|
||||
"RVNG"
|
||||
],
|
||||
"first_season": 27,
|
||||
"last_season": 10
|
||||
},
|
||||
|
|
@ -9293,7 +9298,9 @@
|
|||
"names": [
|
||||
"XyLo"
|
||||
],
|
||||
"team_history": [],
|
||||
"team_history": [
|
||||
"AGGG"
|
||||
],
|
||||
"first_season": 48,
|
||||
"last_season": 10
|
||||
},
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ def summarize_team_tags(team_players):
|
|||
# FIX: safe top-tier calculation
|
||||
# -----------------------------
|
||||
valid_tiers = [t for t in tiers if t in "SABCD"]
|
||||
top_tier = max(valid_tiers, key=lambda t: "SABCD".index(t)) if valid_tiers else "-"
|
||||
from data.tag_framework import tier_from_percentile
|
||||
top_tier = tier_from_percentile(safe_avg(pcts)) # Map the average pct to a tier
|
||||
|
||||
summary[tag] = {
|
||||
"avg_pct": safe_avg(pcts),
|
||||
|
|
|
|||
|
|
@ -226,20 +226,17 @@ class DashLeagueGUI:
|
|||
self.sync_button = ttk.Button(top_frame, text="Sync Stats", command=self.on_sync_stats)
|
||||
self.sync_button.grid(row=0, column=1, padx=(0, 10))
|
||||
|
||||
self.spotlight_button = ttk.Button(top_frame, text="Player Spotlight", command=self.on_player_spotlight)
|
||||
self.spotlight_button.grid(row=0, column=2, padx=(0, 10))
|
||||
|
||||
self.team_id_button = ttk.Button(top_frame, text="Team Identity", command=self.on_team_identity)
|
||||
self.team_id_button.grid(row=0, column=3, padx=(0, 10))
|
||||
self.team_id_button.grid(row=0, column=2, padx=(0, 10))
|
||||
|
||||
self.storyline_button = ttk.Button(top_frame, text="Match Storyline", command=self.on_match_storyline)
|
||||
self.storyline_button.grid(row=0, column=4, padx=(0, 10))
|
||||
self.storyline_button.grid(row=0, column=3, padx=(0, 10))
|
||||
|
||||
self.rankings_button = ttk.Button(top_frame, text="Rankings", command=self.on_rankings)
|
||||
self.rankings_button.grid(row=0, column=5, padx=(0, 10))
|
||||
self.rankings_button.grid(row=0, column=4, padx=(0, 10))
|
||||
|
||||
self.rookie_button = ttk.Button(top_frame, text="Rookie Leaderboard", command=self.on_rookie_leaderboard)
|
||||
self.rookie_button.grid(row=0, column=6, padx=(0, 10))
|
||||
self.rookie_button.grid(row=0, column=5, padx=(0, 10))
|
||||
|
||||
self.output_folder_button = ttk.Button(
|
||||
top_frame,
|
||||
|
|
@ -290,6 +287,16 @@ class DashLeagueGUI:
|
|||
ttk.Label(bottom_frame, text="Map:").grid(row=0, column=1, padx=(10, 5))
|
||||
ttk.OptionMenu(bottom_frame, self.map_var, "Payload", "Payload", "Domination").grid(row=0, column=2)
|
||||
|
||||
# Create a specific section for the 4K Graphics
|
||||
broadcast_frame = ttk.LabelFrame(main_frame, text="4K Broadcast Graphics", padding=10)
|
||||
broadcast_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(10, 0))
|
||||
|
||||
ttk.Button(broadcast_frame, text="Player Spotlight", command=self.on_player_spotlight).grid(row=0, column=0, padx=5)
|
||||
ttk.Button(broadcast_frame, text="Tale of the Tape (PvP)", command=self.on_tale_of_the_tape).grid(row=0, column=1, padx=5)
|
||||
ttk.Button(broadcast_frame, text="Team vs Team (DNA)", command=self.on_team_comparison).grid(row=0, column=2, padx=5)
|
||||
ttk.Button(broadcast_frame, text="Win Probability", command=self.on_win_probability).grid(row=0, column=3, padx=5)
|
||||
|
||||
|
||||
status_frame = ttk.Frame(self.root)
|
||||
status_frame.grid(row=1, column=0, sticky="ew")
|
||||
status_label = ttk.Label(status_frame, textvariable=self.status_var, anchor="w")
|
||||
|
|
@ -300,6 +307,86 @@ class DashLeagueGUI:
|
|||
self.status_var.set(text)
|
||||
self.root.update_idletasks()
|
||||
|
||||
|
||||
# Add these methods inside the DashLeagueGUI class
|
||||
|
||||
def get_team_roster_data(self, team_name):
|
||||
"""Helper to get full career data for the current 5-man roster."""
|
||||
career_path = os.path.join(BASE_DIR, "career_stats.json")
|
||||
with open(career_path, "r", encoding="utf-8") as f:
|
||||
db = json.load(f)
|
||||
|
||||
roster_data = []
|
||||
# Filter match players for this specific team
|
||||
team_players = [p for p in self.current_match_players if p.get("team") == team_name]
|
||||
|
||||
for p in team_players:
|
||||
cid = p.get("canonical")
|
||||
if cid in db["players"]:
|
||||
# Merge match team info with career stats
|
||||
p_data = db["players"][cid]
|
||||
p_data["current_team"] = team_name
|
||||
roster_data.append(p_data)
|
||||
return roster_data
|
||||
|
||||
def on_tale_of_the_tape(self):
|
||||
if not self.current_match_players:
|
||||
messagebox.showerror("Error", "Generate Player Slots first.")
|
||||
return
|
||||
|
||||
team_a = self.team_a_var.get()
|
||||
team_b = self.team_b_var.get()
|
||||
|
||||
roster_a = self.get_team_roster_data(team_a)
|
||||
roster_b = self.get_team_roster_data(team_b)
|
||||
|
||||
from utils.graphics_engine import generate_tale_of_the_tape
|
||||
try:
|
||||
generate_tale_of_the_tape(roster_a, roster_b, team_a, team_b)
|
||||
self.set_status("Generated 4K Tale of the Tape (PvP)")
|
||||
messagebox.showinfo("Success", "PvP Graphic generated in obs_exports.")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Graphics Error", str(e))
|
||||
|
||||
def on_team_comparison(self):
|
||||
if not self.current_match_players:
|
||||
messagebox.showerror("Error", "Generate Player Slots first.")
|
||||
return
|
||||
|
||||
team_a = self.team_a_var.get()
|
||||
team_b = self.team_b_var.get()
|
||||
|
||||
roster_a = self.get_team_roster_data(team_a)
|
||||
roster_b = self.get_team_roster_data(team_b)
|
||||
|
||||
from utils.graphics_engine import generate_team_dna
|
||||
try:
|
||||
generate_team_dna(roster_a, roster_b, team_a, team_b)
|
||||
self.set_status("Generated 4K Team DNA Graphic")
|
||||
messagebox.showinfo("Success", "Team DNA Graphic generated in obs_exports.")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Graphics Error", str(e))
|
||||
|
||||
|
||||
def on_win_probability(self):
|
||||
if not self.current_match_players:
|
||||
messagebox.showerror("Error", "Generate Player Slots first.")
|
||||
return
|
||||
|
||||
team_a = self.team_a_var.get()
|
||||
team_b = self.team_b_var.get()
|
||||
roster_a = self.get_team_roster_data(team_a)
|
||||
roster_b = self.get_team_roster_data(team_b)
|
||||
|
||||
from utils.graphics_engine import generate_win_probability
|
||||
try:
|
||||
generate_win_probability(roster_a, roster_b, team_a, team_b)
|
||||
self.set_status("Generated 4K Win Probability Graphic")
|
||||
messagebox.showinfo("Success", "Win Probability Graphic generated in obs_exports.")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Graphics Error", str(e))
|
||||
|
||||
|
||||
def on_sync_stats(self):
|
||||
try:
|
||||
self.set_status(f"Syncing stats for season {self.current_season}...")
|
||||
|
|
@ -793,82 +880,92 @@ class DashLeagueGUI:
|
|||
|
||||
|
||||
def on_player_spotlight(self):
|
||||
# 1. Check if players are actually selected for a match
|
||||
if not self.current_match_players:
|
||||
messagebox.showerror("Error", "Please select teams and click 'Generate Player Slots' first.")
|
||||
messagebox.showerror("Error", "Please generate player slots first.")
|
||||
return
|
||||
|
||||
# 2. Get names ONLY from the 10 players in the current match
|
||||
names = [p["name"] for p in self.current_match_players]
|
||||
names.sort()
|
||||
# Prepare the list of players and names
|
||||
players = self.current_match_players
|
||||
names = [p["name"] for p in players]
|
||||
self.spotlight_idx = 0
|
||||
|
||||
# Load the career DB once so the 'show' function can pull match history for trends
|
||||
# Load the career DB for Trend/Tag data
|
||||
career_path = os.path.join(BASE_DIR, "career_stats.json")
|
||||
with open(career_path, "r", encoding="utf-8") as f:
|
||||
db = json.load(f)
|
||||
|
||||
win = tk.Toplevel(self.root)
|
||||
win.title("Match Player Spotlight")
|
||||
win.geometry("450x550")
|
||||
win.title("Spotlight Operator")
|
||||
win.geometry("500x700")
|
||||
|
||||
ttk.Label(win, text="Select a Player from this Match:").pack(anchor="w", padx=10, pady=5)
|
||||
combo = ttk.Combobox(win, values=names, state="readonly")
|
||||
combo.pack(fill="x", padx=10)
|
||||
ttk.Label(win, text="Active Spotlight Player:").pack(pady=5)
|
||||
|
||||
output = tk.Text(win, wrap="word", font=("Courier", 10))
|
||||
output.pack(expand=True, fill="both", padx=10, pady=10)
|
||||
# Dropdown selection
|
||||
combo_var = tk.StringVar()
|
||||
combo = ttk.Combobox(win, textvariable=combo_var, values=names, state="readonly")
|
||||
combo.pack(fill="x", padx=20)
|
||||
|
||||
def show():
|
||||
selected_name = combo.get()
|
||||
if not selected_name:
|
||||
return
|
||||
output = tk.Text(win, wrap="word", font=("Courier", 10), height=20)
|
||||
output.pack(expand=True, fill="both", padx=20, pady=10)
|
||||
|
||||
# Find the match player object (this has the current team/side info)
|
||||
match_p = next((p for p in self.current_match_players if p["name"] == selected_name), None)
|
||||
def update_spotlight(idx):
|
||||
self.spotlight_idx = idx % len(players)
|
||||
p_match = players[self.spotlight_idx]
|
||||
selected_name = p_match["name"]
|
||||
combo_var.set(selected_name)
|
||||
|
||||
# Find the full career object (this has the history/matches for the graphics engine)
|
||||
cid = match_p.get("canonical")
|
||||
# Get full career data
|
||||
cid = p_match.get("canonical")
|
||||
player_data = db["players"].get(cid)
|
||||
|
||||
if not player_data:
|
||||
output.delete("1.0", tk.END)
|
||||
output.insert(tk.END, "Error: Player data not found in career database.")
|
||||
output.insert(tk.END, f"Data missing for {selected_name}")
|
||||
return
|
||||
|
||||
# Determine Team Side for Colors
|
||||
# If the player is in the Team B listbox, they are 'Red'
|
||||
side = "Blue"
|
||||
# Determine Side
|
||||
team_b_names = [self.team_b_listbox.get(i) for i in range(self.team_b_listbox.size())]
|
||||
if selected_name in team_b_names:
|
||||
side = "Red"
|
||||
side = "Red" if selected_name in team_b_names else "Blue"
|
||||
|
||||
# 3. Trigger the 4K Graphic
|
||||
# Generate 4K PNG
|
||||
from utils.graphics_engine import generate_player_card
|
||||
try:
|
||||
img_path = generate_player_card(player_data, team_side=side)
|
||||
self.set_status(f"Generated 4K Spotlight: {selected_name}")
|
||||
generate_player_card(player_data, team_side=side)
|
||||
self.set_status(f"Updated 4K Graphic: {selected_name}")
|
||||
except Exception as e:
|
||||
print(f"Graphics Error: {e}")
|
||||
|
||||
# 4. Generate the Text Preview for the GUI window
|
||||
# Update Text Preview
|
||||
career = player_data["career"]
|
||||
text = f"PLAYER SPOTLIGHT: {selected_name}\n"
|
||||
text += f"Team: {match_p.get('team', 'Unknown')}\n"
|
||||
text += f"-----------------------------------\n\n"
|
||||
text += f"CAREER STATS:\n"
|
||||
text += f"Kills: {int(career['kills']):,}\n"
|
||||
text += f"Maps: {int(career['maps'])}\n"
|
||||
text += f"KD: {career['KD']:.2f}\n\n"
|
||||
|
||||
text += f"TAG PERCENTILES:\n"
|
||||
text = f"PLAYER: {selected_name}\n"
|
||||
text += f"Team: {p_match.get('team', 'Unknown')}\n"
|
||||
text += f"Side: {side}\n"
|
||||
text += "-"*30 + "\n"
|
||||
text += f"Kills: {int(career.get('kills',0)):,}\n"
|
||||
text += f"Maps: {int(career.get('maps',0))}\n"
|
||||
text += f"KD: {career.get('KD',0):.2f}\n\n"
|
||||
text += "SKILL PERCENTILES:\n"
|
||||
for tag in ["slayer", "sharpshooter", "consistency", "clutch", "anchor"]:
|
||||
t_obj = player_data.get(tag, {})
|
||||
text += f"{tag.title():<14}: {t_obj.get('tier', 'D')} ({t_obj.get('pct', 0):.0f}%)\n"
|
||||
text += f"{tag.title():<14}: {t_obj.get('pct',0):.0f}%\n"
|
||||
|
||||
output.delete("1.0", tk.END)
|
||||
output.insert(tk.END, text)
|
||||
|
||||
ttk.Button(win, text="Generate 4K Graphic", command=show).pack(pady=10)
|
||||
# Button logic
|
||||
btn_frame = ttk.Frame(win)
|
||||
btn_frame.pack(pady=10)
|
||||
|
||||
ttk.Button(btn_frame, text="<< Prev",
|
||||
command=lambda: update_spotlight(self.spotlight_idx - 1)).grid(row=0, column=0, padx=5)
|
||||
ttk.Button(btn_frame, text="Next >>",
|
||||
command=lambda: update_spotlight(self.spotlight_idx + 1)).grid(row=0, column=1, padx=5)
|
||||
|
||||
# Sync dropdown selection to buttons
|
||||
combo.bind("<<ComboboxSelected>>", lambda e: update_spotlight(names.index(combo.get())))
|
||||
|
||||
# Initialize first player
|
||||
update_spotlight(0)
|
||||
|
||||
def on_team_identity(self):
|
||||
team = self.team_a_var.get()
|
||||
|
|
|
|||
|
|
@ -197,8 +197,30 @@ def build_career_database(output_path):
|
|||
|
||||
# --- Advanced Tags ---
|
||||
pdata["anchor"] = compute_anchor_for_career_player(pdata, league_averages, distributions["anchor"], raw_cons)
|
||||
|
||||
# FIX: Ensure we use the pre-calculated clutch_distribution
|
||||
pdata["clutch"] = compute_clutch(pdata, league_averages, clutch_distribution)
|
||||
|
||||
# --- Breaker Tag (Ensure distribution is full) ---
|
||||
# Calculate raw for everyone to build a proper distribution
|
||||
all_breaker_raws = []
|
||||
for inner_p in all_players:
|
||||
ic = inner_p["career"]
|
||||
all_breaker_raws.append(compute_breaker_raw(ic["damage"]/max(1,ic["maps"]), ic["kills"]/max(1,ic["maps"])))
|
||||
all_breaker_raws.sort()
|
||||
|
||||
dmg_map = career["damage"] / max(1, career["maps"])
|
||||
kills_map = career["kills"] / max(1, career["maps"])
|
||||
raw_breaker = compute_breaker_raw(dmg_map, kills_map)
|
||||
break_pct = percentile_rank(raw_breaker, all_breaker_raws)
|
||||
|
||||
pdata["breaker"] = {
|
||||
"raw": raw_breaker,
|
||||
"pct": break_pct,
|
||||
"tier": tier_from_percentile(break_pct),
|
||||
"summary": f"{tier_from_percentile(break_pct)} Tier Offensive Disruptor"
|
||||
}
|
||||
|
||||
# --- Breaker Tag ---
|
||||
dmg_map = career["damage"] / max(1, career["maps"])
|
||||
kills_map = career["kills"] / max(1, career["maps"])
|
||||
|
|
|
|||
|
|
@ -89,3 +89,18 @@ def get_player_match_history(player_uuid):
|
|||
"""
|
||||
rows = query(sql, (player_uuid,))
|
||||
return aggregate_rounds_into_matches(rows)
|
||||
|
||||
|
||||
def get_team_history_stats(team_uuid):
|
||||
sql = """
|
||||
SELECT
|
||||
COUNT(DISTINCT MatchID) as total_matches,
|
||||
SUM(Kills) as total_kills,
|
||||
SUM(Score) as total_score,
|
||||
MIN(CycleID) as first_season
|
||||
FROM aggregate_stats_table -- Adjust name based on your schema
|
||||
WHERE TeamUUID = ?
|
||||
"""
|
||||
# Note: If your schema doesn't have a direct team stats table,
|
||||
# we would aggregate the 'stats' table joined with 'matches'.
|
||||
return query_one(sql, (team_uuid,))
|
||||
|
|
@ -6,72 +6,35 @@ import math
|
|||
# ---------------------------------------------------------
|
||||
|
||||
def compute_clutch_raw(player):
|
||||
"""
|
||||
Computes a meaningful 0–1 clutch score with real separation.
|
||||
Components:
|
||||
- pressure efficiency (log-scaled)
|
||||
- high-pressure accuracy (expanded)
|
||||
- consistency (smoothed)
|
||||
- clutch moment density (rare-event amplifier)
|
||||
"""
|
||||
|
||||
career = player.get("career", {})
|
||||
dmg = career.get("damage", 0)
|
||||
deaths = career.get("deaths", 0)
|
||||
# 1. Pressure Efficiency: Log-scaled but with a much higher ceiling (4000 instead of 3000)
|
||||
# This pushes average players further down the scale.
|
||||
pressure_eff = career.get("damage", 0) / (career.get("deaths", 0) + 1)
|
||||
pressure_norm = math.log1p(pressure_eff) / math.log1p(4500)
|
||||
|
||||
# 2. Consistency: Use their raw stability score
|
||||
consistency_norm = player.get("consistency", {}).get("raw", 0.5)
|
||||
|
||||
# 3. Conversion: Tighten elite accuracy requirements
|
||||
shots = career.get("shots", 0)
|
||||
shots_hit = career.get("shots_hit", 0)
|
||||
headshots = career.get("headshots", 0)
|
||||
|
||||
# -----------------------------------------
|
||||
# 1. Pressure Efficiency (log-scaled)
|
||||
# -----------------------------------------
|
||||
pressure_eff = dmg / (deaths + 1)
|
||||
pressure_norm = math.log1p(pressure_eff) / math.log1p(3000)
|
||||
|
||||
# -----------------------------------------
|
||||
# 2. Consistency (smoothed)
|
||||
# -----------------------------------------
|
||||
consistency = player.get("consistency", {})
|
||||
consistency_norm = consistency.get("raw", 0.0)
|
||||
consistency_norm = max(0.1, consistency_norm) # avoid collapse
|
||||
|
||||
# -----------------------------------------
|
||||
# 3. Accuracy + Headshot Rate (expanded)
|
||||
# -----------------------------------------
|
||||
accuracy = shots_hit / max(1, shots)
|
||||
headshot_rate = headshots / max(1, shots_hit)
|
||||
|
||||
acc_norm = min(1.0, accuracy / 0.25) # 25% = elite
|
||||
hs_norm = min(1.0, headshot_rate / 0.20) # 20% = elite
|
||||
hit = career.get("shots_hit", 0)
|
||||
hs = career.get("headshots", 0)
|
||||
accuracy = hit / max(1, shots)
|
||||
headshot_rate = hs / max(1, hit)
|
||||
|
||||
# Elite is now 30% acc / 25% HS - harder to hit 1.0
|
||||
acc_norm = min(1.0, accuracy / 0.30)
|
||||
hs_norm = min(1.0, headshot_rate / 0.25)
|
||||
conversion = (acc_norm * 0.4) + (hs_norm * 0.6)
|
||||
|
||||
# -----------------------------------------
|
||||
# 4. Clutch Moment Density (rare-event amplifier)
|
||||
# -----------------------------------------
|
||||
matches = player.get("matches", [])
|
||||
clutch_events = 0
|
||||
|
||||
for m in matches:
|
||||
# Count high-pressure events (kills in final minute, etc.)
|
||||
# We don't have real clutch stats, so approximate:
|
||||
if m.get("Kills", 0) >= 10:
|
||||
clutch_events += 1
|
||||
|
||||
density = clutch_events / max(1, len(matches))
|
||||
density_norm = min(1.0, density * 3.0) # amplify rare events
|
||||
|
||||
# -----------------------------------------
|
||||
# Final weighted score (expanded)
|
||||
# -----------------------------------------
|
||||
# 4. Weighting: Shift weight toward Pressure and Conversion
|
||||
raw = (
|
||||
pressure_norm * 0.35 +
|
||||
consistency_norm * 0.25 +
|
||||
conversion * 0.25 +
|
||||
density_norm * 0.15
|
||||
pressure_norm * 0.40 +
|
||||
conversion * 0.40 +
|
||||
consistency_norm * 0.20
|
||||
)
|
||||
|
||||
return max(0.05, min(1.0, raw))
|
||||
# Apply an exponential "curve" to create more separation at the top
|
||||
return max(0.05, min(1.0, raw ** 1.5))
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2,132 +2,273 @@ import os
|
|||
import math
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# --- PATHING CONFIGURATION ---
|
||||
# --- PATHING ---
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ASSETS_DIR = os.path.join(BASE_DIR, "assets")
|
||||
# This points to your stats folder for OBS to read
|
||||
OUTPUT_PATH = os.path.join(BASE_DIR, "..", "stats", "obs_exports", "spotlight_card.png")
|
||||
EXPORT_DIR = os.path.join(BASE_DIR, "..", "stats", "obs_exports")
|
||||
|
||||
# Ensure directories exist
|
||||
os.makedirs(ASSETS_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
# Hex to RGB conversion
|
||||
COLOR_BLUE = (13, 134, 244) # #0d86f4
|
||||
COLOR_RED = (244, 13, 53) # #f40d35
|
||||
|
||||
SPOTLIGHT_PATH = os.path.join(EXPORT_DIR, "spotlight_card.png")
|
||||
TAPE_PATH = os.path.join(EXPORT_DIR, "tale_of_the_tape.png")
|
||||
DNA_PATH = os.path.join(EXPORT_DIR, "team_dna.png")
|
||||
WIN_PATH = os.path.join(EXPORT_DIR, "win_probability.png")
|
||||
|
||||
def get_font(size):
|
||||
try: return ImageFont.truetype("arialbd.ttf", size)
|
||||
except:
|
||||
try: return ImageFont.truetype("arial.ttf", size)
|
||||
except: return ImageFont.load_default()
|
||||
|
||||
def load_bg(screen_name):
|
||||
width, height = 3840, 2160
|
||||
paths = [os.path.join(ASSETS_DIR, f"{screen_name}_bg.png"), os.path.join(ASSETS_DIR, "background.png")]
|
||||
for p in paths:
|
||||
if os.path.exists(p): return Image.open(p).resize((width, height))
|
||||
return Image.new('RGB', (width, height), color=(10, 10, 15))
|
||||
|
||||
def draw_radar_chart(draw, center, radius, params, color):
|
||||
points = []
|
||||
for i, (label, value) in enumerate(params):
|
||||
angle = (i * 2 * math.pi / 5) - (math.pi / 2)
|
||||
dist = (max(5, value) / 100) * radius
|
||||
points.append((center[0] + dist * math.cos(angle), center[1] + dist * math.sin(angle)))
|
||||
|
||||
for r_step in [0.25, 0.5, 0.75, 1.0]:
|
||||
grid_points = []
|
||||
for i in range(5):
|
||||
angle = (i * 2 * math.pi / 5) - (math.pi / 2)
|
||||
grid_points.append((center[0] + (radius * r_step) * math.cos(angle), center[1] + (radius * r_step) * math.sin(angle)))
|
||||
draw.polygon(grid_points, outline=(255, 255, 255, 30))
|
||||
if r_step == 1.0:
|
||||
for i, (label, _) in enumerate(params):
|
||||
angle = (i * 2 * math.pi / 5) - (math.pi / 2)
|
||||
draw.text((center[0] + (radius + 80) * math.cos(angle), center[1] + (radius + 40) * math.sin(angle)), label, font=get_font(50), fill=(255, 255, 255), anchor="mm")
|
||||
draw.polygon(points, fill=color + (80,), outline=color + (255,))
|
||||
|
||||
def generate_player_card(player_data, team_side="Blue"):
|
||||
"""
|
||||
Generates a 4K (3840x2160) Player Spotlight card.
|
||||
team_side: "Blue" or "Red" (determines accent colors)
|
||||
"""
|
||||
# 1. Setup Canvas (4K)
|
||||
width, height = 3840, 2160
|
||||
# Professional Blue/Red Hex codes
|
||||
accent_color = (0, 162, 255) if team_side == "Blue" else (255, 38, 0)
|
||||
img = load_bg("spotlight")
|
||||
draw = ImageDraw.Draw(img, 'RGBA')
|
||||
accent_color = COLOR_BLUE if team_side == "Blue" else COLOR_RED
|
||||
|
||||
# 2. Background Logic
|
||||
bg_path = os.path.join(ASSETS_DIR, "background.png")
|
||||
if os.path.exists(bg_path):
|
||||
img = Image.open(bg_path).resize((width, height))
|
||||
else:
|
||||
# Create a deep dark slate gradient-style background
|
||||
img = Image.new('RGB', (width, height), color=(10, 10, 15))
|
||||
name = player_data.get("name", "Unknown").upper()
|
||||
draw.text((200, 150), name, font=get_font(220), fill=(255, 255, 255))
|
||||
|
||||
draw = ImageDraw.Draw(img, 'RGBA') # RGBA for semi-transparent lines
|
||||
|
||||
# Font Helper
|
||||
def get_font(size):
|
||||
# On Windows, arial is standard. On Linux/Mac, you might need a path to a .ttf
|
||||
try:
|
||||
return ImageFont.truetype("arial.ttf", size)
|
||||
except:
|
||||
return ImageFont.load_default()
|
||||
|
||||
# Define Font Styles
|
||||
title_font = get_font(220)
|
||||
label_font = get_font(70)
|
||||
stat_font = get_font(130)
|
||||
tag_font = get_font(90)
|
||||
hook_font = get_font(60)
|
||||
|
||||
# 3. DRAW PLAYER HEADER
|
||||
name = player_data.get("name", "Unknown Player").upper()
|
||||
draw.text((200, 150), name, font=title_font, fill=(255, 255, 255))
|
||||
|
||||
# 4. TREND ANALYSIS (Last 5 Matches vs Career)
|
||||
matches = player_data.get("matches", [])
|
||||
career_kd = player_data.get("career", {}).get("KD", 1.0)
|
||||
|
||||
recent_kd = career_kd
|
||||
if len(matches) >= 3:
|
||||
last_5 = matches[-5:]
|
||||
recent_kills = sum(m.get("Kills", 0) or 0 for m in last_5)
|
||||
recent_deaths = sum(m.get("Deaths", 0) or 0 for m in last_5)
|
||||
recent_kd = recent_kills / recent_deaths if recent_deaths > 0 else recent_kills
|
||||
|
||||
last_5 = matches[-5:] if len(matches) >= 5 else matches
|
||||
recent_kd = (sum(m.get("Kills",0) for m in last_5)/sum(m.get("Deaths",1) for m in last_5)) if last_5 else career_kd
|
||||
diff = recent_kd - career_kd
|
||||
trend_color = (50, 255, 50) if diff >= 0 else (255, 50, 50)
|
||||
trend_text = "HOT FORM" if diff >= 0 else "COOLING"
|
||||
draw.rounded_rectangle([200, 400, 950, 500], radius=20, fill=(50, 255, 50) if diff >= 0 else (255, 50, 50))
|
||||
draw.text((230, 415), f"{'HOT FORM' if diff >=0 else 'COOLING'} ({'+' if diff >=0 else ''}{diff:.2f} KD)", font=get_font(45), fill=(0,0,0))
|
||||
|
||||
# Draw Trend Box
|
||||
draw.rounded_rectangle([200, 400, 850, 500], radius=20, fill=trend_color)
|
||||
draw.text((230, 415), f"{trend_text} ({'+' if diff >=0 else ''}{diff:.2f} KD)", font=get_font(45), fill=(0,0,0))
|
||||
|
||||
# 5. LEFT COLUMN: CORE CAREER STATS
|
||||
career = player_data.get("career", {})
|
||||
stats = [
|
||||
("TOTAL KILLS", f"{int(career.get('kills', 0)):,}"),
|
||||
("TOTAL SCORE", f"{int(career.get('score', 0)):,}"),
|
||||
("MAPS PLAYED", f"{int(career.get('maps', 0))}"),
|
||||
("CAREER KD", f"{career.get('KD', 0):.2f}")
|
||||
]
|
||||
|
||||
curr_y = 650
|
||||
stats = [("TOTAL KILLS", f"{int(career.get('kills', 0)):,}"), ("TOTAL SCORE", f"{int(career.get('score', 0)):,}"), ("MAPS PLAYED", f"{int(career.get('maps', 0))}"), ("CAREER KD", f"{career.get('KD', 0):.2f}")]
|
||||
y = 650
|
||||
for label, value in stats:
|
||||
draw.text((200, curr_y), label, font=label_font, fill=(180, 180, 180))
|
||||
draw.text((200, curr_y + 80), value, font=stat_font, fill=(255, 255, 255))
|
||||
curr_y += 280
|
||||
draw.text((200, y), label, font=get_font(70), fill=(180, 180, 180))
|
||||
draw.text((200, y + 80), value, font=get_font(130), fill=(255, 255, 255))
|
||||
y += 280
|
||||
|
||||
# 6. RIGHT COLUMN: SKILL BARS
|
||||
draw.text((2000, 450), "SKILL PROFILE vs LEAGUE AVG", font=label_font, fill=accent_color)
|
||||
radar_params = [("SLY", player_data.get('slayer', {}).get('pct', 0)), ("AIM", player_data.get('sharpshooter', {}).get('pct', 0)), ("STA", player_data.get('consistency', {}).get('pct', 0)), ("CLU", player_data.get('clutch', {}).get('pct', 0)), ("ANC", player_data.get('anchor', {}).get('pct', 0))]
|
||||
draw_radar_chart(draw, (1850, 1150), 450, radar_params, accent_color)
|
||||
|
||||
tags_to_show = [
|
||||
("SLAYING", "slayer"),
|
||||
("MARKSMAN", "sharpshooter"),
|
||||
("CONSISTENCY", "consistency"),
|
||||
("PRESSURE", "clutch"),
|
||||
("DEFENSIVE", "anchor")
|
||||
draw.text((2700, 550), "SKILL PROFILE", font=get_font(70), fill=accent_color)
|
||||
by = 680
|
||||
for label_long, key in [("SLAYING", "slayer"), ("MARKSMAN", "sharpshooter"), ("STABILITY", "consistency"), ("PRESSURE", "clutch"), ("DEFENSIVE", "anchor")]:
|
||||
pct = player_data.get(key, {}).get("pct", 0)
|
||||
draw.text((2700, by), label_long, font=get_font(45), fill=(200, 200, 200))
|
||||
draw.rectangle([2700, by+60, 3600, by+90], fill=(40, 40, 45))
|
||||
draw.rectangle([2700, by+60, 2700 + (900 * (pct/100)), by+90], fill=accent_color)
|
||||
draw.text((3610, by+45), f"{int(pct)}%", font=get_font(40), fill=(255,255,255), anchor="lm")
|
||||
by += 180
|
||||
|
||||
best_tag = max(["slayer", "sharpshooter", "consistency", "clutch", "anchor"], key=lambda k: player_data.get(k, {}).get("pct", 0))
|
||||
intel = player_data.get(best_tag, {}).get("summary", "Reliable team player.")
|
||||
draw.rectangle([150, 1850, 3690, 2080], fill=(20, 20, 25))
|
||||
draw.text((200, 1880), "SCOUTING REPORT", font=get_font(70), fill=accent_color)
|
||||
draw.text((200, 1960), intel, font=get_font(60), fill=(230, 230, 230))
|
||||
img.save(SPOTLIGHT_PATH)
|
||||
|
||||
def generate_tale_of_the_tape(rosterA, rosterB, nameA, nameB):
|
||||
img = load_bg("tape")
|
||||
draw = ImageDraw.Draw(img, 'RGBA')
|
||||
rosterA.sort(key=lambda p: p['career'].get('KD', 0), reverse=True)
|
||||
rosterB.sort(key=lambda p: p['career'].get('KD', 0), reverse=True)
|
||||
|
||||
draw.text((960, 150), nameA, font=get_font(150), fill=COLOR_BLUE, anchor="mm")
|
||||
draw.text((2880, 150), nameB, font=get_font(150), fill=COLOR_RED, anchor="mm")
|
||||
draw.text((1920, 150), "PVP MATCHUPS", font=get_font(80), fill=(255, 255, 255), anchor="mm")
|
||||
|
||||
y = 350
|
||||
# Added ANCHOR (Stability is Consistency)
|
||||
tags = [("SLAYER", "slayer"), ("AIM", "sharpshooter"), ("STABLE", "consistency"), ("CLUTCH", "clutch"), ("ANCHOR", "anchor")]
|
||||
|
||||
for i in range(5):
|
||||
pA = rosterA[i] if i < len(rosterA) else {"name": "---"}
|
||||
pB = rosterB[i] if i < len(rosterB) else {"name": "---"}
|
||||
draw.text((250, y+100), pA['name'].upper(), font=get_font(90), fill=(255,255,255))
|
||||
draw.text((3590, y+100), pB['name'].upper(), font=get_font(90), fill=(255,255,255), anchor="ra")
|
||||
|
||||
sub_y = y + 10
|
||||
for label, key in tags:
|
||||
pctA, pctB = pA.get(key, {}).get("pct", 0), pB.get(key, {}).get("pct", 0)
|
||||
draw.text((1920, sub_y), label, font=get_font(25), fill=(150,150,150), anchor="mm")
|
||||
draw.rectangle([1450, sub_y-10, 1850, sub_y+10], fill=(30,30,35))
|
||||
draw.rectangle([1850 - (400 * (pctA/100)), sub_y-10, 1850, sub_y+10], fill=COLOR_BLUE)
|
||||
draw.rectangle([1990, sub_y-10, 2390, sub_y+10], fill=(30,30,35))
|
||||
draw.rectangle([1990, sub_y-10, 1990 + (400 * (pctB/100)), sub_y+10], fill=COLOR_RED)
|
||||
sub_y += 48
|
||||
|
||||
draw.line([200, y+280, 3640, y+280], fill=(255,255,255, 15), width=2)
|
||||
y += 310
|
||||
|
||||
avgA = sum(p.get('slayer', {}).get('pct', 0) for p in rosterA) / 5
|
||||
avgB = sum(p.get('slayer', {}).get('pct', 0) for p in rosterB) / 5
|
||||
draw.rectangle([0, 1950, 3840, 2160], fill=(15, 15, 20))
|
||||
draw.text((400, 2055), f"SQUAD SLAYER POWER: {avgA:.1f}%", font=get_font(60), fill=COLOR_BLUE, anchor="lm")
|
||||
draw.text((3440, 2055), f"SQUAD SLAYER POWER: {avgB:.1f}%", font=get_font(60), fill=COLOR_RED, anchor="rm")
|
||||
img.save(TAPE_PATH)
|
||||
|
||||
def generate_team_dna(rosterA, rosterB, nameA, nameB):
|
||||
img = load_bg("dna")
|
||||
draw = ImageDraw.Draw(img, 'RGBA')
|
||||
draw.text((200, 100), nameA, font=get_font(140), fill=COLOR_BLUE)
|
||||
draw.text((3640, 100), nameB, font=get_font(140), fill=COLOR_RED, anchor="ra")
|
||||
draw.text((1920, 150), "STRATEGY DNA", font=get_font(100), fill=(255,255,255), anchor="mm")
|
||||
|
||||
tags = ["slayer", "sharpshooter", "consistency", "clutch", "objective_payload", "objective_domination", "anchor"]
|
||||
y = 450
|
||||
for tag in tags:
|
||||
avgA = sum(p.get(tag, {}).get("pct", 0) for p in rosterA) / max(1, len(rosterA))
|
||||
avgB = sum(p.get(tag, {}).get("pct", 0) for p in rosterB) / max(1, len(rosterB))
|
||||
draw.text((1920, y - 40), tag.replace("_", " ").upper(), font=get_font(55), fill=(160,160,160), anchor="mm")
|
||||
draw.rectangle([600, y, 1850, y+70], fill=(30, 30, 35))
|
||||
draw.rectangle([1850 - (1250 * (avgA/100)), y, 1850, y+70], fill=COLOR_BLUE)
|
||||
draw.rectangle([1990, y, 3240, y+70], fill=(30, 30, 35))
|
||||
draw.rectangle([1990, y, 1990 + (1250 * (avgB/100)), y+70], fill=COLOR_RED)
|
||||
y += 200
|
||||
|
||||
draw.rectangle([0, 1850, 3840, 2160], fill=(15, 15, 20))
|
||||
vitals = [("TOTAL KILLS", lambda r: sum(p['career'].get('kills', 0) for p in r)), ("AVG KD", lambda r: sum(p['career'].get('KD', 0) for p in r) / len(r)), ("TOTAL MAPS", lambda r: sum(p['career'].get('maps', 0) for p in r))]
|
||||
vx_pos = [450, 1100, 1750] # Increased spacing to fix overlap
|
||||
for i, (label, func) in enumerate(vitals):
|
||||
valA, valB = func(rosterA), func(rosterB)
|
||||
draw.text((vx_pos[i], 1920), label, font=get_font(45), fill=(150, 150, 150), anchor="mm")
|
||||
draw.text((vx_pos[i], 2020), f"{valA:,.1f}" if isinstance(valA, float) else f"{valA:,}", font=get_font(90), fill=COLOR_BLUE, anchor="mm")
|
||||
draw.text((3840 - vx_pos[i], 1920), label, font=get_font(45), fill=(150, 150, 150), anchor="mm")
|
||||
draw.text((3840 - vx_pos[i], 2020), f"{valB:,.1f}" if isinstance(valB, float) else f"{valB:,}", font=get_font(90), fill=COLOR_RED, anchor="mm")
|
||||
img.save(DNA_PATH)
|
||||
|
||||
def draw_vs_donut(draw, center, radius, valA, valB, colorA, colorB, label):
|
||||
"""Draws a split donut showing relative strength between two teams."""
|
||||
cx, cy = center
|
||||
width = 35
|
||||
total = valA + valB
|
||||
if total <= 0: # Prevent div by zero
|
||||
valA, valB = 50, 50
|
||||
total = 100
|
||||
|
||||
# Percentages of the circle
|
||||
pctA = valA / total
|
||||
|
||||
# Background circle
|
||||
draw.ellipse([cx-radius, cy-radius, cx+radius, cy+radius], outline=(40, 40, 45), width=width)
|
||||
|
||||
# Arcs
|
||||
start_angle = -90
|
||||
split_angle = start_angle + (360 * pctA)
|
||||
|
||||
# Blue Arc (Team A)
|
||||
draw.arc([cx-radius, cy-radius, cx+radius, cy+radius], start=start_angle, end=split_angle, fill=colorA, width=width)
|
||||
# Red Arc (Team B)
|
||||
draw.arc([cx-radius, cy-radius, cx+radius, cy+radius], start=split_angle, end=start_angle + 360, fill=colorB, width=width)
|
||||
|
||||
# Center Text
|
||||
draw.text((cx, cy), f"{int((valA/total)*100)}% | {int((valB/total)*100)}%", font=get_font(45), fill=(255, 255, 255), anchor="mm")
|
||||
draw.text((cx, cy + radius + 60), label, font=get_font(40), fill=(180, 180, 180), anchor="mm")
|
||||
|
||||
def generate_win_probability(rosterA, rosterB, nameA, nameB):
|
||||
img = load_bg("win")
|
||||
draw = ImageDraw.Draw(img, 'RGBA')
|
||||
|
||||
# 1. DATA CALCULATION
|
||||
from analysis.team_identity_v2 import summarize_team_tags
|
||||
sumA, sumB = summarize_team_tags(rosterA), summarize_team_tags(rosterB)
|
||||
|
||||
# Calculate Strengths (Tags >= 60th percentile)
|
||||
def get_strengths(summary):
|
||||
s_list = []
|
||||
for tag, data in summary.items():
|
||||
if data["avg_pct"] >= 60:
|
||||
s_list.append(f"{tag.replace('_',' ').title()} Specialists")
|
||||
return s_list if s_list else ["Versatile Roster"]
|
||||
|
||||
strengthsA = get_strengths(sumA)
|
||||
strengthsB = get_strengths(sumB)
|
||||
|
||||
# 2. WIN PROBABILITY MATH
|
||||
weights = {"slayer": 0.35, "consistency": 0.25, "clutch": 0.15, "objective_payload": 0.15, "sharpshooter": 0.10}
|
||||
powerA = sum(sumA[tag]["avg_pct"] * weights[tag] for tag in weights)
|
||||
powerB = sum(sumB[tag]["avg_pct"] * weights[tag] for tag in weights)
|
||||
|
||||
probA = (powerA / (powerA + powerB)) * 100 if (powerA + powerB) > 0 else 50
|
||||
probB = 100 - probA
|
||||
|
||||
# 3. HEADER
|
||||
draw.text((1920, 150), "MATCH PREVIEW & PROBABILITY", font=get_font(120), fill=(255,255,255), anchor="mm")
|
||||
|
||||
# Big Win Numbers
|
||||
draw.text((960, 450), nameA.upper(), font=get_font(100), fill=COLOR_BLUE, anchor="mm")
|
||||
draw.text((960, 650), f"{probA:.1f}%", font=get_font(300), fill=(255,255,255), anchor="mm")
|
||||
|
||||
draw.text((2880, 450), nameB.upper(), font=get_font(100), fill=COLOR_RED, anchor="mm")
|
||||
draw.text((2880, 650), f"{probB:.1f}%", font=get_font(300), fill=(255,255,255), anchor="mm")
|
||||
|
||||
# 4. VS DONUT GAUGES (6 Total)
|
||||
gauge_tags = [
|
||||
("SLAYING", "slayer"), ("AIM", "sharpshooter"),
|
||||
("STABILITY", "consistency"), ("PRESSURE", "clutch"), ("TACTICS", "anchor")
|
||||
]
|
||||
gx = 450
|
||||
for label, key in gauge_tags:
|
||||
draw_vs_donut(draw, (gx, 1150), 120, sumA[key]["avg_pct"], sumB[key]["avg_pct"], COLOR_BLUE, COLOR_RED, label)
|
||||
gx += 600
|
||||
|
||||
bar_y = 550
|
||||
for display_name, key in tags_to_show:
|
||||
tag_obj = player_data.get(key, {})
|
||||
pct = tag_obj.get("pct", 0)
|
||||
tier = tag_obj.get("tier", "D")
|
||||
# 6th Gauge: EXPERIENCE (Maps Played)
|
||||
mapsA = sum(p['career'].get('maps', 0) for p in rosterA)
|
||||
mapsB = sum(p['career'].get('maps', 0) for p in rosterB)
|
||||
draw_vs_donut(draw, (gx, 1150), 120, mapsA, mapsB, COLOR_BLUE, COLOR_RED, "EXPERIENCE")
|
||||
|
||||
# Label
|
||||
draw.text((2000, bar_y), f"{display_name} ({tier})", font=tag_font, fill=(255, 255, 255))
|
||||
# 5. MATCH KEY FACTOR
|
||||
diffs = {tag: sumA[tag]["avg_pct"] - sumB[tag]["avg_pct"] for tag in weights}
|
||||
best_tag = max(diffs, key=lambda k: abs(diffs[k]))
|
||||
favored = nameA if diffs[best_tag] > 0 else nameB
|
||||
|
||||
# Bar BG
|
||||
draw.rectangle([2000, bar_y + 110, 3600, bar_y + 140], fill=(40, 40, 45))
|
||||
# Actual Data Bar
|
||||
fill_width = 2000 + (1600 * (pct / 100))
|
||||
draw.rectangle([2000, bar_y + 110, fill_width, bar_y + 140], fill=accent_color)
|
||||
hooks = {
|
||||
"slayer": f"The '{favored}' Firepower: Superior elimination pressure likely to dictate map pace.",
|
||||
"consistency": f"The '{favored}' Floor: Higher stability makes them significantly harder to upset.",
|
||||
"clutch": f"The '{favored}' Resolve: Statistical advantage in high-pressure closing moments.",
|
||||
"objective_payload": f"The '{favored}' Efficiency: Superior objective focus provides a clear win-condition.",
|
||||
"sharpshooter": f"The '{favored}' Precision: Lethal accuracy in duels could negate enemy numbers."
|
||||
}
|
||||
key_hook = hooks.get(best_tag, f"The {best_tag.title()} Edge: {favored} hold the strategic advantage.")
|
||||
|
||||
# --- LEAGUE AVG LINE (50%) ---
|
||||
avg_x = 2000 + (1600 * 0.5)
|
||||
# Drawing a vertical white line at 50%
|
||||
draw.line([avg_x, bar_y + 100, avg_x, bar_y + 150], fill=(255, 255, 255, 180), width=6)
|
||||
draw.rectangle([400, 1450, 3440, 1700], fill=(20, 20, 25))
|
||||
draw.text((1920, 1510), "PRODUCER'S KEY FACTOR", font=get_font(50), fill=COLOR_BLUE if favored == nameA else COLOR_RED, anchor="mm")
|
||||
draw.text((1920, 1610), key_hook, font=get_font(70), fill=(255, 255, 255), anchor="mm")
|
||||
|
||||
bar_y += 230
|
||||
# 6. TEAM INTEL (Strengths Only)
|
||||
def draw_intel(draw, x, name, strengths, color):
|
||||
draw.text((x, 1800), f"{name} STRENGTHS", font=get_font(55), fill=color, anchor="mm")
|
||||
y_off = 1880
|
||||
for s in strengths[:3]:
|
||||
draw.text((x, y_off), f"• {s}", font=get_font(45), fill=(220, 220, 220), anchor="mm")
|
||||
y_off += 65
|
||||
|
||||
# 7. FOOTER: SCOUTING REPORT
|
||||
# Get the summary from their highest-performing tag
|
||||
best_tag_key = max(tags_to_show, key=lambda x: player_data.get(x[1], {}).get("pct", 0))[1]
|
||||
intel = player_data.get(best_tag_key, {}).get("summary", "Reliable league veteran.")
|
||||
draw_intel(draw, 960, nameA, strengthsA, COLOR_BLUE)
|
||||
draw_intel(draw, 2880, nameB, strengthsB, COLOR_RED)
|
||||
|
||||
# Semi-transparent dark box for footer
|
||||
draw.rectangle([150, 1800, 3690, 2050], fill=(20, 20, 25))
|
||||
draw.text((200, 1830), "SCOUTING REPORT", font=label_font, fill=accent_color)
|
||||
draw.text((200, 1920), intel, font=hook_font, fill=(230, 230, 230))
|
||||
|
||||
# 8. SAVE
|
||||
img.save(OUTPUT_PATH)
|
||||
return OUTPUT_PATH
|
||||
img.save(WIN_PATH)
|
||||
Loading…
Reference in a new issue