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 +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: []
|