Алгоритм Рамера–Дугласа–Пекера

Отличная штука при векторизации растра. Мы нашли контур ромба, но он содержит тысячу точек. Хотя там должно быть всего четыре точки. Четыре, Карл! Вот для того, чтобы точек стало четыре, и может пригодиться алгоритм Рамера-Дугласа-Пекера.

Или, например, ведёте линию дрожащей мышкой. Если простить небольшую погрешность, то это явно прямая линия. Возникает вопрос, в каком месте начинается вторая явно прямая линия.

Алгоритм Рамера–Дугласа–Пекера сглаживает исходную ломаную, получая на выходе ломаную со значительно меньшим числом точек и формой, приближенной к желаемой.

Здесь будет описан алгоритм с небольшими дополнениями. Дело в том, что классический алгоритм не заточен на замкнутые ломаные, полигоны. А хотелось бы. Поэтому появился ряд нюансов и дополнений, о которых речь пойдёт ниже. Это в основе своей тот же алгоритм, но более пригодный к практическому использованию. Не только в Delphi. Мне он был нужен в распознавании образов. То есть прошёл практическую апробацию.

Описание алгоритма

Алгоритм простой и хорошо описан. Поэтому, чтобы особо не повторяться, вкратце суть алгоритма такая. Берётся некий диапазон на исходной ломаной. Внутри диапазона ищется максимально удаленная точка от отрезка, образованная границами диапазона. Если расстояние до такой точки больше некоей заданной величины, то данная точка делит заданный диапазон и поиск продолжается уже на двух диапазонах, образованных от деления.

Алгоритм в картинках

Итак, у нас есть некая ломаная:

[]

Массив результирующей ломаной пуст. Стартуем алгоритм.

[A]

A и B это начальная и конечная точки ломаной. Рассматриваем весь диапазон точек между A и B. Точку A помещаем в результирующий массив.

[A]

На диапазоне AB находим максимально удалённую точку С, которая отстоит от отрезка AB на расстояние, больше указанного. Значит, точка С — это новая точка. У нас образовалось два новых диапазона — AC и CB. Точку С пока не добавляем в массив, потому что в процессе прогона по AC вдруг окажутся ещё точки.

[A]

Рассматриваем диапазон AC. На нём ничего выдающегося не оказалось.

[A,C]

Добавляем C в массив точек новой ломаной и рассматриваем диапазон CB. На диапазоне CB оказалась выдающаяся точка D, которая делит диапазон на два — CD и DB.

[A,C]

На диапазоне CD обнаружилась выдающаяся точка E, которая снова делит диапазон на CE и ED. По прежнему ничего не добавляем в массив итоговой ломаной. Потому что не знаем, как сложится ситуация дальше, в новых диапазонах.

[A,C]

На диапазоне CE не нашлось ничего выдающегося. Точки m и n находятся на слишком малом расстоянии от отрезка CE, чтобы считаться выдающимися.

[A,C,E]

Поэтому дописываем в массив точку E и переходим к рассмотрению диапазона ED.

[A,C,E,D]

На диапазоне ED не нашлось ничего выдающегося, поэтому дописываем D. И переходим к рассмотрению диапазона DB.

[A,C,E,D,B]

На диапазоне DB не нашлось ничего выдающегося, поэтому дописываем B.

[A,C,E,D,B]

Нюансы реализации

В теории существует две разновидности алгоритма — рекурсивная и итеративная.

Обе разновидности очень похожи, потому что выполняют, по сути, одно и то же. Только одна разновидность это делает элегантно в рекурсии, а другая тупо в цикле. Поэтому и нюансы совпадают. Предположим, что у нас уже есть работающие алгоритмы, и мы делаем предварительные шаги, уже понимая эти нюансы.

Для начала определим типы:

Константы:

И функции:

Классический алгоритм, описанный выше, заточен на незамкнутую ломаную. Мне не удалось добиться, чтобы классический алгоритм на замкнутых полигонах работал всегда. Пусть потери незначительные, но для обработки контура образов тем не менее важные.

Одним словом, мы хотим, чтобы замкнутые контуры также корректно обрабатывались. В связи с чем, появились нюансы реализации. И, как следствие, некоторые дополнительные параметры в процедуре алгоритма. Да, у нас будет не совсем классический алгоритм, зато работающий.

Нюанс первый: Полигон (параметр AClosed)

Для полигона не надо, чтобы первая и последняя точка совпадали. Однако, если мы строим упрощённую ломаную, которая зиждется на своей начальной и конечной точках, то мы можем получить такую картинку:

Это изображение имеет пустой атрибут alt; его имя файла - p11.jpg

A и B — это начало и конец ломаной, которая состоит из 823 точек. Всё находит изумительно, но вместо 5 вершин, я хочу видеть 4. Это же явный ромб.

Поэтому у нас появляется параметр AClosed, который определяет, с каким типом кривой работаем — замкнутой или незамкнутой.

И если нам требуется замкнутый контур, и при этом начальная и конечная точки не совпадают, добавляем в конец массива точку, равную начальной. Связано это с тем, что для замкнутой кривой мы будем обрабатывать на самом деле две ломаные (нюанс второй), поэтому вторая ломаная должна заканчиваться там, где начинается первая:

После завершения работы алгоритма мы будем иметь на руках массив точек упрощённой ломаной. Если мы заказали замкнутую кривую, то теперь нам совпадающие точки по краям не нужны. Учтём это.

ALines: TPointFDynArray — это результирующий массив точек упрощённой ломаной.

Нюанс второй: Две ломаные (AStrictOnePolyline)

Как говорилось выше, классический алгоритм работает на незамкнутой ломаной. Чтобы он заработал на полигоне, надо совершить ещё ряд действий.

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

Пока мы ищем максимально удалённую точку, можем заодно удалить дубликаты точек.

И, раз уж мы говорим о нюансах, то один и тот же алгоритм, на одних и тех же данных, может выдать немного разный результат для классического случая — одна ломаная, и для случая с разбивкой на две ломаные, с прогонкой алгоритма по каждой.

Поэтому у нас появляется параметр AStrictOnePolyline, который отвечает за следующее. При установленном AStrictOnePolyline незамкнутая ломаная будет обработана классическим алгоритмом. Если у нас выставлен AClosed, действие этого параметра отменяется, деление на две ломаные будет происходить в любом случае.

Где AList: TPointFList — исходные данные, а List: TPointFDynArray — это массив, куда мы переписали обработанные данные.

Нюанс третий: Математика

Алгоритм заключается в том, что на очередном шаге берётся участок ломаной между некими индексами i1, i2. Индексам соответствуют точки P1 и P2. Далее, просматриваем все точки между этими индексами и находим такую точку, которая максимально удалена от отрезка (P1, P2) на расстояние, больше заданного Epsilon. Таким образом, P1 и P2 — это неизменяемые точки отрезка в этом шаге, а P3 — перебираемая (i1+1 .. i2-1) точка участка, от которой ищется расстояние dL до отрезка.

Формула

Для нахождения расстояния от точки до отрезка, берём формулу высоты треугольника:

h = 2 * S / L, где:

  • Sплощадь треугольника,
  • L — длина стороны треугольника, на которую опущена высота.

Площадь треугольника считаем по формуле:

S = |(X1-X3)*(Y2-Y3) — (X2-X3)(Y1-Y3)| / 2

Оптимизация формулы

Формулу немного оптимизируем, чтобы не считать всякий раз в цикле те вещи, которые можно вычислить заранее.

(X1-X3)*(Y2-Y3) — (X2-X3)*(Y1-Y3) =

Раскроем скобки:

X1Y2 — X3Y2 — X1Y3 + X3Y3 — X2Y1 + X3Y1 + X2Y3 — X3Y3 =

Сгруппируем:

(X1Y2 — X2Y1 ) + (X3Y3 — X3Y3) + X3 * (Y1 — Y2) + Y3 * (X2 — X1) =

Сократим (X3Y3 — X3Y3). И у блока X3*(…) поменяем знак, чтобы в скобках получить (Y2 — Y1):

(X1Y2 — X2Y1 ) + Y3 * (X2 — X1) — X3 * (Y2 — Y1) =

Выделим неизменяемое:

Y3 * dX — X3 * dY + A, где

  • A = (X1Y2 — X2Y1)
  • dX = (X2 — X1)
  • dY = (Y2 — Y1)

Таким образом, площадь треугольника:

S = |(X1-X3)*(Y2-Y3) — (X2-X3)(Y1-Y3)| / 2 = |Y3 * dX — X3 * dY + A| / 2

Находим высоту:

h = 2 * S / L = 2 * (|Y3 * dX — X3 * dY + A| / 2) / L =

|Y3 * dX — X3 * dY + A| / L

Оптимизация вычислений

Длина отрезка (P1, P2), или L из формулы выше, равна корню суммы квадратов dX и dY. Операция извлечения квадратного корня одна из самых дорогих операций с вещественным числом. Поэтому мы будем работать с квадратом длины отрезка (основание треугольника).

При нахождении расстояния от точки P3 мы не будем брать модуль |Y3 * dX — X3 * dY + A|. Мы будем брать квадрат.

Чтобы сравнение с Epsilon было корректным, будем сравнивать с его квадратом. В самом начале, запоминаем квадрат отклонения.

В дальнейших вычислениях будем учитывать только его:

Нюанс четвёртый: Лишняя точка (ACheckFirst)

Может так оказаться, что начало и конец ломаной лежат на самом деле на одной прямой получившейся полилинии. Конечно, это не дело рассматриваемого алгоритма, но запускать отдельно дополнительную обработку, которая определяла бы факт наличия точек на одной прямой и удаляла их, уж слишком накладно.

Это изображение имеет пустой атрибут alt; его имя файла - p12.jpg

На картинке ситуация, когда нулевая стартовая вершина лежит на прямой, которую образуют следующая и последняя вершины. Когда начало ломаной находится где-то на ребре получившейся фигуры. Если мы работаем с замкнутой ломаной, в такой ситуации нулевую вершину следовало бы убрать.

Пишем дополнительную функцию, которая возвращает True, когда подобная ситуация имеет место быть и нулевая вершина успешно убрана. С учётом вышеописанных нюансов математики, подразумеваем, что передаваемый Epsilon уже в квадрате.

Дополним список параметров аргументом ACheckFirst. И если он равен TRUE, в самом конце нашей запускающей процедуры вызовем CheckFirstPoint.

Рекурсивный алгоритм Рамера-Дугласа-Пекера

Рекурсивная версия алгоритма RDP состоит из двух ключевых частей:

Подготовительная часть

В подготовительной части происходит подготовка данных и запуск рекурсии:

  • AList: TPointFList — исходные данные, список точек;
  • AEpsilon: Single — точность упрощения. Величина, превышение которой означает появление новой выдающейся точки;
  • AClosed, ACheckFirst, AStrictOnePolyline — описаны выше;
  • out ALines: TPointFDynArray — результат: массив с точками упрощённой линии.

Рекурсивная часть

Получает на вход начальный и конечный индексы участка ломаной, находит максимально удалённую точку, сравнивает с заданным значением Epsilon. Если расстояние до точки меньше, то делат вывод о том, что все точки достаточно близко к прямой и можно заканчивать шаг. Если значение больше, делит участок на две части, до индекса найденной точки и после, и вызывает рекурсию для обеих частей.

  • APoints: TPointFDynArray — Массив вещественных точек исходной ломаной;
  • AStartIdx: Integer — Индекс точки в APoints, означающий начало текущего рассматриваемого диапазона;
  • AEndIdx: Integer — Индекс точки в APoints, означающий конец текущего рассматриваемого диапазона;
  • AEpsilon: Single — Величина, превышение которой означает появление новой выдающейся точки. Все точки, отстоящие от отрезка, заданного точками AStartIdx и AEndIdx, и меньшие этой величины, считаются принадлежащими этому отрезку;
  • var AResLength: Integer — Текущая длина массива точек результирующей ломаной;
  • var ARes: TPointFDynArray — Массив точек результирующей ломаной.

Ключевые особенности рекурсивной версии

Рекурсивная реализация — это самый понятный способ записать алгоритм RDP, который почти дословно повторяет его описание: «берём отрезок, если он простой — оставляем концы, если нет — находим важную точку и повторяем для обеих половин».

Преимущества

  • Простота понимания — каждый шаг делает одно и то же, только для меньшего отрезка;
  • Наглядность — чётко видна логика «разделяй и властвуй»;
  • Минимальный расход памяти — не нужно хранит промежуточные массивы, всё формируется на лету.

Недостатки

  • Риск переполнения стека — для очень длинных ломаных;
  • Невозможность контроля глубины — в стандартной реализации;
  • Медленнее итеративной версии — из-за накладных расходов на вызовы функций. Но это так считается. На самом деле это вопрос весьма спорный. У меня рекурсия работает стабильно быстрее итеративной.

Итеративный алгоритм Рамера-Дугласа-Пекера

Зачем нужен итеративный подход?

Хотя рекурсивная реализация алгоритма элегантна и наглядна, она имеет существенное ограничение — риск переполнения стека вызовов при обработке больших наборов данных. Каждый рекурсивный вызов добавляет новый фрейм в стек, и при глубокой вложенности это может привести к исключению Stack Overflow или Out Of Memory или к другой не менее приятной вещице.

К тому же, когда стал делать рекурсивный алгоритм в JS, столкнулся с рядом неприятных вещей, которые привели к сильным тормозам. Не всегда можно повторить рекурсию. Алгоритм, который реализован исключительно в цикле, можно повторить где угодно.

Итеративный алгоритм прост, надёжен и в теории быстрее рекурсивного (но это не так, мы его сделаем таким в самом конце).

Суть итеративного подхода

Итеративная версия сохраняет всю математическую основу оригинального алгоритма, но заменяет рекурсию явным управлением сегментами через структуру данных — стек. Вместо рекурсивных вызовов мы работаем с очередью сегментов, подлежащих обработке.

Основные этапы итеративного алгоритма:

  1. Инициализация: Создаем стек и помещаем в него начальный сегмент (0, n-1);
  2. Массив отметок: Создаем булевый массив для отметки точек, которые войдут в результат
  3. Цикл обработки: Пока стек не пуст:
    • Извлекаем сегмент (start, end);
    • Находим точку с максимальным отклонением (используя ту же математику, что и в рекурсивной версии);
    • Если отклонение превышает Epsilon, отмечаем точку и добавляем два новых сегмента в стек;
  4. Формирование результата: Собираем все отмеченные точки

Реализация сегмента и стека

Для сегмента вводим специальный тип:

Задача TSegment‘а просто хранить индексы участка ломаной, который надо обработать. Классовая функция Create выполняет ту же функцию, что и конструктор, только быстрее.

Структуры TSegment помещаются в стек, чтобы потом быть извлечёнными и обработанными. В Delphi есть специальный тип для реализации стека — TStack<T>. Но в борьбе за быстродействие пришлось реализовать свой очень минималистический стек.

В итеративном алгоритме ниже, мы инициализируем стек такой записью:

Откуда взялось именно AList.Count div 4 + 8. Это эмпирическая формула, основанная на особенностях алгоритма Рамера-Дугласа-Пекера. Суть инициализации стека — изначально создать массив, которого скорее всего хватит на всю обработку без дополнительного перераспределения памяти.

Почему делим на 4:

  • Эмпирические наблюдения: Для 1000 точек стек редко превышает 250 элементов;
  • Бинарное дерево сегментов: Максимально возможных сегментов N-1, но большинство отсекается из-за Epsilon; Типичное количество активных сегментов: N/4.

Зачем добавляем 8:

  • Минимальный размер для мелких полигонов: Даже для 10 точек: 10/4 + 8 =10 элементов. Без +8 было бы всего 2 элемента;
  • Избегаем частого реаллока для маленьких массивов;
  • Запас для начальных сегментов:

Реализация итеративного алгоритма RDP

  • AList: TPointFList — исходные данные, список точек;
  • AEpsilon: Single — точность упрощения. Величина, превышение которой означает появление новой выдающейся точки;
  • AClosed, ACheckFirst, AStrictOnePolyline — описаны выше;
  • out ALines: TPointFDynArray — результат: массив с точками упрощённой линии.

Ключевые особенности итеративной версии

Преимущества

Безопасность и надёжность

Нет риска переполнения стека. Контроль над памятью и простота отладки.

Возможность оптимизации

Например, большие сегменты обрабатываются первыми.

Параллелизация

Можно разделить на несколько потоков.

Измеримость прогресса
Оптимизации доступа к памяти

Недостатки

Больше служебного кода
Дополнительные структуры данных
Затраты на управление стеком
Потенциальные накладные расходы

Итеративный алгоритм RDP: Турбо-режим

В теории, итеративный алгоритм Рамера-Дугласа-Пекера должен работать быстрее рекурсивного. Но это в теории. У меня же изначально, на родном TStack<T>, он значительно отставал от рекурсивного. Затем я переделал работу со стеком. Этот вариант представлен выше. Стало быстрее, но рекурсивный всё равно побеждал чаще, чем хотелось бы.

Поэтому, меняем работу с данными внутри алгоритма.

Во-первых, я хочу ограничить внешние типы и минимизировать вызовы дополнительных функций. Поэтому описываю тип сегмента локально внутри процедуры. Не нагружаю его больше ничем:

Во-вторых, вся работа со стеком происходит напрямую. Вместо Push использую:

И вместо Pop:

В-третьих, вся работа с массивами происходит только по указателям. У нас в алгоритме везде циклы от индекса до индекса. Поэтому, получив указатель на нужный элемент массива, мы можем его просто инкрементировать в цикле. Например, как это выглядит для инициализации массива и поиска дублей:

Листинг RDP-Turbo

[свернуть]

Вот в таком виде итеративный алгоритм Рамера-Дугласа-Пекера стал быстрее своего рекурсивного родственника. В комментариях оставил примеры, что конкретно ускоряю.

Некоторые вещи можно было бы ускорить и в алгоритме, который не турбо. Но он представлен всё ж таки в некоем классическом варианте, без лишних хитростей. На другой язык его будет переносить легче, чем вся эта возня с указателями.

Заключение

Итеративная реализация не заменяет рекурсивную в образовательном плане — для понимания сути алгоритма рекурсивная версия остается более наглядной. Однако для практического применения в реальных проектах итеративный подход является более надежным и масштабируемым решением, сохраняя все преимущества рекурсивного алгоритма Рамера-Дугласа-Пекера.

Оба подхода используют идентичную математическую основу и производят одинаковые результаты при одинаковых параметрах Epsilon, что позволяет легко переключаться между ними в зависимости от требований конкретной задачи.


Скачать

Друзья, спасибо за внимание!

Надеюсь, материл будет полезен.

Исходник (zip) 107 Кб. Delphi XE 7

Исполняемый файл (zip) 961 Кб.


5 11 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии