Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 96fad22

Browse files
committed
pdf: Use explicit palette when saving indexed images
Asking Pillow for an "adaptive palette" does not appear to guarantee that the chosen colours will be the same, even if asking for exactly the same number as exist in the image. And asking Pillow to quantize with an explicit palette does not work either, as Pillow uses a cache that trims the last two bits from the colour and never makes an explicit match. python-pillow/Pillow#1852 (comment) So instead, manually calculate the indexed image using some NumPy tricks. Additionally, since now the palette may be smaller than 256 colours, Pillow may choose to encode the image data with fewer than 8 bits per component, so we need to properly reflect that in the decode parameters (this was already done for the image parameters). The effect on test images with _many_ colours is small, with a maximum RMS of 1.024, but for images with few colours, the result can be completely wrong as in the reported #25806.
1 parent 3b30f47 commit 96fad22

21 files changed

+46
-22
lines changed

lib/matplotlib/backends/backend_pdf.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,39 +1731,43 @@ def _writeImg(self, data, id, smask=None):
17311731
'Subtype': Name('Image'),
17321732
'Width': width,
17331733
'Height': height,
1734-
'ColorSpace': Name({1: 'DeviceGray',
1735-
3: 'DeviceRGB'}[color_channels]),
1734+
'ColorSpace': Name({1: 'DeviceGray', 3: 'DeviceRGB'}[color_channels]),
17361735
'BitsPerComponent': 8}
17371736
if smask:
17381737
obj['SMask'] = smask
17391738
if mpl.rcParams['pdf.compression']:
17401739
if data.shape[-1] == 1:
17411740
data = data.squeeze(axis=-1)
1741+
png = {'Predictor': 10, 'Colors': color_channels, 'Columns': width}
17421742
img = Image.fromarray(data)
17431743
img_colors = img.getcolors(maxcolors=256)
17441744
if color_channels == 3 and img_colors is not None:
1745-
# Convert to indexed color if there are 256 colors or fewer
1746-
# This can significantly reduce the file size
1745+
# Convert to indexed color if there are 256 colors or fewer. This can
1746+
# significantly reduce the file size.
17471747
num_colors = len(img_colors)
1748-
# These constants were converted to IntEnums and deprecated in
1749-
# Pillow 9.2
1750-
dither = getattr(Image, 'Dither', Image).NONE
1751-
pmode = getattr(Image, 'Palette', Image).ADAPTIVE
1752-
img = img.convert(
1753-
mode='P', dither=dither, palette=pmode, colors=num_colors
1754-
)
1748+
palette = np.array([comp for _, color in img_colors for comp in color],
1749+
dtype=np.uint8)
1750+
palette24 = ((palette[0::3].astype(np.uint32) << 16) |
1751+
(palette[1::3].astype(np.uint32) << 8) |
1752+
palette[2::3])
1753+
rgb24 = ((data[:, :, 0].astype(np.uint32) << 16) |
1754+
(data[:, :, 1].astype(np.uint32) << 8) |
1755+
data[:, :, 2])
1756+
indices = np.argsort(palette24).astype(np.uint8)
1757+
rgb8 = indices[np.searchsorted(palette24, rgb24, sorter=indices)]
1758+
img = Image.fromarray(rgb8, mode='P')
1759+
img.putpalette(palette)
17551760
png_data, bit_depth, palette = self._writePng(img)
17561761
if bit_depth is None or palette is None:
17571762
raise RuntimeError("invalid PNG header")
1758-
palette = palette[:num_colors * 3] # Trim padding
1759-
obj['ColorSpace'] = Verbatim(
1760-
b'[/Indexed /DeviceRGB %d %s]'
1761-
% (num_colors - 1, pdfRepr(palette)))
1763+
palette = palette[:num_colors * 3] # Trim padding; remove for Pillow>=9
1764+
obj['ColorSpace'] = [Name('Indexed'), Name('DeviceRGB'),
1765+
num_colors - 1, palette]
17621766
obj['BitsPerComponent'] = bit_depth
1763-
color_channels = 1
1767+
png['Colors'] = 1
1768+
png['BitsPerComponent'] = bit_depth
17641769
else:
17651770
png_data, _, _ = self._writePng(img)
1766-
png = {'Predictor': 10, 'Colors': color_channels, 'Columns': width}
17671771
else:
17681772
png = None
17691773
self.beginStream(
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

lib/matplotlib/tests/test_backend_pdf.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,30 @@ def test_composite_image():
131131
assert len(pdf._file._images) == 2
132132

133133

134+
def test_indexed_image():
135+
# An image with low color count should compress to a palette-indexed format.
136+
pikepdf = pytest.importorskip('pikepdf')
137+
138+
data = np.zeros((256, 1, 3), dtype=np.uint8)
139+
data[:, 0, 0] = np.arange(256) # Maximum unique colours for an indexed image.
140+
141+
rcParams['pdf.compression'] = True
142+
fig = plt.figure()
143+
fig.figimage(data, resize=True)
144+
buf = io.BytesIO()
145+
fig.savefig(buf, format='pdf', dpi='figure')
146+
147+
with pikepdf.Pdf.open(buf) as pdf:
148+
page, = pdf.pages
149+
image, = page.images.values()
150+
pdf_image = pikepdf.PdfImage(image)
151+
assert pdf_image.indexed
152+
pil_image = pdf_image.as_pil_image()
153+
rgb = np.asarray(pil_image.convert('RGB'))
154+
155+
np.testing.assert_array_equal(data, rgb)
156+
157+
134158
def test_savefig_metadata(monkeypatch):
135159
pikepdf = pytest.importorskip('pikepdf')
136160
monkeypatch.setenv('SOURCE_DATE_EPOCH', '0')

lib/matplotlib/tests/test_image.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -754,11 +754,7 @@ def test_log_scale_image():
754754
ax.set(yscale='log')
755755

756756

757-
# Increased tolerance is needed for PDF test to avoid failure. After the PDF
758-
# backend was modified to use indexed color, there are ten pixels that differ
759-
# due to how the subpixel calculation is done when converting the PDF files to
760-
# PNG images.
761-
@image_comparison(['rotate_image'], remove_text=True, tol=0.35)
757+
@image_comparison(['rotate_image'], remove_text=True)
762758
def test_rotate_image():
763759
delta = 0.25
764760
x = y = np.arange(-3.0, 3.0, delta)

0 commit comments

Comments
 (0)