pycubeview 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: pycubeview
3
+ Version: 0.1.0
4
+ Summary: A Hyperspectral Image Viewer for Python
5
+ License: LICENSE
6
+ License-File: LICENSE
7
+ Author: Z.M. Vig
8
+ Author-email: zvig@umd.edu
9
+ Requires-Python: >=3.13,<4
10
+ Classifier: License :: Other/Proprietary License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: alphashape (>=1.3.1,<2.0.0)
15
+ Requires-Dist: arguably (>=1.3.0,<2.0.0)
16
+ Requires-Dist: pandas (>=2.3.3,<3.0.0)
17
+ Requires-Dist: pandas-stubs (>=2.3.3.251201,<3.0.0.0)
18
+ Requires-Dist: pyqt6 (>=6.10.0,<7.0.0)
19
+ Requires-Dist: pyqtgraph (>=0.14.0,<0.15.0)
20
+ Requires-Dist: shapely (>=2.1.2,<3.0.0)
21
+ Requires-Dist: spectralio (>=0.1.4,<0.2.0)
22
+ Requires-Dist: types-shapely (>=2.1.0.20250917,<3.0.0.0)
23
+ Description-Content-Type: text/markdown
24
+
25
+ # `cubeview` 🔎
26
+
27
+ A Flexible and Interactive Spectral (and more!) Image Viewer for Python
28
+
29
+ ---
30
+
31
+ ## Motivation ✨
32
+
33
+ Whether it's an imaging spectrometer or an InSAR time-series, many remotely
34
+ sensed scientific data comes in the form of a cube, which is here defined as
35
+ any dataset that has spatial information in two dimensions and measured values
36
+ in a third dimension. Below are listed some examples of scientific data cubes:
37
+
38
+ - Hyperspectral Imagery
39
+ - Multispectral Imagery
40
+ - InSAR Time Series
41
+ - Cloud Cover Evolution Map
42
+ - Spectral Maps from lab spectrometers
43
+ - And Many More!
44
+
45
+
46
+ ## Installation ⬇️
47
+
48
+ `cubeview` can be directly install from the Python Package Index using `pip`.
49
+
50
+ ```bash
51
+ pip install cubeview
52
+ ```
53
+
54
+ ## Usage ⚙️
55
+
56
+ The basic `cubeview` GUI can be opened directly from the command line by ensuring you are in a python environment that has `cubeview` installed and running
57
+
58
+ ```bash
59
+ cubeview.exe
60
+ ```
61
+
62
+ The `cubeview` GUI can also be started from a python script.
63
+
64
+ ```python
65
+ from cubeview import open_cubeview
66
+ open_cubeview(image_data, cube_data, wvl_data)
67
+ ```
68
+ Where the data can optionally provided as either a Numpy-Array or a filepath to one of the supported file types.
69
+
70
+ ## Supported File Types 📂
71
+ ### Image and Cube Data
72
+ #### `spectralio` files
73
+
74
+ - .geospcub
75
+ - .spcub
76
+
77
+ #### `rasterio`-compatible files
78
+ - .img
79
+ - .bsq
80
+ - .tif
81
+
82
+ ### Wavelength Data
83
+ - .wvl
84
+ - .hdr
85
+ - .txt
86
+ - .csv
87
+
88
+
89
+
@@ -0,0 +1,64 @@
1
+ # `cubeview` 🔎
2
+
3
+ A Flexible and Interactive Spectral (and more!) Image Viewer for Python
4
+
5
+ ---
6
+
7
+ ## Motivation ✨
8
+
9
+ Whether it's an imaging spectrometer or an InSAR time-series, many remotely
10
+ sensed scientific data comes in the form of a cube, which is here defined as
11
+ any dataset that has spatial information in two dimensions and measured values
12
+ in a third dimension. Below are listed some examples of scientific data cubes:
13
+
14
+ - Hyperspectral Imagery
15
+ - Multispectral Imagery
16
+ - InSAR Time Series
17
+ - Cloud Cover Evolution Map
18
+ - Spectral Maps from lab spectrometers
19
+ - And Many More!
20
+
21
+
22
+ ## Installation ⬇️
23
+
24
+ `cubeview` can be directly install from the Python Package Index using `pip`.
25
+
26
+ ```bash
27
+ pip install cubeview
28
+ ```
29
+
30
+ ## Usage ⚙️
31
+
32
+ The basic `cubeview` GUI can be opened directly from the command line by ensuring you are in a python environment that has `cubeview` installed and running
33
+
34
+ ```bash
35
+ cubeview.exe
36
+ ```
37
+
38
+ The `cubeview` GUI can also be started from a python script.
39
+
40
+ ```python
41
+ from cubeview import open_cubeview
42
+ open_cubeview(image_data, cube_data, wvl_data)
43
+ ```
44
+ Where the data can optionally provided as either a Numpy-Array or a filepath to one of the supported file types.
45
+
46
+ ## Supported File Types 📂
47
+ ### Image and Cube Data
48
+ #### `spectralio` files
49
+
50
+ - .geospcub
51
+ - .spcub
52
+
53
+ #### `rasterio`-compatible files
54
+ - .img
55
+ - .bsq
56
+ - .tif
57
+
58
+ ### Wavelength Data
59
+ - .wvl
60
+ - .hdr
61
+ - .txt
62
+ - .csv
63
+
64
+
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "pycubeview"
3
+ version = "0.1.0"
4
+ description = "A Hyperspectral Image Viewer for Python"
5
+ authors = [
6
+ {name = "Z.M. Vig",email = "zvig@umd.edu"}
7
+ ]
8
+ license = "LICENSE"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13,<4"
11
+ dependencies = [
12
+ "pyqt6 (>=6.10.0,<7.0.0)",
13
+ "pyqtgraph (>=0.14.0,<0.15.0)",
14
+ "shapely (>=2.1.2,<3.0.0)",
15
+ "alphashape (>=1.3.1,<2.0.0)",
16
+ "types-shapely (>=2.1.0.20250917,<3.0.0.0)",
17
+ "spectralio (>=0.1.4,<0.2.0)",
18
+ "arguably (>=1.3.0,<2.0.0)",
19
+ "pandas (>=2.3.3,<3.0.0)",
20
+ "pandas-stubs (>=2.3.3.251201,<3.0.0.0)"
21
+ ]
22
+
23
+ [project.scripts]
24
+ cubeview = "cubeview.cli:main"
25
+
26
+ [build-system]
27
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
28
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,14 @@
1
+ """
2
+ # `specview`
3
+
4
+ A Hyperspectral Image Viewer for Python
5
+
6
+ ---
7
+
8
+ ## Example
9
+ """
10
+
11
+ from .spectral_viewing_window import CubeViewWindow
12
+ from .helper_functions import open_cubeview
13
+
14
+ __all__ = ["open_cubeview", "CubeViewWindow"]
@@ -0,0 +1,66 @@
1
+ # Built-ins
2
+ from dataclasses import dataclass
3
+ from importlib.metadata import version
4
+
5
+ # Dependencies
6
+ import pyqtgraph as pg # type: ignore
7
+ import numpy as np
8
+
9
+ # PyQt6 Imports
10
+ from PyQt6.QtWidgets import QMainWindow, QStatusBar, QMenu
11
+ from PyQt6.QtGui import QAction
12
+
13
+
14
+ @dataclass
15
+ class MasterGUIState:
16
+ spectrum_cache: dict[str, tuple[pg.PlotDataItem, pg.ErrorBarItem]]
17
+ spectrum_edit_open: bool
18
+ drawing: bool
19
+
20
+ @classmethod
21
+ def initial(cls) -> "MasterGUIState":
22
+ return cls(
23
+ spectrum_cache={},
24
+ spectrum_edit_open=False,
25
+ drawing=False,
26
+ )
27
+
28
+
29
+ class BaseWindow(QMainWindow):
30
+ def __init__(self) -> None:
31
+ super().__init__()
32
+
33
+ self.state = MasterGUIState.initial()
34
+
35
+ self.open_cube = QAction("Open Spectral Cube", self)
36
+ self.open_cube.setStatusTip("Open a new spectral cube.")
37
+
38
+ self.open_display = QAction("Open Display", self)
39
+ self.open_display.setStatusTip("Open new display data.")
40
+
41
+ self.clear_spectra = QAction("Clear Spectrum Plot", self)
42
+ self.clear_spectra.setStatusTip("Clear 0 spectra from memory.")
43
+
44
+ menubar = self.menuBar()
45
+ if menubar is not None:
46
+ self.open_menu = QMenu(title="Open")
47
+ menubar.addMenu(self.open_menu)
48
+ self.open_menu.addAction(self.open_cube)
49
+ self.open_menu.addAction(self.open_display)
50
+
51
+ self.spectrum_menu = QMenu(title="Spectrum")
52
+ menubar.addMenu(self.spectrum_menu)
53
+ self.spectrum_menu.addAction(self.clear_spectra)
54
+
55
+ self.status_bar = QStatusBar(self)
56
+ self.setStatusBar(self.status_bar)
57
+
58
+ self.setWindowTitle(f"SpecView v{version("cubeview")}")
59
+
60
+ def set_window_size(self, image: np.ndarray) -> None:
61
+ if image.shape[0] > image.shape[1]:
62
+ self.resize(600, 800)
63
+ elif image.shape[1] > image.shape[0]:
64
+ self.resize(800, 600)
65
+ else:
66
+ self.resize(600, 600)
@@ -0,0 +1,20 @@
1
+ # Dependencies
2
+ import arguably
3
+
4
+ # PyQt6 Imports
5
+ from PyQt6.QtWidgets import QApplication
6
+
7
+ # Local Imports
8
+ from .spectral_viewing_window import CubeViewWindow
9
+
10
+
11
+ @arguably.command
12
+ def cubeview():
13
+ app = QApplication([])
14
+ main = CubeViewWindow()
15
+ main.show()
16
+ app.exec()
17
+
18
+
19
+ def main():
20
+ arguably.run()
@@ -0,0 +1,289 @@
1
+ """
2
+ File Opening Utilities
3
+
4
+ This module provides a centralized, type-safe dispatch for opening three
5
+ categories of files for the operation ofthe SpecView GUI.
6
+
7
+ Supported File Types
8
+ --------------------
9
+
10
+
11
+ Available Helper Functions::
12
+
13
+ from pathlib import Path
14
+
15
+ wvl = open_wvl(Path("wvlvals.wvl"))
16
+ img = open_cube(Path("cube.geospcub"))
17
+
18
+ """
19
+
20
+ # Built-Ins
21
+ from pathlib import Path
22
+ from typing import Protocol
23
+ from dataclasses import dataclass
24
+
25
+ # Dependencies
26
+ import numpy as np
27
+ import rasterio as rio # type: ignore
28
+ import spectralio as sio
29
+ import re
30
+ import pandas as pd
31
+
32
+
33
+ # ---- Handling Wavelength Data Files ----
34
+
35
+
36
+ class WvlHandler(Protocol):
37
+ """
38
+ Protocol for handling wavelength (or other context data) files.
39
+ """
40
+
41
+ def __call__(self, path: Path) -> np.ndarray: ...
42
+
43
+ """
44
+ Handle a file at the given path.
45
+
46
+ Parameters
47
+ ----------
48
+ path: Path
49
+ Path to an existing file whose suffix is one of the following
50
+
51
+ - .wvl
52
+ - .hdr
53
+ - .txt
54
+ - .csv
55
+
56
+ Returns
57
+ -------
58
+ wvl_array: np.ndarray
59
+ A 1D numpy array that holds the wavelength values.
60
+
61
+ Raises
62
+ ------
63
+ OSError
64
+ If the file cannot be read.
65
+ """
66
+
67
+
68
+ def open_wvl_file(path: Path) -> np.ndarray:
69
+ """Reads .wvl files using `spectralio`"""
70
+ wvl = sio.read_wvl(path)
71
+ return wvl.asarray()
72
+
73
+
74
+ def open_hdr_file(path: Path) -> np.ndarray:
75
+ """Read an ENVI .hdr file"""
76
+ wvl_pattern = re.compile(r"wavelength\s=\s\{([^}]*)\}")
77
+ with open(path, "r") as f:
78
+ file_contents = f.read()
79
+ result = re.findall(wvl_pattern, file_contents)
80
+ if len(result) == 0:
81
+ raise OSError("Unable to open .hdr file. Is there a wavelength field?")
82
+ vals = np.asarray([float(i) for i in result[0].split(",")])
83
+ return vals
84
+
85
+
86
+ def open_txt_file(path: Path) -> np.ndarray:
87
+ """
88
+ Read a .txt file. Wavelength values should be seperated by commas.
89
+ """
90
+ with open(path, "r") as f:
91
+ contents = f.read()
92
+ vals = contents.split(",")
93
+ if vals[-1] == " ":
94
+ vals = vals[:-1]
95
+ return np.asarray([float(i) for i in vals])
96
+
97
+
98
+ def open_csv_file(path: Path) -> np.ndarray:
99
+ """
100
+ Opens a csv file where there is one row of headers and at least one is
101
+ "wavelength". Make sure there are no spaces around the commas!
102
+ """
103
+ df = pd.read_csv(path)
104
+ wvl_col_idx = [i.lower() for i in df.columns.to_list()].index("wavelength")
105
+ wvl = df.iloc[:, wvl_col_idx].to_numpy()
106
+ return wvl
107
+
108
+
109
+ # Mapping from lowercase string file extensions to handler functions.
110
+ WVL_HANDLERS: dict[str, WvlHandler] = {
111
+ ".wvl": open_wvl_file,
112
+ ".hdr": open_hdr_file,
113
+ ".txt": open_txt_file,
114
+ ".csv": open_csv_file,
115
+ }
116
+
117
+
118
+ def open_wvl(path: str | Path) -> np.ndarray:
119
+ """
120
+ Open a file that stores wavelength information.
121
+
122
+ This function inspects the files extension name and passes it to the
123
+ appropriate handler for that file type.
124
+
125
+ Parameters
126
+ ----------
127
+ path: str or Path
128
+ Path to file containing wavelength data that is to be opened.
129
+
130
+ Raises
131
+ ------
132
+ FileNotFoundError
133
+ If the file does not exist.
134
+ ValueError
135
+ If the file does not have a valid extension.
136
+ """
137
+ path = Path(path)
138
+ if not path.exists():
139
+ raise FileNotFoundError(path)
140
+
141
+ suffix = path.suffix.lower()
142
+
143
+ handler = WVL_HANDLERS.get(suffix)
144
+
145
+ if handler is None:
146
+ raise ValueError(f"Unsupported file type: {suffix}")
147
+
148
+ return handler(path)
149
+
150
+
151
+ # ---- Handling Cube Data Files ----
152
+ @dataclass
153
+ class CubeAxisOrder:
154
+ """
155
+ Holds information for orienting spectral data cubes.
156
+
157
+ Parameters
158
+ ----------
159
+ x: int
160
+ Axis index for the horizontal image direction.
161
+ y: int
162
+ Axis index for the vertical image direction.
163
+ b: int
164
+ Axis index for the spectral band (or other context data) direction.
165
+ """
166
+
167
+ x: int
168
+ y: int
169
+ b: int
170
+
171
+
172
+ class CubeHandler(Protocol):
173
+ """
174
+ Protocol for handling cube data files.
175
+ """
176
+
177
+ def __call__(self, path: Path, axis_map: dict[str, int]) -> np.ndarray: ...
178
+
179
+ """
180
+ Handle a file at the given path.
181
+
182
+ Parameters
183
+ ----------
184
+ path: Path
185
+ Path to an existing file whose suffix is one of the following
186
+
187
+ - .spcub
188
+ - .geospcub
189
+ - .bsq
190
+ - .img
191
+ - .tif
192
+
193
+
194
+
195
+ Returns
196
+ -------
197
+ cube_array: np.ndarray
198
+ A 3D numpy array where axis 0 is the vertical image dimension, axis 1
199
+ is the horizontal image dimension and axis 2 is the wavelength (or
200
+ other context data) dimension.
201
+
202
+ Raises
203
+ ------
204
+ OSError
205
+ If the file cannot be read.
206
+ """
207
+
208
+
209
+ def open_spcub_cube(path: Path, axis_map: dict[str, int]) -> np.ndarray:
210
+ """
211
+ Read .spcub or .geospcub files using `spectralio`
212
+ """
213
+ cub_obj: sio.Spectrum3D
214
+ if path.suffix.lower() == ".geospcub":
215
+ cub_obj = sio.read_spec3D(path, kind="geospcub")
216
+ elif path.suffix.lower() == ".spcub":
217
+ cub_obj = sio.read_spec3D(path, kind="spcub")
218
+ else:
219
+ raise ValueError(
220
+ "Invalid File Type passed to `open_spcub_file()`: "
221
+ f"{path.suffix.lower()}"
222
+ )
223
+ return cub_obj.load_raster(bbl=True)
224
+
225
+
226
+ def open_rasterio_cube(path: Path, axis_map: dict[str, int]) -> np.ndarray:
227
+ """
228
+ Reads any rasterio-compatible file type.
229
+ """
230
+ try:
231
+ axis_order_obj = CubeAxisOrder(**axis_map)
232
+ except TypeError:
233
+ raise TypeError(
234
+ "Invalid axis_order dictionary with keys: "
235
+ f"{list(axis_map.keys())}. The keys should be ['x', 'y', 'b']"
236
+ " for the horizontal, vertical and spectral (or other) dimension,"
237
+ " respectively."
238
+ )
239
+
240
+ with rio.open(path, "r") as f:
241
+ cube_array = f.read()
242
+ transpose_order = (axis_order_obj.y, axis_order_obj.x, axis_order_obj.b)
243
+ cube_array = np.transpose(cube_array, transpose_order)
244
+ return cube_array
245
+
246
+
247
+ # Mapping from lowercase file extension to handler function.
248
+ CUBE_HANDLERS: dict[str, CubeHandler] = {
249
+ ".spcub": open_spcub_cube,
250
+ ".geospcub": open_spcub_cube,
251
+ ".bsq": open_rasterio_cube,
252
+ ".img": open_rasterio_cube,
253
+ ".tif": open_rasterio_cube,
254
+ }
255
+
256
+
257
+ def open_cube(
258
+ path: str | Path, axis_map: dict[str, int] = {"x": 2, "y": 1, "b": 0}
259
+ ) -> np.ndarray:
260
+ """
261
+ Open a file that stores spectral (or other) cube-based information.
262
+
263
+ This function inspects the files extension name and passes it to the
264
+ appropriate handler for that file type.
265
+
266
+ Parameters
267
+ ----------
268
+ path: str or Path
269
+ Path to file containing wavelength data that is to be opened.
270
+
271
+ Raises
272
+ ------
273
+ FileNotFoundError
274
+ If the file does not exist.
275
+ ValueError
276
+ If the file does not have a valid extension.
277
+ """
278
+ path = Path(path)
279
+ if not path.exists():
280
+ raise FileNotFoundError(path)
281
+
282
+ suffix = path.suffix.lower()
283
+
284
+ handler = CUBE_HANDLERS.get(suffix)
285
+
286
+ if handler is None:
287
+ raise ValueError(f"Unsupported file type: {suffix}")
288
+
289
+ return handler(path, axis_map)
@@ -0,0 +1,19 @@
1
+ # Dependencies
2
+ import numpy as np
3
+
4
+ # PyQt6 Imports
5
+ from PyQt6.QtWidgets import QApplication
6
+
7
+ # Local Imports
8
+ from .spectral_viewing_window import CubeViewWindow
9
+
10
+
11
+ def open_cubeview(
12
+ image_data: np.ndarray | str | None,
13
+ cube_data: np.ndarray | str | None,
14
+ wvl_data: np.ndarray | str | None,
15
+ ):
16
+ app = QApplication([])
17
+ main = CubeViewWindow(wvl_data, image_data, cube_data)
18
+ main.show()
19
+ app.exec()
@@ -0,0 +1,193 @@
1
+ from PyQt6.QtWidgets import QWidget, QVBoxLayout, QApplication
2
+ from PyQt6.QtCore import pyqtSignal, Qt, QPointF
3
+ import pyqtgraph as pg # type: ignore
4
+ from pyqtgraph.GraphicsScene.mouseEvents import MouseClickEvent # type: ignore
5
+ import numpy as np
6
+ from shapely.geometry import Polygon, Point
7
+ from alphashape import alphashape # type: ignore
8
+ import math
9
+
10
+
11
+ class ImagePickerWidget(QWidget):
12
+ mouse_moved = pyqtSignal(float, float, float)
13
+ pixel_picked = pyqtSignal(int, int)
14
+ lasso_finished = pyqtSignal(np.ndarray, np.ndarray)
15
+
16
+ def __init__(self):
17
+ super().__init__()
18
+
19
+ self.imview = pg.ImageView()
20
+ self.imview.scene.sigMouseClicked.connect( # type: ignore
21
+ self.pixel_select
22
+ )
23
+ self.imview.scene.sigMouseClicked.connect( # type: ignore
24
+ self.lasso_click
25
+ )
26
+ self.imview.scene.sigMouseMoved.connect( # type: ignore
27
+ self.lasso_movement
28
+ )
29
+ self.imview.scene.sigMouseMoved.connect( # type: ignore
30
+ self.track_cursor
31
+ )
32
+ self._drawing = False
33
+
34
+ self.lasso = pg.PolyLineROI(
35
+ [[0, 0]],
36
+ closed=True,
37
+ pen=pg.mkPen("r", width=2),
38
+ movable=False,
39
+ removable=False,
40
+ )
41
+ self.imview.getView().addItem(self.lasso)
42
+ self.lasso.setVisible(False)
43
+
44
+ layout = QVBoxLayout()
45
+ layout.addWidget(self.imview)
46
+ self.setLayout(layout)
47
+
48
+ def set_image(self, data: np.ndarray) -> None:
49
+ # Setting imview image
50
+ if data.ndim == 3:
51
+ if data.shape[-1] == 3:
52
+ _ax_interp = {"y": 0, "x": 1, "c": 2}
53
+ self.imview.setImage(data, axes=_ax_interp, levelMode="rgba")
54
+ elif data.shape[-1] > 3:
55
+ _ax_interp = {"y": 0, "x": 1, "t": 2}
56
+ self.imview.setImage(data, axes=_ax_interp, levelMode="mono")
57
+ self.imview.setCurrentIndex(0)
58
+ elif data.ndim == 2:
59
+ _ax_interp = {"y": 0, "x": 1}
60
+ self.imview.setImage(data, axes=_ax_interp, levelMode="mono")
61
+ self.img = data
62
+ else:
63
+ print("Data is the wrong number of dimensions.")
64
+ return
65
+ self.reset_levels(0.2, 99.8)
66
+
67
+ def reset_levels(
68
+ self, low_percentile: float, high_percentile: float
69
+ ) -> None:
70
+ # Setting levels
71
+ img = self.imview.image
72
+ pct_range = [low_percentile, high_percentile]
73
+ if img is None:
74
+ return
75
+ if img.ndim == 3:
76
+ if img.shape[-1] > 3:
77
+ lo, hi = np.percentile(
78
+ img[np.isfinite(img[:, :, 0]), 0], pct_range
79
+ )
80
+ self.imview.setLevels(min=lo, max=hi)
81
+ elif img.shape[-1] == 3:
82
+ rgb_lohi = []
83
+ for i in img.shape[-1]:
84
+ rgb_lohi.append(
85
+ np.percentile(
86
+ img[np.isfinite(img[:, :, i]), i],
87
+ [0.2, 99.8],
88
+ )
89
+ )
90
+ self.imview.setLevels(rgba=rgb_lohi)
91
+ else:
92
+ return
93
+
94
+ def pixel_select(self, mouse_event) -> None:
95
+ mods = QApplication.keyboardModifiers()
96
+ if mods == Qt.KeyboardModifier.ControlModifier:
97
+ return
98
+ if self._drawing:
99
+ return
100
+ img = self.imview.image
101
+ if img is None:
102
+ return
103
+ data_pos = self.imview.getView().mapSceneToView(mouse_event.pos())
104
+ x = int(data_pos.x())
105
+ y = int(data_pos.y())
106
+ if y < 0 or y > img.shape[0]:
107
+ print(f"Out of Image Bounds. ({x}, {y})")
108
+ return
109
+ if x < 0 or x > img.shape[1]:
110
+ print(f"Out of Image Bounds. ({x}, {y})")
111
+ return
112
+ self.pixel_picked.emit(y, x)
113
+
114
+ def lasso_click(self, mouse_event: MouseClickEvent) -> None:
115
+ mods = QApplication.keyboardModifiers()
116
+ if mods != Qt.KeyboardModifier.ControlModifier:
117
+ return
118
+
119
+ pos = mouse_event.scenePos()
120
+ view_pos = self.imview.getView().mapSceneToView(pos)
121
+
122
+ if not self._drawing:
123
+ self.start_lasso([[view_pos.x(), view_pos.y()]])
124
+ else:
125
+ if mouse_event.double():
126
+ self.finish_lasso()
127
+
128
+ def lasso_movement(self, pos: QPointF):
129
+ if not self._drawing:
130
+ return
131
+ view_pos = self.imview.getView().mapSceneToView(pos)
132
+ pts = self.lasso.getState()["points"]
133
+ pts.append([view_pos.x(), view_pos.y()])
134
+ self.lasso.setPoints(pts)
135
+ for h in self.lasso.handles:
136
+ h["item"].setVisible(False)
137
+
138
+ def start_lasso(self, pos):
139
+ self._drawing = True
140
+ self.lasso.clearPoints()
141
+ self.lasso.setPoints(pos)
142
+ self.lasso.setVisible(True)
143
+
144
+ def finish_lasso(self) -> None:
145
+ self._drawing = False
146
+ point_list = self.lasso.getState()["points"]
147
+ self.lasso.setVisible(False)
148
+ vertices = np.empty((len(point_list), 2))
149
+ for n, pt in enumerate(point_list):
150
+ vertices[n, :] = (pt.x(), pt.y())
151
+ poly = Polygon(vertices)
152
+ x_pts = np.asarray([i[0] for i in vertices])
153
+ y_pts = np.asarray([i[1] for i in vertices])
154
+ x_slice = slice(x_pts.min(), x_pts.max())
155
+ y_slice = slice(y_pts.min(), y_pts.max())
156
+
157
+ x_sample, y_sample = np.mgrid[x_slice, y_slice]
158
+ pt_list = [
159
+ (i, j) for i, j in zip(x_sample.flatten(), y_sample.flatten())
160
+ ]
161
+ in_x = []
162
+ in_y = []
163
+ for i in pt_list:
164
+ if poly.contains(Point(i)):
165
+ in_x.append(math.floor(i[0]))
166
+ in_y.append(math.floor(i[1]))
167
+ in_x_arr = np.asarray(in_x).astype(int)
168
+ in_y_arr = np.asarray(in_y).astype(int)
169
+ in_array = np.stack([in_x_arr, in_y_arr], axis=1)
170
+ in_pts: list[tuple[float, float]] = [
171
+ (i, j) for i, j in zip(in_x, in_y)
172
+ ]
173
+
174
+ new_poly = alphashape(points=in_pts, alpha=0.9) # type: ignore
175
+ x_verts = np.asarray(new_poly.exterior.xy[0]) # type: ignore
176
+ y_verts = np.asarray(new_poly.exterior.xy[1]) # type: ignore
177
+ xy_verts = np.concatenate([x_verts[:, None], y_verts[:, None]], axis=1)
178
+
179
+ self.lasso_finished.emit(in_array, xy_verts)
180
+
181
+ def track_cursor(self, pos) -> None:
182
+ view_pos = self.imview.getView().mapSceneToView(pos)
183
+ x_float = view_pos.x()
184
+ y_float = view_pos.y()
185
+ x_int = int(x_float)
186
+ y_int = int(y_float)
187
+ img = self.imview.getImageItem().image # axes flipped
188
+ if img is None:
189
+ return
190
+ if 0 <= y_int < img.shape[1] and 0 <= x_int < img.shape[0]:
191
+ self.mouse_moved.emit(x_float, y_float, img[x_int, y_int])
192
+ else:
193
+ self.mouse_moved.emit(-999, -999, -999)
File without changes
@@ -0,0 +1,140 @@
1
+ from PyQt6.QtWidgets import QWidget, QVBoxLayout
2
+ from PyQt6.QtCore import pyqtSignal
3
+ import pyqtgraph as pg # type: ignore
4
+ import numpy as np
5
+ from functools import partial
6
+
7
+ from .spectrum_edit_window import SpectrumEditWindow
8
+
9
+
10
+ class SpectralDisplayWidget(QWidget):
11
+ data_added = pyqtSignal(pg.PlotDataItem, pg.ErrorBarItem)
12
+ data_updated = pyqtSignal(pg.PlotDataItem, pg.ErrorBarItem, str, str)
13
+ data_removed = pyqtSignal(str)
14
+ plot_reset = pyqtSignal()
15
+
16
+ def __init__(self) -> None:
17
+ super().__init__()
18
+
19
+ self.spec_plot = pg.PlotWidget()
20
+ self.spec_legend = self.spec_plot.addLegend()
21
+
22
+ self.wvl = np.empty((0, 0, 0), dtype=np.float32)
23
+ self.cube = np.empty((0, 0, 0), dtype=np.float32)
24
+ self._count = 0
25
+ self._editing = False
26
+
27
+ layout = QVBoxLayout()
28
+ layout.addWidget(self.spec_plot)
29
+ self.setLayout(layout)
30
+
31
+ self.plot_reset.connect(self.handle_reset)
32
+
33
+ def set_cube(self, wvl: np.ndarray, data: np.ndarray) -> None:
34
+ if data.ndim != 3:
35
+ return
36
+ self.wvl = wvl
37
+ self.cube = data.astype(np.float32)
38
+
39
+ def add_spectrum(self, coord: tuple[int, int]) -> str:
40
+ self._count += 1
41
+ spectrum = self.cube[*coord, :]
42
+ spec_item = pg.PlotDataItem(
43
+ self.wvl,
44
+ spectrum,
45
+ clickable=True,
46
+ name=f"SPECTRUM_{self._count:02d}",
47
+ )
48
+ errbars = pg.ErrorBarItem(x=self.wvl, y=spectrum, height=0)
49
+ errbars.setVisible(False)
50
+
51
+ self.spec_plot.addItem(spec_item)
52
+ self.data_added.emit(spec_item, errbars)
53
+
54
+ spec_item.sigClicked.connect(
55
+ partial(self.edit_spectrum, spec_item, errbars)
56
+ )
57
+
58
+ name = spec_item.name()
59
+ if name is not None:
60
+ return name
61
+ else:
62
+ raise ValueError(f"Spectrum name ({name}) is invalid")
63
+
64
+ def add_group(self, coords: np.ndarray) -> str:
65
+ self._count += 1
66
+ if coords.ndim != 2:
67
+ raise ValueError("Group Coordinate Array is the wrong size")
68
+ if coords.shape[1] != 2:
69
+ raise ValueError("Group Coordinate Array is the wrong size")
70
+
71
+ spec_array = self.cube[coords[:, 1], coords[:, 0], :]
72
+ mean_spectrum = np.mean(spec_array, axis=0)
73
+ err_spectrum = np.std(spec_array, axis=0, ddof=1)
74
+ spec_item = pg.PlotDataItem(
75
+ self.wvl,
76
+ mean_spectrum,
77
+ clickable=True,
78
+ name=f"SPECTRUM_{self._count:02d}",
79
+ )
80
+ errbars = pg.ErrorBarItem(
81
+ x=self.wvl, y=mean_spectrum, height=2 * err_spectrum, beam=10
82
+ )
83
+
84
+ self.spec_plot.addItem(spec_item)
85
+ self.spec_plot.addItem(errbars)
86
+ self.data_added.emit(spec_item, errbars)
87
+
88
+ spec_item.sigClicked.connect(
89
+ partial(self.edit_spectrum, spec_item, errbars)
90
+ )
91
+
92
+ name = spec_item.name()
93
+ if name is not None:
94
+ return name
95
+ else:
96
+ raise ValueError("Group Name is None")
97
+
98
+ def edit_spectrum(self, plot: pg.PlotDataItem, err: pg.ErrorBarItem):
99
+ if self._editing:
100
+ print(
101
+ "Close the current spectrum edit window to edit another"
102
+ " spectrum."
103
+ )
104
+ return
105
+ self._editing = True
106
+ current_color = plot.opts["pen"]
107
+ _pen = pg.mkPen({"color": "red"})
108
+ plot.setPen(_pen)
109
+
110
+ self.edit_win = SpectrumEditWindow(plot)
111
+ self.edit_win.show()
112
+
113
+ def rename_spectrum(old_name: str, new_name: str):
114
+ self.spec_legend.removeItem(plot)
115
+ self.spec_legend.addItem(plot, plot.name())
116
+ self.data_updated.emit(plot, err, old_name, new_name)
117
+
118
+ self.edit_win.name_changed.connect(rename_spectrum)
119
+
120
+ def delete_spectrum():
121
+ # self.state.current_spectra.remove(plot_curve)
122
+ self.spec_plot.removeItem(plot)
123
+ if err is not None:
124
+ self.spec_plot.removeItem(err)
125
+ self.edit_win.close()
126
+ self._editing = False
127
+ self.data_removed.emit(plot.name())
128
+
129
+ self.edit_win.spectrum_deleted.connect(delete_spectrum)
130
+
131
+ def close_window():
132
+ self.edit_win.close()
133
+ _pen = pg.mkPen(current_color)
134
+ plot.setPen(_pen)
135
+ self._editing = False
136
+
137
+ self.edit_win.closed.connect(close_window)
138
+
139
+ def handle_reset(self):
140
+ self._count = 0
@@ -0,0 +1,188 @@
1
+ # Built-Ins
2
+ from typing import Optional
3
+ from tkinter.filedialog import askopenfilename
4
+
5
+ # PyQt6 Imports
6
+ from PyQt6 import QtWidgets as qtw
7
+ from PyQt6 import QtGui
8
+ from PyQt6 import QtCore
9
+
10
+ # Dependencies
11
+ from pyqtgraph.dockarea import Dock, DockArea # type: ignore
12
+ import numpy as np
13
+ import pyqtgraph as pg # type: ignore
14
+
15
+ # Local Imports
16
+ from .base_window import BaseWindow
17
+ from .image_display_widget import ImagePickerWidget
18
+ from .spectral_display_widget import SpectralDisplayWidget
19
+ from .file_opening_utils import open_cube, open_wvl
20
+
21
+
22
+ class CubeViewWindow(BaseWindow):
23
+ def __init__(
24
+ self,
25
+ wvl: Optional[np.ndarray | str] = None,
26
+ image_data: Optional[np.ndarray | str] = None,
27
+ cube_data: Optional[np.ndarray | str] = None,
28
+ ) -> None:
29
+ super().__init__()
30
+ self.polygon_cache: dict[str, qtw.QGraphicsPolygonItem | None] = {}
31
+
32
+ dock_area = DockArea()
33
+ self.setCentralWidget(dock_area)
34
+
35
+ # ---- Connecting menu actions to slots ----
36
+ self.open_display.triggered.connect(self.load_image)
37
+ self.open_cube.triggered.connect(self.load_cube)
38
+ self.clear_spectra.triggered.connect(self.empty_cache)
39
+
40
+ # ---- Image Dock ----
41
+ self.imview_dock = Dock(name="Image Window")
42
+ dock_area.addDock(self.imview_dock, "top")
43
+
44
+ self.img_picker = ImagePickerWidget()
45
+ self.imview_dock.addWidget(self.img_picker)
46
+
47
+ # ---- Spectral Display Dock ----
48
+ self.spec_dock = Dock(name="Spectral Display Window")
49
+ dock_area.addDock(self.spec_dock, "bottom")
50
+
51
+ self.spectral_display = SpectralDisplayWidget()
52
+ self.spec_dock.addWidget(self.spectral_display)
53
+
54
+ # ---- Linking Image and Spectral Display ----
55
+ def intercept_pixel_coord(y: int, x: int):
56
+ spectrum_name = self.spectral_display.add_spectrum((y, x))
57
+ self.polygon_cache[spectrum_name] = None
58
+
59
+ def cache_spectrum(
60
+ plot: pg.PlotDataItem, err: pg.ErrorBarItem
61
+ ) -> None:
62
+ name = plot.name()
63
+ if name is None:
64
+ return
65
+ self.state.spectrum_cache[name] = (plot, err)
66
+ n_cached = len(self.state.spectrum_cache)
67
+ self.clear_spectra.setStatusTip(
68
+ f"Clear {n_cached} spectra from memory."
69
+ )
70
+
71
+ def update_cache(
72
+ plot: pg.PlotDataItem,
73
+ err: pg.ErrorBarItem,
74
+ old_name: str,
75
+ new_name: str,
76
+ ):
77
+ del self.state.spectrum_cache[old_name]
78
+ self.state.spectrum_cache[new_name] = (plot, err)
79
+ self.polygon_cache[new_name] = self.polygon_cache.pop(old_name)
80
+
81
+ def intercept_roi(in_coords: np.ndarray, verts: np.ndarray):
82
+ group_name = self.spectral_display.add_group(in_coords)
83
+ pts = [QtCore.QPointF(*verts[n, :]) for n in range(verts.shape[0])]
84
+ poly = QtGui.QPolygonF(pts)
85
+ poly_item = qtw.QGraphicsPolygonItem(poly)
86
+ poly_item.setPen(pg.mkPen("k", width=2))
87
+ poly_item.setBrush(QtGui.QBrush(QtGui.QColor(255, 0, 0, 30)))
88
+ self.polygon_cache[group_name] = poly_item
89
+ self.img_picker.imview.getView().addItem(poly_item)
90
+
91
+ def remove_spectrum(name):
92
+ poly = self.polygon_cache[name]
93
+ if poly is not None:
94
+ self.img_picker.imview.getView().removeItem(poly)
95
+
96
+ del self.polygon_cache[name]
97
+ del self.state.spectrum_cache[name]
98
+
99
+ self.clear_spectra.setStatusTip(
100
+ f"Clear {len(self.state.spectrum_cache)} spectra from memory."
101
+ )
102
+
103
+ # Image Picker Connections
104
+ self.img_picker.pixel_picked.connect(intercept_pixel_coord)
105
+ self.img_picker.lasso_finished.connect(intercept_roi)
106
+
107
+ # Spectral Display Connections
108
+ self.spectral_display.data_added.connect(cache_spectrum)
109
+ self.spectral_display.data_updated.connect(update_cache)
110
+ self.spectral_display.data_removed.connect(remove_spectrum)
111
+
112
+ # Status Bar Connections
113
+ def cursor_message(x: float, y: float, val: float) -> None:
114
+ if x == -999 and y == -999 and val == -999:
115
+ self.status_bar.clearMessage()
116
+ return
117
+ self.status_bar.showMessage(
118
+ f"x={x:.1f} y={y:.1f} value={val:.5f}"
119
+ )
120
+
121
+ self.img_picker.mouse_moved.connect(cursor_message)
122
+
123
+ if isinstance(image_data, np.ndarray):
124
+ self.set_window_size(image_data)
125
+ self.set_image(image_data)
126
+ else:
127
+ self.resize(600, 600)
128
+
129
+ if isinstance(wvl, np.ndarray) and isinstance(cube_data, np.ndarray):
130
+ self.set_cube(wvl, cube_data)
131
+
132
+ if isinstance(image_data, str):
133
+ arr = open_cube(image_data)
134
+ self.set_image(arr)
135
+
136
+ if isinstance(wvl, str) and isinstance(cube_data, str):
137
+ arr = open_cube(cube_data)
138
+ wvl_arr = open_wvl(wvl)
139
+ self.set_cube(wvl_arr, arr)
140
+
141
+ def load_image(self) -> None:
142
+ fp = askopenfilename(
143
+ title="Select Image Data",
144
+ filetypes=[
145
+ ("Spectral Cube Files", [".spcub", ".geospcub"]),
146
+ ("Rasterio-Compatible Files", [".bsq", ".img", ".tif"]),
147
+ ],
148
+ )
149
+ arr = open_cube(fp)
150
+ self.set_image(arr)
151
+
152
+ def load_cube(self) -> None:
153
+ cube_fp = askopenfilename(
154
+ title="Select Cube Data",
155
+ filetypes=[
156
+ ("Spectral Cube Files", [".spcub", ".geospcub"]),
157
+ ("Rasterio-Compatible Files", [".bsq", ".img", ".tif"]),
158
+ ],
159
+ )
160
+ wvl_fp = askopenfilename(
161
+ title="Select Wavelength (or other context) Data",
162
+ filetypes=[
163
+ ("Wavelength File", [".wvl"]),
164
+ ("ENVI Header File", [".hdr"]),
165
+ ("Text-Based Files", [".txt", ".csv"]),
166
+ ],
167
+ )
168
+ arr = open_cube(cube_fp)
169
+ wvl = open_wvl(wvl_fp)
170
+ self.set_cube(wvl, arr)
171
+
172
+ def set_image(self, data: np.ndarray) -> None:
173
+ self.img_picker.set_image(data)
174
+ self.set_window_size(data)
175
+
176
+ def set_cube(self, wvl: np.ndarray, data: np.ndarray) -> None:
177
+ self.spectral_display.set_cube(wvl, data)
178
+
179
+ def empty_cache(self) -> None:
180
+ for plot, err in self.state.spectrum_cache.values():
181
+ self.spectral_display.spec_plot.removeItem(plot)
182
+ self.spectral_display.spec_plot.removeItem(err)
183
+ self.spectral_display.spec_legend.removeItem(plot)
184
+ for poly in self.polygon_cache.values():
185
+ if poly is None:
186
+ continue
187
+ self.img_picker.imview.getView().removeItem(poly)
188
+ self.spectral_display.plot_reset.emit()
@@ -0,0 +1,45 @@
1
+ from PyQt6 import QtWidgets as qtw
2
+ from PyQt6.QtCore import pyqtSignal
3
+ from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem # type: ignore
4
+
5
+
6
+ class SpectrumEditWindow(qtw.QWidget):
7
+ name_changed = pyqtSignal(str, str)
8
+ spectrum_deleted = pyqtSignal()
9
+ closed = pyqtSignal()
10
+
11
+ def __init__(self, edit_spec: PlotDataItem):
12
+ super().__init__()
13
+
14
+ self.edit_spec = edit_spec
15
+ layout = qtw.QVBoxLayout()
16
+
17
+ layout.addWidget(qtw.QLabel("Spectrum Name:"))
18
+
19
+ self.line_edit_widget = qtw.QLineEdit()
20
+ self.line_edit_widget.setMaxLength(80)
21
+ self.line_edit_widget.setPlaceholderText(f"{self.edit_spec.name()}")
22
+ self.line_edit_widget.returnPressed.connect(self.set_spectrum_name)
23
+
24
+ layout.addWidget(self.line_edit_widget)
25
+
26
+ delete_button = qtw.QPushButton("Delete Spectrum")
27
+ delete_button.pressed.connect(self.delete_spectrum)
28
+ layout.addWidget(delete_button)
29
+
30
+ self.setLayout(layout)
31
+ self.setWindowTitle("Spectrum Editor")
32
+
33
+ def set_spectrum_name(self):
34
+ new_name = self.line_edit_widget.text()
35
+ old_name = self.edit_spec.opts["name"]
36
+ self.edit_spec.opts["name"] = new_name
37
+ self.name_changed.emit(old_name, new_name)
38
+
39
+ def delete_spectrum(self):
40
+ self.spectrum_deleted.emit()
41
+
42
+ def closeEvent(self, a0):
43
+ self.closed.emit()
44
+ if a0 is not None:
45
+ a0.accept()