mss-imageproc 0.0.1__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.
- mss_imageproc-0.0.1/PKG-INFO +55 -0
- mss_imageproc-0.0.1/README.md +32 -0
- mss_imageproc-0.0.1/pyproject.toml +35 -0
- mss_imageproc-0.0.1/setup.cfg +4 -0
- mss_imageproc-0.0.1/src/mss_imageproc/__init__.py +22 -0
- mss_imageproc-0.0.1/src/mss_imageproc/straighten_image.py +522 -0
- mss_imageproc-0.0.1/src/mss_imageproc.egg-info/PKG-INFO +55 -0
- mss_imageproc-0.0.1/src/mss_imageproc.egg-info/SOURCES.txt +9 -0
- mss_imageproc-0.0.1/src/mss_imageproc.egg-info/dependency_links.txt +1 -0
- mss_imageproc-0.0.1/src/mss_imageproc.egg-info/requires.txt +10 -0
- mss_imageproc-0.0.1/src/mss_imageproc.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mss-imageproc
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Image processing utilities for MSS Designer Models
|
|
5
|
+
Author-email: "Sunip K. Mukherjee" <sunipkmukherjee@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: image-processing,remote-sensing,mosaic
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: astropy>=7.1
|
|
14
|
+
Requires-Dist: dacite>=1.8.1
|
|
15
|
+
Requires-Dist: natsort>=8.4.0
|
|
16
|
+
Requires-Dist: numpy>=1.26
|
|
17
|
+
Requires-Dist: scikit-image>=0.22
|
|
18
|
+
Requires-Dist: serde-dataclass>=0.0.4
|
|
19
|
+
Requires-Dist: tomlkit>=0.12.5
|
|
20
|
+
Requires-Dist: xarray>=2024.1.0
|
|
21
|
+
Requires-Dist: netcdf4>=1.6.3
|
|
22
|
+
Requires-Dist: astropy-xarray==0.1.0
|
|
23
|
+
|
|
24
|
+
# mss-imageproc
|
|
25
|
+
This repository contains the Python library `mss-imageproc`, which provides tools for processing images
|
|
26
|
+
from instruments modeled using the Multi-Slit Spectrograph (MSS) Designer. The library includes functions
|
|
27
|
+
applying image transformations to map detector images onto the focal plane of the instrument, as well as
|
|
28
|
+
remove diffraction slit curvature from the images. This library is designed to be used stand-alone, using
|
|
29
|
+
the instrument configuration files generated by the MSS Designer. This library is also used internally by
|
|
30
|
+
the MSS Designer.
|
|
31
|
+
|
|
32
|
+
# Installation
|
|
33
|
+
You can install the library using pip:
|
|
34
|
+
```bash
|
|
35
|
+
pip install mss-imageproc
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
# Usage
|
|
39
|
+
The library is used in the following way:
|
|
40
|
+
```python
|
|
41
|
+
# Import the library
|
|
42
|
+
from mss_imageproc import MosaicImageStraightener
|
|
43
|
+
|
|
44
|
+
# Create a straightener object using the instrument configuration file
|
|
45
|
+
straightener = MosaicImageStraightener.from_instrument_config('path/to/instrument_curve_maps.bin')
|
|
46
|
+
# Load an image and map it onto the mosaic grid
|
|
47
|
+
image_array = ... # Load your image as a 2D NumPy array
|
|
48
|
+
mapped_image = straightener.load_image(image_array)
|
|
49
|
+
# Straighten the image by removing slit curvature
|
|
50
|
+
straightened_images = straightener.straighten_image(mapped_image)
|
|
51
|
+
# The result is a dictionary of straightened images, one for each window. You can access them like this:
|
|
52
|
+
for window_name, straightened_image in straightened_images.items():
|
|
53
|
+
print(f"Straightened image for window {window_name}:")
|
|
54
|
+
straightened_image.show() # Show the straightened image
|
|
55
|
+
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# mss-imageproc
|
|
2
|
+
This repository contains the Python library `mss-imageproc`, which provides tools for processing images
|
|
3
|
+
from instruments modeled using the Multi-Slit Spectrograph (MSS) Designer. The library includes functions
|
|
4
|
+
applying image transformations to map detector images onto the focal plane of the instrument, as well as
|
|
5
|
+
remove diffraction slit curvature from the images. This library is designed to be used stand-alone, using
|
|
6
|
+
the instrument configuration files generated by the MSS Designer. This library is also used internally by
|
|
7
|
+
the MSS Designer.
|
|
8
|
+
|
|
9
|
+
# Installation
|
|
10
|
+
You can install the library using pip:
|
|
11
|
+
```bash
|
|
12
|
+
pip install mss-imageproc
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
# Usage
|
|
16
|
+
The library is used in the following way:
|
|
17
|
+
```python
|
|
18
|
+
# Import the library
|
|
19
|
+
from mss_imageproc import MosaicImageStraightener
|
|
20
|
+
|
|
21
|
+
# Create a straightener object using the instrument configuration file
|
|
22
|
+
straightener = MosaicImageStraightener.from_instrument_config('path/to/instrument_curve_maps.bin')
|
|
23
|
+
# Load an image and map it onto the mosaic grid
|
|
24
|
+
image_array = ... # Load your image as a 2D NumPy array
|
|
25
|
+
mapped_image = straightener.load_image(image_array)
|
|
26
|
+
# Straighten the image by removing slit curvature
|
|
27
|
+
straightened_images = straightener.straighten_image(mapped_image)
|
|
28
|
+
# The result is a dictionary of straightened images, one for each window. You can access them like this:
|
|
29
|
+
for window_name, straightened_image in straightened_images.items():
|
|
30
|
+
print(f"Straightened image for window {window_name}:")
|
|
31
|
+
straightened_image.show() # Show the straightened image
|
|
32
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mss-imageproc"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Image processing utilities for MSS Designer Models"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
authors = [{ name = "Sunip K. Mukherjee", email = "sunipkmukherjee@gmail.com" }]
|
|
12
|
+
keywords = ["image-processing", "remote-sensing", "mosaic"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"astropy>=7.1",
|
|
20
|
+
"dacite>=1.8.1",
|
|
21
|
+
"natsort>=8.4.0",
|
|
22
|
+
"numpy>=1.26",
|
|
23
|
+
"scikit-image>=0.22",
|
|
24
|
+
"serde-dataclass>=0.0.4",
|
|
25
|
+
"tomlkit>=0.12.5",
|
|
26
|
+
"xarray>=2024.1.0",
|
|
27
|
+
"netcdf4>=1.6.3",
|
|
28
|
+
"astropy-xarray==0.1.0",
|
|
29
|
+
]
|
|
30
|
+
license = { text = "MIT" }
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
package-dir = { "" = "src" }
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
__version__ = version(__package__ or 'mss_imageproc')
|
|
4
|
+
|
|
5
|
+
from .straighten_image import (
|
|
6
|
+
MosaicImageMapper,
|
|
7
|
+
MosaicImageStraightener,
|
|
8
|
+
ScaleType,
|
|
9
|
+
TranslationType,
|
|
10
|
+
PixelSizeType,
|
|
11
|
+
TransformationMatrix
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"MosaicImageMapper",
|
|
16
|
+
"MosaicImageStraightener",
|
|
17
|
+
"ScaleType",
|
|
18
|
+
"TranslationType",
|
|
19
|
+
"PixelSizeType",
|
|
20
|
+
"TransformationMatrix",
|
|
21
|
+
"__version__",
|
|
22
|
+
]
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
# %% Imports
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from json import JSONEncoder
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tarfile
|
|
6
|
+
from tempfile import TemporaryDirectory
|
|
7
|
+
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple, Union, overload
|
|
8
|
+
from dacite import Config
|
|
9
|
+
from tomlkit import register_encoder
|
|
10
|
+
from tomlkit.items import Item as TomlItem, item as tomlitem
|
|
11
|
+
from xarray import Dataset, DataArray, concat, MergeError, load_dataset
|
|
12
|
+
from astropy.units import Quantity
|
|
13
|
+
import astropy.units as u
|
|
14
|
+
from skimage.transform import warp, AffineTransform
|
|
15
|
+
from natsort import natsorted
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from numpy import arange, asarray, fromstring, interp, meshgrid, sqrt, stack, nan, ndarray, where
|
|
18
|
+
from numpy.typing import NDArray
|
|
19
|
+
from serde_dataclass import json_config, toml_config, JsonDataclass, TomlDataclass
|
|
20
|
+
import astropy_xarray as _
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
# %% Type Aliases
|
|
24
|
+
if sys.version_info >= (3, 11):
|
|
25
|
+
type MaybeQuantity = str | Quantity
|
|
26
|
+
else:
|
|
27
|
+
from typing import TypeAlias, Union
|
|
28
|
+
MaybeQuantity: TypeAlias = Union[str, Quantity]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_quantity(value: MaybeQuantity) -> Quantity:
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return Quantity(value)
|
|
34
|
+
elif isinstance(value, Quantity):
|
|
35
|
+
return value
|
|
36
|
+
else:
|
|
37
|
+
raise ValueError('Invalid quantity specification.')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def optional_quantity(value: Optional[MaybeQuantity]) -> Optional[Quantity]:
|
|
41
|
+
if value is None:
|
|
42
|
+
return None
|
|
43
|
+
return to_quantity(value)
|
|
44
|
+
|
|
45
|
+
# %% Serde Helpers
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@register_encoder
|
|
49
|
+
def qty_ndarray_encoder(obj: Any, /, _parent=None, _sort_keys=False) -> TomlItem:
|
|
50
|
+
if isinstance(obj, Quantity):
|
|
51
|
+
return tomlitem(f'{obj}')
|
|
52
|
+
elif isinstance(obj, ndarray):
|
|
53
|
+
return tomlitem(obj.tolist())
|
|
54
|
+
else:
|
|
55
|
+
raise TypeError(
|
|
56
|
+
f'Object of type {type(obj)} is not JSON serializable.')
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class QuantityEncoder(JSONEncoder):
|
|
60
|
+
def default(self, o: Any) -> Any:
|
|
61
|
+
if isinstance(o, Quantity):
|
|
62
|
+
return f'{o}'
|
|
63
|
+
elif isinstance(o, ndarray):
|
|
64
|
+
return o.tolist()
|
|
65
|
+
else:
|
|
66
|
+
return super().default(o)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class QuantityDecoder:
|
|
70
|
+
@staticmethod
|
|
71
|
+
def decode_qty(value: str) -> Quantity:
|
|
72
|
+
value = value.strip()
|
|
73
|
+
if value.startswith('['):
|
|
74
|
+
arr_str, unit = value.rsplit(']', 1)
|
|
75
|
+
arr_str = arr_str.strip('[]')
|
|
76
|
+
arr = fromstring(arr_str, sep=' ', dtype=float)
|
|
77
|
+
return Quantity(arr, unit.strip())
|
|
78
|
+
return Quantity(value)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def decode_ndarray(value: List[Any]) -> NDArray:
|
|
82
|
+
return asarray(value, dtype=float)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def config(self) -> Config:
|
|
86
|
+
return Config(
|
|
87
|
+
type_hooks={
|
|
88
|
+
Quantity: self.decode_qty,
|
|
89
|
+
ndarray: self.decode_ndarray
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
QUANTITY_DECODER = QuantityDecoder().config
|
|
95
|
+
|
|
96
|
+
# %% Definitions
|
|
97
|
+
|
|
98
|
+
ScaleType = Union[float, Tuple[float, float]]
|
|
99
|
+
TranslationType = Tuple[float, float]
|
|
100
|
+
PixelSizeType = Tuple[float, float]
|
|
101
|
+
PaddingMode = Literal['constant', 'edge', 'symmetric', 'reflect', 'wrap']
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
@json_config(ser=QuantityEncoder, de=QUANTITY_DECODER)
|
|
106
|
+
@toml_config(de=QUANTITY_DECODER)
|
|
107
|
+
class TransformationMatrix(JsonDataclass, TomlDataclass):
|
|
108
|
+
"""Reusable affine transform state and composition helper."""
|
|
109
|
+
|
|
110
|
+
matrix: ndarray = field(
|
|
111
|
+
default_factory=lambda: asarray([
|
|
112
|
+
[1.0, 0.0, 0.0],
|
|
113
|
+
[0.0, 1.0, 0.0],
|
|
114
|
+
[0.0, 0.0, 1.0],
|
|
115
|
+
], dtype=float),
|
|
116
|
+
metadata={
|
|
117
|
+
'description': '3x3 affine transformation matrix in homogeneous coordinates.',
|
|
118
|
+
'typecheck': lambda x, _: isinstance(x, (list, ndarray)) and asarray(x).shape == (3, 3),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def __post_init__(self) -> None:
|
|
123
|
+
self.matrix = asarray(self.matrix, dtype=float)
|
|
124
|
+
if self.matrix.shape != (3, 3):
|
|
125
|
+
raise ValueError('matrix must have shape (3, 3)')
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_matrix(
|
|
129
|
+
cls,
|
|
130
|
+
matrix: NDArray,
|
|
131
|
+
) -> TransformationMatrix:
|
|
132
|
+
return cls(matrix=asarray(matrix, dtype=float))
|
|
133
|
+
|
|
134
|
+
def append(self, affine: AffineTransform) -> None:
|
|
135
|
+
self.matrix = affine.params @ self.matrix
|
|
136
|
+
|
|
137
|
+
def reset(self) -> None:
|
|
138
|
+
self.matrix = asarray([
|
|
139
|
+
[1.0, 0.0, 0.0],
|
|
140
|
+
[0.0, 1.0, 0.0],
|
|
141
|
+
[0.0, 0.0, 1.0],
|
|
142
|
+
], dtype=float)
|
|
143
|
+
|
|
144
|
+
def affine(self) -> AffineTransform:
|
|
145
|
+
return AffineTransform(matrix=self.matrix.copy())
|
|
146
|
+
|
|
147
|
+
def effective_scale(self) -> Tuple[float, float]:
|
|
148
|
+
a = float(self.matrix[0, 0])
|
|
149
|
+
b = float(self.matrix[0, 1])
|
|
150
|
+
d = float(self.matrix[1, 0])
|
|
151
|
+
e = float(self.matrix[1, 1])
|
|
152
|
+
sx = float(sqrt(a * a + d * d))
|
|
153
|
+
sy = float(sqrt(b * b + e * e))
|
|
154
|
+
return abs(sx), abs(sy)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
@json_config(ser=QuantityEncoder, de=QUANTITY_DECODER)
|
|
159
|
+
@toml_config(de=QUANTITY_DECODER)
|
|
160
|
+
class MosaicImageMapper(JsonDataclass):
|
|
161
|
+
"""Map an image onto mosaic coordinates using a provided affine matrix.
|
|
162
|
+
|
|
163
|
+
This helper is intentionally lightweight compared with ``MosaicImageTransform``:
|
|
164
|
+
it requires only source image axes, mosaic bounds, and a transformation matrix.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
source_x: ndarray
|
|
168
|
+
source_y: ndarray
|
|
169
|
+
target_x: ndarray
|
|
170
|
+
target_y: ndarray
|
|
171
|
+
pixel_size: PixelSizeType
|
|
172
|
+
bounds_x: Tuple[float, float]
|
|
173
|
+
bounds_y: Tuple[float, float]
|
|
174
|
+
transform: TransformationMatrix = field(
|
|
175
|
+
default_factory=TransformationMatrix)
|
|
176
|
+
_source_x0: float = field(init=False)
|
|
177
|
+
_source_y0: float = field(init=False)
|
|
178
|
+
_inv_dx: float = field(init=False)
|
|
179
|
+
_inv_dy: float = field(init=False)
|
|
180
|
+
_use_linear_x: bool = field(init=False)
|
|
181
|
+
_use_linear_y: bool = field(init=False)
|
|
182
|
+
|
|
183
|
+
def __post_init__(self) -> None:
|
|
184
|
+
self.source_x = asarray(self.source_x, dtype=float)
|
|
185
|
+
self.source_y = asarray(self.source_y, dtype=float)
|
|
186
|
+
self.target_x = asarray(self.target_x, dtype=float)
|
|
187
|
+
self.target_y = asarray(self.target_y, dtype=float)
|
|
188
|
+
if self.source_x.ndim != 1 or self.source_y.ndim != 1:
|
|
189
|
+
raise ValueError('source_x and source_y must be 1D arrays')
|
|
190
|
+
if self.target_x.ndim != 1 or self.target_y.ndim != 1:
|
|
191
|
+
raise ValueError('target_x and target_y must be 1D arrays')
|
|
192
|
+
if self.target_x.size == 0 or self.target_y.size == 0:
|
|
193
|
+
raise ValueError('target_x and target_y must not be empty')
|
|
194
|
+
if self.pixel_size[0] <= 0 or self.pixel_size[1] <= 0:
|
|
195
|
+
raise ValueError('pixel_size must be positive')
|
|
196
|
+
if not isinstance(self.transform, TransformationMatrix):
|
|
197
|
+
self.transform = TransformationMatrix.from_matrix(
|
|
198
|
+
asarray(self.transform, dtype=float))
|
|
199
|
+
|
|
200
|
+
# Match MosaicImageTransform coordinate-index behavior for consistency.
|
|
201
|
+
self._source_x0 = float(self.source_x[0])
|
|
202
|
+
self._source_y0 = float(self.source_y[0])
|
|
203
|
+
self._inv_dx = 0.0
|
|
204
|
+
self._inv_dy = 0.0
|
|
205
|
+
self._use_linear_x = False
|
|
206
|
+
self._use_linear_y = False
|
|
207
|
+
|
|
208
|
+
if self.source_x.size >= 2:
|
|
209
|
+
dx = float(self.source_x[1] - self.source_x[0])
|
|
210
|
+
if dx != 0.0:
|
|
211
|
+
xdiff = asarray(
|
|
212
|
+
self.source_x[1:] - self.source_x[:-1], dtype=float)
|
|
213
|
+
xtol = max(1e-12, 1e-9 * abs(dx))
|
|
214
|
+
self._use_linear_x = bool((abs(xdiff - dx) <= xtol).all())
|
|
215
|
+
if self._use_linear_x:
|
|
216
|
+
self._inv_dx = 1.0 / dx
|
|
217
|
+
|
|
218
|
+
if self.source_y.size >= 2:
|
|
219
|
+
dy = float(self.source_y[1] - self.source_y[0])
|
|
220
|
+
if dy != 0.0:
|
|
221
|
+
ydiff = asarray(
|
|
222
|
+
self.source_y[1:] - self.source_y[:-1], dtype=float)
|
|
223
|
+
ytol = max(1e-12, 1e-9 * abs(dy))
|
|
224
|
+
self._use_linear_y = bool((abs(ydiff - dy) <= ytol).all())
|
|
225
|
+
if self._use_linear_y:
|
|
226
|
+
self._inv_dy = 1.0 / dy
|
|
227
|
+
|
|
228
|
+
def map_to_mosaic(
|
|
229
|
+
self,
|
|
230
|
+
image: NDArray,
|
|
231
|
+
order: int = 1,
|
|
232
|
+
cval: float = nan,
|
|
233
|
+
mode: str = 'constant',
|
|
234
|
+
) -> Tuple[DataArray, PixelSizeType]:
|
|
235
|
+
"""Render a 2D image onto the finalized full-resolution mosaic grid."""
|
|
236
|
+
image_data = asarray(image)
|
|
237
|
+
if image_data.ndim != 2:
|
|
238
|
+
raise ValueError('image must be a 2D array')
|
|
239
|
+
if self.source_x.size != image_data.shape[1]:
|
|
240
|
+
raise ValueError('source_x size must match image width')
|
|
241
|
+
if self.source_y.size != image_data.shape[0]:
|
|
242
|
+
raise ValueError('source_y size must match image height')
|
|
243
|
+
|
|
244
|
+
x_target = self.target_x
|
|
245
|
+
y_target = self.target_y
|
|
246
|
+
px, py = float(self.pixel_size[0]), float(self.pixel_size[1])
|
|
247
|
+
|
|
248
|
+
xx, yy = meshgrid(x_target, y_target)
|
|
249
|
+
inv = self.transform.affine().inverse.params
|
|
250
|
+
src_x = inv[0, 0] * xx + inv[0, 1] * yy + inv[0, 2]
|
|
251
|
+
src_y = inv[1, 0] * xx + inv[1, 1] * yy + inv[1, 2]
|
|
252
|
+
|
|
253
|
+
if self._use_linear_x:
|
|
254
|
+
col = self._coord_to_index_linear(
|
|
255
|
+
src_x, self._source_x0, self._inv_dx, self.source_x.size)
|
|
256
|
+
else:
|
|
257
|
+
col = self._coord_to_index(src_x, self.source_x)
|
|
258
|
+
|
|
259
|
+
if self._use_linear_y:
|
|
260
|
+
row = self._coord_to_index_linear(
|
|
261
|
+
src_y, self._source_y0, self._inv_dy, self.source_y.size)
|
|
262
|
+
else:
|
|
263
|
+
row = self._coord_to_index(src_y, self.source_y)
|
|
264
|
+
coords = stack((row, col), axis=0)
|
|
265
|
+
|
|
266
|
+
warped = warp(
|
|
267
|
+
image_data,
|
|
268
|
+
coords,
|
|
269
|
+
order=order,
|
|
270
|
+
cval=cval,
|
|
271
|
+
mode=mode,
|
|
272
|
+
preserve_range=True,
|
|
273
|
+
)
|
|
274
|
+
out = DataArray(
|
|
275
|
+
warped,
|
|
276
|
+
dims=('y', 'x'),
|
|
277
|
+
coords={
|
|
278
|
+
'x': ('x', Quantity(x_target, u.mm), {'units': u.mm, 'description': 'Mosaic X coordinate'}),
|
|
279
|
+
'y': ('y', Quantity(y_target, u.mm), {'units': u.mm, 'description': 'Mosaic Y coordinate'}),
|
|
280
|
+
},
|
|
281
|
+
).astropy.quantify()
|
|
282
|
+
out.attrs['pixel_scale_x_mm_per_px'] = float(px)
|
|
283
|
+
out.attrs['pixel_scale_y_mm_per_px'] = float(py)
|
|
284
|
+
out.attrs['bounds_x_mm'] = self.bounds_x
|
|
285
|
+
out.attrs['bounds_y_mm'] = self.bounds_y
|
|
286
|
+
return out, (float(px), float(py))
|
|
287
|
+
|
|
288
|
+
@staticmethod
|
|
289
|
+
def _coord_to_index(coord: NDArray, axis_values: NDArray) -> NDArray:
|
|
290
|
+
"""Map physical coordinates to floating pixel indices by linear interpolation.
|
|
291
|
+
|
|
292
|
+
The source axis may be ascending or descending.
|
|
293
|
+
Coordinates outside axis range map to ``-1`` and are handled by ``warp``
|
|
294
|
+
according to the selected boundary ``mode`` and ``cval``.
|
|
295
|
+
"""
|
|
296
|
+
idx = arange(axis_values.size, dtype=float)
|
|
297
|
+
axis = asarray(axis_values, dtype=float)
|
|
298
|
+
if axis.size < 2:
|
|
299
|
+
return interp(coord, axis, idx, left=-1.0, right=-1.0)
|
|
300
|
+
if axis[0] > axis[-1]:
|
|
301
|
+
axis = axis[::-1]
|
|
302
|
+
idx = idx[::-1]
|
|
303
|
+
return interp(coord, axis, idx, left=-1.0, right=-1.0)
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _coord_to_index_linear(
|
|
307
|
+
coord: NDArray,
|
|
308
|
+
axis0: float,
|
|
309
|
+
inv_step: float,
|
|
310
|
+
size: int,
|
|
311
|
+
) -> NDArray:
|
|
312
|
+
"""Map physical coordinates to floating indices for a uniform axis.
|
|
313
|
+
|
|
314
|
+
Coordinates outside the axis extent are mapped to ``-1`` so ``warp``
|
|
315
|
+
applies the configured boundary behavior.
|
|
316
|
+
"""
|
|
317
|
+
# Fast O(1) index computation: idx = (coord - axis0) / step
|
|
318
|
+
idx = (coord - axis0) * inv_step
|
|
319
|
+
# Out-of-bounds indices are masked to -1, triggering warp's boundary mode.
|
|
320
|
+
return asarray(
|
|
321
|
+
where((idx < 0.0) | (idx > (size - 1)), -1.0, idx),
|
|
322
|
+
dtype=float,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class MosaicImageStraightener:
|
|
327
|
+
"""Straighten images mapped onto the mosaic grid using curve maps associated with window names.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
def __init__(
|
|
331
|
+
self,
|
|
332
|
+
imaps: Dict[str, List[Dataset]],
|
|
333
|
+
mapper: MosaicImageMapper,
|
|
334
|
+
):
|
|
335
|
+
self._mapper = mapper
|
|
336
|
+
self._imaps = imaps
|
|
337
|
+
self._windows = list(imaps.keys())
|
|
338
|
+
|
|
339
|
+
@classmethod
|
|
340
|
+
def load(
|
|
341
|
+
cls,
|
|
342
|
+
archive_path: Path,
|
|
343
|
+
) -> MosaicImageStraightener:
|
|
344
|
+
"""Load a MosaicImageStraightener from a binary bundle containing a MosaicImageMapper and associated curve maps.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
archive_path (Path): The path to the archive file containing the mosaic image straightener data.
|
|
348
|
+
|
|
349
|
+
Raises:
|
|
350
|
+
ValueError: If the archive file does not exist or if required data is missing.
|
|
351
|
+
ValueError: If the mosaic image mapper is not found in the archive.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
MosaicImageStraightener: The loaded mosaic image straightener.
|
|
355
|
+
"""
|
|
356
|
+
if not archive_path.exists():
|
|
357
|
+
raise ValueError(f'Archive {archive_path} does not exist')
|
|
358
|
+
mapper = None
|
|
359
|
+
with tarfile.open(archive_path, "r:xz") as tar:
|
|
360
|
+
with TemporaryDirectory() as tmpdir:
|
|
361
|
+
tmpdir = Path(tmpdir)
|
|
362
|
+
tmpdir.mkdir(exist_ok=True, parents=True)
|
|
363
|
+
tar.extractall(path=tmpdir)
|
|
364
|
+
imaps = {}
|
|
365
|
+
for dsdir in tmpdir.iterdir():
|
|
366
|
+
if not dsdir.is_dir():
|
|
367
|
+
# Load the mapper
|
|
368
|
+
if dsdir.suffix == '.json' and '_mapper' in dsdir.stem:
|
|
369
|
+
mapper = MosaicImageMapper.from_json(dsdir.read_text())
|
|
370
|
+
else:
|
|
371
|
+
# Load the curve maps
|
|
372
|
+
win_name = dsdir.name
|
|
373
|
+
datasets = []
|
|
374
|
+
for dsfile in natsorted(dsdir.iterdir()):
|
|
375
|
+
if not dsfile.is_file() or dsfile.suffix != '.nc':
|
|
376
|
+
continue
|
|
377
|
+
ds = load_dataset(dsfile)
|
|
378
|
+
datasets.append(ds)
|
|
379
|
+
imaps[win_name] = datasets
|
|
380
|
+
if mapper is None:
|
|
381
|
+
raise ValueError('MosaicImageMapper not found in archive')
|
|
382
|
+
return cls(imaps, mapper)
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def windows(self) -> List[str]:
|
|
386
|
+
"""Return the list of available window names for straightening.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
List[str]: The list of available window names for straightening.
|
|
390
|
+
"""
|
|
391
|
+
return self._windows
|
|
392
|
+
|
|
393
|
+
def load_image(
|
|
394
|
+
self,
|
|
395
|
+
image: NDArray,
|
|
396
|
+
order: int = 1,
|
|
397
|
+
cval: float = nan,
|
|
398
|
+
mode: PaddingMode = 'constant',
|
|
399
|
+
) -> DataArray:
|
|
400
|
+
"""Load an image onto the mosaic grid using the mapper, preparing it for straightening.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
image (NDArray): The input image to be mapped onto the mosaic grid. Must be a 2D array.
|
|
404
|
+
order (int, optional): The interpolation order to use. Defaults to 1 (bilinear). Available options are 0 (nearest), 1 (bilinear), 2 (biquadratic), 3 (bicubic), 4 (biquartic), and 5 (biquintic).
|
|
405
|
+
cval (float, optional): The constant value to use for padding. Defaults to nan.
|
|
406
|
+
mode (PaddingMode, optional): The padding mode to use. Defaults to 'constant'.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
DataArray: The image mapped onto the mosaic grid.
|
|
410
|
+
"""
|
|
411
|
+
da, _ = self._mapper.map_to_mosaic(
|
|
412
|
+
image, order, cval, mode)
|
|
413
|
+
return da
|
|
414
|
+
|
|
415
|
+
@overload
|
|
416
|
+
def straighten_image(
|
|
417
|
+
self,
|
|
418
|
+
image: DataArray,
|
|
419
|
+
window: str,
|
|
420
|
+
*,
|
|
421
|
+
inplace: bool
|
|
422
|
+
) -> DataArray: ...
|
|
423
|
+
|
|
424
|
+
@overload
|
|
425
|
+
def straighten_image(
|
|
426
|
+
self,
|
|
427
|
+
image: DataArray,
|
|
428
|
+
window: List[str],
|
|
429
|
+
*,
|
|
430
|
+
inplace: bool
|
|
431
|
+
) -> Dict[str, DataArray]: ...
|
|
432
|
+
|
|
433
|
+
def straighten_image(
|
|
434
|
+
self,
|
|
435
|
+
image: DataArray,
|
|
436
|
+
window: str | List[str],
|
|
437
|
+
*,
|
|
438
|
+
inplace: bool = True
|
|
439
|
+
) -> DataArray | Dict[str, DataArray]:
|
|
440
|
+
"""Straighten the given image using the curve maps associated with the specified window name(s).
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
image (DataArray): The input image to be straightened, already mapped onto the mosaic grid.
|
|
444
|
+
window (str | Sequence[str]): The name(s) of the window(s) to use for straightening.
|
|
445
|
+
inplace (bool, optional): If True, the input image will be modified in place. Defaults to True.
|
|
446
|
+
|
|
447
|
+
Raises:
|
|
448
|
+
ValueError: If the straightener is not properly initialized or if window is invalid.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
DataArray | Dict[str, DataArray]: _description_
|
|
452
|
+
"""
|
|
453
|
+
if self._imaps is None:
|
|
454
|
+
raise ValueError('Must setup first')
|
|
455
|
+
if isinstance(window, str):
|
|
456
|
+
ret: List = []
|
|
457
|
+
for ds in self._imaps.get(window, []):
|
|
458
|
+
# Trick to select the same location across different datasets
|
|
459
|
+
imageset = Dataset(
|
|
460
|
+
data_vars={
|
|
461
|
+
'image': (('y', 'x'), image.values),
|
|
462
|
+
'loc': (('y', 'x'), ds['loc'].data, ds['loc'].attrs),
|
|
463
|
+
},
|
|
464
|
+
coords={
|
|
465
|
+
'y': (('y',), image['y'].values, {'units': u.mm, 'description': 'Mosaic coordinate, increasing from the bottom.'}),
|
|
466
|
+
'x': (('x',), image['x'].values, {'units': u.mm, 'description': 'Mosaic coordinate, increasing from the left.'}),
|
|
467
|
+
},
|
|
468
|
+
).astropy.quantify()
|
|
469
|
+
xran = ds.attrs['xran']
|
|
470
|
+
yran = ds.attrs['yran']
|
|
471
|
+
xran = (Quantity(xran[0], u.mm), Quantity(xran[1], u.mm))
|
|
472
|
+
yran = (Quantity(yran[0], u.mm), Quantity(yran[1], u.mm))
|
|
473
|
+
xform = ds['xform'].data
|
|
474
|
+
res = ds['resolution']
|
|
475
|
+
data = imageset.where(imageset['loc'], drop=True)['image']
|
|
476
|
+
data = data.sel(y=slice(*yran), x=slice(*xran))
|
|
477
|
+
if inplace:
|
|
478
|
+
try:
|
|
479
|
+
data /= res.data
|
|
480
|
+
except (MergeError, ValueError):
|
|
481
|
+
data = data.data / res.data
|
|
482
|
+
else:
|
|
483
|
+
data = data / res.data
|
|
484
|
+
data = warp(
|
|
485
|
+
data.values[:, :], xform, cval=nan)
|
|
486
|
+
out = DataArray(data*10, coords={
|
|
487
|
+
'y': (
|
|
488
|
+
('y',),
|
|
489
|
+
ds['wly'].data, # type: ignore
|
|
490
|
+
{
|
|
491
|
+
'units': 'mm',
|
|
492
|
+
'description': 'Height in the mosaic coordinate, increasing from the bottom.'
|
|
493
|
+
}
|
|
494
|
+
),
|
|
495
|
+
'wavelength': (
|
|
496
|
+
'wavelength',
|
|
497
|
+
ds['wavelength'].data / 10.0, # type: ignore
|
|
498
|
+
{
|
|
499
|
+
'units': 'nm',
|
|
500
|
+
'description': 'Wavelength in nanometers',
|
|
501
|
+
}
|
|
502
|
+
),
|
|
503
|
+
})
|
|
504
|
+
ret.append(out)
|
|
505
|
+
out: DataArray = concat(ret, dim='y', join='outer') # type: ignore
|
|
506
|
+
out = out.sortby('y')
|
|
507
|
+
out = out.sortby('wavelength')
|
|
508
|
+
if image.attrs.get('units') is not None:
|
|
509
|
+
out.attrs['units'] = image.attrs['units'] + ' / nm'
|
|
510
|
+
else:
|
|
511
|
+
out.attrs['units'] = '1 / nm'
|
|
512
|
+
return out
|
|
513
|
+
elif isinstance(window, Sequence):
|
|
514
|
+
if len(window) == 0:
|
|
515
|
+
window = self.windows
|
|
516
|
+
return {
|
|
517
|
+
name: self.straighten_image(image, name, inplace=inplace)
|
|
518
|
+
for name in window
|
|
519
|
+
}
|
|
520
|
+
else:
|
|
521
|
+
raise ValueError(
|
|
522
|
+
'win_name must be a string or an iterable of strings')
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mss-imageproc
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Image processing utilities for MSS Designer Models
|
|
5
|
+
Author-email: "Sunip K. Mukherjee" <sunipkmukherjee@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: image-processing,remote-sensing,mosaic
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: astropy>=7.1
|
|
14
|
+
Requires-Dist: dacite>=1.8.1
|
|
15
|
+
Requires-Dist: natsort>=8.4.0
|
|
16
|
+
Requires-Dist: numpy>=1.26
|
|
17
|
+
Requires-Dist: scikit-image>=0.22
|
|
18
|
+
Requires-Dist: serde-dataclass>=0.0.4
|
|
19
|
+
Requires-Dist: tomlkit>=0.12.5
|
|
20
|
+
Requires-Dist: xarray>=2024.1.0
|
|
21
|
+
Requires-Dist: netcdf4>=1.6.3
|
|
22
|
+
Requires-Dist: astropy-xarray==0.1.0
|
|
23
|
+
|
|
24
|
+
# mss-imageproc
|
|
25
|
+
This repository contains the Python library `mss-imageproc`, which provides tools for processing images
|
|
26
|
+
from instruments modeled using the Multi-Slit Spectrograph (MSS) Designer. The library includes functions
|
|
27
|
+
applying image transformations to map detector images onto the focal plane of the instrument, as well as
|
|
28
|
+
remove diffraction slit curvature from the images. This library is designed to be used stand-alone, using
|
|
29
|
+
the instrument configuration files generated by the MSS Designer. This library is also used internally by
|
|
30
|
+
the MSS Designer.
|
|
31
|
+
|
|
32
|
+
# Installation
|
|
33
|
+
You can install the library using pip:
|
|
34
|
+
```bash
|
|
35
|
+
pip install mss-imageproc
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
# Usage
|
|
39
|
+
The library is used in the following way:
|
|
40
|
+
```python
|
|
41
|
+
# Import the library
|
|
42
|
+
from mss_imageproc import MosaicImageStraightener
|
|
43
|
+
|
|
44
|
+
# Create a straightener object using the instrument configuration file
|
|
45
|
+
straightener = MosaicImageStraightener.from_instrument_config('path/to/instrument_curve_maps.bin')
|
|
46
|
+
# Load an image and map it onto the mosaic grid
|
|
47
|
+
image_array = ... # Load your image as a 2D NumPy array
|
|
48
|
+
mapped_image = straightener.load_image(image_array)
|
|
49
|
+
# Straighten the image by removing slit curvature
|
|
50
|
+
straightened_images = straightener.straighten_image(mapped_image)
|
|
51
|
+
# The result is a dictionary of straightened images, one for each window. You can access them like this:
|
|
52
|
+
for window_name, straightened_image in straightened_images.items():
|
|
53
|
+
print(f"Straightened image for window {window_name}:")
|
|
54
|
+
straightened_image.show() # Show the straightened image
|
|
55
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/mss_imageproc/__init__.py
|
|
4
|
+
src/mss_imageproc/straighten_image.py
|
|
5
|
+
src/mss_imageproc.egg-info/PKG-INFO
|
|
6
|
+
src/mss_imageproc.egg-info/SOURCES.txt
|
|
7
|
+
src/mss_imageproc.egg-info/dependency_links.txt
|
|
8
|
+
src/mss_imageproc.egg-info/requires.txt
|
|
9
|
+
src/mss_imageproc.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mss_imageproc
|