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,,
|