Skip to content

Commit cda44a4

Browse files
committed
SavitzkyGolay simple layer checks for PyTorch > 1.5.1 if padding mode not zeros, and version dependent tests added.
Signed-off-by: Christian Baker <christian.baker@kcl.ac.uk>
1 parent 204c02e commit cda44a4

4 files changed

Lines changed: 264 additions & 209 deletions

File tree

monai/networks/layers/simplelayers.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"LLTM",
4040
"Reshape",
4141
"separable_filtering",
42-
"SavitskyGolayFilter",
42+
"SavitzkyGolayFilter",
4343
"HilbertTransform",
4444
"ChannelPad",
4545
]
@@ -174,12 +174,18 @@ def separable_filtering(
174174
x: the input image. must have shape (batch, channels, H[, W, ...]).
175175
kernels: kernel along each spatial dimension.
176176
could be a single kernel (duplicated for all dimension), or `spatial_dims` number of kernels.
177-
mode (string, optional): padding mode passed to convolution class. ``'zeros'``, ``'reflect'``, ``'replicate'`` or
178-
``'circular'``. Default: ``'zeros'``. See torch.nn.Conv1d() for more information.
177+
mode (string, optional): padding mode passed to convolution class. ``'zeros'``, ``'reflect'``, ``'replicate'``
178+
or ``'circular'``. Default: ``'zeros'``. Modes other than ``'zeros'`` require PyTorch version >= 1.5.1. See
179+
torch.nn.Conv1d() for more information.
179180
180181
Raises:
181182
TypeError: When ``x`` is not a ``torch.Tensor``.
182183
"""
184+
185+
pytorch_version_pre_1_5_1 = tuple(int(x[0]) for x in torch.__version__.split(".")[:3]) < (1, 5, 1)
186+
if (mode != "zeros") and pytorch_version_pre_1_5_1:
187+
raise InvalidPyTorchVersionError("1.5.1", f"Padding mode '{mode}'")
188+
183189
if not torch.is_tensor(x):
184190
raise TypeError(f"x must be a torch.Tensor but is {type(x).__name__}.")
185191

@@ -219,9 +225,9 @@ def _conv(input_: torch.Tensor, d: int) -> torch.Tensor:
219225
return _conv(x, spatial_dims - 1)
220226

221227

222-
class SavitskyGolayFilter(nn.Module):
228+
class SavitzkyGolayFilter(nn.Module):
223229
"""
224-
Convolve a Tensor along a particular axis with a Savitsky-Golay kernel.
230+
Convolve a Tensor along a particular axis with a Savitzky-Golay kernel.
225231
226232
Args:
227233
window_length: Length of the filter window, must be a positive odd integer.
@@ -247,7 +253,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
247253
x: Tensor or array-like to filter. Must be real, in shape ``[Batch, chns, spatial1, spatial2, ...]`` and
248254
have a device type of ``'cpu'``.
249255
Returns:
250-
torch.Tensor: ``x`` filtered by Savitsky-Golay kernel with window length ``self.window_length`` using
256+
torch.Tensor: ``x`` filtered by Savitzky-Golay kernel with window length ``self.window_length`` using
251257
polynomials of order ``self.order``, along axis specified in ``self.axis``.
252258
"""
253259

Lines changed: 162 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,136 +1,162 @@
1-
# Copyright 2020 MONAI Consortium
2-
# Licensed under the Apache License, Version 2.0 (the "License");
3-
# you may not use this file except in compliance with the License.
4-
# You may obtain a copy of the License at
5-
# http://www.apache.org/licenses/LICENSE-2.0
6-
# Unless required by applicable law or agreed to in writing, software
7-
# distributed under the License is distributed on an "AS IS" BASIS,
8-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9-
# See the License for the specific language governing permissions and
10-
# limitations under the License.
11-
12-
import unittest
13-
14-
import numpy as np
15-
import torch
16-
from parameterized import parameterized
17-
18-
from monai.networks.layers import SavitskyGolayFilter
19-
from tests.utils import skip_if_no_cuda
20-
21-
# Zero-padding trivial tests
22-
23-
TEST_CASE_SINGLE_VALUE = [
24-
{"window_length": 3, "order": 1},
25-
torch.Tensor([1.0]).unsqueeze(0).unsqueeze(0), # Input data: Single value
26-
torch.Tensor([1 / 3]).unsqueeze(0).unsqueeze(0), # Expected output: With a window length of 3 and polyorder 1
27-
# output should be equal to mean of 0, 1 and 0 = 1/3 (because input will be zero-padded and a linear fit performed)
28-
1e-15, # absolute tolerance
29-
]
30-
31-
TEST_CASE_1D = [
32-
{"window_length": 3, "order": 1},
33-
torch.Tensor([1.0, 1.0, 1.0]).unsqueeze(0).unsqueeze(0), # Input data
34-
torch.Tensor([2 / 3, 1.0, 2 / 3])
35-
.unsqueeze(0)
36-
.unsqueeze(0), # Expected output: zero padded, so linear interpolation
37-
# over length-3 windows will result in output of [2/3, 1, 2/3].
38-
1e-15, # absolute tolerance
39-
]
40-
41-
TEST_CASE_2D_AXIS_2 = [
42-
{"window_length": 3, "order": 1}, # along default axis (2, first spatial dim)
43-
torch.ones((3, 2)).unsqueeze(0).unsqueeze(0),
44-
torch.Tensor([[2 / 3, 2 / 3], [1.0, 1.0], [2 / 3, 2 / 3]]).unsqueeze(0).unsqueeze(0),
45-
1e-15, # absolute tolerance
46-
]
47-
48-
TEST_CASE_2D_AXIS_3 = [
49-
{"window_length": 3, "order": 1, "axis": 3}, # along axis 3 (second spatial dim)
50-
torch.ones((2, 3)).unsqueeze(0).unsqueeze(0),
51-
torch.Tensor([[2 / 3, 1.0, 2 / 3], [2 / 3, 1.0, 2 / 3]]).unsqueeze(0).unsqueeze(0),
52-
1e-15, # absolute tolerance
53-
]
54-
55-
# Replicated-padding trivial tests
56-
57-
TEST_CASE_SINGLE_VALUE_REP = [
58-
{"window_length": 3, "order": 1, "mode": "replicate"},
59-
torch.Tensor([1.0]).unsqueeze(0).unsqueeze(0), # Input data: Single value
60-
torch.Tensor([1.0]).unsqueeze(0).unsqueeze(0), # Expected output: With a window length of 3 and polyorder 1
61-
# output will be equal to mean of [1, 1, 1] = 1 (input will be nearest-neighbour-padded and a linear fit performed)
62-
1e-15, # absolute tolerance
63-
]
64-
65-
TEST_CASE_1D_REP = [
66-
{"window_length": 3, "order": 1, "mode": "replicate"},
67-
torch.Tensor([1.0, 1.0, 1.0]).unsqueeze(0).unsqueeze(0), # Input data
68-
torch.Tensor([1.0, 1.0, 1.0]).unsqueeze(0).unsqueeze(0), # Expected output: zero padded, so linear interpolation
69-
# over length-3 windows will result in output of [2/3, 1, 2/3].
70-
1e-15, # absolute tolerance
71-
]
72-
73-
TEST_CASE_2D_AXIS_2_REP = [
74-
{"window_length": 3, "order": 1, "mode": "replicate"}, # along default axis (2, first spatial dim)
75-
torch.ones((3, 2)).unsqueeze(0).unsqueeze(0),
76-
torch.Tensor([[1.0, 1.0], [1.0, 1.0], [1.0, 1.0]]).unsqueeze(0).unsqueeze(0),
77-
1e-15, # absolute tolerance
78-
]
79-
80-
TEST_CASE_2D_AXIS_3_REP = [
81-
{"window_length": 3, "order": 1, "axis": 3, "mode": "replicate"}, # along axis 3 (second spatial dim)
82-
torch.ones((2, 3)).unsqueeze(0).unsqueeze(0),
83-
torch.Tensor([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]).unsqueeze(0).unsqueeze(0),
84-
1e-15, # absolute tolerance
85-
]
86-
87-
# Sine smoothing
88-
89-
TEST_CASE_SINE_SMOOTH = [
90-
{"window_length": 3, "order": 1},
91-
# Sine wave with period equal to savgol window length (windowed to reduce edge effects).
92-
torch.as_tensor(np.sin(2 * np.pi * 1 / 3 * np.arange(100)) * np.hanning(100)).unsqueeze(0).unsqueeze(0),
93-
# Should be smoothed out to zeros
94-
torch.zeros(100).unsqueeze(0).unsqueeze(0),
95-
# tolerance chosen by examining output of SciPy.signal.savgol_filter when provided the above input
96-
2e-2, # absolute tolerance
97-
]
98-
99-
100-
class TestSavitskyGolayCPU(unittest.TestCase):
101-
@parameterized.expand(
102-
[
103-
TEST_CASE_SINGLE_VALUE,
104-
TEST_CASE_1D,
105-
TEST_CASE_2D_AXIS_2,
106-
TEST_CASE_2D_AXIS_3,
107-
TEST_CASE_SINGLE_VALUE_REP,
108-
TEST_CASE_1D_REP,
109-
TEST_CASE_2D_AXIS_2_REP,
110-
TEST_CASE_2D_AXIS_3_REP,
111-
TEST_CASE_SINE_SMOOTH,
112-
]
113-
)
114-
def test_value(self, arguments, image, expected_data, atol):
115-
result = SavitskyGolayFilter(**arguments)(image)
116-
np.testing.assert_allclose(result, expected_data, atol=atol)
117-
118-
119-
@skip_if_no_cuda
120-
class TestSavitskyGolayGPU(unittest.TestCase):
121-
@parameterized.expand(
122-
[
123-
TEST_CASE_SINGLE_VALUE,
124-
TEST_CASE_1D,
125-
TEST_CASE_2D_AXIS_2,
126-
TEST_CASE_2D_AXIS_3,
127-
TEST_CASE_SINGLE_VALUE_REP,
128-
TEST_CASE_1D_REP,
129-
TEST_CASE_2D_AXIS_2_REP,
130-
TEST_CASE_2D_AXIS_3_REP,
131-
TEST_CASE_SINE_SMOOTH,
132-
]
133-
)
134-
def test_value(self, arguments, image, expected_data, atol):
135-
result = SavitskyGolayFilter(**arguments)(image.to(device="cuda"))
136-
np.testing.assert_allclose(result.cpu(), expected_data, atol=atol)
1+
# Copyright 2020 MONAI Consortium
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
12+
import unittest
13+
14+
import numpy as np
15+
import torch
16+
from parameterized import parameterized
17+
18+
from monai.networks.layers import SavitzkyGolayFilter
19+
from monai.utils import InvalidPyTorchVersionError
20+
from tests.utils import SkipIfAtLeastPyTorchVersion, SkipIfBeforePyTorchVersion, skip_if_no_cuda
21+
22+
# Zero-padding trivial tests
23+
24+
TEST_CASE_SINGLE_VALUE = [
25+
{"window_length": 3, "order": 1},
26+
torch.Tensor([1.0]).unsqueeze(0).unsqueeze(0), # Input data: Single value
27+
torch.Tensor([1 / 3]).unsqueeze(0).unsqueeze(0), # Expected output: With a window length of 3 and polyorder 1
28+
# output should be equal to mean of 0, 1 and 0 = 1/3 (because input will be zero-padded and a linear fit performed)
29+
1e-15, # absolute tolerance
30+
]
31+
32+
TEST_CASE_1D = [
33+
{"window_length": 3, "order": 1},
34+
torch.Tensor([1.0, 1.0, 1.0]).unsqueeze(0).unsqueeze(0), # Input data
35+
torch.Tensor([2 / 3, 1.0, 2 / 3])
36+
.unsqueeze(0)
37+
.unsqueeze(0), # Expected output: zero padded, so linear interpolation
38+
# over length-3 windows will result in output of [2/3, 1, 2/3].
39+
1e-15, # absolute tolerance
40+
]
41+
42+
TEST_CASE_2D_AXIS_2 = [
43+
{"window_length": 3, "order": 1}, # along default axis (2, first spatial dim)
44+
torch.ones((3, 2)).unsqueeze(0).unsqueeze(0),
45+
torch.Tensor([[2 / 3, 2 / 3], [1.0, 1.0], [2 / 3, 2 / 3]]).unsqueeze(0).unsqueeze(0),
46+
1e-15, # absolute tolerance
47+
]
48+
49+
TEST_CASE_2D_AXIS_3 = [
50+
{"window_length": 3, "order": 1, "axis": 3}, # along axis 3 (second spatial dim)
51+
torch.ones((2, 3)).unsqueeze(0).unsqueeze(0),
52+
torch.Tensor([[2 / 3, 1.0, 2 / 3], [2 / 3, 1.0, 2 / 3]]).unsqueeze(0).unsqueeze(0),
53+
1e-15, # absolute tolerance
54+
]
55+
56+
# Replicated-padding trivial tests
57+
58+
TEST_CASE_SINGLE_VALUE_REP = [
59+
{"window_length": 3, "order": 1, "mode": "replicate"},
60+
torch.Tensor([1.0]).unsqueeze(0).unsqueeze(0), # Input data: Single value
61+
torch.Tensor([1.0]).unsqueeze(0).unsqueeze(0), # Expected output: With a window length of 3 and polyorder 1
62+
# output will be equal to mean of [1, 1, 1] = 1 (input will be nearest-neighbour-padded and a linear fit performed)
63+
1e-15, # absolute tolerance
64+
]
65+
66+
TEST_CASE_1D_REP = [
67+
{"window_length": 3, "order": 1, "mode": "replicate"},
68+
torch.Tensor([1.0, 1.0, 1.0]).unsqueeze(0).unsqueeze(0), # Input data
69+
torch.Tensor([1.0, 1.0, 1.0]).unsqueeze(0).unsqueeze(0), # Expected output: zero padded, so linear interpolation
70+
# over length-3 windows will result in output of [2/3, 1, 2/3].
71+
1e-15, # absolute tolerance
72+
]
73+
74+
TEST_CASE_2D_AXIS_2_REP = [
75+
{"window_length": 3, "order": 1, "mode": "replicate"}, # along default axis (2, first spatial dim)
76+
torch.ones((3, 2)).unsqueeze(0).unsqueeze(0),
77+
torch.Tensor([[1.0, 1.0], [1.0, 1.0], [1.0, 1.0]]).unsqueeze(0).unsqueeze(0),
78+
1e-15, # absolute tolerance
79+
]
80+
81+
TEST_CASE_2D_AXIS_3_REP = [
82+
{"window_length": 3, "order": 1, "axis": 3, "mode": "replicate"}, # along axis 3 (second spatial dim)
83+
torch.ones((2, 3)).unsqueeze(0).unsqueeze(0),
84+
torch.Tensor([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]).unsqueeze(0).unsqueeze(0),
85+
1e-15, # absolute tolerance
86+
]
87+
88+
# Sine smoothing
89+
90+
TEST_CASE_SINE_SMOOTH = [
91+
{"window_length": 3, "order": 1},
92+
# Sine wave with period equal to savgol window length (windowed to reduce edge effects).
93+
torch.as_tensor(np.sin(2 * np.pi * 1 / 3 * np.arange(100)) * np.hanning(100)).unsqueeze(0).unsqueeze(0),
94+
# Should be smoothed out to zeros
95+
torch.zeros(100).unsqueeze(0).unsqueeze(0),
96+
# tolerance chosen by examining output of SciPy.signal.savgol_filter when provided the above input
97+
2e-2, # absolute tolerance
98+
]
99+
100+
101+
class TestSavitzkyGolayCPU(unittest.TestCase):
102+
@parameterized.expand(
103+
[
104+
TEST_CASE_SINGLE_VALUE,
105+
TEST_CASE_1D,
106+
TEST_CASE_2D_AXIS_2,
107+
TEST_CASE_2D_AXIS_3,
108+
TEST_CASE_SINE_SMOOTH,
109+
]
110+
)
111+
def test_value(self, arguments, image, expected_data, atol):
112+
result = SavitzkyGolayFilter(**arguments)(image)
113+
np.testing.assert_allclose(result, expected_data, atol=atol)
114+
115+
116+
@SkipIfBeforePyTorchVersion((1, 5, 1))
117+
class TestSavitzkyGolayCPUREP(unittest.TestCase):
118+
@parameterized.expand(
119+
[TEST_CASE_SINGLE_VALUE_REP, TEST_CASE_1D_REP, TEST_CASE_2D_AXIS_2_REP, TEST_CASE_2D_AXIS_3_REP]
120+
)
121+
def test_value(self, arguments, image, expected_data, atol):
122+
result = SavitzkyGolayFilter(**arguments)(image)
123+
np.testing.assert_allclose(result, expected_data, atol=atol)
124+
125+
126+
@skip_if_no_cuda
127+
class TestSavitzkyGolayGPU(unittest.TestCase):
128+
@parameterized.expand(
129+
[
130+
TEST_CASE_SINGLE_VALUE,
131+
TEST_CASE_1D,
132+
TEST_CASE_2D_AXIS_2,
133+
TEST_CASE_2D_AXIS_3,
134+
TEST_CASE_SINE_SMOOTH,
135+
]
136+
)
137+
def test_value(self, arguments, image, expected_data, atol):
138+
result = SavitzkyGolayFilter(**arguments)(image.to(device="cuda"))
139+
np.testing.assert_allclose(result.cpu(), expected_data, atol=atol)
140+
141+
142+
@skip_if_no_cuda
143+
@SkipIfBeforePyTorchVersion((1, 5, 1))
144+
class TestSavitzkyGolayGPUREP(unittest.TestCase):
145+
@parameterized.expand(
146+
[
147+
TEST_CASE_SINGLE_VALUE_REP,
148+
TEST_CASE_1D_REP,
149+
TEST_CASE_2D_AXIS_2_REP,
150+
TEST_CASE_2D_AXIS_3_REP,
151+
]
152+
)
153+
def test_value(self, arguments, image, expected_data, atol):
154+
result = SavitzkyGolayFilter(**arguments)(image.to(device="cuda"))
155+
np.testing.assert_allclose(result.cpu(), expected_data, atol=atol)
156+
157+
158+
@SkipIfAtLeastPyTorchVersion((1, 5, 1))
159+
class TestSavitzkyGolayInvalidPyTorch(unittest.TestCase):
160+
def test_invalid_pytorch_error(self):
161+
with self.assertRaisesRegex(InvalidPyTorchVersionError, "version"):
162+
SavitzkyGolayFilter(3, 1, mode="replicate")(torch.ones((1, 1, 10, 10)))

0 commit comments

Comments
 (0)