viewtif 0.1.10__py3-none-any.whl → 0.2.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.
viewtif/tif_viewer.py CHANGED
@@ -34,10 +34,12 @@ import numpy as np
34
34
  import rasterio
35
35
  from rasterio.transform import Affine
36
36
  from PySide6.QtWidgets import (
37
- QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QScrollBar, QGraphicsPathItem
37
+ QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
38
+ QScrollBar, QGraphicsPathItem, QVBoxLayout, QHBoxLayout, QSlider, QLabel,
39
+ QWidget, QStatusBar, QPushButton, QComboBox
38
40
  )
39
41
  from PySide6.QtGui import QImage, QPixmap, QPainter, QPen, QColor, QPainterPath
40
- from PySide6.QtCore import Qt
42
+ from PySide6.QtCore import Qt, QDateTime
41
43
 
42
44
  import matplotlib.cm as cm
43
45
 
@@ -52,53 +54,74 @@ try:
52
54
  except Exception:
53
55
  HAVE_GEO = False
54
56
 
57
+ # Optional NetCDF deps (lazy-loaded when needed)
58
+ HAVE_NETCDF = False
59
+ xr = None
60
+ pd = None
61
+
62
+ # Optional cartopy deps for better map visualization (lazy-loaded when needed)
63
+ # Check if cartopy is available but don't import yet
64
+ try:
65
+ import importlib.util
66
+ HAVE_CARTOPY = importlib.util.find_spec("cartopy") is not None
67
+ except Exception:
68
+ HAVE_CARTOPY = False
69
+
55
70
  def warn_if_large(tif_path, scale=1):
56
71
  """Warn and confirm before loading very large rasters (GeoTIFF, GDB, or HDF).
57
- Works even if GDAL is not installed.
72
+
73
+ Uses GDAL if available, falls back to rasterio for standard formats.
58
74
  """
59
75
  import os
60
- width = height = None
61
- size_mb = None
62
76
 
63
- # Try GDAL if available
77
+ if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
78
+ tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
79
+
80
+
64
81
  try:
65
- from osgeo import gdal
66
- gdal.UseExceptions()
67
- info = gdal.Info(tif_path, format="json")
68
- width, height = info.get("size", [0, 0])
69
- except ImportError:
70
- # Fallback if GDAL not installed
82
+ width, height = None, None
83
+
84
+ # Try GDAL first (supports more formats including GDB, HDF)
71
85
  try:
72
- import rasterio
73
- with rasterio.open(tif_path) as src:
74
- width, height = src.width, src.height
75
- except Exception:
76
- print("[WARN] Could not estimate raster size (no GDAL/rasterio). Skipping size check.")
77
- return
86
+ from osgeo import gdal
87
+ gdal.UseExceptions()
88
+ info = gdal.Info(tif_path, format="json")
89
+ width, height = info.get("size", [0, 0])
90
+ except ImportError:
91
+ # GDAL not available, try rasterio for standard formats
92
+ try:
93
+ with rasterio.open(tif_path) as src:
94
+ width = src.width
95
+ height = src.height
96
+ except Exception:
97
+ # If rasterio also fails, skip the check
98
+ print(f"[INFO] Could not determine raster dimensions for size check.")
99
+ return
100
+
101
+ if width and height:
102
+ total_pixels = (width * height) / (scale ** 2) # account for downsampling
103
+ size_mb = None
104
+ if os.path.exists(tif_path):
105
+ size_mb = os.path.getsize(tif_path) / (1024 ** 2)
106
+
107
+ # Only warn if the *effective* pixels remain large
108
+ if total_pixels > 20_000_000 and scale <= 5:
109
+ print(
110
+ f"[WARN] Large raster detected ({width}×{height}, ~{total_pixels/1e6:.1f}M effective pixels"
111
+ + (f", ~{size_mb:.1f} MB" if size_mb else "")
112
+ + "). Loading may freeze. Consider rerunning with --scale (e.g. --scale 10)."
113
+ )
114
+ ans = input("Proceed anyway? [y/N]: ").strip().lower()
115
+ if ans not in ("y", "yes"):
116
+ print("Cancelled.")
117
+ sys.exit(0)
78
118
  except Exception as e:
79
- print(f"[WARN] Could not pre-check raster size with GDAL: {e}")
80
- return
81
-
82
- # File size
83
- if os.path.exists(tif_path):
84
- size_mb = os.path.getsize(tif_path) / (1024 ** 2)
85
-
86
- total_pixels = (width * height) / (scale ** 2)
87
- if total_pixels > 20_000_000 and scale <= 5:
88
- msg = (
89
- f"[WARN] Large raster detected ({width}×{height}, ~{total_pixels/1e6:.1f}M pixels"
90
- + (f", ~{size_mb:.1f} MB" if size_mb else "")
91
- + "). Loading may freeze. Consider --scale (e.g. --scale 10)."
92
- )
93
- print(msg)
94
- ans = input("Proceed anyway? [y/N]: ").strip().lower()
95
- if ans not in ("y", "yes"):
96
- print("Cancelled.")
97
- sys.exit(0)
119
+ print(f"[INFO] Could not pre-check raster size: {e}")
98
120
 
99
121
  # -------------------------- QGraphicsView tweaks -------------------------- #
100
122
  class RasterView(QGraphicsView):
101
123
  def __init__(self, *args, **kwargs):
124
+ import numpy as np
102
125
  super().__init__(*args, **kwargs)
103
126
  self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
104
127
  self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
@@ -167,7 +190,8 @@ class TiffViewer(QMainWindow):
167
190
  # --- Load data ---
168
191
  if rgbfiles:
169
192
  red, green, blue = rgbfiles
170
- with rasterio.open(red) as r, rasterio.open(green) as g, rasterio.open(blue) as b:
193
+ import rasterio as rio_module
194
+ with rio_module.open(red) as r, rio_module.open(green) as g, rio_module.open(blue) as b:
171
195
  if (r.width, r.height) != (g.width, g.height) or (r.width, r.height) != (b.width, b.height):
172
196
  raise ValueError("All RGB files must have the same dimensions.")
173
197
  arr = np.stack([
@@ -185,8 +209,149 @@ class TiffViewer(QMainWindow):
185
209
  self.tif_path = self.tif_path or (os.path.commonprefix([red, green, blue]) or red)
186
210
 
187
211
  elif tif_path:
212
+ # --------------------- Detect NetCDF --------------------- #
213
+ if tif_path and tif_path.lower().endswith((".nc", ".netcdf")):
214
+ try:
215
+ # Lazy-load NetCDF dependencies
216
+ import xarray as xr
217
+ import pandas as pd
218
+
219
+ # Open the NetCDF file
220
+ ds = xr.open_dataset(tif_path)
221
+
222
+ # List variables, filtering out boundary variables (ending with _bnds)
223
+ all_vars = list(ds.data_vars)
224
+ data_vars = [var for var in all_vars if not var.endswith('_bnds')]
225
+
226
+ # Auto-select the first variable if there's only one and no subset specified
227
+ if len(data_vars) == 1 and subset is None:
228
+ subset = 0
229
+ # Only list variables if --subset not given and multiple variables exist
230
+ elif subset is None:
231
+ sys.exit(0)
232
+
233
+ # Validate subset index
234
+ if subset < 0 or subset >= len(data_vars):
235
+ raise ValueError(f"Invalid variable index {subset}. Valid range: 0–{len(data_vars)-1}")
236
+
237
+ # Get the selected variable from filtered data_vars
238
+ var_name = data_vars[subset]
239
+ var_data = ds[var_name]
240
+
241
+ # Store original dataset and variable information for better visualization
242
+ self._nc_dataset = ds
243
+ self._nc_var_name = var_name
244
+ self._nc_var_data = var_data
245
+
246
+ # Get coordinate info if available
247
+ self._has_geo_coords = False
248
+ if 'lon' in ds.coords and 'lat' in ds.coords:
249
+ self._has_geo_coords = True
250
+ self._lon_data = ds.lon.values
251
+ self._lat_data = ds.lat.values
252
+ elif 'longitude' in ds.coords and 'latitude' in ds.coords:
253
+ self._has_geo_coords = True
254
+ self._lon_data = ds.longitude.values
255
+ self._lat_data = ds.latitude.values
256
+
257
+ # Handle time or other index dimension if present
258
+ self._has_time_dim = False
259
+ self._time_dim_name = None
260
+ time_index = 0
261
+
262
+ # Look for a time dimension first
263
+ if 'time' in var_data.dims:
264
+ self._has_time_dim = True
265
+ self._time_dim_name = 'time'
266
+ self._time_values = ds.time.values
267
+ self._time_index = time_index
268
+
269
+ # Try to format time values for better display
270
+ time_units = getattr(ds.time, 'units', None)
271
+ time_calendar = getattr(ds.time, 'calendar', 'standard')
272
+
273
+ # Select first time step by default
274
+ var_data = var_data.isel(time=time_index)
275
+
276
+ # If no time dimension but variable has multiple dimensions,
277
+ # use the first non-spatial dimension as a "time" dimension
278
+ elif len(var_data.dims) > 2:
279
+ # Try to find a dimension that's not lat/lon
280
+ spatial_dims = ['lat', 'lon', 'latitude', 'longitude', 'y', 'x']
281
+ for dim in var_data.dims:
282
+ if dim not in spatial_dims:
283
+ self._has_time_dim = True
284
+ self._time_dim_name = dim
285
+ self._time_values = ds[dim].values
286
+ self._time_index = time_index
287
+
288
+ # Select first index by default
289
+ var_data = var_data.isel({dim: time_index})
290
+ break
291
+
292
+ # Convert to numpy array
293
+ arr = var_data.values.astype(np.float32)
294
+
295
+ # Process array based on dimensions
296
+ if arr.ndim > 2:
297
+ # Keep only lat/lon dimensions for 3D+ arrays
298
+ arr = np.squeeze(arr)
299
+
300
+ # --- Downsample large arrays for responsiveness ---
301
+ if arr.ndim >= 2:
302
+ h, w = arr.shape[:2]
303
+ if h * w > 4_000_000:
304
+ step = max(2, int((h * w / 4_000_000) ** 0.5))
305
+ if arr.ndim == 2:
306
+ arr = arr[::step, ::step]
307
+ else:
308
+ arr = arr[::step, ::step, :]
309
+
310
+ # --- Final assignments ---
311
+ self.data = arr
312
+
313
+ # Try to extract CRS from CF conventions
314
+ self._transform = None
315
+ self._crs = None
316
+ if 'crs' in ds.variables:
317
+ try:
318
+ import rasterio.crs
319
+ crs_var = ds.variables['crs']
320
+ if hasattr(crs_var, 'spatial_ref'):
321
+ self._crs = rasterio.crs.CRS.from_wkt(crs_var.spatial_ref)
322
+ except Exception as e:
323
+ print(f"Could not parse CRS: {e}")
324
+
325
+ # Set band info
326
+ if arr.ndim == 3:
327
+ self.band_count = arr.shape[2]
328
+ else:
329
+ self.band_count = 1
330
+
331
+ self.band_index = 0
332
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
333
+
334
+ # --- If user specified --band, start there ---
335
+ if self.band and self.band <= self.band_count:
336
+ self.band_index = self.band - 1
337
+
338
+ # Enable cartopy visualization if available
339
+ self._use_cartopy = HAVE_CARTOPY and self._has_geo_coords
340
+
341
+ except ImportError as e:
342
+ if "xarray" in str(e) or "netCDF4" in str(e):
343
+ raise RuntimeError(
344
+ f"NetCDF support requires additional dependencies.\n"
345
+ f"Install them with: pip install viewtif[netcdf]\n"
346
+ f"Original error: {str(e)}"
347
+ )
348
+ else:
349
+ raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
350
+ except Exception as e:
351
+ raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
352
+
188
353
  # ---------------- Handle File Geodatabase (.gdb) ---------------- #
189
- if tif_path.lower().endswith(".gdb") and ":" not in tif_path:
354
+ if tif_path and tif_path.lower().endswith(".gdb") and ":" not in tif_path:
190
355
  import re, subprocess
191
356
  gdb_path = tif_path # use full path to .gdb
192
357
  try:
@@ -205,103 +370,147 @@ class TiffViewer(QMainWindow):
205
370
  except subprocess.CalledProcessError as e:
206
371
  print(f"[WARN] Could not inspect FileGDB: {e}")
207
372
  sys.exit(0)
208
-
209
- # --- Universal size check before loading ---
373
+ # --- Universal size check before loading ---
210
374
  warn_if_large(tif_path, scale=self._scale_arg)
211
-
375
+
376
+ if False: # Placeholder for previous if condition
377
+ pass
212
378
  # --------------------- Detect HDF/HDF5 --------------------- #
213
- if tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
379
+ elif tif_path and tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
214
380
  try:
215
- # Try reading directly with Rasterio first (works for simple HDF layouts)
216
- with rasterio.open(tif_path) as src:
217
- print(f"Opened HDF with rasterio: {os.path.basename(tif_path)}")
218
- arr = src.read().astype(np.float32)
219
- arr = np.squeeze(arr)
220
- if arr.ndim == 3:
221
- arr = np.transpose(arr, (1, 2, 0))
222
- elif arr.ndim == 2:
223
- print("Single-band dataset.")
224
- self.data = arr
225
- self._transform = src.transform
226
- self._crs = src.crs
227
- self.band_count = arr.shape[2] if arr.ndim == 3 else 1
228
- self.band_index = 0
229
- self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
230
- return # ✅ Skip GDAL path if Rasterio succeeded
381
+ # Try GDAL first (best support for HDF subdatasets)
382
+ from osgeo import gdal
383
+ gdal.UseExceptions()
231
384
 
232
- except Exception as e:
233
- print(f"Rasterio could not open HDF directly: {e}")
234
- print("Falling back to GDAL...")
235
-
236
- try:
237
- from osgeo import gdal
238
- gdal.UseExceptions()
385
+ ds = gdal.Open(tif_path)
386
+ subs = ds.GetSubDatasets()
239
387
 
240
- ds = gdal.Open(tif_path)
241
- subs = ds.GetSubDatasets()
242
- if not subs:
243
- raise ValueError("No subdatasets found in HDF/HDF5 file.")
388
+ if not subs:
389
+ raise ValueError("No subdatasets found in HDF/HDF5 file.")
244
390
 
245
- print(f"Found {len(subs)} subdatasets in {os.path.basename(tif_path)}:")
246
- for i, (_, desc) in enumerate(subs):
247
- print(f"[{i}] {desc}")
391
+ print(f"Found {len(subs)} subdatasets in {os.path.basename(tif_path)}:")
392
+ for i, (_, desc) in enumerate(subs):
393
+ print(f"[{i}] {desc}")
248
394
 
249
- if subset is None:
250
- print("\nUse --subset N to open a specific subdataset.")
251
- sys.exit(0)
252
-
253
- if subset < 0 or subset >= len(subs):
254
- raise ValueError(f"Invalid subset index {subset}. Valid range: 0–{len(subs)-1}")
255
-
256
- sub_name, desc = subs[subset]
257
- print(f"\nOpening subdataset [{subset}]: {desc}")
258
- sub_ds = gdal.Open(sub_name)
395
+ # Only list subsets if --subset not given
396
+ if subset is None:
397
+ print("\nUse --subset N to open a specific subdataset.")
398
+ sys.exit(0)
259
399
 
260
- arr = sub_ds.ReadAsArray().astype(np.float32)
261
- arr = np.squeeze(arr)
262
- if arr.ndim == 3:
263
- arr = np.transpose(arr, (1, 2, 0))
264
- elif arr.ndim == 2:
265
- print("Single-band dataset.")
266
- else:
267
- raise ValueError(f"Unexpected array shape {arr.shape}")
268
-
269
- # Downsample large arrays for responsiveness
270
- h, w = arr.shape[:2]
271
- if h * w > 4_000_000:
272
- step = max(2, int((h * w / 4_000_000) ** 0.5))
273
- arr = arr[::step, ::step] if arr.ndim == 2 else arr[::step, ::step, :]
274
- print(f"⚠️ Large dataset preview: downsampled by {step}x")
400
+ # Validate subset index
401
+ if subset < 0 or subset >= len(subs):
402
+ raise ValueError(f"Invalid subset index {subset}. Valid range: 0–{len(subs)-1}")
403
+
404
+ sub_name, desc = subs[subset]
405
+ print(f"\nOpening subdataset [{subset}]: {desc}")
406
+ sub_ds = gdal.Open(sub_name)
407
+
408
+ # --- Read once ---
409
+ arr = sub_ds.ReadAsArray().astype(np.float32)
410
+ #print(f"Raw array shape from GDAL: {arr.shape} (ndim={arr.ndim})")
411
+
412
+ # --- Normalize shape ---
413
+ arr = np.squeeze(arr)
414
+ if arr.ndim == 3:
415
+ # Convert from (bands, rows, cols) → (rows, cols, bands)
416
+ arr = np.transpose(arr, (1, 2, 0))
417
+ #print(f"Transposed to {arr.shape} (rows, cols, bands)")
418
+ elif arr.ndim == 2:
419
+ print("Single-band dataset.")
420
+ else:
421
+ raise ValueError(f"Unexpected array shape {arr.shape}")
422
+
423
+ # --- Downsample large arrays for responsiveness ---
424
+ h, w = arr.shape[:2]
425
+ if h * w > 4_000_000:
426
+ step = max(2, int((h * w / 4_000_000) ** 0.5))
427
+ arr = arr[::step, ::step] if arr.ndim == 2 else arr[::step, ::step, :]
428
+
429
+ # --- Final assignments ---
430
+ self.data = arr
431
+ self._transform = None
432
+ self._crs = None
433
+ self.band_count = arr.shape[2] if arr.ndim == 3 else 1
434
+ self.band_index = 0
435
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
436
+
437
+ if self.band_count > 1:
438
+ print(f"This subdataset has {self.band_count} bands — switch with [ and ] keys.")
439
+ else:
440
+ print("This subdataset has 1 band.")
275
441
 
276
- # Assign
277
- self.data = arr
278
- self._transform = None
279
- self._crs = None
280
- self.band_count = arr.shape[2] if arr.ndim == 3 else 1
442
+ # --- If user specified --band, start there ---
443
+ if self.band and self.band <= self.band_count:
444
+ self.band_index = self.band - 1
445
+ print(f"Opening band {self.band}/{self.band_count}")
446
+ else:
281
447
  self.band_index = 0
282
- self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
283
-
284
- if self.band_count > 1:
285
- print(f"This subdataset has {self.band_count} bands — switch with [ and ] keys.")
286
- else:
287
- print("This subdataset has 1 band.")
288
-
289
- if self.band and self.band <= self.band_count:
290
- self.band_index = self.band - 1
291
- print(f"Opening band {self.band}/{self.band_count}")
292
448
 
293
- except ImportError:
449
+ except ImportError:
450
+ # GDAL not available, try rasterio as fallback for NetCDF
451
+ print("[INFO] GDAL not available, attempting to read HDF/NetCDF with rasterio...")
452
+ try:
453
+ import rasterio as rio
454
+ with rio.open(tif_path) as src:
455
+ print(f"[INFO] NetCDF file opened via rasterio")
456
+ print(f"[INFO] Data shape: {src.height} x {src.width} x {src.count} bands")
457
+
458
+ if src.count == 0:
459
+ raise ValueError("No bands found in NetCDF file.")
460
+
461
+ # Determine which band(s) to read
462
+ if self.band and self.band <= src.count:
463
+ band_indices = [self.band]
464
+ print(f"Opening band {self.band}/{src.count}")
465
+ elif rgb and all(b <= src.count for b in rgb):
466
+ band_indices = rgb
467
+ print(f"Opening bands {rgb} as RGB")
468
+ else:
469
+ band_indices = list(range(1, min(src.count + 1, 4))) # Read up to 3 bands
470
+ print(f"Opening bands {band_indices}")
471
+
472
+ # Read selected bands
473
+ bands = []
474
+ for b in band_indices:
475
+ band_data = src.read(b, out_shape=(src.height // self._scale_arg, src.width // self._scale_arg))
476
+ bands.append(band_data)
477
+
478
+ # Stack into array
479
+ arr = np.stack(bands, axis=-1).astype(np.float32) if len(bands) > 1 else bands[0].astype(np.float32)
480
+
481
+ # Handle no-data values
482
+ nd = src.nodata
483
+ if nd is not None:
484
+ if arr.ndim == 3:
485
+ arr[arr == nd] = np.nan
486
+ else:
487
+ arr[arr == nd] = np.nan
488
+
489
+ # Final assignments
490
+ self.data = arr
491
+ self._transform = src.transform
492
+ self._crs = src.crs
493
+ self.band_count = arr.shape[2] if arr.ndim == 3 else 1
494
+ self.band_index = 0
495
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
496
+
497
+ if self.band_count > 1:
498
+ print(f"Loaded {self.band_count} bands — switch with [ and ] keys.")
499
+ else:
500
+ print("Loaded 1 band.")
501
+ except Exception as e:
294
502
  raise RuntimeError(
295
- "HDF/HDF5 support requires GDAL (Python bindings).\n"
296
- "Install it first (e.g., brew install gdal && pip install GDAL)"
503
+ f"Failed to read HDF/NetCDF file: {e}\n"
504
+ "For full HDF support, install GDAL: pip install GDAL"
297
505
  )
298
506
 
299
507
  # --------------------- Regular GeoTIFF --------------------- #
300
508
  else:
301
- if os.path.dirname(tif_path).endswith(".gdb"):
509
+ if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
302
510
  tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
303
511
 
304
- with rasterio.open(tif_path) as src:
512
+ import rasterio as rio_module
513
+ with rio_module.open(tif_path) as src:
305
514
  self._transform = src.transform
306
515
  self._crs = src.crs
307
516
  if rgb is not None:
@@ -345,16 +554,92 @@ class TiffViewer(QMainWindow):
345
554
  self.gamma = 1.0
346
555
 
347
556
  # Colormap (single-band)
348
- self.cmap_name = "viridis"
349
- self.alt_cmap_name = "magma" # toggle with M in single-band
557
+ # For NetCDF temperature data, have three colormaps in rotation
558
+ if tif_path and tif_path.lower().endswith(('.nc', '.netcdf')):
559
+ self.cmap_names = ["RdBu_r", "viridis", "magma"] # three colormaps for NetCDF
560
+ self.cmap_index = 0 # start with RdBu_r
561
+ self.cmap_name = self.cmap_names[self.cmap_index]
562
+ else:
563
+ self.cmap_name = "viridis"
564
+ self.alt_cmap_name = "magma" # toggle with M in single-band
350
565
 
351
566
  self.zoom_step = 1.2
352
567
  self.pan_step = 80
353
568
 
569
+ # Create main widget and layout
570
+ self.main_widget = QWidget()
571
+ self.main_layout = QVBoxLayout(self.main_widget)
572
+ self.main_layout.setContentsMargins(0, 0, 0, 0)
573
+ self.main_layout.setSpacing(0)
574
+
575
+ # Add time navigation controls at the top for NetCDF files
576
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
577
+ self.time_layout = QHBoxLayout()
578
+ self.time_layout.setContentsMargins(10, 5, 10, 5)
579
+
580
+ # Navigation buttons
581
+ self.prev_button = QPushButton("<<")
582
+ self.prev_button.setToolTip("Previous time step")
583
+ self.prev_button.clicked.connect(self.prev_time_step)
584
+ self.prev_button.setFixedWidth(40)
585
+
586
+ # Play/Pause button
587
+ self.play_button = QPushButton("▶") # Play symbol
588
+ self.play_button.setToolTip("Play/Pause time animation")
589
+ self.play_button.clicked.connect(self.toggle_play_pause)
590
+ self.play_button.setFixedWidth(40)
591
+ self._is_playing = False
592
+ self._play_timer = None
593
+
594
+ self.next_button = QPushButton(">>")
595
+ self.next_button.setToolTip("Next time step")
596
+ self.next_button.clicked.connect(self.next_time_step)
597
+ self.next_button.setFixedWidth(40)
598
+
599
+ # Date/time label
600
+ self.time_label = QLabel()
601
+ self.time_label.setMinimumWidth(200)
602
+
603
+ # Time slider
604
+ self.time_slider = QSlider(Qt.Orientation.Horizontal)
605
+ self.time_slider.setMinimum(0)
606
+ self.time_slider.setMaximum(len(self._time_values) - 1)
607
+ self.time_slider.setValue(self._time_index)
608
+ self.time_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
609
+ self.time_slider.setTickInterval(max(1, len(self._time_values) // 10))
610
+ self.time_slider.valueChanged.connect(self.time_slider_changed)
611
+
612
+ # Date time combo box
613
+ self.date_combo = QComboBox()
614
+ self.populate_date_combo()
615
+ self.date_combo.currentIndexChanged.connect(self.date_combo_changed)
616
+ self.date_combo.setFixedWidth(200)
617
+
618
+ # Add controls to layout
619
+ self.time_layout.addWidget(self.prev_button)
620
+ self.time_layout.addWidget(self.play_button)
621
+ self.time_layout.addWidget(self.time_label)
622
+ self.time_layout.addWidget(self.time_slider, 1) # 1 = stretch factor
623
+ self.time_layout.addWidget(self.next_button)
624
+ self.time_layout.addWidget(QLabel("Jump to:"))
625
+ self.time_layout.addWidget(self.date_combo)
626
+
627
+ # Add time controls to main layout at the top
628
+ self.main_layout.addLayout(self.time_layout)
629
+
630
+ # Update time label
631
+ self.update_time_label()
632
+
354
633
  # Scene + view
355
634
  self.scene = QGraphicsScene(self)
356
635
  self.view = RasterView(self.scene, self)
357
- self.setCentralWidget(self.view)
636
+ self.main_layout.addWidget(self.view)
637
+
638
+ # Status bar
639
+ self.setStatusBar(QStatusBar())
640
+
641
+ # Set central widget
642
+ self.setCentralWidget(self.main_widget)
358
643
 
359
644
  self.pixmap_item = None
360
645
  self._last_rgb = None
@@ -501,11 +786,237 @@ class TiffViewer(QMainWindow):
501
786
  elif self.rgb_mode and self.rgb:
502
787
  self.setWindowTitle(f"RGB {self.rgb} — {os.path.basename(self.tif_path)}")
503
788
  elif hasattr(self, "band_index"):
504
- self.setWindowTitle(
505
- f"Band {self.band_index + 1}/{self.band_count} — {os.path.basename(self.tif_path)}"
506
- )
789
+ title = f"Band {self.band_index + 1}/{self.band_count} — {os.path.basename(self.tif_path)}"
790
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
791
+ time_str = self.format_time_value(self._time_values[self._time_index])
792
+ if len(time_str) > 15: # Truncate if too long for title
793
+ time_str = time_str[:15]
794
+
795
+ # Use generic label for non-time dimensions
796
+ if self._time_dim_name and self._time_dim_name != 'time':
797
+ title += f" - {self._time_dim_name}: {time_str} ({self._time_index + 1}/{len(self._time_values)})"
798
+ else:
799
+ title += f" - {time_str}"
800
+ self.setWindowTitle(title)
507
801
  else:
508
802
  self.setWindowTitle(f"Band {self.band}/{self.band_count} — {os.path.basename(self.tif_path)}")
803
+
804
+ def format_time_value(self, time_value):
805
+ """Format a time value into a user-friendly string"""
806
+ # Default is the string representation
807
+ time_str = str(time_value)
808
+
809
+ try:
810
+ # Handle numpy datetime64
811
+ if hasattr(time_value, 'dtype') and np.issubdtype(time_value.dtype, np.datetime64):
812
+ # Lazy-load pandas for timestamp conversion
813
+ import pandas as pd
814
+ # Convert to Python datetime if possible
815
+ dt = pd.Timestamp(time_value).to_pydatetime()
816
+ time_str = dt.strftime('%Y-%m-%d %H:%M:%S')
817
+ # Handle native Python datetime
818
+ elif hasattr(time_value, 'strftime'):
819
+ time_str = time_value.strftime('%Y-%m-%d %H:%M:%S')
820
+ # Handle cftime datetime-like objects used in some NetCDF files
821
+ elif hasattr(time_value, 'isoformat'):
822
+ time_str = time_value.isoformat().replace('T', ' ')
823
+ except Exception:
824
+ # Fall back to string representation
825
+ pass
826
+
827
+ return time_str
828
+
829
+ def update_time_label(self):
830
+ """Update the time label with the current time value"""
831
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
832
+ try:
833
+ time_value = self._time_values[self._time_index]
834
+ time_str = self.format_time_value(time_value)
835
+
836
+ # Update time label if it exists
837
+ if hasattr(self, 'time_label'):
838
+ self.time_label.setText(f"Time: {time_str}")
839
+
840
+ # Create a progress bar style display of time position
841
+ total = len(self._time_values)
842
+ position = self._time_index + 1
843
+ bar_width = 20 # Width of the progress bar
844
+ filled = int(bar_width * position / total)
845
+ bar = "[" + "#" * filled + "-" * (bar_width - filled) + "]"
846
+
847
+ # Show time info in status bar
848
+ step_info = f"Time step: {position}/{total} {bar} {self.format_time_value(self._time_values[self._time_index])}"
849
+
850
+ # Update status bar if it exists
851
+ if hasattr(self, 'statusBar') and callable(self.statusBar):
852
+ self.statusBar().showMessage(step_info)
853
+ else:
854
+ print(step_info)
855
+ except Exception as e:
856
+ print(f"Error updating time label: {e}")
857
+
858
+ def time_slider_changed(self, value):
859
+ """Handle time slider value change"""
860
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
861
+ self._time_index = value
862
+
863
+ # Update data for new time step
864
+ if self._time_dim_name:
865
+ # Use the named dimension (time or other index)
866
+ var_data = self._nc_var_data.isel({self._time_dim_name: self._time_index})
867
+ else:
868
+ # Fallback to 'time' dimension
869
+ var_data = self._nc_var_data.isel(time=self._time_index)
870
+
871
+ self.data = var_data.values.astype(np.float32)
872
+
873
+ # Find and select the matching date in the combo box (if it exists)
874
+ if hasattr(self, 'date_combo'):
875
+ try:
876
+ for i in range(self.date_combo.count()):
877
+ if self.date_combo.itemData(i) == value:
878
+ self.date_combo.blockSignals(True) # Block signals to avoid recursion
879
+ self.date_combo.setCurrentIndex(i)
880
+ self.date_combo.blockSignals(False)
881
+ break
882
+ except Exception as e:
883
+ print(f"Error updating combo box: {e}")
884
+
885
+ # Update UI
886
+ self.update_time_label()
887
+ self.update_pixmap()
888
+ self.update_title()
889
+
890
+ def next_time_step(self):
891
+ """Go to the next time step"""
892
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
893
+ try:
894
+ next_time = (self._time_index + 1) % len(self._time_values)
895
+ if hasattr(self, 'time_slider'):
896
+ self.time_slider.setValue(next_time)
897
+ else:
898
+ # Direct update if slider doesn't exist
899
+ self._time_index = next_time
900
+ self.update_time_data()
901
+ except Exception as e:
902
+ print(f"Error going to next time step: {e}")
903
+
904
+ def prev_time_step(self):
905
+ """Go to the previous time step"""
906
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
907
+ try:
908
+ prev_time = (self._time_index - 1) % len(self._time_values)
909
+ if hasattr(self, 'time_slider'):
910
+ self.time_slider.setValue(prev_time)
911
+ else:
912
+ # Direct update if slider doesn't exist
913
+ self._time_index = prev_time
914
+ self.update_time_data()
915
+ except Exception as e:
916
+ print(f"Error going to previous time step: {e}")
917
+
918
+ def update_time_data(self):
919
+ """Update data for the current time index"""
920
+ try:
921
+ # Update data for current time step
922
+ if self._time_dim_name:
923
+ # Use the named dimension (time or other index)
924
+ var_data = self._nc_var_data.isel({self._time_dim_name: self._time_index})
925
+ else:
926
+ # Fallback to 'time' dimension
927
+ var_data = self._nc_var_data.isel(time=self._time_index)
928
+
929
+ self.data = var_data.values.astype(np.float32)
930
+
931
+ # Update UI
932
+ self.update_time_label()
933
+ self.update_pixmap()
934
+ self.update_title()
935
+ except Exception as e:
936
+ print(f"Error updating time data: {e}")
937
+
938
+ def toggle_play_pause(self):
939
+ """Toggle play/pause animation of time steps"""
940
+ if self._is_playing:
941
+ self.stop_animation()
942
+ else:
943
+ self.start_animation()
944
+
945
+ def start_animation(self):
946
+ """Start the time animation"""
947
+ from PySide6.QtCore import QTimer
948
+
949
+ if not hasattr(self, '_play_timer') or self._play_timer is None:
950
+ self._play_timer = QTimer(self)
951
+ self._play_timer.timeout.connect(self.animation_step)
952
+
953
+ # Set animation speed (milliseconds between frames)
954
+ animation_speed = 500 # 0.5 seconds between frames
955
+ self._play_timer.start(animation_speed)
956
+
957
+ self._is_playing = True
958
+ self.play_button.setText("⏸") # Pause symbol
959
+ self.play_button.setToolTip("Pause animation")
960
+
961
+ def stop_animation(self):
962
+ """Stop the time animation"""
963
+ if hasattr(self, '_play_timer') and self._play_timer is not None:
964
+ self._play_timer.stop()
965
+
966
+ self._is_playing = False
967
+ self.play_button.setText("▶") # Play symbol
968
+ self.play_button.setToolTip("Play animation")
969
+
970
+ def animation_step(self):
971
+ """Advance one frame in the animation"""
972
+ # Go to next time step
973
+ next_time = (self._time_index + 1) % len(self._time_values)
974
+ self.time_slider.setValue(next_time)
975
+
976
+ def closeEvent(self, event):
977
+ """Clean up resources when the window is closed"""
978
+ # Stop animation timer if it's running
979
+ if hasattr(self, '_is_playing') and self._is_playing:
980
+ self.stop_animation()
981
+
982
+ # Call the parent class closeEvent
983
+ super().closeEvent(event)
984
+
985
+ def populate_date_combo(self):
986
+ """Populate the date combo box with time values"""
987
+ if hasattr(self, '_has_time_dim') and self._has_time_dim and hasattr(self, 'date_combo'):
988
+ try:
989
+ self.date_combo.clear()
990
+
991
+ # Add a reasonable subset of dates if there are too many
992
+ max_items = 100 # Maximum number of items to show in dropdown
993
+
994
+ if len(self._time_values) <= max_items:
995
+ # Add all time values
996
+ for i, time_value in enumerate(self._time_values):
997
+ time_str = self.format_time_value(time_value)
998
+ self.date_combo.addItem(time_str, i)
999
+ else:
1000
+ # Add a subset of time values
1001
+ step = len(self._time_values) // max_items
1002
+
1003
+ # Always include first and last
1004
+ indices = list(range(0, len(self._time_values), step))
1005
+ if (len(self._time_values) - 1) not in indices:
1006
+ indices.append(len(self._time_values) - 1)
1007
+
1008
+ for i in indices:
1009
+ time_str = self.format_time_value(self._time_values[i])
1010
+ self.date_combo.addItem(f"{time_str} [{i+1}/{len(self._time_values)}]", i)
1011
+ except Exception as e:
1012
+ print(f"Error populating date combo: {e}")
1013
+
1014
+ def date_combo_changed(self, index):
1015
+ """Handle date combo box selection change"""
1016
+ if index >= 0:
1017
+ time_index = self.date_combo.itemData(index)
1018
+ if time_index is not None:
1019
+ self.time_slider.setValue(time_index)
509
1020
 
510
1021
  def _render_rgb(self):
511
1022
  if self.rgb_mode:
@@ -514,7 +1025,8 @@ class TiffViewer(QMainWindow):
514
1025
  rgb = np.zeros_like(arr)
515
1026
  if np.any(finite):
516
1027
  # Global 2–98 percentile stretch across all bands (QGIS-like)
517
- global_min, global_max = np.nanpercentile(arr, (2, 98))
1028
+ global_min = np.nanpercentile(arr, 2)
1029
+ global_max = np.nanpercentile(arr, 98)
518
1030
  rng = max(global_max - global_min, 1e-12)
519
1031
  norm = np.clip((arr - global_min) / rng, 0, 1)
520
1032
  rgb = np.clip(norm * self.contrast, 0, 1)
@@ -534,6 +1046,104 @@ class TiffViewer(QMainWindow):
534
1046
  rgb = (cmap(norm)[..., :3] * 255).astype(np.uint8)
535
1047
  return rgb
536
1048
 
1049
+ def _render_cartopy_map(self, data):
1050
+ """Render a NetCDF variable with cartopy for better geographic visualization"""
1051
+ import matplotlib.pyplot as plt
1052
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
1053
+ import cartopy.crs as ccrs
1054
+ import cartopy.feature as cfeature
1055
+
1056
+ # Create a new figure with cartopy projection
1057
+ fig = plt.figure(figsize=(12, 8), dpi=100)
1058
+ ax = plt.axes(projection=ccrs.PlateCarree())
1059
+
1060
+ # Get coordinates
1061
+ lons = self._lon_data
1062
+ lats = self._lat_data
1063
+
1064
+ # Create contour plot
1065
+ levels = 20
1066
+ if hasattr(plt.cm, self.cmap_name):
1067
+ cmap = getattr(plt.cm, self.cmap_name)
1068
+ else:
1069
+ cmap = getattr(cm, self.cmap_name, cm.viridis)
1070
+
1071
+ # Apply contrast and gamma adjustments
1072
+ finite = np.isfinite(data)
1073
+ norm_data = np.zeros_like(data, dtype=np.float32)
1074
+ vmin, vmax = np.nanmin(data), np.nanmax(data)
1075
+ rng = max(vmax - vmin, 1e-12)
1076
+
1077
+ if np.any(finite):
1078
+ norm_data[finite] = (data[finite] - vmin) / rng
1079
+
1080
+ norm_data = np.clip(norm_data * self.contrast, 0.0, 1.0)
1081
+ norm_data = np.power(norm_data, self.gamma)
1082
+ norm_data = norm_data * rng + vmin
1083
+
1084
+ # Downsample coordinates to match downsampled data shape
1085
+ # data shape is (lat_samples, lon_samples) after downsampling
1086
+ data_height, data_width = data.shape[:2]
1087
+ lat_samples = len(lats)
1088
+ lon_samples = len(lons)
1089
+
1090
+ # Calculate downsampling step if needed
1091
+ lat_step = max(1, lat_samples // data_height)
1092
+ lon_step = max(1, lon_samples // data_width)
1093
+
1094
+ # Downsample coordinate arrays to match data
1095
+ lats_downsampled = lats[::lat_step][:data_height]
1096
+ lons_downsampled = lons[::lon_step][:data_width]
1097
+
1098
+ # Convert 0-360 longitude to -180 to 180 if needed
1099
+ if lons_downsampled.max() > 180:
1100
+ lons_downsampled = ((lons_downsampled + 180) % 360) - 180
1101
+
1102
+ # Create 2D meshgrid for proper coordinate alignment with cartopy
1103
+ lon_grid, lat_grid = np.meshgrid(lons_downsampled, lats_downsampled, indexing='xy')
1104
+
1105
+ # Create the plot with 2D meshgrid for proper coordinate alignment
1106
+ contour = ax.contourf(lon_grid, lat_grid, data,
1107
+ transform=ccrs.PlateCarree(),
1108
+ levels=levels, cmap=cmap)
1109
+
1110
+ # Set map extent based on actual downsampled coordinates
1111
+ lon_min, lon_max = lons_downsampled.min(), lons_downsampled.max()
1112
+ lat_min, lat_max = lats_downsampled.min(), lats_downsampled.max()
1113
+ ax.set_extent([lon_min, lon_max, lat_min, lat_max], crs=ccrs.PlateCarree())
1114
+
1115
+ # Add map features
1116
+ ax.coastlines(resolution='50m', linewidth=0.5)
1117
+ ax.add_feature(cfeature.BORDERS, linestyle=':', linewidth=0.5)
1118
+ ax.add_feature(cfeature.STATES, linestyle='-', linewidth=0.3, alpha=0.5)
1119
+ ax.gridlines(draw_labels=True, alpha=0.3)
1120
+
1121
+ # Add title with variable name and time if available
1122
+ title = f"{self._nc_var_name}"
1123
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
1124
+ time_str = str(self._time_values[self._time_index])
1125
+ title += f"\n{time_str}"
1126
+ ax.set_title(title)
1127
+
1128
+ # Add colorbar
1129
+ plt.colorbar(contour, ax=ax, shrink=0.6)
1130
+
1131
+ plt.tight_layout()
1132
+
1133
+ # Convert matplotlib figure to image
1134
+ canvas = FigureCanvasAgg(fig)
1135
+ canvas.draw()
1136
+ width, height = fig.canvas.get_width_height()
1137
+ rgba = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(height, width, 4)
1138
+
1139
+ # Extract RGB and ensure it's C-contiguous for QImage
1140
+ rgb = np.ascontiguousarray(rgba[:, :, :3])
1141
+
1142
+ # Close figure to prevent memory leak
1143
+ plt.close(fig)
1144
+
1145
+ return rgb
1146
+
537
1147
  def update_pixmap(self):
538
1148
  # --- Select display data ---
539
1149
  if hasattr(self, "band_index"):
@@ -554,13 +1164,23 @@ class TiffViewer(QMainWindow):
554
1164
  # ----------------------------
555
1165
 
556
1166
  # --- Render image ---
557
- if rgb is None:
558
- # Grayscale rendering for single-band (scientific) data
1167
+ # Check if we should use cartopy for NetCDF visualization
1168
+ use_cartopy = False
1169
+ if hasattr(self, '_use_cartopy') and self._use_cartopy and HAVE_CARTOPY:
1170
+ if hasattr(self, '_has_geo_coords') and self._has_geo_coords:
1171
+ use_cartopy = True
1172
+
1173
+ if use_cartopy:
1174
+ # Render with cartopy for better geographic visualization
1175
+ rgb = self._render_cartopy_map(a)
1176
+ elif rgb is None:
1177
+ # Standard grayscale rendering for single-band (scientific) data
559
1178
  finite = np.isfinite(a)
560
- rng = max(np.nanmax(a) - np.nanmin(a), 1e-12)
1179
+ vmin, vmax = np.nanmin(a), np.nanmax(a)
1180
+ rng = max(vmax - vmin, 1e-12)
561
1181
  norm = np.zeros_like(a, dtype=np.float32)
562
1182
  if np.any(finite):
563
- norm[finite] = (a[finite] - np.nanmin(a)) / rng
1183
+ norm[finite] = (a[finite] - vmin) / rng
564
1184
  norm = np.clip(norm, 0, 1)
565
1185
  norm = np.power(norm * self.contrast, self.gamma)
566
1186
  cmap = getattr(cm, self.cmap_name, cm.viridis)
@@ -588,10 +1208,11 @@ class TiffViewer(QMainWindow):
588
1208
 
589
1209
  tif_path = self.tif_path
590
1210
 
591
- if os.path.dirname(self.tif_path).endswith(".gdb"):
1211
+ if tif_path and os.path.dirname(self.tif_path).endswith(".gdb"):
592
1212
  tif_path = f"OpenFileGDB:{os.path.dirname(self.tif_path)}:{os.path.basename(self.tif_path)}"
593
1213
 
594
- with rasterio.open(tif_path) as src:
1214
+ import rasterio as rio_module
1215
+ with rio_module.open(tif_path) as src:
595
1216
  self.band = band_num
596
1217
  arr = src.read(self.band).astype(np.float32)
597
1218
  nd = src.nodata
@@ -634,7 +1255,14 @@ class TiffViewer(QMainWindow):
634
1255
 
635
1256
  # Colormap toggle (single-band only)
636
1257
  elif not self.rgb_mode and k == Qt.Key.Key_M:
637
- self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
1258
+ # For NetCDF files, cycle through three colormaps
1259
+ if hasattr(self, 'cmap_names'):
1260
+ self.cmap_index = (self.cmap_index + 1) % len(self.cmap_names)
1261
+ self.cmap_name = self.cmap_names[self.cmap_index]
1262
+ print(f"Colormap: {self.cmap_name}")
1263
+ # For other files, toggle between two colormaps
1264
+ else:
1265
+ self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
638
1266
  self.update_pixmap()
639
1267
 
640
1268
  # Band switch
@@ -655,6 +1283,23 @@ class TiffViewer(QMainWindow):
655
1283
  elif not self.rgb_mode: # GeoTIFF single-band mode
656
1284
  new_band = self.band - 1 if self.band > 1 else self.band_count
657
1285
  self.load_band(new_band)
1286
+
1287
+ # NetCDF time/dimension navigation with Page Up/Down
1288
+ elif k == Qt.Key.Key_PageUp:
1289
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
1290
+ try:
1291
+ # Call the next_time_step method
1292
+ self.next_time_step()
1293
+ except Exception as e:
1294
+ print(f"Error handling PageUp: {e}")
1295
+
1296
+ elif k == Qt.Key.Key_PageDown:
1297
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
1298
+ try:
1299
+ # Call the prev_time_step method
1300
+ self.prev_time_step()
1301
+ except Exception as e:
1302
+ print(f"Error handling PageDown: {e}")
658
1303
 
659
1304
  elif k == Qt.Key.Key_R:
660
1305
  self.contrast = 1.0
@@ -666,8 +1311,8 @@ class TiffViewer(QMainWindow):
666
1311
  super().keyPressEvent(ev)
667
1312
 
668
1313
 
669
- # --------------------------------- Legacy argparse CLI (not used by default) ----------------------------------- #
670
- def legacy_argparse_main():
1314
+ # --------------------------------- CLI ----------------------------------- #
1315
+ def main():
671
1316
  parser = argparse.ArgumentParser(description="TIFF viewer with RGB (2–98%) & shapefile overlays")
672
1317
  parser.add_argument("tif_path", nargs="?", help="Path to TIFF (optional if --rgbfiles is used)")
673
1318
  parser.add_argument("--scale", type=int, default=1, help="Downsample factor (1=full, 10=10x smaller)")
@@ -679,8 +1324,6 @@ def legacy_argparse_main():
679
1324
  parser.add_argument("--shp-width", type=float, default=1.5, help="Overlay line width (screen pixels). Default: 1.5")
680
1325
  args = parser.parse_args()
681
1326
 
682
- from PySide6.QtCore import Qt
683
- QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
684
1327
  app = QApplication(sys.argv)
685
1328
  win = TiffViewer(
686
1329
  args.tif_path,
@@ -705,12 +1348,9 @@ def run_viewer(
705
1348
  shapefile=None,
706
1349
  shp_color=None,
707
1350
  shp_width=None,
708
- subset=None
1351
+ subset=None,
709
1352
  ):
710
-
711
1353
  """Launch the TiffViewer app"""
712
- from PySide6.QtCore import Qt
713
- QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
714
1354
  app = QApplication(sys.argv)
715
1355
  win = TiffViewer(
716
1356
  tif_path,
@@ -721,7 +1361,7 @@ def run_viewer(
721
1361
  shapefiles=shapefile,
722
1362
  shp_color=shp_color,
723
1363
  shp_width=shp_width,
724
- subset=subset
1364
+ subset=subset,
725
1365
  )
726
1366
  win.show()
727
1367
  sys.exit(app.exec())
@@ -729,19 +1369,18 @@ def run_viewer(
729
1369
  import click
730
1370
 
731
1371
  @click.command()
732
- @click.version_option("0.1.10", prog_name="viewtif")
1372
+ @click.version_option("0.2.0", prog_name="viewtif")
733
1373
  @click.argument("tif_path", required=False)
734
1374
  @click.option("--band", default=1, show_default=True, type=int, help="Band number to display")
735
- @click.option("--scale", default=1.0, show_default=True, type=int, help="Scale factor for display")
1375
+ @click.option("--scale", default=1.0, show_default=True, type=float, help="Scale factor for display")
736
1376
  @click.option("--rgb", nargs=3, type=int, help="Three band numbers for RGB, e.g. --rgb 4 3 2")
737
1377
  @click.option("--rgbfiles", nargs=3, type=str, help="Three single-band TIFFs for RGB, e.g. --rgbfiles B4.tif B3.tif B2.tif")
738
1378
  @click.option("--shapefile", multiple=True, type=str, help="One or more shapefiles to overlay")
739
1379
  @click.option("--shp-color", default="white", show_default=True, help="Overlay color (name or #RRGGBB).")
740
1380
  @click.option("--shp-width", default=1.0, show_default=True, type=float, help="Overlay line width (screen pixels).")
741
- @click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file")
742
-
1381
+ @click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file or variable in NetCDF file")
743
1382
  def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width, subset):
744
- """Lightweight GeoTIFF viewer."""
1383
+ """Lightweight GeoTIFF, NetCDF, and HDF viewer."""
745
1384
  # --- Warn early if shapefile requested but geopandas missing ---
746
1385
  if shapefile and not HAVE_GEO:
747
1386
  print(
@@ -759,9 +1398,8 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
759
1398
  shapefile=shapefile,
760
1399
  shp_color=shp_color,
761
1400
  shp_width=shp_width,
762
- subset=subset
1401
+ subset=subset,
763
1402
  )
764
1403
 
765
1404
  if __name__ == "__main__":
766
- main()
767
-
1405
+ main()
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: viewtif
3
- Version: 0.1.10
4
- Summary: Lightweight GeoTIFF, HDF/HDF5, and Esri File Geodatabase (.gdb) viewer with shapefile overlay and large-raster safeguard.
3
+ Version: 0.2.0
4
+ Summary: Lightweight GeoTIFF, NetCDF, HDF/HDF5, and Esri File Geodatabase (.gdb) viewer with optional shapefile overlay and geographic visualization. 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
7
7
  Project-URL: Issues, https://github.com/nkeikon/tifviewer/issues
@@ -16,21 +16,25 @@ 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
 
28
32
  You can visualize single-band GeoTIFFs, RGB composites, and shapefile overlays in a simple Qt-based window.
29
33
 
30
34
  ---
31
- Latest stable release: v0.1.9 (PyPI)
32
35
 
33
- Development branch: v0.2.0-dev (experimental, not released)
36
+ **Latest stable release:** [v0.1.9 on PyPI](https://pypi.org/project/viewtif/)
37
+ **Latest development:** main branch ([v0.2.0, experimental](https://github.com/nkeikon/viewtif))
34
38
 
35
39
  ---
36
40
 
@@ -92,7 +96,7 @@ viewtif AG100.v003.33.-107.0001.h5 --subset 1 --band 3
92
96
 
93
97
  ### Update in v1.0.7: File Geodatabase (.gdb) support
94
98
  `viewtif` can now open raster datasets stored inside Esri File Geodatabases (`.gdb`), using the GDAL `OpenFileGDB` driver.
95
- When you open a .gdb directly, `viewtif` will list available raster datasets first, then you can choose one to view.
99
+ When you open a .gdb directly, `viewtif`` will list available raster datasets first, then you can choose one to view.
96
100
 
97
101
  ```bash
98
102
  # List available raster datasets
@@ -108,6 +112,26 @@ As of v1.0.7, `viewtif` automatically checks the raster size before loading.
108
112
  If the dataset is very large (e.g., >20 million pixels), it will pause and warn that loading may freeze your system.
109
113
  You can proceed manually or rerun with the `--scale` option for a smaller, faster preview.
110
114
 
115
+ ### Update in v0.2.0: NetCDF support with optional cartopy visualization
116
+ `viewtif` now supports NetCDF (`.nc`) files with xarray and optional cartopy geographic visualization. NetCDF support is optional to keep the base installation lightweight.
117
+
118
+ #### Installation with NetCDF support
119
+ ```bash
120
+ pip install "viewtif[netcdf]"
121
+ ```
122
+
123
+ #### Examples
124
+ ```bash
125
+ viewtif data.nc
126
+ ```
127
+
128
+ > **Note:** NetCDF support is optional. If xarray or netCDF4 is missing, viewtif will display:
129
+ > `NetCDF support requires additional dependencies. Install them with: pip install viewtif[netcdf]`
130
+ >
131
+ > **Cartopy visualization:** For enhanced geographic visualization with map projections, coastlines, and borders, install with cartopy:
132
+ > `pip install "viewtif[netcdf]"` (cartopy is included in the netcdf extra)
133
+ > If cartopy is not available, netCDF files will still display with standard RGB rendering.
134
+
111
135
  ## Controls
112
136
  | Key | Action |
113
137
  | -------------------- | --------------------------------------- |
@@ -0,0 +1,5 @@
1
+ viewtif/tif_viewer.py,sha256=zaIQ-u77jz2D1G5fDas0oR878dm1foNLUUKfdf5pTTU,60858
2
+ viewtif-0.2.0.dist-info/METADATA,sha256=APFk7tPPzcaaziAj_73Q0Y9pWitfFihx_x0Tamw7fxU,7497
3
+ viewtif-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
+ viewtif-0.2.0.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
5
+ viewtif-0.2.0.dist-info/RECORD,,
@@ -1,5 +0,0 @@
1
- viewtif/tif_viewer.py,sha256=6mTylUguRn56QaJDZiNLSVPyqNWTDW_HKRRoa9eqjKY,30960
2
- viewtif-0.1.10.dist-info/METADATA,sha256=OTaQUPKmNLiL-L6Pvp5Sin3--KP6s3-MsNWQ7uJKWSk,6338
3
- viewtif-0.1.10.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
- viewtif-0.1.10.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
5
- viewtif-0.1.10.dist-info/RECORD,,