PTENES
MODULE 2.6 · PROJECT

🏁 Project: PID line follower

The project that wraps up Track 2 brings it all together: a 5-sensor IR array calculates where the line is, a steering PID corrects the wheels 200 times per second, calibration adapts to your track, and microSD telemetry shows in numbers whether the tuning improved performance.

6
Topics
Intermediate
Level
Project
Type
Rover + IR array
Hardware
0%
0 of 0 topics read · Section 1 of 6

Detailed content

1

🛤️ The track and sensor array

The simplest track uses 19 mm black electrical tape on a matte white background (poster board, canvas, or light-colored flooring), with curves of at least 10 cm radius to start. At the front of the rover, mount an array of 5 infrared sensors in a line, 3 to 8 mm above the floor, with 12 to 20 mm between them. With five sensors, the robot doesn't just know “I'm on the line”: it knows how far it is from it, and that's what feeds the PID.

🌱 New here?

Each sensor (TCRT5000 or the pair in a QTR array) has an infrared LED that illuminates the floor and a phototransistor that measures how much light bounces back. White reflects a lot; black absorbs it. The analog output gives a number between these extremes, letting you tell when the line is “partly” under a sensor. Use the analog output (A0), not the digital output (D0).

rover S0S1S2S3S4 array 3–8 mmabove the floor black tape19 mm

How to read this map: the black strip is the line; the filled blue circles are the sensors that detect it (S3 strongly, S4 partially), while the hollow circles see white. Since the line is to the right of the center (S2), the robot needs to turn slightly right. The cyan bars at the sides are the wheels.

PartESP32 pinNote
Sensors S0…S4 (A0)36, 39, 34, 35, 32ADC1 only: ADC2 doesn't work when Wi-Fi is on
Left motor (TB6612FNG)PWMA 25, AIN1 26, AIN2 27STBY to 3V3, VM to the battery
Right motorPWMB 14, BIN1 16, BIN2 17Common GND with the ESP32
microSD moduleCS 5, SCK 18, MISO 19, MOSI 23same as module 2.5
Button and LEDBOOT (0), LED (2)already on the board

✓ A track that helps

  • ✓Uniform matte background; tape pulled tight, with no bubbles.
  • ✓A long straight section for measuring speed and a section with curves.
  • ✓Power the array at 3.3 V so its output doesn't exceed the ADC limit.

✗ A track that causes problems

  • ✗Shiny floor or direct sunlight: ambient infrared blinds the sensor.
  • ✗Array too high (more than 1 cm): the sensors see a blurry patch.
  • ✗Sensors connected directly to 5 V and the ESP32 pin.

Why learn this

Half of line-follower problems are mechanical and optical, not code-related. The array's height, spacing, and background determine what the software can do; it's worth getting those right before writing the first line of PID code.

Key concepts

Reflectance
white reflects, black absorbs
Analog output
how much, not just yes or no
ADC1
GPIO 32 to 39
Array height
3 to 8 mm
2

📐 Calculate the line error (weighted average)

Give each sensor an “address”: S0 is 0, S1 is 1000, S2 is 2000, S3 is 3000, and S4 is 4000. After calibration, each reading becomes a number from 0 (white) to 1000 (over the line). The position line position is the average of the addresses, weighted by the readings: sensors that see more black pull the average closer to themselves. The center is 2000, so error = position − 2000. Negative error: line to the left. Positive: to the right.

🌱 New here?

A weighted average multiplies each value by its weight, adds the results, and divides by the sum of the weights. Here, the “value” is the sensor's address and the “weight” is how much black it sees. Formula: position = Σ(reading × address) ÷ Σ(reading).

0050010000 S0 · 0S1 · 1000S2 · 2000S3 · 3000S4 · 4000 center 2667 (500×2000 + 1000×3000) ÷ 1500 = 2667 → error = +667

How to read this map: the height of each bar shows how much black the sensor sees; below it is each sensor's address. The dashed line is the center (S2); the orange line is the calculated position, pulled toward S3, which sees more black. The distance between the two is the error the PID will correct.

What if no sensor sees the line? The readings add up to almost zero, so the division doesn’t make sense. The sketch remembers which way the line was last and returns the extreme value (0 or 4000): the robot turns sharply that way until it finds the line again. If it goes 0.4 s without seeing the line, it stops.

Why learn this

The weighted average turns five on/off sensors into a continuous scale with 4001 points. With it, the PID makes a small correction when the deviation is small and a large one when it’s large, so the robot moves smoothly instead of zigzagging.

Key concepts

Position
0 a 4000
Error
position − 2000
Weight
how much black the sensor sees
Last direction
to find the line again
3

🎯 Steering PID added to base speed

The PID from module 2.3 has just one job here: decide how much to turn. It receives the error and returns a correction. Both wheels run at the base speed, and the correction is added to one and subtracted from the other: left = base + corr, right = base − corr. A line on the right (positive error) speeds up the left wheel and slows the right wheel, so the robot turns right.

IR barposition −2000 PIDKp, Ki, Kd left = base + corr right = base − corr errorcorr the robot turns → the line shifts under the bar (200 times per second)

How to read this map: Follow from left to right: the bar measures, the circle subtracts the center, the PID decides the correction, and it splits into two directions (more on one wheel, less on the other). The cyan arrow closes the loop: the robot’s turn changes the next reading.

Calculation, using the example from topic 2Result
error = 2667 − 2000+667
P = Kp × error = 0,08 × 66753
D = Kd × (error − previous error) = 0,6 × (667 − 600)40
corr = P + D (Ki = 0 for now)93
left = 150 + 93 · right = 150 − 93243 · 57

💡 Is the robot moving away from the line instead of following it?

The signal is inverted somewhere: the sensors are numbered from right to left, a motor is connected in reverse, or the line is light on a dark background. Don’t “fix” it with a negative Kp; test each part on its own (Serial Plotter with the robot stopped, one wheel at a time) and correct the cause.

Why learn this

Separating “how fast” (base) from “how much to turn” (correction) is the standard approach for every differential drive robot, including the one that follows objects with a camera in Track 3 and the one that receives velocity commands in ROS 2.

Key concepts

Base
straight-line speed
Fix
PID output
Differential drive
turns by varying the difference
Saturation
PWM limited to ±255
4

🧭 Automatic sensor calibration

No two sensors are exactly alike, and room lighting changes everything. That’s why the robot learns the track before it races: it spins in place for 2,5 s, moving the bar over the line and the background, and stores the lowest and highest value measured by each sensor. Then each reading becomes (reading − minimum) × 1000 ÷ (maximum − minimum), limited to a range of 0 to 1000. That way, all five use the same scale.

raw reading (0 to 4095) S0S1S2S3S4 04095 normalized 01000

How to read this map: on the left, each bar runs from the minimum (background) to the maximum (line) that sensor measured: they start and end at different points. On the right, after the calculation, they all use the same range from 0 to 1000. A very short bar on the left indicates that the sensor is too high or dirty.

Goal: the complete project sketch. Press BOOT once: it calibrates. Place the robot on the line and press again: it follows. Press once more: it stops and closes the log. If the card is missing, it follows without logging. If your bar gives lower readings over black (check the Serial Monitor during calibration), change LINHA_ESCURA_ALTA to false.

seguidor-pid.ino
// Line follower with PID, automatic calibration, and microSD logging
// ESP32 (esp32 package 3.x) + 5 analog IR sensor bar + TB6612FNG + microSD module
#include <SPI.h>
#include <SD.h>
// ---------- adjust here ----------
float KP = 0.08, KI = 0.0, KD = 0.6;     // steering PID gains (starting point)
int BASE = 150;                           // base speed (0 to 255)
const bool LINHA_ESCURA_ALTA = true;      // does the bar read HIGHER over black?
// ---------- pins ----------
const int SENS[5] = { 36, 39, 34, 35, 32 };   // S0 (left) to S4 (right), ADC1 only
const int PWMA = 25, AIN1 = 26, AIN2 = 27;    // left motor
const int PWMB = 14, BIN1 = 16, BIN2 = 17;    // right motor (STBY on 3V3)
const int SD_CS = 5, BOTAO = 0, LED = 2;

int minimo[5], maximo[5], ultimoLado = 1;
float erroAnt = 0, integral = 0;
bool naLinha = false, temSD = false;
unsigned long tCiclo = 0, tViuLinha = 0;
enum Estado { ESPERA, CALIBRA, PRONTO, SEGUE };
Estado estado = ESPERA;
struct Amostra { uint32_t t; int16_t pos, erro, corr, esq, dir; };
QueueHandle_t filaLog;
File arq;

void motor(int pwm, int in1, int in2, int v) {
  v = constrain(v, -255, 255);
  digitalWrite(in1, v > 0); digitalWrite(in2, v < 0); ledcWrite(pwm, abs(v));   // API 3.x
}
void andar(int esq, int dir) { motor(PWMA, AIN1, AIN2, esq); motor(PWMB, BIN1, BIN2, dir); }
bool botaoApertado() { static int antes = HIGH; int a = digitalRead(BOTAO); bool b = (antes == HIGH && a == LOW); antes = a; return b; }

void calibrar() {                            // spins in place for 2,5 s; this is the program's only wait
  for (int i = 0; i < 5; i++) { minimo[i] = 4095; maximo[i] = 0; }
  unsigned long t0 = millis();
  while (millis() - t0 < 2500) {
    bool lado = ((millis() - t0) / 625) % 2;                 // changes direction every 0,625 s
    andar(lado ? 110 : -110, lado ? -110 : 110);
    for (int i = 0; i < 5; i++) { int v = analogRead(SENS[i]); minimo[i] = min(minimo[i], v); maximo[i] = max(maximo[i], v); }
  }
  andar(0, 0);
  for (int i = 0; i < 5; i++) Serial.printf("S%d: min %d max %d\n", i, minimo[i], maximo[i]);
}

int lerPosicao() {                           // 0 (line under S0) to 4000 (under S4); 2000 = center
  long soma = 0, pesos = 0;
  for (int i = 0; i < 5; i++) {
    long n = constrain((long)(analogRead(SENS[i]) - minimo[i]) * 1000 / max(maximo[i] - minimo[i], 1), 0L, 1000L);
    if (!LINHA_ESCURA_ALTA) n = 1000 - n;                    // 1000 = on the line
    if (n > 50) { soma += n * i * 1000; pesos += n; }         // ignores background noise
  }
  naLinha = pesos > 300;
  if (!naLinha) return ultimoLado < 0 ? 0 : 4000;            // line lost: turn toward where it disappeared
  int pos = soma / pesos;
  ultimoLado = pos < 2000 ? -1 : 1;
  return pos;
}

void registrar(uint32_t t, int pos, int erro, int corr, int esq, int dir) {
  if (!temSD) return;
  Amostra a = { t, (int16_t)pos, (int16_t)erro, (int16_t)corr, (int16_t)esq, (int16_t)dir };
  xQueueSend(filaLog, &a, t <= 1 ? pdMS_TO_TICKS(50) : 0);  // t = 0 opens file, t = 1 closes it
}

void cicloControle() {                       // runs every 5 ms (200 Hz)
  static uint8_t n = 0;
  int pos = lerPosicao();
  if (naLinha) tViuLinha = millis();
  else if (millis() - tViuLinha > 400) { andar(0, 0); return; }   // off the track: stop
  float erro = pos - 2000;                   // negative: line on the left
  integral = constrain(integral + erro, -20000, 20000);        // limits windup
  float corr = KP * erro + KI * integral + KD * (erro - erroAnt);
  erroAnt = erro;
  int esq = constrain(BASE + corr, -255, 255), dir = constrain(BASE - corr, -255, 255);
  andar(esq, dir);
  if (++n % 2 == 0) registrar(millis(), pos, erro, corr, esq, dir);                     // log at 100 Hz
  if (n % 10 == 0) Serial.printf("erro:%.0f,corr:%.0f,esq:%d,dir:%d\n", erro, corr, esq, dir);   // Plotter at 20 Hz
}

void abrirProximo() {                        // /pid001.csv, /pid002.csv... one per run
  char nome[16];
  for (int i = 1; i < 1000; i++) { snprintf(nome, sizeof nome, "/pid%03d.csv", i); if (!SD.exists(nome)) break; }
  arq = SD.open(nome, FILE_WRITE);
  arq.printf("# KP=%.3f KI=%.4f KD=%.3f BASE=%d\n", KP, KI, KD, BASE);
  arq.println("t_ms,pos,erro,corr,esq,dir");
  Serial.printf("log: %s\n", nome);
}

void tarefaLog(void *p) {                    // core 0: writes without delaying control
  Amostra a; int linhas = 0;
  for (;;) {
    if (xQueueReceive(filaLog, &a, portMAX_DELAY) != pdTRUE) continue;
    if (a.t == 0) { abrirProximo(); linhas = 0; }
    else if (a.t == 1) { if (arq) arq.close(); }
    else if (arq) {
      arq.printf("%lu,%d,%d,%d,%d,%d\n", (unsigned long)a.t, a.pos, a.erro, a.corr, a.esq, a.dir);
      if (++linhas % 50 == 0) arq.flush();
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(AIN1, OUTPUT); pinMode(AIN2, OUTPUT); pinMode(BIN1, OUTPUT); pinMode(BIN2, OUTPUT);
  ledcAttach(PWMA, 20000, 8); ledcAttach(PWMB, 20000, 8);
  pinMode(BOTAO, INPUT_PULLUP); pinMode(LED, OUTPUT);
  temSD = SD.begin(SD_CS);
  if (temSD) { filaLog = xQueueCreate(64, sizeof(Amostra)); xTaskCreatePinnedToCore(tarefaLog, "log", 4096, NULL, 1, NULL, 0); }
  Serial.println(temSD ? "SD ok" : "no SD: running without logging");
  Serial.println("BOOT: calibrate, then follow, then stop");
}

void loop() {
  bool botao = botaoApertado();
  switch (estado) {
    case ESPERA:  if (botao) estado = CALIBRA; break;
    case CALIBRA: calibrar(); estado = PRONTO; Serial.println("Calibrated: place on the line and press BOOT"); break;
    case PRONTO:
      if (botao) { erroAnt = 0; integral = 0; tViuLinha = millis(); registrar(0, 0, 0, 0, 0, 0); tCiclo = micros(); estado = SEGUE; }
      break;
    case SEGUE:
      if (botao) { andar(0, 0); registrar(1, 0, 0, 0, 0, 0); estado = PRONTO; Serial.println("Parado"); break; }
      if (micros() - tCiclo >= 5000) { tCiclo += 5000; cicloControle(); }   // 200 Hz, no delay
      break;
  }
  digitalWrite(LED, estado == SEGUE);
}

How to check

  • ✓During calibration, the Serial Monitor shows 5 lines like "S3: min 612 max 3580", with a gap of at least 1000 between the minimum and maximum.
  • ✓With the robot suspended and the line under S3, the Plotter shows a positive error, with left greater than right.
  • ✓After a run, the card contains /pid001.csv with the line # KP=... at the top.
  • ✗Minimum and maximum nearly equal: the bar is too high, the sensor is on the wrong pin, or the IR LED is off (look through your phone camera: the LED appears purple).

Why learn this

Calibrating at the start of each run lets the same code work on different tracks, under different lighting, and with different sensors. In competition, it’s what separates a robot that worked “in the workshop” from one that works on the day.

Key concepts

Minimum/maximum
background and line, per sensor
Normalize
all from 0 to 1000
200 Hz
fixed 5 ms cycle
Logging in another task
module 2.4 queue
5

📊 Tuning with telemetry (before/after)

“It seems better” can be misleading. Each run saves a file with the gains in the header, and you compare two files using two numbers: the RMS error (the typical size of the deviation; lower is better) and the maximum error (the worst moment, usually a turn). Change one one parameter at a time, on the same track and with the battery at the same charge level.

0 +− curvecurve before: KP 0,12, KD 0 (high RMS)after: KP 0,08, KD 0,6

How to read this map: The horizontal axis is the run time, and the vertical axis is the error (zero = centered line). The red curve never settles down: the robot zigzags even on the straight. The cyan curve stays near zero and rises only on turns, then returns. That’s the shape you’re looking for in the PNG.

Step-by-step tuning on the track

1

P only, slowly

BASE 100, KI 0, KD 0. Increase KP (0,02 → 0,04 → 0,06...) until the robot follows the entire track, even if it wobbles.

2

Use D to dampen

Increase KD (0,2 → 0,4 → 0,8...) until the wobble disappears on the straight. If the robot starts shaking rapidly, you’ve gone too far: decrease it by one step.

3

More speed

Increase BASE in steps of 20. At each step, fine-tune KP and KD again while checking the RMS.

4

Use I only if needed

If the average error is always on one side (the robot is “pulling” to one side), a small KI (0,0005) can fix it. On most tracks, KI = 0.

Goal: compare two runs using numbers and a single graph. Use the same Python environment as module 2.5. To view a single run, the analisa_log.py from 2.5 will do: python analisa_log.py pid001.csv erro corr.

compara.py
# compara.py: before vs. after a PID adjustment
# Usage: python compara.py pid001.csv pid002.csv
import sys
import pandas as pd
import matplotlib.pyplot as plt

for arquivo in sys.argv[1:]:
    ganhos = open(arquivo).readline().strip("# \n")           # line "# KP=... KD=..."
    df = pd.read_csv(arquivo, comment="#")
    t = (df["t_ms"] - df["t_ms"].iloc[0]) / 1000
    rms = (df["erro"] ** 2).mean() ** 0.5
    satur = ((df["esq"].abs() >= 255) | (df["dir"].abs() >= 255)).mean() * 100
    print(f"{arquivo}: {ganhos} | RMS {rms:.0f} | max {df['erro'].abs().max()} | lap {t.iloc[-1]:.2f} s | saturated {satur:.0f}%")
    plt.plot(t, df["erro"], label=f"{arquivo} ({ganhos})")

plt.axhline(0, color="gray", lw=0.8)
plt.xlabel("time (s)"); plt.ylabel("erro"); plt.legend(); plt.grid(alpha=0.3)
plt.savefig("comparacao.png", dpi=120)
plt.show()

How to check

  • ✓One line per file, with the gains and RMS: a good adjustment has a lower RMS and the curve stays closer to zero in comparacao.png.
  • ✓“saturated” above 20% indicates that BASE is too high: the wheels are already at their limit, leaving no room for corrections.

Why learn this

Measuring before and after is engineering in miniature: a hypothesis, one change, data, and a conclusion. The same two numbers help you tune the robot with a camera in Trail 3 and explain your project at an expo or competition.

Key concepts

RMS error
typical deviation
Maximum error
the worst moment
One parameter at a time
so you know what helped
Saturation
PWM stuck at the limit
6

🏆 Challenges: sharp turns, intersections, stopwatch

With the robot completing the track, the challenges below help you practice what line-following events (such as the practical category of OBR) require. Each challenge is a small change to the sketch; do them one at a time and compare the logs.

↩️ Sharp turns

Reduce the base when the error increases: int base = BASE - abs(erro) * 0.04; and use base instead of BASE. Fast on the straight, safe on turns.

✚ Intersections

When all five sensors read above 700 at the same time, you’re at an intersection: keep the last error for 100 ms and go straight, without letting the PID react to the “wide line.”

⏱️ Stopwatch

A side marker at the starting line, read by a sixth sensor (GPIO 33, also ADC1): save the millis() at each pass and print the lap time.

✓ Safety and method

  • ✓First test each change with the robot lifted, then on the track.
  • ✓Keep a hand near BOOT for the first few laps; stopping when the line is lost is the second safeguard.
  • ✓Record each log file and what changed in your logbook.

✗ Competition pitfalls

  • ✗Tuning with a full battery and competing with it half-charged (or the other way around).
  • ✗Skipping calibration “because it worked yesterday”: the gym lighting is different.
  • ✗Leaving the LiPo charging unattended on the workbench during tests.

💡 Wrapping up Trail 2

You now have a robot you can control with your phone, that can measure its own movement, runs PID at a fixed rate, divides the work into tasks, and records everything. The remaining limitation is the sensor: five points of light on the floor. In Trail 3, the robot gets a camera and a Raspberry Pi 5, and the same PID starts following objects and people.

Why learn this

Small, measured challenges are the bridge between “it works” and “it works well.” The habit of changing one thing, recording the result, and comparing it is something you’ll carry into every project in the next trails.

Key concepts

Variable base
slows down on turns
Intersection
all sensors see black
Lap time
millis() between markers
Logbook
log + what changed

✅ Module and Trail 2 summary

✓
Track and bar: matte background, bar 3–8 mm away, five analog sensors on ADC1.
✓
Position by weighted average from 0 to 4000; error = position − 2000; remember the last side to find the line again.
✓
left = base + corr, right = base − corr, with PID at 200 Hz and a limited output.
✓
Calibrate before every run: minimum and maximum for each sensor, all normalized from 0 to 1000.
✓
Tune using numbers: one parameter at a time, RMS and maximum error, before and after.

To go further

Next track

Trail 3 · Embedded Vision and AI. Start with module 3.1: prepare the Raspberry Pi 5 with Linux, the “computer that thinks” alongside the ESP32 you already know.