forked from mttu-developers/konabot
Compare commits
15 Commits
enhancemen
...
raymarch_t
| Author | SHA1 | Date | |
|---|---|---|---|
| 81e0c05686 | |||
| 3bf0735af4 | |||
| b73d020b67 | |||
| 0eb957ba87 | |||
| 2c1709a77d | |||
| 87e5029be0 | |||
|
0ba51bc9b2
|
|||
|
27670920f6
|
|||
| e0268ec86b | |||
| 575cd43538 | |||
| cd010afc24 | |||
| c2161635a8 | |||
| f21b7067df | |||
| f7212d6f67 | |||
| b87c58485c |
3
.gitignore
vendored
3
.gitignore
vendored
@ -24,3 +24,6 @@ __pycache__
|
||||
/.venv
|
||||
/venv
|
||||
*.egg-info
|
||||
|
||||
# OS 相关
|
||||
.DS_Store
|
||||
BIN
assets/img/meme/vr.jpg
Executable file
BIN
assets/img/meme/vr.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@ -131,7 +131,7 @@ class DatabaseManager:
|
||||
await conn.execute(command, params or ())
|
||||
await conn.commit()
|
||||
except Exception as e:
|
||||
# 记录错误但重新抛出,让调用者处理
|
||||
await conn.rollback()
|
||||
raise Exception(f"数据库执行失败: {str(e)}") from e
|
||||
finally:
|
||||
await self._return_connection(conn)
|
||||
@ -143,7 +143,7 @@ class DatabaseManager:
|
||||
await conn.executescript(script)
|
||||
await conn.commit()
|
||||
except Exception as e:
|
||||
# 记录错误但重新抛出,让调用者处理
|
||||
await conn.rollback()
|
||||
raise Exception(f"数据库脚本执行失败: {str(e)}") from e
|
||||
finally:
|
||||
await self._return_connection(conn)
|
||||
@ -197,7 +197,7 @@ class DatabaseManager:
|
||||
await conn.executemany(command, seq_of_params)
|
||||
await conn.commit()
|
||||
except Exception as e:
|
||||
# 记录错误但重新抛出,让调用者处理
|
||||
await conn.rollback()
|
||||
raise Exception(f"数据库批量执行失败: {str(e)}") from e
|
||||
finally:
|
||||
await self._return_connection(conn)
|
||||
|
||||
@ -52,7 +52,11 @@ async def get_current_version(conn: aiosqlite.Connection) -> int:
|
||||
if count[0] < 1:
|
||||
logger.info("权限系统数据表不存在,现在创建表")
|
||||
await conn.executescript(SQL_CREATE_TABLE)
|
||||
await conn.commit()
|
||||
try:
|
||||
await conn.commit()
|
||||
except Exception:
|
||||
await conn.rollback()
|
||||
raise
|
||||
return 0
|
||||
cursor = await conn.execute(SQL_GET_MIGRATE_VERSION)
|
||||
row = await cursor.fetchone()
|
||||
@ -72,10 +76,18 @@ async def execute_migration(
|
||||
await conn.executescript(migration.get_upgrade_script())
|
||||
now_version += 1
|
||||
await conn.execute(SQL_UPDATE_VERSION, (now_version,))
|
||||
await conn.commit()
|
||||
try:
|
||||
await conn.commit()
|
||||
except Exception:
|
||||
await conn.rollback()
|
||||
raise
|
||||
while now_version > version:
|
||||
migration = migrations[now_version - 1]
|
||||
await conn.executescript(migration.get_downgrade_script())
|
||||
now_version -= 1
|
||||
await conn.execute(SQL_UPDATE_VERSION, (now_version,))
|
||||
await conn.commit()
|
||||
try:
|
||||
await conn.commit()
|
||||
except Exception:
|
||||
await conn.rollback()
|
||||
raise
|
||||
|
||||
@ -43,11 +43,15 @@ class PermRepo:
|
||||
Raises:
|
||||
AssertionError: 如果创建后无法获取实体 ID。
|
||||
"""
|
||||
await self.conn.execute(
|
||||
s("create_entity.sql"),
|
||||
(entity.platform, entity.entity_type, entity.external_id),
|
||||
)
|
||||
await self.conn.commit()
|
||||
try:
|
||||
await self.conn.execute(
|
||||
s("create_entity.sql"),
|
||||
(entity.platform, entity.entity_type, entity.external_id),
|
||||
)
|
||||
await self.conn.commit()
|
||||
except Exception:
|
||||
await self.conn.rollback()
|
||||
raise
|
||||
eid = await self._get_entity_id_or_none(entity)
|
||||
assert eid is not None
|
||||
return eid
|
||||
@ -115,8 +119,12 @@ class PermRepo:
|
||||
value: 要设置的配置值(True/False/None)。
|
||||
"""
|
||||
eid = await self.get_entity_id(entity)
|
||||
await self.conn.execute(s("update_perm_info.sql"), (eid, config_key, value))
|
||||
await self.conn.commit()
|
||||
try:
|
||||
await self.conn.execute(s("update_perm_info.sql"), (eid, config_key, value))
|
||||
await self.conn.commit()
|
||||
except Exception:
|
||||
await self.conn.rollback()
|
||||
raise
|
||||
|
||||
async def get_entity_id_batch(
|
||||
self, entities: list[PermEntity]
|
||||
@ -135,11 +143,15 @@ class PermRepo:
|
||||
# s("create_entity.sql"),
|
||||
# (entity.platform, entity.entity_type, entity.external_id),
|
||||
# )
|
||||
await self.conn.executemany(
|
||||
s("create_entity.sql"),
|
||||
[(e.platform, e.entity_type, e.external_id) for e in entities],
|
||||
)
|
||||
await self.conn.commit()
|
||||
try:
|
||||
await self.conn.executemany(
|
||||
s("create_entity.sql"),
|
||||
[(e.platform, e.entity_type, e.external_id) for e in entities],
|
||||
)
|
||||
await self.conn.commit()
|
||||
except Exception:
|
||||
await self.conn.rollback()
|
||||
raise
|
||||
val_placeholders = ", ".join(["(?, ?, ?)"] * len(entities))
|
||||
params = []
|
||||
for e in entities:
|
||||
|
||||
@ -79,6 +79,14 @@ fx [滤镜名称] <参数1> <参数2> ...
|
||||
* ```fx JPEG损坏 <质量=10>```
|
||||
* 质量范围建议为 1~95,数值越低,压缩痕迹越重、效果越搞笑。
|
||||
* ```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 存入图像 <目标名称>```
|
||||
|
||||
@ -71,6 +71,14 @@ giftool [图片] [选项]
|
||||
|
||||
- 调整 GIF 图的速度。若为负数,则代表倒放。
|
||||
|
||||
### `--pingpong`(可选)
|
||||
|
||||
- 开启乒乓模式,生成正放-倒放拼接的 GIF 图。
|
||||
- 即播放完正向后,会倒放回去,形成往复循环效果。
|
||||
- 可与 `--speed` 配合使用,调整播放速度。
|
||||
- 示例:`giftool [图片] --pingpong`
|
||||
- 示例:`giftool [图片] --pingpong --speed 2.0`
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 发送指令前,请确保:
|
||||
|
||||
4
konabot/docs/user/march.txt
Normal file
4
konabot/docs/user/march.txt
Normal file
@ -0,0 +1,4 @@
|
||||
# 指令介绍
|
||||
简易 Raymarch 小玩具
|
||||
用法:march <scene>
|
||||
例:march sphere(1) cam(4).pos(-5).lookat(0)
|
||||
@ -1354,6 +1354,140 @@ class ImageFilterImplement:
|
||||
images.append(text_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:
|
||||
|
||||
@ -65,6 +65,8 @@ class ImageFilterManager:
|
||||
"覆盖图像": ImageFilterImplement.apply_overlay,
|
||||
# 生成式
|
||||
"覆加颜色": ImageFilterImplement.generate_solid,
|
||||
# Pixel Sort
|
||||
"像素排序": ImageFilterImplement.apply_pixel_sort,
|
||||
}
|
||||
|
||||
generate_filter_map = {
|
||||
|
||||
@ -6,7 +6,7 @@ import PIL
|
||||
import PIL.Image
|
||||
import cv2
|
||||
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
|
||||
|
||||
from konabot.common.nb.exc import BotExceptionMessage
|
||||
@ -34,6 +34,7 @@ cmd_giftool = on_alconna(
|
||||
Option("-t", Args["length", str]),
|
||||
Option("-to", Args["end_point", str]),
|
||||
Option("--speed", Args["speed_factor", float], default=1.0, alias=["-s"]),
|
||||
Option("--pingpong"),
|
||||
)
|
||||
)
|
||||
|
||||
@ -46,6 +47,7 @@ async def _(
|
||||
length: str | None = None,
|
||||
speed_factor: float = 1.0,
|
||||
end_point: str | None = None,
|
||||
pingpong: Query[bool] = Query("pingpong"),
|
||||
):
|
||||
ss: None | float = None
|
||||
if start_point:
|
||||
@ -162,6 +164,16 @@ async def _(
|
||||
rframes = rframes[::-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()
|
||||
|
||||
if rframes:
|
||||
|
||||
15
konabot/plugins/marchtoy/__init__.py
Normal file
15
konabot/plugins/marchtoy/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters import Message
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
from nonebot.params import CommandArg
|
||||
import render
|
||||
import io
|
||||
cmd_marchtoy = on_command("march", priority=10, block=True)
|
||||
@cmd_marchtoy.handle()
|
||||
async def _(args: Message = CommandArg()):
|
||||
if cmd := args.extract_plain_text():
|
||||
img = await render.render(cmd, 256, 256)
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
await cmd_marchtoy.send(await UniMessage().image(raw=buffer).export())
|
||||
149
konabot/plugins/marchtoy/command.py
Normal file
149
konabot/plugins/marchtoy/command.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""
|
||||
raymarch toy
|
||||
|
||||
usage: march <scene1> <scene2> ... <sceneN>
|
||||
|
||||
<scene> ::= <scene> "." <command>
|
||||
| <command>
|
||||
|
||||
<command> ::= <id>
|
||||
| <id> "(" <args> ")"
|
||||
|
||||
example:
|
||||
march cube.pos(0, 0, 0).color(red) cam(1.0).pos(1, 1, 1).lookat(0, 0, 0)
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
import numpy as np
|
||||
from dataclasses import dataclass
|
||||
from konabot.plugins.marchtoy.obj import Object, Camera, OBJECT_ENTRIES
|
||||
from konabot.plugins.marchtoy.op import OPERATION_ENTRIES
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class Command:
|
||||
id: str
|
||||
args: list[str]
|
||||
|
||||
|
||||
class CommandChainParser:
|
||||
CHAIN_PATTERN = r"^[a-zA-Z]+(\([^(]*\))?(\.[a-zA-Z]+(\([^(]*\))?)+"
|
||||
|
||||
def __init__(self, _command_chain: str) -> None:
|
||||
self.command_chain = _command_chain
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if query := re.match(CommandChainParser.CHAIN_PATTERN, self.command_chain):
|
||||
cmd_chain = query[0]
|
||||
self.command_chain = self.command_chain[len(cmd_chain) + 1 :]
|
||||
return cmd_chain
|
||||
raise StopIteration
|
||||
|
||||
|
||||
class CommandParser:
|
||||
CMD_PATTERN = r"^[a-zA-Z]+(\([^(]*\))?"
|
||||
ID_PATTERN = r"^[a-zA-Z]+(?=\(|\.|$)"
|
||||
|
||||
def __init__(self, _command: str) -> None:
|
||||
self.command = _command
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if query := re.match(CommandParser.CMD_PATTERN, self.command):
|
||||
cmd = query[0]
|
||||
if cmd_id_qry := re.match(CommandParser.ID_PATTERN, cmd):
|
||||
cmd_id = cmd_id_qry[0]
|
||||
self.command = self.command[len(cmd) + 1 :]
|
||||
cmd_args = cmd[len(cmd_id) + 1 : -1].replace(" ", "").split(",")
|
||||
while "" in cmd_args:
|
||||
cmd_args.remove("")
|
||||
return Command(cmd_id, cmd_args)
|
||||
raise StopIteration
|
||||
|
||||
|
||||
class Scene:
|
||||
def __init__(self, _instruction: str) -> None:
|
||||
self.canvas_objs: list[tuple[Object, str]] = []
|
||||
self.camera: Camera = Camera()
|
||||
for raw_cmd in CommandChainParser(_instruction):
|
||||
cmd_queue = CommandParser(raw_cmd)
|
||||
cmd_obj = next(cmd_queue)
|
||||
obj_id, obj_args = cmd_obj.id, cmd_obj.args
|
||||
obj_instance: Optional[Object] = None
|
||||
if obj_id in OBJECT_ENTRIES:
|
||||
obj_cls = OBJECT_ENTRIES[obj_id]
|
||||
if not issubclass(obj_cls, Object):
|
||||
raise Exception(f"{obj_id} is not a subclass of Object.")
|
||||
obj_instance = obj_cls()
|
||||
try:
|
||||
if len(obj_args) != 0:
|
||||
obj_instance.parse_args(obj_args)
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
f"object {obj_id} failed to parse args passed in: {obj_args}"
|
||||
) from e
|
||||
else:
|
||||
raise Exception(f"{obj_id} is not a valid object type.")
|
||||
|
||||
if obj_instance != None:
|
||||
for cmd in cmd_queue:
|
||||
op_id, op_args = cmd.id, cmd.args
|
||||
if op_id in OPERATION_ENTRIES:
|
||||
op_func = OPERATION_ENTRIES[op_id]
|
||||
if not callable(op_func):
|
||||
raise Exception(f"{op_id} is not a valid operation.")
|
||||
op_func(obj_instance, op_args)
|
||||
else:
|
||||
raise Exception(f"{op_id} is not a valid operation.")
|
||||
|
||||
try:
|
||||
sdf_block = obj_instance.sdf_block()
|
||||
self.canvas_objs.append((obj_instance, sdf_block))
|
||||
except:
|
||||
if type(obj_instance) == Camera:
|
||||
self.camera = obj_instance
|
||||
|
||||
def __str__(self) -> str:
|
||||
return ", ".join([str(type(obj)) for obj in self.canvas_objs])
|
||||
|
||||
def compile(self) -> str:
|
||||
PATH = pathlib.Path(__file__).parent / "shaders" / "raymarch.sksl"
|
||||
with PATH.open(encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
sdf_block: str = ""
|
||||
color_block: str = ""
|
||||
|
||||
index = 0
|
||||
for canvas_item in self.canvas_objs:
|
||||
obj, sdf_expr = canvas_item
|
||||
sdf_block += f"float sd{index} = {sdf_expr};"
|
||||
sdf_block += f"if(sd{index} < qry.value)"
|
||||
sdf_block += "{" + f"qry.value = sd{index}; qry.obj_id = {index}; " + "}\n"
|
||||
color = obj.texture.color
|
||||
color_block += (
|
||||
f"if(obj_id == {index}) return float4("
|
||||
f"{color[0]}, {color[1]}, {color[2]}, {color[3]});\n"
|
||||
)
|
||||
index += 1
|
||||
|
||||
content = content.replace("<SDF_BLOCK>", sdf_block)
|
||||
content = content.replace("<COLOR_BLOCK>", color_block)
|
||||
|
||||
cam_pos = self.camera.transform.t[0:3, 3]
|
||||
cam_focus = self.camera.focus
|
||||
cam_dir = self.camera.transform.t @ np.array((0.0, 0.0, -1.0, 0.0))
|
||||
content = content.replace(
|
||||
"<CAM_POS>", f"{cam_pos[0]}, {cam_pos[1]}, {cam_pos[2]}"
|
||||
)
|
||||
content = content.replace("<CAM_FOCUS>", str(cam_focus))
|
||||
content = content.replace(
|
||||
"<CAM_DIR>", f"{cam_dir[0]}, {cam_dir[1]}, {cam_dir[2]}"
|
||||
)
|
||||
return content
|
||||
124
konabot/plugins/marchtoy/obj.py
Normal file
124
konabot/plugins/marchtoy/obj.py
Normal file
@ -0,0 +1,124 @@
|
||||
import numpy as np
|
||||
from konabot.plugins.marchtoy.texture import Texture
|
||||
from konabot.plugins.marchtoy.utilities import ArgParser, SkslFormatter
|
||||
|
||||
OBJECT_ENTRIES = {}
|
||||
|
||||
|
||||
def make_obj(name: str, aliases: list[str] = []):
|
||||
def decorator(cls):
|
||||
OBJECT_ENTRIES[name] = cls
|
||||
for alias in aliases:
|
||||
OBJECT_ENTRIES[alias] = cls
|
||||
return cls
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class Transform:
|
||||
def __init__(self) -> None:
|
||||
self.t: np.ndarray = np.identity(4, dtype=np.float32)
|
||||
|
||||
@staticmethod
|
||||
def normalize(p: np.ndarray) -> np.ndarray:
|
||||
return p / np.sqrt(np.dot(p, p))
|
||||
|
||||
def translate(self, x: float, y: float, z: float):
|
||||
mat = np.identity(4, dtype=np.float32)
|
||||
mat[0:3, 3] = [x, y, z]
|
||||
self.t = mat @ self.t
|
||||
return self
|
||||
|
||||
# scale 会破坏 sdf 的性质,不应该使用
|
||||
def scale(self, x: float, y: float, z: float):
|
||||
mat = np.identity(4, dtype=np.float32)
|
||||
mat[0, 0], mat[1, 1], mat[2, 2] = x, y, z
|
||||
self.t = mat @ self.t
|
||||
return self
|
||||
|
||||
def rotate(self, x: float, y: float, z: float):
|
||||
cx, sx = np.cos(x), np.sin(x)
|
||||
cy, sy = np.cos(y), np.sin(y)
|
||||
cz, sz = np.cos(z), np.sin(z)
|
||||
|
||||
mat = np.identity(4, dtype=np.float32)
|
||||
mat[0, 0] = cy * cz
|
||||
mat[0, 1] = sx * sy * cz - cx * sz
|
||||
mat[0, 2] = cx * sy * cz + sx * sz
|
||||
|
||||
mat[1, 0] = cy * sz
|
||||
mat[1, 1] = sx * sy * sz + cx * cz
|
||||
mat[1, 2] = cx * sy * sz - sx * cz
|
||||
|
||||
mat[2, 0] = -sy
|
||||
mat[2, 1] = sx * cy
|
||||
mat[2, 2] = cx * cy
|
||||
|
||||
self.t = mat @ self.t
|
||||
return self
|
||||
|
||||
def lookat(
|
||||
self, x: float, y: float, z: float, up: np.ndarray = np.array([0.0, 1.0, 0.0])
|
||||
):
|
||||
p = self.t[0:3, 3]
|
||||
q = np.array([x, y, z])
|
||||
zaxis = Transform.normalize(q - p)
|
||||
xaxis = Transform.normalize(np.cross(zaxis, up))
|
||||
yaxis = Transform.normalize(np.cross(xaxis, zaxis))
|
||||
# 约定本地 -Z 为“朝前”,这样和 shader 中使用的相机射线方向保持一致。
|
||||
self.t[:3, 0] = xaxis
|
||||
self.t[:3, 1] = yaxis
|
||||
self.t[:3, 2] = -zaxis
|
||||
return self
|
||||
|
||||
def p_expr(self) -> str:
|
||||
inv = np.linalg.inv(self.t) # + 1e-5 * np.identity(4, dtype=np.float32))
|
||||
return f"({SkslFormatter.mat4(inv)} * float4(p, 1.0)).xyz"
|
||||
|
||||
|
||||
class Object:
|
||||
def __init__(self) -> None:
|
||||
self.transform: Transform = Transform()
|
||||
self.texture: Texture = Texture()
|
||||
|
||||
def parse_args(self, args: list[str]):
|
||||
raise NotImplementedError
|
||||
|
||||
def sdf_block(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@make_obj("cube")
|
||||
class Cube(Object):
|
||||
def __init__(self, _size: np.ndarray = np.array([1.0, 1.0, 1.0])) -> None:
|
||||
super().__init__()
|
||||
self.size = _size
|
||||
|
||||
def parse_args(self, args: list[str]):
|
||||
self.size = ArgParser.as_vec3(args)
|
||||
|
||||
def sdf_block(self) -> str:
|
||||
return f"sdCube({self.transform.p_expr()}, float3({self.size[0]}, {self.size[1]}, {self.size[2]}))"
|
||||
|
||||
|
||||
@make_obj("sphere")
|
||||
class Sphere(Object):
|
||||
def __init__(self, _radius: float = 1.0) -> None:
|
||||
super().__init__()
|
||||
self.radius = _radius
|
||||
|
||||
def parse_args(self, args: list[str]):
|
||||
self.radius = ArgParser.as_float(args)
|
||||
|
||||
def sdf_block(self) -> str:
|
||||
return f"sdSphere({self.transform.p_expr()}, {self.radius})"
|
||||
|
||||
|
||||
@make_obj("camera", ["cam"])
|
||||
class Camera(Object):
|
||||
def __init__(self, _focus: float = 1.0) -> None:
|
||||
super().__init__()
|
||||
self.focus = _focus
|
||||
|
||||
def parse_args(self, args: list[str]):
|
||||
self.focus = ArgParser.as_float(args)
|
||||
52
konabot/plugins/marchtoy/op.py
Normal file
52
konabot/plugins/marchtoy/op.py
Normal file
@ -0,0 +1,52 @@
|
||||
from konabot.plugins.marchtoy.obj import Object
|
||||
import numpy as np
|
||||
from konabot.plugins.marchtoy.utilities import ArgParser
|
||||
|
||||
OPERATION_ENTRIES = {}
|
||||
|
||||
|
||||
def make_operation(name: str, aliases: list[str] = []):
|
||||
def decorator(op):
|
||||
OPERATION_ENTRIES[name] = op
|
||||
for alias in aliases:
|
||||
OPERATION_ENTRIES[alias] = op
|
||||
return op
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@make_operation("pos", ["translate", "position", "p"])
|
||||
def translate(obj: Object, args: list[str]):
|
||||
pos = ArgParser.as_vec3(args)
|
||||
obj.transform.translate(pos[0], pos[1], pos[2])
|
||||
|
||||
|
||||
@make_operation("rot", ["rotate", "r"])
|
||||
def rotate(obj: Object, args: list[str]):
|
||||
pos = ArgParser.as_vec3(args)
|
||||
obj.transform.rotate(pos[0], pos[1], pos[2])
|
||||
|
||||
|
||||
@make_operation("lookat", ["look", "l"])
|
||||
def lookat(obj: Object, args: list[str]):
|
||||
pos = ArgParser.as_vec3(args)
|
||||
obj.transform.lookat(pos[0], pos[1], pos[2])
|
||||
|
||||
|
||||
@make_operation("color", ["col", "texture"])
|
||||
def color(obj: Object, args: list[str]):
|
||||
try:
|
||||
if len(args) == 1:
|
||||
col = ArgParser.as_literal_color(args)
|
||||
obj.texture.color = (col[0], col[1], col[2], col[3])
|
||||
elif len(args) == 3:
|
||||
col = ArgParser.as_vec3(args)
|
||||
obj.texture.color = (col[0], col[1], col[2], 1.0)
|
||||
elif len(args) == 4:
|
||||
col = ArgParser.as_vec4(args)
|
||||
obj.texture.color = (col[0], col[1], col[2], col[3])
|
||||
else:
|
||||
raise Exception("unknown color")
|
||||
|
||||
except:
|
||||
raise Exception("unknown color")
|
||||
41
konabot/plugins/marchtoy/render.py
Normal file
41
konabot/plugins/marchtoy/render.py
Normal file
@ -0,0 +1,41 @@
|
||||
from konabot.plugins.marchtoy.command import Scene
|
||||
import skia
|
||||
import struct
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
# 暂时先照抄小帕的了,之后有空再单独封装一下
|
||||
|
||||
|
||||
async def render(cmd: str, width: int, height: int):
|
||||
scene = Scene(cmd)
|
||||
surface = skia.Surface(width, height)
|
||||
sksl_code = scene.compile()
|
||||
|
||||
runtime_effect = skia.RuntimeEffect.MakeForShader(scene.compile())
|
||||
if runtime_effect is None:
|
||||
raise Exception("cannot compile sksl shader")
|
||||
|
||||
uv_uniform = struct.pack("ff", float(width), float(height))
|
||||
uniform_data = skia.Data.MakeWithCopy(uv_uniform)
|
||||
shader = runtime_effect.makeShader(uniform_data, None, 0)
|
||||
canvas = surface.getCanvas()
|
||||
canvas.clear(skia.Color(0, 0, 0, 0))
|
||||
paint = skia.Paint()
|
||||
paint.setShader(shader)
|
||||
canvas.drawRect(skia.Rect.MakeWH(width, height), paint)
|
||||
image = surface.makeImageSnapshot()
|
||||
target_info = skia.ImageInfo.Make(
|
||||
image.width(),
|
||||
image.height(),
|
||||
skia.ColorType.kBGRA_8888_ColorType,
|
||||
skia.AlphaType.kPremul_AlphaType,
|
||||
)
|
||||
pixel_data = bytearray(
|
||||
image.width() * image.height() * 4
|
||||
) # 4 bytes per pixel (BGRA)
|
||||
success = image.readPixels(target_info, pixel_data, target_info.minRowBytes())
|
||||
img_array = np.frombuffer(pixel_data, dtype=np.uint8).reshape((height, width, 4))
|
||||
rgb_array = img_array[:, :, [2, 1, 0]]
|
||||
pil_img = Image.fromarray(rgb_array)
|
||||
return pil_img
|
||||
73
konabot/plugins/marchtoy/shaders/raymarch.sksl
Normal file
73
konabot/plugins/marchtoy/shaders/raymarch.sksl
Normal file
@ -0,0 +1,73 @@
|
||||
uniform float2 u_resolution;
|
||||
|
||||
const float EPS = 0.001;
|
||||
const int MAX_ITER = 64;
|
||||
struct sdQuery {
|
||||
float value;
|
||||
int obj_id;
|
||||
};
|
||||
|
||||
float sdCube(float3 p, float3 b) {
|
||||
p = abs(p) - b;
|
||||
return length(max(p, 0.0)) + min(max(p.x, max(p.y, p.z)), 0.0);
|
||||
}
|
||||
|
||||
float sdSphere(float3 p, float r) {
|
||||
return length(p) - r;
|
||||
}
|
||||
|
||||
sdQuery sd(float3 p) {
|
||||
sdQuery qry;
|
||||
qry.value = 100000000.0;
|
||||
qry.obj_id = -1;
|
||||
<SDF_BLOCK>
|
||||
return qry;
|
||||
}
|
||||
|
||||
float3 nrm(float3 p) {
|
||||
float2 d = float2(EPS, 0.0);
|
||||
return normalize(float3(
|
||||
sd(p + d.xyy).value - sd(p - d.xyy).value,
|
||||
sd(p + d.yxy).value - sd(p - d.yxy).value,
|
||||
sd(p + d.yyx).value - sd(p - d.yyx).value
|
||||
));
|
||||
}
|
||||
|
||||
float4 materialColor(int obj_id) {
|
||||
<COLOR_BLOCK>
|
||||
return float4(1.0);
|
||||
}
|
||||
|
||||
float4 color(float3 p, int obj_id) {
|
||||
float3 normal = nrm(p);
|
||||
float3 light_dir = normalize(float3(0.5, 0.8, -0.6));
|
||||
float light = 0.2 + 0.8 * max(dot(normal, light_dir), 0.0);
|
||||
float4 base = materialColor(obj_id);
|
||||
return float4(base.rgb * light, base.a);
|
||||
}
|
||||
|
||||
float4 march(float3 p, float3 r) {
|
||||
sdQuery qry;
|
||||
float4 col = float4(0.0);
|
||||
for(int i = 0; i < MAX_ITER; ++i) {
|
||||
qry = sd(p);
|
||||
if(qry.value < EPS){
|
||||
col = color(p, qry.obj_id);
|
||||
break;
|
||||
}
|
||||
p += qry.value * r;
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
half4 main(float2 fragCoord) {
|
||||
float2 uv = -2. * (fragCoord / u_resolution - .5) * float2(u_resolution.x / u_resolution.y, 1.);
|
||||
float3 c_p = float3(<CAM_POS>);
|
||||
float3 c_z = normalize(float3(<CAM_DIR>));
|
||||
float3 world_up = abs(c_z.y) > 0.999 ? float3(0., 0., 1.) : float3(0., 1., 0.);
|
||||
float3 c_x = normalize(cross(c_z, world_up));
|
||||
float3 c_y = normalize(cross(c_x, c_z));
|
||||
float3x3 view = float3x3(c_x, c_y, c_z);
|
||||
float3 r = normalize(float3(uv, <CAM_FOCUS>));
|
||||
return half4(march(c_p, view * r));
|
||||
}
|
||||
13
konabot/plugins/marchtoy/texture.py
Normal file
13
konabot/plugins/marchtoy/texture.py
Normal file
@ -0,0 +1,13 @@
|
||||
COLORS = {
|
||||
"red": (1.0, 0.0, 0.0, 1.0),
|
||||
"green": (0.0, 1.0, 0.0, 1.0),
|
||||
"blue": (0.0, 0.0, 1.0, 1.0),
|
||||
"white": (1.0, 1.0, 1.0, 1.0),
|
||||
}
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Texture:
|
||||
color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)
|
||||
69
konabot/plugins/marchtoy/utilities.py
Normal file
69
konabot/plugins/marchtoy/utilities.py
Normal file
@ -0,0 +1,69 @@
|
||||
import numpy as np
|
||||
from konabot.plugins.marchtoy.texture import COLORS
|
||||
|
||||
|
||||
class SkslFormatter:
|
||||
@staticmethod
|
||||
def mat4(m: np.ndarray) -> str:
|
||||
if m.shape != (4, 4):
|
||||
m = np.identity(4)
|
||||
v_0 = ", ".join([str(x) for x in m[0]])
|
||||
v_1 = ", ".join([str(x) for x in m[1]])
|
||||
v_2 = ", ".join([str(x) for x in m[2]])
|
||||
v_3 = ", ".join([str(x) for x in m[3]])
|
||||
return f"float4x4(float4({v_0}), float4({v_1}), float4({v_2}), float4({v_3}))"
|
||||
|
||||
"""
|
||||
TODO: 除零出现 nan 情况的单独处理
|
||||
"""
|
||||
class ArgParser:
|
||||
@staticmethod
|
||||
def as_float(args: list[str], default: float = 0.0) -> float:
|
||||
try:
|
||||
if len(args) >= 1:
|
||||
x = float(args[0])
|
||||
return x
|
||||
except:
|
||||
raise Exception(f"cannot parse {args}")
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def as_vec3(
|
||||
args: list[str], default: np.ndarray = np.array((0.0, 0.0, 0.0))
|
||||
) -> np.ndarray:
|
||||
try:
|
||||
if len(args) == 1:
|
||||
x = float(args[0])
|
||||
return np.array((x, x, x))
|
||||
elif len(args) == 3:
|
||||
x = float(args[0])
|
||||
y = float(args[1])
|
||||
z = float(args[2])
|
||||
return np.array((x, y, z))
|
||||
except:
|
||||
raise Exception(f"cannot parse {args}")
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def as_vec4(
|
||||
args: list[str], default: np.ndarray = np.array((0.0, 0.0, 0.0, 0.0))
|
||||
) -> np.ndarray:
|
||||
try:
|
||||
if len(args) == 1:
|
||||
x = float(args[0])
|
||||
return np.array((x, x, x, x))
|
||||
elif len(args) == 4:
|
||||
x = float(args[0])
|
||||
y = float(args[1])
|
||||
z = float(args[2])
|
||||
w = float(args[3])
|
||||
return np.array((x, y, z, w))
|
||||
except:
|
||||
raise Exception(f"cannot parse {args}")
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def as_literal_color(args: list[str], default=np.array((1.0, 1.0, 1.0, 1.0))):
|
||||
if len(args) == 1 and args[0] in COLORS:
|
||||
return np.array(COLORS[args[0]])
|
||||
return default
|
||||
@ -34,6 +34,7 @@ from konabot.plugins.memepack.drawing.saying import (
|
||||
draw_mnk,
|
||||
draw_pt,
|
||||
draw_suan,
|
||||
draw_vr,
|
||||
)
|
||||
from konabot.plugins.memepack.drawing.watermark import draw_doubao_watermark
|
||||
|
||||
@ -334,3 +335,29 @@ async def _(img: DepPILImage):
|
||||
result_bytes = BytesIO()
|
||||
result.save(result_bytes, format="PNG")
|
||||
await doubao_cmd.send(await UniMessage().image(raw=result_bytes).export())
|
||||
|
||||
|
||||
vrsay = on_alconna(
|
||||
Alconna(
|
||||
"vr说",
|
||||
Args[
|
||||
"saying",
|
||||
MultiVar(str, "+"),
|
||||
Field(missing_tips=lambda: "你没有写vr说了什么"),
|
||||
],
|
||||
),
|
||||
use_cmd_start=True,
|
||||
use_cmd_sep=False,
|
||||
skip_for_unmatch=False,
|
||||
aliases=set(),
|
||||
)
|
||||
|
||||
|
||||
@vrsay.handle()
|
||||
async def _(saying: list[str]):
|
||||
img = await draw_vr("\n".join(saying))
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, format="PNG")
|
||||
|
||||
await vrsay.send(await UniMessage().image(raw=img_bytes).export())
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ dasuan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "dss.png").convert(
|
||||
suan_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "suanleba.png").convert("RGBA")
|
||||
cute_ten_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "tententen.png").convert("RGBA")
|
||||
kio_image = PIL.Image.open(ASSETS_PATH / "img" / "meme" / "kiosay.jpg").convert("RGBA")
|
||||
vr_image = PIL.Image.open(ASSETS_PATH / 'img' / 'meme' / 'vr.jpg').convert("RGBA")
|
||||
|
||||
|
||||
def _draw_geimao(saying: str):
|
||||
@ -123,3 +124,24 @@ def draw_kiosay(saying: str):
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
@make_async
|
||||
def draw_vr(saying: str):
|
||||
img = vr_image.copy()
|
||||
w, h = img.size
|
||||
hw = 300
|
||||
|
||||
img2 = PIL.Image.new("RGBA", (w, h + hw), 'white')
|
||||
img2.paste(img, (0, hw))
|
||||
|
||||
with imagetext_py.Writer(img2) as iw:
|
||||
iw.draw_text_wrapped(
|
||||
saying, w // 2, hw // 2 + 15, 0.5, 0.5, w, 64, LXGWWENKAI_REGULAR,
|
||||
imagetext_py.Paint.Color(imagetext_py.Color.from_hex("000000FF")),
|
||||
1.0,
|
||||
imagetext_py.TextAlign.Center,
|
||||
draw_emojis=True,
|
||||
)
|
||||
|
||||
return img2
|
||||
|
||||
|
||||
@ -3,15 +3,17 @@ from nonebot_plugin_alconna import Alconna, Args, Option, UniMessage, on_alconna
|
||||
from konabot.common.nb.exc import BotExceptionMessage
|
||||
from konabot.plugins.sksl.run_sksl import render_sksl_shader_to_gif
|
||||
|
||||
cmd_run_sksl = on_alconna(
|
||||
Alconna(
|
||||
"shadertool",
|
||||
Option("--width", Args["width_", int]),
|
||||
Option("--height", Args["height_", int]),
|
||||
Option("--duration", Args["duration_", float]),
|
||||
Option("--fps", Args["fps_", float]),
|
||||
Args["code", str],
|
||||
)
|
||||
)
|
||||
|
||||
cmd_run_sksl = on_alconna(Alconna(
|
||||
"shadertool",
|
||||
Option("--width", Args["width_", int]),
|
||||
Option("--height", Args["height_", int]),
|
||||
Option("--duration", Args["duration_", float]),
|
||||
Option("--fps", Args["fps_", float]),
|
||||
Args["code", str],
|
||||
))
|
||||
|
||||
@cmd_run_sksl.handle()
|
||||
async def _(
|
||||
@ -34,10 +36,12 @@ async def _(
|
||||
if width_ > 640 or height_ > 640:
|
||||
raise BotExceptionMessage("最大支持 640x640 啦!不要太大啦!")
|
||||
|
||||
code = code.strip("\"").strip("'")
|
||||
code = code.strip('"').strip("'")
|
||||
|
||||
try:
|
||||
res = await render_sksl_shader_to_gif(code, width_, height_, duration_, fps_)
|
||||
await cmd_run_sksl.send(await UniMessage().image(raw=res).export())
|
||||
except (ValueError, RuntimeError) as e:
|
||||
await cmd_run_sksl.send(await UniMessage().text(f"渲染时遇到了问题:\n\n{e}").export())
|
||||
await cmd_run_sksl.send(
|
||||
await UniMessage().text(f"渲染时遇到了问题:\n\n{e}").export()
|
||||
)
|
||||
@ -24,7 +24,6 @@ def _pack_uniforms(uniforms_dict, width, height, time_val):
|
||||
# 移除填充字节,使用紧凑布局
|
||||
return time_bytes + res_bytes
|
||||
|
||||
|
||||
def _render_sksl_shader_to_gif(
|
||||
sksl_code: str,
|
||||
width: int = 256,
|
||||
@ -152,4 +151,4 @@ async def render_sksl_shader_to_gif(
|
||||
height,
|
||||
duration,
|
||||
fps,
|
||||
)
|
||||
)
|
||||
2148
poetry.lock
generated
2148
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -19,7 +19,6 @@ dependencies = [
|
||||
"imagetext-py (>=2.2.0,<3.0.0)",
|
||||
"opencv-python-headless (>=4.12.0.88,<5.0.0.0)",
|
||||
"returns (>=0.26.0,<0.27.0)",
|
||||
"skia-python (>=138.0,<139.0)",
|
||||
"nonebot-plugin-analysis-bilibili (>=2.8.1,<3.0.0)",
|
||||
"qrcode (>=8.2,<9.0)",
|
||||
"nanoid (>=2.0.0,<3.0.0)",
|
||||
@ -39,6 +38,7 @@ dependencies = [
|
||||
"pytest-cov (>=7.0.0,<8.0.0)",
|
||||
"aiosignal (>=1.4.0,<2.0.0)",
|
||||
"pytest-mock (>=3.15.1,<4.0.0)",
|
||||
"skia-python (>=144.0.post2,<145.0)",
|
||||
]
|
||||
|
||||
[tool.poetry]
|
||||
|
||||
@ -86,3 +86,67 @@ def test_prase_input_args_parses_resize_second_argument_as_float():
|
||||
assert len(filters) == 1
|
||||
assert filters[0].name == "缩放"
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user