[Из песочницы] Жесткий цигун с условными знаками или зачем нужен geometry generator
Требования заказчика к условным обозначениям на картах кажутся вам нереальными? Дальше вы узнаете, как с помощью geometry generator, QGIS и Python сделать так, чтобы ваши условники были лучше всех.
Всем привет! Меня зовут Михаил, мой отдел занимается решениями, связанными с геоинформационными технологиями, в том числе, например, разработкой модулей для QGIS на Python и PyQt. Среди прочего, в сложных случаях, приходится разрабатывать и условные обозначения (стили) для отображения различных объектов на карте. В этой статье я хочу рассказать об одной интересной возможности QGIS при создании условных обозначений, а именно о geometry generator — генераторе геометрии. В сети не так много материалов на эту тему и официальную документацию я бы не назвал исчерпывающей. Не претендуя на полноту и истину хотел бы поделиться с сообществом теми наблюдениями, которые появились после использования генератора геометрий.
QGIS старается четко следовать модели MVC и данные отделены от их представления. Это прослеживается везде, в т.ч. например, в использовании Model-Based виджетов PyQt для интерфейса. Аналогичный подход используется и для визуального представления пространственных данных, которое настраивается через стили. Стиль влияет на то, как будут выглядеть объекты на карте.
Стандартные стили имеют очень богатые возможности в QGIS:
Почему может не хватать возможностей обычных стилей?
Возможностей обычных стилей достаточно для 99% всех случаев. НО! Если вы столкнулись с ситуацией, когда данные представлены одним типом геометрии (например, полигоном), а для визуального отображения требуется другой (например, линия), или когда требования к визуальному отображению объекта очень сложны, то тут выручает генератор геометрии.
Например, обычный стиль не позволит сделать такой полигон:
А такие «зубы» можно создать только с помощью генератора геометрии:
Это все реальные примеры из системы. В сложных случаях выручает именно генератор геометрии, который позволяет создавать практически безграничные, по сложности, стили (но не будем увлекаться и забывать про производительность). Для создания новой геометрии используется внутренний язык выражений QGIS или Python.
Лирическое замечание
Излишне напоминать, но, все-таки, должен еще раз отметить, что объект, полученный от генератора геометрии, является просто визуальным представлением объекта модели и может (!) не совпадать с исходным объектом ни по типу геометрии, ни по местоположению, ни по размерам! Все пространственные операции выполняются с данными реального объекта, а не с его визуальным представлением.
Допущения
- Проект и примеры подготовлены в QGIS версии 3.10.6 на Ubuntu 18.04.
- В проекте используется «плоская» система координат UTM зона 37N, сделано это для упрощения части примеров и общего удобства использования длин и площадей в метрах.
- Ссылка на проект на GitHub в конце статьи.
- Слово «геометрия» используется в качестве сленгового слова, обозначающего пространственное описание векторного объекта.
- Базовые вопросы, например, как добавить генератор геометрии в стиль или настроить цвет линии, рассматриваться не будут.
Типы геометрии geometry generator
Генератор геометрии возвращает — векторную геометрию определенного типа.
С типами нет ничего неожиданного, их всего три: точка/мультиточка, линия/мультилиния, полигон/мультиполигон. Геометрия, создаваемая генератором, должна совпадать с его типом, иначе она не будет отображена, даже если созданная геометрия корректна. Повторюсь еще раз: тип геометрии, возвращаемой генератором, может не совпадать с типом геометрии исходного объекта модели.
Как «готовить»?
После того, как вы выбрали генератор геометрии и его тип, вам необходимо написать одно выражение (expression), которое возвратит некоторую геометрию. Синтаксис выражений описан в документации Expressions, использование выражений в Python описано в разделе Expressions, Filtering and Calculating Values.
Язык выражений, фактически, является функциональным языком. Все выражения — это или литерал, или одна функция. Есть возможность писать дополнительные функции на Python, а потом использовать их при составлении выражений. Язык выражений используется во многих частях QGIS. В языке выражений есть общепринятые конструкции, a главное есть функции для работы с пространственными данными.
Что важно сказать именно про генератор геометрии: создавайте их как можно более быстрыми. Замедленные генераторы-стили создадут могут привести к тому, что системой будет неприятно пользоваться.
После того как создан генератор геометрии, к объекту созданного типа, можно применить всю мощь стилей QGIS, например, настроить цвет, толщину линий, добавить штриховку и т. д. Генераторы геометрии могут быть вложенными.
Точки в …
Первый пример разберем подробно, в следующих примерах будем останавливаться только на новых моментах.
Представим, что вы используете мультиточку (точечный объект, состоящий из нескольких точек), если их много, бывает очень трудно определить какая точка к какой относится, на картинке ниже представлены два разных стиля для отображения пяти мультиточек, слева обычный, справа тот, который попробуем создать:
Нам потребуется три генератора геометрии: один, с точечным типом, для первой точки; один, также точечный, для второй и последующих точек и еще один — линейный, для стрелок. Все действия производятся для слоя points.
Для выделения первой точки воспользуемся функцией geometry_n()
, которая возвращает часть исходной геометрии по ее индексу:
geometry_n($geometry, 1)
Здесь $geometry
— это предопределенная функция, которая возвращает описание исходной геометрии текущего объекта.
Для второй и последующих точек, выражение генератора будет не слишком сложнее:
if(
@geometry_part_num > 1, -- только вторая и последующие части
geometry_n($geometry, @geometry_part_num ), -- выбираем нужную часть
NULL
)
Как видно из кода, if()
— это функция. В строке 2 используется @geometry_part_num
, которая является предопределенной переменной, содержащей номер текущей точки объекта (обращение к переменным начинается со знака @
). В этом фрагменте мы возвращаем NULL
если это первая точка и возвращаем текущую точку для второй и последующей. Справедливости ради надо отметить, что сделать разными первую и другие точки можно иначе, выражение понадобится, но без генератора геометрии.
Теперь переходим к стрелкам — линиям, создадим генератор геометрии и рассмотрим его выражение:
if(
@geometry_part_num > 1, -- работаем только для второй и последующих точек
with_variable(
'inputs',
array(
10000, -- зазор до центрального узла и минимальная длина стрелки
length( -- расстояние от текущей точки до первой точки
make_line(
start_point($geometry),
geometry_n($geometry, @geometry_part_num)
)
),
azimuth( -- азимут от текущей точки до первой точки
geometry_n($geometry, @geometry_part_num),
start_point($geometry)
)
),
if(
@inputs[0] < @inputs[1], -- не рисуем короткие линии
make_line(
geometry_n($geometry, @geometry_part_num), -- исходная точка
project(
geometry_n($geometry, @geometry_part_num), -- текущая точка
@inputs[1] - @inputs[0], -- длина линии
@inputs[2] -- азимут
)
),
NULL -- расстояние до первой точки меньше, чем длина линии.
)
),
NULL
)
Разберем этот код подробнее. Начинаем с условия, в котором проверяется номер точки и, если это первая точка, то стрелку рисовать не надо и сразу перепрыгиваем на последний NULL
.
Для второй и последующих точек рисуем стрелку — просто линию с определенным стилем. Нам понадобится расстояние от текущей точки до первой точки, а также азимут из текущей точки до первой. Еще мы не хотим рисовать очень короткие стрелки и, чтобы был зазор между первой точкой и стрелкой. Будет хорошо, если эти переменные мы определим заранее и будем использовать их дальше в коде.
Для определения переменных используется функция with_variable()
, которая может принимать только три аргумента: имя переменной, значение переменной, выражение. Но у нас несколько переменных, значит надо как-то уместить их в одну, придется или писать вложенные with_variable
, или искать другой путь. Попробуем обойтись без вложенных объявлений.
Чтобы уместить несколько переменных в одну — создадим список с именем inputs
с помощью функции array()
— индексы начинаются с 0. Первое значение списка — это минимальная длина стрелки, а также зазор до первого узла. Второе и третье значения — расстояние между текущей точкой и первой точкой, а также азимут из текущей точки в первую:
...
length( -- расстояние от текущей точки до первой точки
make_line(
start_point($geometry),
geometry_n($geometry, @geometry_part_num)
)
),
azimuth( -- азимут от текущей точки до первой точки
geometry_n($geometry, @geometry_part_num),
start_point($geometry)
)
...
Для определения расстояния используем функцию length()
, которая принимает линию, а функцией make_line()
и создается линия из первой точки — start_point($geometry)
, до текущей (здесь порядок точек не важен). Функция azimuth()
принимает две точки и возвращает угол в радианах, здесь порядок точек важен!
Теперь все переменные готовы, приступим к линиям. С помощью второго if()
проверяем условие, чтобы расстояние между текущей точкой и первой точкой было больше, чем минимальный размер линии. Для этого обращаемся к массиву @inputs
и если расстояние меньше, чем требуется, то «перепрыгиваем» на предпоследний NULL
.
Непосредственно создание линии выглядит так:
...
make_line(
geometry_n($geometry, @geometry_part_num), -- точка из которой рисуем линию
project(
geometry_n($geometry, @geometry_part_num), -- текущая точка
@inputs[1] - @inputs[0], -- длина линии
@inputs[2] -- азимут
)
)
...
Новая здесь только функция project()
, которая возвращает точку, отдаленную от исходной на определенное расстояние по определенному азимуту. К полученным линиям надо применить стиль, который сделает из линии стрелку.
Дополняем хорошее
В конце статьи есть ссылки на ресурсы по теме генераторов геометрии, среди которых есть прекрасный канал Klas Karlsson, посвященный QGIS. В одном из уроков Клас, с помощью генератора геометрии, создавал линию, соответствующую стандарту для карт по спортивному ориентированию, примерно такую:
Мне не очень понравился метод, который применил Клас. Например, Клас создает сроку в формате WKT для описания необходимой геометрии, с постоянной конкатенацией других строк, в коде много лишних символов »||», все это затрудняет чтение и понимание. Поэтому предлагаю иной способ который, на мой взгляд, является более читаемым и понятным. Разница между методами в:
- начало и конец линии обозначены одинаково, читатель может легко это переделать в качестве домашнего задания;
- линия не будет рисоваться, если расстояние между точками меньше определенного значения.
Приступим (слой lines1). По линии видно, что нам понадобится два генератора геометрии:
- генератор точек для обозначения узлов (без генератора не получится сделать разные знаки на первом и последнем узле).
- генератор линий.
Генератор точек достаточно примитивный:
collect_geometries(
array_foreach(
generate_series(1, num_points($geometry)),
point_n($geometry, @element)
)
)
Начнем «раскручивать» изнутри. Нам надо собрать все узлы из исходной геометрии. Для этого с помощью функции generate_series()
, создается серия (список) значений от 1 до num_points($geometry)
, т. е. до общего количества точек в линии. Мы итерируем по этому списку с помощью функции array_foreach()
. Функция array_foreach
вернет список, состоящий из результатов выражения point_n($geometry, @element)
, т. е. из всех точек линии (point_n()
— возвращает точку геометрии по ее индексу), @element
— стандартный механизм доступа к текущему значению генератора списка. Получившийся список не является геометрией, а значит генератор геометрии не сможет его использовать. Чтобы точки из списка объединить в одну геометрию — мультиточку, воспользуемся функцией collect_geometries()
. После этого применяем к точкам стиль (цвет и размер).
Переходим к линиям. Для линий необходим отдельный генератор геометрии с типом «линия/мультилиния». Выражение для него такое:
with_variable(
'minimal_length', -- длина зазора, а также минимальная длина линии
7000.0,
collect_geometries( -- собираем геометрии
array_foreach(
generate_series(1, num_points($geometry) - 1), -- все кроме крайней
with_variable(
'inputs',
array(
azimuth( -- азимут между текущей и следующей точкой
point_n($geometry, @element),
point_n($geometry, @element + 1)
),
length( -- расстояние между текущей и следующей точкой
make_line(
point_n($geometry, @element),
point_n($geometry, @element + 1)
)
)
),
if(
@inputs[1] - @minimal_length * 2 > 0, -- мин. длина линии
line_substring( -- выделяем укороченную линию из линии
make_line(
point_n($geometry, @element),
point_n($geometry, @element+1)
),
@minimal_length, @inputs[1] - @minimal_length
),
geom_from_wkt('LINESTRING EMPTY') -- пустая геометрия
)
)
)
)
)
В целом это выражение похоже на то, которое мы делали раньше. Нам опять потребуется переменная для задания минимальной длины линии и зазора между линией и узлом @minimal_length
. Дальше мы собираем геометрии с помощью collect_geometries
из списка, который получился из цикла array_foreach
по номерам всех точек линии, кроме крайней. Внутри выражения цикла, для каждой из итераций, нам необходимы две переменные в списке @inputs
: расстояние между точками и азимут из текущей точки до следующей.
Определив переменные строим линии. Линии строим для тех точек, расстояние между которыми больше удвоенной переменной @minimal_length
(зазоры с двух сторон). Если линию строить не надо, то переходим к строке, на которую я хочу обратить особое внимание:
geom_from_wkt('LINESTRING EMPTY')
Здесь, по сути нам не нужна никакая геометрия, но если вместо этой строки поставить NULL
, то итоговая геометрия будет некорректной. Значит нам надо определить пустую линию, чтобы включить ее в список. Пустую линию можно определить в формате WKT как «LINESTRING EMPTY», а затем перевести WKT в тип геометрии QGIS с помощью функции geom_from_wkt()
.
Вернемся к созданию линии. В нашем случае это легче сделать с помощью функции line_subtring()
, которая возвращает укороченную линию из другой линии. На вход этой функции подаем исходную линию между точками, а также расстояние от начала исходной линии до начала вырезаемого участка и расстояние от начала исходной линии до конца участка.
Линии в полигоны
Что если вам требуется очертить буфер вокруг линейного объекта? Причем сделать это только для визуального представления, не создавая дополнительный слой, даже временный. Давайте сделаем такой буфер (см. слой lines2).
Сделать это просто, для этого есть функция buffer()
, но у нее есть проблемы: у нее есть параметр, который определяет количество сегментов в четверти окружности и это параметр не может быть меньше 1. Т.е. получаются только такие буферы:
Если вам нужен такой буфер с плоскими краями, то понадобится генератор геометрии.
Может показаться, что нам потребуется генератор, который создает полигон, но мы обойдемся генератором линий. Нам потребуется размер буфера, который мы сохраним в переменной. Затем исходную линию мы сместим на размер буфера в обе стороны, увеличим длину получившихся линий и нарисуем торцы. Вот код:
with_variable(
'distance',
4000, -- размер буфера
with_variable(
'offset_lines', -- массив с двумя смещенными линиями
array(
extend(
offset_curve($geometry, @distance, join:=2),
@distance, @distance
),
extend(
offset_curve($geometry, -@distance, join:=2),
@distance, @distance
)
),
collect_geometries(
@offset_lines[0], -- линия 1
@offset_lines[1], -- линия 2
make_line( -- соединяем первые точки линии 1 и линии 2
start_point(@offset_lines[0]),
start_point(@offset_lines[1])
),
make_line( -- соединяем крайние точки линии 1 и линии 2
end_point(@offset_lines[0]),
end_point(@offset_lines[1])
)
)
)
)
Да, здесь вложенные with_variable
, но, надеюсь, читаемость они не снижают. Остановлюсь на новых моментах. В списке @offset_lines
создаются две смещенные, относительно исходной, линии (смещение с помощью функции offset_curve()
), длина которых была увеличена с помощью функции extend()
. Далее собираем линии и создаем торцы, соединяя начальные — функция start_point()
и конечные точки — функция end_point()
.
Первый — пошел, второй — пошел
Допустим нам требуется создать линию, четные сегменты которой имеют один стиль, а нечетные другой. Генератор геометрии в помощь (см. слой lines3). Создадим два генератора с линейным типом, код которых почти одинаковый:
with_variable(
'lines',
segments_to_lines($geometry),
collect_geometries(
array_foreach(
generate_series(2, num_geometries(@lines), 2),
geometry_n(@lines, @element)
)
)
)
Функция segments_to_lines()
нужна здесь для того, чтобы разбить исходный объект на отдельные линии — была линия, стала мультилиния. Далее собираем четные и нечетные части, разница между выражениями генераторов только в первом аргументе функции generate_series
. Получаем в итоге:
Крестики
Переходим к полигонам. Бывают ситуации, когда требуется нарисовать поперечные линии между узлами полигона (слой poly2).
Для этого требуется генератор геометрии с типом «линия/мультилиния» со следующим выражением:
with_variable(
'points_num',
-- количество уникальных точек в полигоне
num_points($geometry) - 1, --
collect_geometries(
array_foreach(
-- номера точек первой половины полигона
generate_series(1, round(@points_num / 2.0)),
make_line(
point_n( -- текущая точка
$geometry,
@element
),
point_n( -- противоположная точка
$geometry,
@element + floor(@points_num / 2.0)
)
)
)
)
)
Так как нам надо «обойти» только половину узлов, то функция generate_series()
генерирует серию значений с номерами первой половины узлов. Далее рисуется линия из текущего узла в противоположный.
Звездочки
Снова полигоны, теперь такие (слой poly3):
Выражение такое:
collect_geometries(
array_foreach(
generate_series(1, num_points($geometry) - 1),
make_line(
centroid($geometry),
point_n($geometry, @element)
)
)
)
Ничего нового, кроме функции centroid()
, которая возвращает центр масс полигона, ну и серия генерируется без последнего узла полигона, так как он совпадает с первым.
Полигон в линию
Допустим у вас есть слой с полигональными объектами и часть объектов надо отобразить в виде штриховой линии — слой poly4. Те объекты, которые надо сделать штриховой линией, являются тонкими и имеют явные «торцы», которые короче любой из других сторон. «Тонкий» объект — это такой объект, который, при любом практическом масштабе отображения, выглядит линией. В итоге должно получиться так:
Если сделать штриховую обводку всего полигона, то с разных сторон она не будет синхронизирована и будет перекрываться, т. е. требуется сделать штриховку именно по одной стороне. Для этого требуется генератор геометрии линейного типа, например такой:
with_variable(
'lines',
segments_to_lines($geometry), -- разбиваем линию на сегменты
collect_geometries(
array_foreach(
segments_between_sides_nums( -- функция на Python
array_foreach(
generate_series(1, num_geometries(@lines)),
-- элемент списка [номер сегмента, длина сегмента]
array(
@element,
length(
geometry_n(@lines, @element)
)
)
)
),
geometry_n(@lines, @element)
)
)
)
Суть состоит в том, чтобы найти индексы торцов и сгенерировать список номеров сегментов между ними для отрисовки штриховой линии. Для этого надо связать индекс стороны с ее длиной, отсортировать этот список, тогда можно создать серию значений между индексами в первых двух элементах отсортированного списка.
Список с номерами сегментов между торцами создается функцией segments_between_sides_num()
. Этой функции нет в стандартном QGIS и для ее реализации воспользуемся Python (файл custom.py надо положить в папку ~/.local/share/QGIS/QGIS3/profiles/default/python/expressions):
@qgsfunction(args="auto", group="Специальные")
def segments_between_sides_nums(sides, feature, parent):
"""
Возвращает список с номерами сегментов полигона между самыми короткими
сторнами.
sides - список со списками вида (номер сегмента, длина сегмента)
"""
sorted_sides = sorted(sides, key=lambda x: x[1])
return list(range(int(sorted_sides[0][0]) + 1, int(sorted_sides[1][0])))
В функции на Python мы сортируем входной список по второму значению каждого элемента, т.е. по длине сегмента (самые короткие окажутся первыми), а потом генерируем последовательность индексов нужных сторон.
С выражениями генераторов геометрий завершили. Попробуем понять, а почему нельзя сразу на Python?
Наивный бенчмаркинг
Мне всегда было интересно, а что работает быстрее: выражения QGIS или аналогичный код на Python? Некоторое наивное исследование на эту тему представлено ниже. Исследование не строгое, его цель просто посмотреть есть ли существенная разница в производительности.
Исходные данные
- Слой test_poly с 100000 полигонами, с разным размером и количеством узлов от 3 до 20.
Слой сгенерирован скриптом в файле create_poly_lyr.py, на основе точечного слоя со случайными точками (в проект не включен).
Тест
- Для каждого из объекта сгенерируем набор линий, в котором линии идут из центра полигона к каждому из его узлов, похожий на пример в разделе »Звездочки». Разница с указанным примером в том, что линия будет сгенерирована и для крайнего узла полигона (совпадет с линией в первый узел).
Генерировать будем скриптом на Python, в котором сначала будут использованы выражения QGIS, а потом выходная геометрия будет построена исключительно на Python. Фрагмент файла expression_benchmarking.py:
EXPRESSION = """
collect_geometries(
array_foreach(
generate_series(1, num_points($geometry)),
make_line(
centroid($geometry),
point_n($geometry, @element)
)
)
)
"""
…
for counter, poly in enumerate(lyr_polys.getFeatures()):
exp = QgsExpression(EXPRESSION)
context = QgsExpressionContext()
context.setFeature(poly)
star = exp.evaluate(context)
Фрагмент файла python_benchmarking.py:
for counter, poly in enumerate(lyr_polys.getFeatures()):
centroid = QgsPoint(poly.geometry().centroid().asPoint())
star = QgsMultiLineString()
for vertex in poly.geometry().vertices():
line = QgsLineString(centroid, vertex)
star.addGeometry(line)
Результаты
На моем ноутбуке среднее время для Python составило 5,3 секунды, а для выражений 23,16 секунды. Python оказался быстрее выражений в среднем в 4,5 раза. Метод можно сделать строже, но, скажем так, мы попробовали определить тренд.
Интерпретация результатов
Плюсы выражений:
- Выражения лаконичней. Чтобы построить «звездочку» для всех узлов, кроме крайнего, в выражениях надо просто добавить »- 1» в генератор серии значений, а на Python, понадобятся дополнительные строки кода.
- Выражения сохраняются внутри проекта QGIS, т. е. распространяя среди пользователей проект, вы сразу распространяете все стили с выражениями.
Минусы выражений:
- Скорее всего они существенно медленнее кода на Python.
- Ограниченные возможности языка выражений.
Плюсы Python:
- Производительность по сравнению с выражениями может быть в разы выше.
- Неограниченные возможности Python.
Минусы Python:
- Необходимо распространять файл (ы) *.py вместе с проектом.
- Безграничные возможности Python могут создать генератор геометрий, который будет работать очень долго.
- Если вы используете модули Python, которых нет в стандартной поставке, то возможны трудности с их установкой на ПК пользователя (особенно на Windows).
В целом можно сказать, что в большинстве проектов возможностей и производительности выражений QGIS для создания генератора геометрии более чем достаточно. Вопросы производительности, если они возникают, можно решать, например, ограничением видимости объектов в зависимости от масштаба. Но в каждом конкретном случае вопрос можно решать по разному, в том числе создавая «микс» из выражений и Python.
Заключение
А что если условные знаки сложнее? Например следующие.
Реальный кейс: система координат исходной модели WGS84 (долгота/широта), единицы измерения — градусы. В таком случае все вычисления, в которых используется длина или площадь становятся нетривиальной задачей, так как длина одной угловой секунды отличается на разных широтах. Пользователям неудобно оперировать квадратными градусами или длиной в виде градусов, требуются преобразования. Например, буфер в градусах выглядит так:
Другой реальный пример: геометрия, выдаваемая генератором, выходит за пределы объекта. В этом случае все будет выглядеть некрасиво, приходится обрезать геометрию по границам. Например в слое poly3 полигон с «дыркой» может выглядеть так:
Явно не то, что вы хотите увидеть.
А что если вы хотите, чтобы штриховка полигона была всегда параллельна одной из его сторон, при этом модель у вас с долготой/широтой? Например, чтобы всегда и при любых условиях было так:
Пользователь хочет смотреть на объекты и в «плоской» проекции и вообще как угодно, но чтобы штриховка была всегда параллельна одной из сторон? Без дополнительных ухищрений, с изменением угла в зависимости от проекции, не обойтись.
А как нарисовать «зубы», которые были в самом начале?
Все эти, а также много других сложных случаев, остались за рамками данной статьи. В настоящей статье я постарался продемонстрировать возможности генератора геометрии, а также привести базовые примеры, чтобы у пользователя сложилось понимание зачем и как использовать генераторы геометрии.
Генераторы предоставляют практически безграничные возможности, точнее возможности ограничены только производительностью, и являются незаменимым инструментом для создания действительно сложных условных знаков.
Надеюсь, что статья окажется полезной, а сложные случаи, возможно, рассмотрим в следующей статье. Спасибо за внимание.