Skip to content

Commit

Permalink
jupyter: Render 3D images with m.nviz.image (OSGeo#1831)
Browse files Browse the repository at this point in the history
* 3D is rendered using m.nviz.image.
* The interface to m.nviz.image is direct/basic, i.e., the parameters are just passed as is.
* There is several special parameters which are handled separately.
* The main function to pass the parameters is called render.
* 2D rendered is used to create 'overlays' such as legend.
* In Binder and other headless environments, PyVirtualDisplay is used to call m.nviz.image.
* Given the API of PyVirtualDisplay, the 3D renderer cannot support custom environments (i.e., it always uses os.environ).
* Supports older pyvirtualdisplay versions by inspecting to see if it already has manage_global_env parameter.
* Adds documentation to the grass.jupyter notebook.
* Add PIL/Pillow to CI (a sort of mandatory dependency for GUI and grass.imaging).
  • Loading branch information
wenzeslaus authored Sep 2, 2021
1 parent 2b9a3ec commit d666912
Show file tree
Hide file tree
Showing 6 changed files with 439 additions and 4 deletions.
6 changes: 4 additions & 2 deletions .github/workflows/apt.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ libopenblas-dev
libpdal-dev
libpng-dev
libproj-dev
pdal
proj-bin
libreadline-dev
libzstd-dev
pdal
proj-bin
python3-dateutil
python3-matplotlib
python3-numpy
python3-pil
python3-ply
python3-pyvirtualdisplay
python3-six
python3-termcolor
sqlite3
Expand Down
93 changes: 93 additions & 0 deletions doc/notebooks/grass_jupyter.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,99 @@
"source": [
"fig.save(filename=\"test_map.html\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## GRASS 3D Renderer\n",
"\n",
"The `Grass3dRenderer` class creates 3D visualizations as PNG images. The *m.nviz.image* module is used in the background and the function `render()` accepts parameters of this module.\n",
"The `Grass3dRenderer` objects have `overlay` attribute which can be used in the same way as `GrassRenderer` and 2D images on top of the 3D visualization.\n",
"To display the image, call `show()`."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"First, let's create the object:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"img = gj.Grass3dRenderer()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Now, render a 3D visualization of an elevation raster as a surface colored using, again, the elevation raster:"
],
"metadata": {}
},
{
"cell_type": "code",
"execution_count": null,
"source": [
"img.render(elevation_map=\"elevation\", color_map=\"elevation\", perspective=20)"
],
"outputs": [],
"metadata": {}
},
{
"cell_type": "markdown",
"source": [
"To add a raster legend on the image as an overlay using the 2D rendering capabilities accessible with `overlay.d_legend`:"
],
"metadata": {}
},
{
"cell_type": "code",
"execution_count": null,
"source": [
"img.overlay.d_legend(raster=\"elevation\", at=(60, 97, 87, 92))"
],
"outputs": [],
"metadata": {}
},
{
"cell_type": "markdown",
"source": [
"Finally, we show "
],
"metadata": {}
},
{
"cell_type": "code",
"execution_count": null,
"source": [
"img.show()"
],
"outputs": [],
"metadata": {}
},
{
"cell_type": "markdown",
"source": [
"Now, let's color the elevation surface using a landuse raster (note that the call to `render` removes the result of the previous `render` as well as the current overlays):"
],
"metadata": {}
},
{
"cell_type": "code",
"execution_count": null,
"source": [
"img.render(elevation_map=\"elevation\", color_map=\"landuse\", perspective=20)\n",
"img.show()"
],
"outputs": [],
"metadata": {}
}
],
"metadata": {
Expand Down
1 change: 1 addition & 0 deletions python/grass/jupyter/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ MODULES = \
setup \
display \
interact_display \
render3d \
utils

PYFILES := $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__)
Expand Down
5 changes: 3 additions & 2 deletions python/grass/jupyter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
For more information, visit https://trac.osgeo.org/grass/wiki/GSoC/2021/JupyterAndGRASS
"""

from .setup import *
from .interact_display import *
from .display import *
from .interact_display import *
from .render3d import *
from .setup import *
from .utils import *
204 changes: 204 additions & 0 deletions python/grass/jupyter/render3d.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# MODULE: grass.jupyter.display
#
# AUTHOR(S): Vaclav Petras <wenzeslaus gmail com>
#
# PURPOSE: This module contains functions for non-interactive display
# in Jupyter Notebooks
#
# COPYRIGHT: (C) 2021 Vaclav Petras, 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.

"""Render 3D visualizations"""

import os
import tempfile

import grass.script as gs
from grass.jupyter import GrassRenderer


class Grass3dRenderer:
"""Creates and displays 3D visualization using GRASS GIS 3D rendering engine NVIZ.
The 3D image is created using the *render* function which uses the *m.nviz.image*
module in the background. Additional images can be
placed on the image using the *overlay* attribute which is the 2D renderer, i.e.,
has interface of the *GrassRenderer* class.
Basic usage::
>>> img = Grass3dRenderer()
>>> img.render(elevation_map="elevation", color_map="elevation", perspective=20)
>>> img.overlay.d_legend(raster="elevation", at=(60, 97, 87, 92))
>>> img.show()
For the OpenGL rendering with *m.nviz.image* to work, a display (screen) is needed.
This is not guaranteed on headless systems such as continuous integration (CI) or
Binder service(s). This class uses Xvfb and PyVirtualDisplay to support rendering
in these environments.
"""

def __init__(
self,
width: int = 600,
height: int = 400,
filename: str = None,
mode: str = "fine",
resolution_fine: int = 1,
screen_backend: str = "auto",
font: str = "sans",
text_size: float = 12,
renderer2d: str = "cairo",
):
"""Checks screen_backend and creates a temporary directory for rendering.
:param width: width of image in pixels
:param height: height of image in pixels
:param filename: filename or path to save the resulting PNG image
:param mode: 3D rendering mode (options: fine, coarse, both)
:param resolution_fine: resolution multiplier for the fine mode
:param screen_backend: backend for running the 3D rendering
:param font: font to use in 2D rendering
:param text_size: default text size in 2D rendering, usually overwritten
:param renderer2d: GRASS 2D renderer driver (options: cairo, png)
When *resolution_fine* is 1, rasters are used in the resolution according
to the computational region as usual in GRASS GIS.
Setting *resolution_fine* to values higher than one, causes rasters to
be resampled to a coarser resolution (2 for twice as coarse than computational
region resolution). This allows for fast rendering of large rasters without
changing the computational region.
By default (``screen_backend="auto"``), when
pyvirtualdisplay Python package is present, the class assumes that it is
running in a headless environment, so pyvirtualdisplay is used. When the
package is not present, *m.nviz.image* is executed directly. When
*screen_backend* is set to ``"pyvirtualdisplay"`` and the package cannot be
imported, ValueError is raised. When *screen_backend* is set to ``"simple"``,
*m.nviz.image* is executed directly. For other values of *screen_backend*,
ValueError is raised.
"""
self._width = width
self._height = height
self._mode = mode
self._resolution_fine = resolution_fine

# Temporary dir and files
self._tmpdir = tempfile.TemporaryDirectory()
if filename:
self._filename = filename
else:
self._filename = os.path.join(self._tmpdir.name, "map.png")

# Screen backend
try:
# This tests availability of the module and needs to work even
# when the package is not installed.
# pylint: disable=import-outside-toplevel,unused-import
import pyvirtualdisplay # noqa: F401

pyvirtualdisplay_available = True
except ImportError:
pyvirtualdisplay_available = False
if screen_backend == "auto" and pyvirtualdisplay_available:
self._screen_backend = "pyvirtualdisplay"
elif screen_backend == "auto":
self._screen_backend = "simple"
elif screen_backend == "pyvirtualdisplay" and not pyvirtualdisplay_available:
raise ValueError(
_(
"Screen backend '{}' cannot be used "
"because pyvirtualdisplay cannot be imported"
).format(screen_backend)
)
elif screen_backend in ["simple", "pyvirtualdisplay"]:
self._screen_backend = screen_backend
else:
raise ValueError(
_(
"Screen backend '{}' does not exist. "
"See documentation for the list of supported backends."
).format(screen_backend)
)

self.overlay = GrassRenderer(
height=height,
width=width,
filename=self._filename,
font=font,
text_size=text_size,
renderer=renderer2d,
)

@property
def filename(self):
"""Filename or full path to the file with the resulting image.
The value can be set during initialization. When the filename was not provided
during initialization, a path to temporary file is returned. In that case, the
file is guaranteed to exist as long as the object exists.
"""
return self._filename

def render(self, **kwargs):
"""Run rendering using *m.nviz.image*.
Keyword arguments are passed as parameters to the *m.nviz.image* module.
Parameters set in constructor such as *mode* are used here unless another value
is provided. Parameters related to size, file, and format are handled
internally and will be ignored when passed here.
Calling this function again, overwrites the previously rendered image,
so typically, it is called only once.
"""
module = "m.nviz.image"
name = os.path.join(self._tmpdir.name, "nviz")
ext = "tif"
full_name = f"{name}.{ext}"
kwargs["output"] = name
kwargs["format"] = ext
kwargs["size"] = (self._width, self._height)
if "mode" not in kwargs:
kwargs["mode"] = self._mode
if "resolution_fine" not in kwargs:
kwargs["resolution_fine"] = self._resolution_fine

if self._screen_backend == "pyvirtualdisplay":
import inspect # pylint: disable=import-outside-toplevel

# This is imported only when needed and when the package is available,
# but generally, it may not be available.
# pylint: disable=import-outside-toplevel,import-error
from pyvirtualdisplay import Display

additional_kwargs = {}
has_env_copy = False
if "manage_global_env" in inspect.signature(Display).parameters:
additional_kwargs["manage_global_env"] = False
has_env_copy = True
with Display(
size=(self._width, self._height), **additional_kwargs
) as display:
if has_env_copy:
env = display.env()
else:
env = os.environ
gs.run_command(module, env=env, **kwargs)
else:
gs.run_command(module, **kwargs)

# Lazy import to avoid an import-time dependency on PIL.
from PIL import Image # pylint: disable=import-outside-toplevel

img = Image.open(full_name)
img.save(self._filename)

def show(self):
"""Displays a PNG image of map"""
# Lazy import to avoid an import-time dependency on IPython.
from IPython.display import Image # pylint: disable=import-outside-toplevel

return Image(self._filename)
Loading

0 comments on commit d666912

Please sign in to comment.