viewinline 0.3.0__tar.gz → 0.3.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: viewinline
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -64,9 +64,9 @@ pip install viewinline
64
64
  # Rasters
65
65
  viewinline path/to/file.tif
66
66
  viewinline R.tif G.tif B.tif # RGB composite (also works with --rgbfiles)
67
- viewinline hyperspectral.nc --band 50
68
67
  viewinline path/to/multiband.tif --rgb 3 2 1
69
- 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)
70
70
 
71
71
  # NetCDF and HDF
72
72
  viewinline file.nc # list variables
@@ -74,6 +74,8 @@ viewinline file.nc --subset 2 # display variable 2
74
74
  viewinline file.nc --subset 1 --band 10 # variable 1, timestep 10 --band or --timestep
75
75
  viewinline temp.nc --subset 1 --colormap plasma --vmin 273 --vmax 310
76
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
77
79
 
78
80
  # Vectors
79
81
  viewinline path/to/vector.geojson
@@ -169,6 +171,7 @@ You can also force the chafa path on any terminal by setting `INLINE_VIEWER_ENGI
169
171
 
170
172
  **Gallery view**
171
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.
172
175
 
173
176
  **NetCDF/HDF notes:**
174
177
  - viewinline lists only variables that can be displayed as 2D or 3D arrays
@@ -213,12 +216,13 @@ General:
213
216
 
214
217
  Raster:
215
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).
220
+ --rgb R G B Three band numbers for RGB display (e.g., --rgb 4 3 2). Overrides default 1 2 3.
221
+ --rgbfiles R G B Three single-band rasters for RGB composite. Can also provide as positional arguments.
216
222
  --timestep INTEGER Alias for --band when working with NetCDF files.
217
223
  --subset INTEGER Variable index for NetCDF/HDF files (e.g., --subset 1).
218
224
  --reduce DIM_NAME For 3D NetCDF variables, specify which dimension to use as the band/slider axis. Auto-detected if omitted.
219
225
  --colormap Apply colormap to single-band rasters. Flag without the color scheme → 'terrain'.
220
- --rgb R G B Three band numbers for RGB display (e.g., --rgb 4 3 2). Overrides default 1 2 3.
221
- --rgbfiles R G B Three single-band rasters for RGB composite. Can also provide as positional arguments.
222
226
  --vmin VMIN Minimum pixel value for raster display scaling.
223
227
  --vmax VMAX Maximum pixel value for raster display scaling.
224
228
  --nodata NODATA Override nodata value for rasters if dataset metadata is missing or incorrect.
@@ -31,9 +31,9 @@ pip install viewinline
31
31
  # Rasters
32
32
  viewinline path/to/file.tif
33
33
  viewinline R.tif G.tif B.tif # RGB composite (also works with --rgbfiles)
34
- viewinline hyperspectral.nc --band 50
35
34
  viewinline path/to/multiband.tif --rgb 3 2 1
36
- 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)
37
37
 
38
38
  # NetCDF and HDF
39
39
  viewinline file.nc # list variables
@@ -41,6 +41,8 @@ viewinline file.nc --subset 2 # display variable 2
41
41
  viewinline file.nc --subset 1 --band 10 # variable 1, timestep 10 --band or --timestep
42
42
  viewinline temp.nc --subset 1 --colormap plasma --vmin 273 --vmax 310
43
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
44
46
 
45
47
  # Vectors
46
48
  viewinline path/to/vector.geojson
@@ -136,6 +138,7 @@ You can also force the chafa path on any terminal by setting `INLINE_VIEWER_ENGI
136
138
 
137
139
  **Gallery view**
138
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.
139
142
 
140
143
  **NetCDF/HDF notes:**
141
144
  - viewinline lists only variables that can be displayed as 2D or 3D arrays
@@ -180,12 +183,13 @@ General:
180
183
 
181
184
  Raster:
182
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).
187
+ --rgb R G B Three band numbers for RGB display (e.g., --rgb 4 3 2). Overrides default 1 2 3.
188
+ --rgbfiles R G B Three single-band rasters for RGB composite. Can also provide as positional arguments.
183
189
  --timestep INTEGER Alias for --band when working with NetCDF files.
184
190
  --subset INTEGER Variable index for NetCDF/HDF files (e.g., --subset 1).
185
191
  --reduce DIM_NAME For 3D NetCDF variables, specify which dimension to use as the band/slider axis. Auto-detected if omitted.
186
192
  --colormap Apply colormap to single-band rasters. Flag without the color scheme → 'terrain'.
187
- --rgb R G B Three band numbers for RGB display (e.g., --rgb 4 3 2). Overrides default 1 2 3.
188
- --rgbfiles R G B Three single-band rasters for RGB composite. Can also provide as positional arguments.
189
193
  --vmin VMIN Minimum pixel value for raster display scaling.
190
194
  --vmax VMAX Maximum pixel value for raster display scaling.
191
195
  --nodata NODATA Override nodata value for rasters if dataset metadata is missing or incorrect.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "viewinline"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Quick look geospatial viewer for the terminal, with inline image previews"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -9,14 +9,11 @@ Supports:
9
9
 
10
10
  Display:
11
11
  Sends iTerm2-style inline image escape sequences. Works in terminals that support
12
- the iTerm2 inline image protocol (iTerm2, WezTerm, Konsole, etc.). In other
13
- terminals, the escape codes are ignored.
12
+ the iTerm2 inline image protocol (iTerm2, WezTerm, Konsole, etc.). For others,
13
+ please see line 48-58.
14
14
 
15
15
  Particularly useful on HPC systems and remote servers accessed via SSH — images
16
16
  render on your local terminal without X11 forwarding, VNC, or file downloads.
17
-
18
- No detection, no fallbacks. If images are not shown, it means that the terminal
19
- is not compatible.
20
17
  """
21
18
 
22
19
  import sys, os, base64, shutil, argparse
@@ -28,19 +25,13 @@ from matplotlib import colormaps
28
25
  import matplotlib as mpl
29
26
  import subprocess
30
27
 
31
- try:
32
- import netCDF4
33
- HAS_NETCDF4 = True
34
- except ImportError:
35
- HAS_NETCDF4 = False
36
-
37
28
  import warnings
38
29
 
39
30
  warnings.filterwarnings("ignore", message="More than one layer found", category=UserWarning)
40
31
  warnings.filterwarnings("ignore", message="Dataset has no geotransform", category=UserWarning)
41
32
  warnings.filterwarnings("ignore", message="invalid scale_factor or add_offset attribute", category=UserWarning)
42
33
 
43
- __version__ = "0.3.0"
34
+ __version__ = "0.3.2"
44
35
 
45
36
  AVAILABLE_COLORMAPS = [
46
37
  "viridis", "inferno", "magma", "plasma",
@@ -248,6 +239,41 @@ def resize_to_terminal(img: np.ndarray) -> tuple[np.ndarray, float]:
248
239
  pil_img = ImageOps.contain(pil_img, (new_w, new_h))
249
240
  return np.array(pil_img), scale
250
241
 
242
+ def parse_bands(s: str) -> list[int]:
243
+ """Parse --bands argument: accepts ranges (30-40), lists (3,4,5), or mixed (1,3,10-15)."""
244
+ bands = []
245
+ for part in s.split(","):
246
+ part = part.strip()
247
+ if "-" in part:
248
+ try:
249
+ start, end = map(int, part.split("-", 1))
250
+ bands.extend(range(start, end + 1))
251
+ except ValueError:
252
+ print(f"[WARN] Could not parse band range: {part}")
253
+ else:
254
+ try:
255
+ bands.append(int(part))
256
+ except ValueError:
257
+ print(f"[WARN] Could not parse band: {part}")
258
+ return sorted(set(bands))
259
+
260
+ def parse_rgb(values: list[str]) -> list[int]:
261
+ """Parse --rgb: accepts '4 3 2' or '4,3,2'."""
262
+ if len(values) == 1:
263
+ # comma-separated: '4,3,2'
264
+ parts = values[0].split(",")
265
+ else:
266
+ # space-separated: '4' '3' '2'
267
+ parts = values
268
+ try:
269
+ result = [int(p.strip()) for p in parts]
270
+ if len(result) != 3:
271
+ raise ValueError
272
+ return result
273
+ except ValueError:
274
+ print("[WARN] --rgb requires exactly 3 band numbers. e.g. --rgb 4 3 2 or --rgb 4,3,2")
275
+ return None
276
+
251
277
  # ---------------------------------------------------------------------
252
278
  # CSV handling
253
279
  # ---------------------------------------------------------------------
@@ -286,11 +312,18 @@ def preview_df(df, max_rows: int = 10, query_mode: bool = False, filename: str =
286
312
  # -------------------------------------------------------------
287
313
  if n_rows <= max_rows:
288
314
  rows_to_show = df
315
+ elif query_mode:
316
+ ans = input(f"Filtered results: {n_rows} rows. Show first {max_rows} or all? [first/all]: ").strip().lower()
317
+ if ans == "all":
318
+ rows_to_show = df
319
+ else:
320
+ rows_to_show = df.head(max_rows)
289
321
  else:
290
- ans = input(f"Preview first {max_rows} rows? [y/N]: ").strip().lower()
291
- if ans not in ("y", "yes"):
292
- return
293
- rows_to_show = df.head(max_rows)
322
+ ans = input(f"Large file: {n_rows} rows. Show first {max_rows} or all? [first/all]: ").strip().lower()
323
+ if ans == "all":
324
+ rows_to_show = df
325
+ else:
326
+ rows_to_show = df.head(max_rows)
294
327
 
295
328
  # -------------------------------------------------------------
296
329
  # Build pretty table
@@ -592,8 +625,15 @@ def normalize_to_uint8(band: np.ndarray, vmin=None, vmax=None, nodata=None) -> n
592
625
  valid_vals = band[valid]
593
626
 
594
627
  # --- Manual scaling ---
595
- if vmin is not None and vmax is not None:
596
- mn, mx = vmin, vmax
628
+ if vmin is not None or vmax is not None:
629
+ # Use percentile for whichever end is not specified
630
+ if valid_vals.size < 1_000_000:
631
+ sample = valid_vals
632
+ else:
633
+ sample = np.random.choice(valid_vals, 1_000_000, replace=False)
634
+ p2, p98 = np.percentile(sample, (2, 98))
635
+ mn = vmin if vmin is not None else p2
636
+ mx = vmax if vmax is not None else p98
597
637
  print(f"[VIEW] Using manual scaling: {mn} to {mx}")
598
638
 
599
639
  # --- Percentile fallback ---
@@ -646,7 +686,9 @@ def render_netcdf_via_netcdf4(path, args):
646
686
  """Read a NetCDF file via netCDF4 (bypassing GDAL). Handles hierarchical
647
687
  groups and hyperspectral cubes where GDAL aborts or interprets axes wrong.
648
688
  """
649
- if not HAS_NETCDF4:
689
+ try:
690
+ import netCDF4
691
+ except ImportError:
650
692
  print("[ERROR] netCDF4 not installed. Install with:")
651
693
  print(" pip install netCDF4")
652
694
  print(" or: pip install viewinline[netcdf]")
@@ -737,6 +779,66 @@ def render_netcdf_via_netcdf4(path, args):
737
779
  band_count = var.shape[spectral_axis]
738
780
  band_num = args.band if args.band is not None else 1
739
781
  band_idx = max(0, min(band_num - 1, band_count - 1))
782
+
783
+ # BANDS GALLERY for NetCDF
784
+ if getattr(args, "bands", None):
785
+ band_list = parse_bands(args.bands)
786
+ # print(f"[DEBUG] band_count={band_count}, band_list={band_list}")
787
+ valid_bands = [b for b in band_list if 1 <= b <= band_count]
788
+ if not valid_bands:
789
+ print(f"[ERROR] No valid bands. Variable has {band_count} bands along '{var.dimensions[spectral_axis]}'.")
790
+ nc.close()
791
+ return
792
+ slices = []
793
+ for b in valid_bands:
794
+ slicer = [slice(None)] * 3
795
+ slicer[spectral_axis] = b - 1
796
+ slices.append(np.asarray(var[tuple(slicer)], dtype=np.float64))
797
+ nc.close()
798
+ colormap = args.colormap if args.colormap else "viridis"
799
+ render_bands_gallery(np.stack(slices, axis=0), valid_bands, band_count,
800
+ grid=getattr(args, "gallery", None),
801
+ display_scale=getattr(args, "display", None),
802
+ colormap=colormap)
803
+ return
804
+
805
+ # RGB COMPOSITE for NetCDF
806
+ if getattr(args, "rgb", None):
807
+ try:
808
+ # rgb_bands = args.rgb
809
+ rgb_bands = parse_rgb(args.rgb)
810
+ if rgb_bands is None:
811
+ return
812
+ if len(rgb_bands) != 3:
813
+ raise ValueError("exactly 3 bands required")
814
+ slices = []
815
+ for b in rgb_bands:
816
+ if b < 1 or b > band_count:
817
+ raise ValueError(f"band {b} out of range (1-{band_count})")
818
+ slicer = [slice(None)] * 3
819
+ slicer[spectral_axis] = b - 1
820
+ slices.append(np.asarray(var[tuple(slicer)], dtype=np.float64))
821
+ nc.close()
822
+ print(f"[INFO] Using RGB bands: {rgb_bands}")
823
+ img = np.stack([normalize_to_uint8(s, vmin=args.vmin, vmax=args.vmax, nodata=args.nodata)
824
+ for s in slices], axis=-1)
825
+ H, W = img.shape[:2]
826
+ if args.display:
827
+ new_w = max(1, int(W * args.display))
828
+ new_h = max(1, int(H * args.display))
829
+ img = np.array(Image.fromarray(img).resize((new_w, new_h), Image.BILINEAR))
830
+ else:
831
+ max_dim = 2000
832
+ if max(H, W) > max_dim:
833
+ scale = max_dim / max(H, W)
834
+ new_w, new_h = int(W * scale), int(H * scale)
835
+ img = np.array(Image.fromarray(img).resize((new_w, new_h), Image.BILINEAR))
836
+ show_image_auto(img, getattr(args, "display", None), is_vector=False)
837
+ return
838
+ except ValueError as e:
839
+ print(f"[WARN] Invalid --rgb: {e}. Falling back to band 1.")
840
+
841
+ # Single band slicer
740
842
  slicer = [slice(None)] * 3
741
843
  slicer[spectral_axis] = band_idx
742
844
  data = np.asarray(var[tuple(slicer)], dtype=np.float64)
@@ -793,9 +895,6 @@ def render_netcdf_via_netcdf4(path, args):
793
895
  new_w, new_h = max(1, int(W * args.display)), max(1, int(H * args.display))
794
896
  img = np.array(Image.fromarray(img).resize((new_w, new_h), Image.BILINEAR))
795
897
  print(f"[VIEW] Manual resize ×{args.display:.2f} → {new_w}×{new_h}px")
796
- # else:
797
- # img, scale = resize_to_terminal(img)
798
- # print(f"[VIEW] Rendered image size → {img.shape[1]}×{img.shape[0]}px (size={scale:.2f})")
799
898
  else:
800
899
  max_dim = 2000
801
900
  if max(img.shape[:2]) > max_dim:
@@ -803,7 +902,7 @@ def render_netcdf_via_netcdf4(path, args):
803
902
  new_w = int(img.shape[1] * scale)
804
903
  new_h = int(img.shape[0] * scale)
805
904
  img = np.array(Image.fromarray(img).resize((new_w, new_h), Image.BILINEAR))
806
- print(f"[VIEW] Downsampled from {W}×{H}px to {new_w}×{new_h}px (scale={scale:.2f})")
905
+ print(f"[VIEW] Downsampled from {W}×{H}px to {new_w}×{new_h}px (display scale={scale:.2f})")
807
906
  print(f"[INFO] Use --display 1 for full resolution.")
808
907
  else:
809
908
  # (matches the width_pct logic in show_inline_image)
@@ -907,7 +1006,8 @@ def render_raster(paths: list[str], args) -> None:
907
1006
  resampling=rasterio.enums.Resampling.bilinear
908
1007
  )
909
1008
 
910
- print(f"[VIEW] Downsampled for preview → {out_w}×{out_h}px (scale={scale:.3f})")
1009
+ print(f"[VIEW] Downsampled for preview → {out_w}×{out_h}px (display scale={scale:.3f})")
1010
+ print(f"[INFO] Use --display 1 for full resolution.")
911
1011
  else:
912
1012
  data = ds.read()
913
1013
 
@@ -918,22 +1018,27 @@ def render_raster(paths: list[str], args) -> None:
918
1018
  else:
919
1019
  print(f"[INFO] Multi-band raster detected ({band_count} bands)")
920
1020
 
921
- # MULTI BAND RGB (skip for NetCDF - treat as slices/timesteps, not RGB)
922
- # if band_count >= 3 and not paths[0].lower().endswith('.nc'):
923
- # Auto-composite to RGB only when user didn't explicitly ask for a single band
924
- # user_specified_band = args.band is not None and args.band != 1
1021
+ # BANDS GALLERY
1022
+ if getattr(args, "bands", None):
1023
+ band_list = parse_bands(args.bands)
1024
+ colormap = args.colormap if args.colormap else "viridis"
1025
+ render_bands_gallery(data, band_list, band_count,
1026
+ grid=getattr(args, "gallery", None),
1027
+ display_scale=getattr(args, "display", None),
1028
+ colormap=colormap)
1029
+ return
1030
+
925
1031
  user_specified_band = args.band is not None
926
- if band_count >= 3 and not paths[0].lower().endswith('.nc') and not user_specified_band:
1032
+ if band_count >= 3 and not paths[0].lower().endswith('.nc') and not user_specified_band and getattr(args, 'rgb', None):
927
1033
 
928
1034
  if getattr(args, "rgb", None):
929
- try:
930
- rgb_idx = [b - 1 for b in args.rgb]
931
- if len(rgb_idx) != 3:
932
- raise ValueError
933
- print(f"[INFO] Using RGB bands: {args.rgb}")
934
- except Exception:
1035
+ rgb_parsed = parse_rgb(args.rgb)
1036
+ if rgb_parsed is None:
935
1037
  print("[WARN] Invalid --rgb. Using default 1 2 3")
936
1038
  rgb_idx = [0, 1, 2]
1039
+ else:
1040
+ rgb_idx = [b - 1 for b in rgb_parsed]
1041
+ print(f"[INFO] Using RGB bands: {rgb_parsed}")
937
1042
  else:
938
1043
  rgb_idx = [0, 1, 2]
939
1044
 
@@ -1045,26 +1150,30 @@ def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector
1045
1150
  cols, rows = 4, 4
1046
1151
  nmax = cols * rows
1047
1152
 
1048
- # Collect files
1049
- exts = (".png", ".jpg", ".jpeg", ".tif", ".tiff")
1153
+ # Collect all files, let the loader decide what's valid
1050
1154
  files = [os.path.join(folder, f) for f in sorted(os.listdir(folder))
1051
- if f.lower().endswith(exts)]
1155
+ if os.path.isfile(os.path.join(folder, f))]
1052
1156
  if not files:
1053
- print(f"[WARN] No image/raster files found in {folder}")
1157
+ print(f"[WARN] No files found in {folder}")
1054
1158
  return
1055
1159
 
1056
1160
  files = files[:nmax]
1057
1161
 
1058
1162
  # Load thumbnails
1059
1163
  thumbs = []
1164
+ loaded_files = []
1060
1165
  thumb_size = (128, 128)
1061
1166
  for f in files:
1062
1167
  try:
1063
1168
  ext = os.path.splitext(f)[1].lower()
1064
1169
  if ext in [".tif", ".tiff"]:
1065
1170
  import rasterio
1171
+ from rasterio.enums import Resampling
1066
1172
  with rasterio.open(f) as ds:
1067
- arr = ds.read()
1173
+ arr = ds.read(
1174
+ out_shape=(ds.count, thumb_size[1], thumb_size[0]),
1175
+ resampling=Resampling.nearest
1176
+ )
1068
1177
  if arr.shape[0] >= 3:
1069
1178
  rgb = np.stack([normalize_to_uint8(arr[i]) for i in range(3)], axis=-1)
1070
1179
  else:
@@ -1075,8 +1184,10 @@ def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector
1075
1184
  img = Image.open(f).convert("RGB")
1076
1185
  img.thumbnail(thumb_size)
1077
1186
  thumbs.append(img)
1187
+ loaded_files.append(f)
1188
+
1078
1189
  except Exception as e:
1079
- print(f"[WARN] Skipped {os.path.basename(f)} ({e})")
1190
+ print(f"[SKIP] {os.path.basename(f)} {e}")
1080
1191
 
1081
1192
  if not thumbs:
1082
1193
  print("[WARN] No valid images loaded.")
@@ -1099,11 +1210,103 @@ def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector
1099
1210
  canvas.paste(img, (x, y))
1100
1211
 
1101
1212
  print(f"[INFO] Displaying {n} images ({cols}×{rows} grid)")
1102
- show_image_auto(np.array(canvas), display_scale, is_vector)
1103
1213
 
1214
+ for r in range(rows):
1215
+ row_files = loaded_files[r * cols:(r + 1) * cols]
1216
+ print(" ".join(f"{os.path.basename(f):<20}" for f in row_files))
1217
+
1218
+ show_image_auto(np.array(canvas), display_scale, is_vector)
1104
1219
  except Exception as e:
1105
1220
  print(f"[ERROR] Failed to render gallery: {e}")
1106
1221
 
1222
+ def render_bands_gallery(data: np.ndarray, band_list: list[int], band_count: int,
1223
+ grid: str = None, display_scale=None, colormap: str = "viridis") -> None:
1224
+ """Render multiple bands from a single raster as a grid of thumbnails."""
1225
+ import math
1226
+
1227
+ # Validate bands
1228
+ valid_bands = [b for b in band_list if 1 <= b <= band_count]
1229
+ if not valid_bands:
1230
+ print(f"[ERROR] No valid bands. File has {band_count} bands.")
1231
+ return
1232
+ skipped = set(band_list) - set(valid_bands)
1233
+ if skipped:
1234
+ print(f"[WARN] Skipped out-of-range bands: {sorted(skipped)}")
1235
+
1236
+ n = len(valid_bands)
1237
+
1238
+ # Auto grid or user-specified
1239
+ if grid:
1240
+ try:
1241
+ cols, rows = map(int, grid.lower().split("x"))
1242
+ except Exception:
1243
+ cols = math.ceil(math.sqrt(n))
1244
+ rows = math.ceil(n / cols)
1245
+ else:
1246
+ cols = math.ceil(math.sqrt(n))
1247
+ rows = math.ceil(n / cols)
1248
+
1249
+ # Use fixed longest dimension to preserve aspect ratio
1250
+ max_thumb = 256
1251
+ label_height = 14 # space below each thumbnail for band label
1252
+ margin = 8
1253
+ cmap = colormaps[colormap]
1254
+
1255
+ thumbs = []
1256
+ thumb_w = thumb_h = max_thumb # will be updated from first image
1257
+
1258
+ for i, b in enumerate(valid_bands):
1259
+ arr = data[i].astype(float)
1260
+ normalized = normalize_to_uint8(arr)
1261
+ colored = cmap(normalized / 255.0)
1262
+ rgb = (colored[:, :, :3] * 255).astype(np.uint8)
1263
+ img = Image.fromarray(rgb)
1264
+ img.thumbnail((max_thumb, max_thumb), Image.LANCZOS)
1265
+ if i == 0:
1266
+ thumb_w, thumb_h = img.size
1267
+ thumbs.append(img)
1268
+
1269
+ # Build canvas with extra height per row for labels
1270
+ cols = min(cols, n)
1271
+ rows = math.ceil(n / cols)
1272
+ cell_w = thumb_w + margin
1273
+ cell_h = thumb_h + label_height + margin
1274
+ canvas_w = cols * cell_w + margin
1275
+ canvas_h = rows * cell_h + margin
1276
+ canvas = Image.new("RGB", (canvas_w, canvas_h), (220, 220, 220))
1277
+ draw = ImageDraw.Draw(canvas)
1278
+ from PIL import ImageFont
1279
+ try:
1280
+ font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 10)
1281
+ except Exception:
1282
+ try:
1283
+ font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10)
1284
+ except Exception:
1285
+ font = ImageFont.load_default()
1286
+
1287
+ for i, img in enumerate(thumbs):
1288
+ r, c = divmod(i, cols)
1289
+ x = margin + c * cell_w
1290
+ y = margin + r * cell_h
1291
+ canvas.paste(img, (x, y))
1292
+ if _TERMINAL_SUPPORTS_IMAGES:
1293
+ # Draw label on canvas background below the thumbnail
1294
+ label = f"B{valid_bands[i]}"
1295
+ lx = x + (thumb_w - len(label) * 6) // 2
1296
+ ly = y + thumb_h + 2
1297
+ draw.text((lx, ly), label, fill=(0, 0, 0), font=font)
1298
+
1299
+ print(f"[INFO] Displaying {n} bands ({cols}×{rows} grid, colormap: {colormap})")
1300
+ # print(f"[DEBUG] canvas size: {canvas_w}×{canvas_h}px")
1301
+
1302
+ if not _TERMINAL_SUPPORTS_IMAGES:
1303
+ # Print band labels as text grid
1304
+ for r in range(rows):
1305
+ row_bands = valid_bands[r * cols:(r + 1) * cols]
1306
+ print(" ".join(f"B{b:<4}" for b in row_bands))
1307
+
1308
+ show_image_auto(np.array(canvas), display_scale)
1309
+
1107
1310
  # ---------------------------------------------------------------------
1108
1311
  # Vector handling
1109
1312
  # ---------------------------------------------------------------------
@@ -1463,147 +1666,140 @@ def main() -> None:
1463
1666
  formatter_class=SmartDefaults
1464
1667
  )
1465
1668
 
1466
- # File input
1669
+ # File input
1467
1670
  parser.add_argument(
1468
- "paths", nargs="*", # Zero or more (optional)
1671
+ "paths", nargs="*",
1469
1672
  help="Path to raster(s), vector, or CSV file. Provide 1 file or exactly 3 rasters for RGB (R G B)."
1470
1673
  )
1471
- # Display options
1472
- parser.add_argument(
1674
+
1675
+ # General options
1676
+ general = parser.add_argument_group("General")
1677
+ general.add_argument(
1473
1678
  "--display", type=float, default=None,
1474
1679
  help="Resize only the displayed image (0.5=smaller, 2=bigger). Default: auto-fit to terminal."
1475
1680
  )
1681
+ general.add_argument(
1682
+ "--gallery", nargs="?", const="4x4", metavar="GRID",
1683
+ help="Display all image files in a folder as thumbnails (e.g., --gallery 5x4). Incompatible files are skipped."
1684
+ )
1476
1685
 
1477
1686
  # Raster options
1478
- parser.add_argument(
1687
+ raster = parser.add_argument_group("Raster")
1688
+ raster.add_argument(
1479
1689
  "--band", type=int, default=None,
1480
- help="Band number to display (single raster case), or slice number for NetCDF."
1690
+ help="Band number to display (single raster), or slice number for NetCDF. (default: 1)"
1481
1691
  )
1482
- parser.add_argument(
1692
+ raster.add_argument(
1693
+ "--bands", type=str,
1694
+ help="Display multiple bands as a grid. Accepts ranges (30-40), lists (3,4,5), or mixed (1,3,10-15)."
1695
+ )
1696
+ raster.add_argument(
1483
1697
  "--timestep", type=int, default=None,
1484
- help="Alias for --band when working with NetCDF files (1-based index)."
1698
+ help="Alias for --band when working with NetCDF files."
1485
1699
  )
1486
- parser.add_argument(
1700
+ raster.add_argument(
1701
+ "--subset", type=int, default=None,
1702
+ help="Variable index for NetCDF/HDF files (e.g., --subset 1)."
1703
+ )
1704
+ raster.add_argument(
1705
+ "--reduce", dest="reduce_dim", type=str, default=None, metavar="DIM_NAME",
1706
+ help="For 3D NetCDF variables, specify which dimension to use as the band/slider axis. Auto-detected if omitted."
1707
+ )
1708
+ raster.add_argument(
1487
1709
  "--colormap", nargs="?", const="terrain",
1488
1710
  choices=AVAILABLE_COLORMAPS, default=None,
1489
- help="Apply colormap to single-band rasters or vector coloring. Flag without value → 'terrain'."
1711
+ help="Apply colormap to single-band rasters. Flag without value → 'terrain'."
1490
1712
  )
1491
- parser.add_argument(
1492
- "--rgb", nargs=3, type=int, metavar=('R', 'G', 'B'), default=None,
1493
- help="Three band numbers for RGB display (e.g., --rgb 4 3 2). Overrides default 1 2 3."
1713
+ raster.add_argument(
1714
+ "--rgb", nargs='+', type=str, metavar='BAND', default=None,
1715
+ help="Three band numbers for RGB display (e.g., --rgb 4 3 2 or --rgb 4,3,2). Overrides default 1 2 3."
1494
1716
  )
1495
- parser.add_argument(
1717
+ raster.add_argument(
1496
1718
  "--rgbfiles", nargs=3, type=str, metavar=('R', 'G', 'B'),
1497
- 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."
1719
+ help="Three single-band rasters for RGB composite. Can also provide as positional arguments."
1498
1720
  )
1499
- parser.add_argument(
1721
+ raster.add_argument(
1500
1722
  "--vmin", type=float, default=None,
1501
1723
  help="Minimum pixel value for raster display scaling."
1502
1724
  )
1503
- parser.add_argument(
1725
+ raster.add_argument(
1504
1726
  "--vmax", type=float, default=None,
1505
1727
  help="Maximum pixel value for raster display scaling."
1506
1728
  )
1507
- parser.add_argument(
1729
+ raster.add_argument(
1508
1730
  "--nodata", type=float, default=None,
1509
1731
  help="Override nodata value for rasters if dataset metadata is missing or incorrect."
1510
1732
  )
1511
- parser.add_argument(
1512
- "--gallery", nargs="?", const="4x4", metavar="GRID",
1513
- help="Display all PNG/JPG/TIF images in a folder as thumbnails (e.g., 5x5 grid)."
1514
- )
1515
- parser.add_argument(
1516
- "--subset", type=int, default=None,
1517
- help="Variable index for NetCDF files (e.g. --subset 1)."
1733
+
1734
+ # Vector options
1735
+ vector = parser.add_argument_group("Vector")
1736
+ vector.add_argument(
1737
+ "--color-by", type=str, default=None,
1738
+ help="Numeric column to color vector features by."
1518
1739
  )
1519
- parser.add_argument(
1520
- "--reduce", dest="reduce_dim", type=str, default=None,
1521
- metavar="DIM_NAME",
1522
- help="For 3D NetCDF variables, specify which dimension to use as the band axis (auto-detected if omitted)."
1740
+ vector.add_argument(
1741
+ "--width", type=float, default=0.7,
1742
+ help="Line width for vector boundaries."
1743
+ )
1744
+ vector.add_argument(
1745
+ "--edgecolor", type=str, default="#F6FF00",
1746
+ help="Edge color for vector outlines (hex or named color)."
1747
+ )
1748
+ vector.add_argument(
1749
+ "--layer", type=str, default=None,
1750
+ help="Layer name for GeoPackage/multi-layer files, or variable name for NetCDF files."
1751
+ )
1752
+ vector.add_argument(
1753
+ "--table", action="store_true",
1754
+ help="Display vector/parquet file as tabular data instead of rendering geometry."
1523
1755
  )
1524
1756
 
1525
- # CSV options
1526
- parser.add_argument(
1527
- "--hist",
1528
- nargs="?",
1529
- const=True,
1530
- help="Show histograms for all numeric columns or specify one column name."
1757
+ # Tabular options
1758
+ tabular = parser.add_argument_group("Tabular")
1759
+ tabular.add_argument(
1760
+ "--hist", nargs="?", const=True,
1761
+ help="Show histograms for all numeric columns or specify one column name."
1531
1762
  )
1532
- parser.add_argument(
1533
- "--describe",
1534
- nargs="?",
1535
- const=True,
1763
+ tabular.add_argument(
1764
+ "--describe", nargs="?", const=True,
1536
1765
  help="Show summary statistics for all numeric columns or specify one column name."
1537
1766
  )
1538
- parser.add_argument(
1539
- "--bins", type=int, default=20,
1540
- help="Number of bins for CSV histograms (used with --hist)."
1767
+ tabular.add_argument(
1768
+ "--bins", type=int, default=20,
1769
+ help="Number of bins for histograms (used with --hist)."
1541
1770
  )
1542
- parser.add_argument(
1543
- "--scatter", nargs=2, metavar=("X", "Y"),
1544
- help="Plot scatter of two numeric CSV columns (e.g. --scatter area_km2 year)."
1771
+ tabular.add_argument(
1772
+ "--scatter", nargs=2, metavar=("X", "Y"),
1773
+ help="Scatter plot of two numeric columns (e.g. --scatter area_km2 year)."
1545
1774
  )
1546
- parser.add_argument(
1547
- "--unique",
1548
- metavar="COLUMN",
1549
- help="Show unique values for a categorical column and exit"
1775
+ tabular.add_argument(
1776
+ "--unique", metavar="COLUMN",
1777
+ help="Show unique values for a categorical column."
1550
1778
  )
1551
- parser.add_argument(
1552
- "--where",
1553
- type=str,
1554
- default=None,
1555
- help="Filter rows using SQL WHERE clause (DuckDB required). Example: --where \"year > 2010\""
1779
+ tabular.add_argument(
1780
+ "--where", type=str, default=None,
1781
+ help="Filter rows using SQL WHERE clause (e.g. --where \"year > 2010\")."
1556
1782
  )
1557
- parser.add_argument(
1558
- "--sort",
1559
- type=str,
1560
- default=None,
1561
- help="Sort rows by values in the specified column, ascending by default (e.g. --sort population). Use --desc to reverse."
1783
+ tabular.add_argument(
1784
+ "--sort", type=str, default=None,
1785
+ help="Sort rows by column, ascending by default. Use --desc to reverse."
1562
1786
  )
1563
- parser.add_argument(
1564
- "--desc",
1565
- action="store_true",
1787
+ tabular.add_argument(
1788
+ "--desc", action="store_true",
1566
1789
  help="Sort in descending order."
1567
1790
  )
1568
- parser.add_argument(
1569
- "--limit",
1570
- type=int,
1571
- default=None,
1791
+ tabular.add_argument(
1792
+ "--limit", type=int, default=None,
1572
1793
  help="Limit number of rows shown (e.g. --limit 100)."
1573
1794
  )
1574
- parser.add_argument(
1575
- "--select",
1576
- nargs="+",
1577
- help="Select specific columns (space separated) (e.g. --select Country City)"
1578
- )
1579
- parser.add_argument(
1580
- "--sql",
1581
- type=str,
1582
- help="Execute full DuckDB SQL query against CSV (advanced mode)."
1583
- )
1584
-
1585
- # Vector options
1586
- parser.add_argument(
1587
- "--color-by", type=str, default=None,
1588
- help="Numeric column to color vector features by (optional)."
1589
- )
1590
- parser.add_argument(
1591
- "--width", type=float, default=0.7,
1592
- help="Line width for vector boundaries"
1593
- )
1594
- parser.add_argument(
1595
- "--edgecolor", type=str, default="#F6FF00",
1596
- help="Edge color for vector outlines (hex or named color)."
1795
+ tabular.add_argument(
1796
+ "--select", nargs="+",
1797
+ help="Select specific columns (e.g. --select Country City)."
1597
1798
  )
1598
- parser.add_argument(
1599
- "--layer", type=str, default=None,
1600
- # help="Layer name for GeoPackage or multi-layer files."
1601
- help="Layer name for GeoPackage/multi-layer files, or variable name for NetCDF files."
1799
+ tabular.add_argument(
1800
+ "--sql", type=str,
1801
+ help="Execute full DuckDB SQL query against CSV/parquet (advanced mode)."
1602
1802
  )
1603
- parser.add_argument(
1604
- "--table", action="store_true",
1605
- help="Display vector/parquet file as tabular data instead of rendering geometry."
1606
- )
1607
1803
 
1608
1804
  parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
1609
1805
 
File without changes
File without changes