Smudge Tweaks testers needed

Tags: #<Tag:0x00007fc07eb10170>


I’m also curious how color management will influence color picking, mixing, modifying, etc? Is it similar to what is described in these krita docs?

I also revisited my early flat-brush problem and came up with a hacky way to get it working (somewhat). The new brush locks rotation (direction filter) after a very short distance (stroke input) to avoid buckets mixing up on rotation. During that distance the smudge length is set to 0, which - as I read it from the C code - already resets a single bucket. The short moving distance is also needed for the user to get the rotation right easily after pressing down the stylus

In the end it’s very hacky and I dont like it very much :poop: but I’m using it in a current painting. I guess some input for drawing-angle change might be useful / needed for this to work without having to lock the rotation. But I imagine that to be quite bit messy too since it basically needs to remember the drawing angle for many dabs to be able to look back based on some X distance or something.

Do you have any thoughts on this / a different suggestion? I never got any round brush with manual dab placement to draw very smooth/clean strokes like this simple, flat one. Might be usefull for your brushes as well, as they seem to have the same issue…

edit: brush is quite sensitive, you might have to press very lightly


Sort of, but not really like what Krita is describing here. I think I understand now why it is said so often that linear space will have overly strong whites. It’s a presentation issue. You can use a linear model and have things behave normally by transforming the view of that linear data . This is what OCIO helps with, and Krita supports it too:
Ideally we would have the full OCIO interface, looking like this:
All these settings are just changing the presentation of the data based on the assumptions of the “Input ColorSpace”. My branch only does the bare minimum of allowing you to set the Input Colorspace and the View, as well as the GUI colorspace.

I would experiment with Krita and then try my master_plus_OCIO branch if you want to give it a spin. Just don’t expect to save anything since saving as 15bit int will damage the data pretty badly since it will actually save as 8 bit png.

That is actually really slick, it seems to work pretty well, honestly! I had no idea the direction filter could “lock” the direction. Just sliding the direction filter to the max 10.0 does not lock it. What you’ve done is a clever, since you are “overflowing” the input by stacking the pressure and stroke to achieve 20. I suppose the direction is still not locked but would probably take an absurd amount of time to actually change :slight_smile:

I’m not sure I follow this. I don’t think it would be a problem to hook up some input to the angle adjustment. That’s exactly what I’m doing with barrel rotation and it’s very nice. :wink: One of the things that I forget often is that the libmypaint engine is only ever drawing a single dab at any point. So, the whole execution of mypaint-brush.c is just in the context of one little tiny dab. Kind of crazy that is it still as fast as it is :-p

There is one thing that troubles me, and that is using the “random” input directly on various settings and expecting correlation between all of the settings. I recall being bitten by this a long time ago, so I started using just ONE random input on the “Custom” setting. Then, I use the Custom input to bring in a guaranteed correlated random # into to various settings like offset and smudge bucket. I don’t have time to verify this but that might be the source of some problems and might be worth a try


Hey, can you try this brush?
It’s a “round” brush with dabs placed referenced to the view instead of direction. Smudge buckets seem to stay “clean”


I’ve made some updates that are going to slow things down quite a bit. Instead of just doing spectral on the smudge, I’ve updated the normal blend brush mode to do the same:

I’ve also added some new ability to the gradient editor to blend w/ the spectral mixing but have a modifier to adjust the ratio of weighted geometric mean and plain multiply mixing. I think this might be a crude estimation of mixed paint (WGM) and layered paint (plain multiply). So the “crayon” preset is mostly WGM with a small amount of layering. The watercolor glaze is mostly multiply with a small amount of WGM. Opaque Paint is 100% WGM. For comparison I include normal perceptual RGB and CIECAM JMh.

Because multiply is so powerful, instead of a linear gradient I apply a bell curve to the ratios.

So my plain is to convert several of the smudge settings (pow, spectral, subtractive) into generic settings that apply to both smudge and brushmode. I’m still not sure if this will work well without a “wash” mode like Krita has, where a stroke exists on a temporary layer until the stroke ends and is then merged. We’ll have to see.


Hey Brien,
I’m trying to implement a seemingly simple algorithm in C where the user enters 2 RGB hex colour values and the function will return a 50% blended version of the colour. E.g. blue + yellow = green. I have read posts released by “Paper by 53” team using the Kubelka and Munk algorithm but I could not find any source code and that rabbit hole ended.

Then last night I discovered Scott Burns’ paper “Subtractive Color Mixture Computation” and after going through the comments section on his website, I found your fantastic work! You have done an incredible job of implementing a real world application and by what I have seen on all of your thread posts and code commits, really developing amazing new functions.

I’m currently reaching out to see if you can help me find the newest version of the subtractive colour mixing algorithm because I’m finding it hard to parse through all of the commits and repos. I don’t care if it is in Matlab, C, Python or anything and I’m currently running on a Mac so I don’t think I can compile your brush pack. I also just download the 4gb of RGB data but I think now I didn’t have to… For this project, I don’t need to implement the 36 spectral colours but simply just the 3 RGB values.

Just keep killing it with your efforts to produce the most realistic digital paint software I’ve ever seen!
Thank you so much for your help,


Thanks Matt!

It might be easier to look at the python implementation.

  1. Convert your 2 RGBs to Spectral SPDs (power distributions) using this function:
  2. Mix the two SPDs using weighted geometric mean with this function:
  3. Convert the new (singular) SPD back to RGB with this function:

The only clever thing I’ve done beyond what Scott Burns provided is doing the upsampling from RGB to SPD using just 3 “primary” spectral curves from Scott’s model. This is probably flawed in some way and it would actually be better to use the 4GB (compressed) database as a LUT for the RGB->SPD conversion. But for now I’d rather not consume 5 minutes of load-time and 2GB+ of RAM just for that table :slight_smile:

You can find the C functions here:


Incredible! Thank you so much!! You are a hero among men. I’m going to experiment with this in React.js and see if I can make an interactive colour mixer system. I’ll send you a CodePen link once I get it working and I’ll be sure to message you if I have any questions.

I haven’t had much time in-between work and university but I’ve just put something small together:

import numpy as np
import math

# weighted geometric mean must avoid absolute zero
_WGM_EPSILON = 0.000030517
# _WGM_EPSILON = 0.000030517

def RGB_to_Spectral(rgb):
    """Converts RGB to 36 segments spectral power distribution curve.
    Upsamples to spectral primaries and sums them together into one SPD
    Based on work by Scott Allen Burns.

    r, g, b = rgb

    r = r / 255
    g = g / 255
    b = b / 255

    r = max(r, _WGM_EPSILON)
    g = max(g, _WGM_EPSILON)
    b = max(b, _WGM_EPSILON)

    # Spectral primaries derived by an optimization routine devised by
    # Allen Burns. Smooth curves <= 1.0 to match XYZ
    spectral_r = r * np.array(
        [0.022963, 0.022958, 0.022965, 0.022919973, 0.022752, 0.022217,
         0.021023, 0.019115, 0.016784, 0.014467, 0.012473, 0.010941,
         0.009881, 0.009311, 0.009313, 0.010067, 0.011976, 0.015936,
         0.024301, 0.043798, 0.09737, 0.279537, 0.903191, 1, 1, 1, 1,
         1, 1, 1, 1, 1, 1, 1, 1, 1

    spectral_g = g * np.array(
        [0.023091, 0.023094, 0.02311, 0.023186201, 0.023473, 0.02446,
         0.02704, 0.032978, 0.045879, 0.075263, 0.148204, 0.344509,
         0.810966, 1, 1, 1, 1, 1,
         1, 1, 0.644441, 0.332202, 0.19032, 0.127354,
         0.097532, 0.082594, 0.074711, 0.070621, 0.068506, 0.067451,
         0.066952, 0.066725, 0.066613, 0.066559, 0.066531, 0.066521

    spectral_b = b * np.array(
        [1, 1, 1, 1, 1, 1,
         1, 1, 1, 0.870246, 0.582997, 0.344759,
         0.198566, 0.116401, 0.071107, 0.045856, 0.031371, 0.022678,
         0.017256, 0.013751, 0.011428, 0.009878, 0.008831, 0.008138,
         0.007697, 0.00743, 0.007271, 0.007182, 0.007136, 0.007111,
         0.007099, 0.007094, 0.007092, 0.007091, 0.007089, 0.007089

    x = np.sum([spectral_r, spectral_g, spectral_b], axis=0)

    return x

def Spectral_to_RGB(spd):
    """Converts 36 segments spectral power distribution curve to RGB.
    Based on work by Scott Allen Burns.

    # Spectral_to_XYZ CIE matrix weighted w/ diagonal D65 matrix.  sRGB
    T_MATRIX = (
            [[5.47813E-05, 0.000184722, 0.000935514, 0.003096265,
              0.009507714, 0.017351596, 0.022073595, 0.016353161,
              0.002002407, -0.016177731, -0.033929391, -0.046158952,
              -0.06381706, -0.083911194, -0.091832385, -0.08258148,
              -0.052950086, -0.012727224, 0.037413037, 0.091701812,
              0.147964686, 0.181542886, 0.210684154, 0.210058081,
              0.181312094, 0.132064724, 0.093723787, 0.057159281,
              0.033469657, 0.018235464, 0.009298756, 0.004023687,
              0.002068643, 0.00109484, 0.000454231, 0.000255925],
             [-4.65552E-05, -0.000157894, -0.000806935, -0.002707449,
              -0.008477628, -0.016058258, -0.02200529, -0.020027434,
              -0.011137726, 0.003784809, 0.022138944, 0.038965605,
              0.063361718, 0.095981626, 0.126280277, 0.148575844,
              0.149044804, 0.14239936, 0.122084916, 0.09544734,
              0.067421931, 0.035691251, 0.01313278, -0.002384996,
              -0.009409573, -0.009888983, -0.008379513, -0.005606153,
              -0.003444663, -0.001921041, -0.000995333, -0.000435322,
              -0.000224537, -0.000118838, -4.93038E-05, -2.77789E-05],
             [0.00032594, 0.001107914, 0.005677477, 0.01918448,
              0.060978641, 0.121348231, 0.184875618, 0.208804428,
              0.197318551, 0.147233899, 0.091819086, 0.046485543,
              0.022982618, 0.00665036, -0.005816014, -0.012450334,
              -0.015524259, -0.016712927, -0.01570093, -0.013647887,
              -0.011317812, -0.008077223, -0.005863171, -0.003943485,
              -0.002490472, -0.001440876, -0.000852895, -0.000458929,
              -0.000248389, -0.000129773, -6.41985E-05, -2.71982E-05,
              -1.38913E-05, -7.35203E-06, -3.05024E-06, -1.71858E-06]]
    r, g, b = np.sum(spd*T_MATRIX, axis=1)

    r = r * 255
    g = g * 255
    b = b * 255

    return int(r), int(g), int(b)

def Spectral_Mix_WGM(spd_a, spd_b, ratio):
    """Mixes two SPDs via weighted geomtric mean and returns an SPD.
    Based on work by Scott Allen Burns.
    return spd_a**(1.0 - ratio) * spd_b**ratio

rgb_color_1 = input('Enter hex 1: ').lstrip('#')
rgb_color_2 = input('Enter hex 2: ').lstrip('#')

rgb_color_1 = tuple(int(rgb_color_1[i:i+2], 16) for i in (0, 2, 4))
rgb_color_2 = tuple(int(rgb_color_2[i:i+2], 16) for i in (0, 2, 4))

spectral_color_1 = RGB_to_Spectral(rgb_color_1)
spectral_color_2 = RGB_to_Spectral(rgb_color_2)

blend = float(input('Enter blend (0 to 1): '))

spd_blended = Spectral_Mix_WGM(spectral_color_1, spectral_color_2, blend)

rgb_blended = Spectral_to_RGB(spd_blended)
rgb_blended = '#%02x%02x%02x' % rgb_blended



Haha, it seems to work! You might also try blending the result of the weighted geometric mean with a straight multiply. I’m doing that in the interpolate area in the PigmentColor class. That’s what I’m doing for Crayon and Watercolor Glaze posted a few above ^. Can’t wait to see what you do with react.os.
There’s so much more we can do with this model; expanded color space, different illuminants, custom reflectance curves, etc etc.


After a long discussion over here:
It has dawned on me that the adjusters and sliders we have now are missing an option. Thanks to @toniw we can change the model via a preference, so that “brighter” can mean HSV, HCY, or CAM16. However, it seems to be clear that “brighter” and “darker” in these models is affected by that pesky Abney effect, which we can largely avoid by using the pigment model. So, I think we could add another model to the list to allow brighter and darker controls to mix in amounts of white and black paint, accordingly. Likewise, changing “saturation/chroma” could be achieved by mixing in amounts of the appropriate “grey” paint or pure saturated color. Rotating Hue is trickier, I think, and we may be better off using CAM16 to do that. In fact, determining what color to use for white, grey, pure pigment, and black should probably be done with CAM16 anyway. So if we add a “pigment” model it will implicitly be a hybrid CAM16/Pigment model.


Just added the pigment adjuster model. Made it the default but you’ll need to edit prefs to change to Pigment. Seems to work well, with the caveat that your original paint color is lost upon each modify. That is, as you brighten the color you also permanently desaturate it. Darkening will not return it to the original color purity. I suppose we could make it more stateful though…


Ok I made it stateful so you can recover from desaturation just fine… this is pretty significantly “better” IMO

Left: HSV, Middle: CAM16, Right: Pigment/Spectral


Finally can actually do a tint/tone/shade workflow:

So I’m using CAM16 ( to figure out which grey, white, and black should be used to to create the tones, tints, and shades. Then upsample the RGB to spectral and do the weighted geometric mean mixing.