pillowmulti.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. """
  2. PIL formats for multiple images.
  3. """
  4. import logging
  5. import numpy as np
  6. from .pillow_legacy import PillowFormat, image_as_uint, ndarray_to_pil
  7. logger = logging.getLogger(__name__)
  8. NeuQuant = None # we can implement this when we need it
  9. class TIFFFormat(PillowFormat):
  10. _modes = "i" # arg, why bother; people should use the tiffile version
  11. _description = "TIFF format (Pillow)"
  12. class GIFFormat(PillowFormat):
  13. """See :mod:`imageio.plugins.pillow_legacy`"""
  14. _modes = "iI"
  15. _description = "Static and animated gif (Pillow)"
  16. # GIF reader needs no modifications compared to base pillow reader
  17. class Writer(PillowFormat.Writer): # pragma: no cover
  18. def _open(
  19. self,
  20. loop=0,
  21. duration=None,
  22. fps=10,
  23. palettesize=256,
  24. quantizer=0,
  25. subrectangles=False,
  26. ):
  27. from PIL import __version__ as pillow_version
  28. major, minor, patch = tuple(int(x) for x in pillow_version.split("."))
  29. if major == 10 and minor >= 1:
  30. raise ImportError(
  31. f"Pillow v{pillow_version} is not supported by ImageIO's legacy "
  32. "pillow plugin when writing GIFs. Consider switching to the new "
  33. "plugin or downgrading to `pillow<10.1.0`."
  34. )
  35. # Check palettesize
  36. palettesize = int(palettesize)
  37. if palettesize < 2 or palettesize > 256:
  38. raise ValueError("GIF quantize param must be 2..256")
  39. if palettesize not in [2, 4, 8, 16, 32, 64, 128, 256]:
  40. palettesize = 2 ** int(np.log2(128) + 0.999)
  41. logger.warning(
  42. "Warning: palettesize (%r) modified to a factor of "
  43. "two between 2-256." % palettesize
  44. )
  45. # Duratrion / fps
  46. if duration is None:
  47. self._duration = 1.0 / float(fps)
  48. elif isinstance(duration, (list, tuple)):
  49. self._duration = [float(d) for d in duration]
  50. else:
  51. self._duration = float(duration)
  52. # loop
  53. loop = float(loop)
  54. if loop <= 0 or loop == float("inf"):
  55. loop = 0
  56. loop = int(loop)
  57. # Subrectangles / dispose
  58. subrectangles = bool(subrectangles)
  59. self._dispose = 1 if subrectangles else 2
  60. # The "0" (median cut) quantizer is by far the best
  61. fp = self.request.get_file()
  62. self._writer = GifWriter(
  63. fp, subrectangles, loop, quantizer, int(palettesize)
  64. )
  65. def _close(self):
  66. self._writer.close()
  67. def _append_data(self, im, meta):
  68. im = image_as_uint(im, bitdepth=8)
  69. if im.ndim == 3 and im.shape[-1] == 1:
  70. im = im[:, :, 0]
  71. duration = self._duration
  72. if isinstance(duration, list):
  73. duration = duration[min(len(duration) - 1, self._writer._count)]
  74. dispose = self._dispose
  75. self._writer.add_image(im, duration, dispose)
  76. return
  77. def intToBin(i):
  78. return i.to_bytes(2, byteorder="little")
  79. class GifWriter: # pragma: no cover
  80. """Class that for helping write the animated GIF file. This is based on
  81. code from images2gif.py (part of visvis). The version here is modified
  82. to allow streamed writing.
  83. """
  84. def __init__(
  85. self,
  86. file,
  87. opt_subrectangle=True,
  88. opt_loop=0,
  89. opt_quantizer=0,
  90. opt_palette_size=256,
  91. ):
  92. self.fp = file
  93. self.opt_subrectangle = opt_subrectangle
  94. self.opt_loop = opt_loop
  95. self.opt_quantizer = opt_quantizer
  96. self.opt_palette_size = opt_palette_size
  97. self._previous_image = None # as np array
  98. self._global_palette = None # as bytes
  99. self._count = 0
  100. from PIL.GifImagePlugin import getdata
  101. self.getdata = getdata
  102. def add_image(self, im, duration, dispose):
  103. # Prepare image
  104. im_rect, rect = im, (0, 0)
  105. if self.opt_subrectangle:
  106. im_rect, rect = self.getSubRectangle(im)
  107. im_pil = self.converToPIL(im_rect, self.opt_quantizer, self.opt_palette_size)
  108. # Get pallette - apparently, this is the 3d element of the header
  109. # (but it has not always been). Best we've got. Its not the same
  110. # as im_pil.palette.tobytes().
  111. from PIL.GifImagePlugin import getheader
  112. palette = getheader(im_pil)[0][3]
  113. # Write image
  114. if self._count == 0:
  115. self.write_header(im_pil, palette, self.opt_loop)
  116. self._global_palette = palette
  117. self.write_image(im_pil, palette, rect, duration, dispose)
  118. # assert len(palette) == len(self._global_palette)
  119. # Bookkeeping
  120. self._previous_image = im
  121. self._count += 1
  122. def write_header(self, im, globalPalette, loop):
  123. # Gather info
  124. header = self.getheaderAnim(im)
  125. appext = self.getAppExt(loop)
  126. # Write
  127. self.fp.write(header)
  128. self.fp.write(globalPalette)
  129. self.fp.write(appext)
  130. def close(self):
  131. self.fp.write(";".encode("utf-8")) # end gif
  132. def write_image(self, im, palette, rect, duration, dispose):
  133. fp = self.fp
  134. # Gather local image header and data, using PIL's getdata. That
  135. # function returns a list of bytes objects, but which parts are
  136. # what has changed multiple times, so we put together the first
  137. # parts until we have enough to form the image header.
  138. data = self.getdata(im)
  139. imdes = b""
  140. while data and len(imdes) < 11:
  141. imdes += data.pop(0)
  142. assert len(imdes) == 11
  143. # Make image descriptor suitable for using 256 local color palette
  144. lid = self.getImageDescriptor(im, rect)
  145. graphext = self.getGraphicsControlExt(duration, dispose)
  146. # Write local header
  147. if (palette != self._global_palette) or (dispose != 2):
  148. # Use local color palette
  149. fp.write(graphext)
  150. fp.write(lid) # write suitable image descriptor
  151. fp.write(palette) # write local color table
  152. fp.write(b"\x08") # LZW minimum size code
  153. else:
  154. # Use global color palette
  155. fp.write(graphext)
  156. fp.write(imdes) # write suitable image descriptor
  157. # Write image data
  158. for d in data:
  159. fp.write(d)
  160. def getheaderAnim(self, im):
  161. """Get animation header. To replace PILs getheader()[0]"""
  162. bb = b"GIF89a"
  163. bb += intToBin(im.size[0])
  164. bb += intToBin(im.size[1])
  165. bb += b"\x87\x00\x00"
  166. return bb
  167. def getImageDescriptor(self, im, xy=None):
  168. """Used for the local color table properties per image.
  169. Otherwise global color table applies to all frames irrespective of
  170. whether additional colors comes in play that require a redefined
  171. palette. Still a maximum of 256 color per frame, obviously.
  172. Written by Ant1 on 2010-08-22
  173. Modified by Alex Robinson in Janurari 2011 to implement subrectangles.
  174. """
  175. # Defaule use full image and place at upper left
  176. if xy is None:
  177. xy = (0, 0)
  178. # Image separator,
  179. bb = b"\x2c"
  180. # Image position and size
  181. bb += intToBin(xy[0]) # Left position
  182. bb += intToBin(xy[1]) # Top position
  183. bb += intToBin(im.size[0]) # image width
  184. bb += intToBin(im.size[1]) # image height
  185. # packed field: local color table flag1, interlace0, sorted table0,
  186. # reserved00, lct size111=7=2^(7 + 1)=256.
  187. bb += b"\x87"
  188. # LZW minimum size code now comes later, begining of [imagedata] blocks
  189. return bb
  190. def getAppExt(self, loop):
  191. """Application extension. This part specifies the amount of loops.
  192. If loop is 0 or inf, it goes on infinitely.
  193. """
  194. if loop == 1:
  195. return b""
  196. if loop == 0:
  197. loop = 2**16 - 1
  198. bb = b""
  199. if loop != 0: # omit the extension if we would like a nonlooping gif
  200. bb = b"\x21\xff\x0b" # application extension
  201. bb += b"NETSCAPE2.0"
  202. bb += b"\x03\x01"
  203. bb += intToBin(loop)
  204. bb += b"\x00" # end
  205. return bb
  206. def getGraphicsControlExt(self, duration=0.1, dispose=2):
  207. """Graphics Control Extension. A sort of header at the start of
  208. each image. Specifies duration and transparancy.
  209. Dispose
  210. -------
  211. * 0 - No disposal specified.
  212. * 1 - Do not dispose. The graphic is to be left in place.
  213. * 2 - Restore to background color. The area used by the graphic
  214. must be restored to the background color.
  215. * 3 - Restore to previous. The decoder is required to restore the
  216. area overwritten by the graphic with what was there prior to
  217. rendering the graphic.
  218. * 4-7 -To be defined.
  219. """
  220. bb = b"\x21\xf9\x04"
  221. bb += chr((dispose & 3) << 2).encode("utf-8")
  222. # low bit 1 == transparency,
  223. # 2nd bit 1 == user input , next 3 bits, the low two of which are used,
  224. # are dispose.
  225. bb += intToBin(int(duration * 100 + 0.5)) # in 100th of seconds
  226. bb += b"\x00" # no transparant color
  227. bb += b"\x00" # end
  228. return bb
  229. def getSubRectangle(self, im):
  230. """Calculate the minimal rectangle that need updating. Returns
  231. a two-element tuple containing the cropped image and an x-y tuple.
  232. Calculating the subrectangles takes extra time, obviously. However,
  233. if the image sizes were reduced, the actual writing of the GIF
  234. goes faster. In some cases applying this method produces a GIF faster.
  235. """
  236. # Cannot do subrectangle for first image
  237. if self._count == 0:
  238. return im, (0, 0)
  239. prev = self._previous_image
  240. # Get difference, sum over colors
  241. diff = np.abs(im - prev)
  242. if diff.ndim == 3:
  243. diff = diff.sum(2)
  244. # Get begin and end for both dimensions
  245. X = np.argwhere(diff.sum(0))
  246. Y = np.argwhere(diff.sum(1))
  247. # Get rect coordinates
  248. if X.size and Y.size:
  249. x0, x1 = int(X[0]), int(X[-1] + 1)
  250. y0, y1 = int(Y[0]), int(Y[-1] + 1)
  251. else: # No change ... make it minimal
  252. x0, x1 = 0, 2
  253. y0, y1 = 0, 2
  254. return im[y0:y1, x0:x1], (x0, y0)
  255. def converToPIL(self, im, quantizer, palette_size=256):
  256. """Convert image to Paletted PIL image.
  257. PIL used to not do a very good job at quantization, but I guess
  258. this has improved a lot (at least in Pillow). I don't think we need
  259. neuqant (and we can add it later if we really want).
  260. """
  261. im_pil = ndarray_to_pil(im, "gif")
  262. if quantizer in ("nq", "neuquant"):
  263. # NeuQuant algorithm
  264. nq_samplefac = 10 # 10 seems good in general
  265. im_pil = im_pil.convert("RGBA") # NQ assumes RGBA
  266. nqInstance = NeuQuant(im_pil, nq_samplefac) # Learn colors
  267. im_pil = nqInstance.quantize(im_pil, colors=palette_size)
  268. elif quantizer in (0, 1, 2):
  269. # Adaptive PIL algorithm
  270. if quantizer == 2:
  271. im_pil = im_pil.convert("RGBA")
  272. else:
  273. im_pil = im_pil.convert("RGB")
  274. im_pil = im_pil.quantize(colors=palette_size, method=quantizer)
  275. else:
  276. raise ValueError("Invalid value for quantizer: %r" % quantizer)
  277. return im_pil