viewtif 0.2.4__py3-none-any.whl → 0.2.6__py3-none-any.whl

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