- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
553 lines
17 KiB
Python
553 lines
17 KiB
Python
import hashlib
|
|
import os
|
|
from io import BytesIO
|
|
|
|
import aiohttp
|
|
import cv2
|
|
import numpy as np
|
|
import requests
|
|
import requests.exceptions
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from nonebot import logger
|
|
|
|
from hexi.plugins.nonebot_plugin_bf_bot.user_data.data_utils import UserManager, DesignationManager
|
|
from .param import rank_pic
|
|
|
|
filepath = os.path.dirname(__file__).replace("\\", "/")
|
|
|
|
|
|
# 圆角遮罩处理
|
|
def draw_rect(img, pos, radius, **kwargs):
|
|
trans = Image.new('RGBA', img.size, (0, 0, 0, 0))
|
|
alpha_draw = ImageDraw.Draw(trans, "RGBA")
|
|
alpha_draw.rounded_rectangle(pos, radius, **kwargs)
|
|
img.paste(Image.alpha_composite(img, trans))
|
|
return img
|
|
|
|
|
|
# 圆角处理
|
|
def circle_corner(img, radii):
|
|
"""
|
|
半透明圆角处理
|
|
:param img: 要修改的文件
|
|
:param radii: 圆角弧度
|
|
:return: 返回修改过的文件
|
|
"""
|
|
circle = Image.new('L', (radii * 2, radii * 2), 0) # 创建黑色方形
|
|
draw = ImageDraw.Draw(circle)
|
|
draw.ellipse((0, 0, radii * 2, radii * 2), fill=255) # 黑色方形内切白色圆形
|
|
|
|
img = img.convert("RGBA")
|
|
w, h = img.size
|
|
|
|
# 创建一个alpha层,存放四个圆角,使用透明度切除圆角外的图片
|
|
alpha = Image.new('L', img.size, 255)
|
|
alpha.paste(circle.crop((0, 0, radii, radii)), (0, 0)) # 左上角
|
|
alpha.paste(circle.crop((radii, 0, radii * 2, radii)),
|
|
(w - radii, 0)) # 右上角
|
|
alpha.paste(circle.crop((radii, radii, radii * 2, radii * 2)),
|
|
(w - radii, h - radii)) # 右下角
|
|
alpha.paste(circle.crop((0, radii, radii, radii * 2)),
|
|
(0, h - radii)) # 左下角
|
|
img.putalpha(alpha) # 白色区域透明可见,黑色区域不可见
|
|
|
|
# 添加圆角边框
|
|
draw = ImageDraw.Draw(img)
|
|
draw.rounded_rectangle(img.getbbox(), outline="white", width=3, radius=radii)
|
|
return img
|
|
|
|
|
|
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.NEAREST,
|
|
"BILINEAR": Image.BILINEAR,
|
|
"BICUBIC": Image.BICUBIC,
|
|
"LANCZOS": Image.LANCZOS
|
|
}
|
|
resample_method = resample_map.get(resample, Image.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: Image, under_image: 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
|
|
|
|
|
|
def download_icon(url):
|
|
data = download_url(url)
|
|
img = BytesIO(data)
|
|
return img
|
|
|
|
|
|
# 下载QQ头像
|
|
def download_avatar(user_id: str) -> bytes:
|
|
url = f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640"
|
|
data = download_url(url)
|
|
if not data or hashlib.md5(data).hexdigest() == "acef72340ac0e914090bd35799f5594e":
|
|
url = f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=100"
|
|
data = download_url(url)
|
|
return data
|
|
|
|
|
|
# 根据URL下载文件
|
|
def download_url(url: str) -> bytes:
|
|
for i in range(3):
|
|
try:
|
|
resp = requests.get(url)
|
|
if resp.status_code != 200:
|
|
continue
|
|
return resp.content
|
|
except Exception as e:
|
|
print(f"Error downloading {url}, retry {i}/3: {str(e)}")
|
|
|
|
|
|
# 图片裁剪
|
|
def cut_image(pic_data: bytes, target_ratio: float):
|
|
try:
|
|
pic_data = Image.open(BytesIO(pic_data))
|
|
w, h = pic_data.size
|
|
pic_ratio = w / h
|
|
if pic_ratio > target_ratio:
|
|
# 宽高比大于目标比例,按高度缩放。保持宽度不变
|
|
new_h = w / target_ratio
|
|
h_delta = (h - new_h) / 2
|
|
w_delta = 0
|
|
else:
|
|
# 宽高比小于目标比例,按宽度缩放。保持高度不变
|
|
new_w = h * target_ratio
|
|
w_delta = (w - new_w) / 2
|
|
h_delta = 0
|
|
cropped = pic_data.crop((w_delta, h_delta, w - w_delta, h - h_delta))
|
|
return cropped
|
|
except Exception as e:
|
|
raise Exception(f"图片剪裁失败{str(e)}")
|
|
|
|
|
|
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)
|
|
icon.save(f"{filepath}/img/{game}/{icon_type}/{name}.png")
|
|
logger.info(f"文件已经缓存至:{filepath}/img/{game}/{icon_type}/{name}.png")
|
|
|
|
# 上述步骤处理完再执行一次icon判空
|
|
if icon:
|
|
return icon
|
|
else:
|
|
# 针对空图片处理
|
|
if icon_type == "weapon":
|
|
icon = Image.open(filepath + "/img/wp.png").convert('RGBA')
|
|
else:
|
|
icon = Image.open(filepath + "/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 = f"{filepath}/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(f"{path}/{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):
|
|
pos = (1176, 318)
|
|
# 获取公告
|
|
notice_path = f"{filepath}/notice/notice.png"
|
|
front_img = cv2.imread(notice_path)
|
|
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)
|
|
mix_bg = paste_image_cv2(front_img, cv2_bg, pos)
|
|
return mix_bg
|
|
|
|
|
|
def notice_paste_bf6(cv2_bg):
|
|
x, y = 0, 0
|
|
|
|
notice_path = f"{filepath}/notice/notice_bf6.png"
|
|
front_img = cv2.imread(notice_path, cv2.IMREAD_UNCHANGED)
|
|
|
|
if front_img is None:
|
|
raise RuntimeError(f"Failed to load image: {notice_path}")
|
|
|
|
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 通道 → Alpha 混合
|
|
if front_img.shape[2] == 4:
|
|
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)
|
|
|
|
# 无 Alpha → 直接覆盖
|
|
else:
|
|
cv2_bg[y:y + h, x:x + 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):
|
|
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(f"{filepath}/img/icon/designation/{icon_file_name}")
|
|
return icon_path_list
|
|
|
|
|
|
def paste_rank_icon(rank, img):
|
|
rank_pic_path = get_rank_pic(rank)
|
|
rank_icon_path = f"{filepath}/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"
|