PTENES
MÓDULO 2.6 · PROJETO

🏁 Projeto: seguidor de linha com PID

O projeto que fecha a Trilha 2 junta tudo: uma barra de 5 sensores IR calcula onde está a linha, um PID de direção corrige as rodas 200 vezes por segundo, a calibração se ajusta à sua pista e a telemetria no microSD mostra, em números, se o ajuste melhorou.

6
Tópicos
Intermediário
Nível
Projeto
Tipo
Rover + barra IR
Hardware
0%
0 de 0 tópicos lidos · Seção 1 de 6

Conteúdo detalhado

1

🛤️ A pista e a barra de sensores

A pista mais simples é fita isolante preta de 19 mm sobre fundo branco fosco (cartolina, lona ou piso claro), com curvas de raio de pelo menos 10 cm no começo. Na frente do rover vai uma barra com 5 sensores infravermelhos em linha, a 3 a 8 mm do chão, com espaço de 12 a 20 mm entre eles. Com cinco sensores o robô não sabe só "estou na linha": sabe o quanto está fora dela, e é isso que alimenta o PID.

🌱 Novo aqui?

Cada sensor (TCRT5000 ou o par de uma barra QTR) tem um LED infravermelho que ilumina o chão e um fototransistor que mede quanto voltou. O branco reflete muito, o preto absorve. A saída analógica dá um número entre esses extremos, o que permite saber que a linha está "meio" embaixo de um sensor. Use a saída analógica (A0), não a digital (D0).

rover S0S1S2S3S4 barra a 3–8 mmdo chão fita preta19 mm

Como ler: a faixa preta é a linha; os círculos azuis cheios são os sensores que a enxergam (S3 forte, S4 parcial), os vazados veem branco. Como a linha está à direita do centro (S2), o robô precisa virar um pouco para a direita. As barras ciano nas laterais são as rodas.

PeçaPino do ESP32Observação
Sensores S0…S4 (A0)36, 39, 34, 35, 32só ADC1: o ADC2 não funciona com Wi-Fi ligado
Motor esquerdo (TB6612FNG)PWMA 25, AIN1 26, AIN2 27STBY no 3V3, VM na bateria
Motor direitoPWMB 14, BIN1 16, BIN2 17GND comum com o ESP32
Módulo microSDCS 5, SCK 18, MISO 19, MOSI 23igual ao módulo 2.5
Botão e LEDBOOT (0), LED (2)já vêm na placa

✓ Pista que ajuda

  • ✓Fundo fosco e uniforme; fita bem esticada, sem bolhas.
  • ✓Uma reta longa para medir velocidade e um trecho de curvas.
  • ✓Barra alimentada em 3,3 V, para a saída não passar do limite do ADC.

✗ Pista que atrapalha

  • ✗Piso brilhante ou sol direto: o infravermelho do ambiente cega o sensor.
  • ✗Barra alta demais (mais de 1 cm): os sensores veem uma mancha borrada.
  • ✗Sensores ligados em 5 V direto no pino do ESP32.

Por que aprender

Metade dos problemas de seguidor de linha é mecânica e óptica, não código. Altura da barra, espaçamento e fundo definem o que o software consegue fazer; vale acertar isso antes da primeira linha de PID.

Conceitos-chave

Refletância
branco volta, preto some
Saída analógica
quanto, não só sim/não
ADC1
GPIO 32 a 39
Altura da barra
3 a 8 mm
2

📐 Calcular o erro da linha (média ponderada)

Dê a cada sensor um "endereço": S0 vale 0, S1 vale 1000, S2 vale 2000, S3 vale 3000, S4 vale 4000. Depois da calibração, cada leitura vira um número de 0 (branco) a 1000 (em cima da linha). A posição da linha é a média dos endereços, pesada pelas leituras: sensores que veem mais preto puxam a média para perto de si. O centro é 2000, então erro = posição − 2000. Erro negativo: linha à esquerda. Positivo: à direita.

🌱 Novo aqui?

Uma média ponderada soma cada valor multiplicado pelo seu peso e divide pela soma dos pesos. Aqui o "valor" é o endereço do sensor e o "peso" é quanto preto ele vê. Fórmula: posição = Σ(leitura × endereço) ÷ Σ(leitura).

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

Como ler: a altura de cada barra é o quanto o sensor vê de preto; embaixo, o endereço de cada um. A linha tracejada é o centro (S2); a linha laranja é a posição calculada, puxada para S3, que vê mais preto. A distância entre as duas é o erro que o PID vai corrigir.

E se nenhum sensor vê a linha? A soma das leituras fica quase zero e a divisão não faz sentido. O sketch guarda para que lado a linha estava por último e devolve o extremo (0 ou 4000): o robô gira forte para aquele lado até reencontrá-la. Se passar de 0,4 s sem linha, ele para.

Por que aprender

A média ponderada transforma cinco sensores liga/desliga em uma régua contínua de 4001 pontos. Com ela o PID corrige pouco quando o desvio é pequeno e muito quando é grande, e o robô anda liso em vez de ziguezaguear.

Conceitos-chave

Posição
0 a 4000
Erro
posição − 2000
Peso
quanto preto o sensor vê
Último lado
para reencontrar a linha
3

🎯 PID de direção somado à velocidade base

O PID do módulo 2.3 entra aqui com uma tarefa só: decidir quanto virar. Ele recebe o erro e devolve uma correção. As duas rodas andam na velocidade base, e a correção é somada a uma e subtraída da outra: esq = base + corr, dir = base − corr. Linha à direita (erro positivo) acelera a roda esquerda e freia a direita, e o robô vira para a direita.

barra IRposição −2000 PIDKp, Ki, Kd esq = base + corr dir = base − corr errocorr o robô vira → a linha muda de lugar sob a barra (200 vezes por segundo)

Como ler: siga da esquerda para a direita: a barra mede, o círculo subtrai o centro, o PID decide a correção, e ela se divide em dois sentidos (mais numa roda, menos na outra). A seta ciano fecha a malha: a curva do robô muda a próxima leitura.

Conta, com o exemplo do tópico 2Resultado
erro = 2667 − 2000+667
P = Kp × erro = 0,08 × 66753
D = Kd × (erro − erro anterior) = 0,6 × (667 − 600)40
corr = P + D (Ki = 0 por enquanto)93
esq = 150 + 93 · dir = 150 − 93243 · 57

💡 O robô foge da linha em vez de seguir?

O sinal está invertido em algum lugar: sensores numerados da direita para a esquerda, motor ligado ao contrário ou linha clara sobre fundo escuro. Não "conserte" com Kp negativo; teste cada parte isolada (Serial Plotter com o robô parado, uma roda de cada vez) e corrija na origem.

Por que aprender

Separar "quão rápido" (base) de "quanto virar" (correção) é o padrão de todo robô com tração diferencial, inclusive o que segue objetos com câmera na Trilha 3 e o que recebe comandos de velocidade no ROS 2.

Conceitos-chave

Base
velocidade em linha reta
Correção
saída do PID
Tração diferencial
vira pela diferença
Saturação
PWM limitado a ±255
4

🧭 Calibração automática dos sensores

Nenhum par de sensores é igual, e a luz da sala muda tudo. Por isso o robô aprende a pista antes de correr: gira no lugar por 2,5 s, passando a barra sobre a linha e o fundo, e guarda o menor e o maior valor de cada sensor. Depois, cada leitura vira (leitura − mínimo) × 1000 ÷ (máximo − mínimo), limitada entre 0 e 1000. Assim os cinco falam a mesma língua.

leitura crua (0 a 4095) S0S1S2S3S4 04095 normalizado 01000

Como ler: à esquerda, cada barra vai do mínimo (fundo) ao máximo (linha) que aquele sensor mediu: começam e terminam em lugares diferentes. À direita, depois da conta, todos ocupam a mesma faixa de 0 a 1000. Uma barra muito curta à esquerda indica sensor alto demais ou sujo.

Objetivo: o sketch completo do projeto. BOOT uma vez: calibra. Ponha o robô na linha e aperte de novo: segue. Mais uma vez: para e fecha o log. Se o cartão faltar, ele segue sem log. Se a sua barra der valores menores sobre o preto (veja o Serial Monitor na calibração), troque LINHA_ESCURA_ALTA para false.

seguidor-pid.ino
// Seguidor de linha com PID, calibração automática e log no microSD
// ESP32 (pacote esp32 3.x) + barra de 5 sensores IR analógicos + TB6612FNG + módulo microSD
#include <SPI.h>
#include <SD.h>
// ---------- ajuste aqui ----------
float KP = 0.08, KI = 0.0, KD = 0.6;     // ganhos do PID de direção (ponto de partida)
int BASE = 150;                           // velocidade base (0 a 255)
const bool LINHA_ESCURA_ALTA = true;      // a barra lê MAIS alto sobre o preto?
// ---------- pinos ----------
const int SENS[5] = { 36, 39, 34, 35, 32 };   // S0 (esquerda) a S4 (direita), só ADC1
const int PWMA = 25, AIN1 = 26, AIN2 = 27;    // motor esquerdo
const int PWMB = 14, BIN1 = 16, BIN2 = 17;    // motor direito (STBY no 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() {                            // gira no lugar 2,5 s; é a única espera do programa
  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;                 // troca o sentido a cada 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 (linha sob S0) a 4000 (sob S4); 2000 = centro
  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 = em cima da linha
    if (n > 50) { soma += n * i * 1000; pesos += n; }         // ignora o ruído do fundo
  }
  naLinha = pesos > 300;
  if (!naLinha) return ultimoLado < 0 ? 0 : 4000;            // perdeu: vira para onde ela sumiu
  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 abre arquivo, t = 1 fecha
}

void cicloControle() {                       // roda a cada 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; }   // saiu da pista: para
  float erro = pos - 2000;                   // negativo: linha à esquerda
  integral = constrain(integral + erro, -20000, 20000);        // limita o 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 a 100 Hz
  if (n % 10 == 0) Serial.printf("erro:%.0f,corr:%.0f,esq:%d,dir:%d\n", erro, corr, esq, dir);   // Plotter a 20 Hz
}

void abrirProximo() {                        // /pid001.csv, /pid002.csv... um por corrida
  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) {                    // núcleo 0: grava sem atrasar o controle
  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" : "sem SD: segue sem log");
  Serial.println("BOOT: calibrar, depois seguir, depois parar");
}

void loop() {
  bool botao = botaoApertado();
  switch (estado) {
    case ESPERA:  if (botao) estado = CALIBRA; break;
    case CALIBRA: calibrar(); estado = PRONTO; Serial.println("Calibrado: ponha na linha e aperte 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, sem delay
      break;
  }
  digitalWrite(LED, estado == SEGUE);
}

Como verificar

  • ✓Na calibração, o Serial Monitor mostra 5 linhas como "S3: min 612 max 3580", com distância de pelo menos 1000 entre mínimo e máximo.
  • ✓Com o robô suspenso e a linha sob S3, o Plotter mostra erro positivo, esq maior que dir.
  • ✓Depois de uma corrida, o cartão tem /pid001.csv com a linha # KP=... no topo.
  • ✗Mínimo e máximo quase iguais: barra alta demais, sensor no pino errado ou LED IR desligado (olhe pela câmera do celular: o LED aparece roxo).

Por que aprender

Calibrar no início de cada corrida deixa o mesmo código funcionar em pistas, luzes e sensores diferentes. Em competição, é o que separa o robô que funcionou "na oficina" do que funciona no dia.

Conceitos-chave

Mínimo/máximo
fundo e linha, por sensor
Normalizar
todos de 0 a 1000
200 Hz
ciclo fixo de 5 ms
Log em outra tarefa
fila do módulo 2.4
5

📊 Ajuste por telemetria (antes/depois)

"Parece que melhorou" engana. Cada corrida grava um arquivo com os ganhos no cabeçalho, e você compara dois arquivos com dois números: o erro RMS (o tamanho típico do desvio; menor é melhor) e o erro máximo (o pior momento, em geral uma curva). Mude um parâmetro por vez, na mesma pista e com a bateria na mesma carga.

0 +− curvacurva antes: KP 0,12, KD 0 (RMS alto)depois: KP 0,08, KD 0,6

Como ler: o eixo horizontal é o tempo da corrida, o vertical é o erro (zero = linha no centro). A curva vermelha nunca sossega: o robô ziguezagueia até na reta. A ciano fica perto de zero e só sobe nas curvas, voltando em seguida. É esse formato que você procura no PNG.

Passo a passo de sintonia na pista

1

Só P, devagar

BASE 100, KI 0, KD 0. Suba KP (0,02 → 0,04 → 0,06...) até o robô seguir a pista inteira, mesmo balançando.

2

D para amortecer

Suba KD (0,2 → 0,4 → 0,8...) até o balanço sumir na reta. Se o robô começar a tremer rápido, voltou demais: desça um passo.

3

Mais velocidade

Suba BASE de 20 em 20. A cada degrau, refaça um ajuste fino de KP e KD olhando o RMS.

4

I só se precisar

Se o erro médio fica sempre de um lado (robô "puxado"), um KI pequeno (0,0005) resolve. Na maioria das pistas, KI = 0.

Objetivo: comparar duas corridas em números e num gráfico só. Use o mesmo ambiente Python do módulo 2.5. Para ver uma corrida sozinha, o analisa_log.py do 2.5 já serve: python analisa_log.py pid001.csv erro corr.

compara.py
# compara.py: antes x depois de um ajuste do PID
# Uso: 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")           # linha "# 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} | máx {df['erro'].abs().max()} | volta {t.iloc[-1]:.2f} s | saturado {satur:.0f}%")
    plt.plot(t, df["erro"], label=f"{arquivo} ({ganhos})")

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

Como verificar

  • ✓Uma linha por arquivo, com os ganhos e o RMS: o ajuste bom tem RMS menor e a curva fica mais perto de zero no comparacao.png.
  • ✓"saturado" acima de 20% indica BASE alta demais: as rodas já estão no limite e não sobra margem para corrigir.

Por que aprender

Medir antes e depois é o método de engenharia em miniatura: hipótese, uma mudança, dados, conclusão. Os mesmos dois números servem para ajustar o robô com câmera da Trilha 3 e para defender o projeto numa feira ou competição.

Conceitos-chave

Erro RMS
desvio típico
Erro máximo
o pior momento
Um parâmetro por vez
para saber o que ajudou
Saturação
PWM preso no limite
6

🏆 Desafios: curvas fechadas, cruzamentos, cronômetro

Com o robô completando a pista, os desafios abaixo treinam o que as provas de seguidor de linha (como a modalidade prática da OBR) cobram. Cada um é uma mudança pequena no sketch; faça um por vez e compare com o log.

↩️ Curvas fechadas

Reduza a base quando o erro cresce: int base = BASE - abs(erro) * 0.04; e use base no lugar de BASE. Reta rápida, curva segura.

✚ Cruzamentos

Quando os cinco sensores passam de 700 ao mesmo tempo, é um cruzamento: mantenha o último erro por 100 ms e siga reto, sem deixar o PID reagir à "linha larga".

⏱️ Cronômetro

Uma marca lateral na largada, lida por um sexto sensor (GPIO 33, também ADC1): guarde o millis() a cada passagem e imprima o tempo da volta.

✓ Segurança e método

  • ✓Primeiro teste de cada mudança com o robô suspenso, depois na pista.
  • ✓Mão perto do BOOT nas primeiras voltas; a parada por linha perdida é a segunda proteção.
  • ✓Anote no diário de bordo cada arquivo de log e o que mudou.

✗ Armadilhas de prova

  • ✗Ajustar com bateria cheia e competir com ela pela metade (ou o contrário).
  • ✗Pular a calibração "porque ontem funcionou": a luz do ginásio é outra.
  • ✗Deixar LiPo carregando sozinha na bancada durante os testes.

💡 Fechando a Trilha 2

Você tem agora um robô que se controla pelo celular, sabe medir o próprio movimento, roda PID em ritmo fixo, divide o trabalho em tarefas e registra tudo. O limite que sobra é o sensor: cinco pontos de luz no chão. Na Trilha 3 o robô ganha uma câmera e um Raspberry Pi 5, e o mesmo PID passa a seguir objetos e pessoas.

Por que aprender

Desafios pequenos e medidos são a ponte entre "funciona" e "funciona bem". O hábito de mudar uma coisa, gravar e comparar é o que você vai levar para todos os projetos das próximas trilhas.

Conceitos-chave

Base variável
freia na curva
Cruzamento
todos veem preto
Tempo de volta
millis() entre marcas
Diário de bordo
log + o que mudou

✅ Resumo do módulo e da Trilha 2

✓
Pista e barra: fundo fosco, barra a 3–8 mm, cinco sensores analógicos no ADC1.
✓
Posição por média ponderada de 0 a 4000; erro = posição − 2000; último lado para reencontrar a linha.
✓
esq = base + corr, dir = base − corr, com o PID a 200 Hz e saída limitada.
✓
Calibração a cada corrida: mínimo e máximo por sensor, tudo normalizado de 0 a 1000.
✓
Ajuste com números: um parâmetro por vez, RMS e máximo do erro, antes e depois.

Para ir além

Próxima trilha

Trilha 3 · Visão e IA embarcada. Começa pelo módulo 3.1: preparar o Raspberry Pi 5 com Linux, o "computador que pensa" ao lado do ESP32 que você já domina.