ocitysmap/ocitysmap2/__init__.py

556 wiersze
20 KiB
Python

# -*- coding: utf-8 -*-
# ocitysmap, city map and street index generator from OpenStreetMap data
# Copyright (C) 2010 David Decotigny
# Copyright (C) 2010 Frédéric Lehobey
# Copyright (C) 2010 Pierre Mauduit
# Copyright (C) 2010 David Mentré
# Copyright (C) 2010 Maxime Petazzoni
# Copyright (C) 2010 Thomas Petazzoni
# Copyright (C) 2010 Gaël Utard
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""OCitySMap 2.
OCitySMap is a Mapnik-based map rendering engine from OpenStreetMap.org data.
It is architectured around the concept of Renderers, in charge of rendering the
map and all the visual features that go along with it (scale, grid, legend,
index, etc.) on the given paper size using a provided Mapnik stylesheet,
according to their implemented layout.
The PlainRenderer for example renders a full-page map with its grid, a title
header and copyright notice, but without the index.
How to use OCitySMap?
---------------------
The API of OCitySMap is very simple. First, you need to instanciate the main
OCitySMap class with the path to your OCitySMap configuration file (see
ocitysmap.conf-template):
ocitysmap = ocitysmap2.OCitySMap('/path/to/your/config')
The next step is to create a RenderingConfiguration, the object that
encapsulates all the information to parametize the rendering, including the
Mapnik stylesheet. You can retrieve the list of supported stylesheets (directly
as Stylesheet objects) with:
styles = ocitysmap.get_all_style_configurations()
Fill in your RenderingConfiguration with the map title, the OSM ID or bounding
box, the chosen map language, the Stylesheet object and the paper size (in
millimeters) and simply pass it to OCitySMap's render method:
ocitysmap.render(rendering_configuration, layout_name,
output_formats, prefix)
The layout name is the renderer's key name. You can get the list of all
supported renderers with ocitysmap.get_all_renderers(). The output_formats is a
list of output formats. For now, the following formats are supported:
* PNG at 72dpi
* PDF
* SVG
* SVGZ (gzipped-SVG)
* PS
The prefix is the filename prefix for all the rendered files. This is usually a
path to the destination's directory, eventually followed by some unique, yet
common prefix for the files rendered for a job.
"""
__author__ = 'The MapOSMatic developers'
__version__ = '0.2'
import cairo
import ConfigParser
import gzip
import logging
import os
import psycopg2
import re
import tempfile
import shapely
import shapely.wkt
import shapely.geometry
import coords
import i18n
from indexlib.indexer import StreetIndex
from indexlib.commons import IndexDoesNotFitError, IndexEmptyError
from layoutlib import PAPER_SIZES, renderers
import layoutlib.commons
LOG = logging.getLogger('ocitysmap')
class RenderingConfiguration:
"""
The RenderingConfiguration class encapsulate all the information concerning
a rendering request. This data is used by the layout renderer, in
conjonction with its rendering mode (defined by its implementation), to
produce the map.
"""
def __init__(self):
self.title = None # str
self.osmid = None # None / int (shading + city name)
self.bounding_box = None # bbox (from osmid if None)
self.language = None # str (locale)
self.stylesheet = None # Obj Stylesheet
self.paper_width_mm = None
self.paper_height_mm = None
# Setup by OCitySMap::render() from osmid and bounding_box fields:
self.polygon_wkt = None # str (WKT of interest)
# Setup by OCitySMap::render() from language field:
self.i18n = None # i18n object
class Stylesheet:
"""
A Stylesheet object defines how the map features will be rendered. It
contains information pointing to the Mapnik stylesheet and other styling
parameters.
"""
DEFAULT_ZOOM_LEVEL = 16
def __init__(self):
self.name = None # str
self.path = None # str
self.description = '' # str
self.grid_line_color = 'black'
self.grid_line_alpha = 0.5
self.grid_line_width = 1
self.shade_color = 'black'
self.shade_alpha = 0.1
# shade color for town contour in multi-pages
self.shade_color_2 = 'white'
self.shade_alpha_2 = 0.4
@staticmethod
def create_from_config_section(parser, section_name):
"""Creates a Stylesheet object from the OCitySMap configuration.
Args:
parser (ConfigParser.ConfigParser): the configuration parser
object.
section_name (string): the stylesheet section name in the
configuration.
"""
s = Stylesheet()
def assign_if_present(key, cast_fn=str):
if parser.has_option(section_name, key):
setattr(s, key, cast_fn(parser.get(section_name, key)))
s.name = parser.get(section_name, 'name')
s.path = parser.get(section_name, 'path')
assign_if_present('description')
assign_if_present('grid_line_color')
assign_if_present('grid_line_alpha', float)
assign_if_present('grid_line_width', int)
assign_if_present('shade_color')
assign_if_present('shade_alpha', float)
assign_if_present('shade_color_2')
assign_if_present('shade_alpha_2', float)
return s
@staticmethod
def create_all_from_config(parser):
styles = parser.get('rendering', 'available_stylesheets')
if not styles:
raise ValueError, \
'OCitySMap configuration does not contain any stylesheet!'
return [Stylesheet.create_from_config_section(parser, name.strip())
for name in styles.split(',')]
class OCitySMap:
"""
This is the main entry point of the OCitySMap map rendering engine. Read
this module's documentation for more details on its API.
"""
DEFAULT_REQUEST_TIMEOUT_MIN = 15
DEFAULT_RENDERING_PNG_DPI = 72
STYLESHEET_REGISTRY = []
def __init__(self, config_files=None):
"""Instanciate a new configured OCitySMap instance.
Args:
config_file (string or list or None): path, or list of paths to
the OCitySMap configuration file(s). If None, sensible defaults
are tried.
"""
if config_files is None:
config_files = ['/etc/ocitysmap.conf', '~/.ocitysmap.conf']
elif not isinstance(config_files, list):
config_files = [config_files]
config_files = map(os.path.expanduser, config_files)
LOG.info('Reading OCitySMap configuration from %s...' %
', '.join(config_files))
self._parser = ConfigParser.RawConfigParser()
if not self._parser.read(config_files):
raise IOError, 'None of the configuration files could be read!'
self._locale_path = os.path.join(os.path.dirname(__file__), '..', 'locale')
self.__db = None
# Read stylesheet configuration
self.STYLESHEET_REGISTRY = Stylesheet.create_all_from_config(self._parser)
LOG.debug('Found %d Mapnik stylesheets.'
% len(self.STYLESHEET_REGISTRY))
@property
def _db(self):
if self.__db:
return self.__db
# Database connection
datasource = dict(self._parser.items('datasource'))
# The port is not a mandatory configuration option, so make
# sure we define a default value.
if not datasource.has_key('port'):
datasource['port'] = 5432
LOG.info('Connecting to database %s on %s:%s as %s...' %
(datasource['dbname'], datasource['host'], datasource['port'],
datasource['user']))
db = psycopg2.connect(user=datasource['user'],
password=datasource['password'],
host=datasource['host'],
database=datasource['dbname'],
port=datasource['port'])
# Force everything to be unicode-encoded, in case we run along Django
# (which loads the unicode extensions for psycopg2)
db.set_client_encoding('utf8')
# Make sure the DB is correctly installed
self._verify_db(db)
try:
timeout = int(self._parser.get('datasource', 'request_timeout'))
except (ConfigParser.NoOptionError, ValueError):
timeout = OCitySMap.DEFAULT_REQUEST_TIMEOUT_MIN
self._set_request_timeout(db, timeout)
self.__db = db
return self.__db
def _verify_db(self, db):
"""Make sure the PostGIS DB is compatible with us."""
cursor = db.cursor()
cursor.execute("""
SELECT ST_AsText(ST_LongestLine(
'POINT(100 100)'::geometry,
'LINESTRING(20 80, 98 190, 110 180, 50 75 )'::geometry)
) As lline;
""")
assert cursor.fetchall()[0][0] == "LINESTRING(100 100,98 190)", \
LOG.fatal("PostGIS >= 1.5 required for correct operation !")
def _set_request_timeout(self, db, timeout_minutes=15):
"""Sets the PostgreSQL request timeout to avoid long-running queries on
the database."""
cursor = db.cursor()
cursor.execute('set session statement_timeout=%d;' %
(timeout_minutes * 60 * 1000))
cursor.execute('show statement_timeout;')
LOG.debug('Configured statement timeout: %s.' %
cursor.fetchall()[0][0])
def _cleanup_tempdir(self, tmpdir):
LOG.debug('Cleaning up %s...' % tmpdir)
for root, dirs, files in os.walk(tmpdir, topdown=False):
for name in files:
os.remove(os.path.join(root, name))
for name in dirs:
os.rmdir(os.path.join(root, name))
os.rmdir(tmpdir)
def _get_geographic_info(self, osmid, table):
"""Return the area for the given osm id in the given table, or raise
LookupError when not found
Args:
osmid (integer): OSM ID
table (str): either 'polygon' or 'line'
Return:
Geos geometry object
"""
# Ensure all OSM IDs are integers, bust cast them back to strings
# afterwards.
LOG.debug('Looking up bounding box and contour of OSM ID %d...'
% osmid)
cursor = self._db.cursor()
cursor.execute("""select
st_astext(st_transform(st_buildarea(st_union(way)),
4002))
from planet_osm_%s where osm_id = %d
group by osm_id;""" %
(table, osmid))
records = cursor.fetchall()
try:
((wkt,),) = records
except ValueError:
raise LookupError("OSM ID %d not found in table %s" %
(osmid, table))
return shapely.wkt.loads(wkt)
def get_geographic_info(self, osmid):
"""Return a tuple (WKT_envelope, WKT_buildarea) or raise
LookupError when not found
Args:
osmid (integer): OSM ID
Return:
tuple (WKT bbox, WKT area)
"""
found = False
# Scan polygon table:
try:
polygon_geom = self._get_geographic_info(osmid, 'polygon')
found = True
except LookupError:
polygon_geom = shapely.geometry.Polygon()
# Scan line table:
try:
line_geom = self._get_geographic_info(osmid, 'line')
found = True
except LookupError:
line_geom = shapely.geometry.Polygon()
# Merge results:
if not found:
raise LookupError("No such OSM id: %d" % osmid)
result = polygon_geom.union(line_geom)
return (result.envelope.wkt, result.wkt)
def get_osm_database_last_update(self):
cursor = self._db.cursor()
query = "select last_update from maposmatic_admin;"
try:
cursor.execute(query)
except psycopg2.ProgrammingError:
self._db.rollback()
return None
# Extract datetime object. It is located as the first element
# of a tuple, itself the first element of an array.
return cursor.fetchall()[0][0]
def get_all_style_configurations(self):
"""Returns the list of all available stylesheet configurations (list of
Stylesheet objects)."""
return self.STYLESHEET_REGISTRY
def get_stylesheet_by_name(self, name):
"""Returns a stylesheet by its key name."""
for style in self.STYLESHEET_REGISTRY:
if style.name == name:
return style
raise LookupError, 'The requested stylesheet %s was not found!' % name
def get_all_renderers(self):
"""Returns the list of all available layout renderers (list of
Renderer classes)."""
return renderers.get_renderers()
def get_all_paper_sizes(self):
return PAPER_SIZES
def render(self, config, renderer_name, output_formats, file_prefix):
"""Renders a job with the given rendering configuration, using the
provided renderer, to the given output formats.
Args:
config (RenderingConfiguration): the rendering configuration
object.
renderer_name (string): the layout renderer to use for this rendering.
output_formats (list): a list of output formats to render to, from
the list of supported output formats (pdf, svgz, etc.).
file_prefix (string): filename prefix for all output files.
"""
assert config.osmid or config.bounding_box, \
'At least an OSM ID or a bounding box must be provided!'
output_formats = map(lambda x: x.lower(), output_formats)
config.i18n = i18n.install_translation(config.language,
self._locale_path)
LOG.info('Rendering with renderer %s in language: %s (rtl: %s).' %
(renderer_name, config.i18n.language_code(),
config.i18n.isrtl()))
# Determine bounding box and WKT of interest
if config.osmid:
osmid_bbox, osmid_area \
= self.get_geographic_info(config.osmid)
# Define the bbox if not already defined
if not config.bounding_box:
config.bounding_box \
= coords.BoundingBox.parse_wkt(osmid_bbox)
# Update the polygon WKT of interest
config.polygon_wkt = osmid_area
else:
# No OSM ID provided => use specified bbox
config.polygon_wkt = config.bounding_box.as_wkt()
# Make sure we have a bounding box
assert config.bounding_box is not None
assert config.polygon_wkt is not None
osm_date = self.get_osm_database_last_update()
# Create a temporary directory for all our shape files
tmpdir = tempfile.mkdtemp(prefix='ocitysmap')
try:
LOG.debug('Rendering in temporary directory %s' % tmpdir)
# Prepare the generic renderer
renderer_cls = renderers.get_renderer_class_by_name(renderer_name)
# Perform the actual rendering to the Cairo devices
for output_format in output_formats:
output_filename = '%s.%s' % (file_prefix, output_format)
try:
self._render_one(config, tmpdir, renderer_cls,
output_format, output_filename, osm_date,
file_prefix)
except IndexDoesNotFitError:
LOG.exception("The actual font metrics probably don't "
"match those pre-computed by the renderer's"
"constructor. Backtrace follows...")
finally:
self._cleanup_tempdir(tmpdir)
def _render_one(self, config, tmpdir, renderer_cls,
output_format, output_filename, osm_date, file_prefix):
LOG.info('Rendering to %s format...' % output_format.upper())
factory = None
dpi = layoutlib.commons.PT_PER_INCH
if output_format == 'png':
try:
dpi = int(self._parser.get('rendering', 'png_dpi'))
except ConfigParser.NoOptionError:
dpi = OCitySMap.DEFAULT_RENDERING_PNG_DPI
# As strange as it may seem, we HAVE to use a vector
# device here and not a raster device such as
# ImageSurface. Because, for some reason, with
# ImageSurface, the font metrics would NOT match those
# pre-computed by renderer_cls.__init__() and used to
# layout the whole page
def factory(w,h):
w_px = int(layoutlib.commons.convert_pt_to_dots(w, dpi))
h_px = int(layoutlib.commons.convert_pt_to_dots(h, dpi))
LOG.debug("Rendering PNG into %dpx x %dpx area..."
% (w_px, h_px))
return cairo.PDFSurface(None, w_px, h_px)
elif output_format == 'svg':
factory = lambda w,h: cairo.SVGSurface(output_filename, w, h)
elif output_format == 'svgz':
factory = lambda w,h: cairo.SVGSurface(
gzip.GzipFile(output_filename, 'wb'), w, h)
elif output_format == 'pdf':
factory = lambda w,h: cairo.PDFSurface(output_filename, w, h)
elif output_format == 'ps':
factory = lambda w,h: cairo.PSSurface(output_filename, w, h)
elif output_format == 'ps.gz':
factory = lambda w,h: cairo.PSSurface(
gzip.GzipFile(output_filename, 'wb'), w, h)
elif output_format == 'csv':
# We don't render maps into CSV.
return
else:
raise ValueError, \
'Unsupported output format: %s!' % output_format.upper()
renderer = renderer_cls(self._db, config, tmpdir, dpi, file_prefix)
surface = factory(renderer.paper_width_pt, renderer.paper_height_pt)
renderer.render(surface, dpi, osm_date)
LOG.debug('Writing %s...' % output_filename)
if output_format == 'png':
surface.write_to_png(output_filename)
surface.finish()
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
o = OCitySMap([os.path.join(os.path.dirname(__file__), '..',
'ocitysmap.conf.mine')])
c = RenderingConfiguration()
c.title = 'Chevreuse, Yvelines, Île-de-France, France, Europe, Monde'
c.osmid = -943886 # Chevreuse
# c.osmid = -7444 # Paris
c.language = 'fr_FR.UTF-8'
c.paper_width_mm = 297
c.paper_height_mm = 420
c.stylesheet = o.get_stylesheet_by_name('Default')
# c.paper_width_mm,c.paper_height_mm = c.paper_height_mm,c.paper_width_mm
o.render(c, 'single_page_index_bottom',
['png', 'pdf', 'ps.gz', 'svgz', 'csv'],
'/tmp/mymap_index_bottom')
c.paper_width_mm,c.paper_height_mm = c.paper_height_mm,c.paper_width_mm
o.render(c, 'single_page_index_side',
['png', 'pdf', 'ps.gz', 'svgz', 'csv'],
'/tmp/mymap_index_side')
o.render(c, 'plain',
['png', 'pdf', 'ps.gz', 'svgz', 'csv'],
'/tmp/mymap_plain')