camalian 0.0.3 → 0.2.2
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 +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 +29 -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,29 @@
|
|
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
|
+
buckets = buckets.values
|
17
|
+
buckets = buckets.sort_by(&:size).reverse
|
18
|
+
average_colors = buckets.map(&:average_color)
|
19
|
+
Palette.new(average_colors[0...count])
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def bucket_key(color, bucket_size)
|
25
|
+
"#{color.r / bucket_size}:#{color.g / bucket_size}:#{color.b / bucket_size}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
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
|