Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Transfer Functions

stough 202-

Preview: Contrast and Image Enhancement
  1. Windowing and Piece-wise Linear Transforms

  2. Power Transforms

  3. Log and Miscellaneous Transforms

  • 🔨 Your Turn: Log Correction in Lab Space

  • Enhancement: manipulating an image to be more suitable for the application

    • Human perception

    • Compressibility

    • Further analysis (making features easier for an algorithm, not just a person, to find)

  • Contrast: difference in color or luminance between nearby elements or over a whole image

In this notebook we’ll consider some images that could be enhanced by improving the contrast. We’ll apply Transfer functions of various type, which can all very generally be expressed as

s=T(r)s = \mathbf{T}(r)

where rr is the image intensity or color stored in the file, and ss is a corrected or transformed intensity that we want the display device to use. This transfer function applies to each pixel independently; it maintains no information about the local context.

Transfer functions are already being used.

Whether or not we use them to enhance our images, transfer functions are already ubiquitous. Every display device uses some mapping to turn the image file’s pixel intensities into voltages for the display elements/pixels (or pigment mixtures for the printer). These mappings can be constructed to conform to human perception, or to exaggerate or understate nominal differences. The mapping generally used in digital displays is a Gamma function, which we’ll actually use below.

So doing nothing to your image is in fact a decision, and may be a poor one if your goal is to communicate something through the image.

Imports

Note the addition of the vis_pair function, which can nicely plot an original and changed image for side-by-side comparison.

Windowing and Piece-wise Linear Transforms

The simplest transfer function one might imagine is the linear function

s=T(r)=Ax+Bs = \mathbf{T}(r) = Ax+B

where AA and BB are constants. Let’s consider a particular case.

((1200, 2134, 3), dtype('uint8'), np.uint8(0), np.uint8(204))
<Figure size 900x300 with 2 Axes>

arr_info tells us this image has data that are 8-bit unsigned integer. Given that there are 3 color channels, that implies a 24-bit color image, very typical. From the histograms though, we can also see this image has pretty much all of its pixels bunched up in the lower half of the nominal display range [0,255][0,255]. Notwithstanding the photographer’s intent in choreographing the shot, let’s “correct” for my old eyes. A simple linear function might do. But we have to remember that matplotlib expects three-channel images to stay in [0,255][0,255] (if integer type).

The above function implementation has three key steps:

  • Create a lambda function Tr that applies the Ax+BAx+B

  • Make the initial output image J that is Tr applied to the original image. We assure we’re doing floating math by sending I.astype('float'). Note that this initial output J could have values outside [0,255][0,255] and will be floating point data, neither of which are valid for integer image display through matplotlib.

  • The return statement both clips the image J to ensure a [0,255][0,255] range and casts to unsigned integer uint8 datatype.

We can even see practically what this simple linear transform does in transforming the input intensity.

<Figure size 400x300 with 1 Axes>
<Figure size 800x300 with 2 Axes>

You can see what we did by viewing the histograms, or even (cooler), seeing what happened in the rgb cube!

<Figure size 900x300 with 2 Axes>
<Figure size 900x300 with 2 Axes>
<Figure size 400x400 with 1 Axes>
<Figure size 400x400 with 1 Axes>

One approach you may think of is to renormalize the intensity so that [min,max][min,max] is exactly [0,255][0,255]. This is of course also a linear transform:

s=T(r)=255r−I.minI.max−I.mins = \mathbf{T}(r) = 255 \frac{r - \tt{I.min}}{\tt{I.max} - \tt{I.min}} \\

or A=255I.max−I.minA = \frac{255}{\tt{I.max} - \tt{I.min}}, and B=−255×I.minI.max−I.minB = \frac{-255\times\tt{I.min}}{\tt{I.max} - \tt{I.min}}

<Figure size 800x300 with 2 Axes>

If we plot all of these potential linear maps we can see their varying effect.

<Figure size 400x300 with 1 Axes>

Gaussian Noise Example

We’ll do one more example with windowing. We’ll use a Gaussian distributed random image that we’ve come to know from previous notebooks. It abstractly represents poor contrast, because its intensities are bunched up relative to a uniform distributed image.

<Figure size 800x300 with 2 Axes>
<Figure size 400x400 with 1 Axes>
<Figure size 900x300 with 2 Axes>

Looking at the histogram of I_gauss, you see that most of the intensities seem to be bunched up in the [50,200][50,200] range. But our output range is [0,255][0,255], so we have some room to work with. We want to define a transfer function that will dedicate the entire output range to where in the input range our data actually are. Let’s make a function that linearly maps some input range to some output range. While we could formulate this in terms of Ax+BAx+B or the two point form, I like to think of it as mixing: we’re mixing between 0 and 255, and the mixing amount is equal to how far we are along the road from 50 to 200.

T(r)=(1−α)×0+α×255α=r−50200−50\mathbf{T}(r) = (1-\alpha)\times0 + \alpha\times255\\ \alpha = \frac{r - 50}{200-50}

The above gives us a linear map defined by input and output ranges, as in our [50,200][50,200]->[0,255][0,255] attempt above. However, the linear map doesn’t clip the results of the transformation like simple_linear above. Let’s use it in a larger function that takes care of the out-of-bounds issues. We can also see practically the transfer function.

<Figure size 400x300 with 1 Axes>

As you can see, our attempted interval_stretch will stretch the middle [50,200][50,200] of the input range, where our data actually are, to the entire display range [0,255][0,255].

<Figure size 900x300 with 2 Axes>
<Figure size 800x300 with 2 Axes>
<Figure size 400x400 with 1 Axes>
<Figure size 400x400 with 1 Axes>

We can see that our windowing redistributed the image intensities to better cover the whole display range or RGB cube. We call this windowing because we dedicate the entire display range to a window, or valid input range, of intensities (in this case, [50,200][50,200]).

We can also see from the histogram that values which were already outside of our arbitrary [50,200][50,200] range start piling up at the tail ends of I_gauss_enhanced’s histogram. The tighter we set our window (e.g., [70,180][70,180]), the more pixels that will just end up with their intensities maxed out at 0 or 255. Go ahead and try it out.

Power Transforms

While windowing has the very simple effect of linearly scaling input ranges, we see that it can create artifacts at the extreme ends. Additionally the perception of light itself is nonlinear, so that maybe a nonlinear approach could be better.

The nonlinear transfer function that is used to map intensity to voltage in display devices is commonly the Gamma function:

s=T(r)=crγs = \mathbf{T}(r) = cr^{\gamma}

Functions of this form also include what we think of as powers or roots. For example, γ=2\gamma=2 corresponds to squaring the intensity, while the reciprocol γ=12\gamma=\frac{1}{2} leads to the square root. We can plot this transfer function for various values of γ\gamma, with cc set to correct the output scale to [0,255][0,255]

<Figure size 400x300 with 1 Axes>

You can see that where the bulk of an image’s intensities are can inform you as to a good γ\gamma that can enhance the contrast in the image. In the above I intentionally place 2.4 and its reciprocal (0.42) in the plot because 2.4 is special: it is a standard gamma value for High Dynamic Range (HDR) content for movies viewed in darker rooms.

Here is an interactive visualization to play around with it for yourself.

<Figure size 900x300 with 2 Axes>
<Figure size 800x300 with 3 Axes>
<contextlib.ExitStack at 0x7f5c93f24fd0>

Log and Miscellaneous Transforms

Other nonlinear families often used for image enhancement are logs and their inverse the exponentials:

  • s=T(r)=clog⁡(1+r)s = \mathbf{T}(r) = c \log(1+r)

  • s=T(r)=cers = \mathbf{T}(r) = c e^{r}

<Figure size 400x300 with 1 Axes>

(In the above code there are some confounding bits to get the output correct. These come from the fact that the discrete interval [0,255][0,255], if understood as continous, is the right-open interval [0,256)[0,256).)

From the plot above we can see that the log⁡\log function has the effect of dedicating much of the display range to the darker pixels in the image. Further this contrast stretching is dynamic (nonlinear) in the input range. For example pixel instensities that are 50 units apart at the dark end of the domain will be mapped to intensities that are 150+ apart in the output range, while the same difference of 50 units at the high end will end up being compressed to about 10.

Let’s see what this does to a severely underexposed image.

<Figure size 900x300 with 2 Axes>
<Figure size 800x300 with 2 Axes>

In the above you can see clearly the differential contrast adjustment in the dark and light areas of the image. In this image the log⁡\log transform greatly expanded contrast on the dark buildings, but at the cost of reducing the contrast in the bright sky.

 

🔨 Your Turn: Log Correction in Lab Space

You might notice a few additional artifacts in our corrected image above. For example the blue sky has turned a kind of aqua (maybe?). Additionally the colorful lens flare on the church and the blue tone of the leftmost rooftop are likely artifacts of the intense scaling done at the very low end of the input range, and not the most faithful representation of the underexposed foreground in reality. (Not that faithfulness to reality has to be the goal). Often in image acquisition, very small differences in R, G, and B are unreliable when found at the extreme low end.

We would like to contrast-enhance the original underexposed image in a way that does not change hue too drastically (blue sky turning aqua). Conveniently then, in the Color module we saw several alternative colorspaces that separate the “color” from the brightness/value/luminance dimension of perception, and we can use any of these instead of RGB as above. Among HSV, YCbCr, and Lab, let’s try Lab. If we log⁡\log transform the Luminance (L) only, leaving the chromaticity dimensions (a, b) constant, we might more objectively enhance the contrast in the underexposed foreground.

Task: Log-correct the underexposed image I by transforming only its L channel in Lab space, then convert back to RGB and show it next to the RGB log correction I_log_corrected from above. Does the sky keep its hue? Is the foreground as well lit?

Guidance:

  • Convert with color.rgb2lab and back with color.lab2rgb. Work on a copy, so that I itself is unchanged.

  • L lives in [0,100][0, 100], not [0,255][0, 255]. Our log curve above was built to send 0→00 \to 0 and 255→255255 \to 255; you need a version of it that sends 0→00 \to 0 and 100→100100 \to 100. Values outside [0,100][0, 100] can cause errors or strange colors when converting back.

  • Make frequent use of arr_info to check the datatype and range after every step: what does rgb2lab expect as input, and what does lab2rgb give you back?

  • vis_pair(I_log_corrected, your_result, ...) with show_ticks=False makes the comparison easy. Plotting the histogram of the L channel before and after with vis_hists shows the effect directly.