17  Visualizing Geospatial Rasters

The previous chapter showed how to map geospatial vector data: points, lines, and polygons, each recorded as explicit coordinates. This chapter turns to the other main class of geospatial data: rasters. Instead of storing coordinates for every observation, a raster arranges values on a regular grid, so each cell’s position follows from the grid’s origin and cell size. Figure 17.1 illustrates the difference by showing the same landscape both ways: The river is a crisp polyline in the vector data displayed on the left but appears coarse-grained in the raster data on the right. Vector data suit discrete objects with clearly defined boundaries or paths; rasters suit spatially varying measurements (e.g., elevation) and classifications (e.g., land cover). In this chapter, we import, inspect, and visualize rasters with the stars package and ggplot2.

Two side-by-side maps of the same landscape. Panel (a), labeled Vector, shows polygons with straight edges, a river line, and point symbols. Panel (b), labeled Raster, shows the same landscape as a 20-by-20 grid of colored square cells.
Figure 17.1: Two representations of the same landscape. (a) Vector data store the forest, the lake, and bare rock as polygons, the river as a line, and the weather stations and peaks as points. (b) Raster data record one land-cover class per cell of a regular grid.

Section 17.1 introduces this chapter’s running example: raster data for the Matterhorn region. The remaining sections align with the typical raster-data workflow:

  1. Loading: Section 17.2
  2. Inspecting: Sections 17.3 and 17.4
  3. Geoprocessing: Sections 17.5–17.8
  4. Communicating visually: Sections 17.9–17.10

17.1 A First Raster Map: Matterhorn Region

Before examining how rasters are structured, it helps to see how to move from a raster file to a map using a minimal example. The example uses a digital elevation model (DEM) of the Matterhorn region, cropped to a 10 km by 10 km square on the Swiss–Italian border and taken from the Copernicus DEM GLO-30 product.1 Each cell records the surface elevation, in meters. The following code reads the file:

library(tidyverse)
library(stars)
matterhorn_dem <-
  "matterhorn_dem_copernicus_glo30_native.tif" |>
  read_stars() |>
  setNames("elevation")
matterhorn_dem
stars object with 2 dimensions and 1 attribute
attribute(s):
                  Min.  1st Qu.   Median     Mean  3rd Qu.     Max.
elevation [m] 1832.444 2540.651 2846.281 2843.889 3118.472 4330.093
dimension(s):
  from  to offset      delta refsys point x/y
x    1 467  7.594  0.0002778 WGS 84 FALSE [x]
y    1 325  46.02 -0.0002778 WGS 84 FALSE [y]

The read_stars() function loads a GeoTIFF file into a stars object, which combines attributes and dimensions. In this example, elevation is the only attribute, while x and y are the dimensions. By default, the attribute would take the name of the input file; to ensure a concise, meaningful attribute name, we set it to elevation using setNames(). The output also summarizes the attribute’s values, showing that elevation ranges from about 1,800 m in the valleys to roughly 4,300 m near the summit. From the band metadata stored in the GeoTIFF, read_stars() knew that height is measured in meters ([m]).

Additionally, the output provides the spatial information needed to place the cells on the Earth’s surface:

  • from and to: the index bounds within the reference grid for each dimension
  • offset: the coordinate at the start of cell 1 in the reference grid
  • delta: the signed spacing between adjacent cells
  • refsys: the coordinate reference system (CRS) used to interpret each dimension
  • point: a Boolean value indicating whether positions along a dimension represent points (TRUE) or rectangular intervals (FALSE)
  • x/y: which dimensions act as the spatial x- and y-axes, marked by [x] and [y]

The geom_stars() function draws a map of a stars object. Because matterhorn_dem has only one attribute (elevation), geom_stars() maps it to the fill aesthetic automatically. As with other geoms, the colors can be customized with a compatible scale_fill_*() function; for now, we use ggplot2’s default continuous fill scale:

ggplot() +
  geom_stars(data = matterhorn_dem) +
  coord_sf(crs = st_crs(matterhorn_dem)) +
  labs(
    x = "Longitude",
    y = "Latitude",
    fill = "Elevation (m)"
  ) +
  theme_minimal()
A raster map of the Matterhorn region in which cell color varies continuously with elevation.
Figure 17.2: Surface elevation of the Matterhorn region from the Copernicus GLO-30 DEM.

That is the whole recipe: read_stars() to load and geom_stars() to draw. The coord_sf() call is also needed because, unlike sf objects, stars objects do not pass their CRS to the ggplot object. Knowing the CRS, ggplot2 can set the aspect ratio of the map: At the latitude of the Matterhorn, one degree of longitude covers only about 70% of the distance of one degree of latitude. Later sections in this chapter refine these steps.

17.2 Importing a Raster as a stars Object

The preceding section passed a filename to read_stars() and immediately produced a map. We now look more closely at what the file contains, how read_stars() interprets it, and how we can inspect a large raster without immediately loading all its cell values into memory.

17.2.1 GeoTIFF and Other Raster Formats

The Matterhorn elevation data are stored in a file whose name ends in .tif. This extension identifies the Tagged Image File Format (TIFF), commonly used for raster images, such as photos or document scans. When a TIFF also contains the information needed to place its cells geographically, it is called a GeoTIFF.

In addition to the raster values, a GeoTIFF can record metadata such as:

  • The CRS
  • The spatial extent
  • The cell size
  • Descriptions and units for individual bands
  • The value used to represent missing data

Consolidating the values and their spatial metadata in a single file makes GeoTIFF a convenient, robust format for exchanging geospatial rasters.

GeoTIFF is widely used, but it is only one of several common raster formats. Many environmental datasets—especially those with a time dimension—are stored in Network Common Data Form (NetCDF) files, which use the .nc extension. Other common formats include GRIB for weather and atmospheric data, HDF5 for large scientific datasets, JPEG 2000 for compressed satellite imagery, and ASCII Grid for simple text-based grids.

These formats differ in what they can store and how they organize the stored data. Fortunately, we do not need a different R function for each one. The stars package delegates file access to the Geospatial Data Abstraction Library (GDAL). GDAL provides drivers that understand the individual formats, allowing read_stars() to provide a common interface in R. If an input file declares a NoData value, read_stars() normally converts occurrences of that value to NA.

17.2.2 Lazy Loading with a Proxy

The Matterhorn DEM contains only 467 × 325 cells, so reading all its values into memory is inexpensive. Full national and global datasets can be several orders of magnitude larger. For such files, it is more efficient to inspect and subset the raster before reading all its values.

Setting proxy = TRUE creates a reference to the raster without immediately loading its complete array of cell values:

matterhorn_dem_proxy <- read_stars(
  "matterhorn_dem_copernicus_glo30_native.tif",
  proxy = TRUE
) |>
  setNames("elevation")
matterhorn_dem_proxy
stars_proxy object with 1 attribute in 1 file(s):
$elevation
[1] "matterhorn_dem_copernicus_glo30_native.tif"

dimension(s):
  from  to offset      delta refsys point x/y
x    1 467  7.594  0.0002778 WGS 84 FALSE [x]
y    1 325  46.02 -0.0002778 WGS 84 FALSE [y]

The result is a stars_proxy object. Its printed output identifies the source file and describes the raster dimensions, extent, resolution, and CRS. Unlike the printed in-memory object matterhorn_dem in the preceding section, the proxy does not report a statistical summary of the elevation values because those values have not yet been read from disk.

A proxy stores information about where the values can be obtained and records certain operations without carrying them out immediately. For example, we can crop a proxy to a smaller area while it remains a proxy. Reading is deferred until an operation requires the values.

To load the values explicitly, pass the proxy to st_as_stars(). The deferred read is then executed, the array is loaded into memory, and the result becomes an ordinary in-memory stars object:

matterhorn_dem_in_memory <- st_as_stars(matterhorn_dem_proxy)
identical(matterhorn_dem, matterhorn_dem_in_memory)
[1] TRUE

In summary:

  • With proxy = FALSE, read_stars() reads the raster values into memory immediately.
  • With proxy = TRUE, it returns a stars_proxy containing metadata and a reference to the source.
  • The st_as_stars() function materializes a proxy by reading the required values.

When proxy is not set explicitly, read_stars() compares the number of cells with a size threshold and returns a proxy for larger inputs. Explicitly supplying TRUE or FALSE makes the intended behavior clearer.

A proxy offers no practical advantage for our small Matterhorn DEM, so the remainder of the chapter continues to use the in-memory matterhorn_dem object.

17.3 Attributes and Dimensions

To work with stars objects effectively, we must understand their structure. A stars object is a data cube—one or more arrays of values organized along labeled dimensions:

  • Attributes contain the values being represented, such as elevation, temperature, or land-cover class;
  • Dimensions describe how those values are organized, for example across space, spectral bands, or time.

The names() function returns the name of its single attribute, elevation, while dim() reports the lengths of its two dimensions, x and y:

names(matterhorn_dem)
[1] "elevation"
dim(matterhorn_dem)
  x   y 
467 325 

The attribute can be extracted using the $ operator or dplyr::pull(). It is stored in an array with the same dimension as the stars object:

elevation_values <- matterhorn_dem$elevation
dim(elevation_values)
  x   y 
467 325 

A stars object can contain several attributes that share the same dimensions. For example, an object might contain separate attributes named elevation and temperature, each represented by a 467-by-325 array over the same spatial grid.

Dimensions can also extend beyond the two spatial axes. Consider the three-band Sentinel-2 image supplied with this chapter:2

matterhorn_rgb <- read_stars(
  "matterhorn_rgb_sentinel2_native.tif"
) |>
  setNames("rgb")
dim(matterhorn_rgb)
   x    y band 
1020 1020    3 

The object has dimensions x, y, and band. The first two identify a spatial cell; the third selects its red, green, or blue channel. A single cell therefore contains three values:

matterhorn_rgb$rgb[1, 1, ] |>
  setNames(c("red", "green", "blue"))
  red green  blue 
  208   188   133 

All three values describe the same patch of ground. They differ systematically by color channel, so representing the channel as a band dimension produces one 1020-by-1020-by-3 array.

The same data could, in principle, be represented by three attributes named red, green, and blue, each arranged over x and y. The band representation reflects the organization of the multiband GeoTIFF and makes it convenient to select, compare, or combine channels along one dimension.

This distinction suggests a useful rule of thumb. Conceptually different quantities measured over the same grid, such as elevation and temperature, are often represented as separate attributes. Values that differ along a systematic axis, such as spectral channel or observation time, are often represented as positions along an additional dimension. The most useful representation depends partly on how the data are stored and how we intend to analyze them.

In short, attributes and dimensions answer complementary questions. An attribute tells us what values are stored. Its dimensions tell us where those values belong and how they are organized.

17.4 Retrieving Metadata

The printed representation of a stars object provides a useful overview, but often we must retrieve particular pieces of metadata with dedicated functions. We already used two such functions in Section 17.3: names() and dim(). The complete description of every dimension is returned by st_dimensions() as a dimensions object, which can be queried programmatically:

dem_dimensions <- st_dimensions(matterhorn_dem)
class(dem_dimensions)
[1] "dimensions"

A dimensions object stores information in a list with one element per dimension:

names(dem_dimensions)
[1] "x" "y"

For each dimension, we can retrieve from, to, and additional spatial information if available:

names(dem_dimensions$x)
[1] "from"   "to"     "offset" "delta"  "refsys" "point"  "values"

For example, the delta element of dem_dimensions$x contains the spacing between adjacent cells in the x-direction:

dem_dimensions$x$delta
[1] 0.0002777778

Several spatial properties can be retrieved without going through the dimensions object: the CRS with st_crs(), the bounding box with st_bbox(), and the resolution with st_res(). The following code uses st_crs() to reveal the name of the CRS, and the epsg element contains its EPSG code:

matterhorn_crs <- st_crs(matterhorn_dem)
matterhorn_crs$input
[1] "WGS 84"
matterhorn_crs$epsg
[1] 4326

We conclude that the raster uses EPSG:4326, the CRS we encountered in Section 16.3, in which the x-coordinate is the longitude and the y-coordinate is the latitude. Both are measured in degrees.

The st_bbox() function returns the bounding box, which consists of the outer boundaries of the raster:

st_bbox(matterhorn_dem)
     xmin      ymin      xmax      ymax 
 7.593750 45.931250  7.723472 46.021528 

Here, xmin and xmax are the longitudes of the western and eastern boundaries, while ymin and ymax are the latitudes of the southern and northern boundaries.

The st_res() function returns the resolution, which is the cell size along the two spatial dimensions:

st_res(matterhorn_dem)
           x            y 
0.0002777778 0.0002777778 

The output shows that each cell measures 1/3600 of a degree in both directions. On the ground, however, a degree of longitude and a degree of latitude cover different distances, which is why the map in Section 17.1 needed coord_sf() to set the aspect ratio.

Because offset and delta encode a regular grid without storing every coordinate individually, the coordinates themselves must be generated on request. A convenient function for this purpose is st_get_dimension_values(); its where argument selects the start, center, or end of each cell interval:

matterhorn_dem |>
  st_get_dimension_values("x", where = "start") |>
  head()
[1] 7.593750 7.594028 7.594306 7.594583 7.594861 7.595139

For nonspatial dimensions, the returned values need not be numeric:

st_get_dimension_values(matterhorn_rgb, "band")
[1] "red"   "green" "blue" 

Table 17.1 summarizes the accessor functions introduced in this section. Together, they let us inspect a stars object programmatically, without relying on its printed representation.

Table 17.1: Functions for retrieving metadata from a stars object.
Function Information returned
names() Names of the attributes
dim() Length of every dimension
st_dimensions() Complete dimension metadata
st_crs() Coordinate reference system
st_bbox() Spatial bounding box
st_res() Spatial resolution
st_get_dimension_values() Coordinates or labels along one dimension

17.5 Selecting Areas of Interest

When visualizing geospatial rasters, we sometimes want to focus on a particular geographic region rather than the entire dataset. In this section, we consider three approaches. Zooming changes the area displayed on the map without altering the underlying raster. Cropping retains a rectangular subset of the raster, while masking replaces cell values outside a selected region with missing values (NA), allowing us to isolate areas with irregular boundaries.

To compare zooming and cropping, we select a rectangular region, outlined in red in Figure 17.3, around the Matterhorn summit. We then use the Swiss–Italian border to demonstrate masking.

region <- st_bbox(
  c(xmin = 7.625, xmax = 7.690, ymin = 45.965, ymax = 45.990),
  crs = st_crs(matterhorn_dem)
)
gg_matterhorn <- function(raster) {
  ggplot() +
    geom_stars(data = raster) +
    labs(x = "Longitude", y = "Latitude", fill = "Elevation (m)") +
    theme_minimal()
}
gg_matterhorn(matterhorn_dem) +
  geom_sf(
    data = st_as_sf(st_as_sfc(region)),
    fill = NA,
    color = "red",
    linewidth = 2
  ) +
  coord_sf(crs = st_crs(matterhorn_dem))
Elevation map of the Matterhorn region with a red rectangle marking a smaller area around the summit.
Figure 17.3: The Matterhorn DEM with the region of interest outlined in red.

17.5.1 Zooming to a Specific Area

gg_matterhorn(matterhorn_dem) +
  coord_sf(
    xlim = region[c("xmin", "xmax")],
    ylim = region[c("ymin", "ymax")],
    crs = st_crs(matterhorn_dem)
  )
The Matterhorn elevation raster zoomed to the previously outlined rectangular region.
Figure 17.4: The area of interest displayed by setting coordinate limits without cropping.

17.5.2 Cropping to a Bounding Box

cropped_dem <- st_crop(matterhorn_dem, region)
st_bbox(cropped_dem)
     xmin      ymin      xmax      ymax 
 7.624861 45.964861  7.690139 45.990139 
gg_matterhorn(cropped_dem) +
  coord_sf(crs = st_crs(cropped_dem))
Figure 17.5

17.5.3 Masking with a Polygon

The Matterhorn stands on the border between Switzerland and Italy, so we use a polygon of the Swiss national territory to demonstrate masking:3

switzerland <- st_read(
  "switzerland_swissboundaries3d.geojson",
  quiet = TRUE
) |>
  st_transform(st_crs(matterhorn_dem))
inside_mask <- st_crop(
  matterhorn_dem,
  switzerland,
  crop = FALSE
)
gg_matterhorn(inside_mask) +
  coord_sf(crs = st_crs(inside_mask))
Elevation colors north of the Swiss–Italian border, with the area south of the border gray to indicate masked cells.
Figure 17.6: Elevation inside Switzerland, with cells outside the country masked.
outside_switzerland <- matterhorn_dem |>
  st_bbox() |>
  st_as_sfc() |>
  st_difference(st_geometry(switzerland))
outside_mask <- st_crop(
  matterhorn_dem,
  outside_switzerland,
  crop = FALSE
)
gg_matterhorn(outside_mask) +
  coord_sf(crs = st_crs(outside_mask))
Elevation colors south of the Swiss–Italian border, with the area north of the border gray to indicate masked cells.
Figure 17.7: Elevation outside Switzerland, preserving the original raster extent.
outside_cropped <- st_crop(
  matterhorn_dem,
  outside_switzerland,
  crop = TRUE
)
gg_matterhorn(outside_cropped) +
  coord_sf(crs = st_crs(outside_cropped))
A smaller rectangular elevation map covering the Italian portion, with the empty northern rows removed.
Figure 17.8: Elevation outside Switzerland after cropping to the masked region’s bounding box.

17.6 Missing Data

17.7 Transforming Values and Dimensions

17.8 Changing the Grid: Resolution, Alignment, and Coordinate Reference System

17.9 Choosing a Suitable Palette

17.10 Terrain Visualization

17.11 Conclusion


  1. Produced using Copernicus WorldDEM-30 © DLR e.V. 2010–2014 and © Airbus Defence and Space GmbH 2014–2018, provided under COPERNICUS by the European Union and ESA; all rights reserved. The organisations in charge of the Copernicus programme by law or by delegation do not incur any liability for any use of the Copernicus WorldDEM-30.↩︎

  2. Contains modified Copernicus Sentinel data 2023.↩︎

  3. Source: Federal Office of Topography swisstopo. The polygon is taken from the swissBOUNDARIES3D dataset (release 2026-01), with elevations removed and coordinates converted to longitude and latitude.↩︎