hypmix 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.
hypmix-0.1.0/LICENSE ADDED
@@ -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.
hypmix-0.1.0/PKG-INFO ADDED
@@ -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
+
hypmix-0.1.0/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # hypmix
2
+ Hyperspectral Unmixing Library for Python
3
+
4
+ ## Installation
5
+
6
+ ```bash
7
+ pip install hypmix
8
+ ```
9
+
10
+ ## Usage
11
+ ```python
12
+ import hypmix
13
+
14
+ em_list = []
15
+ for data, wvl in zip(spectrum_list, wvl_list):
16
+ spec=hypmix.Spectrum(data, wvl)
17
+ em_list.append(hypmix.EndMember("endmember1", spec))
18
+
19
+ model = hypmix.MixtureModel(em_list, data_cube)
20
+ model.add_virtual_shade()
21
+
22
+ res = model.run("save/path/results.hdf5", "model_name")
23
+ hypmix.save_model_result(res)
24
+ ```
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "hypmix"
3
+ version = "0.1.0"
4
+ description = "A Hyperspectral Linear Mixture anlysis code for python."
5
+ authors = [
6
+ {name = "Z.M. Vig",email = "zvig@umd.edu"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "numpy (>=2.2,<3.0.0)",
12
+ "h5py (>=3.14.0,<4.0.0)",
13
+ "typing-extensions (>=4.14.1,<5.0.0)",
14
+ "pyqt6 (>=6.10.0,<7.0.0)",
15
+ "superqt (>=0.7.6,<0.8.0)",
16
+ "rasterio (>=1.4.3,<2.0.0)"
17
+ ]
18
+
19
+
20
+ [build-system]
21
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
22
+ build-backend = "poetry.core.masonry.api"
@@ -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
+ ]
@@ -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
@@ -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)
@@ -0,0 +1,3 @@
1
+ from .main import run_mixview, run_residualview
2
+
3
+ __all__ = ["run_mixview", "run_residualview"]
@@ -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()
@@ -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
+ )
@@ -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)
File without changes
@@ -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
@@ -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)