127 lines
4.8 KiB
Python
127 lines
4.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
生成 Bark 推送用的图标与告警配图(2026-09-13,v2 修正版)
|
||
产物:
|
||
icon.png 512x512 通知图标
|
||
down.png 1200x630 服务异常告警配图(平线 = 心跳停止)
|
||
up.png 1200x630 服务恢复配图(正常心电)
|
||
要点:所有坐标必须落在画布内;波形与文字分区,互不重叠。
|
||
"""
|
||
import os
|
||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||
|
||
OUT = r"E:\Project\workbuddy.xpcool.com\.workbuddy\tmp\bark-assets"
|
||
os.makedirs(OUT, exist_ok=True)
|
||
|
||
FONT_BOLD = r"C:\Windows\Fonts\msyhbd.ttc"
|
||
FONT_REG = r"C:\Windows\Fonts\msyh.ttc"
|
||
|
||
|
||
def vgrad(size, top, bottom):
|
||
"""竖向线性渐变"""
|
||
w, h = size
|
||
base = Image.new("RGB", (1, h))
|
||
d = ImageDraw.Draw(base)
|
||
for y in range(h):
|
||
t = y / max(h - 1, 1)
|
||
d.point((0, y), tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3)))
|
||
return base.resize((w, h), Image.BILINEAR)
|
||
|
||
|
||
def rounded_mask(size, radius):
|
||
m = Image.new("L", size, 0)
|
||
ImageDraw.Draw(m).rounded_rectangle([0, 0, size[0] - 1, size[1] - 1], radius=radius, fill=255)
|
||
return m
|
||
|
||
|
||
# 归一化波形(v 为相对基线的倍数,正=向上);幅度一律控制在 [-1, 1] 内
|
||
SHAPE_NORMAL = [
|
||
(0.00, 0.00), (0.12, 0.00), (0.16, 0.12), (0.20, 0.00),
|
||
(0.30, 0.00), (0.33, 0.18), (0.36, 1.00), (0.40, -0.42),
|
||
(0.44, 0.00), (0.58, 0.00), (0.64, 0.16), (0.70, 0.00), (1.00, 0.00),
|
||
]
|
||
SHAPE_FLAT = [ # 心跳停止:几乎一条直线,末尾一点微弱扰动
|
||
(0.00, 0.00), (0.62, 0.00), (0.68, -0.10), (0.74, 0.06), (1.00, 0.00),
|
||
]
|
||
|
||
|
||
def ecg_points(x0, x1, ybase, amp, shape, cycles=1):
|
||
pts, span = [], x1 - x0
|
||
for c in range(cycles):
|
||
for t, v in shape:
|
||
pts.append((x0 + (c + t) * span / cycles, ybase - v * amp))
|
||
ymin = min(p[1] for p in pts)
|
||
ymax = max(p[1] for p in pts)
|
||
assert amp + 1 < ybase < 630 - amp, f"基线 {ybase} 超出安全范围"
|
||
return pts, ymin, ymax
|
||
|
||
|
||
def draw_glow_line(img, pts, color, width, glow=16):
|
||
"""带柔和外发光的折线"""
|
||
layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
||
ImageDraw.Draw(layer).line(pts, fill=color + (105,), width=width + glow * 2, joint="curve")
|
||
img.alpha_composite(layer.filter(ImageFilter.GaussianBlur(glow)))
|
||
ImageDraw.Draw(img).line(pts, fill=color + (255,), width=width, joint="curve")
|
||
|
||
|
||
def center_text(d, cx, y, text, font, fill):
|
||
l, t, r, b = d.textbbox((0, 0), text, font=font)
|
||
d.text((cx - (r - l) / 2 - l, y), text, font=font, fill=fill)
|
||
|
||
|
||
# ---------------- 1. 通知图标 512x512 ----------------
|
||
def make_icon():
|
||
S = 512
|
||
img = vgrad((S, S), (30, 27, 75), (15, 23, 42)).convert("RGBA")
|
||
img.putalpha(rounded_mask((S, S), 116))
|
||
|
||
d = ImageDraw.Draw(img)
|
||
d.rounded_rectangle([14, 14, S - 15, S - 15], radius=102, outline=(99, 102, 241, 95), width=3)
|
||
|
||
pts, _, _ = ecg_points(80, S - 80, 272, 128, SHAPE_NORMAL, cycles=1)
|
||
draw_glow_line(img, pts, (34, 211, 238), 20, glow=15)
|
||
d.ellipse([65, 257, 95, 287], fill=(165, 243, 252, 255))
|
||
|
||
img.convert("RGB").save(os.path.join(OUT, "icon.png"), optimize=True)
|
||
print("icon.png OK")
|
||
|
||
|
||
# ---------------- 2. 告警配图 1200x630 ----------------
|
||
def make_banner(name, top, bottom, line, dot, title, subtitle, flat):
|
||
W, H = 1200, 630
|
||
img = vgrad((W, H), top, bottom).convert("RGBA")
|
||
|
||
halo = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||
ImageDraw.Draw(halo).ellipse([700, -260, 1460, 500], fill=dot + (52,))
|
||
img.alpha_composite(halo.filter(ImageFilter.GaussianBlur(95)))
|
||
|
||
# 波形放在上半区,基线 175,振幅 62 → 范围 113~237,与下方文字完全分离
|
||
shape = SHAPE_FLAT if flat else SHAPE_NORMAL
|
||
pts, ymin, ymax = ecg_points(110, W - 110, 175, 62, shape, cycles=1 if flat else 3)
|
||
draw_glow_line(img, pts, line, 11, glow=13)
|
||
|
||
# 基线参考 + 底部细分隔线
|
||
d = ImageDraw.Draw(img)
|
||
d.line([(110, 175), (W - 110, 175)], fill=line + (55,), width=2)
|
||
d.line([(110, 300), (W - 110, 300)], fill=line + (70,), width=2)
|
||
|
||
center_text(d, W / 2, 350, title, ImageFont.truetype(FONT_BOLD, 96), (255, 255, 255, 255))
|
||
center_text(d, W / 2, 480, subtitle, ImageFont.truetype(FONT_REG, 32), line + (230,))
|
||
|
||
# 左下角状态点
|
||
d.ellipse([112, 515, 148, 551], fill=dot + (255,))
|
||
|
||
img.convert("RGB").save(os.path.join(OUT, f"{name}.png"), optimize=True)
|
||
print(f"{name}.png OK (波形 y: {ymin:.0f}~{ymax:.0f})")
|
||
|
||
|
||
make_icon()
|
||
make_banner("down", (127, 29, 29), (43, 6, 6), (252, 165, 165), (239, 68, 68),
|
||
"服务异常", "UPTIME KUMA · SERVICE DOWN", flat=True)
|
||
make_banner("up", (6, 78, 59), (2, 34, 25), (110, 231, 183), (16, 185, 129),
|
||
"服务已恢复", "UPTIME KUMA · SERVICE RECOVERED", flat=False)
|
||
|
||
print("---")
|
||
for f in sorted(os.listdir(OUT)):
|
||
print(f"{f} {os.path.getsize(os.path.join(OUT, f))} bytes")
|