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.
- mvos_hsi-0.1.0/PKG-INFO +60 -0
- mvos_hsi-0.1.0/README.md +43 -0
- mvos_hsi-0.1.0/pyproject.toml +31 -0
- mvos_hsi-0.1.0/setup.cfg +4 -0
- mvos_hsi-0.1.0/src/mvos_hsi/__init__.py +13 -0
- mvos_hsi-0.1.0/src/mvos_hsi/augmentation.py +186 -0
- mvos_hsi-0.1.0/src/mvos_hsi/calibration.py +244 -0
- mvos_hsi-0.1.0/src/mvos_hsi/clipping.py +354 -0
- mvos_hsi-0.1.0/src/mvos_hsi/plotting.py +415 -0
- mvos_hsi-0.1.0/src/mvos_hsi.egg-info/PKG-INFO +60 -0
- mvos_hsi-0.1.0/src/mvos_hsi.egg-info/SOURCES.txt +14 -0
- mvos_hsi-0.1.0/src/mvos_hsi.egg-info/dependency_links.txt +1 -0
- mvos_hsi-0.1.0/src/mvos_hsi.egg-info/entry_points.txt +5 -0
- mvos_hsi-0.1.0/src/mvos_hsi.egg-info/requires.txt +7 -0
- mvos_hsi-0.1.0/src/mvos_hsi.egg-info/top_level.txt +1 -0
- mvos_hsi-0.1.0/tests/test_smoke.py +2 -0
mvos_hsi-0.1.0/PKG-INFO
ADDED
|
@@ -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 .
|
mvos_hsi-0.1.0/README.md
ADDED
|
@@ -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"]
|
mvos_hsi-0.1.0/setup.cfg
ADDED
|
@@ -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")
|