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