Building a FastAPI Backend That Serves Live Satellite Imagery and Orbital Position
The idea: one place, two scales
The project's thesis is simple: observation scale changes what you can understand about a place. From orbit, Sentinel-2 sees vegetation health across a whole neighborhood at ~10 m per pixel but knows nothing of texture. From the ground, a photogrammetric mesh captures sub-centimeter detail of a single monument but sees nothing of its surroundings. Put both on one interactive globe and the contrast becomes the point.
The frontend is a CesiumJS 3D globe; the ground model is a RealityScan .glb. Neither is what makes this a backend story. What makes it a backend story is everything that has to happen server-side to get live, honest, self-consistent satellite data onto that globe - where "self-consistent" means the imagery, the reported date, and the satellite's position all describe the same acquisition.
The stack for that:
| Concern | Tool |
|---|---|
| API server | FastAPI + Uvicorn |
| Imagery & metadata | Copernicus Sentinel Hub (Catalog / Process / Statistics APIs) |
| Orbital mechanics | Skyfield (SGP4 propagation) |
| Live orbital elements | Celestrak TLE feed |
| Math | NumPy (vectorized) |
Four endpoints: latest-scene, ndvi, ndvi-stats, and orbit-at-scene-time. Let's build the data pipeline in the order the data actually flows.
Getting satellite data, step one: authentication
Copernicus Sentinel Hub uses OAuth2 client-credentials. You register an app in the Copernicus Data Space, get a client ID and secret, and exchange them for a short-lived bearer token that every subsequent request carries. The secret never touches the frontend - it lives in backend environment variables (set in the Render dashboard in production), which is the entire reason a backend exists here rather than calling the API from the browser.
TOKEN_URL = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token"
def get_token():
res = requests.post(
TOKEN_URL,
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
timeout=30,
)
res.raise_for_status()
return res.json()["access_token"]
That's the whole handshake. Every data call below starts by getting a token and putting it in an Authorization: Bearer β¦ header. The backend is, among other things, an auth proxy: it holds the credentials, and the browser only ever talks to the backend.
Step two: finding the newest real scene
Here's the design decision that shapes everything: the dashboard shows the newest available scene, and reports that scene's real acquisition date - not the time you happened to load the page. Satellites don't image every spot continuously; Sentinel-2 revisits a given area every few days, and cloud cover knocks out many passes. So "what's the latest usable image of this exact place?" is a genuine query.
That query goes to the Sentinel Hub Catalog API. I ask for every Sentinel-2 L2A scene intersecting my bounding box in the last 30 days, then sort client-side and pick the newest one under a cloud-cover threshold:
BBOX = [-1.4635, 53.3745, -1.4550, 53.3798] # around the monument
def get_latest_scene_meta(days=30, max_cloud=30):
now = datetime.now(timezone.utc)
start = now - timedelta(days=days)
body = {
"collections": ["sentinel-2-l2a"],
"bbox": BBOX,
"datetime": f"{start:%Y-%m-%dT%H:%M:%SZ}/{now:%Y-%m-%dT%H:%M:%SZ}",
"limit": 50,
}
token = get_token()
res = requests.post(
CATALOG_URL,
headers={"Authorization": f"Bearer {token}"},
json=body,
timeout=30,
)
res.raise_for_status()
feats = res.json().get("features", [])
# newest first
feats.sort(key=lambda f: f["properties"].get("datetime", ""), reverse=True)
# newest scene under the cloud threshold, else newest of all
chosen = next(
(f for f in feats if f["properties"].get("eo:cloud_cover", 100) <= max_cloud),
None,
) or (feats[0] if feats else None)
if chosen:
return {
"datetime": chosen["properties"]["datetime"],
"cloud_cover": round(float(chosen["properties"].get("eo:cloud_cover", 0)), 1),
}
return None
Two small robustness choices worth calling out. Sorting happens client-side rather than via a sortby in the request, because not every catalog deployment supports that parameter reliably - sorting 50 features locally is free and removes a compatibility assumption. And the or feats[0] fallback means that if every recent scene is cloudy, the dashboard still shows the newest one rather than nothing; a cloudy real scene beats an empty panel. The whole function returns None on any failure so callers can degrade gracefully - a pattern that becomes a theme.
The latest-scene endpoint wraps this and returns a clean metadata object. Crucially, that scene's datetime becomes the anchor every other view is tied to.
Step three: NDVI imagery and statistics
NDVI - the Normalized Difference Vegetation Index - is the classic "how green is it" measure: (NIR β Red) / (NIR + Red), using Sentinel-2's B08 (near-infrared) and B04 (red) bands. Healthy vegetation reflects strongly in NIR and absorbs red, so it scores high.
Sentinel Hub's Process API lets you compute this server-side at the source with an evalscript - a small JS function that runs per pixel on the imagery - and hand back a finished PNG. No raw bands cross the network; you receive exactly the visualization you asked for.
I bucket the NDVI values into a simple color ramp from bare-earth brown to dense-vegetation green:
//VERSION=3
function setup() {
return {
input: ["B04", "B08"],
output: { bands: 3 }
};
}
function evaluatePixel(sample) {
let ndvi = (sample.B08 - sample.B04) / (sample.B08 + sample.B04);
if (ndvi < 0.0) return [0.3, 0.2, 0.1];
if (ndvi < 0.2) return [0.8, 0.7, 0.3];
if (ndvi < 0.4) return [0.4, 0.8, 0.3];
if (ndvi < 0.6) return [0.2, 0.6, 0.2];
return [0.0, 0.4, 0.0];
}
The request sends this evalscript alongside the bounding box, a 30-day time range, a 30% cloud filter, and a 512Γ512 output size, then streams the PNG straight back to the browser:
res = requests.post(
PROCESS_URL,
headers={"Authorization": f"Bearer {token}"},
json=payload,
)
if res.status_code != 200:
return JSONResponse(content=res.json(), status_code=res.status_code)
return Response(content=res.content, media_type="image/png")
512Γ512 is a deliberate balance: fine enough to show real spatial structure at Sentinel-2's resolution, small enough to stay snappy for real-time draping over the globe.
The ndvi-stats endpoint does the numerical companion via the Statistics API, aggregating NDVI daily over a 60-day window and pulling out the mean - this time with a dataMask band so cloud/no-data pixels are excluded from the average rather than dragging it down. It returns a single {"mean": 0.342} for the telemetry readout.
Step four: from a TLE to a position in orbit
This is the part I find most fun, and the part most people haven't built. I want a marker on the globe showing where Sentinel-2A actually was when it captured the scene - sitting on its real ground track, not a decorative dot.
The raw material is a TLE (two-line element set): a compact, standardized encoding of a satellite's orbit at a moment in time (its "epoch"). Celestrak publishes fresh TLEs for active satellites. I fetch the active catalog and pull out Sentinel-2A specifically:
def fetch_sentinel2a_tle():
req = urllib.request.Request(TLE_URL, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=20) as response:
lines = [l.strip() for l in response.read().decode().splitlines() if l.strip()]
for i in range(len(lines) - 2):
if "SENTINEL-2A" in lines[i].upper() and lines[i+1].startswith("1"):
return lines[i], lines[i+1], lines[i+2]
# β¦hardcoded fallback TLE if the fetch failsβ¦
A TLE isn't a position - it's the input to a propagator. SGP4 is the standard model that turns "here's the orbit" plus "here's a time" into an actual Earth-centered coordinate. Skyfield wraps SGP4 in a friendly API and does the coordinate conversions. Feeding it the TLE and an array of times gives sub-satellite points (the latitude/longitude directly beneath the satellite):
from skyfield.api import EarthSatellite, load, wgs84
ts = load.timescale()
name, l1, l2 = fetch_sentinel2a_tle()
sat = EarthSatellite(l1, l2, name, ts)
# a whole array of times at once
sp = wgs84.subpoint(sat.at(ts.utc(y, mo, d, h, mi, s + seconds_array)))
lats, lons = sp.latitude.degrees, sp.longitude.degrees
The thing to notice: Skyfield is vectorized. You pass a NumPy array of time offsets and get back arrays of positions in one call, rather than looping second-by-second. That matters a lot for the next step, where I evaluate the orbit at thousands of instants.
Pinning the satellite to the scene
Reporting Sentinel-2A's position at some arbitrary instant would put the marker wherever the satellite happens to be - usually nowhere near Sheffield. What I actually want is its closest approach to the monument around the scene's acquisition time. I find it with a coarse-then-fine search, both fully vectorized:
def _haversine_km(lats, lons):
dlat = np.radians(lats - MONUMENT_LAT)
dlon = np.radians(lons - MONUMENT_LON)
a = (
np.sin(dlat / 2) ** 2
+ np.cos(np.radians(MONUMENT_LAT))
* np.cos(np.radians(lats))
* np.sin(dlon / 2) ** 2
)
return 6371 * 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
# Coarse scan: Β±1 day around the scene, one sample per 60 s
sec = np.arange(0, 2 * 86400, 60)
sp = wgs84.subpoint(sat.at(ts.utc(y, mo, d, h, mi, s + sec)))
dist = _haversine_km(sp.latitude.degrees, sp.longitude.degrees)
coarse_s = int(sec[int(np.argmin(dist))])
# Refine: Β±60 s around that minimum, one sample per second
rsec = np.arange(coarse_s - 60, coarse_s + 61, 1)
rsp = wgs84.subpoint(sat.at(ts.utc(y, mo, d, h, mi, s + rsec)))
rdist = _haversine_km(rsp.latitude.degrees, rsp.longitude.degrees)
closest_s = int(rsec[int(np.argmin(rdist))])
The haversine gives great-circle distance from each sub-satellite point to the monument. The coarse pass (2,880 samples across two days) narrows to the right minute; the refinement (121 one-second samples) pinpoints the exact second of closest approach. Because the newest scene was itself captured as Sentinel-2A flew over Sheffield, that closest approach correctly lands right over the monument - the imagery and the orbit agree because they describe the same overpass.
Finally I build a Β±45-minute ground track (181 points at 30-second spacing) centered on that closest second, so the frontend can draw the arc of the orbit with the marker glued to the middle of it:
tsec = closest_s + np.arange(-2700, 2701, 30) # Β±45 min, 30 s steps
tsp = wgs84.subpoint(sat.at(ts.utc(y, mo, d, h, mi, s + tsec)))
track_points = [
{"lon": float(lo), "lat": float(la), "altitude_m": SAT_ALTITUDE_M}
for la, lo in zip(tsp.latitude.degrees, tsp.longitude.degrees)
]
One honesty note I put in the README too: this design deliberately only tracks the newest scene, whose date is always recent. That keeps the current Celestrak TLE's epoch close to the scene time, so SGP4 stays accurate without hunting down epoch-matched historical TLEs. Historical-scene accuracy is intentionally out of scope - a scoped limitation is better than a silent inaccuracy.
Designing endpoints that can't break the frontend
The globe refreshes every five minutes and calls all four endpoints. Any of them depends on a third party - Copernicus, Celestrak - that can rate-limit, time out, or briefly vanish. A 500 error here isn't just an empty panel; because of how CORS works, a raised exception can arrive at the browser without the CORS headers, turning a transient upstream hiccup into a confusing cross-origin error in the console.
So the orbit endpoint is built to always return valid JSON, falling back to a safe position over the monument rather than raising:
@app.get("/api/orbit-at-scene-time")
def orbit():
try:
meta = get_latest_scene_meta()
ref_dt = parse_iso(meta["datetime"]) if meta else datetime.now(timezone.utc)
key = ref_dt.isoformat()
if _orbit_cache["key"] != key:
_orbit_cache["result"] = compute_orbit(ref_dt)
_orbit_cache["key"] = key
return _orbit_cache["result"]
except Exception:
return {
"closest_point": {"lon": -1.459, "lat": 53.377, "altitude_m": 786_000},
"ground_track": [],
"scene_datetime": None,
}
Keeping it light: caching
Every endpoint caches aggressively. The latest-scene result is cached for 5 minutes (matching the frontend refresh interval). The ndvi PNG is cached for 30 minutes - the imagery doesn't change until a new scene appears. The orbit computation is cached by scene datetime, so repeated requests for the same scene reuse the same ground track.
The cache is a simple in-memory dict with a timestamp key. No Redis, no external dependency - the app is a single process, and the data volumes are tiny.
Takeaways
- The Copernicus Sentinel Hub APIs are well-designed and fast, but the OAuth dance means you need a backend proxy anyway. That's fine - it's a good separation of concerns.
- Vectorized orbital mechanics with Skyfield + NumPy makes what sounds like a complex computation (closest approach over two days) trivial: two calls to the propagator, each handling thousands of time steps.
- Designing for graceful degradation - every endpoint returns something valid even when upstreams fail - turns transient outages into invisible blips rather than broken dashboards.
- The self-consistency constraint (imagery, date, and orbit all describe the same acquisition) is the detail that makes the visualization feel honest rather than decorative.
Comments
No comments yet. Start the discussion.