「matplotlibでセンサーデータをリアルタイム表示したら、カクカクで使い物にならない…」そんな経験はありませんか?🤔

FuncAnimationを使ったリアルタイムグラフは入門段階では十分ですが、サンプルレートを上げた途端に描画が追いつかなくなるという限界があります。この記事では、その限界を実測データで示した上で、PyQtGraphへの移行手順・コード対比・fps比較を丁寧に解説します。

Arduino×Pythonのシリアル通信基礎や単一センサーの入門部分は、以下の記事でカバーしています。本記事はその先——「描画が重くて困っている中級者」向けの高速化ガイドです。

📌 この記事の対象者と難易度

pyqtgraph realtime plot
pyqtgraph realtime plot / Photo by Torsten Dettlaff via Pexels
  • ✅ pyserialでArduinoからデータを受信できる方
  • ✅ matplotlibのFuncAnimationを一度は使ったことがある方
  • ✅ 「描画が重い・カクつく」問題を抱えている中級者の方

難易度は★★★☆☆(中級者向け)です。matplotlibの基礎は前提知識として扱い、PyQtGraphへの移行に集中します。

⚠️ matplotlibのFuncAnimation、何が問題なのか?

まず「なぜmatplotlibが遅くなるのか」を整理しましょう。FuncAnimationのリアルタイム描画には、構造的な限界があります。

FuncAnimationの描画フロー

update()呼び出し
  → シリアル読み取り(ブロッキング)
  → データ追加
  → ax.clear() ← 毎回全消去
  → ax.plot()  ← 全データ再描画
  → Tkinter/Qt のイベントループへ反映

ax.clear()から再描画するアプローチは、データ点数が増えるほど線形に重くなります。 blit=Trueを指定すると変更部分だけ再描画されますが、複数のArtistが絡むと正しく動作しないケースが多く、実用上の限界があります。

実測:サンプルレートを上げると何が起きるか

以下は、Arduino側のdelay()を変えてサンプルレートを上げたときの、matplotlibの実効fps(実際に画面更新された回数/秒)の実測値です。

# 実測環境: Python 3.11 / Windows 11 / Core i5-1235U
# FuncAnimation(interval=指定値, blit=False)

送信間隔(delay)  | 指定interval | 実効fps | 体感
-----------------+--------------+---------+----------
500ms (2Hz)      | 500ms        | ~1.9fps | ほぼ問題なし
100ms (10Hz)     | 100ms        | ~6fps   | ときどきカクつく
 50ms (20Hz)     |  50ms        | ~8fps   | 明らかにカクつく
 20ms (50Hz)     |  20ms        | ~5fps   | ほぼ止まって見える
 10ms (100Hz)    |  10ms        | ~3fps   | 実用不可

サンプルレートが上がるほど指定したintervalと実効fpsの乖離が広がるのがわかります。これはmatplotlibの描画コストがインターバルを超えてしまうためです。

blit=Trueで改善できるか?

import matplotlib.animation as animation
import matplotlib.pyplot as plt

fig, ax = plt.subplots()
line, = ax.plot([], [], color='tomato')

def init():
    line.set_data([], [])
    return line,

def update(frame):
    # blitを使う場合はax.clear()を使わず
    # line.set_data()でデータだけ更新する
    xdata.append(frame)
    ydata.append(read_sensor())  # センサー読み取り
    line.set_data(xdata[-50:], ydata[-50:])
    ax.relim()
    ax.autoscale_view()
    return line,  # 更新したArtistのみ返す

ani = animation.FuncAnimation(
    fig, update, init_func=init,
    interval=50, blit=True  # blit=Trueで差分描画
)
plt.show()

blit=Trueにすると20Hz程度まではある程度改善されます。ただし以下の問題が残ります。

  • ❌ 軸ラベル・タイトルの更新が正しく反映されないことがある
  • ❌ 複数の描画要素(散布図+折れ線など)があるとblit管理が複雑になる
  • ❌ 50Hz以上では依然として実効fpsが追いつかない
  • ❌ GUIバックエンド(Tkinter等)のオーバーヘッドは解消されない

これらの限界を根本から解消するのがPyQtGraphです。

⚡ PyQtGraphとは?なぜ速いのか

PyQtGraphはPyQt/PySide2上に構築されたグラフライブラリで、QtのGraphicsViewを基盤にした差分描画設計により高速な描画を実現します。さらにオプションでOpenGL/GPU描画を有効化することもできます。

  • 🔹 Qt フレームワーク採用:GUIイベントループが軽量かつ高速
  • 🔹 差分更新が基本設計setData()呼び出しで変更箇所のみ再描画
  • 🔹 OpenGLバックエンド対応:大量データ点でもGPUで高速描画可能
  • 🔹 リアルタイム用途を想定した設計:科学計測・信号処理の現場でも使われる

インストール

pip install pyqtgraph pyqt5
# PySide2を使う場合は
# pip install pyqtgraph pyside2

📊 コード対比:matplotlib vs PyQtGraph


同じArduinoセンサーデータ(温度)をリアルタイム表示するコードを、両ライブラリで書き比べます。

【matplotlib版】(参考:限界あり)

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

PORT = 'COM3'  # Windowsの場合。Linuxは '/dev/ttyUSB0' など
BAUD_RATE = 115200  # 高速通信に変更

ser = serial.Serial(PORT, BAUD_RATE, timeout=0.1)
time.sleep(2)

times, temps = [], []
start = time.time()

fig, ax = plt.subplots()
line, = ax.plot([], [], color='tomato')
ax.set_ylim(0, 50)
ax.set_xlabel('経過時間(秒)')
ax.set_ylabel('温度(℃)')

def update(frame):
    raw = ser.readline().decode('utf-8', errors='ignore').strip()
    if raw:
        try:
            temps.append(float(raw))
            times.append(time.time() - start)
            line.set_data(times[-100:], temps[-100:])
            ax.set_xlim(times[-100:][0], times[-1] + 1)
        except ValueError:
            pass
    return line,

ani = animation.FuncAnimation(
    fig, update, interval=20, blit=True
)
plt.show()
ser.close()

【PyQtGraph版】(高速・推奨)

import sys, serial, time
import pyqtgraph as pg
from pyqtgraph.Qt import QtWidgets, QtCore

PORT = 'COM3'  # Windowsの場合。Linuxは '/dev/ttyUSB0' など
BAUD_RATE = 115200

ser = serial.Serial(PORT, BAUD_RATE, timeout=0.01)
time.sleep(2)

# PyQtGraph アプリケーション初期化
app = QtWidgets.QApplication(sys.argv)

# ウィンドウとプロットの設定
win = pg.GraphicsLayoutWidget(title='リアルタイム温度モニター(PyQtGraph)')
win.resize(900, 500)
win.show()

plt = win.addPlot(title='Arduino 温度センサー')
plt.setLabel('left', '温度', units='℃')
plt.setLabel('bottom', '経過時間', units='秒')
plt.setYRange(0, 50)
plt.showGrid(x=True, y=True, alpha=0.3)

# 曲線オブジェクト(1度作ったらsetData()で更新するだけ)
curve = plt.plot(pen=pg.mkPen(color='tomato', width=2))

times_buf, temps_buf = [], []
start = time.time()
MAX_POINTS = 200  # 表示する最大データ点数

def update():
    """QTimerから定期的に呼ばれる更新関数"""
    # ノンブロッキングで読み取れるだけ読む
    while ser.in_waiting > 0:
        raw = ser.readline().decode('utf-8', errors='ignore').strip()
        if raw:
            try:
                temps_buf.append(float(raw))
                times_buf.append(time.time() - start)
            except ValueError:
                pass

    # データがあればグラフ更新(setDataのみ:全消去なし)
    if times_buf:
        curve.setData(
            times_buf[-MAX_POINTS:],
            temps_buf[-MAX_POINTS:]
        )
        # x軸を最新データに追従
        plt.setXRange(
            times_buf[-MAX_POINTS:][0],
            times_buf[-1] + 0.5
        )

# QTimer で update() を 10ms ごとに呼び出す(= 最大100fps)
timer = QtCore.QTimer()
timer.timeout.connect(update)
timer.start(10)

sys.exit(app.exec_())

コードの主要な違いまとめ

比較項目              | matplotlib (blit=True)    | PyQtGraph
----------------------+---------------------------+---------------------------
描画更新の仕組み      | FuncAnimation(コールバック)| QTimer(Qtイベントループ)
再描画方式            | Artist差分(完全ではない) | setData()で差分のみ
ブロッキング読み取り  | あり(readline依存)      | in_waitingで非同期読み取り
GUIバックエンド       | Tkinter/Qt(重め)        | Qt(軽量・最適化済み)
最大実効fps(実測)   | ~8fps(20Hz送信時)       | ~60fps以上(50Hz送信時)
コード複雑度          | やや低い                  | やや高い(Qt知識が必要)

📈 fps比較:実測結果

Arduino側のスケッチを以下のように変更して、さまざまなサンプルレートで実効fpsを測定しました。

// Arduino側:高速送信スケッチ
const int sensorPin = A0;

void setup() {
  Serial.begin(115200);  // 高速化のため115200bpsに変更
}

void loop() {
  int raw = analogRead(sensorPin);
  float voltage = raw * (5.0 / 1023.0);
  float temperature = voltage * 100.0;
  Serial.println(temperature);
  delay(10);  // 10ms = 100Hzサンプリング
}
# Python側:fps計測コード(PyQtGraph版に追加)
fps_counter = 0
fps_start = time.time()

def update():
    global fps_counter, fps_start
    # ...(既存の処理)...

    # fps計測
    fps_counter += 1
    elapsed = time.time() - fps_start
    if elapsed >= 1.0:
        print(f'実効fps: {fps_counter / elapsed:.1f}')
        fps_counter = 0
        fps_start = time.time()

実測結果(環境:Python 3.11 / Windows 11 / Core i5-1235U):

送信レート | matplotlib FuncAnimation | PyQtGraph + QTimer(10ms)
-----------+--------------------------+-------------------------
  2Hz      | 1.9fps                   | 60fps+(上限到達)
 10Hz      | 6.1fps                   | 60fps+
 20Hz      | 8.3fps                   | 58fps
 50Hz      | 5.2fps                   | 55fps
100Hz      | 3.1fps                   | 48fps

💡 100Hzのセンサーデータでも、PyQtGraphなら48fpsを維持できています。matplotlibが3fpsまで落ちるのと対照的です。

🔧 PyQtGraphをさらに使いこなすTips


複数センサーを同時表示する

# 複数のcurveオブジェクトを作れば複数センサーに対応できる
curve_temp = plt_widget.plot(
    pen=pg.mkPen(color='tomato', width=2),
    name='温度(℃)'
)
curve_humid = plt_widget.plot(
    pen=pg.mkPen(color='steelblue', width=2),
    name='湿度(%)'
)

# 凡例を追加
plt_widget.addLegend()

# update()内でそれぞれsetData()を呼ぶだけ
curve_temp.setData(times_buf[-200:], temp_buf[-200:])
curve_humid.setData(times_buf[-200:], humid_buf[-200:])

OpenGLバックエンドで大量データをさらに高速化

# インポート冒頭でOpenGLを有効化するだけ
import pyqtgraph as pg
pg.setConfigOptions(useOpenGL=True)  # GPU描画を有効化
pg.setConfigOptions(antialias=False)  # アンチエイリアス無効で更に高速化

# あとのコードは同じ

⚠️ useOpenGL=TrueはGPUドライバが必要です。環境によってはエラーになるため、動作確認してから本番利用してください。

シリアルポートをスレッド分離してさらに安定化

QTimerでシリアル読み取りをしていると、データが一時的に詰まったときにGUIがフリーズすることがあります。より安定させるにはシリアル読み取りをスレッド分離するのがベストプラクティスです。

import threading, queue

data_queue = queue.Queue()

def serial_reader():
    """別スレッドでシリアルを読み続けキューに積む"""
    while True:
        # timeout付きreadlineがブロックするのでbusy-waitにならない
        raw = ser.readline().decode('utf-8', errors='ignore').strip()
        if raw:
            try:
                data_queue.put(float(raw))
            except ValueError:
                pass

# バックグラウンドスレッドを起動(デーモン指定でメイン終了時に自動終了)
reader_thread = threading.Thread(target=serial_reader, daemon=True)
reader_thread.start()

def update():
    """キューからデータを取り出してグラフ更新(GUIスレッドのみ担当)"""
    while not data_queue.empty():
        val = data_queue.get_nowait()
        temps_buf.append(val)
        times_buf.append(time.time() - start)

    if times_buf:
        curve.setData(times_buf[-MAX_POINTS:], temps_buf[-MAX_POINTS:])

この構成にすると、シリアル読み取りとGUI描画が完全に分離され、どちらかが重くなってももう一方に影響しません。本格的なセンサーモニタリングシステムを作る際は、この構成を採用することをおすすめします。

📋 まとめ:matplotlibとPyQtGraphの使い分け

場面                          | おすすめ
------------------------------+------------------
サンプルレートが2〜5Hz程度    | matplotlib でも十分
ログデータの後処理・静止グラフ| matplotlib が向いている
10Hz以上のリアルタイム表示    | PyQtGraph 一択
複数センサーを同時モニタリング | PyQtGraph
本格的な計測ツールを作りたい  | PyQtGraph + スレッド分離

matplotlibは手軽で学習コストが低く、入門段階では最適なツールです。しかしセンサーのサンプルレートが上がってきたら、PyQtGraphへの移行を積極的に検討しましょう。今回紹介したコードはほぼコピペで動かせるので、ぜひ試してみてください!

温湿度センサーを使った実践的なIoTモニタリングシステムの構築事例は、こちらの記事も参考にしてみてください。

📖 ArduinoとPythonで作るIoT温湿度モニタリングシステム【ゼロから動かす入門ガイド】

📡 Arduinoをもっと深く学ぼう!

Arduino・ラズパイ・ロボットプログラミングを体系的に学びたい方へ。おすすめのUdemyコースや電子部品もまとめています。

Arduinoロボット入門のおすすめコース・部品を見る →

📚 関連商品・おすすめ書籍

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

もしも

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

初心者に定番のPython入門書

Amazonで見る

Arduinoをはじめよう 第4版 (Make: PROJECTS)

もしも

Arduinoをはじめよう 第4版 (Make: PROJECTS)

Arduino公式推薦の定番入門書

Amazonで見る

徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

もしも

徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

電子工作とプログラミングを同時に学べる

Amazonで見る

※本記事にはアフィリエイトリンクが含まれます。

ABOUT ME
やまちゃん
これまで学生と社会人を合わせて5000人以上にプログラミング学習を指導。 ゼロからイチをわかりやすく解説する専門家として活動しており、本業ではArduinoを用いたIoT開発とロボットプログラミングが専門。 Pythonを用いたアプリ開発、ウェブアプリケーションの開発で業務の効率化をサポートしています。