diff --git a/.github/workflows/build-cache-deps.yml b/.github/workflows/build-cache-deps.yml index 7868a74a..9e42470b 100644 --- a/.github/workflows/build-cache-deps.yml +++ b/.github/workflows/build-cache-deps.yml @@ -35,7 +35,7 @@ jobs: - name: manylinux preparations if: matrix.cibw_buildlinux == 'manylinux' - run: echo INSTALL_OS_PACKAGES="yum update -y && yum install -y $OS_PACKAGES" >> $GITHUB_ENV + run: echo INSTALL_OS_PACKAGES="yum makecache && yum install -y $OS_PACKAGES" >> $GITHUB_ENV env: OS_PACKAGES: "git-all" diff --git a/.github/workflows/test-wheels.yaml b/.github/workflows/test-wheels.yaml index 1bb5b27d..34f3eb7d 100644 --- a/.github/workflows/test-wheels.yaml +++ b/.github/workflows/test-wheels.yaml @@ -33,6 +33,10 @@ jobs: ] steps: + - name: Delay, waiting Pypi to update. + if: ${{ github.event_name != 'workflow_dispatch' }} + run: sleep 60 + - uses: actions/checkout@v3 - name: Set up QEMU if: matrix.i['arch'] == 'arm64' @@ -73,6 +77,11 @@ jobs: python-version: ["3.7", "3.8", "3.9", "3.10"] steps: + - name: Delay, waiting Pypi to update. + if: ${{ github.event_name != 'workflow_dispatch' }} + run: Start-Sleep -s 60 + shell: powershell + - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v3 @@ -85,7 +94,7 @@ jobs: python3 -m pip install --only-binary=:all: pillow_heif - name: Test wheel - run: cd .. && python3 -m pytest -s pillow_heif + run: cd .. && python3 -m pytest -rs pillow_heif macos-wheels: if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} @@ -96,6 +105,10 @@ jobs: python-version: ["pypy-3.7", "pypy-3.8", "3.7", "3.8", "3.9", "3.10"] steps: + - name: Delay, waiting Pypi to update. + if: ${{ github.event_name != 'workflow_dispatch' }} + run: sleep 60 + - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v3 @@ -109,7 +122,7 @@ jobs: python3 -m pip install --only-binary=:all: pillow_heif - name: Test wheel - run: cd .. && python3 -m pytest -s pillow_heif + run: cd .. && python3 -m pytest -rs pillow_heif manylinux-wheels: if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} @@ -120,6 +133,10 @@ jobs: python-version: ["pypy-3.7", "pypy-3.8", "3.6", "3.7", "3.8", "3.9", "3.10"] steps: + - name: Delay, waiting Pypi to update. + if: ${{ github.event_name != 'workflow_dispatch' }} + run: sleep 60 + - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v3 @@ -133,4 +150,4 @@ jobs: python3 -m pip install --only-binary=:all: pillow_heif - name: Test wheel - run: cd .. && python3 -m pytest -s pillow_heif + run: cd .. && python3 -m pytest -rs pillow_heif diff --git a/CHANGELOG.md b/CHANGELOG.md index 197fef8b..1fdcbeba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## [0.2.1 - 2022-04-18] +## [0.2.1 - 2022-04-17] ### Added diff --git a/README.md b/README.md index 2a7ecec7..027223b0 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,9 @@ if pillow_heif.is_supported('input.heic'): heif_file = pillow_heif.open_heif('input.heic') for img in heif_file: # you still can use it without iteration, like before. img.scale(1024, 768) # `libheif` does not provide much operations, that can be done on image, so just scaling it. - # get save mask and set thumb_box=-1 to ignore all thumbs image have. - save_mask = heif_file.get_img_thumb_mask_for_save(pillow_heif.HeifSaveMask.SAVE_ALL, thumb_box=-1) - heif_file.add_thumbs_to_mask(save_mask, [768, 512, 256]) # add three new thumbnail boxes. + heif_file.add_thumbnails([768, 512, 256]) # add three new thumbnail boxes. # default quality is probably ~77 in x265, set it a bit lower and specify `save mask`. - heif_file.save('output.heic', quality=70, save_mask=save_mask) + heif_file.save('output.heic', quality=70, save_all=False) #save_all is True by default. exit(0) ``` ### [More examples](https://github.com/bigcat88/pillow_heif/tree/master/examples) @@ -85,7 +83,6 @@ The returned `HeifImageFile` by `Pillow` function `Image.open` has the following * `metadata` - is a list of dictionaries with `type` and `data` keys, excluding `exif`. May be empty. * `icc_profile` - contains data and present only when file has `ICC` color profile(`prof` or `rICC`). * `nclx_profile` - contains data and present only when file has `NCLX` color profile. - * `img_id` - id of image, needed for encoding operations. ### The HeifFile object The returned `HeifFile` by function `open_heif` or `from_pillow` has the following properties: diff --git a/docker/test_wheels.Dockerfile b/docker/test_wheels.Dockerfile index 74e8a913..c7c10d43 100644 --- a/docker/test_wheels.Dockerfile +++ b/docker/test_wheels.Dockerfile @@ -12,4 +12,4 @@ RUN python3 -m pip install --no-deps --only-binary=:all: pillow_heif COPY . /pillow_heif -RUN python3 -m pytest -s -v pillow_heif/. && echo "**** Test Done ****" && python3 -m pip show pillow_heif +RUN python3 -m pytest -rs -v pillow_heif/. && echo "**** Test Done ****" && python3 -m pip show pillow_heif diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 62e999d9..0c9c5821 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -26,16 +26,16 @@ Notes: 1. Building for first time will take a long time, if in your system `cmake` version `>=3.16.1` is not present. 2. Arm7(32 bit): - * On Alpine you need install `aom-dev`. + * On Alpine need install `aom-dev`. * On Ubuntu(22.04+) you need install `libaom-dev`. - * On Ubuntu less then 22.04 you can compile it from source, but `AV1` codecs will be not avalaible. + * On Ubuntu less 22.04 you can compile it from source, but `AV1` codecs will be not available. * Encoder will not be available if you did not install `x265`. It is not build from source by default on armv7. ### MacOS ```bash /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" brew install x265 libjpeg libde265 libheif -pip3 install --no-binary pillow_heif +pip3 install --no-binary=:all: pillow_heif ``` ### Windows @@ -43,7 +43,8 @@ By default, build script assumes that `vcpkg` builds libs in `C:\vcpkg\installed If not, then set `VCPKG_PREFIX` environment variable to your custom path, e.g. `setx VCPKG_PREFIX "D:\vcpkg\installed\x64-windows"` ```bat vcpkg install aom libheif --triplet=x64-windows -pip3 install --no-binary pillow_heif +pip3 install --no-binary=:all: pillow_heif ``` +After that copy `heif.dll`, `aom.dll`, `libde265.dll` and `libx265.dll` from `vcpkg\installed\x64-windows\bin` to site-packages root. Note: there is no support for 10/12 bit file formats for encoder now on Windows. diff --git a/examples/opener_display_images.py b/examples/opener_display_images.py index ba8287fc..9e877f63 100644 --- a/examples/opener_display_images.py +++ b/examples/opener_display_images.py @@ -17,17 +17,8 @@ img = Image.open(image_path) img.load() for i, frame in enumerate(ImageSequence.Iterator(img)): - # `img.info["thumbnails"]` can be changed in future versions. - # Probably soon will be introduced a new method instead of `Image.frombytes` for thumbnails. for thumb in img.info["thumbnails"]: - thumb_img = Image.frombytes( - thumb.mode, - thumb.size, - thumb.data, - "raw", - thumb.mode, - thumb.stride, - ) + thumb_img = thumb.to_pillow() thumb_img.show(title=f"Img={i} Thumbnail={thumb.info['thumb_id']}") img.show(title=f"Image index={i}") except Exception as e: diff --git a/examples/reader_add_thumbnails.py b/examples/reader_add_thumbnails.py new file mode 100644 index 00000000..9ef16331 --- /dev/null +++ b/examples/reader_add_thumbnails.py @@ -0,0 +1,22 @@ +import os +import sys +import traceback +from pathlib import Path + +import pillow_heif + +if __name__ == "__main__": + os.chdir(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests")) + target_folder = "../converted" + os.makedirs(target_folder, exist_ok=True) + image_path = Path("images/pug_1_0.heic") + try: + heif_image = pillow_heif.open_heif(image_path) + result_path = os.path.join(target_folder, f"{image_path.stem}.heic") + heif_image.add_thumbnails([256, 512]) + heif_image.save(result_path, quality=35) + heif_image.close() + except Exception as e: + print(f"{repr(e)} during processing {image_path.as_posix()}", file=sys.stderr) + print(traceback.format_exc()) + exit(0) diff --git a/examples/reader_display_images.py b/examples/reader_display_images.py index a0bf343e..a70da72f 100644 --- a/examples/reader_display_images.py +++ b/examples/reader_display_images.py @@ -3,8 +3,6 @@ import traceback from pathlib import Path -from PIL import Image - import pillow_heif # This demo displays all thumbnails and all images. @@ -19,23 +17,9 @@ print(f"number of images in file: {len(heif_image)}") for image in heif_image: for thumb in image.thumbnails: - thumbnail_img = Image.frombytes( - thumb.mode, - thumb.size, - thumb.data, - "raw", - thumb.mode, - thumb.stride, - ) + thumbnail_img = thumb.to_pillow() thumbnail_img.show(title=f"Thumbnail {thumb.info['thumb_id']}") - _img = Image.frombytes( - image.mode, - image.size, - image.data, - "raw", - image.mode, - image.stride, - ) + _img = image.to_pillow() _img.show(title=f"Image {image.info['img_id']}") except Exception as e: print(f"{repr(e)} during processing {image_path.as_posix()}", file=sys.stderr) diff --git a/examples/reader_remove_image.py b/examples/reader_remove_image.py new file mode 100644 index 00000000..abc926ea --- /dev/null +++ b/examples/reader_remove_image.py @@ -0,0 +1,22 @@ +import os +import sys +import traceback +from pathlib import Path + +import pillow_heif + +if __name__ == "__main__": + os.chdir(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests")) + target_folder = "../converted" + os.makedirs(target_folder, exist_ok=True) + image_path = Path("images/pug_2_0.heic") + try: + heif_image = pillow_heif.open_heif(image_path) + result_path = os.path.join(target_folder, f"{image_path.stem}.heic") + del heif_image[1] + heif_image.save(result_path, quality=35) + heif_image.close() + except Exception as e: + print(f"{repr(e)} during processing {image_path.as_posix()}", file=sys.stderr) + print(traceback.format_exc()) + exit(0) diff --git a/pillow_heif/__init__.py b/pillow_heif/__init__.py index 24f87bc4..ea8b0912 100644 --- a/pillow_heif/__init__.py +++ b/pillow_heif/__init__.py @@ -25,7 +25,6 @@ HeifCompressionFormat, HeifErrorCode, HeifFiletype, - HeifSaveMask, ) from .error import HeifError diff --git a/pillow_heif/_version.py b/pillow_heif/_version.py index 949c68dd..f06d00e4 100644 --- a/pillow_heif/_version.py +++ b/pillow_heif/_version.py @@ -1,3 +1,3 @@ """ Version of pillow_heif """ -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/pillow_heif/as_opener.py b/pillow_heif/as_opener.py index 647c4159..bf77dd8c 100644 --- a/pillow_heif/as_opener.py +++ b/pillow_heif/as_opener.py @@ -2,13 +2,14 @@ Opener for Pillow library. """ +from copy import deepcopy from typing import Any from PIL import Image, ImageFile from ._options import options from .error import HeifError -from .heif import from_pillow, is_supported, open_heif +from .heif import HeifImage, from_pillow, is_supported, open_heif from .misc import reset_orientation @@ -16,6 +17,7 @@ class HeifImageFile(ImageFile.ImageFile): format = "HEIF" format_description = "HEIF container for HEVC and AV1" heif_file: Any + _close_exclusive_fp_after_loading = False def __init__(self, *args, **kwargs): self.heif_file = None @@ -33,13 +35,14 @@ def _open(self): def load(self): if self.heif_file: frame_heif = self._heif_file_by_index(self.tell()) - frame_heif.load() self.load_prepare() self.frombytes(frame_heif.data, "raw", (self.mode, frame_heif.stride)) - if self.is_animated or self.info["thumbnails"]: + if self.is_animated: frame_heif.unload() else: + self.info["thumbnails"] = deepcopy(self.info["thumbnails"]) self.heif_file = None + self._close_exclusive_fp_after_loading = True return super().load() def seek(self, frame): @@ -73,7 +76,7 @@ def _seek_check(self, frame): raise EOFError("attempt to seek outside sequence") return self.tell() != frame - def _heif_file_by_index(self, index): + def _heif_file_by_index(self, index) -> HeifImage: return self.heif_file[index] def _init_from_heif_file(self, heif_image) -> None: @@ -89,11 +92,11 @@ def _init_from_heif_file(self, heif_image) -> None: def _save(im, fp, _filename): - from_pillow(im, load_one=True).save(fp, save_one=True, **im.encoderinfo) + from_pillow(im, load_one=True).save(fp, save_all=False, **im.encoderinfo) def _save_all(im, fp, _filename): - from_pillow(im).save(fp, save_one=False, **im.encoderinfo) + from_pillow(im).save(fp, save_all=True, **im.encoderinfo) def register_heif_opener(**kwargs): diff --git a/pillow_heif/constants.py b/pillow_heif/constants.py index 20a35d5a..0657c232 100644 --- a/pillow_heif/constants.py +++ b/pillow_heif/constants.py @@ -63,7 +63,7 @@ class HeifChannel(IntEnum): ALPHA = 6 """Alpha color channel""" INTERLEAVED = 10 - """Interleaved color channels""" + """Interleaved color channel""" def encode_fourcc(fourcc): @@ -172,17 +172,6 @@ class HeifCompressionFormat(IntEnum): """The compression format is AV1.""" -class HeifSaveMask(IntEnum): - """Possible mask types for saving HEIC.""" - - SAVE_ALL = 0 - """Mask where all images set to True.""" - SAVE_ONE = 1 - """Mask where all images except main is set to False.""" - SAVE_NONE = 2 - """Mask where all images set to False.""" - - # -------------------------------------------------------------------- # DEPRECATED CONSTANTS. # pylint: disable=invalid-name diff --git a/pillow_heif/heif.py b/pillow_heif/heif.py index cce0732c..4a56b209 100644 --- a/pillow_heif/heif.py +++ b/pillow_heif/heif.py @@ -2,6 +2,7 @@ Functions and classes for heif images to read and write. """ import builtins +from copy import deepcopy from typing import Any, Dict, Iterator, List, Tuple, Union from warnings import warn @@ -14,14 +15,23 @@ HeifBrand, HeifChannel, HeifChroma, - HeifColorProfileType, HeifColorspace, HeifCompressionFormat, HeifFiletype, - HeifSaveMask, ) from .error import HeifError, HeifErrorCode, check_libheif_error -from .misc import _get_bytes, _get_chroma +from .misc import _get_bytes, _get_chroma, reset_orientation +from .private import ( + create_image, + get_img_depth, + heif_ctx_as_dict, + read_color_profile, + read_metadata, + retrieve_exif, + set_color_profile, + set_exif, + set_metadata, +) class HeifImageBase: @@ -44,14 +54,9 @@ def __init__(self, heif_ctx: Union[LibHeifCtx, dict], handle): self.bit_depth = heif_ctx["bit_depth"] self.size = heif_ctx["size"] self.has_alpha = heif_ctx["mode"] == "RGBA" - if self.bit_depth == 8: - _chroma = HeifChroma.INTERLEAVED_RGBA if self.has_alpha else HeifChroma.INTERLEAVED_RGB - else: - _chroma = HeifChroma.INTERLEAVED_RRGGBBAA_BE if self.has_alpha else HeifChroma.INTERLEAVED_RRGGBB_BE + _chroma = _get_chroma(self.bit_depth, self.has_alpha) _stride = heif_ctx.get("stride", None) - _img = _create_image( - self.size, HeifColorspace.RGB, _chroma, self.bit_depth, heif_ctx["mode"], heif_ctx["data"], _stride - ) + _img = create_image(self.size, _chroma, self.bit_depth, heif_ctx["mode"], heif_ctx["data"], stride=_stride) self._img_to_img_data_dict(_img, HeifColorspace.RGB, _chroma) @property @@ -81,11 +86,31 @@ def chroma(self): def color(self): return self._img_data.get("color", HeifColorspace.UNDEFINED) + def to_pillow(self, ignore_thumbnails: bool = False) -> Image: + image = Image.frombytes( + self.mode, + self.size, + self.data, + "raw", + self.mode, + self.stride, + ) + if isinstance(self, HeifImage): + for k in ("main", "brand", "exif", "metadata"): + image.info[k] = self.info[k] + for k in ("icc_profile", "icc_profile_type", "nclx_profile"): + if k in self.info: + image.info[k] = self.info[k] + if not ignore_thumbnails: + image.info["thumbnails"] = deepcopy(self.thumbnails) + image.info["original_orientation"] = reset_orientation(image.info) + return image + def _load_if_not(self): if self._img_data or self._handle is None: return colorspace = HeifColorspace.RGB - chroma = _get_chroma(self.misc["to_8bit"], self.bit_depth, self.has_alpha) + chroma = _get_chroma(self.bit_depth, self.has_alpha, self.misc["to_8bit"]) p_options = lib.heif_decoding_options_alloc() p_options = ffi.gc(p_options, lib.heif_decoding_options_free) p_options.ignore_transformations = int(not self.misc["transforms"]) @@ -108,10 +133,11 @@ def load(self): return self def unload(self): - self._img_data.clear() + if self._handle is not None: + self._img_data.clear() def close(self): - self.unload() + self._img_data.clear() self._handle = None @@ -137,6 +163,10 @@ def __repr__(self): f"and with {_bytes} image data>" ) + def __deepcopy__(self, memo): + heif_ctx = heif_ctx_as_dict(self.bit_depth, self.mode, self.size, self.data, stride=self.stride) + return HeifThumbnail(heif_ctx, None, self.info["thumb_id"], self.info["img_index"]) + class HeifImage(HeifImageBase): def __init__(self, img_id: int, img_index: int, heif_ctx: Union[LibHeifCtx, dict]): @@ -149,10 +179,10 @@ def __init__(self, img_id: int, img_index: int, heif_ctx: Union[LibHeifCtx, dict error = lib.heif_context_get_primary_image_handle(heif_ctx.ctx, p_handle) check_libheif_error(error) handle = p_handle[0] - _metadata = _read_metadata(handle) - _exif = _retrieve_exif(_metadata) + _metadata = read_metadata(handle) + _exif = retrieve_exif(_metadata) additional_info["metadata"] = _metadata - _color_profile = _read_color_profile(handle) + _color_profile = read_color_profile(handle) if _color_profile: if _color_profile["type"] in ("rICC", "prof"): additional_info["icc_profile"] = _color_profile["data"] @@ -189,11 +219,10 @@ def load(self): thumbnail.load() return self - def unload(self, thumbnails: bool = True): + def unload(self): super().unload() - if thumbnails: - for thumbnail in self.thumbnails: - thumbnail.unload() + for thumbnail in self.thumbnails: + thumbnail.unload() return self def scale(self, width: int, height: int): @@ -208,6 +237,42 @@ def scale(self, width: int, height: int): self._img_to_img_data_dict(scaled_heif_img, self.color, self.chroma) return self + def add_thumbnails(self, boxes: Union[list, int]) -> None: + if isinstance(boxes, list): + boxes_list = boxes + else: + boxes_list = [boxes] + self.load() + for box in boxes_list: + if box <= 3: + continue + if self.size[0] <= box and self.size[1] <= box: + continue + if self.size[0] > self.size[1]: + thumb_height = int(self.size[1] * box / self.size[0]) + thumb_width = box + else: + thumb_width = int(self.size[0] * box / self.size[1]) + thumb_height = box + thumb_height = thumb_height - 1 if (thumb_height & 1) else thumb_height + thumb_width = thumb_width - 1 if (thumb_width & 1) else thumb_width + if max((thumb_height, thumb_width)) in [max(i.size) for i in self.thumbnails]: + continue + p_new_thumbnail = ffi.new("struct heif_image **") + error = lib.heif_image_scale_image(self.heif_img, p_new_thumbnail, thumb_width, thumb_height, ffi.NULL) + check_libheif_error(error) + new_thumbnail = ffi.gc(p_new_thumbnail[0], lib.heif_image_release) + __size = ( + lib.heif_image_get_width(new_thumbnail, HeifChannel.INTERLEAVED), + lib.heif_image_get_height(new_thumbnail, HeifChannel.INTERLEAVED), + ) + p_dest_stride = ffi.new("int *") + p_data = lib.heif_image_get_plane(new_thumbnail, HeifChannel.INTERLEAVED, p_dest_stride) + dest_stride = p_dest_stride[0] + data = ffi.buffer(p_data, __size[1] * dest_stride) + __heif_ctx = heif_ctx_as_dict(get_img_depth(self), self.mode, __size, data, stride=dest_stride) + self.thumbnails.append(HeifThumbnail(__heif_ctx, None, 0, 0)) + class HeifFile: def __init__(self, heif_ctx: Union[LibHeifCtx, dict], img_ids: list = None): @@ -265,19 +330,17 @@ def thumbnails_all(self, one_for_image: bool = False) -> Iterator[HeifThumbnail] break def load(self, everything: bool = False): - if everything: - for img in self: - img.load() - else: - self._images[0].load() + for img in self: + img.load() + if not everything: + break return self def unload(self, everything: bool = False): - if everything: - for img in self: - img.unload() - else: - self._images[0].unload() + for img in self: + img.unload() + if not everything: + break return self def scale(self, width: int, height: int) -> None: @@ -286,7 +349,7 @@ def scale(self, width: int, height: int) -> None: def _add_frombytes(self, bit_depth: int, mode: str, size: tuple, data, **kwargs): __ids = [i.info["img_id"] for i in self._images] + [i.info["thumb_id"] for i in self.thumbnails_all()] + [0] __new_id = 2 + max(__ids) - __heif_ctx = self.__heif_ctx_as_dict(bit_depth, mode, size, data, **kwargs) + __heif_ctx = heif_ctx_as_dict(bit_depth, mode, size, data, **kwargs) self._images.append(HeifImage(__new_id, len(self), __heif_ctx)) return self @@ -295,16 +358,27 @@ def _add_frombytes(self, bit_depth: int, mode: str, size: tuple, data, **kwargs) def add_from_pillow(self, pil_image: Image, load_one=False): for frame in ImageSequence.Iterator(pil_image): - additional_info = {} - for k in ("exif", "icc_profile", "icc_profile_type", "nclx_profile", "metadata", "brand"): - if k in frame.info: - additional_info[k] = frame.info[k] - if frame.mode == "P": - mode = 'RGBA' if frame.info.get('transparency') else 'RGB' - frame = frame.convert(mode=mode) - # How here we can detect bit depth of Pillow image? pallete.rawmode or maybe something else? - __bit_depth = 8 - self._add_frombytes(__bit_depth, frame.mode, frame.size, frame.tobytes(), add_info={**additional_info}) + if frame.width > 0 and frame.height > 0: + additional_info = {} + for k in ("exif", "icc_profile", "icc_profile_type", "nclx_profile", "metadata", "brand"): + if k in frame.info: + additional_info[k] = frame.info[k] + if frame.mode == "P": + mode = "RGBA" if frame.info.get("transparency") else "RGB" + frame = frame.convert(mode=mode) + # How here we can detect bit-depth of Pillow image? pallete.rawmode or maybe something else? + __bit_depth = 8 + self._add_frombytes(__bit_depth, frame.mode, frame.size, frame.tobytes(), add_info={**additional_info}) + for thumb in frame.info.get("thumbnails", []): + self._images[len(self._images) - 1].thumbnails.append( + self.__get_image_thumb_frombytes( + thumb.bit_depth, + thumb.mode, + thumb.size, + thumb.data, + stride=thumb.stride, + ) + ) if load_one: break return self @@ -333,34 +407,24 @@ def add_from_heif(self, heif_image): thumb.mode, thumb.size, thumb.data, - img_index=len(self._images), stride=thumb.stride, ) ) + return self - def get_img_thumb_mask_for_save(self, mask=HeifSaveMask.SAVE_ALL, thumb_box: int = 0) -> list: - if mask == HeifSaveMask.SAVE_ALL: - return [[True, [thumb_box if thumb_box else max(_.size) for _ in img.thumbnails]] for img in self._images] - result = [[False, [thumb_box if thumb_box else max(_.size) for _ in img.thumbnails]] for img in self._images] - if mask == HeifSaveMask.SAVE_ONE: - result[0][0] = True - return result - - @staticmethod - def add_thumbs_to_mask(save_mask: list, thumb_boxes: list) -> None: - for _mask in save_mask: - _mask[1].extend([_ for _ in thumb_boxes if _ not in _mask[1]]) + def add_thumbnails(self, boxes: Union[list, int]) -> None: + for img in self._images: + img.add_thumbnails(boxes) - def save(self, fp, save_mask: list = None, **kwargs): + def save(self, fp, **kwargs): # append_images = kwargs.get("append_images", []) if not options().hevc_enc: raise HeifError(code=HeifErrorCode.ENCODING_ERROR, subcode=5000, message="No encoder found.") - _save_mask = save_mask if save_mask else self.get_img_thumb_mask_for_save() - quality = kwargs.get("quality", None) - enc_params = kwargs.get("enc_params", []) + if not self._images: + raise ValueError("Cannot write empty image as HEIF.") _heif_write_ctx = LibHeifCtxWrite(fp) - _encoder = self._get_encoder(_heif_write_ctx, quality, enc_params) - self._save(_heif_write_ctx, _encoder, _save_mask) + _encoder = self._get_encoder(_heif_write_ctx, kwargs.get("quality", None), kwargs.get("enc_params", [])) + self._save(_heif_write_ctx, _encoder, not kwargs.get("save_all", True)) error = lib.heif_context_write(_heif_write_ctx.ctx, _heif_write_ctx.writer, _heif_write_ctx.cpointer) check_libheif_error(error) _heif_write_ctx.close() @@ -391,70 +455,45 @@ def __getitem__(self, index): 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 __del__(self): self.close() - def _save(self, out_ctx: LibHeifCtxWrite, encoder, save_mask: list): + def _save(self, out_ctx: LibHeifCtxWrite, encoder, save_one: bool) -> None: encoding_options = lib.heif_encoding_options_alloc() encoding_options = ffi.gc(encoding_options, lib.heif_encoding_options_free) - for i, img in enumerate(self): - if not save_mask[i][0]: - continue + for img in self: img.load() - # new_img = img.heif_img - __bit_depth = 8 if getattr(img, "misc", {}).get("to_8bit", None) else img.bit_depth - new_img = _create_image( - img.size, HeifColorspace.RGB, img.chroma, __bit_depth, img.mode, img.data, img.stride - ) - __icc_profile = img.info.get("icc_profile", None) - if __icc_profile is not None: - _prof_type = img.info.get("icc_profile_type", "prof").encode("ascii") - error = lib.heif_image_set_raw_color_profile( - new_img, _prof_type, img.info["icc_profile"], len(img.info["icc_profile"]) - ) - check_libheif_error(error) - elif img.info.get("nclx_profile", None): - error = lib.heif_image_set_nclx_color_profile( - new_img, - ffi.cast("const struct heif_color_profile_nclx*", ffi.from_buffer(img.info["nclx_profile"])), - ) - check_libheif_error(error) + new_img = create_image(img.size, img.chroma, get_img_depth(img), img.mode, img.data, stride=img.stride) + set_color_profile(new_img, img.info) p_new_img_handle = ffi.new("struct heif_image_handle **") error = lib.heif_context_encode_image(out_ctx.ctx, new_img, encoder, encoding_options, p_new_img_handle) check_libheif_error(error) new_img_handle = ffi.gc(p_new_img_handle[0], lib.heif_image_handle_release) - if img.info["exif"] is not None: - error = lib.heif_context_add_exif_metadata( - out_ctx.ctx, new_img_handle, img.info["exif"], len(img.info["exif"]) - ) - check_libheif_error(error) - for metadata in img.info["metadata"]: - error = lib.heif_context_add_generic_metadata( - out_ctx.ctx, - new_img_handle, - metadata["data"], - len(metadata["data"]), - metadata["metadata_type"], - metadata["content_type"], - ) - check_libheif_error(error) - thumbs_masks = save_mask[i][1] - for thumb_box in thumbs_masks: - if thumb_box: - if max(img.size) > thumb_box > 3: - p_new_thumb_handle = ffi.new("struct heif_image_handle **") - error = lib.heif_context_encode_thumbnail( - out_ctx.ctx, - new_img, - new_img_handle, - encoder, - encoding_options, - thumb_box, - p_new_thumb_handle, - ) - check_libheif_error(error) - if p_new_thumb_handle[0] != ffi.NULL: - lib.heif_image_handle_release(p_new_thumb_handle[0]) + set_exif(out_ctx, new_img_handle, img.info) + set_metadata(out_ctx, new_img_handle, img.info) + for thumbnail in img.thumbnails: + thumb_box = max(thumbnail.size) + if max(img.size) > thumb_box > 3: + p_new_thumb_handle = ffi.new("struct heif_image_handle **") + error = lib.heif_context_encode_thumbnail( + out_ctx.ctx, + new_img, + new_img_handle, + encoder, + encoding_options, + thumb_box, + p_new_thumb_handle, + ) + check_libheif_error(error) + if p_new_thumb_handle[0] != ffi.NULL: + lib.heif_image_handle_release(p_new_thumb_handle[0]) + if save_one: + break @staticmethod def _get_encoder(heif_ctx, quality: int = None, enc_params: List[Tuple[str, str]] = None): @@ -478,25 +517,12 @@ def _get_encoder(heif_ctx, quality: int = None, enc_params: List[Tuple[str, str] ) return encoder - def __get_image_thumb_frombytes(self, bit_depth: int, mode: str, size: tuple, data, img_index: int, **kwargs): + def __get_image_thumb_frombytes(self, bit_depth: int, mode: str, size: tuple, data, **kwargs): __ids = [i.info["img_id"] for i in self._images] + [i.info["thumb_id"] for i in self.thumbnails_all()] + [0] __new_id = 2 + max(__ids) - __heif_ctx = self.__heif_ctx_as_dict(bit_depth, mode, size, data, **kwargs) - return HeifThumbnail(__heif_ctx, None, __new_id, img_index) - - @staticmethod - def __heif_ctx_as_dict(bit_depth: int, mode: str, size: tuple, data, **kwargs) -> dict: - __factor = 1 if bit_depth == 8 else 2 - __bytes_per_pix = 3 * __factor if mode == "RGB" else 4 * __factor - __stride = kwargs.get("stride", None) - return { - "bit_depth": bit_depth, - "mode": mode, - "size": size, - "data": data, - "stride": __stride if __stride else size[0] * __bytes_per_pix, - "additional_info": kwargs.get("add_info", {}), - } + __heif_ctx = heif_ctx_as_dict(bit_depth, mode, size, data, **kwargs) + __img_index = kwargs.get("img_index", len(self._images)) + return HeifThumbnail(__heif_ctx, None, __new_id, __img_index) def _debug_dump(self, file_path="debug_boxes_dump.txt"): with builtins.open(file_path, "wb") as f: @@ -557,68 +583,6 @@ def from_pillow(pil_image: Image, load_one=False) -> HeifFile: return HeifFile({}).add_from_pillow(pil_image, load_one) -def _read_metadata(handle) -> list: - block_count = lib.heif_image_handle_get_number_of_metadata_blocks(handle, ffi.NULL) - if block_count == 0: - return [] - metadata = [] - blocks_ids = ffi.new("heif_item_id[]", block_count) - lib.heif_image_handle_get_list_of_metadata_block_IDs(handle, ffi.NULL, blocks_ids, block_count) - for block_id in blocks_ids: - metadata_type = lib.heif_image_handle_get_metadata_type(handle, block_id) - decoded_data_type = ffi.string(metadata_type).decode() - content_type = ffi.string(lib.heif_image_handle_get_metadata_content_type(handle, block_id)) - data_length = lib.heif_image_handle_get_metadata_size(handle, block_id) - if data_length > 0: - p_data = ffi.new("char[]", data_length) - error = lib.heif_image_handle_get_metadata(handle, block_id, p_data) - check_libheif_error(error) - data_buffer = ffi.buffer(p_data, data_length) - data = bytes(data_buffer) - if decoded_data_type == "Exif": - data = data[4:] # skip TIFF header, first 4 bytes - metadata.append( - {"type": decoded_data_type, "data": data, "metadata_type": metadata_type, "content_type": content_type} - ) - return metadata - - -def _retrieve_exif(metadata: list): - _result = None - _purge = [] - for i, md_block in enumerate(metadata): - if md_block["type"] == "Exif": - _purge.append(i) - if not _result and md_block["data"] and md_block["data"][0:4] == b"Exif": - _result = md_block["data"] - for i in reversed(_purge): - del metadata[i] - return _result - - -def _read_color_profile(handle) -> dict: - profile_type = lib.heif_image_handle_get_color_profile_type(handle) - if profile_type == HeifColorProfileType.NOT_PRESENT: - return {} - if profile_type == HeifColorProfileType.NCLX: - _type = "nclx" - pp_data = ffi.new("struct heif_color_profile_nclx **") - data_length = ffi.sizeof("struct heif_color_profile_nclx") - error = lib.heif_image_handle_get_nclx_color_profile(handle, pp_data) - p_data = pp_data[0] - ffi.release(pp_data) - else: - _type = "prof" if profile_type == HeifColorProfileType.PROF else "rICC" - data_length = lib.heif_image_handle_get_raw_color_profile_size(handle) - if data_length == 0: - return {"type": _type, "data": b""} - p_data = ffi.new("char[]", data_length) - error = lib.heif_image_handle_get_raw_color_profile(handle, p_data) - check_libheif_error(error) - data_buffer = ffi.buffer(p_data, data_length) - return {"type": _type, "data": bytes(data_buffer)} - - def _read_thumbnails(heif_ctx: Union[LibHeifCtx, dict], img_handle, img_index: int) -> List[HeifThumbnail]: result: List[HeifThumbnail] = [] if img_handle is None or not options().thumbnails: @@ -633,30 +597,6 @@ def _read_thumbnails(heif_ctx: Union[LibHeifCtx, dict], img_handle, img_index: i return result -def _create_image( - size: tuple, color: HeifColorspace, chroma: HeifChroma, bit_depth: int, mode: str, data, stride: int = None -): - width, height = size - p_new_img = ffi.new("struct heif_image **") - error = lib.heif_image_create(width, height, color, chroma, p_new_img) - check_libheif_error(error) - new_img = ffi.gc(p_new_img[0], lib.heif_image_release) - error = lib.heif_image_add_plane(new_img, HeifChannel.INTERLEAVED, width, height, bit_depth) - check_libheif_error(error) - p_dest_stride = ffi.new("int *") - p_data = lib.heif_image_get_plane(new_img, HeifChannel.INTERLEAVED, p_dest_stride) - dest_stride = p_dest_stride[0] - p_source = ffi.from_buffer("uint8_t*", data) - __factor = 1 if bit_depth == 8 else 2 - source_stride = stride if stride else width * 3 * __factor if mode == "RGB" else width * 4 * __factor - if dest_stride == source_stride: - ffi.memmove(p_data, data, len(data)) - else: - for i in range(height): - ffi.memmove(p_data + dest_stride * i, p_source + source_stride * i, source_stride) - return new_img - - # -------------------------------------------------------------------- # DEPRECATED FUNCTIONS. diff --git a/pillow_heif/misc.py b/pillow_heif/misc.py index a3669adb..893ee8f3 100644 --- a/pillow_heif/misc.py +++ b/pillow_heif/misc.py @@ -67,7 +67,7 @@ def _get_bytes(fp, length=None) -> bytes: return bytes(fp)[:length] -def _get_chroma(hdr_to_8bit: bool, bit_depth: int, has_alpha: bool) -> HeifChroma: +def _get_chroma(bit_depth: int, has_alpha: bool, hdr_to_8bit: bool = False) -> HeifChroma: if hdr_to_8bit or bit_depth <= 8: chroma = HeifChroma.INTERLEAVED_RGBA if has_alpha else HeifChroma.INTERLEAVED_RGB else: diff --git a/pillow_heif/private.py b/pillow_heif/private.py new file mode 100644 index 00000000..47043e3f --- /dev/null +++ b/pillow_heif/private.py @@ -0,0 +1,150 @@ +""" +Undocumented private functions for other code to look better. +""" +from _pillow_heif_cffi import ffi, lib + +from ._libheif_ctx import LibHeifCtxWrite +from .constants import HeifChannel, HeifChroma, HeifColorProfileType, HeifColorspace +from .error import check_libheif_error + + +def create_image(size: tuple, chroma: HeifChroma, bit_depth: int, mode: str, data, **kwargs): + width, height = size + p_new_img = ffi.new("struct heif_image **") + error = lib.heif_image_create(width, height, kwargs.get("color", HeifColorspace.RGB), chroma, p_new_img) + check_libheif_error(error) + new_img = ffi.gc(p_new_img[0], lib.heif_image_release) + error = lib.heif_image_add_plane(new_img, HeifChannel.INTERLEAVED, width, height, bit_depth) + check_libheif_error(error) + p_dest_stride = ffi.new("int *") + p_data = lib.heif_image_get_plane(new_img, HeifChannel.INTERLEAVED, p_dest_stride) + dest_stride = p_dest_stride[0] + copy_image_data(p_data, data, dest_stride, get_stride(bit_depth, mode, width, **kwargs), height) + return new_img + + +def copy_image_data(dest_data, src_data, dest_stride: int, source_stride: int, height: int): + if dest_stride == source_stride: + ffi.memmove(dest_data, src_data, len(src_data)) + else: + p_source = ffi.from_buffer("uint8_t*", src_data) + for i in range(height): + ffi.memmove(dest_data + dest_stride * i, p_source + source_stride * i, source_stride) + + +def get_stride(bit_depth: int, mode: str, width: int, **kwargs) -> int: + __stride = kwargs.get("stride", None) + __factor = 1 if bit_depth == 8 else 2 + return __stride if __stride else width * 3 * __factor if mode == "RGB" else width * 4 * __factor + + +def heif_ctx_as_dict(bit_depth: int, mode: str, size: tuple, data, **kwargs) -> dict: + return { + "bit_depth": bit_depth, + "mode": mode, + "size": size, + "data": data, + "stride": get_stride(bit_depth, mode, size[0], **kwargs), + "additional_info": kwargs.get("add_info", {}), + } + + +def get_img_depth(img): + return 8 if getattr(img, "misc", {}).get("to_8bit", None) else img.bit_depth + + +def read_color_profile(handle) -> dict: + profile_type = lib.heif_image_handle_get_color_profile_type(handle) + if profile_type == HeifColorProfileType.NOT_PRESENT: + return {} + if profile_type == HeifColorProfileType.NCLX: + _type = "nclx" + pp_data = ffi.new("struct heif_color_profile_nclx **") + data_length = ffi.sizeof("struct heif_color_profile_nclx") + error = lib.heif_image_handle_get_nclx_color_profile(handle, pp_data) + p_data = pp_data[0] + ffi.release(pp_data) + else: + _type = "prof" if profile_type == HeifColorProfileType.PROF else "rICC" + data_length = lib.heif_image_handle_get_raw_color_profile_size(handle) + if data_length == 0: + return {"type": _type, "data": b""} + p_data = ffi.new("char[]", data_length) + error = lib.heif_image_handle_get_raw_color_profile(handle, p_data) + check_libheif_error(error) + data_buffer = ffi.buffer(p_data, data_length) + return {"type": _type, "data": bytes(data_buffer)} + + +def set_color_profile(heif_img, info: dict) -> None: + __icc_profile = info.get("icc_profile", None) + if __icc_profile is not None: + _prof_type = info.get("icc_profile_type", "prof").encode("ascii") + error = lib.heif_image_set_raw_color_profile( + heif_img, _prof_type, info["icc_profile"], len(info["icc_profile"]) + ) + check_libheif_error(error) + elif info.get("nclx_profile", None): + error = lib.heif_image_set_nclx_color_profile( + heif_img, + ffi.cast("const struct heif_color_profile_nclx*", ffi.from_buffer(info["nclx_profile"])), + ) + check_libheif_error(error) + + +def retrieve_exif(metadata: list): + _result = None + _purge = [] + for i, md_block in enumerate(metadata): + if md_block["type"] == "Exif": + _purge.append(i) + if not _result and md_block["data"] and md_block["data"][0:4] == b"Exif": + _result = md_block["data"] + for i in reversed(_purge): + del metadata[i] + return _result + + +def set_exif(ctx: LibHeifCtxWrite, heif_img_handle, info: dict) -> None: + if info["exif"] is not None: + error = lib.heif_context_add_exif_metadata(ctx.ctx, heif_img_handle, info["exif"], len(info["exif"])) + check_libheif_error(error) + + +def read_metadata(handle) -> list: + block_count = lib.heif_image_handle_get_number_of_metadata_blocks(handle, ffi.NULL) + if block_count == 0: + return [] + metadata = [] + blocks_ids = ffi.new("heif_item_id[]", block_count) + lib.heif_image_handle_get_list_of_metadata_block_IDs(handle, ffi.NULL, blocks_ids, block_count) + for block_id in blocks_ids: + metadata_type = lib.heif_image_handle_get_metadata_type(handle, block_id) + decoded_data_type = ffi.string(metadata_type).decode() + content_type = ffi.string(lib.heif_image_handle_get_metadata_content_type(handle, block_id)) + data_length = lib.heif_image_handle_get_metadata_size(handle, block_id) + if data_length > 0: + p_data = ffi.new("char[]", data_length) + error = lib.heif_image_handle_get_metadata(handle, block_id, p_data) + check_libheif_error(error) + data_buffer = ffi.buffer(p_data, data_length) + data = bytes(data_buffer) + if decoded_data_type == "Exif": + data = data[4:] # skip TIFF header, first 4 bytes + metadata.append( + {"type": decoded_data_type, "data": data, "metadata_type": metadata_type, "content_type": content_type} + ) + return metadata + + +def set_metadata(ctx: LibHeifCtxWrite, heif_img_handle, info: dict) -> None: + for metadata in info["metadata"]: + error = lib.heif_context_add_generic_metadata( + ctx.ctx, + heif_img_handle, + metadata["data"], + len(metadata["data"]), + metadata["metadata_type"], + metadata["content_type"], + ) + check_libheif_error(error) diff --git a/pyproject.toml b/pyproject.toml index e4347402..7b74b51a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,9 +30,7 @@ profile = "black" [tool.pylint] master.py-version = "3.6" master.unsafe-load-any-extension = "yes" -design.max-attributes = 9 -design.max-args = 8 -design.max-locals = 20 +design.max-attributes = 8 basic.good-names = [ "a", "b", "c", "d", "e", "f", "i", "j", "k", "v", "ex", "_", "fp", "im" diff --git a/tests/hashes_test.py b/tests/hashes_test.py index 44789b7d..8c1d3848 100644 --- a/tests/hashes_test.py +++ b/tests/hashes_test.py @@ -1,11 +1,16 @@ -from __future__ import absolute_import, division, print_function +from gc import collect +from io import BytesIO +from pathlib import Path import pytest -from PIL import Image, ImageFilter +from PIL import Image + +from pillow_heif import open_heif, options, register_heif_opener __version__ = "4.2.1" numpy = pytest.importorskip("numpy", reason="NumPy not installed") +register_heif_opener() """ You may copy this file, if you keep the copyright information below: @@ -42,55 +47,6 @@ """ -def _binary_array_to_hex(arr): - """ - internal function to make a hex string out of a binary array. - """ - bit_string = "".join(str(b) for b in 1 * arr.flatten()) - width = int(numpy.ceil(len(bit_string) / 4)) - return "{:0>{width}x}".format(int(bit_string, 2), width=width) - - -class ImageHash(object): - """ - Hash encapsulation. Can be used for dictionary keys and comparisons. - """ - - def __init__(self, binary_array): - self.hash = binary_array - - def __str__(self): - return _binary_array_to_hex(self.hash.flatten()) - - def __repr__(self): - return repr(self.hash) - - def __sub__(self, other): - if other is None: - raise TypeError("Other hash must not be None.") - if self.hash.size != other.hash.size: - raise TypeError("ImageHashes must be of the same shape.", self.hash.shape, other.hash.shape) - return numpy.count_nonzero(self.hash.flatten() != other.hash.flatten()) - - def __eq__(self, other): - if other is None: - return False - return numpy.array_equal(self.hash.flatten(), other.hash.flatten()) - - def __ne__(self, other): - if other is None: - return False - return not numpy.array_equal(self.hash.flatten(), other.hash.flatten()) - - def __hash__(self): - # this returns a 8 bit integer, intentionally shortening the information - return sum([2 ** (i % 8) for i, v in enumerate(self.hash.flatten()) if v]) - - def __len__(self): - # Returns the bit length of the hash - return self.hash.size - - def average_hash(image, hash_size=8, mean=numpy.mean): """ Average Hash computation @@ -112,8 +68,7 @@ def average_hash(image, hash_size=8, mean=numpy.mean): # create string of bits diff = pixels > avg - # make a hash - return ImageHash(diff) + return diff def dhash(image, hash_size=8): @@ -134,25 +89,7 @@ def dhash(image, hash_size=8): pixels = numpy.asarray(image) # compute differences between columns diff = pixels[:, 1:] > pixels[:, :-1] - return ImageHash(diff) - - -def dhash_vertical(image, hash_size=8): - """ - Difference Hash computation. - - following http://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html - - computes differences vertically - - @image must be a PIL instance. - """ - # resize(w, h), but numpy.array((h, w)) - image = image.convert("L").resize((hash_size, hash_size + 1), Image.ANTIALIAS) - pixels = numpy.asarray(image) - # compute differences between rows - diff = pixels[1:, :] > pixels[:-1, :] - return ImageHash(diff) + return diff def colorhash(image, binbits=3): @@ -205,238 +142,84 @@ def colorhash(image, binbits=3): bitarray = [] for v in values: bitarray += [v // (2 ** (binbits - i - 1)) % 2 ** (binbits - i) > 0 for i in range(binbits)] - return ImageHash(numpy.asarray(bitarray).reshape((-1, binbits))) - - -class ImageMultiHash(object): - """ - This is an image hash containing a list of individual hashes for segments of the image. - The matching logic is implemented as described in Efficient Cropping-Resistant Robust Image Hashing - """ - - def __init__(self, hashes): - self.segment_hashes = hashes - - def __eq__(self, other): - if other is None: - return False - return self.matches(other) - - def __ne__(self, other): - return not self.matches(other) - - def __sub__(self, other, hamming_cutoff=None, bit_error_rate=None): - matches, sum_distance = self.hash_diff(other, hamming_cutoff, bit_error_rate) - max_difference = len(self.segment_hashes) - if matches == 0: - return max_difference - max_distance = matches * len(self.segment_hashes[0]) - tie_breaker = 0 - (float(sum_distance) / max_distance) - match_score = matches + tie_breaker - return max_difference - match_score - - def __hash__(self): - return hash(tuple(hash(segment) for segment in self.segment_hashes)) - - def __str__(self): - return ",".join(str(x) for x in self.segment_hashes) - - def __repr__(self): - return repr(self.segment_hashes) - - def hash_diff(self, other_hash, hamming_cutoff=None, bit_error_rate=None): - """ - Gets the difference between two multi-hashes, as a tuple. The first element of the tuple is the number of - matching segments, and the second element is the sum of the hamming distances of matching hashes. - NOTE: Do not order directly by this tuple, as higher is better for matches, and worse for hamming cutoff. - :param other_hash: The image multi hash to compare against - :param hamming_cutoff: The maximum hamming distance to a region hash in the target hash - :param bit_error_rate: Percentage of bits which can be incorrect, an alternative to the hamming cutoff. The - default of 0.25 means that the segment hashes can be up to 25% different - """ - # Set default hamming cutoff if it's not set. - if hamming_cutoff is None and bit_error_rate is None: - bit_error_rate = 0.25 - if hamming_cutoff is None: - hamming_cutoff = len(self.segment_hashes[0]) * bit_error_rate - # Get the hash distance for each region hash within cutoff - distances = [] - for segment_hash in self.segment_hashes: - lowest_distance = min(segment_hash - other_segment_hash for other_segment_hash in other_hash.segment_hashes) - if lowest_distance > hamming_cutoff: - continue - distances.append(lowest_distance) - return len(distances), sum(distances) - - def matches(self, other_hash, region_cutoff=1, hamming_cutoff=None, bit_error_rate=None): - """ - Checks whether this hash matches another crop resistant hash, `other_hash`. - :param other_hash: The image multi hash to compare against - :param region_cutoff: The minimum number of regions which must have a matching hash - :param hamming_cutoff: The maximum hamming distance to a region hash in the target hash - :param bit_error_rate: Percentage of bits which can be incorrect, an alternative to the hamming cutoff. The - default of 0.25 means that the segment hashes can be up to 25% different - """ - matches, _ = self.hash_diff(other_hash, hamming_cutoff, bit_error_rate) - return matches >= region_cutoff - - def best_match(self, other_hashes, hamming_cutoff=None, bit_error_rate=None): - """ - Returns the hash in a list which is the best match to the current hash - :param other_hashes: A list of image multi hashes to compare against - :param hamming_cutoff: The maximum hamming distance to a region hash in the target hash - :param bit_error_rate: Percentage of bits which can be incorrect, an alternative to the hamming cutoff. - Defaults to 0.25 if unset, which means the hash can be 25% different - """ - return min(other_hashes, key=lambda other_hash: self.__sub__(other_hash, hamming_cutoff, bit_error_rate)) - - -def _find_region(remaining_pixels, segmented_pixels): - """ - Finds a region and returns a set of pixel coordinates for it. - :param remaining_pixels: A numpy bool array, with True meaning the pixels are remaining to segment - :param segmented_pixels: A set of pixel coordinates which have already been assigned to segment. This will be - updated with the new pixels added to the returned segment. - """ - in_region = set() - not_in_region = set() - # Find the first pixel in remaining_pixels with a value of True - available_pixels = numpy.transpose(numpy.nonzero(remaining_pixels)) - start = tuple(available_pixels[0]) - in_region.add(start) - new_pixels = in_region.copy() - while True: - try_next = set() - # Find surrounding pixels - for pixel in new_pixels: - x, y = pixel - neighbours = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] - try_next.update(neighbours) - # Remove pixels we have already seen - try_next.difference_update(segmented_pixels, not_in_region) - # If there's no more pixels to try, the region is complete - if not try_next: - break - # Empty new pixels set, so we know whose neighbour's to check next time - new_pixels = set() - # Check new pixels - for pixel in try_next: - if remaining_pixels[pixel]: - in_region.add(pixel) - new_pixels.add(pixel) - segmented_pixels.add(pixel) - else: - not_in_region.add(pixel) - return in_region - - -def _find_all_segments(pixels, segment_threshold, min_segment_size): - """ - Finds all the regions within an image pixel array, and returns a list of the regions. - - Note: Slightly different segmentations are produced when using pillow version 6 vs. >=7, due to a change in - rounding in the greyscale conversion. - :param pixels: A numpy array of the pixel brightnesses. - :param segment_threshold: The brightness threshold to use when differentiating between hills and valleys. - :param min_segment_size: The minimum number of pixels for a segment. - """ - img_width, img_height = pixels.shape - # threshold pixels - threshold_pixels = pixels > segment_threshold - unassigned_pixels = numpy.full(pixels.shape, True, dtype=bool) - - segments = [] - already_segmented = set() - - # Add all the pixels around the border outside the image: - already_segmented.update([(-1, z) for z in range(img_height)]) - already_segmented.update([(z, -1) for z in range(img_width)]) - already_segmented.update([(img_width, z) for z in range(img_height)]) - already_segmented.update([(z, img_height) for z in range(img_width)]) - - # Find all the "hill" regions - while numpy.bitwise_and(threshold_pixels, unassigned_pixels).any(): - remaining_pixels = numpy.bitwise_and(threshold_pixels, unassigned_pixels) - segment = _find_region(remaining_pixels, already_segmented) - # Apply segment - if len(segment) > min_segment_size: - segments.append(segment) - for pix in segment: - unassigned_pixels[pix] = False - - # Invert the threshold matrix, and find "valleys" - threshold_pixels_i = numpy.invert(threshold_pixels) - while len(already_segmented) < img_width * img_height: - remaining_pixels = numpy.bitwise_and(threshold_pixels_i, unassigned_pixels) - segment = _find_region(remaining_pixels, already_segmented) - # Apply segment - if len(segment) > min_segment_size: - segments.append(segment) - for pix in segment: - unassigned_pixels[pix] = False - - return segments - - -def crop_resistant_hash( - image, hash_func=None, limit_segments=None, segment_threshold=128, min_segment_size=500, segmentation_image_size=300 -): - """ - Creates a CropResistantHash object, by the algorithm described in the paper "Efficient Cropping-Resistant Robust - Image Hashing". DOI 10.1109/ARES.2014.85 - This algorithm partitions the image into bright and dark segments, using a watershed-like algorithm, and then does - an image hash on each segment. This makes the image much more resistant to cropping than other algorithms, with - the paper claiming resistance to up to 50% cropping, while most other algorithms stop at about 5% cropping. - - Note: Slightly different segmentations are produced when using pillow version 6 vs. >=7, due to a change in - rounding in the greyscale conversion. This leads to a slightly different result. - :param image: The image to hash - :param hash_func: The hashing function to use - :param limit_segments: If you have storage requirements, you can limit to hashing only the M largest segments - :param segment_threshold: Brightness threshold between hills and valleys. This should be static, putting it between - peak and trough dynamically breaks the matching - :param min_segment_size: Minimum number of pixels for a hashable segment - :param segmentation_image_size: Size which the image is resized to before segmentation - """ - if hash_func is None: - hash_func = dhash - - orig_image = image.copy() - # Convert to gray scale and resize - image = image.convert("L").resize((segmentation_image_size, segmentation_image_size), Image.ANTIALIAS) - # Add filters - image = image.filter(ImageFilter.GaussianBlur()).filter(ImageFilter.MedianFilter()) - pixels = numpy.array(image).astype(numpy.float32) - - segments = _find_all_segments(pixels, segment_threshold, min_segment_size) - - # If there are no segments, have 1 segment including the whole image - if not segments: - full_image_segment = {(0, 0), (segmentation_image_size - 1, segmentation_image_size - 1)} - segments.append(full_image_segment) - - # If segment limit is set, discard the smaller segments - if limit_segments: - segments = sorted(segments, key=lambda s: len(s), reverse=True)[:limit_segments] - - # Create bounding box for each segment - hashes = [] - for segment in segments: - orig_w, orig_h = orig_image.size - scale_w = float(orig_w) / segmentation_image_size - scale_h = float(orig_h) / segmentation_image_size - min_y = min(coord[0] for coord in segment) * scale_h - min_x = min(coord[1] for coord in segment) * scale_w - max_y = (max(coord[0] for coord in segment) + 1) * scale_h - max_x = (max(coord[1] for coord in segment) + 1) * scale_w - # Compute robust hash for each bounding box - bounding_box = orig_image.crop((min_x, min_y, max_x, max_y)) - hashes.append(hash_func(bounding_box)) - # Show bounding box - # im_segment = image.copy() - # for pix in segment: - # im_segment.putpixel(pix[::-1], 255) - # im_segment.show() - # bounding_box.show() - - return ImageMultiHash(hashes) + return numpy.asarray(bitarray).reshape((-1, binbits)) + + +def compare_hashes(pillow_images: list, hash_type="average", hash_size=16, max_difference=0): + image_hashes = [] + for pillow_image in pillow_images: + if isinstance(pillow_image, (str, Path, BytesIO)): + pillow_image = Image.open(pillow_image) + if hash_type == "dhash": + image_hash = dhash(pillow_image, hash_size) + elif hash_type == "colorhash": + image_hash = colorhash(pillow_image) + else: + image_hash = average_hash(pillow_image, hash_size) + image_hash = image_hash.flatten() + for _ in range(len(image_hashes)): + distance = numpy.count_nonzero(image_hash != image_hashes[_]) + assert distance <= max_difference + image_hashes.append(image_hash) + + +# TESTS STARTS HERE + + +@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +def test_scale(): + heic_file = open_heif(Path("images/pug_1_0.heic")) + heic_file.scale(640, 640) + out_buffer = BytesIO() + heic_file.save(out_buffer) + compare_hashes([Path("images/pug_1_0.heic"), out_buffer]) + + +@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +def test_add_from(): + heif_file1 = open_heif(Path("images/pug_1_1.heic")) + heif_file2 = open_heif(Path("images/pug_2_3.heic")) + heif_file1.add_from_heif(heif_file2) + heif_file1.load(everything=True) + heif_file2.close(only_fp=True) + heif_file1.close(only_fp=True) + collect() + out_buf = BytesIO() + heif_file1.save(out_buf) + out_heif = open_heif(out_buf) + assert len([_ for _ in out_heif.thumbnails_all(one_for_image=True)]) == 3 + assert len([_ for _ in out_heif.thumbnails_all()]) == 4 + pillow_image = Image.open(out_buf) + compare_hashes([pillow_image, Path("images/pug_1_1.heic")]) + pillow_image.seek(1) + compare_hashes([pillow_image, Path("images/pug_2_3.heic")]) + pillow_image.seek(2) + _ = Image.open(Path("images/pug_2_3.heic")) + _.seek(1) + compare_hashes([pillow_image, _]) + out_heif.close() + heif_file1.close() + heif_file2.close() + + +# @pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +# @pytest.mark.parametrize( +# "image_path", +# ( +# "images/rgba10bit.avif", +# "images/rgba10bit.heif", +# "images/mono10bit.avif", +# "images/mono10bit.heif", +# "images/cat.hif", +# "images/10bit.heic", +# ), +# ) +# def test_10bit_to8(image_path): +# heif_image_10bit = open_heif(Path(image_path), convert_hdr_to_8bit=False) +# heif_image_10bit_2 = HeifFile({}).add_from_heif(heif_image_10bit) +# heif_image_8bit_saved = BytesIO() +# heif_image_10bit.save(heif_image_10bit_saved) +# pillow_image = heif_image[0].to_pillow(ignore_thumbnails=True) +# pillow_image.save("test.jpg") +# # compare_hashes([pillow_image, Path("images/cat.hif")]) +# # image_8bit = Image.open(Path(image_path)) diff --git a/tests/heif_encoder_test.py b/tests/heif_encoder_test.py index 83147381..47c6b166 100644 --- a/tests/heif_encoder_test.py +++ b/tests/heif_encoder_test.py @@ -1,46 +1,188 @@ +import builtins import os -from io import BytesIO +from io import SEEK_END, BytesIO from pathlib import Path +from sys import platform import pytest -from PIL import Image +from heif_test import compare_heif_files_fields from pillow_heif import _options # noqa from pillow_heif import HeifError, open_heif, options, register_heif_opener -imagehash = pytest.importorskip("hashes_test", reason="NumPy not installed") - - os.chdir(os.path.dirname(os.path.abspath(__file__))) register_heif_opener() -def compare_hashes(pillow_images: list, hash_type="average", hash_size=16, max_difference=0): - image_hashes = [] - for pillow_image in pillow_images: - if isinstance(pillow_image, (str, Path)): - pillow_image = Image.open(pillow_image) - elif isinstance(pillow_image, BytesIO): - pillow_image = Image.open(pillow_image) - if hash_type == "dhash": - image_hash = imagehash.dhash(pillow_image, hash_size) - elif hash_type == "colorhash": - image_hash = imagehash.colorhash(pillow_image, hash_size) - else: - image_hash = imagehash.average_hash(pillow_image, hash_size) - for _ in range(len(image_hashes)): - distance = image_hash - image_hashes[_] - assert distance <= max_difference - image_hashes.append(image_hash) +@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +def test_outputs(): + with builtins.open(Path("images/pug_1_1.heic"), "rb") as f: + output = BytesIO() + open_heif(f).save(output, quality=10) + assert output.seek(0, SEEK_END) > 0 + with builtins.open(Path("tmp.heic"), "wb") as output: + open_heif(f).save(output, quality=10) + assert output.seek(0, SEEK_END) > 0 + open_heif(f).save(Path("tmp.heic"), quality=10) + assert Path("tmp.heic").stat().st_size > 0 + Path("tmp.heic").unlink() + with pytest.raises(TypeError): + open_heif(f).save(bytes(b"1234567890"), quality=10) @pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") -def test_scale(): +def test_save_empty(): heic_file = open_heif(Path("images/pug_1_0.heic")) - heic_file.scale(640, 640) + del heic_file[0] + out_buffer = BytesIO() + with pytest.raises(ValueError): + heic_file.save(out_buffer) + + +@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +@pytest.mark.parametrize( + "image_path,remove_img,remove_thumb,expected", + ( + ("images/pug_2_1.heic", [0], [], (1, 0)), + ("images/pug_2_1.heic", [1], [], (1, 1)), + ("images/pug_2_1.heic", [1], [(0, 0)], (1, 0)), + ("images/pug_2_3.heic", [0], [], (1, 2)), + ("images/pug_2_3.heic", [0], [(0, 0)], (1, 1)), + ("images/pug_2_3.heic", [0], [(0, 1)], (1, 1)), + ("images/pug_2_3.heic", [0], [(0, 1), (0, 0)], (1, 0)), + ("images/pug_2_3.heic", [1], [], (1, 1)), + ("images/pug_2_3.heic", [1], [(0, 0)], (1, 0)), + ), +) +def test_remove(image_path, remove_img: list, remove_thumb: list, expected: tuple): + heic_file_2_images = open_heif(Path(image_path)) + for remove_index in remove_img: + del heic_file_2_images[remove_index] + for remove_tuple in remove_thumb: + del heic_file_2_images[remove_tuple[0]].thumbnails[remove_tuple[1]] out_buffer = BytesIO() - heic_file.save(out_buffer) - compare_hashes([Path("images/pug_1_0.heic"), out_buffer], max_difference=1) + heic_file_2_images.save(out_buffer) + heic_file_1_image = open_heif(out_buffer) + assert len(heic_file_1_image) == expected[0] + assert len(heic_file_1_image.thumbnails) == expected[1] + + +@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +@pytest.mark.parametrize( + "thumbs,expected", + ( + (-1, 1), + ([-1], 1), + (0, 1), + ([0], 1), + (1, 1), + ([1], 1), + (256, 1), + ([256], 1), + ([2048], 1), + (128, 2), + ([128], 2), + ([128, 0], 2), + ([0, 128], 2), + ([128, 400], 3), + ), +) +def test_add_thumbs_to_image(thumbs, expected): + heif_file = open_heif(Path("images/pug_1_1.heic")) + heif_file[0].add_thumbnails(thumbs) + output = BytesIO() + heif_file.save(output, quality=10) + assert len(open_heif(output)[0].thumbnails) == expected + + +@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +@pytest.mark.parametrize( + "thumbs,expected", + ( + ([-1], (1, 0)), + (0, (1, 0)), + (256, (2, 1)), + ([256], (2, 1)), + (128, (1, 1)), + ([128, 0], (1, 1)), + ([0, 128], (1, 1)), + ([128, 400], (2, 2)), + ([280, 464], (3, 2)), + ), +) +def test_add_thumbs_to_images(thumbs, expected): + heif_file = open_heif(Path("images/pug_2_1.heic")) + heif_file.add_thumbnails(thumbs) + output = BytesIO() + heif_file.save(output, quality=10) + heif_file = open_heif(output) + assert len(heif_file[0].thumbnails) == expected[0] + assert len(heif_file[1].thumbnails) == expected[1] + + +@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +def test_add_from_heif(): + heif_file = open_heif(Path("images/pug_1_1.heic")) + heif_file.add_from_heif(heif_file) + assert len(heif_file) == 2 + assert len([_ for _ in heif_file.thumbnails_all()]) == 2 + compare_heif_files_fields(heif_file[0], heif_file[1]) + heif_file_to_add = open_heif(Path("images/pug_1_2.heic")) + heif_file.add_from_heif(heif_file_to_add) + heif_file.add_from_heif(heif_file_to_add[0]) + compare_heif_files_fields(heif_file[2], heif_file[3]) + out_buf = BytesIO() + heif_file.save(out_buf, quality=10, enc_params=[("x265:ctu", "32")]) + heif_file_to_add.close() + saved_heif_file = open_heif(out_buf) + assert len(saved_heif_file) == 4 + assert len([_ for _ in saved_heif_file.thumbnails_all()]) == 6 + compare_heif_files_fields(heif_file, saved_heif_file, ignore=["len"]) + + +@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +@pytest.mark.skipif(platform.lower() == "win32", reason="No 10/12 bit encoder for Windows.") +def test_10_bit(): + heif_file = open_heif(Path("images/mono10bit.heif"), convert_hdr_to_8bit=False) + heif_file.add_from_heif(heif_file) + assert len(heif_file) == 2 + compare_heif_files_fields(heif_file[0], heif_file[1]) + heif_file_to_add = open_heif(Path("images/rgba10bit.heif"), convert_hdr_to_8bit=False) + heif_file.add_from_heif(heif_file_to_add) + heif_file.add_from_heif(heif_file_to_add[0]) + compare_heif_files_fields(heif_file[2], heif_file[3]) + out_buf = BytesIO() + heif_file.save(out_buf, enc_params=[("x265:ctu", "32")]) + heif_file.close() + heif_file_to_add.close() + heif_file = open_heif(out_buf, convert_hdr_to_8bit=False) + assert len(heif_file) == 4 + compare_heif_files_fields(heif_file[0], heif_file[1]) + compare_heif_files_fields(heif_file[2], heif_file[3]) + assert heif_file[0].bit_depth == 10 + assert heif_file[0].mode == "RGBA" + assert heif_file[2].bit_depth == 10 + assert heif_file[2].mode == "RGBA" + + +@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +def test_save_all(): + heif_file = open_heif(Path("images/pug_2_0.heic")) + out_buf_save_all = BytesIO() + heif_file.save(out_buf_save_all, save_all=True, quality=15) + out_buf_save_one = BytesIO() + heif_file.save(out_buf_save_one, save_all=False, quality=15) + assert len(open_heif(out_buf_save_all)) == 2 + assert len(open_heif(out_buf_save_one)) == 1 + + +@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +def test_hif_file(): + heif_file1 = open_heif(Path("images/cat.hif")) + out_buf = BytesIO() + heif_file1.save(out_buf, quality=10) + heif_file2 = open_heif(out_buf) + compare_heif_files_fields(heif_file1, heif_file2, thumb_max_differ=3) def test_no_encoder(): diff --git a/tests/heif_test.py b/tests/heif_test.py index 7df5d5d7..ecdda1fa 100644 --- a/tests/heif_test.py +++ b/tests/heif_test.py @@ -1,9 +1,9 @@ import builtins import os from gc import collect -from io import SEEK_END, BytesIO +from io import BytesIO from pathlib import Path -from sys import platform +from typing import Union from warnings import warn import pytest @@ -15,7 +15,6 @@ HeifErrorCode, HeifFile, HeifImage, - HeifSaveMask, open_heif, options, register_heif_opener, @@ -32,7 +31,45 @@ warn("Skipping tests for `AV1` format due to lack of codecs.") avif_images.clear() -images_dataset = heic_images + avif_images + heif_images +full_dataset = heic_images + avif_images + heif_images +minimal_dataset = [ + Path("images/pug_1_0.heic"), + Path("images/pug_2_1.heic"), + Path("images/invalid_id.heic"), + Path("images/pug_2_3.heic"), + Path("images/nokia/alpha.heic"), +] + + +def compare_heif_files_fields( + heif1: Union[HeifFile, HeifImage], heif2: Union[HeifFile, HeifImage], ignore=None, thumb_max_differ=0 +): + def compare_images_fields(image1: HeifImage, image2: HeifImage): + assert image1.size == image2.size + assert image1.mode == image2.mode + if ignore is not None and "bit_depth" not in ignore: + assert image1.bit_depth == image2.bit_depth + if ignore is not None and "stride" not in ignore: + assert image1.stride == image2.stride + if ignore is not None and "len" not in ignore: + assert len(image1.data) == len(image2.data) + for i_thumb, thumbnail in enumerate(image1.thumbnails): + with_difference = thumbnail.size[0] - image2.thumbnails[i_thumb].size[0] + height_difference = thumbnail.size[1] - image2.thumbnails[i_thumb].size[1] + assert with_difference + height_difference <= thumb_max_differ + assert thumbnail.mode == image2.thumbnails[i_thumb].mode + if ignore is not None and "bit_depth" not in ignore: + assert thumbnail.bit_depth == image2.thumbnails[i_thumb].bit_depth + if ignore is not None and "stride" not in ignore: + assert thumbnail.stride == image2.thumbnails[i_thumb].stride + if ignore is not None and "len" not in ignore: + assert len(thumbnail.data) == len(image2.thumbnails[i_thumb].data) + + if isinstance(heif1, HeifFile): + for i, image in enumerate(heif1): + compare_images_fields(image, heif2[i]) + else: + compare_images_fields(heif1, heif2) @pytest.mark.parametrize("img_path", list(Path().glob("images/invalid/*"))) @@ -47,64 +84,7 @@ def test_corrupted_open(img_path): assert str(exception).find("Invalid input") != -1 -@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") -def test_get_img_thumb_mask_for_save(): - heif_file = open_heif(Path("images/pug_2_2.heic")) - mask = heif_file.get_img_thumb_mask_for_save(HeifSaveMask.SAVE_NONE) - output = BytesIO() - heif_file.save(output, save_mask=mask, quality=10) - with pytest.raises(HeifError): - open_heif(output) - mask = heif_file.get_img_thumb_mask_for_save(HeifSaveMask.SAVE_ONE) - output = BytesIO() - heif_file.save(output, save_mask=mask, quality=10) - new_heif = open_heif(output) - assert len(new_heif) == 1 - assert len(new_heif[0].thumbnails) == 0 - mask = heif_file.get_img_thumb_mask_for_save(HeifSaveMask.SAVE_ALL) - output = BytesIO() - heif_file.save(output, save_mask=mask, quality=10) - new_heif = open_heif(output) - assert len(new_heif) == 2 - assert len(new_heif[1].thumbnails) == 1 - mask = heif_file.get_img_thumb_mask_for_save(HeifSaveMask.SAVE_ALL, thumb_box=-1) - output = BytesIO() - heif_file.save(output, save_mask=mask, quality=10) - new_heif = open_heif(output) - assert len(new_heif) == 2 - assert len(new_heif[1].thumbnails) == 0 - mask = heif_file.get_img_thumb_mask_for_save(HeifSaveMask.SAVE_ALL, thumb_box=128) - output = BytesIO() - heif_file.save(output, save_mask=mask, quality=10) - new_heif = open_heif(output) - assert len(new_heif) == 2 - assert len(new_heif[1].thumbnails) == 1 - - -@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") -@pytest.mark.parametrize( - "thumbs,expected", - ( - ([-1], 1), - ([0], 1), - ([1], 1), - ([256], 1), - ([128], 2), - ([128, 0], 2), - ([0, 128], 2), - ([128, 400], 3), - ), -) -def test_add_thumbs_to_mask(thumbs, expected): - heif_file = open_heif(Path("images/pug_1_1.heic")) - mask = heif_file.get_img_thumb_mask_for_save(HeifSaveMask.SAVE_ALL) - output = BytesIO() - heif_file.add_thumbs_to_mask(mask, thumbs) - heif_file.save(output, save_mask=mask, quality=10) - assert len(open_heif(output)[0].thumbnails) == expected - - -def test_image_index(): +def test_index(): heif_file = open_heif(Path("images/pug_2_2.heic")) with pytest.raises(IndexError): heif_file[-1].load() @@ -112,151 +92,46 @@ def test_image_index(): heif_file[len(heif_file)].load() assert heif_file[0].info["main"] assert not heif_file[len(heif_file) - 1].info["main"] + with pytest.raises(IndexError): + heif_file[0].thumbnails[-1].load() + with pytest.raises(IndexError): + heif_file[0].thumbnails[len(heif_file[0].thumbnails)].load() + with pytest.raises(IndexError): + del heif_file[-1] + with pytest.raises(IndexError): + del heif_file[2] heif_file.close() -@pytest.mark.parametrize("img_path", avif_images[:4] + heic_images[:4]) -def test_inputs(img_path): - with builtins.open(img_path, "rb") as f: - d = f.read() - for heif_file in (open_heif(f), open_heif(d), open_heif(bytearray(d)), open_heif(BytesIO(d))): - assert heif_file.size[0] > 0 - assert heif_file.size[1] > 0 - assert heif_file.info - assert len(heif_file.data) > 0 - heif_file.load(everything=True) - heif_file.close(only_fp=True) - heif_file.close(only_fp=True) - collect() - for thumb in heif_file.thumbnails_all(): - assert len(thumb.data) > 0 - thumb.close() - assert not thumb.data - heif_file.close() - assert getattr(heif_file, "_heif_ctx") is None - assert not heif_file.data - for thumb in heif_file.thumbnails_all(): - assert not thumb.data - heif_file.close() - f.seek(0) - - -@pytest.mark.parametrize("img_path", avif_images[:4] + heic_images[:4]) -def test_inputs_collect(img_path): - with builtins.open(img_path, "rb") as f: - d = f.read() - for heif_file in (open_heif(f), open_heif(d), open_heif(BytesIO(d))): - heif_file.load(everything=True) - heif_file.close(only_fp=True) - heif_file.unload() - collect() - with pytest.raises(HeifError): - assert heif_file.data - heif_file.close() - f.seek(0) - - -@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") -def test_outputs(): - with builtins.open(Path("images/pug_1_1.heic"), "rb") as f: - output = BytesIO() - open_heif(f).save(output, quality=10) - assert output.seek(0, SEEK_END) > 0 - with builtins.open(Path("tmp.heic"), "wb") as output: - open_heif(f).save(output, quality=10) - assert output.seek(0, SEEK_END) > 0 - open_heif(f).save(Path("tmp.heic"), quality=10) - assert Path("tmp.heic").stat().st_size > 0 - Path("tmp.heic").unlink() - with pytest.raises(TypeError): - open_heif(f).save(bytes(b"1234567890"), quality=10) - - -@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") -def test_thumbnails(): +def test_etc(): + heif_file = open_heif(Path("images/pug_2_2.heic")) + heif_file.load(everything=False) + assert getattr(heif_file[0], "_img_data") + assert not getattr(heif_file[1], "_img_data") + assert heif_file.size == heif_file[0].size + assert heif_file.mode == heif_file[0].mode + assert len(heif_file.data) == len(heif_file[0].data) + assert heif_file.stride == heif_file[0].stride + assert heif_file.chroma == heif_file[0].chroma + assert heif_file.color == heif_file[0].color + assert heif_file.has_alpha == heif_file[0].has_alpha + assert heif_file.bit_depth == heif_file[0].bit_depth + + +def test_thumb_one_for_image(): + heif_file = open_heif(Path("images/pug_2_1.heic")) + assert len([_ for _ in heif_file.thumbnails_all(one_for_image=True)]) == 1 + assert len([_ for _ in heif_file.thumbnails_all(one_for_image=False)]) == 1 heif_file = open_heif(Path("images/pug_2_3.heic")) assert len([_ for _ in heif_file.thumbnails_all(one_for_image=True)]) == 2 - assert len([_ for _ in heif_file.thumbnails_all()]) == 3 - heif_file.load(everything=True) - heif_file_to_add = open_heif(Path("images/pug_1_1.heic")) - heif_file.add_from_heif(heif_file_to_add) - heif_file.close(only_fp=True) - collect() - out_buf = BytesIO() - heif_file.save(out_buf) - out_heif = open_heif(out_buf) - assert len([_ for _ in out_heif.thumbnails_all(one_for_image=True)]) == 3 - assert len([_ for _ in out_heif.thumbnails_all()]) == 4 - out_heif.close() - heif_file.close() - - -def test_add_from_heif(): - def check_equality(): - assert len(heif_file) == 4 - assert len([_ for _ in heif_file.thumbnails_all()]) == 6 - assert heif_file[0].size == heif_file[1].size - assert heif_file[0].mode == heif_file[1].mode - assert heif_file[0].stride == heif_file[1].stride - assert len(heif_file[0].data) == len(heif_file[1].data) - assert heif_file[2].size == heif_file[3].size - assert heif_file[2].mode == heif_file[3].mode - assert heif_file[2].stride == heif_file[3].stride - assert len(heif_file[2].data) == len(heif_file[3].data) - - heif_file = open_heif(Path("images/pug_1_1.heic")) - heif_file.add_from_heif(heif_file) - assert len(heif_file) == 2 - assert len([_ for _ in heif_file.thumbnails_all()]) == 2 - heif_file_to_add = open_heif(Path("images/pug_1_2.heic")) - heif_file.add_from_heif(heif_file_to_add) - heif_file.add_from_heif(heif_file_to_add[0]) - check_equality() - if options().hevc_enc: - out_buf = BytesIO() - heif_file.save(out_buf, quality=10, enc_params=[("x265:ctu", "32")]) - heif_file.close() - heif_file_to_add.close() - heif_file = open_heif(out_buf) - assert len(heif_file) == 4 - assert len([_ for _ in heif_file.thumbnails_all()]) == 6 - heif_file.load(everything=True) - check_equality() - - -@pytest.mark.skipif(platform.lower() == "win32", reason="No 10/12 bit encoder for Windows.") -def test_add_from_heif_10bit(): - def check_equality(): - assert len(heif_file) == 4 - assert heif_file[0].size == heif_file[1].size - assert heif_file[0].mode == heif_file[1].mode - assert heif_file[0].stride == heif_file[1].stride - assert len(heif_file[0].data) == len(heif_file[1].data) - assert heif_file[2].size == heif_file[3].size - assert heif_file[2].mode == heif_file[3].mode - assert heif_file[2].stride == heif_file[3].stride - assert len(heif_file[2].data) == len(heif_file[3].data) - - heif_file = open_heif(Path("images/mono10bit.heif"), convert_hdr_to_8bit=False) - heif_file.add_from_heif(heif_file) - assert len(heif_file) == 2 - heif_file_to_add = open_heif(Path("images/rgba10bit.heif"), convert_hdr_to_8bit=False) - heif_file.add_from_heif(heif_file_to_add) - heif_file.add_from_heif(heif_file_to_add[0]) - check_equality() - out_buf = BytesIO() - if options().hevc_enc: - heif_file.save(out_buf, enc_params=[("x265:ctu", "32")]) - heif_file.close() - heif_file_to_add.close() - heif_file = open_heif(out_buf, convert_hdr_to_8bit=False) - assert len(heif_file) == 4 - heif_file.load(everything=True) - check_equality() + assert len([_ for _ in heif_file.thumbnails_all(one_for_image=False)]) == 3 def test_collect(): heif_file = open_heif(Path("images/pug_2_1.heic")) + second_heif = HeifFile({}) + second_heif.add_from_heif(heif_file) + heif_file = second_heif collect() heif_file.load(everything=True) heif_file.unload(everything=True) @@ -264,10 +139,6 @@ def test_collect(): heif_file.load(everything=True) heif_file.close(only_fp=True) collect() - if options().hevc_enc: - new_heif_image = BytesIO() - heif_file.save(new_heif_image, quality=10) - assert isinstance(open_heif(new_heif_image), HeifFile) for image in heif_file: data = image.data # noqa for thumbnail in image.thumbnails: @@ -287,18 +158,80 @@ def test_collect(): heif_file.close() -@pytest.mark.parametrize("image_path", images_dataset) +@pytest.mark.parametrize("img_path", minimal_dataset) +def test_inputs(img_path): + with builtins.open(img_path, "rb") as f: + b = f.read() + non_exclusive = [open_heif(BytesIO(b)), open_heif(f)] + exclusive = [open_heif(img_path), open_heif(b)] + for heif_file in [*non_exclusive, *exclusive]: + assert min(heif_file.size) > 0 + assert heif_file.info + assert getattr(heif_file, "_heif_ctx") is not None + collect() + # This will load all data + for image in heif_file: + assert len(image.data) > 0 + for thumbnail in image.thumbnails: + assert len(thumbnail.data) > 0 + # Check if unloading data works. `unload` method is for private use, it must not be called in apps code. + heif_file.unload(everything=True) + collect() + for image in heif_file: + assert not getattr(image, "_img_data") + for thumbnail in image.thumbnails: + assert not getattr(thumbnail, "_img_data") + # After `unload` with if `fp` is present, it will read and load images again. + for image in heif_file: + assert len(image.data) > 0 + for thumbnail in image.thumbnails: + assert len(thumbnail.data) > 0 + heif_file.close(only_fp=True) + collect() + assert getattr(heif_file, "_heif_ctx") is not None + # `fp` must be closed here. + assert getattr(heif_file._heif_ctx, "fp") is None + assert getattr(heif_file._heif_ctx, "_fp_close_after") == bool(heif_file in exclusive) + # Create new heif_file + second_heif = HeifFile({}) + second_heif.add_from_heif(heif_file) + # This must do nothing, cause there is no `fp` in new heif file. + second_heif.close(only_fp=True) + collect() + compare_heif_files_fields(second_heif, heif_file) + heif_file.unload() + collect() + # After `unload` with `fp`=None, must be an exception accessing `data` + with pytest.raises(HeifError): + data = heif_file.data # noqa + heif_file.close() + collect() + # Closing original heif file, must not affect newly created. + for image in second_heif: + assert len(image.data) > 0 + for thumbnail in image.thumbnails: + assert len(thumbnail.data) > 0 + collect() + # Create heif_file from already created and compare. + heif_file = HeifFile({}) + heif_file.add_from_heif(second_heif) + collect() + compare_heif_files_fields(second_heif, heif_file) + heif_file.close() + second_heif.close() + + +@pytest.mark.parametrize("image_path", full_dataset) def test_all(image_path): heif_file = open_heif(image_path) for c, image in enumerate(heif_file): image.misc["to_8bit"] = True - pass_count = 2 if heif_file.bit_depth > 8 and platform.lower() != "win32" else 1 + pass_count = 2 if heif_file.bit_depth > 8 else 1 for i in range(pass_count): if i == 1: image.misc["to_8bit"] = False image.unload() assert min(image.size) > 0 - assert image.size[1] > 0 assert image.mode == "RGBA" if image.has_alpha else "RGB" assert image.bit_depth >= 8 assert image.chroma == HeifChroma.UNDEFINED @@ -310,26 +243,10 @@ def test_all(image_path): assert image.stride >= minimal_stride assert len(image.data) == image.stride * image.size[1] assert image.chroma != HeifChroma.UNDEFINED - assert image.color == HeifColorspace.RGB + assert image.color != HeifColorspace.UNDEFINED assert isinstance(image.load(), HeifImage) - - if not options().hevc_enc: - continue - save_mask = heif_file.get_img_thumb_mask_for_save(mask=HeifSaveMask.SAVE_NONE) - save_mask[c][0] = True - new_heif_image = BytesIO() - heif_file.save(new_heif_image, quality=10, save_mask=save_mask) - new_heif_file = open_heif(new_heif_image, convert_hdr_to_8bit=image.misc["to_8bit"]) - assert new_heif_file.mode == image.mode - assert new_heif_file.bit_depth == 8 if image.misc["to_8bit"] else image.bit_depth - assert isinstance(new_heif_file.load(), HeifFile) - assert new_heif_file.has_alpha == image.has_alpha - assert new_heif_file.chroma == image.chroma - assert new_heif_file.color == image.color - assert new_heif_file.size[0] == image.size[0] - assert new_heif_file.size[1] == image.size[1] - minimal_stride = new_heif_file.size[0] * 4 if new_heif_file.has_alpha else new_heif_file.size[0] * 3 - if new_heif_file.bit_depth > 8 and not new_heif_file[0].misc["to_8bit"]: - minimal_stride *= 2 - assert new_heif_file.stride >= minimal_stride - new_heif_file.close() + heif_file.close() + assert getattr(heif_file, "_heif_ctx") is None + collect() + # Here will be no exception, heif_file is in `closed` state without `_heif_ctx` and will not try to load anything. + assert heif_file.data is None diff --git a/tests/opener_encoder_test.py b/tests/opener_encoder_test.py index f5e278a1..7fac6ca4 100644 --- a/tests/opener_encoder_test.py +++ b/tests/opener_encoder_test.py @@ -5,7 +5,7 @@ import pytest from PIL import Image, ImageSequence -from pillow_heif import options, register_heif_opener, from_pillow +from pillow_heif import from_pillow, options, register_heif_opener imagehash = pytest.importorskip("hashes_test", reason="NumPy not installed") @@ -13,37 +13,18 @@ register_heif_opener() -def compare_hashes(pillow_images: list, hash_type="average", hash_size=16, max_difference=0): - image_hashes = [] - for pillow_image in pillow_images: - if isinstance(pillow_image, (str, Path)): - pillow_image = Image.open(pillow_image) - elif isinstance(pillow_image, BytesIO): - pillow_image = Image.open(pillow_image) - if hash_type == "dhash": - image_hash = imagehash.dhash(pillow_image, hash_size) - elif hash_type == "colorhash": - image_hash = imagehash.colorhash(pillow_image, hash_size) - else: - image_hash = imagehash.average_hash(pillow_image, hash_size) - for _ in range(len(image_hashes)): - distance = image_hash - image_hashes[_] - assert distance <= max_difference - image_hashes.append(image_hash) - - @pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") def test_jpeg_heic(): jpeg_pillow = Image.open(Path("images/jpeg_gif_png/pug.jpeg")) heic_pillow = Image.open(Path("images/pug_1_1.heic")) # test decoder basic - compare_hashes([jpeg_pillow, heic_pillow]) + imagehash.compare_hashes([jpeg_pillow, heic_pillow]) # test jpeg->heic and heic->jpeg out_heic = BytesIO() jpeg_pillow.save(out_heic, format="HEIF") out_jpeg = BytesIO() heic_pillow.save(out_jpeg, format="JPEG") - compare_hashes([jpeg_pillow, heic_pillow, out_heic, out_jpeg]) + imagehash.compare_hashes([jpeg_pillow, heic_pillow, out_heic, out_jpeg]) @pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") @@ -52,7 +33,7 @@ def test_jpeg_to_heic_orientation(): jpeg_pillow = Image.open(Path("images/jpeg_gif_png/pug_90_flipped.jpeg")) out_heic = BytesIO() jpeg_pillow.save(out_heic, format="HEIF", quality=80) - compare_hashes([jpeg_pillow, out_heic], hash_type="dhash", max_difference=1) + imagehash.compare_hashes([jpeg_pillow, out_heic], hash_type="dhash", max_difference=1) @pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") @@ -61,12 +42,12 @@ def test_quality(): heic_pillow = Image.open(Path("images/arrow.heic")) out_jpeg = BytesIO() heic_pillow.save(out_jpeg, format="JPEG") - compare_hashes([heic_pillow, out_jpeg], hash_type="dhash", max_difference=1) + imagehash.compare_hashes([heic_pillow, out_jpeg], hash_type="dhash", max_difference=1) out_heic_q30 = BytesIO() out_heic_q20 = BytesIO() heic_pillow.save(out_heic_q30, format="HEIF", quality=30) heic_pillow.save(out_heic_q20, format="HEIF", quality=20) - compare_hashes([heic_pillow, out_heic_q30, out_heic_q20], hash_size=8) + imagehash.compare_hashes([heic_pillow, out_heic_q30, out_heic_q20], hash_size=8) assert out_heic_q30.seek(0, SEEK_END) < Path("images/arrow.heic").stat().st_size assert out_heic_q20.seek(0, SEEK_END) < out_heic_q30.seek(0, SEEK_END) @@ -77,14 +58,23 @@ def test_gif(): gif_pillow = Image.open(Path("images/jpeg_gif_png/chi.gif")) out_heic = BytesIO() gif_pillow.save(out_heic, format="HEIF") - compare_hashes([gif_pillow, out_heic], hash_type="dhash") + imagehash.compare_hashes([gif_pillow, out_heic], hash_type="dhash") # convert all frames of gif(pillow_heif does not skip identical frames and saves all frames like in source) out_all_heic = BytesIO() gif_pillow.save(out_all_heic, format="HEIF", save_all=True, quality=80) assert out_heic.seek(0, SEEK_END) * 2 < out_all_heic.seek(0, SEEK_END) heic_pillow = Image.open(out_all_heic) for i, frame in enumerate(ImageSequence.Iterator(gif_pillow)): - compare_hashes([ImageSequence.Iterator(heic_pillow)[i], frame], max_difference=1) + imagehash.compare_hashes([ImageSequence.Iterator(heic_pillow)[i], frame], max_difference=1) + + +@pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") +@pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) +def test_zero(size: tuple): + out_heif = BytesIO() + im = Image.new("RGB", size) + with pytest.raises(ValueError): + im.save(out_heif, format="HEIF") @pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") @@ -95,22 +85,21 @@ def test_alpha_channel(): heic_pillow.save(out_png, format="PNG", save_all=True) png_pillow = Image.open(out_png) for i, frame in enumerate(ImageSequence.Iterator(png_pillow)): - compare_hashes([ImageSequence.Iterator(heic_pillow)[i], frame]) + imagehash.compare_hashes([ImageSequence.Iterator(heic_pillow)[i], frame]) # saving from png to heic out_heic = BytesIO() png_pillow.save(out_heic, format="HEIF", quality=90, save_all=True) heic_pillow = Image.open(out_heic) for i, frame in enumerate(ImageSequence.Iterator(png_pillow)): - compare_hashes([ImageSequence.Iterator(heic_pillow)[i], frame]) + imagehash.compare_hashes([ImageSequence.Iterator(heic_pillow)[i], frame]) @pytest.mark.skipif(not options().hevc_enc, reason="No HEVC encoder.") -def test_palette_with_bytes_tranparency(): - png_pillow = Image.open( - Path("images/jpeg_gif_png/palette_with_bytes_transparency.png")) +def test_palette_with_bytes_transparency(): + png_pillow = Image.open(Path("images/jpeg_gif_png/palette_with_bytes_transparency.png")) heif_file = from_pillow(png_pillow) out_heic = BytesIO() heif_file.save(out_heic, format="HEIF", quality=90, save_all=True) heic_pillow = Image.open(out_heic) - assert heic_pillow.heif_file.has_alpha is True + assert heic_pillow.heif_file.has_alpha is True # noqa assert heic_pillow.mode == "RGBA" diff --git a/tests/opener_test.py b/tests/opener_test.py index c4703d00..b21e3642 100644 --- a/tests/opener_test.py +++ b/tests/opener_test.py @@ -3,12 +3,22 @@ from gc import collect from io import BytesIO from pathlib import Path +from typing import Union from warnings import warn import pytest +from heif_test import compare_heif_files_fields from PIL import Image, ImageCms, ImageSequence, UnidentifiedImageError -from pillow_heif import HeifBrand, options, register_heif_opener +from pillow_heif import ( + HeifBrand, + HeifFile, + HeifImage, + HeifThumbnail, + open_heif, + options, + register_heif_opener, +) register_heif_opener() os.chdir(os.path.dirname(os.path.abspath(__file__))) @@ -24,11 +34,36 @@ images_dataset = heic_images + avif_images + heif_images +def compare_heif_to_pillow_fields(heif: Union[HeifFile, HeifImage, HeifThumbnail], pillow: Image, ignore=None): + def compare_images_fields(heif_image: Union[HeifImage, HeifThumbnail], pillow_image: Image): + assert heif_image.size == pillow_image.size + assert heif_image.mode == pillow_image.mode + is_heif_image = isinstance(heif_image, HeifImage) + assert is_heif_image == bool(len(pillow_image.info)) + for k in ("main", "brand", "exif", "metadata"): + if heif_image.info.get(k, None): + if isinstance(heif_image.info[k], (bool, int, float, str)): + assert heif_image.info[k] == pillow_image.info[k] + else: + assert len(heif_image.info[k]) == len(pillow_image.info[k]) + for k in ("icc_profile", "icc_profile_type", "nclx_profile"): + if heif_image.info.get(k, None): + assert len(heif_image.info[k]) == len(pillow_image.info[k]) + + if isinstance(heif, HeifFile): + for i, image in enumerate(heif): + pillow.seek(i) + compare_images_fields(image, pillow) + else: + compare_images_fields(heif, pillow) + + @pytest.mark.parametrize("image_path", images_dataset) def test_open_images(image_path): pillow_image = Image.open(image_path) assert getattr(pillow_image, "fp") is not None assert getattr(pillow_image, "heif_file") is not None + assert not getattr(pillow_image, "_close_exclusive_fp_after_loading") pillow_image.verify() # Here we must check verify, but currently verify do nothing. images_count = len([_ for _ in ImageSequence.Iterator(pillow_image)]) _last_img_id = -1 @@ -43,10 +78,12 @@ def test_open_images(image_path): assert image.info["main"] image.load() assert getattr(pillow_image, "fp") is not None - if images_count > 1 or len(pillow_image.info["thumbnails"]): + if images_count > 1: assert getattr(pillow_image, "heif_file") is not None + assert not getattr(pillow_image, "_close_exclusive_fp_after_loading") else: assert getattr(pillow_image, "heif_file") is None + assert getattr(pillow_image, "_close_exclusive_fp_after_loading") @pytest.mark.parametrize("img_path", list(Path().glob("images/invalid/*"))) @@ -92,3 +129,54 @@ def test_after_load(): assert len(frame.tobytes()) > 0 for thumb in img.info["thumbnails"]: assert len(thumb.data) > 0 + + +@pytest.mark.parametrize("image_path", ("images/pug_2_1.heic", "images/pug_2_3.heic")) +def test_to_from_pillow(image_path): + heif_file = open_heif(image_path) + pillow_image1 = heif_file[0].to_pillow() + pillow_image2 = heif_file[1].to_pillow() + compare_heif_to_pillow_fields(heif_file[0], pillow_image1) + compare_heif_to_pillow_fields(heif_file[1], pillow_image2) + heif_from_pillow = HeifFile({}) + heif_from_pillow.add_from_pillow(pillow_image1) + heif_from_pillow.add_from_pillow(pillow_image2) + compare_heif_files_fields(heif_file, heif_from_pillow) + + +@pytest.mark.parametrize( + "image_path,compare_info", + ( + ("images/pug_2_1.heic", {0: [0], 1: []}), + ("images/pug_2_3.heic", {0: [0], 1: [0, 1]}), + ("images/nokia/alpha.heic", {0: [], 1: [], 2: []}), + ), +) +def test_to_from_pillow_extra(image_path, compare_info): + heif_file = open_heif(image_path) + pil_list = [] + # HeifFile to Pillow Images list + for img_i, thumb_i_list in compare_info.items(): + pil_list.append(heif_file[img_i].to_pillow(ignore_thumbnails=True)) + for thumb_i in thumb_i_list: + pil_list.append(heif_file[img_i].thumbnails[thumb_i].to_pillow()) + collect() + # Pillow Images compare to HeifFile + i = 0 + for img_i, thumb_i_list in compare_info.items(): + compare_heif_to_pillow_fields(heif_file[img_i], pil_list[i]) + i += 1 + for thumb_i in thumb_i_list: + compare_heif_to_pillow_fields(heif_file[img_i].thumbnails[thumb_i], pil_list[i]) + i += 1 + collect() + # From Pillow Images create one HeifFile and compare. + heif_from_pillow = HeifFile({}) + i = 0 + for img_i, thumb_i_list in compare_info.items(): + heif_from_pillow.add_from_pillow(pil_list[i]) + i += 1 + for _ in thumb_i_list: + heif_from_pillow[len(heif_from_pillow) - 1].add_thumbnails(max(pil_list[i].size)) + i += 1 + compare_heif_files_fields(heif_file, heif_from_pillow, thumb_max_differ=3)