diff --git a/api/src/caption.py b/api/src/caption.py index 88e3588..b11dfb2 100644 --- a/api/src/caption.py +++ b/api/src/caption.py @@ -3,6 +3,7 @@ import io import os import textwrap +from transparent_gif_converter import save_transparent_gif PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -17,9 +18,10 @@ def get_background_height(text, width, height, padding, font): return current_h -def caption_gif(text, file_in, file_out, padding=15): +def caption_gif(text, file_in, file_out, padding=30): gif = Image.open(file_in) + width, height = gif.size fontsize = int(height / 10) font = ImageFont.truetype( @@ -53,5 +55,9 @@ def caption_gif(text, file_in, file_out, padding=15): frames.append(background) - frames[0].save(file_out, save_all=True, - append_images=frames[1:], loop=0, optimize=False, duration=gif.info['duration']) + if gif.mode == "RGBA" or "transparency" in gif.info: + durations = [gif.info['duration'] for _ in range(len(frames))] + save_transparent_gif(frames[1:], durations, file_out) + else: + frames[0].save(file_out, save_all=True, append_images=frames[1:], + loop=0, duration=gif.info['duration']) diff --git a/api/src/transparent_gif_converter.py b/api/src/transparent_gif_converter.py new file mode 100644 index 0000000..fd3a598 --- /dev/null +++ b/api/src/transparent_gif_converter.py @@ -0,0 +1,158 @@ +##################################################################### +# Code from https://github.com/python-pillow/Pillow/issues/4644 # +##################################################################### + + +# This code adapted from https://github.com/python-pillow/Pillow/issues/4644 to resolve an issue +# described in https://github.com/python-pillow/Pillow/issues/4640 +# +# There is a long-standing issue with the Pillow library that messes up GIF transparency by replacing the +# transparent pixels with black pixels (among other issues) when the GIF is saved using PIL.Image.save(). +# This code works around the issue and allows us to properly generate transparent GIFs. + +from typing import Tuple, List, Union +from collections import defaultdict +from random import randrange +from itertools import chain + +from PIL.Image import Image + + +class TransparentAnimatedGifConverter(object): + _PALETTE_SLOTSET = set(range(256)) + + def __init__(self, img_rgba: Image, alpha_threshold: int = 0): + self._img_rgba = img_rgba + self._alpha_threshold = alpha_threshold + + def _process_pixels(self): + """Set the transparent pixels to the color 0.""" + self._transparent_pixels = set( + idx for idx, alpha in enumerate( + self._img_rgba.getchannel(channel='A').getdata()) + if alpha <= self._alpha_threshold) + + 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: + """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], durations: Union[int, List[int]]) -> Tuple[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 = frame.copy() # type: Image + thumbnail_rgba = thumbnail.convert(mode='RGBA') + thumbnail_rgba.thumbnail(size=frame.size, reducing_gap=3.0) + converter = TransparentAnimatedGifConverter(img_rgba=thumbnail_rgba) + thumbnail_p = converter.process() # type: Image + 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=durations, + disposal=2, # Other disposals don't work + loop=0) + return output_image, save_kwargs + + +def save_transparent_gif(images: List[Image], durations: Union[int, List[int]], save_file): + """Creates a transparent GIF, adjusting to avoid transparency issues that are present in the PIL library + + Note that this does NOT work for partial alpha. The partial alpha gets discarded and replaced by solid colors. + + Parameters: + images: a list of PIL Image objects that compose the GIF frames + durations: an int or List[int] that describes the animation durations for the frames of this GIF + save_file: A filename (string), pathlib.Path object or file object. (This parameter corresponds + and is passed to the PIL.Image.save() method.) + Returns: + Image - The PIL Image object (after first saving the image to the specified target) + """ + root_frame, save_args = _create_animated_gif(images, durations) + root_frame.save(save_file, **save_args) diff --git a/frontend/src/components/Heading.svelte b/frontend/src/components/Heading.svelte new file mode 100644 index 0000000..75d1587 --- /dev/null +++ b/frontend/src/components/Heading.svelte @@ -0,0 +1,14 @@ + + +

push("/")}>Caption GIFs

+ + diff --git a/frontend/src/pages/CaptionedGif.svelte b/frontend/src/pages/CaptionedGif.svelte index 951e6b6..711984d 100644 --- a/frontend/src/pages/CaptionedGif.svelte +++ b/frontend/src/pages/CaptionedGif.svelte @@ -5,6 +5,7 @@ import axios from "axios"; import LoadingAnimation from "../components/LoadingAnimation.svelte"; + import Heading from "../components/Heading.svelte"; export let params = {}; @@ -23,8 +24,7 @@
-

Your Captioned GIF

- + {#if !loading} {#if gifFile}
@@ -37,7 +37,7 @@

- + {:else}

Cannot find GIF '{params.filename}'.

@@ -62,13 +62,6 @@ margin: 0 auto 5% auto; } - h1 { - color: white; - - font-size: 5em; - font-weight: 100; - } - .center { margin-top: 5%; margin-bottom: 5%; diff --git a/frontend/src/pages/UploadGif.svelte b/frontend/src/pages/UploadGif.svelte index 1b8847d..261816c 100644 --- a/frontend/src/pages/UploadGif.svelte +++ b/frontend/src/pages/UploadGif.svelte @@ -6,6 +6,7 @@ import axios from "axios"; import LoadingAnimation from "../components/LoadingAnimation.svelte"; + import Heading from "../components/Heading.svelte"; let selectedFiles = []; let gif; @@ -72,7 +73,7 @@
-

Caption GIFs

+
{}}>