Przeglądaj źródła

grass.jupyter: more flexible region handling for rendering (#1871)

By default rendering in jupyter notebook is based on the first layer extent and resolution.
Saved region and current region can also be specified.
Anna Petrasova 3 lat temu
rodzic
commit
2d2eff55af

+ 58 - 0
doc/notebooks/grass_jupyter.ipynb

@@ -148,6 +148,64 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
+    "By default the display extent (and resolution if applicable) is derived from the first raster or vector layer:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "img3 = gj.GrassRenderer()\n",
+    "img3.d_vect(map=\"boundary_state\")\n",
+    "img3.d_rast(map=\"geology\")\n",
+    "img3.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To respect computational region, set `use_region=True`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "img4 = gj.GrassRenderer(use_region=True)\n",
+    "img4.d_vect(map=\"boundary_state\")\n",
+    "img4.d_rast(map=\"geology\")\n",
+    "img4.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can also use a saved region:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "gs.run_command(\"g.region\", save=\"myregion\", n=224000, s=222000, w=633500, e=637300)\n",
+    "img5 = gj.GrassRenderer(saved_region=\"myregion\")\n",
+    "img5.d_rast(map=\"elevation\")\n",
+    "img5.d_rast(map=\"lakes\")\n",
+    "img5.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
     "## Interactive Map Display\n",
     "\n",
     "The `InteractiveMap` class displays *GRASS GIS* rasters and vectors with [*folium*](http://python-visualization.github.io/folium/), a [*leaflet*](https://leafletjs.com/) library for *Python*."

+ 1 - 0
python/grass/jupyter/Makefile

@@ -9,6 +9,7 @@ MODULES = \
 	setup \
 	display \
 	interact_display \
+	region \
 	render3d \
 	utils
 

+ 17 - 0
python/grass/jupyter/display.py

@@ -16,6 +16,8 @@ import shutil
 import tempfile
 import grass.script as gs
 
+from .region import RegionManagerFor2D
+
 
 class GrassRenderer:
     """GrassRenderer creates and displays GRASS maps in
@@ -48,6 +50,8 @@ class GrassRenderer:
         font="sans",
         text_size=12,
         renderer="cairo",
+        use_region=False,
+        saved_region=None,
     ):
 
         """Creates an instance of the GrassRenderer class.
@@ -62,6 +66,10 @@ class GrassRenderer:
                         font file
         :param int text_size: default text size, overwritten by most display modules
         :param renderer: GRASS renderer driver (options: cairo, png, ps, html)
+        :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
@@ -96,6 +104,9 @@ class GrassRenderer:
         self._legend_file = os.path.join(self._tmpdir.name, "legend.txt")
         self._env["GRASS_LEGEND_FILE"] = str(self._legend_file)
 
+        # rendering region setting
+        self._region_manager = RegionManagerFor2D(use_region, saved_region, self._env)
+
     @property
     def filename(self):
         """Filename or full path to the file with the resulting image.
@@ -106,6 +117,11 @@ class GrassRenderer:
         """
         return self._filename
 
+    @property
+    def region_manager(self):
+        """Region manager object"""
+        return self._region_manager
+
     def run(self, module, **kwargs):
         """Run modules from the GRASS display family (modules starting with "d.").
 
@@ -117,6 +133,7 @@ class GrassRenderer:
 
         # Check module is from display library then run
         if module[0] == "d":
+            self._region_manager.set_region_from_command(module, **kwargs)
             gs.run_command(module, env=self._env, **kwargs)
         else:
             raise ValueError("Module must begin with letter 'd'.")

+ 11 - 1
python/grass/jupyter/interact_display.py

@@ -24,6 +24,7 @@ from .utils import (
     reproject_region,
     setup_location,
 )
+from .region import RegionManagerForInteractiveMap
 
 
 class InteractiveMap:
@@ -37,7 +38,7 @@ class InteractiveMap:
     >>> m.show()
     """
 
-    def __init__(self, width=400, height=400):
+    def __init__(self, width=400, height=400, use_region=False, saved_region=None):
         """Creates a blank folium map centered on g.region.
 
         :param int height: height in pixels of figure (default 400)
@@ -81,6 +82,10 @@ class InteractiveMap:
         )
         # Set LayerControl default
         self.layer_control = False
+        # region handling
+        self._region_manager = RegionManagerForInteractiveMap(
+            use_region, saved_region, self._src_env, self._psmerc_env
+        )
 
         # Cleanup rcfiles with finalizer
         def remove_if_exists(path):
@@ -114,6 +119,8 @@ class InteractiveMap:
         name = file_info["name"]
         mapset = file_info["mapset"]
         new_name = full_name.replace("@", "_")
+        # set bbox
+        self._region_manager.set_bbox_vector(full_name)
         # Reproject vector into WGS84 Location
         env_info = gs.gisenv(env=self._src_env)
         gs.run_command(
@@ -160,6 +167,7 @@ class InteractiveMap:
         name = file_info["name"]
         mapset = file_info["mapset"]
 
+        self._region_manager.set_region_from_raster(full_name)
         # Reproject raster into WGS84/epsg3857 location
         env_info = gs.gisenv(env=self._src_env)
         resolution = estimate_resolution(
@@ -190,6 +198,7 @@ class InteractiveMap:
             height=png_height,
             env=self._psmerc_env,
             filename=filename,
+            use_region=True,
         )
         m.run("d.rast", map=tgt_name)
 
@@ -237,6 +246,7 @@ class InteractiveMap:
         fig = self._folium.Figure(width=self.width, height=self.height)
         # Add map to figure
         fig.add_child(self.map)
+        self.map.fit_bounds(self._region_manager.bbox)
 
         return fig
 

+ 215 - 0
python/grass/jupyter/region.py

@@ -0,0 +1,215 @@
+#
+# AUTHOR(S): Anna Petrasova <kratochanna AT gmail>
+#
+# PURPOSE:   This module contains functionality for managing region
+#            during rendering.
+#
+# COPYRIGHT: (C) 2021 Anna Petrasova, 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.
+
+
+import grass.script as gs
+from grass.exceptions import CalledModuleError
+from .utils import (
+    get_location_proj_string,
+    get_region,
+    reproject_region,
+    get_map_name_from_d_command,
+)
+
+
+class RegionManagerForInteractiveMap:
+    def __init__(self, use_region, saved_region, src_env, tgt_env):
+        """Manages region during rendering for interactive map.
+
+        :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
+        :param src_env: source environment (original projection)
+        :param tgt_env: target environment (pseudomercator)
+        """
+        self._use_region = use_region
+        self._saved_region = saved_region
+        self._src_env = src_env
+        self._tgt_env = tgt_env
+        self._bbox = [[90, 180], [-90, -180]]
+
+    @property
+    def bbox(self):
+        """Bbox property for accessing maximum
+        bounding box of all rendered layers.
+        """
+        return self._bbox
+
+    def set_region_from_raster(self, raster):
+        """Sets computational region for rendering.
+
+        This functions sets computational region based on
+        a raster map in the target environment.
+
+        If user specified the name of saved region during object's initialization,
+        the provided region is used. If it's not specified
+        and use_region=True, current region is used.
+
+        Also enlarges bounding box based on the raster.
+        """
+        if self._saved_region:
+            self._src_env["GRASS_REGION"] = gs.region_env(
+                region=self._saved_region, env=self._src_env
+            )
+        elif self._use_region:
+            # use current
+            self._set_bbox(self._src_env)
+            return
+        else:
+            self._src_env["GRASS_REGION"] = gs.region_env(
+                raster=raster, env=self._src_env
+            )
+        region = get_region(env=self._src_env)
+        from_proj = get_location_proj_string(self._src_env)
+        to_proj = get_location_proj_string(env=self._tgt_env)
+        new_region = reproject_region(region, from_proj, to_proj)
+        gs.run_command(
+            "g.region",
+            n=new_region["north"],
+            s=new_region["south"],
+            e=new_region["east"],
+            w=new_region["west"],
+            env=self._tgt_env,
+        )
+        self._set_bbox(self._src_env)
+
+    def set_bbox_vector(self, vector):
+        """Enlarge bounding box based on vector"""
+        env = self._src_env.copy()
+        env["GRASS_REGION"] = gs.region_env(vector=vector, env=env)
+        self._set_bbox(env)
+
+    def _set_bbox(self, env):
+        bbox = gs.parse_command("g.region", flags="bg", env=env)
+        s = float(bbox["ll_s"])
+        w = float(bbox["ll_w"])
+        n = float(bbox["ll_n"])
+        e = float(bbox["ll_e"])
+        if self._bbox[0][0] > s:
+            self._bbox[0][0] = s
+        if self._bbox[0][1] > w:
+            self._bbox[0][1] = w
+        if self._bbox[1][0] < n:
+            self._bbox[1][0] = n
+        if self._bbox[1][1] < e:
+            self._bbox[1][1] = e
+
+
+class RegionManagerFor2D:
+    def __init__(self, use_region, saved_region, env):
+        """Manages region during rendering.
+
+        :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
+        :param env: environment for rendering
+        """
+        self._env = env
+        self._use_region = use_region
+        self._saved_region = saved_region
+        self._extent_set = False
+        self._resolution_set = False
+
+    def set_region_from_env(self, env):
+        """Copies GRASS_REGION from provided environment
+        to local environment to set the computational region"""
+        self._env["GRASS_REGION"] = env["GRASS_REGION"]
+
+    def set_region_from_command(self, module, **kwargs):
+        """Sets computational region for rendering.
+
+        This functions identifies a raster/vector map from command
+        and tries to set computational region based on that.
+        It takes the extent from the first layer (raster or vector)
+        and resolution and alignment from first raster layer.
+
+        If user specified the name of saved region during object's initialization,
+        the provided region is used. If it's not specified
+        and use_region=True, current region is used.
+        """
+        if self._saved_region:
+            self._env["GRASS_REGION"] = gs.region_env(
+                region=self._saved_region, env=self._env
+            )
+            return
+        if self._use_region:
+            # use current
+            return
+        if self._resolution_set and self._extent_set:
+            return
+        name = get_map_name_from_d_command(module, **kwargs)
+        if not name:
+            return
+        if len(name.split()) > 1:
+            name = name.split()[0]
+        try:
+            if module.startswith("d.vect"):
+                if not self._resolution_set and not self._extent_set:
+                    self._env["GRASS_REGION"] = gs.region_env(
+                        vector=name, env=self._env
+                    )
+                    self._extent_set = True
+            else:
+                if not self._resolution_set and not self._extent_set:
+                    self._env["GRASS_REGION"] = gs.region_env(
+                        raster=name, env=self._env
+                    )
+                    self._extent_set = True
+                    self._resolution_set = True
+                elif not self._resolution_set:
+                    self._env["GRASS_REGION"] = gs.region_env(align=name, env=self._env)
+                    self._resolution_set = True
+        except CalledModuleError:
+            return
+
+
+class RegionManagerFor3D:
+    def __init__(self, use_region, saved_region):
+        """Manages region during rendering.
+
+        :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
+        """
+        self._use_region = use_region
+        self._saved_region = saved_region
+        self._region_set = False
+
+    def set_region_from_command(self, env, **kwargs):
+        """Sets computational region for rendering.
+
+        This functions identifies a raster map from m.nviz.image command
+        and tries to set computational region based on that.
+
+        If user specified the name of saved region during object's initialization,
+        the provided region is used. If it's not specified
+        and use_region=True, current region is used.
+        """
+        if self._saved_region:
+            env["GRASS_REGION"] = gs.region_env(region=self._saved_region, env=env)
+            self._region_set = True
+            return
+        if self._use_region:
+            # use current
+            return
+        if self._region_set:
+            return
+        if "elevation_map" in kwargs:
+            elev = kwargs["elevation_map"].split(",")[0]
+            try:
+                env["GRASS_REGION"] = gs.region_env(raster=elev, env=env)
+                self._region_set = True
+            except CalledModuleError:
+                return

+ 24 - 2
python/grass/jupyter/render3d.py

@@ -19,6 +19,8 @@ import tempfile
 import grass.script as gs
 from grass.jupyter import GrassRenderer
 
+from .region import RegionManagerFor3D
+
 
 class Grass3dRenderer:
     """Creates and displays 3D visualization using GRASS GIS 3D rendering engine NVIZ.
@@ -52,6 +54,8 @@ class Grass3dRenderer:
         font: str = "sans",
         text_size: float = 12,
         renderer2d: str = "cairo",
+        use_region: bool = False,
+        saved_region: str = None,
     ):
         """Checks screen_backend and creates a temporary directory for rendering.
 
@@ -64,6 +68,10 @@ class Grass3dRenderer:
         :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)
+        :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
 
         When *resolution_fine* is 1, rasters are used in the resolution according
         to the computational region as usual in GRASS GIS.
@@ -131,7 +139,11 @@ class Grass3dRenderer:
             font=font,
             text_size=text_size,
             renderer=renderer2d,
+            use_region=use_region,
+            saved_region=saved_region,
         )
+        # rendering region setting
+        self._region_manager = RegionManagerFor3D(use_region, saved_region)
 
     @property
     def filename(self):
@@ -143,6 +155,11 @@ class Grass3dRenderer:
         """
         return self._filename
 
+    @property
+    def region_manager(self):
+        """Region manager object"""
+        return self._region_manager
+
     def render(self, **kwargs):
         """Run rendering using *m.nviz.image*.
 
@@ -185,10 +202,15 @@ class Grass3dRenderer:
                 if has_env_copy:
                     env = display.env()
                 else:
-                    env = os.environ
+                    env = os.environ.copy()
+                self._region_manager.set_region_from_command(env=env, **kwargs)
+                self.overlay.region_manager.set_region_from_env(env)
                 gs.run_command(module, env=env, **kwargs)
         else:
-            gs.run_command(module, **kwargs)
+            env = os.environ.copy()
+            self._region_manager.set_region_from_command(env=env, **kwargs)
+            self.overlay.region_manager.set_region_from_env(env)
+            gs.run_command(module, env=env, **kwargs)
 
         # Lazy import to avoid an import-time dependency on PIL.
         from PIL import Image  # pylint: disable=import-outside-toplevel

+ 12 - 0
python/grass/jupyter/utils.py

@@ -132,3 +132,15 @@ def setup_location(name, path, epsg, src_env):
         env=new_env,
     )
     return rcfile, new_env
+
+
+def get_map_name_from_d_command(module, **kwargs):
+    """Returns map name from display command.
+
+    Assumes only positional parameters.
+    When more maps are present (e.g., d.rgb), it returns only 1.
+    Returns empty string if fails to find it.
+    """
+    special = {"d.his": "hue", "d.legend": "raster", "d.rgb": "red", "d.shade": "shade"}
+    parameter = special.get(module, "map")
+    return kwargs.get(parameter, "")