Точка в полигоне: метод трассировки луча

Урок реализует один из самых частых пространственных запросов — «находится ли точка внутри полигона» — классическим методом трассировки луча.

Point-in-polygon — проверка принадлежности точки многоугольнику; метод трассировки луча (ray casting) считает, сколько раз луч из точки пересекает границу полигона.

«В каком районе находится этот адрес?», «Попадает ли датчик в зону охраны?», «Этот клик по карте — внутри страны или в море?» — всё это один и тот же вопрос: лежит ли точка внутри полигона. Для выпуклых фигур есть простые приёмы, но реальные районы бывают какими угодно — с заливами, выступами, вмятинами. Метод трассировки луча работает для любых, даже невыпуклых, полигонов.

Идея чётности пересечений

Выпустим из проверяемой точки луч в любую сторону (удобно — горизонтально вправо) и посчитаем, сколько раз он пересечёт границу полигона. Если число пересечений нечётное — точка внутри; если чётное (включая ноль) — снаружи. Интуиция: каждый раз, входя в фигуру, мы пересекаем границу, и выходя — снова; чтобы оказаться внутри, входов должно быть на один больше, чем выходов.

  Точка снаружи: луч пересекает границу 2 раза (чётно)
      *----->  |####|  -->
               2 пересечения

  Точка внутри:  луч пересекает границу 1 раз (нечётно)
      |##*----->|  -->
               1 пересечение

Реализация

Перебираем рёбра полигона (пары соседних вершин). Ребро пересекает горизонтальный луч точки, если оно «перешагивает» её широту по вертикали, и точка пересечения лежит правее проверяемой $x$:

def point_in_polygon(x, y, poly):
    n = len(poly)
    inside = False
    j = n - 1  # предыдущая вершина (замыкаем на последнюю)
    for i in range(n):
        xi, yi = poly[i]
        xj, yj = poly[j]
        # ребро пересекает горизонтальную линию точки?
        crosses = (yi > y) != (yj > y)
        if crosses:
            x_cross = (xj - xi) * (y - yi) / (yj - yi) + xi
            if x < x_cross:
                inside = not inside
        j = i
    return inside

square = [(0, 0), (4, 0), (4, 4), (0, 4)]
print(point_in_polygon(2, 2, square))  # центр
print(point_in_polygon(5, 5, square))  # снаружи

# Невыпуклый полигон в форме буквы L
L_shape = [(0, 0), (4, 0), (4, 2), (2, 2), (2, 4), (0, 4)]
print(point_in_polygon(1, 3, L_shape))  # в ножке L
print(point_in_polygon(3, 3, L_shape))  # в вырезе

Вывод:

True
False
True
False

Точка $(3, 3)$ лежит в «вырезе» буквы L — формально внутри ограничивающего прямоугольника, но снаружи самой фигуры. Метод луча это правильно отлавливает, что отличает его от наивной проверки «между min и max».

Как работает под капотом

Условие (yi > y) != (yj > y) истинно ровно тогда, когда одна вершина ребра выше широты точки, а другая ниже или на уровне — то есть ребро действительно «перешагивает» горизонтальный луч. Строгое сравнение по одной стороне аккуратно обрабатывает вершины, лежащие точно на луче, чтобы не посчитать пересечение дважды. Сложность — $O(n)$ на одну точку при $n$ вершинах. Если точек миллионы, сначала отсекают по bounding box (быстрая проверка прямоугольником), а уже прошедшие — точным лучом. Граничные случаи (точка ровно на ребре) метод трактует неустойчиво — для них в продакшене берут готовый предикат из shapely.

Частые ошибки

  • Проверять только bounding box. «Между min и max координат» — не то же самое, что внутри полигона (см. вырез L).
  • Неверно замкнуть полигон. Последнее ребро соединяет последнюю вершину с первой; об этом легко забыть.
  • Нестрогие сравнения по широте. Использование >= с обеих сторон удваивает счёт на вершинах, лежащих на луче.

Запрос, который вы используете каждый день

Проверка «точка в полигоне» незаметно работает в десятках сервисов вокруг нас. Когда такси определяет, в каком тарифном поясе вы находитесь, — это точка в полигоне. Когда сайт показывает цены в вашей стране по геолокации — точка в полигоне (страны). Когда служба доставки решает, входит ли адрес в зону обслуживания, когда игра определяет, в какой триггер-зоне игрок, когда метеосервис находит ваш район для прогноза — везде один и тот же алгоритм трассировки луча или его родственники. Эта операция настолько фундаментальна, что её аппаратно ускоряют в графических процессорах (там она называется растеризацией) и оптимизируют в каждой ГИС-библиотеке.

Метод луча красив своей универсальностью — он не требует, чтобы фигура была выпуклой, и работает с любыми, самыми изрезанными контурами районов. Но у него есть честные ограничения, о которых важно знать. Граничные случаи — точка ровно на ребре или на вершине — он трактует неустойчиво, и для них в продакшене берут проверенные предикаты shapely. На больших объёмах его всегда предваряют отсевом по bounding box, иначе линейный проход по тысячам вершин каждого из тысяч полигонов убьёт производительность. И для полигонов с дырками (озеро с островом) логику расширяют: точка внутри внешнего кольца, но внутри дырки — снаружи фигуры. Понимание этих нюансов отделяет «работает на примере» от «работает на реальных данных».

Итог

  • Метод луча: нечётное число пересечений границы — точка внутри.
  • Работает для любых полигонов, включая невыпуклые (буква L).
  • Сложность $O(n)$; для масс точек сначала отсекают по bounding box.
  • Граничные случаи лучше доверять готовому shapely.
Проверьте себя
1. Когда метод трассировки луча считает точку внутри полигона?
AПри чётном числе пересечений границы
BПри нечётном числе пересечений границы
CВсегда
DНикогда для невыпуклых фигур
2. Почему проверки по bounding box недостаточно?
AОна слишком медленная
BТочка может быть внутри прямоугольника, но в «вырезе» невыпуклой фигуры — снаружи полигона
CBounding box не существует
DОна работает только для кругов
3. Какая вычислительная сложность метода луча на одну точку?
AO(1)
BO(n) по числу вершин полигона
CO(n²)
DO(log n)