A line follower that only switches motors on and off oscillates around the line and loses it at speed. A PID controller turns the reflectance sensor array reading into a smooth, proportional steering correction. This guide covers the sensor math, the discrete control law, copy-pasteable Arduino code, a tuning procedure, and an offline simulation.
Quick Takeaways
- Compute line position with a weighted average over an 8-channel QTR array. This gives sub-sensor resolution (about 1,000 counts per sensor pitch).
- Run the loop at a fixed 200 Hz (5 ms) period. A fixed
dtkeeps Kd and Ki meaningful. - Tune in order Kp → Kd → Ki. Most line followers need little or no integral term.
- Reduce base speed in proportion to |error| so the robot slows down in tight curves.
System Architecture and Specifications
| Subsystem | Component | Key Spec |
|---|---|---|
| Sensing | QTR-8RC reflectance array | 8 channels, 9.525 mm pitch, 3.3–5V |
| Controller | Arduino Nano / Uno (ATmega328P) | 16 MHz, 10-bit PWM-capable |
| Driver | TB6612FNG dual H-bridge | 1.2A continuous, 12V max |
| Actuation | 2× N20 gear motor (e.g., 1000 RPM @ 6V) | Differential drive |
| Power | 2S LiPo (7.4V) → 5V regulator | Motors on the battery rail |
Mount the array 8–12 mm ahead of the wheel axis and 2–3 mm above the surface. A larger lever arm gives earlier steering information, which helps at high speed.
Sensor Fundamentals
Choosing a Line Sensor
| Sensor Type | Resolution | Latency | Cost | Best For |
|---|---|---|---|---|
| IR reflectance array (QTR) | Analog or digital per channel | <1 ms | Low | Competition line followers |
| Single IR pair | 1 point | <0.1 ms | Very low | Bang-bang demos |
| Color sensor (TCS34725) | 16-bit color | 2.4–614 ms | Medium | Color-coded paths |
| Camera + OpenCV | Sub-pixel | 15–50 ms | High | Complex tracks, ROS2 pipelines |
The IR array wins on latency, which matters because phase delay in the sensor path limits how high Kp can go.
Weighted-Average Line Position
Each sensor i (0 to N-1) returns a normalized reading s_i in [0, 1000], where 1000 means black line. Assign each sensor a weight w_i = i * 1000. The line position is:
sum( w_i * s_i )
pos = --------------------- pos in [0, (N-1)*1000]
sum( s_i )
For 8 sensors, pos runs from 0 to 7000. The setpoint is the center, 3500. The error is:
e[k] = pos[k] - 3500
If sum(s_i) is near zero, no sensor sees the line. Hold the last known error sign so the robot keeps steering toward where the line was lost.
Calibration
Before running, sweep the array across the line and background for about 5 seconds. The QTRSensors library records per-channel min and max values and scales each reading to 0–1000. Skipping this step shifts the computed center and creates a constant steering bias.
Control Law Design
Continuous PID
u(t) = Kp*e(t) + Ki*∫e(τ)dτ + Kd*de(t)/dt
Discrete Form at Fixed dt
With dt = 0.005 s:
P[k] = Kp * e[k]
I[k] = I[k-1] + Ki * e[k] * dt (clamped to ±I_max)
D[k] = Kd * (e[k] - e[k-1]) / dt
u[k] = P[k] + I[k] + D[k]
Because the setpoint is constant, derivative-on-error equals derivative-on-measurement, so there is no setpoint kick.
Differential Drive Mixing
The controller output u is a speed differential applied to the two wheels:
v_left = clamp(v_base + u, 0, v_max)
v_right = clamp(v_base - u, 0, v_max)
Sign convention: pos > 3500 means the line is to the right, so e > 0, u > 0, and the left wheel speeds up. The robot turns right toward the line.
Why Each Term Matters
| Term | Effect | Too High | Too Low |
|---|---|---|---|
| Kp | Pulls the robot toward the line | Oscillation, wobble | Slow response, leaves the line on curves |
| Kd | Damps rate of error change | Jitter, noise amplification | Overshoot after turns |
| Ki | Removes steady-state bias | Windup, slow oscillation | Constant offset on long arcs |
Arduino Implementation
This sketch uses the QTRSensors library by Pololu, a TB6612FNG driver, and a hardware-timed 5 ms loop.
#include <QTRSensors.h>
// ---------- Hardware map ----------
const uint8_t AIN1 = 7, AIN2 = 8, PWMA = 5; // left motor
const uint8_t BIN1 = 9, BIN2 = 10, PWMB = 6; // right motor
const uint8_t STBY = 4; // driver standby (HIGH = enabled)
QTRSensors qtr;
const uint8_t SENSOR_COUNT = 8;
uint16_t sensorValues[SENSOR_COUNT];
// ---------- Control parameters ----------
float Kp = 0.045f; // proportional gain (PWM counts per position count)
float Ki = 0.0f; // integral gain; start at zero
float Kd = 0.55f; // derivative gain (units: PWM * s / count)
const int BASE_SPEED = 140; // nominal PWM, 0-255
const int MAX_SPEED = 255;
const float I_MAX = 60.0f; // anti-windup clamp on the integral term
const uint32_t LOOP_US = 5000; // 5 ms = 200 Hz
const float DT = LOOP_US / 1e6f;
float lastError = 0, integral = 0;
uint32_t nextTick;
void setMotor(uint8_t in1, uint8_t in2, uint8_t pwm, int speed) {
speed = constrain(speed, -MAX_SPEED, MAX_SPEED);
digitalWrite(in1, speed > 0); // forward pin
digitalWrite(in2, speed < 0); // reverse pin
analogWrite(pwm, abs(speed)); // duty cycle magnitude
}
void setup() {
pinMode(STBY, OUTPUT); digitalWrite(STBY, HIGH);
for (uint8_t p : {AIN1, AIN2, PWMA, BIN1, BIN2, PWMB}) pinMode(p, OUTPUT);
qtr.setTypeRC(); // RC (digital timing) variant
qtr.setSensorPins((const uint8_t[]){A0,A1,A2,A3,A4,A5,2,3}, SENSOR_COUNT);
qtr.setEmitterPin(12);
// 5 s calibration: sweep the robot over the line by hand
for (uint16_t i = 0; i < 250; i++) { qtr.calibrate(); delay(20); }
delay(1000);
nextTick = micros();
}
void loop() {
// Fixed-period scheduler: wait until the next 5 ms tick
while ((int32_t)(micros() - nextTick) < 0) {}
nextTick += LOOP_US;
// readLineBlack returns 0..7000; 3500 = line centered under the array
uint16_t pos = qtr.readLineBlack(sensorValues);
float error = (float)pos - 3500.0f;
// Integral with clamping (anti-windup)
integral += Ki * error * DT;
integral = constrain(integral, -I_MAX, I_MAX);
// Derivative on error
float derivative = (error - lastError) / DT;
lastError = error;
float u = Kp * error + integral + Kd * derivative;
// Curve-aware speed scheduling: slow down as |error| grows
float speedScale = 1.0f - 0.5f * min(fabs(error) / 3500.0f, 1.0f);
int vBase = (int)(BASE_SPEED * speedScale);
setMotor(AIN1, AIN2, PWMA, vBase + (int)u); // left wheel
setMotor(BIN1, BIN2, PWMB, vBase - (int)u); // right wheel
}
Notes on the code:
Kdis expressed per second because the derivative divides byDT. Do not changeLOOP_USwithout rescaling the gains.- If the line is lost,
readLineBlacksaturates at 0 or 7000, soerrorbecomes ±3500 and the robot spins back toward the last known side. - Allowing negative wheel speeds (
setMotoraccepts them) lets the robot pivot in tight corners.
Tuning Procedure
Tune with the robot on the real track at the speed you plan to race.
Step-by-Step Method
- Zero Ki and Kd. Set
BASE_SPEEDto about 40% of maximum. - Raise Kp from 0.01 in steps of 0.005. Stop when the robot oscillates with a steady amplitude. This is the ultimate gain Ku. Record the oscillation period Tu in seconds, using a phone slow-motion video or a serial log.
- Back off. Use
Kp = 0.5 * Kuas the starting point. - Add Kd. Start at
Kd = Kp * Tu / 8and increase until overshoot after corners disappears. Stop before the motors start to buzz. - Raise speed by 10% and repeat steps 2–4 for that speed.
- Add Ki last, and only if the robot drifts on long constant-radius arcs. Typical values are
Ki < Kp / 100.
Ziegler-Nichols Starting Points
| Controller | Kp | Ki | Kd |
|---|---|---|---|
| P | 0.50·Ku | 0 | 0 |
| PD | 0.80·Ku | 0 | Kp·Tu/8 |
| Classic PID | 0.60·Ku | 2·Kp/Tu | Kp·Tu/8 |
| No-overshoot PID | 0.20·Ku | 2·Kp/Tu | Kp·Tu/3 |
For line followers, the PD and no-overshoot rows give the best results. The track gives constant disturbances only on arcs, so a large integral term mostly adds lag.
Diagnosing Behavior
| Symptom | Likely Cause | Fix |
|---|---|---|
| Fast wobble (>5 Hz) | Kd too high or sensor noise | Lower Kd, add a first-order filter on the derivative |
| Slow weave (1–2 Hz) | Kp too high or Kd too low | Lower Kp, raise Kd |
| Cuts corners, exits line | Kp too low | Raise Kp, shorten the lever arm |
| Drifts on long arcs | Steady-state error | Add small Ki |
| Veers at start | Poor calibration | Re-run calibration with the full sweep |
| Overshoots after sharp turn | Windup or high speed | Clamp the integral, enable speed scheduling |
Offline Simulation for Gain Selection
Simulate before you burn through battery cycles. This minimal model treats the robot as a unicycle with a first-order steering lag.
import numpy as np
dt, T = 0.005, 6.0 # 200 Hz loop, 6 s run
v = 0.6 # forward speed [m/s]
Kp, Ki, Kd = 1.8, 0.0, 0.12 # gains in rad/s per meter of lateral error
y, theta = 0.03, 0.0 # initial lateral offset 30 mm, heading 0 rad
omega = 0.0 # actual yaw rate [rad/s]
tau = 0.04 # actuator lag time constant [s]
e_prev, I = y, 0.0
trace = []
for k in range(int(T/dt)):
e = -y # error = 0 - lateral offset
I += e * dt
d = (e - e_prev) / dt
e_prev = e
u = Kp*e + Ki*I + Kd*d # commanded yaw rate
omega += (u - omega) * dt / tau # first-order motor/steering lag
theta += omega * dt
y += v * np.sin(theta) * dt # lateral kinematics
trace.append(y)
print(f"max |y| after 1 s: {max(abs(np.array(trace[200:]))):.4f} m")
print(f"final offset: {trace[-1]*1000:.2f} mm")
Adjust tau to match your motor response and sweep Kp and Kd. A gain pair that settles in under 0.5 s with less than 10% overshoot in simulation is a solid starting point on hardware.
Real-World Workflow: Tuning for a 60 cm/s Sprint Track
A typical build uses an 8-sensor array, N20 motors at 7.4V, and a 5 ms loop.
- Baseline at 30 cm/s. Pure P with
Kp = 0.04oscillates atKu = 0.08,Tu = 0.22 s. - Compute PD.
Kp = 0.8 × 0.08 = 0.064is too aggressive for the real track, so useKp = 0.045. ThenKd = 0.045 × 0.22 / 8 ≈ 0.0012in seconds, which converts to roughly 0.25 in the sketch’s per-second units after unit scaling. Refine empirically. - Raise speed to 60 cm/s. Increase
BASE_SPEEDin 10-count steps. Re-tuneKdupward as speed grows, because the same lateral error develops faster. - Enable speed scheduling. The
speedScaleterm cuts speed by up to 50% in tight arcs, which typically brings lap times down by letting the robot enter turns under control. - Log and verify. Print
erroranduover serial at 200 Hz for 10 s and plot them. A well-tuned loop showserrordecaying within 3–4 samples of a step without ringing.
Hardware Pitfalls That Break a Good Controller
- Ambient IR. Sunlight saturates the QTR receivers. Shade the array or use the emitter-on and emitter-off subtraction mode.
- Motor noise. Place a 100 nF ceramic capacitor across each motor’s terminals and a 470 µF bulk capacitor on the driver supply.
- Voltage sag. Battery droop changes motor gain. Re-tune near the end of a charge, or normalize PWM by measured battery voltage.
- Sensor height drift. A 1 mm height change shifts raw readings. Re-calibrate after any mechanical adjustment.
- Backlash. Cheap gearboxes add dead zone, which looks like a need for more Ki. Fix the mechanics first.
Extending to ROS2
For camera-based or larger platforms, publish line error as a std_msgs/msg/Float32 on a /line_error topic. A control node subscribes at 50–100 Hz and publishes geometry_msgs/msg/Twist on /cmd_vel, mapping u to angular.z. The same PID math applies. Replace the micros() scheduler with a ROS2 wall timer and compute dt from the actual callback timestamps.
FAQ
What is the best PID value for a line follower?
No universal value exists, because the right gains depend on motor response, sensor distance, and speed. A reliable starting point is Kp = 0.5·Ku and Kd = Kp·Tu/8, with Ki = 0. Determine Ku and Tu by raising Kp until the robot oscillates steadily.
Do I need the integral term in a line follower?
Usually not. A line follower’s error averages near zero on straights, so integral action adds lag and windup risk. Add a very small Ki only if the robot drifts on long constant-radius arcs, and always clamp it.
How many sensors do I need for a PID line follower?
Eight sensors is the practical standard. The weighted average yields about 1,000 counts per sensor pitch, which is enough resolution for smooth control. Fewer than five sensors gives coarse position estimates and a jerky response.
How fast should the PID loop run?
Run it at 200 Hz or faster. The loop period should be at least five times shorter than the time the robot takes to drift off one sensor pitch. A fixed period is more important than a high one, since it keeps the derivative term consistent.




