PTENES
MÓDULO 3.6 · PROJETO

🎯 Projeto: robô que segue um objeto

Tudo da trilha num robô só: a câmera do Pi 5 encontra o alvo (uma bola colorida com OpenCV ou uma pessoa com YOLO26), o programa transforma a posição em erro, o PID transforma o erro em velocidade de cada roda, e o ESP32 do módulo 3.5 executa com parada segura.

6
Tópicos
Avançado
Nível
Projeto
Tipo
Rover + Pi 5
Hardware
0%
0 de 0 tópicos lidos · Seção 1 de 6

Conteúdo detalhado

1

🗺️ O plano: câmera → detecção → erro → motores

O robô quer duas coisas: manter o alvo no centro da imagem e a uma distância fixa. A câmera não mede distância, mas o tamanho aparente do objeto serve: perto ele ocupa muitos pixels, longe ocupa poucos. Então cada quadro vira dois números de erro, e cada erro controla um movimento: o erro horizontal manda girar, o erro de tamanho manda avançar ou recuar.

câmeraquadro detecçãocentro, tamanho errogiro, distância PIDesq, dir ESP32motores Raspberry Pi 5UART o robô se move, o alvo muda de lugar na imagem, e tudo recomeça

Como ler: as quatro caixas roxas rodam no Pi, uma vez por quadro; a ciano é o ESP32. O destaque está no erro porque é ali que a imagem vira número. Se algo falha, descubra em qual caixa: a detecção não acha o alvo, o erro sai com sinal trocado ou o motor não obedece.

🌱 Novo aqui?

Erro normalizado é o erro dividido pela metade da largura da imagem, para ficar entre −1 (alvo na borda esquerda) e +1 (borda direita), com 0 no centro. Assim os ganhos do controle não mudam se você trocar a resolução. Ganho é o número que multiplica o erro para virar velocidade.

Por que aprender

Seguir com a câmera é o mesmo laço sentir-pensar-agir do módulo 1.1, só que o sensor agora é uma imagem e o "pensar" inclui visão computacional. Esse desenho (detector → erro → controlador → atuador) serve para câmera de segurança que acompanha, drone que segue e braço que mira.

Conceitos-chave

Erro de giro
centro do alvo − centro
Erro de distância
tamanho alvo − atual
Normalizar
erro entre −1 e 1
Diferencial
gira com rodas diferentes
2

🔧 Montagem do Pi + ESP32 no chassi

Parta do rover da Trilha 1 (chassi, motores, TB6612FNG e ESP32). O que entra: o Pi 5 com cooler ativo, a Camera Module 3 na frente e uma alimentação separada para o Pi. O Pi 5 pede 5 V com até 5 A (a fonte oficial é de 27 W); no robô, use um regulador buck de 5 V e 5 A ligado a um pack 2S (duas 18650) com BMS. Os motores ficam na bateria deles, e todos os GND se encontram.

câmera Pi 5+ cooler ESP32 TB6612FNG bateria 2S+ buck 5 V bat. motores frente ↑ rodas UARTtracejado

Como ler: a câmera vai centralizada na frente, porque o código assume que o centro da imagem é a frente do robô. O roxo alimenta o Pi; o âmbar alimenta só a ponte H. Separar as duas fontes impede que o pico de corrente do motor reinicie o Pi. O tracejado é o cabo UART do módulo 3.5.

Passo a passo

1

Teste cada parte na bancada

Pi com câmera (módulo 3.2), ESP32 com o receptor (3.5) e o link_esp32.py girando as rodas no ar. Só monte o que já funcionou solto.

2

Fixe com folga para o ar

Pi sobre espaçadores (uma segunda placa de acrílico ou MDF ajuda), cooler livre. A câmera numa peça firme, na altura do alvo e levemente inclinada para baixo.

3

Ligue energia e sinais

Buck ajustado e medido com multímetro (5,1 V) antes de ligar no Pi. Três fios de UART com GND comum. Chave de corte na bateria dos motores, ao alcance da mão.

4

Acesse sem fio

Pi no Wi-Fi e SSH do módulo 3.1. Rode os programas pelo SSH, com o robô sobre um apoio, rodas no ar, na primeira vez.

✓ Faça

  • ✓Medir a saída do buck antes de conectar o Pi.
  • ✓Prender o cabo flat da câmera para não dobrar nem soltar.
  • ✓Desligar o Pi com sudo poweroff antes de cortar a energia.

✗ Não faça

  • ✗Alimentar o Pi pelo 5 V do ESP32 ou de um módulo L298N.
  • ✗Ligar a bateria direto no pino de 5 V do Pi sem regulador.
  • ✗Tampar o cooler: o Pi 5 reduz a velocidade quando esquenta e o FPS cai.

Por que aprender

Metade dos "bugs" de robô com Pi são de energia: reinício aleatório, cartão corrompido, câmera que some. Montar com fontes separadas, GND comum e medição antes de ligar elimina esses fantasmas antes de você abrir o código.

Conceitos-chave

Buck 5 V / 5 A
energia do Pi
Fontes separadas
motor não derruba o Pi
Câmera centrada
centro = frente
Chave de corte
parada física
3

🔵 Seguir um objeto colorido (OpenCV)

Comece pela versão mais leve: uma bola de cor forte (azul, por exemplo), achada pela máscara HSV e pelo maior contorno, exatamente como no módulo 3.2. Em 320×240 o Pi 5 processa dezenas de quadros por segundo, o que deixa o controle suave. O controle aqui é só proporcional (velocidade = ganho × erro); no tópico 5 ele vira PID.

centro x = 160 cx = 232 erro de giro (232 − 160) ÷ 160 = +0,45 alvo à direita → virar à direita rodas esq = avanço + giro dir = avanço − giro área pequena → longe → avança

Como ler: a linha tracejada é o centro da imagem, que coincide com a frente do robô. A seta roxa é o erro: ela aponta para onde o robô precisa virar. Com erro positivo, a roda esquerda gira mais que a direita e o robô vira para a direita, trazendo a bola de volta ao centro.

Objetivo: o robô gira até centralizar a bola e anda até ela ficar do tamanho desejado. Salve na mesma pasta do link_esp32.py e rode no venv do curso (criado com --system-site-packages para enxergar o picamera2). Ajuste HSV_MIN/HSV_MAX para a sua cor.

seguidor_cor.py
#!/usr/bin/env python3
"""Robô que segue um objeto colorido (módulo 3.6). Requer link_esp32.py (módulo 3.5) na mesma pasta."""
import time
import cv2
import numpy as np
from picamera2 import Picamera2
from link_esp32 import Link

LARGURA, ALTURA = 320, 240
HSV_MIN = np.array([100, 120, 70])    # faixa do azul; calibre com o módulo 3.2
HSV_MAX = np.array([130, 255, 255])
AREA_MIN = 300                        # menor que isso é ruído
AREA_ALVO = 6000                      # área (pixels) na distância desejada: meça a sua
KP_GIRO, KP_DIST, VEL_MAX = 180, 160, 180


def acha_objeto(quadro):
    hsv = cv2.cvtColor(quadro, cv2.COLOR_BGR2HSV)
    mascara = cv2.inRange(hsv, HSV_MIN, HSV_MAX)
    mascara = cv2.morphologyEx(mascara, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
    contornos, _ = cv2.findContours(mascara, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contornos:
        return None
    maior = max(contornos, key=cv2.contourArea)
    area = cv2.contourArea(maior)
    if area < AREA_MIN:
        return None
    m = cv2.moments(maior)
    return m["m10"] / m["m00"], area      # centro x e área


def comando(cx, area):
    erro_x = (cx - LARGURA / 2) / (LARGURA / 2)                    # -1 esquerda .. +1 direita
    erro_d = max(-1.0, min(1.0, (AREA_ALVO - area) / AREA_ALVO))   # >0 longe, <0 perto
    giro, avanco = KP_GIRO * erro_x, KP_DIST * erro_d
    esq = max(-VEL_MAX, min(VEL_MAX, avanco + giro))
    drt = max(-VEL_MAX, min(VEL_MAX, avanco - giro))
    return esq, drt


def main():
    cam = Picamera2()
    cam.configure(cam.create_video_configuration(main={"size": (LARGURA, ALTURA), "format": "RGB888"}))
    cam.start()
    link = Link()
    t0, quadros, esq, drt = time.monotonic(), 0, 0, 0
    try:
        while True:
            quadro = cam.capture_array()     # "RGB888" do picamera2 já vem na ordem BGR do OpenCV
            achado = acha_objeto(quadro)
            esq, drt = comando(*achado) if achado else (0, 0)   # perdeu o alvo: parado
            link.motores(esq, drt)           # todo quadro manda um comando: mantém o ESP32 vivo
            quadros += 1
            if time.monotonic() - t0 >= 1.0:
                print(f"{quadros} FPS | alvo={'sim' if achado else 'nao'} | esq={esq:.0f} dir={drt:.0f}")
                t0, quadros = time.monotonic(), 0
    except KeyboardInterrupt:
        pass
    finally:
        link.fechar()                        # manda "S"
        cam.stop()


if __name__ == "__main__":
    main()

Como verificar (rodas no ar primeiro)

  • ✓O terminal mostra uma linha por segundo com o FPS (espere algo acima de 20) e "alvo=sim" com a bola na frente.
  • ✓Bola à direita: a roda esquerda gira mais. Bola perto demais: as duas giram para trás.
  • ✗Vira para o lado errado? Troque os fios de um motor ou inverta o sinal de giro. Câmera montada de cabeça para baixo também inverte.

Por que aprender

A versão por cor é rápida, barata e fácil de depurar, então é a melhor para acertar a mecânica, os sinais e os ganhos. Quando ela funciona, trocar o detector por uma rede neural é mudar uma função.

Conceitos-chave

Máscara HSV
separa a cor
Momentos
centro do contorno
Controle P
ganho × erro
AREA_ALVO
distância desejada
4

🧍 Seguir uma pessoa ou objeto (YOLO26)

Para seguir uma pessoa, uma cor não basta. Troque o detector pelo YOLO26n exportado para NCNN (módulo 3.3). Em vez de área, use a altura da caixa como medida de distância: ela muda menos quando a pessoa abre os braços. O detector é mais lento que a máscara de cor (alguns quadros por segundo em CPU), por isso os ganhos aqui são menores. Com o AI HAT+ o FPS sobe bastante.

🌱 Novo aqui?

O YOLO26 pré-treinado conhece as 80 classes do conjunto COCO; a classe 0 é "person", a 32 é "sports ball". Cada detecção vem com uma caixa (x1, y1, x2, y2), a classe e a confiança (0 a 1). Ignore detecções com confiança baixa para não perseguir fantasmas.

Objetivo: seguir a maior pessoa da imagem. Antes, exporte o modelo no tamanho usado aqui: yolo export model=yolo26n.pt format=ncnn imgsz=320 (cria a pasta yolo26n_ncnn_model).

seguidor_yolo.py
#!/usr/bin/env python3
"""Segue uma pessoa com YOLO26 (NCNN) no Pi 5 (módulo 3.6). Requer link_esp32.py e o modelo exportado."""
import time
from picamera2 import Picamera2
from ultralytics import YOLO
from link_esp32 import Link

LARGURA, ALTURA = 640, 480
CLASSE = 0              # 0 = "person" no COCO; 32 = "sports ball"
CONF_MIN = 0.5
ALTURA_ALVO = 0.6       # altura da caixa ÷ altura da imagem na distância desejada
KP_GIRO, KP_DIST, VEL_MAX = 140, 150, 150
modelo = YOLO("yolo26n_ncnn_model")


def maior_caixa(resultado):
    melhor = None
    for caixa in resultado.boxes:
        if int(caixa.cls) != CLASSE or float(caixa.conf) < CONF_MIN:
            continue
        x1, y1, x2, y2 = caixa.xyxy[0].tolist()
        if melhor is None or (y2 - y1) > (melhor[3] - melhor[1]):
            melhor = (x1, y1, x2, y2)
    return melhor


def main():
    cam = Picamera2()
    cam.configure(cam.create_video_configuration(main={"size": (LARGURA, ALTURA), "format": "RGB888"}))
    cam.start()
    link = Link()
    try:
        while True:
            t = time.monotonic()
            quadro = cam.capture_array()
            caixa = maior_caixa(modelo.predict(quadro, imgsz=320, verbose=False)[0])
            esq = drt = 0
            if caixa:
                x1, y1, x2, y2 = caixa
                erro_x = ((x1 + x2) / 2 - LARGURA / 2) / (LARGURA / 2)
                erro_d = max(-1.0, min(1.0, (ALTURA_ALVO - (y2 - y1) / ALTURA) / ALTURA_ALVO))
                giro, avanco = KP_GIRO * erro_x, KP_DIST * erro_d
                esq = max(-VEL_MAX, min(VEL_MAX, avanco + giro))
                drt = max(-VEL_MAX, min(VEL_MAX, avanco - giro))
            link.motores(esq, drt)
            print(f"{(time.monotonic() - t) * 1000:.0f} ms | pessoa={'sim' if caixa else 'nao'} | esq={esq:.0f} dir={drt:.0f}")
    except KeyboardInterrupt:
        pass
    finally:
        link.fechar()
        cam.stop()


if __name__ == "__main__":
    main()

Como verificar

  • ✓Cada linha mostra o tempo do ciclo. Se passar de 300 ms, o ESP32 para entre um quadro e outro: reduza imgsz ou use o AI HAT+.
  • ✓Ande devagar na frente do robô: ele gira para manter você no centro e para a uns dois metros.
  • ✗"pessoa=nao" com você na frente? Luz fraca ou contra a janela; teste o modelo sozinho como no módulo 3.3.

Por que aprender

Trocar a cor por uma rede neural mostra o valor da arquitetura em camadas: o link, o ESP32 e a lógica de rodas ficaram iguais. Treinando o YOLO26 com as suas imagens (módulo 3.3), o mesmo programa segue o seu objeto, o mascote da escola ou outro robô.

Conceitos-chave

Classe COCO
0 = person
Confiança
corte em 0,5
Altura da caixa
medida de distância
Tempo de ciclo
abaixo de 300 ms
5

🎛️ PID de giro e de distância

Só com o termo P, o robô costuma passar do ponto e balançar de um lado para o outro (o atraso da câmera piora isso). O D freia a virada quando o erro já está diminuindo; o I corrige a pequena distância que sobra por atrito. São dois PIDs independentes, como no módulo 2.3: um para o giro, outro para a distância, somados na hora de mandar as rodas.

erro de giro erro de dist. PID giroP + D PID dist.P + I esq = avanço + giro dir = avanço − giro soma

Como ler: cada erro tem o seu controlador, e as setas cruzadas mostram que as duas rodas recebem as duas parcelas. O giro entra com sinal oposto em cada roda; o avanço entra igual. Ajuste um PID de cada vez: primeiro o giro com o avanço zerado, depois a distância.

Objetivo: um PID reutilizável, com limite de saída e proteção contra windup (a integral para de crescer quando a saída satura).

pid.py
"""PID simples com limite e anti-windup (módulo 3.6)."""
import time


class PID:
    def __init__(self, kp, ki=0.0, kd=0.0, limite=255.0):
        self.kp, self.ki, self.kd, self.limite = kp, ki, kd, limite
        self.reset()

    def reset(self):
        self.integral, self.erro_ant, self.t_ant = 0.0, 0.0, None

    def __call__(self, erro):
        agora = time.monotonic()
        dt = 0.0 if self.t_ant is None else agora - self.t_ant
        deriv = (erro - self.erro_ant) / dt if dt > 0 else 0.0
        self.integral += erro * dt
        saida = self.kp * erro + self.ki * self.integral + self.kd * deriv
        if abs(saida) > self.limite:          # saturou: desfaz a soma (anti-windup)
            self.integral -= erro * dt
            saida = max(-self.limite, min(self.limite, saida))
        self.erro_ant, self.t_ant = erro, agora
        return saida

No seguidor_cor.py, acrescente o import e os dois objetos depois das constantes e troque a função comando inteira por esta. No main(), quando o alvo some, chame pid_giro.reset() e pid_dist.reset().

seguidor_cor.py (trecho que muda)
from pid import PID

pid_giro = PID(kp=180, ki=0.0, kd=20, limite=150)   # comece com kd=0 e suba aos poucos
pid_dist = PID(kp=160, ki=30, kd=0, limite=150)


def comando(cx, area):
    erro_x = (cx - LARGURA / 2) / (LARGURA / 2)
    erro_d = max(-1.0, min(1.0, (AREA_ALVO - area) / AREA_ALVO))
    if abs(erro_x) < 0.05:                    # zona morta: não tremer com o alvo no centro
        erro_x = 0.0
    giro, avanco = pid_giro(erro_x), pid_dist(erro_d)
    esq = max(-VEL_MAX, min(VEL_MAX, avanco + giro))
    drt = max(-VEL_MAX, min(VEL_MAX, avanco - giro))
    return esq, drt

Como verificar: com o robô no chão, empurre a bola para o lado. Só com P ele passa do ponto e volta; com o D ajustado, chega ao centro com no máximo uma pequena ultrapassagem. Se começar a tremer, o kd está alto demais.

Por que aprender

O mesmo PID do seguidor de linha da Trilha 2, agora com um sensor lento e ruidoso (a câmera). Aprender a sintonizar com atraso é o que separa um robô que persegue suavemente de um que dança em volta do alvo.

Conceitos-chave

Dois PIDs
giro e distância
Anti-windup
integral não explode
Zona morta
ignora erro pequeno
reset()
ao perder o alvo
6

✅ Testes, segurança e melhorias

Teste em camadas, do mais seguro para o mais real: rodas no ar, chão livre com velocidade máxima baixa (VEL_MAX = 100), e só depois a velocidade final. Anote em cada rodada o que mudou e o resultado, como no diário de bordo da Trilha 2. Os problemas mais comuns têm causa conhecida:

SintomaCausa provávelO que mexer
Balança em volta do alvoKP_GIRO alto, atraso da câmerabaixar kp, subir kd
Para longe do alvoatrito vence o PWM baixosubir ki da distância
Para e anda aos trancosciclo maior que 300 msmenor resolução, NCNN, AI HAT+
Persegue a cortina azulfaixa HSV larga demaisestreitar HSV, subir AREA_MIN
Pi reinicia ao arrancarenergia compartilhadafontes separadas (tópico 2)

✓ Segurança

  • ✓Chave de corte dos motores sempre ao alcance.
  • ✓Área de teste livre de escadas e cabos; ninguém deitado no chão.
  • ✓Teste o timeout: Ctrl+C e cabo puxado param o robô.

✗ Evite

  • ✗Seguir crianças ou animais em velocidade alta.
  • ✗Testar na mesa: um erro de sinal e o robô cai.
  • ✗Mudar três ganhos de uma vez: você não sabe qual ajudou.

Melhorias (desafios)

🔄 Procurar quando perde

Em vez de parar, gire devagar para o lado onde o alvo foi visto por último, por no máximo 5 segundos.

📉 Filtrar a posição

Uma média móvel do centro x (módulo 1.4) reduz o tremor causado por ruído na detecção.

🛑 Freio por proximidade

Ligue o HC-SR04 no ESP32: abaixo de 20 cm ele ignora o comando de avançar, decida o Pi o que decidir.

📺 Ver o que o robô vê

Desenhe a caixa e o erro no quadro e transmita para o navegador, como no módulo 3.2.

💡 Próximo degrau: ROS 2

O robô da Trilha 3 já tem o desenho de um robô com ROS 2: um computador que pensa, um microcontrolador que age e mensagens entre os dois. Na Trilha 4 o protocolo caseiro vira tópicos (/cmd_vel), o ESP32 fala micro-ROS, e um LiDAR acrescenta mapa e navegação.

Por que aprender

Um projeto termina quando funciona de forma repetível e segura, não na primeira vez que dá certo. Documentar sintomas, causas e ajustes é o que permite apresentar o robô, ensinar com ele e continuar evoluindo sem recomeçar.

Conceitos-chave

Teste em camadas
ar, chão lento, final
Um ajuste por vez
e anotar
Trava local
ESP32 vence o Pi
/cmd_vel
o mesmo, em ROS 2

✅ Resumo do módulo e da trilha

✓
Laço câmera → detecção → erro → PID → ESP32, rodando a cada quadro.
✓
Montagem com fontes separadas: buck 5 V/5 A para o Pi, bateria própria para motores, GND comum, chave de corte.
✓
Dois detectores, mesmo controle: cor com OpenCV para acertar a mecânica, YOLO26 em NCNN para pessoas.
✓
Dois PIDs: giro (P+D) e distância (P+I), com anti-windup e zona morta.
✓
Trilha 3 completa: Pi 5 e Linux, câmera, YOLO26, TinyML no ESP32-S3, link Pi↔ESP32 e um robô que segue.

Para ir além

Próximo: Trilha 4 · ROS 2 e navegação

4.1 · ROS 2 sem medo: nós, tópicos e mensagens. O seu robô da Trilha 3 ganha mapa, localização e navegação de cômodo em cômodo.