Connect your AI coding assistants to the physical world with Wherobots MCP and CLI Learn More

Change Detection Using AlphaEarth Foundations (Part 2)

Authors

alphaearth foundations part 2 header image

AlphaEarth Foundations embeddings gave us an initial view of temporal change in our earlier post on agricultural dynamics, but that first pass also exposed a limitation: raw embedding distances do not live on a consistent scale across pixels or land-cover types. As a result, it is hard to compare how unusual a given year looks from place to place, even when the relative signal is informative.

In this follow-up, we ask a more targeted question: can we score each annual embedding by how much it stands out from the rest of its local time series? To do that, we compute leave-one-out (LOO) medoid scores in embedding space and apply robust z-score scaling through time for each pixel. This gives us a more stable and interpretable way to identify unusual years across a short annual record.

As in the earlier AlphaEarth Foundations agriculture post, the goal is not to present a definitive change-detection benchmark. Instead, we show a practical workflow for surfacing unusual temporal states in AEF embeddings. We run that workflow against the global AEF Zarr mosaic hosted on Source Cooperative, build a multiscale output for exploration, and inspect several examples around Boulder, Colorado.

Global Zarr AEF Mosaic

For this follow-up we use the global AlphaEarth Foundations Zarr mosaic hosted on Source Cooperative. Thanks to Taylor Geospatial and Source Cooperative for making the dataset available in a form that is easy to access lazily in a single python function call!

The mosaic stores annual AEF embeddings at 10-meter resolution in EPSG:4326, with nine temporal snapshots from 2017 through 2025. Working from one global store keeps the workflow simple: we can subset the region of interest, compute scores, and publish a derived multiscale product without assembling separate local inputs.

import xarray as xr
ds = xr.open_zarr(
    "s3://us-west-2.opendata.source.coop/tge-labs/aef-mosaic/",
    consolidated=False,
    storage_options={"anon": True},
)
ds
/Users/len/code/wherobots-notebook-blogs/.pixi/envs/default/lib/python3.14/site-packages/zarr/core/group.py:3559: ZarrUserWarning: Object at README.md is not recognized as a component of a Zarr hierarchy.
  warnings.warn(
/Users/len/code/wherobots-notebook-blogs/.pixi/envs/default/lib/python3.14/site-packages/zarr/core/group.py:3559: ZarrUserWarning: Object at .checkpoint is not recognized as a component of a Zarr hierarchy.
  warnings.warn(
<xarray.Dataset> Size: 4PB
Dimensions:     (time: 9, band: 64, y: 1859584, x: 4009984)
Coordinates:
  * time        (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
  * band        (band) object 512B 'A00' 'A01' 'A02' 'A03' ... 'A61' 'A62' 'A63'
  * y           (y) float64 15MB 83.69 83.69 83.69 ... -83.36 -83.36 -83.36
  * x           (x) float64 32MB -180.0 -180.0 -180.0 ... 180.2 180.2 180.2
Data variables:
    embeddings  (time, band, y, x) int8 4PB dask.array<chunksize=(1, 64, 256, 256), meta=np.ndarray>
Attributes: (12/15)
    proj:code:               EPSG:4326
    spatial:dimensions:      ['y', 'x']
    spatial:transform:       [8.983111749910169e-05, 0.0, -180.0, 0.0, -8.983...
    spatial:transform_type:  affine
    spatial:bbox:            [-180.0, -83.36280346631479, 180.2213438735178, ...
    spatial:shape:           [1859584, 4009984]
    ...                      ...
    geoemb:model:            https://developers.google.com/earth-engine/datas...
    geoemb:source_data:      https://source.coop/tge-labs/aef/v1/annual/
    geoemb:data_type:        int8
    geoemb:gsd:              8.983111749910169e-05
    geoemb:quantization:     {'method': 'signed_square', 'original_dtype': 'f...
    zarr_conventions:        [{'uuid': 'f17cb550-5864-4468-aeb7-f3180cfb622f'...

At first glance, 3.81 PiB and 1,024,049,664 chunks in S3 sounds impractically large. For this demo, though, a few details keep the workflow manageable:

  • only chunks that intersect land are present
  • the dataset is sharded into nine spatial chunks, which reduces the effective object count
  • we subset Colorado rather than operating on the full global mosaic

RasterFlow Tools Used

RasterFlow is Wherobots’ serverless inference engine for Earth Observation data. It builds inference-ready mosaics from remote sensing data and runs distributed workflows at scale. This notebook uses two RasterFlow capabilities:

Change Detection with RasterFlow Remote

For this run, we call run_mosaics_change on the RasterFlow Remote client. The method takes an input Zarr mosaic and writes out a derived Zarr mosaic containing one or more temporal change scores.

Here we compute both difference-over-time and LOO medoid scores in AEF embedding space, with robust z-score normalization applied across time for each pixel. The snippet below matches the current remote-client API used for this Colorado example.

from affine import Affine
from rasterio.transform import rowcol

# construct a bounding box selector for Colorado
transform = Affine(*ds.attrs["spatial:transform"])
row_min, col_min = rowcol(transform, -109.060253, 41.003444)
row_max, col_max = rowcol(transform, -102.041524, 37.000232)
selector = {"x": [col_min, col_max], "y": [row_min, row_max]}
selector
{'x': [np.int32(789701), np.int32(867833)],
 'y': [np.int32(475138), np.int32(519702)]}
from rasterflow_remote import DistanceMetricEnum, RasterflowClient, TemporalScoreMethodEnum

client = RasterflowClient()

result = client.run_mosaics_change(
    store="s3://us-west-2.opendata.source.coop/tge-labs/aef-mosaic/",
    score_methods=[
        TemporalScoreMethodEnum.DIFFERENCE,
        TemporalScoreMethodEnum.LOO_MEDOID,
    ],
    distance_metric=DistanceMetricEnum.ALPHA_EARTH_COSINE,
    data_var="embeddings",
    robust_time_zscore=True,
    selector=selector,
)

LOO Medoid Scores for Small Samples

To measure how unusual a given annual embedding is relative to its temporal context, we use a leave-one-out medoid (LOO medoid) score in embedding space. This is well suited to the AEF setting here, where we only have nine annual observations and want a reference that is less sensitive to outliers than a simple average.

Given a time series of embeddings {e1,,eT}\{e_1, \dots, e_T\}, the LOO medoid score at time tkt_k compares eke_k to a representative embedding computed from the remaining observations:

Ek={ejjk}. E_{-k} = \{e_j \mid j \neq k\}.

We define that representative as the medoid of EkE_{-k}, i.e. the embedding that minimizes total distance to all others:

emedoid(k)=argmineiEkejEkd(ei,ej). e_{\text{medoid}}^{(-k)} = \arg\min_{e_i \in E_{-k}} \sum_{e_j \in E_{-k}} d(e_i, e_j).

The LOO medoid score is then: LOOmedoid(tk)=d(ek,emedoid(k)), \mathrm{LOO}_{\mathrm{medoid}}(t_k) = d\left(e_k, e_{\text{medoid}}^{(-k)}\right),

where d(,)d(\cdot, \cdot) is a distance metric (e.g., Euclidean or cosine distance).

Interpretation: LOOmedoid(tk)\mathrm{LOO}_{\mathrm{medoid}}(t_k) measures how far the embedding at time tkt_k is from the medoid of the remaining time series. Larger values indicate that the year-specific embedding is less consistent with the rest of the temporal sequence, suggesting a potential change or anomalous state with respect to all other years.

Results

Using the workflow above, we compute two normalized change layers across the nine-year Colorado subset of the AEF mosaic: a difference-over-time score and a LOO medoid score.

The intermediate result below is a Zarr dataset. You may notice:

  • 9 time steps, corresponding to the annual AEF embeddings from 2017 through 2025
  • 2 score bands, one for consecutive difference and one for LOO medoid deviation
  • ~235Gb of data
ds = xr.open_zarr(
    "s3://sandbox-wherobots-mosaics-tmp/rasterflow/rasterflow/development/rbz2h49wvpjt7h4nzq5g/76e986b947cc.zarr"
)
ds
<xarray.Dataset> Size: 253GB
Dimensions:     (time: 9, band: 2, y: 44800, x: 78336)
Coordinates:
  * time        (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
  * band        (band) object 16B 'difference_alpha_earth_cosine_distance_rob...
  * y           (y) float64 358kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
  * x           (x) float64 627kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
Data variables:
    embeddings  (time, band, y, x) float32 253GB dask.array<chunksize=(1, 1, 256, 256), meta=np.ndarray>
Attributes: (12/15)
    proj:code:               EPSG:4326
    spatial:dimensions:      ['y', 'x']
    spatial:transform:       [8.983111749910169e-05, 0.0, -180.0, 0.0, -8.983...
    spatial:transform_type:  affine
    spatial:bbox:            [-180.0, -83.36280346631479, 180.2213438735178, ...
    spatial:shape:           [1859584, 4009984]
    ...                      ...
    geoemb:model:            https://developers.google.com/earth-engine/datas...
    geoemb:source_data:      https://source.coop/tge-labs/aef/v1/annual/
    geoemb:data_type:        int8
    geoemb:gsd:              8.983111749910169e-05
    geoemb:quantization:     {'method': 'signed_square', 'original_dtype': 'f...
    zarr_conventions:        [{'uuid': 'f17cb550-5864-4468-aeb7-f3180cfb622f'...

Visualization

To make the result easier to explore, we run the build_zarr_multiscales function to create a multiscale Zarr mosaic optimized for interactive viewing and analysis, then publish it to a public bucket for anyone to explore.

xr.open_datatree("s3://wherobots-examples/rasterflow/mosaics/co-aef-change.zarr")
<xarray.DataTree>
Group: /
│   Attributes:
│       zarr_conventions:        [{'uuid': 'd35379db-88df-4056-af3a-620245f8e347'...
│       multiscales:             {'layout': [{'asset': '0', 'transform': {'scale'...
│       proj:code:               EPSG:4326
│       proj:wkt2:               GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System...
│       spatial:dimensions:      ['y', 'x']
│       spatial:registration:    pixel
│       spatial:transform_type:  affine
│       spatial:shape:           [44800, 78336]
│       spatial:transform:       [8.983111749216732e-05, 0.0, -109.07797340998921...
│       spatial:bbox:            [-109.07797340998921, 36.97927342911413, -102.04...
├── Group: /0
│       Dimensions:      (time: 9, band: 2, y: 44800, x: 78336)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 358kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
│         * x            (x) float64 627kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 253GB ...
│           spatial_ref  int32 4B ...
├── Group: /1
│       Dimensions:      (time: 9, band: 2, y: 22400, x: 39168)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 179kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
│         * x            (x) float64 313kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 63GB ...
│           spatial_ref  int32 4B ...
├── Group: /2
│       Dimensions:      (time: 9, band: 2, y: 11200, x: 19584)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 90kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
│         * x            (x) float64 157kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 16GB ...
│           spatial_ref  int32 4B ...
├── Group: /3
│       Dimensions:      (time: 9, band: 2, y: 5600, x: 9792)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 45kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
│         * x            (x) float64 78kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 4GB ...
│           spatial_ref  int32 4B ...
├── Group: /4
│       Dimensions:      (time: 9, band: 2, y: 2800, x: 4896)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 22kB 41.0 41.0 41.0 41.0 ... 36.98 36.98 36.98
│         * x            (x) float64 39kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 987MB ...
│           spatial_ref  int32 4B ...
├── Group: /5
│       Dimensions:      (time: 9, band: 2, y: 1400, x: 2448)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 11kB 41.0 41.0 41.0 40.99 ... 36.99 36.98 36.98
│         * x            (x) float64 20kB -109.1 -109.1 -109.1 ... -102.0 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 247MB ...
│           spatial_ref  int32 4B ...
├── Group: /6
│       Dimensions:      (time: 9, band: 2, y: 700, x: 1224)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 6kB 41.0 41.0 40.99 40.98 ... 36.99 36.99 36.98
│         * x            (x) float64 10kB -109.1 -109.1 -109.1 ... -102.1 -102.0 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 62MB ...
│           spatial_ref  int32 4B ...
├── Group: /7
│       Dimensions:      (time: 9, band: 2, y: 350, x: 612)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 3kB 41.0 40.99 40.97 40.96 ... 37.01 37.0 36.99
│         * x            (x) float64 5kB -109.1 -109.1 -109.0 ... -102.1 -102.1 -102.0
│       Data variables:
│           embeddings   (time, band, y, x) float32 15MB ...
│           spatial_ref  int32 4B ...
├── Group: /8
│       Dimensions:      (time: 9, band: 2, y: 175, x: 306)
│       Coordinates:
│         * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
│         * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
│         * y            (y) float64 1kB 40.99 40.97 40.95 40.92 ... 37.04 37.01 36.99
│         * x            (x) float64 2kB -109.1 -109.0 -109.0 ... -102.1 -102.1 -102.1
│       Data variables:
│           embeddings   (time, band, y, x) float32 4MB ...
│           spatial_ref  int32 4B ...
└── Group: /9
        Dimensions:      (time: 9, band: 2, y: 88, x: 153)
        Coordinates:
          * time         (time) int32 36B 2017 2018 2019 2020 2021 2022 2023 2024 2025
          * band         (band) StringDType() 32B 'difference_alpha_earth_cosine_dist...
          * y            (y) float64 704B 40.98 40.93 40.89 40.84 ... 37.07 37.03 36.98
          * x            (x) float64 1kB -109.1 -109.0 -109.0 ... -102.2 -102.1 -102.1
        Data variables:
            embeddings   (time, band, y, x) float32 969kB ...
            spatial_ref  int32 4B ...

Using the multiscales convention and building on carbonplan’s zarr-layer, we can visualize the nine-year mosaic.

If the viewer below is not working, try fullscreen mode here.

Examples of Changes in Boulder, Colorado

The video below shows the normalized change scores around Boulder, Colorado. It is a quick qualitative pass rather than a formal validation exercise, but several patterns stand out more clearly once the scores are viewed over time.

Takeaways and Next Steps

This follow-up suggests that leave-one-out medoid scoring, combined with robust time-wise normalization, is more useful for inspecting AEF change signal than raw distance alone. The normalization makes change magnitude more comparable across pixels, while the leave-one-out medoid highlights anomalous signal across the full observation window rather than only between adjacent time steps. This does not make AEF embeddings a complete change-detection product, but it does provide a clearer and more consistent way to identify which years look unusual at each pixel.

That matters because AEF operates at a spatial and temporal resolution where many changes are subtle, cumulative, or mixed with recurring seasonal variation. A normalized score does not solve interpretation by itself, but it makes the first-pass search for interesting change much more practical.

In this notebook, we used run_mosaics_change on the RasterFlow Remote client to compute change scores from the global AEF mosaic, built a multiscales derivative for browser-based exploration, and reviewed several examples around Boulder. Followup work may include using the embeddings to also help filter the object types of interest for taking yet another step closer towards a more complete change-detection product, especially where we have external labels or stronger domain priors.

If you are interested in this approach, reach out to the Wherobots team at support@wherobots.com.