viewtif 0.2.0__py3-none-any.whl → 0.2.1__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,14 +34,16 @@ 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,
38
- QScrollBar, QGraphicsPathItem, QVBoxLayout, QHBoxLayout, QSlider, QLabel,
39
- QWidget, QStatusBar, QPushButton, QComboBox
37
+ QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QScrollBar, QGraphicsPathItem
40
38
  )
41
39
  from PySide6.QtGui import QImage, QPixmap, QPainter, QPen, QColor, QPainterPath
42
- from PySide6.QtCore import Qt, QDateTime
40
+ from PySide6.QtCore import Qt
43
41
 
44
42
  import matplotlib.cm as cm
43
+ import warnings
44
+ warnings.filterwarnings("ignore", category=RuntimeWarning, module="shapely")
45
+
46
+ __version__ = "0.2.1"
45
47
 
46
48
  # Optional overlay deps
47
49
  try:
@@ -54,74 +56,53 @@ try:
54
56
  except Exception:
55
57
  HAVE_GEO = False
56
58
 
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
-
70
59
  def warn_if_large(tif_path, scale=1):
71
60
  """Warn and confirm before loading very large rasters (GeoTIFF, GDB, or HDF).
72
-
73
- Uses GDAL if available, falls back to rasterio for standard formats.
61
+ Works even if GDAL is not installed.
74
62
  """
75
63
  import os
64
+ width = height = None
65
+ size_mb = None
76
66
 
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
-
67
+ # Try GDAL if available
81
68
  try:
82
- width, height = None, None
83
-
84
- # Try GDAL first (supports more formats including GDB, HDF)
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
85
75
  try:
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)
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
118
82
  except Exception as e:
119
- print(f"[INFO] Could not pre-check raster size: {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)
120
102
 
121
103
  # -------------------------- QGraphicsView tweaks -------------------------- #
122
104
  class RasterView(QGraphicsView):
123
105
  def __init__(self, *args, **kwargs):
124
- import numpy as np
125
106
  super().__init__(*args, **kwargs)
126
107
  self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
127
108
  self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
@@ -190,8 +171,7 @@ class TiffViewer(QMainWindow):
190
171
  # --- Load data ---
191
172
  if rgbfiles:
192
173
  red, green, blue = rgbfiles
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:
174
+ with rasterio.open(red) as r, rasterio.open(green) as g, rasterio.open(blue) as b:
195
175
  if (r.width, r.height) != (g.width, g.height) or (r.width, r.height) != (b.width, b.height):
196
176
  raise ValueError("All RGB files must have the same dimensions.")
197
177
  arr = np.stack([
@@ -209,149 +189,8 @@ class TiffViewer(QMainWindow):
209
189
  self.tif_path = self.tif_path or (os.path.commonprefix([red, green, blue]) or red)
210
190
 
211
191
  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
-
353
192
  # ---------------- Handle File Geodatabase (.gdb) ---------------- #
354
- if tif_path and tif_path.lower().endswith(".gdb") and ":" not in tif_path:
193
+ if tif_path.lower().endswith(".gdb") and ":" not in tif_path:
355
194
  import re, subprocess
356
195
  gdb_path = tif_path # use full path to .gdb
357
196
  try:
@@ -370,147 +209,103 @@ class TiffViewer(QMainWindow):
370
209
  except subprocess.CalledProcessError as e:
371
210
  print(f"[WARN] Could not inspect FileGDB: {e}")
372
211
  sys.exit(0)
373
- # --- Universal size check before loading ---
212
+
213
+ # --- Universal size check before loading ---
374
214
  warn_if_large(tif_path, scale=self._scale_arg)
375
-
376
- if False: # Placeholder for previous if condition
377
- pass
215
+
378
216
  # --------------------- Detect HDF/HDF5 --------------------- #
379
- elif tif_path and tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
217
+ if tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
380
218
  try:
381
- # Try GDAL first (best support for HDF subdatasets)
382
- from osgeo import gdal
383
- gdal.UseExceptions()
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
384
235
 
385
- ds = gdal.Open(tif_path)
386
- subs = ds.GetSubDatasets()
236
+ except Exception as e:
237
+ print(f"Rasterio could not open HDF directly: {e}")
238
+ print("Falling back to GDAL...")
387
239
 
388
- if not subs:
389
- raise ValueError("No subdatasets found in HDF/HDF5 file.")
240
+ try:
241
+ from osgeo import gdal
242
+ gdal.UseExceptions()
390
243
 
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}")
244
+ ds = gdal.Open(tif_path)
245
+ subs = ds.GetSubDatasets()
246
+ if not subs:
247
+ raise ValueError("No subdatasets found in HDF/HDF5 file.")
394
248
 
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)
249
+ print(f"Found {len(subs)} subdatasets in {os.path.basename(tif_path)}:")
250
+ for i, (_, desc) in enumerate(subs):
251
+ print(f"[{i}] {desc}")
399
252
 
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.")
253
+ if subset is None:
254
+ print("\nUse --subset N to open a specific subdataset.")
255
+ sys.exit(0)
441
256
 
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:
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
447
285
  self.band_index = 0
286
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
448
287
 
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:
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.")
292
+
293
+ if self.band and self.band <= self.band_count:
294
+ self.band_index = self.band - 1
295
+ print(f"Opening band {self.band}/{self.band_count}")
296
+
297
+ except ImportError:
502
298
  raise RuntimeError(
503
- f"Failed to read HDF/NetCDF file: {e}\n"
504
- "For full HDF support, install GDAL: pip install GDAL"
299
+ "HDF/HDF5 support requires GDAL (Python bindings).\n"
300
+ "Install it first (e.g., brew install gdal && pip install GDAL)"
505
301
  )
506
302
 
507
303
  # --------------------- Regular GeoTIFF --------------------- #
508
304
  else:
509
- if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
305
+ if os.path.dirname(tif_path).endswith(".gdb"):
510
306
  tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
511
307
 
512
- import rasterio as rio_module
513
- with rio_module.open(tif_path) as src:
308
+ with rasterio.open(tif_path) as src:
514
309
  self._transform = src.transform
515
310
  self._crs = src.crs
516
311
  if rgb is not None:
@@ -554,92 +349,16 @@ class TiffViewer(QMainWindow):
554
349
  self.gamma = 1.0
555
350
 
556
351
  # Colormap (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
352
+ self.cmap_name = "viridis"
353
+ self.alt_cmap_name = "magma" # toggle with M in single-band
565
354
 
566
355
  self.zoom_step = 1.2
567
356
  self.pan_step = 80
568
357
 
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
-
633
358
  # Scene + view
634
359
  self.scene = QGraphicsScene(self)
635
360
  self.view = RasterView(self.scene, self)
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)
361
+ self.setCentralWidget(self.view)
643
362
 
644
363
  self.pixmap_item = None
645
364
  self._last_rgb = None
@@ -786,237 +505,11 @@ class TiffViewer(QMainWindow):
786
505
  elif self.rgb_mode and self.rgb:
787
506
  self.setWindowTitle(f"RGB {self.rgb} — {os.path.basename(self.tif_path)}")
788
507
  elif hasattr(self, "band_index"):
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)
508
+ self.setWindowTitle(
509
+ f"Band {self.band_index + 1}/{self.band_count} — {os.path.basename(self.tif_path)}"
510
+ )
801
511
  else:
802
512
  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)
1020
513
 
1021
514
  def _render_rgb(self):
1022
515
  if self.rgb_mode:
@@ -1025,8 +518,7 @@ class TiffViewer(QMainWindow):
1025
518
  rgb = np.zeros_like(arr)
1026
519
  if np.any(finite):
1027
520
  # Global 2–98 percentile stretch across all bands (QGIS-like)
1028
- global_min = np.nanpercentile(arr, 2)
1029
- global_max = np.nanpercentile(arr, 98)
521
+ global_min, global_max = np.nanpercentile(arr, (2, 98))
1030
522
  rng = max(global_max - global_min, 1e-12)
1031
523
  norm = np.clip((arr - global_min) / rng, 0, 1)
1032
524
  rgb = np.clip(norm * self.contrast, 0, 1)
@@ -1046,104 +538,6 @@ class TiffViewer(QMainWindow):
1046
538
  rgb = (cmap(norm)[..., :3] * 255).astype(np.uint8)
1047
539
  return rgb
1048
540
 
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
-
1147
541
  def update_pixmap(self):
1148
542
  # --- Select display data ---
1149
543
  if hasattr(self, "band_index"):
@@ -1164,23 +558,13 @@ class TiffViewer(QMainWindow):
1164
558
  # ----------------------------
1165
559
 
1166
560
  # --- Render image ---
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
561
+ if rgb is None:
562
+ # Grayscale rendering for single-band (scientific) data
1178
563
  finite = np.isfinite(a)
1179
- vmin, vmax = np.nanmin(a), np.nanmax(a)
1180
- rng = max(vmax - vmin, 1e-12)
564
+ rng = max(np.nanmax(a) - np.nanmin(a), 1e-12)
1181
565
  norm = np.zeros_like(a, dtype=np.float32)
1182
566
  if np.any(finite):
1183
- norm[finite] = (a[finite] - vmin) / rng
567
+ norm[finite] = (a[finite] - np.nanmin(a)) / rng
1184
568
  norm = np.clip(norm, 0, 1)
1185
569
  norm = np.power(norm * self.contrast, self.gamma)
1186
570
  cmap = getattr(cm, self.cmap_name, cm.viridis)
@@ -1208,11 +592,10 @@ class TiffViewer(QMainWindow):
1208
592
 
1209
593
  tif_path = self.tif_path
1210
594
 
1211
- if tif_path and os.path.dirname(self.tif_path).endswith(".gdb"):
595
+ if os.path.dirname(self.tif_path).endswith(".gdb"):
1212
596
  tif_path = f"OpenFileGDB:{os.path.dirname(self.tif_path)}:{os.path.basename(self.tif_path)}"
1213
597
 
1214
- import rasterio as rio_module
1215
- with rio_module.open(tif_path) as src:
598
+ with rasterio.open(tif_path) as src:
1216
599
  self.band = band_num
1217
600
  arr = src.read(self.band).astype(np.float32)
1218
601
  nd = src.nodata
@@ -1255,14 +638,7 @@ class TiffViewer(QMainWindow):
1255
638
 
1256
639
  # Colormap toggle (single-band only)
1257
640
  elif not self.rgb_mode and k == Qt.Key.Key_M:
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
641
+ self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
1266
642
  self.update_pixmap()
1267
643
 
1268
644
  # Band switch
@@ -1283,23 +659,6 @@ class TiffViewer(QMainWindow):
1283
659
  elif not self.rgb_mode: # GeoTIFF single-band mode
1284
660
  new_band = self.band - 1 if self.band > 1 else self.band_count
1285
661
  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}")
1303
662
 
1304
663
  elif k == Qt.Key.Key_R:
1305
664
  self.contrast = 1.0
@@ -1311,8 +670,8 @@ class TiffViewer(QMainWindow):
1311
670
  super().keyPressEvent(ev)
1312
671
 
1313
672
 
1314
- # --------------------------------- CLI ----------------------------------- #
1315
- def main():
673
+ # --------------------------------- Legacy argparse CLI (not used by default) ----------------------------------- #
674
+ def legacy_argparse_main():
1316
675
  parser = argparse.ArgumentParser(description="TIFF viewer with RGB (2–98%) & shapefile overlays")
1317
676
  parser.add_argument("tif_path", nargs="?", help="Path to TIFF (optional if --rgbfiles is used)")
1318
677
  parser.add_argument("--scale", type=int, default=1, help="Downsample factor (1=full, 10=10x smaller)")
@@ -1324,6 +683,8 @@ def main():
1324
683
  parser.add_argument("--shp-width", type=float, default=1.5, help="Overlay line width (screen pixels). Default: 1.5")
1325
684
  args = parser.parse_args()
1326
685
 
686
+ from PySide6.QtCore import Qt
687
+ QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
1327
688
  app = QApplication(sys.argv)
1328
689
  win = TiffViewer(
1329
690
  args.tif_path,
@@ -1348,9 +709,12 @@ def run_viewer(
1348
709
  shapefile=None,
1349
710
  shp_color=None,
1350
711
  shp_width=None,
1351
- subset=None,
712
+ subset=None
1352
713
  ):
714
+
1353
715
  """Launch the TiffViewer app"""
716
+ from PySide6.QtCore import Qt
717
+ QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
1354
718
  app = QApplication(sys.argv)
1355
719
  win = TiffViewer(
1356
720
  tif_path,
@@ -1361,7 +725,7 @@ def run_viewer(
1361
725
  shapefiles=shapefile,
1362
726
  shp_color=shp_color,
1363
727
  shp_width=shp_width,
1364
- subset=subset,
728
+ subset=subset
1365
729
  )
1366
730
  win.show()
1367
731
  sys.exit(app.exec())
@@ -1369,18 +733,19 @@ def run_viewer(
1369
733
  import click
1370
734
 
1371
735
  @click.command()
1372
- @click.version_option("0.2.0", prog_name="viewtif")
736
+ @click.version_option("0.2.1", prog_name="viewtif")
1373
737
  @click.argument("tif_path", required=False)
1374
738
  @click.option("--band", default=1, show_default=True, type=int, help="Band number to display")
1375
- @click.option("--scale", default=1.0, show_default=True, type=float, help="Scale factor for display")
739
+ @click.option("--scale", default=1.0, show_default=True, type=int, help="Scale factor for display")
1376
740
  @click.option("--rgb", nargs=3, type=int, help="Three band numbers for RGB, e.g. --rgb 4 3 2")
1377
741
  @click.option("--rgbfiles", nargs=3, type=str, help="Three single-band TIFFs for RGB, e.g. --rgbfiles B4.tif B3.tif B2.tif")
1378
742
  @click.option("--shapefile", multiple=True, type=str, help="One or more shapefiles to overlay")
1379
743
  @click.option("--shp-color", default="white", show_default=True, help="Overlay color (name or #RRGGBB).")
1380
744
  @click.option("--shp-width", default=1.0, show_default=True, type=float, help="Overlay line width (screen pixels).")
1381
- @click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file or variable in NetCDF file")
745
+ @click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file")
746
+
1382
747
  def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width, subset):
1383
- """Lightweight GeoTIFF, NetCDF, and HDF viewer."""
748
+ """Lightweight GeoTIFF viewer."""
1384
749
  # --- Warn early if shapefile requested but geopandas missing ---
1385
750
  if shapefile and not HAVE_GEO:
1386
751
  print(
@@ -1398,8 +763,9 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
1398
763
  shapefile=shapefile,
1399
764
  shp_color=shp_color,
1400
765
  shp_width=shp_width,
1401
- subset=subset,
766
+ subset=subset
1402
767
  )
1403
768
 
1404
769
  if __name__ == "__main__":
1405
- main()
770
+ main()
771
+
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: viewtif
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].
3
+ Version: 0.2.1
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
7
7
  Project-URL: Issues, https://github.com/nkeikon/tifviewer/issues
@@ -16,27 +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'
24
19
  Description-Content-Type: text/markdown
25
20
 
26
21
  # viewtif
27
22
  [![Downloads](https://static.pepy.tech/badge/viewtif)](https://pepy.tech/project/viewtif)
28
23
  [![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/)
29
25
 
30
26
  A lightweight GeoTIFF viewer for quick visualization directly from the command line.
31
27
 
32
- You can visualize single-band GeoTIFFs, RGB composites, and shapefile overlays in a simple Qt-based window.
33
-
34
- ---
35
-
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))
38
-
39
- ---
28
+ You can visualize single-band GeoTIFFs, RGB composites, HDF, NetCDF files and shapefile overlays in a simple Qt-based window.
40
29
 
41
30
  ## Installation
42
31
 
@@ -56,7 +45,7 @@ pip install "viewtif[geo]"
56
45
  > **Note:** For macOS(zsh) users:
57
46
  > Make sure to include the quotes, or zsh will interpret it as a pattern.
58
47
 
59
- #### HDF/HDF5 support
48
+ #### HDF/HDF5 support
60
49
  ```bash
61
50
  brew install gdal # macOS
62
51
  sudo apt install gdal-bin python3-gdal # Linux
@@ -95,8 +84,14 @@ viewtif AG100.v003.33.-107.0001.h5 --subset 1 --band 3
95
84
  `[WARN] raster lacks CRS/transform; cannot place overlays.`
96
85
 
97
86
  ### Update in v1.0.7: File Geodatabase (.gdb) support
98
- `viewtif` can now open raster datasets stored inside Esri File Geodatabases (`.gdb`), using the GDAL `OpenFileGDB` driver.
99
- When you open a .gdb directly, `viewtif`` will list available raster datasets first, then you can choose one to view.
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.
89
+
90
+ Most Rasterio installations already include the OpenFileGDB driver, so .gdb datasets often open without installing GDAL manually.
91
+
92
+ If you encounter:
93
+ RuntimeError: GDB support requires GDAL,
94
+ install GDAL as shown above to enable the driver.
100
95
 
101
96
  ```bash
102
97
  # List available raster datasets
@@ -105,33 +100,13 @@ viewtif /path/to/geodatabase.gdb
105
100
  # Open a specific raster
106
101
  viewtif "OpenFileGDB:/path/to/geodatabase.gdb:RasterName"
107
102
  ```
108
- > **Note:** Requires GDAL 3.7 or later with the OpenFileGDB driver enabled. If multiple raster datasets are present, viewtif lists them all and shows how to open each. The .gdb path and raster name must be separated by a colon (:).
103
+ > **Note:** If multiple raster datasets are present, viewtif lists them all and shows how to open each. The .gdb path and raster name must be separated by a colon (:).
109
104
 
110
105
  ### Update in v1.0.7: Large raster safeguard
111
106
  As of v1.0.7, `viewtif` automatically checks the raster size before loading.
112
107
  If the dataset is very large (e.g., >20 million pixels), it will pause and warn that loading may freeze your system.
113
108
  You can proceed manually or rerun with the `--scale` option for a smaller, faster preview.
114
109
 
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
-
135
110
  ## Controls
136
111
  | Key | Action |
137
112
  | -------------------- | --------------------------------------- |
@@ -140,12 +115,12 @@ viewtif data.nc
140
115
  | `C` / `V` | Increase / decrease contrast |
141
116
  | `G` / `H` | Increase / decrease gamma |
142
117
  | `M` | Toggle colormap (`viridis` ↔ `magma`) |
143
- | `[` / `]` | Previous / next band (single-band only) |
118
+ | `[` / `]` | Previous / next band (or time step) |
144
119
  | `R` | Reset view |
145
120
 
146
121
  ## Features
147
122
  - Command-line driven GeoTIFF viewer.
148
- - Supports single-band, RGB composite, and HDF/HDF5 subdatasets.
123
+ - Supports single-band, RGB composite, HDF/HDF5 subdatasets, and NetCDF.
149
124
  - Optional shapefile overlay for geographic context.
150
125
  - Adjustable contrast, gamma, and colormap.
151
126
  - Fast preview using rasterio and PySide6.
@@ -166,5 +141,5 @@ Longenecker, Jake; Lee, Christine; Hulley, Glynn; Cawse-Nicholson, Kerry; Purkis
166
141
  This project is released under the MIT License.
167
142
 
168
143
  ## Contributors
169
- - [@HarshShinde0](https://github.com/HarshShinde0) — added mouse-wheel and trackpad zoom support
144
+ - [@HarshShinde0](https://github.com/HarshShinde0) — added mouse-wheel and trackpad zoom support; added NetCDF support with [@nkeikon](https://github.com/nkeikon)
170
145
  - [@p-vdp](https://github.com/p-vdp) — added File Geodatabase (.gdb) raster support
@@ -0,0 +1,5 @@
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,,
@@ -1,5 +0,0 @@
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,,