339 lines
12 KiB
Python
339 lines
12 KiB
Python
|
"""
|
||
|
PIL formats for multiple images.
|
||
|
"""
|
||
|
|
||
|
import logging
|
||
|
|
||
|
import numpy as np
|
||
|
|
||
|
from .pillow_legacy import PillowFormat, image_as_uint, ndarray_to_pil
|
||
|
|
||
|
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):
|
||
|
"""See :mod:`imageio.plugins.pillow_legacy`"""
|
||
|
|
||
|
_modes = "iI"
|
||
|
_description = "Static and animated gif (Pillow)"
|
||
|
|
||
|
# GIF reader needs no modifications compared to base pillow reader
|
||
|
|
||
|
class Writer(PillowFormat.Writer): # pragma: no cover
|
||
|
def _open(
|
||
|
self,
|
||
|
loop=0,
|
||
|
duration=None,
|
||
|
fps=10,
|
||
|
palettesize=256,
|
||
|
quantizer=0,
|
||
|
subrectangles=False,
|
||
|
):
|
||
|
from PIL import __version__ as pillow_version
|
||
|
|
||
|
major, minor, patch = tuple(int(x) for x in pillow_version.split("."))
|
||
|
if major == 10 and minor >= 1:
|
||
|
raise ImportError(
|
||
|
f"Pillow v{pillow_version} is not supported by ImageIO's legacy "
|
||
|
"pillow plugin when writing GIFs. Consider switching to the new "
|
||
|
"plugin or downgrading to `pillow<10.1.0`."
|
||
|
)
|
||
|
|
||
|
# 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
|
||
|
|
||
|
|
||
|
def intToBin(i):
|
||
|
return i.to_bytes(2, byteorder="little")
|
||
|
|
||
|
|
||
|
class GifWriter: # pragma: no cover
|
||
|
"""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
|