convolver 1.0.1 → 3.0.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,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Convolver
4
+ # Coordinates validation, implementation selection, and one operation family.
5
+ # @private
6
+ class OperationExecution
7
+ DIRECT_OPERATION_COST = { 1 => 6.8e-10, 2 => 5.9e-10, 3 => 4.5e-10 }.freeze
8
+ EXTENSION_COST = 1.0e-9
9
+ FFT_SPEEDUP_MARGIN = 0.8
10
+
11
+ METHODS = {
12
+ correlation: {
13
+ basic: :correlate_basic,
14
+ fft: :correlate_fft,
15
+ predict_basic: :predict_correlate_basic_time,
16
+ predict_fft: :predict_correlate_fft_time
17
+ },
18
+ convolution: {
19
+ basic: :convolve_basic,
20
+ fft: :convolve_fft,
21
+ predict_basic: :predict_convolve_basic_time,
22
+ predict_fft: :predict_convolve_fft_time
23
+ }
24
+ }.freeze
25
+
26
+ def initialize(operation, signal, kernel, mode:, boundary:, fill_value:, origin:)
27
+ @operation = operation
28
+ @signal = signal
29
+ @kernel = kernel
30
+ @mode = mode
31
+ @boundary = boundary
32
+ @fill_value = fill_value
33
+ @origin = origin
34
+ end
35
+
36
+ def automatic(receiver)
37
+ return invoke(receiver, :basic) if plan.extended_size < 1000
38
+
39
+ direct_time = invoke(receiver, :predict_basic)
40
+ return invoke(receiver, :fft) if automatic_fft_time(receiver) < FFT_SPEEDUP_MARGIN * direct_time
41
+
42
+ invoke(receiver, :basic)
43
+ end
44
+
45
+ def basic
46
+ plan.validate_basic!
47
+ extended_signal = plan.extend_signal(signal)
48
+ return Convolver.send(:correlate_basic_valid, extended_signal, kernel) if operation == :correlation
49
+
50
+ Convolver.send(:convolve_basic_valid, extended_signal, kernel)
51
+ end
52
+
53
+ def fft
54
+ plan.fft_buffers
55
+ return Numo::SFloat.cast(signal) * Numo::SFloat.cast(kernel) if signal.ndim.zero?
56
+
57
+ fft_operation.call
58
+ end
59
+
60
+ def fft_time
61
+ FftEstimator.new(operation, signal, kernel, plan).call
62
+ end
63
+
64
+ def basic_time
65
+ plan.validate_basic!
66
+ calculation_cost = direct_operation_cost * plan.result_size * kernel.size
67
+ extension_cost = plan.valid? ? 0.0 : EXTENSION_COST * plan.extended_size
68
+ calculation_cost + extension_cost
69
+ end
70
+
71
+ private
72
+
73
+ attr_reader :operation, :signal, :kernel, :mode, :boundary, :fill_value, :origin
74
+
75
+ def plan
76
+ @plan ||= OperationPlan.new(signal, kernel, operation:, mode:, boundary:, fill_value:, origin:)
77
+ end
78
+
79
+ def invoke(receiver, method)
80
+ receiver.public_send(METHODS.fetch(operation).fetch(method), signal, kernel, **public_options)
81
+ end
82
+
83
+ def public_options
84
+ options = { mode:, boundary:, origin: }
85
+ options[:fill_value] = fill_value unless fill_value.equal?(UNSPECIFIED_FILL)
86
+ options
87
+ end
88
+
89
+ def fft_operation
90
+ return CircularFftOperation.new(operation, signal, kernel, plan) if plan.wrap?
91
+
92
+ LinearFftOperation.new(operation, plan.extend_signal(signal), kernel, plan)
93
+ end
94
+
95
+ def automatic_fft_time(receiver)
96
+ invoke(receiver, :predict_fft)
97
+ rescue FftUnavailable
98
+ Float::INFINITY
99
+ end
100
+
101
+ def direct_operation_cost
102
+ DIRECT_OPERATION_COST.fetch(signal.ndim, DIRECT_OPERATION_COST.values.last)
103
+ end
104
+ end
105
+
106
+ private_constant :OperationExecution
107
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Convolver
4
+ # Validates and normalizes one convolution or correlation operation.
5
+ # @private
6
+ class OperationOptions
7
+ OPERATIONS = %i[convolution correlation].freeze
8
+ MODES = %i[valid same full].freeze
9
+ BOUNDARIES = %i[constant nearest reflect mirror wrap].freeze
10
+
11
+ attr_reader :operation, :mode, :boundary, :fill_value, :origins, :anchors
12
+
13
+ def initialize(signal, kernel, operation:, mode:, boundary:, fill_value:, origin:)
14
+ validate_inputs!(signal, kernel)
15
+ validate_vocabulary!(operation, mode, boundary)
16
+ assign_options(signal, kernel, operation:, mode:, boundary:, fill_value:, origin:)
17
+ validate_combinations!(signal.shape, kernel.shape)
18
+ end
19
+
20
+ private
21
+
22
+ def validate_inputs!(signal, kernel)
23
+ unless signal.is_a?(Numo::NArray) && kernel.is_a?(Numo::NArray)
24
+ raise ArgumentError, 'signal and kernel must be Numo::NArray values'
25
+ end
26
+ raise ArgumentError, 'signal and kernel must not be empty' if signal.empty? || kernel.empty?
27
+ raise ArgumentError, 'signal and kernel must have equal rank' unless signal.ndim == kernel.ndim
28
+ raise ArgumentError, "maximum supported rank is #{MAX_RANK}" if signal.ndim > MAX_RANK
29
+ end
30
+
31
+ def validate_vocabulary!(operation, mode, boundary)
32
+ raise ArgumentError, "operation must be one of #{OPERATIONS.inspect}" unless OPERATIONS.include?(operation)
33
+ raise ArgumentError, "mode must be one of #{MODES.inspect}" unless MODES.include?(mode)
34
+ return if BOUNDARIES.include?(boundary)
35
+
36
+ raise ArgumentError, "boundary must be one of #{BOUNDARIES.inspect}"
37
+ end
38
+
39
+ def assign_options(signal, kernel, operation:, mode:, boundary:, fill_value:, origin:)
40
+ @operation = operation
41
+ @mode = mode
42
+ @boundary = boundary
43
+ @fill_value_given = !fill_value.equal?(UNSPECIFIED_FILL)
44
+ @fill_value = normalize_fill_value(fill_value)
45
+ @origins = normalize_origins(origin, signal.ndim)
46
+ @anchors = kernel.shape.zip(@origins).map.with_index do |(kernel_size, axis_origin), axis|
47
+ normalize_anchor(kernel_size, axis_origin, axis)
48
+ end.freeze
49
+ end
50
+
51
+ def normalize_fill_value(fill_value)
52
+ value = fill_value.equal?(UNSPECIFIED_FILL) ? 0.0 : fill_value
53
+ unless value.is_a?(Numeric) && !value.is_a?(Complex)
54
+ raise ArgumentError, 'fill_value must be a real numeric value'
55
+ end
56
+
57
+ value.to_f
58
+ end
59
+
60
+ def normalize_origins(origin, rank)
61
+ origins = case origin
62
+ when Integer then Array.new(rank, origin)
63
+ when Array then origin.dup
64
+ else
65
+ raise ArgumentError, 'origin must be an Integer or an Array of one Integer per dimension'
66
+ end
67
+
68
+ unless origins.length == rank && origins.all?(Integer)
69
+ raise ArgumentError, 'origin must contain one Integer per dimension'
70
+ end
71
+
72
+ origins.freeze
73
+ end
74
+
75
+ def normalize_anchor(kernel_size, origin, axis)
76
+ anchor = (kernel_size / 2) + origin
77
+ return anchor if anchor.between?(0, kernel_size - 1)
78
+
79
+ raise ArgumentError,
80
+ "origin #{origin} is out of range for kernel dimension #{axis} of size #{kernel_size}"
81
+ end
82
+
83
+ def validate_combinations!(signal_shape, kernel_shape)
84
+ validate_mode_combination!(signal_shape, kernel_shape)
85
+ validate_fill_combination!
86
+ validate_origin_combination!
87
+ end
88
+
89
+ def validate_mode_combination!(signal_shape, kernel_shape)
90
+ return validate_valid_options!(signal_shape, kernel_shape) if mode == :valid
91
+ return unless mode == :full && boundary != :constant
92
+
93
+ raise ArgumentError, 'mode: :full only supports boundary: :constant'
94
+ end
95
+
96
+ def validate_fill_combination!
97
+ return unless boundary != :constant && @fill_value_given
98
+
99
+ raise ArgumentError, 'fill_value is only supported with boundary: :constant'
100
+ end
101
+
102
+ def validate_origin_combination!
103
+ return if mode == :same || origins.all?(&:zero?)
104
+
105
+ raise ArgumentError, 'nonzero origin is only supported with mode: :same'
106
+ end
107
+
108
+ def validate_valid_options!(signal_shape, kernel_shape)
109
+ unless signal_shape.zip(kernel_shape).all? { |signal_size, kernel_size| signal_size >= kernel_size }
110
+ raise ArgumentError, 'kernel must not be larger than signal in any dimension'
111
+ end
112
+ raise ArgumentError, 'mode: :valid only supports boundary: :constant' unless boundary == :constant
113
+ raise ArgumentError, 'mode: :valid requires fill_value: 0' unless fill_value.zero?
114
+ end
115
+ end
116
+
117
+ private_constant :OperationOptions
118
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'convolver/operation_options'
5
+ require 'convolver/operation_shapes'
6
+ require 'convolver/signal_extension'
7
+ require 'convolver/fft_buffer_plan'
8
+
9
+ # Internal planning and extension support for Convolver's public operations.
10
+ module Convolver
11
+ # Distinguishes an omitted fill_value keyword from an explicitly supplied
12
+ # value. This lets non-constant boundaries reject even an explicit zero.
13
+ # @private
14
+ UNSPECIFIED_FILL = Object.new.freeze
15
+
16
+ # Validated dimensions and boundary-extension details for one operation.
17
+ # @private
18
+ class OperationPlan
19
+ extend Forwardable
20
+
21
+ def_delegators :options, :operation, :mode, :boundary, :fill_value, :origins, :anchors
22
+ def_delegators :shapes, :padding_before, :padding_after, :result_shape,
23
+ :extended_shape, :result_size, :extended_size, :linear_fft_shape,
24
+ :linear_fft_size, :linear_spectrum_size
25
+
26
+ def initialize(signal, kernel, operation:, mode:, boundary:, fill_value:, origin:)
27
+ @options = OperationOptions.new(signal, kernel, operation:, mode:, boundary:, fill_value:, origin:)
28
+ @signal_shape = signal.shape.freeze
29
+ @kernel_shape = kernel.shape.freeze
30
+ @shapes = OperationShapes.new(
31
+ signal.shape, kernel.shape, operation:, mode:, anchors: options.anchors
32
+ )
33
+ end
34
+
35
+ def valid?
36
+ mode == :valid
37
+ end
38
+
39
+ def wrap?
40
+ mode == :same && boundary == :wrap
41
+ end
42
+
43
+ def extend_signal(signal)
44
+ return signal if valid?
45
+
46
+ SignalExtension.new(shapes, boundary:, fill_value:).call(signal)
47
+ end
48
+
49
+ def validate_basic!
50
+ BufferPreparation.new(self, @signal_shape, @kernel_shape).basic!
51
+ end
52
+
53
+ def fft_buffers
54
+ @fft_buffers ||= FftBufferPlan.new(self, @signal_shape, @kernel_shape)
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :options, :shapes
60
+ end
61
+
62
+ private_constant :OperationPlan, :UNSPECIFIED_FILL
63
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'convolver/real_fft_shape'
4
+ require 'convolver/fft_limits'
5
+
6
+ module Convolver
7
+ # Calculates and validates output, extension, and FFT dimensions.
8
+ # @private
9
+ class OperationShapes
10
+ SIZE_MAX = (1 << ([0].pack('J').bytesize * 8)) - 1
11
+
12
+ attr_reader :padding_before, :padding_after, :result_shape,
13
+ :extended_shape, :result_size, :extended_size
14
+
15
+ def initialize(signal_shape, kernel_shape, operation:, mode:, anchors:)
16
+ @operation = operation
17
+ @mode = mode
18
+ calculate_shapes(signal_shape, kernel_shape, anchors)
19
+ validate_sizes!
20
+ end
21
+
22
+ def linear_fft_shape(kernel_shape, limits: FftLimits.new)
23
+ return [].freeze if kernel_shape.empty?
24
+
25
+ RealFftShape.new(linear_result_shape(kernel_shape), size_max: SIZE_MAX, limits:).call
26
+ end
27
+
28
+ def linear_fft_size(kernel_shape)
29
+ checked_product(linear_fft_shape(kernel_shape), 'FFT size')
30
+ end
31
+
32
+ def linear_spectrum_size(kernel_shape)
33
+ shape = linear_fft_shape(kernel_shape)
34
+ return 1 if shape.empty?
35
+
36
+ spectrum_shape = shape.dup
37
+ spectrum_shape[-1] = (spectrum_shape[-1] / 2) + 1
38
+ checked_product(spectrum_shape, 'FFT spectrum size')
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :operation, :mode
44
+
45
+ def calculate_shapes(signal_shape, kernel_shape, anchors)
46
+ @padding_before, @padding_after, @result_shape = shapes_for(signal_shape, kernel_shape, anchors)
47
+ @extended_shape = signal_shape.zip(padding_before, padding_after).map do |signal_size, before, after|
48
+ signal_size + before + after
49
+ end
50
+ end
51
+
52
+ def shapes_for(signal_shape, kernel_shape, anchors)
53
+ case mode
54
+ when :valid then valid_shapes(signal_shape, kernel_shape)
55
+ when :same then same_shapes(signal_shape, kernel_shape, anchors)
56
+ when :full then full_shapes(signal_shape, kernel_shape)
57
+ end
58
+ end
59
+
60
+ def valid_shapes(signal_shape, kernel_shape)
61
+ result = signal_shape.zip(kernel_shape).map do |signal_size, kernel_size|
62
+ signal_size - kernel_size + 1
63
+ end
64
+ zeros = Array.new(signal_shape.length, 0).freeze
65
+ [zeros, zeros, result]
66
+ end
67
+
68
+ def same_shapes(signal_shape, kernel_shape, anchors)
69
+ correlation_before = anchors.dup
70
+ correlation_after = kernel_shape.zip(anchors).map { |kernel_size, anchor| kernel_size - 1 - anchor }
71
+ before, after = if operation == :correlation
72
+ [correlation_before, correlation_after]
73
+ else
74
+ [correlation_after, correlation_before]
75
+ end
76
+ before.freeze
77
+ after.freeze
78
+ [before, after, signal_shape.dup]
79
+ end
80
+
81
+ def full_shapes(signal_shape, kernel_shape)
82
+ padding = kernel_shape.map { |kernel_size| kernel_size - 1 }.freeze
83
+ result = signal_shape.zip(kernel_shape).map { |signal_size, kernel_size| signal_size + kernel_size - 1 }
84
+ [padding, padding, result]
85
+ end
86
+
87
+ def validate_sizes!
88
+ @padding_before, @padding_after, @result_shape, @extended_shape = [
89
+ [padding_before, 'padding shape'],
90
+ [padding_after, 'padding shape'],
91
+ [result_shape, 'result shape'],
92
+ [extended_shape, 'extended signal shape']
93
+ ].map { |shape, description| checked_shape(shape, description).freeze }
94
+ @result_size = checked_product(result_shape, 'result size')
95
+ @extended_size = checked_product(extended_shape, 'extended signal size')
96
+ end
97
+
98
+ def checked_shape(shape, description)
99
+ return shape if shape.all? { |size| size.between?(0, SIZE_MAX) }
100
+
101
+ raise RangeError, "#{description} exceeds native implementation limit"
102
+ end
103
+
104
+ def checked_product(shape, description)
105
+ shape.reduce(1) do |product, size|
106
+ if !product.zero? && size > SIZE_MAX / product
107
+ raise RangeError, "#{description} exceeds native implementation limit"
108
+ end
109
+
110
+ product * size
111
+ end
112
+ end
113
+
114
+ def linear_result_shape(kernel_shape)
115
+ shape = extended_shape.zip(kernel_shape).map do |signal_size, kernel_size|
116
+ checked_add(signal_size, kernel_size - 1, 'FFT shape')
117
+ end
118
+ checked_shape(shape, 'FFT shape')
119
+ end
120
+
121
+ def checked_add(left, right, description)
122
+ return left + right if right <= SIZE_MAX - left
123
+
124
+ raise RangeError, "#{description} exceeds native implementation limit"
125
+ end
126
+ end
127
+
128
+ private_constant :OperationShapes
129
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'convolver/fft_limits'
4
+
5
+ module Convolver
6
+ # Selects a safe real-transform shape from exact/even and fast candidates.
7
+ # @private
8
+ class RealFftShape
9
+ def initialize(minimum_shape, size_max:, limits: nil)
10
+ @minimum_shape = minimum_shape
11
+ @size_max = size_max
12
+ @limits = limits
13
+ end
14
+
15
+ def call
16
+ exact_shape = even_final_axis(minimum_shape)
17
+ candidates = [exact_shape, fast_shape(exact_shape)].compact.select { |shape| admissible?(shape) }
18
+ (candidates.min_by { |shape| transform_cost(shape) } || raise_rejection).freeze
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :minimum_shape, :size_max, :limits
24
+
25
+ def admissible?(shape)
26
+ return false unless representable?(shape)
27
+
28
+ limits&.validate!(shape, real_axis: shape.length - 1)
29
+ true
30
+ rescue FftUnavailable => e
31
+ @rejection ||= e
32
+ false
33
+ end
34
+
35
+ def raise_rejection
36
+ raise @rejection if @rejection
37
+
38
+ raise_overflow
39
+ end
40
+
41
+ def fast_shape(exact_shape)
42
+ fast_shape = exact_shape.map.with_index do |size, axis|
43
+ next_fast_size(size, even: axis == exact_shape.length - 1)
44
+ end
45
+ fast_shape if fast_shape.all?
46
+ end
47
+
48
+ def even_final_axis(shape)
49
+ result = shape.dup
50
+ result[-1] = checked_add(result[-1], 1) if result[-1].odd?
51
+ result.freeze
52
+ end
53
+
54
+ def next_fast_size(target, even:)
55
+ best = nil
56
+ each_power(5) do |power_of_five|
57
+ each_power(3, power_of_five) do |power_of_three|
58
+ candidate = doubled_candidate(power_of_three, target, even)
59
+ best = candidate if candidate && (!best || candidate < best)
60
+ end
61
+ end
62
+ best
63
+ end
64
+
65
+ def each_power(factor, initial = 1)
66
+ value = initial
67
+ loop do
68
+ yield value
69
+ break if value > size_max / factor
70
+
71
+ value *= factor
72
+ end
73
+ end
74
+
75
+ def doubled_candidate(initial, target, even)
76
+ candidate = initial
77
+ while candidate < target || (even && candidate.odd?)
78
+ return nil if candidate > size_max / 2
79
+
80
+ candidate *= 2
81
+ end
82
+ candidate
83
+ end
84
+
85
+ def transform_cost(shape)
86
+ shape.reduce(:*) * shape.sum { |size| Math.log2(size) + large_factor_penalty(size) }
87
+ end
88
+
89
+ def large_factor_penalty(size)
90
+ prime_factors(size).sum do |factor|
91
+ factor > 7 ? 1.5 * (Math.log2(factor) - 3) : 0.0
92
+ end
93
+ end
94
+
95
+ def prime_factors(value)
96
+ factors = []
97
+ divisor = 2
98
+ while divisor * divisor <= value
99
+ value = extract_factor(value, divisor, factors)
100
+ divisor += divisor == 2 ? 1 : 2
101
+ end
102
+ factors << value if value > 1
103
+ factors
104
+ end
105
+
106
+ def extract_factor(value, divisor, factors)
107
+ while (value % divisor).zero?
108
+ factors << divisor
109
+ value /= divisor
110
+ end
111
+ value
112
+ end
113
+
114
+ def representable?(shape)
115
+ shape.reduce(1) do |product, size|
116
+ return false if !product.zero? && size > size_max / product
117
+
118
+ product * size
119
+ end
120
+ true
121
+ end
122
+
123
+ def checked_add(left, right)
124
+ return left + right if right <= size_max - left
125
+
126
+ raise_overflow
127
+ end
128
+
129
+ def raise_overflow
130
+ raise(limits ? FftUnavailable : RangeError, 'FFT shape exceeds native implementation limit')
131
+ end
132
+ end
133
+
134
+ private_constant :RealFftShape
135
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Convolver
4
+ # Extends a signal according to one operation's boundary policy.
5
+ # @private
6
+ class SignalExtension
7
+ def initialize(shapes, boundary:, fill_value:)
8
+ @shapes = shapes
9
+ @boundary = boundary
10
+ @fill_value = fill_value
11
+ end
12
+
13
+ def call(signal)
14
+ source = Numo::SFloat.cast(signal)
15
+ return constant_extension(source) if boundary == :constant
16
+
17
+ source[*extension_indices]
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :shapes, :boundary, :fill_value
23
+
24
+ def constant_extension(source)
25
+ extended = Numo::SFloat.new(*shapes.extended_shape).fill(fill_value)
26
+ ranges = source.shape.zip(shapes.padding_before).map do |signal_size, before|
27
+ before...(before + signal_size)
28
+ end
29
+ extended[*ranges] = source
30
+ extended
31
+ end
32
+
33
+ def extension_indices
34
+ shapes.extended_shape.zip(shapes.padding_before).map.with_index do |(length, before), axis|
35
+ Array.new(length) { |offset| boundary_index(offset - before, signal_size(axis, before)) }
36
+ end
37
+ end
38
+
39
+ def signal_size(axis, before)
40
+ shapes.extended_shape[axis] - before - shapes.padding_after[axis]
41
+ end
42
+
43
+ def boundary_index(position, size)
44
+ case boundary
45
+ when :nearest then position.clamp(0, size - 1)
46
+ when :wrap then position % size
47
+ when :reflect then reflect_index(position, size)
48
+ when :mirror then mirror_index(position, size)
49
+ end
50
+ end
51
+
52
+ def reflect_index(position, size)
53
+ residue = position % (2 * size)
54
+ residue < size ? residue : (2 * size) - 1 - residue
55
+ end
56
+
57
+ def mirror_index(position, size)
58
+ return 0 if size == 1
59
+
60
+ period = 2 * (size - 1)
61
+ residue = position % period
62
+ residue < size ? residue : period - residue
63
+ end
64
+ end
65
+
66
+ private_constant :SignalExtension
67
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Convolver
4
4
  # Current gem version.
5
- VERSION = '1.0.1'
5
+ VERSION = '3.0.1'
6
6
  end