Skip to content

Commit d93a5ad

Browse files
authored
Merge pull request #7553 from bgilbert/jpeg-rgb
Add `keep_rgb` option when saving JPEG to prevent conversion of RGB colorspace
2 parents aed764f + 372083c commit d93a5ad

File tree

7 files changed

+85
-20
lines changed

7 files changed

+85
-20
lines changed

Tests/test_file_jpeg.py

+35-19
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,19 @@ def test_cmyk(self):
142142
)
143143
assert k > 0.9
144144

145+
def test_rgb(self):
146+
def getchannels(im):
147+
return tuple(v[0] for v in im.layer)
148+
149+
im = hopper()
150+
im_ycbcr = self.roundtrip(im)
151+
assert getchannels(im_ycbcr) == (1, 2, 3)
152+
assert_image_similar(im, im_ycbcr, 17)
153+
154+
im_rgb = self.roundtrip(im, keep_rgb=True)
155+
assert getchannels(im_rgb) == (ord("R"), ord("G"), ord("B"))
156+
assert_image_similar(im, im_rgb, 12)
157+
145158
@pytest.mark.parametrize(
146159
"test_image_path",
147160
[TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"],
@@ -423,25 +436,28 @@ def getsampling(im):
423436
return layer[0][1:3] + layer[1][1:3] + layer[2][1:3]
424437

425438
# experimental API
426-
im = self.roundtrip(hopper(), subsampling=-1) # default
427-
assert getsampling(im) == (2, 2, 1, 1, 1, 1)
428-
im = self.roundtrip(hopper(), subsampling=0) # 4:4:4
429-
assert getsampling(im) == (1, 1, 1, 1, 1, 1)
430-
im = self.roundtrip(hopper(), subsampling=1) # 4:2:2
431-
assert getsampling(im) == (2, 1, 1, 1, 1, 1)
432-
im = self.roundtrip(hopper(), subsampling=2) # 4:2:0
433-
assert getsampling(im) == (2, 2, 1, 1, 1, 1)
434-
im = self.roundtrip(hopper(), subsampling=3) # default (undefined)
435-
assert getsampling(im) == (2, 2, 1, 1, 1, 1)
436-
437-
im = self.roundtrip(hopper(), subsampling="4:4:4")
438-
assert getsampling(im) == (1, 1, 1, 1, 1, 1)
439-
im = self.roundtrip(hopper(), subsampling="4:2:2")
440-
assert getsampling(im) == (2, 1, 1, 1, 1, 1)
441-
im = self.roundtrip(hopper(), subsampling="4:2:0")
442-
assert getsampling(im) == (2, 2, 1, 1, 1, 1)
443-
im = self.roundtrip(hopper(), subsampling="4:1:1")
444-
assert getsampling(im) == (2, 2, 1, 1, 1, 1)
439+
for subsampling in (-1, 3): # (default, invalid)
440+
im = self.roundtrip(hopper(), subsampling=subsampling)
441+
assert getsampling(im) == (2, 2, 1, 1, 1, 1)
442+
for subsampling in (0, "4:4:4"):
443+
im = self.roundtrip(hopper(), subsampling=subsampling)
444+
assert getsampling(im) == (1, 1, 1, 1, 1, 1)
445+
for subsampling in (1, "4:2:2"):
446+
im = self.roundtrip(hopper(), subsampling=subsampling)
447+
assert getsampling(im) == (2, 1, 1, 1, 1, 1)
448+
for subsampling in (2, "4:2:0", "4:1:1"):
449+
im = self.roundtrip(hopper(), subsampling=subsampling)
450+
assert getsampling(im) == (2, 2, 1, 1, 1, 1)
451+
452+
# RGB colorspace
453+
for subsampling in (-1, 0, "4:4:4"):
454+
# "4:4:4" doesn't really make sense for RGB, but the conversion
455+
# to an integer happens at a higher level
456+
im = self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling)
457+
assert getsampling(im) == (1, 1, 1, 1, 1, 1)
458+
for subsampling in (1, "4:2:2", 2, "4:2:0", 3):
459+
with pytest.raises(OSError):
460+
self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling)
445461

446462
with pytest.raises(TypeError):
447463
self.roundtrip(hopper(), subsampling="1:1:1")

docs/handbook/image-file-formats.rst

+10
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,16 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options:
487487
**exif**
488488
If present, the image will be stored with the provided raw EXIF data.
489489

490+
**keep_rgb**
491+
By default, libjpeg converts images with an RGB color space to YCbCr.
492+
If this option is present and true, those images will be stored as RGB
493+
instead.
494+
495+
When this option is enabled, attempting to chroma-subsample RGB images
496+
with the ``subsampling`` option will raise an :py:exc:`OSError`.
497+
498+
.. versionadded:: 10.2.0
499+
490500
**subsampling**
491501
If present, sets the subsampling for the encoder.
492502

docs/releasenotes/10.2.0.rst

+8
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ Added DdsImagePlugin enums
4242
:py:class:`~PIL.DdsImagePlugin.DXGI_FORMAT` and :py:class:`~PIL.DdsImagePlugin.D3DFMT`
4343
enums have been added to :py:class:`PIL.DdsImagePlugin`.
4444

45+
JPEG RGB color space
46+
^^^^^^^^^^^^^^^^^^^^
47+
48+
When saving JPEG files, ``keep_rgb`` can now be set to ``True``. This will store RGB
49+
images in the RGB color space instead of being converted to YCbCr automatically by
50+
libjpeg. When this option is enabled, attempting to chroma-subsample RGB images with
51+
the ``subsampling`` option will raise an :py:exc:`OSError`.
52+
4553
JPEG restart marker interval
4654
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
4755

src/PIL/JpegImagePlugin.py

+1
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,7 @@ def validate_qtables(qtables):
785785
progressive,
786786
info.get("smooth", 0),
787787
optimize,
788+
info.get("keep_rgb", False),
788789
info.get("streamtype", 0),
789790
dpi[0],
790791
dpi[1],

src/encode.c

+4-1
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
10421042
Py_ssize_t progressive = 0;
10431043
Py_ssize_t smooth = 0;
10441044
Py_ssize_t optimize = 0;
1045+
int keep_rgb = 0;
10451046
Py_ssize_t streamtype = 0; /* 0=interchange, 1=tables only, 2=image only */
10461047
Py_ssize_t xdpi = 0, ydpi = 0;
10471048
Py_ssize_t subsampling = -1; /* -1=default, 0=none, 1=medium, 2=high */
@@ -1059,13 +1060,14 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
10591060

10601061
if (!PyArg_ParseTuple(
10611062
args,
1062-
"ss|nnnnnnnnnnOz#y#y#",
1063+
"ss|nnnnpnnnnnnOz#y#y#",
10631064
&mode,
10641065
&rawmode,
10651066
&quality,
10661067
&progressive,
10671068
&smooth,
10681069
&optimize,
1070+
&keep_rgb,
10691071
&streamtype,
10701072
&xdpi,
10711073
&ydpi,
@@ -1150,6 +1152,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) {
11501152

11511153
strncpy(((JPEGENCODERSTATE *)encoder->state.context)->rawmode, rawmode, 8);
11521154

1155+
((JPEGENCODERSTATE *)encoder->state.context)->keep_rgb = keep_rgb;
11531156
((JPEGENCODERSTATE *)encoder->state.context)->quality = quality;
11541157
((JPEGENCODERSTATE *)encoder->state.context)->qtables = qarrays;
11551158
((JPEGENCODERSTATE *)encoder->state.context)->qtablesLen = qtablesLen;

src/libImaging/Jpeg.h

+3
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ typedef struct {
7474
/* Optimize Huffman tables (slow) */
7575
int optimize;
7676

77+
/* Disable automatic conversion of RGB images to YCbCr if nonzero */
78+
int keep_rgb;
79+
7780
/* Stream type (0=full, 1=tables only, 2=image only) */
7881
int streamtype;
7982

src/libImaging/JpegEncode.c

+24
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,30 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) {
137137
/* Compressor configuration */
138138
jpeg_set_defaults(&context->cinfo);
139139

140+
/* Prevent RGB -> YCbCr conversion */
141+
if (context->keep_rgb) {
142+
switch (context->cinfo.in_color_space) {
143+
case JCS_RGB:
144+
#ifdef JCS_EXTENSIONS
145+
case JCS_EXT_RGBX:
146+
#endif
147+
switch (context->subsampling) {
148+
case -1: /* Default */
149+
case 0: /* No subsampling */
150+
break;
151+
default:
152+
/* Would subsample the green and blue
153+
channels, which doesn't make sense */
154+
state->errcode = IMAGING_CODEC_CONFIG;
155+
return -1;
156+
}
157+
jpeg_set_colorspace(&context->cinfo, JCS_RGB);
158+
break;
159+
default:
160+
break;
161+
}
162+
}
163+
140164
/* Use custom quantization tables */
141165
if (context->qtables) {
142166
int i;

0 commit comments

Comments
 (0)