Source code: Least-cost path analysis in Python: cost surfaces and walking routes up Yr Wyddfa
Runnable Python scripts, requirements and console output for the tutorial “Least-cost path analysis in Python: cost surfaces and walking routes up Yr…
Last updated
These are the exact scripts used to produce every figure and number in Least-cost path analysis in Python: cost surfaces and walking routes up Yr Wyddfa. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- least_cost_path.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython least_cost_path.pyleast_cost_path.py
"""Least-cost path analysis in Python: Pen-y-Pass to the summit of Yr Wyddfa (Snowdon).
Builds a walking cost surface from real slope (Copernicus GLO-30 DEM) and land cover(ESA WorldCover 2021), computes the accumulated cost (travel time) surface from Pen-y-Passwith scikit-image's MCP_Geometric, traces the least-cost path to the summit, and compares itwith the straight line and with the footpaths mapped in OpenStreetMap. It then changes thecost weights to show how each choice moves the route.
Data: Copernicus DEM GLO-30 and ESA WorldCover from the Microsoft Planetary Computer (noaccount needed) and OpenStreetMap footpaths from the Overpass API.Run with: python least_cost_path.py"""import jsonimport platformimport timefrom importlib.metadata import versionfrom pathlib import Path
import matplotlib
matplotlib.use("Agg")import matplotlib.pyplot as pltimport networkx as nximport numpy as npimport planetary_computerimport pystac_clientimport rasterioimport requestsimport skimagefrom matplotlib.colors import BoundaryNorm, LightSource, ListedColormap, LogNormfrom matplotlib.lines import Line2Dfrom pyproj import Transformerfrom rasterio import featuresfrom rasterio.transform import from_origin, rowcolfrom rasterio.warp import Resampling, reprojectfrom scipy import ndimage, sparsefrom scipy.sparse import csgraphfrom shapely.geometry import LineStringfrom skimage.graph import MCP_Geometric
HERE = Path(__file__).parentOUT = HERE / "output"DATA = HERE / "data"OUT.mkdir(exist_ok=True)DATA.mkdir(exist_ok=True)plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})SPECTRAL = plt.get_cmap("Spectral_r")
def section(title): print(f"\n=== {title} ===")
def tobler_kmh(s): """Tobler's hiking function. Pass a signed gradient for direction, or abs(gradient) for the isotropic form.""" return 6.0 * np.exp(-3.5 * np.abs(s + 0.05))
section("versions")print(f"python {platform.python_version()} | scikit-image {skimage.__version__} | numpy {np.__version__} | " f"rasterio {rasterio.__version__} | networkx {nx.__version__} | scipy {version('scipy')}")
# ------------------------------------------------------------------ 1. study area and end pointsRES = 10.0 # metres: WorldCover's native cell sizeX0, Y0, X1, Y1 = 258_000, 351_000, 267_000, 359_000 # 9 km x 8 km around Yr Wyddfa, British National GridW, H = int((X1 - X0) / RES), int((Y1 - Y0) / RES)TRANSFORM = from_origin(X0, Y1, RES, RES)TO_BNG = Transformer.from_crs(4326, 27700, always_xy=True)TO_LL = Transformer.from_crs(27700, 4326, always_xy=True)START = ("Pen-y-Pass", 53.08066, -4.01934) # car park and youth hostel at the top of the Llanberis PassEND = ("Yr Wyddfa summit", 53.06850, -4.07620) # summit trig point, 1,085 msx, sy = TO_BNG.transform(START[2], START[1])ex, ey = TO_BNG.transform(END[2], END[1])section("end points")print(f"start {START[0]}: {sx:.0f} E, {sy:.0f} N | end {END[0]}: {ex:.0f} E, {ey:.0f} N | " f"straight-line distance {np.hypot(ex - sx, ey - sy):.0f} m")lon0, lat0, lon1, lat1 = TO_LL.transform_bounds(X0, Y0, X1, Y1)catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1", modifier=planetary_computer.sign_inplace)
def mosaic(collection, asset, path, resampling, dtype, query=None): """Reproject every STAC item that touches the box onto the 10 m BNG grid.""" if not path.exists(): items = [i for i in catalog.search(collections=[collection], bbox=[lon0, lat0, lon1, lat1]).items() if query is None or query in i.id] print(f"{collection}: {', '.join(i.id for i in items)}") nodata = np.nan if dtype == "float32" else 0 out = np.full((H, W), nodata, dtype=dtype) for item in items: with rasterio.open(item.assets[asset].href) as src: part = np.full((H, W), nodata, dtype=dtype) reproject(rasterio.band(src, 1), part, dst_transform=TRANSFORM, dst_crs="EPSG:27700", dst_nodata=nodata, resampling=resampling) empty = np.isnan(out) if dtype == "float32" else out == 0 out = np.where(empty, part, out) with rasterio.open(path, "w", driver="GTiff", width=W, height=H, count=1, dtype=dtype, crs="EPSG:27700", transform=TRANSFORM, compress="deflate") as dst: dst.write(out, 1) with rasterio.open(path) as src: return src.read(1)
section("rasters on a 10 m British National Grid")dem = mosaic("cop-dem-glo-30", "data", DATA / "glo30_snowdon_10m.tif", Resampling.bilinear, "float32").astype("float64")lc = mosaic("esa-worldcover", "map", DATA / "worldcover_snowdon_10m.tif", Resampling.nearest, "uint8", query="2021")print(f"grid {W} x {H} cells | DEM {np.nanmin(dem):.0f}-{np.nanmax(dem):.0f} m, missing {np.isnan(dem).sum()} | " f"WorldCover classes {sorted(np.unique(lc).tolist())}")
# ------------------------------------------------------------------ 2. OpenStreetMap footpathsOVERPASS = ["https://overpass-api.de/api/interpreter", "https://overpass.private.coffee/api/interpreter", "https://maps.mail.ru/osm/tools/overpass/api/interpreter"]QUERY = f"""[out:json][timeout:90];way["highway"~"^(path|footway|track|bridleway|steps)$"]({lat0},{lon0},{lat1},{lon1});(._;>;);out;"""
def overpass(query, cache): if cache.exists(): return json.loads(cache.read_text()) for attempt in range(4): for url in OVERPASS: try: r = requests.post(url, data={"data": query}, timeout=120, headers={"User-Agent": "technicalgis-tutorial"}) r.raise_for_status() els = r.json()["elements"] cache.write_text(json.dumps(els)) return els except (requests.RequestException, ValueError): continue time.sleep(15 * (attempt + 1)) raise RuntimeError("all Overpass mirrors failed")
section("OpenStreetMap footpaths")els = overpass(QUERY, DATA / "osm_paths_snowdon.json")nodes = {e["id"]: TO_BNG.transform(e["lon"], e["lat"]) for e in els if e["type"] == "node"}ways = [e for e in els if e["type"] == "way"]SCRAMBLES = {"demanding_mountain_hiking", "alpine_hiking", "demanding_alpine_hiking", "difficult_alpine_hiking"}
def z_at(x, y): """Bilinear DEM height at British National Grid coordinates.""" col = (np.asarray(x) - X0) / RES - 0.5 row = (Y1 - np.asarray(y)) / RES - 0.5 return ndimage.map_coordinates(dem, [np.atleast_1d(row), np.atleast_1d(col)], order=1, mode="nearest")
G = nx.Graph()for w in ways: nd, tags = w["nodes"], w.get("tags", {}) for a, b in zip(nd[:-1], nd[1:]): (xa, ya), (xb, yb) = nodes[a], nodes[b] length = float(np.hypot(xb - xa, yb - ya)) grade = abs(float(z_at(xb, yb)[0] - z_at(xa, ya)[0])) / max(length, 0.1) G.add_edge(a, b, length=length, minutes=length * 3.6 / tobler_kmh(grade) / 60, name=tags.get("name", ""), sac=tags.get("sac_scale", ""))paths_len = sum(d["length"] for *_, d in G.edges(data=True))print(f"{len(ways)} ways, {G.number_of_nodes()} nodes, {paths_len / 1000:.1f} km of path in the box")named = sorted({w["tags"]["name"] for w in ways if "name" in w.get("tags", {})})print("named paths:", "; ".join(named))sac = {}for *_, d in G.edges(data=True): sac[d["sac"] or "(no sac_scale)"] = sac.get(d["sac"] or "(no sac_scale)", 0) + d["length"] / 1000print("sac_scale:", ", ".join(f"{k} {v:.1f} km" for k, v in sorted(sac.items(), key=lambda kv: -kv[1])))comps = sorted(nx.connected_components(G), key=len, reverse=True)print(f"{len(comps)} connected pieces; the largest holds {len(comps[0]) / G.number_of_nodes() * 100:.0f}% of the nodes")node_ids = np.array(list(comps[0])) # snap only to the main network, not to stray fragmentsnode_xy = np.array([nodes[n] for n in node_ids])
def snap(x, y): return node_ids[np.argmin(np.hypot(node_xy[:, 0] - x, node_xy[:, 1] - y))]
n_start, n_end = snap(sx, sy), snap(ex, ey)print(f"start snapped {np.hypot(*(np.array(nodes[n_start]) - (sx, sy))):.0f} m, " f"end snapped {np.hypot(*(np.array(nodes[n_end]) - (ex, ey))):.0f} m to the path network")(sx, sy), (ex, ey) = nodes[n_start], nodes[n_end] # every route below uses the same two snapped points
def osm_route(graph, weight, label): ids = nx.shortest_path(graph, n_start, n_end, weight=weight) used = [] for a, b in zip(ids[:-1], ids[1:]): nm = graph[a][b]["name"] or "(unnamed)" if not used or used[-1][0] != nm: used.append([nm, 0.0]) used[-1][1] += graph[a][b]["length"] line = LineString([nodes[n] for n in ids]) mins = sum(graph[a][b]["minutes"] for a, b in zip(ids[:-1], ids[1:])) hard = sum(graph[a][b]["length"] for a, b in zip(ids[:-1], ids[1:]) if graph[a][b]["sac"] in SCRAMBLES) print(f"{label}: {line.length:.0f} m, {mins:.0f} min, {hard:.0f} m graded as scrambling | via " + ", ".join(f"{n} {l:.0f} m" for n, l in used if l > 100)) return line
shortest_line = osm_route(G, "length", "shortest OSM route")walkers = G.edge_subgraph([(a, b) for a, b, d in G.edges(data=True) if d["sac"] not in SCRAMBLES])osm_line = osm_route(walkers, "minutes", "fastest OSM route without scrambles")
# ------------------------------------------------------------------ 3. cost surfacesection("cost surface")gy, gx = np.gradient(dem, RES)slope = np.hypot(gx, gy) # rise over runslope_deg = np.degrees(np.arctan(slope))speed = tobler_kmh(slope) # km/h; slope >= 0, so up and down cost the sameslope_cost = 3.6 / speed # seconds per metreprint(f"slope {np.percentile(slope_deg, 50):.1f} deg median, {np.percentile(slope_deg, 99):.1f} deg 99th percentile, " f"{slope_deg.max():.1f} deg max")print(f"Tobler speed: {tobler_kmh(0):.2f} km/h on the flat, {tobler_kmh(np.tan(np.radians(20))):.2f} km/h at 20 deg, " f"{tobler_kmh(np.tan(np.radians(40))):.2f} km/h at 40 deg")
WC = {10: ("Tree cover", "#2b8a3e", 1.5), 20: ("Shrubland", "#a9c25d", 1.3), 30: ("Grassland", "#f3e48c", 1.0), 40: ("Cropland", "#f0a73a", 1.2), 50: ("Built-up", "#d7301f", 1.0), 60: ("Bare / sparse", "#bdbdbd", 1.25), 70: ("Snow and ice", "#f7f7f7", 1.5), 80: ("Permanent water", "#3182bd", np.inf), 90: ("Herbaceous wetland", "#5ab4ac", 2.0), 100: ("Moss and lichen", "#e0c9a6", 1.1)}lc_mult = np.ones_like(dem)for code, (_, _, m) in WC.items(): lc_mult[lc == code] = mfor code in np.unique(lc): if code in WC: print(f" {code:>3} {WC[code][0]:<20} {(lc == code).mean() * 100:5.1f}% of cells multiplier {WC[code][2]}")cost = slope_cost * lc_multprint(f"combined cost: {np.nanmin(cost):.2f}-{np.nanmax(cost[np.isfinite(cost)]):.2f} s/m, " f"{(~np.isfinite(cost)).sum()} impassable water cells")
# ------------------------------------------------------------------ 4. accumulated cost and least-cost pathsection("accumulated cost and least-cost path")r0, c0 = rowcol(TRANSFORM, sx, sy)r1, c1 = rowcol(TRANSFORM, ex, ey)xs = X0 + (np.arange(W) + 0.5) * RESys = Y1 - (np.arange(H) + 0.5) * RESosm_mask = features.rasterize([(osm_line, 1)], out_shape=(H, W), transform=TRANSFORM, all_touched=True)all_paths = [LineString([nodes[a], nodes[b]]) for a, b in G.edges]paths_mask = features.rasterize([(g, 1) for g in all_paths], out_shape=(H, W), transform=TRANSFORM, all_touched=True)dist_to_osm = ndimage.distance_transform_edt(osm_mask == 0) * RESdist_to_any = ndimage.distance_transform_edt(paths_mask == 0) * RES
def least_cost(costs): t = time.perf_counter() mcp = MCP_Geometric(costs, fully_connected=True, sampling=(RES, RES)) acc, _ = mcp.find_costs([(r0, c0)]) rc = np.array(mcp.traceback((r1, c1))) return acc, rc, time.perf_counter() - t
def describe(rc): line = LineString(np.c_[xs[rc[:, 1]], ys[rc[:, 0]]]) z = dem[rc[:, 0], rc[:, 1]] d_osm = dist_to_osm[rc[:, 0], rc[:, 1]] d_any = dist_to_any[rc[:, 0], rc[:, 1]] stats = dict(length=line.length, ascent=float(np.clip(np.diff(z), 0, None).sum()), within50=float((d_osm <= 50).mean() * 100), within50_any=float((d_any <= 50).mean() * 100), mean_d=float(d_osm.mean())) return line, stats
def walk(line, step=10.0): """Walk a line uphill from Pen-y-Pass: Tobler speed from the gradient along the line, sampled every 10 m.""" d = np.arange(0, line.length + step, step).clip(max=line.length) p = np.array([line.interpolate(v).coords[0] for v in d]) z = z_at(p[:, 0], p[:, 1]) ds = np.diff(d) ok = ds > 0 grade = np.diff(z)[ok] / ds[ok] rr, cc = rowcol(TRANSFORM, p[:, 0], p[:, 1]) terrain = slope_deg[np.array(rr), np.array(cc)] return dict(minutes=float(np.sum(ds[ok] * 3.6 / tobler_kmh(grade)) / 60), ascent=float(np.clip(np.diff(z), 0, None).sum()), water=float((lc[np.array(rr), np.array(cc)] == 80).mean() * line.length), steepest_terrain=float(terrain.max()), over30=float((terrain > 30).mean() * 100))
acc, rc, dt = least_cost(cost)lcp, st = describe(rc)print(f"MCP_Geometric on {W * H:,} cells: {dt:.2f} s | accumulated cost at the summit {acc[r1, c1] / 60:.0f} min")print(f"least-cost path: {st['length']:.0f} m, {len(rc)} cells | {st['within50']:.0f}% within 50 m of the OSM route, " f"{st['within50_any']:.0f}% within 50 m of any mapped path, mean distance from the OSM route {st['mean_d']:.0f} m")
straight = LineString([(sx, sy), (ex, ey)])naismith = lambda length, w: length / 5000 * 60 + w["ascent"] / 100 * 10print(f"{'route':<22} {'length':>8} {'Tobler':>8} {'Naismith':>9} {'ascent':>7} {'water':>6} {'>30 deg':>8} {'steepest':>9}")for name, line in (("straight line", straight), ("OSM walkers' route", osm_line), ("OSM shortest route", shortest_line), ("least-cost path", lcp)): w = walk(line) print(f"{name:<22} {line.length:7.0f}m {w['minutes']:6.0f}mn {naismith(line.length, w):7.0f}mn " f"{w['ascent']:6.0f}m {w['water']:5.0f}m {w['over30']:7.0f}% {w['steepest_terrain']:7.0f}deg")
# ------------------------------------------------------------------ 5. how the weights change the routesection("cost weights and the route")steep = np.where(slope_deg > 30, 4.0, 1.0) # extra penalty for scrambling groundscenarios = { "Distance only": np.where(lc == 80, np.inf, 1.0), "Slope only (Tobler)": slope_cost, "Slope + land cover": cost, "Slope + land cover + 4x above 30 deg": cost * steep, "As above + 0.5x on mapped paths": cost * steep * np.where(paths_mask == 1, 0.5, 1.0),}results = {}for name, cst in scenarios.items(): _, rcs, dts = least_cost(cst) line, s = describe(rcs) s.update(walk(line)) # every route walked with the same Tobler rule results[name] = (line, s) print(f"{name:<37} {s['length']:5.0f} m | {s['minutes']:4.0f} min | ascent {s['ascent']:4.0f} m | water " f"{s['water']:4.0f} m | {s['over30']:3.0f}% over 30 deg | {s['within50']:3.0f}% within 50 m of the OSM route, " f"{s['within50_any']:3.0f}% of any path | {dts:.2f} s")
conn = {}for fc in (False, True): mcp = MCP_Geometric(cost, fully_connected=fc, sampling=(RES, RES)) mcp.find_costs([(r0, c0)]) conn[fc] = describe(np.array(mcp.traceback((r1, c1))))[0].lengthprint(f"4-connected moves: {conn[False]:.0f} m | 8-connected moves: {conn[True]:.0f} m " f"(diagonals cut the zigzag by {(1 - conn[True] / conn[False]) * 100:.1f}%)")
# ------------------------------------------------------------------ 6. direction-aware (anisotropic) costsection("anisotropic cost with scipy.sparse.csgraph")t = time.perf_counter()idx = np.arange(H * W).reshape(H, W)src_l, dst_l, w_l = [], [], []for dr, dc in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: rs = slice(max(0, -dr), H - max(0, dr)); cs_ = slice(max(0, -dc), W - max(0, dc)) # source cells rd = slice(rs.start + dr, rs.stop + dr); cd = slice(cs_.start + dc, cs_.stop + dc) # their neighbours step_len = RES * np.hypot(dr, dc) grade = (dem[rd, cd] - dem[rs, cs_]) / step_len # signed: positive is uphill secs = step_len * 3.6 / tobler_kmh(grade) * (lc_mult[rs, cs_] + lc_mult[rd, cd]) / 2 ok = np.isfinite(secs) src_l.append(idx[rs, cs_][ok]); dst_l.append(idx[rd, cd][ok]); w_l.append(secs[ok])graph = sparse.csr_matrix((np.concatenate(w_l), (np.concatenate(src_l), np.concatenate(dst_l))), shape=(H * W, H * W))t_build = time.perf_counter() - tt = time.perf_counter()acc_aniso, pred = csgraph.dijkstra(graph, directed=True, indices=idx[r0, c0], return_predecessors=True)t_dij = time.perf_counter() - tnode, seq = idx[r1, c1], []while node != idx[r0, c0]: seq.append(node); node = pred[node]seq.append(node)rc_an = np.array(np.unravel_index(np.array(seq[::-1]), (H, W))).Taniso, st_an = describe(rc_an)st_an.update(walk(aniso))print(f"graph: {graph.shape[0]:,} nodes, {graph.nnz:,} directed edges, built in {t_build:.2f} s | Dijkstra {t_dij:.2f} s")print(f"accumulated cost at the summit {acc_aniso[idx[r1, c1]] / 60:.0f} min (isotropic: {acc[r1, c1] / 60:.0f} min)")print(f"anisotropic least-cost path: {st_an['length']:.0f} m | Tobler {st_an['minutes']:.0f} min | ascent " f"{st_an['ascent']:.0f} m | {st_an['over30']:.0f}% over 30 deg | {st_an['within50']:.0f}% within 50 m of the OSM " f"route, {st_an['within50_any']:.0f}% of any path | mean offset {st_an['mean_d']:.0f} m")results["Anisotropic Tobler + land cover"] = (aniso, st_an)
# ------------------------------------------------------------------ 6. figuressection("figures")hs = LightSource(azdeg=315, altdeg=45).hillshade(dem, vert_exag=1.5, dx=RES, dy=RES)ext = (X0, X1, Y0, Y1)zoom = (259_500, 266_000, 352_500, 357_500)water = np.ma.masked_where(lc != 80, lc)BLUE = ListedColormap(["#3182bd"])
def base(ax, z=True): ax.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1) ax.imshow(water, cmap=BLUE, extent=ext, alpha=0.9) if z: ax.set_xlim(zoom[0], zoom[1]); ax.set_ylim(zoom[2], zoom[3]) ax.set_xticks([]); ax.set_yticks([])
def pts(ax): ax.plot(sx, sy, "o", ms=8, mfc="white", mec="black", zorder=6) ax.plot(ex, ey, "^", ms=9, mfc="white", mec="black", zorder=6) ax.annotate("Pen-y-Pass", (sx, sy), xytext=(6, -14), textcoords="offset points", fontsize=8, bbox=dict(fc="white", ec="none", alpha=0.85, pad=1)) ax.annotate("Yr Wyddfa", (ex, ey), xytext=(-24, 10), textcoords="offset points", fontsize=8, bbox=dict(fc="white", ec="none", alpha=0.85, pad=1))
# Figure 1: study areafig, ax = plt.subplots(figsize=(8.5, 7))ax.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)im = ax.imshow(dem, cmap=SPECTRAL, extent=ext, alpha=0.55)ax.imshow(water, cmap=BLUE, extent=ext)for g in all_paths: ax.plot(*g.xy, color="black", lw=0.6, alpha=0.8)ax.plot(*osm_line.xy, color="white", lw=4.5); ax.plot(*osm_line.xy, color="#7b3294", lw=2.5)ax.plot(*straight.xy, color="black", lw=1.5, ls="--")ax.plot(*shortest_line.xy, color="#d53e4f", lw=2, ls=":")pts(ax)ax.set_xlim(X0, X1); ax.set_ylim(Y0, Y1); ax.set_xticks([]); ax.set_yticks([])fig.colorbar(im, ax=ax, shrink=0.7, label="elevation (m, Copernicus GLO-30)")ax.legend(handles=[Line2D([], [], color="black", lw=0.6, label="OSM footpaths and tracks"), Line2D([], [], color="#7b3294", lw=2.5, label=f"fastest OSM route without scrambles ({osm_line.length / 1000:.1f} km)"), Line2D([], [], color="#d53e4f", lw=2, ls=":", label=f"shortest OSM route, over Crib Goch ({shortest_line.length / 1000:.1f} km)"), Line2D([], [], color="black", lw=1.5, ls="--", label=f"straight line ({straight.length / 1000:.1f} km)"), Line2D([], [], color="#3182bd", lw=6, label="permanent water (WorldCover)")], loc="lower left", fontsize=7.5, framealpha=0.9)ax.set_title("Yr Wyddfa (Snowdon): 9 km x 8 km on British National Grid, 10 m cells")fig.savefig(OUT / "01-study-area.png"); plt.close(fig)
# Figure 2: cost-surface building stepsfig, axs = plt.subplots(2, 3, figsize=(13, 8.4))a = axs[0, 0]a.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1)im = a.imshow(slope_deg, cmap=SPECTRAL, extent=ext, vmin=0, vmax=50, alpha=0.75)a.set_xticks([]); a.set_yticks([]); fig.colorbar(im, ax=a, shrink=0.75, label="slope (degrees)")a.set_title("1. Slope from the DEM")a = axs[0, 1]s = np.linspace(0, 1.2, 200)a.plot(np.degrees(np.arctan(s)), tobler_kmh(s), color="#d7301f", lw=2)a.set_xlabel("slope (degrees)"); a.set_ylabel("walking speed (km/h)"); a.grid(alpha=0.3)a.set_title("2. Tobler's hiking function (isotropic)")for deg in (0, 10, 20, 30, 40): v = tobler_kmh(np.tan(np.radians(deg))) a.annotate(f"{v:.1f} km/h", (deg, v), xytext=(4, 4), textcoords="offset points", fontsize=7.5) a.plot(deg, v, "o", color="#d7301f", ms=4)a = axs[0, 2]im = a.imshow(slope_cost, cmap=SPECTRAL, extent=ext, norm=LogNorm(0.7, 15))a.set_xticks([]); a.set_yticks([]); fig.colorbar(im, ax=a, shrink=0.75, label="seconds per metre")a.set_title("3. Slope cost = 3.6 / speed")codes = sorted(WC)a = axs[1, 0]a.imshow(np.ma.masked_equal(lc, 0), cmap=ListedColormap([WC[c][1] for c in codes]), extent=ext, norm=BoundaryNorm([c - 5 for c in codes] + [codes[-1] + 5], len(codes)), interpolation="nearest")a.set_xticks([]); a.set_yticks([]); a.set_title("4. ESA WorldCover 2021")present = [c for c in codes if (lc == c).any()]a.legend(handles=[Line2D([], [], marker="s", ls="", mfc=WC[c][1], mec="grey", ms=7, label=WC[c][0]) for c in present], loc="lower left", fontsize=6.5, framealpha=0.9)a = axs[1, 1]im = a.imshow(np.where(np.isfinite(lc_mult), lc_mult, np.nan), cmap=SPECTRAL, extent=ext, vmin=1, vmax=2)a.imshow(water, cmap=ListedColormap(["black"]), extent=ext)a.set_xticks([]); a.set_yticks([]); fig.colorbar(im, ax=a, shrink=0.75, label="multiplier (water impassable, black)")a.set_title("5. Land-cover multiplier")a = axs[1, 2]im = a.imshow(np.where(np.isfinite(cost), cost, np.nan), cmap=SPECTRAL, extent=ext, norm=LogNorm(0.7, 15))a.imshow(water, cmap=ListedColormap(["black"]), extent=ext)a.set_xticks([]); a.set_yticks([]); fig.colorbar(im, ax=a, shrink=0.75, label="seconds per metre")a.set_title("6. Cost = slope cost x multiplier")fig.suptitle("Building the cost surface, step by step", fontsize=11)fig.tight_layout(); fig.savefig(OUT / "02-cost-surface-steps.png"); plt.close(fig)
# Figure 3: accumulated cost surfaces, isotropic and anisotropicfig, axs = plt.subplots(1, 2, figsize=(13, 5.6))for a, surf, line, title in ((axs[0], acc, lcp, "Isotropic: MCP_Geometric on slope + land cover"), (axs[1], acc_aniso.reshape(H, W), aniso, "Anisotropic: Dijkstra on uphill/downhill edges")): mins = np.where(np.isfinite(surf), surf / 60, np.nan) a.imshow(hs, cmap="gray", extent=ext, vmin=0, vmax=1) im = a.imshow(mins, cmap=SPECTRAL, extent=ext, alpha=0.65, vmin=0, vmax=240) cs = a.contour(xs, ys, mins, levels=np.arange(30, 241, 30), colors="black", linewidths=0.6) a.clabel(cs, fmt="%d min", fontsize=7) a.imshow(water, cmap=BLUE, extent=ext) a.plot(*line.xy, color="white", lw=4); a.plot(*line.xy, color="#d7301f", lw=2) pts(a); a.set_xticks([]); a.set_yticks([]) a.set_title(f"{title}\n{surf.flat[idx[r1, c1]] / 60:.0f} min to the summit", fontsize=9)fig.colorbar(im, ax=axs, shrink=0.8, label="minutes from Pen-y-Pass (accumulated cost)")fig.savefig(OUT / "03-accumulated-cost.png"); plt.close(fig)
def profile(line): d = np.arange(0, line.length, 10.0) p = np.array([line.interpolate(v).coords[0] for v in d]) return d / 1000, z_at(p[:, 0], p[:, 1])
# Figure 4: least-cost paths vs straight line vs OSM route, with profilesROUTES = ((straight, "black", "--", "straight line"), (osm_line, "#7b3294", "-", "OSM walkers' route (Pyg Track)"), (lcp, "#f46d43", "-", "isotropic least-cost path"), (aniso, "#9e0142", "-", "anisotropic least-cost path"))fig = plt.figure(figsize=(12, 6.2))ax = fig.add_axes([0.0, 0.0, 0.5, 1.0]); base(ax)for g in all_paths: ax.plot(*g.xy, color="black", lw=0.5, alpha=0.6)for line, col, ls_, lab in ROUTES: if ls_ == "-": ax.plot(*line.xy, color="white", lw=4.2) ax.plot(*line.xy, color=col, lw=2.2 if col == "#7b3294" else 1.7, ls=ls_)pts(ax)ax.legend(handles=[Line2D([], [], color=c, lw=2, ls=s, label=l) for _, c, s, l in ROUTES], loc="lower left", fontsize=7.5, framealpha=0.9)ax2 = fig.add_axes([0.57, 0.12, 0.42, 0.8])for line, col, ls_, lab in ROUTES: d, z = profile(line) ax2.plot(d, z, color=col, ls=ls_, lw=1.8, label=f"{lab}: {line.length / 1000:.2f} km")ax2.set_xlabel("distance from Pen-y-Pass (km)"); ax2.set_ylabel("elevation (m)"); ax2.grid(alpha=0.3)ax2.legend(fontsize=8); ax2.set_title("Elevation profiles")fig.savefig(OUT / "04-route-comparison.png"); plt.close(fig)
# Figure 5: how the weights change the routecols = ["#5e4fa2", "#3288bd", "#d7301f", "#f46d43", "#66c2a5", "#9e0142"]fig = plt.figure(figsize=(12.5, 6.4))ax = fig.add_axes([0.0, 0.0, 0.5, 1.0]); base(ax)ax.plot(*osm_line.xy, color="#fdbf2d", lw=7, alpha=0.85, solid_capstyle="round")for (name, (line, s)), col in zip(results.items(), cols): ax.plot(*line.xy, color="white", lw=3.2); ax.plot(*line.xy, color=col, lw=1.7)pts(ax)ax.legend(handles=[Line2D([], [], color="#fdbf2d", lw=6, alpha=0.85, label="OSM walkers' route (Pyg Track)")] + [Line2D([], [], color=c, lw=1.7, label=n) for n, c in zip(results, cols)], loc="lower left", fontsize=7, framealpha=0.9)names = list(results)y = np.arange(len(names))short = [n.replace("Slope + land cover + 4x above 30 deg", "Slope + LC + 4x above 30 deg") .replace("Anisotropic Tobler + land cover", "Anisotropic Tobler + LC") for n in names]for i, (key, lab, fmt) in enumerate((("minutes", "walking time uphill, Tobler along the route (min)", "{:.0f}"), ("within50", "% of route within 50 m of the OSM walkers' route", "{:.0f}%"))): b = fig.add_axes([0.66, 0.56 - i * 0.48, 0.32, 0.38]) vals = [results[n][1][key] for n in names] b.barh(y, vals, color=cols) for yy, v in zip(y, vals): b.text(v, yy, " " + fmt.format(v), va="center", fontsize=7.5) b.set_yticks(y, short, fontsize=7.5) b.invert_yaxis(); b.set_xlabel(lab); b.set_xlim(0, max(vals) * 1.25); b.grid(axis="x", alpha=0.3)fig.savefig(OUT / "05-cost-weights.png"); plt.close(fig)print("saved 5 figures to", OUT)