Image Saturation: Color Intensity

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 image "saturation." Then I'll walk through some programming approaches how to adjust saturation with all the trade-offs.
Image Saturation
The saturation of an image is how vivid or intense the colors are.

Ways to edit image saturation
There are 2 main approaches to adjusting the saturation of an image.
All the code is on Github: https://github.com/nbw/foto/blob/main/src/cmds/saturation.rs
[1] HSV approach: slower but accurate
How it works:
Convert the image from RGB to its HSV (Hue, Saturation, Value) representation. Here's my blog post about what HSV is and how it works.
Once you have an image in HSV value of a pixel, adjust the S value appropriately, then convert back to an RGB pixel.
Pros:
- generally accurate way of adjusting saturation
- easy to rationalize
- preserves hue
Cons:
- compatibly CPU intensive to other approaches
- the conversion from RBG to HSV is a non-linear operation so you can't use things like SIMD to parallelize it at the CPU level
Rust code:
fn apply_hsv_saturation(img: DynamicImage, saturation: f32) -> Result<DynamicImage> {
let mut out_img: RgbImage = ImageBuffer::new(img.width(), img.height());
for (x, y, pixel) in img.to_rgb8().enumerate_pixels() {
let [r, g, b] = pixel.0;
let (h, s_base, v) = color::rgb_to_hsv(r, g, b);
let s = (s_base * saturation).clamp(0.0, 1.0);
let (r, g, b) = color::hsv_to_rgb(h, s, v);
let new_pixel = image::Rgb([r, g, b]);
out_img.put_pixel(x, y, new_pixel);
}
Ok(DynamicImage::ImageRgb8(out_img))
}
[2] Via Luminance approach: fast but has issues
How it works:
Luminance (L) and Saturation are related concepts; rather than HSV we can use HSL.
Conveniently, luminance can be approximated using the following equation (the ITU-R BT.601 standard, used to calculate the perceived brightness of a color):
L = 0.299 * R + 0.587 * G + 0.114 * B;
then we can calculate the new R', G', B' values using:
R' = L + (R - L) * saturation
G' = L + (G - L) * saturation
B' = L + (B - L) * saturation
Pros:
- fast and good enough for many cases
- linear operation that can be parallelized easily at the CPU level with tools like SIMD.
Cons
- With extreme adjustments can shift the hue in ways that are hard to control
Rust code using luminance approach:
fn apply_luminance_saturation(img: DynamicImage, saturation: f32) -> Result<DynamicImage> {
let mut out_img: RgbImage = ImageBuffer::new(img.width(), img.height());
for (x, y, pixel) in img.to_rgb8().enumerate_pixels() {
let [r, g, b] = pixel.0;
let r_f = r as f32;
let g_f = g as f32;
let b_f = b as f32;
let luminance = 0.299 * r_f + 0.587 * g_f + 0.114 * b_f;
let new_pixel = image::Rgb([
(luminance + (r_f - luminance) * saturation) as u8,
(luminance + (g_f - luminance) * saturation) as u8,
(luminance + (b_f - luminance) * saturation) as u8,
]);
out_img.put_pixel(x, y, new_pixel);
}
Ok(DynamicImage::ImageRgb8(out_img))
}
Rust code using luminance approach with Simd for performance:
fn apply_luminance_saturation_simd(img: DynamicImage, saturation: f32) -> Result<DynamicImage> {
let mut out_img_raw = vec![0u8; img.to_rgb8().into_raw().len()];
// NOTE:assumes Simd lane factor of 4,
// but this value depends on your hardware!
let lanes = 4;
for (chunk_idx, rgb_chunk) in img
.to_rgb32f()
.into_raw()
.chunks_exact(lanes * 3)
.enumerate()
{
let chunk_start = chunk_idx * lanes * 3;
let mut r_vals = [0f32; 4];
let mut g_vals = [0f32; 4];
let mut b_vals = [0f32; 4];
for i in 0..lanes {
r_vals[i] = rgb_chunk[i * 3] as f32;
g_vals[i] = rgb_chunk[i * 3 + 1] as f32;
b_vals[i] = rgb_chunk[i * 3 + 2] as f32;
}
let r_f = Simd::from_array(r_vals);
let g_f = Simd::from_array(g_vals);
let b_f = Simd::from_array(b_vals);
let luminance =
Simd::splat(0.299) * r_f + Simd::splat(0.587) * g_f + Simd::splat(0.114) * b_f;
let sat: Simd<f32, 4> = Simd::splat(saturation);
let r_out = luminance + (r_f - luminance) * sat;
let g_out = luminance + (g_f - luminance) * sat;
let b_out = luminance + (b_f - luminance) * sat;
for i in 0..lanes {
out_img_raw[chunk_start + i * 3] = (r_out[i].clamp(0.0, 1.0) * 255.0) as u8;
out_img_raw[chunk_start + i * 3 + 1] = (g_out[i].clamp(0.0, 1.0) * 255.0) as u8;
out_img_raw[chunk_start + i * 3 + 2] = (b_out[i].clamp(0.0, 1.0) * 255.0) as u8;
}
}
let out_img = image::RgbImage::from_raw(img.width(), img.height(), out_img_raw)
.expect("Failed to create image");
Ok(image::DynamicImage::ImageRgb8(out_img))
}
Comparison of approaches:

Applying a saturation factor of 2.5 with each approach:



Performance using a 3mb image wise we get:
. ./benchmarks/saturation rs 12:27
hsv 1.71s user 0.12s system 107% cpu 1.704 total
lum 1.02s user 0.11s system 112% cpu 1.010 total
lumsimd 1.11s user 0.15s system 111% cpu 1.121 total
The luminance simd approach is actually slower than without in this instance 🤔 so it would be worth spending time to make some optimizations there. For video processing video files, using SIMD should be a 3-10x speed increase.
I verified the same results on a 25mb image as well.
Conclusion
There are a bunch of way to edit an image's saturation. Each has it's tradeoffs or side-effects.
In the case of editing photos, the photographer's eye is the final judge; it's probably not super important which technique you use if you're adjusting saturation within reasonable limits. In that case, I'd probably opt for whatever is fastest.
Personally, I find saturation to be less useful compared to vibrance, which I'll look at in another post!