viewtif 0.2.6__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,14 +1,16 @@
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
@@ -16,7 +18,8 @@ Controls
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
20
  M : toggle colormap. Single-band: viridis/magma. NetCDF: RdBu_r/viridis/magma.
19
- [ / ] : previous / next band (or time step) (single-band)
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
@@ -28,8 +31,6 @@ Examples
28
31
  import sys
29
32
  import os
30
33
  import numpy as np
31
- import rasterio
32
- from rasterio.transform import Affine
33
34
  from PySide6.QtWidgets import (
34
35
  QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
35
36
  QScrollBar, QGraphicsPathItem, QVBoxLayout, QWidget, QStatusBar
@@ -37,42 +38,85 @@ from PySide6.QtWidgets import (
37
38
  from PySide6.QtGui import QImage, QPixmap, QPainter, QPen, QColor, QPainterPath
38
39
  from PySide6.QtCore import Qt
39
40
 
40
- import matplotlib.cm as cm
41
- import warnings
42
- warnings.filterwarnings("ignore", category=RuntimeWarning, module="shapely")
43
- warnings.filterwarnings("ignore", category=UserWarning, module="rasterio")
44
- warnings.filterwarnings("ignore", category=UserWarning, module="xarray")
45
-
46
- __version__ = "0.2.6"
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
47
97
 
48
- # Optional overlay deps
98
+ # Check availability without importing
99
+ HAVE_GEO = True # Assume available, will be set False if import fails
49
100
  try:
50
- import geopandas as gpd
51
- from shapely.geometry import (
52
- LineString, MultiLineString, Polygon, MultiPolygon,
53
- GeometryCollection, Point, MultiPoint
54
- )
55
- HAVE_GEO = True
101
+ import importlib.util
102
+ HAVE_CARTOPY = importlib.util.find_spec("cartopy") is not None
56
103
  except Exception:
57
- HAVE_GEO = False
104
+ HAVE_CARTOPY = False
58
105
 
59
106
  # Optional NetCDF deps (lazy-loaded when needed)
60
107
  HAVE_NETCDF = False
61
108
  xr = None
62
109
  pd = None
63
110
 
64
- # Optional cartopy deps for better map visualization (lazy-loaded when needed)
65
- # Check if cartopy is available but don't import yet
66
- try:
67
- import importlib.util
68
- HAVE_CARTOPY = importlib.util.find_spec("cartopy") is not None
69
- except Exception:
70
- HAVE_CARTOPY = False
71
-
72
111
  def warn_if_large(tif_path, scale=1):
73
112
  """Warn and confirm before loading very large rasters (GeoTIFF, GDB, or HDF).
74
113
  Uses GDAL if available, falls back to rasterio for standard formats.
75
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()
76
120
  import os
77
121
  width = height = None
78
122
  size_mb = None
@@ -80,7 +124,6 @@ def warn_if_large(tif_path, scale=1):
80
124
  if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
81
125
  tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
82
126
 
83
-
84
127
  try:
85
128
  width, height = None, None
86
129
 
@@ -118,6 +161,7 @@ def warn_if_large(tif_path, scale=1):
118
161
  if ans not in ("y", "yes"):
119
162
  print("Cancelled.")
120
163
  sys.exit(0)
164
+
121
165
  except Exception as e:
122
166
  print(f"[INFO] Could not pre-check raster size: {e}")
123
167
 
@@ -161,19 +205,20 @@ class RasterView(QGraphicsView):
161
205
  class TiffViewer(QMainWindow):
162
206
  def __init__(
163
207
  self,
164
- tif_path: str | None,
165
- scale: int = 1,
166
- band: int = 1,
167
- rgb: list[int] | None = None,
168
- rgbfiles: list[str] | None = None,
169
- shapefiles: list[str] | None = None,
170
- shp_color: str = "cyan",
171
- shp_width: float = 2,
172
- subset: int | None = None,
173
- vmin: float | None = None,
174
- vmax: float | None = None,
175
- cartopy: str = "on",
176
- timestep: 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,
177
222
  ):
178
223
  super().__init__()
179
224
 
@@ -185,13 +230,37 @@ class TiffViewer(QMainWindow):
185
230
  self._user_vmin = vmin
186
231
  self._user_vmax = vmax
187
232
  self.cartopy_mode = cartopy.lower()
233
+ self._nodata = nodata
188
234
 
189
235
  if not tif_path and not rgbfiles:
190
236
  print("Usage: viewtif <file.tif>")
191
237
  sys.exit(1)
192
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
261
+
193
262
  self._scale_arg = max(1, int(scale or 1))
194
- self._transform: Affine | None = None
263
+ self._transform = None
195
264
  self._crs = None
196
265
 
197
266
  # Overlay config/state
@@ -199,12 +268,21 @@ class TiffViewer(QMainWindow):
199
268
  self._shp_color = shp_color
200
269
  self._shp_width = float(shp_width)
201
270
  self._overlay_items: list[QGraphicsPathItem] = []
271
+
272
+ # Basemap state
273
+ self.base_gdf = None
274
+ self.basemap_items: list[QGraphicsPathItem] = []
202
275
 
203
276
  # --- Load data ---
204
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
+
205
284
  red, green, blue = rgbfiles
206
- import rasterio as rio_module
207
- 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:
208
286
  if (r.width, r.height) != (g.width, g.height) or (r.width, r.height) != (b.width, b.height):
209
287
  raise ValueError("All RGB files must have the same dimensions.")
210
288
  arr = np.stack([
@@ -212,13 +290,19 @@ class TiffViewer(QMainWindow):
212
290
  g.read(1, out_shape=(g.height // self._scale_arg, g.width // self._scale_arg)),
213
291
  b.read(1, out_shape=(b.height // self._scale_arg, b.width // self._scale_arg))
214
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
+
215
298
  self._transform = r.transform
216
299
  self._crs = r.crs
217
300
 
218
301
  self.data = arr
219
302
  self.band_count = 3
220
- self.rgb = [os.path.basename(red), os.path.basename(green), os.path.basename(blue)]
221
- 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
222
306
 
223
307
  elif tif_path:
224
308
 
@@ -260,6 +344,8 @@ class TiffViewer(QMainWindow):
260
344
  if tif_path.lower().endswith((".nc", ".netcdf")):
261
345
  try:
262
346
  import xarray as xr
347
+ import warnings
348
+ warnings.filterwarnings("ignore", category=xr.SerializationWarning)
263
349
  except ModuleNotFoundError:
264
350
  print("NetCDF support requires extra dependencies.")
265
351
  print("Install them with: pip install viewtif[netcdf]")
@@ -275,8 +361,12 @@ class TiffViewer(QMainWindow):
275
361
  # Auto-select the first variable if there's only one and no subset specified
276
362
  if len(data_vars) == 1 and subset is None:
277
363
  subset = 0
278
- # Only list variables if --subset not given and multiple variables exist
364
+ # List variables if --subset not given and multiple variables exist
279
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.")
280
370
  sys.exit(0)
281
371
 
282
372
  # Validate subset index
@@ -333,6 +423,24 @@ class TiffViewer(QMainWindow):
333
423
  arr = var_data.values.astype(np.float32)
334
424
  arr = np.squeeze(arr)
335
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
+
336
444
  # --------------------------------------------------------
337
445
  # Apply timestep jump after base array is created
338
446
  # --------------------------------------------------------
@@ -359,7 +467,6 @@ class TiffViewer(QMainWindow):
359
467
 
360
468
  if "crs" in ds.variables:
361
469
  try:
362
- import rasterio.crs
363
470
  crs_var = ds.variables["crs"]
364
471
  if hasattr(crs_var, "spatial_ref"):
365
472
  self._crs = rasterio.crs.CRS.from_wkt(crs_var.spatial_ref)
@@ -413,6 +520,21 @@ class TiffViewer(QMainWindow):
413
520
  arr = sub_ds.ReadAsArray().astype(np.float32)
414
521
  arr = np.squeeze(arr)
415
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
+
416
538
  if arr.ndim == 3:
417
539
  # Convert from (bands, rows, cols) → (rows, cols, bands)
418
540
  arr = np.transpose(arr, (1, 2, 0))
@@ -457,8 +579,7 @@ class TiffViewer(QMainWindow):
457
579
  # Regular TIFF
458
580
  # ---------------------------------------------------------------
459
581
  else:
460
- import rasterio as rio_module
461
- with rio_module.open(tif_path) as src:
582
+ with rasterio.open(tif_path) as src:
462
583
  self._transform = src.transform
463
584
  self._crs = src.crs
464
585
 
@@ -469,9 +590,16 @@ class TiffViewer(QMainWindow):
469
590
  ]
470
591
 
471
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
472
599
  nd = src.nodata
473
600
  if nd is not None:
474
601
  arr[arr == nd] = np.nan
602
+
475
603
  self.data = arr
476
604
  self.band_count = 3
477
605
  else:
@@ -479,10 +607,18 @@ class TiffViewer(QMainWindow):
479
607
  self.band,
480
608
  out_shape=(src.height // self._scale_arg, src.width // self._scale_arg)
481
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
482
616
  nd = src.nodata
483
617
  if nd is not None:
484
618
  arr[arr == nd] = np.nan
619
+
485
620
  self.data = arr
621
+
486
622
  self.band_count = src.count
487
623
 
488
624
  if self.band_count == 1:
@@ -500,6 +636,7 @@ class TiffViewer(QMainWindow):
500
636
  else:
501
637
  raise ValueError("No stats in file")
502
638
  except Exception:
639
+ # Always calculate from masked array for consistency
503
640
  self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
504
641
  if getattr(self, "_scale_arg", 1) > 1:
505
642
  print(f"[INFO] Value range (scaled): {self.vmin:.3f} -> {self.vmax:.3f}")
@@ -538,6 +675,7 @@ class TiffViewer(QMainWindow):
538
675
 
539
676
  # Status bar
540
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")
541
679
 
542
680
  # Set central widget
543
681
  self.setCentralWidget(self.main_widget)
@@ -587,7 +725,7 @@ class TiffViewer(QMainWindow):
587
725
  # self.view.centerOn(self.pixmap_item)
588
726
 
589
727
  # ---------------------------- Overlays ---------------------------- #
590
- def _geo_to_pixel(self, x: float, y: float):
728
+ def _geo_to_pixel(self, x, y):
591
729
  """Map coords (raster CRS) -> image pixel coords (after downsampling)."""
592
730
  if self._transform is None:
593
731
  return None
@@ -595,11 +733,23 @@ class TiffViewer(QMainWindow):
595
733
  col, row = inv * (x, y)
596
734
  return (col / self._scale_arg, row / self._scale_arg)
597
735
 
598
- def _geom_to_qpath(self, geom) -> QPainterPath | None:
736
+ def _geom_to_qpath(self, geom):
599
737
  """
600
738
  Convert shapely geom (in raster CRS) to QPainterPath in *image pixel* coords.
601
739
  Z/M tolerant: only X,Y are used. Draws Points as tiny segments.
602
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
+
603
753
  def _coords_to_path(coords, path: QPainterPath):
604
754
  first = True
605
755
  for c in coords:
@@ -668,8 +818,13 @@ class TiffViewer(QMainWindow):
668
818
  return None
669
819
 
670
820
  def _add_shapefile_overlays(self):
671
- if not HAVE_GEO:
672
- 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.")
673
828
  return
674
829
  if self._crs is None or self._transform is None:
675
830
  print("[WARN] raster lacks CRS/transform; cannot place overlays.")
@@ -680,8 +835,12 @@ class TiffViewer(QMainWindow):
680
835
  pen.setCosmetic(True) # constant on-screen width
681
836
 
682
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
683
841
  try:
684
842
  gdf = gpd.read_file(shp_path)
843
+
685
844
  if gdf.empty:
686
845
  continue
687
846
 
@@ -706,6 +865,206 @@ class TiffViewer(QMainWindow):
706
865
  except Exception as e:
707
866
  print(f"[WARN] Failed to draw overlay {os.path.basename(shp_path)}: {e}")
708
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
+
709
1068
  # ----------------------- Title / Rendering ----------------------- #
710
1069
  def update_title(self):
711
1070
  """Add band before the title."""
@@ -852,6 +1211,11 @@ class TiffViewer(QMainWindow):
852
1211
  return time_str
853
1212
 
854
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
+
855
1219
  if self.rgb_mode:
856
1220
  arr = self.data
857
1221
  finite = np.isfinite(arr)
@@ -880,11 +1244,18 @@ class TiffViewer(QMainWindow):
880
1244
 
881
1245
  def _render_cartopy_map(self, data):
882
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
+
883
1252
  import matplotlib.pyplot as plt
884
1253
  from matplotlib.backends.backend_agg import FigureCanvasAgg
885
1254
  import cartopy.crs as ccrs
886
1255
  import cartopy.feature as cfeature
887
1256
 
1257
+ cm = _get_matplotlib_cm()
1258
+
888
1259
  # Create a new figure with cartopy projection
889
1260
  fig = plt.figure(figsize=(12, 8), dpi=100)
890
1261
  ax = plt.axes(projection=ccrs.PlateCarree())
@@ -902,7 +1273,13 @@ class TiffViewer(QMainWindow):
902
1273
  # Apply contrast and gamma adjustments
903
1274
  finite = np.isfinite(data)
904
1275
  norm_data = np.zeros_like(data, dtype=np.float32)
905
- 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
+
906
1283
  rng = max(vmax - vmin, 1e-12)
907
1284
 
908
1285
  if np.any(finite):
@@ -1053,26 +1430,44 @@ class TiffViewer(QMainWindow):
1053
1430
  rgb = self._render_cartopy_map(a)
1054
1431
  elif rgb is None:
1055
1432
  # Standard grayscale rendering for single-band data
1433
+ cm = _get_matplotlib_cm()
1056
1434
  finite = np.isfinite(a)
1057
1435
 
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
-
1062
- rng = max(vmax - vmin, 1e-12)
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)
1063
1456
 
1064
- norm = np.zeros_like(a, dtype=np.float32)
1065
- if np.any(finite):
1066
- norm[finite] = (a[finite] - vmin) / rng
1067
- norm = np.clip(norm, 0, 1)
1068
- norm = np.power(norm * self.contrast, self.gamma)
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
+
1069
1463
  cmap = getattr(cm, self.cmap_name, cm.viridis)
1070
1464
  rgb = (cmap(norm)[..., :3] * 255).astype(np.uint8)
1071
1465
  else:
1072
1466
  # True RGB mode (unchanged)
1073
1467
  rgb = self._render_rgb()
1074
1468
 
1075
- h, w, _ = rgb.shape
1469
+
1470
+ h, w = rgb.shape[:2] # for both 2D and 3D
1076
1471
  self._last_rgb = rgb
1077
1472
 
1078
1473
  qimg = QImage(rgb.data, w, h, 3 * w, QImage.Format.Format_RGB888)
@@ -1085,27 +1480,33 @@ class TiffViewer(QMainWindow):
1085
1480
  self.scene.addItem(self.pixmap_item)
1086
1481
  else:
1087
1482
  self.pixmap_item.setPixmap(pix)
1088
-
1089
1483
  # ----------------------- Single-band switching ------------------- #
1090
1484
  def load_band(self, band_num: int):
1091
1485
  if self.rgb_mode:
1092
1486
  return
1093
1487
 
1488
+ rasterio = _get_rasterio()
1094
1489
  tif_path = self.tif_path
1095
1490
 
1096
1491
  if tif_path and os.path.dirname(self.tif_path).endswith(".gdb"):
1097
1492
  tif_path = f"OpenFileGDB:{os.path.dirname(self.tif_path)}:{os.path.basename(self.tif_path)}"
1098
1493
 
1099
- import rasterio as rio_module
1100
- with rio_module.open(tif_path) as src:
1494
+ with rasterio.open(tif_path) as src:
1101
1495
  self.band = band_num
1102
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
1103
1503
  nd = src.nodata
1104
1504
  if nd is not None:
1105
1505
  arr[arr == nd] = np.nan
1106
1506
  self.data = arr
1107
-
1507
+
1108
1508
  self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
1509
+ print(f"[INFO] Value range: {self.vmin:.3f} -> {self.vmax:.3f}")
1109
1510
  self.update_pixmap()
1110
1511
  self.update_title()
1111
1512
 
@@ -1129,15 +1530,31 @@ class TiffViewer(QMainWindow):
1129
1530
  elif k in (Qt.Key.Key_Down, Qt.Key.Key_S):
1130
1531
  vsb.setValue(vsb.value() + self.pan_step)
1131
1532
 
1132
- # Contrast / Gamma now work in both modes
1533
+ # Contrast / Gamma
1133
1534
  elif k == Qt.Key.Key_C:
1134
- 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()
1135
1540
  elif k == Qt.Key.Key_V:
1136
- 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()
1137
1546
  elif k == Qt.Key.Key_G:
1138
- 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()
1139
1552
  elif k == Qt.Key.Key_H:
1140
- 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()
1141
1558
 
1142
1559
  # Colormap toggle (single-band only)
1143
1560
  elif not self.rgb_mode and k == Qt.Key.Key_M:
@@ -1157,8 +1574,15 @@ class TiffViewer(QMainWindow):
1157
1574
  if hasattr(self, "band_index"): # HDF/NetCDF mode
1158
1575
  self.band_index = (self.band_index + 1) % self.band_count
1159
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
+
1160
1583
  self.update_pixmap()
1161
1584
  self.update_title()
1585
+
1162
1586
  elif not self.rgb_mode: # GeoTIFF single-band mode
1163
1587
  new_band = self.band + 1 if self.band < self.band_count else 1
1164
1588
  self.load_band(new_band)
@@ -1167,28 +1591,37 @@ class TiffViewer(QMainWindow):
1167
1591
  if hasattr(self, "band_index"): # HDF/NetCDF mode
1168
1592
  self.band_index = (self.band_index - 1) % self.band_count
1169
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
+
1170
1600
  self.update_pixmap()
1171
1601
  self.update_title()
1602
+
1172
1603
  elif not self.rgb_mode: # GeoTIFF single-band mode
1173
1604
  new_band = self.band - 1 if self.band > 1 else self.band_count
1174
1605
  self.load_band(new_band)
1175
1606
 
1176
- # NetCDF time/dimension navigation with Page Up/Down
1177
- elif k == Qt.Key.Key_PageUp:
1178
- if hasattr(self, '_has_time_dim') and self._has_time_dim:
1179
- try:
1180
- # Call the next_time_step method
1181
- self.next_time_step()
1182
- except Exception as e:
1183
- 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()
1184
1619
 
1185
- elif k == Qt.Key.Key_PageDown:
1186
- if hasattr(self, '_has_time_dim') and self._has_time_dim:
1187
- try:
1188
- # Call the prev_time_step method
1189
- self.prev_time_step()
1190
- except Exception as e:
1191
- 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")
1192
1625
 
1193
1626
  elif k == Qt.Key.Key_R:
1194
1627
  self.contrast = 1.0
@@ -1214,7 +1647,8 @@ def run_viewer(
1214
1647
  vmin=None,
1215
1648
  vmax=None,
1216
1649
  cartopy="on",
1217
- timestep=None
1650
+ timestep=None,
1651
+ nodata=None,
1218
1652
  ):
1219
1653
 
1220
1654
  """Launch the TiffViewer app"""
@@ -1233,6 +1667,7 @@ def run_viewer(
1233
1667
  vmax=vmax,
1234
1668
  cartopy=cartopy,
1235
1669
  timestep=timestep,
1670
+ nodata=nodata,
1236
1671
  )
1237
1672
  win.show()
1238
1673
  sys.exit(app.exec())
@@ -1243,12 +1678,12 @@ import click
1243
1678
  @click.version_option(__version__, prog_name="viewtif")
1244
1679
  @click.argument("tif_path", required=False)
1245
1680
  @click.option("--band", default=1, show_default=True, type=int, help="Band number to display")
1246
- @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)")
1247
1682
  @click.option("--rgb", nargs=3, type=int, help="Three band numbers for RGB, e.g. --rgb 4 3 2")
1248
1683
  @click.option("--rgbfiles", nargs=3, type=str, help="Three single-band TIFFs for RGB, e.g. --rgbfiles B4.tif B3.tif B2.tif")
1249
- @click.option("--shapefile", multiple=True, type=str, help="One or more shapefiles to overlay")
1250
- @click.option("--shp-color", default="cyan", show_default=True, help="Overlay color (name or #RRGGBB).")
1251
- @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).")
1252
1687
  @click.option("--subset", default=None, type=int, help="Open specific subdataset index in .hdf/.h5 file or variable in NetCDF file")
1253
1688
  @click.option("--vmin", type=float, default=None, help="Manual minimum display value")
1254
1689
  @click.option("--vmax", type=float, default=None, help="Manual maximum display value")
@@ -1265,9 +1700,14 @@ import click
1265
1700
  show_default=True,
1266
1701
  help="Use cartopy for NetCDF geospatial rendering."
1267
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)")
1268
1709
 
1269
-
1270
- def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width, subset, vmin, vmax, cartopy, timestep):
1710
+ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width, subset, vmin, vmax, cartopy, timestep, qgis, nodata):
1271
1711
  """Lightweight GeoTIFF, NetCDF, and HDF viewer."""
1272
1712
  # --- Warn early if shapefile requested but geopandas missing ---
1273
1713
  if shapefile and not HAVE_GEO:
@@ -1276,6 +1716,320 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
1276
1716
  " Install them with: pip install viewtif[geo]\n"
1277
1717
  " Proceeding without shapefile overlay."
1278
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
1279
2033
 
1280
2034
  run_viewer(
1281
2035
  tif_path,
@@ -1291,6 +2045,7 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
1291
2045
  vmax=vmax,
1292
2046
  cartopy=cartopy,
1293
2047
  timestep=timestep,
2048
+ nodata=nodata,
1294
2049
  )
1295
2050
 
1296
2051
  if __name__ == "__main__":