viewtif 0.2.1__py3-none-any.whl → 0.2.2__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 +760 -177
- {viewtif-0.2.1.dist-info → viewtif-0.2.2.dist-info}/METADATA +27 -8
- viewtif-0.2.2.dist-info/RECORD +5 -0
- viewtif-0.2.1.dist-info/RECORD +0 -5
- {viewtif-0.2.1.dist-info → viewtif-0.2.2.dist-info}/WHEEL +0 -0
- {viewtif-0.2.1.dist-info → viewtif-0.2.2.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,154 @@ 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
|
+
# --------------------- Detect NetCDF --------------------- #
|
|
214
|
+
if tif_path and tif_path.lower().endswith((".nc", ".netcdf")):
|
|
215
|
+
try:
|
|
216
|
+
# Lazy-load NetCDF dependencies
|
|
217
|
+
import xarray as xr
|
|
218
|
+
import pandas as pd
|
|
219
|
+
|
|
220
|
+
# Open the NetCDF file
|
|
221
|
+
ds = xr.open_dataset(tif_path)
|
|
222
|
+
|
|
223
|
+
# List variables, filtering out boundary variables (ending with _bnds)
|
|
224
|
+
all_vars = list(ds.data_vars)
|
|
225
|
+
data_vars = [var for var in all_vars if not var.endswith('_bnds')]
|
|
226
|
+
|
|
227
|
+
# Auto-select the first variable if there's only one and no subset specified
|
|
228
|
+
if len(data_vars) == 1 and subset is None:
|
|
229
|
+
subset = 0
|
|
230
|
+
# Only list variables if --subset not given and multiple variables exist
|
|
231
|
+
elif subset is None:
|
|
232
|
+
sys.exit(0)
|
|
233
|
+
|
|
234
|
+
# Validate subset index
|
|
235
|
+
if subset < 0 or subset >= len(data_vars):
|
|
236
|
+
raise ValueError(f"Invalid variable index {subset}. Valid range: 0–{len(data_vars)-1}")
|
|
237
|
+
|
|
238
|
+
# Get the selected variable from filtered data_vars
|
|
239
|
+
var_name = data_vars[subset]
|
|
240
|
+
var_data = ds[var_name]
|
|
241
|
+
|
|
242
|
+
# Store original dataset and variable information for better visualization
|
|
243
|
+
self._nc_dataset = ds
|
|
244
|
+
self._nc_var_name = var_name
|
|
245
|
+
self._nc_var_data = var_data
|
|
246
|
+
|
|
247
|
+
# Get coordinate info if available
|
|
248
|
+
self._has_geo_coords = False
|
|
249
|
+
if 'lon' in ds.coords and 'lat' in ds.coords:
|
|
250
|
+
self._has_geo_coords = True
|
|
251
|
+
self._lon_data = ds.lon.values
|
|
252
|
+
self._lat_data = ds.lat.values
|
|
253
|
+
elif 'longitude' in ds.coords and 'latitude' in ds.coords:
|
|
254
|
+
self._has_geo_coords = True
|
|
255
|
+
self._lon_data = ds.longitude.values
|
|
256
|
+
self._lat_data = ds.latitude.values
|
|
257
|
+
|
|
258
|
+
# Handle time or other index dimension if present
|
|
259
|
+
self._has_time_dim = False
|
|
260
|
+
self._time_dim_name = None
|
|
261
|
+
time_index = 0
|
|
262
|
+
|
|
263
|
+
# Look for a time dimension first
|
|
264
|
+
if 'time' in var_data.dims:
|
|
265
|
+
self._has_time_dim = True
|
|
266
|
+
self._time_dim_name = 'time'
|
|
267
|
+
self._time_values = ds['time'].values
|
|
268
|
+
self._time_index = 0
|
|
269
|
+
print(f"NetCDF time dimension detected: {len(self._time_values)} steps")
|
|
270
|
+
|
|
271
|
+
self.band_count = var_data.sizes['time']
|
|
272
|
+
self.band_index = 0
|
|
273
|
+
self._time_dim_name = 'time'
|
|
274
|
+
|
|
275
|
+
# Try to format time values for better display
|
|
276
|
+
time_units = getattr(ds.time, 'units', None)
|
|
277
|
+
time_calendar = getattr(ds.time, 'calendar', 'standard')
|
|
278
|
+
|
|
279
|
+
# Select first time step by default
|
|
280
|
+
var_data = var_data.isel(time=time_index)
|
|
281
|
+
|
|
282
|
+
# If no time dimension but variable has multiple dimensions,
|
|
283
|
+
# use the first non-spatial dimension as a "time" dimension
|
|
284
|
+
elif len(var_data.dims) > 2:
|
|
285
|
+
# Try to find a dimension that's not lat/lon
|
|
286
|
+
spatial_dims = ['lat', 'lon', 'latitude', 'longitude', 'y', 'x']
|
|
287
|
+
for dim in var_data.dims:
|
|
288
|
+
if dim not in spatial_dims:
|
|
289
|
+
self._has_time_dim = True
|
|
290
|
+
self._time_dim_name = dim
|
|
291
|
+
self._time_values = ds[dim].values
|
|
292
|
+
self._time_index = time_index
|
|
293
|
+
|
|
294
|
+
# Select first index by default
|
|
295
|
+
var_data = var_data.isel({dim: time_index})
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
# Convert to numpy array
|
|
299
|
+
arr = var_data.values.astype(np.float32)
|
|
300
|
+
|
|
301
|
+
# Process array based on dimensions
|
|
302
|
+
if arr.ndim > 2:
|
|
303
|
+
# Keep only lat/lon dimensions for 3D+ arrays
|
|
304
|
+
arr = np.squeeze(arr)
|
|
305
|
+
|
|
306
|
+
# --- Downsample large arrays for responsiveness ---
|
|
307
|
+
if arr.ndim >= 2:
|
|
308
|
+
h, w = arr.shape[:2]
|
|
309
|
+
if h * w > 4_000_000:
|
|
310
|
+
step = max(2, int((h * w / 4_000_000) ** 0.5))
|
|
311
|
+
if arr.ndim == 2:
|
|
312
|
+
arr = arr[::step, ::step]
|
|
313
|
+
else:
|
|
314
|
+
arr = arr[::step, ::step, :]
|
|
315
|
+
|
|
316
|
+
# --- Final assignments ---
|
|
317
|
+
self.data = arr
|
|
318
|
+
|
|
319
|
+
# Try to extract CRS from CF conventions
|
|
320
|
+
self._transform = None
|
|
321
|
+
self._crs = None
|
|
322
|
+
if 'crs' in ds.variables:
|
|
323
|
+
try:
|
|
324
|
+
import rasterio.crs
|
|
325
|
+
crs_var = ds.variables['crs']
|
|
326
|
+
if hasattr(crs_var, 'spatial_ref'):
|
|
327
|
+
self._crs = rasterio.crs.CRS.from_wkt(crs_var.spatial_ref)
|
|
328
|
+
except Exception as e:
|
|
329
|
+
print(f"Could not parse CRS: {e}")
|
|
330
|
+
|
|
331
|
+
# Set band info
|
|
332
|
+
if arr.ndim == 3:
|
|
333
|
+
self.band_count = arr.shape[2]
|
|
334
|
+
else:
|
|
335
|
+
self.band_count = 1
|
|
336
|
+
|
|
337
|
+
self.band_index = 0
|
|
338
|
+
self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
|
|
339
|
+
|
|
340
|
+
# --- If user specified --band, start there ---
|
|
341
|
+
if self.band and self.band <= self.band_count:
|
|
342
|
+
self.band_index = self.band - 1
|
|
343
|
+
|
|
344
|
+
# Enable cartopy visualization if available
|
|
345
|
+
self._use_cartopy = HAVE_CARTOPY and self._has_geo_coords
|
|
346
|
+
|
|
347
|
+
except ImportError as e:
|
|
348
|
+
if "xarray" in str(e) or "netCDF4" in str(e):
|
|
349
|
+
raise RuntimeError(
|
|
350
|
+
f"NetCDF support requires additional dependencies.\n"
|
|
351
|
+
f"Install them with: pip install viewtif[netcdf]\n"
|
|
352
|
+
f"Original error: {str(e)}"
|
|
353
|
+
)
|
|
354
|
+
else:
|
|
355
|
+
raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
|
|
356
|
+
except Exception as e:
|
|
357
|
+
raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
|
|
358
|
+
|
|
192
359
|
# ---------------- Handle File Geodatabase (.gdb) ---------------- #
|
|
193
|
-
if tif_path.lower().endswith(".gdb") and ":" not in tif_path:
|
|
360
|
+
if tif_path and tif_path.lower().endswith(".gdb") and ":" not in tif_path:
|
|
194
361
|
import re, subprocess
|
|
195
362
|
gdb_path = tif_path # use full path to .gdb
|
|
196
363
|
try:
|
|
@@ -212,100 +379,141 @@ class TiffViewer(QMainWindow):
|
|
|
212
379
|
|
|
213
380
|
# --- Universal size check before loading ---
|
|
214
381
|
warn_if_large(tif_path, scale=self._scale_arg)
|
|
215
|
-
|
|
382
|
+
|
|
383
|
+
if False: # Placeholder for previous if condition
|
|
384
|
+
pass
|
|
216
385
|
# --------------------- Detect HDF/HDF5 --------------------- #
|
|
217
|
-
|
|
386
|
+
elif tif_path and tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
|
|
218
387
|
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
|
|
388
|
+
# Try GDAL first (best support for HDF subdatasets)
|
|
389
|
+
from osgeo import gdal
|
|
390
|
+
gdal.UseExceptions()
|
|
235
391
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
print("Falling back to GDAL...")
|
|
392
|
+
ds = gdal.Open(tif_path)
|
|
393
|
+
subs = ds.GetSubDatasets()
|
|
239
394
|
|
|
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.")
|
|
395
|
+
if not subs:
|
|
396
|
+
raise ValueError("No subdatasets found in HDF/HDF5 file.")
|
|
248
397
|
|
|
398
|
+
# Only list subsets if --subset not given
|
|
399
|
+
if subset is None:
|
|
249
400
|
print(f"Found {len(subs)} subdatasets in {os.path.basename(tif_path)}:")
|
|
250
401
|
for i, (_, desc) in enumerate(subs):
|
|
251
402
|
print(f"[{i}] {desc}")
|
|
403
|
+
print("\nUse --subset N to open a specific subdataset.")
|
|
404
|
+
sys.exit(0)
|
|
252
405
|
|
|
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
|
-
|
|
406
|
+
# Validate subset index
|
|
407
|
+
if subset < 0 or subset >= len(subs):
|
|
408
|
+
raise ValueError(f"Invalid subset index {subset}. Valid range: 0–{len(subs)-1}")
|
|
409
|
+
|
|
410
|
+
sub_name, desc = subs[subset]
|
|
411
|
+
print(f"\nOpening subdataset [{subset}]: {desc}")
|
|
412
|
+
sub_ds = gdal.Open(sub_name)
|
|
413
|
+
|
|
414
|
+
# --- Read once ---
|
|
415
|
+
arr = sub_ds.ReadAsArray().astype(np.float32)
|
|
416
|
+
#print(f"Raw array shape from GDAL: {arr.shape} (ndim={arr.ndim})")
|
|
417
|
+
|
|
418
|
+
# --- Normalize shape ---
|
|
419
|
+
arr = np.squeeze(arr)
|
|
420
|
+
if arr.ndim == 3:
|
|
421
|
+
# Convert from (bands, rows, cols) → (rows, cols, bands)
|
|
422
|
+
arr = np.transpose(arr, (1, 2, 0))
|
|
423
|
+
#print(f"Transposed to {arr.shape} (rows, cols, bands)")
|
|
424
|
+
elif arr.ndim == 2:
|
|
425
|
+
print("Single-band dataset.")
|
|
426
|
+
else:
|
|
427
|
+
raise ValueError(f"Unexpected array shape {arr.shape}")
|
|
428
|
+
|
|
429
|
+
# --- Downsample large arrays for responsiveness ---
|
|
430
|
+
h, w = arr.shape[:2]
|
|
431
|
+
if h * w > 4_000_000:
|
|
432
|
+
step = max(2, int((h * w / 4_000_000) ** 0.5))
|
|
433
|
+
arr = arr[::step, ::step] if arr.ndim == 2 else arr[::step, ::step, :]
|
|
434
|
+
|
|
435
|
+
# --- Final assignments ---
|
|
436
|
+
self.data = arr
|
|
437
|
+
self._transform = None
|
|
438
|
+
self._crs = None
|
|
439
|
+
self.band_count = arr.shape[2] if arr.ndim == 3 else 1
|
|
440
|
+
self.band_index = 0
|
|
441
|
+
self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
|
|
442
|
+
|
|
443
|
+
if self.band_count > 1:
|
|
444
|
+
print(f"This subdataset has {self.band_count} bands — switch with [ and ] keys.")
|
|
445
|
+
else:
|
|
446
|
+
print("This subdataset has 1 band.")
|
|
292
447
|
|
|
293
448
|
if self.band and self.band <= self.band_count:
|
|
294
449
|
self.band_index = self.band - 1
|
|
295
450
|
print(f"Opening band {self.band}/{self.band_count}")
|
|
296
451
|
|
|
297
|
-
|
|
452
|
+
except ImportError:
|
|
453
|
+
# GDAL not available, try rasterio as fallback for NetCDF
|
|
454
|
+
print("[INFO] GDAL not available, attempting to read HDF/NetCDF with rasterio...")
|
|
455
|
+
try:
|
|
456
|
+
import rasterio as rio
|
|
457
|
+
with rio.open(tif_path) as src:
|
|
458
|
+
print(f"[INFO] NetCDF file opened via rasterio")
|
|
459
|
+
print(f"[INFO] Data shape: {src.height} x {src.width} x {src.count} bands")
|
|
460
|
+
|
|
461
|
+
if src.count == 0:
|
|
462
|
+
raise ValueError("No bands found in NetCDF file.")
|
|
463
|
+
|
|
464
|
+
# Determine which band(s) to read
|
|
465
|
+
if self.band and self.band <= src.count:
|
|
466
|
+
band_indices = [self.band]
|
|
467
|
+
print(f"Opening band {self.band}/{src.count}")
|
|
468
|
+
elif rgb and all(b <= src.count for b in rgb):
|
|
469
|
+
band_indices = rgb
|
|
470
|
+
print(f"Opening bands {rgb} as RGB")
|
|
471
|
+
else:
|
|
472
|
+
band_indices = list(range(1, min(src.count + 1, 4))) # Read up to 3 bands
|
|
473
|
+
print(f"Opening bands {band_indices}")
|
|
474
|
+
|
|
475
|
+
# Read selected bands
|
|
476
|
+
bands = []
|
|
477
|
+
for b in band_indices:
|
|
478
|
+
band_data = src.read(b, out_shape=(src.height // self._scale_arg, src.width // self._scale_arg))
|
|
479
|
+
bands.append(band_data)
|
|
480
|
+
|
|
481
|
+
# Stack into array
|
|
482
|
+
arr = np.stack(bands, axis=-1).astype(np.float32) if len(bands) > 1 else bands[0].astype(np.float32)
|
|
483
|
+
|
|
484
|
+
# Handle no-data values
|
|
485
|
+
nd = src.nodata
|
|
486
|
+
if nd is not None:
|
|
487
|
+
if arr.ndim == 3:
|
|
488
|
+
arr[arr == nd] = np.nan
|
|
489
|
+
else:
|
|
490
|
+
arr[arr == nd] = np.nan
|
|
491
|
+
|
|
492
|
+
# Final assignments
|
|
493
|
+
self.data = arr
|
|
494
|
+
self._transform = src.transform
|
|
495
|
+
self._crs = src.crs
|
|
496
|
+
self.band_count = arr.shape[2] if arr.ndim == 3 else 1
|
|
497
|
+
self.band_index = 0
|
|
498
|
+
self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
|
|
499
|
+
|
|
500
|
+
if self.band_count > 1:
|
|
501
|
+
print(f"Loaded {self.band_count} bands — switch with [ and ] keys.")
|
|
502
|
+
else:
|
|
503
|
+
print("Loaded 1 band.")
|
|
504
|
+
except Exception as e:
|
|
298
505
|
raise RuntimeError(
|
|
299
|
-
"
|
|
300
|
-
"
|
|
506
|
+
f"Failed to read HDF/NetCDF file: {e}\n"
|
|
507
|
+
"For full HDF support, install GDAL: pip install GDAL"
|
|
301
508
|
)
|
|
302
509
|
|
|
303
510
|
# --------------------- Regular GeoTIFF --------------------- #
|
|
304
511
|
else:
|
|
305
|
-
if os.path.dirname(tif_path).endswith(".gdb"):
|
|
512
|
+
if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
|
|
306
513
|
tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
|
|
307
514
|
|
|
308
|
-
|
|
515
|
+
import rasterio as rio_module
|
|
516
|
+
with rio_module.open(tif_path) as src:
|
|
309
517
|
self._transform = src.transform
|
|
310
518
|
self._crs = src.crs
|
|
311
519
|
if rgb is not None:
|
|
@@ -349,19 +557,39 @@ class TiffViewer(QMainWindow):
|
|
|
349
557
|
self.gamma = 1.0
|
|
350
558
|
|
|
351
559
|
# Colormap (single-band)
|
|
352
|
-
|
|
353
|
-
|
|
560
|
+
# For NetCDF temperature data, have three colormaps in rotation
|
|
561
|
+
if tif_path and tif_path.lower().endswith(('.nc', '.netcdf')):
|
|
562
|
+
self.cmap_names = ["RdBu_r", "viridis", "magma"] # three colormaps for NetCDF
|
|
563
|
+
self.cmap_index = 0 # start with RdBu_r
|
|
564
|
+
self.cmap_name = self.cmap_names[self.cmap_index]
|
|
565
|
+
else:
|
|
566
|
+
self.cmap_name = "viridis"
|
|
567
|
+
self.alt_cmap_name = "magma" # toggle with M in single-band
|
|
354
568
|
|
|
355
569
|
self.zoom_step = 1.2
|
|
356
570
|
self.pan_step = 80
|
|
357
571
|
|
|
572
|
+
# Create main widget and layout
|
|
573
|
+
self.main_widget = QWidget()
|
|
574
|
+
self.main_layout = QVBoxLayout(self.main_widget)
|
|
575
|
+
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
|
576
|
+
self.main_layout.setSpacing(0)
|
|
577
|
+
|
|
358
578
|
# Scene + view
|
|
359
579
|
self.scene = QGraphicsScene(self)
|
|
360
580
|
self.view = RasterView(self.scene, self)
|
|
361
|
-
self.
|
|
581
|
+
self.main_layout.addWidget(self.view)
|
|
582
|
+
|
|
583
|
+
# Status bar
|
|
584
|
+
self.setStatusBar(QStatusBar())
|
|
585
|
+
|
|
586
|
+
# Set central widget
|
|
587
|
+
self.setCentralWidget(self.main_widget)
|
|
362
588
|
|
|
363
589
|
self.pixmap_item = None
|
|
364
590
|
self._last_rgb = None
|
|
591
|
+
|
|
592
|
+
# --- Initial render ---
|
|
365
593
|
self.update_pixmap()
|
|
366
594
|
|
|
367
595
|
# Overlays (if any)
|
|
@@ -499,17 +727,246 @@ class TiffViewer(QMainWindow):
|
|
|
499
727
|
|
|
500
728
|
# ----------------------- Title / Rendering ----------------------- #
|
|
501
729
|
def update_title(self):
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
730
|
+
"""Show correct title for GeoTIFF or NetCDF time series."""
|
|
731
|
+
import os
|
|
732
|
+
|
|
733
|
+
if hasattr(self, "_has_time_dim") and self._has_time_dim:
|
|
734
|
+
nc_name = getattr(self, "_nc_var_name", "")
|
|
735
|
+
file_name = os.path.basename(self.tif_path)
|
|
736
|
+
title = f"Time step {self.band_index + 1}/{self.band_count} — {file_name}"
|
|
737
|
+
|
|
507
738
|
elif hasattr(self, "band_index"):
|
|
508
|
-
self.
|
|
509
|
-
|
|
510
|
-
|
|
739
|
+
title = f"Band {self.band_index + 1}/{self.band_count} — {os.path.basename(self.tif_path)}"
|
|
740
|
+
|
|
741
|
+
elif self.rgb_mode and self.rgb:
|
|
742
|
+
# title = f"RGB {self.rgb} — {os.path.basename(self.tif_path)}"
|
|
743
|
+
title = f"RGB {self.rgb}"
|
|
744
|
+
|
|
511
745
|
else:
|
|
512
|
-
|
|
746
|
+
title = os.path.basename(self.tif_path)
|
|
747
|
+
|
|
748
|
+
print(f"Title: {title}")
|
|
749
|
+
self.setWindowTitle(title)
|
|
750
|
+
|
|
751
|
+
def _normalize_lat_lon(self, frame):
|
|
752
|
+
"""Flip frame only if data and lat orientation disagree."""
|
|
753
|
+
import numpy as np
|
|
754
|
+
|
|
755
|
+
if not hasattr(self, "_lat_data"):
|
|
756
|
+
return frame
|
|
757
|
+
|
|
758
|
+
lats = self._lat_data
|
|
759
|
+
|
|
760
|
+
# 1D latitude case
|
|
761
|
+
if np.ndim(lats) == 1:
|
|
762
|
+
lat_ascending = lats[0] < lats[-1]
|
|
763
|
+
|
|
764
|
+
# If first pixel row corresponds to northernmost lat → do nothing
|
|
765
|
+
# If first pixel row corresponds to southernmost lat → flip to make north at top
|
|
766
|
+
# We'll assume data[0, :] corresponds to lats[0]
|
|
767
|
+
if lat_ascending:
|
|
768
|
+
print("[DEBUG] Flipping latitude orientation (lat ascending, data starts south)")
|
|
769
|
+
frame = np.flipud(frame)
|
|
770
|
+
# else:
|
|
771
|
+
# print("[DEBUG] No flip (lat descending, already north-up)")
|
|
772
|
+
return frame
|
|
773
|
+
|
|
774
|
+
# 2D latitude grid (rare case)
|
|
775
|
+
elif np.ndim(lats) == 2:
|
|
776
|
+
first_col = lats[:, 0]
|
|
777
|
+
lat_ascending = first_col[0] < first_col[-1]
|
|
778
|
+
if lat_ascending:
|
|
779
|
+
print("[DEBUG] Flipping latitude orientation (2D grid ascending)")
|
|
780
|
+
frame = np.flipud(frame)
|
|
781
|
+
# else:
|
|
782
|
+
# print("[DEBUG] No flip (2D grid already north-up)")
|
|
783
|
+
return frame
|
|
784
|
+
|
|
785
|
+
return frame
|
|
786
|
+
|
|
787
|
+
def _apply_scale_if_needed(self, frame):
|
|
788
|
+
"""Downsample frame and lat/lon consistently if --scale > 1."""
|
|
789
|
+
if not hasattr(self, "_scale_arg") or self._scale_arg <= 1:
|
|
790
|
+
return frame
|
|
791
|
+
|
|
792
|
+
step = int(self._scale_arg)
|
|
793
|
+
print(f"[DEBUG] Applying scale factor {step} to current frame")
|
|
794
|
+
|
|
795
|
+
# Downsample the frame
|
|
796
|
+
frame = frame[::step, ::step]
|
|
797
|
+
|
|
798
|
+
# Also downsample lat/lon for this viewer instance if not already
|
|
799
|
+
if hasattr(self, "_lat_data") and np.ndim(self._lat_data) == 1 and len(self._lat_data) > frame.shape[0]:
|
|
800
|
+
self._lat_data = self._lat_data[::step]
|
|
801
|
+
if hasattr(self, "_lon_data") and np.ndim(self._lon_data) == 1 and len(self._lon_data) > frame.shape[1]:
|
|
802
|
+
self._lon_data = self._lon_data[::step]
|
|
803
|
+
|
|
804
|
+
return frame
|
|
805
|
+
|
|
806
|
+
def get_current_frame(self):
|
|
807
|
+
"""Return the current time/band frame as a NumPy array (2D)."""
|
|
808
|
+
frame = None
|
|
809
|
+
|
|
810
|
+
if hasattr(self, '_time_dim_name') and hasattr(self, '_nc_var_data'):
|
|
811
|
+
# Select frame using band_index
|
|
812
|
+
try:
|
|
813
|
+
frame = self._nc_var_data.isel({self._time_dim_name: self.band_index})
|
|
814
|
+
except Exception:
|
|
815
|
+
# Already numpy or index error fallback
|
|
816
|
+
frame = self._nc_var_data
|
|
817
|
+
|
|
818
|
+
elif isinstance(self.data, np.ndarray):
|
|
819
|
+
frame = self.data
|
|
820
|
+
|
|
821
|
+
# Normalize lat orientation if needed
|
|
822
|
+
frame = self._normalize_lat_lon(frame)
|
|
823
|
+
frame = self._apply_scale_if_needed(frame)
|
|
824
|
+
# Convert to numpy if it's still an xarray
|
|
825
|
+
if hasattr(frame, "values"):
|
|
826
|
+
frame = frame.values
|
|
827
|
+
|
|
828
|
+
# Apply same scaling factor (if any)
|
|
829
|
+
if hasattr(self, "_scale_arg") and self._scale_arg > 1:
|
|
830
|
+
step = int(self._scale_arg)
|
|
831
|
+
|
|
832
|
+
return frame.astype(np.float32)
|
|
833
|
+
|
|
834
|
+
def format_time_value(self, time_value):
|
|
835
|
+
"""Format a time value into a user-friendly string"""
|
|
836
|
+
# Default is the string representation
|
|
837
|
+
time_str = str(time_value)
|
|
838
|
+
|
|
839
|
+
try:
|
|
840
|
+
# Handle numpy datetime64
|
|
841
|
+
if hasattr(time_value, 'dtype') and np.issubdtype(time_value.dtype, np.datetime64):
|
|
842
|
+
# Lazy-load pandas for timestamp conversion
|
|
843
|
+
import pandas as pd
|
|
844
|
+
# Convert to Python datetime if possible
|
|
845
|
+
dt = pd.Timestamp(time_value).to_pydatetime()
|
|
846
|
+
time_str = dt.strftime('%Y-%m-%d %H:%M:%S')
|
|
847
|
+
# Handle native Python datetime
|
|
848
|
+
elif hasattr(time_value, 'strftime'):
|
|
849
|
+
time_str = time_value.strftime('%Y-%m-%d %H:%M:%S')
|
|
850
|
+
# Handle cftime datetime-like objects used in some NetCDF files
|
|
851
|
+
elif hasattr(time_value, 'isoformat'):
|
|
852
|
+
time_str = time_value.isoformat().replace('T', ' ')
|
|
853
|
+
except Exception:
|
|
854
|
+
# Fall back to string representation
|
|
855
|
+
pass
|
|
856
|
+
|
|
857
|
+
return time_str
|
|
858
|
+
|
|
859
|
+
# def update_time_label(self):
|
|
860
|
+
# """Update the time label with the current time value"""
|
|
861
|
+
# if hasattr(self, '_has_time_dim') and self._has_time_dim:
|
|
862
|
+
# try:
|
|
863
|
+
# time_value = self._time_values[self._time_index]
|
|
864
|
+
# time_str = self.format_time_value(time_value)
|
|
865
|
+
|
|
866
|
+
# # Update time label if it exists
|
|
867
|
+
# if hasattr(self, 'time_label'):
|
|
868
|
+
# self.time_label.setText(f"Time: {time_str}")
|
|
869
|
+
|
|
870
|
+
# # Create a progress bar style display of time position
|
|
871
|
+
# total = len(self._time_values)
|
|
872
|
+
# position = self._time_index + 1
|
|
873
|
+
# bar_width = 20 # Width of the progress bar
|
|
874
|
+
# filled = int(bar_width * position / total)
|
|
875
|
+
# bar = "[" + "#" * filled + "-" * (bar_width - filled) + "]"
|
|
876
|
+
|
|
877
|
+
# # Show time info in status bar
|
|
878
|
+
# step_info = f"Time step: {position}/{total} {bar} {self.format_time_value(self._time_values[self._time_index])}"
|
|
879
|
+
|
|
880
|
+
# # Update status bar if it exists
|
|
881
|
+
# if hasattr(self, 'statusBar') and callable(self.statusBar):
|
|
882
|
+
# self.statusBar().showMessage(step_info)
|
|
883
|
+
# else:
|
|
884
|
+
# print(step_info)
|
|
885
|
+
# except Exception as e:
|
|
886
|
+
# print(f"Error updating time label: {e}")
|
|
887
|
+
|
|
888
|
+
# def toggle_play_pause(self):
|
|
889
|
+
# """Toggle play/pause animation of time steps"""
|
|
890
|
+
# if self._is_playing:
|
|
891
|
+
# self.stop_animation()
|
|
892
|
+
# else:
|
|
893
|
+
# self.start_animation()
|
|
894
|
+
|
|
895
|
+
# def start_animation(self):
|
|
896
|
+
# """Start the time animation"""
|
|
897
|
+
# from PySide6.QtCore import QTimer
|
|
898
|
+
|
|
899
|
+
# if not hasattr(self, '_play_timer') or self._play_timer is None:
|
|
900
|
+
# self._play_timer = QTimer(self)
|
|
901
|
+
# self._play_timer.timeout.connect(self.animation_step)
|
|
902
|
+
|
|
903
|
+
# # Set animation speed (milliseconds between frames)
|
|
904
|
+
# animation_speed = 500 # 0.5 seconds between frames
|
|
905
|
+
# self._play_timer.start(animation_speed)
|
|
906
|
+
|
|
907
|
+
# self._is_playing = True
|
|
908
|
+
# self.play_button.setText("⏸") # Pause symbol
|
|
909
|
+
# self.play_button.setToolTip("Pause animation")
|
|
910
|
+
|
|
911
|
+
# def stop_animation(self):
|
|
912
|
+
# """Stop the time animation"""
|
|
913
|
+
# if hasattr(self, '_play_timer') and self._play_timer is not None:
|
|
914
|
+
# self._play_timer.stop()
|
|
915
|
+
|
|
916
|
+
# self._is_playing = False
|
|
917
|
+
# self.play_button.setText("▶") # Play symbol
|
|
918
|
+
# self.play_button.setToolTip("Play animation")
|
|
919
|
+
|
|
920
|
+
# def animation_step(self):
|
|
921
|
+
# """Advance one frame in the animation"""
|
|
922
|
+
# # Go to next time step
|
|
923
|
+
# next_time = (self._time_index + 1) % len(self._time_values)
|
|
924
|
+
# self.time_slider.setValue(next_time)
|
|
925
|
+
|
|
926
|
+
# def closeEvent(self, event):
|
|
927
|
+
# """Clean up resources when the window is closed"""
|
|
928
|
+
# # Stop animation timer if it's running
|
|
929
|
+
# if hasattr(self, '_is_playing') and self._is_playing:
|
|
930
|
+
# self.stop_animation()
|
|
931
|
+
|
|
932
|
+
# # Call the parent class closeEvent
|
|
933
|
+
# super().closeEvent(event)
|
|
934
|
+
|
|
935
|
+
# def populate_date_combo(self):
|
|
936
|
+
# """Populate the date combo box with time values"""
|
|
937
|
+
# if hasattr(self, '_has_time_dim') and self._has_time_dim and hasattr(self, 'date_combo'):
|
|
938
|
+
# try:
|
|
939
|
+
# self.date_combo.clear()
|
|
940
|
+
|
|
941
|
+
# # Add a reasonable subset of dates if there are too many
|
|
942
|
+
# max_items = 100 # Maximum number of items to show in dropdown
|
|
943
|
+
|
|
944
|
+
# if len(self._time_values) <= max_items:
|
|
945
|
+
# # Add all time values
|
|
946
|
+
# for i, time_value in enumerate(self._time_values):
|
|
947
|
+
# time_str = self.format_time_value(time_value)
|
|
948
|
+
# self.date_combo.addItem(time_str, i)
|
|
949
|
+
# else:
|
|
950
|
+
# # Add a subset of time values
|
|
951
|
+
# step = len(self._time_values) // max_items
|
|
952
|
+
|
|
953
|
+
# # Always include first and last
|
|
954
|
+
# indices = list(range(0, len(self._time_values), step))
|
|
955
|
+
# if (len(self._time_values) - 1) not in indices:
|
|
956
|
+
# indices.append(len(self._time_values) - 1)
|
|
957
|
+
|
|
958
|
+
# for i in indices:
|
|
959
|
+
# time_str = self.format_time_value(self._time_values[i])
|
|
960
|
+
# self.date_combo.addItem(f"{time_str} [{i+1}/{len(self._time_values)}]", i)
|
|
961
|
+
# except Exception as e:
|
|
962
|
+
# print(f"Error populating date combo: {e}")
|
|
963
|
+
|
|
964
|
+
# def date_combo_changed(self, index):
|
|
965
|
+
# """Handle date combo box selection change"""
|
|
966
|
+
# if index >= 0:
|
|
967
|
+
# time_index = self.date_combo.itemData(index)
|
|
968
|
+
# if time_index is not None:
|
|
969
|
+
# self.time_slider.setValue(time_index)
|
|
513
970
|
|
|
514
971
|
def _render_rgb(self):
|
|
515
972
|
if self.rgb_mode:
|
|
@@ -538,6 +995,126 @@ class TiffViewer(QMainWindow):
|
|
|
538
995
|
rgb = (cmap(norm)[..., :3] * 255).astype(np.uint8)
|
|
539
996
|
return rgb
|
|
540
997
|
|
|
998
|
+
def _render_cartopy_map(self, data):
|
|
999
|
+
"""Render a NetCDF variable with cartopy for better geographic visualization"""
|
|
1000
|
+
import matplotlib.pyplot as plt
|
|
1001
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
1002
|
+
import cartopy.crs as ccrs
|
|
1003
|
+
import cartopy.feature as cfeature
|
|
1004
|
+
|
|
1005
|
+
# Create a new figure with cartopy projection
|
|
1006
|
+
fig = plt.figure(figsize=(12, 8), dpi=100)
|
|
1007
|
+
ax = plt.axes(projection=ccrs.PlateCarree())
|
|
1008
|
+
|
|
1009
|
+
# Get coordinates
|
|
1010
|
+
lons = self._lon_data
|
|
1011
|
+
lats = self._lat_data
|
|
1012
|
+
|
|
1013
|
+
# Create contour plot
|
|
1014
|
+
levels = 20
|
|
1015
|
+
if hasattr(plt.cm, self.cmap_name):
|
|
1016
|
+
cmap = getattr(plt.cm, self.cmap_name)
|
|
1017
|
+
else:
|
|
1018
|
+
cmap = getattr(cm, self.cmap_name, cm.viridis)
|
|
1019
|
+
|
|
1020
|
+
# Apply contrast and gamma adjustments
|
|
1021
|
+
finite = np.isfinite(data)
|
|
1022
|
+
norm_data = np.zeros_like(data, dtype=np.float32)
|
|
1023
|
+
vmin, vmax = np.nanmin(data), np.nanmax(data)
|
|
1024
|
+
rng = max(vmax - vmin, 1e-12)
|
|
1025
|
+
|
|
1026
|
+
if np.any(finite):
|
|
1027
|
+
norm_data[finite] = (data[finite] - vmin) / rng
|
|
1028
|
+
|
|
1029
|
+
norm_data = np.clip(norm_data * self.contrast, 0.0, 1.0)
|
|
1030
|
+
norm_data = np.power(norm_data, self.gamma)
|
|
1031
|
+
norm_data = norm_data * rng + vmin
|
|
1032
|
+
|
|
1033
|
+
# Downsample coordinates to match downsampled data shape
|
|
1034
|
+
# --- Align coordinates with data shape (no stepping assumptions) ---
|
|
1035
|
+
# Downsample coordinates to match downsampled data shape
|
|
1036
|
+
data_height, data_width = data.shape[:2]
|
|
1037
|
+
lat_samples = len(lats)
|
|
1038
|
+
lon_samples = len(lons)
|
|
1039
|
+
|
|
1040
|
+
lat_step = max(1, lat_samples // data_height)
|
|
1041
|
+
lon_step = max(1, lon_samples // data_width)
|
|
1042
|
+
|
|
1043
|
+
# Downsample coordinate arrays to match data
|
|
1044
|
+
lats_downsampled = lats[::lat_step][:data_height]
|
|
1045
|
+
lons_downsampled = lons[::lon_step][:data_width]
|
|
1046
|
+
|
|
1047
|
+
# --- Synchronize latitude orientation with normalized data ---
|
|
1048
|
+
if np.ndim(lats) == 1 and lats[0] < lats[-1]:
|
|
1049
|
+
print("[DEBUG] Lat ascending → flip lats_downsampled to match flipped data")
|
|
1050
|
+
lats_downsampled = lats_downsampled[::-1]
|
|
1051
|
+
elif np.ndim(lats) == 2:
|
|
1052
|
+
first_col = lats[:, 0]
|
|
1053
|
+
if first_col[0] < first_col[-1]:
|
|
1054
|
+
print("[DEBUG] 2D lat grid ascending → flip lats_downsampled vertically")
|
|
1055
|
+
lats_downsampled = np.flipud(lats_downsampled)
|
|
1056
|
+
|
|
1057
|
+
# Convert 0–360 longitude to −180–180 if needed
|
|
1058
|
+
if lons_downsampled.max() > 180:
|
|
1059
|
+
lons_downsampled = ((lons_downsampled + 180) % 360) - 180
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
# --- Build meshgrid AFTER any flip ---
|
|
1063
|
+
lon_grid, lat_grid = np.meshgrid(lons_downsampled, lats_downsampled, indexing="xy")
|
|
1064
|
+
|
|
1065
|
+
# Use pcolormesh (more stable than contourf for gridded data)
|
|
1066
|
+
img = ax.pcolormesh(
|
|
1067
|
+
lon_grid, lat_grid, data,
|
|
1068
|
+
transform=ccrs.PlateCarree(),
|
|
1069
|
+
cmap=cmap,
|
|
1070
|
+
shading="auto"
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
# Set extent from the 1D vectors (already flipped if needed)
|
|
1074
|
+
ax.set_extent(
|
|
1075
|
+
[lons_downsampled.min(), lons_downsampled.max(),
|
|
1076
|
+
lats_downsampled.min(), lats_downsampled.max()],
|
|
1077
|
+
crs=ccrs.PlateCarree()
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
# Add map features
|
|
1081
|
+
ax.coastlines(resolution="50m", linewidth=0.5)
|
|
1082
|
+
ax.add_feature(cfeature.BORDERS, linestyle=":", linewidth=0.5)
|
|
1083
|
+
ax.add_feature(cfeature.STATES, linestyle="-", linewidth=0.3, alpha=0.5)
|
|
1084
|
+
ax.gridlines(draw_labels=True, alpha=0.3)
|
|
1085
|
+
|
|
1086
|
+
# --- Add dynamic title ---
|
|
1087
|
+
title = os.path.basename(self.tif_path)
|
|
1088
|
+
if hasattr(self, "_has_time_dim") and self._has_time_dim:
|
|
1089
|
+
# Use current band_index as proxy for time_index
|
|
1090
|
+
try:
|
|
1091
|
+
current_time = self._time_values[self.band_index]
|
|
1092
|
+
time_str = self.format_time_value(current_time) if hasattr(self, "format_time_value") else str(current_time)
|
|
1093
|
+
ax.set_title(f"{title}\n{time_str}", fontsize=10)
|
|
1094
|
+
except Exception as e:
|
|
1095
|
+
ax.set_title(f"{title}\n(time step {self.band_index + 1})", fontsize=10)
|
|
1096
|
+
else:
|
|
1097
|
+
ax.set_title(title, fontsize=10)
|
|
1098
|
+
|
|
1099
|
+
# Add colorbar
|
|
1100
|
+
plt.colorbar(img, ax=ax, shrink=0.6)
|
|
1101
|
+
plt.tight_layout()
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
# Convert matplotlib figure to image
|
|
1105
|
+
canvas = FigureCanvasAgg(fig)
|
|
1106
|
+
canvas.draw()
|
|
1107
|
+
width, height = fig.canvas.get_width_height()
|
|
1108
|
+
rgba = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(height, width, 4)
|
|
1109
|
+
|
|
1110
|
+
# Extract RGB and ensure it's C-contiguous for QImage
|
|
1111
|
+
rgb = np.ascontiguousarray(rgba[:, :, :3])
|
|
1112
|
+
|
|
1113
|
+
# Close figure to prevent memory leak
|
|
1114
|
+
plt.close(fig)
|
|
1115
|
+
|
|
1116
|
+
return rgb
|
|
1117
|
+
|
|
541
1118
|
def update_pixmap(self):
|
|
542
1119
|
# --- Select display data ---
|
|
543
1120
|
if hasattr(self, "band_index"):
|
|
@@ -558,13 +1135,23 @@ class TiffViewer(QMainWindow):
|
|
|
558
1135
|
# ----------------------------
|
|
559
1136
|
|
|
560
1137
|
# --- Render image ---
|
|
561
|
-
if
|
|
562
|
-
|
|
1138
|
+
# Check if we should use cartopy for NetCDF visualization
|
|
1139
|
+
use_cartopy = False
|
|
1140
|
+
if hasattr(self, '_use_cartopy') and self._use_cartopy and HAVE_CARTOPY:
|
|
1141
|
+
if hasattr(self, '_has_geo_coords') and self._has_geo_coords:
|
|
1142
|
+
use_cartopy = True
|
|
1143
|
+
|
|
1144
|
+
if use_cartopy:
|
|
1145
|
+
# Render with cartopy for better geographic visualization
|
|
1146
|
+
rgb = self._render_cartopy_map(a)
|
|
1147
|
+
elif rgb is None:
|
|
1148
|
+
# Standard grayscale rendering for single-band (scientific) data
|
|
563
1149
|
finite = np.isfinite(a)
|
|
564
|
-
|
|
1150
|
+
vmin, vmax = np.nanmin(a), np.nanmax(a)
|
|
1151
|
+
rng = max(vmax - vmin, 1e-12)
|
|
565
1152
|
norm = np.zeros_like(a, dtype=np.float32)
|
|
566
1153
|
if np.any(finite):
|
|
567
|
-
norm[finite] = (a[finite] -
|
|
1154
|
+
norm[finite] = (a[finite] - vmin) / rng
|
|
568
1155
|
norm = np.clip(norm, 0, 1)
|
|
569
1156
|
norm = np.power(norm * self.contrast, self.gamma)
|
|
570
1157
|
cmap = getattr(cm, self.cmap_name, cm.viridis)
|
|
@@ -592,10 +1179,11 @@ class TiffViewer(QMainWindow):
|
|
|
592
1179
|
|
|
593
1180
|
tif_path = self.tif_path
|
|
594
1181
|
|
|
595
|
-
if os.path.dirname(self.tif_path).endswith(".gdb"):
|
|
1182
|
+
if tif_path and os.path.dirname(self.tif_path).endswith(".gdb"):
|
|
596
1183
|
tif_path = f"OpenFileGDB:{os.path.dirname(self.tif_path)}:{os.path.basename(self.tif_path)}"
|
|
597
1184
|
|
|
598
|
-
|
|
1185
|
+
import rasterio as rio_module
|
|
1186
|
+
with rio_module.open(tif_path) as src:
|
|
599
1187
|
self.band = band_num
|
|
600
1188
|
arr = src.read(self.band).astype(np.float32)
|
|
601
1189
|
nd = src.nodata
|
|
@@ -638,13 +1226,21 @@ class TiffViewer(QMainWindow):
|
|
|
638
1226
|
|
|
639
1227
|
# Colormap toggle (single-band only)
|
|
640
1228
|
elif not self.rgb_mode and k == Qt.Key.Key_M:
|
|
641
|
-
|
|
1229
|
+
# For NetCDF files, cycle through three colormaps
|
|
1230
|
+
if hasattr(self, 'cmap_names'):
|
|
1231
|
+
self.cmap_index = (self.cmap_index + 1) % len(self.cmap_names)
|
|
1232
|
+
self.cmap_name = self.cmap_names[self.cmap_index]
|
|
1233
|
+
print(f"Colormap: {self.cmap_name}")
|
|
1234
|
+
# For other files, toggle between two colormaps
|
|
1235
|
+
else:
|
|
1236
|
+
self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
|
|
642
1237
|
self.update_pixmap()
|
|
643
1238
|
|
|
644
1239
|
# Band switch
|
|
645
1240
|
elif k == Qt.Key.Key_BracketRight:
|
|
646
1241
|
if hasattr(self, "band_index"): # HDF/NetCDF mode
|
|
647
1242
|
self.band_index = (self.band_index + 1) % self.band_count
|
|
1243
|
+
self.data = self.get_current_frame()
|
|
648
1244
|
self.update_pixmap()
|
|
649
1245
|
self.update_title()
|
|
650
1246
|
elif not self.rgb_mode: # GeoTIFF single-band mode
|
|
@@ -654,11 +1250,29 @@ class TiffViewer(QMainWindow):
|
|
|
654
1250
|
elif k == Qt.Key.Key_BracketLeft:
|
|
655
1251
|
if hasattr(self, "band_index"): # HDF/NetCDF mode
|
|
656
1252
|
self.band_index = (self.band_index - 1) % self.band_count
|
|
1253
|
+
self.data = self.get_current_frame()
|
|
657
1254
|
self.update_pixmap()
|
|
658
1255
|
self.update_title()
|
|
659
1256
|
elif not self.rgb_mode: # GeoTIFF single-band mode
|
|
660
1257
|
new_band = self.band - 1 if self.band > 1 else self.band_count
|
|
661
1258
|
self.load_band(new_band)
|
|
1259
|
+
|
|
1260
|
+
# NetCDF time/dimension navigation with Page Up/Down
|
|
1261
|
+
elif k == Qt.Key.Key_PageUp:
|
|
1262
|
+
if hasattr(self, '_has_time_dim') and self._has_time_dim:
|
|
1263
|
+
try:
|
|
1264
|
+
# Call the next_time_step method
|
|
1265
|
+
self.next_time_step()
|
|
1266
|
+
except Exception as e:
|
|
1267
|
+
print(f"Error handling PageUp: {e}")
|
|
1268
|
+
|
|
1269
|
+
elif k == Qt.Key.Key_PageDown:
|
|
1270
|
+
if hasattr(self, '_has_time_dim') and self._has_time_dim:
|
|
1271
|
+
try:
|
|
1272
|
+
# Call the prev_time_step method
|
|
1273
|
+
self.prev_time_step()
|
|
1274
|
+
except Exception as e:
|
|
1275
|
+
print(f"Error handling PageDown: {e}")
|
|
662
1276
|
|
|
663
1277
|
elif k == Qt.Key.Key_R:
|
|
664
1278
|
self.contrast = 1.0
|
|
@@ -670,36 +1284,7 @@ class TiffViewer(QMainWindow):
|
|
|
670
1284
|
super().keyPressEvent(ev)
|
|
671
1285
|
|
|
672
1286
|
|
|
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
|
-
|
|
1287
|
+
# --------------------------------- CLI ----------------------------------- #
|
|
703
1288
|
def run_viewer(
|
|
704
1289
|
tif_path,
|
|
705
1290
|
scale=None,
|
|
@@ -714,7 +1299,7 @@ def run_viewer(
|
|
|
714
1299
|
|
|
715
1300
|
"""Launch the TiffViewer app"""
|
|
716
1301
|
from PySide6.QtCore import Qt
|
|
717
|
-
|
|
1302
|
+
# QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
|
|
718
1303
|
app = QApplication(sys.argv)
|
|
719
1304
|
win = TiffViewer(
|
|
720
1305
|
tif_path,
|
|
@@ -733,7 +1318,7 @@ def run_viewer(
|
|
|
733
1318
|
import click
|
|
734
1319
|
|
|
735
1320
|
@click.command()
|
|
736
|
-
@click.version_option("0.2.
|
|
1321
|
+
@click.version_option("0.2.2", prog_name="viewtif")
|
|
737
1322
|
@click.argument("tif_path", required=False)
|
|
738
1323
|
@click.option("--band", default=1, show_default=True, type=int, help="Band number to display")
|
|
739
1324
|
@click.option("--scale", default=1.0, show_default=True, type=int, help="Scale factor for display")
|
|
@@ -742,10 +1327,9 @@ import click
|
|
|
742
1327
|
@click.option("--shapefile", multiple=True, type=str, help="One or more shapefiles to overlay")
|
|
743
1328
|
@click.option("--shp-color", default="white", show_default=True, help="Overlay color (name or #RRGGBB).")
|
|
744
1329
|
@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
|
-
|
|
1330
|
+
@click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file or variable in NetCDF file")
|
|
747
1331
|
def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width, subset):
|
|
748
|
-
"""Lightweight GeoTIFF viewer."""
|
|
1332
|
+
"""Lightweight GeoTIFF, NetCDF, and HDF viewer."""
|
|
749
1333
|
# --- Warn early if shapefile requested but geopandas missing ---
|
|
750
1334
|
if shapefile and not HAVE_GEO:
|
|
751
1335
|
print(
|
|
@@ -767,5 +1351,4 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
|
|
|
767
1351
|
)
|
|
768
1352
|
|
|
769
1353
|
if __name__ == "__main__":
|
|
770
|
-
main()
|
|
771
|
-
|
|
1354
|
+
main()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: viewtif
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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=egk8LkdTtbV77no4IUd_bHwpU7b0oCNE9z6qxpkOKa0,57343
|
|
2
|
+
viewtif-0.2.2.dist-info/METADATA,sha256=_7BJ66mI4kZzwSwTSG6fwWr5fe-USNzbGESpZvcNW7M,7280
|
|
3
|
+
viewtif-0.2.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
4
|
+
viewtif-0.2.2.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
|
|
5
|
+
viewtif-0.2.2.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
|