diff --git a/lib/color/rgb.rb b/lib/color/rgb.rb index 1295995..d108fae 100644 --- a/lib/color/rgb.rb +++ b/lib/color/rgb.rb @@ -354,7 +354,7 @@ def closest_match(color_list, threshold_distance = 1000.0) best_match = nil color_list.each do |c| - distance = delta_e94(lab, c.to_lab) + distance = delta_e2k(lab, c.to_lab) if (distance < closest_distance) closest_distance = distance best_match = c @@ -437,6 +437,109 @@ def delta_e94(color_1, color_2, weighting_type = :graphic_arts) Math.sqrt(composite_L + composite_C + composite_H) end + # The Delta E (CIEDE2000) algorithm + # http://en.wikipedia.org/wiki/Color_difference#CIEDE2000 + # + # This newer version 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. + # + # The calculations go through LCH(ab). (?) + # + # Comment numbers match up with this research document outlining implementation steps: + # http://www.ece.rochester.edu/~gsharma/ciede2000/ciede2000noteCRNA.pdf + # + # NOTE: This should be moved to Color::Lab. + def delta_e2k(color_1, color_2) + # Weighting factors + kl = 1.0 + kc = 1.0 + kh = 1.0 + + # Conversions + radians = lambda { |n| n * (Math::PI / 180.0) } + degrees = lambda { |n| n * (180.0 / Math::PI) } + + # Step 1. Calculate c1, c2, c_bar, c1_prime, c2_prime, h_prime + c1 = Math.sqrt( (color_1[:a] ** 2) + (color_1[:b] ** 2) ) # 2 + c2 = Math.sqrt( (color_2[:a] ** 2) + (color_2[:b] ** 2) ) # 2 + c_bar = (c1 + c2).to_f / 2 # 3 + + g = 0.5 * ( 1 - Math.sqrt( (c_bar ** 7).to_f / (c_bar ** 7 + 25 ** 7) ) ) # 4 + + a1_prime = (1 + g) * color_1[:a] # 5 + a2_prime = (1 + g) * color_2[:a] # 5 + + c1_prime = Math.sqrt( (a1_prime ** 2) + (color_1[:b] ** 2) ) # 6 + c2_prime = Math.sqrt( (a2_prime ** 2) + (color_2[:b] ** 2) ) # 6 + + h1 = degrees.call( Math.atan2(color_1[:b], a1_prime) ) # 7 + h2 = degrees.call( Math.atan2(color_2[:b], a2_prime) ) # 7 + h1 = h1 + 360 if h1 < 0 # 7 + h2 = h2 + 360 if h2 < 0 # 7 + + # Step 2. Calculate delta_l, delta_c, h_bar, delta_h + delta_l = color_2[:L] - color_1[:L] # 8 + delta_c = c2_prime - c1_prime # 9 + + # h_prime: 10 + h_diff = h1 - h2 + if c1_prime * c2_prime == 0 + h_prime = 0 + elsif h_diff.abs <= 180 + h_prime = h2 - h1 + elsif h_diff > 180 + h_prime = (h2 - h1) - 360 + else + h_prime = (h1 - h2) + 360 + end + + delta_h = 2 * Math.sqrt(c1_prime * c2_prime) * Math.sin(radians.call(h_prime.to_f / 2)) # 11 + + # Step 3. Calculate l_bar, c_bar, h_bar, t, delta_theta, rc, sl, sh, sc, rt + l_bar = (color_1[:L] + color_2[:L]).to_f / 2 # 12 + c_bar = (c1_prime + c2_prime).to_f / 2 # 13 + + # h_bar: 14 + if c1_prime * c2_prime == 0 + h_bar = h1 + h2 + elsif h_diff.abs <= 180 + h_bar = (h1 + h2).to_f / 2 + elsif h1 + h2 < 360 + h_bar = (h1 + h2 + 360).to_f / 2 + elsif h1 + h2 >= 360 + h_bar = (h1 + h2 - 360).to_f / 2 + end + + # t: 15 + t = 1 - + (0.17 * Math.cos(radians.call(h_bar - 30))) + + (0.24 * Math.cos(radians.call(2 * h_bar))) + + (0.32 * Math.cos(radians.call(3 * h_bar + 6))) - + (0.20 * Math.cos(radians.call(4 * h_bar - 63))) + + delta_theta = 30 * Math.exp( -(( (h_bar - 275) / 25 ) ** 2) ) # 16 + rc = 2 * Math.sqrt( (c_bar ** 7).to_f / (c_bar ** 7 + 25 ** 7) ) # 17 + + # sl: 18 + sl = 1 + ( ( 0.015 * ((l_bar - 50) ** 2) ).to_f / + ( Math.sqrt(20 + ( (l_bar - 50) ** 2 ) ) ) ) + + sc = 1 + 0.045 * c_bar # 19 + sh = 1 + 0.015 * c_bar * t # 20 + rt = -(Math.sin(radians.call(2 * delta_theta)) * rc) # 21 + + # Calculate the CIEDE2000 Color-Difference + Math.sqrt( + ( ( delta_l.to_f / (kl * sl) ) ** 2) + + ( ( delta_c.to_f / (kc * sc) ) ** 2 ) + + ( ( delta_h.to_f / (kh * sh) ) ** 2 ) + + ( rt * ( (delta_c.to_f / (kc * sc)) * (delta_h.to_f / (kh * sh)) ) ) + ) + end + # Returns the red component of the colour in the normal 0 .. 255 range. def red @r * 255.0 diff --git a/test/test_rgb.rb b/test/test_rgb.rb index 3bdebb5..74cffd7 100644 --- a/test/test_rgb.rb +++ b/test/test_rgb.rb @@ -298,10 +298,10 @@ def test_closest_match # 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 + # DarkRed & Firebrick are visually closer than Crimson and Firebrick # (more precise match) match_from += [Color::RGB::DarkRed, Color::RGB::Crimson] - assert_equal(Color::RGB::Crimson, + assert_equal(Color::RGB::DarkRed, Color::RGB::Firebrick.closest_match(match_from)) # Specifying a threshold low enough will cause even that match to # fail, though. @@ -316,6 +316,13 @@ def test_closest_match # 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)) + + # It should match a very dark Blue to Black instead of MidnightBlue + # (this is something CIE94 is not good at) + almost_black = Color::RGB.new(41, 33, 44) + match_from += [Color::RGB::MidnightBlue, Color::RGB::Black] + assert_equal(Color::RGB::Black, + almost_black.closest_match(match_from)) end def test_add