viewtif 0.2.1__py3-none-any.whl → 0.2.3__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,157 @@ 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
+ # --- Warn for large files before loading ---
214
+ warn_if_large(tif_path, scale=self._scale_arg)
215
+
216
+ # --------------------- Detect NetCDF --------------------- #
217
+ if tif_path and tif_path.lower().endswith((".nc", ".netcdf")):
218
+ try:
219
+ # Lazy-load NetCDF dependencies
220
+ import xarray as xr
221
+ import pandas as pd
222
+
223
+ # Open the NetCDF file
224
+ ds = xr.open_dataset(tif_path)
225
+
226
+ # List variables, filtering out boundary variables (ending with _bnds)
227
+ all_vars = list(ds.data_vars)
228
+ data_vars = [var for var in all_vars if not var.endswith('_bnds')]
229
+
230
+ # Auto-select the first variable if there's only one and no subset specified
231
+ if len(data_vars) == 1 and subset is None:
232
+ subset = 0
233
+ # Only list variables if --subset not given and multiple variables exist
234
+ elif subset is None:
235
+ sys.exit(0)
236
+
237
+ # Validate subset index
238
+ if subset < 0 or subset >= len(data_vars):
239
+ raise ValueError(f"Invalid variable index {subset}. Valid range: 0–{len(data_vars)-1}")
240
+
241
+ # Get the selected variable from filtered data_vars
242
+ var_name = data_vars[subset]
243
+ var_data = ds[var_name]
244
+
245
+ # Store original dataset and variable information for better visualization
246
+ self._nc_dataset = ds
247
+ self._nc_var_name = var_name
248
+ self._nc_var_data = var_data
249
+
250
+ # Get coordinate info if available
251
+ self._has_geo_coords = False
252
+ if 'lon' in ds.coords and 'lat' in ds.coords:
253
+ self._has_geo_coords = True
254
+ self._lon_data = ds.lon.values
255
+ self._lat_data = ds.lat.values
256
+ elif 'longitude' in ds.coords and 'latitude' in ds.coords:
257
+ self._has_geo_coords = True
258
+ self._lon_data = ds.longitude.values
259
+ self._lat_data = ds.latitude.values
260
+
261
+ # Handle time or other index dimension if present
262
+ self._has_time_dim = False
263
+ self._time_dim_name = None
264
+ time_index = 0
265
+
266
+ # Look for a time dimension first
267
+ if 'time' in var_data.dims:
268
+ self._has_time_dim = True
269
+ self._time_dim_name = 'time'
270
+ self._time_values = ds['time'].values
271
+ self._time_index = 0
272
+ print(f"NetCDF time dimension detected: {len(self._time_values)} steps")
273
+
274
+ self.band_count = var_data.sizes['time']
275
+ self.band_index = 0
276
+ self._time_dim_name = 'time'
277
+
278
+ # Try to format time values for better display
279
+ time_units = getattr(ds.time, 'units', None)
280
+ time_calendar = getattr(ds.time, 'calendar', 'standard')
281
+
282
+ # Select first time step by default
283
+ var_data = var_data.isel(time=time_index)
284
+
285
+ # If no time dimension but variable has multiple dimensions,
286
+ # use the first non-spatial dimension as a "time" dimension
287
+ elif len(var_data.dims) > 2:
288
+ # Try to find a dimension that's not lat/lon
289
+ spatial_dims = ['lat', 'lon', 'latitude', 'longitude', 'y', 'x']
290
+ for dim in var_data.dims:
291
+ if dim not in spatial_dims:
292
+ self._has_time_dim = True
293
+ self._time_dim_name = dim
294
+ self._time_values = ds[dim].values
295
+ self._time_index = time_index
296
+
297
+ # Select first index by default
298
+ var_data = var_data.isel({dim: time_index})
299
+ break
300
+
301
+ # Convert to numpy array
302
+ arr = var_data.values.astype(np.float32)
303
+
304
+ # Process array based on dimensions
305
+ if arr.ndim > 2:
306
+ # Keep only lat/lon dimensions for 3D+ arrays
307
+ arr = np.squeeze(arr)
308
+
309
+ # --- Downsample large arrays for responsiveness ---
310
+ if arr.ndim >= 2:
311
+ h, w = arr.shape[:2]
312
+ if h * w > 4_000_000:
313
+ step = max(2, int((h * w / 4_000_000) ** 0.5))
314
+ if arr.ndim == 2:
315
+ arr = arr[::step, ::step]
316
+ else:
317
+ arr = arr[::step, ::step, :]
318
+
319
+ # --- Final assignments ---
320
+ self.data = arr
321
+
322
+ # Try to extract CRS from CF conventions
323
+ self._transform = None
324
+ self._crs = None
325
+ if 'crs' in ds.variables:
326
+ try:
327
+ import rasterio.crs
328
+ crs_var = ds.variables['crs']
329
+ if hasattr(crs_var, 'spatial_ref'):
330
+ self._crs = rasterio.crs.CRS.from_wkt(crs_var.spatial_ref)
331
+ except Exception as e:
332
+ print(f"Could not parse CRS: {e}")
333
+
334
+ # Set band info
335
+ if arr.ndim == 3:
336
+ self.band_count = arr.shape[2]
337
+ else:
338
+ self.band_count = 1
339
+
340
+ self.band_index = 0
341
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
342
+
343
+ # --- If user specified --band, start there ---
344
+ if self.band and self.band <= self.band_count:
345
+ self.band_index = self.band - 1
346
+
347
+ # Enable cartopy visualization if available
348
+ self._use_cartopy = HAVE_CARTOPY and self._has_geo_coords
349
+
350
+ except ImportError as e:
351
+ if "xarray" in str(e) or "netCDF4" in str(e):
352
+ raise RuntimeError(
353
+ f"NetCDF support requires additional dependencies.\n"
354
+ f"Install them with: pip install viewtif[netcdf]\n"
355
+ f"Original error: {str(e)}"
356
+ )
357
+ else:
358
+ raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
359
+ except Exception as e:
360
+ raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
361
+
192
362
  # ---------------- Handle File Geodatabase (.gdb) ---------------- #
193
- if tif_path.lower().endswith(".gdb") and ":" not in tif_path:
363
+ if tif_path and tif_path.lower().endswith(".gdb") and ":" not in tif_path:
194
364
  import re, subprocess
195
365
  gdb_path = tif_path # use full path to .gdb
196
366
  try:
@@ -210,102 +380,143 @@ class TiffViewer(QMainWindow):
210
380
  print(f"[WARN] Could not inspect FileGDB: {e}")
211
381
  sys.exit(0)
212
382
 
213
- # --- Universal size check before loading ---
214
- warn_if_large(tif_path, scale=self._scale_arg)
215
-
383
+ # # --- Universal size check before loading ---
384
+ # warn_if_large(tif_path, scale=self._scale_arg)
385
+
386
+ if False: # Placeholder for previous if condition
387
+ pass
216
388
  # --------------------- Detect HDF/HDF5 --------------------- #
217
- if tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
389
+ elif tif_path and tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
218
390
  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
391
+ # Try GDAL first (best support for HDF subdatasets)
392
+ from osgeo import gdal
393
+ gdal.UseExceptions()
235
394
 
236
- except Exception as e:
237
- print(f"Rasterio could not open HDF directly: {e}")
238
- print("Falling back to GDAL...")
395
+ ds = gdal.Open(tif_path)
396
+ subs = ds.GetSubDatasets()
239
397
 
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.")
398
+ if not subs:
399
+ raise ValueError("No subdatasets found in HDF/HDF5 file.")
248
400
 
401
+ # Only list subsets if --subset not given
402
+ if subset is None:
249
403
  print(f"Found {len(subs)} subdatasets in {os.path.basename(tif_path)}:")
250
404
  for i, (_, desc) in enumerate(subs):
251
405
  print(f"[{i}] {desc}")
406
+ print("\nUse --subset N to open a specific subdataset.")
407
+ sys.exit(0)
252
408
 
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.")
409
+ # Validate subset index
410
+ if subset < 0 or subset >= len(subs):
411
+ raise ValueError(f"Invalid subset index {subset}. Valid range: 0–{len(subs)-1}")
412
+
413
+ sub_name, desc = subs[subset]
414
+ print(f"\nOpening subdataset [{subset}]: {desc}")
415
+ sub_ds = gdal.Open(sub_name)
416
+
417
+ # --- Read once ---
418
+ arr = sub_ds.ReadAsArray().astype(np.float32)
419
+ #print(f"Raw array shape from GDAL: {arr.shape} (ndim={arr.ndim})")
420
+
421
+ # --- Normalize shape ---
422
+ arr = np.squeeze(arr)
423
+ if arr.ndim == 3:
424
+ # Convert from (bands, rows, cols) → (rows, cols, bands)
425
+ arr = np.transpose(arr, (1, 2, 0))
426
+ #print(f"Transposed to {arr.shape} (rows, cols, bands)")
427
+ elif arr.ndim == 2:
428
+ print("Single-band dataset.")
429
+ else:
430
+ raise ValueError(f"Unexpected array shape {arr.shape}")
431
+
432
+ # --- Downsample large arrays for responsiveness ---
433
+ h, w = arr.shape[:2]
434
+ if h * w > 4_000_000:
435
+ step = max(2, int((h * w / 4_000_000) ** 0.5))
436
+ arr = arr[::step, ::step] if arr.ndim == 2 else arr[::step, ::step, :]
437
+
438
+ # --- Final assignments ---
439
+ self.data = arr
440
+ self._transform = None
441
+ self._crs = None
442
+ self.band_count = arr.shape[2] if arr.ndim == 3 else 1
443
+ self.band_index = 0
444
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
445
+
446
+ if self.band_count > 1:
447
+ print(f"This subdataset has {self.band_count} bands — switch with [ and ] keys.")
448
+ else:
449
+ print("This subdataset has 1 band.")
292
450
 
293
451
  if self.band and self.band <= self.band_count:
294
452
  self.band_index = self.band - 1
295
453
  print(f"Opening band {self.band}/{self.band_count}")
296
454
 
297
- except ImportError:
455
+ except ImportError:
456
+ # GDAL not available, try rasterio as fallback for NetCDF
457
+ print("[INFO] GDAL not available, attempting to read HDF/NetCDF with rasterio...")
458
+ try:
459
+ import rasterio as rio
460
+ with rio.open(tif_path) as src:
461
+ print(f"[INFO] NetCDF file opened via rasterio")
462
+ print(f"[INFO] Data shape: {src.height} x {src.width} x {src.count} bands")
463
+
464
+ if src.count == 0:
465
+ raise ValueError("No bands found in NetCDF file.")
466
+
467
+ # Determine which band(s) to read
468
+ if self.band and self.band <= src.count:
469
+ band_indices = [self.band]
470
+ print(f"Opening band {self.band}/{src.count}")
471
+ elif rgb and all(b <= src.count for b in rgb):
472
+ band_indices = rgb
473
+ print(f"Opening bands {rgb} as RGB")
474
+ else:
475
+ band_indices = list(range(1, min(src.count + 1, 4))) # Read up to 3 bands
476
+ print(f"Opening bands {band_indices}")
477
+
478
+ # Read selected bands
479
+ bands = []
480
+ for b in band_indices:
481
+ band_data = src.read(b, out_shape=(src.height // self._scale_arg, src.width // self._scale_arg))
482
+ bands.append(band_data)
483
+
484
+ # Stack into array
485
+ arr = np.stack(bands, axis=-1).astype(np.float32) if len(bands) > 1 else bands[0].astype(np.float32)
486
+
487
+ # Handle no-data values
488
+ nd = src.nodata
489
+ if nd is not None:
490
+ if arr.ndim == 3:
491
+ arr[arr == nd] = np.nan
492
+ else:
493
+ arr[arr == nd] = np.nan
494
+
495
+ # Final assignments
496
+ self.data = arr
497
+ self._transform = src.transform
498
+ self._crs = src.crs
499
+ self.band_count = arr.shape[2] if arr.ndim == 3 else 1
500
+ self.band_index = 0
501
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
502
+
503
+ if self.band_count > 1:
504
+ print(f"Loaded {self.band_count} bands — switch with [ and ] keys.")
505
+ else:
506
+ print("Loaded 1 band.")
507
+ except Exception as e:
298
508
  raise RuntimeError(
299
- "HDF/HDF5 support requires GDAL (Python bindings).\n"
300
- "Install it first (e.g., brew install gdal && pip install GDAL)"
509
+ f"Failed to read HDF/NetCDF file: {e}\n"
510
+ "For full HDF support, install GDAL: pip install GDAL"
301
511
  )
302
512
 
303
513
  # --------------------- Regular GeoTIFF --------------------- #
304
514
  else:
305
- if os.path.dirname(tif_path).endswith(".gdb"):
515
+ if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
306
516
  tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
307
517
 
308
- with rasterio.open(tif_path) as src:
518
+ import rasterio as rio_module
519
+ with rio_module.open(tif_path) as src:
309
520
  self._transform = src.transform
310
521
  self._crs = src.crs
311
522
  if rgb is not None:
@@ -349,19 +560,39 @@ class TiffViewer(QMainWindow):
349
560
  self.gamma = 1.0
350
561
 
351
562
  # Colormap (single-band)
352
- self.cmap_name = "viridis"
353
- self.alt_cmap_name = "magma" # toggle with M in single-band
563
+ # For NetCDF temperature data, have three colormaps in rotation
564
+ if tif_path and tif_path.lower().endswith(('.nc', '.netcdf')):
565
+ self.cmap_names = ["RdBu_r", "viridis", "magma"] # three colormaps for NetCDF
566
+ self.cmap_index = 0 # start with RdBu_r
567
+ self.cmap_name = self.cmap_names[self.cmap_index]
568
+ else:
569
+ self.cmap_name = "viridis"
570
+ self.alt_cmap_name = "magma" # toggle with M in single-band
354
571
 
355
572
  self.zoom_step = 1.2
356
573
  self.pan_step = 80
357
574
 
575
+ # Create main widget and layout
576
+ self.main_widget = QWidget()
577
+ self.main_layout = QVBoxLayout(self.main_widget)
578
+ self.main_layout.setContentsMargins(0, 0, 0, 0)
579
+ self.main_layout.setSpacing(0)
580
+
358
581
  # Scene + view
359
582
  self.scene = QGraphicsScene(self)
360
583
  self.view = RasterView(self.scene, self)
361
- self.setCentralWidget(self.view)
584
+ self.main_layout.addWidget(self.view)
585
+
586
+ # Status bar
587
+ self.setStatusBar(QStatusBar())
588
+
589
+ # Set central widget
590
+ self.setCentralWidget(self.main_widget)
362
591
 
363
592
  self.pixmap_item = None
364
593
  self._last_rgb = None
594
+
595
+ # --- Initial render ---
365
596
  self.update_pixmap()
366
597
 
367
598
  # Overlays (if any)
@@ -499,17 +730,246 @@ class TiffViewer(QMainWindow):
499
730
 
500
731
  # ----------------------- Title / Rendering ----------------------- #
501
732
  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)}")
733
+ """Show correct title for GeoTIFF or NetCDF time series."""
734
+ import os
735
+
736
+ if hasattr(self, "_has_time_dim") and self._has_time_dim:
737
+ nc_name = getattr(self, "_nc_var_name", "")
738
+ file_name = os.path.basename(self.tif_path)
739
+ title = f"Time step {self.band_index + 1}/{self.band_count} — {file_name}"
740
+
507
741
  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
- )
742
+ title = f"Band {self.band_index + 1}/{self.band_count} — {os.path.basename(self.tif_path)}"
743
+
744
+ elif self.rgb_mode and self.rgb:
745
+ # title = f"RGB {self.rgb} — {os.path.basename(self.tif_path)}"
746
+ title = f"RGB {self.rgb}"
747
+
511
748
  else:
512
- self.setWindowTitle(f"Band {self.band}/{self.band_count} — {os.path.basename(self.tif_path)}")
749
+ title = os.path.basename(self.tif_path)
750
+
751
+ print(f"Title: {title}")
752
+ self.setWindowTitle(title)
753
+
754
+ def _normalize_lat_lon(self, frame):
755
+ """Flip frame only if data and lat orientation disagree."""
756
+ import numpy as np
757
+
758
+ if not hasattr(self, "_lat_data"):
759
+ return frame
760
+
761
+ lats = self._lat_data
762
+
763
+ # 1D latitude case
764
+ if np.ndim(lats) == 1:
765
+ lat_ascending = lats[0] < lats[-1]
766
+
767
+ # If first pixel row corresponds to northernmost lat → do nothing
768
+ # If first pixel row corresponds to southernmost lat → flip to make north at top
769
+ # We'll assume data[0, :] corresponds to lats[0]
770
+ if lat_ascending:
771
+ print("[DEBUG] Flipping latitude orientation (lat ascending, data starts south)")
772
+ frame = np.flipud(frame)
773
+ # else:
774
+ # print("[DEBUG] No flip (lat descending, already north-up)")
775
+ return frame
776
+
777
+ # 2D latitude grid (rare case)
778
+ elif np.ndim(lats) == 2:
779
+ first_col = lats[:, 0]
780
+ lat_ascending = first_col[0] < first_col[-1]
781
+ if lat_ascending:
782
+ print("[DEBUG] Flipping latitude orientation (2D grid ascending)")
783
+ frame = np.flipud(frame)
784
+ # else:
785
+ # print("[DEBUG] No flip (2D grid already north-up)")
786
+ return frame
787
+
788
+ return frame
789
+
790
+ def _apply_scale_if_needed(self, frame):
791
+ """Downsample frame and lat/lon consistently if --scale > 1."""
792
+ if not hasattr(self, "_scale_arg") or self._scale_arg <= 1:
793
+ return frame
794
+
795
+ step = int(self._scale_arg)
796
+ print(f"[DEBUG] Applying scale factor {step} to current frame")
797
+
798
+ # Downsample the frame
799
+ frame = frame[::step, ::step]
800
+
801
+ # Also downsample lat/lon for this viewer instance if not already
802
+ if hasattr(self, "_lat_data") and np.ndim(self._lat_data) == 1 and len(self._lat_data) > frame.shape[0]:
803
+ self._lat_data = self._lat_data[::step]
804
+ if hasattr(self, "_lon_data") and np.ndim(self._lon_data) == 1 and len(self._lon_data) > frame.shape[1]:
805
+ self._lon_data = self._lon_data[::step]
806
+
807
+ return frame
808
+
809
+ def get_current_frame(self):
810
+ """Return the current time/band frame as a NumPy array (2D)."""
811
+ frame = None
812
+
813
+ if hasattr(self, '_time_dim_name') and hasattr(self, '_nc_var_data'):
814
+ # Select frame using band_index
815
+ try:
816
+ frame = self._nc_var_data.isel({self._time_dim_name: self.band_index})
817
+ except Exception:
818
+ # Already numpy or index error fallback
819
+ frame = self._nc_var_data
820
+
821
+ elif isinstance(self.data, np.ndarray):
822
+ frame = self.data
823
+
824
+ # Normalize lat orientation if needed
825
+ frame = self._normalize_lat_lon(frame)
826
+ frame = self._apply_scale_if_needed(frame)
827
+ # Convert to numpy if it's still an xarray
828
+ if hasattr(frame, "values"):
829
+ frame = frame.values
830
+
831
+ # Apply same scaling factor (if any)
832
+ if hasattr(self, "_scale_arg") and self._scale_arg > 1:
833
+ step = int(self._scale_arg)
834
+
835
+ return frame.astype(np.float32)
836
+
837
+ def format_time_value(self, time_value):
838
+ """Format a time value into a user-friendly string"""
839
+ # Default is the string representation
840
+ time_str = str(time_value)
841
+
842
+ try:
843
+ # Handle numpy datetime64
844
+ if hasattr(time_value, 'dtype') and np.issubdtype(time_value.dtype, np.datetime64):
845
+ # Lazy-load pandas for timestamp conversion
846
+ import pandas as pd
847
+ # Convert to Python datetime if possible
848
+ dt = pd.Timestamp(time_value).to_pydatetime()
849
+ time_str = dt.strftime('%Y-%m-%d %H:%M:%S')
850
+ # Handle native Python datetime
851
+ elif hasattr(time_value, 'strftime'):
852
+ time_str = time_value.strftime('%Y-%m-%d %H:%M:%S')
853
+ # Handle cftime datetime-like objects used in some NetCDF files
854
+ elif hasattr(time_value, 'isoformat'):
855
+ time_str = time_value.isoformat().replace('T', ' ')
856
+ except Exception:
857
+ # Fall back to string representation
858
+ pass
859
+
860
+ return time_str
861
+
862
+ # def update_time_label(self):
863
+ # """Update the time label with the current time value"""
864
+ # if hasattr(self, '_has_time_dim') and self._has_time_dim:
865
+ # try:
866
+ # time_value = self._time_values[self._time_index]
867
+ # time_str = self.format_time_value(time_value)
868
+
869
+ # # Update time label if it exists
870
+ # if hasattr(self, 'time_label'):
871
+ # self.time_label.setText(f"Time: {time_str}")
872
+
873
+ # # Create a progress bar style display of time position
874
+ # total = len(self._time_values)
875
+ # position = self._time_index + 1
876
+ # bar_width = 20 # Width of the progress bar
877
+ # filled = int(bar_width * position / total)
878
+ # bar = "[" + "#" * filled + "-" * (bar_width - filled) + "]"
879
+
880
+ # # Show time info in status bar
881
+ # step_info = f"Time step: {position}/{total} {bar} {self.format_time_value(self._time_values[self._time_index])}"
882
+
883
+ # # Update status bar if it exists
884
+ # if hasattr(self, 'statusBar') and callable(self.statusBar):
885
+ # self.statusBar().showMessage(step_info)
886
+ # else:
887
+ # print(step_info)
888
+ # except Exception as e:
889
+ # print(f"Error updating time label: {e}")
890
+
891
+ # def toggle_play_pause(self):
892
+ # """Toggle play/pause animation of time steps"""
893
+ # if self._is_playing:
894
+ # self.stop_animation()
895
+ # else:
896
+ # self.start_animation()
897
+
898
+ # def start_animation(self):
899
+ # """Start the time animation"""
900
+ # from PySide6.QtCore import QTimer
901
+
902
+ # if not hasattr(self, '_play_timer') or self._play_timer is None:
903
+ # self._play_timer = QTimer(self)
904
+ # self._play_timer.timeout.connect(self.animation_step)
905
+
906
+ # # Set animation speed (milliseconds between frames)
907
+ # animation_speed = 500 # 0.5 seconds between frames
908
+ # self._play_timer.start(animation_speed)
909
+
910
+ # self._is_playing = True
911
+ # self.play_button.setText("⏸") # Pause symbol
912
+ # self.play_button.setToolTip("Pause animation")
913
+
914
+ # def stop_animation(self):
915
+ # """Stop the time animation"""
916
+ # if hasattr(self, '_play_timer') and self._play_timer is not None:
917
+ # self._play_timer.stop()
918
+
919
+ # self._is_playing = False
920
+ # self.play_button.setText("▶") # Play symbol
921
+ # self.play_button.setToolTip("Play animation")
922
+
923
+ # def animation_step(self):
924
+ # """Advance one frame in the animation"""
925
+ # # Go to next time step
926
+ # next_time = (self._time_index + 1) % len(self._time_values)
927
+ # self.time_slider.setValue(next_time)
928
+
929
+ # def closeEvent(self, event):
930
+ # """Clean up resources when the window is closed"""
931
+ # # Stop animation timer if it's running
932
+ # if hasattr(self, '_is_playing') and self._is_playing:
933
+ # self.stop_animation()
934
+
935
+ # # Call the parent class closeEvent
936
+ # super().closeEvent(event)
937
+
938
+ # def populate_date_combo(self):
939
+ # """Populate the date combo box with time values"""
940
+ # if hasattr(self, '_has_time_dim') and self._has_time_dim and hasattr(self, 'date_combo'):
941
+ # try:
942
+ # self.date_combo.clear()
943
+
944
+ # # Add a reasonable subset of dates if there are too many
945
+ # max_items = 100 # Maximum number of items to show in dropdown
946
+
947
+ # if len(self._time_values) <= max_items:
948
+ # # Add all time values
949
+ # for i, time_value in enumerate(self._time_values):
950
+ # time_str = self.format_time_value(time_value)
951
+ # self.date_combo.addItem(time_str, i)
952
+ # else:
953
+ # # Add a subset of time values
954
+ # step = len(self._time_values) // max_items
955
+
956
+ # # Always include first and last
957
+ # indices = list(range(0, len(self._time_values), step))
958
+ # if (len(self._time_values) - 1) not in indices:
959
+ # indices.append(len(self._time_values) - 1)
960
+
961
+ # for i in indices:
962
+ # time_str = self.format_time_value(self._time_values[i])
963
+ # self.date_combo.addItem(f"{time_str} [{i+1}/{len(self._time_values)}]", i)
964
+ # except Exception as e:
965
+ # print(f"Error populating date combo: {e}")
966
+
967
+ # def date_combo_changed(self, index):
968
+ # """Handle date combo box selection change"""
969
+ # if index >= 0:
970
+ # time_index = self.date_combo.itemData(index)
971
+ # if time_index is not None:
972
+ # self.time_slider.setValue(time_index)
513
973
 
514
974
  def _render_rgb(self):
515
975
  if self.rgb_mode:
@@ -538,6 +998,126 @@ class TiffViewer(QMainWindow):
538
998
  rgb = (cmap(norm)[..., :3] * 255).astype(np.uint8)
539
999
  return rgb
540
1000
 
1001
+ def _render_cartopy_map(self, data):
1002
+ """Render a NetCDF variable with cartopy for better geographic visualization"""
1003
+ import matplotlib.pyplot as plt
1004
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
1005
+ import cartopy.crs as ccrs
1006
+ import cartopy.feature as cfeature
1007
+
1008
+ # Create a new figure with cartopy projection
1009
+ fig = plt.figure(figsize=(12, 8), dpi=100)
1010
+ ax = plt.axes(projection=ccrs.PlateCarree())
1011
+
1012
+ # Get coordinates
1013
+ lons = self._lon_data
1014
+ lats = self._lat_data
1015
+
1016
+ # Create contour plot
1017
+ levels = 20
1018
+ if hasattr(plt.cm, self.cmap_name):
1019
+ cmap = getattr(plt.cm, self.cmap_name)
1020
+ else:
1021
+ cmap = getattr(cm, self.cmap_name, cm.viridis)
1022
+
1023
+ # Apply contrast and gamma adjustments
1024
+ finite = np.isfinite(data)
1025
+ norm_data = np.zeros_like(data, dtype=np.float32)
1026
+ vmin, vmax = np.nanmin(data), np.nanmax(data)
1027
+ rng = max(vmax - vmin, 1e-12)
1028
+
1029
+ if np.any(finite):
1030
+ norm_data[finite] = (data[finite] - vmin) / rng
1031
+
1032
+ norm_data = np.clip(norm_data * self.contrast, 0.0, 1.0)
1033
+ norm_data = np.power(norm_data, self.gamma)
1034
+ norm_data = norm_data * rng + vmin
1035
+
1036
+ # Downsample coordinates to match downsampled data shape
1037
+ # --- Align coordinates with data shape (no stepping assumptions) ---
1038
+ # Downsample coordinates to match downsampled data shape
1039
+ data_height, data_width = data.shape[:2]
1040
+ lat_samples = len(lats)
1041
+ lon_samples = len(lons)
1042
+
1043
+ lat_step = max(1, lat_samples // data_height)
1044
+ lon_step = max(1, lon_samples // data_width)
1045
+
1046
+ # Downsample coordinate arrays to match data
1047
+ lats_downsampled = lats[::lat_step][:data_height]
1048
+ lons_downsampled = lons[::lon_step][:data_width]
1049
+
1050
+ # --- Synchronize latitude orientation with normalized data ---
1051
+ if np.ndim(lats) == 1 and lats[0] < lats[-1]:
1052
+ print("[DEBUG] Lat ascending → flip lats_downsampled to match flipped data")
1053
+ lats_downsampled = lats_downsampled[::-1]
1054
+ elif np.ndim(lats) == 2:
1055
+ first_col = lats[:, 0]
1056
+ if first_col[0] < first_col[-1]:
1057
+ print("[DEBUG] 2D lat grid ascending → flip lats_downsampled vertically")
1058
+ lats_downsampled = np.flipud(lats_downsampled)
1059
+
1060
+ # Convert 0–360 longitude to −180–180 if needed
1061
+ if lons_downsampled.max() > 180:
1062
+ lons_downsampled = ((lons_downsampled + 180) % 360) - 180
1063
+
1064
+
1065
+ # --- Build meshgrid AFTER any flip ---
1066
+ lon_grid, lat_grid = np.meshgrid(lons_downsampled, lats_downsampled, indexing="xy")
1067
+
1068
+ # Use pcolormesh (more stable than contourf for gridded data)
1069
+ img = ax.pcolormesh(
1070
+ lon_grid, lat_grid, data,
1071
+ transform=ccrs.PlateCarree(),
1072
+ cmap=cmap,
1073
+ shading="auto"
1074
+ )
1075
+
1076
+ # Set extent from the 1D vectors (already flipped if needed)
1077
+ ax.set_extent(
1078
+ [lons_downsampled.min(), lons_downsampled.max(),
1079
+ lats_downsampled.min(), lats_downsampled.max()],
1080
+ crs=ccrs.PlateCarree()
1081
+ )
1082
+
1083
+ # Add map features
1084
+ ax.coastlines(resolution="50m", linewidth=0.5)
1085
+ ax.add_feature(cfeature.BORDERS, linestyle=":", linewidth=0.5)
1086
+ ax.add_feature(cfeature.STATES, linestyle="-", linewidth=0.3, alpha=0.5)
1087
+ ax.gridlines(draw_labels=True, alpha=0.3)
1088
+
1089
+ # --- Add dynamic title ---
1090
+ title = os.path.basename(self.tif_path)
1091
+ if hasattr(self, "_has_time_dim") and self._has_time_dim:
1092
+ # Use current band_index as proxy for time_index
1093
+ try:
1094
+ current_time = self._time_values[self.band_index]
1095
+ time_str = self.format_time_value(current_time) if hasattr(self, "format_time_value") else str(current_time)
1096
+ ax.set_title(f"{title}\n{time_str}", fontsize=10)
1097
+ except Exception as e:
1098
+ ax.set_title(f"{title}\n(time step {self.band_index + 1})", fontsize=10)
1099
+ else:
1100
+ ax.set_title(title, fontsize=10)
1101
+
1102
+ # Add colorbar
1103
+ plt.colorbar(img, ax=ax, shrink=0.6)
1104
+ plt.tight_layout()
1105
+
1106
+
1107
+ # Convert matplotlib figure to image
1108
+ canvas = FigureCanvasAgg(fig)
1109
+ canvas.draw()
1110
+ width, height = fig.canvas.get_width_height()
1111
+ rgba = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(height, width, 4)
1112
+
1113
+ # Extract RGB and ensure it's C-contiguous for QImage
1114
+ rgb = np.ascontiguousarray(rgba[:, :, :3])
1115
+
1116
+ # Close figure to prevent memory leak
1117
+ plt.close(fig)
1118
+
1119
+ return rgb
1120
+
541
1121
  def update_pixmap(self):
542
1122
  # --- Select display data ---
543
1123
  if hasattr(self, "band_index"):
@@ -558,13 +1138,23 @@ class TiffViewer(QMainWindow):
558
1138
  # ----------------------------
559
1139
 
560
1140
  # --- Render image ---
561
- if rgb is None:
562
- # Grayscale rendering for single-band (scientific) data
1141
+ # Check if we should use cartopy for NetCDF visualization
1142
+ use_cartopy = False
1143
+ if hasattr(self, '_use_cartopy') and self._use_cartopy and HAVE_CARTOPY:
1144
+ if hasattr(self, '_has_geo_coords') and self._has_geo_coords:
1145
+ use_cartopy = True
1146
+
1147
+ if use_cartopy:
1148
+ # Render with cartopy for better geographic visualization
1149
+ rgb = self._render_cartopy_map(a)
1150
+ elif rgb is None:
1151
+ # Standard grayscale rendering for single-band (scientific) data
563
1152
  finite = np.isfinite(a)
564
- rng = max(np.nanmax(a) - np.nanmin(a), 1e-12)
1153
+ vmin, vmax = np.nanmin(a), np.nanmax(a)
1154
+ rng = max(vmax - vmin, 1e-12)
565
1155
  norm = np.zeros_like(a, dtype=np.float32)
566
1156
  if np.any(finite):
567
- norm[finite] = (a[finite] - np.nanmin(a)) / rng
1157
+ norm[finite] = (a[finite] - vmin) / rng
568
1158
  norm = np.clip(norm, 0, 1)
569
1159
  norm = np.power(norm * self.contrast, self.gamma)
570
1160
  cmap = getattr(cm, self.cmap_name, cm.viridis)
@@ -592,10 +1182,11 @@ class TiffViewer(QMainWindow):
592
1182
 
593
1183
  tif_path = self.tif_path
594
1184
 
595
- if os.path.dirname(self.tif_path).endswith(".gdb"):
1185
+ if tif_path and os.path.dirname(self.tif_path).endswith(".gdb"):
596
1186
  tif_path = f"OpenFileGDB:{os.path.dirname(self.tif_path)}:{os.path.basename(self.tif_path)}"
597
1187
 
598
- with rasterio.open(tif_path) as src:
1188
+ import rasterio as rio_module
1189
+ with rio_module.open(tif_path) as src:
599
1190
  self.band = band_num
600
1191
  arr = src.read(self.band).astype(np.float32)
601
1192
  nd = src.nodata
@@ -638,13 +1229,21 @@ class TiffViewer(QMainWindow):
638
1229
 
639
1230
  # Colormap toggle (single-band only)
640
1231
  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
1232
+ # For NetCDF files, cycle through three colormaps
1233
+ if hasattr(self, 'cmap_names'):
1234
+ self.cmap_index = (self.cmap_index + 1) % len(self.cmap_names)
1235
+ self.cmap_name = self.cmap_names[self.cmap_index]
1236
+ print(f"Colormap: {self.cmap_name}")
1237
+ # For other files, toggle between two colormaps
1238
+ else:
1239
+ self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
642
1240
  self.update_pixmap()
643
1241
 
644
1242
  # Band switch
645
1243
  elif k == Qt.Key.Key_BracketRight:
646
1244
  if hasattr(self, "band_index"): # HDF/NetCDF mode
647
1245
  self.band_index = (self.band_index + 1) % self.band_count
1246
+ self.data = self.get_current_frame()
648
1247
  self.update_pixmap()
649
1248
  self.update_title()
650
1249
  elif not self.rgb_mode: # GeoTIFF single-band mode
@@ -654,11 +1253,29 @@ class TiffViewer(QMainWindow):
654
1253
  elif k == Qt.Key.Key_BracketLeft:
655
1254
  if hasattr(self, "band_index"): # HDF/NetCDF mode
656
1255
  self.band_index = (self.band_index - 1) % self.band_count
1256
+ self.data = self.get_current_frame()
657
1257
  self.update_pixmap()
658
1258
  self.update_title()
659
1259
  elif not self.rgb_mode: # GeoTIFF single-band mode
660
1260
  new_band = self.band - 1 if self.band > 1 else self.band_count
661
1261
  self.load_band(new_band)
1262
+
1263
+ # NetCDF time/dimension navigation with Page Up/Down
1264
+ elif k == Qt.Key.Key_PageUp:
1265
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
1266
+ try:
1267
+ # Call the next_time_step method
1268
+ self.next_time_step()
1269
+ except Exception as e:
1270
+ print(f"Error handling PageUp: {e}")
1271
+
1272
+ elif k == Qt.Key.Key_PageDown:
1273
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
1274
+ try:
1275
+ # Call the prev_time_step method
1276
+ self.prev_time_step()
1277
+ except Exception as e:
1278
+ print(f"Error handling PageDown: {e}")
662
1279
 
663
1280
  elif k == Qt.Key.Key_R:
664
1281
  self.contrast = 1.0
@@ -670,36 +1287,7 @@ class TiffViewer(QMainWindow):
670
1287
  super().keyPressEvent(ev)
671
1288
 
672
1289
 
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
-
1290
+ # --------------------------------- CLI ----------------------------------- #
703
1291
  def run_viewer(
704
1292
  tif_path,
705
1293
  scale=None,
@@ -714,7 +1302,7 @@ def run_viewer(
714
1302
 
715
1303
  """Launch the TiffViewer app"""
716
1304
  from PySide6.QtCore import Qt
717
- QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
1305
+ # QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
718
1306
  app = QApplication(sys.argv)
719
1307
  win = TiffViewer(
720
1308
  tif_path,
@@ -733,7 +1321,7 @@ def run_viewer(
733
1321
  import click
734
1322
 
735
1323
  @click.command()
736
- @click.version_option("0.2.1", prog_name="viewtif")
1324
+ @click.version_option("0.2.2", prog_name="viewtif")
737
1325
  @click.argument("tif_path", required=False)
738
1326
  @click.option("--band", default=1, show_default=True, type=int, help="Band number to display")
739
1327
  @click.option("--scale", default=1.0, show_default=True, type=int, help="Scale factor for display")
@@ -742,10 +1330,9 @@ import click
742
1330
  @click.option("--shapefile", multiple=True, type=str, help="One or more shapefiles to overlay")
743
1331
  @click.option("--shp-color", default="white", show_default=True, help="Overlay color (name or #RRGGBB).")
744
1332
  @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
-
1333
+ @click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file or variable in NetCDF file")
747
1334
  def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width, subset):
748
- """Lightweight GeoTIFF viewer."""
1335
+ """Lightweight GeoTIFF, NetCDF, and HDF viewer."""
749
1336
  # --- Warn early if shapefile requested but geopandas missing ---
750
1337
  if shapefile and not HAVE_GEO:
751
1338
  print(
@@ -767,5 +1354,4 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
767
1354
  )
768
1355
 
769
1356
  if __name__ == "__main__":
770
- main()
771
-
1357
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: viewtif
3
- Version: 0.2.1
3
+ Version: 0.2.3
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=tzcABPB4F7CZPovrSTyP837mT_WbsQf5L087PPTww6g,57465
2
+ viewtif-0.2.3.dist-info/METADATA,sha256=cW2EgoTWjFai7P7OyvE5e7AARnXS5Go2GPh0ze6zOUU,7280
3
+ viewtif-0.2.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
+ viewtif-0.2.3.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
5
+ viewtif-0.2.3.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,,