From ade87b193d33ef0c0138804a22c778f5cbddada1 Mon Sep 17 00:00:00 2001 From: sinux-x <14353790+sinus-x@users.noreply.github.com> Date: Thu, 8 Jun 2023 23:15:05 +0200 Subject: [PATCH] Fun: Finally fix animated gifs This issue has existed since the very beginning of this module in Rubbergoddess (and Rubbergod) codebase. Fairly recently (April 2022) the issue of flickering GIFs has been fixed in Pillow 9.1.0 via PR 6128. As the issue depends on the avatar of the user, it is not possible to claim this fixes it 100 %. I do believe/hope it does. --- fun/image_utils.py | 156 --------------------------------------------- fun/module.py | 37 ++++++----- 2 files changed, 22 insertions(+), 171 deletions(-) diff --git a/fun/image_utils.py b/fun/image_utils.py index a852584..6c61d09 100644 --- a/fun/image_utils.py +++ b/fun/image_utils.py @@ -1,12 +1,7 @@ from PIL import Image, ImageDraw -from typing import Tuple, List, Union import numpy as np -from collections import defaultdict -from random import randrange -from itertools import chain - class ImageUtils: def round_image(frame_avatar: Image.Image) -> Image.Image: @@ -73,154 +68,3 @@ def shift_hue(arr, hout): hsv[..., 0] = hout rgb = ImageUtils.hsv_to_rgb(hsv) return rgb - - class GifConverter: - # Sourced from https://gist.github.com/egocarib/ea022799cca8a102d14c54a22c45efe0 - _PALETTE_SLOTSET = set(range(256)) - - def __init__(self, img_rgba: Image.Image, alpha_treshold: int = 0): - self._img_rgba = img_rgba - self._alpha_treshold = alpha_treshold - - def _process_pixels(self): - """Set transparent pixels to the color palette index 0.""" - self._transparent_pixels = set( - idx - for idx, alpha in enumerate( - self._img_rgba.getchannel(channel="A").getdata() - ) - if alpha <= self._alpha_treshold - ) - - def _set_parsed_palette(self): - """Parse the RGB palette color `tuple`s from the palette.""" - palette = self._img_p.getpalette() - self._img_p_used_palette_idxs = set( - idx - for pal_idx, idx in enumerate(self._img_p_data) - if pal_idx not in self._transparent_pixels - ) - self._img_p_parsedpalette = dict( - (idx, tuple(palette[idx * 3 : idx * 3 + 3])) - for idx in self._img_p_used_palette_idxs - ) - - def _get_similar_color_idx(self): - """Return a palette index with the closest similar color.""" - old_color = self._img_p_parsedpalette[0] - dict_distance = defaultdict(list) - for idx in range(1, 256): - color_item = self._img_p_parsedpalette[idx] - if color_item == old_color: - return idx - distance = sum( - ( - abs(old_color[0] - color_item[0]), # Red - abs(old_color[1] - color_item[1]), # Green - abs(old_color[2] - color_item[2]), # Blue - ) - ) - dict_distance[distance].append(idx) - return dict_distance[sorted(dict_distance)[0]][0] - - def _remap_palette_idx_zero(self): - """Since the first color is used in the palette, remap it.""" - free_slots = self._PALETTE_SLOTSET - self._img_p_used_palette_idxs - new_idx = free_slots.pop() if free_slots else self._get_similar_color_idx() - self._img_p_used_palette_idxs.add(new_idx) - self._palette_replaces["idx_from"].append(0) - self._palette_replaces["idx_to"].append(new_idx) - self._img_p_parsedpalette[new_idx] = self._img_p_parsedpalette[0] - del self._img_p_parsedpalette[0] - - def _get_unused_color(self) -> tuple: - """Return a color for the palette that does not collide with any other - already in the palette.""" - used_colors = set(self._img_p_parsedpalette.values()) - while True: - new_color = (randrange(256), randrange(256), randrange(256)) - if new_color not in used_colors: - return new_color - - def _process_palette(self): - """Adjust palette to have the zeroth color set as transparent. - Basically, get another palette index for the zeroth color.""" - self._set_parsed_palette() - if 0 in self._img_p_used_palette_idxs: - self._remap_palette_idx_zero() - self._img_p_parsedpalette[0] = self._get_unused_color() - - def _adjust_pixels(self): - """Convert the pixels into their new values.""" - if self._palette_replaces["idx_from"]: - trans_table = bytearray.maketrans( - bytes(self._palette_replaces["idx_from"]), - bytes(self._palette_replaces["idx_to"]), - ) - self._img_p_data = self._img_p_data.translate(trans_table) - for idx_pixel in self._transparent_pixels: - self._img_p_data[idx_pixel] = 0 - self._img_p.frombytes(data=bytes(self._img_p_data)) - - def _adjust_palette(self): - """Modify the palette in the new `Image`.""" - unused_color = self._get_unused_color() - final_palette = chain.from_iterable( - self._img_p_parsedpalette.get(x, unused_color) for x in range(256) - ) - self._img_p.putpalette(data=final_palette) - - def process(self) -> Image.Image: - """Return the processed mode `P` `Image`.""" - self._img_p = self._img_rgba.convert(mode="P") - self._img_p_data = bytearray(self._img_p.tobytes()) - self._palette_replaces = dict(idx_from=list(), idx_to=list()) - self._process_pixels() - self._process_palette() - self._adjust_pixels() - self._adjust_palette() - self._img_p.info["transparency"] = 0 - self._img_p.info["background"] = 0 - return self._img_p - - def create_animated_gif( - images: List[Image.Image], duration: Union[int, List[int]] - ) -> Tuple[Image.Image, dict]: - """If the image is a GIF, create an its thumbnail here.""" - save_kwargs = dict() - new_images: List[Image] = [] - - for frame in images: - thumbnail: Image.Image = frame.copy() - thumbnail_rgba = thumbnail.convert(mode="RGBA") - thumbnail_rgba.thumbnail(size=frame.size, reducing_gap=3.0) - converter = GifConverter(img_rgba=thumbnail_rgba) # noqa: F821 - thumbnail_p = converter.process() - new_images.append(thumbnail_p) - - output_image = new_images[0] - save_kwargs.update( - format="GIF", - save_all=True, - optimize=False, - append_images=new_images[1:], - duration=duration, - disposal=2, # Other disposals don't work - loop=0, - ) - return output_image, save_kwargs - - def save_gif( - images: List[Image.Image], duration: Union[int, List[int]], save_file: str - ): - """Create a transparent GIF, with no problems with transparent pixel flashing - - Does not work with partial alpha, which gets discarded and replaced by solid colors. - - Parameters: - images: list of Pillow Image objects that compose the GIF frames - durations: an int or list of ints that describe the frame durations - save_file: A string, pathlib.Path or file object to save the file to. - """ - root_frame, save_args = create_animated_gif(images, duration) # noqa: F821 - root_frame.save(save_file, **save_args) diff --git a/fun/module.py b/fun/module.py index e7ab102..e476bed 100644 --- a/fun/module.py +++ b/fun/module.py @@ -173,12 +173,13 @@ async def whip(self, ctx, *, user: discord.Member = None): frames[0].save( image_binary, format="GIF", + interlace=True, save_all=True, append_images=frames[1:], duration=30, loop=0, - transparency=0, disposal=2, + background=255, optimize=False, ) image_binary.seek(0) @@ -226,12 +227,13 @@ async def spank(self, ctx, *, user: discord.Member = None): frames[0].save( image_binary, format="GIF", + interlace=True, save_all=True, append_images=frames[1:], duration=frame_duration, loop=0, - transparency=0, disposal=2, + background=255, optimize=False, ) image_binary.seek(0) @@ -268,12 +270,13 @@ async def pet(self, ctx, *, user: discord.Member = None): frames[0].save( image_binary, format="GIF", + interlace=True, save_all=True, append_images=frames[1:], duration=40, loop=0, - transparency=0, disposal=2, + background=255, optimize=False, ) image_binary.seek(0) @@ -310,12 +313,13 @@ async def hyperpet(self, ctx, *, user: discord.Member = None): frames[0].save( image_binary, format="GIF", + interlace=True, save_all=True, append_images=frames[1:], duration=30, loop=0, - transparency=0, disposal=2, + background=255, optimize=False, ) image_binary.seek(0) @@ -355,12 +359,13 @@ async def bonk(self, ctx, *, user: discord.Member = None): frames[0].save( image_binary, format="GIF", + interlace=True, save_all=True, append_images=frames[1:], duration=30, loop=0, - transparency=0, disposal=2, + background=255, optimize=False, ) image_binary.seek(0) @@ -437,12 +442,13 @@ async def lick(self, ctx, *, user: discord.Member = None): frames[0].save( image_binary, format="GIF", + interlace=True, save_all=True, append_images=frames[1:], duration=30, loop=0, - transparency=0, disposal=2, + background=255, optimize=False, ) image_binary.seek(0) @@ -479,12 +485,13 @@ async def hyperlick(self, ctx, *, user: discord.Member = None): frames[0].save( image_binary, format="GIF", + interlace=True, save_all=True, append_images=frames[1:], duration=30, loop=0, - transparency=0, disposal=2, + background=255, optimize=False, ) image_binary.seek(0) @@ -791,7 +798,7 @@ def get_pet_frames(avatar: Image.Image) -> List[Image.Image]: for i in range(14): img = "%02d" % (i + 1) - frame = Image.new("RGBA", (width, height), (54, 57, 63, 1)) + frame = Image.new("RGBA", (width, height), (0, 0, 0, 0)) frame_object = Image.open(DATA_DIR / f"pet/{img}.png") frame.paste(frame_avatar, (35, 25 + vertical_offset[i]), frame_avatar) frame.paste(frame_object, (10, 5), frame_object) @@ -817,7 +824,7 @@ def get_hyperpet_frames(avatar: Image.Image) -> List[Image.Image]: ) frame_object = Image.open(DATA_DIR / f"hyperpet/{img}.png") - frame = Image.new("RGBA", (width, height), (54, 57, 63, 1)) + frame = Image.new("RGBA", (width, height), (0, 0, 0, 0)) frame.paste(frame_avatar, (35, 25 + vertical_offset[i]), frame_avatar) frame.paste(frame_object, (10, 5), frame_object) frames.append(frame) @@ -838,7 +845,7 @@ def get_bonk_frames(avatar: Image.Image) -> List[Image.Image]: frame_avatar = avatar.resize((100, 100 - deformation[i])) frame_object = Image.open(DATA_DIR / f"bonk/{img}.png") - frame = Image.new("RGBA", (width, height), (54, 57, 63, 1)) + frame = Image.new("RGBA", (width, height), (0, 0, 0, 0)) frame.paste(frame_avatar, (80, 60 + deformation[i]), frame_avatar) frame.paste(frame_object, (10, 5), frame_object) frames.append(frame) @@ -860,7 +867,7 @@ def get_whip_frames(avatar: Image.Image) -> List[Image.Image]: frame_avatar = avatar.resize((100 - deformation[i], 100)) frame_object = Image.open(DATA_DIR / f"whip/{img}.png").resize((150, 150)) - frame = Image.new("RGBA", (width, height), (54, 57, 63, 1)) + frame = Image.new("RGBA", (width, height), (0, 0, 0, 0)) frame.paste( frame_avatar, (135 + deformation[i] + translation[i], 25), frame_avatar ) @@ -885,7 +892,7 @@ def get_spank_frames(avatar: Image.Image) -> List[Image.Image]: ) frame_object = Image.open(DATA_DIR / f"spank/{img}.png").resize((100, 100)) - frame = Image.new("RGBA", (width, height), (54, 57, 63, 1)) + frame = Image.new("RGBA", (width, height), (0, 0, 0, 0)) frame.paste(frame_object, (10, 15), frame_object) frame.paste( frame_avatar, (80 - deformation[i], 10 - deformation[i]), frame_avatar @@ -915,7 +922,7 @@ def get_spank_frames_figures( frame_object = Image.open(DATA_DIR / f"spank_figures/{img}.png") frame_source_avatar = source_avatar.resize((64, 64)) frame_target_avatar = rotated_avatar.resize((64, 64)) - frame = Image.new("RGBA", (width, height), (54, 57, 63, 1)) + frame = Image.new("RGBA", (width, height), (0, 0, 0, 0)) frame.paste( frame_target_avatar, @@ -951,7 +958,7 @@ def get_lick_frames(avatar: Image.Image) -> List[Image.Image]: frame_avatar = avatar.resize((64, 64)) frame_object = Image.open(DATA_DIR / f"lick/{img}.png") - frame = Image.new("RGBA", (width, height), (54, 57, 63, 1)) + frame = Image.new("RGBA", (width, height), (0, 0, 0, 0)) frame.paste(frame_object, (10, 15), frame_object) frame.paste(frame_avatar, (198 + voffset[i], 68 + hoffset[i]), frame_avatar) frames.append(frame) @@ -977,7 +984,7 @@ def get_hyperlick_frames(avatar: Image.Image) -> List[Image.Image]: ) frame_object = Image.open(DATA_DIR / f"lick/{img}.png") - frame = Image.new("RGBA", (width, height), (54, 57, 63, 1)) + frame = Image.new("RGBA", (width, height), (0, 0, 0, 0)) frame.paste(frame_object, (10, 15), frame_object) frame.paste(frame_avatar, (198 + voffset[i], 68 + hoffset[i]), frame_avatar) frames.append(frame)