Color - a comprehensive color library

Color (github) is a comprehensive color library. Now at version 0.2 but much more developed than the long ago 0.1. In fact I expect to move this to 1.0 release quite quickly since I consider its API stable. Feedback might change that though! :slight_smile:

Features

  • 20+ color space structs including Color.SRGB, Color.AdobeRGB, Color.RGB (linear, in any of 24 named working spaces), Color.Lab, Color.LCHab, Color.Luv, Color.LCHuv, Color.XYZ, Color.XYY, Color.Oklab, Color.Oklch, Color.HSLuv, Color.HPLuv, Color.Hsl, Color.Hsv, Color.CMYK, Color.YCbCr, Color.JzAzBz, Color.ICtCp, Color.IPT, Color.CAM16UCS.

  • Top-level conversion API. Color.new/2 and Color.convert/2,3,4 accept structs, hex strings, CSS named colors, atoms, or bare lists of numbers (with strict per-space validation) and convert between any pair of supported spaces. Color.convert_many/2,3,4 is the batch equivalent. Alpha is preserved across every path.

  • Chromatic adaptation with six methods (:bradford, :xyz_scaling, :von_kries, :sharp, :cmccat2000, :cat02). Color.convert/3,4 auto-adapts the source illuminant when the target requires a fixed reference white.

  • ICC rendering intents wired into Color.convert/3,4: :relative_colorimetric (default), :absolute_colorimetric, :perceptual, :saturation. Optional black-point compensation via bpc: true.

  • ICC matrix-profile reader (Color.ICC.Profile) for ICC v2 / v4 RGB→XYZ profiles, with curv LUT and para parametric tone response curves. Loads profiles like sRGB IEC61966-2.1.icc, Display P3.icc, AdobeRGB1998.icc, and most camera and scanner profiles.

  • Color difference (ΔE). CIE76, CIE94, CIEDE2000 (verified against the Sharma 2005 test data), and CMC l:c.

  • Contrast. WCAG 2.x relative luminance and contrast ratio, APCA W3 0.1.9 (L_c), and pick_contrasting/2 for accessibility helpers.

  • Mixing and gradients. Color.Mix.mix/4 interpolates in any supported space (default Oklab) with CSS Color 4 hue-interpolation modes (:shorter, :longer, :increasing, :decreasing). Color.Mix.gradient/4 produces evenly spaced gradients.

  • Gamut checking and mapping. Color.Gamut.in_gamut?/2 and Color.Gamut.to_gamut/3 with the CSS Color 4 Oklch binary-search algorithm or simple RGB clip.

  • Color harmonies. Complementary, analogous, triadic, tetradic, and split-complementary in any cylindrical space (default Oklch).

  • Color temperature. CCT ↔ chromaticity, Planckian locus and CIE daylight locus.

  • CSS Color Module Level 4 / 5. Full parser and serialiser for hex, named colors, rgb()/rgba(), hsl()/hsla(), hwb(), lab(), lch(), oklab(), oklch(), color(srgb|display-p3|rec2020|…), device-cmyk(), color-mix(), relative color syntax, none keyword, and calc() expressions.

  • ~COLOR sigil for compile-time color literals in any supported space.

  • Spectral pipeline. Color.Spectral and Color.Spectral.Tables provide the CIE 1931 2° and CIE 1964 10° standard observer CMFs, the D65 / D50 / A / E illuminant SPDs, emissive and reflective integration to XYZ, and a metamerism helper.

  • Blend modes. All 16 CSS Compositing Level 1 modes (:multiply, :screen, :overlay, :darken, :lighten, :color_dodge, :color_burn, :hard_light, :soft_light, :difference, :exclusion, :hue, :saturation, :color, :luminosity, :normal).

  • Transfer functions. sRGB, gamma 2.2 / 1.8, L*, BT.709, BT.2020, PQ (SMPTE ST 2084), HLG, Adobe RGB γ.

A proper Color module has been largely the last sticking point before I could consider moving image to version 1.0. With this update to color I will soon be able to move image to 1.0 too.

60 Likes

Splendid!

Do you think Color.ANSI (terminal escape sequences) might belong to this library, alongside with Color.CSS or it’s out of scope?

3 Likes

Oooooo.I like that idea. Something like Color.to_ansi/1 you mean? Parsing the sequences too?

2 Likes

Yes. Both parsing and producing. I recently published Marcli which is essentially a markdown parser for terminals and I would love to use more sophisticated coloring there.

Consider it done for the next release. Maybe even today.

14 Likes

:heart:

Thank you!

And yes, I’ll never get tired of repeating it, your work on Elixir ecosystem is astonishing, thank you for that.

17 Likes

I published Color v0.3.0 with the following additions:

  • Color.ANSI module for parsing and emitting ANSI SGR colour escape sequences. Supports 16-colour, 256-colour indexed, and 24-bit truecolor forms, with perceptual nearest-palette matching (CIEDE2000) when encoding to the 16- or 256-colour palette. Includes parse/1, to_string/2, wrap/3, nearest_256/1, nearest_16/1, palette_256/0, palette_16/0, and a typed Color.ANSI.ParseError exception.

  • Top-level Color.to_hex/1, Color.to_css/1,2, and Color.to_ansi/1,2 convenience functions that accept any input Color.new/1 accepts and raise a typed exception on failure.

And yes, AI can definitely assist in cases like this where the domain is clear, the specification is clear and the expected results are clear.

12 Likes

They say, Socrates once argued that Understanding a question is half an answer. Literally nothing has changed with ages.

7 Likes

One more thing you might want to consider adding: extra meta-info to colors.

This is maybe special to my use case, but when you try to address an LED strip, you can not only specify an RGB tripplet, but also one or even 2 white LEDs. It would be nice if those could be specified as part of a color.

I know that this a rather special use case and can cause quite a few headaches, but I thought it might still be worth considering.

You can already specify and illuminate (typically d50 or d65) on a conversion. Can you point me at a reference I can study - happy to look at adding color metadata.

So cool!

Is there a way so safely convert oklch to a displayable srgb color?

Yes, there is. Either directly during the conversion or separately to force a color into gamut.

TLDR; use intent: :perceptual on conversions to force gamut mapping. Its a no-op if the color is already in gamut.

Examples

wild = %Color.Oklch{l: 0.85, c: 0.3, h: 100.0}

# Perceptual gamut map — safe to display:
{:ok, mapped} = Color.convert(wild, Color.SRGB, intent: :perceptual)
Color.Gamut.in_gamut?(mapped, :SRGB)
# => true
Color.to_hex(mapped)
# => "#d7d200"

# Or using a separate gamut step:
{:ok, mapped} = Color.Gamut.to_gamut(wild, :SRGB)

You can also do gamut checking:

# Direct conversion — NOT displayable:
{:ok, raw_rgb} = Color.convert(wild, Color.RGB, working_space: :SRGB)
Color.Gamut.in_gamut?(raw_rgb, :SRGB)
# => false
1 Like

let me look at d50/d65 :slightly_smiling_face:

WS2814: has RGBW (an extra white led)

WS2805 has RGBW1W2 (two extra white leds)

1 Like

I don’t know what I will use this for, but I have to use it for something. Thank you for the great work!

2 Likes

I learned a lot about LED lighting products (which is useful to understand my home installation better too). d50/d65 was a red herring. They are applicable to reflective color whereas LEDs are, of course, emissive.

Hope you enjoy the all new color 0.4.0. There is a bit of math to map the mix of RGB + W or RGB + WW that aims to give the nearest perceptual match but it’s quite possible the math isn’t the most optimal. Feedback definitely welcome. Here are some of the new functions:

Color.LED.RGBW — 4-channel (R, G, B, W)

This is for chips with one fixed-temperature white LED per pixel like WS2814 and SK6812-RGBW.

# 1. Build a pixel from a hex colour for a WS2814 neutral-white strip
{:ok, target} = Color.new("#ffa500")            # sRGB orange
pixel = Color.LED.RGBW.from_srgb(target, chip: :ws2814_nw)
# => %Color.LED.RGBW{r: 0.832…, g: 0.162…, b: 0.0, w: 0.252…, white_temperature: 4500, alpha: nil}

# 2. The white LED takes over for achromatic colours
{:ok, white} = Color.new("#ffffff")
pixel = Color.LED.RGBW.from_srgb(white, chip: :ws2814_ww)
# => r, g, b ≈ 0  |  w ≈ 1.0  (all light from the warm-white LED)

# 3. Pure saturated red can't use the white LED at all
{:ok, red} = Color.new("#ff0000")
pixel = Color.LED.RGBW.from_srgb(red, white_temperature: 3000)
# => r: 1.0, g: 0.0, b: 0.0, w: 0.0

# 4. Specify the white LED temperature explicitly instead of a chip preset
{:ok, sky} = Color.new("#87ceeb")
pixel = Color.LED.RGBW.from_srgb(sky, white_temperature: 5000)

# 5. Preview what the pixel will actually emit
{:ok, preview} = Color.LED.RGBW.to_srgb(pixel)
Color.to_hex(preview)
# => "#87ceea" (or very close — exact round-trip for most sRGB colours)

# 6. Go all the way to XYZ for analysis
{:ok, xyz} = Color.LED.RGBW.to_xyz(pixel)

Color.LED.RGBWW — 5-channel (R, G, B, WW, CW)

This is for chips with warm-white + cool-white LEDs per pixel like WS2805 and RGB+CCT SK6812 variants. The two whites blend to reach any CCT between them. Hence why these need to be specified.

# 1. Build a pixel for a WS2805 from an sRGB colour
{:ok, target} = Color.new("#ffa500")
pixel = Color.LED.RGBWW.from_srgb(target, chip: :ws2805)
# => %Color.LED.RGBWW{r: …, g: …, b: …, ww: …, cw: …, warm_temperature: 3000, cool_temperature: 6500, alpha: nil}

# 2. A warm target drives mostly the warm-white LED
{:ok, warm} = Color.new([1.0, 0.85, 0.6])
pixel = Color.LED.RGBWW.from_srgb(warm, chip: :ws2805)
# => pixel.ww > pixel.cw

# 3. A cool target drives mostly the cool-white LED
{:ok, cool} = Color.new([0.85, 0.9, 1.0])
pixel = Color.LED.RGBWW.from_srgb(cool, chip: :ws2805)
# => pixel.cw > pixel.ww

# 4. Pure white splits across both whites to land on ~D65
{:ok, white} = Color.new("#ffffff")
pixel = Color.LED.RGBWW.from_srgb(white, chip: :ws2805)
# => ww + cw ≈ 1.0,  r, g, b ≈ 0

# 5. Custom warm/cool temperatures (e.g. a 2700 K + 5700 K fixture)
{:ok, target} = Color.new("rebeccapurple")
pixel =
  Color.LED.RGBWW.from_srgb(target,
    warm_temperature: 2700,
    cool_temperature: 5700
  )

# 6. Or pass chip_options/1 directly
options = Color.LED.chip_options(:ws2805)
# => [kind: :rgbww, warm_temperature: 3000, cool_temperature: 6500]
{:ok, target} = Color.new("#336699")
pixel = Color.LED.RGBWW.from_srgb(target, options)

# 7. Simulate what the pixel emits
{:ok, preview} = Color.LED.RGBWW.to_srgb(pixel)

Picking the right chip preset

Color.LED.chips()
# => [:sk6812_cw, :sk6812_nw, :sk6812_ww,
#     :ws2805, :ws2814_cw, :ws2814_nw, :ws2814_ww]

Color.LED.chip_options(:ws2814_ww)
# => [kind: :rgbw, white_temperature: 3000]

Color.LED.chip_options(:sk6812_cw)
# => [kind: :rgbw, white_temperature: 6500]

Color.LED.chip_options(:ws2805)
# => [kind: :rgbww, warm_temperature: 3000, cool_temperature: 6500]

Writing pixel bytes to a strip

The channel values are linear floats in [0, 1]. For a typical 8-bit addressable strip, scale and round. Its just plain old bitstring composition (gotta love OTP):

defp to_bytes(%Color.LED.RGBW{r: r, g: g, b: b, w: w}) do
  <<round(r * 255), round(g * 255), round(b * 255), round(w * 255)>>
end

defp to_bytes(%Color.LED.RGBWW{r: r, g: g, b: b, ww: ww, cw: cw}) do
  <<round(r * 255), round(g * 255), round(b * 255),
    round(ww * 255), round(cw * 255)>>
end

It seems that the WS2814 datasheet order is typically GRBW, and WS2805 is typically GRB-WW-CW, so you may need to swap the first two bytes to match your specific chip and SPI driver. I’m fine to add a to_bytes/1 (or maybe better named to_bitstring/1) but I’d need guidance on the canonical byte order - or a configurable option.

1 Like

Does it really make sense for color to have knowledge about individual chips? This doesn’t look very scalable to me and I’m not sure how useful a subset of chips is.

You may be right. Hopefully @a-maze-d has an informed opinion about this. My reading suggested there is a small number of standard chips but I am definitely not a domain expert so feedback very welcome.

wow! :hushed_face:

I would agree that the LED specific part should be left out from your library

I think it makes more sense for me to integrate it into Fledex. But with the RGBW(W) addition I should be able to kigrate most color related things to your library.

Let me take a look at it at home in the evening (hopefully) and get back to you

1 Like

I don’t think I can leave all the LED-specific stuff out because colour conversion needs to know the CCT of the white LED(s). But if, after your consideration, you think taking the chip-specific code out I’m happy to do that. The CCTs can be specified in degrees-K instead without knowing the chipset.

1 Like

That’s really cool! I didn’t look into the color libraries, so I have a possibly stupid question.

I imagine that this library is mostly data transformations, and doesn’t contain many BEAM-specific features. If that’s the case, would it make sense to implement this in C or Zig, to make it available to the broader ecosystem? Then it would be trivial to wrap the library in any other language, e.g. Python, and get a high quality color library out of it.

I understand if it’s not something you’re personally interested in doing, just wondering about the approach.