PyGeoFetch 1.3.0__tar.gz → 1.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/CHANGELOG.md +2 -2
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/LICENSE +1 -1
- {pygeofetch-1.3.0/pygeofetch.egg-info → pygeofetch-1.4.0}/PKG-INFO +5 -5
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/README.md +2 -2
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/__init__.py +1 -1
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/meta.yaml +1 -1
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/preprocessor.py +117 -7
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/sar.py +45 -8
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/timeseries.py +66 -30
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/copernicus.py +89 -9
- pygeofetch-1.4.0/pygeofetch/sar/__init__.py +22 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/sar/_native.py +5 -4
- pygeofetch-1.4.0/pygeofetch/sar/extraction.py +189 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0/pygeofetch.egg-info}/PKG-INFO +5 -5
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/SOURCES.txt +1 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pyproject.toml +4 -2
- pygeofetch-1.3.0/pygeofetch/sar/__init__.py +0 -18
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/MANIFEST.in +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/__init__.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/auth_commands.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/config_commands.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/download_commands.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/index_commands.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/main.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/pipeline_process_commands.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/postprocess_commands.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/preprocess_commands.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/sar_commands.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/cli/search_commands.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/config/__init__.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/config/defaults.yaml +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/config/settings.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/__init__.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/authenticator.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/cache_manager.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/downloader.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/engine.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/logging.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/orbits.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/scheduler.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/core/searcher.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/__init__.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/atmosphere.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/extraction.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/interferogram.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/timeseries.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/insar/unwrap.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/models/__init__.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/models/download_task.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/models/satellite_data.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/models/search_query.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/models/user_auth.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/__init__.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/base.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/batch.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/indices.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/pipeline.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processing/postprocessor.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/__init__.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/indices.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/landsat.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/loader.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/processor/stacker.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/__init__.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/airbus_oneatlas.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/alaska_satellite_facility.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/aws_earth.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/base.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/digitalglobe.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/earth_explorer_additional.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/element84.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/eodag_provider.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/esa_scihub.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/geoserver_generic.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/google_earth_engine.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/inpe_cbers.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/isro_bhuvan.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/jaxa_earth.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/maxar_gbdx.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/nasa_earthdata.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/noaa_big_data.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/opentopography.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/planet.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/planetary_computer.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/sentinel_hub.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/terrabotics.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/providers/usgs.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/sar/_ost.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/sar/_sarxarray.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/sar/processor.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/__init__.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/file_utils.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/gcp_recovery.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/geo_utils.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/logging_setup.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/retry_handler.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/utils/validators.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/viz/__init__.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/viz/map.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch/viz/plot.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/dependency_links.txt +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/entry_points.txt +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/not-zip-safe +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/requires.txt +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/pygeofetch.egg-info/top_level.txt +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/setup.cfg +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/tests/test_extraction.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/tests/test_insar.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/tests/test_pgv_integration_contract.py +0 -0
- {pygeofetch-1.3.0 → pygeofetch-1.4.0}/tests/test_viz_plot.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to PyGeoFetch are documented here.
|
|
4
|
-
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.4.0/).
|
|
5
5
|
|
|
6
|
-
## [1.
|
|
6
|
+
## [1.4.0] — 2026-07-12
|
|
7
7
|
|
|
8
8
|
### Added
|
|
9
9
|
- Federated search across 22+ satellite data providers
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pygeofetch
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Universal satellite data pipeline + geospatial processing platform
|
|
5
|
-
Author-email:
|
|
5
|
+
Author-email: Samuel Appiah Kubi <appiahkubis14@gmail.com>
|
|
6
6
|
License: MIT License
|
|
7
7
|
|
|
8
|
-
Copyright (c)
|
|
8
|
+
Copyright (c) 2026 Samuel Appiah Kubi
|
|
9
9
|
|
|
10
10
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
11
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -606,8 +606,8 @@ jupyter lab
|
|
|
606
606
|
---
|
|
607
607
|
|
|
608
608
|
<p align="center">
|
|
609
|
-
<img src="
|
|
610
|
-
<img src="
|
|
609
|
+
<img src="docs/images/trend_map.png" width="48%" />
|
|
610
|
+
<img src="docs/images/trend_classification.png" width="48%" />
|
|
611
611
|
</p>
|
|
612
612
|
<p align="center"><em>NDVI trend (2018–2024) and severity classification for the Obuasi Municipal District, computed end-to-end with PyGeoFetch — real USGS Landsat data, boundary-clipped before processing.</em></p>
|
|
613
613
|
|
|
@@ -471,8 +471,8 @@ jupyter lab
|
|
|
471
471
|
---
|
|
472
472
|
|
|
473
473
|
<p align="center">
|
|
474
|
-
<img src="
|
|
475
|
-
<img src="
|
|
474
|
+
<img src="docs/images/trend_map.png" width="48%" />
|
|
475
|
+
<img src="docs/images/trend_classification.png" width="48%" />
|
|
476
476
|
</p>
|
|
477
477
|
<p align="center"><em>NDVI trend (2018–2024) and severity classification for the Obuasi Municipal District, computed end-to-end with PyGeoFetch — real USGS Landsat data, boundary-clipped before processing.</em></p>
|
|
478
478
|
|
|
@@ -707,15 +707,63 @@ class Preprocessor:
|
|
|
707
707
|
transform_geom(geometry_crs, src.crs, shp) for shp in shapes
|
|
708
708
|
]
|
|
709
709
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
710
|
+
try:
|
|
711
|
+
out_image, out_transform = rasterio_mask(
|
|
712
|
+
src, shapes, crop=True, all_touched=all_touched, nodata=0
|
|
713
|
+
)
|
|
714
|
+
except ValueError as exc:
|
|
715
|
+
if "do not overlap" not in str(exc):
|
|
716
|
+
raise
|
|
717
|
+
# A near-miss (off by a pixel or two), not a gross CRS
|
|
718
|
+
# mismatch — the geometry and raster ARE in the same CRS
|
|
719
|
+
# and roughly the same place, but floating-point precision
|
|
720
|
+
# in the reprojection, or the AOI sitting right at the
|
|
721
|
+
# edge of the raster's real coverage, can produce a
|
|
722
|
+
# bounding window that misses by a sliver. Retry once with
|
|
723
|
+
# a small buffer (a few pixels' worth, computed from the
|
|
724
|
+
# raster's own resolution) before giving up — a standard,
|
|
725
|
+
# safe technique for exactly this class of edge case.
|
|
726
|
+
from shapely.geometry import shape as _shp_shape
|
|
727
|
+
|
|
728
|
+
px_size = abs(src.transform[0])
|
|
729
|
+
buffer_dist = px_size * 3
|
|
730
|
+
buffered_shapes = [
|
|
731
|
+
_shp_shape(s).buffer(buffer_dist).__geo_interface__
|
|
732
|
+
for s in shapes
|
|
733
|
+
]
|
|
734
|
+
try:
|
|
735
|
+
out_image, out_transform = rasterio_mask(
|
|
736
|
+
src, buffered_shapes, crop=True, all_touched=all_touched, nodata=0
|
|
737
|
+
)
|
|
738
|
+
logger.warning(
|
|
739
|
+
f"clip(): initial geometry missed the raster by a "
|
|
740
|
+
f"sliver — succeeded after a {buffer_dist:.1f}m "
|
|
741
|
+
f"buffer retry. Raster bounds: {tuple(src.bounds)}, "
|
|
742
|
+
f"CRS: {src.crs}."
|
|
743
|
+
)
|
|
744
|
+
except ValueError:
|
|
745
|
+
# Genuinely doesn't overlap even with a buffer — surface
|
|
746
|
+
# the actual bounds so this is diagnosable directly from
|
|
747
|
+
# the error, instead of a bare "do not overlap" message.
|
|
748
|
+
geom_bounds = _shp_shape(shapes[0]).bounds
|
|
749
|
+
raise ValueError(
|
|
750
|
+
f"Input shapes do not overlap raster, even after a "
|
|
751
|
+
f"buffer retry. Raster bounds: {tuple(src.bounds)} "
|
|
752
|
+
f"(CRS: {src.crs}). Geometry bounds (after any CRS "
|
|
753
|
+
f"reprojection): {geom_bounds}. If these are wildly "
|
|
754
|
+
f"different, the raster's real coverage genuinely "
|
|
755
|
+
f"doesn't include this AOI (check you have the "
|
|
756
|
+
f"right scene/tile) — if they're close, this may be "
|
|
757
|
+
f"a genuine precision edge case worth reporting."
|
|
758
|
+
) from exc
|
|
759
|
+
|
|
713
760
|
profile = src.profile.copy()
|
|
714
761
|
profile.update(
|
|
715
762
|
dtype=out_image.dtype,
|
|
716
763
|
height=out_image.shape[1],
|
|
717
764
|
width=out_image.shape[2],
|
|
718
765
|
transform=out_transform,
|
|
766
|
+
nodata=0,
|
|
719
767
|
)
|
|
720
768
|
with rasterio.open(out_path, "w", **profile) as dst:
|
|
721
769
|
dst.write(out_image)
|
|
@@ -826,11 +874,13 @@ class Preprocessor:
|
|
|
826
874
|
input: str | Path,
|
|
827
875
|
resolution: float | None = None,
|
|
828
876
|
scale_factor: float | None = None,
|
|
877
|
+
reference: str | Path | None = None,
|
|
829
878
|
method: str = "bilinear",
|
|
830
879
|
output: str | None = None,
|
|
831
880
|
) -> ProcessingResult:
|
|
832
881
|
"""
|
|
833
|
-
Resample raster to a different spatial resolution
|
|
882
|
+
Resample raster to a different spatial resolution, or align it to
|
|
883
|
+
exactly match another raster's grid.
|
|
834
884
|
|
|
835
885
|
Args:
|
|
836
886
|
input: Input raster.
|
|
@@ -838,8 +888,23 @@ class Preprocessor:
|
|
|
838
888
|
For geographic CRS, use degrees; for projected, metres.
|
|
839
889
|
scale_factor: Alternatively, a scale factor (0.5 = half resolution,
|
|
840
890
|
2.0 = double resolution). Cannot combine with resolution.
|
|
891
|
+
reference: Alternatively, another raster to match exactly —
|
|
892
|
+
same shape, transform, and CRS as this file, not
|
|
893
|
+
just the same resolution. Two rasters independently
|
|
894
|
+
resampled to "the same resolution" can still have
|
|
895
|
+
different origins/extents and fail to line up
|
|
896
|
+
pixel-for-pixel; `reference` guarantees they do.
|
|
897
|
+
The real, common case this solves: combining
|
|
898
|
+
results from two different sensors (e.g. Sentinel-1
|
|
899
|
+
SAR at ~10m native resolution and Landsat optical
|
|
900
|
+
at 30m) that need to sit on identical pixels before
|
|
901
|
+
being compared or combined. Cannot combine with
|
|
902
|
+
resolution or scale_factor.
|
|
841
903
|
method: ``"nearest"``, ``"bilinear"``, ``"cubic"``, ``"lanczos"``,
|
|
842
|
-
``"average"``.
|
|
904
|
+
``"average"``. Use ``"nearest"`` for categorical/
|
|
905
|
+
classified data (class codes, masks) — bilinear
|
|
906
|
+
and the other continuous-data methods will blend
|
|
907
|
+
adjacent classes into meaningless fractional values.
|
|
843
908
|
output: Output path.
|
|
844
909
|
|
|
845
910
|
Example::
|
|
@@ -848,8 +913,13 @@ class Preprocessor:
|
|
|
848
913
|
result = client.preprocess.resample("B02_10m.tif", resolution=30)
|
|
849
914
|
# Downsample by factor of 2
|
|
850
915
|
result = client.preprocess.resample("scene.tif", scale_factor=0.5)
|
|
916
|
+
# Align a SAR classification mask onto an optical raster's exact grid
|
|
917
|
+
result = client.preprocess.resample(
|
|
918
|
+
"sar_mask.tif", reference="optical_classified.tif", method="nearest",
|
|
919
|
+
)
|
|
851
920
|
"""
|
|
852
921
|
rasterio = _require_rasterio()
|
|
922
|
+
np = _require_numpy()
|
|
853
923
|
from rasterio.enums import Resampling as RS
|
|
854
924
|
|
|
855
925
|
rs_map = {
|
|
@@ -862,6 +932,46 @@ class Preprocessor:
|
|
|
862
932
|
rs_method = rs_map.get(method, RS.bilinear)
|
|
863
933
|
|
|
864
934
|
inp = Path(input)
|
|
935
|
+
|
|
936
|
+
if reference is not None:
|
|
937
|
+
if resolution is not None or scale_factor is not None:
|
|
938
|
+
msg = "Provide only one of resolution, scale_factor, or reference"
|
|
939
|
+
raise ValueError(msg)
|
|
940
|
+
|
|
941
|
+
from rasterio.warp import reproject as _warp_reproject
|
|
942
|
+
|
|
943
|
+
out_path = _resolve_output(inp, output, "aligned")
|
|
944
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
945
|
+
|
|
946
|
+
with rasterio.open(reference) as ref_src:
|
|
947
|
+
ref_transform, ref_crs = ref_src.transform, ref_src.crs
|
|
948
|
+
new_h, new_w = ref_src.height, ref_src.width
|
|
949
|
+
|
|
950
|
+
with rasterio.open(inp) as src:
|
|
951
|
+
profile = src.profile.copy()
|
|
952
|
+
profile.update(height=new_h, width=new_w, transform=ref_transform, crs=ref_crs)
|
|
953
|
+
data = np.zeros((src.count, new_h, new_w), dtype=src.dtypes[0])
|
|
954
|
+
for band_idx in range(1, src.count + 1):
|
|
955
|
+
_warp_reproject(
|
|
956
|
+
source=rasterio.band(src, band_idx),
|
|
957
|
+
destination=data[band_idx - 1],
|
|
958
|
+
src_transform=src.transform, src_crs=src.crs,
|
|
959
|
+
dst_transform=ref_transform, dst_crs=ref_crs,
|
|
960
|
+
resampling=rs_method,
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
with rasterio.open(out_path, "w", **profile) as dst:
|
|
964
|
+
dst.write(data)
|
|
965
|
+
|
|
966
|
+
logger.info(f"Aligned to reference grid → {new_w}×{new_h} → {out_path}")
|
|
967
|
+
return ProcessingResult(
|
|
968
|
+
success=True,
|
|
969
|
+
operation="resample",
|
|
970
|
+
input_path=inp,
|
|
971
|
+
output_path=out_path,
|
|
972
|
+
metadata={"method": method, "new_width": new_w, "new_height": new_h, "reference": str(reference)},
|
|
973
|
+
)
|
|
974
|
+
|
|
865
975
|
res_label = str(resolution or scale_factor).replace(".", "p")
|
|
866
976
|
out_path = _resolve_output(inp, output, f"resamp_{res_label}m")
|
|
867
977
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -876,7 +986,7 @@ class Preprocessor:
|
|
|
876
986
|
scale_x = scale_factor
|
|
877
987
|
scale_y = scale_factor
|
|
878
988
|
else:
|
|
879
|
-
msg = "Provide either resolution or
|
|
989
|
+
msg = "Provide either resolution, scale_factor, or reference"
|
|
880
990
|
raise ValueError(msg)
|
|
881
991
|
|
|
882
992
|
new_h = max(1, int(src.height * scale_y))
|
|
@@ -1151,4 +1261,4 @@ class Preprocessor:
|
|
|
1151
1261
|
operation=f"composite:{method}",
|
|
1152
1262
|
output_path=out_path,
|
|
1153
1263
|
metadata={"n_inputs": len(inputs), "method": method},
|
|
1154
|
-
)
|
|
1264
|
+
)
|
|
@@ -223,6 +223,7 @@ class SARProcessor:
|
|
|
223
223
|
threshold: float = -15.0,
|
|
224
224
|
output: str | None = None,
|
|
225
225
|
reference: str | Path | None = None,
|
|
226
|
+
detect_direction: str = "decrease",
|
|
226
227
|
) -> ProcessingResult:
|
|
227
228
|
"""
|
|
228
229
|
Flood mapping from SAR backscatter via simple threshold or change detection.
|
|
@@ -230,17 +231,39 @@ class SARProcessor:
|
|
|
230
231
|
Args:
|
|
231
232
|
input: SAR raster in dB (VV or VH polarisation).
|
|
232
233
|
threshold: Backscatter below this = water (typical -15 to -20 dB).
|
|
234
|
+
Also sets the change-detection sensitivity (see
|
|
235
|
+
detect_direction) as abs(threshold * 0.5).
|
|
233
236
|
output: Output binary mask (1=water, 0=land, 255=nodata).
|
|
234
237
|
reference: Optional pre-event reference for change-based detection.
|
|
238
|
+
detect_direction: For change detection only. "decrease" (default)
|
|
239
|
+
flags pixels where backscatter DROPPED — the correct
|
|
240
|
+
signature for open water (smooth surfaces are
|
|
241
|
+
near-specular reflectors). "increase" flags pixels
|
|
242
|
+
where backscatter ROSE — the signature of flooded
|
|
243
|
+
urban/built-up areas, where water at the base of
|
|
244
|
+
buildings creates a double-bounce reflection
|
|
245
|
+
(ground-wall-sensor) stronger than dry ground alone.
|
|
246
|
+
"both" flags either direction — the most robust
|
|
247
|
+
choice when an AOI mixes open water and dense urban
|
|
248
|
+
flooding (e.g. Accra), since a one-directional
|
|
249
|
+
threshold structurally cannot detect the other
|
|
250
|
+
pattern at all, not just detect it poorly.
|
|
235
251
|
|
|
236
252
|
Example::
|
|
237
253
|
|
|
238
254
|
result = client.sar.flood_map("post_event.tif",
|
|
239
|
-
reference="pre_event.tif"
|
|
255
|
+
reference="pre_event.tif",
|
|
256
|
+
detect_direction="both")
|
|
240
257
|
"""
|
|
241
258
|
np = _require_numpy()
|
|
242
259
|
rasterio = _require_rasterio()
|
|
243
260
|
|
|
261
|
+
if detect_direction not in ("decrease", "increase", "both"):
|
|
262
|
+
raise ValueError(
|
|
263
|
+
f"detect_direction must be 'decrease', 'increase', or "
|
|
264
|
+
f"'both', got {detect_direction!r}"
|
|
265
|
+
)
|
|
266
|
+
|
|
244
267
|
inp = Path(input)
|
|
245
268
|
out_path = _resolve_output(inp, output, "flood_map")
|
|
246
269
|
|
|
@@ -251,8 +274,14 @@ class SARProcessor:
|
|
|
251
274
|
|
|
252
275
|
if reference is not None:
|
|
253
276
|
ref_d, _, _ = self._read(Path(reference), ref_shape=data.shape)
|
|
254
|
-
change = ref_d - data
|
|
255
|
-
|
|
277
|
+
change = ref_d - data # positive = backscatter dropped (open water)
|
|
278
|
+
change_threshold = abs(threshold * 0.5)
|
|
279
|
+
if detect_direction == "decrease":
|
|
280
|
+
water_mask = (change > change_threshold) & valid
|
|
281
|
+
elif detect_direction == "increase":
|
|
282
|
+
water_mask = (change < -change_threshold) & valid
|
|
283
|
+
else: # "both"
|
|
284
|
+
water_mask = (np.abs(change) > change_threshold) & valid
|
|
256
285
|
else:
|
|
257
286
|
water_mask = (data < threshold) & valid
|
|
258
287
|
|
|
@@ -276,15 +305,23 @@ class SARProcessor:
|
|
|
276
305
|
dst.write(result[None, :, :])
|
|
277
306
|
|
|
278
307
|
logger.info("Flood map: %.1f%% water → %s", water_pct, out_path.name)
|
|
308
|
+
metadata = {
|
|
309
|
+
"threshold_db": threshold,
|
|
310
|
+
"water_pct": round(float(water_pct), 2),
|
|
311
|
+
}
|
|
312
|
+
if reference is not None:
|
|
313
|
+
metadata["detect_direction"] = detect_direction
|
|
314
|
+
decrease_pct = 100 * np.sum((change > change_threshold) & valid) / (np.sum(valid) + 1e-10)
|
|
315
|
+
increase_pct = 100 * np.sum((change < -change_threshold) & valid) / (np.sum(valid) + 1e-10)
|
|
316
|
+
metadata["decrease_pct"] = round(float(decrease_pct), 2)
|
|
317
|
+
metadata["increase_pct"] = round(float(increase_pct), 2)
|
|
318
|
+
|
|
279
319
|
return ProcessingResult(
|
|
280
320
|
success=True,
|
|
281
321
|
operation="flood_map",
|
|
282
322
|
input_path=inp,
|
|
283
323
|
output_path=out_path,
|
|
284
|
-
metadata=
|
|
285
|
-
"threshold_db": threshold,
|
|
286
|
-
"water_pct": round(float(water_pct), 2),
|
|
287
|
-
},
|
|
324
|
+
metadata=metadata,
|
|
288
325
|
)
|
|
289
326
|
|
|
290
327
|
# ── Coherence ────────────────────────────────────────────────────────
|
|
@@ -366,4 +403,4 @@ class SARProcessor:
|
|
|
366
403
|
operation="coherence",
|
|
367
404
|
output_path=out_path,
|
|
368
405
|
metadata={"mean_coherence": round(mean_coh, 4), "window": window},
|
|
369
|
-
)
|
|
406
|
+
)
|
|
@@ -127,6 +127,7 @@ class TimeSeriesAnalyzer:
|
|
|
127
127
|
self,
|
|
128
128
|
date_bands: Dict[str, Dict[str, Union[str, Path]]],
|
|
129
129
|
precomputed: bool = False,
|
|
130
|
+
align_grids: bool = True,
|
|
130
131
|
) -> IndexTimeStack:
|
|
131
132
|
"""
|
|
132
133
|
Compute the configured index for every date and stack the results
|
|
@@ -141,14 +142,21 @@ class TimeSeriesAnalyzer:
|
|
|
141
142
|
but without keeping the profile for zonal use).
|
|
142
143
|
precomputed: Treat date_bands values as ready-made single-band
|
|
143
144
|
index rasters instead of raw band dicts.
|
|
145
|
+
align_grids: If a date's raster doesn't share the first date's
|
|
146
|
+
grid (different shape, transform, or CRS), reproject
|
|
147
|
+
it onto the first date's grid automatically rather
|
|
148
|
+
than raising. Default True. This is a real, common
|
|
149
|
+
case: different acquisitions of the same AOI can
|
|
150
|
+
come from different satellite scene footprints
|
|
151
|
+
(tile boundaries, orbit tracks), especially for
|
|
152
|
+
elongated or irregularly-shaped AOIs, and end up
|
|
153
|
+
with slightly different pixel grids after clipping
|
|
154
|
+
even though they cover (nearly) the same area. Set
|
|
155
|
+
False to restore the old strict behaviour (raise on
|
|
156
|
+
any mismatch) if you specifically want that.
|
|
144
157
|
|
|
145
158
|
Returns:
|
|
146
159
|
IndexTimeStack — sorted by date, with .as_xarray() available.
|
|
147
|
-
|
|
148
|
-
All dates must share the same grid (same CRS, transform, shape) —
|
|
149
|
-
typically true for repeat acquisitions of the same AOI from the
|
|
150
|
-
same sensor. Reproject/resample first if that's not the case for
|
|
151
|
-
your data (see the reproject post-process action).
|
|
152
160
|
"""
|
|
153
161
|
np = _require_numpy()
|
|
154
162
|
rasterio = _require_rasterio()
|
|
@@ -159,40 +167,68 @@ class TimeSeriesAnalyzer:
|
|
|
159
167
|
arrays = []
|
|
160
168
|
profile = None
|
|
161
169
|
ref_shape = None
|
|
170
|
+
ref_transform = None
|
|
171
|
+
ref_crs = None
|
|
162
172
|
|
|
163
173
|
si = SpectralIndex(prefer_spyndex=False) if not precomputed else None
|
|
164
174
|
|
|
175
|
+
def _read_aligned(path, date, label):
|
|
176
|
+
nonlocal profile, ref_shape, ref_transform, ref_crs
|
|
177
|
+
with rasterio.open(path) as src:
|
|
178
|
+
arr = src.read(1).astype(np.float32)
|
|
179
|
+
if profile is None:
|
|
180
|
+
profile = src.profile.copy()
|
|
181
|
+
ref_shape = arr.shape
|
|
182
|
+
ref_transform = src.transform
|
|
183
|
+
ref_crs = src.crs
|
|
184
|
+
return arr
|
|
185
|
+
|
|
186
|
+
same_grid = (
|
|
187
|
+
arr.shape == ref_shape
|
|
188
|
+
and src.transform == ref_transform
|
|
189
|
+
and src.crs == ref_crs
|
|
190
|
+
)
|
|
191
|
+
if same_grid:
|
|
192
|
+
return arr
|
|
193
|
+
|
|
194
|
+
if not align_grids:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"{date}/{label}: grid {arr.shape} doesn't match the "
|
|
197
|
+
f"first date's grid {ref_shape}. All dates must "
|
|
198
|
+
f"share the same grid, or call with align_grids=True "
|
|
199
|
+
f"(the default) to align automatically."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
from rasterio.warp import reproject, Resampling
|
|
203
|
+
|
|
204
|
+
logger.info(
|
|
205
|
+
"%s/%s: grid %s differs from the first date's %s — "
|
|
206
|
+
"reprojecting onto the first date's grid automatically "
|
|
207
|
+
"(align_grids=True). This is expected when different "
|
|
208
|
+
"acquisitions come from different scene footprints over "
|
|
209
|
+
"the same AOI.",
|
|
210
|
+
date, label, arr.shape, ref_shape,
|
|
211
|
+
)
|
|
212
|
+
aligned = np.full(ref_shape, np.nan, dtype=np.float32)
|
|
213
|
+
reproject(
|
|
214
|
+
source=arr, destination=aligned,
|
|
215
|
+
src_transform=src.transform, src_crs=src.crs,
|
|
216
|
+
dst_transform=ref_transform, dst_crs=ref_crs,
|
|
217
|
+
resampling=Resampling.bilinear, src_nodata=np.nan, dst_nodata=np.nan,
|
|
218
|
+
)
|
|
219
|
+
return aligned
|
|
220
|
+
|
|
165
221
|
for date in dates:
|
|
166
222
|
entry = date_bands[date]
|
|
167
223
|
|
|
168
224
|
if precomputed:
|
|
169
|
-
|
|
170
|
-
with rasterio.open(path) as src:
|
|
171
|
-
arr = src.read(1).astype(np.float32)
|
|
172
|
-
if profile is None:
|
|
173
|
-
profile = src.profile.copy()
|
|
174
|
-
ref_shape = arr.shape
|
|
175
|
-
elif arr.shape != ref_shape:
|
|
176
|
-
raise ValueError(
|
|
177
|
-
f"{date}: shape {arr.shape} doesn't match the "
|
|
178
|
-
f"first date's shape {ref_shape}. All dates must "
|
|
179
|
-
f"share the same grid — reproject/resample first."
|
|
180
|
-
)
|
|
225
|
+
arr = _read_aligned(Path(entry), date, "index") # type: ignore[arg-type]
|
|
181
226
|
else:
|
|
182
227
|
band_arrays = {}
|
|
183
228
|
for band_name, band_path in entry.items():
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
profile = src.profile.copy()
|
|
188
|
-
ref_shape = arr.shape
|
|
189
|
-
elif arr.shape != ref_shape:
|
|
190
|
-
raise ValueError(
|
|
191
|
-
f"{date}/{band_name}: shape {arr.shape} doesn't "
|
|
192
|
-
f"match the first date's shape {ref_shape}. All "
|
|
193
|
-
f"bands/dates must share the same grid."
|
|
194
|
-
)
|
|
195
|
-
band_arrays[band_name.upper()] = arr
|
|
229
|
+
band_arrays[band_name.upper()] = _read_aligned(
|
|
230
|
+
Path(band_path), date, band_name
|
|
231
|
+
)
|
|
196
232
|
arr = np.asarray(si.compute(self.index_name, **band_arrays), dtype=np.float32)
|
|
197
233
|
|
|
198
234
|
arrays.append(arr)
|
|
@@ -397,4 +433,4 @@ class TimeSeriesAnalyzer:
|
|
|
397
433
|
with np.errstate(divide="ignore", invalid="ignore"):
|
|
398
434
|
z = np.where(baseline_std > 0, (target_arr - baseline_mean) / baseline_std, np.nan)
|
|
399
435
|
|
|
400
|
-
return z
|
|
436
|
+
return z
|
|
@@ -338,7 +338,35 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
338
338
|
if query.end_date:
|
|
339
339
|
filters.append(f"ContentDate/Start le {query.end_date}T23:59:59.000Z")
|
|
340
340
|
|
|
341
|
-
|
|
341
|
+
# Prefer a real polygon spatial filter over a bounding-box
|
|
342
|
+
# approximation when a geometry is given. Previously query.geometry
|
|
343
|
+
# was never checked here at all — only query.bbox — meaning any
|
|
344
|
+
# search using geometry= (not bbox=) had NO spatial constraint
|
|
345
|
+
# applied whatsoever: the query scanned Copernicus's entire global
|
|
346
|
+
# archive for the date/product-type/polarisation criteria, sorted
|
|
347
|
+
# by most-recent-first, with location playing no role at all. This
|
|
348
|
+
# is confirmed to be the actual explanation for a real search
|
|
349
|
+
# returning a Sentinel-1 scene over the Yucatán Peninsula, Mexico
|
|
350
|
+
# for a query intended for Accra, Ghana — not a GCP-reading or
|
|
351
|
+
# georeferencing bug downstream, which was independently tested
|
|
352
|
+
# and ruled out. This is the exact same bug class (a field never
|
|
353
|
+
# checked at all) found and fixed for USGS earlier — it just
|
|
354
|
+
# hadn't been checked for Copernicus specifically until now.
|
|
355
|
+
geometry = getattr(query, "geometry", None)
|
|
356
|
+
if geometry:
|
|
357
|
+
geom_type = geometry.get("type", "Polygon")
|
|
358
|
+
coordinates = geometry.get("coordinates")
|
|
359
|
+
if coordinates and geom_type == "Polygon":
|
|
360
|
+
# OData.CSC.Intersects expects a WKT POLYGON — build it
|
|
361
|
+
# directly from the GeoJSON ring, not a bbox approximation,
|
|
362
|
+
# so search results are constrained to the real AOI shape.
|
|
363
|
+
ring = coordinates[0]
|
|
364
|
+
coord_str = ",".join(f"{lon} {lat}" for lon, lat in ring)
|
|
365
|
+
polygon = f"POLYGON(({coord_str}))"
|
|
366
|
+
filters.append(
|
|
367
|
+
f"OData.CSC.Intersects(area=geography'SRID=4326;{polygon}')"
|
|
368
|
+
)
|
|
369
|
+
elif query.bbox:
|
|
342
370
|
bbox = query.bbox
|
|
343
371
|
polygon = (
|
|
344
372
|
f"POLYGON(("
|
|
@@ -727,10 +755,24 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
727
755
|
for attempt in range(1, max_attempts + 1):
|
|
728
756
|
try:
|
|
729
757
|
start_time = time.time()
|
|
758
|
+
|
|
759
|
+
# Resume from where the previous attempt left off, rather
|
|
760
|
+
# than deleting the partial file and restarting the whole
|
|
761
|
+
# download from zero on every retry — confirmed this was
|
|
762
|
+
# happening: for a multi-GB Sentinel-1 product, a dropped
|
|
763
|
+
# connection at 1GB+ into the transfer meant every retry
|
|
764
|
+
# re-downloaded that same 1GB+ again before even reaching
|
|
765
|
+
# new data, on a connection that had just shown it
|
|
766
|
+
# couldn't sustain the transfer in the first place.
|
|
767
|
+
existing_bytes = output_file.stat().st_size if output_file.exists() else 0
|
|
768
|
+
headers = {"Authorization": f"Bearer {self._session.access_token}"} # type: ignore
|
|
769
|
+
if existing_bytes > 0:
|
|
770
|
+
headers["Range"] = f"bytes={existing_bytes}-"
|
|
771
|
+
|
|
730
772
|
with httpx.stream(
|
|
731
773
|
"GET",
|
|
732
774
|
href,
|
|
733
|
-
headers=
|
|
775
|
+
headers=headers,
|
|
734
776
|
timeout=options.timeout_seconds,
|
|
735
777
|
follow_redirects=True,
|
|
736
778
|
) as resp:
|
|
@@ -749,17 +791,33 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
749
791
|
response=resp,
|
|
750
792
|
)
|
|
751
793
|
self._handle_http_error(resp)
|
|
794
|
+
|
|
795
|
+
# CDSE is documented to sometimes ignore Range headers
|
|
796
|
+
# and return the full file (200) instead of resuming
|
|
797
|
+
# (206) — check which actually happened rather than
|
|
798
|
+
# assume the Range request was honoured.
|
|
799
|
+
resumed = existing_bytes > 0 and resp.status_code == 206
|
|
800
|
+
if existing_bytes > 0 and resp.status_code == 200:
|
|
801
|
+
# Server sent the whole file despite the Range
|
|
802
|
+
# request — start writing from scratch, not append,
|
|
803
|
+
# or the file would be corrupted with duplicated
|
|
804
|
+
# content at the front.
|
|
805
|
+
existing_bytes = 0
|
|
806
|
+
|
|
752
807
|
total_bytes = int(resp.headers.get("content-length", 0))
|
|
753
|
-
|
|
808
|
+
if resumed:
|
|
809
|
+
total_bytes += existing_bytes
|
|
810
|
+
bytes_written = existing_bytes
|
|
754
811
|
chunk_t0 = time.time()
|
|
755
|
-
|
|
812
|
+
write_mode = "ab" if resumed else "wb"
|
|
813
|
+
with open(output_file, write_mode) as f:
|
|
756
814
|
for chunk in resp.iter_bytes(
|
|
757
815
|
chunk_size=int(options.chunk_size_mb * 1024 * 1024)
|
|
758
816
|
):
|
|
759
817
|
f.write(chunk)
|
|
760
818
|
bytes_written += len(chunk)
|
|
761
819
|
elapsed = time.time() - chunk_t0
|
|
762
|
-
speed = bytes_written / elapsed if elapsed > 0 else 0.0
|
|
820
|
+
speed = (bytes_written - existing_bytes) / elapsed if elapsed > 0 else 0.0
|
|
763
821
|
report_download_progress(bytes_written, total_bytes, speed)
|
|
764
822
|
|
|
765
823
|
duration = time.time() - start_time
|
|
@@ -782,15 +840,37 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
782
840
|
except Exception as exc:
|
|
783
841
|
last_error = str(exc)
|
|
784
842
|
last_error_type = type(exc).__name__
|
|
785
|
-
|
|
843
|
+
# No longer unconditionally deleting the partial file here —
|
|
844
|
+
# the next attempt (or the whole retry loop, if this was
|
|
845
|
+
# the final attempt) needs it intact to resume from.
|
|
786
846
|
if attempt < max_attempts:
|
|
787
|
-
backoff
|
|
847
|
+
# Base backoff scaled by how much has already been
|
|
848
|
+
# downloaded — a connection that just dropped a
|
|
849
|
+
# multi-GB transfer needs meaningfully more recovery
|
|
850
|
+
# time than the previous flat min(2**attempt, 30)
|
|
851
|
+
# gave it (as little as 1-2 seconds on early attempts,
|
|
852
|
+
# confirmed against a real 1GB+ download that had
|
|
853
|
+
# just timed out).
|
|
854
|
+
current_size_gb = (
|
|
855
|
+
output_file.stat().st_size / (1024**3)
|
|
856
|
+
if output_file.exists()
|
|
857
|
+
else 0
|
|
858
|
+
)
|
|
859
|
+
size_factor = max(1.0, current_size_gb * 10)
|
|
860
|
+
backoff = min(15 * size_factor * attempt, 120)
|
|
788
861
|
self._logger.warning(
|
|
789
862
|
f"Download attempt {attempt}/{max_attempts} failed for "
|
|
790
|
-
f"{data.id}: {last_error}.
|
|
863
|
+
f"{data.id}: {last_error}. Resuming from "
|
|
864
|
+
f"{output_file.stat().st_size / (1024**2):.0f}MB in "
|
|
865
|
+
f"{backoff:.0f}s..."
|
|
866
|
+
if output_file.exists()
|
|
867
|
+
else f"Download attempt {attempt}/{max_attempts} failed for "
|
|
868
|
+
f"{data.id}: {last_error}. Retrying in {backoff:.0f}s..."
|
|
791
869
|
)
|
|
792
870
|
time.sleep(backoff)
|
|
793
871
|
|
|
872
|
+
# Final failure — clean up only now, not on every intermediate retry
|
|
873
|
+
output_file.unlink(missing_ok=True)
|
|
794
874
|
return DownloadResult(
|
|
795
875
|
status=DownloadStatus.FAILED,
|
|
796
876
|
data_id=data.id,
|
|
@@ -917,4 +997,4 @@ class CopernicusProvider(AbstractBaseProvider):
|
|
|
917
997
|
"note": "Copernicus provides free access; rate limits may apply",
|
|
918
998
|
"docs": "https://dataspace.copernicus.eu/terms-and-conditions",
|
|
919
999
|
},
|
|
920
|
-
)
|
|
1000
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyGeoFetch SAR — optional SAR processing layer.
|
|
3
|
+
|
|
4
|
+
Lightweight backend (sarxarray): pip install "pygeofetch[sar]"
|
|
5
|
+
Heavy backend (SNAP via OST): pip install "pygeofetch[ost]"
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from pygeofetch.sar import SARProcessor, GRDExtractor
|
|
10
|
+
|
|
11
|
+
extractor = GRDExtractor(polarisation="VV")
|
|
12
|
+
vv_path = extractor.extract_band(download_result, output_dir="./data")
|
|
13
|
+
|
|
14
|
+
proc = SARProcessor(backend="sarxarray") # lightweight
|
|
15
|
+
result = proc.calibrate(str(vv_path), output_type="sigma0")
|
|
16
|
+
result = proc.despeckle("calibrated.tif", filter="lee")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from pygeofetch.sar.extraction import GRDExtractor, georeference_via_gcps_if_needed
|
|
20
|
+
from pygeofetch.sar.processor import SARProcessor
|
|
21
|
+
|
|
22
|
+
__all__ = ["SARProcessor", "GRDExtractor", "georeference_via_gcps_if_needed"]
|