Тайловый сервер - что с ним делать?

В прошлых заметках Подготовка карт для генератора тайлов Mapnik, Настройка базы данных для генератора тайлов Mapnik, Установка генератора тайлов Mapnik и Установка renderd и mod_tile - системы отрисовки тайлов по запросу мы настроили обычный тайловый сервер. Однако, от одного лишь просмотра карт толку мало - ведь то же самое без лишних усилий можно увидеть и на официальном сайте OpenStreetMap.

1. Добавление локальной информации

Для того, чтобы на карту можно было вносить локальную информацию, можно настроить локальный веб-сайт OpenStreetMap и подключаясь к нему с помощью редактора JOSM, редактировать имеющуюся информацию. Я пробовал устанавливать Ruby и Rails и мне даже удалось запустить локальный веб-сайт, однако работал он очень медленно, а ускорить его работу мне не удалось - не хватило знаний Ruby on Rails и времени, чтобы в нём разобраться.

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

Поэтому я выбрал другой вариант - локальные данные будут храниться в OSM-файле, который можно редактировать с помощью уже знакомого нам редактора JOSM. Локальной информацией может быть, например, информация с расположением торговых точек в случае магазина, с расположением банкоматов в случае банка, платёжных терминалов, телефонов-автоматов, wifi-точек, зон ответственности развозчиков пиццы и т.п.

Этот OSM-файл сразу после редактирования можно импортировать в отдельную базу данных. А для того, чтобы Mapnik отображал информацию из локальной базы данных, нужно добавить в файл стилей /etc/mapnik-osm-data/osm.xml настройки для подключения к новой базе данных и написать стиль отрисовки объектов из неё.

Документацию по написанию файлов стилей можно найти здесь: Mapnik configuration XML.

Вот пример фрагмента файла osm.xml, в котором задаётся стиль отображения некоего зонального деления территорий, берущегося из базы данных zones:

<Style name="zones">
  <Rule>
    &maxscale_zoom0;
    &minscale_zoom10;
  </Rule>

  <Rule>
    &maxscale_zoom9;
    &minscale_zoom11;
    <Filter>not ([name] = '') and [area] = 'yes'</Filter>
    <LineSymbolizer stroke="#000000" stroke-opacity="1" stroke-width="1" stroke-linecap="round"/>
  </Rule>

  <Rule>
    &maxscale_zoom12;
    &minscale_zoom13;
    <Filter>not ([name] = '') and [area] = 'yes'</Filter>
    <LineSymbolizer stroke="#000000" stroke-opacity="1" stroke-width="2" stroke-linecap="round"/>
    <TextSymbolizer size="10" allow-overlap="yes" fill="#000000" fontset-name="book-fonts" opacity="1" placement="interior">[name]</TextSymbolizer>
  </Rule>

  <Rule>
    &maxscale_zoom14;
    &minscale_zoom19;
    <Filter>not ([name] = '') and [area] = 'yes'</Filter>
    <LineSymbolizer stroke="#000000" stroke-opacity="1" stroke-width="4" stroke-linecap="round"/>
    <TextSymbolizer size="20" allow-overlap="yes" fill="#000000" fontset-name="book-fonts" opacity="1" placement="interior">[name]</TextSymbolizer>
  </Rule>
</Style>

<Layer name="zones" status="on" srs="&srs900913;">
  <StyleName>zones</StyleName>
  <Datasource>
    <Parameter name="table">(select * from planet_osm_polygon) as zones</Parameter>
    <Parameter name="type">postgis</Parameter>
    <Parameter name="password">password</Parameter>
    <!-- <Parameter name="host">localhost</Parameter> -->
    <Parameter name="user">osm</Parameter>
    <Parameter name="dbname">zones</Parameter>
    <Parameter name="estimate_extent">false</Parameter>
    <Parameter name="extent">-20037508,-19929239,20037508,19929239</Parameter>
  </Datasource>
</Layer>

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

Его неоптимальность заключается в том, что во-первых, запрос извлекает из таблицы все поля, вне зависимости от того, нужны ли они для отрисовки карты или нет. В моём случае достаточно оставить поля way и name - их вполне достаточно для отрисовки контура участка и его номера.

Второй момент - запрос написан не оптимально, т.к. извлекает из таблицы все строки, а Mapnik будет рисовать только те объекты, которые удовлетворяют настройкам фильтра. Вместо этого можно дополнить запрос условием WHERE name IS NOT NULL AND name <> '' AND area = 'yes', а из описания стиля удалить все фильтры.

Третий момент - это настройка extent, в которой указаны границы всего мира, хотя, наверняка, локальные данные находятся в каких-то предсказуемых границах. Например, локальные данные в моём случае ограничиваются только Республикой Башкортостан, Республикой Татарстан и Оренбургской областью. Можно однажды выполнить следующий запрос:

SELECT ST_Extent(way)
FROM (SELECT ST_ConvexHull(ST_Extent(way)) AS way
FROM planet_osm_polygon
UNION SELECT ST_ConvexHull(ST_Extent(way)) AS way
FROM planet_osm_point
UNION SELECT ST_ConvexHull(ST_Extent(way)) AS way
FROM planet_osm_line
UNION SELECT ST_ConvexHull(ST_Extent(way)) AS way
FROM planet_osm_roads) AS ways;

и прописать в настройки extent возвращённые значения. Это позволит Mapnik'у не выполнять запросы к базе данных, если заведомо известно, что запрос не вернёт результатов для интересующей его области.

Более подробно о рекомендациях по оптимизации файла стилей Mapnik можно прочитать в статье Optimize Rendering with PostGIS.

2. Использование информации из базы данных

Кроме заливки информации в локальную базу данных, я также пользуюсь и основной базой данных Mapnik, непосредственно залезая в её недра с помощью SQL-запросов. Поэтому мне в её таблицах бывают нужны некоторые атрибуты объектов, которые по умолчанию не импортируются в базу данных утилитой osm2pgsql.

Чтобы указать дополнительные поля, нужно отредактировать файл стиля базы данных /usr/share/osm2pgsql/default.style

Например, я добавил в файл стиля базы данных колонки addr:city и addr:street, которые берутся из одноимённых атрибутов объектов из файла OSM:

node,way         addr:city           text
linearnode,way   addr:street         text  linear

node означает, что этот атрибут может быть назначен точке и должен быть импортирован в таблицу planet_osm_point.

way означает, что этот атрибут может быть назначен контуру (линии, дороге или многоугольнику) и должен быть импортирован в таблицу planet_osm_line, planet_osm_roads или planet_osm_polygon.

Теперь информацию из базы данных можно извлекать с помощью SQL-запросов, в чём особенно помогают различные функции PostGIS.

Вот лишь краткий список функций, которые оказались полезными для моих задач:

  1. ST_AsText - возвращает геометрический объект в формате WKT (Well-known Text), описанный в стандартах OpenGIS.
  2. ST_Transform - переводит координаты опорных точек геометрического объекта из одной проекции в указанную.
  3. ST_GeomFromText - возвращает геометрический объект по его описанию в формате WKT и (опционально) заданной проекции.
  4. ST_IsValid - проверяет правильность объекта - замкнутость многоугольника, отсутствие самопересечений и т.п.
  5. ST_PointOnSurface - возвращает точку, находящуюся строго на поверхности объекта (многоугольника или мультиполигона, многоугольника с дырами - геометрического объекта, имеющего один внешний контур и произвольное количество внутренних контуров).
  6. ST_ContainsProperly - функция, возвращающая "истину", если второй объект находится строго внутри первого. Достаточно, чтобы хотя-бы одна вершина второго объекта не попала внутрь первого, или попала в дыру первого объекта, чтобы функция вернула "ложь".
  7. ST_Extent - агрегатная функция (работает подобно агрегатным функциям COUNT, MIN, MAX, SUM или AVG), возвращает геометрический объект BOX - прямоугольник, охватывающий выбранные геометрические объекты.

Для чего можно использовать эти функции? Приведу несколько примеров, иллюстрирующих, как их использую я.

Например, для того, чтобы удалить из таблицы planet_osm_polygon многоугольники с самопересечениями и просто многоугольники, имеющие какие-то ошибки, можно воспользоваться таким запросом:

DELETE FROM planet_osm_polygon
WHERE NOT ST_IsValid(way);

Или можно вернуть координаты точки на поверхности каждого дома из таблицы planet_osm_polygon в формате WKT в проекции WGS 84:

SELECT ST_AsText(ST_Transform(ST_PointOnSurface(way), 4326))
FROM planet_osm_polygon
WHERE building IS NOT NULL;

Или, например, найти, контур здания по точке внутри него:

SELECT way
FROM planet_osm_polygon
WHERE building IS NOT NULL
  AND ST_ContainsProperly(way, ST_Transform(ST_GeomFromText('POINT(48.2445263783448 55.8405766215408)', 4326), 900913));

Где 48.2445263783448 - долгота, 55.8405766215408 - широта.

Или вычислить прямоугольник, содержащий весь населённый пункт с указанным именем:

SELECT ST_Extent(ST_Transform(way, 4326))
FROM planet_osm_polygon
WHERE place IN ('city', 'town', 'village', 'hamlet')
  AND name = 'Салават';

Естественно, чтобы извлекать значения полей addr:city, addr:street, нужно их сначала добавить в файл стиля базы данных для утилиты osm2pgsql, а затем импортировать данные, что мы уже проделали в предыдущем пункте этой заметки. Правда, не всегда и везде проставляются значения этих полей, потому что для отрисовки карты Mapnik их никак не использует - поверх дома выводится только его номер.

Но некоторые поля можно проставить довольно просто. Например, чтобы проставить поле "addr:city" у всех домов, попадающих в административную границу какого-либо населённого пункта, я пользуюсь скриптом на Perl, часть которого приведена ниже:

# Перебираем населённые пункты, прописываем домам населённый пункт в поле addr:city
sub osm_fill_city()
{
  my $total = 0;
  my $sth_polygon = $dbh_o->prepare("UPDATE planet_osm_polygon
                                     SET \"addr:city\" = ?
                                     WHERE building IS NOT NULL
                                       AND (\"addr:city\" IS NULL OR \"addr:city\" = '')
                                       AND ST_ContainsProperly(ST_GeomFromText(?, 900913), way)");
  my $sth_point = $dbh_o->prepare("UPDATE planet_osm_point
                                   SET \"addr:city\" = ?
                                   WHERE building IS NOT NULL
                                     AND (\"addr:city\" IS NULL OR \"addr:city\" = '')
                                     AND ST_ContainsProperly(ST_GeomFromText(?, 900913), way)");
  my $sth_city = $dbh_o->prepare("SELECT name,
                                         ST_AsText(way)
                                  FROM planet_osm_polygon
                                  WHERE place IN ('city', 'town', 'village', 'hamlet')
                                     AND name IS NOT NULL
                                     AND name <> ''");
  $sth_city->execute();
  while (my ($name, $wkt) = $sth_city->fetchrow_array())
  {
    $sth_polygon->execute($name, $wkt);
    $sth_point->execute($name, $wkt);
    $total++;
    print "Простановка населённых пунктов на зданиях, всего обработано населённых пунктов: $total\r";
  }
  $sth_city->finish();
  $sth_point->finish();
  $sth_polygon->finish();
  print "Простановка населённых пунктов на зданиях, всего обработано населённых пунктов: $total\n";
}

Можно, конечно, не заниматься этим, а взять координаты или контур интересующего нас объекта и с помощью функции ST_ContainsProperly узнать, в административные границы какого населённого пункта этот объект попадает.

3. Геокодинг - поиск географических объектов

Эту информацию без дополнительной обработки можно использовать для обратного геокодинга, то есть для получения адреса здания по географическим координатам точки, попавшей в контур здания:

SELECT "addr:city", "addr:street", "addr:housenumber"
FROM planet_osm_polygon
WHERE building IS NOT NULL
  AND building <> ''
  AND "addr:city" IS NOT NULL
  AND "addr:city" <> ''
  AND "addr:street" IS NOT NULL
  AND "addr:street" <> ''
  AND "addr:housenumber" IS NOT NULL
  AND "addr:housenumber" <> ''
  AND ST_ContainsProperly(way, ST_Transform(ST_GeomFromText('POINT(55.98886 54.74241)', 4326), 900913));

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

Для себя я нашёл подходящее решение, которое в целом меня устраивает, но не является универсальным, т.к. опирается на некоторые допущения, которые верны для интересующих меня населённых пунктов.

Для поиска адреса создаётся индекс адресов, в который помещаются "нормализованные" строки, содержащие название населённого пункта, улицы и дома. Перед поиском адреса по индексу, искомый адрес тоже переводится в нормализованную форму, а дальнейший поиск выполняется простым SQL-запросом.

Процедура нормализации у меня делится на три части, из которых самой сложной является нормализация названия улицы.

Нормализация названия населённого пункта:

  1. Буквы переводятся в нижний регистр,
  2. Удаляются незначащие пробельные символы - символы в начале и конце строки, а подряд идущие пробельные символы заменяются на один пробел,
  3. Буква "ё" заменяется на "е",
  4. Удаляются сокращения "г.", "п.", "с.", "д.", слова "город", "поселок", "село", "деревня".

Полученная строка используется для сравнения.

Из неучтённых особенностей тут могут быть одноимённые населённые пункты разного класса. Например, посёлок Октябрьский и город Октябрьский. Или одноимённые населённые пункты из разных районов - посёлок Фёдоровка рядом с Уфой и посёлок Фёдоровка в Фёдоровском районе. Но поскольку мне нужен поиск адресов только в 9 городах, то эти особенности я учитывать не стал.

Нормализация номера дома:

  1. Буквы переводятся в нижний регистр,
  2. Удаляются незначащие пробельные символы - символы в начале и конце строки, а подряд идущие пробельные символы заменяются на один пробел,
  3. Буква "ё" заменяется на "е".

Из неучтённых особенностей тут могут быть попытки вставить в поле номера дома слово "дом" или сокращение "д.", могут быть присутствовать слова "корпус", "корп.", "строение", "стр.", попытки вместо знака дроби написать слово "дробь" и т.п.

Нормализация названия улицы:

  1. Буквы переводятся в нижний регистр,
  2. Удаляются незначащие пробельные символы - символы в начале и конце строки, а подряд идущие пробельные символы заменяются на один пробел,
  3. Буква "ё" заменяется на "е",
  4. Получившаяся строка разбивается на последовательность слов, а границами слов считаются пробелы и точки. Это сделано для того, чтобы различные сокращения и инициалы отделились от слов, с которыми они написаны слитно,
  5. Удаляются одиночные буквы,
  6. Раскрываются сокращения "ул" -> "улица", "пер" -> "переулок", "пр" -> "проспект", "пер" -> "переулок", "бул" -> "бульвар", "пл" -> "площадь", "шос" -> "шоссе", "наб" -> "набережная", "им" -> "имени",
  7. От чисел отрезаются окончания, так что строки типа "60-летия", "2-й", "1-я" превращаются просто в числа,
  8. Удаляются незначащие слова типа "лет", "летия", "реки", "имени". Названия многих улиц приурочены к юбилеям каких-либо памятных событий ("50-летия Октября" или "60 лет СССР"). Набережные, естественно, часто имеют в своём названии названия рек, вдоль которых они расположены, поэтому между названием типа "набережная реки Уфы" или "набережная Уфы" нет никакой разницы. И, наконец, улицы часто называются в честь каких-то людей, поэтому нет разницы между названиями типа "проспект имени Ленина" или "проспект Ленина",
  9. Удаляются слова-классификаторы адреса типа "улица", "проспект", "площадь", "тракт", из которых запоминается только первое.
  10. Из оставшихся слов собирается нормализованный адрес, перед которым ставится слово-классификатор адреса.
  11. В получившейся строке ищутся идущие подряд пары слов типа "имя фамилия" или "титул фамилия", из которых остаётся только фамилия. Тут я делаю предположение, что в городе не бывает улиц одного класса, названных именами однофамильцев. То есть, в городе не может быть улицы Льва Толстого и улицы Алексея Толстого, но может быть улица Льва Толстого и проспект Алексея Толстого - в этом случае однофамильцы будут различаться классом улицы. И сюда же относятся различия в титулах - алгоритм нормализации не учитывает, что могут быть улицы академика Морозова и Павлика Морозова. Это преобразование помогает находить названия улиц, в случае если имя или титул человека, в честь которого названа искомая улица, не были указаны. Тут мне пришлось приложить усилия и составить список людей, именами которых названы улицы. У меня это единый список, но вообще, хорошо бы иметь отдельный список для каждого населённого пункта - так и точность и скорость нормализации будут выше. В России для этого можно использовать адресный справочник КЛАДР или пришедший ему на смену ФИАС - читайте, например КЛАДР умер, да здравствует ФИАС?

Также при поиске дома по адресу следует учитывать, что существуют угловые дома, которым часто назначаются сразу два адреса. В проекте OpenStreetMap нет единого соглашения по тому, каким образом в базе данных указывать такие адреса. Есть несколько разных подходов, которые описаны на этой странице: Key:addr. Угловые дома

Для отображения информации на карте я использую JavaScript-библиотеку LeafLet, написанную киевским программистом Владимиром Агафонкиным. Эта библиотека отстаёт по возможностям от библиотеки OpenLayers, которая используется самим проектом OpenStreetMap, но мне она понравилась компактностью и простотой использования.

Написать автору