Implementing HSV-neutral contrast

When I edit photos, one of the first adjustments is often to increase the contrast a bit. And most of the time, I don't care about the fact that basic contrast filters also increase the saturation. Often these two effects play nice together. There are occasions, however, where you just want to increase the contrast, without any side effects. Modern image editing applications are well equipped to do that, but what if you want to do this in a shader?

I came across this problem when working on a Stochastic Sampling shader, which is a technique to break up repetitive patterns in often-tiled textures. It blends the same texture with itself, losing contrast in the process. If, however, you want to counter this effect by passing texels through a contrast filter before blending them, you will also influence saturation and brightness of the resulting blend. In today's post, I like to explain why and how to correct it.

Figuring this out will also help you understand what contrast and saturation actually are, which makes knowing this all the more valuable. Let's kick things off with the most basic contrast filter one can imagine:

float3 Contrast(float3 rgb, float strength) {
    return (rgb - 0.5f) * strength + 0.5f;
}

At first, we subtract 0.5 from every color channel in our input color vector. Provided that this is no High Dynamic Range color, each channel runs from a value of 0 to 1. Subtracting 0.5 shifts this range from -0.5 to 0.5. We centered the range at the number line's origin so that when we multiply with a strength value, which is bigger than 1 when wanting to increase contrast, we widen this range evenly in both directions. Dark (negative) colors get darker (smaller), bright (positive) colors get brighter (larger). The neutral value of 0.5 (now 0) stays exactly as it was. At last, we shift the range back to its original center by adding 0.5.

Visual representation of the contrast filter's behaviour: Shift histogram left by subtracting (2),
spread by multiplying (3), shift back by adding (4).

Commonly you have to handle the fact that values can be pushed beyond 0 and 1, but this isn't important here. What is more important, is that we can now explain why this formula also increases saturation when applied to RGB color vectors.

Saturation can be described as the difference between the biggest channel value and the smallest. An RGB vector of (0.6, 0.6, 0.6) is a mid-tone gray, with a difference of 0. (1, 0, 0) on the other hand is the strongest red you can get, with a difference of 1. Because the contrast filter was spreading the value range apart, any channel difference already present in the input vector got widened as well. Let's say we start with a color of (0.4, 0.5, 0.5), having a difference of 0.1. By doubling the contrast, we transform it to (0.3, 0.5, 0.5), thus also doubling the difference.

The naive contrast formula applied to a wood texture:
only hue stays constant, saturation and value shift.

The key to avoiding this side effect is to do calculations in another color space than RGB, for example, the Hue-Saturation-Value (HSV) space. Here, we only have to change a single float to manipulate contrast: the value, which one could also call brightness. Saturation stays independent from it by definition because it's stored in another color-vector coordinate.

After the value ran through the contrast filter, we simply transform the HSV value back to RGB. I won't detail how to transform colors between color spaces, because it would go beyond the scope of this article. Unity's and Unreal's shader editors already offer nodes for this, and there is plenty of material online.

The same formula applied in HSV color space:
hue and saturation stay constant, but value still shifts.

Anyway, we still have a problem to solve. If you apply this process to an arbitrary texture, you will likely see a shift in overall brightness. This is because we always shift colors by 0.5 back and forth on the number line. Imagine a very bright texture where all pixels have a value over 0.5. The contrast filter will push these values further away from zero in the positive direction, making them even brighter. However there are no pixel values under 0.5, so nothing is pushed in the negative direction to balance this out. As a result, everything gets brighter.

We can solve this if we shift by the texture's mean (a.k.a. average) value, and not always by 0.5, the mean value of the whole color range. If your bright texture features values between 0.6 and 1, a shift by 0.8 will make the textures' darkest pixels darker, and its brightest pixels brighter, because values from 0.6 to 0.8 are shifted past zero to negative territory. All stays balanced. Note that I choose 0.8 as an example. The mean value depends on the distribution of values in the texture. 0.8 would be the center of a uniform distribution, where all values between 0.6 and 0.8 are equally common.

The last mystery to solve then is how to find a texture's mean value. The smartest way is to sample its lowest MipMap level. Being only 1x1 pixels in size, it already holds the mean value for us. If your texture doesn't have MipMaps, you could sample its central pixel at UV coordinate (0.5, 0.5), which can be a decent enough approximation.

The dynamic mean made hue, saturation, and value stay constant.

Now we have all the building blocks we need. The following image shows the complete filter as a Unity shader graph, but the process works in other engines too, of course. LOD 100 is chosen to be absurdly high so that that for sure the smallest MipMap is chosen. The given texture must be the same from which the input Value originates.

Hopefully, this short excursus into the nature of contrast and saturation gave you a new insight. For me, it definitely did.

~David

The entire setup as Unity shader graph

Comments

Popular Posts