1# -*- coding: utf-8 -*-
2# imageio is distributed under the terms of the (new) BSD License.
3
4""" Read/Write images using Pillow/PIL.
5
6Backend Library: `Pillow <https://pillow.readthedocs.io/en/stable/>`_
7
8Plugin that wraps the the Pillow library. Pillow is a friendly fork of PIL
9(Python Image Library) and supports reading and writing of common formats (jpg,
10png, gif, tiff, ...). For, the complete list of features and supported formats
11please refer to pillows official docs (see the Backend Library link).
12
13Parameters
14----------
15request : Request
16 A request object representing the resource to be operated on.
17
18Methods
19-------
20
21.. autosummary::
22 :toctree: _plugins/pillow
23
24 PillowPlugin.read
25 PillowPlugin.write
26 PillowPlugin.iter
27 PillowPlugin.get_meta
28
29"""
30
31import sys
32import warnings
33from io import BytesIO
34from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union, cast
35
36import numpy as np
37from PIL import ExifTags, GifImagePlugin, Image, ImageSequence, UnidentifiedImageError
38from PIL import __version__ as pil_version # type: ignore
39
40from ..core.request import URI_BYTES, InitializationError, IOMode, Request
41from ..core.v3_plugin_api import ImageProperties, PluginV3
42from ..typing import ArrayLike
43
44
45def pillow_version() -> Tuple[int]:
46 return tuple(int(x) for x in pil_version.split("."))
47
48
49def _exif_orientation_transform(orientation: int, mode: str) -> Callable:
50 # get transformation that transforms an image from a
51 # given EXIF orientation into the standard orientation
52
53 # -1 if the mode has color channel, 0 otherwise
54 axis = -2 if Image.getmodebands(mode) > 1 else -1
55
56 EXIF_ORIENTATION = {
57 1: lambda x: x,
58 2: lambda x: np.flip(x, axis=axis),
59 3: lambda x: np.rot90(x, k=2),
60 4: lambda x: np.flip(x, axis=axis - 1),
61 5: lambda x: np.flip(np.rot90(x, k=3), axis=axis),
62 6: lambda x: np.rot90(x, k=3),
63 7: lambda x: np.flip(np.rot90(x, k=1), axis=axis),
64 8: lambda x: np.rot90(x, k=1),
65 }
66
67 return EXIF_ORIENTATION[orientation]
68
69
70class PillowPlugin(PluginV3):
71 def __init__(self, request: Request) -> None:
72 """Instantiate a new Pillow Plugin Object
73
74 Parameters
75 ----------
76 request : {Request}
77 A request object representing the resource to be operated on.
78
79 """
80
81 super().__init__(request)
82
83 # Register HEIF opener for Pillow
84 try:
85 from pillow_heif import register_heif_opener
86 except ImportError:
87 pass
88 else:
89 register_heif_opener()
90
91 # Register AVIF opener for Pillow
92 try:
93 from pillow_heif import register_avif_opener
94 except ImportError:
95 pass
96 else:
97 register_avif_opener()
98
99 self._image: Image = None
100 self.images_to_write = []
101
102 if request.mode.io_mode == IOMode.read:
103 try:
104 with Image.open(request.get_file()):
105 # Check if it is generally possible to read the image.
106 # This will not read any data and merely try to find a
107 # compatible pillow plugin (ref: the pillow docs).
108 pass
109 except UnidentifiedImageError:
110 if request._uri_type == URI_BYTES:
111 raise InitializationError(
112 "Pillow can not read the provided bytes."
113 ) from None
114 else:
115 raise InitializationError(
116 f"Pillow can not read {request.raw_uri}."
117 ) from None
118
119 self._image = Image.open(self._request.get_file())
120 else:
121 self.save_args = {}
122
123 extension = self.request.extension or self.request.format_hint
124 if extension is None:
125 warnings.warn(
126 "Can't determine file format to write as. You _must_"
127 " set `format` during write or the call will fail. Use "
128 "`extension` to supress this warning. ",
129 UserWarning,
130 )
131 return
132
133 tirage = [Image.preinit, Image.init]
134 for format_loader in tirage:
135 format_loader()
136 if extension in Image.registered_extensions().keys():
137 return
138
139 raise InitializationError(
140 f"Pillow can not write `{extension}` files."
141 ) from None
142
143 def close(self) -> None:
144 self._flush_writer()
145
146 if self._image:
147 self._image.close()
148
149 self._request.finish()
150
151 def read(
152 self,
153 *,
154 index: int = None,
155 mode: str = None,
156 rotate: bool = False,
157 apply_gamma: bool = False,
158 writeable_output: bool = True,
159 pilmode: str = None,
160 exifrotate: bool = None,
161 as_gray: bool = None,
162 ) -> np.ndarray:
163 """
164 Parses the given URI and creates a ndarray from it.
165
166 Parameters
167 ----------
168 index : int
169 If the ImageResource contains multiple ndimages, and index is an
170 integer, select the index-th ndimage from among them and return it.
171 If index is an ellipsis (...), read all ndimages in the file and
172 stack them along a new batch dimension and return them. If index is
173 None, this plugin reads the first image of the file (index=0) unless
174 the image is a GIF or APNG, in which case all images are read
175 (index=...).
176 mode : str
177 Convert the image to the given mode before returning it. If None,
178 the mode will be left unchanged. Possible modes can be found at:
179 https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes
180 rotate : bool
181 If True and the image contains an EXIF orientation tag,
182 apply the orientation before returning the ndimage.
183 apply_gamma : bool
184 If True and the image contains metadata about gamma, apply gamma
185 correction to the image.
186 writable_output : bool
187 If True, ensure that the image is writable before returning it to
188 the user. This incurs a full copy of the pixel data if the data
189 served by pillow is read-only. Consequentially, setting this flag to
190 False improves performance for some images.
191 pilmode : str
192 Deprecated, use `mode` instead.
193 exifrotate : bool
194 Deprecated, use `rotate` instead.
195 as_gray : bool
196 Deprecated. Exists to raise a constructive error message.
197
198 Returns
199 -------
200 ndimage : ndarray
201 A numpy array containing the loaded image data
202
203 Notes
204 -----
205 If you read a paletted image (e.g. GIF) then the plugin will apply the
206 palette by default. Should you wish to read the palette indices of each
207 pixel use ``mode="P"``. The coresponding color pallete can be found in
208 the image's metadata using the ``palette`` key when metadata is
209 extracted using the ``exclude_applied=False`` kwarg. The latter is
210 needed, as palettes are applied by default and hence excluded by default
211 to keep metadata and pixel data consistent.
212
213 """
214
215 if pilmode is not None:
216 warnings.warn(
217 "`pilmode` is deprecated. Use `mode` instead.", DeprecationWarning
218 )
219 mode = pilmode
220
221 if exifrotate is not None:
222 warnings.warn(
223 "`exifrotate` is deprecated. Use `rotate` instead.", DeprecationWarning
224 )
225 rotate = exifrotate
226
227 if as_gray is not None:
228 raise TypeError(
229 "The keyword `as_gray` is no longer supported."
230 "Use `mode='F'` for a backward-compatible result, or "
231 " `mode='L'` for an integer-valued result."
232 )
233
234 if self._image.format == "GIF":
235 # Converting GIF P frames to RGB
236 # https://github.com/python-pillow/Pillow/pull/6150
237 GifImagePlugin.LOADING_STRATEGY = (
238 GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY
239 )
240
241 if index is None:
242 if self._image.format == "GIF":
243 index = Ellipsis
244 elif self._image.custom_mimetype == "image/apng":
245 index = Ellipsis
246 else:
247 index = 0
248
249 if isinstance(index, int):
250 # will raise IO error if index >= number of frames in image
251 self._image.seek(index)
252 image = self._apply_transforms(
253 self._image, mode, rotate, apply_gamma, writeable_output
254 )
255 else:
256 iterator = self.iter(
257 mode=mode,
258 rotate=rotate,
259 apply_gamma=apply_gamma,
260 writeable_output=writeable_output,
261 )
262 image = np.stack([im for im in iterator], axis=0)
263
264 return image
265
266 def iter(
267 self,
268 *,
269 mode: str = None,
270 rotate: bool = False,
271 apply_gamma: bool = False,
272 writeable_output: bool = True,
273 ) -> Iterator[np.ndarray]:
274 """
275 Iterate over all ndimages/frames in the URI
276
277 Parameters
278 ----------
279 mode : {str, None}
280 Convert the image to the given mode before returning it. If None,
281 the mode will be left unchanged. Possible modes can be found at:
282 https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes
283 rotate : {bool}
284 If set to ``True`` and the image contains an EXIF orientation tag,
285 apply the orientation before returning the ndimage.
286 apply_gamma : {bool}
287 If ``True`` and the image contains metadata about gamma, apply gamma
288 correction to the image.
289 writable_output : bool
290 If True, ensure that the image is writable before returning it to
291 the user. This incurs a full copy of the pixel data if the data
292 served by pillow is read-only. Consequentially, setting this flag to
293 False improves performance for some images.
294 """
295
296 for im in ImageSequence.Iterator(self._image):
297 yield self._apply_transforms(
298 im, mode, rotate, apply_gamma, writeable_output
299 )
300
301 def _apply_transforms(
302 self, image, mode, rotate, apply_gamma, writeable_output
303 ) -> np.ndarray:
304 if mode is not None:
305 image = image.convert(mode)
306 elif image.mode == "P":
307 # adjust for pillow9 changes
308 # see: https://github.com/python-pillow/Pillow/issues/5929
309 image = image.convert(image.palette.mode)
310 elif image.format == "PNG" and image.mode == "I":
311 major, minor, patch = pillow_version()
312
313 if sys.byteorder == "little":
314 desired_mode = "I;16"
315 else: # pragma: no cover
316 # can't test big-endian in GH-Actions
317 desired_mode = "I;16B"
318
319 if major < 10: # pragma: no cover
320 warnings.warn(
321 "Loading 16-bit (uint16) PNG as int32 due to limitations "
322 "in pillow's PNG decoder. This will be fixed in a future "
323 "version of pillow which will make this warning dissapear.",
324 UserWarning,
325 )
326 elif minor < 1: # pragma: no cover
327 # pillow<10.1.0 can directly decode into 16-bit grayscale
328 image.mode = desired_mode
329 else:
330 # pillow >= 10.1.0
331 image = image.convert(desired_mode)
332
333 image = np.asarray(image)
334
335 meta = self.metadata(index=self._image.tell(), exclude_applied=False)
336 if rotate and "Orientation" in meta:
337 transformation = _exif_orientation_transform(
338 meta["Orientation"], self._image.mode
339 )
340 image = transformation(image)
341
342 if apply_gamma and "gamma" in meta:
343 gamma = float(meta["gamma"])
344 scale = float(65536 if image.dtype == np.uint16 else 255)
345 gain = 1.0
346 image = ((image / scale) ** gamma) * scale * gain + 0.4999
347 image = np.round(image).astype(np.uint8)
348
349 if writeable_output and not image.flags["WRITEABLE"]:
350 image = np.array(image)
351
352 return image
353
354 def write(
355 self,
356 ndimage: Union[ArrayLike, List[ArrayLike]],
357 *,
358 mode: str = None,
359 format: str = None,
360 is_batch: bool = None,
361 **kwargs,
362 ) -> Optional[bytes]:
363 """
364 Write an ndimage to the URI specified in path.
365
366 If the URI points to a file on the current host and the file does not
367 yet exist it will be created. If the file exists already, it will be
368 appended if possible; otherwise, it will be replaced.
369
370 If necessary, the image is broken down along the leading dimension to
371 fit into individual frames of the chosen format. If the format doesn't
372 support multiple frames, and IOError is raised.
373
374 Parameters
375 ----------
376 image : ndarray or list
377 The ndimage to write. If a list is given each element is expected to
378 be an ndimage.
379 mode : str
380 Specify the image's color format. If None (default), the mode is
381 inferred from the array's shape and dtype. Possible modes can be
382 found at:
383 https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes
384 format : str
385 Optional format override. If omitted, the format to use is
386 determined from the filename extension. If a file object was used
387 instead of a filename, this parameter must always be used.
388 is_batch : bool
389 Explicitly tell the writer that ``image`` is a batch of images
390 (True) or not (False). If None, the writer will guess this from the
391 provided ``mode`` or ``image.shape``. While the latter often works,
392 it may cause problems for small images due to aliasing of spatial
393 and color-channel axes.
394 kwargs : ...
395 Extra arguments to pass to pillow. If a writer doesn't recognise an
396 option, it is silently ignored. The available options are described
397 in pillow's `image format documentation
398 <https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html>`_
399 for each writer.
400
401 Notes
402 -----
403 When writing batches of very narrow (2-4 pixels wide) gray images set
404 the ``mode`` explicitly to avoid the batch being identified as a colored
405 image.
406
407 """
408 if "fps" in kwargs:
409 warnings.warn(
410 "The keyword `fps` is no longer supported. Use `duration`"
411 "(in ms) instead, e.g. `fps=50` == `duration=20` (1000 * 1/50).",
412 DeprecationWarning,
413 )
414 kwargs["duration"] = 1000 * 1 / kwargs.get("fps")
415
416 if isinstance(ndimage, list):
417 ndimage = np.stack(ndimage, axis=0)
418 is_batch = True
419 else:
420 ndimage = np.asarray(ndimage)
421
422 # check if ndimage is a batch of frames/pages (e.g. for writing GIF)
423 # if mode is given, use it; otherwise fall back to image.ndim only
424 if is_batch is not None:
425 pass
426 elif mode is not None:
427 is_batch = (
428 ndimage.ndim > 3 if Image.getmodebands(mode) > 1 else ndimage.ndim > 2
429 )
430 elif ndimage.ndim == 2:
431 is_batch = False
432 elif ndimage.ndim == 3 and ndimage.shape[-1] == 1:
433 raise ValueError("Can't write images with one color channel.")
434 elif ndimage.ndim == 3 and ndimage.shape[-1] in [2, 3, 4]:
435 # Note: this makes a channel-last assumption
436 is_batch = False
437 else:
438 is_batch = True
439
440 if not is_batch:
441 ndimage = ndimage[None, ...]
442
443 for frame in ndimage:
444 pil_frame = Image.fromarray(frame, mode=mode)
445 if "bits" in kwargs:
446 pil_frame = pil_frame.quantize(colors=2 ** kwargs["bits"])
447 self.images_to_write.append(pil_frame)
448
449 if (
450 format is not None
451 and "format" in self.save_args
452 and self.save_args["format"] != format
453 ):
454 old_format = self.save_args["format"]
455 warnings.warn(
456 "Changing the output format during incremental"
457 " writes is strongly discouraged."
458 f" Was `{old_format}`, is now `{format}`.",
459 UserWarning,
460 )
461
462 extension = self.request.extension or self.request.format_hint
463 self.save_args["format"] = format or Image.registered_extensions()[extension]
464 self.save_args.update(kwargs)
465
466 # when writing to `bytes` we flush instantly
467 result = None
468 if self._request._uri_type == URI_BYTES:
469 self._flush_writer()
470 file = cast(BytesIO, self._request.get_file())
471 result = file.getvalue()
472
473 return result
474
475 def _flush_writer(self):
476 if len(self.images_to_write) == 0:
477 return
478
479 primary_image = self.images_to_write.pop(0)
480
481 if len(self.images_to_write) > 0:
482 self.save_args["save_all"] = True
483 self.save_args["append_images"] = self.images_to_write
484
485 primary_image.save(self._request.get_file(), **self.save_args)
486 self.images_to_write.clear()
487 self.save_args.clear()
488
489 def get_meta(self, *, index=0) -> Dict[str, Any]:
490 return self.metadata(index=index, exclude_applied=False)
491
492 def metadata(
493 self, index: int = None, exclude_applied: bool = True
494 ) -> Dict[str, Any]:
495 """Read ndimage metadata.
496
497 Parameters
498 ----------
499 index : {integer, None}
500 If the ImageResource contains multiple ndimages, and index is an
501 integer, select the index-th ndimage from among them and return its
502 metadata. If index is an ellipsis (...), read and return global
503 metadata. If index is None, this plugin reads metadata from the
504 first image of the file (index=0) unless the image is a GIF or APNG,
505 in which case global metadata is read (index=...).
506 exclude_applied : bool
507 If True, exclude metadata fields that are applied to the image while
508 reading. For example, if the binary data contains a rotation flag,
509 the image is rotated by default and the rotation flag is excluded
510 from the metadata to avoid confusion.
511
512 Returns
513 -------
514 metadata : dict
515 A dictionary of format-specific metadata.
516
517 """
518
519 if index is None:
520 if self._image.format == "GIF":
521 index = Ellipsis
522 elif self._image.custom_mimetype == "image/apng":
523 index = Ellipsis
524 else:
525 index = 0
526
527 if isinstance(index, int) and self._image.tell() != index:
528 self._image.seek(index)
529
530 metadata = self._image.info.copy()
531 metadata["mode"] = self._image.mode
532 metadata["shape"] = self._image.size
533
534 if self._image.mode == "P" and not exclude_applied:
535 metadata["palette"] = np.asarray(tuple(self._image.palette.colors.keys()))
536
537 if self._image.getexif():
538 exif_data = {
539 ExifTags.TAGS.get(key, "unknown"): value
540 for key, value in dict(self._image.getexif()).items()
541 }
542 exif_data.pop("unknown", None)
543 metadata.update(exif_data)
544
545 if exclude_applied:
546 metadata.pop("Orientation", None)
547
548 return metadata
549
550 def properties(self, index: int = None) -> ImageProperties:
551 """Standardized ndimage metadata
552 Parameters
553 ----------
554 index : int
555 If the ImageResource contains multiple ndimages, and index is an
556 integer, select the index-th ndimage from among them and return its
557 properties. If index is an ellipsis (...), read and return the
558 properties of all ndimages in the file stacked along a new batch
559 dimension. If index is None, this plugin reads and returns the
560 properties of the first image (index=0) unless the image is a GIF or
561 APNG, in which case it reads and returns the properties all images
562 (index=...).
563
564 Returns
565 -------
566 properties : ImageProperties
567 A dataclass filled with standardized image metadata.
568
569 Notes
570 -----
571 This does not decode pixel data and is fast for large images.
572
573 """
574
575 if index is None:
576 if self._image.format == "GIF":
577 index = Ellipsis
578 elif self._image.custom_mimetype == "image/apng":
579 index = Ellipsis
580 else:
581 index = 0
582
583 if index is Ellipsis:
584 self._image.seek(0)
585 else:
586 self._image.seek(index)
587
588 if self._image.mode == "P":
589 # mode of palette images is determined by their palette
590 mode = self._image.palette.mode
591 else:
592 mode = self._image.mode
593
594 width: int = self._image.width
595 height: int = self._image.height
596 shape: Tuple[int, ...] = (height, width)
597
598 n_frames: Optional[int] = None
599 if index is ...:
600 n_frames = getattr(self._image, "n_frames", 1)
601 shape = (n_frames, *shape)
602
603 dummy = np.asarray(Image.new(mode, (1, 1)))
604 pil_shape: Tuple[int, ...] = dummy.shape
605 if len(pil_shape) > 2:
606 shape = (*shape, *pil_shape[2:])
607
608 return ImageProperties(
609 shape=shape,
610 dtype=dummy.dtype,
611 n_images=n_frames,
612 is_batch=index is Ellipsis,
613 )