"""Plugins for the Pillow library."""
from itertools import chain
from typing import IO
from warnings import warn
from PIL import Image, ImageFile, ImageSequence
from . import options
from .constants import HeifCompressionFormat
from .heif import HeifFile, HeifImage, HeifThumbnail
from .misc import (
CtxEncode,
_exif_from_pillow,
_get_bytes,
_get_orientation_for_encoder,
_get_primary_index,
_pil_to_supported_mode,
_xmp_from_pillow,
set_orientation,
)
try:
import _pillow_heif
except ImportError as ex:
from ._deffered_error import DeferredError
_pillow_heif = DeferredError(ex)
[docs]
class _LibHeifImageFile(ImageFile.ImageFile):
"""Base class with all functionality for ``HeifImageFile`` class."""
_heif_file: HeifFile | None = None
_close_exclusive_fp_after_loading = True
_mode: str
def __init__(self, *args, **kwargs):
self.__frame = 0
self.__thumbnail: HeifThumbnail | None = None
self.__loaded = False
super().__init__(*args, **kwargs)
def _open(self):
try:
# when Pillow starts supporting 16-bit multichannel images change `convert_hdr_to_8bit` to False
heif_file = HeifFile(self.fp, convert_hdr_to_8bit=True, hdr_to_16bit=True, remove_stride=False)
except (OSError, ValueError, SyntaxError, RuntimeError, EOFError) as exception:
raise SyntaxError(str(exception)) from None
self.custom_mimetype = heif_file.mimetype
self._heif_file = heif_file
self.__frame = heif_file.primary_index
self._init_from_heif_file(self.__frame)
self.tile = []
[docs]
def load(self):
if self._heif_file and not self.__loaded:
frame_heif = self.__thumbnail or self._heif_file[self.tell()]
try:
data = frame_heif.data # Size of Image can change during decoding
self._size = frame_heif.size # noqa
self.load_prepare()
self.frombytes(data, "raw", (frame_heif.mode, frame_heif.stride))
except (EOFError, ValueError):
if not ImageFile.LOAD_TRUNCATED_IMAGES:
raise
self.load_prepare()
self.__loaded = True
# In any case, we close `fp`, since the input data bytes are held by the `HeifFile` class.
if self.fp and getattr(self, "_exclusive_fp", False) and hasattr(self.fp, "close"):
self.fp.close()
self.fp = None
if not self.is_animated:
self._heif_file = None
self.__thumbnail = None
return super().load()
def load_prepare(self) -> None:
# Pillow 11+ creates the image memory only when there is none, `seek` and `draft` can change the size or mode
if self._im is not None and (self.im.size != self.size or self.im.mode != self.mode):
self.im = Image.core.new(self.mode, self.size) # pylint: disable=too-many-function-args
super().load_prepare()
[docs]
def draft(self, mode: str | None, size: tuple[int, int] | None) -> tuple[str, tuple[int, int, float, float]] | None:
"""Configures the loader to decode an embedded thumbnail instead of the image.
The smallest thumbnail that is not smaller than ``size`` and is a scaled copy of the image (same mode,
aspect ratio, crop, rotation and mirroring, no other color profile) is selected, ``mode`` is ignored.
:external:py:meth:`~PIL.Image.Image.thumbnail` calls this with ``size`` multiplied by its ``reducing_gap``.
:returns: ``(mode, box)`` when a thumbnail was selected, ``None`` otherwise.
"""
if not size or not self._heif_file or self.__loaded or self.__thumbnail:
return None
thumbnail = _thumbnail_for_size(self._heif_file[self.tell()], size)
if thumbnail is None:
return None
self.__thumbnail = thumbnail
self._size = thumbnail.size
self._mode = thumbnail.mode
return self.mode, (0, 0, *thumbnail.size)
[docs]
def seek(self, frame: int):
if not self._seek_check(frame):
return
self.__frame = frame
self._init_from_heif_file(frame)
# Pillow 11.0+
# We need to create a new core image object on second and
# subsequent frames in the image. Image may be different size/mode.
# https://github.com/python-pillow/Pillow/issues/8439
self.im = Image.core.new(self._mode, self._size) # pylint: disable=too-many-function-args
exif = getattr(self, "_exif", None) # Pillow 9.2+ do no reload exif between frames.
if exif is not None and getattr(exif, "_loaded", None):
exif._loaded = False # pylint: disable=protected-access
[docs]
def tell(self) -> int:
return self.__frame
[docs]
def verify(self) -> None:
pass
@property
def n_frames(self) -> int:
"""Number of available frames.
:returns: Frame number, starting with 0.
"""
return len(self._heif_file) if self._heif_file else 1
@property
def is_animated(self) -> bool:
"""``True`` if this image contains more than one frame, ``False`` otherwise."""
return self.n_frames > 1
def _seek_check(self, frame: int):
if frame < 0 or frame >= self.n_frames:
raise EOFError("attempt to seek outside sequence")
return self.tell() != frame
def _init_from_heif_file(self, img_index: int) -> None:
if self._heif_file:
self.__thumbnail = None
self.__loaded = False
self._size = self._heif_file[img_index].size
self._mode = self._heif_file[img_index].mode
self.info = self._heif_file[img_index].info
self.info["original_orientation"] = set_orientation(self.info)
[docs]
class HeifImageFile(_LibHeifImageFile):
"""Pillow plugin class type for a HEIF image format."""
format = "HEIF" # noqa
format_description = "HEIF container"
def _thumbnail_for_size(image: HeifImage, size: tuple[int, int]) -> HeifThumbnail | None:
candidates = []
for index in range(len(image.info.get("thumbnails", []))):
try:
thumbnail = image.get_thumbnail(index)
except (OSError, ValueError, SyntaxError, RuntimeError, EOFError, IndexError):
continue
if _is_scaled_copy(thumbnail, image, size):
candidates.append(thumbnail)
for thumbnail in sorted(candidates, key=lambda i: i.size[0] * i.size[1]):
try:
thumbnail.load()
except (OSError, ValueError, SyntaxError, RuntimeError, EOFError):
continue
return thumbnail
return None
def _is_scaled_copy(thumbnail: HeifThumbnail, image: HeifImage, size: tuple[int, int]) -> bool:
width, height = image.size
t_width, t_height = thumbnail.size
if thumbnail.mode != image.mode or t_width < size[0] or t_height < size[1]:
return False
if t_width >= width and t_height >= height:
return False
if abs(t_width * height - t_height * width) > 2 * max(width, height): # aspect ratio differs beyond rounding
return False
transformations = image._c_image.transformations # pylint: disable=protected-access
t_transformations = thumbnail._c_image.transformations # pylint: disable=protected-access
if _orientation(t_transformations) != _orientation(transformations):
return False
if not _same_crop(t_transformations, transformations):
return False
profile, t_profile = _color_profile(image.info), _color_profile(thumbnail.info)
return profile is None or t_profile is None or profile == t_profile
def _orientation(transformations: tuple) -> tuple:
return tuple(i for i in transformations if i[0] != "clap")
def _crop(transformations: tuple) -> list:
# a `clap` that only removes the padding of an encoder (anchored top-left, less than 64 pixels) is not a crop
return [i[1:] for i in transformations if i[0] == "clap" and (i[1] or i[2] or i[3] >= 64 or i[4] >= 64)]
def _same_crop(t_transformations: tuple, transformations: tuple) -> bool:
t_crop, crop = _crop(t_transformations), _crop(transformations)
if len(t_crop) != len(crop):
return False
for t_clap, clap in zip(t_crop, crop, strict=True): # (left, top, right, bottom, width, height)
for border, size in ((0, 4), (2, 4), (1, 5), (3, 5)):
if abs(t_clap[border] * clap[size] - clap[border] * t_clap[size]) > 2 * max(clap[size], t_clap[size]):
return False # the crop differs beyond rounding
return True
def _color_profile(info: dict) -> tuple[str, object] | None:
if "icc_profile" in info:
return "icc", info["icc_profile"]
if "nclx_profile" in info:
return "nclx", info["nclx_profile"]
return None
def _is_supported_heif(fp) -> bool:
magic = _get_bytes(fp, 12)
if magic[4:8] != b"ftyp":
return False
return magic[8:12] in (b"heic", b"heix", b"heim", b"heis", b"hevc", b"hevx", b"hevm", b"hevs", b"mif1", b"msf1")
def _save_heif(im: Image.Image, fp: IO[bytes], _filename: str | bytes):
__save_one(im, fp, HeifCompressionFormat.HEVC)
def _save_all_heif(im: Image.Image, fp: IO[bytes], _filename: str | bytes):
__save_all(im, fp, HeifCompressionFormat.HEVC)
[docs]
def register_heif_opener(**kwargs) -> None:
"""Registers a Pillow plugin for HEIF format.
:param kwargs: dictionary with values to set in options. See: :ref:`options`.
"""
__options_update(**kwargs)
Image.register_open(HeifImageFile.format, HeifImageFile, _is_supported_heif)
if _pillow_heif.get_lib_info()["HEIF"]:
Image.register_save(HeifImageFile.format, _save_heif)
Image.register_save_all(HeifImageFile.format, _save_all_heif)
extensions = [".heic", ".heics", ".heif", ".heifs", ".hif"]
Image.register_mime(HeifImageFile.format, "image/heif")
Image.register_extensions(HeifImageFile.format, extensions)
def __options_update(**kwargs):
"""Internal function to set options from `register_heif_opener` method."""
for k, v in kwargs.items():
if k == "thumbnails":
options.THUMBNAILS = v
elif k == "depth_images":
options.DEPTH_IMAGES = v
elif k == "aux_images":
options.AUX_IMAGES = v
elif k == "quality":
options.QUALITY = v
elif k == "save_to_12bit":
options.SAVE_HDR_TO_12_BIT = v
elif k == "decode_threads":
options.DECODE_THREADS = v
elif k == "save_nclx_profile":
options.SAVE_NCLX_PROFILE = v
elif k == "preferred_encoder":
options.PREFERRED_ENCODER = v
elif k == "preferred_decoder":
options.PREFERRED_DECODER = v
elif k == "grid_tile_size":
options.GRID_TILE_SIZE = v
else:
warn(f"Unknown option: {k}", stacklevel=1)
def __save_one(im: Image.Image, fp: IO[bytes], compression_format: HeifCompressionFormat):
ctx_write = CtxEncode(compression_format, **im.encoderinfo)
_pil_encode_image(ctx_write, im, True, **im.encoderinfo)
ctx_write.save(fp)
def __save_all(im: Image.Image, fp: IO[bytes], compression_format: HeifCompressionFormat):
ctx_write = CtxEncode(compression_format, **im.encoderinfo)
current_frame = im.tell() if hasattr(im, "tell") else None
append_images = im.encoderinfo.get("append_images", [])
primary_index = _get_primary_index(
chain(ImageSequence.Iterator(im), append_images), im.encoderinfo.get("primary_index", None)
)
for i, frame in enumerate(chain(ImageSequence.Iterator(im), append_images)):
_pil_encode_image(ctx_write, frame, i == primary_index, **im.encoderinfo)
if current_frame is not None and hasattr(im, "seek"):
im.seek(current_frame)
ctx_write.save(fp)
def _pil_encode_image(ctx: CtxEncode, img: Image.Image, primary: bool, **kwargs) -> None:
if img.size[0] <= 0 or img.size[1] <= 0:
raise ValueError("Empty images are not supported.")
info = {k: v for k, v in img.info.items() if isinstance(k, str)}
info["exif"] = _exif_from_pillow(img)
info["xmp"] = _xmp_from_pillow(img)
info.update(**kwargs)
info["primary"] = primary
if img.mode == "YCbCr":
ctx.add_image_ycbcr(img, image_orientation=_get_orientation_for_encoder(info), **info)
else:
img = _pil_to_supported_mode(img)
ctx.add_image(img.size, img.mode, img.tobytes(), image_orientation=_get_orientation_for_encoder(info), **info)