This notebook illustrates how to access time series of Sentinel-2 imagery using the packages rstac and datacubes.
rstac offers features that simplify the process of acquiring spatial data from STAC (SpatioTemporal Asset Catalog). More information here.
gdalcubes s an R package and C++ library aiming at making processing collections of satellite imagery easier, faster, and more interactive. More information here.
We will use the freely available Sentinel-2 COG catalog on Amazon Web Services (AWS) and the corresponding STAC-API endpoint at https://earth-search.aws.element84.com/v0/collections/sentinel-s2-l2a.
In addition, we will use terra for reading raster files and tmap for visualization.
First of all, we need to install the rstac package.
Use the console to run the following code – one line at a time –:
## Do not run this code from here - Use the R console!!!
#install.packages("rstac")
#install.packages("tidyterra")
#install.packages("gdalcubes")
#install.packages("stars")
#install.packages("mapview")
#install.packages("tmap")
Clean the memory and next, load the libraries:
# clean workspace
rm(list = ls(all=TRUE))
# load libraries
library(rstac)
library(gdalcubes)
library(stars)
library(tmap)
library(tmaptools)
library(mapview)
library(leaflet)
library(sf)
library(ggplot2)
Follow these instructions to create a GeoJSON file with your AOI:
Go to GeoJSON Studio,
Search the municipality in your department with higher agricultural production
Change the background to Satellite
Click on Draw to create a polygon with your area of interest
Save the AOI as GeoJSON in your data directory
In this notebook, the AOI is *La UniĂ³n in Valle del Cauca.
Then, read the geojson file:
#use the function list.files() to check your directory and filenames
union <- st_read("la_union/map.geojson")
## Reading layer `map' from data source
## `/Users/ials/Documents/unal2025/GB2024/data/la_union/map.geojson'
## using driver `GeoJSON'
## Simple feature collection with 1 feature and 1 field
## Geometry type: POLYGON
## Dimension: XY
## Bounding box: xmin: -76.14981 ymin: 4.437113 xmax: -75.9467 ymax: 4.601428
## Geodetic CRS: WGS 84
The function stac_search() allows users to search Sentinel-2 data given an AOI with georeference EPSG:4326, a time period. Let’s try it:
s = stac("https://earth-search.aws.element84.com/v0")
items <- s |>
stac_search(collections = "sentinel-s2-l2a-cogs",
bbox = c(st_bbox(union)["xmin"],
st_bbox(union)["ymin"],
st_bbox(union)["xmax"],
st_bbox(union)["ymax"]),
datetime = "2025-01-01/2025-12-31",
limit = 60) |>
post_request()
items
## ###Items
## - matched feature(s): 224
## - features (60 item(s) / 164 not fetched):
## - S2B_18NUK_20251213_0_L2A
## - S2C_18NUK_20251211_0_L2A
## - S2A_18NUK_20251210_0_L2A
## - S2C_18NUK_20251208_0_L2A
## - S2C_18NUL_20251208_0_L2A
## - S2B_18NUK_20251206_0_L2A
## - S2B_18NUL_20251206_0_L2A
## - S2B_18NUK_20251203_0_L2A
## - S2B_18NUL_20251203_0_L2A
## - S2C_18NUK_20251201_0_L2A
## - ... with 50 more feature(s).
## - assets:
## AOT, B01, B02, B03, B04, B05, B06, B07, B08, B09, B11, B12, B8A, info, metadata, overview, SCL, thumbnail, visual, WVP
## - item's fields:
## assets, bbox, collection, geometry, id, links, properties, stac_extensions, stac_version, type
Let print date and time of first and last images:
range(sapply(items$features, function(x) {x$properties$datetime}))
## [1] "2025-10-17T15:41:46Z" "2025-12-13T15:32:03Z"
We can convert the STAC response to a gdalcubes image collections using stac_image_collection(). This function expects a STAC feature list as input and optionally can apply some filters on metadata and bands. Notice that this operation is much faster than the creation of image collections from local files, because all metadata are already available and no image file must be opened. Below, we create a collection for images with less than 50% cloud cover.
assets = c("B01","B02","B03","B04","B05","B06", "B07","B08","B8A","B09","B11","SCL")
s2_collection = stac_image_collection(items$features, asset_names = assets, property_filter = function(x) {x[["eo:cloud_cover"]] < 20})
Let’s check wa we got:
s2_collection
## Image collection object, referencing 3 images with 12 bands
## Images:
## name left top bottom right
## 1 S2C_18NUK_20251208_0_L2A -76.34752 4.523459 3.529607 -75.81212
## 2 S2C_18NUL_20251208_0_L2A -76.14920 5.427668 4.434019 -75.81300
## 3 S2C_18NUL_20251118_0_L2A -76.14245 5.427668 4.434029 -75.81300
## datetime srs
## 1 2025-12-08T15:32:13 EPSG:32618
## 2 2025-12-08T15:31:59 EPSG:32618
## 3 2025-11-18T15:32:01 EPSG:32618
##
## Bands:
## name offset scale unit nodata image_count
## 1 B01 0 1 3
## 2 B02 0 1 3
## 3 B03 0 1 3
## 4 B04 0 1 3
## 5 B05 0 1 3
## 6 B06 0 1 3
## 7 B07 0 1 3
## 8 B08 0 1 3
## 9 B09 0 1 3
## 10 B11 0 1 3
## 11 B8A 0 1 3
## 12 SCL 0 1 3
In this case, the collection contains 3 images.
Having an image collection object, we can now use gdalcubes. Particularly, we define a data cube view and maybe some additional data cube operations. In the following example, we create a coarse resolution (100m) simple median-composite RGB image from a monthly data cube.
(union_box = st_bbox(union))
## xmin ymin xmax ymax
## -76.149811 4.437113 -75.946702 4.601428
(union_b9377 <- st_bbox(st_transform(union, "EPSG:9377")))
## xmin ymin xmax ymax
## 4650592 2048948 4673070 2067185
gdalcubes_options(parallel = 8)
(v = cube_view(srs="EPSG:9377", dx=10, dy=10, dt="P1M",
aggregation="median", resampling = "average",
extent=list(t0 = "2025-01-01", t1 = "2025-12-31",
left=union_b9377["xmin"], right=union_b9377["xmax"],
top=union_b9377["ymax"], bottom=union_b9377["ymin"])))
## A data cube view object
##
## Dimensions:
## low high count pixel_size
## t 2025-01-01 2025-12-31 12 P1M
## y 2048946.65931213 2067186.65931213 1824 10
## x 4650590.60828835 4673070.60828835 2248 10
##
## SRS: "EPSG:9377"
## Temporal aggregation method: "median"
## Spatial resampling method: "average"
First, natural color (i.e. RGB 432):
raster_cube(s2_collection, v) |>
select_bands(c("B02","B03","B04")) |>
reduce_time(c("median(B02)", "median(B03)", "median(B04)")) |>
plot(rgb = 3:1, zlim = c(0,2500))
Now, false color (i.e. RGB 8-11-4):
raster_cube(s2_collection, v) |>
select_bands(c("B04","B11","B08")) |>
reduce_time(c("median(B04)", "median(B11)", "median(B08)")) |>
plot(rgb = 3:1, zlim = c(0,4500))
# we "download" the data and save it as a netcdf or a tif file
tutorialDir = path.expand("./la_union/")
if (!file.exists((file.path(tutorialDir,"Union_10"))))
{
s2.mask = image_mask("SCL", values = c(3,8,9))
gdalcubes_options(parallel = 24, ncdf_compression_level = 0)
raster_cube(s2_collection, v, mask = s2.mask) |>
write_tif(file.path(tutorialDir,"Union_10.tif"))
}
Check names of downloaded files:
list.files("la_union/Union_10")
## [1] "cube_12e097724472025-01-01.tif" "cube_12e097724472025-02-01.tif"
## [3] "cube_12e097724472025-03-01.tif" "cube_12e097724472025-04-01.tif"
## [5] "cube_12e097724472025-05-01.tif" "cube_12e097724472025-06-01.tif"
## [7] "cube_12e097724472025-07-01.tif" "cube_12e097724472025-08-01.tif"
## [9] "cube_12e097724472025-09-01.tif" "cube_12e097724472025-10-01.tif"
## [11] "cube_12e097724472025-11-01.tif" "cube_12e097724472025-12-01.tif"
First, read one GTiff file:
f <- "la_union/Union_10/cube_12e097724472025-12-01.tif"
union_s2 <- terra::rast(f)
Then, compute the NDVI index:
# 1. Define the bands
red <- union_s2[[4]] # Band 4
nir <- union_s2[[8]] # Band 8
# 2. Calculate NDVI
ndvi <- (nir - red) / (nir + red)
What we got?
ndvi
## class : SpatRaster
## size : 1824, 2248, 1 (nrow, ncol, nlyr)
## resolution : 10, 10 (x, y)
## extent : 4650591, 4673071, 2048947, 2067187 (xmin, xmax, ymin, ymax)
## coord. ref. : MAGNA-SIRGAS 2018 / Origen-Nacional (EPSG:9377)
## source(s) : memory
## varname : cube_12e097724472025-12-01
## name : B08
## min value : -0.99974
## max value : 0.999544
Now, visualization time. Let’s start with a simple plot:
# Map NDVI with a continuous Red-Yellow-Green palette
tm_shape(ndvi) +
tm_raster(
col_palette = "RdYlGn",
style = "cont",
title = "NDVI Value"
) +
tm_layout(main.title = "Sentinel-2 NDVI Dec. 2025")
As you may know, the results of the NDVI calculation range from -1 to 1. Negative values correspond to areas with water surfaces, man-made structures, rocks, clouds, snow; bare soil usually falls within 0.1- 0.2 range; and plants will always have positive values between 0.2 and 1.
Healthy, dense vegetation canopy should be above 0.5, and sparse vegetation will most likely fall within 0.2 to 0.5. However, it’s only a rule of thumb and you should always take into account the season, type of plant and regional peculiarities to know exactly what NDVI values mean.
Another visualization may help to interpret NDVI values:
# 2. Define the requested Red -> Yellow -> Green color palette
# We wrap the domain values to match the theoretical bounds of NDVI (-1 to 1)
ndvi_palette <- colorNumeric(
palette = c("red", "yellow", "green"),
domain = c(-1, 1),
na.color = "transparent" # Keeps pixels with missing data invisible
)
# 3. Build the interactive Leaflet Map
map <- leaflet() %>%
# Add the background Esri Satellite imagery layer
addProviderTiles("Esri.WorldImagery", group = "Esri Satellite") %>%
# Define the map view: longitude, latitude, and specific zoom level
setView(lng = -76.0, lat = 4.50, zoom = 14) %>%
# Add the custom NDVI raster layer overlay
addRasterImage(
x = ndvi,
colors = ndvi_palette,
opacity = 0.6, # Adjust opacity so the underlying satellite imagery is partially visible
group = "NDVI Layer" # Group identifier for the layer control panel
) %>%
# Add a static visual color legend to the map
addLegend(
pal = ndvi_palette,
values = c(-1, 1),
title = "NDVI Index",
position = "bottomright",
opacity = 0.8
) %>%
# Add structural toggles so you can turn the NDVI map layer on or off
addLayersControl(
baseGroups = c("Esri Satellite"),
overlayGroups = c("NDVI Layer"),
options = layersControlOptions(collapsed = FALSE)
)
# Display the map
map
Please note that the date of the satellite image shown in the background does not necessarily match the date of the NDVI.
All you need to know about NDVI may be here
That’s all for today.