mvos-hsi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: mvos_hsi
3
+ Version: 0.1.0
4
+ Summary: Hyperspectral imagery (HSI) preprocessing utilities for Machine Learning applications
5
+ Author: Moon Kim, Thomas Burks, Jianwei Qin
6
+ Author-email: Rishik Aggarwal <rishik.aggarwal@sdstate.edu>, Pappu Kumar Yadav <pappu.yadav@sdstate.edu>
7
+ License: MIT
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: numpy
11
+ Requires-Dist: scipy
12
+ Requires-Dist: spectral
13
+ Requires-Dist: scikit-image
14
+ Requires-Dist: opencv-python
15
+ Requires-Dist: imgaug
16
+ Requires-Dist: matplotlib
17
+
18
+ # mvos_hsi
19
+
20
+ **MVOS Hyperspectral Imaging Utilities**
21
+ Python package for calibration, clipping, and augmentation of hyperspectral leaf images, ported from MATLAB scripts.
22
+
23
+ ---
24
+
25
+ ## Features
26
+ - **Calibration**
27
+ - Single image or batch folder calibration
28
+ - Dark and white reference correction
29
+ - Spectral binning & spatial binning options
30
+ - Outputs calibrated reflectance and fluorescence cubes
31
+ - Saves calibrated cubes as `.mat` files (`*_R.mat`, `*_F.mat`)
32
+
33
+ - **Clipping**
34
+ - Detect leaves using vegetation indices:
35
+ - NDVI (Normalized Difference Vegetation Index)
36
+ - CI-RedEdge (Chlorophyll Index Red-Edge)
37
+ - GCI (Green Chlorophyll Index)
38
+ - Thresholding via Otsu (auto) or manual (e.g., NDVI > 0.45)
39
+ - Flexible cropping:
40
+ - Square windows (fixed size, e.g., 30×30 pixels)
41
+ - Tight bounding boxes fit to each leaf
42
+ - Saves clipped cubes as ENVI files (`.hdr` + `.img`) in `clipped_hypercubes/`
43
+
44
+ - **Augmentation**
45
+ - Apply data augmentation to calibrated or clipped cubes
46
+ - Options:
47
+ - Per image (single `.mat` or `.hdr`)
48
+ - Per folder
49
+ - Per class (each subfolder = class label)
50
+ - User-defined number of augmentations
51
+ - Saves augmented cubes in `augmented_hypercubes/`
52
+
53
+ ---
54
+
55
+ ## Installation
56
+ Clone this repository and install in **editable mode**:
57
+
58
+ ```bash
59
+ conda activate hyperspectral
60
+ pip install -e .
@@ -0,0 +1,43 @@
1
+ # mvos_hsi
2
+
3
+ **MVOS Hyperspectral Imaging Utilities**
4
+ Python package for calibration, clipping, and augmentation of hyperspectral leaf images, ported from MATLAB scripts.
5
+
6
+ ---
7
+
8
+ ## Features
9
+ - **Calibration**
10
+ - Single image or batch folder calibration
11
+ - Dark and white reference correction
12
+ - Spectral binning & spatial binning options
13
+ - Outputs calibrated reflectance and fluorescence cubes
14
+ - Saves calibrated cubes as `.mat` files (`*_R.mat`, `*_F.mat`)
15
+
16
+ - **Clipping**
17
+ - Detect leaves using vegetation indices:
18
+ - NDVI (Normalized Difference Vegetation Index)
19
+ - CI-RedEdge (Chlorophyll Index Red-Edge)
20
+ - GCI (Green Chlorophyll Index)
21
+ - Thresholding via Otsu (auto) or manual (e.g., NDVI > 0.45)
22
+ - Flexible cropping:
23
+ - Square windows (fixed size, e.g., 30×30 pixels)
24
+ - Tight bounding boxes fit to each leaf
25
+ - Saves clipped cubes as ENVI files (`.hdr` + `.img`) in `clipped_hypercubes/`
26
+
27
+ - **Augmentation**
28
+ - Apply data augmentation to calibrated or clipped cubes
29
+ - Options:
30
+ - Per image (single `.mat` or `.hdr`)
31
+ - Per folder
32
+ - Per class (each subfolder = class label)
33
+ - User-defined number of augmentations
34
+ - Saves augmented cubes in `augmented_hypercubes/`
35
+
36
+ ---
37
+
38
+ ## Installation
39
+ Clone this repository and install in **editable mode**:
40
+
41
+ ```bash
42
+ conda activate hyperspectral
43
+ pip install -e .
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mvos_hsi"
7
+ version = "0.1.0"
8
+ description = "Hyperspectral imagery (HSI) preprocessing utilities for Machine Learning applications"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [{ name = "Rishik Aggarwal", email = "rishik.aggarwal@sdstate.edu" },
12
+ { name = "Pappu Kumar Yadav", email = "pappu.yadav@sdstate.edu" },{name = "Moon Kim"}, {name = "Thomas Burks"}, {name = "Jianwei Qin"}]
13
+ license = { text = "MIT" }
14
+ dependencies = [
15
+ "numpy",
16
+ "scipy",
17
+ "spectral",
18
+ "scikit-image",
19
+ "opencv-python",
20
+ "imgaug",
21
+ "matplotlib"
22
+ ]
23
+
24
+ [project.scripts]
25
+ mvos-hsi = "mvos_hsi.calibration:main"
26
+ mvos-hsi-clip = "mvos_hsi.clipping:main"
27
+ mvos-hsi-augment = "mvos_hsi.augmentation:main"
28
+ mvos-hsi-plot = "mvos_hsi.plotting:main"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ from .calibration import calibrate, calibrate_folder
2
+ from .clipping import clip_sample, clip_folder
3
+ from .augmentation import augment_image, augment_folder, augment_classes
4
+ from .plotting import (
5
+ plot_pixel, plot_roi, plot_leaf_center, plot_leaf_centers_multi, load_wavelengths
6
+ )
7
+
8
+ __all__ = [
9
+ "calibrate", "calibrate_folder",
10
+ "clip_sample", "clip_folder",
11
+ "augment_image", "augment_folder", "augment_classes",
12
+ "plot_pixel", "plot_roi", "plot_leaf_center", "plot_leaf_centers_multi", "load_wavelengths",
13
+ ]
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+ import os, glob
3
+ from typing import Optional, List
4
+ import numpy as np
5
+ import scipy.io as sio
6
+ from spectral import envi, open_image
7
+ from imgaug import augmenters as iaa
8
+
9
+ # -------- helpers --------
10
+ def _ensure_outdir(path: str):
11
+ os.makedirs(path, exist_ok=True)
12
+
13
+ def _build_aug_seq(
14
+ flip: bool = False,
15
+ rotate: Optional[tuple] = None,
16
+ shear: Optional[tuple] = None,
17
+ scale: Optional[tuple] = None,
18
+ ) -> iaa.Sequential:
19
+ """
20
+ Build augmentation pipeline based on user choices.
21
+ - flip: horizontal flip with p=0.5
22
+ - rotate: (min_deg, max_deg)
23
+ - shear: (min_deg, max_deg)
24
+ - scale: (min_factor, max_factor)
25
+ """
26
+ augs = []
27
+ if flip:
28
+ augs.append(iaa.Fliplr(0.5))
29
+ if rotate is not None:
30
+ augs.append(iaa.Affine(rotate=rotate))
31
+ if shear is not None:
32
+ augs.append(iaa.Affine(shear=shear))
33
+ if scale is not None:
34
+ augs.append(iaa.Affine(scale=scale))
35
+ if not augs:
36
+ raise ValueError("No augmentation selected. Please specify at least one.")
37
+ return iaa.Sequential(augs)
38
+
39
+ def _augment_cube(cube: np.ndarray, aug_seq) -> np.ndarray:
40
+ # imgaug expects (H,W,C)
41
+ return aug_seq.augment_image(cube)
42
+
43
+ def _save_augmented_mat(cube: np.ndarray, out_path: str):
44
+ sio.savemat(out_path, {"R_Leaf": cube})
45
+
46
+ def _save_augmented_envi(cube: np.ndarray, out_stem: str):
47
+ meta = {
48
+ "description": "Augmented hyperspectral cube",
49
+ "samples": cube.shape[1],
50
+ "lines": cube.shape[0],
51
+ "bands": cube.shape[2],
52
+ "data type": 4, # float32
53
+ "interleave": "bil",
54
+ }
55
+ envi.save_image(out_stem + ".hdr", cube.astype(np.float32), metadata=meta)
56
+
57
+ # -------- core functions --------
58
+ def augment_image(
59
+ path: str,
60
+ num_aug: int,
61
+ outdir: Optional[str] = None,
62
+ *,
63
+ flip: bool = False,
64
+ rotate: Optional[tuple] = None,
65
+ shear: Optional[tuple] = None,
66
+ scale: Optional[tuple] = None,
67
+ ) -> List[str]:
68
+ """
69
+ Augment one hyperspectral cube (.mat or ENVI .hdr).
70
+ User chooses which augmentations to apply.
71
+ """
72
+ aug_seq = _build_aug_seq(flip=flip, rotate=rotate, shear=shear, scale=scale)
73
+
74
+ if not os.path.exists(path):
75
+ raise FileNotFoundError(path)
76
+
77
+ if outdir is None:
78
+ outdir = os.path.join(os.path.dirname(path), "augmented_hypercubes")
79
+ _ensure_outdir(outdir)
80
+
81
+ base = os.path.splitext(os.path.basename(path))[0]
82
+
83
+ # load cube
84
+ if path.lower().endswith(".mat"):
85
+ m = sio.loadmat(path)
86
+ if "R_plant" in m: cube = m["R_plant"]
87
+ elif "R_Leaf" in m: cube = m["R_Leaf"]
88
+ else: raise KeyError("MAT must contain R_plant or R_Leaf.")
89
+ save_fn = lambda arr, stem: _save_augmented_mat(arr, os.path.join(outdir, stem + ".mat"))
90
+ elif path.lower().endswith(".hdr"):
91
+ cube = open_image(path).load().astype(np.float32)
92
+ save_fn = lambda arr, stem: _save_augmented_envi(arr, os.path.join(outdir, stem))
93
+ else:
94
+ raise ValueError("Unsupported file type: must be .mat or .hdr")
95
+
96
+ saved = []
97
+ for i in range(num_aug):
98
+ aug = _augment_cube(cube.copy(), aug_seq)
99
+ stem = f"{base}_aug{i+1}"
100
+ save_fn(aug, stem)
101
+ saved.append(stem)
102
+ return saved
103
+
104
+ def augment_folder(
105
+ folder: str,
106
+ num_aug: int,
107
+ outdir: Optional[str] = None,
108
+ **kwargs
109
+ ) -> dict:
110
+ """
111
+ Augment all .mat/.hdr cubes in a folder with chosen augmentations.
112
+ kwargs = flip, rotate, shear, scale
113
+ """
114
+ files = glob.glob(os.path.join(folder, "*.mat")) + glob.glob(os.path.join(folder, "*.hdr"))
115
+ results = {}
116
+ for f in files:
117
+ results[f] = augment_image(f, num_aug, outdir=outdir, **kwargs)
118
+ return results
119
+
120
+ def augment_classes(
121
+ root: str,
122
+ num_aug: int,
123
+ outdir: Optional[str] = None,
124
+ **kwargs
125
+ ) -> dict:
126
+ """
127
+ Augment each class subfolder separately (root/classX/*.mat or *.hdr).
128
+ kwargs = flip, rotate, shear, scale
129
+ """
130
+ results = {}
131
+ for sub in sorted(os.listdir(root)):
132
+ subpath = os.path.join(root, sub)
133
+ if os.path.isdir(subpath):
134
+ results[sub] = augment_folder(
135
+ subpath, num_aug, outdir=(outdir or os.path.join(subpath, "augmented_hypercubes")), **kwargs
136
+ )
137
+ return results
138
+
139
+ # -------- CLI --------
140
+ def main(argv: Optional[List[str]] = None):
141
+ import argparse, json
142
+ p = argparse.ArgumentParser(prog="mvos-hsi-augment", description="Augment hyperspectral cubes")
143
+ sub = p.add_subparsers(dest="cmd", required=True)
144
+
145
+ def add_aug_flags(sp):
146
+ sp.add_argument("--flip", action="store_true", help="Enable horizontal flip (p=0.5)")
147
+ sp.add_argument("--rotate", nargs=2, type=int, help="Rotation range, e.g. --rotate -10 10")
148
+ sp.add_argument("--shear", nargs=2, type=int, help="Shear range, e.g. --shear -16 16")
149
+ sp.add_argument("--scale", nargs=2, type=float, help="Scale range, e.g. --scale 0.9 1.1")
150
+
151
+ s1 = sub.add_parser("image", help="Augment one image")
152
+ s1.add_argument("--path", required=True)
153
+ s1.add_argument("--num", type=int, required=True)
154
+ s1.add_argument("--outdir")
155
+ add_aug_flags(s1)
156
+
157
+ s2 = sub.add_parser("folder", help="Augment all cubes in a folder")
158
+ s2.add_argument("--folder", required=True)
159
+ s2.add_argument("--num", type=int, required=True)
160
+ s2.add_argument("--outdir")
161
+ add_aug_flags(s2)
162
+
163
+ s3 = sub.add_parser("classes", help="Augment each class subfolder")
164
+ s3.add_argument("--root", required=True)
165
+ s3.add_argument("--num", type=int, required=True)
166
+ s3.add_argument("--outdir")
167
+ add_aug_flags(s3)
168
+
169
+ args = p.parse_args(argv)
170
+
171
+ kwargs = {
172
+ "flip": args.flip,
173
+ "rotate": tuple(args.rotate) if args.rotate else None,
174
+ "shear": tuple(args.shear) if args.shear else None,
175
+ "scale": tuple(args.scale) if args.scale else None,
176
+ }
177
+
178
+ if args.cmd == "image":
179
+ res = augment_image(args.path, args.num, outdir=args.outdir, **kwargs)
180
+ print(json.dumps({"saved": res}, indent=2))
181
+ elif args.cmd == "folder":
182
+ res = augment_folder(args.folder, args.num, outdir=args.outdir, **kwargs)
183
+ print(json.dumps(res, indent=2))
184
+ elif args.cmd == "classes":
185
+ res = augment_classes(args.root, args.num, outdir=args.outdir, **kwargs)
186
+ print(json.dumps(res, indent=2))
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+ import os, glob, re
3
+ from typing import Dict, Optional, List
4
+ import numpy as np
5
+
6
+ from spectral import envi, io
7
+ from skimage.filters import threshold_multiotsu
8
+ from skimage.morphology import remove_small_objects
9
+ from scipy.ndimage import binary_fill_holes
10
+ from scipy.io import savemat
11
+
12
+ # ----- camera / pipeline defaults (mirrors your MATLAB) -----
13
+ N_LINE_SAMPLE = 250
14
+ N_LINE_DARK = 20
15
+ N_SAMPLE = 810
16
+ N_BAND = 348
17
+
18
+ DEFAULT_SPECTRAL_BIN = 3
19
+ DEFAULT_SPATIAL_BIN = 3
20
+
21
+ # Index windows (MATLAB -> Python slices)
22
+ INDEX_WHITE = slice(20, 40) # 21:40
23
+ INDEX_IFOV = slice(10, 260) # 11:260 (columns)
24
+ INDEX_PLANT_R = slice(46, 246) # 47:246 (rows)
25
+ INDEX_PLANT_F_SHIFT = -2
26
+
27
+ # Bands used for mask/visuals after calibration
28
+ B815 = 80 # reflectance mask band
29
+ B685 = 56 # example F band (not required in outputs)
30
+
31
+ # -------- helpers --------
32
+ def _check_factor(k: int, name: str):
33
+ if not isinstance(k, int) or k <= 0:
34
+ raise ValueError(f"{name} must be a positive integer (got {k}).")
35
+
36
+ def _bin_spectral(arr: np.ndarray, k: int) -> np.ndarray:
37
+ b = arr.shape[-1]
38
+ if b % k != 0:
39
+ arr = arr[..., : (b // k) * k]
40
+ b = arr.shape[-1]
41
+ return arr.reshape(*arr.shape[:-1], b // k, k).mean(axis=-1)
42
+
43
+ def _bin_spatial_along_samples(arr: np.ndarray, k: int) -> np.ndarray:
44
+ s = arr.shape[1]
45
+ if s % k != 0:
46
+ arr = arr[:, : (s // k) * k, :]
47
+ s = arr.shape[1]
48
+ return arr.reshape(arr.shape[0], s // k, k, arr.shape[2]).mean(axis=2)
49
+
50
+ def _rotate(arr: np.ndarray, k: int) -> np.ndarray:
51
+ # numpy.rot90 CCW; MATLAB used +90 for R and -90 for F
52
+ if k == 1:
53
+ return np.rot90(arr, k=1, axes=(0, 1))
54
+ elif k == -1:
55
+ return np.rot90(arr, k=3, axes=(0, 1))
56
+ raise ValueError("k must be 1 or -1.")
57
+
58
+ def _imquantize_mask(img: np.ndarray) -> np.ndarray:
59
+ ths = threshold_multiotsu(img, classes=2)
60
+ mask = (img >= ths[0])
61
+ mask = remove_small_objects(mask, 100, connectivity=2)
62
+ mask = binary_fill_holes(mask)
63
+ return mask
64
+
65
+ def _extract_masked_spectra(cube: np.ndarray, mask: np.ndarray) -> np.ndarray:
66
+ h, w, b = cube.shape
67
+ flat = cube.reshape(h * w, b)
68
+ flat_mask = mask.reshape(h * w)
69
+ return flat[flat_mask]
70
+
71
+ # case-insensitive _R/_F + .hdr/.HDR + .img/.IMG
72
+ def _read_envi_cube(base: str, suffix: str) -> np.ndarray:
73
+ hdr_candidates = [f"{base}_{suffix}.hdr", f"{base}_{suffix}.HDR"]
74
+ for hdr in hdr_candidates:
75
+ if os.path.exists(hdr):
76
+ ds = envi.open(hdr)
77
+ return np.asarray(ds.load())
78
+
79
+ img_candidates = [f"{base}_{suffix}.img", f"{base}_{suffix}.IMG"]
80
+ for img in img_candidates:
81
+ if os.path.exists(img):
82
+ shape = (N_LINE_SAMPLE if suffix.upper() == 'R' else N_LINE_DARK, N_SAMPLE, N_BAND)
83
+ arr = io.bilopen(img, shape=shape, dtype=np.int16, byteorder='<').load()
84
+ return np.asarray(arr)
85
+
86
+ raise FileNotFoundError(f"Could not find any of {hdr_candidates + img_candidates}")
87
+
88
+ _BASE_RE = re.compile(r'_(R|F)\.(hdr|img)$', flags=re.IGNORECASE)
89
+ def _strip_base(path: str) -> str:
90
+ return _BASE_RE.sub('', path)
91
+
92
+ # -------- core calibration --------
93
+ def _calibrate_pair(
94
+ sample_base: str,
95
+ dark_base: str,
96
+ spectral_bin: int = DEFAULT_SPECTRAL_BIN,
97
+ spatial_bin: int = DEFAULT_SPATIAL_BIN,
98
+ ) -> Dict[str, np.ndarray]:
99
+ _check_factor(spectral_bin, "spectral_bin")
100
+ _check_factor(spatial_bin, "spatial_bin")
101
+
102
+ # Read cubes
103
+ IR_sample = _read_envi_cube(sample_base, "R")
104
+ IF_sample = _read_envi_cube(sample_base, "F")
105
+ IR_dark = _read_envi_cube(dark_base, "R")
106
+ IF_dark = _read_envi_cube(dark_base, "F")
107
+
108
+ # Binning
109
+ IR_sample = _bin_spectral(IR_sample, spectral_bin)
110
+ IF_sample = _bin_spectral(IF_sample, spectral_bin)
111
+ IR_dark = _bin_spectral(IR_dark, spectral_bin)
112
+ IF_dark = _bin_spectral(IF_dark, spectral_bin)
113
+
114
+ IR_sample = _bin_spatial_along_samples(IR_sample, spatial_bin)
115
+ IF_sample = _bin_spatial_along_samples(IF_sample, spatial_bin)
116
+ IR_dark = _bin_spatial_along_samples(IR_dark, spatial_bin)
117
+ IF_dark = _bin_spatial_along_samples(IF_dark, spatial_bin)
118
+
119
+ # IFOV crop (cols)
120
+ IR_sample = IR_sample[:, INDEX_IFOV, :]
121
+ IF_sample = IF_sample[:, INDEX_IFOV, :]
122
+ IR_dark = IR_dark[:, INDEX_IFOV, :]
123
+ IF_dark = IF_dark[:, INDEX_IFOV, :]
124
+
125
+ # dark averages over lines
126
+ IR_dark_mean = IR_dark.mean(axis=0, keepdims=True).repeat(IR_sample.shape[0], axis=0)
127
+ IF_dark_mean = IF_dark.mean(axis=0, keepdims=True).repeat(IF_sample.shape[0], axis=0)
128
+
129
+ # white (lines 21–40) from sample R
130
+ I_white = IR_sample[INDEX_WHITE, :, :].mean(axis=0, keepdims=True).repeat(IR_sample.shape[0], axis=0)
131
+
132
+ # flat-field reflectance + fluorescence
133
+ with np.errstate(divide='ignore', invalid='ignore'):
134
+ R_sample = (IR_sample - IR_dark_mean) / (I_white - IR_dark_mean)
135
+ R_sample = np.nan_to_num(R_sample, nan=0.0, posinf=0.0, neginf=0.0)
136
+ F_sample = (IF_sample - IF_dark_mean)
137
+
138
+ # rotations
139
+ R_sample_rot = _rotate(R_sample, k=1)
140
+ F_sample_rot = _rotate(F_sample, k=-1)
141
+
142
+ # plant crops
143
+ R_plant = R_sample_rot[:, INDEX_PLANT_R, :]
144
+ idx_f_start = INDEX_PLANT_R.start + INDEX_PLANT_F_SHIFT
145
+ idx_f_stop = INDEX_PLANT_R.stop + INDEX_PLANT_F_SHIFT
146
+ F_plant = F_sample_rot[:, slice(idx_f_start, idx_f_stop), :]
147
+
148
+ # mask from R@815
149
+ R815 = R_plant[:, :, B815]
150
+ M_plant = _imquantize_mask(R815)
151
+
152
+ # abnormal reflectance removal
153
+ bad = (R_plant.min(axis=2) <= 0) | (R815 < 0.2)
154
+ R_mask = M_plant & (~bad)
155
+ F_mask = M_plant # keep same as MATLAB-style (F abnormal was commented)
156
+
157
+ # spectra
158
+ RS_plant = _extract_masked_spectra(R_plant, R_mask)
159
+ FS_plant = _extract_masked_spectra(F_plant, F_mask)
160
+
161
+ return {
162
+ "R_plant": R_plant.astype(np.float32),
163
+ "F_plant": F_plant.astype(np.float32),
164
+ "M_plant": M_plant.astype(bool),
165
+ "RS_plant": RS_plant.astype(np.float32),
166
+ "FS_plant": FS_plant.astype(np.float32),
167
+ }
168
+
169
+ # -------- public api --------
170
+ def calibrate(
171
+ sample_base: str,
172
+ dark_base: str,
173
+ spectral_bin: int = DEFAULT_SPECTRAL_BIN,
174
+ spatial_bin: int = DEFAULT_SPATIAL_BIN,
175
+ ) -> Dict[str, np.ndarray]:
176
+ return _calibrate_pair(sample_base, dark_base, spectral_bin, spatial_bin)
177
+
178
+ def calibrate_folder(
179
+ folder: str,
180
+ dark_base: str,
181
+ spectral_bin: int = DEFAULT_SPECTRAL_BIN,
182
+ spatial_bin: int = DEFAULT_SPATIAL_BIN,
183
+ pattern: str = "*_R.*",
184
+ ) -> List[Dict[str, np.ndarray]]:
185
+ _check_factor(spectral_bin, "spectral_bin")
186
+ _check_factor(spatial_bin, "spatial_bin")
187
+
188
+ # gather R candidates, case-insensitive
189
+ candidates = set()
190
+ for p in glob.glob(os.path.join(folder, "*_R.*")): candidates.add(p)
191
+ for p in glob.glob(os.path.join(folder, "*_r.*")): candidates.add(p)
192
+
193
+ norm_dark = os.path.normcase(_strip_base(dark_base))
194
+ results = []
195
+
196
+ for rfile in sorted(candidates):
197
+ base = _strip_base(rfile)
198
+ if os.path.normcase(base) == norm_dark:
199
+ continue # skip dark
200
+
201
+ out = _calibrate_pair(base, dark_base, spectral_bin, spatial_bin)
202
+
203
+ # save calibrated cubes
204
+ savemat(base + "_R.mat", {"R_plant": out["R_plant"]})
205
+ savemat(base + "_F.mat", {"F_plant": out["F_plant"]})
206
+
207
+ results.append(out)
208
+ return results
209
+
210
+ # -------- cli --------
211
+ def main(argv: Optional[List[str]] = None):
212
+ import argparse
213
+ p = argparse.ArgumentParser(prog="mvos-hsi", description="MVOS HSI calibration")
214
+ sub = p.add_subparsers(dest="cmd", required=True)
215
+
216
+ s1 = sub.add_parser("calibrate", help="Calibrate one sample")
217
+ s1.add_argument("--sample", required=True, help="base path without _R/_F")
218
+ s1.add_argument("--dark", required=True, help="dark base without _R/_F")
219
+ s1.add_argument("--k", type=int, default=DEFAULT_SPECTRAL_BIN, help="spectral bin factor (>=1)")
220
+ s1.add_argument("--spatial", type=int, default=DEFAULT_SPATIAL_BIN, help="spatial bin factor (>=1)")
221
+
222
+ s2 = sub.add_parser("calibrate-folder", help="Calibrate all samples in a folder")
223
+ s2.add_argument("--folder", required=True)
224
+ s2.add_argument("--dark", required=True)
225
+ s2.add_argument("--k", type=int, default=DEFAULT_SPECTRAL_BIN)
226
+ s2.add_argument("--spatial", type=int, default=DEFAULT_SPATIAL_BIN)
227
+ s2.add_argument("--pattern", default="*_R.*", help="glob to find R files (case-insensitive handled)")
228
+
229
+ args = p.parse_args(argv)
230
+
231
+ if args.cmd == "calibrate":
232
+ if args.k == 0:
233
+ raise ValueError("spectral bin 0 is not allowed.")
234
+ out = calibrate(args.sample, args.dark, spectral_bin=args.k, spatial_bin=args.spatial)
235
+ # save outputs like folder mode
236
+ savemat(args.sample + "_R.mat", {"R_plant": out["R_plant"]})
237
+ savemat(args.sample + "_F.mat", {"F_plant": out["F_plant"]})
238
+ print(f"Calibrated and saved: {args.sample}_R.mat, {args.sample}_F.mat")
239
+
240
+ elif args.cmd == "calibrate-folder":
241
+ if args.k == 0:
242
+ raise ValueError("spectral bin 0 is not allowed.")
243
+ outs = calibrate_folder(args.folder, args.dark, spectral_bin=args.k, spatial_bin=args.spatial, pattern=args.pattern)
244
+ print(f"Calibrated {len(outs)} sample(s) and saved *_R.mat / *_F.mat")