I'm trying to program a Hue/Saturation/Lightness filter for an image (in C++). The RGB->HSL conversions work fine, but my issue comes when accounting for the fact that each pixel has a different initial saturation and lightness.
For each pixel, I've calculated the source HSL and am given the HSL values as the filter input (in the range [0, 1]). I'd like to make 0.5 the default for all (the output is the same as the input), 0.0 for saturation means grayscale or completely black for lightness, and 1.0 means fully saturated or completely white. Unlike saturation and lightness, the hue part is easy: just add and wrap in the range [0, 1].
So then comes the task: how do we transform the saturation and lightness values of each pixel based on the filter inputs?
One solution that came to mind:
Out.H = Wrap(In.H + Filter.H - 0.5f, 0.0f, 1.0f);
Out.S = std::clamp(In.S * Filter.S * 2.0f, 0.0f, 1.0f);
Out.L = std::clamp(In.L * Filter.L * 2.0f, 0.0f, 1.0f);
Where Out
is the output, In
is the source image, and Filter
is the filter settings. But if the source image has a lot of contrast to it, the initially light parts of the image clip quickly (get clamped to solid 1.0f), losing detail while the darker parts may still be barely visible. So instead, I figured perhaps there'd be some curves that could be applied to it that would prevent this clipping while still letting 0.5f return the original value. I thought of using a power function and calculating the power that would be needed. I came up with:
f(x) = x ^ log_1/2(a)
Where a
is the value at the input pixel (the function's value at x = 0.5f). For instance:
This function, f(x) = x ^ log_1/2(0.2)
, passes through (0,0), (0.5, 0.2), and (1, 1). So if the saturation of the input pixel was 0.2f and the filter's saturation input from the user was x, running it through this function would work. You'd need to replace the 0.2 in the function with whatever the input pixel's saturation is. Supposedly, the same thing could be done for lightness. Thus, the code becomes:
Out.H = Wrap(In.H + Filer.H - 0.5f, 0.0f, 1.0f);
Out.S = pow(In.S, log(Filter.S) / log(0.5f));
Out.L = pow(In.L, log(Filter.L) / log(0.5f));
This works well for images with mid-range saturation and lightness. But, if it's initially very high or very low, this approach can mean there's a drop-off of the function very close to 0 or 1. For instance, if the saturation is initially 0.97, the function looks like:
(With a point at (0.5, 0.97), I forgot to mark it).
This means you'd have to turn down the saturation to something like .001 before you start to see any actual difference. I've come across this problem with this test image:
Original (Saturation = 0.5):
Saturation = 0.001
Saturation = 0.0
At this point I'm stuck. Is there a way to adjust the saturation and lightness without losing detail or clipping? Perhaps there's another method of curving that can be used? Is there a standard way of doing it that I've failed to find? Thanks in advance.
Graphs made with Desmos online graphing calculator.