Skip to content

Commit

Permalink
Show image's color gamut
Browse files Browse the repository at this point in the history
  • Loading branch information
rbreu committed Jan 17, 2024
1 parent 3cefa4d commit 3fafc16
Show file tree
Hide file tree
Showing 8 changed files with 281 additions and 1 deletion.
6 changes: 6 additions & 0 deletions beeref/actions/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions beeref/actions/menu_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@
'items': [
'change_opacity',
'grayscale',
MENU_SEPARATOR,
'show_color_gamut',
],
},
{
Expand Down
37 changes: 37 additions & 0 deletions beeref/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
text).
"""

from collections import defaultdict
from functools import cached_property
import logging

from PyQt6 import QtCore, QtGui, QtWidgets
Expand Down Expand Up @@ -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())

Expand Down
3 changes: 3 additions & 0 deletions beeref/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion beeref/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
140 changes: 140 additions & 0 deletions beeref/widgets/color_gamut.py
Original file line number Diff line number Diff line change
@@ -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()
31 changes: 31 additions & 0 deletions tests/items/test_pixmapitem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
61 changes: 61 additions & 0 deletions tests/widgets/test_color_gamut.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 3fafc16

Please sign in to comment.