Files

398 lines
11 KiB
Python
Raw Permalink Normal View History

2026-06-08 21:58:16 +12:00
import os
import math
import hashlib
import random
from typing import List, Tuple
from PIL import Image, ImageDraw, ImageFont, ImageFilter
# =========================================================
# CONFIG
# =========================================================
OUTPUT_DIR = "emby_user_thumbs"
IMAGE_SIZE = 512 # final image size (square)
CORNER_RADIUS = 22 # rounded corners
FONT_SIZE_RATIO = 0.34 # relative to image size
USE_GRADIENTS = True # True = soft gradient backgrounds
ADD_SUBTLE_SHADOW = False # set True if you want slight depth
TEXT_COLOUR = (245, 245, 245, 255)
# Optional: point this to a nicer font installed on your system.
# Windows examples:
# r"C:\Windows\Fonts\segoeuib.ttf"
# r"C:\Windows\Fonts\bahnschrift.ttf"
# r"C:\Windows\Fonts\arialbd.ttf"
# Leave as None to use Pillow default fallback search.
FONT_PATH = r"C:\Windows\Fonts\segoeuib.ttf"
# Example user list. Replace with your own, or load from Emby.
USERS = [
"AC",
"AV",
"AS",
"DN",
"DC",
"FTV",
"LB",
"MC",
"PB",
"PR",
"PRXX",
"PC",
"RH",
"SC",
"TH",
"X",
]
# Background palette pairs for gradients / solids.
# These are chosen to feel fairly modern and similar in spirit
# to app profile tiles.
PALETTE: List[Tuple[Tuple[int, int, int], Tuple[int, int, int]]] = [
((28, 148, 33), (33, 120, 39)), # green
((85, 65, 201), (102, 45, 184)), # purple
((170, 72, 157), (141, 60, 134)), # magenta
((91, 116, 139), (111, 128, 149)), # slate blue
((86, 47, 88), (106, 60, 110)), # plum
((54, 108, 63), (63, 120, 71)), # deep green
((41, 58, 73), (52, 70, 85)), # charcoal blue
((238, 146, 12), (113, 56, 180)), # orange to violet
((255, 93, 39), (45, 144, 241)), # orange to blue
((25, 26, 34), (38, 40, 51)), # dark neutral
]
# =========================================================
# HELPERS
# =========================================================
def ensure_output_dir(path: str) -> None:
os.makedirs(path, exist_ok=True)
def safe_filename(name: str) -> str:
cleaned = "".join(c for c in name if c.isalnum() or c in ("-", "_", " ")).strip()
cleaned = cleaned.replace(" ", "_")
return cleaned or "user"
def initials_from_name(name: str) -> str:
name = (name or "").strip()
if not name:
return "?"
# Split on spaces first
parts = [p for p in name.replace("_", " ").replace("-", " ").split() if p]
if len(parts) >= 2:
return (parts[0][0] + parts[1][0]).upper()
# Handle camel-ish or single-token names like DaveN, PaulR, RobH
token = parts[0] if parts else name
uppers = [c for c in token[1:] if c.isupper()]
if len(token) == 1:
return token.upper()
if uppers:
return (token[0] + uppers[0]).upper()
return token[:2].upper()
def hash_to_palette_index(name: str, palette_size: int) -> int:
digest = hashlib.sha256(name.encode("utf-8")).hexdigest()
return int(digest[:8], 16) % palette_size
def seeded_rng(name: str) -> random.Random:
digest = hashlib.sha256(name.encode("utf-8")).digest()
return random.Random(int.from_bytes(digest[:8], "big"))
def load_font(image_size: int) -> ImageFont.FreeTypeFont:
font_size = int(image_size * FONT_SIZE_RATIO)
# Try configured font first
if FONT_PATH and os.path.exists(FONT_PATH):
try:
return ImageFont.truetype(FONT_PATH, font_size)
except Exception:
pass
# Common fallbacks
fallback_fonts = [
"DejaVuSans-Bold.ttf",
"Arial Bold.ttf",
"arialbd.ttf",
"seguiemj.ttf", # not ideal, but sometimes present
"bahnschrift.ttf"
]
for font_name in fallback_fonts:
try:
return ImageFont.truetype(font_name, font_size)
except Exception:
continue
# Last resort
return ImageFont.load_default()
def lerp(a: int, b: int, t: float) -> int:
return int(a + (b - a) * t)
def blend_colours(
c1: Tuple[int, int, int],
c2: Tuple[int, int, int],
t: float,
) -> Tuple[int, int, int]:
return (
lerp(c1[0], c2[0], t),
lerp(c1[1], c2[1], t),
lerp(c1[2], c2[2], t),
)
def make_gradient_background(size: int, c1: Tuple[int, int, int], c2: Tuple[int, int, int]) -> Image.Image:
"""
Creates a diagonal gradient background.
"""
img = Image.new("RGBA", (size, size))
px = img.load()
for y in range(size):
for x in range(size):
# diagonal interpolation
t = (x + y) / (2 * (size - 1))
r = lerp(c1[0], c2[0], t)
g = lerp(c1[1], c2[1], t)
b = lerp(c1[2], c2[2], t)
px[x, y] = (r, g, b, 255)
return img
def make_mesh_gradient_background(
size: int,
c1: Tuple[int, int, int],
c2: Tuple[int, int, int],
rng: random.Random,
) -> Image.Image:
"""
Creates a softer, more modern mesh-like gradient with an angled sweep.
"""
img = Image.new("RGBA", (size, size))
px = img.load()
angle = rng.uniform(0, math.pi)
dx = math.cos(angle)
dy = math.sin(angle)
cx = rng.uniform(size * 0.2, size * 0.8)
cy = rng.uniform(size * 0.2, size * 0.8)
accent = blend_colours(c1, c2, 0.5)
accent_strength = rng.uniform(0.12, 0.28)
radius = size * rng.uniform(0.35, 0.6)
for y in range(size):
for x in range(size):
proj = ((x - cx) * dx + (y - cy) * dy) / size
t = max(0.0, min(1.0, 0.5 + proj))
base = blend_colours(c1, c2, t)
dist = math.hypot(x - cx, y - cy)
glow = max(0.0, 1.0 - (dist / radius))
mix = min(1.0, accent_strength * glow)
px[x, y] = (
lerp(base[0], accent[0], mix),
lerp(base[1], accent[1], mix),
lerp(base[2], accent[2], mix),
255,
)
return img
def make_solid_background(size: int, colour: Tuple[int, int, int]) -> Image.Image:
return Image.new("RGBA", (size, size), colour + (255,))
def rounded_mask(size: int, radius: int) -> Image.Image:
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
return mask
def add_soft_light_overlay(base: Image.Image) -> Image.Image:
"""
Adds a subtle glossy highlight to make the tile feel a bit more polished.
"""
overlay = Image.new("RGBA", base.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(overlay)
w, h = base.size
draw.ellipse((-w * 0.25, -h * 0.35, w * 0.85, h * 0.45), fill=(255, 255, 255, 28))
overlay = overlay.filter(ImageFilter.GaussianBlur(radius=w // 18))
return Image.alpha_composite(base, overlay)
def add_modern_shapes(
base: Image.Image,
c1: Tuple[int, int, int],
c2: Tuple[int, int, int],
rng: random.Random,
) -> Image.Image:
"""
Adds blurred blobs, rings, and line accents for a more contemporary feel.
"""
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
w, h = base.size
accent_light = blend_colours(c1, (255, 255, 255), 0.45)
accent_dark = blend_colours(c2, (8, 12, 18), 0.35)
for _ in range(rng.randint(2, 4)):
blob_w = int(w * rng.uniform(0.28, 0.58))
blob_h = int(h * rng.uniform(0.28, 0.58))
x = int(rng.uniform(-w * 0.12, w * 0.72))
y = int(rng.uniform(-h * 0.12, h * 0.72))
fill = accent_light if rng.random() > 0.45 else accent_dark
alpha = rng.randint(38, 88)
draw.ellipse((x, y, x + blob_w, y + blob_h), fill=fill + (alpha,))
for _ in range(rng.randint(1, 2)):
ring_size = int(w * rng.uniform(0.2, 0.42))
x = int(rng.uniform(-w * 0.08, w * 0.82))
y = int(rng.uniform(-h * 0.08, h * 0.82))
width = max(3, w // 64)
draw.ellipse(
(x, y, x + ring_size, y + ring_size),
outline=(255, 255, 255, rng.randint(40, 95)),
width=width,
)
for _ in range(rng.randint(2, 4)):
x1 = int(rng.uniform(0, w))
y1 = int(rng.uniform(0, h))
x2 = int(rng.uniform(0, w))
y2 = int(rng.uniform(0, h))
draw.line(
(x1, y1, x2, y2),
fill=(255, 255, 255, rng.randint(18, 45)),
width=max(2, w // 128),
)
overlay = overlay.filter(ImageFilter.GaussianBlur(radius=max(8, w // 22)))
return Image.alpha_composite(base, overlay)
def add_text(img: Image.Image, text: str, font: ImageFont.FreeTypeFont) -> Image.Image:
draw = ImageDraw.Draw(img)
center = (img.width / 2, img.height / 2)
if ADD_SUBTLE_SHADOW:
shadow_offset = max(2, img.width // 128)
draw.text(
(center[0] + shadow_offset, center[1] + shadow_offset),
text,
font=font,
fill=(0, 0, 0, 60),
anchor="mm",
)
draw.text(center, text, font=font, fill=TEXT_COLOUR, anchor="mm")
return img
def create_avatar(name: str, size: int = IMAGE_SIZE) -> Image.Image:
rng = seeded_rng(name)
idx = hash_to_palette_index(name, len(PALETTE))
c1, c2 = PALETTE[idx]
if USE_GRADIENTS:
if rng.random() > 0.4:
bg = make_mesh_gradient_background(size, c1, c2, rng)
else:
bg = make_gradient_background(size, c1, c2)
else:
bg = make_solid_background(size, c1)
bg = add_modern_shapes(bg, c1, c2, rng)
bg = add_soft_light_overlay(bg)
mask = rounded_mask(size, CORNER_RADIUS)
rounded = Image.new("RGBA", (size, size), (0, 0, 0, 0))
rounded.paste(bg, (0, 0), mask)
font = load_font(size)
text = initials_from_name(name)
rounded = add_text(rounded, text, font)
return rounded
def save_avatar(img: Image.Image, username: str, out_dir: str) -> str:
filename = f"{safe_filename(username)}.png"
out_path = os.path.join(out_dir, filename)
img.save(out_path, format="PNG", optimize=True)
return out_path
# =========================================================
# OPTIONAL: replace this with a real Emby API call
# =========================================================
def get_emby_users() -> List[str]:
"""
Replace this function with a real Emby API call if you want.
For now it returns the USERS list above.
"""
return USERS
# Example real API version if you want it later:
#
# import requests
#
# def get_emby_users() -> List[str]:
# emby_url = "http://YOUR-EMBY:8096"
# api_key = "YOUR_API_KEY"
# headers = {"X-Emby-Token": api_key}
# r = requests.get(f"{emby_url}/Users", headers=headers, timeout=30)
# r.raise_for_status()
# data = r.json()
# return [u.get("Name", "").strip() for u in data if u.get("Name")]
# =========================================================
# MAIN
# =========================================================
def main() -> None:
ensure_output_dir(OUTPUT_DIR)
users = get_emby_users()
if not users:
print("No users found.")
return
print(f"Generating avatars for {len(users)} users...\n")
for user in users:
avatar = create_avatar(user, IMAGE_SIZE)
saved = save_avatar(avatar, user, OUTPUT_DIR)
print(f"Created: {saved}")
print(f"\nDone. Files saved to: {os.path.abspath(OUTPUT_DIR)}")
if __name__ == "__main__":
main()