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 +722 -52
- {viewtif-0.1.9.dist-info → viewtif-0.2.0.dist-info}/METADATA +33 -4
- viewtif-0.2.0.dist-info/RECORD +5 -0
- viewtif-0.1.9.dist-info/RECORD +0 -5
- {viewtif-0.1.9.dist-info → viewtif-0.2.0.dist-info}/WHEEL +0 -0
- {viewtif-0.1.9.dist-info → viewtif-0.2.0.dist-info}/entry_points.txt +0 -0
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,
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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"[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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.
|
|
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.
|
|
478
|
-
|
|
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
|
|
532
|
-
|
|
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
|
-
|
|
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] -
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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.
|
|
4
|
-
Summary: Lightweight GeoTIFF, HDF/HDF5, and Esri File Geodatabase (.gdb) viewer with shapefile overlay and
|
|
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
|
[](https://pepy.tech/project/viewtif)
|
|
23
28
|
[](https://pypi.org/project/viewtif/)
|
|
24
|
-
[](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,,
|
viewtif-0.1.9.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|