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()