1# -*- coding: utf-8 -*-
2# imageio is distributed under the terms of the (new) BSD License.
3
4"""Plugin for multi-image freeimafe formats, like animated GIF and ico.
5"""
6
7import logging
8import numpy as np
9
10from ..core import Format, image_as_uint
11from ._freeimage import fi, IO_FLAGS
12from .freeimage import FreeimageFormat
13
14logger = logging.getLogger(__name__)
15
16
17class FreeimageMulti(FreeimageFormat):
18 """Base class for freeimage formats that support multiple images."""
19
20 _modes = "iI"
21 _fif = -1
22
23 class Reader(Format.Reader):
24 def _open(self, flags=0):
25 flags = int(flags)
26 # Create bitmap
27 self._bm = fi.create_multipage_bitmap(
28 self.request.filename, self.format.fif, flags
29 )
30 self._bm.load_from_filename(self.request.get_local_filename())
31
32 def _close(self):
33 self._bm.close()
34
35 def _get_length(self):
36 return len(self._bm)
37
38 def _get_data(self, index):
39 sub = self._bm.get_page(index)
40 try:
41 return sub.get_image_data(), sub.get_meta_data()
42 finally:
43 sub.close()
44
45 def _get_meta_data(self, index):
46 index = index or 0
47 if index < 0 or index >= len(self._bm):
48 raise IndexError()
49 sub = self._bm.get_page(index)
50 try:
51 return sub.get_meta_data()
52 finally:
53 sub.close()
54
55 # --
56
57 class Writer(FreeimageFormat.Writer):
58 def _open(self, flags=0):
59 # Set flags
60 self._flags = flags = int(flags)
61 # Instantiate multi-page bitmap
62 self._bm = fi.create_multipage_bitmap(
63 self.request.filename, self.format.fif, flags
64 )
65 self._bm.save_to_filename(self.request.get_local_filename())
66
67 def _close(self):
68 # Close bitmap
69 self._bm.close()
70
71 def _append_data(self, im, meta):
72 # Prepare data
73 if im.ndim == 3 and im.shape[-1] == 1:
74 im = im[:, :, 0]
75 im = image_as_uint(im, bitdepth=8)
76 # Create sub bitmap
77 sub1 = fi.create_bitmap(self._bm._filename, self.format.fif)
78 # Let subclass add data to bitmap, optionally return new
79 sub2 = self._append_bitmap(im, meta, sub1)
80 # Add
81 self._bm.append_bitmap(sub2)
82 sub2.close()
83 if sub1 is not sub2:
84 sub1.close()
85
86 def _append_bitmap(self, im, meta, bitmap):
87 # Set data
88 bitmap.allocate(im)
89 bitmap.set_image_data(im)
90 bitmap.set_meta_data(meta)
91 # Return that same bitmap
92 return bitmap
93
94 def _set_meta_data(self, meta):
95 pass # ignore global meta data
96
97
98class MngFormat(FreeimageMulti):
99 """An Mng format based on the Freeimage library.
100
101 Read only. Seems broken.
102 """
103
104 _fif = 6
105
106 def _can_write(self, request): # pragma: no cover
107 return False
108
109
110class IcoFormat(FreeimageMulti):
111 """An ICO format based on the Freeimage library.
112
113 This format supports grayscale, RGB and RGBA images.
114
115 The freeimage plugin requires a `freeimage` binary. If this binary
116 is not available on the system, it can be downloaded by either
117
118 - the command line script ``imageio_download_bin freeimage``
119 - the Python method ``imageio.plugins.freeimage.download()``
120
121 Parameters for reading
122 ----------------------
123 makealpha : bool
124 Convert to 32-bit and create an alpha channel from the AND-
125 mask when loading. Default False. Note that this returns wrong
126 results if the image was already RGBA.
127
128 """
129
130 _fif = 1
131
132 class Reader(FreeimageMulti.Reader):
133 def _open(self, flags=0, makealpha=False):
134 # Build flags from kwargs
135 flags = int(flags)
136 if makealpha:
137 flags |= IO_FLAGS.ICO_MAKEALPHA
138 return FreeimageMulti.Reader._open(self, flags)
139
140
141class GifFormat(FreeimageMulti):
142 """A format for reading and writing static and animated GIF, based
143 on the Freeimage library.
144
145 Images read with this format are always RGBA. Currently,
146 the alpha channel is ignored when saving RGB images with this
147 format.
148
149 The freeimage plugin requires a `freeimage` binary. If this binary
150 is not available on the system, it can be downloaded by either
151
152 - the command line script ``imageio_download_bin freeimage``
153 - the Python method ``imageio.plugins.freeimage.download()``
154
155 Parameters for reading
156 ----------------------
157 playback : bool
158 'Play' the GIF to generate each frame (as 32bpp) instead of
159 returning raw frame data when loading. Default True.
160
161 Parameters for saving
162 ---------------------
163 loop : int
164 The number of iterations. Default 0 (meaning loop indefinitely)
165 duration : {float, list}
166 The duration (in seconds) of each frame. Either specify one value
167 that is used for all frames, or one value for each frame.
168 Note that in the GIF format the duration/delay is expressed in
169 hundredths of a second, which limits the precision of the duration.
170 fps : float
171 The number of frames per second. If duration is not given, the
172 duration for each frame is set to 1/fps. Default 10.
173 palettesize : int
174 The number of colors to quantize the image to. Is rounded to
175 the nearest power of two. Default 256.
176 quantizer : {'wu', 'nq'}
177 The quantization algorithm:
178 * wu - Wu, Xiaolin, Efficient Statistical Computations for
179 Optimal Color Quantization
180 * nq (neuqant) - Dekker A. H., Kohonen neural networks for
181 optimal color quantization
182 subrectangles : bool
183 If True, will try and optimize the GIF by storing only the
184 rectangular parts of each frame that change with respect to the
185 previous. Unfortunately, this option seems currently broken
186 because FreeImage does not handle DisposalMethod correctly.
187 Default False.
188 """
189
190 _fif = 25
191
192 class Reader(FreeimageMulti.Reader):
193 def _open(self, flags=0, playback=True):
194 # Build flags from kwargs
195 flags = int(flags)
196 if playback:
197 flags |= IO_FLAGS.GIF_PLAYBACK
198 FreeimageMulti.Reader._open(self, flags)
199
200 def _get_data(self, index):
201 im, meta = FreeimageMulti.Reader._get_data(self, index)
202 # im = im[:, :, :3] # Drop alpha channel
203 return im, meta
204
205 # -- writer
206
207 class Writer(FreeimageMulti.Writer):
208 # todo: subrectangles
209 # todo: global palette
210
211 def _open(
212 self,
213 flags=0,
214 loop=0,
215 duration=None,
216 fps=10,
217 palettesize=256,
218 quantizer="Wu",
219 subrectangles=False,
220 ):
221 # Check palettesize
222 if palettesize < 2 or palettesize > 256:
223 raise ValueError("GIF quantize param must be 2..256")
224 if palettesize not in [2, 4, 8, 16, 32, 64, 128, 256]:
225 palettesize = 2 ** int(np.log2(128) + 0.999)
226 logger.warning(
227 "Warning: palettesize (%r) modified to a factor of "
228 "two between 2-256." % palettesize
229 )
230 self._palettesize = palettesize
231 # Check quantizer
232 self._quantizer = {"wu": 0, "nq": 1}.get(quantizer.lower(), None)
233 if self._quantizer is None:
234 raise ValueError('Invalid quantizer, must be "wu" or "nq".')
235 # Check frametime
236 if duration is None:
237 self._frametime = [int(1000 / float(fps) + 0.5)]
238 elif isinstance(duration, list):
239 self._frametime = [int(1000 * d) for d in duration]
240 elif isinstance(duration, (float, int)):
241 self._frametime = [int(1000 * duration)]
242 else:
243 raise ValueError("Invalid value for duration: %r" % duration)
244 # Check subrectangles
245 self._subrectangles = bool(subrectangles)
246 self._prev_im = None
247 # Init
248 FreeimageMulti.Writer._open(self, flags)
249 # Set global meta data
250 self._meta = {}
251 self._meta["ANIMATION"] = {
252 # 'GlobalPalette': np.array([0]).astype(np.uint8),
253 "Loop": np.array([loop]).astype(np.uint32),
254 # 'LogicalWidth': np.array([x]).astype(np.uint16),
255 # 'LogicalHeight': np.array([x]).astype(np.uint16),
256 }
257
258 def _append_bitmap(self, im, meta, bitmap):
259 # Prepare meta data
260 meta = meta.copy()
261 meta_a = meta["ANIMATION"] = {}
262 # If this is the first frame, assign it our "global" meta data
263 if len(self._bm) == 0:
264 meta.update(self._meta)
265 meta_a = meta["ANIMATION"]
266 # Set frame time
267 index = len(self._bm)
268 if index < len(self._frametime):
269 ft = self._frametime[index]
270 else:
271 ft = self._frametime[-1]
272 meta_a["FrameTime"] = np.array([ft]).astype(np.uint32)
273 # Check array
274 if im.ndim == 3 and im.shape[-1] == 4:
275 im = im[:, :, :3]
276 # Process subrectangles
277 im_uncropped = im
278 if self._subrectangles and self._prev_im is not None:
279 im, xy = self._get_sub_rectangles(self._prev_im, im)
280 meta_a["DisposalMethod"] = np.array([1]).astype(np.uint8)
281 meta_a["FrameLeft"] = np.array([xy[0]]).astype(np.uint16)
282 meta_a["FrameTop"] = np.array([xy[1]]).astype(np.uint16)
283 self._prev_im = im_uncropped
284 # Set image data
285 sub2 = sub1 = bitmap
286 sub1.allocate(im)
287 sub1.set_image_data(im)
288 # Quantize it if its RGB
289 if im.ndim == 3 and im.shape[-1] == 3:
290 sub2 = sub1.quantize(self._quantizer, self._palettesize)
291 # Set meta data and return
292 sub2.set_meta_data(meta)
293 return sub2
294
295 def _get_sub_rectangles(self, prev, im):
296 """
297 Calculate the minimal rectangles that need updating each frame.
298 Returns a two-element tuple containing the cropped images and a
299 list of x-y positions.
300 """
301 # Get difference, sum over colors
302 diff = np.abs(im - prev)
303 if diff.ndim == 3:
304 diff = diff.sum(2)
305 # Get begin and end for both dimensions
306 X = np.argwhere(diff.sum(0))
307 Y = np.argwhere(diff.sum(1))
308 # Get rect coordinates
309 if X.size and Y.size:
310 x0, x1 = int(X[0]), int(X[-1]) + 1
311 y0, y1 = int(Y[0]), int(Y[-1]) + 1
312 else: # No change ... make it minimal
313 x0, x1 = 0, 2
314 y0, y1 = 0, 2
315 # Cut out and return
316 return im[y0:y1, x0:x1], (x0, y0)