Skip to content

Commit

Permalink
Added support for monochrome images decoding
Browse files Browse the repository at this point in the history
Signed-off-by: Alexander Piskun <bigcat88@icloud.com>
  • Loading branch information
bigcat88 committed Feb 27, 2024
1 parent 9f4acd0 commit e1db3e9
Show file tree
Hide file tree
Showing 31 changed files with 138 additions and 39 deletions.
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
All notable changes to this project will be documented in this file.

## [0.16.0 - 2024-02-2x]
## [0.16.0 - 2024-02-27]

This release contains breaking change for monochrome images.

### Added

- Monochrome images **without alpha** channel, will be opened in `L` or `I;16` mode instead of `RGB`. #215

### Changed

- `convert_hdr_to_8bit` value now ignores `monochrome` images. #215
- `subsampling` parameter for encoding has higher priority then `chroma`. #213
- the minimum required `libehif` version is `1.17.0`. #214

Expand Down
86 changes: 65 additions & 21 deletions pillow_heif/_pillow_heif.c
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,11 @@ typedef struct {
int height; // size[1];
int bits; // one of: 8, 10, 12.
int alpha; // one of: 0, 1.
char mode[8]; // one of: RGB, RGBA, RGBa, BGR, BGRA, BGRa + Optional[;10/12/16]
char mode[8]; // one of: L, RGB, RGBA, RGBa, BGR, BGRA, BGRa + Optional[;10/12/16]
int n_channels; // 1, 2, 3, 4.
int primary; // one of: 0, 1.
enum heif_colorspace colorspace;
enum heif_chroma chroma;
int hdr_to_8bit; // private. decode option.
int bgr_mode; // private. decode option.
int remove_stride; // private. decode option.
Expand Down Expand Up @@ -767,6 +769,8 @@ PyObject* _CtxDepthImage(struct heif_image_handle* main_handle, heif_item_id dep
}
ctx_image->hdr_to_8bit = 0;
ctx_image->bgr_mode = 0;
ctx_image->colorspace = heif_colorspace_monochrome;
ctx_image->chroma = heif_chroma_monochrome;
ctx_image->handle = depth_handle;
ctx_image->heif_image = NULL;
ctx_image->data = NULL;
Expand Down Expand Up @@ -795,7 +799,9 @@ static void _CtxImage_destructor(CtxImageObject* self) {
PyObject* _CtxImage(struct heif_image_handle* handle, int hdr_to_8bit,
int bgr_mode, int remove_stride, int hdr_to_16bit,
int reload_size, int primary, PyObject* file_bytes,
const char *decoder_id) {
const char *decoder_id,
enum heif_colorspace colorspace, enum heif_chroma chroma
) {
CtxImageObject *ctx_image = PyObject_New(CtxImageObject, &CtxImage_Type);
if (!ctx_image) {
heif_image_handle_release(handle);
Expand All @@ -805,23 +811,41 @@ PyObject* _CtxImage(struct heif_image_handle* handle, int hdr_to_8bit,
ctx_image->image_type = PhHeifImage;
ctx_image->width = heif_image_handle_get_width(handle);
ctx_image->height = heif_image_handle_get_height(handle);
strcpy(ctx_image->mode, bgr_mode ? "BGR" : "RGB");
ctx_image->alpha = heif_image_handle_has_alpha_channel(handle);
ctx_image->n_channels = 3;
if (ctx_image->alpha) {
strcat(ctx_image->mode, heif_image_handle_is_premultiplied_alpha(handle) ? "a" : "A");
ctx_image->n_channels = 4;
}
ctx_image->bits = heif_image_handle_get_luma_bits_per_pixel(handle);
if ((ctx_image->bits > 8) && (!hdr_to_8bit)) {
if (hdr_to_16bit) {
strcat(ctx_image->mode, ";16");
if ((chroma == heif_chroma_monochrome) && (colorspace == heif_colorspace_monochrome) && (!ctx_image->alpha)) {
strcpy(ctx_image->mode, "L");
if (ctx_image->bits > 8) {
if (hdr_to_16bit) {
strcpy(ctx_image->mode, "I;16");
}
else if (ctx_image->bits == 10) {
strcpy(ctx_image->mode, "I;10");
}
else {
strcpy(ctx_image->mode, "I;12");
}
}
else if (ctx_image->bits == 10) {
strcat(ctx_image->mode, ";10");
ctx_image->n_channels = 1;
bgr_mode = 0;
hdr_to_8bit = 0;
} else {
strcpy(ctx_image->mode, bgr_mode ? "BGR" : "RGB");
ctx_image->n_channels = 3;
if (ctx_image->alpha) {
strcat(ctx_image->mode, heif_image_handle_is_premultiplied_alpha(handle) ? "a" : "A");
ctx_image->n_channels += 1;
}
else {
strcat(ctx_image->mode, ";12");
if ((ctx_image->bits > 8) && (!hdr_to_8bit)) {
if (hdr_to_16bit) {
strcat(ctx_image->mode, ";16");
}
else if (ctx_image->bits == 10) {
strcat(ctx_image->mode, ";10");
}
else {
strcat(ctx_image->mode, ";12");
}
}
}
ctx_image->hdr_to_8bit = hdr_to_8bit;
Expand All @@ -833,6 +857,8 @@ PyObject* _CtxImage(struct heif_image_handle* handle, int hdr_to_8bit,
ctx_image->hdr_to_16bit = hdr_to_16bit;
ctx_image->reload_size = reload_size;
ctx_image->primary = primary;
ctx_image->colorspace = colorspace;
ctx_image->chroma = chroma;
ctx_image->file_bytes = file_bytes;
ctx_image->stride = get_stride(ctx_image);
strcpy(ctx_image->decoder_id, decoder_id);
Expand All @@ -852,6 +878,14 @@ static PyObject* _CtxImage_bit_depth(CtxImageObject* self, void* closure) {
return Py_BuildValue("i", self->bits);
}

static PyObject* _CtxImage_colorspace(CtxImageObject* self, void* closure) {
return Py_BuildValue("i", self->colorspace);
}

static PyObject* _CtxImage_chroma(CtxImageObject* self, void* closure) {
return Py_BuildValue("i", self->chroma);
}

static PyObject* _CtxImage_color_profile(CtxImageObject* self, void* closure) {
enum heif_color_profile_type profile_type = heif_image_handle_get_color_profile_type(self->handle);
if (profile_type == heif_color_profile_type_not_present)
Expand Down Expand Up @@ -1155,6 +1189,8 @@ static struct PyGetSetDef _CtxImage_getseters[] = {
{"size_mode", (getter)_CtxImage_size_mode, NULL, NULL, NULL},
{"primary", (getter)_CtxImage_primary, NULL, NULL, NULL},
{"bit_depth", (getter)_CtxImage_bit_depth, NULL, NULL, NULL},
{"colorspace", (getter)_CtxImage_colorspace, NULL, NULL, NULL},
{"chroma", (getter)_CtxImage_chroma, NULL, NULL, NULL},
{"color_profile", (getter)_CtxImage_color_profile, NULL, NULL, NULL},
{"metadata", (getter)_CtxImage_metadata, NULL, NULL, NULL},
{"thumbnails", (getter)_CtxImage_thumbnails, NULL, NULL, NULL},
Expand Down Expand Up @@ -1264,6 +1300,8 @@ static PyObject* _load_file(PyObject* self, PyObject* args) {
return NULL;
}

enum heif_colorspace colorspace;
enum heif_chroma chroma;
struct heif_image_handle* handle;
struct heif_error error;
for (int i = 0; i < n_images; i++) {
Expand All @@ -1274,13 +1312,19 @@ static PyObject* _load_file(PyObject* self, PyObject* args) {
}
else
error = heif_context_get_image_handle(heif_ctx, images_ids[i], &handle);
if (error.code == heif_error_Ok)
PyList_SET_ITEM(images_list,
i,
_CtxImage(handle, hdr_to_8bit,
if (error.code == heif_error_Ok) {
error = heif_image_handle_get_preferred_decoding_colorspace(handle, &colorspace, &chroma);
if (error.code == heif_error_Ok) {
PyList_SET_ITEM(images_list,
i,
_CtxImage(handle, hdr_to_8bit,
bgr_mode, remove_stride, hdr_to_16bit, reload_size, primary, heif_bytes,
decoder_id));
else {
decoder_id, colorspace, chroma));
} else {
heif_image_handle_release(handle);
}
}
if (error.code != heif_error_Ok) {
Py_INCREF(Py_None);
PyList_SET_ITEM(images_list, i, Py_None);
}
Expand Down
5 changes: 2 additions & 3 deletions pillow_heif/as_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ def __init__(self, *args, **kwargs):

def _open(self):
try:
# when Pillow starts supporting 16-bit images:
# set `convert_hdr_to_8bit` to False and `convert_hdr_to_8bit` to True
_heif_file = HeifFile(self.fp, convert_hdr_to_8bit=True, remove_stride=False)
# when Pillow starts supporting 16-bit multichannel images change `convert_hdr_to_8bit` to False
_heif_file = HeifFile(self.fp, convert_hdr_to_8bit=True, hdr_to_16bit=True, remove_stride=False)
except (OSError, ValueError, SyntaxError, RuntimeError, EOFError) as exception:
raise SyntaxError(str(exception)) from None
self.custom_mimetype = _heif_file.mimetype
Expand Down
7 changes: 5 additions & 2 deletions pillow_heif/heif.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
_rotate_pil,
_xmp_from_pillow,
get_file_mimetype,
save_colorspace_chroma,
set_orientation,
)

Expand Down Expand Up @@ -120,6 +121,7 @@ def __init__(self, c_image):
self.info = {
"metadata": _metadata,
}
save_colorspace_chroma(c_image, self.info)

def __repr__(self):
_bytes = f"{len(self.data)} bytes" if self._data or isinstance(self._c_image, MimCImage) else "no"
Expand Down Expand Up @@ -158,6 +160,7 @@ def __init__(self, c_image):
"thumbnails": _thumbnails,
"depth_images": _depth_images,
}
save_colorspace_chroma(c_image, self.info)
_color_profile: Dict[str, Any] = c_image.color_profile
if _color_profile:
if _color_profile["type"] in ("rICC", "prof"):
Expand Down Expand Up @@ -493,7 +496,7 @@ def open_heif(fp, convert_hdr_to_8bit=True, bgr_mode=False, **kwargs) -> HeifFil
:param fp: See parameter ``fp`` in :func:`is_supported`
:param convert_hdr_to_8bit: Boolean indicating should 10 bit or 12 bit images
be converted to 8-bit images during decoding. Otherwise, they will open in 16-bit mode.
``Does not affect "depth images".``
``Does not affect "monochrome" or "depth images".``
:param bgr_mode: Boolean indicating should be `RGB(A)` images be opened in `BGR(A)` mode.
:param kwargs: **hdr_to_16bit** a boolean value indicating that 10/12-bit image data
should be converted to 16-bit mode during decoding. `Has lower priority than convert_hdr_to_8bit`!
Expand All @@ -518,7 +521,7 @@ def read_heif(fp, convert_hdr_to_8bit=True, bgr_mode=False, **kwargs) -> HeifFil
:param fp: See parameter ``fp`` in :func:`is_supported`
:param convert_hdr_to_8bit: Boolean indicating should 10 bit or 12 bit images
be converted to 8-bit images during decoding. Otherwise, they will open in 16-bit mode.
``Does not affect "depth images".``
``Does not affect "monochrome" or "depth images".``
:param bgr_mode: Boolean indicating should be `RGB(A)` images be opened in `BGR(A)` mode.
:param kwargs: **hdr_to_16bit** a boolean value indicating that 10/12-bit image data
should be converted to 16-bit mode during decoding. `Has lower priority than convert_hdr_to_8bit`!
Expand Down
16 changes: 16 additions & 0 deletions pillow_heif/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@
"4:2:0": 420,
}

LIBHEIF_CHROMA_MAP = {
1: 420,
2: 422,
3: 444,
}


def save_colorspace_chroma(c_image, info: dict) -> None:
"""Converts `chroma` value from `c_image` to useful values and stores them in ``info`` dict."""
# Saving of `colorspace` was removed, as currently is not clear where to use that value.
chroma = LIBHEIF_CHROMA_MAP.get(c_image.chroma, None)
if chroma is not None:
info["chroma"] = chroma


def set_orientation(info: dict) -> Optional[int]:
"""Reset orientation in ``EXIF`` to ``1`` if any orientation present.
Expand Down Expand Up @@ -440,6 +454,8 @@ def __init__(self, mode: str, size: tuple, data: bytes, **kwargs):
self.thumbnails: List[int] = []
self.depth_image_list: List = []
self.primary = False
self.chroma = HeifChroma.UNDEFINED.value
self.colorspace = HeifColorspace.UNDEFINED.value

@property
def size_mode(self):
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ profile = "black"
[tool.pylint]
master.py-version = "3.8"
master.extension-pkg-allow-list = ["_pillow_heif"]
design.max-attributes = 9
design.max-attributes = 12
design.max-branches = 16
design.max-locals = 18
design.max-returns = 8
Expand Down
4 changes: 2 additions & 2 deletions tests/basic_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,10 @@ def test_is_supported_fails(img):

def test_heif_str():
str_img_nl_1 = "<HeifImage 64x64 RGB with no image data and 2 thumbnails>"
str_img_nl_2 = "<HeifImage 64x64 RGB with no image data and 1 thumbnails>"
str_img_nl_2 = "<HeifImage 64x64 L with no image data and 1 thumbnails>"
str_img_nl_3 = "<HeifImage 96x64 RGB with no image data and 0 thumbnails>"
str_img_l_1 = "<HeifImage 64x64 RGB with 12288 bytes image data and 2 thumbnails>"
str_img_l_2 = "<HeifImage 64x64 RGB with 12288 bytes image data and 1 thumbnails>"
str_img_l_2 = "<HeifImage 64x64 L with 4096 bytes image data and 1 thumbnails>"
heif_file = pillow_heif.open_heif(Path("images/heif/zPug_3.heic"))
assert str(heif_file) == f"<HeifFile with 3 images: ['{str_img_nl_1}', '{str_img_nl_2}', '{str_img_nl_3}']>"
assert str(heif_file[0]) == str_img_nl_1
Expand Down
Binary file modified tests/images/heif/L_10__128x128.avif
Binary file not shown.
Binary file modified tests/images/heif/L_10__128x128.heif
Binary file not shown.
Binary file modified tests/images/heif/L_10__29x100.avif
Binary file not shown.
Binary file modified tests/images/heif/L_10__29x100.heif
Binary file not shown.
Binary file modified tests/images/heif/L_12__128x128.avif
Binary file not shown.
Binary file modified tests/images/heif/L_12__128x128.heif
Binary file not shown.
Binary file modified tests/images/heif/L_12__29x100.avif
Binary file not shown.
Binary file modified tests/images/heif/L_12__29x100.heif
Binary file not shown.
Binary file modified tests/images/heif/L_8__128x128.avif
Binary file not shown.
Binary file modified tests/images/heif/L_8__128x128.heif
Binary file not shown.
Binary file modified tests/images/heif/L_8__29x100.avif
Binary file not shown.
Binary file modified tests/images/heif/L_8__29x100.heif
Binary file not shown.
File renamed without changes.
Binary file added tests/images/heif_other/RGB_8_chroma444.heif
Binary file not shown.
2 changes: 1 addition & 1 deletion tests/leaks_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def test_color_profile_leaks(im, cp_type):
@pytest.mark.skipif(machine().find("x86_64") == -1, reason="run only on x86_64")
def test_metadata_leaks():
mem_limit = None
heif_file = pillow_heif.open_heif(Path("images/heif_other/exif_xmp_iptc.heic"))
heif_file = pillow_heif.open_heif(Path("images/heif_other/L_exif_xmp_iptc.heic"))
for i in range(1000):
_metadata = heif_file[0]._c_image.metadata # noqa
_metadata = None # noqa
Expand Down
31 changes: 25 additions & 6 deletions tests/read_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,33 @@ def test_heif_file_to_pillow():
helpers.assert_image_equal(heif_file.to_pillow(), heif_file[1].to_pillow())


def test_heif_zpug_image():
heif_file = pillow_heif.open_heif(Path("images/heif/zPug_3.heic"))
assert heif_file[0].mode == "RGB"
assert heif_file[0].stride >= heif_file[0].size[0] * 3
assert heif_file[1].mode == "L"
assert heif_file[1].stride >= heif_file[1].size[0] * 1
assert heif_file[2].mode == "RGB"
assert heif_file[2].stride >= heif_file[2].size[0] * 3


@pytest.mark.parametrize("image_path", dataset.FULL_DATASET)
def test_heif_read_images(image_path):
def test_read_image(convert_hdr_to_8bit: bool) -> bool:
heif_file = pillow_heif.open_heif(image_path, convert_hdr_to_8bit=convert_hdr_to_8bit)
for image in heif_file:
assert min(image.size) > 0
assumed_mode = "RGBA" if image.has_alpha else "RGB"
minimal_stride = image.size[0] * 4 if image.has_alpha else image.size[0] * 3
if image.info["bit_depth"] > 8 and not convert_hdr_to_8bit:
monochrome = str(image_path).find("L_") != -1
if monochrome:
assumed_mode = "I" if image.info["bit_depth"] > 8 else "L"
minimal_stride = image.size[0] * 1
elif not image.has_alpha:
assumed_mode = "RGB"
minimal_stride = image.size[0] * 3
else:
assumed_mode = "RGBA"
minimal_stride = image.size[0] * 4
if image.info["bit_depth"] > 8 and (monochrome or not convert_hdr_to_8bit):
assumed_mode += ";16"
minimal_stride *= 2
assert image.mode == assumed_mode
Expand All @@ -256,9 +274,10 @@ def test_read_image(convert_hdr_to_8bit: bool) -> bool:
assert len(image.data) == image.stride * image.size[1]
return heif_file.info["bit_depth"] > 8

one_more = test_read_image(False)
if one_more:
test_read_image(True)
if str(image_path).find("zPug_3.heic") == -1:
one_more = test_read_image(False)
if one_more:
test_read_image(True)


@pytest.mark.parametrize("image_path", dataset.FULL_DATASET)
Expand Down
15 changes: 13 additions & 2 deletions tests/write_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ def test_L_color_mode(save_format): # noqa
out_heif = BytesIO()
im.save(out_heif, format=save_format, quality=-1)
im_heif = Image.open(out_heif)
assert im_heif.mode == "RGB"
assert im_heif.mode == "L"
helpers.compare_hashes([im, im_heif], hash_size=32)


Expand All @@ -261,7 +261,7 @@ def test_1_color_mode():
out_heif = BytesIO()
im.save(out_heif, format="HEIF", quality=-1)
im_heif = Image.open(out_heif)
assert im_heif.mode == "RGB"
assert im_heif.mode == "L"
helpers.compare_hashes([im, im_heif], hash_size=16)


Expand Down Expand Up @@ -583,6 +583,17 @@ def test_lossless_encoding_rgba(save_format):
helpers.assert_image_equal(im_rgb, Image.open(buf))


def test_input_chroma_value():
im = Image.open(Path("images/heif_other/RGB_8_chroma444.heif"))
assert im.info["chroma"] == 444
im = pillow_heif.open_heif(Path("images/heif_other/RGB_8_chroma444.heif"))
assert im.info["chroma"] == 444
im = Image.open(Path("images/heif_other/pug.heic"))
assert im.info["chroma"] == 420
im = pillow_heif.open_heif(Path("images/heif_other/pug.heic"))
assert im.info["chroma"] == 420


def test_invalid_encoder():
im_rgb = helpers.gradient_rgba()
buf = BytesIO()
Expand Down

0 comments on commit e1db3e9

Please sign in to comment.