viewtif 0.2.0__py3-none-any.whl → 0.2.2__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 +294 -345
- {viewtif-0.2.0.dist-info → viewtif-0.2.2.dist-info}/METADATA +21 -27
- viewtif-0.2.2.dist-info/RECORD +5 -0
- viewtif-0.2.0.dist-info/RECORD +0 -5
- {viewtif-0.2.0.dist-info → viewtif-0.2.2.dist-info}/WHEEL +0 -0
- {viewtif-0.2.0.dist-info → viewtif-0.2.2.dist-info}/entry_points.txt +0 -0
viewtif/tif_viewer.py
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
TIFF Viewer (PySide6) —
|
|
3
|
+
TIFF Viewer (PySide6) — view GeoTIFF, NetCDF, and HDF datasets with shapefile overlays.
|
|
4
4
|
|
|
5
|
-
Features
|
|
5
|
+
Features:
|
|
6
6
|
- Open GeoTIFFs (single or multi-band)
|
|
7
|
-
- Combine separate single-band TIFFs into RGB
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
- Overlay one or more shapefiles reprojected to raster CRS
|
|
13
|
-
- Z/M tolerant: ignores Z or M coords in shapefiles
|
|
7
|
+
- Combine separate single-band TIFFs into RGB
|
|
8
|
+
- Apply global 2–98% stretch for RGB
|
|
9
|
+
- Display NetCDF/HDF subsets with consistent scaling
|
|
10
|
+
- Overlay shapefiles automatically reprojected to raster CRS
|
|
11
|
+
- Navigate bands/time steps interactively
|
|
14
12
|
|
|
15
13
|
Controls
|
|
16
14
|
+ / - : zoom in/out
|
|
@@ -18,7 +16,7 @@ Controls
|
|
|
18
16
|
C / V : increase/decrease contrast (works in RGB and single-band)
|
|
19
17
|
G / H : increase/decrease gamma (works in RGB and single-band)
|
|
20
18
|
M : toggle colormap (viridis <-> magma) — single-band only
|
|
21
|
-
[ / ] : previous / next band (single-band)
|
|
19
|
+
[ / ] : previous / next band (or time step) (single-band)
|
|
22
20
|
R : reset view
|
|
23
21
|
|
|
24
22
|
Examples
|
|
@@ -35,13 +33,16 @@ import rasterio
|
|
|
35
33
|
from rasterio.transform import Affine
|
|
36
34
|
from PySide6.QtWidgets import (
|
|
37
35
|
QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
|
|
38
|
-
QScrollBar, QGraphicsPathItem, QVBoxLayout, QHBoxLayout,
|
|
39
|
-
QWidget, QStatusBar, QPushButton, QComboBox
|
|
36
|
+
QScrollBar, QGraphicsPathItem, QVBoxLayout, QHBoxLayout, QWidget, QStatusBar
|
|
40
37
|
)
|
|
41
38
|
from PySide6.QtGui import QImage, QPixmap, QPainter, QPen, QColor, QPainterPath
|
|
42
|
-
from PySide6.QtCore import Qt
|
|
39
|
+
from PySide6.QtCore import Qt
|
|
43
40
|
|
|
44
41
|
import matplotlib.cm as cm
|
|
42
|
+
import warnings
|
|
43
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning, module="shapely")
|
|
44
|
+
|
|
45
|
+
__version__ = "0.2.2"
|
|
45
46
|
|
|
46
47
|
# Optional overlay deps
|
|
47
48
|
try:
|
|
@@ -68,11 +69,12 @@ except Exception:
|
|
|
68
69
|
HAVE_CARTOPY = False
|
|
69
70
|
|
|
70
71
|
def warn_if_large(tif_path, scale=1):
|
|
71
|
-
"""Warn and confirm before loading very large rasters (GeoTIFF, GDB, or HDF).
|
|
72
|
-
|
|
72
|
+
"""Warn and confirm before loading very large rasters (GeoTIFF, GDB, or HDF).
|
|
73
73
|
Uses GDAL if available, falls back to rasterio for standard formats.
|
|
74
74
|
"""
|
|
75
75
|
import os
|
|
76
|
+
width = height = None
|
|
77
|
+
size_mb = None
|
|
76
78
|
|
|
77
79
|
if tif_path and os.path.dirname(tif_path).endswith(".gdb"):
|
|
78
80
|
tif_path = f"OpenFileGDB:{os.path.dirname(tif_path)}:{os.path.basename(tif_path)}"
|
|
@@ -121,7 +123,6 @@ def warn_if_large(tif_path, scale=1):
|
|
|
121
123
|
# -------------------------- QGraphicsView tweaks -------------------------- #
|
|
122
124
|
class RasterView(QGraphicsView):
|
|
123
125
|
def __init__(self, *args, **kwargs):
|
|
124
|
-
import numpy as np
|
|
125
126
|
super().__init__(*args, **kwargs)
|
|
126
127
|
self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
|
|
127
128
|
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
|
|
@@ -263,9 +264,14 @@ class TiffViewer(QMainWindow):
|
|
|
263
264
|
if 'time' in var_data.dims:
|
|
264
265
|
self._has_time_dim = True
|
|
265
266
|
self._time_dim_name = 'time'
|
|
266
|
-
self._time_values = ds
|
|
267
|
-
self._time_index =
|
|
268
|
-
|
|
267
|
+
self._time_values = ds['time'].values
|
|
268
|
+
self._time_index = 0
|
|
269
|
+
print(f"NetCDF time dimension detected: {len(self._time_values)} steps")
|
|
270
|
+
|
|
271
|
+
self.band_count = var_data.sizes['time']
|
|
272
|
+
self.band_index = 0
|
|
273
|
+
self._time_dim_name = 'time'
|
|
274
|
+
|
|
269
275
|
# Try to format time values for better display
|
|
270
276
|
time_units = getattr(ds.time, 'units', None)
|
|
271
277
|
time_calendar = getattr(ds.time, 'calendar', 'standard')
|
|
@@ -370,7 +376,8 @@ class TiffViewer(QMainWindow):
|
|
|
370
376
|
except subprocess.CalledProcessError as e:
|
|
371
377
|
print(f"[WARN] Could not inspect FileGDB: {e}")
|
|
372
378
|
sys.exit(0)
|
|
373
|
-
|
|
379
|
+
|
|
380
|
+
# --- Universal size check before loading ---
|
|
374
381
|
warn_if_large(tif_path, scale=self._scale_arg)
|
|
375
382
|
|
|
376
383
|
if False: # Placeholder for previous if condition
|
|
@@ -388,12 +395,11 @@ class TiffViewer(QMainWindow):
|
|
|
388
395
|
if not subs:
|
|
389
396
|
raise ValueError("No subdatasets found in HDF/HDF5 file.")
|
|
390
397
|
|
|
391
|
-
print(f"Found {len(subs)} subdatasets in {os.path.basename(tif_path)}:")
|
|
392
|
-
for i, (_, desc) in enumerate(subs):
|
|
393
|
-
print(f"[{i}] {desc}")
|
|
394
|
-
|
|
395
398
|
# Only list subsets if --subset not given
|
|
396
399
|
if subset is None:
|
|
400
|
+
print(f"Found {len(subs)} subdatasets in {os.path.basename(tif_path)}:")
|
|
401
|
+
for i, (_, desc) in enumerate(subs):
|
|
402
|
+
print(f"[{i}] {desc}")
|
|
397
403
|
print("\nUse --subset N to open a specific subdataset.")
|
|
398
404
|
sys.exit(0)
|
|
399
405
|
|
|
@@ -439,12 +445,9 @@ class TiffViewer(QMainWindow):
|
|
|
439
445
|
else:
|
|
440
446
|
print("This subdataset has 1 band.")
|
|
441
447
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
print(f"Opening band {self.band}/{self.band_count}")
|
|
446
|
-
else:
|
|
447
|
-
self.band_index = 0
|
|
448
|
+
if self.band and self.band <= self.band_count:
|
|
449
|
+
self.band_index = self.band - 1
|
|
450
|
+
print(f"Opening band {self.band}/{self.band_count}")
|
|
448
451
|
|
|
449
452
|
except ImportError:
|
|
450
453
|
# GDAL not available, try rasterio as fallback for NetCDF
|
|
@@ -572,64 +575,6 @@ class TiffViewer(QMainWindow):
|
|
|
572
575
|
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
|
573
576
|
self.main_layout.setSpacing(0)
|
|
574
577
|
|
|
575
|
-
# Add time navigation controls at the top for NetCDF files
|
|
576
|
-
if hasattr(self, '_has_time_dim') and self._has_time_dim:
|
|
577
|
-
self.time_layout = QHBoxLayout()
|
|
578
|
-
self.time_layout.setContentsMargins(10, 5, 10, 5)
|
|
579
|
-
|
|
580
|
-
# Navigation buttons
|
|
581
|
-
self.prev_button = QPushButton("<<")
|
|
582
|
-
self.prev_button.setToolTip("Previous time step")
|
|
583
|
-
self.prev_button.clicked.connect(self.prev_time_step)
|
|
584
|
-
self.prev_button.setFixedWidth(40)
|
|
585
|
-
|
|
586
|
-
# Play/Pause button
|
|
587
|
-
self.play_button = QPushButton("▶") # Play symbol
|
|
588
|
-
self.play_button.setToolTip("Play/Pause time animation")
|
|
589
|
-
self.play_button.clicked.connect(self.toggle_play_pause)
|
|
590
|
-
self.play_button.setFixedWidth(40)
|
|
591
|
-
self._is_playing = False
|
|
592
|
-
self._play_timer = None
|
|
593
|
-
|
|
594
|
-
self.next_button = QPushButton(">>")
|
|
595
|
-
self.next_button.setToolTip("Next time step")
|
|
596
|
-
self.next_button.clicked.connect(self.next_time_step)
|
|
597
|
-
self.next_button.setFixedWidth(40)
|
|
598
|
-
|
|
599
|
-
# Date/time label
|
|
600
|
-
self.time_label = QLabel()
|
|
601
|
-
self.time_label.setMinimumWidth(200)
|
|
602
|
-
|
|
603
|
-
# Time slider
|
|
604
|
-
self.time_slider = QSlider(Qt.Orientation.Horizontal)
|
|
605
|
-
self.time_slider.setMinimum(0)
|
|
606
|
-
self.time_slider.setMaximum(len(self._time_values) - 1)
|
|
607
|
-
self.time_slider.setValue(self._time_index)
|
|
608
|
-
self.time_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
|
|
609
|
-
self.time_slider.setTickInterval(max(1, len(self._time_values) // 10))
|
|
610
|
-
self.time_slider.valueChanged.connect(self.time_slider_changed)
|
|
611
|
-
|
|
612
|
-
# Date time combo box
|
|
613
|
-
self.date_combo = QComboBox()
|
|
614
|
-
self.populate_date_combo()
|
|
615
|
-
self.date_combo.currentIndexChanged.connect(self.date_combo_changed)
|
|
616
|
-
self.date_combo.setFixedWidth(200)
|
|
617
|
-
|
|
618
|
-
# Add controls to layout
|
|
619
|
-
self.time_layout.addWidget(self.prev_button)
|
|
620
|
-
self.time_layout.addWidget(self.play_button)
|
|
621
|
-
self.time_layout.addWidget(self.time_label)
|
|
622
|
-
self.time_layout.addWidget(self.time_slider, 1) # 1 = stretch factor
|
|
623
|
-
self.time_layout.addWidget(self.next_button)
|
|
624
|
-
self.time_layout.addWidget(QLabel("Jump to:"))
|
|
625
|
-
self.time_layout.addWidget(self.date_combo)
|
|
626
|
-
|
|
627
|
-
# Add time controls to main layout at the top
|
|
628
|
-
self.main_layout.addLayout(self.time_layout)
|
|
629
|
-
|
|
630
|
-
# Update time label
|
|
631
|
-
self.update_time_label()
|
|
632
|
-
|
|
633
578
|
# Scene + view
|
|
634
579
|
self.scene = QGraphicsScene(self)
|
|
635
580
|
self.view = RasterView(self.scene, self)
|
|
@@ -643,6 +588,8 @@ class TiffViewer(QMainWindow):
|
|
|
643
588
|
|
|
644
589
|
self.pixmap_item = None
|
|
645
590
|
self._last_rgb = None
|
|
591
|
+
|
|
592
|
+
# --- Initial render ---
|
|
646
593
|
self.update_pixmap()
|
|
647
594
|
|
|
648
595
|
# Overlays (if any)
|
|
@@ -780,27 +727,110 @@ class TiffViewer(QMainWindow):
|
|
|
780
727
|
|
|
781
728
|
# ----------------------- Title / Rendering ----------------------- #
|
|
782
729
|
def update_title(self):
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
730
|
+
"""Show correct title for GeoTIFF or NetCDF time series."""
|
|
731
|
+
import os
|
|
732
|
+
|
|
733
|
+
if hasattr(self, "_has_time_dim") and self._has_time_dim:
|
|
734
|
+
nc_name = getattr(self, "_nc_var_name", "")
|
|
735
|
+
file_name = os.path.basename(self.tif_path)
|
|
736
|
+
title = f"Time step {self.band_index + 1}/{self.band_count} — {file_name}"
|
|
737
|
+
|
|
788
738
|
elif hasattr(self, "band_index"):
|
|
789
739
|
title = f"Band {self.band_index + 1}/{self.band_count} — {os.path.basename(self.tif_path)}"
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
# Use generic label for non-time dimensions
|
|
796
|
-
if self._time_dim_name and self._time_dim_name != 'time':
|
|
797
|
-
title += f" - {self._time_dim_name}: {time_str} ({self._time_index + 1}/{len(self._time_values)})"
|
|
798
|
-
else:
|
|
799
|
-
title += f" - {time_str}"
|
|
800
|
-
self.setWindowTitle(title)
|
|
740
|
+
|
|
741
|
+
elif self.rgb_mode and self.rgb:
|
|
742
|
+
# title = f"RGB {self.rgb} — {os.path.basename(self.tif_path)}"
|
|
743
|
+
title = f"RGB {self.rgb}"
|
|
744
|
+
|
|
801
745
|
else:
|
|
802
|
-
|
|
803
|
-
|
|
746
|
+
title = os.path.basename(self.tif_path)
|
|
747
|
+
|
|
748
|
+
print(f"Title: {title}")
|
|
749
|
+
self.setWindowTitle(title)
|
|
750
|
+
|
|
751
|
+
def _normalize_lat_lon(self, frame):
|
|
752
|
+
"""Flip frame only if data and lat orientation disagree."""
|
|
753
|
+
import numpy as np
|
|
754
|
+
|
|
755
|
+
if not hasattr(self, "_lat_data"):
|
|
756
|
+
return frame
|
|
757
|
+
|
|
758
|
+
lats = self._lat_data
|
|
759
|
+
|
|
760
|
+
# 1D latitude case
|
|
761
|
+
if np.ndim(lats) == 1:
|
|
762
|
+
lat_ascending = lats[0] < lats[-1]
|
|
763
|
+
|
|
764
|
+
# If first pixel row corresponds to northernmost lat → do nothing
|
|
765
|
+
# If first pixel row corresponds to southernmost lat → flip to make north at top
|
|
766
|
+
# We'll assume data[0, :] corresponds to lats[0]
|
|
767
|
+
if lat_ascending:
|
|
768
|
+
print("[DEBUG] Flipping latitude orientation (lat ascending, data starts south)")
|
|
769
|
+
frame = np.flipud(frame)
|
|
770
|
+
# else:
|
|
771
|
+
# print("[DEBUG] No flip (lat descending, already north-up)")
|
|
772
|
+
return frame
|
|
773
|
+
|
|
774
|
+
# 2D latitude grid (rare case)
|
|
775
|
+
elif np.ndim(lats) == 2:
|
|
776
|
+
first_col = lats[:, 0]
|
|
777
|
+
lat_ascending = first_col[0] < first_col[-1]
|
|
778
|
+
if lat_ascending:
|
|
779
|
+
print("[DEBUG] Flipping latitude orientation (2D grid ascending)")
|
|
780
|
+
frame = np.flipud(frame)
|
|
781
|
+
# else:
|
|
782
|
+
# print("[DEBUG] No flip (2D grid already north-up)")
|
|
783
|
+
return frame
|
|
784
|
+
|
|
785
|
+
return frame
|
|
786
|
+
|
|
787
|
+
def _apply_scale_if_needed(self, frame):
|
|
788
|
+
"""Downsample frame and lat/lon consistently if --scale > 1."""
|
|
789
|
+
if not hasattr(self, "_scale_arg") or self._scale_arg <= 1:
|
|
790
|
+
return frame
|
|
791
|
+
|
|
792
|
+
step = int(self._scale_arg)
|
|
793
|
+
print(f"[DEBUG] Applying scale factor {step} to current frame")
|
|
794
|
+
|
|
795
|
+
# Downsample the frame
|
|
796
|
+
frame = frame[::step, ::step]
|
|
797
|
+
|
|
798
|
+
# Also downsample lat/lon for this viewer instance if not already
|
|
799
|
+
if hasattr(self, "_lat_data") and np.ndim(self._lat_data) == 1 and len(self._lat_data) > frame.shape[0]:
|
|
800
|
+
self._lat_data = self._lat_data[::step]
|
|
801
|
+
if hasattr(self, "_lon_data") and np.ndim(self._lon_data) == 1 and len(self._lon_data) > frame.shape[1]:
|
|
802
|
+
self._lon_data = self._lon_data[::step]
|
|
803
|
+
|
|
804
|
+
return frame
|
|
805
|
+
|
|
806
|
+
def get_current_frame(self):
|
|
807
|
+
"""Return the current time/band frame as a NumPy array (2D)."""
|
|
808
|
+
frame = None
|
|
809
|
+
|
|
810
|
+
if hasattr(self, '_time_dim_name') and hasattr(self, '_nc_var_data'):
|
|
811
|
+
# Select frame using band_index
|
|
812
|
+
try:
|
|
813
|
+
frame = self._nc_var_data.isel({self._time_dim_name: self.band_index})
|
|
814
|
+
except Exception:
|
|
815
|
+
# Already numpy or index error fallback
|
|
816
|
+
frame = self._nc_var_data
|
|
817
|
+
|
|
818
|
+
elif isinstance(self.data, np.ndarray):
|
|
819
|
+
frame = self.data
|
|
820
|
+
|
|
821
|
+
# Normalize lat orientation if needed
|
|
822
|
+
frame = self._normalize_lat_lon(frame)
|
|
823
|
+
frame = self._apply_scale_if_needed(frame)
|
|
824
|
+
# Convert to numpy if it's still an xarray
|
|
825
|
+
if hasattr(frame, "values"):
|
|
826
|
+
frame = frame.values
|
|
827
|
+
|
|
828
|
+
# Apply same scaling factor (if any)
|
|
829
|
+
if hasattr(self, "_scale_arg") and self._scale_arg > 1:
|
|
830
|
+
step = int(self._scale_arg)
|
|
831
|
+
|
|
832
|
+
return frame.astype(np.float32)
|
|
833
|
+
|
|
804
834
|
def format_time_value(self, time_value):
|
|
805
835
|
"""Format a time value into a user-friendly string"""
|
|
806
836
|
# Default is the string representation
|
|
@@ -826,197 +856,117 @@ class TiffViewer(QMainWindow):
|
|
|
826
856
|
|
|
827
857
|
return time_str
|
|
828
858
|
|
|
829
|
-
def update_time_label(self):
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
859
|
+
# def update_time_label(self):
|
|
860
|
+
# """Update the time label with the current time value"""
|
|
861
|
+
# if hasattr(self, '_has_time_dim') and self._has_time_dim:
|
|
862
|
+
# try:
|
|
863
|
+
# time_value = self._time_values[self._time_index]
|
|
864
|
+
# time_str = self.format_time_value(time_value)
|
|
835
865
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
# Create a progress bar style display of time position
|
|
841
|
-
total = len(self._time_values)
|
|
842
|
-
position = self._time_index + 1
|
|
843
|
-
bar_width = 20 # Width of the progress bar
|
|
844
|
-
filled = int(bar_width * position / total)
|
|
845
|
-
bar = "[" + "#" * filled + "-" * (bar_width - filled) + "]"
|
|
846
|
-
|
|
847
|
-
# Show time info in status bar
|
|
848
|
-
step_info = f"Time step: {position}/{total} {bar} {self.format_time_value(self._time_values[self._time_index])}"
|
|
849
|
-
|
|
850
|
-
# Update status bar if it exists
|
|
851
|
-
if hasattr(self, 'statusBar') and callable(self.statusBar):
|
|
852
|
-
self.statusBar().showMessage(step_info)
|
|
853
|
-
else:
|
|
854
|
-
print(step_info)
|
|
855
|
-
except Exception as e:
|
|
856
|
-
print(f"Error updating time label: {e}")
|
|
857
|
-
|
|
858
|
-
def time_slider_changed(self, value):
|
|
859
|
-
"""Handle time slider value change"""
|
|
860
|
-
if hasattr(self, '_has_time_dim') and self._has_time_dim:
|
|
861
|
-
self._time_index = value
|
|
862
|
-
|
|
863
|
-
# Update data for new time step
|
|
864
|
-
if self._time_dim_name:
|
|
865
|
-
# Use the named dimension (time or other index)
|
|
866
|
-
var_data = self._nc_var_data.isel({self._time_dim_name: self._time_index})
|
|
867
|
-
else:
|
|
868
|
-
# Fallback to 'time' dimension
|
|
869
|
-
var_data = self._nc_var_data.isel(time=self._time_index)
|
|
866
|
+
# # Update time label if it exists
|
|
867
|
+
# if hasattr(self, 'time_label'):
|
|
868
|
+
# self.time_label.setText(f"Time: {time_str}")
|
|
870
869
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
if self.date_combo.itemData(i) == value:
|
|
878
|
-
self.date_combo.blockSignals(True) # Block signals to avoid recursion
|
|
879
|
-
self.date_combo.setCurrentIndex(i)
|
|
880
|
-
self.date_combo.blockSignals(False)
|
|
881
|
-
break
|
|
882
|
-
except Exception as e:
|
|
883
|
-
print(f"Error updating combo box: {e}")
|
|
884
|
-
|
|
885
|
-
# Update UI
|
|
886
|
-
self.update_time_label()
|
|
887
|
-
self.update_pixmap()
|
|
888
|
-
self.update_title()
|
|
889
|
-
|
|
890
|
-
def next_time_step(self):
|
|
891
|
-
"""Go to the next time step"""
|
|
892
|
-
if hasattr(self, '_has_time_dim') and self._has_time_dim:
|
|
893
|
-
try:
|
|
894
|
-
next_time = (self._time_index + 1) % len(self._time_values)
|
|
895
|
-
if hasattr(self, 'time_slider'):
|
|
896
|
-
self.time_slider.setValue(next_time)
|
|
897
|
-
else:
|
|
898
|
-
# Direct update if slider doesn't exist
|
|
899
|
-
self._time_index = next_time
|
|
900
|
-
self.update_time_data()
|
|
901
|
-
except Exception as e:
|
|
902
|
-
print(f"Error going to next time step: {e}")
|
|
903
|
-
|
|
904
|
-
def prev_time_step(self):
|
|
905
|
-
"""Go to the previous time step"""
|
|
906
|
-
if hasattr(self, '_has_time_dim') and self._has_time_dim:
|
|
907
|
-
try:
|
|
908
|
-
prev_time = (self._time_index - 1) % len(self._time_values)
|
|
909
|
-
if hasattr(self, 'time_slider'):
|
|
910
|
-
self.time_slider.setValue(prev_time)
|
|
911
|
-
else:
|
|
912
|
-
# Direct update if slider doesn't exist
|
|
913
|
-
self._time_index = prev_time
|
|
914
|
-
self.update_time_data()
|
|
915
|
-
except Exception as e:
|
|
916
|
-
print(f"Error going to previous time step: {e}")
|
|
870
|
+
# # Create a progress bar style display of time position
|
|
871
|
+
# total = len(self._time_values)
|
|
872
|
+
# position = self._time_index + 1
|
|
873
|
+
# bar_width = 20 # Width of the progress bar
|
|
874
|
+
# filled = int(bar_width * position / total)
|
|
875
|
+
# bar = "[" + "#" * filled + "-" * (bar_width - filled) + "]"
|
|
917
876
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
try:
|
|
921
|
-
# Update data for current time step
|
|
922
|
-
if self._time_dim_name:
|
|
923
|
-
# Use the named dimension (time or other index)
|
|
924
|
-
var_data = self._nc_var_data.isel({self._time_dim_name: self._time_index})
|
|
925
|
-
else:
|
|
926
|
-
# Fallback to 'time' dimension
|
|
927
|
-
var_data = self._nc_var_data.isel(time=self._time_index)
|
|
877
|
+
# # Show time info in status bar
|
|
878
|
+
# step_info = f"Time step: {position}/{total} {bar} {self.format_time_value(self._time_values[self._time_index])}"
|
|
928
879
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
print(f"Error updating time data: {e}")
|
|
880
|
+
# # Update status bar if it exists
|
|
881
|
+
# if hasattr(self, 'statusBar') and callable(self.statusBar):
|
|
882
|
+
# self.statusBar().showMessage(step_info)
|
|
883
|
+
# else:
|
|
884
|
+
# print(step_info)
|
|
885
|
+
# except Exception as e:
|
|
886
|
+
# print(f"Error updating time label: {e}")
|
|
937
887
|
|
|
938
|
-
def toggle_play_pause(self):
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
888
|
+
# def toggle_play_pause(self):
|
|
889
|
+
# """Toggle play/pause animation of time steps"""
|
|
890
|
+
# if self._is_playing:
|
|
891
|
+
# self.stop_animation()
|
|
892
|
+
# else:
|
|
893
|
+
# self.start_animation()
|
|
944
894
|
|
|
945
|
-
def start_animation(self):
|
|
946
|
-
|
|
947
|
-
|
|
895
|
+
# def start_animation(self):
|
|
896
|
+
# """Start the time animation"""
|
|
897
|
+
# from PySide6.QtCore import QTimer
|
|
948
898
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
899
|
+
# if not hasattr(self, '_play_timer') or self._play_timer is None:
|
|
900
|
+
# self._play_timer = QTimer(self)
|
|
901
|
+
# self._play_timer.timeout.connect(self.animation_step)
|
|
952
902
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
903
|
+
# # Set animation speed (milliseconds between frames)
|
|
904
|
+
# animation_speed = 500 # 0.5 seconds between frames
|
|
905
|
+
# self._play_timer.start(animation_speed)
|
|
956
906
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
907
|
+
# self._is_playing = True
|
|
908
|
+
# self.play_button.setText("⏸") # Pause symbol
|
|
909
|
+
# self.play_button.setToolTip("Pause animation")
|
|
960
910
|
|
|
961
|
-
def stop_animation(self):
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
911
|
+
# def stop_animation(self):
|
|
912
|
+
# """Stop the time animation"""
|
|
913
|
+
# if hasattr(self, '_play_timer') and self._play_timer is not None:
|
|
914
|
+
# self._play_timer.stop()
|
|
965
915
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
916
|
+
# self._is_playing = False
|
|
917
|
+
# self.play_button.setText("▶") # Play symbol
|
|
918
|
+
# self.play_button.setToolTip("Play animation")
|
|
969
919
|
|
|
970
|
-
def animation_step(self):
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
920
|
+
# def animation_step(self):
|
|
921
|
+
# """Advance one frame in the animation"""
|
|
922
|
+
# # Go to next time step
|
|
923
|
+
# next_time = (self._time_index + 1) % len(self._time_values)
|
|
924
|
+
# self.time_slider.setValue(next_time)
|
|
975
925
|
|
|
976
|
-
def closeEvent(self, event):
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
926
|
+
# def closeEvent(self, event):
|
|
927
|
+
# """Clean up resources when the window is closed"""
|
|
928
|
+
# # Stop animation timer if it's running
|
|
929
|
+
# if hasattr(self, '_is_playing') and self._is_playing:
|
|
930
|
+
# self.stop_animation()
|
|
981
931
|
|
|
982
|
-
|
|
983
|
-
|
|
932
|
+
# # Call the parent class closeEvent
|
|
933
|
+
# super().closeEvent(event)
|
|
984
934
|
|
|
985
|
-
def populate_date_combo(self):
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
935
|
+
# def populate_date_combo(self):
|
|
936
|
+
# """Populate the date combo box with time values"""
|
|
937
|
+
# if hasattr(self, '_has_time_dim') and self._has_time_dim and hasattr(self, 'date_combo'):
|
|
938
|
+
# try:
|
|
939
|
+
# self.date_combo.clear()
|
|
990
940
|
|
|
991
|
-
|
|
992
|
-
|
|
941
|
+
# # Add a reasonable subset of dates if there are too many
|
|
942
|
+
# max_items = 100 # Maximum number of items to show in dropdown
|
|
993
943
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
944
|
+
# if len(self._time_values) <= max_items:
|
|
945
|
+
# # Add all time values
|
|
946
|
+
# for i, time_value in enumerate(self._time_values):
|
|
947
|
+
# time_str = self.format_time_value(time_value)
|
|
948
|
+
# self.date_combo.addItem(time_str, i)
|
|
949
|
+
# else:
|
|
950
|
+
# # Add a subset of time values
|
|
951
|
+
# step = len(self._time_values) // max_items
|
|
1002
952
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
953
|
+
# # Always include first and last
|
|
954
|
+
# indices = list(range(0, len(self._time_values), step))
|
|
955
|
+
# if (len(self._time_values) - 1) not in indices:
|
|
956
|
+
# indices.append(len(self._time_values) - 1)
|
|
1007
957
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
958
|
+
# for i in indices:
|
|
959
|
+
# time_str = self.format_time_value(self._time_values[i])
|
|
960
|
+
# self.date_combo.addItem(f"{time_str} [{i+1}/{len(self._time_values)}]", i)
|
|
961
|
+
# except Exception as e:
|
|
962
|
+
# print(f"Error populating date combo: {e}")
|
|
1013
963
|
|
|
1014
|
-
def date_combo_changed(self, index):
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
964
|
+
# def date_combo_changed(self, index):
|
|
965
|
+
# """Handle date combo box selection change"""
|
|
966
|
+
# if index >= 0:
|
|
967
|
+
# time_index = self.date_combo.itemData(index)
|
|
968
|
+
# if time_index is not None:
|
|
969
|
+
# self.time_slider.setValue(time_index)
|
|
1020
970
|
|
|
1021
971
|
def _render_rgb(self):
|
|
1022
972
|
if self.rgb_mode:
|
|
@@ -1025,8 +975,7 @@ class TiffViewer(QMainWindow):
|
|
|
1025
975
|
rgb = np.zeros_like(arr)
|
|
1026
976
|
if np.any(finite):
|
|
1027
977
|
# Global 2–98 percentile stretch across all bands (QGIS-like)
|
|
1028
|
-
global_min = np.nanpercentile(arr, 2)
|
|
1029
|
-
global_max = np.nanpercentile(arr, 98)
|
|
978
|
+
global_min, global_max = np.nanpercentile(arr, (2, 98))
|
|
1030
979
|
rng = max(global_max - global_min, 1e-12)
|
|
1031
980
|
norm = np.clip((arr - global_min) / rng, 0, 1)
|
|
1032
981
|
rgb = np.clip(norm * self.contrast, 0, 1)
|
|
@@ -1082,53 +1031,75 @@ class TiffViewer(QMainWindow):
|
|
|
1082
1031
|
norm_data = norm_data * rng + vmin
|
|
1083
1032
|
|
|
1084
1033
|
# Downsample coordinates to match downsampled data shape
|
|
1085
|
-
# data shape
|
|
1034
|
+
# --- Align coordinates with data shape (no stepping assumptions) ---
|
|
1035
|
+
# Downsample coordinates to match downsampled data shape
|
|
1086
1036
|
data_height, data_width = data.shape[:2]
|
|
1087
1037
|
lat_samples = len(lats)
|
|
1088
1038
|
lon_samples = len(lons)
|
|
1089
|
-
|
|
1090
|
-
# Calculate downsampling step if needed
|
|
1039
|
+
|
|
1091
1040
|
lat_step = max(1, lat_samples // data_height)
|
|
1092
1041
|
lon_step = max(1, lon_samples // data_width)
|
|
1093
|
-
|
|
1094
|
-
|
|
1042
|
+
|
|
1043
|
+
# Downsample coordinate arrays to match data
|
|
1095
1044
|
lats_downsampled = lats[::lat_step][:data_height]
|
|
1096
1045
|
lons_downsampled = lons[::lon_step][:data_width]
|
|
1097
|
-
|
|
1098
|
-
#
|
|
1046
|
+
|
|
1047
|
+
# --- Synchronize latitude orientation with normalized data ---
|
|
1048
|
+
if np.ndim(lats) == 1 and lats[0] < lats[-1]:
|
|
1049
|
+
print("[DEBUG] Lat ascending → flip lats_downsampled to match flipped data")
|
|
1050
|
+
lats_downsampled = lats_downsampled[::-1]
|
|
1051
|
+
elif np.ndim(lats) == 2:
|
|
1052
|
+
first_col = lats[:, 0]
|
|
1053
|
+
if first_col[0] < first_col[-1]:
|
|
1054
|
+
print("[DEBUG] 2D lat grid ascending → flip lats_downsampled vertically")
|
|
1055
|
+
lats_downsampled = np.flipud(lats_downsampled)
|
|
1056
|
+
|
|
1057
|
+
# Convert 0–360 longitude to −180–180 if needed
|
|
1099
1058
|
if lons_downsampled.max() > 180:
|
|
1100
1059
|
lons_downsampled = ((lons_downsampled + 180) % 360) - 180
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
# --- Build meshgrid AFTER any flip ---
|
|
1063
|
+
lon_grid, lat_grid = np.meshgrid(lons_downsampled, lats_downsampled, indexing="xy")
|
|
1064
|
+
|
|
1065
|
+
# Use pcolormesh (more stable than contourf for gridded data)
|
|
1066
|
+
img = ax.pcolormesh(
|
|
1067
|
+
lon_grid, lat_grid, data,
|
|
1068
|
+
transform=ccrs.PlateCarree(),
|
|
1069
|
+
cmap=cmap,
|
|
1070
|
+
shading="auto"
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
# Set extent from the 1D vectors (already flipped if needed)
|
|
1074
|
+
ax.set_extent(
|
|
1075
|
+
[lons_downsampled.min(), lons_downsampled.max(),
|
|
1076
|
+
lats_downsampled.min(), lats_downsampled.max()],
|
|
1077
|
+
crs=ccrs.PlateCarree()
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1115
1080
|
# Add map features
|
|
1116
|
-
ax.coastlines(resolution=
|
|
1117
|
-
ax.add_feature(cfeature.BORDERS, linestyle=
|
|
1118
|
-
ax.add_feature(cfeature.STATES, linestyle=
|
|
1081
|
+
ax.coastlines(resolution="50m", linewidth=0.5)
|
|
1082
|
+
ax.add_feature(cfeature.BORDERS, linestyle=":", linewidth=0.5)
|
|
1083
|
+
ax.add_feature(cfeature.STATES, linestyle="-", linewidth=0.3, alpha=0.5)
|
|
1119
1084
|
ax.gridlines(draw_labels=True, alpha=0.3)
|
|
1120
|
-
|
|
1121
|
-
# Add title
|
|
1122
|
-
title =
|
|
1123
|
-
if hasattr(self,
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1085
|
+
|
|
1086
|
+
# --- Add dynamic title ---
|
|
1087
|
+
title = os.path.basename(self.tif_path)
|
|
1088
|
+
if hasattr(self, "_has_time_dim") and self._has_time_dim:
|
|
1089
|
+
# Use current band_index as proxy for time_index
|
|
1090
|
+
try:
|
|
1091
|
+
current_time = self._time_values[self.band_index]
|
|
1092
|
+
time_str = self.format_time_value(current_time) if hasattr(self, "format_time_value") else str(current_time)
|
|
1093
|
+
ax.set_title(f"{title}\n{time_str}", fontsize=10)
|
|
1094
|
+
except Exception as e:
|
|
1095
|
+
ax.set_title(f"{title}\n(time step {self.band_index + 1})", fontsize=10)
|
|
1096
|
+
else:
|
|
1097
|
+
ax.set_title(title, fontsize=10)
|
|
1098
|
+
|
|
1128
1099
|
# Add colorbar
|
|
1129
|
-
plt.colorbar(
|
|
1130
|
-
|
|
1100
|
+
plt.colorbar(img, ax=ax, shrink=0.6)
|
|
1131
1101
|
plt.tight_layout()
|
|
1102
|
+
|
|
1132
1103
|
|
|
1133
1104
|
# Convert matplotlib figure to image
|
|
1134
1105
|
canvas = FigureCanvasAgg(fig)
|
|
@@ -1269,6 +1240,7 @@ class TiffViewer(QMainWindow):
|
|
|
1269
1240
|
elif k == Qt.Key.Key_BracketRight:
|
|
1270
1241
|
if hasattr(self, "band_index"): # HDF/NetCDF mode
|
|
1271
1242
|
self.band_index = (self.band_index + 1) % self.band_count
|
|
1243
|
+
self.data = self.get_current_frame()
|
|
1272
1244
|
self.update_pixmap()
|
|
1273
1245
|
self.update_title()
|
|
1274
1246
|
elif not self.rgb_mode: # GeoTIFF single-band mode
|
|
@@ -1278,6 +1250,7 @@ class TiffViewer(QMainWindow):
|
|
|
1278
1250
|
elif k == Qt.Key.Key_BracketLeft:
|
|
1279
1251
|
if hasattr(self, "band_index"): # HDF/NetCDF mode
|
|
1280
1252
|
self.band_index = (self.band_index - 1) % self.band_count
|
|
1253
|
+
self.data = self.get_current_frame()
|
|
1281
1254
|
self.update_pixmap()
|
|
1282
1255
|
self.update_title()
|
|
1283
1256
|
elif not self.rgb_mode: # GeoTIFF single-band mode
|
|
@@ -1312,33 +1285,6 @@ class TiffViewer(QMainWindow):
|
|
|
1312
1285
|
|
|
1313
1286
|
|
|
1314
1287
|
# --------------------------------- CLI ----------------------------------- #
|
|
1315
|
-
def main():
|
|
1316
|
-
parser = argparse.ArgumentParser(description="TIFF viewer with RGB (2–98%) & shapefile overlays")
|
|
1317
|
-
parser.add_argument("tif_path", nargs="?", help="Path to TIFF (optional if --rgbfiles is used)")
|
|
1318
|
-
parser.add_argument("--scale", type=int, default=1, help="Downsample factor (1=full, 10=10x smaller)")
|
|
1319
|
-
parser.add_argument("--band", type=int, default=1, help="Band number (ignored if --rgb/--rgbfiles used)")
|
|
1320
|
-
parser.add_argument("--rgb", nargs=3, type=int, help="Three band numbers for RGB, e.g. --rgb 4 3 2")
|
|
1321
|
-
parser.add_argument("--rgbfiles", nargs=3, help="Three single-band TIFFs for RGB, e.g. --rgbfiles B4.tif B3.tif B2.tif")
|
|
1322
|
-
parser.add_argument("--shapefile", nargs="+", help="One or more shapefiles to overlay")
|
|
1323
|
-
parser.add_argument("--shp-color", default="cyan", help="Overlay color (name or #RRGGBB). Default: cyan")
|
|
1324
|
-
parser.add_argument("--shp-width", type=float, default=1.5, help="Overlay line width (screen pixels). Default: 1.5")
|
|
1325
|
-
args = parser.parse_args()
|
|
1326
|
-
|
|
1327
|
-
app = QApplication(sys.argv)
|
|
1328
|
-
win = TiffViewer(
|
|
1329
|
-
args.tif_path,
|
|
1330
|
-
scale=args.scale,
|
|
1331
|
-
band=args.band,
|
|
1332
|
-
rgb=args.rgb,
|
|
1333
|
-
rgbfiles=args.rgbfiles,
|
|
1334
|
-
shapefiles=args.shapefile,
|
|
1335
|
-
shp_color=args.shp_color,
|
|
1336
|
-
shp_width=args.shp_width,
|
|
1337
|
-
)
|
|
1338
|
-
win.show()
|
|
1339
|
-
sys.exit(app.exec())
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
1288
|
def run_viewer(
|
|
1343
1289
|
tif_path,
|
|
1344
1290
|
scale=None,
|
|
@@ -1348,9 +1294,12 @@ def run_viewer(
|
|
|
1348
1294
|
shapefile=None,
|
|
1349
1295
|
shp_color=None,
|
|
1350
1296
|
shp_width=None,
|
|
1351
|
-
subset=None
|
|
1297
|
+
subset=None
|
|
1352
1298
|
):
|
|
1299
|
+
|
|
1353
1300
|
"""Launch the TiffViewer app"""
|
|
1301
|
+
from PySide6.QtCore import Qt
|
|
1302
|
+
# QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
|
|
1354
1303
|
app = QApplication(sys.argv)
|
|
1355
1304
|
win = TiffViewer(
|
|
1356
1305
|
tif_path,
|
|
@@ -1361,7 +1310,7 @@ def run_viewer(
|
|
|
1361
1310
|
shapefiles=shapefile,
|
|
1362
1311
|
shp_color=shp_color,
|
|
1363
1312
|
shp_width=shp_width,
|
|
1364
|
-
subset=subset
|
|
1313
|
+
subset=subset
|
|
1365
1314
|
)
|
|
1366
1315
|
win.show()
|
|
1367
1316
|
sys.exit(app.exec())
|
|
@@ -1369,10 +1318,10 @@ def run_viewer(
|
|
|
1369
1318
|
import click
|
|
1370
1319
|
|
|
1371
1320
|
@click.command()
|
|
1372
|
-
@click.version_option("0.2.
|
|
1321
|
+
@click.version_option("0.2.2", prog_name="viewtif")
|
|
1373
1322
|
@click.argument("tif_path", required=False)
|
|
1374
1323
|
@click.option("--band", default=1, show_default=True, type=int, help="Band number to display")
|
|
1375
|
-
@click.option("--scale", default=1.0, show_default=True, type=
|
|
1324
|
+
@click.option("--scale", default=1.0, show_default=True, type=int, help="Scale factor for display")
|
|
1376
1325
|
@click.option("--rgb", nargs=3, type=int, help="Three band numbers for RGB, e.g. --rgb 4 3 2")
|
|
1377
1326
|
@click.option("--rgbfiles", nargs=3, type=str, help="Three single-band TIFFs for RGB, e.g. --rgbfiles B4.tif B3.tif B2.tif")
|
|
1378
1327
|
@click.option("--shapefile", multiple=True, type=str, help="One or more shapefiles to overlay")
|
|
@@ -1398,7 +1347,7 @@ def main(tif_path, band, scale, rgb, rgbfiles, shapefile, shp_color, shp_width,
|
|
|
1398
1347
|
shapefile=shapefile,
|
|
1399
1348
|
shp_color=shp_color,
|
|
1400
1349
|
shp_width=shp_width,
|
|
1401
|
-
subset=subset
|
|
1350
|
+
subset=subset
|
|
1402
1351
|
)
|
|
1403
1352
|
|
|
1404
1353
|
if __name__ == "__main__":
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: viewtif
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: Lightweight GeoTIFF, NetCDF, HDF/HDF5, and Esri File Geodatabase (.gdb) viewer with optional shapefile overlay
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -29,14 +29,7 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
|
|
30
30
|
A lightweight GeoTIFF viewer for quick visualization directly from the command line.
|
|
31
31
|
|
|
32
|
-
You can visualize single-band GeoTIFFs, RGB composites, and shapefile overlays in a simple Qt-based window.
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
**Latest stable release:** [v0.1.9 on PyPI](https://pypi.org/project/viewtif/)
|
|
37
|
-
**Latest development:** main branch ([v0.2.0, experimental](https://github.com/nkeikon/viewtif))
|
|
38
|
-
|
|
39
|
-
---
|
|
32
|
+
You can visualize single-band GeoTIFFs, RGB composites, HDF, NetCDF files and shapefile overlays in a simple Qt-based window.
|
|
40
33
|
|
|
41
34
|
## Installation
|
|
42
35
|
|
|
@@ -56,7 +49,7 @@ pip install "viewtif[geo]"
|
|
|
56
49
|
> **Note:** For macOS(zsh) users:
|
|
57
50
|
> Make sure to include the quotes, or zsh will interpret it as a pattern.
|
|
58
51
|
|
|
59
|
-
#### HDF/HDF5 support
|
|
52
|
+
#### HDF/HDF5 support
|
|
60
53
|
```bash
|
|
61
54
|
brew install gdal # macOS
|
|
62
55
|
sudo apt install gdal-bin python3-gdal # Linux
|
|
@@ -64,6 +57,11 @@ pip install GDAL
|
|
|
64
57
|
```
|
|
65
58
|
> **Note:** GDAL is required to open `.hdf`, .`h5`, and `.hdf5` files. If it’s missing, viewtif will display: `RuntimeError: HDF support requires GDAL.`
|
|
66
59
|
|
|
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.
|
|
67
65
|
## Quick Start
|
|
68
66
|
```bash
|
|
69
67
|
# View a GeoTIFF
|
|
@@ -95,8 +93,11 @@ viewtif AG100.v003.33.-107.0001.h5 --subset 1 --band 3
|
|
|
95
93
|
`[WARN] raster lacks CRS/transform; cannot place overlays.`
|
|
96
94
|
|
|
97
95
|
### Update in v1.0.7: File Geodatabase (.gdb) support
|
|
98
|
-
`viewtif` can now open raster datasets stored inside Esri File Geodatabases (`.gdb`)
|
|
99
|
-
|
|
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.
|
|
100
101
|
|
|
101
102
|
```bash
|
|
102
103
|
# List available raster datasets
|
|
@@ -105,15 +106,15 @@ viewtif /path/to/geodatabase.gdb
|
|
|
105
106
|
# Open a specific raster
|
|
106
107
|
viewtif "OpenFileGDB:/path/to/geodatabase.gdb:RasterName"
|
|
107
108
|
```
|
|
108
|
-
> **Note:**
|
|
109
|
+
> **Note:** If multiple raster datasets are present, viewtif lists them all and shows how to open each. The .gdb path and raster name must be separated by a colon (:).
|
|
109
110
|
|
|
110
111
|
### Update in v1.0.7: Large raster safeguard
|
|
111
112
|
As of v1.0.7, `viewtif` automatically checks the raster size before loading.
|
|
112
113
|
If the dataset is very large (e.g., >20 million pixels), it will pause and warn that loading may freeze your system.
|
|
113
114
|
You can proceed manually or rerun with the `--scale` option for a smaller, faster preview.
|
|
114
115
|
|
|
115
|
-
### Update in v0.2.
|
|
116
|
-
`viewtif` now supports NetCDF (`.nc`) files with xarray and optional cartopy geographic visualization.
|
|
116
|
+
### 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.
|
|
117
118
|
|
|
118
119
|
#### Installation with NetCDF support
|
|
119
120
|
```bash
|
|
@@ -125,13 +126,6 @@ pip install "viewtif[netcdf]"
|
|
|
125
126
|
viewtif data.nc
|
|
126
127
|
```
|
|
127
128
|
|
|
128
|
-
> **Note:** NetCDF support is optional. If xarray or netCDF4 is missing, viewtif will display:
|
|
129
|
-
> `NetCDF support requires additional dependencies. Install them with: pip install viewtif[netcdf]`
|
|
130
|
-
>
|
|
131
|
-
> **Cartopy visualization:** For enhanced geographic visualization with map projections, coastlines, and borders, install with cartopy:
|
|
132
|
-
> `pip install "viewtif[netcdf]"` (cartopy is included in the netcdf extra)
|
|
133
|
-
> If cartopy is not available, netCDF files will still display with standard RGB rendering.
|
|
134
|
-
|
|
135
129
|
## Controls
|
|
136
130
|
| Key | Action |
|
|
137
131
|
| -------------------- | --------------------------------------- |
|
|
@@ -140,12 +134,12 @@ viewtif data.nc
|
|
|
140
134
|
| `C` / `V` | Increase / decrease contrast |
|
|
141
135
|
| `G` / `H` | Increase / decrease gamma |
|
|
142
136
|
| `M` | Toggle colormap (`viridis` ↔ `magma`) |
|
|
143
|
-
| `[` / `]` | Previous / next band (
|
|
137
|
+
| `[` / `]` | Previous / next band (or time step) |
|
|
144
138
|
| `R` | Reset view |
|
|
145
139
|
|
|
146
140
|
## Features
|
|
147
141
|
- Command-line driven GeoTIFF viewer.
|
|
148
|
-
- Supports single-band, RGB composite,
|
|
142
|
+
- Supports single-band, RGB composite, HDF/HDF5 subdatasets, and NetCDF.
|
|
149
143
|
- Optional shapefile overlay for geographic context.
|
|
150
144
|
- Adjustable contrast, gamma, and colormap.
|
|
151
145
|
- Fast preview using rasterio and PySide6.
|
|
@@ -166,5 +160,5 @@ Longenecker, Jake; Lee, Christine; Hulley, Glynn; Cawse-Nicholson, Kerry; Purkis
|
|
|
166
160
|
This project is released under the MIT License.
|
|
167
161
|
|
|
168
162
|
## Contributors
|
|
169
|
-
- [@HarshShinde0](https://github.com/HarshShinde0) — added mouse-wheel and trackpad zoom support
|
|
170
|
-
- [@p-vdp](https://github.com/p-vdp) — added File Geodatabase (.gdb) raster support
|
|
163
|
+
- [@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
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
viewtif/tif_viewer.py,sha256=egk8LkdTtbV77no4IUd_bHwpU7b0oCNE9z6qxpkOKa0,57343
|
|
2
|
+
viewtif-0.2.2.dist-info/METADATA,sha256=_7BJ66mI4kZzwSwTSG6fwWr5fe-USNzbGESpZvcNW7M,7280
|
|
3
|
+
viewtif-0.2.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
4
|
+
viewtif-0.2.2.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
|
|
5
|
+
viewtif-0.2.2.dist-info/RECORD,,
|
viewtif-0.2.0.dist-info/RECORD
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
viewtif/tif_viewer.py,sha256=zaIQ-u77jz2D1G5fDas0oR878dm1foNLUUKfdf5pTTU,60858
|
|
2
|
-
viewtif-0.2.0.dist-info/METADATA,sha256=APFk7tPPzcaaziAj_73Q0Y9pWitfFihx_x0Tamw7fxU,7497
|
|
3
|
-
viewtif-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
4
|
-
viewtif-0.2.0.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
|
|
5
|
-
viewtif-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|