diff options
Diffstat (limited to 'bot/exts')
-rw-r--r-- | bot/exts/utilities/color.py | 471 |
1 files changed, 471 insertions, 0 deletions
diff --git a/bot/exts/utilities/color.py b/bot/exts/utilities/color.py new file mode 100644 index 00000000..c1523281 --- /dev/null +++ b/bot/exts/utilities/color.py @@ -0,0 +1,471 @@ +import colorsys +import json +import logging +import re +from io import BytesIO + +from discord import Embed, File +from discord.ext import commands +from PIL import Image, ImageColor +from rapidfuzz import process + +from bot.bot import Bot +from bot.constants import Colours + +# from bot.exts.core.extension import invoke_help_command + + +logger = logging.getLogger(__name__) + + +ERROR_MSG = """The color code {user_color} is not a possible color combination. +The range of possible values are: +RGB & HSV: 0-255 +CMYK: 0-100% +HSL: 0-360 degrees +Hex: #000000-#FFFFFF +""" + +with open("bot/resources/utilities/ryanzec_colours.json") as f: + COLOR_MAPPING = json.load(f) + + +THUMBNAIL_SIZE = 80 + +""" +class Colour(commands.Cog): + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @commands.group(aliases=["color"]) + async def colour(self, ctx: commands.Context) -> None: + if ctx.invoked_subcommand is None: + await invoke_help_command(ctx) + + @colour.command() + async def rgb(self, ctx: commands.Context, red: int, green: int, blue: int) -> None: + rgb_tuple = ImageColor.getrgb(f"rgb({red}, {green}, {blue})") + await Colour.send_colour_response(ctx, list(rgb_tuple)) + + @colour.command() + async def hsv(self, ctx: commands.Context, hue: int, saturation: int, value: int) -> None: + hsv_tuple = ImageColor.getrgb(f"hsv({hue}, {saturation}%, {value}%)") + await Colour.send_colour_response(ctx, list(hsv_tuple)) + + @colour.command() + async def hsl(self, ctx: commands.Context, hue: int, saturation: int, lightness: int) -> None: + hsl_tuple = ImageColor.getrgb(f"hsl({hue}, {saturation}%, {lightness}%)") + await Colour.send_colour_response(ctx, list(hsl_tuple)) + + @colour.command() + async def cmyk(self, ctx: commands.Context, cyan: int, yellow: int, magenta: int, key: int) -> None: + ... + + @colour.command() + async def hex(self, ctx: commands.Context, hex_code: str) -> None: + hex_tuple = ImageColor.getrgb(hex_code) + await Colour.send_colour_response(ctx, list(hex_tuple)) + + @colour.command() + async def yiq( + self, + ctx: commands.Context, + perceived_luminesence: int, + in_phase: int, + quadrature: int + ) -> None: + yiq_list = list(colorsys.yiq_to_rgb(perceived_luminesence, in_phase, quadrature)) + yiq_tuple = [int(val * 255.0) for val in yiq_list] + await Colour.send_colour_response(ctx, list(yiq_tuple)) + + @staticmethod + async def send_colour_response(ctx: commands.Context, rgb: list[int]) -> Message: + r, g, b = rgb[0], rgb[1], rgb[2] + colour_embed = Embed( + title="Colour", + description="Here lies thy colour", + colour=int(f"{r:02x}{g:02x}{b:02x}", 16) + ) + colour_conversions = Colour.get_colour_conversions(rgb) + for colour_space, value in colour_conversions.items(): + colour_embed.add_field( + name=colour_space.upper(), + value=f"`{value}`", + inline=True + ) + + thumbnail = Image.new("RGB", (THUMBNAIL_SIZE, THUMBNAIL_SIZE), color=tuple(rgb)) + buffer = BytesIO() + thumbnail.save(buffer, "PNG") + buffer.seek(0) + thumbnail_file = File(buffer, filename="colour.png") + + colour_embed.set_thumbnail(url="attachment://colour.png") + + await ctx.send(file=thumbnail_file, embed=colour_embed) + + @staticmethod + def get_colour_conversions(rgb: list[int]) -> dict[str, str]: + return { + "rgb": tuple(rgb), + "hsv": Colour._rgb_to_hsv(rgb), + "hsl": Colour._rgb_to_hsl(rgb), + "cmyk": Colour._rgb_to_cmyk(rgb), + "hex": Colour._rgb_to_hex(rgb), + "yiq": Colour._rgb_to_yiq(rgb) + } + + @staticmethod + def _rgb_to_hsv(rgb: list[int]) -> tuple[int, int, int]: + rgb = [val / 255.0 for val in rgb] + h, v, s = colorsys.rgb_to_hsv(*rgb) + hsv = (round(h * 360), round(s * 100), round(v * 100)) + return hsv + + @staticmethod + def _rgb_to_hsl(rgb: list[int]) -> tuple[int, int, int]: + rgb = [val / 255.0 for val in rgb] + h, l, s = colorsys.rgb_to_hls(*rgb) + hsl = (round(h * 360), round(s * 100), round(l * 100)) + return hsl + + @staticmethod + def _rgb_to_cmyk(rgb: list[int]) -> tuple[int, int, int, int]: + rgb = [val / 255.0 for val in rgb] + + if all(val == 0 for val in rgb): + return 0, 0, 0, 100 + + cmy = [1 - val / 255 for val in rgb] + min_cmy = min(cmy) + + cmyk = [(val - min_cmy) / (1 - min_cmy) for val in cmy] + [min_cmy] + cmyk = [round(val * 100) for val in cmyk] + + return tuple(cmyk) + + @staticmethod + def _rgb_to_hex(rgb: list[int]) -> str: + hex_ = ''.join([hex(val)[2:].zfill(2) for val in rgb]) + hex_code = f"#{hex_}".upper() + return hex_code + + @staticmethod + def _rgb_to_yiq(rgb: list[int]) -> tuple[int, int, int, int]: + rgb = [val / 255.0 for val in rgb] + y, i, q = colorsys.rgb_to_yiq(*rgb) + yiq = (round(y), round(i), round(q)) + return yiq + +def setup(bot: commands.Bot) -> None: + bot.add_cog(Colour(bot)) +""" + + +class Color(commands.Cog): + """User initiated commands to receive color information.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.command(aliases=["colour"]) + async def color(self, ctx: commands.Context, mode: str, *, user_color: str) -> None: + """ + Send information on input color code or color name. + + Possible modes are: "hex", "rgb", "hsv", "hsl", "cmyk" or "name". + """ + logger.debug(f"{mode = }") + logger.debug(f"{user_color = }") + if mode.lower() == "hex": + await self.hex_to_rgb(ctx, user_color) + elif mode.lower() == "rgb": + rgb_color = self.tuple_create(user_color) + await self.color_embed(ctx, rgb_color) + elif mode.lower() == "hsv": + await self.hsv_to_rgb(ctx, user_color) + elif mode.lower() == "hsl": + await self.hsl_to_rgb(ctx, user_color) + elif mode.lower() == "cmyk": + await self.cmyk_to_rgb(ctx, user_color) + elif mode.lower() == "name": + color_name, hex_color = self.match_color_name(user_color) + if "#" in hex_color: + rgb_color = ImageColor.getcolor(hex_color, "RGB") + else: + rgb_color = ImageColor.getcolor("#" + hex_color, "RGB") + await self.color_embed(ctx, rgb_color, color_name) + else: + # mode is either None or an invalid code + if mode is None: + no_mode_embed = Embed( + title="No mode was passed, please define a color code.", + description="Possible modes are: Name, Hex, RGB, HSV, HSL and CMYK.", + color=Colours.soft_red, + ) + await ctx.send(embed=no_mode_embed) + return + wrong_mode_embed = Embed( + title=f"The color code {mode} is not a valid option", + description="Possible modes are: Name, Hex, RGB, HSV, HSL and CMYK.", + color=Colours.soft_red, + ) + await ctx.send(embed=wrong_mode_embed) + return + + @staticmethod + def tuple_create(input_color: str) -> tuple[int, int, int]: + """ + Create a tuple of integers based on user's input. + + Can handle inputs of the types: + (100, 100, 100) + 100, 100, 100 + 100 100 100 + """ + if "(" in input_color: + remove = "[() ]" + color_tuple = re.sub(remove, "", input_color) + color_tuple = tuple(map(int, color_tuple.split(","))) + elif "," in input_color: + color_tuple = tuple(map(int, input_color.split(","))) + else: + color_tuple = tuple(map(int, input_color.split(" "))) + return color_tuple + + async def hex_to_rgb(self, ctx: commands.Context, hex_string: str) -> None: + """Function to convert hex color to rgb color and send main embed.""" + hex_match = re.fullmatch(r"(#?[0x]?)((?:[0-9a-fA-F]{3}){1,2})", hex_string) + if hex_match: + if "#" in hex_string: + rgb_color = ImageColor.getcolor(hex_string, "RGB") + elif "0x" in hex_string: + hex_ = hex_string.replace("0x", "#") + rgb_color = ImageColor.getcolor(hex_, "RGB") + else: + hex_ = "#" + hex_string + rgb_color = ImageColor.getcolor(hex_, "RGB") + await self.color_embed(ctx, rgb_color) + else: + await ctx.send( + embed=Embed( + title="There was an issue converting the hex color code.", + description=ERROR_MSG.format(user_color=hex_string), + ) + ) + + async def hsv_to_rgb(self, ctx: commands.Context, input_color: tuple[int, int, int]) -> tuple[int, int, int]: + """Function to convert hsv color to rgb color and send main embed.""" + input_color = self.tuple_create(input_color) + (h, v, s) = input_color # the function hsv_to_rgb expects v and s to be swapped + h = h / 360 + s = s / 100 + v = v / 100 + rgb_color = colorsys.hsv_to_rgb(h, s, v) + (r, g, b) = rgb_color + r = int(r * 255) + g = int(g * 255) + b = int(b * 255) + await self.color_embed(ctx, (r, g, b)) + + async def hsl_to_rgb(self, ctx: commands.Context, input_color: tuple[int, int, int]) -> tuple[int, int, int]: + """Function to convert hsl color to rgb color and send main embed.""" + input_color = self.tuple_create(input_color) + (h, s, l) = input_color + h = h / 360 + s = s / 100 + l = l / 100 # noqa: E741 It's little `L`, Reason: To maintain consistency. + rgb_color = colorsys.hls_to_rgb(h, l, s) + (r, g, b) = rgb_color + r = int(r * 255) + g = int(g * 255) + b = int(b * 255) + await self.color_embed(ctx, (r, g, b)) + + async def cmyk_to_rgb( + self, + ctx: commands.Context, + input_color: tuple[int, int, int, int] + ) -> tuple[int, int, int]: + """Function to convert cmyk color to rgb color and send main embed.""" + input_color = self.tuple_create(input_color) + c = input_color[0] + m = input_color[1] + y = input_color[2] + k = input_color[3] + r = int(255 * (1.0 - c / float(100)) * (1.0 - k / float(100))) + g = int(255 * (1.0 - m / float(100)) * (1.0 - k / float(100))) + b = int(255 * (1.0 - y / float(100)) * (1.0 - k / float(100))) + await self.color_embed(ctx, (r, g, b)) + + @staticmethod + async def create_thumbnail_attachment(color: tuple[int, int, int]) -> File: + """ + Generate a thumbnail from `color`. + + Assumes that color is an rgb tuple. + """ + thumbnail = Image.new("RGB", (80, 80), color=color) + bufferedio = BytesIO() + thumbnail.save(bufferedio, format="PNG") + bufferedio.seek(0) + + file = File(bufferedio, filename="color.png") + + return file + + @staticmethod + def get_color_fields(rgb_color: tuple[int, int, int]) -> list[dict]: + """Converts from `RGB` to `CMYK`, `HSV`, `HSL` and returns a list of fields.""" + + def _rgb_to_hex(rgb_color: tuple[int, int, int]) -> str: + """To convert from `RGB` to `Hex` notation.""" + return '#' + ''.join(hex(int(color))[2:].zfill(2) for color in rgb_color).upper() + + def _rgb_to_cmyk(rgb_color: tuple[int, int, int]) -> tuple[int, int, int, int]: + """To convert from `RGB` to `CMYK` color space.""" + r, g, b = rgb_color + + # RGB_SCALE -> 255 + # CMYK_SCALE -> 100 + + if (r == g == b == 0): + return 0, 0, 0, 100 # Representing Black + + # rgb [0,RGB_SCALE] -> cmy [0,1] + c = 1 - r / 255 + m = 1 - g / 255 + y = 1 - b / 255 + + # extract out k [0, 1] + min_cmy = min(c, m, y) + c = (c - min_cmy) / (1 - min_cmy) + m = (m - min_cmy) / (1 - min_cmy) + y = (y - min_cmy) / (1 - min_cmy) + k = min_cmy + + # rescale to the range [0,CMYK_SCALE] and round off + c = round(c * 100) + m = round(m * 100) + y = round(y * 100) + k = round(k * 100) + + return c, m, y, k + + def _rgb_to_hsv(rgb_color: tuple[int, int, int]) -> tuple[int, int, int]: + """To convert from `RGB` to `HSV` color space.""" + r, g, b = rgb_color + h, v, s = colorsys.rgb_to_hsv(r / float(255), g / float(255), b / float(255)) + h = round(h * 360) + s = round(s * 100) + v = round(v * 100) + return h, s, v + + def _rgb_to_hsl(rgb_color: tuple[int, int, int]) -> tuple[int, int, int]: + """To convert from `RGB` to `HSL` color space.""" + r, g, b = rgb_color + h, l, s = colorsys.rgb_to_hls(r / float(255), g / float(255), b / float(255)) + h = round(h * 360) + s = round(s * 100) + l = round(l * 100) # noqa: E741 It's little `L`, Reason: To maintain consistency. + return h, s, l + + all_fields = [ + { + "name": "RGB", + "value": f"» rgb {rgb_color}" + }, + { + "name": "HEX", + "value": f"» hex {_rgb_to_hex(rgb_color)}" + }, + { + "name": "CMYK", + "value": f"» cmyk {_rgb_to_cmyk(rgb_color)}" + }, + { + "name": "HSV", + "value": f"» hsv {_rgb_to_hsv(rgb_color)}" + }, + { + "name": "HSL", + "value": f"» hsl {_rgb_to_hsl(rgb_color)}" + }, + ] + + return all_fields + + @staticmethod + def match_color_name(input_color_name: str) -> str: + """Use fuzzy matching to return a hex color code based on the user's input.""" + try: + match, certainty, _ = process.extractOne( + query=input_color_name, + choices=COLOR_MAPPING.keys(), + score_cutoff=50 + ) + logger.debug(f"{match = }, {certainty = }") + hex_match = COLOR_MAPPING[match] + logger.debug(f"{hex_match = }") + except TypeError: + match = "No color name match found." + hex_match = input_color_name + + return match, hex_match + + @staticmethod + def match_color_hex(input_hex_color: str) -> str: + """Use fuzzy matching to return a hex color code based on the user's input.""" + try: + match, certainty, _ = process.extractOne( + query=input_hex_color, + choices=COLOR_MAPPING.values(), + score_cutoff=80 + ) + logger.debug(f"{match = }, {certainty = }") + color_name = [name for name, _ in COLOR_MAPPING.items() if _ == match][0] + logger.debug(f"{color_name = }") + except TypeError: + color_name = "No color name match found." + + return color_name + + async def color_embed( + self, + ctx: commands.Context, + rgb_color: tuple[int, int, int], + color_name: str = None + ) -> None: + """Take a RGB color tuple, create embed, and send.""" + (r, g, b) = rgb_color + discord_rgb_int = int(f"{r:02x}{g:02x}{b:02x}", 16) + all_colors = self.get_color_fields(rgb_color) + hex_color = all_colors[1]["value"].replace("» hex ", "") + if color_name is None: + logger.debug(f"Find color name from hex color: {hex_color}") + color_name = self.match_color_hex(hex_color) + + async with ctx.typing(): + main_embed = Embed( + title=color_name, + description='(Approx..)', + color=discord_rgb_int, + ) + + file = await self.create_thumbnail_attachment(rgb_color) + main_embed.set_thumbnail(url="attachment://color.png") + + for field in all_colors: + main_embed.add_field( + name=field['name'], + value=field['value'], + inline=False, + ) + + await ctx.send(file=file, embed=main_embed) + + +def setup(bot: Bot) -> None: + """Load the Color Cog.""" + bot.add_cog(Color(bot)) |