Files
HeXi/hexi/plugins/nonebot_plugin_steam_info/draw.py
T
sansenhoshiandClaude Code 6af60a7d55 feat(steam-info): 资料页状态/最近游玩/ban 描述增强,时长图渲染与定时任务迁移
- steam.py / draw.py / handlers 扩充:个人资料页状态构建、最近游玩、
  游戏头像与中文游戏名查询、ban 描述
- 定时播报迁入 hexi.core.scheduler
- 新增 tests/test_steam_profile_parse.py(资料 HTML/XML 解析)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:22 +08:00

1428 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import numpy as np
from functools import lru_cache
from io import BytesIO
from pathlib import Path
from typing import List, Dict, Tuple, Optional
from colorsys import rgb_to_hsv, hsv_to_rgb
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
from .utils import hex_to_rgb
from .models import DrawPlayerStatusData, Achievements, ProfileCardExtra
WIDTH = 400
PARENT_AVATAR_SIZE = 72
MEMBER_AVATAR_SIZE = 50
# 游戏图标与玩家信息的统一水平坐标
GAME_ICON_X = 12
GAME_ICON_SIZE = 26
GAME_NAME_X = GAME_ICON_X + GAME_ICON_SIZE + 12 # 50:图标和游戏/玩家信息对齐点
PLAYER_AVATAR_X = GAME_NAME_X - 2 # 48:头像带边框,内部头像落在 GAME_NAME_X
PLAYER_NAME_X = GAME_NAME_X + MEMBER_AVATAR_SIZE + 18 # 118:玩家昵称/游戏名/状态统一左对齐
unknown_avatar_path = Path(__file__).parent / "res/unknown_avatar.jpg"
parent_status_path = Path(__file__).parent / "res/parent_status.png"
friends_search_path = Path(__file__).parent / "res/friends_search.png"
busy_path = Path(__file__).parent / "res/busy.png"
zzz_online_path = Path(__file__).parent / "res/zzz_online.png"
zzz_gaming_path = Path(__file__).parent / "res/zzz_gaming.png"
gaming_path = Path(__file__).parent / "res/gaming.png"
font_regular_path = None
font_light_path = None
font_bold_path = None
def set_font_paths(regular_path, light_path, bold_path):
global font_regular_path, font_light_path, font_bold_path
base_dir = Path().cwd()
font_regular_path = str((base_dir / regular_path).resolve())
font_light_path = str((base_dir / light_path).resolve())
font_bold_path = str((base_dir / bold_path).resolve())
def check_font():
if not Path(font_regular_path).exists():
raise FileNotFoundError(f"Font file {font_regular_path} not found.")
if not Path(font_light_path).exists():
raise FileNotFoundError(f"Font file {font_light_path} not found.")
if not Path(font_bold_path).exists():
raise FileNotFoundError(f"Font file {font_bold_path} not found.")
@lru_cache(maxsize=None)
def get_font(path: str, size: int) -> ImageFont.FreeTypeFont:
"""按 (路径, 字号) 缓存字体,避免每次绘制都重新解析字体文件"""
return ImageFont.truetype(path, size)
personastate_colors = {
0: (hex_to_rgb("969697"), hex_to_rgb("656565")),
1: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
2: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
3: (hex_to_rgb("45778e"), hex_to_rgb("365969")),
4: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
5: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
6: (hex_to_rgb("6dcef5"), hex_to_rgb("4c91ac")),
}
def avatar_with_border(
avatar: Image.Image, border_width: int = 2, border_color=(255, 255, 255)
) -> Image.Image:
"""给头像添加边框,返回比原头像大 2*border_width 的图像"""
width, height = avatar.size
bordered = Image.new(
"RGB", (width + border_width * 2, height + border_width * 2), border_color
)
bordered.paste(avatar, (border_width, border_width))
return bordered
def vertically_concatenate_images(images: List[Image.Image]) -> Image.Image:
widths, heights = zip(*(i.size for i in images))
total_width = max(widths)
total_height = sum(heights)
new_image = Image.new("RGB", (total_width, total_height))
y_offset = 0
for image in images:
new_image.paste(image, (0, y_offset))
y_offset += image.size[1]
return new_image
def _truncate_with_ellipsis(
draw: ImageDraw.ImageDraw, text: str, font, max_width: int
) -> str:
"""将文本按 max_width 省略号截断,返回最终绘制文本"""
ellipsis = "…"
truncated = ""
for ch in text:
if draw.textlength(truncated + ch + ellipsis, font=font) > max_width:
break
truncated += ch
return truncated + ellipsis
def _fit_name_nick(
draw: ImageDraw.ImageDraw,
name: str,
nickname: str,
name_font,
nick_font,
gap: int,
max_width: int,
) -> Tuple[str, str]:
"""在 max_width 内排布 [主名][群昵称],返回 (主名绘制文本, 群昵称绘制文本)
主名优先: 主名过长时省略号截断主名,不再画群昵称;
主名放得下时,群昵称只取剩余宽度,放不下则返回空串。
"""
if draw.textlength(name, font=name_font) > max_width:
return _truncate_with_ellipsis(draw, name, name_font, max_width), ""
if not nickname:
return name, ""
available = max_width - draw.textlength(name, font=name_font) - gap
if available <= 0:
return name, ""
if draw.textlength(nickname, font=nick_font) <= available:
return name, nickname
return name, _truncate_with_ellipsis(draw, nickname, nick_font, available)
def draw_start_gaming(
avatar: Image.Image, friend_name: str, game_name: str, nickname: str = None
):
canvas = Image.open(gaming_path)
canvas.paste(avatar.resize((66, 66), Image.BICUBIC), (15, 20))
# 绘制名称:steam 昵称优先,群昵称小字挂尾,超宽省略号截断,避免画出图片外
draw = ImageDraw.Draw(canvas)
name_font = get_font(font_regular_path, 19)
nick_font = get_font(font_light_path, 13)
name_drawn, nick_drawn = _fit_name_nick(
draw, friend_name, nickname or "", name_font, nick_font, 6,
canvas.width - 104 - 10,
)
draw.text((104, 14), name_drawn, font=name_font, fill=hex_to_rgb("e3ffc2"))
if nick_drawn:
draw.text(
(104 + draw.textlength(name_drawn, font=name_font) + 6, 15),
nick_drawn,
font=nick_font,
fill=hex_to_rgb("969696"),
)
# 绘制"正在玩"
draw.text(
(103, 42),
"正在玩",
font=get_font(font_regular_path, 17),
fill=hex_to_rgb("969696"),
)
# 绘制游戏名称
draw.text(
(104, 66),
game_name,
font=get_font(font_bold_path, 14),
fill=hex_to_rgb("91c257"),
)
return canvas
def draw_parent_status(parent_avatar: Image.Image, parent_name: str) -> Image.Image:
parent_avatar = parent_avatar.resize(
(PARENT_AVATAR_SIZE, PARENT_AVATAR_SIZE), Image.BICUBIC
)
canvas = Image.open(parent_status_path).resize((WIDTH, 120), Image.BICUBIC)
draw = ImageDraw.Draw(canvas)
# 在左下角 (16, 16) 处绘制头像
avatar_height = 120 - 16 - PARENT_AVATAR_SIZE
canvas.paste(avatar_with_border(parent_avatar), (14, avatar_height - 2))
# 绘制名称
draw.text(
(16 + PARENT_AVATAR_SIZE + 16, avatar_height + 12),
parent_name,
font=get_font(font_bold_path, 20),
fill=hex_to_rgb("6dcff6"),
)
# 绘制状态
draw.text(
(16 + PARENT_AVATAR_SIZE + 16, avatar_height + 20 + 16),
"在线",
font=get_font(font_light_path, 18),
fill=hex_to_rgb("4c91ac"),
)
return canvas
def draw_friends_search() -> Image.Image:
canvas = Image.new("RGB", (WIDTH, 50), hex_to_rgb("434953"))
friends_search = Image.open(friends_search_path)
canvas.paste(friends_search, (WIDTH - friends_search.width, 0))
draw = ImageDraw.Draw(canvas)
draw.text(
(24, 10),
"好友",
hex_to_rgb("b7ccd5"),
font=get_font(font_regular_path, 20),
)
return canvas
def draw_friend_status(
friend_avatar: Image.Image,
friend_name: str,
status: str,
personastate: int,
nickname: str = None,
) -> Image.Image:
friend_avatar = friend_avatar.resize(
(MEMBER_AVATAR_SIZE, MEMBER_AVATAR_SIZE), Image.BICUBIC
)
canvas = Image.new("RGB", (WIDTH, 64), hex_to_rgb("1e2024"))
draw = ImageDraw.Draw(canvas)
# 状态图标(忙碌/ZZZ)插在 steam 昵称与群昵称之间,先预留其宽度
name_font = get_font(font_bold_path, 20)
nick_font = get_font(font_light_path, 13)
name_x = 22 + MEMBER_AVATAR_SIZE + 18
icon = None
if personastate == 2:
icon = Image.open(busy_path)
elif personastate == 4:
icon = Image.open(zzz_online_path if status == "在线" else zzz_gaming_path)
# 让 zzz 图标与名字之间留出更明显的间距
icon_gap = 12
icon_space = icon.width + 2 * icon_gap if icon else 0
# 主名优先、群昵称小字挂尾,超宽均省略号截断,避免画出图片外
name_drawn, nick_drawn = _fit_name_nick(
draw, friend_name, nickname or "", name_font, nick_font, 6,
WIDTH - name_x - 10 - icon_space,
)
# 绘制头像
canvas.paste(avatar_with_border(friend_avatar), (20, 6))
# 忙碌/打盹沿用"在线"的配色(原实现通过递归 personastate=1 达到同样效果)
if personastate in (2, 4):
draw_state = 1
else:
draw_state = personastate
if status != "在线" and draw_state == 1:
fill = (hex_to_rgb("e3ffc2"), hex_to_rgb("8ebe56"))
elif status != "离开" and draw_state == 3:
fill = (hex_to_rgb("e3ffc2"), hex_to_rgb("8ebe56"))
else:
fill = personastate_colors[draw_state]
# 绘制名称:steam 昵称 -> 状态图标 -> 群昵称
x = name_x
draw.text((x, 12), name_drawn, font=name_font, fill=fill[0])
x += int(draw.textlength(name_drawn, font=name_font))
if icon:
canvas.paste(icon, (x + icon_gap, 18))
x += icon_gap + icon.width + icon_gap
if nick_drawn:
draw.text((x, 13), nick_drawn, font=nick_font, fill=hex_to_rgb("969696"))
# 绘制状态
draw.text(
(22 + MEMBER_AVATAR_SIZE + 16, 36),
status,
font=get_font(font_regular_path, 18),
fill=fill[1],
)
return canvas
def _draw_game_group(members: list[dict]) -> Image.Image:
"""绘制同一款游戏的分组卡片:游戏图标 + 游戏名 + 人数 + 好友列表"""
header_height = 52
game_name = members[0].get("game_name") or members[0].get("status") or "游戏中"
icon = members[0].get("game_icon")
canvas = Image.new(
"RGB",
(WIDTH, header_height + 64 * len(members)),
hex_to_rgb("1e2024"),
)
draw = ImageDraw.Draw(canvas)
# 游戏图标(Steam header.jpg 是宽图,先居中裁成正方形再缩放)
x = GAME_ICON_X
if icon:
icon_w, icon_h = icon.size
if icon_w > icon_h:
left = (icon_w - icon_h) // 2
icon = icon.crop((left, 0, left + icon_h, icon_h))
elif icon_h > icon_w:
top = (icon_h - icon_w) // 2
icon = icon.crop((0, top, icon_w, top + icon_w))
icon = icon.resize((GAME_ICON_SIZE, GAME_ICON_SIZE), Image.BICUBIC)
canvas.paste(icon, (x, (header_height - GAME_ICON_SIZE) // 2))
x += GAME_ICON_SIZE + 12
# 游戏名(与玩家信息的水平对齐点一致)
game_font = get_font(font_bold_path, 16)
max_game_width = WIDTH - GAME_NAME_X - 100
if draw.textlength(game_name, font=game_font) > max_game_width:
game_name = _truncate_with_ellipsis(draw, game_name, game_font, max_game_width)
draw.text(
(GAME_NAME_X, header_height // 2 - 10),
game_name,
font=game_font,
fill=hex_to_rgb("8f989b"),
)
# 右侧人数
count_text = f"{len(members)} 人在玩"
count_font = get_font(font_regular_path, 14)
draw.text(
(
WIDTH - draw.textlength(count_text, font=count_font) - 12,
header_height // 2 - 9,
),
count_text,
font=count_font,
fill=hex_to_rgb("67665c"),
)
# 好友行(头像与游戏名水平对齐,形成树结构)
friend_indent = GAME_NAME_X - 22 # draw_friend_status 内部头像起点为 22
for i, member in enumerate(members):
status = member["status"]
if status in ("在线", "未知", "") and member.get("game_name"):
status = member["game_name"]
row = draw_friend_status(
member["avatar"],
member["name"],
status,
member["personastate"],
member.get("nickname"),
)
canvas.paste(row, (friend_indent, header_height + 64 * i))
return canvas
def draw_single_game_friend(
game_icon: Image.Image | None,
friend_avatar: Image.Image,
friend_name: str,
game_name: str,
status: str,
personastate: int,
nickname: str = None,
) -> Image.Image:
"""单人游戏行:游戏图标 + 头像 + 昵称 + 游戏名 + 状态"""
row_height = 76
icon_size = GAME_ICON_SIZE
canvas = Image.new("RGB", (WIDTH, row_height), hex_to_rgb("1e2024"))
draw = ImageDraw.Draw(canvas)
# 游戏图标(最左,和多人组头一致)
if game_icon:
icon_w, icon_h = game_icon.size
if icon_w > icon_h:
left = (icon_w - icon_h) // 2
game_icon = game_icon.crop((left, 0, left + icon_h, icon_h))
elif icon_h > icon_w:
top = (icon_h - icon_w) // 2
game_icon = game_icon.crop((0, top, icon_w, top + icon_w))
game_icon = game_icon.resize((icon_size, icon_size), Image.BICUBIC)
canvas.paste(game_icon, (GAME_ICON_X, (row_height - icon_size) // 2))
# 头像(与多人玩家信息对齐到 GAME_NAME_X)
friend_avatar = friend_avatar.resize(
(MEMBER_AVATAR_SIZE, MEMBER_AVATAR_SIZE), Image.BICUBIC
)
canvas.paste(avatar_with_border(friend_avatar), (PLAYER_AVATAR_X, 8))
# 名称行:steam 昵称 -> 状态图标 -> 群昵称
name_font = get_font(font_bold_path, 18)
nick_font = get_font(font_light_path, 13)
name_x = PLAYER_NAME_X
icon = None
if personastate == 2:
icon = Image.open(busy_path)
elif personastate == 4:
icon = Image.open(zzz_online_path if status == "在线" else zzz_gaming_path)
icon_gap = 12
icon_space = icon.width + 2 * icon_gap if icon else 0
name_drawn, nick_drawn = _fit_name_nick(
draw, friend_name, nickname or "", name_font, nick_font, 6,
WIDTH - name_x - 10 - icon_space,
)
if personastate in (2, 4):
draw_state = 1
else:
draw_state = personastate
if status != "在线" and draw_state == 1:
fill = (hex_to_rgb("e3ffc2"), hex_to_rgb("8ebe56"))
elif status != "离开" and draw_state == 3:
fill = (hex_to_rgb("e3ffc2"), hex_to_rgb("8ebe56"))
else:
fill = personastate_colors[draw_state]
x = name_x
draw.text((x, 8), name_drawn, font=name_font, fill=fill[0])
x += int(draw.textlength(name_drawn, font=name_font))
if icon:
canvas.paste(icon, (x + icon_gap, 14))
x += icon_gap + icon.width + icon_gap
if nick_drawn:
draw.text((x, 9), nick_drawn, font=nick_font, fill=hex_to_rgb("969696"))
# 游戏名
if game_name:
draw.text(
(name_x, 30),
game_name,
font=get_font(font_regular_path, 15),
fill=hex_to_rgb("91c257"),
)
# 状态详情(与游戏名相同则不重复画)
if status and status != game_name:
draw.text(
(name_x, 48),
status,
font=get_font(font_regular_path, 14),
fill=hex_to_rgb("969696"),
)
return canvas
def draw_gaming_friends_status(data: list[dict]) -> Image.Image:
# 按游戏分组:同一游戏的人列在同一个游戏标题下
groups: dict[tuple, list[dict]] = {}
for d in data:
key = (d.get("gameid"), d.get("game_name") or d.get("status"))
groups.setdefault(key, []).append(d)
# 排序:多人组按人数降序在前,单人组按游戏名 A-Z 在后
def _group_name(members: list[dict]) -> str:
return (members[0].get("game_name") or members[0].get("status") or "").lower()
group_list = list(groups.values())
multi_groups = [m for m in group_list if len(m) >= 2]
single_groups = [m for m in group_list if len(m) == 1]
multi_groups.sort(key=lambda m: (-len(m), _group_name(m)))
single_groups.sort(key=_group_name)
parts: list[Image.Image] = []
for members in multi_groups + single_groups:
if len(members) == 1:
# 一个人玩的游戏:游戏图标 + 头像昵称 + 游戏名 + 状态
member = members[0]
status = member["status"]
game_name = member.get("game_name") or ""
if status in ("在线", "未知", "") and game_name:
status = game_name
parts.append(
draw_single_game_friend(
member.get("game_icon"),
member["avatar"],
member["name"],
game_name,
status,
member["personastate"],
member.get("nickname"),
)
)
else:
parts.append(_draw_game_group(members))
if not parts:
return Image.new("RGB", (WIDTH, 64), hex_to_rgb("1e2024"))
return vertically_concatenate_images(parts)
def draw_online_friends_status(data: List[Dict[str, str]]) -> Image.Image:
canvas = Image.new(
"RGB",
(WIDTH, 64 + (MEMBER_AVATAR_SIZE + 16) * len(data) + 16),
hex_to_rgb("1e2024"),
)
draw = ImageDraw.Draw(canvas)
# 绘制标题
draw.text(
(22, 22),
"在线好友",
hex_to_rgb("c5d6d4"),
font=get_font(font_regular_path, 22),
)
# 绘制在线人数
draw.text(
(115, 25),
f"({len(data)})",
hex_to_rgb("67665c"),
font=get_font(font_regular_path, 18),
)
# 绘制好友头像和名称
friends_status_list = [
draw_friend_status(
d["avatar"], d["name"], d["status"], d["personastate"], d["nickname"]
)
for d in data
]
# 拼接好友头像和名称
for i, friend_status in enumerate(friends_status_list):
canvas.paste(friend_status, (0, 64 + (MEMBER_AVATAR_SIZE + 16) * i))
return canvas
def draw_offline_friends_status(data: List[Dict[str, str]]) -> Image.Image:
canvas = Image.new(
"RGB",
(WIDTH, 64 + (MEMBER_AVATAR_SIZE + 16) * len(data) + 16),
hex_to_rgb("1e2024"),
)
draw = ImageDraw.Draw(canvas)
# 绘制标题
draw.text(
(22, 22),
"离线",
hex_to_rgb("c5d6d4"),
font=get_font(font_regular_path, 22),
)
# 绘制离线人数
draw.text(
(72, 25),
f"({len(data)})",
hex_to_rgb("67665c"),
font=get_font(font_regular_path, 18),
)
# 绘制好友头像和名称
friends_status_list = [
draw_friend_status(
d["avatar"], d["name"], d["status"], d["personastate"], d["nickname"]
)
for d in data
]
# 拼接好友头像和名称
for i, friend_status in enumerate(friends_status_list):
canvas.paste(friend_status, (0, 64 + (MEMBER_AVATAR_SIZE + 16) * i))
return canvas
def draw_friends_status(
parent_avatar: Image.Image, parent_name: str, data: List[Dict[str, str]]
):
data.sort(key=lambda x: x["personastate"])
def _is_gaming(d: Dict[str, str]) -> bool:
# 有 gameid 说明确实在游戏里:即使 Steam 没返回详细状态(gameextrainfo),
# 也归入"游戏中"并显示游戏名,符合"具体状态获取不到就显示游戏名字"。
if d.get("gameid"):
return d["personastate"] in (1, 2, 3, 4)
return (
(d["personastate"] == 1 and d["status"] != "在线")
or (d["personastate"] == 3 and d["status"] != "离开")
or (d["personastate"] == 4 and d["status"] != "在线")
)
parent_status = draw_parent_status(parent_avatar, parent_name)
friends_search = draw_friends_search()
status_images: List[Image.Image] = []
height = parent_status.height + friends_search.height
gaming_data = [d for d in data if _is_gaming(d)]
if gaming_data:
status_images.append(draw_gaming_friends_status(gaming_data))
height += status_images[-1].height
online_data = [
d
for d in data
if not _is_gaming(d)
and (
(d["personastate"] == 1 and d["status"] == "在线")
or (d["personastate"] == 3 and d["status"] == "离开")
or (d["personastate"] == 4 and d["status"] == "在线")
or (d["personastate"] in [2, 5, 6])
)
]
# 按 1, 2, 4, 5, 6, 3 的顺序排序
online_data.sort(key=lambda x: (7 if x["personastate"] == 3 else x["personastate"]))
if online_data:
status_images.append(draw_online_friends_status(online_data))
height += status_images[-1].height
offline_data = [d for d in data if d["personastate"] == 0]
if offline_data:
status_images.append(draw_offline_friends_status(offline_data))
height += status_images[-1].height
# 拼合图片
canvas = Image.new("RGB", (WIDTH, height), hex_to_rgb("1e2024"))
draw = ImageDraw.Draw(canvas)
canvas.paste(parent_status, (0, 0))
canvas.paste(friends_search, (0, parent_status.height))
y = parent_status.height + friends_search.height
for i, status_image in enumerate(status_images):
canvas.paste(status_image, (0, y))
y += status_image.height
# 绘制分割线
if i != len(status_images) - 1:
draw.rectangle([0, y - 1, WIDTH, y], fill=hex_to_rgb("333439"))
return canvas
def get_average_color(image: Image.Image) -> tuple[int, int, int]:
"""获取图片的平均颜色"""
image_np = np.array(image)
average_color = image_np.mean(axis=(0, 1)).astype(int)
return tuple(average_color)
def split_image(
image: Image.Image, rows: int, cols: int
) -> tuple[list[Image.Image], int, int]:
"""将图片分割为rows * cols份"""
width, height = image.size
piece_width = width // cols
piece_height = height // rows
pieces = []
for r in range(rows):
for c in range(cols):
box = (
c * piece_width,
r * piece_height,
(c + 1) * piece_width,
(r + 1) * piece_height,
)
piece = image.crop(box)
pieces.append(piece)
return pieces, piece_width, piece_height
def recolor_image(image: Image.Image, rows: int, cols: int) -> Image.Image:
"""分片图片,提取平均颜色后拼接"""
total_average_color = get_average_color(image) # 获取整体平均颜色
pieces, piece_width, piece_height = split_image(image, rows, cols)
diameter = min(pieces[0].size) # 以最小边为直径
radius = diameter // 2
new_image = Image.new("RGB", image.size, total_average_color)
for i, piece in enumerate(pieces):
average_color = get_average_color(piece) # 获取每片的平均颜色
# 计算放置的位置
row, col = divmod(i, cols)
x = col * piece_width + piece_width // 2
y = row * piece_height + piece_height // 2
# 画圆
circle = Image.new("RGBA", (piece_width, piece_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(circle)
draw.ellipse((0, 0, piece_width, piece_height), fill=average_color)
# 将圆形图片粘贴到新图片上
new_image.paste(circle, (x - radius, y - radius), circle)
new_image = new_image.filter(ImageFilter.SMOOTH)
new_image = new_image.filter(ImageFilter.GaussianBlur(50))
return new_image
def create_gradient_image(
size: Tuple[int, int], color1: Tuple[int, int, int], color2: Tuple[int, int, int]
) -> Image.Image:
"""创建渐变图片"""
# 确保颜色值在 0-255 范围内
color1 = tuple(max(0, min(255, c)) for c in color1)
color2 = tuple(max(0, min(255, c)) for c in color2)
# 创建一个渐变的线性空间
gradient_array = np.linspace(color1, color2, size[0])
# 将渐变数组的形状调整为 (height, width, 3)
gradient_image = np.tile(gradient_array, (size[1], 1, 1)).astype(np.uint8)
return Image.fromarray(gradient_image, "RGBA")
def create_vertical_gradient_rect(width, height, start_color, end_color):
"""
创建一个在竖直方向上渐变的矩形图像.
Args:
width (int): 矩形的宽度 (以像素为单位).
height (int): 矩形的高度 (以像素为单位).
start_color (tuple): 起始颜色,格式为 (R, G, B),每个值范围为 0-255.
end_color (tuple): 结束颜色,格式为 (R, G, B),每个值范围为 0-255.
Returns:
Image: PIL Image 对象,表示生成的渐变矩形.
"""
if width <= 0 or height <= 0:
return Image.new("RGBA", (1, 1), (0, 0, 0, 0))
# 确保颜色不超过 0-255 的范围
start_color = tuple(max(0, min(255, c)) for c in start_color)
end_color = tuple(max(0, min(255, c)) for c in end_color)
# 使用 NumPy 创建一个线性渐变数组
gradient_array = np.linspace(start_color, end_color, num=height, dtype=np.uint8)
gradient_array = np.tile(gradient_array[:, np.newaxis, :], (1, width, 1))
# 使用 Pillow 创建图像并填充颜色
image = Image.fromarray(gradient_array)
return image
def random_color_offset(
color: Tuple[int, int, int], offset: int
) -> Tuple[int, int, int]:
return tuple(
min(255, max(0, c + np.random.randint(-offset, offset + 1))) for c in color
)
def get_brightest_and_darkest_color(
image: Image.Image,
saturation_threshold: int = 100,
hue_difference_threshold: int = 30,
) -> Tuple[Tuple[int, int, int], Tuple[int, int, int]]:
"""获取图片最亮和最暗的颜色"""
# 将RGB图像转换为HSV
img_hsv = np.array(image.convert("HSV"))
# 设定一个阈值来定义“鲜艳的颜色”,例如饱和度大于150
vivid_mask = img_hsv[..., 1] > saturation_threshold
# 获取饱和度较高(鲜艳)的像素索引
vivid_pixels = img_hsv[vivid_mask]
if len(vivid_pixels) < 10:
return get_brightest_and_darkest_color(image, saturation_threshold - 10)
# 在鲜艳的像素中,根据亮度(V通道)找到最亮和最暗的颜色
brightest_pixel = vivid_pixels[np.argmax(vivid_pixels[..., 2])]
darkest_pixel = vivid_pixels[np.argmin(vivid_pixels[..., 2])]
# 获取最亮和最暗的颜色的色相差异
hue_difference = abs(int(brightest_pixel[0]) - int(darkest_pixel[0]))
# 如果色相差异过小,则尝试寻找新的最暗颜色,直到色相差异大于设定阈值
if hue_difference < hue_difference_threshold:
possible_dark_pixels = vivid_pixels[vivid_pixels[..., 0] != brightest_pixel[0]]
if len(possible_dark_pixels) > 0:
darkest_pixel = possible_dark_pixels[
np.argmin(possible_dark_pixels[..., 2])
]
# 将最亮和最暗的像素从HSV转回RGB
brightest_color = (
Image.fromarray(np.uint8([[brightest_pixel]]), "HSV")
.convert("RGB")
.getpixel((0, 0))
)
darkest_color = (
Image.fromarray(np.uint8([[darkest_pixel]]), "HSV")
.convert("RGB")
.getpixel((0, 0))
)
return brightest_color, darkest_color
def draw_game_info(
header: Image.Image,
game_name: str,
game_time: str,
last_play_time: str,
achievements: List[Achievements],
completed_achievement_number: int,
total_achievement_number: int,
achievement_color: Tuple[int, int, int],
two_week_time: Optional[str] = None,
) -> Image.Image:
bg = Image.new("RGBA", (880, 110 + 64 + 10), (0, 0, 0, 110))
header = header.resize((229, 86), Image.BICUBIC)
bg.paste(header, (10, 110 // 2 - header.height // 2))
draw = ImageDraw.Draw(bg)
# 近两周时长(可选):右上角绿色小字,游戏名超宽时先截断避让
two_week_width = 0
if two_week_time:
font_two_week = get_font(font_light_path, 20)
two_week_width = int(draw.textlength(two_week_time, font=font_two_week))
draw.text(
(bg.width - two_week_width - 10, 22),
two_week_time,
font=font_two_week,
fill=hex_to_rgb("91c257"),
)
# 画游戏名
name_font = get_font(font_regular_path, 26)
max_name_width = bg.width - 260 - 20 - two_week_width - (30 if two_week_width else 0)
if draw.textlength(game_name, font=name_font) > max_name_width:
game_name = _truncate_with_ellipsis(draw, game_name, name_font, max_name_width)
draw.text(
(260, 10),
game_name,
font=name_font,
fill=(255, 255, 255),
)
# 画最后游玩时间
font = get_font(font_light_path, 22)
display_text = last_play_time
draw.text(
(int(bg.width - font.getlength(display_text)) - 10, 75),
display_text,
font=font,
fill=(150, 150, 150),
)
# 画游戏时间
font = get_font(font_light_path, 22)
display_text = f"总时数 {game_time}"
draw.text(
(int(bg.width - font.getlength(display_text)) - 10, 50),
display_text,
font=font,
fill=(150, 150, 150),
)
if completed_achievement_number is None or total_achievement_number is None:
return bg.crop((0, 0, bg.width, 110))
# 画成就 + 64 + 10
achievement_bg = Image.new("RGBA", (860, 64), achievement_color)
draw_achievement = ImageDraw.Draw(achievement_bg)
# 画成就进度
font = get_font(font_light_path, 18)
x = 14
draw_achievement.text(
(x, 20),
"成就进度",
font=font,
fill=(255, 255, 255, 255),
)
x += font.getlength("成就进度") + 10
draw_achievement.text(
(int(x), 20),
f"{completed_achievement_number} / {total_achievement_number}",
font=font,
fill=(185, 185, 185),
)
x += (
font.getlength(f"{completed_achievement_number} / {total_achievement_number}")
+ 10
)
progress_bar = create_progress_bar(
completed_achievement_number / total_achievement_number, achievement_color
)
achievement_bg.paste(progress_bar, (int(x), 24), progress_bar)
# 画成就图标
x = 860 - 48 * 6 - 10 * 6
for achievement in achievements:
achievement_image = Image.open(BytesIO(achievement["image"])).resize((48, 48))
achievement_bg.paste(achievement_image, (x, 8))
x += 48 + 10
if completed_achievement_number > 6:
font = get_font(font_regular_path, 22)
display_text = f"+{completed_achievement_number - 5}"
draw_achievement.rectangle((x, 8, x + 48, 56), fill=(34, 34, 34))
draw_achievement.text(
(x + 24 - font.getlength(display_text) // 2, 18),
display_text,
font=font,
fill=(255, 255, 255),
)
bg.paste(achievement_bg, (10, 110), achievement_bg)
return bg
# 信息卡尺寸/排版常量(调整版式优先动这里)
CARD_WIDTH = 1200 # 卡片条宽度,背景按此居中裁切
CARD_MARGIN = 20 # 左右安全边距
LEFT_W = 880 # 左栏(游戏动态)宽度
RIGHT_X = CARD_MARGIN + LEFT_W + CARD_MARGIN # 920:右栏起点
RIGHT_W = CARD_WIDTH - RIGHT_X - CARD_MARGIN # 260:右栏宽度
AVATAR_SIZE = 160
AVATAR_X = 30 # 头像框约 1.36×头像,需留出外溢空间
HEADER_TEXT_X = AVATAR_X + AVATAR_SIZE + 30 # 220:头部文字统一左对齐点
GAME_GAP = 16 # 游戏卡片间距
COUNT_ROW_MAX_STEP = 44 # 右栏计数条目间距上限(面板矮时自动收紧)
AVATAR_FRAME_SCALE = 1.36 # 头像框相对头像的缩放(对齐 Steam 内孔比例)
# 状态行配色,key 为 ProfileCardExtra.status_kind
status_row_colors = {
"in-game": hex_to_rgb("91c257"),
"online": hex_to_rgb("6dcef5"),
"away": hex_to_rgb("e0a948"),
"offline": hex_to_rgb("969697"),
"private": hex_to_rgb("969697"),
}
# 风控标记配色,key 为 ProfileCardExtra.ban_kind
ban_tag_colors = {
"vac": hex_to_rgb("c4453c"),
"trade": hex_to_rgb("c98a2e"),
"limited": hex_to_rgb("67665c"),
}
# 右栏状态区标题,key 为 ProfileCardExtra.status_kind
status_titles = {
"in-game": "当前正在游戏",
"online": "当前在线",
"away": "当前在线",
"offline": "当前离线",
"private": "当前状态",
}
def _draw_kv_parts(
draw: ImageDraw.ImageDraw,
x: int,
y: int,
parts: List[Tuple[str, tuple]],
font,
) -> int:
"""按 (文本, 颜色) 依次绘制,返回右边缘 x(间距由文本自带的空格控制)"""
for text, color in parts:
draw.text((x, y), text, font=font, fill=color)
x += int(draw.textlength(text, font=font))
return x
def _draw_status_row(
draw: ImageDraw.ImageDraw,
x: int,
y: int,
text: str,
kind: str = "online",
second_line: str = "",
) -> None:
"""状态行:彩色圆点 + 文案(游戏中/在线/离线/资料私密),过长可折第二行"""
color = status_row_colors.get(kind, status_row_colors["online"])
font = get_font(font_regular_path, 17)
draw.ellipse((x, y + 4, x + 11, y + 15), fill=color)
draw.text((x + 19, y), text, font=font, fill=color)
if second_line:
draw.text((x + 19, y + 22), second_line, font=font, fill=color)
def _split_two_lines(
draw: ImageDraw.ImageDraw, text: str, font, max_width: int
) -> List[str]:
"""超宽文本折成最多两行:优先在最后一个放得下的分隔符处断,否则省略号截断"""
if draw.textlength(text, font=font) <= max_width:
return [text]
break_at = None
for idx, char in enumerate(text):
if char in "::" or char == " ":
if draw.textlength(text[: idx + 1], font=font) <= max_width:
break_at = idx + 1
if break_at is None:
return [_truncate_with_ellipsis(draw, text, font, max_width)]
second = text[break_at:].strip()
if draw.textlength(second, font=font) > max_width:
second = _truncate_with_ellipsis(draw, second, font, max_width)
return [text[:break_at].strip(), second]
def _draw_level_badge(
draw: ImageDraw.ImageDraw, x: int, center_y: int, level: int
) -> int:
"""Steam 风格等级圆牌,返回右边缘 x"""
diameter = 34
top = center_y - diameter // 2
draw.ellipse(
(x, top, x + diameter, top + diameter),
fill=hex_to_rgb("1b2838"),
outline=hex_to_rgb("66c0f4"),
width=2,
)
font = get_font(font_bold_path, 18)
text = str(level)
text_width = draw.textlength(text, font=font)
draw.text(
(x + (diameter - text_width) / 2, top + (diameter - font.size) / 2 - 3),
text,
font=font,
fill=(255, 255, 255),
)
return x + diameter
def _draw_ban_tag(
draw: ImageDraw.ImageDraw, x: int, y: int, text: str, kind: str
) -> int:
"""风控标记圆角标签,返回右边缘 x"""
font = get_font(font_regular_path, 13)
padding_x, padding_y = 9, 4
width = draw.textlength(text, font=font) + padding_x * 2
height = font.size + padding_y * 2
draw.rounded_rectangle(
(x, y, x + width, y + height),
radius=height // 2,
fill=ban_tag_colors.get(kind, ban_tag_colors["limited"]),
)
draw.text(
(x + padding_x, y + padding_y - 1),
text,
font=font,
fill=(255, 255, 255),
)
return int(x + width)
def draw_player_status(
player_bg: Image.Image,
player_avatar: Image.Image,
player_name: str,
player_id: str,
player_description: str,
player_last_two_weeks_time: str, # e.g. 10.2 小时(过去 2 周)
player_games: List[DrawPlayerStatusData],
extra: Optional[ProfileCardExtra] = None,
avatar_frame: Optional[bytes] = None,
):
"""信息卡:通栏头部 + 左栏「游戏动态」+ 右栏「当前在线/计数」(参考 Steam 官方资料页)"""
if isinstance(player_bg, bytes):
player_bg = Image.open(BytesIO(player_bg))
if isinstance(player_avatar, bytes):
player_avatar = Image.open(BytesIO(player_avatar))
extra = extra or {}
bg = recolor_image(
player_bg.crop(
(
(player_bg.width - CARD_WIDTH) // 2,
0,
(player_bg.width + CARD_WIDTH) // 2,
player_bg.height,
)
),
10,
10,
)
bg = ImageEnhance.Brightness(bg).enhance(0.7)
draw = ImageDraw.Draw(bg)
# ---------- 头部:头像(+头像框)/ 昵称+等级+风控 / 简介 / 好友代码·注册时间 ----------
avatar = player_avatar.convert("RGB").resize(
(AVATAR_SIZE, AVATAR_SIZE), Image.BICUBIC
)
bg.paste(avatar_with_border(avatar), (AVATAR_X - 2, AVATAR_X - 2))
if avatar_frame:
try:
frame = Image.open(BytesIO(avatar_frame)).convert("RGBA")
frame_size = int(AVATAR_SIZE * AVATAR_FRAME_SCALE)
frame = frame.resize((frame_size, frame_size), Image.LANCZOS)
frame_pos = AVATAR_X + (AVATAR_SIZE - frame_size) // 2
bg.paste(frame, (frame_pos, frame_pos), frame)
except Exception:
pass # 头像框只是装饰,画不出来不影响整卡
# 好友代码/注册时间的标签色:比正文灰稍亮一档(#969696 在深色底上偏暗)
text_gray = hex_to_rgb("b4b4b4")
name_font = get_font(font_light_path, 38)
reserved = 34 + 10 if extra.get("level") is not None else 0
if extra.get("ban_text"):
ban_font = get_font(font_regular_path, 13)
reserved += int(draw.textlength(extra["ban_text"], font=ban_font)) + 18 + 10
max_name_width = CARD_WIDTH - CARD_MARGIN - HEADER_TEXT_X - reserved
if draw.textlength(player_name, font=name_font) > max_name_width:
player_name = _truncate_with_ellipsis(
draw, player_name, name_font, max_name_width
)
name_y = AVATAR_X + 6
draw.text(
(HEADER_TEXT_X, name_y), player_name, font=name_font, fill=(255, 255, 255)
)
# 等级圆牌 / 风控标记紧随昵称
cursor = HEADER_TEXT_X + int(draw.textlength(player_name, font=name_font)) + 12
if extra.get("level") is not None:
cursor = _draw_level_badge(draw, cursor, name_y + 21, extra["level"]) + 10
if extra.get("ban_text"):
_draw_ban_tag(
draw, cursor, name_y + 10, extra["ban_text"], extra.get("ban_kind") or "limited"
)
# 简介(最多 3 行)
desc_font = get_font(font_light_path, 20)
desc_top = name_y + 60
max_desc_width = CARD_WIDTH - CARD_MARGIN - HEADER_TEXT_X
line_width = 0
offset = 0
line = ""
for idx, char in enumerate(player_description):
line += char
line_width += desc_font.getlength(char)
if (
line_width > max_desc_width
or idx == len(player_description) - 1
or char == "\n"
):
draw.text(
(HEADER_TEXT_X, desc_top + offset),
line,
font=desc_font,
fill=(235, 235, 235),
)
line = ""
line_width = 0
offset += 27
if offset >= 27 * 3:
break
# 好友代码 / 注册时间
meta_font = get_font(font_regular_path, 16)
meta_y = desc_top + offset + 14
meta_parts = [("好友代码: ", text_gray), (player_id, (255, 255, 255))]
if extra.get("member_since"):
meta_parts += [
(" 注册时间: ", text_gray),
(extra["member_since"], (255, 255, 255)),
]
_draw_kv_parts(draw, HEADER_TEXT_X, meta_y, meta_parts, meta_font)
header_h = max(AVATAR_X + AVATAR_SIZE + 26, meta_y + 22) + 14
draw.rectangle(
(CARD_MARGIN, header_h - 1, CARD_WIDTH - CARD_MARGIN, header_h),
fill=hex_to_rgb("333439"),
)
# ---------- 左栏:游戏动态 ----------
body_top = header_h + 16
brightest_color, darkest_color = get_brightest_and_darkest_color(player_bg)
brightest_color = tuple(map(lambda x: x - 30 if x >= 30 else 0, brightest_color))
darkest_color = tuple(
map(lambda x: x + 30 if x <= 255 - 30 else 255, darkest_color)
)
# 成就条底色:取背景亮色的深色变体(压暗一些,保证白色文字和数字读得清)
hsv_achievement_color = rgb_to_hsv(*brightest_color)
achievement_color = tuple(
map(
int,
hsv_to_rgb(
hsv_achievement_color[0],
hsv_achievement_color[1] * 0.8,
hsv_achievement_color[2] * 0.35,
),
)
)
cards: List[Image.Image] = []
for game in player_games:
cards.append(
draw_game_info(
Image.open(BytesIO(game["game_header"])),
game["game_name"],
game["game_time"],
game["last_play_time"],
game["achievements"],
game["completed_achievement_number"],
game["total_achievement_number"],
achievement_color,
two_week_time=game.get("two_week_time"),
)
)
# 背景太矮时先向下补一段(拉伸底部横条),避免内容整体被裁
min_height = body_top + 58 + 110 + CARD_MARGIN
if bg.height < min_height:
strip = bg.crop((0, bg.height - 8, CARD_WIDTH, bg.height)).resize(
(CARD_WIDTH, min_height - bg.height), Image.BICUBIC
)
canvas = Image.new("RGB", (CARD_WIDTH, min_height))
canvas.paste(bg, (0, 0))
canvas.paste(strip, (0, bg.height))
bg = canvas
draw = ImageDraw.Draw(bg)
# 按可用高度筛出画得下的游戏卡片(至少画第一条)
y = body_top + 58
max_bottom = bg.height - CARD_MARGIN
shown_cards: List[Image.Image] = []
for card in cards:
if shown_cards and y + card.height > max_bottom:
break
shown_cards.append(card)
y += card.height + GAME_GAP
left_bottom = (y - GAME_GAP + 12) if shown_cards else (body_top + 66)
# 左右两栏等高的半透明底板
left_panel = Image.new("RGBA", (LEFT_W, left_bottom - body_top), (0, 0, 0, 120))
bg.paste(left_panel, (CARD_MARGIN, body_top), left_panel)
right_panel = Image.new("RGBA", (RIGHT_W, left_bottom - body_top), (0, 0, 0, 120))
bg.paste(right_panel, (RIGHT_X, body_top), right_panel)
# 左栏标题条(渐变)+ 近两周总时长
gradient = create_gradient_image(
(LEFT_W, 50),
(brightest_color[0], brightest_color[1], brightest_color[2], 128),
(darkest_color[0], darkest_color[1], darkest_color[2], 128),
)
bg.paste(gradient, (CARD_MARGIN, body_top), gradient)
draw.text(
(CARD_MARGIN + 14, body_top + 7),
"游戏动态",
font=get_font(font_light_path, 26),
fill=(255, 255, 255),
)
if player_last_two_weeks_time:
total_font = get_font(font_light_path, 22)
total_width = total_font.getlength(player_last_two_weeks_time)
draw.text(
(CARD_MARGIN + LEFT_W - total_width - 14, body_top + 9),
player_last_two_weeks_time,
font=total_font,
fill=(255, 255, 255),
)
y = body_top + 58
for card in shown_cards:
bg.paste(card, (CARD_MARGIN, y), card.convert("RGBA"))
y += card.height + GAME_GAP
# ---------- 右栏:当前在线 + 计数 ----------
status_title = status_titles.get(
extra.get("status_kind") or "online", "当前在线"
)
draw.text(
(RIGHT_X + 20, body_top + 14),
status_title,
font=get_font(font_light_path, 20),
fill=hex_to_rgb("c5d6d4"),
)
status_extra_h = 0
if extra.get("status_text"):
status_font = get_font(font_regular_path, 17)
status_lines = _split_two_lines(
draw, extra["status_text"], status_font, RIGHT_W - 60
)
status_extra_h = 22 * (len(status_lines) - 1)
_draw_status_row(
draw,
RIGHT_X + 20,
body_top + 46,
status_lines[0],
extra.get("status_kind") or "online",
status_lines[1] if len(status_lines) > 1 else "",
)
divider_y = body_top + 76 + status_extra_h
draw.rectangle(
(RIGHT_X + 20, divider_y, RIGHT_X + RIGHT_W - 20, divider_y + 1),
fill=hex_to_rgb("333439"),
)
count_rows = list((extra.get("counts") or {}).items())
panel_h = left_bottom - body_top - status_extra_h
step = COUNT_ROW_MAX_STEP
if count_rows:
step = min(COUNT_ROW_MAX_STEP, max(26, (panel_h - 114) // len(count_rows)))
row_font = get_font(font_regular_path, 15)
ry = divider_y + 14
for label, value in count_rows:
if ry + 20 > left_bottom - 12: # 面板放不下就不画,保证不越界
break
draw.text((RIGHT_X + 20, ry), label, font=row_font, fill=hex_to_rgb("8f989b"))
value_text = str(value)
draw.text(
(RIGHT_X + RIGHT_W - 20 - row_font.getlength(value_text), ry),
value_text,
font=row_font,
fill=(255, 255, 255),
)
ry += step
# 内容高于背景时(极端情况),背景向下拉伸补足后再贴回
if bg.height > player_bg.height:
strip = player_bg.crop(
(0, player_bg.height - 8, player_bg.width, player_bg.height)
).resize((player_bg.width, bg.height - player_bg.height), Image.BICUBIC)
canvas = Image.new("RGB", (player_bg.width, bg.height))
canvas.paste(player_bg, (0, 0))
canvas.paste(strip, (0, player_bg.height))
player_bg = canvas
player_bg.paste(bg, ((player_bg.width - CARD_WIDTH) // 2, 0), bg.convert("RGBA"))
return player_bg
def rounded_rectangle(
image: Image.Image,
radius: int,
border=False,
border_width=0,
border_color=(0, 0, 0),
):
"""
将给定的Image.Image对象切割为圆角矩形。
Args:
image: 一个PIL Image对象。
radius: 圆角半径,单位为像素。
border: 是否需要边框,默认为False。
border_width: 边框宽度,单位为像素,默认为0。
border_color: 边框颜色,RGB元组,默认为黑色(0, 0, 0)。
Returns:
一个PIL Image对象,表示切割后的圆角矩形图像。
"""
width, height = image.size
image_ = Image.new("RGBA", (width + 1, height + 1), (0, 0, 0, 0))
image_.paste(image, (0, 0), image.convert("RGBA"))
# 创建一个圆角矩形的遮罩
result = Image.new("RGBA", (width + 1, height + 1), (0, 0, 0, 0))
mask = Image.new("L", (width + 1, height + 1), 0)
draw = ImageDraw.Draw(mask)
image_draw = ImageDraw.Draw(result)
# 绘制圆角矩形
draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
# 应用遮罩到原始图像
result.paste(image_, (0, 0), mask)
# 添加边框 (如果需要)
if border:
image_draw.rounded_rectangle(
(0, 0, width, height),
radius=radius,
outline=border_color,
width=border_width,
)
return result
def create_progress_bar(
progress: float, color: Tuple[int, int, int], width=186, height=16
):
color_hsv = rgb_to_hsv(*color)
# 外条
bar_color = tuple(
map(int, hsv_to_rgb(color_hsv[0], color_hsv[1], color_hsv[2] * 0.8))
)
border_color = tuple(map(lambda x: max(x - 20, 0), color))
border_image = rounded_rectangle(
Image.new("RGBA", (width, height), bar_color),
8,
border=True,
border_width=1,
border_color=border_color,
)
# 内条
bar_color_top = tuple(
map(int, hsv_to_rgb(color_hsv[0], color_hsv[1] / 2, color_hsv[2] * 5 / 2))
)
bar_color_bottem = tuple(
map(int, hsv_to_rgb(color_hsv[0], color_hsv[1] / 2, color_hsv[2]))
)
bar_image = create_vertical_gradient_rect(
int(width * progress) - 6, height - 4, bar_color_top, bar_color_bottem
)
bar_image = rounded_rectangle(bar_image, 6)
# 合并
border_image.paste(bar_image, (3, 2), bar_image)
return border_image