from enum import Enum from io import BytesIO import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont from konabot.common.path import ASSETS_PATH, FONTS_PATH from konabot.common.path import DATA_PATH import json class CrashType(Enum): BURNT = 0 FROZEN = 1 class AirConditioner: air_conditioners: dict[str, "AirConditioner"] = {} def __init__(self, id: str) -> None: self.id = id self.on = False self.temperature = 24 # 默认温度 self.burnt = False self.frozen = False AirConditioner.air_conditioners[id] = self def change_ac(self): self.burnt = False self.frozen = False self.on = False self.temperature = 24 # 重置为默认温度 def broke_ac(self, crash_type: CrashType): ''' 让空调坏掉,并保存数据 :param crash_type: CrashType 枚举,表示空调坏掉的类型 ''' match crash_type: case CrashType.BURNT: self.burnt = True case CrashType.FROZEN: self.frozen = True self.save_crash_data(crash_type) def save_crash_data(self, crash_type: CrashType): ''' 如果空调爆炸了,就往本地的 ac_crash_data.json 里该 id 的记录加一 ''' data_file = DATA_PATH / "ac_crash_data.json" crash_data = {} if data_file.exists(): with open(data_file, "r", encoding="utf-8") as f: crash_data = json.load(f) if self.id not in crash_data: crash_data[self.id] = {"burnt": 0, "frozen": 0} match crash_type: case CrashType.BURNT: crash_data[self.id]["burnt"] += 1 case CrashType.FROZEN: crash_data[self.id]["frozen"] += 1 with open(data_file, "w", encoding="utf-8") as f: json.dump(crash_data, f, ensure_ascii=False, indent=4) def get_crashes_and_ranking(self) -> tuple[int, int]: ''' 获取该群在全国空调损坏的数量与排行榜的位置 ''' data_file = DATA_PATH / "ac_crash_data.json" if not data_file.exists(): return 0, 1 with open(data_file, "r", encoding="utf-8") as f: crash_data = json.load(f) ranking_list = [] for gid, record in crash_data.items(): total = record.get("burnt", 0) + record.get("frozen", 0) ranking_list.append((gid, total)) ranking_list.sort(key=lambda x: x[1], reverse=True) total_crashes = crash_data.get(self.id, {}).get("burnt", 0) + crash_data.get(self.id, {}).get("frozen", 0) rank = 1 for gid, total in ranking_list: if gid == self.id: break rank += 1 return total_crashes, rank def text_to_transparent_image(text, font_size=40, padding=0, text_color=(0, 0, 0)): """ 将文本转换为带透明背景的图像,图像大小刚好包含文本 """ # 创建临时图像来计算文本尺寸 temp_image = Image.new('RGB', (1, 1), (255, 255, 255)) temp_draw = ImageDraw.Draw(temp_image) font = ImageFont.truetype(FONTS_PATH / "montserrat.otf", font_size) # 获取文本边界框 bbox = temp_draw.textbbox((0, 0), text, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] # 计算图像大小(文本大小 + 内边距) image_width = int(text_width + 2 * padding) image_height = int(text_height + 2 * padding) # 创建RGBA模式的空白图像(带透明通道) image = Image.new('RGBA', (image_width, image_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(image) # 绘制文本(考虑内边距) x = padding - bbox[0] # 调整起始位置 y = padding - bbox[1] # 设置文本颜色(带透明度) if len(text_color) == 3: text_color = text_color + (255,) # 添加完全不透明的alpha值 draw.text((x, y), text, fill=text_color, font=font) # 转换为OpenCV格式(BGRA) image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGBA2BGRA) return image_cv def perspective_transform(image, target, corners): """ 对图像进行透视变换(保持透明通道) target: 画布 corners: 四个角点的坐标,顺序为 [左上, 右上, 右下, 左下] """ height, width = image.shape[:2] # 源点(原始图像的四个角) src_points = np.array([ [0, 0], # 左上 [width-1, 0], # 右上 [width-1, height-1], # 右下 [0, height-1] # 左下 ], dtype=np.float32) # 目标点(变换后的四个角) dst_points = np.array(corners, dtype=np.float32) # 计算透视变换矩阵 matrix = cv2.getPerspectiveTransform(src_points, dst_points) # 获取画布大小 target_height, target_width = target.shape[:2] # 应用透视变换(保持所有通道,包括alpha) transformed = cv2.warpPerspective(image, matrix, (target_width, target_height), flags=cv2.INTER_LINEAR) return transformed, matrix def blend_with_transparency(background, foreground, position): """ 将带透明通道的前景图像合成到背景图像上 position: 前景图像在背景图像上的位置 (x, y) """ bg = background.copy() # 如果背景没有alpha通道,添加一个 if bg.shape[2] == 3: bg = cv2.cvtColor(bg, cv2.COLOR_BGR2BGRA) bg[:, :, 3] = 255 # 完全不透明 x, y = position fg_height, fg_width = foreground.shape[:2] bg_height, bg_width = bg.shape[:2] # 确保位置在图像范围内 x = max(0, min(x, bg_width - fg_width)) y = max(0, min(y, bg_height - fg_height)) # 提取前景的alpha通道并归一化 alpha_foreground = foreground[:, :, 3] / 255.0 # 对于每个颜色通道进行合成 for c in range(3): bg_region = bg[y:y+fg_height, x:x+fg_width, c] fg_region = foreground[:, :, c] # alpha混合公式 bg[y:y+fg_height, x:x+fg_width, c] = ( alpha_foreground * fg_region + (1 - alpha_foreground) * bg_region ) # 更新背景的alpha通道(如果需要) bg_alpha_region = bg[y:y+fg_height, x:x+fg_width, 3] bg[y:y+fg_height, x:x+fg_width, 3] = np.maximum(bg_alpha_region, foreground[:, :, 3]) return bg def precise_blend_with_perspective(background, foreground, corners): """ 精确合成:根据四个角点将前景图像透视合成到背景上 """ # 创建与背景相同大小的空白图像 bg_height, bg_width = background.shape[:2] # 如果背景没有alpha通道,转换为BGRA if background.shape[2] == 3: background_bgra = cv2.cvtColor(background, cv2.COLOR_BGR2BGRA) else: background_bgra = background.copy() # 创建与背景相同大小的前景图层 foreground_layer = np.zeros((bg_height, bg_width, 4), dtype=np.uint8) # 计算前景图像在背景中的边界框 min_x = int(min(corners[:, 0])) max_x = int(max(corners[:, 0])) min_y = int(min(corners[:, 1])) max_y = int(max(corners[:, 1])) # 将变换后的前景图像放置到对应位置 fg_height, fg_width = foreground.shape[:2] if min_y + fg_height <= bg_height and min_x + fg_width <= bg_width: foreground_layer[min_y:min_y+fg_height, min_x:min_x+fg_width] = foreground # 创建掩码(只在前景有内容的地方合成) mask = (foreground_layer[:, :, 3] > 0) # 合成图像 result = background_bgra.copy() for c in range(3): result[:, :, c][mask] = foreground_layer[:, :, c][mask] result[:, :, 3][mask] = foreground_layer[:, :, 3][mask] return result def wiggle_transform(image, intensity=2) -> list[np.ndarray]: ''' 返回一组图像振动的帧组,模拟空调运作时的抖动效果 ''' frames = [] height, width = image.shape[:2] shifts = [(-intensity, 0), (intensity, 0), (0, -intensity), (0, intensity), (0, 0)] for dx, dy in shifts: M = np.float32([[1, 0, dx], [0, 1, dy]]) shifted = cv2.warpAffine(image, M, (width, height)) frames.append(shifted) return frames async def generate_ac_image(ac: AirConditioner) -> BytesIO: # 找到空调底图 ac_image = cv2.imread(str(ASSETS_PATH / "img" / "ac" / "ac.png"), cv2.IMREAD_UNCHANGED) if not ac.on: # 空调关闭状态,直接返回底图 pil_final = Image.fromarray(ac_image) output = BytesIO() pil_final.save(output, format="GIF") return output # 根据生成温度文本图像 text = f"{round(ac.temperature, 1)}°C" text_image = text_to_transparent_image( text, font_size=60, text_color=(0, 0, 0) # 黑色文字 ) # 获取长宽比 height, width = text_image.shape[:2] aspect_ratio = width / height # 定义3D变换的四个角点(透视效果) # 顺序: [左上, 右上, 右下, 左下] corners = np.array([ [123, 45], # 左上 [284, 101], # 右上 [290, 140], # 右下 [119, 100] # 左下 ], dtype=np.float32) # 对文本图像进行3D变换(保持透明通道) transformed_text, transform_matrix = perspective_transform(text_image, ac_image, corners) final_image_simple = blend_with_transparency(ac_image, transformed_text, (0, 0)) intensity = max(2, abs(int(ac.temperature) - 24) // 2) frames = wiggle_transform(final_image_simple, intensity=intensity) pil_frames = [Image.fromarray(frame) for frame in frames] output = BytesIO() pil_frames[0].save(output, format="GIF", save_all=True, append_images=pil_frames[1:], loop=0, duration=50, disposal=2) return output