PyGeoFetch 2.6.2.3__py3-none-any.whl → 2.6.2.5__py3-none-any.whl
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/__init__.py +1 -1
- pygeofetch/cli/main.py +4 -0
- pygeofetch/cli/multisensor_commands.py +243 -0
- pygeofetch/cli/optical_commands.py +105 -0
- pygeofetch/cli/search_commands.py +121 -8
- pygeofetch/core/data_organizer.py +364 -0
- pygeofetch/core/engine.py +8 -4
- pygeofetch/insar/advanced_safeguards.py +370 -0
- pygeofetch/insar/preflight.py +136 -1
- pygeofetch/meta.yaml +1 -1
- pygeofetch/models/satellite_data.py +49 -0
- pygeofetch/multisensor/dem_differencing.py +201 -0
- pygeofetch/multisensor/flood_mapping.py +215 -0
- pygeofetch/multisensor/insar_optical_fusion.py +269 -0
- pygeofetch/multisensor/multisensor_init.py +55 -0
- pygeofetch/multisensor/terrain_corrected_change.py +285 -0
- pygeofetch/multisensor/vegetation_disturbance.py +220 -0
- pygeofetch/optical/__init__.py +35 -0
- pygeofetch/optical/offset_tracking.py +789 -0
- pygeofetch/processor/indices.py +143 -11
- pygeofetch/providers/__init__.py +2 -4
- pygeofetch/providers/airbus_oneatlas.py +1 -1
- pygeofetch/providers/alaska_satellite_facility.py +202 -97
- pygeofetch/providers/digitalglobe.py +281 -112
- pygeofetch/providers/earth_explorer_additional.py +95 -245
- pygeofetch/providers/geoserver_generic.py +212 -91
- pygeofetch/providers/inpe_cbers.py +114 -96
- pygeofetch/providers/isro_bhuvan.py +201 -98
- pygeofetch/providers/jaxa_earth.py +170 -150
- pygeofetch/providers/maxar_gbdx.py +122 -90
- pygeofetch/providers/nasa_earthdata.py +26 -6
- pygeofetch/providers/nasa_earthdata_cloud.py +114 -38
- pygeofetch/providers/opentopography.py +203 -7
- pygeofetch/providers/planet.py +23 -2
- pygeofetch/providers/sentinel_hub.py +50 -25
- pygeofetch/sar/__init__.py +28 -1
- pygeofetch/sar/math.py +100 -0
- pygeofetch/sar/pipelines.py +573 -0
- pygeofetch/sar/processor.py +371 -97
- pygeofetch/utils/sar_math.py +100 -0
- pygeofetch/validation/optical_validator.py +69 -33
- pygeofetch/viz/plot.py +24 -5
- {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/METADATA +5 -10
- {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/RECORD +48 -33
- {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/WHEEL +0 -0
- {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/entry_points.txt +0 -0
- {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/licenses/LICENSE +0 -0
- {pygeofetch-2.6.2.3.dist-info → pygeofetch-2.6.2.5.dist-info}/top_level.txt +0 -0
pygeofetch/__init__.py
CHANGED
pygeofetch/cli/main.py
CHANGED
|
@@ -26,6 +26,8 @@ from pygeofetch.cli.auth_commands import auth
|
|
|
26
26
|
from pygeofetch.cli.config_commands import config
|
|
27
27
|
from pygeofetch.cli.download_commands import download
|
|
28
28
|
from pygeofetch.cli.index_commands import index
|
|
29
|
+
from pygeofetch.cli.multisensor_commands import multisensor
|
|
30
|
+
from pygeofetch.cli.optical_commands import optical
|
|
29
31
|
from pygeofetch.cli.pipeline_process_commands import proc_pipeline
|
|
30
32
|
from pygeofetch.cli.postprocess_commands import post
|
|
31
33
|
from pygeofetch.cli.preprocess_commands import preprocess
|
|
@@ -103,6 +105,8 @@ cli.add_command(index)
|
|
|
103
105
|
cli.add_command(post)
|
|
104
106
|
cli.add_command(sar)
|
|
105
107
|
cli.add_command(proc_pipeline)
|
|
108
|
+
cli.add_command(optical)
|
|
109
|
+
cli.add_command(multisensor)
|
|
106
110
|
|
|
107
111
|
|
|
108
112
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""CLI access to pygeofetch's 5 real multi-sensor pipelines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
def multisensor() -> None:
|
|
12
|
+
"""Multi-sensor fusion pipelines: InSAR+optical, SAR+optical flood
|
|
13
|
+
mapping, terrain-corrected change, DEM differencing, vegetation
|
|
14
|
+
disturbance. See docs/processing/multi-sensor-pipelines.md."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _print_result(result) -> None:
|
|
18
|
+
if result.success:
|
|
19
|
+
click.echo(f"✓ Success -> {result.final_output}")
|
|
20
|
+
if result.metadata:
|
|
21
|
+
click.echo(
|
|
22
|
+
json.dumps(
|
|
23
|
+
{k: str(v) for k, v in result.metadata.items()},
|
|
24
|
+
indent=2,
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
else:
|
|
28
|
+
click.echo(f"✗ Failed: {result.error}", err=True)
|
|
29
|
+
raise SystemExit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@multisensor.command("insar-optical-fusion")
|
|
33
|
+
@click.option("--insar-velocity", required=True, type=click.Path(exists=True))
|
|
34
|
+
@click.option("--insar-coherence", required=True, type=click.Path(exists=True))
|
|
35
|
+
@click.option("--optical-ref", required=True, type=click.Path(exists=True))
|
|
36
|
+
@click.option("--optical-sec", required=True, type=click.Path(exists=True))
|
|
37
|
+
@click.option("--output-dir", required=True, type=click.Path())
|
|
38
|
+
@click.option("--window-size", default=64, show_default=True, type=int)
|
|
39
|
+
@click.option("--step-size", default=16, show_default=True, type=int)
|
|
40
|
+
@click.option("--snr-threshold", default=3.0, show_default=True, type=float)
|
|
41
|
+
@click.option("--coherence-low", default=0.4, show_default=True, type=float)
|
|
42
|
+
@click.option("--coherence-high", default=0.6, show_default=True, type=float)
|
|
43
|
+
@click.option("--cloud-mask", default=None, type=click.Path(exists=True))
|
|
44
|
+
def insar_optical_fusion_cmd(
|
|
45
|
+
insar_velocity,
|
|
46
|
+
insar_coherence,
|
|
47
|
+
optical_ref,
|
|
48
|
+
optical_sec,
|
|
49
|
+
output_dir,
|
|
50
|
+
window_size,
|
|
51
|
+
step_size,
|
|
52
|
+
snr_threshold,
|
|
53
|
+
coherence_low,
|
|
54
|
+
coherence_high,
|
|
55
|
+
cloud_mask,
|
|
56
|
+
):
|
|
57
|
+
"""Fuse InSAR displacement with optical pixel offset tracking."""
|
|
58
|
+
from pygeofetch.multisensor import insar_optical_displacement_pipeline
|
|
59
|
+
|
|
60
|
+
result = insar_optical_displacement_pipeline(
|
|
61
|
+
insar_velocity,
|
|
62
|
+
insar_coherence,
|
|
63
|
+
optical_ref,
|
|
64
|
+
optical_sec,
|
|
65
|
+
output_dir,
|
|
66
|
+
cloud_mask_path=cloud_mask,
|
|
67
|
+
window_size=window_size,
|
|
68
|
+
step_size=step_size,
|
|
69
|
+
snr_threshold=snr_threshold,
|
|
70
|
+
coherence_low=coherence_low,
|
|
71
|
+
coherence_high=coherence_high,
|
|
72
|
+
)
|
|
73
|
+
_print_result(result)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@multisensor.command("flood-map")
|
|
77
|
+
@click.option("--sar", "sar_input", required=True, type=click.Path(exists=True))
|
|
78
|
+
@click.option("--optical-green", required=True, type=click.Path(exists=True))
|
|
79
|
+
@click.option("--optical-nir", required=True, type=click.Path(exists=True))
|
|
80
|
+
@click.option("--output-dir", required=True, type=click.Path())
|
|
81
|
+
@click.option("--sar-reference", default=None, type=click.Path(exists=True))
|
|
82
|
+
@click.option("--sar-threshold", default=-15.0, show_default=True, type=float)
|
|
83
|
+
@click.option("--ndwi-threshold", default=0.0, show_default=True, type=float)
|
|
84
|
+
@click.option("--cloud-mask", default=None, type=click.Path(exists=True))
|
|
85
|
+
@click.option(
|
|
86
|
+
"--fusion-mode",
|
|
87
|
+
default="cloud_aware",
|
|
88
|
+
show_default=True,
|
|
89
|
+
type=click.Choice(["cloud_aware", "union", "intersection"]),
|
|
90
|
+
)
|
|
91
|
+
def flood_map_cmd(
|
|
92
|
+
sar_input,
|
|
93
|
+
optical_green,
|
|
94
|
+
optical_nir,
|
|
95
|
+
output_dir,
|
|
96
|
+
sar_reference,
|
|
97
|
+
sar_threshold,
|
|
98
|
+
ndwi_threshold,
|
|
99
|
+
cloud_mask,
|
|
100
|
+
fusion_mode,
|
|
101
|
+
):
|
|
102
|
+
"""Fuse SAR and optical NDWI for flood/water extent mapping."""
|
|
103
|
+
from pygeofetch.multisensor import multi_sensor_flood_pipeline
|
|
104
|
+
from pygeofetch.sar import SARProcessor
|
|
105
|
+
|
|
106
|
+
result = multi_sensor_flood_pipeline(
|
|
107
|
+
SARProcessor(),
|
|
108
|
+
sar_input,
|
|
109
|
+
optical_green,
|
|
110
|
+
optical_nir,
|
|
111
|
+
output_dir,
|
|
112
|
+
sar_reference_path=sar_reference,
|
|
113
|
+
sar_threshold=sar_threshold,
|
|
114
|
+
optical_ndwi_threshold=ndwi_threshold,
|
|
115
|
+
cloud_mask_path=cloud_mask,
|
|
116
|
+
fusion_mode=fusion_mode,
|
|
117
|
+
)
|
|
118
|
+
_print_result(result)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@multisensor.command("terrain-change")
|
|
122
|
+
@click.option("--pre", "pre_path", required=True, type=click.Path(exists=True))
|
|
123
|
+
@click.option("--post", "post_path", required=True, type=click.Path(exists=True))
|
|
124
|
+
@click.option("--dem", "dem_path", required=True, type=click.Path(exists=True))
|
|
125
|
+
@click.option(
|
|
126
|
+
"--pre-datetime",
|
|
127
|
+
required=True,
|
|
128
|
+
help="Real UTC ISO datetime, e.g. 2024-01-15T10:30:00",
|
|
129
|
+
)
|
|
130
|
+
@click.option("--post-datetime", required=True, help="Real UTC ISO datetime")
|
|
131
|
+
@click.option("--lat", "latitude", required=True, type=float)
|
|
132
|
+
@click.option("--lon", "longitude", required=True, type=float)
|
|
133
|
+
@click.option("--output-dir", required=True, type=click.Path())
|
|
134
|
+
@click.option("--change-threshold", default=0.1, show_default=True, type=float)
|
|
135
|
+
def terrain_change_cmd(
|
|
136
|
+
pre_path,
|
|
137
|
+
post_path,
|
|
138
|
+
dem_path,
|
|
139
|
+
pre_datetime,
|
|
140
|
+
post_datetime,
|
|
141
|
+
latitude,
|
|
142
|
+
longitude,
|
|
143
|
+
output_dir,
|
|
144
|
+
change_threshold,
|
|
145
|
+
):
|
|
146
|
+
"""Terrain-corrected optical change detection (removes real
|
|
147
|
+
illumination artifacts using each date's own solar position)."""
|
|
148
|
+
from datetime import datetime, timezone
|
|
149
|
+
|
|
150
|
+
from pygeofetch.multisensor import terrain_corrected_change_pipeline
|
|
151
|
+
|
|
152
|
+
def _parse(dt_str):
|
|
153
|
+
dt = datetime.fromisoformat(dt_str)
|
|
154
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
155
|
+
|
|
156
|
+
result = terrain_corrected_change_pipeline(
|
|
157
|
+
pre_path,
|
|
158
|
+
post_path,
|
|
159
|
+
dem_path,
|
|
160
|
+
_parse(pre_datetime),
|
|
161
|
+
_parse(post_datetime),
|
|
162
|
+
latitude,
|
|
163
|
+
longitude,
|
|
164
|
+
output_dir,
|
|
165
|
+
change_threshold=change_threshold,
|
|
166
|
+
)
|
|
167
|
+
_print_result(result)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@multisensor.command("dem-diff")
|
|
171
|
+
@click.option("--dem-old", required=True, type=click.Path(exists=True))
|
|
172
|
+
@click.option("--dem-new", required=True, type=click.Path(exists=True))
|
|
173
|
+
@click.option("--output-dir", required=True, type=click.Path())
|
|
174
|
+
@click.option(
|
|
175
|
+
"--old-rmse",
|
|
176
|
+
default=1.0,
|
|
177
|
+
show_default=True,
|
|
178
|
+
type=float,
|
|
179
|
+
help="Real vertical RMSE of the old DEM, metres.",
|
|
180
|
+
)
|
|
181
|
+
@click.option(
|
|
182
|
+
"--new-rmse",
|
|
183
|
+
default=1.0,
|
|
184
|
+
show_default=True,
|
|
185
|
+
type=float,
|
|
186
|
+
help="Real vertical RMSE of the new DEM, metres.",
|
|
187
|
+
)
|
|
188
|
+
@click.option("--confidence-t", default=1.96, show_default=True, type=float)
|
|
189
|
+
def dem_diff_cmd(dem_old, dem_new, output_dir, old_rmse, new_rmse, confidence_t):
|
|
190
|
+
"""DEM-of-Difference volumetric change analysis."""
|
|
191
|
+
from pygeofetch.multisensor import dem_differencing_pipeline
|
|
192
|
+
|
|
193
|
+
result = dem_differencing_pipeline(
|
|
194
|
+
dem_old,
|
|
195
|
+
dem_new,
|
|
196
|
+
output_dir,
|
|
197
|
+
dem_old_vertical_rmse_m=old_rmse,
|
|
198
|
+
dem_new_vertical_rmse_m=new_rmse,
|
|
199
|
+
confidence_t_value=confidence_t,
|
|
200
|
+
)
|
|
201
|
+
_print_result(result)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@multisensor.command("vegetation-disturbance")
|
|
205
|
+
@click.option("--red-pre", required=True, type=click.Path(exists=True))
|
|
206
|
+
@click.option("--nir-pre", required=True, type=click.Path(exists=True))
|
|
207
|
+
@click.option("--red-post", required=True, type=click.Path(exists=True))
|
|
208
|
+
@click.option("--nir-post", required=True, type=click.Path(exists=True))
|
|
209
|
+
@click.option("--slc-pre", required=True, type=click.Path(exists=True))
|
|
210
|
+
@click.option("--slc-post", required=True, type=click.Path(exists=True))
|
|
211
|
+
@click.option("--output-dir", required=True, type=click.Path())
|
|
212
|
+
@click.option("--ndvi-drop-threshold", default=0.15, show_default=True, type=float)
|
|
213
|
+
@click.option("--coherence-threshold", default=0.3, show_default=True, type=float)
|
|
214
|
+
@click.option("--coherence-window", default=7, show_default=True, type=int)
|
|
215
|
+
def vegetation_disturbance_cmd(
|
|
216
|
+
red_pre,
|
|
217
|
+
nir_pre,
|
|
218
|
+
red_post,
|
|
219
|
+
nir_post,
|
|
220
|
+
slc_pre,
|
|
221
|
+
slc_post,
|
|
222
|
+
output_dir,
|
|
223
|
+
ndvi_drop_threshold,
|
|
224
|
+
coherence_threshold,
|
|
225
|
+
coherence_window,
|
|
226
|
+
):
|
|
227
|
+
"""Classify visible canopy loss vs. real sub-canopy ground
|
|
228
|
+
disturbance, from optical NDVI + SAR coherence."""
|
|
229
|
+
from pygeofetch.multisensor import vegetation_disturbance_pipeline
|
|
230
|
+
|
|
231
|
+
result = vegetation_disturbance_pipeline(
|
|
232
|
+
red_pre,
|
|
233
|
+
nir_pre,
|
|
234
|
+
red_post,
|
|
235
|
+
nir_post,
|
|
236
|
+
slc_pre,
|
|
237
|
+
slc_post,
|
|
238
|
+
output_dir,
|
|
239
|
+
ndvi_drop_threshold=ndvi_drop_threshold,
|
|
240
|
+
coherence_threshold=coherence_threshold,
|
|
241
|
+
coherence_window=coherence_window,
|
|
242
|
+
)
|
|
243
|
+
_print_result(result)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""CLI commands for optical pixel offset tracking (POT)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def optical() -> None:
|
|
15
|
+
"""Optical pixel offset tracking — measures large 2D ground
|
|
16
|
+
displacement via image correlation, complementing InSAR for
|
|
17
|
+
motion that exceeds phase InSAR's coherence limit."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@optical.command("offset-track")
|
|
21
|
+
@click.option("--ref", "reference_path", required=True, type=click.Path(exists=True), help="Reference-date raster.")
|
|
22
|
+
@click.option("--sec", "secondary_path", required=True, type=click.Path(exists=True), help="Secondary-date raster.")
|
|
23
|
+
@click.option("--output", "-o", required=True, type=click.Path(), help="Output GeoTIFF (dx/dy/snr, 3 bands).")
|
|
24
|
+
@click.option("--band", default=1, show_default=True, type=int, help="1-based band index to correlate.")
|
|
25
|
+
@click.option("--window-size", default=64, show_default=True, type=int, help="Search window size, pixels.")
|
|
26
|
+
@click.option("--step-size", default=16, show_default=True, type=int, help="Spacing between window centers, pixels.")
|
|
27
|
+
@click.option("--chip-size", default=None, type=int, help="Reference template size, pixels. Default: window-size // 2.")
|
|
28
|
+
@click.option("--snr-threshold", default=3.0, show_default=True, type=float, help="Minimum SNR to mark a window reliable.")
|
|
29
|
+
@click.option("--cloud-mask", default=None, type=click.Path(exists=True), help="Real Sentinel-2 SCL raster, already aligned to --ref.")
|
|
30
|
+
@click.option("--water-mask-threshold", default=None, type=float, help="Mask pixels where NDWI exceeds this value (needs --green-band/--nir-band).")
|
|
31
|
+
@click.option("--green-band", default=None, type=int, help="1-based green band index, for --water-mask-threshold.")
|
|
32
|
+
@click.option("--nir-band", default=None, type=int, help="1-based NIR band index, for --water-mask-threshold.")
|
|
33
|
+
def offset_track_cmd(
|
|
34
|
+
reference_path,
|
|
35
|
+
secondary_path,
|
|
36
|
+
output,
|
|
37
|
+
band,
|
|
38
|
+
window_size,
|
|
39
|
+
step_size,
|
|
40
|
+
chip_size,
|
|
41
|
+
snr_threshold,
|
|
42
|
+
cloud_mask,
|
|
43
|
+
water_mask_threshold,
|
|
44
|
+
green_band,
|
|
45
|
+
nir_band,
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
Measure 2D ground displacement between two optical scenes via
|
|
49
|
+
image correlation.
|
|
50
|
+
|
|
51
|
+
\b
|
|
52
|
+
Examples:
|
|
53
|
+
# Basic: two co-registered scenes, real cloud masking via SCL
|
|
54
|
+
pygeofetch optical offset-track \\
|
|
55
|
+
--ref before.tif --sec after.tif \\
|
|
56
|
+
--cloud-mask before_SCL.tif \\
|
|
57
|
+
--output displacement.tif
|
|
58
|
+
|
|
59
|
+
# Mining-scale displacement, larger search window
|
|
60
|
+
pygeofetch optical offset-track \\
|
|
61
|
+
--ref jan.tif --sec jun.tif \\
|
|
62
|
+
--window-size 96 --step-size 24 \\
|
|
63
|
+
--output mine_displacement.tif
|
|
64
|
+
"""
|
|
65
|
+
from pygeofetch.optical.offset_tracking import compute_pixel_offsets, prepare_optical_pair
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
ref_arr, sec_arr, ref_profile = prepare_optical_pair(
|
|
69
|
+
reference_path,
|
|
70
|
+
secondary_path,
|
|
71
|
+
band_index=band,
|
|
72
|
+
cloud_mask_path=cloud_mask,
|
|
73
|
+
water_mask_threshold=water_mask_threshold,
|
|
74
|
+
green_band_index=green_band,
|
|
75
|
+
nir_band_index=nir_band,
|
|
76
|
+
)
|
|
77
|
+
except ValueError as exc:
|
|
78
|
+
console.print(f"[red]✗[/] {exc}")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
pixel_size_m = abs(ref_profile["transform"].a)
|
|
82
|
+
result = compute_pixel_offsets(
|
|
83
|
+
ref_arr,
|
|
84
|
+
sec_arr,
|
|
85
|
+
pixel_size_m=pixel_size_m,
|
|
86
|
+
window_size=window_size,
|
|
87
|
+
step_size=step_size,
|
|
88
|
+
chip_size=chip_size,
|
|
89
|
+
snr_threshold=snr_threshold,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
out_transform = ref_profile["transform"] * ref_profile["transform"].scale(step_size, step_size)
|
|
93
|
+
out_profile = dict(ref_profile)
|
|
94
|
+
out_profile.update(
|
|
95
|
+
height=result.dx.shape[0],
|
|
96
|
+
width=result.dx.shape[1],
|
|
97
|
+
transform=out_transform,
|
|
98
|
+
)
|
|
99
|
+
written = result.export_geotiff(output, out_profile)
|
|
100
|
+
|
|
101
|
+
n_reliable = int(result.reliable.sum())
|
|
102
|
+
n_total = int(result.reliable.size)
|
|
103
|
+
console.print(f"[green]✓[/] offset-track → {written}")
|
|
104
|
+
console.print(f" {n_reliable}/{n_total} windows reliable ({100 * n_reliable / n_total:.1f}%)")
|
|
105
|
+
console.print(f" window={window_size}px step={step_size}px pixel_size={pixel_size_m:.2f}m")
|
|
@@ -282,6 +282,7 @@ def search_run(
|
|
|
282
282
|
|
|
283
283
|
sb = PyGeoFetch()
|
|
284
284
|
|
|
285
|
+
optical_config = None
|
|
285
286
|
if validate_optical and (
|
|
286
287
|
optical_max_cloud_cover,
|
|
287
288
|
optical_min_coverage,
|
|
@@ -298,22 +299,48 @@ def search_run(
|
|
|
298
299
|
overrides["required_bands"] = [
|
|
299
300
|
b.strip() for b in optical_required_bands.split(",")
|
|
300
301
|
]
|
|
301
|
-
|
|
302
|
+
optical_config = OpticalValidationConfig(**overrides)
|
|
303
|
+
sb.optical_validation_config = optical_config
|
|
302
304
|
|
|
303
305
|
with console.status(
|
|
304
306
|
f"[cyan]Searching {len(provider_list or [])} provider(s)...[/]"
|
|
305
307
|
):
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
308
|
+
if validate_optical:
|
|
309
|
+
# Real, deliberate choice: search WITHOUT filtering here, then
|
|
310
|
+
# validate directly in this CLI layer, rather than calling
|
|
311
|
+
# search(validate_optical=True) and losing visibility into
|
|
312
|
+
# what got rejected and why. sb.search()'s own contract
|
|
313
|
+
# (returns only the passed scenes) is left untouched for
|
|
314
|
+
# every other caller -- this is a CLI-only reporting path,
|
|
315
|
+
# not a change to the library's real filtering behavior.
|
|
316
|
+
raw_results = sb.search(
|
|
317
|
+
query,
|
|
318
|
+
providers=provider_list,
|
|
319
|
+
use_cache=not no_cache,
|
|
320
|
+
validate_optical=False,
|
|
321
|
+
)
|
|
322
|
+
results, validation_summary = _validate_and_summarize(
|
|
323
|
+
sb, raw_results, query, optical_config
|
|
324
|
+
)
|
|
325
|
+
else:
|
|
326
|
+
results = sb.search(
|
|
327
|
+
query,
|
|
328
|
+
providers=provider_list,
|
|
329
|
+
use_cache=not no_cache,
|
|
330
|
+
validate_optical=False,
|
|
331
|
+
)
|
|
332
|
+
validation_summary = None
|
|
312
333
|
|
|
313
334
|
if not results:
|
|
314
335
|
console.print("[yellow]No results found.[/]")
|
|
315
336
|
return
|
|
316
337
|
|
|
338
|
+
# Real, clean validation info + full results table, organized so the
|
|
339
|
+
# summary always appears before the detailed table -- not interleaved
|
|
340
|
+
# with save/format output below.
|
|
341
|
+
if validation_summary is not None:
|
|
342
|
+
_display_validation_summary(validation_summary)
|
|
343
|
+
|
|
317
344
|
# Save if requested
|
|
318
345
|
if output:
|
|
319
346
|
out_path = Path(output)
|
|
@@ -365,6 +392,92 @@ def search_run(
|
|
|
365
392
|
click.echo(r.id)
|
|
366
393
|
|
|
367
394
|
|
|
395
|
+
def _validate_and_summarize(sb, raw_results, query, optical_config):
|
|
396
|
+
"""
|
|
397
|
+
Real validation pass over every raw candidate, returning both the
|
|
398
|
+
scenes that actually pass and a real, structured summary of what got
|
|
399
|
+
rejected and why -- the information sb.search(validate_optical=True)
|
|
400
|
+
alone discards once it returns only the filtered list.
|
|
401
|
+
"""
|
|
402
|
+
from collections import Counter
|
|
403
|
+
|
|
404
|
+
from pygeofetch.validation import OpticalPreflightValidator, OpticalValidationConfig
|
|
405
|
+
|
|
406
|
+
validator = OpticalPreflightValidator(optical_config or OpticalValidationConfig())
|
|
407
|
+
aoi = sb._derive_aoi_polygon(query)
|
|
408
|
+
|
|
409
|
+
passed_results = []
|
|
410
|
+
rejection_reasons: Counter = Counter()
|
|
411
|
+
warning_reasons: Counter = Counter()
|
|
412
|
+
n_warned_but_passed = 0
|
|
413
|
+
|
|
414
|
+
for scene in raw_results:
|
|
415
|
+
report = validator.validate_scene(scene, aoi, query.start_date, query.end_date)
|
|
416
|
+
if report.passed:
|
|
417
|
+
passed_results.append(scene)
|
|
418
|
+
if report.issues:
|
|
419
|
+
n_warned_but_passed += 1
|
|
420
|
+
for issue in report.issues:
|
|
421
|
+
warning_reasons[issue.code] += 1
|
|
422
|
+
else:
|
|
423
|
+
for issue in report.issues:
|
|
424
|
+
if issue.severity == "ERROR":
|
|
425
|
+
rejection_reasons[issue.code] += 1
|
|
426
|
+
|
|
427
|
+
summary = {
|
|
428
|
+
"n_total": len(raw_results),
|
|
429
|
+
"n_passed": len(passed_results),
|
|
430
|
+
"n_rejected": len(raw_results) - len(passed_results),
|
|
431
|
+
"n_warned_but_passed": n_warned_but_passed,
|
|
432
|
+
"rejection_reasons": rejection_reasons,
|
|
433
|
+
"warning_reasons": warning_reasons,
|
|
434
|
+
}
|
|
435
|
+
return passed_results, summary
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _display_validation_summary(summary: dict) -> None:
|
|
439
|
+
"""Clean, real info block summarizing optical validation -- shown
|
|
440
|
+
before the detailed results table, not interleaved with it."""
|
|
441
|
+
n_total = summary["n_total"]
|
|
442
|
+
n_passed = summary["n_passed"]
|
|
443
|
+
n_rejected = summary["n_rejected"]
|
|
444
|
+
pct = (100 * n_passed / n_total) if n_total else 0.0
|
|
445
|
+
|
|
446
|
+
console.print(
|
|
447
|
+
f"\n[bold]Optical validation:[/] {n_passed}/{n_total} scenes passed "
|
|
448
|
+
f"({pct:.0f}%)"
|
|
449
|
+
+ (f", [yellow]{n_rejected} rejected[/]" if n_rejected else "")
|
|
450
|
+
+ (
|
|
451
|
+
f", [cyan]{summary['n_warned_but_passed']} passed with warnings[/]"
|
|
452
|
+
if summary["n_warned_but_passed"]
|
|
453
|
+
else ""
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
if summary["rejection_reasons"]:
|
|
458
|
+
reasons_table = Table(
|
|
459
|
+
title="Rejection reasons", header_style="bold red", show_lines=False
|
|
460
|
+
)
|
|
461
|
+
reasons_table.add_column("Code", style="red")
|
|
462
|
+
reasons_table.add_column("Count", justify="right")
|
|
463
|
+
for code, count in summary["rejection_reasons"].most_common():
|
|
464
|
+
reasons_table.add_row(code, str(count))
|
|
465
|
+
console.print(reasons_table)
|
|
466
|
+
|
|
467
|
+
if summary["warning_reasons"]:
|
|
468
|
+
warn_table = Table(
|
|
469
|
+
title="Warnings on passed scenes",
|
|
470
|
+
header_style="bold yellow",
|
|
471
|
+
show_lines=False,
|
|
472
|
+
)
|
|
473
|
+
warn_table.add_column("Code", style="yellow")
|
|
474
|
+
warn_table.add_column("Count", justify="right")
|
|
475
|
+
for code, count in summary["warning_reasons"].most_common():
|
|
476
|
+
warn_table.add_row(code, str(count))
|
|
477
|
+
console.print(warn_table)
|
|
478
|
+
console.print() # real spacing before the full results table below
|
|
479
|
+
|
|
480
|
+
|
|
368
481
|
def _display_table(results) -> None:
|
|
369
482
|
table = Table(title=f"{len(results)} Results", header_style="bold blue")
|
|
370
483
|
table.add_column("ID", style="cyan", max_width=30)
|
|
@@ -441,4 +554,4 @@ def _save_csv(results, path: Path) -> None:
|
|
|
441
554
|
)
|
|
442
555
|
writer.writeheader()
|
|
443
556
|
for r in results:
|
|
444
|
-
writer.writerow(_item_to_dict(r))
|
|
557
|
+
writer.writerow(_item_to_dict(r))
|