viewtif 0.1.9__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
viewtif/tif_viewer.py CHANGED
@@ -34,10 +34,12 @@ import numpy as np
34
34
  import rasterio
35
35
  from rasterio.transform import Affine
36
36
  from PySide6.QtWidgets import (
37
- QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QScrollBar, QGraphicsPathItem
37
+ QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
38
+ QScrollBar, QGraphicsPathItem, QVBoxLayout, QHBoxLayout, QSlider, QLabel,
39
+ QWidget, QStatusBar, QPushButton, QComboBox
38
40
  )
39
41
  from PySide6.QtGui import QImage, QPixmap, QPainter, QPen, QColor, QPainterPath
40
- from PySide6.QtCore import Qt
42
+ from PySide6.QtCore import Qt, QDateTime
41
43
 
42
44
  import matplotlib.cm as cm
43
45
 
@@ -52,33 +54,69 @@ try:
52
54
  except Exception:
53
55
  HAVE_GEO = False
54
56
 
57
+ # Optional NetCDF deps (lazy-loaded when needed)
58
+ HAVE_NETCDF = False
59
+ xr = None
60
+ pd = None
61
+
62
+ # Optional cartopy deps for better map visualization (lazy-loaded when needed)
63
+ # Check if cartopy is available but don't import yet
64
+ try:
65
+ import importlib.util
66
+ HAVE_CARTOPY = importlib.util.find_spec("cartopy") is not None
67
+ except Exception:
68
+ HAVE_CARTOPY = False
69
+
55
70
  def warn_if_large(tif_path, scale=1):
56
- """Warn and confirm before loading very large rasters (GeoTIFF, GDB, or HDF)."""
57
- from osgeo import gdal
71
+ """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.
74
+ """
58
75
  import os
59
76
 
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
+
60
81
  try:
61
- gdal.UseExceptions()
62
- info = gdal.Info(tif_path, format="json")
63
- width, height = info.get("size", [0, 0])
64
- total_pixels = (width * height) / (scale ** 2) # account for downsampling
65
- size_mb = None
66
- if os.path.exists(tif_path):
67
- size_mb = os.path.getsize(tif_path) / (1024 ** 2)
68
-
69
- # Only warn if the *effective* pixels remain large
70
- if total_pixels > 20_000_000 and scale <= 5:
71
- print(
72
- f"[WARN] Large raster detected ({width}×{height}, ~{total_pixels/1e6:.1f}M effective pixels"
73
- + (f", ~{size_mb:.1f} MB" if size_mb else "")
74
- + "). Loading may freeze. Consider rerunning with --scale (e.g. --scale 10)."
75
- )
76
- ans = input("Proceed anyway? [y/N]: ").strip().lower()
77
- if ans not in ("y", "yes"):
78
- print("Cancelled.")
79
- sys.exit(0)
82
+ width, height = None, None
83
+
84
+ # Try GDAL first (supports more formats including GDB, HDF)
85
+ 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)
80
118
  except Exception as e:
81
- print(f"[WARN] Could not pre-check raster size: {e}")
119
+ print(f"[INFO] Could not pre-check raster size: {e}")
82
120
 
83
121
  # -------------------------- QGraphicsView tweaks -------------------------- #
84
122
  class RasterView(QGraphicsView):
@@ -152,7 +190,8 @@ class TiffViewer(QMainWindow):
152
190
  # --- Load data ---
153
191
  if rgbfiles:
154
192
  red, green, blue = rgbfiles
155
- with rasterio.open(red) as r, rasterio.open(green) as g, rasterio.open(blue) as b:
193
+ import rasterio as rio_module
194
+ with rio_module.open(red) as r, rio_module.open(green) as g, rio_module.open(blue) as b:
156
195
  if (r.width, r.height) != (g.width, g.height) or (r.width, r.height) != (b.width, b.height):
157
196
  raise ValueError("All RGB files must have the same dimensions.")
158
197
  arr = np.stack([
@@ -170,8 +209,149 @@ class TiffViewer(QMainWindow):
170
209
  self.tif_path = self.tif_path or (os.path.commonprefix([red, green, blue]) or red)
171
210
 
172
211
  elif tif_path:
212
+ # --------------------- Detect NetCDF --------------------- #
213
+ if tif_path and tif_path.lower().endswith((".nc", ".netcdf")):
214
+ try:
215
+ # Lazy-load NetCDF dependencies
216
+ import xarray as xr
217
+ import pandas as pd
218
+
219
+ # Open the NetCDF file
220
+ ds = xr.open_dataset(tif_path)
221
+
222
+ # List variables, filtering out boundary variables (ending with _bnds)
223
+ all_vars = list(ds.data_vars)
224
+ data_vars = [var for var in all_vars if not var.endswith('_bnds')]
225
+
226
+ # Auto-select the first variable if there's only one and no subset specified
227
+ if len(data_vars) == 1 and subset is None:
228
+ subset = 0
229
+ # Only list variables if --subset not given and multiple variables exist
230
+ elif subset is None:
231
+ sys.exit(0)
232
+
233
+ # Validate subset index
234
+ if subset < 0 or subset >= len(data_vars):
235
+ raise ValueError(f"Invalid variable index {subset}. Valid range: 0–{len(data_vars)-1}")
236
+
237
+ # Get the selected variable from filtered data_vars
238
+ var_name = data_vars[subset]
239
+ var_data = ds[var_name]
240
+
241
+ # Store original dataset and variable information for better visualization
242
+ self._nc_dataset = ds
243
+ self._nc_var_name = var_name
244
+ self._nc_var_data = var_data
245
+
246
+ # Get coordinate info if available
247
+ self._has_geo_coords = False
248
+ if 'lon' in ds.coords and 'lat' in ds.coords:
249
+ self._has_geo_coords = True
250
+ self._lon_data = ds.lon.values
251
+ self._lat_data = ds.lat.values
252
+ elif 'longitude' in ds.coords and 'latitude' in ds.coords:
253
+ self._has_geo_coords = True
254
+ self._lon_data = ds.longitude.values
255
+ self._lat_data = ds.latitude.values
256
+
257
+ # Handle time or other index dimension if present
258
+ self._has_time_dim = False
259
+ self._time_dim_name = None
260
+ time_index = 0
261
+
262
+ # Look for a time dimension first
263
+ if 'time' in var_data.dims:
264
+ self._has_time_dim = True
265
+ self._time_dim_name = 'time'
266
+ self._time_values = ds.time.values
267
+ self._time_index = time_index
268
+
269
+ # Try to format time values for better display
270
+ time_units = getattr(ds.time, 'units', None)
271
+ time_calendar = getattr(ds.time, 'calendar', 'standard')
272
+
273
+ # Select first time step by default
274
+ var_data = var_data.isel(time=time_index)
275
+
276
+ # If no time dimension but variable has multiple dimensions,
277
+ # use the first non-spatial dimension as a "time" dimension
278
+ elif len(var_data.dims) > 2:
279
+ # Try to find a dimension that's not lat/lon
280
+ spatial_dims = ['lat', 'lon', 'latitude', 'longitude', 'y', 'x']
281
+ for dim in var_data.dims:
282
+ if dim not in spatial_dims:
283
+ self._has_time_dim = True
284
+ self._time_dim_name = dim
285
+ self._time_values = ds[dim].values
286
+ self._time_index = time_index
287
+
288
+ # Select first index by default
289
+ var_data = var_data.isel({dim: time_index})
290
+ break
291
+
292
+ # Convert to numpy array
293
+ arr = var_data.values.astype(np.float32)
294
+
295
+ # Process array based on dimensions
296
+ if arr.ndim > 2:
297
+ # Keep only lat/lon dimensions for 3D+ arrays
298
+ arr = np.squeeze(arr)
299
+
300
+ # --- Downsample large arrays for responsiveness ---
301
+ if arr.ndim >= 2:
302
+ h, w = arr.shape[:2]
303
+ if h * w > 4_000_000:
304
+ step = max(2, int((h * w / 4_000_000) ** 0.5))
305
+ if arr.ndim == 2:
306
+ arr = arr[::step, ::step]
307
+ else:
308
+ arr = arr[::step, ::step, :]
309
+
310
+ # --- Final assignments ---
311
+ self.data = arr
312
+
313
+ # Try to extract CRS from CF conventions
314
+ self._transform = None
315
+ self._crs = None
316
+ if 'crs' in ds.variables:
317
+ try:
318
+ import rasterio.crs
319
+ crs_var = ds.variables['crs']
320
+ if hasattr(crs_var, 'spatial_ref'):
321
+ self._crs = rasterio.crs.CRS.from_wkt(crs_var.spatial_ref)
322
+ except Exception as e:
323
+ print(f"Could not parse CRS: {e}")
324
+
325
+ # Set band info
326
+ if arr.ndim == 3:
327
+ self.band_count = arr.shape[2]
328
+ else:
329
+ self.band_count = 1
330
+
331
+ self.band_index = 0
332
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
333
+
334
+ # --- If user specified --band, start there ---
335
+ if self.band and self.band <= self.band_count:
336
+ self.band_index = self.band - 1
337
+
338
+ # Enable cartopy visualization if available
339
+ self._use_cartopy = HAVE_CARTOPY and self._has_geo_coords
340
+
341
+ except ImportError as e:
342
+ if "xarray" in str(e) or "netCDF4" in str(e):
343
+ raise RuntimeError(
344
+ f"NetCDF support requires additional dependencies.\n"
345
+ f"Install them with: pip install viewtif[netcdf]\n"
346
+ f"Original error: {str(e)}"
347
+ )
348
+ else:
349
+ raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
350
+ except Exception as e:
351
+ raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
352
+
173
353
  # ---------------- Handle File Geodatabase (.gdb) ---------------- #
174
- if tif_path.lower().endswith(".gdb") and ":" not in tif_path:
354
+ if tif_path and tif_path.lower().endswith(".gdb") and ":" not in tif_path:
175
355
  import re, subprocess
176
356
  gdb_path = tif_path # use full path to .gdb
177
357
  try:
@@ -192,9 +372,13 @@ class TiffViewer(QMainWindow):
192
372
  sys.exit(0)
193
373
  # --- Universal size check before loading ---
194
374
  warn_if_large(tif_path, scale=self._scale_arg)
375
+
376
+ if False: # Placeholder for previous if condition
377
+ pass
195
378
  # --------------------- Detect HDF/HDF5 --------------------- #
196
- if tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
379
+ elif tif_path and tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
197
380
  try:
381
+ # Try GDAL first (best support for HDF subdatasets)
198
382
  from osgeo import gdal
199
383
  gdal.UseExceptions()
200
384
 
@@ -241,7 +425,6 @@ class TiffViewer(QMainWindow):
241
425
  if h * w > 4_000_000:
242
426
  step = max(2, int((h * w / 4_000_000) ** 0.5))
243
427
  arr = arr[::step, ::step] if arr.ndim == 2 else arr[::step, ::step, :]
244
- print(f"⚠️ Large dataset preview: downsampled by {step}x")
245
428
 
246
429
  # --- Final assignments ---
247
430
  self.data = arr
@@ -264,17 +447,70 @@ class TiffViewer(QMainWindow):
264
447
  self.band_index = 0
265
448
 
266
449
  except ImportError:
267
- raise RuntimeError(
268
- "HDF support requires GDAL.\n"
269
- "Install it first (e.g., brew install gdal && pip install GDAL)"
270
- )
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:
502
+ raise RuntimeError(
503
+ f"Failed to read HDF/NetCDF file: {e}\n"
504
+ "For full HDF support, install GDAL: pip install GDAL"
505
+ )
271
506
 
272
507
  # --------------------- Regular GeoTIFF --------------------- #
273
508
  else:
274
- if os.path.dirname(tif_path).endswith(".gdb"):
509
+ if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
275
510
  tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
276
511
 
277
- with rasterio.open(tif_path) as src:
512
+ import rasterio as rio_module
513
+ with rio_module.open(tif_path) as src:
278
514
  self._transform = src.transform
279
515
  self._crs = src.crs
280
516
  if rgb is not None:
@@ -318,16 +554,92 @@ class TiffViewer(QMainWindow):
318
554
  self.gamma = 1.0
319
555
 
320
556
  # Colormap (single-band)
321
- self.cmap_name = "viridis"
322
- self.alt_cmap_name = "magma" # toggle with M in single-band
557
+ # For NetCDF temperature data, have three colormaps in rotation
558
+ if tif_path and tif_path.lower().endswith(('.nc', '.netcdf')):
559
+ self.cmap_names = ["RdBu_r", "viridis", "magma"] # three colormaps for NetCDF
560
+ self.cmap_index = 0 # start with RdBu_r
561
+ self.cmap_name = self.cmap_names[self.cmap_index]
562
+ else:
563
+ self.cmap_name = "viridis"
564
+ self.alt_cmap_name = "magma" # toggle with M in single-band
323
565
 
324
566
  self.zoom_step = 1.2
325
567
  self.pan_step = 80
326
568
 
569
+ # Create main widget and layout
570
+ self.main_widget = QWidget()
571
+ self.main_layout = QVBoxLayout(self.main_widget)
572
+ self.main_layout.setContentsMargins(0, 0, 0, 0)
573
+ self.main_layout.setSpacing(0)
574
+
575
+ # Add time navigation controls at the top for NetCDF files
576
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
577
+ self.time_layout = QHBoxLayout()
578
+ self.time_layout.setContentsMargins(10, 5, 10, 5)
579
+
580
+ # Navigation buttons
581
+ self.prev_button = QPushButton("<<")
582
+ self.prev_button.setToolTip("Previous time step")
583
+ self.prev_button.clicked.connect(self.prev_time_step)
584
+ self.prev_button.setFixedWidth(40)
585
+
586
+ # Play/Pause button
587
+ self.play_button = QPushButton("▶") # Play symbol
588
+ self.play_button.setToolTip("Play/Pause time animation")
589
+ self.play_button.clicked.connect(self.toggle_play_pause)
590
+ self.play_button.setFixedWidth(40)
591
+ self._is_playing = False
592
+ self._play_timer = None
593
+
594
+ self.next_button = QPushButton(">>")
595
+ self.next_button.setToolTip("Next time step")
596
+ self.next_button.clicked.connect(self.next_time_step)
597
+ self.next_button.setFixedWidth(40)
598
+
599
+ # Date/time label
600
+ self.time_label = QLabel()
601
+ self.time_label.setMinimumWidth(200)
602
+
603
+ # Time slider
604
+ self.time_slider = QSlider(Qt.Orientation.Horizontal)
605
+ self.time_slider.setMinimum(0)
606
+ self.time_slider.setMaximum(len(self._time_values) - 1)
607
+ self.time_slider.setValue(self._time_index)
608
+ self.time_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
609
+ self.time_slider.setTickInterval(max(1, len(self._time_values) // 10))
610
+ self.time_slider.valueChanged.connect(self.time_slider_changed)
611
+
612
+ # Date time combo box
613
+ self.date_combo = QComboBox()
614
+ self.populate_date_combo()
615
+ self.date_combo.currentIndexChanged.connect(self.date_combo_changed)
616
+ self.date_combo.setFixedWidth(200)
617
+
618
+ # Add controls to layout
619
+ self.time_layout.addWidget(self.prev_button)
620
+ self.time_layout.addWidget(self.play_button)
621
+ self.time_layout.addWidget(self.time_label)
622
+ self.time_layout.addWidget(self.time_slider, 1) # 1 = stretch factor
623
+ self.time_layout.addWidget(self.next_button)
624
+ self.time_layout.addWidget(QLabel("Jump to:"))
625
+ self.time_layout.addWidget(self.date_combo)
626
+
627
+ # Add time controls to main layout at the top
628
+ self.main_layout.addLayout(self.time_layout)
629
+
630
+ # Update time label
631
+ self.update_time_label()
632
+
327
633
  # Scene + view
328
634
  self.scene = QGraphicsScene(self)
329
635
  self.view = RasterView(self.scene, self)
330
- self.setCentralWidget(self.view)
636
+ self.main_layout.addWidget(self.view)
637
+
638
+ # Status bar
639
+ self.setStatusBar(QStatusBar())
640
+
641
+ # Set central widget
642
+ self.setCentralWidget(self.main_widget)
331
643
 
332
644
  self.pixmap_item = None
333
645
  self._last_rgb = None
@@ -474,11 +786,237 @@ class TiffViewer(QMainWindow):
474
786
  elif self.rgb_mode and self.rgb:
475
787
  self.setWindowTitle(f"RGB {self.rgb} — {os.path.basename(self.tif_path)}")
476
788
  elif hasattr(self, "band_index"):
477
- self.setWindowTitle(
478
- f"Band {self.band_index + 1}/{self.band_count} — {os.path.basename(self.tif_path)}"
479
- )
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)
480
801
  else:
481
802
  self.setWindowTitle(f"Band {self.band}/{self.band_count} — {os.path.basename(self.tif_path)}")
803
+
804
+ def format_time_value(self, time_value):
805
+ """Format a time value into a user-friendly string"""
806
+ # Default is the string representation
807
+ time_str = str(time_value)
808
+
809
+ try:
810
+ # Handle numpy datetime64
811
+ if hasattr(time_value, 'dtype') and np.issubdtype(time_value.dtype, np.datetime64):
812
+ # Lazy-load pandas for timestamp conversion
813
+ import pandas as pd
814
+ # Convert to Python datetime if possible
815
+ dt = pd.Timestamp(time_value).to_pydatetime()
816
+ time_str = dt.strftime('%Y-%m-%d %H:%M:%S')
817
+ # Handle native Python datetime
818
+ elif hasattr(time_value, 'strftime'):
819
+ time_str = time_value.strftime('%Y-%m-%d %H:%M:%S')
820
+ # Handle cftime datetime-like objects used in some NetCDF files
821
+ elif hasattr(time_value, 'isoformat'):
822
+ time_str = time_value.isoformat().replace('T', ' ')
823
+ except Exception:
824
+ # Fall back to string representation
825
+ pass
826
+
827
+ return time_str
828
+
829
+ def update_time_label(self):
830
+ """Update the time label with the current time value"""
831
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
832
+ try:
833
+ time_value = self._time_values[self._time_index]
834
+ time_str = self.format_time_value(time_value)
835
+
836
+ # Update time label if it exists
837
+ if hasattr(self, 'time_label'):
838
+ self.time_label.setText(f"Time: {time_str}")
839
+
840
+ # Create a progress bar style display of time position
841
+ total = len(self._time_values)
842
+ position = self._time_index + 1
843
+ bar_width = 20 # Width of the progress bar
844
+ filled = int(bar_width * position / total)
845
+ bar = "[" + "#" * filled + "-" * (bar_width - filled) + "]"
846
+
847
+ # Show time info in status bar
848
+ step_info = f"Time step: {position}/{total} {bar} {self.format_time_value(self._time_values[self._time_index])}"
849
+
850
+ # Update status bar if it exists
851
+ if hasattr(self, 'statusBar') and callable(self.statusBar):
852
+ self.statusBar().showMessage(step_info)
853
+ else:
854
+ print(step_info)
855
+ except Exception as e:
856
+ print(f"Error updating time label: {e}")
857
+
858
+ def time_slider_changed(self, value):
859
+ """Handle time slider value change"""
860
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
861
+ self._time_index = value
862
+
863
+ # Update data for new time step
864
+ if self._time_dim_name:
865
+ # Use the named dimension (time or other index)
866
+ var_data = self._nc_var_data.isel({self._time_dim_name: self._time_index})
867
+ else:
868
+ # Fallback to 'time' dimension
869
+ var_data = self._nc_var_data.isel(time=self._time_index)
870
+
871
+ self.data = var_data.values.astype(np.float32)
872
+
873
+ # Find and select the matching date in the combo box (if it exists)
874
+ if hasattr(self, 'date_combo'):
875
+ try:
876
+ for i in range(self.date_combo.count()):
877
+ if self.date_combo.itemData(i) == value:
878
+ self.date_combo.blockSignals(True) # Block signals to avoid recursion
879
+ self.date_combo.setCurrentIndex(i)
880
+ self.date_combo.blockSignals(False)
881
+ break
882
+ except Exception as e:
883
+ print(f"Error updating combo box: {e}")
884
+
885
+ # Update UI
886
+ self.update_time_label()
887
+ self.update_pixmap()
888
+ self.update_title()
889
+
890
+ def next_time_step(self):
891
+ """Go to the next time step"""
892
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
893
+ try:
894
+ next_time = (self._time_index + 1) % len(self._time_values)
895
+ if hasattr(self, 'time_slider'):
896
+ self.time_slider.setValue(next_time)
897
+ else:
898
+ # Direct update if slider doesn't exist
899
+ self._time_index = next_time
900
+ self.update_time_data()
901
+ except Exception as e:
902
+ print(f"Error going to next time step: {e}")
903
+
904
+ def prev_time_step(self):
905
+ """Go to the previous time step"""
906
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
907
+ try:
908
+ prev_time = (self._time_index - 1) % len(self._time_values)
909
+ if hasattr(self, 'time_slider'):
910
+ self.time_slider.setValue(prev_time)
911
+ else:
912
+ # Direct update if slider doesn't exist
913
+ self._time_index = prev_time
914
+ self.update_time_data()
915
+ except Exception as e:
916
+ print(f"Error going to previous time step: {e}")
917
+
918
+ def update_time_data(self):
919
+ """Update data for the current time index"""
920
+ try:
921
+ # Update data for current time step
922
+ if self._time_dim_name:
923
+ # Use the named dimension (time or other index)
924
+ var_data = self._nc_var_data.isel({self._time_dim_name: self._time_index})
925
+ else:
926
+ # Fallback to 'time' dimension
927
+ var_data = self._nc_var_data.isel(time=self._time_index)
928
+
929
+ self.data = var_data.values.astype(np.float32)
930
+
931
+ # Update UI
932
+ self.update_time_label()
933
+ self.update_pixmap()
934
+ self.update_title()
935
+ except Exception as e:
936
+ print(f"Error updating time data: {e}")
937
+
938
+ def toggle_play_pause(self):
939
+ """Toggle play/pause animation of time steps"""
940
+ if self._is_playing:
941
+ self.stop_animation()
942
+ else:
943
+ self.start_animation()
944
+
945
+ def start_animation(self):
946
+ """Start the time animation"""
947
+ from PySide6.QtCore import QTimer
948
+
949
+ if not hasattr(self, '_play_timer') or self._play_timer is None:
950
+ self._play_timer = QTimer(self)
951
+ self._play_timer.timeout.connect(self.animation_step)
952
+
953
+ # Set animation speed (milliseconds between frames)
954
+ animation_speed = 500 # 0.5 seconds between frames
955
+ self._play_timer.start(animation_speed)
956
+
957
+ self._is_playing = True
958
+ self.play_button.setText("⏸") # Pause symbol
959
+ self.play_button.setToolTip("Pause animation")
960
+
961
+ def stop_animation(self):
962
+ """Stop the time animation"""
963
+ if hasattr(self, '_play_timer') and self._play_timer is not None:
964
+ self._play_timer.stop()
965
+
966
+ self._is_playing = False
967
+ self.play_button.setText("▶") # Play symbol
968
+ self.play_button.setToolTip("Play animation")
969
+
970
+ def animation_step(self):
971
+ """Advance one frame in the animation"""
972
+ # Go to next time step
973
+ next_time = (self._time_index + 1) % len(self._time_values)
974
+ self.time_slider.setValue(next_time)
975
+
976
+ def closeEvent(self, event):
977
+ """Clean up resources when the window is closed"""
978
+ # Stop animation timer if it's running
979
+ if hasattr(self, '_is_playing') and self._is_playing:
980
+ self.stop_animation()
981
+
982
+ # Call the parent class closeEvent
983
+ super().closeEvent(event)
984
+
985
+ def populate_date_combo(self):
986
+ """Populate the date combo box with time values"""
987
+ if hasattr(self, '_has_time_dim') and self._has_time_dim and hasattr(self, 'date_combo'):
988
+ try:
989
+ self.date_combo.clear()
990
+
991
+ # Add a reasonable subset of dates if there are too many
992
+ max_items = 100 # Maximum number of items to show in dropdown
993
+
994
+ if len(self._time_values) <= max_items:
995
+ # Add all time values
996
+ for i, time_value in enumerate(self._time_values):
997
+ time_str = self.format_time_value(time_value)
998
+ self.date_combo.addItem(time_str, i)
999
+ else:
1000
+ # Add a subset of time values
1001
+ step = len(self._time_values) // max_items
1002
+
1003
+ # Always include first and last
1004
+ indices = list(range(0, len(self._time_values), step))
1005
+ if (len(self._time_values) - 1) not in indices:
1006
+ indices.append(len(self._time_values) - 1)
1007
+
1008
+ for i in indices:
1009
+ time_str = self.format_time_value(self._time_values[i])
1010
+ self.date_combo.addItem(f"{time_str} [{i+1}/{len(self._time_values)}]", i)
1011
+ except Exception as e:
1012
+ print(f"Error populating date combo: {e}")
1013
+
1014
+ def date_combo_changed(self, index):
1015
+ """Handle date combo box selection change"""
1016
+ if index >= 0:
1017
+ time_index = self.date_combo.itemData(index)
1018
+ if time_index is not None:
1019
+ self.time_slider.setValue(time_index)
482
1020
 
483
1021
  def _render_rgb(self):
484
1022
  if self.rgb_mode:
@@ -508,6 +1046,104 @@ class TiffViewer(QMainWindow):
508
1046
  rgb = (cmap(norm)[..., :3] * 255).astype(np.uint8)
509
1047
  return rgb
510
1048
 
1049
+ def _render_cartopy_map(self, data):
1050
+ """Render a NetCDF variable with cartopy for better geographic visualization"""
1051
+ import matplotlib.pyplot as plt
1052
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
1053
+ import cartopy.crs as ccrs
1054
+ import cartopy.feature as cfeature
1055
+
1056
+ # Create a new figure with cartopy projection
1057
+ fig = plt.figure(figsize=(12, 8), dpi=100)
1058
+ ax = plt.axes(projection=ccrs.PlateCarree())
1059
+
1060
+ # Get coordinates
1061
+ lons = self._lon_data
1062
+ lats = self._lat_data
1063
+
1064
+ # Create contour plot
1065
+ levels = 20
1066
+ if hasattr(plt.cm, self.cmap_name):
1067
+ cmap = getattr(plt.cm, self.cmap_name)
1068
+ else:
1069
+ cmap = getattr(cm, self.cmap_name, cm.viridis)
1070
+
1071
+ # Apply contrast and gamma adjustments
1072
+ finite = np.isfinite(data)
1073
+ norm_data = np.zeros_like(data, dtype=np.float32)
1074
+ vmin, vmax = np.nanmin(data), np.nanmax(data)
1075
+ rng = max(vmax - vmin, 1e-12)
1076
+
1077
+ if np.any(finite):
1078
+ norm_data[finite] = (data[finite] - vmin) / rng
1079
+
1080
+ norm_data = np.clip(norm_data * self.contrast, 0.0, 1.0)
1081
+ norm_data = np.power(norm_data, self.gamma)
1082
+ norm_data = norm_data * rng + vmin
1083
+
1084
+ # Downsample coordinates to match downsampled data shape
1085
+ # data shape is (lat_samples, lon_samples) after downsampling
1086
+ data_height, data_width = data.shape[:2]
1087
+ lat_samples = len(lats)
1088
+ lon_samples = len(lons)
1089
+
1090
+ # Calculate downsampling step if needed
1091
+ lat_step = max(1, lat_samples // data_height)
1092
+ lon_step = max(1, lon_samples // data_width)
1093
+
1094
+ # Downsample coordinate arrays to match data
1095
+ lats_downsampled = lats[::lat_step][:data_height]
1096
+ lons_downsampled = lons[::lon_step][:data_width]
1097
+
1098
+ # Convert 0-360 longitude to -180 to 180 if needed
1099
+ if lons_downsampled.max() > 180:
1100
+ lons_downsampled = ((lons_downsampled + 180) % 360) - 180
1101
+
1102
+ # Create 2D meshgrid for proper coordinate alignment with cartopy
1103
+ lon_grid, lat_grid = np.meshgrid(lons_downsampled, lats_downsampled, indexing='xy')
1104
+
1105
+ # Create the plot with 2D meshgrid for proper coordinate alignment
1106
+ contour = ax.contourf(lon_grid, lat_grid, data,
1107
+ transform=ccrs.PlateCarree(),
1108
+ levels=levels, cmap=cmap)
1109
+
1110
+ # Set map extent based on actual downsampled coordinates
1111
+ lon_min, lon_max = lons_downsampled.min(), lons_downsampled.max()
1112
+ lat_min, lat_max = lats_downsampled.min(), lats_downsampled.max()
1113
+ ax.set_extent([lon_min, lon_max, lat_min, lat_max], crs=ccrs.PlateCarree())
1114
+
1115
+ # Add map features
1116
+ ax.coastlines(resolution='50m', linewidth=0.5)
1117
+ ax.add_feature(cfeature.BORDERS, linestyle=':', linewidth=0.5)
1118
+ ax.add_feature(cfeature.STATES, linestyle='-', linewidth=0.3, alpha=0.5)
1119
+ ax.gridlines(draw_labels=True, alpha=0.3)
1120
+
1121
+ # Add title with variable name and time if available
1122
+ title = f"{self._nc_var_name}"
1123
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
1124
+ time_str = str(self._time_values[self._time_index])
1125
+ title += f"\n{time_str}"
1126
+ ax.set_title(title)
1127
+
1128
+ # Add colorbar
1129
+ plt.colorbar(contour, ax=ax, shrink=0.6)
1130
+
1131
+ plt.tight_layout()
1132
+
1133
+ # Convert matplotlib figure to image
1134
+ canvas = FigureCanvasAgg(fig)
1135
+ canvas.draw()
1136
+ width, height = fig.canvas.get_width_height()
1137
+ rgba = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(height, width, 4)
1138
+
1139
+ # Extract RGB and ensure it's C-contiguous for QImage
1140
+ rgb = np.ascontiguousarray(rgba[:, :, :3])
1141
+
1142
+ # Close figure to prevent memory leak
1143
+ plt.close(fig)
1144
+
1145
+ return rgb
1146
+
511
1147
  def update_pixmap(self):
512
1148
  # --- Select display data ---
513
1149
  if hasattr(self, "band_index"):
@@ -528,13 +1164,23 @@ class TiffViewer(QMainWindow):
528
1164
  # ----------------------------
529
1165
 
530
1166
  # --- Render image ---
531
- if rgb is None:
532
- # Grayscale rendering for single-band (scientific) data
1167
+ # Check if we should use cartopy for NetCDF visualization
1168
+ use_cartopy = False
1169
+ if hasattr(self, '_use_cartopy') and self._use_cartopy and HAVE_CARTOPY:
1170
+ if hasattr(self, '_has_geo_coords') and self._has_geo_coords:
1171
+ use_cartopy = True
1172
+
1173
+ if use_cartopy:
1174
+ # Render with cartopy for better geographic visualization
1175
+ rgb = self._render_cartopy_map(a)
1176
+ elif rgb is None:
1177
+ # Standard grayscale rendering for single-band (scientific) data
533
1178
  finite = np.isfinite(a)
534
- rng = max(np.nanmax(a) - np.nanmin(a), 1e-12)
1179
+ vmin, vmax = np.nanmin(a), np.nanmax(a)
1180
+ rng = max(vmax - vmin, 1e-12)
535
1181
  norm = np.zeros_like(a, dtype=np.float32)
536
1182
  if np.any(finite):
537
- norm[finite] = (a[finite] - np.nanmin(a)) / rng
1183
+ norm[finite] = (a[finite] - vmin) / rng
538
1184
  norm = np.clip(norm, 0, 1)
539
1185
  norm = np.power(norm * self.contrast, self.gamma)
540
1186
  cmap = getattr(cm, self.cmap_name, cm.viridis)
@@ -562,10 +1208,11 @@ class TiffViewer(QMainWindow):
562
1208
 
563
1209
  tif_path = self.tif_path
564
1210
 
565
- if os.path.dirname(self.tif_path).endswith(".gdb"):
1211
+ if tif_path and os.path.dirname(self.tif_path).endswith(".gdb"):
566
1212
  tif_path = f"OpenFileGDB:{os.path.dirname(self.tif_path)}:{os.path.basename(self.tif_path)}"
567
1213
 
568
- with rasterio.open(tif_path) as src:
1214
+ import rasterio as rio_module
1215
+ with rio_module.open(tif_path) as src:
569
1216
  self.band = band_num
570
1217
  arr = src.read(self.band).astype(np.float32)
571
1218
  nd = src.nodata
@@ -608,7 +1255,14 @@ class TiffViewer(QMainWindow):
608
1255
 
609
1256
  # Colormap toggle (single-band only)
610
1257
  elif not self.rgb_mode and k == Qt.Key.Key_M:
611
- self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
1258
+ # For NetCDF files, cycle through three colormaps
1259
+ if hasattr(self, 'cmap_names'):
1260
+ self.cmap_index = (self.cmap_index + 1) % len(self.cmap_names)
1261
+ self.cmap_name = self.cmap_names[self.cmap_index]
1262
+ print(f"Colormap: {self.cmap_name}")
1263
+ # For other files, toggle between two colormaps
1264
+ else:
1265
+ self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
612
1266
  self.update_pixmap()
613
1267
 
614
1268
  # Band switch
@@ -629,6 +1283,23 @@ class TiffViewer(QMainWindow):
629
1283
  elif not self.rgb_mode: # GeoTIFF single-band mode
630
1284
  new_band = self.band - 1 if self.band > 1 else self.band_count
631
1285
  self.load_band(new_band)
1286
+
1287
+ # NetCDF time/dimension navigation with Page Up/Down
1288
+ elif k == Qt.Key.Key_PageUp:
1289
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
1290
+ try:
1291
+ # Call the next_time_step method
1292
+ self.next_time_step()
1293
+ except Exception as e:
1294
+ print(f"Error handling PageUp: {e}")
1295
+
1296
+ elif k == Qt.Key.Key_PageDown:
1297
+ if hasattr(self, '_has_time_dim') and self._has_time_dim:
1298
+ try:
1299
+ # Call the prev_time_step method
1300
+ self.prev_time_step()
1301
+ except Exception as e:
1302
+ print(f"Error handling PageDown: {e}")
632
1303
 
633
1304
  elif k == Qt.Key.Key_R:
634
1305
  self.contrast = 1.0
@@ -698,7 +1369,7 @@ def run_viewer(
698
1369
  import click
699
1370
 
700
1371
  @click.command()
701
- @click.version_option("1.0.9", prog_name="viewtif")
1372
+ @click.version_option("0.2.0", prog_name="viewtif")
702
1373
  @click.argument("tif_path", required=False)
703
1374
  @click.option("--band", default=1, show_default=True, type=int, help="Band number to display")
704
1375
  @click.option("--scale", default=1.0, show_default=True, type=float, help="Scale factor for display")
@@ -707,9 +1378,9 @@ import click
707
1378
  @click.option("--shapefile", multiple=True, type=str, help="One or more shapefiles to overlay")
708
1379
  @click.option("--shp-color", default="white", show_default=True, help="Overlay color (name or #RRGGBB).")
709
1380
  @click.option("--shp-width", default=1.0, show_default=True, type=float, help="Overlay line width (screen pixels).")
710
- @click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file")
1381
+ @click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file or variable in NetCDF file")
711
1382
  def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width, subset):
712
- """Lightweight GeoTIFF viewer."""
1383
+ """Lightweight GeoTIFF, NetCDF, and HDF viewer."""
713
1384
  # --- Warn early if shapefile requested but geopandas missing ---
714
1385
  if shapefile and not HAVE_GEO:
715
1386
  print(
@@ -731,5 +1402,4 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
731
1402
  )
732
1403
 
733
1404
  if __name__ == "__main__":
734
- main()
735
-
1405
+ main()
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: viewtif
3
- Version: 0.1.9
4
- Summary: Lightweight GeoTIFF, HDF/HDF5, and Esri File Geodatabase (.gdb) viewer with shapefile overlay and large-raster safeguard.
3
+ Version: 0.2.0
4
+ Summary: Lightweight GeoTIFF, NetCDF, HDF/HDF5, and Esri File Geodatabase (.gdb) viewer with optional shapefile overlay and geographic visualization. NetCDF and cartopy support available via pip install viewtif[netcdf].
5
5
  Project-URL: Homepage, https://github.com/nkeikon/tifviewer
6
6
  Project-URL: Source, https://github.com/nkeikon/tifviewer
7
7
  Project-URL: Issues, https://github.com/nkeikon/tifviewer/issues
@@ -16,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
 
@@ -29,6 +33,11 @@ You can visualize single-band GeoTIFFs, RGB composites, and shapefile overlays i
29
33
 
30
34
  ---
31
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
+ ---
40
+
32
41
  ## Installation
33
42
 
34
43
  ```bash
@@ -103,6 +112,26 @@ As of v1.0.7, `viewtif` automatically checks the raster size before loading.
103
112
  If the dataset is very large (e.g., >20 million pixels), it will pause and warn that loading may freeze your system.
104
113
  You can proceed manually or rerun with the `--scale` option for a smaller, faster preview.
105
114
 
115
+ ### Update in v0.2.0: NetCDF support with optional cartopy visualization
116
+ `viewtif` now supports NetCDF (`.nc`) files with xarray and optional cartopy geographic visualization. NetCDF support is optional to keep the base installation lightweight.
117
+
118
+ #### Installation with NetCDF support
119
+ ```bash
120
+ pip install "viewtif[netcdf]"
121
+ ```
122
+
123
+ #### Examples
124
+ ```bash
125
+ viewtif data.nc
126
+ ```
127
+
128
+ > **Note:** NetCDF support is optional. If xarray or netCDF4 is missing, viewtif will display:
129
+ > `NetCDF support requires additional dependencies. Install them with: pip install viewtif[netcdf]`
130
+ >
131
+ > **Cartopy visualization:** For enhanced geographic visualization with map projections, coastlines, and borders, install with cartopy:
132
+ > `pip install "viewtif[netcdf]"` (cartopy is included in the netcdf extra)
133
+ > If cartopy is not available, netCDF files will still display with standard RGB rendering.
134
+
106
135
  ## Controls
107
136
  | Key | Action |
108
137
  | -------------------- | --------------------------------------- |
@@ -138,4 +167,4 @@ This project is released under the MIT License.
138
167
 
139
168
  ## Contributors
140
169
  - [@HarshShinde0](https://github.com/HarshShinde0) — added mouse-wheel and trackpad zoom support
141
- - [@p-vdp](https://github.com/p-vdp) — added File Geodatabase (.gdb) raster support
170
+ - [@p-vdp](https://github.com/p-vdp) — added File Geodatabase (.gdb) raster support
@@ -0,0 +1,5 @@
1
+ viewtif/tif_viewer.py,sha256=zaIQ-u77jz2D1G5fDas0oR878dm1foNLUUKfdf5pTTU,60858
2
+ viewtif-0.2.0.dist-info/METADATA,sha256=APFk7tPPzcaaziAj_73Q0Y9pWitfFihx_x0Tamw7fxU,7497
3
+ viewtif-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
+ viewtif-0.2.0.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
5
+ viewtif-0.2.0.dist-info/RECORD,,
@@ -1,5 +0,0 @@
1
- viewtif/tif_viewer.py,sha256=huQInI8s-OxgoxqLPy0QzzRh4OTRZHOG2QE_0llK-Dw,29612
2
- viewtif-0.1.9.dist-info/METADATA,sha256=vKcAT60CbNgz5Ax-XahxEuhZhU4ZRS8tgb-Z_WVOM9Q,6234
3
- viewtif-0.1.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
- viewtif-0.1.9.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
5
- viewtif-0.1.9.dist-info/RECORD,,