color_tools.gamut

Gamut checking and mapping for sRGB color space.

Not all LAB colors can be represented in sRGB! Some theoretical colors that humans can perceive simply can’t be displayed on a monitor. This module helps you: 1. Check if a LAB color is displayable (in gamut) 2. Find the nearest displayable color if it’s not

The key insight: sRGB is a cube (R, G, B all 0-255), but LAB describes a much larger volume. The intersection is the “sRGB gamut.”

color_tools.gamut.is_in_srgb_gamut(lab, tolerance=None)[source]

Check if a LAB color can be represented in sRGB without clipping.

This is the fundamental question of color reproduction: “Can my monitor actually show this color, or will it have to fake it by clamping?”

How it works: Convert LAB → RGB without clamping. If all RGB components are naturally in the 0-255 range (with a small tolerance), the color is in gamut!

Why tolerance? Floating point math is imprecise. A value of 255.0000001 should be considered “in gamut” even though it’s technically over 255.

Examples of out-of-gamut colors: - Super saturated colors at mid-lightness (like “laser red”) - Colors that would need negative RGB values - Colors that would need RGB values > 255

Parameters:
  • lab (Tuple[float, float, float]) – L*a*b* color tuple

  • tolerance (float | None) – How close to 0/255 boundaries before considering out-of-gamut. If None, uses the global config value (default 0.01).

Return type:

bool

Returns:

True if the color can be represented in sRGB, False if it would clip

Example

>>> is_in_srgb_gamut((50, 0, 0))  # Mid gray
True
>>> is_in_srgb_gamut((50, 150, 100))  # Super saturated - impossible!
False
color_tools.gamut.find_nearest_in_gamut(lab, max_iterations=None)[source]

Find the nearest in-gamut LAB color to a given (possibly out-of-gamut) LAB color.

The Strategy: When a color is out of gamut, it’s usually because it’s too saturated (high chroma). So we convert to LCH, then use binary search to find the maximum chroma that still fits in sRGB. This preserves: - ✅ Lightness (L) - stays the same - ✅ Hue (h) - stays the same - ❌ Chroma (C) - reduced until it fits

Why this works: Think of it like turning down the “saturation” slider in Photoshop until the color becomes displayable. The hue doesn’t change (red stays red), it just becomes less “punchy.”

Binary search magic: Instead of trying every possible chroma value (0.0, 0.01, 0.02, …), we do a binary search. Each iteration cuts the search space in half. After 20 iterations, we’ve checked 2^20 = 1 million possible values! 🚀

Parameters:
  • lab (Tuple[float, float, float]) – Potentially out-of-gamut L*a*b* color

  • max_iterations (int | None) – Maximum desaturation steps (binary search iterations). If None, uses global config value (default 20).

Return type:

Tuple[float, float, float]

Returns:

In-gamut L*a*b* color (closest match that can be displayed)

Example

>>> # Super saturated red - can't be displayed!
>>> out_of_gamut = (50, 100, 80)
>>> displayable = find_nearest_in_gamut(out_of_gamut)
>>> # Result: same lightness and hue, but less saturated
>>> is_in_srgb_gamut(displayable)
True
color_tools.gamut.clamp_to_gamut(lab)[source]

Convert LAB to RGB and back to LAB (with clamping).

This is the “quick and dirty” approach to gamut mapping. Instead of carefully desaturating, we just let lab_to_rgb() clamp the RGB values to 0-255, then convert back to LAB to see what we got.

Pros: Fast!

Cons: Can change hue (not just chroma). A pure red might become a slightly orange-red after clamping.

For most applications, find_nearest_in_gamut() is better because it preserves hue. This function exists for when you need speed over accuracy.

Parameters:

lab (Tuple[float, float, float]) – Any L*a*b* color (in-gamut or not)

Return type:

Tuple[float, float, float]

Returns:

In-gamut L*a*b* color (clamped)