mirror of
https://github.com/rbreu/beeref.git
synced 2026-03-11 08:54:28 +00:00
Show image's color gamut
This commit is contained in:
parent
3cefa4dbc3
commit
3fafc16e35
8 changed files with 281 additions and 1 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ menu_structure = [
|
|||
'items': [
|
||||
'change_opacity',
|
||||
'grayscale',
|
||||
MENU_SEPARATOR,
|
||||
'show_color_gamut',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
140
beeref/widgets/color_gamut.py
Normal file
140
beeref/widgets/color_gamut.py
Normal 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()
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
61
tests/widgets/test_color_gamut.py
Normal file
61
tests/widgets/test_color_gamut.py
Normal 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
|
||||
Loading…
Reference in a new issue