Arduinoのセンサーフィルタリング:移動平均、メディアン、EMA、カルマンフィルタ
Arduinoのセンサーフィルタリングを解説します。移動平均、メディアンフィルタ、EMA、ヒステリシス、サンプリング周波数、シリアルプロッタでの比較、カルマンフィルタの入門を紹介します。
Arduinoのセンサーフィルタリングとは、analogRead()や距離センサーから得られる細かく揺れたり飛び跳ねたりする値を、プロジェクトで使える安定した信頼性の高い数値に変えることです。ポテンショメーターに触れていないのにシリアルモニタに511、513、510、514と表示される、HC-SR04がときどき理由もなく3 cmや400 cmを示す、暗さと明るさの境目で常夜灯がちらつくといった現象は、どれも同じ問題の別の表れです。多くの場合、センサーの故障ではなく、電気的ノイズやADCの量子化誤差、ときどき発生する誤ったエコーが測定値に入り込んでいます。幸い、数行のフィルタでこれらの問題のほとんどを解決できます。
このガイドでは、まずノイズの種類と、回路側で最初に行う対策を紹介します。その後、簡単なものから順に、複数サンプルの平均、移動平均、スパイクを除去するメディアンフィルタ、少ないメモリで動作する指数フィルタ(EMA)、しきい値付近の不安定な判定を落ち着かせるヒステリシスという5つの方法をコードにします。Arduino IDEのシリアルプロッタで並べて描画し、サンプリング周波数の選び方を説明して、カルマンフィルタの概要を紹介します。最後のセクションでは、ESP32のノイズの多いADCと、Raspberry Pi Pico向けのMicroPythonコードを扱います。

センサーデータにノイズが入る理由
測定誤差をすべて同じものと考えると、対策を誤ります。ノイズの種類によって適したフィルタが異なるため、まずどの問題が起きているかを把握する必要があります。そのためには、しばらく生の値をシリアルモニタに出力し、数値を観察すれば十分です。特定の値の周りで常に細かく揺れていればランダムノイズです。大半の測定値は正しいのに、ときどきまったく不自然な値が出る場合はスパイクです。値がゆっくりずれていく場合、問題はノイズではなくドリフトです。
| ノイズの種類 | 現れ方 | 主な原因 | 適した対策 |
|---|---|---|---|
| ランダムノイズ | 値が±2〜10カウント揺れる | 電源のリップル、長い配線、ADC自体のノイズ | 平均化、移動平均、EMA |
| スパイク | ときどき大きく外れた値が出る | HC-SR04のエコーの取りこぼし、接触不良、モーターのノイズ | メディアンフィルタ |
| 段階的な誤差(量子化誤差) | 値が隣り合う2つの数値を行き来する | ADCの分解能(Unoでは5 V / 1024 = 4.9 mV) | 平均化、ヒステリシス |
| 周期的ノイズ | 規則的なリップル | 照明の100 Hzのちらつき、50 Hzの商用電源 | 20 msの整数倍の期間で平均化 |
| ドリフト | 値が数分かけてゆっくり変わる | 温度、電池電圧の低下 | フィルタではなく、キャリブレーションと安定した基準 |
まず回路から:発生源でノイズを減らす
ソフトウェアのフィルタでは、回路で取得できなかった情報を取り戻せません。まずはノイズが入りにくくすることが大切です。アナログセンサーの配線を短くし、モーターやリレーなど電流を多く消費する部品の配線から離してください。アナログ入力とGNDの間に、センサーの近くで100 nFのセラミックコンデンサを入れると、速いノイズの多くを吸収できます。抵抗とコンデンサで作るRCローパスフィルタのカットオフ周波数は、f = 1 / (2πRC)で求められます。10 kΩと1 µFなら約16 Hzとなり、それより速い変化を抑えます。値はRC時定数ツールで計算できます。モーターを使うプロジェクトでは、センサーのGNDをモータードライバのGNDとは別の経路でボードにつなぎ、ボードに安定した電源を供給することも有効です。ADCの仕組みと基準電圧の影響は、ADCとは?で解説しています。
テスト用の構成
この記事のコードは、扱いやすい3種類のセンサーで試します。ランダムノイズの観察にはポテンショメーター、周期的ノイズとしきい値判定にはLDR、スパイクの観察にはHC-SR04が最適です。LDRを5 Vとアナログピンの間に、10 kΩの抵抗をアナログピンとGNDの間につなぐと、明るくなるほど読み取り値が大きくなります。詳しくはLDR光センサーとポテンショメーターの記事を参照してください。
| 接続 | Arduino Uno | ESP32 | Raspberry Pi Pico |
|---|---|---|---|
| ポテンショメーターの摺動端子 | A0 | GPIO 34 | GP26(ADC0) |
| LDRと10 kΩの分圧回路の中点 | A1 | GPIO 35 | GP27(ADC1) |
| HC-SR04 TRIG | D9 | GPIO 5 | GP2 |
| HC-SR04 ECHO | D10 | GPIO 18(分圧回路を使用) | GP3(分圧回路を使用) |
| LEDまたはリレー(ヒステリシスの例) | D8 | - | - |
| 電源 | ポテンショメーターとLDRは5V、HC-SR04は5V | ポテンショメーターとLDRは3V3、HC-SR04は5V | ポテンショメーターとLDRは3V3、HC-SR04はVBUS |
1.複数サンプルの平均
最も簡単なフィルタは、続けて何回か測定し、その平均を使う方法です。ランダムノイズは各測定値を異なる方向にずらすため、合計すると打ち消し合います。統計学では、独立したN回の測定値の平均を取ると、ノイズがおよそ√N分の1になるという法則があります。4サンプルでノイズは半分、16サンプルで4分の1になります。64サンプルなら8分の1になりますが、時間は4倍かかります。Arduino UnoではanalogRead()が1回約112 µsなので、16サンプルの平均は2 ms未満で求められます。この方法の欠点は、測定のたびに短時間プロセッサを占有することと、スパイクに弱いことです。16サンプルのうち1つでも不自然な値があると、平均も影響を受けます。
1const int POT_PIN = A0;
2
3// Art arda "adet" ölçüm al, ortalamasını döndür / take "adet" readings in a row, return the average
4int ortalamaOku(int pin, int adet) {
5 long toplam = 0; // taşmasın diye long / long to avoid overflow
6 for (int i = 0; i < adet; i++) {
7 toplam += analogRead(pin);
8 delayMicroseconds(100); // ölçümler arasında kısa ara / short gap between readings
9 }
10 return toplam / adet;
11}
12
13void setup() {
14 Serial.begin(115200);
15}
16
17void loop() {
18 int ham = analogRead(POT_PIN);
19 int ort4 = ortalamaOku(POT_PIN, 4);
20 int ort16 = ortalamaOku(POT_PIN, 16);
21 // Serial Plotter için etiket:değer biçimi / label:value format for the Serial Plotter
22 Serial.print("Ham:");
23 Serial.print(ham);
24 Serial.print(",Ort4:");
25 Serial.print(ort4);
26 Serial.print(",Ort16:");
27 Serial.println(ort16);
28 delay(20);
29}2.移動平均
移動平均は直近N回の測定値の平均を保持し、新しい値を読み取るたびに最も古い値を除いて新しい値を加えます。これにより、ループごとに1回だけ読み取りながら、N回分の平均の効果を得られます。コードでは毎回N個の数値を足す代わりに、sum変数を保持し、外れる値を引いて入ってくる値を加えます。そのため、ウィンドウの大きさに関係なく計算速度は一定です。移動平均の代償は遅延です。出力は実際の値に対して約(N−1)/2回分遅れます。10 msごとにサンプリングする10回分のウィンドウなら、遅延は約45 msです。ポテンショメーターや温度の測定では問題になりませんが、高速なロボットでは遅れを感じます。
1const int POT_PIN = A0;
2const int PENCERE = 10; // son 10 ölçüm / last 10 readings
3
4int tampon[PENCERE]; // dairesel tampon / circular buffer
5int sira = 0;
6long toplam = 0;
7bool dolu = false;
8
9int hareketliOrtalama(int yeni) {
10 toplam -= tampon[sira]; // en eski değeri çıkar / remove the oldest value
11 tampon[sira] = yeni;
12 toplam += yeni; // yeni değeri ekle / add the new value
13 sira = (sira + 1) % PENCERE;
14 if (sira == 0) dolu = true;
15 return dolu ? toplam / PENCERE : toplam / sira; // ilk turda bölen küçük / smaller divisor at start
16}
17
18unsigned long sonOlcum = 0;
19
20void setup() {
21 Serial.begin(115200);
22}
23
24void loop() {
25 if (millis() - sonOlcum >= 10) { // 100 Hz örnekleme / 100 Hz sampling
26 sonOlcum = millis();
27 int ham = analogRead(POT_PIN);
28 int filtreli = hareketliOrtalama(ham);
29 Serial.print("Ham:");
30 Serial.print(ham);
31 Serial.print(",Hareketli:");
32 Serial.println(filtreli);
33 }
34}| ウィンドウ(N) | ノイズ低減率 | 遅延(10 msごとに1回読み取り) | 適した用途 |
|---|---|---|---|
| 4 | 約2分の1 | 15 ms | 速い応答が必要な制御 |
| 10 | 約3分の1 | 45 ms | ポテンショメーター、ジョイスティック、LDR |
| 32 | 約5.7分の1 | 155 ms | 温度、湿度、電池電圧 |
| 100 | 10分の1 | 495 ms | 非常にゆっくり変化する値、安定した表示 |
3.メディアンフィルタ:スパイクの除去
平均化はスパイクに弱い方法です。HC-SR04が50 cmを4回測定した後、1回だけエコーを取りこぼして0を返すと、5回の平均は40 cmです。この値は正しくなく、誤りが起きたことも示しません。一方、メディアンフィルタは直近N回の測定値を小さい順に並べ、中央の値を採用します。同じ例では、並べた値は0、50、50、50、50となり、中央は50です。誤った測定値は結果に反映されません。5要素のメディアンフィルタなら、連続する誤った測定値を2回まで完全に除去できます。もう1つの長所は、実際に起きた急な変化を平均のようにぼかさないことです。障害物が本当に近づくと、緩やかな曲線ではなく、数回の測定以内に明確な段差で新しい値へ移ります。以下のコードは60 msごとにHC-SR04を読み取り、直近5回の中央値を出力します。詳しくはHC-SR04距離センサーの記事を参照してください。
1const int TRIG_PIN = 9;
2const int ECHO_PIN = 10;
3const int N = 5; // medyan penceresi, tek sayı olmalı / median window, must be odd
4
5int olcumler[N];
6int sira = 0;
7
8int mesafeOlc() {
9 digitalWrite(TRIG_PIN, LOW);
10 delayMicroseconds(2);
11 digitalWrite(TRIG_PIN, HIGH);
12 delayMicroseconds(10);
13 digitalWrite(TRIG_PIN, LOW);
14 unsigned long sure = pulseIn(ECHO_PIN, HIGH, 25000); // ~4 m zaman aşımı / ~4 m timeout
15 return sure / 58; // yankı yoksa 0 / 0 if no echo
16}
17
18int medyan() {
19 int kopya[N];
20 for (int i = 0; i < N; i++) kopya[i] = olcumler[i];
21 for (int i = 1; i < N; i++) { // eklemeli sıralama / insertion sort
22 int deger = kopya[i];
23 int j = i - 1;
24 while (j >= 0 && kopya[j] > deger) {
25 kopya[j + 1] = kopya[j];
26 j--;
27 }
28 kopya[j + 1] = deger;
29 }
30 return kopya[N / 2]; // ortadaki eleman / middle element
31}
32
33void setup() {
34 Serial.begin(115200);
35 pinMode(TRIG_PIN, OUTPUT);
36 pinMode(ECHO_PIN, INPUT);
37 for (int i = 0; i < N; i++) { // tamponu ilk ölçümlerle doldur / fill the buffer first
38 olcumler[i] = mesafeOlc();
39 delay(60);
40 }
41}
42
43void loop() {
44 int ham = mesafeOlc();
45 olcumler[sira] = ham;
46 sira = (sira + 1) % N;
47 Serial.print("Ham:");
48 Serial.print(ham);
49 Serial.print(",Medyan:");
50 Serial.println(medyan());
51 delay(60); // HC-SR04 için en az 60 ms / at least 60 ms for HC-SR04
52}4.指数フィルタ(EMA):少ないメモリで平滑化
指数移動平均(EMA)はバッファを使わず、前回の結果だけを記憶します。新しい測定値を得るたびに、結果と測定値との差の一部だけ、結果を測定値へ近づけます。式はy = y + α × (x − y)です。ここでxは新しい測定値、yはフィルタ処理後の値、α(アルファ)は0から1の係数です。αが小さいほど出力は安定しますが、応答が遅くなります。各測定値の影響が時間とともに指数関数的に減るため、指数移動平均と呼びます。電子回路のRCローパスフィルタに相当するソフトウェアの処理です。EMAの時定数はおよそτ ≈ T / αです(Tはサンプリング間隔)。10 msごとに測定し、α = 0.1とすると、急な変化の63%に約100 msで、95%に約300 msで到達します。コードには浮動小数点版と、Unoなどの小型ボードで高速に動作する整数版があります。整数版ではα = 1/16を使い、割り算の代わりにシフト(>>)を使います。
1const int LDR_PIN = A1;
2const float ALFA = 0.1; // 0-1 arası; küçük = daha sakin / 0-1; smaller = smoother
3
4float ema = -1; // henüz başlamadı / not started yet
5long emaX16 = -1; // tamsayılı EMA x16 / integer EMA times 16
6
7unsigned long sonOlcum = 0;
8
9void setup() {
10 Serial.begin(115200);
11}
12
13void loop() {
14 if (millis() - sonOlcum >= 10) {
15 sonOlcum = millis();
16 int ham = analogRead(LDR_PIN);
17
18 if (ema < 0) ema = ham; // ilk değerle başlat / start from the first reading
19 ema += ALFA * (ham - ema); // y = y + a(x - y)
20
21 if (emaX16 < 0) emaX16 = (long)ham << 4;
22 emaX16 += ham - (emaX16 >> 4); // alfa = 1/16, float yok / alpha = 1/16, no float
23
24 Serial.print("Ham:");
25 Serial.print(ham);
26 Serial.print(",EMA:");
27 Serial.print(ema, 1);
28 Serial.print(",EMA_int:");
29 Serial.println(emaX16 >> 4);
30 }
31}| α(アルファ) | おおよその等価ウィンドウ | 63%応答時間(10 ms間隔でサンプリング) | 特性 |
|---|---|---|---|
| 0.5 | 3 | 10〜20 ms | 非常に速いが、平滑化は弱い |
| 0.2 | 9 | 50 ms | 速く、軽い平滑化 |
| 0.1 | 19 | 100 ms | バランスがよく、多くのセンサーで最初に試す値に適する |
| 0.05 | 39 | 200 ms | 安定するが遅い |
| 0.01 | 199 | 1 s | 非常に安定するが、ゆっくり変化する値に限る |
表の「等価ウィンドウ」は、同じ程度の平滑化が得られる移動平均のウィンドウの概算値で、N ≈ 2/α − 1で計算します。EMAの最大の利点はメモリ使用量です。100要素の移動平均には200バイトのRAMが必要ですが、EMAに必要なのは変数1つだけです。RAMが2 KBのArduino Unoで複数のセンサーをフィルタ処理する場合、この差は重要です。欠点は平均化と同様にスパイクに弱いことで、大きなスパイクが入ると数回の測定にわたって結果が引っ張られます。
シリアルプロッタでフィルタを比較する
フィルタの違いは数値だけでは分かりにくいものの、グラフではひと目で分かります。以下のコードはポテンショメーターを100 Hzで読み取り、同じ測定値を4つの経路で処理して、すべてをlabel:value形式で出力します。違いを分かりやすくするため、約3%の測定値に人工的なスパイクを加えています。実際のプロジェクトでは、この行を削除してください。グラフでは、メディアンがスパイクを完全に除去する一方、移動平均とEMAは小さな山に変える様子を確認できます。
- コードを書き込み、シリアルモニタが開いていれば閉じてください。シリアルモニタとシリアルプロッタは同時に開けません。
- Arduino IDEで「ツール」>「シリアルプロッタ」を開くか、右上のグラフアイコンをクリックします。
- 右下の通信速度を115200ボーに設定します。
- ポテンショメーターをゆっくり回し、次に速く回して、各線の応答を観察します。
- ラベルの横にあるチェックボックスで線の表示を切り替え、2つのフィルタを直接比較します。
1const int POT_PIN = A0;
2
3// Hareketli ortalama / moving average
4const int PENCERE = 10;
5int maTampon[PENCERE];
6int maSira = 0;
7long maToplam = 0;
8
9int hareketliOrtalama(int yeni) {
10 maToplam += yeni - maTampon[maSira];
11 maTampon[maSira] = yeni;
12 maSira = (maSira + 1) % PENCERE;
13 return maToplam / PENCERE;
14}
15
16// 5'lik medyan / median of 5
17int mdTampon[5];
18int mdSira = 0;
19
20int medyan5(int yeni) {
21 mdTampon[mdSira] = yeni;
22 mdSira = (mdSira + 1) % 5;
23 int a[5];
24 for (int i = 0; i < 5; i++) a[i] = mdTampon[i];
25 for (int i = 1; i < 5; i++) { // eklemeli sıralama / insertion sort
26 int d = a[i], j = i - 1;
27 while (j >= 0 && a[j] > d) { a[j + 1] = a[j]; j--; }
28 a[j + 1] = d;
29 }
30 return a[2];
31}
32
33// Üstel filtre / exponential filter
34float ema = 0;
35const float ALFA = 0.1;
36
37unsigned long sonOlcum = 0;
38
39void setup() {
40 Serial.begin(115200);
41 int ilk = analogRead(POT_PIN);
42 for (int i = 0; i < PENCERE; i++) maTampon[i] = ilk; // tamponları doldur / prefill buffers
43 maToplam = (long)ilk * PENCERE;
44 for (int i = 0; i < 5; i++) mdTampon[i] = ilk;
45 ema = ilk;
46 randomSeed(analogRead(A5));
47}
48
49void loop() {
50 if (millis() - sonOlcum < 10) return; // 100 Hz
51 sonOlcum = millis();
52
53 int ham = analogRead(POT_PIN);
54 if (random(100) < 3) { // TEST: %3 yapay sıçrama, projede sil / TEST: 3% fake spikes, remove in a project
55 ham = constrain(ham + random(-400, 400), 0, 1023);
56 }
57 ema += ALFA * (ham - ema);
58
59 Serial.print("Ham:");
60 Serial.print(ham);
61 Serial.print(",Hareketli:");
62 Serial.print(hareketliOrtalama(ham));
63 Serial.print(",Medyan:");
64 Serial.print(medyan5(ham));
65 Serial.print(",EMA:");
66 Serial.println(ema, 1);
67}グラフの右上には、色付きのラベルが表示されます。ラベル同士をカンマで区切り、ラベルと値の間にコロンを入れないと、シリアルプロッタは線を正しく分けられません。Arduino IDE 1.8の古いプロッタでは、ラベルへの対応が限られています。問題が起きる場合は、カンマで区切った数値だけを送ってください。シリアルポートやボーレートの問題については、Arduinoのシリアルモニタの記事を参照してください。
5.ヒステリシス:しきい値付近での判定の揺れを止める
フィルタは値を安定させますが、それだけでは判定の問題は解決しません。「明るさの値が350未満になったらランプをつける」という常夜灯は、夕暮れ時に値が349と351の間を行き来すると、1秒間に何度もオンとオフを繰り返します。これではリレーの接点が早く消耗します。対策はヒステリシスです。オンにするしきい値とオフにするしきい値を分けます。例えば、値が300未満になると点灯し、400を超えるまで消灯しないようにします。その間の100カウントの「不感帯」により、小さな変動では判定が変わらなくなります。以下のコードはヒステリシスにEMAと短い確認時間を組み合わせています。条件が1秒間途切れずに成立する必要があるため、通り過ぎる影や一瞬のヘッドライトでも判定が変わりません。
1const int LDR_PIN = A1;
2const int LAMBA_PIN = 8; // LED ya da röle modülü / LED or relay module
3const int YAK_ESIK = 300; // bunun altı karanlık / below this is dark
4const int SONDUR_ESIK = 400; // bunun üstü aydınlık / above this is bright
5const unsigned long ONAY_MS = 1000; // koşul 1 sn sürmeli / condition must last 1 s
6
7float ema = -1;
8bool lambaAcik = false;
9unsigned long kosulBasladi = 0;
10bool kosulVar = false;
11
12void setup() {
13 Serial.begin(115200);
14 pinMode(LAMBA_PIN, OUTPUT);
15}
16
17void loop() {
18 int ham = analogRead(LDR_PIN);
19 if (ema < 0) ema = ham;
20 ema += 0.05 * (ham - ema); // yavaş EMA / slow EMA
21
22 // Durum değiştirmek için hangi koşul gerekiyor? / which condition flips the state?
23 bool degistir = lambaAcik ? (ema > SONDUR_ESIK) : (ema < YAK_ESIK);
24
25 if (degistir) {
26 if (!kosulVar) {
27 kosulVar = true;
28 kosulBasladi = millis(); // koşul şimdi başladı / condition just started
29 } else if (millis() - kosulBasladi >= ONAY_MS) {
30 lambaAcik = !lambaAcik; // 1 sn sürdü, kararı değiştir / lasted 1 s, flip the decision
31 digitalWrite(LAMBA_PIN, lambaAcik ? HIGH : LOW);
32 kosulVar = false;
33 }
34 } else {
35 kosulVar = false; // koşul bozuldu, sayacı sıfırla / condition broke, reset the timer
36 }
37
38 Serial.print("EMA:");
39 Serial.print(ema, 0);
40 Serial.print(",Yak:");
41 Serial.print(YAK_ESIK);
42 Serial.print(",Sondur:");
43 Serial.print(SONDUR_ESIK);
44 Serial.print(",Lamba:");
45 Serial.println(lambaAcik ? 600 : 100); // grafikte görünsün / visible on the plot
46 delay(10);
47}しきい値を設定する前に、シリアルプロッタで1日の室内の値を観察してください。不感帯の幅は、フィルタ処理後の値の揺れの少なくとも2〜3倍にします。同じ方法は、サーモスタット(例えば22 °Cで暖房を切り、20 °Cで入れる)、水タンクのポンプ、電池保護回路にも使われます。ボタンのチャタリング対策も、実は確認時間を設ける方法の一種です。詳しくはArduinoに押しボタンをつなぐ方法で解説しています。
サンプリング周波数の選び方
フィルタをうまく機能させるには、適切な頻度でセンサーを読み取る必要があります。遅すぎると速い変化を見逃し、速すぎると同じ値が繰り返し返されたり、センサーの準備ができていなかったりします。基本となるのはナイキストの条件です。追跡したい最も速い変化の少なくとも2倍の周波数でサンプリングします。実際には5〜10倍にするとより確実です。センサーによっては読み取り間隔の下限が決まっており、それより速く読んでも効果はありません。照明のちらつきが問題になる場合、50 Hzの商用電源では照明が1秒間に100回ちらつくことに注意してください。LDRなどのアナログセンサーでこのちらつきを打ち消す最も簡単な方法は、20 msの整数倍の期間に等間隔で測定し、平均を取ることです。ウィンドウが商用電源の周期を整数個含むため、ちらつきが打ち消し合います。
| センサー | 推奨読み取り間隔 | 補足 |
|---|---|---|
| DHT11 / DHT22 | 1 s / 2 s | これより頻繁に読み取ると、同じ値かエラーが返る |
| DS18B20(12ビット) | 750 ms | 変換に必要な時間 |
| HC-SR04 | 少なくとも60 ms | 前回のエコーが消えるまで待つ |
| ポテンショメーター、ジョイスティック | 10〜20 ms | 人の動きには50〜100 Hzで十分以上 |
| LDR(照明のちらつきを抑える場合) | 20 msにわたり1 msごとに読み取り、その後平均化 | 50 Hzの商用電源によるちらつきが打ち消される |
| MPU6050 | 5〜10 ms | 100〜200 Hzで動きを測定する場合の一般的な範囲 |
カルマンフィルタの概要
カルマンフィルタは、測定値のノイズの大きさと、実際の値がどれだけ速く変化し得るかを考慮して、ゲインを自動調整します。最も簡単な1次元の形では、設定項目が2つあります。Rは測定ノイズの分散(センサー値がどれくらい揺れるか)、Qはプロセスノイズ(実際の値がどれくらい速く変化すると予想するか)です。各ステップでは、まず不確かさにQを加え、次にゲインK = P / (P + R)を計算し、推定値を測定値へ差のK倍だけ近づけます。測定ノイズが大きい場合(Rが大きい場合)、Kが小さくなり、測定値をあまり信頼しません。値が速く変化すると予想する場合(Qが大きい場合)、Kが大きくなり、速く応答します。興味深いことに、QとRを固定すると、しばらくしてKは一定値に落ち着き、1次元のカルマンフィルタは実質的にEMAになります。カルマンフィルタの真価は、加速度センサーとジャイロセンサーのデータを組み合わせて傾斜角を求める場合など、複数の量を同時に推定するときに発揮されます。より簡単な類似手法である相補フィルタは、MPU6050の記事で紹介しています。
1const int POT_PIN = A0;
2
3struct Kalman1D {
4 float q; // süreç gürültüsü / process noise
5 float r; // ölçüm gürültüsü (varyans) / measurement noise (variance)
6 float p; // tahminin belirsizliği / estimate uncertainty
7 float x; // tahmin / estimate
8 bool ilk;
9};
10
11Kalman1D kf = {0.05, 9.0, 1.0, 0.0, true}; // r = 9: yaklaşık ±3 basamak titreşim / about ±3 counts of jitter
12
13float kalmanGuncelle(Kalman1D &k, float olcum) {
14 if (k.ilk) { k.x = olcum; k.ilk = false; return k.x; }
15 k.p += k.q; // 1. tahmin: belirsizlik artar / predict: uncertainty grows
16 float kazanc = k.p / (k.p + k.r); // 2. Kalman kazancı / Kalman gain
17 k.x += kazanc * (olcum - k.x); // 3. ölçüme doğru düzelt / correct toward the measurement
18 k.p *= (1.0 - kazanc); // 4. belirsizlik azalır / uncertainty shrinks
19 return k.x;
20}
21
22void setup() {
23 Serial.begin(115200);
24}
25
26void loop() {
27 int ham = analogRead(POT_PIN);
28 float tahmin = kalmanGuncelle(kf, ham);
29 Serial.print("Ham:");
30 Serial.print(ham);
31 Serial.print(",Kalman:");
32 Serial.println(tahmin, 1);
33 delay(10);
34}Rは簡単に見積もれます。ポテンショメーターに触れずに生の値を100回読み取り、値のばらつきを確認します。平均の周りで約±3カウント揺れるなら、標準偏差は約3、分散は約9です。Qは実験で決めます。小さいQ(0.001)では非常に安定するものの遅い出力になり、大きいQ(1)では速いものの揺れの多い出力になります。まずQをRの1〜10%に設定し、ポテンショメーターを回しながらシリアルプロッタで微調整してください。
ESP32のADCノイズとフィルタリング
ESP32の12ビットADCは、Arduino Unoより細かい変化を読み取れますが、ノイズが多く、特に0〜0.1 Vと3.1〜3.3 Vの両端付近では非線形です。Wi-Fiを有効にすると、ノイズはさらに増えます。扱いやすくする方法は2つあります。生の値を返す代わりにチップの工場出荷時のキャリブレーションを適用するanalogReadMilliVolts()を使うことと、各測定で16〜64サンプルを平均してからEMAを適用することです。以下のコードは、この2段階でポテンショメーターを読み取ります。Wi-Fiを使うプロジェクトでは、必ずセンサーをADC1のピンGPIO 32〜39につないでください。
1const int POT_PIN = 34; // ADC1 pini, Wi-Fi ile çalışır / ADC1 pin, works with Wi-Fi
2const int ORNEK = 32; // her ölçümde 32 örnek / 32 samples per reading
3const float ALFA = 0.15;
4
5float ema = -1;
6
7int millivoltOku() {
8 uint32_t toplam = 0;
9 for (int i = 0; i < ORNEK; i++) {
10 toplam += analogReadMilliVolts(POT_PIN); // kalibre mV / calibrated mV
11 }
12 return toplam / ORNEK;
13}
14
15void setup() {
16 Serial.begin(115200);
17 analogReadResolution(12); // 0-4095
18}
19
20void loop() {
21 int ham = analogRead(POT_PIN); // karşılaştırma için tek ham ölçüm / single raw reading for comparison
22 int mv = millivoltOku();
23 if (ema < 0) ema = mv;
24 ema += ALFA * (mv - ema);
25 Serial.printf("Ham:%d,mV:%d,EMA_mV:%.1f\n", ham, mv, ema);
26 delay(20);
27}Raspberry Pi PicoとMicroPythonでのフィルタリング
Raspberry Pi PicoのADC.read_u16()は0から65535の値を返しますが、ADCの実際の分解能は12ビットで、下位ビットにはノイズがあります。MicroPythonではsorted()関数で簡単に中央値を求められ、移動平均にはリストと更新し続ける合計値があれば十分です。以下のコードはポテンショメーターを100 Hzで読み取り、3つのフィルタの出力を、Thonnyの「表示」>「プロッター」ウィンドウで描画できる形式で出力します。
1from machine import ADC
2import time
3
4pot = ADC(26) # GP26 = ADC0
5
6PENCERE = 10
7tampon = [pot.read_u16()] * PENCERE # hareketli ortalama tamponu / moving average buffer
8toplam = sum(tampon)
9sira = 0
10son5 = tampon[:5] # medyan için son 5 değer / last 5 values for the median
11ema = float(tampon[0])
12ALFA = 0.1
13
14while True:
15 ham = pot.read_u16()
16
17 toplam += ham - tampon[sira] # hareketli ortalama / moving average
18 tampon[sira] = ham
19 sira = (sira + 1) % PENCERE
20 hareketli = toplam // PENCERE
21
22 son5.pop(0) # medyan / median
23 son5.append(ham)
24 medyan = sorted(son5)[2]
25
26 ema += ALFA * (ham - ema) # üstel filtre / exponential filter
27
28 print("Ham:", ham, "Hareketli:", hareketli, "Medyan:", medyan, "EMA:", int(ema))
29 time.sleep_ms(10)Thonnyのプロッターは、各行の数値を別々の線として表示し、数値の前のテキストをラベルとして使います。MicroPythonはArduinoより実行が遅いため、フィルタを極端に高い頻度で動かさないでください。小さなウィンドウで100 Hz程度なら、Picoで余裕を持って動作します。MicroPythonが初めての場合は、MicroPythonの基本の記事から始めてください。
どのフィルタを選ぶか
| 状況 | 推奨フィルタ | 理由 |
|---|---|---|
| ポテンショメーター、ジョイスティック、アナログセンサーの値が揺れる | 移動平均またはEMA(α = 0.1〜0.2) | 簡単で速く、十分に平滑化できる |
| ときどき不自然な値が出る(HC-SR04、流量センサー) | 3点または5点のメディアン | 散発的な誤りを完全に除去する |
| スパイクと細かい揺れの両方がある | メディアンの後にEMA | 2つの問題を順に解決する |
| RAMが非常に少なく、センサーが多い | 整数版EMA | センサー1つにつき変数1つ |
| しきい値付近でオン・オフ判定が揺れる | ヒステリシスと確認時間 | 判定の問題を解決する。フィルタだけでは不十分 |
| 照明や商用電源によるリップル | 20 msにわたり等間隔で測定して平均化 | 50 Hzの1周期全体を含む |
| 複数のセンサーを組み合わせる(傾き、位置) | カルマンフィルタまたは相補フィルタ | 各センサーの長所を組み合わせる |
よくある問題と対策
| 症状 | 考えられる原因 | 対策 |
|---|---|---|
| フィルタ処理後の値の応答が遅すぎる | ウィンドウが大きすぎる、またはαが小さすぎる | ウィンドウを小さくするかαを大きくし、表から遅延を求める |
| 起動時に移動平均がゼロからゆっくり上がる | バッファがゼロで埋められた状態で始まる | 最初の測定値でバッファを埋めるか、いっぱいになるまでは実際の測定回数で割る |
| 64サンプルで平均値がおかしくなる | int型の合計値がオーバーフローしている(64 × 1023 = 65,472 > 32,767) | 合計値の変数をlong型にする |
| メディアンフィルタを使っても変化がない | ウィンドウが2などの偶数、またはスパイクが連続している | 連続するスパイクの数より大きい奇数のウィンドウ(3、5、7)を使う |
| シリアルプロッタの線が重なる、またはすべて同じ色になる | ラベルの形式が違う | label:valueをカンマで区切り、printlnで行を終える |
| EMAの値がまったく変わらない | 整数の割り算になっている。0.1ではなく1/10と書いている | 係数をfloat型の値(0.1)で書くか、整数版EMAの式を使う |
| フィルタを使っても値が変動する | ノイズではなく、ドリフトや不安定な電源が原因 | 電源を確認し、100 nFのコンデンサを追加して、キャリブレーションする |
センサーフィルタリングは、測定の信頼性を高める最も安上がりな方法です。追加の部品は不要で、数行のコードだけでプロジェクトの動作が安定します。平均化、移動平均、メディアン、EMA、ヒステリシスを組み合わせる方法が分かれば、揺れる表示、誤報、リレーの頻繁な切り替わりを解消できます。BlueGraysアプリでは、LDRで光の強さを測定するプロジェクト、常夜灯、HC-SR04で距離を測定するプロジェクト、駐車センサー、身長計などを、ステップごとの回路図とすぐに使えるコードで学べます。これらのフィルタを追加して、実際の効果を確かめてください。
よくある質問
Arduinoのセンサー値が変わり続けるのはなぜですか?
アナログ測定では、電源のリップル、長い配線、近くのモーター、ADC自体のノイズにより、値が数カウント動きます。これは正常な現象で、センサーの故障を意味しません。アナログ入力に100 nFのコンデンサを追加し、ソフトウェアで平均化やEMAフィルタを使うと、値が安定します。
移動平均とは何ですか?
移動平均は直近N回の測定値の平均です。新しい値を読み取るたびに最も古い値を除き、新しい値を加えます。ノイズを約√N分の1に減らしますが、出力は(N−1)/2回分遅れます。Arduinoでは、リングバッファと合計値の変数1つを使い、数行で実装できます。
メディアンフィルタと平均の違いは何ですか?
平均はすべての値を計算に含めるため、1つでも不自然な測定値があると結果が崩れます。メディアンフィルタは値を並べて中央の値を採用するので、散発的な誤測定を完全に無視できます。そのため、ときどき誤った値を返すHC-SR04のようなセンサーにはメディアンが、細かく揺れるアナログ測定値には平均化が適しています。
指数フィルタ(EMA)の係数はどう選びますか?
係数αは0から1の範囲です。小さいαでは安定するものの遅い出力に、大きいαでは速いものの揺れの多い出力になります。10 msごとに読み取る場合、α = 0.1で時定数は約100 msとなり、多くのセンサーで最初に試す値に適しています。応答時間はτ ≈ T / αで求められます。
Arduinoのシリアルプロッタで複数の値を描画するにはどうしますか?
各行の値をlabel:value形式でカンマ区切りにして出力し、printlnで行を終えます。例えばHam:512,EMA:509です。シリアルプロッタは各ラベルを別々の色の線として表示します。シリアルモニタが開いていれば先に閉じ、ボーレートをコードの設定に合わせてください。
Arduinoでカルマンフィルタは使えますか?
はい。簡単な1次元のカルマンフィルタなら、Arduino Unoでも数行のコードで動作します。測定ノイズRとプロセスノイズQで調整します。センサーが1つの場合、結果は通常、適切に調整したEMAと非常に近くなります。本来の利点は、加速度センサーとジャイロセンサーなど、複数のセンサーを組み合わせるときに現れます。
この記事を読んだ後に作れるプロジェクト
全プロジェクトを見る
LDRで光の強さを測定
部屋の明るさを数値で見てみませんか?光に反応する小さな部品、LDRで周囲の光を測定し、Arduinoが0.5秒ごとにその値をコンピューターの画面に表示します。手で覆うと数値が変わる様子を確認できます。自動で点灯する常夜灯などのプロジェクトへの第一歩です。

HC-SR04で距離を測定(シリアルモニタ表示)
HC-SR04センサーは、コウモリが暗闇で道を見つけるのと同じ仕組みで動作します。人には聞こえない音を出し、その反響を捉えて前方の物体までの距離を測ります。Arduinoは0.5秒ごとに距離をセンチメートル単位でコンピューターの画面に表示し、3〜100センチメートルの測定値を示します。駐車センサーや障害物を避けるロボットなどのプロジェクトの基礎になります。

LDRで作る常夜灯
暗くなると自動で点灯し、朝になると消灯する小さな常夜灯です。光センサーが部屋の明るさを測定し、Arduinoが十分に暗くなったらLEDを点灯させ、明るくなったら消灯させます。街灯が夕方になると自動で点灯する仕組みも理解できます。

HC-SR04で作る駐車センサー
車の駐車センサーを小さく再現します。物体が近づくほど、ビープ音の間隔が短くなります。距離センサーが前方の物体までの距離を測り、20センチメートルより遠ければ緑のLEDが点灯します。近づくと赤いLEDが点滅してブザーが鳴り、さらに近づくほど警告の間隔が短くなります。車が壁にぶつからずに駐車できる仕組みを、机の上で確かめられます。

身長測定プロジェクト
ドア枠に取り付けて、下に立った人の身長をセンチメートルで表示する身長計です。まず、下に誰もいない状態で1つ目のボタンを押し、距離センサーで床までの距離を測ります。次に、人が下に立って2つ目のボタンを押すと、Arduinoが2つの測定値の差を計算し、身長をディスプレイに表示します。病院にある身長計の電子版を自分で作れます。

ポテンショメーターでサーボモーターを操作
ポテンショメーターのつまみを回すと、サーボモーターのアームもそれに合わせて回ります。つまみの一方の端が0度、もう一方の端が半回転に対応します。Arduinoがつまみの位置を常に読み取り、サーボモーターが動きにすぐ反応します。ロボットアームや回転式カメラ台などのプロジェクトへの第一歩です。

ESP32とLDRモジュールで光の強さを読み取る
LDRモジュールの生のADC値と、暗い状態・明るい状態の基準値から計算した相対的な明るさの割合を、シリアルモニタでリアルタイムに確認します。この割合はルクスの測定値ではありません。モジュールの校正の仕組みを学べます。しきい値の決定に使うため、プロジェクト10の前に取り組むのが適しています。

ESP32とHC-SR04で作る超音波距離計
HC-SR04超音波センサーは、発した音波が物体に反射して戻るまでの時間(Time of Flight)を測定して距離を計算します。このプロジェクトでは、ESP32からセンサーのTRIGピンに10マイクロ秒のパルスを送り、ECHOピンからの反射波の持続時間をpulseIn()で読み取ります。計算した距離をcm単位で16x2 I2C LCDに表示します。HC-SR04は5Vで動作するため、ECHOピンには分圧回路が必須です。分圧しないとESP32の3.3V GPIOが破損します。
ほかの記事
BlueGraysアプリ
この回路をスマホでステップごとに作る
回路図、コード、部品リストを1つの画面で確認できます。Arduino、ESP32、Picoのプロジェクトを200件以上用意しています。

