vissen-output 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vissen
4
+ module Output
5
+ # This is the top level output error class and should be subclassed by all
6
+ # other custom error classes used in this library.
7
+ class Error < StandardError
8
+ end
9
+ end
10
+ 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