Inspecting Time Series at a Clicked Pixel
On click, find the scenes covering the point, read a single pixel from each, and plot the valid values against date:
def on_click(**kw):
if kw.get("type") == "click":
lat, lon = kw["coordinates"]
series = read_point_series(lon, lat) # one tiny read per scene
plot_series(series)
m.on_interaction(on_click)
Clicking through a handful of locations finds seasonal oddities, bad dates and mis-scaled scenes faster than any aggregate. This page belongs to interactive raster exploration in Jupyter in Visualization, Tiling & Web Delivery.
Why a Point Read Is Cheap
A single-pixel read from a Cloud-Optimized GeoTIFF costs one header fetch and one internal tile — tens of kilobytes regardless of the scene’s size. That is what makes a whole time series interactive.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
pystac-client |
>=0.7 |
Finding every scene that covers the point |
rasterio |
>=1.3.0 |
Single-window remote reads |
matplotlib |
>=3.8 |
Plotting the series |
leafmap |
>=0.32 |
The map and its click events |
pip install "pystac-client>=0.7" "rasterio>=1.3.0" "matplotlib>=3.8" "leafmap>=0.32"
Complete Working Example
from concurrent.futures import ThreadPoolExecutor
import matplotlib.pyplot as plt
import numpy as np
import pystac_client
import rasterio
import rasterio.warp
from rasterio.windows import Window
API = "https://earth-search.aws.element84.com/v1"
SCL_CLEAR = {4, 5, 6, 11} # vegetation, bare, water, snow
def _read(href: str, lon: float, lat: float, size: int = 3) -> float:
with rasterio.open(href) as src:
xs, ys = rasterio.warp.transform("EPSG:4326", src.crs, [lon], [lat])
row, col = src.index(xs[0], ys[0])
win = Window(col - size // 2, row - size // 2, size, size)
arr = src.read(1, window=win, boundless=True, masked=True).astype("float32")
return float(arr.mean()) if arr.count() else np.nan
def point_series(lon: float, lat: float, start: str, end: str) -> dict[str, tuple]:
items = list(pystac_client.Client.open(API).search(
collections=["sentinel-2-l2a"],
intersects={"type": "Point", "coordinates": [lon, lat]},
datetime=f"{start}/{end}",
).items())
def one(item):
red = _read(item.assets["red"].href, lon, lat)
nir = _read(item.assets["nir"].href, lon, lat)
scl = _read(item.assets["scl"].href, lon, lat, size=1)
ndvi = (nir - red) / (nir + red) if (nir + red) else np.nan
return item.datetime.date().isoformat(), (ndvi, int(scl) in SCL_CLEAR)
with ThreadPoolExecutor(max_workers=8) as pool:
return dict(sorted(pool.map(one, items)))
def plot_series(series: dict[str, tuple], title: str = "") -> None:
dates = np.array(list(series), dtype="datetime64[D]")
vals = np.array([v for v, _ in series.values()])
clear = np.array([c for _, c in series.values()])
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(dates[clear], vals[clear], "-o", color="#15803d", label="clear")
ax.plot(dates[~clear], vals[~clear], "o", mfc="none", color="#64748b",
label="masked (cloud, shadow)")
ax.set_ylabel("NDVI")
ax.set_ylim(-0.2, 1.0)
ax.legend(loc="lower right", fontsize=8)
ax.set_title(title, fontsize=9, loc="left")
fig.autofmt_xdate()
Two design decisions carry the method. Reading a 3×3 neighbourhood and averaging it smooths the pixel-level registration jitter between acquisitions, which otherwise makes a stable field look noisy. And masked observations are plotted rather than dropped — hollow markers show where the gaps are, which is diagnostic information in its own right. The masking itself follows masking clouds with the Sentinel-2 SCL band.
Reading What the Series Shows
The isolated dip is the commonest finding and the most useful: a single clear-flagged observation far below its neighbours is almost always residual cloud or shadow that the mask missed, and seeing it tells you the mask needs dilation, as discussed in dilating cloud masks to catch thin cirrus.
A cluster of hollow markers around the peak matters for anything downstream that summarises the season. Percentile features computed from that pixel will describe the shoulders of the curve, not its top, which is exactly the issue that motivates carrying an observation count as a feature in building temporal feature stacks from image time series.
The abrupt, sustained drop is the signal change detection looks for. Seeing it at one pixel is anecdote; seeing it at a dozen clicked pixels across a field is a reason to trust — or question — whatever the change map says about that field.
Verification
import rasterio
import rasterio.warp
lon, lat = 34.7512, 0.3504
series = point_series(lon, lat, "2026-06-01", "2026-06-30")
date, (ndvi, _) = next(iter(series.items()))
# Independent path: open one scene directly and compute at the same place
with rasterio.open(red_href) as r, rasterio.open(nir_href) as n:
xs, ys = rasterio.warp.transform("EPSG:4326", r.crs, [lon], [lat])
row, col = r.index(xs[0], ys[0])
rv = r.read(1, window=((row - 1, row + 2), (col - 1, col + 2))).mean()
nv = n.read(1, window=((row - 1, row + 2), (col - 1, col + 2))).mean()
assert abs((nv - rv) / (nv + rv) - ndvi) < 1e-4
The comparison is cheap and it validates the whole chain from click to value. A mismatch almost always traces to a skipped coordinate transform or to reading the red asset from one item and the near-infrared from another.
Common Errors
Every value is NaN
The point falls outside the scenes returned, usually because the search used a bbox around the wrong coordinate order. Use intersects with a GeoJSON point in longitude, latitude order.
The series is extremely noisy
Single-pixel reads are picking up registration jitter. Read and average a 3×3 neighbourhood.
Values are in the thousands
The assets are scaled integers. Divide by the scale factor before computing the index, as described in handling nodata and scale factors in band math.
The plot takes a long time to appear
Reads are serial. Use a small thread pool; latency, not bandwidth, is the limit. Eight workers is plenty — beyond that the catalog search, not the reads, dominates.
Frequently Asked Questions
Q: Is reading one pixel from many remote files slow? Each read fetches the file header and one internal tile — a few tens of kilobytes. Fifty scenes take a few seconds serially and well under a second with a small thread pool, which is fast enough for interactive use.
Q: Should I plot masked observations? Plot them differently rather than hiding them. Showing cloud-masked dates as hollow markers makes the gaps visible, and a series with no observations near its peak is a series whose seasonal statistics cannot be trusted.
Q: Why read a small neighbourhood rather than one pixel? Because a single pixel carries geolocation jitter between acquisitions of up to a pixel or so. Averaging a three by three neighbourhood smooths that jitter and gives a series that reflects the surface rather than the registration error.
Q: Can this run against a local stack instead of a catalog? Yes, and it is simpler: open the stacked file once and read the same window across the time dimension. The catalog version exists because most archives are not stacked, and a point read against remote scenes avoids ever building the stack.
Related
- Interactive Raster Exploration in Jupyter — the parent topic.
- Temporal Aggregation and Time Series Analysis — turning series like these into products.
- Filling Gaps in NDVI Time Series with Interpolation — what to do about the hollow markers.
- Comparing Two Dates with a Split Map — the spatial counterpart to this temporal view.