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