viewinline 0.2.3__tar.gz → 0.3.1__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.
- {viewinline-0.2.3 → viewinline-0.3.1}/PKG-INFO +15 -5
- {viewinline-0.2.3 → viewinline-0.3.1}/README.md +14 -4
- {viewinline-0.2.3 → viewinline-0.3.1}/pyproject.toml +1 -1
- {viewinline-0.2.3 → viewinline-0.3.1}/src/viewinline/viewinline.py +345 -34
- {viewinline-0.2.3 → viewinline-0.3.1}/.github/FUNDING.yml +0 -0
- {viewinline-0.2.3 → viewinline-0.3.1}/.gitignore +0 -0
- {viewinline-0.2.3 → viewinline-0.3.1}/LICENSE +0 -0
- {viewinline-0.2.3 → viewinline-0.3.1}/src/viewinline/__init__.py +0 -0
- {viewinline-0.2.3 → viewinline-0.3.1}/viewinline_gif1.gif +0 -0
- {viewinline-0.2.3 → viewinline-0.3.1}/viewinline_gif2.gif +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: viewinline
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Quick look geospatial viewer for the terminal, with inline image previews
|
|
5
5
|
Project-URL: Homepage, https://github.com/nkeikon/viewinline
|
|
6
6
|
Project-URL: Repository, https://github.com/nkeikon/viewinline
|
|
@@ -37,7 +37,7 @@ Description-Content-Type: text/markdown
|
|
|
37
37
|
[](https://pypi.org/project/viewinline/)
|
|
38
38
|
|
|
39
39
|
**Quick-look geospatial viewer for compatible terminals.**
|
|
40
|
-
Displays rasters, vectors, and tabular data directly in the terminal with no GUI
|
|
40
|
+
Displays rasters, vectors, and tabular data directly in the terminal with no GUI.
|
|
41
41
|
|
|
42
42
|
<p align="center">
|
|
43
43
|
<a href="viewinline_gif1.gif"><img src="viewinline_gif1.gif" width="49%"></a>
|
|
@@ -65,13 +65,17 @@ pip install viewinline
|
|
|
65
65
|
viewinline path/to/file.tif
|
|
66
66
|
viewinline R.tif G.tif B.tif # RGB composite (also works with --rgbfiles)
|
|
67
67
|
viewinline path/to/multiband.tif --rgb 3 2 1
|
|
68
|
-
viewinline path/to/folder --gallery 4x3 # show image gallery (e.g. 4x3 grid)
|
|
68
|
+
viewinline path/to/folder --gallery 4x3 # show image gallery of all files in the folder (e.g. 4x3 grid)
|
|
69
|
+
viewinline path/to/hyperspectral.tif --bands 10-50 # show image gallery of selected bands (also works with --bands 11,15,30,45; --gallery 5x5)
|
|
69
70
|
|
|
70
71
|
# NetCDF and HDF
|
|
71
72
|
viewinline file.nc # list variables
|
|
72
73
|
viewinline file.nc --subset 2 # display variable 2
|
|
73
74
|
viewinline file.nc --subset 1 --band 10 # variable 1, timestep 10 --band or --timestep
|
|
74
75
|
viewinline temp.nc --subset 1 --colormap plasma --vmin 273 --vmax 310
|
|
76
|
+
viewinline hyperspectral.nc --subset 1 --reduce NumberOfScanlines # override auto-detected axis
|
|
77
|
+
viewinline hyperspectral.nc --subset 22 --band 50 # show image gallery of selected bands
|
|
78
|
+
viewinline hyperspectral.nc --subset 22 --bands 10-54 --gallery 5x11
|
|
75
79
|
|
|
76
80
|
# Vectors
|
|
77
81
|
viewinline path/to/vector.geojson
|
|
@@ -113,6 +117,7 @@ Install chafa once (it's a system binary, available across all conda/virtualenv
|
|
|
113
117
|
brew install chafa # macOS
|
|
114
118
|
sudo apt install chafa # Debian/Ubuntu
|
|
115
119
|
sudo dnf install chafa # Fedora
|
|
120
|
+
scoop install chafa # Windows
|
|
116
121
|
```
|
|
117
122
|
Without chafa, terminals outside the native list above show an info message instead of an image.
|
|
118
123
|
You can also force the chafa path on any terminal by setting `INLINE_VIEWER_ENGINE=chafa`.
|
|
@@ -166,10 +171,13 @@ You can also force the chafa path on any terminal by setting `INLINE_VIEWER_ENGI
|
|
|
166
171
|
|
|
167
172
|
**Gallery view**
|
|
168
173
|
- Display all images in a folder with `--gallery 4x4`
|
|
174
|
+
- Display selected bands of a single raster as a grid with `--bands 101-120` or `--bands 11,12,45,55`. Works with GeoTIFF and NetCDF files.
|
|
169
175
|
|
|
170
176
|
**NetCDF/HDF notes:**
|
|
171
177
|
- viewinline lists only variables that can be displayed as 2D or 3D arrays
|
|
172
|
-
-
|
|
178
|
+
- 3D variables with time or known spatial dimensions are auto-handled (slices along the non-spatial axis)
|
|
179
|
+
- For 3D variables with non-standard dimensions (e.g., hyperspectral cubes like PICARD), viewinline auto-detects the band axis by smallest dimension. Use `--reduce DIM_NAME` to override.
|
|
180
|
+
- Variables with 4+ dimensions are not supported
|
|
173
181
|
- For a complete variable list, use `ncdump -h file.nc` or `viewtif`
|
|
174
182
|
|
|
175
183
|
## Dependencies
|
|
@@ -198,7 +206,7 @@ You can also force the chafa path on any terminal by setting `INLINE_VIEWER_ENGI
|
|
|
198
206
|
|
|
199
207
|
**Note on HDF support:**
|
|
200
208
|
- **HDF5** (.h5, .hdf5): Supported via rasterio if GDAL has HDF5 support (most installations)
|
|
201
|
-
- **HDF4** (.hdf): Requires GDAL compiled with HDF4 support (
|
|
209
|
+
- **HDF4** (.hdf): Requires GDAL compiled with HDF4 support (the legacy format used by MODIS and older NASA products)
|
|
202
210
|
- **NetCDF** (.nc): Supported via rasterio (uses GDAL's NetCDF driver)
|
|
203
211
|
|
|
204
212
|
## Available options
|
|
@@ -208,8 +216,10 @@ General:
|
|
|
208
216
|
|
|
209
217
|
Raster:
|
|
210
218
|
--band BAND Band number to display (single raster), or slice number for NetCDF. (default: 1)
|
|
219
|
+
--bands BANDS Display multiple bands as a grid. Accepts ranges (30-40), lists (3,4,5), or mixed (1,5,10-15).
|
|
211
220
|
--timestep INTEGER Alias for --band when working with NetCDF files.
|
|
212
221
|
--subset INTEGER Variable index for NetCDF/HDF files (e.g., --subset 1).
|
|
222
|
+
--reduce DIM_NAME For 3D NetCDF variables, specify which dimension to use as the band/slider axis. Auto-detected if omitted.
|
|
213
223
|
--colormap Apply colormap to single-band rasters. Flag without the color scheme → 'terrain'.
|
|
214
224
|
--rgb R G B Three band numbers for RGB display (e.g., --rgb 4 3 2). Overrides default 1 2 3.
|
|
215
225
|
--rgbfiles R G B Three single-band rasters for RGB composite. Can also provide as positional arguments.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://pypi.org/project/viewinline/)
|
|
5
5
|
|
|
6
6
|
**Quick-look geospatial viewer for compatible terminals.**
|
|
7
|
-
Displays rasters, vectors, and tabular data directly in the terminal with no GUI
|
|
7
|
+
Displays rasters, vectors, and tabular data directly in the terminal with no GUI.
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="viewinline_gif1.gif"><img src="viewinline_gif1.gif" width="49%"></a>
|
|
@@ -32,13 +32,17 @@ pip install viewinline
|
|
|
32
32
|
viewinline path/to/file.tif
|
|
33
33
|
viewinline R.tif G.tif B.tif # RGB composite (also works with --rgbfiles)
|
|
34
34
|
viewinline path/to/multiband.tif --rgb 3 2 1
|
|
35
|
-
viewinline path/to/folder --gallery 4x3 # show image gallery (e.g. 4x3 grid)
|
|
35
|
+
viewinline path/to/folder --gallery 4x3 # show image gallery of all files in the folder (e.g. 4x3 grid)
|
|
36
|
+
viewinline path/to/hyperspectral.tif --bands 10-50 # show image gallery of selected bands (also works with --bands 11,15,30,45; --gallery 5x5)
|
|
36
37
|
|
|
37
38
|
# NetCDF and HDF
|
|
38
39
|
viewinline file.nc # list variables
|
|
39
40
|
viewinline file.nc --subset 2 # display variable 2
|
|
40
41
|
viewinline file.nc --subset 1 --band 10 # variable 1, timestep 10 --band or --timestep
|
|
41
42
|
viewinline temp.nc --subset 1 --colormap plasma --vmin 273 --vmax 310
|
|
43
|
+
viewinline hyperspectral.nc --subset 1 --reduce NumberOfScanlines # override auto-detected axis
|
|
44
|
+
viewinline hyperspectral.nc --subset 22 --band 50 # show image gallery of selected bands
|
|
45
|
+
viewinline hyperspectral.nc --subset 22 --bands 10-54 --gallery 5x11
|
|
42
46
|
|
|
43
47
|
# Vectors
|
|
44
48
|
viewinline path/to/vector.geojson
|
|
@@ -80,6 +84,7 @@ Install chafa once (it's a system binary, available across all conda/virtualenv
|
|
|
80
84
|
brew install chafa # macOS
|
|
81
85
|
sudo apt install chafa # Debian/Ubuntu
|
|
82
86
|
sudo dnf install chafa # Fedora
|
|
87
|
+
scoop install chafa # Windows
|
|
83
88
|
```
|
|
84
89
|
Without chafa, terminals outside the native list above show an info message instead of an image.
|
|
85
90
|
You can also force the chafa path on any terminal by setting `INLINE_VIEWER_ENGINE=chafa`.
|
|
@@ -133,10 +138,13 @@ You can also force the chafa path on any terminal by setting `INLINE_VIEWER_ENGI
|
|
|
133
138
|
|
|
134
139
|
**Gallery view**
|
|
135
140
|
- Display all images in a folder with `--gallery 4x4`
|
|
141
|
+
- Display selected bands of a single raster as a grid with `--bands 101-120` or `--bands 11,12,45,55`. Works with GeoTIFF and NetCDF files.
|
|
136
142
|
|
|
137
143
|
**NetCDF/HDF notes:**
|
|
138
144
|
- viewinline lists only variables that can be displayed as 2D or 3D arrays
|
|
139
|
-
-
|
|
145
|
+
- 3D variables with time or known spatial dimensions are auto-handled (slices along the non-spatial axis)
|
|
146
|
+
- For 3D variables with non-standard dimensions (e.g., hyperspectral cubes like PICARD), viewinline auto-detects the band axis by smallest dimension. Use `--reduce DIM_NAME` to override.
|
|
147
|
+
- Variables with 4+ dimensions are not supported
|
|
140
148
|
- For a complete variable list, use `ncdump -h file.nc` or `viewtif`
|
|
141
149
|
|
|
142
150
|
## Dependencies
|
|
@@ -165,7 +173,7 @@ You can also force the chafa path on any terminal by setting `INLINE_VIEWER_ENGI
|
|
|
165
173
|
|
|
166
174
|
**Note on HDF support:**
|
|
167
175
|
- **HDF5** (.h5, .hdf5): Supported via rasterio if GDAL has HDF5 support (most installations)
|
|
168
|
-
- **HDF4** (.hdf): Requires GDAL compiled with HDF4 support (
|
|
176
|
+
- **HDF4** (.hdf): Requires GDAL compiled with HDF4 support (the legacy format used by MODIS and older NASA products)
|
|
169
177
|
- **NetCDF** (.nc): Supported via rasterio (uses GDAL's NetCDF driver)
|
|
170
178
|
|
|
171
179
|
## Available options
|
|
@@ -175,8 +183,10 @@ General:
|
|
|
175
183
|
|
|
176
184
|
Raster:
|
|
177
185
|
--band BAND Band number to display (single raster), or slice number for NetCDF. (default: 1)
|
|
186
|
+
--bands BANDS Display multiple bands as a grid. Accepts ranges (30-40), lists (3,4,5), or mixed (1,5,10-15).
|
|
178
187
|
--timestep INTEGER Alias for --band when working with NetCDF files.
|
|
179
188
|
--subset INTEGER Variable index for NetCDF/HDF files (e.g., --subset 1).
|
|
189
|
+
--reduce DIM_NAME For 3D NetCDF variables, specify which dimension to use as the band/slider axis. Auto-detected if omitted.
|
|
180
190
|
--colormap Apply colormap to single-band rasters. Flag without the color scheme → 'terrain'.
|
|
181
191
|
--rgb R G B Three band numbers for RGB display (e.g., --rgb 4 3 2). Overrides default 1 2 3.
|
|
182
192
|
--rgbfiles R G B Three single-band rasters for RGB composite. Can also provide as positional arguments.
|
|
@@ -28,12 +28,19 @@ from matplotlib import colormaps
|
|
|
28
28
|
import matplotlib as mpl
|
|
29
29
|
import subprocess
|
|
30
30
|
|
|
31
|
+
try:
|
|
32
|
+
import netCDF4
|
|
33
|
+
HAS_NETCDF4 = True
|
|
34
|
+
except ImportError:
|
|
35
|
+
HAS_NETCDF4 = False
|
|
36
|
+
|
|
31
37
|
import warnings
|
|
32
38
|
|
|
33
39
|
warnings.filterwarnings("ignore", message="More than one layer found", category=UserWarning)
|
|
34
40
|
warnings.filterwarnings("ignore", message="Dataset has no geotransform", category=UserWarning)
|
|
41
|
+
warnings.filterwarnings("ignore", message="invalid scale_factor or add_offset attribute", category=UserWarning)
|
|
35
42
|
|
|
36
|
-
__version__ = "0.
|
|
43
|
+
__version__ = "0.3.1"
|
|
37
44
|
|
|
38
45
|
AVAILABLE_COLORMAPS = [
|
|
39
46
|
"viridis", "inferno", "magma", "plasma",
|
|
@@ -184,14 +191,7 @@ def show_inline_image(image_array: np.ndarray, display_scale = None, is_vector:
|
|
|
184
191
|
|
|
185
192
|
if _TERMINAL_SUPPORTS_IMAGES:
|
|
186
193
|
sys.stdout.write(f"\033]1337;File=inline=1;width={width_pct}%:{encoded}\a\n")
|
|
187
|
-
else:
|
|
188
|
-
# if is_chafa_available():
|
|
189
|
-
# chafa_output = subprocess.check_output(
|
|
190
|
-
# ["chafa", "-"],
|
|
191
|
-
# input=image_bytes
|
|
192
|
-
# ).decode()
|
|
193
|
-
|
|
194
|
-
# sys.stdout.write(f"\n{chafa_output}\a\n")
|
|
194
|
+
else:
|
|
195
195
|
if is_chafa_available():
|
|
196
196
|
# Inside tmux, force chafa to use block-art symbols instead of
|
|
197
197
|
# graphics protocols. Tmux mangles kitty graphics and sixel
|
|
@@ -227,9 +227,9 @@ def show_image_auto(img: np.ndarray, display_scale=None, is_vector: bool = False
|
|
|
227
227
|
try:
|
|
228
228
|
show_inline_image(img, display_scale, is_vector)
|
|
229
229
|
if _TERMINAL_SUPPORTS_IMAGES:
|
|
230
|
-
print("[VIEW]
|
|
230
|
+
print("[VIEW] Inline render complete")
|
|
231
231
|
elif is_chafa_available():
|
|
232
|
-
print("[VIEW]
|
|
232
|
+
print("[VIEW] Inline render complete via chafa")
|
|
233
233
|
# If neither path applies, show_inline_image already printed the info message
|
|
234
234
|
except Exception as e:
|
|
235
235
|
print(f"[ERROR] Failed to render image: {e}")
|
|
@@ -248,6 +248,24 @@ def resize_to_terminal(img: np.ndarray) -> tuple[np.ndarray, float]:
|
|
|
248
248
|
pil_img = ImageOps.contain(pil_img, (new_w, new_h))
|
|
249
249
|
return np.array(pil_img), scale
|
|
250
250
|
|
|
251
|
+
def parse_bands(s: str) -> list[int]:
|
|
252
|
+
"""Parse --bands argument: accepts ranges (30-40), lists (3,4,5), or mixed (1,3,10-15)."""
|
|
253
|
+
bands = []
|
|
254
|
+
for part in s.split(","):
|
|
255
|
+
part = part.strip()
|
|
256
|
+
if "-" in part:
|
|
257
|
+
try:
|
|
258
|
+
start, end = map(int, part.split("-", 1))
|
|
259
|
+
bands.extend(range(start, end + 1))
|
|
260
|
+
except ValueError:
|
|
261
|
+
print(f"[WARN] Could not parse band range: {part}")
|
|
262
|
+
else:
|
|
263
|
+
try:
|
|
264
|
+
bands.append(int(part))
|
|
265
|
+
except ValueError:
|
|
266
|
+
print(f"[WARN] Could not parse band: {part}")
|
|
267
|
+
return sorted(set(bands))
|
|
268
|
+
|
|
251
269
|
# ---------------------------------------------------------------------
|
|
252
270
|
# CSV handling
|
|
253
271
|
# ---------------------------------------------------------------------
|
|
@@ -262,7 +280,6 @@ def load_csv_to_df(path: str) -> pd.DataFrame:
|
|
|
262
280
|
print(f"[ERROR] Failed to read CSV: {e}")
|
|
263
281
|
return pd.DataFrame()
|
|
264
282
|
|
|
265
|
-
|
|
266
283
|
# =============================================================
|
|
267
284
|
# Preview
|
|
268
285
|
# =============================================================
|
|
@@ -643,6 +660,196 @@ def render_simple_image(filepath: str, args) -> None:
|
|
|
643
660
|
except Exception as e:
|
|
644
661
|
print(f"[ERROR] Failed to load image: {e}")
|
|
645
662
|
|
|
663
|
+
def render_netcdf_via_netcdf4(path, args):
|
|
664
|
+
"""Read a NetCDF file via netCDF4 (bypassing GDAL). Handles hierarchical
|
|
665
|
+
groups and hyperspectral cubes where GDAL aborts or interprets axes wrong.
|
|
666
|
+
"""
|
|
667
|
+
if not HAS_NETCDF4:
|
|
668
|
+
print("[ERROR] netCDF4 not installed. Install with:")
|
|
669
|
+
print(" pip install netCDF4")
|
|
670
|
+
print(" or: pip install viewinline[netcdf]")
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
nc = netCDF4.Dataset(path)
|
|
675
|
+
except Exception as e:
|
|
676
|
+
print(f"[ERROR] Could not open NetCDF file: {e}")
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
# Recursively collect (path, variable) pairs across all groups
|
|
680
|
+
def collect_vars(group, prefix=""):
|
|
681
|
+
out = []
|
|
682
|
+
for name, var in group.variables.items():
|
|
683
|
+
full_name = f"{prefix}{name}"
|
|
684
|
+
out.append((full_name, var))
|
|
685
|
+
for sub_name, sub in group.groups.items():
|
|
686
|
+
out.extend(collect_vars(sub, f"{prefix}{sub_name}/"))
|
|
687
|
+
return out
|
|
688
|
+
|
|
689
|
+
all_vars = collect_vars(nc)
|
|
690
|
+
|
|
691
|
+
if not all_vars:
|
|
692
|
+
print("[ERROR] No variables found in file.")
|
|
693
|
+
nc.close()
|
|
694
|
+
return
|
|
695
|
+
|
|
696
|
+
# If no --subset, list all variables and exit
|
|
697
|
+
if not args.subset:
|
|
698
|
+
print(f"Found {len(all_vars)} variables in {os.path.basename(path)}:")
|
|
699
|
+
for i, (name, var) in enumerate(all_vars, 1):
|
|
700
|
+
shape_str = "x".join(str(s) for s in var.shape)
|
|
701
|
+
print(f" [{i}] {name} ({shape_str}, {var.dtype})")
|
|
702
|
+
print(f"\nUse --subset <N> to display a specific variable.")
|
|
703
|
+
nc.close()
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
# Validate --subset
|
|
707
|
+
if args.subset < 1 or args.subset > len(all_vars):
|
|
708
|
+
print(f"[ERROR] --subset must be between 1 and {len(all_vars)}")
|
|
709
|
+
nc.close()
|
|
710
|
+
return
|
|
711
|
+
|
|
712
|
+
var_name, var = all_vars[args.subset - 1]
|
|
713
|
+
print(f"[INFO] Displaying variable {args.subset}: {var_name}")
|
|
714
|
+
print(f"[DATA] Shape: {var.shape} dtype: {var.dtype} dims: {var.dimensions}")
|
|
715
|
+
|
|
716
|
+
# Detect dimensionality and read the right slice
|
|
717
|
+
if var.ndim == 2:
|
|
718
|
+
data = np.asarray(var[:, :], dtype=np.float64)
|
|
719
|
+
slice_info = "2D variable"
|
|
720
|
+
|
|
721
|
+
elif var.ndim == 3:
|
|
722
|
+
spatial_dims = {'lat', 'lon', 'latitude', 'longitude', 'y', 'x'}
|
|
723
|
+
|
|
724
|
+
spectral_axis = None
|
|
725
|
+
|
|
726
|
+
# 1. User override via --reduce
|
|
727
|
+
if args.reduce_dim is not None:
|
|
728
|
+
if args.reduce_dim in var.dimensions:
|
|
729
|
+
spectral_axis = list(var.dimensions).index(args.reduce_dim)
|
|
730
|
+
print(f"[INFO] Using user-specified --reduce '{args.reduce_dim}'")
|
|
731
|
+
else:
|
|
732
|
+
print(f"[ERROR] --reduce '{args.reduce_dim}' is not a dimension of this variable.")
|
|
733
|
+
print(f"[INFO] Available dimensions: {list(var.dimensions)}")
|
|
734
|
+
nc.close()
|
|
735
|
+
return
|
|
736
|
+
|
|
737
|
+
# 2. Standard convention: reduce along the non-spatial dim
|
|
738
|
+
if spectral_axis is None:
|
|
739
|
+
has_standard_spatial = any(d in spatial_dims for d in var.dimensions)
|
|
740
|
+
if has_standard_spatial:
|
|
741
|
+
for i, dim_name in enumerate(var.dimensions):
|
|
742
|
+
if dim_name not in spatial_dims:
|
|
743
|
+
spectral_axis = i
|
|
744
|
+
break
|
|
745
|
+
|
|
746
|
+
# 3. Fallback heuristic: smallest dim is typically the band axis
|
|
747
|
+
if spectral_axis is None:
|
|
748
|
+
sizes = [(i, var.shape[i]) for i in range(3)]
|
|
749
|
+
spectral_axis = min(sizes, key=lambda x: x[1])[0]
|
|
750
|
+
print(f"[INFO] Non-standard dimensions detected: {list(var.dimensions)}")
|
|
751
|
+
print(f"[INFO] Reducing along '{var.dimensions[spectral_axis]}' (size {var.shape[spectral_axis]}, assumed band/spectral axis)")
|
|
752
|
+
print(f"[INFO] If this is not correct, use --reduce DIM_NAME to override.")
|
|
753
|
+
|
|
754
|
+
# Slice along chosen axis
|
|
755
|
+
band_count = var.shape[spectral_axis]
|
|
756
|
+
band_num = args.band if args.band is not None else 1
|
|
757
|
+
band_idx = max(0, min(band_num - 1, band_count - 1))
|
|
758
|
+
|
|
759
|
+
# BANDS GALLERY for NetCDF
|
|
760
|
+
if getattr(args, "bands", None):
|
|
761
|
+
band_list = parse_bands(args.bands)
|
|
762
|
+
print(f"[DEBUG] band_count={band_count}, band_list={band_list}")
|
|
763
|
+
valid_bands = [b for b in band_list if 1 <= b <= band_count]
|
|
764
|
+
if not valid_bands:
|
|
765
|
+
print(f"[ERROR] No valid bands. Variable has {band_count} bands along '{var.dimensions[spectral_axis]}'.")
|
|
766
|
+
nc.close()
|
|
767
|
+
return
|
|
768
|
+
slices = []
|
|
769
|
+
for b in valid_bands:
|
|
770
|
+
slicer = [slice(None)] * 3
|
|
771
|
+
slicer[spectral_axis] = b - 1
|
|
772
|
+
slices.append(np.asarray(var[tuple(slicer)], dtype=np.float64))
|
|
773
|
+
nc.close()
|
|
774
|
+
colormap = args.colormap if args.colormap else "viridis"
|
|
775
|
+
render_bands_gallery(np.stack(slices, axis=0), valid_bands, band_count,
|
|
776
|
+
display_scale=getattr(args, "display", None),
|
|
777
|
+
colormap=colormap)
|
|
778
|
+
return
|
|
779
|
+
|
|
780
|
+
slicer = [slice(None)] * 3
|
|
781
|
+
slicer[spectral_axis] = band_idx
|
|
782
|
+
data = np.asarray(var[tuple(slicer)], dtype=np.float64)
|
|
783
|
+
slice_info = f"slice along axis {spectral_axis} ({var.dimensions[spectral_axis]}), band {band_idx + 1} of {band_count}"
|
|
784
|
+
|
|
785
|
+
else:
|
|
786
|
+
print(f"[ERROR] viewinline only supports 2D or 3D variables. This one is {var.ndim}D.")
|
|
787
|
+
nc.close()
|
|
788
|
+
return
|
|
789
|
+
|
|
790
|
+
print(f"[DATA] {slice_info}")
|
|
791
|
+
# Apply fill value
|
|
792
|
+
fill = getattr(var, '_FillValue', None)
|
|
793
|
+
if fill is not None:
|
|
794
|
+
data = np.where(data == fill, np.nan, data)
|
|
795
|
+
# Flip vertically if data is stored south-to-north so north appears at top.
|
|
796
|
+
# Determine which dims remain after slicing — for the 2D result, figure out
|
|
797
|
+
# which axis (0 or 1) corresponds to latitude, and check that dim's coord values.
|
|
798
|
+
if var.ndim == 2:
|
|
799
|
+
remaining_dims = list(var.dimensions)
|
|
800
|
+
elif var.ndim == 3:
|
|
801
|
+
if spectral_axis is not None:
|
|
802
|
+
remaining_dims = [d for i, d in enumerate(var.dimensions) if i != spectral_axis]
|
|
803
|
+
else:
|
|
804
|
+
remaining_dims = list(var.dimensions[1:]) # axis 0 was reduced
|
|
805
|
+
else:
|
|
806
|
+
remaining_dims = []
|
|
807
|
+
lat_names = {'lat', 'latitude', 'y'}
|
|
808
|
+
for axis_in_2d, dim_name in enumerate(remaining_dims):
|
|
809
|
+
if dim_name in lat_names and dim_name in nc.variables:
|
|
810
|
+
lat_vals = nc[dim_name][:]
|
|
811
|
+
if len(lat_vals) > 1 and lat_vals[0] < lat_vals[-1]:
|
|
812
|
+
data = np.flip(data, axis=axis_in_2d)
|
|
813
|
+
print(f"[INFO] Flipped along '{dim_name}' for display (data stored south-to-north).")
|
|
814
|
+
break
|
|
815
|
+
nc.close()
|
|
816
|
+
|
|
817
|
+
# Normalize and display
|
|
818
|
+
band_u8 = normalize_to_uint8(data, vmin=args.vmin, vmax=args.vmax,
|
|
819
|
+
nodata=args.nodata)
|
|
820
|
+
|
|
821
|
+
if args.colormap:
|
|
822
|
+
cmap = colormaps[args.colormap]
|
|
823
|
+
colored = cmap(band_u8 / 255.0)
|
|
824
|
+
img = (colored[:, :, :3] * 255).astype(np.uint8)
|
|
825
|
+
print(f"[INFO] Applying colormap: {args.colormap}")
|
|
826
|
+
else:
|
|
827
|
+
img = np.stack([band_u8] * 3, axis=-1)
|
|
828
|
+
print("[INFO] Displaying grayscale")
|
|
829
|
+
|
|
830
|
+
# Resize to terminal
|
|
831
|
+
H, W = img.shape[:2]
|
|
832
|
+
if args.display:
|
|
833
|
+
new_w, new_h = max(1, int(W * args.display)), max(1, int(H * args.display))
|
|
834
|
+
img = np.array(Image.fromarray(img).resize((new_w, new_h), Image.BILINEAR))
|
|
835
|
+
print(f"[VIEW] Manual resize ×{args.display:.2f} → {new_w}×{new_h}px")
|
|
836
|
+
else:
|
|
837
|
+
max_dim = 2000
|
|
838
|
+
if max(img.shape[:2]) > max_dim:
|
|
839
|
+
scale = max_dim / max(img.shape[:2])
|
|
840
|
+
new_w = int(img.shape[1] * scale)
|
|
841
|
+
new_h = int(img.shape[0] * scale)
|
|
842
|
+
img = np.array(Image.fromarray(img).resize((new_w, new_h), Image.BILINEAR))
|
|
843
|
+
print(f"[VIEW] Downsampled from {W}×{H}px to {new_w}×{new_h}px (display scale={scale:.2f})")
|
|
844
|
+
print(f"[INFO] Use --display 1 for full resolution.")
|
|
845
|
+
else:
|
|
846
|
+
# (matches the width_pct logic in show_inline_image)
|
|
847
|
+
display_pct = args.display if args.display is not None else 0.33
|
|
848
|
+
|
|
849
|
+
print(f"[VIEW] Rendered image size → {img.shape[1]}×{img.shape[0]}px (size={display_pct:.2f})")
|
|
850
|
+
|
|
851
|
+
show_image_auto(img, getattr(args, "display", None), is_vector=False)
|
|
852
|
+
|
|
646
853
|
def render_raster(paths: list[str], args) -> None:
|
|
647
854
|
try:
|
|
648
855
|
import rasterio
|
|
@@ -655,8 +862,13 @@ def render_raster(paths: list[str], args) -> None:
|
|
|
655
862
|
|
|
656
863
|
if len(paths) == 1:
|
|
657
864
|
path = paths[0]
|
|
865
|
+
|
|
866
|
+
if path.lower().endswith('.nc'):
|
|
867
|
+
render_netcdf_via_netcdf4(path, args)
|
|
868
|
+
return
|
|
658
869
|
|
|
659
870
|
# Handle NetCDF/HDF with subdatasets
|
|
871
|
+
|
|
660
872
|
if path.lower().endswith(('.nc', '.hdf', '.hdf5', '.h5')):
|
|
661
873
|
try:
|
|
662
874
|
|
|
@@ -732,7 +944,8 @@ def render_raster(paths: list[str], args) -> None:
|
|
|
732
944
|
resampling=rasterio.enums.Resampling.bilinear
|
|
733
945
|
)
|
|
734
946
|
|
|
735
|
-
print(f"[
|
|
947
|
+
print(f"[VIEW] Downsampled for preview → {out_w}×{out_h}px (display scale={scale:.3f})")
|
|
948
|
+
print(f"[INFO] Use --display 1 for full resolution.")
|
|
736
949
|
else:
|
|
737
950
|
data = ds.read()
|
|
738
951
|
|
|
@@ -743,9 +956,18 @@ def render_raster(paths: list[str], args) -> None:
|
|
|
743
956
|
else:
|
|
744
957
|
print(f"[INFO] Multi-band raster detected ({band_count} bands)")
|
|
745
958
|
|
|
746
|
-
#
|
|
747
|
-
if
|
|
959
|
+
# BANDS GALLERY
|
|
960
|
+
if getattr(args, "bands", None):
|
|
961
|
+
band_list = parse_bands(args.bands)
|
|
962
|
+
colormap = args.colormap if args.colormap else "viridis"
|
|
963
|
+
render_bands_gallery(data, band_list, band_count,
|
|
964
|
+
grid=getattr(args, "gallery", None),
|
|
965
|
+
display_scale=getattr(args, "display", None),
|
|
966
|
+
colormap=colormap)
|
|
967
|
+
return
|
|
748
968
|
|
|
969
|
+
user_specified_band = args.band is not None
|
|
970
|
+
if band_count >= 3 and not paths[0].lower().endswith('.nc') and not user_specified_band and getattr(args, 'rgb', None):
|
|
749
971
|
|
|
750
972
|
if getattr(args, "rgb", None):
|
|
751
973
|
try:
|
|
@@ -774,7 +996,8 @@ def render_raster(paths: list[str], args) -> None:
|
|
|
774
996
|
# SINGLE BAND
|
|
775
997
|
else:
|
|
776
998
|
|
|
777
|
-
|
|
999
|
+
band_num = args.band if args.band is not None else 1
|
|
1000
|
+
band_idx = max(0, min(band_num - 1, band_count - 1))
|
|
778
1001
|
# print(f"[INFO] Displaying band {band_idx + 1} of {band_count}")
|
|
779
1002
|
raw_band = data[band_idx].astype(float)
|
|
780
1003
|
|
|
@@ -851,7 +1074,7 @@ def render_raster(paths: list[str], args) -> None:
|
|
|
851
1074
|
print(f"[ERROR] Cannot display this variable.")
|
|
852
1075
|
print("[INFO] viewinline only supports 2D or 3D NetCDF variables")
|
|
853
1076
|
else:
|
|
854
|
-
print(f"[ERROR]
|
|
1077
|
+
print(f"[ERROR] Inline render failed: {e}")
|
|
855
1078
|
|
|
856
1079
|
|
|
857
1080
|
def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector=False) -> None:
|
|
@@ -866,12 +1089,11 @@ def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector
|
|
|
866
1089
|
cols, rows = 4, 4
|
|
867
1090
|
nmax = cols * rows
|
|
868
1091
|
|
|
869
|
-
# Collect files
|
|
870
|
-
exts = (".png", ".jpg", ".jpeg", ".tif", ".tiff")
|
|
1092
|
+
# Collect all files, let the loader decide what's valid
|
|
871
1093
|
files = [os.path.join(folder, f) for f in sorted(os.listdir(folder))
|
|
872
|
-
if
|
|
1094
|
+
if os.path.isfile(os.path.join(folder, f))]
|
|
873
1095
|
if not files:
|
|
874
|
-
print(f"[WARN] No
|
|
1096
|
+
print(f"[WARN] No files found in {folder}")
|
|
875
1097
|
return
|
|
876
1098
|
|
|
877
1099
|
files = files[:nmax]
|
|
@@ -884,8 +1106,12 @@ def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector
|
|
|
884
1106
|
ext = os.path.splitext(f)[1].lower()
|
|
885
1107
|
if ext in [".tif", ".tiff"]:
|
|
886
1108
|
import rasterio
|
|
1109
|
+
from rasterio.enums import Resampling
|
|
887
1110
|
with rasterio.open(f) as ds:
|
|
888
|
-
arr = ds.read(
|
|
1111
|
+
arr = ds.read(
|
|
1112
|
+
out_shape=(ds.count, thumb_size[1], thumb_size[0]),
|
|
1113
|
+
resampling=Resampling.nearest
|
|
1114
|
+
)
|
|
889
1115
|
if arr.shape[0] >= 3:
|
|
890
1116
|
rgb = np.stack([normalize_to_uint8(arr[i]) for i in range(3)], axis=-1)
|
|
891
1117
|
else:
|
|
@@ -896,8 +1122,9 @@ def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector
|
|
|
896
1122
|
img = Image.open(f).convert("RGB")
|
|
897
1123
|
img.thumbnail(thumb_size)
|
|
898
1124
|
thumbs.append(img)
|
|
1125
|
+
|
|
899
1126
|
except Exception as e:
|
|
900
|
-
print(f"[
|
|
1127
|
+
print(f"[SKIP] {os.path.basename(f)} — {e}")
|
|
901
1128
|
|
|
902
1129
|
if not thumbs:
|
|
903
1130
|
print("[WARN] No valid images loaded.")
|
|
@@ -925,6 +1152,87 @@ def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector
|
|
|
925
1152
|
except Exception as e:
|
|
926
1153
|
print(f"[ERROR] Failed to render gallery: {e}")
|
|
927
1154
|
|
|
1155
|
+
def render_bands_gallery(data: np.ndarray, band_list: list[int], band_count: int,
|
|
1156
|
+
grid: str = None, display_scale=None, colormap: str = "viridis") -> None:
|
|
1157
|
+
"""Render multiple bands from a single raster as a grid of thumbnails."""
|
|
1158
|
+
import math
|
|
1159
|
+
from PIL import ImageDraw
|
|
1160
|
+
|
|
1161
|
+
# Validate bands
|
|
1162
|
+
valid_bands = [b for b in band_list if 1 <= b <= band_count]
|
|
1163
|
+
if not valid_bands:
|
|
1164
|
+
print(f"[ERROR] No valid bands. File has {band_count} bands.")
|
|
1165
|
+
return
|
|
1166
|
+
skipped = set(band_list) - set(valid_bands)
|
|
1167
|
+
if skipped:
|
|
1168
|
+
print(f"[WARN] Skipped out-of-range bands: {sorted(skipped)}")
|
|
1169
|
+
|
|
1170
|
+
n = len(valid_bands)
|
|
1171
|
+
|
|
1172
|
+
# Auto grid or user-specified
|
|
1173
|
+
if grid:
|
|
1174
|
+
try:
|
|
1175
|
+
cols, rows = map(int, grid.lower().split("x"))
|
|
1176
|
+
except Exception:
|
|
1177
|
+
cols = math.ceil(math.sqrt(n))
|
|
1178
|
+
rows = math.ceil(n / cols)
|
|
1179
|
+
else:
|
|
1180
|
+
cols = math.ceil(math.sqrt(n))
|
|
1181
|
+
rows = math.ceil(n / cols)
|
|
1182
|
+
|
|
1183
|
+
# Use fixed longest dimension to preserve aspect ratio
|
|
1184
|
+
max_thumb = 256
|
|
1185
|
+
label_height = 14 # space below each thumbnail for band label
|
|
1186
|
+
margin = 8
|
|
1187
|
+
cmap = colormaps[colormap]
|
|
1188
|
+
|
|
1189
|
+
thumbs = []
|
|
1190
|
+
thumb_w = thumb_h = max_thumb # will be updated from first image
|
|
1191
|
+
|
|
1192
|
+
for i, b in enumerate(valid_bands):
|
|
1193
|
+
arr = data[i].astype(float)
|
|
1194
|
+
normalized = normalize_to_uint8(arr)
|
|
1195
|
+
colored = cmap(normalized / 255.0)
|
|
1196
|
+
rgb = (colored[:, :, :3] * 255).astype(np.uint8)
|
|
1197
|
+
img = Image.fromarray(rgb)
|
|
1198
|
+
img.thumbnail((max_thumb, max_thumb), Image.LANCZOS)
|
|
1199
|
+
if i == 0:
|
|
1200
|
+
thumb_w, thumb_h = img.size
|
|
1201
|
+
thumbs.append(img)
|
|
1202
|
+
|
|
1203
|
+
# Build canvas with extra height per row for labels
|
|
1204
|
+
cols = min(cols, n)
|
|
1205
|
+
rows = math.ceil(n / cols)
|
|
1206
|
+
cell_w = thumb_w + margin
|
|
1207
|
+
cell_h = thumb_h + label_height + margin
|
|
1208
|
+
canvas_w = cols * cell_w + margin
|
|
1209
|
+
canvas_h = rows * cell_h + margin
|
|
1210
|
+
canvas = Image.new("RGB", (canvas_w, canvas_h), (220, 220, 220))
|
|
1211
|
+
draw = ImageDraw.Draw(canvas)
|
|
1212
|
+
from PIL import ImageFont
|
|
1213
|
+
try:
|
|
1214
|
+
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 10)
|
|
1215
|
+
except Exception:
|
|
1216
|
+
try:
|
|
1217
|
+
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10)
|
|
1218
|
+
except Exception:
|
|
1219
|
+
font = ImageFont.load_default()
|
|
1220
|
+
|
|
1221
|
+
for i, img in enumerate(thumbs):
|
|
1222
|
+
r, c = divmod(i, cols)
|
|
1223
|
+
x = margin + c * cell_w
|
|
1224
|
+
y = margin + r * cell_h
|
|
1225
|
+
canvas.paste(img, (x, y))
|
|
1226
|
+
# Draw label on canvas background below the thumbnail
|
|
1227
|
+
label = f"B{valid_bands[i]}"
|
|
1228
|
+
lx = x + (thumb_w - len(label) * 6) // 2
|
|
1229
|
+
ly = y + thumb_h + 2
|
|
1230
|
+
draw.text((lx, ly), label, fill=(0, 0, 0), font=font)
|
|
1231
|
+
|
|
1232
|
+
print(f"[INFO] Displaying {n} bands ({cols}×{rows} grid, colormap: {colormap})")
|
|
1233
|
+
# print(f"[DEBUG] canvas size: {canvas_w}×{canvas_h}px")
|
|
1234
|
+
show_image_auto(np.array(canvas), display_scale)
|
|
1235
|
+
|
|
928
1236
|
# ---------------------------------------------------------------------
|
|
929
1237
|
# Vector handling
|
|
930
1238
|
# ---------------------------------------------------------------------
|
|
@@ -956,13 +1264,6 @@ def render_vector(path, args):
|
|
|
956
1264
|
except Exception as e:
|
|
957
1265
|
print(f"[WARN] Could not list layers: {e}")
|
|
958
1266
|
|
|
959
|
-
# try:
|
|
960
|
-
# gdf = gpd.read_file(path, layer=getattr(args, "layer", None))
|
|
961
|
-
# print(f"[DATA] Vector loaded: {os.path.basename(path)} ({len(gdf)} features)")
|
|
962
|
-
# except Exception as e:
|
|
963
|
-
# print(f"[ERROR] Failed to read vector: {e}")
|
|
964
|
-
# return
|
|
965
|
-
|
|
966
1267
|
try:
|
|
967
1268
|
# Use read_parquet for parquet/geoparquet files
|
|
968
1269
|
if path.lower().endswith(('.parquet', '.geoparquet')):
|
|
@@ -1304,7 +1605,7 @@ def main() -> None:
|
|
|
1304
1605
|
|
|
1305
1606
|
# Raster options
|
|
1306
1607
|
parser.add_argument(
|
|
1307
|
-
"--band", type=int, default=
|
|
1608
|
+
"--band", type=int, default=None,
|
|
1308
1609
|
help="Band number to display (single raster case), or slice number for NetCDF."
|
|
1309
1610
|
)
|
|
1310
1611
|
parser.add_argument(
|
|
@@ -1320,6 +1621,10 @@ def main() -> None:
|
|
|
1320
1621
|
"--rgb", nargs=3, type=int, metavar=('R', 'G', 'B'), default=None,
|
|
1321
1622
|
help="Three band numbers for RGB display (e.g., --rgb 4 3 2). Overrides default 1 2 3."
|
|
1322
1623
|
)
|
|
1624
|
+
parser.add_argument(
|
|
1625
|
+
"--rgbfiles", nargs=3, type=str, metavar=('R', 'G', 'B'),
|
|
1626
|
+
help="Three single-band rasters for RGB composite (e.g., --rgbfiles R.tif G.tif B.tif). Can also provide as positional arguments without the flag."
|
|
1627
|
+
)
|
|
1323
1628
|
parser.add_argument(
|
|
1324
1629
|
"--vmin", type=float, default=None,
|
|
1325
1630
|
help="Minimum pixel value for raster display scaling."
|
|
@@ -1341,8 +1646,14 @@ def main() -> None:
|
|
|
1341
1646
|
help="Variable index for NetCDF files (e.g. --subset 1)."
|
|
1342
1647
|
)
|
|
1343
1648
|
parser.add_argument(
|
|
1344
|
-
"--
|
|
1345
|
-
|
|
1649
|
+
"--reduce", dest="reduce_dim", type=str, default=None,
|
|
1650
|
+
metavar="DIM_NAME",
|
|
1651
|
+
help="For 3D NetCDF variables, specify which dimension to use as the band axis (auto-detected if omitted)."
|
|
1652
|
+
)
|
|
1653
|
+
parser.add_argument(
|
|
1654
|
+
"--bands",
|
|
1655
|
+
type=str,
|
|
1656
|
+
help="Display multiple bands as a grid. Accepts ranges (30-40), lists (3,4,5), or mixed (1,3,10-15)."
|
|
1346
1657
|
)
|
|
1347
1658
|
|
|
1348
1659
|
# CSV options
|
|
@@ -1426,7 +1737,7 @@ def main() -> None:
|
|
|
1426
1737
|
parser.add_argument(
|
|
1427
1738
|
"--table", action="store_true",
|
|
1428
1739
|
help="Display vector/parquet file as tabular data instead of rendering geometry."
|
|
1429
|
-
)
|
|
1740
|
+
)
|
|
1430
1741
|
|
|
1431
1742
|
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
1432
1743
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|