Embeddings (TESSERA, AlphaEarth)¶
Foundation models for Earth observation turn a whole year of satellite images into one vector per pixel: an embedding. Two products are global, open and made at 10 m every year since 2017, TESSERA and Google's AlphaEarth Foundations. zeit reads them as a cube like any other, so the classifiers, the clustering, the segmentation and the validation of zeit work on them as they are, and adds what is particular to embeddings: looking at them, searching for places like a sample, and following each pixel's embedding from year to year.
(time, band, y, x) cube of 128 (TESSERA) or 64 (AlphaEarth) dimensionsWhat an embedding is¶
A pixel seen by Sentinel-1 and Sentinel-2 through one year is a few hundred numbers: a dozen bands on every clear date, radar backscatter every few days, gaps under clouds. A foundation model is a neural network trained on such series from the whole planet to squeeze them into a short vector, so that the vector keeps what tells places apart (forest from pasture, soy from maize, a city from bare soil) and drops what does not (noise, clouds, the exact dates of the images). That vector is the embedding.
The vector of a pixel summarises how it looked through the whole year. Pixels whose vectors point the same way look alike; a pixel whose vector turns from one year to the next has changed.
The numbers of an embedding have no physical meaning on their own: A07 is not a band nor an index. What carries meaning is the direction of the vector. Two pixels whose vectors point the same way looked alike through the year; a pixel whose vector turns from one year to the next has changed. Everything zeit does with embeddings comes back to that angle, measured by the cosine similarity:
1 for the same direction, 0 for unrelated ones. zeit.similarity maps it against a reference, and zeit.embedding_change maps \(1 - \cos\theta\) between consecutive years.
This makes embeddings very good at some things and blind to others:
| Embeddings are good at | They cannot tell |
|---|---|
| Classifying land cover from a few dozen samples | When in the year something happened (a whole year is one vector) |
| Finding more places like a sample, anywhere | Physical quantities: NDVI, biomass, a magnitude of loss in reflectance |
| Flagging that a pixel changed between two years, whatever the change | The direction of a change (loss and regrowth both just "move") |
| Clustering a region into its kinds of land without labels | Anything before 2017 |
For dates within a year, physical magnitudes and the record before 2017, use the classic algorithms (LandTrendr, CCDC, BFAST) on the images themselves. The two approaches answer different questions, and zeit compares them on the same grid (see below).
Two products, one cube¶
| TESSERA | AlphaEarth Foundations | |
|---|---|---|
| Dimensions | 128 | 64 (each embedding has length 1) |
| Cells, years | 10 m, 2017–2025 | 10 m, 2017–2025 |
| Made from | Sentinel-1 and Sentinel-2 | Sentinel-1, Sentinel-2, Landsat and more |
| Stored as | Zarr, int8 times a scale per pixel, a group per UTM zone | COGs, int8 on a fixed curve, per year and UTM zone |
| Read through | geotessera, installed with zeit (Python 3.12+) |
nothing: free open COGs on Source Cooperative (default); or Earth Engine |
| Licence | CC0 1.0 (attribution requested) | CC-BY 4.0, attribution required |
Both are read with one function, and give the same thing: a georeferenced (time, band, y, x) cube, time on January 1 of each year, band the dimensions (A00, A01...), NaN where there is no embedding. A region within a UTM zone keeps the zone's CRS and the cells the embeddings were made on, so the two products line up cell by cell:
import zeit
box = (-63.136, -10.133, -63.086, -10.083) # 5 x 5 km of Rondônia, Brazil
emb = zeit.load_embeddings(box, source="tessera", years=range(2017, 2026))
aef = zeit.load_embeddings(box, source="alphaearth", years=range(2017, 2026))
emb.rio.transform() == aef.rio.transform() # True: the same 554 x 549 cells of 10 m, EPSG:32720
Every figure of this page comes from these two cubes, a box of the Rondônia scene used by the LandTrendr tutorial, where cattle pasture and soy fields have kept replacing the forest since 2016.
The cube is lazy: the blocks are read when computed, a year and every dimension at a time, kept as downloaded (up to 1 GB, so that what is computed twice is downloaded once) and dequantised as they arrive. Over this box, all nine years took 38 s for TESSERA and 59 s for AlphaEarth on a home connection: 700 MB of AlphaEarth and 1.4 GB of TESSERA once in memory as float32. chunks=None reads it all at once; zeit.save_raster(emb, "emb.tif") keeps a copy that zeit.load_raster reads back, still knowing what it holds.
Regions come as bounds in longitude and latitude, a vector file or a GeoDataFrame (cells outside its polygons become NaN), or the grid of another raster:
emb = zeit.load_embeddings("municipality.gpkg", source="tessera", years=2024)
on_landsat = zeit.load_embeddings(source="alphaearth", like=landsat, years=range(2018, 2025)) # 30 m, averaged
With like= (or crs=/res=) the embeddings are put on that grid: by the nearest cell, or by the mean of the cells when the new ones are at least 1.5 times larger. A region across UTM zones needs crs=.
Check the coverage of each year
The products are global, but not every year covers every cell. Over this box, TESSERA has no 2017 embedding for 72% of the cells (AlphaEarth has all of them), so the figures below start in 2018. One line shows the share of cells without an embedding, per year:
What the cube remembers¶
The cube's attributes say which embeddings it holds: product, version and variant (TESSERA has had several inference runs, and each is a space of its own), the licence and the citation. A model trained with zeit.train_classifier or zeit.ai.train records them, and zeit.classify/zeit.ai.predict refuse a cube of other embeddings rather than produce a map from vectors it was never trained on. The algorithms that fit a physical, seasonal signal (LandTrendr, CCDC, BFAST, phenology, TWDTW, indices...) refuse a cube of embeddings too: there is nothing in a yearly summary for them to fit.
Look¶
Dozens of dimensions cannot be shown at once, so zeit.plot shows the first three principal components as red, green and blue. They hold much of the variety of a region: here 69% of the variance of AlphaEarth (51%, 13% and 5%) and 70% of TESSERA's. The components are fitted once, on pixels of every year, and applied to all of them: a colour means the same embedding in 2018 and in 2025, and paging through the years shows the land changing.
The viewer has two more views, picked in the panel beside the map, and computes all of them on the GPU from the vectors themselves:
- Similarity: move the mouse, and every pixel is coloured by how much it looks like the one under the cursor (the cosine similarity of their embeddings). Click a pixel to pin it as the reference, tick keep the reference's year, and page through the years: a pasture that comes to look like the forest you pinned, or a field that stops looking like it, shows up. The chart below the map follows the cursor's pixel over the years.
- Change: how far each pixel moved from the previous year (1 − cosine), the same distance as
zeit.embedding_change.
The components can also be fitted on the visible area only: zoom into a forest, and the colours spread over its differences instead of over forest versus city. See Visualisation for the details.
Classify from few samples¶
The embeddings were made for this. Each dimension of a year is a feature:
year = emb.sel(time="2024") # blocks read for training are kept for classify
rf = zeit.train_classifier(year, "samples.gpkg", label="class")
classes = zeit.classify(year, rf)
Because the model behind the embeddings has already learned what tells land covers apart, a random forest on them needs far fewer samples than one on raw bands: both papers report accurate maps from sparse labels. Then validate it as any map, with a probability sample and error-adjusted areas (Accuracy & Area):
design = zeit.sampling_design(classes.label, expected_ua=0.8)
points = zeit.stratified_sample(classes.label, design=design)
Several years at once (emb.sel(time=slice("2022", "2024"))) give the classifier how each pixel changed. Train on the same product, version and variant you classify: the cube's attributes enforce it.
Without labels, zeit.som clusters the embeddings into a few kinds of land and zeit.snic segments them into objects:
Find more places like these¶
forest = zeit.similarity(aef, "forest_samples.gpkg", year=2018) # (time, y, x): cosine similarity
per_class = zeit.similarity(emb, "samples.gpkg", by="class") # one map per class
The reference is the mean embedding of the samples (every cell of a polygon) in one year (year, the last of the cube by default), and every year of the cube is compared with it: where something like it appears, and when. With by=, each value of a column gets its own reference and its own map, under a class dimension. metric="euclidean" gives a distance instead (0 for the same embedding).
A similarity map has no fixed threshold: how similar "the same thing" is depends on the product and on how varied the samples are. Look at its distribution, as in the figure: when the samples are a coherent class, it has a clear peak near 1, and the valley before it is the threshold.
Change from year to year¶
change = zeit.embedding_change(emb) # distance of each year to the year before
events = zeit.extract_events(change, min_magnitude=0.3)
embedding_change returns two maps: distance (time, y, x), how far each pixel's embedding moved (1 - cos of the angle between two years, 0 for no movement, up to 2), and noise (y, x), the median of a pixel's distances, its usual movement from year to year. With baseline="first" or a year, every year is measured against that one instead of the year before.
extract_events keeps the largest move of each pixel in the schema of the other change algorithms: yod the year before the first embedding that shows the change, date, magnitude (the distance), and dsnr (the move over the pixel's noise). An embedding summarises a year, so a clearing in the middle of a year shows partly in it and partly in the next.
Choosing a threshold¶
Each product has its own scale. Over this box, the median pixel moved 0.037 a year in AlphaEarth and 0.082 in TESSERA, and the 99th percentile was 0.21 and 0.41. The same threshold flags very different amounts of change: at 0.2, 1.1% of AlphaEarth's pixel-years and 11% of TESSERA's; at 0.3, 0.45% and 4.0%.
Two ways to set it:
- From the distribution. Plot
change.distanceas a histogram, as above: the bulk is year-to-year noise, and the threshold goes where its tail ends. Here, 0.2 for AlphaEarth and 0.3 for TESSERA. -
Relative to each pixel.
extract_events(change, sort_by="dsnr")keeps, for each pixel, the move that stands furthest above its usual one, and a threshold ondsnrmeans the same in both products. It suits places with very different usual movement (a field that changes crop every year next to a forest that does not):
Then check the result as any change map, with a sample and error-adjusted areas: Accuracy & Area.
Compare with the classic algorithms¶
agree = zeit.agreement({
"tessera": zeit.extract_events(zeit.embedding_change(emb), min_magnitude=0.3),
"alphaearth": zeit.extract_events(zeit.embedding_change(aef), min_magnitude=0.2),
"landtrendr": zeit.extract_events(zeit.landtrendr(ndvi), min_magnitude=2000),
}, tolerance=1)
On this box, with the thresholds above:
| Pair | Pixels where both find a change | ... and agree on its year (± 1) |
|---|---|---|
| AlphaEarth and LandTrendr | 15,042 | 12,664 (84%) |
| TESSERA and LandTrendr | 19,449 | 14,869 (76%) |
| AlphaEarth and TESSERA | 17,040 | 14,575 (86%) |
The maps also differ, and for reasons worth knowing. LandTrendr was asked for losses of NDVI only; the embeddings find any change, so changes inside fields (the dark block on the right of TESSERA's map is a field in every year, and changed in 2025) show up in them and not in LandTrendr. LandTrendr works on 30 m Landsat pixels, so its map is grainier, and its record starts in 1985: its losses of 2017 (light yellow) happened between the 2016 and 2017 images, before the first embedding, so the embeddings cannot see them. And TESSERA, at 0.3, flags more pixels than AlphaEarth at 0.2, among them small edges and field margins: a higher threshold, or min_dsnr, makes it stricter.
Earth Engine, local copies, versions¶
- AlphaEarth comes by default from its open copy on Source Cooperative: free, no account, no quota.
backend="gee"reads it from theGOOGLE/SATELLITE_EMBEDDING/V1/ANNUALcollection of Earth Engine instead (start a session withzeit.gee.auth.initialize_gee()), which counts against the account's quota; each year is downloaded once intocache_dir. store=reads a copy of either product: a TESSERA Zarr store (a local one needs nogeotessera), or a folder with AlphaEarth'saef_index.parquetand its COGs.version=/variant=choose a TESSERA run (default v1.1, the complete global one) anddepth=the first dimensions of a v2 store (Matryoshka embeddings, trained to work truncated).- From the shell:
zeit embeddings ./out --source tessera --bbox -63 -10 -62.9 -9.9 --years 2018-2024(CLI).
Cite¶
TESSERA: Feng et al. (2025), TESSERA: Temporal Embeddings of Surface Spectra for Earth Representation and Analysis, arXiv:2506.20380 (embeddings and weights CC0; the authors ask to be cited). AlphaEarth: Brown et al. (2025), AlphaEarth Foundations: An embedding field model for accurate and efficient global mapping from sparse label data, arXiv:2507.22291; the dataset is CC-BY 4.0 and requires the attribution "The AlphaEarth Foundations Satellite Embedding dataset is produced by Google and Google DeepMind." Both are in the cube's attributes.