Files
ChemaVXandClaude Opus 5 a43bbc752d
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
avatar del bot: búho con gafas, generado y aplicado por API
Mismo patrón que hermes-bot y roswell-corpus: la foto de perfil se genera con
PIL (dibujado a 4× y reducido) en vez de ser un PNG opaco en el repo. Telegram
recorta en círculo y lo enseña a 48 px en la lista de chats, así que el dibujo
es una silueta y no una ilustración.

Las gafas son el único guiño a la investigación que sobrevive al tamaño chico
—una lupa o un libro se vuelven una mancha—, y la montura va en tono oscuro:
en ámbar sobre ámbar el aro se fundía con el plumaje y a 48 px desaparecía.

Documentado en el README, con el gotcha de `setMyProfilePhoto`: el parámetro
`photo` no admite el fichero suelto (responde `photo isn't specified`), hay que
pasarle un InputProfilePhoto que apunte al adjunto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:57:18 +00:00

147 lines
5.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Genera el avatar de @chemavx_researchowl_bot: búho con gafas de lectura.
Mismo método que los avatares de hermes-bot y roswell-corpus: se dibuja a 4× y
se reduce, que es la forma barata de tener bordes suaves sin antialiasing
propio en PIL. Telegram recorta en CÍRCULO, así que todo lo que importa vive
dentro del círculo inscrito.
Por qué gafas y no un búho a secas: el búho solo dice "búho". El aro sobre cada
ojo, con su puente, se lee como gafas de leer al tamaño grande y como disco
facial al pequeño, y ninguna de las dos lecturas es errónea. Es el único guiño
a la investigación que sobrevive a 48 px; una lupa o un libro se convierten en
una mancha.
python3 make_avatar.py # deja el PNG en assets/avatar.png
"""
import math
import os
from PIL import Image, ImageDraw, ImageFilter
SIZE = 640
SS = 4 # supersampling
W = SIZE * SS
CX = CY = W // 2
AMBAR = (236, 173, 84) # plumaje
AMBAR_OSC = (184, 116, 52) # alas
MONTURA = (46, 26, 30) # gafas y pico: tienen que contrastar con el plumaje
CREMA = (255, 246, 230) # ojos
TINTA = (30, 16, 36) # pupilas y huecos
AQUI = os.path.dirname(os.path.abspath(__file__))
def fondo() -> Image.Image:
"""Degradado radial ciruela, dibujado pequeño y ampliado."""
n = 128
g = Image.new("RGB", (n, n))
px = g.load()
for y in range(n):
for x in range(n):
d = min(1.0, math.hypot(x - n / 2, y - n / 2) / (n / 2 * 1.10))
px[x, y] = (int(56 + (11 - 56) * d),
int(33 + (6 - 33) * d),
int(66 + (16 - 66) * d))
return g.resize((W, W), Image.BICUBIC).convert("RGBA")
def tinta(mascara: Image.Image, color) -> Image.Image:
"""Convierte una máscara L en una capa RGBA del color dado."""
capa = Image.new("RGBA", mascara.size, color + (0,))
capa.putalpha(mascara)
return capa
def caja(x0, y0, x1, y1):
"""Rectángulo en fracciones de W → píxeles."""
return [x0 * W, y0 * W, x1 * W, y1 * W]
def cuerpo() -> Image.Image:
"""Silueta completa: penachos + cuerpo. Se reutiliza como máscara."""
m = Image.new("L", (W, W), 0)
d = ImageDraw.Draw(m)
for signo in (-1, 1):
d.polygon([
(CX + signo * 0.262 * W, 0.352 * W),
(CX + signo * 0.108 * W, 0.300 * W),
(CX + signo * 0.196 * W, 0.148 * W),
], fill=255)
d.ellipse(caja(0.20, 0.28, 0.80, 0.84), fill=255)
return m
def alas(mascara: Image.Image) -> Image.Image:
"""Dos alas plegadas a los lados, recortadas contra la silueta.
Van recortadas y no dibujadas a pelo porque el ala tiene que morir justo en
el borde del cuerpo: si asoma, el búho deja de tener contorno limpio y a
tamaño pequeño se convierte en un borrón con orejas.
"""
capa = Image.new("RGBA", (W, W), (0, 0, 0, 0))
d = ImageDraw.Draw(capa)
for signo in (-1, 1):
cx = CX + signo * 0.205 * W
d.ellipse([cx - 0.115 * W, 0.455 * W, cx + 0.115 * W, 0.855 * W],
fill=AMBAR_OSC + (255,))
return Image.composite(capa, Image.new("RGBA", (W, W), (0, 0, 0, 0)), mascara)
def cara() -> Image.Image:
"""Ojos, gafas y pico."""
capa = Image.new("RGBA", (W, W), (0, 0, 0, 0))
d = ImageDraw.Draw(capa)
y = 0.425
r = 0.105
grosor = int(W * 0.019)
for signo in (-1, 1):
cx = CX + signo * 0.125 * W
d.ellipse([cx - r * W, (y - r) * W, cx + r * W, (y + r) * W],
fill=CREMA + (255,))
d.ellipse([cx - r * W, (y - r) * W, cx + r * W, (y + r) * W],
outline=MONTURA + (255,), width=grosor)
pr = 0.046 * W
d.ellipse([cx - pr, y * W - pr, cx + pr, y * W + pr], fill=TINTA + (255,))
br = 0.017 * W # reflejo: sin él la mirada es de muñeco
bx, by = cx - 0.020 * W, y * W - 0.030 * W
d.ellipse([bx - br, by - br, bx + br, by + br], fill=(255, 255, 255, 210))
# puente de las gafas
d.rectangle([CX - 0.022 * W, y * W - grosor / 2, CX + 0.022 * W, y * W + grosor / 2],
fill=MONTURA + (255,))
d.polygon([(CX - 0.042 * W, 0.500 * W), (CX + 0.042 * W, 0.500 * W),
(CX, 0.575 * W)], fill=MONTURA + (255,))
return capa
def main():
img = fondo()
silueta = cuerpo()
# resplandor: la silueta desenfocada por debajo, para despegar al búho del
# fondo cuando el icono se ve pequeño
brillo = tinta(silueta.filter(ImageFilter.GaussianBlur(W * 0.030)), AMBAR)
img.alpha_composite(Image.blend(Image.new("RGBA", (W, W), (0, 0, 0, 0)), brillo, 0.5))
img.alpha_composite(tinta(silueta, AMBAR))
img.alpha_composite(alas(silueta))
img.alpha_composite(cara())
# aro fino: le da borde al icono cuando Telegram lo recorta en círculo
m = int(W * 0.045)
ImageDraw.Draw(img).ellipse([m, m, W - m, W - m],
outline=AMBAR + (110,), width=int(W * 0.008))
salida = os.path.join(AQUI, "avatar.png")
img.convert("RGB").resize((SIZE, SIZE), Image.LANCZOS).save(salida, "PNG")
print(salida)
if __name__ == "__main__":
main()