The implementation is not documented as it's not considered to be important to the normal developer. The API is not fully exposed (as there's usually no need for that), and for the same reason, the implementation is written for optimization, rather than "dev usability".
Also, consider that most color transformations are done using the RGB color model: HSV, HSL, etc are alternate color models that are normally intended for different requirements.
The problem in finding how the colorize effect actually works is that it uses lots of internal functions and private classes; while you can use a smart code browser (like the one provided by woboq), some functions are created and accessed dynamically within the code, making their research quite difficult. You can usually easily access functions and definitions that are publicly available in the API (such as the basic implementation of a QGraphicsEffect), but finding out what they actually do is quite another story.
First of all, QGraphicsEffects classes must implement a draw()
function, but graphics effects normally use advanced painting functions that are not part of the public API.
After some research, I can tell you how it works:
- QGraphicsColorizeEffect uses an internal class (see it in the woboq code browser) that has a private QPixmapColorizeFilter (woboq);
- the graphics effect calls the
draw()
function of the filter;
- the filter creates a gray scale pixmap of the source;
- it sets the
CompositionMode_Screen
on the painter for that pixmap (more on this later);
- fills the pixmap with the color of the effect, using the
strength()
as opacity for the painter, thus creating the "colorize" effect due to the composition mode;
Now, how can we do this on our own?
Considering your example, we can implement that with a single color, and we need two functions:
- transform the color into a gray scale;
- blend the gray with the new tint;
The first function is quite simple, there are various ways of doing it (see this related post), but Qt uses this simple formula:
def toGray(r, g, b): # values are within the 0-255 range
return (r * 11 + g * 16 + b * 5) / 32
Then the blending is done using the CompositionMode_Screen
, which the documentation explains:
The source and destination colors are inverted and then multiplied. Screening a color with white produces white, whereas screening a color with black leaves the color unchanged.
How it actually does that is a bit difficult to find, as compositions are helper function accessed "by attribute" (I believe); the Screen
composition works like this (woboq):
def blend(a, b):
return 255 - ((255 - a) * (255 - b) >> 8)
# └invert | └invert
# └multiply
r1, g1, b1, _ = color1.getRgb()
r2, g2, b2, _ = color2.getRgb()
result = QColor(blend(r1, r2), blend(g1, g2), blend(b1, b2))
Considering the above, we can get more or less the correct result:
def toGray(r, g, b):
return (r * 11 + g * 16 + b * 5) / 32
def blend(a, b):
return 255 - ((255 - a) * (255 - b) >> 8)
base_hsv = (246, 134, 168)
base_color = QColor.fromHsv(base_hsv[0], base_hsv[1], base_hsv[2])
tint = (120, 128, 128)
tint_color = QColor.fromHsv(tint[0], tint[1], tint[2])
gray = round(toGray(*base_color.getRgb()[:3]))
r, g, b, _ = tint_color.getRgb()
res_color = QColor(blend(gray, r), blend(gray, g), blend(gray, b))
# ...
color = image.pixelColor(0, 0)
print("Final Color: ({}, {}, {})".format(*color.getHsv())
print("Computed Color: ({}, {}, {})".format(*res_color.getHsv())
The above, based on your colors, results in the following:
Final Color: (120, 58, 176)
Computed Color: (120, 59, 177)
Not perfect (probably due to rounding issues), but close enough.
But there's another issue: the effect also supports the strength
property.
As said above, Qt does that by setting the opacity of the painter when drawing the source gray scale. But if we want to compute the color, that's not a valid solution: we want to compute the final color, not get its result after it's being painted.
In order to know the actual final result of the effect, we need to tweak the blend()
function a bit, considering the original color:
def blend(a, b, base, strength):
value = 255 - ((255 - int(a)) * (255 - int(b)) >> 8)
diff = value - base
return base + diff * strength
The above will compute the blended component as done before, but then uses the difference between that and the source, and returns the sum of the source plus the difference multiplied by the strength ratio.
The result is still not perfect in integer values, but quite close to the result.
In order to clarify all the above, here is an example that show how it works, allowing color changes and strength factors, and finally comparing the resulting "colorized" value and the computed one:
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
def blend(a, b, base=None, strength=1.):
value = 255 - ((255 - a) * (255 - b) >> 8)
diff = value - base
return (base or a) + diff * strength
class ColorButton(QPushButton):
colorChanged = pyqtSignal(QColor)
_color = QColor()
def __init__(self, text, h, s, v):
super().__init__(text)
self.setColor(QColor.fromHsv(h, s, v))
self.clicked.connect(self.changeColor)
def changeColor(self):
color = QColorDialog.getColor(self._color, self, self.text())
if color.isValid():
self.setColor(color)
def color(self):
return self._color
def setColor(self, color):
if self._color != color and color.isValid():
self._color = QColor(color)
pm = QPixmap(32, 32)
pm.fill(color)
self.setIcon(QIcon(pm))
self.colorChanged.emit(QColor(color))
class ColorizeTest(QWidget):
def __init__(self):
super().__init__()
layout = QVBoxLayout(self)
tools = QHBoxLayout()
layout.addLayout(tools)
self.srcButton = ColorButton('Source', 246, 134, 168)
self.efxButton = ColorButton('Effect', 120, 128, 128)
self.strengthSlider = QSlider(Qt.Horizontal, maximum=100)
self.strengthSlider.setValue(100)
tools.addWidget(self.srcButton)
tools.addWidget(self.efxButton)
tools.addWidget(self.strengthSlider)
tools.addStretch()
self.scene = QGraphicsScene()
self.srcItem = QGraphicsRectItem(0, 0, 100, 100)
self.efxItem = QGraphicsRectItem(0, 0, 100, 100)
self.compItem = QGraphicsRectItem(0, 0, 100, 100)
for i in self.srcItem, self.efxItem, self.compItem:
i.setPen(QPen(Qt.NoPen))
self.scene.addItem(i)
self.efxItem.setX(self.srcItem.sceneBoundingRect().right())
self.compItem.setX(self.efxItem.sceneBoundingRect().right())
self.effect = QGraphicsColorizeEffect()
self.efxItem.setGraphicsEffect(self.effect)
self.view = QGraphicsView(self.scene)
layout.addWidget(self.view)
self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
labels = QHBoxLayout()
layout.addLayout(labels)
self.srcLabel = QLabel()
self.efxLabel = QLabel()
self.compLabel = QLabel()
for l in self.srcLabel, self.efxLabel, self.compLabel:
labels.addWidget(l)
l.setAlignment(Qt.AlignCenter)
self.srcButton.colorChanged.connect(self.updateColors)
self.efxButton.colorChanged.connect(self.updateColors)
self.strengthSlider.valueChanged.connect(self.updateColors)
def updateColors(self):
src = self.srcButton.color()
tint = self.efxButton.color()
strength = self.strengthSlider.value() * .01
self.srcLabel.setText('{}, {}, {}'.format(*src.getHsv()))
self.srcItem.setBrush(QBrush(src))
self.efxItem.setBrush(QBrush(src))
self.effect.setColor(tint)
self.effect.setStrength(strength)
center = self.view.mapFromScene(
self.efxItem.sceneBoundingRect().center())
pm = self.view.viewport().grab(QRect(center, QSize(1, 1)))
pixelColor = pm.toImage().pixelColor(0, 0)
self.efxLabel.setText('{}, {}, {}'.format(*pixelColor.getHsv()))
sr, sg, sb, _ = src.getRgb()
gray = round((sr * 11 + sg * 16 + sb * 5) / 32)
er, eg, eb, _ = tint.getRgb()
comp = QColor(
blend(gray, er, sr, strength),
blend(gray, eg, sg, strength),
blend(gray, eb, sb, strength))
self.compItem.setBrush(QBrush(comp))
self.compLabel.setText('{}, {}, {}'.format(*comp.getHsv()))
def showEvent(self, event):
super().showEvent(event)
self.updateColors()
def resizeEvent(self, event):
super().resizeEvent(event)
QTimer.singleShot(0, lambda:
self.view.fitInView(self.scene.itemsBoundingRect()))
app = QApplication([])
test = ColorizeTest()
test.show()
app.exec()
Finally, the above obviously doesn't consider the alpha channel of the source or the effect colors: the final resulting color can only depend on what the item is being painted on. Also, remember that the grab
function can only consider the Qt context, if you're using transparency there is absolutely no way to know the exact result, unless you can access the OS capabilities: considering that, there's really no point in doing all this efforts, just grab a screenshot and get the pixel.