prism-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.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: prism-imageproc
3
+ Version: 0.0.1
4
+ Summary: Image processing utilities for SpectroForged instruments, powered by PrISM
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.11
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.11
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
+ # prism-imageproc
25
+
26
+ [![DOI](https://zenodo.org/badge/1201252361.svg)](https://zenodo.org/badge/latestdoi/1201252361)
27
+
28
+ This repository contains the Python library `prism-imageproc`, which provides tools for processing images
29
+ from instruments modeled using SpectroForge (https://github.com/sunipkm/SpectroForge). The library includes functions
30
+ applying image transformations to map detector images onto the focal plane of the instrument, as well as
31
+ remove diffraction slit curvature from the images. This library is designed to be used stand-alone, using
32
+ the instrument configuration files generated by SpectroForge. This library is also used internally by
33
+ SpectroForge.
34
+
35
+ # Installation
36
+
37
+ You can install the library using pip:
38
+
39
+ ```bash
40
+ pip install prism-imageproc
41
+ ```
42
+
43
+ # Usage
44
+
45
+ The library is used in the following way:
46
+
47
+ ```python
48
+ # Import the library
49
+ from prism_imageproc import ImageStraightener
50
+ import matplotlib.pyplot as plt # For plotting the straightened images
51
+
52
+ # Create a straightener object using the instrument configuration file
53
+ straightener = ImageStraightener.from_instrument_config('path/to/instrument_curve_maps.bin')
54
+ # Load an image and map it onto the mosaic grid
55
+ image_array = ... # Load your image as a 2D NumPy array
56
+ mapped_image = straightener.load_image(image_array)
57
+ # Straighten the image by removing slit curvature
58
+ straightened_images = mapped_image.straighten_image()
59
+ # The result is a dictionary of straightened images, one for each window. You can access them like this:
60
+ for window_name, straightened_image in straightened_images.items():
61
+ print(f"Straightened image for window {window_name}:")
62
+ straightened_image.plot() # Render the straightened image
63
+ plt.show() # Display the plot
64
+ ```
@@ -0,0 +1,41 @@
1
+ # prism-imageproc
2
+
3
+ [![DOI](https://zenodo.org/badge/1201252361.svg)](https://zenodo.org/badge/latestdoi/1201252361)
4
+
5
+ This repository contains the Python library `prism-imageproc`, which provides tools for processing images
6
+ from instruments modeled using SpectroForge (https://github.com/sunipkm/SpectroForge). The library includes functions
7
+ applying image transformations to map detector images onto the focal plane of the instrument, as well as
8
+ remove diffraction slit curvature from the images. This library is designed to be used stand-alone, using
9
+ the instrument configuration files generated by SpectroForge. This library is also used internally by
10
+ SpectroForge.
11
+
12
+ # Installation
13
+
14
+ You can install the library using pip:
15
+
16
+ ```bash
17
+ pip install prism-imageproc
18
+ ```
19
+
20
+ # Usage
21
+
22
+ The library is used in the following way:
23
+
24
+ ```python
25
+ # Import the library
26
+ from prism_imageproc import ImageStraightener
27
+ import matplotlib.pyplot as plt # For plotting the straightened images
28
+
29
+ # Create a straightener object using the instrument configuration file
30
+ straightener = ImageStraightener.from_instrument_config('path/to/instrument_curve_maps.bin')
31
+ # Load an image and map it onto the mosaic grid
32
+ image_array = ... # Load your image as a 2D NumPy array
33
+ mapped_image = straightener.load_image(image_array)
34
+ # Straighten the image by removing slit curvature
35
+ straightened_images = mapped_image.straighten_image()
36
+ # The result is a dictionary of straightened images, one for each window. You can access them like this:
37
+ for window_name, straightened_image in straightened_images.items():
38
+ print(f"Straightened image for window {window_name}:")
39
+ straightened_image.plot() # Render the straightened image
40
+ plt.show() # Display the plot
41
+ ```
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "prism-imageproc"
7
+ version = "0.0.1"
8
+ description = "Image processing utilities for SpectroForged instruments, powered by PrISM"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
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.11",
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ import importlib.metadata as metadata
2
+
3
+ try:
4
+ __version__ = metadata.version(__package__ or 'prism_imageproc')
5
+ except metadata.PackageNotFoundError:
6
+ # Fallback version if package metadata is not found.
7
+ # This should be updated if the version is changed in pyproject.toml.
8
+ __version__ = '0.0.2'
9
+
10
+ from .straighten import (
11
+ ImageStraightener,
12
+ MappedImage,
13
+ )
14
+ from . import utils
15
+ from .internals import PaddingMode
16
+
17
+ __all__ = [
18
+ "ImageStraightener",
19
+ "MappedImage",
20
+ "PaddingMode",
21
+ "internals",
22
+ "utils",
23
+ "__version__",
24
+ ]
@@ -0,0 +1,250 @@
1
+ # %% Imports
2
+ from __future__ import annotations
3
+ from typing import Dict, Literal, Tuple, Union
4
+ from xarray import DataArray
5
+ from astropy.units import Quantity
6
+ import astropy.units as u
7
+ from skimage.transform import AffineTransform
8
+ from scipy.ndimage import map_coordinates
9
+ from dataclasses import dataclass, field
10
+ from numpy import arange, asarray, interp, sqrt, nan, ndarray
11
+ from numpy.typing import NDArray
12
+ from serde_dataclass import json_config, toml_config, JsonDataclass, TomlDataclass
13
+ import astropy_xarray as _
14
+
15
+ from .utils import QuantityEncoder, QUANTITY_DECODER
16
+
17
+ # %% Definitions
18
+
19
+ ScaleType = Union[float, Tuple[float, float]]
20
+ TranslationType = Tuple[float, float]
21
+ PixelSizeType = Tuple[float, float]
22
+ PaddingMode = Literal['constant', 'edge', 'symmetric', 'reflect', 'wrap']
23
+
24
+ # Map skimage-only mode names to scipy.ndimage equivalents.
25
+ _SKIMAGE_TO_SCIPY_MODE: Dict[str, str] = {
26
+ 'edge': 'nearest',
27
+ 'symmetric': 'mirror',
28
+ }
29
+
30
+
31
+ @dataclass
32
+ @json_config(ser=QuantityEncoder, de=QUANTITY_DECODER)
33
+ @toml_config(de=QUANTITY_DECODER)
34
+ class TransformMatrix(JsonDataclass, TomlDataclass):
35
+ """Reusable affine transform state and composition helper."""
36
+
37
+ matrix: ndarray = field(
38
+ default_factory=lambda: asarray([
39
+ [1.0, 0.0, 0.0],
40
+ [0.0, 1.0, 0.0],
41
+ [0.0, 0.0, 1.0],
42
+ ], dtype=float),
43
+ metadata={
44
+ 'description': '3x3 affine transformation matrix in homogeneous coordinates.',
45
+ 'typecheck': lambda x, _: isinstance(x, (list, ndarray)) and asarray(x).shape == (3, 3),
46
+ }
47
+ )
48
+
49
+ def __post_init__(self) -> None:
50
+ self.matrix = asarray(self.matrix, dtype=float)
51
+ if self.matrix.shape != (3, 3):
52
+ raise ValueError('matrix must have shape (3, 3)')
53
+
54
+ @classmethod
55
+ def from_matrix(
56
+ cls,
57
+ matrix: NDArray,
58
+ ) -> TransformMatrix:
59
+ return cls(matrix=asarray(matrix, dtype=float))
60
+
61
+ def append(self, affine: AffineTransform) -> None:
62
+ self.matrix = affine.params @ self.matrix
63
+
64
+ def reset(self) -> None:
65
+ self.matrix = asarray([
66
+ [1.0, 0.0, 0.0],
67
+ [0.0, 1.0, 0.0],
68
+ [0.0, 0.0, 1.0],
69
+ ], dtype=float)
70
+
71
+ def affine(self) -> AffineTransform:
72
+ return AffineTransform(matrix=self.matrix.copy())
73
+
74
+ def effective_scale(self) -> Tuple[float, float]:
75
+ a = float(self.matrix[0, 0])
76
+ b = float(self.matrix[0, 1])
77
+ d = float(self.matrix[1, 0])
78
+ e = float(self.matrix[1, 1])
79
+ sx = float(sqrt(a * a + d * d))
80
+ sy = float(sqrt(b * b + e * e))
81
+ return abs(sx), abs(sy)
82
+
83
+
84
+ @dataclass
85
+ @json_config(ser=QuantityEncoder, de=QUANTITY_DECODER)
86
+ @toml_config(de=QUANTITY_DECODER)
87
+ class MosaicImageMapper(JsonDataclass):
88
+ """Map an image onto mosaic coordinates using a provided affine matrix.
89
+
90
+ This helper is intentionally lightweight compared with ``MosaicImageTransform``:
91
+ it requires only source image axes, mosaic bounds, and a transformation matrix.
92
+ """
93
+
94
+ source_x: ndarray
95
+ source_y: ndarray
96
+ target_x: ndarray
97
+ target_y: ndarray
98
+ pixel_size: PixelSizeType
99
+ bounds_x: Tuple[float, float]
100
+ bounds_y: Tuple[float, float]
101
+ transform: TransformMatrix = field(
102
+ default_factory=TransformMatrix)
103
+ _source_x0: float = field(init=False)
104
+ _source_y0: float = field(init=False)
105
+ _inv_dx: float = field(init=False)
106
+ _inv_dy: float = field(init=False)
107
+ _use_linear_x: bool = field(init=False)
108
+ _use_linear_y: bool = field(init=False)
109
+
110
+ def __post_init__(self) -> None:
111
+ self.source_x = asarray(self.source_x, dtype=float)
112
+ self.source_y = asarray(self.source_y, dtype=float)
113
+ self.target_x = asarray(self.target_x, dtype=float)
114
+ self.target_y = asarray(self.target_y, dtype=float)
115
+ if self.source_x.ndim != 1 or self.source_y.ndim != 1:
116
+ raise ValueError('source_x and source_y must be 1D arrays')
117
+ if self.target_x.ndim != 1 or self.target_y.ndim != 1:
118
+ raise ValueError('target_x and target_y must be 1D arrays')
119
+ if self.target_x.size == 0 or self.target_y.size == 0:
120
+ raise ValueError('target_x and target_y must not be empty')
121
+ if self.pixel_size[0] <= 0 or self.pixel_size[1] <= 0:
122
+ raise ValueError('pixel_size must be positive')
123
+ if not isinstance(self.transform, TransformMatrix):
124
+ self.transform = TransformMatrix.from_matrix(
125
+ asarray(self.transform, dtype=float))
126
+
127
+ # Match MosaicImageTransform coordinate-index behavior for consistency.
128
+ self._source_x0 = float(self.source_x[0])
129
+ self._source_y0 = float(self.source_y[0])
130
+ self._inv_dx = 0.0
131
+ self._inv_dy = 0.0
132
+ self._use_linear_x = False
133
+ self._use_linear_y = False
134
+
135
+ if self.source_x.size >= 2:
136
+ dx = float(self.source_x[1] - self.source_x[0])
137
+ if dx != 0.0:
138
+ xdiff = asarray(
139
+ self.source_x[1:] - self.source_x[:-1], dtype=float)
140
+ xtol = max(1e-12, 1e-9 * abs(dx))
141
+ self._use_linear_x = bool((abs(xdiff - dx) <= xtol).all())
142
+ if self._use_linear_x:
143
+ self._inv_dx = 1.0 / dx
144
+
145
+ if self.source_y.size >= 2:
146
+ dy = float(self.source_y[1] - self.source_y[0])
147
+ if dy != 0.0:
148
+ ydiff = asarray(
149
+ self.source_y[1:] - self.source_y[:-1], dtype=float)
150
+ ytol = max(1e-12, 1e-9 * abs(dy))
151
+ self._use_linear_y = bool((abs(ydiff - dy) <= ytol).all())
152
+ if self._use_linear_y:
153
+ self._inv_dy = 1.0 / dy
154
+
155
+ def map_to_mosaic(
156
+ self,
157
+ image: NDArray,
158
+ order: int = 1,
159
+ cval: float = nan,
160
+ mode: str = 'constant',
161
+ ) -> Tuple[DataArray, PixelSizeType]:
162
+ """Render a 2D image onto the finalized full-resolution mosaic grid."""
163
+ image_data = asarray(image)
164
+ if image_data.ndim != 2:
165
+ raise ValueError(
166
+ f'image must be a 2D array, got {image_data.ndim}D')
167
+ if self.source_x.size != image_data.shape[1]:
168
+ raise ValueError(
169
+ f'source_x size {self.source_x.size} must match image width {image_data.shape[1]}')
170
+ if self.source_y.size != image_data.shape[0]:
171
+ raise ValueError(
172
+ f'source_y size {self.source_y.size} must match image height {image_data.shape[0]}')
173
+
174
+ x_target = self.target_x
175
+ y_target = self.target_y
176
+ px, py = float(self.pixel_size[0]), float(self.pixel_size[1])
177
+
178
+ # Broadcasting avoids allocating two full meshgrid arrays.
179
+ x_row = x_target[None, :] # shape (1, nx)
180
+ y_col = y_target[:, None] # shape (ny, 1)
181
+ inv = self.transform.affine().inverse.params
182
+ src_x = inv[0, 0] * x_row + inv[0, 1] * y_col + inv[0, 2] # (ny, nx)
183
+ src_y = inv[1, 0] * x_row + inv[1, 1] * y_col + inv[1, 2] # (ny, nx)
184
+
185
+ if self._use_linear_x:
186
+ col = self._coord_to_index_linear(
187
+ src_x, self._source_x0, self._inv_dx, self.source_x.size)
188
+ else:
189
+ col = self._coord_to_index(src_x, self.source_x)
190
+
191
+ if self._use_linear_y:
192
+ row = self._coord_to_index_linear(
193
+ src_y, self._source_y0, self._inv_dy, self.source_y.size)
194
+ else:
195
+ row = self._coord_to_index(src_y, self.source_y)
196
+
197
+ # Translate skimage-only mode names to scipy equivalents.
198
+ scipy_mode = _SKIMAGE_TO_SCIPY_MODE.get(mode, mode)
199
+ warped = map_coordinates(
200
+ image_data.astype(float, copy=False),
201
+ [row, col],
202
+ order=order,
203
+ cval=cval,
204
+ mode=scipy_mode,
205
+ prefilter=order > 1,
206
+ )
207
+ out = DataArray(
208
+ warped,
209
+ dims=('y', 'x'),
210
+ coords={
211
+ 'x': ('x', Quantity(x_target, u.mm), {'units': u.mm, 'description': 'Mosaic X coordinate'}),
212
+ 'y': ('y', Quantity(y_target, u.mm), {'units': u.mm, 'description': 'Mosaic Y coordinate'}),
213
+ },
214
+ ).astropy.quantify()
215
+ out.attrs['pixel_scale_x_mm_per_px'] = float(px)
216
+ out.attrs['pixel_scale_y_mm_per_px'] = float(py)
217
+ out.attrs['bounds_x_mm'] = self.bounds_x
218
+ out.attrs['bounds_y_mm'] = self.bounds_y
219
+ return out, (float(px), float(py))
220
+
221
+ @staticmethod
222
+ def _coord_to_index(coord: NDArray, axis_values: NDArray) -> NDArray:
223
+ """Map physical coordinates to floating pixel indices by linear interpolation.
224
+
225
+ The source axis may be ascending or descending.
226
+ Coordinates outside axis range map to ``-1`` and are handled by ``warp``
227
+ according to the selected boundary ``mode`` and ``cval``.
228
+ """
229
+ idx = arange(axis_values.size, dtype=float)
230
+ axis = asarray(axis_values, dtype=float)
231
+ if axis.size < 2:
232
+ return interp(coord, axis, idx, left=-1.0, right=-1.0)
233
+ if axis[0] > axis[-1]:
234
+ axis = axis[::-1]
235
+ idx = idx[::-1]
236
+ return interp(coord, axis, idx, left=-1.0, right=-1.0)
237
+
238
+ @staticmethod
239
+ def _coord_to_index_linear(
240
+ coord: NDArray,
241
+ axis0: float,
242
+ inv_step: float,
243
+ size: int,
244
+ ) -> NDArray:
245
+ """Map physical coordinates to floating indices for a uniform axis.
246
+
247
+ Out-of-bounds coordinates are left as-is; ``map_coordinates`` applies
248
+ ``cval`` for any index outside ``[0, size-1]`` when ``mode='constant'``.
249
+ """
250
+ return (coord - axis0) * inv_step
@@ -0,0 +1,267 @@
1
+ # %% Imports
2
+ from __future__ import annotations
3
+ from pathlib import Path
4
+ import tarfile
5
+ from tempfile import TemporaryDirectory
6
+ from typing import Dict, List, Optional, Sequence, overload
7
+ from xarray import Dataset, DataArray, concat, MergeError, load_dataset
8
+ from astropy.units import Quantity
9
+ import astropy.units as u
10
+ from scipy.ndimage import map_coordinates
11
+ from natsort import natsorted
12
+ from dataclasses import dataclass, field
13
+ from numpy import nan
14
+ from numpy.typing import NDArray
15
+ import astropy_xarray as _
16
+
17
+ from .internals import MosaicImageMapper, PaddingMode, PixelSizeType, TransformMatrix
18
+
19
+ # %% Definitions
20
+
21
+
22
+ class ImageStraightener:
23
+ """Straighten images mapped onto the mosaic grid using curve maps associated with window names.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ imaps: Dict[str, List[Dataset]],
29
+ mapper: MosaicImageMapper,
30
+ ):
31
+ self._mapper = mapper
32
+ self._imaps = imaps
33
+ self._windows = list(imaps.keys())
34
+
35
+ @classmethod
36
+ def load(
37
+ cls,
38
+ archive_path: Path,
39
+ ) -> ImageStraightener:
40
+ """Load a MosaicImageStraightener from a binary bundle containing a MosaicImageMapper and associated curve maps.
41
+
42
+ Args:
43
+ archive_path (Path): The path to the archive file containing the mosaic image straightener data.
44
+
45
+ Raises:
46
+ ValueError: If the archive file does not exist or if required data is missing.
47
+ ValueError: If the mosaic image mapper is not found in the archive.
48
+
49
+ Returns:
50
+ MosaicImageStraightener: The loaded mosaic image straightener.
51
+ """
52
+ if not archive_path.exists():
53
+ raise ValueError(f'Archive {archive_path} does not exist')
54
+ mapper = None
55
+ with tarfile.open(archive_path, "r:xz") as tar:
56
+ with TemporaryDirectory() as tmpdir:
57
+ tmpdir = Path(tmpdir)
58
+ tmpdir.mkdir(exist_ok=True, parents=True)
59
+ tar.extractall(path=tmpdir)
60
+ imaps = {}
61
+ for dsdir in tmpdir.iterdir():
62
+ if not dsdir.is_dir():
63
+ # Load the mapper
64
+ if dsdir.suffix == '.nc' and '_mapper' in dsdir.stem:
65
+ ds = load_dataset(dsdir)
66
+ mapper = MosaicImageMapper(
67
+ source_x=ds['source_x'].values,
68
+ source_y=ds['source_y'].values,
69
+ target_x=ds['target_x'].values,
70
+ target_y=ds['target_y'].values,
71
+ pixel_size=(ds.attrs['pixel_size_x'], ds.attrs['pixel_size_y']),
72
+ bounds_x=(ds.attrs['bounds_x_0'], ds.attrs['bounds_x_1']),
73
+ bounds_y=(ds.attrs['bounds_y_0'], ds.attrs['bounds_y_1']),
74
+ transform=TransformMatrix.from_matrix(ds['transform_matrix'].values),
75
+ )
76
+ else:
77
+ # Load the curve maps
78
+ win_name = dsdir.name
79
+ datasets = []
80
+ for dsfile in natsorted(dsdir.iterdir()):
81
+ if not dsfile.is_file() or dsfile.suffix != '.nc':
82
+ continue
83
+ ds = load_dataset(dsfile)
84
+ datasets.append(ds)
85
+ imaps[win_name] = datasets
86
+ if mapper is None:
87
+ raise ValueError('MosaicImageMapper not found in archive')
88
+ return cls(imaps, mapper)
89
+
90
+ @property
91
+ def windows(self) -> List[str]:
92
+ """Return the list of available window names for straightening.
93
+
94
+ Returns:
95
+ List[str]: The list of available window names for straightening.
96
+ """
97
+ return self._windows
98
+
99
+ def load_image(
100
+ self,
101
+ image: NDArray,
102
+ order: int = 1,
103
+ cval: float = nan,
104
+ mode: PaddingMode = 'constant',
105
+ ) -> MappedImage:
106
+ """Load an image onto the mosaic grid using the mapper, preparing it for straightening.
107
+
108
+ Args:
109
+ image (NDArray): The input image to be mapped onto the mosaic grid. Must be a 2D array.
110
+ 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).
111
+ cval (float, optional): The constant value to use for padding. Defaults to nan.
112
+ mode (PaddingMode, optional): The padding mode to use. Defaults to 'constant'.
113
+
114
+ Returns:
115
+ MosaicMappedImage: The image mapped onto the mosaic grid.
116
+ """
117
+ da, px = self._mapper.map_to_mosaic(
118
+ image, order, cval, mode)
119
+ mapped = MappedImage(
120
+ image=da,
121
+ pixel_size=px
122
+ )
123
+ mapped._imaps = self._imaps
124
+ mapped._windows = self._windows
125
+ return mapped
126
+
127
+
128
+ @dataclass
129
+ class MappedImage:
130
+ """An image mapped onto the mosaic grid, ready for straightening using associated curve maps."""
131
+ image: DataArray
132
+ pixel_size: PixelSizeType
133
+ _imaps: Dict[str, List[Dataset]] = field(init=False, repr=False)
134
+ _windows: List[str] = field(init=False, repr=False)
135
+
136
+ @property
137
+ def windows(self) -> List[str]:
138
+ """Return the list of available window names for straightening.
139
+
140
+ Returns:
141
+ List[str]: The list of available window names for straightening.
142
+ """
143
+ return self._windows
144
+
145
+ @overload
146
+ def straighten_image(
147
+ self,
148
+ window: str,
149
+ *,
150
+ inplace: bool = ...
151
+ ) -> DataArray: ...
152
+
153
+ @overload
154
+ def straighten_image(
155
+ self,
156
+ window: List[str],
157
+ *,
158
+ inplace: bool = ...
159
+ ) -> Dict[str, DataArray]: ...
160
+
161
+ @overload
162
+ def straighten_image(
163
+ self,
164
+ window: None = ...,
165
+ *,
166
+ inplace: bool = ...
167
+ ) -> Dict[str, DataArray]: ...
168
+
169
+ def straighten_image(
170
+ self,
171
+ window: Optional[str] | List[str] = None,
172
+ *,
173
+ inplace: bool = True
174
+ ) -> DataArray | Dict[str, DataArray]:
175
+ """Straighten the given image using the curve maps associated with the specified window name(s).
176
+
177
+ Args:
178
+ window (Optional[str | Sequence[str]]): The name(s) of the window(s) to use for straightening. If None, all available windows will be used. If a string is provided, the corresponding window will be used. If a list of strings is provided, each specified window will be used and the results will be returned in a dictionary keyed by window name. Defaults to None.
179
+ inplace (bool, optional): If True, the input image will be modified in place. Defaults to True.
180
+
181
+ Raises:
182
+ ValueError: If the straightener is not properly initialized or if window is invalid.
183
+
184
+ Returns:
185
+ DataArray | Dict[str, DataArray]: _description_
186
+ """
187
+ image = self.image
188
+ if self._imaps is None:
189
+ raise ValueError('Must setup first')
190
+ if window is None:
191
+ window = self.windows
192
+ if isinstance(window, str):
193
+ ret: List = []
194
+ for ds in self._imaps.get(window, []):
195
+ # Trick to select the same location across different datasets
196
+ imageset = Dataset(
197
+ data_vars={
198
+ 'image': (('y', 'x'), image.values),
199
+ 'loc': (('y', 'x'), ds['loc'].data, ds['loc'].attrs),
200
+ },
201
+ coords={
202
+ 'y': (('y',), image['y'].values, {'units': u.mm, 'description': 'Mosaic coordinate, increasing from the bottom.'}),
203
+ 'x': (('x',), image['x'].values, {'units': u.mm, 'description': 'Mosaic coordinate, increasing from the left.'}),
204
+ },
205
+ ).astropy.quantify()
206
+ xran = ds.attrs['xran']
207
+ yran = ds.attrs['yran']
208
+ xran = (Quantity(xran[0], u.mm), Quantity(xran[1], u.mm))
209
+ yran = (Quantity(yran[0], u.mm), Quantity(yran[1], u.mm))
210
+ xform = ds['xform'].data
211
+ res = ds['resolution']
212
+ data = imageset.where(imageset['loc'], drop=True)['image']
213
+ data = data.sel(y=slice(*yran), x=slice(*xran))
214
+ if inplace:
215
+ try:
216
+ data /= res.data
217
+ except (MergeError, ValueError):
218
+ data = data.data / res.data
219
+ else:
220
+ data = data / res.data
221
+ data = map_coordinates(
222
+ data.values[:, :], xform, cval=nan, order=1, prefilter=False)
223
+ out = DataArray(data*10, dims=('y', 'wavelength'), coords={
224
+ 'y': (
225
+ ('y',),
226
+ ds['wly'].data,
227
+ {
228
+ 'units': 'mm',
229
+ 'description': 'Height in the mosaic coordinate, increasing from the bottom.'
230
+ }
231
+ ),
232
+ 'wavelength': (
233
+ 'wavelength',
234
+ ds['wavelength'].data / 10.0,
235
+ {
236
+ 'units': 'nm',
237
+ 'description': 'Wavelength in nanometers',
238
+ }
239
+ ),
240
+ 'y_slit': (
241
+ ('y',),
242
+ ds['y_slit'].data,
243
+ {
244
+ 'units': 'mm',
245
+ 'description': 'Slit Y coordinate corresponding to mosaic Y',
246
+ }
247
+ ),
248
+ })
249
+ ret.append(out)
250
+ out: DataArray = concat(ret, dim='y', join='outer') # type: ignore
251
+ out = out.sortby('y')
252
+ out = out.sortby('wavelength')
253
+ if image.attrs.get('units') is not None:
254
+ out.attrs['units'] = image.attrs['units'] + ' / nm'
255
+ else:
256
+ out.attrs['units'] = '1 / nm'
257
+ return out
258
+ elif isinstance(window, Sequence):
259
+ if len(window) == 0:
260
+ window = self.windows
261
+ return {
262
+ name: self.straighten_image(name, inplace=inplace)
263
+ for name in window
264
+ }
265
+ else:
266
+ raise ValueError(
267
+ 'win_name must be a string or an iterable of strings')
@@ -0,0 +1,84 @@
1
+ # %% Imports
2
+ from __future__ import annotations
3
+ from typing import Any, List, Optional, Union
4
+ from dacite import Config
5
+ from tomlkit import register_encoder
6
+ from tomlkit.items import Item as TomlItem, item as tomlitem
7
+ from astropy.units import Quantity
8
+ import astropy.units as u
9
+ from numpy import ndarray, asarray, fromstring
10
+ from numpy.typing import NDArray
11
+ from json import JSONEncoder
12
+ import sys
13
+ # %% Type Aliases
14
+ if sys.version_info >= (3, 11):
15
+ type MaybeQuantity = str | Quantity
16
+ else:
17
+ from typing import TypeAlias, Union
18
+ MaybeQuantity: TypeAlias = Union[str, Quantity]
19
+
20
+
21
+ def to_quantity(value: MaybeQuantity) -> Quantity:
22
+ if isinstance(value, str):
23
+ return Quantity(value)
24
+ elif isinstance(value, Quantity):
25
+ return value
26
+ else:
27
+ raise ValueError('Invalid quantity specification.')
28
+
29
+
30
+ def optional_quantity(value: Optional[MaybeQuantity]) -> Optional[Quantity]:
31
+ if value is None:
32
+ return None
33
+ return to_quantity(value)
34
+
35
+ # %% Serde Helpers
36
+
37
+
38
+ @register_encoder
39
+ def qty_ndarray_encoder(obj: Any, /, _parent=None, _sort_keys=False) -> TomlItem:
40
+ if isinstance(obj, Quantity):
41
+ return tomlitem(f'{obj}')
42
+ elif isinstance(obj, ndarray):
43
+ return tomlitem(obj.tolist())
44
+ else:
45
+ raise TypeError(
46
+ f'Object of type {type(obj)} is not JSON serializable.')
47
+
48
+
49
+ class QuantityEncoder(JSONEncoder):
50
+ def default(self, o: Any) -> Any:
51
+ if isinstance(o, Quantity):
52
+ return f'{o}'
53
+ elif isinstance(o, ndarray):
54
+ return o.tolist()
55
+ else:
56
+ return super().default(o)
57
+
58
+
59
+ class QuantityDecoder:
60
+ @staticmethod
61
+ def decode_qty(value: str) -> Quantity:
62
+ value = value.strip()
63
+ if value.startswith('['):
64
+ arr_str, unit = value.rsplit(']', 1)
65
+ arr_str = arr_str.strip('[]')
66
+ arr = fromstring(arr_str, sep=' ', dtype=float)
67
+ return Quantity(arr, unit.strip())
68
+ return Quantity(value)
69
+
70
+ @staticmethod
71
+ def decode_ndarray(value: List[Any]) -> NDArray:
72
+ return asarray(value, dtype=float)
73
+
74
+ @property
75
+ def config(self) -> Config:
76
+ return Config(
77
+ type_hooks={
78
+ Quantity: self.decode_qty,
79
+ ndarray: self.decode_ndarray
80
+ },
81
+ )
82
+
83
+
84
+ QUANTITY_DECODER = QuantityDecoder().config
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: prism-imageproc
3
+ Version: 0.0.1
4
+ Summary: Image processing utilities for SpectroForged instruments, powered by PrISM
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.11
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.11
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
+ # prism-imageproc
25
+
26
+ [![DOI](https://zenodo.org/badge/1201252361.svg)](https://zenodo.org/badge/latestdoi/1201252361)
27
+
28
+ This repository contains the Python library `prism-imageproc`, which provides tools for processing images
29
+ from instruments modeled using SpectroForge (https://github.com/sunipkm/SpectroForge). The library includes functions
30
+ applying image transformations to map detector images onto the focal plane of the instrument, as well as
31
+ remove diffraction slit curvature from the images. This library is designed to be used stand-alone, using
32
+ the instrument configuration files generated by SpectroForge. This library is also used internally by
33
+ SpectroForge.
34
+
35
+ # Installation
36
+
37
+ You can install the library using pip:
38
+
39
+ ```bash
40
+ pip install prism-imageproc
41
+ ```
42
+
43
+ # Usage
44
+
45
+ The library is used in the following way:
46
+
47
+ ```python
48
+ # Import the library
49
+ from prism_imageproc import ImageStraightener
50
+ import matplotlib.pyplot as plt # For plotting the straightened images
51
+
52
+ # Create a straightener object using the instrument configuration file
53
+ straightener = ImageStraightener.from_instrument_config('path/to/instrument_curve_maps.bin')
54
+ # Load an image and map it onto the mosaic grid
55
+ image_array = ... # Load your image as a 2D NumPy array
56
+ mapped_image = straightener.load_image(image_array)
57
+ # Straighten the image by removing slit curvature
58
+ straightened_images = mapped_image.straighten_image()
59
+ # The result is a dictionary of straightened images, one for each window. You can access them like this:
60
+ for window_name, straightened_image in straightened_images.items():
61
+ print(f"Straightened image for window {window_name}:")
62
+ straightened_image.plot() # Render the straightened image
63
+ plt.show() # Display the plot
64
+ ```
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/prism_imageproc/__init__.py
4
+ src/prism_imageproc/internals.py
5
+ src/prism_imageproc/straighten.py
6
+ src/prism_imageproc/utils.py
7
+ src/prism_imageproc.egg-info/PKG-INFO
8
+ src/prism_imageproc.egg-info/SOURCES.txt
9
+ src/prism_imageproc.egg-info/dependency_links.txt
10
+ src/prism_imageproc.egg-info/requires.txt
11
+ src/prism_imageproc.egg-info/top_level.txt
@@ -0,0 +1,10 @@
1
+ astropy>=7.1
2
+ dacite>=1.8.1
3
+ natsort>=8.4.0
4
+ numpy>=1.26
5
+ scikit-image>=0.22
6
+ serde-dataclass>=0.0.4
7
+ tomlkit>=0.12.5
8
+ xarray>=2024.1.0
9
+ netcdf4>=1.6.3
10
+ astropy-xarray==0.1.0
@@ -0,0 +1 @@
1
+ prism_imageproc