Homelabtoolkit v2
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user