|
| 1 | +""" |
| 2 | +Interactive Hydraulic Erosion Simulation -- Grand Canyon |
| 3 | +======================================================== |
| 4 | +
|
| 5 | +Watch channels carve into real Grand Canyon terrain as simulated |
| 6 | +raindrops erode and deposit sediment. Uses a Copernicus 30m DEM tile |
| 7 | +downloaded from the public AWS bucket; falls back to synthetic terrain |
| 8 | +if rasterio is unavailable or the download fails. |
| 9 | +
|
| 10 | +Driven by the xrspatial erosion and terrain modules: |
| 11 | +
|
| 12 | + * **erode** -- particle-based hydraulic erosion (Numba-accelerated) |
| 13 | + * **slope** -- terrain slope for visual overlay |
| 14 | + * **generate_terrain** -- procedural fallback terrain |
| 15 | +
|
| 16 | +The simulation pre-computes a fully-eroded result, then animates by |
| 17 | +interpolating between the original and eroded surfaces. A diverging |
| 18 | +overlay highlights where material was removed (erosion) vs. deposited. |
| 19 | +
|
| 20 | +Controls |
| 21 | +-------- |
| 22 | +* **Up / Down** -- step erosion forward / backward |
| 23 | +* **Space** -- pause / resume automatic progression |
| 24 | +* **R** -- reset to original terrain |
| 25 | +* **Q / Escape** -- quit |
| 26 | +
|
| 27 | +Requires: xarray, numpy, matplotlib, xrspatial (this repo) |
| 28 | +Optional: rasterio (for real DEM download) |
| 29 | +""" |
| 30 | + |
| 31 | +from __future__ import annotations |
| 32 | + |
| 33 | +import numpy as np |
| 34 | +import xarray as xr |
| 35 | +import matplotlib.pyplot as plt |
| 36 | +import matplotlib.colors as mcolors |
| 37 | +from matplotlib.animation import FuncAnimation |
| 38 | + |
| 39 | +from xrspatial import generate_terrain, slope |
| 40 | +from xrspatial.erosion import erode |
| 41 | + |
| 42 | +# -- Tunable parameters ----------------------------------------------------- |
| 43 | +CELL_SIZE = 30.0 # metres per pixel |
| 44 | +EROSION_ITERATIONS = 100_000 # total droplets for the full erosion run |
| 45 | +ALPHA_STEP = 0.02 # manual step size (Up/Down keys) |
| 46 | +AUTO_SPEED = 0.003 # alpha increment per animation frame |
| 47 | +FPS = 30 |
| 48 | +# --------------------------------------------------------------------------- |
| 49 | + |
| 50 | + |
| 51 | +def load_dem() -> xr.DataArray: |
| 52 | + """Load a Copernicus 30m DEM subset covering part of the Grand Canyon. |
| 53 | +
|
| 54 | + Downloads a windowed region from the public AWS S3 bucket (no auth |
| 55 | + required). Falls back to synthetic terrain if rasterio is missing |
| 56 | + or the download fails. |
| 57 | + """ |
| 58 | + try: |
| 59 | + import rasterio |
| 60 | + from rasterio.windows import Window |
| 61 | + |
| 62 | + url = ( |
| 63 | + "https://copernicus-dem-30m.s3.amazonaws.com/" |
| 64 | + "Copernicus_DSM_COG_10_N36_00_W113_00_DEM/" |
| 65 | + "Copernicus_DSM_COG_10_N36_00_W113_00_DEM.tif" |
| 66 | + ) |
| 67 | + |
| 68 | + print("Downloading Grand Canyon DEM (Copernicus 30m) ...") |
| 69 | + with rasterio.open(url) as src: |
| 70 | + window = Window(col_off=1800, row_off=2400, width=400, height=300) |
| 71 | + data = src.read(1, window=window).astype(np.float64) |
| 72 | + nodata = src.nodata |
| 73 | + |
| 74 | + if nodata is not None: |
| 75 | + data[data == nodata] = np.nan |
| 76 | + |
| 77 | + h, w = data.shape |
| 78 | + dem = xr.DataArray(data, dims=["y", "x"], name="elevation") |
| 79 | + dem["y"] = np.linspace(h - 1, 0, h) |
| 80 | + dem["x"] = np.linspace(0, w - 1, w) |
| 81 | + |
| 82 | + print(f" Loaded DEM: {dem.shape}, " |
| 83 | + f"elevation {np.nanmin(data):.0f} - {np.nanmax(data):.0f} m") |
| 84 | + return dem |
| 85 | + |
| 86 | + except Exception as e: |
| 87 | + print(f"DEM download failed ({e}), using synthetic terrain") |
| 88 | + h, w = 300, 400 |
| 89 | + xs = np.linspace(0, w * CELL_SIZE, w) |
| 90 | + ys = np.linspace(0, h * CELL_SIZE, h) |
| 91 | + template = xr.DataArray( |
| 92 | + np.zeros((h, w), dtype=np.float32), |
| 93 | + dims=["y", "x"], |
| 94 | + coords={"y": ys, "x": xs}, |
| 95 | + ) |
| 96 | + return generate_terrain(template, zfactor=400, seed=42) |
| 97 | + |
| 98 | + |
| 99 | +# -- Build the world -------------------------------------------------------- |
| 100 | +elevation = load_dem() |
| 101 | +GRID_H, GRID_W = elevation.shape |
| 102 | + |
| 103 | +print("Computing slope (original terrain) ...") |
| 104 | +slope_orig = slope(elevation) |
| 105 | +slope_orig_vals = np.nan_to_num(slope_orig.values, nan=0.0) |
| 106 | + |
| 107 | +print(f"Running hydraulic erosion ({EROSION_ITERATIONS:,} droplets) ...") |
| 108 | +eroded = erode(elevation, iterations=EROSION_ITERATIONS, seed=42) |
| 109 | + |
| 110 | +print("Computing slope (eroded terrain) ...") |
| 111 | +slope_eroded = slope(eroded) |
| 112 | +slope_eroded_vals = np.nan_to_num(slope_eroded.values, nan=0.0) |
| 113 | + |
| 114 | +# Difference map: negative = material removed, positive = deposited |
| 115 | +elev_orig = elevation.values.astype(np.float64) |
| 116 | +elev_eroded = eroded.values.astype(np.float64) |
| 117 | +diff = elev_eroded - elev_orig |
| 118 | + |
| 119 | +diff_abs_max = max(np.nanmax(np.abs(diff)), 0.01) |
| 120 | +print(f" Erosion range: {np.nanmin(diff):.2f} to {np.nanmax(diff):.2f} m") |
| 121 | +print(f" Net volume change: {np.nansum(diff) * CELL_SIZE**2:.0f} m^3") |
| 122 | + |
| 123 | +# -- Simulation state ------------------------------------------------------- |
| 124 | +alpha = 0.0 # 0 = original, 1 = fully eroded |
| 125 | +paused = False |
| 126 | +auto_play = True |
| 127 | + |
| 128 | +# -- Visualisation ----------------------------------------------------------- |
| 129 | + |
| 130 | +# Diverging colourmap for erosion (red) vs deposition (blue) |
| 131 | +erosion_cmap = mcolors.LinearSegmentedColormap.from_list("erosion", [ |
| 132 | + (0.0, (0.8, 0.2, 0.1)), # deep erosion (red) |
| 133 | + (0.3, (0.95, 0.5, 0.3)), # moderate erosion (orange) |
| 134 | + (0.5, (0.95, 0.95, 0.85)), # neutral (near-white) |
| 135 | + (0.7, (0.3, 0.55, 0.85)), # moderate deposition (light blue) |
| 136 | + (1.0, (0.1, 0.2, 0.7)), # heavy deposition (blue) |
| 137 | +]) |
| 138 | + |
| 139 | +fig, ax = plt.subplots(figsize=(12, 7)) |
| 140 | +fig.patch.set_facecolor("black") |
| 141 | +ax.set_facecolor("black") |
| 142 | +ax.set_title( |
| 143 | + "Hydraulic Erosion | Up/Down: step | Space: pause " |
| 144 | + "| R: reset | Q: quit", |
| 145 | + color="white", fontsize=11, |
| 146 | +) |
| 147 | +ax.tick_params(colors="white") |
| 148 | + |
| 149 | +# Terrain layer (interpolated between original and eroded) |
| 150 | +current_elev = elev_orig.copy() |
| 151 | +terrain_img = ax.imshow( |
| 152 | + current_elev, cmap=plt.cm.terrain, origin="lower", |
| 153 | + aspect="equal", interpolation="bilinear", |
| 154 | +) |
| 155 | + |
| 156 | +# Erosion/deposition overlay |
| 157 | +erosion_data = np.full((GRID_H, GRID_W), np.nan, dtype=np.float32) |
| 158 | +erosion_img = ax.imshow( |
| 159 | + erosion_data, cmap=erosion_cmap, origin="lower", aspect="equal", |
| 160 | + vmin=-diff_abs_max, vmax=diff_abs_max, alpha=0.55, interpolation="bilinear", |
| 161 | +) |
| 162 | + |
| 163 | +status_text = ax.text( |
| 164 | + 0.01, 0.01, "", transform=ax.transAxes, color="cyan", |
| 165 | + fontsize=9, verticalalignment="bottom", |
| 166 | + bbox=dict(boxstyle="round,pad=0.3", facecolor="black", alpha=0.7), |
| 167 | +) |
| 168 | + |
| 169 | +# Explanation blurb |
| 170 | +ax.text( |
| 171 | + 0.99, 0.99, |
| 172 | + "Hydraulic erosion simulates raindrops landing on terrain,\n" |
| 173 | + "picking up sediment as they flow downhill, and depositing\n" |
| 174 | + "it where the water slows down. Over time this carves\n" |
| 175 | + "channels and builds up alluvial fans -- the same process\n" |
| 176 | + "that shaped the Grand Canyon over millions of years.\n" |
| 177 | + "\n" |
| 178 | + "Red/orange = material removed (channel cutting)\n" |
| 179 | + "Blue = material deposited (sediment accumulation)\n" |
| 180 | + "White = little change", |
| 181 | + transform=ax.transAxes, color="white", fontsize=8, |
| 182 | + verticalalignment="top", horizontalalignment="right", |
| 183 | + bbox=dict(boxstyle="round,pad=0.4", facecolor="black", alpha=0.6), |
| 184 | +) |
| 185 | + |
| 186 | + |
| 187 | +def update_frame(frame: int): |
| 188 | + """Advance erosion interpolation and redraw.""" |
| 189 | + global alpha |
| 190 | + |
| 191 | + if auto_play and not paused: |
| 192 | + alpha = min(alpha + AUTO_SPEED, 1.0) |
| 193 | + |
| 194 | + # Interpolate terrain between original and fully eroded |
| 195 | + current = elev_orig + alpha * diff |
| 196 | + terrain_img.set_data(current) |
| 197 | + |
| 198 | + # Update colour limits to track changing elevation range |
| 199 | + vmin = np.nanmin(current) |
| 200 | + vmax = np.nanmax(current) |
| 201 | + terrain_img.set_clim(vmin=vmin, vmax=vmax) |
| 202 | + |
| 203 | + # Erosion/deposition overlay -- scale by current alpha |
| 204 | + if alpha > 0: |
| 205 | + current_diff = alpha * diff |
| 206 | + erosion_img.set_data(current_diff) |
| 207 | + overlay_max = max(np.nanmax(np.abs(current_diff)), 0.01) |
| 208 | + erosion_img.set_clim(vmin=-overlay_max, vmax=overlay_max) |
| 209 | + erosion_img.set_alpha(min(0.55, alpha * 2)) |
| 210 | + else: |
| 211 | + erosion_img.set_data(np.full((GRID_H, GRID_W), np.nan)) |
| 212 | + erosion_img.set_alpha(0.0) |
| 213 | + |
| 214 | + # Interpolate slope for stats |
| 215 | + current_slope = slope_orig_vals + alpha * (slope_eroded_vals - slope_orig_vals) |
| 216 | + |
| 217 | + # Stats |
| 218 | + current_diff_vals = alpha * diff |
| 219 | + eroded_cells = np.sum(current_diff_vals < -0.01) |
| 220 | + deposited_cells = np.sum(current_diff_vals > 0.01) |
| 221 | + max_erosion = np.nanmin(current_diff_vals) |
| 222 | + max_deposition = np.nanmax(current_diff_vals) |
| 223 | + mean_slope = np.nanmean(current_slope) |
| 224 | + |
| 225 | + state = "PAUSED" if paused else ("ERODING" if alpha < 1.0 else "COMPLETE") |
| 226 | + status_text.set_text( |
| 227 | + f"{state} | progress: {100 * alpha:.1f}% | " |
| 228 | + f"eroded: {eroded_cells:,} cells | deposited: {deposited_cells:,} cells | " |
| 229 | + f"max cut: {max_erosion:.2f} m | max fill: {max_deposition:.2f} m | " |
| 230 | + f"mean slope: {mean_slope:.1f} deg" |
| 231 | + ) |
| 232 | + |
| 233 | + return (terrain_img, erosion_img, status_text) |
| 234 | + |
| 235 | + |
| 236 | +def on_key(event): |
| 237 | + """Keyboard: erosion step, pause, reset, quit.""" |
| 238 | + global alpha, paused, auto_play |
| 239 | + |
| 240 | + if event.key == "up": |
| 241 | + auto_play = False |
| 242 | + alpha = min(alpha + ALPHA_STEP, 1.0) |
| 243 | + elif event.key == "down": |
| 244 | + auto_play = False |
| 245 | + alpha = max(alpha - ALPHA_STEP, 0.0) |
| 246 | + elif event.key == " ": |
| 247 | + paused = not paused |
| 248 | + print(" Paused" if paused else " Resumed") |
| 249 | + elif event.key == "r": |
| 250 | + alpha = 0.0 |
| 251 | + auto_play = True |
| 252 | + paused = False |
| 253 | + print(" Reset") |
| 254 | + elif event.key in ("q", "escape"): |
| 255 | + plt.close(fig) |
| 256 | + |
| 257 | + |
| 258 | +fig.canvas.mpl_connect("key_press_event", on_key) |
| 259 | + |
| 260 | +anim = FuncAnimation( |
| 261 | + fig, update_frame, interval=1000 // FPS, blit=False, cache_frame_data=False, |
| 262 | +) |
| 263 | + |
| 264 | +plt.tight_layout() |
| 265 | +print("\nReady -- erosion will progress automatically. " |
| 266 | + "Press Up/Down for manual control.\n") |
| 267 | +plt.show() |
0 commit comments