# -*- 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")