|
@@ -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)
|