Show image's color gamut

This commit is contained in:
Rebecca Breu 2023-12-01 20:58:46 +01:00
parent 3cefa4dbc3
commit 3fafc16e35
8 changed files with 281 additions and 1 deletions

View file

@ -265,6 +265,12 @@ actions = ActionList([
'callback': 'on_action_grayscale',
'group': 'active_when_selection',
}),
Action({
'id': 'show_color_gamut',
'text': 'Show &Color Gamut',
'callback': 'on_action_show_color_gamut',
'group': 'active_when_single_image',
}),
Action({
'id': 'crop',
'text': '&Crop',

View file

@ -108,6 +108,8 @@ menu_structure = [
'items': [
'change_opacity',
'grayscale',
MENU_SEPARATOR,
'show_color_gamut',
],
},
{

View file

@ -17,6 +17,8 @@
text).
"""
from collections import defaultdict
from functools import cached_property
import logging
from PyQt6 import QtCore, QtGui, QtWidgets
@ -211,6 +213,41 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
item.crop = self.crop
return item
@cached_property
def color_gamut(self):
logger.debug(f'Calculating color gamut for {self}')
gamut = defaultdict(int)
img = self.pixmap().toImage()
# Don't evaluate every pixel for larger images:
step = max(1, int(max(img.width(), img.height()) / 1000))
logger.debug(f'Considering every {step}. row/column')
# Not actually faster than solution below :(
# ptr = img.bits()
# size = img.sizeInBytes()
# pixelsize = int(img.sizeInBytes() / img.width() / img.height())
# ptr.setsize(size)
# for pixel in batched(ptr, n=pixelsize):
# r, g, b, alpha = tuple(map(ord, pixel))
# if 5 < alpha and 5 < r < 250 and 5 < g < 250 and 5 < b < 250:
# # Only consider pixels that aren't close to
# # transparent, white or black
# rgb = QtGui.QColor(r, g, b)
# gamut[rgb.hue(), rgb.saturation()] += 1
for i in range(0, img.width(), step):
for j in range(0, img.height(), step):
rgb = img.pixelColor(i, j)
rgbtuple = (rgb.red(), rgb.blue(), rgb.green())
if (5 < rgb.alpha()
and min(rgbtuple) < 250 and max(rgbtuple) > 5):
# Only consider pixels that aren't close to
# transparent, white or black
gamut[rgb.hue(), rgb.saturation()] += 1
logger.debug(f'Got {len(gamut)} color gamut values')
return gamut
def copy_to_clipboard(self, clipboard):
clipboard.setPixmap(self.pixmap())

View file

@ -329,6 +329,9 @@ class BeeGraphicsView(MainControlsMixin,
self.undo_stack.push(commands.ResetTransforms(
self.scene.selectedItems(user_only=True)))
def on_action_show_color_gamut(self):
widgets.color_gamut.GamutDialog(self, self.scene.selectedItems()[0])
def on_items_loaded(self, value):
logger.debug('On items loaded: add queued items')
self.scene.add_queued_items()

View file

@ -21,7 +21,7 @@ from PyQt6.QtCore import Qt
from beeref import constants, commands
from beeref.config import logfile_name
from beeref.widgets import settings, welcome_overlay # noqa: F401
from beeref.widgets import settings, welcome_overlay, color_gamut # noqa: F401
logger = logging.getLogger(__name__)

View file

@ -0,0 +1,140 @@
# This file is part of BeeRef.
#
# BeeRef 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 3 of the License, or
# (at your option) any later version.
#
# BeeRef 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 BeeRef. If not, see <https://www.gnu.org/licenses/>.
import logging
import math
from PyQt6 import QtWidgets, QtGui, QtCore
from PyQt6.QtCore import Qt
logger = logging.getLogger(__name__)
class GamutPainterThread(QtCore.QThread):
"""Dedicated thread for drawing the gamut image."""
finished = QtCore.pyqtSignal(QtGui.QImage)
radius = 250
def __init__(self, parent, item):
super().__init__()
self.item = item
self.parent = parent
def run(self):
logger.debug('Start drawing gamut image...')
self.image = QtGui.QImage(
QtCore.QSize(2 * self.radius, 2 * self.radius),
QtGui.QImage.Format.Format_ARGB32)
self.image.fill(QtGui.QColor(0, 0, 0, 0))
painter = QtGui.QPainter(self.image)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
painter.setBrush(QtGui.QBrush(QtGui.QColor(0, 0, 0)))
painter.setPen(Qt.PenStyle.NoPen)
center = QtCore.QPoint(self.radius, self.radius)
painter.drawEllipse(center, self.radius, self.radius)
logger.debug(f'Threshold: {self.parent.threshold}')
for (hue, saturation), count in self.item.color_gamut.items():
if count < self.parent.threshold:
continue
hypotenuse = saturation / 255 * self.radius
angle = math.radians(-90 - hue)
x = int(math.sin(angle) * hypotenuse) + center.x()
y = int(math.cos(angle) * hypotenuse) + center.y()
color = QtGui.QColor()
color.setHsv(hue, saturation, 255)
painter.setBrush(QtGui.QBrush(color))
painter.drawEllipse(QtCore.QPoint(x, y), 3, 3)
logger.debug('Finished drawing gamut image.')
self.finished.emit(self.image)
class GamutWidget(QtWidgets.QWidget):
def __init__(self, parent, item):
super().__init__(parent)
self.item = item
self.image = None
self.worker = GamutPainterThread(self, item)
self.worker.finished.connect(self.on_gamut_finished)
self.worker.start()
@property
def threshold(self):
return self.parent().threshold_input.value()
def on_gamut_finished(self, image):
logger.debug('Gamut image update received')
self.image = image
self.update()
def minimumSizeHint(self):
return QtCore.QSize(200, 200)
def update_values(self):
self.worker.start()
def paintEvent(self, event):
painter = QtGui.QPainter(self)
painter.setRenderHint(painter.RenderHint.SmoothPixmapTransform)
if self.image:
size = min(self.size().width(), self.size().height())
x = max((self.size().width() - size) / 2, 0)
y = max((self.size().height() - size) / 2, 0)
painter.drawImage(QtCore.QRectF(x, y, size, size), self.image)
else:
painter.drawText(10, 20, 'Counting pixels...')
class GamutDialog(QtWidgets.QDialog):
def __init__(self, parent, item):
super().__init__(parent)
self.item = item
self.setWindowTitle('Color Gamut')
# The input controls on the right
controls_layout = QtWidgets.QVBoxLayout()
label = QtWidgets.QLabel('Threshold:', self)
controls_layout.addWidget(label)
self.threshold_input = QtWidgets.QSlider(self)
self.threshold_input.setRange(0, 500)
self.threshold_input.setValue(20)
self.threshold_input.setTracking(False)
self.threshold_input.valueChanged.connect(self.on_value_changed)
controls_layout.addWidget(
self.threshold_input, alignment=Qt.AlignmentFlag.AlignHCenter)
buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
controls_layout.addWidget(buttons)
# The gamut display
layout = QtWidgets.QHBoxLayout()
self.setLayout(layout)
self.gamut_widget = GamutWidget(self, item)
layout.addWidget(self.gamut_widget, stretch=1)
layout.addLayout(controls_layout, stretch=0)
self.show()
def on_value_changed(self, value):
self.gamut_widget.update_values()

View file

@ -377,6 +377,37 @@ def test_create_copy(qapp, imgfilename3x3):
assert copy.grayscale is True
def test_color_gamut_finds_colors(qapp):
img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32)
img.fill(QtGui.QColor(0, 0, 0))
img.setPixelColor(1, 1, QtGui.QColor(255, 0, 0))
img.setPixelColor(5, 5, QtGui.QColor(0, 255, 0))
img.setPixelColor(5, 6, QtGui.QColor(0, 50, 0))
item = BeePixmapItem(img, 'foo.png')
assert item.color_gamut == {(0, 255): 1, (120, 255): 2}
def test_color_gamut_ignores_almost_black(qapp):
img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32)
img.fill(QtGui.QColor(3, 3, 3))
item = BeePixmapItem(img, 'foo.png')
assert item.color_gamut == {}
def test_color_gamut_ignores_almost_white(qapp):
img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32)
img.fill(QtGui.QColor(253, 253, 253))
item = BeePixmapItem(img, 'foo.png')
assert item.color_gamut == {}
def test_color_gamut_ignores_almost_transparent(qapp):
img = QtGui.QImage(10, 10, QtGui.QImage.Format.Format_ARGB32)
img.fill(QtGui.QColor(255, 0, 0, 3))
item = BeePixmapItem(img, 'foo.png')
assert item.color_gamut == {}
def test_copy_to_clipboard(qapp, imgfilename3x3):
clipboard = QtWidgets.QApplication.clipboard()
item = BeePixmapItem(QtGui.QImage(imgfilename3x3), 'foo.png')

View file

@ -0,0 +1,61 @@
from unittest.mock import MagicMock
from PyQt6 import QtGui
from beeref.items import BeePixmapItem
from beeref.widgets.color_gamut import (
GamutDialog,
GamutPainterThread,
GamutWidget,
)
def test_gamut_painter_thread_generates_image(view, imgfilename3x3):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
dialog = GamutDialog(view, item)
dialog.threshold_input.setValue(0)
widget = GamutWidget(dialog, item)
worker = GamutPainterThread(widget, item)
mock = MagicMock()
worker.finished.connect(mock)
worker.run()
mock.assert_called_once()
image = mock.call_args[0][0]
assert image.size().width() == 500
assert image.size().height() == 500
assert image.allGray() is False
def test_gamut_painter_thread_generates_image_below_threshold(
view, imgfilename3x3):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
dialog = GamutDialog(view, item)
dialog.threshold_input.setValue(20)
widget = GamutWidget(dialog, item)
worker = GamutPainterThread(widget, item)
mock = MagicMock()
worker.finished.connect(mock)
worker.run()
mock.assert_called_once()
image = mock.call_args[0][0]
assert image.size().width() == 500
assert image.size().height() == 500
assert image.allGray() is True
def test_gamut_widget_generates_image(view, imgfilename3x3, qtbot):
item = BeePixmapItem(QtGui.QImage(imgfilename3x3))
view.scene.addItem(item)
dialog = GamutDialog(view, item)
dialog.threshold_input.setValue(0)
widget = GamutWidget(dialog, item)
assert widget.image is None
widget.show()
qtbot.waitUntil(lambda: widget.image is not None)
assert widget.image.size().width() == 500
assert widget.image.size().height() == 500
assert widget.image.allGray() is False