PyGeoFetch 1.6.0__tar.gz → 1.8.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.6.0 → pygeofetch-1.8.0}/CHANGELOG.md +2 -2
- {pygeofetch-1.6.0/pygeofetch.egg-info → pygeofetch-1.8.0}/PKG-INFO +3 -3
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/__init__.py +1 -1
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/__init__.py +46 -1
- pygeofetch-1.8.0/pygeofetch/insar/annotation.py +153 -0
- pygeofetch-1.8.0/pygeofetch/insar/coregister.py +339 -0
- pygeofetch-1.8.0/pygeofetch/insar/geolocation.py +478 -0
- pygeofetch-1.8.0/pygeofetch/insar/gpu.py +106 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/interferogram.py +174 -19
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/timeseries.py +57 -5
- pygeofetch-1.8.0/pygeofetch/insar/validate.py +251 -0
- pygeofetch-1.8.0/pygeofetch/insar/visualize.py +226 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/meta.yaml +1 -1
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/airbus_oneatlas.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/alaska_satellite_facility.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/digitalglobe.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/earth_explorer_additional.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/esa_scihub.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/geoserver_generic.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/google_earth_engine.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/inpe_cbers.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/jaxa_earth.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/maxar_gbdx.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/noaa_big_data.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/planet.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/terrabotics.py +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/viz/plot.py +381 -1
- {pygeofetch-1.6.0 → pygeofetch-1.8.0/pygeofetch.egg-info}/PKG-INFO +3 -3
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/SOURCES.txt +6 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/requires.txt +2 -2
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pyproject.toml +3 -3
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/LICENSE +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/MANIFEST.in +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/README.md +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/__init__.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/auth_commands.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/config_commands.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/download_commands.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/index_commands.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/main.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/pipeline_process_commands.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/postprocess_commands.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/preprocess_commands.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/sar_commands.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/cli/search_commands.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/config/__init__.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/config/defaults.yaml +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/config/settings.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/__init__.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/authenticator.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/cache_manager.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/downloader.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/engine.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/logging.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/orbits.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/scheduler.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/core/searcher.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/atmosphere.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/extraction.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/insar/unwrap.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/models/__init__.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/models/download_task.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/models/satellite_data.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/models/search_query.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/models/user_auth.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/__init__.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/base.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/batch.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/indices.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/pipeline.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/postprocessor.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/preprocessor.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processing/sar.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/__init__.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/indices.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/landsat.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/loader.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/stacker.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/processor/timeseries.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/__init__.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/aws_earth.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/base.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/copernicus.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/element84.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/eodag_provider.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/isro_bhuvan.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/nasa_earthdata.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/nasa_earthdata_cloud.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/opentopography.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/planetary_computer.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/sentinel_hub.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/providers/usgs.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/__init__.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/_native.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/_ost.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/_sarxarray.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/extraction.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/sar/processor.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/__init__.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/file_utils.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/gcp_recovery.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/geo_utils.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/logging_setup.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/retry_handler.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/utils/validators.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/viz/__init__.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch/viz/map.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/dependency_links.txt +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/entry_points.txt +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/not-zip-safe +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/pygeofetch.egg-info/top_level.txt +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/setup.cfg +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/tests/test_extraction.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/tests/test_insar.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.0}/tests/test_pgv_integration_contract.py +0 -0
- {pygeofetch-1.6.0 → pygeofetch-1.8.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.8.0/).
|
|
5
5
|
|
|
6
|
-
## [1.
|
|
6
|
+
## [1.8.0] — 2026-07-12
|
|
7
7
|
|
|
8
8
|
### Added
|
|
9
9
|
- Federated search across 22+ satellite data providers
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pygeofetch
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: Universal satellite data pipeline + geospatial processing platform
|
|
5
5
|
Author-email: Samuel Appiah Kubi <appiahkubis14@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -94,8 +94,8 @@ Requires-Dist: plotly>=5.14.0; extra == "viz"
|
|
|
94
94
|
Requires-Dist: whitebox>=2.3.0; extra == "viz"
|
|
95
95
|
Requires-Dist: whiteboxgui>=2.3.0; extra == "viz"
|
|
96
96
|
Requires-Dist: geemap>=0.28.0; extra == "viz"
|
|
97
|
-
Requires-Dist: osmnx>=1.
|
|
98
|
-
Requires-Dist: contextily>=1.
|
|
97
|
+
Requires-Dist: osmnx>=1.8.0; extra == "viz"
|
|
98
|
+
Requires-Dist: contextily>=1.8.0; extra == "viz"
|
|
99
99
|
Requires-Dist: xyzservices>=2023.10.0; extra == "viz"
|
|
100
100
|
Requires-Dist: mapclassify>=2.5.0; extra == "viz"
|
|
101
101
|
Requires-Dist: geoplot>=0.5.0; extra == "viz"
|
|
@@ -52,9 +52,39 @@ Usage::
|
|
|
52
52
|
|
|
53
53
|
from pygeofetch.insar.atmosphere import AtmosphericCorrector
|
|
54
54
|
from pygeofetch.insar.extraction import SLCExtractor
|
|
55
|
+
from pygeofetch.insar.gpu import gpu_available
|
|
55
56
|
from pygeofetch.insar.interferogram import InterferogramGenerator, InterferogramResult
|
|
56
57
|
from pygeofetch.insar.timeseries import SBASTimeSeries
|
|
57
58
|
from pygeofetch.insar.unwrap import PhaseUnwrapper
|
|
59
|
+
from pygeofetch.insar.validate import DataValidator, ValidationResult
|
|
60
|
+
from pygeofetch.insar.visualize import (
|
|
61
|
+
visualize_interferogram,
|
|
62
|
+
visualize_timeseries,
|
|
63
|
+
visualize_unwrapped,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Real, orbit-based coregistration components. All exported here are
|
|
67
|
+
# individually verified against known ground truth (see each module's
|
|
68
|
+
# docstring for specifics): annotation.py against ESA's own field-path
|
|
69
|
+
# spec; orbit parsing/interpolation against exact reference values;
|
|
70
|
+
# find_zero_doppler_time against known times from starting guesses up
|
|
71
|
+
# to 30 seconds off; geodetic_to_ecef via round-trip verification.
|
|
72
|
+
# solve_ground_point (in geolocation.py) is deliberately NOT exported
|
|
73
|
+
# here — it has a known, unresolved reliability gap and is not
|
|
74
|
+
# recommended as a primary coregistration path; use
|
|
75
|
+
# compute_offset_field_from_dem() instead, which avoids it entirely.
|
|
76
|
+
from pygeofetch.insar.annotation import SLCGeometry, parse_slc_geometry
|
|
77
|
+
from pygeofetch.insar.geolocation import (
|
|
78
|
+
geodetic_to_ecef,
|
|
79
|
+
find_zero_doppler_time,
|
|
80
|
+
parse_orbit_file,
|
|
81
|
+
interpolate_orbit_state,
|
|
82
|
+
)
|
|
83
|
+
from pygeofetch.insar.coregister import (
|
|
84
|
+
compute_offset_field_from_dem,
|
|
85
|
+
fit_offset_polynomial,
|
|
86
|
+
resample_with_offset_field,
|
|
87
|
+
)
|
|
58
88
|
|
|
59
89
|
__all__ = [
|
|
60
90
|
"InterferogramGenerator",
|
|
@@ -63,4 +93,19 @@ __all__ = [
|
|
|
63
93
|
"SBASTimeSeries",
|
|
64
94
|
"AtmosphericCorrector",
|
|
65
95
|
"SLCExtractor",
|
|
66
|
-
|
|
96
|
+
"DataValidator",
|
|
97
|
+
"ValidationResult",
|
|
98
|
+
"gpu_available",
|
|
99
|
+
"visualize_interferogram",
|
|
100
|
+
"visualize_unwrapped",
|
|
101
|
+
"visualize_timeseries",
|
|
102
|
+
"SLCGeometry",
|
|
103
|
+
"parse_slc_geometry",
|
|
104
|
+
"geodetic_to_ecef",
|
|
105
|
+
"find_zero_doppler_time",
|
|
106
|
+
"parse_orbit_file",
|
|
107
|
+
"interpolate_orbit_state",
|
|
108
|
+
"compute_offset_field_from_dem",
|
|
109
|
+
"fit_offset_polynomial",
|
|
110
|
+
"resample_with_offset_field",
|
|
111
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sentinel-1 annotation XML parsing — real per-pixel acquisition timing.
|
|
3
|
+
|
|
4
|
+
This is the missing link between "a pixel at (row, col) in an extracted
|
|
5
|
+
SLC GeoTIFF" and "the exact acquisition time and slant range needed to
|
|
6
|
+
feed geolocation.py's orbit-based solver" — that information does not
|
|
7
|
+
exist in the GeoTIFF itself (a general raster format); it lives in a
|
|
8
|
+
separate XML file inside the .SAFE archive.
|
|
9
|
+
|
|
10
|
+
Field paths confirmed against ESA's own Mission Performance Centre
|
|
11
|
+
documentation (Thermal Denoising of Products Generated by the S-1 IPF,
|
|
12
|
+
MPC-0392):
|
|
13
|
+
|
|
14
|
+
/product/imageAnnotation/imageInformation/productFirstLineUtcTime
|
|
15
|
+
/product/imageAnnotation/imageInformation/azimuthTimeInterval
|
|
16
|
+
/product/imageAnnotation/imageInformation/slantRangeTime
|
|
17
|
+
/product/generalAnnotation/productInformation/rangeSamplingRate
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import zipfile
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import datetime, timedelta
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional, Union
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("pygeofetch.insar.annotation")
|
|
30
|
+
|
|
31
|
+
SPEED_OF_LIGHT = 299792458.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SLCGeometry:
|
|
36
|
+
"""
|
|
37
|
+
Real per-pixel acquisition geometry for one Sentinel-1 SLC measurement,
|
|
38
|
+
parsed from its annotation XML — everything needed to convert a pixel
|
|
39
|
+
(row, col) into an exact acquisition time and slant range.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
first_line_time: datetime
|
|
43
|
+
azimuth_time_interval_s: float
|
|
44
|
+
near_range_time_s: float
|
|
45
|
+
range_sampling_rate_hz: float
|
|
46
|
+
n_lines: int
|
|
47
|
+
n_columns: int
|
|
48
|
+
|
|
49
|
+
def azimuth_time(self, row: float) -> datetime:
|
|
50
|
+
"""Exact acquisition time of a given (possibly fractional) row."""
|
|
51
|
+
return self.first_line_time + timedelta(
|
|
52
|
+
seconds=row * self.azimuth_time_interval_s
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def range_time(self, col: float) -> float:
|
|
56
|
+
"""Two-way slant range time (seconds) of a given (possibly
|
|
57
|
+
fractional) column."""
|
|
58
|
+
return self.near_range_time_s + col / self.range_sampling_rate_hz
|
|
59
|
+
|
|
60
|
+
def slant_range_m(self, col: float) -> float:
|
|
61
|
+
"""One-way slant range (metres) of a given column."""
|
|
62
|
+
return self.range_time(col) * SPEED_OF_LIGHT / 2.0
|
|
63
|
+
|
|
64
|
+
def row_for_azimuth_time(self, t: datetime) -> float:
|
|
65
|
+
"""Inverse of azimuth_time(): which (fractional) row corresponds
|
|
66
|
+
to a given acquisition time."""
|
|
67
|
+
return (t - self.first_line_time).total_seconds() / self.azimuth_time_interval_s
|
|
68
|
+
|
|
69
|
+
def col_for_range_time(self, range_time_s: float) -> float:
|
|
70
|
+
"""Inverse of range_time(): which (fractional) column corresponds
|
|
71
|
+
to a given two-way range time."""
|
|
72
|
+
return (range_time_s - self.near_range_time_s) * self.range_sampling_rate_hz
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_slc_geometry(
|
|
76
|
+
safe_zip_path: Union[str, Path], member_hint: Optional[str] = None
|
|
77
|
+
) -> SLCGeometry:
|
|
78
|
+
"""
|
|
79
|
+
Parse real per-pixel acquisition geometry from a Sentinel-1 SAFE
|
|
80
|
+
archive's annotation XML.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
safe_zip_path: Path to the downloaded .SAFE.zip archive.
|
|
84
|
+
member_hint: Optional substring to disambiguate which
|
|
85
|
+
annotation file to use when multiple sub-swaths/
|
|
86
|
+
polarisations are present (e.g. "iw2-vv" or the
|
|
87
|
+
measurement filename this geometry corresponds
|
|
88
|
+
to). If None, uses the first annotation XML found.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A real SLCGeometry with the exact timing needed to feed
|
|
92
|
+
geolocation.py's solve_ground_point().
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError if no annotation XML is found, or a required field
|
|
96
|
+
is missing — surfaced clearly rather than returning a
|
|
97
|
+
geometry built from guessed/default values.
|
|
98
|
+
"""
|
|
99
|
+
import xml.etree.ElementTree as ET
|
|
100
|
+
|
|
101
|
+
with zipfile.ZipFile(safe_zip_path) as zf:
|
|
102
|
+
candidates = [
|
|
103
|
+
n for n in zf.namelist()
|
|
104
|
+
if "/annotation/" in n and n.lower().endswith(".xml")
|
|
105
|
+
and "/calibration/" not in n.lower()
|
|
106
|
+
]
|
|
107
|
+
if member_hint:
|
|
108
|
+
filtered = [n for n in candidates if member_hint.lower() in n.lower()]
|
|
109
|
+
if filtered:
|
|
110
|
+
candidates = filtered
|
|
111
|
+
|
|
112
|
+
if not candidates:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"{safe_zip_path}: no annotation XML found — this doesn't "
|
|
115
|
+
f"look like a real Sentinel-1 SAFE archive, or the "
|
|
116
|
+
f"expected internal structure has changed."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
with zf.open(candidates[0]) as f:
|
|
120
|
+
root = ET.parse(f).getroot()
|
|
121
|
+
|
|
122
|
+
def get_text(path: str) -> str:
|
|
123
|
+
elem = root.find(path)
|
|
124
|
+
if elem is None or elem.text is None:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"{safe_zip_path}: required annotation field missing: {path}"
|
|
127
|
+
)
|
|
128
|
+
return elem.text
|
|
129
|
+
|
|
130
|
+
first_line_str = get_text(".//imageAnnotation/imageInformation/productFirstLineUtcTime")
|
|
131
|
+
first_line_time = datetime.fromisoformat(first_line_str)
|
|
132
|
+
|
|
133
|
+
geometry = SLCGeometry(
|
|
134
|
+
first_line_time=first_line_time,
|
|
135
|
+
azimuth_time_interval_s=float(
|
|
136
|
+
get_text(".//imageAnnotation/imageInformation/azimuthTimeInterval")
|
|
137
|
+
),
|
|
138
|
+
near_range_time_s=float(
|
|
139
|
+
get_text(".//imageAnnotation/imageInformation/slantRangeTime")
|
|
140
|
+
),
|
|
141
|
+
range_sampling_rate_hz=float(
|
|
142
|
+
get_text(".//generalAnnotation/productInformation/rangeSamplingRate")
|
|
143
|
+
),
|
|
144
|
+
n_lines=int(get_text(".//imageAnnotation/imageInformation/numberOfLines")),
|
|
145
|
+
n_columns=int(get_text(".//imageAnnotation/imageInformation/numberOfColumns")),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
logger.info(
|
|
149
|
+
"Parsed real SLC geometry from %s: %d x %d, starting %s",
|
|
150
|
+
Path(safe_zip_path).name, geometry.n_lines, geometry.n_columns,
|
|
151
|
+
geometry.first_line_time,
|
|
152
|
+
)
|
|
153
|
+
return geometry
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Real orbit-based coregistration offset field.
|
|
3
|
+
|
|
4
|
+
Combines annotation.py (real per-pixel acquisition timing) and
|
|
5
|
+
geolocation.py (the orbit-based Doppler/Range/Ellipsoid solve) to
|
|
6
|
+
compute a genuine, physically-grounded offset field between a
|
|
7
|
+
reference and secondary SLC — replacing the naive shape-only resample
|
|
8
|
+
that was previously used regardless of whether the two images were
|
|
9
|
+
actually aligned.
|
|
10
|
+
|
|
11
|
+
Method (standard practice, e.g. Kampes, Hanssen & Perski 2003; matches
|
|
12
|
+
what the 2019 URSI paper and the current TOPS coregistration literature
|
|
13
|
+
both describe): compute the real offset at a sparse grid of points
|
|
14
|
+
across the image (not every pixel — solving per pixel would be far
|
|
15
|
+
too slow and offers no real accuracy benefit, since the offset field
|
|
16
|
+
is smooth), fit a low-degree 2D polynomial to that sparse grid, then
|
|
17
|
+
apply the fitted, dense field to resample the secondary image.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Tuple, Union
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("pygeofetch.insar.coregister")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compute_offset_field_from_dem(
|
|
30
|
+
ref_geometry,
|
|
31
|
+
ref_orbit,
|
|
32
|
+
sec_geometry,
|
|
33
|
+
sec_orbit,
|
|
34
|
+
dem_path: Union[str, Path],
|
|
35
|
+
ref_scene_center_time,
|
|
36
|
+
sec_scene_center_time,
|
|
37
|
+
grid_points: int = 7,
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Compute a real, orbit-based offset field using ground points sampled
|
|
41
|
+
directly from a real DEM's own geographic coordinates — not solved
|
|
42
|
+
for via the unreliable solve_ground_point(). This is the
|
|
43
|
+
recommended path: it uses only components verified reliable
|
|
44
|
+
(geodetic_to_ecef, a closed-form conversion with no iteration, and
|
|
45
|
+
find_zero_doppler_time, confirmed robust even from starting guesses
|
|
46
|
+
tens of seconds off), never the 3-equation Doppler/Range/Ellipsoid
|
|
47
|
+
solve that has a known, unresolved reliability gap.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
ref_geometry, sec_geometry: SLCGeometry for each image.
|
|
51
|
+
ref_orbit, sec_orbit: (times, positions, velocities) for each.
|
|
52
|
+
dem_path: A real DEM covering the scene, with real
|
|
53
|
+
geographic (lat/lon) referencing — the same DEM
|
|
54
|
+
already optionally supplied to process_pair()
|
|
55
|
+
for topographic phase removal.
|
|
56
|
+
ref_scene_center_time, sec_scene_center_time: Approximate
|
|
57
|
+
acquisition time for each image (e.g. each
|
|
58
|
+
geometry's azimuth_time() at its own centre row)
|
|
59
|
+
— used as the starting guess for
|
|
60
|
+
find_zero_doppler_time(), which only needs to be
|
|
61
|
+
roughly right (confirmed robust to a 30-second-
|
|
62
|
+
off starting guess in testing).
|
|
63
|
+
grid_points: Sample grid resolution per axis.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
(grid_rows, grid_cols, offset_rows, offset_cols) — same shape
|
|
67
|
+
as compute_offset_field(), ready for fit_offset_polynomial().
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
RuntimeError if too many grid points fail (more than half) —
|
|
71
|
+
a real, actionable signal (e.g. the DEM doesn't actually
|
|
72
|
+
overlap the scene), not silently tolerated.
|
|
73
|
+
"""
|
|
74
|
+
import numpy as np
|
|
75
|
+
import rasterio
|
|
76
|
+
|
|
77
|
+
from pygeofetch.insar.geolocation import geodetic_to_ecef, find_zero_doppler_time
|
|
78
|
+
|
|
79
|
+
with rasterio.open(dem_path) as src:
|
|
80
|
+
dem = src.read(1)
|
|
81
|
+
transform = src.transform
|
|
82
|
+
dem_h, dem_w = dem.shape
|
|
83
|
+
|
|
84
|
+
dem_row_samples = [int(r) for r in _linspace(0, dem_h - 1, grid_points)]
|
|
85
|
+
dem_col_samples = [int(c) for c in _linspace(0, dem_w - 1, grid_points)]
|
|
86
|
+
|
|
87
|
+
grid_rows, grid_cols, offset_rows, offset_cols = [], [], [], []
|
|
88
|
+
n_failed = 0
|
|
89
|
+
n_total = 0
|
|
90
|
+
|
|
91
|
+
for dem_row in dem_row_samples:
|
|
92
|
+
for dem_col in dem_col_samples:
|
|
93
|
+
n_total += 1
|
|
94
|
+
try:
|
|
95
|
+
lon, lat = transform * (dem_col + 0.5, dem_row + 0.5)
|
|
96
|
+
height = float(dem[dem_row, dem_col])
|
|
97
|
+
if not np.isfinite(height):
|
|
98
|
+
height = 0.0
|
|
99
|
+
ground_point = geodetic_to_ecef(lat, lon, height)
|
|
100
|
+
|
|
101
|
+
t_ref = find_zero_doppler_time(
|
|
102
|
+
ref_orbit[0], ref_orbit[1], ref_orbit[2],
|
|
103
|
+
ground_point, ref_scene_center_time,
|
|
104
|
+
)
|
|
105
|
+
t_sec = find_zero_doppler_time(
|
|
106
|
+
sec_orbit[0], sec_orbit[1], sec_orbit[2],
|
|
107
|
+
ground_point, sec_scene_center_time,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
ref_row = ref_geometry.row_for_azimuth_time(t_ref)
|
|
111
|
+
sec_row = sec_geometry.row_for_azimuth_time(t_sec)
|
|
112
|
+
|
|
113
|
+
sat_pos_ref, _ = _interpolate(ref_orbit, t_ref)
|
|
114
|
+
sat_pos_sec, _ = _interpolate(sec_orbit, t_sec)
|
|
115
|
+
range_ref_time = 2 * _distance(sat_pos_ref, ground_point) / 299792458.0
|
|
116
|
+
range_sec_time = 2 * _distance(sat_pos_sec, ground_point) / 299792458.0
|
|
117
|
+
ref_col = ref_geometry.col_for_range_time(range_ref_time)
|
|
118
|
+
sec_col = sec_geometry.col_for_range_time(range_sec_time)
|
|
119
|
+
|
|
120
|
+
if not (0 <= ref_row < ref_geometry.n_lines and 0 <= ref_col < ref_geometry.n_columns):
|
|
121
|
+
continue # this DEM point falls outside the actual SLC extent
|
|
122
|
+
|
|
123
|
+
grid_rows.append(ref_row)
|
|
124
|
+
grid_cols.append(ref_col)
|
|
125
|
+
offset_rows.append(sec_row - ref_row)
|
|
126
|
+
offset_cols.append(sec_col - ref_col)
|
|
127
|
+
except RuntimeError as exc:
|
|
128
|
+
n_failed += 1
|
|
129
|
+
logger.debug("DEM-driven offset field: point (%d, %d) failed: %s", dem_row, dem_col, exc)
|
|
130
|
+
|
|
131
|
+
if n_failed > n_total / 2:
|
|
132
|
+
raise RuntimeError(
|
|
133
|
+
f"DEM-driven offset field: {n_failed}/{n_total} points failed "
|
|
134
|
+
f"— check that the DEM genuinely overlaps the scene and the "
|
|
135
|
+
f"orbit files' validity periods cover both acquisition times."
|
|
136
|
+
)
|
|
137
|
+
if not grid_rows:
|
|
138
|
+
raise RuntimeError(
|
|
139
|
+
"DEM-driven offset field: no valid grid points fell within "
|
|
140
|
+
"the actual SLC extent — check the DEM covers the scene."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
logger.info(
|
|
144
|
+
"DEM-driven offset field: %d/%d points solved successfully",
|
|
145
|
+
len(grid_rows), n_total,
|
|
146
|
+
)
|
|
147
|
+
return grid_rows, grid_cols, offset_rows, offset_cols
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def compute_offset_field(
|
|
151
|
+
ref_geometry,
|
|
152
|
+
ref_orbit,
|
|
153
|
+
sec_geometry,
|
|
154
|
+
sec_orbit,
|
|
155
|
+
image_shape: Tuple[int, int],
|
|
156
|
+
dem_height_m: float = 0.0,
|
|
157
|
+
grid_points: int = 7,
|
|
158
|
+
):
|
|
159
|
+
"""
|
|
160
|
+
Compute a real, orbit-based offset field between a reference and
|
|
161
|
+
secondary SLC image, sampled on a sparse grid and ready for
|
|
162
|
+
polynomial fitting.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
ref_geometry, sec_geometry: SLCGeometry from
|
|
166
|
+
annotation.parse_slc_geometry() for each image.
|
|
167
|
+
ref_orbit, sec_orbit: (times, positions, velocities) from
|
|
168
|
+
geolocation.parse_orbit_file() for each image.
|
|
169
|
+
image_shape: (rows, cols) of the reference image.
|
|
170
|
+
dem_height_m: Constant height above the ellipsoid to use for
|
|
171
|
+
the geolocation solve (0.0 = pure ellipsoid). A
|
|
172
|
+
real per-pixel DEM lookup would be more accurate
|
|
173
|
+
over rugged terrain but isn't wired in here yet.
|
|
174
|
+
grid_points: Number of sample points per axis (grid_points^2
|
|
175
|
+
total ground-point solves) — 7 is a reasonable
|
|
176
|
+
default balancing accuracy and speed; the offset
|
|
177
|
+
field is smooth, so a sparse grid captures it well.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
(grid_rows, grid_cols, offset_rows, offset_cols): four lists of
|
|
181
|
+
equal length — the sample grid positions in the reference image
|
|
182
|
+
and the real, solved offset (secondary - reference) at each,
|
|
183
|
+
in pixels.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
RuntimeError if too many grid points fail to solve (more than
|
|
187
|
+
half) — a real, actionable signal that the orbit data or
|
|
188
|
+
geometry inputs are likely wrong, surfaced clearly rather than
|
|
189
|
+
silently fitting a polynomial to mostly-garbage points.
|
|
190
|
+
"""
|
|
191
|
+
from pygeofetch.insar.geolocation import solve_ground_point, find_zero_doppler_time
|
|
192
|
+
|
|
193
|
+
rows, cols = image_shape
|
|
194
|
+
row_samples = [int(r) for r in _linspace(0, rows - 1, grid_points)]
|
|
195
|
+
col_samples = [int(c) for c in _linspace(0, cols - 1, grid_points)]
|
|
196
|
+
|
|
197
|
+
grid_rows, grid_cols, offset_rows, offset_cols = [], [], [], []
|
|
198
|
+
n_failed = 0
|
|
199
|
+
n_total = 0
|
|
200
|
+
last_ground_point = None # warm-start seed, carried across grid points
|
|
201
|
+
|
|
202
|
+
for row in row_samples:
|
|
203
|
+
for col in col_samples:
|
|
204
|
+
n_total += 1
|
|
205
|
+
try:
|
|
206
|
+
t_ref = ref_geometry.azimuth_time(row)
|
|
207
|
+
range_time = ref_geometry.range_time(col)
|
|
208
|
+
|
|
209
|
+
sat_pos, sat_vel = _interpolate(ref_orbit, t_ref)
|
|
210
|
+
ground_point = solve_ground_point(
|
|
211
|
+
sat_pos, sat_vel, range_time, dem_height_m=dem_height_m,
|
|
212
|
+
initial_guess=last_ground_point,
|
|
213
|
+
)
|
|
214
|
+
last_ground_point = ground_point # seed the next grid point
|
|
215
|
+
|
|
216
|
+
t_sec = find_zero_doppler_time(
|
|
217
|
+
sec_orbit[0], sec_orbit[1], sec_orbit[2], ground_point, t_ref
|
|
218
|
+
)
|
|
219
|
+
sec_row = sec_geometry.row_for_azimuth_time(t_sec)
|
|
220
|
+
|
|
221
|
+
sat_pos_sec, _ = _interpolate(sec_orbit, t_sec)
|
|
222
|
+
range_sec_m = _distance(sat_pos_sec, ground_point)
|
|
223
|
+
range_time_sec = 2 * range_sec_m / 299792458.0
|
|
224
|
+
sec_col = sec_geometry.col_for_range_time(range_time_sec)
|
|
225
|
+
|
|
226
|
+
grid_rows.append(row)
|
|
227
|
+
grid_cols.append(col)
|
|
228
|
+
offset_rows.append(sec_row - row)
|
|
229
|
+
offset_cols.append(sec_col - col)
|
|
230
|
+
except RuntimeError as exc:
|
|
231
|
+
n_failed += 1
|
|
232
|
+
logger.debug("Offset field: grid point (%d, %d) failed: %s", row, col, exc)
|
|
233
|
+
# Don't carry a failed point's (non-existent) result forward
|
|
234
|
+
# as the next warm-start seed -- last_ground_point simply
|
|
235
|
+
# stays at its last successful value, which is still a
|
|
236
|
+
# reasonable seed for the next point.
|
|
237
|
+
|
|
238
|
+
if n_failed > n_total / 2:
|
|
239
|
+
raise RuntimeError(
|
|
240
|
+
f"Offset field computation: {n_failed}/{n_total} grid points "
|
|
241
|
+
f"failed to solve — this usually means the orbit data doesn't "
|
|
242
|
+
f"actually correspond to this scene, or the annotation timing "
|
|
243
|
+
f"is wrong, not a transient numerical issue. Check that the "
|
|
244
|
+
f"orbit file's validity period actually covers this "
|
|
245
|
+
f"acquisition's real timestamp."
|
|
246
|
+
)
|
|
247
|
+
if n_failed > 0:
|
|
248
|
+
logger.warning(
|
|
249
|
+
"Offset field: %d/%d grid points failed to solve (proceeding "
|
|
250
|
+
"with the remaining %d) — the polynomial fit below is still "
|
|
251
|
+
"valid, but based on fewer points than requested.",
|
|
252
|
+
n_failed, n_total, n_total - n_failed,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return grid_rows, grid_cols, offset_rows, offset_cols
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def fit_offset_polynomial(grid_rows, grid_cols, offsets, degree: int = 1):
|
|
259
|
+
"""
|
|
260
|
+
Fit a low-degree 2D polynomial to a sparse offset grid — standard
|
|
261
|
+
practice (Kampes, Hanssen & Perski 2003; matches the 2019 URSI
|
|
262
|
+
paper's approach) rather than every-pixel solving, since the true
|
|
263
|
+
offset field varies smoothly across a scene.
|
|
264
|
+
|
|
265
|
+
Returns a callable f(row, col) -> offset, evaluable at any pixel.
|
|
266
|
+
"""
|
|
267
|
+
import numpy as np
|
|
268
|
+
|
|
269
|
+
rows = np.asarray(grid_rows, dtype=np.float64)
|
|
270
|
+
cols = np.asarray(grid_cols, dtype=np.float64)
|
|
271
|
+
vals = np.asarray(offsets, dtype=np.float64)
|
|
272
|
+
|
|
273
|
+
if degree == 1:
|
|
274
|
+
A = np.column_stack([np.ones_like(rows), rows, cols])
|
|
275
|
+
elif degree == 2:
|
|
276
|
+
A = np.column_stack([
|
|
277
|
+
np.ones_like(rows), rows, cols, rows * cols, rows**2, cols**2,
|
|
278
|
+
])
|
|
279
|
+
else:
|
|
280
|
+
raise ValueError(f"Unsupported polynomial degree: {degree} (use 1 or 2)")
|
|
281
|
+
|
|
282
|
+
coeffs, *_ = np.linalg.lstsq(A, vals, rcond=None)
|
|
283
|
+
|
|
284
|
+
def evaluate(row, col):
|
|
285
|
+
row = np.asarray(row, dtype=np.float64)
|
|
286
|
+
col = np.asarray(col, dtype=np.float64)
|
|
287
|
+
if degree == 1:
|
|
288
|
+
return coeffs[0] + coeffs[1] * row + coeffs[2] * col
|
|
289
|
+
return (
|
|
290
|
+
coeffs[0] + coeffs[1] * row + coeffs[2] * col
|
|
291
|
+
+ coeffs[3] * row * col + coeffs[4] * row**2 + coeffs[5] * col**2
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return evaluate
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def resample_with_offset_field(data, row_offset_fn, col_offset_fn):
|
|
298
|
+
"""
|
|
299
|
+
Resample a complex array using a real, per-pixel offset field
|
|
300
|
+
(from fit_offset_polynomial), rather than a naive shape-only zoom.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
data: Complex array to resample (the secondary SLC).
|
|
304
|
+
row_offset_fn, col_offset_fn: Callables from fit_offset_polynomial(),
|
|
305
|
+
giving the real (secondary - reference) offset
|
|
306
|
+
at any (row, col) in the reference grid.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
The resampled complex array, on the reference image's grid.
|
|
310
|
+
"""
|
|
311
|
+
import numpy as np
|
|
312
|
+
from scipy.ndimage import map_coordinates
|
|
313
|
+
|
|
314
|
+
h, w = data.shape
|
|
315
|
+
row_idx, col_idx = np.mgrid[0:h, 0:w].astype(np.float64)
|
|
316
|
+
|
|
317
|
+
sample_rows = row_idx + row_offset_fn(row_idx, col_idx)
|
|
318
|
+
sample_cols = col_idx + col_offset_fn(row_idx, col_idx)
|
|
319
|
+
|
|
320
|
+
real = map_coordinates(data.real, [sample_rows, sample_cols], order=1, mode="constant", cval=0.0)
|
|
321
|
+
imag = map_coordinates(data.imag, [sample_rows, sample_cols], order=1, mode="constant", cval=0.0)
|
|
322
|
+
return (real + 1j * imag).astype(np.complex64)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _interpolate(orbit, t):
|
|
326
|
+
from pygeofetch.insar.geolocation import interpolate_orbit_state
|
|
327
|
+
|
|
328
|
+
return interpolate_orbit_state(orbit[0], orbit[1], orbit[2], t)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _distance(a, b):
|
|
332
|
+
return sum((a[i] - b[i]) ** 2 for i in range(3)) ** 0.5
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _linspace(start, stop, n):
|
|
336
|
+
if n == 1:
|
|
337
|
+
return [start]
|
|
338
|
+
step = (stop - start) / (n - 1)
|
|
339
|
+
return [start + i * step for i in range(n)]
|