Python으로 배우는 DSP/Part 3. 디지털 필터 설계와 구현

[14] 필터의 기초와 컨볼루션(Convolution): 시스템 응답의 원리

multimedia 2026. 5. 9. 10:00
반응형

들어가며

이전 시간, 웨이블릿 변환(Wavelet)을 통해 우리는 신호를 다중 해상도로 분해하고 각 주파수 대역의 성분을 독립적으로 다루는 방법을 익혔습니다. 이 과정에서 '저역통과 필터(Low-pass Filter)와 고역통과 필터(High-pass Filter)를 반복 적용하여 신호를 서브밴드로 분해한다'는 핵심 구조를 살펴보았습니다.

그렇다면 필터(Filter)란 정확히 무엇일까요? 잡음을 제거하거나 특정 목소리만 부각하는 등, 신호에 필터를 적용하여 형태를 바꾼다는 것은 수학적으로 어떤 연산을 의미할까요?

오늘부터 시작되는 Part 3. 디지털 필터 설계와 구현의 첫 단추로, 디지털 신호 처리의 가장 핵심적인 연산인 컨볼루션(Convolution)을 집중적으로 다룹니다. 컨볼루션은 단순한 수식이 아닙니다. 모든 디지털 필터의 동작 원리를 하나의 언어로 기술하는 연산이며, 임펄스 응답(Impulse Response)이라는 개념을 통해 시스템의 특성을 완전하게 표현합니다. Python 실습을 통해 이 원리를 손으로 직접 체험해 보겠습니다.

 

1. 필터란 무엇인가?

필터(Filter)는 신호에서 원하는 성분은 통과시키고, 불필요한 성분은 억제하는 시스템입니다. 일상적인 예로는 이어폰의 저음 강조 기능, 카메라의 노이즈 제거, 심전도(ECG) 신호에서 전원 잡음(60 Hz)을 제거하는 과정 등이 있습니다.

디지털 필터는 통과 대역(passband)에 따라 크게 네 가지로 분류됩니다.

필터 종류 통과 대역 대표 용도
저역통과 필터 (LPF) 낮은 주파수 노이즈 제거, 스무딩
고역통과 필터 (HPF) 높은 주파수 에지 검출, 선명화
대역통과 필터 (BPF) 특정 주파수 대역 특정 음성 대역 추출
대역저지 필터 (BSF/Notch) 특정 주파수 제거 전원 잡음 제거

이 모든 필터의 동작은 수학적으로 컨볼루션 하나로 기술됩니다.

DSP에서 필터는 단순한 연산이 아니라 아래와 같은 하나의 시스템(System)으로 간주될 수 있습니다.

  • 입력(Input): $x[n]$
  • 출력(Output): $y[n]$
  • 시스템: $H$
$$y[n] = H\{x[n]\}$$

여기서 중요한 가정이 있습니다.

우리가 다루는 대부분의 필터는 LTI 시스템(Linear Time-Invariant System)입니다.

  • Linear $\rightarrow$ 입력의 합 = 출력의 합
  • Time-Invariant $\rightarrow$ 시간 이동해도 동일하게 동작

이 두 가지 성질이 성립하면, 놀랍게도 시스템은 단 하나의 정보로 완전히 설명됩니다. 바로 임펄스 응답 (Impulse Response,  $h[n]$)입니다.

 

2. 시스템의 지문, 임펄스 응답(Impulse Response)

디지털 신호 처리에서 시스템(System)은 입력 신호 $x[n]$을 받아 특정한 규칙에 따라 가공하여 출력 신호 $y[n]$을 내보내는 일종의 '블랙박스'입니다. 우리가 흔히 말하는 '필터'가 바로 이 시스템의 대표적인 예입니다.

이 블랙박스가 어떤 성질을 가지고 있는지 알아내려면 어떻게 해야 할까요? 가장 확실한 방법은 아주 짧고 강한 자극을 한 번 줘보고, 그 시스템이 어떻게 반응하며 진동하는지 관찰하는 것입니다. 종을 칠 때 망치로 '땅!' 하고 한 번 때려보는 것과 같습니다.

수학적으로 이 '망치' 역할을 하는 것이 크기가 1이고 시간이 0일 때만 존재하는 단위 임펄스 신호 $\delta[n]$입니다. 그리고 이 임펄스 신호가 시스템을 통과해서 나온 출력 결과를 임펄스 응답(Impulse Response, $h[n]$)이라고 부릅니다.

  • 입력: $\delta[n]$ (망치로 한 번 때림)
    $$\delta[n] = \begin{cases} 1 & n = 0 \\ 0 & \text{otherwise} \end{cases}$$
  • 시스템: 블랙박스 필터
  • 출력: $h[n]$ (종이 울리는 소리의 파형)
    $$h[n] = H\{\delta[n]\}$$

놀랍게도 선형 시불변(LTI, Linear Time-Invariant) 시스템에서는, 이 임펄스 응답 $h[n]$ 하나만 완벽하게 알고 있으면 시스템의 모든 동작 특성을 파악할 수 있습니다. 그 이유는 모든 입력 신호를 임펄스들의 합으로 표현할 수 있기 때문입니다. 따라서, 선형 시불변 시스템에 입력 신호를 인가하면, 임펄스 응답의 조합으로 출력을 얻을 수 있으므로,  $h[n]$은 곧 그 시스템(필터) 고유의 '지문'과도 같습니다.  

아래 코드는 Butterworth 저역통과 필터를 설계한 후, 설계된 필터의 임펄스 응답을 구하고 주파수 응답을 확인하는 내용입니다.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib import rcParams

# 한국어 폰트 설정
plt.rcParams['font.family'] = 'Malgun Gothic'
plt.rcParams['axes.unicode_minus'] = False

# --- 임펄스 응답 시각화 ---
from scipy.signal import butter, lfilter, freqz

def make_impulse(N=64):
    delta = np.zeros(N)
    delta[0] = 1.0
    return delta

delta = make_impulse(N=64)

# Butterworth 저역통과 필터 설계 (cutoff=0.2, order=4)
b, a = butter(N=4, Wn=0.2, btype='low')

# 임펄스 응답 = 필터에 임펄스를 통과시킨 결과
h_lpf = lfilter(b, a, delta)

# 주파수 응답 계산
w, H = freqz(b, a, worN=512)
freq_norm = w / np.pi   # 정규화 주파수 (0~1, 1=Nyquist)

fig, axes = plt.subplots(1, 2, figsize=(12, 4))

axes[0].stem(np.arange(len(h_lpf)), h_lpf,
             linefmt='C0-', markerfmt='C0o', basefmt='k-')
axes[0].set_title('4차 Butterworth LPF - 임펄스 응답 h[n]')
axes[0].set_xlabel('샘플 인덱스 n')
axes[0].set_ylabel('진폭')
axes[0].grid(True, linestyle='--', alpha=0.5)

axes[1].plot(freq_norm, 20 * np.log10(np.abs(H) + 1e-12), color='C1', lw=2)
axes[1].axvline(x=0.2, color='red', linestyle='--', label='차단 주파수 (0.2)')
axes[1].set_title('주파수 응답 |H(f)| [dB]')
axes[1].set_xlabel('정규화 주파수 (×π rad/sample)')
axes[1].set_ylabel('크기 [dB]')
axes[1].set_ylim(-80, 5)
axes[1].legend()
axes[1].grid(True, linestyle='--', alpha=0.5)

plt.tight_layout()
plt.savefig('impulse_response.png', dpi=150)
plt.show()

 

3. 컨볼루션(Convolution): 신호와 시스템의 만남

우리가 시스템의 지문인 임펄스 응답 $h[n]$을 알게 되었습니다. 그렇다면 망치로 한 번 때리는 것이 아니라, 복잡하게 변화하는 실제 오디오나 센서 신호 $x[n]$을 이 시스템에 통과시키면 어떤 출력 $y[n]$이 나올까요?

이때 사용되는 연산이 바로 컨볼루션(Convolution)입니다. 기호로는 $*$를 사용하며, 수식은 다음과 같습니다.

$$y[n] = x[n] * h[n] = \sum_{k=-\infty}^{\infty} x[k]h[n-k]$$

수식이 복잡해 보이지만, 그 안에 담긴 물리적 의미는 4가지 단계로 아주 직관적으로 풀이할 수 있습니다.

  1. 반전 (Flip): 필터의 임펄스 응답 $h[k]$를 시간 축에서 좌우로 뒤집습니다. ($h[-k]$)
  2. 이동 (Shift): 뒤집힌 필터를 현재 시간 $n$만큼 이동시킵니다. ($h[n-k]$)
  3. 곱셈 (Multiply): 이동시킨 필터와 원본 신호 $x[k]$가 겹치는 부분의 값들을 각각 곱합니다.
  4. 덧셈 (Add): 곱해진 값들을 모두 더하여 출력 신호 $y[n]$의 한 점을 만듭니다. ($\sum$)

즉, 컨볼루션은 "필터를 뒤집은 다음, 입력 신호 위를 미끄러지듯(Sliding) 이동시키면서 겹치는 부분의 가중합을 계속해서 구하는 과정"입니다. 이 연산을 통해 원본 신호는 필터의 특성($h[n]$)에 맞게 깎이고 다듬어지게 됩니다.

컨볼루션을 가장 직관적으로 이해하는 방법은 위의 4단계 설명을 위한 슬라이딩 윈도우(sliding window) 시각화입니다.

 

다음은 이론만으로는 와닿지 않는 컨볼루션을 Python으로 직접 구현해 보겠습니다. 아주 단순한 필터인 이동 평균 필터(Moving Average Filter)를 만들어 잡음이 섞인 신호를 깔끔하게 다듬어보겠습니다. 이동 평균 필터는 고주파 잡음을 걸러내는 가장 기초적인 '저역통과 필터(LPF)'입니다. 

import numpy as np
import matplotlib.pyplot as plt

plt.rcParams['font.family'] = 'Malgun Gothic'
plt.rcParams['axes.unicode_minus'] = False

t = np.linspace(0, 2, 500)
clean_signal = np.sin(2 * np.pi * 3 * t)
noise = 0.5 * np.random.randn(len(t))
noisy_signal = clean_signal + noise

window_size = 15
h = np.ones(window_size) / window_size

filtered_signal = np.convolve(noisy_signal, h, mode='same')

fig, axes = plt.subplots(3, 1, figsize=(10, 8))

axes[0].plot(t, noisy_signal, color='tomato')
axes[0].set_title('원본 입력 신호 x[n] (노이즈 포함)')
axes[0].set_xlim(0, 2)         
axes[0].grid(True, alpha=0.3)

# h[n]은 샘플 인덱스(0~14)로 표시 — 시간축과 무관
axes[1].stem(np.arange(window_size), h, linefmt='green',
             markerfmt='go', basefmt='k-')
axes[1].set_title(f'필터의 임펄스 응답 h[n] (길이: {window_size})')
axes[1].set_xlabel('샘플 인덱스 [n]')   
axes[1].set_xlim(-1, window_size)       
axes[1].grid(True, alpha=0.3)

axes[2].plot(t, filtered_signal, color='steelblue', linewidth=2)
axes[2].plot(t, clean_signal, color='gray', linestyle='--', label='원래의 깨끗한 신호')
axes[2].set_title('컨볼루션 출력 신호 y[n] (노이즈 제거됨)')
axes[2].set_xlabel('시간 [초]')
axes[2].set_xlim(0, 2)         
axes[2].legend()
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('convolution_MA.png', dpi=150)
plt.show()

위 코드를 실행하면 길이가 15인 이동 평균 필터를 이용하여 고주파 잡음을 걸러낸 결과를 아래와 같이 얻을 수 있습니다.

위 코드를 실행해 보면, 자글자글했던 노이즈 신호(noisy_signal)가 네모반듯한 모양의 임펄스 응답 필터(h)와 컨볼루션 연산을 거친 후, 본래의 부드러운 사인파 궤적(filtered_signal)을 훌륭하게 되찾은 것을 눈으로 확인할 수 있습니다.

 

4. FIR vs IIR: 임펄스 응답 길이의 차이

임펄스 응답의 길이와 형태가 필터의 특성을 결정합니다. 이 점에서 FIR 필터와 IIR 필터의 근본적인 차이가 시작됩니다.

구분 FIR (Finite Impulse Response) IIR (Infinite Impulse Response)
임펄스 응답 유한한 길이로 감쇠 이론적으로 무한히 지속
구조 피드포워드(feedforward)만 피드백(feedback) 포함
안정성 항상 안정 설계에 따라 불안정 가능
위상 특성 선형 위상 설계 가능 비선형 위상
연산량 상대적으로 많음 적은 계수로 급격한 감쇠 구현

다음 코드는 FIR 필터와 IIR 필터를 비교해 보는 내용입니다.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib import rcParams

# 한국어 폰트 설정
plt.rcParams['font.family'] = 'Malgun Gothic'
plt.rcParams['axes.unicode_minus'] = False

# --- FIR vs IIR 임펄스 응답 비교 ---
from scipy.signal import firwin, butter, lfilter


def make_impulse(N=64):
    delta = np.zeros(N)
    delta[0] = 1.0
    return delta

N_fir = 64
delta = make_impulse(N=N_fir)

# FIR: 윈도우 기법 (Hamming window, 35탭)
h_fir_coeff = firwin(numtaps=35, cutoff=0.2)
h_fir = np.convolve(delta, h_fir_coeff)[:N_fir]

# IIR: Butterworth 저역통과 필터 설계 (cutoff=0.2, order=4)
b, a = butter(N=4, Wn=0.2, btype='low')
h_iir = lfilter(b, a, delta)

fig, axes = plt.subplots(1, 2, figsize=(12, 4))

axes[0].stem(np.arange(N_fir), h_fir,
             linefmt='C0-', markerfmt='C0o', basefmt='k-')
axes[0].set_title('FIR 필터 임펄스 응답 (35탭)')
axes[0].set_xlabel('샘플 인덱스 n')
axes[0].grid(True, linestyle='--', alpha=0.5)

axes[1].stem(np.arange(N_fir), h_iir,
             linefmt='C3-', markerfmt='C3o', basefmt='k-')
axes[1].set_title('IIR 필터 임펄스 응답 (4차 Butterworth)')
axes[1].set_xlabel('샘플 인덱스 n')
axes[1].grid(True, linestyle='--', alpha=0.5)

plt.suptitle('FIR vs IIR: 임펄스 응답 비교', fontsize=13, fontweight='bold')
plt.tight_layout()
plt.savefig('fir_vs_iir_impulse.png', dpi=150)
plt.show()

FIR 필터는 35개 샘플 이후 응답이 완전히 0으로 수렴합니다. 반면 IIR 필터는 이론적으로 무한히 지속되지만 지수적으로 빠르게 감쇠합니다. 위의 코드에서 butter 함수는 IIR 필터를 위한 차분 방정식(Difference Equation)의 분자 계수 $b$와 분모 계수 $a$, 두 계수 배열을 반환합니다.

IIR 필터의 출력은 다음 차분 방정식으로 표현됩니다.

$$b_0 x[n] + b_1 x[n - 1] + \cdots + b_M x[n - M] = y[n] + a_1 y[n - 1] + \cdots + a_N y[n - N]$$

정리하면:

$$y[n] = \sum_{k=0}^{M} b_k x[n - k] - \sum_{k=1}^{N} a_k y[n - k]$$

즉 $b_k$는 현재 및 과거 입력 $x[n]$에 곱해지는 가중치이고, $a_k$는 과거 출력 $y[n]$에 곱해지는 가중치입니다. 여기서 보듯이 IIR 필터의 특징은 입력 데이터뿐만 아니라 과거의 출력 데이터(피드백)를 이용하여 현재의 출력을 만들어 냅니다.  반면에 FIR 필터는 현재의 입력과 과거의 입력으로부터 결과를 만들어 냅니다. 자세한 내용은 FIR 필터 설계와 IIR 필터 설계에서 다루도록 하겠습니다.

 

5. 컨볼루션으로 노이즈 제거 (Python 실전)

import numpy as np
import matplotlib.pyplot as plt
from matplotlib import rcParams

# 한국어 폰트 설정
plt.rcParams['font.family'] = 'Malgun Gothic'
plt.rcParams['axes.unicode_minus'] = False

# --- 실전: 노이즈가 섞인 신호에 LPF 적용 ---
from scipy.signal import butter, filtfilt

fs = 1000          # 샘플링 주파수 [Hz]
T  = 1.0           # 신호 길이 [s]
t  = np.linspace(0, T, int(fs * T), endpoint=False)

# 원본 신호: 50 Hz 사인파
f_signal = 50
x_clean = np.sin(2 * np.pi * f_signal * t)

# 잡음 추가: 200 Hz 고주파 + 가우시안 노이즈
rng = np.random.default_rng(seed=42)
noise = 0.5 * np.sin(2 * np.pi * 200 * t) + 0.3 * rng.standard_normal(len(t))
x_noisy = x_clean + noise

# Butterworth LPF 적용 (차단: 100 Hz)
b_f, a_f = butter(N=4, Wn=100 / (fs / 2), btype='low')
# filtfilt: 위상 왜곡 없는 양방향 필터링
x_filtered = filtfilt(b_f, a_f, x_noisy)

fig, axes = plt.subplots(3, 1, figsize=(12, 8), sharex=True)

axes[0].plot(t, x_clean, color='C0', lw=1.5)
axes[0].set_title('원본 신호 (50 Hz)')
axes[0].set_ylabel('진폭')

axes[1].plot(t, x_noisy, color='C3', lw=0.8, alpha=0.8)
axes[1].set_title('노이즈 추가 신호')
axes[1].set_ylabel('진폭')

axes[2].plot(t, x_filtered, color='C2', lw=1.5)
axes[2].set_title('LPF 적용 후 복원 신호')
axes[2].set_xlabel('시간 [s]')
axes[2].set_ylabel('진폭')

for ax in axes:
    ax.set_xlim(0, 0.1)
    ax.grid(True, linestyle='--', alpha=0.5)

plt.suptitle('저역통과 필터를 이용한 노이즈 제거', fontsize=13, fontweight='bold')
plt.tight_layout()
plt.savefig('noise_removal.png', dpi=150)
plt.show()

# SNR 계산
signal_power = np.mean(x_clean ** 2)
noise_before  = np.mean((x_noisy    - x_clean) ** 2)
noise_after   = np.mean((x_filtered - x_clean) ** 2)

snr_before = 10 * np.log10(signal_power / noise_before)
snr_after  = 10 * np.log10(signal_power / noise_after)

print(f"필터 적용 전 SNR: {snr_before:.2f} dB")
print(f"필터 적용 후 SNR: {snr_after:.2f} dB")
print(f"SNR 개선: {snr_after - snr_before:.2f} dB")

위 코드에서 filtfilt()는 신호를 정방향·역방향으로 두 번 필터링하여 위상 왜곡을 완전히 제거하는 함수입니다. 오디오나 생체 신호처럼 위상 정보가 중요한 응용에서 특히 유용합니다.

필터 적용 전 SNR: 3.40 dB
필터 적용 후 SNR: 14.47 dB
SNR 개선: 11.07 dB

4차 Butterworth 저역통과 필터를 적용한 결과, 필터 적용 전 노이즈 신호 대비 SNR이 11.07 dB 개선되었음을 확인하였습니다.

 

[Insight] 

이번 편에서 컨볼루션과 임펄스 응답이라는 두 가지 핵심 개념을 확인했습니다. 컨볼루션은 필터를 적용하는 연산 그 자체이고, 임펄스 응답은 필터의 특성을 완전하게 기술하는 표현입니다. 이 두 개념이 맞물릴 때 LTI 시스템 이론의 강력함이 드러납니다. 어떤 복잡한 입력 신호라도, 시스템의 임펄스 응답만 알면 출력을 정확히 예측할 수 있습니다.

최근 AI 분야에 관심이 있으신 분들이라면 '컨볼루션'이라는 단어가 무척 친숙하실 것입니다. 딥러닝 이미지 처리의 꽃인 CNN(Convolutional Neural Network)이 바로 이 연산을 2차원으로 확장한 것이기 때문입니다. 동일한 수학적 연산이지만, DSP 엔지니어와 AI 연구자가 이를 다루는 관점은 흥미롭게 다릅니다.

  • DSP(신호 처리)의 관점: 엔지니어가 수학적 주파수 응답을 분석하여 신호의 특정 주파수 대역을 깎아내거나 통과시키기 위해 필터의 가중치($h[n]$)를 사람이 직접 정교하게 계산하고 '설계(Design)'합니다.
  • AI(딥러닝)의 관점: 고양이 이미지에서 에지(Edge)나 질감 패턴을 추출하기 위해 필요한 최적의 필터 가중치(Kernel/Weight)를 모델이 다량의 데이터를 통해 스스로 '학습(Learning)'하여 찾아냅니다.

결국 AI 모델 내부에서 일어나는 신비로운 특징 추출(Feature Extraction) 과정도 그 근간을 파헤쳐 보면 오늘 배운 DSP의 '컨볼루션과 임펄스 응답'이라는 고전적인 수학적 원리와 완벽하게 맞닿아 있습니다. DSP를 깊이 있게 배우면 최신 딥러닝 아키텍처의 동작 원리까지 꿰뚫어 볼 수 있는 이유가 바로 여기에 있습니다.

 

다음 글 예고

컨볼루션과 임펄스 응답을 통해 필터가 신호에 작용하는 원리를 시간 영역에서 살펴보았습니다. 출력이 과거 입력값들의 가중 합산이라는 사실, 그리고 임펄스 응답이 시스템의 특성을 완전히 기술한다는 사실을 Python 실습으로 직접 확인했습니다.

그러나 필터의 안정성은 어떻게 판단할까요? 극점(pole)과 영점(zero)이란 무엇이며, 이들이 필터의 주파수 특성과 어떤 관계를 맺고 있을까요? 시간 영역의 컨볼루션을 주파수 영역의 곱셈으로 변환함으로써 필터 분석을 체계화하는 도구가 필요합니다.

다음 시간에는 [15. Z-변환(Z-Transform) 기초: 이산 시스템의 표현과 ROC(수렴 영역) 이해] 편을 통해, 이산 시스템 분석의 핵심 수학 도구인 Z-변환의 정의와 물리적 의미, 그리고 수렴 영역(ROC)의 개념을 Python 시각화와 함께 살펴보겠습니다.

 

📌 이전 연재 글 보기

 

반응형