Repository: python-pillow/Pillow

Issue:
Create an animated GIF from scratch
Can Pillow create animated GIFs from scratch at all? Like, having a series of 10 images of the same size, can I turn them into frames and save them as an animated GIF? 



Discussion:
Yes you can, check this package https://gist.github.com/jonschoning/7216290 .

No offence, but you are practically writing the GIF by hand byte by byte. I could do that as well, but I'd rather have an abstraction over the format of GIF files, that's why I'm using an imaging library.

Hi. There isn't a simple way to do that at the moment. I'd be interested in putting together a PR, if anyone can think of a good API to use.

To solve your immediate problem, I have copied and modified GifImagePlugin's `_save` method to put together an example script for creating an animated GIF. This could be better, but hopefully you will find it helpful.

```
from PIL import Image, ImageFile, ImageChops
from PIL.GifImagePlugin import _imaging_gif, RAWMODE, _convert_mode, getheader, getdata, get_interlace, _get_local_header

def save_sequence(ims, filename):
    im = ims[0]
    fp = open(filename, 'wb')

    if hasattr(im, 'encoderinfo'):
        im.encoderinfo.update(im.info)
    if _imaging_gif:
        # call external driver
        try:
            _imaging_gif.save(im, fp, filename)
            return
        except IOError:
            pass  # write uncompressed file

    if im.mode in RAWMODE:
        im_out = im.copy()
    else:
        im_out = _convert_mode(im, True)

    # header
    if hasattr(im, 'encoderinfo'):
        try:
            palette = im.encoderinfo["palette"]
        except KeyError:
            palette = None
            im.encoderinfo["optimize"] = im.encoderinfo.get("optimize", True)
    else:
        palette = None
        im.encoderinfo = {}

    save_all = True
    if save_all:
        previous = None

        first_frame = None
        for im_frame in ims:
            im_frame = _convert_mode(im_frame)

            # To specify duration, add the time in milliseconds to getdata(),
            # e.g. getdata(im_frame, duration=1000)
            if not previous:
                # global header
                first_frame = getheader(im_frame, palette, im.encoderinfo)[0]
                first_frame += getdata(im_frame, (0, 0), **im.encoderinfo)
            else:
                if first_frame:
                    for s in first_frame:
                        fp.write(s)
                    first_frame = None

                # delta frame
                delta = ImageChops.subtract_modulo(im_frame, previous.copy())
                bbox = delta.getbbox()

                if bbox:
                    # compress difference
                    for s in getdata(im_frame.crop(bbox),
                                     bbox[:2], **im.encoderinfo):
                        fp.write(s)
                else:
                    # FIXME: what should we do in this case?
                    pass
            previous = im_frame
        if first_frame:
            save_all = False
    if not save_all:
        header = getheader(im_out, palette, im.encoderinfo)[0]
        for s in header:
            fp.write(s)

        flags = 0

        if get_interlace(im):
            flags = flags | 64

        # local image header
        _get_local_header(fp, im, (0, 0), flags)

        im_out.encoderconfig = (8, get_interlace(im))
        ImageFile._save(im_out, fp, [("gif", (0, 0)+im.size, 0,
                                      RAWMODE[im_out.mode])])

        fp.write(b"\0")  # end of image data

    fp.write(b";")  # end of file

    if hasattr(fp, "flush"):
        fp.flush()

ims = [Image.new('RGB', (100, 100), hex) for hex in ['#f00', '#0f0','#0ff','#ff0','#000','#fff','#f0f']]
save_sequence(ims, 'test.gif')
```

You could take a look at Wand, which is another binding to ImageMagick. Here's an example of what creating a GIF looks like there (although the implementation is buggy so I've had to abandon that library):

https://github.com/dahlia/wand/issues/300#issue-166542295

I'd be very thankful if you could make that PR, I'm very interested in this feature.

Okay, I've created a PR. Please provide any feedback you might have.


Resolving patch:
diff --git a/PIL/GifImagePlugin.py b/PIL/GifImagePlugin.py
--- a/PIL/GifImagePlugin.py
+++ b/PIL/GifImagePlugin.py
@@ -351,34 +351,38 @@ def _save(im, fp, filename, save_all=False):
         previous = None
 
         first_frame = None
-        for im_frame in ImageSequence.Iterator(im):
-            im_frame = _convert_mode(im_frame)
-
-            # To specify duration, add the time in milliseconds to getdata(),
-            # e.g. getdata(im_frame, duration=1000)
-            if not previous:
-                # global header
-                first_frame = getheader(im_frame, palette, im.encoderinfo)[0]
-                first_frame += getdata(im_frame, (0, 0), **im.encoderinfo)
-            else:
-                if first_frame:
-                    for s in first_frame:
-                        fp.write(s)
-                    first_frame = None
-
-                # delta frame
-                delta = ImageChops.subtract_modulo(im_frame, previous.copy())
-                bbox = delta.getbbox()
-
-                if bbox:
-                    # compress difference
-                    for s in getdata(im_frame.crop(bbox),
-                                     bbox[:2], **im.encoderinfo):
-                        fp.write(s)
+        append_images = im.encoderinfo.get("append_images", [])
+        for imSequence in [im]+append_images:
+            for im_frame in ImageSequence.Iterator(imSequence):
+                encoderinfo = im.encoderinfo.copy()
+                im_frame = _convert_mode(im_frame)
+
+                # To specify duration, add the time in milliseconds to getdata(),
+                # e.g. getdata(im_frame, duration=1000)
+                if not previous:
+                    # global header
+                    first_frame = getheader(im_frame, palette, encoderinfo)[0]
+                    first_frame += getdata(im_frame, (0, 0), **encoderinfo)
                 else:
-                    # FIXME: what should we do in this case?
-                    pass
-            previous = im_frame
+                    if first_frame:
+                        for s in first_frame:
+                            fp.write(s)
+                        first_frame = None
+
+                    # delta frame
+                    delta = ImageChops.subtract_modulo(im_frame, previous.copy())
+                    bbox = delta.getbbox()
+
+                    if bbox:
+                        # compress difference
+                        encoderinfo['include_color_table'] = True
+                        for s in getdata(im_frame.crop(bbox),
+                                         bbox[:2], **encoderinfo):
+                            fp.write(s)
+                    else:
+                        # FIXME: what should we do in this case?
+                        pass
+                previous = im_frame
         if first_frame:
             save_all = False
     if not save_all:
@@ -455,7 +459,7 @@ def _get_local_header(fp, im, offset, flags):
         fp.write(b"!" +
                  o8(249) +                # extension intro
                  o8(4) +                  # length
-                 o8(transparency_flag) +  # transparency info present
+                 o8(transparency_flag) +  # packed fields
                  o16(duration) +          # duration
                  o8(transparency) +       # transparency index
                  o8(0))
@@ -476,13 +480,27 @@ def _get_local_header(fp, im, offset, flags):
                  o8(1) +
                  o16(number_of_loops) +   # number of loops
                  o8(0))
+    include_color_table = im.encoderinfo.get('include_color_table')
+    if include_color_table:
+        try:
+            palette = im.encoderinfo["palette"]
+        except KeyError:
+            palette = None
+        palette_bytes = _get_palette_bytes(im, palette, im.encoderinfo)[0]
+        color_table_size = _get_color_table_size(palette_bytes)
+        if color_table_size:
+            flags = flags | 128               # local color table flag
+            flags = flags | color_table_size
+
     fp.write(b"," +
              o16(offset[0]) +             # offset
              o16(offset[1]) +
              o16(im.size[0]) +            # size
              o16(im.size[1]) +
-             o8(flags) +                  # flags
-             o8(8))                       # bits
+             o8(flags))                   # flags
+    if include_color_table and color_table_size:
+        fp.write(_get_header_palette(palette_bytes))
+    fp.write(o8(8))                       # bits
 
 
 def _save_netpbm(im, fp, filename):
@@ -550,31 +568,25 @@ def _get_used_palette_colors(im):
 
     return used_palette_colors
 
+def _get_color_table_size(palette_bytes):
+    # calculate the palette size for the header
+    import math
+    color_table_size = int(math.ceil(math.log(len(palette_bytes)//3, 2)))-1
+    if color_table_size < 0:
+        color_table_size = 0
+    return color_table_size
 
-def getheader(im, palette=None, info=None):
-    """Return a list of strings representing a GIF header"""
-
-    # Header Block
-    # http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
+def _get_header_palette(palette_bytes):
+    color_table_size = _get_color_table_size(palette_bytes)
 
-    version = b"87a"
-    for extensionKey in ["transparency", "duration", "loop", "comment"]:
-        if info and extensionKey in info:
-            if ((extensionKey == "duration" and info[extensionKey] == 0) or
-                (extensionKey == "comment" and not (1 <= len(info[extensionKey]) <= 255))):
-                continue
-            version = b"89a"
-            break
-    else:
-        if im.info.get("version") == "89a":
-            version = b"89a"
-
-    header = [
-        b"GIF"+version +        # signature + version
-        o16(im.size[0]) +       # canvas width
-        o16(im.size[1])         # canvas height
-    ]
+    # add the missing amount of bytes
+    # the palette has to be 2<<n in size
+    actual_target_size_diff = (2 << color_table_size) - len(palette_bytes)//3
+    if actual_target_size_diff > 0:
+        palette_bytes += o8(0) * 3 * actual_target_size_diff
+    return palette_bytes
 
+def _get_palette_bytes(im, palette, info):
     if im.mode == "P":
         if palette and isinstance(palette, bytes):
             source_palette = palette[:768]
@@ -617,15 +629,38 @@ def getheader(im, palette=None, info=None):
 
     if not palette_bytes:
         palette_bytes = source_palette
+    return palette_bytes, used_palette_colors
+
+def getheader(im, palette=None, info=None):
+    """Return a list of strings representing a GIF header"""
+
+    # Header Block
+    # http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp
+
+    version = b"87a"
+    for extensionKey in ["transparency", "duration", "loop", "comment"]:
+        if info and extensionKey in info:
+            if ((extensionKey == "duration" and info[extensionKey] == 0) or
+                (extensionKey == "comment" and not (1 <= len(info[extensionKey]) <= 255))):
+                continue
+            version = b"89a"
+            break
+    else:
+        if im.info.get("version") == "89a":
+            version = b"89a"
+
+    header = [
+        b"GIF"+version +        # signature + version
+        o16(im.size[0]) +       # canvas width
+        o16(im.size[1])         # canvas height
+    ]
+
+    palette_bytes, used_palette_colors = _get_palette_bytes(im, palette, info)
 
     # Logical Screen Descriptor
-    # calculate the palette size for the header
-    import math
-    color_table_size = int(math.ceil(math.log(len(palette_bytes)//3, 2)))-1
-    if color_table_size < 0:
-        color_table_size = 0
+    color_table_size = _get_color_table_size(palette_bytes)
     # size of global color table + global color table flag
-    header.append(o8(color_table_size + 128))
+    header.append(o8(color_table_size + 128))  # packed fields
     # background + reserved/aspect
     if info and "background" in info:
         background = info["background"]
@@ -640,14 +675,8 @@ def getheader(im, palette=None, info=None):
     header.append(o8(background) + o8(0))
     # end of Logical Screen Descriptor
 
-    # add the missing amount of bytes
-    # the palette has to be 2<<n in size
-    actual_target_size_diff = (2 << color_table_size) - len(palette_bytes)//3
-    if actual_target_size_diff > 0:
-        palette_bytes += o8(0) * 3 * actual_target_size_diff
-
     # Header + Logical Screen Descriptor + Global Color Table
-    header.append(palette_bytes)
+    header.append(_get_header_palette(palette_bytes))
     return header, used_palette_colors
 
 


Test patch:
diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py
--- a/Tests/test_file_gif.py
+++ b/Tests/test_file_gif.py
@@ -306,6 +306,24 @@ def test_version(self):
         reread = Image.open(out)
         self.assertEqual(reread.info["version"], b"GIF87a")
 
+    def test_append_images(self):
+        out = self.tempfile('temp.gif')
+
+        # Test appending single frame images
+        im = Image.new('RGB', (100, 100), '#f00')
+        ims = [Image.new('RGB', (100, 100), color) for color in ['#0f0', '#00f']]
+        im.save(out, save_all=True, append_images=ims)
+
+        reread = Image.open(out)
+        self.assertEqual(reread.n_frames, 3)
+
+        # Tests appending single and multiple frame images
+        im = Image.open("Tests/images/dispose_none.gif")
+        ims = [Image.open("Tests/images/dispose_prev.gif")]
+        im.save(out, save_all=True, append_images=ims)
+
+        reread = Image.open(out)
+        self.assertEqual(reread.n_frames, 10)
 
 if __name__ == '__main__':
     unittest.main()