hypmix 0.1.0__py3-none-any.whl
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.
- hypmix/__init__.py +15 -0
- hypmix/endmember.py +52 -0
- hypmix/io.py +164 -0
- hypmix/mixview/__init__.py +3 -0
- hypmix/mixview/main.py +101 -0
- hypmix/mixview/mixture_image_canvas.py +118 -0
- hypmix/mixview/pan_zoom_canvas.py +173 -0
- hypmix/mixview/residual_image_canvas.py +32 -0
- hypmix/mixview/utils.py +88 -0
- hypmix/model_math.py +89 -0
- hypmix/py.typed +0 -0
- hypmix/run_model.py +121 -0
- hypmix/typing.py +34 -0
- hypmix-0.1.0.dist-info/METADATA +44 -0
- hypmix-0.1.0.dist-info/RECORD +17 -0
- hypmix-0.1.0.dist-info/WHEEL +4 -0
- hypmix-0.1.0.dist-info/licenses/LICENSE +21 -0
hypmix/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .endmember import InSceneEndMember, ExternalEndMember, EndMember
|
|
2
|
+
from .io import ModelResult, save_model_result, load_model_result
|
|
3
|
+
from .run_model import MixtureModel
|
|
4
|
+
from .typing import Spectrum
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"InSceneEndMember",
|
|
8
|
+
"ExternalEndMember",
|
|
9
|
+
"EndMember",
|
|
10
|
+
"ModelResult",
|
|
11
|
+
"save_model_result",
|
|
12
|
+
"load_model_result",
|
|
13
|
+
"MixtureModel",
|
|
14
|
+
"Spectrum",
|
|
15
|
+
]
|
hypmix/endmember.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Standard Libraries
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Tuple
|
|
4
|
+
|
|
5
|
+
# Dependencies
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
# Relative Imports
|
|
9
|
+
from .typing import Spectrum, PathLike
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class EndMember:
|
|
14
|
+
name: str
|
|
15
|
+
spectrum: Spectrum
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class InSceneEndMember(EndMember):
|
|
20
|
+
coord: Tuple[int, int]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ExternalEndMember(EndMember):
|
|
25
|
+
file_path: PathLike
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EndMemberGroup:
|
|
29
|
+
def __init__(self, endmember_list: list[EndMember]):
|
|
30
|
+
self.endmember_list = endmember_list
|
|
31
|
+
self.endmember_array = np.empty(
|
|
32
|
+
(len(endmember_list[0].spectrum.data), len(endmember_list)),
|
|
33
|
+
dtype=np.float32,
|
|
34
|
+
)
|
|
35
|
+
self.endmember_name_list: list[str] = []
|
|
36
|
+
|
|
37
|
+
for n, i in enumerate(endmember_list):
|
|
38
|
+
self.endmember_array[:, n] = i.spectrum.data
|
|
39
|
+
self.endmember_name_list.append(i.name)
|
|
40
|
+
|
|
41
|
+
def __len__(self):
|
|
42
|
+
return len(self.endmember_name_list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_endmember_set(
|
|
46
|
+
names: list[str], arr: np.ndarray, wvl: np.ndarray
|
|
47
|
+
) -> list[EndMember]:
|
|
48
|
+
em_list: list[EndMember] = []
|
|
49
|
+
for n, name in enumerate(names):
|
|
50
|
+
spec = Spectrum(arr[n, :], wvl)
|
|
51
|
+
em_list.append(EndMember(name, spec))
|
|
52
|
+
return em_list
|
hypmix/io.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Standard Libraries
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
# Dependencies
|
|
8
|
+
import rasterio as rio # type: ignore
|
|
9
|
+
from rasterio.crs import CRS # type: ignore
|
|
10
|
+
import numpy.typing as npt
|
|
11
|
+
import h5py as h5 # type: ignore
|
|
12
|
+
|
|
13
|
+
# Relative Imports
|
|
14
|
+
from .typing import PathLike, Spectrum
|
|
15
|
+
from .endmember import EndMemberGroup, EndMember
|
|
16
|
+
from .model_math import UnMixedCube
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
type GeotransformType = tuple[float, float, float, float, float, float]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnsupportedFileExtensionError(Exception):
|
|
23
|
+
def __init__(self, message: str) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ModelResult:
|
|
29
|
+
savefile: PathLike
|
|
30
|
+
modelID: str
|
|
31
|
+
unmixed_image: UnMixedCube
|
|
32
|
+
endmembers: EndMemberGroup
|
|
33
|
+
rsquared: npt.NDArray
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SaveMode(Enum):
|
|
37
|
+
HDF5 = ".hdf5"
|
|
38
|
+
SMA = ".sma"
|
|
39
|
+
CSV = ".csv"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_model_result(res: ModelResult):
|
|
43
|
+
file_ext = Path(res.savefile).suffix
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
mode = SaveMode(file_ext)
|
|
47
|
+
except ValueError:
|
|
48
|
+
raise UnsupportedFileExtensionError(
|
|
49
|
+
f"{file_ext} is not a supported" "file type."
|
|
50
|
+
)
|
|
51
|
+
match mode:
|
|
52
|
+
case SaveMode.HDF5:
|
|
53
|
+
if Path(res.savefile).is_file():
|
|
54
|
+
open_flag = "r+"
|
|
55
|
+
else:
|
|
56
|
+
open_flag = "w"
|
|
57
|
+
|
|
58
|
+
with h5.File(res.savefile, open_flag) as f:
|
|
59
|
+
try:
|
|
60
|
+
g = f.create_group(res.modelID)
|
|
61
|
+
except ValueError:
|
|
62
|
+
del f[res.modelID]
|
|
63
|
+
g = f.create_group(res.modelID)
|
|
64
|
+
|
|
65
|
+
g.attrs["wavelengths"] = res.endmembers.endmember_list[
|
|
66
|
+
0
|
|
67
|
+
].spectrum.wvl
|
|
68
|
+
gg = g.create_group("endmembers")
|
|
69
|
+
for n, name in enumerate(res.endmembers.endmember_name_list):
|
|
70
|
+
gg.create_dataset(
|
|
71
|
+
name,
|
|
72
|
+
data=res.endmembers.endmember_list[n].spectrum.data,
|
|
73
|
+
)
|
|
74
|
+
gg[name].attrs["index"] = n
|
|
75
|
+
g.create_dataset("fractions", data=res.unmixed_image.fracs)
|
|
76
|
+
g.create_dataset("residuals", data=res.unmixed_image.res)
|
|
77
|
+
g.create_dataset("model", data=res.unmixed_image.model)
|
|
78
|
+
g.create_dataset("rsquared", data=res.rsquared)
|
|
79
|
+
case SaveMode.SMA:
|
|
80
|
+
raise NotImplementedError(
|
|
81
|
+
"Saving to .sma has not been implemented."
|
|
82
|
+
)
|
|
83
|
+
case SaveMode.CSV:
|
|
84
|
+
raise NotImplementedError(
|
|
85
|
+
"Saving to .csv has not been implemented."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_model_result(p: PathLike, model_name: str):
|
|
90
|
+
with h5.File(p) as f:
|
|
91
|
+
g = f[model_name]
|
|
92
|
+
endmember_list_with_idx = [
|
|
93
|
+
[
|
|
94
|
+
EndMember(
|
|
95
|
+
name,
|
|
96
|
+
Spectrum(dat[...], g.attrs["wavelengths"]), # type: ignore
|
|
97
|
+
),
|
|
98
|
+
dat.attrs["index"][...], # type: ignore
|
|
99
|
+
]
|
|
100
|
+
for (name, dat) in g["endmembers"].items() # type: ignore
|
|
101
|
+
]
|
|
102
|
+
endmember_list_sorted = [
|
|
103
|
+
i[0] for i in sorted(endmember_list_with_idx, key=lambda x: x[1])
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
endmember_grp = EndMemberGroup(endmember_list_sorted)
|
|
107
|
+
|
|
108
|
+
return ModelResult(
|
|
109
|
+
p,
|
|
110
|
+
model_name,
|
|
111
|
+
UnMixedCube(
|
|
112
|
+
g["model"][...], # type: ignore
|
|
113
|
+
g["fractions"][:, :, :-1], # type: ignore
|
|
114
|
+
g["residuals"][...], # type: ignore
|
|
115
|
+
),
|
|
116
|
+
endmember_grp,
|
|
117
|
+
g["rsquared"][...], # type: ignore
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def write_model_to_gis(
|
|
122
|
+
model: ModelResult,
|
|
123
|
+
geotransform: GeotransformType,
|
|
124
|
+
crs: str,
|
|
125
|
+
save_directory: str | Path,
|
|
126
|
+
name_prefix: Optional[str] = None,
|
|
127
|
+
):
|
|
128
|
+
profile = {
|
|
129
|
+
"driver": "ENVI",
|
|
130
|
+
"height": model.unmixed_image.fracs.shape[0],
|
|
131
|
+
"width": model.unmixed_image.fracs.shape[1],
|
|
132
|
+
"count": model.unmixed_image.fracs.shape[2],
|
|
133
|
+
"transform": geotransform,
|
|
134
|
+
"crs": CRS.from_wkt(crs),
|
|
135
|
+
"dtype": "float32",
|
|
136
|
+
"nodata": -999,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if name_prefix is not None:
|
|
140
|
+
file_name = f"{name_prefix}_"
|
|
141
|
+
else:
|
|
142
|
+
file_name = f"{model.modelID}_"
|
|
143
|
+
with rio.open(
|
|
144
|
+
Path(save_directory, f"{file_name}fractions").with_suffix(".bsq"),
|
|
145
|
+
"w",
|
|
146
|
+
**profile,
|
|
147
|
+
) as f:
|
|
148
|
+
for n in range(model.unmixed_image.fracs.shape[-1]):
|
|
149
|
+
f.write(model.unmixed_image.fracs[:, :, n], n + 1)
|
|
150
|
+
with open(
|
|
151
|
+
Path(save_directory, f"{file_name}fractions").with_suffix(".hdr"), "a"
|
|
152
|
+
) as f:
|
|
153
|
+
f.write("band_names = {\n")
|
|
154
|
+
for i in model.endmembers.endmember_name_list:
|
|
155
|
+
f.write(f" {i},\n")
|
|
156
|
+
f.write("}")
|
|
157
|
+
|
|
158
|
+
profile["count"] = 1
|
|
159
|
+
with rio.open(
|
|
160
|
+
Path(save_directory, f"{file_name}residual").with_suffix(".bsq"),
|
|
161
|
+
"w",
|
|
162
|
+
**profile,
|
|
163
|
+
) as f:
|
|
164
|
+
f.write(model.rsquared, 1)
|
hypmix/mixview/main.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Standard Libraries
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
# Dependencies
|
|
5
|
+
from PyQt6.QtCore import Qt
|
|
6
|
+
from PyQt6.QtWidgets import (
|
|
7
|
+
QApplication,
|
|
8
|
+
QMainWindow,
|
|
9
|
+
QVBoxLayout,
|
|
10
|
+
QWidget,
|
|
11
|
+
QSlider,
|
|
12
|
+
)
|
|
13
|
+
from superqt import QRangeSlider
|
|
14
|
+
|
|
15
|
+
# Relative Imports
|
|
16
|
+
from .mixture_image_canvas import MixtureImageCanvas
|
|
17
|
+
from .residual_image_canvas import ResidualImageCanvas
|
|
18
|
+
import numpy.typing as npt
|
|
19
|
+
|
|
20
|
+
# Top-Level Imports
|
|
21
|
+
from hypmix.endmember import EndMemberGroup
|
|
22
|
+
from hypmix.run_model import ModelResult
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ResidualViewWindow(QMainWindow):
|
|
26
|
+
def __init__(self, residual_image: npt.NDArray):
|
|
27
|
+
super().__init__()
|
|
28
|
+
|
|
29
|
+
self.res_canvas = ResidualImageCanvas(residual_image)
|
|
30
|
+
self.res_canvas.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
31
|
+
self.res_canvas.setFocus()
|
|
32
|
+
|
|
33
|
+
self.range_slider = QRangeSlider()
|
|
34
|
+
self.range_slider.setValue((20, 80))
|
|
35
|
+
self.range_slider.setRange(min=0, max_=100)
|
|
36
|
+
self.range_slider.setOrientation(Qt.Orientation.Horizontal)
|
|
37
|
+
self.range_slider.valueChanged.connect(self.res_canvas._update)
|
|
38
|
+
|
|
39
|
+
layout = QVBoxLayout()
|
|
40
|
+
layout.addWidget(self.res_canvas)
|
|
41
|
+
layout.addWidget(self.range_slider)
|
|
42
|
+
|
|
43
|
+
widget = QWidget()
|
|
44
|
+
widget.setLayout(layout)
|
|
45
|
+
|
|
46
|
+
self.setWindowTitle("ResidualView v0.0.1")
|
|
47
|
+
# self.setFixedSize(QSize(1200, 6))
|
|
48
|
+
self.setCentralWidget(widget)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MixViewWindow(QMainWindow):
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
mixture_cube: npt.NDArray,
|
|
55
|
+
endmembers: EndMemberGroup,
|
|
56
|
+
):
|
|
57
|
+
super().__init__()
|
|
58
|
+
|
|
59
|
+
self.im_canvas = MixtureImageCanvas(mixture_cube, endmembers)
|
|
60
|
+
self.im_canvas.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
61
|
+
self.im_canvas.setFocus()
|
|
62
|
+
|
|
63
|
+
self.band_slider = QSlider()
|
|
64
|
+
self.band_slider.setRange(0, mixture_cube.shape[-1] - 1)
|
|
65
|
+
self.band_slider.setOrientation(Qt.Orientation.Horizontal)
|
|
66
|
+
|
|
67
|
+
self.band_slider.valueChanged.connect(self.im_canvas.change_image)
|
|
68
|
+
|
|
69
|
+
layout = QVBoxLayout()
|
|
70
|
+
layout.addWidget(self.im_canvas)
|
|
71
|
+
layout.addWidget(self.band_slider)
|
|
72
|
+
|
|
73
|
+
widget = QWidget()
|
|
74
|
+
widget.setLayout(layout)
|
|
75
|
+
|
|
76
|
+
self.setWindowTitle("MixView v0.0.1")
|
|
77
|
+
# self.setFixedSize(QSize(1200, 900))
|
|
78
|
+
self.setCentralWidget(widget)
|
|
79
|
+
|
|
80
|
+
self.im_canvas.image_changed.connect(self._move_slider)
|
|
81
|
+
|
|
82
|
+
def _move_slider(self):
|
|
83
|
+
self.band_slider.setSliderPosition(self.im_canvas.selected_band)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_mixview(result: ModelResult) -> None:
|
|
87
|
+
app = QApplication(sys.argv)
|
|
88
|
+
|
|
89
|
+
window = MixViewWindow(result.unmixed_image.fracs, result.endmembers)
|
|
90
|
+
window.show()
|
|
91
|
+
|
|
92
|
+
app.exec()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_residualview(result: ModelResult) -> None:
|
|
96
|
+
app = QApplication(sys.argv)
|
|
97
|
+
|
|
98
|
+
window = ResidualViewWindow(result.rsquared)
|
|
99
|
+
window.show()
|
|
100
|
+
|
|
101
|
+
app.exec()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Standard Libraries
|
|
2
|
+
from typing import Any
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
# Dependencies
|
|
6
|
+
import numpy as np
|
|
7
|
+
import numpy.typing as npt
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
from matplotlib import colormaps
|
|
10
|
+
from matplotlib.colors import ListedColormap
|
|
11
|
+
from matplotlib.backend_bases import Event, MouseEvent, KeyEvent
|
|
12
|
+
from PyQt6.QtCore import pyqtSignal
|
|
13
|
+
|
|
14
|
+
# Relative Imports
|
|
15
|
+
from .pan_zoom_canvas import PanZoomCanvas
|
|
16
|
+
from .utils import square_image, set_image_axis
|
|
17
|
+
|
|
18
|
+
# Top-Level Imports
|
|
19
|
+
from hypmix.endmember import EndMemberGroup
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
gray_cmap = colormaps["gray"](np.linspace(0, 1, 100))
|
|
23
|
+
teal = np.asarray([0, 0.807843137254902, 0.819607843137255, 1])
|
|
24
|
+
red = np.asarray([0.698039215686274, 0.133333333333333, 0.133333333333333, 1])
|
|
25
|
+
cmap_array = np.concat([teal[None, :], gray_cmap, red[None, :]], axis=0)
|
|
26
|
+
minmax_cmap = ListedColormap(colors=cmap_array)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class HoverData:
|
|
31
|
+
_img: npt.NDArray
|
|
32
|
+
xexact: float = 0.0
|
|
33
|
+
yexact: float = 0.0
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
self.xpixel: int = round(self.xexact)
|
|
37
|
+
self.ypixel: int = round(self.yexact)
|
|
38
|
+
if self.xpixel < 0:
|
|
39
|
+
self.xpixel = 0
|
|
40
|
+
if self.ypixel < 0:
|
|
41
|
+
self.ypixel = 0
|
|
42
|
+
self.value: float = self._img[self.ypixel, self.xpixel]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MixtureImageCanvas(PanZoomCanvas):
|
|
46
|
+
image_changed = pyqtSignal()
|
|
47
|
+
|
|
48
|
+
def __init__(self, mixture_cube: npt.NDArray, endmembers: EndMemberGroup):
|
|
49
|
+
fig, ax = plt.subplots()
|
|
50
|
+
set_image_axis(ax)
|
|
51
|
+
super().__init__(ax)
|
|
52
|
+
|
|
53
|
+
self.selected_band: int = 0
|
|
54
|
+
self.mixture_cube = mixture_cube
|
|
55
|
+
self.endmembers = endmembers
|
|
56
|
+
self.clipmin = -0.1
|
|
57
|
+
self.clipmax = 1.1
|
|
58
|
+
offsets, self.show_band = square_image(
|
|
59
|
+
self.mixture_cube[:, :, self.selected_band]
|
|
60
|
+
)
|
|
61
|
+
self.img_obj = self.main_axis.imshow(
|
|
62
|
+
self.show_band,
|
|
63
|
+
cmap=minmax_cmap,
|
|
64
|
+
vmin=self.clipmin,
|
|
65
|
+
vmax=self.clipmax,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self.band_names = self.endmembers.endmember_name_list
|
|
69
|
+
self.main_axis.set_title(self.band_names[self.selected_band])
|
|
70
|
+
|
|
71
|
+
self.state.x_offset = offsets.x_offset
|
|
72
|
+
self.state.y_offset = offsets.y_offset
|
|
73
|
+
|
|
74
|
+
self.hover = HoverData(self.show_band)
|
|
75
|
+
|
|
76
|
+
def change_image(self, new_band: int):
|
|
77
|
+
self.img_obj.remove()
|
|
78
|
+
self.selected_band = new_band
|
|
79
|
+
_, self.show_band = square_image(
|
|
80
|
+
self.mixture_cube[:, :, self.selected_band]
|
|
81
|
+
)
|
|
82
|
+
self.img_obj = self.main_axis.imshow(
|
|
83
|
+
self.show_band,
|
|
84
|
+
cmap=minmax_cmap,
|
|
85
|
+
vmin=self.clipmin,
|
|
86
|
+
vmax=self.clipmax,
|
|
87
|
+
)
|
|
88
|
+
self.main_axis.set_title(self.band_names[self.selected_band])
|
|
89
|
+
self.image_changed.emit()
|
|
90
|
+
self.draw_idle()
|
|
91
|
+
|
|
92
|
+
def on_motion(self, event: Event, draw: bool = True) -> Any:
|
|
93
|
+
super().on_motion(event, draw)
|
|
94
|
+
if not isinstance(event, MouseEvent):
|
|
95
|
+
return
|
|
96
|
+
if event.xdata is None or event.ydata is None:
|
|
97
|
+
return
|
|
98
|
+
self.hover = HoverData(
|
|
99
|
+
self.show_band,
|
|
100
|
+
xexact=event.xdata,
|
|
101
|
+
yexact=event.ydata,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def on_key_press(self, event: Event) -> Any:
|
|
105
|
+
super().on_key_press(event)
|
|
106
|
+
|
|
107
|
+
if not isinstance(event, KeyEvent):
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
if event.key == "right":
|
|
111
|
+
if self.selected_band < self.mixture_cube.shape[-1] - 1:
|
|
112
|
+
self.selected_band += 1
|
|
113
|
+
self.change_image(self.selected_band)
|
|
114
|
+
|
|
115
|
+
if event.key == "left":
|
|
116
|
+
if self.selected_band > 0:
|
|
117
|
+
self.selected_band -= 1
|
|
118
|
+
self.change_image(self.selected_band)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
from matplotlib.axes import Axes
|
|
4
|
+
from matplotlib.figure import Figure
|
|
5
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
|
|
6
|
+
from matplotlib.backend_bases import Event, MouseEvent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class PanZoomState:
|
|
11
|
+
x_offset: int = 0
|
|
12
|
+
y_offset: int = 0
|
|
13
|
+
panning_active: bool = False
|
|
14
|
+
panning_start_coord: tuple = (0, 0)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PanZoomCanvas(FigureCanvasQTAgg):
|
|
18
|
+
"""
|
|
19
|
+
Subclassing with this class adds zooming and panning with the middle
|
|
20
|
+
mouse button to any matplotlib GUI figure.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
axis: Axes
|
|
25
|
+
Matplotlib Axes object for the zoom/panning to be applied to.
|
|
26
|
+
zoom_speed: float, optional
|
|
27
|
+
Adjusts the speed at which the mouse zooms in/out. Default is 1.5.
|
|
28
|
+
pan_sensitivity: float, optional
|
|
29
|
+
Adjusts the sensitivty of the panning motion. Default is 0.5.
|
|
30
|
+
|
|
31
|
+
Attributes
|
|
32
|
+
----------
|
|
33
|
+
main_axis: Axes
|
|
34
|
+
Points to the `axis` argument.
|
|
35
|
+
state: PanZoomState
|
|
36
|
+
Canvas state variables: x_offset, y_offset, panning_active and
|
|
37
|
+
panning_start_coord.
|
|
38
|
+
|
|
39
|
+
Notes
|
|
40
|
+
-----
|
|
41
|
+
If modifying an image to make it square, be sure to adjust the
|
|
42
|
+
`self.state.xoffset` and `self.state.yoffset` accordingly.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
axis: Axes,
|
|
48
|
+
zoom_speed: float = 1.5,
|
|
49
|
+
pan_sensitivity: float = 0.5,
|
|
50
|
+
):
|
|
51
|
+
if isinstance(axis.figure, Figure):
|
|
52
|
+
super().__init__(axis.figure)
|
|
53
|
+
else:
|
|
54
|
+
raise ValueError("Axis is part of a SubFigure.")
|
|
55
|
+
self.main_axis = axis
|
|
56
|
+
self._zoom_speed = zoom_speed
|
|
57
|
+
self._pan_sensitivity = pan_sensitivity
|
|
58
|
+
self.state = PanZoomState()
|
|
59
|
+
self.mpl_connect("button_press_event", self.on_button_press)
|
|
60
|
+
self.mpl_connect("button_release_event", self.on_button_release)
|
|
61
|
+
self.mpl_connect("scroll_event", self.on_scroll)
|
|
62
|
+
self.mpl_connect("motion_notify_event", self.on_motion)
|
|
63
|
+
self.mpl_connect("key_press_event", self.on_key_press)
|
|
64
|
+
|
|
65
|
+
def on_button_press(self, event: Event) -> Any:
|
|
66
|
+
"""When the middle mouse button is pressed, activate panning"""
|
|
67
|
+
if not isinstance(event, MouseEvent):
|
|
68
|
+
return
|
|
69
|
+
if event.xdata is None or event.ydata is None:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
if event.button == 2:
|
|
73
|
+
self.state.panning_active = True
|
|
74
|
+
self.state.panning_start_coord = (
|
|
75
|
+
event.xdata + self.state.x_offset,
|
|
76
|
+
event.ydata + self.state.y_offset,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def on_button_release(self, event: Event) -> Any:
|
|
80
|
+
"""On middle mouse button release, deactivate panning"""
|
|
81
|
+
if not isinstance(event, MouseEvent):
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if event.button == 2:
|
|
85
|
+
self.state.panning_active = False
|
|
86
|
+
|
|
87
|
+
def on_scroll(self, event: Event, draw: bool = True) -> Any:
|
|
88
|
+
"""Zoom in on a forward scroll and out on a backward scroll"""
|
|
89
|
+
# Checking if the Event is the right type.
|
|
90
|
+
if not isinstance(event, MouseEvent):
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Checking if the event is in the image axis.
|
|
94
|
+
ax = event.inaxes
|
|
95
|
+
if ax is None:
|
|
96
|
+
return
|
|
97
|
+
if ax != self.main_axis:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Current cursor position in data coordinates
|
|
101
|
+
fx = event.xdata
|
|
102
|
+
fy = event.ydata
|
|
103
|
+
if fx is None or fy is None:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
x0, x1 = ax.get_xlim()
|
|
107
|
+
y0, y1 = ax.get_ylim()
|
|
108
|
+
|
|
109
|
+
xdata = event.xdata
|
|
110
|
+
ydata = event.ydata
|
|
111
|
+
|
|
112
|
+
if xdata is None:
|
|
113
|
+
return
|
|
114
|
+
if ydata is None:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
if event.button == "down":
|
|
118
|
+
scale = 1 / self._zoom_speed
|
|
119
|
+
elif event.button == "up":
|
|
120
|
+
scale = self._zoom_speed
|
|
121
|
+
else:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Distances from cursor to each side
|
|
125
|
+
dx0 = fx - x0
|
|
126
|
+
dx1 = x1 - fx
|
|
127
|
+
dy0 = fy - y0
|
|
128
|
+
dy1 = y1 - fy
|
|
129
|
+
|
|
130
|
+
# Scale distances
|
|
131
|
+
dx0 /= scale
|
|
132
|
+
dx1 /= scale
|
|
133
|
+
dy0 /= scale
|
|
134
|
+
dy1 /= scale
|
|
135
|
+
|
|
136
|
+
ax.set_xlim(fx - dx0, fx + dx1)
|
|
137
|
+
ax.set_ylim(fy - dy0, fy + dy1)
|
|
138
|
+
|
|
139
|
+
if draw:
|
|
140
|
+
self.draw_idle()
|
|
141
|
+
|
|
142
|
+
def on_motion(self, event: Event, draw: bool = True) -> Any:
|
|
143
|
+
if not isinstance(event, MouseEvent):
|
|
144
|
+
return
|
|
145
|
+
if not self.state.panning_active:
|
|
146
|
+
return
|
|
147
|
+
if event.inaxes != self.main_axis:
|
|
148
|
+
return
|
|
149
|
+
if event.xdata is None or event.ydata is None:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
start_pt = self.state.panning_start_coord
|
|
153
|
+
end_pt = (
|
|
154
|
+
event.xdata + self.state.x_offset,
|
|
155
|
+
event.ydata + self.state.y_offset,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
dx_data = (start_pt[0] - end_pt[0]) * self._pan_sensitivity
|
|
159
|
+
dy_data = (start_pt[1] - end_pt[1]) * self._pan_sensitivity
|
|
160
|
+
|
|
161
|
+
cur_xlim = self.main_axis.get_xlim()
|
|
162
|
+
cur_ylim = self.main_axis.get_ylim()
|
|
163
|
+
|
|
164
|
+
self.main_axis.set_xlim(cur_xlim[0] + dx_data, cur_xlim[1] + dx_data)
|
|
165
|
+
self.main_axis.set_ylim(cur_ylim[0] + dy_data, cur_ylim[1] + dy_data)
|
|
166
|
+
|
|
167
|
+
self.state.panning_start_coord = end_pt
|
|
168
|
+
|
|
169
|
+
if draw:
|
|
170
|
+
self.draw_idle()
|
|
171
|
+
|
|
172
|
+
def on_key_press(self, event: Event) -> Any:
|
|
173
|
+
return
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
import numpy as np
|
|
4
|
+
import numpy.typing as npt
|
|
5
|
+
|
|
6
|
+
# Relative Imports
|
|
7
|
+
from .pan_zoom_canvas import PanZoomCanvas
|
|
8
|
+
from .utils import square_image, set_image_axis
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ResidualImageCanvas(PanZoomCanvas):
|
|
12
|
+
def __init__(self, residual_image: npt.NDArray):
|
|
13
|
+
fig, ax = plt.subplots()
|
|
14
|
+
fig.subplots_adjust(bottom=0.25)
|
|
15
|
+
set_image_axis(ax)
|
|
16
|
+
super().__init__(ax)
|
|
17
|
+
|
|
18
|
+
self.residual_image = residual_image
|
|
19
|
+
clean_res_img = residual_image[np.isfinite(residual_image)]
|
|
20
|
+
self.image_extrema = (np.min(clean_res_img), np.max(clean_res_img))
|
|
21
|
+
|
|
22
|
+
offsets, self.sqr_img = square_image(self.residual_image)
|
|
23
|
+
|
|
24
|
+
self.img_obj = self.main_axis.imshow(self.sqr_img, cmap="gray")
|
|
25
|
+
self.state.x_offset = offsets.x_offset
|
|
26
|
+
self.state.y_offset = offsets.y_offset
|
|
27
|
+
|
|
28
|
+
def _update(self, values: tuple[float, float]):
|
|
29
|
+
vmin, vmax = values
|
|
30
|
+
self.img_obj.norm.vmin = self.image_extrema[0] * vmin / 100
|
|
31
|
+
self.img_obj.norm.vmax = self.image_extrema[1] * vmax / 100
|
|
32
|
+
self.draw_idle()
|
hypmix/mixview/utils.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Standard Libraries
|
|
2
|
+
from typing import NamedTuple
|
|
3
|
+
|
|
4
|
+
# Dependencies
|
|
5
|
+
from matplotlib.axes import Axes
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SquareOffset(NamedTuple):
|
|
10
|
+
x_offset: int = 0
|
|
11
|
+
y_offset: int = 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def square_image(
|
|
15
|
+
img: np.ndarray, rgb: bool = False
|
|
16
|
+
) -> tuple[SquareOffset, np.ndarray]:
|
|
17
|
+
"""
|
|
18
|
+
Pads a rectangular image to make it square, for the purpose of reshaping
|
|
19
|
+
an image axis to be better suited for GUI applications.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
img: np.ndarray
|
|
24
|
+
Image array.
|
|
25
|
+
rgb: bool, optional
|
|
26
|
+
Option to specify RGB image input.
|
|
27
|
+
|
|
28
|
+
Returns
|
|
29
|
+
-------
|
|
30
|
+
offset: SquareOffset
|
|
31
|
+
NamedTuple of offset values added due to padding. The 2 attributes are
|
|
32
|
+
`x_offset` and `y_offset` representing the horizontal and vertical
|
|
33
|
+
offsets respectively. One will always be zero.
|
|
34
|
+
square_img: np.ndarray
|
|
35
|
+
Image with np.nan values padding the sides to fit the square
|
|
36
|
+
"""
|
|
37
|
+
long_axis = np.argmax(img.shape)
|
|
38
|
+
if not rgb:
|
|
39
|
+
short_axis = np.argmin(img.shape)
|
|
40
|
+
padding_length = (img.shape[long_axis] - img.shape[short_axis]) // 2
|
|
41
|
+
square_padding = np.full(
|
|
42
|
+
(img.shape[long_axis], padding_length),
|
|
43
|
+
np.nan,
|
|
44
|
+
)
|
|
45
|
+
square_img = np.concat(
|
|
46
|
+
[square_padding, img, square_padding], axis=short_axis
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if long_axis < short_axis:
|
|
50
|
+
offset = SquareOffset(-padding_length, 0)
|
|
51
|
+
else:
|
|
52
|
+
offset = SquareOffset(0, -padding_length)
|
|
53
|
+
|
|
54
|
+
return offset, square_img
|
|
55
|
+
else:
|
|
56
|
+
short_axis = np.argmin(img.shape[:-1])
|
|
57
|
+
padding_length = (img.shape[long_axis] - img.shape[short_axis]) // 2
|
|
58
|
+
square_padding = np.full(
|
|
59
|
+
(img.shape[long_axis], padding_length, 3),
|
|
60
|
+
np.nan,
|
|
61
|
+
)
|
|
62
|
+
square_img = np.concat(
|
|
63
|
+
[square_padding, img, square_padding],
|
|
64
|
+
axis=short_axis,
|
|
65
|
+
)
|
|
66
|
+
if long_axis < short_axis:
|
|
67
|
+
offset = SquareOffset(-padding_length, 0)
|
|
68
|
+
else:
|
|
69
|
+
offset = SquareOffset(0, -padding_length)
|
|
70
|
+
return offset, square_img
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def set_image_axis(ax: Axes) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Sets a matplotlib axis up to display an image.
|
|
76
|
+
"""
|
|
77
|
+
ax.set_xticks([])
|
|
78
|
+
ax.set_yticks([])
|
|
79
|
+
ax.tick_params(
|
|
80
|
+
axis="both",
|
|
81
|
+
which="both",
|
|
82
|
+
bottom=False,
|
|
83
|
+
top=False,
|
|
84
|
+
left=False,
|
|
85
|
+
right=False,
|
|
86
|
+
labelbottom=False,
|
|
87
|
+
labelleft=False,
|
|
88
|
+
)
|
hypmix/model_math.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Standard Libraries
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing_extensions import Annotated
|
|
4
|
+
from typing import Tuple
|
|
5
|
+
|
|
6
|
+
# Dependencies
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.typing as npt
|
|
9
|
+
|
|
10
|
+
# Relative Imports
|
|
11
|
+
from .typing import ImageCubeLike
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MixedCube:
|
|
16
|
+
"""
|
|
17
|
+
Represents an made up of pixels that are each an areal mixture of spectral
|
|
18
|
+
endmembers.
|
|
19
|
+
|
|
20
|
+
Attributes
|
|
21
|
+
----------
|
|
22
|
+
G: NDArray[np.float32, (2,)]
|
|
23
|
+
Least squares design matrix. The array is of shape NxM where N is the
|
|
24
|
+
number of spectral bands and M is the number of endmembers in the
|
|
25
|
+
unmixing model.
|
|
26
|
+
d: NDArray[np.float32, (3,)]
|
|
27
|
+
Data matrix for every pixel. This is just the spectral cube data.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
G: Annotated[npt.NDArray[np.float32], (2,)]
|
|
31
|
+
d: Annotated[npt.NDArray[np.float32], (3,)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class UnMixedCube:
|
|
36
|
+
"""
|
|
37
|
+
The results of spectral unmixing.
|
|
38
|
+
|
|
39
|
+
Attributes
|
|
40
|
+
----------
|
|
41
|
+
model: NDArray[np.float32, (3,)]
|
|
42
|
+
Modeled spectral cube, where each pixel is a linear combination of
|
|
43
|
+
endmembers that best fits the actual data.
|
|
44
|
+
fracs: NDArray[np.float32, (3,)]
|
|
45
|
+
3D array representing the modeled fractions of endmembers at all
|
|
46
|
+
pixels.
|
|
47
|
+
res: NDArray[np.float32, (2,)]
|
|
48
|
+
Pixel-by-pixel model residuals. That is, a Spectrum-Like vector for
|
|
49
|
+
each pixel that represents model - data in each spectral band.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
model: ImageCubeLike
|
|
53
|
+
fracs: ImageCubeLike
|
|
54
|
+
res: ImageCubeLike
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _solve_cube(
|
|
58
|
+
G: Annotated[npt.NDArray[np.float32], (2,)],
|
|
59
|
+
d: Annotated[npt.NDArray[np.float32], (3,)],
|
|
60
|
+
) -> Tuple[ImageCubeLike, ImageCubeLike, ImageCubeLike]:
|
|
61
|
+
prefix = np.linalg.inv(G.T @ G) @ G.T
|
|
62
|
+
|
|
63
|
+
fracs = np.einsum("ij,...j->...i", prefix, d)
|
|
64
|
+
|
|
65
|
+
model = np.einsum("ij,...j->...i", G, fracs)
|
|
66
|
+
|
|
67
|
+
res = model - d
|
|
68
|
+
|
|
69
|
+
return model, fracs, res
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def unmix_spectral_cube(mixed_cube: MixedCube, add_to_one: bool = True):
|
|
73
|
+
G = mixed_cube.G
|
|
74
|
+
d = mixed_cube.d
|
|
75
|
+
|
|
76
|
+
if add_to_one:
|
|
77
|
+
G_aug = np.hstack((G, np.ones([G.shape[0], 1])), dtype=np.float32)
|
|
78
|
+
bottom_row = np.append(np.ones(G.shape[1]), 0)
|
|
79
|
+
G_aug = np.vstack((G_aug, bottom_row), dtype=np.float32)
|
|
80
|
+
|
|
81
|
+
d_aug = np.concat(
|
|
82
|
+
[d, np.ones(d.shape[:2])[:, :, None]], axis=2, dtype=np.float32
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
model, fracs, res = _solve_cube(G_aug, d_aug)
|
|
86
|
+
else:
|
|
87
|
+
model, fracs, res = _solve_cube(G, d)
|
|
88
|
+
|
|
89
|
+
return UnMixedCube(model, fracs, res)
|
hypmix/py.typed
ADDED
|
File without changes
|
hypmix/run_model.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Standard Libraries
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
# Dependencies
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
# Relative Imports
|
|
8
|
+
from .typing import ImageCube, Spectrum, PathLike
|
|
9
|
+
from .io import ModelResult
|
|
10
|
+
from .model_math import unmix_spectral_cube, MixedCube
|
|
11
|
+
from .endmember import EndMember, EndMemberGroup
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EndmemberAlreadyExistsError(Exception):
|
|
15
|
+
def __init__(self, message: str) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ModelState:
|
|
21
|
+
has_reflector: bool = False
|
|
22
|
+
has_shade: bool = False
|
|
23
|
+
has_blackbody: bool = False
|
|
24
|
+
endmember_count: int = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class MixtureModel:
|
|
29
|
+
"""
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
endmembers: list[EndMember]
|
|
33
|
+
List of model endmembers to use.
|
|
34
|
+
data_cube: ImageCube
|
|
35
|
+
Spectral data cube.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
endmembers: list[EndMember]
|
|
39
|
+
data_cube: ImageCube
|
|
40
|
+
state: ModelState = field(default_factory=ModelState)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self):
|
|
43
|
+
if self.data_cube.bands_first:
|
|
44
|
+
self.data_cube.data = np.transpose(self.data_cube.data, (1, 2, 0))
|
|
45
|
+
self.state.endmember_count = len(self.endmembers)
|
|
46
|
+
|
|
47
|
+
def add_endmember(self, endmember: EndMember) -> None:
|
|
48
|
+
if endmember.name in [i.name for i in self.endmembers]:
|
|
49
|
+
raise EndmemberAlreadyExistsError(
|
|
50
|
+
f"{endmember} has the same name as an existing endmember."
|
|
51
|
+
)
|
|
52
|
+
self.endmembers.append(endmember)
|
|
53
|
+
self.state.endmember_count += 1
|
|
54
|
+
|
|
55
|
+
def add_virtual_reflector(self):
|
|
56
|
+
if not self.state.has_reflector:
|
|
57
|
+
self.endmembers.append(
|
|
58
|
+
EndMember(
|
|
59
|
+
"Reflector",
|
|
60
|
+
Spectrum(
|
|
61
|
+
np.ones(len(self.data_cube.wvl)), self.data_cube.wvl
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
self.state.endmember_count += 1
|
|
66
|
+
self.state.has_reflector = True
|
|
67
|
+
|
|
68
|
+
def add_virtual_shade(self):
|
|
69
|
+
if not self.state.has_shade:
|
|
70
|
+
self.endmembers.append(
|
|
71
|
+
EndMember(
|
|
72
|
+
"Shade",
|
|
73
|
+
Spectrum(
|
|
74
|
+
0.001 * np.ones(len(self.data_cube.wvl)),
|
|
75
|
+
self.data_cube.wvl,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
self.state.endmember_count += 1
|
|
80
|
+
self.state.has_shade = True
|
|
81
|
+
else:
|
|
82
|
+
raise EndmemberAlreadyExistsError(
|
|
83
|
+
"Virtual Shade already exists in this model."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def add_virtual_blackbody(self) -> None:
|
|
87
|
+
if not self.state.has_blackbody:
|
|
88
|
+
self.endmembers.append(
|
|
89
|
+
EndMember(
|
|
90
|
+
"Blackbody",
|
|
91
|
+
Spectrum(
|
|
92
|
+
np.ones(len(self.data_cube.wvl)), self.data_cube.wvl
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
self.state.endmember_count += 1
|
|
97
|
+
self.state.has_blackbody = True
|
|
98
|
+
else:
|
|
99
|
+
raise EndmemberAlreadyExistsError(
|
|
100
|
+
"Virtual blackbody already exists in this model."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def run(self, dst_path: PathLike, modelID: str) -> ModelResult:
|
|
104
|
+
G = np.empty(
|
|
105
|
+
[len(self.data_cube.wvl), len(self.endmembers)], dtype=np.float32
|
|
106
|
+
)
|
|
107
|
+
for n, em in enumerate(self.endmembers):
|
|
108
|
+
G[:, n] = em.spectrum.data
|
|
109
|
+
mixed_cube = MixedCube(G, self.data_cube.data)
|
|
110
|
+
unmixed_cube = unmix_spectral_cube(mixed_cube)
|
|
111
|
+
rsquared = np.sqrt(np.sum(unmixed_cube.res**2, axis=2))
|
|
112
|
+
|
|
113
|
+
result = ModelResult(
|
|
114
|
+
dst_path,
|
|
115
|
+
modelID,
|
|
116
|
+
unmixed_cube,
|
|
117
|
+
EndMemberGroup(self.endmembers),
|
|
118
|
+
rsquared,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return result
|
hypmix/typing.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Standard Libraries
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
from typing_extensions import Annotated
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Dependencies
|
|
9
|
+
import numpy.typing as npt
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
PathLike = str | os.PathLike | Path
|
|
13
|
+
|
|
14
|
+
DType = TypeVar("DType", bound=np.generic)
|
|
15
|
+
SpectrumLike = Annotated[npt.NDArray[DType], (1,)]
|
|
16
|
+
ImageCubeLike = Annotated[npt.NDArray[DType], (3,)]
|
|
17
|
+
ImageLike = Annotated[npt.NDArray[DType], (2,)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Spectrum:
|
|
22
|
+
data: SpectrumLike
|
|
23
|
+
wvl: SpectrumLike
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ImageCube:
|
|
28
|
+
data: ImageCubeLike
|
|
29
|
+
wvl: SpectrumLike
|
|
30
|
+
bands_first: bool = False
|
|
31
|
+
|
|
32
|
+
def __post_init__(self):
|
|
33
|
+
bands_dim = np.argmin(self.data.shape)
|
|
34
|
+
self.data = np.moveaxis(self.data, bands_dim, -1)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hypmix
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Hyperspectral Linear Mixture anlysis code for python.
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Author: Z.M. Vig
|
|
7
|
+
Author-email: zvig@umd.edu
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Requires-Dist: h5py (>=3.14.0,<4.0.0)
|
|
13
|
+
Requires-Dist: numpy (>=2.2,<3.0.0)
|
|
14
|
+
Requires-Dist: pyqt6 (>=6.10.0,<7.0.0)
|
|
15
|
+
Requires-Dist: rasterio (>=1.4.3,<2.0.0)
|
|
16
|
+
Requires-Dist: superqt (>=0.7.6,<0.8.0)
|
|
17
|
+
Requires-Dist: typing-extensions (>=4.14.1,<5.0.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# hypmix
|
|
21
|
+
Hyperspectral Unmixing Library for Python
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install hypmix
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
```python
|
|
31
|
+
import hypmix
|
|
32
|
+
|
|
33
|
+
em_list = []
|
|
34
|
+
for data, wvl in zip(spectrum_list, wvl_list):
|
|
35
|
+
spec=hypmix.Spectrum(data, wvl)
|
|
36
|
+
em_list.append(hypmix.EndMember("endmember1", spec))
|
|
37
|
+
|
|
38
|
+
model = hypmix.MixtureModel(em_list, data_cube)
|
|
39
|
+
model.add_virtual_shade()
|
|
40
|
+
|
|
41
|
+
res = model.run("save/path/results.hdf5", "model_name")
|
|
42
|
+
hypmix.save_model_result(res)
|
|
43
|
+
```
|
|
44
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
hypmix/__init__.py,sha256=55aokY3Sce7CbqjI-Y1FHHxoE2j8Iw0ZsHTnVPU70a4,402
|
|
2
|
+
hypmix/endmember.py,sha256=4hzLzINKqTMQq1nZ8rhAzcMOX-qXxnoqDBvi6e_pF2U,1275
|
|
3
|
+
hypmix/io.py,sha256=M4Cq_KK_xk6vaVg0TIQ56Oc-KZs6PKf1dMDSh8JvPaw,5103
|
|
4
|
+
hypmix/mixview/__init__.py,sha256=PMNzFqd_LT10hCCnmbT11I7SAMiLIf0pBmzEw49-XTo,98
|
|
5
|
+
hypmix/mixview/main.py,sha256=Xv_5z4aoIagJp2FQEiDMRfg27LdkKjqnVkYUpYQTunw,2886
|
|
6
|
+
hypmix/mixview/mixture_image_canvas.py,sha256=gVAjxIdbjUvkdGHUDeBweOlOnPfqwZMZ-7Hwm_eZA_I,3760
|
|
7
|
+
hypmix/mixview/pan_zoom_canvas.py,sha256=bTBuAsCQ_PXy0nOp3Q3NcsLKNWByP4jwDart0u2AI1E,5380
|
|
8
|
+
hypmix/mixview/residual_image_canvas.py,sha256=s2pIU3jcVwubUnuYFgUn_Snk8oN2D5kCl88yStjursE,1129
|
|
9
|
+
hypmix/mixview/utils.py,sha256=fnaTEH7zmFZnYmPX78-eZKi7k2IfEQfDHRneQ_4BnWs,2539
|
|
10
|
+
hypmix/model_math.py,sha256=HzWfvL7sQaHQSlzgRyJY6FdKa29kavVJVPaeZyOKLOA,2540
|
|
11
|
+
hypmix/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
hypmix/run_model.py,sha256=r41vf-YbkcLa8CkpKUypWRogpS8kUMS-JMW9pmgYTb8,3788
|
|
13
|
+
hypmix/typing.py,sha256=R37OCQTlpwc1myLRyqPKGjVPw5DiF5VM0fpKxDYsv_4,800
|
|
14
|
+
hypmix-0.1.0.dist-info/licenses/LICENSE,sha256=c26YrgmhpKOz_7dxlt-FxackpoVokgH5pZ25lxDW6Sc,1082
|
|
15
|
+
hypmix-0.1.0.dist-info/METADATA,sha256=_oazFDsKztHJajqUEYSy5vsday-gS38aiUyXPdKRylw,1096
|
|
16
|
+
hypmix-0.1.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
17
|
+
hypmix-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Zach
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|