Skip to content

Commit

Permalink
Way to easy specify NCLX output color profile (#171)
Browse files Browse the repository at this point in the history
* reworked NCLX color profile

Signed-off-by: Alexander Piskun <[email protected]>
  • Loading branch information
bigcat88 authored Nov 14, 2023
1 parent 365d230 commit 9218ac1
Show file tree
Hide file tree
Showing 11 changed files with 171 additions and 38 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test-src-build-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
- arch: "arm/v7"
docker_file: "Debian_12"
- arch: "amd64"
docker_file: "Fedora_38"
docker_file: "Fedora_39"

steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file.
### Changed

- Libheif updated from `1.16.2` to `1.17.3` version. #166
- `NCLX` color profile - was reworked, updated docs, see PR for more info. #171
- Minimum supported Pillow version raised to `9.2.0`.
- Linux: When building from source, `libheif` and other libraries are no longer try built automatically. #158
- Pi-Heif: As last libheif version `1.17.3` requires minimum `cmake>=3.16.3` dropped Debian `10 armv7` wheels. #160
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM fedora:38 as base
FROM fedora:39 as base

RUN \
dnf install -y https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm && \
Expand Down
62 changes: 62 additions & 0 deletions docs/saving-images.rst
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,66 @@ Method ``save`` supports ``primary_index`` parameter, that accepts ``index of im

Specifying ``primary_index`` during ``save`` has highest priority.

NCLX color profile
""""""""""""""""""

By default, since version **0.14.0**, if the image already had an NCLX color profile, it will be saved during encoding.

.. note:: If you need old behaviour and for some reason do not need `NCLX` profile be saved you can set global option ``SAVE_NCLX_PROFILE`` to ``False``.

To change it, you can specify your values for NCLX color conversion for ``save`` operation in two ways.

Set `output` NCLX profile:

.. note:: Avalaible only from **0.14.0** version.

.. code-block:: python
buf = BytesIO()
im.save(buf, format="HEIF", matrix_coefficients=0, color_primaries=1)
In this case the default output NCLX profile will be created, and values you provide in such way,
will replace the values from default output profile.

Edit NCLX profile in `image.info`:

.. code-block:: python
buf = BytesIO()
im.info["nclx_profile"]["matrix_coefficients"] = 0 # code assumes that image has already "nclx_profile"
im.info["nclx_profile"]["color_primaries"] = 1
im.save(buf, format="HEIF")
Under the hood it is much complex, as second way will change the **input** NCLX profile.

The preferable way is to specify new NCLX values during ``save``.

Here is additional info, from the **libheif repo** with relevant information:

https://github.com/strukturag/libheif/discussions/931
https://github.com/strukturag/libheif/issues/995

Lossless encoding
"""""""""""""""""

.. note:: Parameter ``matrix_coefficients`` avalaible only from **0.14.0** version.

Although the HEIF format is not intended for lossless encoding, it is possible with some encoders that support it.

You need to specify ``matrix_coefficients=0``
(which will tell **libheif** to perform the conversion in the RGB color space) and chrome subsampling equal to "4:4:4".

.. code-block:: python
im_rgb = Image.merge(
"RGB",
[
Image.linear_gradient(mode="L"),
Image.linear_gradient(mode="L").transpose(Image.ROTATE_90),
Image.linear_gradient(mode="L").transpose(Image.ROTATE_180),
],
)
buf = BytesIO()
im_rgb.save(buf, format="HEIF", quality=-1, chroma=444, matrix_coefficients=0)
That's all.
46 changes: 35 additions & 11 deletions pillow_heif/_pillow_heif.c
Original file line number Diff line number Diff line change
Expand Up @@ -498,39 +498,63 @@ static PyObject* _CtxWriteImage_set_icc_profile(CtxWriteImageObject* self, PyObj

static PyObject* _CtxWriteImage_set_nclx_profile(CtxWriteImageObject* self, PyObject* args) {
/* color_primaries: int, transfer_characteristics: int, matrix_coefficients: int, full_range_flag: int */
struct heif_error error;
int color_primaries, transfer_characteristics, matrix_coefficients, full_range_flag;

if (!PyArg_ParseTuple(args, "iiii",
&color_primaries, &transfer_characteristics, &matrix_coefficients, &full_range_flag))
return NULL;

self->output_nclx_color_profile = heif_nclx_color_profile_alloc();
self->output_nclx_color_profile->color_primaries = color_primaries;
self->output_nclx_color_profile->transfer_characteristics = transfer_characteristics;
self->output_nclx_color_profile->matrix_coefficients = matrix_coefficients;
self->output_nclx_color_profile->full_range_flag = full_range_flag;
struct heif_color_profile_nclx* nclx_color_profile = heif_nclx_color_profile_alloc();
nclx_color_profile->color_primaries = color_primaries;
nclx_color_profile->transfer_characteristics = transfer_characteristics;
nclx_color_profile->matrix_coefficients = matrix_coefficients;
nclx_color_profile->full_range_flag = full_range_flag;
error = heif_image_set_nclx_color_profile(self->image, nclx_color_profile);
heif_nclx_color_profile_free(nclx_color_profile);
if (check_error(error))
return NULL;
RETURN_NONE
}

static PyObject* _CtxWriteImage_encode(CtxWriteImageObject* self, PyObject* args) {
/* ctx: CtxWriteObject, primary: int */
CtxWriteObject* ctx_write;
int primary, save_nclx, image_orientation;
int primary, image_orientation,
save_nclx, color_primaries, transfer_characteristics, matrix_coefficients, full_range_flag;
struct heif_error error;
struct heif_encoding_options* options;

if (!PyArg_ParseTuple(args, "Oiii", (PyObject*)&ctx_write, &primary, &save_nclx, &image_orientation))
if (!PyArg_ParseTuple(args, "Oiiiiiii",
(PyObject*)&ctx_write, &primary,
&save_nclx, &color_primaries, &transfer_characteristics, &matrix_coefficients, &full_range_flag,
&image_orientation
))
return NULL;

Py_BEGIN_ALLOW_THREADS
options = heif_encoding_options_alloc();
options->macOS_compatibility_workaround_no_nclx_profile = !save_nclx;
if (!self->output_nclx_color_profile && save_nclx)
self->output_nclx_color_profile = heif_nclx_color_profile_alloc();
if (self->output_nclx_color_profile)
options->output_nclx_profile = self->output_nclx_color_profile;
if (
(color_primaries != -1) ||
(transfer_characteristics != -1) ||
(matrix_coefficients != -1) ||
(full_range_flag != -1)
) {
options->output_nclx_profile = heif_nclx_color_profile_alloc();
if (color_primaries != -1)
options->output_nclx_profile->color_primaries = color_primaries;
if (transfer_characteristics != -1)
options->output_nclx_profile->transfer_characteristics = transfer_characteristics;
if (matrix_coefficients != -1)
options->output_nclx_profile->matrix_coefficients = matrix_coefficients;
if (full_range_flag != -1)
options->output_nclx_profile->full_range_flag = full_range_flag;
}
options->image_orientation = image_orientation;
error = heif_context_encode_image(ctx_write->ctx, self->image, ctx_write->encoder, options, &self->handle);
if (options->output_nclx_profile)
heif_nclx_color_profile_free(options->output_nclx_profile);
heif_encoding_options_free(options);
Py_END_ALLOW_THREADS
if (check_error(error))
Expand Down
8 changes: 8 additions & 0 deletions pillow_heif/heif.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,14 @@ def save(self, fp, **kwargs) -> None:
``save_nclx_profile`` - boolean, see :py:attr:`~pillow_heif.options.SAVE_NCLX_PROFILE`
``matrix_coefficients`` - int, nclx profile: color conversion matrix coefficients, default=6 (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)
Expand Down
12 changes: 8 additions & 4 deletions pillow_heif/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,14 +368,14 @@ def add_image_ycbcr(self, img: Image.Image, **kwargs) -> None:
self._finish_add_image(im_out, img.size, **kwargs)

def _finish_add_image(self, im_out, size: tuple, **kwargs):
# color profile
# set ICC color profile
__icc_profile = kwargs.get("icc_profile", None)
if __icc_profile is not None:
im_out.set_icc_profile(kwargs.get("icc_profile_type", "prof"), __icc_profile)
elif kwargs.get("nclx_profile", None):
nclx_profile = kwargs["nclx_profile"]
# set NCLX color profile
if kwargs.get("nclx_profile", None):
im_out.set_nclx_profile(*[
nclx_profile[i]
kwargs["nclx_profile"][i]
for i in ("color_primaries", "transfer_characteristics", "matrix_coefficients", "full_range_flag")
])
# encode
Expand All @@ -384,6 +384,10 @@ def _finish_add_image(self, im_out, size: tuple, **kwargs):
self.ctx_write,
kwargs.get("primary", False),
kwargs.get("save_nclx_profile", options.SAVE_NCLX_PROFILE),
kwargs.get("color_primaries", -1),
kwargs.get("transfer_characteristics", -1),
kwargs.get("matrix_coefficients", -1),
kwargs.get("full_range_flag", -1),
image_orientation,
)
# adding metadata
Expand Down
7 changes: 3 additions & 4 deletions pillow_heif/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,13 @@
When use pillow_heif as a plugin you can set it with: `register_*_opener(allow_incorrect_headers=True)`"""


SAVE_NCLX_PROFILE = False
SAVE_NCLX_PROFILE = True
"""Should be ``nclx`` profile saved or not.
Default for all previous versions was NOT TO save `nclx` profile,
Default for all previous versions(pillow_heif<0.14.0) was NOT TO save `nclx` profile,
due to an old bug in Apple software refusing to open images with `nclx` profiles.
Apple has already fixed this and there is no longer a need to not save the default profile.
Currently to be compatible in behaviour with previous versions, still is ``False`` by default.
.. note:: `save_nclx_profile` specified during calling ``save`` has higher priority than this.
When use pillow_heif as a plugin you can unset it with: `register_*_opener(save_nclx_profile=True)`"""
When use pillow_heif as a plugin you can unset it with: `register_*_opener(save_nclx_profile=False)`"""
4 changes: 2 additions & 2 deletions tests/leaks_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ def test_open_to_numpy_mem_leaks():
def test_color_profile_leaks(im, cp_type):
mem_limit = None
heif_file = pillow_heif.open_heif(Path(im), convert_hdr_to_8bit=False)
for i in range(1000):
for i in range(1200):
_nclx = heif_file[0]._c_image.color_profile # noqa
_nclx = None # noqa
gc.collect()
mem = _get_mem_usage()
if i < 100:
if i < 200:
mem_limit = mem + 2
continue
assert mem <= mem_limit, f"memory usage limit exceeded after {i + 1} iterations. Color profile type:{cp_type}"
Expand Down
6 changes: 3 additions & 3 deletions tests/options_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,21 @@ def test_options_change_from_plugin_registering(register_opener):
save_to_12bit=True,
decode_threads=3,
depth_images=False,
save_nclx_profile=True,
save_nclx_profile=False,
)
assert not options.THUMBNAILS
assert options.QUALITY == 69
assert options.SAVE_HDR_TO_12_BIT
assert options.DECODE_THREADS == 3
assert options.DEPTH_IMAGES is False
assert options.SAVE_NCLX_PROFILE is True
assert options.SAVE_NCLX_PROFILE is False
finally:
options.THUMBNAILS = True
options.QUALITY = None
options.SAVE_HDR_TO_12_BIT = False
options.DECODE_THREADS = 4
options.DEPTH_IMAGES = True
options.SAVE_NCLX_PROFILE = False
options.SAVE_NCLX_PROFILE = True


@pytest.mark.skipif(not hevc_enc(), reason="No HEVC encoder.")
Expand Down
59 changes: 47 additions & 12 deletions tests/write_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
if not helpers.hevc_enc() or not helpers.aom():
pytest.skip("No HEIF or AVIF support.", allow_module_level=True)

if parse_version(pillow_heif.libheif_version()) < parse_version("1.17.3"):
pytest.skip("Requires libheif 1.17.3+", allow_module_level=True)

os.chdir(os.path.dirname(os.path.abspath(__file__)))
pillow_heif.register_avif_opener()
pillow_heif.register_heif_opener()
Expand Down Expand Up @@ -144,6 +147,7 @@ def test_hif_file():
heif_file1.save(out_buf, quality=80)
heif_file2 = pillow_heif.open_heif(out_buf)
assert heif_file2.info["bit_depth"] == 8
assert heif_file1.info["nclx_profile"] == heif_file2.info["nclx_profile"]
helpers.compare_heif_files_fields(heif_file1, heif_file2, ignore=["bit_depth"])
helpers.compare_hashes([hif_path, out_buf], hash_size=16)

Expand Down Expand Up @@ -519,27 +523,58 @@ def test_invalid_ispe_stride_pillow(image_path):
def test_nclx_profile_write():
im_rgb = helpers.gradient_rgb()
buf = BytesIO()
# no NCLX profile stored
im_rgb.save(buf, format="HEIF", save_nclx_profile=False)
assert "nclx_profile" not in Image.open(buf).info
# no NCLX profile stored as Image has no one.
im_rgb.save(buf, format="HEIF", save_nclx_profile=True)
assert "nclx_profile" not in Image.open(buf).info
# specify NCLX for the image, color profile should be stored
nclx_profile = {
"color_primaries": 4,
"transfer_characteristics": 4,
"matrix_coefficients": 0,
"full_range_flag": 1,
}
im_rgb.info["nclx_profile"] = nclx_profile
im_rgb.save(buf, format="HEIF", save_nclx_profile=True)
assert "nclx_profile" in Image.open(buf).info
nclx_out = Image.open(buf).info["nclx_profile"]
for k in nclx_profile:
assert nclx_profile[k] == nclx_out[k]
try:
pillow_heif.options.SAVE_NCLX_PROFILE = True
im_rgb.save(buf, format="HEIF", save_nclx_profile=False)
assert "nclx_profile" not in Image.open(buf).info
pillow_heif.options.SAVE_NCLX_PROFILE = False
im_rgb.save(buf, format="HEIF")
assert "nclx_profile" in Image.open(buf).info
im_rgb.info["nclx_profile"] = {
assert "nclx_profile" not in Image.open(buf).info
im_rgb.save(buf, format="HEIF", save_nclx_profile=True)
nclx_out = Image.open(buf).info["nclx_profile"]
for k in nclx_profile:
assert nclx_profile[k] == nclx_out[k]
# here we set the “output” color profile, even if the image has one, it will be overridden.
nclx_profile = {
"color_primaries": 1,
"transfer_characteristics": 1,
"matrix_coefficients": 10,
"full_range_flag": 0,
}
im_rgb.save(buf, format="HEIF")
im_rgb.save(buf, format="HEIF", **nclx_profile, save_nclx_profile=True)
nclx_out = Image.open(buf).info["nclx_profile"]
if parse_version(pillow_heif.libheif_version()) >= parse_version("1.17.0"):
# in libheif 1.17.0 logic of this was corrected: https://github.com/strukturag/libheif/issues/995
for k in im_rgb.info["nclx_profile"]:
assert im_rgb.info["nclx_profile"][k] == nclx_out[k]
for k in nclx_profile:
assert nclx_profile[k] == nclx_out[k]
finally:
pillow_heif.options.SAVE_NCLX_PROFILE = False
pillow_heif.options.SAVE_NCLX_PROFILE = True


@pytest.mark.parametrize("save_format", ("HEIF", "AVIF"))
def test_lossless_encoding_rgb(save_format):
im_rgb = helpers.gradient_rgb()
buf = BytesIO()
im_rgb.save(buf, format=save_format, quality=-1, chroma=444, matrix_coefficients=0)
helpers.assert_image_equal(im_rgb, Image.open(buf))


@pytest.mark.parametrize("save_format", ("HEIF", "AVIF"))
def test_lossless_encoding_rgba(save_format):
im_rgb = helpers.gradient_rgba()
buf = BytesIO()
im_rgb.save(buf, format=save_format, quality=-1, chroma=444, matrix_coefficients=0)
helpers.assert_image_equal(im_rgb, Image.open(buf))

0 comments on commit 9218ac1

Please sign in to comment.