Masters programme | E-portfolio
Semester I & II
Spatial Analyses

Visibility analyses

Analysing the views along austrian hiking paths with consideration of the landuse within viewsheds. Presenting the map results as well as descriptive statistics in form of a simple dashboard (R Shiny).

Table of Contents


Viewshed analyses are an integrative part of most GIS software systems. Based on a suitable terrain model – i.e. depending on the scale and purpose of the analyses either a DEM or DSM – the implemented algorithms enable to identify all areas that are visible from a specified starting point. Integrating this information together with distance-specific landuse data will be done in the following in order to retrieve rough estimates on the beauty of views along two Austrian hiking paths. While the visibility of areas is subject to a certain degree of uncertainty due to the precision of the source data and the applied algorithm, the results are deterministic in nature and can be objectively evaluated in terms of „true“ or „false“. The step towards assessing views implies a higher degree of uncertainty, as the perception of landscapes varies subjectively. In order to avoid corresponding bias and overfitting in the operationalisation of the views, the analyses described below are limited to a few factors that are assessed intersubjectively similarly combined in relatively simple weighting model.

Data & Methods

The following data sets were used for the current viewfactor analysis:

  • Hiking path geolocation data derived from OpenStreetMap (OSM) using the turbo overpass API. Querying for nodes, ways and relations tagged with the note „“ several paths were identified and exported as GeoJSONs. Subsequently, two long-distance paths were selected and clipped to the federal state of Salzburg.
  • DEM data with 25m resolution created by the Airbus mission provided as part of the ESRI Multilayer Terrain dataset was selected as input data to perform the viewshed calculations. The 25m resolution represents a good trade-off between the accuracy of the model and computational efficiency considering the fact that multiple views within radii of several tens of kilometers need to be calculated.
  • Corine Landcover (CLC) data from 2018 with a 25m Minimum Mapping unit (MMU) provided in raster fromat with 100m cell resolution ( was deemed to appropriately representing the landuse/landcover aspect. Other data sources such as the Sentinel 2 based landuse classification may offer a higher spatial resolution but sacrify the great thematic detail, which is of greater significance for the current analyses. Subsuming all artificial surfaces in just one class does not meet the totally different perception of historical city centers compared to industrial sites, for example, which needs to be accounted for in the applied weighting model.
The workflow to derive ratings of the views (in the following called „viewfactors“) started with the DEM based calculation of geodesic viewsheds. The underlying algorithm transforms the elevation surface into a geocentric 3D coordinate system, which compared to more simplistic euclidean methods has the advantage that no approximate curvature correction parameter needs to be specified. To reduce the computational burden, each path was devided into points seperated by 1km distance instead of using each and every vertex of the paths for seperate viewshed calculations. Each calculation was performed in a 40km radius assuming that consindering larger radii does not substantially influence the ranking of different points to each other while increasing the computational effort quadratically. Given these settings, the calculation time for the resource-demanding viewshed algorithms was limited to approximately 8 hours. The size of the resulting visibile areas devided by the potential maximum, i.e. the total surface area in a circle with 40km radius, was taken to be the basic viewfactor subsequently modified by the landuse/landcover within the viewshed. Accounting for the landuse/-cover first required to assign a weighting factor to each of the landcover classes. As shown in the table below, most of the natural surfaces where rated with a (+1) whereas sites occupied by infrastructure, industrial or mining activity were considered as a negative influence (-1). Each pixel within the viewshed was multiplied with the weight belonging to its underlying class. Additionally, a distance weight was applied assuming that the significance of landuse/-cover generally decreases with increasing distance from the observer point. A exponentially decreasing function was choosen to model the distance dependent influence of landcover. The distance- and landcover-specific values for all pixels finally were summed and added to/substracted from the basic viewfactor resulting in the final viewfactor. This weighting system including the specification of the function’s parameters was designed in a way that final viewfactors can only range between [0,2]. For further details, see the script documentation in the Appendix.
Reclassification of landcover/landuse classes

Result visualisation

The results of the analyses are framed as a web app to allow for an interactive exploration of the results. In the following, some excerpts are presented. If you’d like to explore the full functionality, you can do so easily by running the app’s source code hosted on GitHub on your local installation of R. All you have to do, is to execute the following commands:

runGitHub(repo="fkroeber/spatial_analysis", subdir="visibility_viewshed_analysis", ref="main")

The interface provide filter functionalities to display only the most beautiful view points according to their calculated viewfactors. A topographic or satellite imagery basemap can be choosen and the Corine landuse WMS can be overlayed. Once a specific viewpoint is selected, the map zooms to the surrounding area and shows the visible areas. Beneath the map frame basic stats regarding the ranking of the particular viewfactor compared to the distribution of viewfactors for both paths are provided. Additionally, statistics regarding the landcover/landuse classes for the visible areas are given.

Basic user interface with parameter options to the right and the map representation of paths and viewpoints to the right

Detail view for one specific viewpoint

Result discussion

Examining the results more closely leads to several interesting findings:

  • Viewfactors are generally close to zero which meets the expectations. Even for observer points that are located in high altitudes, the majority of the surrounding area is due to the complex topography still not visible.
  • Both paths show a similar distribution of viewfactors. In both cases there is a bunch of viewpoints with small viewsheds resulting in relatively low viewfactors. On the opposite, there is a small amount of points mostly belonging to the mountain ridges and peaks resulting in viewfactors that are substantially higher. Judging on the basis of mean viewfactors, the north-south oriented hiking path seems to be a bit more scenerary compared to the west-east route.
  • The whole area under investigation belongs to the apline region and, thus, is predominantly covered by forests and seminatural surfaces (including bare rocks). Consequently, the observation point specific variation in the influence of different landcovers/landuses is limited. To investigate this more closely, one may not focus on this regional area of interest instead but instead compare hiking paths from different geographical regions.
  • Since forests and seminatural areas are connotated positively (see above) applying the inverse distance weighting function results in greater viewfactors for points with their visible areas being nearby compared to points with an area of equal size being more distant. One example of this is presented below.

Comparsion of viewsheds, their location and resulting viewfactors for two observer points

Of course, the detailed evaluation of results also reveals some problematic cases in terms of counterintuitive assessment of views. The example to the right represents such a case with the viewpoint being rated as pretty poor (i.e. lower than the average of viewpoints) due to its topographically restrained viewshed. However, the location of the point close to a natural lake somewhere in the mountains suggests that it nevertheless may be one of the more beautiful ones. Most probably when it comes the subjective human experience, the very natural surroundings with complete absence of artificial surfaces compensated for the limited viewshed. This example is just one to illustrate the problem mentioned at the beginning of the post that its quite difficult to quantify a very subjective and complex perceptional phenomenom. Other problems that are not immediately evident from the results but nevertheless important to be aware of are:

  • The choosen DEM is (compared to a DSM) not capable of capturing the importance of objects and their heigths. Walking on a forest path, for example, views may be severly obscured by surrounding trees.
  • Temporally varying perception of landcovers can not be covered with the current approach. Especially the seasonally changing attractiveness of broad-leaved and mixed forests as well as agricultural fields is not represented.

Viewshed for an observer point surrounded by nature


Appendix - Scripts

part A - data analyses (ArcGISPro)

					import arcpy
import math
import numpy as np
import pandas as pd
import os

gdb ="current").defaultGeodatabase
home ="current").homeFolder

# Converting landuse raster to weight raster
reclass_table = pd.read_excel(os.path.join(home, "reclass_clc.xlsx")).iloc[:-1, 1:]
remapped_vals = [
    f"{x} {y}" for x, y in zip(reclass_table["CODE_18"], reclass_table["weight"])

outReclass =
    ";".join(remapped_vals + ["x 0"]),
), "landuse_weights"))

summary_stats = []
# performing viewshed & weighting for all points of interest
for point_id in range(1, int(str(arcpy.GetCount_management("observer_points")))):
    # isolate single point
    selected_point =
        "observer_points", "NEW_SELECTION", f"OBJECTID = {point_id}", None
    arcpy.CopyFeatures_management(selected_point, f"obs_point_{point_id}")
    # set max distance for viewshed
    max_dist = 40000
    # calc required extent for subsequent analyses
    srs = arcpy.SpatialReference(
        "Projected Coordinate Systems/UTM/WGS 1984/Northern Hemisphere/WGS 1984 UTM Zone 33N"
    x_y = [
        for row in arcpy.da.SearchCursor(
            f"obs_point_{point_id}", "SHAPE@XY", spatial_reference=srs
    required_extent = f"{x_y[0][0][0]-max_dist} {x_y[0][0][1]-max_dist} {x_y[0][0][0]+max_dist} {x_y[0][0][1]+max_dist}"
    # perform viewshed calculation
    with arcpy.EnvManager(
        out_raster =
            "0 Meters",
            "0 Meters",
            "1.75 Meters",
            f"{max_dist/1000} Kilometers",
        ), f"viewshed_{point_id}.tif"))
    # perform euclidean distance calculation
    with arcpy.EnvManager(
        snapRaster=os.path.join(home, f"viewshed_{point_id}.tif"),
        distance_raster =
            os.path.join(home, f"viewshed_{point_id}.tif"),
    # create inverse distance weighted raster
    with arcpy.EnvManager(extent=required_extent):
        idw_raster = arcpy.ia.RasterCalculator(
            "4 *  Exp(-0.1 * x/1000)",
    # extract landuse & landuse weights within viewshed
    with arcpy.EnvManager(
        snapRaster=os.path.join(home, f"viewshed_{point_id}.tif"),
        cellSize=os.path.join(home, f"viewshed_{point_id}.tif"),
        out_raster =
            os.path.join(home, f"viewshed_{point_id}.tif"),
        ), f"landuse_{point_id}.tif"))
        landuse_weights_raster =
            "landuse_weights", os.path.join(home, f"viewshed_{point_id}.tif")
    # get landuse stats for viewshed
    landuse_stats = [
            f'clc_{clc_class[0]}_{clc_class[1].replace(" ", "").replace("-", "")}',
        for clc_class in arcpy.da.SearchCursor(
            os.path.join(home, f"landuse_{point_id}.tif"),
            ["CODE_18", "LABEL3", "COUNT"],
    landuse_dict = {}
    for clc_class in landuse_stats:
        landuse_dict[clc_class[0]] = clc_class[1]
    # create modification factor by multiplying idw raster with landuse weights
    # add/substract mod factor to normal vis raster
    with arcpy.EnvManager(extent=required_extent):
        out_raster = arcpy.ia.RasterCalculator(
                os.path.join(home, f"viewshed_{point_id}.tif"),
            ["normal_vis", "landuse_weight", "dist_weight"],
            "normal_vis + landuse_weight * dist_weight",
        ), f"viewshed_evaluated_{point_id}.tif"))
    # get visibility stat by summarising vis raster vals
    viewshed_int =, f"viewshed_{point_id}.tif"))
        os.path.join(home, f"count_viewshed_{point_id}"),
    n_vis = [
        for row in arcpy.da.SearchCursor(f"count_viewshed_{point_id}", "COUNT")
    n_all = math.pi * math.pow(max_dist, 2) / math.pow(25, 2)
    mean =
        os.path.join(home, f"viewshed_evaluated_{point_id}.tif"), "MEAN"
    viewfactor = float(str(mean)) * n_vis[0][0] / n_all
    # calc further visibility stats (i.e. max vis range & vis area)
    vis_range =
        os.path.join(home, f"viewshed_{point_id}.tif"),
    max_vis_range = float(
        str(, "MAXIMUM"))
    vis_area_km2 = n_vis[0][0] * math.pow(0.025, 2)
    # append stats to summary
            "id": point_id,
            "viewfactor": viewfactor,
            "max_vis": max_vis_range,
            "area_vis": vis_area_km2,

df_stats = pd.DataFrame(summary_stats)
df_stats.to_csv(os.path.join(home, "summary_stats2.csv"))

part B - result visualisation (R)


ui <- fluidPage(
  titlePanel("Views along Austrian hiking paths"),
      checkboxInput("paths", "show paths", value = T, width = NULL),
      checkboxInput("points", "show viewpoints along paths", value = F, width = NULL),
        condition = "input.points == 1",
          cellWidths = c("10%", "90%"),
                 checkboxInput("points_colorise", "colorise viewpoints according to viewfactor", value = F, width = NULL),
                 p("filter points: best x% of viewpoints"),
                 sliderInput("points_filter", NULL, min = 0, max = 100, value = 100, width = NULL),
      p("transparency landcover/landuse (",
        HTML("<a href=>legend</a>"),
      sliderInput("landuse", NULL,
                  min = 0, max = 1, value = 1, width = NULL),
      p("Info: Basic stats for one specific viewpoint are displayed below the map frame as",
        "soon as a single marker is selected on the map")

server <- function(input, output, session) {
  # loads paths, points & stats
  paths = rgdal::readOGR("paths.geojson")
  paths_bbox = paths %>% st_bbox() %>% as.character()
  points = rgdal::readOGR("points.geojson")
  stats_all = read_csv("summary_stats.csv")
  print("input data loaded")

  # define static basemaps
  bmaps = c("Esri.WorldTopoMap", "Esri.WorldImagery")
  addBasemaps = function(x){
    for (i in bmaps){
      x = x %>% addProviderTiles(provider=i, group=i)

  output$map = renderLeaflet({
    leaflet() %>%
    fitBounds(paths_bbox[1], paths_bbox[2], paths_bbox[3], paths_bbox[4]) %>% 
    addBasemaps() %>% 
    addLayersControl(baseGroups = bmaps, 
                     options = layersControlOptions(collapsed = T, autoZIndex = F)) %>%
    addMiniMap(tiles = bmaps[[1]], toggleDisplay = F, position = "bottomleft") %>%
      function(el, x) {
        var myMap = this;
          function (e) {
      }") %>% 

  # add transparent landcover layer
  observe({leafletProxy("map") %>%
      clearGroup("clc_wms") %>%
                             layerId = "clc_wms",
                             dynamicMapLayerOptions(opacity = (1-input$landuse)))})
  # add hiking paths
  col_paths = map(.f = ~colorNumeric("Blues", c(-100,250))(.x),
                  .x = paths$OBJECTID)
  observe({leafletProxy("map") %>%
      clearGroup("paths") %>%
                           data = paths,
                           group = "paths",
                           opacity = 0.8,
                           color = unlist(col_paths),
                           label = ~paste0(route, " path: ", name)),
  # add (filtered & colorised) viewpoints
  best_points_geom = reactive({
    stats_all %>% 
      top_frac(input$points_filter/100, viewfactor) %>% 
      {points[points$OBJECTID %in% .$id,]}
  best_points_viewfactors = reactive({
    stats_all %>% 
      top_frac(input$points_filter/100, viewfactor) %>% 
  col_markers = reactive({
    viewranked_ids = stats_all %>% 
      arrange(desc(viewfactor)) %>% 
      transmute(id = id, view_rank = 1:dim(stats_all)[1]) %>% 
      right_join(points, by=c("id" = "OBJECTID"), copy=T) %>% 
      drop_na(view_rank) %>% 
      filter(id %in% best_points_geom()$OBJECTID) %>%
      arrange(id) %>%
           list(unlist(map(.x = viewranked_ids, 
                           .f = ~colorNumeric(rev("RdYlGn"), 
                                              c(0, dim(stats_all)[1]),
                                              reverse = T)(.x)))),
    leafletProxy("map") %>%
      clearGroup("points_multiple") %>% 
                         data = best_points_geom(),
                         layerId = best_points_geom()$OBJECTID,
                         label = sprintf("point_id: %s<br/>
                                         <strong>viewfactor: %.3f</strong><br/>",
                                         best_points_viewfactors()) %>%
                         color = unlist(col_markers()),
                         opacity = 0.8,
                         fillOpacity = 0.5,
                         group = "points_multiple"), 
  # register map clicks
  data <- reactiveValues(clickedMarker=NULL)
  clicklist <- reactiveVal(list())
  observeEvent(input$map_click, {
    click <- input$map_click
    temp <- clicklist()
    temp[[length(temp)+1]] <- click
    if (length(unlist(rev(clicklist())[1])) == 3 & 
        length(unlist(rev(clicklist())[2])) == 3) 
       {data$clickedMarker = NULL}
  observeEvent(input$map_marker_click, {
    click <- input$map_marker_click
    temp <- clicklist()
    temp[[length(temp)+1]] <- click
    data$clickedMarker = input$map_marker_click

  # define behaviour in case of marker clicks
  # click: zoom to point, show viewhshed raster and corresponding stats
  # unclick: show all viewpoints again
    view_point = data$clickedMarker
    if (is.null(view_point)) {
      leafletProxy("map") %>%
        clearGroup("points_multiple") %>%
        clearGroup("point_single") %>%
        clearGroup("viewshed_raster") %>%
        clearGroup("circles") %>%
      if (length(clicklist()) > 0){
        updateCheckboxInput(session, "points", value = T)
      output$stats_panel = NULL
    } else {
      updateCheckboxInput(session, "points", value = F)
      viewshed = raster(paste0("viewshed_", view_point$id, ".tif"))
      view_point_geom = points[points$OBJECTID==view_point$id,]
      view_point_stat = stats_all[stats_all$id==view_point$id,]
      point_viewfactor = view_point_stat$viewfactor
      point_viewarea = view_point_stat$area_vis
      landuse_stats_level_1 = map(
        .f = ~sum(stats_all[stats_all$id==view_point$id,] %>% select(starts_with(.x)), na.rm = T)*625/10000/
        .x = c("clc_1", "clc_2", "clc_3", "clc_4", "clc_5")
      paths_point_ids = points@data %>%
        group_by(route,name) %>%
      output$stats_panel = renderUI({
                 p(icon("eye-open", lib="glyphicon"), HTML("&nbsp;viewfactor compared to other locations")),
                 renderPlot(height=200, ggplot() +
                              map2(.f = ~geom_density(data=stats_all %>% filter(id %in% .x),
                                   .x = paths_point_ids,
                                   .y = col_paths) +
                                         size=1) +
                              geom_text(aes(x=view_point_stat$viewfactor, y=40,
                                        text=element_text(size=11)) +
                              #scale_y_sqrt() +
                              labs(y = "density") +
                 p(icon("globe", lib="glyphicon"), HTML("&nbsp;landuse/landcover within the field of vision")),
                 renderText({sprintf("artificial surfaces: %.1f%%", landuse_stats_level_1[[1]])}),
                 renderText({sprintf("agricultural surfaces: %.1f%%", landuse_stats_level_1[[2]])}),
                 renderText({sprintf("forests and seminatural surfaces: %.1f%%", landuse_stats_level_1[[3]])}),
                 renderText({sprintf("wetlands: %.1f%%", landuse_stats_level_1[[4]])}),
                 renderText({sprintf("water bodies: %.1f%%", landuse_stats_level_1[[5]])})
      leafletProxy("map") %>%
        clearGroup("points_multiple") %>%
        clearGroup("point_single") %>%
        clearGroup("viewshed_raster") %>%
        clearGroup("circles") %>%
        clearControls() %>%
        addCircleMarkers(data = view_point_geom,
                         color = col_markers(),
                         opacity = 0.8,
                         fillOpacity = 0.5,
                         group = "point_single") %>%
                        label="40 km radius",
                        radius=40000) %>%
                     project = F, 
                     group = "viewshed_raster",
                     autozoom = F,
                     colorOptions = leafem:::colorOptions(palette=c(rgb(0, 0, 0, max = 255, alpha = 0), "#000000"))) %>%
        flyTo(view_point$lng, view_point$lat, zoom=12) %>%
        addLegend(position = "bottomright",
                  colors = "#000000",
                  labels = "visible areas",
                  group = "viewshed_raster",
                  opacity = 1,
                  title = NULL)


shinyApp(ui, server)