{"id":11767,"date":"2026-04-14T10:05:00","date_gmt":"2026-04-14T08:05:00","guid":{"rendered":"https:\/\/bitacora.eniac2000.com\/?p=11767"},"modified":"2026-04-13T19:04:45","modified_gmt":"2026-04-13T17:04:45","slug":"aplicacion-android-para-fotogrametria-con-ia-fase-7-edicion-de-poligono-estimacion-de-bateria-corregida-y-cache-offline","status":"publish","type":"post","link":"https:\/\/bitacora.eniac2000.com\/?p=11767","title":{"rendered":"Aplicaci\u00f3n Android para fotogrametr\u00eda con IA. Fase 7: Edici\u00f3n de pol\u00edgono, estimaci\u00f3n de bater\u00eda corregida y cach\u00e9 offline"},"content":{"rendered":"<div class=\"seriesmeta\">Esta entrada es la parte 10 de 10 de la serie <a href=\"https:\/\/bitacora.eniac2000.com\/?series=fotogrametria-asistida-por-ia\" class=\"series-1957\" title=\"Fotogrametr\u00eda asistida por IA\">Fotogrametr\u00eda asistida por IA<\/a><\/div>\n<p>La <a href=\"https:\/\/bitacora.eniac2000.com\/?p=11703\">Fase 6<\/a> cerr\u00f3 el ciclo principal de la aplicaci\u00f3n: planificar, volar, procesar y ver los resultados en el mapa. La Fase 7 no a\u00f1ade capacidades radicalmente nuevas, sino que pule lo que ya existe en cuatro \u00e1reas concretas: notificaciones inteligentes de procesado, historial de vuelos con estad\u00edsticas reales, una estimaci\u00f3n de bater\u00eda que por fin se ajusta a lo que ocurre en campo, as\u00ed como la edici\u00f3n de pol\u00edgono sin tener que redibujar el \u00e1rea desde cero. Son el tipo de mejoras que no generan mucho titular pero que cambian mucho la experiencia de uso diaria.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"559\" src=\"https:\/\/bitacora.eniac2000.com\/wp-content\/uploads\/2026\/04\/Gemini_Generated_Image_ym5l7bym5l7bym5l-1024x559.png\" alt=\"Fase 7: acciones desempe\u00f1adas\" class=\"wp-image-11769\" srcset=\"https:\/\/bitacora.eniac2000.com\/wp-content\/uploads\/2026\/04\/Gemini_Generated_Image_ym5l7bym5l7bym5l-1024x559.png 1024w, https:\/\/bitacora.eniac2000.com\/wp-content\/uploads\/2026\/04\/Gemini_Generated_Image_ym5l7bym5l7bym5l-300x164.png 300w, https:\/\/bitacora.eniac2000.com\/wp-content\/uploads\/2026\/04\/Gemini_Generated_Image_ym5l7bym5l7bym5l-768x419.png 768w, https:\/\/bitacora.eniac2000.com\/wp-content\/uploads\/2026\/04\/Gemini_Generated_Image_ym5l7bym5l7bym5l.png 1408w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">Fase 7: acciones desempe\u00f1adas<\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Notificaciones que llevan al sitio correcto<\/h2>\n\n\n\n<p>El procesado fotogram\u00e9trico en ODM tarda entre 30 minutos y dos horas. La app ya enviaba una notificaci\u00f3n cuando terminaba, pero al tocarla simplemente abr\u00eda la aplicaci\u00f3n en la pantalla donde estuviera. Si el usuario ten\u00eda varias misiones, ten\u00eda que buscar cu\u00e1l era la que hab\u00eda terminado.<\/p>\n\n\n\n<p>La mejora fue convertir esa notificaci\u00f3n en un <em>deep link:<\/em> al tocarla, la app navega directamente a la pantalla de procesado de la misi\u00f3n concreta que acaba de terminar. El <code>OdmPollingWorker<\/code> construye un <code>PendingIntent<\/code> con el ID de misi\u00f3n como extra, y <code>MainActivity<\/code> lo detecta tanto en <code>onCreate<\/code> (si la app estaba cerrada) como en <code>onNewIntent<\/code> (si estaba en primer plano), navegando en ambos casos a la pantalla correcta.<\/p>\n\n\n\n<p>Un detalle t\u00e9cnico que vale la pena mencionar: el <code>PendingIntent<\/code> usa los flags <code>FLAG_ACTIVITY_SINGLE_TOP | FLAG_ACTIVITY_CLEAR_TOP<\/code>, que garantizan que funciona correctamente tanto si la <em>app<\/em> est\u00e1 cerrada como si est\u00e1 activa. Y el estado de navegaci\u00f3n usa <code>mutableLongStateOf<\/code> en lugar de <code>mutableStateOf&lt;Long&gt;<\/code>, que es la optimizaci\u00f3n de Compose para primitivos num\u00e9ricos.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Historial de vuelos: estad\u00edsticas que ya estaban ah\u00ed<\/h2>\n\n\n\n<p>La lista de misiones se limitaba a mostrar nombre y fecha, nada m\u00e1s. Pero todos los datos necesarios para calcular estad\u00edsticas de vuelo ya estaban guardados en Room desde fases anteriores: los <em>timestamps<\/em> de inicio y fin de misi\u00f3n, el <em>trail<\/em> de trayectoria como lista de coordenadas, las <em>snapshots<\/em> de foto con nivel de bater\u00eda en cada punto, y el pol\u00edgono del \u00e1rea. Solo faltaba calcular.<\/p>\n\n\n\n<p>Se cre\u00f3 <code>FlightStatisticsCalculator<\/code>, que procesa esos datos existentes y produce un objeto <code>FlightStatistics<\/code> con duraci\u00f3n, distancia recorrida, n\u00famero de fotos, velocidad media, altitud m\u00e1xima, bater\u00eda consumida y cobertura en hect\u00e1reas. No fue necesaria ninguna migraci\u00f3n de base de datos: todos los datos ya exist\u00edan, simplemente no se calculaba nada a partir de ellos.<\/p>\n\n\n\n<p>Los algoritmos son los que corresponden a cada m\u00e9trica. La distancia usa Haversine sobre puntos consecutivos del trail. La duraci\u00f3n es la diferencia entre los <em>timestamps<\/em> de fin e inicio. La bater\u00eda consumida es la diferencia entre el nivel en la primera y la \u00faltima snapshot fotogr\u00e1fica. La cobertura usa la f\u00f3rmula de Shoelace sobre los v\u00e9rtices del pol\u00edgono proyectados a metros con proyecci\u00f3n equirectangular \u2014la misma que usa el motor de c\u00e1lculo de la rejilla desde la Fase 1\u2014 convertida finalmente a hect\u00e1reas.<\/p>\n\n\n\n<p>En la lista de misiones, las que tienen datos de vuelo muestran una l\u00ednea adicional en azul: duraci\u00f3n, distancia, fotos y bater\u00eda consumida en una sola l\u00ednea compacta. Las que no han volado todav\u00eda no muestran nada adicional.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">La estimaci\u00f3n de bater\u00eda que subestimaba un 17%<\/h2>\n\n\n\n<p>Desde la Fase 1, la estimaci\u00f3n de consumo de bater\u00eda se calculaba as\u00ed:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>val flightTimeMin = totalDistanceM \/ speedMs \/ 60.0\nval batteryPct = (flightTimeMin \/ drone.maxFlightTimeMin) * 100.0<\/code><\/pre>\n\n\n\n<p>Solo contaba el tiempo de crucero horizontal. Para el vuelo de referencia de la Fase 5 \u201437 waypoints, 20 metros de altura, 3 m\/s\u2014 la estimaci\u00f3n daba un 12,4% de consumo. El consumo real fue del 15%. Una subestimaci\u00f3n del 17%, que se hace mayor en misiones largas porque el coste fijo \u2014despegue, RTH\u2014 pesa proporcionalmente m\u00e1s.<\/p>\n\n\n\n<p>La f\u00f3rmula revisada a\u00f1ade todos los componentes que la v1 ignoraba:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>cruiseTime    = totalDistance \/ speed\ntakeoffTime   = altitude \/ 2.0 m\/s  +  15s de preparaci\u00f3n\nrthTime       = distancia(\u00faltimoWP \u2192 home) \/ 5.0 m\/s  +  altitude \/ 2.0  +  10s de aterrizaje\nwaypointStops = numWaypoints \u00d7 1.5s\ntransitions   = numLines \u00d7 3.0s\ntotalTime     = (cruiseTime + takeoffTime + rthTime + waypointStops + transitions) \u00d7 1.15\nbatteryPct    = (totalTime \/ maxFlightTime) \u00d7 100<\/code><\/pre>\n\n\n\n<p>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\u00f3n entre l\u00edneas (3,0 s) cubre la deceleraci\u00f3n, el giro y la aceleraci\u00f3n. El RTH usa 5 m\/s porque el dron vuelve a casa m\u00e1s r\u00e1pido que en crucero de misi\u00f3n. El factor 1,15 a\u00f1ade un 15% de margen por viento, correcciones GPS y rotaciones de yaw.<\/p>\n\n\n\n<p>Con la f\u00f3rmula 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\u00f3n supera el 90% de la bater\u00eda disponible, y es preferible que ese aviso aparezca antes de tiempo a que aparezca tarde o no aparezca. El an\u00e1lisis detallado de la calibraci\u00f3n est\u00e1 documentado en el repositorio.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Cach\u00e9 <em>offline<\/em> de <em>tiles<\/em> de mapa<\/h2>\n\n\n\n<p>El caso de uso es claro: llegar a una parcela y descubrir que no hay cobertura de datos m\u00f3viles justo cuando se necesita ver el mapa para orientarse. La soluci\u00f3n implementada es un cach\u00e9 pasivo de 500 MB con OkHttp: todos los tiles que se visualizan en la <em>app<\/em> se guardan autom\u00e1ticamente en disco con un tiempo de vida de 7 d\u00edas. Las zonas que se han revisado en la oficina antes de salir al campo son accesibles <em>offline<\/em> sin hacer nada adicional.<\/p>\n\n\n\n<p>La implementaci\u00f3n a\u00f1ade un interceptor de <code>Cache-Control<\/code> al cliente OkHttp global de MapLibre y configura el directorio y tama\u00f1o m\u00e1ximo de cach\u00e9. El panel de ajustes de la app muestra el tama\u00f1o actual ocupado y ofrece un bot\u00f3n para limpiarlo.<\/p>\n\n\n\n<p>El <em>bug<\/em> m\u00e1s interesante de toda la fase apareci\u00f3 aqu\u00ed: la <em>app<\/em> se cerraba mostrando un mensaje de error al arrancar con un <code>ExceptionInInitializerError<\/code> en <code>HttpRequestUtil.setOkHttpClient()<\/code>. La causa era un problema de orden de inicializaci\u00f3n: MapLibre 11.8.0 requiere que <code>MapLibre.getInstance()<\/code> se haya llamado antes de cualquier operaci\u00f3n sobre <code>HttpRequestUtil<\/code>. El c\u00f3digo configuraba la cach\u00e9 antes de que MapLibre estuviera inicializado. La soluci\u00f3n fue a\u00f1adir MapLibre.getInstance(this) como primera l\u00ednea de setupTileCache(). El diagn\u00f3stico lleg\u00f3 v\u00eda <code>adb logcat<\/code> con la traza completa del <em>crash<\/em>.<\/p>\n\n\n\n<p>Un matiz importante de la implementaci\u00f3n: el cliente OkHttp configurado con la cach\u00e9 de <em>tiles<\/em> 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\u00f3n; si se les a\u00f1adiera el <em>header<\/em> JWT, las peticiones fallar\u00edan. WebODM usa su propio cliente OkHttp configurado en Retrofit, completamente separado, sin ninguna interferencia.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Edici\u00f3n de pol\u00edgono: mover, borrar e insertar v\u00e9rtices<\/h2>\n\n\n\n<p>Hasta esta fase, modificar el pol\u00edgono del \u00e1rea de misi\u00f3n despu\u00e9s de confirmarlo requer\u00eda borrarlo y redibujarlo desde cero. La edici\u00f3n de pol\u00edgono resuelve eso: permite seleccionar un v\u00e9rtice y moverlo a otra posici\u00f3n, borrarlo (con un m\u00ednimo de tres v\u00e9rtices) o insertar un nuevo v\u00e9rtice entre dos existentes tocando el punto medio entre ellos.<\/p>\n\n\n\n<p>La parte t\u00e9cnicamente m\u00e1s interesante es c\u00f3mo MapLibre representa los v\u00e9rtices para permitir la interacci\u00f3n. En lugar de un <code>MultiPoint<\/code> \u00fanico, cada v\u00e9rtice es un <code>Feature<\/code> individual con dos propiedades: su \u00edndice en la lista y si est\u00e1 seleccionado o no. Esto permite aplicar estilos distintos dependiendo del estado:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Expression.switchCase(\n    Expression.get(\"is-selected\"),\n    Expression.literal(0xFFFF9800.toInt()),  \/\/ naranja si seleccionado\n    Expression.literal(0xFF2196F3.toInt())   \/\/ azul si no\n)<\/code><\/pre>\n\n\n\n<p>Los puntos medios entre v\u00e9rtices son una capa separada de c\u00edrculos semitransparentes m\u00e1s peque\u00f1os. Cada uno lleva la propiedad <code>after-index<\/code> indicando entre qu\u00e9 par de v\u00e9rtices est\u00e1, que es lo que necesita la l\u00f3gica de inserci\u00f3n al tocarlos.<\/p>\n\n\n\n<p>La l\u00f3gica de <em>tap<\/em> en modo edici\u00f3n distingue cuatro casos: tocar un punto medio (insertar), tocar un v\u00e9rtice (seleccionar o deseleccionar si ya estaba seleccionado), tocar espacio vac\u00edo con un v\u00e9rtice seleccionado (mover), y tocar espacio vac\u00edo sin selecci\u00f3n (no hacer nada). Cualquier cambio en el pol\u00edgono dispara un rec\u00e1lculo completo de la rejilla de <em>waypoints<\/em>, igual que si se hubiera modificado un par\u00e1metro de vuelo.<\/p>\n\n\n\n<p>Dos <em>bugs<\/em> de compilaci\u00f3n aparecieron durante el desarrollo. El primero fue intentar usar las expresiones de estilo de MapLibre como m\u00e9todos de instancia \u2014<code>expr.switchCase(...)<\/code>\u2014 cuando en realidad son m\u00e9todos est\u00e1ticos de Java: <code>Expression.switchCase(...)<\/code>, <code>Expression.get(...)<\/code>. El error de compilaci\u00f3n era expl\u00edcito, pero la causa no era obvia sin conocer la API. El segundo fue m\u00e1s peculiar: una anotaci\u00f3n <code>@Composable<\/code> residual de una edici\u00f3n anterior se adhiri\u00f3 a una funci\u00f3n normal, y aunque se elimin\u00f3, el compilador de Compose segu\u00eda marcando el error en cualquier punto donde se llamara a esa funci\u00f3n desde un lambda. La soluci\u00f3n fue eliminar la funci\u00f3n e inlinear la l\u00f3gica directamente en el lambda, lo que tambi\u00e9n simplific\u00f3 el c\u00f3digo.<\/p>\n\n\n\n<p>La interacci\u00f3n actual \u2014seleccionar v\u00e9rtice con tap, luego tap en el destino para moverlo\u2014 funciona correctamente, pero es menos intuitiva que el drag directo. El <em>drag<\/em> directo ser\u00eda m\u00e1s natural, pero requiere distinguir entre el <em>pan<\/em> del mapa y el arrastre de un v\u00e9rtice, algo que MapLibre no resuelve de forma nativa y que a\u00f1adir\u00eda complejidad considerable. Est\u00e1 documentado como mejora para la Fase 8.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">El estado actual: siete fases completadas<\/h2>\n\n\n\n<p>Con la Fase 7 cerrada, el proyecto incluye siete fases completadas. La aplicaci\u00f3n cubre el ciclo completo de fotogrametr\u00eda con drones desde la planificaci\u00f3n hasta la visualizaci\u00f3n de resultados, con auditor\u00eda IA en tiempo real, gesti\u00f3n de m\u00faltiples bater\u00edas, reanudaci\u00f3n de misiones interrumpidas, cach\u00e9 <em>offline<\/em> para trabajo en campo sin cobertura, y ahora tambi\u00e9n edici\u00f3n de pol\u00edgono y estad\u00edsticas hist\u00f3ricas de vuelo.<\/p>\n\n\n\n<p>La Fase 8, planificada, a\u00f1adir\u00e1 soporte para otros modelos de drones DJI m\u00e1s all\u00e1 del Mini 3 Pro, integraci\u00f3n de condiciones meteorol\u00f3gicas, indicadores de cobertura GPS y descarga activa de <em>tiles<\/em> para zonas sin cobertura. Cuando llegue el momento, habr\u00e1 art\u00edculo.<\/p>\n","protected":false},"excerpt":{"rendered":"<div class=\"seriesmeta\">Esta entrada es la parte 10 de 10 de la serie <a href=\"https:\/\/bitacora.eniac2000.com\/?series=fotogrametria-asistida-por-ia\" class=\"series-1957\" title=\"Fotogrametr\u00eda asistida por IA\">Fotogrametr\u00eda asistida por IA<\/a><\/div><p>La Fase 6 cerr\u00f3 el ciclo principal de la aplicaci\u00f3n:<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"advanced_seo_description":"","jetpack_seo_html_title":"","jetpack_seo_noindex":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2}},"categories":[1845,13],"tags":[537,1970,1202],"series":[1957],"class_list":["post-11767","post","type-post","status-publish","format-standard","hentry","category-generado-con-ia","category-informatica","tag-dji-mini-3-pro","tag-maplibre","tag-open-drone-map","series-fotogrametria-asistida-por-ia"],"jetpack_publicize_connections":[],"jetpack_featured_media_url":"","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/bitacora.eniac2000.com\/index.php?rest_route=\/wp\/v2\/posts\/11767","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/bitacora.eniac2000.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/bitacora.eniac2000.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/bitacora.eniac2000.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/bitacora.eniac2000.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=11767"}],"version-history":[{"count":2,"href":"https:\/\/bitacora.eniac2000.com\/index.php?rest_route=\/wp\/v2\/posts\/11767\/revisions"}],"predecessor-version":[{"id":11770,"href":"https:\/\/bitacora.eniac2000.com\/index.php?rest_route=\/wp\/v2\/posts\/11767\/revisions\/11770"}],"wp:attachment":[{"href":"https:\/\/bitacora.eniac2000.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=11767"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/bitacora.eniac2000.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=11767"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/bitacora.eniac2000.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=11767"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/bitacora.eniac2000.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=11767"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}