refactor(bf-bot): 插件重构为 handlers/services/utils 分层,资源归入 res/
- 旧平铺模块(data/data_utils/database_op/image_builder*/get_bf6_data/ gametools_bf6/bf6_data/img_utils)拆分为 handlers(绑定/查询)、 services(fetchers/processing/query)、utils(渲染/参数)分层; 新增 repository.py / config.py / paths.py - 字体/图片/模板资源迁入 res/,新增 BF2042 卡面素材(bg/object_icon 等) - browser_ext/:BF2042 扩展捕获桥(WS 直连兜底通道)源码入库 - 新增 hexi/web/src/plugins/bf_bot 前端管理页 - 新增 tests:test_bf_bot_helpers / repository / tracker_api Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
614e1d7e00
commit
a31aab00e8
752 files changed
+6241
-2506
No files matched your search
@@ -0,0 +1 @@
|
||||
"""纯工具层:文本/参数/图像处理与卡片渲染(尽量无副作用,可单测)。"""
|
||||
@@ -0,0 +1,68 @@
|
||||
"""指令参数解析与展示表(纯函数、无副作用,可单测)。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 游戏代号 -> 中文展示名(回复文案用)
|
||||
BF_GAME_NAMES = {
|
||||
"bf3": "战地3",
|
||||
"bf4": "战地4",
|
||||
"bf1": "战地1",
|
||||
"bfv": "战地5",
|
||||
"bf2042": "战地2042",
|
||||
"bf6": "战地6",
|
||||
}
|
||||
|
||||
# BF2042 取数失败原因 -> 给用户看的话(detail 只写日志不刷屏)
|
||||
BF2042_ERROR_HINT = {
|
||||
"not_found": "未找到该玩家,请检查 EAID 拼写是否正确",
|
||||
"ext_offline": "查询服务未就绪(取数浏览器未连接),请联系管理员",
|
||||
"timeout": "访问 tracker.gg 超时,请稍后再试",
|
||||
"auth": "查询服务登录态异常,请联系管理员",
|
||||
"error": "tracker.gg 取数失败,请稍后再试",
|
||||
}
|
||||
|
||||
# 子命令关键词 -> (视图, 榜单类型)。不打关键词就是生涯卡
|
||||
_BF2042_VIEWS = {
|
||||
"武器": ("list", "weapon"),
|
||||
"枪械": ("list", "weapon"),
|
||||
"weapon": ("list", "weapon"),
|
||||
"weapons": ("list", "weapon"),
|
||||
"载具": ("list", "vehicle"),
|
||||
"vehicle": ("list", "vehicle"),
|
||||
"vehicles": ("list", "vehicle"),
|
||||
"装备": ("list", "gadget"),
|
||||
"道具": ("list", "gadget"),
|
||||
"gadget": ("list", "gadget"),
|
||||
"gadgets": ("list", "gadget"),
|
||||
"模式": ("gamemode", ""),
|
||||
"gamemode": ("gamemode", ""),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bf2042Target:
|
||||
"""BF2042 指令解析结果。
|
||||
|
||||
view: career(生涯卡)/ list(榜单卡)/ gamemode(模式明细)
|
||||
kind: 榜单类型 weapon/vehicle/gadget,仅 view=list 时非空
|
||||
player: 玩家名;空串 = 未指定,用绑定记录
|
||||
"""
|
||||
|
||||
view: str = "career"
|
||||
kind: str = ""
|
||||
player: str = ""
|
||||
|
||||
|
||||
def parse_bf2042_args(input_text: str) -> Bf2042Target:
|
||||
"""拆出 BF2042 子命令关键词,返回目标。
|
||||
|
||||
首个词不是关键词时整串都当玩家名(生涯卡),所以玩家名里有空格也不受影响。
|
||||
"""
|
||||
text = (input_text or "").strip()
|
||||
if not text:
|
||||
return Bf2042Target()
|
||||
head, _, rest = text.partition(" ")
|
||||
view = _BF2042_VIEWS.get(head.lower())
|
||||
if view is None:
|
||||
return Bf2042Target(view="career", kind="", player=text)
|
||||
return Bf2042Target(view=view[0], kind=view[1], player=rest.strip())
|
||||
@@ -0,0 +1,610 @@
|
||||
"""图像处理工具:缩放/粘贴、头像下载缓存、物品图标、公告栏与标签绘制。
|
||||
|
||||
静态资源统一走 paths.RES_DIR;QQ 头像缓存落在 hexi/data/nonebot_plugin_bf_bot/avatar_cache。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import requests
|
||||
import requests.exceptions
|
||||
from PIL import Image
|
||||
from nonebot import logger
|
||||
|
||||
from ..paths import AVATAR_CACHE_DIR, RES_DIR
|
||||
from ..repository import DesignationManager, UserManager
|
||||
from .params import rank_pic
|
||||
|
||||
|
||||
def png_resize(source_file, new_width=0, new_height=0, resample="LANCZOS", ref_file=""):
|
||||
"""
|
||||
PNG 等比缩放(保持 Alpha 透明度)
|
||||
:param source_file: Image.open() 得到的 Image 对象
|
||||
:param new_width: 目标宽度(可选)
|
||||
:param new_height: 目标高度(可选)
|
||||
:param resample: NEAREST / BILINEAR / BICUBIC / LANCZOS
|
||||
:param ref_file: 参考图片路径(等比适配到参考尺寸)
|
||||
:return: PIL.Image (RGBA)
|
||||
"""
|
||||
|
||||
img = source_file.convert("RGBA")
|
||||
src_w, src_h = img.size
|
||||
|
||||
# -------------------------
|
||||
# 计算目标尺寸(等比)
|
||||
# -------------------------
|
||||
if ref_file:
|
||||
ref_img = Image.open(ref_file)
|
||||
ref_w, ref_h = ref_img.size
|
||||
|
||||
scale = min(ref_w / src_w, ref_h / src_h)
|
||||
target_w = int(src_w * scale)
|
||||
target_h = int(src_h * scale)
|
||||
|
||||
else:
|
||||
if new_width > 0 and new_height > 0:
|
||||
scale = min(new_width / src_w, new_height / src_h)
|
||||
target_w = int(src_w * scale)
|
||||
target_h = int(src_h * scale)
|
||||
|
||||
elif new_width > 0:
|
||||
scale = new_width / src_w
|
||||
target_w = new_width
|
||||
target_h = int(src_h * scale)
|
||||
|
||||
elif new_height > 0:
|
||||
scale = new_height / src_h
|
||||
target_h = new_height
|
||||
target_w = int(src_w * scale)
|
||||
|
||||
else:
|
||||
# 未指定任何尺寸,直接返回
|
||||
return img
|
||||
|
||||
# -------------------------
|
||||
# 重采样方式
|
||||
# -------------------------
|
||||
resample_map = {
|
||||
"NEAREST": Image.Resampling.NEAREST,
|
||||
"BILINEAR": Image.Resampling.BILINEAR,
|
||||
"BICUBIC": Image.Resampling.BICUBIC,
|
||||
"LANCZOS": Image.Resampling.LANCZOS,
|
||||
}
|
||||
resample_method = resample_map.get(resample, Image.Resampling.LANCZOS)
|
||||
|
||||
# -------------------------
|
||||
# Alpha 安全 resize(逐通道)
|
||||
# -------------------------
|
||||
bands = img.split()
|
||||
resized_bands = [
|
||||
band.resize((target_w, target_h), resample=resample_method) for band in bands
|
||||
]
|
||||
|
||||
return Image.merge("RGBA", resized_bands)
|
||||
|
||||
|
||||
# 图片粘贴
|
||||
def image_paste(paste_image, under_image, pos):
|
||||
"""
|
||||
|
||||
:param paste_image: 需要粘贴的图片
|
||||
:param under_image: 底图
|
||||
:param pos: 位置(x,y)坐标
|
||||
:return: 返回图片
|
||||
"""
|
||||
# 获取需要贴入图片的透明通道
|
||||
r, g, b, alpha = paste_image.split()
|
||||
# 粘贴时将alpha值传递至mask属性
|
||||
under_image.paste(paste_image, pos, alpha)
|
||||
return under_image
|
||||
|
||||
|
||||
def image_paste_center(paste_image, under_image, pos):
|
||||
"""
|
||||
将一张图片粘贴到另一张图片上,并使粘贴图片的Y轴中心对齐到指定y坐标
|
||||
|
||||
:param paste_image: 需要粘贴的图片 (RGBA)
|
||||
:param under_image: 底图 (RGBA)
|
||||
:param pos: 粘贴位置 (x, y),其中y表示目标对齐的中心位置
|
||||
:return: 返回合成后的图片
|
||||
"""
|
||||
x, y = pos
|
||||
# 计算上下居中偏移
|
||||
paste_w, paste_h = paste_image.size
|
||||
y_top = y - paste_h // 2 # 上边位置 = 中心点y - 半高
|
||||
|
||||
# 获取透明通道
|
||||
r, g, b, alpha = paste_image.split()
|
||||
under_image.paste(paste_image, (x, y_top), alpha)
|
||||
return under_image
|
||||
|
||||
|
||||
# 下载QQ头像
|
||||
def download_avatar(user_id: str) -> bytes | None:
|
||||
url = f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640"
|
||||
data = download_url(url, timeout=10)
|
||||
if not data or hashlib.md5(data).hexdigest() == QQ_DEFAULT_AVATAR_MD5:
|
||||
url = f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=100"
|
||||
data = download_url(url, timeout=10)
|
||||
return data
|
||||
|
||||
|
||||
# 根据URL下载文件
|
||||
def download_url(url: str, timeout=None) -> bytes | None:
|
||||
for i in range(3):
|
||||
try:
|
||||
resp = requests.get(url, timeout=timeout)
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
return resp.content
|
||||
except Exception as e:
|
||||
logger.warning(f"Error downloading {url}, retry {i}/3: {str(e)}")
|
||||
|
||||
|
||||
# QQ 无头像时 qlogo 返回的默认图,md5 固定
|
||||
QQ_DEFAULT_AVATAR_MD5 = "acef72340ac0e914090bd35799f5594e"
|
||||
|
||||
|
||||
# 校验字节流能不能当图片打开
|
||||
def _is_image(data) -> bool:
|
||||
try:
|
||||
Image.open(BytesIO(data)).size
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# 按QQ号获取头像字节流:先读本地缓存,未命中则下载并落盘
|
||||
def get_avatar_bytes(user_id):
|
||||
if not user_id:
|
||||
return None
|
||||
cache_file = AVATAR_CACHE_DIR / f"{user_id}.png"
|
||||
if cache_file.exists():
|
||||
try:
|
||||
cached = cache_file.read_bytes()
|
||||
if _is_image(cached):
|
||||
return cached
|
||||
# 缓存已损坏(旧版本写入或写了一半),删掉重下,避免一直读到坏图
|
||||
logger.warning(f"头像缓存已损坏,重新下载:{cache_file}")
|
||||
cache_file.unlink()
|
||||
except Exception as err:
|
||||
logger.error(f"读取头像缓存失败:{str(err)}")
|
||||
|
||||
try:
|
||||
data = download_avatar(str(user_id))
|
||||
except Exception as err:
|
||||
logger.error(f"下载头像失败:{str(err)}")
|
||||
return None
|
||||
if not data:
|
||||
logger.warning(f"未获取到QQ {user_id} 的头像数据")
|
||||
return None
|
||||
|
||||
# 确认是图片再缓存,避免把错误页写进缓存
|
||||
if not _is_image(data):
|
||||
logger.error(f"头像数据不是有效图片,QQ:{user_id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
AVATAR_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_bytes(data)
|
||||
logger.info(f"头像已缓存至:{cache_file}")
|
||||
except Exception as err:
|
||||
logger.error(f"写入头像缓存失败:{str(err)}")
|
||||
return data
|
||||
|
||||
|
||||
# 灰色圆形占位头像(BGRA,圆外透明)
|
||||
def placeholder_avatar(size: int):
|
||||
avatar = np.zeros((size, size, 4), dtype=np.uint8)
|
||||
center = size // 2
|
||||
cv2.circle(avatar, (center, center), size // 2, (90, 90, 90, 255), -1)
|
||||
cv2.circle(
|
||||
avatar, (center, int(size * 0.38)), int(size * 0.16), (150, 150, 150, 255), -1
|
||||
)
|
||||
cv2.ellipse(
|
||||
avatar,
|
||||
(center, int(size * 0.96)),
|
||||
(int(size * 0.28), int(size * 0.30)),
|
||||
0,
|
||||
180,
|
||||
360,
|
||||
(150, 150, 150, 255),
|
||||
-1,
|
||||
)
|
||||
return avatar
|
||||
|
||||
|
||||
# 载入指定尺寸的圆形头像,统一返回BGRA四通道
|
||||
def load_avatar(user_id, size: int):
|
||||
data = get_avatar_bytes(user_id)
|
||||
avatar = (
|
||||
cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
if data
|
||||
else None
|
||||
)
|
||||
if avatar is not None and avatar.size:
|
||||
if avatar.ndim == 2: # 灰度图
|
||||
avatar = cv2.cvtColor(avatar, cv2.COLOR_GRAY2BGRA)
|
||||
elif avatar.shape[2] == 3:
|
||||
avatar = cv2.cvtColor(avatar, cv2.COLOR_BGR2BGRA)
|
||||
elif avatar.shape[2] != 4:
|
||||
logger.warning(f"头像通道数异常({avatar.shape[2]}),改用占位头像")
|
||||
avatar = None
|
||||
else:
|
||||
avatar = None
|
||||
|
||||
if avatar is None:
|
||||
logger.warning(f"未获取到头像(QQ:{user_id}),使用占位头像")
|
||||
avatar = placeholder_avatar(size)
|
||||
return cv2.resize(avatar, (size, size), interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
def draw_centered_text(draw, text, x_left, x_right, y, font, fill):
|
||||
"""
|
||||
在指定区域水平居中绘制文本
|
||||
:param draw: ImageDraw对象
|
||||
:param text: 要绘制的文本
|
||||
:param x_left: 区域左边界x坐标
|
||||
:param x_right: 区域右边界x坐标
|
||||
:param y: 文本基线y坐标
|
||||
:param font: ImageFont对象
|
||||
:param fill: 文本颜色
|
||||
"""
|
||||
text = str(text)
|
||||
text_width = font.getlength(text) # 获取文本像素宽度
|
||||
area_width = x_right - x_left
|
||||
x_center = x_left + (area_width - text_width) / 2
|
||||
draw.text((x_center, y), text, font=font, fill=fill)
|
||||
|
||||
|
||||
def draw_right_aligned_text(draw, text, x_left, x_right, y, font, fill):
|
||||
"""
|
||||
在指定区域内从右往左绘制文本(右对齐)
|
||||
:param draw: ImageDraw 对象
|
||||
:param text: 要绘制的文本
|
||||
:param x_left: 区域左边界 x
|
||||
:param x_right: 区域右边界 x(文本右侧贴齐这里)
|
||||
:param y: 文本基线 y 坐标
|
||||
:param font: ImageFont 对象
|
||||
:param fill: 文本颜色
|
||||
"""
|
||||
text = str(text)
|
||||
text_width = font.getlength(text)
|
||||
|
||||
# 右对齐:文本右边贴到 x_right
|
||||
x_start = x_right - text_width
|
||||
|
||||
# 可选:防止越界(超出左边界时截断)
|
||||
if x_start < x_left:
|
||||
x_start = x_left
|
||||
|
||||
draw.text((x_start, y), text, font=font, fill=fill)
|
||||
|
||||
|
||||
def get_save_icon(game, name, icon_type, url):
|
||||
if game == "bf6":
|
||||
name = name.replace("/", "").upper()
|
||||
else:
|
||||
name = name.replace("/", "")
|
||||
logger.info(f"查询的物品: {name},图标类型:{icon_type},游戏:{game}")
|
||||
icon = get_icon_from_cache(name, game, icon_type)
|
||||
if not icon:
|
||||
if game == "bf1":
|
||||
icon = get_icon_from_url(url)
|
||||
if icon_type == "weapon":
|
||||
icon = png_resize(icon, 500, 125)
|
||||
else:
|
||||
icon = png_resize(icon, 315, 79)
|
||||
elif game == "bf3":
|
||||
icon = get_icon_from_url(url)
|
||||
if icon_type == "weapon":
|
||||
icon = png_resize(icon, 500, 300)
|
||||
else:
|
||||
icon = png_resize(icon, 315, 200)
|
||||
elif game == "bf4":
|
||||
icon = get_icon_from_url(url)
|
||||
if icon_type == "weapon":
|
||||
icon = png_resize(icon, 500, 165)
|
||||
else:
|
||||
icon = png_resize(icon, 315, 80)
|
||||
elif game == "bfv":
|
||||
icon = get_icon_from_url(url)
|
||||
if icon_type == "weapon":
|
||||
icon = png_resize(icon, 500, 166)
|
||||
else:
|
||||
icon = png_resize(icon, 315, 315)
|
||||
# bf6 没有下载分支:缓存没命中时 icon 仍是 None,交给下面的占位图兜底
|
||||
if icon:
|
||||
icon_path = RES_DIR / "img" / game / icon_type / f"{name}.png"
|
||||
icon.save(icon_path)
|
||||
logger.info(f"文件已经缓存至:{icon_path}")
|
||||
|
||||
# 上述步骤处理完再执行一次icon判空
|
||||
if icon:
|
||||
return icon
|
||||
else:
|
||||
# 针对空图片处理
|
||||
if icon_type == "weapon":
|
||||
icon = Image.open(RES_DIR / "img" / "wp.png").convert("RGBA")
|
||||
else:
|
||||
icon = Image.open(RES_DIR / "img" / "vc.png").convert("RGBA")
|
||||
return icon
|
||||
|
||||
|
||||
# 获取icon
|
||||
def get_icon_from_url(url):
|
||||
if url:
|
||||
try:
|
||||
# 添加10s超时判断,如果超时返回空
|
||||
res = BytesIO(requests.get(url, timeout=10).content)
|
||||
icon = Image.open(res).convert("RGBA")
|
||||
return icon
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"请求异常:{e}")
|
||||
|
||||
|
||||
def get_icon_from_cache(icon_name, game, icon_type):
|
||||
path = RES_DIR / "img" / game / icon_type
|
||||
logger.info(f"查询物品:{icon_name}物品路径:{path}")
|
||||
try:
|
||||
icon_list = os.listdir(path)
|
||||
# logger.info(f"所有物品:{icon_list}")
|
||||
if icon_name in str(icon_list):
|
||||
logger.info(f"本地存在{icon_name}物品")
|
||||
img = Image.open(path / f"{icon_name}.png").convert("RGBA")
|
||||
return img
|
||||
except Exception as err:
|
||||
logger.error(f"获取图标失败:{str(err)}")
|
||||
return None
|
||||
|
||||
|
||||
def cutout_region(img, mask, alpha=0):
|
||||
"""
|
||||
在图像指定区域设置透明度
|
||||
:param img: 输入图像 (BGR 或 BGRA)
|
||||
:param mask: 区域定义
|
||||
- 矩形: (x1, y1, x2, y2)
|
||||
- 圆形: (cx, cy, r)
|
||||
- 任意 mask: numpy.ndarray (0/255)
|
||||
:param alpha: 设置的透明度 (0=透明, 255=不透明, 0-255之间半透明)
|
||||
:return: 处理后的图像 (BGRA)
|
||||
"""
|
||||
|
||||
# 转为 BGRA (确保有 alpha 通道)
|
||||
if img.shape[2] == 3:
|
||||
b, g, r = cv2.split(img)
|
||||
a = np.ones(b.shape, dtype=np.uint8) * 255
|
||||
img = cv2.merge((b, g, r, a))
|
||||
|
||||
h, w = img.shape[:2]
|
||||
region_mask = np.zeros((h, w), dtype=np.uint8)
|
||||
|
||||
# 根据传入参数类型生成区域 mask
|
||||
if isinstance(mask, tuple):
|
||||
if len(mask) == 4: # 矩形
|
||||
x1, y1, x2, y2 = mask
|
||||
region_mask[y1:y2, x1:x2] = 255
|
||||
elif len(mask) == 3: # 圆形
|
||||
cx, cy, r = mask
|
||||
cv2.circle(region_mask, (cx, cy), r, 255, -1)
|
||||
else:
|
||||
raise ValueError("不支持的 mask tuple 格式")
|
||||
elif isinstance(mask, np.ndarray): # 自定义 mask
|
||||
if mask.shape != (h, w):
|
||||
raise ValueError("mask 尺寸必须与图像一致")
|
||||
region_mask = mask
|
||||
else:
|
||||
raise TypeError("mask 必须是 tuple 或 numpy.ndarray")
|
||||
|
||||
# 修改 alpha 通道
|
||||
img[region_mask > 0, 3] = alpha
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def paste_image_cv2(fg, bg, pos):
|
||||
"""
|
||||
将前景图粘贴到背景图上,支持透明度 (Alpha 混合)
|
||||
:param fg: 前景图 (必须是 BGRA)
|
||||
:param bg: 背景图 (BGR 或 BGRA)
|
||||
:param pos: 粘贴位置 (x, y) 左上角坐标
|
||||
:return: 合成后的图像 (BGRA)
|
||||
"""
|
||||
|
||||
x, y = pos
|
||||
fh, fw = fg.shape[:2]
|
||||
|
||||
# 确保背景有 alpha 通道
|
||||
if bg.shape[2] == 3:
|
||||
b, g, r = cv2.split(bg)
|
||||
a = np.ones(b.shape, dtype=np.uint8) * 255
|
||||
bg = cv2.merge((b, g, r, a))
|
||||
|
||||
# 截取 ROI 区域
|
||||
roi = bg[y : y + fh, x : x + fw]
|
||||
|
||||
# 防止越界
|
||||
if roi.shape[0] != fh or roi.shape[1] != fw:
|
||||
raise ValueError("前景图超出背景范围")
|
||||
|
||||
# 提取 alpha 通道
|
||||
alpha_fg = fg[:, :, 3] / 255.0
|
||||
alpha_bg = 1.0 - alpha_fg
|
||||
|
||||
# 混合 RGB
|
||||
for c in range(3):
|
||||
roi[:, :, c] = alpha_fg * fg[:, :, c] + alpha_bg * roi[:, :, c]
|
||||
|
||||
# 更新 alpha 通道(取最大值,保证透明度正确)
|
||||
roi[:, :, 3] = np.clip(fg[:, :, 3] + roi[:, :, 3], 0, 255)
|
||||
|
||||
# 放回背景
|
||||
bg[y : y + fh, x : x + fw] = roi
|
||||
return bg
|
||||
|
||||
|
||||
def notice_paste(cv2_bg):
|
||||
"""贴公告栏(notice.png)到 BF1/3/4/5 卡片右上。
|
||||
|
||||
公告图是 RGBA(透明底)素材:必须按 alpha 混合,否则透明区域会以白底
|
||||
整块盖在卡片上。没有 alpha 通道的旧素材仍走「三角切角 + 不透明覆盖」老流程。
|
||||
"""
|
||||
pos = (1176, 318)
|
||||
front_img = cv2.imread(str(RES_DIR / "notice" / "notice.png"), cv2.IMREAD_UNCHANGED)
|
||||
if front_img is None:
|
||||
logger.warning("公告栏图片缺失(res/notice/notice.png),跳过公告栏绘制")
|
||||
return cv2_bg
|
||||
|
||||
if front_img.shape[2] == 4:
|
||||
return _paste_rgba(front_img, cv2_bg, pos)
|
||||
|
||||
# 旧素材(无 alpha):三角切角后按不透明贴图
|
||||
if front_img.shape[:2] != (631, 1355):
|
||||
logger.warning(
|
||||
f"公告栏图片尺寸 {front_img.shape[:2]} 与切角蒙版 (631, 1355) 不一致,跳过公告栏绘制"
|
||||
)
|
||||
return cv2_bg
|
||||
mask = np.zeros((631, 1355), dtype=np.uint8)
|
||||
pts = np.array([[0, 0], [167, 0], [0, 631]], np.int32)
|
||||
cv2.fillPoly(mask, [pts], 255)
|
||||
front_img = cutout_region(front_img, mask, alpha=0)
|
||||
return paste_image_cv2(front_img, cv2_bg, pos)
|
||||
|
||||
|
||||
def _paste_rgba(front_img, cv2_bg, pos):
|
||||
"""RGBA 前景按 alpha 混合到背景(超出背景的部分自动裁剪)。"""
|
||||
x, y = pos
|
||||
h, w = front_img.shape[:2]
|
||||
|
||||
# ROI 边界裁剪
|
||||
bg_h, bg_w = cv2_bg.shape[:2]
|
||||
w = min(w, bg_w - x)
|
||||
h = min(h, bg_h - y)
|
||||
|
||||
roi = cv2_bg[y : y + h, x : x + w]
|
||||
|
||||
alpha = front_img[:h, :w, 3] / 255.0
|
||||
alpha = alpha[:, :, None]
|
||||
|
||||
cv2_bg[y : y + h, x : x + w] = (
|
||||
alpha * front_img[:h, :w, :3] + (1 - alpha) * roi
|
||||
).astype(np.uint8)
|
||||
return cv2_bg
|
||||
|
||||
|
||||
def notice_paste_bf6(cv2_bg):
|
||||
notice_path = RES_DIR / "notice" / "notice_bf6.png"
|
||||
front_img = cv2.imread(str(notice_path), cv2.IMREAD_UNCHANGED)
|
||||
if front_img is None:
|
||||
logger.warning(f"公告栏图片缺失({notice_path}),跳过公告栏绘制")
|
||||
return cv2_bg
|
||||
|
||||
if front_img.shape[2] == 4:
|
||||
# 有 Alpha 通道 → Alpha 混合
|
||||
return _paste_rgba(front_img, cv2_bg, (0, 0))
|
||||
|
||||
# 无 Alpha → 直接覆盖(保留边界裁剪)
|
||||
bg_h, bg_w = cv2_bg.shape[:2]
|
||||
h = min(front_img.shape[0], bg_h)
|
||||
w = min(front_img.shape[1], bg_w)
|
||||
cv2_bg[0:h, 0:w] = front_img[:h, :w]
|
||||
return cv2_bg
|
||||
|
||||
|
||||
def designation_paste(
|
||||
player, cv2_bg, user_id, start_x=10, start_y=10, target_height=40, spacing=6
|
||||
):
|
||||
designation_list_path = get_designation_list_by_user_id(user_id, player)
|
||||
if not designation_list_path:
|
||||
return cv2_bg
|
||||
|
||||
icons = []
|
||||
total_width = 0
|
||||
|
||||
for path in designation_list_path:
|
||||
icon = cv2.imread(path, cv2.IMREAD_UNCHANGED)
|
||||
if icon is None:
|
||||
continue
|
||||
|
||||
h, w = icon.shape[:2]
|
||||
scale = target_height / h
|
||||
new_w = int(w * scale)
|
||||
|
||||
icon = cv2.resize(icon, (new_w, target_height))
|
||||
icons.append(icon)
|
||||
total_width += new_w
|
||||
|
||||
total_width += spacing * (len(icons) - 1)
|
||||
|
||||
x = start_x
|
||||
y = start_y # 关键变化点:直接使用 top-left
|
||||
|
||||
bg_h, bg_w = cv2_bg.shape[:2]
|
||||
|
||||
for icon in icons:
|
||||
ih, iw = icon.shape[:2]
|
||||
|
||||
if x + iw > bg_w or y + ih > bg_h:
|
||||
break
|
||||
|
||||
roi = cv2_bg[y : y + ih, x : x + iw]
|
||||
|
||||
if icon.shape[2] == 4:
|
||||
alpha = icon[:, :, 3] / 255.0
|
||||
for c in range(3):
|
||||
roi[:, :, c] = roi[:, :, c] * (1 - alpha) + icon[:, :, c] * alpha
|
||||
else:
|
||||
roi[:] = icon
|
||||
|
||||
cv2_bg[y : y + ih, x : x + iw] = roi
|
||||
x += iw + spacing
|
||||
|
||||
return cv2_bg
|
||||
|
||||
|
||||
def get_designation_list_by_user_id(user_id, player):
|
||||
# 标签是装饰性内容,查库失败(缺表、库损坏等)不该拖垮整张卡片,返回空即可
|
||||
try:
|
||||
usermanager = UserManager()
|
||||
designationmanager = DesignationManager()
|
||||
user_info = usermanager.get_user_by_qq(user_id)
|
||||
icon_no_list = []
|
||||
if (
|
||||
user_info is not None
|
||||
and user_info["ea_player_name"].upper() == player.upper()
|
||||
):
|
||||
logger.info(f"玩家标签列表:{user_info}")
|
||||
icon_no_list = user_info["designation"]
|
||||
icon_list = designationmanager.get_designation_by_id_list(icon_no_list)
|
||||
icon_path_list = []
|
||||
for icon in icon_list:
|
||||
icon_file_name = icon["icon_path"]
|
||||
icon_path_list.append(
|
||||
str(RES_DIR / "img" / "icon" / "designation" / icon_file_name)
|
||||
)
|
||||
return icon_path_list
|
||||
except Exception as err:
|
||||
logger.warning(f"获取玩家标签失败(跳过标签绘制):{str(err)}")
|
||||
return []
|
||||
|
||||
|
||||
def paste_rank_icon(rank, img):
|
||||
rank_pic_path = get_rank_pic(rank)
|
||||
rank_icon_path = RES_DIR / "img" / "icon" / "rank" / rank_pic_path
|
||||
rank_icon = Image.open(rank_icon_path)
|
||||
rank_icon = png_resize(rank_icon, new_width=132, new_height=192)
|
||||
fm_img = image_paste(rank_icon, img, (946, 61))
|
||||
return fm_img
|
||||
|
||||
|
||||
def get_rank_pic(rank: int) -> str:
|
||||
"""
|
||||
根据 rank 返回对应的 rank_pic 路径
|
||||
"""
|
||||
for (start, end), info in rank_pic.items():
|
||||
if start <= rank <= end:
|
||||
return info["path"]
|
||||
return "t_ui_rankswatch_1-9 1.png"
|
||||
@@ -0,0 +1,267 @@
|
||||
"""BF2042 卡片素材加载(背景库/物品图标/专家头像/兵种/logo/字体)。
|
||||
|
||||
素材 2026-10 从旧 project(dev/bf2042)整体迁移到 ``res/bf2042/``,
|
||||
这里提供"名字 → 图片"的统一入口:
|
||||
|
||||
- 物品图标(tracker.gg 的名字 vs 本地文件名)按 精确 → 归一化(去空格/
|
||||
连字符、大小写) → 别名表 → 占位图 四级回退;
|
||||
- 背景:特权用户(bindings 表 bf2042_support=1)优先个人图库,否则公共库随机;
|
||||
- 所有图片按需缩放并缓存(避免每次出图重复解码)。
|
||||
|
||||
纯 PIL,不依赖 nonebot,可脱离插件包单测(用 importlib 裸加载)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageFont
|
||||
|
||||
from ..paths import (
|
||||
BF2042_BG_DIR,
|
||||
BF2042_CLASS_ICON_DIR,
|
||||
BF2042_DEV_LOGO_DIR,
|
||||
BF2042_LOGO_DIR,
|
||||
BF2042_OBJECT_ICON_DIR,
|
||||
BF2042_PLATFORM_DIR,
|
||||
BF2042_SPECIALIST_ICON_DIR,
|
||||
RES_DIR,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"object_icon",
|
||||
"specialist_icon",
|
||||
"class_icon",
|
||||
"pick_background",
|
||||
"platform_icon",
|
||||
"bf2042_logo",
|
||||
"dev_logo",
|
||||
"no_pats_icon",
|
||||
"load_bf2042_font",
|
||||
]
|
||||
|
||||
# tracker.gg 名字 → 本地文件名(仅收录名字对不上的;实测全量扫描的差集)
|
||||
_ICON_ALIASES = {
|
||||
"bren": "Bren LMG",
|
||||
"ghostmaker 10": "Ghostmaker R10",
|
||||
"rpt-31": "RPT-3",
|
||||
"brawler": "CAV-BRAWLER",
|
||||
}
|
||||
|
||||
_FONT_DIR = RES_DIR / "font"
|
||||
|
||||
|
||||
def _normalize(name: str) -> str:
|
||||
"""去空格/连字符、统一小写,用于宽松匹配。"""
|
||||
return re.sub(r"[\s\-_]", "", (name or "").strip()).lower()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _icon_index() -> dict[str, Path]:
|
||||
"""本地图标索引:归一化文件名 → 路径。"""
|
||||
index: dict[str, Path] = {}
|
||||
if BF2042_OBJECT_ICON_DIR.exists():
|
||||
for f in BF2042_OBJECT_ICON_DIR.iterdir():
|
||||
if f.suffix.lower() in (".png", ".webp", ".jpg"):
|
||||
index[_normalize(f.stem)] = f
|
||||
return index
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _load_scaled(path_str: str, width: int, height: int) -> Image.Image:
|
||||
"""等比缩放到 (width, height) 框内(contain),居中垫透明边。
|
||||
|
||||
素材形状不一(专家头像 300×360、武器/载具横条),直接 resize 会拉伸;
|
||||
这里统一按比例适配后居中,给渲染层一个固定尺寸的贴图。
|
||||
"""
|
||||
img = Image.open(path_str).convert("RGBA")
|
||||
if width and height:
|
||||
scale = min(width / img.width, height / img.height)
|
||||
new_size = (max(1, round(img.width * scale)), max(1, round(img.height * scale)))
|
||||
resized = img.resize(new_size, Image.Resampling.LANCZOS)
|
||||
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
canvas.alpha_composite(
|
||||
resized,
|
||||
dest=((width - new_size[0]) // 2, (height - new_size[1]) // 2),
|
||||
)
|
||||
canvas.load()
|
||||
return canvas
|
||||
if width:
|
||||
img = img.resize(
|
||||
(width, max(1, int(img.height * width / img.width))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
img.load() # 立即解码,后续缓存持有真实像素
|
||||
return img
|
||||
|
||||
|
||||
def _placeholder(width: int, height: int) -> Image.Image:
|
||||
return _load_scaled(str(BF2042_OBJECT_ICON_DIR / "default.png"), width, height)
|
||||
|
||||
|
||||
def object_icon(name: str, width: int = 0, height: int = 0) -> Image.Image:
|
||||
"""物品(武器/载具)图标。四 级回退:精确 → 归一化 → 别名 → 占位图。"""
|
||||
key = (name or "").strip()
|
||||
if key:
|
||||
# 1) 精确/大小写
|
||||
direct = BF2042_OBJECT_ICON_DIR / f"{key}.png"
|
||||
if direct.exists():
|
||||
return _load_scaled(str(direct), width, height)
|
||||
# 2) 归一化
|
||||
hit = _icon_index().get(_normalize(key))
|
||||
if hit:
|
||||
return _load_scaled(str(hit), width, height)
|
||||
# 3) 别名表
|
||||
alias = _ICON_ALIASES.get(key.lower()) or _ICON_ALIASES.get(_normalize(key))
|
||||
if alias:
|
||||
alias_hit = _icon_index().get(_normalize(alias))
|
||||
if alias_hit:
|
||||
return _load_scaled(str(alias_hit), width, height)
|
||||
# 4) 占位
|
||||
return _placeholder(width, height)
|
||||
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def _specialist_index() -> dict[str, Path]:
|
||||
"""specialist_icon/ 索引:专家头像(500×500 方形,卡片用这套)。"""
|
||||
index: dict[str, Path] = {}
|
||||
if BF2042_SPECIALIST_ICON_DIR.exists():
|
||||
for f in BF2042_SPECIALIST_ICON_DIR.iterdir():
|
||||
if f.suffix.lower() in (".png", ".webp", ".jpg"):
|
||||
index[_normalize(f.stem)] = f
|
||||
return index
|
||||
|
||||
|
||||
def specialist_icon(name: str, width: int = 0, height: int = 0) -> Image.Image:
|
||||
"""专家头像(tracker.gg soldier 段的 metadata.name,如 "Falck")。
|
||||
|
||||
用 specialist_icon/(方形头像)那套素材,不是 classes/(竖版立绘)。
|
||||
"""
|
||||
key = _normalize(name or "")
|
||||
hit = _specialist_index().get(key)
|
||||
if hit:
|
||||
return _load_scaled(str(hit), width, height)
|
||||
fallback = BF2042_SPECIALIST_ICON_DIR / "No-Pats.png"
|
||||
if not fallback.exists():
|
||||
fallback = BF2042_CLASS_ICON_DIR / "No-Pats.png"
|
||||
return _load_scaled(str(fallback), width, height)
|
||||
|
||||
|
||||
def class_icon(category: str, width: int = 0, height: int = 0) -> Image.Image:
|
||||
"""兵种图标(Assault/Support/Recon/Engineer);取不到回 No-Pats。"""
|
||||
mapping = {
|
||||
"assault": "ui/Assault_Icon_small.png",
|
||||
"support": "ui/Support_Icon_small.png",
|
||||
"recon": "ui/Recon_Icon_small.png",
|
||||
"engineer": "ui/Engineer_Icon_small.png",
|
||||
}
|
||||
rel = mapping.get((category or "").strip().lower())
|
||||
if rel and (BF2042_CLASS_ICON_DIR / rel).exists():
|
||||
return _load_scaled(str(BF2042_CLASS_ICON_DIR / rel), width, height)
|
||||
return _load_scaled(str(BF2042_CLASS_ICON_DIR / "No-Pats.png"), width, height)
|
||||
|
||||
|
||||
def no_pats_icon(width: int = 0, height: int = 0) -> Image.Image:
|
||||
"""默认头像占位(EA 头像暂不可用,一律用这张)。"""
|
||||
return _load_scaled(str(BF2042_CLASS_ICON_DIR / "No-Pats.png"), width, height)
|
||||
|
||||
|
||||
def _random_image_from(directory: Path) -> Image.Image | None:
|
||||
try:
|
||||
files = [
|
||||
f
|
||||
for f in os.listdir(directory)
|
||||
if Path(f).suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")
|
||||
]
|
||||
except OSError:
|
||||
return None
|
||||
if not files:
|
||||
return None
|
||||
return _load_scaled(str(directory / random.choice(files)), 1920, 1080)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _common_bg_list() -> tuple[str, ...]:
|
||||
try:
|
||||
return tuple(
|
||||
f
|
||||
for f in os.listdir(BF2042_BG_DIR / "common")
|
||||
if Path(f).suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")
|
||||
)
|
||||
except OSError:
|
||||
return ()
|
||||
|
||||
|
||||
def pick_background(qq_id: str, support: bool) -> Image.Image:
|
||||
"""生涯卡背景。
|
||||
|
||||
特权用户(bindings.bf2042_support=1)优先个人图库 ``bg/user/{qq}/``;
|
||||
没有个人图或个人图库为空 → 公共库 ``bg/common/`` 随机。
|
||||
"""
|
||||
if support and qq_id:
|
||||
personal = BF2042_BG_DIR / "user" / str(qq_id)
|
||||
img = _random_image_from(personal)
|
||||
if img is not None:
|
||||
return img
|
||||
|
||||
files = _common_bg_list()
|
||||
if files:
|
||||
return _load_scaled(
|
||||
str(BF2042_BG_DIR / "common" / random.choice(files)), 1920, 1080
|
||||
)
|
||||
# 素材缺失时的兜底:纯色深底
|
||||
return Image.new("RGBA", (1920, 1080), (20, 24, 30, 255))
|
||||
|
||||
|
||||
def platform_icon(platform: str, width: int = 40, height: int = 40) -> Image.Image:
|
||||
"""平台图标;slug 与旧 project 文件名对齐(origin/steam/xbl→xbox/psn→playstation)。"""
|
||||
slug = (platform or "").lower()
|
||||
name = {
|
||||
"origin": "origin.png",
|
||||
"pc": "origin.png",
|
||||
"steam": "steam.png",
|
||||
"xbl": "xbox.png",
|
||||
"xboxone": "xbox.png",
|
||||
"psn": "playstation.png",
|
||||
"ps4": "playstation.png",
|
||||
}.get(slug, "origin.png")
|
||||
path = BF2042_PLATFORM_DIR / name
|
||||
if not path.exists():
|
||||
path = BF2042_PLATFORM_DIR / "origin.png"
|
||||
return _load_scaled(str(path), width, height)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def bf2042_logo(width: int = 0, height: int = 0) -> Image.Image:
|
||||
path = BF2042_LOGO_DIR / "bf2042logo.png"
|
||||
return _load_scaled(str(path), width, height)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def dev_logo(width: int = 0, height: int = 0) -> Image.Image:
|
||||
# 旧 project img/dev_logo 下只有一个文件,名字不固定,取第一个
|
||||
try:
|
||||
files = sorted(os.listdir(BF2042_DEV_LOGO_DIR))
|
||||
files = [
|
||||
f for f in files if Path(f).suffix.lower() in (".png", ".webp", ".jpg")
|
||||
]
|
||||
if files:
|
||||
return _load_scaled(str(BF2042_DEV_LOGO_DIR / files[0]), width, height)
|
||||
except OSError:
|
||||
pass
|
||||
return Image.new("RGBA", (width or 1, height or 1), (0, 0, 0, 0))
|
||||
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def load_bf2042_font(size: int, kind: str = "ch") -> ImageFont.FreeTypeFont:
|
||||
"""卡片字体。
|
||||
|
||||
kind: ch=中文(NotoSansSCMedium-4) / en=英数(BF_Modernista-Bold)。
|
||||
"""
|
||||
name = "NotoSansSCMedium-4.ttf" if kind == "ch" else "BF_Modernista-Bold.ttf"
|
||||
path = _FONT_DIR / name
|
||||
return ImageFont.truetype(str(path), size)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,435 @@
|
||||
"""BF2042 战绩卡渲染(tracker.gg 通道专用)。
|
||||
|
||||
- 生涯卡 ``build_bf2042_career_card``:照旧 project(dev/bf2042)的
|
||||
`bf_2042_gen_pic` 版式重绘(1920×1080 四面板 + TOP5 武器/载具);
|
||||
- 数据槽位用旧字段契约(见 ``services.processing.build_bf2042_career_card``),
|
||||
没有的字段由数据层填 "–" 占位;
|
||||
- 榜单卡 ``build_bf2042_list_card``:轻量文字榜单(武器/载具/模式)。
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from .images import load_avatar
|
||||
from .images_bf2042 import (
|
||||
bf2042_logo,
|
||||
load_bf2042_font,
|
||||
no_pats_icon,
|
||||
object_icon,
|
||||
pick_background,
|
||||
platform_icon,
|
||||
specialist_icon,
|
||||
)
|
||||
|
||||
__all__ = ["build_bf2042_career_card", "build_bf2042_list_card"]
|
||||
|
||||
# 配色(旧版风格)
|
||||
_WHITE = (255, 255, 255, 255)
|
||||
_PANEL = (0, 0, 0, 150)
|
||||
_GREEN = (144, 238, 144, 255) # lightgreen 标题
|
||||
_SKY = (135, 206, 235, 255) # skyblue 名字
|
||||
_GOLD = (255, 215, 0, 255)
|
||||
_LINE_TOP = (204, 255, 0, 255) # TOP3 黄绿
|
||||
_LINE_REST = (102, 204, 255, 255) # TOP4-5 蓝
|
||||
_VERDICT_COLOR = {"gold": _GOLD, "skyblue": _SKY}
|
||||
|
||||
|
||||
def _draw_rect(img: Image.Image, box, radius: int, fill) -> None:
|
||||
"""圆角半透明面板(旧项目 draw_rect 的等价实现)。"""
|
||||
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
||||
ImageDraw.Draw(overlay).rounded_rectangle(box, radius=radius, fill=fill)
|
||||
img.alpha_composite(overlay)
|
||||
|
||||
|
||||
def _fit(draw: ImageDraw.ImageDraw, text: str, font, max_w: float) -> str:
|
||||
"""超宽截断加省略号。"""
|
||||
text = str(text)
|
||||
if draw.textlength(text, font=font) <= max_w:
|
||||
return text
|
||||
while text and draw.textlength(text + "…", font=font) > max_w:
|
||||
text = text[:-1]
|
||||
return text + "…"
|
||||
|
||||
|
||||
def _paste(img: Image.Image, source: Image.Image, pos) -> None:
|
||||
img.alpha_composite(source, (int(pos[0]), int(pos[1])))
|
||||
|
||||
|
||||
def _paste_circle(img: Image.Image, source: Image.Image, pos, radius: int) -> None:
|
||||
"""贴图并做圆角。"""
|
||||
src = source.convert("RGBA")
|
||||
mask = Image.new("L", src.size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle(
|
||||
(0, 0, src.width, src.height), radius=radius, fill=255
|
||||
)
|
||||
img.paste(src, (int(pos[0]), int(pos[1])), mask)
|
||||
|
||||
|
||||
def _qq_avatar(qq_id: str, size: int) -> Image.Image | None:
|
||||
"""QQ 头像(load_avatar 带缓存与占位兜底);取不到返回 None。"""
|
||||
if not qq_id:
|
||||
return None
|
||||
try:
|
||||
bgra = load_avatar(str(qq_id), size)
|
||||
if bgra is None or not getattr(bgra, "size", 0):
|
||||
return None
|
||||
rgba = cv2.cvtColor(bgra, cv2.COLOR_BGRA2RGBA)
|
||||
return Image.fromarray(np.ascontiguousarray(rgba), "RGBA")
|
||||
except Exception: # noqa: BLE001 - 头像拿不到就回内置占位
|
||||
return None
|
||||
|
||||
|
||||
def _paste_ic_logo(img: Image.Image) -> None:
|
||||
"""底部信息栏(旧 project `paste_ic_logo` 的移植,按需精简)。
|
||||
|
||||
保留:数据来源(tracker.gg)/ 插件作者 / 查询时间 / 友情合作(贴吧官群)。
|
||||
已按用户要求去掉:logo 图、友情赞助两段、铁幕重工。
|
||||
"""
|
||||
import datetime
|
||||
|
||||
_draw_rect(img, (25, 1050, 1895, 1075), 1, _PANEL)
|
||||
draw = ImageDraw.Draw(img)
|
||||
f_en = load_bf2042_font(20, "en")
|
||||
f_ch = load_bf2042_font(20, "ch")
|
||||
f_ch_s = load_bf2042_font(18, "ch")
|
||||
|
||||
# 左:数据来源
|
||||
draw.text((30, 1050), "Data Source : TRACKER.GG", fill=_WHITE, font=f_en)
|
||||
|
||||
# 中:插件作者(左组与右侧块之间居中)
|
||||
info1, info2 = "Plugin Designed By", "SANSENHOSHI"
|
||||
f_en_s = load_bf2042_font(18, "en")
|
||||
w1 = draw.textlength(info1, font=f_en_s)
|
||||
w2 = draw.textlength(info2, font=f_en_s)
|
||||
x0 = (360 + 1180 - (w1 + w2 + 20)) / 2
|
||||
draw.text((x0, 1052), info1, fill=_WHITE, font=f_en_s)
|
||||
draw.text((x0 + w1 + 20, 1052), info2, fill=_SKY, font=f_en_s)
|
||||
|
||||
# 右:查询时间 + 友情合作(从右往左排)
|
||||
now = datetime.datetime.now()
|
||||
t_text = "查询时间: " + now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
p2 = "贴吧官群:1019165018"
|
||||
w_t = draw.textlength(t_text, font=f_ch)
|
||||
x_p2 = 1870 - round(draw.textlength(p2, font=f_ch_s))
|
||||
x_coop = round(x_p2 - draw.textlength("友情合作:", font=f_ch_s) - 24)
|
||||
x_time = round(x_coop - w_t - 40)
|
||||
draw.text((x_time, 1052), t_text, fill=_WHITE, font=f_ch)
|
||||
draw.text((x_coop, 1052), "友情合作:", fill=_WHITE, font=f_ch_s)
|
||||
draw.text((x_p2, 1052), p2, fill=(153, 204, 0, 255), font=f_ch_s)
|
||||
|
||||
|
||||
def build_bf2042_career_card(card: dict):
|
||||
"""生涯卡:1920×1080,返回 BytesIO(PNG)。
|
||||
|
||||
版式照旧 `bf_2042_gen_pic`:
|
||||
[玩家信息 768] [最佳专家 550] [对局数据 552] y 25~262
|
||||
[生涯数据 1870×285 四列 + 鉴定] y 275~560
|
||||
[TOP5 武器 | TOP5 载具] y 575~1044
|
||||
"""
|
||||
card = card or {}
|
||||
qq_id = str(card.get("_qq_id") or "")
|
||||
support = bool(card.get("_support"))
|
||||
|
||||
# 1) 背景(特权用户个人图库 > 公共库随机)
|
||||
new_img = pick_background(qq_id, support).convert("RGBA")
|
||||
|
||||
# 2) 右上角 logo
|
||||
logo = bf2042_logo(130, 130)
|
||||
_paste_circle(new_img, logo, (1762, 30), 10)
|
||||
|
||||
# 3) 面板
|
||||
_draw_rect(new_img, (25, 25, 793, 262), 10, _PANEL) # 玩家信息
|
||||
_draw_rect(new_img, (808, 25, 1358, 262), 10, _PANEL) # 最佳专家
|
||||
_draw_rect(new_img, (1373, 25, 1895, 262), 10, _PANEL) # 对局数据
|
||||
_draw_rect(new_img, (25, 275, 1895, 560), 10, _PANEL) # 生涯数据
|
||||
_draw_rect(new_img, (25, 575, 1895, 1044), 10, _PANEL) # TOP5
|
||||
|
||||
draw = ImageDraw.Draw(new_img)
|
||||
f22 = load_bf2042_font(22, "ch")
|
||||
f24 = load_bf2042_font(24, "ch")
|
||||
f26 = load_bf2042_font(26, "ch")
|
||||
f28 = load_bf2042_font(28, "ch")
|
||||
f30 = load_bf2042_font(30, "ch")
|
||||
f28e = load_bf2042_font(28, "en")
|
||||
f32e = load_bf2042_font(32, "en")
|
||||
f38 = load_bf2042_font(38, "ch")
|
||||
|
||||
# ---- ① 玩家信息 ----
|
||||
avatar = _qq_avatar(qq_id, 140) or no_pats_icon(140, 140)
|
||||
_paste_circle(new_img, avatar, (30, 30), 10)
|
||||
|
||||
draw.text((196, 36), "玩家:", fill=_WHITE, font=f28)
|
||||
name = _fit(draw, card.get("userName", "—"), f32e, 320)
|
||||
draw.text((268, 32), name, fill=_WHITE, font=f32e)
|
||||
|
||||
draw.text((196, 84), "LEVEL: ", fill=_WHITE, font=f30)
|
||||
draw.text((292, 84), str(card.get("level", "—")), fill=_WHITE, font=f30)
|
||||
|
||||
plat = platform_icon(card.get("platform", "origin"), 34, 34)
|
||||
_paste(new_img, plat, (199, 130))
|
||||
draw.text((240, 132), "游玩时长:", fill=_WHITE, font=f26)
|
||||
draw.text((378, 128), f"{card.get('secondsPlayed', '—')} H", fill=_WHITE, font=f30)
|
||||
|
||||
# ---- ② 最佳专家 ----
|
||||
best = card.get("bestClass") or {}
|
||||
draw.text((826, 62), "最 佳", fill=_GREEN, font=f38)
|
||||
draw.text((826, 117), "专 家", fill=_GREEN, font=f38)
|
||||
if best:
|
||||
icon = specialist_icon(best.get("characterName", ""), 88, 88)
|
||||
_paste(new_img, icon, (924, 42))
|
||||
draw.text(
|
||||
(1024, 46),
|
||||
str(best.get("displayName") or best.get("characterName", "—")),
|
||||
fill=_SKY,
|
||||
font=f28,
|
||||
)
|
||||
draw.text(
|
||||
(1024, 86), f"K/D:{best.get('killDeath', '—')}", fill=_WHITE, font=f22
|
||||
)
|
||||
draw.text((1024, 118), f"KPM:{best.get('kpm', '—')}", fill=_WHITE, font=f22)
|
||||
draw.text((1024, 150), f"击杀:{best.get('kills', '—')}", fill=_WHITE, font=f22)
|
||||
draw.text(
|
||||
(1024, 182),
|
||||
f"时长:{best.get('secondsPlayed', 0) // 3600} H",
|
||||
fill=_WHITE,
|
||||
font=f22,
|
||||
)
|
||||
else:
|
||||
draw.text((924, 120), "暂无数据", fill=_WHITE, font=f30)
|
||||
|
||||
# ---- ③ 对局数据(旧三行:场数 / MVP·最佳小队 / 处决) ----
|
||||
x3 = 1396
|
||||
draw.text(
|
||||
(x3, 58), f"游玩场数: {card.get('matchesPlayed', '—')}", fill=_WHITE, font=f28
|
||||
)
|
||||
draw.text((x3, 118), f"MVP: {card.get('mvp', '—')}", fill=_WHITE, font=f28)
|
||||
draw.text(
|
||||
(x3, 178), f"处决: {card.get('playerTakeDowns', '—')}", fill=_WHITE, font=f28
|
||||
)
|
||||
|
||||
# ---- ④ 生涯数据(四列) ----
|
||||
def cell(x: float, y: float, label: str, value) -> None:
|
||||
draw.text((x, y), label, fill=_WHITE, font=f28)
|
||||
draw.text((x + 232, y), _fit(draw, str(value), f28, 205), fill=_WHITE, font=f28)
|
||||
|
||||
y0 = 290
|
||||
step = 50
|
||||
col1, col2, col3, col4 = 52, 500, 948, 1373
|
||||
|
||||
cell(col1, y0, "K/D:", card.get("killDeath", "—"))
|
||||
cell(col1, y0 + step, "真实 K/D:", card.get("infantryKillDeath", "—"))
|
||||
cell(col1, y0 + step * 2, "击杀:", card.get("kills", "—"))
|
||||
cell(
|
||||
col1,
|
||||
y0 + step * 3,
|
||||
"载具击杀:",
|
||||
(card.get("dividedKills") or {}).get("vehicle", "—"),
|
||||
)
|
||||
cell(col1, y0 + step * 4, "死亡数:", card.get("deaths", "—"))
|
||||
|
||||
cell(col2, y0, "KPM:", card.get("killsPerMinute", "—"))
|
||||
cell(col2, y0 + step, "真实 KPM:", card.get("realKpm", "—"))
|
||||
cell(col2, y0 + step * 2, "爆头率:", card.get("headShots", "—"))
|
||||
cell(col2, y0 + step * 3, "命中率:", card.get("accuracy", "—"))
|
||||
cell(col2, y0 + step * 4, "胜率:", card.get("winPercent", "—"))
|
||||
|
||||
cell(col3, y0, "AI 击杀:", (card.get("dividedKills") or {}).get("ai", "—"))
|
||||
cell(col3, y0 + step, "场均击杀:", card.get("killsPerMatch", "—"))
|
||||
cell(col3, y0 + step * 2, "急救数:", card.get("revives", "—"))
|
||||
cell(col3, y0 + step * 3, "标记敌人数:", card.get("enemiesSpotted", "—"))
|
||||
cell(col3, y0 + step * 4, "摧毁载具数:", card.get("vehiclesDestroyed", "—"))
|
||||
|
||||
draw.text((col4, y0 - 8), "机器棱鉴定(仅供参考):", fill=_WHITE, font=f22)
|
||||
verdict = str(card.get("verdict") or "未知")
|
||||
color = _VERDICT_COLOR.get(str(card.get("verdictColor") or ""), _WHITE)
|
||||
draw.multiline_text((col4, y0 + 28), verdict, fill=color, font=f26, spacing=6)
|
||||
draw.text((col4, y0 + 140), "联BAN查询:", fill=_WHITE, font=f24)
|
||||
draw.text((col4, y0 + 178), "–", fill=_GOLD, font=f24)
|
||||
|
||||
# ---- ⑤ TOP5 武器 / 载具 ----
|
||||
draw.line((950, 600, 950, 1032), fill=_WHITE, width=3)
|
||||
|
||||
def draw_top_list(
|
||||
col_x: float,
|
||||
rows: list[dict],
|
||||
icon_size: tuple[int, int],
|
||||
name_key: str,
|
||||
name_w: float,
|
||||
title: str,
|
||||
is_vehicle: bool,
|
||||
) -> None:
|
||||
draw.text((col_x, 588), title, fill=_GREEN, font=f30)
|
||||
row_y = 630
|
||||
for i, row in enumerate(rows[:5]):
|
||||
color = _LINE_TOP if i < 3 else _LINE_REST
|
||||
draw.line((col_x, row_y + 6, col_x, row_y + 72), fill=color, width=5)
|
||||
icon = object_icon(row.get(name_key, ""), *icon_size)
|
||||
_paste(new_img, icon, (col_x + 14, row_y + 4))
|
||||
name_x = col_x + 14 + icon_size[0] + 16
|
||||
label = _fit(draw, str(row.get(name_key, "—")), f28e, name_w)
|
||||
draw.text((name_x, row_y + 2), label, fill=_WHITE, font=f28e)
|
||||
draw.text(
|
||||
(name_x, row_y + 40),
|
||||
f"击杀:{row.get('kills', '—')}",
|
||||
fill=_WHITE,
|
||||
font=f26,
|
||||
)
|
||||
if is_vehicle:
|
||||
draw.text(
|
||||
(name_x + 190, row_y + 2),
|
||||
f"KPM:{row.get('killsPerMinute', '—')}",
|
||||
fill=_WHITE,
|
||||
font=f22,
|
||||
)
|
||||
draw.text(
|
||||
(name_x + 190, row_y + 40),
|
||||
f"摧毁数:{row.get('destroyed', '—')}",
|
||||
fill=_WHITE,
|
||||
font=f22,
|
||||
)
|
||||
else:
|
||||
draw.text(
|
||||
(name_x + 180, row_y + 2),
|
||||
f"爆头率:{row.get('headshots', '—')}",
|
||||
fill=_WHITE,
|
||||
font=f22,
|
||||
)
|
||||
draw.text(
|
||||
(name_x + 180, row_y + 40),
|
||||
f"命中率:{row.get('accuracy', '—')}",
|
||||
fill=_WHITE,
|
||||
font=f22,
|
||||
)
|
||||
draw.text(
|
||||
(name_x + 360, row_y + 2),
|
||||
f"KPM:{row.get('killsPerMinute', '—')}",
|
||||
fill=_WHITE,
|
||||
font=f22,
|
||||
)
|
||||
draw.text(
|
||||
(name_x + 360, row_y + 40),
|
||||
f"时长:{row.get('timeEquipped', 0) // 3600} H",
|
||||
fill=_WHITE,
|
||||
font=f22,
|
||||
)
|
||||
row_y += 82
|
||||
|
||||
draw_top_list(
|
||||
55, card.get("weapons") or [], (150, 66), "weaponName", 170, "TOP5 武器", False
|
||||
)
|
||||
draw_top_list(
|
||||
985,
|
||||
card.get("vehicles") or [],
|
||||
(240, 66),
|
||||
"vehicleName",
|
||||
180,
|
||||
"TOP5 载具",
|
||||
True,
|
||||
)
|
||||
|
||||
# 底部信息栏(数据来源/赞助/作者/时间/合作)
|
||||
_paste_ic_logo(new_img)
|
||||
|
||||
b_io = io.BytesIO()
|
||||
new_img.convert("RGB").save(b_io, format="PNG")
|
||||
return b_io
|
||||
|
||||
|
||||
def build_bf2042_list_card(info: dict):
|
||||
"""榜单卡:武器 / 载具 / 模式共用(轻量文字版)。
|
||||
|
||||
info 形如 {"玩家名称":.., "标题":"武器", "列名":[...], "行":[{"名称","标签","值":[...]}]},
|
||||
数值列统一右对齐;名称过长自动截断,类别跟在名字后面同排显示。
|
||||
"""
|
||||
rows = info.get("行") or []
|
||||
columns = info.get("列名") or []
|
||||
count = max(len(columns), 1)
|
||||
|
||||
width = 1100
|
||||
pad = 50
|
||||
value_left = 470
|
||||
gap = 24
|
||||
bg = (20, 24, 30, 255)
|
||||
accent = (0, 168, 224, 255)
|
||||
fg = (236, 240, 244, 255)
|
||||
muted = (150, 160, 172, 255)
|
||||
|
||||
f_s = load_bf2042_font(28, "ch")
|
||||
f_xl = load_bf2042_font(44, "ch")
|
||||
|
||||
header_h = 170
|
||||
rows_y = header_h + 80
|
||||
row_h = 46
|
||||
height = rows_y + max(len(rows), 1) * row_h + 30
|
||||
|
||||
card = Image.new("RGBA", (width, height), bg)
|
||||
draw = ImageDraw.Draw(card)
|
||||
|
||||
draw.text((pad, 40), f"战地2042 · {info.get('玩家名称', '—')}", fill=fg, font=f_xl)
|
||||
draw.text(
|
||||
(pad, 112),
|
||||
f"{info.get('标题', '')}榜 · 前 {len(rows)} 名",
|
||||
fill=muted,
|
||||
font=f_s,
|
||||
)
|
||||
draw.line((pad, header_h, width - pad, header_h), fill=accent, width=3)
|
||||
|
||||
widths = []
|
||||
for i in range(count):
|
||||
widest = draw.textlength(columns[i], font=f_s) if i < len(columns) else 0
|
||||
for row in rows:
|
||||
values = row.get("值") or []
|
||||
if i < len(values):
|
||||
widest = max(widest, draw.textlength(str(values[i]), font=f_s))
|
||||
widths.append(widest)
|
||||
|
||||
avail = (width - pad) - value_left
|
||||
room = avail - gap * (count - 1)
|
||||
scale = min(1.0, room / sum(widths)) if sum(widths) else 1.0
|
||||
widths = [w * scale for w in widths]
|
||||
|
||||
edges = []
|
||||
cursor = float(width - pad)
|
||||
for w in reversed(widths):
|
||||
edges.append(cursor)
|
||||
cursor -= w + gap
|
||||
edges.reverse()
|
||||
|
||||
name_w = max(200.0, (edges[0] - widths[0]) - gap - pad)
|
||||
|
||||
def cell(text: str, right_x: float, row_y: int, color) -> None:
|
||||
text = str(text)
|
||||
draw.text(
|
||||
(right_x - draw.textlength(text, font=f_s), row_y),
|
||||
text,
|
||||
fill=color,
|
||||
font=f_s,
|
||||
)
|
||||
|
||||
head_y = header_h + 30
|
||||
draw.text((pad, head_y), "名称", fill=muted, font=f_s)
|
||||
for name, edge in zip(columns, edges):
|
||||
cell(name, edge, head_y, muted)
|
||||
draw.line((pad, head_y + 34, width - pad, head_y + 34), fill=muted, width=1)
|
||||
|
||||
y = rows_y
|
||||
if not rows:
|
||||
draw.text((pad, y), "(无数据)", fill=muted, font=f_s)
|
||||
for rank, row in enumerate(rows, 1):
|
||||
label = _fit(draw, f"{rank}. {row.get('名称', '—')}", f_s, name_w)
|
||||
draw.text((pad, y), label, fill=fg, font=f_s)
|
||||
tag = row.get("标签")
|
||||
if tag:
|
||||
used = draw.textlength(label, font=f_s)
|
||||
suffix = _fit(draw, f" · {tag}", f_s, name_w - used)
|
||||
draw.text((pad + used, y), suffix, fill=muted, font=f_s)
|
||||
for value, edge in zip(row.get("值") or [], edges):
|
||||
cell(value, edge, y, fg)
|
||||
y += row_h
|
||||
|
||||
b_io = io.BytesIO()
|
||||
card.save(b_io, format="PNG")
|
||||
return b_io
|
||||
@@ -0,0 +1,917 @@
|
||||
"""EA 系(BF1/3/4/5/6)战绩卡渲染:OpenCV 拼模板 + PIL 绘字,输出 PNG BytesIO。"""
|
||||
|
||||
import datetime
|
||||
import io
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from nonebot import logger
|
||||
|
||||
from ..paths import RES_DIR
|
||||
from .images import (
|
||||
designation_paste,
|
||||
draw_centered_text,
|
||||
draw_right_aligned_text,
|
||||
get_icon_from_cache,
|
||||
get_save_icon,
|
||||
image_paste,
|
||||
image_paste_center,
|
||||
load_avatar,
|
||||
notice_paste,
|
||||
notice_paste_bf6,
|
||||
paste_rank_icon,
|
||||
png_resize,
|
||||
)
|
||||
from .params import bf_item, classes
|
||||
from .text import add_commas, add_commas_with_padding
|
||||
|
||||
# 字体
|
||||
_FONT_NUM = RES_DIR / "font" / "bf-sub-headline-bold 等宽数字.ttf"
|
||||
_FONT_CH = RES_DIR / "font" / "演示创黑FLY.ttf"
|
||||
|
||||
font_XXL = ImageFont.truetype(str(_FONT_NUM), 72)
|
||||
font_XL = ImageFont.truetype(str(_FONT_NUM), 64)
|
||||
font_L = ImageFont.truetype(str(_FONT_NUM), 48)
|
||||
font_ML = ImageFont.truetype(str(_FONT_NUM), 42)
|
||||
font_M = ImageFont.truetype(str(_FONT_NUM), 36)
|
||||
font_MS = ImageFont.truetype(str(_FONT_NUM), 32)
|
||||
font_S = ImageFont.truetype(str(_FONT_NUM), 28)
|
||||
font_XS = ImageFont.truetype(str(_FONT_NUM), 24)
|
||||
font_XXS = ImageFont.truetype(str(_FONT_NUM), 18)
|
||||
font_XXXS = ImageFont.truetype(str(_FONT_NUM), 16)
|
||||
|
||||
font_XXL_CH = ImageFont.truetype(str(_FONT_CH), 72)
|
||||
font_XL_CH = ImageFont.truetype(str(_FONT_CH), 64)
|
||||
font_L_CH = ImageFont.truetype(str(_FONT_CH), 48)
|
||||
font_ML_CH = ImageFont.truetype(str(_FONT_CH), 42)
|
||||
font_M_CH = ImageFont.truetype(str(_FONT_CH), 36)
|
||||
font_MS_CH = ImageFont.truetype(str(_FONT_CH), 32)
|
||||
font_S_CH = ImageFont.truetype(str(_FONT_CH), 28)
|
||||
font_XS_CH = ImageFont.truetype(str(_FONT_CH), 24)
|
||||
font_XXS_CH = ImageFont.truetype(str(_FONT_CH), 18)
|
||||
font_XXXS_CH = ImageFont.truetype(str(_FONT_CH), 16)
|
||||
|
||||
|
||||
async def build_stats_card(
|
||||
game="bfv",
|
||||
player="Player001",
|
||||
pid=1145141919,
|
||||
kd=1.25,
|
||||
kpm=2.3,
|
||||
spm=500,
|
||||
acc="25.6",
|
||||
head_shots="10",
|
||||
rank=100,
|
||||
time_play=100.0,
|
||||
kills=100,
|
||||
kill_assists=150,
|
||||
revives=114,
|
||||
wins=10,
|
||||
loses=10,
|
||||
destroyed=514,
|
||||
best_weapon="M1907 SF",
|
||||
best_vehicle="坦克",
|
||||
best_class="Support",
|
||||
longest_head_shot=1200,
|
||||
highest_ill_streak=100,
|
||||
stat_data=None,
|
||||
level_designation=None,
|
||||
qq_id=None,
|
||||
):
|
||||
if stat_data is None:
|
||||
stat_data = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
|
||||
|
||||
# 获取当前事件并且格式化
|
||||
now = datetime.datetime.now()
|
||||
formatted_time = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# ---------------- OpenCV 部分 ----------------
|
||||
# 载入模板
|
||||
img = cv2.imread(str(RES_DIR / "template" / f"{game}.png"), cv2.IMREAD_COLOR)
|
||||
|
||||
# 载入头像(按QQ号取,取不到时使用占位头像,避免空图 resize 崩溃)
|
||||
avatar = load_avatar(qq_id, 200)
|
||||
|
||||
# 创建圆形蒙版
|
||||
mask = np.zeros((200, 200), dtype=np.uint8)
|
||||
cv2.circle(mask, (100, 100), 100, 255, -1) # 绘制半径100的实心圆
|
||||
|
||||
# 合并圆形蒙版与原有alpha通道(load_avatar 保证是BGRA四通道)
|
||||
alpha = cv2.bitwise_and(avatar[:, :, 3], mask)
|
||||
avatar[:, :, 3] = alpha
|
||||
|
||||
# 粘贴头像(保持原有坐标)
|
||||
y, x = 55, 426
|
||||
h, w = avatar.shape[:2]
|
||||
alpha = avatar[:, :, 3] / 255.0
|
||||
for c in range(3):
|
||||
img[y : y + h, x : x + w, c] = (
|
||||
alpha * avatar[:, :, c] + (1 - alpha) * img[y : y + h, x : x + w, c]
|
||||
)
|
||||
|
||||
# 公告栏
|
||||
img = notice_paste(img)
|
||||
|
||||
# ---------------- PIL 部分 ----------------
|
||||
pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
# 玩家信息
|
||||
draw.text((665, 65), f"{player}", fill="white", font=font_L_CH)
|
||||
draw.text((745, 133), f"{pid}", fill="white", font=font_M_CH)
|
||||
|
||||
# 等级游玩时长
|
||||
draw.text((740, 212), f"{rank}", fill="white", font=font_M_CH)
|
||||
draw.text((985, 212), f"{time_play}H", fill="white", font=font_M_CH)
|
||||
|
||||
# 生涯总览
|
||||
|
||||
# 场次
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=(wins + loses),
|
||||
x_left=45,
|
||||
x_right=265,
|
||||
y=350,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 命中率
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=round(float(acc.replace("%", "")), 1),
|
||||
x_left=415,
|
||||
x_right=495,
|
||||
y=343,
|
||||
font=font_M_CH,
|
||||
fill="white",
|
||||
)
|
||||
# 爆头率
|
||||
if head_shots:
|
||||
head_shots = head_shots.replace("%", "")
|
||||
else:
|
||||
head_shots = 0
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=round(float(head_shots), 1),
|
||||
x_left=395,
|
||||
x_right=470,
|
||||
y=420,
|
||||
font=font_M_CH,
|
||||
fill="white",
|
||||
)
|
||||
# kd
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=kd,
|
||||
x_left=585,
|
||||
x_right=785,
|
||||
y=340,
|
||||
font=font_XL_CH,
|
||||
fill="white",
|
||||
)
|
||||
# kpm
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=round(kpm, 1),
|
||||
x_left=825,
|
||||
x_right=1025,
|
||||
y=340,
|
||||
font=font_XL_CH,
|
||||
fill="white",
|
||||
)
|
||||
# spm
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=round(spm, 1),
|
||||
x_left=1065,
|
||||
x_right=1265,
|
||||
y=340,
|
||||
font=font_XL_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 击杀
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=kills,
|
||||
x_left=870,
|
||||
x_right=1190,
|
||||
y=595,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 助攻
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=kill_assists,
|
||||
x_left=820,
|
||||
x_right=1145,
|
||||
y=770,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 急救
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=revives,
|
||||
x_left=770,
|
||||
x_right=1090,
|
||||
y=960,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 摧毁
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=destroyed,
|
||||
x_left=720,
|
||||
x_right=1040,
|
||||
y=1140,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 底部栏
|
||||
|
||||
# 评级
|
||||
if game == "bf6":
|
||||
logger.info("bf6不计算称号")
|
||||
elif level_designation:
|
||||
level = level_designation["level"]
|
||||
des = level_designation["designation"]
|
||||
color = level_designation["color"]
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=level,
|
||||
x_left=70,
|
||||
x_right=200,
|
||||
y=1120,
|
||||
font=font_XL_CH,
|
||||
fill=color,
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=des,
|
||||
x_left=200,
|
||||
x_right=550,
|
||||
y=1120,
|
||||
font=font_XL_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 最佳兵种
|
||||
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=classes[best_class],
|
||||
x_left=140,
|
||||
x_right=310,
|
||||
y=1290,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 最远击杀
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=f"{longest_head_shot}m",
|
||||
x_left=455,
|
||||
x_right=625,
|
||||
y=1290,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 最高连杀
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=highest_ill_streak,
|
||||
x_left=765,
|
||||
x_right=935,
|
||||
y=1290,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 最佳⭐
|
||||
pil_img = build_best(draw, pil_img, best_weapon, best_vehicle, game)
|
||||
|
||||
# 生成时间
|
||||
draw.text(
|
||||
(1870, 1420), f"{formatted_time}", fill=(154, 132, 149), font=font_XXXS_CH
|
||||
)
|
||||
|
||||
# ---------------- 转回 OpenCV ----------------
|
||||
img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||
|
||||
# ---------------- 雷达图 ----------------
|
||||
if game != "bf6":
|
||||
center = (311, 796)
|
||||
r = 185
|
||||
scale_min = 0.25 if min(stat_data) < 0.25 else min(stat_data)
|
||||
img = draw_radar(
|
||||
img,
|
||||
stat_data,
|
||||
center=center,
|
||||
r=r,
|
||||
buffer=0.1,
|
||||
scale_min=scale_min,
|
||||
scale_max=1.10,
|
||||
)
|
||||
# 保存
|
||||
success, buffer = cv2.imencode(".png", img)
|
||||
if success:
|
||||
b_io = io.BytesIO(buffer)
|
||||
return b_io # 就可以直接 return 二进制流
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
async def build_bf6_stats_card(
|
||||
game="bfv",
|
||||
qq_id=2931,
|
||||
player="Player001",
|
||||
pid=1145141919,
|
||||
rank=114,
|
||||
kd=1.25,
|
||||
kpm=2.3,
|
||||
spm=500,
|
||||
acc="25.6",
|
||||
head_shots="10",
|
||||
time_play=100.0,
|
||||
kills=100,
|
||||
kill_assists=150,
|
||||
revives=114,
|
||||
repairs=1000,
|
||||
captured=300,
|
||||
score=145674,
|
||||
wins=10,
|
||||
loses=10,
|
||||
destroyed=514,
|
||||
best_weapon="M1907 SF",
|
||||
best_vehicle="坦克",
|
||||
best_class="Support",
|
||||
):
|
||||
# 获取当前事件并且格式化
|
||||
now = datetime.datetime.now()
|
||||
formatted_time = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# ---------------- OpenCV 部分 ----------------
|
||||
# 载入模板
|
||||
img = cv2.imread(str(RES_DIR / "template" / f"{game}.png"), cv2.IMREAD_COLOR)
|
||||
|
||||
# 载入头像(按QQ号取,取不到时使用占位头像,避免空图 resize 崩溃)
|
||||
avatar = load_avatar(qq_id, 134)
|
||||
|
||||
# 粘贴头像(保持原有坐标)
|
||||
y, x = 65, 105
|
||||
h, w = avatar.shape[:2]
|
||||
if avatar.shape[2] == 4:
|
||||
# 带透明通道时按alpha混合,避免直接赋值通道数不匹配
|
||||
alpha = avatar[:, :, 3:4] / 255.0
|
||||
img[y : y + h, x : x + w] = (
|
||||
alpha * avatar[:, :, :3] + (1 - alpha) * img[y : y + h, x : x + w]
|
||||
).astype(np.uint8)
|
||||
else:
|
||||
img[y : y + h, x : x + w] = avatar
|
||||
|
||||
# 公告栏
|
||||
img = notice_paste_bf6(img)
|
||||
img = designation_paste(
|
||||
player,
|
||||
img,
|
||||
user_id=qq_id,
|
||||
start_x=104,
|
||||
start_y=215,
|
||||
target_height=30,
|
||||
spacing=10,
|
||||
)
|
||||
|
||||
# ---------------- PIL 部分 ----------------
|
||||
pil_img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
# 玩家信息 105+160
|
||||
draw.text((265, 80), f"{player}", fill="white", font=font_XL)
|
||||
draw.text((265, 140), f"UID: {pid}", fill="#CCCCCC", font=font_L)
|
||||
|
||||
# 等级
|
||||
pil_img = paste_rank_icon(int(rank), pil_img)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=f"{rank}",
|
||||
x_left=950,
|
||||
x_right=1075,
|
||||
y=205,
|
||||
font=font_M,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 游玩时长
|
||||
draw.text((2300, 350), f"{time_play:.1f} H", fill="white", font=font_L)
|
||||
# 场次
|
||||
draw.text((2300, 450), f"{wins + loses}", fill="white", font=font_L)
|
||||
|
||||
# 生涯总览
|
||||
# 胜率
|
||||
|
||||
win_rate = round((wins / (wins + loses)) * 100, 1)
|
||||
logger.info(f"胜场{wins},负场{loses},胜率{win_rate}")
|
||||
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=f"{win_rate}%",
|
||||
x_left=2090,
|
||||
x_right=2200,
|
||||
y=375,
|
||||
font=font_XL,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 命中率
|
||||
acc_format = round(float(acc.replace("%", "")), 1)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=f"{acc_format}%",
|
||||
x_left=1210,
|
||||
x_right=1303,
|
||||
y=363,
|
||||
font=font_M,
|
||||
fill="white",
|
||||
)
|
||||
# 爆头率
|
||||
if head_shots:
|
||||
head_shots = head_shots.replace("%", "")
|
||||
else:
|
||||
head_shots = 0
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=f"{round(float(head_shots), 1)}%",
|
||||
x_left=1210,
|
||||
x_right=1303,
|
||||
y=458,
|
||||
font=font_M,
|
||||
fill="white",
|
||||
)
|
||||
# kd
|
||||
draw.text((1350, 355), text=str(kd), fill="white", font=font_XXL)
|
||||
# kpm
|
||||
draw.text((1610, 355), text=f"{kpm:.2f}", fill="white", font=font_XXL)
|
||||
# spm
|
||||
draw.text((1870, 355), text=f"{int(spm)}", fill="white", font=font_XXL)
|
||||
|
||||
# 击杀
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas_with_padding("0", 10),
|
||||
x_left=1165,
|
||||
x_right=1455,
|
||||
y=805,
|
||||
font=font_L,
|
||||
fill="#444445",
|
||||
)
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas(str(kills)),
|
||||
x_left=1165,
|
||||
x_right=1455,
|
||||
y=805,
|
||||
font=font_L,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 急救
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas_with_padding("0", 10),
|
||||
x_left=1165,
|
||||
x_right=1455,
|
||||
y=1080,
|
||||
font=font_L,
|
||||
fill="#444445",
|
||||
)
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas(str(revives)),
|
||||
x_left=1165,
|
||||
x_right=1455,
|
||||
y=1080,
|
||||
font=font_L,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 摧毁
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas_with_padding("0", 10),
|
||||
x_left=1165,
|
||||
x_right=1455,
|
||||
y=1350,
|
||||
font=font_L,
|
||||
fill="#444445",
|
||||
)
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas(str(destroyed)),
|
||||
x_left=1165,
|
||||
x_right=1455,
|
||||
y=1350,
|
||||
font=font_L,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 右侧
|
||||
# 占领
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas_with_padding("0", 10),
|
||||
x_left=1485,
|
||||
x_right=1780,
|
||||
y=745,
|
||||
font=font_L,
|
||||
fill="#444445",
|
||||
)
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas(str(captured)),
|
||||
x_left=1485,
|
||||
x_right=1780,
|
||||
y=745,
|
||||
font=font_L,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 得分
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas_with_padding("0", 10),
|
||||
x_left=1485,
|
||||
x_right=1780,
|
||||
y=955,
|
||||
font=font_L,
|
||||
fill="#444445",
|
||||
)
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas(str(score)),
|
||||
x_left=1485,
|
||||
x_right=1780,
|
||||
y=955,
|
||||
font=font_L,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 助攻
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas_with_padding("0", 10),
|
||||
x_left=1485,
|
||||
x_right=1780,
|
||||
y=1160,
|
||||
font=font_L,
|
||||
fill="#444445",
|
||||
)
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas(str(kill_assists)),
|
||||
x_left=1485,
|
||||
x_right=1780,
|
||||
y=1160,
|
||||
font=font_L,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 修理
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas_with_padding("0", 10),
|
||||
x_left=1485,
|
||||
x_right=1780,
|
||||
y=1370,
|
||||
font=font_L,
|
||||
fill="#444445",
|
||||
)
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas(str(repairs)),
|
||||
x_left=1485,
|
||||
x_right=1780,
|
||||
y=1370,
|
||||
font=font_L,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 最佳⭐
|
||||
pil_img = build_best_bf6(draw, pil_img, best_weapon, best_vehicle, best_class, game)
|
||||
|
||||
# 生成时间
|
||||
draw.text(
|
||||
(1750, 10),
|
||||
f"{formatted_time}/维护 SANSENHOSHI",
|
||||
fill=(154, 132, 149),
|
||||
font=font_XS_CH,
|
||||
)
|
||||
|
||||
# ---------------- 转回 OpenCV ----------------
|
||||
img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||
|
||||
# 保存
|
||||
success, buffer = cv2.imencode(".png", img)
|
||||
if success:
|
||||
b_io = io.BytesIO(buffer)
|
||||
return b_io # 就可以直接 return 二进制流
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def build_best(draw, img, weapons, vehicle, game: str):
|
||||
# 最佳武器
|
||||
weapon_name = weapons["weaponName"]
|
||||
w_kills = weapons["kills"]
|
||||
w_kpm = weapons["killsPerMinute"]
|
||||
acc = weapons["accuracy"].replace("%", "")
|
||||
headshots = weapons["headshots"].replace("%", "")
|
||||
|
||||
# 最佳载具
|
||||
vehicle_name = vehicle["vehicleName"]
|
||||
v_kills = vehicle["kills"]
|
||||
v_kpm = vehicle["killsPerMinute"]
|
||||
destroyed = vehicle["destroyed"]
|
||||
|
||||
# 绘制最佳⭐
|
||||
# 武器
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=bf_item[game][weapon_name].upper(),
|
||||
x_left=1240,
|
||||
x_right=1750,
|
||||
y=1355,
|
||||
font=font_MS_CH,
|
||||
fill="white",
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=w_kills,
|
||||
x_left=1190,
|
||||
x_right=1390,
|
||||
y=1260,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=w_kpm,
|
||||
x_left=1510,
|
||||
x_right=1635,
|
||||
y=1260,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=acc,
|
||||
x_left=1735,
|
||||
x_right=1803,
|
||||
y=1030,
|
||||
font=font_M_CH,
|
||||
fill="white",
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=headshots,
|
||||
x_left=1695,
|
||||
x_right=1760,
|
||||
y=1200,
|
||||
font=font_M_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 载具
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=bf_item[game][vehicle_name].upper(),
|
||||
x_left=1990,
|
||||
x_right=2440,
|
||||
y=1355,
|
||||
font=font_MS_CH,
|
||||
fill="white",
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=v_kills,
|
||||
x_left=1940,
|
||||
x_right=2150,
|
||||
y=1260,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=v_kpm,
|
||||
x_left=2295,
|
||||
x_right=2465,
|
||||
y=1055,
|
||||
font=font_XL_CH,
|
||||
fill="white",
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=destroyed,
|
||||
x_left=2265,
|
||||
x_right=2440,
|
||||
y=1260,
|
||||
font=font_L_CH,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 图片
|
||||
weapon_url = weapons["image"]
|
||||
vehicle_url = vehicle["image"]
|
||||
wp_icon = get_save_icon(game, weapon_name, "weapon", weapon_url)
|
||||
vc_icon = get_save_icon(game, vehicle_name, "vehicles", vehicle_url)
|
||||
|
||||
img = image_paste_center(wp_icon, img, (1165, 1110))
|
||||
img = image_paste_center(vc_icon, img, (1920, 1110))
|
||||
return img
|
||||
|
||||
|
||||
def build_best_bf6(draw, img, weapons, vehicle, classes, game: str):
|
||||
# 最佳武器
|
||||
weapon_name = weapons["weaponName"]
|
||||
w_kills = weapons["kills"]
|
||||
w_kpm = weapons["killsPerMinute"]
|
||||
acc = round(float(weapons["accuracy"].replace("%", "")), 1)
|
||||
headshots = round(float(weapons["headshots"].replace("%", "")), 1)
|
||||
|
||||
# 最佳载具
|
||||
vehicle_name = vehicle["vehicleName"]
|
||||
v_kills = vehicle["kills"]
|
||||
v_kpm = vehicle["killsPerMinute"]
|
||||
destroyed = vehicle["destroyed"]
|
||||
|
||||
# 绘制最佳⭐
|
||||
# 武器
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas_with_padding("0", 7),
|
||||
x_left=95,
|
||||
x_right=313,
|
||||
y=665,
|
||||
font=font_L,
|
||||
fill="#313131",
|
||||
)
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas(str(w_kills)),
|
||||
x_left=95,
|
||||
x_right=313,
|
||||
y=665,
|
||||
font=font_L,
|
||||
fill="white",
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw, text=w_kpm, x_left=335, x_right=447, y=665, font=font_L, fill="white"
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=f"{acc}%",
|
||||
x_left=470,
|
||||
x_right=580,
|
||||
y=670,
|
||||
font=font_M,
|
||||
fill="white",
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=f"{headshots}%",
|
||||
x_left=610,
|
||||
x_right=717,
|
||||
y=670,
|
||||
font=font_M,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 载具
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas_with_padding("0", 7),
|
||||
x_left=125,
|
||||
x_right=325,
|
||||
y=1087,
|
||||
font=font_L,
|
||||
fill="#313131",
|
||||
)
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas(str(v_kills)),
|
||||
x_left=125,
|
||||
x_right=325,
|
||||
y=1087,
|
||||
font=font_L,
|
||||
fill="white",
|
||||
)
|
||||
draw_centered_text(
|
||||
draw=draw,
|
||||
text=v_kpm,
|
||||
x_left=351,
|
||||
x_right=463,
|
||||
y=1087,
|
||||
font=font_L,
|
||||
fill="white",
|
||||
)
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=add_commas_with_padding("0", 7),
|
||||
x_left=477,
|
||||
x_right=688,
|
||||
y=1087,
|
||||
font=font_L,
|
||||
fill="#313131",
|
||||
)
|
||||
draw_right_aligned_text(
|
||||
draw=draw,
|
||||
text=destroyed,
|
||||
x_left=477,
|
||||
x_right=688,
|
||||
y=1087,
|
||||
font=font_L,
|
||||
fill="white",
|
||||
)
|
||||
|
||||
# 图片
|
||||
weapon_url = weapons["image"]
|
||||
vehicle_url = vehicle["image"]
|
||||
|
||||
wp_icon = get_save_icon(game, weapon_name, "weapon", weapon_url)
|
||||
vc_icon = get_save_icon(game, vehicle_name, "vehicles", vehicle_url)
|
||||
classes_icon = get_icon_from_cache(classes, "bf6", "classes")
|
||||
wp_icon_new = png_resize(wp_icon, new_width=729 - 88)
|
||||
vc_icon_new = png_resize(vc_icon, new_width=729 - 88)
|
||||
classes_icon_new = png_resize(
|
||||
classes_icon, new_height=1165 - 326, new_width=1100 - 748
|
||||
)
|
||||
|
||||
img = image_paste(wp_icon_new, img, (88, 325))
|
||||
img = image_paste(vc_icon_new, img, (88, 746))
|
||||
img = image_paste(classes_icon_new, img, (748, 326))
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def normalize_for_radar(data, buffer=0.1, scale_min=0.0, scale_max=1.0):
|
||||
"""
|
||||
将数据动态归一化到指定区间 [scale_min, scale_max],
|
||||
并给最大值预留 buffer,让图形更饱满。
|
||||
|
||||
Args:
|
||||
data (list/ndarray): 原始数据
|
||||
buffer (float): 缓冲比例,例如 0.1 表示最大值按 1.1 倍处理
|
||||
scale_min (float): 映射区间下限
|
||||
scale_max (float): 映射区间上限
|
||||
|
||||
Returns:
|
||||
list: 归一化后的数据
|
||||
"""
|
||||
data = np.array(data, dtype=float)
|
||||
d_min, d_max = data.min(), data.max()
|
||||
|
||||
# 给最大值留 buffer
|
||||
d_max = d_max * (1 + buffer)
|
||||
|
||||
if d_max == d_min: # 避免除零
|
||||
norm = np.ones_like(data) * (scale_min + scale_max) / 2
|
||||
else:
|
||||
norm = (data - d_min) / (d_max - d_min)
|
||||
norm = scale_min + norm * (scale_max - scale_min)
|
||||
|
||||
return norm.tolist()
|
||||
|
||||
|
||||
# ---------------- 雷达图示例 ----------------
|
||||
def draw_radar(
|
||||
img, stat_data, center=(311, 796), r=185, buffer=0.1, scale_min=0.1, scale_max=1.05
|
||||
):
|
||||
# 动态归一化
|
||||
scaled_data = normalize_for_radar(stat_data, buffer, scale_min, scale_max)
|
||||
|
||||
points = []
|
||||
for i, v in enumerate(scaled_data):
|
||||
angle = 2 * np.pi / len(scaled_data) * i - np.pi / 2
|
||||
px = int(center[0] + r * v * np.cos(angle))
|
||||
py = int(center[1] + r * v * np.sin(angle))
|
||||
points.append([px, py])
|
||||
points = np.array(points, np.int32)
|
||||
|
||||
# 绘制填充
|
||||
overlay = img.copy()
|
||||
cv2.fillPoly(overlay, [points], color=(255, 255, 255, 76))
|
||||
img = cv2.addWeighted(overlay, 0.3, img, 0.7, 0)
|
||||
# 绘制边框
|
||||
cv2.polylines(img, [points], isClosed=True, color=(255, 255, 255), thickness=2)
|
||||
return img
|
||||
@@ -0,0 +1,54 @@
|
||||
"""文本/数字格式化。"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def convert_to_hours(time_str):
|
||||
"""
|
||||
将各种时间格式字符串转换为小时数
|
||||
支持格式示例:
|
||||
- "1 day, 1:49:15" (包含天数)
|
||||
- "1:56:55" (小时:分钟:秒)
|
||||
- "0:06:41" (分钟:秒)
|
||||
- "14:11:36" (小时:分钟:秒)
|
||||
|
||||
接口偶尔会给带引号包裹的串(如 '"1 day, 1:49:15"'),先统一剥掉引号/空白。
|
||||
"""
|
||||
total_hours = 0.0
|
||||
time_str = (time_str or "").strip().strip('"').strip("'")
|
||||
|
||||
# 处理包含天数的部分
|
||||
if "day" in time_str:
|
||||
days_part, time_part = time_str.split(", ")
|
||||
days_match = re.search(r"\d+", days_part)
|
||||
total_hours += (float(days_match.group()) if days_match else 0.0) * 24
|
||||
time_str = time_part
|
||||
|
||||
# 分割时间部分
|
||||
parts = list(map(float, time_str.split(":")))
|
||||
|
||||
# 根据时间部分长度计算
|
||||
if len(parts) == 3: # 时:分:秒
|
||||
total_hours += parts[0] + parts[1] / 60 + parts[2] / 3600
|
||||
elif len(parts) == 2: # 分:秒
|
||||
total_hours += parts[0] / 60 + parts[1] / 3600
|
||||
elif len(parts) == 1: # 仅秒
|
||||
total_hours += parts[0] / 3600
|
||||
|
||||
return round(total_hours, 2)
|
||||
|
||||
|
||||
def add_commas(text):
|
||||
"""给纯数字字符串加千分位,如 "1234567" -> "1,234,567"。"""
|
||||
return re.sub(r"(?<!^)(?=(\d{3})+$)", ",", text)
|
||||
|
||||
|
||||
def add_commas_with_padding(value, total_digits):
|
||||
"""
|
||||
固定总位数补零后,再添加千分位分隔符
|
||||
:param value: 输入数字(int / str)
|
||||
:param total_digits: 固定总位数(如 7)
|
||||
:return: 格式化后的字符串,如 '1,234,567'
|
||||
"""
|
||||
text = str(value).zfill(total_digits)
|
||||
return re.sub(r"(?<!^)(?=(\d{3})+$)", ",", text)
|
||||
Reference in new issue
Block a user