신호 처리: 지수 이동 평균(EMA) 필터

앞서 신호 처리 소개 기사에서, 유한 임펄스 응답(FIR)과 무한 임펄스 응답(IIR)의 두 가지 필터 클래스에 대해 알아보았습니다. 이동 평균 필터를 FIR과 IIR 형태로 표현하는 방법을 살펴보았는데, 그렇다면 각 필터의 이점은 무엇일까요?

제 이전 블로그의 예시를 다시 살펴보면, 확장된 FIR 필터의 형태는 다음과 같습니다.

y[5] = (x[5]+x[4]+x[3]+x[2]+x[1]+x[0])/5,

이를 위해서는 다음이 필요합니다.

  1. 5번의 곱셈
  2. 4번의 합산 연산

곱셈 연산은 특히 컴퓨팅 비용이 많이 듭니다. 한편 IIR 양식을 다시 살펴보면 다음 사항만 필요하다는 것을 알 수 있습니다.

  1. 3번의 곱셈
  2. 4번의 합산 연산

y[6]=(x[6]+y[5]-x[1])/5

이렇게 하면 컴퓨팅 비용이 크게 줄어듭니다! 이 방법은 마이크로 컨트롤러와 같은 임베디드 장치가 계산을 수행할 때마다 리소스를 덜 소모하기 때문에 유용합니다.

예를 들어, FIR 및 IIR 형태의 11점 이동 평균 필터에 Python 함수 'time.time'을 사용하는 경우, 모든 파라미터(창 크기, 샘플링 속도, 샘플 크기 등)를 동일하게 설정한 상태에서 각각 51ms와 27ms의 런타임 결과를 얻습니다.

이산 소자 시간 IIR 필터 예시

IIR 필터가 마이크로 컨트롤러에서 더 나은 성능을 발휘하는 이유를 염두에 두고, Arduino UNODFRobot MPU6050 관성 측정 장치(IMU)를 사용하는 프로젝트의 예시를 살펴보겠습니다(그림 1). IMU 데이터에 지수 이동 평균(EMA) 필터를 적용하여 원시 데이터와 평활화된 데이터의 차이를 확인하게 됩니다.

그림 1: MPU6050과 Arduino Uno 간의 블록 다이어그램 연결. (이미지 출처: Mustahsin Zarif)

그림 2: MPU6050과 Arduino Uno의 연결. (이미지 출처: Mustahsin Zarif)

지수 이동 평균 필터는 재귀적 형태입니다.

y[n] = α*x[n] + (1- α)*y[n-1]

우리가 측정 중인 현재 출력은 이전 출력에 따라 달라지므로(예: 시스템에 메모리가 있음) 재귀적입니다.

상수 알파()는 이전 출력과 반대로 현재 입력에 얼마나 많은 가중치를 부여할지 결정합니다. 이해를 돕기 위해 방정식을 확장해 보겠습니다.

y[n] = α*x[n] + (1- α )*(α*x[n−1]+(1−α)*y[n−2])

y[n] = α*x[n] + (1- α )*x[n−1]+α*(1−α)2*x[n−2])+ ...

y[n] = k=0nα*(1−α)k*x[n−k]

알파가 클수록 현재 입력이 현재 출력에 더 많은 영향을 미친다는 것을 알 수 있습니다. 시스템이 진화하고 있다면, 오래된 과거의 값은 현재 시스템을 충분히 반영하지 못하므로 이는 바람직합니다. 반면에, 예를 들어 시스템이 정상적인 상태로부터 갑작스럽고 순간적으로 변화가 있는 경우에는 이전 출력이 따르던 추세를 따르는 것이 좋을 수가 있습니다.

그렇다면 이제 MPU6050에서 EMA 필터의 코드가 어떻게 작동하는지 살펴보겠습니다.

EMA 필터 코드:

Copy#include <wire.h>
#include <mpu6050.h>

MPU6050 mpu;

#define BUFFER_SIZE 11  // Window size

float accelXBuffer[BUFFER_SIZE];
float accelYBuffer[BUFFER_SIZE];
float accelZBuffer[BUFFER_SIZE];
int bufferCount = 0;  

void setup() {
  Serial.begin(115200);
  Wire.begin();

  mpu.initialize();

  if (!mpu.testConnection()) {
    Serial.println("MPU6050 connection failed!");
    while (1);
  }

  int16_t ax, ay, az;
  for (int i = 0; i < BUFFER_SIZE; i++) {
    mpu.getMotion6(&ax, &ay, &az, NULL, NULL, NULL);
    accelXBuffer[i] = ax / 16384.0;
    accelYBuffer[i] = ay / 16384.0;
    accelZBuffer[i] = az / 16384.0;
  }
  bufferCount = BUFFER_SIZE;
}

void loop() {
  int16_t accelX, accelY, accelZ;

  mpu.getMotion6(&accelX, &accelY, &accelZ, NULL, NULL, NULL);

  float accelX_float = accelX / 16384.0;
  float accelY_float = accelY / 16384.0;
  float accelZ_float = accelZ / 16384.0;

  if (bufferCount < BUFFER_SIZE) {
    accelXBuffer[bufferCount] = accelX_float;
    accelYBuffer[bufferCount] = accelY_float;
    accelZBuffer[bufferCount] = accelZ_float;
    bufferCount++;
  } else {
    for (int i = 1; i < BUFFER_SIZE; i++) {
      accelXBuffer[i - 1] = accelXBuffer[i];
      accelYBuffer[i - 1] = accelYBuffer[i];
      accelZBuffer[i - 1] = accelZBuffer[i];
    }
    accelXBuffer[BUFFER_SIZE - 1] = accelX_float;
    accelYBuffer[BUFFER_SIZE - 1] = accelY_float;
    accelZBuffer[BUFFER_SIZE - 1] = accelZ_float;
  }

//calculate EMA using acceleration values stored in buffer
  float emaAccelX = accelXBuffer[0];
  float emaAccelY = accelYBuffer[0];
  float emaAccelZ = accelZBuffer[0];
  float alpha = 0.2;

  for (int i = 1; i < bufferCount; i++) {
    emaAccelX = alpha * accelXBuffer[i] + (1 - alpha) * emaAccelX;
    emaAccelY = alpha * accelYBuffer[i] + (1 - alpha) * emaAccelY;
    emaAccelZ = alpha * accelZBuffer[i] + (1 - alpha) * emaAccelZ;
  }

  Serial.print(accelX_float); Serial.print(",");
  Serial.print(accelY_float); Serial.print(",");
  Serial.print(accelZ_float); Serial.print(",");
  Serial.print(emaAccelX); Serial.print(",");
  Serial.print(emaAccelY); Serial.print(",");
  Serial.println(emaAccelZ);

  delay(100);
}
</mpu6050.h></wire.h>

이 코드를 실행하고 직렬 플로터를 확인하면, 창 크기 11과 알파 값 0.2를 사용하여 x, y 및 z 축의 가속도에 대해 거친 선과 부드러운 선이 쌍으로 표시되는 것을 볼 수 있습니다(그림 3 ~ 5).

그림 3: x 방향의 원시 가속도 값과 필터링된 가속도 값. (이미지 출처: Mustahsin Zarif)

그림 4: y 방향의 원시 가속도 값과 필터링된 가속도 값. (이미지 출처: Mustahsin Zarif)

그림 5: z 방향의 원시 가속도 값과 필터링된 가속도 값. (이미지 출처: Mustahsin Zarif)

한층 더 스마트하게 코드 생성

IIR 필터가 FIR 필터에 비해 합산 및 곱셈 계산이 훨씬 적게 필요하기 때문에, IIR 필터가 컨트롤러에 더 적합하다는 사실을 알게 되었습니다. 하지만 이 코드를 구현하는 경우 합산과 곱셈만 계산에 포함되는 것이 아닙니다. 새로운 시간 샘플이 들어올 때마다 샘플을 이동해야 하며, 이 과정에는 내부적으로 컴퓨팅 성능이 필요합니다. 따라서 모든 샘플링 시간 간격마다 모든 샘플을 이동하는 대신, 순환 버퍼를 사용할 수 있습니다.

그 방법은 들어온 데이터 샘플의 인덱스를 기억하는 포인터를 사용하는 것입니다. 포인터가 버퍼의 마지막 소자를 가리킬 때마다 다음 버퍼의 첫 번째 소자를 가리키며, 새로운 데이터가 이전에 저장되어 있던 데이터를 대체합니다. 이제 그 데이터는 더 이상 필요하지 않은 가장 오래된 데이터이기 때문입니다(그림 6). 따라서 이 방법을 사용하면, 해당 배열의 마지막 소자에 새 데이터를 넣기 위해 매번 샘플을 이동할 필요 없이 버퍼에서 가장 오래된 샘플을 추적하고 해당 샘플을 교체할 수 있습니다.

그림 6: 순환 버퍼의 예시 그림. (이미지 출처: Mustahsin Zarif)

순환 버퍼를 사용하여 EMA 필터를 구현하는 코드는 다음과 같습니다. 가속도계가 아닌, 자이로스코프에서 이를 실행해 보시겠어요? 이 계수를 자유자재로 활용해 보세요!

순환 버퍼 코드를 사용한 EMA 필터:

Copy#include <wire.h>

#include <mpu6050.h>

MPU6050 mpu;

#define BUFFER_SIZE 11  // Window size

float accelXBuffer[BUFFER_SIZE];

float accelYBuffer[BUFFER_SIZE];

float accelZBuffer[BUFFER_SIZE];

int bufferIndex = 0;  

void setup() {

  Serial.begin(115200);

  Wire.begin();
 

  mpu.initialize();


  if (!mpu.testConnection()) {

    Serial.println("MPU6050 connection failed!");

    while (1);

  }

  int16_t ax, ay, az;

  for (int i = 0; i < BUFFER_SIZE; i++) {

    mpu.getMotion6(&ax, &ay, &az, NULL, NULL, NULL);

    accelXBuffer[i] = ax / 16384.0;

    accelYBuffer[i] = ay / 16384.0;

    accelZBuffer[i] = az / 16384.0;

  }

}

void loop() {

  int16_t accelX, accelY, accelZ;

  mpu.getMotion6(&accelX, &accelY, &accelZ, NULL, NULL, NULL);

  float accelX_float = accelX / 16384.0;

  float accelY_float = accelY / 16384.0;

  float accelZ_float = accelZ / 16384.0;

  accelXBuffer[bufferIndex] = accelX_float;

  accelYBuffer[bufferIndex] = accelY_float;

  accelZBuffer[bufferIndex] = accelZ_float;

  bufferIndex = (bufferIndex + 1) % BUFFER_SIZE; //circular buffer implementation 

  float emaAccelX = accelXBuffer[bufferIndex];

  float emaAccelY = accelYBuffer[bufferIndex];

  float emaAccelZ = accelZBuffer[bufferIndex];

  float alpha = 0.2;

  for (int i = 1; i < BUFFER_SIZE; i++) {

    int index = (bufferIndex + i) % BUFFER_SIZE;

    emaAccelX = alpha  accelXBuffer[index] + (1 - alpha)  emaAccelX;

    emaAccelY = alpha  accelYBuffer[index] + (1 - alpha)  emaAccelY;

    emaAccelZ = alpha  accelZBuffer[index] + (1 - alpha)  emaAccelZ;

  }

  Serial.print(accelX_float); Serial.print(",");

  Serial.print(emaAccelX); Serial.print(",");

  Serial.print(accelY_float); Serial.print(",");

  Serial.print(emaAccelY); Serial.print(",");

  Serial.print(accelZ_float); Serial.print(",");

  Serial.println(emaAccelZ);

  delay(100);

}
</mpu6050.h></wire.h>

요약

이 블로그에서는 계산 효율성과 관련된 IIR 필터와 FIR 필터의 차이점을 살펴보았습니다. 간단한 예를 통해 FIR에서 IIR로 전환할 때 연산 수가 얼마나 줄어드는지를 살펴보면, 응용 프로그램이 확장될수록 IIR 필터가 얼마나 효율적인지를 짐작할 수 있습니다. 이는 제한된 하드웨어 성능에서 실시간 응용 제품을 구현하는 데 중요한 부분입니다.

또한 센서 데이터의 잡음을 줄이면서도 기본 신호 동작을 캡처하기 위해 지수 이동 평균 필터를 배포한 Arduino Uno와 MPU6050 IMU를 사용한 프로젝트 예시를 살펴보았습니다. 마지막으로, 효율성을 위해 매 시간 간격마다 데이터를 이동하는 대신 순환 버퍼를 사용한 더 스마트한 코드의 예시도 보았습니다.

다음 블로그에서는 Red Pitaya의 FPGA 기능을 사용하여 4탭 FIR 필터 디지털 회로를 구현해 보겠습니다!

작성자 정보

Image of Mustahsin Zarif

Electrical Engineering student at The University of California, San Diego.

More posts by Mustahsin Zarif
 TechForum

Have questions or comments? Continue the conversation on TechForum, Digi-Key's online community and technical resource.

Visit TechForum