Commit fabaa42f authored by Heisenberg5124's avatar Heisenberg5124
Browse files

Initial commit

parent 028b7032
...@@ -23,6 +23,8 @@ import planetary_computer as pc ...@@ -23,6 +23,8 @@ import planetary_computer as pc
import requests import requests
import numpy as np import numpy as np
from shapely.geometry import mapping from shapely.geometry import mapping
from fastapi.middleware.cors import CORSMiddleware
# --- CRS / AOI helpers -------------------------------------------------------- # --- CRS / AOI helpers --------------------------------------------------------
...@@ -38,6 +40,7 @@ def read_aoi_geom_wgs84(aoi_path: str) -> dict: ...@@ -38,6 +40,7 @@ def read_aoi_geom_wgs84(aoi_path: str) -> dict:
geom = gdf.unary_union geom = gdf.unary_union
return mapping(geom) # GeoJSON geometry dict return mapping(geom) # GeoJSON geometry dict
def buffer_aoi(aoi_path: str, buffer_meters: float, output_path: str) -> str: 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), Buffer the AOI by N meters (using EPSG:3857 for meter-based buffering),
...@@ -53,6 +56,7 @@ def buffer_aoi(aoi_path: str, buffer_meters: float, output_path: str) -> str: ...@@ -53,6 +56,7 @@ def buffer_aoi(aoi_path: str, buffer_meters: float, output_path: str) -> str:
aoi_buffered_gdf.to_file(output_path, driver="GeoJSON") aoi_buffered_gdf.to_file(output_path, driver="GeoJSON")
return output_path return output_path
# --- High-quality asset selection / composition ------------------------------- # --- High-quality asset selection / composition -------------------------------
def _pick_fullres_asset_or_none(item): def _pick_fullres_asset_or_none(item):
...@@ -71,6 +75,7 @@ def _pick_fullres_asset_or_none(item): ...@@ -71,6 +75,7 @@ def _pick_fullres_asset_or_none(item):
return a return a
return None return None
def _scale_to_uint8(arr: np.ndarray) -> np.ndarray: def _scale_to_uint8(arr: np.ndarray) -> np.ndarray:
""" """
Simple 2–98 percentile contrast stretch to uint8. Simple 2–98 percentile contrast stretch to uint8.
...@@ -85,6 +90,7 @@ def _scale_to_uint8(arr: np.ndarray) -> np.ndarray: ...@@ -85,6 +90,7 @@ def _scale_to_uint8(arr: np.ndarray) -> np.ndarray:
scaled = (arr.astype("float32") - p2) * (255.0 / (p98 - p2)) scaled = (arr.astype("float32") - p2) * (255.0 / (p98 - p2))
return np.clip(scaled, 0, 255).astype("uint8") return np.clip(scaled, 0, 255).astype("uint8")
def _build_true_color_from_bands(item, out_path: str, rgb_uint8: bool) -> str: 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). Build a 10 m true-color GeoTIFF from Sentinel-2 L2A bands (B04,B03,B02).
...@@ -99,8 +105,8 @@ def _build_true_color_from_bands(item, out_path: str, rgb_uint8: bool) -> str: ...@@ -99,8 +105,8 @@ def _build_true_color_from_bands(item, out_path: str, rgb_uint8: bool) -> str:
bands[bn] = pc.sign(a.href) bands[bn] = pc.sign(a.href)
with rasterio.open(bands["B04"]) as rsrc, \ with rasterio.open(bands["B04"]) as rsrc, \
rasterio.open(bands["B03"]) as gsrc, \ rasterio.open(bands["B03"]) as gsrc, \
rasterio.open(bands["B02"]) as bsrc: rasterio.open(bands["B02"]) as bsrc:
red = rsrc.read(1) red = rsrc.read(1)
grn = gsrc.read(1) grn = gsrc.read(1)
...@@ -129,6 +135,7 @@ def _build_true_color_from_bands(item, out_path: str, rgb_uint8: bool) -> str: ...@@ -129,6 +135,7 @@ def _build_true_color_from_bands(item, out_path: str, rgb_uint8: bool) -> str:
return out_path return out_path
def download_best_image(aoi_geojson: str, def download_best_image(aoi_geojson: str,
collection: str, collection: str,
date_range: Tuple[str, str], date_range: Tuple[str, str],
...@@ -174,13 +181,15 @@ def download_best_image(aoi_geojson: str, ...@@ -174,13 +181,15 @@ def download_best_image(aoi_geojson: str,
except Exception: except Exception:
return None return None
# --- Post-processing: resampling & overviews ---------------------------------- # --- Post-processing: resampling & overviews ----------------------------------
def _pixel_size_from_transform(transform) -> Tuple[float, float]: def _pixel_size_from_transform(transform) -> Tuple[float, float]:
# (pixel width, pixel height) in CRS units (usually meters) # (pixel width, pixel height) in CRS units (usually meters)
return abs(transform.a), abs(transform.e) 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:
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). Reproject/resample to a clean target pixel size (meters).
If target_res is finer than native and force_upsample=False, clamp to native. If target_res is finer than native and force_upsample=False, clamp to native.
...@@ -218,6 +227,7 @@ def reproject_to_resolution(src_path: str, dst_path: str, target_res_m: float, f ...@@ -218,6 +227,7 @@ def reproject_to_resolution(src_path: str, dst_path: str, target_res_m: float, f
) )
return dst_path return dst_path
def add_overviews_inplace(tif_path: str, levels=(2, 4, 8, 16)): def add_overviews_inplace(tif_path: str, levels=(2, 4, 8, 16)):
""" """
Build internal overviews to improve on-screen clarity at multiple zooms. Build internal overviews to improve on-screen clarity at multiple zooms.
...@@ -226,6 +236,7 @@ def add_overviews_inplace(tif_path: str, levels=(2, 4, 8, 16)): ...@@ -226,6 +236,7 @@ def add_overviews_inplace(tif_path: str, levels=(2, 4, 8, 16)):
ds.build_overviews(levels, Resampling.average) ds.build_overviews(levels, Resampling.average)
ds.update_tags(ns="rio_overview", resampling="average") ds.update_tags(ns="rio_overview", resampling="average")
# --- NDVI helpers ------------------------------------------------------------- # --- NDVI helpers -------------------------------------------------------------
def collection_for_year(year: int): def collection_for_year(year: int):
...@@ -252,6 +263,7 @@ def collection_for_year(year: int): ...@@ -252,6 +263,7 @@ def collection_for_year(year: int):
"native_res_m": 30.0, "native_res_m": 30.0,
} }
def _fetch_least_cloudy_item(collection: str, geometry: dict, date_range: tuple, max_cloud: int): 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") cat = Client.open("https://planetarycomputer.microsoft.com/api/stac/v1")
search = cat.search( search = cat.search(
...@@ -266,6 +278,7 @@ def _fetch_least_cloudy_item(collection: str, geometry: dict, date_range: tuple, ...@@ -266,6 +278,7 @@ def _fetch_least_cloudy_item(collection: str, geometry: dict, date_range: tuple,
items.sort(key=lambda x: x.properties.get("eo:cloud_cover", 100)) items.sort(key=lambda x: x.properties.get("eo:cloud_cover", 100))
return items[0] return items[0]
def _read_band_to_array_signed(asset_href: str) -> tuple: def _read_band_to_array_signed(asset_href: str) -> tuple:
"""Open a single-band raster and return (array, profile).""" """Open a single-band raster and return (array, profile)."""
with rasterio.open(pc.sign(asset_href)) as src: with rasterio.open(pc.sign(asset_href)) as src:
...@@ -273,6 +286,7 @@ def _read_band_to_array_signed(asset_href: str) -> tuple: ...@@ -273,6 +286,7 @@ def _read_band_to_array_signed(asset_href: str) -> tuple:
profile = src.profile.copy() profile = src.profile.copy()
return arr, profile return arr, profile
def _scale_reflectance(arr: np.ndarray, scale_info) -> np.ndarray: def _scale_reflectance(arr: np.ndarray, scale_info) -> np.ndarray:
"""Return reflectance in 0..1 range where possible.""" """Return reflectance in 0..1 range where possible."""
kind, params = scale_info kind, params = scale_info
...@@ -287,6 +301,7 @@ def _scale_reflectance(arr: np.ndarray, scale_info) -> np.ndarray: ...@@ -287,6 +301,7 @@ def _scale_reflectance(arr: np.ndarray, scale_info) -> np.ndarray:
else: else:
return arr return arr
def _compute_ndvi(nir: np.ndarray, red: np.ndarray, nodata_mask: np.ndarray = None) -> np.ndarray: def _compute_ndvi(nir: np.ndarray, red: np.ndarray, nodata_mask: np.ndarray = None) -> np.ndarray:
denom = (nir + red) denom = (nir + red)
ndvi = np.where(denom != 0, (nir - red) / denom, np.nan).astype("float32") ndvi = np.where(denom != 0, (nir - red) / denom, np.nan).astype("float32")
...@@ -294,6 +309,7 @@ def _compute_ndvi(nir: np.ndarray, red: np.ndarray, nodata_mask: np.ndarray = No ...@@ -294,6 +309,7 @@ def _compute_ndvi(nir: np.ndarray, red: np.ndarray, nodata_mask: np.ndarray = No
ndvi = np.where(nodata_mask, np.nan, ndvi) ndvi = np.where(nodata_mask, np.nan, ndvi)
return ndvi return ndvi
def _write_float_geotiff(path: str, arr: np.ndarray, profile, nodata=-9999.0): def _write_float_geotiff(path: str, arr: np.ndarray, profile, nodata=-9999.0):
""" """
Write a single-band float32 GeoTIFF. Write a single-band float32 GeoTIFF.
...@@ -331,19 +347,19 @@ def _resample_match(src_arr, src_profile, ref_profile, resampling=Resampling.bil ...@@ -331,19 +347,19 @@ def _resample_match(src_arr, src_profile, ref_profile, resampling=Resampling.bil
"""Resample a single-band array to the reference profile's grid.""" """Resample a single-band array to the reference profile's grid."""
with MemoryFile() as mem_src: with MemoryFile() as mem_src:
with rasterio.open( with rasterio.open(
mem_src, "w", driver="GTiff", mem_src, "w", driver="GTiff",
height=src_profile["height"], width=src_profile["width"], height=src_profile["height"], width=src_profile["width"],
count=1, dtype="float32", count=1, dtype="float32",
crs=src_profile["crs"], transform=src_profile["transform"] crs=src_profile["crs"], transform=src_profile["transform"]
) as src_ds: ) as src_ds:
src_ds.write(src_arr.astype("float32"), 1) src_ds.write(src_arr.astype("float32"), 1)
with MemoryFile() as mem_dst: with MemoryFile() as mem_dst:
with rasterio.open( with rasterio.open(
mem_dst, "w", driver="GTiff", mem_dst, "w", driver="GTiff",
height=ref_profile["height"], width=ref_profile["width"], height=ref_profile["height"], width=ref_profile["width"],
count=1, dtype="float32", count=1, dtype="float32",
crs=ref_profile["crs"], transform=ref_profile["transform"] crs=ref_profile["crs"], transform=ref_profile["transform"]
) as dst_ds: ) as dst_ds:
reproject( reproject(
source=rasterio.band(src_ds, 1), source=rasterio.band(src_ds, 1),
...@@ -356,6 +372,7 @@ def _resample_match(src_arr, src_profile, ref_profile, resampling=Resampling.bil ...@@ -356,6 +372,7 @@ def _resample_match(src_arr, src_profile, ref_profile, resampling=Resampling.bil
) )
return dst_ds.read(1) return dst_ds.read(1)
def _summarize_ndvi(ndvi: np.ndarray, pixel_size_m: float) -> dict: def _summarize_ndvi(ndvi: np.ndarray, pixel_size_m: float) -> dict:
valid = np.isfinite(ndvi) valid = np.isfinite(ndvi)
if not np.any(valid): if not np.any(valid):
...@@ -375,6 +392,7 @@ def _summarize_ndvi(ndvi: np.ndarray, pixel_size_m: float) -> dict: ...@@ -375,6 +392,7 @@ def _summarize_ndvi(ndvi: np.ndarray, pixel_size_m: float) -> dict:
"area_gt_0_6_m2": float(np.sum(v > 0.6) * area_per_pixel_m2), "area_gt_0_6_m2": float(np.sum(v > 0.6) * area_per_pixel_m2),
} }
# --- API setup ---------------------------------------------------------------- # --- API setup ----------------------------------------------------------------
app = FastAPI( app = FastAPI(
...@@ -387,27 +405,45 @@ An API that returns a satellite image file for a given year and computes NDVI & ...@@ -387,27 +405,45 @@ An API that returns a satellite image file for a given year and computes NDVI &
version="1.3.0" version="1.3.0"
) )
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
], # use ["*"] for local dev only
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Defaults; override via env or query params # Defaults; override via env or query params
AOI_PATH = os.environ.get("AOI_PATH", "map.geojson") AOI_PATH = os.environ.get("AOI_PATH", "map.geojson")
BUFFER_METERS_DEFAULT = float(os.environ.get("BUFFER_METERS", "200")) BUFFER_METERS_DEFAULT = float(os.environ.get("BUFFER_METERS", "200"))
COLLECTION_DEFAULT = os.environ.get("COLLECTION", "sentinel-2-l2a") COLLECTION_DEFAULT = os.environ.get("COLLECTION", "sentinel-2-l2a")
MAX_CLOUD_DEFAULT = int(os.environ.get("MAX_CLOUD", "10")) MAX_CLOUD_DEFAULT = int(os.environ.get("MAX_CLOUD", "10"))
# Map a year -> date window (customize as needed; here June–Aug) # Map a year -> date window (customize as needed; here June–Aug)
def year_to_range(year: int) -> Tuple[str, str]: def year_to_range(year: int) -> Tuple[str, str]:
return (f"{year}-06-01", f"{year}-08-31") return (f"{year}-06-01", f"{year}-08-31")
# --- Download Endpoint -------------------------------------------------------- # --- Download Endpoint --------------------------------------------------------
class DownloadParams(BaseModel): class DownloadParams(BaseModel):
year: int = Field(..., ge=2015, le=2100, description="Year to fetch imagery for") 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)") 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") 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)") 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") 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)") rgb_uint8: Optional[bool] = Field(default=False,
target_res_m: Optional[float] = Field(default=10.0, ge=0, description="Reproject/resample clipped output to this pixel size in meters") description="If true, write 8-bit RGB with a simple stretch (preview-friendly)")
build_overviews: Optional[bool] = Field(default=True, description="If true, add internal overviews to the output GeoTIFF") 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( @app.get(
"/download", "/download",
...@@ -421,14 +457,15 @@ class DownloadParams(BaseModel): ...@@ -421,14 +457,15 @@ class DownloadParams(BaseModel):
summary="Download imagery for a given year (GeoTIFF)" summary="Download imagery for a given year (GeoTIFF)"
) )
def download_imagery( def download_imagery(
year: int = Query(..., ge=2015, le=2100, description="Year to fetch imagery for"), 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)"), 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"), 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"), 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"), 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)"), 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"), target_res_m: float = Query(10.0, ge=0,
build_overviews: bool = Query(True, description="If true, add internal overviews to the output GeoTIFF") 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`. Returns a GeoTIFF image file for the requested `year`.
...@@ -450,7 +487,8 @@ def download_imagery( ...@@ -450,7 +487,8 @@ def download_imagery(
raw_tif = os.path.join(workdir, f"imagery_{year}.tif") 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) out_path = download_best_image(aoi_path, collection, date_range, max_cloud, raw_tif, rgb_uint8=rgb_uint8)
if not out_path: if not out_path:
raise HTTPException(status_code=404, detail=f"No imagery found for year={year}, range={date_range}, max_cloud={max_cloud}") 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) # Optional: clip to AOI (must reproject AOI to raster CRS before masking)
final_path = out_path final_path = out_path
...@@ -493,6 +531,7 @@ def download_imagery( ...@@ -493,6 +531,7 @@ def download_imagery(
filename=os.path.basename(final_path) filename=os.path.basename(final_path)
) )
@app.post( @app.post(
"/download", "/download",
response_class=FileResponse, response_class=FileResponse,
...@@ -510,15 +549,17 @@ def download_imagery_post(params: DownloadParams): ...@@ -510,15 +549,17 @@ def download_imagery_post(params: DownloadParams):
build_overviews=params.build_overviews, build_overviews=params.build_overviews,
) )
# --- NDVI Endpoints ----------------------------------------------------------- # --- NDVI Endpoints -----------------------------------------------------------
@app.get("/ndvi", summary="Compute NDVI for a given year; returns stats and writes a GeoTIFF") @app.get("/ndvi", summary="Compute NDVI for a given year; returns stats and writes a GeoTIFF")
def ndvi_year( def ndvi_year(
year: int = Query(..., ge=2013, le=2100), year: int = Query(..., ge=2013, le=2100),
max_cloud: int = Query(MAX_CLOUD_DEFAULT, ge=0, le=100), max_cloud: int = Query(MAX_CLOUD_DEFAULT, ge=0, le=100),
buffer_meters: float = Query(BUFFER_METERS_DEFAULT, ge=0), buffer_meters: float = Query(BUFFER_METERS_DEFAULT, ge=0),
clip_to_aoi: bool = Query(True, description="Clip NDVI to AOI"), 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"), 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): if not os.path.exists(AOI_PATH):
raise HTTPException(status_code=500, detail=f"AOI file not found at {AOI_PATH}") raise HTTPException(status_code=500, detail=f"AOI file not found at {AOI_PATH}")
...@@ -548,7 +589,8 @@ def ndvi_year( ...@@ -548,7 +589,8 @@ def ndvi_year(
nir = _scale_reflectance(nir, cfg["scale"]) nir = _scale_reflectance(nir, cfg["scale"])
# Ensure bands are on identical grid (usually true, but safeguard) # 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) same_grid = (red_prof["transform"] == nir_prof["transform"]) and (red_prof["crs"] == nir_prof["crs"]) and (
red.shape == nir.shape)
if not same_grid: if not same_grid:
nir = _resample_match(nir, nir_prof, red_prof, resampling=Resampling.bilinear) nir = _resample_match(nir, nir_prof, red_prof, resampling=Resampling.bilinear)
...@@ -611,14 +653,15 @@ def ndvi_year( ...@@ -611,14 +653,15 @@ def ndvi_year(
"stats": stats "stats": stats
} }
@app.get("/ndvi-change", summary="Compute NDVI change (end - start); returns stats and paths to GeoTIFFs") @app.get("/ndvi-change", summary="Compute NDVI change (end - start); returns stats and paths to GeoTIFFs")
def ndvi_change( def ndvi_change(
start_year: int = Query(..., ge=2013), start_year: int = Query(..., ge=2013),
end_year: int = Query(..., ge=2013), end_year: int = Query(..., ge=2013),
max_cloud: int = Query(MAX_CLOUD_DEFAULT, ge=0, le=100), max_cloud: int = Query(MAX_CLOUD_DEFAULT, ge=0, le=100),
buffer_meters: float = Query(BUFFER_METERS_DEFAULT, ge=0), buffer_meters: float = Query(BUFFER_METERS_DEFAULT, ge=0),
clip_to_aoi: bool = Query(True), clip_to_aoi: bool = Query(True),
compare_res_m: float = Query(30.0, ge=0, description="Common resolution (m) for both years"), compare_res_m: float = Query(30.0, ge=0, description="Common resolution (m) for both years"),
): ):
if end_year < start_year: if end_year < start_year:
raise HTTPException(status_code=422, detail="end_year must be >= start_year") raise HTTPException(status_code=422, detail="end_year must be >= start_year")
...@@ -640,12 +683,13 @@ def ndvi_change( ...@@ -640,12 +683,13 @@ def ndvi_change(
) )
ndvi_start_path = os.path.join(start["workdir"], start["ndvi_tif"]) ndvi_start_path = os.path.join(start["workdir"], start["ndvi_tif"])
ndvi_end_path = os.path.join(end["workdir"], end["ndvi_tif"]) ndvi_end_path = os.path.join(end["workdir"], end["ndvi_tif"])
# Align and difference # Align and difference
with rasterio.open(ndvi_end_path) as end_ds, rasterio.open(ndvi_start_path) as start_ds: 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 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): 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_s = _resample_match(start_ds.read(1), start_ds.profile, end_ds.profile, resampling=Resampling.bilinear)
arr_e = end_ds.read(1) arr_e = end_ds.read(1)
profile = end_ds.profile.copy() profile = end_ds.profile.copy()
......
import { defineConfig } from 'orval'
export default defineConfig({
api: {
input: 'http://127.0.0.1:8000/openapi.json',
output: {
target: 'src/api/__generated__/index.ts', // ⬅️ its own dir
client: 'react-query',
clean: true, // safe now
override: {
mutator: {
path: 'src/api/fetcher.ts', // stays outside __generated__
name: 'apiFetch', // or { default: true } if default export
},
},
},
},
})
This diff is collapsed.
...@@ -7,7 +7,8 @@ ...@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"orval": "orval"
}, },
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^0.1.80", "@napi-rs/canvas": "^0.1.80",
...@@ -18,6 +19,7 @@ ...@@ -18,6 +19,7 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-router": "^1.132.47", "@tanstack/react-router": "^1.132.47",
"canvas": "^3.2.0", "canvas": "^3.2.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
...@@ -25,6 +27,7 @@ ...@@ -25,6 +27,7 @@
"geotiff": "^2.1.4-beta.0", "geotiff": "^2.1.4-beta.0",
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"maplibre-gl": "^5.9.0", "maplibre-gl": "^5.9.0",
"proj4": "^2.19.10",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-map-gl": "^8.1.0", "react-map-gl": "^8.1.0",
...@@ -33,6 +36,7 @@ ...@@ -33,6 +36,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@tanstack/react-query-devtools": "^5.90.2",
"@types/node": "^24.6.0", "@types/node": "^24.6.0",
"@types/react": "^19.1.16", "@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
...@@ -41,6 +45,7 @@ ...@@ -41,6 +45,7 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22", "eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0", "globals": "^16.4.0",
"orval": "^7.13.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.45.0",
......
/**
* Generated by orval v7.13.2 🍺
* Do not edit manually.
* AOI Imagery API
*
An API that returns a satellite image file for a given year and computes NDVI & change.
- Swagger UI: `/docs`
- ReDoc: `/redoc`
* OpenAPI spec version: 1.3.0
*/
import {
useMutation,
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import { apiFetch } from '../fetcher';
/**
* STAC collection (e.g., sentinel-2-l2a)
*/
export type DownloadParamsCollection = string | null;
/**
* Max cloud cover percent
*/
export type DownloadParamsMaxCloud = number | null;
/**
* Buffer to apply to AOI (meters)
*/
export type DownloadParamsBufferMeters = number | null;
/**
* If true, clip the image to buffered AOI
*/
export type DownloadParamsClipToAoi = boolean | null;
/**
* If true, write 8-bit RGB with a simple stretch (preview-friendly)
*/
export type DownloadParamsRgbUint8 = boolean | null;
/**
* Reproject/resample clipped output to this pixel size in meters
*/
export type DownloadParamsTargetResM = number | null;
/**
* If true, add internal overviews to the output GeoTIFF
*/
export type DownloadParamsBuildOverviews = boolean | null;
export interface DownloadParams {
/**
* Year to fetch imagery for
* @minimum 2015
* @maximum 2100
*/
year: number;
/** STAC collection (e.g., sentinel-2-l2a) */
collection?: DownloadParamsCollection;
/** Max cloud cover percent */
max_cloud?: DownloadParamsMaxCloud;
/** Buffer to apply to AOI (meters) */
buffer_meters?: DownloadParamsBufferMeters;
/** If true, clip the image to buffered AOI */
clip_to_aoi?: DownloadParamsClipToAoi;
/** If true, write 8-bit RGB with a simple stretch (preview-friendly) */
rgb_uint8?: DownloadParamsRgbUint8;
/** Reproject/resample clipped output to this pixel size in meters */
target_res_m?: DownloadParamsTargetResM;
/** If true, add internal overviews to the output GeoTIFF */
build_overviews?: DownloadParamsBuildOverviews;
}
export interface HTTPValidationError {
detail?: ValidationError[];
}
export type ValidationErrorLocItem = string | number;
export interface ValidationError {
loc: ValidationErrorLocItem[];
msg: string;
type: string;
}
export type DownloadImageryDownloadGetParams = {
/**
* Year to fetch imagery for
* @minimum 2015
* @maximum 2100
*/
year: number;
/**
* STAC collection (e.g., sentinel-2-l2a)
*/
collection?: string;
/**
* Max cloud cover percent
* @minimum 0
* @maximum 100
*/
max_cloud?: number;
/**
* Buffer in meters to apply to AOI
* @minimum 0
*/
buffer_meters?: number;
/**
* If true, clip the image to buffered AOI
*/
clip_to_aoi?: boolean;
/**
* If true, write 8-bit RGB with a simple stretch (preview-friendly)
*/
rgb_uint8?: boolean;
/**
* Reproject/resample clipped output to this pixel size in meters
* @minimum 0
*/
target_res_m?: number;
/**
* If true, add internal overviews to the output GeoTIFF
*/
build_overviews?: boolean;
};
export type NdviYearNdviGetParams = {
/**
* @minimum 2013
* @maximum 2100
*/
year: number;
/**
* @minimum 0
* @maximum 100
*/
max_cloud?: number;
/**
* @minimum 0
*/
buffer_meters?: number;
/**
* Clip NDVI to AOI
*/
clip_to_aoi?: boolean;
/**
* Optional resampling resolution (m) for cross-year/sensor comparability
* @minimum 0
*/
res_for_compare_m?: number;
};
export type NdviChangeNdviChangeGetParams = {
/**
* @minimum 2013
*/
start_year: number;
/**
* @minimum 2013
*/
end_year: number;
/**
* @minimum 0
* @maximum 100
*/
max_cloud?: number;
/**
* @minimum 0
*/
buffer_meters?: number;
clip_to_aoi?: boolean;
/**
* Common resolution (m) for both years
* @minimum 0
*/
compare_res_m?: number;
};
/**
* Returns a GeoTIFF image file for the requested `year`.
By default returns the best (least cloudy) June–August image intersecting your AOI.
* @summary Download imagery for a given year (GeoTIFF)
*/
export const downloadImageryDownloadGet = (
params: DownloadImageryDownloadGetParams,
signal?: AbortSignal
) => {
return apiFetch<unknown>(
{url: `/download`, method: 'GET',
params, signal
},
);
}
export const getDownloadImageryDownloadGetQueryKey = (params?: DownloadImageryDownloadGetParams,) => {
return [
`/download`, ...(params ? [params]: [])
] as const;
}
export const getDownloadImageryDownloadGetQueryOptions = <TData = Awaited<ReturnType<typeof downloadImageryDownloadGet>>, TError = void | void | void>(params: DownloadImageryDownloadGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof downloadImageryDownloadGet>>, TError, TData>>, }
) => {
const {query: queryOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getDownloadImageryDownloadGetQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof downloadImageryDownloadGet>>> = ({ signal }) => downloadImageryDownloadGet(params, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof downloadImageryDownloadGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type DownloadImageryDownloadGetQueryResult = NonNullable<Awaited<ReturnType<typeof downloadImageryDownloadGet>>>
export type DownloadImageryDownloadGetQueryError = void | void | void
export function useDownloadImageryDownloadGet<TData = Awaited<ReturnType<typeof downloadImageryDownloadGet>>, TError = void | void | void>(
params: DownloadImageryDownloadGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof downloadImageryDownloadGet>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof downloadImageryDownloadGet>>,
TError,
Awaited<ReturnType<typeof downloadImageryDownloadGet>>
> , 'initialData'
>, }
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useDownloadImageryDownloadGet<TData = Awaited<ReturnType<typeof downloadImageryDownloadGet>>, TError = void | void | void>(
params: DownloadImageryDownloadGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof downloadImageryDownloadGet>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof downloadImageryDownloadGet>>,
TError,
Awaited<ReturnType<typeof downloadImageryDownloadGet>>
> , 'initialData'
>, }
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useDownloadImageryDownloadGet<TData = Awaited<ReturnType<typeof downloadImageryDownloadGet>>, TError = void | void | void>(
params: DownloadImageryDownloadGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof downloadImageryDownloadGet>>, TError, TData>>, }
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Download imagery for a given year (GeoTIFF)
*/
export function useDownloadImageryDownloadGet<TData = Awaited<ReturnType<typeof downloadImageryDownloadGet>>, TError = void | void | void>(
params: DownloadImageryDownloadGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof downloadImageryDownloadGet>>, TError, TData>>, }
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getDownloadImageryDownloadGetQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary Download imagery (POST body)
*/
export const downloadImageryPostDownloadPost = (
downloadParams: DownloadParams,
signal?: AbortSignal
) => {
return apiFetch<void>(
{url: `/download`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: downloadParams, signal
},
);
}
export const getDownloadImageryPostDownloadPostMutationOptions = <TError = HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof downloadImageryPostDownloadPost>>, TError,{data: DownloadParams}, TContext>, }
): UseMutationOptions<Awaited<ReturnType<typeof downloadImageryPostDownloadPost>>, TError,{data: DownloadParams}, TContext> => {
const mutationKey = ['downloadImageryPostDownloadPost'];
const {mutation: mutationOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof downloadImageryPostDownloadPost>>, {data: DownloadParams}> = (props) => {
const {data} = props ?? {};
return downloadImageryPostDownloadPost(data,)
}
return { mutationFn, ...mutationOptions }}
export type DownloadImageryPostDownloadPostMutationResult = NonNullable<Awaited<ReturnType<typeof downloadImageryPostDownloadPost>>>
export type DownloadImageryPostDownloadPostMutationBody = DownloadParams
export type DownloadImageryPostDownloadPostMutationError = HTTPValidationError
/**
* @summary Download imagery (POST body)
*/
export const useDownloadImageryPostDownloadPost = <TError = HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof downloadImageryPostDownloadPost>>, TError,{data: DownloadParams}, TContext>, }
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof downloadImageryPostDownloadPost>>,
TError,
{data: DownloadParams},
TContext
> => {
const mutationOptions = getDownloadImageryPostDownloadPostMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary Compute NDVI for a given year; returns stats and writes a GeoTIFF
*/
export const ndviYearNdviGet = (
params: NdviYearNdviGetParams,
signal?: AbortSignal
) => {
return apiFetch<unknown>(
{url: `/ndvi`, method: 'GET',
params, signal
},
);
}
export const getNdviYearNdviGetQueryKey = (params?: NdviYearNdviGetParams,) => {
return [
`/ndvi`, ...(params ? [params]: [])
] as const;
}
export const getNdviYearNdviGetQueryOptions = <TData = Awaited<ReturnType<typeof ndviYearNdviGet>>, TError = HTTPValidationError>(params: NdviYearNdviGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ndviYearNdviGet>>, TError, TData>>, }
) => {
const {query: queryOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getNdviYearNdviGetQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof ndviYearNdviGet>>> = ({ signal }) => ndviYearNdviGet(params, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof ndviYearNdviGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type NdviYearNdviGetQueryResult = NonNullable<Awaited<ReturnType<typeof ndviYearNdviGet>>>
export type NdviYearNdviGetQueryError = HTTPValidationError
export function useNdviYearNdviGet<TData = Awaited<ReturnType<typeof ndviYearNdviGet>>, TError = HTTPValidationError>(
params: NdviYearNdviGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof ndviYearNdviGet>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof ndviYearNdviGet>>,
TError,
Awaited<ReturnType<typeof ndviYearNdviGet>>
> , 'initialData'
>, }
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useNdviYearNdviGet<TData = Awaited<ReturnType<typeof ndviYearNdviGet>>, TError = HTTPValidationError>(
params: NdviYearNdviGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ndviYearNdviGet>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof ndviYearNdviGet>>,
TError,
Awaited<ReturnType<typeof ndviYearNdviGet>>
> , 'initialData'
>, }
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useNdviYearNdviGet<TData = Awaited<ReturnType<typeof ndviYearNdviGet>>, TError = HTTPValidationError>(
params: NdviYearNdviGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ndviYearNdviGet>>, TError, TData>>, }
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Compute NDVI for a given year; returns stats and writes a GeoTIFF
*/
export function useNdviYearNdviGet<TData = Awaited<ReturnType<typeof ndviYearNdviGet>>, TError = HTTPValidationError>(
params: NdviYearNdviGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ndviYearNdviGet>>, TError, TData>>, }
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getNdviYearNdviGetQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary Compute NDVI change (end - start); returns stats and paths to GeoTIFFs
*/
export const ndviChangeNdviChangeGet = (
params: NdviChangeNdviChangeGetParams,
signal?: AbortSignal
) => {
return apiFetch<unknown>(
{url: `/ndvi-change`, method: 'GET',
params, signal
},
);
}
export const getNdviChangeNdviChangeGetQueryKey = (params?: NdviChangeNdviChangeGetParams,) => {
return [
`/ndvi-change`, ...(params ? [params]: [])
] as const;
}
export const getNdviChangeNdviChangeGetQueryOptions = <TData = Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>, TError = HTTPValidationError>(params: NdviChangeNdviChangeGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>, TError, TData>>, }
) => {
const {query: queryOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getNdviChangeNdviChangeGetQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>> = ({ signal }) => ndviChangeNdviChangeGet(params, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type NdviChangeNdviChangeGetQueryResult = NonNullable<Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>>
export type NdviChangeNdviChangeGetQueryError = HTTPValidationError
export function useNdviChangeNdviChangeGet<TData = Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>, TError = HTTPValidationError>(
params: NdviChangeNdviChangeGetParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>,
TError,
Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>
> , 'initialData'
>, }
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useNdviChangeNdviChangeGet<TData = Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>, TError = HTTPValidationError>(
params: NdviChangeNdviChangeGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>,
TError,
Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>
> , 'initialData'
>, }
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useNdviChangeNdviChangeGet<TData = Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>, TError = HTTPValidationError>(
params: NdviChangeNdviChangeGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>, TError, TData>>, }
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Compute NDVI change (end - start); returns stats and paths to GeoTIFFs
*/
export function useNdviChangeNdviChangeGet<TData = Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>, TError = HTTPValidationError>(
params: NdviChangeNdviChangeGetParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof ndviChangeNdviChangeGet>>, TError, TData>>, }
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getNdviChangeNdviChangeGetQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
// src/api/fetcher.ts
type AnyObj = Record<string, any>
export type FetcherArgs = {
url: string
method?: string
body?: unknown
headers?: Record<string, string>
params?: AnyObj | { query?: AnyObj } // Orval may wrap query under `params.query`
signal?: AbortSignal
}
// build a=1&b=2 from a plain object (handles arrays/booleans/numbers)
function toQueryString(obj?: AnyObj): string {
if (!obj) return ''
const usp = new URLSearchParams()
const add = (k: string, v: any) => usp.append(k, String(v))
for (const [k, v] of Object.entries(obj)) {
if (v === undefined || v === null) continue
if (Array.isArray(v)) v.forEach((x) => add(k, x))
else if (typeof v === 'object') {
// flatten simple nested objects like { a: { b: 1 } } -> a.b=1
for (const [kk, vv] of Object.entries(v)) {
if (vv !== undefined && vv !== null) add(`${k}.${kk}`, vv)
}
} else add(k, v)
}
const s = usp.toString()
return s ? `?${s}` : ''
}
const apiFetch = async <T>({
url,
method = 'GET',
body,
headers,
params,
signal,
}: FetcherArgs): Promise<T> => {
const baseUrl = import.meta.env.VITE_API_URL ?? ''
// Orval sometimes calls mutator with `params: { query: {...} }`
const queryObj = params && 'query' in (params as AnyObj)
? (params as any).query
: params
const qs = toQueryString(queryObj)
const finalUrl = `${baseUrl}${url}${qs}`
const token = localStorage.getItem('token')
const init: RequestInit = {
method,
signal,
headers: {
...(body != null ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
...(body != null ? { body: JSON.stringify(body) } : {}),
}
const res = await fetch(finalUrl, init)
// Try JSON first; fall back to text (handles empty 204/strings)
const contentType = res.headers.get('content-type') || ''
let data: any = null
if (contentType.includes('application/json')) {
try { data = await res.json() } catch { data = null }
} else {
try { data = await res.text() } catch { data = null }
}
if (!res.ok) {
throw { status: res.status, message: res.statusText, data }
}
return data as T
}
export default apiFetch
export { apiFetch }
import {Layer, Map, Source, type ViewStateChangeEvent} from 'react-map-gl/maplibre'; import {Layer, Map, Source, type ViewStateChangeEvent} from 'react-map-gl/maplibre';
import * as React from "react"; import * as React from "react";
import {fromArrayBuffer} from "geotiff";
import proj4 from "proj4";
interface CameraState { interface CameraState {
longitude: number; longitude: number;
...@@ -10,6 +11,13 @@ interface CameraState { ...@@ -10,6 +11,13 @@ interface CameraState {
pitch?: number; pitch?: number;
} }
type ImageCorners = [
[number, number], // top-left (lng, lat)
[number, number], // top-right
[number, number], // bottom-right
[number, number] // bottom-left
];
interface MapViewerProps { interface MapViewerProps {
year: number; year: number;
viewState?: CameraState; viewState?: CameraState;
...@@ -26,19 +34,240 @@ const DEFAULT_VIEW = { ...@@ -26,19 +34,240 @@ const DEFAULT_VIEW = {
pitch: 0 pitch: 0
}; };
const data = {
year: 2025,
collection: "sentinel-2-l2a",
native_res_m: 10,
compare_res_m: 30,
ndvi_tif: "ndvi_2025_30m.tif",
workdir: "C:\\Users\\vinhd\\AppData\\Local\\Temp\\ndvi_2025_06ozaqja",
stats: {
count: 4347,
mean: -57.32052993774414,
median: 0.15449757874011993,
p05: 0.04584402218461037,
p95: 0.4455701410770416,
frac_gt_0_2: 0.3556475730388774,
frac_gt_0_4: 0.07292385553255118,
frac_gt_0_6: 0.0006901311249137336,
area_gt_0_4_m2: 285300,
area_gt_0_6_m2: 2700
}
};
function rdylgn(t: number): [number, number, number] {
// very small ramp: red->yellow->green
const clamp = (x: number) => Math.max(0, Math.min(1, x));
t = clamp(t);
let r = 0, g = 0, b = 0;
if (t < 0.5) {
r = 255;
g = Math.round(510 * t);
} // red->yellow
else {
r = Math.round(510 * (1 - t));
g = 255;
} // yellow->green
return [r, g, b];
}
// Build proj4 string for common UTM EPSG codes (Sentinel/Landsat)
function projFromEPSG(epsg: number) {
if (epsg >= 32601 && epsg <= 32660) return `+proj=utm +zone=${epsg - 32600} +datum=WGS84 +units=m +no_defs`;
if (epsg >= 32701 && epsg <= 32760) return `+proj=utm +zone=${epsg - 32700} +south +datum=WGS84 +units=m +no_defs`;
return 'EPSG:4326';
}
function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps) { function MapViewer({year, viewState, onMove, style, mapStyleUrl}: MapViewerProps) {
console.log('year', year);
const tilesUrl = `/tiles/ndvi_${year}/{z}/{x}/{y}.png`; const [imageUrl, setImageUrl] = React.useState<string>();
const [coords, setCoords] = React.useState<ImageCorners | null>(null);
function percentileFrom(values: number[], p: number) {
if (!values.length) return NaN;
const k = Math.max(0, Math.min(values.length - 1, Math.floor((p / 100) * (values.length - 1))));
// partial sort via nth_element-ish: for simplicity use sort on a copy (AOIs are small)
const v = values.slice().sort((a, b) => a - b);
return v[k];
}
function median3x3NaN(buf: Float32Array, w: number, h: number) {
const out = new Float32Array(buf.length);
const win = new Float32Array(9);
let idx = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++, idx++) {
let n = 0;
for (let dy = -1; dy <= 1; dy++) {
const yy = y + dy;
if (yy < 0 || yy >= h) continue;
for (let dx = -1; dx <= 1; dx++) {
const xx = x + dx;
if (xx < 0 || xx >= w) continue;
const v = buf[yy * w + xx];
if (Number.isFinite(v)) win[n++] = v;
}
}
if (!n) {
out[idx] = NaN;
continue;
}
// median of n values in win[0..n)
const temp = Array.from(win.slice(0, n)).sort((a, b) => a - b);
out[idx] = temp[Math.floor(n / 2)];
}
}
buf.set(out);
}
function enhanceNdviForDisplay(
ndvi: Float32Array | number[],
width: number,
height: number,
{nodata = -9999, despeckle = true, gamma = 0.95}: { nodata?: number; despeckle?: boolean; gamma?: number }
) {
const N = width * height;
// 1) Clean + collect valid values for robust limits
const cleaned = new Float32Array(N);
const vals: number[] = [];
for (let i = 0; i < N; i++) {
let v = (ndvi as any)[i];
if (!Number.isFinite(v) || v === nodata || v < -1.5 || v > 1.5) {
cleaned[i] = NaN;
continue;
}
cleaned[i] = v;
vals.push(v);
}
if (!vals.length) return {norm: cleaned, lo: -1, hi: 1};
// 2) Robust min/max (5th–95th)
const lo = percentileFrom(vals, 5);
const hi = Math.max(lo + 1e-6, percentileFrom(vals, 95));
// 3) Optional despeckle (median 3×3)
if (despeckle) median3x3NaN(cleaned, width, height);
// 4) Normalize + gamma
const inv = 1 / (hi - lo);
const norm = new Float32Array(N);
for (let i = 0; i < N; i++) {
const v = cleaned[i];
if (!Number.isFinite(v)) {
norm[i] = NaN;
continue;
}
let t = (v - lo) * inv; // 0..1
t = t < 0 ? 0 : t > 1 ? 1 : t; // clamp
norm[i] = Math.pow(t, gamma); // gamma
}
return {norm, lo, hi};
}
// optional: nicer NDVI colormap (cfastie-like)
function colorFromUnit(t: number): [number, number, number] {
// t in [0..1]; quick 6-stop ramp
const stops = [
[0, 0, 0], // black (nodata)
[165, 0, 38], // deep red
[215, 48, 39], // red
[244, 109, 67], // orange
[102, 189, 99], // green
[26, 152, 80], // deeper green
];
const p = t * (stops.length - 1);
const i = Math.floor(p);
const a = p - i;
const s0 = stops[i], s1 = stops[Math.min(i + 1, stops.length - 1)];
return [
Math.round(s0[0] + a * (s1[0] - s0[0])),
Math.round(s0[1] + a * (s1[1] - s0[1])),
Math.round(s0[2] + a * (s1[2] - s0[2])),
] as [number, number, number];
}
async function handleFile(file: File) {
const buf = await file.arrayBuffer();
const tiff = await fromArrayBuffer(buf);
const img = await tiff.getImage();
const ras = await img.readRasters({samples: [0]}); // NDVI band
const width = img.getWidth(), height = img.getHeight();
const bbox = img.getBoundingBox(); // [minX,minY,maxX,maxY] in the TIFF CRS
const gk = img.getGeoKeys() || {};
const epsg = gk.ProjectedCSTypeGeoKey || gk.ProjectedCRSGeoKey || 4326;
// corners -> lon/lat
const src = proj4(projFromEPSG(Number(epsg)));
const toLL = (x: number, y: number) => src.inverse([x, y]) as [number, number];
const [minX, minY, maxX, maxY] = bbox;
const corners: ImageCorners = [
toLL(minX, maxY), // top-left
toLL(maxX, maxY), // top-right
toLL(maxX, minY), // bottom-right
toLL(minX, minY), // bottom-left
];
setCoords(corners);
// ...after you read rasters and get width/height (you already have this) :contentReference[oaicite:1]{index=1}
const ndviArr = ras[0] as Float32Array | number[];
const {norm} = enhanceNdviForDisplay(ndviArr, width, height, {
nodata: -9999,
despeckle: true, // turn off if you need exact per-pixel values
gamma: 0.95
});
// Paint to canvas
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
const imgData = ctx.createImageData(width, height);
for (let i = 0; i < width * height; i++) {
const t = norm[i];
if (!Number.isFinite(t)) {
imgData.data[4 * i + 3] = 0;
continue;
} // transparent
const [r, g, b] = colorFromUnit(t);
imgData.data[4 * i] = r;
imgData.data[4 * i + 1] = g;
imgData.data[4 * i + 2] = b;
imgData.data[4 * i + 3] = 255;
}
ctx.putImageData(imgData, 0, 0);
setImageUrl(canvas.toDataURL());
}
return ( return (
<Map <div className='relative h-full w-full'>
{...(viewState ? viewState : {})} <div style={{position: 'absolute', zIndex: 10, padding: 8}}>
{...(!viewState ? { initialViewState: DEFAULT_VIEW } : {})} <input
onMove={onMove} type="file"
style={style} accept=".tif,.tiff"
mapStyle={mapStyleUrl ?? 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'}> onChange={(e) => {
<Source key={year} id="ndvi" type="raster" tiles={[tilesUrl]} tileSize={256}/> const f = e.target.files?.[0];
<Layer id="ndvi-layer" type="raster" source="ndvi" paint={{'raster-opacity': 0.85}}/> if (f) handleFile(f);
</Map> }}
/>
</div>
<Map
{...(viewState ? viewState : {})}
{...(!viewState ? {initialViewState: DEFAULT_VIEW} : {})}
onMove={onMove}
style={style}
mapStyle={mapStyleUrl ?? 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'}
>
{imageUrl && coords && (
<Source id="ndvi-static" type="image" url={imageUrl} coordinates={coords}>
<Layer id="ndvi-layer" type="raster" paint={{'raster-opacity': 0.5}}/>
</Source>
)}
</Map>
</div>
); );
} }
......
import {QueryClient} from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 60_000,
},
},
})
\ No newline at end of file
import { StrictMode } from 'react' import {StrictMode} from 'react'
import { createRoot } from 'react-dom/client' import {createRoot} from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import {QueryClientProvider} from "@tanstack/react-query";
import {queryClient} from "@/lib/react-query.ts";
import {ReactQueryDevtools} from "@tanstack/react-query-devtools";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <QueryClientProvider client={queryClient}>
<App/>
<ReactQueryDevtools initialIsOpen={false}/>
</QueryClientProvider>
</StrictMode>, </StrictMode>,
) )
...@@ -22,5 +22,5 @@ ...@@ -22,5 +22,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts", "orval.config.ts"]
} }
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