调整 Gif 图渲染策略

This commit is contained in:
2025-11-15 20:16:42 +08:00
parent eff25435e3
commit 2f22f11d57
6 changed files with 183 additions and 147 deletions

View File

@ -1,4 +1,5 @@
from io import BytesIO from io import BytesIO
from pathlib import Path
from typing import Annotated from typing import Annotated
import httpx import httpx
@ -19,15 +20,21 @@ from PIL import UnidentifiedImageError
from pydantic import BaseModel from pydantic import BaseModel
from returns.result import Failure, Result, Success from returns.result import Failure, Result, Success
from konabot.common.path import ASSETS_PATH
discordConfig = nonebot.get_plugin_config(DiscordConfig) discordConfig = nonebot.get_plugin_config(DiscordConfig)
class ExtractImageConfig(BaseModel): class ExtractImageConfig(BaseModel):
module_extract_image_no_download: bool = False module_extract_image_no_download: bool = False
"要不要算了,不下载了,直接爆炸算了,适用于一些比较奇怪的网络环境,无法从协议端下载文件" """
要不要算了,不下载了,直接爆炸算了,
适用于一些比较奇怪的网络环境,无法从协议端下载文件
"""
module_extract_image_target: str = './assets/img/other/boom.jpg'
"""
使用哪个图片呢
"""
module_config = nonebot.get_plugin_config(ExtractImageConfig) module_config = nonebot.get_plugin_config(ExtractImageConfig)
@ -37,7 +44,7 @@ async def download_image_bytes(url: str, proxy: str | None = None) -> Result[byt
# if "/matcha/cache/" in url: # if "/matcha/cache/" in url:
# url = url.replace('127.0.0.1', '10.126.126.101') # url = url.replace('127.0.0.1', '10.126.126.101')
if module_config.module_extract_image_no_download: if module_config.module_extract_image_no_download:
return Success((ASSETS_PATH / "img" / "other" / "boom.jpg").read_bytes()) return Success(Path(module_config.module_extract_image_target).read_bytes())
logger.debug(f"开始从 {url} 下载图片") logger.debug(f"开始从 {url} 下载图片")
async with httpx.AsyncClient(proxy=proxy) as c: async with httpx.AsyncClient(proxy=proxy) as c:
try: try:
@ -70,15 +77,22 @@ def bytes_to_pil(raw_data: bytes | BytesIO) -> Result[PIL.Image.Image, str]:
return Failure("图像无法读取可能是网络存在问题orz") return Failure("图像无法读取可能是网络存在问题orz")
async def unimsg_img_to_pil(image: Image) -> Result[PIL.Image.Image, str]: async def unimsg_img_to_bytes(image: Image) -> Result[bytes, str]:
if image.url is not None: if image.url is not None:
raw_result = await download_image_bytes(image.url) raw_result = await download_image_bytes(image.url)
elif image.raw is not None: elif image.raw is not None:
raw_result = Success(image.raw) if isinstance(image.raw, bytes):
raw_result = Success(image.raw)
else:
raw_result = Success(image.raw.getvalue())
else: else:
return Failure("由于一些内部问题下载图片失败了orz") return Failure("由于一些内部问题下载图片失败了orz")
return raw_result.bind(bytes_to_pil) return raw_result
async def unimsg_img_to_pil(image: Image) -> Result[PIL.Image.Image, str]:
return (await unimsg_img_to_bytes(image)).bind(bytes_to_pil)
async def extract_image_from_qq_message( async def extract_image_from_qq_message(
@ -86,7 +100,7 @@ async def extract_image_from_qq_message(
evt: OnebotV11MessageEvent, evt: OnebotV11MessageEvent,
bot: OnebotV11Bot, bot: OnebotV11Bot,
allow_reply: bool = True, allow_reply: bool = True,
) -> Result[PIL.Image.Image, str]: ) -> Result[bytes, str]:
if allow_reply and (reply := evt.reply) is not None: if allow_reply and (reply := evt.reply) is not None:
return await extract_image_from_qq_message( return await extract_image_from_qq_message(
reply.message, reply.message,
@ -118,18 +132,17 @@ async def extract_image_from_qq_message(
url = seg.data.get("url") url = seg.data.get("url")
if url is None: if url is None:
return Failure("无法下载图片,可能有一些网络问题") return Failure("无法下载图片,可能有一些网络问题")
data = await download_image_bytes(url) return await download_image_bytes(url)
return data.bind(bytes_to_pil)
return Failure("请在消息中包含图片,或者引用一个含有图片的消息") return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
async def extract_image_from_message( async def extract_image_data_from_message(
msg: Message, msg: Message,
evt: Event, evt: Event,
bot: Bot, bot: Bot,
allow_reply: bool = True, allow_reply: bool = True,
) -> Result[PIL.Image.Image, str]: ) -> Result[bytes, str]:
if ( if (
isinstance(bot, OnebotV11Bot) isinstance(bot, OnebotV11Bot)
and isinstance(msg, OnebotV11Message) and isinstance(msg, OnebotV11Message)
@ -145,18 +158,18 @@ async def extract_image_from_message(
if "image/" not in a.content_type: if "image/" not in a.content_type:
continue continue
url = a.proxy_url url = a.proxy_url
return (await download_image_bytes(url, discordConfig.discord_proxy)).bind(bytes_to_pil) return await download_image_bytes(url, discordConfig.discord_proxy)
for seg in UniMessage.of(msg, bot): for seg in UniMessage.of(msg, bot):
logger.info(seg) logger.info(seg)
if isinstance(seg, Image): if isinstance(seg, Image):
return await unimsg_img_to_pil(seg) return await unimsg_img_to_bytes(seg)
elif isinstance(seg, Reply) and allow_reply: elif isinstance(seg, Reply) and allow_reply:
msg2 = seg.msg msg2 = seg.msg
logger.debug(f"深入搜索引用的消息:{msg2}") logger.debug(f"深入搜索引用的消息:{msg2}")
if msg2 is None or isinstance(msg2, str): if msg2 is None or isinstance(msg2, str):
continue continue
return await extract_image_from_message(msg2, evt, bot, False) return await extract_image_data_from_message(msg2, evt, bot, False)
elif isinstance(seg, RefNode) and allow_reply: elif isinstance(seg, RefNode) and allow_reply:
if isinstance(bot, DiscordBot): if isinstance(bot, DiscordBot):
return Failure("暂时不支持在 Discord 中通过引用的方式获取图片") return Failure("暂时不支持在 Discord 中通过引用的方式获取图片")
@ -165,12 +178,12 @@ async def extract_image_from_message(
return Failure("请在消息中包含图片,或者引用一个含有图片的消息") return Failure("请在消息中包含图片,或者引用一个含有图片的消息")
async def _ext_img( async def _ext_img_data(
evt: Event, evt: Event,
bot: Bot, bot: Bot,
matcher: Matcher, matcher: Matcher,
) -> PIL.Image.Image | None: ) -> bytes | None:
match await extract_image_from_message(evt.get_message(), evt, bot): match await extract_image_data_from_message(evt.get_message(), evt, bot):
case Success(img): case Success(img):
return img return img
case Failure(err): case Failure(err):
@ -180,4 +193,20 @@ async def _ext_img(
assert False assert False
PIL_Image = Annotated[PIL.Image.Image, nonebot.params.Depends(_ext_img)] async def _ext_img(
evt: Event,
bot: Bot,
matcher: Matcher,
) -> PIL.Image.Image | None:
r = await _ext_img_data(evt, bot, matcher)
if r:
match bytes_to_pil(r):
case Success(img):
return img
case Failure(msg):
await matcher.send(await UniMessage.text(msg).export())
return None
DepImageBytes = Annotated[bytes, nonebot.params.Depends(_ext_img_data)]
DepPILImage = Annotated[PIL.Image.Image, nonebot.params.Depends(_ext_img)]

View File

@ -1,24 +1,32 @@
import re import re
from io import BytesIO from io import BytesIO
from typing import Any
import PIL
import PIL.Image import PIL.Image
import cv2
import imageio.v3 as iio
from nonebot import on_message from nonebot import on_message
from nonebot.adapters import Bot from nonebot.adapters import Bot
from nonebot_plugin_alconna import Alconna, Args, Image, Option, UniMessage, on_alconna 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.exc import BotExceptionMessage
from konabot.common.nb.extract_image import PIL_Image from konabot.common.nb.extract_image import DepImageBytes, DepPILImage
from konabot.common.nb.match_keyword import match_keyword from konabot.common.nb.match_keyword import match_keyword
from konabot.common.nb.reply_image import reply_image from konabot.common.nb.reply_image import reply_image
# 保持不变
cmd_black_white = on_message(rule=match_keyword("黑白")) cmd_black_white = on_message(rule=match_keyword("黑白"))
@cmd_black_white.handle() @cmd_black_white.handle()
async def _(img: PIL_Image, bot: Bot): async def _(img: DepPILImage, bot: Bot):
# 保持不变
await reply_image(cmd_black_white, bot, img.convert("LA")) await reply_image(cmd_black_white, bot, img.convert("LA"))
# 保持不变
def parse_timestamp(tx: str) -> float | None: def parse_timestamp(tx: str) -> float | None:
res = 0.0 res = 0.0
for component in tx.split(":"): for component in tx.split(":"):
@ -29,6 +37,7 @@ def parse_timestamp(tx: str) -> float | None:
return res return res
# 保持不变
cmd_giftool = on_alconna( cmd_giftool = on_alconna(
Alconna( Alconna(
"giftool", "giftool",
@ -44,7 +53,7 @@ cmd_giftool = on_alconna(
@cmd_giftool.handle() @cmd_giftool.handle()
async def _( async def _(
image: PIL_Image, image: DepImageBytes,
start_point: str | None = None, start_point: str | None = None,
frame_count: int | None = None, frame_count: int | None = None,
length: str | None = None, length: str | None = None,
@ -79,28 +88,24 @@ async def _(
is_rev = speed_factor < 0 is_rev = speed_factor < 0
speed_factor = abs(speed_factor) speed_factor = abs(speed_factor)
if not getattr(image, "is_animated", False):
raise BotExceptionMessage("错误输入的不是动图GIF")
##
# 从这里开始,采样整个 GIF 图
frames: list[PIL.Image.Image] = []
durations: list[float] = []
try: try:
for i in range(getattr(image, "n_frames")): reader = iio.imread(BytesIO(image), extension=".gif", index=None)
image.seek(i) np_frames = list(reader)
frames.append(image.copy())
duration = image.info.get("duration", 100) / 1000 _pil = PIL.Image.open(BytesIO(image))
durations.append(duration) durations: list[float] = []
except EOFError: while True:
pass try:
if not frames: duration = _pil.info.get('duration', 20)
durations.append(max(duration, 20) / 1000)
_pil.seek(_pil.tell() + 1)
except EOFError:
break
except Exception:
raise BotExceptionMessage("错误:读取 GIF 帧失败") raise BotExceptionMessage("错误:读取 GIF 帧失败")
# 采样结束
## ##
# 根据开始、结束时间或者帧数量来裁取 GIF 图 # 根据开始、结束时间或者帧数量来裁取 GIF 图
begin_time = ss or 0 begin_time = ss or 0
end_time = sum(durations) end_time = sum(durations)
end_time = min(begin_time + (t or end_time), to or end_time, end_time) end_time = min(begin_time + (t or end_time), to or end_time, end_time)
@ -108,94 +113,95 @@ async def _(
accumulated = 0.0 accumulated = 0.0
status = 0 status = 0
sel_frames: list[PIL.Image.Image] = [] sel_np_frames: list[numpy.ndarray[Any, Any]] = []
sel_durations: list[float] = [] sel_durations: list[float] = []
for i in range(len(frames)): for i in range(len(np_frames)):
frame = frames[i] frame = np_frames[i]
duration = durations[i] duration = durations[i]
if status == 0: if status == 0:
if accumulated + duration > begin_time: if accumulated + duration > begin_time:
status = 1 status = 1
sel_frames.append(frame) sel_np_frames.append(frame)
sel_durations.append(accumulated + duration - begin_time) sel_durations.append(accumulated + duration - begin_time)
elif accumulated + duration == begin_time:
status = 1
elif status == 1: elif status == 1:
if accumulated + duration > end_time: if accumulated + duration >= end_time:
sel_frames.append(frame) included_duration = end_time - accumulated
sel_durations.append(end_time - accumulated) if included_duration > 0:
sel_np_frames.append(frame)
sel_durations.append(included_duration)
break break
sel_frames.append(frame) sel_np_frames.append(frame)
sel_durations.append(duration) sel_durations.append(duration)
accumulated += duration accumulated += duration
## if not sel_np_frames:
# 加速! raise BotExceptionMessage("错误:裁取 GIF 帧失败(可能时间设置错误)")
sel_durations = [dur / speed_factor * 1000 for dur in durations]
rframes = [] rdur_ms_unprocessed = [dur / speed_factor * 1000 for dur in sel_durations]
rdur = [] rframes: list[numpy.ndarray] = []
rdur_ms: list[int] = []
acc_mod_20 = 0 acc_mod_20 = 0
for i in range(len(sel_frames)): for i in range(len(sel_np_frames)):
fr = sel_frames[i] fr = sel_np_frames[i]
du = round(sel_durations[i]) du = rdur_ms_unprocessed[i]
if du >= 20: if du >= 20:
rframes.append(fr) rframes.append(fr)
rdur.append(int(du)) rdur_ms.append(int(round(du)))
acc_mod_20 = 0 acc_mod_20 = 0
else: else:
if acc_mod_20 == 0: if acc_mod_20 == 0:
rframes.append(fr) rframes.append(fr)
rdur.append(20) rdur_ms.append(20)
acc_mod_20 += du acc_mod_20 += du
else: else:
acc_mod_20 += du acc_mod_20 += du
if acc_mod_20 >= 20: if acc_mod_20 >= 20:
acc_mod_20 = 0 acc_mod_20 = 0
if len(rframes) == 1 and len(sel_frames) > 1: if len(rframes) == 1 and len(sel_np_frames) > 1:
rframes.append(sel_frames[max(2, len(sel_frames) // 2)]) middle_index = max(2, len(sel_np_frames) // 2)
rdur.append(20) rframes.append(sel_np_frames[middle_index])
rdur_ms.append(20)
##
# 收尾:看看透明度这块
transparency_flag = False
for f in rframes:
if f.mode == "RGBA":
if any(pix < 255 for pix in f.getchannel("A").getdata()):
transparency_flag = True
break
elif f.mode == "P" and "transparency" in f.info:
transparency_flag = True
break
tf = {}
if transparency_flag:
tf["transparency"] = 0
if is_rev: if is_rev:
rframes = rframes[::-1] rframes = rframes[::-1]
rdur = rdur[::-1] rdur_ms = rdur_ms[::-1]
output_img = BytesIO() output_img = BytesIO()
if rframes: if rframes:
rframes[0].save( do_transparent = any((f.shape[2] == 4 for f in rframes))
output_img, if do_transparent:
format="GIF", rframes = [(
save_all=True, f
append_images=rframes[1:], if f.shape[2] == 4
duration=rdur, else cv2.cvtColor(f, cv2.COLOR_RGB2RGBA)
loop=0, ) for f in rframes]
optimize=False, kwargs = { "transparency": 0, "disposal": 2, "mode": "RGBA" }
disposal=2, else:
**tf, kwargs = {}
) try:
iio.imwrite(
output_img,
rframes,
extension=".gif",
duration=rdur_ms,
loop=0,
optimize=True,
plugin="pillow",
**kwargs,
)
except Exception as e:
raise BotExceptionMessage(f"错误:写入 GIF 失败: {e}")
else: else:
raise BotExceptionMessage("错误:没有可输出的帧") raise BotExceptionMessage("错误:没有可输出的帧")
output_img.seek(0) output_img.seek(0)
await cmd_giftool.send(await UniMessage().image(raw=output_img).export()) await cmd_giftool.send(await UniMessage().image(raw=output_img).export())

View File

@ -17,7 +17,7 @@ from nonebot_plugin_alconna import (
) )
from playwright.async_api import ConsoleMessage, Page from playwright.async_api import ConsoleMessage, Page
from konabot.common.nb.extract_image import PIL_Image, extract_image_from_message from konabot.common.nb.extract_image import DepPILImage
from konabot.common.web_render import konaweb from konabot.common.web_render import konaweb
from konabot.common.web_render.core import WebRenderer from konabot.common.web_render.core import WebRenderer
from konabot.common.web_render.host_images import host_tempdir from konabot.common.web_render.host_images import host_tempdir
@ -36,9 +36,6 @@ from konabot.plugins.memepack.drawing.saying import (
) )
from konabot.plugins.memepack.drawing.watermark import draw_doubao_watermark from konabot.plugins.memepack.drawing.watermark import draw_doubao_watermark
from nonebot.adapters import Bot, Event
from returns.result import Success, Failure
geimao = on_alconna( geimao = on_alconna(
Alconna( Alconna(
@ -194,7 +191,7 @@ cao_display_cmd = on_message()
@cao_display_cmd.handle() @cao_display_cmd.handle()
async def _(msg: UniMsg, evt: Event, bot: Bot): async def _(msg: UniMsg, img: DepPILImage):
flag = False flag = False
for text in cast(Iterable[Text], msg.get(Text)): for text in cast(Iterable[Text], msg.get(Text)):
if text.text.strip() == "小槽展示": if text.text.strip() == "小槽展示":
@ -205,20 +202,10 @@ async def _(msg: UniMsg, evt: Event, bot: Bot):
return return
if not flag: if not flag:
return return
match await extract_image_from_message(evt.get_message(), evt, bot): img_handled = await draw_cao_display(img)
case Success(img): img_bytes = BytesIO()
img_handled = await draw_cao_display(img) img_handled.save(img_bytes, format="PNG")
img_bytes = BytesIO() await cao_display_cmd.send(await UniMessage().image(raw=img_bytes).export())
img_handled.save(img_bytes, format="PNG")
await cao_display_cmd.send(await UniMessage().image(raw=img_bytes).export())
case Failure(err):
await cao_display_cmd.send(
await UniMessage()
.at(user_id=evt.get_user_id())
.text(" ")
.text(err)
.export()
)
snaur_display_cmd = on_alconna( snaur_display_cmd = on_alconna(
@ -235,7 +222,7 @@ snaur_display_cmd = on_alconna(
@snaur_display_cmd.handle() @snaur_display_cmd.handle()
async def _( async def _(
img: PIL_Image, img: DepPILImage,
whiteness: float = 0.0, whiteness: float = 0.0,
black_level: float = 0.2, black_level: float = 0.2,
opacity: float = 0.8, opacity: float = 0.8,
@ -254,7 +241,7 @@ async def _(
anan_display_cmd = on_message() anan_display_cmd = on_message()
@anan_display_cmd.handle() @anan_display_cmd.handle()
async def _(msg: UniMsg, evt: Event, bot: Bot): async def _(msg: UniMsg, img: DepPILImage):
flag = False flag = False
for text in cast(Iterable[Text], msg.get(Text)): for text in cast(Iterable[Text], msg.get(Text)):
stripped = text.text.strip() stripped = text.text.strip()
@ -267,20 +254,10 @@ async def _(msg: UniMsg, evt: Event, bot: Bot):
if not flag: if not flag:
return return
match await extract_image_from_message(evt.get_message(), evt, bot): img_handled = await draw_anan_display(img)
case Success(img): img_bytes = BytesIO()
img_handled = await draw_anan_display(img) img_handled.save(img_bytes, format="PNG")
img_bytes = BytesIO() await anan_display_cmd.send(await UniMessage().image(raw=img_bytes).export())
img_handled.save(img_bytes, format="PNG")
await anan_display_cmd.send(await UniMessage().image(raw=img_bytes).export())
case Failure(err):
await anan_display_cmd.send(
await UniMessage()
.at(user_id=evt.get_user_id())
.text(" ")
.text(err)
.export()
)
kiosay = on_alconna( kiosay = on_alconna(
@ -316,7 +293,7 @@ quote_cmd = on_alconna(Alconna(
), aliases={"quote"}) ), aliases={"quote"})
@quote_cmd.handle() @quote_cmd.handle()
async def _(quote: str, author: str, img: PIL_Image): async def _(quote: str, author: str, img: DepPILImage):
async with host_tempdir() as tempdir: async with host_tempdir() as tempdir:
img_path = tempdir.path / "image.png" img_path = tempdir.path / "image.png"
img_url = tempdir.url_of(img_path) img_url = tempdir.url_of(img_path)
@ -351,7 +328,7 @@ doubao_cmd = on_alconna(Alconna(
@doubao_cmd.handle() @doubao_cmd.handle()
async def _(img: PIL_Image): async def _(img: DepPILImage):
result = await draw_doubao_watermark(img) result = await draw_doubao_watermark(img)
result_bytes = BytesIO() result_bytes = BytesIO()
result.save(result_bytes, format="PNG") result.save(result_bytes, format="PNG")

View File

@ -1,14 +1,11 @@
from io import BytesIO from io import BytesIO
from loguru import logger from loguru import logger
from nonebot.adapters import Bot as BaseBot
from nonebot.adapters import Event as BaseEvent
from nonebot.plugin import PluginMetadata from nonebot.plugin import PluginMetadata
from nonebot_plugin_alconna import Alconna, Args, Field, UniMessage, on_alconna from nonebot_plugin_alconna import Alconna, Args, Field, UniMessage, on_alconna
from PIL import Image from PIL import Image
from returns.result import Failure, Success
from konabot.common.nb.extract_image import extract_image_from_message from konabot.common.nb.extract_image import DepPILImage
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="ytpgif", name="ytpgif",
@ -63,7 +60,7 @@ def resize_frame(frame: Image.Image) -> Image.Image:
@ytpgif_cmd.handle() @ytpgif_cmd.handle()
async def handle_ytpgif(event: BaseEvent, bot: BaseBot, speed: float = 1.0): async def handle_ytpgif(src_img: DepPILImage, speed: float = 1.0):
# === 校验 speed 范围 === # === 校验 speed 范围 ===
if not (MIN_SPEED <= speed <= MAX_SPEED): if not (MIN_SPEED <= speed <= MAX_SPEED):
await ytpgif_cmd.send( await ytpgif_cmd.send(
@ -71,19 +68,6 @@ async def handle_ytpgif(event: BaseEvent, bot: BaseBot, speed: float = 1.0):
) )
return return
match await extract_image_from_message(event.get_message(), event, bot):
case Success(img):
src_img = img
case Failure(msg):
await ytpgif_cmd.send(
await UniMessage.text(msg).export()
)
return
case _:
return
try: try:
try: try:
n_frames = getattr(src_img, "n_frames", 1) n_frames = getattr(src_img, "n_frames", 1)
@ -217,4 +201,4 @@ async def handle_ytpgif(event: BaseEvent, bot: BaseBot, speed: float = 1.0):
print(f"[YTPGIF] 处理失败: {e}") print(f"[YTPGIF] 处理失败: {e}")
await ytpgif_cmd.send( await ytpgif_cmd.send(
await UniMessage.text("❌ 处理失败,可能是图片格式不支持、文件损坏或过大。").export() await UniMessage.text("❌ 处理失败,可能是图片格式不支持、文件损坏或过大。").export()
) )

41
poetry.lock generated
View File

@ -1469,6 +1469,45 @@ type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple" url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors" reference = "mirrors"
[[package]]
name = "imageio"
version = "2.37.2"
description = "Read and write images and video across all major formats. Supports scientific and volumetric data."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b"},
{file = "imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a"},
]
[package.dependencies]
numpy = "*"
pillow = ">=8.3.2"
[package.extras]
all-plugins = ["astropy", "av", "fsspec[http]", "imageio-ffmpeg", "numpy (>2)", "pillow-heif", "psutil", "rawpy", "tifffile"]
all-plugins-pypy = ["fsspec[http]", "imageio-ffmpeg", "pillow-heif", "psutil", "tifffile"]
dev = ["black", "flake8", "fsspec[github]", "pytest", "pytest-cov"]
docs = ["numpydoc", "pydata-sphinx-theme", "sphinx (<6)"]
ffmpeg = ["imageio-ffmpeg", "psutil"]
fits = ["astropy"]
freeimage = ["fsspec[http]"]
full = ["astropy", "av", "black", "flake8", "fsspec[github,http]", "imageio-ffmpeg", "numpy (>2)", "numpydoc", "pillow-heif", "psutil", "pydata-sphinx-theme", "pytest", "pytest-cov", "rawpy", "sphinx (<6)", "tifffile"]
gdal = ["gdal"]
itk = ["itk"]
linting = ["black", "flake8"]
pillow-heif = ["pillow-heif"]
pyav = ["av"]
rawpy = ["numpy (>2)", "rawpy"]
test = ["fsspec[github]", "pytest", "pytest-cov"]
tifffile = ["tifffile"]
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]] [[package]]
name = "imagetext-py" name = "imagetext-py"
version = "2.2.0" version = "2.2.0"
@ -4489,4 +4528,4 @@ reference = "mirrors"
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.12,<4.0" python-versions = ">=3.12,<4.0"
content-hash = "af9fc535dd8c4e33c2cac481839ba07bcb8014b9a9cbd6bd1b6f5942640ecefe" content-hash = "478bd59d60d3b73397241c6ed552434486bd26d56cc3805ef34d1cfa1be7006e"

View File

@ -26,6 +26,7 @@ dependencies = [
"opencc (>=1.1.9,<2.0.0)", "opencc (>=1.1.9,<2.0.0)",
"playwright (>=1.55.0,<2.0.0)", "playwright (>=1.55.0,<2.0.0)",
"openai (>=2.7.1,<3.0.0)", "openai (>=2.7.1,<3.0.0)",
"imageio (>=2.37.2,<3.0.0)",
] ]
[tool.poetry] [tool.poetry]