Skip to content

Commit

Permalink
fixed distorted transparent GIF issue by using different save functio…
Browse files Browse the repository at this point in the history
  • Loading branch information
zspiler committed Jun 9, 2022
1 parent 7fab342 commit bf5ab66
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 14 deletions.
12 changes: 9 additions & 3 deletions api/src/caption.py
Expand Up @@ -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__))

Expand All @@ -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(
Expand Down Expand Up @@ -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'])
158 changes: 158 additions & 0 deletions 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)
14 changes: 14 additions & 0 deletions frontend/src/components/Heading.svelte
@@ -0,0 +1,14 @@
<script>
import { push } from "svelte-spa-router";
</script>

<h1 on:click={() => push("/")}>Caption GIFs</h1>

<style>
h1 {
color: white;
font-size: 5em;
font-weight: 100;
cursor: pointer;
}
</style>
13 changes: 3 additions & 10 deletions frontend/src/pages/CaptionedGif.svelte
Expand Up @@ -5,6 +5,7 @@
import axios from "axios";
import LoadingAnimation from "../components/LoadingAnimation.svelte";
import Heading from "../components/Heading.svelte";
export let params = {};
Expand All @@ -23,8 +24,7 @@
</script>

<main>
<h1>Your Captioned GIF</h1>

<Heading />
{#if !loading}
{#if gifFile}
<div class="gif" in:fade>
Expand All @@ -37,7 +37,7 @@
<br />
<button>Download</button>
<br />
<button on:click={() => push("/")}>Upload Another GIF</button>
<button on:click={() => push("/")}>Upload another GIF</button>
{:else}
<div class="center">
<h3>Cannot find GIF '{params.filename}'.</h3>
Expand All @@ -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%;
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/pages/UploadGif.svelte
Expand Up @@ -6,6 +6,7 @@
import axios from "axios";
import LoadingAnimation from "../components/LoadingAnimation.svelte";
import Heading from "../components/Heading.svelte";
let selectedFiles = [];
let gif;
Expand Down Expand Up @@ -72,7 +73,7 @@
</script>

<main>
<h1>Caption GIFs</h1>
<Heading />

<form on:submit|preventDefault={() => {}}>
<input
Expand Down Expand Up @@ -112,6 +113,10 @@
margin: 0 auto 5% auto;
}
img {
height: 10%;
}
h1 {
color: white;
Expand Down

0 comments on commit bf5ab66

Please sign in to comment.