From c72cdd6a6b0e2aa6554dd7964282575fc88dd230 Mon Sep 17 00:00:00 2001 From: MixBadGun <1059129006@qq.com> Date: Wed, 3 Dec 2025 17:20:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E6=BB=A4=E9=95=9C=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- konabot/docs/user/fx.txt | 11 +- konabot/plugins/fx_process/__init__.py | 35 +++- konabot/plugins/fx_process/fx_handle.py | 209 ++++++++++++++++++++++- konabot/plugins/fx_process/fx_manager.py | 13 +- 4 files changed, 252 insertions(+), 16 deletions(-) diff --git a/konabot/docs/user/fx.txt b/konabot/docs/user/fx.txt index 5020f26..2502dab 100644 --- a/konabot/docs/user/fx.txt +++ b/konabot/docs/user/fx.txt @@ -26,7 +26,7 @@ fx [滤镜名称] <参数1> <参数2> ... * ```fx 查找边缘``` * ```fx 平滑``` * ```fx 暗角 <半径=1.5>``` -* ```fx 发光 <强度=1.5> <模糊半径=15>``` +* ```fx 发光 <强度=0.5> <模糊半径=15>``` * ```fx 噪点 <数量=0.05>``` * ```fx 素描``` * ```fx 阴影 <模糊量=10> <不透明度=0.5> <阴影颜色=black>``` @@ -50,10 +50,11 @@ fx [滤镜名称] <参数1> <参数2> ... * ```fx 色调 <颜色="rgb(255,0,0)">``` * ```fx RGB分离 <偏移量=5>``` * ```fx 叠加颜色 <颜色列表=[rgb(255,0,0)|(0,0),rgb(0,255,0)|(0,100),rgb(0,0,255)|(50,100)]> <叠加模式=overlay>``` +* ```fx 像素抖动 <最大偏移量=2>``` ### 几何变换滤镜 * ```fx 平移 ``` -* ```fx 缩放 <比例=1.5>``` +* ```fx 缩放 <比例(X)=1.5> <比例Y=None>``` * ```fx 旋转 <角度=45>``` * ```fx 透视变换 <变换矩阵>``` * ```fx 裁剪 <左=0> <上=0> <右=100> <下=100>(百分比)``` @@ -61,9 +62,15 @@ fx [滤镜名称] <参数1> <参数2> ... * ```fx 波纹 <振幅=5> <波长=20>``` * ```fx 光学补偿 <数量=100> <反转=false>``` * ```fx 球面化 <强度=0.5>``` +* ```fx 镜像 <角度=90>``` +* ```fx 水平翻转``` +* ```fx 垂直翻转``` +* ```fx 复制 <目标位置=(100,100)> <缩放=1.0> <源区域=(0,0,100,100)>(百分比)``` ### 特殊效果滤镜 * ```fx 色键 <目标颜色="rgb(255,0,0)"> <容差=60>``` +* ```fx 晃动 <最大偏移量=5> <运动模糊=False>``` +* ```fx 动图 <帧率=10>``` ## 颜色名称支持 - **基本颜色**:红、绿、蓝、黄、紫、黑、白、橙、粉、灰、青、靛、棕 diff --git a/konabot/plugins/fx_process/__init__.py b/konabot/plugins/fx_process/__init__.py index 914a285..2f80113 100644 --- a/konabot/plugins/fx_process/__init__.py +++ b/konabot/plugins/fx_process/__init__.py @@ -19,6 +19,7 @@ from PIL import Image, ImageSequence @dataclass class FilterItem: + name: str filter: callable args: list @@ -55,7 +56,7 @@ def prase_input_args(input_str: str) -> list[FilterItem]: except Exception: converted_value = arg_value func_args.append(converted_value) - args.append(FilterItem(filter=filter_func, args=func_args)) + args.append(FilterItem(name=filter_name,filter=filter_func, args=func_args)) return args def apply_filters_to_image(img: Image, filters: list[FilterItem]) -> Image: @@ -66,16 +67,39 @@ def apply_filters_to_image(img: Image, filters: list[FilterItem]) -> Image: return img async def apply_filters_to_bytes(image_bytes: bytes, filters: list[FilterItem]) -> BytesIO: + # 检测是否需要将静态图视作动图处理 + frozen_to_move = any( + filter_item.name == "动图" + for filter_item in filters + ) + static_fps = 10 + # 找到动图参数 fps + if frozen_to_move: + for filter_item in filters: + if filter_item.name == "动图" and filter_item.args: + try: + static_fps = int(filter_item.args[0]) + except Exception: + static_fps = 10 + break # 如果 image 是动图,则逐帧处理 img = Image.open(BytesIO(image_bytes)) logger.debug("开始图像处理") output = BytesIO() - if getattr(img, "is_animated", False): + if getattr(img, "is_animated", False) or frozen_to_move: frames = [] all_frames = [] - for frame in ImageSequence.Iterator(img): - frame_copy = frame.copy() - all_frames.append(frame_copy) + if getattr(img, "is_animated", False): + logger.debug("处理动图帧") + for frame in ImageSequence.Iterator(img): + frame_copy = frame.copy() + all_frames.append(frame_copy) + else: + # 将静态图视作单帧动图处理,拷贝多份 + logger.debug("处理静态图为多帧动图") + for _ in range(10): # 默认复制10帧 + all_frames.append(img.copy()) + img.info['duration'] = int(1000 / static_fps) async def process_single_frame(frame: Image.Image, frame_idx: int) -> Image.Image: """处理单帧的异步函数""" @@ -117,7 +141,6 @@ async def apply_filters_to_bytes(image_bytes: bytes, filters: list[FilterItem]) output.seek(0) return output - def is_fx_mentioned(evt: BaseEvent, msg: UniMsg) -> bool: txt = msg.extract_plain_text() if "fx" not in txt[:3].lower(): diff --git a/konabot/plugins/fx_process/fx_handle.py b/konabot/plugins/fx_process/fx_handle.py index f3f488a..ada3785 100644 --- a/konabot/plugins/fx_process/fx_handle.py +++ b/konabot/plugins/fx_process/fx_handle.py @@ -1,3 +1,4 @@ +import random from PIL import Image, ImageFilter from PIL import ImageEnhance from PIL import ImageChops @@ -137,9 +138,22 @@ class ImageFilterImplement: # 缩放 @staticmethod - def apply_resize(image: Image.Image, scale: float = 1.5) -> Image.Image: - if scale <= 0: - scale = 1.0 + def apply_resize(image: Image.Image, scale: float = 1.5, scale_y = None) -> Image.Image: + # scale 可以为负 + # 如果 scale 为负,则代表翻转 + if scale_y is not None: + if float(scale_y) < 0: + image = ImageOps.flip(image) + scale_y = abs(float(scale_y)) + if scale < 0: + image = ImageOps.mirror(image) + scale = abs(scale) + new_size = (int(image.width * scale), int(image.height * float(scale_y))) + return image.resize(new_size, Image.Resampling.LANCZOS) + if scale < 0: + image = ImageOps.mirror(image) + image = ImageOps.flip(image) + scale = abs(scale) new_size = (int(image.width * scale), int(image.height * scale)) return image.resize(new_size, Image.Resampling.LANCZOS) @@ -224,7 +238,7 @@ class ImageFilterImplement: # 发光 @staticmethod - def apply_glow(image: Image.Image, intensity: float = 1.5, blur_radius: float = 15) -> Image.Image: + def apply_glow(image: Image.Image, intensity: float = 0.5, blur_radius: float = 15) -> Image.Image: if image.mode != 'RGBA': image = image.convert('RGBA') # 创建发光图层 @@ -423,6 +437,8 @@ class ImageFilterImplement: # 裁剪 @staticmethod def apply_crop(image: Image.Image, left: float = 0, upper: float = 0, right: float = 100, lower: float = 100) -> Image.Image: + if image.mode != 'RGBA': + image = image.convert('RGBA') # 按百分比裁剪 width, height = image.size left_px = int(width * left / 100) @@ -451,9 +467,10 @@ class ImageFilterImplement: arr = np.array(image) noise = np.random.randint(0, 256, arr.shape, dtype=np.uint8) - # 为每个像素创建掩码,然后扩展到所有通道 + # 为每个像素创建掩码,然后扩展到所有通道,除了 alpha 通道 mask = np.random.rand(*arr.shape[:2]) < amount - mask_3d = mask[:, :, np.newaxis] # 添加第三个维度 + mask_3d = np.repeat(mask[:, :, np.newaxis], 4, axis=2) + mask_3d[:, :, 3] = False # 保持 alpha 通道不变 # 混合噪点 result = np.where(mask_3d, noise, arr) @@ -887,4 +904,184 @@ class ImageFilterImplement: result[:, :, c] = arr[:, :, c] * (1 - distance) + blurred_arr[:, :, c] * distance return Image.fromarray(np.clip(result, 0, 255).astype(np.uint8), 'RGBA') + + # 复制画面 + @staticmethod + def copy_area(image: Image.Image, + dst_move: str = '(100,100)', + scale: float = 1.0, + src_area: str = '(0,0,100,100)') -> Image.Image: + if image.mode != 'RGBA': + image = image.convert('RGBA') + result = image.copy() + # 参数表示的是百分比 + width, height = image.size + # 首先裁剪出源区域 + src_tuple = eval(src_area) + src_area = ImageFilterImplement.apply_crop(image, src_tuple[0], src_tuple[1], src_tuple[2], src_tuple[3]) + # 计算目标位置 + dst_tuple = eval(dst_move) + dst_x = int(width * dst_tuple[0] / 100) + dst_y = int(height * dst_tuple[1] / 100) + # 缩放源区域 + if scale != 1.0: + new_width = int(src_area.width * scale) + new_height = int(src_area.height * scale) + src_area = src_area.resize((new_width, new_height), Image.Resampling.LANCZOS) + # 粘贴到目标位置 + result.paste(src_area, (dst_x, dst_y), src_area) + return result + + # 水平翻转 + @staticmethod + def apply_flip_horizontal(image: Image.Image) -> Image.Image: + return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + + # 垂直翻转 + @staticmethod + def apply_flip_vertical(image: Image.Image) -> Image.Image: + return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + + # 依方向镜像一半画面 + @staticmethod + def apply_mirror_half(image: Image.Image, angle: float = 90) -> Image.Image: + if image.mode != 'RGBA': + image = image.convert('RGBA') + width, height = image.size + arr = np.array(image, dtype=np.uint8) + + # 角度标准化 + angle = angle % 360 + + # 计算折痕的斜率 + # 注意:我们的角度是折痕与水平线的夹角 + # 0° = 垂直线,90° = 水平线 + rad = math.radians(angle) + + # 计算中心点 + center_x = width / 2 + center_y = height / 2 + + # 创建坐标网格 + y_coords, x_coords = np.mgrid[0:height, 0:width] + + # 计算折痕的方向向量 + # 折痕方向垂直于法向量 + # 对于角度θ,折痕方向向量为(cosθ, sinθ) + direction_x = math.cos(rad) + direction_y = math.sin(rad) + + # 计算法向量(垂直于折痕) + # 旋转90度:(-sinθ, cosθ) + normal_x = -direction_y + normal_y = direction_x + + # 计算每个像素相对于折痕的位置 + # 点到直线的有向距离: (x-cx)*nx + (y-cy)*ny + rel_x = x_coords - center_x + rel_y = y_coords - center_y + signed_distance = rel_x * normal_x + rel_y * normal_y + + source_mask = signed_distance >= 0 + + # 创建结果图像 + result = arr.copy() + + # 获取镜像侧的像素(需要被替换的像素) + mirror_mask = ~source_mask + + if mirror_mask.any(): + # 对于镜像侧的每个像素,计算其关于折痕的镜像点 + mirror_x = x_coords[mirror_mask] + mirror_y = y_coords[mirror_mask] + + # 计算相对坐标 + mirror_rel_x = mirror_x - center_x + mirror_rel_y = mirror_y - center_y + + # 计算到折痕的距离(带符号) + dist_to_line = signed_distance[mirror_mask] + + # 计算镜像点的相对坐标 + # 镜像公式: p' = p - 2 * (p·n) * n + # 其中p是相对中心点的向量,n是单位法向量 + mirror_target_rel_x = mirror_rel_x - 2 * dist_to_line * normal_x + mirror_target_rel_y = mirror_rel_y - 2 * dist_to_line * normal_y + + # 计算镜像点的绝对坐标 + target_x = np.round(center_x + mirror_target_rel_x).astype(int) + target_y = np.round(center_y + mirror_target_rel_y).astype(int) + + # 确保坐标在图像范围内 + valid = (target_x >= 0) & (target_x < width) & (target_y >= 0) & (target_y < height) + + if valid.any(): + # 将源侧的像素复制到镜像侧的位置 + valid_target_x = target_x[valid] + valid_target_y = target_y[valid] + valid_mirror_x = mirror_x[valid] + valid_mirror_y = mirror_y[valid] + + # 获取源侧的像素值 + source_pixels = arr[valid_target_y, valid_target_x] + + # 复制到镜像位置 + result[valid_mirror_y, valid_mirror_x] = source_pixels + + return Image.fromarray(result, 'RGBA') + + # 随机画面整体晃动,对于动图会很有效 + @staticmethod + def apply_random_wiggle(image: Image.Image, max_offset: int = 5, motion_blur = False) -> Image.Image: + if image.mode != 'RGBA': + image = image.convert('RGBA') + + width, height = image.size + arr = np.array(image) + # 生成随机偏移 + x_offset = random.randint(-max_offset, max_offset) + y_offset = random.randint(-max_offset, max_offset) + # 画面平移 + translated_image = ImageFilterImplement.apply_translate(image, x_offset, y_offset) + if motion_blur: + # 添加运动模糊 + translated_image = ImageFilterImplement.apply_directional_blur(translated_image, + angle=random.uniform(0, 360), + distance=max_offset, + samples=6) + return translated_image + + # 像素随机抖动 + @staticmethod + def apply_pixel_jitter(image: Image.Image, max_offset: int = 2) -> Image.Image: + if image.mode != 'RGBA': + image = image.convert('RGBA') + + width, height = image.size + arr = np.array(image) + + # 创建结果图像 + result = np.zeros_like(arr) + + # numpy 向量化随机偏移 + x_indices = np.arange(width) + y_indices = np.arange(height) + x_grid, y_grid = np.meshgrid(x_indices, y_indices) + x_offsets = np.random.randint(-max_offset, max_offset + 1, size=(height, width)) + y_offsets = np.random.randint(-max_offset, max_offset + 1, size=(height, width)) + new_x = np.clip(x_grid + x_offsets, 0, width - 1) + new_y = np.clip(y_grid + y_offsets, 0, height - 1) + result[y_grid, x_grid] = arr[new_y, new_x] + + return Image.fromarray(result, 'RGBA') + +class ImageFilterEmpty: + # 空滤镜,不做任何处理,形式化参数用 + @staticmethod + def empty_filter(image): + return image + + @staticmethod + def empty_filter_param(image, param = 10): + return image diff --git a/konabot/plugins/fx_process/fx_manager.py b/konabot/plugins/fx_process/fx_manager.py index 7ae32c3..b315563 100644 --- a/konabot/plugins/fx_process/fx_manager.py +++ b/konabot/plugins/fx_process/fx_manager.py @@ -1,5 +1,5 @@ from typing import Optional -from konabot.plugins.fx_process.fx_handle import ImageFilterImplement +from konabot.plugins.fx_process.fx_handle import ImageFilterEmpty, ImageFilterImplement class ImageFilterManager: filter_map = { @@ -40,6 +40,13 @@ class ImageFilterManager: "方向模糊": ImageFilterImplement.apply_directional_blur, "边缘模糊": ImageFilterImplement.apply_focus_blur, "缩放模糊": ImageFilterImplement.apply_zoom_blur, + "镜像": ImageFilterImplement.apply_mirror_half, + "水平翻转": ImageFilterImplement.apply_flip_horizontal, + "垂直翻转": ImageFilterImplement.apply_flip_vertical, + "复制": ImageFilterImplement.copy_area, + "晃动": ImageFilterImplement.apply_random_wiggle, + "动图": ImageFilterEmpty.empty_filter_param, + "像素抖动": ImageFilterImplement.apply_pixel_jitter, } @classmethod @@ -48,4 +55,6 @@ class ImageFilterManager: @classmethod def has_filter(cls, name: str) -> bool: - return name in cls.filter_map \ No newline at end of file + return name in cls.filter_map + + \ No newline at end of file