vissen-output 0.6.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.rubocop.yml +64 -0
- data/.travis.yml +5 -0
- data/CHANGELOG.md +61 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +46 -0
- data/LICENSE.txt +21 -0
- data/README.md +40 -0
- data/Rakefile +17 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/vissen/output/buffer.rb +82 -0
- data/lib/vissen/output/color.rb +118 -0
- data/lib/vissen/output/context/circle.rb +70 -0
- data/lib/vissen/output/context/cloud.rb +130 -0
- data/lib/vissen/output/context/grid.rb +103 -0
- data/lib/vissen/output/context.rb +134 -0
- data/lib/vissen/output/context_error.rb +14 -0
- data/lib/vissen/output/error.rb +10 -0
- data/lib/vissen/output/filter/gamma.rb +38 -0
- data/lib/vissen/output/filter/quantizer.rb +64 -0
- data/lib/vissen/output/filter.rb +35 -0
- data/lib/vissen/output/palette.rb +112 -0
- data/lib/vissen/output/palettes.rb +12 -0
- data/lib/vissen/output/pixel.rb +26 -0
- data/lib/vissen/output/pixel_buffer.rb +71 -0
- data/lib/vissen/output/point.rb +57 -0
- data/lib/vissen/output/version.rb +8 -0
- data/lib/vissen/output/vixel.rb +64 -0
- data/lib/vissen/output/vixel_buffer.rb +56 -0
- data/lib/vissen/output/vixel_stack.rb +89 -0
- data/lib/vissen/output.rb +31 -0
- data/vissen-output.gemspec +31 -0
- metadata +148 -0
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vissen
|
4
|
+
module Output
|
5
|
+
module Context
|
6
|
+
# The cloud context imposes no structure on the placement of its elements
|
7
|
+
# but instead accepts coordinates for each individual point.
|
8
|
+
#
|
9
|
+
# == Usage
|
10
|
+
# The following example creates a context with three points, placed on a
|
11
|
+
# straight line.
|
12
|
+
#
|
13
|
+
# placement = [[0.1, 0.1],
|
14
|
+
# [0.5, 0.5],
|
15
|
+
# [0.9, 0.9]]
|
16
|
+
# context = Cloud.new placement
|
17
|
+
#
|
18
|
+
class Cloud
|
19
|
+
include Context
|
20
|
+
|
21
|
+
# @return [Array<Point>] the points in the context.
|
22
|
+
attr_reader :points
|
23
|
+
|
24
|
+
# @param points [Array<Point>, Array<Array<Integer>>] the position of
|
25
|
+
# the points in the context.
|
26
|
+
# @param width [Float] the width of the context.
|
27
|
+
# @param height [Float] the height of the context.
|
28
|
+
# @param args (see Context).
|
29
|
+
def initialize(points, width: 1.0, height: 1.0, **args)
|
30
|
+
super(width, height, **args)
|
31
|
+
|
32
|
+
factor = @width / width
|
33
|
+
|
34
|
+
@points = points.map { |point| Point.from point, scale: factor }
|
35
|
+
freeze
|
36
|
+
end
|
37
|
+
|
38
|
+
# Prevents any more points from being added.
|
39
|
+
#
|
40
|
+
# @return [self]
|
41
|
+
def freeze
|
42
|
+
@points.freeze
|
43
|
+
super
|
44
|
+
end
|
45
|
+
|
46
|
+
# See `Context#point_count`.
|
47
|
+
#
|
48
|
+
# @return [Integer] the number of grid points.
|
49
|
+
def point_count
|
50
|
+
@points.length
|
51
|
+
end
|
52
|
+
|
53
|
+
# @return [Array<Integer>] the x and y coordinates of a point.
|
54
|
+
def position(index)
|
55
|
+
@points[index].position
|
56
|
+
end
|
57
|
+
|
58
|
+
class << self
|
59
|
+
# Randomly places points separated by a minimum distance. Note that
|
60
|
+
# the algorithm is nondeterministic in the time it takes to find the
|
61
|
+
# points.
|
62
|
+
#
|
63
|
+
# Draws a random point from the space of valid coordinates and
|
64
|
+
# calculates the distance to all previously selected points. If the
|
65
|
+
# distances are all greater than the given value the point is accepted
|
66
|
+
# and the process repeats until all points have been found.
|
67
|
+
#
|
68
|
+
# @raise [RangeError] if distance is too great and the allocation
|
69
|
+
# algorithm therefore is unlikely to converge.
|
70
|
+
#
|
71
|
+
# @param point_count [Integer] the number of points to scatter.
|
72
|
+
# @param width [Numeric] the width of the context.
|
73
|
+
# @param height [Numeric] the height of the context.
|
74
|
+
# @param distance [Numeric] the minimum distance between each point.
|
75
|
+
# @param args (see Context).
|
76
|
+
def scatter(point_count,
|
77
|
+
width: 1.0,
|
78
|
+
height: 1.0,
|
79
|
+
distance: nil,
|
80
|
+
**args)
|
81
|
+
if distance
|
82
|
+
d2 = (distance**2)
|
83
|
+
raise RangeError if 2 * d2 * point_count > width * height
|
84
|
+
else
|
85
|
+
d2 = (width * height) / (2.0 * point_count)
|
86
|
+
end
|
87
|
+
|
88
|
+
points = place_points point_count,
|
89
|
+
position_scrambler(width, height),
|
90
|
+
distance_condition(d2)
|
91
|
+
|
92
|
+
new(points, width: width, height: height, **args)
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
# @param x_max [#to_f] the largest value x is allowed to take.
|
98
|
+
# @param y_max [#to_f] the largest value y is allowed to take.
|
99
|
+
# @return [Proc] a proc that ranomizes the first and second element of
|
100
|
+
# the given array.
|
101
|
+
def position_scrambler(x_max, y_max)
|
102
|
+
x_range = 0.0..x_max.to_f
|
103
|
+
y_range = 0.0..y_max.to_f
|
104
|
+
|
105
|
+
proc do |pos|
|
106
|
+
pos[0] = rand x_range
|
107
|
+
pos[1] = rand y_range
|
108
|
+
pos
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def place_points(point_count, scrambler, condition)
|
113
|
+
points = Array.new(point_count) { [0.0, 0.0] }
|
114
|
+
points.each_with_index do |point, i|
|
115
|
+
loop do
|
116
|
+
scrambler.call point
|
117
|
+
break if points[0...i].all?(&condition.curry[point])
|
118
|
+
end
|
119
|
+
end
|
120
|
+
points
|
121
|
+
end
|
122
|
+
|
123
|
+
def distance_condition(distance_squared)
|
124
|
+
->(p, q) { (p[0] - q[0])**2 + (p[1] - q[1])**2 > distance_squared }
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vissen
|
4
|
+
module Output
|
5
|
+
module Context
|
6
|
+
# The grid structure stores the number of rows and columns as well as its
|
7
|
+
# width and height. The dimensions are normalized to fit within a 1x1
|
8
|
+
# square.
|
9
|
+
#
|
10
|
+
# Aspect ratio is defined as width/height. If it is not given each grid
|
11
|
+
# cell is assumed to be square, meaning the aspect_ratio will equal
|
12
|
+
# columns/rows.
|
13
|
+
class Grid
|
14
|
+
include Context
|
15
|
+
|
16
|
+
# @return [Integer] the number of rows in the grid.
|
17
|
+
attr_reader :rows
|
18
|
+
|
19
|
+
# @return [Integer] the number of columns in the grid.
|
20
|
+
attr_reader :columns
|
21
|
+
|
22
|
+
# @param rows [Integer] the number of rows in the grid.
|
23
|
+
# @param columns [Integer] the number of columns in the grid.
|
24
|
+
# @param width [Numeric] (see Context)
|
25
|
+
# @param height [Numeric] (see Context)
|
26
|
+
# @param args (see Context)
|
27
|
+
def initialize(rows,
|
28
|
+
columns,
|
29
|
+
width: (columns - 1).to_f,
|
30
|
+
height: (rows - 1).to_f,
|
31
|
+
**args)
|
32
|
+
raise RangeError if rows <= 0 || columns <= 0
|
33
|
+
|
34
|
+
@rows = rows
|
35
|
+
@columns = columns
|
36
|
+
|
37
|
+
super(width, height, **args)
|
38
|
+
|
39
|
+
define_position
|
40
|
+
end
|
41
|
+
|
42
|
+
# See `Context#point_count`.
|
43
|
+
#
|
44
|
+
# @return [Integer] the number of grid points.
|
45
|
+
def point_count
|
46
|
+
@rows * @columns
|
47
|
+
end
|
48
|
+
|
49
|
+
# See `Context#index_from`.
|
50
|
+
#
|
51
|
+
# WARNING: no range check is performed.
|
52
|
+
#
|
53
|
+
# @param row [Integer] the row of the point of interest.
|
54
|
+
# @param column [Integer] the column of the point of interest.
|
55
|
+
# @return [Integer] the index of a row and column.
|
56
|
+
def index_from(row, column)
|
57
|
+
column * @rows + row
|
58
|
+
end
|
59
|
+
|
60
|
+
# Calculates the row and column of the the point stored at the given
|
61
|
+
# index.
|
62
|
+
#
|
63
|
+
# @param index [Integer] the index of the point of interest.
|
64
|
+
# @return [Array<Integer>] the row and column of a given index.
|
65
|
+
def row_column_from(index)
|
66
|
+
row = (index % @rows)
|
67
|
+
column = (index / @rows)
|
68
|
+
[row, column]
|
69
|
+
end
|
70
|
+
|
71
|
+
# Iterates over each point in the grid and yields the index, row and
|
72
|
+
# column.
|
73
|
+
#
|
74
|
+
# @return [Integer] the number of points in the grid.
|
75
|
+
def each_row_and_column
|
76
|
+
return to_enum(__callee__) unless block_given?
|
77
|
+
|
78
|
+
point_count.times { |i| yield(i, *row_column_from(i)) }
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def define_position
|
84
|
+
# Define #position dynamically based on the
|
85
|
+
# calculated width and height
|
86
|
+
x_factor = @columns == 1 ? 0.0 : width / (@columns - 1)
|
87
|
+
y_factor = @rows == 1 ? 0.0 : height / (@rows - 1)
|
88
|
+
|
89
|
+
# Position
|
90
|
+
#
|
91
|
+
# Returns the x and y coordinates of the grid point at the given
|
92
|
+
# index.
|
93
|
+
define_singleton_method(:position) do |index|
|
94
|
+
[
|
95
|
+
x_factor * (index / @rows),
|
96
|
+
y_factor * (index % @rows)
|
97
|
+
].freeze
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vissen
|
4
|
+
module Output
|
5
|
+
# The output context gives the points that relate to it their position and
|
6
|
+
# color. Contexts can come in different forms and offer different
|
7
|
+
# functionality but they must be able to answer three questions:
|
8
|
+
#
|
9
|
+
# 1. How many points are in the context,
|
10
|
+
# 2. what is the absolute position of each point and
|
11
|
+
# 3. what color palette corresponds to a palette index.
|
12
|
+
#
|
13
|
+
module Context
|
14
|
+
# @return [Float] the normalized width of the context.
|
15
|
+
attr_reader :width
|
16
|
+
|
17
|
+
# @return [Float] the normalized width of the context.
|
18
|
+
attr_reader :height
|
19
|
+
|
20
|
+
# @return [Array<Palette>] the array of palettes used to render the
|
21
|
+
# context.
|
22
|
+
attr_reader :palettes
|
23
|
+
|
24
|
+
# Output contexts give the two things to the vixels within them: a
|
25
|
+
# position and a color.
|
26
|
+
#
|
27
|
+
# The width and height are always normalized to fit within a 1 x 1 square.
|
28
|
+
#
|
29
|
+
# @param width [Numeric] the width of the context.
|
30
|
+
# @param height [Numeric] the height of the context.
|
31
|
+
# @param palettes [Array<Palette>] the color palettes to use when
|
32
|
+
# rendering the context.
|
33
|
+
def initialize(width, height, palettes: PALETTES)
|
34
|
+
if width.negative? || height.negative? || (width.zero? && height.zero?)
|
35
|
+
raise RangeError, 'Contexts needs a size in at least one dimension'
|
36
|
+
end
|
37
|
+
|
38
|
+
# Normalize width and height
|
39
|
+
normalizing_factor = 1.0 / [width, height].max
|
40
|
+
|
41
|
+
@width = width * normalizing_factor
|
42
|
+
@height = height * normalizing_factor
|
43
|
+
@palettes = palettes
|
44
|
+
end
|
45
|
+
|
46
|
+
# This method must be implemented by any class that includes this module.
|
47
|
+
def point_count
|
48
|
+
raise NotImplementedError
|
49
|
+
end
|
50
|
+
|
51
|
+
# @return [true, false] true if the context has only one dimension.
|
52
|
+
def one_dimensional?
|
53
|
+
@width == 0.0 || @height == 0.0
|
54
|
+
end
|
55
|
+
|
56
|
+
# Allocates, for each grid point, one object of the given class.
|
57
|
+
# Optionally takes a block that is expected to return each new object. The
|
58
|
+
# index of the element is passed to the given block.
|
59
|
+
#
|
60
|
+
# @raise [ArgumentError] if both a class and a block are given.
|
61
|
+
#
|
62
|
+
# @param klass [Class] the class of the allocated objects.
|
63
|
+
# @param block [Proc] the block to call for allocating each object.
|
64
|
+
# @return [Array<klass>] an array of new objects.
|
65
|
+
def alloc_points(klass = nil, &block)
|
66
|
+
if klass
|
67
|
+
raise ArgumentError if block_given?
|
68
|
+
block = proc { klass.new }
|
69
|
+
end
|
70
|
+
|
71
|
+
Array.new(point_count, &block)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Iterates over the context points. The index of the the point is passed
|
75
|
+
# to the given block.
|
76
|
+
#
|
77
|
+
# @return [Enumerator, Integer] an `Enumerator` if no block is given,
|
78
|
+
# otherwise the number of points that was iterated over.
|
79
|
+
def each
|
80
|
+
point_count.times
|
81
|
+
end
|
82
|
+
|
83
|
+
# This method must be implemented by a class that includes this module and
|
84
|
+
# should return the x and y coordinates of the point with the given index.
|
85
|
+
#
|
86
|
+
# @param _index [Integer] the index of a point in the context.
|
87
|
+
# @return [Array<Float>] an array containing the x and y coordinates of
|
88
|
+
# the point associated with the given intex.
|
89
|
+
def position(_index)
|
90
|
+
raise NotImplementedError
|
91
|
+
end
|
92
|
+
|
93
|
+
# Iterates over each point in the grid and yields the point index and x
|
94
|
+
# and y coordinates.
|
95
|
+
#
|
96
|
+
# @return (see #each)
|
97
|
+
def each_position
|
98
|
+
return to_enum(__callee__) unless block_given?
|
99
|
+
|
100
|
+
point_count.times do |index|
|
101
|
+
yield(index, *position(index))
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Context specific method to convert any domain specifc property (like row
|
106
|
+
# and column) to an index. The Cloud module calls this method when
|
107
|
+
# resolving the index given to its #[] method.
|
108
|
+
#
|
109
|
+
# @param index [Object] the object or objects that are used to refer to
|
110
|
+
# points in this context.
|
111
|
+
# @return [Integer] the index of the point.
|
112
|
+
def index_from(index)
|
113
|
+
index
|
114
|
+
end
|
115
|
+
|
116
|
+
# This utility method traverses the given target array and calculates for
|
117
|
+
# each corresponding grid point index the squared distance between the
|
118
|
+
# point and the given coordinate.
|
119
|
+
#
|
120
|
+
# @param x [Numeric] the x coordinate to calculate distances from.
|
121
|
+
# @param y [Numeric] the y coordinate to calculate distances from.
|
122
|
+
# @param target [Array<Float>] the target array to populate with
|
123
|
+
# distances.
|
124
|
+
def distance_squared(x, y, target)
|
125
|
+
target.each_with_index do |_, i|
|
126
|
+
x_i, y_i = position i
|
127
|
+
dx = x_i - x
|
128
|
+
dy = y_i - y
|
129
|
+
target[i] = (dx * dx) + (dy * dy)
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vissen
|
4
|
+
module Output
|
5
|
+
# Context errors occur when two components of the output stack are used
|
6
|
+
# together even though they do not share the same context.
|
7
|
+
class ContextError < Error
|
8
|
+
# @param msg [String] the error messge.
|
9
|
+
def initialize(msg = 'The context of the given object does not match')
|
10
|
+
super
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vissen
|
4
|
+
module Output
|
5
|
+
module Filter
|
6
|
+
# Applies gamma correction to the given PixelGrid.
|
7
|
+
class Gamma
|
8
|
+
include Filter
|
9
|
+
|
10
|
+
# @return [Float] the gamma correction value.
|
11
|
+
attr_reader :value
|
12
|
+
|
13
|
+
# @param args (see Filter)
|
14
|
+
# @param value [Float] the gamma correction value.
|
15
|
+
def initialize(*args, value: 2.2)
|
16
|
+
super(*args)
|
17
|
+
|
18
|
+
@value = value
|
19
|
+
|
20
|
+
freeze
|
21
|
+
end
|
22
|
+
|
23
|
+
# Applies the filter to the given pixel cloud.
|
24
|
+
#
|
25
|
+
# @see Filter
|
26
|
+
# @param pixel_buffer [PixelBuffer] the pixel buffer to perform the
|
27
|
+
# filter operation on.
|
28
|
+
def apply(pixel_buffer)
|
29
|
+
pixel_buffer.each do |pixel|
|
30
|
+
pixel.r = pixel.r**@value
|
31
|
+
pixel.g = pixel.g**@value
|
32
|
+
pixel.b = pixel.b**@value
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vissen
|
4
|
+
module Output
|
5
|
+
module Filter
|
6
|
+
# Scales and rounds the color components to only take discrete values.
|
7
|
+
class Quantizer
|
8
|
+
include Filter
|
9
|
+
|
10
|
+
# @raise [RangeError] if steps < 2.
|
11
|
+
# @raise [ArgumentError] if the range is exclusive and has a floating
|
12
|
+
# point end value.
|
13
|
+
#
|
14
|
+
# @param args (see Filter)
|
15
|
+
# @param steps [Integer] the number of quantized steps.
|
16
|
+
# @param range [Range] the range in which the quantized values should
|
17
|
+
# lie.
|
18
|
+
def initialize(*args, steps: 256, range: 0...steps)
|
19
|
+
super(*args)
|
20
|
+
|
21
|
+
raise RangeError if steps < 2
|
22
|
+
|
23
|
+
from = range.begin
|
24
|
+
to = if range.exclude_end?
|
25
|
+
raise ArgumentError if range.end.is_a?(Float)
|
26
|
+
range.end - 1
|
27
|
+
else
|
28
|
+
range.end
|
29
|
+
end
|
30
|
+
|
31
|
+
design_function from, to, steps
|
32
|
+
|
33
|
+
freeze
|
34
|
+
end
|
35
|
+
|
36
|
+
# Applies the filter to the given pixel cloud.
|
37
|
+
#
|
38
|
+
# @see Filter
|
39
|
+
# @param pixel_buffer [PixelBuffer] the pixel Buffer to perform the
|
40
|
+
# filter operation on.
|
41
|
+
def apply(pixel_buffer)
|
42
|
+
pixel_buffer.each do |pixel|
|
43
|
+
pixel.r = @fn.call pixel.r
|
44
|
+
pixel.g = @fn.call pixel.g
|
45
|
+
pixel.b = @fn.call pixel.b
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def design_function(from, to, steps)
|
52
|
+
steps -= 1
|
53
|
+
@fn =
|
54
|
+
if from.zero? && to == steps
|
55
|
+
->(v) { (v * to).round }
|
56
|
+
else
|
57
|
+
factor = (to - from).to_f / steps
|
58
|
+
->(v) { from + (v * steps).round * factor }
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vissen
|
4
|
+
module Output
|
5
|
+
# An output filter is defined as a time invariant operation on a pixel
|
6
|
+
# cloud. Upon initialization the filter is given the output context as a
|
7
|
+
# chance to precompute some results. The rest of the work is done in
|
8
|
+
# `#apply` and should not depend on time.
|
9
|
+
module Filter
|
10
|
+
# @return [Context] the filter context.
|
11
|
+
attr_reader :context
|
12
|
+
|
13
|
+
# @raise [TypeError] if the context is not a `Context`.
|
14
|
+
#
|
15
|
+
# @param context [Context] the context within which the filter will be
|
16
|
+
# applied.
|
17
|
+
def initialize(context)
|
18
|
+
raise TypeError unless context.is_a? Context
|
19
|
+
|
20
|
+
@context = context
|
21
|
+
end
|
22
|
+
|
23
|
+
# This method should apply the filter to the given `PixelBuffer`.
|
24
|
+
#
|
25
|
+
# @raise NotImplementedError if the method is not implemented in the
|
26
|
+
# specific `Filter` implementation.
|
27
|
+
#
|
28
|
+
# @param _pixel_buffer [PixelBuffer] the pixel cloud to which the filter
|
29
|
+
# should be applied.
|
30
|
+
def apply(_pixel_buffer)
|
31
|
+
raise NotImplementedError
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vissen
|
4
|
+
module Output
|
5
|
+
# The Palette is, at its core, a transformation between a position (0..1)
|
6
|
+
# and a color value \\{(0..1) x 3\\}. It can either be continous or be based
|
7
|
+
# on a pre-allocated lookup table.
|
8
|
+
#
|
9
|
+
# == Usage
|
10
|
+
# The following example creates a continuous palette and acesses the color
|
11
|
+
# at index 0.42.
|
12
|
+
#
|
13
|
+
# palette = Palette.new 0x11998e, 0x38ef7d, label: 'Quepal'
|
14
|
+
# palette[0.42] => #21BD87
|
15
|
+
#
|
16
|
+
# A discrete palette can also be created by specifying the number of steps
|
17
|
+
# to use.
|
18
|
+
#
|
19
|
+
# palette = Palette.new 0x11998e, 0x38ef7d, steps: 5
|
20
|
+
# palette[0.42] => #1BAF8A
|
21
|
+
#
|
22
|
+
class Palette
|
23
|
+
# @return [String, nil] the optional palette label.
|
24
|
+
attr_reader :label
|
25
|
+
|
26
|
+
# @param colors [Array<Color>, Array<#to_a>] the colors to use in the
|
27
|
+
# palette.
|
28
|
+
# @param steps [Integer] the number of discrete palette values. The
|
29
|
+
# palette will be continuous if nil.
|
30
|
+
# @param label [String] the optional label to use when identifying the
|
31
|
+
# palette.
|
32
|
+
def initialize(*colors, steps: nil, label: nil)
|
33
|
+
@colors = colors.map { |c| Color.from(c).freeze }
|
34
|
+
@label = label
|
35
|
+
|
36
|
+
if steps
|
37
|
+
define_discrete_accessor steps
|
38
|
+
else
|
39
|
+
define_continous_accessor
|
40
|
+
end
|
41
|
+
|
42
|
+
freeze
|
43
|
+
end
|
44
|
+
|
45
|
+
# Prevents both the palette colors and the label from changing.
|
46
|
+
#
|
47
|
+
# @return [self]
|
48
|
+
def freeze
|
49
|
+
@colors.freeze
|
50
|
+
@label.freeze
|
51
|
+
|
52
|
+
super
|
53
|
+
end
|
54
|
+
|
55
|
+
# Discretize the palette into the given number of values. Palettes defined
|
56
|
+
# with a step count are sampled as if they where continuous.
|
57
|
+
#
|
58
|
+
# @param n [Integer] the number of discrete values to produce.
|
59
|
+
# @return [Array<Color>] an array of colors sampled from the palette.
|
60
|
+
def to_a(n)
|
61
|
+
Array.new(n) { |i| color_at(i.to_f / (n - 1)).freeze }
|
62
|
+
end
|
63
|
+
|
64
|
+
# Example output:
|
65
|
+
# "#42BEAF -> #020180 (rainbow)"
|
66
|
+
#
|
67
|
+
# @return [String] a string representation of the palette made up of the
|
68
|
+
# palette colors as well as the (optional) label.
|
69
|
+
def inspect
|
70
|
+
@colors.map(&:inspect).join(' -> ').tap do |base|
|
71
|
+
break "#{base} (#{@label})" if @label
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def define_discrete_accessor(steps)
|
78
|
+
@palette = to_a steps
|
79
|
+
define_singleton_method :[] do |index|
|
80
|
+
index = if index >= 1.0 then @palette.length - 1
|
81
|
+
elsif index < 0.0 then 0
|
82
|
+
else (index * (@palette.length - 1)).floor
|
83
|
+
end
|
84
|
+
|
85
|
+
@palette[index]
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def define_continous_accessor
|
90
|
+
define_singleton_method(:[]) { |pos| color_at pos }
|
91
|
+
end
|
92
|
+
|
93
|
+
def color_bin(pos)
|
94
|
+
num_bins = (@colors.length - 1)
|
95
|
+
bin = (pos * num_bins).floor
|
96
|
+
mixing_ratio = pos * num_bins - bin
|
97
|
+
|
98
|
+
[bin, mixing_ratio]
|
99
|
+
end
|
100
|
+
|
101
|
+
def color_at(pos)
|
102
|
+
return @colors[0] if pos <= 0
|
103
|
+
return @colors[-1] if pos >= 1
|
104
|
+
|
105
|
+
# Find the two colors we are between
|
106
|
+
bin, r = color_bin pos
|
107
|
+
a, b = @colors[bin, 2]
|
108
|
+
a.mix_with b, r
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vissen
|
4
|
+
module Output
|
5
|
+
# Default palette collection.
|
6
|
+
PALETTES = {
|
7
|
+
'Argon' => [0x03001e, 0x7303c0, 0xec38bc, 0xfdeff9],
|
8
|
+
'Red Sunset' => [0x355c7d, 0x6c5b7b, 0xc06c84],
|
9
|
+
'Quepal' => [0x11998e, 0x38ef7d]
|
10
|
+
}.map { |l, c| Palette.new(*c, label: l) }.freeze
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Vissen
|
4
|
+
module Output
|
5
|
+
# The `Pixel` object is used by the `PixelBuffer` to store the colors of its
|
6
|
+
# points.
|
7
|
+
#
|
8
|
+
# TODO: How do we want the pixel to saturate? When written or when read?
|
9
|
+
class Pixel < Color
|
10
|
+
# Reset the pixel color to black (0, 0, 0).
|
11
|
+
#
|
12
|
+
# @return [self]
|
13
|
+
def clear!
|
14
|
+
self.r = 0
|
15
|
+
self.g = 0
|
16
|
+
self.b = 0
|
17
|
+
self
|
18
|
+
end
|
19
|
+
|
20
|
+
# @return [String] a string representation of the pixel.
|
21
|
+
def inspect
|
22
|
+
format '(%.1f, %.1f, %.1f)', r, g, b
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|