Viewshed analysis in GRASS and Jupyter

Alexander Dunkel

No description has been provided for this image
•••
Out[242]:

Last updated: Nov-07-2024, Carto-Lab Docker Version 0.22.2

This is a project where I calculate viewsheds using GRASS and Jupyter. This notebook is based on the excellent FOSS4G 2022 workshop.

Prepare environment

To run this notebook, we suggest to use the Carto-Lab Docker Container with the GRASS compose file.
•••

Load dependencies:

In [244]:
import os
import sys
import json
import subprocess
import psutil
import pandas as pd
import matplotlib.pyplot as plt
import time
from pathlib import Path
from tqdm import tqdm
from typing import List, Tuple, Dict, Optional
from IPython.display import clear_output, display, HTML
from multiprocessing import Pool, cpu_count

Are we running in 64bit?

In [245]:
import sys
if sys.maxsize > 2**32:
    print('64-bit')
else:
    print('32-bit')
64-bit

How much memory? (MB)

In [246]:
TOTAL_MEMORY_MB = int(psutil.virtual_memory().total / 1024 / 1024)
TOTAL_MEMORY_MB
Out[246]:
128777

How many CPUs?

In [247]:
PROC = cpu_count()
PROC
Out[247]:
16
  • Ask GRASS GIS where its Python packages are.
  • Import the GRASS GIS packages we need.
  • Start GRASS Session
In [248]:
sys.path.append(
    subprocess.check_output(
        ["grass", "--config", "python_path"], text=True).strip()
)
import grass.script as gs
import grass.jupyter as gj
from grass.exceptions import CalledModuleError

Activate autoreload of changed python files:

In [249]:
%load_ext autoreload
%autoreload 2
The autoreload extension is already loaded. To reload it, use:
  %reload_ext autoreload

Parameters

Define initial parameters that affect processing

In [250]:
MAX_DISTANCE = 10000     # Max distance of viewshed calculation (in Meters)
HA_FILTER_NOISE = 5      # minimal area unit for viewshed noise removal

Current working Path

In [251]:
Path.cwd()
Out[251]:
PosixPath('/home/jovyan/work/viewshed_analysis/grassdata/viewshed')
In [252]:
DATA = Path.cwd().parents[0] / "grassdata"
DATA.mkdir(exist_ok=True)
In [253]:
(Path.cwd().parents[0] / "notebooks").mkdir(exist_ok=True)
(Path.cwd().parents[0] / "py").mkdir(exist_ok=True)

Notebook environment

By default all cells are running Python:

In [254]:
import sys
v = sys.version_info
print(f"We are using Python {v.major}.{v.minor}.{v.micro}")
We are using Python 3.12.7

Cells can also run bash using IPython magic. Later, we will use this to show how GRASS can be used from shell.

In [255]:
%%bash
pwd
/home/jovyan/work/viewshed_analysis/grassdata/viewshed

or we can use ! to run individual lines in bash:

In [256]:
!pwd
/home/jovyan/work/viewshed_analysis/grassdata/viewshed

Create grass project (a Location)

In this first part, we will demonstrate starting GRASS GIS, creating new project and basic data visualization.

Set WORK_FOLDER in python, export to bash/env variable.

In [257]:
PROJECT_NAME = "viewshed"
WORK_FOLDER = "/home/jovyan/work/viewshed_analysis/grassdata/"
PROJECT_FOLDER = f'{WORK_FOLDER}{PROJECT_NAME}/'
os.environ['PROJECT_FOLDER'] = PROJECT_FOLDER

First, we create new empty location (project) called viewshed.

Flag c stands for creating new location and e will exit the command after creating the location. See manual for more examples.

Check if folder exists, otherwise create location.

We use 25833 EPSG projection, which is suitable for Germany and matches out input digital elevation model projection.

In [258]:
%%bash
[[ -d $PROJECT_FOLDER ]] || grass -c EPSG:25833 -e $PROJECT_FOLDER

Start GRASS GIS session in the newly created location.

In [259]:
session = gj.init(WORK_FOLDER, PROJECT_NAME, "PERMANENT")

Change working directory into project folder

In [260]:
os.chdir(f'{WORK_FOLDER}{PROJECT_NAME}')

Import data

We will import prepared digitial surface model (DSM). This data was exported from ArcPro ESRI to a Geo-TIFF. The data CRS matches the CRS of the viewshed location, so we don't need to reproject it.

In [261]:
%%time
%%capture output
%%bash
r.import input=dsm.tif output=dsm resample=bilinear
CPU times: user 26.5 ms, sys: 27.5 ms, total: 54 ms
Wall time: 2.71 s
In [262]:
print(output.stderr)
WARNING: Raster map <dsm> already exists and will be overwritten
Importing raster map <dsm>...
   0   3   6   9  12  15  18  21  24  27  30  33  36  39  42  45  48  51  54  57  60  63  66  69  72  75  78  81  84  87  90  93  96  99 100

List available data in our project:

  • this may list results from previous runs.
In [263]:
%%bash
g.list type=raster,vector -m -t
raster/dsm@PERMANENT
raster/stop_areas_raster@PERMANENT
vector/stop_areas@PERMANENT

Data visualization

We will call d.rast/d.vect modules using the new GRASS Jupyter API. The Map class creates and displays GRASS maps as static PNG images.

First let's display bare ground elevation:

In [264]:
# Create Map instance
elevation_map = gj.Map()
# Add a dsm raster to the map
elevation_map.d_rast(map="dsm")
# Display map
elevation_map.show()
Out[264]:
No description has been provided for this image

Load viewpoints

We will first derive viewpoints that will be used for the viewshed calculation.

  1. Load points (Pot Wea 0 Test)
  2. Create new vector of points dataset
In [265]:
%%capture output
%%bash
v.import input="POT_WEA/pot_WEA_0.shp" output=pot_wea_0
In [266]:
print(output.stderr)
Check if OGR layer <pot_WEA_0> contains polygons...
   0  11  22  33  44  55  66  77  88 100
Creating attribute table for layer <pot_WEA_0>...
Importing 9 features (OGR layer <pot_WEA_0>)...
   0  11  22  33  44  55  66  77  88 100
-----------------------------------------------------
Building topology for vector map <pot_wea_0@PERMANENT>...
Registering primitives...
Input <POT_WEA/pot_WEA_0.shp> successfully imported without reprojection

Visualize the points with InteractiveMap with OSM tiles (see other tile options):

In [267]:
gs.run_command("g.region", raster="dsm", save="zoom_region", grow=250)
dsm_map = gj.InteractiveMap(tiles="OpenStreetMap", saved_region="zoom_region")
dsm_map.add_vector("pot_wea_0")
dsm_map.show()
Out[267]:

Next part of analysis is raster-based, so we need to make sure we set computational region as we need. Specifically, we set it to match the DSM:

In [268]:
gs.run_command("g.region", raster="dsm")

Viewshed computation

To get the cumulative viewshed, we will compute viewsheds from all the viewpoints.

First, we get the list coordinates of the viewpoints and height data (this is a wind turbines dataset):

In [269]:
df = pd.DataFrame(json.loads(gs.read_command("v.db.select", map="pot_wea_0", columns="cat,Gesamthoeh", layer=1, format="json"))["records"])
df
Out[269]:
cat Gesamthoeh
0 1 261
1 2 261
2 3 261
3 4 261
4 5 261
5 6 261
6 7 261
7 8 261
8 9 261
In [270]:
viewpoints = gs.read_command(
        'v.out.ascii', input='pot_wea_0',
        separator='comma', layer=1).strip().splitlines()
viewpoints = [p.split(",") for p in viewpoints]
[print(p) for p in viewpoints]
viewpoints
['335846.5446', '5671162.4905', '179.8599765', '1']
['335814.6023', '5671223.4251', '179.9199765', '2']
['335782.6599', '5671284.3597', '179.7999765', '3']
['336119.2617', '5671472.3884', '182.4299765', '4']
['337047.9506', '5671520.8243', '183.8699765', '5']
['336087.3194', '5671533.323', '182.3399765', '6']
['337016.0085', '5671581.7586', '182.9899765', '7']
['336055.3773', '5671594.2576', '182.0999765', '8']
['336984.0665', '5671642.6929', '181.7499765', '9']
Out[270]:
[['335846.5446', '5671162.4905', '179.8599765', '1'],
 ['335814.6023', '5671223.4251', '179.9199765', '2'],
 ['335782.6599', '5671284.3597', '179.7999765', '3'],
 ['336119.2617', '5671472.3884', '182.4299765', '4'],
 ['337047.9506', '5671520.8243', '183.8699765', '5'],
 ['336087.3194', '5671533.323', '182.3399765', '6'],
 ['337016.0085', '5671581.7586', '182.9899765', '7'],
 ['336055.3773', '5671594.2576', '182.0999765', '8'],
 ['336984.0665', '5671642.6929', '181.7499765', '9']]

We will now compute the viewshed from each viewpoint in a loop. We set max distance of 10km (MAX_DISTANCE parameter). Each viewshed will be named viewshed_cat. We consider the curvature of earth. We assign max 25% of memory to the computation, which is about 32GB on this VM (128GB total).

Configure r.viewshed Paremeters.

In [271]:
vkwargs = {
    "input":"dsm",               # digital surface model to be used
    "max_distance":MAX_DISTANCE, # 10km distance (max) viewshed area radius
    "flags":"bc",                # compute visible area, consider curvature of earth
    "memory":TOTAL_MEMORY_MB/4,  # allocate max 25% of max memory per computation
    "target_elevation": 1.8,     # Offset for target elevation above the ground          
}
In [272]:
%%time
maps = []
for x, y, height, cat in tqdm(viewpoints):
    name = f"viewshed_{cat}"
    gs.run_command(
        "r.viewshed", output=name, observer_elevation=height,
        coordinates=(x, y), **vkwargs)
    maps.append(name)
100%|██████████| 9/9 [03:55<00:00, 26.21s/it]
CPU times: user 37.2 ms, sys: 37.4 ms, total: 74.7 ms
Wall time: 3min 55s

Since these are independent runs, we can easily parallelize the r.viewshed calls using Python multiprocessing. We define a function that computes the viewshed and returns the name of the output or None in case of error:

In [273]:
def viewshed(point) -> str:
    """Calculate viewshed for point and return (name)"""
    x, y, height, cat = point
    # ghoeh = df["Gesamthoeh"][int(cat)-1]
    x, y = float(x), float(y)
    name = f"viewshed_{cat}"
    try:
        gs.run_command(
            "r.viewshed", output=name, observer_elevation=height,
            coordinates=(x, y), **vkwargs)
        return f"viewshed_{cat}"
    except CalledModuleError:
        return None

Run with the total number of CPUs (threads) available (PROC)

In [274]:
%%time
with Pool(processes=PROC) as pool:
    maps = pool.map_async(viewshed, viewpoints).get()
print(maps)
['viewshed_1', 'viewshed_2', 'viewshed_3', 'viewshed_4', 'viewshed_5', 'viewshed_6', 'viewshed_7', 'viewshed_8', 'viewshed_9']
CPU times: user 20.3 ms, sys: 172 ms, total: 192 ms
Wall time: 27.1 s

One trick to speedup viewshed computation is to limit the computation only to the actual area given by the maxdistance option. To do that we will locally modify the computational region and pass the environment to the module directly. The current computational region won't be affected.

In [210]:
def viewshed(point, max_distance: int = None):
    """Calculate viewshed for point, limit to region, store raster, and return the name"""
    x, y, height, cat = point
    # ghoeh = df["Gesamthoeh"][int(cat)-1]
    x, y = float(x), float(y)
    if max_distance is None:
        max_distance = MAX_DISTANCE
    # copy current environment
    env = os.environ.copy()
    # set GRASS_REGION variable using region_env function
    env["GRASS_REGION"] = gs.region_env(
        align="dsm",
        e=x + max_distance,
        w=x - max_distance,
        n=y + max_distance,
        s=y - max_distance)
    grp_str = ""
    name = f"viewshed_{cat}"
    try:
        gs.run_command(
            "r.viewshed", output=name,  observer_elevation=height,
            coordinates=(x, y), env=env, **vkwargs)
        return f"viewshed_{cat}"
    except CalledModuleError:
        return None

Run with the number of CPUs available

In [211]:
%%time 
with Pool(processes=PROC) as pool:
    maps = pool.map_async(viewshed, viewpoints).get()
print(maps)
print(f"Viewshed num cells: {gs.raster_info(maps[0])['cells']}")
print(f"DSM num cells: {gs.raster_info('dsm')['cells']}")
['viewshed_1', 'viewshed_2', 'viewshed_3', 'viewshed_4', 'viewshed_5', 'viewshed_6', 'viewshed_7', 'viewshed_8', 'viewshed_9', 'viewshed_10', 'viewshed_11', 'viewshed_12', 'viewshed_13', 'viewshed_14', 'viewshed_15']
Viewshed num cells: 4004001
DSM num cells: 100317212
CPU times: user 13.6 ms, sys: 90.9 ms, total: 105 ms
Wall time: 11.3 s

preview viewshed 1 of 9:

In [212]:
gs.run_command("g.region", raster=maps, save="zoom_region", grow=250)
tileset = "Esri.WorldImagery" # "OpenStreetMap"
cumulative_map = gj.InteractiveMap(tiles=tileset, saved_region="zoom_region")
cumulative_map.add_raster("viewshed_1", opacity=0.8)
cumulative_map.add_layer_control(position="bottomright")
cumulative_map.add_vector("pot_wea_0")
cumulative_map.show()
Out[212]:

Cumulative viewshed

We can compute the cumulative viewshed, which aggregates viewsheds from multiple viewpoints. In this way you can e.g., identify the most frequently visible areas for the wind turbines dataset.

Since our viewshed rasters are binary (0 invisible, 1 visible), we will use r.series method sum. As a result, we get a cumulative viewshed raster with the number of overlays encoded. Then we replace zeros with no data using r.null and set a new color ramp:

In [213]:
gs.run_command("r.series", input=maps, output="cumulative_viewshed", method="sum")
gs.run_command("r.null", map="cumulative_viewshed", setnull=0)
gs.run_command("r.colors", map="cumulative_viewshed", color="plasma")

Let's visualize the results:

  • Interactive Folium map
  • Select the Tile provider
  • zoom in to data using saved_region, leave a 250 border
In [214]:
gs.run_command("g.region", raster=maps, save="zoom_region", grow=250)
tileset = "Esri.WorldImagery" # "OpenStreetMap"
cumulative_map = gj.InteractiveMap(tiles=tileset, saved_region="zoom_region")
cumulative_map.add_raster("cumulative_viewshed", opacity=0.8)
cumulative_map.add_layer_control(position="bottomright")
cumulative_map.add_vector("pot_wea_0")
cumulative_map.show()
Out[214]:

Set non-null to 1 (binary)

In [215]:
# gs.run_command("g.region", save="zoom_region")
gs.mapcalc('cumulative_viewshed_bin = if(isnull(cumulative_viewshed), cumulative_viewshed, 1)')

Cleanup Map based on 5 ha minimum area:

In [216]:
gs.run_command(
    "r.reclass.area", input="cumulative_viewshed_bin", output="cumulative_viewshed_cleaned", mode="greater",
     value=HA_FILTER_NOISE)

Visualize as static png with the dsm in background.

In [217]:
viewshed_map = gj.Map(saved_region="zoom_region")
viewshed_map.d_rast(map="dsm")
viewshed_map.d_rast(map="cumulative_viewshed_cleaned")
viewshed_map.show()
Out[217]:
No description has been provided for this image

Remove stop areas

Import stop areas

In [218]:
%%time
%%capture output
%%bash
v.import input="stop_areas/sichtverstellende_Elemente.shp" output=stop_areas
CPU times: user 50.1 ms, sys: 38.2 ms, total: 88.4 ms
Wall time: 19.8 s

Convert to raster

In [219]:
%%time
%%capture output
%%bash
v.to.rast input=stop_areas output=stop_areas_raster use=val val=1
CPU times: user 19.1 ms, sys: 11 ms, total: 30.1 ms
Wall time: 2.66 s
In [ ]:
print(output.stderr)
In [221]:
gs.mapcalc(
    'cumulative_viewshed_stopareas = if(isnull(stop_areas_raster), cumulative_viewshed_cleaned, 0)')
In [222]:
display_raster_map(raster_layer="cumulative_viewshed_stopareas")
No description has been provided for this image
In [223]:
gs.run_command("r.null", map="cumulative_viewshed_stopareas", setnull=0)
In [224]:
display_raster_map(raster_layer="cumulative_viewshed_stopareas")
No description has been provided for this image

Export result

Export map

In [45]:
viewshed_map.save("viewshed0.png")

Write to GeoTiff

In [116]:
gs.run_command("g.region", region="zoom_region")
gs.run_command(
    "r.out.gdal", 
    flags="fmt",
    input="cumulative_viewshed_cleaned", 
    output=f"{PROJECT_FOLDER}viewshed0.tif",
    format="GTiff", 
    overviews="4")

Cumulative viewshed with r.viewshed.cva

ToDo:

  • See r.viewshed.cva
  • it seems like different observation heights are not possible
gs.run_command( "r.viewshed.cva", output="cumulative_viewshed", observer_elevation=height, coordinates=(x, y), **vkwargs)

Loop through all points

Loop through complete wind turbines point dataset

Let's create a few methods first and then we will loop through all *.shp and create individual viewsheds.

In [228]:
def get_viewpoints(shp_name: str):
    """Get group of viewpoints from a given shapefile"""
    layer_name = shp_name[:-4]
    gs.run_command("v.import", input=f"POT_WEA/{shp_name}", output="pot_wea")
    # v.import input=f"POT_WEA/{shp_name}" output=layer_name
    viewpoints = gs.read_command(
        'v.out.ascii', input="pot_wea",
        separator='comma', layer=1).strip().splitlines()
    viewpoints = [p.split(",") for p in viewpoints]
    return viewpoints
    

def create_viewshed_group(viewpoints):
    """Create a viewshed for a given group of points"""
    with Pool(processes=PROC) as pool:
        maps = pool.map_async(viewshed, viewpoints).get()
    return maps

def cumulative_viewshed(maps, output_name: str):
    """Calculate cumulative viewshed from several viewsheds"""
    gs.run_command("g.region", raster=maps, save="zoom_region", grow=250)
    ## faster shortcut, without reclass and HA_FILTER_NOISE:
    # gs.run_command("r.series", input=maps, output="cumulative_viewshed", method="maximum")
    gs.run_command("r.series", input=maps, output="cumulative_viewshed", method="sum")
    gs.run_command("r.null", map="cumulative_viewshed", setnull=0)
    # gs.run_command("r.colors", map="cumulative_viewshed", color="plasma")
    gs.run_command("g.region", raster=maps, save="zoom_region", grow=250)
    gs.mapcalc('cumulative_viewshed_bin = if(isnull(cumulative_viewshed), cumulative_viewshed, 1)')
    gs.run_command(
       "r.reclass.area", input="cumulative_viewshed_bin", 
       output="cumulative_viewshed_cleaned", mode="greater",
       value=HA_FILTER_NOISE)
    return "cumulative_viewshed_cleaned"

def save_viewshed(input_name, output_name):
    """Save viewshed to GeoTiff"""
    gs.run_command("g.region", region="zoom_region")
    gs.run_command(
        "r.out.gdal", 
        flags="fmt",
        input=input_name, 
        # input="cumulative_viewshed",
        output=f"{PROJECT_FOLDER}out/{output_name}.tif",
        format="GTiff", 
        overviews="4")

def cleanup_grass_project():
    """Clean up grass project layers (regions, raster, vector)"""
    gs.run_command("g.remove", 
               type="vector", 
               pattern="pot_*", 
               flags="f")
    gs.run_command("g.remove", 
                   type="region", 
                   pattern="zoom_*", 
                   flags="f")
    gs.run_command("g.remove", 
                   type="raster", 
                   pattern="viewshed_*", 
                   flags="f")
    gs.run_command("g.remove", 
                   type="raster", 
                   pattern="cumulative_*", 
                   flags="f")

def remove_stopareas(input_name: str):
    """Remove stopareas from map"""
    gs.mapcalc(
        f'cumulative_viewshed_stopareas = if(isnull(stop_areas_raster), {input_name}, 0)')
    gs.run_command("r.null", map="cumulative_viewshed_stopareas", setnull=0)
    return "cumulative_viewshed_stopareas"

def display_raster_map(raster_layer):
    """Display raster map in jupyter as static image"""
    viewshed_map = gj.Map(saved_region="zoom_region")
    viewshed_map.d_rast(map="dsm")
    viewshed_map.d_rast(map=raster_layer)
    display(viewshed_map.show())
In [137]:
cleanup_grass_project()
In [138]:
%%bash
g.list type=raster,vector,region -m -t
raster/dsm@PERMANENT
In [94]:
get_shapes_list = list(Path("POT_WEA").glob('*.shp'))
len(get_shapes_list)
Out[94]:
205

Process all viewsheds for all shapefiles

In [230]:
%%time
cleanup_grass_project()
# create progress bar for list of shapefiles
it = tqdm(get_shapes_list, unit='shapefile')
for shapefile in it:
    loop_time = time.time()
    cleanup_grass_project()
    # gs.run_command("g.region", raster="dsm", save="zoom_region", grow=250)
    gs.run_command("g.region", raster="dsm")
    print(f"Processing {shapefile.name}")
    viewpoints = get_viewpoints(shapefile.name)
    maps = create_viewshed_group(viewpoints)
    gs.run_command("g.region", raster=maps, save="zoom_region")
    clear_output(wait=True)
    # display_raster_map(raster_layer="viewshed_1")
    output_name = shapefile.name[:-4]
    input_name = cumulative_viewshed(maps=maps, output_name=output_name)
    layer_stopareas = remove_stopareas(input_name=input_name)
    # display_raster_map(raster_layer=layer_stopareas)
    save_viewshed(layer_stopareas, output_name)
    cleanup_grass_project()
    it.display()
100%|██████████| 205/205 [2:40:44<00:00, 47.05s/shapefile]
CPU times: user 5.03 s, sys: 22.3 s, total: 27.3 s
Wall time: 2h 40min 45s

zip results

Make sure that 7z is available (apt-get install p7zip-full)

In [ ]:
!cd /home/jovyan/work/viewshed_analysis/grassdata/viewshed && \
    7z a -tzip -mx=9 out/$(date +"%Y-%m-%d")_viewsheds.zip \
    out/* -x!*.zip -x!out/.ipynb_checkpoints \
    -y > /dev/null

Create Notebook HTML

In [275]:
os.chdir("/home/jovyan/work/viewshed_analysis/notebooks")
In [276]:
!jupyter nbconvert --to html_toc \
    --output-dir=../resources/html/ ./01_viewshedanalysis.ipynb \
    --template=../nbconvert.tpl \
    --ExtractOutputPreprocessor.enabled=False >&- 2>&- # create single output file
In [ ]:
 

Jupyter Base Template v0.10.0