viewtif 0.2.5__py3-none-any.whl → 0.2.7__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 CHANGED
@@ -1,22 +1,25 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- TIFF Viewer (PySide6) view GeoTIFF, NetCDF, and HDF datasets with shapefile overlays.
3
+ TIFF Viewer (PySide6) view GeoTIFF, NetCDF, HDF, and File Geodatabase with vector overlays.
4
4
 
5
5
  Features:
6
6
  - Open GeoTIFFs (single or multi-band)
7
7
  - Combine separate single-band TIFFs into RGB
8
8
  - Apply global 2–98% stretch for RGB
9
9
  - Display NetCDF/HDF subsets with consistent scaling
10
- - Overlay shapefiles automatically reprojected to raster CRS
10
+ - Identify and display raster from File Geodatabase if any
11
+ - Overlay vector files automatically reprojected to raster CRS
11
12
  - Navigate bands/time steps interactively
13
+ - Remote file support: open files directly from HTTP/HTTPS URLs, S3, Google Cloud Storage, and Azure Blob Storage.
12
14
 
13
15
  Controls
14
16
  + / - : zoom in/out
15
17
  Arrow keys or WASD : pan
16
18
  C / V : increase/decrease contrast (works in RGB and single-band)
17
19
  G / H : increase/decrease gamma (works in RGB and single-band)
18
- M : toggle colormap (viridis <-> magma) — single-band only
19
- [ / ] : previous / next band (or time step) (single-band)
20
+ M : toggle colormap. Single-band: viridis/magma. NetCDF: RdBu_r/viridis/magma.
21
+ [ / ] : previous / next band (or time step)
22
+ B : toggle basemap (Natural Earth country boundaries)
20
23
  R : reset view
21
24
 
22
25
  Examples
@@ -27,51 +30,93 @@ Examples
27
30
 
28
31
  import sys
29
32
  import os
30
- import argparse
31
33
  import numpy as np
32
- import rasterio
33
- from rasterio.transform import Affine
34
34
  from PySide6.QtWidgets import (
35
35
  QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
36
- QScrollBar, QGraphicsPathItem, QVBoxLayout, QHBoxLayout, QWidget, QStatusBar
36
+ QScrollBar, QGraphicsPathItem, QVBoxLayout, QWidget, QStatusBar
37
37
  )
38
38
  from PySide6.QtGui import QImage, QPixmap, QPainter, QPen, QColor, QPainterPath
39
39
  from PySide6.QtCore import Qt
40
40
 
41
- import matplotlib.cm as cm
42
- import warnings
43
- warnings.filterwarnings("ignore", category=RuntimeWarning, module="shapely")
44
-
45
- __version__ = "0.2.5"
41
+ __version__ = "0.2.7"
42
+
43
+ # Lazy-loaded heavy imports
44
+ _rasterio = None
45
+ _cm = None
46
+ _gpd = None
47
+ _shapely_geoms = None
48
+
49
+ def _get_rasterio():
50
+ """Lazy-load rasterio (slow: ~0.5-1s)"""
51
+ global _rasterio
52
+ if _rasterio is None:
53
+ import rasterio
54
+ from rasterio.transform import Affine
55
+ import warnings
56
+ warnings.filterwarnings("ignore", category=UserWarning, module="rasterio")
57
+ warnings.filterwarnings("ignore", category=FutureWarning, module="osgeo")
58
+ _rasterio = rasterio
59
+ # Store Affine in the module for easy access
60
+ _rasterio.Affine = Affine
61
+ return _rasterio
62
+
63
+ def _get_matplotlib_cm():
64
+ """Lazy-load matplotlib colormap (slow: ~0.3-0.5s)"""
65
+ global _cm
66
+ if _cm is None:
67
+ import matplotlib.cm as cm
68
+ _cm = cm
69
+ return _cm
70
+
71
+ def _get_geopandas():
72
+ """Lazy-load geopandas (slow: ~1-2s)"""
73
+ global _gpd, _shapely_geoms
74
+ if _gpd is None:
75
+ try:
76
+ import geopandas as gpd
77
+ from shapely.geometry import (
78
+ LineString, MultiLineString, Polygon, MultiPolygon,
79
+ GeometryCollection, Point, MultiPoint
80
+ )
81
+ import warnings
82
+ warnings.filterwarnings("ignore", category=RuntimeWarning, module="shapely")
83
+ _gpd = gpd
84
+ _shapely_geoms = {
85
+ 'LineString': LineString,
86
+ 'MultiLineString': MultiLineString,
87
+ 'Polygon': Polygon,
88
+ 'MultiPolygon': MultiPolygon,
89
+ 'GeometryCollection': GeometryCollection,
90
+ 'Point': Point,
91
+ 'MultiPoint': MultiPoint
92
+ }
93
+ except ImportError:
94
+ _gpd = None
95
+ _shapely_geoms = None
96
+ return _gpd, _shapely_geoms
46
97
 
47
- # Optional overlay deps
98
+ # Check availability without importing
99
+ HAVE_GEO = True # Assume available, will be set False if import fails
48
100
  try:
49
- import geopandas as gpd
50
- from shapely.geometry import (
51
- LineString, MultiLineString, Polygon, MultiPolygon,
52
- GeometryCollection, Point, MultiPoint
53
- )
54
- HAVE_GEO = True
101
+ import importlib.util
102
+ HAVE_CARTOPY = importlib.util.find_spec("cartopy") is not None
55
103
  except Exception:
56
- HAVE_GEO = False
104
+ HAVE_CARTOPY = False
57
105
 
58
106
  # Optional NetCDF deps (lazy-loaded when needed)
59
107
  HAVE_NETCDF = False
60
108
  xr = None
61
109
  pd = None
62
110
 
63
- # Optional cartopy deps for better map visualization (lazy-loaded when needed)
64
- # Check if cartopy is available but don't import yet
65
- try:
66
- import importlib.util
67
- HAVE_CARTOPY = importlib.util.find_spec("cartopy") is not None
68
- except Exception:
69
- HAVE_CARTOPY = False
70
-
71
111
  def warn_if_large(tif_path, scale=1):
72
112
  """Warn and confirm before loading very large rasters (GeoTIFF, GDB, or HDF).
73
113
  Uses GDAL if available, falls back to rasterio for standard formats.
74
114
  """
115
+ # Skip size check for URLs, S3, and remote paths (can't reliably check remote file size)
116
+ if tif_path and tif_path.startswith(("http://", "https://", "s3://", "/vsi")):
117
+ return
118
+
119
+ rasterio = _get_rasterio()
75
120
  import os
76
121
  width = height = None
77
122
  size_mb = None
@@ -79,7 +124,6 @@ def warn_if_large(tif_path, scale=1):
79
124
  if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
80
125
  tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
81
126
 
82
-
83
127
  try:
84
128
  width, height = None, None
85
129
 
@@ -117,6 +161,7 @@ def warn_if_large(tif_path, scale=1):
117
161
  if ans not in ("y", "yes"):
118
162
  print("Cancelled.")
119
163
  sys.exit(0)
164
+
120
165
  except Exception as e:
121
166
  print(f"[INFO] Could not pre-check raster size: {e}")
122
167
 
@@ -160,15 +205,20 @@ class RasterView(QGraphicsView):
160
205
  class TiffViewer(QMainWindow):
161
206
  def __init__(
162
207
  self,
163
- tif_path: str | None,
164
- scale: int = 1,
165
- band: int = 1,
166
- rgb: list[int] | None = None,
167
- rgbfiles: list[str] | None = None,
168
- shapefiles: list[str] | None = None,
169
- shp_color: str = "white",
170
- shp_width: float = 2,
171
- subset: int | None = None,
208
+ tif_path,
209
+ scale=1,
210
+ band=1,
211
+ rgb=None,
212
+ rgbfiles=None,
213
+ shapefiles=None,
214
+ shp_color="cyan",
215
+ shp_width=2,
216
+ subset=None,
217
+ vmin=None,
218
+ vmax=None,
219
+ cartopy="on",
220
+ timestep=None,
221
+ nodata=None,
172
222
  ):
173
223
  super().__init__()
174
224
 
@@ -177,9 +227,40 @@ class TiffViewer(QMainWindow):
177
227
  self.band = int(band)
178
228
  self.rgb = rgb
179
229
  self.rgbfiles = rgbfiles
230
+ self._user_vmin = vmin
231
+ self._user_vmax = vmax
232
+ self.cartopy_mode = cartopy.lower()
233
+ self._nodata = nodata
234
+
235
+ if not tif_path and not rgbfiles:
236
+ print("Usage: viewtif <file.tif>")
237
+ sys.exit(1)
238
+
239
+ # Check if file exists (skip for URLs)
240
+ if tif_path and not tif_path.startswith(("http://", "https://", "s3://", "/vsi")):
241
+ # Extract actual file path from GDAL format strings
242
+ check_path = tif_path
243
+ if tif_path.startswith("OpenFileGDB:"):
244
+ # OpenFileGDB:path.gdb:layer -> path.gdb
245
+ parts = tif_path.split(":")
246
+ if len(parts) >= 2:
247
+ check_path = parts[1]
248
+ elif tif_path.startswith(("HDF4_EOS:", "HDF5:")):
249
+ # HDF format strings - extract file path
250
+ parts = tif_path.split(":")
251
+ if len(parts) >= 2:
252
+ check_path = parts[1]
253
+
254
+ if not os.path.exists(check_path):
255
+ print(f"[ERROR] File not found: {check_path}")
256
+ sys.exit(1)
257
+
258
+ # Load rasterio early since we'll need it
259
+ rasterio = _get_rasterio()
260
+ Affine = rasterio.Affine
180
261
 
181
262
  self._scale_arg = max(1, int(scale or 1))
182
- self._transform: Affine | None = None
263
+ self._transform = None
183
264
  self._crs = None
184
265
 
185
266
  # Overlay config/state
@@ -187,12 +268,21 @@ class TiffViewer(QMainWindow):
187
268
  self._shp_color = shp_color
188
269
  self._shp_width = float(shp_width)
189
270
  self._overlay_items: list[QGraphicsPathItem] = []
271
+
272
+ # Basemap state
273
+ self.base_gdf = None
274
+ self.basemap_items: list[QGraphicsPathItem] = []
190
275
 
191
276
  # --- Load data ---
192
277
  if rgbfiles:
278
+ # Check if all RGB files exist (skip for remote paths)
279
+ for f in rgbfiles:
280
+ if not f.startswith(("http://", "https://", "s3://", "/vsi")) and not os.path.exists(f):
281
+ print(f"[ERROR] File not found: {f}")
282
+ sys.exit(1)
283
+
193
284
  red, green, blue = rgbfiles
194
- import rasterio as rio_module
195
- with rio_module.open(red) as r, rio_module.open(green) as g, rio_module.open(blue) as b:
285
+ with rasterio.open(red) as r, rasterio.open(green) as g, rasterio.open(blue) as b:
196
286
  if (r.width, r.height) != (g.width, g.height) or (r.width, r.height) != (b.width, b.height):
197
287
  raise ValueError("All RGB files must have the same dimensions.")
198
288
  arr = np.stack([
@@ -200,47 +290,67 @@ class TiffViewer(QMainWindow):
200
290
  g.read(1, out_shape=(g.height // self._scale_arg, g.width // self._scale_arg)),
201
291
  b.read(1, out_shape=(b.height // self._scale_arg, b.width // self._scale_arg))
202
292
  ], axis=-1).astype(np.float32)
293
+
294
+ # Apply nodata mask if specified
295
+ if self._nodata is not None:
296
+ arr = np.where(arr == self._nodata, np.nan, arr)
297
+
203
298
  self._transform = r.transform
204
299
  self._crs = r.crs
205
300
 
206
301
  self.data = arr
207
302
  self.band_count = 3
208
- 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
- self.tif_path = self.tif_path or (os.path.commonprefix([red, green, blue]) or red)
303
+ # Extract filenames from paths (works for both local and remote)
304
+ self.rgb = [f.split('/')[-1] for f in [red, green, blue]]
305
+ self.tif_path = self.tif_path or red
211
306
 
212
307
  elif tif_path:
213
- # ---------------- Handle File Geodatabase (.gdb) ---------------- #
214
- if tif_path and tif_path.lower().endswith(".gdb") and ":" not in tif_path:
308
+
309
+ # ---------------- Handle File Geodatabase (.gdb) ---------------- #
310
+ if tif_path.lower().endswith(".gdb") and ":" not in tif_path:
311
+
215
312
  import re, subprocess
216
- gdb_path = tif_path # use full path to .gdb
313
+ gdb_path = tif_path
314
+
217
315
  try:
218
- out = subprocess.check_output(["gdalinfo", "-norat", gdb_path], text=True)
316
+ out = subprocess.check_output(
317
+ ["gdalinfo", "-norat", gdb_path],
318
+ text=True
319
+ )
219
320
  rasters = re.findall(r"RASTER_DATASET=(\S+)", out)
321
+
220
322
  if not rasters:
221
323
  print(f"[WARN] No raster datasets found in {os.path.basename(gdb_path)}.")
222
324
  sys.exit(0)
223
- else:
224
- print(f"Found {len(rasters)} raster dataset{'s' if len(rasters) > 1 else ''}:")
225
- for i, r in enumerate(rasters):
226
- print(f"[{i}] {r}")
227
- print("\nUse one of these names to open. For example, to open the first raster:")
228
- print(f'viewtif "OpenFileGDB:{gdb_path}:{rasters[0]}"')
229
- sys.exit(0)
230
- except subprocess.CalledProcessError as e:
231
- print(f"[WARN] Could not inspect FileGDB: {e}")
325
+
326
+ print(f"Found {len(rasters)} raster dataset{'s' if len(rasters) > 1 else ''}:")
327
+ for i, r in enumerate(rasters):
328
+ print(f"[{i}] {r}")
329
+
330
+ print("\nUse one of these names to open. For example, to open the first raster:")
331
+ print(f'viewtif "OpenFileGDB:{gdb_path}:{rasters[0]}"')
232
332
  sys.exit(0)
233
333
 
234
- # --- Warn for large files before loading ---
334
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
335
+ print("[ERROR] This file requires full GDAL support.")
336
+ sys.exit(1)
337
+
338
+ # Warn for large files
235
339
  warn_if_large(tif_path, scale=self._scale_arg)
236
340
 
237
- # --------------------- Detect NetCDF --------------------- #
238
- if tif_path and tif_path.lower().endswith((".nc", ".netcdf")):
239
- try:
240
- # Lazy-load NetCDF dependencies
241
- import xarray as xr
242
- import pandas as pd
243
-
341
+ # ---------------------------------------------------------------
342
+ # Detect NetCDF
343
+ # ---------------------------------------------------------------
344
+ if tif_path.lower().endswith((".nc", ".netcdf")):
345
+ try:
346
+ import xarray as xr
347
+ import warnings
348
+ warnings.filterwarnings("ignore", category=xr.SerializationWarning)
349
+ except ModuleNotFoundError:
350
+ print("NetCDF support requires extra dependencies.")
351
+ print("Install them with: pip install viewtif[netcdf]")
352
+ sys.exit(0)
353
+
244
354
  # Open the NetCDF file
245
355
  ds = xr.open_dataset(tif_path)
246
356
 
@@ -251,8 +361,12 @@ class TiffViewer(QMainWindow):
251
361
  # Auto-select the first variable if there's only one and no subset specified
252
362
  if len(data_vars) == 1 and subset is None:
253
363
  subset = 0
254
- # Only list variables if --subset not given and multiple variables exist
364
+ # List variables if --subset not given and multiple variables exist
255
365
  elif subset is None:
366
+ print(f"Found {len(data_vars)} variables in {os.path.basename(tif_path)}:")
367
+ for i, var in enumerate(data_vars):
368
+ print(f"[{i}] {var}")
369
+ print("\nUse --subset N to open a specific variable.")
256
370
  sys.exit(0)
257
371
 
258
372
  # Validate subset index
@@ -270,11 +384,11 @@ class TiffViewer(QMainWindow):
270
384
 
271
385
  # Get coordinate info if available
272
386
  self._has_geo_coords = False
273
- if 'lon' in ds.coords and 'lat' in ds.coords:
387
+ if "lon" in ds.coords and "lat" in ds.coords:
274
388
  self._has_geo_coords = True
275
389
  self._lon_data = ds.lon.values
276
390
  self._lat_data = ds.lat.values
277
- elif 'longitude' in ds.coords and 'latitude' in ds.coords:
391
+ elif "longitude" in ds.coords and "latitude" in ds.coords:
278
392
  self._has_geo_coords = True
279
393
  self._lon_data = ds.longitude.values
280
394
  self._lat_data = ds.latitude.values
@@ -282,29 +396,18 @@ class TiffViewer(QMainWindow):
282
396
  # Handle time or other index dimension if present
283
397
  self._has_time_dim = False
284
398
  self._time_dim_name = None
285
- time_index = 0
286
399
 
287
400
  # Look for a time dimension first
288
401
  if 'time' in var_data.dims:
289
402
  self._has_time_dim = True
290
- self._time_dim_name = 'time'
291
- self._time_values = ds['time'].values
403
+ self._time_dim_name = "time"
404
+ self._time_values = ds["time"].values
292
405
  self._time_index = 0
293
406
  print(f"NetCDF time dimension detected: {len(self._time_values)} steps")
294
-
295
- self.band_count = var_data.sizes['time']
407
+ self.band_count = var_data.sizes["time"]
296
408
  self.band_index = 0
297
- self._time_dim_name = 'time'
409
+ var_data = var_data.isel(time=0)
298
410
 
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
411
  elif len(var_data.dims) > 2:
309
412
  # Try to find a dimension that's not lat/lon
310
413
  spatial_dims = ['lat', 'lon', 'latitude', 'longitude', 'y', 'x']
@@ -313,91 +416,90 @@ class TiffViewer(QMainWindow):
313
416
  self._has_time_dim = True
314
417
  self._time_dim_name = dim
315
418
  self._time_values = ds[dim].values
316
- self._time_index = time_index
317
-
318
- # Select first index by default
319
- var_data = var_data.isel({dim: time_index})
419
+ self._time_index = 0
420
+ var_data = var_data.isel({dim: 0})
320
421
  break
321
-
322
- # Convert to numpy array
422
+
323
423
  arr = var_data.values.astype(np.float32)
324
-
325
- # Process array based on dimensions
326
- if arr.ndim > 2:
327
- # Keep only lat/lon dimensions for 3D+ arrays
328
- arr = np.squeeze(arr)
329
-
330
- # --- Downsample large arrays for responsiveness ---
424
+ arr = np.squeeze(arr)
425
+
426
+ # Check if variable has unsupported dimensions (e.g., vertical levels)
427
+ spatial_dims = ['lat', 'lon', 'latitude', 'longitude', 'y', 'x']
428
+ time_dims = ['time']
429
+
430
+ # Count spatial and time dimensions
431
+ spatial_count = sum(1 for d in var_data.dims if d in spatial_dims)
432
+ time_count = sum(1 for d in var_data.dims if d in time_dims)
433
+ total_dims = len(var_data.dims)
434
+
435
+ # Valid: 2 spatial dims, or 1 time + 2 spatial dims
436
+ is_valid = (total_dims == 2 and spatial_count == 2) or \
437
+ (total_dims == 3 and time_count == 1 and spatial_count == 2)
438
+
439
+ if not is_valid:
440
+ print(f"[ERROR] Variable has unsupported dimensions: {list(var_data.dims)}")
441
+ print(f"[INFO] viewtif only supports 2D (lat, lon) or 3D (time, lat, lon) NetCDF data")
442
+ sys.exit(1)
443
+
444
+ # --------------------------------------------------------
445
+ # Apply timestep jump after base array is created
446
+ # --------------------------------------------------------
447
+ if timestep is not None and self._has_time_dim:
448
+ ts = max(1, min(timestep, self.band_count))
449
+ self.band_index = ts - 1
450
+ print(f"[INFO] Jumping to timestep {ts}/{self.band_count}")
451
+
452
+ # Replace arr with the correct slice
453
+ frame = self._nc_var_data.isel({self._time_dim_name: self.band_index})
454
+ arr = np.squeeze(frame.values.astype(np.float32))
455
+
331
456
  if arr.ndim >= 2:
332
457
  h, w = arr.shape[:2]
333
458
  if h * w > 4_000_000:
334
459
  step = max(2, int((h * w / 4_000_000) ** 0.5))
335
- if arr.ndim == 2:
336
- arr = arr[::step, ::step]
337
- else:
338
- arr = arr[::step, ::step, :]
339
-
340
- # --- Final assignments ---
460
+ arr = arr[::step, ::step]
461
+
341
462
  self.data = arr
342
463
 
343
464
  # Try to extract CRS from CF conventions
344
465
  self._transform = None
345
466
  self._crs = None
346
- if 'crs' in ds.variables:
467
+
468
+ if "crs" in ds.variables:
347
469
  try:
348
- import rasterio.crs
349
- crs_var = ds.variables['crs']
350
- if hasattr(crs_var, 'spatial_ref'):
470
+ crs_var = ds.variables["crs"]
471
+ if hasattr(crs_var, "spatial_ref"):
351
472
  self._crs = rasterio.crs.CRS.from_wkt(crs_var.spatial_ref)
352
473
  except Exception as e:
353
474
  print(f"Could not parse CRS: {e}")
354
-
355
- # Set band info
356
- if arr.ndim == 3:
357
- self.band_count = arr.shape[2]
358
- else:
475
+
476
+ # Preserve time dimension if detected earlier
477
+ if not self._has_time_dim:
359
478
  self.band_count = 1
360
-
361
- self.band_index = 0
479
+ self.band_index = 0
480
+
362
481
  self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
363
-
364
- # --- If user specified --band, start there ---
365
- if self.band and self.band <= self.band_count:
366
- self.band_index = self.band - 1
367
-
368
- # Enable cartopy visualization if available
482
+
483
+ if self._user_vmin is not None:
484
+ self.vmin = self._user_vmin
485
+ if self._user_vmax is not None:
486
+ self.vmax = self._user_vmax
487
+
369
488
  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
489
 
384
- # # --- Universal size check before loading ---
385
- # warn_if_large(tif_path, scale=self._scale_arg)
386
-
387
- if False: # Placeholder for previous if condition
388
- pass
389
- # --------------------- Detect HDF/HDF5 --------------------- #
390
- elif tif_path and tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
490
+ # ---------------------------------------------------------------
491
+ # Detect HDF or HDF5
492
+ # ---------------------------------------------------------------
493
+ elif tif_path.lower().endswith((".hdf", ".h5", ".hdf5")):
391
494
  try:
392
- # Try GDAL first (best support for HDF subdatasets)
393
495
  from osgeo import gdal
394
- gdal.UseExceptions()
496
+ # gdal.UseExceptions()
395
497
 
396
498
  ds = gdal.Open(tif_path)
397
499
  subs = ds.GetSubDatasets()
398
500
 
399
501
  if not subs:
400
- raise ValueError("No subdatasets found in HDF/HDF5 file.")
502
+ raise ValueError("No subdatasets found in HDF file.")
401
503
 
402
504
  # Only list subsets if --subset not given
403
505
  if subset is None:
@@ -409,18 +511,30 @@ class TiffViewer(QMainWindow):
409
511
 
410
512
  # Validate subset index
411
513
  if subset < 0 or subset >= len(subs):
412
- raise ValueError(f"Invalid subset index {subset}. Valid range: 0–{len(subs)-1}")
514
+ raise ValueError(f"Invalid subset index {subset}.")
413
515
 
414
516
  sub_name, desc = subs[subset]
415
517
  print(f"\nOpening subdataset [{subset}]: {desc}")
416
518
  sub_ds = gdal.Open(sub_name)
417
519
 
418
- # --- Read once ---
419
520
  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
521
  arr = np.squeeze(arr)
522
+
523
+ # -------------------------------
524
+ # Apply nodata masking (HDF)
525
+ # -------------------------------
526
+ if self._nodata is not None:
527
+ arr[arr == self._nodata] = np.nan
528
+
529
+ # Try dataset-provided nodata as well
530
+ try:
531
+ band = sub_ds.GetRasterBand(1)
532
+ ds_nodata = band.GetNoDataValue()
533
+ if ds_nodata is not None:
534
+ arr[arr == ds_nodata] = np.nan
535
+ except Exception:
536
+ pass
537
+
424
538
  if arr.ndim == 3:
425
539
  # Convert from (bands, rows, cols) → (rows, cols, bands)
426
540
  arr = np.transpose(arr, (1, 2, 0))
@@ -436,97 +550,56 @@ class TiffViewer(QMainWindow):
436
550
  step = max(2, int((h * w / 4_000_000) ** 0.5))
437
551
  arr = arr[::step, ::step] if arr.ndim == 2 else arr[::step, ::step, :]
438
552
 
439
- # --- Final assignments ---
440
553
  self.data = arr
441
554
  self._transform = None
442
555
  self._crs = None
443
556
  self.band_count = arr.shape[2] if arr.ndim == 3 else 1
444
557
  self.band_index = 0
445
558
  self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
559
+ if getattr(self, "_scale_arg", 1) > 1:
560
+ print(f"[INFO] Value range (scaled): {self.vmin:.3f} -> {self.vmax:.3f}")
561
+ else:
562
+ print(f"[INFO] Value range: {self.vmin:.3f} -> {self.vmax:.3f}")
446
563
 
447
- if self.band_count > 1:
448
- print(f"This subdataset has {self.band_count} bands — switch with [ and ] keys.")
564
+ except ImportError as e:
565
+ if "osgeo" in str(e):
566
+ print("[ERROR] This file requires full GDAL support.")
567
+ # print("Install GDAL with:")
568
+ # print(" conda install -c conda-forge gdal")
569
+ sys.exit(1)
449
570
  else:
450
- print("This subdataset has 1 band.")
571
+ print(f"Error reading HDF file: {e}")
572
+ sys.exit(1)
451
573
 
452
- if self.band and self.band <= self.band_count:
453
- self.band_index = self.band - 1
454
- print(f"Opening band {self.band}/{self.band_count}")
574
+ except Exception as e:
575
+ print(f"Error reading HDF file: {e}")
576
+ sys.exit(1)
455
577
 
456
- except ImportError:
457
- # GDAL not available, try rasterio as fallback for NetCDF
458
- print("[INFO] GDAL not available, attempting to read HDF/NetCDF with rasterio...")
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 --------------------- #
578
+ # ---------------------------------------------------------------
579
+ # Regular TIFF
580
+ # ---------------------------------------------------------------
515
581
  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
- import rasterio as rio_module
520
- with rio_module.open(tif_path) as src:
582
+ with rasterio.open(tif_path) as src:
521
583
  self._transform = src.transform
522
584
  self._crs = src.crs
585
+
523
586
  if rgb is not None:
524
- bands = [src.read(b, out_shape=(src.height // self._scale_arg, src.width // self._scale_arg))
525
- for b in rgb]
587
+ bands = [
588
+ src.read(b, out_shape=(src.height // self._scale_arg, src.width // self._scale_arg))
589
+ for b in rgb
590
+ ]
591
+
526
592
  arr = np.stack(bands, axis=-1).astype(np.float32)
593
+
594
+ # Apply user-specified nodata first
595
+ if self._nodata is not None:
596
+ arr[arr == self._nodata] = np.nan
597
+
598
+ # Then apply file's nodata if present
527
599
  nd = src.nodata
528
600
  if nd is not None:
529
601
  arr[arr == nd] = np.nan
602
+
530
603
  self.data = arr
531
604
  self.band_count = 3
532
605
  else:
@@ -534,13 +607,28 @@ class TiffViewer(QMainWindow):
534
607
  self.band,
535
608
  out_shape=(src.height // self._scale_arg, src.width // self._scale_arg)
536
609
  ).astype(np.float32)
610
+
611
+ # Apply user-specified nodata first
612
+ if self._nodata is not None:
613
+ arr[arr == self._nodata] = np.nan
614
+
615
+ # Then apply file's nodata if present
537
616
  nd = src.nodata
538
617
  if nd is not None:
539
618
  arr[arr == nd] = np.nan
619
+
540
620
  self.data = arr
621
+
541
622
  self.band_count = src.count
542
623
 
543
- # single-band display range (fast stats or fallback)
624
+ if self.band_count == 1:
625
+ print("[INFO] This TIFF has 1 band.")
626
+ else:
627
+ print(
628
+ f"[INFO] This TIFF has {self.band_count} bands. "
629
+ "Use [ and ] to switch bands, or use --rgb R G B."
630
+ )
631
+
544
632
  try:
545
633
  stats = src.stats(self.band)
546
634
  if stats and stats.min is not None and stats.max is not None:
@@ -548,10 +636,12 @@ class TiffViewer(QMainWindow):
548
636
  else:
549
637
  raise ValueError("No stats in file")
550
638
  except Exception:
639
+ # Always calculate from masked array for consistency
551
640
  self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
552
-
553
- else:
554
- raise ValueError("Provide a TIFF path or --rgbfiles.")
641
+ if getattr(self, "_scale_arg", 1) > 1:
642
+ print(f"[INFO] Value range (scaled): {self.vmin:.3f} -> {self.vmax:.3f}")
643
+ else:
644
+ print(f"[INFO] Value range: {self.vmin:.3f} -> {self.vmax:.3f}")
555
645
 
556
646
  # Window title
557
647
  self.update_title()
@@ -561,7 +651,6 @@ class TiffViewer(QMainWindow):
561
651
  self.gamma = 1.0
562
652
 
563
653
  # Colormap (single-band)
564
- # For NetCDF temperature data, have three colormaps in rotation
565
654
  if tif_path and tif_path.lower().endswith(('.nc', '.netcdf')):
566
655
  self.cmap_names = ["RdBu_r", "viridis", "magma"] # three colormaps for NetCDF
567
656
  self.cmap_index = 0 # start with RdBu_r
@@ -586,6 +675,7 @@ class TiffViewer(QMainWindow):
586
675
 
587
676
  # Status bar
588
677
  self.setStatusBar(QStatusBar())
678
+ self.statusBar().showMessage("Keys: +/- zoom | C/V contrast | G/H gamma | M colormap | [/] bands or timestep | B basemap | R reset")
589
679
 
590
680
  # Set central widget
591
681
  self.setCentralWidget(self.main_widget)
@@ -594,7 +684,9 @@ class TiffViewer(QMainWindow):
594
684
  self._last_rgb = None
595
685
 
596
686
  # --- Initial render ---
687
+ self._suppress_scale_print = True # Need for NetCDF
597
688
  self.update_pixmap()
689
+ self._suppress_scale_print = False # Need for NetCDF
598
690
 
599
691
  # Overlays (if any)
600
692
  if self._shapefiles:
@@ -605,12 +697,35 @@ class TiffViewer(QMainWindow):
605
697
  if self.pixmap_item is not None:
606
698
  rect = self.pixmap_item.boundingRect()
607
699
  self.scene.setSceneRect(rect)
700
+
701
+ # Fit first
608
702
  self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatioByExpanding)
609
- self.view.scale(5, 5)
703
+
704
+ # ----------------------------
705
+ # NetCDF needs a different scaling (appears smaller)
706
+ # ----------------------------
707
+ if hasattr(self, "_nc_var_name"):
708
+ # NetCDF view adjustment
709
+ self.view.scale(11.0, 11.0)
710
+ else:
711
+ # Default behavior for TIFF/HDF imagery
712
+ self.view.scale(7.0, 7.0)
713
+
610
714
  self.view.centerOn(self.pixmap_item)
715
+
716
+ # Previous version below
717
+ # # --- Initial render ---
718
+ # self.update_pixmap()
719
+ # self.resize(1200, 800)
720
+ # if self.pixmap_item is not None:
721
+ # rect = self.pixmap_item.boundingRect()
722
+ # self.scene.setSceneRect(rect)
723
+ # self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatioByExpanding)
724
+ # self.view.scale(5, 5)
725
+ # self.view.centerOn(self.pixmap_item)
611
726
 
612
727
  # ---------------------------- Overlays ---------------------------- #
613
- def _geo_to_pixel(self, x: float, y: float):
728
+ def _geo_to_pixel(self, x, y):
614
729
  """Map coords (raster CRS) -> image pixel coords (after downsampling)."""
615
730
  if self._transform is None:
616
731
  return None
@@ -618,11 +733,23 @@ class TiffViewer(QMainWindow):
618
733
  col, row = inv * (x, y)
619
734
  return (col / self._scale_arg, row / self._scale_arg)
620
735
 
621
- def _geom_to_qpath(self, geom) -> QPainterPath | None:
736
+ def _geom_to_qpath(self, geom):
622
737
  """
623
738
  Convert shapely geom (in raster CRS) to QPainterPath in *image pixel* coords.
624
739
  Z/M tolerant: only X,Y are used. Draws Points as tiny segments.
625
740
  """
741
+ _, shapely_geoms = _get_geopandas()
742
+ if shapely_geoms is None:
743
+ return None
744
+
745
+ LineString = shapely_geoms['LineString']
746
+ MultiLineString = shapely_geoms['MultiLineString']
747
+ Polygon = shapely_geoms['Polygon']
748
+ MultiPolygon = shapely_geoms['MultiPolygon']
749
+ GeometryCollection = shapely_geoms['GeometryCollection']
750
+ Point = shapely_geoms['Point']
751
+ MultiPoint = shapely_geoms['MultiPoint']
752
+
626
753
  def _coords_to_path(coords, path: QPainterPath):
627
754
  first = True
628
755
  for c in coords:
@@ -691,8 +818,13 @@ class TiffViewer(QMainWindow):
691
818
  return None
692
819
 
693
820
  def _add_shapefile_overlays(self):
694
- if not HAVE_GEO:
695
- print("[WARN] geopandas/shapely not available; --shapefile ignored.")
821
+ gpd, _ = _get_geopandas()
822
+ if gpd is None:
823
+ global HAVE_GEO
824
+ HAVE_GEO = False
825
+ print("[WARN] --shapefile requires geopandas and shapely.")
826
+ print(" Install them with: pip install viewtif[geo]")
827
+ print(" Proceeding without shapefile overlay.")
696
828
  return
697
829
  if self._crs is None or self._transform is None:
698
830
  print("[WARN] raster lacks CRS/transform; cannot place overlays.")
@@ -703,8 +835,12 @@ class TiffViewer(QMainWindow):
703
835
  pen.setCosmetic(True) # constant on-screen width
704
836
 
705
837
  for shp_path in self._shapefiles:
838
+ if not os.path.exists(shp_path):
839
+ print(f"[WARN] File not found: {shp_path}")
840
+ continue
706
841
  try:
707
842
  gdf = gpd.read_file(shp_path)
843
+
708
844
  if gdf.empty:
709
845
  continue
710
846
 
@@ -729,25 +865,242 @@ class TiffViewer(QMainWindow):
729
865
  except Exception as e:
730
866
  print(f"[WARN] Failed to draw overlay {os.path.basename(shp_path)}: {e}")
731
867
 
868
+ # ---------------------------- Basemap ---------------------------- #
869
+ def _load_basemap(self):
870
+ """Load Natural Earth basemap with timeout to avoid blocking."""
871
+ gpd, _ = _get_geopandas()
872
+ if gpd is None:
873
+ print("[WARN] geopandas not available; cannot load basemap.")
874
+ return
875
+
876
+ # Basemap not supported for NetCDF files
877
+ if hasattr(self, "_nc_var_name"):
878
+ print("[INFO] Basemap not supported for NetCDF files (cartopy used).")
879
+ return
880
+
881
+ if self._crs is None:
882
+ print("[WARN] Raster lacks CRS; cannot load basemap.")
883
+ return
884
+
885
+ # Get CRS info
886
+ crs_string = str(self._crs).upper()
887
+
888
+ # Try to get EPSG code
889
+ crs_code = None
890
+ try:
891
+ crs_code = self._crs.to_epsg()
892
+ except Exception:
893
+ pass
894
+
895
+ if crs_code is None:
896
+ import re
897
+ epsg_match = re.search(r'EPSG:(\d+)', crs_string)
898
+ if epsg_match:
899
+ crs_code = int(epsg_match.group(1))
900
+
901
+ # Block UTM zones (known to cause artifacts)
902
+ if crs_code and (32600 <= crs_code <= 32660 or 32700 <= crs_code <= 32760):
903
+ self._show_disabled_message(crs_code)
904
+ self.base_gdf = None
905
+ return
906
+
907
+ # Check if suitable for basemap
908
+ is_geographic = False
909
+ try:
910
+ is_geographic = self._crs.is_geographic
911
+ except Exception:
912
+ is_geographic = 'GEOGCS' in crs_string or 'GEOG' in crs_string
913
+
914
+ # Good projected CRS
915
+ good_crs = [4326, 3857, 3395, 4269, 4267]
916
+ is_approved = crs_code in good_crs if crs_code else False
917
+
918
+ # Equal-area projections (work well with basemap)
919
+ equal_area_keywords = ['ALBERS', 'EQUAL_AREA', 'LAMBERT_AZIMUTHAL_EQUAL_AREA']
920
+ is_equal_area = any(kw in crs_string for kw in equal_area_keywords)
921
+
922
+ # Allow if: geographic OR approved OR equal-area
923
+ if not (is_geographic or is_approved or is_equal_area):
924
+ self._show_disabled_message(crs_code)
925
+ self.base_gdf = None
926
+ return
927
+
928
+ # Load basemap
929
+ import requests
930
+ from io import BytesIO
931
+
932
+ url = "https://naturalearth.s3.amazonaws.com/110m_cultural/ne_110m_admin_0_countries.zip"
933
+ print("[INFO] Loading basemap (timeout 3s)...")
934
+
935
+ try:
936
+ resp = requests.get(url, timeout=3)
937
+ resp.raise_for_status()
938
+ except requests.exceptions.Timeout:
939
+ print("[WARN] Basemap download timed out (slow connection).")
940
+ self.base_gdf = None
941
+ return
942
+ except requests.exceptions.ConnectionError:
943
+ print("[WARN] Basemap not loaded (no internet connection).")
944
+ self.base_gdf = None
945
+ return
946
+ except Exception as e:
947
+ print(f"[WARN] Basemap download failed: {e}")
948
+ self.base_gdf = None
949
+ return
950
+
951
+ try:
952
+ zip_bytes = BytesIO(resp.content)
953
+ gdf = gpd.read_file(zip_bytes)
954
+
955
+ # Reproject to raster CRS
956
+ if gdf.crs != self._crs:
957
+ gdf = gdf.to_crs(self._crs)
958
+
959
+ self.base_gdf = gdf
960
+ # print("[INFO] Basemap loaded successfully")
961
+
962
+ except Exception as e:
963
+ print(f"[WARN] Basemap processing failed: {e}")
964
+ self.base_gdf = None
965
+ return
966
+
967
+ def _show_disabled_message(self, crs_code):
968
+ """Show location info when basemap is disabled."""
969
+ rasterio = _get_rasterio()
970
+ try:
971
+ if self._transform is not None:
972
+ h, w = self.data.shape[:2] if self.data.ndim == 2 else self.data.shape[:2]
973
+ from rasterio.warp import transform_bounds
974
+ west, south, east, north = transform_bounds(
975
+ self._crs, 'EPSG:4326',
976
+ self._transform.c,
977
+ self._transform.f + self._transform.e * h,
978
+ self._transform.c + self._transform.a * w,
979
+ self._transform.f
980
+ )
981
+ center_lon = (west + east) / 2
982
+ center_lat = (south + north) / 2
983
+
984
+ # Get continent info
985
+ continent_info = ""
986
+ country_info = ""
987
+ try:
988
+ import requests
989
+ from io import BytesIO
990
+ gpd, shapely_geoms = _get_geopandas()
991
+ if gpd is None or shapely_geoms is None:
992
+ raise ImportError("geopandas/shapely not available")
993
+ Point = shapely_geoms['Point']
994
+
995
+ url = "https://naturalearth.s3.amazonaws.com/110m_cultural/ne_110m_admin_0_countries.zip"
996
+ resp = requests.get(url, timeout=3)
997
+ resp.raise_for_status()
998
+
999
+ zip_bytes = BytesIO(resp.content)
1000
+ gdf = gpd.read_file(zip_bytes)
1001
+ center_point = Point(center_lon, center_lat)
1002
+
1003
+ if 'CONTINENT' in gdf.columns:
1004
+ containing = gdf[gdf.contains(center_point)]
1005
+ if not containing.empty:
1006
+ continent_info = containing.iloc[0]['CONTINENT']
1007
+ country_info = containing.iloc[0].get('NAME', 'unknown')
1008
+ else:
1009
+ import warnings
1010
+ warnings.filterwarnings("ignore", message="Geometry is in a geographic CRS")
1011
+ gdf['dist'] = gdf.distance(center_point)
1012
+ nearest = gdf.loc[gdf['dist'].idxmin()]
1013
+ continent_info = nearest['CONTINENT']
1014
+ country_info = f"near {nearest.get('NAME', 'unknown')}"
1015
+ except Exception:
1016
+ pass
1017
+
1018
+ if continent_info and country_info:
1019
+ print(f"[INFO] Location: {continent_info}, {country_info} ({center_lat:.4f}°, {center_lon:.4f}°)")
1020
+ else:
1021
+ print(f"[INFO] Location: {center_lat:.4f}°, {center_lon:.4f}°")
1022
+ print(f"[INFO] Basemap disabled for this projection (CRS: {crs_code or 'unknown'})")
1023
+ print("[INFO] Add your own boundaries with --shapefile <vector_file>")
1024
+ except Exception:
1025
+ print(f"[INFO] Basemap disabled for this projection (CRS: {crs_code or 'unknown'})")
1026
+ print("[INFO] Add your own boundaries with --shapefile <vector_file>")
1027
+
1028
+ def _draw_basemap(self):
1029
+ """Draw basemap using the loaded Natural Earth data."""
1030
+ if self.base_gdf is None:
1031
+ return
1032
+
1033
+ # Determine pen color based on theme
1034
+ palette = QApplication.palette()
1035
+ bg = palette.window().color()
1036
+ brightness = (bg.red() * 299 + bg.green() * 587 + bg.blue() * 114) / 1000
1037
+ pen = QPen(QColor(255, 255, 255) if brightness < 128 else QColor(80, 80, 80))
1038
+ pen.setWidthF(0.5)
1039
+ pen.setCosmetic(True)
1040
+
1041
+ # Clear existing basemap items
1042
+ for it in self.basemap_items:
1043
+ self.scene.removeItem(it)
1044
+ self.basemap_items.clear()
1045
+
1046
+ # Draw each geometry using pixel transformation
1047
+ for geom in self.base_gdf.geometry:
1048
+ if geom is None or geom.is_empty:
1049
+ continue
1050
+
1051
+ # Fix invalid geometries after reprojection
1052
+ if not geom.is_valid:
1053
+ try:
1054
+ geom = geom.buffer(0)
1055
+ except Exception:
1056
+ continue
1057
+
1058
+ qpath = self._geom_to_qpath(geom)
1059
+ if qpath is None or qpath.isEmpty():
1060
+ continue
1061
+
1062
+ item = QGraphicsPathItem(qpath)
1063
+ item.setPen(pen)
1064
+ item.setZValue(-100) # Draw behind raster
1065
+ self.scene.addItem(item)
1066
+ self.basemap_items.append(item)
1067
+
732
1068
  # ----------------------- Title / Rendering ----------------------- #
733
1069
  def update_title(self):
734
- """Show correct title for GeoTIFF or NetCDF time series."""
1070
+ """Add band before the title."""
735
1071
  import os
1072
+ file_name = os.path.basename(self.tif_path)
736
1073
 
737
1074
  if hasattr(self, "_has_time_dim") and self._has_time_dim:
738
- nc_name = getattr(self, "_nc_var_name", "")
739
- file_name = os.path.basename(self.tif_path)
1075
+ # nc_name = getattr(self, "_nc_var_name", "")
1076
+
740
1077
  title = f"Time step {self.band_index + 1}/{self.band_count} — {file_name}"
1078
+
741
1079
 
742
1080
  elif hasattr(self, "band_index"):
743
- title = f"Band {self.band_index + 1}/{self.band_count} — {os.path.basename(self.tif_path)}"
1081
+ title = f"Band {self.band_index + 1}/{self.band_count} — {file_name}"
1082
+
1083
+ elif self.rgb_mode:
1084
+
1085
+ # Case 1: --rgbfiles → filenames
1086
+ if self.rgbfiles:
1087
+ files = [os.path.basename(p) for p in self.rgbfiles]
1088
+ title = f"RGB ({files[0]}, {files[1]}, {files[2]})"
744
1089
 
745
- elif self.rgb_mode and self.rgb:
746
- # title = f"RGB {self.rgb} — {os.path.basename(self.tif_path)}"
747
- title = f"RGB {self.rgb}"
1090
+ # Case 2: --rgb → band numbers
1091
+ elif self.rgb:
1092
+ r, g, b = self.rgb
1093
+ title = f"RGB ({r}, {g}, {b}) — {file_name}"
1094
+
1095
+ else:
1096
+ title = f"RGB — {file_name}"
1097
+
1098
+ elif not self.rgb_mode:
1099
+ # TIFF uses self.band
1100
+ title = f"Band {self.band}/{self.band_count} — {file_name}"
748
1101
 
749
1102
  else:
750
- title = os.path.basename(self.tif_path)
1103
+ title = {file_name}
751
1104
 
752
1105
  print(f"Title: {title}")
753
1106
  self.setWindowTitle(title)
@@ -794,7 +1147,8 @@ class TiffViewer(QMainWindow):
794
1147
  return frame
795
1148
 
796
1149
  step = int(self._scale_arg)
797
- print(f"Applying scale factor {step} to current frame")
1150
+ if not hasattr(self, "_suppress_scale_print"):
1151
+ print(f"Applying scale factor {self._scale_arg} to current frame")
798
1152
 
799
1153
  # Downsample the frame
800
1154
  frame = frame[::step, ::step]
@@ -829,10 +1183,6 @@ class TiffViewer(QMainWindow):
829
1183
  if hasattr(frame, "values"):
830
1184
  frame = frame.values
831
1185
 
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
1186
  return frame.astype(np.float32)
837
1187
 
838
1188
  def format_time_value(self, time_value):
@@ -861,6 +1211,11 @@ class TiffViewer(QMainWindow):
861
1211
  return time_str
862
1212
 
863
1213
  def _render_rgb(self):
1214
+ import warnings
1215
+ warnings.filterwarnings("ignore", message="invalid value encountered in cast")
1216
+
1217
+ cm = _get_matplotlib_cm()
1218
+
864
1219
  if self.rgb_mode:
865
1220
  arr = self.data
866
1221
  finite = np.isfinite(arr)
@@ -888,12 +1243,19 @@ class TiffViewer(QMainWindow):
888
1243
  return rgb
889
1244
 
890
1245
  def _render_cartopy_map(self, data):
891
- """Render a NetCDF variable with cartopy for better geographic visualization"""
1246
+ """ Use cartopy for better visualization"""
1247
+ import warnings
1248
+ warnings.filterwarnings("ignore", category=RuntimeWarning, module="shapely")
1249
+ warnings.filterwarnings("ignore", message="invalid value encountered in create_collection")
1250
+ warnings.filterwarnings("ignore", message="All-NaN slice encountered")
1251
+
892
1252
  import matplotlib.pyplot as plt
893
1253
  from matplotlib.backends.backend_agg import FigureCanvasAgg
894
1254
  import cartopy.crs as ccrs
895
1255
  import cartopy.feature as cfeature
896
1256
 
1257
+ cm = _get_matplotlib_cm()
1258
+
897
1259
  # Create a new figure with cartopy projection
898
1260
  fig = plt.figure(figsize=(12, 8), dpi=100)
899
1261
  ax = plt.axes(projection=ccrs.PlateCarree())
@@ -903,7 +1265,6 @@ class TiffViewer(QMainWindow):
903
1265
  lats = self._lat_data
904
1266
 
905
1267
  # Create contour plot
906
- levels = 20
907
1268
  if hasattr(plt.cm, self.cmap_name):
908
1269
  cmap = getattr(plt.cm, self.cmap_name)
909
1270
  else:
@@ -912,7 +1273,13 @@ class TiffViewer(QMainWindow):
912
1273
  # Apply contrast and gamma adjustments
913
1274
  finite = np.isfinite(data)
914
1275
  norm_data = np.zeros_like(data, dtype=np.float32)
915
- vmin, vmax = np.nanmin(data), np.nanmax(data)
1276
+
1277
+ # Check if we have any valid data
1278
+ if not np.any(finite):
1279
+ vmin, vmax = 0, 1 # Use dummy values for all-NaN data
1280
+ else:
1281
+ vmin, vmax = np.nanmin(data), np.nanmax(data)
1282
+
916
1283
  rng = max(vmax - vmin, 1e-12)
917
1284
 
918
1285
  if np.any(finite):
@@ -922,8 +1289,6 @@ class TiffViewer(QMainWindow):
922
1289
  norm_data = np.power(norm_data, self.gamma)
923
1290
  norm_data = norm_data * rng + vmin
924
1291
 
925
- # Downsample coordinates to match downsampled data shape
926
- # --- Align coordinates with data shape (no stepping assumptions) ---
927
1292
  # Downsample coordinates to match downsampled data shape
928
1293
  data_height, data_width = data.shape[:2]
929
1294
  lat_samples = len(lats)
@@ -946,27 +1311,37 @@ class TiffViewer(QMainWindow):
946
1311
  # print("[DEBUG] 2D lat grid ascending → flip lats_downsampled vertically")
947
1312
  lats_downsampled = np.flipud(lats_downsampled)
948
1313
 
949
- # Convert 0–360 longitude to −180–180 if needed
950
- if lons_downsampled.max() > 180:
951
- lons_downsampled = ((lons_downsampled + 180) % 360) - 180
1314
+ # ---- Fix longitude and sort correctly ----
1315
+ lons_ds = lons_downsampled.copy()
952
1316
 
1317
+ # Convert 0–360 → -180–180 only once
1318
+ if lons_ds.max() > 180:
1319
+ lons_ds = ((lons_ds + 180) % 360) - 180
953
1320
 
954
- # --- Build meshgrid AFTER any flip ---
955
- lon_grid, lat_grid = np.meshgrid(lons_downsampled, lats_downsampled, indexing="xy")
1321
+ # Sort and reorder data
1322
+ sort_idx = np.argsort(lons_ds)
1323
+ lons_ds = lons_ds[sort_idx]
1324
+ data = data[:, sort_idx]
956
1325
 
957
- # Use pcolormesh (more stable than contourf for gridded data)
958
- img = ax.pcolormesh(
959
- lon_grid, lat_grid, data,
960
- transform=ccrs.PlateCarree(),
961
- cmap=cmap,
962
- shading="auto"
1326
+ extent = (
1327
+ float(lons_ds[0]),
1328
+ float(lons_ds[-1]),
1329
+ float(lats_downsampled[-1]),
1330
+ float(lats_downsampled[0])
963
1331
  )
964
1332
 
965
- # Set extent from the 1D vectors (already flipped if needed)
966
- ax.set_extent(
967
- [lons_downsampled.min(), lons_downsampled.max(),
968
- lats_downsampled.min(), lats_downsampled.max()],
969
- crs=ccrs.PlateCarree()
1333
+ vmin = self.vmin if self._user_vmin is not None else np.nanmin(data)
1334
+ vmax = self.vmax if self._user_vmax is not None else np.nanmax(data)
1335
+
1336
+ # Changed from pcolormesh to imshow to prevent artefacts when used with cartopy
1337
+ img = ax.imshow(
1338
+ data,
1339
+ extent=extent,
1340
+ transform=ccrs.PlateCarree(),
1341
+ cmap=cmap,
1342
+ interpolation="nearest",
1343
+ vmin=vmin,
1344
+ vmax=vmax
970
1345
  )
971
1346
 
972
1347
  # Add map features
@@ -1004,85 +1379,134 @@ class TiffViewer(QMainWindow):
1004
1379
 
1005
1380
  # Close figure to prevent memory leak
1006
1381
  plt.close(fig)
1382
+ del fig
1007
1383
 
1008
1384
  return rgb
1009
-
1385
+
1010
1386
  def update_pixmap(self):
1011
- # --- Select display data ---
1012
- if hasattr(self, "band_index"):
1013
- # HDF or scientific multi-band
1014
- if self.data.ndim == 3:
1015
- a = self.data[:, :, self.band_index]
1016
- else:
1017
- a = self.data
1018
- rgb = None
1387
+ # ------------------------------------------------------------------
1388
+ # Select respective data (a = single-band 2D, rgb = RGB array)
1389
+ # ------------------------------------------------------------------
1390
+
1391
+ rgb = None # ensure defined
1392
+
1393
+ # Case 1: RGB override (GeoTIFF or RGB-files)
1394
+ if self.rgb_mode:
1395
+ rgb = self.data
1396
+ a = None
1397
+
1398
+ # Case 2: Scientific multi-band (NetCDF/HDF)
1399
+ elif hasattr(self, "band_index"):
1400
+ # Always get consistent per-frame 2D data
1401
+ a = self.get_current_frame()
1402
+
1403
+ # Case 3: Regular GeoTIFF single-band
1019
1404
  else:
1020
- # Regular GeoTIFF (could be RGB or single-band)
1021
- if self.rgb_mode: # user explicitly passed --rgb or --rgbtiles
1022
- rgb = self.data
1023
- a = None
1024
- else:
1025
- a = self.data
1026
- rgb = None
1027
- # ----------------------------
1405
+ rgb = None
1406
+ a = self.data
1028
1407
 
1029
1408
  # --- Render image ---
1030
- # Check if we should use cartopy for NetCDF visualization
1409
+ # Cartopy is only relevant for NetCDF
1031
1410
  use_cartopy = False
1032
- if hasattr(self, '_use_cartopy') and self._use_cartopy and HAVE_CARTOPY:
1033
- if hasattr(self, '_has_geo_coords') and self._has_geo_coords:
1034
- use_cartopy = True
1035
-
1411
+
1412
+ if hasattr(self, "_nc_var_name"):
1413
+ use_cartopy = (
1414
+ self.cartopy_mode == "on"
1415
+ and HAVE_CARTOPY
1416
+ and getattr(self, "_use_cartopy", False)
1417
+ and getattr(self, "_has_geo_coords", False)
1418
+ )
1419
+
1420
+ # Inform user when cartopy was requested but cannot be used
1421
+ if self.cartopy_mode == "on" and not use_cartopy:
1422
+ if not HAVE_CARTOPY:
1423
+ print("[INFO] Cartopy not installed — using standard scientific rendering.")
1424
+ elif not getattr(self, "_use_cartopy", False):
1425
+ print("[INFO] This file lacks geospatial coordinates — cartopy disabled.")
1426
+ elif not getattr(self, "_has_geo_coords", False):
1427
+ print("[INFO] No lat/lon coordinates found — cartopy disabled.")
1428
+
1036
1429
  if use_cartopy:
1037
- # Render with cartopy for better geographic visualization
1038
1430
  rgb = self._render_cartopy_map(a)
1039
1431
  elif rgb is None:
1040
- # Standard grayscale rendering for single-band (scientific) data
1432
+ # Standard grayscale rendering for single-band data
1433
+ cm = _get_matplotlib_cm()
1041
1434
  finite = np.isfinite(a)
1042
- vmin, vmax = np.nanmin(a), np.nanmax(a)
1043
- rng = max(vmax - vmin, 1e-12)
1044
- norm = np.zeros_like(a, dtype=np.float32)
1045
- if np.any(finite):
1046
- norm[finite] = (a[finite] - vmin) / rng
1047
- norm = np.clip(norm, 0, 1)
1048
- norm = np.power(norm * self.contrast, self.gamma)
1435
+
1436
+ # Check if we have any valid data
1437
+ if not np.any(finite):
1438
+ vmin = vmax = 0
1439
+ rng = 1e-12
1440
+ norm = np.zeros_like(a, dtype=np.float32)
1441
+ else:
1442
+ # Respect user-specified limits or calculate from valid pixels only
1443
+ if self._user_vmin is not None:
1444
+ vmin = self._user_vmin
1445
+ else:
1446
+ valid_pixels = a[finite]
1447
+ vmin = np.percentile(valid_pixels, 2) # 2nd percentile
1448
+
1449
+ if self._user_vmax is not None:
1450
+ vmax = self._user_vmax
1451
+ else:
1452
+ valid_pixels = a[finite]
1453
+ vmax = np.percentile(valid_pixels, 98) # 98th percentile
1454
+
1455
+ rng = max(vmax - vmin, 1e-12)
1456
+
1457
+ norm = np.zeros_like(a, dtype=np.float32)
1458
+ if np.any(finite):
1459
+ norm[finite] = (a[finite] - vmin) / rng
1460
+ norm = np.clip(norm, 0, 1)
1461
+ norm = np.power(norm * self.contrast, self.gamma)
1462
+
1049
1463
  cmap = getattr(cm, self.cmap_name, cm.viridis)
1050
1464
  rgb = (cmap(norm)[..., :3] * 255).astype(np.uint8)
1051
1465
  else:
1052
1466
  # True RGB mode (unchanged)
1053
1467
  rgb = self._render_rgb()
1054
- # ----------------------
1055
1468
 
1056
- h, w, _ = rgb.shape
1469
+
1470
+ h, w = rgb.shape[:2] # for both 2D and 3D
1057
1471
  self._last_rgb = rgb
1472
+
1058
1473
  qimg = QImage(rgb.data, w, h, 3 * w, QImage.Format.Format_RGB888)
1059
1474
  pix = QPixmap.fromImage(qimg)
1475
+
1060
1476
  if self.pixmap_item is None:
1477
+
1061
1478
  self.pixmap_item = QGraphicsPixmapItem(pix)
1062
1479
  self.pixmap_item.setZValue(0.0)
1063
1480
  self.scene.addItem(self.pixmap_item)
1064
1481
  else:
1065
1482
  self.pixmap_item.setPixmap(pix)
1066
-
1067
1483
  # ----------------------- Single-band switching ------------------- #
1068
1484
  def load_band(self, band_num: int):
1069
1485
  if self.rgb_mode:
1070
1486
  return
1071
1487
 
1488
+ rasterio = _get_rasterio()
1072
1489
  tif_path = self.tif_path
1073
1490
 
1074
1491
  if tif_path and os.path.dirname(self.tif_path).endswith(".gdb"):
1075
1492
  tif_path = f"OpenFileGDB:{os.path.dirname(self.tif_path)}:{os.path.basename(self.tif_path)}"
1076
1493
 
1077
- import rasterio as rio_module
1078
- with rio_module.open(tif_path) as src:
1494
+ with rasterio.open(tif_path) as src:
1079
1495
  self.band = band_num
1080
1496
  arr = src.read(self.band).astype(np.float32)
1497
+
1498
+ # Apply user-specified nodata first
1499
+ if self._nodata is not None:
1500
+ arr[arr == self._nodata] = np.nan
1501
+
1502
+ # Then apply file's nodata if present
1081
1503
  nd = src.nodata
1082
1504
  if nd is not None:
1083
1505
  arr[arr == nd] = np.nan
1084
1506
  self.data = arr
1507
+
1085
1508
  self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
1509
+ print(f"[INFO] Value range: {self.vmin:.3f} -> {self.vmax:.3f}")
1086
1510
  self.update_pixmap()
1087
1511
  self.update_title()
1088
1512
 
@@ -1106,15 +1530,31 @@ class TiffViewer(QMainWindow):
1106
1530
  elif k in (Qt.Key.Key_Down, Qt.Key.Key_S):
1107
1531
  vsb.setValue(vsb.value() + self.pan_step)
1108
1532
 
1109
- # Contrast / Gamma now work in both modes
1533
+ # Contrast / Gamma
1110
1534
  elif k == Qt.Key.Key_C:
1111
- self.contrast *= 1.1; self.update_pixmap()
1535
+ if hasattr(self, "_nc_var_name") and self.cartopy_mode == "on" and getattr(self, "_use_cartopy", False):
1536
+ print("[INFO] Contrast adjustment disabled with cartopy rendering")
1537
+ print("[INFO] Use --vmin/--vmax flags, or reopen with --cartopy off")
1538
+ else:
1539
+ self.contrast *= 1.1; self.update_pixmap()
1112
1540
  elif k == Qt.Key.Key_V:
1113
- self.contrast /= 1.1; self.update_pixmap()
1541
+ if hasattr(self, "_nc_var_name") and self.cartopy_mode == "on" and getattr(self, "_use_cartopy", False):
1542
+ print("[INFO] Contrast adjustment disabled with cartopy rendering")
1543
+ print("[INFO] Use --vmin/--vmax flags, or reopen with --cartopy off")
1544
+ else:
1545
+ self.contrast /= 1.1; self.update_pixmap()
1114
1546
  elif k == Qt.Key.Key_G:
1115
- self.gamma *= 1.1; self.update_pixmap()
1547
+ if hasattr(self, "_nc_var_name") and self.cartopy_mode == "on" and getattr(self, "_use_cartopy", False):
1548
+ print("[INFO] Gamma adjustment disabled with cartopy rendering")
1549
+ print("[INFO] Use --vmin/--vmax flags, or reopen with --cartopy off")
1550
+ else:
1551
+ self.gamma *= 1.1; self.update_pixmap()
1116
1552
  elif k == Qt.Key.Key_H:
1117
- self.gamma /= 1.1; self.update_pixmap()
1553
+ if hasattr(self, "_nc_var_name") and self.cartopy_mode == "on" and getattr(self, "_use_cartopy", False):
1554
+ print("[INFO] Gamma adjustment disabled with cartopy rendering")
1555
+ print("[INFO] Use --vmin/--vmax flags, or reopen with --cartopy off")
1556
+ else:
1557
+ self.gamma /= 1.1; self.update_pixmap()
1118
1558
 
1119
1559
  # Colormap toggle (single-band only)
1120
1560
  elif not self.rgb_mode and k == Qt.Key.Key_M:
@@ -1126,6 +1566,7 @@ class TiffViewer(QMainWindow):
1126
1566
  # For other files, toggle between two colormaps
1127
1567
  else:
1128
1568
  self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
1569
+ print(f"Colormap: {self.cmap_name}")
1129
1570
  self.update_pixmap()
1130
1571
 
1131
1572
  # Band switch
@@ -1133,8 +1574,15 @@ class TiffViewer(QMainWindow):
1133
1574
  if hasattr(self, "band_index"): # HDF/NetCDF mode
1134
1575
  self.band_index = (self.band_index + 1) % self.band_count
1135
1576
  self.data = self.get_current_frame()
1577
+
1578
+ # Recalculate and print value range for new band
1579
+ if self._user_vmin is None and self._user_vmax is None:
1580
+ self.vmin, self.vmax = np.nanmin(self.data), np.nanmax(self.data)
1581
+ print(f"[INFO] Value range: {self.vmin:.3f} -> {self.vmax:.3f}")
1582
+
1136
1583
  self.update_pixmap()
1137
1584
  self.update_title()
1585
+
1138
1586
  elif not self.rgb_mode: # GeoTIFF single-band mode
1139
1587
  new_band = self.band + 1 if self.band < self.band_count else 1
1140
1588
  self.load_band(new_band)
@@ -1143,28 +1591,37 @@ class TiffViewer(QMainWindow):
1143
1591
  if hasattr(self, "band_index"): # HDF/NetCDF mode
1144
1592
  self.band_index = (self.band_index - 1) % self.band_count
1145
1593
  self.data = self.get_current_frame()
1594
+
1595
+ # Recalculate and print value range for new band
1596
+ if self._user_vmin is None and self._user_vmax is None:
1597
+ self.vmin, self.vmax = np.nanmin(self.data), np.nanmax(self.data)
1598
+ print(f"[INFO] Value range: {self.vmin:.3f} -> {self.vmax:.3f}")
1599
+
1146
1600
  self.update_pixmap()
1147
1601
  self.update_title()
1602
+
1148
1603
  elif not self.rgb_mode: # GeoTIFF single-band mode
1149
1604
  new_band = self.band - 1 if self.band > 1 else self.band_count
1150
1605
  self.load_band(new_band)
1151
1606
 
1152
- # NetCDF time/dimension navigation with Page Up/Down
1153
- elif k == Qt.Key.Key_PageUp:
1154
- if hasattr(self, '_has_time_dim') and self._has_time_dim:
1155
- try:
1156
- # Call the next_time_step method
1157
- self.next_time_step()
1158
- except Exception as e:
1159
- print(f"Error handling PageUp: {e}")
1607
+ # Basemap toggle
1608
+ elif k == Qt.Key.Key_B:
1609
+ if self.basemap_items:
1610
+ # Basemap currently visible
1611
+ for it in self.basemap_items:
1612
+ self.scene.removeItem(it)
1613
+ self.basemap_items.clear()
1614
+ print("[INFO] Basemap removed")
1615
+ else:
1616
+ # Basemap not visible - load and display it
1617
+ if self.base_gdf is None:
1618
+ self._load_basemap()
1160
1619
 
1161
- elif k == Qt.Key.Key_PageDown:
1162
- if hasattr(self, '_has_time_dim') and self._has_time_dim:
1163
- try:
1164
- # Call the prev_time_step method
1165
- self.prev_time_step()
1166
- except Exception as e:
1167
- print(f"Error handling PageDown: {e}")
1620
+ if self.base_gdf is not None:
1621
+ self._draw_basemap()
1622
+ print("[INFO] Basemap displayed")
1623
+ # else:
1624
+ # print("[INFO] Basemap not available")
1168
1625
 
1169
1626
  elif k == Qt.Key.Key_R:
1170
1627
  self.contrast = 1.0
@@ -1186,12 +1643,15 @@ def run_viewer(
1186
1643
  shapefile=None,
1187
1644
  shp_color=None,
1188
1645
  shp_width=None,
1189
- subset=None
1646
+ subset=None,
1647
+ vmin=None,
1648
+ vmax=None,
1649
+ cartopy="on",
1650
+ timestep=None,
1651
+ nodata=None,
1190
1652
  ):
1191
1653
 
1192
1654
  """Launch the TiffViewer app"""
1193
- from PySide6.QtCore import Qt
1194
- # QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
1195
1655
  app = QApplication(sys.argv)
1196
1656
  win = TiffViewer(
1197
1657
  tif_path,
@@ -1202,7 +1662,12 @@ def run_viewer(
1202
1662
  shapefiles=shapefile,
1203
1663
  shp_color=shp_color,
1204
1664
  shp_width=shp_width,
1205
- subset=subset
1665
+ subset=subset,
1666
+ vmin=vmin,
1667
+ vmax=vmax,
1668
+ cartopy=cartopy,
1669
+ timestep=timestep,
1670
+ nodata=nodata,
1206
1671
  )
1207
1672
  win.show()
1208
1673
  sys.exit(app.exec())
@@ -1210,17 +1675,39 @@ def run_viewer(
1210
1675
  import click
1211
1676
 
1212
1677
  @click.command()
1213
- @click.version_option("0.2.5", prog_name="viewtif")
1678
+ @click.version_option(__version__, prog_name="viewtif")
1214
1679
  @click.argument("tif_path", required=False)
1215
1680
  @click.option("--band", default=1, show_default=True, type=int, help="Band number to display")
1216
- @click.option("--scale", default=1.0, show_default=True, type=int, help="Scale factor for display")
1681
+ @click.option("--scale", default=1, show_default=True, type=int, help="Downsample by factor N (e.g., --scale 5 loads 1/25 of pixels)")
1217
1682
  @click.option("--rgb", nargs=3, type=int, help="Three band numbers for RGB, e.g. --rgb 4 3 2")
1218
1683
  @click.option("--rgbfiles", nargs=3, type=str, help="Three single-band TIFFs for RGB, e.g. --rgbfiles B4.tif B3.tif B2.tif")
1219
- @click.option("--shapefile", multiple=True, type=str, help="One or more shapefiles to overlay")
1220
- @click.option("--shp-color", default="white", show_default=True, help="Overlay color (name or #RRGGBB).")
1221
- @click.option("--shp-width", default=1.0, show_default=True, type=float, help="Overlay line width (screen pixels).")
1684
+ @click.option("--shapefile", multiple=True, type=str, help="Vector overlay file(s) (shapefile, GeoJSON, etc.)")
1685
+ @click.option("--shp-color", default="cyan", show_default=True, help="Vector overlay color (name or #RRGGBB).")
1686
+ @click.option("--shp-width", default=1.0, show_default=True, type=float, help="Vector overlay line width (screen pixels).")
1222
1687
  @click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file or variable in NetCDF file")
1223
- def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width, subset):
1688
+ @click.option("--vmin", type=float, default=None, help="Manual minimum display value")
1689
+ @click.option("--vmax", type=float, default=None, help="Manual maximum display value")
1690
+ @click.option(
1691
+ "--timestep",
1692
+ type=int,
1693
+ default=None,
1694
+ help="For NetCDF files, jump directly to a specific time index (1-based)."
1695
+ )
1696
+ @click.option(
1697
+ "--cartopy",
1698
+ type=click.Choice(["on", "off"], case_sensitive=False),
1699
+ default="on",
1700
+ show_default=True,
1701
+ help="Use cartopy for NetCDF geospatial rendering."
1702
+ )
1703
+ @click.option(
1704
+ "--qgis",
1705
+ is_flag=True,
1706
+ help="Open in QGIS directly (skips viewer)"
1707
+ )
1708
+ @click.option("--nodata", type=float, default=None, help="Nodata value to mask (e.g., -9999)")
1709
+
1710
+ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width, subset, vmin, vmax, cartopy, timestep, qgis, nodata):
1224
1711
  """Lightweight GeoTIFF, NetCDF, and HDF viewer."""
1225
1712
  # --- Warn early if shapefile requested but geopandas missing ---
1226
1713
  if shapefile and not HAVE_GEO:
@@ -1229,6 +1716,320 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
1229
1716
  " Install them with: pip install viewtif[geo]\n"
1230
1717
  " Proceeding without shapefile overlay."
1231
1718
  )
1719
+ # Check if vector files exist before launching viewer
1720
+ if shapefile:
1721
+ for shp_path in shapefile:
1722
+ if not os.path.exists(shp_path):
1723
+ print(f"[ERROR] Vector file not found: {shp_path}")
1724
+ sys.exit(1)
1725
+
1726
+ # --- Handle --qgis: check QGIS availability first, then export ---
1727
+ if qgis:
1728
+ import uuid
1729
+ import tempfile
1730
+
1731
+ # Load rasterio early for QGIS export
1732
+ rasterio = _get_rasterio()
1733
+ Affine = rasterio.Affine
1734
+
1735
+ if not tif_path:
1736
+ print("[ERROR] --qgis requires a file path")
1737
+ sys.exit(1)
1738
+
1739
+ # Check if QGIS is available BEFORE exporting
1740
+ qgis_path = None
1741
+
1742
+ if sys.platform == "darwin":
1743
+ candidates = [
1744
+ "/Applications/QGIS.app",
1745
+ "/Applications/QGIS-LTR.app",
1746
+ ]
1747
+ for app in candidates:
1748
+ if os.path.exists(app):
1749
+ qgis_path = app
1750
+ break
1751
+
1752
+ elif sys.platform.startswith("win"):
1753
+ candidates = [
1754
+ r"C:\Program Files\QGIS 3.34.0\bin\qgis-bin.exe",
1755
+ r"C:\Program Files\QGIS 3.32.0\bin\qgis-bin.exe",
1756
+ r"C:\OSGeo4W64\bin\qgis-bin.exe",
1757
+ ]
1758
+ for exe in candidates:
1759
+ if os.path.exists(exe):
1760
+ qgis_path = exe
1761
+ break
1762
+
1763
+ # Try system PATH
1764
+ if not qgis_path:
1765
+ import shutil
1766
+ if shutil.which("qgis"):
1767
+ qgis_path = "qgis"
1768
+
1769
+ else: # Linux
1770
+ import shutil
1771
+ if shutil.which("qgis"):
1772
+ qgis_path = "qgis"
1773
+ else:
1774
+ linux_candidates = [
1775
+ "/usr/bin/qgis",
1776
+ "/usr/local/bin/qgis",
1777
+ "/snap/bin/qgis",
1778
+ ]
1779
+ for exe in linux_candidates:
1780
+ if os.path.exists(exe):
1781
+ qgis_path = exe
1782
+ break
1783
+
1784
+ # If QGIS not found, exit early
1785
+ if not qgis_path:
1786
+ print("[ERROR] QGIS not found on your system")
1787
+ print("[INFO] Install QGIS or specify the path manually")
1788
+ sys.exit(1)
1789
+
1790
+ # Warn if --shapefile was provided (it will be ignored)
1791
+ ignored_flags = []
1792
+ if shapefile:
1793
+ ignored_flags.append("--shapefile")
1794
+ if scale and scale != 1:
1795
+ ignored_flags.append("--scale")
1796
+ if vmin is not None or vmax is not None:
1797
+ ignored_flags.append("--vmin/--vmax")
1798
+ if band and band != 1:
1799
+ ignored_flags.append("--band")
1800
+
1801
+ if ignored_flags:
1802
+ print(f"[INFO] {', '.join(ignored_flags)} ignored when using --qgis")
1803
+
1804
+ # QGIS found - proceed with export
1805
+ # Handle GDAL format strings (e.g., "OpenFileGDB:path.gdb:layer")
1806
+ if ":" in tif_path and tif_path.startswith(("OpenFileGDB:", "HDF4_EOS:", "HDF5:")):
1807
+ parts = tif_path.split(":")
1808
+ if len(parts) >= 2:
1809
+ file_part = parts[1]
1810
+ ext = os.path.splitext(file_part.lower())[1]
1811
+ else:
1812
+ ext = ""
1813
+ else:
1814
+ ext = os.path.splitext(tif_path.lower())[1]
1815
+
1816
+ # Skip local file check for remote paths
1817
+ is_remote = tif_path.startswith(("http://", "https://", "s3://", "/vsi"))
1818
+
1819
+ # Check if NetCDF - not supported for --qgis
1820
+ if ext in (".nc", ".netcdf"):
1821
+ print("[ERROR] --qgis is not supported for NetCDF files")
1822
+ sys.exit(1)
1823
+
1824
+ tmp_file_path = None
1825
+ random_part = uuid.uuid4().hex[:6]
1826
+
1827
+ try:
1828
+ # For regular GeoTIFFs, check if remote or local
1829
+ if ext in (".tif", ".tiff"):
1830
+ if is_remote:
1831
+ # Remote GeoTIFFs need to be downloaded first
1832
+ print(f"[INFO] Downloading remote GeoTIFF for QGIS...")
1833
+ base = tif_path.split('/')[-1].replace('.tif', '').replace('.tiff', '')
1834
+ tmp_file_path = os.path.join(tempfile.gettempdir(), f"{base}_{random_part}.tif")
1835
+
1836
+ # Download using rasterio
1837
+ with rasterio.open(tif_path) as src:
1838
+ data = src.read()
1839
+
1840
+ # --- FORCE clean display-friendly GeoTIFF ---
1841
+ profile = {
1842
+ "driver": "GTiff",
1843
+ "height": src.height,
1844
+ "width": src.width,
1845
+ "count": src.count,
1846
+ "dtype": data.dtype,
1847
+ "crs": src.crs,
1848
+ "transform": src.transform,
1849
+ "compress": "LZW", # safe default
1850
+ "interleave": "PIXEL",
1851
+ }
1852
+
1853
+ with rasterio.open(tmp_file_path, "w", **profile) as dst:
1854
+ dst.write(data)
1855
+
1856
+ print(f"[INFO] Download complete")
1857
+ else:
1858
+ # Local GeoTIFF - use directly
1859
+ tmp_file_path = tif_path
1860
+
1861
+ # For File Geodatabase (.gdb), export to temporary GeoTIFF
1862
+ elif ext == ".gdb":
1863
+ try:
1864
+ from osgeo import gdal
1865
+ except ImportError:
1866
+ print("[ERROR] This file requires full GDAL support.")
1867
+ sys.exit(1)
1868
+
1869
+ if not tif_path.startswith("OpenFileGDB:"):
1870
+ print("[ERROR] File Geodatabase requires layer specification for --qgis")
1871
+ print("[INFO] You provided: " + tif_path)
1872
+ print("[INFO] First run without --qgis to see available raster layers:")
1873
+ print(f'[INFO] viewtif {tif_path}')
1874
+ print("[INFO] Then use the GDAL format with layer name:")
1875
+ print(f'[INFO] viewtif "OpenFileGDB:{tif_path}:LAYERNAME" --qgis')
1876
+ sys.exit(1)
1877
+
1878
+ parts = tif_path.split(":")
1879
+ if len(parts) < 3 or not parts[2].strip():
1880
+ print("[ERROR] Layer name is missing in the path")
1881
+ print("[INFO] You provided: " + tif_path)
1882
+ print("[INFO] Correct format: OpenFileGDB:path/to/file.gdb:LAYERNAME")
1883
+ print("[INFO] Example: viewtif \"OpenFileGDB:Wetlands.gdb:Wetlands\" --qgis")
1884
+ sys.exit(1)
1885
+
1886
+ layer_name = parts[2]
1887
+
1888
+ print(f"[INFO] Exporting {layer_name} to temporary GeoTIFF...")
1889
+
1890
+ try:
1891
+ ds = gdal.Open(tif_path)
1892
+ if ds is None:
1893
+ print(f"[ERROR] Could not open layer '{layer_name}' in geodatabase")
1894
+ print("[INFO] Possible reasons:")
1895
+ print(" - Layer name is incorrect")
1896
+ print(" - Layer is not a raster (vector layers not supported with --qgis)")
1897
+ print(" - GDAL cannot access the file")
1898
+ print(f"[INFO] Run without --qgis to see available raster layers:")
1899
+ gdb_path = parts[1]
1900
+ print(f"[INFO] viewtif {gdb_path}")
1901
+ sys.exit(1)
1902
+
1903
+ arr = ds.ReadAsArray().astype(np.float32)
1904
+ arr = np.squeeze(arr)
1905
+
1906
+ base = os.path.splitext(os.path.basename(parts[1]))[0]
1907
+ tmp_file_path = os.path.join(tempfile.gettempdir(), f"{base}_{layer_name}_{random_part}.tif")
1908
+
1909
+ print(f"[INFO] Writing {arr.shape[0]}×{arr.shape[1]} raster...")
1910
+
1911
+ geotransform = ds.GetGeoTransform()
1912
+ projection = ds.GetProjection()
1913
+
1914
+ with rasterio.open(
1915
+ tmp_file_path, 'w',
1916
+ driver='GTiff',
1917
+ height=arr.shape[0],
1918
+ width=arr.shape[1],
1919
+ count=1,
1920
+ dtype=arr.dtype,
1921
+ compress='lzw',
1922
+ transform=Affine.from_gdal(*geotransform) if geotransform else None,
1923
+ crs=projection if projection else None
1924
+ ) as dst:
1925
+ dst.write(arr, 1)
1926
+
1927
+ print(f"[INFO] Export complete")
1928
+
1929
+ except Exception as e:
1930
+ print(f"[ERROR] Failed to export .gdb raster: {e}")
1931
+ sys.exit(1)
1932
+
1933
+ # For HDF, export to temporary GeoTIFF
1934
+ elif ext in (".hdf", ".h5", ".hdf5"):
1935
+ if subset is None:
1936
+ print("[ERROR] HDF file requires --subset N")
1937
+ print("[INFO] First run without --qgis to see available subdatasets")
1938
+ sys.exit(1)
1939
+
1940
+ try:
1941
+ from osgeo import gdal
1942
+ except ImportError:
1943
+ print("[ERROR] This file requires full GDAL support.")
1944
+ sys.exit(1)
1945
+
1946
+ ds = gdal.Open(tif_path)
1947
+ subs = ds.GetSubDatasets()
1948
+
1949
+ if subset < 0 or subset >= len(subs):
1950
+ print(f"[ERROR] Invalid subset index {subset}. Valid range: 0–{len(subs)-1}")
1951
+ sys.exit(1)
1952
+
1953
+ base = os.path.splitext(os.path.basename(tif_path))[0]
1954
+ tmp_file_path = os.path.join(tempfile.gettempdir(), f"{base}_subset{subset}_{random_part}.tif")
1955
+
1956
+ print(f"[INFO] Exporting HDF subdataset to temporary GeoTIFF...")
1957
+
1958
+ sub_name, _ = subs[subset]
1959
+ sub_ds = gdal.Open(sub_name)
1960
+
1961
+ if sub_ds is None:
1962
+ print(f"[ERROR] Could not open HDF subdataset {subset}")
1963
+ sys.exit(1)
1964
+
1965
+ arr = sub_ds.ReadAsArray().astype(np.float32)
1966
+ arr = np.squeeze(arr)
1967
+
1968
+ print(f"[INFO] Writing {arr.shape[0]}×{arr.shape[1]} raster...")
1969
+
1970
+ # Try to get geotransform and projection
1971
+ geotransform = sub_ds.GetGeoTransform()
1972
+ projection = sub_ds.GetProjection()
1973
+
1974
+ # Build kwargs for rasterio
1975
+ write_kwargs = {
1976
+ 'driver': 'GTiff',
1977
+ 'height': arr.shape[0],
1978
+ 'width': arr.shape[1],
1979
+ 'count': 1,
1980
+ 'dtype': arr.dtype,
1981
+ 'compress': 'lzw'
1982
+ }
1983
+
1984
+ # Only add transform/crs if they exist AND are valid
1985
+ if geotransform and geotransform != (0.0, 1.0, 0.0, 0.0, 0.0, 1.0):
1986
+ write_kwargs['transform'] = Affine.from_gdal(*geotransform)
1987
+
1988
+ if projection and projection.strip():
1989
+ write_kwargs['crs'] = projection
1990
+
1991
+ # Warn if missing georeferencing
1992
+ if 'crs' not in write_kwargs:
1993
+ print("[WARN] HDF subdataset has no CRS - exported image will lack georeferencing")
1994
+
1995
+ with rasterio.open(tmp_file_path, 'w', **write_kwargs) as dst:
1996
+ dst.write(arr, 1)
1997
+
1998
+ print(f"[INFO] Export complete")
1999
+
2000
+ else:
2001
+ print(f"[ERROR] --qgis only supports GeoTIFF (.tif), HDF (.hdf, .h5, .hdf5), and File Geodatabase (.gdb)")
2002
+ print(f"[INFO] File extension '{ext}' is not supported")
2003
+ sys.exit(1)
2004
+
2005
+ # Check if QGIS is already running
2006
+ qgis_running = False
2007
+ if sys.platform == "darwin":
2008
+ import subprocess
2009
+ result = subprocess.run(['pgrep', '-f', 'QGIS'], capture_output=True)
2010
+ qgis_running = result.returncode == 0
2011
+
2012
+ # Launch QGIS (works whether already running or not)
2013
+ if sys.platform == "darwin":
2014
+ os.system(f'open -a "{qgis_path}" "{tmp_file_path}"')
2015
+ elif sys.platform.startswith("win"):
2016
+ os.system(f'start "" "{qgis_path}" "{tmp_file_path}"')
2017
+ else:
2018
+ os.system(f'"{qgis_path}" "{tmp_file_path}" &')
2019
+
2020
+ # Info message
2021
+ if ext in (".tif", ".tiff"):
2022
+ print(f"[INFO] Opened in QGIS")
2023
+ else:
2024
+ print(f"[INFO] Opened in QGIS")
2025
+ # print(f"[INFO] Temp file: {tmp_file_path}")
2026
+ # print(f"[INFO] (Will be cleaned on system reboot)")
2027
+
2028
+ except Exception as e:
2029
+ print(f"[ERROR] Failed to export for QGIS: {e}")
2030
+ sys.exit(1)
2031
+
2032
+ return
1232
2033
 
1233
2034
  run_viewer(
1234
2035
  tif_path,
@@ -1239,7 +2040,12 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
1239
2040
  shapefile=shapefile,
1240
2041
  shp_color=shp_color,
1241
2042
  shp_width=shp_width,
1242
- subset=subset
2043
+ subset=subset,
2044
+ vmin=vmin,
2045
+ vmax=vmax,
2046
+ cartopy=cartopy,
2047
+ timestep=timestep,
2048
+ nodata=nodata,
1243
2049
  )
1244
2050
 
1245
2051
  if __name__ == "__main__":