Skip to content

Commit e44d135

Browse files
authored
Merge pull request scipy#18375 from sagi-ezri/add-fwind1-filter
ENH: signal: Add `firwin_2d` filter
2 parents b384efd + b3cb5e7 commit e44d135

File tree

4 files changed

+261
-6
lines changed

4 files changed

+261
-6
lines changed

scipy/signal/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@
8787
-- defined as pass and stop bands.
8888
firwin2 -- Windowed FIR filter design, with arbitrary frequency
8989
-- response.
90+
firwin_2d -- Windowed FIR filter design, with frequency response for
91+
-- 2D using 1D design.
9092
freqs -- Analog filter frequency response from TF coefficients.
9193
freqs_zpk -- Analog filter frequency response from ZPK coefficients.
9294
freqz -- Digital filter frequency response from TF coefficients.

scipy/signal/_fir_filter_design.py

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414

1515
from . import _sigtools
1616

17+
1718
__all__ = ['kaiser_beta', 'kaiser_atten', 'kaiserord',
18-
'firwin', 'firwin2', 'remez', 'firls', 'minimum_phase']
19+
'firwin', 'firwin2', 'firwin_2d', 'remez', 'firls', 'minimum_phase']
1920

2021

2122
# Some notes on function parameters:
@@ -323,6 +324,7 @@ def firwin(numtaps, cutoff, *, width=None, window='hamming', pass_zero=True,
323324
See Also
324325
--------
325326
firwin2
327+
firwin_2d
326328
firls
327329
minimum_phase
328330
remez
@@ -1284,3 +1286,148 @@ def minimum_phase(h: np.ndarray,
12841286
h_minimum = h_temp.real
12851287
n_out = (n_half + len(h) % 2) if half else len(h)
12861288
return h_minimum[:n_out]
1289+
1290+
1291+
def firwin_2d(hsize, window, *, fc=None, fs=2, circular=False,
1292+
pass_zero=True, scale=True):
1293+
"""
1294+
2D FIR filter design using the window method.
1295+
1296+
This function computes the coefficients of a 2D finite impulse response
1297+
filter. The filter is separable with linear phase; it will be designed
1298+
as a product of two 1D filters with dimensions defined by `hsize`.
1299+
Additionally, it can create approximately circularly symmetric 2-D windows.
1300+
1301+
Parameters
1302+
----------
1303+
hsize : tuple or list of length 2
1304+
Lengths of the filter in each dimension. `hsize[0]` specifies the
1305+
number of coefficients in the row direction and `hsize[1]` specifies
1306+
the number of coefficients in the column direction.
1307+
window : tuple or list of length 2 or string
1308+
Desired window to use for each 1D filter or a single window type
1309+
for creating circularly symmetric 2-D windows. Each element should be
1310+
a string or tuple of string and parameter values. See
1311+
`~scipy.signal.get_window` for a list of windows and required
1312+
parameters.
1313+
fc : float or 1-D array_like, optional
1314+
Cutoff frequency of the filter in the same units as `fs`. This defines
1315+
the frequency at which the filter's gain drops to approximately -6 dB
1316+
(half power) in a low-pass or high-pass filter. For multi-band filters,
1317+
`fc` can be an array of cutoff frequencies (i.e., band edges) in the
1318+
range [0, fs/2], with each band specified in pairs. Required if
1319+
`circular` is False.
1320+
fs : float, optional
1321+
The sampling frequency of the signal. Default is 2.
1322+
circular : bool, optional
1323+
Whether to create a circularly symmetric 2-D window. Default is ``False``.
1324+
pass_zero : {True, False, 'bandpass', 'lowpass', 'highpass', 'bandstop'}, optional
1325+
This parameter is directly passed to `firwin` for each scalar frequency axis.
1326+
Hence, if ``True``, the DC gain, i.e., the gain at frequency (0, 0), is 1.
1327+
If ``False``, the DC gain is 0 at frequency (0, 0) if `circular` is ``True``.
1328+
If `circular` is ``False`` the frequencies (0, f1) and (f0, 0) will
1329+
have gain 0.
1330+
It can also be a string argument for the desired filter type
1331+
(equivalent to ``btype`` in IIR design functions).
1332+
scale : bool, optional
1333+
This parameter is directly passed to `firwin` for each scalar frequency axis.
1334+
Set to ``True`` to scale the coefficients so that the frequency
1335+
response is exactly unity at a certain frequency on one frequency axis.
1336+
That frequency is either:
1337+
1338+
- 0 (DC) if the first passband starts at 0 (i.e. pass_zero is ``True``)
1339+
- `fs`/2 (the Nyquist frequency) if the first passband ends at `fs`/2
1340+
(i.e., the filter is a single band highpass filter);
1341+
center of first passband otherwise
1342+
1343+
Returns
1344+
-------
1345+
filter_2d : (hsize[0], hsize[1]) ndarray
1346+
Coefficients of 2D FIR filter.
1347+
1348+
Raises
1349+
------
1350+
ValueError
1351+
- If `hsize` and `window` are not 2-element tuples or lists.
1352+
- If `cutoff` is None when `circular` is True.
1353+
- If `cutoff` is outside the range [0, `fs`/2] and `circular` is ``False``.
1354+
- If any of the elements in `window` are not recognized.
1355+
RuntimeError
1356+
If `firwin` fails to converge when designing the filter.
1357+
1358+
See Also
1359+
--------
1360+
firwin: FIR filter design using the window method for 1d arrays.
1361+
get_window: Return a window of a given length and type.
1362+
1363+
Examples
1364+
--------
1365+
Generate a 5x5 low-pass filter with cutoff frequency 0.1:
1366+
1367+
>>> import numpy as np
1368+
>>> from scipy.signal import get_window
1369+
>>> from scipy.signal import firwin_2d
1370+
>>> hsize = (5, 5)
1371+
>>> window = (("kaiser", 5.0), ("kaiser", 5.0))
1372+
>>> fc = 0.1
1373+
>>> filter_2d = firwin_2d(hsize, window, fc=fc)
1374+
>>> filter_2d
1375+
array([[0.00025366, 0.00401662, 0.00738617, 0.00401662, 0.00025366],
1376+
[0.00401662, 0.06360159, 0.11695714, 0.06360159, 0.00401662],
1377+
[0.00738617, 0.11695714, 0.21507283, 0.11695714, 0.00738617],
1378+
[0.00401662, 0.06360159, 0.11695714, 0.06360159, 0.00401662],
1379+
[0.00025366, 0.00401662, 0.00738617, 0.00401662, 0.00025366]])
1380+
1381+
Generate a circularly symmetric 5x5 low-pass filter with Hamming window:
1382+
1383+
>>> filter_2d = firwin_2d((5, 5), 'hamming', fc=fc, circular=True)
1384+
>>> filter_2d
1385+
array([[-0.00020354, -0.00020354, -0.00020354, -0.00020354, -0.00020354],
1386+
[-0.00020354, 0.01506844, 0.09907658, 0.01506844, -0.00020354],
1387+
[-0.00020354, 0.09907658, -0.00020354, 0.09907658, -0.00020354],
1388+
[-0.00020354, 0.01506844, 0.09907658, 0.01506844, -0.00020354],
1389+
[-0.00020354, -0.00020354, -0.00020354, -0.00020354, -0.00020354]])
1390+
1391+
Generate Plots comparing the product of two 1d filters with a circular
1392+
symmetric filter:
1393+
1394+
>>> import matplotlib.pyplot as plt
1395+
>>> hsize, fc = (50, 50), 0.05
1396+
>>> window = (("kaiser", 5.0), ("kaiser", 5.0))
1397+
>>> filter0_2d = firwin_2d(hsize, window, fc=fc)
1398+
>>> filter1_2d = firwin_2d((50, 50), 'hamming', fc=fc, circular=True)
1399+
...
1400+
>>> fg, (ax0, ax1) = plt.subplots(1, 2, tight_layout=True, figsize=(6.5, 3.5))
1401+
>>> ax0.set_title("Product of 2 Windows")
1402+
>>> im0 = ax0.imshow(filter0_2d, cmap='viridis', origin='lower', aspect='equal')
1403+
>>> fg.colorbar(im0, ax=ax0, shrink=0.7)
1404+
>>> ax1.set_title("Circular Window")
1405+
>>> im1 = ax1.imshow(filter1_2d, cmap='plasma', origin='lower', aspect='equal')
1406+
>>> fg.colorbar(im1, ax=ax1, shrink=0.7)
1407+
>>> plt.show()
1408+
"""
1409+
if len(hsize) != 2:
1410+
raise ValueError("hsize must be a 2-element tuple or list")
1411+
1412+
if circular:
1413+
if fc is None:
1414+
raise ValueError("Cutoff frequency `fc` must be "
1415+
"provided when `circular` is True")
1416+
1417+
n_r = max(hsize[0], hsize[1]) * 8 # oversample 1d window by factor 8
1418+
1419+
win_r = firwin(n_r, cutoff=fc, window=window, fs=fs)
1420+
1421+
f1, f2 = np.meshgrid(np.linspace(-1, 1, hsize[0]), np.linspace(-1, 1, hsize[1]))
1422+
r = np.sqrt(f1**2 + f2**2)
1423+
1424+
win_2d = np.interp(r, np.linspace(0, 1, n_r), win_r)
1425+
return win_2d
1426+
1427+
if len(window) != 2:
1428+
raise ValueError("window must be a 2-element tuple or list")
1429+
1430+
row_filter = firwin(hsize[0], cutoff=fc, window=window[0], fs=fs)
1431+
col_filter = firwin(hsize[1], cutoff=fc, window=window[1], fs=fs)
1432+
1433+
return np.outer(row_filter, col_filter)

scipy/signal/fir_filter_design.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
__all__ = [ # noqa: F822
88
'kaiser_beta', 'kaiser_atten', 'kaiserord',
99
'firwin', 'firwin2', 'remez', 'firls', 'minimum_phase',
10+
'firwin_2d',
1011
]
1112

1213

scipy/signal/tests/test_fir_filter_design.py

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
from pytest import raises as assert_raises
88
import pytest
99

10-
from scipy.fft import fft
10+
from scipy.fft import fft, fft2
1111
from scipy.special import sinc
12-
from scipy.signal import (kaiser_beta, kaiser_atten, kaiserord,
13-
firwin, firwin2, freqz, remez, firls, minimum_phase
14-
)
15-
12+
from scipy.signal import kaiser_beta, kaiser_atten, kaiserord, \
13+
firwin, firwin2, freqz, remez, firls, minimum_phase, \
14+
convolve2d
15+
from scipy.signal._fir_filter_design import firwin_2d
1616

1717
def test_kaiser_beta():
1818
b = kaiser_beta(58.7)
@@ -652,3 +652,108 @@ def test_hilbert(self):
652652
-0.014977068692269, -0.158416139047557]
653653
m = minimum_phase(h, 'hilbert', n_fft=2**19)
654654
xp_assert_close(m, k, rtol=2e-3)
655+
656+
657+
class Testfirwin_2d:
658+
def test_invalid_args(self):
659+
with pytest.raises(ValueError,
660+
match="hsize must be a 2-element tuple or list"):
661+
firwin_2d((50,), window=(("kaiser", 5.0), "boxcar"), fc=0.4)
662+
663+
with pytest.raises(ValueError,
664+
match="window must be a 2-element tuple or list"):
665+
firwin_2d((51, 51), window=("hamming",), fc=0.5)
666+
667+
with pytest.raises(ValueError,
668+
match="window must be a 2-element tuple or list"):
669+
firwin_2d((51, 51), window="invalid_window", fc=0.5)
670+
671+
def test_filter_design(self):
672+
hsize = (51, 51)
673+
window = (("kaiser", 8.0), ("kaiser", 8.0))
674+
fc = 0.4
675+
taps_kaiser = firwin_2d(hsize, window, fc=fc)
676+
assert taps_kaiser.shape == (51, 51)
677+
678+
window = ("hamming", "hamming")
679+
taps_hamming = firwin_2d(hsize, window, fc=fc)
680+
assert taps_hamming.shape == (51, 51)
681+
682+
def test_impulse_response(self):
683+
hsize = (31, 31)
684+
window = ("hamming", "hamming")
685+
fc = 0.4
686+
taps = firwin_2d(hsize, window, fc=fc)
687+
688+
impulse = np.zeros((63, 63))
689+
impulse[31, 31] = 1
690+
691+
response = convolve2d(impulse, taps, mode='same')
692+
693+
expected_response = taps
694+
xp_assert_close(response[16:47, 16:47], expected_response, rtol=1e-5)
695+
696+
def test_frequency_response(self):
697+
"""Compare 1d and 2d frequency response. """
698+
hsize = (31, 31)
699+
windows = ("hamming", "hamming")
700+
fc = 0.4
701+
taps_1d = firwin(numtaps=hsize[0], cutoff=fc, window=windows[0])
702+
taps_2d = firwin_2d(hsize, windows, fc=fc)
703+
704+
f_resp_1d = fft(taps_1d)
705+
f_resp_2d = fft2(taps_2d)
706+
707+
xp_assert_close(f_resp_2d[0, :], f_resp_1d,
708+
err_msg='DC Gain at (0, f1) is not unity!')
709+
xp_assert_close(f_resp_2d[:, 0], f_resp_1d,
710+
err_msg='DC Gain at (f0, 0) is not unity!')
711+
xp_assert_close(f_resp_2d, np.outer(f_resp_1d, f_resp_1d),
712+
atol=np.finfo(f_resp_2d.dtype).resolution,
713+
err_msg='2d frequency response is not product of 1d responses')
714+
715+
def test_symmetry(self):
716+
hsize = (51, 51)
717+
window = ("hamming", "hamming")
718+
fc = 0.4
719+
taps = firwin_2d(hsize, window, fc=fc)
720+
xp_assert_close(taps, np.flip(taps), rtol=1e-5)
721+
722+
def test_circular_symmetry(self):
723+
hsize = (51, 51)
724+
window = "hamming"
725+
taps = firwin_2d(hsize, window, circular=True, fc=0.5)
726+
center = hsize[0] // 2
727+
for i in range(hsize[0]):
728+
for j in range(hsize[1]):
729+
xp_assert_close(taps[i, j],
730+
taps[center - (i - center), center - (j - center)],
731+
rtol=1e-5)
732+
733+
def test_edge_case_circular(self):
734+
hsize = (3, 3)
735+
window = "hamming"
736+
taps_small = firwin_2d(hsize, window, circular=True, fc=0.5)
737+
assert taps_small.shape == (3, 3)
738+
739+
hsize = (101, 101)
740+
taps_large = firwin_2d(hsize, window, circular=True, fc=0.5)
741+
assert taps_large.shape == (101, 101)
742+
743+
def test_known_result(self):
744+
hsize = (5, 5)
745+
window = ('kaiser', 8.0)
746+
fc = 0.1
747+
fs = 2
748+
749+
row_filter = firwin(hsize[0], cutoff=fc, window=window, fs=fs)
750+
col_filter = firwin(hsize[1], cutoff=fc, window=window, fs=fs)
751+
known_result = np.outer(row_filter, col_filter)
752+
753+
taps = firwin_2d(hsize, (window, window), fc=fc)
754+
assert taps.shape == known_result.shape, (
755+
f"Shape mismatch: {taps.shape} vs {known_result.shape}"
756+
)
757+
assert np.allclose(taps, known_result, rtol=1e-1), (
758+
f"Filter shape mismatch: {taps} vs {known_result}"
759+
)

0 commit comments

Comments
 (0)