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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: viewinline
3
- Version: 0.2.3
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
  [![Python version](https://img.shields.io/badge/python-%3E%3D3.9-blue.svg)](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 and no temporary files.
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
- - Variables with additional dimensions (e.g., vertical levels) may be listed but will fail to display with a clear error message
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 (common in MODIS data)
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
  [![Python version](https://img.shields.io/badge/python-%3E%3D3.9-blue.svg)](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 and no temporary files.
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
- - Variables with additional dimensions (e.g., vertical levels) may be listed but will fail to display with a clear error message
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 (common in MODIS data)
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "viewinline"
7
- version = "0.2.3"
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" }
@@ -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.2.3"
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] Image rendered")
230
+ print("[VIEW] Inline render complete")
231
231
  elif is_chafa_available():
232
- print("[VIEW] Rendered via chafa")
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"[PROC] 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.")
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
- # MULTI BAND RGB (skip for NetCDF - treat as slices/timesteps, not RGB)
747
- if band_count >= 3 and not paths[0].lower().endswith('.nc'):
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
- band_idx = max(0, min(args.band - 1, band_count - 1))
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] Raster rendering failed: {e}")
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 f.lower().endswith(exts)]
1094
+ if os.path.isfile(os.path.join(folder, f))]
873
1095
  if not files:
874
- print(f"[WARN] No image/raster files found in {folder}")
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"[WARN] Skipped {os.path.basename(f)} ({e})")
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=1,
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
- "--rgbfiles", nargs=3, type=str, metavar=('R', 'G', 'B'),
1345
- 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."
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