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 +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +76 -7
- data/ext/convolver/convolver.c +3 -3
- data/lib/convolver/operation_options.rb +114 -0
- data/lib/convolver/operation_plan.rb +48 -0
- data/lib/convolver/operation_shapes.rb +96 -0
- data/lib/convolver/signal_extension.rb +66 -0
- data/lib/convolver/version.rb +1 -1
- data/lib/convolver.rb +109 -61
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08959da24c94d02889f0067f18f7233578b4b14ec4e58538b144efeef1cbe25f'
|
|
4
|
+
data.tar.gz: c39df2ebf01a04f93fa62e49bd8a4542e78244868d822d7f47a00cc78b9aaa7e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
[](https://github.com/neilslater/convolver/actions/workflows/ci.yml)
|
|
3
4
|
[](https://badge.fury.io/rb/convolver)
|
|
4
5
|
|
|
5
|
-
Convolver calculates
|
|
6
|
-
[`Numo::NArray`](https://github.com/yoshoku/numo-narray-alt) values
|
|
7
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
data/ext/convolver/convolver.c
CHANGED
|
@@ -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
|
|
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
|
|
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, "
|
|
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
|
data/lib/convolver/version.rb
CHANGED
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
|
-
#
|
|
8
|
+
# Cross-correlation operations for Numo::NArray values.
|
|
9
9
|
module Convolver
|
|
10
|
-
# Maximum number of dimensions supported by the
|
|
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
|
|
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
|
-
# @
|
|
24
|
-
# @
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
52
|
+
# Uses PocketFFT to calculate the requested cross-correlation.
|
|
39
53
|
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
# @
|
|
44
|
-
def convolve_fft(signal, kernel
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
53
|
-
|
|
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.
|
|
87
|
+
version: '3.3'
|
|
84
88
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
89
|
requirements:
|
|
86
90
|
- - ">="
|