Compare commits

..

5 Commits

Author SHA1 Message Date
274ca0fa9a 初步尝试UI化 2025-12-03 22:00:44 +08:00
c72cdd6a6b 新滤镜,新修复
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 17:20:46 +08:00
16b0451133 删掉黑白
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 13:32:11 +08:00
cb34813c4b Merge branch 'master' of ssh://gitea.service.jazzwhom.top:2221/mttu-developers/konabot 2025-12-03 13:22:09 +08:00
26e10be4ec 修复相关文档 2025-11-28 17:10:18 +08:00
9 changed files with 341 additions and 34 deletions

View File

@ -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 阴影 <x偏移量=10> <y偏移量=10> <模糊量=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 平移 <x偏移量=10> <y偏移量=10>```
* ```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>```
## 颜色名称支持
- **基本颜色**:红、绿、蓝、黄、紫、黑、白、橙、粉、灰、青、靛、棕

View File

@ -9,7 +9,7 @@ John: 11-28 16:50:37
谁来总结一下今天的工作?
Jack: 11-28 16:50:55
[引用John的消息] tqszm
[引用John的消息] @此方Bot tqszm
此方Bot: 11-28 16:50:56
slzjyxjtdgz
@ -18,7 +18,7 @@ slzjyxjtdgz
或者,你也可以直接以正常指令的方式调用:
```
提取首字母 中山大学
@此方Bot 提取首字母 中山大学
> zsdx
```

View File

@ -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():

View File

@ -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)
@ -545,8 +562,8 @@ class ImageFilterImplement:
def apply_shadow(image: Image.Image,
x_offset: int = 10,
y_offset: int = 10,
blur = 10,
opacity = 0.5,
blur: float = 10,
opacity: float = 0.5,
shadow_color = "black") -> Image.Image:
if image.mode != 'RGBA':
image = image.convert('RGBA')
@ -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

View File

@ -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
return name in cls.filter_map

View File

@ -6,24 +6,11 @@ import PIL
import PIL.Image
import cv2
import imageio.v3 as iio
from nonebot import on_message
from nonebot.adapters import Bot
from nonebot_plugin_alconna import Alconna, Args, Image, Option, UniMessage, on_alconna
import numpy
from konabot.common.nb.exc import BotExceptionMessage
from konabot.common.nb.extract_image import DepImageBytes, DepPILImage
from konabot.common.nb.match_keyword import match_keyword
from konabot.common.nb.reply_image import reply_image
# 保持不变
cmd_black_white = on_message(rule=match_keyword("黑白"))
@cmd_black_white.handle()
async def _(img: DepPILImage, bot: Bot):
# 保持不变
await reply_image(cmd_black_white, bot, img.convert("LA"))
from konabot.common.nb.extract_image import DepImageBytes
# 保持不变

View File

@ -0,0 +1,26 @@
from loguru import logger
import nonebot
from nonebot.adapters import Event as BaseEvent
from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
from nonebot_plugin_alconna import (
UniMessage,
UniMsg
)
from konabot.plugins.notice_ui.notice import NoticeUI
from nonebot_plugin_alconna import on_alconna, Alconna, Args
evt = on_alconna(Alconna(
"notice",
Args["title", str],
Args["message", str]
),
use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True
)
@evt.handle()
async def _(title: str, message: str, msg: UniMsg, event: BaseEvent):
logger.debug(f"Received notice command with title: {title}, message: {message}")
out = await NoticeUI.render_notice(title, message)
await evt.send(await UniMessage().image(raw=out).export())

View File

@ -0,0 +1,53 @@
from io import BytesIO
from PIL import Image
from konabot.common.web_render import konaweb
from konabot.common.web_render.core import WebRenderer
from playwright.async_api import Page
class NoticeUI:
@staticmethod
async def render_notice(title: str, message: str) -> bytes:
"""
渲染一个通知图片,包含标题和消息内容。
"""
async def page_function(page: Page):
# 直到 setMaskMode 函数加载完成
await page.wait_for_function("typeof setMaskMode === 'function'", timeout=1000)
await page.evaluate('setMaskMode(false)')
# 直到 setContent 函数加载完成
await page.wait_for_function("typeof setContent === 'function'", timeout=1000)
# 设置标题和消息内容
await page.evaluate(f'setContent("{title}", "{message}")')
async def mask_function(page: Page):
# 直到 setContent 函数加载完成
await page.wait_for_function("typeof setContent === 'function'", timeout=1000)
# 设置标题和消息内容
await page.evaluate(f'setContent("{title}", "{message}")')
# 直到 setMaskMode 函数加载完成
await page.wait_for_function("typeof setMaskMode === 'function'", timeout=1000)
await page.evaluate('setMaskMode(true)')
image_bytes = await WebRenderer.render_with_persistent_page(
"notice_renderer",
konaweb('notice'),
target='#main',
other_function=page_function,
)
mask_bytes = await WebRenderer.render_with_persistent_page(
"notice_renderer",
konaweb('notice'),
target='#main',
other_function=mask_function)
image = Image.open(BytesIO(image_bytes)).convert("RGBA")
mask = Image.open(BytesIO(mask_bytes)).convert("L")
# 应用遮罩
image.putalpha(mask)
output_buffer = BytesIO()
image.save(output_buffer, format="GIF", disposal=2, loop=0)
output_buffer.seek(0)
return output_buffer.getvalue()

View File

@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any
import nanoid
from konabot.plugins.notice_ui.notice import NoticeUI
import nonebot
from loguru import logger
from nonebot import get_plugin_config, on_message
@ -107,6 +108,10 @@ async def _(task: LongTask):
await task.target.send_message(
UniMessage().text(f"代办提醒:{message}")
)
notice_bytes = NoticeUI.render_notice("代办提醒", message)
await task.target.send_message(
await UniMessage().image(raw=notice_bytes).export()
)
async with DATA_FILE_LOCK:
data = load_notify_config()
if (chan := data.notify_channels.get(task.target.target_id)) is not None: