メジアンフィルタの理論とPython実装:インパルスノイズ除去の非線形フィルタ

メジアンフィルタの数学的原理、線形フィルタ(EMA・移動平均)との違い、インパルスノイズ除去の実践、scipy.signal.medfiltを使ったPython実装を解説します。

メジアンフィルタとは

メジアンフィルタ(Median Filter)は、窓内のデータをソートし、その中央値(メジアン)を出力値とする非線形フィルタです。

EMAフィルタ移動平均フィルタなどの線形フィルタとは根本的に異なり、インパルスノイズ(スパイク状のノイズ)を極めて効果的に除去できることが最大の特徴です。

主な用途

  • インパルスノイズ除去: センサーの一時的な誤検知、通信エラーによるビット反転
  • 画像処理: 塩胡椒ノイズ(Salt-and-Pepper Noise)の除去
  • 医療信号処理: ECG(心電図)のアーチファクト除去
  • 金融データ: 株価データの外れ値除去(スポット誤値)

数学的定義

窓幅 \(2m+1\)(\(m\) は片側のデータ点数)のメジアンフィルタは、時刻 \(n\) における出力 \(y[n]\) を以下のように定義します:

\[ y[n] = \text{median}\bigl(x[n-m],\, x[n-m+1],\, \dots,\, x[n],\, \dots,\, x[n+m]\bigr) \tag{1} \]

ここで \(\text{median}(\cdot)\) は集合の中央値を表します。\(2m+1\) 個の値を昇順または降順に並べたとき、中央(\(m+1\) 番目)の値が出力となります。

例(窓幅 \(=5\), \(m=2\)):

入力列の一部が \([\ldots, 3, 4, \mathbf{100}, 4, 3, \ldots]\) のとき(太字はスパイク):

\[ \text{sort}(3, 4, 100, 4, 3) = (3, 3, \mathbf{4}, 4, 100) \Rightarrow y[n] = 4 \tag{2} \]

スパイク値 \(100\) は完全に除去され、本来の信号値 \(4\) が出力されます。

線形フィルタとの本質的な違い

重ね合わせ原理が成立しない

線形フィルタでは入力の加重和として出力が計算されます。しかしメジアン演算はソートを伴う非線形演算であるため、重ね合わせ原理(superposition principle)が成立しません:

\[ \text{median}(ax_1 + bx_2) \neq a\cdot\text{median}(x_1) + b\cdot\text{median}(x_2) \tag{3} \]

この非線形性こそが、インパルスノイズ除去における線形フィルタとの決定的な差を生みます。

インパルスノイズへの耐性

線形フィルタはインパルスノイズを周囲の多くのサンプルに「拡散」させてしまいます。一方、メジアンフィルタはインパルスの影響を窓内に閉じ込め、ソート操作によって完全に排除します。

フィルタガウシアンノイズ除去インパルスノイズ除去信号のエッジ保存
移動平均良好不十分(拡散)低い
EMA良好(重み付き)不十分(拡散)低い
サビツキー・ゴーレイ良好不十分高い
メジアン普通非常に優れる高い(保存しやすい)

Python実装

手動実装

import numpy as np

def median_filter_manual(x: np.ndarray, window_size: int) -> np.ndarray:
    """メジアンフィルタの手動実装(エッジは折り返しパディング)"""
    assert window_size % 2 == 1, "window_size は奇数を指定してください"
    m = window_size // 2
    n = len(x)
    y = np.empty(n)
    # reflect パディングでエッジを処理
    x_padded = np.pad(x, m, mode="reflect")
    for i in range(n):
        window = x_padded[i : i + window_size]
        y[i] = np.median(window)
    return y

scipy.signal.medfilt を使った実装

実用上は scipy.signal.medfilt を使うのが最も簡単です。

import numpy as np
import matplotlib.pyplot as plt
from scipy import signal

# --- 信号生成 ---
np.random.seed(42)
t = np.linspace(0, 1, 500)
# 正弦波 + ガウシアンノイズ
x_clean = np.sin(2 * np.pi * 5 * t)
noise_gaussian = 0.2 * np.rng.standard_normal(len(t))
x_noisy = x_clean + noise_gaussian

# インパルスノイズを追加(ランダムな位置に大きなスパイク)
rng = np.random.default_rng(0)
impulse_indices = rng.choice(len(t), size=30, replace=False)
x_impulse = x_noisy.copy()
x_impulse[impulse_indices] += rng.choice([-4.0, 4.0], size=30)

# --- フィルタ適用 ---
window_size = 11

# メジアンフィルタ
y_median = signal.medfilt(x_impulse, kernel_size=window_size)

# 比較用:移動平均フィルタ
y_ma = np.convolve(x_impulse, np.ones(window_size) / window_size, mode="same")

# --- プロット ---
fig, axes = plt.subplots(3, 1, figsize=(12, 9), sharex=True)

axes[0].plot(t, x_impulse, color="gray", alpha=0.7, label="インパルスノイズ入り信号")
axes[0].plot(t, x_clean, color="blue", linestyle="--", label="原信号", linewidth=2)
axes[0].set_ylabel("振幅")
axes[0].legend()
axes[0].set_title("入力信号(インパルスノイズ混入)")

axes[1].plot(t, y_median, color="red", label=f"メジアンフィルタ (窓={window_size})")
axes[1].plot(t, x_clean, color="blue", linestyle="--", label="原信号", linewidth=2)
axes[1].set_ylabel("振幅")
axes[1].legend()
axes[1].set_title("メジアンフィルタ出力")

axes[2].plot(t, y_ma, color="green", label=f"移動平均フィルタ (窓={window_size})")
axes[2].plot(t, x_clean, color="blue", linestyle="--", label="原信号", linewidth=2)
axes[2].set_ylabel("振幅")
axes[2].set_xlabel("時間 [s]")
axes[2].legend()
axes[2].set_title("移動平均フィルタ出力(比較)")

plt.tight_layout()
plt.show()

窓幅の選択

窓幅 \(W = 2m+1\) はメジアンフィルタの特性を決定する唯一のパラメータです。

import numpy as np
from scipy import signal
import matplotlib.pyplot as plt

np.random.seed(0)
t = np.linspace(0, 1, 300)
x_clean = np.sin(2 * np.pi * 3 * t)
x_noisy = x_clean.copy()
# 10%の点にインパルスノイズ
idx = np.random.choice(len(t), size=30, replace=False)
x_noisy[idx] += np.random.choice([-3, 3], size=30)

window_sizes = [3, 7, 15, 31]
fig, axes = plt.subplots(len(window_sizes), 1, figsize=(12, 10), sharex=True)

for ax, ws in zip(axes, window_sizes):
    y = signal.medfilt(x_noisy, kernel_size=ws)
    mse = np.mean((y - x_clean) ** 2)
    ax.plot(t, x_noisy, color="gray", alpha=0.5, label="入力(ノイズ混入)")
    ax.plot(t, y, color="red", label=f"窓幅={ws}, MSE={mse:.4f}")
    ax.plot(t, x_clean, color="blue", linestyle="--", label="原信号", linewidth=1.5)
    ax.legend(loc="upper right")
    ax.set_ylabel("振幅")

axes[-1].set_xlabel("時間 [s]")
plt.suptitle("窓幅によるメジアンフィルタの違い")
plt.tight_layout()
plt.show()

選択の指針:

  • 小さな窓幅(3〜5): 細かいスパイクを除去、信号の詳細を保持。ノイズが少ない場合向き。
  • 大きな窓幅(11〜31): より強いノイズ除去。ただし信号のエッジや鋭いピークが鈍化する。
  • ノイズが連続している場合: 窓幅はノイズの連続長より大きくする必要があります。

ガウシアンノイズへの対応:EMAとの組み合わせ

メジアンフィルタはインパルスノイズに強い一方、ガウシアンノイズの除去はEMAや移動平均に劣ります。実用的なパイプラインでは、両フィルタを組み合わせます:

from scipy import signal
import numpy as np

def hybrid_filter(x: np.ndarray, median_window: int = 5, ema_alpha: float = 0.2) -> np.ndarray:
    """
    メジアン → EMA の2段フィルタ
    Step1: メジアンフィルタでインパルスノイズを除去
    Step2: EMAでガウシアンノイズをさらに平滑化
    """
    # Step 1: インパルスノイズ除去
    y_median = signal.medfilt(x, kernel_size=median_window)
    # Step 2: EMAで残留ガウシアンノイズを平滑化
    y_ema = np.zeros_like(y_median)
    y_ema[0] = y_median[0]
    for i in range(1, len(y_median)):
        y_ema[i] = ema_alpha * y_median[i] + (1 - ema_alpha) * y_ema[i - 1]
    return y_ema

この2段構成は、センサーデータ処理や通信システムのデノイジングで広く採用されています。

計算量と実装上の注意

計算量

単純な実装では、各出力サンプルにつき \(O(W \log W)\)(ソートのコスト)かかるため、全体は \(O(N W \log W)\) です。

しかし、scipy.signal.medfilt は内部でヒストグラムベースの高速アルゴリズムを使用しており、実用上は十分高速です。大規模データには scipy.ndimage.median_filter の方が効率的な場合があります。

from scipy import ndimage

# scipy.ndimage.median_filter(1D信号にも使用可能)
y = ndimage.median_filter(x, size=11)

エッジ処理

scipy.signal.medfilt はデフォルトでゼロパディングを使用します。エッジ処理を制御したい場合は scipy.ndimage.median_filtermode パラメータを使用します:

# reflect: 折り返しパディング(通常推奨)
y_reflect = ndimage.median_filter(x, size=11, mode="reflect")
# nearest: 端点の値でパディング
y_nearest = ndimage.median_filter(x, size=11, mode="nearest")

まとめ

特性メジアンフィルタ
フィルタ種別非線形
インパルスノイズ除去非常に優秀
ガウシアンノイズ除去普通(線形フィルタに劣る)
エッジ保存性高い
パラメータ数1(窓幅 \(W\))
計算量\(O(NW \log W)\)
因果性(リアルタイム対応)半因果(窓を前後にとる場合は非因果)

メジアンフィルタはインパルスノイズが支配的な環境で最も力を発揮します。ガウシアンノイズが主体であればEMAフィルタバターワースフィルタ、信号のピーク形状を保存したい場合はサビツキー・ゴーレイフィルタ、環境が時変な場合は適応フィルタを検討してください。

関連記事

参考


関連ツール