398 lines
11 KiB
Python
398 lines
11 KiB
Python
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()
|