True-colour approach 1 — a Sentinel-2 time-lapse of the Danube at Budapest¶
The Rhine/Danube low-flow showcase uses a before/after pair. Two stills understate the story; an
animation lets a viewer watch the channel change in natural colour. Here we build a Sentinel-2
true-colour (B4,B3,B2) time-lapse of the Danube through Budapest across the 2026 drought — same data
as a before/after, presented as a movie.
Needs
GEE_SERVICE_ACCOUNT/GEE_SERVICE_KEYandpyramids-gis[viz].
Setup¶
pyramids reads and plots the GeoTIFFs (Dataset / DatasetCollection); earthlens provides the
EarthLens entry point. Earth Engine credentials come from a repo-root .env.
import os
import shutil
import tempfile
import warnings
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from IPython.display import Image, display
from loguru import logger
from pyramids.dataset import Dataset, DatasetCollection
from earthlens.core import EarthLens
warnings.filterwarnings("ignore")
logger.remove()
plt.rcParams["figure.dpi"] = 80
from dotenv import find_dotenv, load_dotenv
load_dotenv(find_dotenv(usecwd=True))
SERVICE_ACCOUNT = os.environ["GEE_SERVICE_ACCOUNT"]
SERVICE_KEY = os.environ["GEE_SERVICE_KEY"]
The reach, the windows, and the pixel size¶
A box over the Danube through Budapest, and near-monthly windows through the drought. Windows are ~3 weeks wide so the per-pixel median can reject cloud; the pixel size is 20 m to keep each composite under Earth Engine's 50 MB single-URL export cap.
AOI = [18.98, 47.40, 19.14, 47.58] # lon_min, lat_min, lon_max, lat_max
S2 = "COPERNICUS/S2_SR_HARMONIZED"
SCALE = 20
WINDOWS = [
("2026-04-01", "2026-04-21"),
("2026-04-22", "2026-05-12"),
("2026-05-13", "2026-06-02"),
("2026-06-03", "2026-06-23"),
("2026-06-24", "2026-07-14"),
("2026-07-15", "2026-07-24"),
]
OUT = Path("out") / "tc1_budapest_timelapse"
rgb_dir = OUT / "rgb"
rgb_dir.mkdir(parents=True, exist_ok=True)
Fetch a cloud-median true-colour composite per window¶
For each window we ask Earth Engine for a median composite of the visible bands over the reach, and
record its mean brightness (a cloud proxy). Everything is cached, so re-runs skip the GEE pulls.
labels, brightness = [], []
for start, end in WINDOWS:
tag = start.replace("-", "")
p = rgb_dir / f"budapest_{tag}.tif"
if not p.exists():
job = EarthLens(
data_source="gee",
cadence="raw",
path=tempfile.mkdtemp(),
dataset=S2,
variables=["B4", "B3", "B2"],
aoi=AOI,
start=start,
end=end,
scale=SCALE,
reducer="median",
)
job.authenticate(service_account=SERVICE_ACCOUNT, service_key=SERVICE_KEY)
out = job.download(progress_bar=False)
if not out:
continue
shutil.copy(str(out[0]), p)
labels.append(start)
brightness.append(float(np.asarray(Dataset.read_file(str(p)).read_array()).mean()))
labels
Drop cloud-contaminated frames¶
A cloudy median stays bright, so any window more than 1.5x brighter than the clearest one is dropped — its haze would spoil the animation.
import pandas as pd
brightness = np.array(brightness)
clear = brightness <= 1.5 * brightness.min()
clear_files = [
rgb_dir / f"budapest_{labels[i].replace('-', '')}.tif"
for i, ok in enumerate(clear)
if ok
]
pd.DataFrame({"window": labels, "brightness": brightness.round(0), "clear": clear})
Animate the true colour¶
Read the cloud-clear composites into one collection and animate the RGB as a GIF — the channel and its banks in natural colour, month by month.
tl_dir = OUT / "clear"
tl_dir.mkdir(parents=True, exist_ok=True)
for f in clear_files:
shutil.copy(str(f), tl_dir / f.name)
dc = DatasetCollection.read_multiple_files(str(tl_dir), date=False)
gif = OUT / "budapest_truecolour.gif"
dc.plot(
rgb_options={"rgb": [0, 1, 2], "percentile": 2},
figsize=(5, 5),
title="Danube at Budapest - Sentinel-2 true colour, Apr-Jul 2026",
).save_animation(str(gif), fps=1.2)
plt.close("all")
display(Image(filename=str(gif)))
Notes¶
- More, narrower windows give a smoother movie but risk more cloud; the median-per-window is what keeps each frame usable.
- The Danube through Budapest is a wide, embanked urban reach, so the visible change is modest — see approach 3 (a braided reach) for a stronger true-colour extent signal.