eb8dbf66a1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
193 lines
6.5 KiB
Python
193 lines
6.5 KiB
Python
"""Genera un avatar circular de iniciales tipo foto de perfil.
|
|
|
|
Dibuja un circulo relleno de color con 1-2 iniciales blancas centradas sobre
|
|
fondo transparente y lo exporta como PNG cuadrado. El color de fondo se puede
|
|
fijar explicitamente o derivar de forma DETERMINISTA del texto (mismo texto ->
|
|
mismo color siempre), lo que produce avatares reconocibles y distintos por
|
|
nombre sin necesidad de una imagen real.
|
|
|
|
Funciones publicas reutilizables:
|
|
derive_initials — extrae 1-2 iniciales en mayusculas de un nombre
|
|
derive_bg_color — mapea un texto a un color de paleta de forma estable
|
|
"""
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
# Fuente bold preinstalada en este Linux. Si no existe, se cae al default de PIL.
|
|
DEFAULT_FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
|
|
|
# Paleta agradable tipo Tailwind 500-600 (12 colores). El indice se elige de
|
|
# forma determinista a partir del hash del texto -> mismo texto, mismo color.
|
|
PALETTE = [
|
|
"#0ea5e9", # sky-500
|
|
"#10b981", # emerald-500
|
|
"#8b5cf6", # violet-500
|
|
"#f59e0b", # amber-500
|
|
"#f43f5e", # rose-500
|
|
"#6366f1", # indigo-500
|
|
"#14b8a6", # teal-500
|
|
"#f97316", # orange-500
|
|
"#d946ef", # fuchsia-500
|
|
"#84cc16", # lime-500
|
|
"#06b6d4", # cyan-500
|
|
"#ef4444", # red-500
|
|
]
|
|
|
|
# Factor de supersampling para antialiasing: se renderiza a NxN veces el tamano
|
|
# final y se reduce con LANCZOS para obtener bordes suaves.
|
|
_SUPERSAMPLE = 4
|
|
|
|
# Margen del circulo respecto al canvas (~4% por lado).
|
|
_MARGIN_RATIO = 0.04
|
|
|
|
# Tamano de fuente como fraccion del lado del canvas.
|
|
_FONT_RATIO = 0.46
|
|
|
|
|
|
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
|
|
"""Convierte un color "#RRGGBB" (o "RRGGBB") a una tupla RGB."""
|
|
h = h.lstrip("#")
|
|
if len(h) != 6:
|
|
raise ValueError(f"color hex invalido, se espera #RRGGBB: {h!r}")
|
|
return tuple(int(h[i: i + 2], 16) for i in (0, 2, 4))
|
|
|
|
|
|
def derive_initials(text: str) -> str:
|
|
"""Extrae 1-2 iniciales en mayusculas a partir de un nombre.
|
|
|
|
Trocea el texto por espacios, guiones y guiones bajos. Toma la primera
|
|
letra alfabetica de los dos primeros tokens que empiecen por letra. Si solo
|
|
un token tiene letra inicial, devuelve 1 inicial. Si ninguno empieza por
|
|
letra, usa el primer caracter alfanumerico del texto completo.
|
|
|
|
Args:
|
|
text: Nombre del que derivar las iniciales (ej. "John Doe", "osint_01").
|
|
|
|
Returns:
|
|
1 o 2 caracteres en mayusculas. Cadena vacia si no hay nada alfanumerico.
|
|
"""
|
|
# Normaliza separadores a espacios.
|
|
normalized = text.replace("-", " ").replace("_", " ")
|
|
tokens = [t for t in normalized.split() if t]
|
|
|
|
initials = []
|
|
for token in tokens:
|
|
# Primera letra alfabetica del token (el token debe empezar por letra).
|
|
if token[0].isalpha():
|
|
initials.append(token[0].upper())
|
|
if len(initials) == 2:
|
|
break
|
|
|
|
if initials:
|
|
return "".join(initials)
|
|
|
|
# Fallback: primer caracter alfanumerico del texto entero.
|
|
for ch in text:
|
|
if ch.isalnum():
|
|
return ch.upper()
|
|
return ""
|
|
|
|
|
|
def derive_bg_color(text: str) -> str:
|
|
"""Mapea un texto a un color de la paleta de forma estable y determinista.
|
|
|
|
Usa md5 del texto para indexar la paleta, de modo que el mismo texto
|
|
produce siempre el mismo color entre ejecuciones y procesos.
|
|
|
|
Args:
|
|
text: Texto del que derivar el color.
|
|
|
|
Returns:
|
|
Color en formato "#RRGGBB" de la paleta interna.
|
|
"""
|
|
digest = hashlib.md5(text.encode("utf-8")).hexdigest()
|
|
idx = int(digest, 16) % len(PALETTE)
|
|
return PALETTE[idx]
|
|
|
|
|
|
def _load_font(font_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
"""Carga la fuente DejaVu Bold al tamano dado, con fallback al default."""
|
|
try:
|
|
return ImageFont.truetype(DEFAULT_FONT_PATH, font_size)
|
|
except OSError:
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def generate_initials_avatar(
|
|
text: str,
|
|
out_path: str,
|
|
bg_hex: str = "",
|
|
size: int = 256,
|
|
fg_hex: str = "#FFFFFF",
|
|
) -> str:
|
|
"""Genera un avatar circular de iniciales y lo guarda como PNG.
|
|
|
|
Dibuja un circulo relleno con `bg_hex` (o un color derivado de `text` si va
|
|
vacio) y centra 1-2 iniciales en `fg_hex` sobre el. El fondo fuera del
|
|
circulo queda transparente. Renderiza a 4x y reduce con LANCZOS para bordes
|
|
suaves.
|
|
|
|
Args:
|
|
text: Nombre del que derivar las iniciales (ej. "Aurgi", "John Doe").
|
|
out_path: Ruta de salida del PNG. El directorio padre se crea si falta.
|
|
bg_hex: Color de fondo del circulo en "#RRGGBB". Si va vacio (""), se
|
|
deriva de forma determinista de `text`.
|
|
size: Lado del PNG cuadrado en pixels (default 256).
|
|
fg_hex: Color del texto en "#RRGGBB" (default blanco "#FFFFFF").
|
|
|
|
Returns:
|
|
La misma `out_path` recibida.
|
|
|
|
Raises:
|
|
ValueError: Si algun color no tiene formato "#RRGGBB" o `size` <= 0.
|
|
OSError: Si falla la escritura del archivo a disco.
|
|
"""
|
|
if size <= 0:
|
|
raise ValueError(f"size debe ser positivo, recibido: {size!r}")
|
|
|
|
background = bg_hex if bg_hex else derive_bg_color(text)
|
|
bg_rgb = _hex_to_rgb(background)
|
|
fg_rgb = _hex_to_rgb(fg_hex)
|
|
|
|
initials = derive_initials(text)
|
|
|
|
# Render a 4x para antialiasing, luego se reduce con LANCZOS.
|
|
big = size * _SUPERSAMPLE
|
|
canvas = Image.new("RGBA", (big, big), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
margin = int(big * _MARGIN_RATIO)
|
|
circle_box = [margin, margin, big - margin - 1, big - margin - 1]
|
|
draw.ellipse(circle_box, fill=bg_rgb + (255,))
|
|
|
|
if initials:
|
|
font_size = max(1, int(big * _FONT_RATIO))
|
|
font = _load_font(font_size)
|
|
|
|
# Bounding box real del glyph (no solo ascent) para centrado optico.
|
|
bbox = draw.textbbox((0, 0), initials, font=font)
|
|
text_w = bbox[2] - bbox[0]
|
|
text_h = bbox[3] - bbox[1]
|
|
# El origen del texto se desplaza por el offset del bbox para que el
|
|
# glyph quede centrado tanto horizontal como verticalmente.
|
|
text_x = (big - text_w) / 2 - bbox[0]
|
|
text_y = (big - text_h) / 2 - bbox[1]
|
|
draw.text((text_x, text_y), initials, font=font, fill=fg_rgb + (255,))
|
|
|
|
final = canvas.resize((size, size), Image.LANCZOS)
|
|
|
|
out = Path(out_path)
|
|
if not out.is_absolute():
|
|
out = Path.cwd() / out
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
try:
|
|
final.save(out, format="PNG")
|
|
except OSError as exc:
|
|
raise OSError(f"no se pudo escribir el avatar en {out}: {exc}") from exc
|
|
|
|
return out_path
|