PTENES
MÓDULO 3.2 · PRÁTICA

📷 Câmera e OpenCV

O robô ganha olhos. Você entende o que é uma imagem para o computador, liga a Camera Module 3 com a picamera2, separa um objeto pela cor com o OpenCV, encontra o centro dele, mede quantas imagens por segundo o Pi aguenta e assiste ao vídeo do robô no navegador do celular.

6
Tópicos
Intermediário
Nível
Prática
Tipo
Pi 5 + câmera
Hardware
0%
0 de 0 tópicos lidos · Seção 1 de 6

Conteúdo detalhado

1

🖼️ 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.

um pixel = 3 números B (azul) 30 G (verde) 40 R (vermelho) 220 (0, 0) x → direita y ↓ para baixo imagem 640 × 480 → array (480, 640, 3): linhas, colunas, canais

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.

⬛ Tons de cinza

Um canal só, de 0 (preto) a 255 (branco). Três vezes menos dados; ótimo para bordas e formas.

🌈 BGR

Três canais, como a câmera entrega. Bom para exibir e salvar, ruim para separar cor quando a luz muda.

🎨 HSV

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

Pixel
menor ponto da imagem
Canal
uma camada de cor
BGR
a ordem do OpenCV
shape
(altura, largura, canais)
2

📷 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.

sensorCamera Module 3 ISP do Picor, exposição picamera2array NumPy OpenCVseu código cabo flat capture_array() "RGB888" na picamera2 já chega na ordem BGR que o OpenCV espera

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).

terminal do Pi
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
~/robo/captura.py
# 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

rpicam-apps
teste no terminal
picamera2
câmera em Python
capture_array
quadro como NumPy
Cabo 22 → 15
o certo para o Pi 5
3

🎨 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.

090179 0–10170–179 verde 40–80 vermelho = duas faixas somadas · S e V mínimos cortam cinza e sombra valores iniciais: ajuste com o seu objeto e a sua luz

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.

~/robo/mascara.py
# 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

HSV
cor separada da luz
inRange
faixa → máscara
Morfologia
abre e fecha a máscara
Vermelho duplo
0–10 + 170–179
4

🎯 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.

meio: x = 320 maior contorno mancha pequena: ignorada erro cx = 440 erro = 440 − 320 = +120 positivo → virar à direita quadro 640 × 480 desenhado em escala

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.

~/robo/centro_cor.py
# 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

Contorno
borda da mancha
Momentos
dão o centro (cx, cy)
Erro
cx − meio da imagem
Área mínima
filtra ruído
5

⏱️ 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.

1920 × 10802,1 M 1280 × 7200,9 M 640 × 4800,3 M · bom ponto de partida 320 × 2400,08 M · cor simples, bem rápido pixels por quadro (milhões): o tempo de processar cresce junto

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.

~/robo/fps.py
# 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

FPS do laço
capturar + processar
Latência
atraso até reagir
Resolução
metade = ¼ do trabalho
perf_counter
relógio para medir
6

🌐 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.

Raspberry Pi 5 capturadesenha, codifica servidorporta 8000 navegadorcelular ounotebook JPEG,JPEG… http://robo.local:8000 · só na rede local

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.

~/robo/stream.py
# 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

MJPEG
JPEG em sequência
Porta 8000
endereço do serviço
Thread
capturar e servir juntos
Overlay
marcações no quadro

✅ Resumo do módulo

✓
Imagem = tabela de números: (altura, largura, 3), na ordem BGR, com y crescendo para baixo.
✓
Câmera testada em duas camadas: rpicam-hello no terminal, picamera2 no código.
✓
Cor por HSV: faixa → máscara → limpeza; o vermelho pede duas faixas.
✓
Centro e área viram erro e distância: os dois números que o robô usa para virar e se aproximar.
✓
Medir e ver: FPS do laço inteiro e vídeo MJPEG no navegador para depurar.

Para ir além

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.