fetchart/artresizer: add max_filesize support (#3560)

Squashed from the PR, relevant commit messages follow below:

Added file size option to artresizer

 - In line with comments on PR, adjusted the ArtResizer API to add
   functionality to "resize to X bytes" through `max_filesize` arg

 - Adjustment to changelog.rst to include max_filesize change to ArtResizer
   and addition of new plugin.

Added explicit tests for PIL & Imagemagick Methods

 - Checks new resizing functions do reduce the filesize of images

Expose max_filesize logic to fetchart plugin

- Add syspath escaping for OS cross compatibility
- Return smaller PIL image even if max filesize not reached.
- Test resize logic against known smaller filesize (//2)
- Pass integer (not float) quality argument to PIL
- Remove Pillow from dependencies
- Implement "max_filesize" fetchart option, including
  logic to resize and rescale if maxwidth is also set.

Added tests & documentation for fetchart additions.

Tests now check that a target filesize is reached with a
higher initial quality (a difficult check to pass).

With a starting quality of 95% PIL takes 4 iterations to succeed
in lowering the example cover image to 90% its original size.
To cover all bases, the PIL loop has been changed to 5 iterations
in the worst case, and the documentation altered to reflect the
50% loss in quality this implies. This seems reasonable as users
concerned about performance would most likely be persuaded to
install ImageMagick, or remove the maximum filesize constraint.
The previous 30% figure was arbitrary.
This commit is contained in:
David Swarbrick 2020-04-19 13:28:38 +01:00 committed by wisp3rwind
parent 04ea754d00
commit 07b5e69f40
6 changed files with 231 additions and 20 deletions

View file

@ -64,12 +64,13 @@ def temp_file_for(path):
return util.bytestring_path(f.name)
def pil_resize(maxwidth, path_in, path_out=None, quality=0):
def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
"""Resize using Python Imaging Library (PIL). Return the output path
of resized image.
"""
path_out = path_out or temp_file_for(path_in)
from PIL import Image
log.debug(u'artresizer: PIL resizing {0} to {1}',
util.displayable_path(path_in), util.displayable_path(path_out))
@ -83,14 +84,43 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0):
quality = -1
im.save(util.py3_path(path_out), quality=quality)
return path_out
if max_filesize > 0:
# If maximum filesize is set, we attempt to lower the quality of
# jpeg conversion by a proportional amount, up to 3 attempts
# First, set the maximum quality to either provided, or 95
if quality > 0:
lower_qual = quality
else:
lower_qual = 95
for i in range(5):
# 3 attempts is an abitrary choice
filesize = os.stat(util.syspath(path_out)).st_size
log.debug(u"PIL Pass {0} : Output size: {1}B", i, filesize)
if filesize <= max_filesize:
return path_out
# The relationship between filesize & quality will be
# image dependent.
lower_qual -= 10
# Restrict quality dropping below 10
if lower_qual < 10:
lower_qual = 10
# Use optimize flag to improve filesize decrease
im.save(
util.py3_path(path_out), quality=lower_qual, optimize=True
)
log.warning(u"PIL Failed to resize file to below {0}B",
max_filesize)
return path_out
else:
return path_out
except IOError:
log.error(u"PIL cannot create thumbnail for '{0}'",
util.displayable_path(path_in))
return path_in
def im_resize(maxwidth, path_in, path_out=None, quality=0):
def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
"""Resize using ImageMagick.
Use the ``magick`` program or ``convert`` on older versions. Return
@ -103,6 +133,9 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0):
# "-resize WIDTHx>" shrinks images with the width larger
# than the given width while maintaining the aspect ratio
# with regards to the height.
# "-define jpeg:extent=SIZEb" sets the target filesize
# for imagemagick to SIZE in bytes
cmd = ArtResizer.shared.im_convert_cmd + [
util.syspath(path_in, prefix=False),
'-resize', '{0}x>'.format(maxwidth),
@ -111,6 +144,9 @@ def im_resize(maxwidth, path_in, path_out=None, quality=0):
if quality > 0:
cmd += ['-quality', '{0}'.format(quality)]
if max_filesize > 0:
cmd += ['-define', 'jpeg:extent={0}b'.format(max_filesize)]
cmd.append(util.syspath(path_out, prefix=False))
try:
@ -131,6 +167,7 @@ BACKEND_FUNCS = {
def pil_getsize(path_in):
from PIL import Image
try:
im = Image.open(util.syspath(path_in))
return im.size
@ -171,6 +208,7 @@ class Shareable(type):
lazily-created shared instance of ``MyClass`` while calling
``MyClass()`` to construct a new object works as usual.
"""
def __init__(cls, name, bases, dict):
super(Shareable, cls).__init__(name, bases, dict)
cls._instance = None
@ -205,7 +243,9 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
self.im_convert_cmd = ['magick']
self.im_identify_cmd = ['magick', 'identify']
def resize(self, maxwidth, path_in, path_out=None, quality=0):
def resize(
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
):
"""Manipulate an image file according to the method, returning a
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
temporary file and encodes with the specified quality level.
@ -213,7 +253,8 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
"""
if self.local:
func = BACKEND_FUNCS[self.method[0]]
return func(maxwidth, path_in, path_out, quality=quality)
return func(maxwidth, path_in, path_out,
quality=quality, max_filesize=max_filesize)
else:
return path_in

View file

@ -51,6 +51,7 @@ class Candidate(object):
CANDIDATE_BAD = 0
CANDIDATE_EXACT = 1
CANDIDATE_DOWNSCALE = 2
CANDIDATE_DOWNSIZE = 3
MATCH_EXACT = 0
MATCH_FALLBACK = 1
@ -71,12 +72,14 @@ class Candidate(object):
Return `CANDIDATE_BAD` if the file is unusable.
Return `CANDIDATE_EXACT` if the file is usable as-is.
Return `CANDIDATE_DOWNSCALE` if the file must be resized.
Return `CANDIDATE_DOWNSCALE` if the file must be rescaled.
Return `CANDIDATE_DOWNSIZE` if the file must be resized.
"""
if not self.path:
return self.CANDIDATE_BAD
if not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth):
if (not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth
or plugin.max_filesize)):
return self.CANDIDATE_EXACT
# get_size returns None if no local imaging backend is available
@ -94,7 +97,7 @@ class Candidate(object):
short_edge = min(self.size)
long_edge = max(self.size)
# Check minimum size.
# Check minimum dimension.
if plugin.minwidth and self.size[0] < plugin.minwidth:
self._log.debug(u'image too small ({} < {})',
self.size[0], plugin.minwidth)
@ -122,22 +125,44 @@ class Candidate(object):
self.size[0], self.size[1])
return self.CANDIDATE_BAD
# Check maximum size.
# Check maximum dimension.
downscale = False
if plugin.maxwidth and self.size[0] > plugin.maxwidth:
self._log.debug(u'image needs resizing ({} > {})',
self._log.debug(u'image needs rescaling ({} > {})',
self.size[0], plugin.maxwidth)
return self.CANDIDATE_DOWNSCALE
downscale = True
return self.CANDIDATE_EXACT
# Check filesize.
filesize = os.stat(syspath(self.path)).st_size
downsize = False
if plugin.max_filesize and filesize > plugin.max_filesize:
self._log.debug(u'image needs resizing ({}B > {}B)',
filesize, plugin.max_filesize)
downsize = True
if downscale:
return self.CANDIDATE_DOWNSCALE
elif downsize:
return self.CANDIDATE_DOWNSIZE
else:
return self.CANDIDATE_EXACT
def validate(self, plugin):
self.check = self._validate(plugin)
return self.check
def resize(self, plugin):
if plugin.maxwidth and self.check == self.CANDIDATE_DOWNSCALE:
self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path,
quality=plugin.quality)
if self.check == self.CANDIDATE_DOWNSCALE:
self.path = \
ArtResizer.shared.resize(plugin.maxwidth, self.path,
quality=plugin.quality,
max_filesize=plugin.max_filesize)
elif self.check == self.CANDIDATE_DOWNSIZE:
# dimensions are correct, so maxwidth is set to maximum dimension
self.path = \
ArtResizer.shared.resize(max(self.size), self.path,
quality=plugin.quality,
max_filesize=plugin.max_filesize)
def _logged_get(log, *args, **kwargs):
@ -892,6 +917,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
'minwidth': 0,
'maxwidth': 0,
'quality': 0,
'max_filesize': 0,
'enforce_ratio': False,
'cautious': False,
'cover_names': ['cover', 'front', 'art', 'album', 'folder'],
@ -910,6 +936,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
self.minwidth = self.config['minwidth'].get(int)
self.maxwidth = self.config['maxwidth'].get(int)
self.max_filesize = self.config['max_filesize'].get(int)
self.quality = self.config['quality'].get(int)
# allow both pixel and percentage-based margin specifications

View file

@ -25,9 +25,9 @@ New features:
* A new :ref:`extra_tags` configuration option allows more tagged metadata
to be included in MusicBrainz queries.
* A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets
* :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added a new ``quality``
option that controls the quality of the image output when the image is
resized.
* :doc:`plugins/fetchart` and :doc:`plugins/embedart`: Added new ``quality`` option
that controls the quality of the output when an image is resized. Additionally,
the new ``max_filesize`` option for fetchart can be used to target a maximum image filesize.
* :doc:`plugins/keyfinder`: Added support for `keyfinder-cli`_
Thanks to :user:`BrainDamage`.
* :doc:`plugins/fetchart`: Added a new ``high_resolution`` config option to

View file

@ -49,6 +49,13 @@ file. The available options are:
estimate the input image quality and uses 92 if it cannot be determined, and
PIL defaults to 75.
Default: 0 (disabled)
- **max_filesize**: The maximum size of a target piece of cover art in bytes.
When using an ImageMagick backend this sets
``-define jpeg:extent=max_filesize``. Using PIL this will reduce JPG quality
by up to 50% to attempt to reach the target filesize. Neither method is
*guaranteed* to reach the target size, however in most cases it should
succeed.
Default: 0 (disabled)
- **enforce_ratio**: Only images with a width:height ratio of 1:1 are
considered as valid album art candidates if set to ``yes``.
It is also possible to specify a certain deviation to the exact ratio to

View file

@ -742,14 +742,17 @@ class ArtImporterTest(UseThePlugin):
class ArtForAlbumTest(UseThePlugin):
""" Tests that fetchart.art_for_album respects the size
configuration (e.g., minwidth, enforce_ratio)
""" Tests that fetchart.art_for_album respects the scale & filesize
configurations (e.g., minwidth, enforce_ratio, max_filesize)
"""
IMG_225x225 = os.path.join(_common.RSRC, b'abbey.jpg')
IMG_348x348 = os.path.join(_common.RSRC, b'abbey-different.jpg')
IMG_500x490 = os.path.join(_common.RSRC, b'abbey-similar.jpg')
IMG_225x225_SIZE = os.stat(util.syspath(IMG_225x225)).st_size
IMG_348x348_SIZE = os.stat(util.syspath(IMG_348x348)).st_size
def setUp(self):
super(ArtForAlbumTest, self).setUp()
@ -839,6 +842,29 @@ class ArtForAlbumTest(UseThePlugin):
self._assertImageResized(self.IMG_225x225, False)
self._assertImageResized(self.IMG_348x348, True)
def test_fileresize(self):
self._require_backend()
self.plugin.max_filesize = self.IMG_225x225_SIZE // 2
self._assertImageResized(self.IMG_225x225, True)
def test_fileresize_if_necessary(self):
self._require_backend()
self.plugin.max_filesize = self.IMG_225x225_SIZE
self._assertImageResized(self.IMG_225x225, False)
self._assertImageIsValidArt(self.IMG_225x225, True)
def test_fileresize_no_scale(self):
self._require_backend()
self.plugin.maxwidth = 300
self.plugin.max_filesize = self.IMG_225x225_SIZE // 2
self._assertImageResized(self.IMG_225x225, True)
def test_fileresize_and_scale(self):
self._require_backend()
self.plugin.maxwidth = 200
self.plugin.max_filesize = self.IMG_225x225_SIZE // 2
self._assertImageResized(self.IMG_225x225, True)
class DeprecatedConfigTest(_common.TestCase):
"""While refactoring the plugin, the remote_priority option was deprecated,

110
test/test_art_resize.py Normal file
View file

@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2020, David Swarbrick.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Tests for image resizing based on filesize."""
from __future__ import division, absolute_import, print_function
import unittest
import os
from test import _common
from test.helper import TestHelper
from beets.util import syspath
from beets.util.artresizer import (
pil_resize,
im_resize,
get_im_version,
get_pil_version,
)
class ArtResizerFileSizeTest(_common.TestCase, TestHelper):
"""Unittest test case for Art Resizer to a specific filesize."""
IMG_225x225 = os.path.join(_common.RSRC, b"abbey.jpg")
IMG_225x225_SIZE = os.stat(syspath(IMG_225x225)).st_size
def setUp(self):
"""Called before each test, setting up beets."""
self.setup_beets()
def tearDown(self):
"""Called after each test, unloading all plugins."""
self.teardown_beets()
def _test_img_resize(self, resize_func):
"""Test resizing based on file size, given a resize_func."""
# Check quality setting unaffected by new parameter
im_95_qual = resize_func(
225,
self.IMG_225x225,
quality=95,
max_filesize=0,
)
# check valid path returned - max_filesize hasn't broken resize command
self.assertExists(im_95_qual)
# Attempt a lower filesize with same quality
im_a = resize_func(
225,
self.IMG_225x225,
quality=95,
max_filesize=0.9 * os.stat(syspath(im_95_qual)).st_size,
)
self.assertExists(im_a)
# target size was achieved
self.assertLess(os.stat(syspath(im_a)).st_size,
os.stat(syspath(im_95_qual)).st_size)
# Attempt with lower initial quality
im_75_qual = resize_func(
225,
self.IMG_225x225,
quality=75,
max_filesize=0,
)
self.assertExists(im_75_qual)
im_b = resize_func(
225,
self.IMG_225x225,
quality=95,
max_filesize=0.9 * os.stat(syspath(im_75_qual)).st_size,
)
self.assertExists(im_b)
# Check high (initial) quality still gives a smaller filesize
self.assertLess(os.stat(syspath(im_b)).st_size,
os.stat(syspath(im_75_qual)).st_size)
@unittest.skipUnless(get_pil_version(), "PIL not available")
def test_pil_file_resize(self):
"""Test PIL resize function is lowering file size."""
self._test_img_resize(pil_resize)
@unittest.skipUnless(get_im_version(), "ImageMagick not available")
def test_im_file_resize(self):
"""Test IM resize function is lowering file size."""
self._test_img_resize(im_resize)
def suite():
"""Run this suite of tests."""
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == "__main__":
unittest.main(defaultTest="suite")