Un drone que persigue una pelota sin GPS ni sensores caros
Imagina un drone que sigue solo a un objeto en movimiento por toda la pieza, sin GPS, sin LiDAR y sin una placa de inteligencia artificial de US$ 300. Todo lo que necesitas es una cámara web colgada del techo y un notebook corriendo Python. El truco es simple: la cámara mira hacia abajo y convierte el piso en un mapa de coordenadas gigante. El programa ubica dos marcadores de color. uno sobre el drone y otro sobre el objetivo. y empuja al drone hacia el objetivo, como si estuviera juntando dos puntos. Al final de este tutorial vas a tener un drone LiteWing haciendo seguimiento autónomo y, más importante, vas a entender por qué esta estrategia por color le gana en simplicidad a cualquier modelo pesado de visión.

La idea: el piso como mapa de coordenadas
En vez de resolver el problema desde el aire (lo típico: GPS, IMU y algoritmos de navegación), este proyecto lo resuelve desde arriba. Una cámara USB fija en el techo entrega una vista cenital constante de toda la zona de vuelo. Cada cuadro de video se procesa en el notebook, no en el drone, así que el drone no necesita más cómputo que el de mantenerse estable.
El flujo que corre varias veces por segundo es siempre el mismo: la cámara captura el cuadro, OpenCV lo convierte al espacio de color HSV, se aplican umbrales para aislar el marcador verde y el marcador azul, se calcula el centro de cada mancha detectada y, con esas dos coordenadas, se decide hacia dónde debe moverse el drone. Esa decisión se traduce en comandos de vuelo (adelante, atrás, izquierda, derecha) que viajan por WiFi al drone usando la librería LiteWing.

Por qué HSV y no RGB (lo que el tutorial original da por sentado)
Un detalle que marca la diferencia: la conversión a HSV. En RGB, el color de un objeto cambia muchísimo cuando cambia la iluminación. una pelota verde bajo sombra tiene valores RGB completamente distintos que bajo luz directa. . HSV separa el tono (H) de la saturación y el brillo, así que puedes fijar un rango de tono para "verde" y que siga funcionando aunque la luz de la pieza no sea perfecta. Por eso los umbrales del código se definen en HSV: es lo que hace robusta la detección sin necesidad de una pieza con iluminación de estudio.
El corazón del sistema: modelo de tres zonas
Acá está la elegancia del proyecto. En lugar de planificar rutas, el drone decide su movimiento según una sola variable: la distancia en píxeles entre él y el objetivo. Con esa distancia se define en cuál de tres zonas está:
- Zona de seguimiento (Follow): el objetivo está lejos (más allá de
FOLLOW_DIST). El drone se mueve hacia él, eligiendo primero el eje dominante (horizontal o vertical) para acortar la distancia. - Zona de mantención (Maintain): el drone quedó a la distancia justa. No se envía ningún comando y el drone simplemente flota. Esto evita las oscilaciones y hace el seguimiento suave.
- Zona de retirada (Retreat): el drone se acercó demasiado (menos de
RETREAT_DIST). Se mueve en sentido contrario para recuperar una distancia segura y no chocar con el objetivo.

Qué hardware necesitas
El montaje es deliberadamente simple. El objetivo es que puedas replicarlo sin hardware especializado ni dependencias complejas:
- Drone LiteWing (basado en ESP32), que trae su propio punto de acceso WiFi para recibir comandos en tiempo real.
- Módulo de posición LiteWing, que le da mantención de altura y de posición. Gracias a él, el script de seguimiento solo se preocupa de la dirección, no de estabilizar el vuelo.
- Cámara web USB montada en el techo, apuntando hacia abajo, para la vista cenital.
- Notebook o PC con Python 3.11, que corre todo el procesamiento.
- Dos marcadores de color bien distintos entre sí y del fondo: uno para el drone y otro para el objetivo.
Antes de volar: actualiza el drone al firmware más reciente y usa la última versión de la librería LiteWing. El firmware desactualizado provoca problemas de conexión y comportamientos raros en vuelo, y las versiones nuevas de la librería suelen traer correcciones y mejor compatibilidad.
El software, función por función
Todo el proyecto está escrito en Python con OpenCV y NumPy. Vamos a las dos funciones que hacen el trabajo pesado.
Detección de color: detect()
Cada cuadro se convierte a HSV y esta función aísla el marcador según su rango de color. Para limpiar el ruido, la máscara pasa por erosión y dilatación; después se busca el contorno más grande, se calcula su centro y se devuelven sus coordenadas. Esas coordenadas son la entrada del algoritmo de seguimiento.
def detect(hsv, color):
"""Return (cx, cy, radius) of the largest blob, or None."""
if color == "target":
mask = cv2.inRange(hsv, BLUE_LO, BLUE_HI)
else:
mask = cv2.inRange(hsv, GREEN_LO, GREEN_HI)
kern = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.dilate(cv2.erode(mask, kern, iterations=1), kern, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
best = max(contours, key=cv2.contourArea)
if cv2.contourArea(best) < MIN_AREA:
return None
(cx, cy), r = cv2.minEnclosingCircle(best)
return int(cx), int(cy), int(r)
Lógica de seguimiento: track()
Con las posiciones del drone y del objetivo, esta función calcula los desfases horizontal y vertical (dx, dy) y la distancia entre ambos, y aplica el modelo de tres zonas para devolver un estado y una dirección.
def track(target, drone_pos, drone):
"""3-zone autonomous tracking. Returns (status, direction)."""
clear_keys(drone)
if target is None or drone_pos is None:
return "TARGET LOST", "HOLD"
dx = target[0] - drone_pos[0]
dy = target[1] - drone_pos[1]
dist = (dx**2 + dy**2) ** 0.5
vert_dom = abs(dy) >= abs(dx)
# ── ZONE 1: RETREAT (too close) ───────────────────────
if dist < RETREAT_DIST:
if vert_dom:
key = DIRS["down"] if dy < 0 else DIRS["up"]
label = "RETREAT " + ("BACK" if dy < 0 else "FWD")
else:
key = DIRS["right"] if dx < 0 else DIRS["left"]
label = "RETREAT " + ("RIGHT" if dx < 0 else "LEFT")
drone._manual_keys[key] = True
return "TOO CLOSE", label
# ── ZONE 2: MAINTAIN (buffer — hold position) ────────
if dist < FOLLOW_DIST:
return "MAINTAINING", "HOLD"
# ── ZONE 3: FOLLOW (far away — move toward target) ───
if vert_dom and abs(dy) > DEADZONE:
key = DIRS["up"] if dy < 0 else DIRS["down"]
label = "FWD" if dy < 0 else "BACK"
elif abs(dx) > DEADZONE:
key = DIRS["left"] if dx < 0 else DIRS["right"]
label = "LEFT" if dx < 0 else "RIGHT"
else:
return "ALIGNED", "ALIGNED"
drone._manual_keys[key] = True
return "TRACKING", label
Profundización: la zona muerta y por qué evita el temblor
Fíjate en dos decisiones finas del código que el tutorial original no explica en detalle. La primera es el DEADZONE: cuando el desfase es menor que ese umbral, el drone no corrige. Sin esa zona muerta, el drone estaría enviando micro correcciones constantes y se vería tembloroso. La segunda es vert_dom = abs(dy) >= abs(dx): en cada ciclo el drone corrige un solo eje, el que tiene el error más grande. Corregir de a un eje evita movimientos diagonales bruscos y hace el seguimiento más predecible.
Cómo ejecutar la demo
- Enciende el drone LiteWing junto con el módulo de posición y confirma que tenga el firmware actualizado.
- Conecta tu notebook al punto de acceso WiFi (AP) del LiteWing.
- Abre el proyecto en tu entorno de Python y ejecuta el script. Va a aparecer una ventana con el video en vivo y el sistema empezará a detectar el drone y el objetivo según los colores configurados.
- Cuando veas que ambos objetos se detectan bien, presiona T en la terminal. El drone despega, sube a la altura configurada y empieza a seguir el objetivo con la lógica de tres zonas.
- Mueve el objetivo dentro del campo de visión de la cámara: el drone ajusta su posición y lo sigue manteniendo la distancia deseada.
- Para terminar, presiona L para aterrizar, o la barra espaciadora en cualquier momento para un paro de emergencia.
Si algo no detecta bien, casi siempre es la luz o el contraste de color: mejora la iluminación y ajusta los rangos HSV; si aparece detección ruidosa, sube el área mínima de contorno (MIN_AREA) y afina la erosión/dilatación; y si el drone se va hacia el borde, revisa los límites de BOUNDARY y baja la sensibilidad.
Código completo
# LiteWing Object Tracker — Lite Edition
# Autonomous BLUE-ball follower using a ceiling camera.
# Drone is identified by GREEN paper on top.
#
# Keys: T=Takeoff | L=Land | SPACE=E-Stop | Q=Quit
import threading, msvcrt, time
import cv2
import numpy as np
from litewing import LiteWing
from litewing.manual_control import run_manual_control
# ── Configuration ─────────────────────────────────────────────
DEBUG_MODE = 0 # Enable debug mode (displays debug info/badge on the HUD)
CAMERA_INDEX = 1 # Index of the camera device to use (e.g., 0 for built-in, 1 for external ceiling camera)
DRONE_IP = "192.168.43.42" # IP address of the LiteWing drone for network connection
SENSITIVITY = 0.2 # Movement sensitivity modifier for the drone controller
TARGET_HEIGHT = 0.4 # Target altitude in meters for the drone to maintain when airborne
# Camera Feed Resizing Options
# Set to integers (e.g., 640 and 480) to scale the processed camera frame.
# Set both to None to use the camera's original default resolution.
RESIZE_WIDTH = None
RESIZE_HEIGHT = None
# HSV ranges — light green target (tuned for bright green ball)
GREEN_LO = (35, 60, 50)
GREEN_HI = (90, 255, 255)
# HSV ranges — blue drone marker (Light/Sky Blue)
BLUE_LO = (90, 80, 100)
BLUE_HI = (135, 255, 255)
# Tracking — 3-zone distance model
RETREAT_DIST = 85 # px — retreat if closer than this
FOLLOW_DIST = 125 # px — follow only if farther than this
DEADZONE = 20 # px — ignore smaller offsets
MIN_AREA = 500 # px² — noise filter
BOUNDARY = 40 # px — safety margin from frame edge
# Direction keys (adjust to match camera orientation)
DIRS = {"up": "w", "down": "s", "left": "a", "right": "d"}
# States that allow autonomous movement
AIRBORNE = {"HOVERING", "TRACKING", "TOO CLOSE", "MAINTAINING",
"ALIGNED", "TARGET LOST", "TAKING_OFF"}
# ── Helpers ───────────────────────────────────────────────────
def clear_keys(drone):
"""Reset all movement keys to False."""
for k in "wasd":
drone._manual_keys[k] = False
def detect(hsv, color):
"""Return (cx, cy, radius) of the largest blob, or None."""
if color == "target":
mask = cv2.inRange(hsv, BLUE_LO, BLUE_HI)
else:
mask = cv2.inRange(hsv, GREEN_LO, GREEN_HI)
kern = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.dilate(cv2.erode(mask, kern, iterations=1), kern, iterations=2)
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
best = max(contours, key=cv2.contourArea)
if cv2.contourArea(best) < MIN_AREA:
return None
(cx, cy), r = cv2.minEnclosingCircle(best)
return int(cx), int(cy), int(r)
def out_of_bounds(pos, shape):
"""True if pos is outside the boundary margin."""
if pos is None:
return False
h, w = shape[:2]
x, y = pos[0], pos[1]
return x < BOUNDARY or x > w - BOUNDARY or y < BOUNDARY or y > h - BOUNDARY
# ── Tracking Logic ────────────────────────────────────────────
def track(target, drone_pos, drone):
"""3-zone autonomous tracking. Returns (status, direction)."""
clear_keys(drone)
if target is None or drone_pos is None:
return "TARGET LOST", "HOLD"
dx = target[0] - drone_pos[0]
dy = target[1] - drone_pos[1]
dist = (dx**2 + dy**2) ** 0.5
vert_dom = abs(dy) >= abs(dx)
# ── ZONE 1: RETREAT (too close) ───────────────────────
if dist < RETREAT_DIST:
if vert_dom:
key = DIRS["down"] if dy < 0 else DIRS["up"]
label = "RETREAT " + ("BACK" if dy < 0 else "FWD")
else:
key = DIRS["right"] if dx < 0 else DIRS["left"]
label = "RETREAT " + ("RIGHT" if dx < 0 else "LEFT")
drone._manual_keys[key] = True
return "TOO CLOSE", label
# ── ZONE 2: MAINTAIN (buffer — hold position) ────────
if dist < FOLLOW_DIST:
return "MAINTAINING", "HOLD"
# ── ZONE 3: FOLLOW (far away — move toward target) ───
if vert_dom and abs(dy) > DEADZONE:
key = DIRS["up"] if dy < 0 else DIRS["down"]
label = "FWD" if dy < 0 else "BACK"
elif abs(dx) > DEADZONE:
key = DIRS["left"] if dx < 0 else DIRS["right"]
label = "LEFT" if dx < 0 else "RIGHT"
else:
return "ALIGNED", "ALIGNED"
drone._manual_keys[key] = True
return "TRACKING", label
# ── HUD ───────────────────────────────────────────────────────
STATUS_COLORS = {
"TRACKING": (0, 200, 0),
"ALIGNED": (0, 255, 200),
"MAINTAINING": (200, 180, 0),
"TOO CLOSE": (0, 100, 255),
"TARGET LOST": (0, 140, 255),
}
def draw_hud(frame, target, drone_pos, status, drone, oob, direction):
"""Minimal heads-up display."""
h, w = frame.shape[:2]
# Boundary box
color = (0, 0, 255) if oob else (80, 80, 80)
cv2.rectangle(frame, (BOUNDARY, BOUNDARY), (w - BOUNDARY, h - BOUNDARY), color, 2)
# Target marker
if target:
tx, ty, tr = target
cv2.circle(frame, (tx, ty), tr, (255, 0, 0), 3)
cv2.circle(frame, (tx, ty), 4, (255, 0, 0), -1)
cv2.putText(frame, "TARGET", (tx - 28, ty - tr - 8),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 0, 0), 1, cv2.LINE_AA)
# Drone marker
if drone_pos:
dx, dy, dr = drone_pos
cv2.circle(frame, (dx, dy), dr, (0, 255, 0), 3)
cv2.circle(frame, (dx, dy), 4, (0, 255, 0), -1)
cv2.putText(frame, "DRONE", (dx - 24, dy - dr - 8),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 0), 1, cv2.LINE_AA)
else:
cv2.putText(frame, "DRONE: NOT FOUND", (15, h - 40),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
# Arrow from drone → target + zone rings
if target and drone_pos:
cv2.arrowedLine(frame, (drone_pos[0], drone_pos[1]),
(target[0], target[1]), (0, 255, 255), 2, tipLength=0.15)
cv2.circle(frame, (drone_pos[0], drone_pos[1]), RETREAT_DIST, (0, 80, 200), 1)
cv2.circle(frame, (drone_pos[0], drone_pos[1]), FOLLOW_DIST, (0, 160, 0), 1)
# Status + direction (top-left)
s_col = STATUS_COLORS.get(status, (200, 200, 200))
cv2.putText(frame, status, (15, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.65, s_col, 2, cv2.LINE_AA)
if direction:
cv2.putText(frame, direction, (15, 58),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1, cv2.LINE_AA)
# Telemetry (bottom-left)
info = f"Bat {drone.battery:.1f}V | Alt {drone.height:.2f}m"
cv2.putText(frame, info, (15, h - 15),
cv2.FONT_HERSHEY_SIMPLEX, 0.42, (200, 200, 200), 1, cv2.LINE_AA)
# Debug badge
if drone.debug_mode:
cv2.putText(frame, "[DEBUG]", (w - 100, 28),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 165, 255), 2, cv2.LINE_AA)
# ── Main ──────────────────────────────────────────────────────
def main():
drone = LiteWing(DRONE_IP)
drone.debug_mode = bool(DEBUG_MODE)
drone.sensitivity = SENSITIVITY
drone.target_height = TARGET_HEIGHT
drone.default_landing_duration = 0.1
try:
drone.connect()
drone.land()
time.sleep(1)
except Exception as e:
print(f"[CONN] {e}")
cap = cv2.VideoCapture(CAMERA_INDEX, cv2.CAP_DSHOW)
if not cap.isOpened():
cap = cv2.VideoCapture(CAMERA_INDEX)
if not cap.isOpened():
print(f"[ERR] Cannot open camera {CAMERA_INDEX}")
return
print("LiteWing Tracker Lite | T=Takeoff L=Land SPACE=Stop Q=Quit")
status = "LANDED"
flight_thread = None
def _flight_loop():
nonlocal status
drone._manual_active = True
try:
run_manual_control(drone)
except Exception:
pass
finally:
drone._manual_active = False
status = "LANDED"
# Initialize resizable display window
cv2.namedWindow("LiteWing Tracker", cv2.WINDOW_NORMAL)
while True:
ret, frame = cap.read()
if not ret:
break
if RESIZE_WIDTH is not None and RESIZE_HEIGHT is not None:
frame = cv2.resize(frame, (RESIZE_WIDTH, RESIZE_HEIGHT))
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
target = detect(hsv, "target")
dpos = detect(hsv, "drone")
# ── Key handling ──
if msvcrt.kbhit():
key = msvcrt.getch()
if key in (b'q', b'Q'):
break
elif key == b' ':
drone._manual_active = False
clear_keys(drone)
try:
drone.emergency_stop()
except Exception:
pass
break
elif key in (b't', b'T') and status == "LANDED":
if flight_thread is None or not flight_thread.is_alive():
status = "TAKING_OFF"
flight_thread = threading.Thread(target=_flight_loop, daemon=True)
flight_thread.start()
elif key in (b'l', b'L') and status not in ("LANDED", "LANDING"):
status = "LANDING"
clear_keys(drone)
drone._manual_active = False
# Transition: taking off → hovering
if status == "TAKING_OFF" and drone.height >= TARGET_HEIGHT * 0.85:
status = "HOVERING"
oob = out_of_bounds(target, frame.shape) or out_of_bounds(dpos, frame.shape)
# ── Autonomous tracking ──
direction = ""
if status in AIRBORNE:
status, direction = track(target, dpos, drone)
else:
clear_keys(drone)
if status == "LANDED":
direction = "Press T"
label = f"[DBG] {status}" if drone.debug_mode else status
draw_hud(frame, target, dpos, label, drone, oob, direction)
cv2.imshow("LiteWing Tracker", frame)
if cv2.waitKey(1) & 0xFF in (ord('q'), ord(' ')):
break
# Cleanup
drone._manual_active = False
clear_keys(drone)
cap.release()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
Variantes y mejoras
Este proyecto es una base ideal para escalar. Tres ideas concretas para llevarlo más lejos:
- Cambiar detección por color por un modelo YOLO: en lugar de umbrales HSV, puedes integrar un detector de objetos como YOLO. Ya no necesitas ponerle marcadores de color al drone ni al objetivo. el modelo reconoce las cosas por su forma. a cambio de más cómputo y algo menos de velocidad. Es el salto natural de "seguir un color" a "seguir un objeto real".
- Seguir varios objetivos a la vez: la implementación actual sigue un solo objetivo. Combinando YOLO con un algoritmo de asignación (por ejemplo, seguimiento por IDs) puedes hacer que el drone elija entre varios objetos o alterne entre ellos.
- Registrar la telemetría: en vez de solo mostrar el estado en pantalla, guarda la posición, la batería y la altura en un archivo CSV para después analizar la trayectoria del vuelo o depurar el comportamiento del seguimiento.
Personalización para Chile
Casi todo lo que pide este proyecto lo encuentras en el catálogo de MechatronicStore para armarlo con componentes en stock local:
- La cámara web USB cenital es un componente estándar: cualquier webcam USB con buena resolución sirve para la vista desde el techo.
- Los marcadores de color pueden ser simples trozos de cartulina o pelotas plásticas de colores vivos (verde y azul funcionan bien por su separación de tono en HSV).
- El notebook o PC lo pones tú; no se necesita una GPU dedicada para la detección por color.
- El drone LiteWing con su módulo de posición es el corazón del proyecto y es un kit especializado basado en ESP32; es la pieza clave a conseguir para replicar el montaje tal cual.
Los enlaces directos a cada producto del catálogo de MechatronicStore, con SKU y precio en CLP, se agregan automáticamente en la ficha del blog. Arma tu lista según el hardware disponible en stock y ajusta los rangos HSV a los colores que consigas.
Recursos
- Tutorial original (inglés): LiteWing Object Tracking Drone: Full OpenCV Tutorial
- Repositorio GitHub: Object Tracking-Drone using-LiteWing
- Código completo (.zip): descargar desde GitHub
- Librería LiteWing: Circuit Digest/LiteWing Library
- Documentación adicional: Wiki LiteWing y Módulo de posición LiteWing
Versión chilena con componentes en stock local en MechatronicStore, inspirada en el proyecto de Circuit Digest.




