pillow_heif.heif — pillow-heif 1.4.0 documentation

# Source code for pillow_heif.heif

    """Functions and classes for heif images to read and write."""

    from copy import copy, deepcopy
    from io import SEEK_SET
    from threading import Lock
    from typing import Any

    from PIL import Image

    from . import options
    from .constants import HeifCompressionFormat
    from .misc import (
        MODE_INFO,
        CtxEncode,
        MimCImage,
        _exif_from_pillow,
        _get_bytes,
        _get_heif_meta,
        _get_orientation_for_encoder,
        _get_primary_index,
        _pil_to_supported_mode,
        _retrieve_exif,
        _retrieve_xmp,
        _rotate_pil,
        _xmp_from_pillow,
        get_file_mimetype,
        save_colorspace_chroma,
        set_orientation,
    )

    try:
        import _pillow_heif
    except ImportError as ex:
        from ._deffered_error import DeferredError

        _pillow_heif = DeferredError(ex)

    class BaseImage:
        """Base class for :py:class:`HeifImage`, :py:class:`HeifDepthImage` and :py:class:`HeifAuxImage`."""

        size: tuple[int, int]
        """Width and height of the image."""

        mode: str
        """A string which defines the type and depth of a pixel in the image:
        `Pillow Modes <https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes>`_

        For currently supported modes by Pillow-Heif see :ref:`image-modes`."""

        def __init__(self, c_image):
            self.size, self.mode = c_image.size_mode
            self._c_image = c_image
            self._data = None
            self._load_lock = Lock()

        @property
        def data(self):
            """Decodes image and returns image data.

            :returns: ``bytes`` of the decoded image.
            """
            self.load()
            return self._data

        @property
        def stride(self) -> int:
            """Stride of the image.

            .. note:: from `0.10.0` version this value always will have width * sizeof pixel in default usage mode.

            :returns: An Int value indicating the image stride after decoding.
            """
            self.load()
            return self._c_image.stride

        @property
        def __array_interface__(self):
            """Numpy array interface support."""
            self.load()
            width = int(self.stride / MODE_INFO[self.mode][0])
            if MODE_INFO[self.mode][1] <= 8:
                typestr = "|u1"
            else:
                width = int(width / 2)
                typestr = "<u2"
            shape: tuple[Any, ...] = (self.size[1], width)
            if MODE_INFO[self.mode][0] > 1:
                shape += (MODE_INFO[self.mode][0],)
            return {"shape": shape, "typestr": typestr, "version": 3, "data": self.data}

        def to_pillow(self) -> Image.Image:
            """Helper method to create :external:py:class:`~PIL.Image.Image` class.

            :returns: :external:py:class:`~PIL.Image.Image` class created from an image.
            """
            self.load()
            return Image.frombytes(
                self.mode,  # noqa
                self.size,
                self.data,
                "raw",
                self.mode,
                self.stride,
            )

        def load(self) -> None:
            """Method to decode image.

            .. note:: In normal cases, you should not call this method directly,
                when reading `data` or `stride` property of image will be loaded automatically.
            """
            if not self._data:
                with self._load_lock:
                    if not self._data:
                        data = self._c_image.data
                        self.size, _ = self._c_image.size_mode
                        self._data = data

    class HeifDepthImage(BaseImage):
        """Class representing the depth image associated with the :py:class:`~pillow_heif.HeifImage` class."""

        def __init__(self, c_image):
            super().__init__(c_image)
            metadata: dict = c_image.metadata
            self.info = {
                "metadata": metadata,
            }
            save_colorspace_chroma(c_image, self.info)

        def __repr__(self):
            return f"<{self.__class__.__name__} {self.size[0]}x{self.size[1]} {self.mode}>"

        def to_pillow(self) -> Image.Image:
            """Helper method to create :external:py:class:`~PIL.Image.Image` class.

            :returns: :external:py:class:`~PIL.Image.Image` class created from an image.
            """
            image = super().to_pillow()
            image.info = self.info.copy()
            return image

    class HeifAuxImage(BaseImage):
        """Class representing the auxiliary image associated with the :py:class:`~pillow_heif.HeifImage` class."""

        def __repr__(self):
            return f"<{self.__class__.__name__} {self.size[0]}x{self.size[1]} {self.mode}>"

    class HeifImage(BaseImage):
        """One image in a :py:class:`~pillow_heif.HeifFile` container."""

        def __init__(self, c_image):
            super().__init__(c_image)
            metadata: list[dict] = c_image.metadata
            exif = _retrieve_exif(metadata)
            xmp = _retrieve_xmp(metadata)
            thumbnails: list[int | None] = [i for i in c_image.thumbnails if i is not None] if options.THUMBNAILS else []
            depth_images: list[HeifDepthImage | None] = (
                [HeifDepthImage(i) for i in c_image.depth_image_list if i is not None] if options.DEPTH_IMAGES else []
            )
            self.info = {
                "primary": bool(c_image.primary),
                "bit_depth": int(c_image.bit_depth),
                "exif": exif,
                "metadata": metadata,
                "thumbnails": thumbnails,
                "depth_images": depth_images,
            }
            if options.AUX_IMAGES:
                ctx_aux_info = {}
                for aux_id in c_image.aux_image_ids:
                    aux_type = c_image.get_aux_type(aux_id)
                    if aux_type not in ctx_aux_info:
                        ctx_aux_info[aux_type] = []
                    ctx_aux_info[aux_type].append(aux_id)
                self.info["aux"] = ctx_aux_info
            heif_meta = _get_heif_meta(c_image)
            if xmp:
                self.info["xmp"] = xmp
            if heif_meta:
                self.info["heif"] = heif_meta
            pixel_aspect_ratio = c_image.pixel_aspect_ratio
            if pixel_aspect_ratio:
                self.info["pixel_aspect_ratio"] = pixel_aspect_ratio
            save_colorspace_chroma(c_image, self.info)
            color_profile: dict[str, Any] = c_image.color_profile
            if color_profile:
                if color_profile["type"] in ("rICC", "prof"):
                    self.info["icc_profile"] = color_profile["data"]
                    self.info["icc_profile_type"] = color_profile["type"]
                else:
                    self.info["nclx_profile"] = color_profile["data"]

        def __repr__(self):
            s_bytes = f"{len(self.data)} bytes" if self._data or isinstance(self._c_image, MimCImage) else "no"
            return (
                f"<{self.__class__.__name__} {self.size[0]}x{self.size[1]} {self.mode} "
                f"with {s_bytes} image data and {len(self.info.get('thumbnails', []))} thumbnails>"
            )

        @property
        def has_alpha(self) -> bool:
            """``True`` for images with the ``alpha`` channel, ``False`` otherwise."""
            return self.mode.split(sep=";")[0][-1] in ("A", "a")

        @property
        def premultiplied_alpha(self) -> bool:
            """``True`` for images with ``premultiplied alpha`` channel, ``False`` otherwise."""
            return bool(self.mode.split(sep=";")[0][-1] == "a")

        @premultiplied_alpha.setter
        def premultiplied_alpha(self, value: bool):
            if self.has_alpha:
                self.mode = self.mode.replace("A" if value else "a", "a" if value else "A")

        def to_pillow(self) -> Image.Image:
            """Helper method to create :external:py:class:`~PIL.Image.Image` class.

            :returns: :external:py:class:`~PIL.Image.Image` class created from an image.
            """
            image = super().to_pillow()
            image.info = self.info.copy()
            image.info["original_orientation"] = set_orientation(image.info)
            return image

        def get_aux_image(self, aux_id: int) -> HeifAuxImage:
            """Method to retrieve the auxiliary image at the given ID.

            :returns: a :py:class:`~pillow_heif.HeifAuxImage` class instance.
            """
            aux_image = self._c_image.get_aux_image(aux_id)
            return HeifAuxImage(aux_image)

    class HeifFile:
        """Representation of the :py:class:`~pillow_heif.HeifImage` classes container.

        To create :py:class:`~pillow_heif.HeifFile` object, use the appropriate factory functions.

        * :py:func:`~pillow_heif.open_heif`
        * :py:func:`~pillow_heif.read_heif`
        * :py:func:`~pillow_heif.from_pillow`
        * :py:func:`~pillow_heif.from_bytes`

        Exceptions that can be raised when working with methods:
            `ValueError`, `EOFError`, `SyntaxError`, `RuntimeError`, `OSError`
        """

        def __init__(self, fp=None, convert_hdr_to_8bit=True, bgr_mode=False, **kwargs):
            if hasattr(fp, "seek"):
                fp.seek(0, SEEK_SET)

            if fp is None:
                images = []
                mimetype = ""
            else:
                fp_bytes = _get_bytes(fp)
                mimetype = get_file_mimetype(fp_bytes)
                if mimetype.find("avif") != -1:
                    preferred_decoder = options.PREFERRED_DECODER.get("AVIF", "")
                elif mimetype.find("heic") != -1 or mimetype.find("heif") != -1:
                    preferred_decoder = options.PREFERRED_DECODER.get("HEIF", "")
                else:
                    preferred_decoder = ""
                images = _pillow_heif.load_file(
                    fp_bytes,
                    options.DECODE_THREADS,
                    convert_hdr_to_8bit,
                    bgr_mode,
                    kwargs.get("remove_stride", True),
                    kwargs.get("hdr_to_16bit", True),
                    preferred_decoder,
                    options.DISABLE_SECURITY_LIMITS,
                )
            self.mimetype = mimetype
            self._images: list[HeifImage] = [HeifImage(i) for i in images if i is not None]
            self.primary_index = 0
            for index, _ in enumerate(self._images):
                if _.info.get("primary", False):
                    self.primary_index = index

        @property
        def size(self):
            """:attr:`~pillow_heif.HeifImage.size` property of the primary :class:`~pillow_heif.HeifImage`.

            :exception IndexError: If there are no images.
            """
            return self._images[self.primary_index].size

        @property
        def mode(self):
            """:attr:`~pillow_heif.HeifImage.mode` property of the primary :class:`~pillow_heif.HeifImage`.

            :exception IndexError: If there are no images.
            """
            return self._images[self.primary_index].mode

        @property
        def has_alpha(self):
            """:attr:`~pillow_heif.HeifImage.has_alpha` property of the primary :class:`~pillow_heif.HeifImage`.

            :exception IndexError: If there are no images.
            """
            return self._images[self.primary_index].has_alpha

        @property
        def premultiplied_alpha(self):
            """:attr:`~pillow_heif.HeifImage.premultiplied_alpha` property of the primary :class:`~pillow_heif.HeifImage`.

            :exception IndexError: If there are no images.
            """
            return self._images[self.primary_index].premultiplied_alpha

        @premultiplied_alpha.setter
        def premultiplied_alpha(self, value: bool):
            self._images[self.primary_index].premultiplied_alpha = value

        @property
        def data(self):
            """:attr:`~pillow_heif.HeifImage.data` property of the primary :class:`~pillow_heif.HeifImage`.

            :exception IndexError: If there are no images.
            """
            return self._images[self.primary_index].data

        @property
        def stride(self):
            """:attr:`~pillow_heif.HeifImage.stride` property of the primary :class:`~pillow_heif.HeifImage`.

            :exception IndexError: If there are no images.
            """
            return self._images[self.primary_index].stride

        @property
        def info(self):
            """`info`` dict of the primary :class:`~pillow_heif.HeifImage` in the container.

            :exception IndexError: If there are no images.
            """
            return self._images[self.primary_index].info

        def to_pillow(self) -> Image.Image:
            """Helper method to create Pillow :external:py:class:`~PIL.Image.Image`.

            :returns: :external:py:class:`~PIL.Image.Image` class created from the primary image.
            """
            return self._images[self.primary_index].to_pillow()

        def save(self, fp, **kwargs) -> None:
            """Saves image(s) under the given fp.

            Keyword options can be used to provide additional instructions to the writer.
            If a writer does not recognize an option, it is silently ignored.

            Supported options:
                ``save_all`` - boolean. Should all images from ``HeiFile`` be saved?
                (default = ``True``)

                ``append_images`` - do the same as in Pillow. Accepts the list of ``HeifImage``

                .. note:: Appended images always will have ``info["primary"]=False``

                ``quality`` - see :py:attr:`~pillow_heif.options.QUALITY`

                ``enc_params`` - dictionary with key:value to pass to :ref:`x265 <hevc-encoder>` encoder.

                ``exif`` - override primary image's EXIF with specified.
                Accepts ``None``, ``bytes`` or ``PIL.Image.Exif`` class.

                ``xmp`` - override primary image's XMP with specified. Accepts ``None`` or ``bytes``.

                ``primary_index`` - ignore ``info["primary"]`` and set `PrimaryImage` by index.

                ``chroma`` - custom subsampling value. Possible values: ``444``, ``422`` or ``420`` (``x265`` default).

                ``subsampling`` - synonym for *chroma*. Format is string, compatible with Pillow: ``x:x:x``, e.g. '4:4:4'.

                ``save_nclx_profile`` - boolean, see :py:attr:`~pillow_heif.options.SAVE_NCLX_PROFILE`

                ``matrix_coefficients`` - int, nclx profile: color conversion matrix coef., default=6 (BT.601, see h.273)

                ``color_primaries`` - int, nclx profile: color primaries (see h.273)

                ``transfer_characteristic`` - int, nclx profile: transfer characteristics (see h.273)

                ``full_range_flag`` - nclx profile: full range flag, default: 1

            :param fp: A filename (string), pathlib.Path object or an object with `write` method.
            """
            _encode_images(self._images, fp, **kwargs)

        def __repr__(self):
            return f"<{self.__class__.__name__} with {len(self)} images: {[str(i) for i in self]}>"

        def __len__(self):
            return len(self._images)

        def __iter__(self):
            yield from self._images

        def __getitem__(self, index):
            if index < 0 or index >= len(self._images):
                raise IndexError(f"invalid image index: {index}")
            return self._images[index]

        def __delitem__(self, key):
            if key < 0 or key >= len(self._images):
                raise IndexError(f"invalid image index: {key}")
            del self._images[key]

        def add_frombytes(self, mode: str, size: tuple[int, int], data, **kwargs):
            """Adds image from bytes to container.

            .. note:: Supports ``stride`` value if needed.

            :param mode: see :ref:`image-modes`.
            :param size: tuple with ``width`` and ``height`` of image.
            :param data: bytes object with raw image data.

            :returns: :py:class:`~pillow_heif.HeifImage` added object.
            """
            added_image = HeifImage(MimCImage(mode, size, data, **kwargs))
            self._images.append(added_image)
            return added_image

        def add_from_heif(self, image: HeifImage) -> HeifImage:
            """Add image to the container.

            :param image: :py:class:`~pillow_heif.HeifImage` class to add from.

            :returns: :py:class:`~pillow_heif.HeifImage` added object.
            """
            image.load()
            added_image = self.add_frombytes(
                image.mode,
                image.size,
                image.data,
                stride=image.stride,
            )
            added_image.info = deepcopy(image.info)
            added_image.info.pop("primary", None)
            return added_image

        def add_from_pillow(self, image: Image.Image) -> HeifImage:
            """Add image to the container.

            :param image: Pillow :external:py:class:`~PIL.Image.Image` class to add from.

            :returns: :py:class:`~pillow_heif.HeifImage` added object.
            """
            if image.size[0] <= 0 or image.size[1] <= 0:
                raise ValueError("Empty images are not supported.")
            info = image.info.copy()
            info["exif"] = _exif_from_pillow(image)
            xmp = _xmp_from_pillow(image)
            if xmp:
                info["xmp"] = xmp
            original_orientation = set_orientation(info)
            img = _pil_to_supported_mode(image)
            if original_orientation is not None and original_orientation != 1:
                img = _rotate_pil(img, original_orientation)
            img.load()
            added_image = self.add_frombytes(
                img.mode,
                img.size,
                img.tobytes(),
            )
            for key in ["bit_depth", "thumbnails", "icc_profile", "icc_profile_type", "pixel_aspect_ratio"]:
                if key in image.info:
                    added_image.info[key] = image.info[key]
            for key in ["nclx_profile", "metadata"]:
                if key in image.info:
                    added_image.info[key] = deepcopy(image.info[key])
            added_image.info["exif"] = _exif_from_pillow(image)
            im_xmp = _xmp_from_pillow(image)
            if im_xmp:
                added_image.info["xmp"] = im_xmp
            return added_image

        @property
        def __array_interface__(self):
            """Returns the primary image as a numpy array."""
            return self._images[self.primary_index].__array_interface__

        def __getstate__(self):
            im_desc = []
            for im in self._images:
                im_data = bytes(im.data)
                im_desc.append([im.mode, im.size, im_data, im.info])
            return [self.primary_index, self.mimetype, im_desc]

        def __setstate__(self, state):
            self.__init__()
            self.primary_index, self.mimetype, images = state
            for im_desc in images:
                im_mode, im_size, im_data, im_info = im_desc
                added_image = self.add_frombytes(im_mode, im_size, im_data)
                added_image.info = im_info

        def __copy(self):
            im_copy = HeifFile()
            im_copy._images = copy(self._images)  # pylint: disable=protected-access
            im_copy.mimetype = self.mimetype
            im_copy.primary_index = self.primary_index
            return im_copy

        def get_aux_image(self, aux_id):
            """`get_aux_image`` method of the primary :class:`~pillow_heif.HeifImage` in the container.

            :exception IndexError: If there are no images.
            """
            return self._images[self.primary_index].get_aux_image(aux_id)

        __copy__ = __copy

    def is_supported(fp) -> bool:
        """Checks if the given `fp` object contains a supported file type.

        :param fp: A filename (string), pathlib.Path object or a file object.
            The file object must implement ``file.read``, ``file.seek``, and ``file.tell`` methods,
            and be opened in binary mode.

        :returns: A boolean indicating if the object can be opened.
        """
        f_data = _get_bytes(fp, 12)
        if f_data[4:8] != b"ftyp":
            return False
        return get_file_mimetype(f_data) != ""

    def open_heif(fp, convert_hdr_to_8bit=True, bgr_mode=False, **kwargs) -> HeifFile:
        """Opens the given HEIF image file.

        :param fp: See parameter ``fp`` in :func:`is_supported`
        :param convert_hdr_to_8bit: Boolean indicating should 10 bit or 12 bit images
            be converted to 8-bit images during decoding. Otherwise, they will open in 16-bit mode.
            ``Does not affect "monochrome" or "depth images".``
        :param bgr_mode: Boolean indicating should be `RGB(A)` images be opened in `BGR(A)` mode.
        :param kwargs: **hdr_to_16bit** a boolean value indicating that 10/12-bit image data
            should be converted to 16-bit mode during decoding. `Has lower priority than convert_hdr_to_8bit`!
            Default = **True**

        :returns: :py:class:`~pillow_heif.HeifFile` object.
        :exception ValueError: invalid input data.
        :exception EOFError: corrupted image data.
        :exception SyntaxError: unsupported feature.
        :exception RuntimeError: some other error.
        :exception OSError: out of memory.
        """
        return HeifFile(fp, convert_hdr_to_8bit, bgr_mode, **kwargs)

    def read_heif(fp, convert_hdr_to_8bit=True, bgr_mode=False, **kwargs) -> HeifFile:
        """Opens the given HEIF image file and decodes all images.

        .. note:: In most cases it is better to call :py:meth:`~pillow_heif.open_heif`, and
            let images decoded automatically only when needed.

        :param fp: See parameter ``fp`` in :func:`is_supported`
        :param convert_hdr_to_8bit: Boolean indicating should 10 bit or 12 bit images
            be converted to 8-bit images during decoding. Otherwise, they will open in 16-bit mode.
            ``Does not affect "monochrome" or "depth images".``
        :param bgr_mode: Boolean indicating should be `RGB(A)` images be opened in `BGR(A)` mode.
        :param kwargs: **hdr_to_16bit** a boolean value indicating that 10/12-bit image data
            should be converted to 16-bit mode during decoding. `Has lower priority than convert_hdr_to_8bit`!
            Default = **True**

        :returns: :py:class:`~pillow_heif.HeifFile` object.
        :exception ValueError: invalid input data.
        :exception EOFError: corrupted image data.
        :exception SyntaxError: unsupported feature.
        :exception RuntimeError: some other error.
        :exception OSError: out of memory.
        """
        ret = HeifFile(fp, convert_hdr_to_8bit, bgr_mode, **kwargs)
        for img in ret:
            img.load()
        return ret

    def encode(mode: str, size: tuple[int, int], data, fp, **kwargs) -> None:
        """Encodes data in a ``fp``.

        :param mode: `BGR(A);16`, `RGB(A);16`, LA;16`, `L;16`, `I;16L`, `BGR(A)`, `RGB(A)`, `LA`, `L`
        :param size: tuple with ``width`` and ``height`` of an image.
        :param data: bytes object with raw image data.
        :param fp: A filename (string), pathlib.Path object or an object with ``write`` method.
        """
        _encode_images([HeifImage(MimCImage(mode, size, data, **kwargs))], fp, **kwargs)

    def _encode_images(images: list[HeifImage], fp, **kwargs) -> None:
        compression = kwargs.get("format", "HEIF")
        compression_format = HeifCompressionFormat.AV1 if compression == "AVIF" else HeifCompressionFormat.HEVC
        if not _pillow_heif.get_lib_info()[compression]:
            raise RuntimeError(f"No {compression} encoder found.")
        images_to_save: list[HeifImage] = images + kwargs.get("append_images", [])
        if not kwargs.get("save_all", True):
            images_to_save = images_to_save[:1]
        if not images_to_save:
            raise ValueError("Cannot write file with no images as HEIF.")
        primary_index = _get_primary_index(images_to_save, kwargs.get("primary_index"))
        ctx_write = CtxEncode(compression_format, **kwargs)
        for i, img in enumerate(images_to_save):
            img.load()
            info = img.info.copy()
            info["primary"] = False
            if i == primary_index:
                info.update(**kwargs)
                info["primary"] = True
            info.pop("stride", 0)
            ctx_write.add_image(
                img.size,
                img.mode,
                img.data,
                image_orientation=_get_orientation_for_encoder(info),
                **info,
                stride=img.stride,
            )
        ctx_write.save(fp)

    def from_pillow(pil_image: Image.Image) -> HeifFile:
        """Creates :py:class:`~pillow_heif.HeifFile` from a Pillow Image.

        :param pil_image: Pillow :external:py:class:`~PIL.Image.Image` class.

        :returns: New :py:class:`~pillow_heif.HeifFile` object.
        """
        _ = HeifFile()
        _.add_from_pillow(pil_image)
        return _

    def from_bytes(mode: str, size: tuple[int, int], data, **kwargs) -> HeifFile:
        """Creates :py:class:`~pillow_heif.HeifFile` from bytes.

        .. note:: Supports ``stride`` value if needed.

        :param mode: see :ref:`image-modes`.
        :param size: tuple with ``width`` and ``height`` of an image.
        :param data: bytes object with raw image data.

        :returns: New :py:class:`~pillow_heif.HeifFile` object.
        """
        _ = HeifFile()
        _.add_frombytes(mode, size, data, **kwargs)
        return _