⏱️ Lectura: 12 min
Eighty million six hundred ninety thousand seven hundred seventy-seven triangles: that’s the number of geometric comparisons an OSINT researcher ran on a GPU to pinpoint, without any image search tools, the exact island where a resort photo was taken. The challenge is called gralhix 004, published by researcher Sofia Santos under the alias Gralhix.
📑 En este artículo
The solution, documented on August 16, 2026, combines basic photogrammetry, geospatial filtering with OpenStreetMap data, and CUDA programming, without relying at any point on Google Lens or any image search engine.
TL;DR
- The gralhix 004 challenge asked participants to identify a resort on an island from a single photo with no EXIF or GPS metadata.
- The author ruled out Google Lens and solved the case with geometry, Python, and GPU CUDA programming.
- The original image is a 736×515 pixel WEBP verified with exiftool, with no camera data.
- The geospatial filter used the land-polygons-split-4326 dataset from OpenStreetMap, 882 MB in size.
- A tropical latitude filter (-30° to 30°) reduced the universe to 141,131 land polygons.
- A local density filter (maximum 10 neighbors within 5 km) brought it down to 51,576 candidates.
- Clustering with cKDTree (20 km radius) grouped 23,500 valid clusters with 3 or more islands.
- Generating triplets produced 80,690,777 candidate triangles, evaluated in parallel with a CUDA kernel.
Introduction
Most geolocation challenges (geoguessing, in OSINT jargon) are solved by looking at shadows, signs, or vegetation and then cross-checking that intuition with Google Lens or Bing Visual Search. The author of this writeup decided to treat the photo as a pure geometry problem and let a GPU do the heavy lifting of comparing millions of possible combinations.
The result blends basic computer vision, spatial data structures (SciPy’s cKDTree) and CUDA applied not to machine learning, but to geometric brute force over real cartographic data.
What Happened
The gralhix 004 challenge, created by Sofia Santos (Gralhix), featured a single aerial image, apparently taken by a drone, of a resort located on a small island (P0) with two other landmasses visible in the background: one to the right (P1) and another to the left with a mountain peak (P2). The three questions were straightforward: the resort’s name, the island’s coordinates, and the cardinal direction the camera was pointing.
The first thing the author checked was the metadata with exiftool. The file turned out to be a lossless WEBP at 736×515 pixels, with no EXIF, no GPS, and no camera brand: a blank starting point, typical of images that circulate on social media and lose their metadata when recompressed.
Context and History
Reconstructing a geometric fingerprint from a photo isn’t a new technique: surveyors and navigators used triangulation by bearings and relative distances long before GPS. What’s different about this case is that it applies that logic in reverse: instead of calculating a position from known fixed references, the author needed to find the fixed references (the three real islands) that matched the shape of the triangle observed in the photo.
Without a reliable perspective model (drone photo, unknown altitude, uncalibrated lens), it wasn’t possible to reconstruct an exact overhead view. The author opted for a deliberate simplification: estimate only the relative distances and angles between P0, P1, and P2 by visual intuition, using a custom GUI to click on each point and automatically calculate the triangle’s geometry. To compensate for the margin of error from a manual click, he applied a ±20% tolerance around each value.
Technical Details and Performance
The Filter Pipeline
Comparing a triangle against every island on the planet is unfeasible unless most candidates are ruled out beforehand. The author built a pipeline of four progressive filters, each one more computationally expensive than the last but applied to an increasingly smaller universe.
flowchart TD
A["Land polygons OSM (882 MB)"] --> B["Tropical latitude filter -30 to 30"]
B --> C["141131 polygons"]
C --> D["Local density filter (fewer than 10 neighbors within 5km)"]
D --> E["51576 candidates"]
E --> F["Clustering with cKDTree (20km radius, 3 or more points)"]
F --> G["23500 clusters"]
G --> H["Triplet generation (stratified sampling, cap 60)"]
H --> I["80690777 candidate triangles"]
I --> J["GPU matching with CUDA"]
J --> K["Candidates ranked by geometric similarity"]
The first filter is the cheapest: if the island in the background of the photo looks tropical, any landmass outside the -30° to 30° latitude band is ruled out before calculating any geometry at all. That single rule reduced the universe to 141,131 polygons.
The second filter measures local density: for each island centroid, it counts how many other centroids fall within a 5 km radius. If there are more than 10 neighbors that close, that island is probably part of a dense reef or an archipelago with dozens of islets, not the group of three isolated landmasses shown in the photo. The filter left 51,576 candidates.
The third step groups the remaining points into clusters: if a point has at least two other neighbors within 20 km, there’s enough nearby landmass to form a real triangle. The author solved this search with a SciPy cKDTree and query_ball_point, the standard way to search for nearby neighbors without comparing every point against all the others. The result: 23,500 valid clusters.
From Clusters to 80 Million Triangles
Each cluster with n points generates C(n,3) possible triangles, a combinatorial count that grows fast: a cluster of just 60 islands already produces 34,220 combinations. To keep a giant cluster from dominating the computation, the author capped each cluster at 60 points using stratified sampling (one third of the smallest islands, one third of the largest, one third from the middle of the area distribution), instead of a random cutoff.
Even with that limit, the 23,500 clusters generated 80,690,777 candidate triangles. Comparing them one by one on CPU, even with vectorized NumPy, would have meant minutes or hours of sequential computation. The solution was to assign a CUDA thread to each triangle: each thread sorts its three points by land area (the smallest is the candidate for P0, the resort’s islet), determines P1 and P2 using the sign of the cross product, and calculates the angle at P0 and the distance ratio, the same metrics the author had extracted by hand from the original photo.
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}")
This script reproduces at small scale what the click GUI does: it turns three on-screen coordinates into an angle and a distance ratio, the geometric fingerprint that’s later searched against the world map.
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
Each thread runs that block independently, with no conditional branches depending on which cluster the triangle belongs to: only the sign of the cross product. It’s the same trick used to determine whether three points turn clockwise or counterclockwise, applied here to deterministically fix which background islet is P1 and which is P2. The expected result is a resultados array with a 1 in every position whose triangle falls within tolerance.
| Approach | When to Use It | Advantage | Limitation |
|---|---|---|---|
| Vectorized NumPy on CPU | Fewer than one million comparisons | No hardware dependencies, easy to debug | Scales poorly past millions of triangles |
| CUDA with Numba on GPU | Tens or hundreds of millions of independent comparisons | One thread per triangle, massive parallelism without writing C++ | Requires an NVIDIA GPU and the CUDA Toolkit installed |
| Native C++ CUDA | When the Numba kernel is already the bottleneck | Maximum control over memory and performance | Steeper learning curve, slower development cycle |
⚠️ Heads up: if numba.cuda.detect() doesn’t list any GPU, the kernel runs in CPU simulation mode without throwing any error, and a computation of 80 million triangles that should take minutes can end up running for hours without you noticing.
How to Try It
The full pipeline relies on free tools: Python, SciPy for cKDTree, Numba to compile CUDA kernels without writing C++, and OpenStreetMap’s public land polygon dataset. Here’s how to set up the environment on the three main operating systems.
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)
# requires Python 3.10+ installed from python.org
winget install ExifTool
pip install numpy scipy numba
For the geospatial filter you need the land-polygons-split-4326 dataset, published at osmdata.openstreetmap.de: 882 MB of global coastline vectors in WGS84, the same coordinate system GPS uses. The initial load with geopandas.read_file() is usually the slowest step in the whole pipeline, so it helps to cache the centroids and areas in a separate file before running the filters repeatedly.
The GPU part also requires the NVIDIA CUDA Toolkit, which Numba detects automatically if installed. Before launching any kernel over millions of triangles, it helps to confirm with numba.cuda.detect() that the card shows up in the list.
Impact and Analysis
What’s valuable about this writeup isn’t so much the final result (identifying one specific resort), but the demonstration that a typical image recognition problem can be solved without neural networks or third-party services: with high school geometry, a standard spatial data structure, and GPU parallelism applied to brute force. It’s a reproducible, auditable alternative to black-box tools like Google Lens, where there’s no way to know why the system suggests a given location.
💭 Key takeaway: the same pattern (extracting a simple geometric fingerprint and searching for it via parallel brute force against a public dataset) works for any matching problem where there isn’t enough information to invert the geometry analytically.
For the OSINT and CTF community, the case leaves behind a reusable pattern: when metadata gives nothing away, as happened here with the WEBP lacking EXIF or GPS, it pays to ask what other geometric or topological structure exists in the image (coastlines, shadows, vegetation patterns) that could be compared at scale against an open database, instead of relying on generic visual recognition.
What’s Next
The author published the complete code, including the click GUI, the filtering scripts, and the CUDA kernel, along with the challenge’s final report, in his GitHub repository. The natural next step for anyone wanting to reproduce the method is adapting it to other metadata-free geolocation problems: mountain photos using the horizon profile, or urban photos using the relative arrangement of tall buildings, following the same scheme of geometric fingerprinting and parallel brute-force search.
📖 Summary on Telegram: View summary
Try it yourself: download the land-polygons-split-4326 dataset and run the tropical latitude filter on your own test coordinates to see in minutes how much it shrinks the search universe.
Frequently Asked Questions
What exactly is the gralhix 004 challenge?
It’s an OSINT challenge created by Sofia Santos (Gralhix) that asks participants to identify, from a single photo with no metadata, a resort’s name on an island, its coordinates, and the direction the camera was pointing.
Why didn’t the author use Google Lens?
Because he considered solving it with his own math and code more interesting and educational than relying on a black-box image search engine.
What is the land-polygons-split-4326 dataset?
It’s a set of coastline vectors for the entire planet in WGS84 format, published by OpenStreetMap Data, 882 MB in size, which serves as the search universe for comparing the geometry of real islands.
Why use CUDA instead of just the CPU?
Because the pipeline generated 80,690,777 candidate triangles: processing them one by one on CPU would be too slow, while assigning a GPU thread to each triangle allows evaluating all of them in parallel.
What is cKDTree and how was it used here?
It’s a SciPy spatial data structure that allows searching for nearby neighbors without comparing each point against all the others; the author used it to group islands into clusters within a 20 km radius.
Can this method be applied to other geolocation problems?
Yes: any problem where a simple geometric fingerprint can be extracted from an image (angles, distance ratios, patterns) and compared against an open geospatial dataset is a candidate for the same approach of progressive filtering and GPU brute-force matching.
References
- yassa9.github.io: original writeup of the gralhix 004 challenge, with the full code and report.
- osmdata.openstreetmap.de: official page for the land-polygons-split-4326 dataset used for geospatial filtering.
- docs.scipy.org: documentation for cKDTree, the spatial data structure used for clustering.
- numba.readthedocs.io: official Numba documentation for programming CUDA kernels from Python.
- developer.nvidia.com: official page for the NVIDIA CUDA Toolkit.
📱 Enjoying this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.
Imagen destacada: Foto de Gilly Tanabose en Unsplash
0 Comments