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

Tiff: Add support for JPEG quality #3886

Merged
merged 5 commits into from Jul 1, 2019
Merged
Show file tree
Hide file tree
Changes from 2 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
88 changes: 77 additions & 11 deletions Tests/test_file_libtiff.py
Expand Up @@ -3,6 +3,7 @@
from PIL import features
from PIL._util import py3

from collections import namedtuple
from ctypes import c_float
import io
import logging
Expand Down Expand Up @@ -235,12 +236,39 @@ def test_additional_metadata(self):
TiffImagePlugin.WRITE_LIBTIFF = False

def test_custom_metadata(self):
tc = namedtuple("test_case", "value,type,supported_by_default")
custom = {
37000: [4, TiffTags.SHORT],
37001: [4.2, TiffTags.RATIONAL],
37002: ["custom tag value", TiffTags.ASCII],
37003: [u"custom tag value", TiffTags.ASCII],
37004: [b"custom tag value", TiffTags.BYTE],
37000 + k: v
for k, v in enumerate(
[
tc(4, TiffTags.SHORT, True),
tc(123456789, TiffTags.LONG, True),
tc(-4, TiffTags.SIGNED_BYTE, False),
tc(-4, TiffTags.SIGNED_SHORT, False),
tc(-123456789, TiffTags.SIGNED_LONG, False),
tc(TiffImagePlugin.IFDRational(4, 7), TiffTags.RATIONAL, True),
tc(4.25, TiffTags.FLOAT, True),
tc(4.25, TiffTags.DOUBLE, True),
tc("custom tag value", TiffTags.ASCII, True),
tc(u"custom tag value", TiffTags.ASCII, True),
tc(b"custom tag value", TiffTags.BYTE, True),
tc((4, 5, 6), TiffTags.SHORT, True),
tc((123456789, 9, 34, 234, 219387, 92432323), TiffTags.LONG, True),
tc((-4, 9, 10), TiffTags.SIGNED_BYTE, False),
tc((-4, 5, 6), TiffTags.SIGNED_SHORT, False),
tc(
(-123456789, 9, 34, 234, 219387, -92432323),
TiffTags.SIGNED_LONG,
False,
),
tc((4.25, 5.25), TiffTags.FLOAT, True),
tc((4.25, 5.25), TiffTags.DOUBLE, True),
# array of TIFF_BYTE requires bytes instead of tuple for backwards
# compatibility
tc(bytes([4]), TiffTags.BYTE, True),
tc(bytes((4, 9, 10)), TiffTags.BYTE, True),
]
)
}

libtiff_version = TiffImagePlugin._libtiff_version()
Expand All @@ -263,8 +291,13 @@ def check_tags(tiffinfo):
reloaded = Image.open(out)
for tag, value in tiffinfo.items():
reloaded_value = reloaded.tag_v2[tag]
if isinstance(reloaded_value, TiffImagePlugin.IFDRational):
reloaded_value = float(reloaded_value)
if (
isinstance(reloaded_value, TiffImagePlugin.IFDRational)
and libtiff
):
# libtiff does not support real RATIONALS
self.assertAlmostEqual(float(reloaded_value), float(value))
continue

if libtiff and isinstance(value, bytes):
value = value.decode()
Expand All @@ -274,12 +307,19 @@ def check_tags(tiffinfo):
# Test with types
ifd = TiffImagePlugin.ImageFileDirectory_v2()
for tag, tagdata in custom.items():
ifd[tag] = tagdata[0]
ifd.tagtype[tag] = tagdata[1]
ifd[tag] = tagdata.value
ifd.tagtype[tag] = tagdata.type
check_tags(ifd)

# Test without types
check_tags({tag: tagdata[0] for tag, tagdata in custom.items()})
# Test without types. This only works for some types, int for example are
# always encoded as LONG and not SIGNED_LONG.
check_tags(
{
tag: tagdata.value
for tag, tagdata in custom.items()
if tagdata.supported_by_default
}
)
TiffImagePlugin.WRITE_LIBTIFF = False

def test_int_dpi(self):
Expand Down Expand Up @@ -395,18 +435,44 @@ def test_blur(self):
self.assert_image_equal(im, im2)

def test_compressions(self):
# Test various tiff compressions and assert similar image content but reduced
# file sizes.
im = hopper("RGB")
out = self.tempfile("temp.tif")
im.save(out)
size_raw = os.path.getsize(out)

for compression in ("packbits", "tiff_lzw"):
im.save(out, compression=compression)
size_compressed = os.path.getsize(out)
im2 = Image.open(out)
self.assert_image_equal(im, im2)

im.save(out, compression="jpeg")
size_jpeg = os.path.getsize(out)
im2 = Image.open(out)
self.assert_image_similar(im, im2, 30)

im.save(out, compression="jpeg", quality=30)
size_jpeg_30 = os.path.getsize(out)
im3 = Image.open(out)
self.assert_image_similar(im2, im3, 30)

assert size_raw > size_compressed
hugovk marked this conversation as resolved.
Show resolved Hide resolved
assert size_compressed > size_jpeg
hugovk marked this conversation as resolved.
Show resolved Hide resolved
assert size_jpeg > size_jpeg_30
hugovk marked this conversation as resolved.
Show resolved Hide resolved

def test_quality(self):
im = hopper("RGB")
out = self.tempfile("temp.tif")

self.assertRaises(ValueError, im.save, out, compression="tiff_lzw", quality=50)
self.assertRaises(ValueError, im.save, out, compression="jpeg", quality=-1)
self.assertRaises(ValueError, im.save, out, compression="jpeg", quality=101)
self.assertRaises(ValueError, im.save, out, compression="jpeg", quality="good")
im.save(out, compression="jpeg", quality=0)
im.save(out, compression="jpeg", quality=100)

def test_cmyk_save(self):
im = hopper("CMYK")
out = self.tempfile("temp.tif")
Expand Down
20 changes: 16 additions & 4 deletions docs/handbook/image-file-formats.rst
Expand Up @@ -716,14 +716,20 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum
:py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v1` object may
be passed in this field. However, this is deprecated.

.. versionadded:: 3.0.0

.. note::
.. versionadded:: 5.4.0

Only some tags are currently supported when writing using
Previous versions only supported some tags when writing using
libtiff. The supported list is found in
:py:attr:`~PIL:TiffTags.LIBTIFF_CORE`.

.. versionadded:: 6.1.0

Added support for signed types (e.g. ``TIFF_SIGNED_LONG``) and multiple values.
Multiple values for a single tag must be to
:py:class:`~PIL.TiffImagePlugin.ImageFileDirectory_v2` as a tuple and
require a matching type in
:py:attr:`~PIL.TiffImagePlugin.ImageFileDirectory_v2.tagtype` tagtype.

**compression**
A string containing the desired compression method for the
file. (valid only with libtiff installed) Valid compression
Expand All @@ -732,6 +738,12 @@ The :py:meth:`~PIL.Image.Image.save` method can take the following keyword argum
``"tiff_thunderscan"``, ``"tiff_deflate"``, ``"tiff_sgilog"``,
``"tiff_sgilog24"``, ``"tiff_raw_16"``

**quality**
The image quality for JPEG compression, on a scale from 0 (worst) to 100
(best). The default is 75.

.. versionadded:: 6.1.0

These arguments to set the tiff header fields are an alternative to
using the general tags available through tiffinfo.

Expand Down
50 changes: 42 additions & 8 deletions src/PIL/TiffImagePlugin.py
Expand Up @@ -99,6 +99,7 @@
Y_RESOLUTION = 283
PLANAR_CONFIGURATION = 284
RESOLUTION_UNIT = 296
TRANSFERFUNCTION = 301
SOFTWARE = 305
DATE_TIME = 306
ARTIST = 315
Expand All @@ -108,12 +109,14 @@
EXTRASAMPLES = 338
SAMPLEFORMAT = 339
JPEGTABLES = 347
REFERENCEBLACKWHITE = 532
COPYRIGHT = 33432
IPTC_NAA_CHUNK = 33723 # newsphoto properties
PHOTOSHOP_CHUNK = 34377 # photoshop properties
ICCPROFILE = 34675
EXIFIFD = 34665
XMP = 700
JPEGQUALITY = 65537 # pseudo-tag by libtiff

# https://github.com/imagej/ImageJA/blob/master/src/main/java/ij/io/TiffDecoder.java
IMAGEJ_META_DATA_BYTE_COUNTS = 50838
Expand Down Expand Up @@ -1527,6 +1530,16 @@ def _save(im, fp, filename):
ifd[COMPRESSION] = COMPRESSION_INFO_REV.get(compression, 1)

if libtiff:
if "quality" in im.encoderinfo:
quality = im.encoderinfo["quality"]
if not isinstance(quality, int) or quality < 0 or quality > 100:
raise ValueError("Invalid quality setting")
if compression != "jpeg":
raise ValueError(
"quality setting only supported for 'jpeg' compression"
)
ifd[JPEGQUALITY] = quality

if DEBUG:
print("Saving using libtiff encoder")
print("Items: %s" % sorted(ifd.items()))
Expand All @@ -1538,9 +1551,21 @@ def _save(im, fp, filename):
except io.UnsupportedOperation:
pass

# optional types for non core tags
types = {}
# STRIPOFFSETS and STRIPBYTECOUNTS are added by the library
# based on the data in the strip.
blocklist = [STRIPOFFSETS, STRIPBYTECOUNTS]
# The other tags expect arrays with a certain length (fixed or depending on
# BITSPERSAMPLE, etc), passing arrays with a different length will result in
# segfaults. Block these tags until we add extra validation.
blocklist = [
COLORMAP,
REFERENCEBLACKWHITE,
STRIPBYTECOUNTS,
STRIPOFFSETS,
TRANSFERFUNCTION,
]

atts = {}
# bits per sample is a single short in the tiff directory, not a list.
atts[BITSPERSAMPLE] = bits[0]
Expand All @@ -1555,15 +1580,19 @@ def _save(im, fp, filename):
):
# Libtiff can only process certain core items without adding
# them to the custom dictionary.
# Support for custom items has only been been added
# for int, float, unicode, string and byte values
# Custom items are supported for int, float, unicode, string and byte
# values. Other types and tuples require a tagtype.
if tag not in TiffTags.LIBTIFF_CORE:
if TiffTags.lookup(tag).type == TiffTags.UNDEFINED:
continue
if (
distutils.version.StrictVersion(_libtiff_version())
< distutils.version.StrictVersion("4.0")
) or not (
if distutils.version.StrictVersion(
_libtiff_version()
) < distutils.version.StrictVersion("4.0"):
continue

if tag in ifd.tagtype:
types[tag] = ifd.tagtype[tag]
elif not (
isinstance(value, (int, float, str, bytes))
or (not py3 and isinstance(value, unicode)) # noqa: F821
):
Expand All @@ -1586,7 +1615,12 @@ def _save(im, fp, filename):
if im.mode in ("I;16B", "I;16"):
rawmode = "I;16N"

a = (rawmode, compression, _fp, filename, atts)
# Pass tags as sorted list so that the tags are set in a fixed order.
# This is required by libtiff for some tags. For example, the JPEGQUALITY
# pseudo tag requires that the COMPRESS tag was already set.
tags = list(atts.items())
tags.sort()
a = (rawmode, compression, _fp, filename, tags, types)
e = Image._getencoder(im.mode, "libtiff", a, im.encoderconfig)
e.setimage(im.im, (0, 0) + im.size)
while True:
Expand Down
4 changes: 4 additions & 0 deletions src/PIL/TiffTags.py
Expand Up @@ -432,6 +432,9 @@ def _populate():
# 389: case TIFFTAG_REFERENCEBLACKWHITE:
# 393: case TIFFTAG_INKNAMES:

# Following pseudo-tags are also handled by default in libtiff:
# TIFFTAG_JPEGQUALITY 65537

# some of these are not in our TAGS_V2 dict and were included from tiff.h

# This list also exists in encode.c
Expand Down Expand Up @@ -476,6 +479,7 @@ def _populate():
333,
# as above
269, # this has been in our tests forever, and works
65537,
}

LIBTIFF_CORE.remove(320) # Array of short, crashes
Expand Down