aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/avatar_modification/_effects.py
diff options
context:
space:
mode:
authorGravatar Janine vN <[email protected]>2021-09-04 23:55:44 -0400
committerGravatar Janine vN <[email protected]>2021-09-04 23:55:44 -0400
commit34ecc9e688c6a9a04ef54c2584fe814890d3979a (patch)
treed4d11e787389cb1a81aa608f3e2520b2eb8eae06 /bot/exts/avatar_modification/_effects.py
parentMove Hanukkah to Holidays folder (diff)
Update paths to new resource links
Additionally, this commit fixes an error with the pridepfp command. The avatar image now uses discord.py's v.20 avatar.url instead of avatar_url
Diffstat (limited to 'bot/exts/avatar_modification/_effects.py')
-rw-r--r--bot/exts/avatar_modification/_effects.py296
1 files changed, 296 insertions, 0 deletions
diff --git a/bot/exts/avatar_modification/_effects.py b/bot/exts/avatar_modification/_effects.py
new file mode 100644
index 00000000..f1c2e6d1
--- /dev/null
+++ b/bot/exts/avatar_modification/_effects.py
@@ -0,0 +1,296 @@
+import math
+import random
+from io import BytesIO
+from pathlib import Path
+from typing import Callable, Optional
+
+import discord
+from PIL import Image, ImageDraw, ImageOps
+
+from bot.constants import Colours
+
+
+class PfpEffects:
+ """
+ Implements various image modifying effects, for the PfpModify cog.
+
+ All of these functions are slow, and blocking, so they should be ran in executors.
+ """
+
+ @staticmethod
+ def apply_effect(image_bytes: bytes, effect: Callable, filename: str, *args) -> discord.File:
+ """Applies the given effect to the image passed to it."""
+ im = Image.open(BytesIO(image_bytes))
+ im = im.convert("RGBA")
+ im = im.resize((1024, 1024))
+ im = effect(im, *args)
+
+ bufferedio = BytesIO()
+ im.save(bufferedio, format="PNG")
+ bufferedio.seek(0)
+
+ return discord.File(bufferedio, filename=filename)
+
+ @staticmethod
+ def closest(x: tuple[int, int, int]) -> tuple[int, int, int]:
+ """
+ Finds the closest "easter" colour to a given pixel.
+
+ Returns a merge between the original colour and the closest colour.
+ """
+ r1, g1, b1 = x
+
+ def distance(point: tuple[int, int, int]) -> int:
+ """Finds the difference between a pastel colour and the original pixel colour."""
+ r2, g2, b2 = point
+ return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2
+
+ closest_colours = sorted(Colours.easter_like_colours, key=distance)
+ r2, g2, b2 = closest_colours[0]
+ r = (r1 + r2) // 2
+ g = (g1 + g2) // 2
+ b = (b1 + b2) // 2
+
+ return r, g, b
+
+ @staticmethod
+ def crop_avatar_circle(avatar: Image.Image) -> Image.Image:
+ """This crops the avatar given into a circle."""
+ mask = Image.new("L", avatar.size, 0)
+ draw = ImageDraw.Draw(mask)
+ draw.ellipse((0, 0) + avatar.size, fill=255)
+ avatar.putalpha(mask)
+ return avatar
+
+ @staticmethod
+ def crop_ring(ring: Image.Image, px: int) -> Image.Image:
+ """This crops the given ring into a circle."""
+ mask = Image.new("L", ring.size, 0)
+ draw = ImageDraw.Draw(mask)
+ draw.ellipse((0, 0) + ring.size, fill=255)
+ draw.ellipse((px, px, 1024-px, 1024-px), fill=0)
+ ring.putalpha(mask)
+ return ring
+
+ @staticmethod
+ def pridify_effect(image: Image.Image, pixels: int, flag: str) -> Image.Image:
+ """Applies the given pride effect to the given image."""
+ image = PfpEffects.crop_avatar_circle(image)
+
+ ring = Image.open(Path(f"bot/resources/holidays/pride/flags/{flag}.png")).resize((1024, 1024))
+ ring = ring.convert("RGBA")
+ ring = PfpEffects.crop_ring(ring, pixels)
+
+ image.alpha_composite(ring, (0, 0))
+ return image
+
+ @staticmethod
+ def eight_bitify_effect(image: Image.Image) -> Image.Image:
+ """
+ Applies the 8bit effect to the given image.
+
+ This is done by reducing the image to 32x32 and then back up to 1024x1024.
+ We then quantize the image before returning too.
+ """
+ image = image.resize((32, 32), resample=Image.NEAREST)
+ image = image.resize((1024, 1024), resample=Image.NEAREST)
+ return image.quantize()
+
+ @staticmethod
+ def flip_effect(image: Image.Image) -> Image.Image:
+ """
+ Flips the image horizontally.
+
+ This is done by just using ImageOps.mirror().
+ """
+ image = ImageOps.mirror(image)
+
+ return image
+
+ @staticmethod
+ def easterify_effect(image: Image.Image, overlay_image: Optional[Image.Image] = None) -> Image.Image:
+ """
+ Applies the easter effect to the given image.
+
+ This is done by getting the closest "easter" colour to each pixel and changing the colour
+ to the half-way RGB value.
+
+ We also then add an overlay image on top in middle right, a chocolate bunny by default.
+ """
+ if overlay_image:
+ ratio = 64 / overlay_image.height
+ overlay_image = overlay_image.resize((
+ round(overlay_image.width * ratio),
+ round(overlay_image.height * ratio)
+ ))
+ overlay_image = overlay_image.convert("RGBA")
+ else:
+ overlay_image = Image.open(Path("bot/resources/holidays/easter/chocolate_bunny.png"))
+
+ alpha = image.getchannel("A").getdata()
+ image = image.convert("RGB")
+ image = ImageOps.posterize(image, 6)
+
+ data = image.getdata()
+ data_set = set(data)
+ easterified_data_set = {}
+
+ for x in data_set:
+ easterified_data_set[x] = PfpEffects.closest(x)
+ new_pixel_data = [
+ (*easterified_data_set[x], alpha[i])
+ if x in easterified_data_set else x
+ for i, x in enumerate(data)
+ ]
+
+ im = Image.new("RGBA", image.size)
+ im.putdata(new_pixel_data)
+ im.alpha_composite(
+ overlay_image,
+ (im.width - overlay_image.width, (im.height - overlay_image.height) // 2)
+ )
+ return im
+
+ @staticmethod
+ def split_image(img: Image.Image, squares: int) -> list:
+ """
+ Split an image into a selection of squares, specified by the squares argument.
+
+ Explanation:
+
+ 1. It gets the width and the height of the Image passed to the function.
+
+ 2. It gets the root of a number of squares (number of squares) passed, which is called xy. Reason: if let's say
+ 25 squares (number of squares) were passed, that is the total squares (split pieces) that the image is supposed
+ to be split into. As it is known, a 2D shape has a height and a width, and in this case the program thinks of it
+ as rows and columns. Rows multiplied by columns is equal to the passed squares (number of squares). To get rows
+ and columns, since in this case, each square (split piece) is identical, rows are equal to columns and the
+ program treats the image as a square-shaped, it gets the root out of the squares (number of squares) passed.
+
+ 3. Now width and height are both of the original Image, Discord PFP, so when it comes to forming the squares,
+ the program divides the original image's height and width by the xy. In a case of 25 squares (number of squares)
+ passed, xy would be 5, so if an image was 250x300, x_frac would be 50 and y_frac - 60. Note:
+ x_frac stands for a fracture of width. The reason it's called that is because it is shorter to use x for width
+ in mind and then it's just half of the word fracture, same applies to y_frac, just height instead of width.
+ x_frac and y_frac are width and height of a single square (split piece).
+
+ 4. With left, top, right, bottom, = 0, 0, x_frac, y_frac, the program sets these variables to create the initial
+ square (split piece). Explanation: all of these 4 variables start at the top left corner of the Image, by adding
+ value to right and bottom, it's creating the initial square (split piece).
+
+ 5. In the for loop, it keeps adding those squares (split pieces) in a row and once (index + 1) % xy == 0 is
+ True, it adds to top and bottom to lower them and reset right and left to recreate the initial space between
+ them, forming a square (split piece), it also adds the newly created square (split piece) into the new_imgs list
+ where it stores them. The program keeps repeating this process till all 25 squares get added to the list.
+
+ 6. It returns new_imgs, a list of squares (split pieces).
+ """
+ width, heigth = img.size
+
+ xy = math.sqrt(squares)
+
+ x_frac = width // xy
+ y_frac = heigth // xy
+
+ left, top, right, bottom, = 0, 0, x_frac, y_frac
+
+ new_imgs = []
+
+ for index in range(squares):
+ new_img = img.crop((left, top, right, bottom))
+ new_imgs.append(new_img)
+
+ if (index + 1) % xy == 0:
+ top += y_frac
+ bottom += y_frac
+ left = 0
+ right = x_frac
+ else:
+ left += x_frac
+ right += x_frac
+
+ return new_imgs
+
+ @staticmethod
+ def join_images(images: list[Image.Image]) -> Image.Image:
+ """
+ Stitches all the image squares into a new image.
+
+ Explanation:
+
+ 1. Shuffles the passed images to randomize the pieces.
+
+ 2. The program gets a single square (split piece) out of the list and defines single_width as the square's width
+ and single_height as the square's height.
+
+ 3. It gets the root of type integer of the number of images (split pieces) in the list and calls it multiplier.
+ Program then proceeds to calculate total height and width of the new image that it's creating using the same
+ multiplier.
+
+ 4. The program then defines new_image as the image that it's creating, using the previously obtained total_width
+ and total_height.
+
+ 5. Now it defines width_multiplier as well as height with values of 0. These will be used to correctly position
+ squares (split pieces) onto the new_image canvas.
+
+ 6. Similar to how in the split_image function, the program gets the root of number of images in the list.
+ In split_image function, it was the passed squares (number of squares) instead of a number of imgs in the
+ list that it got the square of here.
+
+ 7. In the for loop, as it iterates, the program multiplies single_width by width_multiplier to correctly
+ position a square (split piece) width wise. It then proceeds to paste the newly positioned square (split piece)
+ onto the new_image. The program increases the width_multiplier by 1 every iteration so the image wouldn't get
+ pasted in the same spot and the positioning would move accordingly. It makes sure to increase the
+ width_multiplier before the check, which checks if the end of a row has been reached, -
+ (index + 1) % pieces == 0, so after it, if it was True, width_multiplier would have been reset to 0 (start of
+ the row). If the check returns True, the height gets increased by a single square's (split piece) height to
+ lower the positioning height wise and, as mentioned, the width_multiplier gets reset to 0 and width will
+ then be calculated from the start of the new row. The for loop finishes once all the squares (split pieces) were
+ positioned accordingly.
+
+ 8. Finally, it returns the new_image, the randomized squares (split pieces) stitched back into the format of the
+ original image - user's PFP.
+ """
+ random.shuffle(images)
+ single_img = images[0]
+
+ single_wdith = single_img.size[0]
+ single_height = single_img.size[1]
+
+ multiplier = int(math.sqrt(len(images)))
+
+ total_width = multiplier * single_wdith
+ total_height = multiplier * single_height
+
+ new_image = Image.new("RGBA", (total_width, total_height), (250, 250, 250))
+
+ width_multiplier = 0
+ height = 0
+
+ squares = math.sqrt(len(images))
+
+ for index, image in enumerate(images):
+ width = single_wdith * width_multiplier
+
+ new_image.paste(image, (width, height))
+
+ width_multiplier += 1
+
+ if (index + 1) % squares == 0:
+ width_multiplier = 0
+ height += single_height
+
+ return new_image
+
+ @staticmethod
+ def mosaic_effect(image: Image.Image, squares: int) -> Image.Image:
+ """
+ Applies a mosaic effect to the given image.
+
+ The "squares" argument specifies the number of squares to split
+ the image into. This should be a square number.
+ """
+ img_squares = PfpEffects.split_image(image, squares)
+ new_img = PfpEffects.join_images(img_squares)
+
+ return new_img