This entry is parte 2 de 2 in the series Fotogrametría asistida por IA

Fotogrametría asistida por IA

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

En el artículo anterior expliqué el origen de este proyecto, el planteamiento general de la aplicación y el plan de desarrollo que Claude Code trazó antes de escribir la primera línea de código. En este artículo toca hablar de cómo se materializó la primera fase: desde la verificación del entorno en el Mac Mini M4 hasta tener una aplicación Android funcional capaz de planificar misiones de fotogrametría sobre un mapa real.

Ejemplo de una rejilla de vuelo sobre una misión
Ejemplo de una rejilla de vuelo sobre una misión

Primero lo primero: comprobar que el terreno está listo

Antes de escribir ni una línea de código, empecé por una tarea que se suele subestimar, pero que es clave para que el proyecto transcurra de manera fluida: verificar que el entorno de desarrollo está en condiciones. En este caso, el entorno era un Mac Mini M4 con Android Studio. Hace ya algunos meses había intentado dar algunos pasos en este ámbito, y aunque el resultado fue infructuoso, el entorno estaba básicamente listo, Por ello, la comprobación se redujo a ejecutar una serie de comandos en la terminal.

El diagnóstico fue rápido y, afortunadamente, satisfactorio:

ComponenteVersión encontrada
JDKOpenJDK 17.0.18 (Homebrew)
Android SDKAPIs 33, 34, 35 y 36 disponibles
Build Tools34.0.0 a 36.1.0
ADBv36.0.0
EmuladorPixel 7 Pro disponible

No fue necesario instalar nada adicional. Un buen comienzo. Pero sí surgió una primera lección de esas que conviene apuntar: Android necesita JDK 17, no 21. La versión 21 genera incompatibilidades con el plugin de Gradle actual, algo que no es evidente hasta que el proyecto empieza a dar errores oscuros en la compilación. La versión 17 es, a día de hoy, la que ofrece mayor compatibilidad.

Creando el proyecto desde cero (sin el wizard de Android Studio)

La decisión de no usar el asistente de creación de proyectos de Android Studio fue deliberada. Tras una fase previa de validación de las capacidades de Claude, el asistente propuso crear cada fichero de configuración a mano, explicando qué hace cada uno. Esto era algo en lo que había sido específico con el asistente de IA: el objetivo era que yo entendiera la estructura, no que simplemente apareciera una app ya montada. Una aproximación de tutor más que de desarrollador.

Los cuatro ficheros clave que dan vida a un proyecto Android moderno son:

  • settings.gradle.kts: define de dónde obtener plugins y dependencias (repositorios de Google y Maven Central) y qué módulos forman el proyecto.
  • build.gradle.kts (raíz): declara los plugins globales con sus versiones. Aquí vive el Android Gradle Plugin, el compilador de Kotlin, el plugin de Compose y Hilt para inyección de dependencias.
  • gradle.properties: parámetros de configuración de JVM y flags como android.useAndroidX=true.
  • app/build.gradle.kts: el fichero más importante. Define el SDK objetivo (API 35), el SDK mínimo (API 26, requerido por DJI) y todas las dependencias organizadas por categorías.

El proceso no estuvo exento de pequeños tropiezos. Uno de los primeros fue intentar generar el Gradle Wrapper con la versión 9.4 instalada globalmente, que rechazó el comando porque el directorio app/ no existía todavía. Solución trivial (mkdir -p app), pero ilustrativa de lo que pasa cuando das pasos en el orden incorrecto. Otro: la sintaxis dependencyResolution que aparece en la documentación más reciente de Gradle es de la versión 9.x; como el wrapper usa 8.11.1, hay que usar dependencyResolutionManagement. Detalles pequeños, pero que con el plugin de IA bien puesto encima se resuelven al vuelo.

La arquitectura elegida: MVVM

Antes de entrar en las pantallas, conviene entender el patrón de arquitectura sobre el que se construye toda la aplicación: MVVM (Model-View-ViewModel), el recomendado por Google para apps Android modernas.

La idea central es sencilla: los datos fluyen hacia abajo y el estado fluye hacia arriba. La interfaz de usuario (Jetpack Compose) observa un StateFlow que emite el ViewModel. El ViewModel llama al Repositorio para obtener o guardar datos. El Repositorio habla con la base de datos (Room/SQLite). Nadie se salta la cadena.

UI (Compose) → observa StateFlow
     ↑                ↓
ViewModel ←→ MissionCalculator
     ↓
Repository
     ↓
Room / SQLite

Esta separación tiene una ventaja práctica inmediata: cuando el usuario cambia un slider de parámetros, la rejilla de vuelo se recalcula y el mapa se actualiza automáticamente, sin que la pantalla sepa nada de cómo funciona el algoritmo por debajo. Todo el estado vive en una única data class inmutable (MapUiState) que el ViewModel actualiza con cada acción del usuario.

Primera pantalla: el mapa

La pantalla principal de la app es un mapa interactivo. Para implementarlo, la elección fue MapLibre GL en lugar de Google Maps, y la razón es pragmática: MapLibre es open source, no requiere API key, no tiene límites de uso y permite añadir capas WMS personalizadas (como el catastro o las ortofotos del IGN) sin restricciones. Para una aplicación de fotogrametría que va a usar capas cartográficas específicas de España, esto es determinante.

El único inconveniente es que MapLibre es una librería de Views clásicas (pre-Compose), así que hay que envolverla en un AndroidView para integrarla en Jetpack Compose. Es el puente oficial entre los dos sistemas, y funciona bien una vez que entiendes que el mapa se configura en el callback getMapAsync, no fuera de él.

Los tiles de OpenStreetMap se definen como un estilo JSON inline, sin necesidad de ficheros externos ni servicios de pago:

private val OSM_STYLE_JSON = """
{
  "version": 8,
  "sources": {
    "osm-raster": {
      "type": "raster",
      "tiles": ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
      "tileSize": 256
    }
  },
  "layers": [{
    "id": "osm-raster-layer",
    "type": "raster",
    "source": "osm-raster"
  }]
}
""".trimIndent()

Dibujando el área de interés

Con el mapa en pantalla, el siguiente paso era permitir que el usuario defina el área sobre la que va a volar. La solución es un modo de dibujo que se activa al pulsar el botón «Nueva Misión»: a partir de ese momento, cada toque en el mapa añade un vértice al polígono.

Este fue el primer momento en que el patrón MVVM se puso a trabajar de verdad. Al tocar el mapa, la pantalla llama a viewModel.addVertex(latLng). El ViewModel actualiza el estado. Un LaunchedEffect en la pantalla detecta el cambio y actualiza el GeoJsonSource de MapLibre. El mapa se redibuja. Todo esto sucede de forma reactiva, sin que ninguna capa sepa lo que hace la otra.

Para la barra de herramientas del modo dibujo (Cancelar / Deshacer / Confirmar), se usó AnimatedVisibility con transiciones de deslizamiento vertical, de forma que aparece y desaparece suavemente al entrar y salir del modo dibujo.

Las capas del polígono en MapLibre siguen el modelo Source + Layer: se crean vacías al arrancar el mapa y después solo se actualizan los datos del GeoJsonSource, que es mucho más eficiente que recrear las capas en cada cambio.

El motor fotogramétrico

Con el polígono definido, la aplicación necesita calcular la rejilla de vuelo. Este es el núcleo de la app, y la parte más interesante desde el punto de vista técnico.

Todo empieza con el GSD (Ground Sample Distance), la métrica fundamental en fotogrametría: cuántos centímetros del mundo real representa cada píxel de la imagen. La fórmula depende de la altura de vuelo, las características del sensor y la distancia focal:

GSD = (altura × ancho_sensor_mm) / (focal_mm × ancho_imagen_px) × 100

Con el DJI Mini 3 Pro a 60 metros de altura, el resultado es aproximadamente 1,07 cm/px. Una resolución más que suficiente para fotogrametría de precisión.

A partir del GSD se calcula el footprint (la superficie que cubre cada fotografía en el suelo) y de ahí el espaciado entre fotos y entre pasadas, aplicando los porcentajes de overlap configurados por el usuario.

El algoritmo de generación de la rejilla de waypoints sigue estos pasos:

  1. Proyectar los vértices del polígono a coordenadas locales en metros (proyección equirectangular, suficientemente precisa para áreas de menos de 1 km²).
  2. Rotar el polígono para alinear el heading de vuelo con el eje X, simplificando el problema a líneas horizontales.
  3. Generar scan lines desde el mínimo al máximo Y, separadas por el spacing lateral.
  4. Para cada línea, calcular las intersecciones con los bordes del polígono.
  5. Colocar waypoints entre cada par de intersecciones, separados por el spacing frontal.
  6. Alternar la dirección en pasadas consecutivas (patrón serpentina) para minimizar la distancia total.
  7. Rotar todo de vuelta al heading original y convertir a coordenadas geográficas.

Para misiones de modelo 3D, este proceso se repite con el heading girado 90°, generando el patrón cross-hatch que permite capturar múltiples ángulos de cada punto del terreno.

Un detalle importante: las especificaciones del sensor del DJI Mini 3 Pro están precargadas en la app. La focal que se usa en los cálculos es la focal real (6,7 mm), no la equivalente en full frame (24 mm), que es solo una referencia para fotógrafos acostumbrados a cámaras de formato completo.

Configuración de parámetros y estimaciones en tiempo real

El panel de configuración de la misión permite ajustar mediante sliders los parámetros de vuelo: altura (1–120 m), overlap frontal (60–90%), overlap lateral (50–85%), velocidad (1–12 m/s) y orientación de las pasadas (0–170°). Cada cambio dispara inmediatamente un recálculo del motor fotogramétrico y actualiza la visualización en el mapa.

Las estimaciones que muestra la app en tiempo real son:

  • GSD: resolución de cada píxel en centímetros.
  • Número de fotos: total de disparos que realizará el dron.
  • Distancia total: kilómetros de vuelo.
  • Duración estimada: en minutos, según la velocidad configurada.
  • Batería necesaria: porcentaje estimado, con aviso si supera el 90%.

Esta retroalimentación inmediata es especialmente útil en campo: puedes ajustar altura y overlaps hasta encontrar el equilibrio entre resolución, cobertura y autonomía que necesitas para cada misión concreta.

Guardar y cargar misiones

El último bloque de la Fase 1 fue la persistencia local de misiones, implementada con Room, el ORM de SQLite para Android.

El desafío técnico aquí es que Room solo puede guardar tipos primitivos de forma directa, y los datos de una misión incluyen una lista de coordenadas (List<LatLng>) y un objeto de configuración (MissionConfig). La solución fue serializar ambos como JSON en columnas de texto. Una misión completa ocupa una sola fila en la base de datos, lo que simplifica mucho las operaciones de lectura y escritura.

El DAO expone un Flow<List<MissionEntity>>: cuando se guarda o elimina una misión, la lista se actualiza automáticamente en la pantalla sin necesidad de refrescar nada manualmente. Es la naturaleza reactiva de Kotlin Coroutines y Room trabajando juntos.

La interfaz de gestión de misiones se compone de un diálogo para nombrarlas (con fecha sugerida por defecto) y un bottom sheet con la lista completa, donde cada elemento muestra nombre, fecha y un botón de eliminar.

Bugs y lecciones aprendidas

Ningún proceso de desarrollo está exento de problemas, y este no fue una excepción. Los más reseñables de esta primera fase:

  • API de ubicación de MapLibre: la versión 11.x cambió la forma de activar el componente de localización. El método antiguo (activateLocationComponent(context)) no compila; hay que usar LocationComponentActivationOptions.builder().
  • Crash con String.format(): la app se cerraba al arrancar con UnknownFormatConversionException. El culpable era el carácter % en una cadena de formato Kotlin. La solución es separar el String.format() del literal %, nunca mezclarlos en la misma cadena.
  • Polígono que persiste al eliminar una misión: al borrar la misión que estaba activa en el mapa, el polígono seguía visible. La solución fue detectar si el ID de la misión eliminada coincide con la cargada en pantalla, y en ese caso resetear el estado completo.

El resultado

Al cierre de la Fase 1, la aplicación era ya completamente funcional para la planificación de misiones:

  • Mapa interactivo con tiles de OpenStreetMap.
  • Dibujo de polígonos con barra de herramientas animada.
  • Configuración de todos los parámetros de vuelo con sliders.
  • Selector de tipo de misión: 2D (ortomosaico) o 3D (cross-hatch).
  • Cálculo automático de grilla de waypoints con visualización en tiempo real.
  • Estimaciones de GSD, fotos, duración y batería.
  • Patrón serpentina y doble grilla para misiones 3D.
  • Guardado, carga y borrado de misiones con Room/SQLite.

Todo ello desarrollado de forma guiada con Claude Code, entendiendo cada decisión de arquitectura y cada componente del código. En el próximo artículo hablaremos de la Fase 2: cómo se integró el DJI Mobile SDK v5, los problemas (bastante gordos) que aparecieron intentando hacer volar el dron de forma autónoma y cómo se resolvieron.

Fotogrametría asistida por IA

Diseño y desarrollo de un entorno de fotogrametría con drones asistido por IA

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.