Examples

Habitat Corridor Mapping

In this example, we'll map a habitat corridor in central Maryland. We'll use SpatialGraphs.jl to convert the landscape into a graph, then calculate cost distances, and finally convert the result back into a raster to display a least cost corridor in space.

First, install the necessary packages and import them:

using Pkg
Pkg.add(["Rasters", "Plots", "Graphs", "SimpleWeightedGraphs", "SpatialGraphs"])
using SpatialGraphs, Rasters, Plots, Graphs, SimpleWeightedGraphs

Now, let's download the land cover map that we'll use as the basis for defining the weights of our graph, and plot it. Graph weights correspond to the cost of an animal moving from one graph vertex to another. This is also commonly referred to as "resistance" in the context of connectivity modeling.

url_base = "https://raw.githubusercontent.com/Circuitscape/datasets/main/"
download(string(url_base, "nlcd_2016_frederick_md.tif"),
         "nlcd_2016_frederick_md.tif")
values = [11, 21, 22, 23, 24, 31, 41, 42, 43, 52, 71, 81, 82, 90, 95]
palette = ["#476BA0", "#DDC9C9", "#D89382", "#ED0000", "#AA0000",
           "#b2b2b2", "#68AA63", "#1C6330", "#B5C98E", "#CCBA7C",
           "#E2E2C1", "#DBD83D", "#AA7028", "#BAD8EA", "#70A3BA"]

plot(Raster("nlcd_2016_frederick_md.tif"), title = "Land Cover Type", 
     xlabel = "Easting", ylabel = "Northing", 
     seriescolor = cgrad(palette, (values .- 12) ./ 84,
     categorical = true), size = (700, 640))

Load the land cover data as a Raster, read it into memory, then convert it to Float64.

landcover = convert.(
    Float64,
    read(
        Raster(
            "nlcd_2016_frederick_md.tif",
            missingval = -9999.0
        )
    )
)
725×694×1 Raster{Float64,3} with dimensions: 
  X Projected range(1.56369e6, stop=1.58541e6, length=725) ForwardOrdered Regular Intervals crs: WellKnownText,
  Y Projected range(1.98663e6, stop=1.96584e6, length=694) ReverseOrdered Regular Intervals crs: WellKnownText,
  Band Categorical 1:1 ForwardOrdered
with missingval: -9999.0
[:, :, 1]
 82.0  82.0  82.0  82.0  82.0  41.0  …  81.0  81.0  81.0  82.0  82.0  81.0
 82.0  82.0  82.0  82.0  82.0  41.0     82.0  82.0  82.0  82.0  81.0  81.0
 82.0  82.0  82.0  82.0  41.0  41.0     82.0  82.0  82.0  82.0  81.0  43.0
 82.0  82.0  82.0  82.0  82.0  41.0     82.0  82.0  82.0  82.0  82.0  81.0
 82.0  82.0  82.0  82.0  82.0  41.0     82.0  82.0  82.0  82.0  82.0  81.0
 82.0  82.0  82.0  82.0  82.0  41.0  …  82.0  82.0  82.0  82.0  82.0  81.0
  ⋮                             ⋮    ⋱               ⋮                
 43.0  43.0  43.0  43.0  43.0  81.0     82.0  22.0  21.0  81.0  81.0  43.0
 43.0  43.0  43.0  43.0  43.0  43.0  …  82.0  82.0  22.0  21.0  81.0  81.0
 43.0  43.0  43.0  43.0  43.0  43.0     82.0  82.0  22.0  22.0  21.0  43.0
 43.0  43.0  43.0  43.0  43.0  43.0     22.0  82.0  82.0  82.0  22.0  21.0
 43.0  43.0  43.0  43.0  43.0  81.0     82.0  82.0  82.0  82.0  82.0  22.0
 43.0  43.0  43.0  43.0  81.0  81.0     82.0  82.0  82.0  82.0  82.0  82.0

Now, we need to convert land cover into resistance by reclassifying the values in the land cover raster to their corresponding costs/weights.

# Copy the landcover object to use as a place holder for resistance
resistance = deepcopy(landcover)

# Define how values should be reclassified. Original values are on the left,
# and new values are on the right.
reclass_table = [
    11.	100; # Water
    21	500; # Developed, open space
    22	1000; # Developed, low intensity
    23	-9999; # Developed, medium intensity
    24	-9999; # Developed, high intensity
    31	100; # Barren land
    41	1; # Deciduous forest
    42	1; # Evergreen forest
    43	1; # Mixed forest
    52	20; # Shrub/scrub
    71	30; # Grassland/herbaceous
    81	200; # Pasture/hay
    82	300; # Cultivated crops
    90	20; # Woody wetlands
    95	30; # Emergent herbaceous wetlands
]

# Redefine the values in resistance based on the corresponding land cover
# values, using reclass_table
for i in 1:(size(reclass_table)[1])
    resistance[landcover .== reclass_table[i, 1]] .= reclass_table[i, 2]
end

Now that we have a resistance raster, we can start using SpatialGraphs.jl!

Convert the resistance raster into a WeightedRasterGraph:

wrg = weightedrastergraph(resistance)
WeightedRasterGraph{Int64, Float64}:
  graph: SimpleWeightedGraphs.SimpleWeightedGraph{Int64, Float64} with 472395 vertices and 1842168 edges
  vertex_raster: Raster{Int64, 3} with dimensions:
    X: range(1.56369e6, 1.58541e6, step=30.0)
    Y: range(1.96584e6, 1.98663e6, step=-30.0)
    Band: 1:1

Now that we have a WeightedRasterGraph, we can make use of the functions in Graphs.jl.

Let's calculate cost distances from two different vertices. Vertex IDs, the second argument to dijkstra_shortest_paths, correspond to the values in wrg.vertex_raster.

First, we'll use dijkstra_shortest_paths to calculate to total cost distance from each vertex of interest to all other vertices in the graph.

cd_1 = dijkstra_shortest_paths(wrg, 1)
cd_2 = dijkstra_shortest_paths(wrg, 348021)
Graphs.DijkstraState{Float64, Int64}([718, 718, 719, 5, 6, 723, 723, 724, 8, 11  …  471667, 471669, 471669, 471670, 471671, 472390, 471673, 471675, 471675, 471676], [25701.62603007754, 25577.361961365612, 25515.02282022846, 25278.70510711612, 24978.70510711612, 24678.70510711612, 24566.365965978966, 24466.451752416593, 24566.951752416593, 24565.709111729473  …  10062.751906195092, 10065.272900944012, 9982.430188469392, 9978.87679787612, 9917.037656738969, 10017.537656738969, 10003.812439463301, 9929.048370751374, 10013.362945802135, 9601.023804664985], [Int64[], Int64[], Int64[], Int64[], Int64[], Int64[], Int64[], Int64[], Int64[], Int64[]  …  Int64[], Int64[], Int64[], Int64[], Int64[], Int64[], Int64[], Int64[], Int64[], Int64[]], [4.0298935876110776e136, 1.3432978625370258e136, 1.3432978625370258e136, 1.4160598300911148e137, 1.4160598300911148e137, 1.4160598300911148e137, 1.4160598300911148e137, 8.496358980546688e136, 8.496358980546688e136, 7.365264750938306e135  …  1.7619493254311218e33, 1.006828185960641e33, 2.5170704649016025e32, 2.5170704649016025e32, 2.5170704649016025e32, 2.5170704649016025e32, 3.020484557881923e33, 1.006828185960641e33, 1.006828185960641e33, 1.006828185960641e33], Int64[])

Now, create placeholder rasters to store the cost distance values for each vertex, and populate them using the cost distances calculated above. Finally, we'll sum the rasters to enable us to delineate a corridor. The value at each pixel corresponds to the total cost distance of the least cost path between vertices 1 and 348021 that intersects that pixel.

# Create rasters
cd_1_raster = Raster( # Start with an empty raster that will be populated
    fill(float(resistance.missingval), size(wrg.vertex_raster)),
    dims(resistance),
    missingval=resistance.missingval
)
cd_2_raster = Raster(
    fill(float(wrg.vertex_raster.missingval), size(wrg.vertex_raster)),
    dims(resistance),
    missingval=resistance.missingval
)

# Populate with cost distances
cd_1_raster[wrg.vertex_raster .!= wrg.vertex_raster.missingval] .= cd_1.dists[
    wrg.vertex_raster[wrg.vertex_raster.!= wrg.vertex_raster.missingval]
]
cd_2_raster[wrg.vertex_raster .!= wrg.vertex_raster.missingval] .= cd_2.dists[
    wrg.vertex_raster[wrg.vertex_raster.!= wrg.vertex_raster.missingval]
]

# Sum the rasters to enalbe corridor mapping
cwd_sum = cd_1_raster + cd_2_raster
725×694×1 Raster{Float64,3} with dimensions: 
  X Projected range(1.56369e6, stop=1.58541e6, length=725) ForwardOrdered Regular Intervals crs: WellKnownText,
  Y Projected range(1.98663e6, stop=1.96584e6, length=694) ReverseOrdered Regular Intervals crs: WellKnownText,
  Band Categorical 1:1 ForwardOrdered
with missingval: -9999.0
[:, :, 1]
 25701.6  25701.6  25701.6  25814.9  …  73534.8  74034.8  74634.8  75134.8
 25877.4  25701.6  25701.6  25701.6     73841.9  74241.9  74741.9  75141.9
 26115.0  25939.3  25763.6  25763.6     74290.4  74690.4  74949.0  75026.1
 26178.7  26240.6  26125.9  25950.2     74608.8  75138.9  75449.0  75227.1
 26178.7  26240.6  26363.9  26486.0     74857.3  75457.3  75934.3  75627.1
 26136.5  26011.8  26009.8  26007.8  …  75105.9  75705.9  76305.9  76027.1
     ⋮                               ⋱      ⋮                      
 39101.2  39099.2  39097.2  39095.2     44093.3  44793.3  45193.3  45394.3
 39100.4  39098.4  39096.4  39094.4  …  44261.9  45493.3  45359.0  45595.3
 39099.5  39097.5  39095.5  39093.5     44261.1  45174.0  45066.9  45567.9
 39098.7  39096.7  39094.7  39092.7     43335.5  43935.5  45235.5  45418.3
 39099.5  39097.5  39095.5  39093.5     43087.0  43687.0  44287.0  45587.0
 39100.4  39098.4  39096.4  39094.4     42962.3  43562.3  44162.3  44762.3

Setting some land cover classes to NoData resulted in some vertices being completely isolated from the vertices from which we calculated cost distances above. In these cases, dijkstra_shortest_paths returns a value of Inf for the cost distance. Let's get rid of those by setting them to the NoData value of our raster.

cwd_sum[cwd_sum .== Inf] .= cwd_sum.missingval
1340-element view(::Array{Float64, 3}, CartesianIndex{3}[CartesianIndex(525, 46, 1), CartesianIndex(494, 65, 1), CartesianIndex(384, 144, 1), CartesianIndex(384, 145, 1), CartesianIndex(287, 151, 1), CartesianIndex(287, 152, 1), CartesianIndex(287, 153, 1), CartesianIndex(352, 165, 1), CartesianIndex(424, 168, 1), CartesianIndex(425, 168, 1)  …  CartesianIndex(609, 630, 1), CartesianIndex(653, 641, 1), CartesianIndex(666, 641, 1), CartesianIndex(242, 665, 1), CartesianIndex(245, 665, 1), CartesianIndex(246, 665, 1), CartesianIndex(247, 665, 1), CartesianIndex(248, 665, 1), CartesianIndex(246, 666, 1), CartesianIndex(247, 666, 1)]) with eltype Float64:
 -9999.0
 -9999.0
 -9999.0
 -9999.0
 -9999.0
 -9999.0
 -9999.0
 -9999.0
 -9999.0
 -9999.0
     ⋮
 -9999.0
 -9999.0
 -9999.0
 -9999.0
 -9999.0
 -9999.0
 -9999.0
 -9999.0
 -9999.0

Finally, let's plot the result! We'll use a threshold to set pixels above a certain value to NoData, so we will only retain the pixels within our "corridor".

corridor = copy(cwd_sum)
corridor[cwd_sum .>= (minimum(cwd_sum[cwd_sum .>= 0]) + 250)] .= -9999

plot(
    cwd_sum,
    title = "Cost Distance Corridor",
    xlabel = "Easting", ylabel = "Northing",
    colorbar_title = " \nCost Distance Sum",
    size = (650, 475)
)