PyGeoFetch 2.0.3__py3-none-any.whl → 2.0.4__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 CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch v2.0.3 — Universal Satellite Data Pipeline.
2
+ PyGeoFetch v2.0.4 — Universal Satellite Data Pipeline.
3
3
 
4
4
  Unified access to 22+ satellite data providers with a consistent
5
5
  Python API and CLI. Supports authentication management, federated search,
@@ -30,7 +30,7 @@ CLI::
30
30
  pygeofetch doctor
31
31
  """
32
32
 
33
- __version__ = "2.0.3"
33
+ __version__ = "2.0.4"
34
34
  __author__ = "PyGeoFetch Contributors"
35
35
  __license__ = "MIT"
36
36
 
pygeofetch/cli/main.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- PyGeoFetch CLI v2.0.3 main entry point.
2
+ PyGeoFetch CLI v2.0.4 main entry point.
3
3
 
4
4
  Full command reference:
5
5
  pygeofetch auth — credential management
@@ -71,7 +71,7 @@ def cli(
71
71
  ) -> None:
72
72
  """
73
73
  \b
74
- PyGeoFetch v2.0.3 — Universal Satellite Data Pipeline
74
+ PyGeoFetch v2.0.4 — Universal Satellite Data Pipeline
75
75
  ===========================================================
76
76
  Unified access to 22+ satellite data providers.
77
77
 
@@ -22,25 +22,258 @@ Install: pip install "pygeofetch[insar]" (installs snaphu, scipy)
22
22
  from __future__ import annotations
23
23
 
24
24
  import logging
25
+ import platform
26
+ import subprocess
27
+ import urllib.request
28
+ import zipfile
29
+ import shutil
30
+ import json
31
+ import tempfile
32
+ from dataclasses import dataclass, field
25
33
  from pathlib import Path
26
- from typing import Any, Optional, Tuple, Union
34
+ from typing import Any, Dict, Optional, Tuple, Union
27
35
 
28
36
  logger = logging.getLogger("pygeofetch.insar.unwrap")
29
37
 
30
38
 
31
- def _require_snaphu():
32
- try:
33
- import snaphu
39
+ @dataclass
40
+ class UnwrapResult:
41
+ """
42
+ Result of phase unwrapping for one interferogram pair.
43
+
44
+ Mirrors the InterferogramResult.save() pattern: the notebook cell
45
+ stays focused on orchestration (which pairs, what parameters), while
46
+ this class handles all file I/O, georeferencing, nodata masking,
47
+ and optional visualization.
48
+ """
49
+
50
+ unwrapped_phase: Any # float32 array, radians, continuous
51
+ conncomp: Any # int32 array, 0 = unreliable/masked
52
+ coherence: Any # float32 array, 0-1 (multilooked coherence used)
53
+ profile: Dict[str, Any] # rasterio-style profile for georeferencing
54
+ reference_date: Optional[str] = None
55
+ secondary_date: Optional[str] = None
56
+ nlooks: float = 1.0
57
+ cost_mode: str = "defo"
58
+ init_method: str = "mcf"
59
+ reliable_fraction: float = 0.0
60
+ metadata: Dict[str, Any] = field(default_factory=dict)
61
+
62
+ def save(
63
+ self,
64
+ output_dir: Union[str, Path],
65
+ save_png: bool = False,
66
+ mask_unreliable: bool = True,
67
+ ) -> Dict[str, Path]:
68
+ """
69
+ Save unwrapped phase and connected components as GeoTIFFs.
70
+
71
+ Writes:
72
+ unwrapped_phase.tif (float32, radians, nodata=-9999)
73
+ conncomp.tif (int32, 0=unreliable)
74
+ coherence.tif (float32, 0-1)
75
+ [optional] unwrapped_phase.png, conncomp.png
76
+
77
+ Args:
78
+ output_dir: Directory to save into.
79
+ save_png: If True, also save PNG visualizations.
80
+ mask_unreliable: If True (default), pixels with conncomp==0
81
+ are set to nodata in the unwrapped phase
82
+ GeoTIFF. This is baked in automatically so
83
+ every user gets correct nodata handling
84
+ without having to remember to do it manually.
85
+
86
+ Returns:
87
+ Dict mapping product name to output path.
88
+ """
89
+ import numpy as np
90
+
91
+ try:
92
+ import rasterio
93
+ except ImportError:
94
+ raise ImportError('rasterio required: pip install "pygeofetch[geo]"')
95
+
96
+ out_dir = Path(output_dir)
97
+ out_dir.mkdir(parents=True, exist_ok=True)
98
+ paths = {}
99
+
100
+ base_profile = {
101
+ "driver": "GTiff",
102
+ "count": 1,
103
+ "height": self.unwrapped_phase.shape[0],
104
+ "width": self.unwrapped_phase.shape[1],
105
+ "crs": self.profile.get("crs"),
106
+ "transform": self.profile.get("transform"),
107
+ "compress": "deflate",
108
+ "tiled": True,
109
+ "blockxsize": 256,
110
+ "blockysize": 256,
111
+ }
112
+
113
+ # ── Unwrapped phase ──
114
+ unw_to_save = self.unwrapped_phase.copy()
115
+ if mask_unreliable:
116
+ unw_to_save[self.conncomp == 0] = -9999.0
117
+
118
+ phase_path = out_dir / "unwrapped_phase.tif"
119
+ phase_profile = dict(base_profile, dtype="float32", nodata=-9999.0)
120
+ with rasterio.open(phase_path, "w", **phase_profile) as dst:
121
+ dst.write(unw_to_save.astype(np.float32)[np.newaxis])
122
+ paths["unwrapped_phase"] = phase_path
123
+
124
+ # ── Connected components ──
125
+ cc_path = out_dir / "conncomp.tif"
126
+ cc_profile = dict(base_profile, dtype="int32", nodata=0)
127
+ with rasterio.open(cc_path, "w", **cc_profile) as dst:
128
+ dst.write(self.conncomp.astype(np.int32)[np.newaxis])
129
+ paths["conncomp"] = cc_path
130
+
131
+ # ── Coherence ──
132
+ coh_path = out_dir / "coherence.tif"
133
+ coh_profile = dict(base_profile, dtype="float32", nodata=-1.0)
134
+ with rasterio.open(coh_path, "w", **coh_profile) as dst:
135
+ dst.write(self.coherence.astype(np.float32)[np.newaxis])
136
+ paths["coherence"] = coh_path
34
137
 
35
- return snaphu
36
- except ImportError:
37
- raise ImportError(
38
- "snaphu-py is not installed.\n"
39
- 'Install with: pip install "pygeofetch[insar]"\n'
40
- "Or directly: pip install snaphu\n\n"
41
- "snaphu-py provides Python bindings for SNAPHU, the same "
42
- "phase-unwrapping algorithm (Chen & Zebker 2001) used by "
43
- "ASF, ISCE2/3, GAMMA, and SNAP."
138
+ logger.info(
139
+ "Unwrap products saved → %s (reliable=%.1f%%)",
140
+ out_dir, self.reliable_fraction * 100,
141
+ )
142
+
143
+ # ── Optional PNG visualizations ──
144
+ if save_png:
145
+ self._save_pngs(out_dir, unw_to_save, paths)
146
+
147
+ return paths
148
+
149
+ def _save_pngs(self, out_dir: Path, unw_masked, paths: Dict[str, Path]) -> None:
150
+ """Save PNG visualizations with robust percentile scaling."""
151
+ import numpy as np
152
+ import matplotlib
153
+ matplotlib.use("Agg") # non-interactive backend for headless saving
154
+ import matplotlib.pyplot as plt
155
+
156
+ # ── Unwrapped phase PNG ──
157
+ fig, ax = plt.subplots(1, 1, figsize=(14, 8))
158
+ masked_unw = np.ma.masked_where(self.conncomp == 0, self.unwrapped_phase)
159
+ finite_unw = self.unwrapped_phase[self.conncomp > 0]
160
+ if finite_unw.size > 0:
161
+ vmin = float(np.percentile(finite_unw, 2))
162
+ vmax = float(np.percentile(finite_unw, 98))
163
+ else:
164
+ vmin, vmax = -np.pi, np.pi
165
+ im = ax.imshow(masked_unw, cmap="jet", vmin=vmin, vmax=vmax)
166
+ ax.set_title(
167
+ f"Unwrapped Phase (rad)\n"
168
+ f"{self.reference_date} → {self.secondary_date} | "
169
+ f"reliable={self.reliable_fraction*100:.1f}%"
170
+ )
171
+ fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
172
+ png_path = out_dir / "unwrapped_phase.png"
173
+ fig.savefig(png_path, dpi=150, bbox_inches="tight")
174
+ plt.close(fig)
175
+
176
+ # ── Connected components PNG ──
177
+ fig, ax = plt.subplots(1, 1, figsize=(14, 8))
178
+ masked_cc = np.ma.masked_where(self.conncomp == 0, self.conncomp)
179
+ ax.imshow(masked_cc, cmap="tab20")
180
+ ax.set_title(
181
+ f"Connected Components\n"
182
+ f"{self.reference_date} → {self.secondary_date} | "
183
+ f"reliable={self.reliable_fraction*100:.1f}%"
184
+ )
185
+ png_path = out_dir / "conncomp.png"
186
+ fig.savefig(png_path, dpi=150, bbox_inches="tight")
187
+ plt.close(fig)
188
+
189
+ logger.info("Unwrap PNGs saved → %s", out_dir)
190
+
191
+
192
+ def _ensure_snaphu_cli() -> str:
193
+ """
194
+ Ensures the SNAPHU CLI executable is available.
195
+ 1. Checks system PATH.
196
+ 2. Checks ~/.pygeofetch/bin/snaphu(.exe).
197
+ 3. If on Windows and missing, downloads the pre-compiled binary from GitHub.
198
+ """
199
+ exe_name = "snaphu.exe" if platform.system() == "Windows" else "snaphu"
200
+
201
+ # 1. System PATH
202
+ path = shutil.which(exe_name)
203
+ if path:
204
+ return path
205
+
206
+ # 2. Local cache
207
+ bin_dir = Path.home() / ".pygeofetch" / "bin"
208
+ bin_dir.mkdir(parents=True, exist_ok=True)
209
+ local_exe = bin_dir / exe_name
210
+
211
+ if local_exe.exists():
212
+ return str(local_exe)
213
+
214
+ # 3. Download if on Windows
215
+ if platform.system() == "Windows":
216
+ logger.info("SNAPHU executable not found. Downloading pre-compiled binary for Windows...")
217
+ api_url = "https://api.github.com/repos/isce-framework/snaphu-py/releases/latest"
218
+ asset_url = None
219
+
220
+ try:
221
+ req = urllib.request.Request(api_url, headers={'User-Agent': 'PyGeoFetch'})
222
+ with urllib.request.urlopen(req, timeout=15) as response:
223
+ data = json.loads(response.read().decode())
224
+
225
+ for asset in data.get("assets", []):
226
+ name = asset["name"].lower()
227
+ if "win" in name and name.endswith(".zip"):
228
+ asset_url = asset["browser_download_url"]
229
+ break
230
+
231
+ if not asset_url:
232
+ # Fallback to scottstanie's repo which often hosts the windows binaries
233
+ api_url2 = "https://api.github.com/repos/scottstanie/snaphu-py/releases/latest"
234
+ req2 = urllib.request.Request(api_url2, headers={'User-Agent': 'PyGeoFetch'})
235
+ with urllib.request.urlopen(req2, timeout=15) as response2:
236
+ data2 = json.loads(response2.read().decode())
237
+ for asset in data2.get("assets", []):
238
+ if "win" in asset["name"].lower() and asset["name"].endswith(".zip"):
239
+ asset_url = asset["browser_download_url"]
240
+ break
241
+ except Exception as e:
242
+ logger.warning(f"Could not query GitHub API for SNAPHU binary: {e}")
243
+
244
+ if not asset_url:
245
+ raise RuntimeError(
246
+ "Could not find a pre-compiled SNAPHU executable for Windows.\n"
247
+ "Please install it manually and add it to your PATH, or install via pip:\n"
248
+ " pip install snaphu"
249
+ )
250
+
251
+ logger.info(f"Downloading SNAPHU from {asset_url}...")
252
+ try:
253
+ with tempfile.TemporaryDirectory() as tmp_dir:
254
+ zip_path = Path(tmp_dir) / "snaphu.zip"
255
+ urllib.request.urlretrieve(asset_url, str(zip_path))
256
+
257
+ with zipfile.ZipFile(zip_path, 'r') as zf:
258
+ exe_files = [f for f in zf.namelist() if f.lower().endswith(".exe")]
259
+ if not exe_files:
260
+ raise RuntimeError("Downloaded zip did not contain an .exe file.")
261
+
262
+ with zf.open(exe_files[0]) as src, open(local_exe, "wb") as dst:
263
+ shutil.copyfileobj(src, dst)
264
+
265
+ logger.info(f"SNAPHU downloaded successfully to {local_exe}")
266
+ return str(local_exe)
267
+ except Exception as e:
268
+ raise RuntimeError(f"Failed to download/extract SNAPHU executable: {e}")
269
+
270
+ else:
271
+ raise RuntimeError(
272
+ "SNAPHU executable not found in PATH.\n"
273
+ "Please install it using your system package manager:\n"
274
+ " Ubuntu/Debian: sudo apt-get install snaphu\n"
275
+ " macOS (Homebrew): brew install snaphu\n"
276
+ "Or install the Python bindings: pip install snaphu"
44
277
  )
45
278
 
46
279
 
@@ -52,49 +285,6 @@ def goldstein_filter(
52
285
  ):
53
286
  """
54
287
  Adaptive Goldstein phase filter.
55
-
56
- Suppresses interferometric phase noise in the frequency domain,
57
- per-tile, weighting each spatial frequency by its own local power.
58
- This is fundamentally different from multilook() -- multilooking
59
- averages blindly in the spatial domain regardless of what's in the
60
- data; Goldstein filtering adaptively preserves real fringe patterns
61
- while suppressing noise, since noise and signal separate cleanly in
62
- the frequency domain but not in the spatial domain. The two are
63
- complementary, not substitutes -- multilook first to reduce array
64
- size and get an honest coherence estimate, then Goldstein filter
65
- the (still noisy) phase before unwrapping.
66
-
67
- Verified before use, not assumed: on synthetic data with known
68
- ground truth (real fringe pattern, coherence=0.5, matching this
69
- project's real Mexico City coherence level), filtering reduced mean
70
- phase error from 0.96 rad to 0.36 rad against the true phase (62%
71
- reduction). More directly: on synthetic data at coherence=0.55 that
72
- reproducibly failed to unwrap (0% reliable, matching every real
73
- failure seen across this project's Obuasi, Accra, and Mexico City
74
- InSAR work), the SAME data with Goldstein filtering applied first
75
- unwrapped at 94.8% reliable.
76
-
77
- Args:
78
- interferogram: Complex interferogram (ref * conj(sec)), any
79
- shape. Real, non-complex wrapped phase should be
80
- converted first via np.exp(1j * phase).
81
- alpha: Filter strength, 0.0 (no filtering, returns the
82
- input essentially unchanged) to 1.0 (aggressive,
83
- risks distorting real signal in low-SNR tiles).
84
- 0.5-0.7 is a reasonable starting range; higher
85
- values trade more noise suppression for more
86
- risk of altering genuine phase structure.
87
- tile_size: FFT tile size in pixels. Smaller tiles adapt to
88
- local noise better but have less frequency
89
- resolution; larger tiles are the opposite.
90
- 32-64 is standard practice.
91
- overlap: Fraction of tile overlap (0-1), blended with a
92
- Hann window to avoid tile-boundary artifacts.
93
- 0.5 (50%) is standard and verified here.
94
-
95
- Returns:
96
- Filtered complex interferogram, same shape and dtype
97
- (complex64) as the input.
98
288
  """
99
289
  import numpy as np
100
290
 
@@ -138,7 +328,7 @@ def goldstein_filter(
138
328
 
139
329
  valid = weight_sum > 0
140
330
  output[valid] /= weight_sum[valid]
141
- output[~valid] = interferogram[~valid] # untouched where no tile ever covered it
331
+ output[~valid] = interferogram[~valid]
142
332
  return output.astype(np.complex64)
143
333
 
144
334
 
@@ -150,75 +340,7 @@ def bridge_unwrap_regions(
150
340
  reference_pixel=None,
151
341
  ):
152
342
  """
153
- Bridging unwrapping-error correction (Yunjun, Fattahi & Amelung 2019,
154
- Computers & Geosciences 133, 104331 -- the method MintPy uses,
155
- ported and adapted natively here rather than depending on MintPy).
156
-
157
- SNAPHU unwraps each connected component (conncomp) internally
158
- consistently, but has no way to relate physically DISCONNECTED
159
- regions to each other -- each island's phase could be off from the
160
- others by an unknown INTEGER multiple of 2*pi, since SNAPHU's
161
- network-flow solver never sees a path connecting them. Bridging
162
- finds the closest point-pair between regions and estimates that
163
- integer offset from the real, local median phase difference there
164
- (robust to per-pixel noise), then corrects the whole region by that
165
- exact multiple of 2*pi.
166
-
167
- Real, important, honest limitation, not a flaw in this
168
- implementation specifically: bridging can only recover RELATIVE
169
- consistency between regions, never absolute truth. The largest
170
- region is used as the reference (offset 0); if THAT region itself
171
- carries a real, undetected cycle error, every other region gets
172
- correctly bridged relative to it but the whole scene stays offset
173
- from ground truth by that same amount. This is the same fundamental
174
- limitation that's why SBASTimeSeries needs an explicit reference
175
- pixel -- no phase-based method alone can anchor to an absolute
176
- value without external ground truth (a GPS station, a known-stable
177
- reference point, etc.).
178
-
179
- Verified before use: on synthetic data with THREE disconnected
180
- regions and different, deliberate, known integer-cycle errors
181
- introduced in each (including realistic per-pixel phase noise at
182
- coherence=0.6), every pairwise region-to-region consistency check
183
- came back under 0.02 rad after correction -- confirming the method
184
- correctly recovers mutual consistency, the real, valid claim this
185
- technique can make (verified NOT to claim absolute-truth recovery,
186
- which no such method can deliver without external reference data).
187
-
188
- Args:
189
- unwrapped_phase: 2D array of unwrapped phase (radians), as
190
- produced by PhaseUnwrapper.unwrap().
191
- conncomp: 2D array of connected-component labels from the
192
- same unwrap() call, same shape. 0 = unreliable
193
- (not part of any real region).
194
- bridge_radius: Half-size of the window around each bridge
195
- endpoint used to compute the robust median phase
196
- difference. Matches MintPy's own default (50).
197
- min_region_size: Regions smaller than this (in pixels) are
198
- excluded from bridging -- too small to trust a
199
- median estimate from, left as unreliable.
200
- reference_pixel: Optional (row, col). If given, the region
201
- CONTAINING this pixel is used as the reference
202
- (offset 0) instead of always defaulting to the
203
- largest region. Matters for multi-pair use (e.g.
204
- SBAS): bridging each pair independently with the
205
- default (largest-region) reference can anchor
206
- different pairs to genuinely different real
207
- locations, since "largest region" isn't
208
- necessarily the same region pair to pair —
209
- confirmed directly: the same pixel's bridged
210
- value varied by tens of radians across pairs
211
- without this, corrupting the downstream joint
212
- inversion even though each pair was individually
213
- correctly bridged. Pass the SAME reference_pixel
214
- used for SBAS's own referencing to fix this.
215
-
216
- Returns:
217
- (corrected_phase, offsets_applied) -- corrected_phase is the
218
- same shape as unwrapped_phase, with each region's integer-cycle
219
- offset applied. offsets_applied is a dict mapping region label
220
- to the real, applied correction in radians (0 for the
221
- reference region, by definition).
343
+ Bridging unwrapping-error correction.
222
344
  """
223
345
  import numpy as np
224
346
  from scipy.spatial import cKDTree
@@ -238,9 +360,7 @@ def bridge_unwrap_regions(
238
360
  if rp_label == 0 or rp_label not in valid_labels:
239
361
  raise ValueError(
240
362
  f"reference_pixel {reference_pixel} is not part of any "
241
- f"valid (reliable, large-enough) region — cannot anchor "
242
- f"bridging there. Pick a pixel with conncomp != 0 in a "
243
- f"region at least min_region_size={min_region_size} pixels."
363
+ f"valid (reliable, large-enough) region."
244
364
  )
245
365
  reference_label = rp_label
246
366
  else:
@@ -252,7 +372,7 @@ def bridge_unwrap_regions(
252
372
  remaining = set(valid_labels) - {reference_label}
253
373
 
254
374
  while remaining:
255
- best = None # (distance, label, target_label, point_a, point_b)
375
+ best = None
256
376
  for lbl in remaining:
257
377
  lbl_points = np.column_stack(np.where(conncomp == lbl))
258
378
  for resolved_lbl, ref_points in resolved_points.items():
@@ -295,64 +415,7 @@ def multilook(
295
415
  wrapped_phase: Optional[bool] = None,
296
416
  ) -> Any:
297
417
  """
298
- Average an array down by the given factor in each dimension before
299
- unwrapping — reduces both pixel count and phase noise, the standard
300
- technique for improving unwrapping reliability on low-coherence data
301
- (Eineder 1999; cited directly in ESA's InSAR processing tutorial,
302
- TM-19: "In cases of low coherence (say 0.1), the number of looks to
303
- be averaged should increase up to 100").
304
-
305
- Args:
306
- data: 2D array to multilook. Complex (interferogram),
307
- or real-valued (coherence, wrapped phase,
308
- unwrapped phase, or any other raster).
309
- looks_azimuth: Averaging factor along axis 0.
310
- looks_range: Averaging factor along axis 1.
311
- wrapped_phase: Required for real-valued input, not optional or
312
- defaulted — dtype alone cannot tell wrapped phase
313
- apart from coherence or unwrapped phase, and
314
- guessing wrong is not a small error:
315
-
316
- - wrapped_phase=True: circular (complex-exponential)
317
- averaging. Correct for phase bounded to
318
- [-pi, pi) — plain arithmetic averaging would be
319
- biased wherever a look-window straddles the
320
- wrap boundary (e.g. averaging -3.1 and +3.1 rad
321
- arithmetically gives ~0, when the true circular
322
- average is near +-pi).
323
- - wrapped_phase=False: plain arithmetic averaging.
324
- Correct for coherence (bounded [0,1], not an
325
- angle at all) and for unwrapped phase (an
326
- unbounded real value). Verified directly:
327
- circular-averaging real unwrapped phase of
328
- ~45 rad silently collapses it to ~1.3 rad,
329
- destroying the very thing unwrapping produced.
330
-
331
- Complex input (interferograms) ignores this
332
- parameter entirely and always averages directly
333
- as complex numbers — unambiguous, no real/wrapped
334
- distinction applies.
335
-
336
- Returns:
337
- The multilooked array, same dtype family as the input.
338
-
339
- Raises:
340
- ValueError: if `data` is real-valued and `wrapped_phase` was not
341
- explicitly specified — a real, deliberate refusal to guess,
342
- not an oversight. Silent dtype-based guessing was the
343
- original design of this function before this exact failure
344
- mode was found and fixed.
345
-
346
- Example::
347
-
348
- # A real interferogram (complex) -- unambiguous
349
- igram_ml = multilook(interferogram, looks_azimuth=4, looks_range=1)
350
-
351
- # Wrapped phase -- must say so explicitly
352
- phase_ml = multilook(wrapped_phase_rad, 4, 1, wrapped_phase=True)
353
-
354
- # Coherence -- must say so explicitly (plain averaging)
355
- coherence_ml = multilook(coherence, 4, 1, wrapped_phase=False)
418
+ Average an array down by the given factor in each dimension.
356
419
  """
357
420
  import numpy as np
358
421
 
@@ -368,13 +431,8 @@ def multilook(
368
431
  if wrapped_phase is None:
369
432
  raise ValueError(
370
433
  "multilook() received real-valued input but wrapped_phase was "
371
- "not specified. This is required, not optional: dtype alone "
372
- "cannot distinguish wrapped phase (needs circular averaging) "
373
- "from coherence or unwrapped phase (needs plain arithmetic "
374
- "averaging), and guessing wrong silently corrupts the data "
375
- "rather than raising an error. Pass wrapped_phase=True for "
376
- "wrapped phase in radians, or wrapped_phase=False for "
377
- "coherence, unwrapped phase, or any other real-valued raster."
434
+ "not specified. Pass wrapped_phase=True for wrapped phase, "
435
+ "or wrapped_phase=False for coherence/unwrapped phase."
378
436
  )
379
437
 
380
438
  if wrapped_phase:
@@ -386,50 +444,83 @@ def multilook(
386
444
 
387
445
  class PhaseUnwrapper:
388
446
  """
389
- Phase unwrapping via SNAPHU (Statistical-cost, Network-flow Algorithm).
390
-
391
- Args:
392
- cost_mode: SNAPHU cost function — ``"topo"`` (default, for terrain),
393
- ``"defo"`` (for deformation — less smoothing bias),
394
- ``"smooth"`` (generic smoothness prior),
395
- ``"nostatcosts"`` (uniform cost, fastest, least accurate).
396
- init_method: ``"mcf"`` (default — Minimum Cost Flow, matches ASF/GAMMA)
397
- or ``"mst"`` (Minimum Spanning Tree — faster, less optimal).
398
-
399
- Example::
400
-
401
- from pygeofetch.insar import InterferogramGenerator, PhaseUnwrapper
402
-
403
- gen = InterferogramGenerator()
404
- result = gen.process_pair("ref.tif", "sec.tif", dem="dem.tif")
405
-
406
- unwrapper = PhaseUnwrapper(cost_mode="defo")
407
- unwrapped, conncomp = unwrapper.unwrap(
408
- result.interferogram, result.coherence
409
- )
410
-
411
- Choosing cost_mode:
412
- - Use ``"topo"`` for DEM generation / topographic mapping tasks
413
- where phase gradients follow terrain.
414
- - Use ``"defo"`` for deformation monitoring (subsidence, volcanic,
415
- earthquake) where phase gradients follow displacement, not
416
- terrain — this is the standard choice for MintPy/SBAS workflows.
447
+ Phase unwrapping via SNAPHU.
417
448
  """
418
449
 
419
450
  def __init__(self, cost_mode: str = "defo", init_method: str = "mcf") -> None:
420
451
  valid_costs = ("topo", "defo", "smooth", "nostatcosts")
421
452
  if cost_mode not in valid_costs:
422
- raise ValueError(
423
- f"cost_mode must be one of {valid_costs}, got {cost_mode!r}"
424
- )
453
+ raise ValueError(f"cost_mode must be one of {valid_costs}, got {cost_mode!r}")
425
454
  valid_inits = ("mcf", "mst")
426
455
  if init_method not in valid_inits:
427
- raise ValueError(
428
- f"init_method must be one of {valid_inits}, got {init_method!r}"
429
- )
456
+ raise ValueError(f"init_method must be one of {valid_inits}, got {init_method!r}")
430
457
  self._cost_mode = cost_mode
431
458
  self._init_method = init_method
432
459
 
460
+ def _unwrap_cli(self, igram, corr, nlooks, min_conncomp_frac, min_region_size):
461
+ """
462
+ Fallback unwrapping using the SNAPHU CLI executable.
463
+ Bypasses Python C-extension compilation issues on Windows entirely.
464
+ """
465
+ import numpy as np
466
+
467
+ cli_path = _ensure_snaphu_cli()
468
+ height, width = igram.shape
469
+
470
+ with tempfile.TemporaryDirectory() as tmp_dir:
471
+ tmp = Path(tmp_dir)
472
+ igram_path = tmp / "igram.cpx"
473
+ corr_path = tmp / "corr.cor"
474
+ unw_path = tmp / "unw.unw"
475
+ cc_path = tmp / "conncomp.cc"
476
+ conf_path = tmp / "snaphu.conf"
477
+
478
+ # Write binary files
479
+ igram.tofile(str(igram_path))
480
+ corr.tofile(str(corr_path))
481
+
482
+ # Write config file using relative paths to avoid Windows space issues
483
+ nlooks_int = max(1, int(nlooks))
484
+ conf_text = f"""INFILE igram.cpx
485
+ INFILEFORMAT COMPLEX_DATA
486
+ CORRFILE corr.cor
487
+ CORRFILEFORMAT FLOAT_DATA
488
+ OUTFILE unw.unw
489
+ OUTFILEFORMAT FLOAT_DATA
490
+ CONNCOMPFILE conncomp.cc
491
+ CONNCOMPOUTFORMAT UINT_DATA
492
+ LINELENGTH {width}
493
+ STATCOSTMODE {self._cost_mode.upper()}
494
+ INITMETHOD {self._init_method.upper()}
495
+ NLOOKSRANGE {nlooks_int}
496
+ NLOOKSAZ {nlooks_int}
497
+ """
498
+ conf_path.write_text(conf_text)
499
+
500
+ # Run SNAPHU from within the temp directory
501
+ try:
502
+ cmd = [cli_path, "-f", "snaphu.conf"]
503
+ result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(tmp), timeout=3600)
504
+ if result.returncode != 0:
505
+ raise RuntimeError(f"SNAPHU CLI failed with code {result.returncode}: {result.stderr}")
506
+ except Exception as e:
507
+ raise RuntimeError(f"SNAPHU CLI execution failed: {e}")
508
+
509
+ # Read outputs
510
+ unw = np.fromfile(str(unw_path), dtype=np.float32).reshape((height, width))
511
+ conncomp = np.fromfile(str(cc_path), dtype=np.uint32).reshape((height, width))
512
+
513
+ # Apply reliability filtering to match Python API behavior
514
+ total_pixels = height * width
515
+ labels, counts = np.unique(conncomp, return_counts=True)
516
+ for lbl, count in zip(labels, counts):
517
+ if lbl == 0:
518
+ continue
519
+ if count < min_region_size or (count / total_pixels) < min_conncomp_frac:
520
+ conncomp[conncomp == lbl] = 0
521
+
522
+ return unw, conncomp.astype(np.int32)
523
+
433
524
  def unwrap(
434
525
  self,
435
526
  interferogram: Any,
@@ -439,56 +530,11 @@ class PhaseUnwrapper:
439
530
  min_conncomp_frac: float = 0.01,
440
531
  min_region_size: int = 100,
441
532
  ) -> Tuple[Any, Any]:
442
- """
443
- Unwrap a wrapped interferogram phase.
444
-
445
- Args:
446
- interferogram: Complex64 array (wrapped interferogram) OR
447
- float32 array of wrapped phase in radians.
448
- coherence: Float32 coherence array, 0-1, same shape.
449
- nlooks: Effective number of looks (affects statistical
450
- cost weighting). Use the multilook factor applied
451
- during interferogram formation.
452
- mask: Optional boolean array — True = valid, False = masked out
453
- (e.g. water bodies, layover/shadow regions).
454
- min_conncomp_frac: Minimum size of a connected component, as a
455
- fraction of total pixels, for SNAPHU to mark it
456
- reliable (conncomp != 0). snaphu-py's own
457
- default (0.01 = 1% of the whole scene) can
458
- discard genuinely valid, internally-consistent
459
- regions when the real coherent area is small
460
- relative to the full scene — confirmed
461
- directly: a real low-coherence scene with a
462
- small but real coherent patch reported 100%
463
- unreliable at the default, purely because that
464
- real patch fell under the 1%-of-scene pixel
465
- threshold, not because nothing was actually
466
- unwrappable. Lower this (e.g. 0.001-0.005) for
467
- scenes with small AOIs or patchy real coherence.
468
- min_region_size: Minimum absolute pixel count for the same
469
- reliability decision — snaphu-py's own default
470
- is 100. Works alongside min_conncomp_frac (the
471
- less restrictive of the two typically governs
472
- on a small scene).
473
-
474
- Returns:
475
- (unwrapped_phase, conncomp) — both same shape as input.
476
- unwrapped_phase: float32 radians, continuous (not wrapped to [-pi, pi))
477
- conncomp: int32 connected-component labels (0 = unreliable/masked)
478
-
479
- Example::
480
-
481
- unwrapped, conncomp = unwrapper.unwrap(igram, coherence, nlooks=4.0)
482
- # conncomp == 0 marks pixels SNAPHU could not confidently unwrap
483
- reliable = conncomp > 0
484
- """
485
533
  np = self._np()
486
- sx = _require_snaphu()
487
534
 
488
535
  if np.iscomplexobj(interferogram):
489
536
  igram = interferogram.astype(np.complex64)
490
537
  else:
491
- # Treat as wrapped phase in radians — convert to unit-magnitude complex
492
538
  igram = np.exp(1j * interferogram).astype(np.complex64)
493
539
 
494
540
  corr = np.clip(coherence, 0.0, 1.0).astype(np.float32)
@@ -496,9 +542,6 @@ class PhaseUnwrapper:
496
542
  if mask is not None:
497
543
  corr = np.where(mask, corr, 0.0).astype(np.float32)
498
544
 
499
- # Real, worthwhile defensive check: NaN/Inf coherence (e.g. from
500
- # an upstream division-by-zero on a fully decorrelated pixel)
501
- # would otherwise reach SNAPHU as invalid input.
502
545
  corr = np.nan_to_num(corr, nan=0.0, posinf=0.0, neginf=0.0)
503
546
 
504
547
  logger.info(
@@ -510,7 +553,9 @@ class PhaseUnwrapper:
510
553
  )
511
554
 
512
555
  try:
513
- unwrapped, conncomp = sx.unwrap(
556
+ # Try Python API first (fastest)
557
+ import snaphu
558
+ unwrapped, conncomp = snaphu.unwrap(
514
559
  igram,
515
560
  corr,
516
561
  nlooks=nlooks,
@@ -519,12 +564,17 @@ class PhaseUnwrapper:
519
564
  min_conncomp_frac=min_conncomp_frac,
520
565
  min_region_size=min_region_size,
521
566
  )
567
+ except ImportError:
568
+ # Fallback to CLI (solves Windows installation nightmares)
569
+ logger.info("snaphu Python package not found. Falling back to SNAPHU CLI executable...")
570
+ unwrapped, conncomp = self._unwrap_cli(
571
+ igram, corr, nlooks, min_conncomp_frac, min_region_size
572
+ )
522
573
  except Exception as exc:
523
574
  raise RuntimeError(
524
575
  f"SNAPHU unwrapping failed: {exc}\n"
525
576
  "Common causes: incompatible array shapes, all-zero coherence, "
526
- "or insufficient memory for large scenes. Consider multilooking "
527
- "the interferogram first to reduce pixel count."
577
+ "or insufficient memory for large scenes."
528
578
  ) from exc
529
579
 
530
580
  n_unreliable = int(np.sum(conncomp == 0))
@@ -532,8 +582,7 @@ class PhaseUnwrapper:
532
582
  if pct > 30:
533
583
  logger.warning(
534
584
  "%.1f%% of pixels are in the unreliable connected component "
535
- "(conncomp==0). Consider improving coherence via multilooking "
536
- "or filtering, or check for large decorrelated areas.",
585
+ "(conncomp==0). Consider improving coherence via multilooking.",
537
586
  pct,
538
587
  )
539
588
  else:
@@ -541,6 +590,87 @@ class PhaseUnwrapper:
541
590
 
542
591
  return unwrapped.astype(np.float32), conncomp
543
592
 
593
+ def unwrap_pair(
594
+ self,
595
+ interferogram: Any,
596
+ coherence: Any,
597
+ profile: Dict[str, Any],
598
+ reference_date: Optional[str] = None,
599
+ secondary_date: Optional[str] = None,
600
+ nlooks: float = 1.0,
601
+ looks_azimuth: int = 1,
602
+ looks_range: int = 1,
603
+ mask: Optional[Any] = None,
604
+ min_conncomp_frac: float = 0.001,
605
+ min_region_size: int = 100,
606
+ ) -> UnwrapResult:
607
+ """
608
+ Unwrap a single interferogram pair and return an UnwrapResult.
609
+
610
+ This is the high-level method that combines unwrap() + result
611
+ packaging. Use this in notebook cells for clean orchestration:
612
+
613
+ result = unwrapper.unwrap_pair(phase_ml, coh_ml, profile,
614
+ looks_azimuth=UNWRAP_LOOKS_AZ,
615
+ looks_range=UNWRAP_LOOKS_RG)
616
+ result.save(output_dir / f"{d1}_{d2}", save_png=True)
617
+
618
+ Args:
619
+ interferogram: Complex interferogram or wrapped phase (radians).
620
+ coherence: Coherence array (0-1), same shape.
621
+ profile: Rasterio-style profile for georeferencing.
622
+ reference_date, secondary_date: ISO date strings for labeling.
623
+ nlooks: Effective number of looks.
624
+ looks_azimuth, looks_range: Multilook factors used to produce the
625
+ input arrays. Used to correctly scale
626
+ the georeferencing transform.
627
+ mask: Optional boolean mask (True=valid).
628
+ min_conncomp_frac: Minimum connected component fraction.
629
+ min_region_size: Minimum region size in pixels.
630
+
631
+ Returns:
632
+ UnwrapResult with all data packaged and ready to save.
633
+ """
634
+ import numpy as np
635
+
636
+ unwrapped, conncomp = self.unwrap(
637
+ interferogram, coherence,
638
+ nlooks=nlooks,
639
+ mask=mask,
640
+ min_conncomp_frac=min_conncomp_frac,
641
+ min_region_size=min_region_size,
642
+ )
643
+
644
+ reliable_fraction = float(np.mean(conncomp > 0))
645
+
646
+ # Scale the profile transform to match the multilooked arrays
647
+ scaled_profile = dict(profile)
648
+ if looks_azimuth > 1 or looks_range > 1:
649
+ if scaled_profile.get("transform") is not None:
650
+ from rasterio.transform import Affine
651
+ scaled_profile["transform"] = scaled_profile["transform"] * Affine.scale(looks_range, looks_azimuth)
652
+ scaled_profile["height"] = unwrapped.shape[0]
653
+ scaled_profile["width"] = unwrapped.shape[1]
654
+
655
+ return UnwrapResult(
656
+ unwrapped_phase=unwrapped,
657
+ conncomp=conncomp,
658
+ coherence=coherence,
659
+ profile=scaled_profile,
660
+ reference_date=reference_date,
661
+ secondary_date=secondary_date,
662
+ nlooks=nlooks,
663
+ cost_mode=self._cost_mode,
664
+ init_method=self._init_method,
665
+ reliable_fraction=reliable_fraction,
666
+ metadata={
667
+ "cost_mode": self._cost_mode,
668
+ "init_method": self._init_method,
669
+ "nlooks": nlooks,
670
+ "reliable_fraction": reliable_fraction,
671
+ },
672
+ )
673
+
544
674
  def unwrap_files(
545
675
  self,
546
676
  interferogram_path: Union[str, Path],
@@ -549,26 +679,6 @@ class PhaseUnwrapper:
549
679
  nlooks: float = 1.0,
550
680
  mask_path: Optional[Union[str, Path]] = None,
551
681
  ) -> Path:
552
- """
553
- Unwrap directly from/to GeoTIFF files, preserving georeferencing.
554
-
555
- Args:
556
- interferogram_path: Wrapped phase or complex interferogram GeoTIFF.
557
- coherence_path: Coherence GeoTIFF (0-1).
558
- output_path: Output path for the unwrapped phase GeoTIFF.
559
- nlooks: Effective number of looks.
560
- mask_path: Optional binary mask GeoTIFF (1=valid, 0=masked).
561
-
562
- Returns:
563
- Path to the unwrapped phase GeoTIFF.
564
-
565
- Example::
566
-
567
- unwrapper.unwrap_files(
568
- "wrapped_phase.tif", "coherence.tif",
569
- output_path="unwrapped.tif", nlooks=4.0,
570
- )
571
- """
572
682
  np = self._np()
573
683
  try:
574
684
  import rasterio
@@ -622,5 +732,4 @@ class PhaseUnwrapper:
622
732
 
623
733
  def _np(self):
624
734
  import numpy as np
625
-
626
735
  return np
pygeofetch/meta.yaml CHANGED
@@ -1,4 +1,4 @@
1
- {% set version = "2.0.3" %}
1
+ {% set version = "2.0.4" %}
2
2
 
3
3
  package:
4
4
  name: pygeofetch
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygeofetch
3
- Version: 2.0.3
3
+ Version: 2.0.4
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
@@ -1,11 +1,11 @@
1
- pygeofetch/__init__.py,sha256=g7_nMbdOgkKQ2ey8F9Vne6cto0205qDjI-sfcj-JULs,1990
2
- pygeofetch/meta.yaml,sha256=k9ZW4b5eJItOWjelJsCPU0XUp1-2KgXXiklYjo4vxSI,1280
1
+ pygeofetch/__init__.py,sha256=BKesKaBedlzLMOOF5DKJ5qFl2_HT0ND_1DzMXYQuSLc,1990
2
+ pygeofetch/meta.yaml,sha256=jLWw6eKXkoNNHhqAMfC8viFsNaPLFS6cVVXBnzFq5u0,1280
3
3
  pygeofetch/cli/__init__.py,sha256=QZMIIHEgmjGFieucqq9fZ5uephxP9-jdu1s8Q4qqw8o,30
4
4
  pygeofetch/cli/auth_commands.py,sha256=YqbrN_BYcAYCaUIYLpSJ_VIw35tKFUiD9Kc55SWbDWg,7742
5
5
  pygeofetch/cli/config_commands.py,sha256=1Pps0HLCLPODMlM6SFV8ru3IKCDpexSTz8uKIXvIIF8,3918
6
6
  pygeofetch/cli/download_commands.py,sha256=reQ9vmA50mj1zqechjLHAOe_cjBfa1miqfWa8UiIlxU,11741
7
7
  pygeofetch/cli/index_commands.py,sha256=IoLGrsSU-lTcBitFFsg8EtucqSKZtNL-0ODj_I8Twew,10020
8
- pygeofetch/cli/main.py,sha256=6p8mrv2zmIF0NFV04v1gf4Gp0VU48xHmRC4XlXi1pW8,30335
8
+ pygeofetch/cli/main.py,sha256=wJG74Rbfsolf4xgUTq4S8UTZ0aVrX1IxyRSJzUA1ncA,30335
9
9
  pygeofetch/cli/pipeline_process_commands.py,sha256=OvuouT35zZlrC-7hWAJRfaY_aUNk4ScK0Fia9WVB_Gw,6904
10
10
  pygeofetch/cli/postprocess_commands.py,sha256=kof_f-kjjvYl5eqPqrcNHSOUM_qVkGdSh6-N4vcx2y4,5825
11
11
  pygeofetch/cli/preprocess_commands.py,sha256=cFp1VR03Q1pmCwiBWYmS5lXF3NOnzVAq3XgrZh3rBdU,8696
@@ -39,7 +39,7 @@ pygeofetch/insar/splitspectrum_diagnostic.py,sha256=_IlfBs__BW7Yz2RwdmWxfrOspe67
39
39
  pygeofetch/insar/stack_selection.py,sha256=ebFwO063cCT9kn3zLPlkYu6fpnfx-SRC90Ch18E7qJk,13157
40
40
  pygeofetch/insar/synthetic.py,sha256=FazdaG_u3GR1qk8022T92W2uxDJPg7OJaUSGY0wR0pw,16248
41
41
  pygeofetch/insar/timeseries.py,sha256=EtLJiPySMaUWwyNxI2GyfBdkqB4W-uvL-_E_T_POHz4,32063
42
- pygeofetch/insar/unwrap.py,sha256=Sv6T5X5Zis44c_uxZy466vaHahwfO0tTiqvl0wdknag,27369
42
+ pygeofetch/insar/unwrap.py,sha256=3cAEI80KkY98lZ8rHNWlqzr-TXt_6RVWT3auslTi4FA,28218
43
43
  pygeofetch/insar/validate.py,sha256=BZaVvaPNllfDzoPwat0vDA4do6H5xh5Ju_g4r5YNogE,10110
44
44
  pygeofetch/insar/visualize.py,sha256=D_KlWkpuOvfjke6ThHcMBkZTRwWn1KdBFNL1TBcy6kI,7543
45
45
  pygeofetch/insar/workflow.py,sha256=8IxGgabdz9fDLoGAVrJRCdw16GP3sSDSoFPXyv1RXcg,11713
@@ -104,9 +104,9 @@ pygeofetch/utils/validators.py,sha256=f-k3mGFP4byjimyDymhPEdsz5I16jZacx1HSnwrARj
104
104
  pygeofetch/viz/__init__.py,sha256=xf2C9IL4n3bv3oxk8RYkogq7nkuNyt18V-urAoXWRQY,449
105
105
  pygeofetch/viz/map.py,sha256=vYO99NvTumCAVXVtXcEzMx_cpmH-FvSVNWl8X0PNSgE,15494
106
106
  pygeofetch/viz/plot.py,sha256=I16rdOWb80gcjYDsDvTMzuyek7QHgUNOQxY2RejBAPs,53675
107
- pygeofetch-2.0.3.dist-info/licenses/LICENSE,sha256=zavMyhODD6NvZTmmKFEtATrngwY4CU_NEtY7lhjxGnI,1075
108
- pygeofetch-2.0.3.dist-info/METADATA,sha256=ogUCAYOSzmGFw-n2_2siFpF3CNsKg1uB5mhjFfXv0pQ,43254
109
- pygeofetch-2.0.3.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
110
- pygeofetch-2.0.3.dist-info/entry_points.txt,sha256=dHiUXLF5xNJ8vgtXZoUQNleEMQUeDPZiKQOE1tW8SDY,55
111
- pygeofetch-2.0.3.dist-info/top_level.txt,sha256=rOmnZargIfNk99fwVOqt6OJyJvpSTBBAYO5HU37yhHk,11
112
- pygeofetch-2.0.3.dist-info/RECORD,,
107
+ pygeofetch-2.0.4.dist-info/licenses/LICENSE,sha256=zavMyhODD6NvZTmmKFEtATrngwY4CU_NEtY7lhjxGnI,1075
108
+ pygeofetch-2.0.4.dist-info/METADATA,sha256=RtGCL4jStxgOt8TpMJb0WHGaCPdQPpoByRXVqGpKeAo,43254
109
+ pygeofetch-2.0.4.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
110
+ pygeofetch-2.0.4.dist-info/entry_points.txt,sha256=dHiUXLF5xNJ8vgtXZoUQNleEMQUeDPZiKQOE1tW8SDY,55
111
+ pygeofetch-2.0.4.dist-info/top_level.txt,sha256=rOmnZargIfNk99fwVOqt6OJyJvpSTBBAYO5HU37yhHk,11
112
+ pygeofetch-2.0.4.dist-info/RECORD,,