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 CHANGED
@@ -1,16 +1,14 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- TIFF Viewer (PySide6) — RGB (2–98% global stretch) + Shapefile overlays
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 (--rgbfiles R.tif G.tif B.tif)
8
- - QGIS-like RGB display using global 2–98 percentile stretch
9
- - Single-band view with contrast/gamma + colormap toggle (viridis <-> magma)
10
- - Pan & zoom
11
- - Switch bands with [ and ] (single-band)
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, QSlider, QLabel,
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, QDateTime
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.time.values
267
- self._time_index = 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
- # --- Universal size check before loading ---
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
- # --- If user specified --band, start there ---
443
- if self.band and self.band <= self.band_count:
444
- self.band_index = self.band - 1
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
- if self.rgbfiles:
784
- names = [os.path.basename(n) for n in self.rgbfiles]
785
- self.setWindowTitle(f"RGB ({', '.join(names)})")
786
- elif self.rgb_mode and self.rgb:
787
- self.setWindowTitle(f"RGB {self.rgb} — {os.path.basename(self.tif_path)}")
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
- if hasattr(self, '_has_time_dim') and self._has_time_dim:
791
- time_str = self.format_time_value(self._time_values[self._time_index])
792
- if len(time_str) > 15: # Truncate if too long for title
793
- time_str = time_str[:15]
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
- self.setWindowTitle(f"Band {self.band}/{self.band_count} — {os.path.basename(self.tif_path)}")
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
- """Update the time label with the current time value"""
831
- if hasattr(self, '_has_time_dim') and self._has_time_dim:
832
- try:
833
- time_value = self._time_values[self._time_index]
834
- time_str = self.format_time_value(time_value)
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
- # Update time label if it exists
837
- if hasattr(self, 'time_label'):
838
- self.time_label.setText(f"Time: {time_str}")
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
- self.data = var_data.values.astype(np.float32)
872
-
873
- # Find and select the matching date in the combo box (if it exists)
874
- if hasattr(self, 'date_combo'):
875
- try:
876
- for i in range(self.date_combo.count()):
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
- def update_time_data(self):
919
- """Update data for the current time index"""
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
- self.data = var_data.values.astype(np.float32)
930
-
931
- # Update UI
932
- self.update_time_label()
933
- self.update_pixmap()
934
- self.update_title()
935
- except Exception as e:
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
- """Toggle play/pause animation of time steps"""
940
- if self._is_playing:
941
- self.stop_animation()
942
- else:
943
- self.start_animation()
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
- """Start the time animation"""
947
- from PySide6.QtCore import QTimer
895
+ # def start_animation(self):
896
+ # """Start the time animation"""
897
+ # from PySide6.QtCore import QTimer
948
898
 
949
- if not hasattr(self, '_play_timer') or self._play_timer is None:
950
- self._play_timer = QTimer(self)
951
- self._play_timer.timeout.connect(self.animation_step)
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
- # Set animation speed (milliseconds between frames)
954
- animation_speed = 500 # 0.5 seconds between frames
955
- self._play_timer.start(animation_speed)
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
- self._is_playing = True
958
- self.play_button.setText("⏸") # Pause symbol
959
- self.play_button.setToolTip("Pause animation")
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
- """Stop the time animation"""
963
- if hasattr(self, '_play_timer') and self._play_timer is not None:
964
- self._play_timer.stop()
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
- self._is_playing = False
967
- self.play_button.setText("▶") # Play symbol
968
- self.play_button.setToolTip("Play animation")
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
- """Advance one frame in the animation"""
972
- # Go to next time step
973
- next_time = (self._time_index + 1) % len(self._time_values)
974
- self.time_slider.setValue(next_time)
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
- """Clean up resources when the window is closed"""
978
- # Stop animation timer if it's running
979
- if hasattr(self, '_is_playing') and self._is_playing:
980
- self.stop_animation()
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
- # Call the parent class closeEvent
983
- super().closeEvent(event)
932
+ # # Call the parent class closeEvent
933
+ # super().closeEvent(event)
984
934
 
985
- def populate_date_combo(self):
986
- """Populate the date combo box with time values"""
987
- if hasattr(self, '_has_time_dim') and self._has_time_dim and hasattr(self, 'date_combo'):
988
- try:
989
- self.date_combo.clear()
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
- # Add a reasonable subset of dates if there are too many
992
- max_items = 100 # Maximum number of items to show in dropdown
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
- if len(self._time_values) <= max_items:
995
- # Add all time values
996
- for i, time_value in enumerate(self._time_values):
997
- time_str = self.format_time_value(time_value)
998
- self.date_combo.addItem(time_str, i)
999
- else:
1000
- # Add a subset of time values
1001
- step = len(self._time_values) // max_items
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
- # Always include first and last
1004
- indices = list(range(0, len(self._time_values), step))
1005
- if (len(self._time_values) - 1) not in indices:
1006
- indices.append(len(self._time_values) - 1)
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
- for i in indices:
1009
- time_str = self.format_time_value(self._time_values[i])
1010
- self.date_combo.addItem(f"{time_str} [{i+1}/{len(self._time_values)}]", i)
1011
- except Exception as e:
1012
- print(f"Error populating date combo: {e}")
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
- """Handle date combo box selection change"""
1016
- if index >= 0:
1017
- time_index = self.date_combo.itemData(index)
1018
- if time_index is not None:
1019
- self.time_slider.setValue(time_index)
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 is (lat_samples, lon_samples) after downsampling
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
- # Downsample coordinate arrays to match data
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
- # Convert 0-360 longitude to -180 to 180 if needed
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
- # Create 2D meshgrid for proper coordinate alignment with cartopy
1103
- lon_grid, lat_grid = np.meshgrid(lons_downsampled, lats_downsampled, indexing='xy')
1104
-
1105
- # Create the plot with 2D meshgrid for proper coordinate alignment
1106
- contour = ax.contourf(lon_grid, lat_grid, data,
1107
- transform=ccrs.PlateCarree(),
1108
- levels=levels, cmap=cmap)
1109
-
1110
- # Set map extent based on actual downsampled coordinates
1111
- lon_min, lon_max = lons_downsampled.min(), lons_downsampled.max()
1112
- lat_min, lat_max = lats_downsampled.min(), lats_downsampled.max()
1113
- ax.set_extent([lon_min, lon_max, lat_min, lat_max], crs=ccrs.PlateCarree())
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='50m', linewidth=0.5)
1117
- ax.add_feature(cfeature.BORDERS, linestyle=':', linewidth=0.5)
1118
- ax.add_feature(cfeature.STATES, linestyle='-', linewidth=0.3, alpha=0.5)
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 with variable name and time if available
1122
- title = f"{self._nc_var_name}"
1123
- if hasattr(self, '_has_time_dim') and self._has_time_dim:
1124
- time_str = str(self._time_values[self._time_index])
1125
- title += f"\n{time_str}"
1126
- ax.set_title(title)
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(contour, ax=ax, shrink=0.6)
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.0", prog_name="viewtif")
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=float, help="Scale factor for display")
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.0
4
- Summary: Lightweight GeoTIFF, NetCDF, HDF/HDF5, and Esri File Geodatabase (.gdb) viewer with optional shapefile overlay and geographic visualization. NetCDF and cartopy support available via pip install viewtif[netcdf].
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`), using the GDAL `OpenFileGDB` driver.
99
- When you open a .gdb directly, `viewtif`` will list available raster datasets first, then you can choose one to view.
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:** Requires GDAL 3.7 or later with the OpenFileGDB driver enabled. 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
+ > **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.0: NetCDF support with optional cartopy visualization
116
- `viewtif` now supports NetCDF (`.nc`) files with xarray and optional cartopy geographic visualization. NetCDF support is optional to keep the base installation lightweight.
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 (single-band only) |
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, and HDF/HDF5 subdatasets.
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,,
@@ -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,,