3 min read

Image Contrast: the distance from middle-gray

I take a lot of photos and I edit the ones I like in post. Lately I've been wondering how basic photo editing operations like contrast, saturation, exposure, etc. are actually work.

In this post I define "contrast" in programmatic/mathematical terms (i.e: the raw pixel value), show a quick example, and finally provide a simple rust script to illustrate how it works.

What is image contrast?

Adjusting the "contrast" of an image means stretching pixel values away or towards middle gray.

What is middle gray? If black is on one end of the spectrum (0) and white is on the other (255) then middle gray could be in the middle (128).

If we were to take the histogram of an image, which is a graph of the spread of pixel values of an image, we could draw a line down the middle and call it "middle gray".

Increasing (+) the contrast of an image would push all the values left of the line even further left (and oppositely those right of the line further right). I.e: stretch pixel values to the edges of the graph.

Decreasing the contrast pushes pixel values closer to middle gray. I.e: crush pixel values towards the middle of the graph.

Example

Original Image:

Original Image

Increased contrast (2.0)

Increased contrast by factor of 2

Decreased contrast (0.5)

Decrease contrast by a factor of 0.5

Adjusting contrast with code

Here's an code example using rust to adjust the contrast of an image.

Usage:

  • requires image library as a dependency
  • assumes an image file input.jpg in the project folder
use image::{ImageBuffer, RgbImage};

/*
Adjusts the contrast of a pixel.

How it works:
- subtract 128 to moves the value range of 0 - 256 to -128 to 128
- multiplies the result by a "factor"
- adds 128 to return the value range between 0 - 256
*/
fn adjust_contrast(value: u8, factor: f32) -> u8 {
    let val = (value as f32 - 128.0) * factor + 128.0;
    val.clamp(0.0, 255.0) as u8
}

fn main() {
    // read the image
    let img = image::open("input.jpg").expect("Failed to open image");
    let mut out_img: RgbImage = ImageBuffer::new(img.width(), img.height());

    // iterate over each pixel of the image
    for (x, y, pixel) in img.to_rgb8().enumerate_pixels() {
        // in this example the contrast is being adjusted by
        // a factor of 0.5
        let [r, g, b] = pixel.0;
        let new_pixel = image::Rgb([
            adjust_contrast(r, 0.5),
            adjust_contrast(g, 0.5),
            adjust_contrast(b, 0.5),
        ]);
        out_img.put_pixel(x, y, new_pixel);
    }

    out_img.save("output.jpg").expect("Failed to save image");
}

That's it!

Things to think about

  • In this example I've assumed anchoring to "middle gray" as the threshold (value of 128). What if we used a different anchor value instead? What if we used a different anchor for each color (R, G, B)?
  • Adjusting contrast linearly with a simple multiplication by a "factor" is easy, but what if we used a more complex equation instead (logarithmic for example).