Conteúdo detalhado
🖼️ Como o computador vê uma imagem
Para o programa, uma foto não é uma cena: é uma tabela de números. Cada quadradinho, o pixel, guarda três valores de 0 a 255, um para cada cor básica. Uma imagem de 640 × 480 é uma tabela com 480 linhas, 640 colunas e 3 camadas. Em Python ela vira um array do NumPy com formato (480, 640, 3). Achar "a bola vermelha" é, no fundo, procurar uma região da tabela onde os números têm um certo padrão.
🌱 Novo aqui?
Um canal é uma das camadas de cor da imagem. O OpenCV guarda as cores na ordem BGR (azul, verde, vermelho), ao contrário do RGB que você vê em editores. O NumPy é a biblioteca Python de tabelas de números; o OpenCV é a biblioteca de visão computacional que trabalha em cima dessas tabelas. A coordenada (0, 0) é o canto superior esquerdo: x cresce para a direita e y cresce para baixo.
Como ler: à esquerda, um pedaço de imagem ampliado até aparecerem os quadradinhos. O pixel destacado em roxo é "vermelho" porque o número do canal R é alto e os outros são baixos. Repare na ordem B, G, R: é a do OpenCV. As setas ciano mostram que o y conta de cima para baixo.
Um canal só, de 0 (preto) a 255 (branco). Três vezes menos dados; ótimo para bordas e formas.
Três canais, como a câmera entrega. Bom para exibir e salvar, ruim para separar cor quando a luz muda.
Matiz, saturação e brilho. Separa "qual cor" de "quanta luz": o formato certo para achar objeto colorido (tópico 3).
Por que aprender
Quando a máscara não pega o objeto ou o centro aparece no lugar errado, o defeito quase sempre está aqui: cores trocadas (RGB tratado como BGR), eixo y invertido na cabeça ou imagem grande demais para o Pi processar a tempo.
Conceitos-chave
📷 Camera Module 3 com picamera2
A Camera Module 3 liga no conector de câmera do Pi 5 por um cabo flat (fita). No terminal, as ferramentas rpicam-apps (rpicam-hello, rpicam-still) testam a câmera em segundos. No seu código Python, quem entrega as imagens é a picamera2, que já vem no Raspberry Pi OS. Tutoriais antigos usam ferramentas de linha de comando que não existem mais no sistema atual; se o comando não começa com rpicam-, desconfie.
🌱 Novo aqui?
O conector do Pi 5 é menor (22 vias) que o das câmeras (15 vias): use o cabo próprio para Pi 5, que tem uma ponta de cada tamanho. O lado com os contatos metálicos segue a orientação indicada no conector; abra a trava com a unha, encaixe reto e feche. Faça isso com o Pi desligado.
Como ler: da esquerda para a direita, a luz vira números. O ISP (processador de imagem) corrige cor e exposição antes de você receber o quadro; por isso a primeira imagem logo após ligar a câmera pode sair escura, e o código espera um segundo antes de capturar.
Objetivo: confirmar que a câmera foi detectada, tirar uma foto pelo terminal e depois capturar um quadro em Python. Instale o OpenCV do sistema uma vez (o venv do módulo 3.1 enxerga ele).
rpicam-hello --list-cameras # deve listar a câmera (imx708 = Camera Module 3)
rpicam-still -o ~/robo/teste.jpg # tira uma foto pelo terminal
sudo apt install -y python3-opencv # OpenCV do sistema, visível no venv
source ~/robo-venv/bin/activate
# Captura um quadro da câmera e salva como JPEG
import time, os
import cv2
from picamera2 import Picamera2
picam2 = Picamera2()
config = picam2.create_preview_configuration(main={"size": (640, 480), "format": "RGB888"})
picam2.configure(config)
picam2.start()
time.sleep(1) # deixa a exposição automática se ajustar
quadro = picam2.capture_array() # array NumPy na ordem BGR
print("formato:", quadro.shape, quadro.dtype)
print("pixel do centro (B, G, R):", quadro[240, 320])
cv2.imwrite(os.path.expanduser("~/robo/quadro.jpg"), quadro)
picam2.stop()
print("salvo em ~/robo/quadro.jpg")
Como verificar
- ✓O terminal mostra formato: (480, 640, 3) uint8 e três números do pixel central.
- ✓Copie a foto para o seu computador (scp <seu_usuario>@robo.local:~/robo/quadro.jpg .) e abra: as cores devem estar naturais.
- ✗"no cameras available"? Desligue, confira o cabo (lado e trava) dos dois lados e ligue de novo.
Por que aprender
Esse bloco de configuração (tamanho, formato, start, capture_array) é a porta de entrada de tudo o que vem depois: máscara de cor, YOLO26 e o projeto da trilha. Testar primeiro pelo rpicam-hello separa problema de hardware de problema de código.
Conceitos-chave
🎨 OpenCV: cores, HSV e máscaras
Uma bola vermelha na sombra e a mesma bola ao sol têm números BGR bem diferentes. No espaço HSV a diferença cai quase toda no V (brilho), e o H (matiz, "qual cor") continua parecido. Por isso o caminho padrão é: converter o quadro para HSV, escolher uma faixa de H, S e V e criar uma máscara, uma imagem preto e branco em que só os pixels dentro da faixa ficam brancos.
🌱 Novo aqui?
Matiz (H) é a cor no círculo das cores; no OpenCV vai de 0 a 179. Saturação (S) é o quanto a cor é viva (0 = cinza). Brilho (V) é o quanto há de luz (0 = preto). Uma máscara é um filtro: branco onde a condição vale, preto no resto. O vermelho fica nas duas pontas do círculo (perto de 0 e perto de 179), por isso precisa de duas faixas somadas.
Como ler: a faixa é o círculo das cores aberto em linha reta, na escala 0–179 do OpenCV. As molduras roxas mostram por que o vermelho dá trabalho: ele começa e termina a régua. A moldura ciano mostra uma cor "comportada", que cabe numa janela só.
Objetivo: gerar a máscara de um objeto vermelho e salvar lado a lado o quadro e a máscara, para você ajustar a faixa olhando o resultado.
# Máscara de cor em HSV (vermelho = duas faixas)
import time, os
import cv2, numpy as np
from picamera2 import Picamera2
picam2 = Picamera2()
picam2.configure(picam2.create_preview_configuration(main={"size": (640, 480), "format": "RGB888"}))
picam2.start(); time.sleep(1)
quadro = picam2.capture_array()
picam2.stop()
hsv = cv2.cvtColor(quadro, cv2.COLOR_BGR2HSV)
baixo1, alto1 = np.array([0, 120, 70]), np.array([10, 255, 255]) # vermelho, começo da régua
baixo2, alto2 = np.array([170, 120, 70]), np.array([179, 255, 255]) # vermelho, fim da régua
mascara = cv2.inRange(hsv, baixo1, alto1) | cv2.inRange(hsv, baixo2, alto2)
# Limpeza: tira pontinhos soltos e fecha buracos
nucleo = np.ones((5, 5), np.uint8)
mascara = cv2.morphologyEx(mascara, cv2.MORPH_OPEN, nucleo)
mascara = cv2.morphologyEx(mascara, cv2.MORPH_CLOSE, nucleo)
brancos = cv2.countNonZero(mascara)
print(f"pixels na máscara: {brancos} ({100 * brancos / mascara.size:.1f}% da imagem)")
lado_a_lado = np.hstack([quadro, cv2.cvtColor(mascara, cv2.COLOR_GRAY2BGR)])
cv2.imwrite(os.path.expanduser("~/robo/mascara.jpg"), lado_a_lado)
print("salvo em ~/robo/mascara.jpg")
Como verificar
- ✓Com um objeto vermelho na frente, a metade direita de mascara.jpg mostra uma mancha branca no formato dele.
- ✓Sem o objeto, a porcentagem cai para perto de 0%.
- ✗Pele e madeira aparecendo? Suba o S mínimo (120 → 150). Objeto sumindo na sombra? Baixe o V mínimo.
✓ Ajuda a cor funcionar
- ✓Objeto de cor viva e fosca (bola, cartolina, fita).
- ✓Fundo de cor bem diferente da do objeto.
- ✓Ajustar a faixa no lugar onde o robô vai rodar.
✗ Atrapalha
- ✗Objeto brilhante que reflete a lâmpada (vira branco).
- ✗Luz amarelada ou piscando, que muda o matiz.
- ✗Calibrar de dia e testar à noite.
Por que aprender
Máscara de cor é a forma mais leve de visão: roda a dezenas de quadros por segundo no Pi, sem IA, e basta para um robô seguir uma bola ou uma faixa colorida. É a primeira versão do projeto 3.6, antes de entrar o YOLO26.
Conceitos-chave
🎯 Contornos e centro do objeto
A máscara diz "aqui tem vermelho", mas o robô precisa de dois números: onde está o objeto e quão perto. O OpenCV encontra os contornos (as bordas de cada mancha branca), você fica com o maior, calcula o centro dele e a área. O centro vira o erro horizontal (quanto o objeto está à esquerda ou à direita do meio da imagem) e a área indica a distância: quanto maior, mais perto.
Como ler: o retângulo é a máscara; a linha tracejada é o meio da imagem. Das duas manchas, só a maior conta (a pequena é ruído). A seta ciano é o erro: positivo quando o objeto está à direita, negativo à esquerda, zero quando está centralizado. É esse número que o PID do projeto 3.6 vai zerar.
Objetivo: rodar em laço contínuo, imprimir centro, erro e área do maior objeto vermelho e dizer para que lado o robô deveria virar.
# Acha o maior objeto vermelho e calcula centro, erro e área
import time
import cv2, numpy as np
from picamera2 import Picamera2
LARGURA, ALTURA = 640, 480
AREA_MIN = 800 # menor que isso é ruído
FAIXAS = [((0, 120, 70), (10, 255, 255)), ((170, 120, 70), (179, 255, 255))]
nucleo = np.ones((5, 5), np.uint8)
def achar_objeto(quadro):
hsv = cv2.cvtColor(quadro, cv2.COLOR_BGR2HSV)
mascara = np.zeros(hsv.shape[:2], np.uint8)
for baixo, alto in FAIXAS: # junta as faixas (OU)
mascara |= cv2.inRange(hsv, np.array(baixo), np.array(alto))
mascara = cv2.morphologyEx(mascara, cv2.MORPH_OPEN, nucleo)
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)
cx, cy = int(m["m10"] / m["m00"]), int(m["m01"] / m["m00"])
return cx, cy, area
picam2 = Picamera2()
picam2.configure(picam2.create_preview_configuration(main={"size": (LARGURA, ALTURA), "format": "RGB888"}))
picam2.start(); time.sleep(1)
print("Mostre um objeto vermelho. Ctrl+C para sair.")
try:
while True:
achado = achar_objeto(picam2.capture_array())
if achado is None:
print("nenhum objeto → parar e procurar")
else:
cx, cy, area = achado
erro = cx - LARGURA // 2
lado = "centro" if abs(erro) < 40 else ("direita" if erro > 0 else "esquerda")
print(f"cx={cx:3d} cy={cy:3d} erro={erro:+4d} area={area:7.0f} → {lado}")
time.sleep(0.1)
except KeyboardInterrupt:
picam2.stop()
Como verificar
- ✓Movendo o objeto para a direita da câmera, o erro fica positivo e aparece "→ direita".
- ✓Aproximando o objeto, a área cresce bastante; afastando, diminui.
- ✗Direita e esquerda trocadas? A câmera está de cabeça para baixo ou espelhada: inverta o sinal do erro ou gire a câmera.
💡 Área não é distância em metros
A área serve para comparar ("mais perto", "longe demais"), não para medir. Anote a área com o objeto a 30 cm e a 1 m: esses dois números viram os limites de "chegou" e "aproxime" no projeto.
Por que aprender
Centro e área são a ponte entre visão e movimento. O YOLO26 do próximo módulo entrega uma caixa em vez de um contorno, mas você vai calcular exatamente o mesmo erro e o mesmo "tamanho" a partir dela.
Conceitos-chave
⏱️ Medir FPS e reduzir resolução
Um robô que vê a 3 quadros por segundo reage tarde: andando a meio metro por segundo, ele percorre mais de 15 cm entre uma imagem e outra. O número que importa é o FPS do laço inteiro (capturar + processar + decidir), não o da câmera. A alavanca mais forte é a resolução: metade da largura e da altura é um quarto dos pixels, e quase todo o processamento cai junto.
🌱 Novo aqui?
FPS (quadros por segundo) conta quantas voltas completas o seu laço dá em um segundo. Latência é o atraso entre algo acontecer na frente da câmera e o robô reagir. Os dois andam juntos: FPS baixo quase sempre significa reação atrasada.
Como ler: cada barra é o trabalho de um quadro. Ir de Full HD para 640 × 480 corta o trabalho em quase sete vezes. Para seguir uma bola colorida, até 320 × 240 resolve; para o YOLO26, 640 × 480 é o tamanho natural.
Objetivo: medir o FPS real do laço "capturar + HSV + máscara" em três resoluções e escolher a sua com números.
# Mede o FPS do laço completo em três resoluções (5 s cada)
import time
import cv2
from picamera2 import Picamera2
picam2 = Picamera2()
for largura, altura in [(1280, 720), (640, 480), (320, 240)]:
cfg = picam2.create_video_configuration(main={"size": (largura, altura), "format": "RGB888"})
picam2.configure(cfg)
picam2.start(); time.sleep(1)
n, t0 = 0, time.perf_counter()
while time.perf_counter() - t0 < 5:
quadro = picam2.capture_array()
hsv = cv2.cvtColor(quadro, cv2.COLOR_BGR2HSV) # trabalho típico
mascara = cv2.inRange(hsv, (0, 120, 70), (10, 255, 255))
n += 1
fps = n / (time.perf_counter() - t0)
print(f"{largura}x{altura}: {fps:5.1f} FPS ({1000 / fps:5.1f} ms por volta)")
picam2.stop()
Como verificar
- ✓Aparecem três linhas, e o FPS sobe conforme a resolução cai (o teto é a taxa da própria câmera).
- ✓Anote os números: eles são a sua referência para comparar com o YOLO26 no 3.3.
- ✗FPS caindo com o tempo? Rode o saude.py do 3.1: provavelmente é calor.
✓ Ganha FPS
- ✓Pedir à câmera a resolução final (sem redimensionar depois).
- ✓Processar só a faixa da imagem que interessa (por exemplo, a metade de baixo).
- ✓Imprimir menos: print a cada quadro também custa.
✗ Perde FPS
- ✗Salvar JPEG em todo quadro no cartão.
- ✗Abrir área de trabalho com janela de vídeo enquanto o robô roda.
- ✗Pi sem cooler: começa rápido e fica lento.
Por que aprender
Sem medir, toda otimização é chute. Com o FPS na mão você decide com dados entre "cor rápida" e "IA mais lenta", e sabe quanto o robô pode andar entre duas decisões sem perder o objeto.
Conceitos-chave
🌐 Transmitir o vídeo para o navegador
O Pi no robô não tem tela, e janelas de vídeo pelo SSH não funcionam. A solução é o Pi virar um pequeno servidor web: ele comprime cada quadro em JPEG e manda a sequência pela rede no formato MJPEG, que qualquer navegador mostra como vídeo. Assim você vê, no celular ou no notebook, exatamente o que o robô vê, já com as marcações do seu código.
🌱 Novo aqui?
MJPEG é "JPEG em movimento": uma foto atrás da outra, numa conexão que fica aberta. É simples e tem pouco atraso, mas gasta mais rede que vídeo comprimido de verdade. A porta (aqui, 8000) é o número que diferencia os serviços dentro do mesmo computador; por isso o endereço termina em :8000.
Como ler: dentro do Pi (tracejado), duas tarefas rodam juntas: uma só captura e prepara o JPEG mais recente; a outra só entrega. Separar as duas impede que um navegador lento atrase a visão do robô.
Objetivo: ver o vídeo do robô no navegador, com o centro do objeto vermelho marcado. Usa só a biblioteca padrão do Python, sem instalar nada.
# Vídeo da câmera no navegador (MJPEG) com o centro do objeto marcado
import threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import cv2, numpy as np
from picamera2 import Picamera2
picam2 = Picamera2()
picam2.configure(picam2.create_video_configuration(main={"size": (640, 480), "format": "RGB888"}))
picam2.start()
ultimo_jpeg, trava = None, threading.Lock()
def capturar():
global ultimo_jpeg
while True:
quadro = picam2.capture_array()
hsv = cv2.cvtColor(quadro, cv2.COLOR_BGR2HSV)
mascara = cv2.inRange(hsv, (0, 120, 70), (10, 255, 255)) | cv2.inRange(hsv, (170, 120, 70), (179, 255, 255))
contornos, _ = cv2.findContours(mascara, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if contornos:
maior = max(contornos, key=cv2.contourArea)
m = cv2.moments(maior)
if m["m00"] > 800:
cx, cy = int(m["m10"] / m["m00"]), int(m["m01"] / m["m00"])
cv2.drawContours(quadro, [maior], -1, (255, 0, 255), 2)
cv2.circle(quadro, (cx, cy), 6, (255, 255, 0), -1)
cv2.line(quadro, (320, 0), (320, 480), (200, 200, 200), 1) # meio da imagem
ok, buf = cv2.imencode(".jpg", quadro, [cv2.IMWRITE_JPEG_QUALITY, 70])
if ok:
with trava:
ultimo_jpeg = buf.tobytes()
PAGINA = b'<html><body style="margin:0;background:#111"><img src="/video" style="width:100%"></body></html>'
class Servidor(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/":
self.send_response(200); self.send_header("Content-Type", "text/html"); self.end_headers()
self.wfile.write(PAGINA)
elif self.path == "/video":
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=QUADRO")
self.end_headers()
try:
while True:
with trava:
jpg = ultimo_jpeg
if jpg:
self.wfile.write(b"--QUADRO\r\nContent-Type: image/jpeg\r\n\r\n" + jpg + b"\r\n")
time.sleep(0.05) # até ~20 quadros por segundo
except (BrokenPipeError, ConnectionResetError):
pass # o navegador fechou a aba
else:
self.send_error(404)
def log_message(self, *args):
pass # silencia o log de cada pedido
threading.Thread(target=capturar, daemon=True).start()
print("Abra http://robo.local:8000 no navegador. Ctrl+C para sair.")
ThreadingHTTPServer(("0.0.0.0", 8000), Servidor).serve_forever()
Como verificar
- ✓No celular (mesmo Wi-Fi), http://robo.local:8000 mostra o vídeo com uma linha vertical no meio.
- ✓Um objeto vermelho ganha contorno magenta e um ponto ciano no centro.
- ✗O celular não abre robo.local? Use o IP do Pi (hostname -I) no lugar do nome.
💡 Rede local apenas
Esse servidor não tem senha: qualquer pessoa na mesma rede vê a câmera. Use em casa ou no laboratório, nunca exposto à internet. Em rede de escola, combine com o professor antes de ligar.
Por que aprender
Ver o que o robô vê é a ferramenta de depuração mais poderosa da trilha. Quando ele girar para o lado errado no projeto 3.6, você vai olhar o vídeo e saber em segundos se o problema é a visão (marcou o objeto errado) ou o controle (marcou certo e virou errado).
Conceitos-chave
✅ Resumo do módulo
Para ir além
- Software de câmera do Raspberry Pi — rpicam-apps e picamera2
- Câmeras do Raspberry Pi — modelos, cabos e instalação
- OpenCV: espaços de cor — HSV e inRange passo a passo
- OpenCV: contornos — momentos, área e caixas
- Documentação do OpenCV — referência completa
Próximo módulo
3.3 · Detectar objetos com YOLO26: do "ache o vermelho" para "ache a pessoa, o gato ou a garrafa", rodando no Pi 5.