Precision Line Follower With PID Control

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 dt keeps 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:

  • Kd is expressed per second because the derivative divides by DT. Do not change LOOP_US without rescaling the gains.
  • If the line is lost, readLineBlack saturates at 0 or 7000, so error becomes ±3500 and the robot spins back toward the last known side.
  • Allowing negative wheel speeds (setMotor accepts 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

  1. Zero Ki and Kd. Set BASE_SPEED to about 40% of maximum.
  2. 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.
  3. Back off. Use Kp = 0.5 * Ku as the starting point.
  4. Add Kd. Start at Kd = Kp * Tu / 8 and increase until overshoot after corners disappears. Stop before the motors start to buzz.
  5. Raise speed by 10% and repeat steps 2–4 for that speed.
  6. 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.

  1. Baseline at 30 cm/s. Pure P with Kp = 0.04 oscillates at Ku = 0.08, Tu = 0.22 s.
  2. Compute PD. Kp = 0.8 × 0.08 = 0.064 is too aggressive for the real track, so use Kp = 0.045. Then Kd = 0.045 × 0.22 / 8 ≈ 0.0012 in seconds, which converts to roughly 0.25 in the sketch’s per-second units after unit scaling. Refine empirically.
  3. Raise speed to 60 cm/s. Increase BASE_SPEED in 10-count steps. Re-tune Kd upward as speed grows, because the same lateral error develops faster.
  4. Enable speed scheduling. The speedScale term cuts speed by up to 50% in tight arcs, which typically brings lap times down by letting the robot enter turns under control.
  5. Log and verify. Print error and u over serial at 200 Hz for 10 s and plot them. A well-tuned loop shows error decaying 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.

Hot this week

Humanoids in Factories and Warehouses: What They Actually Do Today

Which humanoid robots do real work in factories and warehouses today? See deployment data, ROI math and ROS 2 code. Read the 2026 guide.

Debugging Robot Code: Logs, Serial Output, rosbag, and Foxglove

Debug robot code faster with ROS 2 logging, serial capture, rosbag2 recording, and Foxglove. Copy the configs and scripts into your stack today.

Build a Mapping Robot With SLAM From Scratch

Build a SLAM mapping robot from scratch: wiring, odometry, LiDAR, ROS 2 slam_toolbox config and tuning. Follow the full guide and map your first room.

Depth Cameras: Stereo, Time-of-Flight, and Structured Light Compared

Compare stereo, time-of-flight, and structured light depth cameras: accuracy, range, sunlight, ROS2 setup. Pick the right sensor for your robot.

Python vs C++ for Robotics: Which Should You Use?

Python vs C++ for robotics compared: latency, ROS2 performance, control loops, and code examples. Learn which language fits your robot. Read the guide.

Topics

Humanoids in Factories and Warehouses: What They Actually Do Today

Which humanoid robots do real work in factories and warehouses today? See deployment data, ROI math and ROS 2 code. Read the 2026 guide.

Debugging Robot Code: Logs, Serial Output, rosbag, and Foxglove

Debug robot code faster with ROS 2 logging, serial capture, rosbag2 recording, and Foxglove. Copy the configs and scripts into your stack today.

Build a Mapping Robot With SLAM From Scratch

Build a SLAM mapping robot from scratch: wiring, odometry, LiDAR, ROS 2 slam_toolbox config and tuning. Follow the full guide and map your first room.

Depth Cameras: Stereo, Time-of-Flight, and Structured Light Compared

Compare stereo, time-of-flight, and structured light depth cameras: accuracy, range, sunlight, ROS2 setup. Pick the right sensor for your robot.

Python vs C++ for Robotics: Which Should You Use?

Python vs C++ for robotics compared: latency, ROS2 performance, control loops, and code examples. Learn which language fits your robot. Read the guide.

Build a ROS 2 Rover: Raspberry Pi 5, Motors, and LiDAR

Build a ROS 2 rover with Raspberry Pi 5, RPLIDAR C1, and a Pico motor controller. Copy the code, wire it, and map your room. Start building now.

Imitation Learning for Robots: Teach by Demonstration

Learn imitation learning for robots: behavior cloning, DAgger, ACT, and diffusion policies with ROS2 code. Build your first policy today.

LiDAR for Robots: 2D, 3D, and Solid-State Compared

Compare 2D, 3D, and solid-state LiDAR for robots. See specs, ROS2 configs, code, and a selection workflow. Pick the right sensor now.

Related Articles

Popular Categories