⏱️ Lectura: 14 min
Ochenta millones seiscientos noventa mil setecientos setenta y siete triángulos: ese es el número de comparaciones geométricas que un investigador de OSINT ejecutó en una GPU para ubicar, sin herramientas de búsqueda por imagen, la isla exacta donde se tomó la foto de un resort. El reto se llama gralhix 004 y lo publicó la investigadora Sofia Santos bajo el alias Gralhix.
📑 En este artículo
La solución, documentada el 16 de agosto de 2026, combina fotogrametría básica, filtrado geoespacial con datos de OpenStreetMap y programación CUDA, sin depender en ningún momento de Google Lens ni de ningún motor de búsqueda por imagen.
TL;DR
- El reto gralhix 004 pedía identificar un resort en una isla a partir de una sola foto sin metadatos EXIF ni GPS.
- El autor descartó Google Lens y resolvió el caso con geometría, Python y programación CUDA en GPU.
- La imagen original es un WEBP de 736×515 píxeles verificado con exiftool, sin datos de cámara.
- El filtro geoespacial usó el dataset land-polygons-split-4326 de OpenStreetMap, de 882 MB.
- Un filtro de latitud tropical (-30° a 30°) redujo el universo a 141.131 polígonos de tierra.
- Un filtro de densidad local (máximo 10 vecinos en 5 km) lo bajó a 51.576 candidatos.
- El clustering con cKDTree (radio de 20 km) agrupó 23.500 clústeres válidos con 3 o más islas.
- La generación de tríos produjo 80.690.777 triángulos candidatos, evaluados en paralelo con un kernel CUDA.
Introducción
La mayoría de los retos de geolocalización (geoguessing, en la jerga OSINT) se resuelven mirando sombras, carteles o vegetación y después cruzando esa intuición con Google Lens o Bing Visual Search. El autor de este writeup decidió tratar la foto como un problema de geometría pura y dejar que una GPU hiciera el trabajo pesado de comparar millones de combinaciones posibles.
El resultado mezcla visión por computadora básica, estructuras de datos espaciales (cKDTree de SciPy) y CUDA aplicado no a machine learning, sino a fuerza bruta geométrica sobre datos cartográficos reales.
Qué pasó
El desafío gralhix 004, creado por Sofia Santos (Gralhix), presentaba una sola imagen aérea, tomada aparentemente por un dron, de un resort ubicado en un islote (P0) con otras dos masas de tierra visibles al fondo: una a la derecha (P1) y otra a la izquierda con un pico montañoso (P2). Las tres preguntas eran directas: nombre del resort, coordenadas de la isla y dirección cardinal hacia la que apuntaba la cámara.
Lo primero que revisó el autor fue la metadata con exiftool. El archivo resultó ser un WEBP en modo lossless de 736×515 píxeles, sin EXIF, sin GPS y sin marca de cámara: un punto de partida en blanco, típico de imágenes que circulan en redes sociales y pierden sus metadatos al recomprimirse.
Contexto e historia
Reconstruir una huella geométrica a partir de una foto no es una técnica nueva: geodetas y navegantes usaban triangulación por rumbos y distancias relativas mucho antes del GPS. Lo distinto de este caso es que aplica esa lógica al revés: en vez de calcular una posición conociendo referencias fijas, el autor necesitaba encontrar las referencias fijas (las tres islas reales) que coincidieran con la forma del triángulo observado en la foto.
Sin un modelo de perspectiva confiable (foto de dron, sin altura conocida, sin lente calibrado), no era posible reconstruir una vista cenital exacta. El autor optó por una simplificación deliberada: estimar solo las distancias relativas y los ángulos entre P0, P1 y P2 por intuición visual, con una GUI propia para hacer clic sobre cada punto y calcular la geometría del triángulo automáticamente. Para compensar el margen de error de un clic manual, aplicó una tolerancia de ±20% alrededor de cada valor.
Detalles técnicos y rendimiento
El pipeline de filtros
Comparar un triángulo contra todas las islas del planeta es inviable si no se descarta antes a la mayoría de los candidatos. El autor construyó un pipeline de cuatro filtros progresivos, cada uno más caro computacionalmente que el anterior pero aplicado a un universo cada vez más chico.
flowchart TD
A["Land polygons OSM (882 MB)"] --> B["Filtro de latitud tropical -30 a 30"]
B --> C["141131 poligonos"]
C --> D["Filtro de densidad local (menos de 10 vecinos en 5km)"]
D --> E["51576 candidatos"]
E --> F["Clustering con cKDTree (radio 20km, 3 o mas puntos)"]
F --> G["23500 clusters"]
G --> H["Generacion de trios (muestreo estratificado, cap 60)"]
H --> I["80690777 triangulos candidatos"]
I --> J["Matching en GPU con CUDA"]
J --> K["Candidatos rankeados por similitud geometrica"]
El primer filtro es el más barato: si la isla del fondo de la foto se ve tropical, cualquier masa de tierra fuera de la franja de -30° a 30° de latitud queda descartada antes de calcular nada de geometría. Esa sola regla redujo el universo a 141.131 polígonos.
El segundo filtro mide densidad local: para cada centroide de isla cuenta cuántos otros centroides caen dentro de un radio de 5 km. Si hay más de 10 vecinos tan cerca, esa isla probablemente forma parte de un arrecife denso o un archipiélago con decenas de islotes, no del grupo de tres masas de tierra aisladas que muestra la foto. El filtro dejó 51.576 candidatos.
El tercer paso agrupa los puntos restantes en clústeres: si un punto tiene al menos otros dos vecinos dentro de 20 km, hay suficiente masa de tierra cerca como para formar un triángulo real. El autor resolvió esta búsqueda con un cKDTree de SciPy y query_ball_point, la forma estándar de buscar vecinos cercanos sin comparar cada punto contra todos los demás. El resultado: 23.500 clústeres válidos.
De clústeres a 80 millones de triángulos
Cada clúster con n puntos genera C(n,3) triángulos posibles, una combinatoria que crece rápido: un clúster de solo 60 islas ya produce 34.220 combinaciones. Para evitar que un clúster gigante domine el cómputo, el autor limitó cada clúster a 60 puntos con un muestreo estratificado (un tercio de las islas más chicas, un tercio de las más grandes, un tercio del medio de la distribución de área), en vez de un corte aleatorio.
Aun con ese límite, los 23.500 clústeres generaron 80.690.777 triángulos candidatos. Compararlos uno por uno en CPU, incluso con NumPy vectorizado, habría significado minutos u horas de cómputo secuencial. La solución fue asignarle un hilo de CUDA a cada triángulo: cada hilo ordena sus tres puntos por área de tierra (el más chico es candidato a P0, el islote del resort), determina P1 y P2 con el signo del producto cruzado, y calcula el ángulo en P0 y la razón de distancias, las mismas métricas que el autor había extraído a mano de la foto original.
import numpy as np
def triangulo_desde_clicks(p0, p1, p2):
d1 = np.linalg.norm(np.array(p1) - np.array(p0))
d2 = np.linalg.norm(np.array(p2) - np.array(p0))
v1 = np.array(p1) - np.array(p0)
v2 = np.array(p2) - np.array(p0)
coseno = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
angulo_p0 = np.degrees(np.arccos(coseno))
razon_distancias = d1 / d2
return angulo_p0, razon_distancias
angulo, razon = triangulo_desde_clicks((120, 340), (610, 210), (95, 60))
print(f"angulo_p0={angulo:.2f} razon_distancias={razon:.3f}")
Este script reproduce a pequeña escala lo que hace la GUI de clics: convierte tres coordenadas en pantalla en un ángulo y una razón de distancias, la huella geométrica que después se busca contra el mapa mundial.
from numba import cuda
import math
@cuda.jit
def match_triangulos(lats, lons, areas, triples, razon_objetivo, tolerancia, resultados):
i = cuda.grid(1)
if i >= triples.shape[0]:
return
a, b, c = triples[i][0], triples[i][1], triples[i][2]
idx = [a, b, c]
for x in range(1, 3):
key = idx[x]
j = x - 1
while j >= 0 and areas[idx[j]] > areas[key]:
idx[j + 1] = idx[j]
j -= 1
idx[j + 1] = key
p0, pa, pb = idx[0], idx[1], idx[2]
ax, ay = lons[pa] - lons[p0], lats[pa] - lats[p0]
bx, by = lons[pb] - lons[p0], lats[pb] - lats[p0]
cruz = ax * by - bx * ay
p1, p2 = (pa, pb) if cruz > 0 else (pb, pa)
d1 = math.sqrt((lons[p1] - lons[p0]) ** 2 + (lats[p1] - lats[p0]) ** 2)
d2 = math.sqrt((lons[p2] - lons[p0]) ** 2 + (lats[p2] - lats[p0]) ** 2)
razon = d1 / d2
if abs(razon - razon_objetivo) <= tolerancia:
resultados[i] = 1
Cada hilo ejecuta ese bloque de forma independiente, sin ramas condicionales que dependan de a qué clúster pertenece el triángulo: solo el signo del producto cruzado. Es el mismo truco que se usa para saber si tres puntos giran en sentido horario o antihorario, aplicado acá para fijar de forma determinista cuál islote de fondo es P1 y cuál es P2. El resultado esperado es un arreglo resultados con un 1 en cada posición cuyo triángulo cae dentro de la tolerancia.
| Enfoque | Cuándo usarlo | Ventaja | Limitación |
|---|---|---|---|
| NumPy vectorizado en CPU | Menos de un millón de comparaciones | Sin dependencias de hardware, fácil de depurar | Escala mal pasados los millones de triángulos |
| CUDA con Numba en GPU | Decenas o cientos de millones de comparaciones independientes | Un hilo por triángulo, paralelismo masivo sin escribir C++ | Requiere GPU NVIDIA y CUDA Toolkit instalado |
| CUDA en C++ nativo | Cuando el kernel de Numba ya es el cuello de botella | Máximo control de memoria y rendimiento | Curva de aprendizaje más alta, ciclo de desarrollo más lento |
⚠️ Ojo: si numba.cuda.detect() no lista ninguna GPU, el kernel se ejecuta en modo simulado por CPU sin lanzar ningún error, y un cómputo de 80 millones de triángulos que debería tardar minutos puede terminar corriendo horas sin que lo notes.
Cómo probarlo
El pipeline completo se apoya en herramientas libres: Python, SciPy para el cKDTree, Numba para compilar kernels CUDA sin escribir C++, y el dataset público de polígonos de tierra de OpenStreetMap. Así se arma el entorno en los tres sistemas operativos principales.
Linux
# Debian/Ubuntu
sudo apt install exiftool python3-pip
pip install numpy scipy numba
# Arch/Void
sudo pacman -S perl-image-exiftool python-pip
pip install numpy scipy numba
macOS
brew install exiftool
pip3 install numpy scipy numba
Windows (PowerShell)
# requiere Python 3.10+ instalado desde python.org
winget install ExifTool
pip install numpy scipy numba
Para el filtro geoespacial hace falta el dataset land-polygons-split-4326, publicado en osmdata.openstreetmap.de: son 882 MB de vectores de costa global en WGS84, el mismo sistema de coordenadas que usa GPS. La carga inicial con geopandas.read_file() suele ser el paso más lento del pipeline completo; conviene cachear los centroides y áreas en un archivo aparte antes de correr los filtros repetidamente.
Para la parte de GPU hace falta además el CUDA Toolkit de NVIDIA, que Numba detecta automáticamente si está instalado. Antes de lanzar cualquier kernel sobre millones de triángulos conviene confirmar con numba.cuda.detect() que la tarjeta aparece listada.
Impacto y análisis
Lo valioso de este writeup no es tanto el resultado final (identificar un resort puntual), sino la demostración de que un problema típico de reconocimiento de imagen se puede resolver sin redes neuronales ni servicios de terceros: con geometría de secundaria, una estructura de datos espacial estándar y paralelismo de GPU aplicado a fuerza bruta. Es una alternativa reproducible y auditable frente a herramientas de caja negra como Google Lens, donde no hay forma de saber por qué el sistema sugiere una ubicación.
💭 Clave: el mismo patrón (extraer una huella geométrica simple y buscarla por fuerza bruta paralela contra un dataset público) sirve para cualquier problema de matching donde no alcanza la información para invertir la geometría de forma analítica.
Para la comunidad de OSINT y CTF, el caso deja un patrón reutilizable: cuando la metadata no aporta nada, como pasó acá con el WEBP sin EXIF ni GPS, conviene preguntarse qué otra estructura geométrica o topológica hay en la imagen (líneas de costa, sombras, patrones de vegetación) que se pueda comparar de forma masiva contra una base de datos abierta, en vez de depender de reconocimiento visual genérico.
Qué sigue
El autor publicó el código completo, incluida la GUI de clics, los scripts de filtrado y el kernel CUDA, junto con el reporte final del reto, en su repositorio de GitHub. El siguiente paso natural para quien quiera reproducir el método es adaptarlo a otros problemas de geolocalización sin metadatos: fotos de montañas usando el perfil del horizonte, o fotos urbanas usando la disposición relativa de edificios altos, siguiendo el mismo esquema de huella geométrica y búsqueda paralela por fuerza bruta.
📖 Resumen en Telegram: Ver resumen
Probalo vos: descargá el dataset land-polygons-split-4326 y corré el filtro de latitud tropical sobre tus propias coordenadas de prueba para ver en minutos cuánto reduce el universo de búsqueda.
Preguntas frecuentes
¿Qué es exactamente el reto gralhix 004?
Es un desafío de OSINT creado por Sofia Santos (Gralhix) que pide identificar, a partir de una sola foto sin metadatos, el nombre de un resort en una isla, sus coordenadas y la dirección hacia la que apuntaba la cámara.
¿Por qué el autor no usó Google Lens?
Porque consideró que resolverlo con matemática y programación propia era más interesante y educativo que depender de un motor de búsqueda por imagen de caja negra.
¿Qué es el dataset land-polygons-split-4326?
Es un conjunto de vectores de costa de todo el planeta en formato WGS84, publicado por OpenStreetMap Data, de 882 MB, que sirve como universo de búsqueda para comparar la geometría de islas reales.
¿Por qué usar CUDA en vez de solo CPU?
Porque el pipeline generó 80.690.777 triángulos candidatos: procesarlos uno por uno en CPU sería demasiado lento, mientras que asignarle un hilo de GPU a cada triángulo permite evaluarlos todos en paralelo.
¿Qué es cKDTree y para qué se usó acá?
Es una estructura de datos espacial de SciPy que permite buscar vecinos cercanos sin comparar cada punto contra todos los demás; el autor la usó para agrupar islas en clústeres dentro de un radio de 20 km.
¿Se puede aplicar este método a otros problemas de geolocalización?
Sí: cualquier problema donde se pueda extraer una huella geométrica simple de una imagen (ángulos, razones de distancia, patrones) y compararla contra un dataset geoespacial abierto es candidato al mismo enfoque de filtrado progresivo y matching por fuerza bruta en GPU.
Referencias
- yassa9.github.io: writeup original del reto gralhix 004, con el código y el reporte completo.
- osmdata.openstreetmap.de: página oficial del dataset land-polygons-split-4326 usado para el filtrado geoespacial.
- docs.scipy.org: documentación de cKDTree, la estructura de datos espacial usada para el clustering.
- numba.readthedocs.io: documentación oficial de Numba para programar kernels CUDA desde Python.
- developer.nvidia.com: página oficial del CUDA Toolkit de NVIDIA.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
Imagen destacada: Foto de Gilly Tanabose en Unsplash
0 Comentarios