True-colour approach 3 — a braided reach where extent actually shows: the Danube at Novi Sad¶
An embanked urban reach (like the Danube through Budapest) barely changes width even as discharge collapses. The signal is far stronger on a braided, sandbar-rich reach. The Danube at Novi Sad (Serbia) is such a reach — its low water in 2026 famously re-exposed sandbars and long-sunk wrecks.
We deliberately use the Danube rather than a Rhine reach: the 2026 drought was a high-pressure event, so the south-eastern basin had clear skies while the north-west (the Rhine/Waal) stayed cloudy — too cloudy for a clean optical read. We show a Sentinel-2 true-colour before/after and a time-lapse here.
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 Novi Sad reach and the windows¶
A box over the Danube at Novi Sad, near-monthly windows through the drought, 15 m pixels (keeps the export under the 50 MB cap).
AOI = [19.78, 45.20, 19.95, 45.30] # Danube at Novi Sad
S2 = "COPERNICUS/S2_SR_HARMONIZED"
SCALE = 15
WINDOWS = [
("2026-04-01", "2026-04-28"),
("2026-05-01", "2026-05-28"),
("2026-06-01", "2026-06-28"),
("2026-07-01", "2026-07-24"),
]
OUT = Path("out") / "tc3_novisad_braided"
rgb_dir = OUT / "rgb"
rgb_dir.mkdir(parents=True, exist_ok=True)
Fetch a cloud-median composite per window¶
median true-colour composites over the Novi Sad reach, with a brightness cloud proxy, cached.
labels, brightness = [], []
for start, end in WINDOWS:
tag = start.replace("-", "")
p = rgb_dir / f"novisad_{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
Keep the cloud-clear windows¶
import pandas as pd
brightness = np.array(brightness)
clear = brightness <= 1.5 * brightness.min()
clear_idx = [i for i, ok in enumerate(clear) if ok]
dc = DatasetCollection.read_multiple_files(str(rgb_dir), date=False)
pd.DataFrame({"window": labels, "brightness": brightness.round(0), "clear": clear})
Before / after — the braided channel at low water¶
First vs last cloud-clear window: at low water the pale sandbars emerge in the channel.
if len(clear_idx) >= 2:
first_i, last_i = clear_idx[0], clear_idx[-1]
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
rgb_opts = {"rgb": [0, 1, 2], "percentile": 2}
for ax, i in zip(axes, (first_i, last_i)):
dc.iloc(i).plot(rgb_options=rgb_opts, ax=ax, fig=fig)
ax.set_title(labels[i])
ax.set_xticks([])
ax.set_yticks([])
fig.suptitle("Danube at Novi Sad - Sentinel-2 true colour, before vs after", y=1.02)
plt.tight_layout()
plt.show()
else:
print("not enough cloud-clear windows")
Animate the true colour¶
gif = OUT / "novisad_truecolour.gif"
dc.plot(
rgb_options={"rgb": [0, 1, 2], "percentile": 2},
figsize=(5, 5),
title="Danube at Novi Sad - Sentinel-2 true colour, Apr-Jul 2026",
).save_animation(str(gif), fps=1.2)
plt.close("all")
display(Image(filename=str(gif)))
Notes¶
- The Danube here is where the news photos of re-emerged sandbars and wrecks came from — a genuinely braided reach, unlike the embanked Budapest stretch (approach 1).
- Approach 4 turns this same reach into an exposed-riverbed map.