From 71a0574e8458a40220643c43dce13b95d18e62c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wuyi=E6=97=A0=E7=96=91?= <444835641@qq.com> Date: Tue, 9 May 2023 23:54:44 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=E6=96=B0=E5=A2=9E`utils.image`?= =?UTF-8?q?=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gsuid_core/utils/image/convert.py | 109 ++++++++ gsuid_core/utils/image/image_tools.py | 312 ++++++++++++++++++++++ gsuid_core/utils/image/texture2d/mask.png | Bin 0 -> 5831 bytes gsuid_core/utils/image/texture2d/ring.png | Bin 0 -> 7988 bytes 4 files changed, 421 insertions(+) create mode 100644 gsuid_core/utils/image/convert.py create mode 100644 gsuid_core/utils/image/image_tools.py create mode 100644 gsuid_core/utils/image/texture2d/mask.png create mode 100644 gsuid_core/utils/image/texture2d/ring.png diff --git a/gsuid_core/utils/image/convert.py b/gsuid_core/utils/image/convert.py new file mode 100644 index 0000000..c3e3c31 --- /dev/null +++ b/gsuid_core/utils/image/convert.py @@ -0,0 +1,109 @@ +from io import BytesIO +from pathlib import Path +from base64 import b64encode +from typing import Union, overload + +import aiofiles +from PIL import Image, ImageFont + + +@overload +async def convert_img(img: Image.Image, is_base64: bool = False) -> bytes: + ... + + +@overload +async def convert_img(img: Image.Image, is_base64: bool = True) -> str: + ... + + +@overload +async def convert_img(img: bytes, is_base64: bool = False) -> str: + ... + + +@overload +async def convert_img(img: Path, is_base64: bool = False) -> str: + ... + + +async def convert_img( + img: Union[Image.Image, str, Path, bytes], is_base64: bool = False +): + """ + :说明: + 将PIL.Image对象转换为bytes或者base64格式。 + :参数: + * img (Image): 图片。 + * is_base64 (bool): 是否转换为base64格式, 不填默认转为bytes。 + :返回: + * res: bytes对象或base64编码图片。 + """ + if isinstance(img, Image.Image): + img = img.convert('RGB') + result_buffer = BytesIO() + img.save(result_buffer, format='PNG', quality=80, subsampling=0) + res = result_buffer.getvalue() + if is_base64: + res = 'base64://' + b64encode(res).decode() + return res + elif isinstance(img, bytes): + pass + else: + async with aiofiles.open(img, 'rb') as fp: + img = await fp.read() + return f'base64://{b64encode(img).decode()}' + + +async def str_lenth(r: str, size: int, limit: int = 540) -> str: + result = '' + temp = 0 + for i in r: + if i == '\n': + temp = 0 + result += i + continue + + if temp >= limit: + result += '\n' + i + temp = 0 + else: + result += i + + if i.isdigit(): + temp += round(size / 10 * 6) + elif i == '/': + temp += round(size / 10 * 2.2) + elif i == '.': + temp += round(size / 10 * 3) + elif i == '%': + temp += round(size / 10 * 9.4) + else: + temp += size + return result + + +def get_str_size( + r: str, font: ImageFont.FreeTypeFont, limit: int = 540 +) -> str: + result = '' + line = '' + for i in r: + if i == '\n': + result += f'{line}\n' + line = '' + continue + + line += i + size, _ = font.getsize(line) + if size >= limit: + result += f'{line}\n' + line = '' + else: + result += line + return result + + +def get_height(content: str, size: int) -> int: + line_count = content.count('\n') + return (line_count + 1) * size diff --git a/gsuid_core/utils/image/image_tools.py b/gsuid_core/utils/image/image_tools.py new file mode 100644 index 0000000..b6c469e --- /dev/null +++ b/gsuid_core/utils/image/image_tools.py @@ -0,0 +1,312 @@ +import math +import random +from io import BytesIO +from pathlib import Path +from typing import Tuple, Union, Optional + +import httpx +from httpx import get +from PIL import Image, ImageDraw, ImageFont + +TEXT_PATH = Path(__file__).parent / 'texture2d' + + +async def get_pic(url, size: Optional[Tuple[int, int]] = None) -> Image.Image: + """ + 从网络获取图片, 格式化为RGBA格式的指定尺寸 + """ + async with httpx.AsyncClient(timeout=None) as client: + resp = await client.get(url=url) + if resp.status_code != 200: + if size is None: + size = (960, 600) + return Image.new('RGBA', size) + pic = Image.open(BytesIO(resp.read())) + pic = pic.convert("RGBA") + if size is not None: + pic = pic.resize(size, Image.LANCZOS) + return pic + + +def draw_text_by_line( + img: Image.Image, + pos: Tuple[int, int], + text: str, + font: ImageFont.FreeTypeFont, + fill: Union[Tuple[int, int, int, int], str], + max_length: float, + center=False, + line_space: Optional[float] = None, +): + """ + 在图片上写长段文字, 自动换行 + max_length单行最大长度, 单位像素 + line_space 行间距, 单位像素, 默认是字体高度的0.3倍 + """ + x, y = pos + _, h = font.getsize('X') + if line_space is None: + y_add = math.ceil(1.3 * h) + else: + y_add = math.ceil(h + line_space) + draw = ImageDraw.Draw(img) + row = "" # 存储本行文字 + length = 0 # 记录本行长度 + for character in text: + w, h = font.getsize(character) # 获取当前字符的宽度 + if length + w * 2 <= max_length: + row += character + length += w + else: + row += character + if center: + font_size = font.getsize(row) + x = math.ceil((img.size[0] - font_size[0]) / 2) + draw.text((x, y), row, font=font, fill=fill) + row = "" + length = 0 + y += y_add + if row != "": + if center: + font_size = font.getsize(row) + x = math.ceil((img.size[0] - font_size[0]) / 2) + draw.text((x, y), row, font=font, fill=fill) + + +def easy_paste( + im: Image.Image, im_paste: Image.Image, pos=(0, 0), direction="lt" +): + """ + inplace method + 快速粘贴, 自动获取被粘贴图像的坐标。 + pos应当是粘贴点坐标,direction指定粘贴点方位,例如lt为左上 + """ + x, y = pos + size_x, size_y = im_paste.size + if "d" in direction: + y = y - size_y + if "r" in direction: + x = x - size_x + if "c" in direction: + x = x - int(0.5 * size_x) + y = y - int(0.5 * size_y) + im.paste(im_paste, (x, y, x + size_x, y + size_y), im_paste) + + +def easy_alpha_composite( + im: Image.Image, im_paste: Image.Image, pos=(0, 0), direction="lt" +) -> Image.Image: + ''' + 透明图像快速粘贴 + ''' + base = Image.new("RGBA", im.size) + easy_paste(base, im_paste, pos, direction) + base = Image.alpha_composite(im, base) + return base + + +async def get_qq_avatar( + qid: Optional[Union[int, str]] = None, avatar_url: Optional[str] = None +) -> Image.Image: + if qid: + avatar_url = f'http://q1.qlogo.cn/g?b=qq&nk={qid}&s=640' + elif avatar_url is None: + avatar_url = 'https://q1.qlogo.cn/g?b=qq&nk=3399214199&s=640' + char_pic = Image.open(BytesIO(get(avatar_url).content)).convert('RGBA') + return char_pic + + +async def draw_pic_with_ring( + pic: Image.Image, + size: int, + bg_color: Optional[Tuple[int, int, int]] = None, +): + ''' + :说明: + 绘制一张带白色圆环的1:1比例图片。 + + :参数: + * pic: `Image.Image`: 要修改的图片。 + * size: `int`: 最后传出图片的大小(1:1)。 + * bg_color: `Optional[Tuple[int, int, int]]`: 是否指定圆环内背景颜色。 + + :返回: + * img: `Image.Image`: 图片对象 + ''' + ring_pic = Image.open(TEXT_PATH / 'ring.png') + mask_pic = Image.open(TEXT_PATH / 'mask.png') + img = Image.new('RGBA', (size, size)) + mask = mask_pic.resize((size, size)) + ring = ring_pic.resize((size, size)) + resize_pic = crop_center_img(pic, size, size) + if bg_color: + img_color = Image.new('RGBA', (size, size), bg_color) + img_color.paste(resize_pic, (0, 0), resize_pic) + img.paste(img_color, (0, 0), mask) + else: + img.paste(resize_pic, (0, 0), mask) + img.paste(ring, (0, 0), ring) + return img + + +def crop_center_img( + img: Image.Image, based_w: int, based_h: int +) -> Image.Image: + # 确定图片的长宽 + based_scale = '%.3f' % (based_w / based_h) + w, h = img.size + scale_f = '%.3f' % (w / h) + new_w = math.ceil(based_h * float(scale_f)) + new_h = math.ceil(based_w / float(scale_f)) + if scale_f > based_scale: + resize_img = img.resize((new_w, based_h), Image.ANTIALIAS) + x1 = int(new_w / 2 - based_w / 2) + y1 = 0 + x2 = int(new_w / 2 + based_w / 2) + y2 = based_h + else: + resize_img = img.resize((based_w, new_h), Image.ANTIALIAS) + x1 = 0 + y1 = int(new_h / 2 - based_h / 2) + x2 = based_w + y2 = int(new_h / 2 + based_h / 2) + crop_img = resize_img.crop((x1, y1, x2, y2)) + return crop_img + + +class CustomizeImage: + def __init__(self, bg_path: Path) -> None: + self.bg_path = bg_path + + def get_image( + self, image: Union[str, Image.Image], based_w: int, based_h: int + ) -> Image.Image: + # 获取背景图片 + if isinstance(image, Image.Image): + edit_bg = image + elif image: + edit_bg = Image.open(BytesIO(get(image).content)).convert('RGBA') + else: + path = random.choice(list(self.bg_path.iterdir())) + edit_bg = Image.open(path).convert('RGBA') + + # 确定图片的长宽 + bg_img = crop_center_img(edit_bg, based_w, based_h) + return bg_img + + @staticmethod + def get_dominant_color(pil_img: Image.Image) -> Tuple[int, int, int]: + img = pil_img.copy() + img = img.convert("RGBA") + img = img.resize((1, 1), resample=0) + dominant_color = img.getpixel((0, 0)) + return dominant_color + + @staticmethod + def get_bg_color( + edit_bg: Image.Image, is_light: Optional[bool] = False + ) -> Tuple[int, int, int]: + # 获取背景主色 + color = 8 + q = edit_bg.quantize(colors=color, method=2) + bg_color = (0, 0, 0) + if is_light: + based_light = 195 + else: + based_light = 120 + temp = 9999 + for i in range(color): + bg = tuple( + q.getpalette()[ # type:ignore + i * 3 : (i * 3) + 3 # noqa:E203 + ] + ) + light_value = bg[0] * 0.3 + bg[1] * 0.6 + bg[2] * 0.1 + if abs(light_value - based_light) < temp: # noqa:E203 + bg_color = bg + temp = abs(light_value - based_light) + return bg_color + + @staticmethod + def get_text_color(bg_color: Tuple[int, int, int]) -> Tuple[int, int, int]: + # 通过背景主色(bg_color)确定文字主色 + r = 125 + if max(*bg_color) > 255 - r: + r *= -1 + text_color = ( + math.floor(bg_color[0] + r if bg_color[0] + r <= 255 else 255), + math.floor(bg_color[1] + r if bg_color[1] + r <= 255 else 255), + math.floor(bg_color[2] + r if bg_color[2] + r <= 255 else 255), + ) + return text_color + + @staticmethod + def get_char_color(bg_color: Tuple[int, int, int]) -> Tuple[int, int, int]: + r = 140 + if max(*bg_color) > 255 - r: + r *= -1 + char_color = ( + math.floor(bg_color[0] + 5 if bg_color[0] + r <= 255 else 255), + math.floor(bg_color[1] + 5 if bg_color[1] + r <= 255 else 255), + math.floor(bg_color[2] + 5 if bg_color[2] + r <= 255 else 255), + ) + return char_color + + @staticmethod + def get_char_high_color( + bg_color: Tuple[int, int, int] + ) -> Tuple[int, int, int]: + r = 140 + d = 20 + if max(*bg_color) > 255 - r: + r *= -1 + char_color = ( + math.floor(bg_color[0] + d if bg_color[0] + r <= 255 else 255), + math.floor(bg_color[1] + d if bg_color[1] + r <= 255 else 255), + math.floor(bg_color[2] + d if bg_color[2] + r <= 255 else 255), + ) + return char_color + + @staticmethod + def get_bg_detail_color( + bg_color: Tuple[int, int, int] + ) -> Tuple[int, int, int]: + r = 140 + if max(*bg_color) > 255 - r: + r *= -1 + bg_detail_color = ( + math.floor(bg_color[0] - 20 if bg_color[0] + r <= 255 else 255), + math.floor(bg_color[1] - 20 if bg_color[1] + r <= 255 else 255), + math.floor(bg_color[2] - 20 if bg_color[2] + r <= 255 else 255), + ) + return bg_detail_color + + @staticmethod + def get_highlight_color( + color: Tuple[int, int, int] + ) -> Tuple[int, int, int]: + red_color = color[0] + green_color = color[1] + blue_color = color[2] + + highlight_color = { + 'red': red_color - 127 if red_color > 127 else 127, + 'green': green_color - 127 if green_color > 127 else 127, + 'blue': blue_color - 127 if blue_color > 127 else 127, + } + + max_color = max(highlight_color.values()) + + name = '' + for _highlight_color in highlight_color: + if highlight_color[_highlight_color] == max_color: + name = str(_highlight_color) + + if name == 'red': + return red_color, highlight_color['green'], highlight_color['blue'] + elif name == 'green': + return highlight_color['red'], green_color, highlight_color['blue'] + elif name == 'blue': + return highlight_color['red'], highlight_color['green'], blue_color + else: + return 0, 0, 0 # Error diff --git a/gsuid_core/utils/image/texture2d/mask.png b/gsuid_core/utils/image/texture2d/mask.png new file mode 100644 index 0000000000000000000000000000000000000000..c2211002bb790a52f19fa3c33c8b491106342c80 GIT binary patch literal 5831 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 literal 0 HcmV?d00001 diff --git a/gsuid_core/utils/image/texture2d/ring.png b/gsuid_core/utils/image/texture2d/ring.png new file mode 100644 index 0000000000000000000000000000000000000000..328995cb67d55b95cb71135c640b8dcb1171d9a1 GIT binary patch literal 7988 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 NH&BMxNLL(V{|C{+e}Vu2 literal 0 HcmV?d00001