From 3fafc16e35071ecce3d61fcf96162667f4db79df Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Fri, 1 Dec 2023 20:58:46 +0100 Subject: [PATCH] Show image's color gamut --- beeref/actions/actions.py | 6 ++ beeref/actions/menu_structure.py | 2 + beeref/items.py | 37 ++++++++ beeref/view.py | 3 + beeref/widgets/__init__.py | 2 +- beeref/widgets/color_gamut.py | 140 ++++++++++++++++++++++++++++++ tests/items/test_pixmapitem.py | 31 +++++++ tests/widgets/test_color_gamut.py | 61 +++++++++++++ 8 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 beeref/widgets/color_gamut.py create mode 100644 tests/widgets/test_color_gamut.py diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index 08da46a..195eca9 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -265,6 +265,12 @@ def __getitem__(self, key): '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', diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index cec0766..97d3a65 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -108,6 +108,8 @@ 'items': [ 'change_opacity', 'grayscale', + MENU_SEPARATOR, + 'show_color_gamut', ], }, { diff --git a/beeref/items.py b/beeref/items.py index 1750021..31a67cc 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -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 @@ def create_copy(self): 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()) diff --git a/beeref/view.py b/beeref/view.py index b223168..9b05072 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -329,6 +329,9 @@ def on_action_reset_transforms(self): 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() diff --git a/beeref/widgets/__init__.py b/beeref/widgets/__init__.py index 1f98497..4b51c11 100644 --- a/beeref/widgets/__init__.py +++ b/beeref/widgets/__init__.py @@ -21,7 +21,7 @@ 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__) diff --git a/beeref/widgets/color_gamut.py b/beeref/widgets/color_gamut.py new file mode 100644 index 0000000..b3ff889 --- /dev/null +++ b/beeref/widgets/color_gamut.py @@ -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 . + +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() diff --git a/tests/items/test_pixmapitem.py b/tests/items/test_pixmapitem.py index 1d2063f..b484474 100644 --- a/tests/items/test_pixmapitem.py +++ b/tests/items/test_pixmapitem.py @@ -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') diff --git a/tests/widgets/test_color_gamut.py b/tests/widgets/test_color_gamut.py new file mode 100644 index 0000000..410e383 --- /dev/null +++ b/tests/widgets/test_color_gamut.py @@ -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