jnylen-colorscore 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 1aa628a12c958769b9f8e4dfa9fe4f8b436e42e8
4
+ data.tar.gz: 6bfcd87069d5461451e08bc88f05507d9004fc36
5
+ SHA512:
6
+ metadata.gz: 2b26dfc2e43aa85dea7693f4a4c88ea6b3f125912a9be0a484d3c99445937fa529da2ade54ced4a86caffd4aff1d8c3a0c1ea92d8a73f6b990f017843de8bb5f
7
+ data.tar.gz: dea93b215d1eaf5d6e271dfa158b420c0eb7cee7d738b541a2da8295060718bed57ce7454e088c35dfb5597e5f8ad8e3599ba0233e9fdfbcd707b044adb9bee8
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "http://rubygems.org"
2
+
3
+ gem 'test-unit'
4
+
5
+ # Specify your gem's dependencies in colorscore.gemspec
6
+ gemspec
data/README.md ADDED
@@ -0,0 +1,22 @@
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
+ ```
data/Rakefile ADDED
@@ -0,0 +1,7 @@
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
@@ -0,0 +1,21 @@
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 = "jnylen-colorscore"
7
+ s.version = Colorscore::VERSION
8
+ s.authors = ["Joakim Nylén", "Milo Winningham"]
9
+ s.email = ["me@jnylen.nu"]
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
@@ -0,0 +1,43 @@
1
+ require "shellwords"
2
+
3
+ module Colorscore
4
+ class Histogram
5
+ def initialize(image_path, options = {})
6
+ params = [
7
+ '-resize 400x400',
8
+ '-format %c',
9
+ "-dither #{options.fetch(:dither) { 'None' }}"
10
+ "-quantize #{options.fetch(:quantize) { 'YIQ' }}",
11
+ "-colors #{options.fetch(:colors) { 16 }.to_i}",
12
+ "-depth #{options.fetch(:depth) { 8 }.to_i}",
13
+ '-alpha deactivate '
14
+ ]
15
+
16
+ #params.unshift(options[:resize]) if options[:resize]
17
+
18
+ output = `convert #{image_path.shellescape} #{ params.join(' ') } histogram:info:-`
19
+ @lines = output.lines.map(&:strip).reject(&:empty?).
20
+ sort_by { |l| l[/(\d+):/, 1].to_i }
21
+ end
22
+
23
+ # Returns an array of colors in descending order of occurances.
24
+ def hex_colors
25
+ hex_values = @lines.map { |line| line[/#([0-9A-F]{6}) /, 1] }.compact
26
+ hex_values.map { |hex| Color::RGB.from_html(hex) }
27
+ end
28
+
29
+ def rgb_colors
30
+ @lines.map { |line| line[/ \(([0-9, ]+)\) /, 1].split(',').map(&:strip).take(3).join(',') }.compact
31
+ end
32
+
33
+ def color_counts
34
+ @lines.map { |line| line.split(':')[0].to_i }
35
+ end
36
+
37
+ def scores
38
+ total = color_counts.inject(:+).to_f
39
+ scores = color_counts.map { |count| count / total }
40
+ scores.zip(colors)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,135 @@
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
@@ -0,0 +1,68 @@
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
+
40
+ # From a large palette, collects scores and reduces them based on
41
+ # the distance threshold (which can be varied to find a specific number of colors)
42
+ def collect_scores(distance_threshold=0.275, min_colors=3, max_colors=7)
43
+ def generate_scores(threshold)
44
+ @res = [[1,self[0]]]
45
+ @pivot = self[0]
46
+ self.each do |color|
47
+ if (Metrics.distance(@pivot, color)) > threshold
48
+ @res << [1,color]
49
+ @pivot = color
50
+ else
51
+ @res.last[0] +=1
52
+ end
53
+ end
54
+ @res
55
+ end
56
+
57
+ @results = generate_scores(distance_threshold)
58
+
59
+ if @results.length <= min_colors && distance_threshold > 0.05
60
+ self.collect_scores((distance_threshold-0.05))
61
+ elsif @results.length > max_colors && distance_threshold < 0.5
62
+ self.collect_scores((distance_threshold+0.05))
63
+ else
64
+ return @results
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,3 @@
1
+ module Colorscore
2
+ VERSION = "0.1.0"
3
+ end
data/lib/colorscore.rb ADDED
@@ -0,0 +1,6 @@
1
+ require "color"
2
+
3
+ require "colorscore/histogram"
4
+ require "colorscore/metrics"
5
+ require "colorscore/palette"
6
+ require "colorscore/version"
Binary file
Binary file
@@ -0,0 +1,14 @@
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_operator histogram.colors.size, :<=, colors
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,13 @@
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
@@ -0,0 +1,3 @@
1
+ require "test/unit"
2
+ require "colorscore"
3
+ include Colorscore
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jnylen-colorscore
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Joakim Nylén
8
+ - Milo Winningham
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2016-08-08 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: color
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '0'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '0'
28
+ - !ruby/object:Gem::Dependency
29
+ name: rake
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '0'
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: test-unit
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ description: Finds the dominant colors in an image and scores them against a user-defined
57
+ palette, using the CIE2000 Delta E formula.
58
+ email:
59
+ - me@jnylen.nu
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - ".gitignore"
65
+ - Gemfile
66
+ - README.md
67
+ - Rakefile
68
+ - colorscore.gemspec
69
+ - lib/colorscore.rb
70
+ - lib/colorscore/histogram.rb
71
+ - lib/colorscore/metrics.rb
72
+ - lib/colorscore/palette.rb
73
+ - lib/colorscore/version.rb
74
+ - test/fixtures/skydiver.jpg
75
+ - test/fixtures/transparency.png
76
+ - test/histogram_test.rb
77
+ - test/metrics_test.rb
78
+ - test/palette_test.rb
79
+ - test/test_helper.rb
80
+ homepage:
81
+ licenses: []
82
+ metadata: {}
83
+ post_install_message:
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubyforge_project:
99
+ rubygems_version: 2.5.1
100
+ signing_key:
101
+ specification_version: 4
102
+ summary: Finds the dominant colors in an image.
103
+ test_files: []