pillow.py 22 KB

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