convolver 1.0.1 → 2.0.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e886e4cc4f0c47a5c0e40209a20ac4b90f9c5fa349c76e9b04889fb8c010fa70
4
- data.tar.gz: b656b786753b87ab68d89985262bedbbd3ad0929527a2953acde7af56cde643b
3
+ metadata.gz: '08959da24c94d02889f0067f18f7233578b4b14ec4e58538b144efeef1cbe25f'
4
+ data.tar.gz: c39df2ebf01a04f93fa62e49bd8a4542e78244868d822d7f47a00cc78b9aaa7e
5
5
  SHA512:
6
- metadata.gz: 187f65c4daf5c25007348d0e3f21e44dda4bccb629f0dab067ac8193aac8977d22a441b79249d35ad4b7c2b17e43e3339db7045c0f83882009556bae6b446425
7
- data.tar.gz: 4605ecab87c0027c02c30c82e78298766b311a4e359b178fec8b47427a5c6bfc57a049193173fb38fba3877874e679cf1d96ac41700a5cf090a31c5fa43c9635
6
+ metadata.gz: 73d425b7851b21dd93cb135ca7ad88aa75f760b41a53ed8a2b72b2a9110e183062c1643af9563f81d59248d50d3f49a531e2bbac6d0c5cdcef6383717e31f73a
7
+ data.tar.gz: 89ddc9f26950329b9ce2ce3917e5f90057c3c625f2210eb39fcc527cbe7b3f418f14f387665a3be3c1ea44d50beac101907c038c6ef9998998e30c21d61966d2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [2.0.0] - 2026-08-20
11
+
12
+ ### Added
13
+
14
+ - Added `:valid`, `:same`, and `:full` output modes to every calculation and
15
+ estimator method.
16
+ - Added constant, nearest, reflect, mirror, and wrap signal boundary extensions,
17
+ including nonzero constant fill and kernels larger than the signal where the
18
+ output mode permits them.
19
+ - Added scalar and per-axis kernel origins with an explicit even-kernel
20
+ alignment convention.
21
+ - Added an origin-aware circular PocketFFT correlation path for periodic
22
+ same-sized output.
23
+
24
+ ### Changed
25
+
26
+ - **Breaking:** Raised the minimum supported Ruby version to 3.3.
27
+ - Adopted `ncs_rubocop_conf` 0.1.0 as the versioned RuboCop policy, added
28
+ exception auditing, and began tracking the development dependency lockfile.
29
+ - Made algorithm selection and both public time estimators account for output
30
+ shape, boundary extension, kernel folding, and FFT working shape.
31
+ - Moved the native valid-only calculation behind a shared Ruby orchestration
32
+ layer so direct and FFT implementations use identical option validation and
33
+ boundary semantics.
34
+
8
35
  ## [1.0.1] - 2026-07-29
9
36
 
10
37
  ### Fixed
@@ -54,5 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
54
81
  - Updated the native extension to use the maintained Numo C API and removed the
55
82
  legacy untyped-data compatibility code.
56
83
 
84
+ [Unreleased]: https://github.com/neilslater/convolver/compare/v2.0.0...HEAD
85
+ [2.0.0]: https://github.com/neilslater/convolver/compare/v1.0.1...v2.0.0
57
86
  [1.0.1]: https://github.com/neilslater/convolver/compare/v1.0.0...v1.0.1
58
87
  [1.0.0]: https://github.com/neilslater/convolver/compare/v0.3.2...v1.0.0
data/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # Convolver
2
2
 
3
+ [![CI](https://github.com/neilslater/convolver/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/neilslater/convolver/actions/workflows/ci.yml)
3
4
  [![Gem Version](https://badge.fury.io/rb/convolver.svg)](https://badge.fury.io/rb/convolver)
4
5
 
5
- Convolver calculates valid cross-correlations between multidimensional
6
- [`Numo::NArray`](https://github.com/yoshoku/numo-narray-alt) values. It chooses
7
- between a direct native implementation for smaller inputs and a
6
+ Convolver calculates cross-correlations between multidimensional
7
+ [`Numo::NArray`](https://github.com/yoshoku/numo-narray-alt) values, with
8
+ configurable output extents and signal boundary extensions. It chooses between
9
+ a direct native implementation for smaller inputs and a
8
10
  [`Numo::Pocketfft`](https://github.com/yoshoku/numo-pocketfft)-based
9
11
  implementation for larger inputs.
10
12
 
@@ -46,9 +48,10 @@ Convolver.convolve(signal, kernel)
46
48
  # [0.19, 0.27]
47
49
  ```
48
50
 
49
- The signal and kernel must have the same rank, and the kernel must be no larger
50
- than the signal in any dimension. Convolver returns only positions at which the
51
- kernel overlaps the signal completely. The result size in each dimension is:
51
+ With no keywords, Convolver preserves its original valid-correlation behavior:
52
+ the signal and kernel must have the same rank, the kernel must be no larger
53
+ than the signal in any dimension, and only positions with complete overlap are
54
+ returned. The result size in each dimension is:
52
55
 
53
56
  ```ruby
54
57
  signal_size - kernel_size + 1
@@ -67,8 +70,74 @@ Convolver.convolve_basic(signal, kernel)
67
70
  Convolver.convolve_fft(signal, kernel)
68
71
  ```
69
72
 
73
+ Both implementations and the automatic method accept the same options:
74
+
75
+ ```ruby
76
+ Convolver.convolve(signal, kernel,
77
+ mode: :same,
78
+ boundary: :reflect,
79
+ origin: 0)
80
+ ```
81
+
82
+ ### Output modes
83
+
84
+ `mode:` controls the returned extent independently in each dimension:
85
+
86
+ | Mode | Meaning | Result size |
87
+ | --- | --- | --- |
88
+ | `:valid` | Kernel overlaps the stored signal completely | `S - K + 1` |
89
+ | `:same` | One result aligned with each stored signal position | `S` |
90
+ | `:full` | Every kernel position with any stored-signal overlap | `S + K - 1` |
91
+
92
+ `:valid` is the default and accepts only the default `boundary: :constant`,
93
+ `fill_value: 0.0`, and `origin: 0`. `:full` supports constant extension only.
94
+ `:same` supports every boundary described below. Kernels larger than the signal
95
+ are supported by `:same` and `:full`, but not by `:valid`. `mode:` and
96
+ `boundary:` accept only the symbols listed here.
97
+
98
+ ### Boundary extension
99
+
100
+ For a one-dimensional signal `a b c d`, `boundary:` selects values outside the
101
+ stored signal:
102
+
103
+ | Boundary | Extended sequence |
104
+ | --- | --- |
105
+ | `:constant` | `k k k k | a b c d | k k k k` |
106
+ | `:nearest` | `a a a a | a b c d | d d d d` |
107
+ | `:reflect` | `d c b a | a b c d | d c b a` |
108
+ | `:mirror` | `d c b | a b c d | c b a` |
109
+ | `:wrap` | `a b c d | a b c d | a b c d` |
110
+
111
+ `fill_value:` sets `k` for `:constant` and defaults to zero. It must not be
112
+ passed with another boundary. `:reflect` repeats the edge sample; `:mirror`
113
+ does not. All boundary modes work across every dimension, including
114
+ length-one axes and extensions wider than the stored signal.
115
+
116
+ ### Kernel origin
117
+
118
+ `origin:` shifts the kernel anchor for `:same`. It accepts one integer applied
119
+ to every dimension or an array with one integer per dimension. For a kernel
120
+ dimension of length `K`:
121
+
122
+ ```ruby
123
+ anchor = (K / 2) + origin
124
+ ```
125
+
126
+ The anchor must remain within the kernel. Positive origins sample farther
127
+ toward lower signal indices. With the default origin, an even kernel gives the
128
+ extra boundary sample to the lower-index, or left, side: a length-four kernel
129
+ uses two samples before and one after the aligned signal position. Nonzero
130
+ origins are supported only for `:same`; `:valid` and `:full` require zero.
131
+
132
+ The estimator methods accept and validate the same options:
133
+
134
+ ```ruby
135
+ Convolver.predict_convolve_basic_time(signal, kernel, mode: :same, boundary: :nearest)
136
+ Convolver.predict_convolve_fft_time(signal, kernel, mode: :same, boundary: :wrap)
137
+ ```
138
+
70
139
  `Convolver.convolve_fftw3` remains as a deprecated alias for `convolve_fft` to
71
- ease migration from Convolver 0.x.
140
+ ease migration from Convolver 0.x and forwards all options.
72
141
 
73
142
  ## Contributing
74
143
 
@@ -17,12 +17,12 @@ static void copy_shape(int rank, const size_t *source, size_t *target) {
17
17
  /*
18
18
  * Calculates a valid cross-correlation using the direct native implementation.
19
19
  *
20
- * @overload convolve_basic(signal, kernel)
20
+ * @overload convolve_basic_valid(signal, kernel)
21
21
  * @param signal [Numo::NArray] input values
22
22
  * @param kernel [Numo::NArray] correlation kernel
23
23
  * @return [Numo::SFloat] valid cross-correlation result
24
24
  */
25
- static VALUE convolver_convolve_basic(VALUE self, VALUE signal, VALUE kernel) {
25
+ static VALUE convolver_convolve_basic_valid(VALUE self, VALUE signal, VALUE kernel) {
26
26
  volatile VALUE signal_value;
27
27
  volatile VALUE kernel_value;
28
28
  volatile VALUE result_value;
@@ -87,5 +87,5 @@ static VALUE convolver_convolve_basic(VALUE self, VALUE signal, VALUE kernel) {
87
87
 
88
88
  void Init_convolver(void) {
89
89
  mConvolver = rb_define_module("Convolver");
90
- rb_define_singleton_method(mConvolver, "convolve_basic", convolver_convolve_basic, 2);
90
+ rb_define_singleton_method(mConvolver, "convolve_basic_valid", convolver_convolve_basic_valid, 2);
91
91
  }
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Convolver
4
+ # Validates and normalizes the options for a correlation operation.
5
+ class OperationOptions
6
+ MODES = %i[valid same full].freeze
7
+ BOUNDARIES = %i[constant nearest reflect mirror wrap].freeze
8
+
9
+ attr_reader :mode, :boundary, :fill_value, :origins, :anchors
10
+
11
+ def initialize(signal, kernel, mode:, boundary:, fill_value:, origin:)
12
+ validate_inputs!(signal, kernel)
13
+ validate_vocabulary!(mode, boundary)
14
+ assign_options(signal, kernel, mode:, boundary:, fill_value:, origin:)
15
+ validate_combinations!(signal.shape, kernel.shape)
16
+ end
17
+
18
+ private
19
+
20
+ def validate_inputs!(signal, kernel)
21
+ unless signal.is_a?(Numo::NArray) && kernel.is_a?(Numo::NArray)
22
+ raise ArgumentError, 'signal and kernel must be Numo::NArray values'
23
+ end
24
+ raise ArgumentError, 'signal and kernel must not be empty' if signal.empty? || kernel.empty?
25
+ raise ArgumentError, 'signal and kernel must have equal rank' unless signal.ndim == kernel.ndim
26
+ raise ArgumentError, "maximum supported rank is #{MAX_RANK}" if signal.ndim > MAX_RANK
27
+ end
28
+
29
+ def validate_vocabulary!(mode, boundary)
30
+ raise ArgumentError, "mode must be one of #{MODES.inspect}" unless MODES.include?(mode)
31
+ return if BOUNDARIES.include?(boundary)
32
+
33
+ raise ArgumentError, "boundary must be one of #{BOUNDARIES.inspect}"
34
+ end
35
+
36
+ def assign_options(signal, kernel, mode:, boundary:, fill_value:, origin:)
37
+ @mode = mode
38
+ @boundary = boundary
39
+ @fill_value_given = !fill_value.equal?(UNSPECIFIED_FILL)
40
+ @fill_value = normalize_fill_value(fill_value)
41
+ @origins = normalize_origins(origin, signal.ndim)
42
+ @anchors = kernel.shape.zip(@origins).map.with_index do |(kernel_size, axis_origin), axis|
43
+ normalize_anchor(kernel_size, axis_origin, axis)
44
+ end.freeze
45
+ end
46
+
47
+ def normalize_fill_value(fill_value)
48
+ value = fill_value.equal?(UNSPECIFIED_FILL) ? 0.0 : fill_value
49
+ unless value.is_a?(Numeric) && !value.is_a?(Complex)
50
+ raise ArgumentError, 'fill_value must be a real numeric value'
51
+ end
52
+
53
+ value.to_f
54
+ end
55
+
56
+ def normalize_origins(origin, rank)
57
+ origins = case origin
58
+ when Integer then Array.new(rank, origin)
59
+ when Array then origin.dup
60
+ else
61
+ raise ArgumentError, 'origin must be an Integer or an Array of one Integer per dimension'
62
+ end
63
+
64
+ unless origins.length == rank && origins.all?(Integer)
65
+ raise ArgumentError, 'origin must contain one Integer per dimension'
66
+ end
67
+
68
+ origins.freeze
69
+ end
70
+
71
+ def normalize_anchor(kernel_size, origin, axis)
72
+ anchor = (kernel_size / 2) + origin
73
+ return anchor if anchor.between?(0, kernel_size - 1)
74
+
75
+ raise ArgumentError,
76
+ "origin #{origin} is out of range for kernel dimension #{axis} of size #{kernel_size}"
77
+ end
78
+
79
+ def validate_combinations!(signal_shape, kernel_shape)
80
+ validate_mode_combination!(signal_shape, kernel_shape)
81
+ validate_fill_combination!
82
+ validate_origin_combination!
83
+ end
84
+
85
+ def validate_mode_combination!(signal_shape, kernel_shape)
86
+ return validate_valid_options!(signal_shape, kernel_shape) if mode == :valid
87
+ return unless mode == :full && boundary != :constant
88
+
89
+ raise ArgumentError, 'mode: :full only supports boundary: :constant'
90
+ end
91
+
92
+ def validate_fill_combination!
93
+ return unless boundary != :constant && @fill_value_given
94
+
95
+ raise ArgumentError, 'fill_value is only supported with boundary: :constant'
96
+ end
97
+
98
+ def validate_origin_combination!
99
+ return if mode == :same || origins.all?(&:zero?)
100
+
101
+ raise ArgumentError, 'nonzero origin is only supported with mode: :same'
102
+ end
103
+
104
+ def validate_valid_options!(signal_shape, kernel_shape)
105
+ unless signal_shape.zip(kernel_shape).all? { |signal_size, kernel_size| signal_size >= kernel_size }
106
+ raise ArgumentError, 'kernel must not be larger than signal in any dimension'
107
+ end
108
+ raise ArgumentError, 'mode: :valid only supports boundary: :constant' unless boundary == :constant
109
+ raise ArgumentError, 'mode: :valid requires fill_value: 0' unless fill_value.zero?
110
+ end
111
+ end
112
+
113
+ private_constant :OperationOptions
114
+ end
@@ -0,0 +1,48 @@
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
+
8
+ # Internal planning and extension support for Convolver's public operations.
9
+ module Convolver
10
+ # Distinguishes an omitted fill_value keyword from an explicitly supplied
11
+ # value. This lets non-constant boundaries reject even an explicit zero.
12
+ UNSPECIFIED_FILL = Object.new.freeze
13
+
14
+ # Validated dimensions and boundary-extension details for one correlation.
15
+ class OperationPlan
16
+ extend Forwardable
17
+
18
+ def_delegators :options, :mode, :boundary, :fill_value, :origins, :anchors
19
+ def_delegators :shapes, :padding_before, :padding_after, :result_shape,
20
+ :extended_shape, :result_size, :extended_size, :linear_fft_shape,
21
+ :linear_fft_size
22
+
23
+ def initialize(signal, kernel, mode:, boundary:, fill_value:, origin:)
24
+ @options = OperationOptions.new(signal, kernel, mode:, boundary:, fill_value:, origin:)
25
+ @shapes = OperationShapes.new(signal.shape, kernel.shape, mode:, anchors: options.anchors)
26
+ end
27
+
28
+ def valid?
29
+ mode == :valid
30
+ end
31
+
32
+ def wrap?
33
+ mode == :same && boundary == :wrap
34
+ end
35
+
36
+ def extend_signal(signal)
37
+ return signal if valid?
38
+
39
+ SignalExtension.new(shapes, boundary:, fill_value:).call(signal)
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :options, :shapes
45
+ end
46
+
47
+ private_constant :OperationPlan, :UNSPECIFIED_FILL
48
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Convolver
4
+ # Calculates and validates output, extension, and FFT dimensions.
5
+ class OperationShapes
6
+ SIZE_MAX = (1 << ([0].pack('J').bytesize * 8)) - 1
7
+
8
+ attr_reader :padding_before, :padding_after, :result_shape,
9
+ :extended_shape, :result_size, :extended_size
10
+
11
+ def initialize(signal_shape, kernel_shape, mode:, anchors:)
12
+ @mode = mode
13
+ calculate_shapes(signal_shape, kernel_shape, anchors)
14
+ validate_sizes!
15
+ end
16
+
17
+ def linear_fft_shape(kernel_shape)
18
+ checked_shape(
19
+ extended_shape.zip(kernel_shape).map { |signal_size, kernel_size| signal_size + kernel_size - 1 },
20
+ 'FFT shape'
21
+ )
22
+ end
23
+
24
+ def linear_fft_size(kernel_shape)
25
+ checked_product(linear_fft_shape(kernel_shape), 'FFT size')
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :mode
31
+
32
+ def calculate_shapes(signal_shape, kernel_shape, anchors)
33
+ @padding_before, @padding_after, @result_shape = shapes_for(signal_shape, kernel_shape, anchors)
34
+ @extended_shape = signal_shape.zip(padding_before, padding_after).map do |signal_size, before, after|
35
+ signal_size + before + after
36
+ end
37
+ end
38
+
39
+ def shapes_for(signal_shape, kernel_shape, anchors)
40
+ case mode
41
+ when :valid then valid_shapes(signal_shape, kernel_shape)
42
+ when :same then same_shapes(signal_shape, kernel_shape, anchors)
43
+ when :full then full_shapes(signal_shape, kernel_shape)
44
+ end
45
+ end
46
+
47
+ def valid_shapes(signal_shape, kernel_shape)
48
+ result = signal_shape.zip(kernel_shape).map do |signal_size, kernel_size|
49
+ signal_size - kernel_size + 1
50
+ end
51
+ zeros = Array.new(signal_shape.length, 0).freeze
52
+ [zeros, zeros, result]
53
+ end
54
+
55
+ def same_shapes(signal_shape, kernel_shape, anchors)
56
+ before = anchors.dup.freeze
57
+ after = kernel_shape.zip(anchors).map { |kernel_size, anchor| kernel_size - 1 - anchor }.freeze
58
+ [before, after, signal_shape.dup]
59
+ end
60
+
61
+ def full_shapes(signal_shape, kernel_shape)
62
+ padding = kernel_shape.map { |kernel_size| kernel_size - 1 }.freeze
63
+ result = signal_shape.zip(kernel_shape).map { |signal_size, kernel_size| signal_size + kernel_size - 1 }
64
+ [padding, padding, result]
65
+ end
66
+
67
+ def validate_sizes!
68
+ @padding_before, @padding_after, @result_shape, @extended_shape = [
69
+ [padding_before, 'padding shape'],
70
+ [padding_after, 'padding shape'],
71
+ [result_shape, 'result shape'],
72
+ [extended_shape, 'extended signal shape']
73
+ ].map { |shape, description| checked_shape(shape, description).freeze }
74
+ @result_size = checked_product(result_shape, 'result size')
75
+ @extended_size = checked_product(extended_shape, 'extended signal size')
76
+ end
77
+
78
+ def checked_shape(shape, description)
79
+ return shape if shape.all? { |size| size.between?(0, SIZE_MAX) }
80
+
81
+ raise RangeError, "#{description} exceeds native implementation limit"
82
+ end
83
+
84
+ def checked_product(shape, description)
85
+ shape.reduce(1) do |product, size|
86
+ if !product.zero? && size > SIZE_MAX / product
87
+ raise RangeError, "#{description} exceeds native implementation limit"
88
+ end
89
+
90
+ product * size
91
+ end
92
+ end
93
+ end
94
+
95
+ private_constant :OperationShapes
96
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Convolver
4
+ # Extends a signal according to one operation's boundary policy.
5
+ class SignalExtension
6
+ def initialize(shapes, boundary:, fill_value:)
7
+ @shapes = shapes
8
+ @boundary = boundary
9
+ @fill_value = fill_value
10
+ end
11
+
12
+ def call(signal)
13
+ source = Numo::SFloat.cast(signal)
14
+ return constant_extension(source) if boundary == :constant
15
+
16
+ source[*extension_indices]
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :shapes, :boundary, :fill_value
22
+
23
+ def constant_extension(source)
24
+ extended = Numo::SFloat.new(*shapes.extended_shape).fill(fill_value)
25
+ ranges = source.shape.zip(shapes.padding_before).map do |signal_size, before|
26
+ before...(before + signal_size)
27
+ end
28
+ extended[*ranges] = source
29
+ extended
30
+ end
31
+
32
+ def extension_indices
33
+ shapes.extended_shape.zip(shapes.padding_before).map.with_index do |(length, before), axis|
34
+ Array.new(length) { |offset| boundary_index(offset - before, signal_size(axis, before)) }
35
+ end
36
+ end
37
+
38
+ def signal_size(axis, before)
39
+ shapes.extended_shape[axis] - before - shapes.padding_after[axis]
40
+ end
41
+
42
+ def boundary_index(position, size)
43
+ case boundary
44
+ when :nearest then position.clamp(0, size - 1)
45
+ when :wrap then position % size
46
+ when :reflect then reflect_index(position, size)
47
+ when :mirror then mirror_index(position, size)
48
+ end
49
+ end
50
+
51
+ def reflect_index(position, size)
52
+ residue = position % (2 * size)
53
+ residue < size ? residue : (2 * size) - 1 - residue
54
+ end
55
+
56
+ def mirror_index(position, size)
57
+ return 0 if size == 1
58
+
59
+ period = 2 * (size - 1)
60
+ residue = position % period
61
+ residue < size ? residue : period - residue
62
+ end
63
+ end
64
+
65
+ private_constant :SignalExtension
66
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Convolver
4
4
  # Current gem version.
5
- VERSION = '1.0.1'
5
+ VERSION = '2.0.0'
6
6
  end
data/lib/convolver.rb CHANGED
@@ -5,105 +5,153 @@ require 'numo/pocketfft'
5
5
  require 'convolver/convolver'
6
6
  require 'convolver/version'
7
7
 
8
- # Valid cross-correlation operations for Numo::NArray values.
8
+ # Cross-correlation operations for Numo::NArray values.
9
9
  module Convolver
10
- # Maximum number of dimensions supported by the direct native implementation.
10
+ # Maximum number of dimensions supported by the implementations.
11
11
  MAX_RANK = 16
12
12
 
13
+ require 'convolver/operation_plan'
14
+
13
15
  class << self
14
- # Chooses the likely fastest implementation for a valid cross-correlation.
15
- #
16
- # The inputs must have the same rank, and the kernel must not be larger than
17
- # the signal in any dimension. The result shape is:
18
- #
19
- # signal.shape.zip(kernel.shape).map { |signal_size, kernel_size| signal_size - kernel_size + 1 }
16
+ # Chooses the likely fastest cross-correlation implementation.
20
17
  #
21
18
  # @param signal [Numo::NArray] input values
22
19
  # @param kernel [Numo::NArray] correlation kernel
23
- # @return [Numo::SFloat] valid cross-correlation result
24
- # @raise [ArgumentError] if the inputs have incompatible ranks or shapes
25
- def convolve(signal, kernel)
26
- validate_inputs!(signal, kernel)
27
- return convolve_basic(signal, kernel) if signal.size < 1000 || kernel.size < 100
28
-
29
- basic_time_predicted = predict_convolve_basic_time(signal, kernel)
30
- return convolve_basic(signal, kernel) if basic_time_predicted < 0.1
31
-
32
- fft_time_predicted = predict_convolve_fft_time(signal, kernel)
33
- return convolve_fft(signal, kernel) if fft_time_predicted < 2 * basic_time_predicted
20
+ # @param mode [:valid, :same, :full] returned output extent
21
+ # @param boundary [:constant, :nearest, :reflect, :mirror, :wrap] signal extension
22
+ # @param fill_value [Numeric] constant extension value
23
+ # @param origin [Integer, Array<Integer>] kernel origin shift
24
+ # @return [Numo::SFloat] cross-correlation result
25
+ # @raise [ArgumentError] if inputs or options are incompatible
26
+ def convolve(signal, kernel, mode: :valid, boundary: :constant,
27
+ fill_value: UNSPECIFIED_FILL, origin: 0)
28
+ plan = operation_plan(signal, kernel, mode:, boundary:, fill_value:, origin:)
29
+ options = operation_options(mode, boundary, fill_value, origin)
30
+
31
+ return convolve_basic(signal, kernel, **options) if plan.extended_size < 1000 || kernel.size < 100
32
+
33
+ basic_time_predicted = predict_convolve_basic_time(signal, kernel, **options)
34
+ return convolve_basic(signal, kernel, **options) if basic_time_predicted < 0.1
35
+
36
+ fft_time_predicted = predict_convolve_fft_time(signal, kernel, **options)
37
+ return convolve_fft(signal, kernel, **options) if fft_time_predicted < 2 * basic_time_predicted
38
+
39
+ convolve_basic(signal, kernel, **options)
40
+ end
34
41
 
35
- convolve_basic(signal, kernel)
42
+ # Uses the direct native valid primitive after applying the requested signal
43
+ # extension in Ruby.
44
+ #
45
+ # @return [Numo::SFloat] cross-correlation result
46
+ def convolve_basic(signal, kernel, mode: :valid, boundary: :constant,
47
+ fill_value: UNSPECIFIED_FILL, origin: 0)
48
+ plan = operation_plan(signal, kernel, mode:, boundary:, fill_value:, origin:)
49
+ convolve_basic_valid(plan.extend_signal(signal), kernel)
36
50
  end
37
51
 
38
- # Uses PocketFFT to calculate a valid cross-correlation.
52
+ # Uses PocketFFT to calculate the requested cross-correlation.
39
53
  #
40
- # @param signal [Numo::NArray] input values
41
- # @param kernel [Numo::NArray] correlation kernel
42
- # @return [Numo::SFloat] valid cross-correlation result
43
- # @raise [ArgumentError] if the inputs have incompatible ranks or shapes
44
- def convolve_fft(signal, kernel)
45
- validate_inputs!(signal, kernel)
46
- ranges = kernel.shape.zip(signal.shape).map { |kernel_size, signal_size| (kernel_size - 1)...signal_size }
47
- full_convolution = Numo::Pocketfft.fftconvolve(signal, kernel.reverse)
54
+ # Periodic same-sized results use a circular transform. Other combinations
55
+ # use the shared extension plan and PocketFFT's linear convolution.
56
+ #
57
+ # @return [Numo::SFloat] cross-correlation result
58
+ def convolve_fft(signal, kernel, mode: :valid, boundary: :constant,
59
+ fill_value: UNSPECIFIED_FILL, origin: 0)
60
+ plan = operation_plan(signal, kernel, mode:, boundary:, fill_value:, origin:)
61
+ return convolve_fft_wrap(signal, kernel, plan) if plan.wrap?
48
62
 
49
- Numo::SFloat.cast(full_convolution[*ranges])
63
+ convolve_fft_valid(plan.extend_signal(signal), kernel)
50
64
  end
51
65
 
52
66
  # Compatibility alias for the former FFTW3-backed implementation.
53
67
  #
54
68
  # @deprecated Use {.convolve_fft}; Convolver no longer uses FFTW3.
55
- # @return [Numo::SFloat] valid cross-correlation result
56
- def convolve_fftw3(signal, kernel)
69
+ # @return [Numo::SFloat] cross-correlation result
70
+ def convolve_fftw3(signal, kernel, mode: :valid, boundary: :constant,
71
+ fill_value: UNSPECIFIED_FILL, origin: 0)
57
72
  warn 'Convolver.convolve_fftw3 is deprecated; use .convolve_fft instead', uplevel: 1
58
- convolve_fft(signal, kernel)
73
+ options = operation_options(mode, boundary, fill_value, origin)
74
+ convolve_fft(signal, kernel, **options)
59
75
  end
60
76
 
61
- # Estimates the relative cost of {.convolve_fft}.
77
+ # Estimates the relative cost of {.convolve_fft} for the requested options.
62
78
  #
63
- # @param signal [Numo::NArray] input values
64
- # @param kernel [Numo::NArray] correlation kernel
65
79
  # @return [Float] machine-specific relative cost estimate
66
- def predict_convolve_fft_time(signal, kernel)
67
- validate_inputs!(signal, kernel)
68
- output_size = result_shape(signal.shape, kernel.shape).inject(:*)
69
- 16 * 4.55e-08 * output_size * Math.log(output_size)
80
+ def predict_convolve_fft_time(signal, kernel, mode: :valid, boundary: :constant,
81
+ fill_value: UNSPECIFIED_FILL, origin: 0)
82
+ plan = operation_plan(signal, kernel, mode:, boundary:, fill_value:, origin:)
83
+ transform_size = plan.wrap? ? signal.size : plan.linear_fft_size(kernel.shape)
84
+ transform_cost = 16 * 4.55e-08 * transform_size * Math.log(transform_size)
85
+ transform_cost + (4.55e-08 * fft_preparation_size(plan, signal, kernel))
70
86
  end
71
87
 
72
- # Estimates the relative cost of {.convolve_basic}.
88
+ # Estimates the relative cost of {.convolve_basic} for the requested options.
73
89
  #
74
- # @param signal [Numo::NArray] input values
75
- # @param kernel [Numo::NArray] correlation kernel
76
90
  # @return [Float] machine-specific relative cost estimate
77
- def predict_convolve_basic_time(signal, kernel)
78
- validate_inputs!(signal, kernel)
79
- outputs = result_shape(signal.shape, kernel.shape).inject(:*)
80
- 4.54e-12 * (outputs * signal.size * kernel.size)
91
+ def predict_convolve_basic_time(signal, kernel, mode: :valid, boundary: :constant,
92
+ fill_value: UNSPECIFIED_FILL, origin: 0)
93
+ plan = operation_plan(signal, kernel, mode:, boundary:, fill_value:, origin:)
94
+ operations = plan.result_size * plan.extended_size * kernel.size
95
+ operations += plan.extended_size unless plan.valid?
96
+ 4.54e-12 * operations
81
97
  end
82
98
 
83
99
  private
84
100
 
85
- def result_shape(signal_shape, kernel_shape)
86
- signal_shape.zip(kernel_shape).map { |signal_size, kernel_size| signal_size - kernel_size + 1 }
101
+ private :convolve_basic_valid
102
+
103
+ def operation_plan(signal, kernel, mode:, boundary:, fill_value:, origin:)
104
+ OperationPlan.new(signal, kernel, mode:, boundary:, fill_value:, origin:)
105
+ end
106
+
107
+ def operation_options(mode, boundary, fill_value, origin)
108
+ options = { mode:, boundary:, origin: }
109
+ options[:fill_value] = fill_value unless fill_value.equal?(UNSPECIFIED_FILL)
110
+ options
111
+ end
112
+
113
+ def convolve_fft_valid(signal, kernel)
114
+ ranges = kernel.shape.zip(signal.shape).map do |kernel_size, signal_size|
115
+ (kernel_size - 1)...signal_size
116
+ end
117
+ full_convolution = Numo::Pocketfft.fftconvolve(signal, kernel.reverse)
118
+
119
+ Numo::SFloat.cast(full_convolution[*ranges])
87
120
  end
88
121
 
89
- def validate_inputs!(signal, kernel)
90
- validate_types!(signal, kernel)
91
- validate_shapes!(signal, kernel)
122
+ def convolve_fft_wrap(signal, kernel, plan)
123
+ signal_value = Numo::DFloat.cast(signal)
124
+ folded_kernel = fold_kernel(Numo::DFloat.cast(kernel), signal.shape, plan.anchors)
125
+ spectrum = Numo::Pocketfft.fftn(signal_value) * Numo::Pocketfft.fftn(folded_kernel).conj
126
+
127
+ Numo::SFloat.cast(Numo::Pocketfft.ifftn(spectrum).real)
128
+ end
129
+
130
+ def fold_kernel(kernel, signal_shape, anchors)
131
+ folded = Numo::DFloat.zeros(*signal_shape)
132
+ kernel.flatten.each_with_index do |value, flat_index|
133
+ target = folded_kernel_target(flat_index, kernel.shape, signal_shape, anchors)
134
+ folded[*target] = folded[*target] + value
135
+ end
136
+ folded
92
137
  end
93
138
 
94
- def validate_types!(signal, kernel)
95
- unless signal.is_a?(Numo::NArray) && kernel.is_a?(Numo::NArray)
96
- raise ArgumentError, 'signal and kernel must be Numo::NArray values'
139
+ def folded_kernel_target(flat_index, kernel_shape, signal_shape, anchors)
140
+ remainder = flat_index
141
+ Array.new(kernel_shape.length).tap do |target|
142
+ (kernel_shape.length - 1).downto(0) do |axis|
143
+ coordinate = remainder % kernel_shape[axis]
144
+ remainder /= kernel_shape[axis]
145
+ target[axis] = (coordinate - anchors[axis]) % signal_shape[axis]
146
+ end
97
147
  end
98
- raise ArgumentError, 'signal and kernel must not be empty' if signal.empty? || kernel.empty?
99
148
  end
100
149
 
101
- def validate_shapes!(signal, kernel)
102
- raise ArgumentError, 'signal and kernel must have equal rank' unless signal.ndim == kernel.ndim
103
- raise ArgumentError, "maximum supported rank is #{MAX_RANK}" if signal.ndim > MAX_RANK
104
- return if signal.shape.zip(kernel.shape).all? { |signal_size, kernel_size| signal_size >= kernel_size }
150
+ def fft_preparation_size(plan, signal, kernel)
151
+ return signal.size + kernel.size if plan.wrap?
152
+ return 0 if plan.valid?
105
153
 
106
- raise ArgumentError, 'kernel must not be larger than signal in any dimension'
154
+ plan.extended_size
107
155
  end
108
156
  end
109
157
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: convolver
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Neil Slater
@@ -49,8 +49,8 @@ dependencies:
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
51
  version: '0.8'
52
- description: Fast valid cross-correlation for multidimensional Numo::NArray values,
53
- with native and FFT implementations.
52
+ description: Fast cross-correlation for multidimensional Numo::NArray values, with
53
+ configurable output and boundary modes.
54
54
  email:
55
55
  - slobo777@gmail.com
56
56
  executables: []
@@ -66,6 +66,10 @@ files:
66
66
  - ext/convolver/convolver.c
67
67
  - ext/convolver/extconf.rb
68
68
  - lib/convolver.rb
69
+ - lib/convolver/operation_options.rb
70
+ - lib/convolver/operation_plan.rb
71
+ - lib/convolver/operation_shapes.rb
72
+ - lib/convolver/signal_extension.rb
69
73
  - lib/convolver/version.rb
70
74
  homepage: https://github.com/neilslater/convolver
71
75
  licenses:
@@ -80,7 +84,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
80
84
  requirements:
81
85
  - - ">="
82
86
  - !ruby/object:Gem::Version
83
- version: '3.2'
87
+ version: '3.3'
84
88
  required_rubygems_version: !ruby/object:Gem::Requirement
85
89
  requirements:
86
90
  - - ">="