viewtif 0.1.0__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 ADDED
@@ -0,0 +1,496 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ TIFF Viewer (PySide6) — RGB (2–98% global stretch) + Shapefile overlays
4
+
5
+ Features
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
14
+
15
+ Controls
16
+ + / - : zoom in/out
17
+ Arrow keys or WASD : pan
18
+ C / V : increase/decrease contrast (works in RGB and single-band)
19
+ G / H : increase/decrease gamma (works in RGB and single-band)
20
+ M : toggle colormap (viridis <-> magma) — single-band only
21
+ [ / ] : previous / next band (single-band)
22
+ R : reset view
23
+
24
+ Examples
25
+ python tiff_viewer.py my.tif --band 1
26
+ python tiff_viewer.py my_multiband.tif --rgb 4 3 2
27
+ python tiff_viewer.py --rgbfiles B4.tif B3.tif B2.tif --shapefile coast.shp counties.shp --shp-color cyan --shp-width 1.8
28
+ """
29
+
30
+ import sys
31
+ import os
32
+ import argparse
33
+ import numpy as np
34
+ import rasterio
35
+ from rasterio.transform import Affine
36
+ from PySide6.QtWidgets import (
37
+ QApplication, QMainWindow, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QScrollBar, QGraphicsPathItem
38
+ )
39
+ from PySide6.QtGui import QImage, QPixmap, QPainter, QPen, QColor, QPainterPath
40
+ from PySide6.QtCore import Qt
41
+
42
+ import matplotlib.cm as cm
43
+
44
+ # Optional overlay deps
45
+ try:
46
+ import geopandas as gpd
47
+ from shapely.geometry import (
48
+ LineString, MultiLineString, Polygon, MultiPolygon,
49
+ GeometryCollection, Point, MultiPoint
50
+ )
51
+ HAVE_GEO = True
52
+ except Exception:
53
+ HAVE_GEO = False
54
+
55
+
56
+ # -------------------------- QGraphicsView tweaks -------------------------- #
57
+ class RasterView(QGraphicsView):
58
+ def __init__(self, *args, **kwargs):
59
+ super().__init__(*args, **kwargs)
60
+ self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
61
+ self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
62
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
63
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
64
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
65
+
66
+
67
+ # ------------------------------- Main Window ------------------------------ #
68
+ class TiffViewer(QMainWindow):
69
+ def __init__(
70
+ self,
71
+ tif_path: str | None,
72
+ scale: int = 1,
73
+ band: int = 1,
74
+ rgb: list[int] | None = None,
75
+ rgbfiles: list[str] | None = None,
76
+ shapefiles: list[str] | None = None,
77
+ shp_color: str = "cyan",
78
+ shp_width: float = 1.5,
79
+ ):
80
+ super().__init__()
81
+
82
+ self.tif_path = tif_path or ""
83
+ self.rgb_mode = rgb is not None or rgbfiles is not None
84
+ self.band = int(band)
85
+ self.rgb = rgb
86
+ self.rgbfiles = rgbfiles
87
+
88
+ self._scale_arg = max(1, int(scale))
89
+ self._transform: Affine | None = None
90
+ self._crs = None
91
+
92
+ # Overlay config/state
93
+ self._shapefiles = shapefiles or []
94
+ self._shp_color = shp_color
95
+ self._shp_width = float(shp_width)
96
+ self._overlay_items: list[QGraphicsPathItem] = []
97
+
98
+ # --- Load data ---
99
+ if rgbfiles:
100
+ red, green, blue = rgbfiles
101
+ with rasterio.open(red) as r, rasterio.open(green) as g, rasterio.open(blue) as b:
102
+ if (r.width, r.height) != (g.width, g.height) or (r.width, r.height) != (b.width, b.height):
103
+ raise ValueError("All RGB files must have the same dimensions.")
104
+ arr = np.stack([
105
+ r.read(1, out_shape=(r.height // self._scale_arg, r.width // self._scale_arg)),
106
+ g.read(1, out_shape=(g.height // self._scale_arg, g.width // self._scale_arg)),
107
+ b.read(1, out_shape=(b.height // self._scale_arg, b.width // self._scale_arg))
108
+ ], axis=-1).astype(np.float32)
109
+ self._transform = r.transform
110
+ self._crs = r.crs
111
+
112
+ self.data = arr
113
+ self.band_count = 3
114
+ self.rgb = [os.path.basename(red), os.path.basename(green), os.path.basename(blue)]
115
+ # Use common prefix for title if tif_path not passed
116
+ self.tif_path = self.tif_path or (os.path.commonprefix([red, green, blue]) or red)
117
+
118
+ elif tif_path:
119
+ with rasterio.open(tif_path) as src:
120
+ self._transform = src.transform
121
+ self._crs = src.crs
122
+ if rgb is not None:
123
+ bands = [src.read(b, out_shape=(src.height // self._scale_arg, src.width // self._scale_arg))
124
+ for b in rgb]
125
+ arr = np.stack(bands, axis=-1).astype(np.float32)
126
+ nd = src.nodata
127
+ if nd is not None:
128
+ arr[arr == nd] = np.nan
129
+ self.data = arr
130
+ self.band_count = 3
131
+ else:
132
+ arr = src.read(
133
+ self.band,
134
+ out_shape=(src.height // self._scale_arg, src.width // self._scale_arg)
135
+ ).astype(np.float32)
136
+ nd = src.nodata
137
+ if nd is not None:
138
+ arr[arr == nd] = np.nan
139
+ self.data = arr
140
+ self.band_count = src.count
141
+
142
+ # single-band display range (fast stats or fallback)
143
+ try:
144
+ stats = src.stats(self.band)
145
+ if stats and stats.min is not None and stats.max is not None:
146
+ self.vmin, self.vmax = stats.min, stats.max
147
+ else:
148
+ raise ValueError("No stats in file")
149
+ except Exception:
150
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
151
+ else:
152
+ raise ValueError("Provide a TIFF path or --rgbfiles.")
153
+
154
+ # Window title
155
+ self.update_title()
156
+
157
+ # State
158
+ self.contrast = 1.0
159
+ self.gamma = 1.0
160
+
161
+ # Colormap (single-band)
162
+ self.cmap_name = "viridis"
163
+ self.alt_cmap_name = "magma" # toggle with M in single-band
164
+
165
+ self.zoom_step = 1.2
166
+ self.pan_step = 80
167
+
168
+ # Scene + view
169
+ self.scene = QGraphicsScene(self)
170
+ self.view = RasterView(self.scene, self)
171
+ self.setCentralWidget(self.view)
172
+
173
+ self.pixmap_item = None
174
+ self._last_rgb = None
175
+ self.update_pixmap()
176
+
177
+ # Overlays (if any)
178
+ if self._shapefiles:
179
+ self._add_shapefile_overlays()
180
+
181
+ self.resize(1200, 800)
182
+
183
+ if self.pixmap_item is not None:
184
+ rect = self.pixmap_item.boundingRect()
185
+ self.scene.setSceneRect(rect)
186
+ self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatioByExpanding)
187
+ self.view.scale(5, 5)
188
+ self.view.centerOn(self.pixmap_item)
189
+
190
+ # ---------------------------- Overlays ---------------------------- #
191
+ def _geo_to_pixel(self, x: float, y: float):
192
+ """Map coords (raster CRS) -> image pixel coords (after downsampling)."""
193
+ if self._transform is None:
194
+ return None
195
+ inv = ~self._transform # (col, row) from (x, y)
196
+ col, row = inv * (x, y)
197
+ return (col / self._scale_arg, row / self._scale_arg)
198
+
199
+ def _geom_to_qpath(self, geom) -> QPainterPath | None:
200
+ """
201
+ Convert shapely geom (in raster CRS) to QPainterPath in *image pixel* coords.
202
+ Z/M tolerant: only X,Y are used. Draws Points as tiny segments.
203
+ """
204
+ def _coords_to_path(coords, path: QPainterPath):
205
+ first = True
206
+ for c in coords:
207
+ if c is None:
208
+ continue
209
+ # tolerate 2D or 3D tuples (ignore Z/M)
210
+ x = c[0]
211
+ y = c[1] if len(c) > 1 else None
212
+ if y is None:
213
+ continue
214
+ px = self._geo_to_pixel(x, y)
215
+ if px is None:
216
+ continue
217
+ if first:
218
+ path.moveTo(px[0], px[1])
219
+ first = False
220
+ else:
221
+ path.lineTo(px[0], px[1])
222
+
223
+ path = QPainterPath()
224
+
225
+ if isinstance(geom, LineString):
226
+ _coords_to_path(list(geom.coords), path)
227
+ return path
228
+
229
+ if isinstance(geom, MultiLineString):
230
+ for ls in geom.geoms:
231
+ _coords_to_path(list(ls.coords), path)
232
+ return path
233
+
234
+ if isinstance(geom, Polygon):
235
+ _coords_to_path(list(geom.exterior.coords), path)
236
+ for ring in geom.interiors:
237
+ _coords_to_path(list(ring.coords), path)
238
+ return path
239
+
240
+ if isinstance(geom, MultiPolygon):
241
+ for poly in geom.geoms:
242
+ _coords_to_path(list(poly.exterior.coords), path)
243
+ for ring in poly.interiors:
244
+ _coords_to_path(list(ring.coords), path)
245
+ return path
246
+
247
+ if isinstance(geom, Point):
248
+ px = self._geo_to_pixel(geom.x, geom.y)
249
+ if px is None:
250
+ return None
251
+ path.moveTo(px[0], px[1])
252
+ path.lineTo(px[0] + 0.01, px[1] + 0.01) # tiny mark; cosmetic pen keeps visible
253
+ return path
254
+
255
+ if isinstance(geom, MultiPoint):
256
+ for p in geom.geoms:
257
+ sub = self._geom_to_qpath(p)
258
+ if sub:
259
+ path.addPath(sub)
260
+ return path
261
+
262
+ if isinstance(geom, GeometryCollection):
263
+ for g in geom.geoms:
264
+ sub = self._geom_to_qpath(g)
265
+ if sub:
266
+ path.addPath(sub)
267
+ return path
268
+
269
+ return None
270
+
271
+ def _add_shapefile_overlays(self):
272
+ if not HAVE_GEO:
273
+ print("[WARN] geopandas/shapely not available; --shapefile ignored.")
274
+ return
275
+ if self._crs is None or self._transform is None:
276
+ print("[WARN] raster lacks CRS/transform; cannot place overlays.")
277
+ return
278
+
279
+ pen = QPen(QColor(self._shp_color))
280
+ pen.setWidthF(self._shp_width)
281
+ pen.setCosmetic(True) # constant on-screen width
282
+
283
+ for shp_path in self._shapefiles:
284
+ try:
285
+ gdf = gpd.read_file(shp_path)
286
+ if gdf.empty:
287
+ continue
288
+
289
+ if gdf.crs is None:
290
+ print(f"[WARN] {os.path.basename(shp_path)} has no CRS; assuming raster CRS.")
291
+ gdf = gdf.set_crs(self._crs)
292
+ else:
293
+ gdf = gdf.to_crs(self._crs)
294
+
295
+ for geom in gdf.geometry:
296
+ if geom is None or geom.is_empty:
297
+ continue
298
+ qpath = self._geom_to_qpath(geom)
299
+ if qpath is None or qpath.isEmpty():
300
+ continue
301
+ item = QGraphicsPathItem(qpath)
302
+ item.setPen(pen)
303
+ item.setZValue(10.0)
304
+ self.scene.addItem(item)
305
+ self._overlay_items.append(item)
306
+
307
+ except Exception as e:
308
+ print(f"[WARN] Failed to draw overlay {os.path.basename(shp_path)}: {e}")
309
+
310
+ # ----------------------- Title / Rendering ----------------------- #
311
+ def update_title(self):
312
+ if self.rgbfiles:
313
+ names = [os.path.basename(n) for n in self.rgbfiles]
314
+ self.setWindowTitle(f"RGB ({', '.join(names)})")
315
+ elif self.rgb_mode and self.rgb:
316
+ self.setWindowTitle(f"RGB {self.rgb} — {os.path.basename(self.tif_path)}")
317
+ else:
318
+ self.setWindowTitle(f"Band {self.band}/{self.band_count} — {os.path.basename(self.tif_path)}")
319
+
320
+ def _render_rgb(self):
321
+ if self.rgb_mode:
322
+ arr = self.data
323
+ finite = np.isfinite(arr)
324
+ rgb = np.zeros_like(arr)
325
+ if np.any(finite):
326
+ # Global 2–98 percentile stretch across all bands (QGIS-like)
327
+ global_min = np.nanpercentile(arr, 2)
328
+ global_max = np.nanpercentile(arr, 98)
329
+ rng = max(global_max - global_min, 1e-12)
330
+ norm = np.clip((arr - global_min) / rng, 0, 1)
331
+ rgb = np.clip(norm * self.contrast, 0, 1)
332
+ rgb = np.power(rgb, self.gamma)
333
+ return (rgb * 255).astype(np.uint8)
334
+ else:
335
+ a = self.data
336
+ finite = np.isfinite(a)
337
+ norm = np.zeros_like(a, dtype=np.float32)
338
+ rng = max(self.vmax - self.vmin, 1e-12)
339
+ if np.any(finite):
340
+ norm[finite] = (a[finite] - self.vmin) / rng
341
+ norm = np.clip(norm * self.contrast, 0.0, 1.0)
342
+ norm = np.power(norm, self.gamma)
343
+ # viridis <-> magma toggle
344
+ cmap = getattr(cm, self.cmap_name, cm.viridis)
345
+ rgb = (cmap(norm)[..., :3] * 255).astype(np.uint8)
346
+ return rgb
347
+
348
+ def update_pixmap(self):
349
+ rgb = self._render_rgb()
350
+ h, w, _ = rgb.shape
351
+ self._last_rgb = rgb
352
+ qimg = QImage(self._last_rgb.data, w, h, 3 * w, QImage.Format.Format_RGB888)
353
+ pix = QPixmap.fromImage(qimg)
354
+ if self.pixmap_item is None:
355
+ self.pixmap_item = QGraphicsPixmapItem(pix)
356
+ self.pixmap_item.setZValue(0.0)
357
+ self.scene.addItem(self.pixmap_item)
358
+ else:
359
+ self.pixmap_item.setPixmap(pix)
360
+
361
+ # ----------------------- Single-band switching ------------------- #
362
+ def load_band(self, band_num: int):
363
+ if self.rgb_mode:
364
+ return
365
+ with rasterio.open(self.tif_path) as src:
366
+ self.band = band_num
367
+ arr = src.read(self.band).astype(np.float32)
368
+ nd = src.nodata
369
+ if nd is not None:
370
+ arr[arr == nd] = np.nan
371
+ self.data = arr
372
+ self.vmin, self.vmax = np.nanmin(arr), np.nanmax(arr)
373
+ self.update_pixmap()
374
+ self.update_title()
375
+
376
+ # ------------------------------ Keys ----------------------------- #
377
+ def keyPressEvent(self, ev):
378
+ k = ev.key()
379
+ hsb: QScrollBar = self.view.horizontalScrollBar()
380
+ vsb: QScrollBar = self.view.verticalScrollBar()
381
+
382
+ if k in (Qt.Key.Key_Plus, Qt.Key.Key_Equal, Qt.Key.Key_Z):
383
+ self.view.scale(self.zoom_step, self.zoom_step)
384
+ elif k in (Qt.Key.Key_Minus, Qt.Key.Key_Underscore, Qt.Key.Key_X):
385
+ inv = 1.0 / self.zoom_step
386
+ self.view.scale(inv, inv)
387
+ elif k in (Qt.Key.Key_Left, Qt.Key.Key_A):
388
+ hsb.setValue(hsb.value() - self.pan_step)
389
+ elif k in (Qt.Key.Key_Right, Qt.Key.Key_D):
390
+ hsb.setValue(hsb.value() + self.pan_step)
391
+ elif k in (Qt.Key.Key_Up, Qt.Key.Key_W):
392
+ vsb.setValue(vsb.value() - self.pan_step)
393
+ elif k in (Qt.Key.Key_Down, Qt.Key.Key_S):
394
+ vsb.setValue(vsb.value() + self.pan_step)
395
+
396
+ # Contrast / Gamma now work in both modes
397
+ elif k == Qt.Key.Key_C:
398
+ self.contrast *= 1.1; self.update_pixmap()
399
+ elif k == Qt.Key.Key_V:
400
+ self.contrast /= 1.1; self.update_pixmap()
401
+ elif k == Qt.Key.Key_G:
402
+ self.gamma *= 1.1; self.update_pixmap()
403
+ elif k == Qt.Key.Key_H:
404
+ self.gamma /= 1.1; self.update_pixmap()
405
+
406
+ # Colormap toggle (single-band only)
407
+ elif not self.rgb_mode and k == Qt.Key.Key_M:
408
+ self.cmap_name, self.alt_cmap_name = self.alt_cmap_name, self.cmap_name
409
+ self.update_pixmap()
410
+
411
+ # Band switch (single-band)
412
+ elif not self.rgb_mode and k == Qt.Key.Key_BracketRight:
413
+ new_band = self.band + 1 if self.band < self.band_count else 1
414
+ self.load_band(new_band)
415
+ elif not self.rgb_mode and k == Qt.Key.Key_BracketLeft:
416
+ new_band = self.band - 1 if self.band > 1 else self.band_count
417
+ self.load_band(new_band)
418
+
419
+ elif k == Qt.Key.Key_R:
420
+ self.contrast = 1.0
421
+ self.gamma = 1.0
422
+ self.update_pixmap()
423
+ self.view.resetTransform()
424
+ self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
425
+ else:
426
+ super().keyPressEvent(ev)
427
+
428
+
429
+ # --------------------------------- CLI ----------------------------------- #
430
+ def main():
431
+ parser = argparse.ArgumentParser(description="TIFF viewer with RGB (2–98%) & shapefile overlays")
432
+ parser.add_argument("tif_path", nargs="?", help="Path to TIFF (optional if --rgbfiles is used)")
433
+ parser.add_argument("--scale", type=int, default=1, help="Downsample factor (1=full, 10=10x smaller)")
434
+ parser.add_argument("--band", type=int, default=1, help="Band number (ignored if --rgb/--rgbfiles used)")
435
+ parser.add_argument("--rgb", nargs=3, type=int, help="Three band numbers for RGB, e.g. --rgb 4 3 2")
436
+ parser.add_argument("--rgbfiles", nargs=3, help="Three single-band TIFFs for RGB, e.g. --rgbfiles B4.tif B3.tif B2.tif")
437
+ parser.add_argument("--shapefile", nargs="+", help="One or more shapefiles to overlay")
438
+ parser.add_argument("--shp-color", default="cyan", help="Overlay color (name or #RRGGBB). Default: cyan")
439
+ parser.add_argument("--shp-width", type=float, default=1.5, help="Overlay line width (screen pixels). Default: 1.5")
440
+ args = parser.parse_args()
441
+
442
+ app = QApplication(sys.argv)
443
+ win = TiffViewer(
444
+ args.tif_path,
445
+ scale=args.scale,
446
+ band=args.band,
447
+ rgb=args.rgb,
448
+ rgbfiles=args.rgbfiles,
449
+ shapefiles=args.shapefile,
450
+ shp_color=args.shp_color,
451
+ shp_width=args.shp_width,
452
+ )
453
+ win.show()
454
+ sys.exit(app.exec())
455
+
456
+
457
+ def run_viewer(
458
+ tif_path,
459
+ scale=None,
460
+ band=None,
461
+ rgb=None,
462
+ rgbfiles=None,
463
+ shapefile=None,
464
+ shp_color=None,
465
+ shp_width=None,
466
+ ):
467
+ """Launch the TiffViewer app"""
468
+ app = QApplication(sys.argv)
469
+ win = TiffViewer(
470
+ tif_path,
471
+ scale=scale,
472
+ band=band,
473
+ rgb=rgb,
474
+ rgbfiles=rgbfiles,
475
+ shapefiles=shapefile,
476
+ shp_color=shp_color,
477
+ shp_width=shp_width,
478
+ )
479
+ win.show()
480
+ sys.exit(app.exec())
481
+
482
+ import click
483
+
484
+ @click.command()
485
+ @click.argument("tif_path", type=click.Path(exists=True))
486
+ @click.option("--shapefile", type=click.Path(exists=True), default=None,
487
+ help="Optional shapefile overlay")
488
+ @click.option("--scale", type=float, default=None, help="Optional scale")
489
+ @click.option("--band", type=int, default=None, help="Optional band index")
490
+ def main(tif_path, shapefile, scale, band):
491
+ """CLI entry point for viewtif"""
492
+ run_viewer(tif_path, scale=scale, band=band, shapefile=shapefile)
493
+
494
+ if __name__ == "__main__":
495
+ main()
496
+
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: viewtif
3
+ Version: 0.1.0
4
+ Summary: Simple GeoTIFF viewer with optional shapefile overlay.
5
+ Author: Keiko Nomura
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: click>=8.1
9
+ Requires-Dist: matplotlib>=3.7
10
+ Requires-Dist: numpy>=1.23
11
+ Requires-Dist: pyside6>=6.5
12
+ Requires-Dist: rasterio>=1.3
13
+ Description-Content-Type: text/markdown
14
+
15
+ # viewtif
16
+
17
+ A simple GeoTIFF viewer with optional shapefile overlay.
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ viewtif my_raster.tif --shapefile my_overlay.shp
23
+
@@ -0,0 +1,5 @@
1
+ viewtif/tif_viewer.py,sha256=hXoL4J0XSJlhoDytXIDc3UiS1mAQ29t5T6YNofrtONo,18933
2
+ viewtif-0.1.0.dist-info/METADATA,sha256=_v0HW4AQHrrd1M7Z9BXQ8kC2usSu171h85rKYtb_QBQ,491
3
+ viewtif-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
4
+ viewtif-0.1.0.dist-info/entry_points.txt,sha256=NVEjlRyJ7R7hFPOVsZJio3Hl0VqlX7_oVfA7819XvHM,52
5
+ viewtif-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ viewtif = viewtif.tif_viewer:main