viewtif 0.2.1__py3-none-any.whl → 0.2.2__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.
viewtif/tif_viewer.py CHANGED
@@ -1,16 +1,14 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- TIFF Viewer (PySide6) — RGB (2–98% global stretch) + Shapefile overlays
3
+ TIFF Viewer (PySide6) — view GeoTIFF, NetCDF, and HDF datasets with shapefile overlays.
4
4
 
5
- Features
5
+ Features:
6
6
  - Open GeoTIFFs (single or multi-band)
7
- - Combine separate single-band TIFFs into RGB (--rgbfiles R.tif G.tif B.tif)
8
- - QGIS-like RGB display using global 2–98 percentile stretch
9
- - Single-band view with contrast/gamma + colormap toggle (viridis <-> magma)
10
- - Pan & zoom
11
- - Switch bands with [ and ] (single-band)
12
- - Overlay one or more shapefiles reprojected to raster CRS
13
- - Z/M tolerant: ignores Z or M coords in shapefiles
7
+ - Combine separate single-band TIFFs into RGB
8
+ - Apply global 2–98% stretch for RGB
9
+ - Display NetCDF/HDF subsets with consistent scaling
10
+ - Overlay shapefiles automatically reprojected to raster CRS
11
+ - Navigate bands/time steps interactively
14
12
 
15
13
  Controls
16
14
  + / - : zoom in/out
@@ -18,7 +16,7 @@ Controls
18
16
  C / V : increase/decrease contrast (works in RGB and single-band)
19
17
  G / H : increase/decrease gamma (works in RGB and single-band)
20
18
  M : toggle colormap (viridis <-> magma) — single-band only
21
- [ / ] : previous / next band (single-band)
19
+ [ / ] : previous / next band (or time step) (single-band)
22
20
  R : reset view
23
21
 
24
22
  Examples
@@ -34,7 +32,8 @@ import numpy as np
34
32
  import rasterio
35
33
  from rasterio.transform import Affine
36
34
  from PySide6.QtWidgets import (
37
- QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QScrollBar, QGraphicsPathItem
35
+ QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
36
+ QScrollBar, QGraphicsPathItem, QVBoxLayout, QHBoxLayout, QWidget, QStatusBar
38
37
  )
39
38
  from PySide6.QtGui import QImage, QPixmap, QPainter, QPen, QColor, QPainterPath
40
39
  from PySide6.QtCore import Qt
@@ -43,7 +42,7 @@ import matplotlib.cm as cm
43
42
  import warnings
44
43
  warnings.filterwarnings("ignore", category=RuntimeWarning, module="shapely")
45
44
 
46
- __version__ = "0.2.1"
45
+ __version__ = "0.2.2"
47
46
 
48
47
  # Optional overlay deps
49
48
  try:
@@ -56,49 +55,70 @@ try:
56
55
  except Exception:
57
56
  HAVE_GEO = False
58
57
 
58
+ # Optional NetCDF deps (lazy-loaded when needed)
59
+ HAVE_NETCDF = False
60
+ xr = None
61
+ pd = None
62
+
63
+ # Optional cartopy deps for better map visualization (lazy-loaded when needed)
64
+ # Check if cartopy is available but don't import yet
65
+ try:
66
+ import importlib.util
67
+ HAVE_CARTOPY = importlib.util.find_spec("cartopy") is not None
68
+ except Exception:
69
+ HAVE_CARTOPY = False
70
+
59
71
  def warn_if_large(tif_path, scale=1):
60
- """Warn and confirm before loading very large rasters (GeoTIFF, GDB, or HDF).
61
- Works even if GDAL is not installed.
72
+ """Warn and confirm before loading very large rasters (GeoTIFF, GDB, or HDF).
73
+ Uses GDAL if available, falls back to rasterio for standard formats.
62
74
  """
63
75
  import os
64
76
  width = height = None
65
77
  size_mb = None
66
78
 
67
- # Try GDAL if available
79
+ if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
80
+ tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
81
+
82
+
68
83
  try:
69
- from osgeo import gdal
70
- gdal.UseExceptions()
71
- info = gdal.Info(tif_path, format="json")
72
- width, height = info.get("size", [0, 0])
73
- except ImportError:
74
- # Fallback if GDAL not installed
84
+ width, height = None, None
85
+
86
+ # Try GDAL first (supports more formats including GDB, HDF)
75
87
  try:
76
- import rasterio
77
- with rasterio.open(tif_path) as src:
78
- width, height = src.width, src.height
79
- except Exception:
80
- print("[WARN] Could not estimate raster size (no GDAL/rasterio). Skipping size check.")
81
- return
88
+ from osgeo import gdal
89
+ gdal.UseExceptions()
90
+ info = gdal.Info(tif_path, format="json")
91
+ width, height = info.get("size", [0, 0])
92
+ except ImportError:
93
+ # GDAL not available, try rasterio for standard formats
94
+ try:
95
+ with rasterio.open(tif_path) as src:
96
+ width = src.width
97
+ height = src.height
98
+ except Exception:
99
+ # If rasterio also fails, skip the check
100
+ print(f"[INFO] Could not determine raster dimensions for size check.")
101
+ return
102
+
103
+ if width and height:
104
+ total_pixels = (width * height) / (scale ** 2) # account for downsampling
105
+ size_mb = None
106
+ if os.path.exists(tif_path):
107
+ size_mb = os.path.getsize(tif_path) / (1024 ** 2)
108
+
109
+ # Only warn if the *effective* pixels remain large
110
+ if total_pixels > 20_000_000 and scale <= 5:
111
+ print(
112
+ f"[WARN] Large raster detected ({width}×{height}, ~{total_pixels/1e6:.1f}M effective pixels"
113
+ + (f", ~{size_mb:.1f} MB" if size_mb else "")
114
+ + "). Loading may freeze. Consider rerunning with --scale (e.g. --scale 10)."
115
+ )
116
+ ans = input("Proceed anyway? [y/N]: ").strip().lower()
117
+ if ans not in ("y", "yes"):
118
+ print("Cancelled.")
119
+ sys.exit(0)
82
120
  except Exception as e:
83
- print(f"[WARN] Could not pre-check raster size with GDAL: {e}")
84
- return
85
-
86
- # File size
87
- if os.path.exists(tif_path):
88
- size_mb = os.path.getsize(tif_path) / (1024 ** 2)
89
-
90
- total_pixels = (width * height) / (scale ** 2)
91
- if total_pixels > 20_000_000 and scale <= 5:
92
- msg = (
93
- f"[WARN] Large raster detected ({width}×{height}, ~{total_pixels/1e6:.1f}M pixels"
94
- + (f", ~{size_mb:.1f} MB" if size_mb else "")
95
- + "). Loading may freeze. Consider --scale (e.g. --scale 10)."
96
- )
97
- print(msg)
98
- ans = input("Proceed anyway? [y/N]: ").strip().lower()
99
- if ans not in ("y", "yes"):
100
- print("Cancelled.")
101
- sys.exit(0)
121
+ print(f"[INFO] Could not pre-check raster size: {e}")
102
122
 
103
123
  # -------------------------- QGraphicsView tweaks -------------------------- #
104
124
  class RasterView(QGraphicsView):
@@ -171,7 +191,8 @@ class TiffViewer(QMainWindow):
171
191
  # --- Load data ---
172
192
  if rgbfiles:
173
193
  red, green, blue = rgbfiles
174
- with rasterio.open(red) as r, rasterio.open(green) as g, rasterio.open(blue) as b:
194
+ import rasterio as rio_module
195
+ with rio_module.open(red) as r, rio_module.open(green) as g, rio_module.open(blue) as b:
175
196
  if (r.width, r.height) != (g.width, g.height) or (r.width, r.height) != (b.width, b.height):
176
197
  raise ValueError("All RGB files must have the same dimensions.")
177
198
  arr = np.stack([
@@ -189,8 +210,154 @@ class TiffViewer(QMainWindow):
189
210
  self.tif_path = self.tif_path or (os.path.commonprefix([red, green, blue]) or red)
190
211
 
191
212
  elif tif_path:
213
+ # --------------------- Detect NetCDF --------------------- #
214
+ if tif_path and tif_path.lower().endswith((".nc", ".netcdf")):
215
+ try:
216
+ # Lazy-load NetCDF dependencies
217
+ import xarray as xr
218
+ import pandas as pd
219
+
220
+ # Open the NetCDF file
221
+ ds = xr.open_dataset(tif_path)
222
+
223
+ # List variables, filtering out boundary variables (ending with _bnds)
224
+ all_vars = list(ds.data_vars)
225
+ data_vars = [var for var in all_vars if not var.endswith('_bnds')]
226
+
227
+ # Auto-select the first variable if there's only one and no subset specified
228
+ if len(data_vars) == 1 and subset is None:
229
+ subset = 0
230
+ # Only list variables if --subset not given and multiple variables exist
231
+ elif subset is None:
232
+ sys.exit(0)
233
+
234
+ # Validate subset index
235
+ if subset < 0 or subset >= len(data_vars):
236
+ raise ValueError(f"Invalid variable index {subset}. Valid range: 0–{len(data_vars)-1}")
237
+
238
+ # Get the selected variable from filtered data_vars
239
+ var_name = data_vars[subset]
240
+ var_data = ds[var_name]
241
+
242
+ # Store original dataset and variable information for better visualization
243
+ self._nc_dataset = ds
244
+ self._nc_var_name = var_name
245
+ self._nc_var_data = var_data
246
+
247
+ # Get coordinate info if available
248
+ self._has_geo_coords = False
249
+ if 'lon' in ds.coords and 'lat' in ds.coords:
250
+ self._has_geo_coords = True
251
+ self._lon_data = ds.lon.values
252
+ self._lat_data = ds.lat.values
253
+ elif 'longitude' in ds.coords and 'latitude' in ds.coords:
254
+ self._has_geo_coords = True
255
+ self._lon_data = ds.longitude.values
256
+ self._lat_data = ds.latitude.values
257
+
258
+ # Handle time or other index dimension if present
259
+ self._has_time_dim = False
260
+ self._time_dim_name = None
261
+ time_index = 0
262
+
263
+ # Look for a time dimension first
264
+ if 'time' in var_data.dims:
265
+ self._has_time_dim = True
266
+ self._time_dim_name = 'time'
267
+ self._time_values = ds['time'].values
268
+ self._time_index = 0
269
+ print(f"NetCDF time dimension detected: {len(self._time_values)} steps")
270
+
271
+ self.band_count = var_data.sizes['time']
272
+ self.band_index = 0
273
+ self._time_dim_name = 'time'
274
+
275
+ # Try to format time values for better display
276
+ time_units = getattr(ds.time, 'units', None)
277
+ time_calendar = getattr(ds.time, 'calendar', 'standard')
278
+
279
+ # Select first time step by default
280
+ var_data = var_data.isel(time=time_index)
281
+
282
+ # If no time dimension but variable has multiple dimensions,
283
+ # use the first non-spatial dimension as a "time" dimension
284
+ elif len(var_data.dims) > 2:
285
+ # Try to find a dimension that's not lat/lon
286
+ spatial_dims = ['lat', 'lon', 'latitude', 'longitude', 'y', 'x']
287
+ for dim in var_data.dims:
288
+ if dim not in spatial_dims:
289
+ self._has_time_dim = True
290
+ self._time_dim_name = dim
291
+ self._time_values = ds[dim].values
292
+ self._time_index = time_index
293
+
294
+ # Select first index by default
295
+ var_data = var_data.isel({dim: time_index})
296
+ break
297
+
298
+ # Convert to numpy array
299
+ arr = var_data.values.astype(np.float32)
300
+
301
+ # Process array based on dimensions
302
+ if arr.ndim > 2:
303
+ # Keep only lat/lon dimensions for 3D+ arrays
304
+ arr = np.squeeze(arr)
305
+
306
+ # --- Downsample large arrays for responsiveness ---
307
+ if arr.ndim >= 2:
308
+ h, w = arr.shape[:2]
309
+ if h * w > 4_000_000:
310
+ step = max(2, int((h * w / 4_000_000) ** 0.5))
311
+ if arr.ndim == 2:
312
+ arr = arr[::step, ::step]
313
+ else:
314
+ arr = arr[::step, ::step, :]
315
+
316
+ # --- Final assignments ---
317
+ self.data = arr
318
+
319
+ # Try to extract CRS from CF conventions
320
+ self._transform = None
321
+ self._crs = None
322
+ if 'crs' in ds.variables:
323
+ try:
324
+ import rasterio.crs
325
+ crs_var = ds.variables['crs']
326
+ if hasattr(crs_var, 'spatial_ref'):
327
+ self._crs = rasterio.crs.CRS.from_wkt(crs_var.spatial_ref)
328
+ except Exception as e:
329
+ print(f"Could not parse CRS: {e}")
330
+
331
+ # Set band info
332
+ if arr.ndim == 3:
333
+ self.band_count = arr.shape[2]
334
+ else:
335
+ self.band_count = 1
336
+
337
+ self.band_index = 0
338
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
339
+
340
+ # --- If user specified --band, start there ---
341
+ if self.band and self.band <= self.band_count:
342
+ self.band_index = self.band - 1
343
+
344
+ # Enable cartopy visualization if available
345
+ self._use_cartopy = HAVE_CARTOPY and self._has_geo_coords
346
+
347
+ except ImportError as e:
348
+ if "xarray" in str(e) or "netCDF4" in str(e):
349
+ raise RuntimeError(
350
+ f"NetCDF support requires additional dependencies.\n"
351
+ f"Install them with: pip install viewtif[netcdf]\n"
352
+ f"Original error: {str(e)}"
353
+ )
354
+ else:
355
+ raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
356
+ except Exception as e:
357
+ raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
358
+
192
359
  # ---------------- Handle File Geodatabase (.gdb) ---------------- #
193
- if tif_path.lower().endswith(".gdb") and ":" not in tif_path:
360
+ if tif_path and tif_path.lower().endswith(".gdb") and ":" not in tif_path:
194
361
  import re, subprocess
195
362
  gdb_path = tif_path # use full path to .gdb
196
363
  try:
@@ -212,100 +379,141 @@ class TiffViewer(QMainWindow):
212
379
 
213
380
  # --- Universal size check before loading ---
214
381
  warn_if_large(tif_path, scale=self._scale_arg)
215
-
382
+
383
+ if False: # Placeholder for previous if condition
384
+ pass
216
385
  # --------------------- Detect HDF/HDF5 --------------------- #
217
- if tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
386
+ elif tif_path and tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
218
387
  try:
219
- # Try reading directly with Rasterio first (works for simple HDF layouts)
220
- with rasterio.open(tif_path) as src:
221
- print(f"Opened HDF with rasterio: {os.path.basename(tif_path)}")
222
- arr = src.read().astype(np.float32)
223
- arr = np.squeeze(arr)
224
- if arr.ndim == 3:
225
- arr = np.transpose(arr, (1, 2, 0))
226
- elif arr.ndim == 2:
227
- print("Single-band dataset.")
228
- self.data = arr
229
- self._transform = src.transform
230
- self._crs = src.crs
231
- self.band_count = arr.shape[2] if arr.ndim == 3 else 1
232
- self.band_index = 0
233
- self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
234
- return # ✅ Skip GDAL path if Rasterio succeeded
388
+ # Try GDAL first (best support for HDF subdatasets)
389
+ from osgeo import gdal
390
+ gdal.UseExceptions()
235
391
 
236
- except Exception as e:
237
- print(f"Rasterio could not open HDF directly: {e}")
238
- print("Falling back to GDAL...")
392
+ ds = gdal.Open(tif_path)
393
+ subs = ds.GetSubDatasets()
239
394
 
240
- try:
241
- from osgeo import gdal
242
- gdal.UseExceptions()
243
-
244
- ds = gdal.Open(tif_path)
245
- subs = ds.GetSubDatasets()
246
- if not subs:
247
- raise ValueError("No subdatasets found in HDF/HDF5 file.")
395
+ if not subs:
396
+ raise ValueError("No subdatasets found in HDF/HDF5 file.")
248
397
 
398
+ # Only list subsets if --subset not given
399
+ if subset is None:
249
400
  print(f"Found {len(subs)} subdatasets in {os.path.basename(tif_path)}:")
250
401
  for i, (_, desc) in enumerate(subs):
251
402
  print(f"[{i}] {desc}")
403
+ print("\nUse --subset N to open a specific subdataset.")
404
+ sys.exit(0)
252
405
 
253
- if subset is None:
254
- print("\nUse --subset N to open a specific subdataset.")
255
- sys.exit(0)
256
-
257
- if subset < 0 or subset >= len(subs):
258
- raise ValueError(f"Invalid subset index {subset}. Valid range: 0–{len(subs)-1}")
259
-
260
- sub_name, desc = subs[subset]
261
- print(f"\nOpening subdataset [{subset}]: {desc}")
262
- sub_ds = gdal.Open(sub_name)
263
-
264
- arr = sub_ds.ReadAsArray().astype(np.float32)
265
- arr = np.squeeze(arr)
266
- if arr.ndim == 3:
267
- arr = np.transpose(arr, (1, 2, 0))
268
- elif arr.ndim == 2:
269
- print("Single-band dataset.")
270
- else:
271
- raise ValueError(f"Unexpected array shape {arr.shape}")
272
-
273
- # Downsample large arrays for responsiveness
274
- h, w = arr.shape[:2]
275
- if h * w > 4_000_000:
276
- step = max(2, int((h * w / 4_000_000) ** 0.5))
277
- arr = arr[::step, ::step] if arr.ndim == 2 else arr[::step, ::step, :]
278
- print(f"⚠️ Large dataset preview: downsampled by {step}x")
279
-
280
- # Assign
281
- self.data = arr
282
- self._transform = None
283
- self._crs = None
284
- self.band_count = arr.shape[2] if arr.ndim == 3 else 1
285
- self.band_index = 0
286
- self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
287
-
288
- if self.band_count > 1:
289
- print(f"This subdataset has {self.band_count} bands — switch with [ and ] keys.")
290
- else:
291
- print("This subdataset has 1 band.")
406
+ # Validate subset index
407
+ if subset < 0 or subset >= len(subs):
408
+ raise ValueError(f"Invalid subset index {subset}. Valid range: 0–{len(subs)-1}")
409
+
410
+ sub_name, desc = subs[subset]
411
+ print(f"\nOpening subdataset [{subset}]: {desc}")
412
+ sub_ds = gdal.Open(sub_name)
413
+
414
+ # --- Read once ---
415
+ arr = sub_ds.ReadAsArray().astype(np.float32)
416
+ #print(f"Raw array shape from GDAL: {arr.shape} (ndim={arr.ndim})")
417
+
418
+ # --- Normalize shape ---
419
+ arr = np.squeeze(arr)
420
+ if arr.ndim == 3:
421
+ # Convert from (bands, rows, cols) → (rows, cols, bands)
422
+ arr = np.transpose(arr, (1, 2, 0))
423
+ #print(f"Transposed to {arr.shape} (rows, cols, bands)")
424
+ elif arr.ndim == 2:
425
+ print("Single-band dataset.")
426
+ else:
427
+ raise ValueError(f"Unexpected array shape {arr.shape}")
428
+
429
+ # --- Downsample large arrays for responsiveness ---
430
+ h, w = arr.shape[:2]
431
+ if h * w > 4_000_000:
432
+ step = max(2, int((h * w / 4_000_000) ** 0.5))
433
+ arr = arr[::step, ::step] if arr.ndim == 2 else arr[::step, ::step, :]
434
+
435
+ # --- Final assignments ---
436
+ self.data = arr
437
+ self._transform = None
438
+ self._crs = None
439
+ self.band_count = arr.shape[2] if arr.ndim == 3 else 1
440
+ self.band_index = 0
441
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
442
+
443
+ if self.band_count > 1:
444
+ print(f"This subdataset has {self.band_count} bands — switch with [ and ] keys.")
445
+ else:
446
+ print("This subdataset has 1 band.")
292
447
 
293
448
  if self.band and self.band <= self.band_count:
294
449
  self.band_index = self.band - 1
295
450
  print(f"Opening band {self.band}/{self.band_count}")
296
451
 
297
- except ImportError:
452
+ except ImportError:
453
+ # GDAL not available, try rasterio as fallback for NetCDF
454
+ print("[INFO] GDAL not available, attempting to read HDF/NetCDF with rasterio...")
455
+ try:
456
+ import rasterio as rio
457
+ with rio.open(tif_path) as src:
458
+ print(f"[INFO] NetCDF file opened via rasterio")
459
+ print(f"[INFO] Data shape: {src.height} x {src.width} x {src.count} bands")
460
+
461
+ if src.count == 0:
462
+ raise ValueError("No bands found in NetCDF file.")
463
+
464
+ # Determine which band(s) to read
465
+ if self.band and self.band <= src.count:
466
+ band_indices = [self.band]
467
+ print(f"Opening band {self.band}/{src.count}")
468
+ elif rgb and all(b <= src.count for b in rgb):
469
+ band_indices = rgb
470
+ print(f"Opening bands {rgb} as RGB")
471
+ else:
472
+ band_indices = list(range(1, min(src.count + 1, 4))) # Read up to 3 bands
473
+ print(f"Opening bands {band_indices}")
474
+
475
+ # Read selected bands
476
+ bands = []
477
+ for b in band_indices:
478
+ band_data = src.read(b, out_shape=(src.height // self._scale_arg, src.width // self._scale_arg))
479
+ bands.append(band_data)
480
+
481
+ # Stack into array
482
+ arr = np.stack(bands, axis=-1).astype(np.float32) if len(bands) > 1 else bands[0].astype(np.float32)
483
+
484
+ # Handle no-data values
485
+ nd = src.nodata
486
+ if nd is not None:
487
+ if arr.ndim == 3:
488
+ arr[arr == nd] = np.nan
489
+ else:
490
+ arr[arr == nd] = np.nan
491
+
492
+ # Final assignments
493
+ self.data = arr
494
+ self._transform = src.transform
495
+ self._crs = src.crs
496
+ self.band_count = arr.shape[2] if arr.ndim == 3 else 1
497
+ self.band_index = 0
498
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
499
+
500
+ if self.band_count > 1:
501
+ print(f"Loaded {self.band_count} bands — switch with [ and ] keys.")
502
+ else:
503
+ print("Loaded 1 band.")
504
+ except Exception as e:
298
505
  raise RuntimeError(
299
- "HDF/HDF5 support requires GDAL (Python bindings).\n"
300
- "Install it first (e.g., brew install gdal && pip install GDAL)"
506
+ f"Failed to read HDF/NetCDF file: {e}\n"
507
+ "For full HDF support, install GDAL: pip install GDAL"
301
508
  )
302
509
 
303
510
  # --------------------- Regular GeoTIFF --------------------- #
304
511
  else:
305
- if os.path.dirname(tif_path).endswith(".gdb"):
512
+ if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
306
513
  tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
307
514
 
308
- with rasterio.open(tif_path) as src:
515
+ import rasterio as rio_module
516
+ with rio_module.open(tif_path) as src:
309
517
  self._transform = src.transform
310
518
  self._crs = src.crs
311
519
  if rgb is not None:
@@ -349,19 +557,39 @@ class TiffViewer(QMainWindow):
349
557
  self.gamma = 1.0
350
558
 
351
559
  # Colormap (single-band)
352
- self.cmap_name = "viridis"
353
- self.alt_cmap_name = "magma" # toggle with M in single-band
560
+ # For NetCDF temperature data, have three colormaps in rotation
561
+ if tif_path and tif_path.lower().endswith(('.nc', '.netcdf')):
562
+ self.cmap_names = ["RdBu_r", "viridis", "magma"] # three colormaps for NetCDF
563
+ self.cmap_index = 0 # start with RdBu_r
564
+ self.cmap_name = self.cmap_names[self.cmap_index]
565
+ else:
566
+ self.cmap_name = "viridis"
567
+ self.alt_cmap_name = "magma" # toggle with M in single-band
354
568
 
355
569
  self.zoom_step = 1.2
356
570
  self.pan_step = 80
357
571
 
572
+ # Create main widget and layout
573
+ self.main_widget = QWidget()
574
+ self.main_layout = QVBoxLayout(self.main_widget)
575
+ self.main_layout.setContentsMargins(0, 0, 0, 0)
576
+ self.main_layout.setSpacing(0)
577
+
358
578
  # Scene + view
359
579
  self.scene = QGraphicsScene(self)
360
580
  self.view = RasterView(self.scene, self)
361
- self.setCentralWidget(self.view)
581
+ self.main_layout.addWidget(self.view)
582
+
583
+ # Status bar
584
+ self.setStatusBar(QStatusBar())
585
+
586
+ # Set central widget
587
+ self.setCentralWidget(self.main_widget)
362
588
 
363
589
  self.pixmap_item = None
364
590
  self._last_rgb = None
591
+
592
+ # --- Initial render ---
365
593
  self.update_pixmap()
366
594
 
367
595
  # Overlays (if any)
@@ -499,17 +727,246 @@ class TiffViewer(QMainWindow):
499
727
 
500
728
  # ----------------------- Title / Rendering ----------------------- #
501
729
  def update_title(self):
502
- if self.rgbfiles:
503
- names = [os.path.basename(n) for n in self.rgbfiles]
504
- self.setWindowTitle(f"RGB ({', '.join(names)})")
505
- elif self.rgb_mode and self.rgb:
506
- self.setWindowTitle(f"RGB {self.rgb} — {os.path.basename(self.tif_path)}")
730
+ """Show correct title for GeoTIFF or NetCDF time series."""
731
+ import os
732
+
733
+ if hasattr(self, "_has_time_dim") and self._has_time_dim:
734
+ nc_name = getattr(self, "_nc_var_name", "")
735
+ file_name = os.path.basename(self.tif_path)
736
+ title = f"Time step {self.band_index + 1}/{self.band_count} — {file_name}"
737
+
507
738
  elif hasattr(self, "band_index"):
508
- self.setWindowTitle(
509
- f"Band {self.band_index + 1}/{self.band_count} — {os.path.basename(self.tif_path)}"
510
- )
739
+ title = f"Band {self.band_index + 1}/{self.band_count} — {os.path.basename(self.tif_path)}"
740
+
741
+ elif self.rgb_mode and self.rgb:
742
+ # title = f"RGB {self.rgb} — {os.path.basename(self.tif_path)}"
743
+ title = f"RGB {self.rgb}"
744
+
511
745
  else:
512
- self.setWindowTitle(f"Band {self.band}/{self.band_count} — {os.path.basename(self.tif_path)}")
746
+ title = os.path.basename(self.tif_path)
747
+
748
+ print(f"Title: {title}")
749
+ self.setWindowTitle(title)
750
+
751
+ def _normalize_lat_lon(self, frame):
752
+ """Flip frame only if data and lat orientation disagree."""
753
+ import numpy as np
754
+
755
+ if not hasattr(self, "_lat_data"):
756
+ return frame
757
+
758
+ lats = self._lat_data
759
+
760
+ # 1D latitude case
761
+ if np.ndim(lats) == 1:
762
+ lat_ascending = lats[0] < lats[-1]
763
+
764
+ # If first pixel row corresponds to northernmost lat → do nothing
765
+ # If first pixel row corresponds to southernmost lat → flip to make north at top
766
+ # We'll assume data[0, :] corresponds to lats[0]
767
+ if lat_ascending:
768
+ print("[DEBUG] Flipping latitude orientation (lat ascending, data starts south)")
769
+ frame = np.flipud(frame)
770
+ # else:
771
+ # print("[DEBUG] No flip (lat descending, already north-up)")
772
+ return frame
773
+
774
+ # 2D latitude grid (rare case)
775
+ elif np.ndim(lats) == 2:
776
+ first_col = lats[:, 0]
777
+ lat_ascending = first_col[0] < first_col[-1]
778
+ if lat_ascending:
779
+ print("[DEBUG] Flipping latitude orientation (2D grid ascending)")
780
+ frame = np.flipud(frame)
781
+ # else:
782
+ # print("[DEBUG] No flip (2D grid already north-up)")
783
+ return frame
784
+
785
+ return frame
786
+
787
+ def _apply_scale_if_needed(self, frame):
788
+ """Downsample frame and lat/lon consistently if --scale > 1."""
789
+ if not hasattr(self, "_scale_arg") or self._scale_arg <= 1:
790
+ return frame
791
+
792
+ step = int(self._scale_arg)
793
+ print(f"[DEBUG] Applying scale factor {step} to current frame")
794
+
795
+ # Downsample the frame
796
+ frame = frame[::step, ::step]
797
+
798
+ # Also downsample lat/lon for this viewer instance if not already
799
+ if hasattr(self, "_lat_data") and np.ndim(self._lat_data) == 1 and len(self._lat_data) > frame.shape[0]:
800
+ self._lat_data = self._lat_data[::step]
801
+ if hasattr(self, "_lon_data") and np.ndim(self._lon_data) == 1 and len(self._lon_data) > frame.shape[1]:
802
+ self._lon_data = self._lon_data[::step]
803
+
804
+ return frame
805
+
806
+ def get_current_frame(self):
807
+ """Return the current time/band frame as a NumPy array (2D)."""
808
+ frame = None
809
+
810
+ if hasattr(self, '_time_dim_name') and hasattr(self, '_nc_var_data'):
811
+ # Select frame using band_index
812
+ try:
813
+ frame = self._nc_var_data.isel({self._time_dim_name: self.band_index})
814
+ except Exception:
815
+ # Already numpy or index error fallback
816
+ frame = self._nc_var_data
817
+
818
+ elif isinstance(self.data, np.ndarray):
819
+ frame = self.data
820
+
821
+ # Normalize lat orientation if needed
822
+ frame = self._normalize_lat_lon(frame)
823
+ frame = self._apply_scale_if_needed(frame)
824
+ # Convert to numpy if it's still an xarray
825
+ if hasattr(frame, "values"):
826
+ frame = frame.values
827
+
828
+ # Apply same scaling factor (if any)
829
+ if hasattr(self, "_scale_arg") and self._scale_arg > 1:
830
+ step = int(self._scale_arg)
831
+
832
+ return frame.astype(np.float32)
833
+
834
+ def format_time_value(self, time_value):
835
+ """Format a time value into a user-friendly string"""
836
+ # Default is the string representation
837
+ time_str = str(time_value)
838
+
839
+ try:
840
+ # Handle numpy datetime64
841
+ if hasattr(time_value, 'dtype') and np.issubdtype(time_value.dtype, np.datetime64):
842
+ # Lazy-load pandas for timestamp conversion
843
+ import pandas as pd
844
+ # Convert to Python datetime if possible
845
+ dt = pd.Timestamp(time_value).to_pydatetime()
846
+ time_str = dt.strftime('%Y-%m-%d %H:%M:%S')
847
+ # Handle native Python datetime
848
+ elif hasattr(time_value, 'strftime'):
849
+ time_str = time_value.strftime('%Y-%m-%d %H:%M:%S')
850
+ # Handle cftime datetime-like objects used in some NetCDF files
851
+ elif hasattr(time_value, 'isoformat'):
852
+ time_str = time_value.isoformat().replace('T', ' ')
853
+ except Exception:
854
+ # Fall back to string representation
855
+ pass
856
+
857
+ return time_str
858
+
859
+ # def update_time_label(self):
860
+ # """Update the time label with the current time value"""
861
+ # if hasattr(self, '_has_time_dim') and self._has_time_dim:
862
+ # try:
863
+ # time_value = self._time_values[self._time_index]
864
+ # time_str = self.format_time_value(time_value)
865
+
866
+ # # Update time label if it exists
867
+ # if hasattr(self, 'time_label'):
868
+ # self.time_label.setText(f"Time: {time_str}")
869
+
870
+ # # Create a progress bar style display of time position
871
+ # total = len(self._time_values)
872
+ # position = self._time_index + 1
873
+ # bar_width = 20 # Width of the progress bar
874
+ # filled = int(bar_width * position / total)
875
+ # bar = "[" + "#" * filled + "-" * (bar_width - filled) + "]"
876
+
877
+ # # Show time info in status bar
878
+ # step_info = f"Time step: {position}/{total} {bar} {self.format_time_value(self._time_values[self._time_index])}"
879
+
880
+ # # Update status bar if it exists
881
+ # if hasattr(self, 'statusBar') and callable(self.statusBar):
882
+ # self.statusBar().showMessage(step_info)
883
+ # else:
884
+ # print(step_info)
885
+ # except Exception as e:
886
+ # print(f"Error updating time label: {e}")
887
+
888
+ # def toggle_play_pause(self):
889
+ # """Toggle play/pause animation of time steps"""
890
+ # if self._is_playing:
891
+ # self.stop_animation()
892
+ # else:
893
+ # self.start_animation()
894
+
895
+ # def start_animation(self):
896
+ # """Start the time animation"""
897
+ # from PySide6.QtCore import QTimer
898
+
899
+ # if not hasattr(self, '_play_timer') or self._play_timer is None:
900
+ # self._play_timer = QTimer(self)
901
+ # self._play_timer.timeout.connect(self.animation_step)
902
+
903
+ # # Set animation speed (milliseconds between frames)
904
+ # animation_speed = 500 # 0.5 seconds between frames
905
+ # self._play_timer.start(animation_speed)
906
+
907
+ # self._is_playing = True
908
+ # self.play_button.setText("⏸") # Pause symbol
909
+ # self.play_button.setToolTip("Pause animation")
910
+
911
+ # def stop_animation(self):
912
+ # """Stop the time animation"""
913
+ # if hasattr(self, '_play_timer') and self._play_timer is not None:
914
+ # self._play_timer.stop()
915
+
916
+ # self._is_playing = False
917
+ # self.play_button.setText("▶") # Play symbol
918
+ # self.play_button.setToolTip("Play animation")
919
+
920
+ # def animation_step(self):
921
+ # """Advance one frame in the animation"""
922
+ # # Go to next time step
923
+ # next_time = (self._time_index + 1) % len(self._time_values)
924
+ # self.time_slider.setValue(next_time)
925
+
926
+ # def closeEvent(self, event):
927
+ # """Clean up resources when the window is closed"""
928
+ # # Stop animation timer if it's running
929
+ # if hasattr(self, '_is_playing') and self._is_playing:
930
+ # self.stop_animation()
931
+
932
+ # # Call the parent class closeEvent
933
+ # super().closeEvent(event)
934
+
935
+ # def populate_date_combo(self):
936
+ # """Populate the date combo box with time values"""
937
+ # if hasattr(self, '_has_time_dim') and self._has_time_dim and hasattr(self, 'date_combo'):
938
+ # try:
939
+ # self.date_combo.clear()
940
+
941
+ # # Add a reasonable subset of dates if there are too many
942
+ # max_items = 100 # Maximum number of items to show in dropdown
943
+
944
+ # if len(self._time_values) <= max_items:
945
+ # # Add all time values
946
+ # for i, time_value in enumerate(self._time_values):
947
+ # time_str = self.format_time_value(time_value)
948
+ # self.date_combo.addItem(time_str, i)
949
+ # else:
950
+ # # Add a subset of time values
951
+ # step = len(self._time_values) // max_items
952
+
953
+ # # Always include first and last
954
+ # indices = list(range(0, len(self._time_values), step))
955
+ # if (len(self._time_values) - 1) not in indices:
956
+ # indices.append(len(self._time_values) - 1)
957
+
958
+ # for i in indices:
959
+ # time_str = self.format_time_value(self._time_values[i])
960
+ # self.date_combo.addItem(f"{time_str} [{i+1}/{len(self._time_values)}]", i)
961
+ # except Exception as e:
962
+ # print(f"Error populating date combo: {e}")
963
+
964
+ # def date_combo_changed(self, index):
965
+ # """Handle date combo box selection change"""
966
+ # if index >= 0:
967
+ # time_index = self.date_combo.itemData(index)
968
+ # if time_index is not None:
969
+ # self.time_slider.setValue(time_index)
513
970
 
514
971
  def _render_rgb(self):
515
972
  if self.rgb_mode:
@@ -538,6 +995,126 @@ class TiffViewer(QMainWindow):
538
995
  rgb = (cmap(norm)[..., :3] * 255).astype(np.uint8)
539
996
  return rgb
540
997
 
998
+ def _render_cartopy_map(self, data):
999
+ """Render a NetCDF variable with cartopy for better geographic visualization"""
1000
+ import matplotlib.pyplot as plt
1001
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
1002
+ import cartopy.crs as ccrs
1003
+ import cartopy.feature as cfeature
1004
+
1005
+ # Create a new figure with cartopy projection
1006
+ fig = plt.figure(figsize=(12, 8), dpi=100)
1007
+ ax = plt.axes(projection=ccrs.PlateCarree())
1008
+
1009
+ # Get coordinates
1010
+ lons = self._lon_data
1011
+ lats = self._lat_data
1012
+
1013
+ # Create contour plot
1014
+ levels = 20
1015
+ if hasattr(plt.cm, self.cmap_name):
1016
+ cmap = getattr(plt.cm, self.cmap_name)
1017
+ else:
1018
+ cmap = getattr(cm, self.cmap_name, cm.viridis)
1019
+
1020
+ # Apply contrast and gamma adjustments
1021
+ finite = np.isfinite(data)
1022
+ norm_data = np.zeros_like(data, dtype=np.float32)
1023
+ vmin, vmax = np.nanmin(data), np.nanmax(data)
1024
+ rng = max(vmax - vmin, 1e-12)
1025
+
1026
+ if np.any(finite):
1027
+ norm_data[finite] = (data[finite] - vmin) / rng
1028
+
1029
+ norm_data = np.clip(norm_data * self.contrast, 0.0, 1.0)
1030
+ norm_data = np.power(norm_data, self.gamma)
1031
+ norm_data = norm_data * rng + vmin
1032
+
1033
+ # Downsample coordinates to match downsampled data shape
1034
+ # --- Align coordinates with data shape (no stepping assumptions) ---
1035
+ # Downsample coordinates to match downsampled data shape
1036
+ data_height, data_width = data.shape[:2]
1037
+ lat_samples = len(lats)
1038
+ lon_samples = len(lons)
1039
+
1040
+ lat_step = max(1, lat_samples // data_height)
1041
+ lon_step = max(1, lon_samples // data_width)
1042
+
1043
+ # Downsample coordinate arrays to match data
1044
+ lats_downsampled = lats[::lat_step][:data_height]
1045
+ lons_downsampled = lons[::lon_step][:data_width]
1046
+
1047
+ # --- Synchronize latitude orientation with normalized data ---
1048
+ if np.ndim(lats) == 1 and lats[0] < lats[-1]:
1049
+ print("[DEBUG] Lat ascending → flip lats_downsampled to match flipped data")
1050
+ lats_downsampled = lats_downsampled[::-1]
1051
+ elif np.ndim(lats) == 2:
1052
+ first_col = lats[:, 0]
1053
+ if first_col[0] < first_col[-1]:
1054
+ print("[DEBUG] 2D lat grid ascending → flip lats_downsampled vertically")
1055
+ lats_downsampled = np.flipud(lats_downsampled)
1056
+
1057
+ # Convert 0–360 longitude to −180–180 if needed
1058
+ if lons_downsampled.max() > 180:
1059
+ lons_downsampled = ((lons_downsampled + 180) % 360) - 180
1060
+
1061
+
1062
+ # --- Build meshgrid AFTER any flip ---
1063
+ lon_grid, lat_grid = np.meshgrid(lons_downsampled, lats_downsampled, indexing="xy")
1064
+
1065
+ # Use pcolormesh (more stable than contourf for gridded data)
1066
+ img = ax.pcolormesh(
1067
+ lon_grid, lat_grid, data,
1068
+ transform=ccrs.PlateCarree(),
1069
+ cmap=cmap,
1070
+ shading="auto"
1071
+ )
1072
+
1073
+ # Set extent from the 1D vectors (already flipped if needed)
1074
+ ax.set_extent(
1075
+ [lons_downsampled.min(), lons_downsampled.max(),
1076
+ lats_downsampled.min(), lats_downsampled.max()],
1077
+ crs=ccrs.PlateCarree()
1078
+ )
1079
+
1080
+ # Add map features
1081
+ ax.coastlines(resolution="50m", linewidth=0.5)
1082
+ ax.add_feature(cfeature.BORDERS, linestyle=":", linewidth=0.5)
1083
+ ax.add_feature(cfeature.STATES, linestyle="-", linewidth=0.3, alpha=0.5)
1084
+ ax.gridlines(draw_labels=True, alpha=0.3)
1085
+
1086
+ # --- Add dynamic title ---
1087
+ title = os.path.basename(self.tif_path)
1088
+ if hasattr(self, "_has_time_dim") and self._has_time_dim:
1089
+ # Use current band_index as proxy for time_index
1090
+ try:
1091
+ current_time = self._time_values[self.band_index]
1092
+ time_str = self.format_time_value(current_time) if hasattr(self, "format_time_value") else str(current_time)
1093
+ ax.set_title(f"{title}\n{time_str}", fontsize=10)
1094
+ except Exception as e:
1095
+ ax.set_title(f"{title}\n(time step {self.band_index + 1})", fontsize=10)
1096
+ else:
1097
+ ax.set_title(title, fontsize=10)
1098
+
1099
+ # Add colorbar
1100
+ plt.colorbar(img, ax=ax, shrink=0.6)
1101
+ plt.tight_layout()
1102
+
1103
+
1104
+ # Convert matplotlib figure to image
1105
+ canvas = FigureCanvasAgg(fig)
1106
+ canvas.draw()
1107
+ width, height = fig.canvas.get_width_height()
1108
+ rgba = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(height, width, 4)
1109
+
1110
+ # Extract RGB and ensure it's C-contiguous for QImage
1111
+ rgb = np.ascontiguousarray(rgba[:, :, :3])
1112
+
1113
+ # Close figure to prevent memory leak
1114
+ plt.close(fig)
1115
+
1116
+ return rgb
1117
+
541
1118
  def update_pixmap(self):
542
1119
  # --- Select display data ---
543
1120
  if hasattr(self, "band_index"):
@@ -558,13 +1135,23 @@ class TiffViewer(QMainWindow):
558
1135
  # ----------------------------
559
1136
 
560
1137
  # --- Render image ---
561
- if rgb is None:
562
- # Grayscale rendering for single-band (scientific) data
1138
+ # Check if we should use cartopy for NetCDF visualization
1139
+ use_cartopy = False
1140
+ if hasattr(self, '_use_cartopy') and self._use_cartopy and HAVE_CARTOPY:
1141
+ if hasattr(self, '_has_geo_coords') and self._has_geo_coords:
1142
+ use_cartopy = True
1143
+
1144
+ if use_cartopy:
1145
+ # Render with cartopy for better geographic visualization
1146
+ rgb = self._render_cartopy_map(a)
1147
+ elif rgb is None:
1148
+ # Standard grayscale rendering for single-band (scientific) data
563
1149
  finite = np.isfinite(a)
564
- rng = max(np.nanmax(a) - np.nanmin(a), 1e-12)
1150
+ vmin, vmax = np.nanmin(a), np.nanmax(a)
1151
+ rng = max(vmax - vmin, 1e-12)
565
1152
  norm = np.zeros_like(a, dtype=np.float32)
566
1153
  if np.any(finite):
567
- norm[finite] = (a[finite] - np.nanmin(a)) / rng
1154
+ norm[finite] = (a[finite] - vmin) / rng
568
1155
  norm = np.clip(norm, 0, 1)
569
1156
  norm = np.power(norm * self.contrast, self.gamma)
570
1157
  cmap = getattr(cm, self.cmap_name, cm.viridis)
@@ -592,10 +1179,11 @@ class TiffViewer(QMainWindow):
592
1179
 
593
1180
  tif_path = self.tif_path
594
1181
 
595
- if os.path.dirname(self.tif_path).endswith(".gdb"):
1182
+ if tif_path and os.path.dirname(self.tif_path).endswith(".gdb"):
596
1183
  tif_path = f"OpenFileGDB:{os.path.dirname(self.tif_path)}:{os.path.basename(self.tif_path)}"
597
1184
 
598
- with rasterio.open(tif_path) as src:
1185
+ import rasterio as rio_module
1186
+ with rio_module.open(tif_path) as src:
599
1187
  self.band = band_num
600
1188
  arr = src.read(self.band).astype(np.float32)
601
1189
  nd = src.nodata
@@ -638,13 +1226,21 @@ class TiffViewer(QMainWindow):
638
1226
 
639
1227
  # Colormap toggle (single-band only)
640
1228
  elif not self.rgb_mode and k == Qt.Key.Key_M:
641
- self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
1229
+ # For NetCDF files, cycle through three colormaps
1230
+ if hasattr(self, 'cmap_names'):
1231
+ self.cmap_index = (self.cmap_index + 1) % len(self.cmap_names)
1232
+ self.cmap_name = self.cmap_names[self.cmap_index]
1233
+ print(f"Colormap: {self.cmap_name}")
1234
+ # For other files, toggle between two colormaps
1235
+ else:
1236
+ self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
642
1237
  self.update_pixmap()
643
1238
 
644
1239
  # Band switch
645
1240
  elif k == Qt.Key.Key_BracketRight:
646
1241
  if hasattr(self, "band_index"): # HDF/NetCDF mode
647
1242
  self.band_index = (self.band_index + 1) % self.band_count
1243
+ self.data = self.get_current_frame()
648
1244
  self.update_pixmap()
649
1245
  self.update_title()
650
1246
  elif not self.rgb_mode: # GeoTIFF single-band mode
@@ -654,11 +1250,29 @@ class TiffViewer(QMainWindow):
654
1250
  elif k == Qt.Key.Key_BracketLeft:
655
1251
  if hasattr(self, "band_index"): # HDF/NetCDF mode
656
1252
  self.band_index = (self.band_index - 1) % self.band_count
1253
+ self.data = self.get_current_frame()
657
1254
  self.update_pixmap()
658
1255
  self.update_title()
659
1256
  elif not self.rgb_mode: # GeoTIFF single-band mode
660
1257
  new_band = self.band - 1 if self.band > 1 else self.band_count
661
1258
  self.load_band(new_band)
1259
+
1260
+ # NetCDF time/dimension navigation with Page Up/Down
1261
+ elif k == Qt.Key.Key_PageUp:
1262
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
1263
+ try:
1264
+ # Call the next_time_step method
1265
+ self.next_time_step()
1266
+ except Exception as e:
1267
+ print(f"Error handling PageUp: {e}")
1268
+
1269
+ elif k == Qt.Key.Key_PageDown:
1270
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
1271
+ try:
1272
+ # Call the prev_time_step method
1273
+ self.prev_time_step()
1274
+ except Exception as e:
1275
+ print(f"Error handling PageDown: {e}")
662
1276
 
663
1277
  elif k == Qt.Key.Key_R:
664
1278
  self.contrast = 1.0
@@ -670,36 +1284,7 @@ class TiffViewer(QMainWindow):
670
1284
  super().keyPressEvent(ev)
671
1285
 
672
1286
 
673
- # --------------------------------- Legacy argparse CLI (not used by default) ----------------------------------- #
674
- def legacy_argparse_main():
675
- parser = argparse.ArgumentParser(description="TIFF viewer with RGB (2–98%) & shapefile overlays")
676
- parser.add_argument("tif_path", nargs="?", help="Path to TIFF (optional if --rgbfiles is used)")
677
- parser.add_argument("--scale", type=int, default=1, help="Downsample factor (1=full, 10=10x smaller)")
678
- parser.add_argument("--band", type=int, default=1, help="Band number (ignored if --rgb/--rgbfiles used)")
679
- parser.add_argument("--rgb", nargs=3, type=int, help="Three band numbers for RGB, e.g. --rgb 4 3 2")
680
- parser.add_argument("--rgbfiles", nargs=3, help="Three single-band TIFFs for RGB, e.g. --rgbfiles B4.tif B3.tif B2.tif")
681
- parser.add_argument("--shapefile", nargs="+", help="One or more shapefiles to overlay")
682
- parser.add_argument("--shp-color", default="cyan", help="Overlay color (name or #RRGGBB). Default: cyan")
683
- parser.add_argument("--shp-width", type=float, default=1.5, help="Overlay line width (screen pixels). Default: 1.5")
684
- args = parser.parse_args()
685
-
686
- from PySide6.QtCore import Qt
687
- QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
688
- app = QApplication(sys.argv)
689
- win = TiffViewer(
690
- args.tif_path,
691
- scale=args.scale,
692
- band=args.band,
693
- rgb=args.rgb,
694
- rgbfiles=args.rgbfiles,
695
- shapefiles=args.shapefile,
696
- shp_color=args.shp_color,
697
- shp_width=args.shp_width,
698
- )
699
- win.show()
700
- sys.exit(app.exec())
701
-
702
-
1287
+ # --------------------------------- CLI ----------------------------------- #
703
1288
  def run_viewer(
704
1289
  tif_path,
705
1290
  scale=None,
@@ -714,7 +1299,7 @@ def run_viewer(
714
1299
 
715
1300
  """Launch the TiffViewer app"""
716
1301
  from PySide6.QtCore import Qt
717
- QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
1302
+ # QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
718
1303
  app = QApplication(sys.argv)
719
1304
  win = TiffViewer(
720
1305
  tif_path,
@@ -733,7 +1318,7 @@ def run_viewer(
733
1318
  import click
734
1319
 
735
1320
  @click.command()
736
- @click.version_option("0.2.1", prog_name="viewtif")
1321
+ @click.version_option("0.2.2", prog_name="viewtif")
737
1322
  @click.argument("tif_path", required=False)
738
1323
  @click.option("--band", default=1, show_default=True, type=int, help="Band number to display")
739
1324
  @click.option("--scale", default=1.0, show_default=True, type=int, help="Scale factor for display")
@@ -742,10 +1327,9 @@ import click
742
1327
  @click.option("--shapefile", multiple=True, type=str, help="One or more shapefiles to overlay")
743
1328
  @click.option("--shp-color", default="white", show_default=True, help="Overlay color (name or #RRGGBB).")
744
1329
  @click.option("--shp-width", default=1.0, show_default=True, type=float, help="Overlay line width (screen pixels).")
745
- @click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file")
746
-
1330
+ @click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file or variable in NetCDF file")
747
1331
  def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width, subset):
748
- """Lightweight GeoTIFF viewer."""
1332
+ """Lightweight GeoTIFF, NetCDF, and HDF viewer."""
749
1333
  # --- Warn early if shapefile requested but geopandas missing ---
750
1334
  if shapefile and not HAVE_GEO:
751
1335
  print(
@@ -767,5 +1351,4 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
767
1351
  )
768
1352
 
769
1353
  if __name__ == "__main__":
770
- main()
771
-
1354
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: viewtif
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Lightweight GeoTIFF, NetCDF, HDF/HDF5, and Esri File Geodatabase (.gdb) viewer with optional shapefile overlay. NetCDF and cartopy support available via pip install viewtif[netcdf].
5
5
  Project-URL: Homepage, https://github.com/nkeikon/tifviewer
6
6
  Project-URL: Source, https://github.com/nkeikon/tifviewer
@@ -16,12 +16,16 @@ Requires-Dist: rasterio>=1.3
16
16
  Provides-Extra: geo
17
17
  Requires-Dist: geopandas>=0.13; extra == 'geo'
18
18
  Requires-Dist: shapely>=2.0; extra == 'geo'
19
+ Provides-Extra: netcdf
20
+ Requires-Dist: cartopy>=0.22; extra == 'netcdf'
21
+ Requires-Dist: netcdf4>=1.6; extra == 'netcdf'
22
+ Requires-Dist: pandas>=2.0; extra == 'netcdf'
23
+ Requires-Dist: xarray>=2023.1; extra == 'netcdf'
19
24
  Description-Content-Type: text/markdown
20
25
 
21
26
  # viewtif
22
27
  [![Downloads](https://static.pepy.tech/badge/viewtif)](https://pepy.tech/project/viewtif)
23
28
  [![PyPI version](https://img.shields.io/pypi/v/viewtif)](https://pypi.org/project/viewtif/)
24
- [![Python versions](https://img.shields.io/pypi/pyversions/viewtif)](https://pypi.org/project/viewtif/)
25
29
 
26
30
  A lightweight GeoTIFF viewer for quick visualization directly from the command line.
27
31
 
@@ -53,6 +57,11 @@ pip install GDAL
53
57
  ```
54
58
  > **Note:** GDAL is required to open `.hdf`, .`h5`, and `.hdf5` files. If it’s missing, viewtif will display: `RuntimeError: HDF support requires GDAL.`
55
59
 
60
+ #### NetCDF support
61
+ ```bash
62
+ brew install "viewtif[netcdf]"
63
+ ```
64
+ > **Note:** For enhanced geographic visualization with map projections, coastlines, and borders, install with cartopy: `pip install "viewtif[netcdf]"` (cartopy is included in the netcdf extra). If cartopy is not available, netCDF files will still display with standard RGB rendering.
56
65
  ## Quick Start
57
66
  ```bash
58
67
  # View a GeoTIFF
@@ -84,14 +93,11 @@ viewtif AG100.v003.33.-107.0001.h5 --subset 1 --band 3
84
93
  `[WARN] raster lacks CRS/transform; cannot place overlays.`
85
94
 
86
95
  ### Update in v1.0.7: File Geodatabase (.gdb) support
87
- `viewtif` can now open raster datasets stored inside Esri File Geodatabases (`.gdb`).
88
- When you open a .gdb directly, `viewtif` will list available raster datasets first, then you can choose one to view.
96
+ `viewtif` can now open raster datasets stored inside Esri File Geodatabases (`.gdb`). When you open a .gdb directly, `viewtif`` will list available raster datasets first, then you can choose one to view.
89
97
 
90
98
  Most Rasterio installations already include the OpenFileGDB driver, so .gdb datasets often open without installing GDAL manually.
91
99
 
92
- If you encounter:
93
- RuntimeError: GDB support requires GDAL,
94
- install GDAL as shown above to enable the driver.
100
+ If you encounter: RuntimeError: GDB support requires GDAL, install GDAL as shown above to enable the driver.
95
101
 
96
102
  ```bash
97
103
  # List available raster datasets
@@ -107,6 +113,19 @@ As of v1.0.7, `viewtif` automatically checks the raster size before loading.
107
113
  If the dataset is very large (e.g., >20 million pixels), it will pause and warn that loading may freeze your system.
108
114
  You can proceed manually or rerun with the `--scale` option for a smaller, faster preview.
109
115
 
116
+ ### Update in v0.2.2: NetCDF support with optional cartopy visualization
117
+ `viewtif` now supports NetCDF (`.nc`) files with xarray and optional cartopy geographic visualization.
118
+
119
+ #### Installation with NetCDF support
120
+ ```bash
121
+ pip install "viewtif[netcdf]"
122
+ ```
123
+
124
+ #### Examples
125
+ ```bash
126
+ viewtif data.nc
127
+ ```
128
+
110
129
  ## Controls
111
130
  | Key | Action |
112
131
  | -------------------- | --------------------------------------- |
@@ -142,4 +161,4 @@ This project is released under the MIT License.
142
161
 
143
162
  ## Contributors
144
163
  - [@HarshShinde0](https://github.com/HarshShinde0) — added mouse-wheel and trackpad zoom support; added NetCDF support with [@nkeikon](https://github.com/nkeikon)
145
- - [@p-vdp](https://github.com/p-vdp) — added File Geodatabase (.gdb) raster support
164
+ - [@p-vdp](https://github.com/p-vdp) — added File Geodatabase (.gdb) raster support
@@ -0,0 +1,5 @@
1
+ viewtif/tif_viewer.py,sha256=egk8LkdTtbV77no4IUd_bHwpU7b0oCNE9z6qxpkOKa0,57343
2
+ viewtif-0.2.2.dist-info/METADATA,sha256=_7BJ66mI4kZzwSwTSG6fwWr5fe-USNzbGESpZvcNW7M,7280
3
+ viewtif-0.2.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
+ viewtif-0.2.2.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
5
+ viewtif-0.2.2.dist-info/RECORD,,
@@ -1,5 +0,0 @@
1
- viewtif/tif_viewer.py,sha256=cHu62pMpvYFZD4MNhpsOme6AYD2eNAsd13FGmaxVSd8,31075
2
- viewtif-0.2.1.dist-info/METADATA,sha256=EveWboZK5lTOaAXyzFCs6O5zaebSkdKCb9Al1Av_br4,6522
3
- viewtif-0.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
- viewtif-0.2.1.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
5
- viewtif-0.2.1.dist-info/RECORD,,