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 +853 -98
- viewtif-0.2.7.dist-info/METADATA +307 -0
- viewtif-0.2.7.dist-info/RECORD +7 -0
- {viewtif-0.2.6.dist-info → viewtif-0.2.7.dist-info}/WHEEL +1 -1
- viewtif-0.2.6.dist-info/METADATA +0 -184
- viewtif-0.2.6.dist-info/RECORD +0 -7
- {viewtif-0.2.6.dist-info → viewtif-0.2.7.dist-info}/entry_points.txt +0 -0
- {viewtif-0.2.6.dist-info → viewtif-0.2.7.dist-info}/licenses/LICENSE +0 -0
viewtif/tif_viewer.py
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
TIFF Viewer (PySide6)
|
|
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
|
-
-
|
|
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)
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
#
|
|
98
|
+
# Check availability without importing
|
|
99
|
+
HAVE_GEO = True # Assume available, will be set False if import fails
|
|
49
100
|
try:
|
|
50
|
-
import
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
scale
|
|
166
|
-
band
|
|
167
|
-
rgb
|
|
168
|
-
rgbfiles
|
|
169
|
-
shapefiles
|
|
170
|
-
shp_color
|
|
171
|
-
shp_width
|
|
172
|
-
subset
|
|
173
|
-
vmin
|
|
174
|
-
vmax
|
|
175
|
-
cartopy
|
|
176
|
-
timestep
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
self.
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
672
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1533
|
+
# Contrast / Gamma
|
|
1133
1534
|
elif k == Qt.Key.Key_C:
|
|
1134
|
-
self
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
1177
|
-
elif k == Qt.Key.
|
|
1178
|
-
if
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
self.
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
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
|
|
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="
|
|
1250
|
-
@click.option("--shp-color", default="cyan", show_default=True, help="
|
|
1251
|
-
@click.option("--shp-width", default=1.0, show_default=True, type=float, help="
|
|
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__":
|