Skip to content

Commit

Permalink
Merge pull request metabrainz#2506 from twodoorcoupe/image_filtering
Browse files Browse the repository at this point in the history
PICARD-2926: Add option to filter out images below a given size
  • Loading branch information
phw authored Jun 7, 2024
2 parents f28091b + f08a663 commit cc38010
Show file tree
Hide file tree
Showing 15 changed files with 563 additions and 20 deletions.
17 changes: 11 additions & 6 deletions picard/coverart/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
CoverArtImageIdentificationError,
CoverArtImageIOError,
)
from picard.coverart.processing import run_image_filters
from picard.coverart.providers import (
CoverArtProvider,
cover_art_providers,
Expand Down Expand Up @@ -107,12 +108,16 @@ def _coverart_downloaded(self, coverartimage, data, http, error):
},
echo=None
)
try:
self._set_metadata(coverartimage, data)
except CoverArtImageIOError:
# It doesn't make sense to store/download more images if we can't
# save them in the temporary folder, abort.
return
filters_result = True
if coverartimage.can_be_filtered:
filters_result = run_image_filters(data)
if filters_result:
try:
self._set_metadata(coverartimage, data)
except CoverArtImageIOError:
# It doesn't make sense to store/download more images if we can't
# save them in the temporary folder, abort.
return

self.next_in_queue()

Expand Down
2 changes: 2 additions & 0 deletions picard/coverart/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ def __init__(self, url=None, types=None, comment='', data=None, support_types=No
self.can_be_saved_to_tags = True
self.can_be_saved_to_disk = True
self.can_be_saved_to_metadata = True
self.can_be_filtered = True
if support_types is not None:
self.support_types = support_types
if support_multi_types is not None:
Expand Down Expand Up @@ -491,6 +492,7 @@ def __init__(self, url, types=None, is_front=False, comment='', data=None):
self.can_be_saved_to_disk = False
self.can_be_saved_to_tags = False
self.can_be_saved_to_metadata = False
self.can_be_filtered = False


class TagCoverArtImage(CoverArtImage):
Expand Down
45 changes: 45 additions & 0 deletions picard/coverart/processing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2024 Giorgio Fontanive
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

from picard.coverart.processing import ( # noqa: F401 # pylint: disable=unused-import
filters,
)
from picard.extension_points.cover_art_filters import (
ext_point_cover_art_filters,
ext_point_cover_art_metadata_filters,
)


def run_image_filters(data):
for f in ext_point_cover_art_filters:
if not f(data):
return False
return True


def run_image_metadata_filters(metadata):
for f in ext_point_cover_art_metadata_filters:
if not f(metadata):
return False
return True


# def run_image_processors(data):
# pass
62 changes: 62 additions & 0 deletions picard/coverart/processing/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2024 Giorgio Fontanive
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

from PyQt6.QtGui import QImage

from picard import log
from picard.config import get_config
from picard.extension_points.cover_art_filters import (
register_cover_art_filter,
register_cover_art_metadata_filter,
)


def _check_threshold_size(width, height):
config = get_config()
if not config.setting['filter_cover_by_size']:
return True
# If the given width or height is -1, that dimension is not considered
min_width = config.setting['cover_minimum_width'] if width != -1 else -1
min_height = config.setting['cover_minimum_height'] if height != -1 else -1
if width < min_width or height < min_height:
log.debug(
"Discarding cover art due to size. Image size: %d x %d. Minimum: %d x %d",
width,
height,
min_width,
min_height
)
return False
return True


def size_filter(data):
image = QImage.fromData(data)
return _check_threshold_size(image.width(), image.height())


def size_metadata_filter(metadata):
if 'width' not in metadata or 'height' not in metadata:
return True
return _check_threshold_size(metadata['width'], metadata['height'])


register_cover_art_filter(size_filter)
register_cover_art_metadata_filter(size_metadata_filter)
28 changes: 17 additions & 11 deletions picard/coverart/providers/caa.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
CaaCoverArtImage,
CaaThumbnailCoverArtImage,
)
from picard.coverart.processing import run_image_metadata_filters
from picard.coverart.providers.provider import (
CoverArtProvider,
ProviderOptions,
Expand All @@ -66,6 +67,7 @@


CaaSizeItem = namedtuple('CaaSizeItem', ['thumbnail', 'label'])
CaaThumbnailListItem = namedtuple('CAAThumbnailListItem', ['url', 'width'])

_CAA_THUMBNAIL_SIZE_MAP = OrderedDict([
(250, CaaSizeItem('250', N_('250 px'))),
Expand All @@ -90,22 +92,22 @@ def caa_url_fallback_list(desired_size, thumbnails):
If user choice isn't matching an available thumbnail size, a fallback to
smaller thumbnails is possible
This function returns the list of possible urls, ordered from the biggest
matching the user choice to the smallest one.
matching the user choice to the smallest one, together with its thumbnail's size.
Of course, if none are possible, the returned list may be empty.
"""
reversed_map = OrderedDict(reversed(list(_CAA_THUMBNAIL_SIZE_MAP.items())))
urls = []
for item_id, item in reversed_map.items():
if item_id == -1 or item_id > desired_size:
thumbnail_list = []
for thumbnail_width, item in reversed_map.items():
if thumbnail_width == -1 or thumbnail_width > desired_size:
continue
url = thumbnails.get(item.thumbnail, None)
if url is None:
size_alias = _CAA_THUMBNAIL_SIZE_ALIASES.get(item.thumbnail, None)
if size_alias is not None:
url = thumbnails.get(size_alias, None)
if url is not None:
urls.append(url)
return urls
thumbnail_list.append(CaaThumbnailListItem(url, thumbnail_width))
return thumbnail_list


class ProviderOptionsCaa(ProviderOptions):
Expand Down Expand Up @@ -303,23 +305,27 @@ def _caa_json_downloaded(self, data, http, error):
accepted = True

if accepted:
urls = caa_url_fallback_list(config.setting['caa_image_size'], image['thumbnails'])
if not urls or is_pdf:
thumbnail_list = caa_url_fallback_list(config.setting['caa_image_size'], image['thumbnails'])
if not thumbnail_list or is_pdf:
url = image['image']
else:
image_data = {'width': thumbnail_list[0].width, 'height': -1}
filters_result = run_image_metadata_filters(image_data)
if not filters_result:
continue
# FIXME: try other urls in case of 404
url = urls[0]
url = thumbnail_list[0].url
coverartimage = self.coverartimage_class(
url,
types=image['types'],
is_front=image['front'],
comment=image['comment'],
)
if urls and is_pdf:
if thumbnail_list and is_pdf:
# thumbnail will be used to "display" PDF in info
# dialog
thumbnail = self.coverartimage_thumbnail_class(
url=urls[0],
url=thumbnail_list[0].url,
types=image['types'],
is_front=image['front'],
comment=image['comment'],
Expand Down
33 changes: 33 additions & 0 deletions picard/extension_points/cover_art_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2024 Giorgio Fontanive
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

from picard.plugin import ExtensionPoint


ext_point_cover_art_filters = ExtensionPoint(label='cover_art_filters')
ext_point_cover_art_metadata_filters = ExtensionPoint(label='cover_art_metadata_filters')


def register_cover_art_filter(cover_art_filter):
ext_point_cover_art_filters.register(cover_art_filter.__module__, cover_art_filter)


def register_cover_art_metadata_filter(cover_art_metadata_filter):
ext_point_cover_art_metadata_filters.register(cover_art_metadata_filter.__module__, cover_art_metadata_filter)
6 changes: 6 additions & 0 deletions picard/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@
BoolOption('setting', 'save_images_to_tags', True, title=N_("Embed cover images into tags"))
BoolOption('setting', 'save_only_one_front_image', False, title=N_("Save only a single front image as separate file"))

# picard/ui/options/cover_processing.py
# Cover Art Image Processing
BoolOption('setting', 'filter_cover_by_size', False)
IntOption('setting', 'cover_minimum_width', 250)
IntOption('setting', 'cover_minimum_height', 250)

# picard/ui/options/dialog.py
# Attached Profiles
TextOption('persist', 'options_last_active_page', '')
Expand Down
96 changes: 96 additions & 0 deletions picard/ui/forms/ui_options_cover_processing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Form implementation generated from reading ui file 'ui/options_cover_processing.ui'
#
# Created by: PyQt6 UI code generator 6.6.1
#
# Automatically generated - do not edit.
# Use `python setup.py build_ui` to update it.

from PyQt6 import (
QtCore,
QtGui,
QtWidgets,
)

from picard.i18n import gettext as _


class Ui_CoverProcessingOptionsPage(object):
def setupUi(self, CoverProcessingOptionsPage):
CoverProcessingOptionsPage.setObjectName("CoverProcessingOptionsPage")
CoverProcessingOptionsPage.resize(400, 300)
self.verticalLayout = QtWidgets.QVBoxLayout(CoverProcessingOptionsPage)
self.verticalLayout.setObjectName("verticalLayout")
self.filtering = QtWidgets.QGroupBox(parent=CoverProcessingOptionsPage)
self.filtering.setCheckable(True)
self.filtering.setChecked(False)
self.filtering.setObjectName("filtering")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.filtering)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.width_widget = QtWidgets.QWidget(parent=self.filtering)
self.width_widget.setObjectName("width_widget")
self.horizontalLayout = QtWidgets.QHBoxLayout(self.width_widget)
self.horizontalLayout.setContentsMargins(0, 0, 0, 0)
self.horizontalLayout.setObjectName("horizontalLayout")
self.width_label = QtWidgets.QLabel(parent=self.width_widget)
self.width_label.setObjectName("width_label")
self.horizontalLayout.addWidget(self.width_label)
self.width_value = QtWidgets.QSpinBox(parent=self.width_widget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.width_value.sizePolicy().hasHeightForWidth())
self.width_value.setSizePolicy(sizePolicy)
self.width_value.setMaximum(1000)
self.width_value.setProperty("value", 250)
self.width_value.setObjectName("width_value")
self.horizontalLayout.addWidget(self.width_value)
self.px_label2 = QtWidgets.QLabel(parent=self.width_widget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.px_label2.sizePolicy().hasHeightForWidth())
self.px_label2.setSizePolicy(sizePolicy)
self.px_label2.setObjectName("px_label2")
self.horizontalLayout.addWidget(self.px_label2)
self.verticalLayout_2.addWidget(self.width_widget)
self.height_widget = QtWidgets.QWidget(parent=self.filtering)
self.height_widget.setObjectName("height_widget")
self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.height_widget)
self.horizontalLayout_2.setContentsMargins(0, 0, 0, 0)
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.height_label = QtWidgets.QLabel(parent=self.height_widget)
self.height_label.setObjectName("height_label")
self.horizontalLayout_2.addWidget(self.height_label)
self.height_value = QtWidgets.QSpinBox(parent=self.height_widget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.height_value.sizePolicy().hasHeightForWidth())
self.height_value.setSizePolicy(sizePolicy)
self.height_value.setMaximum(1000)
self.height_value.setProperty("value", 250)
self.height_value.setObjectName("height_value")
self.horizontalLayout_2.addWidget(self.height_value)
self.px_label1 = QtWidgets.QLabel(parent=self.height_widget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Maximum, QtWidgets.QSizePolicy.Policy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.px_label1.sizePolicy().hasHeightForWidth())
self.px_label1.setSizePolicy(sizePolicy)
self.px_label1.setObjectName("px_label1")
self.horizontalLayout_2.addWidget(self.px_label1)
self.verticalLayout_2.addWidget(self.height_widget)
self.verticalLayout.addWidget(self.filtering)
spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding)
self.verticalLayout.addItem(spacerItem)

self.retranslateUi(CoverProcessingOptionsPage)
QtCore.QMetaObject.connectSlotsByName(CoverProcessingOptionsPage)

def retranslateUi(self, CoverProcessingOptionsPage):
CoverProcessingOptionsPage.setWindowTitle(_("Form"))
self.filtering.setTitle(_("Discard images if below the given size"))
self.width_label.setText(_("Width:"))
self.px_label2.setText(_("px"))
self.height_label.setText(_("Height:"))
self.px_label1.setText(_("px"))
2 changes: 1 addition & 1 deletion picard/ui/forms/ui_provider_options_caa.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,5 @@ def retranslateUi(self, CaaOptions):
CaaOptions.setWindowTitle(_("Form"))
self.restrict_images_types.setText(_("Download only cover art images matching selected types"))
self.select_caa_types.setText(_("Select types…"))
self.label.setText(_("Only use images of the following size:"))
self.label.setText(_("Only use images of at most the following size:"))
self.cb_approved_only.setText(_("Download only approved images"))
Loading

0 comments on commit cc38010

Please sign in to comment.