render3d.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. # MODULE: grass.jupyter.display
  2. #
  3. # AUTHOR(S): Vaclav Petras <wenzeslaus gmail com>
  4. #
  5. # PURPOSE: This module contains functions for non-interactive display
  6. # in Jupyter Notebooks
  7. #
  8. # COPYRIGHT: (C) 2021 Vaclav Petras, and by the GRASS Development Team
  9. #
  10. # This program is free software under the GNU General Public
  11. # License (>=v2). Read the file COPYING that comes with GRASS
  12. # for details.
  13. """Render 3D visualizations"""
  14. import os
  15. import tempfile
  16. import weakref
  17. import grass.script as gs
  18. from .display import GrassRenderer
  19. from .region import RegionManagerFor3D
  20. class Grass3dRenderer:
  21. """Creates and displays 3D visualization using GRASS GIS 3D rendering engine NVIZ.
  22. The 3D image is created using the *render* function which uses the *m.nviz.image*
  23. module in the background. Additional images can be
  24. placed on the image using the *overlay* attribute which is the 2D renderer, i.e.,
  25. has interface of the *GrassRenderer* class.
  26. Basic usage::
  27. >>> img = Grass3dRenderer()
  28. >>> img.render(elevation_map="elevation", color_map="elevation", perspective=20)
  29. >>> img.overlay.d_legend(raster="elevation", at=(60, 97, 87, 92))
  30. >>> img.show()
  31. For the OpenGL rendering with *m.nviz.image* to work, a display (screen) is needed.
  32. This is not guaranteed on headless systems such as continuous integration (CI) or
  33. Binder service(s). This class uses Xvfb and PyVirtualDisplay to support rendering
  34. in these environments.
  35. """
  36. def __init__(
  37. self,
  38. width: int = 600,
  39. height: int = 400,
  40. filename: str = None,
  41. mode: str = "fine",
  42. resolution_fine: int = 1,
  43. screen_backend: str = "auto",
  44. font: str = "sans",
  45. text_size: float = 12,
  46. renderer2d: str = "cairo",
  47. use_region: bool = False,
  48. saved_region: str = None,
  49. ):
  50. """Checks screen_backend and creates a temporary directory for rendering.
  51. :param width: width of image in pixels
  52. :param height: height of image in pixels
  53. :param filename: filename or path to save the resulting PNG image
  54. :param mode: 3D rendering mode (options: fine, coarse, both)
  55. :param resolution_fine: resolution multiplier for the fine mode
  56. :param screen_backend: backend for running the 3D rendering
  57. :param font: font to use in 2D rendering
  58. :param text_size: default text size in 2D rendering, usually overwritten
  59. :param renderer2d: GRASS 2D renderer driver (options: cairo, png)
  60. :param use_region: if True, use either current or provided saved region,
  61. else derive region from rendered layers
  62. :param saved_region: if name of saved_region is provided,
  63. this region is then used for rendering
  64. When *resolution_fine* is 1, rasters are used in the resolution according
  65. to the computational region as usual in GRASS GIS.
  66. Setting *resolution_fine* to values higher than one, causes rasters to
  67. be resampled to a coarser resolution (2 for twice as coarse than computational
  68. region resolution). This allows for fast rendering of large rasters without
  69. changing the computational region.
  70. By default (``screen_backend="auto"``), when
  71. pyvirtualdisplay Python package is present, the class assumes that it is
  72. running in a headless environment, so pyvirtualdisplay is used. When the
  73. package is not present, *m.nviz.image* is executed directly. When
  74. *screen_backend* is set to ``"pyvirtualdisplay"`` and the package cannot be
  75. imported, ValueError is raised. When *screen_backend* is set to ``"simple"``,
  76. *m.nviz.image* is executed directly. For other values of *screen_backend*,
  77. ValueError is raised.
  78. """
  79. self._width = width
  80. self._height = height
  81. self._mode = mode
  82. self._resolution_fine = resolution_fine
  83. # Temporary dir and files
  84. # Resource managed by weakref.finalize.
  85. self._tmpdir = (
  86. tempfile.TemporaryDirectory() # pylint: disable=consider-using-with
  87. )
  88. def cleanup(tmpdir):
  89. tmpdir.cleanup()
  90. weakref.finalize(self, cleanup, self._tmpdir)
  91. if filename:
  92. self._filename = filename
  93. else:
  94. self._filename = os.path.join(self._tmpdir.name, "map.png")
  95. # Screen backend
  96. try:
  97. # This tests availability of the module and needs to work even
  98. # when the package is not installed.
  99. # pylint: disable=import-outside-toplevel,unused-import
  100. import pyvirtualdisplay # noqa: F401
  101. pyvirtualdisplay_available = True
  102. except ImportError:
  103. pyvirtualdisplay_available = False
  104. if screen_backend == "auto" and pyvirtualdisplay_available:
  105. self._screen_backend = "pyvirtualdisplay"
  106. elif screen_backend == "auto":
  107. self._screen_backend = "simple"
  108. elif screen_backend == "pyvirtualdisplay" and not pyvirtualdisplay_available:
  109. raise ValueError(
  110. _(
  111. "Screen backend '{}' cannot be used "
  112. "because pyvirtualdisplay cannot be imported"
  113. ).format(screen_backend)
  114. )
  115. elif screen_backend in ["simple", "pyvirtualdisplay"]:
  116. self._screen_backend = screen_backend
  117. else:
  118. raise ValueError(
  119. _(
  120. "Screen backend '{}' does not exist. "
  121. "See documentation for the list of supported backends."
  122. ).format(screen_backend)
  123. )
  124. self.overlay = GrassRenderer(
  125. height=height,
  126. width=width,
  127. filename=self._filename,
  128. font=font,
  129. text_size=text_size,
  130. renderer=renderer2d,
  131. use_region=use_region,
  132. saved_region=saved_region,
  133. )
  134. # rendering region setting
  135. self._region_manager = RegionManagerFor3D(use_region, saved_region)
  136. @property
  137. def filename(self):
  138. """Filename or full path to the file with the resulting image.
  139. The value can be set during initialization. When the filename was not provided
  140. during initialization, a path to temporary file is returned. In that case, the
  141. file is guaranteed to exist as long as the object exists.
  142. """
  143. return self._filename
  144. @property
  145. def region_manager(self):
  146. """Region manager object"""
  147. return self._region_manager
  148. def render(self, **kwargs):
  149. """Run rendering using *m.nviz.image*.
  150. Keyword arguments are passed as parameters to the *m.nviz.image* module.
  151. Parameters set in constructor such as *mode* are used here unless another value
  152. is provided. Parameters related to size, file, and format are handled
  153. internally and will be ignored when passed here.
  154. Calling this function again, overwrites the previously rendered image,
  155. so typically, it is called only once.
  156. """
  157. module = "m.nviz.image"
  158. name = os.path.join(self._tmpdir.name, "nviz")
  159. ext = "tif"
  160. full_name = f"{name}.{ext}"
  161. kwargs["output"] = name
  162. kwargs["format"] = ext
  163. kwargs["size"] = (self._width, self._height)
  164. if "mode" not in kwargs:
  165. kwargs["mode"] = self._mode
  166. if "resolution_fine" not in kwargs:
  167. kwargs["resolution_fine"] = self._resolution_fine
  168. if self._screen_backend == "pyvirtualdisplay":
  169. import inspect # pylint: disable=import-outside-toplevel
  170. # This is imported only when needed and when the package is available,
  171. # but generally, it may not be available.
  172. # pylint: disable=import-outside-toplevel,import-error
  173. from pyvirtualdisplay import Display
  174. additional_kwargs = {}
  175. has_env_copy = False
  176. if "manage_global_env" in inspect.signature(Display).parameters:
  177. additional_kwargs["manage_global_env"] = False
  178. has_env_copy = True
  179. with Display(
  180. size=(self._width, self._height), **additional_kwargs
  181. ) as display:
  182. if has_env_copy:
  183. env = display.env()
  184. else:
  185. env = os.environ.copy()
  186. self._region_manager.set_region_from_command(env=env, **kwargs)
  187. self.overlay.region_manager.set_region_from_env(env)
  188. gs.run_command(module, env=env, **kwargs)
  189. else:
  190. env = os.environ.copy()
  191. self._region_manager.set_region_from_command(env=env, **kwargs)
  192. self.overlay.region_manager.set_region_from_env(env)
  193. gs.run_command(module, env=env, **kwargs)
  194. # Lazy import to avoid an import-time dependency on PIL.
  195. from PIL import Image # pylint: disable=import-outside-toplevel
  196. img = Image.open(full_name)
  197. img.save(self._filename)
  198. def show(self):
  199. """Displays a PNG image of map"""
  200. # Lazy import to avoid an import-time dependency on IPython.
  201. from IPython.display import Image # pylint: disable=import-outside-toplevel
  202. return Image(self._filename)