Greater-alpha blend mode
ClosedPublic

Authored by nicholasguttenberg on Dec 19 2015, 2:19 PM.

Details

Summary

This adds a blend mode that uses a sigmoid blending function to take the greater alpha of either the current layer or the brush. The result of this is that making multiple passes across the same area but with different strokes will not further increase the opacity past the brush setting or stylus pressure. This can help with consistently shading a region across multiple separate strokes without creating spurious or accidental sharp edges due to edge-overlaps with existing brush strokes.

Currently there is a bit of a weird effect when you overwrite a stroke of one hue with a stroke of another hue and at a greater opacity. This produces something that looks like a diffuse whitening where the strokes come into contact. This is most visible when using a mouse to draw a stroke at a fixed opacity, then to draw a different stroke at a higher fixed opacity over it.

Diff Detail

Repository
R37 Krita
Lint
Automatic diff as part of commit; lint not applicable.
Unit
Automatic diff as part of commit; unit tests not applicable.
nicholasguttenberg retitled this revision from to Greater-alpha blend mode.
nicholasguttenberg updated this object.
nicholasguttenberg edited the test plan for this revision. (Show Details)
nicholasguttenberg set the repository for this revision to R37 Krita.

Hi, Nicolas!

Could you tell, what is the difference between your composite op and COMPOSITE_ALPHA_DARKEN? Because the purpose of the alpha darken is exactly the same as you describe. Coupled with the "Wash Mode" of the brush, it allows the user to paint over existing strokes without building up the color :)

rempt added a subscriber: rempt.Dec 19 2015, 8:06 PM

Hm, I think that was discussed in the forum thread I lost track off...

dkazakov added a comment.EditedDec 19 2015, 8:16 PM

Ok, thank you!

I will check tomorrow in the evening or on Monday :)

This ended up being revisited because of another more recent forum thread:

https://forum.kde.org/viewtopic.php?f=139&t=129791&p=347553

Alpha darken is mentioned as a solution to what this user wanted, but evidently there's some weird behavior when you use it with a pressure-sensitive stylus? It's been awhile since I wrote this, but I do remember having difficulty getting this effect with the existing blend modes. That was something like 2 years ago though, so I'm not sure what parts of the behavior of things have changed.

abrahams added a subscriber: abrahams.EditedJan 14 2016, 3:59 AM

Seems like the new blending mode works as described - it is a bit of a strange effect when painting over one color with another though, perhaps some tweak to the algorithm would do a better job with mixing colors? I also notice the white halos bordering strokes.

Maybe comparing the results with Sai output would help in tweaking the algorithm to create a more pleasing effect. From the recent forum thread - http://postimg.org/image/ua34tfbpj/

Hi, Nicolas!

I have just tested your patch! The basic idea is very good, but I there are still some glitches that will prevent painters form using it. I have recorded a video showing them:

So basically, we should fix two problems:

  1. Painting with a brighter color on darker background creates aereals around the stroke that was painted with very high pressure.
  2. After you painted on a canvas with a hight pressure, you cannot paint over this part of image anymore. See the last stroke in the video.

So in the end, I love the idea of being able to paint over previous strokes without overlapping, and we should have it. But this very formula makes some strange artifacts, that will interfere into people's workflow. It should be fixed somehow :)

dkazakov requested changes to this revision.Feb 22 2016, 8:48 AM
dkazakov added a reviewer: dkazakov.

Mark the task that it needs changes

This revision now requires changes to proceed.Feb 22 2016, 8:48 AM

When I examine the RGB and alpha channels separately, I can't actually see the haloes. Somehow it's just when the channels are combined with a layer-level blend function that the haloes emerge. I'm beginning to think this has something to do with Krita's color model not working the way I thought it does when I wrote the blend equation. Basically, the only way I could explain these results is if somehow the effective opacity of the layer during normal blend depended on the saturation of the color there, not just the alpha channel. In that case, areas where I'm going from a red color to a green color for example have a yellowish lower-saturation band in between, and that combined with the layer-level compositing seems to be creating weird results. I think one indication of this is that if you do everything monochrome, these bugs don't appear, so its somehow all taking place when you are combining different colors together.

So if Krita is working in HSV space at one point and the stuff I wrote just makes sense in RGB space, that might be what's causing these problems. However, I'm not familiar enough with how that kind of thing is handled under the hood to figure out what the correct way to make a color-space independent formula would be. Does anyone more familiar with the code base have any suggestions?

woltherav edited edge metadata.EditedFeb 23 2016, 8:02 PM

When I examine the RGB and alpha channels separately, I can't actually see the haloes. Somehow it's just when the channels are combined with a layer-level blend function that the haloes emerge. I'm beginning to think this has something to do with Krita's color model not working the way I thought it does when I wrote the blend equation. Basically, the only way I could explain these results is if somehow the effective opacity of the layer during normal blend depended on the saturation of the color there, not just the alpha channel. In that case, areas where I'm going from a red color to a green color for example have a yellowish lower-saturation band in between, and that combined with the layer-level compositing seems to be creating weird results. I think one indication of this is that if you do everything monochrome, these bugs don't appear, so its somehow all taking place when you are combining different colors together.

So if Krita is working in HSV space at one point and the stuff I wrote just makes sense in RGB space, that might be what's causing these problems. However, I'm not familiar enough with how that kind of thing is handled under the hood to figure out what the correct way to make a color-space independent formula would be. Does anyone more familiar with the code base have any suggestions?

No, there's no HSV conversion going on at all. However, do remember that any time you interpolate between full red and green, the hue shifts even in RGB color space. It is kind of the nature of the beast. There's a simple example of the peculiarities of advanced RGB mixing here, in the user manual: https://docs.krita.org/Gamma_and_Linear

However, don't be fooled, we should not at all get linear space when using the default color space which is sRGB. (I WISH this was possible)
What we can say, due to the halo being much brighter than the surroundings, is that somehow we're getting out much higher values than we should(which is why you see yellow when mixing red and green instead of the regular brownish mud color)

What I suspect is affecting the whole business is that Krita uses pre-multiplied alpha, which you go into at line 75, and then you lerp in line 77 but I am having trouble finding where you unmultiply?

There's an example at the end of KoCompositeCopy2.h on how to do the simple over-op. The more complicated version in actual over op is nearly unreadable in the name of optimisation, but it seems to use different methods because of a balance of optimisation and expectation(amongst others having access to all color channels).

My apologies for taking so long on this, despite requesting you. I have been a bit overworked lately, and it wasn't until your comment about yellow that the gears in my brain finally started to work :)

fazek added a subscriber: fazek.Feb 24 2016, 8:01 AM

I'm working on a problem a while ago about the sRGB and the linear color space. Linear space is very good to have correct results when you mix colors, but has strange side effects, a bright stroke on a dark background looks much thicker than the same dark stroke over bright. sRGB is like the opposite but much less visible. I think there is no really good solution: the sRGB results a wrong color, but the linear also depends on both the foreground and the background colors. Perhaps the alpha blending itself is the problem.

In D666#19332, @fazek wrote:

I'm working on a problem a while ago about the sRGB and the linear color space. Linear space is very good to have correct results when you mix colors, but has strange side effects, a bright stroke on a dark background looks much thicker than the same dark stroke over bright. sRGB is like the opposite but much less visible. I think there is no really good solution: the sRGB results a wrong color, but the linear also depends on both the foreground and the background colors. Perhaps the alpha blending itself is the problem.

It isn't related. I was mentioning it to show that r+g becomes yellow, and like I said, it's actually impossible to go into linear mode, as I wish it were possible(there are some advanced blending things I want to use it for :3)

So one thing about the haloes is, if I replace the white background with a dark background, the halo area in particular looks darker than the surrounding rather than lighter than the surrounding. That's what makes me think that it has something to do with the blending between layers and the assumptions I'm making about what alpha means in the equations. Another way to see it is, if you set a black background and then change the upper layer to 'Add', then the haloes go away.

So somehow to fix this, I have to basically invert whatever the 'normal' blend operation is doing so that my equation outputs the thing that will make the normal blend act as if it were linear (I think?)

fazek added a comment.Feb 24 2016, 8:50 AM

Actually the bright yellow between red and green is physically correct (you can check it with a red-green checkerboard pixel pattern). I think one solution could be for color blending is to use different alpha values for R, G, and B. But since the sRGB also has some similar, but opposite effect with the brightness and thickness, maybe a gamma value between 1 and 2 is actually better sometimes than both of these spaces.

Interesting fact that the multiply blending mode is independent of the gamma value, because of the identities between the multilply and power functions.

I think the ideal would be to use different gamma values for each layers because sometimes this space is better, sometimes another. The conversion can be very fast with lookup tables (but also very memory consuming).

So one thing about the haloes is, if I replace the white background with a dark background, the halo area in particular looks darker than the surrounding rather than lighter than the surrounding. That's what makes me think that it has something to do with the blending between layers and the assumptions I'm making about what alpha means in the equations. Another way to see it is, if you set a black background and then change the upper layer to 'Add', then the haloes go away.

So somehow to fix this, I have to basically invert whatever the 'normal' blend operation is doing so that my equation outputs the thing that will make the normal blend act as if it were linear (I think?)

No, I feel bad for bringing this up.

You values are too high. Probably caused by not reverting the pre-multiplication after lerping:

What I suspect is affecting the whole business is that Krita uses pre-multiplied alpha, which you go into at line 75, and then you lerp in line 77 but I am having trouble finding where you unmultiply?

Are you doing unmultiplying anywhere or not?

I have no idea who fazek is btw, which means they are an too overly helpful observer. Please ignore them for this review, because the pigment library is already too darn complicated.

here, specific lines identified.

libs/pigment/compositeops/KoCompositeOpGreater.h
76

Here you multiply, this is good.

78

Here you lerp, also good.

Where do you divide??? (check kocompositecopy2.h, at the end)

81

I am not sure if this one is necessary in regards to krita's ability to have HDR/scene-reffered values. (79 IS necesaary)

Might be worth experimenting with.

Line 75 is the lerp and line 77 is the division. It's a weighted linear interpolation for when the weighting factors don't sum to 1.

output = (w1 * in1 + w2 * in2)/(w1 + w2)

For pre-multiplied alpha, does that mean I should be doing the following?

output = (w1 * w1 * in1 + w2 * w2 * in2)/( (w1 + w2) * newalpha )

fazek added a comment.EditedFeb 24 2016, 2:32 PM

Sorry if I'm disturbing your conversation, just I'm making such algorithms often and maybe I can help.

About the color spaces, what I want to say is, don't expect perfect results and strange artefacts can happen everywhere, specially at the half-transparent parts, but the reason is the color space and not the blending.

About the premultiplied: if your in1 and in2 are premultiplied, and the result must be too, I think it's like this:

output = (in1 + in2) * newalpha / (w1 + w2)

I tried all of the premultiply variants discussed so far, and they all look obviously much more wrong than what's there right now (like, bright white edges when painting over a color with itself, things like that).

One thing I noticed when comparing with KoCompositeOpCopy2 is that where they do the blending there, they're using the opacity alone to do the blending, not the Mask*srcAlpha*opacity thing that gives appliedAlpha. I've been assuming I should use appliedAlpha everywhere, but is there a reason why I should use opacity separately in the color blend part?

woltherav added a comment.EditedFeb 24 2016, 4:12 PM

Yeah, sorry, I am really not clear minded, I didn't see the '/'. No, you don't need to pre-premultiply, because you are applying this per-channel already.

No, mask should be involved.

Take basic-tip_gaussian, and then draw some cross-hatching. Now set the brush blending mode to copy, and draw again... that's what mask does.

Now, if you do this with alpha_darken, it doesn't do anything, because basic-tip has opacity pressure...

With greater, you will see a subtle halo, but I am not sure if that's a real mathematical one or just a visual one caused by the abrupt contrast change that greater-alpha gives...

I made a test case:

(extreme version with red at 80% for people watching along)

You can see here that the halo appears whenever there's a mis-match between alpha values. It seems that it is somewhat correct mathematically(you can use the color picker TOOL to check, it allows you to see numbers in the tool options), however, when we have a white background behind our 50% transparent 50% mix between red and green, it gets lightened, which causes the yellow halo. I am not sure what could be done about this...

Furtheremore, despite being 100% opaque, the destination layer still gets 50% mixed... (alpha darken doesn't do this, but alpha darken does have a strange result on the source layer... speaking of which, when the source layer is 100% opaque, it gives similar artefacts but different in a particular way to alpha darken)

fazek added a comment.Feb 24 2016, 4:14 PM

I checked this KoCompositeOpCopy2 and the values are definitely not premultiplied there. That's why the multiplying before the lerp.

I'm not sure about your case, but sometimes I'm using something like this (if in2 is not fully transparent):

lerp_w= w1 / w2
output= (in1 * lerp_w) + (in2 * (1 - lerp_w))

Could you try this too? Maybe you can try this with newalpha/w2 as well.

fazek added a comment.Feb 24 2016, 4:31 PM

Uhm, sorry, I mean w1/newalpha. It must be between 0 and 1.

I tried out the ratio thing and it doesn't quite work. Different problem though - no haloes, but now the color in the main part of the stroke doesn't seem to change correctly. I also tried transforming to linear color space before blending and then transforming back, and that didn't seem to fix it either.

I had an idea that might explain why this looks so weird though. Normally, if I had one color at (1,r1,g1,b1) with opacity a1, and another color at (a2,r2,g2,b2), the new alpha value due to the basic 'Over' would be a1*1 + a2*(1-a1), and the new color value would be a1*r1 + (1-a1)*r2. However, in this case, the alpha value changes much less than you would normally expect when src is very faint. The result may be that because the alpha value changes less, you wouldn't expect to see the color change as much as it does - and so the mismatch might be worst when both the alpha and the color are rapidly changing (e.g. at the edge of a stroke).

So maybe the thing to do is to calculate a sort of 'effective' opacity of an Over operation that would produce the right final alpha value as given by the softmax function, then just apply an Over operation with that opacity value rather than the actual opacity of the brush at that point. So the only thing this blend mode would do would be to change the opacity profile of the brush, and then just use the existing code for Over for everything else. Does that make sense?

I tried out the ratio thing and it doesn't quite work. Different problem though - no haloes, but now the color in the main part of the stroke doesn't seem to change correctly. I also tried transforming to linear color space before blending and then transforming back, and that didn't seem to fix it either.

I had an idea that might explain why this looks so weird though. Normally, if I had one color at (1,r1,g1,b1) with opacity a1, and another color at (a2,r2,g2,b2), the new alpha value due to the basic 'Over' would be a1*1 + a2*(1-a1), and the new color value would be a1*r1 + (1-a1)*r2. However, in this case, the alpha value changes much less than you would normally expect when src is very faint. The result may be that because the alpha value changes less, you wouldn't expect to see the color change as much as it does - and so the mismatch might be worst when both the alpha and the color are rapidly changing (e.g. at the edge of a stroke).

So maybe the thing to do is to calculate a sort of 'effective' opacity of an Over operation that would produce the right final alpha value as given by the softmax function, then just apply an Over operation with that opacity value rather than the actual opacity of the brush at that point. So the only thing this blend mode would do would be to change the opacity profile of the brush, and then just use the existing code for Over for everything else. Does that make sense?

Yes it does. I think this might be the way to go yes.

Okay, that seems to work better, though its still not perfect. I've attached the best version I've found so far. I'm not sure whether I should actually premultiply the source color by srcAlpha, or if I should just premultiply by unitValue (since that's what I assumed when I calculated the opacity).

woltherav added a comment.EditedFeb 24 2016, 7:38 PM

Much better, much cleaner code too. There's still artefacts when doing consecutive strokes in the lower alpha-values(it seems it tries to mix between the transparent color's implicit black and the paint color. (maybe check if the src alpha is 0 before doing things?)

There's also still the issue that when the source alpha is 1, it overrides the destination no matter what. (Or, if two alphas are the same, the src color overrides... I am not sure if this is intended?)

(Don't forget to do something about float sA before submitting, it makes cmake complain a lot thanks to the wonders of templates, there's a Q_Unused for when you are passing the value on, but in this case you're not using it at all, so.)

fazek added a comment.Feb 24 2016, 8:18 PM

If a < dA, fakeOpacity will be 0, but near transparent, maybe the 1e-16 part adds some error. Maybe

if (a <= dA) {
    a= dA;
    fakeOpacity= 0.0;
} else fakeOpacity= 1.0 - (1.0f - a) / (1.0f - dA);

is better. It cannot divide by zero, because it can happen only if dA is 1, and I think that's in the other case. Also when sA == 0, it shoud fall into there.

nicholasguttenberg edited edge metadata.

Changed the behavior to just calculate an effective opacity for a normal draw, and then apply the standard blending math. The result removes the bright haloes when drawing a more opaque stroke of a different hue over a background stroke.

Following the discussion so far in the comments, this version of the patch also fixes the dark corners when drawing very light strokes, which was caused by premultiplying with the source alpha when the effective opacity calculation is already overriding that and assuming it to be equal to 1.

Hi, @nicholasguttenberg!

I tested your patch. The aerials has almost gone. I have two small questions:

  1. When I painted a 100% pressure stroke I can no longer paint over it with any other color. Is it an expected behavior?
  2. Calling exponent function in a hot spot might be a bit expensive. We process up to a 1 GiB/s in this function. Is there any option to avoid exponent? Like approximate it with some simple function? Or it is a basis of the effect?

To all:

  1. Is it possible to ask some artist to make a video about this feature? @timotheegiet, @Deevad, @woltherav, @gdquest, are you interested? :)

I'm just not sure how to communicate this feature to the users :)

After considering questions 1) and 2) we can push the patch into master, so the painters can test it.

libs/pigment/compositeops/KoCompositeOpGreater.h
62

Using exp() might be a bit expensive, is there any option for it?

Hi, @nicholasguttenberg!

I tested your patch. The aerials has almost gone. I have two small questions:

  1. When I painted a 100% pressure stroke I can no longer paint over it with any other color. Is it an expected behavior?

This is expected, but it sounds like it's counter-intuitive - and it seems to lead to some very sharp edges which would be an undesirable outcome. It looks like a lot of the obvious work-arounds to actually make a sensible behavior here run into creating sharp edges or other artefacts. I'll try out a couple things and see if I can make this go to some kind of soft limiting behavior (basically the issue is that the 'fake' opacity limits to 1 when both the brush and background alpha limit to 1, so you get this sudden sharp 'replace the entire color with a hard edge' behavior).

  1. Calling exponent function in a hot spot might be a bit expensive. We process up to a 1 GiB/s in this function. Is there any option to avoid exponent? Like approximate it with some simple function? Or it is a basis of the effect?

The sharpness should be preserved I think, but I think we should be able to replace this with an approximation. A close fit appears to be:

float delta = dA - scale<float>(appliedAlpha);
float w = 0.5 + 10*delta/(1+20*fabs(delta));
if (w > 0.5) w = 1- (1-w)*(1-w);
else w = w*w;

When I try this in Krita, it looks fine, so we should just be able to do that instead.

  1. When I painted a 100% pressure stroke I can no longer paint over it with any other color. Is it an expected behavior?

This is expected, but it sounds like it's counter-intuitive - and it seems to lead to some very sharp edges which would be an undesirable outcome. It looks like a lot of the obvious work-arounds to actually make a sensible behavior here run into creating sharp edges or other artefacts. I'll try out a couple things and see if I can make this go to some kind of soft limiting behavior (basically the issue is that the 'fake' opacity limits to 1 when both the brush and background alpha limit to 1, so you get this sudden sharp 'replace the entire color with a hard edge' behavior).

Probably, it is possible to add some 'epsilon' value that overrides the background when it is near to 1.0?

  1. Calling exponent function in a hot spot might be a bit expensive. We process up to a 1 GiB/s in this function. Is there any option to avoid exponent? Like approximate it with some simple function? Or it is a basis of the effect?

The sharpness should be preserved I think, but I think we should be able to replace this with an approximation. A close fit appears to be:

float delta = dA - scale<float>(appliedAlpha);
float w = 0.5 + 10*delta/(1+20*fabs(delta));
if (w > 0.5) w = 1- (1-w)*(1-w);
else w = w*w;

When I try this in Krita, it looks fine, so we should just be able to do that instead.

You can check how fast your composite op runs in a KisCompositionBenchmark benchmark. It was created exactly for such cases :) Then you will know exactly if you need this approximation or not, and how fast it is :)

fazek added a comment.Mar 1 2016, 7:13 PM

I think using float is itself slows down the things, specially for the integer formats. The integer-float conversion is expensive, and the integer arithmetics is cca. two times faster than the floating point. Isn't it possible to use native arithmetics here? It works well in other modes.

nicholasguttenberg added a comment.EditedMar 2 2016, 12:15 AM

The reason for using float here was basically conceptual clarity to help with developing the correct blend math and getting the kinks out. There are a lot of references to specific values and scales in the formula as a result - for instance, picking 40 as a good slope for the sigmoid is dependent on the numbers being in a 0..1 range. So to generalize this to native math, you'd want to make sure all those constants are being transformed consistently to the different maximum/minimum ranges of the native representation. I'd want to wait until we're absolutely sure we don't want to touch the specific details of the formula again, since it'll make it very hard to interpret.

For the 'add epsilon to override the background', I've tried a couple of things but it seems like they're bringing back the haloes or creating sharp edges when you paint over a 100% pressure stroke. I think the reason is that for this math, there's a sense of a fixed residual amount that you can change the color at a given position. That fixed residual is basically 1-alpha. So as you approach alpha=1, the behavior of the blend mode is smoothly approaching a state where it only changes the alpha and not the color. So when you add an exception to make it change the color strongly at alpha=1 when drawn over by alpha=1, you get haloes where (for example) the brush edge is down to alpha=0.9 and is drawing over alpha=0.95 and suddenly the degree of influence over the color drops rapidly. I've also tried just treating the background as if it were at a lower alpha value than it actually is, but I wasn't getting good results that way.

What I have done is to add a little bit of an interpolation so that when you paint over a 100% stroke, it doesn't leave a hard edge between the alpha=0.99 and alpha=1 parts of the stroke. Would something like this be okay?

Also, how do I use KisCompositionBenchmark?

dkazakov accepted this revision.Mar 4 2016, 6:20 AM
dkazakov edited edge metadata.

Hi, @nicholasguttenberg!

Using KisCompositionBenchmark is very simple. Just go to krita/benchmarks/kis_composition_benchmark.cpp and add a function like KisCompositionBenchmark::testRgb8CompositeCopyLegacy(), but change the name of your composite op. Then test two variants of your patch to compare speed difference.

Don't forget that to have the test and benchmarks available, you should have BUILD_TESTING cmake option enabled.

Basically I'm ok with pushing the patch as it is and thinking about optimizations later. This will let the painters to test it and make videos about it. This is the main thing we should do it :)

Do you have commit rights?

This revision is now accepted and ready to land.Mar 4 2016, 6:20 AM

I don't think I have commit rights, so how should I proceed?

Dmitry, me or @rempt will push for you then, though I am not sure whether dmitry want this in master???

rempt added a comment.Mar 10 2016, 9:43 AM

This was pushed in the meantime.

This revision was automatically updated to reflect the committed changes.