# MODULE: grass.jupyter.seriesmap
#
# AUTHOR(S): Caitlin Haedrich <caitlin DOT haedrich AT gmail>
#
# PURPOSE: This module contains functions for visualizing series of rasters in
# Jupyter Notebooks
#
# COPYRIGHT: (C) 2022 Caitlin Haedrich, and by the GRASS Development Team
#
# This program is free software under the GNU General Public
# License (>=v2). Read the file COPYING that comes with GRASS
# for details.
"""Create and display visualizations for a series of rasters."""
import tempfile
import os
import weakref
import shutil
import grass.script as gs
from grass.grassdb.data import map_exists
from .map import Map
from .region import RegionManagerForSeries
from .utils import save_gif
[docs]class SeriesMap:
"""Creates visualizations from a series of rasters or vectors in Jupyter
Notebooks.
Basic usage::
>>> series = gj.SeriesMap(height = 500)
>>> series.add_rasters(["elevation_shade", "geology", "soils"])
>>> series.add_vectors(["streams", "streets", "viewpoints"])
>>> series.d_barscale()
>>> series.show() # Create Slider
>>> series.save("image.gif")
This class of grass.jupyter is experimental and under development. The API can
change at anytime.
"""
# pylint: disable=too-many-instance-attributes
# pylint: disable=duplicate-code
def __init__(
self,
width=None,
height=None,
env=None,
use_region=False,
saved_region=None,
):
"""Creates an instance of the SeriesMap visualizations class.
:param int width: width of map in pixels
:param int height: height of map in pixels
:param str env: environment
:param use_region: if True, use either current or provided saved region,
else derive region from rendered layers
:param saved_region: if name of saved_region is provided,
this region is then used for rendering
"""
# Copy Environment
if env:
self._env = env.copy()
else:
self._env = os.environ.copy()
self._series_length = None
self._base_layer_calls = []
self._calls = []
self._series_added = False
self._layers_rendered = False
self._layer_filename_dict = {}
self._names = []
self._width = width
self._height = height
# Create a temporary directory for our PNG images
# Resource managed by weakref.finalize.
self._tmpdir = (
# pylint: disable=consider-using-with
tempfile.TemporaryDirectory()
)
def cleanup(tmpdir):
tmpdir.cleanup()
weakref.finalize(self, cleanup, self._tmpdir)
# Handle Regions
self._region_manager = RegionManagerForSeries(
use_region=use_region,
saved_region=saved_region,
width=width,
height=height,
env=self._env,
)
[docs] def add_rasters(self, rasters, **kwargs):
"""
:param list rasters: list of raster layers to add to SeriesMap
"""
for raster in rasters:
if not map_exists(name=raster, element="raster"):
raise NameError(_("Could not find a raster named {}").format(raster))
# Update region to rasters if not use_region or saved_region
self._region_manager.set_region_from_rasters(rasters)
if self._series_added:
assert self._series_length == len(rasters), _(
"Number of vectors in series must match number of vectors"
)
for i in range(self._series_length):
kwargs["map"] = rasters[i]
self._calls[i].append(("d.rast", kwargs.copy()))
else:
self._series_length = len(rasters)
for raster in rasters:
kwargs["map"] = raster
self._calls.append([("d.rast", kwargs.copy())])
self._series_added = True
if not self._names:
self._names = rasters
self._layers_rendered = False
[docs] def add_vectors(self, vectors, **kwargs):
"""
:param list vectors: list of vector layers to add to SeriesMap
"""
for vector in vectors:
if not map_exists(name=vector, element="vector"):
raise NameError(_("Could not find a vector named {}").format(vector))
# Update region extent to vectors if not use_region or saved_region
self._region_manager.set_region_from_vectors(vectors)
if self._series_added:
assert self._series_length == len(vectors), _(
"Number of rasters in series must match number of vectors"
)
for i in range(self._series_length):
kwargs["map"] = vectors[i]
self._calls[i].append(("d.vect", kwargs.copy()))
else:
self._series_length = len(vectors)
for vector in vectors:
kwargs["map"] = vector
self._calls.append([("d.vect", kwargs.copy())])
self._series_added = True
if not self._names:
self._names = vectors
self._layers_rendered = False
def __getattr__(self, name):
"""
Parse attribute to GRASS display module. Attribute should be in
the form 'd_module_name'. For example, 'd.rast' is called with 'd_rast'.
"""
# Check to make sure format is correct
if not name.startswith("d_"):
raise AttributeError(_("Module must begin with 'd_'"))
# Reformat string
grass_module = name.replace("_", ".")
# Assert module exists
if not shutil.which(grass_module):
raise AttributeError(_("Cannot find GRASS module {}").format(grass_module))
# if this function is called, the images need to be rendered again
self._layers_rendered = False
def wrapper(**kwargs):
if not self._series_added:
self._base_layer_calls.append((grass_module, kwargs))
else:
for row in self._calls:
row.append((grass_module, kwargs))
return wrapper
[docs] def add_names(self, names):
"""Add list of names associated with layers.
Default will be names of first series added."""
assert self._series_length == len(names), _(
"Number of vectors in series must match number of vectors"
)
self._names = names
def _render_baselayers(self, img):
"""Add collected baselayers to Map instance"""
for grass_module, kwargs in self._base_layer_calls:
img.run(grass_module, **kwargs)
[docs] def render(self):
"""Renders image for each raster in series.
Save PNGs to temporary directory. Must be run before creating a visualization
(i.e. show or save).
"""
if not self._series_added:
raise RuntimeError(
"Cannot render series since none has been added."
"Use SeriesMap.add_rasters() or SeriesMap.add_vectors()"
)
# Make base image (background and baselayers)
# Random name needed to avoid potential conflict with layer names
random_name_base = gs.append_random("base", 8) + ".png"
base_file = os.path.join(self._tmpdir.name, random_name_base)
img = Map(
width=self._width,
height=self._height,
filename=base_file,
use_region=True,
env=self._env,
read_file=True,
)
# We have to call d_erase to ensure the file is created. If there are no
# base layers, then there is nothing to render in random_base_name
img.d_erase()
# Add baselayers
self._render_baselayers(img)
# Render each layer
for i in range(self._series_length):
# Create file
filename = os.path.join(self._tmpdir.name, f"{i}.png")
# Copying the base_file ensures that previous results are overwritten
shutil.copyfile(base_file, filename)
self._layer_filename_dict[i] = filename
# Render image
img = Map(
width=self._width,
height=self._height,
filename=filename,
use_region=True,
env=self._env,
read_file=True,
)
for grass_module, kwargs in self._calls[i]:
img.run(grass_module, **kwargs)
self._layers_rendered = True
[docs] def show(self, slider_width=None):
"""Create interactive timeline slider.
param str slider_width: width of datetime selection slider
The slider_width parameter sets the width of the slider in the output cell.
It should be formatted as a percentage (%) between 0 and 100 of the cell width
or in pixels (px). Values should be formatted as strings and include the "%"
or "px" suffix. For example, slider_width="80%" or slider_width="500px".
slider_width is passed to ipywidgets in ipywidgets.Layout(width=slider_width).
"""
# Lazy Imports
import ipywidgets as widgets # pylint: disable=import-outside-toplevel
# Render images if they have not been already
if not self._layers_rendered:
self.render()
# Set default slider width
if not slider_width:
slider_width = "70%"
# Create lookup table for slider
lookup = list(zip(self._names, range(self._series_length)))
# Datetime selection slider
slider = widgets.SelectionSlider(
options=lookup,
value=0,
disabled=False,
continuous_update=True,
orientation="horizontal",
readout=True,
layout=widgets.Layout(width=slider_width),
)
play = widgets.Play(
interval=500,
value=0,
min=0,
max=self._series_length - 1,
step=1,
description="Press play",
disabled=False,
)
out_img = widgets.Image(value=b"", format="png")
def change_slider(change):
slider.value = slider.options[change.new][1]
play.observe(change_slider, names="value")
# Display image associated with datetime
def change_image(index):
# Look up layer name for date
filename = self._layer_filename_dict[index]
with open(filename, "rb") as rfile:
out_img.value = rfile.read()
# Return interact widget with image and slider
widgets.interactive_output(change_image, {"index": slider})
layout = widgets.Layout(
width="100%", display="inline-flex", flex_flow="row wrap"
)
return widgets.HBox([play, slider, out_img], layout=layout)
[docs] def save(
self,
filename,
duration=500,
label=True,
font=None,
text_size=12,
text_color="gray",
):
"""
Creates a GIF animation of rendered layers.
Text color must be in a format accepted by PIL ImageColor module. For supported
formats, visit:
https://pillow.readthedocs.io/en/stable/reference/ImageColor.html#color-names
param str filename: name of output GIF file
param int duration: time to display each frame; milliseconds
param bool label: include label on each frame
param str font: font file
param int text_size: size of label text
param str text_color: color to use for the text
"""
# Render images if they have not been already
if not self._layers_rendered:
self.render()
tmp_files = []
for _, file in self._layer_filename_dict.items():
tmp_files.append(file)
save_gif(
tmp_files,
filename,
duration=duration,
label=label,
labels=self._names,
font=font,
text_size=text_size,
text_color=text_color,
)
# Display the GIF
return filename