viewinline 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: viewinline
3
- Version: 0.3.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
@@ -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,6 +216,7 @@ 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).
216
220
  --timestep INTEGER Alias for --band when working with NetCDF files.
217
221
  --subset INTEGER Variable index for NetCDF/HDF files (e.g., --subset 1).
218
222
  --reduce DIM_NAME For 3D NetCDF variables, specify which dimension to use as the band/slider axis. Auto-detected if omitted.
@@ -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,6 +183,7 @@ 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).
183
187
  --timestep INTEGER Alias for --band when working with NetCDF files.
184
188
  --subset INTEGER Variable index for NetCDF/HDF files (e.g., --subset 1).
185
189
  --reduce DIM_NAME For 3D NetCDF variables, specify which dimension to use as the band/slider axis. Auto-detected if omitted.
@@ -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.1"
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" }
@@ -40,7 +40,7 @@ warnings.filterwarnings("ignore", message="More than one layer found", category=
40
40
  warnings.filterwarnings("ignore", message="Dataset has no geotransform", category=UserWarning)
41
41
  warnings.filterwarnings("ignore", message="invalid scale_factor or add_offset attribute", category=UserWarning)
42
42
 
43
- __version__ = "0.3.0"
43
+ __version__ = "0.3.1"
44
44
 
45
45
  AVAILABLE_COLORMAPS = [
46
46
  "viridis", "inferno", "magma", "plasma",
@@ -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
  # ---------------------------------------------------------------------
@@ -737,6 +755,28 @@ def render_netcdf_via_netcdf4(path, args):
737
755
  band_count = var.shape[spectral_axis]
738
756
  band_num = args.band if args.band is not None else 1
739
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
+
740
780
  slicer = [slice(None)] * 3
741
781
  slicer[spectral_axis] = band_idx
742
782
  data = np.asarray(var[tuple(slicer)], dtype=np.float64)
@@ -793,9 +833,6 @@ def render_netcdf_via_netcdf4(path, args):
793
833
  new_w, new_h = max(1, int(W * args.display)), max(1, int(H * args.display))
794
834
  img = np.array(Image.fromarray(img).resize((new_w, new_h), Image.BILINEAR))
795
835
  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
836
  else:
800
837
  max_dim = 2000
801
838
  if max(img.shape[:2]) > max_dim:
@@ -803,7 +840,7 @@ def render_netcdf_via_netcdf4(path, args):
803
840
  new_w = int(img.shape[1] * scale)
804
841
  new_h = int(img.shape[0] * scale)
805
842
  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})")
843
+ print(f"[VIEW] Downsampled from {W}×{H}px to {new_w}×{new_h}px (display scale={scale:.2f})")
807
844
  print(f"[INFO] Use --display 1 for full resolution.")
808
845
  else:
809
846
  # (matches the width_pct logic in show_inline_image)
@@ -907,7 +944,8 @@ def render_raster(paths: list[str], args) -> None:
907
944
  resampling=rasterio.enums.Resampling.bilinear
908
945
  )
909
946
 
910
- print(f"[VIEW] Downsampled for preview → {out_w}×{out_h}px (scale={scale:.3f})")
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.")
911
949
  else:
912
950
  data = ds.read()
913
951
 
@@ -918,12 +956,18 @@ def render_raster(paths: list[str], args) -> None:
918
956
  else:
919
957
  print(f"[INFO] Multi-band raster detected ({band_count} bands)")
920
958
 
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
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
968
+
925
969
  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:
970
+ if band_count >= 3 and not paths[0].lower().endswith('.nc') and not user_specified_band and getattr(args, 'rgb', None):
927
971
 
928
972
  if getattr(args, "rgb", None):
929
973
  try:
@@ -1045,12 +1089,11 @@ def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector
1045
1089
  cols, rows = 4, 4
1046
1090
  nmax = cols * rows
1047
1091
 
1048
- # Collect files
1049
- exts = (".png", ".jpg", ".jpeg", ".tif", ".tiff")
1092
+ # Collect all files, let the loader decide what's valid
1050
1093
  files = [os.path.join(folder, f) for f in sorted(os.listdir(folder))
1051
- if f.lower().endswith(exts)]
1094
+ if os.path.isfile(os.path.join(folder, f))]
1052
1095
  if not files:
1053
- print(f"[WARN] No image/raster files found in {folder}")
1096
+ print(f"[WARN] No files found in {folder}")
1054
1097
  return
1055
1098
 
1056
1099
  files = files[:nmax]
@@ -1063,8 +1106,12 @@ def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector
1063
1106
  ext = os.path.splitext(f)[1].lower()
1064
1107
  if ext in [".tif", ".tiff"]:
1065
1108
  import rasterio
1109
+ from rasterio.enums import Resampling
1066
1110
  with rasterio.open(f) as ds:
1067
- arr = ds.read()
1111
+ arr = ds.read(
1112
+ out_shape=(ds.count, thumb_size[1], thumb_size[0]),
1113
+ resampling=Resampling.nearest
1114
+ )
1068
1115
  if arr.shape[0] >= 3:
1069
1116
  rgb = np.stack([normalize_to_uint8(arr[i]) for i in range(3)], axis=-1)
1070
1117
  else:
@@ -1075,8 +1122,9 @@ def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector
1075
1122
  img = Image.open(f).convert("RGB")
1076
1123
  img.thumbnail(thumb_size)
1077
1124
  thumbs.append(img)
1125
+
1078
1126
  except Exception as e:
1079
- print(f"[WARN] Skipped {os.path.basename(f)} ({e})")
1127
+ print(f"[SKIP] {os.path.basename(f)} — {e}")
1080
1128
 
1081
1129
  if not thumbs:
1082
1130
  print("[WARN] No valid images loaded.")
@@ -1104,6 +1152,87 @@ def render_gallery(folder: str, grid: str = "4x4", display_scale=None, is_vector
1104
1152
  except Exception as e:
1105
1153
  print(f"[ERROR] Failed to render gallery: {e}")
1106
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
+
1107
1236
  # ---------------------------------------------------------------------
1108
1237
  # Vector handling
1109
1238
  # ---------------------------------------------------------------------
@@ -1521,6 +1650,11 @@ def main() -> None:
1521
1650
  metavar="DIM_NAME",
1522
1651
  help="For 3D NetCDF variables, specify which dimension to use as the band axis (auto-detected if omitted)."
1523
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)."
1657
+ )
1524
1658
 
1525
1659
  # CSV options
1526
1660
  parser.add_argument(
File without changes
File without changes