Repository: obspy/obspy

Issue:
Zerophase filtering not zerophase for numpy matrices
<!--

**GitHub should mainly be used for bug reports and code developments/discussions. For generic questions, please use either the [[obspy-users] mailing list](http://lists.swapbytes.de/mailman/listinfo/obspy-users) (for scientific questions to the general ObsPy community) or our [gitter chat](https://gitter.im/obspy/obspy) (for technical questions and direct contact to the developers).**

Before submitting an Issue, please review the [Issue Guidelines](https://github.com/obspy/obspy/blob/master/CONTRIBUTING.md#submitting-an-issue).

-->

With  `zerophase=True` the low pass filter is applied twice in opposite directions to give a zerophase output, as in this example with two delta spikes showing the expected behaviour:
```
import numpy as np
import matplotlib.pyplot as plt
from obspy.signal.filter import lowpass

ts=np.zeros([100])
ts[50]=1
ts[25]=0.5

ts_1=lowpass(ts, 0.05,1)      # single phase filter for comparison
ts_2=lowpass(ts, 0.05,1,zerophase=True)

plt.figure()
plt.plot(ts)
plt.plot(ts_1)
plt.plot(ts_2)
```

However, when instead a matrix presented, the single phase filter works as expected on the last dimension, but with `zerophase=True` instead of working in two directions, the zerophase filter is applied twice in the same direction, see this example

```
md=np.zeros([3,100])
md[:,50]=1
md[:,25]=0.5

md_1=lowpass(md, 0.05,1)
md_2=lowpass(md, 0.05,1,zerophase=True)

plt.figure()
plt.plot(md[0,:])
plt.plot(md_1[0,:])
plt.plot(md_2[0,:])
```

The following fix seems to work for this little test (although I have not subjected it to formal testing) also for the one-dimensional array. In the definition of `lowpass`,  replace the line 
```
        return sosfilt(sos, firstpass[::-1])[::-1]
```
with 
```
        return np.flip(sosfilt(sos, np.flip(firstpass)))
```

Of course equivalent fixes should be implemented for all filter functions. I don't have a good overview and am not familiar with the testing framework, and feel therefore not qualified to make a pull request. I hope someone can implement and test this adaption.

ObsPy version: 1.2.2  (installation via pip)



Discussion:
Hi, thanks for the short report. Probably nobody tried to use the filter functions with matrices up til now. Seems like a nice addition. We could also add the `axis` kwarg and pass it to the relevant functions.
Yes, adding an `axis` kwarg seems to make perfect sense. 
This issue has been mentioned on **ObsPy Forum**. There might be relevant details there:

https://discourse.obspy.org/t/unexpected-behaviour-of-bandpass-with-zero-phase-option/1692/3

Hello, I've recently come across this issue as well using the filter functions on 2-D data (ambient noise cross-correlation time-distance gathers). Just want to confirm @ftilmann 's solution worked for me! :-) See before/after below:

![image](https://user-images.githubusercontent.com/49694323/231800465-d1f4e79d-1eba-4e43-a702-70d425231f2a.png)


Resolving patch:
diff --git a/obspy/signal/_sosfilt.py b/obspy/signal/_sosfilt.py
deleted file mode 100644
--- a/obspy/signal/_sosfilt.py
+++ /dev/null
@@ -1,509 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-# ----------------------------------------------------------------------
-# Filename: _sosfilt.py
-#  Purpose: Backport of Second-Order Section Filtering from SciPy 0.16.0
-#   Author: Elliott Sales de Andrade + SciPy authors
-# ---------------------------------------------------------------------
-"""
-Backport of Second-Order Section Filtering from SciPy 0.16.0
-
-:copyright:
-    The ObsPy Development Team (devs@obspy.org)
-:license:
-    GNU Lesser General Public License, Version 3
-    (https://www.gnu.org/copyleft/lesser.html)
-"""
-import numpy as np
-from scipy.signal import lfilter, zpk2tf
-
-
-def _cplxreal(z, tol=None):
-    """
-    Split into complex and real parts, combining conjugate pairs.
-
-    The 1D input vector `z` is split up into its complex (`zc`) and real (`zr`)
-    elements.  Every complex element must be part of a complex-conjugate pair,
-    which are combined into a single number (with positive imaginary part) in
-    the output.  Two complex numbers are considered a conjugate pair if their
-    real and imaginary parts differ in magnitude by less than ``tol * abs(z)``.
-
-    Parameters
-    ----------
-    z : array_like
-        Vector of complex numbers to be sorted and split
-    tol : float, optional
-        Relative tolerance for testing realness and conjugate equality.
-        Default is ``100 * spacing(1)`` of `z`'s data type (i.e. 2e-14 for
-        float64)
-
-    Returns
-    -------
-    zc : :class:`numpy.ndarray`
-        Complex elements of `z`, with each pair represented by a single value
-        having positive imaginary part, sorted first by real part, and then
-        by magnitude of imaginary part.  The pairs are averaged when combined
-        to reduce error.
-    zr : :class:`numpy.ndarray`
-        Real elements of `z` (those having imaginary part less than
-        `tol` times their magnitude), sorted by value.
-
-    Raises
-    ------
-    ValueError
-        If there are any complex numbers in `z` for which a conjugate
-        cannot be found.
-
-    See Also
-    --------
-    _cplxpair
-
-    Examples
-    --------
-    >>> a = [4, 3, 1, 2-2j, 2+2j, 2-1j, 2+1j, 2-1j, 2+1j, 1+1j, 1-1j]
-    >>> zc, zr = _cplxreal(a)
-    >>> print(zc.real)
-    [ 1.  2.  2.  2.]
-    >>> print(zc.imag)
-    [ 1.  1.  1.  2.]
-    >>> print(zr)
-    [ 1.  3.  4.]
-    """
-
-    z = np.atleast_1d(z)
-    if z.size == 0:
-        return z, z
-    elif z.ndim != 1:
-        raise ValueError('_cplxreal only accepts 1D input')
-
-    if tol is None:
-        # Get tolerance from dtype of input
-        tol = 100 * np.finfo((1.0 * z).dtype).eps
-
-    # Sort by real part, magnitude of imaginary part (speed up further sorting)
-    z = z[np.lexsort((abs(z.imag), z.real))]
-
-    # Split reals from conjugate pairs
-    real_indices = abs(z.imag) <= tol * abs(z)
-    zr = z[real_indices].real
-
-    if len(zr) == len(z):
-        # Input is entirely real
-        return np.array([]), zr
-
-    # Split positive and negative halves of conjugates
-    z = z[~real_indices]
-    zp = z[z.imag > 0]
-    zn = z[z.imag < 0]
-
-    if len(zp) != len(zn):
-        raise ValueError('Array contains complex value with no matching '
-                         'conjugate.')
-
-    # Find runs of (approximately) the same real part
-    same_real = np.diff(zp.real) <= tol * abs(zp[:-1])
-    diffs = np.diff(np.concatenate(([0], same_real, [0])))
-    run_starts = np.where(diffs > 0)[0]
-    run_stops = np.where(diffs < 0)[0]
-
-    # Sort each run by their imaginary parts
-    for i in range(len(run_starts)):
-        start = run_starts[i]
-        stop = run_stops[i] + 1
-        for chunk in (zp[start:stop], zn[start:stop]):
-            chunk[...] = chunk[np.lexsort([abs(chunk.imag)])]
-
-    # Check that negatives match positives
-    if any(abs(zp - zn.conj()) > tol * abs(zn)):
-        raise ValueError('Array contains complex value with no matching '
-                         'conjugate.')
-
-    # Average out numerical inaccuracy in real vs imag parts of pairs
-    zc = (zp + zn.conj()) / 2
-
-    return zc, zr
-
-
-def _nearest_real_complex_idx(fro, to, which):
-    """Get the next closest real or complex element based on distance"""
-    assert which in ('real', 'complex')
-    order = np.argsort(np.abs(fro - to))
-    mask = np.isreal(fro[order])
-    if which == 'complex':
-        mask = ~mask
-    return order[np.where(mask)[0][0]]
-
-
-def _zpk2sos(z, p, k, pairing='nearest'):
-    """
-    Return second-order sections from zeros, poles, and gain of a system
-
-    Parameters
-    ----------
-    z : array_like
-        Zeros of the transfer function.
-    p : array_like
-        Poles of the transfer function.
-    k : float
-        System gain.
-    pairing : {'nearest', 'keep_odd'}, optional
-        The method to use to combine pairs of poles and zeros into sections.
-        See Notes below.
-
-    Returns
-    -------
-    sos : :class:`numpy.ndarray`
-        Array of second-order filter coefficients, with shape
-        ``(n_sections, 6)``. See `sosfilt` for the SOS filter format
-        specification.
-
-    See Also
-    --------
-    sosfilt
-
-    Notes
-    -----
-    The algorithm used to convert ZPK to SOS format is designed to
-    minimize errors due to numerical precision issues. The pairing
-    algorithm attempts to minimize the peak gain of each biquadratic
-    section. This is done by pairing poles with the nearest zeros, starting
-    with the poles closest to the unit circle.
-
-    *Algorithms*
-
-    The current algorithms are designed specifically for use with digital
-    filters. Although they can operate on analog filters, the results may
-    be sub-optimal.
-
-    The steps in the ``pairing='nearest'`` and ``pairing='keep_odd'``
-    algorithms are mostly shared. The ``nearest`` algorithm attempts to
-    minimize the peak gain, while ``'keep_odd'`` minimizes peak gain under
-    the constraint that odd-order systems should retain one section
-    as first order. The algorithm steps and are as follows:
-
-    As a pre-processing step, add poles or zeros to the origin as
-    necessary to obtain the same number of poles and zeros for pairing.
-    If ``pairing == 'nearest'`` and there are an odd number of poles,
-    add an additional pole and a zero at the origin.
-
-    The following steps are then iterated over until no more poles or
-    zeros remain:
-
-    1. Take the (next remaining) pole (complex or real) closest to the
-       unit circle to begin a new filter section.
-
-    2. If the pole is real and there are no other remaining real poles [#]_,
-       add the closest real zero to the section and leave it as a first
-       order section. Note that after this step we are guaranteed to be
-       left with an even number of real poles, complex poles, real zeros,
-       and complex zeros for subsequent pairing iterations.
-
-    3. Else:
-
-        1. If the pole is complex and the zero is the only remaining real
-           zero*, then pair the pole with the *next* closest zero
-           (guaranteed to be complex). This is necessary to ensure that
-           there will be a real zero remaining to eventually create a
-           first-order section (thus keeping the odd order).
-
-        2. Else pair the pole with the closest remaining zero (complex or
-           real).
-
-        3. Proceed to complete the second-order section by adding another
-           pole and zero to the current pole and zero in the section:
-
-            1. If the current pole and zero are both complex, add their
-               conjugates.
-
-            2. Else if the pole is complex and the zero is real, add the
-               conjugate pole and the next closest real zero.
-
-            3. Else if the pole is real and the zero is complex, add the
-               conjugate zero and the real pole closest to those zeros.
-
-            4. Else (we must have a real pole and real zero) add the next
-               real pole closest to the unit circle, and then add the real
-               zero closest to that pole.
-
-    .. [#] This conditional can only be met for specific odd-order inputs
-           with the ``pairing == 'keep_odd'`` method.
-
-    Examples
-    --------
-
-    Design a 6th order low-pass elliptic digital filter for a system with a
-    sampling rate of 8000 Hz that has a pass-band corner frequency of
-    1000 Hz.  The ripple in the pass-band should not exceed 0.087 dB, and
-    the attenuation in the stop-band should be at least 90 dB.
-
-    In the following call to `signal.ellip`, we could use ``output='sos'``,
-    but for this example, we'll use ``output='zpk'``, and then convert to SOS
-    format with `zpk2sos`:
-
-    >>> from scipy import signal
-    >>> z, p, k = signal.ellip(6, 0.087, 90, 1000/(0.5*8000), output='zpk')
-
-    Now convert to SOS format.
-
-    >>> sos = _zpk2sos(z, p, k)
-
-    The coefficents of the numerators of the sections:
-
-    >>> sos[:, :3]  # doctest: +SKIP
-    array([[ 0.0014154 ,  0.00248707,  0.0014154 ],
-           [ 1.        ,  0.72965193,  1.        ],
-           [ 1.        ,  0.17594966,  1.        ]])
-
-    The symmetry in the coefficients occurs because all the zeros are on the
-    unit circle.
-
-    The coefficients of the denominators of the sections:
-
-    >>> sos[:, 3:]  # doctest: +SKIP
-    array([[ 1.        , -1.32543251,  0.46989499],
-           [ 1.        , -1.26117915,  0.6262586 ],
-           [ 1.        , -1.25707217,  0.86199667]])
-
-    The next example shows the effect of the `pairing` option.  We have a
-    system with three poles and three zeros, so the SOS array will have
-    shape (2, 6).  The means there is, in effect, an extra pole and an extra
-    zero at the origin in the SOS representation.
-
-    >>> z1 = np.array([-1, -0.5-0.5j, -0.5+0.5j])
-    >>> p1 = np.array([0.75, 0.8+0.1j, 0.8-0.1j])
-
-    With ``pairing='nearest'`` (the default), we obtain
-
-    >>> _zpk2sos(z1, p1, 1)
-    array([[ 1.  ,  1.  ,  0.5 ,  1.  , -0.75,  0.  ],
-           [ 1.  ,  1.  ,  0.  ,  1.  , -1.6 ,  0.65]])
-
-    The first section has the zeros {-0.5-0.05j, -0.5+0.5j} and the poles
-    {0, 0.75}, and the second section has the zeros {-1, 0} and poles
-    {0.8+0.1j, 0.8-0.1j}.  Note that the extra pole and zero at the origin
-    have been assigned to different sections.
-
-    With ``pairing='keep_odd'``, we obtain:
-
-    >>> _zpk2sos(z1, p1, 1, pairing='keep_odd')
-    array([[ 1.  ,  1.  ,  0.  ,  1.  , -0.75,  0.  ],
-           [ 1.  ,  1.  ,  0.5 ,  1.  , -1.6 ,  0.65]])
-
-    The extra pole and zero at the origin are in the same section.
-    The first section is, in effect, a first-order section.
-
-    """
-    # TODO in the near future:
-    # 1. Add SOS capability to `filtfilt`, `freqz`, etc. somehow (#3259).
-    # 2. Make `decimate` use `sosfilt` instead of `lfilter`.
-    # 3. Make sosfilt automatically simplify sections to first order
-    #    when possible. Note this might make `sosfiltfilt` a bit harder (ICs).
-    # 4. Further optimizations of the section ordering / pole-zero pairing.
-    # See the wiki for other potential issues.
-
-    valid_pairings = ['nearest', 'keep_odd']
-    if pairing not in valid_pairings:
-        raise ValueError('pairing must be one of %s, not %s'
-                         % (valid_pairings, pairing))
-    if len(z) == len(p) == 0:
-        return np.array([[k, 0., 0., 1., 0., 0.]])
-
-    # ensure we have the same number of poles and zeros, and make copies
-    p = np.concatenate((p, np.zeros(max(len(z) - len(p), 0))))
-    z = np.concatenate((z, np.zeros(max(len(p) - len(z), 0))))
-    n_sections = (max(len(p), len(z)) + 1) // 2
-    sos = np.zeros((n_sections, 6))
-
-    if len(p) % 2 == 1 and pairing == 'nearest':
-        p = np.concatenate((p, [0.]))
-        z = np.concatenate((z, [0.]))
-    assert len(p) == len(z)
-
-    # Ensure we have complex conjugate pairs
-    # (note that _cplxreal only gives us one element of each complex pair):
-    z = np.concatenate(_cplxreal(z))
-    p = np.concatenate(_cplxreal(p))
-
-    p_sos = np.zeros((n_sections, 2), np.complex128)
-    z_sos = np.zeros_like(p_sos)
-    for si in range(n_sections):
-        # Select the next "worst" pole
-        p1_idx = np.argmin(np.abs(1 - np.abs(p)))
-        p1 = p[p1_idx]
-        p = np.delete(p, p1_idx)
-
-        # Pair that pole with a zero
-
-        if np.isreal(p1) and np.isreal(p).sum() == 0:
-            # Special case to set a first-order section
-            z1_idx = _nearest_real_complex_idx(z, p1, 'real')
-            z1 = z[z1_idx]
-            z = np.delete(z, z1_idx)
-            p2 = z2 = 0
-        else:
-            if not np.isreal(p1) and np.isreal(z).sum() == 1:
-                # Special case to ensure we choose a complex zero to pair
-                # with so later (setting up a first-order section)
-                z1_idx = _nearest_real_complex_idx(z, p1, 'complex')
-                assert not np.isreal(z[z1_idx])
-            else:
-                # Pair the pole with the closest zero (real or complex)
-                z1_idx = np.argmin(np.abs(p1 - z))
-            z1 = z[z1_idx]
-            z = np.delete(z, z1_idx)
-
-            # Now that we have p1 and z1, figure out what p2 and z2 need to be
-            if not np.isreal(p1):
-                if not np.isreal(z1):  # complex pole, complex zero
-                    p2 = p1.conj()
-                    z2 = z1.conj()
-                else:  # complex pole, real zero
-                    p2 = p1.conj()
-                    z2_idx = _nearest_real_complex_idx(z, p1, 'real')
-                    z2 = z[z2_idx]
-                    assert np.isreal(z2)
-                    z = np.delete(z, z2_idx)
-            else:
-                if not np.isreal(z1):  # real pole, complex zero
-                    z2 = z1.conj()
-                    p2_idx = _nearest_real_complex_idx(p, z1, 'real')
-                    p2 = p[p2_idx]
-                    assert np.isreal(p2)
-                else:  # real pole, real zero
-                    # pick the next "worst" pole to use
-                    idx = np.where(np.isreal(p))[0]
-                    assert len(idx) > 0
-                    p2_idx = idx[np.argmin(np.abs(np.abs(p[idx]) - 1))]
-                    p2 = p[p2_idx]
-                    # find a real zero to match the added pole
-                    assert np.isreal(p2)
-                    z2_idx = _nearest_real_complex_idx(z, p2, 'real')
-                    z2 = z[z2_idx]
-                    assert np.isreal(z2)
-                    z = np.delete(z, z2_idx)
-                p = np.delete(p, p2_idx)
-        p_sos[si] = [p1, p2]
-        z_sos[si] = [z1, z2]
-    assert len(p) == len(z) == 0  # we've consumed all poles and zeros
-    del p, z
-
-    # Construct the system, reversing order so the "worst" are last
-    p_sos = np.reshape(p_sos[::-1], (n_sections, 2))
-    z_sos = np.reshape(z_sos[::-1], (n_sections, 2))
-    gains = np.ones(n_sections)
-    gains[0] = k
-    for si in range(n_sections):
-        x = zpk2tf(z_sos[si], p_sos[si], gains[si])
-        sos[si] = np.concatenate(x)
-    return sos
-
-
-def _sosfilt(sos, x, axis=-1, zi=None):
-    """
-    Filter data along one dimension using cascaded second-order sections
-    Filter a data sequence, `x`, using a digital IIR filter defined by
-    `sos`. This is implemented by performing `lfilter` for each
-    second-order section.  See `lfilter` for details.
-
-    Parameters
-    ----------
-    sos : array_like
-        Array of second-order filter coefficients, must have shape
-        ``(n_sections, 6)``. Each row corresponds to a second-order
-        section, with the first three columns providing the numerator
-        coefficients and the last three providing the denominator
-        coefficients.
-    x : array_like
-        An N-dimensional input array.
-    axis : int, optional
-        The axis of the input data array along which to apply the
-        linear filter. The filter is applied to each subarray along
-        this axis.  Default is -1.
-    zi : array_like, optional
-        Initial conditions for the cascaded filter delays.  It is a (at
-        least 2D) vector of shape ``(n_sections, ..., 2, ...)``, where
-        ``..., 2, ...`` denotes the shape of `x`, but with ``x.shape[axis]``
-        replaced by 2.  If `zi` is None or is not given then initial rest
-        (i.e. all zeros) is assumed.
-        Note that these initial conditions are *not* the same as the initial
-        conditions given by `lfiltic` or `lfilter_zi`.
-
-    Returns
-    -------
-    y : :class:`numpy.ndarray`
-        The output of the digital filter.
-    zf : :class:`numpy.ndarray`, optional
-        If `zi` is None, this is not returned, otherwise, `zf` holds the
-        final filter delay values.
-
-    See Also
-    --------
-    zpk2sos, sos2zpk, sosfilt_zi
-
-    Notes
-    -----
-    The filter function is implemented as a series of second-order filters
-    with direct-form II transposed structure. It is designed to minimize
-    numerical precision errors for high-order filters.
-
-    Examples
-    --------
-    Plot a 13th-order filter's impulse response using both `lfilter` and
-    `sosfilt`, showing the instability that results from trying to do a
-    13th-order filter in a single stage (the numerical error pushes some poles
-    outside of the unit circle):
-    >>> import matplotlib.pyplot as plt
-    >>> from scipy import signal
-    >>> b, a = signal.ellip(13, 0.009, 80, 0.05, output='ba')
-    >>> z, p, k = signal.ellip(13, 0.009, 80, 0.05, output='zpk')
-    >>> sos = _zpk2sos(z, p, k)
-    >>> x = np.zeros(700)
-    >>> x[0] = 1.
-    >>> y_tf = signal.lfilter(b, a, x)
-    >>> y_sos = _sosfilt(sos, x)
-    >>> plt.figure()  # doctest: +ELLIPSIS
-    <...Figure ...>
-    >>> plt.plot(y_tf, 'r', label='TF')  # doctest: +ELLIPSIS
-    [<matplotlib.lines.Line2D object at ...>]
-    >>> plt.plot(y_sos, 'k', label='SOS')  # doctest: +ELLIPSIS
-    [<matplotlib.lines.Line2D object at ...>]
-    >>> plt.legend(loc='best')  # doctest: +ELLIPSIS
-    <matplotlib.legend.Legend object at ...>
-    >>> plt.show()
-    """
-    x = np.asarray(x)
-
-    sos = np.atleast_2d(sos)
-    if sos.ndim != 2:
-        raise ValueError('sos array must be 2D')
-
-    n_sections, m = sos.shape
-    if m != 6:
-        raise ValueError('sos array must be shape (n_sections, 6)')
-
-    use_zi = zi is not None
-    if use_zi:
-        zi = np.asarray(zi)
-        x_zi_shape = list(x.shape)
-        x_zi_shape[axis] = 2
-        x_zi_shape = tuple([n_sections] + x_zi_shape)
-        if zi.shape != x_zi_shape:
-            raise ValueError('Invalid zi shape.  With axis=%r, an input with '
-                             'shape %r, and an sos array with %d sections, zi '
-                             'must have shape %r.' %
-                             (axis, x.shape, n_sections, x_zi_shape))
-        zf = np.zeros_like(zi)
-
-    for section in range(n_sections):
-        if use_zi:
-            x, zf[section] = lfilter(sos[section, :3], sos[section, 3:],
-                                     x, axis, zi=zi[section])
-        else:
-            x = lfilter(sos[section, :3], sos[section, 3:], x, axis)
-    out = (x, zf) if use_zi else x
-    return out
-
-
-if __name__ == '__main__':
-    import doctest
-    doctest.testmod(exclude_empty=True)
diff --git a/obspy/signal/filter.py b/obspy/signal/filter.py
--- a/obspy/signal/filter.py
+++ b/obspy/signal/filter.py
@@ -21,17 +21,24 @@
 import numpy as np
 from scipy.fftpack import hilbert
 from scipy.signal import (cheb2ord, cheby2, convolve, get_window, iirfilter,
-                          remez)
+                          remez, sosfilt)
 
-try:
-    from scipy.signal import sosfilt
-    from scipy.signal import zpk2sos
-except ImportError:
-    from ._sosfilt import _sosfilt as sosfilt
-    from ._sosfilt import _zpk2sos as zpk2sos
 
+def _filter(data, freqs, df, btype='band', ftype='butter',
+            corners=4, zerophase=False, axis=-1):
+    fe = 0.5 * df
+    normalized_freqs = [f/fe for f in freqs]
+    sos = iirfilter(corners, normalized_freqs, btype=btype,
+                    ftype=ftype, output='sos')
+    if zerophase:
+        firstpass = np.flip(sosfilt(sos, data, axis=axis), axis=axis)
+        return np.flip(sosfilt(sos, firstpass, axis=axis), axis=axis)
+    else:
+        return sosfilt(sos, data, axis=axis)
 
-def bandpass(data, freqmin, freqmax, df, corners=4, zerophase=False):
+
+def bandpass(data, freqmin, freqmax, df, corners=4, zerophase=False,
+             axis=-1):
     """
     Butterworth-Bandpass Filter.
 
@@ -49,6 +56,9 @@ def bandpass(data, freqmin, freqmax, df, corners=4, zerophase=False):
     :param zerophase: If True, apply filter once forwards and once backwards.
         This results in twice the filter order but zero phase shift in
         the resulting filtered trace.
+    :param axis: The axis of the input data array along which to apply the
+        linear filter. The filter is applied to each subarray along this axis.
+        Default is -1.
     :return: Filtered data.
     """
     fe = 0.5 * df
@@ -65,17 +75,12 @@ def bandpass(data, freqmin, freqmax, df, corners=4, zerophase=False):
     if low > 1:
         msg = "Selected low corner frequency is above Nyquist."
         raise ValueError(msg)
-    z, p, k = iirfilter(corners, [low, high], btype='band',
-                        ftype='butter', output='zpk')
-    sos = zpk2sos(z, p, k)
-    if zerophase:
-        firstpass = sosfilt(sos, data)
-        return sosfilt(sos, firstpass[::-1])[::-1]
-    else:
-        return sosfilt(sos, data)
+    return _filter(data, (freqmin, freqmax), df, btype='band',
+                   corners=corners, zerophase=zerophase, axis=axis)
 
 
-def bandstop(data, freqmin, freqmax, df, corners=4, zerophase=False):
+def bandstop(data, freqmin, freqmax, df, corners=4, zerophase=False,
+             axis=-1):
     """
     Butterworth-Bandstop Filter.
 
@@ -93,6 +98,9 @@ def bandstop(data, freqmin, freqmax, df, corners=4, zerophase=False):
     :param zerophase: If True, apply filter once forwards and once backwards.
         This results in twice the number of corners but zero phase shift in
         the resulting filtered trace.
+    :param axis: The axis of the input data array along which to apply the
+        linear filter. The filter is applied to each subarray along this axis.
+        Default is -1.
     :return: Filtered data.
     """
     fe = 0.5 * df
@@ -107,17 +115,12 @@ def bandstop(data, freqmin, freqmax, df, corners=4, zerophase=False):
     if low > 1:
         msg = "Selected low corner frequency is above Nyquist."
         raise ValueError(msg)
-    z, p, k = iirfilter(corners, [low, high],
-                        btype='bandstop', ftype='butter', output='zpk')
-    sos = zpk2sos(z, p, k)
-    if zerophase:
-        firstpass = sosfilt(sos, data)
-        return sosfilt(sos, firstpass[::-1])[::-1]
-    else:
-        return sosfilt(sos, data)
+    return _filter(data, (freqmin, freqmax), df, btype='bandstop',
+                   corners=corners, zerophase=zerophase, axis=axis)
 
 
-def lowpass(data, freq, df, corners=4, zerophase=False):
+def lowpass(data, freq, df, corners=4, zerophase=False,
+            axis=-1):
     """
     Butterworth-Lowpass Filter.
 
@@ -134,6 +137,9 @@ def lowpass(data, freq, df, corners=4, zerophase=False):
     :param zerophase: If True, apply filter once forwards and once backwards.
         This results in twice the number of corners but zero phase shift in
         the resulting filtered trace.
+    :param axis: The axis of the input data array along which to apply the
+        linear filter. The filter is applied to each subarray along this axis.
+        Default is -1.
     :return: Filtered data.
     """
     fe = 0.5 * df
@@ -144,17 +150,12 @@ def lowpass(data, freq, df, corners=4, zerophase=False):
         msg = "Selected corner frequency is above Nyquist. " + \
               "Setting Nyquist as high corner."
         warnings.warn(msg)
-    z, p, k = iirfilter(corners, f, btype='lowpass', ftype='butter',
-                        output='zpk')
-    sos = zpk2sos(z, p, k)
-    if zerophase:
-        firstpass = sosfilt(sos, data)
-        return sosfilt(sos, firstpass[::-1])[::-1]
-    else:
-        return sosfilt(sos, data)
+    return _filter(data, (freq,), df, btype='lowpass',
+                   corners=corners, zerophase=zerophase, axis=axis)
 
 
-def highpass(data, freq, df, corners=4, zerophase=False):
+def highpass(data, freq, df, corners=4, zerophase=False,
+             axis=-1):
     """
     Butterworth-Highpass Filter.
 
@@ -171,6 +172,9 @@ def highpass(data, freq, df, corners=4, zerophase=False):
     :param zerophase: If True, apply filter once forwards and once backwards.
         This results in twice the number of corners but zero phase shift in
         the resulting filtered trace.
+    :param axis: The axis of the input data array along which to apply the
+        linear filter. The filter is applied to each subarray along this axis.
+        Default is -1.
     :return: Filtered data.
     """
     fe = 0.5 * df
@@ -179,14 +183,8 @@ def highpass(data, freq, df, corners=4, zerophase=False):
     if f > 1:
         msg = "Selected corner frequency is above Nyquist."
         raise ValueError(msg)
-    z, p, k = iirfilter(corners, f, btype='highpass', ftype='butter',
-                        output='zpk')
-    sos = zpk2sos(z, p, k)
-    if zerophase:
-        firstpass = sosfilt(sos, data)
-        return sosfilt(sos, firstpass[::-1])[::-1]
-    else:
-        return sosfilt(sos, data)
+    return _filter(data, (freq,), df, btype='highpass',
+                   corners=corners, zerophase=zerophase, axis=axis)
 
 
 def envelope(data):
@@ -388,8 +386,7 @@ def lowpass_cheby_2(data, freq, df, maxorder=12, ba=False,
         order, wn = cheb2ord(wp, ws, rp, rs, analog=0)
     if ba:
         return cheby2(order, rs, wn, btype='low', analog=0, output='ba')
-    z, p, k = cheby2(order, rs, wn, btype='low', analog=0, output='zpk')
-    sos = zpk2sos(z, p, k)
+    sos = cheby2(order, rs, wn, btype='low', analog=0, output='sos')
     if freq_passband:
         return sosfilt(sos, data), wp * nyquist
     return sosfilt(sos, data)


Test patch:
diff --git a/obspy/signal/tests/test_filter.py b/obspy/signal/tests/test_filter.py
--- a/obspy/signal/tests/test_filter.py
+++ b/obspy/signal/tests/test_filter.py
@@ -10,14 +10,15 @@
 import scipy.signal as sg
 
 from obspy import read
-from obspy.signal.filter import (bandpass, highpass, lowpass, envelope,
-                                 lowpass_cheby_2)
+from obspy.signal.filter import (bandpass, bandstop, highpass, lowpass,
+                                 envelope, lowpass_cheby_2)
 
 
 class TestFilter():
     """
     Test cases for Filter.
     """
+
     def test_bandpass_vs_pitsa(self, testdata):
         """
         Test Butterworth bandpass filter against Butterworth bandpass filter
@@ -264,3 +265,25 @@ def test_bandpass_high_corner_at_nyquist(self):
                 for got in (got1, got2, got3):
                     np.testing.assert_allclose(got, expected, rtol=1e-3,
                                                atol=0.9)
+
+    def test_filterarr2d_axis_and_zerophase(self):
+        """
+        See #2805
+        """
+        stream = read()
+        df = stream[0].stats.sampling_rate
+        arr = np.array([tr.data for tr in stream[:3]])
+        for filterf, args in [(bandpass, (1, 5)),
+                              (bandstop, (1, 5)),
+                              (lowpass, (5,)),
+                              (highpass, (5,))]:
+            for zerophase in (False, True):
+                farray = filterf(arr, *args, df=df, zerophase=zerophase)
+                fseries = [filterf(arr[i, :], *args,
+                                   df=df, zerophase=zerophase)
+                           for i in range(3)]
+                assert np.all(np.isclose(farray, fseries))
+                # test filter along axis=0
+                farray2 = filterf(np.transpose(arr), *args, df=df,
+                                  zerophase=zerophase, axis=0)
+                assert np.all(np.isclose(farray2, np.transpose(farray)))