diff --git a/.travis.yml b/.travis.yml index db59da4..dbcbf03 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,6 @@ env: global: # Set defaults to avoid repeating in most cases - PYTHON_VERSION=$TRAVIS_PYTHON_VERSION - - NUMPY_VERSION=stable - MAIN_CMD='python setup.py' - CONDA_DEPENDENCIES='sphinx pillow pyproj coveralls coverage mock aggdraw six pyshp pyresample' - PIP_DEPENDENCIES='' @@ -29,9 +28,16 @@ matrix: - env: PYTHON_VERSION=3.6 os: windows language: bash + - env: PYTHON_VERSION=3.7 + os: linux + - env: PYTHON_VERSION=3.7 + os: osx + - env: PYTHON_VERSION=3.7 + os: windows + language: bash install: -- git clone --depth 1 git://github.com/astropy/ci-helpers.git -- source ci-helpers/travis/setup_conda.sh + - git clone --depth 1 git://github.com/astropy/ci-helpers.git + - source ci-helpers/travis/setup_conda.sh script: coverage run --source=pycoast setup.py test after_success: - if [[ $PYTHON_VERSION == 3.6 ]]; then coveralls; fi diff --git a/docs/source/config.rst b/docs/source/config.rst index 79b004f..59ec017 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -1,7 +1,7 @@ Pycoast from a configuration file --------------------------------- -If you want to run to avoid typing the same options over and over again, of if +If you want to run to avoid typing the same options over and over again, or if caching is an optimization you want, you can use a configuration file with the pycoast options you need: @@ -9,13 +9,14 @@ pycoast options you need: [cache] file=/var/run/satellit/white_overlay - + regenerate=False + [coasts] level=1 width=0.75 outline=white fill=yellow - + [borders] outline=white width=0.5 diff --git a/pycoast/__init__.py b/pycoast/__init__.py index 6cae0fb..e0db1e1 100644 --- a/pycoast/__init__.py +++ b/pycoast/__init__.py @@ -1,12 +1,12 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from .version import get_versions -__version__ = get_versions()['version'] -del get_versions from .cw_pil import ContourWriterPIL from .cw_agg import ContourWriterAGG - +from pycoast.cw_base import get_resolution_from_area +from .version import get_versions +__version__ = get_versions()['version'] +del get_versions class ContourWriter(ContourWriterPIL): """Writer wrapper for deprecation warning. @@ -21,4 +21,4 @@ def __init__(self, *args, **kwargs): import warnings warnings.warn("'ContourWriter' has been deprecated please use " "'ContourWriterPIL' or 'ContourWriterAGG' instead", DeprecationWarning) - super(ContourWriter, self).__init__(*args, **kwargs) \ No newline at end of file + super(ContourWriter, self).__init__(*args, **kwargs) diff --git a/pycoast/cw_base.py b/pycoast/cw_base.py index f4b8c67..ac12d77 100644 --- a/pycoast/cw_base.py +++ b/pycoast/cw_base.py @@ -23,6 +23,7 @@ from PIL import Image import pyproj import logging +import ast try: import configparser @@ -32,6 +33,37 @@ logger = logging.getLogger(__name__) +def get_resolution_from_area(area_def): + """Get the best resolution for an area definition.""" + x_size = area_def.width + y_size = area_def.height + prj = Proj(area_def.proj_str) + if prj.is_latlong(): + x_ll, y_ll = prj(area_def.area_extent[0], area_def.area_extent[1]) + x_ur, y_ur = prj(area_def.area_extent[2], area_def.area_extent[3]) + x_resolution = (x_ur - x_ll) / x_size + y_resolution = (y_ur - y_ll) / y_size + else: + x_resolution = ((area_def.area_extent[2] - + area_def.area_extent[0]) / + x_size) + y_resolution = ((area_def.area_extent[3] - + area_def.area_extent[1]) / + y_size) + res = min(x_resolution, y_resolution) + + if res > 25000: + return "c" + elif res > 5000: + return "l" + elif res > 1000: + return "i" + elif res > 200: + return "h" + else: + return "f" + + class Proj(pyproj.Proj): """Wrapper around pyproj to add in 'is_latlong'.""" @@ -168,7 +200,7 @@ def _add_grid(self, image, area_def, """ try: - proj4_string = area_def.proj4_string + proj4_string = area_def.proj_str area_extent = area_def.area_extent except AttributeError: proj4_string = area_def[0] @@ -512,7 +544,7 @@ def add_shapes(self, image, area_def, shapes, feature_type=None, x_offset=0, y_o """ try: - proj4_string = area_def.proj4_string + proj4_string = area_def.proj_str area_extent = area_def.area_extent except AttributeError: proj4_string = area_def[0] @@ -644,22 +676,12 @@ def _iterate_db(self, db_name, tag, resolution, level, zero_pad, db_root_path=No yield shape def _finalize(self, draw): - """Do any need finalization of the drawing - """ + """Do any need finalization of the drawing.""" pass - def add_overlay_from_config(self, config_file, area_def): - """Create and return a transparent image adding all the overlays contained in a configuration file. - - :Parameters: - config_file : str - Configuration file name - area_def : object - Area Definition of the creating image - - """ - + def _config_to_dict(self, config_file): + """Convert a config file to a dict.""" config = configparser.ConfigParser() try: with open(config_file, 'r'): @@ -673,56 +695,79 @@ def add_overlay_from_config(self, config_file, area_def): logger.error("Error in %s", str(config_file)) raise + SECTIONS = ['cache', 'coasts', 'rivers', 'borders', 'cities', 'grid'] + overlays = {} + for section in config.sections(): + if section in SECTIONS: + overlays[section] = {} + for option in config.options(section): + val = config.get(section, option) + try: + overlays[section][option] = ast.literal_eval(val) + except ValueError: + overlays[section][option] = val + return overlays + + def add_overlay_from_dict(self, overlays, area_def, cache_epoch=None, background=None): + """Create and return a transparent image adding all the overlays contained in the `overlays` dict. + + :Parameters: + overlays : dict + overlays configuration + area_def : object + Area Definition of the creating image + cache_epoch: seconds since epoch + The latest time allowed for cache the cache file. If the cache file is older than this (mtime), + the cache should be regenerated. + background: pillow image instance + The image on which to write the overlays on. If it's None (default), + a new image is created, otherwise the provide background is use + an change *in place*. + + + The keys in `overlays` that will be taken into account are: + cache, coasts, rivers, borders, cities, grid + + For all of them except `cache`, the items are the same as the corresponding + functions in pycoast, so refer to the docstrings of these functions + (add_coastlines, add_rivers, add_borders, add_grid, add_cities). + For cache, two parameters are configurable: `file` which specifies the directory + and the prefix of the file to save the caches decoration to + (for example /var/run/black_coasts_red_borders), and `regenerate` that can be + True or False (default) to force the overwriting of an already cached file. + + """ + # Cache management cache_file = None - if config.has_section('cache'): - cache_file = (config.get('cache', 'file') + '_' + + if 'cache' in overlays: + cache_file = (overlays['cache']['file'] + '_' + area_def.area_id + '.png') try: - configTime = os.path.getmtime(config_file) - cacheTime = os.path.getmtime(cache_file) + config_time = cache_epoch + cache_time = os.path.getmtime(cache_file) # Cache file will be used only if it's newer than config file - if configTime < cacheTime: + if ((config_time is not None and config_time < cache_time) + and not overlays['cache'].get('regenerate', False)): foreground = Image.open(cache_file) logger.info('Using image in cache %s', cache_file) + if background is not None: + background.paste(foreground, mask=foreground.split()[-1]) return foreground else: - logger.info("Cache file is not used " - "because config file has changed") + logger.info("Regenerating cache file.") except OSError: - logger.info("New overlay image will be saved in cache") - - x_size = area_def.x_size - y_size = area_def.y_size - foreground = Image.new('RGBA', (x_size, y_size), (0, 0, 0, 0)) - - # Lines (coasts, rivers, borders) management - prj = Proj(area_def.proj4_string) - if prj.is_latlong(): - x_ll, y_ll = prj(area_def.area_extent[0], area_def.area_extent[1]) - x_ur, y_ur = prj(area_def.area_extent[2], area_def.area_extent[3]) - x_resolution = (x_ur - x_ll) / x_size - y_resolution = (y_ur - y_ll) / y_size - else: - x_resolution = ((area_def.area_extent[2] - - area_def.area_extent[0]) / - x_size) - y_resolution = ((area_def.area_extent[3] - - area_def.area_extent[1]) / - y_size) - res = min(x_resolution, y_resolution) - - if res > 25000: - default_resolution = "c" - elif res > 5000: - default_resolution = "l" - elif res > 1000: - default_resolution = "i" - elif res > 200: - default_resolution = "h" + logger.info("No overlay image found, new overlay image will be saved in cache.") + + x_size = area_def.width + y_size = area_def.height + if cache_file is None and background is not None: + foreground = background else: - default_resolution = "f" + foreground = Image.new('RGBA', (x_size, y_size), (0, 0, 0, 0)) + + default_resolution = get_resolution_from_area(area_def) DEFAULT = {'level': 1, 'outline': 'white', @@ -734,18 +779,9 @@ def add_overlay_from_config(self, config_file, area_def): 'y_offset': 0, 'resolution': default_resolution} - SECTIONS = ['coasts', 'rivers', 'borders', 'cities', 'grid'] - overlays = {} - - for section in config.sections(): - if section in SECTIONS: - overlays[section] = {} - for option in config.options(section): - overlays[section][option] = config.get(section, option) - is_agg = self._draw_module == "AGG" - # Coasts + # Coasts, rivers, borders for section, fun in zip(['coasts', 'rivers', 'borders'], [self.add_coastlines, self.add_rivers, @@ -800,9 +836,10 @@ def add_overlay_from_config(self, config_file, area_def): lat_minor = float(overlays['grid'].get('lat_minor', 2.0)) font = overlays['grid'].get('font', None) font_size = int(overlays['grid'].get('font_size', 10)) - write_text = overlays['grid'].get('write_text', - 'true').lower() in \ - ['true', 'yes', '1'] + + write_text = overlays['grid'].get('write_text', True) + if isinstance(write_text, str): + write_text = write_text.lower() in ['true', 'yes', '1', 'on'] outline = overlays['grid'].get('outline', 'white') if isinstance(font, str): if is_agg: @@ -832,9 +869,23 @@ def add_overlay_from_config(self, config_file, area_def): foreground.save(cache_file) except IOError as e: logger.error("Can't save cache: %s", str(e)) - + if background is not None: + background.paste(foreground, mask=foreground.split()[-1]) return foreground + def add_overlay_from_config(self, config_file, area_def, background=None): + """Create and return a transparent image adding all the overlays contained in a configuration file. + + :Parameters: + config_file : str + Configuration file name + area_def : object + Area Definition of the creating image + + """ + overlays = self._config_to_dict(config_file) + return self.add_overlay_from_dict(overlays, area_def, os.path.getmtime(config_file), background) + def add_cities(self, image, area_def, citylist, font_file, font_size, ptsize, outline, box_outline, box_opacity, db_root_path=None): """Add cities (point and name) to a PIL image object @@ -846,7 +897,7 @@ def add_cities(self, image, area_def, citylist, font_file, font_size, raise ValueError("'db_root_path' must be specified to use this method") try: - proj4_string = area_def.proj4_string + proj4_string = area_def.proj_str area_extent = area_def.area_extent except AttributeError: proj4_string = area_def[0] diff --git a/pycoast/tests/contours_europe_alpha.png b/pycoast/tests/contours_europe_alpha.png new file mode 100644 index 0000000..366f100 Binary files /dev/null and b/pycoast/tests/contours_europe_alpha.png differ diff --git a/pycoast/tests/test_data/test_config.ini b/pycoast/tests/test_data/test_config.ini new file mode 100644 index 0000000..addb455 --- /dev/null +++ b/pycoast/tests/test_data/test_config.ini @@ -0,0 +1,12 @@ +[coasts] +level=4 +resolution=l + +[borders] +outline=(255, 0, 0) +resolution=c + +[rivers] +level=5 +outline=blue +resolution=c diff --git a/pycoast/tests/test_pycoast.py b/pycoast/tests/test_pycoast.py index 2aeac04..98ea288 100644 --- a/pycoast/tests/test_pycoast.py +++ b/pycoast/tests/test_pycoast.py @@ -22,6 +22,7 @@ import numpy as np from PIL import Image, ImageFont +import time def tmp(f): f.tmp = True @@ -718,11 +719,119 @@ def test_coastlines_convert_to_rgba_agg(self): self.assertTrue(image_mode == 'RGBA', 'Conversion to RGBA failed.') +class FakeAreaDef(): + """A fake area definition object.""" + + def __init__(self, proj4_string, area_extent, x_size, y_size): + self.proj_str = proj4_string + self.area_extent = area_extent + self.width = x_size + self.height = y_size + self.area_id = 'fakearea' + + +class TestFromConfig(TestPycoast): + """Test burning overlays from a config file.""" + + def test_foreground(self): + """Test generating a transparent foreground.""" + from pycoast import ContourWriterPIL + euro_img = Image.open(os.path.join(os.path.dirname(__file__), + 'contours_europe_alpha.png')) + euro_data = np.array(euro_img) + + # img = Image.new('RGB', (640, 480)) + proj4_string = \ + '+proj=stere +lon_0=8.00 +lat_0=50.00 +lat_ts=50.00 +ellps=WGS84' + area_extent = (-3363403.31, -2291879.85, 2630596.69, 2203620.1) + area_def = FakeAreaDef(proj4_string, area_extent, 640, 480) + cw = ContourWriterPIL(gshhs_root_dir) + config_file = os.path.join(os.path.dirname(__file__), 'test_data', 'test_config.ini') + img = cw.add_overlay_from_config(config_file, area_def) + + res = np.array(img) + self.assertTrue(fft_metric(euro_data, res), + 'Writing of contours failed') + + overlays = {'coasts': {'level': 4, 'resolution': 'l'}, + 'borders': {'outline': (255, 0, 0), 'resolution': 'c'}, + 'rivers': {'outline': 'blue', 'resolution': 'c', 'level': 5}} + + img = cw.add_overlay_from_dict(overlays, area_def) + res = np.array(img) + self.assertTrue(fft_metric(euro_data, res), + 'Writing of contours failed') + + def test_cache(self): + """Test generating a transparent foreground and cache it.""" + from pycoast import ContourWriterPIL + from tempfile import gettempdir + euro_img = Image.open(os.path.join(os.path.dirname(__file__), + 'contours_europe_alpha.png')) + euro_data = np.array(euro_img) + + # img = Image.new('RGB', (640, 480)) + proj4_string = \ + '+proj=stere +lon_0=8.00 +lat_0=50.00 +lat_ts=50.00 +ellps=WGS84' + area_extent = (-3363403.31, -2291879.85, 2630596.69, 2203620.1) + area_def = FakeAreaDef(proj4_string, area_extent, 640, 480) + cw = ContourWriterPIL(gshhs_root_dir) + + tmp = gettempdir() + + overlays = {'cache': {'file': os.path.join(tmp, 'pycoast_cache')}, + 'coasts': {'level': 4, 'resolution': 'l'}, + 'borders': {'outline': (255, 0, 0), 'resolution': 'c'}, + 'rivers': {'outline': 'blue', 'resolution': 'c', 'level': 5}} + + cache_filename = os.path.join(tmp, 'pycoast_cache_fakearea.png') + img = cw.add_overlay_from_dict(overlays, area_def) + res = np.array(img) + self.assertTrue(fft_metric(euro_data, res), + 'Writing of contours failed') + self.assertTrue(os.path.isfile(cache_filename)) + + current_time = time.time() + + img = cw.add_overlay_from_dict(overlays, area_def, current_time) + + mtime = os.path.getmtime(cache_filename) + + self.assertGreater(mtime, current_time) + self.assertTrue(fft_metric(euro_data, res), + 'Writing of contours failed') + + img = cw.add_overlay_from_dict(overlays, area_def, current_time) + + self.assertEqual(os.path.getmtime(cache_filename), mtime) + self.assertTrue(fft_metric(euro_data, res), + 'Writing of contours failed') + overlays['cache']['regenerate'] = True + img = cw.add_overlay_from_dict(overlays, area_def) + + self.assertNotEqual(os.path.getmtime(cache_filename), mtime) + self.assertTrue(fft_metric(euro_data, res), + 'Writing of contours failed') + os.remove(os.path.join(tmp, 'pycoast_cache_fakearea.png')) + + def test_get_resolution(self): + """Get the automagical resolution computation.""" + from pycoast import get_resolution_from_area + proj4_string = \ + '+proj=stere +lon_0=8.00 +lat_0=50.00 +lat_ts=50.00 +ellps=WGS84' + area_extent = (-3363403.31, -2291879.85, 2630596.69, 2203620.1) + area_def = FakeAreaDef(proj4_string, area_extent, 640, 480) + self.assertEqual(get_resolution_from_area(area_def), 'l') + area_def = FakeAreaDef(proj4_string, area_extent, 6400, 4800) + self.assertEqual(get_resolution_from_area(area_def), 'h') + + def suite(): loader = unittest.TestLoader() mysuite = unittest.TestSuite() mysuite.addTest(loader.loadTestsFromTestCase(TestPIL)) mysuite.addTest(loader.loadTestsFromTestCase(TestPILAGG)) + mysuite.addTest(loader.loadTestsFromTestCase(TestFromConfig)) return mysuite