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