Esta entrada es la parte 6 de 6 de la serie Fotogrametría asistida por IA

En los artículos anteriores de esta serie describí cómo la aplicación planifica misiones de fotogrametría, hace volar al dron de forma autónoma y supervisa el vuelo en tiempo real. Al terminar la Fase 2 y la Fase 3, la app era ya capaz de ejecutar una misión completa y tomar las fotos en los waypoints correctos, pero el resultado quedaba a medias: las imágenes se almacenaban en la tarjeta del dron, y llegar desde ahí hasta un modelo 3D procesado requería transferirlas manualmente al ordenador, subirlas a WebODM, esperar el procesado y abrir el resultado en el navegador. La Fase 4 cierra ese hueco: conecta la app directamente con el servidor de procesado y añade la exportación de la telemetría de vuelo en formato KMZ.

El pipeline completo: del dron al servidor en tres pasos

El objetivo de esta fase era que, al terminar un vuelo, el operador pudiera pulsar un botón y que la app se encargara de todo lo demás: descargar las fotos del dron al teléfono, subirlas al servidor WebODM y monitorizar el procesado hasta que el modelo estuviera listo. El flujo quedó así:

Dron (tarjeta SD)
     │ DJI MSDK v5 — MediaDownloader
     ▼
Teléfono (caché local)
     │ WorkManager — OdmUploadWorker
     ▼
WebODM API REST
     │ WorkManager — OdmPollingWorker
     ▼
Procesado ODM en servidor
     │ status == COMPLETED
     ▼
Notificación al usuario
Primera construcción fotogramétrica automatizada desde la aplicación
Primera construcción fotogramétrica automatizada desde la aplicación

Cada etapa tiene su propia pieza técnica, y cada una trajo sus propios bugs. Voy por partes.

Descargando las fotos del dron

El primer paso era recuperar las imágenes capturadas durante el vuelo. El DJI Mobile SDK v5 expone una API de gestión de medios que permite listar y descargar los ficheros almacenados en la tarjeta del dron. Se implementó en MediaDownloader.kt, que recorre los ficheros disponibles, filtra los JPEG y los descarga al almacenamiento local de la app.

El primer bug apareció aquí, y fue de esos que son difíciles de detectar a simple vista. Las fotos se descargaban correctamente —sin errores, sin excepciones— pero pesaban todas exactamente 976 bytes, lo que indicaba que estaban claramente corruptas.

La causa estaba en la API de descarga del SDK. El listener de descarga recibe dos parámetros: data: ByteArray (el chunk de datos) y position: Long. El nombre position sugiere tamaño, pero en realidad es el offset acumulado desde el inicio del fichero. El código original usaba position.toInt() como número de bytes a escribir, que en el primer chunk es el tamaño de ese chunk, pero en los siguientes es el offset total acumulado, lo que provocaba escrituras truncadas o desbordadas. La solución es usar data.size:

// Incorrecto: position es el offset acumulado, no el tamaño del chunk
bos.write(data, 0, position.toInt())

// Correcto: data.size es el tamaño real del chunk recibido
bos.write(data, 0, data.size)

Un detalle de la API del SDK que no aparece documentado con claridad, y que sólo se descubre comparando el tamaño de los ficheros descargados con los originales.

Además de la descarga, se añadió un sistema de caché local: si la app detecta que ya existen fotos válidas (más de 10 KB, extensión JPEG) en el directorio de la misión, las reutiliza directamente sin necesidad de reconectar el dron. Esto permite corregir parámetros de procesado en WebODM y resubir las mismas imágenes sin volver a conectar el hardware.

Subiendo a WebODM con WorkManager

La carga de las imágenes al servidor se implementó con WorkManager, que es la herramienta correcta para operaciones de red largas en Android: sobrevive a la muerte de la Activity, gestiona reintentos automáticos y permite encadenar workers pasando datos entre ellos.

Ortofotografía sobre WebODM
Ortofotografía sobre WebODM

La cadena es OdmUploadWorkerOdmPollingWorker: el primero crea el proyecto en WebODM, sube todas las imágenes y devuelve el task_id; el segundo recoge ese ID y hace polling periódico del estado del procesado, actualizando la pantalla hasta que el status pasa a COMPLETED o FAILED.

La autenticación con WebODM usa JWT, implementada como un interceptor de OkHttp (JwtAuthInterceptor) que añade la cabecera Authorization: JWT <token> a todas las peticiones y la renueva automáticamente cuando caduca. Aquí apareció el segundo bug relevante: WebODM devuelve 403, no 401, cuando el token ha expirado. El interceptor inicial sólo manejaba 401, así que los tokens caducados provocaban fallos silenciosos en las peticiones. La solución fue añadir 403 a la lógica de refresco.

El tercer bug vino de una asunción incorrecta sobre la API de WebODM. El código esperaba que los IDs de tarea fueran enteros, igual que los IDs de proyecto. WebODM usa UUIDs (strings) para los task IDs. El crash era un NumberFormatException al parsear la respuesta, y la solución fue cambiar taskId: Int a taskId: String en toda la cadena: modelos, API, repositorio, workers y ViewModel.

Las credenciales del servidor (URL, usuario y contraseña) se configuran desde una pantalla de ajustes dedicada y se almacenan con EncryptedSharedPreferences, nunca en texto claro. Una pantalla de procesamiento muestra en tiempo real el progreso de descarga, upload y procesado en servidor mediante barras de progreso independientes.

Flujo de procesado completo, desde el dron al procesado
Flujo de procesado completo, desde el dron al procesado

Exportación KMZ: un problema de arquitectura disfrazado de feature

La exportación KMZ —un fichero que contiene el polígono de la misión, la trayectoria real del dron y los puntos donde se tomaron las fotos— parecía una funcionalidad sencilla. Resultó ser el problema técnico más interesante de toda la fase.

Menú de exportación de misiones, tanto ODM como KMZ
Menú de exportación de misiones, tanto ODM como KMZ

El KMZ generaba correctamente el polígono del área planificada, pero la trayectoria y los puntos de captura salían vacíos. El motivo: esos datos viven en MissionExecutor durante el vuelo, pero cuando el usuario termina la misión y vuelve a la pantalla del mapa para exportar el KMZ, Navigation Compose ya ha destruido y recreado la pantalla, y los datos se han perdido.

Se intentaron cinco aproximaciones distintas para pasar esos datos al ViewModel al volver de la pantalla de ejecución:

  1. LaunchedEffect(Unit) en MapScreen — solo ejecuta una vez al montar la pantalla, no al volver de otra.
  2. DisposableEffect con ON_RESUME — en navegación intra-Activity de Compose, el evento de resume no se dispara como se esperaría.
  3. Un flag en savedStateHandle — demasiado frágil, con problemas de timing.
  4. LaunchedEffect(MissionExecutor.missionEndTime) — una var normal no es Compose State, no garantiza recomposición.
  5. Eliminar el reset() de MissionExecutor al pulsar atrás — los datos se perdían igualmente por timing entre la navegación y la lectura.

Cinco intentos fallidos. La solución llegó al plantear el problema de otra manera: en lugar de intentar pasar datos a través del flujo de navegación, escribirlos a disco durante el vuelo y leerlos desde ahí cuando haga falta.

Se creó FlightDataWriter, un componente que graba la telemetría estructurada en un fichero JSON independiente mientras el dron vuela. La arquitectura es limpia:

MissionExecutor ─── FlightDataWriter.start()
     │                       │
     ├── recordTrailPoint() ──► addTrailPoint(lat, lng, alt)
     ├── recordPhotoPoint() ──► addPhotoSnapshot(...)
     │                       │
     └── RTH/finish ─────────► FlightDataWriter.finish()
                                        │
                                        ▼
                              telemetry_<timestamp>.json

MapViewModel.exportKml() ◄── FlightDataWriter.loadForMission()

El fichero se escribe durante el vuelo, de forma completamente independiente a la navegación entre pantallas. Cuando el usuario exporta el KMZ horas o días después, FlightDataWriter.loadForMission() recupera los datos desde el fichero. La navegación Compose ya no importa.

El patrón de escribir a fichero en lugar de depender de la memoria de la aplicación era ya conocido en el proyecto: el FlightLogger de diagnóstico que se implementó en la Fase 2 para poder revisar logs en campo sin acceso a ADB sigue exactamente la misma filosofía.

El bug del Locale: comas que rompen coordenadas

Con los datos de telemetría disponibles, el KMZ generaba la trayectoria y los puntos de foto. Pero al abrirlo en Google Earth, la trayectoria mostraba saltos de miles de kilómetros y altitudes absurdas.

La causa era sutil. El formato KML representa las coordenadas como longitud,latitud,altitud separados por comas. En un dispositivo Android con el idioma configurado en español, "%.1f".format(6.3) produce "6,3" con coma decimal en lugar de punto. En el contexto del KML, esa coma adicional se interpreta como un separador de coordenadas:

Generado (Locale español):  -6,041619,37,4422,6,3    ← 6 valores (corrupto)
Esperado:                   -6.041619,37.4422,6.3     ← 3 valores (correcto)

Cada coordenada en el fichero era un sinsentido geográfico. La solución es forzar Locale.US en todos los formatos numéricos del KML, garantizando el punto como separador decimal independientemente de la configuración del dispositivo:

String.format(Locale.US, "%.8f,%.8f,%.1f", longitude, latitude, altitude)

Es el tipo de bug que no aparece en los tests porque los tests suelen ejecutarse en máquinas con Locale inglés, y que en producción solo se manifiesta en dispositivos configurados con idiomas que usan coma decimal. Clásico.

Vuelo exportado en KMZ visualizado en Google Earth Pro. Se observan el área de la misión de vuelo, el vuelo en sí, y los PDIs con fotografías
Vuelo exportado en KMZ visualizado en Google Earth Pro. Se observan el área de la misión de vuelo, el vuelo en sí, y los PDIs con fotografías

El estado del proyecto tras la Fase 4

Con esta fase cerrada, el ciclo completo de fotogrametría está cubierto de extremo a extremo dentro de la app:

  1. Planificar: dibujar el área sobre el mapa, configurar altura y overlaps, previsualizar la grilla de vuelo.
  2. Volar: ejecución autónoma via Virtual Stick con control de heading y disparo automático de cámara.
  3. Supervisar: telemetría en tiempo real, trail de trayectoria, puntos de foto sobre mini-mapa.
  4. Descargar: transferencia de imágenes del dron al teléfono con caché local.
  5. Procesar: upload a WebODM, seguimiento del procesado en servidor, notificación al completarse.
  6. Exportar: KMZ con polígono de misión, trayectoria real y puntos de captura con altitud.

Lo que queda pendiente está documentado como Fase 5: visualización del ortomosaico resultante como overlay sobre el mapa, gestión de múltiples baterías para misiones largas, reanudación de misión interrumpida, soporte para otros drones DJI, edición de polígono post-confirmación y caché offline de tiles de mapa para trabajar en campo sin cobertura de datos. Material, en definitiva, para seguir contando en próximos artículos.

Navegación de la serie<< Aplicación Android para fotogrametría con IA. La plataforma de procesado: OpenDroneMap desplegado con Claude Code

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.