505 lines
15 KiB
Python
505 lines
15 KiB
Python
import base64
|
|
import collections
|
|
import copy
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import struct
|
|
import tempfile
|
|
import uuid
|
|
import zlib
|
|
from contextlib import contextmanager
|
|
from urllib.parse import urlparse, uses_netloc, uses_params, uses_relative
|
|
|
|
import numpy as np
|
|
|
|
try:
|
|
import pandas as pd
|
|
except ImportError:
|
|
pd = None
|
|
|
|
|
|
_VALID_URLS = set(uses_relative + uses_netloc + uses_params)
|
|
_VALID_URLS.discard("")
|
|
_VALID_URLS.add("data")
|
|
|
|
|
|
def validate_location(location): # noqa: C901
|
|
"""Validate a single lat/lon coordinate pair and convert to a list
|
|
|
|
Validate that location:
|
|
* is a sized variable
|
|
* with size 2
|
|
* allows indexing (i.e. has an ordering)
|
|
* where both values are floats (or convertible to float)
|
|
* and both values are not NaN
|
|
|
|
Returns
|
|
-------
|
|
list[float, float]
|
|
|
|
"""
|
|
if isinstance(location, np.ndarray) or (
|
|
pd is not None and isinstance(location, pd.DataFrame)
|
|
):
|
|
location = np.squeeze(location).tolist()
|
|
if not hasattr(location, "__len__"):
|
|
raise TypeError(
|
|
"Location should be a sized variable, "
|
|
"for example a list or a tuple, instead got "
|
|
"{!r} of type {}.".format(location, type(location))
|
|
)
|
|
if len(location) != 2:
|
|
raise ValueError(
|
|
"Expected two (lat, lon) values for location, "
|
|
"instead got: {!r}.".format(location)
|
|
)
|
|
try:
|
|
coords = (location[0], location[1])
|
|
except (TypeError, KeyError):
|
|
raise TypeError(
|
|
"Location should support indexing, like a list or "
|
|
"a tuple does, instead got {!r} of type {}.".format(
|
|
location, type(location)
|
|
)
|
|
)
|
|
for coord in coords:
|
|
try:
|
|
float(coord)
|
|
except (TypeError, ValueError):
|
|
raise ValueError(
|
|
"Location should consist of two numerical values, "
|
|
"but {!r} of type {} is not convertible to float.".format(
|
|
coord, type(coord)
|
|
)
|
|
)
|
|
if math.isnan(float(coord)):
|
|
raise ValueError("Location values cannot contain NaNs.")
|
|
return [float(x) for x in coords]
|
|
|
|
|
|
def validate_locations(locations):
|
|
"""Validate an iterable with multiple lat/lon coordinate pairs.
|
|
|
|
Returns
|
|
-------
|
|
list[list[float, float]] or list[list[list[float, float]]]
|
|
|
|
"""
|
|
locations = if_pandas_df_convert_to_numpy(locations)
|
|
try:
|
|
iter(locations)
|
|
except TypeError:
|
|
raise TypeError(
|
|
"Locations should be an iterable with coordinate pairs,"
|
|
" but instead got {!r}.".format(locations)
|
|
)
|
|
try:
|
|
next(iter(locations))
|
|
except StopIteration:
|
|
raise ValueError("Locations is empty.")
|
|
try:
|
|
float(next(iter(next(iter(next(iter(locations)))))))
|
|
except (TypeError, StopIteration):
|
|
# locations is a list of coordinate pairs
|
|
return [validate_location(coord_pair) for coord_pair in locations]
|
|
else:
|
|
# locations is a list of a list of coordinate pairs, recurse
|
|
return [validate_locations(lst) for lst in locations]
|
|
|
|
|
|
def if_pandas_df_convert_to_numpy(obj):
|
|
"""Return a Numpy array from a Pandas dataframe.
|
|
|
|
Iterating over a DataFrame has weird side effects, such as the first
|
|
row being the column names. Converting to Numpy is more safe.
|
|
"""
|
|
if pd is not None and isinstance(obj, pd.DataFrame):
|
|
return obj.values
|
|
else:
|
|
return obj
|
|
|
|
|
|
def image_to_url(image, colormap=None, origin="upper"):
|
|
"""
|
|
Infers the type of an image argument and transforms it into a URL.
|
|
|
|
Parameters
|
|
----------
|
|
image: string, file or array-like object
|
|
* If string, it will be written directly in the output file.
|
|
* If file, it's content will be converted as embedded in the
|
|
output file.
|
|
* If array-like, it will be converted to PNG base64 string and
|
|
embedded in the output.
|
|
origin: ['upper' | 'lower'], optional, default 'upper'
|
|
Place the [0, 0] index of the array in the upper left or
|
|
lower left corner of the axes.
|
|
colormap: callable, used only for `mono` image.
|
|
Function of the form [x -> (r,g,b)] or [x -> (r,g,b,a)]
|
|
for transforming a mono image into RGB.
|
|
It must output iterables of length 3 or 4, with values between
|
|
0. and 1. You can use colormaps from `matplotlib.cm`.
|
|
|
|
"""
|
|
if isinstance(image, str) and not _is_url(image):
|
|
fileformat = os.path.splitext(image)[-1][1:]
|
|
with open(image, "rb") as f:
|
|
img = f.read()
|
|
b64encoded = base64.b64encode(img).decode("utf-8")
|
|
url = f"data:image/{fileformat};base64,{b64encoded}"
|
|
elif "ndarray" in image.__class__.__name__:
|
|
img = write_png(image, origin=origin, colormap=colormap)
|
|
b64encoded = base64.b64encode(img).decode("utf-8")
|
|
url = f"data:image/png;base64,{b64encoded}"
|
|
else:
|
|
# Round-trip to ensure a nice formatted json.
|
|
url = json.loads(json.dumps(image))
|
|
return url.replace("\n", " ")
|
|
|
|
|
|
def _is_url(url):
|
|
"""Check to see if `url` has a valid protocol."""
|
|
try:
|
|
return urlparse(url).scheme in _VALID_URLS
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def write_png(data, origin="upper", colormap=None):
|
|
"""
|
|
Transform an array of data into a PNG string.
|
|
This can be written 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," + png_str.encode("base64")
|
|
|
|
Inspired from
|
|
https://stackoverflow.com/questions/902761/saving-a-numpy-array-as-an-image
|
|
|
|
Parameters
|
|
----------
|
|
data: numpy array or equivalent list-like object.
|
|
Must be NxM (mono), NxMx3 (RGB) or NxMx4 (RGBA)
|
|
|
|
origin : ['upper' | 'lower'], optional, default 'upper'
|
|
Place the [0,0] index of the array in the upper left or lower left
|
|
corner of the axes.
|
|
|
|
colormap : callable, used only for `mono` image.
|
|
Function of the form [x -> (r,g,b)] or [x -> (r,g,b,a)]
|
|
for transforming a mono image into RGB.
|
|
It must output iterables of length 3 or 4, with values between
|
|
0. and 1. Hint: you can use colormaps from `matplotlib.cm`.
|
|
|
|
Returns
|
|
-------
|
|
PNG formatted byte string
|
|
|
|
"""
|
|
if colormap is None:
|
|
|
|
def colormap(x):
|
|
return (x, x, x, 1)
|
|
|
|
arr = np.atleast_3d(data)
|
|
height, width, nblayers = arr.shape
|
|
|
|
if nblayers not in [1, 3, 4]:
|
|
raise ValueError("Data must be NxM (mono), NxMx3 (RGB), or NxMx4 (RGBA)")
|
|
assert arr.shape == (height, width, nblayers)
|
|
|
|
if nblayers == 1:
|
|
arr = np.array(list(map(colormap, arr.ravel())))
|
|
nblayers = arr.shape[1]
|
|
if nblayers not in [3, 4]:
|
|
raise ValueError(
|
|
"colormap must provide colors of length 3 (RGB) or 4 (RGBA)"
|
|
)
|
|
arr = arr.reshape((height, width, nblayers))
|
|
assert arr.shape == (height, width, nblayers)
|
|
|
|
if nblayers == 3:
|
|
arr = np.concatenate((arr, np.ones((height, width, 1))), axis=2)
|
|
nblayers = 4
|
|
assert arr.shape == (height, width, nblayers)
|
|
assert nblayers == 4
|
|
|
|
# Normalize to uint8 if it isn't already.
|
|
if arr.dtype != "uint8":
|
|
with np.errstate(divide="ignore", invalid="ignore"):
|
|
arr = arr * 255.0 / arr.max(axis=(0, 1)).reshape((1, 1, 4))
|
|
arr[~np.isfinite(arr)] = 0
|
|
arr = arr.astype("uint8")
|
|
|
|
# Eventually flip the image.
|
|
if origin == "lower":
|
|
arr = arr[::-1, :, :]
|
|
|
|
# Transform the array to bytes.
|
|
raw_data = b"".join([b"\x00" + arr[i, :, :].tobytes() for i in range(height)])
|
|
|
|
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""),
|
|
]
|
|
)
|
|
|
|
|
|
def mercator_transform(data, lat_bounds, origin="upper", height_out=None):
|
|
"""
|
|
Transforms an image computed in (longitude,latitude) coordinates into
|
|
the a Mercator projection image.
|
|
|
|
Parameters
|
|
----------
|
|
|
|
data: numpy array or equivalent list-like object.
|
|
Must be NxM (mono), NxMx3 (RGB) or NxMx4 (RGBA)
|
|
|
|
lat_bounds : length 2 tuple
|
|
Minimal and maximal value of the latitude of the image.
|
|
Bounds must be between -85.051128779806589 and 85.051128779806589
|
|
otherwise they will be clipped to that values.
|
|
|
|
origin : ['upper' | 'lower'], optional, default 'upper'
|
|
Place the [0,0] index of the array in the upper left or lower left
|
|
corner of the axes.
|
|
|
|
height_out : int, default None
|
|
The expected height of the output.
|
|
If None, the height of the input is used.
|
|
|
|
See https://en.wikipedia.org/wiki/Web_Mercator for more details.
|
|
|
|
"""
|
|
import numpy as np
|
|
|
|
def mercator(x):
|
|
return np.arcsinh(np.tan(x * np.pi / 180.0)) * 180.0 / np.pi
|
|
|
|
array = np.atleast_3d(data).copy()
|
|
height, width, nblayers = array.shape
|
|
|
|
lat_min = max(lat_bounds[0], -85.051128779806589)
|
|
lat_max = min(lat_bounds[1], 85.051128779806589)
|
|
if height_out is None:
|
|
height_out = height
|
|
|
|
# Eventually flip the image
|
|
if origin == "upper":
|
|
array = array[::-1, :, :]
|
|
|
|
lats = lat_min + np.linspace(0.5 / height, 1.0 - 0.5 / height, height) * (
|
|
lat_max - lat_min
|
|
)
|
|
latslats = mercator(lat_min) + np.linspace(
|
|
0.5 / height_out, 1.0 - 0.5 / height_out, height_out
|
|
) * (mercator(lat_max) - mercator(lat_min))
|
|
|
|
out = np.zeros((height_out, width, nblayers))
|
|
for i in range(width):
|
|
for j in range(nblayers):
|
|
out[:, i, j] = np.interp(latslats, mercator(lats), array[:, i, j])
|
|
|
|
# Eventually flip the image.
|
|
if origin == "upper":
|
|
out = out[::-1, :, :]
|
|
return out
|
|
|
|
|
|
def none_min(x, y):
|
|
if x is None:
|
|
return y
|
|
elif y is None:
|
|
return x
|
|
else:
|
|
return min(x, y)
|
|
|
|
|
|
def none_max(x, y):
|
|
if x is None:
|
|
return y
|
|
elif y is None:
|
|
return x
|
|
else:
|
|
return max(x, y)
|
|
|
|
|
|
def iter_coords(obj):
|
|
"""
|
|
Returns all the coordinate tuples from a geometry or feature.
|
|
|
|
"""
|
|
if isinstance(obj, (tuple, list)):
|
|
coords = obj
|
|
elif "features" in obj:
|
|
coords = [geom["geometry"]["coordinates"] for geom in obj["features"]]
|
|
elif "geometry" in obj:
|
|
coords = obj["geometry"]["coordinates"]
|
|
elif "geometries" in obj and "coordinates" in obj["geometries"][0]:
|
|
coords = obj["geometries"][0]["coordinates"]
|
|
else:
|
|
coords = obj.get("coordinates", obj)
|
|
for coord in coords:
|
|
if isinstance(coord, (float, int)):
|
|
yield tuple(coords)
|
|
break
|
|
else:
|
|
yield from iter_coords(coord)
|
|
|
|
|
|
def _locations_mirror(x):
|
|
"""
|
|
Mirrors the points in a list-of-list-of-...-of-list-of-points.
|
|
For example:
|
|
>>> _locations_mirror([[[1, 2], [3, 4]], [5, 6], [7, 8]])
|
|
[[[2, 1], [4, 3]], [6, 5], [8, 7]]
|
|
|
|
"""
|
|
if hasattr(x, "__iter__"):
|
|
if hasattr(x[0], "__iter__"):
|
|
return list(map(_locations_mirror, x))
|
|
else:
|
|
return list(x[::-1])
|
|
else:
|
|
return x
|
|
|
|
|
|
def get_bounds(locations, lonlat=False):
|
|
"""
|
|
Computes the bounds of the object in the form
|
|
[[lat_min, lon_min], [lat_max, lon_max]]
|
|
|
|
"""
|
|
bounds = [[None, None], [None, None]]
|
|
for point in iter_coords(locations):
|
|
bounds = [
|
|
[
|
|
none_min(bounds[0][0], point[0]),
|
|
none_min(bounds[0][1], point[1]),
|
|
],
|
|
[
|
|
none_max(bounds[1][0], point[0]),
|
|
none_max(bounds[1][1], point[1]),
|
|
],
|
|
]
|
|
if lonlat:
|
|
bounds = _locations_mirror(bounds)
|
|
return bounds
|
|
|
|
|
|
def camelize(key):
|
|
"""Convert a python_style_variable_name to lowerCamelCase.
|
|
|
|
Examples
|
|
--------
|
|
>>> camelize("variable_name")
|
|
'variableName'
|
|
>>> camelize("variableName")
|
|
'variableName'
|
|
"""
|
|
return "".join(x.capitalize() if i > 0 else x for i, x in enumerate(key.split("_")))
|
|
|
|
|
|
def _parse_size(value):
|
|
try:
|
|
if isinstance(value, (int, float)):
|
|
value_type = "px"
|
|
value = float(value)
|
|
assert value > 0
|
|
else:
|
|
value_type = "%"
|
|
value = float(value.strip("%"))
|
|
assert 0 <= value <= 100
|
|
except Exception:
|
|
msg = "Cannot parse value {!r} as {!r}".format
|
|
raise ValueError(msg(value, value_type))
|
|
return value, value_type
|
|
|
|
|
|
def compare_rendered(obj1, obj2):
|
|
"""
|
|
Return True/False if the normalized rendered version of
|
|
two folium map objects are the equal or not.
|
|
|
|
"""
|
|
return normalize(obj1) == normalize(obj2)
|
|
|
|
|
|
def normalize(rendered):
|
|
"""Return the input string without non-functional spaces or newlines."""
|
|
out = "".join([line.strip() for line in rendered.splitlines() if line.strip()])
|
|
out = out.replace(", ", ",")
|
|
return out
|
|
|
|
|
|
@contextmanager
|
|
def temp_html_filepath(data):
|
|
"""Yields the path of a temporary HTML file containing data."""
|
|
filepath = ""
|
|
try:
|
|
fid, filepath = tempfile.mkstemp(suffix=".html", prefix="folium_")
|
|
os.write(fid, data.encode("utf8") if isinstance(data, str) else data)
|
|
os.close(fid)
|
|
yield filepath
|
|
finally:
|
|
if os.path.isfile(filepath):
|
|
os.remove(filepath)
|
|
|
|
|
|
def deep_copy(item_original):
|
|
"""Return a recursive deep-copy of item where each copy has a new ID."""
|
|
item = copy.copy(item_original)
|
|
item._id = uuid.uuid4().hex
|
|
if hasattr(item, "_children") and len(item._children) > 0:
|
|
children_new = collections.OrderedDict()
|
|
for subitem_original in item._children.values():
|
|
subitem = deep_copy(subitem_original)
|
|
subitem._parent = item
|
|
children_new[subitem.get_name()] = subitem
|
|
item._children = children_new
|
|
return item
|
|
|
|
|
|
def get_obj_in_upper_tree(element, cls):
|
|
"""Return the first object in the parent tree of class `cls`."""
|
|
if not hasattr(element, "_parent"):
|
|
raise ValueError(f"The top of the tree was reached without finding a {cls}")
|
|
parent = element._parent
|
|
if not isinstance(parent, cls):
|
|
return get_obj_in_upper_tree(parent, cls)
|
|
return parent
|
|
|
|
|
|
def parse_options(**kwargs):
|
|
"""Return a dict with lower-camelcase keys and non-None values.."""
|
|
return {camelize(key): value for key, value in kwargs.items() if value is not None}
|
|
|
|
|
|
def escape_backticks(text):
|
|
"""Escape backticks so text can be used in a JS template."""
|
|
return re.sub(r"(?<!\\)`", r"\`", text)
|
|
|
|
|
|
def escape_double_quotes(text):
|
|
return text.replace('"', r"\"")
|
|
|
|
|
|
def javascript_identifier_path_to_array_notation(path):
|
|
"""Convert a path like obj1.obj2 to array notation: ["obj1"]["obj2"]."""
|
|
return "".join(f'["{escape_double_quotes(x)}"]' for x in path.split("."))
|