Table of Contents
Introduction
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 „http://wiki.openstreetmap.org/wiki/WikiProject_Austria/Wanderwege“ 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 (land.copernicus.eu/pan-european/corine-land-cover/clc2018) 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.

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:
library(shiny)
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

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.
sds
Appendix - Scripts
part A - data analyses (ArcGISPro)
import arcpy
import math
import numpy as np
import pandas as pd
import os
gdb = arcpy.mp.ArcGISProject("current").defaultGeodatabase
home = arcpy.mp.ArcGISProject("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 = arcpy.sa.Reclassify(
"U2018_CLC2018_V2020_20u1.tif",
"CODE_18",
";".join(remapped_vals + ["x 0"]),
"NODATA",
)
outReclass.save(os.path.join(gdb, "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 = arcpy.management.SelectLayerByAttribute(
"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 = [
row
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(
scratchWorkspace=gdb,
outputCoordinateSystem='PROJCS["WGS_1984_UTM_Zone_33N",GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]],VERTCS["WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PARAMETER["Vertical_Shift",0.0],PARAMETER["Direction",1.0],UNIT["Meter",1.0]]',
cellSizeProjectionMethod="CONVERT_UNITS",
parallelProcessingFactor="100%",
extent=required_extent,
cellSize=25,
workspace=gdb,
):
out_raster = arcpy.sa.Viewshed2(
"Terrain",
f"obs_point_{point_id}",
None,
"FREQUENCY",
"0 Meters",
None,
0.13,
"0 Meters",
None,
"1.75 Meters",
None,
"GROUND",
f"{max_dist/1000} Kilometers",
"GROUND",
0,
360,
90,
-90,
"ALL_SIGHTLINES",
)
out_raster.save(os.path.join(home, f"viewshed_{point_id}.tif"))
# perform euclidean distance calculation
with arcpy.EnvManager(
snapRaster=os.path.join(home, f"viewshed_{point_id}.tif"),
extent=required_extent,
):
distance_raster = arcpy.sa.EucDistance(
f"obs_point_{point_id}",
None,
os.path.join(home, f"viewshed_{point_id}.tif"),
None,
"GEODESIC",
None,
None,
)
# create inverse distance weighted raster
with arcpy.EnvManager(extent=required_extent):
idw_raster = arcpy.ia.RasterCalculator(
[distance_raster],
["x"],
"4 * Exp(-0.1 * x/1000)",
)
# extract landuse & landuse weights within viewshed
with arcpy.EnvManager(
outputCoordinateSystem='PROJCS["WGS_1984_UTM_Zone_33N",GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],PARAMETER["False_Easting",500000.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",15.0],PARAMETER["Scale_Factor",0.9996],PARAMETER["Latitude_Of_Origin",0.0],UNIT["Meter",1.0]],VERTCS["WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PARAMETER["Vertical_Shift",0.0],PARAMETER["Direction",1.0],UNIT["Meter",1.0]]',
snapRaster=os.path.join(home, f"viewshed_{point_id}.tif"),
cellSize=os.path.join(home, f"viewshed_{point_id}.tif"),
extent=required_extent,
):
out_raster = arcpy.sa.ExtractByMask(
"U2018_CLC2018_V2020_20u1.tif",
os.path.join(home, f"viewshed_{point_id}.tif"),
)
out_raster.save(os.path.join(home, f"landuse_{point_id}.tif"))
landuse_weights_raster = arcpy.sa.ExtractByMask(
"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("-", "")}',
clc_class[2],
]
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"),
landuse_weights_raster,
idw_raster,
],
["normal_vis", "landuse_weight", "dist_weight"],
"normal_vis + landuse_weight * dist_weight",
)
out_raster.save(os.path.join(home, f"viewshed_evaluated_{point_id}.tif"))
# get visibility stat by summarising vis raster vals
viewshed_int = arcpy.sa.Int(os.path.join(home, f"viewshed_{point_id}.tif"))
arcpy.sa.ZonalStatisticsAsTable(
viewshed_int,
"Value",
viewshed_int,
os.path.join(home, f"count_viewshed_{point_id}"),
"DATA",
"SUM",
"CURRENT_SLICE",
90,
"AUTO_DETECT",
)
n_vis = [
list(row)
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 = arcpy.management.GetRasterProperties(
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 = arcpy.sa.ExtractByMask(
distance_raster,
os.path.join(home, f"viewshed_{point_id}.tif"),
)
max_vis_range = float(
str(arcpy.management.GetRasterProperties(vis_range, "MAXIMUM"))
)
vis_area_km2 = n_vis[0][0] * math.pow(0.025, 2)
# append stats to summary
summary_stats.append(
{
"id": point_id,
"viewfactor": viewfactor,
"max_vis": max_vis_range,
"area_vis": vis_area_km2,
**landuse_dict,
}
)
df_stats = pd.DataFrame(summary_stats)
df_stats.to_csv(os.path.join(home, "summary_stats2.csv"))
part B - result visualisation (R)
library(shiny)
library(sf)
library(leafem)
library(leaflet)
library(leaflet.extras)
library(leaflet.esri)
library(raster)
library(rgdal)
library(tidyverse)
ui <- fluidPage(
titlePanel("Views along Austrian hiking paths"),
sidebarLayout(
sidebarPanel(
verbatimTextOutput("clickInfo"),
checkboxInput("paths", "show paths", value = T, width = NULL),
checkboxInput("points", "show viewpoints along paths", value = F, width = NULL),
conditionalPanel(
condition = "input.points == 1",
splitLayout(
cellWidths = c("10%", "90%"),
column(width=12),
column(width=12,
checkboxInput("points_colorise", "colorise viewpoints according to viewfactor", value = F, width = NULL),
br(),
p("filter points: best x% of viewpoints"),
sliderInput("points_filter", NULL, min = 0, max = 100, value = 100, width = NULL),
br()
)
)
),
p("transparency landcover/landuse (",
HTML("legend"),
")"),
sliderInput("landuse", NULL,
min = 0, max = 1, value = 1, width = NULL),
br(),
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")
),
mainPanel(
verticalLayout(
leafletOutput("map"),
br(),
uiOutput("stats_panel")
)
)
)
)
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)
}
return(x)
}
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") %>%
htmlwidgets::onRender("
function(el, x) {
var myMap = this;
myMap.on('baselayerchange',
function (e) {
myMap.minimap.changeLayer(L.tileLayer.provider(e.name));
})
}") %>%
addMouseCoordinates()
})
# add transparent landcover layer
observe({leafletProxy("map") %>%
clearGroup("clc_wms") %>%
addEsriDynamicMapLayer("https://image.discomap.eea.europa.eu/arcgis/rest/services/Corine/CLC2018_WM/MapServer",
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") %>%
{ifelse(input$paths,
addPolylines(.,
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) %>%
.$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) %>%
.$view_rank
ifelse(input$points_colorise,
list(unlist(map(.x = viewranked_ids,
.f = ~colorNumeric(rev("RdYlGn"),
c(0, dim(stats_all)[1]),
reverse = T)(.x)))),
list("#838383"))
})
observe({
leafletProxy("map") %>%
clearGroup("points_multiple") %>%
{ifelse(input$points,
addCircleMarkers(.,
data = best_points_geom(),
layerId = best_points_geom()$OBJECTID,
label = sprintf("point_id: %s
viewfactor: %.3f
",
best_points_geom()$OBJECTID,
best_points_viewfactors()) %>%
lapply(htmltools::HTML),
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
clicklist(temp)
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
clicklist(temp)
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
observe({
view_point = data$clickedMarker
if (is.null(view_point)) {
leafletProxy("map") %>%
clearGroup("points_multiple") %>%
clearGroup("point_single") %>%
clearGroup("viewshed_raster") %>%
clearGroup("circles") %>%
clearControls()
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/
(stats_all[stats_all$id==view_point$id,]$area_vis),
.x = c("clc_1", "clc_2", "clc_3", "clc_4", "clc_5")
)
paths_point_ids = points@data %>%
group_by(route,name) %>%
group_map(~.x$OBJECTID)
output$stats_panel = renderUI({
splitLayout(
column(width=12,
p(icon("eye-open", lib="glyphicon"), HTML(" viewfactor compared to other locations")),
renderPlot(height=200, ggplot() +
map2(.f = ~geom_density(data=stats_all %>% filter(id %in% .x),
aes(x=viewfactor),
colour=.y,
size=1),
.x = paths_point_ids,
.y = col_paths) +
geom_vline(aes(xintercept=view_point_stat$viewfactor),
linetype="dashed",
colour="black",
size=1) +
geom_text(aes(x=view_point_stat$viewfactor, y=40,
label="\ncurrent"),
colour="black",
angle=90,
text=element_text(size=11)) +
#scale_y_sqrt() +
labs(y = "density") +
theme_classic())
),
column(width=12,
p(icon("globe", lib="glyphicon"), HTML(" 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") %>%
addGreatCircles(view_point$lat,
view_point$lng,
label="40 km radius",
steps=200,
color="#000000",
group="circles",
weight=2,
radius=40000) %>%
addGeoRaster(x=viewshed,
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)