diff --git a/README.md b/README.md index bce7a18..877d071 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,32 @@ git add . && git commit -m "feat: add researchowl" && git push - Set `MAX_DEPTH` to 1-2 - 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 - Uses **qwen2.5:7b** (scoring) and **bge-m3** (embeddings) on your existing Ollama — zero API cost diff --git a/assets/avatar.png b/assets/avatar.png new file mode 100644 index 0000000..3ef9edc Binary files /dev/null and b/assets/avatar.png differ diff --git a/assets/make_avatar.py b/assets/make_avatar.py new file mode 100644 index 0000000..4dc8801 --- /dev/null +++ b/assets/make_avatar.py @@ -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()