- Diseño y desarrollo de un entorno de fotogrametría con drones asistido por IA
- Aplicación Android para fotogrametría con IA. Verificación del entorno y Fase 1 de desarrollo inicial
- Aplicación Android para fotogrametría con IA. Fase 2: Primera versión capaz de volar
- Aplicación Android para fotogrametría con IA. Fase 3: Corrección de heading, visualización en vuelo y ajustes de UX
- Aplicación Android para fotogrametría con IA. La plataforma de procesado: OpenDroneMap desplegado con Claude Code
- Aplicación Android para fotogrametría con IA. Fase 4: Integración cloud, exportación KMZ y cierre del ciclo completo
En el artículo anterior de esta serie expliqué cómo se construyó el motor de navegación autónoma con Virtual Stick y los bugs más relevantes que aparecieron durante las primeras pruebas de vuelo real. Al terminar esa fase, la app era capaz de volar una misión de principio a fin y tomar fotos en los waypoints correctos. Pero había un problema que sólo se hacía evidente cuando ves volar al dron: volaba de lado.
La cámara no apuntaba en la dirección de avance durante las pasadas de vuelo, sino que el dron navegaba en diagonal, orientado de manera cuasi aleatoria. Para fotogrametría esto es un problema serio: las fotos salen mal encuadradas, los solapamientos entre imágenes no se corresponden con los calculados y el resultado del procesado se resiente. Esta Fase 3 fue, en buena medida, la fase de hacer que el dron volara bien, no sólo que volara.

El problema del heading: por qué el dron volaba de lado
En la implementación original del motor de navegación, el yaw del dron —es decir, hacia dónde apunta— se controlaba usando el bearing instantáneo hacia el siguiente waypoint. En teoría suena razonable: apunta hacia donde vas. En la práctica, el bearing instantáneo oscila mucho cerca de los waypoints, cambia durante las transiciones entre pasadas y no tiene en cuenta que lo que necesita la fotogrametría es que la cámara esté alineada con la dirección de la pasada, no con la dirección puntual de movimiento.

La solución fue añadir un campo headingDeg a cada waypoint, calculado en el motor fotogramétrico como el bearing entre el primer y el último waypoint de cada línea de vuelo. Así, todos los waypoints de una misma pasada comparten un heading fijo, que es la dirección real de esa línea. La cámara siempre apunta a lo largo de la pasada, independientemente de pequeñas correcciones de trayectoria.
El problema venía en las transiciones entre líneas: cuando el dron terminaba una pasada y se desplazaba lateralmente a la siguiente, usar el heading de línea durante ese movimiento no tiene sentido. Para detectar estas transiciones se comparaba el bearing instantáneo de movimiento con el heading de la línea destino: si la diferencia superaba los 45°, se consideraba una transición y el dron apunta hacia donde se movía; si estaba dentro de los 45°, mantenía el heading de línea.
val angleDiff = abs(((bearing - wp.headingDeg + 540) % 360) - 180)
val yaw = if (angleDiff > 45.0) bearing else wp.headingDeg
Pero detectar la transición no es suficiente. Cuando el dron llega al primer waypoint de una nueva pasada tras un movimiento lateral, puede estar todavía orientado en la dirección de la transición. Si se dispara la foto en ese momento, sale girada. Se añadió por tanto una fase de rotación pre-foto: si la diferencia entre la orientación actual y el heading de línea supera los 20°, el dron hace hover y rota hasta alinearse antes de disparar. El tiempo de rotación es proporcional al ángulo a corregir, con un mínimo de 1,5 segundos y un máximo de 3.
El bug crítico: DJI rechaza silenciosamente los ángulos fuera de rango
Corregido el heading, los vuelos de prueba mejoraron notablemente, pero seguía habiendo un comportamiento extraño en las líneas orientadas hacia el oeste. El dron llegaba al primer waypoint de esas pasadas orientado en la dirección correcta, pero al llegar a los siguientes, la rotación no se completaba. Los logs lo mostraban con claridad:
WP8 ROTANDO: yaw actual=~180° → línea=270.0° (Δ90°)
WP9 ROTANDO: yaw actual=~180° → línea=270.0° (Δ90°) ← no rotó
El dron intentaba rotar a 270° (oeste) pero se quedaba clavado en 180°. Después de descartar otras causas, el diagnóstico fue el siguiente: el modo YawControlMode.ANGLE del DJI MSDK v5 acepta valores únicamente en el rango [-180°, 180°]. Un valor de 270° no produce un error —el SDK lo recorta silenciosamente al límite— y el dron se queda apuntando a 180° sin que nada indique que algo ha fallado.
La solución es normalizar el yaw antes de enviarlo al SDK, convirtiendo cualquier ángulo al rango [-180°, 180°]:
var normalizedYaw = yawAngle % 360
if (normalizedYaw > 180) normalizedYaw -= 360
if (normalizedYaw < -180) normalizedYaw += 360
Con esta normalización, 270° se convierte en -90°, que es exactamente lo mismo (oeste) expresado en el rango que el SDK espera. Un detalle pequeño con un impacto grande: sin él, la mitad de las líneas de vuelo con orientación oeste, noroeste o suroeste se recorrían con el dron mirando en la dirección incorrecta.
Un crash inesperado: listas vacías a gran altitud
Mientras se ajustaba el heading, apareció otro bug desagradable: al editar una misión existente y subir la altura por encima de unos 79 metros, la app se cerraba con un NoSuchElementException: List is empty en el motor fotogramétrico.
La causa tiene una lógica clara una vez que se entiende: a mayor altura, mayor es la superficie captada por cada foto y mayor la separación entre waypoints. Llegado a cierto punto, algunas líneas de vuelo quedan tan cortas (en relación a la zona de vuelo) que no caben waypoints dentro del polígono. El generador de rejilla devuelve listas vacías para esas líneas, y el código que calculaba el heading intentaba acceder al primer y último elemento de una lista vacía.
La solución fue doble: filtrar las líneas sin waypoints antes de devolverlas, y añadir salvaguardas isNotEmpty() antes de cualquier acceso a la rejilla. Un bug de esos que solo aparece en los extremos del rango de parámetros, pero que en campo puede surgir perfectamente si alguien decide subir la altura para cubrir un área grande con menos batería.
Visualización en tiempo real: saber exactamente qué está haciendo el dron
Con el vuelo ya correcto, el siguiente objetivo fue mejorar la información disponible en pantalla durante la ejecución. El mini-mapa original mostraba el plan de vuelo y la posición del dron, pero no permitía saber si la trayectoria real se correspondía con la planificada ni dónde se habían tomado las fotos.
Se añadieron dos StateFlow en el motor de ejecución que se actualizan durante el vuelo:
- Trail de trayectoria: la posición real del dron se registra cada vez que se desplaza más de 2 metros, formando una línea que va dibujando el camino recorrido. Se muestra en cian sobre el mini-mapa.
- Puntos de foto: cada vez que se dispara la cámara, se registra la posición exacta. Se muestran como puntos verdes sobre el mini-mapa.
El mini-mapa quedó con cinco capas superpuestas, cada una con su propio GeoJsonSource en MapLibre y actualización reactiva independiente:
| Capa | Color | Qué muestra |
|---|---|---|
| Líneas de vuelo | Naranja | El plan de misión original |
| Trail | Cian | La trayectoria real del dron |
| Puntos de foto | Verde | Dónde se tomó cada fotografía |
| Heading | Blanco | Una línea corta indicando hacia dónde apunta el dron |
| Posición actual | Azul | La ubicación del dron en tiempo real |
El resultado práctico es que durante el vuelo puedes ver en tiempo real si el dron está siguiendo el plan, identificar si se ha saltado alguna línea y confirmar que se están tomando fotos en los puntos correctos. Es la diferencia entre supervisar una misión y solo esperarla.

Tres mejoras de UX que parecen pequeñas y no lo son
El cierre de esta fase incluyó tres ajustes que individualmente parecen menores, pero que en la práctica cambian bastante la experiencia de uso en campo.
Pantalla siempre encendida. Una misión de fotogrametría puede durar entre 10 y 30 minutos. Sin el flag FLAG_KEEP_SCREEN_ON activado, el teléfono apaga la pantalla tras unos minutos de inactividad táctil —porque el operador no está tocando la pantalla, está vigilando el dron— y se pierde toda la monitorización en tiempo real. El fix es una línea de código, pero sin ella la app es prácticamente inusable en misiones largas.
Centrado de mapa en la posición real. Al abrir la app en campo, el mapa se centraba en Madrid, que es donde se inicializó el centro por defecto durante el desarrollo. Evidentemente inútil si estás en una parcela de Sevilla. El sistema de centrado se rehízo con tres niveles de fallback: primero intenta obtener la última posición conocida de MapLibre, luego la del LocationManager de Android, y si ninguna está disponible de inmediato, hace polling cada 100 milisegundos durante un máximo de 10 segundos hasta conseguir una coordenada válida.
Panel de parámetros lateral. El panel de configuración de la misión estaba implementado originalmente como un BottomSheetScaffold que se desplegaba desde la parte inferior de la pantalla. El problema es que, al abrirlo, tapaba prácticamente todo el mapa, impidiendo ver cómo cambiaba la rejilla de vuelo al ajustar los sliders, que es exactamente la razón por la que se ajustan. Se reemplazó por un ModalNavigationDrawer de Material 3 que se desliza desde la izquierda con 400 dp de ancho, dejando el mapa visible a la derecha. Incluye gesto de arrastre para cerrarlo, scrim semitransparente y tap-to-dismiss, todo de serie sin código adicional.
El estado actual de la aplicación

Con esta tercera fase cerrada, la aplicación está completa en todo lo que se refiere al ciclo de vuelo: planificación, ejecución y monitorización. Un resumen de las capacidades actuales:
- Planificación de misiones 2D (ortomosaico) y 3D (cross-hatch) sobre mapa interactivo, con capas OSM, ortofotos PNOA y Catastro.
- Cálculo automático de GSD, rejilla de waypoints, número de fotos, duración y batería estimada.
- Heading fijo por línea de vuelo con detección de transiciones y rotación pre-foto.
- Navegación autónoma vía Virtual Stick con perfil de velocidad trapezoidal.
- Disparo automático de cámara en cada waypoint fotográfico.
- Control en vivo de parámetros de cámara.
- Visualización en tiempo real: trail de trayectoria, puntos de foto y heading del dron.
- Retorno automático a casa al finalizar, con parada de emergencia disponible en todo momento.
- Guardado y carga de misiones con Room/SQLite.
Lo que queda pendiente es la Fase 4: la integración con WebODM para el procesado fotogramétrico en la nube, principalmente, además de algunas mejoras menores que tengo en mente. La idea es que, al terminar un vuelo, la app pueda subir directamente las imágenes al servidor, lanzar el procesado y notificar cuando el modelo 3D esté listo. Pero esto me abre la puerta para hablar de la otra parte del proyecto: la plataforma de procesado de imágenes que he desplegado, basada en OpenDroneMap.