ガイド · 読了まで16分

Arduinoでライントレースロボット:TCRT5000センサーとPID

Arduinoでライントレースロボットを作ります。TCRT5000センサー、取り付け位置とキャリブレーション、if-elseとPIDのコード、Kp/Kdの調整、モータードライバ、電池、競技のルールを解説します。

ライントレースロボットは、床の黒い(または白い)線を赤外線センサーで検知し、線の上にとどまるよう2つの車輪の速さを調整し続ける自律走行車です。センサーの読み取り、判断、モーターの制御を1つのプロジェクトで組み合わせるので、Arduinoで作る最初の本格的なロボットのプロジェクトの1つです。工場で資材を運ぶ自律搬送車(AGV)も同じ考え方で動いていて、ライントレースは学校のロボット競技で最も参加者の多い部門の1つです。

このガイドでは、TCRT5000センサーのしくみ、2個・3個・5個のセンサーアレイの違い、センサーを取り付ける高さと間隔、しきい値のキャリブレーションの方法を学びます。まず簡単なif-elseのスケッチでロボットを動かし、次にジグザグせず、カーブでもコースを外れずに線をたどるPID制御の版に進みます。Arduino Uno、ESP32、Raspberry Pi Picoで動くコードに加え、モータードライバと電池の選び方、一般的な競技のルール、最も多い問題の対処法も紹介します。

TCRT5000赤外線反射センサー
TCRT5000:青みがかった赤外線LEDと黒いフォトトランジスタが1つのケースに並んでいます。ラインセンサーのモジュールはこの部品を中心に作られています。

ライントレースロボットのしくみ

白い面は赤外線のほとんどを反射し、つやのない黒い面はほとんどを吸収します。ロボットの下のセンサーはこの差を測り、ロボットから見て線がどこにあるかを求めます。ロボットにハンドルはなく、差動駆動と呼ばれる方法で曲がります。左の車輪が右より速く回るとロボットは右に曲がり、遅く回ると左に曲がります。プログラムは1秒に何百回も同じループを回します。センサーを読み、線がどれだけずれたかを求め、それに応じて2つのモーターの速さを補正します。この閉ループが速く安定して回るほど、ロボットはなめらかに動きます。

部品おすすめメモ
制御ボードArduino UnoまたはNanoNanoのほうが小さなシャーシに収めやすい。ESP32やPicoでも可
ラインセンサー3チャンネルか5チャンネルのTCRT5000アレイ、QTR-8Aアナログ出力のアレイのほうがPIDでよい結果になる
モータードライバL298NまたはTB6612FNGTB6612FNGのほうが小さく効率がよい
モーターギア付きDCモーター2個(TT 1:48かN20)2つのモーターは同じ型番・同じ速さにする
車輪駆動輪2個とキャスター1個65 mmの車輪が最も一般的
電源18650リチウムイオン電池2本(7.4 V)とスイッチ9 Vの角形電池(PP3)はモーターには弱すぎる
その他シャーシ、押しボタン、ジャンパーワイヤーボタンがあるとキャリブレーションとスタートが楽になる
Arduino Nano
Arduino Nano
L298N
L298N
N20
N20
18650リチウムイオン電池
18650リチウムイオン電池

TCRT5000センサー:2個・3個・5個のセンサーアレイ

TCRT5000は、赤外線LED(950 nm)とフォトトランジスタが同じケースに並んだものです。LEDの光が床で多く跳ね返ってフォトトランジスタに届くほど、トランジスタはよく導通します。センサーは約2.5 mmの距離で最もよく働き、1 cmを超えると黒と白の差が急に小さくなります。既製のモジュールにはLM393コンパレーターが入っていて、ほとんどのモジュールでは、ポテンショメーターで設定したしきい値に応じて、DOピンが黒の上でHIGH、白の上でLOWを出力し、基板のLEDは白い面の上で点きます。AOピンは生のアナログ値を出し、黒の上で高く、白の上で低くなります。センサーのすべての詳細はTCRT5000赤外線センサーにあります。

センサーの数配置アルゴリズム選ぶとき
2線の両側。ふだんはどちらも白を見るif-else最初の挑戦、ゆるいカーブの広いコース
3中央を線の上に、外側を線の端にif-elseか簡単なPID学校のプロジェクト、初心者レベルの競技
5等間隔の列、中央を線の上にPID(加重平均)速く、なめらかなライントレース
8幅の広い列(QTR-8Aのような)PIDと交差点の検出急カーブや交差点のある競技のコース

センサーの配置:高さ、間隔、前方への距離

コードがどんなによくても、センサーの位置が間違っていればロボットはうまく走れません。センサーバーは車輪の軸より前に、床と平行に、ロボットの中心線に対して左右対称に取り付けます。次の寸法がよい出発点になります。

  • 高さ:センサーの下面と床の間を3〜5 mmあけます。PololuはQTR-8Aアレイの最適な距離を3 mm、推奨の最大を6 mmとしています。それより高いと黒と白の差が縮み、ロボットが線を見失います。
  • 間隔:アナログのアレイでは、2つのセンサーの間隔を線の幅の半分から3分の2にします。20 mmの線なら10〜13 mmがよく合います。こうすると線はたいてい2つのセンサーの下に入り、位置がなめらかに計算されます。
  • 3センサーのデジタルの配置:中央のセンサーを線の真上に、両側のセンサーを線の端のすぐ外に置きます。20 mmの線なら中心間で約18〜22 mmです。
  • 前方への距離:センサーを車輪の軸から4〜8 cm前に置きます。近すぎるとロボットがカーブに気づくのが遅れ、遠すぎると少し曲がっただけでもセンサーが大きく振れ、ロボットがふらつきます。
  • 周囲の光:日光やハロゲンランプにも赤外線が含まれます。黒いスカートやテープでセンサーを覆うと、値がずっと安定します。
よくある失敗は、センサーを斜めや高すぎる位置に取り付けることです。ロボットが最初のカーブで線を見失うなら、コードの前にセンサーの高さを確認しましょう。数ミリの違いでも値が半分になることがあります。光沢のあるビニールテープも赤外線を反射するので、黒い線が白に見えることがあります。できればつやのないテープか印刷した線を使いましょう。

配線:Arduino Uno、ESP32、Pico

下の表は、センサーのテストとPIDのスケッチにそのまま対応しています。3センサーのif-elseの例の配線は、その章で別に説明します。センサーには、ロボットの後ろから見て左から順に番号を付けます。一番左のセンサーが1番です。モータードライバにはL298Nを使います。各ピンの役割とHブリッジのしくみはL298NでDCモーターを制御するで詳しく解説しています。Picoにはアナログ入力が3つしかないので、Picoではセンサー1、3、5を使います。

接続Arduino UnoESP32Raspberry Pi Pico
センサー1(一番左)のAOA0GPIO 36GP26
センサー2のAOA1GPIO 39-
センサー3(中央)のAOA2GPIO 34GP27
センサー4のAOA3GPIO 35-
センサー5(一番右)のAOA4GPIO 32GP28
センサーのVCC5V3V33V3
L298NのENA(左のモーターの速度)D9GPIO 25GP15
L298NのIN1、IN2D8、D7GPIO 26、27GP14、GP13
L298NのENB(右のモーターの速度)D10GPIO 33GP10
L298NのIN3、IN4D5、D4GPIO 18、19GP11、GP12
スタートボタン(もう一方の足はGNDへ)D2GPIO 23GP16
GNDGNDGNDGND(電池とドライバと共通)
電池のマイナス端子、モータードライバのGND、ボードのGNDはすべてつなぐ必要があります。共通GNDがないとモーターがでたらめに動きます。モーターをボードの5Vピンから給電しないでください。ESP32とPicoでは、センサーを3V3から給電しましょう。5 Vで給電したモジュールのAO出力は5 Vまで上がることがあり、3.3 Vのアナログ入力を壊すことがあります。

モータードライバと電池の選び方

L298Nは安くて丈夫ですが、バイポーラトランジスタが1.5〜3 Vを食ってしまい、7.4 Vの電池パックからモーターに届くのは約5〜5.5 Vだけです。小さく速いロボットには、MOSFETを使ったTB6612FNGやDRV8833のほうが向いています。TB6612FNGならコードはそのままで、ENA、IN1、IN2の接続をPWMA、AIN1、AIN2に(BチャンネルではそれぞれPWMB、BIN1、BIN2に)つなぎ、STBYピンをVCCにつなぎます。DRV8833には別の速度(イネーブル)ピンがなく、PWM信号を直接INピンに入れるので、drive()関数を書き換える必要があります。電源には、直列にした18650リチウムイオン電池2本が最も実用的です。満充電で8.4 V、公称7.4 Vで、大きな電流も楽に出せます。9 Vの角形電池(PP3)はモーターの電流に耐えられず、すぐに切れてしまいます。電池がどのくらいもつかは電池寿命ツールで計算できます。

ドライバモーターの電圧1チャンネルあたりの電流電圧降下評価
L298N5〜35 V2 A1.5〜3 V丈夫だが、大きく効率が悪い
TB6612FNG4.5〜13.5 V1.2 A(ピーク3.2 A)とても小さいライントレースに最もコスパがよい
DRV88332.7〜10.8 V1.5 A(ピーク2 A)とても小さいN20モーターの小さなロボット
L9110S2.5〜12 V0.8 A小さい軽くて遅いロボットのみ
黄色のTTギア付きDCモーターと車輪
65 mmの車輪付きの黄色のTTギアモーター:ギアボックスで回転数を下げ、トルクを上げています。ライントレースとロボットカーのプロジェクトで最もよく使われるモーターです。

速さの簡単な例を見てみましょう。黄色のTTモーター(1:48)は、6 Vの無負荷で約200 rpmで回ります。65 mmの車輪の円周はπ × 65 ≈ 204 mmなので、ロボットは1分に最大200 × 0.204 ≈ 41 m、つまり1秒に約0.7 m進めます。ほとんどの学校のコースには十分すぎる速さで、本当の課題はその速さで線を見失わないことです。もっと速いロボットは、N20のマイクロギアモーターと軽いシャーシを使います。電池の電圧が下がるとモーターが遅くなり、調整したPIDの値がずれてきます。分圧回路で電池の電圧を測り、それに合わせて速度を補正するのは、発展的な改良です。

ステップ1:センサーをテストし、しきい値を見つける

モーターをつなぐ前に、センサーが何を見ているかを確かめましょう。下のスケッチは、5つのセンサーの生のアナログ値をタブ区切りで表示します。Arduino IDEで「ツール」>「シリアルプロッタ」を115200ボーで開くと、各センサーが1本の線で表示されます。手でゆっくりロボットを線の上で動かし、値がどう変わるかを見てみましょう。

C++ (Arduino IDE)
1// 5 sensörün ham değerlerini okur / reads the raw values of 5 sensors
2const int N = 5;
3const int SENSOR[N] = {A0, A1, A2, A3, A4};   // A0 en solda / A0 is the leftmost
4
5void setup() {
6  Serial.begin(115200);
7}
8
9void loop() {
10  for (int i = 0; i < N; i++) {
11    Serial.print(analogRead(SENSOR[i]));
12    Serial.print(i < N - 1 ? '\t' : '\n');    // Serial Plotter için sekme / tabs for the Serial Plotter
13  }
14  delay(50);
15}

5 Vでの一般的なTCRT5000モジュールの値は、白い面で30〜200、黒い線で600〜1000ですが、この数字はセンサーの高さ、床の素材、周囲の光で変わります。最も簡単なしきい値は、しきい値 = (白の値 + 黒の値) / 2です。たとえば白で80、黒で820なら、しきい値は450です。センサーごとに値が少しずつ違うので、PIDのスケッチではこれを自動で行います。キャリブレーション中に各センサーの最小値と最大値を記録し、その後のすべての値を(値 − min) × 1000 / (max − min)で0(白)〜1000(黒)に換算します。これで、どのセンサーでもしきい値は500になります。

デジタル(DO)出力のあるモジュールでは、基板のポテンショメーターでしきい値を設定します。センサーが黒い線の上にあるときはモジュールのLEDが消え、白い面の上にあるときは点くように半固定抵抗を回します。その2点のちょうど真ん中の位置が最も安定します。調整は、走らせるコースの上で、同じ照明のもとで行いましょう。

ステップ2:簡単なif-elseのアルゴリズム(センサー3個)

最初に動くロボットを作るなら、3つのセンサーの状態に応じて、いくつかの決まった動きから選ぶ方法が最もわかりやすいです。3チャンネルのモジュールのDO出力をA0(左)、A1(中央)、A2(右)につなぎます。Unoのアナログピンはデジタル入力としても使えます。ロジックは次の表のとおりです(1はセンサーが線を見ていることを表します)。

左中央右意味動き
010線が中央まっすぐ進む
110線が少し左左の車輪を遅くし、ゆるく左へ
100線が大きく左左の車輪を逆転し、鋭く左へ
011線が少し右右の車輪を遅くし、ゆるく右へ
001線が大きく右右の車輪を逆転し、鋭く右へ
111交差点かゴールラインまっすぐ通過する
000線を見失った最後に見た側へ曲がる
C++ (Arduino IDE)
1// 3 sensörlü basit çizgi izleyen / simple 3-sensor line follower
2const int ENA = 9, IN1 = 8, IN2 = 7;      // sol motor / left motor
3const int ENB = 10, IN3 = 5, IN4 = 4;     // sağ motor / right motor
4const int S_LEFT = A0, S_MID = A1, S_RIGHT = A2;   // sensörlerin DO çıkışları / sensor DO outputs
5const bool WHITE_LINE = false;            // siyah zemin + beyaz çizgi ise true / true for a white line on black
6
7const int SPEED = 150;                    // düz gidiş hızı / straight-line speed
8const int SOFT = 80;                      // hafif dönüşte iç teker / inner wheel on a soft turn
9const int BACK = -90;                     // sert dönüşte iç teker geri / inner wheel reverses on a sharp turn
10int lastSide = 0;                         // -1 sol, 1 sağ / -1 left, 1 right
11
12void drive(int left, int right) {         // her biri -255 … 255 / each -255 … 255
13  left = constrain(left, -255, 255);
14  right = constrain(right, -255, 255);
15  digitalWrite(IN1, left >= 0);  digitalWrite(IN2, left < 0);
16  digitalWrite(IN3, right >= 0); digitalWrite(IN4, right < 0);
17  analogWrite(ENA, abs(left));
18  analogWrite(ENB, abs(right));
19}
20
21void setup() {
22  int outputs[] = {ENA, IN1, IN2, ENB, IN3, IN4};
23  for (int p : outputs) pinMode(p, OUTPUT);
24  pinMode(S_LEFT, INPUT);
25  pinMode(S_MID, INPUT);
26  pinMode(S_RIGHT, INPUT);
27}
28
29void loop() {
30  bool l = digitalRead(S_LEFT) != WHITE_LINE;    // true = sensör çizgide / true = sensor on the line
31  bool m = digitalRead(S_MID) != WHITE_LINE;
32  bool r = digitalRead(S_RIGHT) != WHITE_LINE;
33
34  if (m && !l && !r) {                    // tam ortada: düz git / centered: go straight
35    drive(SPEED, SPEED);
36  } else if (l && r) {                    // kavşak ya da bitiş çizgisi: düz geç / junction or finish line: go straight
37    drive(SPEED, SPEED);
38  } else if (l && m) {                    // çizgi biraz solda / line slightly to the left
39    drive(SOFT, SPEED);  lastSide = -1;
40  } else if (r && m) {                    // çizgi biraz sağda / line slightly to the right
41    drive(SPEED, SOFT);  lastSide = 1;
42  } else if (l) {                         // çizgi çok solda / line far to the left
43    drive(BACK, SPEED);  lastSide = -1;
44  } else if (r) {                         // çizgi çok sağda / line far to the right
45    drive(SPEED, BACK);  lastSide = 1;
46  } else {                                // çizgi kayıp: son görülen tarafa dön / line lost: turn to the last seen side
47    if (lastSide < 0)      drive(BACK, SPEED);
48    else if (lastSide > 0) drive(SPEED, BACK);
49    else                   drive(SOFT, SOFT);
50  }
51}

lastSideの変数は、ロボットが最後にどちらの側で線を見たかを覚えています。急カーブで3つのセンサーがすべて線を外れても、ロボットはまっすぐさまよわず、その方向に曲がって線を探し直します。センサー2個なら、同じロジックはさらに簡単です。センサーは線の両側にあり、どちらも白を見ている間はまっすぐ進み、左が黒を見たら左に、右が黒を見たら右に曲がります。この方法の弱点は、動きが段階的なことです。ロボットはいくつかの決まった速さを切り替えるだけなので、直線でも少しジグザグし、速度を上げるとカーブでコースを外れます。

ステップ3:PIDでなめらかなライントレース

PIDは、ロボットが線からどれだけずれたかに比例した補正をかけます。まず、5つのセンサーから加重平均で線の位置を計算します。各センサーの0〜1000の値に自分の位置(0、1000、2000、3000、4000)を掛け、結果を足し、値の合計で割ります。結果は0〜4000の数で、線がちょうど中央にあるときは2000です。たとえばセンサーの値が0、0、300、900、0なら、位置は(300 × 2000 + 900 × 3000) / 1200 = 2750です。誤差 = 位置 − 2000で、プラスの値は線が右に、マイナスの値は左にあることを意味します。

補正は3つの項の合計です:補正 = Kp × 誤差 + Ki × 誤差の合計 + Kd × (誤差 − 前回の誤差)。P項は、ずれが大きいほど強く曲げます。D項は誤差の変化の速さを見て、ロボットが線に近づくとブレーキをかけ、振動を抑えます。I項は長い間片側に残る小さな誤差を直しますが、ライントレースではふつうゼロにしておきます。そのあと、左のモーターを基本速度 + 補正、右のモーターを基本速度 − 補正で回します。たとえば誤差が800、Kp = 0.06なら補正は48で、基本速度140では左のモーターが188、右が92で回り、ロボットはなめらかに右に曲がります。

C++ (Arduino IDE)
1// PID çizgi izleyen: Arduino Uno + 5 analog sensör + L298N
2// PID line follower: Arduino Uno + 5 analog sensors + L298N
3const int ENA = 9, IN1 = 8, IN2 = 7;      // sol motor / left motor
4const int ENB = 10, IN3 = 5, IN4 = 4;     // sağ motor / right motor
5const int BUTTON = 2;                     // diğer bacağı GND'ye / other leg to GND
6const int N = 5;
7const int SENSOR[N] = {A0, A1, A2, A3, A4};   // A0 en solda / A0 is the leftmost
8const bool WHITE_LINE = false;            // siyah zemin + beyaz çizgi ise true / true for a white line on black
9const int CENTER = (N - 1) * 500;         // orta konum: 2000 / center position: 2000
10
11float Kp = 0.06, Ki = 0.0, Kd = 0.6;      // başlangıç değerleri / starting values
12int baseSpeed = 140;                      // düzlük hızı (0-255) / straight-line speed (0-255)
13const unsigned long PERIOD_MS = 5;        // sabit döngü süresi / fixed loop period
14
15int minVal[N], maxVal[N];
16int lastPosition = CENTER;
17float integral = 0, lastError = 0;
18unsigned long lastTime = 0;
19
20void drive(int left, int right) {         // her biri -255 … 255 / each -255 … 255
21  left = constrain(left, -255, 255);
22  right = constrain(right, -255, 255);
23  digitalWrite(IN1, left >= 0);  digitalWrite(IN2, left < 0);
24  digitalWrite(IN3, right >= 0); digitalWrite(IN4, right < 0);
25  analogWrite(ENA, abs(left));
26  analogWrite(ENB, abs(right));
27}
28
29void waitButton() {
30  while (digitalRead(BUTTON) == HIGH) {}  // basılmasını bekle / wait for a press
31  delay(30);
32  while (digitalRead(BUTTON) == LOW) {}   // bırakılmasını bekle / wait for the release
33  delay(30);
34}
35
36void calibrate() {                        // 5 sn: robotu çizginin üstünde sağa sola gezdir / 5 s: sweep the robot across the line
37  for (int i = 0; i < N; i++) { minVal[i] = 1023; maxVal[i] = 0; }
38  digitalWrite(LED_BUILTIN, HIGH);
39  unsigned long start = millis();
40  while (millis() - start < 5000) {
41    for (int i = 0; i < N; i++) {
42      int v = analogRead(SENSOR[i]);
43      if (v < minVal[i]) minVal[i] = v;
44      if (v > maxVal[i]) maxVal[i] = v;
45    }
46  }
47  digitalWrite(LED_BUILTIN, LOW);
48}
49
50int readPosition() {                      // 0 (en sol) … 4000 (en sağ) / 0 (leftmost) … 4000 (rightmost)
51  long sum = 0, weighted = 0;
52  bool onLine = false;
53  for (int i = 0; i < N; i++) {
54    int range = max(maxVal[i] - minVal[i], 1);
55    long v = (long)(analogRead(SENSOR[i]) - minVal[i]) * 1000 / range;
56    v = constrain(v, 0L, 1000L);          // 0 = beyaz, 1000 = siyah / 0 = white, 1000 = black
57    if (WHITE_LINE) v = 1000 - v;
58    if (v > 500) onLine = true;           // eşik: kalibrasyonun ortası / threshold: middle of the calibration
59    if (v > 50) {                         // küçük gürültüyü yok say / ignore small noise
60      weighted += v * (i * 1000L);
61      sum += v;
62    }
63  }
64  if (!onLine) {                          // çizgi kayıp: son görülen tarafa dön / line lost: turn to the last seen side
65    return (lastPosition < CENTER) ? 0 : (N - 1) * 1000;
66  }
67  lastPosition = weighted / sum;
68  return lastPosition;
69}
70
71void setup() {
72  int outputs[] = {ENA, IN1, IN2, ENB, IN3, IN4, LED_BUILTIN};
73  for (int p : outputs) pinMode(p, OUTPUT);
74  pinMode(BUTTON, INPUT_PULLUP);
75  drive(0, 0);
76  waitButton();                           // 1. basış: kalibrasyon / 1st press: calibration
77  calibrate();
78  waitButton();                           // 2. basış: yarış / 2nd press: race
79  lastTime = millis();
80}
81
82void loop() {
83  if (millis() - lastTime < PERIOD_MS) return;
84  lastTime += PERIOD_MS;
85
86  float error = readPosition() - CENTER;  // artı: çizgi sağda / positive: line is to the right
87  integral = constrain(integral + error, -20000.0f, 20000.0f);   // birikmeyi sınırla / anti-windup
88  float derivative = error - lastError;
89  lastError = error;
90
91  int correction = (int)(Kp * error + Ki * integral + Kd * derivative);
92  drive(baseSpeed + correction, baseSpeed - correction);
93}

書き込んだら、ボタンを1回押します。基板のLEDが5秒間点きます。その間に、すべてのセンサーが黒と白の両方を見るよう、手でロボットを線の左右に何回か滑らせます。LEDが消えたら、ロボットを線の上に置き、もう一度ボタンを押します。ループはmillis()で5 msごとに回ります。周期を一定にすると、D項がいつも同じスケールに保たれます。線が完全に消えたら、readPosition()は最後に見た側に応じて0か4000を返し、ロボットは最も鋭い旋回で線を探します。ボタンの配線はArduinoに押しボタンをつなぐ方法を、delayを使わないタイミングはArduinoのmillis()とdelay()をご覧ください。

KpとKdの調整方法

  1. 基本速度を低く(たとえば100)して、KiとKdをゼロにします。
  2. Kpを小さく(0.02)始め、ロボットがカーブを曲がれるようになるまで少しずつ上げます。ロボットが線のまわりで速く規則的に振動し始めたら、それがKpの上限です。その値より少し下にしましょう。
  3. Kdは、Kpの約10倍から始め、振動が消えるまで上げます。Kdが大きすぎると、ロボットが細かく震え、モーターがうなります。
  4. ロボットが直線でまっすぐ、カーブでなめらかに走るようになったら、基本速度を10〜20上げ、ステップ2と3を繰り返します。
  5. 長い直線でロボットが片側にずれ続けるときだけ、とても小さな値(0.0001など)でKiを試します。
  6. 見つけた値とそのときの電池の電圧をメモしておきましょう。電池が減ると、同じ値でも違う動きになります。
この記事のスケール(位置0〜4000、PWM 0〜255、5 msのループ)では、Kpは0.04〜0.08、Kdは0.4〜1.2がよい出発点です。調整中はループの中にSerial.printを入れないでください。表示するたびにループが遅くなり、D項が乱れます。値を見る必要があるなら、表示は200 msに1回までにしましょう。

ESP32でライントレース:スマートフォンからPIDを調整する

ESP32の大きな利点は、毎回コードを書き込み直さなくても、スマートフォンからPIDの値を変えられることです。ロジックはUno版と同じで、違いは次のとおりです。ESP32のアナログ入力は12ビット(0〜4095)で、センサーはADC1のピン、GPIO 32〜39につなぐ必要があり(ADC2のピンはWi-Fiがオンの間は読めません)、センサーは3.3 Vで給電します。analogWrite()は、ESP32 Arduinoコアの2.xと3.xのどちらでも動きます。AndroidのスマートフォンにSerial Bluetooth Terminalなどのアプリを入れ、「CizgiRobot」というデバイスに接続し、p0.07、d0.9、s160のような命令を送ります。ロボットは新しい値をすぐに使い、送り返してきます。クラシックBluetoothは元のESP32にしかなく(S3やC3にはありません)、iPhoneはこの種のシリアル接続に対応していません。ESP32が初めてなら、先にESP32とは?を読みましょう。

C++ (Arduino IDE)
1// ESP32 PID çizgi izleyen + Bluetooth ile canlı ayar
2// ESP32 PID line follower + live tuning over Bluetooth
3#include "BluetoothSerial.h"
4
5BluetoothSerial SerialBT;
6
7const int ENA = 25, IN1 = 26, IN2 = 27;   // sol motor / left motor
8const int ENB = 33, IN3 = 18, IN4 = 19;   // sağ motor / right motor
9const int BUTTON = 23;                    // diğer bacağı GND'ye / other leg to GND
10const int LED_PIN = 2;                    // DevKit üzerindeki LED / onboard LED on the DevKit
11const int N = 5;
12const int SENSOR[N] = {36, 39, 34, 35, 32};   // yalnız ADC1 pinleri / ADC1 pins only
13const bool WHITE_LINE = false;            // siyah zemin + beyaz çizgi ise true / true for a white line on black
14const int CENTER = (N - 1) * 500;
15
16float Kp = 0.06, Ki = 0.0, Kd = 0.6;
17int baseSpeed = 140;
18const unsigned long PERIOD_MS = 5;
19
20int minVal[N], maxVal[N];
21int lastPosition = CENTER;
22float integral = 0, lastError = 0;
23unsigned long lastTime = 0;
24
25void drive(int left, int right) {         // her biri -255 … 255 / each -255 … 255
26  left = constrain(left, -255, 255);
27  right = constrain(right, -255, 255);
28  digitalWrite(IN1, left >= 0);  digitalWrite(IN2, left < 0);
29  digitalWrite(IN3, right >= 0); digitalWrite(IN4, right < 0);
30  analogWrite(ENA, abs(left));
31  analogWrite(ENB, abs(right));
32}
33
34void waitButton() {
35  while (digitalRead(BUTTON) == HIGH) {}
36  delay(30);
37  while (digitalRead(BUTTON) == LOW) {}
38  delay(30);
39}
40
41void calibrate() {                        // ESP32 ADC 12 bit: 0-4095 / ESP32 ADC is 12-bit: 0-4095
42  for (int i = 0; i < N; i++) { minVal[i] = 4095; maxVal[i] = 0; }
43  digitalWrite(LED_PIN, HIGH);
44  unsigned long start = millis();
45  while (millis() - start < 5000) {
46    for (int i = 0; i < N; i++) {
47      int v = analogRead(SENSOR[i]);
48      if (v < minVal[i]) minVal[i] = v;
49      if (v > maxVal[i]) maxVal[i] = v;
50    }
51  }
52  digitalWrite(LED_PIN, LOW);
53}
54
55int readPosition() {
56  long sum = 0, weighted = 0;
57  bool onLine = false;
58  for (int i = 0; i < N; i++) {
59    int range = max(maxVal[i] - minVal[i], 1);
60    long v = (long)(analogRead(SENSOR[i]) - minVal[i]) * 1000 / range;
61    v = constrain(v, 0L, 1000L);
62    if (WHITE_LINE) v = 1000 - v;
63    if (v > 500) onLine = true;
64    if (v > 50) {
65      weighted += v * (i * 1000L);
66      sum += v;
67    }
68  }
69  if (!onLine) {
70    return (lastPosition < CENTER) ? 0 : (N - 1) * 1000;
71  }
72  lastPosition = weighted / sum;
73  return lastPosition;
74}
75
76void readTuning() {                       // telefondan "p0.07", "d0.9", "s160" gibi komutlar / commands like "p0.07", "d0.9", "s160" from the phone
77  if (!SerialBT.available()) return;
78  char cmd = SerialBT.read();
79  if (cmd != 'p' && cmd != 'i' && cmd != 'd' && cmd != 's') return;   // satır sonlarını atla / skip line endings
80  float value = SerialBT.parseFloat();
81  if (cmd == 'p') Kp = value;
82  if (cmd == 'i') { Ki = value; integral = 0; }
83  if (cmd == 'd') Kd = value;
84  if (cmd == 's') baseSpeed = (int)value;
85  SerialBT.printf("Kp=%.3f Ki=%.4f Kd=%.3f speed=%d\n", Kp, Ki, Kd, baseSpeed);
86}
87
88void setup() {
89  int outputs[] = {ENA, IN1, IN2, ENB, IN3, IN4, LED_PIN};
90  for (int p : outputs) pinMode(p, OUTPUT);
91  pinMode(BUTTON, INPUT_PULLUP);
92  drive(0, 0);
93  SerialBT.begin("CizgiRobot");           // telefonda görünecek ad / name shown on the phone
94  SerialBT.setTimeout(20);                // parseFloat robotu bekletmesin / keep parseFloat from blocking
95  waitButton();
96  calibrate();
97  waitButton();
98  lastTime = millis();
99}
100
101void loop() {
102  readTuning();
103  if (millis() - lastTime < PERIOD_MS) return;
104  lastTime += PERIOD_MS;
105
106  float error = readPosition() - CENTER;
107  integral = constrain(integral + error, -20000.0f, 20000.0f);
108  float derivative = error - lastError;
109  lastError = error;
110
111  int correction = (int)(Kp * error + Ki * integral + Kd * derivative);
112  drive(baseSpeed + correction, baseSpeed - correction);
113}
競技の規則によっては、ロボットにWi-Fi、Bluetooth、RFのモジュールを載せること、さらにはWi-Fi付きのボードそのものを禁止しているものもあります。最も安全なのは、調整と練習にはESP32を使い、本番はArduino Uno、Nano、Wi-FiなしのPicoで出ることです。迷ったら主催者に確認しましょう。

Raspberry Pi PicoとMicroPythonでライントレース

Picoでユーザーが使えるアナログ入力は3つ(GP26、GP27、GP28)だけなので、下のコードは3つのアナログセンサーで動き、位置を0〜2000で計算します。5つのセンサーを使いたいなら、デジタル出力の5チャンネルのモジュールを好きな5本のGPIOにつなぎ、各センサーを0か1000として数えます。加重平均は粗くなりますが、それでも動きます。read_u16()は0〜65535の値を返し、モーターの速度はパーセント(-100〜100)で指定します。セットアップと最初の一歩はRaspberry Pi Picoとは?をご覧ください。

MicroPython
1from machine import Pin, PWM, ADC
2import time
3
4ena = PWM(Pin(15)); ena.freq(1000)        # sol motor hızı / left motor speed
5in1 = Pin(14, Pin.OUT); in2 = Pin(13, Pin.OUT)
6enb = PWM(Pin(10)); enb.freq(1000)        # sağ motor hızı / right motor speed
7in3 = Pin(11, Pin.OUT); in4 = Pin(12, Pin.OUT)
8button = Pin(16, Pin.IN, Pin.PULL_UP)     # diğer bacağı GND'ye / other leg to GND
9led = Pin("LED", Pin.OUT)
10sensors = [ADC(26), ADC(27), ADC(28)]     # sol, orta, sağ / left, middle, right
11WHITE_LINE = False                        # siyah zemin + beyaz çizgi ise True / True for a white line on black
12
13N = len(sensors)
14CENTER = (N - 1) * 500                    # orta konum: 1000 / center position: 1000
15Kp, Ki, Kd = 0.05, 0.0, 0.4
16BASE = 45                                 # taban hız (%) / base speed (%)
17min_val = [65535] * N
18max_val = [0] * N
19last_pos = CENTER
20
21def drive(left, right):                   # her biri -100 … 100 / each -100 … 100
22    left = max(-100, min(100, left))
23    right = max(-100, min(100, right))
24    in1.value(left >= 0); in2.value(left < 0)
25    in3.value(right >= 0); in4.value(right < 0)
26    ena.duty_u16(int(abs(left) * 655.35))
27    enb.duty_u16(int(abs(right) * 655.35))
28
29def wait_button():
30    while button.value():                 # basılmasını bekle / wait for a press
31        time.sleep_ms(10)
32    time.sleep_ms(30)
33    while not button.value():             # bırakılmasını bekle / wait for the release
34        time.sleep_ms(10)
35    time.sleep_ms(30)
36
37def calibrate():                          # 5 sn sağa sola gezdir / sweep for 5 s
38    led.on()
39    start = time.ticks_ms()
40    while time.ticks_diff(time.ticks_ms(), start) < 5000:
41        for i, s in enumerate(sensors):
42            v = s.read_u16()
43            min_val[i] = min(min_val[i], v)
44            max_val[i] = max(max_val[i], v)
45    led.off()
46
47def read_position():                      # 0 (sol) … 2000 (sağ) / 0 (left) … 2000 (right)
48    global last_pos
49    total = weighted = 0
50    on_line = False
51    for i, s in enumerate(sensors):
52        rng = max(1, max_val[i] - min_val[i])
53        v = max(0, min(1000, (s.read_u16() - min_val[i]) * 1000 // rng))
54        if WHITE_LINE:
55            v = 1000 - v
56        if v > 500:
57            on_line = True
58        if v > 50:
59            weighted += v * i * 1000
60            total += v
61    if not on_line:                       # çizgi kayıp / line lost
62        return 0 if last_pos < CENTER else (N - 1) * 1000
63    last_pos = weighted // total
64    return last_pos
65
66drive(0, 0)
67wait_button()                             # 1. basış: kalibrasyon / 1st press: calibration
68calibrate()
69wait_button()                             # 2. basış: yarış / 2nd press: race
70integral = 0
71last_error = 0
72while True:
73    t0 = time.ticks_ms()
74    error = read_position() - CENTER
75    integral = max(-10000, min(10000, integral + error))
76    correction = Kp * error + Ki * integral + Kd * (error - last_error)
77    last_error = error
78    drive(BASE + correction, BASE - correction)
79    while time.ticks_diff(time.ticks_ms(), t0) < 5:   # 5 ms sabit döngü / fixed 5 ms loop
80        pass

競技のルールとコース

ライントレース競技のルールは主催者や年によって違いますが、基本の考え方は同じです。ロボットは線を外れずに、最短の時間でコースを走り切らなければなりません。たとえば、トルコの全国大会であるMEBロボット競技の2024年の初心者レベルのライントレースの規則では、コースは黒の背景に20 ± 2 mmの白い線です。ロボットの大きさは車輪を含めて最大280 × 180 mm、重さは電池を含めて最大1 kgで、動作電圧は12 Vを超えてはならず、プラスチックのギアを持つ6〜12 VのL字型のギア付きDCモーター2個、直径65 mm以下の車輪、最大8個のセンサーのアレイを使う必要があります。コースには勾配13度の橋と、真ん中をまっすぐ横切ると20秒のペナルティになる四角い区間があります。車体が完全に黒の背景に出たロボットはコースアウトとみなされ、線の上で30秒より長く止まったロボットは失格です。ほかの学校や地域の大会では逆に、白の背景に20 ± 2 mmの黒い線を使い、500グラム以下の小さなロボットを求めることもあります。つまり、ロボットを設計する前に、必ずその年の規則を読みましょう。

  • 白黒反転のコース:黒の背景に白い線なら、スケッチのWHITE_LINEの変数をtrueにします。コースの途中で色が入れ替わるなら、外側のセンサーが黒を、中央のセンサーが白を見始めたらモードを切り替えるチェックを追加できます。
  • 90度のカーブ:カーブの前で基本速度を下げるか、内側の車輪を逆転させます。この記事のスケッチでは、補正が基本速度より大きくなると、内側の車輪がすでに逆転するようになっています。
  • 交差点:すべてのセンサーが同時に線を見たら、まっすぐ通過します。方向を選ぶ必要があるなら、交差点を数えるステートマシンを作りましょう。
  • 破線:線が少しの間消えたら、すぐに曲がるのではなく、100〜200 msまっすぐ進み続けてみましょう。
  • 坂と橋:坂ではセンサーと床の距離が変わります。センサーバーを低く、車輪の近くに保ち、坂では少し速度を落としましょう。
  • 練習用のコース:白いつやのない厚紙かスチレンボードに、19 mmのつやのない黒いビニールテープを貼ります。最初のうちは、半径15 cmより急なカーブは作らないようにしましょう。

よくある問題と対処法

問題考えられる原因対処法
直線でジグザグするKpが高すぎる、Kdが低すぎる、または速度が高すぎるKpを約20%下げ、Kdを上げる。必要なら基本速度を下げる
カーブでコースを外れる速度が高すぎる、センサーアレイが狭すぎる、または線を見失ったときの反応が違う基本速度を下げ、内側の車輪を逆転させ、線を見失ったら最後に見た側に曲がる
ロボットがいつも片側に寄るモーターの速さが違う、またはセンサーバーが中央にないセンサーを中央にし、速いほうのモーターを小さな係数(たとえば0.95)で下げる
センサーがどこでも黒か白を見るキャリブレーションしていない、センサーが高すぎる、または半固定抵抗を調整していない高さを3〜5 mmに下げ、コースの上でキャリブレーションし直す
モーターが動き出すとArduinoがリセットする電池の電圧が一瞬下がる、または共通GNDが不十分ボードを別に給電するか電源に470 µFのコンデンサを入れ、線を短くする
電池が減るとロボットの動きが変わる電圧とともにモーターの速さが下がる電池の電圧を測って速度を補正するか、毎回満充電の電池を付ける
家では動くのに会場ではうまくいかない周囲の光や光沢のある床が赤外線を乱す黒いスカートでセンサーを覆い、競技の照明のもとでキャリブレーションする
問題を絞り込む一番速い方法は、車輪を床から浮かせ、手でロボットを線の上で動かしてみることです。車輪が正しい向きに速くなれば、センサーとコードは正常で、問題は機械的な部分か電源にあります。リセットの問題の詳しい診断はArduinoがリセットを繰り返すときをご覧ください。

次のステップとプロジェクトのアイデア

  • 障害物を避けるライントレース:前にHC-SR04を追加し、コース上の障害物をよけて走らせます。判断のロジックは障害物を避けるロボットにあります。
  • Bluetoothモード:ボタンでライントレースからスマートフォンでの操作に切り替えます。HC-05 Bluetoothモジュールで簡単に追加できます。
  • ラップタイマー:スタートラインを検知し、ラップタイムをOLEDディスプレイに表示するロボットです。
  • 迷路を解くロボット:交差点を数えて覚え、2回目の走行で最短の道をたどるロボットです。
  • 倉庫ロボット:線に沿ったステーションで止まり、サーボで荷物を降ろす小さなAGVの模型です。
  • しっかりした土台:シャーシ、モーター、電池をまだ組み立てていなければ、Arduinoでロボットカーのガイドから始めましょう。

ライントレースロボットは、センサー、アルゴリズム、モーター、電源を一度に教えてくれ、調整のたびに少しずつ速くなります。まず簡単なif-elseの版でロボットを動かし、それからPIDに切り替えて、辛抱強く値を調整しましょう。PWMでモーターの速さが決まるしくみが気になるなら、次はPWMとは?を読むのがおすすめです。BlueGraysアプリでは、TCRT5000での白黒の面の検出、Bluetoothで操作するL298Nの車、障害物を避けるロボットのプロジェクトを、回路図とコード付きでステップごとに学べます。

よくある質問

ライントレースロボットにはセンサーが何個必要ですか?

動くロボットならセンサー2個で十分ですが、3個にすると簡単なif-elseのスケッチでもずっと安定した結果になります。なめらかで速いライントレースには、5個か8個のアナログセンサーとPIDがすすめられます。多くの競技ではセンサーは最大8個まで認められています。

ライントレースロボットがジグザグするのはなぜですか?

最も多い原因は、Kpが高すぎてKdが低すぎることです。ロボットがずれのたびに補正しすぎて、線の反対側まで行ってしまいます。Kpを少し下げる、Kdを上げる、基本速度を下げる、センサーを車輪の4〜8 cm前に移す、のどれもが振動を減らします。簡単なif-elseのコードでは、ロボットがいくつかの決まった速さを切り替えるので、少しのジグザグは普通です。

PIDなしでライントレースロボットは作れますか?

はい。簡単なif-elseのスケッチと2〜3個のセンサーでもロボットは線をたどり、学校のプロジェクトならそれで十分なことがよくあります。ただ、速度を上げると、カーブで線を見失い、直線でふらつきます。競技で上位を目指すなら、PIDに切り替える必要があります。

ライントレースロボットに最適な電池は?

最も実用的なのは、直列にした18650リチウムイオン電池2本です。公称7.4 Vで、モーターが流す大きな電流も楽に供給できます。軽いロボットでは2SのLiPoパックも使われます。9 Vの角形電池(PP3)はモーターの電流を出せず、すぐに切れてしまいます。

黒の背景に白い線のときは、コードをどう変えますか?

センサーの値を反転するだけです。この記事のスケッチでは、WHITE_LINEをtrueにすると正規化した値が1000 − 値になり、ロボットは白い線をたどります。デジタルのモジュールでは、同じ変数でDOの読み取りが反転します。それでも、走らせるコースの上でキャリブレーションはしましょう。

TCRT5000センサーは床からどのくらいの高さにすればいいですか?

TCRT5000は約2.5 mmで最もよく働き、実際にはセンサーと床の間を3〜5 mmあけるとよい結果になります。1 cmを超えると黒と白の差が大きく縮み、ロボットが線を見失います。坂のあるコースでは、センサーバーを車輪の近くに置くと、距離の変化が小さくなります。

全プロジェクトを見る
TCRT5000で床面の白黒を判別
#78中級
プレミアム

TCRT5000で床面の白黒を判別

ライントレースロボットの目となる小さなセンサーで、床面が黒か白かを判別できます。TCRT5000センサーは目に見えない光を床面に当て、戻ってくる光の量を調べます。白い面では緑のLED、黒い面では赤のLEDが点灯します。自分でロボットを作る前に学んでおきたい大切なステップです。

4.7(40)
49
TCRT5000で反射(近接)を読み取る
#77中級
プレミアム

TCRT5000で反射(近接)を読み取る

目に見えない光を出し、その反射量を測る小さなセンサーで、前方の表面を検出できます。物体や明るい色の表面にセンサーを近づけるほど戻ってくる光が増え、Arduinoがその値を0.5秒ごとに画面に数値で表示します。ライントレースロボットや非接触式の水栓には、このようなセンサーが使われています。

4.6(33)
43
L298Nで作るBluetooth操作の車
#80上級
プレミアム

L298Nで作るBluetooth操作の車

スマートフォンをリモコンにして、自作のロボットカーを操作できます。ArduinoはBluetoothモジュールを通じてスマートフォンのリモコンアプリから指令を受け取り、モータードライバーで車輪を回します。前進、後退、左右や斜めへの移動ができ、速度もスマートフォンから調整できます。停止ボタンを押すと、短いブレーキ動作でモーターがすぐに止まります。

5.0(38)
65
障害物回避ロボット
#81上級
プレミアム

障害物回避ロボット

自分で進む道を見つける、賢い車を作ってみませんか?障害物回避ロボットは、前面の超音波センサーで周囲を常に調べます。障害物を見つけると停止し、サーボモーターで左右を確認して、最も障害物の少ない方向を選んで進みます。自動運転の基礎を学ぶのにぴったりの入門プロジェクトです。

5.0(38)
49
HC-SR04で距離を測定(シリアルモニタ表示)
#27初級

HC-SR04で距離を測定(シリアルモニタ表示)

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

4.6(46)
71
ボタンでLEDを点灯(押している間のみ)
#9初級

ボタンでLEDを点灯(押している間のみ)

呼び鈴のように、押している間は点灯し、離すと消灯します。Arduinoはボタンの状態を常に読み取り、押されている間はLEDを点灯させ、指を離した瞬間に消灯させます。このシンプルな回路で、ボードが外部からの操作を検知する仕組みを学べます。

4.8(113)
229
ESP32のPWMでLEDをフェード
#110初級

ESP32のPWMでLEDをフェード

LEDの明るさを0から最大へ、最大から0へと滑らかに変化させます。Arduinoと同じようにanalogWrite()でPWMを制御します。ESP32 Arduino Core 3.0+では同じ構文がそのまま使えます。ピンの定義とpinMode、analogWriteだけで実現できます。

4.8(36)
54
Raspberry Pi PicoのPWMでLEDをフェード(呼吸のような明滅)
#160初級

Raspberry Pi PicoのPWMでLEDをフェード(呼吸のような明滅)

LEDを単に点滅させるだけでなく、呼吸するように明滅させます!Raspberry Pi PicoのPWM(パルス幅変調)を使い、スリープ中のMacBookのランプのように、明るさをゆっくり上げてからゆっくり下げます。machine.PWMクラス、duty_u16()(0–65535の16ビットの明るさ)、PWM周波数(freq(1000)で1秒間に1000回オン・オフし、ちらつきを感じさせない)、range()による段階的なループ(増加と減少)、デジタルピンからのアナログのような出力を学びます。PWMはRGBの混色やモーターなどに使われる、組み込み開発の基本技術です。

0.0(0)
4