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:
sansenhoshiandClaude Code committed 2026-10-13 17:53:06 +08:00
1 parent 614e1d7e00
commit a31aab00e8
752 files changed
+6241 -2506

No files matched your search

@@ -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"