avatar del bot: búho con gafas, generado y aplicado por API
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
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>
This commit is contained in:
@@ -100,6 +100,32 @@ git add . && git commit -m "feat: add researchowl" && git push
|
|||||||
- Set `MAX_DEPTH` to 1-2
|
- Set `MAX_DEPTH` to 1-2
|
||||||
- Higher `QUALITY_THRESHOLD` to 0.6
|
- Higher `QUALITY_THRESHOLD` to 0.6
|
||||||
|
|
||||||
|
## Bot avatar
|
||||||
|
|
||||||
|
The profile picture of `@chemavx_researchowl_bot` is not an opaque binary
|
||||||
|
checked into the repo: `assets/make_avatar.py` draws it with PIL at 4× and
|
||||||
|
scales it down, so the emblem can be retouched without hunting for an original.
|
||||||
|
Telegram crops avatars to a **circle**, so everything that matters lives inside
|
||||||
|
the inscribed circle; verified legible at 48 px.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 assets/make_avatar.py # writes assets/avatar.png
|
||||||
|
```
|
||||||
|
|
||||||
|
It is applied **over the API with the token from the secret, no BotFather**.
|
||||||
|
Watch out for `setMyProfilePhoto`: its `photo` parameter is not the file, it is
|
||||||
|
an `InputProfilePhoto` object pointing at the attachment. Posting the file on
|
||||||
|
its own gets you a baffling `photo isn't specified`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOK=$(kubectl get secret researchowl-secrets-infisical -n researchowl \
|
||||||
|
-o jsonpath='{.data.telegram-bot-token}' | base64 -d)
|
||||||
|
curl -s -F 'photo={"type":"static","photo":"attach://av"}' \
|
||||||
|
-F "av=@assets/avatar.png" \
|
||||||
|
"https://api.telegram.org/bot$TOK/setMyProfilePhoto"
|
||||||
|
unset TOK
|
||||||
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Uses **qwen2.5:7b** (scoring) and **bge-m3** (embeddings) on your existing Ollama — zero API cost
|
- Uses **qwen2.5:7b** (scoring) and **bge-m3** (embeddings) on your existing Ollama — zero API cost
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,146 @@
|
|||||||
|
#!/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()
|
||||||
Reference in New Issue
Block a user