From bf37d530bc03e9d15d227940c1c32ef4dbe7da65 Mon Sep 17 00:00:00 2001 From: Malcolm Brooks Date: Tue, 10 Jan 2017 15:26:17 +0000 Subject: [PATCH] Add a user/site specified background image directory (#815) * Added code to include user defined background image library. --- .../data/raster/natural_earth/images.json | 7 + lib/cartopy/mpl/geoaxes.py | 190 +++++++++++++++++- lib/cartopy/tests/mpl/test_images.py | 6 + 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 lib/cartopy/data/raster/natural_earth/images.json diff --git a/lib/cartopy/data/raster/natural_earth/images.json b/lib/cartopy/data/raster/natural_earth/images.json new file mode 100644 index 000000000..57d672d30 --- /dev/null +++ b/lib/cartopy/data/raster/natural_earth/images.json @@ -0,0 +1,7 @@ +{"__comment__": "JSON file specifying the image to use for a given type/name and resolution. Read in by cartopy.mpl.geoaxes.read_user_background_images.", + "ne_shaded": { + "__comment__": "Natural Earth shaded relief", + "__source__": "http://www.naturalearthdata.com/downloads/50m-raster-data/50m-natural-earth-1/", + "__projection__": "PlateCarree", + "low": "50-natural-earth-1-downsampled.png" } +} diff --git a/lib/cartopy/mpl/geoaxes.py b/lib/cartopy/mpl/geoaxes.py index 682e18db3..454ec1a64 100644 --- a/lib/cartopy/mpl/geoaxes.py +++ b/lib/cartopy/mpl/geoaxes.py @@ -1,4 +1,4 @@ -# (C) British Crown Copyright 2011 - 2016, Met Office +# (C) British Crown Copyright 2011 - 2017, Met Office # # This file is part of cartopy. # @@ -68,6 +68,18 @@ """ +_BACKG_IMG_CACHE = {} +""" +A dictionary of pre-loaded images for large background images, kept as a +dictionary so that large images are loaded only once. +""" + +_USER_BG_IMGS = {} +""" +A dictionary of background images in the directory specified by the +CARTOPY_USER_BACKGROUNDS environment variable. +""" + # XXX call this InterCRSTransform class InterProjectionTransform(mtransforms.Transform): @@ -784,6 +796,182 @@ def stock_img(self, name='ne_shaded'): else: raise ValueError('Unknown stock image %r.' % name) + def background_img(self, name='ne_shaded', resolution='low', extent=None, + cache=False): + """ + Adds a background image to the map, from a selection of pre-prepared + images held in a directory specified by the CARTOPY_USER_BACKGROUNDS + environment variable. That directory is checked with + func:`self.read_user_background_images` and needs to contain a JSON + file which defines for the image metadata. + + Kwargs: + + * name - the name of the image to read according to the contents + of the JSON file. A typical file might have, for instance: + 'ne_shaded' : Natural Earth Shaded Relief + 'ne_grey' : Natural Earth Grey Earth + + * resolution - the resolution of the image to read, according to + the contents of the JSON file. A typical file might + have the following for each name of the image: + 'low', 'med', 'high', 'vhigh', 'full'. + + * extent - using a high resolution background image, zoomed into + a small area, will take a very long time to render as + the image is prepared globally, even though only a small + area is used. Adding the extent will only render a + particular geographic region. Specified as + [longitude start, longitude end, + latitude start, latitude end]. + + e.g. [-11, 3, 48, 60] for the UK + or [167.0, 193.0, 47.0, 68.0] to cross the date line. + + * cache - logical flag as to whether or not to cache the loaded + images into memory. The images are stored before the + extent is used. + """ + # read in the user's background image directory: + if len(_USER_BG_IMGS) == 0: + self.read_user_background_images() + import os + bgdir = os.getenv('CARTOPY_USER_BACKGROUNDS') + if bgdir is None: + bgdir = os.path.join(config["repo_data_dir"], + 'raster', 'natural_earth') + # now get the filename we want to use: + try: + fname = _USER_BG_IMGS[name][resolution] + except KeyError: + msg = ('Image "{}" and resolution "{}" are not present in ' + 'the user background image metadata in directory "{}"') + raise ValueError(msg.format(name, resolution, bgdir)) + # Now obtain the image data from file or cache: + fpath = os.path.join(bgdir, fname) + if cache: + if fname in _BACKG_IMG_CACHE: + img = _BACKG_IMG_CACHE[fname] + else: + img = imread(fpath) + _BACKG_IMG_CACHE[fname] = img + else: + img = imread(fpath) + if len(img.shape) == 2: + # greyscale images are only 2-dimensional, so need replicating + # to 3 colour channels: + img = np.repeat(img[:, :, np.newaxis], 3, axis=2) + # now get the projection from the metadata: + if _USER_BG_IMGS[name]['__projection__'] == 'PlateCarree': + # currently only PlateCarree is defined: + source_proj = ccrs.PlateCarree() + else: + raise NotImplementedError('Background image projection undefined') + + if extent is None: + # not specifying an extent, so return all of it: + return self.imshow(img, origin='upper', + transform=source_proj, + extent=[-180, 180, -90, 90]) + else: + # return only a subset of the image: + # set up coordinate arrays: + d_lat = 180.0 / img.shape[0] + d_lon = 360.0 / img.shape[1] + # latitude starts at 90N for this image: + lat_pts = (np.arange(img.shape[0]) * -d_lat - (d_lat / 2.0)) + 90.0 + lon_pts = (np.arange(img.shape[1]) * d_lon + (d_lon / 2.0)) - 180.0 + + # which points are in range: + lat_in_range = np.logical_and(lat_pts >= extent[2], + lat_pts <= extent[3]) + if extent[0] < 180 and extent[1] > 180: + # we have a region crossing the dateline + # this is the westerly side of the input image: + lon_in_range1 = np.logical_and(lon_pts >= extent[0], + lon_pts <= 180.0) + img_subset1 = img[lat_in_range, :, :][:, lon_in_range1, :] + # and the eastward half: + lon_in_range2 = lon_pts + 360. <= extent[1] + img_subset2 = img[lat_in_range, :, :][:, lon_in_range2, :] + # now join them up: + img_subset = np.concatenate((img_subset1, img_subset2), axis=1) + # now define the extent for output that matches those points: + ret_extent = [lon_pts[lon_in_range1][0] - d_lon / 2.0, + lon_pts[lon_in_range2][-1] + d_lon / 2.0 + 360, + lat_pts[lat_in_range][-1] - d_lat / 2.0, + lat_pts[lat_in_range][0] + d_lat / 2.0] + else: + # not crossing the dateline, so just find the region: + lon_in_range = np.logical_and(lon_pts >= extent[0], + lon_pts <= extent[1]) + img_subset = img[lat_in_range, :, :][:, lon_in_range, :] + # now define the extent for output that matches those points: + ret_extent = [lon_pts[lon_in_range][0] - d_lon / 2.0, + lon_pts[lon_in_range][-1] + d_lon / 2.0, + lat_pts[lat_in_range][-1] - d_lat / 2.0, + lat_pts[lat_in_range][0] + d_lat / 2.0] + + return self.imshow(img_subset, origin='upper', + transform=source_proj, + extent=ret_extent) + + def read_user_background_images(self, verify=True): + """ + Reads the metadata in the specified CARTOPY_USER_BACKGROUNDS + environment variable to populate the dictionaries for background_img. + + If CARTOPY_USER_BACKGROUNDS is not set then by default the image in + lib/cartopy/data/raster/natural_earth/ will be made available. + + The metadata should be a standard JSON file which specifies a two + level dictionary. The first level is the image type. + For each image type there must be the fields: + __comment__, __source__ and __projection__ + and then an element giving the filename for each resolution. + + An example JSON file can be found at: + lib/cartopy/data/raster/natural_earth/images.json + + """ + import os + import json + + bgdir = os.getenv('CARTOPY_USER_BACKGROUNDS') + if bgdir is None: + bgdir = os.path.join(config["repo_data_dir"], + 'raster', 'natural_earth') + json_file = os.path.join(bgdir, 'images.json') + + with open(json_file, 'r') as js_obj: + dict_in = json.load(js_obj) + for img_type in dict_in: + _USER_BG_IMGS[img_type] = dict_in[img_type] + + if verify: + required_info = ['__comment__', '__source__', '__projection__'] + for img_type in _USER_BG_IMGS: + if img_type == '__comment__': + # the top level comment doesn't need verifying: + pass + else: + # check that this image type has the required info: + for required in required_info: + if required not in _USER_BG_IMGS[img_type]: + msg = ('User background metadata file "{}", ' + 'image type "{}", does not specify ' + 'metadata item "{}"') + raise ValueError(msg.format(json_file, img_type, + required)) + for resln in _USER_BG_IMGS[img_type]: + # the required_info items are not resolutions: + if resln not in required_info: + img_it_r = _USER_BG_IMGS[img_type][resln] + test_file = os.path.join(bgdir, img_it_r) + if not os.path.isfile(test_file): + msg = 'File "{}" not found' + raise ValueError(msg.format(test_file)) + def add_raster(self, raster_source, **slippy_image_kwargs): """ Add the given raster source to the GeoAxes. diff --git a/lib/cartopy/tests/mpl/test_images.py b/lib/cartopy/tests/mpl/test_images.py index ba6c63445..afc9a9abe 100644 --- a/lib/cartopy/tests/mpl/test_images.py +++ b/lib/cartopy/tests/mpl/test_images.py @@ -143,6 +143,12 @@ def test_pil_Image(): extent=[-180, 180, -90, 90]) +@ImageTesting(['imshow_natural_earth_ortho'], tolerance=0.7) +def test_background_img(): + ax = plt.axes(projection=ccrs.Orthographic()) + ax.background_img(name='ne_shaded', resolution='low') + + if __name__ == '__main__': import nose nose.runmodule(argv=['-sv', '--with-doctest'], exit=False)