Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add keep_rgb option when saving JPEG to prevent conversion of RGB colorspace #7553

Merged
merged 6 commits into from Jan 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
54 changes: 35 additions & 19 deletions Tests/test_file_jpeg.py
Expand Up @@ -141,6 +141,19 @@ def test_cmyk(self):
)
assert k > 0.9

def test_rgb(self):
def getchannels(im):
return tuple(v[0] for v in im.layer)

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"))
assert_image_similar(im, im_rgb, 12)
bgilbert marked this conversation as resolved.
Show resolved Hide resolved

@pytest.mark.parametrize(
"test_image_path",
[TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"],
Expand Down Expand Up @@ -422,25 +435,28 @@ def getsampling(im):
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)
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)
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)
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)
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)

# 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")
Expand Down
10 changes: 10 additions & 0 deletions docs/handbook/image-file-formats.rst
Expand Up @@ -486,6 +486,16 @@ 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.

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**
If present, sets the subsampling for the encoder.

Expand Down
8 changes: 8 additions & 0 deletions docs/releasenotes/10.2.0.rst
Expand Up @@ -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
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
1 change: 1 addition & 0 deletions src/PIL/JpegImagePlugin.py
Expand Up @@ -781,6 +781,7 @@ def validate_qtables(qtables):
progressive,
info.get("smooth", 0),
optimize,
info.get("keep_rgb", False),
info.get("streamtype", 0),
dpi[0],
dpi[1],
Expand Down
5 changes: 4 additions & 1 deletion src/encode.c
Expand Up @@ -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 */
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/libImaging/Jpeg.h
Expand Up @@ -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;

Expand Down
24 changes: 24 additions & 0 deletions src/libImaging/JpegEncode.c
Expand Up @@ -137,6 +137,30 @@ 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
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:
break;
}
}

/* Use custom quantization tables */
if (context->qtables) {
int i;
Expand Down