Practical 7 Code Solution

``````"""
Understanding GIS: Practical 7
@author jonnyhuck

Calculate a Viewshed using Bresenham's Line and Midpoint Circle Algorithm

References:
https://matplotlib.org/3.1.1/gallery/color/colormap_reference.html
https://matplotlib.org/stable/gallery/text_labels_and_annotations/custom_legends.html
http://webhelp.esri.com/arcgisdesktop/9.3/index.cfm?TopicName=How%20Visibility%20works
https://www.zoran-cuckovic.from.hr/QGIS-visibility-analysis/help_qgis2.html#algorithm-options
https://matplotlib.org/api/_as_gen/matplotlib.axes.Axes.contour.html#matplotlib.axes.Axes.contour

New Topics:
break
pass
"""

from sys import exit
from geopandas import GeoSeries
from shapely.geometry import Point
from math import hypot, floor, ceil
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
from numpy import zeros, column_stack
from rasterio import open as rio_open
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable
from rasterio.plot import show as rio_show
from skimage.draw import line, circle_perimeter
from matplotlib.pyplot import subplots, savefig
from matplotlib_scalebar.scalebar import ScaleBar
from matplotlib.colors import LinearSegmentedColormap

def adjust_height(height, distance, earth_diameter=12740000, refraction_coefficient=0.13):
"""
* Adjust the apparant height of an object at a certain distance, accounting for the
* 	curvature of the earth and atmospheric refraction
*
* This is the Arc GIS version of the calculation for atmospheric refraction:
* 	a = distance**2 / earth_diameter
* 	return height - a + refraction_coefficient * a
"""
# this is the QGIS version of the calculation for atmospheric refraction
return height - (distance**2 / earth_diameter) * (1 - refraction_coefficient)

def line_of_sight(r0, c0, height0, r1, c1, height1, radius, dem_data, d, output):
"""
* Use Bresenham's Line algorithm to calculate a line of sight from one point to another point,
*	returning a list of visible cells
"""
# init variable for biggest dydx so far (starts at -infinity)
max_dydx = -float('inf')

# loop along the pixels in the line (exclusing the first one)
for r, c in column_stack(line(r0, c0, r1, c1))[1:]:

# calculate distance travelled so far (in pixels)
dx = hypot(c0 - c, r0 - r)

# if we go too far, or go off the edge of the data, stop looping
if dx > radius or not 0 <= r < d.height or not 0 <= c < d.width:
break

# calculate the current value for dy / dx
base_dydx = (adjust_height(dem_data[(r, c)], dx * d.res[0]) - height0) / dx
tip_dydx = (adjust_height(dem_data[(r, c)] + height1, dx * d.res[0]) - height0) / dx

# if the tip dydx is bigger than the previous max, it is visible
if tip_dydx > max_dydx:
output[(r, c)] = 1

# if the base dydx is bigger than the previous max, update
max_dydx = max(max_dydx, base_dydx)

# return updated output surface
return output

def viewshed(x0, y0, radius_m, observer_height, target_height, dem_data, d):
"""
* Use Midpoint Circle algorithm to determine endpoints for viewshed
"""
# convert origin coordinates to image space
r0, c0 = d.index(x0, y0)

# make sure that we are within the dataset
if not 0 <= r0 < d.height or not 0 <= c0 < d.width:
print(f"Sorry, {x0, y0} is not within the elevation dataset.")
exit()

# convert the radius (m) to pixels

# get the observer height (above sea level)
height0 = dem_data[(r0, c0)] + observer_height

# create output array at the same dimensions as data for viewshed
output = zeros(dem_data.shape)

# set the start location as visible automatically
output[(r0, c0)] = 1

# get pixels in the perimeter of the viewshed
for r, c in column_stack(circle_perimeter(r0, c0, radius_px*3)):

# calculate line of sight to each pixel, pass output and get a new one back each time
output = line_of_sight(r0, c0, height0, r, c, target_height, radius_px, dem_data, d, output)

# return the resulting viewshed
return output

# open the elevation data file
with rio_open("../data/helvellyn/Helvellyn-50.tif") as d:

# read the data out of band 1 in the dataset

# set origin for viewshed
x0, y0 = 332000, 514000

# calculate the viewshed
output = viewshed(x0, y0, 5000, 1.8, 100, dem_data, d)

# output image
fig, my_ax = subplots(1, 1, figsize=(16, 10))
my_ax.set_title("Viewshed Analysis")

# draw dem
rio_show(
dem_data,
ax=my_ax,
transform = d.transform,
cmap = 'viridis',
)

# draw dem as contours
rio_show(
dem_data,
ax=my_ax,
contour=True,
transform = d.transform,
colors = ['white'],
linewidths = [0.5],
)

rio_show(
output,
ax=my_ax,
transform=d.transform,
cmap = LinearSegmentedColormap.from_list('binary_viewshed', [(0, 0, 0, 0), (1, 0, 0, 0.5)], N=2)
)

# add origin point
GeoSeries(Point(x0, y0)).plot(
ax = my_ax,
markersize = 60,
color = 'black',
edgecolor = 'white'
)

# add a colour bar
fig.colorbar(ScalarMappable(norm=Normalize(vmin=floor(dem_data.min()), vmax=ceil(dem_data.max())), cmap='viridis'), ax=my_ax, pad=0.01)

# add north arrow
x, y, arrow_length = 0.97, 0.99, 0.1
my_ax.annotate('N', xy=(x, y), xytext=(x, y-arrow_length),
ha='center', va='center', fontsize=20, xycoords=my_ax.transAxes)