Files
fn_registry/python/functions/infra/generate_initials_avatar.py
T
egutierrez eb8dbf66a1 feat(infra): auto-commit con 88 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 00:16:46 +02:00

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