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