""" PIL formats for multiple images. """ import logging import numpy as np from .pillow import PillowFormat, ndarray_to_pil, image_as_uint logger = logging.getLogger(__name__) NeuQuant = None # we can implement this when we need it class TIFFFormat(PillowFormat): _modes = "i" # arg, why bother; people should use the tiffile version _description = "TIFF format (Pillow)" class GIFFormat(PillowFormat): """ A format for reading and writing static and animated GIF, based on Pillow. Images read with this format are always RGBA. Currently, the alpha channel is ignored when saving RGB images with this format. Parameters for reading ---------------------- None Parameters for saving --------------------- loop : int The number of iterations. Default 0 (meaning loop indefinitely). duration : {float, list} The duration (in seconds) of each frame. Either specify one value that is used for all frames, or one value for each frame. Note that in the GIF format the duration/delay is expressed in hundredths of a second, which limits the precision of the duration. fps : float The number of frames per second. If duration is not given, the duration for each frame is set to 1/fps. Default 10. palettesize : int The number of colors to quantize the image to. Is rounded to the nearest power of two. Default 256. subrectangles : bool If True, will try and optimize the GIF by storing only the rectangular parts of each frame that change with respect to the previous. Default False. """ _modes = "iI" _description = "Static and animated gif (Pillow)" class Reader(PillowFormat.Reader): def _open(self, playback=None): # compat with FI format return PillowFormat.Reader._open(self) class Writer(PillowFormat.Writer): def _open( self, loop=0, duration=None, fps=10, palettesize=256, quantizer=0, subrectangles=False, ): # Check palettesize palettesize = int(palettesize) if palettesize < 2 or palettesize > 256: raise ValueError("GIF quantize param must be 2..256") if palettesize not in [2, 4, 8, 16, 32, 64, 128, 256]: palettesize = 2 ** int(np.log2(128) + 0.999) logger.warning( "Warning: palettesize (%r) modified to a factor of " "two between 2-256." % palettesize ) # Duratrion / fps if duration is None: self._duration = 1.0 / float(fps) elif isinstance(duration, (list, tuple)): self._duration = [float(d) for d in duration] else: self._duration = float(duration) # loop loop = float(loop) if loop <= 0 or loop == float("inf"): loop = 0 loop = int(loop) # Subrectangles / dispose subrectangles = bool(subrectangles) self._dispose = 1 if subrectangles else 2 # The "0" (median cut) quantizer is by far the best fp = self.request.get_file() self._writer = GifWriter( fp, subrectangles, loop, quantizer, int(palettesize) ) def _close(self): self._writer.close() def _append_data(self, im, meta): im = image_as_uint(im, bitdepth=8) if im.ndim == 3 and im.shape[-1] == 1: im = im[:, :, 0] duration = self._duration if isinstance(duration, list): duration = duration[min(len(duration) - 1, self._writer._count)] dispose = self._dispose self._writer.add_image(im, duration, dispose) return intToBin = lambda i: i.to_bytes(2, byteorder="little") class GifWriter: """ Class that for helping write the animated GIF file. This is based on code from images2gif.py (part of visvis). The version here is modified to allow streamed writing. """ def __init__( self, file, opt_subrectangle=True, opt_loop=0, opt_quantizer=0, opt_palette_size=256, ): self.fp = file self.opt_subrectangle = opt_subrectangle self.opt_loop = opt_loop self.opt_quantizer = opt_quantizer self.opt_palette_size = opt_palette_size self._previous_image = None # as np array self._global_palette = None # as bytes self._count = 0 from PIL.GifImagePlugin import getdata self.getdata = getdata def add_image(self, im, duration, dispose): # Prepare image im_rect, rect = im, (0, 0) if self.opt_subrectangle: im_rect, rect = self.getSubRectangle(im) im_pil = self.converToPIL(im_rect, self.opt_quantizer, self.opt_palette_size) # Get pallette - apparently, this is the 3d element of the header # (but it has not always been). Best we've got. Its not the same # as im_pil.palette.tobytes(). from PIL.GifImagePlugin import getheader palette = getheader(im_pil)[0][3] # Write image if self._count == 0: self.write_header(im_pil, palette, self.opt_loop) self._global_palette = palette self.write_image(im_pil, palette, rect, duration, dispose) # assert len(palette) == len(self._global_palette) # Bookkeeping self._previous_image = im self._count += 1 def write_header(self, im, globalPalette, loop): # Gather info header = self.getheaderAnim(im) appext = self.getAppExt(loop) # Write self.fp.write(header) self.fp.write(globalPalette) self.fp.write(appext) def close(self): self.fp.write(";".encode("utf-8")) # end gif def write_image(self, im, palette, rect, duration, dispose): fp = self.fp # Gather local image header and data, using PIL's getdata. That # function returns a list of bytes objects, but which parts are # what has changed multiple times, so we put together the first # parts until we have enough to form the image header. data = self.getdata(im) imdes = b"" while data and len(imdes) < 11: imdes += data.pop(0) assert len(imdes) == 11 # Make image descriptor suitable for using 256 local color palette lid = self.getImageDescriptor(im, rect) graphext = self.getGraphicsControlExt(duration, dispose) # Write local header if (palette != self._global_palette) or (dispose != 2): # Use local color palette fp.write(graphext) fp.write(lid) # write suitable image descriptor fp.write(palette) # write local color table fp.write(b"\x08") # LZW minimum size code else: # Use global color palette fp.write(graphext) fp.write(imdes) # write suitable image descriptor # Write image data for d in data: fp.write(d) def getheaderAnim(self, im): """ Get animation header. To replace PILs getheader()[0] """ bb = b"GIF89a" bb += intToBin(im.size[0]) bb += intToBin(im.size[1]) bb += b"\x87\x00\x00" return bb def getImageDescriptor(self, im, xy=None): """ Used for the local color table properties per image. Otherwise global color table applies to all frames irrespective of whether additional colors comes in play that require a redefined palette. Still a maximum of 256 color per frame, obviously. Written by Ant1 on 2010-08-22 Modified by Alex Robinson in Janurari 2011 to implement subrectangles. """ # Defaule use full image and place at upper left if xy is None: xy = (0, 0) # Image separator, bb = b"\x2C" # Image position and size bb += intToBin(xy[0]) # Left position bb += intToBin(xy[1]) # Top position bb += intToBin(im.size[0]) # image width bb += intToBin(im.size[1]) # image height # packed field: local color table flag1, interlace0, sorted table0, # reserved00, lct size111=7=2^(7 + 1)=256. bb += b"\x87" # LZW minimum size code now comes later, begining of [imagedata] blocks return bb def getAppExt(self, loop): """ Application extension. This part specifies the amount of loops. If loop is 0 or inf, it goes on infinitely. """ if loop == 1: return b"" if loop == 0: loop = 2 ** 16 - 1 bb = b"" if loop != 0: # omit the extension if we would like a nonlooping gif bb = b"\x21\xFF\x0B" # application extension bb += b"NETSCAPE2.0" bb += b"\x03\x01" bb += intToBin(loop) bb += b"\x00" # end return bb def getGraphicsControlExt(self, duration=0.1, dispose=2): """ Graphics Control Extension. A sort of header at the start of each image. Specifies duration and transparancy. Dispose ------- * 0 - No disposal specified. * 1 - Do not dispose. The graphic is to be left in place. * 2 - Restore to background color. The area used by the graphic must be restored to the background color. * 3 - Restore to previous. The decoder is required to restore the area overwritten by the graphic with what was there prior to rendering the graphic. * 4-7 -To be defined. """ bb = b"\x21\xF9\x04" bb += chr((dispose & 3) << 2).encode("utf-8") # low bit 1 == transparency, # 2nd bit 1 == user input , next 3 bits, the low two of which are used, # are dispose. bb += intToBin(int(duration * 100 + 0.5)) # in 100th of seconds bb += b"\x00" # no transparant color bb += b"\x00" # end return bb def getSubRectangle(self, im): """ Calculate the minimal rectangle that need updating. Returns a two-element tuple containing the cropped image and an x-y tuple. Calculating the subrectangles takes extra time, obviously. However, if the image sizes were reduced, the actual writing of the GIF goes faster. In some cases applying this method produces a GIF faster. """ # Cannot do subrectangle for first image if self._count == 0: return im, (0, 0) prev = self._previous_image # Get difference, sum over colors diff = np.abs(im - prev) if diff.ndim == 3: diff = diff.sum(2) # Get begin and end for both dimensions X = np.argwhere(diff.sum(0)) Y = np.argwhere(diff.sum(1)) # Get rect coordinates if X.size and Y.size: x0, x1 = int(X[0]), int(X[-1] + 1) y0, y1 = int(Y[0]), int(Y[-1] + 1) else: # No change ... make it minimal x0, x1 = 0, 2 y0, y1 = 0, 2 return im[y0:y1, x0:x1], (x0, y0) def converToPIL(self, im, quantizer, palette_size=256): """Convert image to Paletted PIL image. PIL used to not do a very good job at quantization, but I guess this has improved a lot (at least in Pillow). I don't think we need neuqant (and we can add it later if we really want). """ im_pil = ndarray_to_pil(im, "gif") if quantizer in ("nq", "neuquant"): # NeuQuant algorithm nq_samplefac = 10 # 10 seems good in general im_pil = im_pil.convert("RGBA") # NQ assumes RGBA nqInstance = NeuQuant(im_pil, nq_samplefac) # Learn colors im_pil = nqInstance.quantize(im_pil, colors=palette_size) elif quantizer in (0, 1, 2): # Adaptive PIL algorithm if quantizer == 2: im_pil = im_pil.convert("RGBA") else: im_pil = im_pil.convert("RGB") im_pil = im_pil.quantize(colors=palette_size, method=quantizer) else: raise ValueError("Invalid value for quantizer: %r" % quantizer) return im_pil