viewinline 0.1.0__tar.gz
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.
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: viewinline
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Quick look geospatial viewer for iTerm2 and ANSI compatible terminals
|
|
5
|
+
Project-URL: Homepage, https://github.com/nkeikon/viewinline
|
|
6
|
+
Project-URL: Repository, https://github.com/nkeikon/viewinline
|
|
7
|
+
Project-URL: Issues, https://github.com/nkeikon/viewinline/issues
|
|
8
|
+
Author: Keiko Nomura
|
|
9
|
+
License: MIT
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: geopandas
|
|
12
|
+
Requires-Dist: matplotlib
|
|
13
|
+
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: pillow
|
|
15
|
+
Requires-Dist: pyogrio
|
|
16
|
+
Requires-Dist: rasterio
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# viewinline
|
|
20
|
+
**Quick-look geospatial viewer for iTerm2.**
|
|
21
|
+
Displays rasters and vectors directly in the terminal — no GUI, no temporary files.
|
|
22
|
+
|
|
23
|
+
This tool combines the core display logic of `viewtif` and `viewgeom`, but is **non-interactive**:
|
|
24
|
+
you can’t zoom, pan, or switch colormaps on the fly. Instead, you control everything through command-line options (e.g. --display, --color-by, --colormap).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
viewinline path/to/file.tif
|
|
28
|
+
viewinline path/to/vector.geojson
|
|
29
|
+
viewinline R.tif G.tif B.tif # RGB composite
|
|
30
|
+
```
|
|
31
|
+
It’s designed for iTerm2 on macOS, using its inline image protocol to render a preview.
|
|
32
|
+
|
|
33
|
+
### Dependencies
|
|
34
|
+
Requires Python 3.9 or later and the following libraries:
|
|
35
|
+
```bash
|
|
36
|
+
pip install numpy pillow rasterio geopandas matplotlib pyogrio
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Available options
|
|
40
|
+
```bash
|
|
41
|
+
--display DISPLAY # resize the displayed image (0.5=smaller, 2=bigger). default: auto fit to terminal
|
|
42
|
+
--ansi-size ANSI_SIZE # set resolution if you are viewing the ANSI preview (try 180x90 or 200x100)
|
|
43
|
+
--band BAND # band number to display (single raster case). default: 1
|
|
44
|
+
--colormap [{viridis,inferno,magma,plasma,cividis,terrain,RdYlGn,coolwarm,Spectral,cubehelix,tab10,turbo}]
|
|
45
|
+
# apply a colormap to single band rasters or vector coloring
|
|
46
|
+
# flag without value uses 'terrain' by default
|
|
47
|
+
--color-by COLOR_BY # select a numeric column to color vector features
|
|
48
|
+
--edgecolor EDGECOLOR # edge color for vector outlines (hex or named color). default: #F6FF00
|
|
49
|
+
--layer LAYER # layer name for GeoPackage or multi layer files
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### ANSI/ASCII color preview
|
|
53
|
+
If iTerm2 isn’t available, viewinline will automatically switch an
|
|
54
|
+
ANSI/ASCII color preview or save a quick PNG under /tmp/viewinline_preview.png.
|
|
55
|
+
|
|
56
|
+
This mode works on terminals with **ANSI color support** and may not display correctly on others.
|
|
57
|
+
For compatible terminals, `viewinline` renders images in a very coarse resolution. This feature is experimental.
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
This project is released under the MIT License © 2025 Keiko Nomura
|
|
61
|
+
If you find this tool useful, please consider supporting or acknowledging the author.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# viewinline
|
|
2
|
+
**Quick-look geospatial viewer for iTerm2.**
|
|
3
|
+
Displays rasters and vectors directly in the terminal — no GUI, no temporary files.
|
|
4
|
+
|
|
5
|
+
This tool combines the core display logic of `viewtif` and `viewgeom`, but is **non-interactive**:
|
|
6
|
+
you can’t zoom, pan, or switch colormaps on the fly. Instead, you control everything through command-line options (e.g. --display, --color-by, --colormap).
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
viewinline path/to/file.tif
|
|
10
|
+
viewinline path/to/vector.geojson
|
|
11
|
+
viewinline R.tif G.tif B.tif # RGB composite
|
|
12
|
+
```
|
|
13
|
+
It’s designed for iTerm2 on macOS, using its inline image protocol to render a preview.
|
|
14
|
+
|
|
15
|
+
### Dependencies
|
|
16
|
+
Requires Python 3.9 or later and the following libraries:
|
|
17
|
+
```bash
|
|
18
|
+
pip install numpy pillow rasterio geopandas matplotlib pyogrio
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Available options
|
|
22
|
+
```bash
|
|
23
|
+
--display DISPLAY # resize the displayed image (0.5=smaller, 2=bigger). default: auto fit to terminal
|
|
24
|
+
--ansi-size ANSI_SIZE # set resolution if you are viewing the ANSI preview (try 180x90 or 200x100)
|
|
25
|
+
--band BAND # band number to display (single raster case). default: 1
|
|
26
|
+
--colormap [{viridis,inferno,magma,plasma,cividis,terrain,RdYlGn,coolwarm,Spectral,cubehelix,tab10,turbo}]
|
|
27
|
+
# apply a colormap to single band rasters or vector coloring
|
|
28
|
+
# flag without value uses 'terrain' by default
|
|
29
|
+
--color-by COLOR_BY # select a numeric column to color vector features
|
|
30
|
+
--edgecolor EDGECOLOR # edge color for vector outlines (hex or named color). default: #F6FF00
|
|
31
|
+
--layer LAYER # layer name for GeoPackage or multi layer files
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### ANSI/ASCII color preview
|
|
35
|
+
If iTerm2 isn’t available, viewinline will automatically switch an
|
|
36
|
+
ANSI/ASCII color preview or save a quick PNG under /tmp/viewinline_preview.png.
|
|
37
|
+
|
|
38
|
+
This mode works on terminals with **ANSI color support** and may not display correctly on others.
|
|
39
|
+
For compatible terminals, `viewinline` renders images in a very coarse resolution. This feature is experimental.
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
This project is released under the MIT License © 2025 Keiko Nomura
|
|
43
|
+
If you find this tool useful, please consider supporting or acknowledging the author.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "viewinline"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Quick look geospatial viewer for iTerm2 and ANSI compatible terminals"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Keiko Nomura" }
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.9"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"numpy",
|
|
17
|
+
"pillow",
|
|
18
|
+
"rasterio",
|
|
19
|
+
"geopandas",
|
|
20
|
+
"matplotlib",
|
|
21
|
+
"pyogrio"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
viewinline = "viewinline.viewinline:main"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/viewinline"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/nkeikon/viewinline"
|
|
32
|
+
Repository = "https://github.com/nkeikon/viewinline"
|
|
33
|
+
Issues = "https://github.com/nkeikon/viewinline/issues"
|
|
34
|
+
|
|
File without changes
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
viewinline — quick-look geospatial viewer for iTerm2 / ANSI/ASCII preview.
|
|
4
|
+
|
|
5
|
+
Supports:
|
|
6
|
+
• Rasters (.tif, .tiff)
|
|
7
|
+
• Vectors (.shp, .geojson, .gpkg)
|
|
8
|
+
• ANSI color preview in text-only terminals (half-block resolution)
|
|
9
|
+
|
|
10
|
+
Notes:
|
|
11
|
+
- iTerm2 inline images require ITERM_SESSION_ID.
|
|
12
|
+
- In HPC/text-only shells, switches to ANSI color preview.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import sys, os, base64, shutil, argparse
|
|
16
|
+
from io import BytesIO
|
|
17
|
+
import numpy as np
|
|
18
|
+
from PIL import Image, ImageOps
|
|
19
|
+
from matplotlib import colormaps
|
|
20
|
+
import warnings
|
|
21
|
+
|
|
22
|
+
warnings.filterwarnings("ignore", message="More than one layer found", category=UserWarning)
|
|
23
|
+
|
|
24
|
+
AVAILABLE_COLORMAPS = [
|
|
25
|
+
"viridis", "inferno", "magma", "plasma",
|
|
26
|
+
"cividis", "terrain", "RdYlGn", "coolwarm",
|
|
27
|
+
"Spectral", "cubehelix", "tab10", "turbo"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------
|
|
31
|
+
# Display utilities
|
|
32
|
+
# ---------------------------------------------------------------------
|
|
33
|
+
def show_inline_image(image_array: np.ndarray) -> None:
|
|
34
|
+
"""Display a numpy RGB image inline in iTerm2."""
|
|
35
|
+
try:
|
|
36
|
+
buffer = BytesIO()
|
|
37
|
+
Image.fromarray(image_array).save(buffer, format="PNG")
|
|
38
|
+
encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
|
39
|
+
# sys.stdout.write(f"\033]1337;File=inline=1:{encoded}\a\n")
|
|
40
|
+
sys.stdout.write(f"\033]1337;File=inline=1;width=33%:{encoded}\a\n") #1/3 of window size
|
|
41
|
+
sys.stdout.flush()
|
|
42
|
+
except Exception as e:
|
|
43
|
+
print(f"[WARN] Inline display failed ({e})")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def show_ansi_preview(image_array: np.ndarray, width: int = 120, height: int = 60) -> None:
|
|
47
|
+
"""ANSI preview using half-block characters (▀)."""
|
|
48
|
+
try:
|
|
49
|
+
img = Image.fromarray(image_array).resize((width, height * 2), Image.BILINEAR)
|
|
50
|
+
arr = np.array(img)
|
|
51
|
+
# for y in range(0, arr.shape[0] - 1, 2):
|
|
52
|
+
# top = arr[y]
|
|
53
|
+
# bottom = arr[y + 1]
|
|
54
|
+
# line = []
|
|
55
|
+
# for (r1, g1, b1), (r2, g2, b2) in zip(top, bottom):
|
|
56
|
+
# line.append(f"\033[38;2;{r1};{g1};{b1}m\033[48;2;{r2};{g2};{b2}m▀")
|
|
57
|
+
# print("".join(line) + "\033[0m")
|
|
58
|
+
for y in range(0, arr.shape[0] - 1, 2):
|
|
59
|
+
top, bottom = arr[y], arr[y + 1]
|
|
60
|
+
line = "".join(
|
|
61
|
+
f"\033[38;2;{r1};{g1};{b1}m\033[48;2;{r2};{g2};{b2}m▀"
|
|
62
|
+
for (r1, g1, b1), (r2, g2, b2) in zip(top, bottom)
|
|
63
|
+
)
|
|
64
|
+
print(f"{line}\033[0m")
|
|
65
|
+
|
|
66
|
+
# sys.stdout.flush()
|
|
67
|
+
print("[OK] ANSI preview displayed.")
|
|
68
|
+
except Exception as e:
|
|
69
|
+
print(f"[WARN] ANSI preview failed ({e}); saving file...")
|
|
70
|
+
save_image_to_tmp(image_array)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def save_image_to_tmp(image_array: np.ndarray) -> str:
|
|
74
|
+
"""Save to /tmp and print file path."""
|
|
75
|
+
outfile = "/tmp/viewinline_preview.png"
|
|
76
|
+
Image.fromarray(image_array).save(outfile)
|
|
77
|
+
print(f"[WARN] Inline not supported — saved preview to {outfile}")
|
|
78
|
+
return outfile
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resize_to_terminal(img: np.ndarray) -> tuple[np.ndarray, float]:
|
|
82
|
+
"""Resize image to fit terminal window (approx 8x16 pixel cells)."""
|
|
83
|
+
cols, rows = shutil.get_terminal_size((100, 40))
|
|
84
|
+
max_w = cols * 8
|
|
85
|
+
max_h = rows * 16
|
|
86
|
+
h, w = img.shape[:2]
|
|
87
|
+
scale = min(max_w / w, max_h / h, 1.0)
|
|
88
|
+
new_w, new_h = max(1, int(w * scale)), max(1, int(h * scale))
|
|
89
|
+
pil_img = Image.fromarray(img)
|
|
90
|
+
pil_img = ImageOps.contain(pil_img, (new_w, new_h))
|
|
91
|
+
return np.array(pil_img), scale
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------
|
|
95
|
+
# Raster handling
|
|
96
|
+
# ---------------------------------------------------------------------
|
|
97
|
+
def normalize_to_uint8(band: np.ndarray) -> np.ndarray:
|
|
98
|
+
band = band.astype(float)
|
|
99
|
+
valid = np.isfinite(band)
|
|
100
|
+
if not np.any(valid):
|
|
101
|
+
return np.zeros_like(band, dtype=np.uint8)
|
|
102
|
+
mn, mx = np.percentile(
|
|
103
|
+
band[valid] if band[valid].size < 1_000_000 else np.random.choice(band[valid], 1_000_000, replace=False),
|
|
104
|
+
(2, 98)
|
|
105
|
+
)
|
|
106
|
+
if mx <= mn:
|
|
107
|
+
return np.zeros_like(band, dtype=np.uint8)
|
|
108
|
+
band = np.clip((band - mn) / (mx - mn), 0, 1)
|
|
109
|
+
band[~valid] = 0
|
|
110
|
+
return (band * 255).astype(np.uint8)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def render_raster(paths: list[str], args) -> None:
|
|
114
|
+
try:
|
|
115
|
+
import rasterio
|
|
116
|
+
import rasterio.enums
|
|
117
|
+
except ImportError:
|
|
118
|
+
print("[ERROR] rasterio not installed. Please install with `pip install rasterio`.")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
if len(paths) == 1:
|
|
123
|
+
with rasterio.open(paths[0]) as ds:
|
|
124
|
+
H, W = ds.height, ds.width
|
|
125
|
+
print(f"[DATA] Raster loaded: {os.path.basename(paths[0])} ({W}×{H})")
|
|
126
|
+
|
|
127
|
+
max_dim = 2000
|
|
128
|
+
if max(H, W) > max_dim:
|
|
129
|
+
scale = max_dim / max(H, W)
|
|
130
|
+
out_h, out_w = int(H * scale), int(W * scale)
|
|
131
|
+
data = ds.read(
|
|
132
|
+
out_shape=(ds.count, out_h, out_w),
|
|
133
|
+
resampling=rasterio.enums.Resampling.bilinear
|
|
134
|
+
)
|
|
135
|
+
print(f"[PROC] Downsampled → {out_w}×{out_h}px (scale={scale:.3f})")
|
|
136
|
+
else:
|
|
137
|
+
data = ds.read()
|
|
138
|
+
|
|
139
|
+
band_idx = max(0, min(args.band - 1, data.shape[0] - 1))
|
|
140
|
+
band = normalize_to_uint8(data[band_idx])
|
|
141
|
+
print(f"[INFO] Displaying band {band_idx + 1} of {data.shape[0]}")
|
|
142
|
+
|
|
143
|
+
# Grayscale default
|
|
144
|
+
if args.colormap:
|
|
145
|
+
cmap_name = args.colormap or "terrain"
|
|
146
|
+
cmap = colormaps[cmap_name]
|
|
147
|
+
colored = cmap(band / 255.0)
|
|
148
|
+
img = (colored[:, :, :3] * 255).astype(np.uint8)
|
|
149
|
+
print(f"[INFO] Applying colormap: {cmap_name}")
|
|
150
|
+
else:
|
|
151
|
+
img = np.stack([band] * 3, axis=-1)
|
|
152
|
+
print("[INFO] Displaying in grayscale (no colormap applied)")
|
|
153
|
+
|
|
154
|
+
elif len(paths) == 3:
|
|
155
|
+
bands = []
|
|
156
|
+
for p in paths:
|
|
157
|
+
with rasterio.open(p) as ds:
|
|
158
|
+
bands.append(ds.read(1))
|
|
159
|
+
shapes = {b.shape for b in bands}
|
|
160
|
+
if len(shapes) != 1:
|
|
161
|
+
print("[ERROR] Raster sizes do not match.")
|
|
162
|
+
return
|
|
163
|
+
data = np.stack(bands, axis=0)
|
|
164
|
+
H, W = data.shape[1:]
|
|
165
|
+
print(f"[DATA] RGB raster stack loaded: {W}×{H}")
|
|
166
|
+
img = np.stack([normalize_to_uint8(b) for b in data], axis=-1)
|
|
167
|
+
print("[INFO] Displaying 3-band RGB composite")
|
|
168
|
+
|
|
169
|
+
else:
|
|
170
|
+
print("[ERROR] Provide one raster or exactly three rasters for RGB.")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Resize for terminal
|
|
174
|
+
H, W = img.shape[:2]
|
|
175
|
+
if args.display:
|
|
176
|
+
new_w, new_h = max(1, int(W * args.display)), max(1, int(H * args.display))
|
|
177
|
+
img = np.array(Image.fromarray(img).resize((new_w, new_h), Image.BILINEAR))
|
|
178
|
+
print(f"[VIEW] Manual resize ×{args.display:.2f} → {new_w}×{new_h}px")
|
|
179
|
+
else:
|
|
180
|
+
img, scale = resize_to_terminal(img)
|
|
181
|
+
print(f"[VIEW] Auto-fit display → {img.shape[1]}×{img.shape[0]}px (size={scale:.2f})")
|
|
182
|
+
|
|
183
|
+
show_image_auto(img, args)
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
print(f"[ERROR] Raster rendering failed: {e}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------
|
|
190
|
+
# Vector handling
|
|
191
|
+
# ---------------------------------------------------------------------
|
|
192
|
+
def render_vector(path, args):
|
|
193
|
+
try:
|
|
194
|
+
import geopandas as gpd
|
|
195
|
+
import matplotlib.pyplot as plt
|
|
196
|
+
from pyogrio import list_layers
|
|
197
|
+
except ImportError as e:
|
|
198
|
+
print("[ERROR] Missing dependency. Install with:")
|
|
199
|
+
print(" pip install geopandas matplotlib pyogrio")
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
# layers = list_layers(path)
|
|
204
|
+
if path.lower().endswith((".shp", ".geojson", ".json", ".parquet", ".geoparquet")):
|
|
205
|
+
# Common single-layer formats — skip list_layers() call
|
|
206
|
+
layers = [(os.path.splitext(os.path.basename(path))[0], None)]
|
|
207
|
+
else:
|
|
208
|
+
layers = list_layers(path)
|
|
209
|
+
if len(layers) > 1 and not getattr(args, "layer", None):
|
|
210
|
+
print(f"[INFO] Multiple layers found in '{os.path.basename(path)}':")
|
|
211
|
+
for i, lyr in enumerate(layers, 1):
|
|
212
|
+
name = lyr[0]
|
|
213
|
+
geom = lyr[1] if len(lyr) > 1 and lyr[1] else "Unknown"
|
|
214
|
+
print(f" {i}. {name} ({geom})")
|
|
215
|
+
first = layers[0][0]
|
|
216
|
+
print(f"[INFO] Defaulting to first layer: '{first}' (use --layer <name> to select another).")
|
|
217
|
+
args.layer = first
|
|
218
|
+
except Exception as e:
|
|
219
|
+
print(f"[WARN] Could not list layers: {e}")
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
gdf = gpd.read_file(path, layer=getattr(args, "layer", None))
|
|
223
|
+
print(f"[DATA] Vector loaded: {os.path.basename(path)} ({len(gdf)} features)")
|
|
224
|
+
except Exception as e:
|
|
225
|
+
print(f"[ERROR] Failed to read vector: {e}")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
# Detect numeric columns
|
|
229
|
+
num_cols = []
|
|
230
|
+
for c in gdf.columns:
|
|
231
|
+
if c == gdf.geometry.name:
|
|
232
|
+
continue
|
|
233
|
+
try:
|
|
234
|
+
if np.issubdtype(gdf[c].dtype, np.number):
|
|
235
|
+
num_cols.append(c)
|
|
236
|
+
except TypeError:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
if num_cols:
|
|
240
|
+
print("[INFO] Numeric columns detected:", ", ".join(num_cols))
|
|
241
|
+
if not args.color_by:
|
|
242
|
+
print("[INFO] Showing border-only view (use --color-by <column> to color by numeric values).")
|
|
243
|
+
else:
|
|
244
|
+
print("[INFO] Displaying boundaries only - no numeric columns detected")
|
|
245
|
+
|
|
246
|
+
# Figure setup (black background)
|
|
247
|
+
fig, ax = plt.subplots(figsize=(6, 6), dpi=150, facecolor="gray")
|
|
248
|
+
ax.set_facecolor("gray")
|
|
249
|
+
ax.set_axis_off()
|
|
250
|
+
|
|
251
|
+
# Determine colormap
|
|
252
|
+
column = args.color_by if args.color_by in gdf.columns else None
|
|
253
|
+
|
|
254
|
+
# Warn if user provided an invalid column
|
|
255
|
+
if args.color_by and args.color_by not in gdf.columns:
|
|
256
|
+
print(f"[WARN] Column '{args.color_by}' not found. Showing border-only view.")
|
|
257
|
+
column = None
|
|
258
|
+
|
|
259
|
+
if column and args.colormap is None:
|
|
260
|
+
args.colormap = "terrain"
|
|
261
|
+
print("[INFO] Applying default colormap: terrain")
|
|
262
|
+
|
|
263
|
+
cmap = colormaps.get(args.colormap) if args.colormap else None
|
|
264
|
+
|
|
265
|
+
# Plot
|
|
266
|
+
try:
|
|
267
|
+
if column and np.issubdtype(gdf[column].dtype, np.number):
|
|
268
|
+
vmin, vmax = np.percentile(gdf[column].dropna(), (2, 98))
|
|
269
|
+
print(f"[INFO] Coloring by '{column}' (range: {vmin:.2f}–{vmax:.2f})")
|
|
270
|
+
gdf.plot(ax=ax, column=column, cmap=cmap, vmin=vmin, vmax=vmax,
|
|
271
|
+
linewidth=0.3, edgecolor="black", zorder=1)
|
|
272
|
+
else:
|
|
273
|
+
gdf.plot(ax=ax, facecolor="none", edgecolor=args.edgecolor,
|
|
274
|
+
linewidth=0.7, zorder=1)
|
|
275
|
+
except Exception as e:
|
|
276
|
+
print(f"[WARN] Plotting failed ({e}) — fallback to border-only.")
|
|
277
|
+
gdf.plot(ax=ax, facecolor="none", edgecolor="gray", linewidth=0.5)
|
|
278
|
+
|
|
279
|
+
# Save to buffer (adaptive DPI)
|
|
280
|
+
render_dpi = 200 if "ITERM_SESSION_ID" in os.environ else 400
|
|
281
|
+
buf = BytesIO()
|
|
282
|
+
fig.savefig(buf, format="png", dpi=render_dpi,
|
|
283
|
+
bbox_inches="tight", pad_inches=0.05,
|
|
284
|
+
facecolor=fig.get_facecolor())
|
|
285
|
+
plt.close(fig)
|
|
286
|
+
buf.seek(0)
|
|
287
|
+
img = np.array(Image.open(buf).convert("RGB"))
|
|
288
|
+
# print(f"[PROC] Rendered vector") # (DPI={render_dpi})
|
|
289
|
+
|
|
290
|
+
img, scale = resize_to_terminal(img)
|
|
291
|
+
print(f"[VIEW] Auto-fit display → {img.shape[1]}×{img.shape[0]}px (size={scale:.2f})")
|
|
292
|
+
|
|
293
|
+
show_image_auto(img, args)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------
|
|
297
|
+
# Smart display selector
|
|
298
|
+
# ---------------------------------------------------------------------
|
|
299
|
+
def show_image_auto(img: np.ndarray, args) -> None:
|
|
300
|
+
"""Automatically pick best display method."""
|
|
301
|
+
if "ITERM_SESSION_ID" in os.environ:
|
|
302
|
+
try:
|
|
303
|
+
show_inline_image(img)
|
|
304
|
+
print("[OK] Inline render complete.")
|
|
305
|
+
return
|
|
306
|
+
except Exception:
|
|
307
|
+
print("[WARN] Inline display failed; trying ANSI fallback...")
|
|
308
|
+
|
|
309
|
+
if sys.stdout.isatty(): #and os.getenv("TERM"):
|
|
310
|
+
try:
|
|
311
|
+
w, h = (120, 60)
|
|
312
|
+
mode = "auto"
|
|
313
|
+
if getattr(args, "ansi_size", None):
|
|
314
|
+
try:
|
|
315
|
+
w, h = map(int, args.ansi_size.lower().split("x"))
|
|
316
|
+
mode = f"ansi-size {w}x{h}"
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
319
|
+
elif getattr(args, "display", None):
|
|
320
|
+
w = max(1, int(w * args.display))
|
|
321
|
+
h = max(1, int(h * args.display))
|
|
322
|
+
mode = f"display size {args.display:.2f}"
|
|
323
|
+
print(f"[VIEW] ANSI display → {w}×{h} grid ({mode})")
|
|
324
|
+
show_ansi_preview(img, width=w, height=h)
|
|
325
|
+
return
|
|
326
|
+
except Exception as e:
|
|
327
|
+
print(f"[WARN] ANSI preview failed ({e}); saving file...")
|
|
328
|
+
|
|
329
|
+
save_image_to_tmp(img)
|
|
330
|
+
|
|
331
|
+
# ---------------------------------------------------------------------
|
|
332
|
+
# Smart help formatter to hide None defaults
|
|
333
|
+
# ---------------------------------------------------------------------
|
|
334
|
+
import argparse
|
|
335
|
+
|
|
336
|
+
class SmartDefaults(argparse.ArgumentDefaultsHelpFormatter):
|
|
337
|
+
"""Show defaults only when meaningful (not None or SUPPRESS)."""
|
|
338
|
+
def _get_help_string(self, action):
|
|
339
|
+
if action.help and "%(default)" in action.help:
|
|
340
|
+
return action.help
|
|
341
|
+
if action.default is None or action.default is argparse.SUPPRESS:
|
|
342
|
+
return action.help
|
|
343
|
+
return super()._get_help_string(action)
|
|
344
|
+
|
|
345
|
+
# ---------------------------------------------------------------------
|
|
346
|
+
# Dispatcher
|
|
347
|
+
# ---------------------------------------------------------------------
|
|
348
|
+
def main() -> None:
|
|
349
|
+
parser = argparse.ArgumentParser(
|
|
350
|
+
prog="viewinline",
|
|
351
|
+
description=(
|
|
352
|
+
"Quick-look geospatial viewer for iTerm2 and headless environments.\n\n"
|
|
353
|
+
"Supports rasters (.tif, .tiff) and vectors (.shp, .geojson, .gpkg).\n"
|
|
354
|
+
"Displays inline in iTerm2 if available, otherwise as ANSI color preview."
|
|
355
|
+
),
|
|
356
|
+
formatter_class=SmartDefaults
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# File input
|
|
360
|
+
parser.add_argument("paths", nargs="+",
|
|
361
|
+
help="Path to raster(s) or vector file. Provide 1 file or exactly 3 rasters for RGB (R G B).")
|
|
362
|
+
|
|
363
|
+
# Display options
|
|
364
|
+
parser.add_argument("--display", type=float, default=None,
|
|
365
|
+
help="Resize only the displayed image (0.5=smaller, 2=bigger). Default: auto-fit to terminal.")
|
|
366
|
+
parser.add_argument("--ansi-size", type=str, default=None,
|
|
367
|
+
help="ANSI fallback resolution. Try 180x90 or 200x100.")
|
|
368
|
+
|
|
369
|
+
# Raster options
|
|
370
|
+
parser.add_argument("--band", type=int, default=1,
|
|
371
|
+
help="Band number to display (single raster case).")
|
|
372
|
+
parser.add_argument("--colormap", nargs="?", const="terrain",
|
|
373
|
+
choices=AVAILABLE_COLORMAPS, default=None,
|
|
374
|
+
help="Apply colormap to single-band rasters or vector coloring. Flag without value → 'terrain'.")
|
|
375
|
+
|
|
376
|
+
# Vector options
|
|
377
|
+
parser.add_argument("--color-by", type=str, default=None,
|
|
378
|
+
help="Numeric column to color vector features by (optional).")
|
|
379
|
+
parser.add_argument("--edgecolor", type=str, default="#F6FF00",
|
|
380
|
+
help="Edge color for vector outlines (hex or named color).")
|
|
381
|
+
parser.add_argument("--layer", type=str, default=None,
|
|
382
|
+
help="Layer name for GeoPackage or multi-layer files.")
|
|
383
|
+
|
|
384
|
+
args = parser.parse_args()
|
|
385
|
+
|
|
386
|
+
# --- Basic argument sanity check ---
|
|
387
|
+
for bad in ("color-by", "edgecolor", "colormap", "band", "display"):
|
|
388
|
+
for a in args.paths:
|
|
389
|
+
if a == bad:
|
|
390
|
+
print(f"[ERROR] Missing '--' before '{bad}'.")
|
|
391
|
+
print(" Example: --color-by column_name")
|
|
392
|
+
sys.exit(1)
|
|
393
|
+
|
|
394
|
+
paths = args.paths
|
|
395
|
+
|
|
396
|
+
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
|
397
|
+
|
|
398
|
+
if "iterm" not in term_program:
|
|
399
|
+
print("[WARN] iTerm2 not detected. For better inline image display, use iTerm2 (mac).")
|
|
400
|
+
print("[INFO] Switching to ANSI/ASCII preview mode. This may not display correctly on all terminals.")
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
ans = input("Continue with ANSI/ASCII preview? [y/N]: ").strip().lower()
|
|
404
|
+
except EOFError:
|
|
405
|
+
ans = "n"
|
|
406
|
+
|
|
407
|
+
if ans not in ("y", "yes"):
|
|
408
|
+
print("Cancelled by user.")
|
|
409
|
+
sys.exit(0)
|
|
410
|
+
|
|
411
|
+
raster_exts = (".tif", ".tiff")
|
|
412
|
+
vector_exts = (".shp", ".geojson", ".json", ".gpkg")
|
|
413
|
+
|
|
414
|
+
if len(paths) == 1:
|
|
415
|
+
p = paths[0].lower()
|
|
416
|
+
if p.endswith(raster_exts):
|
|
417
|
+
render_raster(paths, args)
|
|
418
|
+
elif p.endswith(vector_exts):
|
|
419
|
+
render_vector(paths[0], args)
|
|
420
|
+
else:
|
|
421
|
+
print("[ERROR] Unsupported file type.")
|
|
422
|
+
sys.exit(1)
|
|
423
|
+
elif len(paths) == 3 and all(p.lower().endswith(raster_exts) for p in paths):
|
|
424
|
+
render_raster(paths, args)
|
|
425
|
+
else:
|
|
426
|
+
print("[ERROR] Provide one raster/vector file or three rasters for RGB.")
|
|
427
|
+
sys.exit(1)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
if __name__ == "__main__":
|
|
431
|
+
main()
|