jnylen-colorscore 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/Gemfile +6 -0
- data/README.md +22 -0
- data/Rakefile +7 -0
- data/colorscore.gemspec +21 -0
- data/lib/colorscore/histogram.rb +43 -0
- data/lib/colorscore/metrics.rb +135 -0
- data/lib/colorscore/palette.rb +68 -0
- data/lib/colorscore/version.rb +3 -0
- data/lib/colorscore.rb +6 -0
- data/test/fixtures/skydiver.jpg +0 -0
- data/test/fixtures/transparency.png +0 -0
- data/test/histogram_test.rb +14 -0
- data/test/metrics_test.rb +13 -0
- data/test/palette_test.rb +13 -0
- data/test/test_helper.rb +3 -0
- metadata +103 -0
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
data/Gemfile
ADDED
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
data/colorscore.gemspec
ADDED
@@ -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
|
data/lib/colorscore.rb
ADDED
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
|
data/test/test_helper.rb
ADDED
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: []
|