From 4b422db2439d1858cc71f38e706775daa419b692 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Tue, 24 Oct 2023 23:55:19 -0500 Subject: [PATCH 1/6] Add keep_rgb option to prevent RGB -> YCbCr conversion during JPEG write libjpeg automatically converts RGB to YCbCr by default. Add a keep_rgb option to disable libjpeg's automatic conversion of RGB images during write. --- Tests/test_file_jpeg.py | 14 ++++++++++++++ docs/handbook/image-file-formats.rst | 7 +++++++ src/PIL/JpegImagePlugin.py | 1 + src/encode.c | 5 ++++- src/libImaging/Jpeg.h | 3 +++ src/libImaging/JpegEncode.c | 14 ++++++++++++++ 6 files changed, 43 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index ef070b6c5..3b11c1123 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -141,6 +141,16 @@ class TestFileJpeg: ) assert k > 0.9 + def test_rgb(self): + def getchannels(im): + return tuple(v[0] for v in im.layer) + + im = self.roundtrip(hopper()) + assert getchannels(im) == (1, 2, 3) + im = self.roundtrip(hopper(), keep_rgb=True) + assert getchannels(im) == (ord("R"), ord("G"), ord("B")) + assert_image_similar(hopper(), im, 12) + @pytest.mark.parametrize( "test_image_path", [TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"], @@ -445,6 +455,10 @@ class TestFileJpeg: with pytest.raises(TypeError): self.roundtrip(hopper(), subsampling="1:1:1") + # RGB colorspace, no subsampling by default + im = self.roundtrip(hopper(), subsampling=3, keep_rgb=True) + assert getsampling(im) == (1, 1, 1, 1, 1, 1) + def test_exif(self): with Image.open("Tests/images/pil_sample_rgb.jpg") as im: info = im._getexif() diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 23da312a6..46998e03a 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -486,6 +486,13 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: **exif** If present, the image will be stored with the provided raw EXIF data. +**keep_rgb** + By default, libjpeg converts images with an RGB color space to YCbCr. + If this option is present and true, those images will be stored as RGB + instead. + + .. versionadded:: 10.2.0 + **subsampling** If present, sets the subsampling for the encoder. diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 5add65f45..00548e8b7 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -781,6 +781,7 @@ def _save(im, fp, filename): progressive, info.get("smooth", 0), optimize, + info.get("keep_rgb", False), info.get("streamtype", 0), dpi[0], dpi[1], diff --git a/src/encode.c b/src/encode.c index 4664ad0f3..c7dd51015 100644 --- a/src/encode.c +++ b/src/encode.c @@ -1042,6 +1042,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { Py_ssize_t progressive = 0; Py_ssize_t smooth = 0; Py_ssize_t optimize = 0; + int keep_rgb = 0; Py_ssize_t streamtype = 0; /* 0=interchange, 1=tables only, 2=image only */ Py_ssize_t xdpi = 0, ydpi = 0; Py_ssize_t subsampling = -1; /* -1=default, 0=none, 1=medium, 2=high */ @@ -1059,13 +1060,14 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "ss|nnnnnnnnnnOz#y#y#", + "ss|nnnnpnnnnnnOz#y#y#", &mode, &rawmode, &quality, &progressive, &smooth, &optimize, + &keep_rgb, &streamtype, &xdpi, &ydpi, @@ -1150,6 +1152,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { strncpy(((JPEGENCODERSTATE *)encoder->state.context)->rawmode, rawmode, 8); + ((JPEGENCODERSTATE *)encoder->state.context)->keep_rgb = keep_rgb; ((JPEGENCODERSTATE *)encoder->state.context)->quality = quality; ((JPEGENCODERSTATE *)encoder->state.context)->qtables = qarrays; ((JPEGENCODERSTATE *)encoder->state.context)->qtablesLen = qtablesLen; diff --git a/src/libImaging/Jpeg.h b/src/libImaging/Jpeg.h index 5cc74e69b..98eaac28d 100644 --- a/src/libImaging/Jpeg.h +++ b/src/libImaging/Jpeg.h @@ -74,6 +74,9 @@ typedef struct { /* Optimize Huffman tables (slow) */ int optimize; + /* Disable automatic conversion of RGB images to YCbCr if nonzero */ + int keep_rgb; + /* Stream type (0=full, 1=tables only, 2=image only) */ int streamtype; diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 9da830b18..2946cc530 100644 --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -137,6 +137,20 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { /* Compressor configuration */ jpeg_set_defaults(&context->cinfo); + /* Prevent RGB -> YCbCr conversion */ + if (context->keep_rgb) { + switch (context->cinfo.in_color_space) { + case JCS_RGB: +#ifdef JCS_EXTENSIONS + case JCS_EXT_RGBX: +#endif + jpeg_set_colorspace(&context->cinfo, JCS_RGB); + break; + default: + break; + } + } + /* Use custom quantization tables */ if (context->qtables) { int i; From f90827dfc881c1dbbebe27aed78abc279a09f0be Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 27 Nov 2023 14:35:23 +1100 Subject: [PATCH 2/6] Rearranged subsampling assertions --- Tests/test_file_jpeg.py | 43 +++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 3b11c1123..e270820bd 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -145,11 +145,13 @@ class TestFileJpeg: def getchannels(im): return tuple(v[0] for v in im.layer) - im = self.roundtrip(hopper()) - assert getchannels(im) == (1, 2, 3) - im = self.roundtrip(hopper(), keep_rgb=True) - assert getchannels(im) == (ord("R"), ord("G"), ord("B")) - assert_image_similar(hopper(), im, 12) + im = hopper() + im_ycbcr = self.roundtrip(im) + assert getchannels(im_ycbcr) == (1, 2, 3) + + im_rgb = self.roundtrip(im, keep_rgb=True) + assert getchannels(im_rgb) == (ord("R"), ord("G"), ord("B")) + assert_image_similar(im, im_rgb, 12) @pytest.mark.parametrize( "test_image_path", @@ -434,31 +436,26 @@ class TestFileJpeg: # experimental API im = self.roundtrip(hopper(), subsampling=-1) # default assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=0) # 4:4:4 - assert getsampling(im) == (1, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=1) # 4:2:2 - assert getsampling(im) == (2, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=2) # 4:2:0 - assert getsampling(im) == (2, 2, 1, 1, 1, 1) + for subsampling in (0, "4:4:4"): + im = self.roundtrip(hopper(), subsampling=subsampling) + assert getsampling(im) == (1, 1, 1, 1, 1, 1) + for subsampling in (1, "4:2:2"): + im = self.roundtrip(hopper(), subsampling=subsampling) + assert getsampling(im) == (2, 1, 1, 1, 1, 1) + for subsampling in (2, "4:2:0", "4:1:1"): + im = self.roundtrip(hopper(), subsampling=subsampling) + assert getsampling(im) == (2, 2, 1, 1, 1, 1) + im = self.roundtrip(hopper(), subsampling=3) # default (undefined) assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:4:4") - assert getsampling(im) == (1, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:2:2") - assert getsampling(im) == (2, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:2:0") - assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:1:1") - assert getsampling(im) == (2, 2, 1, 1, 1, 1) - - with pytest.raises(TypeError): - self.roundtrip(hopper(), subsampling="1:1:1") - # RGB colorspace, no subsampling by default im = self.roundtrip(hopper(), subsampling=3, keep_rgb=True) assert getsampling(im) == (1, 1, 1, 1, 1, 1) + with pytest.raises(TypeError): + self.roundtrip(hopper(), subsampling="1:1:1") + def test_exif(self): with Image.open("Tests/images/pil_sample_rgb.jpg") as im: info = im._getexif() From 14146732be77093e57622cbc367311219cc64c29 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 4 Dec 2023 06:12:29 -0600 Subject: [PATCH 3/6] Clarify JPEG tests for default/invalid subsampling -1 is the default; 3 is invalid and should behave the same as the default. --- Tests/test_file_jpeg.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index e270820bd..748c07119 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -434,8 +434,9 @@ class TestFileJpeg: return layer[0][1:3] + layer[1][1:3] + layer[2][1:3] # experimental API - im = self.roundtrip(hopper(), subsampling=-1) # default - assert getsampling(im) == (2, 2, 1, 1, 1, 1) + for subsampling in (-1, 3): # (default, invalid) + im = self.roundtrip(hopper(), subsampling=subsampling) + assert getsampling(im) == (2, 2, 1, 1, 1, 1) for subsampling in (0, "4:4:4"): im = self.roundtrip(hopper(), subsampling=subsampling) assert getsampling(im) == (1, 1, 1, 1, 1, 1) @@ -446,11 +447,8 @@ class TestFileJpeg: im = self.roundtrip(hopper(), subsampling=subsampling) assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=3) # default (undefined) - assert getsampling(im) == (2, 2, 1, 1, 1, 1) - # RGB colorspace, no subsampling by default - im = self.roundtrip(hopper(), subsampling=3, keep_rgb=True) + im = self.roundtrip(hopper(), keep_rgb=True) assert getsampling(im) == (1, 1, 1, 1, 1, 1) with pytest.raises(TypeError): From a5fab5fc0b559136120d9d6bea73896dacd0e3d7 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Mon, 4 Dec 2023 07:31:24 -0600 Subject: [PATCH 4/6] Fail if chroma subsampling selected when writing RGB JPEG The user presumably doesn't intend to subsample the green and blue channels. --- Tests/test_file_jpeg.py | 12 +++++++++--- docs/handbook/image-file-formats.rst | 3 +++ src/libImaging/JpegEncode.c | 10 ++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 748c07119..75851e477 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -447,9 +447,15 @@ class TestFileJpeg: im = self.roundtrip(hopper(), subsampling=subsampling) assert getsampling(im) == (2, 2, 1, 1, 1, 1) - # RGB colorspace, no subsampling by default - im = self.roundtrip(hopper(), keep_rgb=True) - assert getsampling(im) == (1, 1, 1, 1, 1, 1) + # RGB colorspace + for subsampling in (-1, 0, "4:4:4"): + # "4:4:4" doesn't really make sense for RGB, but the conversion + # to an integer happens at a higher level + im = self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling) + assert getsampling(im) == (1, 1, 1, 1, 1, 1) + for subsampling in (1, "4:2:2", 2, "4:2:0", 3): + with pytest.raises(OSError): + self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling) with pytest.raises(TypeError): self.roundtrip(hopper(), subsampling="1:1:1") diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 46998e03a..773d517ef 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -491,6 +491,9 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: If this option is present and true, those images will be stored as RGB instead. + When this option is enabled, attempting to chroma-subsample RGB images + with the ``subsampling`` option will raise an :py:exc:`OSError`. + .. versionadded:: 10.2.0 **subsampling** diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 2946cc530..00f3d5f74 100644 --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -144,6 +144,16 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { #ifdef JCS_EXTENSIONS case JCS_EXT_RGBX: #endif + switch (context->subsampling) { + case -1: /* Default */ + case 0: /* No subsampling */ + break; + default: + /* Would subsample the green and blue + channels, which doesn't make sense */ + state->errcode = IMAGING_CODEC_CONFIG; + return -1; + } jpeg_set_colorspace(&context->cinfo, JCS_RGB); break; default: From e2018a6697d5e1cbee4eb29da887f8fc9b0b60f4 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 13 Dec 2023 18:27:55 -0600 Subject: [PATCH 5/6] Add release note for JPEG keep_rgb option Co-authored-by: Andrew Murray --- docs/releasenotes/10.2.0.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/releasenotes/10.2.0.rst b/docs/releasenotes/10.2.0.rst index 9883f10ba..284de7f4f 100644 --- a/docs/releasenotes/10.2.0.rst +++ b/docs/releasenotes/10.2.0.rst @@ -46,6 +46,14 @@ Added DdsImagePlugin enums :py:class:`~PIL.DdsImagePlugin.DXGI_FORMAT` and :py:class:`~PIL.DdsImagePlugin.D3DFMT` enums have been added to :py:class:`PIL.DdsImagePlugin`. +JPEG RGB color space +^^^^^^^^^^^^^^^^^^^^ + +When saving JPEG files, ``keep_rgb`` can now be set to ``True``. This will store RGB +images in the RGB color space instead of being converted to YCbCr automatically by +libjpeg. When this option is enabled, attempting to chroma-subsample RGB images with +the ``subsampling`` option will raise an :py:exc:`OSError`. + JPEG restart marker interval ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 372083c59f06c37564be040a1d4b8cd1dc5bd0ac Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Thu, 28 Dec 2023 13:00:17 -0600 Subject: [PATCH 6/6] Check similarity of round-tripped YCbCr JPEG, for symmetry with RGB --- Tests/test_file_jpeg.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 75851e477..5513b1403 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -148,6 +148,7 @@ class TestFileJpeg: im = hopper() im_ycbcr = self.roundtrip(im) assert getchannels(im_ycbcr) == (1, 2, 3) + assert_image_similar(im, im_ycbcr, 17) im_rgb = self.roundtrip(im, keep_rgb=True) assert getchannels(im_rgb) == (ord("R"), ord("G"), ord("B"))