Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add image overlay #152

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 4 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ before_install:
- wget http://bit.ly/miniconda -O miniconda.sh
- bash miniconda.sh -b -p $HOME/miniconda
- export PATH="$HOME/miniconda/bin:$PATH"
- hash -r
- conda config --set always_yes yes
- conda update --yes conda
- travis_retry conda create --yes -n test $CONDA pip jinja2 pandas mock six nose
- conda info -a
- travis_retry conda create -n test $CONDA pip jinja2 pandas mock six nose
- source activate test
- travis_retry pip install vincent

install:
- python setup.py install

Expand Down
1 change: 1 addition & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Added cartodb positron and dark_matter tiles (ocefpaf d4daee7)
- Forcing HTTPS when available. (ocefpaf c69ac89)
- Added Stamen Watercolor tiles. (ocefpaf 8c1f837)
- Added Image Overlay. (andrewgiessel ---)

Bug Fixes

Expand Down
101 changes: 92 additions & 9 deletions folium/folium.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from folium.six import text_type, binary_type, iteritems

import sys

import base64

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

Expand Down Expand Up @@ -221,6 +221,7 @@ def __init__(self, location=None, width='100%', height='100%',
self.added_layers = []
self.template_vars.setdefault('wms_layers', [])
self.template_vars.setdefault('tile_layers', [])
self.template_vars.setdefault('image_layers', [])

@iter_obj('simple')
def add_tile_layer(self, tile_name=None, tile_url=None, active=False):
Expand Down Expand Up @@ -281,18 +282,14 @@ def add_layers_to_map(self):
data_string = ''
for i, layer in enumerate(self.added_layers):
name = list(layer.keys())[0]
data_string += '\"'
data_string += name
data_string += '\"'
data_string += ': '
data_string += name
if i < len(self.added_layers)-1:
data_string += ",\n"
term_string = ",\n"
else:
data_string += "\n"
term_string += "\n"
data_string += '\"{}\": {}'.format(name, name, term_string)

data_layers = layers_temp.render({'layers': data_string})
self.template_vars.setdefault('data_layers', []).append((data_string))
self.template_vars.setdefault('data_layers', []).append((data_layers))

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

@iter_obj('image_overlay')
def image_overlay(self, data, opacity=0.25, min_lat=-90.0, max_lat=90.0,
min_lon=-180.0, max_lon=180.0, image_name=None, filename=None):
"""Simple image overlay of raster data from a numpy array. This is a lightweight
way to overlay geospatial data on top of a map. If your data is high res, consider
implementing a WMS server and adding a WMS layer.

This function works by generating a PNG file from a numpy array. If you do not
specifiy a filename, it will embed the image inline. Otherwise, it saves the file in the
current directory, and then adds it as an image overlay layer in leaflet.js.
By default, the image is placed and stretched using bounds that cover the
entire globe.

Parameters
----------
data: numpy array OR url string, required.
if numpy array, must be a image format, i.e., NxM (mono), NxMx3 (rgb), or NxMx4 (rgba)
if url, must be a valid url to a image (local or external)
opacity: float, default 0.25
Image layer opacity in range 0 (completely transparent) to 1 (opaque)
min_lat: float, default -90.0
max_lat: float, default 90.0
min_lon: float, default -180.0
max_lon: float, default 180.0
image_name: string, default None
The name of the layer object in leaflet.js
filename: string, default None
Optional file name of output.png for image overlay. If None, we use a
inline PNG.

Output
------
Image overlay data layer in obj.template_vars

Examples
-------
# assumes a map object `m` has been created
>>> import numpy as np
>>> data = np.random.random((100,100))

# to make a rgba from a specific matplotlib colormap:
>>> import matplotlib.cm as cm
>>> cmapper = cm.cm.ColorMapper('jet')
>>> data2 = cmapper.to_rgba(np.random.random((100,100)))

# place the data over all of the globe (will be pretty pixelated!)
>>> m.image_overlay(data)

# put it only over a single city (Paris)
>>> m.image_overlay(data, min_lat=48.80418, max_lat=48.90970, min_lon=2.25214, max_lon=2.44731)

"""

if isinstance(data, str):
filename = data
else:
try:
png_str = utilities.write_png(data)
except Exception as e:
raise e

if filename is not None:
with open(filename, 'wb') as fd:
fd.write(png_str)
else:
filename = "data:image/png;base64,"+base64.b64encode(png_str).decode('utf-8')

if image_name not in self.added_layers:
if image_name is None:
image_name = "Image_Overlay"
else:
image_name = image_name.replace(" ", "_")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have to do this more that I would like.... maybe it is time for a normalization function... do not worry about this in this PR.

image_url = filename
image_bounds = [[min_lat, min_lon], [max_lat, max_lon]]
image_opacity = opacity

image_temp = self.env.get_template('image_layer.js')

image = image_temp.render({'image_name': image_name,
'image_url': image_url,
'image_bounds': image_bounds,
'image_opacity': image_opacity})

self.template_vars['image_layers'].append(image)
self.added_layers.append(image_name)

def _build_map(self, html_templ=None, templ_type='string'):
self._auto_bounds()
"""Build HTML/JS/CSS from Templates given current map type."""
Expand Down
7 changes: 7 additions & 0 deletions folium/templates/fol_template.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@

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

/*
addition of the image layers
*/
{% for image in image_layers %}
{{ image }}
{% endfor %}

//cluster group
var clusteredmarkers = L.markerClusterGroup();
//section for adding clustered markers
Expand Down
1 change: 1 addition & 0 deletions folium/templates/image_layer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
var {{ image_name }} = L.imageOverlay('{{ image_url }}', {{ image_bounds }}).addTo(map).setOpacity({{ image_opacity }});
75 changes: 75 additions & 0 deletions folium/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from __future__ import division
import math
from jinja2 import Environment, PackageLoader, Template
import struct, zlib

try:
import pandas as pd
Expand Down Expand Up @@ -270,3 +271,77 @@ def base(x):
# Some weirdness in series quantiles a la 0.13
arr = series.values
return [base(np.percentile(arr, x)) for x in quants]

def write_png(array):
"""Format a numpy array as a PNG byte string.
This can be writen to disk using binary I/O, or encoded using base64
for an inline png like this:

>>> png_str = write_png(array)
>>> "data:image/png;base64,"+base64.b64encode(png_str)

Taken from
http://stackoverflow.com/questions/902761/saving-a-numpy-array-as-an-image

Parameters
----------

array: numpy array
Must be NxM (mono), NxMx3 (rgb) or NxMx4 (rgba)

Returns
-------
PNG formatted byte string
"""
import numpy as np

array = np.atleast_3d(array)
if array.shape[2] not in [1, 3, 4]:
raise ValueError("Data must be NxM (mono), " + \
"NxMx3 (rgb), or NxMx4 (rgba)")

# have to broadcast up into a full rgba array
array_full = np.empty((array.shape[0], array.shape[1], 4))
# NxM -> NxMx4
if array.shape[2] == 1:
array_full[:,:,0] = array[:,:,0]
array_full[:,:,1] = array[:,:,0]
array_full[:,:,2] = array[:,:,0]
array_full[:,:,3] = 1
# NxMx3 -> NxMx4
elif array.shape[2] == 3:
array_full[:,:,0] = array[:,:,0]
array_full[:,:,1] = array[:,:,1]
array_full[:,:,2] = array[:,:,2]
array_full[:,:,3] = 1
# NxMx4 -> keep
else:
array_full = array

# normalize to uint8 if it isn't already
if array_full.dtype != 'uint8':
for component in range(4):
frame = array_full[:,:,component]
array_full[:,:,component] = (frame / frame.max() * 255)
array_full = array_full.astype('uint8')
width, height = array_full.shape[:2]

array_full = array_full.tobytes()

# reverse the vertical line order and add null bytes at the start
width_byte_4 = width * 4
raw_data = b''.join(b'\x00' + array_full[span:span + width_byte_4]
for span in range((height - 1) * width * 4, -1, - width_byte_4))

def png_pack(png_tag, data):
chunk_head = png_tag + data
return (struct.pack("!I", len(data)) +
chunk_head +
struct.pack("!I", 0xFFFFFFFF & zlib.crc32(chunk_head)))

return b''.join([
b'\x89PNG\r\n\x1a\n',
png_pack(b'IHDR', struct.pack("!2I5B", width, height, 8, 6, 0, 0, 0)),
png_pack(b'IDAT', zlib.compress(raw_data, 9)),
png_pack(b'IEND', b'')])

45 changes: 43 additions & 2 deletions tests/folium_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def test_init(self):
'zoom_level': 4,
'tile_layers': [],
'wms_layers': [],
'image_layers': [],
'min_zoom': 1,
'min_lat': -90,
'max_lat': 90,
Expand Down Expand Up @@ -473,8 +474,48 @@ def test_fit_bounds(self):
fit_bounds_rendered = fit_bounds_tpl.render({
'bounds': json.dumps(bounds),
'fit_bounds_options': json.dumps({'maxZoom': 15,
'padding': (3, 3), }),
}, sort_keys=True)
'padding': (3, 3), }, sort_keys=True),
})

self.map.fit_bounds(bounds, max_zoom=15, padding=(3, 3))

assert self.map.template_vars['fit_bounds'] == fit_bounds_rendered

def test_image_overlay(self):
"""Test image overlay"""
from numpy.random import random
from folium.utilities import write_png
import base64

data = random((100,100))
png_str = write_png(data)
with open('data.png', 'wb') as f:
f.write(png_str)
inline_image_url = "data:image/png;base64,"+base64.b64encode(png_str).decode('utf-8')

image_tpl = self.env.get_template('image_layer.js')
image_name = 'Image_Overlay'
image_opacity = 0.25
image_url = 'data.png'
min_lon, max_lon, min_lat, max_lat = -90.0, 90.0, -180.0, 180.0
image_bounds = [[min_lon, min_lat], [max_lon, max_lat]]

image_rendered = image_tpl.render({'image_name': image_name,
'image_url': image_url,
'image_bounds': image_bounds,
'image_opacity': image_opacity
})

self.map.image_overlay(data, filename=image_url)
assert image_rendered in self.map.template_vars['image_layers']


image_rendered = image_tpl.render({'image_name': image_name,
'image_url': inline_image_url,
'image_bounds': image_bounds,
'image_opacity': image_opacity
})

self.map.image_overlay(data)
assert image_rendered in self.map.template_vars['image_layers']