센서 실험실 #2: 가속도계 센서 데이터 출력 (feat. Python)

기운찬곰·2025년 12월 4일

센서 실험실

목록 보기
2/3
post-thumbnail

💻 이전 글 참고: https://velog.io/@ckstn0779/Sensors-Accelerometer

저번 시간 글이 너무 길어져서 MPU-6050 센서 모듈의 데이터 출력을 파이썬 그래프로 출력하는 내용을 이어서 작성해보도록 하겠습니다.


Matplotlib 소개

파이썬에서 시각화하면 대표적으로 사용되는 라이브러리, Matplotlib에 대해 알아보겠습니다.

Matplotlib는 Python에서 정적, 애니메이션 및 상호작용 방식의 시각화를 생성하기 위한 포괄적인 라이브러리입니다. 데이터 분석가와 과학자들이 가장 널리 사용하는 라이브러리 중 하나이며, MATLAB과 유사한 명령형 인터페이스를 제공하여 사용이 쉽습니다.

💻 공식 문서 참고: https://matplotlib.org/

✍️ 당연히 MATLAB을 사용하고 싶지만... 개인이 사용하기에는 너무 비싸기 때문에... (학생 때 많이 쓰자)

주요 특징 및 사용 목적은 다음과 같습니다.

  • 다양한 그래프 유형: 꺾은선 그래프(Line plots), 산점도(Scatter plots), 막대 그래프(Bar charts), 히스토그램(Histograms), 파이 차트(Pie charts) 등 거의 모든 유형의 2차원 그래프는 물론, 3차원 그래프도 지원합니다.
  • 높은 유연성 및 사용자 정의: 그래프의 모든 요소(선 스타일, 색상, 글꼴, 축 범위, 제목, 범례 등)를 상세하게 제어하고 사용자 정의할 수 있습니다.
  • 출력 형식: 그래프를 PNG, JPG, PDF, SVG 등 다양한 파일 형식으로 저장할 수 있습니다.
  • 백엔드 지원: 웹 애플리케이션, GUI 도구 키트(Tkinter, PyQt 등) 등 다양한 환경에서 시각화를 통합할 수 있는 백엔드(Backend) 시스템을 갖추고 있습니다. - 용어 주의... (프론트, 백엔드 아님!)

핵심 모듈은 다음과 같습니다.

  • pyplot (plt): Matplotlib의 가장 핵심적인 모듈로, MATLAB과 유사한 인터페이스를 제공하여 빠르고 쉽게 그래프를 그릴 수 있게 해줍니다. (대부분의 코드에서 import matplotlib.pyplot as plt로 사용)
  • matplotlib.figure (Figure): 전체 그래프 창이나 페이지를 나타내는 최상위 컨테이너입니다.
  • matplotlib.axes (Axes): 데이터를 실제로 플롯(Plot)하는 영역입니다. 하나의 Figure 안에 여러 개의 Axes가 포함될 수 있습니다.

Matplotlib의 계층적 구조

Matplotlib는 데이터를 시각화하는 데 있어 계층적인 객체 모델을 따릅니다.

  • Figure: 가장 큰 컨테이너입니다. 캔버스나 종이 전체라고 생각할 수 있습니다.
  • Axes: Figure 내부에 위치하며, 실제 데이터를 시각화하는 영역입니다. 여기에 X축, Y축, 제목, 눈금, 데이터 플롯 등이 포함됩니다.
  • Plot/Artist: Axes 위에 그려지는 실제 데이터 요소(선, 점, 막대 등)입니다.

사용자가 plt.plot()와 같은 함수를 호출할 때, Matplotlib는 이 계층 구조에 따라 Figure와 Axes 객체를 자동으로 생성하고 데이터 포인트를 추가합니다.

예시 1 - 그래프 출력

Matplotlib를 사용하여 간단한 그래프를 그리는 가장 기본적인 방법은 pyplot 모듈을 활용하는 것입니다. 기본적인 꺾은선 그래프(Line Plot)를 만드는 방법을 설명해 드릴게요.

import matplotlib.pyplot as plt

# 1. 데이터 준비
x = [1, 2, 3, 4, 5]
y = [10, 15, 7, 20, 12]

# 2. 그래프 그리기
plt.plot(x, y, label='데이터 시리즈 1', color='red', linestyle='--') # 선 스타일 지정 가능

# 3. 그래프 꾸미기
plt.title("간단한 꺾은선 그래프 예시")
plt.xlabel("시간 (Time)")
plt.ylabel("값 (Value)")
plt.legend() # 범례 표시 (label을 설정했을 때 필요)
plt.grid(True) # 그리드(격자) 표시

# 4. 그래프 출력
plt.show()
  1. 기본 준비: Matplotlib를 사용하려면 먼저 matplotlib.pyplot 모듈을 가져와야 합니다. 보통 plt라는 별칭으로 사용합니다.
  2. 데이터 준비: 그래프를 그릴 X축과 Y축의 데이터를 Python 리스트(List)나 NumPy 배열(Array) 형태로 준비합니다.
  3. 그래프 그리기 (plot): plt.plot() 함수를 사용하여 X와 Y 데이터를 연결하여 선 그래프를 그립니다.
  4. 그래프 꾸미기 (레이블, 제목): 그래프를 더 이해하기 쉽게 만들기 위해 제목과 축 레이블을 추가합니다.
  5. 그래프 출력 (show): 모든 설정이 완료되면, plt.show() 함수를 사용하여 화면에 그래프 창을 띄웁니다.

실행 결과는 다음과 같습니다. 간단하죠?

참고로, Mac에서 Matplotlib 사용 시 한글 깨짐 문제가 있다면 아래 코드를 실행해 주세요. 이는 Matplotlib의 기본 폰트가 한글을 지원하지 않기 때문입니다.

plt.rcParams['font.family'] = 'AppleGothic' # 맥 기본 한글 서체
plt.rcParams['axes.unicode_minus'] = False  # 마이너스 기호 깨짐 방지

예시 2 - 서브 플롯

이전의 간단한 예시는 pyplot 모듈의 기본 함수들을 사용하여 Figure와 Axes 객체를 자동으로 생성하도록 했기 때문에 명시적으로 Figure와 Axes 객체를 다루는 부분이 없었어요. Matplotlib를 능숙하게 사용하고 복잡한 그래프(예: 여러 개의 서브플롯, 실시간 업데이트)를 만들 때는 이 두 객체를 직접 생성하고 제어해야 합니다.

Matplotlib의 핵심 구조는 Figure (전체 캔버스) 안에 Axes (실제 그림 영역)가 포함되는 계층 구조입니다. 이 객체들을 직접 다루는 것을 객체 지향 인터페이스라고 합니다.

plt.subplots()를 이용한 동시 생성하는 것은 가장 흔하고 효율적인 방법입니다. Figure와 Axes를 한 번에 생성하고, 이를 변수에 할당받아 사용합니다.

import matplotlib.pyplot as plt

# 1. Figure(fig)와 Axes(ax) 객체 생성
# fig: 전체 창 (Figure 객체)
# ax: 그림이 그려지는 영역 (Axes 객체)
fig, ax = plt.subplots() 

# 2. Axes 객체를 사용하여 데이터 플롯
x = [1, 2, 3, 4]
y = [10, 5, 20, 15]
ax.plot(x, y, label='단일 축')

# 3. Axes 객체의 메서드를 사용하여 꾸미기
ax.set_title("Axes 객체를 사용한 제어")
ax.set_xlabel("X축")
ax.set_ylabel("Y축")
ax.legend()

plt.show()
  • fig (Figure): 전체 캔버스/창 관리 (파일 저장, 크기 변경). 주요 사용 메서드로는 fig.savefig('plot.png'), fig.set_size_inches() 가 있습니다.
  • ax (Axes): 실제 데이터 그리기 및 꾸미기 (축, 제목, 범례). 주요 사용 메서드로는 ax.plot(), ax.set_title(), ax.set_xlabel(), ax.set_ylim() 가 있습니다.

이를 이용하면 하나의 창에 여러 개의 그래프를 나란히 표시할 수 있습니다.

import matplotlib.pyplot as plt
import numpy as np

# Figure 하나에 2행 1열의 Axes 2개 생성
fig, (ax_accel, ax_gyro) = plt.subplots(2, 1, sharex=True)

# 1. 가속도 그래프 (ax_accel)
t = np.linspace(0, 10, 100)
ax_accel.plot(t, np.sin(t), color='blue', label='가속도 X')
ax_accel.set_title('가속도계 데이터')
ax_accel.set_ylabel('g')
ax_accel.legend()

# 2. 각속도 그래프 (ax_gyro)
ax_gyro.plot(t, np.cos(t), color='red', label='자이로 Y')
ax_gyro.set_title('자이로스코프 데이터')
ax_gyro.set_xlabel('시간 (s)')
ax_gyro.set_ylabel('deg/s')
ax_gyro.legend()

# Figure 객체를 사용해 전체 창의 레이아웃을 조정
fig.tight_layout() 

plt.show()
  • plt.subplots(2, 1): 2행 1열의 Axes 두 개를 생성하고, 이 Axes 객체들을 튜플(ax_accel, ax_gyro) 형태로 반환합니다.
  • sharex=True: 두 Axes가 X축(시간축)을 공유하도록 설정합니다. (한 축을 확대/축소하면 다른 축도 함께 움직입니다.)
  • fig.tight_layout(): 그래프 요소(제목, 축 레이블 등)가 서로 겹치지 않도록 Figure 전체의 간격을 자동으로 조정해 줍니다.

실시간 그래프 업데이트

애니메이션을 사용하지 않고 그래프를 실시간으로 업데이트 시키는 방법이 있습니다.

import matplotlib.pyplot as plt
import numpy as np
import time

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

plt.ion()  # interactive mode ON

# 1. Figure와 Axes 객체 생성 (객체 지향 인터페이스)
fig, ax = plt.subplots()

# 2. 초기 데이터 및 선 객체 준비
# 데이터 저장 리스트 (최대 100개 포인트만 표시)
x_data = [] # 시간
y_data = [] # 데이터
MAX_POINTS = 100  

# 선 객체(line)를 미리 생성하고, 이를 업데이트할 것임
line, = ax.plot(x_data, y_data)

# 초기 축 범위 설정
ax.set_xlim(0, MAX_POINTS)
ax.set_ylim(-1.1, 1.1)
ax.set_title("실시간 그래프 예제: 스크롤링 사인파")
ax.grid(True)

start_time = time.time()

while True:
    # 새로운 데이터 생성
    t = time.time() - start_time      # 0초부터 시작한 시간
    new_y = np.sin(t)

    # 데이터 추가
    x_data.append(t)
    y_data.append(new_y)

    # MAX_POINTS 넘어가면 자동 스크롤: 오래된 데이터 제거
    if len(x_data) > MAX_POINTS:
        x_data.pop(0)
        y_data.pop(0)

    # x축 범위 = 최신 구간만 보이도록 업데이트 (가장 오래된 데이터 ~ 최신 데이터)
    # 단, 데이터가 1개인 경우, x축의 시작점과 끝점과 일치해서 그래프를 그릴 수 없어 경고가 발생한다
    if len(x_data) > 1:
        ax.set_xlim(x_data[0], x_data[-1])

    # 선 업데이트
    line.set_data(x_data, y_data)

    # 화면 갱신
    fig.canvas.draw()
    fig.canvas.flush_events()

    time.sleep(0.02)  # 약 50 Hz

코드를 보면 화면에 보여 줄 최대 데이터는 100개로 설정했습니다. 그리고 나서 시간에 따라 x_data, y_data 배열에 데이터를 계속 추가해줍니다. 그리고 그래프(선)을 업데이트, 화면 갱신을 해줍니다. 데이터가 100개가 넘어가면 오래된 데이터를 제거해주고, 새로운 데이터를 추가해줍니다. 큐 구조인거죠. 이렇게 하면 최대 데이터는 100개를 유지한 채로 그래프는 계속 업데이트되면서 실시간 느낌을 줄 수 있겠죠?

해당 코드에서 주요 함수에 대해 설명 드리겠습니다.

  • plt.ion(): 인터랙티브 모드 on, blocking 없이 화면 갱신 가능. plt.ion()을 켜면 창이 즉시 열리고 fig.canvas.draw() + flush_events()로 계속 업데이트되기 때문에 plt.show()는 없어도 됩니다. 오히려 plt.show()는 “블로킹(멈춤)” 역할이라 루프가 멈출 수 있습니다.
  • fig.canvas.draw(): 변경된 부분까지 포함해서 그림 전체를 다시 그림. blit=False와 동일한 효과. 빠른 주기에서는 CPU 사용량 증가.
  • fig.canvas.flush_events(): GUI 이벤트 큐를 처리하는 함수로, 사용자의 마우스/키보드 입력이나 창 조작 이벤트를 처리합니다. draw()만 하면 그래프는 업데이트되지만 창이 멈춘 것처럼 보일 수 있어서, flush_events()로 이벤트를 처리해야 창이 반응하고 정상적으로 작동합니다!

실시간 스크롤링 느낌으로 잘 동작합니다.


🎯 참고. ax.set_xlim vs. ax.relim(), ax.autoscale_view()

ax.set_xlim(min, max) 대신 ax.relim(), ax.autoscale_view() 라는 함수가 있길래 사용하면 되지 않을까 했지만 좀 다른 거 같더군요. relim()은 현재 그려진 데이터의 실제 범위를 재계산하는 함수이고, autoscale_view()는 그 계산된 범위를 축에 실제로 적용하는 함수입니다. relim()이 "계산", autoscale_view()가 "적용"이라서 보통 두 개를 세트로 사용합니다.

음... 그니까 relim/autoscale_view 는 데이터 범위를 미리 알 수 없을 때, matplotlib이 모든 데이터를 자동으로 분석해서 최적의 범위를 찾아서 보여줄 때 사용하면 좋을 거 같고요. x축 실시간 스크롤링/고정된 윈도우 크기를 구현하려면 ax.set_xlime 이 맞는거 같네요. (헷갈리네...그냥 무시하십쇼...)

Matplotlib 애니메이션

Matplotlib에서 애니메이션을 구현하는 가장 일반적이고 강력한 방법은 matplotlib.animation 모듈의 FuncAnimation 클래스를 사용하는 것입니다. 이 클래스는 정해진 시간 간격(interval)마다 특정 함수를 반복적으로 호출하여 그래프의 데이터를 갱신하고, 이를 통해 움직이는 듯한 효과를 만듭니다.

FuncAnimation은 애니메이션을 구동하는 핵심 클래스이며, 객체를 생성할 때 다음과 같은 필수 인수를 필요로 합니다.

  • fig (Figure): 애니메이션을 표시할 Figure 객체를 지정합니다.
  • func: 반복적으로 호출되어 데이터를 갱신할 함수 (콜백 함수)를 지정합니다.
  • interval: func 함수가 호출될 시간 간격을 밀리초(ms) 단위로 지정합니다.
  • frames (선택): 애니메이션 프레임의 총 개수를 지정합니다. 실시간 스트리밍에서는 생략됩니다.

간단한 사인파(Sine Wave) 실시간 애니메이션 코드입니다. 애니메이션을 사용하지 않은 코드랑 거의 유사하죠?

import matplotlib.pyplot as plt
import matplotlib.animation as animation
import numpy as np
import time

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

# 1. Figure와 Axes 객체 생성 (객체 지향 인터페이스)
fig, ax = plt.subplots()

# 2. 초기 데이터 및 선 객체 준비
# 데이터 저장 리스트 (최대 50개 포인트만 표시)
x_data = [] 
y_data = []
MAX_POINTS = 100

# 선 객체(line)를 미리 생성하고, 이를 업데이트할 것임 (고성능 핵심)
line, = ax.plot(x_data, y_data) 

# 초기 축 범위 설정
ax.set_ylim(-1.1, 1.1) 
ax.set_title("FuncAnimation 예제: 스크롤링 사인파")
ax.grid(True)

start_time = time.time()
# 3. 데이터 갱신 함수 정의 (콜백 함수)
def animate(i):
    """interval마다 호출되어 그래프 데이터를 갱신하는 함수"""
    
    # 새로운 데이터 추가
    t = time.time() - start_time 
    new_y = np.sin(t)
    
    x_data.append(t)
    y_data.append(new_y)
    
    # 데이터 포인트 수 제한 (스크롤링 효과)
    if len(x_data) > MAX_POINTS:
        x_data.pop(0) # 가장 오래된 X 데이터 제거
        y_data.pop(0) # 가장 오래된 Y 데이터 제거

    line.set_data(x_data, y_data)

    # x축 범위 = 최신 구간만 보이도록 업데이트 (가장 오래된 데이터 ~ 최신 데이터)
    if len(x_data) > 1:
        ax.set_xlim(x_data[0], x_data[-1])
    
    return line, # FuncAnimation에 업데이트된 객체를 반환

# 4. FuncAnimation 객체 생성 및 실행
# interval=50ms (20Hz)
ani = animation.FuncAnimation(
    fig, 
    animate, 
    interval=50, 
    blit=True, # 최적화 옵션: 변경된 부분만 다시 그립니다.
    cache_frame_data=False # 실시간 데이터 스트리밍 시 필요 없음
)

plt.show()

실시간 그래프에서 성능을 최적화하고 깜빡임 없이 부드러운 애니메이션을 구현하는 핵심은 데이터 갱신입니다.

  • plt.plot()을 반복 호출하지 않기: 매 프레임마다 plt.plot()을 호출하면 Matplotlib는 새로운 선 객체를 만들고 모든 것을 다시 계산해야 합니다. 이는 매우 느립니다.
  • animate 함수 내에서는 line.set_data(x_data, y_data)를 사용하여 기존에 그려진 선 객체 내부의 데이터만 교체합니다.
  • blit=True: 이 옵션은 Matplotlib에게 변경된 부분(여기서는 선 객체)만 다시 그리도록 지시하여, 성능을 더욱 향상시킵니다.
  • interval: 이 값이 낮을수록 갱신 주기가 짧아져 더 부드러운 애니메이션이 되지만, CPU 부하가 증가합니다.

여기서 한가지 문제가 있다면 x축이 업데이트되지 않는 다른 겁니다. blit=True는 “변경된 부분만 다시 그리기” 기능인데, 축(ax)의 변화는 업데이트 대상에 포함되지 않습니다. 그래서 선은 움직이는데 x축은 그대로인 것처럼 보입니다.

blit=False로 하면 매 프레임 전체를 다시 그려서 ax.set_xlim() 도 적용되어 x축이 자연스럽게 스크롤처럼 움직입니다.


🎯 참고. blit 옵션에 대한 자세한 설명

blit 옵션은 Matplotlib 애니메이션 성능을 크게 좌우하는 중요한 개념입니다. 이 옵션은 배경을 한 번 저장해두고, 업데이트되는 요소(선, 점 등)만 그 위에 다시 그려서 비용을 줄이는 최적화 기법입니다. 전체 화면을 매번 다시 그리는 대신 변경된 영역만 업데이트하므로, 실시간 애니메이션의 성능이 크게 향상됩니다.

위 예시에서는 '선'만 업데이트 되는거죠. 선 전체를 매번 다시 그리지만, 변하지 않는 축/그리드 등은 캐시된 배경 이미지를 재사용해서 빠른 겁니다. (업데이트는 개별 데이터 포인트가 아니라 그래픽 객체 단위로 작동!)

  • 장점: 화면 전체를 다시 그리지 않으므로 매우 빠름. CPU / GPU 부담과 깜빡임 감소.
  • 단점: (xlim/ylim), ticks, labels, grid 같은 요소는 보통 blit 대상이 아닙니다.

MPU-6050 데이터 실시간 시각화

실습 코드 작성

Matplotlib 에 대해 대략적으로 알아봤으니 이제 이를 이용해 측정 데이터를 실시간 시각화를 해보도록 하겠습니다.

#1. 초기 설정 및 라이브러리

시리얼 통신을 위한 라이브러리, matplotlib 라이브러리를 불러옵니다. 시리얼 통신 설정과 연결을 시도합니다. 그래프에 표시할 데이터의 최대 개수 및 데이터 저장소를 만듭니다.

import serial
import time
import re
import matplotlib.pyplot as plt
import matplotlib.animation as animation

# -- 설정값 -- 
SERIAL_PORT = 'COM4'
BAUD_RATE = 115200
MAX_POINTS = 100        # 그래프에서 표시할 최대 데이터 포인트 수
NUM_AXES = 6            # 가속도 3축 + 자이로 3축

# -- 데이터 저장소 -- 
xs = [] # 시간축 (X축) 데이터
ys = [[] for _ in range(NUM_AXES)] # 가속도 및 자이로스코프 데이터 (Y축) - 2차원 리스트

# -- 시리얼 포트 초기화
try:
    ser = serial.Serial(SERIAL_PORT, BAUD_RATE)
    time.sleep(0.5)
    ser.reset_input_buffer()   # 🔥 기존 입력 버퍼 전체 삭제!
    print(f"시리얼 포트 {SERIAL_PORT} 연결 성공.")
except serial.SerialException as e:
    print(f"시리얼 포트 연결 실패: {e}")
    print("포트 번호가 올바른지, ESP32가 연결되어 있는지 확인하세요.")
    exit()

여기서 한 가지 중요한 점은 시리얼 포트는 이전에 ESP32가 출력한 데이터가 PC 버퍼에 그대로 남아 있기 때문에, 파이썬이 실행되면 그 “남아 있던 오래된 데이터”부터 읽어옵니다.

ESP32 재부팅 전/후, 파이썬 코드 실행 전, 출력된 모든 문자열이 Windows의 시리얼 버퍼에 쌓여 있고, 파이썬의 ser.readline()이 그걸 처음부터 차례대로 읽습니다.

따라서 코드에서 시리얼 포트를 연 직후 시리얼 버퍼를 비워주면(flush) 해결됩니다. 안정성을 위해 ESP32와 연결 된 이후 0.5초 정도 기다린 뒤 지워줍니다.


#2. matplotlib 설정, 그래프 틀

# -- matplotlib 설정 --
fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True)
fig.suptitle('MPU-6050 ACC, GYRO')

# 첫 번째 그래프: 가속도 (ACC)
ax1.set_title('ACC (g)')
ax1.set_ylabel('ACC (g)')
lines_accel = [ax1.plot([], [], label=f'ACC {axis}')[0] for axis in ['X', 'Y', 'Z']]
ax1.legend(loc='upper right')
ax1.grid(True)

# 두 번째 그래프: 각속도 (GYRO)
ax2.set_title('GYRO (deg/s)')
ax2.set_xlabel('Time (s)')
ax2.set_ylabel('GYRO (deg/s)')
lines_gyro = [ax2.plot([], [], label=f'GYRO {axis}')[0] for axis in ['X', 'Y', 'Z']]
ax2.legend(loc='upper right')
ax2.grid(True)
  • 2행 1열의 서브플롯(subplot)을 생성합니다. ax1은 가속도 그래프, ax2는 각속도 그래프이며, sharex=True를 통해 두 그래프의 X축(시간)을 동기화합니다.
  • lines_accel, lines_gyro: 각 축에 대한 선 객체를 미리 생성하고 리스트에 저장합니다. 이 객체들의 데이터만 변경하면 그래프를 효율적으로 업데이트할 수 있습니다.

#3. 데이터 파싱(data_pattern, read_and_parse_data)

이 부분이 시리얼로 들어오는 텍스트를 숫자 데이터로 변환하는 핵심입니다.

# -- 정규 표현식 패턴 --
# "ACC: X=..., Y=..., Z=... | TEMP: ... | GYRO: X=..., Y=..., Z=..." 형식에서
# 숫자 값만 추출하는 패턴 (온도는 제외)
# 그룹 1~3: ACC X, Y, Z
# 그룹 4~6: GYRO X, Y, Z
data_pattern = re.compile(
    r"ACC: X=([\d.-]+), Y=([\d.-]+), Z=([\d.-]+) \| TEMP: [\d.-]+ C \| GYRO: X=([\d.-]+), Y=([\d.-]+), Z=([\d.-]+)"
)

# -- 데이터 읽기 및 파싱 함수
def read_and_parse_data():
    try:
        # 시리얼 버퍼에서 한 줄 읽기
        line = ser.readline().decode('utf-8').strip()
        if not line:
            return None
        
        # 정규 표현식으로 데이터 추출
        match = data_pattern.match(line)
        if match:
            # 6개 데이터를 float으로 반환하여 리스트로 반환
            return [float<(match.group(i)) for i in range(1, NUM_AXES + 1)]
    except Exception as e:
        print(f"데이터 처리 중 오류: {e}")
        return None
  • 정규 표현식(Regular Expression) 패턴을 정의합니다. ESP32에서 보내는 텍스트 형식에 따라 정규식을 정의합니다. (AI가 알아서 작성해줍니다. 예전엔 공부했는데...)
  • read_and_parse_data(): 시리얼 버퍼에서 줄 바꿈 문자(\n)가 나올 때까지 데이터를 읽습니다. 읽은 줄이 패턴과 일치하는지 확인합니다. 정규 표현식으로 추출된 6개 그룹의 문자열을 float 실수형으로 변환하여 반환합니다.

#4. 그래프 애니메이션 (animate 함수)

animate 함수는 FuncAnimation에 의해 지정된 간격(예. 100ms)마다 반복 호출되어 그래프를 갱신합니다.

# --- 그래프 업데이트 함수 ---
start_time = time.time()

def animate(i):
    global start_time
    
    # 1. 시리얼 데이터 읽기 및 파싱
    data_values = read_and_parse_data()

    if data_values:
        current_time = time.time() - start_time
        
        # 2. 데이터 저장소 업데이트
        xs.append(current_time)
        
        for idx in range(NUM_AXES):
            ys[idx].append(data_values[idx])

        # 데이터 포인트가 MAX_POINTS를 초과하면 가장 오래된 데이터 제거
        if len(xs) > MAX_POINTS:
            xs.pop(0)
            for y_list in ys:
                y_list.pop(0)

        # 3. 그래프 데이터 업데이트 및 스케일 조정
        
        # 가속도 (ACCEL: ys[0]~ys[2])
        for i in range(3):
            lines_accel[i].set_data(xs, ys[i])
        
        # 각속도 (GYRO: ys[3]~ys[5])
        for i in range(3):
            lines_gyro[i].set_data(xs, ys[i+3])

        # X축 스케일 조정
        ax1.set_xlim(xs[0], xs[-1] if xs else 1)
        
        # Y축 스케일 조정 (데이터에 맞춰 동적 조정)
        if ys[0]:
            all_ys = [val for y_list in ys for val in y_list]
            min_y = min(all_ys) - 0.1
            max_y = max(all_ys) + 0.1
            
            # 가속도 축 (ax1)
            ax1.set_ylim(min_y, max_y)
            # 각속도 축 (ax2)
            ax2.set_ylim(min_y, max_y) 
    
    # 🔥 blit=True 를 위한 필수 return
    return lines_accel + lines_gyro
  1. 데이터 읽기: read_and_parse_data()를 호출하여 최신 6축 데이터를 가져옵니다.
  2. 시간 기록: current_time을 계산하여 xs 리스트에 추가합니다.
  3. 데이터 추가 및 FIFO: ys 리스트에 6축 데이터를 각각 추가합니다. 데이터 개수가 제한(100개)를 초과하면 pop(0)을 사용하여 가장 앞쪽(오래된) 데이터를 제거합니다. (FIFO, 선입선출 방식)
  4. 그래프 업데이트: 미리 생성해 둔 선 객체에 업데이트된 xs와 ys 데이터를 적용합니다. 이것이 그래프를 다시 그리는 대신 데이터만 교체하여 고성능을 유지하는 핵심입니다.
  5. 스케일 조정(set_xlim, set_ylim): X축 범위를 현재 데이터의 시작과 끝 시간에 맞춰 조정하여 스크롤링 효과를 만듭니다. Y축 범위는 모든 6축 데이터 중 최솟값과 최댓값을 찾아 그래프의 Y축 범위로 동적으로 설정합니다.

#5. 애니메이션 시작

ESP32에서 출력하는 데이터 속도와 맞추면 됩니다. 처음에는 100Hz(1초에 100개)를 시도했다가 열리지도 않았던가...? 그래서 10Hz로 바꿨습니다.

# --- 애니메이션 시작 ---
# interval=100: 100ms마다 animate 함수 호출 시도 (10Hz 목표)
ani = animation.FuncAnimation(
	fig, 
    animate, 
    interval=100,
    blit=True,
    cache_frame_data=False
)
plt.show()

# --- 종료 시 시리얼 포트 닫기 ---
ser.close()

실습 결과

MPU6050을 여러 방향으로 뒤집어보면 가속도와 각속도가 (거의) 실시간으로 변하는 것을 알 수 있습니다. 다만 시간이 가면 갈수록 반응이 늦어져서 실시간 느낌이 나지 않습니다.

1초에 10개 출력를 출력하는 대신, 5개만 출력하고 animation interval를 200으로 늘려도 지연이 생기는건 여전하고요. Blit=True 설정해도 느리긴 마찬가지였습니다. (그나마 나은거 같기도 하고요. 비록 x축 값이 변하진 않지만요.)

문제의 원인이 뭘까요? blit=True를 적용해도 매 프레임미다 그래프는 다시 그려야 하고요. FuncAnimation 내부에서 결국에는 시리얼 읽고, 파싱하고, 그래프 렌더까지 모두 해야 하기 때문에 렌더가 느려지면 readline()도 밀리고 딜레이가 발생합니다.

matplotlib은 실시간 스트리밍에 적합한 라이브러리는 아니라고 하네요. matplotlib 대신 pyqtgraph를 사용한다면 30~100배 더 빠르다고 합니다.


🤔 matplotlib 대신 pyqtgraph를 사용하면 왜 더 빠를까요?

  • PyQtGraph는 OpenGL을 사용해 GPU 가속으로 렌더링하는 반면, matplotlib은 CPU 기반의 소프트웨어 렌더링을 주로 사용합니다.
  • 또한 pyqtgraph는 실시간 데이터 시각화에 특화되어 설계되었지만, matplotlib은 출판물 품질의 정적 그래프를 만드는 데 최적화되어 있습니다.
  • 수만 개 이상의 포인트를 실시간으로 업데이트할 때 pyqtgraph가 10~100배 더 빠를 수 있지만, 그래프 커스터마이징이나 스타일링은 matplotlib이 훨씬 강력합니다!

아... 목적 자체가 달라서 최적화를 하는 방향도 다른거네요.


PyQtGraph 소개

"Scientific Graphics and GUI Library for Python" - 공식 문서 참고

PyQtGraph는 Python에서 빠르고 강력한 데이터 시각화 및 과학/공학용 그래픽을 위해 설계된 라이브러리입니다. 특히 대용량 데이터를 실시간으로 플로팅(Plotting)하거나, 복잡한 신호 처리 결과를 인터랙티브하게 보여줄 때 주로 사용됩니다.

PyQtGraph의 핵심 특징

PyQtGraph는 다음과 같은 특징 덕분에 과학 및 엔지니어링 분야에서 Matplotlib 대신 실시간 데이터 처리에 자주 활용됩니다.

  • 고성능 및 실시간 처리: NumPy를 기반으로 하며, GPU 가속 기능(OpenGL)을 활용하여 수백만 개의 데이터 포인트를 매우 빠르게 처리하고 실시간으로 갱신할 수 있습니다. 이는 Matplotlib의 애니메이션 기능보다 훨씬 빠르고 효율적입니다.
  • Qt 기반: Qt(PyQt 또는 PySide) GUI 프레임워크 위에서 작동합니다. 따라서 PyQtGraph를 사용하면 데이터 시각화 기능을 갖춘 독립 실행형(Standalone) 데스크톱 애플리케이션을 쉽게 개발할 수 있습니다.
  • 상호작용성 (Interactivity): 기본적으로 마우스 드래그로 확대/축소(Zooming), 이동(Panning)이 부드럽게 지원되며, 다양한 주석 및 측정 도구를 제공합니다.
  • 다목적: 일반적인 2D 그래프 외에도 이미지 처리, 3D 그래프, 파형 분석, 스펙트로그램 등 과학 분야에 필요한 다양한 시각화 기능을 지원합니다.

Matplotlib와 PyQtGraph의 비교

이 두개를 간단하게 표로 비교해보면 다음과 같습니다.

특징MatplotlibPyQtGraph
주요 목적정적, 출판용 고품질 그래프 생성고성능, 실시간, 과학/엔지니어링 데이터 시각화
속도느림 (특히 실시간 데이터 갱신 시)매우 빠름 (OpenGL 가속)
기반 프레임워크다양한 백엔드 지원 (Qt, Tkinter 등)Qt (PyQt/PySide) 전용
상호작용성기본 지원 기능이 적고 느림매우 부드러운 확대/축소 및 이동 기본 지원
개발 난이도간단한 플롯은 쉬움GUI 프레임워크(Qt)를 이해해야 함

데이터의 양이 많거나, 갱신 주기가 빠르거나(예: 100Hz 이상의 센서 데이터), 사용자가 그래프를 인터랙티브하게 조작해야 하는 환경이라면 PyQtGraph가 Matplotlib보다 훨씬 유리합니다.

PyQt / PySide 소개

PyQt와 PySide는 Python 언어를 사용하여 그래픽 사용자 인터페이스 (GUI) 기반의 데스크톱 애플리케이션을 개발할 수 있도록 해주는 프레임워크입니다. 두 프레임워크 모두 강력하고 크로스 플랫폼을 지원하는 GUI 툴킷인 Qt 라이브러리를 Python에서 사용할 수 있도록 래핑(Wrapping)한 것입니다.

Qt의 역할과 두 프레임워크의 관계는 다음과 같습니다.

  • Qt: 원래 C++로 작성된 방대하고 성숙한 GUI 및 애플리케이션 개발 프레임워크입니다. 윈도우, macOS, Linux 등 다양한 운영체제에서 동일한 코드로 네이티브 수준의 애플리케이션을 만들 수 있습니다.
  • PyQt: Riverbank Computing에서 개발한 Qt의 Python 바인딩입니다. 오랜 역사를 가지고 있으며 널리 사용됩니다.
  • PySide: Qt를 개발하는 The Qt Company에서 직접 제공하는 공식 Python 바인딩입니다. PyQt와의 경쟁으로 인해 등장했으며, 주로 라이선스 차이로 인해 선택이 갈립니다.

기능적인 측면에서는 두 프레임워크 모두 Qt의 기능을 거의 완벽하게 지원하므로 큰 차이가 없습니다. 하지만 법적인 측면에서 중요한 차이가 있습니다.

  • PyQt (현재 PyQt6): GPL을 준수해야 하므로, 코드를 공개하지 않고 상업용으로 사용하려면 유료 상업용 라이선스를 구매해야 합니다.
  • PySide (현재 PySide6): LGPL이므로, 코드를 공개하지 않고도 무료로 상업용 애플리케이션을 개발하고 배포할 수 있습니다.

PyQt/PySide를 사용하여 GUI 애플리케이션을 개발할 때 다루는 핵심 개념은 다음과 같습니다.

  • 위젯 (Widgets): GUI의 모든 구성 요소입니다. 버튼(QPushButton), 텍스트 입력창(QLineEdit), 슬라이더(QSlider) 등이 모두 위젯입니다.
  • 레이아웃 (Layouts): 위젯들을 창 내부에 깔끔하고 반응형으로 배치하는 메커니즘입니다. (QHBoxLayout, QVBoxLayout, QGridLayout 등)
  • 시그널 및 슬롯 (Signals & Slots): Qt의 이벤트 처리 시스템입니다. 시그널은 어떤 이벤트가 발생했을 때(예: 버튼 클릭) 위젯이 방출하는 메시지입니다. 슬롯은 시그널이 발생했을 때 호출되는 함수입니다. 개발자는 시그널과 슬롯을 연결(connect)하여 상호작용을 구현합니다.

PyQtGraph와의 관계

PyQtGraph는 이름에서 알 수 있듯이, 이 PyQt/PySide (Qt 프레임워크) 위에서 구동되도록 설계된 라이브러리입니다.

  • PyQtGraph가 제공하는 PlotWidget이나 다른 그래픽 요소들은 모두 Qt의 위젯 시스템을 기반으로 합니다.
  • 따라서 PyQtGraph를 사용하려면, PyQt나 PySide 중 하나를 설치해야 하며, 애플리케이션은 반드시 Qt의 QApplication 객체를 통해 실행되어야 합니다.

간단한 사용법

저는 PyQt6 + PyQtGraph 조합을 사용해보도록 하겠습니다. 사용하기 전에 설치부터 해봅시다.

pip install pyqt6 pyqtgraph

주의할 점으로, 현시점 기준으로 최신 pyqt6은 python 3.13 버전까지만 지원합니다. 저는 python 3.14를 설치했는데... 귀찮게 python 3.13을 하나 더 설치해줍니다. ㅠㅠ

📌 예제 1 - 단일 그래프 한개 띄우기

PyQt6로 GUI 윈도우를 만들고, PyQtGraph의 PlotWidget을 중앙에 배치해서 그래프를 표시하는 코드입니다.

import sys
from PyQt6.QtWidgets import QApplication, QMainWindow
import pyqtgraph as pg


class Window(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("PyQtGraph + PyQt6 Example")

        # 플롯 위젯 생성
        plot_widget = pg.PlotWidget()

        # 그래프 데이터 작성
        x = [1, 2, 3, 4, 5]
        y = [10, 20, 15, 30, 25]

        plot_widget.plot(x, y, pen='r')

        # 메인 윈도우에 플롯 위젯 배치
        self.setCentralWidget(plot_widget)


app = QApplication(sys.argv)
win = Window()
win.show()
sys.exit(app.exec())
  • QApplication (Qt): PyQt 애플리케이션을 실행하는 데 필요한 객체입니다.
  • PlotWidget: 데이터를 플로팅할 수 있는 기본 위젯(Widget)입니다.

Window 클래스에서 x, y 데이터를 빨간색 선(pen='r')으로 그린 후, QApplication으로 GUI 앱을 실행합니다. PyQt의 위젯 시스템과 통합되어 더 복잡한 GUI 애플리케이션을 만들 수 있습니다.

참고로, QMainWindow은 필수는 아니고요. 간단한 그래프만 표시하려면 QWidget을 사용하거나, PlotWidget으로 직접 표시할 수도 있습니다.

import sys
from PyQt6.QtWidgets import QApplication
import pyqtgraph as pg

app = QApplication(sys.argv)

# 플롯 위젯 생성 및 설정
plot_widget = pg.PlotWidget()
plot_widget.setWindowTitle("PyQtGraph + PyQt6 Example")

# 그래프 데이터 작성
x = [1, 2, 3, 4, 5]
y = [10, 20, 15, 30, 25]

plot_widget.plot(x, y, pen='r')

# 위젯 표시
plot_widget.show()

sys.exit(app.exec())

QWidget은 PyQt의 가장 기본적인 UI 요소로, 모든 위젯의 부모 클래스입니다. 버튼, 텍스트박스, 레이블 등 모든 UI 요소가 QWidget을 상속받습니다.

PlotWidget은 QWidget을 상속받아 그래프 그리기 기능이 추가된 특수 위젯입니다. pyqtgraph 라이브러리에서 제공하는 것으로, 내부적으로 OpenGL 기반 플로팅 기능이 구현되어 있습니다.


📌 예제 2 - 단일 그래프 한개 띄우기

QVBoxLayout을 사용해서 2개의 PlotWidget을 수직으로 배치하는 코드입니다. 첫 번째는 빨간색, 두 번째는 파란색 선으로 표시됩니다.

import sys
from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout
import pyqtgraph as pg

app = QApplication(sys.argv)

# 메인 윈도우 생성
window = QWidget()
window.setWindowTitle("PyQtGraph + PyQt6 Example")
layout = QVBoxLayout()

# 첫 번째 그래프
plot_widget1 = pg.PlotWidget()
x1 = [1, 2, 3, 4, 5]
y1 = [10, 20, 15, 30, 25]
plot_widget1.plot(x1, y1, pen='r')
layout.addWidget(plot_widget1)

# 두 번째 그래프
plot_widget2 = pg.PlotWidget()
x2 = [1, 2, 3, 4, 5]
y2 = [5, 15, 10, 25, 20]
plot_widget2.plot(x2, y2, pen='b')
layout.addWidget(plot_widget2)

# 레이아웃 설정 및 표시
window.setLayout(layout)
window.show()

sys.exit(app.exec())

QVBoxLayout + PlotWidget 방식은 Qt 위젯 레이아웃을 직접 관리하는 일반적인 GUI 구성 방식입니다. PyQtGraph의 PlotWidget을 일반 Qt 위젯처럼 사용합니다.

장점으로는 버튼, 라벨, 슬라이더 같은 다른 위젯과 섞어 쓰기 쉽습니다.

GraphicsLayoutWidget을 사용하면 더 간단하고 성능도 좋습니다! PyQt 레이아웃 없이 PyQtGraph 자체 레이아웃 시스템을 사용합니다.

import sys
from PyQt6.QtWidgets import QApplication
import pyqtgraph as pg

app = QApplication(sys.argv)

# GraphicsLayoutWidget 생성
win = pg.GraphicsLayoutWidget()
win.setWindowTitle("PyQtGraph + PyQt6 Example")

# 첫 번째 그래프 (0행 0열)
plot1 = win.addPlot(row=0, col=0)
x1 = [1, 2, 3, 4, 5]
y1 = [10, 20, 15, 30, 25]
plot1.plot(x1, y1, pen='r')

# 두 번째 그래프 (1행 0열)
plot2 = win.addPlot(row=1, col=0)
x2 = [1, 2, 3, 4, 5]
y2 = [5, 15, 10, 25, 20]
plot2.plot(x2, y2, pen='b')

win.show()

sys.exit(app.exec())

GraphicsLayoutWidget은 그리드 기반이라 row, col로 위치를 지정할 수 있고, 모든 플롯이 하나의 OpenGL 컨텍스트를 공유해서 더 빠릅니다. (확실히 더 깔끔한거 같네요)


📌 예제 3 - 실시간 그래프 (QTimer)

QTimer를 사용한 실시간 업데이트 예제입니다. QTimer가 20ms마다 update() 함수를 호출해서 사인파 데이터를 실시간으로 추가하고 그래프를 업데이트합니다.

import sys
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import QTimer
import pyqtgraph as pg
import numpy as np

app = QApplication(sys.argv)

# 플롯 위젯 생성 및 설정
plot_widget = pg.PlotWidget()
plot_widget.setWindowTitle("PyQtGraph 실시간 예제")
plot_widget.setYRange(-1.5, 1.5)

# 초기 데이터
x_data = []
y_data = []
MAX_POINTS = 100
start_time = 0

# 선 객체 생성
line = plot_widget.plot(x_data, y_data, pen='r')

# 업데이트 함수
def update():
    global start_time
    
    # 새 데이터 생성
    t = start_time
    new_y = np.sin(t)
    
    x_data.append(t)
    y_data.append(new_y)
    
    # 최대 포인트 수 제한
    if len(x_data) > MAX_POINTS:
        x_data.pop(0)
        y_data.pop(0)
    
    # 선 업데이트
    line.setData(x_data, y_data)
    
    start_time += 0.1

# QTimer 설정 (20ms마다 업데이트 = 50Hz)
timer = QTimer()
timer.timeout.connect(update)
timer.start(20)

# 위젯 표시
plot_widget.show()

sys.exit(app.exec())

PyQtGraph는 Matplotlib의 FuncAnimation처럼 interval을 사용하는 것이 아니라, Qt 프레임워크의 QTimer를 사용하여 주기적으로 데이터를 읽고 setData()로 갱신하는 방식을 주로 사용합니다.

코드는 Matplotlib을 사용했을 때와 비슷하죠? 구조가 되게 비슷해서 쉽게 교체할 수 있을 겁니다.


PyQtGraph를 사용해서 개선하기

코드 작성

자, Matplotlib을 사용했던 코드를 PyQtGraph + PyQt6 을 사용하도록 수정해봅시다. 구조는 비슷해서 수정하기 간단합니다. 생략된 부분은 이전 코드를 참고하십쇼.

# PyQtGraph version of MPU6050 real-time visualizer
# COM4 115200, parses ACC X/Y/Z and GYRO X/Y/Z

import sys
import serial
import time
import re
from PyQt6.QtWidgets import QApplication
from pyqtgraph.Qt import QtCore
import pyqtgraph as pg

# 초기 설정 값
# ...

# 데이터 저장소
# ...

# 시리얼 포트 초기화
# ...

# 데이터 파싱(data_pattern, read_and_parse_data)
# ...

# GUI
app = QApplication(sys.argv)
win = pg.GraphicsLayoutWidget(show=True, title="MPU6050 ACC / GYRO Real-time Plot")
win.resize(900, 600)

# ACC plot
p1 = win.addPlot(title="ACC (g)")
p1.addLegend() 

acc_colors = ['r', 'g', 'b']
acc_labels = ['ACC X', 'ACC Y', 'ACC Z']

acc_lines = [
    p1.plot(pen=pg.mkPen(color=acc_colors[i], width=2), name=acc_labels[i])
    for i in range(3)
]

win.nextRow()

# GYRO plot
p2 = win.addPlot(title="GYRO (deg/s)")
p2.addLegend()

gyro_colors = ['c', 'm', 'y']
gyro_labels = ['GYRO X', 'GYRO Y', 'GYRO Z']

gyro_lines = [
    p2.plot(pen=pg.mkPen(color=gyro_colors[i], width=2), name=gyro_labels[i])
    for i in range(3)
]

# --- 그래프 업데이트 함수
start_time = time.time()

def update():
    global start_time
    
    # 시리얼 데이터 읽기 및 파싱
    data_values = read_and_parse_data()
    if not data_values:
        return

    t = time.time() - start_time
    xs.append(t)

    for i in range(NUM_AXES):
        ys[i].append(data_values[i])

    # Trim
    if len(xs) > MAX_POINTS:
        xs.pop(0)
        for y_list in ys:
            y_list.pop(0)

    # Update plots
    for i in range(3):
        acc_lines[i].setData(xs, ys[i])
    for i in range(3):
        gyro_lines[i].setData(xs, ys[i+3])

# Timer event (10Hz = 100ms)
timer = QtCore.QTimer()
timer.timeout.connect(update)
timer.start(200)

# Exit
if __name__ == '__main__':
    sys.exit(app.exec())

실습 결과

실행 결과는 다음과 같습니다. 오... 좋습니다.

다만, 처음에는 역시 빠르지만 시간이 지나면 지연이 생기는 군요. 그래도 matplotlib 보다는 빠른 편입니다.

앞으로 해볼 내용

MPU6050으로 데이터를 받아서 실시간 그래프 그리는 것까지 해봤습니다. 앞으로 어떤 걸 해볼까요?

⭐ 1단계: 데이터 처리(필터링)

센서값은 노이즈가 많기 때문에 필터를 적용하면 훨씬 안정적인 동작이 가능합니다.

  • 저역통과필터(LPF) 적용하기
  • 이동평균 필터 적용 → 그래프를 더 부드럽게
  • 칼만 필터(Kalman Filter): “각도 계산” 할 때 사실상 필수

⭐ 2단계: 센서 융합(각도/기울기 추정)

가속도 + 자이로를 조합하면 기울기(roll/pitch) 를 정확하게 계산할 수 있습니다.

  • Complementary Filter(가장 쉽고 효과적)
  • Kalman Filter를 통한 roll/pitch 추정
  • 3축 Orientation(Attitude) 계산: 간단한 3D 모델(PyQtGraph에서 3D도 가능)

⭐ 3단계: 특성 분석 & 이벤트 감지

센서 값 패턴을 분석해서 특정 “동작”을 인식하는 프로젝트입니다. (AI야~ 해줘~...ㅠ)

  • 걸음 감지(Pedometer)
  • 흔들림 감지(Shake Detection)
  • 낙상 감지(Fall Detection)
  • 손가락 탭/터치 감지
  • 특정 방향 기울기 시 LED 켜기 등

⭐ 4단계: 고급 프로젝트(재미 최고)

여기부터는 실전 느낌이 강한 프로젝트들입니다.

  • 밸런싱 로봇(2륜 자이로 로봇) 만들기 - Complementary Filter + PID 제어 필수
  • VR/AR 모션 센서 만들기
  • 손목 모션 기반 스마트 리모컨
  • 스텝 수 측정기(웨어러블) 자체 제작
  • 기울이면 서보모터가 똑같이 움직이는 "리액션 팔" 만들기

일단은 “측정 → 처리 → 해석 → 제어” 단계로 확장하는 과정이 필요할 거 같네요.

먼저 해볼 건, Complementary Filter로 Pitch/Roll 계산. 즉, 기울기를 계산해보는 걸 해봐야 될 거 같습니다.

profile
행동하는 바보가 돼라. 생각을 즉시 행동으로 옮기는 사람이 되어라

0개의 댓글