Merge pull request #4273 from uploadcare/reduce-in-resize

Reduce for resize
This commit is contained in:
Alexander Karpinsky 2019-12-30 17:29:58 +03:00 committed by GitHub
commit c3232e5093
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 215 additions and 9 deletions

View File

@ -136,6 +136,93 @@ class TestImagingCoreResize(PillowTestCase):
self.assertRaises(ValueError, self.resize, hopper(), (10, 10), 9)
class TestReducingGapResize(PillowTestCase):
@classmethod
def setUpClass(cls):
cls.gradients_image = Image.open("Tests/images/radial_gradients.png")
cls.gradients_image.load()
def test_reducing_gap_values(self):
ref = self.gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=None)
im = self.gradients_image.resize((52, 34), Image.BICUBIC)
self.assert_image_equal(ref, im)
with self.assertRaises(ValueError):
self.gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=0)
with self.assertRaises(ValueError):
self.gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=0.99)
def test_reducing_gap_1(self):
for box, epsilon in [
(None, 4),
((1.1, 2.2, 510.8, 510.9), 4),
((3, 10, 410, 256), 10),
]:
ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box)
im = self.gradients_image.resize(
(52, 34), Image.BICUBIC, box=box, reducing_gap=1.0
)
with self.assertRaises(AssertionError):
self.assert_image_equal(ref, im)
self.assert_image_similar(ref, im, epsilon)
def test_reducing_gap_2(self):
for box, epsilon in [
(None, 1.5),
((1.1, 2.2, 510.8, 510.9), 1.5),
((3, 10, 410, 256), 1),
]:
ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box)
im = self.gradients_image.resize(
(52, 34), Image.BICUBIC, box=box, reducing_gap=2.0
)
with self.assertRaises(AssertionError):
self.assert_image_equal(ref, im)
self.assert_image_similar(ref, im, epsilon)
def test_reducing_gap_3(self):
for box, epsilon in [
(None, 1),
((1.1, 2.2, 510.8, 510.9), 1),
((3, 10, 410, 256), 0.5),
]:
ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box)
im = self.gradients_image.resize(
(52, 34), Image.BICUBIC, box=box, reducing_gap=3.0
)
with self.assertRaises(AssertionError):
self.assert_image_equal(ref, im)
self.assert_image_similar(ref, im, epsilon)
def test_reducing_gap_8(self):
for box in [None, (1.1, 2.2, 510.8, 510.9), (3, 10, 410, 256)]:
ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box)
im = self.gradients_image.resize(
(52, 34), Image.BICUBIC, box=box, reducing_gap=8.0
)
self.assert_image_equal(ref, im)
def test_box_filter(self):
for box, epsilon in [
((0, 0, 512, 512), 5.5),
((0.9, 1.7, 128, 128), 9.5),
]:
ref = self.gradients_image.resize((52, 34), Image.BOX, box=box)
im = self.gradients_image.resize(
(52, 34), Image.BOX, box=box, reducing_gap=1.0
)
self.assert_image_similar(ref, im, epsilon)
class TestImageResize(PillowTestCase):
def test_resize(self):
def resize(mode, size):

View File

@ -65,8 +65,36 @@ class TestImageThumbnail(PillowTestCase):
im.paste(Image.new("RGB", (235, 235)), (11, 11))
thumb = fromstring(tostring(im, "JPEG", quality=99, subsampling=0))
thumb.thumbnail((32, 32), Image.BICUBIC)
# small reducing_gap to amplify the effect
thumb.thumbnail((32, 32), Image.BICUBIC, reducing_gap=1.0)
ref = im.resize((32, 32), Image.BICUBIC)
# This is still JPEG, some error is present. Without the fix it is 11.5
self.assert_image_similar(thumb, ref, 1.5)
def test_reducing_gap_values(self):
im = hopper()
im.thumbnail((18, 18), Image.BICUBIC)
ref = hopper()
ref.thumbnail((18, 18), Image.BICUBIC, reducing_gap=2.0)
# reducing_gap=2.0 should be the default
self.assert_image_equal(ref, im)
ref = hopper()
ref.thumbnail((18, 18), Image.BICUBIC, reducing_gap=None)
with self.assertRaises(AssertionError):
self.assert_image_equal(ref, im)
self.assert_image_similar(ref, im, 3.5)
def test_reducing_gap_for_DCT_scaling(self):
with Image.open("Tests/images/hopper.jpg") as ref:
# thumbnail should call draft with reducing_gap scale
ref.draft(None, (18 * 3, 18 * 3))
ref = ref.resize((18, 18), Image.BICUBIC)
with Image.open("Tests/images/hopper.jpg") as im:
im.thumbnail((18, 18), Image.BICUBIC, reducing_gap=3.0)
self.assert_image_equal(ref, im)

View File

@ -87,6 +87,33 @@ Custom unidentified image error
Pillow will now throw a custom ``UnidentifiedImageError`` when an image cannot be
identified. For backwards compatibility, this will inherit from ``IOError``.
New argument ``reducing_gap`` for Image.resize() and Image.thumbnail() methods
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Speeds up resizing by resizing the image in two steps. The bigger ``reducing_gap``,
the closer the result to the fair resampling. The smaller ``reducing_gap``,
the faster resizing. With ``reducing_gap`` greater or equal to 3.0,
the result is indistinguishable from fair resampling.
The default value for :py:meth:`~PIL.Image.Image.resize` is ``None``,
which means that the optimization is turned off by default.
The default value for :py:meth:`~PIL.Image.Image.thumbnail` is 2.0,
which is very close to fair resampling while still being faster in many cases.
In addition, the same gap is applied when :py:meth:`~PIL.Image.Image.thumbnail`
calls :py:meth:`~PIL.Image.Image.draft`, which may greatly improve the quality
of JPEG thumbnails. As a result, :py:meth:`~PIL.Image.Image.thumbnail`
in the new version provides equally high speed and high quality from any
source (JPEG or arbitrary images).
New Image.reduce() method
^^^^^^^^^^^^^^^^^^^^^^^^^
:py:meth:`~PIL.Image.Image.reduce` is a highly efficient operation
to reduce an image by integer times. Normally, it shouldn't be used directly.
Used internally by :py:meth:`~PIL.Image.Image.resize` and :py:meth:`~PIL.Image.Image.thumbnail`
methods to speed up resize when a new argument ``reducing_gap`` is set.
Loading WMF images at a given DPI
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -63,8 +63,9 @@ def _save(im, fp, filename):
fp.write(struct.pack("<H", 32)) # wBitCount(2)
image_io = BytesIO()
# TODO: invent a more convenient method for proportional scalings
tmp = im.copy()
tmp.thumbnail(size, Image.LANCZOS)
tmp.thumbnail(size, Image.LANCZOS, reducing_gap=None)
tmp.save(image_io, "png")
image_io.seek(0)
image_bytes = image_io.read()

View File

@ -144,6 +144,9 @@ HAMMING = 5
BICUBIC = CUBIC = 3
LANCZOS = ANTIALIAS = 1
_filters_support = {BOX: 0.5, BILINEAR: 1.0, HAMMING: 1.0, BICUBIC: 2.0, LANCZOS: 3.0}
# dithers
NEAREST = NONE = 0
ORDERED = 1 # Not yet implemented
@ -1763,7 +1766,24 @@ class Image:
return m_im
def resize(self, size, resample=BICUBIC, box=None):
def _get_safe_box(self, size, resample, box):
"""Expands the box so it includes adjacent pixels
that may be used by resampling with the given resampling filter.
"""
filter_support = _filters_support[resample] - 0.5
scale_x = (box[2] - box[0]) / size[0]
scale_y = (box[3] - box[1]) / size[1]
support_x = filter_support * scale_x
support_y = filter_support * scale_y
return (
max(0, int(box[0] - support_x)),
max(0, int(box[1] - support_y)),
min(self.size[0], math.ceil(box[2] + support_x)),
min(self.size[1], math.ceil(box[3] + support_y)),
)
def resize(self, size, resample=BICUBIC, box=None, reducing_gap=None):
"""
Returns a resized copy of this image.
@ -1781,6 +1801,18 @@ class Image:
the source image region to be scaled.
The values must be within (0, 0, width, height) rectangle.
If omitted or None, the entire source is used.
:param reducing_gap: Apply optimization by resizing the image
in two steps. First, reducing the image by integer times
using :py:meth:`~PIL.Image.Image.reduce`.
Second, resizing using regular resampling. The last step
changes size no less than by ``reducing_gap`` times.
``reducing_gap`` may be None (no first step is performed)
or should be greater than 1.0. The bigger ``reducing_gap``,
the closer the result to the fair resampling.
The smaller ``reducing_gap``, the faster resizing.
With ``reducing_gap`` greater or equal to 3.0, the result is
indistinguishable from fair resampling in most cases.
The default value is None (no optimization).
:returns: An :py:class:`~PIL.Image.Image` object.
"""
@ -1802,6 +1834,9 @@ class Image:
message + " Use " + ", ".join(filters[:-1]) + " or " + filters[-1]
)
if reducing_gap is not None and reducing_gap < 1.0:
raise ValueError("reducing_gap must be 1.0 or greater")
size = tuple(size)
if box is None:
@ -1822,6 +1857,19 @@ class Image:
self.load()
if reducing_gap is not None and resample != NEAREST:
factor_x = int((box[2] - box[0]) / size[0] / reducing_gap) or 1
factor_y = int((box[3] - box[1]) / size[1] / reducing_gap) or 1
if factor_x > 1 or factor_y > 1:
reduce_box = self._get_safe_box(size, resample, box)
self = self.reduce((factor_x, factor_y), box=reduce_box)
box = (
(box[0] - reduce_box[0]) / factor_x,
(box[1] - reduce_box[1]) / factor_y,
(box[2] - reduce_box[0]) / factor_x,
(box[3] - reduce_box[1]) / factor_y,
)
return self._new(self.im.resize(size, resample, box))
def reduce(self, factor, box=None):
@ -2147,7 +2195,7 @@ class Image:
"""
return 0
def thumbnail(self, size, resample=BICUBIC):
def thumbnail(self, size, resample=BICUBIC, reducing_gap=2.0):
"""
Make this image into a thumbnail. This method modifies the
image to contain a thumbnail version of itself, no larger than
@ -2166,7 +2214,21 @@ class Image:
of :py:attr:`PIL.Image.NEAREST`, :py:attr:`PIL.Image.BILINEAR`,
:py:attr:`PIL.Image.BICUBIC`, or :py:attr:`PIL.Image.LANCZOS`.
If omitted, it defaults to :py:attr:`PIL.Image.BICUBIC`.
(was :py:attr:`PIL.Image.NEAREST` prior to version 2.5.0)
(was :py:attr:`PIL.Image.NEAREST` prior to version 2.5.0).
:param reducing_gap: Apply optimization by resizing the image
in two steps. First, reducing the image by integer times
using :py:meth:`~PIL.Image.Image.reduce` or
:py:meth:`~PIL.Image.Image.draft` for JPEG images.
Second, resizing using regular resampling. The last step
changes size no less than by ``reducing_gap`` times.
``reducing_gap`` may be None (no first step is performed)
or should be greater than 1.0. The bigger ``reducing_gap``,
the closer the result to the fair resampling.
The smaller ``reducing_gap``, the faster resizing.
With ``reducing_gap`` greater or equal to 3.0, the result is
indistinguishable from fair resampling in most cases.
The default value is 2.0 (very close to fair resampling
while still being faster in many cases).
:returns: None
"""
@ -2184,12 +2246,13 @@ class Image:
if size == self.size:
return
res = self.draft(None, size)
if res is not None:
box = res[1]
if reducing_gap is not None:
res = self.draft(None, (size[0] * reducing_gap, size[1] * reducing_gap))
if res is not None:
box = res[1]
if self.size != size:
im = self.resize(size, resample, box=box)
im = self.resize(size, resample, box=box, reducing_gap=reducing_gap)
self.im = im.im
self._size = size