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.
- pycubeview-0.1.0/LICENSE +21 -0
- pycubeview-0.1.0/PKG-INFO +89 -0
- pycubeview-0.1.0/README.md +64 -0
- pycubeview-0.1.0/pyproject.toml +28 -0
- pycubeview-0.1.0/src/pycubeview/__init__.py +14 -0
- pycubeview-0.1.0/src/pycubeview/base_window.py +66 -0
- pycubeview-0.1.0/src/pycubeview/cli.py +20 -0
- pycubeview-0.1.0/src/pycubeview/file_opening_utils.py +289 -0
- pycubeview-0.1.0/src/pycubeview/helper_functions.py +19 -0
- pycubeview-0.1.0/src/pycubeview/image_display_widget.py +193 -0
- pycubeview-0.1.0/src/pycubeview/py.typed +0 -0
- pycubeview-0.1.0/src/pycubeview/spectral_display_widget.py +140 -0
- pycubeview-0.1.0/src/pycubeview/spectral_viewing_window.py +188 -0
- pycubeview-0.1.0/src/pycubeview/spectrum_edit_window.py +45 -0
pycubeview-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.
|
|
@@ -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,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()
|