| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614 |
- # -*- coding: utf-8 -*-
- # imageio is distributed under the terms of the (new) BSD License.
- """Read/Write images using Pillow/PIL.
- Backend Library: `Pillow <https://pillow.readthedocs.io/en/stable/>`_
- Plugin that wraps the the Pillow library. Pillow is a friendly fork of PIL
- (Python Image Library) and supports reading and writing of common formats (jpg,
- png, gif, tiff, ...). For, the complete list of features and supported formats
- please refer to pillows official docs (see the Backend Library link).
- Parameters
- ----------
- request : Request
- A request object representing the resource to be operated on.
- Methods
- -------
- .. autosummary::
- :toctree: _plugins/pillow
- PillowPlugin.read
- PillowPlugin.write
- PillowPlugin.iter
- PillowPlugin.get_meta
- """
- import sys
- import warnings
- from io import BytesIO
- from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union, cast
- import numpy as np
- from PIL import ExifTags, GifImagePlugin, Image, ImageSequence, UnidentifiedImageError
- from PIL import __version__ as pil_version # type: ignore
- from ..core.request import URI_BYTES, InitializationError, IOMode, Request
- from ..core.v3_plugin_api import ImageProperties, PluginV3
- from ..typing import ArrayLike
- def pillow_version() -> Tuple[int]:
- return tuple(int(x) for x in pil_version.split("."))
- def _exif_orientation_transform(orientation: int, mode: str) -> Callable:
- # get transformation that transforms an image from a
- # given EXIF orientation into the standard orientation
- # -1 if the mode has color channel, 0 otherwise
- axis = -2 if Image.getmodebands(mode) > 1 else -1
- EXIF_ORIENTATION = {
- 1: lambda x: x,
- 2: lambda x: np.flip(x, axis=axis),
- 3: lambda x: np.rot90(x, k=2),
- 4: lambda x: np.flip(x, axis=axis - 1),
- 5: lambda x: np.flip(np.rot90(x, k=3), axis=axis),
- 6: lambda x: np.rot90(x, k=3),
- 7: lambda x: np.flip(np.rot90(x, k=1), axis=axis),
- 8: lambda x: np.rot90(x, k=1),
- }
- # Some buggy/legacy software may not write the correct orientation (i.e. 0)
- # No transformation if orientation is unknown or missing
- return EXIF_ORIENTATION.get(orientation, lambda x: x)
- class PillowPlugin(PluginV3):
- def __init__(self, request: Request) -> None:
- """Instantiate a new Pillow Plugin Object
- Parameters
- ----------
- request : {Request}
- A request object representing the resource to be operated on.
- """
- super().__init__(request)
- # Register HEIF opener for Pillow
- try:
- from pillow_heif import register_heif_opener
- except ImportError:
- pass
- else:
- register_heif_opener()
- # Register AVIF opener for Pillow
- try:
- from pillow_heif import register_avif_opener
- except ImportError:
- pass
- else:
- register_avif_opener()
- self._image: Image = None
- self.images_to_write = []
- if request.mode.io_mode == IOMode.read:
- try:
- # Check if it is generally possible to read the image.
- # This will not read any data and merely try to find a
- # compatible pillow plugin (ref: the pillow docs).
- image = Image.open(request.get_file())
- except UnidentifiedImageError:
- if request._uri_type == URI_BYTES:
- raise InitializationError(
- "Pillow can not read the provided bytes."
- ) from None
- else:
- raise InitializationError(
- f"Pillow can not read {request.raw_uri}."
- ) from None
- self._image = image
- else:
- self.save_args = {}
- extension = self.request.extension or self.request.format_hint
- if extension is None:
- warnings.warn(
- "Can't determine file format to write as. You _must_"
- " set `format` during write or the call will fail. Use "
- "`extension` to supress this warning. ",
- UserWarning,
- )
- return
- tirage = [Image.preinit, Image.init]
- for format_loader in tirage:
- format_loader()
- if extension in Image.registered_extensions().keys():
- return
- raise InitializationError(
- f"Pillow can not write `{extension}` files."
- ) from None
- def close(self) -> None:
- self._flush_writer()
- if self._image:
- self._image.close()
- self._request.finish()
- def read(
- self,
- *,
- index: int = None,
- mode: str = None,
- rotate: bool = False,
- apply_gamma: bool = False,
- writeable_output: bool = True,
- pilmode: str = None,
- exifrotate: bool = None,
- as_gray: bool = None,
- ) -> np.ndarray:
- """
- Parses the given URI and creates a ndarray from it.
- Parameters
- ----------
- index : int
- If the ImageResource contains multiple ndimages, and index is an
- integer, select the index-th ndimage from among them and return it.
- If index is an ellipsis (...), read all ndimages in the file and
- stack them along a new batch dimension and return them. If index is
- None, this plugin reads the first image of the file (index=0) unless
- the image is a GIF or APNG, in which case all images are read
- (index=...).
- mode : str
- Convert the image to the given mode before returning it. If None,
- the mode will be left unchanged. Possible modes can be found at:
- https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes
- rotate : bool
- If True and the image contains an EXIF orientation tag,
- apply the orientation before returning the ndimage.
- apply_gamma : bool
- If True and the image contains metadata about gamma, apply gamma
- correction to the image.
- writable_output : bool
- If True, ensure that the image is writable before returning it to
- the user. This incurs a full copy of the pixel data if the data
- served by pillow is read-only. Consequentially, setting this flag to
- False improves performance for some images.
- pilmode : str
- Deprecated, use `mode` instead.
- exifrotate : bool
- Deprecated, use `rotate` instead.
- as_gray : bool
- Deprecated. Exists to raise a constructive error message.
- Returns
- -------
- ndimage : ndarray
- A numpy array containing the loaded image data
- Notes
- -----
- If you read a paletted image (e.g. GIF) then the plugin will apply the
- palette by default. Should you wish to read the palette indices of each
- pixel use ``mode="P"``. The coresponding color pallete can be found in
- the image's metadata using the ``palette`` key when metadata is
- extracted using the ``exclude_applied=False`` kwarg. The latter is
- needed, as palettes are applied by default and hence excluded by default
- to keep metadata and pixel data consistent.
- """
- if pilmode is not None:
- warnings.warn(
- "`pilmode` is deprecated. Use `mode` instead.", DeprecationWarning
- )
- mode = pilmode
- if exifrotate is not None:
- warnings.warn(
- "`exifrotate` is deprecated. Use `rotate` instead.", DeprecationWarning
- )
- rotate = exifrotate
- if as_gray is not None:
- raise TypeError(
- "The keyword `as_gray` is no longer supported."
- "Use `mode='F'` for a backward-compatible result, or "
- " `mode='L'` for an integer-valued result."
- )
- if self._image.format == "GIF":
- # Converting GIF P frames to RGB
- # https://github.com/python-pillow/Pillow/pull/6150
- GifImagePlugin.LOADING_STRATEGY = (
- GifImagePlugin.LoadingStrategy.RGB_AFTER_DIFFERENT_PALETTE_ONLY
- )
- if index is None:
- if self._image.format == "GIF":
- index = Ellipsis
- elif self._image.custom_mimetype == "image/apng":
- index = Ellipsis
- else:
- index = 0
- if isinstance(index, int):
- # will raise IO error if index >= number of frames in image
- self._image.seek(index)
- image = self._apply_transforms(
- self._image, mode, rotate, apply_gamma, writeable_output
- )
- else:
- iterator = self.iter(
- mode=mode,
- rotate=rotate,
- apply_gamma=apply_gamma,
- writeable_output=writeable_output,
- )
- image = np.stack([im for im in iterator], axis=0)
- return image
- def iter(
- self,
- *,
- mode: str = None,
- rotate: bool = False,
- apply_gamma: bool = False,
- writeable_output: bool = True,
- ) -> Iterator[np.ndarray]:
- """
- Iterate over all ndimages/frames in the URI
- Parameters
- ----------
- mode : {str, None}
- Convert the image to the given mode before returning it. If None,
- the mode will be left unchanged. Possible modes can be found at:
- https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes
- rotate : {bool}
- If set to ``True`` and the image contains an EXIF orientation tag,
- apply the orientation before returning the ndimage.
- apply_gamma : {bool}
- If ``True`` and the image contains metadata about gamma, apply gamma
- correction to the image.
- writable_output : bool
- If True, ensure that the image is writable before returning it to
- the user. This incurs a full copy of the pixel data if the data
- served by pillow is read-only. Consequentially, setting this flag to
- False improves performance for some images.
- """
- for im in ImageSequence.Iterator(self._image):
- yield self._apply_transforms(
- im, mode, rotate, apply_gamma, writeable_output
- )
- def _apply_transforms(
- self, image, mode, rotate, apply_gamma, writeable_output
- ) -> np.ndarray:
- if mode is not None:
- image = image.convert(mode)
- elif image.mode == "P":
- # adjust for pillow9 changes
- # see: https://github.com/python-pillow/Pillow/issues/5929
- image = image.convert(image.palette.mode)
- elif image.format == "PNG" and image.mode == "I":
- major, minor, patch = pillow_version()
- if sys.byteorder == "little":
- desired_mode = "I;16"
- else: # pragma: no cover
- # can't test big-endian in GH-Actions
- desired_mode = "I;16B"
- if major < 10: # pragma: no cover
- warnings.warn(
- "Loading 16-bit (uint16) PNG as int32 due to limitations "
- "in pillow's PNG decoder. This will be fixed in a future "
- "version of pillow which will make this warning dissapear.",
- UserWarning,
- )
- elif minor < 1: # pragma: no cover
- # pillow<10.1.0 can directly decode into 16-bit grayscale
- image.mode = desired_mode
- else:
- # pillow >= 10.1.0
- image = image.convert(desired_mode)
- image = np.asarray(image)
- meta = self.metadata(index=self._image.tell(), exclude_applied=False)
- if rotate and "Orientation" in meta:
- transformation = _exif_orientation_transform(
- meta["Orientation"], self._image.mode
- )
- image = transformation(image)
- if apply_gamma and "gamma" in meta:
- gamma = float(meta["gamma"])
- scale = float(65536 if image.dtype == np.uint16 else 255)
- gain = 1.0
- image = ((image / scale) ** gamma) * scale * gain + 0.4999
- image = np.round(image).astype(np.uint8)
- if writeable_output and not image.flags["WRITEABLE"]:
- image = np.array(image)
- return image
- def write(
- self,
- ndimage: Union[ArrayLike, List[ArrayLike]],
- *,
- mode: str = None,
- format: str = None,
- is_batch: bool = None,
- **kwargs,
- ) -> Optional[bytes]:
- """
- Write an ndimage to the URI specified in path.
- If the URI points to a file on the current host and the file does not
- yet exist it will be created. If the file exists already, it will be
- appended if possible; otherwise, it will be replaced.
- If necessary, the image is broken down along the leading dimension to
- fit into individual frames of the chosen format. If the format doesn't
- support multiple frames, and IOError is raised.
- Parameters
- ----------
- image : ndarray or list
- The ndimage to write. If a list is given each element is expected to
- be an ndimage.
- mode : str
- Specify the image's color format. If None (default), the mode is
- inferred from the array's shape and dtype. Possible modes can be
- found at:
- https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes
- format : str
- Optional format override. If omitted, the format to use is
- determined from the filename extension. If a file object was used
- instead of a filename, this parameter must always be used.
- is_batch : bool
- Explicitly tell the writer that ``image`` is a batch of images
- (True) or not (False). If None, the writer will guess this from the
- provided ``mode`` or ``image.shape``. While the latter often works,
- it may cause problems for small images due to aliasing of spatial
- and color-channel axes.
- kwargs : ...
- Extra arguments to pass to pillow. If a writer doesn't recognise an
- option, it is silently ignored. The available options are described
- in pillow's `image format documentation
- <https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html>`_
- for each writer.
- Notes
- -----
- When writing batches of very narrow (2-4 pixels wide) gray images set
- the ``mode`` explicitly to avoid the batch being identified as a colored
- image.
- """
- if "fps" in kwargs:
- warnings.warn(
- "The keyword `fps` is no longer supported. Use `duration`"
- "(in ms) instead, e.g. `fps=50` == `duration=20` (1000 * 1/50).",
- DeprecationWarning,
- )
- kwargs["duration"] = 1000 * 1 / kwargs.get("fps")
- if isinstance(ndimage, list):
- ndimage = np.stack(ndimage, axis=0)
- is_batch = True
- else:
- ndimage = np.asarray(ndimage)
- # check if ndimage is a batch of frames/pages (e.g. for writing GIF)
- # if mode is given, use it; otherwise fall back to image.ndim only
- if is_batch is not None:
- pass
- elif mode is not None:
- is_batch = (
- ndimage.ndim > 3 if Image.getmodebands(mode) > 1 else ndimage.ndim > 2
- )
- elif ndimage.ndim == 2:
- is_batch = False
- elif ndimage.ndim == 3 and ndimage.shape[-1] == 1:
- raise ValueError("Can't write images with one color channel.")
- elif ndimage.ndim == 3 and ndimage.shape[-1] in [2, 3, 4]:
- # Note: this makes a channel-last assumption
- is_batch = False
- else:
- is_batch = True
- if not is_batch:
- ndimage = ndimage[None, ...]
- for frame in ndimage:
- pil_frame = Image.fromarray(frame, mode=mode)
- if "bits" in kwargs:
- pil_frame = pil_frame.quantize(colors=2 ** kwargs["bits"])
- self.images_to_write.append(pil_frame)
- if (
- format is not None
- and "format" in self.save_args
- and self.save_args["format"] != format
- ):
- old_format = self.save_args["format"]
- warnings.warn(
- "Changing the output format during incremental"
- " writes is strongly discouraged."
- f" Was `{old_format}`, is now `{format}`.",
- UserWarning,
- )
- extension = self.request.extension or self.request.format_hint
- self.save_args["format"] = format or Image.registered_extensions()[extension]
- self.save_args.update(kwargs)
- # when writing to `bytes` we flush instantly
- result = None
- if self._request._uri_type == URI_BYTES:
- self._flush_writer()
- file = cast(BytesIO, self._request.get_file())
- result = file.getvalue()
- return result
- def _flush_writer(self):
- if len(self.images_to_write) == 0:
- return
- primary_image = self.images_to_write.pop(0)
- if len(self.images_to_write) > 0:
- self.save_args["save_all"] = True
- self.save_args["append_images"] = self.images_to_write
- primary_image.save(self._request.get_file(), **self.save_args)
- self.images_to_write.clear()
- self.save_args.clear()
- def get_meta(self, *, index=0) -> Dict[str, Any]:
- return self.metadata(index=index, exclude_applied=False)
- def metadata(
- self, index: int = None, exclude_applied: bool = True
- ) -> Dict[str, Any]:
- """Read ndimage metadata.
- Parameters
- ----------
- index : {integer, None}
- If the ImageResource contains multiple ndimages, and index is an
- integer, select the index-th ndimage from among them and return its
- metadata. If index is an ellipsis (...), read and return global
- metadata. If index is None, this plugin reads metadata from the
- first image of the file (index=0) unless the image is a GIF or APNG,
- in which case global metadata is read (index=...).
- exclude_applied : bool
- If True, exclude metadata fields that are applied to the image while
- reading. For example, if the binary data contains a rotation flag,
- the image is rotated by default and the rotation flag is excluded
- from the metadata to avoid confusion.
- Returns
- -------
- metadata : dict
- A dictionary of format-specific metadata.
- """
- if index is None:
- if self._image.format == "GIF":
- index = Ellipsis
- elif self._image.custom_mimetype == "image/apng":
- index = Ellipsis
- else:
- index = 0
- if isinstance(index, int) and self._image.tell() != index:
- self._image.seek(index)
- metadata = self._image.info.copy()
- metadata["mode"] = self._image.mode
- metadata["shape"] = self._image.size
- if self._image.mode == "P" and not exclude_applied:
- metadata["palette"] = np.asarray(tuple(self._image.palette.colors.keys()))
- if self._image.getexif():
- exif_data = {
- ExifTags.TAGS.get(key, "unknown"): value
- for key, value in dict(self._image.getexif()).items()
- }
- exif_data.pop("unknown", None)
- metadata.update(exif_data)
- if exclude_applied:
- metadata.pop("Orientation", None)
- return metadata
- def properties(self, index: int = None) -> ImageProperties:
- """Standardized ndimage metadata
- Parameters
- ----------
- index : int
- If the ImageResource contains multiple ndimages, and index is an
- integer, select the index-th ndimage from among them and return its
- properties. If index is an ellipsis (...), read and return the
- properties of all ndimages in the file stacked along a new batch
- dimension. If index is None, this plugin reads and returns the
- properties of the first image (index=0) unless the image is a GIF or
- APNG, in which case it reads and returns the properties all images
- (index=...).
- Returns
- -------
- properties : ImageProperties
- A dataclass filled with standardized image metadata.
- Notes
- -----
- This does not decode pixel data and is fast for large images.
- """
- if index is None:
- if self._image.format == "GIF":
- index = Ellipsis
- elif self._image.custom_mimetype == "image/apng":
- index = Ellipsis
- else:
- index = 0
- if index is Ellipsis:
- self._image.seek(0)
- else:
- self._image.seek(index)
- if self._image.mode == "P":
- # mode of palette images is determined by their palette
- mode = self._image.palette.mode
- else:
- mode = self._image.mode
- width: int = self._image.width
- height: int = self._image.height
- shape: Tuple[int, ...] = (height, width)
- n_frames: Optional[int] = None
- if index is ...:
- n_frames = getattr(self._image, "n_frames", 1)
- shape = (n_frames, *shape)
- dummy = np.asarray(Image.new(mode, (1, 1)))
- pil_shape: Tuple[int, ...] = dummy.shape
- if len(pil_shape) > 2:
- shape = (*shape, *pil_shape[2:])
- return ImageProperties(
- shape=shape,
- dtype=dummy.dtype,
- n_images=n_frames,
- is_batch=index is Ellipsis,
- )
|