camalian 0.0.2 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.github/workflows/gpr-push.yml +30 -0
- data/.github/workflows/ruby.yml +32 -0
- data/.github/workflows/rubygems-push.yml +29 -0
- data/.gitignore +2 -1
- data/.overcommit.yml +33 -0
- data/.rubocop.yml +37 -0
- data/.tool-versions +1 -0
- data/.travis.yml +17 -0
- data/Gemfile +6 -0
- data/README.md +45 -5
- data/Rakefile +6 -3
- data/camalian.gemspec +16 -15
- data/lib/camalian.rb +20 -14
- data/lib/camalian/color.rb +47 -34
- data/lib/camalian/image.rb +24 -22
- data/lib/camalian/palette.rb +24 -5
- data/lib/camalian/quantization/histogram.rb +26 -0
- data/lib/camalian/quantization/k_means.rb +59 -0
- data/lib/camalian/quantization/median_cut.rb +74 -0
- data/lib/camalian/version.rb +3 -1
- data/test/assets/palette.png +0 -0
- data/test/color_test.rb +32 -0
- data/test/palette_test.rb +64 -0
- data/test/quantization/histogram_test.rb +61 -0
- data/test/quantization/k_means_test.rb +61 -0
- data/test/quantization/median_cut_test.rb +63 -0
- data/test/test_helper.rb +11 -0
- metadata +56 -23
- data/lib/chunky_png_patch/color.rb +0 -14
- data/test/test_color.rb +0 -29
- data/test/test_palette.rb +0 -28
data/lib/camalian/image.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Camalian
|
4
|
+
# Load image into Camalian
|
4
5
|
class Image
|
5
6
|
attr_accessor :src_file_path
|
6
7
|
|
@@ -8,29 +9,30 @@ module Camalian
|
|
8
9
|
@src_file_path = file_path
|
9
10
|
end
|
10
11
|
|
11
|
-
def prominent_colors(count=Camalian.options[:color_count]
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
12
|
+
def prominent_colors(count = Camalian.options[:color_count],
|
13
|
+
quantization: Camalian.options[:quantization],
|
14
|
+
optimal: true)
|
15
|
+
image = ::ChunkyPNG::Image.from_file(@src_file_path)
|
16
|
+
|
17
|
+
colors = image.pixels.map do |val|
|
18
|
+
Color.new(
|
19
|
+
ChunkyPNG::Color.r(val),
|
20
|
+
ChunkyPNG::Color.g(val),
|
21
|
+
ChunkyPNG::Color.b(val)
|
22
|
+
)
|
21
23
|
end
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
colors << Color.new(c[0],c[1],c[2])
|
24
|
+
|
25
|
+
quantize = quantization.new
|
26
|
+
|
27
|
+
palette = quantize.process(colors, count)
|
28
|
+
|
29
|
+
retry_count = 1
|
30
|
+
while !optimal && palette.size < count
|
31
|
+
palette = quantize.process(colors, count + retry_count)
|
32
|
+
retry_count += 1
|
32
33
|
end
|
33
|
-
|
34
|
+
|
35
|
+
palette
|
34
36
|
end
|
35
37
|
end
|
36
38
|
end
|
data/lib/camalian/palette.rb
CHANGED
@@ -1,27 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Camalian
|
4
|
+
# Collection of colors with some useful features
|
2
5
|
class Palette < Array
|
6
|
+
def self.from_hex(hex_values)
|
7
|
+
new(hex_values.map { |v| Color.from_hex(v) })
|
8
|
+
end
|
3
9
|
|
4
10
|
def sort_by_lightness
|
5
|
-
Palette.new(
|
11
|
+
Palette.new(sort_by(&:l).reverse)
|
6
12
|
end
|
7
13
|
|
8
14
|
def sort_by_hue
|
9
|
-
Palette.new(
|
15
|
+
Palette.new(sort_by(&:h).reverse)
|
10
16
|
end
|
11
17
|
|
12
18
|
def sort_similar_colors
|
13
|
-
Palette.new(
|
19
|
+
Palette.new(sort_by(&:hsv))
|
14
20
|
end
|
15
21
|
|
16
22
|
def sort_by_saturation
|
17
|
-
Palette.new(
|
23
|
+
Palette.new(sort_by(&:s).reverse)
|
24
|
+
end
|
25
|
+
|
26
|
+
def average_color
|
27
|
+
r = map(&:r).inject(&:+)
|
28
|
+
g = map(&:g).inject(&:+)
|
29
|
+
b = map(&:b).inject(&:+)
|
30
|
+
size = self.size
|
31
|
+
|
32
|
+
Color.new(r / size, g / size, b / size)
|
18
33
|
end
|
19
34
|
|
20
35
|
def light_colors(limit1, limit2)
|
21
36
|
min = [limit1, limit2].min
|
22
37
|
max = [limit1, limit2].max
|
23
|
-
table =
|
38
|
+
table = dup
|
24
39
|
Palette.new(table.delete_if { |color| color.l > max or color.l < min })
|
25
40
|
end
|
41
|
+
|
42
|
+
def to_hex
|
43
|
+
map(&:to_hex)
|
44
|
+
end
|
26
45
|
end
|
27
46
|
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camalian
|
4
|
+
module Quantization
|
5
|
+
class Histogram # :nodoc:
|
6
|
+
def process(colors, count)
|
7
|
+
bucket_size = (255.0 / count).ceil
|
8
|
+
buckets = {}
|
9
|
+
|
10
|
+
colors.each do |color|
|
11
|
+
key = bucket_key(color, bucket_size)
|
12
|
+
buckets[key] ||= Palette.new
|
13
|
+
buckets[key].push(color)
|
14
|
+
end
|
15
|
+
|
16
|
+
Palette.new(buckets.map { |_, value| value.average_color }[0...count])
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def bucket_key(color, bucket_size)
|
22
|
+
"#{color.r / bucket_size}:#{color.g / bucket_size}:#{color.b / bucket_size}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camalian
|
4
|
+
module Quantization
|
5
|
+
class KMeans # :nodoc:
|
6
|
+
INIT_TRIES = 10
|
7
|
+
|
8
|
+
def process(colors, count)
|
9
|
+
# Its faster to extract unique colors once
|
10
|
+
colors = colors.uniq
|
11
|
+
means = initial_means(colors, count)
|
12
|
+
|
13
|
+
done = false
|
14
|
+
|
15
|
+
until done
|
16
|
+
groups = group_by_means(colors, means)
|
17
|
+
new_means = groups.map do |group|
|
18
|
+
Palette.new(group).average_color
|
19
|
+
end
|
20
|
+
common = means & new_means
|
21
|
+
|
22
|
+
done = common.size == means.size
|
23
|
+
means = new_means
|
24
|
+
end
|
25
|
+
|
26
|
+
Palette.new(means)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def initial_means(colors, count)
|
32
|
+
count = colors.size if count > colors.size
|
33
|
+
means = []
|
34
|
+
tries = 0
|
35
|
+
while means.size != count && tries < INIT_TRIES
|
36
|
+
(count - means.size).times { means << colors[rand(colors.size)] }
|
37
|
+
means.uniq!
|
38
|
+
tries += 1
|
39
|
+
end
|
40
|
+
|
41
|
+
means
|
42
|
+
end
|
43
|
+
|
44
|
+
def group_by_means(pixels, means)
|
45
|
+
groups = {}
|
46
|
+
|
47
|
+
pixels.each do |p|
|
48
|
+
distances = means.map { |m| p.rgb_distance(m) }
|
49
|
+
min_distance_index = distances.index(distances.min)
|
50
|
+
|
51
|
+
groups[min_distance_index] ||= []
|
52
|
+
groups[min_distance_index] << p
|
53
|
+
end
|
54
|
+
|
55
|
+
groups.values
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Camalian
|
4
|
+
module Quantization
|
5
|
+
class MedianCut # :nodoc:
|
6
|
+
SplitInfo = Struct.new(:range, :group_index, :color, keyword_init: true)
|
7
|
+
def process(colors, count)
|
8
|
+
# Its faster to extract unique colors once
|
9
|
+
colors = colors.uniq
|
10
|
+
groups = [colors]
|
11
|
+
limit = [count, colors.size].min
|
12
|
+
|
13
|
+
loop do
|
14
|
+
split_info = determine_group_split(groups)
|
15
|
+
group1, group2 = split_group(groups[split_info.group_index], split_info)
|
16
|
+
groups.reject!.each_with_index { |_g, index| index == split_info.group_index }
|
17
|
+
groups << group1 unless group1.empty?
|
18
|
+
groups << group2 unless group2.empty?
|
19
|
+
|
20
|
+
break if groups.size >= limit
|
21
|
+
end
|
22
|
+
|
23
|
+
palletes = groups.map { |g| Palette.new(g.uniq) }
|
24
|
+
average_colors = palletes.map(&:average_color)
|
25
|
+
|
26
|
+
Palette.new(average_colors[0...count])
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def determine_group_split(groups)
|
32
|
+
stats = []
|
33
|
+
|
34
|
+
groups.each_with_index do |group, index|
|
35
|
+
reds = group.map(&:r)
|
36
|
+
greens = group.map(&:g)
|
37
|
+
blues = group.map(&:b)
|
38
|
+
|
39
|
+
ranges = []
|
40
|
+
ranges << SplitInfo.new(group_index: index, range: reds.max - reds.min, color: :r) unless reds.empty?
|
41
|
+
ranges << SplitInfo.new(group_index: index, range: greens.max - greens.min, color: :g) unless greens.empty?
|
42
|
+
ranges << SplitInfo.new(group_index: index, range: blues.max - blues.min, color: :b) unless blues.empty?
|
43
|
+
|
44
|
+
stats << ranges.max_by(&:range)
|
45
|
+
end
|
46
|
+
|
47
|
+
stats.max_by(&:range)
|
48
|
+
end
|
49
|
+
|
50
|
+
def split_group(group, split_info)
|
51
|
+
split_color = split_info.color
|
52
|
+
colors = group.sort_by { |pixel| pixel.send split_color }
|
53
|
+
|
54
|
+
return [[colors[0]], [colors[1]]] if colors.size == 2
|
55
|
+
|
56
|
+
median_index = colors.size / 2
|
57
|
+
median_value = colors[median_index].send split_color
|
58
|
+
|
59
|
+
group1 = []
|
60
|
+
group2 = []
|
61
|
+
|
62
|
+
colors.each do |color|
|
63
|
+
if color.send(split_color) <= median_value
|
64
|
+
group1 << color
|
65
|
+
else
|
66
|
+
group2 << color
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
[group1, group2]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
data/lib/camalian/version.rb
CHANGED
data/test/assets/palette.png
CHANGED
Binary file
|
data/test/color_test.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'test_helper'
|
4
|
+
require 'minitest/autorun'
|
5
|
+
require 'minitest/spec'
|
6
|
+
require 'camalian'
|
7
|
+
|
8
|
+
describe Camalian::Color do
|
9
|
+
before do
|
10
|
+
@color = Camalian::Color.new(120, 255, 30)
|
11
|
+
end
|
12
|
+
|
13
|
+
describe 'Color initialized with 120, 255, 30 rgb values' do
|
14
|
+
it 'hex value must be #78ff1e' do
|
15
|
+
_(@color.to_hex).must_equal '#78ff1e'
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'hsl color components must ' do
|
19
|
+
_([@color.h.to_i, @color.s.to_i, @color.l.to_i]).must_equal [96, 100, 55]
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'hsv color components must ' do
|
23
|
+
_(@color.hsv.map(&:to_i)).must_equal [96, 88, 100]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
describe 'initialized with 1 integer rgb value' do
|
28
|
+
it 'must have leading zero' do
|
29
|
+
_(Camalian::Color.new(7, 7, 7).to_hex).must_equal '#070707'
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'test_helper'
|
4
|
+
require 'minitest/autorun'
|
5
|
+
require 'minitest/spec'
|
6
|
+
require 'camalian'
|
7
|
+
|
8
|
+
describe Camalian::Palette do
|
9
|
+
before do
|
10
|
+
mixed_colors = %w[#2560a2 #4dd915 #1530db #49cc23 #45c031 #3da84d #1d48be #399c5b #359069 #318478 #2d7886 #296c94
|
11
|
+
#2154b0 #193ccc #41b43f].freeze
|
12
|
+
|
13
|
+
@palette = Camalian::Palette.from_hex(mixed_colors)
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'initialize palette with correct size' do
|
17
|
+
_(@palette.size).must_equal 15
|
18
|
+
end
|
19
|
+
|
20
|
+
describe '#sort_by_lightness' do
|
21
|
+
it 'sort colors by lightness ' do
|
22
|
+
_(@palette.sort_by_lightness.to_hex).must_equal ['#41b43f', '#45c031', '#1530db', '#49cc23', '#4dd915',
|
23
|
+
'#193ccc', '#3da84d', '#1d48be', '#399c5b', '#2154b0',
|
24
|
+
'#2560a2', '#359069', '#296c94', '#318478', '#2d7886']
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
describe '#sort_by_hue' do
|
29
|
+
it 'sort colors by hue' do
|
30
|
+
_(@palette.sort_by_hue.to_hex).must_equal ['#1530db', '#193ccc', '#1d48be', '#2154b0', '#2560a2', '#296c94',
|
31
|
+
'#2d7886', '#318478', '#359069', '#399c5b', '#3da84d', '#41b43f',
|
32
|
+
'#45c031', '#49cc23', '#4dd915']
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
describe '#sort_by_saturation' do
|
37
|
+
it 'sort colors by saturation' do
|
38
|
+
_(@palette.sort_by_saturation.to_hex).must_equal ['#1530db', '#4dd915', '#193ccc', '#1d48be', '#49cc23',
|
39
|
+
'#2154b0', '#2560a2', '#45c031', '#296c94', '#2d7886',
|
40
|
+
'#41b43f', '#3da84d', '#399c5b', '#359069', '#318478']
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
describe '#sort_similar_colors' do
|
45
|
+
it 'sort colors by hsv' do
|
46
|
+
_(@palette.sort_similar_colors.to_hex).must_equal ['#4dd915', '#49cc23', '#45c031', '#41b43f', '#3da84d',
|
47
|
+
'#399c5b', '#359069', '#318478', '#2d7886', '#296c94',
|
48
|
+
'#2560a2', '#2154b0', '#1d48be', '#193ccc', '#1530db']
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe '#average_color' do
|
53
|
+
it 'return one average color' do
|
54
|
+
_(@palette.average_color.to_hex).must_equal '#318477'
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
describe '#light_colors' do
|
59
|
+
it 'extract light colors in provided limit' do
|
60
|
+
_(@palette.light_colors(40, 90).to_hex).must_equal ['#4dd915', '#1530db', '#49cc23', '#45c031', '#3da84d',
|
61
|
+
'#1d48be', '#399c5b', '#2154b0', '#193ccc', '#41b43f']
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'test_helper'
|
4
|
+
require 'minitest/autorun'
|
5
|
+
require 'minitest/spec'
|
6
|
+
require 'camalian'
|
7
|
+
|
8
|
+
describe Camalian::Quantization::Histogram do
|
9
|
+
describe 'palette with 15 colors extracted' do
|
10
|
+
before do
|
11
|
+
@image = Camalian.load(File.join(File.dirname(__FILE__), '../assets/palette.png'))
|
12
|
+
@palette = @image.prominent_colors(15, optimal: false, quantization: Camalian::QUANTIZATION_HISTOGRAM)
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'must have 15 colors' do
|
16
|
+
_(@palette.size).must_equal 15
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'sort similar colors in order' do
|
20
|
+
_(@palette.sort_similar_colors.map(&:to_hex)).must_equal PALLET_IMAGE_COLORS
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'should extract distinct colors' do
|
25
|
+
colors = %w[#FF0000 #00FF00 #0000FF].map { |c| Camalian::Color.from_hex(c) }
|
26
|
+
result = Camalian::Quantization::Histogram.new.process(colors, 3)
|
27
|
+
|
28
|
+
_(result.size).must_equal 3
|
29
|
+
_(result).must_equal colors
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'should extract distinct colors lesser than pixels' do
|
33
|
+
colors = %w[#FF0000 #00FF00 #0000FF].map { |c| Camalian::Color.from_hex(c) }
|
34
|
+
result = Camalian::Quantization::Histogram.new.process(colors, 2)
|
35
|
+
|
36
|
+
_(result.size).must_equal 2
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'should extract distinct colors not more than pixels' do
|
40
|
+
colors = %w[#FF0000 #00FF00 #0000FF].map { |c| Camalian::Color.from_hex(c) }
|
41
|
+
result = Camalian::Quantization::Histogram.new.process(colors, 4)
|
42
|
+
|
43
|
+
_(result.size).must_equal 3
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'should extract same color' do
|
47
|
+
colors = %w[#FF0000 #FF0000 #FF0000].map { |c| Camalian::Color.from_hex(c) }
|
48
|
+
result = Camalian::Quantization::Histogram.new.process(colors, 3)
|
49
|
+
|
50
|
+
_(result.size).must_equal 1
|
51
|
+
_(result.to_hex).must_equal ['#ff0000']
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'should extract multiple colors' do
|
55
|
+
colors = %w[#FF0000 #FF0000 #00FF00 #0000FF].map { |c| Camalian::Color.from_hex(c) }
|
56
|
+
result = Camalian::Quantization::Histogram.new.process(colors, 3)
|
57
|
+
|
58
|
+
_(result.size).must_equal 3
|
59
|
+
_(result.to_hex).must_equal %w[#ff0000 #00ff00 #0000ff]
|
60
|
+
end
|
61
|
+
end
|