소스 검색

jupyter: Render 3D images with m.nviz.image (#1831)

* 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).
Vaclav Petras 3 년 전
부모
커밋
d6669122a9

+ 4 - 2
.github/workflows/apt.txt

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

+ 93 - 0
doc/notebooks/grass_jupyter.ipynb

@@ -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": {

+ 1 - 0
python/grass/jupyter/Makefile

@@ -9,6 +9,7 @@ MODULES = \
 	setup \
 	display \
 	interact_display \
+	render3d \
 	utils
 
 PYFILES := $(patsubst %,$(DSTDIR)/%.py,$(MODULES) __init__)

+ 3 - 2
python/grass/jupyter/__init__.py

@@ -21,7 +21,8 @@ Notebooks. The original version was written as part of Google Summer of Code in
 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 - 0
python/grass/jupyter/render3d.py

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

+ 134 - 0
python/grass/jupyter/testsuite/test_render3d.py

@@ -0,0 +1,134 @@
+#!/usr/bin/env python
+
+############################################################################
+#
+# NAME:      Test 3D renderer
+#
+# AUTHOR(S): Vaclav Petras <wenzeslaus gmail com>
+#
+# PURPOSE:   Test script for grass.jupyter's Grass3dRenderer
+#
+# COPYRIGHT: (C) 2021 by Vaclav Petras and 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.
+#
+#############################################################################
+
+"""Test of 3D renderer"""
+
+import os
+import unittest
+import sys
+from pathlib import Path
+import grass.jupyter as gj
+from grass.gunittest.case import TestCase
+from grass.gunittest.main import test
+
+
+def can_import_ipython():
+    """Return True if IPython can be imported, False otherwise"""
+    try:
+        # pylint: disable=import-outside-toplevel,unused-import
+        import IPython  # noqa: F401
+
+        return True
+    except ImportError:
+        return False
+
+
+def can_import_pyvirtualdisplay():
+    """Return True if pyvirtualdisplay can be imported, False otherwise"""
+    try:
+        # pylint: disable=import-outside-toplevel,unused-import
+        import pyvirtualdisplay  # noqa: F401
+
+        return True
+    except ImportError:
+        return False
+
+
+class TestDisplay(TestCase):
+    """Test Grass3dRenderer"""
+
+    files = []
+
+    @classmethod
+    def setUpClass(cls):
+        """Ensures expected computational region"""
+        # to not override mapset's region (which might be used by other tests)
+        cls.use_temp_region()
+        # cls.runModule or self.runModule is used for general module calls
+        # we'll use the elevation raster as a test display
+        cls.runModule("g.region", raster="elevation")
+
+    @classmethod
+    def tearDownClass(cls):
+        """Remove temporary region"""
+        cls.del_temp_region()
+
+    def tearDown(self):
+        """After each run, remove the created files if exist"""
+        for file in self.files:
+            file = Path(file)
+            if sys.version_info < (3, 8):
+                try:
+                    os.remove(file)
+                except FileNotFoundError:
+                    pass
+            else:
+                file.unlink(missing_ok=True)
+
+    def test_defaults(self):
+        """Check that default settings work"""
+        renderer = gj.Grass3dRenderer()
+        renderer.render(elevation_map="elevation", color_map="elevation")
+        self.assertFileExists(renderer.filename)
+
+    def test_filename(self):
+        """Check that custom filename works"""
+        custom_filename = "test_filename.png"
+        renderer = gj.Grass3dRenderer(filename=custom_filename)
+        # Add files to self for cleanup later
+        self.files.append(custom_filename)
+        renderer.render(elevation_map="elevation", color_map="elevation")
+        self.assertFileExists(custom_filename)
+
+    def test_hw(self):
+        """Check that custom width and height works"""
+        renderer = gj.Grass3dRenderer(width=200, height=400)
+        renderer.render(elevation_map="elevation", color_map="elevation")
+        self.assertFileExists(renderer.filename)
+
+    def test_overlay(self):
+        """Check that overlay works"""
+        renderer = gj.Grass3dRenderer()
+        renderer.render(elevation_map="elevation", color_map="elevation")
+        renderer.overlay.d_legend(raster="elevation", at=(60, 97, 87, 92))
+        self.assertFileExists(renderer.filename)
+
+    @unittest.skipIf(
+        not can_import_pyvirtualdisplay(), "Cannot import PyVirtualDisplay"
+    )
+    def test_pyvirtualdisplay_backend(self):
+        """Check that pyvirtualdisplay backend works"""
+        renderer = gj.Grass3dRenderer(screen_backend="pyvirtualdisplay")
+        renderer.render(elevation_map="elevation", color_map="elevation")
+        self.assertFileExists(renderer.filename)
+
+    def test_shortcut_error(self):
+        """Check that wrong screen backend fails"""
+        with self.assertRaisesRegex(ValueError, "does_not_exist"):
+            gj.Grass3dRenderer(screen_backend="does_not_exist")
+
+    @unittest.skipIf(not can_import_ipython(), "Cannot import IPython")
+    def test_image_creation(self):
+        """Check that show() works"""
+        renderer = gj.Grass3dRenderer()
+        renderer.render(elevation_map="elevation", color_map="elevation")
+        self.assertTrue(renderer.show(), "Failed to create IPython Image object")
+
+
+if __name__ == "__main__":
+    test()