viewtif 0.2.5__py3-none-any.whl → 0.2.6__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 +302 -251
- {viewtif-0.2.5.dist-info → viewtif-0.2.6.dist-info}/METADATA +43 -27
- viewtif-0.2.6.dist-info/RECORD +7 -0
- viewtif-0.2.6.dist-info/licenses/LICENSE +21 -0
- viewtif-0.2.5.dist-info/RECORD +0 -6
- {viewtif-0.2.5.dist-info → viewtif-0.2.6.dist-info}/WHEEL +0 -0
- {viewtif-0.2.5.dist-info → viewtif-0.2.6.dist-info}/entry_points.txt +0 -0
viewtif/tif_viewer.py
CHANGED
|
@@ -15,7 +15,7 @@ Controls
|
|
|
15
15
|
Arrow keys or WASD : pan
|
|
16
16
|
C / V : increase/decrease contrast (works in RGB and single-band)
|
|
17
17
|
G / H : increase/decrease gamma (works in RGB and single-band)
|
|
18
|
-
M : toggle colormap
|
|
18
|
+
M : toggle colormap. Single-band: viridis/magma. NetCDF: RdBu_r/viridis/magma.
|
|
19
19
|
[ / ] : previous / next band (or time step) (single-band)
|
|
20
20
|
R : reset view
|
|
21
21
|
|
|
@@ -27,13 +27,12 @@ Examples
|
|
|
27
27
|
|
|
28
28
|
import sys
|
|
29
29
|
import os
|
|
30
|
-
import argparse
|
|
31
30
|
import numpy as np
|
|
32
31
|
import rasterio
|
|
33
32
|
from rasterio.transform import Affine
|
|
34
33
|
from PySide6.QtWidgets import (
|
|
35
34
|
QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
|
|
36
|
-
QScrollBar, QGraphicsPathItem, QVBoxLayout,
|
|
35
|
+
QScrollBar, QGraphicsPathItem, QVBoxLayout, QWidget, QStatusBar
|
|
37
36
|
)
|
|
38
37
|
from PySide6.QtGui import QImage, QPixmap, QPainter, QPen, QColor, QPainterPath
|
|
39
38
|
from PySide6.QtCore import Qt
|
|
@@ -41,8 +40,10 @@ from PySide6.QtCore import Qt
|
|
|
41
40
|
import matplotlib.cm as cm
|
|
42
41
|
import warnings
|
|
43
42
|
warnings.filterwarnings("ignore", category=RuntimeWarning, module="shapely")
|
|
43
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="rasterio")
|
|
44
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="xarray")
|
|
44
45
|
|
|
45
|
-
__version__ = "0.2.
|
|
46
|
+
__version__ = "0.2.6"
|
|
46
47
|
|
|
47
48
|
# Optional overlay deps
|
|
48
49
|
try:
|
|
@@ -166,9 +167,13 @@ class TiffViewer(QMainWindow):
|
|
|
166
167
|
rgb: list[int] | None = None,
|
|
167
168
|
rgbfiles: list[str] | None = None,
|
|
168
169
|
shapefiles: list[str] | None = None,
|
|
169
|
-
shp_color: str = "
|
|
170
|
+
shp_color: str = "cyan",
|
|
170
171
|
shp_width: float = 2,
|
|
171
172
|
subset: int | None = None,
|
|
173
|
+
vmin: float | None = None,
|
|
174
|
+
vmax: float | None = None,
|
|
175
|
+
cartopy: str = "on",
|
|
176
|
+
timestep: int | None = None,
|
|
172
177
|
):
|
|
173
178
|
super().__init__()
|
|
174
179
|
|
|
@@ -177,6 +182,13 @@ class TiffViewer(QMainWindow):
|
|
|
177
182
|
self.band = int(band)
|
|
178
183
|
self.rgb = rgb
|
|
179
184
|
self.rgbfiles = rgbfiles
|
|
185
|
+
self._user_vmin = vmin
|
|
186
|
+
self._user_vmax = vmax
|
|
187
|
+
self.cartopy_mode = cartopy.lower()
|
|
188
|
+
|
|
189
|
+
if not tif_path and not rgbfiles:
|
|
190
|
+
print("Usage: viewtif <file.tif>")
|
|
191
|
+
sys.exit(1)
|
|
180
192
|
|
|
181
193
|
self._scale_arg = max(1, int(scale or 1))
|
|
182
194
|
self._transform: Affine | None = None
|
|
@@ -206,41 +218,53 @@ class TiffViewer(QMainWindow):
|
|
|
206
218
|
self.data = arr
|
|
207
219
|
self.band_count = 3
|
|
208
220
|
self.rgb = [os.path.basename(red), os.path.basename(green), os.path.basename(blue)]
|
|
209
|
-
# Use common prefix for title if tif_path not passed
|
|
210
221
|
self.tif_path = self.tif_path or (os.path.commonprefix([red, green, blue]) or red)
|
|
211
222
|
|
|
212
223
|
elif tif_path:
|
|
213
|
-
|
|
214
|
-
|
|
224
|
+
|
|
225
|
+
# ---------------- Handle File Geodatabase (.gdb) ---------------- #
|
|
226
|
+
if tif_path.lower().endswith(".gdb") and ":" not in tif_path:
|
|
227
|
+
|
|
215
228
|
import re, subprocess
|
|
216
|
-
gdb_path = tif_path
|
|
229
|
+
gdb_path = tif_path
|
|
230
|
+
|
|
217
231
|
try:
|
|
218
|
-
out = subprocess.check_output(
|
|
232
|
+
out = subprocess.check_output(
|
|
233
|
+
["gdalinfo", "-norat", gdb_path],
|
|
234
|
+
text=True
|
|
235
|
+
)
|
|
219
236
|
rasters = re.findall(r"RASTER_DATASET=(\S+)", out)
|
|
237
|
+
|
|
220
238
|
if not rasters:
|
|
221
239
|
print(f"[WARN] No raster datasets found in {os.path.basename(gdb_path)}.")
|
|
222
240
|
sys.exit(0)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
except subprocess.CalledProcessError as e:
|
|
231
|
-
print(f"[WARN] Could not inspect FileGDB: {e}")
|
|
241
|
+
|
|
242
|
+
print(f"Found {len(rasters)} raster dataset{'s' if len(rasters) > 1 else ''}:")
|
|
243
|
+
for i, r in enumerate(rasters):
|
|
244
|
+
print(f"[{i}] {r}")
|
|
245
|
+
|
|
246
|
+
print("\nUse one of these names to open. For example, to open the first raster:")
|
|
247
|
+
print(f'viewtif "OpenFileGDB:{gdb_path}:{rasters[0]}"')
|
|
232
248
|
sys.exit(0)
|
|
233
249
|
|
|
234
|
-
|
|
250
|
+
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
251
|
+
print("[ERROR] This file requires full GDAL support.")
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
|
|
254
|
+
# Warn for large files
|
|
235
255
|
warn_if_large(tif_path, scale=self._scale_arg)
|
|
236
256
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
257
|
+
# ---------------------------------------------------------------
|
|
258
|
+
# Detect NetCDF
|
|
259
|
+
# ---------------------------------------------------------------
|
|
260
|
+
if tif_path.lower().endswith((".nc", ".netcdf")):
|
|
261
|
+
try:
|
|
262
|
+
import xarray as xr
|
|
263
|
+
except ModuleNotFoundError:
|
|
264
|
+
print("NetCDF support requires extra dependencies.")
|
|
265
|
+
print("Install them with: pip install viewtif[netcdf]")
|
|
266
|
+
sys.exit(0)
|
|
267
|
+
|
|
244
268
|
# Open the NetCDF file
|
|
245
269
|
ds = xr.open_dataset(tif_path)
|
|
246
270
|
|
|
@@ -270,11 +294,11 @@ class TiffViewer(QMainWindow):
|
|
|
270
294
|
|
|
271
295
|
# Get coordinate info if available
|
|
272
296
|
self._has_geo_coords = False
|
|
273
|
-
if
|
|
297
|
+
if "lon" in ds.coords and "lat" in ds.coords:
|
|
274
298
|
self._has_geo_coords = True
|
|
275
299
|
self._lon_data = ds.lon.values
|
|
276
300
|
self._lat_data = ds.lat.values
|
|
277
|
-
elif
|
|
301
|
+
elif "longitude" in ds.coords and "latitude" in ds.coords:
|
|
278
302
|
self._has_geo_coords = True
|
|
279
303
|
self._lon_data = ds.longitude.values
|
|
280
304
|
self._lat_data = ds.latitude.values
|
|
@@ -282,29 +306,18 @@ class TiffViewer(QMainWindow):
|
|
|
282
306
|
# Handle time or other index dimension if present
|
|
283
307
|
self._has_time_dim = False
|
|
284
308
|
self._time_dim_name = None
|
|
285
|
-
time_index = 0
|
|
286
309
|
|
|
287
310
|
# Look for a time dimension first
|
|
288
311
|
if 'time' in var_data.dims:
|
|
289
312
|
self._has_time_dim = True
|
|
290
|
-
self._time_dim_name =
|
|
291
|
-
self._time_values = ds[
|
|
313
|
+
self._time_dim_name = "time"
|
|
314
|
+
self._time_values = ds["time"].values
|
|
292
315
|
self._time_index = 0
|
|
293
316
|
print(f"NetCDF time dimension detected: {len(self._time_values)} steps")
|
|
294
|
-
|
|
295
|
-
self.band_count = var_data.sizes['time']
|
|
317
|
+
self.band_count = var_data.sizes["time"]
|
|
296
318
|
self.band_index = 0
|
|
297
|
-
|
|
319
|
+
var_data = var_data.isel(time=0)
|
|
298
320
|
|
|
299
|
-
# Try to format time values for better display
|
|
300
|
-
time_units = getattr(ds.time, 'units', None)
|
|
301
|
-
time_calendar = getattr(ds.time, 'calendar', 'standard')
|
|
302
|
-
|
|
303
|
-
# Select first time step by default
|
|
304
|
-
var_data = var_data.isel(time=time_index)
|
|
305
|
-
|
|
306
|
-
# If no time dimension but variable has multiple dimensions,
|
|
307
|
-
# use the first non-spatial dimension as a "time" dimension
|
|
308
321
|
elif len(var_data.dims) > 2:
|
|
309
322
|
# Try to find a dimension that's not lat/lon
|
|
310
323
|
spatial_dims = ['lat', 'lon', 'latitude', 'longitude', 'y', 'x']
|
|
@@ -313,91 +326,73 @@ class TiffViewer(QMainWindow):
|
|
|
313
326
|
self._has_time_dim = True
|
|
314
327
|
self._time_dim_name = dim
|
|
315
328
|
self._time_values = ds[dim].values
|
|
316
|
-
self._time_index =
|
|
317
|
-
|
|
318
|
-
# Select first index by default
|
|
319
|
-
var_data = var_data.isel({dim: time_index})
|
|
329
|
+
self._time_index = 0
|
|
330
|
+
var_data = var_data.isel({dim: 0})
|
|
320
331
|
break
|
|
321
|
-
|
|
322
|
-
# Convert to numpy array
|
|
332
|
+
|
|
323
333
|
arr = var_data.values.astype(np.float32)
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
334
|
+
arr = np.squeeze(arr)
|
|
335
|
+
|
|
336
|
+
# --------------------------------------------------------
|
|
337
|
+
# Apply timestep jump after base array is created
|
|
338
|
+
# --------------------------------------------------------
|
|
339
|
+
if timestep is not None and self._has_time_dim:
|
|
340
|
+
ts = max(1, min(timestep, self.band_count))
|
|
341
|
+
self.band_index = ts - 1
|
|
342
|
+
print(f"[INFO] Jumping to timestep {ts}/{self.band_count}")
|
|
343
|
+
|
|
344
|
+
# Replace arr with the correct slice
|
|
345
|
+
frame = self._nc_var_data.isel({self._time_dim_name: self.band_index})
|
|
346
|
+
arr = np.squeeze(frame.values.astype(np.float32))
|
|
347
|
+
|
|
331
348
|
if arr.ndim >= 2:
|
|
332
349
|
h, w = arr.shape[:2]
|
|
333
350
|
if h * w > 4_000_000:
|
|
334
351
|
step = max(2, int((h * w / 4_000_000) ** 0.5))
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
else:
|
|
338
|
-
arr = arr[::step, ::step, :]
|
|
339
|
-
|
|
340
|
-
# --- Final assignments ---
|
|
352
|
+
arr = arr[::step, ::step]
|
|
353
|
+
|
|
341
354
|
self.data = arr
|
|
342
355
|
|
|
343
356
|
# Try to extract CRS from CF conventions
|
|
344
357
|
self._transform = None
|
|
345
358
|
self._crs = None
|
|
346
|
-
|
|
359
|
+
|
|
360
|
+
if "crs" in ds.variables:
|
|
347
361
|
try:
|
|
348
362
|
import rasterio.crs
|
|
349
|
-
crs_var = ds.variables[
|
|
350
|
-
if hasattr(crs_var,
|
|
363
|
+
crs_var = ds.variables["crs"]
|
|
364
|
+
if hasattr(crs_var, "spatial_ref"):
|
|
351
365
|
self._crs = rasterio.crs.CRS.from_wkt(crs_var.spatial_ref)
|
|
352
366
|
except Exception as e:
|
|
353
367
|
print(f"Could not parse CRS: {e}")
|
|
354
|
-
|
|
355
|
-
#
|
|
356
|
-
if
|
|
357
|
-
self.band_count = arr.shape[2]
|
|
358
|
-
else:
|
|
368
|
+
|
|
369
|
+
# Preserve time dimension if detected earlier
|
|
370
|
+
if not self._has_time_dim:
|
|
359
371
|
self.band_count = 1
|
|
360
|
-
|
|
361
|
-
|
|
372
|
+
self.band_index = 0
|
|
373
|
+
|
|
362
374
|
self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
375
|
+
|
|
376
|
+
if self._user_vmin is not None:
|
|
377
|
+
self.vmin = self._user_vmin
|
|
378
|
+
if self._user_vmax is not None:
|
|
379
|
+
self.vmax = self._user_vmax
|
|
380
|
+
|
|
369
381
|
self._use_cartopy = HAVE_CARTOPY and self._has_geo_coords
|
|
370
|
-
|
|
371
|
-
except ImportError as e:
|
|
372
|
-
if "xarray" in str(e) or "netCDF4" in str(e):
|
|
373
|
-
raise RuntimeError(
|
|
374
|
-
f"NetCDF support requires additional dependencies.\n"
|
|
375
|
-
f"Install them with: pip install viewtif[netcdf]\n"
|
|
376
|
-
f"Original error: {str(e)}"
|
|
377
|
-
)
|
|
378
|
-
else:
|
|
379
|
-
raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
|
|
380
|
-
except Exception as e:
|
|
381
|
-
raise RuntimeError(f"Error reading NetCDF file: {str(e)}")
|
|
382
|
-
|
|
383
382
|
|
|
384
|
-
#
|
|
385
|
-
#
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
pass
|
|
389
|
-
# --------------------- Detect HDF/HDF5 --------------------- #
|
|
390
|
-
elif tif_path and tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
|
|
383
|
+
# ---------------------------------------------------------------
|
|
384
|
+
# Detect HDF or HDF5
|
|
385
|
+
# ---------------------------------------------------------------
|
|
386
|
+
elif tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
|
|
391
387
|
try:
|
|
392
|
-
# Try GDAL first (best support for HDF subdatasets)
|
|
393
388
|
from osgeo import gdal
|
|
394
|
-
gdal.UseExceptions()
|
|
389
|
+
# gdal.UseExceptions()
|
|
395
390
|
|
|
396
391
|
ds = gdal.Open(tif_path)
|
|
397
392
|
subs = ds.GetSubDatasets()
|
|
398
393
|
|
|
399
394
|
if not subs:
|
|
400
|
-
raise ValueError("No subdatasets found in HDF
|
|
395
|
+
raise ValueError("No subdatasets found in HDF file.")
|
|
401
396
|
|
|
402
397
|
# Only list subsets if --subset not given
|
|
403
398
|
if subset is None:
|
|
@@ -409,18 +404,15 @@ class TiffViewer(QMainWindow):
|
|
|
409
404
|
|
|
410
405
|
# Validate subset index
|
|
411
406
|
if subset < 0 or subset >= len(subs):
|
|
412
|
-
raise ValueError(f"Invalid subset index {subset}.
|
|
407
|
+
raise ValueError(f"Invalid subset index {subset}.")
|
|
413
408
|
|
|
414
409
|
sub_name, desc = subs[subset]
|
|
415
410
|
print(f"\nOpening subdataset [{subset}]: {desc}")
|
|
416
411
|
sub_ds = gdal.Open(sub_name)
|
|
417
412
|
|
|
418
|
-
# --- Read once ---
|
|
419
413
|
arr = sub_ds.ReadAsArray().astype(np.float32)
|
|
420
|
-
#print(f"Raw array shape from GDAL: {arr.shape} (ndim={arr.ndim})")
|
|
421
|
-
|
|
422
|
-
# --- Normalize shape ---
|
|
423
414
|
arr = np.squeeze(arr)
|
|
415
|
+
|
|
424
416
|
if arr.ndim == 3:
|
|
425
417
|
# Convert from (bands, rows, cols) → (rows, cols, bands)
|
|
426
418
|
arr = np.transpose(arr, (1, 2, 0))
|
|
@@ -436,93 +428,46 @@ class TiffViewer(QMainWindow):
|
|
|
436
428
|
step = max(2, int((h * w / 4_000_000) ** 0.5))
|
|
437
429
|
arr = arr[::step, ::step] if arr.ndim == 2 else arr[::step, ::step, :]
|
|
438
430
|
|
|
439
|
-
# --- Final assignments ---
|
|
440
431
|
self.data = arr
|
|
441
432
|
self._transform = None
|
|
442
433
|
self._crs = None
|
|
443
434
|
self.band_count = arr.shape[2] if arr.ndim == 3 else 1
|
|
444
435
|
self.band_index = 0
|
|
445
436
|
self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
|
|
437
|
+
if getattr(self, "_scale_arg", 1) > 1:
|
|
438
|
+
print(f"[INFO] Value range (scaled): {self.vmin:.3f} -> {self.vmax:.3f}")
|
|
439
|
+
else:
|
|
440
|
+
print(f"[INFO] Value range: {self.vmin:.3f} -> {self.vmax:.3f}")
|
|
446
441
|
|
|
447
|
-
|
|
448
|
-
|
|
442
|
+
except ImportError as e:
|
|
443
|
+
if "osgeo" in str(e):
|
|
444
|
+
print("[ERROR] This file requires full GDAL support.")
|
|
445
|
+
# print("Install GDAL with:")
|
|
446
|
+
# print(" conda install -c conda-forge gdal")
|
|
447
|
+
sys.exit(1)
|
|
449
448
|
else:
|
|
450
|
-
print("
|
|
449
|
+
print(f"Error reading HDF file: {e}")
|
|
450
|
+
sys.exit(1)
|
|
451
451
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
452
|
+
except Exception as e:
|
|
453
|
+
print(f"Error reading HDF file: {e}")
|
|
454
|
+
sys.exit(1)
|
|
455
455
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
try:
|
|
460
|
-
import rasterio as rio
|
|
461
|
-
with rio.open(tif_path) as src:
|
|
462
|
-
print(f"[INFO] NetCDF file opened via rasterio")
|
|
463
|
-
print(f"[INFO] Data shape: {src.height} x {src.width} x {src.count} bands")
|
|
464
|
-
|
|
465
|
-
if src.count == 0:
|
|
466
|
-
raise ValueError("No bands found in NetCDF file.")
|
|
467
|
-
|
|
468
|
-
# Determine which band(s) to read
|
|
469
|
-
if self.band and self.band <= src.count:
|
|
470
|
-
band_indices = [self.band]
|
|
471
|
-
print(f"Opening band {self.band}/{src.count}")
|
|
472
|
-
elif rgb and all(b <= src.count for b in rgb):
|
|
473
|
-
band_indices = rgb
|
|
474
|
-
print(f"Opening bands {rgb} as RGB")
|
|
475
|
-
else:
|
|
476
|
-
band_indices = list(range(1, min(src.count + 1, 4))) # Read up to 3 bands
|
|
477
|
-
print(f"Opening bands {band_indices}")
|
|
478
|
-
|
|
479
|
-
# Read selected bands
|
|
480
|
-
bands = []
|
|
481
|
-
for b in band_indices:
|
|
482
|
-
band_data = src.read(b, out_shape=(src.height // self._scale_arg, src.width // self._scale_arg))
|
|
483
|
-
bands.append(band_data)
|
|
484
|
-
|
|
485
|
-
# Stack into array
|
|
486
|
-
arr = np.stack(bands, axis=-1).astype(np.float32) if len(bands) > 1 else bands[0].astype(np.float32)
|
|
487
|
-
|
|
488
|
-
# Handle no-data values
|
|
489
|
-
nd = src.nodata
|
|
490
|
-
if nd is not None:
|
|
491
|
-
if arr.ndim == 3:
|
|
492
|
-
arr[arr == nd] = np.nan
|
|
493
|
-
else:
|
|
494
|
-
arr[arr == nd] = np.nan
|
|
495
|
-
|
|
496
|
-
# Final assignments
|
|
497
|
-
self.data = arr
|
|
498
|
-
self._transform = src.transform
|
|
499
|
-
self._crs = src.crs
|
|
500
|
-
self.band_count = arr.shape[2] if arr.ndim == 3 else 1
|
|
501
|
-
self.band_index = 0
|
|
502
|
-
self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
|
|
503
|
-
|
|
504
|
-
if self.band_count > 1:
|
|
505
|
-
print(f"Loaded {self.band_count} bands — switch with [ and ] keys.")
|
|
506
|
-
else:
|
|
507
|
-
print("Loaded 1 band.")
|
|
508
|
-
except Exception as e:
|
|
509
|
-
raise RuntimeError(
|
|
510
|
-
f"Failed to read HDF/NetCDF file: {e}\n"
|
|
511
|
-
"For full HDF support, install GDAL: pip install GDAL"
|
|
512
|
-
)
|
|
513
|
-
|
|
514
|
-
# --------------------- Regular GeoTIFF --------------------- #
|
|
456
|
+
# ---------------------------------------------------------------
|
|
457
|
+
# Regular TIFF
|
|
458
|
+
# ---------------------------------------------------------------
|
|
515
459
|
else:
|
|
516
|
-
if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
|
|
517
|
-
tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
|
|
518
|
-
|
|
519
460
|
import rasterio as rio_module
|
|
520
461
|
with rio_module.open(tif_path) as src:
|
|
521
462
|
self._transform = src.transform
|
|
522
463
|
self._crs = src.crs
|
|
464
|
+
|
|
523
465
|
if rgb is not None:
|
|
524
|
-
bands = [
|
|
525
|
-
|
|
466
|
+
bands = [
|
|
467
|
+
src.read(b, out_shape=(src.height // self._scale_arg, src.width // self._scale_arg))
|
|
468
|
+
for b in rgb
|
|
469
|
+
]
|
|
470
|
+
|
|
526
471
|
arr = np.stack(bands, axis=-1).astype(np.float32)
|
|
527
472
|
nd = src.nodata
|
|
528
473
|
if nd is not None:
|
|
@@ -540,7 +485,14 @@ class TiffViewer(QMainWindow):
|
|
|
540
485
|
self.data = arr
|
|
541
486
|
self.band_count = src.count
|
|
542
487
|
|
|
543
|
-
|
|
488
|
+
if self.band_count == 1:
|
|
489
|
+
print("[INFO] This TIFF has 1 band.")
|
|
490
|
+
else:
|
|
491
|
+
print(
|
|
492
|
+
f"[INFO] This TIFF has {self.band_count} bands. "
|
|
493
|
+
"Use [ and ] to switch bands, or use --rgb R G B."
|
|
494
|
+
)
|
|
495
|
+
|
|
544
496
|
try:
|
|
545
497
|
stats = src.stats(self.band)
|
|
546
498
|
if stats and stats.min is not None and stats.max is not None:
|
|
@@ -549,9 +501,10 @@ class TiffViewer(QMainWindow):
|
|
|
549
501
|
raise ValueError("No stats in file")
|
|
550
502
|
except Exception:
|
|
551
503
|
self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
504
|
+
if getattr(self, "_scale_arg", 1) > 1:
|
|
505
|
+
print(f"[INFO] Value range (scaled): {self.vmin:.3f} -> {self.vmax:.3f}")
|
|
506
|
+
else:
|
|
507
|
+
print(f"[INFO] Value range: {self.vmin:.3f} -> {self.vmax:.3f}")
|
|
555
508
|
|
|
556
509
|
# Window title
|
|
557
510
|
self.update_title()
|
|
@@ -561,7 +514,6 @@ class TiffViewer(QMainWindow):
|
|
|
561
514
|
self.gamma = 1.0
|
|
562
515
|
|
|
563
516
|
# Colormap (single-band)
|
|
564
|
-
# For NetCDF temperature data, have three colormaps in rotation
|
|
565
517
|
if tif_path and tif_path.lower().endswith(('.nc', '.netcdf')):
|
|
566
518
|
self.cmap_names = ["RdBu_r", "viridis", "magma"] # three colormaps for NetCDF
|
|
567
519
|
self.cmap_index = 0 # start with RdBu_r
|
|
@@ -594,7 +546,9 @@ class TiffViewer(QMainWindow):
|
|
|
594
546
|
self._last_rgb = None
|
|
595
547
|
|
|
596
548
|
# --- Initial render ---
|
|
549
|
+
self._suppress_scale_print = True # Need for NetCDF
|
|
597
550
|
self.update_pixmap()
|
|
551
|
+
self._suppress_scale_print = False # Need for NetCDF
|
|
598
552
|
|
|
599
553
|
# Overlays (if any)
|
|
600
554
|
if self._shapefiles:
|
|
@@ -605,9 +559,32 @@ class TiffViewer(QMainWindow):
|
|
|
605
559
|
if self.pixmap_item is not None:
|
|
606
560
|
rect = self.pixmap_item.boundingRect()
|
|
607
561
|
self.scene.setSceneRect(rect)
|
|
562
|
+
|
|
563
|
+
# Fit first
|
|
608
564
|
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatioByExpanding)
|
|
609
|
-
|
|
565
|
+
|
|
566
|
+
# ----------------------------
|
|
567
|
+
# NetCDF needs a different scaling (appears smaller)
|
|
568
|
+
# ----------------------------
|
|
569
|
+
if hasattr(self, "_nc_var_name"):
|
|
570
|
+
# NetCDF view adjustment
|
|
571
|
+
self.view.scale(11.0, 11.0)
|
|
572
|
+
else:
|
|
573
|
+
# Default behavior for TIFF/HDF imagery
|
|
574
|
+
self.view.scale(7.0, 7.0)
|
|
575
|
+
|
|
610
576
|
self.view.centerOn(self.pixmap_item)
|
|
577
|
+
|
|
578
|
+
# Previous version below
|
|
579
|
+
# # --- Initial render ---
|
|
580
|
+
# self.update_pixmap()
|
|
581
|
+
# self.resize(1200, 800)
|
|
582
|
+
# if self.pixmap_item is not None:
|
|
583
|
+
# rect = self.pixmap_item.boundingRect()
|
|
584
|
+
# self.scene.setSceneRect(rect)
|
|
585
|
+
# self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatioByExpanding)
|
|
586
|
+
# self.view.scale(5, 5)
|
|
587
|
+
# self.view.centerOn(self.pixmap_item)
|
|
611
588
|
|
|
612
589
|
# ---------------------------- Overlays ---------------------------- #
|
|
613
590
|
def _geo_to_pixel(self, x: float, y: float):
|
|
@@ -731,23 +708,40 @@ class TiffViewer(QMainWindow):
|
|
|
731
708
|
|
|
732
709
|
# ----------------------- Title / Rendering ----------------------- #
|
|
733
710
|
def update_title(self):
|
|
734
|
-
"""
|
|
711
|
+
"""Add band before the title."""
|
|
735
712
|
import os
|
|
713
|
+
file_name = os.path.basename(self.tif_path)
|
|
736
714
|
|
|
737
715
|
if hasattr(self, "_has_time_dim") and self._has_time_dim:
|
|
738
|
-
nc_name = getattr(self, "_nc_var_name", "")
|
|
739
|
-
|
|
716
|
+
# nc_name = getattr(self, "_nc_var_name", "")
|
|
717
|
+
|
|
740
718
|
title = f"Time step {self.band_index + 1}/{self.band_count} — {file_name}"
|
|
719
|
+
|
|
741
720
|
|
|
742
721
|
elif hasattr(self, "band_index"):
|
|
743
|
-
title = f"Band {self.band_index + 1}/{self.band_count} — {
|
|
722
|
+
title = f"Band {self.band_index + 1}/{self.band_count} — {file_name}"
|
|
723
|
+
|
|
724
|
+
elif self.rgb_mode:
|
|
725
|
+
|
|
726
|
+
# Case 1: --rgbfiles → filenames
|
|
727
|
+
if self.rgbfiles:
|
|
728
|
+
files = [os.path.basename(p) for p in self.rgbfiles]
|
|
729
|
+
title = f"RGB ({files[0]}, {files[1]}, {files[2]})"
|
|
730
|
+
|
|
731
|
+
# Case 2: --rgb → band numbers
|
|
732
|
+
elif self.rgb:
|
|
733
|
+
r, g, b = self.rgb
|
|
734
|
+
title = f"RGB ({r}, {g}, {b}) — {file_name}"
|
|
735
|
+
|
|
736
|
+
else:
|
|
737
|
+
title = f"RGB — {file_name}"
|
|
744
738
|
|
|
745
|
-
elif self.rgb_mode
|
|
746
|
-
#
|
|
747
|
-
title = f"
|
|
739
|
+
elif not self.rgb_mode:
|
|
740
|
+
# TIFF uses self.band
|
|
741
|
+
title = f"Band {self.band}/{self.band_count} — {file_name}"
|
|
748
742
|
|
|
749
743
|
else:
|
|
750
|
-
title =
|
|
744
|
+
title = {file_name}
|
|
751
745
|
|
|
752
746
|
print(f"Title: {title}")
|
|
753
747
|
self.setWindowTitle(title)
|
|
@@ -794,7 +788,8 @@ class TiffViewer(QMainWindow):
|
|
|
794
788
|
return frame
|
|
795
789
|
|
|
796
790
|
step = int(self._scale_arg)
|
|
797
|
-
|
|
791
|
+
if not hasattr(self, "_suppress_scale_print"):
|
|
792
|
+
print(f"Applying scale factor {self._scale_arg} to current frame")
|
|
798
793
|
|
|
799
794
|
# Downsample the frame
|
|
800
795
|
frame = frame[::step, ::step]
|
|
@@ -829,10 +824,6 @@ class TiffViewer(QMainWindow):
|
|
|
829
824
|
if hasattr(frame, "values"):
|
|
830
825
|
frame = frame.values
|
|
831
826
|
|
|
832
|
-
# Apply same scaling factor (if any)
|
|
833
|
-
if hasattr(self, "_scale_arg") and self._scale_arg > 1:
|
|
834
|
-
step = int(self._scale_arg)
|
|
835
|
-
|
|
836
827
|
return frame.astype(np.float32)
|
|
837
828
|
|
|
838
829
|
def format_time_value(self, time_value):
|
|
@@ -888,7 +879,7 @@ class TiffViewer(QMainWindow):
|
|
|
888
879
|
return rgb
|
|
889
880
|
|
|
890
881
|
def _render_cartopy_map(self, data):
|
|
891
|
-
"""
|
|
882
|
+
""" Use cartopy for better visualization"""
|
|
892
883
|
import matplotlib.pyplot as plt
|
|
893
884
|
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
894
885
|
import cartopy.crs as ccrs
|
|
@@ -903,7 +894,6 @@ class TiffViewer(QMainWindow):
|
|
|
903
894
|
lats = self._lat_data
|
|
904
895
|
|
|
905
896
|
# Create contour plot
|
|
906
|
-
levels = 20
|
|
907
897
|
if hasattr(plt.cm, self.cmap_name):
|
|
908
898
|
cmap = getattr(plt.cm, self.cmap_name)
|
|
909
899
|
else:
|
|
@@ -922,8 +912,6 @@ class TiffViewer(QMainWindow):
|
|
|
922
912
|
norm_data = np.power(norm_data, self.gamma)
|
|
923
913
|
norm_data = norm_data * rng + vmin
|
|
924
914
|
|
|
925
|
-
# Downsample coordinates to match downsampled data shape
|
|
926
|
-
# --- Align coordinates with data shape (no stepping assumptions) ---
|
|
927
915
|
# Downsample coordinates to match downsampled data shape
|
|
928
916
|
data_height, data_width = data.shape[:2]
|
|
929
917
|
lat_samples = len(lats)
|
|
@@ -946,27 +934,37 @@ class TiffViewer(QMainWindow):
|
|
|
946
934
|
# print("[DEBUG] 2D lat grid ascending → flip lats_downsampled vertically")
|
|
947
935
|
lats_downsampled = np.flipud(lats_downsampled)
|
|
948
936
|
|
|
949
|
-
#
|
|
950
|
-
|
|
951
|
-
lons_downsampled = ((lons_downsampled + 180) % 360) - 180
|
|
937
|
+
# ---- Fix longitude and sort correctly ----
|
|
938
|
+
lons_ds = lons_downsampled.copy()
|
|
952
939
|
|
|
940
|
+
# Convert 0–360 → -180–180 only once
|
|
941
|
+
if lons_ds.max() > 180:
|
|
942
|
+
lons_ds = ((lons_ds + 180) % 360) - 180
|
|
953
943
|
|
|
954
|
-
#
|
|
955
|
-
|
|
944
|
+
# Sort and reorder data
|
|
945
|
+
sort_idx = np.argsort(lons_ds)
|
|
946
|
+
lons_ds = lons_ds[sort_idx]
|
|
947
|
+
data = data[:, sort_idx]
|
|
956
948
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
shading="auto"
|
|
949
|
+
extent = (
|
|
950
|
+
float(lons_ds[0]),
|
|
951
|
+
float(lons_ds[-1]),
|
|
952
|
+
float(lats_downsampled[-1]),
|
|
953
|
+
float(lats_downsampled[0])
|
|
963
954
|
)
|
|
964
955
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
956
|
+
vmin = self.vmin if self._user_vmin is not None else np.nanmin(data)
|
|
957
|
+
vmax = self.vmax if self._user_vmax is not None else np.nanmax(data)
|
|
958
|
+
|
|
959
|
+
# Changed from pcolormesh to imshow to prevent artefacts when used with cartopy
|
|
960
|
+
img = ax.imshow(
|
|
961
|
+
data,
|
|
962
|
+
extent=extent,
|
|
963
|
+
transform=ccrs.PlateCarree(),
|
|
964
|
+
cmap=cmap,
|
|
965
|
+
interpolation="nearest",
|
|
966
|
+
vmin=vmin,
|
|
967
|
+
vmax=vmax
|
|
970
968
|
)
|
|
971
969
|
|
|
972
970
|
# Add map features
|
|
@@ -1004,43 +1002,65 @@ class TiffViewer(QMainWindow):
|
|
|
1004
1002
|
|
|
1005
1003
|
# Close figure to prevent memory leak
|
|
1006
1004
|
plt.close(fig)
|
|
1005
|
+
del fig
|
|
1007
1006
|
|
|
1008
1007
|
return rgb
|
|
1009
|
-
|
|
1008
|
+
|
|
1010
1009
|
def update_pixmap(self):
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1010
|
+
# ------------------------------------------------------------------
|
|
1011
|
+
# Select respective data (a = single-band 2D, rgb = RGB array)
|
|
1012
|
+
# ------------------------------------------------------------------
|
|
1013
|
+
|
|
1014
|
+
rgb = None # ensure defined
|
|
1015
|
+
|
|
1016
|
+
# Case 1: RGB override (GeoTIFF or RGB-files)
|
|
1017
|
+
if self.rgb_mode:
|
|
1018
|
+
rgb = self.data
|
|
1019
|
+
a = None
|
|
1020
|
+
|
|
1021
|
+
# Case 2: Scientific multi-band (NetCDF/HDF)
|
|
1022
|
+
elif hasattr(self, "band_index"):
|
|
1023
|
+
# Always get consistent per-frame 2D data
|
|
1024
|
+
a = self.get_current_frame()
|
|
1025
|
+
|
|
1026
|
+
# Case 3: Regular GeoTIFF single-band
|
|
1019
1027
|
else:
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
rgb = self.data
|
|
1023
|
-
a = None
|
|
1024
|
-
else:
|
|
1025
|
-
a = self.data
|
|
1026
|
-
rgb = None
|
|
1027
|
-
# ----------------------------
|
|
1028
|
+
rgb = None
|
|
1029
|
+
a = self.data
|
|
1028
1030
|
|
|
1029
1031
|
# --- Render image ---
|
|
1030
|
-
#
|
|
1032
|
+
# Cartopy is only relevant for NetCDF
|
|
1031
1033
|
use_cartopy = False
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1034
|
+
|
|
1035
|
+
if hasattr(self, "_nc_var_name"):
|
|
1036
|
+
use_cartopy = (
|
|
1037
|
+
self.cartopy_mode == "on"
|
|
1038
|
+
and HAVE_CARTOPY
|
|
1039
|
+
and getattr(self, "_use_cartopy", False)
|
|
1040
|
+
and getattr(self, "_has_geo_coords", False)
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
# Inform user when cartopy was requested but cannot be used
|
|
1044
|
+
if self.cartopy_mode == "on" and not use_cartopy:
|
|
1045
|
+
if not HAVE_CARTOPY:
|
|
1046
|
+
print("[INFO] Cartopy not installed — using standard scientific rendering.")
|
|
1047
|
+
elif not getattr(self, "_use_cartopy", False):
|
|
1048
|
+
print("[INFO] This file lacks geospatial coordinates — cartopy disabled.")
|
|
1049
|
+
elif not getattr(self, "_has_geo_coords", False):
|
|
1050
|
+
print("[INFO] No lat/lon coordinates found — cartopy disabled.")
|
|
1051
|
+
|
|
1036
1052
|
if use_cartopy:
|
|
1037
|
-
# Render with cartopy for better geographic visualization
|
|
1038
1053
|
rgb = self._render_cartopy_map(a)
|
|
1039
1054
|
elif rgb is None:
|
|
1040
|
-
# Standard grayscale rendering for single-band
|
|
1055
|
+
# Standard grayscale rendering for single-band data
|
|
1041
1056
|
finite = np.isfinite(a)
|
|
1042
|
-
|
|
1057
|
+
|
|
1058
|
+
# Respect user-specified limits
|
|
1059
|
+
vmin = self._user_vmin if self._user_vmin is not None else np.nanmin(a)
|
|
1060
|
+
vmax = self._user_vmax if self._user_vmax is not None else np.nanmax(a)
|
|
1061
|
+
|
|
1043
1062
|
rng = max(vmax - vmin, 1e-12)
|
|
1063
|
+
|
|
1044
1064
|
norm = np.zeros_like(a, dtype=np.float32)
|
|
1045
1065
|
if np.any(finite):
|
|
1046
1066
|
norm[finite] = (a[finite] - vmin) / rng
|
|
@@ -1051,13 +1071,15 @@ class TiffViewer(QMainWindow):
|
|
|
1051
1071
|
else:
|
|
1052
1072
|
# True RGB mode (unchanged)
|
|
1053
1073
|
rgb = self._render_rgb()
|
|
1054
|
-
# ----------------------
|
|
1055
1074
|
|
|
1056
1075
|
h, w, _ = rgb.shape
|
|
1057
1076
|
self._last_rgb = rgb
|
|
1077
|
+
|
|
1058
1078
|
qimg = QImage(rgb.data, w, h, 3 * w, QImage.Format.Format_RGB888)
|
|
1059
1079
|
pix = QPixmap.fromImage(qimg)
|
|
1080
|
+
|
|
1060
1081
|
if self.pixmap_item is None:
|
|
1082
|
+
|
|
1061
1083
|
self.pixmap_item = QGraphicsPixmapItem(pix)
|
|
1062
1084
|
self.pixmap_item.setZValue(0.0)
|
|
1063
1085
|
self.scene.addItem(self.pixmap_item)
|
|
@@ -1082,6 +1104,7 @@ class TiffViewer(QMainWindow):
|
|
|
1082
1104
|
if nd is not None:
|
|
1083
1105
|
arr[arr == nd] = np.nan
|
|
1084
1106
|
self.data = arr
|
|
1107
|
+
|
|
1085
1108
|
self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
|
|
1086
1109
|
self.update_pixmap()
|
|
1087
1110
|
self.update_title()
|
|
@@ -1126,6 +1149,7 @@ class TiffViewer(QMainWindow):
|
|
|
1126
1149
|
# For other files, toggle between two colormaps
|
|
1127
1150
|
else:
|
|
1128
1151
|
self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
|
|
1152
|
+
print(f"Colormap: {self.cmap_name}")
|
|
1129
1153
|
self.update_pixmap()
|
|
1130
1154
|
|
|
1131
1155
|
# Band switch
|
|
@@ -1186,12 +1210,14 @@ def run_viewer(
|
|
|
1186
1210
|
shapefile=None,
|
|
1187
1211
|
shp_color=None,
|
|
1188
1212
|
shp_width=None,
|
|
1189
|
-
subset=None
|
|
1213
|
+
subset=None,
|
|
1214
|
+
vmin=None,
|
|
1215
|
+
vmax=None,
|
|
1216
|
+
cartopy="on",
|
|
1217
|
+
timestep=None
|
|
1190
1218
|
):
|
|
1191
1219
|
|
|
1192
1220
|
"""Launch the TiffViewer app"""
|
|
1193
|
-
from PySide6.QtCore import Qt
|
|
1194
|
-
# QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
|
|
1195
1221
|
app = QApplication(sys.argv)
|
|
1196
1222
|
win = TiffViewer(
|
|
1197
1223
|
tif_path,
|
|
@@ -1202,7 +1228,11 @@ def run_viewer(
|
|
|
1202
1228
|
shapefiles=shapefile,
|
|
1203
1229
|
shp_color=shp_color,
|
|
1204
1230
|
shp_width=shp_width,
|
|
1205
|
-
subset=subset
|
|
1231
|
+
subset=subset,
|
|
1232
|
+
vmin=vmin,
|
|
1233
|
+
vmax=vmax,
|
|
1234
|
+
cartopy=cartopy,
|
|
1235
|
+
timestep=timestep,
|
|
1206
1236
|
)
|
|
1207
1237
|
win.show()
|
|
1208
1238
|
sys.exit(app.exec())
|
|
@@ -1210,17 +1240,34 @@ def run_viewer(
|
|
|
1210
1240
|
import click
|
|
1211
1241
|
|
|
1212
1242
|
@click.command()
|
|
1213
|
-
@click.version_option(
|
|
1243
|
+
@click.version_option(__version__, prog_name="viewtif")
|
|
1214
1244
|
@click.argument("tif_path", required=False)
|
|
1215
1245
|
@click.option("--band", default=1, show_default=True, type=int, help="Band number to display")
|
|
1216
1246
|
@click.option("--scale", default=1.0, show_default=True, type=int, help="Scale factor for display")
|
|
1217
1247
|
@click.option("--rgb", nargs=3, type=int, help="Three band numbers for RGB, e.g. --rgb 4 3 2")
|
|
1218
1248
|
@click.option("--rgbfiles", nargs=3, type=str, help="Three single-band TIFFs for RGB, e.g. --rgbfiles B4.tif B3.tif B2.tif")
|
|
1219
1249
|
@click.option("--shapefile", multiple=True, type=str, help="One or more shapefiles to overlay")
|
|
1220
|
-
@click.option("--shp-color", default="
|
|
1250
|
+
@click.option("--shp-color", default="cyan", show_default=True, help="Overlay color (name or #RRGGBB).")
|
|
1221
1251
|
@click.option("--shp-width", default=1.0, show_default=True, type=float, help="Overlay line width (screen pixels).")
|
|
1222
1252
|
@click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file or variable in NetCDF file")
|
|
1223
|
-
|
|
1253
|
+
@click.option("--vmin", type=float, default=None, help="Manual minimum display value")
|
|
1254
|
+
@click.option("--vmax", type=float, default=None, help="Manual maximum display value")
|
|
1255
|
+
@click.option(
|
|
1256
|
+
"--timestep",
|
|
1257
|
+
type=int,
|
|
1258
|
+
default=None,
|
|
1259
|
+
help="For NetCDF files, jump directly to a specific time index (1-based)."
|
|
1260
|
+
)
|
|
1261
|
+
@click.option(
|
|
1262
|
+
"--cartopy",
|
|
1263
|
+
type=click.Choice(["on", "off"], case_sensitive=False),
|
|
1264
|
+
default="on",
|
|
1265
|
+
show_default=True,
|
|
1266
|
+
help="Use cartopy for NetCDF geospatial rendering."
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width, subset, vmin, vmax, cartopy, timestep):
|
|
1224
1271
|
"""Lightweight GeoTIFF, NetCDF, and HDF viewer."""
|
|
1225
1272
|
# --- Warn early if shapefile requested but geopandas missing ---
|
|
1226
1273
|
if shapefile and not HAVE_GEO:
|
|
@@ -1239,7 +1286,11 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
|
|
|
1239
1286
|
shapefile=shapefile,
|
|
1240
1287
|
shp_color=shp_color,
|
|
1241
1288
|
shp_width=shp_width,
|
|
1242
|
-
subset=subset
|
|
1289
|
+
subset=subset,
|
|
1290
|
+
vmin=vmin,
|
|
1291
|
+
vmax=vmax,
|
|
1292
|
+
cartopy=cartopy,
|
|
1293
|
+
timestep=timestep,
|
|
1243
1294
|
)
|
|
1244
1295
|
|
|
1245
1296
|
if __name__ == "__main__":
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: viewtif
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
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
|
|
7
7
|
Project-URL: Issues, https://github.com/nkeikon/tifviewer/issues
|
|
8
8
|
Author: Keiko Nomura
|
|
9
9
|
License: MIT
|
|
10
|
-
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.10
|
|
11
12
|
Requires-Dist: click>=8.1
|
|
12
13
|
Requires-Dist: matplotlib>=3.7
|
|
13
14
|
Requires-Dist: numpy>=1.23
|
|
@@ -26,9 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
26
27
|
# viewtif
|
|
27
28
|
[](https://pepy.tech/project/viewtif)
|
|
28
29
|
[](https://pypi.org/project/viewtif/)
|
|
29
|
-
[](https://pypi.org/project/viewtif/)
|
|
32
31
|
|
|
33
32
|
A lightweight GeoTIFF viewer for quick visualization directly from the command line.
|
|
34
33
|
|
|
@@ -39,7 +38,7 @@ You can visualize single-band GeoTIFFs, RGB composites, HDF, NetCDF files and sh
|
|
|
39
38
|
```bash
|
|
40
39
|
pip install viewtif
|
|
41
40
|
```
|
|
42
|
-
> **Note:** On Linux, you may need python3-tk, libqt5gui5, or PySide6 dependencies.
|
|
41
|
+
> **Note:** Requires Python 3.10 or higher. On Linux, you may need python3-tk, libqt5gui5, or PySide6 dependencies.
|
|
43
42
|
>
|
|
44
43
|
>`viewtif` requires a graphical display environment.
|
|
45
44
|
> It may not run properly on headless systems (e.g., HPC compute nodes or remote servers without X11 forwarding).
|
|
@@ -52,19 +51,20 @@ pip install "viewtif[geo]"
|
|
|
52
51
|
> **Note:** For macOS(zsh) users:
|
|
53
52
|
> Make sure to include the quotes, or zsh will interpret it as a pattern.
|
|
54
53
|
|
|
55
|
-
####
|
|
54
|
+
#### NetCDF support
|
|
55
|
+
```bash
|
|
56
|
+
pip install "viewtif[netcdf]"
|
|
57
|
+
```
|
|
58
|
+
> **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 rendering.
|
|
59
|
+
|
|
60
|
+
#### HDF/HDF5 & FileGDB support
|
|
61
|
+
GDAL is required to open `.hdf`, .`h5`, `.hdf5`, and `.gdb` files.
|
|
56
62
|
```bash
|
|
57
63
|
brew install gdal # macOS
|
|
58
64
|
sudo apt install gdal-bin python3-gdal # Linux
|
|
59
65
|
pip install GDAL
|
|
60
66
|
```
|
|
61
|
-
> **Note:** GDAL is required to open `.hdf`, .`h5`, and `.hdf5` files. If it’s missing, viewtif will display: `RuntimeError: HDF support requires GDAL.`
|
|
62
67
|
|
|
63
|
-
#### NetCDF support
|
|
64
|
-
```bash
|
|
65
|
-
pip install "viewtif[netcdf]"
|
|
66
|
-
```
|
|
67
|
-
> **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 rendering.
|
|
68
68
|
## Quick Start
|
|
69
69
|
```bash
|
|
70
70
|
# View a GeoTIFF
|
|
@@ -76,9 +76,16 @@ viewtif --rgbfiles \
|
|
|
76
76
|
HLS_B03.tif \
|
|
77
77
|
HLS_B02.tif
|
|
78
78
|
|
|
79
|
+
# View an RGB composite from a multi-band file
|
|
80
|
+
viewtif rgb.tif --rgb 4 3 2
|
|
81
|
+
|
|
79
82
|
# View with shapefile overlay
|
|
80
83
|
viewtif ECOSTRESS_LST.tif \
|
|
81
84
|
--shapefile Zip_Codes.shp
|
|
85
|
+
|
|
86
|
+
# Change the color and width
|
|
87
|
+
viewtif ECOSTRESS_LST.tif \
|
|
88
|
+
--shapefile Zip_Codes.shp --shp-color red --shp-width 2
|
|
82
89
|
```
|
|
83
90
|
### Update in v1.0.6: HDF/HDF5 support
|
|
84
91
|
`viewtif` can open `.hdf`, `.h5`, and `.hdf5` files that contain multiple subdatasets. When opened, it lists available subdatasets and lets you view one by index. You can also specify a band to display (default is the first band) or change bands interactively with '[' and ']'.
|
|
@@ -96,11 +103,7 @@ viewtif AG100.v003.33.-107.0001.h5 --subset 1 --band 3
|
|
|
96
103
|
`[WARN] raster lacks CRS/transform; cannot place overlays.`
|
|
97
104
|
|
|
98
105
|
### Update in v1.0.7: File Geodatabase (.gdb) support
|
|
99
|
-
`viewtif` can now open raster datasets stored inside Esri File Geodatabases (`.gdb`). When you open a .gdb directly, `viewtif
|
|
100
|
-
|
|
101
|
-
Most Rasterio installations already include the OpenFileGDB driver, so .gdb datasets often open without installing GDAL manually.
|
|
102
|
-
|
|
103
|
-
If you encounter: RuntimeError: GDB support requires GDAL, install GDAL as shown above to enable the driver.
|
|
106
|
+
`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.
|
|
104
107
|
|
|
105
108
|
```bash
|
|
106
109
|
# List available raster datasets
|
|
@@ -117,11 +120,19 @@ If the dataset is very large (e.g., >20 million pixels), it will pause and warn
|
|
|
117
120
|
You can proceed manually or rerun with the `--scale` option for a smaller, faster preview.
|
|
118
121
|
|
|
119
122
|
### Update in v0.2.2: NetCDF support with optional cartopy visualization
|
|
120
|
-
`viewtif` now supports NetCDF (`.nc`) files via xarray, with optional cartopy-based geographic visualization. Use`[`
|
|
123
|
+
`viewtif` now supports NetCDF (`.nc`) files via xarray, with optional cartopy-based geographic visualization. Use `[` / `]` to move forward or backward through time steps, and press M to switch between the three built-in colormaps ("RdBu_r", "viridis", "magma").
|
|
124
|
+
|
|
125
|
+
### Update in v0.2.6: New user controls for NetCDF visualization
|
|
126
|
+
- `--vmin [value] --vmax [value]` let you set the display range (for all file types).
|
|
127
|
+
- `--timestep [int]` lets you jump directly to a chosen time slice.
|
|
128
|
+
- `cartopy off` let you see raw NetCDF images.
|
|
121
129
|
|
|
122
130
|
#### Examples
|
|
123
131
|
```bash
|
|
124
132
|
viewtif data.nc
|
|
133
|
+
viewtif data.nc --vmin 280 --vmax 320
|
|
134
|
+
viewtif data.nc --timestep 100
|
|
135
|
+
viewtif data.nc --cartopy off
|
|
125
136
|
```
|
|
126
137
|
|
|
127
138
|
## Controls
|
|
@@ -131,7 +142,7 @@ viewtif data.nc
|
|
|
131
142
|
| Arrow keys or `WASD` | Pan |
|
|
132
143
|
| `C` / `V` | Increase / decrease contrast |
|
|
133
144
|
| `G` / `H` | Increase / decrease gamma |
|
|
134
|
-
| `M` | Toggle colormap
|
|
145
|
+
| `M` | Toggle colormap. Single-band: viridis/magma. NetCDF: RdBu_r/viridis/magma. |
|
|
135
146
|
| `[` / `]` | Previous / next band (or time step) |
|
|
136
147
|
| `R` | Reset view |
|
|
137
148
|
|
|
@@ -147,6 +158,8 @@ viewtif data.nc
|
|
|
147
158
|
- Zip_Codes.shp and associated files
|
|
148
159
|
- HLS_B04.tif, HLS_B03.tif, HLS_B02.tif (RGB sample)
|
|
149
160
|
- AG100.v003.33.-107.0001.h5 (HDF5 file)
|
|
161
|
+
- [tasmax_mon_ACCESS-CM2_ssp370_r1i1p1f1_gn_2021.nc](https://data.nas.nasa.gov/nex-dcp30-cmip6/monthly/ACCESS-CM2/ssp370/r1i1p1f1/tasmax/tasmax_mon_ACCESS-CM2_ssp370_r1i1p1f1_gn_2021.nc)
|
|
162
|
+
- [tas_Amon_KIOST-ESM_ssp585_r1i1p1f1_gr1_201501-210012.nc](https://dap.ceda.ac.uk/badc/cmip6/data/CMIP6/ScenarioMIP/KIOST/KIOST-ESM/ssp585/r1i1p1f1/Amon/tas/gr1/v20191106/tas_Amon_KIOST-ESM_ssp585_r1i1p1f1_gr1_201501-210012.nc)
|
|
150
163
|
|
|
151
164
|
## Credit & License
|
|
152
165
|
`viewtif` was inspired by the NASA JPL Thermal Viewer — Semi-Automated Georeferencer (GeoViewer v1.12) developed by Jake Longenecker (University of Miami Rosenstiel School of Marine, Atmospheric & Earth Science) while at the NASA Jet Propulsion Laboratory, California Institute of Technology, with inspiration from JPL’s ECOSTRESS geolocation batch workflow by Andrew Alamillo. The original GeoViewer was released under the MIT License (2025) and may be freely adapted with citation.
|
|
@@ -154,15 +167,18 @@ viewtif data.nc
|
|
|
154
167
|
## Citation
|
|
155
168
|
Longenecker, Jake; Lee, Christine; Hulley, Glynn; Cawse-Nicholson, Kerry; Purkis, Sam; Gleason, Art; Otis, Dan; Galdamez, Ileana; Meiseles, Jacquelyn. GeoViewer v1.12: NASA JPL Thermal Viewer—Semi-Automated Georeferencer User Guide & Reference Manual. Jet Propulsion Laboratory, California Institute of Technology, 2025. PDF.
|
|
156
169
|
|
|
157
|
-
## License
|
|
158
|
-
This project is released under the MIT License.
|
|
159
|
-
|
|
160
170
|
## Contributors
|
|
161
171
|
- [@HarshShinde0](https://github.com/HarshShinde0) — added mouse-wheel and trackpad zoom support; added NetCDF support with [@nkeikon](https://github.com/nkeikon)
|
|
162
172
|
- [@p-vdp](https://github.com/p-vdp) — added File Geodatabase (.gdb) raster support
|
|
163
173
|
|
|
174
|
+
## License
|
|
175
|
+
This project is released under the MIT License © 2025 Keiko Nomura.
|
|
176
|
+
|
|
177
|
+
If you find this tool useful, please consider supporting or acknowledging the author.
|
|
178
|
+
|
|
164
179
|
## Useful links
|
|
165
|
-
- https://www.linkedin.com/posts/desmond-lartey_geospatial-analysts-heads-up-check-out-activity-7386336488050925568-G5bN?utm_source=share&utm_medium=member_desktop&rcm=ACoAAAA0INsBVIO1f6nS_NkKqFh4Na1ZpoYo2fc
|
|
166
|
-
- https://www.linkedin.com/posts/keiko-nomura-0231891_dont-you-sometimes-just-want-to-see-a-activity-7383409138296528896-sbRM?utm_source=share&utm_medium=member_desktop&rcm=ACoAAAA0INsBVIO1f6nS_NkKqFh4Na1ZpoYo2fc
|
|
167
|
-
- https://www.linkedin.com/posts/keiko-nomura-0231891_now-you-can-see-hdf-files-from-the-command-activity-7383963721561399296-F5i0?utm_source=share&utm_medium=member_desktop&rcm=ACoAAAA0INsBVIO1f6nS_NkKqFh4Na1ZpoYo2fc
|
|
168
|
-
- https://www.linkedin.com/posts/keiko-nomura-0231891_you-can-now-view-netcdf-files-nc-from-activity-7386189562072670208-3A0V?utm_source=share&utm_medium=member_desktop&rcm=ACoAAAA0INsBVIO1f6nS_NkKqFh4Na1ZpoYo2fc
|
|
180
|
+
- [Demo by User](https://www.linkedin.com/posts/desmond-lartey_geospatial-analysts-heads-up-check-out-activity-7386336488050925568-G5bN?utm_source=share&utm_medium=member_desktop&rcm=ACoAAAA0INsBVIO1f6nS_NkKqFh4Na1ZpoYo2fc)
|
|
181
|
+
- [Demo at the initial release](https://www.linkedin.com/posts/keiko-nomura-0231891_dont-you-sometimes-just-want-to-see-a-activity-7383409138296528896-sbRM?utm_source=share&utm_medium=member_desktop&rcm=ACoAAAA0INsBVIO1f6nS_NkKqFh4Na1ZpoYo2fc)
|
|
182
|
+
- [Demo on HDF files](https://www.linkedin.com/posts/keiko-nomura-0231891_now-you-can-see-hdf-files-from-the-command-activity-7383963721561399296-F5i0?utm_source=share&utm_medium=member_desktop&rcm=ACoAAAA0INsBVIO1f6nS_NkKqFh4Na1ZpoYo2fc)
|
|
183
|
+
- [Demo on NetCDF files](https://www.linkedin.com/posts/keiko-nomura-0231891_you-can-now-view-netcdf-files-nc-from-activity-7386189562072670208-3A0V?utm_source=share&utm_medium=member_desktop&rcm=ACoAAAA0INsBVIO1f6nS_NkKqFh4Na1ZpoYo2fc)
|
|
184
|
+
- [Demo on Esri Geodatabases](https://www.linkedin.com/posts/keiko-nomura-0231891_did-you-know-viewtif-now-supports-esri-geodatabases-activity-7390165568688959488-pvpk?utm_source=share&utm_medium=member_desktop&rcm=ACoAAAA0INsBVIO1f6nS_NkKqFh4Na1ZpoYo2fc)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
viewtif/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
viewtif/tif_viewer.py,sha256=-vO-62ApjcG7lVVTHB5ggc9qSeB7YmdR1gmfskp-Vzg,51628
|
|
3
|
+
viewtif-0.2.6.dist-info/METADATA,sha256=CJZC9X2CtZjzQTEVg_MKTKMGNS7Vq8hcTUFEQck0Er4,9541
|
|
4
|
+
viewtif-0.2.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
+
viewtif-0.2.6.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
|
|
6
|
+
viewtif-0.2.6.dist-info/licenses/LICENSE,sha256=-aRH3Wo6exTLQLNp9yj6HptkUXXtNjdUSzumkyRsK4s,1069
|
|
7
|
+
viewtif-0.2.6.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Keiko Nomura
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
viewtif-0.2.5.dist-info/RECORD
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
viewtif/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
viewtif/tif_viewer.py,sha256=Vj5_t32_G3ME5WYkDATBfi3Rw5WbzIp6VMgi_RNj0O0,52334
|
|
3
|
-
viewtif-0.2.5.dist-info/METADATA,sha256=psLk-EhO4Cqdb01o-LgxjD41WfrICuCeYXK0SFWHdE8,8245
|
|
4
|
-
viewtif-0.2.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
-
viewtif-0.2.5.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
|
|
6
|
-
viewtif-0.2.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|