PyGeoFetch 2.0.9__py3-none-any.whl → 2.1.0__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.9 — Universal Satellite Data Pipeline.
2
+ PyGeoFetch v2.1.0 — 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.9"
33
+ __version__ = "2.1.0"
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.9 main entry point.
2
+ PyGeoFetch CLI v2.1.0 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.9 — Universal Satellite Data Pipeline
74
+ PyGeoFetch v2.1.0 — Universal Satellite Data Pipeline
75
75
  ===========================================================
76
76
  Unified access to 22+ satellite data providers.
77
77
 
@@ -29,6 +29,7 @@ import zipfile
29
29
  import shutil
30
30
  import json
31
31
  import tempfile
32
+ import io
32
33
  from dataclasses import dataclass, field
33
34
  from pathlib import Path
34
35
  from typing import Any, Dict, Optional, Tuple, Union
@@ -40,17 +41,11 @@ logger = logging.getLogger("pygeofetch.insar.unwrap")
40
41
  class UnwrapResult:
41
42
  """
42
43
  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
44
  """
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
45
+ unwrapped_phase: Any
46
+ conncomp: Any
47
+ coherence: Any
48
+ profile: Dict[str, Any]
54
49
  reference_date: Optional[str] = None
55
50
  secondary_date: Optional[str] = None
56
51
  nlooks: float = 1.0
@@ -65,29 +60,7 @@ class UnwrapResult:
65
60
  save_png: bool = False,
66
61
  mask_unreliable: bool = True,
67
62
  ) -> 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
63
  import numpy as np
90
-
91
64
  try:
92
65
  import rasterio
93
66
  except ImportError:
@@ -110,7 +83,6 @@ class UnwrapResult:
110
83
  "blockysize": 256,
111
84
  }
112
85
 
113
- # ── Unwrapped phase ──
114
86
  unw_to_save = self.unwrapped_phase.copy()
115
87
  if mask_unreliable:
116
88
  unw_to_save[self.conncomp == 0] = -9999.0
@@ -121,89 +93,58 @@ class UnwrapResult:
121
93
  dst.write(unw_to_save.astype(np.float32)[np.newaxis])
122
94
  paths["unwrapped_phase"] = phase_path
123
95
 
124
- # ── Connected components ──
125
96
  cc_path = out_dir / "conncomp.tif"
126
97
  cc_profile = dict(base_profile, dtype="int32", nodata=0)
127
98
  with rasterio.open(cc_path, "w", **cc_profile) as dst:
128
99
  dst.write(self.conncomp.astype(np.int32)[np.newaxis])
129
100
  paths["conncomp"] = cc_path
130
101
 
131
- # ── Coherence ──
132
102
  coh_path = out_dir / "coherence.tif"
133
103
  coh_profile = dict(base_profile, dtype="float32", nodata=-1.0)
134
104
  with rasterio.open(coh_path, "w", **coh_profile) as dst:
135
105
  dst.write(self.coherence.astype(np.float32)[np.newaxis])
136
106
  paths["coherence"] = coh_path
137
107
 
138
- logger.info(
139
- "Unwrap products saved → %s (reliable=%.1f%%)",
140
- out_dir, self.reliable_fraction * 100,
141
- )
108
+ logger.info("Unwrap products saved → %s (reliable=%.1f%%)", out_dir, self.reliable_fraction * 100)
142
109
 
143
- # ── Optional PNG visualizations ──
144
110
  if save_png:
145
- self._save_pngs(out_dir, unw_to_save, paths)
111
+ self._save_pngs(out_dir)
146
112
 
147
113
  return paths
148
114
 
149
- def _save_pngs(self, out_dir: Path, unw_masked, paths: Dict[str, Path]) -> None:
150
- """Save PNG visualizations with robust percentile scaling."""
115
+ def _save_pngs(self, out_dir: Path) -> None:
151
116
  import numpy as np
152
117
  import matplotlib
153
- matplotlib.use("Agg") # non-interactive backend for headless saving
118
+ matplotlib.use("Agg")
154
119
  import matplotlib.pyplot as plt
155
120
 
156
- # ── Unwrapped phase PNG ──
157
121
  fig, ax = plt.subplots(1, 1, figsize=(14, 8))
158
122
  masked_unw = np.ma.masked_where(self.conncomp == 0, self.unwrapped_phase)
159
123
  finite_unw = self.unwrapped_phase[self.conncomp > 0]
160
124
  if finite_unw.size > 0:
161
- vmin = float(np.percentile(finite_unw, 2))
162
- vmax = float(np.percentile(finite_unw, 98))
125
+ vmin, vmax = float(np.percentile(finite_unw, 2)), float(np.percentile(finite_unw, 98))
163
126
  else:
164
127
  vmin, vmax = -np.pi, np.pi
165
128
  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
- )
129
+ ax.set_title(f"Unwrapped Phase (rad)\n{self.reference_date} → {self.secondary_date} | reliable={self.reliable_fraction*100:.1f}%")
171
130
  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")
131
+ fig.savefig(out_dir / "unwrapped_phase.png", dpi=150, bbox_inches="tight")
174
132
  plt.close(fig)
175
133
 
176
- # ── Connected components PNG ──
177
134
  fig, ax = plt.subplots(1, 1, figsize=(14, 8))
178
135
  masked_cc = np.ma.masked_where(self.conncomp == 0, self.conncomp)
179
136
  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")
137
+ ax.set_title(f"Connected Components\n{self.reference_date} → {self.secondary_date}")
138
+ fig.savefig(out_dir / "conncomp.png", dpi=150, bbox_inches="tight")
187
139
  plt.close(fig)
188
140
 
189
- logger.info("Unwrap PNGs saved → %s", out_dir)
190
-
191
141
 
192
142
  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
143
  exe_name = "snaphu.exe" if platform.system() == "Windows" else "snaphu"
200
-
201
- # 1. System PATH
202
144
  path = shutil.which(exe_name)
203
145
  if path:
204
146
  return path
205
147
 
206
- # 2. Local cache
207
148
  bin_dir = Path.home() / ".pygeofetch" / "bin"
208
149
  bin_dir.mkdir(parents=True, exist_ok=True)
209
150
  local_exe = bin_dir / exe_name
@@ -211,12 +152,8 @@ def _ensure_snaphu_cli() -> str:
211
152
  if local_exe.exists():
212
153
  return str(local_exe)
213
154
 
214
- # 3. Download if on Windows
215
155
  if platform.system() == "Windows":
216
156
  logger.info("SNAPHU executable not found. Downloading pre-compiled binary for Windows...")
217
-
218
- # The official isce-framework/snaphu-py does not provide Windows wheels/binaries.
219
- # We use marsfan/SNAPHU-win which reliably hosts pre-compiled Windows binaries.
220
157
  api_url = "https://api.github.com/repos/marsfan/SNAPHU-win/releases/latest"
221
158
  asset_url = None
222
159
 
@@ -224,17 +161,14 @@ def _ensure_snaphu_cli() -> str:
224
161
  req = urllib.request.Request(api_url, headers={'User-Agent': 'PyGeoFetch'})
225
162
  with urllib.request.urlopen(req, timeout=15) as response:
226
163
  data = json.loads(response.read().decode())
227
-
228
164
  for asset in data.get("assets", []):
229
- name = asset["name"].lower()
230
- if name.endswith(".zip") and "snaphu" in name:
165
+ if asset["name"].lower().endswith(".zip") and "snaphu" in asset["name"].lower():
231
166
  asset_url = asset["browser_download_url"]
232
167
  break
233
168
  except Exception as e:
234
169
  logger.warning(f"Could not query GitHub API for SNAPHU binary: {e}")
235
170
 
236
171
  if not asset_url:
237
- # Hardcoded fallback to the known working 2.0.7 release
238
172
  asset_url = "https://github.com/marsfan/SNAPHU-win/releases/download/2.0.7/snaphu-v2.0.7.zip"
239
173
 
240
174
  logger.info(f"Downloading SNAPHU from {asset_url}...")
@@ -242,126 +176,82 @@ def _ensure_snaphu_cli() -> str:
242
176
  with tempfile.TemporaryDirectory() as tmp_dir:
243
177
  zip_path = Path(tmp_dir) / "snaphu.zip"
244
178
  urllib.request.urlretrieve(asset_url, str(zip_path))
245
-
246
179
  with zipfile.ZipFile(zip_path, 'r') as zf:
247
- # Look for snaphu.exe inside the zip (might be in a subfolder)
248
180
  exe_files = [f for f in zf.namelist() if f.lower().endswith(".exe") and "snaphu" in f.lower()]
249
181
  if not exe_files:
250
182
  exe_files = [f for f in zf.namelist() if f.lower().endswith(".exe")]
251
- if not exe_files:
252
- raise RuntimeError("Downloaded zip did not contain an .exe file.")
253
-
254
183
  with zf.open(exe_files[0]) as src, open(local_exe, "wb") as dst:
255
184
  shutil.copyfileobj(src, dst)
256
-
257
185
  logger.info(f"SNAPHU downloaded successfully to {local_exe}")
258
186
  return str(local_exe)
259
187
  except Exception as e:
260
188
  raise RuntimeError(f"Failed to download/extract SNAPHU executable: {e}")
261
189
 
262
- else:
263
- raise RuntimeError(
264
- "SNAPHU executable not found in PATH.\n"
265
- "Please install it using your system package manager:\n"
266
- " Ubuntu/Debian: sudo apt-get install snaphu\n"
267
- " macOS (Homebrew): brew install snaphu\n"
268
- "Or install the Python bindings: pip install snaphu"
269
- )
270
-
271
- def goldstein_filter(
272
- interferogram,
273
- alpha: float = 0.5,
274
- tile_size: int = 32,
275
- overlap: float = 0.5,
276
- ):
277
- """
278
- Adaptive Goldstein phase filter.
279
- """
280
- import numpy as np
190
+ raise RuntimeError(
191
+ "SNAPHU executable not found in PATH.\n"
192
+ "Please install it using your system package manager:\n"
193
+ " Ubuntu/Debian: sudo apt-get install snaphu\n"
194
+ " macOS (Homebrew): brew install snaphu\n"
195
+ "Or install the Python bindings: pip install snaphu"
196
+ )
197
+
281
198
 
199
+ def goldstein_filter(interferogram, alpha: float = 0.5, tile_size: int = 32, overlap: float = 0.5):
200
+ import numpy as np
282
201
  interferogram = np.asarray(interferogram, dtype=np.complex64)
283
202
  h, w = interferogram.shape
284
203
  step = max(1, int(tile_size * (1 - overlap)))
285
204
  window_1d = np.hanning(tile_size)
286
205
  window_2d = np.outer(window_1d, window_1d).astype(np.float32)
287
-
288
206
  output = np.zeros((h, w), dtype=np.complex64)
289
207
  weight_sum = np.zeros((h, w), dtype=np.float32)
290
-
291
208
  row_starts = list(range(0, max(1, h - tile_size + 1), step))
292
209
  col_starts = list(range(0, max(1, w - tile_size + 1), step))
293
- if not row_starts or row_starts[-1] + tile_size < h:
294
- row_starts.append(max(0, h - tile_size))
295
- if not col_starts or col_starts[-1] + tile_size < w:
296
- col_starts.append(max(0, w - tile_size))
297
-
210
+ if not row_starts or row_starts[-1] + tile_size < h: row_starts.append(max(0, h - tile_size))
211
+ if not col_starts or col_starts[-1] + tile_size < w: col_starts.append(max(0, w - tile_size))
298
212
  for row_start in row_starts:
299
213
  for col_start in col_starts:
300
214
  r_end = min(row_start + tile_size, h)
301
215
  c_end = min(col_start + tile_size, w)
302
216
  tile = interferogram[row_start:r_end, col_start:c_end]
303
217
  th, tw = tile.shape
304
- if th < 2 or tw < 2:
305
- continue
306
-
218
+ if th < 2 or tw < 2: continue
307
219
  spectrum = np.fft.fft2(tile)
308
220
  magnitude = np.abs(spectrum)
309
221
  peak = magnitude.max()
310
- if peak > 0:
311
- filtered_spectrum = spectrum * (magnitude ** alpha) / (peak ** alpha)
312
- else:
313
- filtered_spectrum = spectrum
222
+ if peak > 0: filtered_spectrum = spectrum * (magnitude ** alpha) / (peak ** alpha)
223
+ else: filtered_spectrum = spectrum
314
224
  filtered_tile = np.fft.ifft2(filtered_spectrum)
315
-
316
225
  tile_window = window_2d[:th, :tw]
317
226
  output[row_start:r_end, col_start:c_end] += filtered_tile * tile_window
318
227
  weight_sum[row_start:r_end, col_start:c_end] += tile_window
319
-
320
228
  valid = weight_sum > 0
321
229
  output[valid] /= weight_sum[valid]
322
230
  output[~valid] = interferogram[~valid]
323
231
  return output.astype(np.complex64)
324
232
 
325
233
 
326
- def bridge_unwrap_regions(
327
- unwrapped_phase,
328
- conncomp,
329
- bridge_radius: int = 50,
330
- min_region_size: int = 100,
331
- reference_pixel=None,
332
- ):
333
- """
334
- Bridging unwrapping-error correction.
335
- """
234
+ def bridge_unwrap_regions(unwrapped_phase, conncomp, bridge_radius: int = 50, min_region_size: int = 100, reference_pixel=None):
336
235
  import numpy as np
337
236
  from scipy.spatial import cKDTree
338
-
339
237
  labels = np.unique(conncomp)
340
238
  labels = labels[labels != 0]
341
239
  region_sizes = {lbl: int(np.sum(conncomp == lbl)) for lbl in labels}
342
240
  valid_labels = [lbl for lbl in labels if region_sizes[lbl] >= min_region_size]
343
-
344
- if not valid_labels:
345
- return unwrapped_phase.copy(), {}
346
-
241
+ if not valid_labels: return unwrapped_phase.copy(), {}
347
242
  valid_labels.sort(key=lambda lbl: region_sizes[lbl], reverse=True)
348
243
  if reference_pixel is not None:
349
244
  rp_row, rp_col = reference_pixel
350
245
  rp_label = int(conncomp[rp_row, rp_col])
351
246
  if rp_label == 0 or rp_label not in valid_labels:
352
- raise ValueError(
353
- f"reference_pixel {reference_pixel} is not part of any "
354
- f"valid (reliable, large-enough) region."
355
- )
247
+ raise ValueError(f"reference_pixel {reference_pixel} is not part of any valid region.")
356
248
  reference_label = rp_label
357
249
  else:
358
250
  reference_label = valid_labels[0]
359
-
360
251
  corrected = np.array(unwrapped_phase, dtype=np.float64, copy=True)
361
252
  resolved_points = {reference_label: np.column_stack(np.where(conncomp == reference_label))}
362
253
  offsets_applied = {reference_label: 0.0}
363
254
  remaining = set(valid_labels) - {reference_label}
364
-
365
255
  while remaining:
366
256
  best = None
367
257
  for lbl in remaining:
@@ -372,11 +262,8 @@ def bridge_unwrap_regions(
372
262
  min_i = int(np.argmin(dist))
373
263
  if best is None or dist[min_i] < best[0]:
374
264
  best = (dist[min_i], lbl, resolved_lbl, lbl_points[min_i], ref_points[idx[min_i]])
375
-
376
- if best is None:
377
- break
265
+ if best is None: break
378
266
  _, lbl, target_lbl, point_a, point_b = best
379
-
380
267
  def _local_median(point, region_mask):
381
268
  r, c = point
382
269
  r0, r1 = max(0, r - bridge_radius), r + bridge_radius + 1
@@ -385,47 +272,28 @@ def bridge_unwrap_regions(
385
272
  window_mask = region_mask[r0:r1, c0:c1]
386
273
  values = window_phase[window_mask]
387
274
  return float(np.median(values)) if len(values) > 0 else float(corrected[r, c])
388
-
389
275
  median_a = _local_median(point_a, conncomp == lbl)
390
276
  median_b = _local_median(point_b, conncomp == target_lbl)
391
277
  raw_offset = median_b - median_a
392
278
  integer_offset = 2 * np.pi * np.round(raw_offset / (2 * np.pi))
393
-
394
279
  corrected[conncomp == lbl] += integer_offset
395
280
  offsets_applied[lbl] = integer_offset
396
281
  resolved_points[lbl] = np.column_stack(np.where(conncomp == lbl))
397
282
  remaining.remove(lbl)
398
-
399
283
  return corrected.astype(np.float32), offsets_applied
400
284
 
401
285
 
402
- def multilook(
403
- data: Any,
404
- looks_azimuth: int = 4,
405
- looks_range: int = 1,
406
- wrapped_phase: Optional[bool] = None,
407
- ) -> Any:
408
- """
409
- Average an array down by the given factor in each dimension.
410
- """
286
+ def multilook(data: Any, looks_azimuth: int = 4, looks_range: int = 1, wrapped_phase: Optional[bool] = None) -> Any:
411
287
  import numpy as np
412
-
413
288
  h, w = data.shape
414
289
  h_ml = (h // looks_azimuth) * looks_azimuth
415
290
  w_ml = (w // looks_range) * looks_range
416
291
  trimmed = data[:h_ml, :w_ml]
417
292
  reshaped_shape = (h_ml // looks_azimuth, looks_azimuth, w_ml // looks_range, looks_range)
418
-
419
293
  if np.iscomplexobj(trimmed):
420
294
  return trimmed.reshape(reshaped_shape).mean(axis=(1, 3))
421
-
422
295
  if wrapped_phase is None:
423
- raise ValueError(
424
- "multilook() received real-valued input but wrapped_phase was "
425
- "not specified. Pass wrapped_phase=True for wrapped phase, "
426
- "or wrapped_phase=False for coherence/unwrapped phase."
427
- )
428
-
296
+ raise ValueError("multilook() received real-valued input but wrapped_phase was not specified.")
429
297
  if wrapped_phase:
430
298
  reshaped = np.exp(1j * trimmed).reshape(reshaped_shape)
431
299
  return np.angle(reshaped.mean(axis=(1, 3)))
@@ -434,27 +302,16 @@ def multilook(
434
302
 
435
303
 
436
304
  class PhaseUnwrapper:
437
- """
438
- Phase unwrapping via SNAPHU.
439
- """
440
-
441
305
  def __init__(self, cost_mode: str = "defo", init_method: str = "mcf") -> None:
442
306
  valid_costs = ("topo", "defo", "smooth", "nostatcosts")
443
- if cost_mode not in valid_costs:
444
- raise ValueError(f"cost_mode must be one of {valid_costs}, got {cost_mode!r}")
307
+ if cost_mode not in valid_costs: raise ValueError(f"cost_mode must be one of {valid_costs}")
445
308
  valid_inits = ("mcf", "mst")
446
- if init_method not in valid_inits:
447
- raise ValueError(f"init_method must be one of {valid_inits}, got {init_method!r}")
309
+ if init_method not in valid_inits: raise ValueError(f"init_method must be one of {valid_inits}")
448
310
  self._cost_mode = cost_mode
449
311
  self._init_method = init_method
450
312
 
451
313
  def _unwrap_cli(self, igram, corr, nlooks, min_conncomp_frac, min_region_size):
452
- """
453
- Fallback unwrapping using the SNAPHU CLI executable.
454
- Includes auto-repair for the infamous 0xC0000135 (STATUS_DLL_NOT_FOUND) error.
455
- """
456
314
  import numpy as np
457
-
458
315
  cli_path = _ensure_snaphu_cli()
459
316
  height, width = igram.shape
460
317
  bin_dir = Path(cli_path).parent
@@ -466,27 +323,24 @@ class PhaseUnwrapper:
466
323
  unw_path = tmp / "unw.unw"
467
324
  cc_path = tmp / "conncomp.cc"
468
325
  conf_path = tmp / "snaphu.conf"
469
-
470
326
  igram.tofile(str(igram_path))
471
327
  corr.tofile(str(corr_path))
472
-
473
328
  nlooks_int = max(1, int(nlooks))
474
329
  conf_text = f"""INFILE igram.cpx
475
- INFILEFORMAT COMPLEX_DATA
476
- CORRFILE corr.cor
477
- CORRFILEFORMAT FLOAT_DATA
478
- OUTFILE unw.unw
479
- OUTFILEFORMAT FLOAT_DATA
480
- CONNCOMPFILE conncomp.cc
481
- CONNCOMPOUTFORMAT UINT_DATA
482
- LINELENGTH {width}
483
- STATCOSTMODE {self._cost_mode.upper()}
484
- INITMETHOD {self._init_method.upper()}
485
- NLOOKSRANGE {nlooks_int}
486
- NLOOKSAZ {nlooks_int}
487
- """
330
+ INFILEFORMAT COMPLEX_DATA
331
+ CORRFILE corr.cor
332
+ CORRFILEFORMAT FLOAT_DATA
333
+ OUTFILE unw.unw
334
+ OUTFILEFORMAT FLOAT_DATA
335
+ CONNCOMPFILE conncomp.cc
336
+ CONNCOMPOUTFORMAT UINT_DATA
337
+ LINELENGTH {width}
338
+ STATCOSTMODE {self._cost_mode.upper()}
339
+ INITMETHOD {self._init_method.upper()}
340
+ NLOOKSRANGE {nlooks_int}
341
+ NLOOKSAZ {nlooks_int}
342
+ """
488
343
  conf_path.write_text(conf_text)
489
-
490
344
  cmd = [cli_path, "-f", "snaphu.conf"]
491
345
  result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(tmp), timeout=3600)
492
346
  return result, unw_path, cc_path
@@ -496,12 +350,8 @@ class PhaseUnwrapper:
496
350
 
497
351
  # AUTO-REPAIR: Catch Windows STATUS_DLL_NOT_FOUND (0xC0000135)
498
352
  if result.returncode in (3221225781, -1073741515):
499
- logger.warning(
500
- "SNAPHU crashed with STATUS_DLL_NOT_FOUND (0xC0000135). "
501
- "Auto-bundling MSVC runtime DLLs..."
502
- )
353
+ logger.warning("SNAPHU crashed with STATUS_DLL_NOT_FOUND (0xC0000135). Auto-bundling MSVC runtime DLLs...")
503
354
  self._ensure_msvc_runtime(bin_dir)
504
-
505
355
  logger.info("Retrying SNAPHU with bundled DLLs...")
506
356
  result, unw_path, cc_path = _run_snaphu(tmp_dir)
507
357
 
@@ -514,189 +364,72 @@ class PhaseUnwrapper:
514
364
  total_pixels = height * width
515
365
  labels, counts = np.unique(conncomp, return_counts=True)
516
366
  for lbl, count in zip(labels, counts):
517
- if lbl == 0:
518
- continue
367
+ if lbl == 0: continue
519
368
  if count < min_region_size or (count / total_pixels) < min_conncomp_frac:
520
369
  conncomp[conncomp == lbl] = 0
521
-
522
370
  return unw, conncomp.astype(np.int32)
523
371
 
524
372
  def _ensure_msvc_runtime(self, bin_dir: Path):
525
- """
526
- Fixes the 0xC0000135 (STATUS_DLL_NOT_FOUND) error by downloading
527
- the required MSVC DLLs directly from NuGet (VCRuntime.140 package).
528
- This requires ZERO admin rights and ZERO manual installation.
529
- """
530
- import shutil
531
- import zipfile
532
- import io
533
- import urllib.request
534
-
535
- required_dlls = ["vcruntime140.dll", "msvcp140.dll"]
373
+ import shutil, zipfile, io, urllib.request
374
+ required_dlls = ["vcruntime140.dll", "msvcp140.dll", "vcruntime140_1.dll"]
536
375
  missing = [dll for dll in required_dlls if not (bin_dir / dll).exists()]
376
+ if not missing: return
537
377
 
538
- if not missing:
539
- return
540
-
541
378
  logger.info(f"SNAPHU requires {missing}. Downloading directly from NuGet (no admin required)...")
542
-
543
- # NuGet packages are just zip files. We download the VCRuntime.140 package
544
- # which contains the exact DLLs we need for x64 Windows.
545
379
  nuget_url = "https://www.nuget.org/api/v2/package/VCRuntime.140"
546
380
 
547
381
  try:
548
382
  req = urllib.request.Request(nuget_url, headers={'User-Agent': 'PyGeoFetch'})
549
383
  with urllib.request.urlopen(req, timeout=30) as response:
550
384
  nupkg_data = response.read()
551
-
552
385
  extracted = 0
553
386
  with zipfile.ZipFile(io.BytesIO(nupkg_data)) as zf:
554
387
  for name in zf.namelist():
555
- # We only want the 64-bit native DLLs
556
388
  if "win-x64/native/" in name and name.lower().endswith(".dll"):
557
389
  dll_name = Path(name).name
558
390
  if dll_name.lower() in [m.lower() for m in missing]:
559
391
  with zf.open(name) as src, open(bin_dir / dll_name, "wb") as dst:
560
392
  shutil.copyfileobj(src, dst)
561
393
  extracted += 1
562
-
563
- if extracted == len(missing):
394
+ if extracted > 0:
564
395
  logger.info(f"Successfully extracted {extracted} MSVC DLLs to {bin_dir}. Retrying SNAPHU...")
565
396
  return
566
397
  else:
567
- raise RuntimeError(f"Could not find all required DLLs in the NuGet package. Found {extracted}/{len(missing)}")
568
-
398
+ raise RuntimeError("Could not find required DLLs in the NuGet package.")
569
399
  except Exception as e:
570
400
  raise RuntimeError(
571
401
  f"Failed to download/extract MSVC runtime DLLs from NuGet: {e}\n"
572
402
  "As a last resort, please install the Microsoft Visual C++ Redistributable manually from:\n"
573
- "https://aka.ms/vs/17/release/vc_redist.x64.exe\n"
574
- "Then restart your Jupyter kernel."
403
+ "https://aka.ms/vs/17/release/vc_redist.x64.exe\nThen restart your Jupyter kernel."
575
404
  )
576
405
 
577
- def unwrap(
578
- self,
579
- interferogram: Any,
580
- coherence: Any,
581
- nlooks: float = 1.0,
582
- mask: Optional[Any] = None,
583
- min_conncomp_frac: float = 0.01,
584
- min_region_size: int = 100,
585
- ) -> Tuple[Any, Any]:
406
+ def unwrap(self, interferogram: Any, coherence: Any, nlooks: float = 1.0, mask: Optional[Any] = None, min_conncomp_frac: float = 0.01, min_region_size: int = 100) -> Tuple[Any, Any]:
586
407
  np = self._np()
587
-
588
- if np.iscomplexobj(interferogram):
589
- igram = interferogram.astype(np.complex64)
590
- else:
591
- igram = np.exp(1j * interferogram).astype(np.complex64)
592
-
408
+ if np.iscomplexobj(interferogram): igram = interferogram.astype(np.complex64)
409
+ else: igram = np.exp(1j * interferogram).astype(np.complex64)
593
410
  corr = np.clip(coherence, 0.0, 1.0).astype(np.float32)
594
-
595
- if mask is not None:
596
- corr = np.where(mask, corr, 0.0).astype(np.float32)
597
-
411
+ if mask is not None: corr = np.where(mask, corr, 0.0).astype(np.float32)
598
412
  corr = np.nan_to_num(corr, nan=0.0, posinf=0.0, neginf=0.0)
599
-
600
- logger.info(
601
- "Unwrapping %s pixels (cost=%s, init=%s, nlooks=%.1f)",
602
- f"{igram.shape[0]}x{igram.shape[1]}",
603
- self._cost_mode,
604
- self._init_method,
605
- nlooks,
606
- )
607
-
413
+ logger.info("Unwrapping %s pixels (cost=%s, init=%s, nlooks=%.1f)", f"{igram.shape[0]}x{igram.shape[1]}", self._cost_mode, self._init_method, nlooks)
608
414
  try:
609
- # Try Python API first (fastest)
610
415
  import snaphu
611
- unwrapped, conncomp = snaphu.unwrap(
612
- igram,
613
- corr,
614
- nlooks=nlooks,
615
- cost=self._cost_mode,
616
- init=self._init_method,
617
- min_conncomp_frac=min_conncomp_frac,
618
- min_region_size=min_region_size,
619
- )
416
+ unwrapped, conncomp = snaphu.unwrap(igram, corr, nlooks=nlooks, cost=self._cost_mode, init=self._init_method, min_conncomp_frac=min_conncomp_frac, min_region_size=min_region_size)
620
417
  except ImportError:
621
- # Fallback to CLI (solves Windows installation nightmares)
622
418
  logger.info("snaphu Python package not found. Falling back to SNAPHU CLI executable...")
623
- unwrapped, conncomp = self._unwrap_cli(
624
- igram, corr, nlooks, min_conncomp_frac, min_region_size
625
- )
419
+ unwrapped, conncomp = self._unwrap_cli(igram, corr, nlooks, min_conncomp_frac, min_region_size)
626
420
  except Exception as exc:
627
- raise RuntimeError(
628
- f"SNAPHU unwrapping failed: {exc}\n"
629
- "Common causes: incompatible array shapes, all-zero coherence, "
630
- "or insufficient memory for large scenes."
631
- ) from exc
421
+ raise RuntimeError(f"SNAPHU unwrapping failed: {exc}") from exc
632
422
 
633
423
  n_unreliable = int(np.sum(conncomp == 0))
634
424
  pct = 100 * n_unreliable / conncomp.size
635
- if pct > 30:
636
- logger.warning(
637
- "%.1f%% of pixels are in the unreliable connected component "
638
- "(conncomp==0). Consider improving coherence via multilooking.",
639
- pct,
640
- )
641
- else:
642
- logger.info("Unwrapping complete — %.1f%% unreliable pixels", pct)
643
-
425
+ if pct > 30: logger.warning("%.1f%% of pixels are in the unreliable connected component (conncomp==0).", pct)
426
+ else: logger.info("Unwrapping complete — %.1f%% unreliable pixels", pct)
644
427
  return unwrapped.astype(np.float32), conncomp
645
428
 
646
- def unwrap_pair(
647
- self,
648
- interferogram: Any,
649
- coherence: Any,
650
- profile: Dict[str, Any],
651
- reference_date: Optional[str] = None,
652
- secondary_date: Optional[str] = None,
653
- nlooks: float = 1.0,
654
- looks_azimuth: int = 1,
655
- looks_range: int = 1,
656
- mask: Optional[Any] = None,
657
- min_conncomp_frac: float = 0.001,
658
- min_region_size: int = 100,
659
- ) -> UnwrapResult:
660
- """
661
- Unwrap a single interferogram pair and return an UnwrapResult.
662
-
663
- This is the high-level method that combines unwrap() + result
664
- packaging. Use this in notebook cells for clean orchestration:
665
-
666
- result = unwrapper.unwrap_pair(phase_ml, coh_ml, profile,
667
- looks_azimuth=UNWRAP_LOOKS_AZ,
668
- looks_range=UNWRAP_LOOKS_RG)
669
- result.save(output_dir / f"{d1}_{d2}", save_png=True)
670
-
671
- Args:
672
- interferogram: Complex interferogram or wrapped phase (radians).
673
- coherence: Coherence array (0-1), same shape.
674
- profile: Rasterio-style profile for georeferencing.
675
- reference_date, secondary_date: ISO date strings for labeling.
676
- nlooks: Effective number of looks.
677
- looks_azimuth, looks_range: Multilook factors used to produce the
678
- input arrays. Used to correctly scale
679
- the georeferencing transform.
680
- mask: Optional boolean mask (True=valid).
681
- min_conncomp_frac: Minimum connected component fraction.
682
- min_region_size: Minimum region size in pixels.
683
-
684
- Returns:
685
- UnwrapResult with all data packaged and ready to save.
686
- """
429
+ def unwrap_pair(self, interferogram: Any, coherence: Any, profile: Dict[str, Any], reference_date: Optional[str] = None, secondary_date: Optional[str] = None, nlooks: float = 1.0, looks_azimuth: int = 1, looks_range: int = 1, mask: Optional[Any] = None, min_conncomp_frac: float = 0.001, min_region_size: int = 100) -> UnwrapResult:
687
430
  import numpy as np
688
-
689
- unwrapped, conncomp = self.unwrap(
690
- interferogram, coherence,
691
- nlooks=nlooks,
692
- mask=mask,
693
- min_conncomp_frac=min_conncomp_frac,
694
- min_region_size=min_region_size,
695
- )
696
-
431
+ unwrapped, conncomp = self.unwrap(interferogram, coherence, nlooks=nlooks, mask=mask, min_conncomp_frac=min_conncomp_frac, min_region_size=min_region_size)
697
432
  reliable_fraction = float(np.mean(conncomp > 0))
698
-
699
- # Scale the profile transform to match the multilooked arrays
700
433
  scaled_profile = dict(profile)
701
434
  if looks_azimuth > 1 or looks_range > 1:
702
435
  if scaled_profile.get("transform") is not None:
@@ -704,83 +437,32 @@ class PhaseUnwrapper:
704
437
  scaled_profile["transform"] = scaled_profile["transform"] * Affine.scale(looks_range, looks_azimuth)
705
438
  scaled_profile["height"] = unwrapped.shape[0]
706
439
  scaled_profile["width"] = unwrapped.shape[1]
440
+ return UnwrapResult(unwrapped_phase=unwrapped, conncomp=conncomp, coherence=coherence, profile=scaled_profile, reference_date=reference_date, secondary_date=secondary_date, nlooks=nlooks, cost_mode=self._cost_mode, init_method=self._init_method, reliable_fraction=reliable_fraction)
707
441
 
708
- return UnwrapResult(
709
- unwrapped_phase=unwrapped,
710
- conncomp=conncomp,
711
- coherence=coherence,
712
- profile=scaled_profile,
713
- reference_date=reference_date,
714
- secondary_date=secondary_date,
715
- nlooks=nlooks,
716
- cost_mode=self._cost_mode,
717
- init_method=self._init_method,
718
- reliable_fraction=reliable_fraction,
719
- metadata={
720
- "cost_mode": self._cost_mode,
721
- "init_method": self._init_method,
722
- "nlooks": nlooks,
723
- "reliable_fraction": reliable_fraction,
724
- },
725
- )
726
-
727
- def unwrap_files(
728
- self,
729
- interferogram_path: Union[str, Path],
730
- coherence_path: Union[str, Path],
731
- output_path: Union[str, Path],
732
- nlooks: float = 1.0,
733
- mask_path: Optional[Union[str, Path]] = None,
734
- ) -> Path:
442
+ def unwrap_files(self, interferogram_path: Union[str, Path], coherence_path: Union[str, Path], output_path: Union[str, Path], nlooks: float = 1.0, mask_path: Optional[Union[str, Path]] = None) -> Path:
735
443
  np = self._np()
736
- try:
737
- import rasterio
738
- except ImportError:
739
- raise ImportError('rasterio required: pip install "pygeofetch[geo]"')
740
-
444
+ try: import rasterio
445
+ except ImportError: raise ImportError('rasterio required: pip install "pygeofetch[geo]"')
741
446
  with rasterio.open(interferogram_path) as src:
742
447
  profile = src.profile.copy()
743
448
  phase = src.read(1).astype(np.float32)
744
-
745
449
  with rasterio.open(coherence_path) as src:
746
450
  coherence = src.read(1).astype(np.float32)
747
-
748
451
  mask = None
749
452
  if mask_path:
750
- with rasterio.open(mask_path) as src:
751
- mask = src.read(1).astype(bool)
752
-
453
+ with rasterio.open(mask_path) as src: mask = src.read(1).astype(bool)
753
454
  unwrapped, conncomp = self.unwrap(phase, coherence, nlooks=nlooks, mask=mask)
754
-
755
455
  out_path = Path(output_path)
756
456
  out_path.parent.mkdir(parents=True, exist_ok=True)
757
-
758
- out_profile = {
759
- "driver": "GTiff",
760
- "dtype": "float32",
761
- "count": 1,
762
- "height": unwrapped.shape[0],
763
- "width": unwrapped.shape[1],
764
- "crs": profile.get("crs"),
765
- "transform": profile.get("transform"),
766
- "nodata": -9999.0,
767
- "compress": "deflate",
768
- "tiled": True,
769
- "blockxsize": 256,
770
- "blockysize": 256,
771
- }
457
+ out_profile = {"driver": "GTiff", "dtype": "float32", "count": 1, "height": unwrapped.shape[0], "width": unwrapped.shape[1], "crs": profile.get("crs"), "transform": profile.get("transform"), "nodata": -9999.0, "compress": "deflate", "tiled": True, "blockxsize": 256, "blockysize": 256}
772
458
  with rasterio.open(out_path, "w", **out_profile) as dst:
773
459
  dst.write(unwrapped[np.newaxis])
774
460
  dst.update_tags(1, description="unwrapped_phase_radians")
775
-
776
461
  conncomp_path = out_path.parent / f"{out_path.stem}_conncomp.tif"
777
462
  cc_profile = dict(out_profile, dtype="int32", nodata=0)
778
463
  with rasterio.open(conncomp_path, "w", **cc_profile) as dst:
779
464
  dst.write(conncomp.astype(np.int32)[np.newaxis])
780
-
781
- logger.info(
782
- "Unwrapped phase → %s (conncomp → %s)", out_path.name, conncomp_path.name
783
- )
465
+ logger.info("Unwrapped phase → %s (conncomp → %s)", out_path.name, conncomp_path.name)
784
466
  return out_path
785
467
 
786
468
  def _np(self):
pygeofetch/meta.yaml CHANGED
@@ -1,4 +1,4 @@
1
- {% set version = "2.0.9" %}
1
+ {% set version = "2.1.0" %}
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.9
3
+ Version: 2.1.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
@@ -1,11 +1,11 @@
1
- pygeofetch/__init__.py,sha256=RDB0ME4m2ADK1BxKVEQB93rq-yfN7pF0uHbl4cafsPo,1990
2
- pygeofetch/meta.yaml,sha256=PVK-tAx2L30LEg6FaU7zNyFtXmnDybAnQ0SZtd81qHg,1280
1
+ pygeofetch/__init__.py,sha256=L8N1Q9HeHj-WQl0W1f8EFZJ2OjkBFszL4pR5p8sSo5k,1990
2
+ pygeofetch/meta.yaml,sha256=ymM1Zwx7Acs5nqHhu8wSkS-5_FHZvedmax-CdxbXAjI,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=UZVPectgQIF29g30UsyQCEZ_HGYBy47LhAvue9Y7PhA,30335
8
+ pygeofetch/cli/main.py,sha256=OuNJCr8oWa3Gtk1ECDsOhriQfSIl2l7SL5DAdl6CCVc,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=c5viQA3H16r7w7UQNiDY35ht_jx-d9-6Q3j0UgUKEt4,30777
42
+ pygeofetch/insar/unwrap.py,sha256=FqvvqpxlmWZ7WfkCQa05vers8LaFO-a8o1QMW3YszKU,22832
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.9.dist-info/licenses/LICENSE,sha256=zavMyhODD6NvZTmmKFEtATrngwY4CU_NEtY7lhjxGnI,1075
108
- pygeofetch-2.0.9.dist-info/METADATA,sha256=0FB9OccvQb0t7uHgSUxB-DRMGqS9O1W0rHNtwhDTliI,43254
109
- pygeofetch-2.0.9.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
110
- pygeofetch-2.0.9.dist-info/entry_points.txt,sha256=dHiUXLF5xNJ8vgtXZoUQNleEMQUeDPZiKQOE1tW8SDY,55
111
- pygeofetch-2.0.9.dist-info/top_level.txt,sha256=rOmnZargIfNk99fwVOqt6OJyJvpSTBBAYO5HU37yhHk,11
112
- pygeofetch-2.0.9.dist-info/RECORD,,
107
+ pygeofetch-2.1.0.dist-info/licenses/LICENSE,sha256=zavMyhODD6NvZTmmKFEtATrngwY4CU_NEtY7lhjxGnI,1075
108
+ pygeofetch-2.1.0.dist-info/METADATA,sha256=YIRKj3ve8llHulK2xAeCY27rzphU2aPOa26dBldgeaM,43254
109
+ pygeofetch-2.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
110
+ pygeofetch-2.1.0.dist-info/entry_points.txt,sha256=dHiUXLF5xNJ8vgtXZoUQNleEMQUeDPZiKQOE1tW8SDY,55
111
+ pygeofetch-2.1.0.dist-info/top_level.txt,sha256=rOmnZargIfNk99fwVOqt6OJyJvpSTBBAYO5HU37yhHk,11
112
+ pygeofetch-2.1.0.dist-info/RECORD,,