Basic plots¶
Maps that work for any DEM or lsdtt3 raster: hillshades, drapes, basins,
points, locator maps, 3D relief, vector overlays, and the swath profile.
All map functions return a pygmt.Figure and accept an existing fig= so you
can layer them (hillshade → channels/points → basins → decorations). The
conventions they share (coordinate modes, MapStyle, saving) are in
Getting started.
Hillshade¶
CLI
pixi run lsdviz3-hillshade \
--in demo_output/eigg_glo30.tif --out demo_output/hillshade.png \
--coordinates local --cmap gray --opaque
Flags: --in, --out, --input-is-hillshade (treat input as a precomputed
hillshade), --clean (see below), plus the shared style flags.
Clean maps (--clean / MapStyle(clean=True)). Strips all map furniture —
frame ticks, tick marks, tick labels, axis labels, the CRS title, and the
colourbar — leaving just the map inside a thin border, with a small solid black
rectangle scale bar in the bottom-left. The bar length is the largest of a set
of round numbers (0.1, 0.5, 1, 2, 3, 4, 5, 8, 10, 15, 20, 25, 40, 50, 75, 100,
150, 200 km) that fits within 1/10 of the map width, labelled e.g. 8 km. Works
on render_hillshade and render_drape (and any map sharing MapStyle).
Python
from lsdviztools3.io.raster import load_raster
from lsdviztools3.render.hillshade import render_hillshade
from lsdviztools3.render.base import save_figure
from lsdviztools3.render.style import MapStyle
style = MapStyle(cmap="gray", transparent=False)
fig = render_hillshade(load_raster("demo_output/eigg_glo30.tif"),
coordinates="local", shade=True, style=style)
save_figure(fig, "demo_output/hillshade.png", style=style)
render_hillshade(raster, *, coordinates="local", shade=True, style=None, fig=None).
Set shade=False if the raster is already a hillshade.
Draping a data raster over a precomputed hillshade¶
A common 2D figure is a continuous data raster — slope, curvature, a k_sn grid,
relief, land cover — coloured by a CPT and laid semi-transparently over a
grayscale hillshade, so the shaded-relief texture shows through the colour. This
is the 2D analogue of the 3D drape= option (3D perspective relief (rayshader-style)).
render_drape builds the two layers for you: a gray hillshade base, then the
data raster on top through your chosen cmap at a given transparency. The base
is always gray (it provides texture, not colour); the drape carries the colour.
Both rasters here are precomputed and live in a different directory from your working dir (e.g. an lsdtt3 output folder on another disk) — just point at them by path. They must share the same CRS and grid (lsdtt3 outputs for one DEM already do); reproject/resample the drape onto the hillshade grid first if not.
CLI
pixi run lsdviz3-drape \
--base /data/lsdtt3_runs/eigg/eigg_hs.tif \
--drape /data/lsdtt3_runs/eigg/eigg_curvature.tif \
--out demo_output/eigg_curvature_drape.png \
--coordinates local --cmap roma --transparency 40 \
--colorbar --colorbar-label "Curvature" --opaque
Flags: --base, --drape, --out, --shade-base (treat --base as elevation
to illuminate rather than as a precomputed hillshade), --log (colour by log10,
clamping values <= 0 to transparent), --transparency (0 opaque … 100 invisible,
default 40), --vmin/--vmax (fixed colour-scale bounds), --percentile-min/
--percentile-max (bounds from data percentiles), --out-of-bounds
(clamp/clip), --colorbar, --colorbar-label, --colorbar-position
(bottom/top/left/right, default bottom), --clean (strip all furniture,
draw just a solid scale bar — see Hillshade), plus the shared style
flags (--cmap sets the drape colour map; the base stays gray).
Colour maps. --cmap accepts GMT's own maps (geo, roma, batlow,
viridis, seis, …), matplotlib colour maps (cividis, Spectral, RdBu,
twilight, … — converted to a GMT CPT on the fly), a single named colour (black, red) for a solid mask, a colour,colour
list, or a .cpt path. Plain GMT cannot read matplotlib maps; this conversion is
done by render.colormaps.resolve_cmap, which all drape calls route through.
Bounds and thresholding. By default the colour scale spans the drape's data
range. Narrow it with fixed vmin/vmax, or derive it from the data with
percentile_min/percentile_max (0–100). out_of_bounds decides what happens
to values outside the range: "clamp" (default) paints them the nearest end
colour; "clip" leaves them unrendered (transparent — the hillshade shows
through). Combining percentile_min + clip + a solid cmap gives a threshold
mask, e.g. "paint everything above the 90th percentile black, show bare hillshade
elsewhere":
pixi run lsdviz3-drape \
--base /data/rio_tucuy/rt_hillshade.tif \
--drape /data/rio_tucuy/rt_surf_direct_polyfit_curvedness.tif \
--out figures/curvedness_p90_black.png \
--coordinates local --cmap black \
--percentile-min 90 --out-of-bounds clip --transparency 0 --opaque
Python
from pathlib import Path
from lsdviztools3.render.channels import render_drape
from lsdviztools3.render.base import save_figure
from lsdviztools3.render.style import MapStyle
data_dir = Path("/data/lsdtt3_runs/eigg") # precomputed rasters elsewhere
style = MapStyle(cmap="roma", figure_size="17c", transparent=False)
fig = render_drape(
str(data_dir / "eigg_hs.tif"), # gray hillshade base
str(data_dir / "eigg_curvature.tif"), # coloured drape
coordinates="local",
base_is_hillshade=True, # base file is already a hillshade
transparency=40, # 0 opaque … 100 invisible
cmap="roma", # drape colour map
colorbar=True, colorbar_label="Curvature",
colorbar_position="bottom", # or "top" / "left" / "right"
style=style,
)
save_figure(fig, "demo_output/eigg_curvature_drape.png", style=style)
render_drape(base_raster, drape_raster, *, coordinates="local",
base_is_hillshade=True, log_color=False, transparency=40.0, cmap=None,
vmin=None, vmax=None, percentile_min=None, percentile_max=None,
out_of_bounds="clamp", colorbar=False, colorbar_label=None,
colorbar_position="bottom", style=None, fig=None).
Notes:
transparencyis the GMT blend of the colour layer over the hillshade. Lower it for more colour, raise it for more terrain texture.log_color=True(CLI--log) colours bylog10of the drape values — useful for k_sn or drainage-area grids — and clamps values <= 0 to NaN (drawn transparent), so zeros/negatives never break the log. The colourbar then reads in log10 units; label it accordingly (e.g."log@-10@-k@-sn@-").- For an elevation drape (colour by height with hillshade illumination from the
same DEM) you don't need this — a single
render_hillshade(dem, shade=True, style=MapStyle(cmap="geo"))colours by height and illuminates.render_drapeis for draping a different raster than the one providing the shading.
Colouring in classes¶
Instead of a continuous colour scale, a map can be coloured in a few classes,
as in a GIS: every value between two breaks gets the same colour, and a legend
listing the classes replaces the colourbar. It works the same way for rasters
(render_drape, lsdviz3-drape), lines (render_channels,
lsdviz3-channels, and the k_sn maps of lsdviz3-chi and
lsdviz3-knickpoints) and points
(render_points).
Choosing the classes. Give the breaks yourself, or name a scheme that finds them in the data:
classes (CLI --classes) |
Classes |
|---|---|
a list, tuple or NumPy array of breaks, e.g. [50, 100, 200] (CLI 50,100,200) |
n breaks make n + 1 classes: below 50, 50 to 100, 100 to 200, 200 and above |
"equal_interval" |
n_classes equal steps from the lower to the upper limit |
"quantile" |
the same number of values in every class (breaks at data values) |
"natural_breaks" (or "jenks") |
the Fisher-Jenks optimum: the classes that are each as uniform as possible (least sum of squared deviations from the class means) |
n_classes (CLI --n-classes, default 5) sets the number of classes for a
scheme. A value equal to a break goes in the class above it; nodata gets no
class and stays transparent. Natural breaks on more than 2000 values use an
even sample of the sorted data, as GIS packages do.
Limits. vmin and vmax (CLI --vmin, --vmax; --ksn-vmin,
--ksn-vmax on the chi maps) are the ends of the colour gradient, and bound the
schemes: equal_interval runs from vmin to vmax, and quantile and
natural_breaks use only the values between them. Values beyond the limits
still fall in the end classes. Without limits the data's minimum and maximum
are used.
Log scale. With log_color=True (CLI --log; always on for k_sn), the
schemes and the colours work in log10: equal intervals become equal ratios.
Breaks you give are in data units. Values <= 0 get no class.
Colours. By default each class takes the colour of the colour map at the
centre of the class, on the gradient from vmin to vmax: the colour a
continuous map with the same limits would give it there. Unevenly spaced
breaks therefore give unevenly spaced colours, and a classed map matches its
continuous version. Two other choices:
class_color_mode="distinct"(CLI--class-color-mode distinct) takes the colours of a qualitative map in order, one per class, for classes that should look different rather than ordered. This is automatic for matplotlib's qualitative maps (tab10,tab20,Set1,Set2,Set3,Dark2,Paired,Accent,Pastel1,Pastel2); a map with fewer colours than classes is refused.class_colors(CLI--class-colors red,orange,#2c7fb8) gives one colour per class yourself.
The colour map can be a matplotlib one (viridis, magma_r, ...) or a GMT one
(geo, roma, ...).
Legend. The class legend sits outside the right edge of the map, so it
covers no data, with its width set from the longest label and title so the text
stays inside the box. Its title is legend_title (CLI --legend-title on
lsdviz3-channels, --colorbar-label on lsdviz3-drape), in GMT text:
"k@-sn@-" gives a subscript. legend=False leaves it off.
# Gradient (m/m) in 6 natural-breaks classes
pixi run lsdviz3-drape --base eigg_hillshade.tif --drape eigg_gradient.tif \
--out eigg_gradient_classes.png --classes natural_breaks --n-classes 6 \
--cmap viridis --colorbar-label "Gradient (m/m)"
# Channels by k_sn, breaks given
pixi run lsdviz3-channels --base eigg_hillshade.tif --lines eigg_chi_fit_segment_lines_mn_0p450.fgb \
--out eigg_ksn_classes.png --color-by k_sn --classes 20,50,100,200 \
--cmap magma --legend-title "k@-sn@-"
from lsdviztools3.render.channels import render_drape, render_channels
fig = render_drape("eigg_hillshade.tif", "eigg_gradient.tif",
classes="quantile", n_classes=5, vmin=0, vmax=1.2,
cmap="viridis", legend_title="Gradient (m/m)")
fig = render_channels(segments, color_by="k_sn", classes=[20, 50, 100, 200],
cmap="magma", legend_title="k@-sn@-", fig=fig)
The classification itself is in lsdviztools3.colour_classes:
make_colour_classes(values, classes, ...) returns the breaks, a colour and
a label per class, and classify(values) gives each value's class, for use in
your own plots.
Basins¶
CLI
pixi run lsdviz3-basins \
--base demo_output/eigg_glo30.tif \
--basins work/eigg/eigg_junction_basin_polygons.fgb \
--out demo_output/basins.png \
--coordinates local --fill skyblue --transparency 70 --opaque
Flags: --base, --basins, --out, --fill, --transparency, shared style.
Python
from lsdviztools3.render.basins import render_basins
from lsdviztools3.render.hillshade import render_hillshade
from lsdviztools3.io.vector import load_vector
from lsdviztools3.render.base import save_figure
from lsdviztools3.render.style import MapStyle
style = MapStyle(transparent=False)
fig = render_hillshade("demo_output/eigg_glo30.tif", coordinates="local",
style=MapStyle(cmap="gray", transparent=False))
fig = render_basins(load_vector("work/eigg/eigg_junction_basin_polygons.fgb"),
coordinates="local", fill="skyblue", transparency=70,
style=MapStyle(line_color="navy", transparent=False), fig=fig)
save_figure(fig, "demo_output/basins.png", style=style)
Points sized by drainage area¶
Points carrying drainage_area (channel-extraction points, or chi data-map
points) can be size-scaled by log₁₀(drainage_area) so big channels render
thicker, and coloured by another column (e.g. Strahler stream_order).
from lsdviztools3.render.points import render_points
from lsdviztools3.render.hillshade import render_hillshade
from lsdviztools3.render.base import save_figure
from lsdviztools3.render.style import MapStyle
fig = render_hillshade("work/eigg/eigg_remove_seas.tif", coordinates="local",
shade=True, style=MapStyle(cmap="gray", transparent=False))
fig = render_points(
"work/eigg/eigg_channel_network_points.fgb",
size_by="drainage_area", # log10 -> size_range
size_range=(1.0, 3.0), # smallest..largest point, in pt
color_by="stream_order", log_color=False,
cmap="batlow", colorbar=True, colorbar_label="Strahler order",
coordinates="local", style=MapStyle(cmap="batlow"), fig=fig,
)
save_figure(fig, "demo_output/eigg_points.png", style=MapStyle(transparent=False))
render_points(df, *, color_by=None, size_by=None, log_color=True,
size_range=(1,3), cmap=None, colorbar=False, colorbar_label=None,
coordinates="local", style=None, fig=None). Input may be a GeoDataFrame or a
path to a point vector. Drop size_by for a constant style.point_size; drop
color_by for a solid colour.
Locator maps: where is this raster?¶
A locator map shows where a raster sits in the wider world, with the raster's footprint outlined in red. There are three kinds:
| Mode | Map | Good for |
|---|---|---|
global |
a hemisphere centred on the raster, with coastlines and a graticule | which continent, which country |
regional |
a flat (Mercator) map of the region around the raster: coastlines, country borders (solid), state and province borders (dashed), rivers, lakes, optionally shaded relief, a scale bar and a north arrow | where in the country: the nearest big rivers and borders |
aerial |
a perspective view from a height above the raster | large areas seen at an angle |
auto (the default) chooses global for a footprint of 25 square degrees or
more, and regional otherwise. The regional map is square, ten times the
footprint's diagonal across (at least 250 km), or extent_km across. Its
shaded relief (relief=True, CLI --relief) comes from GMT's global relief
grid, downloaded from the GMT server and cached the first time; everything else
needs no network.
A locator map can be its own figure, or an inset in a corner of a map you have already drawn.
As its own figure
pixi run lsdviz3-basemap --in salmon_DEM.tif --out salmon_locator.png --mode regional --relief
pixi run lsdviz3-basemap --in salmon_DEM.tif --out salmon_globe.png --mode global
Flags: --in, --out, --mode {auto,global,regional,aerial}; regional:
--extent-km, --relief, --no-borders, --no-rivers, --no-scale-bar;
aerial: --elevation-km, --view-limit-deg, --look-bearing,
--look-vertical-angle; --grid-spacing-deg, --no-north-arrow (a globe
never has one: north points a different way at every place on it), and the
shared style flags (--figure-size sets the width).
from lsdviztools3.render.basemap import render_orthographic_basemap, render_regional_basemap
from lsdviztools3.render.base import save_figure
from lsdviztools3.render.style import MapStyle
style = MapStyle(figure_size="10c", transparent=False)
fig = render_regional_basemap("salmon_DEM.tif", relief=True, style=style)
save_figure(fig, "salmon_locator.png", style=style)
fig = render_orthographic_basemap("salmon_DEM.tif", mode="global", style=style)
save_figure(fig, "salmon_globe.png", style=style)
As an inset
The map commands lsdviz3-hillshade, lsdviz3-drape, lsdviz3-basins,
lsdviz3-channels, lsdviz3-chi and lsdviz3-knickpoints take --locator
global or --locator regional, with --locator-position TR|TL|BR|BL
(default TR), --locator-width (default 4c), --locator-extent-km and
--locator-relief:
pixi run lsdviz3-drape --base salmon_hillshade.tif --drape salmon_gradient.tif \
--out salmon_gradient.png --locator regional --locator-position BR
In Python, draw the main map, then add the inset:
from lsdviztools3.render.basemap import add_locator_inset
fig = render_hillshade("salmon_DEM.tif", coordinates="local", shade=True,
style=MapStyle(cmap="gray", transparent=False))
add_locator_inset(fig, "salmon_DEM.tif", mode="global", position="TR", width="3.5c")
An inset covers that corner of the map; choose the corner with the least to see, or make the locator a separate figure.
Decorations: scale bar and colourbar¶
add_scalebar(fig, extent, crs, *, length_km=None, anchor="BL")— branches on the CRS: a hand-drawn km ruler for projected (linear-km) maps, native GMTmap_scalefor geographic ones.length_km=Nonepicks a "nice" ~1/5 of the map width;anchoris"BL"|"BR"|"TL"|"TR".extentis the display extent[w, e, s, n](km for projected) — obtain it viagrid_to_display(see Channel network coloured by log₁₀ k_sn).add_colourbar(fig, *, label=None, placement="bottom", position=None, frame=None)— colourbar for the active CPT.placementis"bottom"(default),"top","left", or"right"(offsets are chosen to clear the axis labels); pass a raw GMT-Dstring aspositionto override it.render_channels/render_points/render_drapecall this for you whencolorbar=True; call it directly if you build a CPT yourself.
3D perspective relief (rayshader-style)¶
A 3D perspective view of the DEM via PyGMT grdview: an illuminated terrain
surface seen from a viewing azimuth/elevation, coloured by height (or by an
optional drape raster). Projected DEMs get km x/y axes and an Elevation (m)
z-axis.
from lsdviztools3.render.relief3d import render_relief3d
from lsdviztools3.render.base import save_figure
from lsdviztools3.render.style import MapStyle
style = MapStyle(cmap="geo", figure_size="17c", transparent=False)
fig = render_relief3d(
"demo_output/eigg_glo30.tif",
coordinates="local",
azimuth=135, elevation=30, # camera bearing + angle above horizon
vertical_exaggeration=2.0, # true vertical:horizontal scale ratio
shade=True, # illuminate from the topography
style=style,
)
save_figure(fig, "demo_output/eigg_relief3d.png", style=style)
render_relief3d(raster, *, coordinates="local", azimuth=135.0, elevation=30.0,
vertical_exaggeration=None, zsize="4c", shade=True, drape=None, cmap=None,
style=None, fig=None). vertical_exaggeration (projected CRS only) sets the
true vertical:horizontal scale ratio and overrides zsize; pass zsize (the
raw GMT plotted height of the elevation range) directly for explicit control.
Drape option. Pass drape=<raster> to colour the surface by another raster
(reprojected and resampled onto the DEM grid) instead of by elevation — e.g. a
k_sn, gradient, or land-cover grid. cmap defaults to style.cmap.
Overlaying several vector layers on a hillshade¶
hillshade_with_vectors is a general, dataset-agnostic map builder: give it a
base raster and a list of vector layers (any mix of lines, points and
polygons) and it draws a hillshade, overlays each layer in its own style,
reprojects everything to a common CRS so the layers line up, and adds a legend.
Use it for any overlay job — channel networks, faults, sample sites, roads,
basins — not just the stream-burning case it grew out of. It dispatches each
layer to render_channels / render_points / render_basins by geometry type,
so plain GeoDataFrames draw reliably (a bare fig.plot(data=gdf) does not).
Each layer is a dict. Only path is required; the rest fall back to sensible
defaults:
| key | applies to | default | meaning |
|---|---|---|---|
path |
all | — | vector file path, or a GeoDataFrame |
layer |
all | None |
sub-layer name for multi-layer files (GeoPackage) |
kind |
all | "auto" |
"auto" (detect) / "line" / "point" / "polygon" |
label |
all | — | legend entry (omit to leave the layer out of the legend) |
color |
all | "black" |
line / outline / point colour |
width |
line, polygon outline | "1p" |
pen width |
size |
point | "0.15c" |
symbol size |
symbol |
point | "c" |
GMT symbol code (c circle, s square, t triangle…) |
fill |
point, polygon | = color |
fill colour |
transparency |
polygon | 60 |
fill transparency (0 opaque … 100 invisible) |
Layers draw in list order (first = bottom).
Python
from lsdviztools3.workflows import hillshade_with_vectors
hillshade_with_vectors(
"dem.tif",
[
{"path": "basins.fgb", "kind": "polygon", "fill": "green",
"transparency": 60, "label": "basins"},
{"path": "channels.fgb", "kind": "line", "color": "red",
"width": "0.8p", "label": "channels"},
{"path": "sites.gpkg", "layer": "samples", "kind": "point",
"color": "yellow", "symbol": "c", "size": "0.18c", "label": "samples"},
],
"overlay.png",
region=[xmin, xmax, ymin, ymax], # optional zoom, in the raster's native CRS
)
hillshade_with_vectors(base_raster, layers, savepath=None, *,
coordinates="local", shade=True, base_cmap=None, width=MapStyle.DOUBLE_COLUMN,
region=None, clip_to_valid=False, legend=True, legend_position="JTR+jTR+o0.2c",
high_res=False, fig=None).
Notes:
shade=Truetreatsbase_rasteras elevation (shaded relief);shade=Falsedraws a pre-computed hillshade.base_cmapdefaults to"gray"for a neutral backdrop that makes coloured overlays pop; pass"geo"for a coloured elevation base.region=[xmin, xmax, ymin, ymax](in the raster's native CRS) zooms by cropping the base raster to a temp GeoTIFF, so the hillshade extent and the overlays all follow the zoom.clip_to_valid=Trueclips overlays to the raster's finite-data footprint — handy when a vector tile is larger than the DEM.savepath=Nonereturns thepygmt.Figurewithout writing (e.g. forfig.show()in a notebook, or further layering viafig=).
Swath profiles¶
Distance-along-baseline vs value, with a min/max envelope, an inter-quartile band, and median + mean.
from lsdviztools3.workflows import lsdtt3_swath_profile, render_swath_profile
out = lsdtt3_swath_profile("demo_output/eigg_glo30.tif", "work/eigg_swath",
stem="eigg", source_threshold=300,
half_width_metres=1000, bin_width_metres=500)
render_swath_profile(out, "demo_output/eigg_swath.png")
Plot a swath table yourself:
from lsdviztools3.io.swath import load_swath
from lsdviztools3.charts import plot_swath
df = load_swath("work/eigg_swath/eigg_swath_profile.csv")
fig, ax = plot_swath(df, bands=((0, 100), (25, 75)), value_label="Elevation (m)")
plot_swath(df, *, bands=((0,100),(25,75)), show_median=True, show_mean=True,
distance_units="km", value_label="Elevation (m)", color="tab:blue", ax=None).