Skip to content
Permalink
Browse files

Add Color::RGB#closest_match using…

…the CIE Delta E 94 algorithm.

- Based on work by Aaron Hill (@armahillo), adapted to the Color
  codebase conventions by Austin Ziegler (@halostatue).

- Fixes #5, reported by @armahillo.
  • Loading branch information...
halostatue committed Apr 26, 2014
2 parents 8f3c1ef + 194e6a7 commit ce6013bcf3b2513d039cbdfef1847111b16a0cba
Showing with 261 additions and 28 deletions.
  1. +3 −0 Contributing.rdoc
  2. +14 −0 History.rdoc
  3. +14 −22 README.rdoc
  4. +3 −3 color.gemspec
  5. +1 −1 lib/color.rb
  6. +185 −2 lib/color/rgb.rb
  7. +41 −0 test/test_rgb.rb
@@ -55,3 +55,6 @@ Here's the most direct way to get your work merged into the project:

* Austin Ziegler created color-tools.
* Matt Lyons created color.
* Dave Heitzman (contrast comparison)
* Thomas Sawyer
* Aaron Hill (CIE94 colour matching)
@@ -1,3 +1,17 @@
== 1.6 / 2014-MM-DD

* Major enhancements:
* Aaron Hill (@armahillo) implemented the CIE Delta E 94 method by which an
RGB colour can be asked for the closest matching colour from a list of
provided colours. Fixes #5.
* To implement #closest_match and #delta_e94, conversion methods for sRGB to
XYZ and XYZ to L*a*b* space were implemented. These should be considered
experimental.

* Tooling fixes:
* Ensured that the gem manifest was up-to-date. Fixes #4 reported by @boutil.
Thanks!

== 1.5.1 / 2014-01-28

* color 1.5 was a yanked release.
@@ -15,28 +15,20 @@ named RGB colours (184 with spelling variations) that are commonly supported in
HTML, SVG, and X11 applications. A technique for generating monochromatic
contrasting palettes is also included.

The capabilities of the Color library are limited to pure mathematical
manipulation of the colours based on colour theory without reference to colour
profiles (such as sRGB or Adobe RGB). For most purposes, when working with the
RGB and HSL colours, this won't matter. However, some colour models (like CIE
L*a*b*) are not supported because Color does not yet support colour profiles,
giving no meaningful way to convert colours in absolute colour spaces (like
L*a*b*, XYZ) to non-absolute colour spaces (like RGB).

Color version 1.5.1 is mostly a maintenance release, fixing some bugs that may
have been introduced with the previous release on Ruby 1.8.7. New features
include an experimental contrast comparison method for RGB colours (found in
lib/color/rgb/contrast.rb) provided by Dave Heitzman, and methods suggested by
Thomas Sawyer based on the Spectrum library.

Barring bugs introduced in this release, this will be the last version of color
that supports Ruby 1.8, so make sure that your gem specification is set
properly, to <tt>~> 1.5</tt> if that matters for your application.

=== Note about color 1.5

Color 1.5 was released before the documetation was complete and has been
yanked.
The Color library performs purely mathematical manipulation of the colours
based on colour theory without reference to colour profiles (such as sRGB or
Adobe RGB). For most purposes, when working with RGB and HSL colour spaces,
this won't matter. Absolute colour spaces (like CIE L*a*b* and XYZ) and cannot
be reliably converted to relative colour spaces (like RGB) without colour
profiles.

Color version 1.6 primarily adds a colour matching method for RGB and
experimental CIE L*a*b* and XYZ conversion methods for use with the colour
matching method.

Barring bugs introduced in this release, this is the last version of color that
supports Ruby 1.8, so make sure that your gem specification is set properly (to
<tt>~> 1.6</tt>) if that matters for your application.

== History

@@ -1,15 +1,15 @@
# -*- encoding: utf-8 -*-
# stub: color 1.5.1 ruby lib
# stub: color 1.6 ruby lib

Gem::Specification.new do |s|
s.name = "color"
s.version = "1.5.1"
s.version = "1.6"

s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
s.require_paths = ["lib"]
s.authors = ["Austin Ziegler", "Matt Lyon"]
s.date = "2014-04-26"
s.description = "Color is a Ruby library to provide basic RGB, CMYK, HSL, and other colourspace\nmanipulation support to applications that require it. It also provides 152\nnamed RGB colours (184 with spelling variations) that are commonly supported in\nHTML, SVG, and X11 applications. A technique for generating monochromatic\ncontrasting palettes is also included.\n\nThe capabilities of the Color library are limited to pure mathematical\nmanipulation of the colours based on colour theory without reference to colour\nprofiles (such as sRGB or Adobe RGB). For most purposes, when working with the\nRGB and HSL colours, this won't matter. However, some colour models (like CIE\nL*a*b*) are not supported because Color does not yet support colour profiles,\ngiving no meaningful way to convert colours in absolute colour spaces (like\nL*a*b*, XYZ) to non-absolute colour spaces (like RGB).\n\nColor version 1.5.1 is mostly a maintenance release, fixing some bugs that may\nhave been introduced with the previous release on Ruby 1.8.7. New features\ninclude an experimental contrast comparison method for RGB colours (found in\nlib/color/rgb/contrast.rb) provided by Dave Heitzman, and methods suggested by\nThomas Sawyer based on the Spectrum library.\n\nBarring bugs introduced in this release, this will be the last version of color\nthat supports Ruby 1.8, so make sure that your gem specification is set\nproperly, to <tt>~> 1.5</tt> if that matters for your application."
s.description = "Color is a Ruby library to provide basic RGB, CMYK, HSL, and other colourspace\nmanipulation support to applications that require it. It also provides 152\nnamed RGB colours (184 with spelling variations) that are commonly supported in\nHTML, SVG, and X11 applications. A technique for generating monochromatic\ncontrasting palettes is also included.\n\nThe Color library performs purely mathematical manipulation of the colours\nbased on colour theory without reference to colour profiles (such as sRGB or\nAdobe RGB). For most purposes, when working with RGB and HSL colour spaces,\nthis won't matter. Absolute colour spaces (like CIE L*a*b* and XYZ) and cannot\nbe reliably converted to relative colour spaces (like RGB) without colour\nprofiles.\n\nColor version 1.6 primarily adds a colour matching method for RGB and\nexperimental CIE L*a*b* and XYZ conversion methods for use with the colour\nmatching method.\n\nBarring bugs introduced in this release, this is the last version of color that\nsupports Ruby 1.8, so make sure that your gem specification is set properly (to\n<tt>~> 1.6</tt>) if that matters for your application."
s.email = ["halostatue@gmail.com", "matt@postsomnia.com"]
s.extra_rdoc_files = ["Contributing.rdoc", "History.rdoc", "Licence.rdoc", "Manifest.txt", "README.rdoc", "Contributing.rdoc", "History.rdoc", "Licence.rdoc", "README.rdoc"]
s.files = [".autotest", ".gemtest", ".hoerc", ".minitest.rb", ".travis.yml", "Contributing.rdoc", "Gemfile", "History.rdoc", "Licence.rdoc", "Manifest.txt", "README.rdoc", "Rakefile", "lib/color.rb", "lib/color/cmyk.rb", "lib/color/css.rb", "lib/color/grayscale.rb", "lib/color/hsl.rb", "lib/color/palette.rb", "lib/color/palette/adobecolor.rb", "lib/color/palette/gimp.rb", "lib/color/palette/monocontrast.rb", "lib/color/rgb.rb", "lib/color/rgb/colors.rb", "lib/color/rgb/contrast.rb", "lib/color/rgb/metallic.rb", "lib/color/yiq.rb", "test/minitest_helper.rb", "test/test_adobecolor.rb", "test/test_cmyk.rb", "test/test_color.rb", "test/test_css.rb", "test/test_gimp.rb", "test/test_grayscale.rb", "test/test_hsl.rb", "test/test_monocontrast.rb", "test/test_rgb.rb", "test/test_yiq.rb"]
@@ -3,7 +3,7 @@

# = Colour Management with Ruby
module Color
COLOR_VERSION = '1.5.1'
COLOR_VERSION = '1.6'

class RGB; end
class CMYK; end
@@ -10,7 +10,7 @@ class Color::RGB
class << self
# Creates an RGB colour object from percentages 0..100.
#
# Color::RGB.from_percentage(10, 20 30)
# Color::RGB.from_percentage(10, 20, 30)
def from_percentage(r = 0, g = 0, b = 0, &block)
new(r, g, b, 100.0, &block)
end
@@ -269,6 +269,78 @@ def to_hsl
Color::HSL.from_fraction(hue, sat, lum)
end

# Returns the XYZ colour encoding of the value. Based on the
# {RGB to XYZ}[http://www.brucelindbloom.com/index.html?Eqn_RGB_to_XYZ.html]
# formula presented by Bruce Lindbloom.
#
# Currently only the sRGB colour space is supported.
def to_xyz(color_space = :sRGB)
unless color_space.to_s.downcase == 'srgb'
raise ArgumentError, "Unsupported colour space #{color_space}."
end

# Inverse sRGB companding. Linearizes RGB channels with respect to
# energy.
r, g, b = [ @r, @g, @b ].map { |v|
if (v > 0.04045)
(((v + 0.055) / 1.055) ** 2.4) * 100
else
(v / 12.92) * 100
end
}

# Convert using the RGB/XYZ matrix at:
# http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html#WSMatrices
{
:x => (r * 0.4124564 + g * 0.3575761 + b * 0.1804375),
:y => (r * 0.2126729 + g * 0.7151522 + b * 0.0721750),
:z => (r * 0.0193339 + g * 0.1191920 + b * 0.9503041)
}
end

# Returns the L*a*b* colour encoding of the value via the XYZ colour
# encoding. Based on the
# {XYZ to Lab}[http://www.brucelindbloom.com/index.html?Eqn_XYZ_to_Lab.html]
# formula presented by Bruce Lindbloom.
#
# Currently only the sRGB colour space is supported and defaults to using
# a D65 reference white.
def to_lab(color_space = :sRGB, reference_white = [ 95.047, 100.00, 108.883 ])
xyz = to_xyz

# Calculate the ratio of the XYZ values to the reference white.
# http://www.brucelindbloom.com/index.html?Equations.html
xr = xyz[:x] / reference_white[0]
yr = xyz[:y] / reference_white[1]
zr = xyz[:z] / reference_white[2]

# NOTE: This should be using Rational instead of floating point values,
# otherwise there will be discontinuities.
# http://www.brucelindbloom.com/LContinuity.html
epsilon = (216 / 24389.0)
kappa = (24389 / 27.0)

# And now transform
# http://en.wikipedia.org/wiki/Lab_color_space#Forward_transformation
# There is a brief explanation there as far as the nature of the calculations,
# as well as a much nicer looking modeling of the algebra.
fx, fy, fz = [ xr, yr, zr ].map { |t|
if (t > (epsilon))
t ** (1.0 / 3)
else # t <= epsilon
((kappa * t) + 16) / 116.0
# The 4/29 here is for when t = 0 (black). 4/29 * 116 = 16, and 16 -
# 16 = 0, which is the correct value for L* with black.
# ((1.0/3)*((29.0/6)**2) * t) + (4.0/29)
end
}
{
:L => ((116 * fy) - 16),
:a => (500 * (fx - fy)),
:b => (200 * (fy - fz))
}
end

# Mix the RGB hue with White so that the RGB hue is the specified
# percentage of the resulting colour. Strictly speaking, this isn't a
# darken_by operation.
@@ -350,6 +422,117 @@ def adjust_hue(percent)
hsl.to_rgb
end

# TODO: Identify the base colour profile used for L*a*b* and XYZ
# conversions.

# Calculates and returns the closest match to this colour from a list of
# provided colours. Returns +nil+ if +color_list+ is empty or if there is
# no colour within the +threshold_distance+.
#
# +threshold_distance+ is used to determine the minimum colour distance
# permitted. Uses the CIE Delta E 1994 algorithm (CIE94) to find near
# matches based on perceived visual colour. The default value (1000.0) is
# an arbitrarily large number. The values <tt>:jnd</tt> and
# <tt>:just_noticeable</tt> may be passed as the +threshold_distance+ to
# use the value <tt>2.3</tt>.
def closest_match(color_list, threshold_distance = 1000.0)
color_list = [ color_list ].flatten(1)
return nil if color_list.empty?

threshold_distance = case threshold_distance
when :jnd, :just_noticeable
2.3
else
threshold_distance.to_f
end
lab = to_lab
closest_distance = threshold_distance
best_match = nil

color_list.each do |c|
distance = delta_e94(lab, c.to_lab)
if (distance < closest_distance)
closest_distance = distance
best_match = c
end
end
best_match
end

# The Delta E (CIE94) algorithm
# http://en.wikipedia.org/wiki/Color_difference#CIE94
#
# There is a newer version, CIEDE2000, that uses slightly more complicated
# math, but addresses "the perceptual uniformity issue" left lingering by
# the CIE94 algorithm. color_1 and color_2 are both L*a*b* hashes,
# rendered by #to_lab.
#
# Since our source is treated as sRGB, we use the "graphic arts" presets
# for k_L, k_1, and k_2
#
# The calculations go through LCH(ab). (?)
#
# See also http://www.brucelindbloom.com/index.html?Eqn_DeltaE_CIE94.html
#
# NOTE: This should be moved to Color::Lab.
def delta_e94(color_1, color_2, weighting_type = :graphic_arts)
case weighting_type
when :graphic_arts
k_1 = 0.045
k_2 = 0.015
k_L = 1
when :textiles
k_1 = 0.048
k_2 = 0.014
k_L = 2
else
raise ArgumentError, "Unsupported weighting type #{weighting_type}."
end

# delta_E = Math.sqrt(
# ((delta_L / (k_L * s_L)) ** 2) +
# ((delta_C / (k_C * s_C)) ** 2) +
# ((delta_H / (k_H * s_H)) ** 2)
# )
#
# Under some circumstances in real computers, delta_H could be an
# imaginary number (it's a square root value), so we're going to treat
# this as:
#
# delta_E = Math.sqrt(
# ((delta_L / (k_L * s_L)) ** 2) +
# ((delta_C / (k_C * s_C)) ** 2) +
# (delta_H2 / ((k_H * s_H) ** 2)))
# )
#
# And not perform the square root when calculating delta_H2.

k_C = k_H = 1

l_1, a_1, b_1 = color_1.values_at(:L, :a, :b)
l_2, a_2, b_2 = color_2.values_at(:L, :a, :b)

delta_a = a_1 - a_2
delta_b = b_1 - b_2

c_1 = Math.sqrt((a_1 ** 2) + (b_1 ** 2))
c_2 = Math.sqrt((a_2 ** 2) + (b_2 ** 2))

delta_L = color_1[:L] - color_2[:L]
delta_C = c_1 - c_2

delta_H2 = (delta_a ** 2) + (delta_b ** 2) - (delta_C ** 2)

s_L = 1
s_C = 1 + k_1 * c_1
s_H = 1 + k_2 * c_1

composite_L = (delta_L / (k_L * s_L)) ** 2
composite_C = (delta_C / (k_C * s_C)) ** 2
composite_H = delta_H2 / ((k_H * s_H) ** 2)
Math.sqrt(composite_L + composite_C + composite_H)
end

# Returns the red component of the colour in the normal 0 .. 255 range.
def red
@r * 255.0
@@ -454,7 +637,7 @@ def -(other)
# Retrieve the maxmum RGB value from the current colour as a GrayScale
# colour
def max_rgb_as_grayscale
Color::GrayScale.from_fraction([@r, @g, @b].max)
Color::GrayScale.from_fraction([@r, @g, @b].max)
end
alias max_rgb_as_greyscale max_rgb_as_grayscale

@@ -277,6 +277,47 @@ def test_to_yiq
Color::RGB::Cayenne.to_yiq)
end

def test_to_lab
# Luminosity can be an absolute.
assert_in_delta(0.0, Color::RGB::Black.to_lab[:L], Color::COLOR_TOLERANCE)
assert_in_delta(100.0, Color::RGB::White.to_lab[:L], Color::COLOR_TOLERANCE)

# It's not really possible to have absolute
# numbers here because of how L*a*b* works, but
# negative/positive comparisons work
assert(Color::RGB::Green.to_lab[:a] < 0)
assert(Color::RGB::Magenta.to_lab[:a] > 0)
assert(Color::RGB::Blue.to_lab[:b] < 0)
assert(Color::RGB::Yellow.to_lab[:b] > 0)
end

def test_closest_match
# It should match Blue to Indigo (very simple match)
match_from = [Color::RGB::Red, Color::RGB::Green, Color::RGB::Blue]
assert_equal(Color::RGB::Blue, Color::RGB::Indigo.closest_match(match_from))
# But fails if using the :just_noticeable difference.
assert_nil(Color::RGB::Indigo.closest_match(match_from, :just_noticeable))

# Crimson & Firebrick are visually closer than DarkRed and Firebrick
# (more precise match)
match_from += [Color::RGB::DarkRed, Color::RGB::Crimson]
assert_equal(Color::RGB::Crimson,
Color::RGB::Firebrick.closest_match(match_from))
# Specifying a threshold low enough will cause even that match to
# fail, though.
assert_nil(Color::RGB::Firebrick.closest_match(match_from, 8.0))
# If the match_from list is an empty array, it also returns nil
assert_nil(Color::RGB::Red.closest_match([]))

# RGB::Green is 0,128,0, so we'll pick something VERY close to it, visually
jnd_green = Color::RGB.new(3, 132, 3)
assert_equal(Color::RGB::Green,
jnd_green.closest_match(match_from, :jnd))
# And then something that's just barely out of the tolerance range
diff_green = Color::RGB.new(9, 142, 9)
assert_nil(diff_green.closest_match(match_from, :jnd))
end

def test_add
white = Color::RGB::Cyan + Color::RGB::Yellow
refute_nil(white)

6 comments on commit ce6013b

@pcreux

This comment has been minimized.

Copy link

replied Dec 3, 2014

Thank you for implementing this. You save me hours of obscure math. 💚

@armahillo

This comment has been minimized.

Copy link
Contributor

replied Dec 3, 2014

Hooray! :D Glad it was useful for you. :) It's not perfect but it does alright.

@pcreux

This comment has been minimized.

Copy link

replied Dec 3, 2014

screen shot 2014-12-03 at 11 01 41

@pcreux

This comment has been minimized.

Copy link

replied Dec 3, 2014

That is really good!

@armahillo

This comment has been minimized.

Copy link
Contributor

replied Dec 3, 2014

Nice!!

@halostatue

This comment has been minimized.

Copy link
Owner Author

replied Dec 3, 2014

Please sign in to comment.
You can’t perform that action at this time.