PyGeoFetch 2.0.8__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.8 — 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.8"
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.8 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.8 — 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,191 +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 error by finding the required MSVC DLLs
527
- already present in the user's Python environment (e.g., from
528
- numpy/scipy wheels) and copying them next to snaphu.exe.
529
- """
530
- import sys
531
- import shutil
532
-
533
- required_dlls = ["vcruntime140.dll", "msvcp140.dll"]
373
+ import shutil, zipfile, io, urllib.request
374
+ required_dlls = ["vcruntime140.dll", "msvcp140.dll", "vcruntime140_1.dll"]
534
375
  missing = [dll for dll in required_dlls if not (bin_dir / dll).exists()]
376
+ if not missing: return
535
377
 
536
- if not missing:
537
- return
538
-
539
- logger.info(f"SNAPHU requires {missing}. Searching local Python environment...")
378
+ logger.info(f"SNAPHU requires {missing}. Downloading directly from NuGet (no admin required)...")
379
+ nuget_url = "https://www.nuget.org/api/v2/package/VCRuntime.140"
540
380
 
541
- found = {}
542
- for path in sys.path:
543
- if not path or not Path(path).exists():
544
- continue
545
- try:
546
- p = Path(path)
547
- for dll in missing:
548
- if dll in found:
549
- continue
550
- # Check .libs folders first (where numpy/scipy put them)
551
- for libs_dir in p.glob("*.libs"):
552
- target = libs_dir / dll
553
- if target.exists():
554
- found[dll] = target
555
- break
556
- if dll in found:
557
- continue
558
- # Fallback to full rglob if not in .libs
559
- for target in p.rglob(dll):
560
- found[dll] = target
561
- break
562
- except Exception:
563
- continue
564
-
565
- if len(found) == len(missing):
566
- logger.info("Found MSVC DLLs in local Python environment. Copying to SNAPHU bin...")
567
- for dll, src in found.items():
568
- shutil.copy2(src, bin_dir / dll)
569
- return
570
-
571
- raise RuntimeError(
572
- f"SNAPHU requires the Microsoft Visual C++ Redistributable DLLs ({missing}), "
573
- "but they could not be found in your Python environment.\n"
574
- "Please install the Microsoft Visual C++ Redistributable manually from:\n"
575
- "https://aka.ms/vs/17/release/vc_redist.x64.exe\n"
576
- "Then restart your Jupyter kernel and try again."
577
- )
578
-
579
- def unwrap(
580
- self,
581
- interferogram: Any,
582
- coherence: Any,
583
- nlooks: float = 1.0,
584
- mask: Optional[Any] = None,
585
- min_conncomp_frac: float = 0.01,
586
- min_region_size: int = 100,
587
- ) -> Tuple[Any, Any]:
588
- np = self._np()
589
-
590
- if np.iscomplexobj(interferogram):
591
- igram = interferogram.astype(np.complex64)
592
- else:
593
- igram = np.exp(1j * interferogram).astype(np.complex64)
381
+ try:
382
+ req = urllib.request.Request(nuget_url, headers={'User-Agent': 'PyGeoFetch'})
383
+ with urllib.request.urlopen(req, timeout=30) as response:
384
+ nupkg_data = response.read()
385
+ extracted = 0
386
+ with zipfile.ZipFile(io.BytesIO(nupkg_data)) as zf:
387
+ for name in zf.namelist():
388
+ if "win-x64/native/" in name and name.lower().endswith(".dll"):
389
+ dll_name = Path(name).name
390
+ if dll_name.lower() in [m.lower() for m in missing]:
391
+ with zf.open(name) as src, open(bin_dir / dll_name, "wb") as dst:
392
+ shutil.copyfileobj(src, dst)
393
+ extracted += 1
394
+ if extracted > 0:
395
+ logger.info(f"Successfully extracted {extracted} MSVC DLLs to {bin_dir}. Retrying SNAPHU...")
396
+ return
397
+ else:
398
+ raise RuntimeError("Could not find required DLLs in the NuGet package.")
399
+ except Exception as e:
400
+ raise RuntimeError(
401
+ f"Failed to download/extract MSVC runtime DLLs from NuGet: {e}\n"
402
+ "As a last resort, please install the Microsoft Visual C++ Redistributable manually from:\n"
403
+ "https://aka.ms/vs/17/release/vc_redist.x64.exe\nThen restart your Jupyter kernel."
404
+ )
594
405
 
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]:
407
+ np = self._np()
408
+ if np.iscomplexobj(interferogram): igram = interferogram.astype(np.complex64)
409
+ else: igram = np.exp(1j * interferogram).astype(np.complex64)
595
410
  corr = np.clip(coherence, 0.0, 1.0).astype(np.float32)
596
-
597
- if mask is not None:
598
- corr = np.where(mask, corr, 0.0).astype(np.float32)
599
-
411
+ if mask is not None: corr = np.where(mask, corr, 0.0).astype(np.float32)
600
412
  corr = np.nan_to_num(corr, nan=0.0, posinf=0.0, neginf=0.0)
601
-
602
- logger.info(
603
- "Unwrapping %s pixels (cost=%s, init=%s, nlooks=%.1f)",
604
- f"{igram.shape[0]}x{igram.shape[1]}",
605
- self._cost_mode,
606
- self._init_method,
607
- nlooks,
608
- )
609
-
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)
610
414
  try:
611
- # Try Python API first (fastest)
612
415
  import snaphu
613
- unwrapped, conncomp = snaphu.unwrap(
614
- igram,
615
- corr,
616
- nlooks=nlooks,
617
- cost=self._cost_mode,
618
- init=self._init_method,
619
- min_conncomp_frac=min_conncomp_frac,
620
- min_region_size=min_region_size,
621
- )
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)
622
417
  except ImportError:
623
- # Fallback to CLI (solves Windows installation nightmares)
624
418
  logger.info("snaphu Python package not found. Falling back to SNAPHU CLI executable...")
625
- unwrapped, conncomp = self._unwrap_cli(
626
- igram, corr, nlooks, min_conncomp_frac, min_region_size
627
- )
419
+ unwrapped, conncomp = self._unwrap_cli(igram, corr, nlooks, min_conncomp_frac, min_region_size)
628
420
  except Exception as exc:
629
- raise RuntimeError(
630
- f"SNAPHU unwrapping failed: {exc}\n"
631
- "Common causes: incompatible array shapes, all-zero coherence, "
632
- "or insufficient memory for large scenes."
633
- ) from exc
421
+ raise RuntimeError(f"SNAPHU unwrapping failed: {exc}") from exc
634
422
 
635
423
  n_unreliable = int(np.sum(conncomp == 0))
636
424
  pct = 100 * n_unreliable / conncomp.size
637
- if pct > 30:
638
- logger.warning(
639
- "%.1f%% of pixels are in the unreliable connected component "
640
- "(conncomp==0). Consider improving coherence via multilooking.",
641
- pct,
642
- )
643
- else:
644
- logger.info("Unwrapping complete — %.1f%% unreliable pixels", pct)
645
-
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)
646
427
  return unwrapped.astype(np.float32), conncomp
647
428
 
648
- def unwrap_pair(
649
- self,
650
- interferogram: Any,
651
- coherence: Any,
652
- profile: Dict[str, Any],
653
- reference_date: Optional[str] = None,
654
- secondary_date: Optional[str] = None,
655
- nlooks: float = 1.0,
656
- looks_azimuth: int = 1,
657
- looks_range: int = 1,
658
- mask: Optional[Any] = None,
659
- min_conncomp_frac: float = 0.001,
660
- min_region_size: int = 100,
661
- ) -> UnwrapResult:
662
- """
663
- Unwrap a single interferogram pair and return an UnwrapResult.
664
-
665
- This is the high-level method that combines unwrap() + result
666
- packaging. Use this in notebook cells for clean orchestration:
667
-
668
- result = unwrapper.unwrap_pair(phase_ml, coh_ml, profile,
669
- looks_azimuth=UNWRAP_LOOKS_AZ,
670
- looks_range=UNWRAP_LOOKS_RG)
671
- result.save(output_dir / f"{d1}_{d2}", save_png=True)
672
-
673
- Args:
674
- interferogram: Complex interferogram or wrapped phase (radians).
675
- coherence: Coherence array (0-1), same shape.
676
- profile: Rasterio-style profile for georeferencing.
677
- reference_date, secondary_date: ISO date strings for labeling.
678
- nlooks: Effective number of looks.
679
- looks_azimuth, looks_range: Multilook factors used to produce the
680
- input arrays. Used to correctly scale
681
- the georeferencing transform.
682
- mask: Optional boolean mask (True=valid).
683
- min_conncomp_frac: Minimum connected component fraction.
684
- min_region_size: Minimum region size in pixels.
685
-
686
- Returns:
687
- UnwrapResult with all data packaged and ready to save.
688
- """
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:
689
430
  import numpy as np
690
-
691
- unwrapped, conncomp = self.unwrap(
692
- interferogram, coherence,
693
- nlooks=nlooks,
694
- mask=mask,
695
- min_conncomp_frac=min_conncomp_frac,
696
- min_region_size=min_region_size,
697
- )
698
-
431
+ unwrapped, conncomp = self.unwrap(interferogram, coherence, nlooks=nlooks, mask=mask, min_conncomp_frac=min_conncomp_frac, min_region_size=min_region_size)
699
432
  reliable_fraction = float(np.mean(conncomp > 0))
700
-
701
- # Scale the profile transform to match the multilooked arrays
702
433
  scaled_profile = dict(profile)
703
434
  if looks_azimuth > 1 or looks_range > 1:
704
435
  if scaled_profile.get("transform") is not None:
@@ -706,83 +437,32 @@ class PhaseUnwrapper:
706
437
  scaled_profile["transform"] = scaled_profile["transform"] * Affine.scale(looks_range, looks_azimuth)
707
438
  scaled_profile["height"] = unwrapped.shape[0]
708
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)
709
441
 
710
- return UnwrapResult(
711
- unwrapped_phase=unwrapped,
712
- conncomp=conncomp,
713
- coherence=coherence,
714
- profile=scaled_profile,
715
- reference_date=reference_date,
716
- secondary_date=secondary_date,
717
- nlooks=nlooks,
718
- cost_mode=self._cost_mode,
719
- init_method=self._init_method,
720
- reliable_fraction=reliable_fraction,
721
- metadata={
722
- "cost_mode": self._cost_mode,
723
- "init_method": self._init_method,
724
- "nlooks": nlooks,
725
- "reliable_fraction": reliable_fraction,
726
- },
727
- )
728
-
729
- def unwrap_files(
730
- self,
731
- interferogram_path: Union[str, Path],
732
- coherence_path: Union[str, Path],
733
- output_path: Union[str, Path],
734
- nlooks: float = 1.0,
735
- mask_path: Optional[Union[str, Path]] = None,
736
- ) -> 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:
737
443
  np = self._np()
738
- try:
739
- import rasterio
740
- except ImportError:
741
- raise ImportError('rasterio required: pip install "pygeofetch[geo]"')
742
-
444
+ try: import rasterio
445
+ except ImportError: raise ImportError('rasterio required: pip install "pygeofetch[geo]"')
743
446
  with rasterio.open(interferogram_path) as src:
744
447
  profile = src.profile.copy()
745
448
  phase = src.read(1).astype(np.float32)
746
-
747
449
  with rasterio.open(coherence_path) as src:
748
450
  coherence = src.read(1).astype(np.float32)
749
-
750
451
  mask = None
751
452
  if mask_path:
752
- with rasterio.open(mask_path) as src:
753
- mask = src.read(1).astype(bool)
754
-
453
+ with rasterio.open(mask_path) as src: mask = src.read(1).astype(bool)
755
454
  unwrapped, conncomp = self.unwrap(phase, coherence, nlooks=nlooks, mask=mask)
756
-
757
455
  out_path = Path(output_path)
758
456
  out_path.parent.mkdir(parents=True, exist_ok=True)
759
-
760
- out_profile = {
761
- "driver": "GTiff",
762
- "dtype": "float32",
763
- "count": 1,
764
- "height": unwrapped.shape[0],
765
- "width": unwrapped.shape[1],
766
- "crs": profile.get("crs"),
767
- "transform": profile.get("transform"),
768
- "nodata": -9999.0,
769
- "compress": "deflate",
770
- "tiled": True,
771
- "blockxsize": 256,
772
- "blockysize": 256,
773
- }
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}
774
458
  with rasterio.open(out_path, "w", **out_profile) as dst:
775
459
  dst.write(unwrapped[np.newaxis])
776
460
  dst.update_tags(1, description="unwrapped_phase_radians")
777
-
778
461
  conncomp_path = out_path.parent / f"{out_path.stem}_conncomp.tif"
779
462
  cc_profile = dict(out_profile, dtype="int32", nodata=0)
780
463
  with rasterio.open(conncomp_path, "w", **cc_profile) as dst:
781
464
  dst.write(conncomp.astype(np.int32)[np.newaxis])
782
-
783
- logger.info(
784
- "Unwrapped phase → %s (conncomp → %s)", out_path.name, conncomp_path.name
785
- )
465
+ logger.info("Unwrapped phase → %s (conncomp → %s)", out_path.name, conncomp_path.name)
786
466
  return out_path
787
467
 
788
468
  def _np(self):
pygeofetch/meta.yaml CHANGED
@@ -1,4 +1,4 @@
1
- {% set version = "2.0.8" %}
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.8
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=HFS6RVpvpVEADDOM4bbQMzCMlaqpw57EU9RGwNO8Ojk,1990
2
- pygeofetch/meta.yaml,sha256=rlTZnPfmVCnO9GcrjysJm4kE9Ycjmu2F7APKTKzSIt0,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=T87K-cExYPBptWnMJ5NQwfPjJfSfem5GyipT-et07FY,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=bHdcHqWvceUsbZegvUtpGXLY4jvF_lmiHmds7pTT0Vs,30457
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.8.dist-info/licenses/LICENSE,sha256=zavMyhODD6NvZTmmKFEtATrngwY4CU_NEtY7lhjxGnI,1075
108
- pygeofetch-2.0.8.dist-info/METADATA,sha256=N8qOTU2GQ_Emwaqh8tDUZ_ZGJU8BU7vZdnAcWil8-s0,43254
109
- pygeofetch-2.0.8.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
110
- pygeofetch-2.0.8.dist-info/entry_points.txt,sha256=dHiUXLF5xNJ8vgtXZoUQNleEMQUeDPZiKQOE1tW8SDY,55
111
- pygeofetch-2.0.8.dist-info/top_level.txt,sha256=rOmnZargIfNk99fwVOqt6OJyJvpSTBBAYO5HU37yhHk,11
112
- pygeofetch-2.0.8.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,,