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

Improve resizing options page UI #2520

Merged
merged 6 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions picard/const/cover_processing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2024 Bob Swift
# 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 collections import namedtuple
from enum import IntEnum

from picard.i18n import N_


class ResizeModes(IntEnum):
MAINTAIN_ASPECT_RATIO = 0,
SCALE_TO_WIDTH = 1,
SCALE_TO_HEIGHT = 2,
CROP_TO_FIT = 3,
STRETCH_TO_FIT = 4


CoverResizeMode = namedtuple('CoverResizeMode', ['mode', 'title', 'tooltip'])

COVER_RESIZE_MODES = (
# Items are entered in the order they should appear in the combo box.
# The number is the mode number stored in the settings and may be
# different from the order of appearance in the combo box. This will
# allow modes to be added or removed and re-ordered if required.

CoverResizeMode(ResizeModes.MAINTAIN_ASPECT_RATIO, N_('Maintain aspect ratio'), N_(
"<p>"
"Scale the source image so that it fits within the target dimensions."
"</p><p>"
"One of the final image dimensions may be less than the target dimension if "
"the source image and target dimensions have different aspect ratios."
"</p><p>"
"For example, a 2000x1000 image resized to target dimensions of "
"1000x1000 would result in a final image size of 1000x500."
"</p>"
)),

CoverResizeMode(ResizeModes.SCALE_TO_WIDTH, N_('Scale to width'), N_(
"<p>"
"Scale the width of the source image to the target width while keeping aspect ratio."
"</p><p>"
"For example, a 2000x1000 image resized to a target width of "
"1000 would result in a final image size of 1000x500."
"</p>"
)),

CoverResizeMode(ResizeModes.SCALE_TO_HEIGHT, N_('Scale to height'), N_(
"<p>"
"Scale the height of the source image to the target height while keeping aspect ratio."
"</p><p>"
"For example, a 1000x2000 image resized to a target height of "
"1000 would result in a final image size of 500x1000."
"</p>"
)),

CoverResizeMode(ResizeModes.CROP_TO_FIT, N_('Crop to fit'), N_(
"<p>"
"Scale the source image so that it completely fills the target dimensions "
"in both directions."
"</p><p>"
"If the source image and target dimensions have different aspect ratios"
"then there will be overflow in one direction which will be (center) cropped."
"</p><p>"
"For example, a 500x1000 image resized to target dimensions of "
"1000x1000 would first scale up to 1000x2000, then the excess height "
"would be center cropped resulting in the final image size of 1000x1000."
"</p>"
)),

CoverResizeMode(ResizeModes.STRETCH_TO_FIT, N_('Stretch to fit'), N_(
"<p>"
"Stretch the image to exactly fit the specified dimensions, "
"distorting it if necessary."
"</p><p>"
"For example, a 500x1000 image with target dimension of 1000x1000 "
"would be stretched horizontally resulting in the final image "
"size of 1000x1000."
"</p>"
)),
)

COVER_CONVERTING_FORMATS = ('JPEG', 'PNG', 'WebP', 'TIFF')
6 changes: 5 additions & 1 deletion picard/const/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# Copyright (C) 2020 RomFouq
# Copyright (C) 2021 Gabriel Ferreira
# Copyright (C) 2021 Vladislav Karbovskii
# 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
Expand All @@ -40,6 +41,7 @@
RELEASE_PRIMARY_GROUPS,
RELEASE_SECONDARY_GROUPS,
)
from picard.const.cover_processing import ResizeModes
from picard.const.sys import (
IS_MACOS,
IS_WIN,
Expand Down Expand Up @@ -153,4 +155,6 @@

DEFAULT_COVER_MIN_SIZE = 250
DEFAULT_COVER_MAX_SIZE = 1000
DEFAULT_COVER_CONVERTING_FORMAT = "jpeg"
DEFAULT_COVER_RESIZE_MODE = ResizeModes.MAINTAIN_ASPECT_RATIO

DEFAULT_COVER_CONVERTING_FORMAT = 'JPEG'
4 changes: 2 additions & 2 deletions picard/coverart/processing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,13 @@ def run_image_processors(data, coverartimage):
tags_image = image.copy()
for processor in tags_queue:
processor.run(tags_image, ProcessingTarget.TAGS)
tags_data = tags_image.get_result(default_format=True)
tags_data = tags_image.get_result()
coverartimage.set_tags_data(tags_data)
if config.setting['save_images_to_files']:
file_image = image.copy()
for processor in file_queue:
processor.run(file_image, ProcessingTarget.FILE)
file_data = file_image.get_result(default_format=True)
file_data = file_image.get_result()
coverartimage.set_external_file_data(file_data)
log.debug(
"Image processing for %s finished in %d ms",
Expand Down
97 changes: 36 additions & 61 deletions picard/coverart/processing/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from picard import log
from picard.config import get_config
from picard.const.cover_processing import ResizeModes
from picard.extension_points.cover_art_processors import (
ImageProcessor,
ProcessingTarget,
Expand All @@ -35,86 +36,60 @@ class ResizeImage(ImageProcessor):

def save_to_file(self):
config = get_config()
return config.setting['cover_file_scale_down'] or config.setting['cover_file_scale_up']
return config.setting['cover_file_resize']

def save_to_tags(self):
config = get_config()
return config.setting['cover_tags_scale_down'] or config.setting['cover_tags_scale_up']
return config.setting['cover_tags_resize']

def same_processing(self):
setting = get_config().setting
tags_size = (
setting['cover_tags_resize_target_width'] if setting['cover_tags_resize_use_width'] else 0,
setting['cover_tags_resize_target_height'] if setting['cover_tags_resize_use_height'] else 0
)
file_size = (
setting['cover_file_resize_target_width'] if setting['cover_file_resize_use_width'] else 0,
setting['cover_file_resize_target_height'] if setting['cover_file_resize_use_height'] else 0
)
same_size = tags_size == file_size
tags_direction = (setting['cover_tags_scale_up'], setting['cover_tags_scale_down'])
file_direction = (setting['cover_file_scale_up'], setting['cover_file_scale_down'])
same_direction = tags_direction == file_direction and any(tags_direction)
tags_resize_mode = (setting['cover_tags_stretch'], setting['cover_tags_crop'])
file_resize_mode = (setting['cover_file_stretch'], setting['cover_file_crop'])
same_resize_mode = tags_resize_mode == file_resize_mode
return same_size and same_direction and same_resize_mode
both_resize = setting['cover_tags_resize'] and setting['cover_file_resize']
same_enlarge = setting['cover_tags_enlarge'] == setting['cover_file_enlarge']
same_width = setting['cover_tags_resize_target_width'] == setting['cover_file_resize_target_width']
same_height = setting['cover_tags_resize_target_height'] == setting['cover_file_resize_target_height']
same_resize_mode = setting['cover_tags_resize_mode'] == setting['cover_file_resize_mode']
return both_resize and same_enlarge and same_width and same_height and same_resize_mode

def run(self, image, target):
start_time = time.time()
config = get_config()
if target == ProcessingTarget.TAGS:
scale_up = config.setting['cover_tags_scale_up']
scale_down = config.setting['cover_tags_scale_down']
use_width = config.setting['cover_tags_resize_use_width']
scale_up = config.setting['cover_tags_enlarge']
target_width = config.setting['cover_tags_resize_target_width']
use_height = config.setting['cover_tags_resize_use_height']
target_height = config.setting['cover_tags_resize_target_height']
stretch = config.setting['cover_tags_stretch']
crop = config.setting['cover_tags_crop']
resize_mode = config.setting['cover_tags_resize_mode']
else:
scale_up = config.setting['cover_file_scale_up']
scale_down = config.setting['cover_file_scale_down']
use_width = config.setting['cover_file_resize_use_width']
scale_up = config.setting['cover_file_enlarge']
target_width = config.setting['cover_file_resize_target_width']
use_height = config.setting['cover_file_resize_use_height']
target_height = config.setting['cover_file_resize_target_height']
stretch = config.setting['cover_file_stretch']
crop = config.setting['cover_file_crop']

width_resize = target_width if use_width else image.info.width
height_resize = target_height if use_height else image.info.height
width_scale_factor = width_resize / image.info.width
height_scale_factor = height_resize / image.info.height
use_both_dimensions = use_height and use_width
if use_both_dimensions and not stretch:
if crop:
scale_factor = max(width_scale_factor, height_scale_factor)
else:
scale_factor = min(width_scale_factor, height_scale_factor)
width_scale_factor = scale_factor
height_scale_factor = scale_factor
if (width_scale_factor == 1 and height_scale_factor == 1
or ((width_scale_factor > 1 or height_scale_factor > 1) and not scale_up)
or ((width_scale_factor < 1 or height_scale_factor < 1) and not scale_down)):
resize_mode = config.setting['cover_file_resize_mode']

width_scale_factor = target_width / image.info.width
height_scale_factor = target_height / image.info.height
if resize_mode == ResizeModes.MAINTAIN_ASPECT_RATIO:
scale_factor = min(width_scale_factor, height_scale_factor)
elif resize_mode == ResizeModes.SCALE_TO_WIDTH:
scale_factor = width_scale_factor
elif resize_mode == ResizeModes.SCALE_TO_HEIGHT:
scale_factor = height_scale_factor
else: # crop or stretch
scale_factor = max(width_scale_factor, height_scale_factor)
if scale_factor == 1 or scale_factor > 1 and not scale_up:
# no resizing needed
return

qimage = image.get_result()
if stretch:
scaled_image = qimage.scaled(width_resize, height_resize, Qt.AspectRatioMode.IgnoreAspectRatio)
elif crop:
scaled_image = qimage.scaled(width_resize, height_resize, Qt.AspectRatioMode.KeepAspectRatioByExpanding)
cutoff_width = (scaled_image.width() - width_resize) // 2
cutoff_height = (scaled_image.height() - height_resize) // 2
scaled_image = scaled_image.copy(cutoff_width, cutoff_height, width_resize, height_resize)
else: # keep aspect ratio
if use_both_dimensions:
scaled_image = qimage.scaled(width_resize, height_resize, Qt.AspectRatioMode.KeepAspectRatio)
elif use_width:
scaled_image = qimage.scaledToWidth(width_resize)
else:
scaled_image = qimage.scaledToHeight(height_resize)
qimage = image.get_qimage()
new_width = image.info.width * scale_factor
new_height = image.info.height * scale_factor
if resize_mode == ResizeModes.STRETCH_TO_FIT:
new_width = image.info.width * width_scale_factor
new_height = image.info.height * height_scale_factor
scaled_image = qimage.scaled(int(new_width), int(new_height), Qt.AspectRatioMode.IgnoreAspectRatio)
if resize_mode == ResizeModes.CROP_TO_FIT:
cutoff_width = (scaled_image.width() - target_width) // 2
cutoff_height = (scaled_image.height() - target_height) // 2
scaled_image = scaled_image.copy(cutoff_width, cutoff_height, target_width, target_height)

log.debug(
"Resized cover art from %d x %d to %d x %d in %.2f ms",
Expand Down
10 changes: 5 additions & 5 deletions picard/extension_points/cover_art_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@ def set_result(self, image):
else:
self._qimage = QImage.fromData(image)

def get_result(self, image_format=None, default_format=False, quality=90):
def get_qimage(self):
return self._qimage

def get_result(self, image_format=None, quality=90):
if image_format is None:
if not default_format:
return self._qimage
else:
image_format = self.info.format
image_format = self.info.format
buffer = QBuffer()
if not self._qimage.save(buffer, image_format, quality=quality):
raise CoverArtEncodingError(f"Failed to encode into {image_format}")
Expand Down
19 changes: 7 additions & 12 deletions picard/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
DEFAULT_COVER_IMAGE_FILENAME,
DEFAULT_COVER_MAX_SIZE,
DEFAULT_COVER_MIN_SIZE,
DEFAULT_COVER_RESIZE_MODE,
DEFAULT_CURRENT_BROWSER_PATH,
DEFAULT_DRIVES,
DEFAULT_FPCALC_THREADS,
Expand Down Expand Up @@ -176,24 +177,18 @@
BoolOption('setting', 'filter_cover_by_size', False)
IntOption('setting', 'cover_minimum_width', DEFAULT_COVER_MIN_SIZE)
IntOption('setting', 'cover_minimum_height', DEFAULT_COVER_MIN_SIZE)
BoolOption('setting', 'cover_tags_scale_up', False)
BoolOption('setting', 'cover_tags_scale_down', False)
BoolOption('setting', 'cover_tags_resize_use_width', True)
BoolOption('setting', 'cover_tags_enlarge', False)
BoolOption('setting', 'cover_tags_resize', False)
IntOption('setting', 'cover_tags_resize_target_width', DEFAULT_COVER_MAX_SIZE)
BoolOption('setting', 'cover_tags_resize_use_height', True)
IntOption('setting', 'cover_tags_resize_target_height', DEFAULT_COVER_MAX_SIZE)
BoolOption('setting', 'cover_tags_stretch', False)
BoolOption('setting', 'cover_tags_crop', False)
IntOption('setting', 'cover_tags_resize_mode', DEFAULT_COVER_RESIZE_MODE)
BoolOption('setting', 'cover_tags_convert_images', False)
TextOption('setting', 'cover_tags_convert_to_format', DEFAULT_COVER_CONVERTING_FORMAT)
BoolOption('setting', 'cover_file_scale_up', False)
BoolOption('setting', 'cover_file_scale_down', False)
BoolOption('setting', 'cover_file_resize_use_width', True)
BoolOption('setting', 'cover_file_enlarge', False)
BoolOption('setting', 'cover_file_resize', False)
IntOption('setting', 'cover_file_resize_target_width', DEFAULT_COVER_MAX_SIZE)
BoolOption('setting', 'cover_file_resize_use_height', True)
IntOption('setting', 'cover_file_resize_target_height', DEFAULT_COVER_MAX_SIZE)
BoolOption('setting', 'cover_file_stretch', False)
BoolOption('setting', 'cover_file_crop', False)
IntOption('setting', 'cover_file_resize_mode', DEFAULT_COVER_RESIZE_MODE)
BoolOption('setting', 'cover_file_convert_images', False)
TextOption('setting', 'cover_file_convert_to_format', DEFAULT_COVER_CONVERTING_FORMAT)

Expand Down
Loading
Loading