Repository: python-pillow/Pillow

Problem statement:
Add pickle support
Currently `Image` instances are not pickle-able.

It should be relatively simple to to implement pickle support on top of the existing `tobytes()` and `frombytes()` methods. 



Hints:
:+1: 


Patch:
diff --git a/PIL/Image.py b/PIL/Image.py
--- a/PIL/Image.py
+++ b/PIL/Image.py
@@ -30,6 +30,7 @@
 
 import warnings
 
+
 class _imaging_not_installed:
     # module placeholder
     def __getattr__(self, id):
@@ -52,8 +53,8 @@ def __getattr__(self, id):
     # directly; import Image and use the Image.core variable instead.
     from PIL import _imaging as core
     if PILLOW_VERSION != getattr(core, 'PILLOW_VERSION', None):
-         raise ImportError("The _imaging extension was built for another "
-                            " version of Pillow or PIL")
+        raise ImportError("The _imaging extension was built for another "
+                          " version of Pillow or PIL")
 
 except ImportError as v:
     core = _imaging_not_installed()
@@ -94,19 +95,21 @@ def __getattr__(self, id):
 from PIL._binary import i8, o8
 from PIL._util import isPath, isStringType, deferred_error
 
-import os, sys
+import os
+import sys
 
 # type stuff
 import collections
 import numbers
 
 # works everywhere, win for pypy, not cpython
-USE_CFFI_ACCESS = hasattr(sys, 'pypy_version_info')  
+USE_CFFI_ACCESS = hasattr(sys, 'pypy_version_info')
 try:
     import cffi
-    HAS_CFFI=True
+    HAS_CFFI = True
 except:
-    HAS_CFFI=False
+    HAS_CFFI = False
+
 
 def isImageType(t):
     """
@@ -148,16 +151,16 @@ def isImageType(t):
 # resampling filters
 NONE = 0
 NEAREST = 0
-ANTIALIAS = 1 # 3-lobed lanczos
+ANTIALIAS = 1  # 3-lobed lanczos
 LINEAR = BILINEAR = 2
 CUBIC = BICUBIC = 3
 
 # dithers
 NONE = 0
 NEAREST = 0
-ORDERED = 1 # Not yet implemented
-RASTERIZE = 2 # Not yet implemented
-FLOYDSTEINBERG = 3 # default
+ORDERED = 1  # Not yet implemented
+RASTERIZE = 2  # Not yet implemented
+FLOYDSTEINBERG = 3  # default
 
 # palettes/quantizers
 WEB = 0
@@ -222,7 +225,7 @@ def isImageType(t):
 
 _MODE_CONV = {
     # official modes
-    "1": ('|b1', None), # broken
+    "1": ('|b1', None),  # broken
     "L": ('|u1', None),
     "I": (_ENDIAN + 'i4', None),
     "F": (_ENDIAN + 'f4', None),
@@ -232,8 +235,8 @@ def isImageType(t):
     "RGBA": ('|u1', 4),
     "CMYK": ('|u1', 4),
     "YCbCr": ('|u1', 3),
-    "LAB": ('|u1', 3), # UNDONE - unsigned |u1i1i1
-	# I;16 == I;16L, and I;32 == I;32L  
+    "LAB": ('|u1', 3),  # UNDONE - unsigned |u1i1i1
+    # I;16 == I;16L, and I;32 == I;32L
     "I;16": ('<u2', None),
     "I;16B": ('>u2', None),
     "I;16L": ('<u2', None),
@@ -248,6 +251,7 @@ def isImageType(t):
     "I;32LS": ('<i4', None),
 }
 
+
 def _conv_type_shape(im):
     shape = im.size[1], im.size[0]
     typ, extra = _MODE_CONV[im.mode]
@@ -368,8 +372,8 @@ def init():
     for plugin in _plugins:
         try:
             if DEBUG:
-                print ("Importing %s"%plugin)
-            __import__("PIL.%s"%plugin, globals(), locals(), [])
+                print ("Importing %s" % plugin)
+            __import__("PIL.%s" % plugin, globals(), locals(), [])
         except ImportError:
             if DEBUG:
                 print("Image: failed to import", end=' ')
@@ -379,6 +383,7 @@ def init():
         _initialized = 2
         return 1
 
+
 # --------------------------------------------------------------------
 # Codec factories (used by tobytes/frombytes and ImageFile.load)
 
@@ -398,6 +403,7 @@ def _getdecoder(mode, decoder_name, args, extra=()):
     except AttributeError:
         raise IOError("decoder %s not available" % decoder_name)
 
+
 def _getencoder(mode, encoder_name, args, extra=()):
 
     # tweak arguments
@@ -421,30 +427,36 @@ def _getencoder(mode, encoder_name, args, extra=()):
 def coerce_e(value):
     return value if isinstance(value, _E) else _E(value)
 
+
 class _E:
     def __init__(self, data):
         self.data = data
+
     def __add__(self, other):
         return _E((self.data, "__add__", coerce_e(other).data))
+
     def __mul__(self, other):
         return _E((self.data, "__mul__", coerce_e(other).data))
 
+
 def _getscaleoffset(expr):
     stub = ["stub"]
     data = expr(_E(stub)).data
     try:
-        (a, b, c) = data # simplified syntax
+        (a, b, c) = data  # simplified syntax
         if (a is stub and b == "__mul__" and isinstance(c, numbers.Number)):
             return c, 0.0
         if (a is stub and b == "__add__" and isinstance(c, numbers.Number)):
             return 1.0, c
-    except TypeError: pass
+    except TypeError:
+        pass
     try:
-        ((a, b, c), d, e) = data # full syntax
+        ((a, b, c), d, e) = data  # full syntax
         if (a is stub and b == "__mul__" and isinstance(c, numbers.Number) and
-            d == "__add__" and isinstance(e, numbers.Number)):
+                d == "__add__" and isinstance(e, numbers.Number)):
             return c, e
-    except TypeError: pass
+    except TypeError:
+        pass
     raise ValueError("illegal expression")
 
 
@@ -495,14 +507,15 @@ def _new(self, im):
                 new.info[k] = v
         return new
 
-    _makeself = _new # compatibility
+    _makeself = _new  # compatibility
 
     # Context Manager Support
     def __enter__(self):
         return self
+
     def __exit__(self, *args):
         self.close()
-        
+
     def close(self):
         """
         Closes the file pointer, if possible.
@@ -518,13 +531,12 @@ def close(self):
             self.fp.close()
         except Exception as msg:
             if Image.DEBUG:
-                print ("Error closing: %s" %msg)
+                print("Error closing: %s" % msg)
 
         # Instead of simply setting to None, we're setting up a
         # deferred error that will better explain that the core image
         # object is gone.
         self.im = deferred_error(ValueError("Operation on closed image"))
-        
 
     def _copy(self):
         self.load()
@@ -533,14 +545,15 @@ def _copy(self):
         self.readonly = 0
 
     def _dump(self, file=None, format=None):
-        import tempfile, os
+        import tempfile
+        import os
         suffix = ''
         if format:
             suffix = '.'+format
         if not file:
             f, file = tempfile.mkstemp(suffix)
             os.close(f)
-            
+
         self.load()
         if not format or format == "PPM":
             self.im.save_ppm(file)
@@ -550,6 +563,20 @@ def _dump(self, file=None, format=None):
             self.save(file, format)
         return file
 
+    def __eq__(self, other):
+        a = (self.mode == other.mode)
+        b = (self.size == other.size)
+        c = (self.getpalette() == other.getpalette())
+        d = (self.info == other.info)
+        e = (self.category == other.category)
+        f = (self.readonly == other.readonly)
+        g = (self.tobytes() == other.tobytes())
+        return a and b and c and d and e and f and g
+
+    def __ne__(self, other):
+        eq = (self == other)
+        return not eq
+
     def __repr__(self):
         return "<%s.%s image mode=%s size=%dx%d at 0x%X>" % (
             self.__class__.__module__, self.__class__.__name__,
@@ -568,6 +595,26 @@ def __getattr__(self, name):
             return new
         raise AttributeError(name)
 
+    def __getstate__(self):
+        return [
+            self.info,
+            self.mode,
+            self.size,
+            self.getpalette(),
+            self.tobytes()]
+
+    def __setstate__(self, state):
+        Image.__init__(self)
+        self.tile = []
+        info, mode, size, palette, data = state
+        self.info = info
+        self.mode = mode
+        self.size = size
+        self.im = core.new(mode, size)
+        if mode in ("L", "P"):
+            self.putpalette(palette)
+        self.frombytes(data)
+
     def tobytes(self, encoder_name="raw", *args):
         """
         Return image as a bytes object
@@ -591,7 +638,7 @@ def tobytes(self, encoder_name="raw", *args):
         e = _getencoder(self.mode, encoder_name, args)
         e.setimage(self.im)
 
-        bufsize = max(65536, self.size[0] * 4) # see RawEncode.c
+        bufsize = max(65536, self.size[0] * 4)  # see RawEncode.c
 
         data = []
         while True:
@@ -628,9 +675,11 @@ def tobitmap(self, name="image"):
         if self.mode != "1":
             raise ValueError("not a bitmap")
         data = self.tobytes("xbm")
-        return b"".join([("#define %s_width %d\n" % (name, self.size[0])).encode('ascii'),
-                ("#define %s_height %d\n"% (name, self.size[1])).encode('ascii'),
-                ("static char %s_bits[] = {\n" % name).encode('ascii'), data, b"};"])
+        return b"".join([
+            ("#define %s_width %d\n" % (name, self.size[0])).encode('ascii'),
+            ("#define %s_height %d\n" % (name, self.size[1])).encode('ascii'),
+            ("static char %s_bits[] = {\n" % name).encode('ascii'), data, b"};"
+            ])
 
     def frombytes(self, data, decoder_name="raw", *args):
         """
@@ -663,7 +712,9 @@ def fromstring(self, *args, **kw):
 
         .. deprecated:: 2.0
         """
-        warnings.warn('fromstring() is deprecated. Please call frombytes() instead.', DeprecationWarning)
+        warnings.warn(
+            'fromstring() is deprecated. Please call frombytes() instead.',
+            DeprecationWarning)
         return self.frombytes(*args, **kw)
 
     def load(self):
@@ -672,7 +723,7 @@ def load(self):
         normal cases, you don't need to call this method, since the
         Image class automatically loads an opened image when it is
         accessed for the first time. This method will close the file
-        associated with the image. 
+        associated with the image.
 
         :returns: An image access object.
         """
@@ -774,36 +825,37 @@ def convert(self, mode=None, matrix=None, dither=None,
         trns = None
         delete_trns = False
         # transparency handling
-        if "transparency" in self.info and self.info['transparency'] is not None:
+        if "transparency" in self.info and \
+                self.info['transparency'] is not None:
             if self.mode in ('L', 'RGB') and mode == 'RGBA':
                 # Use transparent conversion to promote from transparent
-                # color to an alpha channel.         
+                # color to an alpha channel.
                 return self._new(self.im.convert_transparent(
-                                           mode, self.info['transparency']))
+                    mode, self.info['transparency']))
             elif self.mode in ('L', 'RGB', 'P') and mode in ('L', 'RGB', 'P'):
                 t = self.info['transparency']
                 if isinstance(t, bytes):
                     # Dragons. This can't be represented by a single color
-                    warnings.warn('Palette images with Transparency expressed '+
-                                  ' in bytes should be converted to RGBA images')
+                    warnings.warn(
+                        'Palette images with Transparency expressed ' +
+                        ' in bytes should be converted to RGBA images')
                     delete_trns = True
                 else:
                     # get the new transparency color.
                     # use existing conversions
-                    trns_im = Image()._new(core.new(self.mode, (1,1)))
+                    trns_im = Image()._new(core.new(self.mode, (1, 1)))
                     if self.mode == 'P':
                         trns_im.putpalette(self.palette)
-                    trns_im.putpixel((0,0), t)
+                    trns_im.putpixel((0, 0), t)
 
-                    if mode in ('L','RGB'):
+                    if mode in ('L', 'RGB'):
                         trns_im = trns_im.convert(mode)
                     else:
                         # can't just retrieve the palette number, got to do it
-                        # after quantization. 
+                        # after quantization.
                         trns_im = trns_im.convert('RGB')
-                    trns = trns_im.getpixel((0,0))
-                        
-                    
+                    trns = trns_im.getpixel((0, 0))
+
         if mode == "P" and palette == ADAPTIVE:
             im = self.im.quantize(colors)
             new = self._new(im)
@@ -811,7 +863,7 @@ def convert(self, mode=None, matrix=None, dither=None,
             new.palette = ImagePalette.raw("RGB", new.im.getpalette("RGB"))
             if delete_trns:
                 # This could possibly happen if we requantize to fewer colors.
-                # The transparency would be totally off in that case. 
+                # The transparency would be totally off in that case.
                 del(new.info['transparency'])
             if trns is not None:
                 try:
@@ -820,13 +872,14 @@ def convert(self, mode=None, matrix=None, dither=None,
                     # if we can't make a transparent color, don't leave the old
                     # transparency hanging around to mess us up.
                     del(new.info['transparency'])
-                    warnings.warn("Couldn't allocate palette entry for transparency")
+                    warnings.warn("Couldn't allocate palette entry " +
+                                  "for transparency")
             return new
 
         # colorspace conversion
         if dither is None:
             dither = FLOYDSTEINBERG
-                
+
         try:
             im = self.im.convert(mode, dither)
         except ValueError:
@@ -839,7 +892,7 @@ def convert(self, mode=None, matrix=None, dither=None,
 
         new_im = self._new(im)
         if delete_trns:
-            #crash fail if we leave a bytes transparency in an rgb/l mode.
+            # crash fail if we leave a bytes transparency in an rgb/l mode.
             del(new_im.info['transparency'])
         if trns is not None:
             if new_im.mode == 'P':
@@ -847,7 +900,8 @@ def convert(self, mode=None, matrix=None, dither=None,
                     new_im.info['transparency'] = new_im.palette.getcolor(trns)
                 except:
                     del(new_im.info['transparency'])
-                    warnings.warn("Couldn't allocate palette entry for transparency")
+                    warnings.warn("Couldn't allocate palette entry " +
+                                  "for transparency")
             else:
                 new_im.info['transparency'] = trns
         return new_im
@@ -863,7 +917,7 @@ def quantize(self, colors=256, method=None, kmeans=0, palette=None):
         # quantizer interface in a later version of PIL.
 
         self.load()
-        
+
         if method is None:
             # defaults:
             method = 0
@@ -871,10 +925,10 @@ def quantize(self, colors=256, method=None, kmeans=0, palette=None):
                 method = 2
 
         if self.mode == 'RGBA' and method != 2:
-            # Caller specified an invalid mode. 
+            # Caller specified an invalid mode.
             raise ValueError('Fast Octree (method == 2) is the ' +
                              ' only valid method for quantizing RGBA images')
-        
+
         if palette:
             # use palette from reference image
             palette.load()
@@ -928,7 +982,7 @@ def crop(self, box=None):
     def draft(self, mode, size):
         """
         NYI
-        
+
         Configures the image file loader so it returns a version of the
         image that as closely as possible matches the given mode and
         size.  For example, you can use this method to convert a color
@@ -963,7 +1017,8 @@ def filter(self, filter):
         if isinstance(filter, collections.Callable):
             filter = filter()
         if not hasattr(filter, "filter"):
-            raise TypeError("filter argument should be ImageFilter.Filter instance or class")
+            raise TypeError("filter argument should be ImageFilter.Filter " +
+                            "instance or class")
 
         if self.im.bands == 1:
             return self._new(filter.filter(self.im))
@@ -1019,7 +1074,7 @@ def getcolors(self, maxcolors=256):
             return out
         return self.im.getcolors(maxcolors)
 
-    def getdata(self, band = None):
+    def getdata(self, band=None):
         """
         Returns the contents of this image as a sequence object
         containing pixel values.  The sequence object is flattened, so
@@ -1040,7 +1095,7 @@ def getdata(self, band = None):
         self.load()
         if band is not None:
             return self.im.getband(band)
-        return self.im # could be abused
+        return self.im  # could be abused
 
     def getextrema(self):
         """
@@ -1070,7 +1125,6 @@ def getim(self):
         self.load()
         return self.im.ptr
 
-
     def getpalette(self):
         """
         Returns the image palette as a list.
@@ -1086,8 +1140,7 @@ def getpalette(self):
             else:
                 return list(self.im.getpalette())
         except ValueError:
-            return None # no palette
-
+            return None  # no palette
 
     def getpixel(self, xy):
         """
@@ -1209,7 +1262,8 @@ def paste(self, im, box=None, mask=None):
 
         if isImageType(box) and mask is None:
             # abbreviated paste(im, mask) syntax
-            mask = box; box = None
+            mask = box
+            box = None
 
         if box is None:
             # cover all of self
@@ -1277,7 +1331,7 @@ def point(self, lut, mode=None):
             if self.mode in ("I", "I;16", "F"):
                 # check if the function can be used with point_transform
                 # UNDONE wiredfool -- I think this prevents us from ever doing
-                # a gamma function point transform on > 8bit images. 
+                # a gamma function point transform on > 8bit images.
                 scale, offset = _getscaleoffset(lut)
                 return self._new(self.im.point_transform(scale, offset))
             # for other modes, convert the function to a table
@@ -1315,7 +1369,7 @@ def putalpha(self, alpha):
                     # do things the hard way
                     im = self.im.convert(mode)
                     if im.mode not in ("LA", "RGBA"):
-                        raise ValueError # sanity check
+                        raise ValueError  # sanity check
                     self.im = im
                     self.pyaccess = None
                 self.mode = self.im.mode
@@ -1393,7 +1447,7 @@ def putpalette(self, data, rawmode="RGB"):
         self.mode = "P"
         self.palette = palette
         self.palette.mode = "RGB"
-        self.load() # install new palette
+        self.load()  # install new palette
 
     def putpixel(self, xy, value):
         """
@@ -1420,9 +1474,9 @@ def putpixel(self, xy, value):
             self._copy()
             self.pyaccess = None
             self.load()
-            
-        if self.pyaccess: 
-            return self.pyaccess.putpixel(xy,value)
+
+        if self.pyaccess:
+            return self.pyaccess.putpixel(xy, value)
         return self.im.putpixel(xy, value)
 
     def resize(self, size, resample=NEAREST):
@@ -1489,9 +1543,10 @@ def rotate(self, angle, resample=NEAREST, expand=0):
             import math
             angle = -angle * math.pi / 180
             matrix = [
-                 math.cos(angle), math.sin(angle), 0.0,
+                math.cos(angle), math.sin(angle), 0.0,
                 -math.sin(angle), math.cos(angle), 0.0
-                 ]
+                ]
+
             def transform(x, y, matrix=matrix):
                 (a, b, c, d, e, f) = matrix
                 return a*x + b*y + c, d*x + e*y + f
@@ -1579,13 +1634,13 @@ def save(self, fp, format=None, **params):
                 try:
                     format = EXTENSION[ext]
                 except KeyError:
-                    raise KeyError(ext) # unknown extension
+                    raise KeyError(ext)  # unknown extension
 
         try:
             save_handler = SAVE[format.upper()]
         except KeyError:
             init()
-            save_handler = SAVE[format.upper()] # unknown format
+            save_handler = SAVE[format.upper()]  # unknown format
 
         if isPath(fp):
             fp = builtins.open(fp, "wb")
@@ -1682,8 +1737,9 @@ def thumbnail(self, size, resample=NEAREST):
         important than quality.
 
         Also note that this function modifies the :py:class:`~PIL.Image.Image`
-        object in place.  If you need to use the full resolution image as well, apply
-        this method to a :py:meth:`~PIL.Image.Image.copy` of the original image.
+        object in place.  If you need to use the full resolution image as well,
+        apply this method to a :py:meth:`~PIL.Image.Image.copy` of the
+        original image.
 
         :param size: Requested size.
         :param resample: Optional resampling filter.  This can be one
@@ -1700,8 +1756,12 @@ def thumbnail(self, size, resample=NEAREST):
 
         # preserve aspect ratio
         x, y = self.size
-        if x > size[0]: y = int(max(y * size[0] / x, 1)); x = int(size[0])
-        if y > size[1]: x = int(max(x * size[1] / y, 1)); y = int(size[1])
+        if x > size[0]:
+            y = int(max(y * size[0] / x, 1))
+            x = int(size[0])
+        if y > size[1]:
+            x = int(max(x * size[1] / y, 1))
+            y = int(size[1])
         size = x, y
 
         if size == self.size:
@@ -1716,7 +1776,7 @@ def thumbnail(self, size, resample=NEAREST):
         except ValueError:
             if resample != ANTIALIAS:
                 raise
-            im = self.resize(size, NEAREST) # fallback
+            im = self.resize(size, NEAREST)  # fallback
 
         self.im = im.im
         self.mode = im.mode
@@ -1752,7 +1812,8 @@ def transform(self, size, method, data=None, resample=NEAREST, fill=1):
         """
 
         if self.mode == 'RGBA':
-            return self.convert('RGBa').transform(size, method, data, resample, fill).convert('RGBA')
+            return self.convert('RGBa').transform(
+                size, method, data, resample, fill).convert('RGBA')
 
         if isinstance(method, ImageTransformHandler):
             return method.transform(size, self, resample=resample, fill=fill)
@@ -1799,8 +1860,13 @@ def __transformer(self, box, image, method, data,
         elif method == QUAD:
             # quadrilateral warp.  data specifies the four corners
             # given as NW, SW, SE, and NE.
-            nw = data[0:2]; sw = data[2:4]; se = data[4:6]; ne = data[6:8]
-            x0, y0 = nw; As = 1.0 / w; At = 1.0 / h
+            nw = data[0:2]
+            sw = data[2:4]
+            se = data[4:6]
+            ne = data[6:8]
+            x0, y0 = nw
+            As = 1.0 / w
+            At = 1.0 / h
             data = (x0, (ne[0]-x0)*As, (sw[0]-x0)*At,
                     (se[0]-sw[0]-ne[0]+x0)*As*At,
                     y0, (ne[1]-y0)*As, (sw[1]-y0)*At,
@@ -1834,6 +1900,7 @@ def transpose(self, method):
         im = self.im.transpose(method)
         return self._new(im)
 
+
 # --------------------------------------------------------------------
 # Lazy operations
 
@@ -1869,6 +1936,7 @@ def load(self):
         # FIXME: future versions should optimize crop/paste
         # sequences!
 
+
 # --------------------------------------------------------------------
 # Abstract handlers.
 
@@ -1876,10 +1944,12 @@ class ImagePointHandler:
     # used as a mixin by point transforms (for use with im.point)
     pass
 
+
 class ImageTransformHandler:
     # used as a mixin by geometry transforms (for use with im.transform)
     pass
 
+
 # --------------------------------------------------------------------
 # Factories
 
@@ -1955,6 +2025,7 @@ def frombytes(mode, size, data, decoder_name="raw", *args):
     im.frombytes(data, decoder_name, args)
     return im
 
+
 def fromstring(*args, **kw):
     """Deprecated alias to frombytes.
 
@@ -2017,9 +2088,9 @@ def frombuffer(mode, size, data, decoder_name="raw", *args):
                     "  frombuffer(mode, size, data, 'raw', mode, 0, 1)",
                     RuntimeWarning, stacklevel=2
                 )
-            args = mode, 0, -1 # may change to (mode, 0, 1) post-1.1.6
+            args = mode, 0, -1  # may change to (mode, 0, 1) post-1.1.6
         if args[0] in _MAPMODES:
-            im = new(mode, (1,1))
+            im = new(mode, (1, 1))
             im = im._new(
                 core.map_buffer(data, size, decoder_name, None, 0, args)
                 )
@@ -2139,8 +2210,8 @@ def open(fp, mode="r"):
                 fp.seek(0)
                 return factory(fp, filename)
         except (SyntaxError, IndexError, TypeError):
-            #import traceback
-            #traceback.print_exc()
+            # import traceback
+            # traceback.print_exc()
             pass
 
     if init():
@@ -2152,13 +2223,14 @@ def open(fp, mode="r"):
                     fp.seek(0)
                     return factory(fp, filename)
             except (SyntaxError, IndexError, TypeError):
-                #import traceback
-                #traceback.print_exc()
+                # import traceback
+                # traceback.print_exc()
                 pass
 
     raise IOError("cannot identify image file %r"
                   % (filename if filename else fp))
 
+
 #
 # Image processing.
 
@@ -2257,6 +2329,7 @@ def merge(mode, bands):
         im.putband(bands[i].im, i)
     return bands[0]._new(im)
 
+
 # --------------------------------------------------------------------
 # Plugin registry
 
@@ -2315,6 +2388,7 @@ def _show(image, **options):
     # override me, as necessary
     _showxv(image, **options)
 
+
 def _showxv(image, title=None, **options):
     from PIL import ImageShow
     ImageShow.show(image, title, **options)


Test patch:
diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py
new file mode 100644
--- /dev/null
+++ b/Tests/test_pickle.py
@@ -0,0 +1,70 @@
+from tester import *
+
+from PIL import Image
+
+
+def helper_test_pickle_file(pickle, protocol=0):
+    im = Image.open('Images/lena.jpg')
+    filename = tempfile('temp.pkl')
+
+    # Act
+    with open(filename, 'wb') as f:
+        pickle.dump(im, f, protocol)
+    with open(filename, 'rb') as f:
+        loaded_im = pickle.load(f)
+
+    # Assert
+    assert_image_completely_equal(im, loaded_im)
+
+
+def helper_test_pickle_string(pickle, protocol=0, file='Images/lena.jpg'):
+    im = Image.open(file)
+
+    # Act
+    dumped_string = pickle.dumps(im, protocol)
+    loaded_im = pickle.loads(dumped_string)
+
+    # Assert
+    assert_image_completely_equal(im, loaded_im)
+
+
+def test_pickle_image():
+    # Arrange
+    import pickle
+
+    # Act / Assert
+    for protocol in range(0, pickle.HIGHEST_PROTOCOL + 1):
+        helper_test_pickle_string(pickle, protocol)
+        helper_test_pickle_file(pickle, protocol)
+
+
+def test_cpickle_image():
+    # Arrange
+    try:
+        import cPickle
+    except ImportError:
+        return
+
+    # Act / Assert
+    for protocol in range(0, cPickle.HIGHEST_PROTOCOL + 1):
+        helper_test_pickle_string(cPickle, protocol)
+        helper_test_pickle_file(cPickle, protocol)
+
+
+def test_pickle_p_mode():
+    # Arrange
+    import pickle
+
+    # Act / Assert
+    for file in [
+            "Tests/images/test-card.png",
+            "Tests/images/zero_bb.png",
+            "Tests/images/zero_bb_scale2.png",
+            "Tests/images/non_zero_bb.png",
+            "Tests/images/non_zero_bb_scale2.png",
+            "Tests/images/p_trns_single.png",
+            "Tests/images/pil123p.png"
+    ]:
+        helper_test_pickle_string(pickle, file=file)
+
+# End of file
diff --git a/Tests/tester.py b/Tests/tester.py
--- a/Tests/tester.py
+++ b/Tests/tester.py
@@ -242,7 +242,13 @@ def assert_image_equal(a, b, msg=None):
         failure(msg or "got size %r, expected %r" % (a.size, b.size))
     elif a.tobytes() != b.tobytes():
         failure(msg or "got different content")
-        # generate better diff?
+    else:
+        success()
+
+
+def assert_image_completely_equal(a, b, msg=None):
+    if a != b:
+        failure(msg or "images different")
     else:
         success()