├── .gitignore ├── lib ├── colorscore │ ├── version.rb │ ├── histogram.rb │ ├── palette.rb │ └── metrics.rb └── colorscore.rb ├── test ├── test_helper.rb ├── fixtures │ ├── skydiver.jpg │ └── transparency.png ├── palette_test.rb ├── metrics_test.rb └── histogram_test.rb ├── Gemfile ├── Rakefile ├── README.md └── colorscore.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | -------------------------------------------------------------------------------- /lib/colorscore/version.rb: -------------------------------------------------------------------------------- 1 | module Colorscore 2 | VERSION = "0.0.5" 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "test/unit" 2 | require "colorscore" 3 | include Colorscore -------------------------------------------------------------------------------- /test/fixtures/skydiver.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quadule/colorscore/HEAD/test/fixtures/skydiver.jpg -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in colorscore.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /test/fixtures/transparency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quadule/colorscore/HEAD/test/fixtures/transparency.png -------------------------------------------------------------------------------- /lib/colorscore.rb: -------------------------------------------------------------------------------- 1 | require "color" 2 | 3 | require "colorscore/histogram" 4 | require "colorscore/metrics" 5 | require "colorscore/palette" 6 | require "colorscore/version" 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/testtask" 2 | require "bundler/gem_tasks" 3 | 4 | task :default => :test 5 | Rake::TestTask.new do |t| 6 | t.test_files = FileList["test/test_helper.rb", "test/*_test.rb"] 7 | end 8 | -------------------------------------------------------------------------------- /test/palette_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../test_helper", __FILE__) 2 | 3 | class PaletteTest < Test::Unit::TestCase 4 | def setup 5 | @histogram = Histogram.new("test/fixtures/skydiver.jpg") 6 | @palette = Palette.default 7 | end 8 | 9 | def test_skydiver_photo_is_mostly_blue 10 | score, color = @palette.scores(@histogram.scores).first 11 | assert_equal Color::RGB.from_html('0099cc'), color 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/metrics_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../test_helper", __FILE__) 2 | 3 | class MetricsTest < Test::Unit::TestCase 4 | def test_no_distance_between_identical_colors 5 | color = Color::RGB.new(123, 45, 67) 6 | assert_equal 0, Metrics.distance(color, color) 7 | end 8 | 9 | def test_maximum_similarity_between_identical_colors 10 | color = Color::RGB.new(123, 45, 67) 11 | assert_equal 1, Metrics.similarity(color, color) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/histogram_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../test_helper", __FILE__) 2 | 3 | class HistogramTest < Test::Unit::TestCase 4 | def test_color_count_is_correct 5 | colors = 7 6 | histogram = Histogram.new("test/fixtures/skydiver.jpg", colors) 7 | assert_equal colors, histogram.colors.size 8 | end 9 | 10 | def test_transparency_is_ignored 11 | histogram = Histogram.new("test/fixtures/transparency.png") 12 | assert_equal Color::RGB.from_html('0000ff'), histogram.colors.first 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Colorscore 2 | 3 | Colorscore is a simple library that uses ImageMagick to quantize an image and find its representative colors. It can also score those colors against a palette using the CIE2000 Delta E formula. This could be used to index images for a "search by color" feature. 4 | 5 | ## Requirements 6 | 7 | * ImageMagick 6.5+ 8 | 9 | ## Usage 10 | 11 | ```ruby 12 | include Colorscore 13 | histogram = Histogram.new('test/fixtures/skydiver.jpg') 14 | 15 | # This image is 78.8% #7a9ab5: 16 | histogram.scores.first # => [0.7884625, RGB [#7a9ab5]] 17 | 18 | # This image is closest to pure blue: 19 | palette = Palette.from_hex(['ff0000', '00ff00', '0000ff']) 20 | scores = palette.scores(histogram.scores, 1) 21 | scores.first # => [0.16493763694876, RGB [#0000ff]] 22 | ``` -------------------------------------------------------------------------------- /colorscore.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "colorscore/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "colorscore" 7 | s.version = Colorscore::VERSION 8 | s.authors = ["Milo Winningham"] 9 | s.email = ["milo@winningham.net"] 10 | s.summary = %q{Finds the dominant colors in an image.} 11 | s.description = %q{Finds the dominant colors in an image and scores them against a user-defined palette, using the CIE2000 Delta E formula.} 12 | 13 | s.add_dependency "color" 14 | s.add_development_dependency "rake" 15 | s.add_development_dependency "test-unit" 16 | 17 | s.files = `git ls-files`.split("\n") 18 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 19 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 20 | s.require_paths = ["lib"] 21 | end 22 | -------------------------------------------------------------------------------- /lib/colorscore/histogram.rb: -------------------------------------------------------------------------------- 1 | require "shellwords" 2 | 3 | module Colorscore 4 | class Histogram 5 | def initialize(image_path, colors=16, depth=8) 6 | output = `convert #{image_path.shellescape} -resize 400x400 -format %c -dither None -quantize YIQ -colors #{colors.to_i} -depth #{depth.to_i} histogram:info:-` 7 | @lines = output.lines.sort.reverse.map(&:strip).reject(&:empty?) 8 | end 9 | 10 | # Returns an array of colors in descending order of occurances. 11 | def colors 12 | hex_values = @lines.map { |line| line[/#([0-9A-F]{6}) /, 1] }.compact 13 | hex_values.map { |hex| Color::RGB.from_html(hex) } 14 | end 15 | 16 | def color_counts 17 | @lines.map { |line| line.split(':')[0].to_i } 18 | end 19 | 20 | def scores 21 | total = color_counts.inject(:+).to_f 22 | scores = color_counts.map { |count| count / total } 23 | scores.zip(colors) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/colorscore/palette.rb: -------------------------------------------------------------------------------- 1 | module Colorscore 2 | class Palette < Array 3 | DEFAULT = ["660000", "990000", "cc0000", "cc3333", "ea4c88", "993399", 4 | "663399", "333399", "0066cc", "0099cc", "66cccc", "77cc33", 5 | "669900", "336600", "666600", "999900", "cccc33", "ffff00", 6 | "ffcc33", "ff9900", "ff6600", "cc6633", "996633", "663300", 7 | "000000", "999999", "cccccc", "ffffff"] 8 | 9 | def self.default 10 | from_hex DEFAULT 11 | end 12 | 13 | def self.from_hex(hex_values) 14 | new hex_values.map { |hex| Color::RGB.from_html(hex) } 15 | end 16 | 17 | def scores(histogram_scores, distance_threshold=0.275) 18 | scores = map do |palette_color| 19 | score = 0 20 | 21 | histogram_scores.each_with_index do |item, index| 22 | color_score, color = *item 23 | 24 | color = color.to_hsl.tap { |c| c.s = 0.05 + c.s * (4 - c.l * 2.5) }.to_rgb 25 | 26 | if (distance = Metrics.distance(palette_color, color)) < distance_threshold 27 | distance_penalty = (1 - distance) ** 4 28 | score += color_score * distance_penalty 29 | end 30 | end 31 | 32 | [score, palette_color] 33 | end 34 | 35 | scores.reject { |score, color| score <= 0.05 }. 36 | sort_by { |score, color| score }. 37 | reverse 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/colorscore/metrics.rb: -------------------------------------------------------------------------------- 1 | module Colorscore 2 | module Metrics 3 | def self.similarity(a, b) 4 | 1 - distance(a, b) 5 | end 6 | 7 | def self.distance(color_1, color_2) 8 | l1, a1, b1 = xyz_to_lab(*rgb_to_xyz(color_1)) 9 | l2, a2, b2 = xyz_to_lab(*rgb_to_xyz(color_2)) 10 | 11 | distance = delta_e_cie_2000(l1, a1, b1, l2, a2, b2) 12 | scale(distance, 0..100) 13 | end 14 | 15 | # Ported from colormath for Python. 16 | def self.delta_e_cie_2000(l1, a1, b1, l2, a2, b2) 17 | kl = kc = kh = 1 18 | 19 | avg_lp = (l1 + l2) / 2.0 20 | c1 = Math.sqrt((a1 ** 2) + (b1 ** 2)) 21 | c2 = Math.sqrt((a2 ** 2) + (b2 ** 2)) 22 | avg_c1_c2 = (c1 + c2) / 2.0 23 | 24 | g = 0.5 * (1 - Math.sqrt((avg_c1_c2 ** 7.0) / ((avg_c1_c2 ** 7.0) + (25.0 ** 7.0)))) 25 | 26 | a1p = (1.0 + g) * a1 27 | a2p = (1.0 + g) * a2 28 | c1p = Math.sqrt((a1p ** 2) + (b1 ** 2)) 29 | c2p = Math.sqrt((a2p ** 2) + (b2 ** 2)) 30 | avg_c1p_c2p = (c1p + c2p) / 2.0 31 | 32 | h1p = ([b1, a1p] == [0.0, 0.0]) ? 0.0 : degrees(Math.atan2(b1,a1p)) 33 | h1p += 360 if h1p < 0 34 | 35 | h2p = ([b2, a2p] == [0.0, 0.0]) ? 0.0 : degrees(Math.atan2(b2,a2p)) 36 | h2p += 360 if h2p < 0 37 | 38 | if (h1p - h2p).abs > 180 39 | avg_hp = (h1p + h2p + 360) / 2.0 40 | else 41 | avg_hp = (h1p + h2p) / 2.0 42 | end 43 | 44 | t = 1 - 0.17 * Math.cos(radians(avg_hp - 30)) + 0.24 * Math.cos(radians(2 * avg_hp)) + 0.32 * Math.cos(radians(3 * avg_hp + 6)) - 0.2 * Math.cos(radians(4 * avg_hp - 63)) 45 | 46 | diff_h2p_h1p = h2p - h1p 47 | if diff_h2p_h1p.abs <= 180 48 | delta_hp = diff_h2p_h1p 49 | elsif diff_h2p_h1p.abs > 180 && h2p <= h1p 50 | delta_hp = diff_h2p_h1p + 360 51 | else 52 | delta_hp = diff_h2p_h1p - 360 53 | end 54 | 55 | delta_lp = l2 - l1 56 | delta_cp = c2p - c1p 57 | delta_hp = 2 * Math.sqrt(c2p * c1p) * Math.sin(radians(delta_hp) / 2.0) 58 | 59 | s_l = 1 + ((0.015 * ((avg_lp - 50) ** 2)) / Math.sqrt(20 + ((avg_lp - 50) ** 2.0))) 60 | s_c = 1 + 0.045 * avg_c1p_c2p 61 | s_h = 1 + 0.015 * avg_c1p_c2p * t 62 | 63 | delta_ro = 30 * Math.exp(-((((avg_hp - 275) / 25) ** 2.0))) 64 | r_c = Math.sqrt(((avg_c1p_c2p ** 7.0)) / ((avg_c1p_c2p ** 7.0) + (25.0 ** 7.0))); 65 | r_t = -2 * r_c * Math.sin(2 * radians(delta_ro)) 66 | 67 | delta_e = Math.sqrt(((delta_lp / (s_l * kl)) ** 2) + ((delta_cp / (s_c * kc)) ** 2) + ((delta_hp / (s_h * kh)) ** 2) + r_t * (delta_cp / (s_c * kc)) * (delta_hp / (s_h * kh))) 68 | end 69 | 70 | def self.rgb_to_xyz(color) 71 | color = color.to_rgb 72 | r, g, b = color.r, color.g, color.b 73 | 74 | # assuming sRGB (D65) 75 | r = (r <= 0.04045) ? r/12.92 : ((r+0.055)/1.055) ** 2.4 76 | g = (g <= 0.04045) ? g/12.92 : ((g+0.055)/1.055) ** 2.4 77 | b = (b <= 0.04045) ? b/12.92 : ((b+0.055)/1.055) ** 2.4 78 | 79 | r *= 100 80 | g *= 100 81 | b *= 100 82 | 83 | x = 0.412453*r + 0.357580*g + 0.180423*b 84 | y = 0.212671*r + 0.715160*g + 0.072169*b 85 | z = 0.019334*r + 0.119193*g + 0.950227*b 86 | 87 | [x, y, z] 88 | end 89 | 90 | def self.xyz_to_lab(x, y, z) 91 | x /= 95.047 92 | y /= 100.000 93 | z /= 108.883 94 | 95 | if x > 0.008856 96 | x = x ** (1.0/3) 97 | else 98 | x = (7.787 * x) + (16.0 / 116) 99 | end 100 | 101 | if y > 0.008856 102 | y = y ** (1.0/3) 103 | else 104 | y = (7.787 * y) + (16.0 / 116) 105 | end 106 | 107 | if z > 0.008856 108 | z = z ** (1.0/3) 109 | else 110 | z = (7.787 * z) + (16.0 / 116) 111 | end 112 | 113 | l = (116.0 * y) - 16.0 114 | a = 500.0 * (x - y) 115 | b = 200.0 * (y - z) 116 | 117 | [l, a, b] 118 | end 119 | 120 | def self.scale(number, from_range, to_range=0..1, clamp=true) 121 | if clamp && number <= from_range.begin 122 | position = 0 123 | elsif clamp && number >= from_range.end 124 | position = 1 125 | else 126 | position = (number - from_range.begin).to_f / (from_range.end - from_range.begin) 127 | end 128 | 129 | position * (to_range.end - to_range.begin) + to_range.begin 130 | end 131 | 132 | def self.radians(degrees); degrees * Math::PI / 180; end 133 | def self.degrees(radians); radians * 180 / Math::PI; end 134 | end 135 | end --------------------------------------------------------------------------------