Is widely used porter-duff formula for source-over on non-premultiplied (straight) alpha wrong? - colors

I am looking for a formula to draw rgba color over rgba background, and it seems to me, that widely used formula for non-premultiplied alpha has an unexpected side-effect when both the underlying (background) and source (foreground) is non-opaque.
The formula, as cited eg. in Win2D for WinUI3 is
To perform a source-over blend between two colors that use straight alpha format:
result = (source.RGB * source.A) + (dest.RGB * (1 - source.A))
However, if we are blending eg. partially transparent blue over a red color with zero alpha, the red color will show up in the result.
destination = rgba(1, 0, 0, 0)
source = rgba(0, 0, 1, 0.5)
resultRed = (source.R * source.A) + (dest.R * (1 - source.A)) = (0 * 0.5) + (1 * (1 - 0.5)) = 0 + 0.5 = 0.5 - the result has a strong red component, even though we are blending blue over 'empty' image.
I haven't found any source that would discuss this or offer alternative formula, short of premultiplying the source colors, doing the porter-duff src-over, and then trying to convert back from premultiplied space, which is a lossy and slow operation.
Do you have a solution for a more natural blend (eg. that would produce partially transparent blue in the above case) operation in non-premultiplied space?

Related

QColor hsl/hsv representaion is wrong?

I'm trying to change color space of given image by using PyQt. I can't understand how QColor works.
Speaking about HSV we have 3 channels: H - from 0 to 359, S - from 0 to 100, V - from 0 to 100. But in documentation:
The value of s, v, and a must all be in the range 0-255; the value of h must be in the range 0-359.
How can be S and V values be in range 0-255? The same question is about HSL, where S and L should be in range 0-100
The value of s, l, and a must all be in the range 0-255; the value of h must be in the range 0-359.
And one more question. Should be the image, converted from rgb to hsl / rgb to hsv look the same and has the same colors?
Speaking about HSV we have 3 channels: H - from 0 to 359, S - from 0 to 100, V - from 0 to 100.
That's just a common convention, but it's not part of the HSV color space definition, nor its "parent" HSL from which it origined.
Those values are always intended as a range between a minimum and a maximum, not a discrete-based value range.
First of all, they both are alternative representations of the RGB color model.[1]
Then, colors are not discrete, our "digital usage" forces us to make them so, and their value range is completely arbitrary.
The commonly used RGB model is based on 8 bits for each primary color (providing a 256 value range for each of them, from 0 to 255), but even if it's normally fine for most usage, it's actually limited, especially when shown in a video or animation: in some cases (notably, with gradients), even a value change of 1 in a component can be clearly seen.
Color model representations in digital world commonly use discrete integer values of color spaces using limited ranges for performance reasons, and that's also valid for the values you're referring to. The range depends on the implementation.
For instance, the CSS rgb() notation accepts values with the 8-bit notation and percentages. Those values are almost never consistent, and for obvious reasons: while the theoretical range is of 256 values, the range of a percentage always refers to the maximum (255), meaning that 50% (or 0.5) is actually 127.5.
In fact, rgb(50%, 50%, 50%) normally results in #808080, which is rgb(128, 128, 128) (since 127.5 is rounded), meaning that rgb(50%, 50%, 50%) and rgb(128, 128, 128) are not the same, conceptually speaking.[2]
So, to the point, the value range only depends on the implementation. The only difference is that the hue component is wrapping because it's based on a circle, meaning that it always truly is a 0-360 range value: 50% (or 0.5) will always be 180 degrees, and that's because the maximum (360°, or 100%) equals the minimum (0).
Qt chose to use a 8-bit standard (0-255) for integer values that, following convention, use 0-255 or percentage ranges, with the exception of the hue component that uses the common 360 degrees notation.
If you want something more consistent with your habits, then you can add it with a simple helper function, but remember that, as the documentation explains, "components are stored using 16-bit integers" (note that this is still valid even for Qt6[3]), meaning that results might slightly differ.
def fromHsv100(*args, alpha=None):
if isinstance(args[0], QColor):
args = args[1:]
h, s, v = args[:3]
if alpha is None:
if len(args) == 4:
alpha = args[3]
else:
alpha = 100
return QColor.fromHsvF(
(h / 360) % 1,
(s * .01) % 1,
(v * .01) % 1,
(alpha * .01) % 1
)
def getHsv100(color):
return (
color.hue(),
round(color.saturationF() * 100),
round(color.valueF() * 100),
round(color.alphaF() * 100)
)
QColor.fromHsv100 = fromHsv100
QColor.getHsv100 = getHsv100
# usage:
color = QColor.fromHsv100(120, 55, 89)
print(color.getHsv100())
Finally, remember that, due to the nature of hue-based color models, you can create different colors that are always shown as "black" if their value (for HSV) or lightness (for HSL) component is 0, while they can have different hue and saturation values:
>> print(QColor.fromHsv(60, 0, 0).name())
#000000
>> print(QColor.fromHsv(240, 50, 0).name())
#000000
About your last question, since HSL and HSV are just alternative representations of the RGB color model, an image created with any of the above will theoretically look the same as long as it uses the same color space, and as long as the resulting integer values of the colors are compatible and rounded in the same way. But, since those values are always rounded based on their ranges, and those ranges are proportional to the actual model (which is not consistent for obvious reasons), that might not always happen.
For instance:
>>> hue = 290
>>> rgb = QColor.fromHsv(hue, 150, 150).getRgb()
>>> print(rgb)
(135, 62, 150, 255)
>>> newHue = QColor.fromRgb(*rgb).hue()
>>> print(hue == newHue, hue, newHue)
False 290 289
This means that if you create or edit images using multiple conversions between different color spaces, you might end up with images that are not actually identical.
[1] See the related Wikipedia article
[2] Actual values of the resulting 24-bit RGB (which, as of late 2022, is the final color shown by a non-HDR browser/system) might depend on the browser and its rounding implementation; note that rounding is not always consistent, for instance, Python uses the Round half to even (aka, the "bankers' rounding") method for round(), meaning that both 127.5 and 128.5 are rounded to 128.
[3] Even if most modern devices support wider color dynamic ranges, QColor is intended for basic, performant behavior, since it's used in a lot of basic classes that expect fast results, like displaying labels, buttons or texts of items in a model view; things for which such dynamic ranges are quite pointless.

Transformed colors when painting semi-transparent in p5.js

A transformation seems to be applied when painting colors in p5.js with an alpha value lower than 255:
for (const color of [[1,2,3,255],[1,2,3,4],[10,11,12,13],[10,20,30,40],[50,100,200,40],[50,100,200,0],[50,100,200,1]]) {
clear();
background(color);
loadPixels();
print(pixels.slice(0, 4).join(','));
}
Input/Expected Output Actual Output (Firefox)
1,2,3,255 1,2,3,255 ✅
1,2,3,4 0,0,0,4
10,11,12,13 0,0,0,13
10,20,30,40 6,19,25,40
50,100,200,40 51,102,204,40
50,100,200,0 0,0,0,0
50,100,200,1 0,0,255,1
The alpha value is preserved, but the RGB information is lost, especially on low alpha values.
This makes visualizations impossible where, for example, 2D shapes are first drawn and then the visibility in certain areas is animated by changing the alpha values.
Can these transformations be turned off or are they predictable in any way?
Update: The behavior is not specific to p5.js:
const ctx = new OffscreenCanvas(1, 1).getContext('2d');
for (const [r,g,b,a] of [[1,2,3,255],[1,2,3,4],[10,11,12,13],[10,20,30,40],[50,100,200,40],[50,100,200,0],[50,100,200,1]]) {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = `rgba(${r},${g},${b},${a/255})`;
ctx.fillRect(0, 0, 1, 1);
console.log(ctx.getImageData(0, 0, 1, 1).data.join(','));
}
I could be way off here...but it looks like internally that in the background method if _isErasing is true then blendMode is called. By default this will apply a linear interpolation of colours.
See https://github.com/processing/p5.js/blob/9cd186349cdb55c5faf28befff9c0d4a390e02ed/src/core/p5.Renderer2D.js#L45
See https://p5js.org/reference/#/p5/blendMode
BLEND - linear interpolation of colours: C = A*factor + B. This is the
default blending mode.
So, if you set the blend mode to REPLACE I think it should work.
REPLACE - the pixels entirely replace the others and don't utilize
alpha (transparency) values.
i.e.
blendMode(REPLACE);
for (const color of [[1,2,3,255],[1,2,3,4],[10,11,12,13],[10,20,30,40],[50,100,200,40],[50,100,200,0],[50,100,200,1]]) {
clear();
background(color);
loadPixels();
print(pixels.slice(0, 4).join(','));
}
Internally, the HTML Canvas stores colors in a different way that cannot preserve RGB values when fully transparent. When writing and reading pixel data, conversions take place that are lossy due to the representation by 8-bit numbers.
Take for example this row from the test above:
Input/Expected Output Actual Output
10,20,30,40 6,19,25,40
IN (conventional alpha)
R
G
B
A
values
10
20
30
40 (= 15.6%)
Interpretation: When painting, add 15.6% of (10,20,30) to the 15.6% darkened (r,g,b) background.
Canvas-internal (premultiplied alpha)
R
G
B
A
R
G
B
A
calculation
10 * 0.156
20 * 0.156
30 * 0.156
40 (= 15.6%)
values
1.56
3.12
4.7
40
values (8-bit)
1
3
4
40
Interpretation: When painting, add (1,3,4) to the 15.6% darkened (r,g,b) background.
Premultiplied alpha allows faster painting and supports additive colors, that is, adding color values without darkening the background.
OUT (conventional alpha)
R
G
B
A
calculation
1 / 0.156
3 / 0.156
4 / 0.156
40
values
6.41
19.23
25.64
40
values (8-bit)
6
19
25
40
So the results are predictable, but due to the different internal representation, the transformation cannot be turned off.
The HTML specification explicitly mentions this in section 4.12.5.1.15 Pixel manipulation:
Due to the lossy nature of converting between color spaces and converting to and from premultiplied alpha color values, pixels that have just been set using putImageData(), and are not completely opaque, might be returned to an equivalent getImageData() as different values.
see also 4.12.5.7 Premultiplied alpha and the 2D rendering context

How to get colors with the same perceived brightness?

Is there a tool / program / color system that enables you to get colors of the same luminance (perceived brightness)?
Say I pick a color (determine RGB values) and the program gives me all the colors around the color wheel with the same luminance but different hues?
I haven't seen such tool yet, all I came across were three different algorithms for color luminance:
(0.2126*R) + (0.7152*G) + (0.0722*B)
(0.299*R + 0.587*G + 0.114*B)
sqrt( 0.241*R^2 + 0.691*G^2 + 0.068*B^2 )
Just to be clear, I'm talking about color luminance / perceived brightness or whatever you want to call it - the attribute that encounters that we perceive red hue brighter than blue for example. (So 255,0,0 has higher luminance value than 0,0,255.)
P.S.: Does anyone know which algorithm is used to determine color luminence on this website: http://www.workwithcolor.com/hsl-color-picker-01.htm
It looks like they used none of the posted algorithms.
In the HSL color picker you linked to, it looks like they are using the 3rd Lightness equation given here, and then making it a percentage. So the equation is:
L = (100 * 0.5 * (max(r,g,b) + min(r,g,b))) / 255
Edit: Actually, I just realized that they have an L value and a Lum value shown on that color picker. The equation above applies to the L value, but I don't know how they are arriving at the Lum value. It doesn't seem to follow any of the standard equations.

Given an RGB value, how do I create a tint (or shade)?

Given an RGB value, like 168, 0, 255, how do I create tints (make it lighter) and shades (make it darker) of the color?
Among several options for shading and tinting:
For shades, multiply each component by 1/4, 1/2, 3/4, etc., of its
previous value. The smaller the factor, the darker the shade.
For tints, calculate (255 - previous value), multiply that by 1/4,
1/2, 3/4, etc. (the greater the factor, the lighter the tint), and add that to the previous value (assuming each.component is a 8-bit integer).
Note that color manipulations (such as tints and other shading) should be done in linear RGB. However, RGB colors specified in documents or encoded in images and video are not likely to be in linear RGB, in which case a so-called inverse transfer function needs to be applied to each of the RGB color's components. This function varies with the RGB color space. For example, in the sRGB color space (which can be assumed if the RGB color space is unknown), this function is roughly equivalent to raising each sRGB color component (ranging from 0 through 1) to a power of 2.2. (Note that "linear RGB" is not an RGB color space.)
See also Violet Giraffe's comment about "gamma correction".
Some definitions
A shade is produced by "darkening" a hue or "adding black"
A tint is produced by "ligthening" a hue or "adding white"
Creating a tint or a shade
Depending on your Color Model, there are different methods to create a darker (shaded) or lighter (tinted) color:
RGB:
To shade:
newR = currentR * (1 - shade_factor)
newG = currentG * (1 - shade_factor)
newB = currentB * (1 - shade_factor)
To tint:
newR = currentR + (255 - currentR) * tint_factor
newG = currentG + (255 - currentG) * tint_factor
newB = currentB + (255 - currentB) * tint_factor
More generally, the color resulting in layering a color RGB(currentR,currentG,currentB) with a color RGBA(aR,aG,aB,alpha) is:
newR = currentR + (aR - currentR) * alpha
newG = currentG + (aG - currentG) * alpha
newB = currentB + (aB - currentB) * alpha
where (aR,aG,aB) = black = (0,0,0) for shading, and (aR,aG,aB) = white = (255,255,255) for tinting
HSV or HSB:
To shade: lower the Value / Brightness or increase the Saturation
To tint: lower the Saturation or increase the Value / Brightness
HSL:
To shade: lower the Lightness
To tint: increase the Lightness
There exists formulas to convert from one color model to another. As per your initial question, if you are in RGB and want to use the HSV model to shade for example, you can just convert to HSV, do the shading and convert back to RGB. Formula to convert are not trivial but can be found on the internet. Depending on your language, it might also be available as a core function :
RGB to HSV color in javascript?
Convert RGB value to HSV
Comparing the models
RGB has the advantage of being really simple to implement, but:
you can only shade or tint your color relatively
you have no idea if your color is already tinted or shaded
HSV or HSB is kind of complex because you need to play with two parameters to get what you want (Saturation & Value / Brightness)
HSL is the best from my point of view:
supported by CSS3 (for webapp)
simple and accurate:
50% means an unaltered Hue
>50% means the Hue is lighter (tint)
<50% means the Hue is darker (shade)
given a color you can determine if it is already tinted or shaded
you can tint or shade a color relatively or absolutely (by just replacing the Lightness part)
If you want to learn more about this subject: Wiki: Colors Model
For more information on what those models are: Wikipedia: HSL and HSV
I'm currently experimenting with canvas and pixels... I'm finding this logic works out for me better.
Use this to calculate the grey-ness ( luma ? )
but with both the existing value and the new 'tint' value
calculate the difference ( I found I did not need to multiply )
add to offset the 'tint' value
var grey = (r + g + b) / 3;
var grey2 = (new_r + new_g + new_b) / 3;
var dr = grey - grey2 * 1;
var dg = grey - grey2 * 1
var db = grey - grey2 * 1;
tint_r = new_r + dr;
tint_g = new_g + dg;
tint_b = new_b _ db;
or something like that...

What's the formula for the combined transparency of two overlaid transparent images?

If I want to display one uniformly semi-transparent image, and then 'fade out' this image, gradually replacing it with another of the same transparency, while maintaining the combined transparency at a constant level during the transition, how do I determine what transparency to draw the images?
By trial and error - drawing transparent images of various alphas on top of each other - I've come up with the graph below, showing transparency of image A on one axis and transparency of image B on the other. The 'isoalpha' lines show combinations of alpha that result in the same alpha all the way along the line. Each line is for a different level of alpha, with fully transparent at the top-left.
You can see that the formula I'm looking for is not a straight linear transition with alphaA + alphaB == alphaTarget.
What's the mathematical formula I am looking for?
X axis - alpha of image B (0-255 l-r). Y axis - alpha of image A (0-255 downwards).
Transparencies multiply.
transparency_new = transparency_a * transparency_b
However since opacity (alpha) is the inverse of transparency:
1 - opacity_new = (1 - opacity_a) * (1 - opacity_b)
or:
opacity_new = 1 - (1 - opacity_a) * (1 - opacity_b)
Scale as appropriate if using alpha runing from 0 - 255 instead of 0 to 1.0
alpha_new = 255 * (1 - (1 - alpha_a / 255) * (1 - alpha_b / 255))
I replicated your graph using the above formula and Grapher.app:

Resources