Compare commits

...

7 Commits

Author SHA1 Message Date
e0268ec86b Merge pull request 'feat(fx): 添加像素排序 (Pixel Sort) 滤镜' (#67) from pi-agent/konabot:feature/pixel-sort into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #67
2026-04-08 15:52:01 +08:00
575cd43538 docs: 补充像素排序滤镜的 man 文档 2026-04-08 15:50:31 +08:00
cd010afc24 feat(fx): 添加像素排序 (Pixel Sort) 滤镜
- 新增 '像素排序' 滤镜,实现类似 Photoshop Pixel Sort 效果
- 支持水平/垂直方向排序
- 支持多种排序依据:亮度、色相、红/绿/蓝通道
- 支持自动阈值计算(使用图像亮度中位数)
- 支持自定义遮罩阈值
- 支持反向排序
- 支持块大小参数
- 添加相关单元测试
2026-04-08 14:20:46 +08:00
c2161635a8 Merge pull request 'fix: use Query to properly handle --pingpong flag' (#65) from pi-agent/konabot:fix/giftool-pingpong-bool into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #65
2026-04-02 20:47:34 +08:00
f21b7067df fix: use Query to properly handle --pingpong flag
Use nonebot_plugin_alconna Query to correctly handle the --pingpong
boolean flag. Previously the flag wasn't working because the
parameter wasn't being properly injected.

Changes:
- Import Query from nonebot_plugin_alconna
- Change Option to not have args (just Option('--pingpong'))
- Use Query[bool] type annotation with default Query('pingpong')
- Check pingpong.available to determine if flag was set
2026-04-02 20:34:23 +08:00
f7212d6f67 Merge pull request 'feat: add --pingpong flag to giftool' (#64) from pi-agent/konabot:feat/giftool-pingpong into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #64
2026-04-02 20:17:25 +08:00
b87c58485c feat: add --pingpong flag to giftool
Add pingpong mode to giftool command. When --pingpong flag is used,
the generated GIF will play forward then backward, creating a
back-and-forth looping effect.

Features:
- Add --pingpong option to giftool command
- Support combining with --speed for adjusted playback speed
- Update documentation with new option

Examples:
- giftool [图片] --pingpong
- giftool [图片] --pingpong --speed 2.0
2026-04-02 20:06:15 +08:00
6 changed files with 229 additions and 1 deletions

View File

@ -79,6 +79,14 @@ fx [滤镜名称] <参数1> <参数2> ...
* ```fx JPEG损坏 <质量=10>``` * ```fx JPEG损坏 <质量=10>```
* 质量范围建议为 1~95数值越低压缩痕迹越重、效果越搞笑。 * 质量范围建议为 1~95数值越低压缩痕迹越重、效果越搞笑。
* ```fx 动图 <帧率=10>``` * ```fx 动图 <帧率=10>```
* ```fx 像素排序 <方向=horizontal> <阈值=0> <自动阈值=true> <排序依据=brightness> <遮罩阈值=128> <反向=false> <块大小=1>```
* 对像素按指定属性进行排序,效果类似 Photoshop/GIMP Pixel Sort。
* **方向**horizontal水平/ vertical垂直
* **排序依据**brightness亮度/ hue色相/ red / green / blue
* **自动阈值**true 时使用图像亮度中位数作为遮罩阈值
* **遮罩阈值**:决定哪些像素参与排序(亮度 >= 阈值)
* **反向**true 时从亮到暗排序
* **块大小**:每 N 行/列作为一个整体排序单位
### 多图像处理器 ### 多图像处理器
* ```fx 存入图像 <目标名称>``` * ```fx 存入图像 <目标名称>```

View File

@ -71,6 +71,14 @@ giftool [图片] [选项]
- 调整 GIF 图的速度。若为负数,则代表倒放。 - 调整 GIF 图的速度。若为负数,则代表倒放。
### `--pingpong`(可选)
- 开启乒乓模式,生成正放-倒放拼接的 GIF 图。
- 即播放完正向后,会倒放回去,形成往复循环效果。
- 可与 `--speed` 配合使用,调整播放速度。
- 示例:`giftool [图片] --pingpong`
- 示例:`giftool [图片] --pingpong --speed 2.0`
## 使用方式 ## 使用方式
1. 发送指令前,请确保: 1. 发送指令前,请确保:

View File

@ -1354,6 +1354,140 @@ class ImageFilterImplement:
images.append(text_image) images.append(text_image)
return image return image
# Pixel Sort - 像素排序效果
@staticmethod
def apply_pixel_sort(
image: Image.Image,
direction: str = "horizontal",
threshold: float = 0.0,
auto_threshold: bool = True,
sort_by: str = "brightness",
mask_threshold: float = 128.0,
reverse: bool = False,
block_size: int = 1
) -> Image.Image:
"""
Pixel Sort 效果
参数:
image: 输入图像
direction: 排序方向,"horizontal"(水平) 或 "vertical"(垂直)
threshold: 亮度阈值 (0-255),低于此值的像素会被排序(仅在 auto_threshold=False 时生效)
auto_threshold: 是否自动计算阈值(使用图像中位数)
sort_by: 排序依据,"brightness"(亮度)、"hue"(色相)、"red""green""blue"
mask_threshold: 遮罩阈值 (0-255),决定哪些像素参与排序
reverse: 是否反向排序
block_size: 块大小,每 N 行/列作为一个整体排序单位
"""
if image.mode != 'RGBA':
image = image.convert('RGBA')
arr = np.array(image)
height, width = arr.shape[:2]
# 获取排序属性
def get_sort_value(pixel):
r, g, b = pixel[0], pixel[1], pixel[2]
if sort_by == "brightness":
return 0.299 * r + 0.587 * g + 0.114 * b
elif sort_by == "hue":
max_c = max(r, g, b)
min_c = min(r, g, b)
diff = max_c - min_c
if diff == 0:
return 0
if max_c == r:
return 60 * (((g - b) / diff) % 6)
elif max_c == g:
return 60 * ((b - r) / diff + 2)
else:
return 60 * ((r - g) / diff + 4)
elif sort_by == "red":
return r
elif sort_by == "green":
return g
elif sort_by == "blue":
return b
return 0.299 * r + 0.587 * g + 0.114 * b
# 自动计算阈值
if auto_threshold:
# 使用图像亮度中位数作为阈值
gray = np.array(image.convert('L'))
mask_threshold = float(np.median(gray))
# 创建遮罩:哪些像素需要排序
mask = np.zeros((height, width), dtype=bool)
for y in range(height):
for x in range(width):
brightness = 0.299 * arr[y, x, 0] + 0.587 * arr[y, x, 1] + 0.114 * arr[y, x, 2]
mask[y, x] = brightness >= mask_threshold
result = arr.copy()
if direction.lower() in ["horizontal", "h", "水平"]:
# 水平排序(逐行)
for y in range(height):
# 收集当前行中需要排序的像素
if block_size > 1:
# 按块处理
for block_start in range(0, width, block_size):
block_end = min(block_start + block_size, width)
pixels = []
indices = []
for x in range(block_start, block_end):
if mask[y, x]:
pixels.append(arr[y, x].copy())
indices.append(x)
if len(pixels) > 1:
# 按指定属性排序
sorted_pixels = sorted(pixels, key=get_sort_value, reverse=reverse)
for i, x in enumerate(indices):
result[y, x] = sorted_pixels[i]
else:
# 逐像素处理
pixels = []
indices = []
for x in range(width):
if mask[y, x]:
pixels.append(arr[y, x].copy())
indices.append(x)
if len(pixels) > 1:
sorted_pixels = sorted(pixels, key=get_sort_value, reverse=reverse)
for i, x in enumerate(indices):
result[y, x] = sorted_pixels[i]
elif direction.lower() in ["vertical", "v", "垂直"]:
# 垂直排序(逐列)
for x in range(width):
if block_size > 1:
# 按块处理
for block_start in range(0, height, block_size):
block_end = min(block_start + block_size, height)
pixels = []
indices = []
for y in range(block_start, block_end):
if mask[y, x]:
pixels.append(arr[y, x].copy())
indices.append(y)
if len(pixels) > 1:
sorted_pixels = sorted(pixels, key=get_sort_value, reverse=reverse)
for i, y in enumerate(indices):
result[y, x] = sorted_pixels[i]
else:
pixels = []
indices = []
for y in range(height):
if mask[y, x]:
pixels.append(arr[y, x].copy())
indices.append(y)
if len(pixels) > 1:
sorted_pixels = sorted(pixels, key=get_sort_value, reverse=reverse)
for i, y in enumerate(indices):
result[y, x] = sorted_pixels[i]
return Image.fromarray(result, 'RGBA')
class ImageFilterEmpty: class ImageFilterEmpty:

View File

@ -65,6 +65,8 @@ class ImageFilterManager:
"覆盖图像": ImageFilterImplement.apply_overlay, "覆盖图像": ImageFilterImplement.apply_overlay,
# 生成式 # 生成式
"覆加颜色": ImageFilterImplement.generate_solid, "覆加颜色": ImageFilterImplement.generate_solid,
# Pixel Sort
"像素排序": ImageFilterImplement.apply_pixel_sort,
} }
generate_filter_map = { generate_filter_map = {

View File

@ -6,7 +6,7 @@ import PIL
import PIL.Image import PIL.Image
import cv2 import cv2
import imageio.v3 as iio import imageio.v3 as iio
from nonebot_plugin_alconna import Alconna, Args, Image, Option, UniMessage, on_alconna from nonebot_plugin_alconna import Alconna, Args, Image, Option, Query, UniMessage, on_alconna
import numpy import numpy
from konabot.common.nb.exc import BotExceptionMessage from konabot.common.nb.exc import BotExceptionMessage
@ -34,6 +34,7 @@ cmd_giftool = on_alconna(
Option("-t", Args["length", str]), Option("-t", Args["length", str]),
Option("-to", Args["end_point", str]), Option("-to", Args["end_point", str]),
Option("--speed", Args["speed_factor", float], default=1.0, alias=["-s"]), Option("--speed", Args["speed_factor", float], default=1.0, alias=["-s"]),
Option("--pingpong"),
) )
) )
@ -46,6 +47,7 @@ async def _(
length: str | None = None, length: str | None = None,
speed_factor: float = 1.0, speed_factor: float = 1.0,
end_point: str | None = None, end_point: str | None = None,
pingpong: Query[bool] = Query("pingpong"),
): ):
ss: None | float = None ss: None | float = None
if start_point: if start_point:
@ -162,6 +164,16 @@ async def _(
rframes = rframes[::-1] rframes = rframes[::-1]
rdur_ms = rdur_ms[::-1] rdur_ms = rdur_ms[::-1]
# 处理 pingpong 模式
if pingpong.available:
# 复制一份反转的帧序列(去掉第一帧避免重复)
pingpong_frames = rframes[1:][::-1] if len(rframes) > 1 else rframes[::-1]
pingpong_durations = rdur_ms[1:][::-1] if len(rdur_ms) > 1 else rdur_ms[::-1]
# 拼接正放和倒放
rframes = rframes + pingpong_frames
rdur_ms = rdur_ms + pingpong_durations
output_img = BytesIO() output_img = BytesIO()
if rframes: if rframes:

View File

@ -86,3 +86,67 @@ def test_prase_input_args_parses_resize_second_argument_as_float():
assert len(filters) == 1 assert len(filters) == 1
assert filters[0].name == "缩放" assert filters[0].name == "缩放"
assert filters[0].args == [2.0, 3.0] assert filters[0].args == [2.0, 3.0]
def test_apply_pixel_sort_keeps_image_mode_and_size():
"""测试 Pixel Sort 保持图像的 mode 和 size"""
image = Image.new("RGBA", (10, 10), (255, 0, 0, 128))
result = ImageFilterImplement.apply_pixel_sort(image)
assert result.size == image.size
assert result.mode == "RGBA"
def test_apply_pixel_sort_horizontal():
"""测试水平方向的 Pixel Sort"""
# 创建一个简单的渐变图像
image = Image.new("RGB", (5, 3))
# 第一行:红到蓝渐变
image.putpixel((0, 0), (255, 0, 0))
image.putpixel((1, 0), (200, 0, 0))
image.putpixel((2, 0), (100, 0, 0))
image.putpixel((3, 0), (50, 0, 0))
image.putpixel((4, 0), (0, 0, 255))
# 填充其他行
for y in range(1, 3):
for x in range(5):
image.putpixel((x, y), (128, 128, 128))
result = ImageFilterImplement.apply_pixel_sort(
image, direction="horizontal", auto_threshold=False, mask_threshold=10
)
assert result.size == image.size
assert result.mode == "RGBA"
def test_apply_pixel_sort_vertical():
"""测试垂直方向的 Pixel Sort"""
image = Image.new("RGB", (3, 5))
# 第一列:绿到红渐变
image.putpixel((0, 0), (0, 255, 0))
image.putpixel((0, 1), (0, 200, 0))
image.putpixel((0, 2), (0, 100, 0))
image.putpixel((0, 3), (0, 50, 0))
image.putpixel((0, 4), (255, 0, 0))
# 填充其他列
for y in range(5):
for x in range(1, 3):
image.putpixel((x, y), (128, 128, 128))
result = ImageFilterImplement.apply_pixel_sort(
image, direction="vertical", auto_threshold=False, mask_threshold=10
)
assert result.size == image.size
assert result.mode == "RGBA"
def test_prase_input_args_parses_pixel_sort_arguments():
"""测试解析 Pixel Sort 参数"""
filters = prase_input_args("像素排序 horizontal 0 false brightness 128 false 1")
assert len(filters) == 1
assert filters[0].name == "像素排序"
assert filters[0].args == ["horizontal", 0.0, False, "brightness", 128.0, False, 1]