vissen-output 0.6.1

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.
@@ -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