- 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
- Fotogrametría con IA: Detección de hallazgos en tiempo real durante el vuelo. Módulo de auditoría IA con YOLOv8 y RTMP sobre el stack ODM
- Aplicación Android para fotogrametría con IA. Fase 5: Streaming RTMP y telemetría en tiempo real — cómo se coordina un sistema entre dos agentes de IA
- Aplicación Android para fotogrametría con IA. Fase 6: Resultados en el mapa, gestión multi-batería y reanudación de misiones
- Aplicación Android para fotogrametría con IA. Fase 7: Edición de polígono, estimación de batería corregida y caché offline
La Fase 6 cerró el ciclo principal de la aplicación: planificar, volar, procesar y ver los resultados en el mapa. La Fase 7 no añade capacidades radicalmente nuevas, sino que pule lo que ya existe en cuatro áreas concretas: notificaciones inteligentes de procesado, historial de vuelos con estadísticas reales, una estimación de batería que por fin se ajusta a lo que ocurre en campo, así como la edición de polígono sin tener que redibujar el área desde cero. Son el tipo de mejoras que no generan mucho titular pero que cambian mucho la experiencia de uso diaria.

Notificaciones que llevan al sitio correcto
El procesado fotogramétrico en ODM tarda entre 30 minutos y dos horas. La app ya enviaba una notificación cuando terminaba, pero al tocarla simplemente abría la aplicación en la pantalla donde estuviera. Si el usuario tenía varias misiones, tenía que buscar cuál era la que había terminado.
La mejora fue convertir esa notificación en un deep link: al tocarla, la app navega directamente a la pantalla de procesado de la misión concreta que acaba de terminar. El OdmPollingWorker construye un PendingIntent con el ID de misión como extra, y MainActivity lo detecta tanto en onCreate (si la app estaba cerrada) como en onNewIntent (si estaba en primer plano), navegando en ambos casos a la pantalla correcta.
Un detalle técnico que vale la pena mencionar: el PendingIntent usa los flags FLAG_ACTIVITY_SINGLE_TOP | FLAG_ACTIVITY_CLEAR_TOP, que garantizan que funciona correctamente tanto si la app está cerrada como si está activa. Y el estado de navegación usa mutableLongStateOf en lugar de mutableStateOf<Long>, que es la optimización de Compose para primitivos numéricos.
Historial de vuelos: estadísticas que ya estaban ahí
La lista de misiones se limitaba a mostrar nombre y fecha, nada más. Pero todos los datos necesarios para calcular estadísticas de vuelo ya estaban guardados en Room desde fases anteriores: los timestamps de inicio y fin de misión, el trail de trayectoria como lista de coordenadas, las snapshots de foto con nivel de batería en cada punto, y el polígono del área. Solo faltaba calcular.
Se creó FlightStatisticsCalculator, que procesa esos datos existentes y produce un objeto FlightStatistics con duración, distancia recorrida, número de fotos, velocidad media, altitud máxima, batería consumida y cobertura en hectáreas. No fue necesaria ninguna migración de base de datos: todos los datos ya existían, simplemente no se calculaba nada a partir de ellos.
Los algoritmos son los que corresponden a cada métrica. La distancia usa Haversine sobre puntos consecutivos del trail. La duración es la diferencia entre los timestamps de fin e inicio. La batería consumida es la diferencia entre el nivel en la primera y la última snapshot fotográfica. La cobertura usa la fórmula de Shoelace sobre los vértices del polígono proyectados a metros con proyección equirectangular —la misma que usa el motor de cálculo de la rejilla desde la Fase 1— convertida finalmente a hectáreas.
En la lista de misiones, las que tienen datos de vuelo muestran una línea adicional en azul: duración, distancia, fotos y batería consumida en una sola línea compacta. Las que no han volado todavía no muestran nada adicional.
La estimación de batería que subestimaba un 17%
Desde la Fase 1, la estimación de consumo de batería se calculaba así:
val flightTimeMin = totalDistanceM / speedMs / 60.0
val batteryPct = (flightTimeMin / drone.maxFlightTimeMin) * 100.0
Solo contaba el tiempo de crucero horizontal. Para el vuelo de referencia de la Fase 5 —37 waypoints, 20 metros de altura, 3 m/s— la estimación daba un 12,4% de consumo. El consumo real fue del 15%. Una subestimación del 17%, que se hace mayor en misiones largas porque el coste fijo —despegue, RTH— pesa proporcionalmente más.
La fórmula revisada añade todos los componentes que la v1 ignoraba:
cruiseTime = totalDistance / speed
takeoffTime = altitude / 2.0 m/s + 15s de preparación
rthTime = distancia(últimoWP → home) / 5.0 m/s + altitude / 2.0 + 10s de aterrizaje
waypointStops = numWaypoints × 1.5s
transitions = numLines × 3.0s
totalTime = (cruiseTime + takeoffTime + rthTime + waypointStops + transitions) × 1.15
batteryPct = (totalTime / maxFlightTime) × 100
Las constantes son el resultado de calibrar con datos reales. El tiempo por waypoint (1,5 s) refleja que el dron en Virtual Stick desacelera pero no para del todo. El tiempo por transición entre líneas (3,0 s) cubre la deceleración, el giro y la aceleración. El RTH usa 5 m/s porque el dron vuelve a casa más rápido que en crucero de misión. El factor 1,15 añade un 15% de margen por viento, correcciones GPS y rotaciones de yaw.
Con la fórmula v2, el mismo vuelo de referencia estima un 21,5% de consumo frente al 15% real. Sobreestima un 43%, y eso es deliberado: la app muestra un aviso cuando la estimación supera el 90% de la batería disponible, y es preferible que ese aviso aparezca antes de tiempo a que aparezca tarde o no aparezca. El análisis detallado de la calibración está documentado en el repositorio.
Caché offline de tiles de mapa
El caso de uso es claro: llegar a una parcela y descubrir que no hay cobertura de datos móviles justo cuando se necesita ver el mapa para orientarse. La solución implementada es un caché pasivo de 500 MB con OkHttp: todos los tiles que se visualizan en la app se guardan automáticamente en disco con un tiempo de vida de 7 días. Las zonas que se han revisado en la oficina antes de salir al campo son accesibles offline sin hacer nada adicional.
La implementación añade un interceptor de Cache-Control al cliente OkHttp global de MapLibre y configura el directorio y tamaño máximo de caché. El panel de ajustes de la app muestra el tamaño actual ocupado y ofrece un botón para limpiarlo.
El bug más interesante de toda la fase apareció aquí: la app se cerraba mostrando un mensaje de error al arrancar con un ExceptionInInitializerError en HttpRequestUtil.setOkHttpClient(). La causa era un problema de orden de inicialización: MapLibre 11.8.0 requiere que MapLibre.getInstance() se haya llamado antes de cualquier operación sobre HttpRequestUtil. El código configuraba la caché antes de que MapLibre estuviera inicializado. La solución fue añadir MapLibre.getInstance(this) como primera línea de setupTileCache(). El diagnóstico llegó vía adb logcat con la traza completa del crash.
Un matiz importante de la implementación: el cliente OkHttp configurado con la caché de tiles es el cliente global de MapLibre, y no debe llevar el interceptor JWT de WebODM. Los tiles de OSM, PNOA y Catastro no necesitan autenticación; si se les añadiera el header JWT, las peticiones fallarían. WebODM usa su propio cliente OkHttp configurado en Retrofit, completamente separado, sin ninguna interferencia.
Edición de polígono: mover, borrar e insertar vértices
Hasta esta fase, modificar el polígono del área de misión después de confirmarlo requería borrarlo y redibujarlo desde cero. La edición de polígono resuelve eso: permite seleccionar un vértice y moverlo a otra posición, borrarlo (con un mínimo de tres vértices) o insertar un nuevo vértice entre dos existentes tocando el punto medio entre ellos.
La parte técnicamente más interesante es cómo MapLibre representa los vértices para permitir la interacción. En lugar de un MultiPoint único, cada vértice es un Feature individual con dos propiedades: su índice en la lista y si está seleccionado o no. Esto permite aplicar estilos distintos dependiendo del estado:
Expression.switchCase(
Expression.get("is-selected"),
Expression.literal(0xFFFF9800.toInt()), // naranja si seleccionado
Expression.literal(0xFF2196F3.toInt()) // azul si no
)
Los puntos medios entre vértices son una capa separada de círculos semitransparentes más pequeños. Cada uno lleva la propiedad after-index indicando entre qué par de vértices está, que es lo que necesita la lógica de inserción al tocarlos.
La lógica de tap en modo edición distingue cuatro casos: tocar un punto medio (insertar), tocar un vértice (seleccionar o deseleccionar si ya estaba seleccionado), tocar espacio vacío con un vértice seleccionado (mover), y tocar espacio vacío sin selección (no hacer nada). Cualquier cambio en el polígono dispara un recálculo completo de la rejilla de waypoints, igual que si se hubiera modificado un parámetro de vuelo.
Dos bugs de compilación aparecieron durante el desarrollo. El primero fue intentar usar las expresiones de estilo de MapLibre como métodos de instancia —expr.switchCase(...)— cuando en realidad son métodos estáticos de Java: Expression.switchCase(...), Expression.get(...). El error de compilación era explícito, pero la causa no era obvia sin conocer la API. El segundo fue más peculiar: una anotación @Composable residual de una edición anterior se adhirió a una función normal, y aunque se eliminó, el compilador de Compose seguía marcando el error en cualquier punto donde se llamara a esa función desde un lambda. La solución fue eliminar la función e inlinear la lógica directamente en el lambda, lo que también simplificó el código.
La interacción actual —seleccionar vértice con tap, luego tap en el destino para moverlo— funciona correctamente, pero es menos intuitiva que el drag directo. El drag directo sería más natural, pero requiere distinguir entre el pan del mapa y el arrastre de un vértice, algo que MapLibre no resuelve de forma nativa y que añadiría complejidad considerable. Está documentado como mejora para la Fase 8.
El estado actual: siete fases completadas
Con la Fase 7 cerrada, el proyecto incluye siete fases completadas. La aplicación cubre el ciclo completo de fotogrametría con drones desde la planificación hasta la visualización de resultados, con auditoría IA en tiempo real, gestión de múltiples baterías, reanudación de misiones interrumpidas, caché offline para trabajo en campo sin cobertura, y ahora también edición de polígono y estadísticas históricas de vuelo.
La Fase 8, planificada, añadirá soporte para otros modelos de drones DJI más allá del Mini 3 Pro, integración de condiciones meteorológicas, indicadores de cobertura GPS y descarga activa de tiles para zonas sin cobertura. Cuando llegue el momento, habrá artículo.