Skip to content

Commit b625613

Browse files
committed
Add image_overlay()
1 parent c1d9849 commit b625613

File tree

7 files changed

+223
-13
lines changed

7 files changed

+223
-13
lines changed

.travis.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ before_install:
1111
- wget http://bit.ly/miniconda -O miniconda.sh
1212
- bash miniconda.sh -b -p $HOME/miniconda
1313
- export PATH="$HOME/miniconda/bin:$PATH"
14+
- hash -r
15+
- conda config --set always_yes yes
1416
- conda update --yes conda
15-
- travis_retry conda create --yes -n test $CONDA pip jinja2 pandas mock six nose
17+
- conda info -a
18+
- travis_retry conda create -n test $CONDA pip jinja2 pandas mock six nose
1619
- source activate test
1720
- travis_retry pip install vincent
18-
1921
install:
2022
- python setup.py install
2123

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- Added cartodb positron and dark_matter tiles (ocefpaf d4daee7)
77
- Forcing HTTPS when available. (ocefpaf c69ac89)
88
- Added Stamen Watercolor tiles. (ocefpaf 8c1f837)
9+
- Added Image Overlay. (andrewgiessel ---)
910

1011
Bug Fixes
1112

folium/folium.py

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from folium.six import text_type, binary_type, iteritems
2424

2525
import sys
26-
26+
import base64
2727

2828
ENV = Environment(loader=PackageLoader('folium', 'templates'))
2929

@@ -221,6 +221,7 @@ def __init__(self, location=None, width='100%', height='100%',
221221
self.added_layers = []
222222
self.template_vars.setdefault('wms_layers', [])
223223
self.template_vars.setdefault('tile_layers', [])
224+
self.template_vars.setdefault('image_layers', [])
224225

225226
@iter_obj('simple')
226227
def add_tile_layer(self, tile_name=None, tile_url=None, active=False):
@@ -281,18 +282,14 @@ def add_layers_to_map(self):
281282
data_string = ''
282283
for i, layer in enumerate(self.added_layers):
283284
name = list(layer.keys())[0]
284-
data_string += '\"'
285-
data_string += name
286-
data_string += '\"'
287-
data_string += ': '
288-
data_string += name
289285
if i < len(self.added_layers)-1:
290-
data_string += ",\n"
286+
term_string = ",\n"
291287
else:
292-
data_string += "\n"
288+
term_string += "\n"
289+
data_string += '\"{}\": {}'.format(name, name, term_string)
293290

294291
data_layers = layers_temp.render({'layers': data_string})
295-
self.template_vars.setdefault('data_layers', []).append((data_string))
292+
self.template_vars.setdefault('data_layers', []).append((data_layers))
296293

297294
@iter_obj('simple')
298295
def simple_marker(self, location=None, popup=None,
@@ -953,6 +950,92 @@ def json_style(style_cnt, line_color, line_weight, line_opacity,
953950
self.template_vars.setdefault('geo_styles', []).append(style)
954951
self.template_vars.setdefault('gjson_layers', []).append(layer)
955952

953+
@iter_obj('image_overlay')
954+
def image_overlay(self, data, opacity=0.25, min_lat=-90.0, max_lat=90.0,
955+
min_lon=-180.0, max_lon=180.0, image_name=None, filename=None):
956+
"""Simple image overlay of raster data from a numpy array. This is a lightweight
957+
way to overlay geospatial data on top of a map. If your data is high res, consider
958+
implementing a WMS server and adding a WMS layer.
959+
960+
This function works by generating a PNG file from a numpy array. If you do not
961+
specifiy a filename, it will embed the image inline. Otherwise, it saves the file in the
962+
current directory, and then adds it as an image overlay layer in leaflet.js.
963+
By default, the image is placed and stretched using bounds that cover the
964+
entire globe.
965+
966+
Parameters
967+
----------
968+
data: numpy array OR url string, required.
969+
if numpy array, must be a image format, i.e., NxM (mono), NxMx3 (rgb), or NxMx4 (rgba)
970+
if url, must be a valid url to a image (local or external)
971+
opacity: float, default 0.25
972+
Image layer opacity in range 0 (completely transparent) to 1 (opaque)
973+
min_lat: float, default -90.0
974+
max_lat: float, default 90.0
975+
min_lon: float, default -180.0
976+
max_lon: float, default 180.0
977+
image_name: string, default None
978+
The name of the layer object in leaflet.js
979+
filename: string, default None
980+
Optional file name of output.png for image overlay. If None, we use a
981+
inline PNG.
982+
983+
Output
984+
------
985+
Image overlay data layer in obj.template_vars
986+
987+
Examples
988+
-------
989+
# assumes a map object `m` has been created
990+
>>> import numpy as np
991+
>>> data = np.random.random((100,100))
992+
993+
# to make a rgba from a specific matplotlib colormap:
994+
>>> import matplotlib.cm as cm
995+
>>> cmapper = cm.cm.ColorMapper('jet')
996+
>>> data2 = cmapper.to_rgba(np.random.random((100,100)))
997+
998+
# place the data over all of the globe (will be pretty pixelated!)
999+
>>> m.image_overlay(data)
1000+
1001+
# put it only over a single city (Paris)
1002+
>>> m.image_overlay(data, min_lat=48.80418, max_lat=48.90970, min_lon=2.25214, max_lon=2.44731)
1003+
1004+
"""
1005+
1006+
if isinstance(data, str):
1007+
filename = data
1008+
else:
1009+
try:
1010+
png_str = utilities.write_png(data)
1011+
except Exception as e:
1012+
raise e
1013+
1014+
if filename is not None:
1015+
with open(filename, 'wb') as fd:
1016+
fd.write(png_str)
1017+
else:
1018+
filename = "data:image/png;base64,"+base64.b64encode(png_str).decode('utf-8')
1019+
1020+
if image_name not in self.added_layers:
1021+
if image_name is None:
1022+
image_name = "Image_Overlay"
1023+
else:
1024+
image_name = image_name.replace(" ", "_")
1025+
image_url = filename
1026+
image_bounds = [[min_lat, min_lon], [max_lat, max_lon]]
1027+
image_opacity = opacity
1028+
1029+
image_temp = self.env.get_template('image_layer.js')
1030+
1031+
image = image_temp.render({'image_name': image_name,
1032+
'image_url': image_url,
1033+
'image_bounds': image_bounds,
1034+
'image_opacity': image_opacity})
1035+
1036+
self.template_vars['image_layers'].append(image)
1037+
self.added_layers.append(image_name)
1038+
9561039
def _build_map(self, html_templ=None, templ_type='string'):
9571040
self._auto_bounds()
9581041
"""Build HTML/JS/CSS from Templates given current map type."""

folium/templates/fol_template.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@
109109

110110
L.control.layers(baseLayer, layer_list).addTo(map);
111111

112+
/*
113+
addition of the image layers
114+
*/
115+
{% for image in image_layers %}
116+
{{ image }}
117+
{% endfor %}
118+
112119
//cluster group
113120
var clusteredmarkers = L.markerClusterGroup();
114121
//section for adding clustered markers

folium/templates/image_layer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
var {{ image_name }} = L.imageOverlay('{{ image_url }}', {{ image_bounds }}).addTo(map).setOpacity({{ image_opacity }});

folium/utilities.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from __future__ import division
1313
import math
1414
from jinja2 import Environment, PackageLoader, Template
15+
import struct, zlib
1516

1617
try:
1718
import pandas as pd
@@ -270,3 +271,77 @@ def base(x):
270271
# Some weirdness in series quantiles a la 0.13
271272
arr = series.values
272273
return [base(np.percentile(arr, x)) for x in quants]
274+
275+
def write_png(array):
276+
"""Format a numpy array as a PNG byte string.
277+
This can be writen to disk using binary I/O, or encoded using base64
278+
for an inline png like this:
279+
280+
>>> png_str = write_png(array)
281+
>>> "data:image/png;base64,"+base64.b64encode(png_str)
282+
283+
Taken from
284+
http://stackoverflow.com/questions/902761/saving-a-numpy-array-as-an-image
285+
286+
Parameters
287+
----------
288+
289+
array: numpy array
290+
Must be NxM (mono), NxMx3 (rgb) or NxMx4 (rgba)
291+
292+
Returns
293+
-------
294+
PNG formatted byte string
295+
"""
296+
import numpy as np
297+
298+
array = np.atleast_3d(array)
299+
if array.shape[2] not in [1, 3, 4]:
300+
raise ValueError("Data must be NxM (mono), " + \
301+
"NxMx3 (rgb), or NxMx4 (rgba)")
302+
303+
# have to broadcast up into a full rgba array
304+
array_full = np.empty((array.shape[0], array.shape[1], 4))
305+
# NxM -> NxMx4
306+
if array.shape[2] == 1:
307+
array_full[:,:,0] = array[:,:,0]
308+
array_full[:,:,1] = array[:,:,0]
309+
array_full[:,:,2] = array[:,:,0]
310+
array_full[:,:,3] = 1
311+
# NxMx3 -> NxMx4
312+
elif array.shape[2] == 3:
313+
array_full[:,:,0] = array[:,:,0]
314+
array_full[:,:,1] = array[:,:,1]
315+
array_full[:,:,2] = array[:,:,2]
316+
array_full[:,:,3] = 1
317+
# NxMx4 -> keep
318+
else:
319+
array_full = array
320+
321+
# normalize to uint8 if it isn't already
322+
if array_full.dtype != 'uint8':
323+
for component in range(4):
324+
frame = array_full[:,:,component]
325+
array_full[:,:,component] = (frame / frame.max() * 255)
326+
array_full = array_full.astype('uint8')
327+
width, height = array_full.shape[:2]
328+
329+
array_full = array_full.tobytes()
330+
331+
# reverse the vertical line order and add null bytes at the start
332+
width_byte_4 = width * 4
333+
raw_data = b''.join(b'\x00' + array_full[span:span + width_byte_4]
334+
for span in range((height - 1) * width * 4, -1, - width_byte_4))
335+
336+
def png_pack(png_tag, data):
337+
chunk_head = png_tag + data
338+
return (struct.pack("!I", len(data)) +
339+
chunk_head +
340+
struct.pack("!I", 0xFFFFFFFF & zlib.crc32(chunk_head)))
341+
342+
return b''.join([
343+
b'\x89PNG\r\n\x1a\n',
344+
png_pack(b'IHDR', struct.pack("!2I5B", width, height, 8, 6, 0, 0, 0)),
345+
png_pack(b'IDAT', zlib.compress(raw_data, 9)),
346+
png_pack(b'IEND', b'')])
347+

tests/folium_tests.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def test_init(self):
6969
'zoom_level': 4,
7070
'tile_layers': [],
7171
'wms_layers': [],
72+
'image_layers': [],
7273
'min_zoom': 1,
7374
'min_lat': -90,
7475
'max_lat': 90,
@@ -473,8 +474,48 @@ def test_fit_bounds(self):
473474
fit_bounds_rendered = fit_bounds_tpl.render({
474475
'bounds': json.dumps(bounds),
475476
'fit_bounds_options': json.dumps({'maxZoom': 15,
476-
'padding': (3, 3), }),
477-
}, sort_keys=True)
477+
'padding': (3, 3), }, sort_keys=True),
478+
})
478479

479480
self.map.fit_bounds(bounds, max_zoom=15, padding=(3, 3))
481+
480482
assert self.map.template_vars['fit_bounds'] == fit_bounds_rendered
483+
484+
def test_image_overlay(self):
485+
"""Test image overlay"""
486+
from numpy.random import random
487+
from folium.utilities import write_png
488+
import base64
489+
490+
data = random((100,100))
491+
png_str = write_png(data)
492+
with open('data.png', 'wb') as f:
493+
f.write(png_str)
494+
inline_image_url = "data:image/png;base64,"+base64.b64encode(png_str).decode('utf-8')
495+
496+
image_tpl = self.env.get_template('image_layer.js')
497+
image_name = 'Image_Overlay'
498+
image_opacity = 0.25
499+
image_url = 'data.png'
500+
min_lon, max_lon, min_lat, max_lat = -90.0, 90.0, -180.0, 180.0
501+
image_bounds = [[min_lon, min_lat], [max_lon, max_lat]]
502+
503+
image_rendered = image_tpl.render({'image_name': image_name,
504+
'image_url': image_url,
505+
'image_bounds': image_bounds,
506+
'image_opacity': image_opacity
507+
})
508+
509+
self.map.image_overlay(data, filename=image_url)
510+
assert image_rendered in self.map.template_vars['image_layers']
511+
512+
513+
image_rendered = image_tpl.render({'image_name': image_name,
514+
'image_url': inline_image_url,
515+
'image_bounds': image_bounds,
516+
'image_opacity': image_opacity
517+
})
518+
519+
self.map.image_overlay(data)
520+
assert image_rendered in self.map.template_vars['image_layers']
521+

0 commit comments

Comments
 (0)