Commit 15b0ccdd authored by Bhoopalam's avatar Bhoopalam
Browse files

Version 2 with new endpoints

parent 3a64a9f3
from fastapi import APIRouter, HTTPException
from app.schemas import SearchBody, SearchResponse
from app.connectors.copernicus_stac import search_cdse, normalize_cdse_item
from app.connectors.usgs_m2m import search_usgs, normalize_usgs_item
from app.util import bbox_to_list
router = APIRouter(prefix="/search", tags=["search"])
@router.post("", response_model=SearchResponse)
async def search(body: SearchBody):
bbox_list = None
if body.bbox:
bbox_list = [body.bbox.minx, body.bbox.miny, body.bbox.maxx, body.bbox.maxy]
if body.provider == "cdse":
raw = await search_cdse(
bbox=bbox_list,
geometry=body.geometry,
datetime=body.datetime,
max_cloud=body.max_cloud,
collections=body.collections,
limit=body.limit,
sort=body.sort,
)
items = [normalize_cdse_item(f) for f in raw.get("features", [])]
return SearchResponse(items=items, next=raw.get("links", [{}])[-1].get("href"))
if body.provider == "usgs":
scenes = await search_usgs(
bbox=bbox_list,
geometry=body.geometry,
datetime=body.datetime,
datasets=body.collections,
max_results=body.limit,
)
items = [normalize_usgs_item(s) for s in scenes]
return SearchResponse(items=items)
# Optional AWS COG source kept for future expansion
raise HTTPException(400, detail="Unsupported provider for search")
\ No newline at end of file
from fastapi import APIRouter
from titiler.core.factory import TilerFactory
router = APIRouter()
# Mount TiTiler COG endpoints at /tiles/cog
cog = TilerFactory(router_prefix="/tiles/cog")
router.include_router(cog.router)
# Notes:
# - Use /tiles/cog/tilejson.json?url=<COG_URL>&assets=B04,B03,B02 for RGB
# - For S2 COGs on AWS, pass the HTTPS URL (RequesterPays OFF for Element84 COGs)
# - Frontend can consume the returned tilejson.tiles[0] template directly
\ No newline at end of file
from fastapi import APIRouter, HTTPException
from typing import List
from app.schemas import SearchBody, TimeSeriesResponse, TimeSlice
from app.routers.search import search as do_search
router = APIRouter(prefix="/timeseries", tags=["timeseries"])
@router.post("", response_model=TimeSeriesResponse)
async def timeseries(body: SearchBody):
# Reuse /search, then map to time slices; front-end can plug this into a slider
res = await do_search(body)
slices: List[TimeSlice] = []
for it in sorted(res.items, key=lambda x: x.datetime):
# If you mount /tiles (TiTiler), front-end can request dynamic RGB tiles per item/asset
slices.append(TimeSlice(
iso_date=it.datetime,
label=it.datetime.split("T")[0] if it.datetime else "",
item_id=it.id,
provider=it.provider,
# tile_template can be filled after you implement your own tiling logic
tile_template=None,
tilejson=None,
))
return TimeSeriesResponse(slices=slices)
\ No newline at end of file
from pydantic import BaseModel, Field
from typing import Literal, Optional, List, Dict, Any
Provider = Literal["cdse", "usgs", "aws_s2_cog"]
class BBox(BaseModel):
# WebMercator / WGS84 lon/lat
minx: float
miny: float
maxx: float
maxy: float
class SearchBody(BaseModel):
provider: Provider
bbox: Optional[BBox] = None
geometry: Optional[Dict[str, Any]] = None # GeoJSON
datetime: str = Field(..., description="STAC datetime interval e.g. 2020-01-01/2020-12-31")
max_cloud: Optional[float] = 30
collections: Optional[List[str]] = None # override defaults per provider
limit: int = 50
sort: Literal["asc", "desc"] = "desc"
class ItemAsset(BaseModel):
key: str
href: str
roles: Optional[List[str]] = None
type: Optional[str] = None
class ImageryItem(BaseModel):
id: str
provider: Provider
collection: str
datetime: str
geometry: Dict[str, Any]
bbox: List[float]
assets: Dict[str, ItemAsset]
preview: Optional[str] = None # thumbnail URL if available
linkouts: Optional[Dict[str, str]] = None # Copernicus Browser / EarthExplorer links
class SearchResponse(BaseModel):
items: List[ImageryItem]
next: Optional[str] = None
class TimeSlice(BaseModel):
iso_date: str
label: str
item_id: str
provider: Provider
tilejson: Optional[Dict[str, Any]] = None
tile_template: Optional[str] = None
class TimeSeriesResponse(BaseModel):
slices: List[TimeSlice]
\ No newline at end of file
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
cdse_stac_endpoint: str = "https://stac.dataspace.copernicus.eu/v1"
cdse_oauth_token_url: str | None = None
cdse_client_id: str | None = None
cdse_client_secret: str | None = None
usgs_m2m_base: str = "https://m2m.cr.usgs.gov"
usgs_m2m_username: str | None = None
usgs_m2m_app_token: str | None = None
sentinel_hub_instance_id: str | None = None
tile_cache_dir: str = "./cache"
allowed_origins: List[str] = ["*"]
class Config:
env_file = ".env"
settings = Settings()
\ No newline at end of file
from typing import Dict, Any, List, Optional
def bbox_to_list(bbox) -> List[float]:
return [bbox.minx, bbox.miny, bbox.maxx, bbox.maxy]
def stac_datetime(start: str, end: str) -> str:
return f"{start}/{end}"
def iso_to_label(iso: str) -> str:
# 2020-05-17T10:11:12Z -> 2020‑05‑17
return iso.split("T")[0]
def build_copernicus_browser_link(lat: float, lon: float, date_iso: str, cloud: int = 30) -> str:
base = "https://browser.dataspace.copernicus.eu/"
return (f"{base}?zoom=13&lat={lat:.6f}&lng={lon:.6f}"
f"&themeId=DEFAULT-THEME&datasetId=S2_L2A_CDAS&cloudCoverage={cloud}"
f"&dateMode=SINGLE&toTime={date_iso}")
def build_earth_explorer_link() -> str:
# EE does not accept bbox in URL; provide homepage link
return "https://earthexplorer.usgs.gov/"
\ No newline at end of file
# === Copernicus Data Space Ecosystem ===
CDSE_STAC_ENDPOINT=https://stac.dataspace.copernicus.eu/v1
# OAuth client (needed for S3/download/process later; STAC search can be public)
CDSE_OAUTH_TOKEN_URL=https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token
CDSE_CLIENT_ID=
CDSE_CLIENT_SECRET=
# === USGS M2M ===
# As of 2025, use Application Token with the login-token endpoint
USGS_M2M_USERNAME=
USGS_M2M_APP_TOKEN=
USGS_M2M_BASE=https://m2m.cr.usgs.gov
# === Optional: Sentinel Hub OGC (WMS/WMTS) passthrough ===
SENTINEL_HUB_INSTANCE_ID=
# === Tile cache & general ===
TILE_CACHE_DIR=./cache
ALLOWED_ORIGINS=*
LOG_LEVEL=info
\ No newline at end of file
fastapi==0.115.0
uvicorn[standard]==0.30.6
httpx==0.27.2
pydantic==2.9.2
pydantic-settings==2.5.2
python-dotenv==1.0.1
# Optional (for /tiles with COGs)
titiler.core==0.16.2
rio-tiler==6.7.0
\ No newline at end of file
# main.py
# Requirements:
# pip install fastapi uvicorn geopandas rasterio pystac-client planetary-computer requests pydantic shapely numpy
# (plus system deps needed by rasterio/GEOS/GDAL depending on your env)
import os
import json
import tempfile
from typing import Optional, Tuple
from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
import geopandas as gpd
import rasterio
from rasterio.mask import mask
from rasterio.enums import Resampling
from rasterio.warp import calculate_default_transform, reproject
from rasterio.io import MemoryFile
from pystac_client import Client
import planetary_computer as pc
import requests
import numpy as np
from shapely.geometry import mapping
# --- CRS / AOI helpers --------------------------------------------------------
def read_aoi_geom_wgs84(aoi_path: str) -> dict:
"""
Load the AOI file and return a single GeoJSON geometry in EPSG:4326.
If there are multiple features, union them.
"""
gdf = gpd.read_file(aoi_path)
if gdf.empty:
raise ValueError(f"AOI at {aoi_path} contains no features.")
gdf = gdf.to_crs(epsg=4326)
geom = gdf.unary_union
return mapping(geom) # GeoJSON geometry dict
def buffer_aoi(aoi_path: str, buffer_meters: float, output_path: str) -> str:
"""
Buffer the AOI by N meters (using EPSG:3857 for meter-based buffering),
and save the result as a GeoJSON in EPSG:4326 for STAC search.
"""
aoi = gpd.read_file(aoi_path)
if aoi.empty:
raise ValueError(f"AOI at {aoi_path} contains no features.")
aoi_merc = aoi.to_crs(epsg=3857) # meter units
aoi_buffered = aoi_merc.buffer(buffer_meters)
aoi_buffered_gdf = gpd.GeoDataFrame(geometry=aoi_buffered, crs="EPSG:3857")
aoi_buffered_gdf = aoi_buffered_gdf.to_crs(epsg=4326) # lon/lat for STAC
aoi_buffered_gdf.to_file(output_path, driver="GeoJSON")
return output_path
# --- High-quality asset selection / composition -------------------------------
def _pick_fullres_asset_or_none(item):
"""
Return a full-resolution 'visual' GeoTIFF/COG asset if present.
Avoid thumbnails/overviews (PNG/JPEG renders).
"""
a = item.assets.get("visual")
if not a:
return None
mt = (a.media_type or "").lower()
roles = [r.lower() for r in (a.roles or [])]
if any(r in roles for r in ["thumbnail", "overview", "rendered_preview"]):
return None
if ("tiff" in mt) or ("geotiff" in mt) or ("cog" in mt):
return a
return None
def _scale_to_uint8(arr: np.ndarray) -> np.ndarray:
"""
Simple 2–98 percentile contrast stretch to uint8.
Ignores zeros when computing percentiles (common for nodata).
"""
mask_pos = arr > 0
if not np.any(mask_pos):
return np.zeros_like(arr, dtype="uint8")
p2, p98 = np.percentile(arr[mask_pos], (2, 98))
if p98 <= p2:
return np.clip(arr, 0, 255).astype("uint8")
scaled = (arr.astype("float32") - p2) * (255.0 / (p98 - p2))
return np.clip(scaled, 0, 255).astype("uint8")
def _build_true_color_from_bands(item, out_path: str, rgb_uint8: bool) -> str:
"""
Build a 10 m true-color GeoTIFF from Sentinel-2 L2A bands (B04,B03,B02).
- Keeps native dtype (usually uint16) unless rgb_uint8=True (applies 2–98 stretch).
- Writes a tiled, compressed GeoTIFF.
"""
bands = {}
for bn in ["B04", "B03", "B02"]:
a = item.assets.get(bn)
if not a:
raise RuntimeError(f"Item lacks required band {bn} to build RGB.")
bands[bn] = pc.sign(a.href)
with rasterio.open(bands["B04"]) as rsrc, \
rasterio.open(bands["B03"]) as gsrc, \
rasterio.open(bands["B02"]) as bsrc:
red = rsrc.read(1)
grn = gsrc.read(1)
blu = bsrc.read(1)
profile = rsrc.profile.copy()
profile.update(
driver="GTiff",
count=3,
tiled=True,
compress="lzw",
predictor=2,
photometric="RGB",
)
if rgb_uint8:
red = _scale_to_uint8(red)
grn = _scale_to_uint8(grn)
blu = _scale_to_uint8(blu)
profile.update(dtype="uint8")
with rasterio.open(out_path, "w", **profile) as dst:
dst.write(red, 1)
dst.write(grn, 2)
dst.write(blu, 3)
return out_path
def download_best_image(aoi_geojson: str,
collection: str,
date_range: Tuple[str, str],
max_cloud: int,
out_path: str,
rgb_uint8: bool) -> Optional[str]:
"""
Search STAC with a WGS84 AOI, pick a full-res asset:
- Prefer the 'visual' GeoTIFF (10 m), avoiding thumbnails.
- If not available, compose RGB from B04/B03/B02 (10 m).
"""
geometry = read_aoi_geom_wgs84(aoi_geojson)
catalog = Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
search = catalog.search(
collections=[collection],
intersects=geometry,
datetime=f"{date_range[0]}/{date_range[1]}",
query={"eo:cloud_cover": {"lt": max_cloud}}
)
items = list(search.get_items())
if not items:
return None
# Least cloudy first
items.sort(key=lambda x: x.properties.get("eo:cloud_cover", 100))
item = items[0]
# 1) Try a proper full-res 'visual' GeoTIFF/COG
visual_asset = _pick_fullres_asset_or_none(item)
if visual_asset is not None:
signed_href = pc.sign(visual_asset.href)
r = requests.get(signed_href, stream=True, timeout=180)
r.raise_for_status()
with open(out_path, "wb") as f:
for chunk in r.iter_content(chunk_size=1 << 20):
f.write(chunk)
return out_path
# 2) Fall back to composing RGB from 10 m bands
try:
return _build_true_color_from_bands(item, out_path, rgb_uint8=rgb_uint8)
except Exception:
return None
# --- Post-processing: resampling & overviews ----------------------------------
def _pixel_size_from_transform(transform) -> Tuple[float, float]:
# (pixel width, pixel height) in CRS units (usually meters)
return abs(transform.a), abs(transform.e)
def reproject_to_resolution(src_path: str, dst_path: str, target_res_m: float, force_upsample: bool=False) -> str:
"""
Reproject/resample to a clean target pixel size (meters).
If target_res is finer than native and force_upsample=False, clamp to native.
"""
with rasterio.open(src_path) as src:
native_x = abs(src.transform.a)
native_y = abs(src.transform.e)
native = (native_x + native_y) / 2.0
# Prevent unintentional upsampling (blur)
out_res = target_res_m
if (target_res_m < native) and not force_upsample:
out_res = native
dst_transform, width, height = calculate_default_transform(
src.crs, src.crs, src.width, src.height, *src.bounds, resolution=out_res
)
profile = src.profile.copy()
profile.update(transform=dst_transform, width=width, height=height)
# Choose resampling: nearest for upsampling (keeps edges), cubic for downsampling
upsampling = out_res < native
resamp = Resampling.nearest if upsampling else Resampling.cubic
with rasterio.open(dst_path, "w", **profile) as dst:
for b in range(1, src.count + 1):
reproject(
source=rasterio.band(src, b),
destination=rasterio.band(dst, b),
src_transform=src.transform,
src_crs=src.crs,
dst_transform=dst_transform,
dst_crs=src.crs,
resampling=resamp
)
return dst_path
def add_overviews_inplace(tif_path: str, levels=(2, 4, 8, 16)):
"""
Build internal overviews to improve on-screen clarity at multiple zooms.
"""
with rasterio.open(tif_path, "r+") as ds:
ds.build_overviews(levels, Resampling.average)
ds.update_tags(ns="rio_overview", resampling="average")
# --- NDVI helpers -------------------------------------------------------------
def collection_for_year(year: int):
"""
Pick dataset + band names by year.
Sentinel-2 L2A for 2016+, Landsat 8/9 L2 for earlier.
"""
if year >= 2016:
# Sentinel-2 L2A on Planetary Computer
return {
"collection": "sentinel-2-l2a",
"red": "B04",
"nir": "B08",
"scale": ("sentinel", None), # 0..10000 -> reflectance 0..1
"native_res_m": 10.0,
}
else:
# Landsat 8 Collection 2 Level-2 Surface Reflectance (pre-2016)
return {
"collection": "landsat-8-c2-l2",
"red": "SR_B4",
"nir": "SR_B5",
"scale": ("landsat_sr", (0.0000275, -0.2)), # reflectance = DN*0.0000275 - 0.2
"native_res_m": 30.0,
}
def _fetch_least_cloudy_item(collection: str, geometry: dict, date_range: tuple, max_cloud: int):
cat = Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
search = cat.search(
collections=[collection],
intersects=geometry,
datetime=f"{date_range[0]}/{date_range[1]}",
query={"eo:cloud_cover": {"lt": max_cloud}}
)
items = list(search.get_items())
if not items:
return None
items.sort(key=lambda x: x.properties.get("eo:cloud_cover", 100))
return items[0]
def _read_band_to_array_signed(asset_href: str) -> tuple:
"""Open a single-band raster and return (array, profile)."""
with rasterio.open(pc.sign(asset_href)) as src:
arr = src.read(1)
profile = src.profile.copy()
return arr, profile
def _scale_reflectance(arr: np.ndarray, scale_info) -> np.ndarray:
"""Return reflectance in 0..1 range where possible."""
kind, params = scale_info
arr = arr.astype("float32")
if kind == "sentinel":
# Planetary Computer S2 L2A are typically scaled 0..10000
return np.clip(arr / 10000.0, 0.0, 1.0)
elif kind == "landsat_sr":
# Landsat C2 L2 SR scale/offset
mult, off = params
return np.clip(arr * mult + off, 0.0, 1.0)
else:
return arr
def _compute_ndvi(nir: np.ndarray, red: np.ndarray, nodata_mask: np.ndarray = None) -> np.ndarray:
denom = (nir + red)
ndvi = np.where(denom != 0, (nir - red) / denom, np.nan).astype("float32")
if nodata_mask is not None:
ndvi = np.where(nodata_mask, np.nan, ndvi)
return ndvi
def _write_float_geotiff(path: str, arr: np.ndarray, profile, nodata=-9999.0):
"""
Write a single-band float32 GeoTIFF.
- Use a finite nodata (e.g. -9999.0) for best GDAL compatibility on Windows.
- Sanitize profile to avoid conflicts from copied profiles.
"""
arr_out = arr.astype("float32", copy=False)
# Replace non-finite values with nodata
if not np.isfinite(arr_out).all():
arr_out = np.where(np.isfinite(arr_out), arr_out, nodata).astype("float32")
# Start from a minimal, clean profile
clean = {
"driver": "GTiff",
"height": profile["height"],
"width": profile["width"],
"count": 1,
"dtype": "float32",
"crs": profile.get("crs"),
"transform": profile.get("transform"),
"tiled": True,
"compress": "lzw",
"predictor": 2,
"nodata": float(nodata),
}
# Make sure directory exists
os.makedirs(os.path.dirname(path), exist_ok=True)
with rasterio.open(path, "w", **clean) as dst:
dst.write(arr_out, 1)
def _resample_match(src_arr, src_profile, ref_profile, resampling=Resampling.bilinear):
"""Resample a single-band array to the reference profile's grid."""
with MemoryFile() as mem_src:
with rasterio.open(
mem_src, "w", driver="GTiff",
height=src_profile["height"], width=src_profile["width"],
count=1, dtype="float32",
crs=src_profile["crs"], transform=src_profile["transform"]
) as src_ds:
src_ds.write(src_arr.astype("float32"), 1)
with MemoryFile() as mem_dst:
with rasterio.open(
mem_dst, "w", driver="GTiff",
height=ref_profile["height"], width=ref_profile["width"],
count=1, dtype="float32",
crs=ref_profile["crs"], transform=ref_profile["transform"]
) as dst_ds:
reproject(
source=rasterio.band(src_ds, 1),
destination=rasterio.band(dst_ds, 1),
src_transform=src_profile["transform"],
src_crs=src_profile["crs"],
dst_transform=ref_profile["transform"],
dst_crs=ref_profile["crs"],
resampling=resampling
)
return dst_ds.read(1)
def _summarize_ndvi(ndvi: np.ndarray, pixel_size_m: float) -> dict:
valid = np.isfinite(ndvi)
if not np.any(valid):
return {"count": 0}
v = ndvi[valid]
area_per_pixel_m2 = pixel_size_m * pixel_size_m
return {
"count": int(v.size),
"mean": float(np.nanmean(v)),
"median": float(np.nanmedian(v)),
"p05": float(np.nanpercentile(v, 5)),
"p95": float(np.nanpercentile(v, 95)),
"frac_gt_0_2": float(np.mean(v > 0.2)),
"frac_gt_0_4": float(np.mean(v > 0.4)),
"frac_gt_0_6": float(np.mean(v > 0.6)),
"area_gt_0_4_m2": float(np.sum(v > 0.4) * area_per_pixel_m2),
"area_gt_0_6_m2": float(np.sum(v > 0.6) * area_per_pixel_m2),
}
# --- API setup ----------------------------------------------------------------
app = FastAPI(
title="AOI Imagery API",
description="""
An API that returns a satellite image file for a given year and computes NDVI & change.
- Swagger UI: `/docs`
- ReDoc: `/redoc`
""",
version="1.3.0"
)
# Defaults; override via env or query params
AOI_PATH = os.environ.get("AOI_PATH", "map.geojson")
BUFFER_METERS_DEFAULT = float(os.environ.get("BUFFER_METERS", "200"))
COLLECTION_DEFAULT = os.environ.get("COLLECTION", "sentinel-2-l2a")
MAX_CLOUD_DEFAULT = int(os.environ.get("MAX_CLOUD", "10"))
# Map a year -> date window (customize as needed; here June–Aug)
def year_to_range(year: int) -> Tuple[str, str]:
return (f"{year}-06-01", f"{year}-08-31")
# --- Download Endpoint --------------------------------------------------------
class DownloadParams(BaseModel):
year: int = Field(..., ge=2015, le=2100, description="Year to fetch imagery for")
collection: Optional[str] = Field(default=COLLECTION_DEFAULT, description="STAC collection (e.g., sentinel-2-l2a)")
max_cloud: Optional[int] = Field(default=MAX_CLOUD_DEFAULT, ge=0, le=100, description="Max cloud cover percent")
buffer_meters: Optional[float] = Field(default=BUFFER_METERS_DEFAULT, ge=0, description="Buffer to apply to AOI (meters)")
clip_to_aoi: Optional[bool] = Field(default=False, description="If true, clip the image to buffered AOI")
rgb_uint8: Optional[bool] = Field(default=False, description="If true, write 8-bit RGB with a simple stretch (preview-friendly)")
target_res_m: Optional[float] = Field(default=10.0, ge=0, description="Reproject/resample clipped output to this pixel size in meters")
build_overviews: Optional[bool] = Field(default=True, description="If true, add internal overviews to the output GeoTIFF")
@app.get(
"/download",
response_class=FileResponse,
responses={
200: {"content": {"image/tiff": {}}, "description": "GeoTIFF imagery file"},
404: {"description": "No imagery found for given inputs"},
422: {"description": "Validation error"},
500: {"description": "Server error"}
},
summary="Download imagery for a given year (GeoTIFF)"
)
def download_imagery(
year: int = Query(..., ge=2015, le=2100, description="Year to fetch imagery for"),
collection: str = Query(COLLECTION_DEFAULT, description="STAC collection (e.g., sentinel-2-l2a)"),
max_cloud: int = Query(MAX_CLOUD_DEFAULT, ge=0, le=100, description="Max cloud cover percent"),
buffer_meters: float = Query(BUFFER_METERS_DEFAULT, ge=0, description="Buffer in meters to apply to AOI"),
clip_to_aoi: bool = Query(False, description="If true, clip the image to buffered AOI"),
rgb_uint8: bool = Query(False, description="If true, write 8-bit RGB with a simple stretch (preview-friendly)"),
target_res_m: float = Query(10.0, ge=0, description="Reproject/resample clipped output to this pixel size in meters"),
build_overviews: bool = Query(True, description="If true, add internal overviews to the output GeoTIFF")
):
"""
Returns a GeoTIFF image file for the requested `year`.
By default returns the best (least cloudy) June–August image intersecting your AOI.
"""
if not os.path.exists(AOI_PATH):
raise HTTPException(status_code=500, detail=f"AOI file not found at {AOI_PATH}")
# Prepare temp workspace
workdir = tempfile.mkdtemp(prefix=f"imagery_{year}_")
aoi_path = AOI_PATH
# Optional buffering (result saved as WGS84 GeoJSON for STAC search)
if buffer_meters and buffer_meters > 0:
aoi_path = os.path.join(workdir, "aoi_buffered.geojson")
buffer_aoi(AOI_PATH, buffer_meters, aoi_path)
date_range = year_to_range(year)
raw_tif = os.path.join(workdir, f"imagery_{year}.tif")
out_path = download_best_image(aoi_path, collection, date_range, max_cloud, raw_tif, rgb_uint8=rgb_uint8)
if not out_path:
raise HTTPException(status_code=404, detail=f"No imagery found for year={year}, range={date_range}, max_cloud={max_cloud}")
# Optional: clip to AOI (must reproject AOI to raster CRS before masking)
final_path = out_path
if clip_to_aoi:
with rasterio.open(out_path) as src:
geoms = gpd.read_file(aoi_path).to_crs(src.crs)
shapes = [mapping(geom) for geom in geoms.geometry]
out_image, out_transform = mask(src, shapes, crop=True)
out_meta = src.meta.copy()
out_meta.update({
"driver": "GTiff",
"height": out_image.shape[1],
"width": out_image.shape[2],
"transform": out_transform,
"photometric": "RGB",
"tiled": True,
"compress": "lzw",
"predictor": 2,
})
clipped_tif = os.path.join(workdir, f"imagery_{year}_clipped.tif")
with rasterio.open(clipped_tif, "w", **out_meta) as dest:
dest.write(out_image)
# Reproject to a clean target resolution (e.g., 10 m) for crisp pixels
reproj_tif = os.path.join(workdir, f"imagery_{year}_clipped_{int(target_res_m)}m.tif")
reproject_to_resolution(clipped_tif, reproj_tif, target_res_m=target_res_m)
# Add internal overviews for sharp display at various zoom levels
if build_overviews:
add_overviews_inplace(reproj_tif)
final_path = reproj_tif
# Serve the file
return FileResponse(
final_path,
media_type="image/tiff",
filename=os.path.basename(final_path)
)
@app.post(
"/download",
response_class=FileResponse,
summary="Download imagery (POST body)"
)
def download_imagery_post(params: DownloadParams):
return download_imagery(
year=params.year,
collection=params.collection,
max_cloud=params.max_cloud,
buffer_meters=params.buffer_meters,
clip_to_aoi=params.clip_to_aoi,
rgb_uint8=params.rgb_uint8,
target_res_m=params.target_res_m,
build_overviews=params.build_overviews,
)
# --- NDVI Endpoints -----------------------------------------------------------
@app.get("/ndvi", summary="Compute NDVI for a given year; returns stats and writes a GeoTIFF")
def ndvi_year(
year: int = Query(..., ge=2013, le=2100),
max_cloud: int = Query(MAX_CLOUD_DEFAULT, ge=0, le=100),
buffer_meters: float = Query(BUFFER_METERS_DEFAULT, ge=0),
clip_to_aoi: bool = Query(True, description="Clip NDVI to AOI"),
res_for_compare_m: float = Query(30.0, ge=0, description="Optional resampling resolution (m) for cross-year/sensor comparability"),
):
if not os.path.exists(AOI_PATH):
raise HTTPException(status_code=500, detail=f"AOI file not found at {AOI_PATH}")
workdir = tempfile.mkdtemp(prefix=f"ndvi_{year}_")
aoi_path = AOI_PATH
if buffer_meters and buffer_meters > 0:
aoi_path = os.path.join(workdir, "aoi_buffered.geojson")
buffer_aoi(AOI_PATH, buffer_meters, aoi_path)
geometry = read_aoi_geom_wgs84(aoi_path)
cfg = collection_for_year(year)
date_range = year_to_range(year)
item = _fetch_least_cloudy_item(cfg["collection"], geometry, date_range, max_cloud)
if item is None:
raise HTTPException(status_code=404, detail="No imagery found")
# Read bands
red_href = item.assets[cfg["red"]].href
nir_href = item.assets[cfg["nir"]].href
red, red_prof = _read_band_to_array_signed(red_href)
nir, nir_prof = _read_band_to_array_signed(nir_href)
# Scale reflectance to 0..1
red = _scale_reflectance(red, cfg["scale"])
nir = _scale_reflectance(nir, cfg["scale"])
# Ensure bands are on identical grid (usually true, but safeguard)
same_grid = (red_prof["transform"] == nir_prof["transform"]) and (red_prof["crs"] == nir_prof["crs"]) and (red.shape == nir.shape)
if not same_grid:
nir = _resample_match(nir, nir_prof, red_prof, resampling=Resampling.bilinear)
ndvi = _compute_ndvi(nir, red)
# Base profile from red band
with rasterio.open(pc.sign(red_href)) as ref:
base_profile = ref.profile.copy()
base_profile.update(count=1, dtype="float32")
# Write temp NDVI before clipping
tmp_ndvi = os.path.join(workdir, f"ndvi_{year}_raw.tif")
_write_float_geotiff(tmp_ndvi, ndvi, base_profile)
final_ndvi = tmp_ndvi
if clip_to_aoi:
with rasterio.open(tmp_ndvi) as src:
geoms = gpd.read_file(aoi_path).to_crs(src.crs)
shapes = [mapping(geom) for geom in geoms.geometry]
out_image, out_transform = mask(src, shapes, crop=True)
out_meta = src.meta.copy()
out_meta.update(transform=out_transform, height=out_image.shape[1], width=out_image.shape[2])
clipped = os.path.join(workdir, f"ndvi_{year}_clip.tif")
with rasterio.open(clipped, "w", **out_meta) as dst:
dst.write(out_image)
final_ndvi = clipped
# Optionally resample to a standard comparison grid (e.g., 30 m)
if res_for_compare_m and res_for_compare_m > 0:
with rasterio.open(final_ndvi) as src:
dst_transform, width, height = calculate_default_transform(
src.crs, src.crs, src.width, src.height, *src.bounds, resolution=res_for_compare_m
)
prof = src.profile.copy()
prof.update(transform=dst_transform, width=width, height=height)
repro = os.path.join(workdir, f"ndvi_{year}_{int(res_for_compare_m)}m.tif")
with rasterio.open(repro, "w", **prof) as dst:
reproject(
source=rasterio.band(src, 1),
destination=rasterio.band(dst, 1),
src_transform=src.transform, src_crs=src.crs,
dst_transform=dst_transform, dst_crs=src.crs,
resampling=Resampling.bilinear
)
final_ndvi = repro
# Compute stats
with rasterio.open(final_ndvi) as ds:
arr = ds.read(1)
resx = abs(ds.transform.a)
stats = _summarize_ndvi(arr, resx)
return {
"year": year,
"collection": cfg["collection"],
"native_res_m": cfg["native_res_m"],
"compare_res_m": res_for_compare_m,
"ndvi_tif": os.path.basename(final_ndvi),
"workdir": workdir,
"stats": stats
}
@app.get("/ndvi-change", summary="Compute NDVI change (end - start); returns stats and paths to GeoTIFFs")
def ndvi_change(
start_year: int = Query(..., ge=2013),
end_year: int = Query(..., ge=2013),
max_cloud: int = Query(MAX_CLOUD_DEFAULT, ge=0, le=100),
buffer_meters: float = Query(BUFFER_METERS_DEFAULT, ge=0),
clip_to_aoi: bool = Query(True),
compare_res_m: float = Query(30.0, ge=0, description="Common resolution (m) for both years"),
):
if end_year < start_year:
raise HTTPException(status_code=422, detail="end_year must be >= start_year")
# Reuse the /ndvi logic twice, then difference
start = ndvi_year(
year=start_year,
max_cloud=max_cloud,
buffer_meters=buffer_meters,
clip_to_aoi=clip_to_aoi,
res_for_compare_m=compare_res_m
)
end = ndvi_year(
year=end_year,
max_cloud=max_cloud,
buffer_meters=buffer_meters,
clip_to_aoi=clip_to_aoi,
res_for_compare_m=compare_res_m
)
ndvi_start_path = os.path.join(start["workdir"], start["ndvi_tif"])
ndvi_end_path = os.path.join(end["workdir"], end["ndvi_tif"])
# Align and difference
with rasterio.open(ndvi_end_path) as end_ds, rasterio.open(ndvi_start_path) as start_ds:
# If shapes differ (shouldn't if same res), resample start to end grid
if (start_ds.transform != end_ds.transform) or (start_ds.width != end_ds.width) or (start_ds.height != end_ds.height):
arr_s = _resample_match(start_ds.read(1), start_ds.profile, end_ds.profile, resampling=Resampling.bilinear)
arr_e = end_ds.read(1)
profile = end_ds.profile.copy()
else:
arr_s = start_ds.read(1)
arr_e = end_ds.read(1)
profile = end_ds.profile.copy()
change = (arr_e - arr_s).astype("float32")
# Save change raster
workdir = tempfile.mkdtemp(prefix=f"ndvi_change_{start_year}_{end_year}_")
change_tif = os.path.join(workdir, f"ndvi_change_{start_year}_{end_year}.tif")
_write_float_geotiff(change_tif, change, profile)
resx = abs(profile["transform"].a)
stats_start = _summarize_ndvi(arr_s, resx)
stats_end = _summarize_ndvi(arr_e, resx)
# Change stats: summarize positive/negative change
valid = np.isfinite(change)
ch = change[valid]
change_stats = {
"mean_change": float(np.nanmean(ch)) if ch.size else None,
"median_change": float(np.nanmedian(ch)) if ch.size else None,
"p05_change": float(np.nanpercentile(ch, 5)) if ch.size else None,
"p95_change": float(np.nanpercentile(ch, 95)) if ch.size else None,
"frac_change_gt_0_1": float(np.mean(ch > 0.1)) if ch.size else None,
"frac_change_lt_-0_1": float(np.mean(ch < -0.1)) if ch.size else None,
}
return {
"start_year": start_year,
"end_year": end_year,
"compare_res_m": compare_res_m,
"ndvi_start_tif": os.path.basename(ndvi_start_path),
"ndvi_end_tif": os.path.basename(ndvi_end_path),
"ndvi_change_tif": os.path.basename(change_tif),
"outputs_dir": workdir,
"start_stats": stats_start,
"end_stats": stats_end,
"change_stats": change_stats
}
# Run:
# uvicorn main:app --host 0.0.0.0 --port 8000
{"type":"FeatureCollection","features":[{"type":"Feature","properties":{},"geometry":{"coordinates":[[[9.16145374315991,48.78558930650067],[9.16145374315991,48.77109004651268],[9.18585591426276,48.77109004651268],[9.18585591426276,48.78558930650067],[9.16145374315991,48.78558930650067]]],"type":"Polygon"}}]}
\ No newline at end of file
# Preprocess AOI and Download Satellite Imagery from Microsoft Planetary Computer
# Requirements: pip install geopandas rasterio pystac-client planetary-computer requests
import geopandas as gpd
import rasterio
from rasterio.mask import mask
import json
import os
from pystac_client import Client
import planetary_computer as pc
import requests
import matplotlib.pyplot as plt
# --- CONFIG ---
AOI_PATH = "map.geojson" # Your AOI file (exported from geojson.io or Google Earth)
BUFFER_METERS = 200 # Buffer distance in meters
OUTPUT_DIR = "processed" # Output directory for processed files
COLLECTION = "sentinel-2-l2a" # or "landsat-8-c2-l2"
DATE_RANGE = [
("2016-06-01", "2016-08-31"), # Past
("2025-06-01", "2025-08-31") # Present
]
MAX_CLOUD = 10 # %
# --- STEP 1: Buffer AOI ---
def buffer_aoi(aoi_path, buffer_meters, output_path):
aoi = gpd.read_file(aoi_path)
aoi_buffered = aoi.to_crs(epsg=3857).buffer(buffer_meters).to_crs(aoi.crs)
aoi_buffered_gdf = gpd.GeoDataFrame(geometry=aoi_buffered)
aoi_buffered_gdf.to_file(output_path, driver="GeoJSON")
print(f"Buffered AOI saved to {output_path}")
return output_path
# --- STEP 2: Download Image from MPC ---
def download_best_image(aoi_geojson, collection, date_range, max_cloud, out_path):
# Load AOI geometry
with open(aoi_geojson) as f:
geojson = json.load(f)
geometry = geojson['features'][0]['geometry']
# Search MPC STAC
catalog = Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
search = catalog.search(
collections=[collection],
intersects=geometry,
datetime=f"{date_range[0]}/{date_range[1]}",
query={"eo:cloud_cover": {"lt": max_cloud}}
)
items = list(search.get_items())
if not items:
print(f"No images found for {date_range}")
return None
# Pick the least cloudy image
items.sort(key=lambda x: x.properties.get("eo:cloud_cover", 100))
item = items[0]
asset = item.assets["visual"] if "visual" in item.assets else list(item.assets.values())[0]
signed_href = pc.sign(asset.href)
# Download the image
print(f"Downloading {signed_href} ...")
r = requests.get(signed_href, stream=True)
with open(out_path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
print(f"Downloaded image to {out_path}")
return out_path
# --- STEP 3: Display Images Side by Side ---
def show_images_side_by_side(image_paths):
fig, axes = plt.subplots(1, 2, figsize=(16, 8))
for i, img_path in enumerate(image_paths):
with rasterio.open(img_path) as src:
img = src.read([1, 2, 3]) # RGB bands
img = img.transpose(1, 2, 0)
# Normalize for display
img = (img - img.min()) / (img.max() - img.min())
axes[i].imshow(img)
axes[i].set_title(f"Image {i+1}")
axes[i].axis('off')
plt.tight_layout()
plt.show()
if __name__ == "__main__":
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Download images only, skip buffer and clipping
image_paths = []
for i, drange in enumerate(DATE_RANGE):
out_img = os.path.join(OUTPUT_DIR, f"raw_{i+1}.tif")
img_path = download_best_image(AOI_PATH, COLLECTION, drange, MAX_CLOUD, out_img)
if img_path:
image_paths.append(img_path)
if len(image_paths) == 2:
show_images_side_by_side(image_paths)
print("\nAll done! You now have raw rasters in the 'processed' folder and can view them side by side.")
{
"type": "FeatureCollection",
"name": "map_buffered",
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
"features": [
{ "type": "Feature", "properties": { }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 9.166994209422283, 48.784091968852827 ], [ 9.178154201230427, 48.784091968852827 ], [ 9.178330301820953, 48.784086268546858 ], [ 9.178504706466526, 48.78406922252217 ], [ 9.178675735555073, 48.784040994929839 ], [ 9.178841741982971, 48.784001857598447 ], [ 9.179001127017576, 48.783952187416872 ], [ 9.179152355693873, 48.783892462705673 ], [ 9.179293971597041, 48.783823258611697 ], [ 9.179424610888514, 48.783745241570486 ], [ 9.179543015440506, 48.783659162889563 ], [ 9.179648044952451, 48.783565851514489 ], [ 9.179738687932733, 48.783466206047081 ], [ 9.179814071439905, 48.783361186092904 ], [ 9.179873469489584, 48.783251803021074 ], [ 9.179916310046078, 48.783139110225356 ], [ 9.179942180531409, 48.783024192980392 ], [ 9.179950831798665, 48.782908157990647 ], [ 9.179950831798665, 48.778299983780748 ], [ 9.179942180531409, 48.778183937869152 ], [ 9.179916310046078, 48.77806900927871 ], [ 9.179873469489584, 48.777956304846001 ], [ 9.179814071439905, 48.777846909994913 ], [ 9.179738687932733, 48.777741878282676 ], [ 9.179648044952451, 48.777642221252542 ], [ 9.179543015440506, 48.777548898690974 ], [ 9.179424610888514, 48.777462809382932 ], [ 9.179293971597041, 48.777384782454767 ], [ 9.179152355693873, 48.77731556938766 ], [ 9.179001127017576, 48.777255836778998 ], [ 9.178841741982971, 48.777206159921221 ], [ 9.178675735555073, 48.777167017260076 ], [ 9.178504706466526, 48.777138785785802 ], [ 9.178330301820953, 48.777121737401508 ], [ 9.178154201230427, 48.777116036303902 ], [ 9.166994209422283, 48.777116036303902 ], [ 9.166818108831755, 48.777121737401508 ], [ 9.166643704186182, 48.777138785785802 ], [ 9.166472675097637, 48.777167017260076 ], [ 9.166306668669737, 48.777206159921221 ], [ 9.166147283635134, 48.777255836778998 ], [ 9.165996054958837, 48.77731556938766 ], [ 9.16585443905567, 48.777384782454767 ], [ 9.165723799764194, 48.777462809382932 ], [ 9.165605395212204, 48.777548898690974 ], [ 9.165500365700257, 48.777642221252542 ], [ 9.165409722719977, 48.777741878282676 ], [ 9.165334339212803, 48.777846909994913 ], [ 9.165274941163126, 48.777956304846001 ], [ 9.165232100606632, 48.77806900927871 ], [ 9.165206230121299, 48.778183937869152 ], [ 9.165197578854045, 48.778299983780748 ], [ 9.165197578854045, 48.782908157990647 ], [ 9.165206230121299, 48.783024192980392 ], [ 9.165232100606632, 48.783139110225356 ], [ 9.165274941163126, 48.783251803021074 ], [ 9.165334339212803, 48.783361186092904 ], [ 9.165409722719977, 48.783466206047081 ], [ 9.165500365700257, 48.783565851514489 ], [ 9.165605395212204, 48.783659162889563 ], [ 9.165723799764194, 48.783745241570486 ], [ 9.16585443905567, 48.783823258611697 ], [ 9.165996054958837, 48.783892462705673 ], [ 9.166147283635134, 48.783952187416872 ], [ 9.166306668669737, 48.784001857598447 ], [ 9.166472675097637, 48.784040994929839 ], [ 9.166643704186182, 48.78406922252217 ], [ 9.166818108831755, 48.784086268546858 ], [ 9.166994209422283, 48.784091968852827 ] ] ] } }
]
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment