A arte do usuário (assets/logo/*-fonte.png) passa a ser a logo do projeto. scripts/processar-logo.py tira o fundo (só o que toca a borda e os bolsões internos, com a faixa da borda refeita pela cor real do desenho, sem halo) e gera as variantes clara e escura (768 e 128 px) e o ícone do app. O app escolhe a variante pelo tema (barra lateral, topo e tela inicial), o favicon e o servidor do guara ui passam a PNG, os ícones do Tauri são regenerados e o README usa <picture> para claro e escuro. A logo SVG antiga e o gerador dela saem. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
189 lines
7.6 KiB
Python
189 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Tira o fundo das logos do Guará e gera as variantes usadas no app, no README e nos ícones.
|
|
|
|
Entrada (arte original, fundo sólido): assets/logo/guara-dark-fonte.png e guara-light-fonte.png.
|
|
Saída: apps/gui/ui/guara-dark.png e guara-light.png (768 px, fundo transparente, recortadas e
|
|
quadradas), guara-dark-s.png e guara-light-s.png (128 px, para barra lateral e favicon) e
|
|
apps/gui/src-tauri/icons/source.png (ícone do app, sobre o fundo escuro da arte; o CI gera o
|
|
resto com `tauri icon`).
|
|
|
|
O fundo é uma cor sólida: sai o que é parecido com ela e está ligado à borda da imagem (sombras
|
|
escuras dentro do desenho ficam). Na faixa de 3 px ao redor, a cor do desenho é estimada pelos
|
|
vizinhos internos e a transparência vem de onde o pixel cai entre o fundo e essa cor, o que evita
|
|
halo escuro ou claro.
|
|
|
|
Uso: python3 scripts/processar-logo.py
|
|
"""
|
|
import os
|
|
from PIL import Image
|
|
|
|
RAIZ = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
|
|
UI = os.path.join(RAIZ, "apps/gui/ui")
|
|
ICONES = os.path.join(RAIZ, "apps/gui/src-tauri/icons")
|
|
# (arquivo de origem, cor do fundo, distância que ainda conta como fundo certo, não usado)
|
|
VARIANTES = {
|
|
"dark": ("assets/logo/guara-dark-fonte.png", (10, 16, 28), 9.0, None),
|
|
"light": ("assets/logo/guara-light-fonte.png", (254, 249, 240), 7.0, None),
|
|
}
|
|
|
|
|
|
def tirar_fundo(path, bg, d0, _d1=None):
|
|
"""Remove o fundo conectado à borda e refina a faixa de 3 px ao redor do desenho.
|
|
|
|
1. Fundo certo: pixels a menos de `d0` da cor do fundo, ligados à borda da imagem ou formando
|
|
um bolsão grande (as frestas entre as pernas). Sombras escuras do desenho, que passam de
|
|
`d0`, não são tocadas.
|
|
2. Faixa de 3 px ao redor do fundo (a borda suavizada da arte): a cor verdadeira do desenho é
|
|
estimada pelos vizinhos internos mais próximos, e a opacidade sai da projeção do pixel
|
|
entre a cor do fundo e essa cor. Sem halo escuro ou claro.
|
|
3. O resto fica como está, opaco.
|
|
"""
|
|
from collections import deque
|
|
|
|
im = Image.open(path).convert("RGB")
|
|
w, h = im.size
|
|
px = im.load()
|
|
br, bgc, bb = bg
|
|
d0sq = d0 * d0
|
|
|
|
def perto(x, y):
|
|
r, g, b = px[x, y]
|
|
return (r - br) ** 2 + (g - bgc) ** 2 + (b - bb) ** 2 < d0sq
|
|
|
|
# 1) fundo certo: flood fill a partir da borda
|
|
fundo = bytearray(w * h)
|
|
visto = bytearray(w * h)
|
|
fila = deque()
|
|
for x in range(w):
|
|
for y in (0, h - 1):
|
|
if perto(x, y) and not fundo[y * w + x]:
|
|
fundo[y * w + x] = 1
|
|
fila.append((x, y))
|
|
for y in range(h):
|
|
for x in (0, w - 1):
|
|
if perto(x, y) and not fundo[y * w + x]:
|
|
fundo[y * w + x] = 1
|
|
fila.append((x, y))
|
|
while fila:
|
|
x, y = fila.popleft()
|
|
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
|
|
if 0 <= nx < w and 0 <= ny < h and not fundo[ny * w + nx] and perto(nx, ny):
|
|
fundo[ny * w + nx] = 1
|
|
fila.append((nx, ny))
|
|
|
|
# 1b) bolsões de fundo dentro do desenho (frestas entre as pernas, por exemplo): regiões grandes
|
|
# com a cor do fundo que não tocam a borda. Manchas pequenas (brilho do olho) ficam.
|
|
for i0 in range(w * h):
|
|
if fundo[i0] or visto[i0]:
|
|
continue
|
|
x0, y0 = i0 % w, i0 // w
|
|
if not perto(x0, y0):
|
|
continue
|
|
comp = [(x0, y0)]
|
|
visto[i0] = 1
|
|
k = 0
|
|
while k < len(comp):
|
|
x, y = comp[k]
|
|
k += 1
|
|
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
|
|
if 0 <= nx < w and 0 <= ny < h and not visto[ny * w + nx] and not fundo[ny * w + nx] and perto(nx, ny):
|
|
visto[ny * w + nx] = 1
|
|
comp.append((nx, ny))
|
|
if len(comp) >= 60:
|
|
for x, y in comp:
|
|
fundo[y * w + x] = 1
|
|
|
|
# 2) distância (em passos de 8 vizinhos) até o fundo, só até 3
|
|
dist = bytearray([255]) * (w * h)
|
|
fila = deque()
|
|
for i in range(w * h):
|
|
if fundo[i]:
|
|
dist[i] = 0
|
|
fila.append((i % w, i // w))
|
|
while fila:
|
|
x, y = fila.popleft()
|
|
d = dist[y * w + x]
|
|
if d >= 3:
|
|
continue
|
|
for dx in (-1, 0, 1):
|
|
for dy in (-1, 0, 1):
|
|
nx, ny = x + dx, y + dy
|
|
if 0 <= nx < w and 0 <= ny < h and dist[ny * w + nx] == 255:
|
|
dist[ny * w + nx] = d + 1
|
|
fila.append((nx, ny))
|
|
|
|
out = Image.new("RGBA", (w, h))
|
|
po = out.load()
|
|
R = 6
|
|
for y in range(h):
|
|
for x in range(w):
|
|
d = dist[y * w + x]
|
|
if d == 0:
|
|
po[x, y] = (0, 0, 0, 0)
|
|
elif d == 255:
|
|
r, g, b = px[x, y]
|
|
po[x, y] = (r, g, b, 255)
|
|
else: # faixa da borda
|
|
soma = [0, 0, 0]
|
|
n = 0
|
|
melhor = 99
|
|
for yy in range(max(0, y - R), min(h, y + R + 1)):
|
|
for xx in range(max(0, x - R), min(w, x + R + 1)):
|
|
if dist[yy * w + xx] == 255:
|
|
q = (xx - x) ** 2 + (yy - y) ** 2
|
|
if q < melhor:
|
|
melhor, soma, n = q, list(px[xx, yy]), 1
|
|
elif q == melhor:
|
|
c = px[xx, yy]
|
|
soma = [soma[0] + c[0], soma[1] + c[1], soma[2] + c[2]]
|
|
n += 1
|
|
r, g, b = px[x, y]
|
|
if n == 0:
|
|
po[x, y] = (r, g, b, 255)
|
|
continue
|
|
f = (soma[0] / n, soma[1] / n, soma[2] / n)
|
|
vx, vy, vz = f[0] - br, f[1] - bgc, f[2] - bb
|
|
nn = vx * vx + vy * vy + vz * vz
|
|
a = ((r - br) * vx + (g - bgc) * vy + (b - bb) * vz) / nn if nn else 1.0
|
|
a = 0.0 if a < 0.02 else 1.0 if a > 0.98 else a
|
|
po[x, y] = (round(f[0]), round(f[1]), round(f[2]), round(a * 255))
|
|
return out
|
|
|
|
|
|
def recortar_quadrado(im, margem=0.04, lado=1024):
|
|
box = im.getchannel("A").point(lambda v: 255 if v > 8 else 0).getbbox()
|
|
im = im.crop(box)
|
|
w, h = im.size
|
|
s = round(max(w, h) * (1 + 2 * margem))
|
|
quadro = Image.new("RGBA", (s, s), (0, 0, 0, 0))
|
|
quadro.paste(im, ((s - w) // 2, (s - h) // 2), im)
|
|
return quadro.resize((lado, lado), Image.LANCZOS)
|
|
|
|
|
|
def main():
|
|
feitas = {}
|
|
for nome, (src, bg, d0, _) in VARIANTES.items():
|
|
im = recortar_quadrado(tirar_fundo(os.path.join(RAIZ, src), bg, d0))
|
|
feitas[nome] = im
|
|
for sufixo, lado in (("", 768), ("-s", 128)):
|
|
im.resize((lado, lado), Image.LANCZOS).save(
|
|
os.path.join(UI, f"guara-{nome}{sufixo}.png"), optimize=True
|
|
)
|
|
print(f"guara-{nome}.png e guara-{nome}-s.png")
|
|
# ícone do app: a arte escura sobre o fundo escuro da própria arte, com cantos arredondados
|
|
lado = 1024
|
|
fundo = Image.new("RGBA", (lado, lado), VARIANTES["dark"][1] + (255,))
|
|
arte = feitas["dark"].resize((round(lado * 0.82),) * 2, Image.LANCZOS)
|
|
fundo.paste(arte, ((lado - arte.width) // 2, (lado - arte.height) // 2), arte)
|
|
mascara = Image.new("L", (lado, lado), 0)
|
|
from PIL import ImageDraw
|
|
ImageDraw.Draw(mascara).rounded_rectangle((0, 0, lado - 1, lado - 1), radius=round(lado * 0.2237), fill=255)
|
|
icone = Image.new("RGBA", (lado, lado), (0, 0, 0, 0))
|
|
icone.paste(fundo, (0, 0), mascara)
|
|
icone.save(os.path.join(ICONES, "source.png"), optimize=True)
|
|
print("icons/source.png", icone.size)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|