16-08-2026
Автор материала подчёркивает: работа выполнена вручную, без использования LLM.
Разбор посвящён решению OSINT-задачи gralhix 004, подготовленной Sofia Santos | Gralhix.
Все файлы кода и итоговый отчёт с инструкциями доступны на GitHub.
Условие задачи

Дано фото курорта, расположенного на острове.
a) Как называется курорт?
b) Каковы координаты острова?
c) В каком направлении была направлена камера в момент съёмки?
Решать задачу через Google Lens означало бы упустить интересную возможность, поэтому был выбран путь через математику и программирование.
a] Метаданные
Первым делом проверены метаданные файла — команда запущена в Linux Void:
> exiftool main.png
File Type : WEBP (lossless)
MIME Type : image/webp
Image Width : 736
Image Height : 515
Как и ожидалось, ничего полезного: ни EXIF, ни GPS, ни модели камеры.
b] Построение "отпечатка"

На изображении видно три массива суши:
- P0: сам островок-курорт,
- P1: остров справа,
- P2: остров слева на переднем плане (с горной вершиной)
Построить корректную перспективную модель вида сверху не удалось — снимок явно сделан с дрона, и оценить высоту съёмки не получалось (в метаданных она тоже отсутствовала).
Поэтому расстояния и углы треугольника между тремя островами пришлось оценивать на глаз.

Для этого написан небольшой GUI 01_triangle_gui.py, который по клику записывает пиксельные координаты каждой точки по порядку и вычисляет геометрию треугольника.
Поскольку клик точно по центру объекта на глаз не идеален, к обоим значениям добавлен допуск ±20% при последующем поиске.
c] Поиск
Когда "отпечаток" зафиксирован, следующий шаг — сверить его с каждым реальным массивом суши на Земле.
В качестве набора данных использован
раздельный набор земельных полигонов OpenStreetMap— land-polygons-split-4326, полный глобальный вектор береговой линии в WGS84 объёмом882 МБ.
Эвристические фильтры (основанные исключительно на интуиции, без строгих доказательств) подбирались несколько дней методом проб и ошибок, пока не сложился рабочий набор правил.
01] Тропический пояс широт
$$ -30° \le latitude \le 30° $$
Остров на фото выглядит тропическим, поэтому всё, что находится вне тропического пояса, отбрасывается сразу, ещё до дорогостоящих геометрических вычислений.
Через этот фильтр проходят ровно
141 131полигон суши.
02] Фильтр локальной плотности
$$ N_{5\text{km}}(p) \le 10 $$
$ N_{5\text{km}}(p) $ считает, сколько других центроидов попадает в радиус 5 км от точки (p). Порог — 10: если у островка больше 10 таких близких соседей, значит он расположен в плотном рифовом поле, на перегруженной береговой линии или в архипелаге с обилием мелких островов — а не в небольшой изолированной группе из 3-4 островов, как на фото.
Число кандидатов сократилось до
51 576.
03] Кластеризация
Для каждой оставшейся точки ищутся все остальные точки в радиусе 20 км (значение подобрано эвристически, на глаз по фотографии). Если у точки есть минимум 2 соседа на таком расстоянии (всего 3 точки), это кластер. Точки без кластера из 3+ соседей отбрасываются — они вообще не могут образовать треугольник.
tree = cKDTree(f_coords)
neigh = tree.query_ball_point(
f_coords,
CLUSTER_RADIUS_KM / 111.0)
clusters = set(tuple(sorted(n)) for n in neigh if len(n) >= 3)
$$ \left|\{q : \text{dist}(p,q) \le 20\,\text{km}\}\right| \ge 3 $$
Итог —
23 500кластеров.
04] Генерация троек
Для каждого кластера каждая комбинация из 3 точек внутри него становится треугольником-кандидатом. Это $ C(n, 3) $, число которых быстро растёт для больших кластеров: например, кластер из 60 точек сам по себе даёт 34 220 троек. Поэтому каждый кластер сначала ограничивается 60 точками, отобранными по размеру, а не случайно.
$$ \binom{n}{3} = \frac{n(n-1)(n-2)}{6} $$
def stratified_sample(idx_arr, area_arr, cap):
order = np.argsort(area_arr[idx_arr])
n_small = cap // 3
n_large = cap // 3
n_mid = cap - n_small - n_large
mid_start = max(0, (len(idx_arr) - n_large - n_mid) // 2)
keep = np.unique(np.concatenate([
order[:n_small],
order[-n_large:],
order[mid_start:mid_start + n_mid],
]))
return idx_arr[keep]
def gen_cluster_triples(idx_arr):
local = np.array(list(
itertools.combinations(range(len(idx_arr)), 3)),
dtype=np.int64)
return idx_arr[local]
Выборка берёт треть маленьких островов, треть крупных и треть из середины распределения по размеру, вместо использования полного кластера или случайного среза.
23 500кластеров дают в сумме80 690 777троек!
05] Сопоставление на GPU
Каждой тройке выделен один поток CUDA. Каждый поток сортирует свои 3 точки по площади суши, чтобы выделить P0 (наименьшую — островок-курорт), а затем по направлению обхода двух остальных точек определяет P1 и P2:
long long i = blockIdx.x * (long long)blockDim.x + threadIdx.x;
if (i >= n_triples) return;
int pos[3] = {0, 1, 2};
for (int a1 = 1; a1 < 3; a1++)
{
int key = pos[a1];
double keyval = a[key];
int j = a1 - 1;
while (j >= 0 && a[pos[j]] > keyval)
{
pos[j + 1] = pos[j];
j--;
}
pos[j + 1] = key;
}
Выбор между P1 и P2 делается через векторное произведение в 2D, без ветвления по тому, из какого кластера пришла тройка — только по знаку:
$$ \text{cross} = x_a y_b - x_b y_a $$ $$ P1 = \begin{cases} a & \text{cross} > 0 \\ b & \text{cross} \le 0 \end{cases} $$
Проход от P0 к a, затем к b. Если cross > 0 — это поворот влево (против часовой стрелки), если cross < 0 — поворот вправо (по часовой). Тот же приём со знаком, что используется для определения направления изгиба трёх точек.
Далее вычисляются угол при P0 и отношение расстояний — по тем же формулам, что и на этапе построения "отпечатка", независимо каждым потоком:
$$ \theta_0 = \arccos\left(\frac{\vec{d_1} \cdot \vec{d_2}}{|\vec{d_1}||\vec{d_2}|}\right), \qquad r = \frac{|\vec{d_1}|}{|\vec{d_2}|} $$
Тройка проходит проверку, если угол, отношение расстояний, размер P0, расстояние между P0 и P1 и обе длины сторон укладываются в допуски "отпечатка". Прошедшие потоки записывают результат в общий выходной массив через атомарный счётчик, чтобы два потока, завершившиеся одновременно, не перезаписали друг друга:
if (hit)
{
unsigned long long slot = atomicAdd(out_count, 1ULL);
out_p0[slot] = p0idx;
out_p1[slot] = p1idx;
out_p2[slot] = p2idx;
}
Результат работы ядра выводится прямо в консоль:
gpu: NVIDIA GeForce RTX 3050 (sm_86)
vram used: 5169 MB
kernel time: 204.1 ms
80,7 миллионатроек обрабатываются параллельно, по одному потоку на каждую.158 784проходят фильтр.
06] Дедупликация
Поскольку одна и та же физическая тройка может засветиться в нескольких потоках GPU, если она принадлежит более чем одному пересекающемуся кластеру, сырые совпадения сначала схлопываются по идентичности:
seen = set()
uniq = []
for i in range(len(p0_all)):
key = (p0_all[i], p1_all[i], p2_all[i])
if key not in seen:
seen.add(key)
uniq.append(i)
После дедупликации остаётся
8 915уникальных троек.
07] Проверка на открытый прямоугольник

Каждая оставшаяся тройка проходит ещё одну проверку: действительно ли пространство рядом с ней — открытая вода, как показано на фото? Вдоль ребра P0→P1 строится прямоугольник, с той стороны, где не находится P2, и он проверяется на предмет наличия внутри чего-либо ещё из данных суши.
width = np.hypot(x1, y1)
u = np.array([x1, y1]) / width
v = np.array([-u[1], u[0]])
# p2 sits on the +v side by construction,
# so the check goes on -v
length = 2 * width
corners_local = [
(0, 0), (x1, y1),
(x1 - v[0]*length, y1 - v[1]*length),
(-v[0]*length, -v[1]*length),
]
Если что-либо, кроме трёх островов-кандидатов, пересекает этот прямоугольник, кандидат отбрасывается. Наличие там суши означает, что это не та открытая, ничем не загороженная вода, что видна на фото.
Из
8 915уникальных троек остаётся948.
Ниже — карта расположения этих 948 кандидатов.

d] Проверка формы кораллового острова
На этом этапе рассматривается только P0 — островок-курорт, и проверяется, действительно ли его форма похожа на коралловый остров.
1] Компактность — насколько форма близка к кругу:
Индекс Полсби — Поппера:
$$ PP = \frac{4\pi \cdot \text{area}}{\text{perimeter}^2} $$
def compactness(row):
return (4 * np.pi * row.area_km2) / (row.perim_km ** 2 + 1e-12)

1.0 соответствует идеальному кругу, чем ниже значение — тем более неровный или вытянутый контур. Коралловые острова обычно округлые за счёт волнового намыва, поэтому всё, что < 0.5, отбрасывается.
2] Проверка ореола из мелких островков
def micro_cay_count(gdf, sindex, lon, lat):
dists_km = nearby.geometry.distance(pt) * 111.0
mask = (dists_km > 0)
& (dists_km <= HALO_KM)
& (nearby["area_km2"].values < MICRO_KM2)
return int(mask.sum())
Подсчитывались фрагменты суши площадью менее 0,05 км² в радиусе 1,5 км от P0 (эвристика). Настоящие рифовые системы разбрасывают вокруг основного острова мелкие песчаные отмели, а не представляют собой единственный изолированный массив суши (это стало понятно на собственном опыте). Поэтому требуется как минимум 1 такой фрагмент.
Обе проверки проходят
213 из 948кандидатов.
e] Проверка овальной формы
Ещё один геометрический фильтр — по собственному полигону P0. Вокруг него строится минимальный повёрнутый прямоугольник, и по нему вычисляются два соотношения.
def aspect_and_fill(geom):
mrr = geom.minimum_rotated_rectangle
coords = list(mrr.exterior.coords)
s1 = math.hypot(coords[1][0] - coords[0][0],
coords[1][1] - coords[0][1])
s2 = math.hypot(coords[2][0] - coords[1][0],
coords[2][1] - coords[1][1])
long_side, short_side = max(s1, s2), min(s1, s2)
return long_side / short_side, geom.area / mrr.area
Соотношение сторон — длинная сторона к короткой стороне этого прямоугольника:
$$ \text{aspect} = \frac{\text{long side}}{\text{short side}} \in [1.05,\ 2.2] $$
Слишком близко к 1.0 — это практически идеальный круг, не совпадающий с чуть вытянутой формой на фото. Слишком высокое значение — форма вытянута сильнее, чем 2:1.
Коэффициент заполнения — какую часть ограничивающего прямоугольника занимает сама форма, и здесь есть точное тождество: любой эллипс заполняет ровно $ \pi / 4 $ площади своего минимального ограничивающего прямоугольника, независимо от степени вытянутости.
$$ \frac{\text{area}_{\text{ellipse}}}{\text{area}_{\text{box}}} = \frac{\pi}{4} \approx 0.785 $$
Это теоретический потолок для идеально гладкого овала. Реальные коралловые острова не являются идеальными эллипсами, поэтому порог отсечения задан как эвристическая безопасная доля от этого потолка:
$$ \text{FILL\_RATIO\_MIN} = 0.75 \times \frac{\pi}{4} \approx 0.589 $$
Форма должна сохранять как минимум 75% заполнения идеального эллипса, чтобы пройти проверку. Полумесяцы, кольца и изрезанные береговые линии оказываются значительно ниже этого порога, а сплошные округлые островки — нет.
Проверку проходят
137 из 213кандидатов.
f] Проверка растительности по NDVI
Финальный этап связан с обращением к API, и он поставлен последним, поскольку упирается в скорость сети, а не вычислений.
Используется
Earth Search от Element84— публичный STAC API, индексирующий снимки Sentinel-2, размещённые в рамках программы открытых данных AWS, бесплатно и без ключа API.
Ознакомиться с ним можно по адресу https://earth-search.aws.element84.com/v1
Проверяется, действительно ли P0 покрыт растительностью — пальмами, а не голым песком или скалой. Из публичного каталога STAC берётся самый свежий снимок Sentinel-2 с низкой облачностью над этой точкой, и по нему считываются значения красного и ближнего инфракрасного каналов в точной пиксельной позиции.
$$ \text{NDVI} = \frac{\text{NIR} - \text{Red}}{\text{NIR} + \text{Red}} $$
Живая растительность сильно отражает в ближнем инфракрасном диапазоне и поглощает красный свет, поэтому здоровый пальмовый покров даёт NDVI значительно выше 0, а голый песок или открытая вода — около 0 или ниже.

Иллюстрация взята из блога Geoawesome.
Порог установлен на уровне 0.6 — достаточно высоко, чтобы требовать реального древесного покрова, а не отдельных разрозненных объектов.
Проверку по NDVI проходят
66 из 137.
g] Проверка рельефа и наличия гор

Последняя проверка перед итоговым результатом. Здесь два условия:
- сам P0 должен быть низким и плоским, что соответствует небольшому рифовому островку,
- у P2 должен быть реальный возвышенный рельеф в том направлении, куда была направлена камера.
"Фронт" снимка — биссектриса между азимутом на P1 и азимутом на P2:
$$ \theta(P_0, P_i) = $$ $$ \text{atan2}\Big(\sin(\Delta\lambda)\cos\phi_i,\ \cos\phi_0\sin\phi_i - \sin\phi_0\cos\phi_i\cos(\Delta\lambda)\Big) $$
$$ \theta_{\text{front}} = $$ $$ \theta(P_0, P_2) + \frac{\big((\theta(P_0,P_1) - \theta(P_0,P_2) + 180) \bmod 360\big) - 180}{2} $$
Это даёт единый азимут — направление, куда был направлен объектив. От него веером разворачиваются точки выборки в диапазоне ±50° вокруг этого азимута, на удалении от 2 до 20 км:
$$ (\text{lat}, \text{lon}) = \Big(\text{lat}_0 + \frac{r\cos\theta}{111},\ \ \text{lon}_0 + \frac{r\sin\theta}{111\cos(\text{lat}_0)}\Big) $$
Каждая из этих точек сверяется с реальными тайлами 30-метрового Copernicus DEM.
Copernicus DEM GLO-30, публикуемый программой Copernicus Евросоюза, размещён в виде бесплатных облачно-оптимизированных GeoTIFF в рамках AWS Open Data, без учётной записи и ключа.
Подробнее — https://registry.opendata.aws/copernicus-dem/
В итоге выживание кандидата определяют два простых эвристических условия (да, к этому моменту всё уже стало эвристикой):
$$ \text{elev}(P_0) \le 50\text{m} $$ $$ 100\text{m} \le \max_{\text{arc}}(\text{elev}) \le 500\text{m} $$

На этой схематичной иллюстрации пунктирная линия — азимут "фронта" камеры, а сектор — веер поиска ±50°, развёрнутый до 20 км для проверки рельефа.
Проверку по рельефу проходят
26 из 66.
Все 26 оставшихся кандидатов расположены в Южной Азии, Австралии и Океании, кроме одного — рядом с Бразилией!

h] Итоговый отчёт
На последнем этапе финальные кандидаты приводятся в вид, удобный для проверки глазами. Каждому выжившему кандидату присваивается название страны через точечный поиск в полигоне по файлу государственных границ, а также прямая ссылка на спутниковый снимок Google Maps для P0, P1 и P2.
Результат — обычная HTML-таблица: индекс, страна, три кликабельные пары координат в строке.

По этому итоговому списку каждый кандидат проверялся визуально.
Первые 7 строк оказались явно не подходящими.

Но восьмая строка в таблице привела в страну Микронезия (впервые пришлось узнать, что существует страна с таким названием):

и подтверждение пришло через P1 и P2:

Это и есть решение...
Посмотреть на карте можно здесь: Google Maps
i] Итоговые ответы
a) What is the name of the resort?
$$ \text{Oan} $$
b) What are the coordinates of the island?
$$7^\circ\,21^\prime\,48.4^{\prime\prime}\,\text{N} \qquad 151^\circ\,45^\prime\,20.7^{\prime\prime}\,\text{E}$$
$$ \text{или} $$
$$7.363444^\circ,\ 151.755750^\circ$$
c) In which cardinal direction was the
camera facing when the photo was taken?
$$ \because\quad \theta = \text{atan2}\Big(\sin(\Delta\lambda)\cos\phi_1,\ \cos\phi_0\sin\phi_1 - \sin\phi_0\cos\phi_1\cos(\Delta\lambda)\Big) $$
$$ P_0 = (7.3633,\ 151.755983), \quad P_1 = (7.386573,\ 151.739534) $$
$$ \therefore\quad \theta = 324.97^\circ \implies \textbf{СЗ} $$
j] Данные и лицензии
Полигоны береговой линии:
land-polygons-split-4326
© участники OpenStreetMap, доступно по лицензии
Open Database License (ODbL) 1.0.
Наборы кандидатов и итоговый отчёт в репозитории являются производной базой данных
и публикуются под той же лицензией.
Данные о рельефе:
Copernicus DEM GLO-30.
© DLR e.V. 2010-2014 и © Airbus Defence and Space GmbH 2014-2018, предоставлено в рамках
COPERNICUS Евросоюзом и ESA; все права защищены.
Спутниковые снимки:
Содержит модифицированные данные Copernicus Sentinel 2025-2026,
полученные через Earth Search
компании Element 84 в рамках AWS Open Data.
Границы стран:
Natural Earth 10m
admin-0, общественное достояние.
Задача и исходное фото:
OSINT Exercise #004
от Sofia Santos (gralhix).
Спутниковые скриншоты в разделе (h) взяты из Google Maps / Google Earth.