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で動くコードに加え、モータードライバと電池の選び方、一般的な競技のルール、最も多い問題の対処法も紹介します。

ライントレースロボットのしくみ
白い面は赤外線のほとんどを反射し、つやのない黒い面はほとんどを吸収します。ロボットの下のセンサーはこの差を測り、ロボットから見て線がどこにあるかを求めます。ロボットにハンドルはなく、差動駆動と呼ばれる方法で曲がります。左の車輪が右より速く回るとロボットは右に曲がり、遅く回ると左に曲がります。プログラムは1秒に何百回も同じループを回します。センサーを読み、線がどれだけずれたかを求め、それに応じて2つのモーターの速さを補正します。この閉ループが速く安定して回るほど、ロボットはなめらかに動きます。
| 部品 | おすすめ | メモ |
|---|---|---|
| 制御ボード | Arduino UnoまたはNano | Nanoのほうが小さなシャーシに収めやすい。ESP32やPicoでも可 |
| ラインセンサー | 3チャンネルか5チャンネルのTCRT5000アレイ、QTR-8A | アナログ出力のアレイのほうがPIDでよい結果になる |
| モータードライバ | L298NまたはTB6612FNG | TB6612FNGのほうが小さく効率がよい |
| モーター | ギア付きDCモーター2個(TT 1:48かN20) | 2つのモーターは同じ型番・同じ速さにする |
| 車輪 | 駆動輪2個とキャスター1個 | 65 mmの車輪が最も一般的 |
| 電源 | 18650リチウムイオン電池2本(7.4 V)とスイッチ | 9 Vの角形電池(PP3)はモーターには弱すぎる |
| その他 | シャーシ、押しボタン、ジャンパーワイヤー | ボタンがあるとキャリブレーションとスタートが楽になる |




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 Uno | ESP32 | Raspberry Pi Pico |
|---|---|---|---|
| センサー1(一番左)のAO | A0 | GPIO 36 | GP26 |
| センサー2のAO | A1 | GPIO 39 | - |
| センサー3(中央)のAO | A2 | GPIO 34 | GP27 |
| センサー4のAO | A3 | GPIO 35 | - |
| センサー5(一番右)のAO | A4 | GPIO 32 | GP28 |
| センサーのVCC | 5V | 3V3 | 3V3 |
| L298NのENA(左のモーターの速度) | D9 | GPIO 25 | GP15 |
| L298NのIN1、IN2 | D8、D7 | GPIO 26、27 | GP14、GP13 |
| L298NのENB(右のモーターの速度) | D10 | GPIO 33 | GP10 |
| L298NのIN3、IN4 | D5、D4 | GPIO 18、19 | GP11、GP12 |
| スタートボタン(もう一方の足はGNDへ) | D2 | GPIO 23 | GP16 |
| GND | GND | GND | GND(電池とドライバと共通) |
モータードライバと電池の選び方
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チャンネルあたりの電流 | 電圧降下 | 評価 |
|---|---|---|---|---|
| L298N | 5〜35 V | 2 A | 1.5〜3 V | 丈夫だが、大きく効率が悪い |
| TB6612FNG | 4.5〜13.5 V | 1.2 A(ピーク3.2 A) | とても小さい | ライントレースに最もコスパがよい |
| DRV8833 | 2.7〜10.8 V | 1.5 A(ピーク2 A) | とても小さい | N20モーターの小さなロボット |
| L9110S | 2.5〜12 V | 0.8 A | 小さい | 軽くて遅いロボットのみ |

速さの簡単な例を見てみましょう。黄色の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本の線で表示されます。手でゆっくりロボットを線の上で動かし、値がどう変わるかを見てみましょう。
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になります。
ステップ2:簡単なif-elseのアルゴリズム(センサー3個)
最初に動くロボットを作るなら、3つのセンサーの状態に応じて、いくつかの決まった動きから選ぶ方法が最もわかりやすいです。3チャンネルのモジュールのDO出力をA0(左)、A1(中央)、A2(右)につなぎます。Unoのアナログピンはデジタル入力としても使えます。ロジックは次の表のとおりです(1はセンサーが線を見ていることを表します)。
| 左 | 中央 | 右 | 意味 | 動き |
|---|---|---|---|---|
| 0 | 1 | 0 | 線が中央 | まっすぐ進む |
| 1 | 1 | 0 | 線が少し左 | 左の車輪を遅くし、ゆるく左へ |
| 1 | 0 | 0 | 線が大きく左 | 左の車輪を逆転し、鋭く左へ |
| 0 | 1 | 1 | 線が少し右 | 右の車輪を遅くし、ゆるく右へ |
| 0 | 0 | 1 | 線が大きく右 | 右の車輪を逆転し、鋭く右へ |
| 1 | 1 | 1 | 交差点かゴールライン | まっすぐ通過する |
| 0 | 0 | 0 | 線を見失った | 最後に見た側へ曲がる |
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で回り、ロボットはなめらかに右に曲がります。
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の調整方法
- 基本速度を低く(たとえば100)して、KiとKdをゼロにします。
- Kpを小さく(0.02)始め、ロボットがカーブを曲がれるようになるまで少しずつ上げます。ロボットが線のまわりで速く規則的に振動し始めたら、それがKpの上限です。その値より少し下にしましょう。
- Kdは、Kpの約10倍から始め、振動が消えるまで上げます。Kdが大きすぎると、ロボットが細かく震え、モーターがうなります。
- ロボットが直線でまっすぐ、カーブでなめらかに走るようになったら、基本速度を10〜20上げ、ステップ2と3を繰り返します。
- 長い直線でロボットが片側にずれ続けるときだけ、とても小さな値(0.0001など)でKiを試します。
- 見つけた値とそのときの電池の電圧をメモしておきましょう。電池が減ると、同じ値でも違う動きになります。
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とは?を読みましょう。
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}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とは?をご覧ください。
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のコンデンサを入れ、線を短くする |
| 電池が減るとロボットの動きが変わる | 電圧とともにモーターの速さが下がる | 電池の電圧を測って速度を補正するか、毎回満充電の電池を付ける |
| 家では動くのに会場ではうまくいかない | 周囲の光や光沢のある床が赤外線を乱す | 黒いスカートでセンサーを覆い、競技の照明のもとでキャリブレーションする |
次のステップとプロジェクトのアイデア
- 障害物を避けるライントレース:前に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で床面の白黒を判別
ライントレースロボットの目となる小さなセンサーで、床面が黒か白かを判別できます。TCRT5000センサーは目に見えない光を床面に当て、戻ってくる光の量を調べます。白い面では緑のLED、黒い面では赤のLEDが点灯します。自分でロボットを作る前に学んでおきたい大切なステップです。

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

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

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

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

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

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

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の混色やモーターなどに使われる、組み込み開発の基本技術です。
ほかの記事
BlueGraysアプリ
この回路をスマホでステップごとに作る
回路図、コード、部品リストを1つの画面で確認できます。Arduino、ESP32、Picoのプロジェクトを200件以上用意しています。

