LCDディスプレ(ST7735)でのFPSチェック

2024-09-29

Raspberry Pi Zero 2 Wを使用してST7735ディスプレイをPythonで制御する際、フレームレート(FPS)はパフォーマンス評価の重要な指標です。 本記事でPythonのライブラリであるst7735を使用し、ディスプレイにイメージを出力しFPSを計測します。

テスト環境

背景

st7735のバージョン0.0.51.0.0

st7735はST7735のTFT LCD ディスプレイを操作するためのライブラリです。 メンテナーはPimoroni Ltd.であり、主に、Pimoroniのディスプレイ1をターゲットにしているようです。

バージョン0.0.5では、BGR/RGBを入れ替えられるようになり、基本的なディスプレイ操作が可能でした。 しかし、バージョン1.0.0で大きな変更があり、従来のExampleなどがそのままでは動作しなくなりました。 原因はRPi.GPIOから、lgpiodとgpiodeviceの組み合わせへの変更です。

今回は、どちらのケースでも動作を確認し、FPSへの影響を検証します。

rpi-gpioからrpi-lgpioへの変更

今回のst7735のパッケージとは直接関係ありませんが、RPi.GPIOはカーネルの更新により現在では一部の機能が利用できないとの報告があります2。 バージョン0.0.5ではRPi.GPIO(rpi-gpio)を利用しています。 代替品としてrpi-lgpioを利用できます。本記事ではrpi-lgpioを利用して実験しています。

sudo apt remove python3-rpi-lgpio
sudo apt install python3-rpi.gpio

2つのライブラリの違いについては、以下のページで解説されています。

FPSを図る方法

ディスプレイサイズに合わせた画像を作成し、その画像を連続表示することで、FPSを計測します。 表示にかかった総時間と画像の枚数から、FPSを算出します。 今回のケースでは148枚の画像を使用しました。 以下に、今回使用した画像イメージ(GIF)を示します。 生成用のスクリプトは末尾に掲載しています(fpsチェック用イメージ生成参照)。

sin_wave_balls.gif

このアニメーションの1フレームあたりのデータ量を計算します。

実際には以下のようにイメージを送信します。

disp = ST7735(
    port=0,
    cs=0,
    dc=24,
    backlight=5,
    rst=25,
    width=128,
    height=160,
    rotation=0,
    invert=False,
    spi_speed_hz=10**6,
    bgr=False
)

for image in images:
    disp.display(image)

つまり、約6Mバイトのデータを送信する必要があります。

FPS

FPSはSPIの転送速度によって変化します。 以下に、SPI速度とFPSの結果をグラフにまとめます。 各プロットは10回平均の値を使用しています(なお、ディスプレイの物理的な制約により、120FPS以上は表示されません)。

FPS/Freq

このグラフから、FPSがステップ的に推移していることがわかります。 このステップ状の挙動については、既に素晴らしい記事がありました3

実際の周波数は、指定した値に応じて、ハードウェア上の分周比で設定可能な近い周波数が選択されます。

つまり、SPIの周波数は任意の値を指定できるわけではなく、ハードウェアの制約により特定の周波数が選択されます。

これらを踏まえて、SPI速度とFPSの結果を表にまとめます。

SPI速度 (MHz)0.0.5 (FPS)1.0.0 (FPS)
6.008.7258.763
6.108.8338.872
6.509.2679.309
7.009.8099.855
8.0010.70310.755
9.0011.59711.655
10.0012.40212.476
11.0013.20613.297
12.0013.97414.066
13.0014.74214.835
14.0015.34915.466
15.0015.95516.097
16.0016.69616.852
17.0017.43817.606
19.0018.25418.436
20.0018.69718.908
23.0020.14620.413
25.0021.26921.509
30.0022.52422.869
34.0023.25423.538
40.0024.82625.097
50.0026.50826.870
67.0029.46929.995
100.0030.86931.305

注記: 実測値は、取得したデータから線形補間を用いて計算しています。

理論値と実測値の比較

理論上の最大FPSの計算

SPI通信速度を考慮します。 例えば、50MHzの場合:

理論上の最大FPSは以下のように計算できます。

理論FPS = 6,250,000 / 40,960 ≈ 152.59 (FPS)

実測値との比較

実際の測定では、SPI速度を上げても理論FPSに近づかないことがわかりました。 その理由を理解するために、SPI速度と実測フレーム時間、オーバーヘッド時間を比較します。

オーバーヘッド時間の定義

オーバーヘッド時間とは、データ転送以外の処理にかかる時間を指します。 具体的には、以下の要素が含まれます。

オーバーヘッド時間は、実測フレーム時間から理論フレーム時間(データ転送時間)を差し引くことで求められます。

オーバーヘッド時間 = 実測フレーム時間 − 理論フレーム時間

実測値と理論値の比較

以下の表に、SPI速度ごとの理論フレーム時間、実測フレーム時間、およびオーバーヘッド時間を示します。

SPI速度 (MHz)理論フレーム時間 (秒)実測フレーム時間 (秒)オーバーヘッド時間 (秒)
60.054610.060030.00542
1000.003280.027960.02468

オーバーヘッド時間がSPI速度の増加に伴い増大していることがわかります。 これは、CPU処理やPythonのスクリプト実行がデータ転送の高速化に追いついていないためと考えられます。 6MHzの場合、データ転送時間が長いため、CPUやPythonの処理遅延がデータ転送中に吸収されやすく、100MHzの場合、データ転送が高速なため、CPUやPythonの処理がボトルネックとなり、待ち時間が増加していると考えられます。

おまけ

SpiDev_xfer3関数による影響

SPI通信はspidevモジュールのxfer3関数を介して行われています。 この関数は、C言語の実装になっています。

コードをコピーする
static PyObject *
SpiDev_xfer3(SpiDevObject *self, PyObject *args)
{
    // 関数の詳細は省略
}

このような低レベルの関数の処理時間を確認することで、オーバーヘッドの原因を特定できる可能性があります。

ボタンが使えない問題

RPi.GPIOを使用して、add_event_detectでスイッチに動作を割り当てる簡単なテストプログラムが突然動かなくなりました。

python3 button.py
# RuntimeError: Failed to add edge detection

rpi.gpioを使い続ける意味はありませんので、とりあえずrpi-lgpioを使います。 ただ、gpiodを使う、という選択肢もあります。現にPimoroniは移行しています。 どちらが良いかは今後考えていこうかと思います。

この問題は、カーネルのバージョンアップに伴い、RPi.GPIOが一部の機能を利用できなくなったためです。 そのため、rpi-gpioを使い続ける意味は薄れ、代替としてrpi-lgpioやgpiodを利用する選択肢があります。 実際に、Pimoroniはgpiodへの移行を進めています4

どちらを選択するかは、今後の検討課題とします。

fpsチェック用イメージ生成

以下は、FPS測定用の画像を生成するためのスクリプトです。

import math
from typing import NamedTuple

from PIL import Image, ImageDraw


class DisplaySize(NamedTuple):
    width: int
    height: int

class SinWaveBallAnimation:
    def __init__(self, display_size: DisplaySize, *,
                 ball_size: int = 20, step_count: int | None = None,
                 amplitude: int = 10, frequency: int = 2) -> None:
        self.width = display_size.width
        self.height = display_size.height
        self.ball_size = ball_size
        self.step_count = step_count \
            if step_count else display_size.width + ball_size
        self.amplitude = amplitude
        self.frequency = frequency

        base_y = self.height/2 - self.ball_size/2
        self.balls = [
            {"color": (0, 255, 0), "y_position": base_y - self.ball_size*2},
            {"color": (255, 0, 0), "y_position": base_y},
            {"color": (0, 0, 255), "y_position": base_y + self.ball_size*2},
        ]

        self.images = []

    def generate_images(self) -> None:
        for step in range(self.step_count):
            image = Image.new("RGB", (self.width, self.height), (0, 0, 0))
            draw = ImageDraw.Draw(image)

            for ball in self.balls:
                offset_left = step - self.ball_size
                s = self.frequency * 2 * math.pi * step / self.step_count
                y_offset = int(self.amplitude * math.sin(s))
                y_position = ball["y_position"] + y_offset
                color = ball["color"]
                xy = (
                    offset_left, y_position,
                    offset_left + self.ball_size, y_position + self.ball_size)
                draw.ellipse(xy, color)

            self.images.append(image)

    def save_gif(self, filename: str, duration: int = 20, loop: int = 0,
                 ) -> None:
        if len(self.images) == 0:
            return
        self.images[0].save(
            filename, save_all=True, append_images=self.images[1:],
            optimize=False, duration=duration, loop=loop)

animation = SinWaveBallAnimation(DisplaySize(128, 160))
animation.generate_images()
animation.save_gif("sin_wave_balls.gif")

coresize

cat /sys/module/spidev/coresize 
# Prints:
# 16384

参考文献