PTENES
MÓDULO 3.2 · PRÁCTICA

📷 Cámara y OpenCV

El robot obtiene ojos. Entiendes qué es una imagen para la computadora, conectas la Camera Module 3 con picamera2, separas un objeto por su color con OpenCV, encuentras su centro, mides cuántas imágenes por segundo puede procesar el Pi y ves el video del robot en el navegador del celular.

6
Temas
Intermedio
Nivel
Práctica
Tipo
Pi 5 + cámara
Hardware
0%
0 de 0 temas leídos · Sección 1 de 6

Contenido detallado

1

🖼️ Cómo ve una imagen la computadora

Para el programa, una foto no es una escena: es una tabla de números. Cada cuadrito, el píxel, guarda tres valores de 0 a 255, uno para cada color básico. Una imagen de 640 × 480 es una tabla con 480 filas, 640 columnas y 3 capas. En Python se convierte en un array de NumPy con formato (480, 640, 3). Encontrar "la pelota roja" es, en el fondo, buscar una región de la tabla donde los números tienen cierto patrón.

🌱 ¿Eres nuevo aquí?

Un canal es una de las capas de color de la imagen. OpenCV guarda los colores en el orden BGR (azul, verde, rojo), a diferencia del RGB que ves en los editores. NumPy NumPy es la biblioteca de Python para trabajar con tablas de números; OpenCV OpenCV es la biblioteca de visión computacional que trabaja sobre estas tablas. La coordenada (0, 0) es la esquina superior izquierda: x crece hacia la derecha e y crece hacia abajo.

un píxel = 3 números B (azul) 30 G (verde) 40 R (rojo) 220 (0, 0) x → derecha y ↓ hacia abajo imagen 640 × 480 → array (480, 640, 3): filas, columnas, canales

Cómo leer: a la izquierda, un fragmento de imagen ampliado hasta que se ven los cuadritos. El píxel resaltado en morado es «rojo» porque el número del canal R es alto y los otros son bajos. Observa el orden B, G, R: es el de OpenCV. Las flechas cian muestran que y cuenta de arriba abajo.

⬛ Tonos de gris

Un solo canal, de 0 (negro) a 255 (blanco). Tres veces menos datos; ideal para bordes y formas.

🌈 BGR

Tres canales, como los entrega la cámara. Bueno para mostrar y guardar, pero malo para separar colores cuando cambia la luz.

🎨 HSV

Matiz, saturación y brillo. Separa «qué color» de «cuánta luz»: el formato adecuado para encontrar objetos de color (tema 3).

Por qué aprender

Cuando la máscara no detecta el objeto o el centro aparece en el lugar equivocado, casi siempre el problema está aquí: colores intercambiados (RGB tratado como BGR), eje y invertido o una imagen demasiado grande para que la procese el Pi a tiempo.

Conceptos clave

Píxel
el punto más pequeño de la imagen
Canal
una capa de color
BGR
el orden de OpenCV
shape
(altura, ancho, canales)
2

📷 Camera Module 3 con picamera2

A Camera Module 3 se conecta al conector de cámara del Pi 5 con un cable flat (cinta). En la terminal, las herramientas rpicam-apps (rpicam-hello, rpicam-still) prueban la cámara en segundos. En tu código Python, quien entrega las imágenes es picamera2, que ya viene con Raspberry Pi OS. Los tutoriales antiguos usan herramientas de línea de comandos que ya no existen en el sistema actual; si el comando no empieza con rpicam-, desconfía.

🌱 ¿Eres nuevo aquí?

El conector del Pi 5 es más pequeño (22 vías) que el de las cámaras (15 vías): usa el cable específico para Pi 5, que tiene un extremo de cada tamaño. El lado con los contactos metálicos debe seguir la orientación indicada en el conector; abre el seguro con la uña, insértalo recto y ciérralo. Hazlo con el Pi apagado.

sensorCamera Module 3 ISP del Picolor, exposición picamera2array NumPy OpenCVtu código cable flat capture_array() «RGB888» en picamera2 ya llega en el orden BGR que espera OpenCV

Cómo leer: de izquierda a derecha, la luz se convierte en números. El ISP (procesador de imagen) corrige el color y la exposición antes de que recibas el cuadro; por eso la primera imagen justo después de encender la cámara puede salir oscura, y el código espera un segundo antes de capturarla.

Objetivo: confirmar que se detectó la cámara, tomar una foto desde la terminal y luego capturar un cuadro en Python. Instala OpenCV del sistema una vez (el venv del módulo 3.1 lo puede ver).

terminal del Pi
rpicam-hello --list-cameras          # debe mostrar la cámara (imx708 = Camera Module 3)
rpicam-still -o ~/robo/teste.jpg      # toma una foto desde la terminal
sudo apt install -y python3-opencv    # OpenCV del sistema, visible desde el venv
source ~/robo-venv/bin/activate
~/robo/captura.py
# Captura un cuadro de la cámara y lo guarda 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)                         # deja que se ajuste la exposición automática

quadro = picam2.capture_array()       # array NumPy en el orden BGR
print("formato:", quadro.shape, quadro.dtype)
print("píxel del centro (B, G, R):", quadro[240, 320])
cv2.imwrite(os.path.expanduser("~/robo/quadro.jpg"), quadro)
picam2.stop()
print("guardado en ~/robo/quadro.jpg")

Cómo verificar

  • ✓La terminal muestra formato: (480, 640, 3) uint8 y tres números del píxel central.
  • ✓Copia la foto a tu computadora (scp <seu_usuario>@robo.local:~/robo/quadro.jpg .) y ábrela: los colores deben verse naturales.
  • ✗«no cameras available»? Apaga el Pi, revisa el cable (orientación y seguro) en ambos extremos y vuelve a encenderlo.

Por qué aprender

Este bloque de configuración (tamaño, formato, start, capture_array) es la puerta de entrada a todo lo que viene después: máscara de color, YOLO26 y el proyecto de la ruta. Probar primero con rpicam-hello permite separar un problema de hardware de uno de código.

Conceptos clave

rpicam-apps
prueba en la terminal
picamera2
cámara en Python
capture_array
cuadro como NumPy
Cable 22 → 15
el adecuado para el Pi 5
3

🎨 OpenCV: colores, HSV y máscaras

Una pelota roja a la sombra y la misma pelota al sol tienen valores BGR muy diferentes. En el espacio HSV la diferencia se concentra casi toda en V (brillo), y H (matiz, «qué color») sigue siendo parecido. Por eso el procedimiento habitual es: convertir el cuadro a HSV, elegir un rango de H, S y V y crear una máscara, una imagen en blanco y negro en la que solo los píxeles dentro del rango quedan en blanco.

🌱 ¿Eres nuevo aquí?

Matiz (H) es el color en el círculo cromático; en OpenCV va de 0 a 179. Saturación (S) indica qué tan vivo es el color (0 = gris). Brillo (V) indica cuánta luz hay (0 = negro). Una máscara es un filtro: blanco donde se cumple la condición, negro en el resto. El rojo está en los dos extremos del círculo (cerca de 0 y de 179), por eso se necesitan dos rangos sumados.

090179 0–10170–179 verde 40–80 rojo = dos rangos sumados · los valores mínimos de S y V eliminan el gris y la sombra valores iniciales: ajústalos según tu objeto y la iluminación

Cómo leer: el rango es el círculo cromático abierto en línea recta, en la escala 0–179 de OpenCV. Los marcos morados muestran por qué cuesta trabajar con el rojo: empieza y termina la escala. El marco cian muestra un color «más sencillo», que cabe en una sola ventana.

Objetivo: generar la máscara de un objeto rojo y guardar el cuadro y la máscara uno al lado del otro para que ajustes el rango mirando el resultado.

~/robo/mascara.py
# Máscara de color en HSV (rojo = dos rangos)
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])   # rojo, principio de la escala
baixo2, alto2 = np.array([170, 120, 70]), np.array([179, 255, 255])  # rojo, final de la escala
mascara = cv2.inRange(hsv, baixo1, alto1) | cv2.inRange(hsv, baixo2, alto2)

# Limpieza: elimina puntos sueltos y cierra huecos
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"píxeles en la máscara: {brancos} ({100 * brancos / mascara.size:.1f}% de la imagen)")
lado_a_lado = np.hstack([quadro, cv2.cvtColor(mascara, cv2.COLOR_GRAY2BGR)])
cv2.imwrite(os.path.expanduser("~/robo/mascara.jpg"), lado_a_lado)
print("guardado en ~/robo/mascara.jpg")

Cómo verificar

  • ✓Con un objeto rojo enfrente, la mitad derecha de mascara.jpg muestra una mancha blanca con su forma.
  • ✓Sin el objeto, el porcentaje baja hasta cerca del 0%.
  • ✗¿Aparecen piel y madera? Sube el mínimo de S (120 → 150). ¿El objeto desaparece en la sombra? Baja el mínimo de V.

✓ Ayuda a que funcione el color

  • ✓Objeto de color vivo y mate (pelota, cartulina, cinta).
  • ✓Fondo de un color muy distinto al del objeto.
  • ✓Ajusta el rango en el lugar donde va a funcionar el robot.

✗ Dificulta

  • ✗Objeto brillante que refleja la lámpara (se vuelve blanco).
  • ✗Luz amarillenta o intermitente, que cambia el matiz.
  • ✗Calibra de día y prueba de noche.

Por qué aprender

La máscara de color es la forma más ligera de visión: funciona a decenas de cuadros por segundo en la Pi, sin IA, y es suficiente para que un robot siga una pelota o una franja de color. Es la primera versión del proyecto 3.6, antes de incorporar YOLO26.

Conceptos clave

HSV
color separado de la luz
inRange
rango → máscara
Morfología
abre y cierra la máscara
Rojo doble
0–10 + 170–179
4

🎯 Contornos y centro del objeto

La máscara indica «aquí hay rojo», pero el robot necesita dos números: dónde está el objeto y qué tan cerca. OpenCV encuentra los contornos (los bordes de cada mancha blanca); te quedas con el más grande y calculas su centro y su área. El centro se convierte en el error horizontal (qué tan a la izquierda o a la derecha del centro de la imagen está el objeto), y el área indica la distancia: cuanto mayor sea, más cerca está.

centro: x = 320 contorno más grande mancha pequeña: se ignora error cx = 440 error = 440 − 320 = +120 positivo → girar a la derecha cuadro de 640 × 480 dibujado a escala

Cómo leer: el rectángulo es la máscara; la línea discontinua es el centro de la imagen. De las dos manchas, solo cuenta la más grande (la pequeña es ruido). La flecha cian indica el error: es positivo cuando el objeto está a la derecha, negativo cuando está a la izquierda y cero cuando está centrado. Ese es el número que el PID del proyecto 3.6 llevará a cero.

Objetivo: ejecutar en un bucle continuo, imprimir el centro, el error y el área del objeto rojo más grande, e indicar hacia qué lado debería girar el robot.

~/robo/centro_cor.py
# Encuentra el objeto rojo más grande y calcula el centro, el error y el área
import time
import cv2, numpy as np
from picamera2 import Picamera2

LARGURA, ALTURA = 640, 480
AREA_MIN = 800                      # menor que esto es ruido
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:                # combina los rangos (OR)
        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("Muestra un objeto rojo. Ctrl+C para salir.")
try:
    while True:
        achado = achar_objeto(picam2.capture_array())
        if achado is None:
            print("ningún objeto → detenerse y buscar")
        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} error={erro:+4d} área={area:7.0f} → {lado}")
        time.sleep(0.1)
except KeyboardInterrupt:
    picam2.stop()

Cómo verificar

  • ✓Al mover el objeto hacia la derecha de la cámara, el error se vuelve positivo y aparece «→ derecha».
  • ✓Al acercar el objeto, el área aumenta bastante; al alejarlo, disminuye.
  • ✗¿Derecha e izquierda están invertidas? La cámara está al revés o en modo espejo: invierte el signo del error o gira la cámara.

💡 El área no es una distancia en metros

El área sirve para comparar («más cerca», «demasiado lejos»), no para medir. Anota el área con el objeto a 30 cm y a 1 m: esos dos números serán los límites de «llegó» y «acércate» en el proyecto.

Por qué aprender

El centro y el área son el puente entre la visión y el movimiento. YOLO26, en el siguiente módulo, entrega un cuadro en lugar de un contorno, pero calcularás exactamente el mismo error y el mismo «tamaño» a partir de él.

Conceptos clave

Contorno
borde de la mancha
Momentos
dan el centro (cx, cy)
Error
cx − centro de la imagen
Área mínima
filtra el ruido
5

⏱️ Medir los FPS y reducir la resolución

Un robot que ve a 3 cuadros por segundo reacciona tarde: si avanza a medio metro por segundo, recorre más de 15 cm entre una imagen y otra. El número que importa es el FPS del bucle completo (capturar + procesar + decidir), no el de la cámara. La medida más efectiva es la resolución: la mitad del ancho y de la altura equivale a un cuarto de los píxeles, y casi todo el procesamiento se reduce en la misma proporción.

🌱 ¿Eres nuevo aquí?

FPS (cuadros por segundo) cuenta cuántas vueltas completas da tu bucle en un segundo. Latencia es el retraso entre lo que ocurre frente a la cámara y la reacción del robot. Ambos van de la mano: pocos FPS casi siempre significan una reacción tardía.

1920 × 10802,1 M 1280 × 7200,9 M 640 × 4800,3 M · buen punto de partida 320 × 2400,08 M · color simple, muy rápido píxeles por cuadro (millones): el tiempo de procesamiento aumenta en la misma proporción

Cómo leer: cada barra representa el trabajo de un cuadro. Pasar de Full HD a 640 × 480 reduce el trabajo casi siete veces. Para seguir una pelota de color, basta con 320 × 240; para YOLO26, 640 × 480 es el tamaño natural.

Objetivo: medir los FPS reales del bucle «capturar + HSV + máscara» en tres resoluciones y elegir la tuya con datos.

~/robo/fps.py
# Mide los FPS del bucle completo en tres resoluciones (5 s cada una)
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)          # trabajo 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 vuelta)")
    picam2.stop()

Cómo verificar

  • ✓Aparecen tres líneas, y los FPS aumentan a medida que baja la resolución (el límite es la tasa de la propia cámara).
  • ✓Anota los números: te servirán de referencia para compararlos con YOLO26 en el 3.3.
  • ✗¿Los FPS disminuyen con el tiempo? Ejecuta saude.py del 3.1: probablemente se deba al calor.

✓ Aumenta los FPS

  • ✓Pedirle a la cámara la resolución final (sin cambiar el tamaño después).
  • ✓Procesar solo la franja de la imagen que te interesa (por ejemplo, la mitad inferior).
  • ✓Imprimir menos: print cada cuadro también cuesta.

✗ Reduce los FPS

  • ✗Guardar un JPEG en la tarjeta en cada cuadro.
  • ✗Abrir el escritorio con una ventana de video mientras funciona el robot.
  • ✗Una Pi sin disipador: empieza rápido y se vuelve lenta.

Por qué aprender

Sin medir, toda optimización es una suposición. Con los FPS a la vista, decides con datos entre «color rápido» e «IA más lenta» y sabes cuánto puede avanzar el robot entre dos decisiones sin perder el objeto.

Conceptos clave

FPS del bucle
capturar + procesar
Latencia
demora hasta reaccionar
Resolución
la mitad = ¼ del trabajo
perf_counter
reloj para medir
6

🌐 Transmitir el video al navegador

La Pi del robot no tiene pantalla, y las ventanas de video por SSH no funcionan. La solución es que la Pi se convierta en un pequeño servidor web: comprime cada cuadro en JPEG y envía la secuencia por la red en el formato MJPEGque cualquier navegador muestra como video. Así ves, en el celular o en la laptop, exactamente lo que ve el robot, ya con las marcas de tu código.

🌱 ¿Eres nuevo aquí?

MJPEG es «JPEG en movimiento»: una foto detrás de otra, en una conexión que permanece abierta. Es simple y tiene poco retraso, pero consume más red que un video realmente comprimido. La puerto (aquí, 8000) es el número que diferencia los servicios dentro de la misma computadora; por eso la dirección termina en :8000.

Raspberry Pi 5 capturadibuja, codifica servidorpuerto 8000 navegadorcelular olaptop JPEG,JPEG… http://robo.local:8000 · solo en la red local

Cómo leer: dentro del Pi (línea discontinua), dos tareas se ejecutan a la vez: una solo captura y prepara el JPEG más reciente; la otra solo lo entrega. Separarlas evita que un navegador lento retrase la visión del robot.

Objetivo: ver el video del robot en el navegador, con el centro del objeto rojo marcado. Usa solo la biblioteca estándar de Python, sin instalar nada.

~/robo/stream.py
# Video de la cámara en el navegador (MJPEG) con el centro del 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)   # centro de la imagen
        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)                 # hasta ~20 cuadros por segundo
            except (BrokenPipeError, ConnectionResetError):
                pass                                 # el navegador cerró la pestaña
        else:
            self.send_error(404)
    def log_message(self, *args):
        pass                                         # silencia el registro de cada solicitud

threading.Thread(target=capturar, daemon=True).start()
print("Abre http://robo.local:8000 en el navegador. Ctrl+C para salir.")
ThreadingHTTPServer(("0.0.0.0", 8000), Servidor).serve_forever()

Cómo verificar

  • ✓En el celular (con el mismo Wi-Fi), http://robo.local:8000 muestra el video con una línea vertical en el centro.
  • ✓Un objeto rojo queda marcado con un contorno magenta y un punto cian en el centro.
  • ✗¿El celular no abre robo.local? Usa la IP del Pi (hostname -I) en lugar del nombre.

💡 Solo en la red local

Este servidor no tiene contraseña: cualquier persona en la misma red puede ver la cámara. Úsalo en casa o en el laboratorio, nunca expuesto a Internet. Si estás en la red de una escuela, consulta con el profesor antes de activarlo.

Por qué aprender

Ver lo que ve el robot es la herramienta de depuración más poderosa de la ruta. Cuando gire hacia el lado equivocado en el proyecto 3.6, mirarás el video y sabrás en segundos si el problema está en la visión (marcó el objeto equivocado) o en el control (lo marcó bien, pero giró mal).

Conceptos clave

MJPEG
JPEG en secuencia
Puerto 8000
dirección del servicio
Thread
capturar y servir juntos
Superposición
marcas en el cuadro

✅ Resumen del módulo

✓
Imagen = tabla de números: (alto, ancho, 3), en orden BGR, con y creciendo hacia abajo.
✓
Cámara probada en dos capas: rpicam-hello en la terminal, picamera2 en el código.
✓
Color por HSV: rango → máscara → limpieza; el rojo necesita dos rangos.
✓
El centro y el área se convierten en error y distancia: los dos números que el robot usa para girar y acercarse.
✓
Medir y ver: FPS del ciclo completo y video MJPEG en el navegador para depurar.

Para profundizar

Siguiente módulo

3.3 · Detectar objetos con YOLO26: de «encuentra el rojo» a «encuentra a la persona, el gato o la botella», ejecutándose en el Pi 5.