astroimred 0.4.0__py3-none-any.whl
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.
- astroimred/__init__.py +18 -0
- astroimred/_core/__init__.py +9 -0
- astroimred/_core/astropy_helpers.py +299 -0
- astroimred/_core/geometry.py +268 -0
- astroimred/_core/numeric.py +531 -0
- astroimred/_core/scales.py +61 -0
- astroimred/_core/system.py +36 -0
- astroimred/_core/types.py +30 -0
- astroimred/_core/wcs.py +122 -0
- astroimred/cli.py +44 -0
- astroimred/external/__init__.py +7 -0
- astroimred/external/sep.py +838 -0
- astroimred/fitsmgmt/__init__.py +9 -0
- astroimred/fitsmgmt/airmass.py +464 -0
- astroimred/fitsmgmt/header.py +778 -0
- astroimred/fitsmgmt/io.py +1029 -0
- astroimred/fitsmgmt/naming.py +276 -0
- astroimred/fitsmgmt/table.py +904 -0
- astroimred/fitsmgmt/wcs.py +683 -0
- astroimred/imutil/__init__.py +6 -0
- astroimred/imutil/_docstrings.py +360 -0
- astroimred/imutil/_util_fits.py +1372 -0
- astroimred/imutil/ccdops.py +865 -0
- astroimred/imutil/imarith.py +420 -0
- astroimred/imutil/imcombine.py +1057 -0
- astroimred/imutil/imcopy.py +160 -0
- astroimred/imutil/imsmooth.py +45 -0
- astroimred/imutil/imstat.py +322 -0
- astroimred/imutil/pixels.py +456 -0
- astroimred/logging.py +35 -0
- astroimred/phot/__init__.py +14 -0
- astroimred/phot/aperture.py +159 -0
- astroimred/phot/apphot.py +549 -0
- astroimred/phot/background.py +565 -0
- astroimred/phot/center.py +1277 -0
- astroimred/phot/daopsf.py +479 -0
- astroimred/phot/growth.py +164 -0
- astroimred/phot/polarimetry.py +1006 -0
- astroimred/phot/radprof.py +480 -0
- astroimred/reduction/__init__.py +3 -0
- astroimred/reduction/_presets.py +74 -0
- astroimred/reduction/cli.py +744 -0
- astroimred/reduction/crrej.py +898 -0
- astroimred/reduction/preproc.py +1011 -0
- astroimred/viz/__init__.py +4 -0
- astroimred/viz/imshow.py +251 -0
- astroimred/viz/ticks.py +77 -0
- astroimred-0.4.0.dist-info/METADATA +63 -0
- astroimred-0.4.0.dist-info/RECORD +52 -0
- astroimred-0.4.0.dist-info/WHEEL +4 -0
- astroimred-0.4.0.dist-info/entry_points.txt +3 -0
- astroimred-0.4.0.dist-info/licenses/LICENSE +28 -0
astroimred/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""astroimred: astronomical image reduction and FITS utilities."""
|
|
2
|
+
|
|
3
|
+
from . import _core, external, fitsmgmt, imutil, phot, viz
|
|
4
|
+
from ._core.astropy_helpers import *
|
|
5
|
+
from ._core.geometry import *
|
|
6
|
+
from ._core.numeric import *
|
|
7
|
+
from ._core.scales import *
|
|
8
|
+
from ._core.system import *
|
|
9
|
+
from .fitsmgmt.airmass import *
|
|
10
|
+
from .fitsmgmt.header import *
|
|
11
|
+
from .fitsmgmt.io import *
|
|
12
|
+
from .fitsmgmt.naming import *
|
|
13
|
+
from .fitsmgmt.table import *
|
|
14
|
+
from .fitsmgmt.wcs import *
|
|
15
|
+
from .imutil.ccdops import *
|
|
16
|
+
from .imutil.imstat import *
|
|
17
|
+
from .imutil.pixels import *
|
|
18
|
+
from .logging import enable_console_logging, logger, set_log_level
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Small helpers built around Astropy units, modeling, and visualization."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from astropy import units as u
|
|
7
|
+
from astropy.modeling.fitting import LevMarLSQFitter
|
|
8
|
+
from astropy.modeling.functional_models import Gaussian2D
|
|
9
|
+
from astropy.time import Time
|
|
10
|
+
from astropy.visualization import (
|
|
11
|
+
AsinhStretch,
|
|
12
|
+
AsymmetricPercentileInterval,
|
|
13
|
+
BaseInterval,
|
|
14
|
+
BaseStretch,
|
|
15
|
+
LinearStretch,
|
|
16
|
+
LogStretch,
|
|
17
|
+
PercentileInterval,
|
|
18
|
+
PowerStretch,
|
|
19
|
+
SinhStretch,
|
|
20
|
+
SqrtStretch,
|
|
21
|
+
SquaredStretch,
|
|
22
|
+
ZScaleInterval,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .numeric import normalize
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"as_quantity",
|
|
29
|
+
"str_now",
|
|
30
|
+
"parse_stretch",
|
|
31
|
+
"parse_interval",
|
|
32
|
+
"Gaussian2D_correct",
|
|
33
|
+
"fit_astropy_model",
|
|
34
|
+
"fit_Gaussian2D",
|
|
35
|
+
"gaussian_kernel",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
_STRETCH_CLASS_MAP: dict[str, type[BaseStretch]] = {
|
|
39
|
+
"linear": LinearStretch,
|
|
40
|
+
"sqrt": SqrtStretch,
|
|
41
|
+
"asinh": AsinhStretch,
|
|
42
|
+
"log": LogStretch,
|
|
43
|
+
"power": PowerStretch,
|
|
44
|
+
"sinh": SinhStretch,
|
|
45
|
+
"square": SquaredStretch,
|
|
46
|
+
"squared": SquaredStretch,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_STRETCH_DEFAULTS: dict[str, dict[str, float]] = {
|
|
50
|
+
"asinh": {"a": 0.1},
|
|
51
|
+
"log": {"a": 1000.0},
|
|
52
|
+
"power": {"a": 1.0},
|
|
53
|
+
"sinh": {"a": 0.3},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def as_quantity(
|
|
58
|
+
value: object,
|
|
59
|
+
unit: str | u.Unit = "",
|
|
60
|
+
to_value: bool = False,
|
|
61
|
+
) -> u.Quantity | object:
|
|
62
|
+
"""Convert an object to an Astropy Quantity or scalar in the requested unit.
|
|
63
|
+
|
|
64
|
+
Non-Quantity inputs are multiplied by ``unit`` unless ``to_value=True``.
|
|
65
|
+
Objects that cannot be multiplied by a unit are copied and returned as-is.
|
|
66
|
+
"""
|
|
67
|
+
if value is None:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
return value.to(unit).value if to_value else value.to(unit)
|
|
72
|
+
except AttributeError:
|
|
73
|
+
if to_value:
|
|
74
|
+
return _copy(value)
|
|
75
|
+
if isinstance(unit, str):
|
|
76
|
+
unit = u.Unit(unit)
|
|
77
|
+
try:
|
|
78
|
+
return value * unit
|
|
79
|
+
except TypeError:
|
|
80
|
+
return _copy(value)
|
|
81
|
+
except TypeError:
|
|
82
|
+
return _copy(value)
|
|
83
|
+
except u.UnitConversionError as err:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"If you use astropy.Quantity, you should use unit convertible to `unit`."
|
|
86
|
+
+ f'\nYou gave "{value.unit}", unconvertible with "{unit}".'
|
|
87
|
+
) from err
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _copy(x: object) -> object:
|
|
91
|
+
try:
|
|
92
|
+
return x.copy()
|
|
93
|
+
except AttributeError:
|
|
94
|
+
return copy.deepcopy(x)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def str_now(
|
|
98
|
+
precision: int = 3,
|
|
99
|
+
fmt: str = "{:.>72s}",
|
|
100
|
+
t_ref: Time | None = None,
|
|
101
|
+
dt_fmt: str = "(dt = {:.3f} s)",
|
|
102
|
+
return_time: bool = False,
|
|
103
|
+
) -> str | tuple:
|
|
104
|
+
"""Get stringified time now in UT ISOT format.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
precision : `int`, optional.
|
|
109
|
+
The precision of the isot format time.
|
|
110
|
+
Default: ``3``.
|
|
111
|
+
|
|
112
|
+
fmt : `str`, optional.
|
|
113
|
+
The Python 3 format string to format the time. Examples::
|
|
114
|
+
|
|
115
|
+
* ``"{:s}"``: plain time ``2020-01-01T01:01:01.23``
|
|
116
|
+
* ``"({:s})"``: plain time in parentheses ``(2020-01-01T01:01:01.23)``
|
|
117
|
+
* ``"{:_^72s}"``: center align, filling with _.
|
|
118
|
+
Default: ``'{:.>72s}'``.
|
|
119
|
+
|
|
120
|
+
t_ref : `~astropy.time.Time`, optional.
|
|
121
|
+
The reference time. If not `None`, delta time is calculated.
|
|
122
|
+
Default: `None`.
|
|
123
|
+
|
|
124
|
+
dt_fmt : `str`, optional.
|
|
125
|
+
The Python 3 format string to format the delta time.
|
|
126
|
+
Default: ``'(dt = {:.3f} s)'``.
|
|
127
|
+
|
|
128
|
+
return_time : `bool`, optional.
|
|
129
|
+
Whether to return the time at the start of this function and the delta
|
|
130
|
+
time (`dt`), as well as the time information string. If `t_ref` is
|
|
131
|
+
`None`, `dt` is automatically set to `None`.
|
|
132
|
+
Default: `False`.
|
|
133
|
+
"""
|
|
134
|
+
now = Time(Time.now(), precision=precision)
|
|
135
|
+
timestr = now.isot
|
|
136
|
+
if t_ref is not None:
|
|
137
|
+
dt = (now - Time(t_ref)).sec
|
|
138
|
+
timestr = dt_fmt.format(dt) + " " + timestr
|
|
139
|
+
else:
|
|
140
|
+
dt = None
|
|
141
|
+
|
|
142
|
+
if return_time:
|
|
143
|
+
return fmt.format(timestr), now, dt
|
|
144
|
+
return fmt.format(timestr)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_stretch(
|
|
148
|
+
stretch: str | BaseStretch,
|
|
149
|
+
*,
|
|
150
|
+
asinh_a: float | None = None,
|
|
151
|
+
log_a: float | None = None,
|
|
152
|
+
power: float | None = None,
|
|
153
|
+
sinh_a: float | None = None,
|
|
154
|
+
**kwargs: object,
|
|
155
|
+
) -> BaseStretch:
|
|
156
|
+
"""Resolve a stretch specification to an Astropy stretch instance.
|
|
157
|
+
|
|
158
|
+
Accepts a `~astropy.visualization.BaseStretch` instance, bare names such as
|
|
159
|
+
`"sqrt"`, and full class-style names such as `"SqrtStretch"`. Extra
|
|
160
|
+
keyword arguments are forwarded to the stretch class constructor.
|
|
161
|
+
Named tuning parameters (`asinh_a`, `log_a`, `power`, `sinh_a`) override
|
|
162
|
+
the package defaults for the corresponding stretches.
|
|
163
|
+
"""
|
|
164
|
+
if isinstance(stretch, BaseStretch):
|
|
165
|
+
return stretch
|
|
166
|
+
|
|
167
|
+
key = stretch.strip().lower()
|
|
168
|
+
if key.endswith("stretch"):
|
|
169
|
+
key = key[: -len("stretch")]
|
|
170
|
+
if key not in _STRETCH_CLASS_MAP:
|
|
171
|
+
supported = ", ".join(sorted(_STRETCH_CLASS_MAP))
|
|
172
|
+
raise ValueError(f"Unknown stretch {stretch!r}. Supported names: {supported}")
|
|
173
|
+
|
|
174
|
+
params = {**_STRETCH_DEFAULTS.get(key, {})}
|
|
175
|
+
if key == "asinh" and asinh_a is not None:
|
|
176
|
+
params["a"] = asinh_a
|
|
177
|
+
elif key == "log" and log_a is not None:
|
|
178
|
+
params["a"] = log_a
|
|
179
|
+
elif key == "power" and power is not None:
|
|
180
|
+
params["a"] = power
|
|
181
|
+
elif key == "sinh" and sinh_a is not None:
|
|
182
|
+
params["a"] = sinh_a
|
|
183
|
+
params.update(kwargs)
|
|
184
|
+
return _STRETCH_CLASS_MAP[key](**params)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_interval(
|
|
188
|
+
interval: str | BaseInterval | None = None,
|
|
189
|
+
*,
|
|
190
|
+
percent: float | None = None,
|
|
191
|
+
min_percent: float | None = None,
|
|
192
|
+
max_percent: float | None = None,
|
|
193
|
+
) -> BaseInterval | None:
|
|
194
|
+
"""Resolve an interval specification to an Astropy interval instance.
|
|
195
|
+
|
|
196
|
+
Accepts a `~astropy.visualization.BaseInterval` instance, `"zscale"`,
|
|
197
|
+
`percent`, or asymmetric `min_percent`/`max_percent` bounds. Returns
|
|
198
|
+
`None` when no interval shortcut is requested.
|
|
199
|
+
"""
|
|
200
|
+
if isinstance(interval, str):
|
|
201
|
+
if interval.lower() == "zscale":
|
|
202
|
+
return ZScaleInterval()
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"Unknown interval string {interval!r}. "
|
|
205
|
+
"Use 'zscale' or a BaseInterval instance."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if interval is not None:
|
|
209
|
+
return interval
|
|
210
|
+
|
|
211
|
+
if percent is not None:
|
|
212
|
+
return PercentileInterval(percent)
|
|
213
|
+
|
|
214
|
+
if min_percent is not None or max_percent is not None:
|
|
215
|
+
lo = min_percent if min_percent is not None else 0.0
|
|
216
|
+
hi = max_percent if max_percent is not None else 100.0
|
|
217
|
+
return AsymmetricPercentileInterval(lo, hi)
|
|
218
|
+
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def Gaussian2D_correct(
|
|
223
|
+
model, theta_lower: float = -np.pi / 2, theta_upper: float = np.pi / 2
|
|
224
|
+
):
|
|
225
|
+
"""Return a Gaussian2D-like model with x as semimajor axis.
|
|
226
|
+
|
|
227
|
+
The returned copy has positive ``x_stddev``/``y_stddev``, ``x_stddev`` as
|
|
228
|
+
the semimajor-axis sigma, and ``theta`` normalized into the requested
|
|
229
|
+
interval.
|
|
230
|
+
"""
|
|
231
|
+
new_model = model.copy()
|
|
232
|
+
sig_x = np.abs(model.x_stddev.value)
|
|
233
|
+
sig_y = np.abs(model.y_stddev.value)
|
|
234
|
+
theta = model.theta.value
|
|
235
|
+
|
|
236
|
+
if sig_x > sig_y:
|
|
237
|
+
theta_norm = normalize(theta, theta_lower, theta_upper)
|
|
238
|
+
new_model.x_stddev.value = sig_x
|
|
239
|
+
new_model.y_stddev.value = sig_y
|
|
240
|
+
new_model.theta.value = theta_norm
|
|
241
|
+
else:
|
|
242
|
+
theta_norm = normalize(theta + np.pi / 2, theta_lower, theta_upper)
|
|
243
|
+
new_model.x_stddev.value = sig_y
|
|
244
|
+
new_model.y_stddev.value = sig_x
|
|
245
|
+
new_model.theta.value = theta_norm
|
|
246
|
+
|
|
247
|
+
return new_model
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def fit_astropy_model(data, model_init, sigma=None, fitter=None, **kwargs):
|
|
251
|
+
"""Fit an Astropy 2D model to image data.
|
|
252
|
+
|
|
253
|
+
``sigma`` is interpreted as per-pixel 1-sigma uncertainty and passed to
|
|
254
|
+
Astropy as ``weights=1/sigma``.
|
|
255
|
+
"""
|
|
256
|
+
yy, xx = np.mgrid[: data.shape[0], : data.shape[1]]
|
|
257
|
+
weights = 1 / sigma if sigma is not None else None
|
|
258
|
+
if fitter is None:
|
|
259
|
+
fitter = LevMarLSQFitter()
|
|
260
|
+
fitted = fitter(model_init, xx, yy, data, weights=weights, **kwargs)
|
|
261
|
+
return fitted, fitter
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def fit_Gaussian2D(data, model_init, correct=True, sigma=None, fitter=None, **kwargs):
|
|
265
|
+
"""Fit a Gaussian2D-like Astropy model and optionally normalize orientation."""
|
|
266
|
+
fitted, fitter = fit_astropy_model(
|
|
267
|
+
data=data, model_init=model_init, sigma=sigma, fitter=fitter, **kwargs
|
|
268
|
+
)
|
|
269
|
+
if correct:
|
|
270
|
+
fitted = Gaussian2D_correct(fitted)
|
|
271
|
+
return fitted, fitter
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def gaussian_kernel(fwhm=None, sigma=None, theta=0, nsigma=5, normalize_area=False):
|
|
275
|
+
"""Generate a 2D Gaussian kernel array.
|
|
276
|
+
|
|
277
|
+
Exactly one of ``fwhm`` and ``sigma`` must be supplied. Scalar widths are
|
|
278
|
+
applied to both axes; two-element widths are interpreted as ``(x, y)``.
|
|
279
|
+
"""
|
|
280
|
+
if ((fwhm is None) + (sigma is None)) != 1:
|
|
281
|
+
raise ValueError("One and only one of `fwhm` and `sigma` should be given.")
|
|
282
|
+
elif fwhm is not None:
|
|
283
|
+
sigma = fwhm / (2 * np.sqrt(2 * np.log(2)))
|
|
284
|
+
sigma = np.atleast_1d(sigma)
|
|
285
|
+
if len(sigma) == 1:
|
|
286
|
+
sigma = np.repeat(sigma, 2)
|
|
287
|
+
amp = 1 / (2 * np.pi * sigma[0] * sigma[1]) if normalize_area else 1
|
|
288
|
+
gauss = Gaussian2D(
|
|
289
|
+
amplitude=amp,
|
|
290
|
+
x_mean=0,
|
|
291
|
+
y_mean=0,
|
|
292
|
+
x_stddev=sigma[0],
|
|
293
|
+
y_stddev=sigma[1],
|
|
294
|
+
theta=theta,
|
|
295
|
+
)
|
|
296
|
+
shape = np.ceil(nsigma * sigma[::-1]).astype(int)
|
|
297
|
+
return gauss(
|
|
298
|
+
*np.ogrid[-shape[0] / 2 : shape[0] / 2 + 1, -shape[1] / 2 : shape[1] / 2 + 1]
|
|
299
|
+
)
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Objects that are
|
|
3
|
+
(1) too fundamental, so used in various places,
|
|
4
|
+
(2) completely INDEPENDENT of all other modules of this package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from astro_ndslice import listify as listify # noqa: F401
|
|
9
|
+
|
|
10
|
+
_numba_available = False
|
|
11
|
+
try:
|
|
12
|
+
import importlib.util as _ilu
|
|
13
|
+
|
|
14
|
+
_numba_available = _ilu.find_spec("numba") is not None
|
|
15
|
+
except Exception:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"sigclip_dataerr",
|
|
20
|
+
"circular_mask",
|
|
21
|
+
"enclosing_circle_radius",
|
|
22
|
+
"mask_enclosing_circle_radius",
|
|
23
|
+
"bezel_mask",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# !FIXME: not finished
|
|
28
|
+
# TODO: add err_lower, err_upper, sigma_lower, sigma_upper
|
|
29
|
+
def sigclip_dataerr(
|
|
30
|
+
val: np.ndarray,
|
|
31
|
+
err: np.ndarray,
|
|
32
|
+
cenfunc: str = "wvg",
|
|
33
|
+
sigma: float = 3,
|
|
34
|
+
maxiters: int = 3,
|
|
35
|
+
) -> tuple[np.ma.MaskedArray, np.ndarray]:
|
|
36
|
+
"""Sigma-clip values using per-point error estimates.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
val, err : array-like
|
|
41
|
+
Values and corresponding 1-sigma errors.
|
|
42
|
+
|
|
43
|
+
cenfunc : {"wvg", "avg", "average", "mean"}, optional
|
|
44
|
+
Center estimator. ``"wvg"`` uses weighted average.
|
|
45
|
+
|
|
46
|
+
sigma : float, optional
|
|
47
|
+
Rejection threshold in units of `err`.
|
|
48
|
+
|
|
49
|
+
maxiters : int, optional
|
|
50
|
+
Maximum clipping iterations.
|
|
51
|
+
"""
|
|
52
|
+
if cenfunc == "wvg":
|
|
53
|
+
from .._core.numeric import wvg
|
|
54
|
+
|
|
55
|
+
def cenfunc(val, err):
|
|
56
|
+
return wvg(val, err=err)
|
|
57
|
+
elif cenfunc in ["avg", "average", "mean"]:
|
|
58
|
+
|
|
59
|
+
def cenfunc(val, err):
|
|
60
|
+
return np.mean(val)[0] # err is dummy
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(f"{cenfunc=} is not implemented yet.")
|
|
63
|
+
|
|
64
|
+
val = np.ma.array(val)
|
|
65
|
+
val_clipped = val.compressed()
|
|
66
|
+
err_clipped = err[val.mask]
|
|
67
|
+
cen = cenfunc(val_clipped, err_clipped)
|
|
68
|
+
|
|
69
|
+
for _i in range(maxiters):
|
|
70
|
+
# calculate deviation for all (even masked) elements:
|
|
71
|
+
deviation = np.abs(val.data - cen)
|
|
72
|
+
mask = deviation > sigma * err
|
|
73
|
+
|
|
74
|
+
return val, mask
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def circular_mask(
|
|
78
|
+
shape: tuple[int, ...],
|
|
79
|
+
center: tuple | None = None,
|
|
80
|
+
radius: float | None = None,
|
|
81
|
+
center_xyz: bool = True,
|
|
82
|
+
) -> np.ndarray:
|
|
83
|
+
"""Creates an N-D circular (circular, spherical, ...) mask.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
shape : `tuple`
|
|
88
|
+
The pythonic shape, i.e., `arr.shape` (not xyz order).
|
|
89
|
+
|
|
90
|
+
center : `tuple`, `None`, optional.
|
|
91
|
+
The center of the circular mask. If `None` (default), the central
|
|
92
|
+
position is used.
|
|
93
|
+
Default: `None`.
|
|
94
|
+
|
|
95
|
+
radius : `float`, `None`, optional.
|
|
96
|
+
The radius of the mask. If `None`, the distance to the closest edge of
|
|
97
|
+
the image is used.
|
|
98
|
+
Default: `None`.
|
|
99
|
+
|
|
100
|
+
center_xyz : `bool`, optional.
|
|
101
|
+
Whether the center is in xyz order.
|
|
102
|
+
Default: `True`.
|
|
103
|
+
|
|
104
|
+
Notes
|
|
105
|
+
-----
|
|
106
|
+
Idea copied from
|
|
107
|
+
https://stackoverflow.com/questions/44865023/how-can-i-create-a-circular-mask-for-a-numpy-array
|
|
108
|
+
|
|
109
|
+
Note that this is slow due to the "general" N-D nature of the mask.
|
|
110
|
+
If you need an aperture-style 2-D mask, use ``astroapers.CircAp``.
|
|
111
|
+
"""
|
|
112
|
+
if center is None: # use the middle of the image
|
|
113
|
+
center = [npix / 2 for npix in shape[::-1]]
|
|
114
|
+
|
|
115
|
+
if center_xyz:
|
|
116
|
+
center = center[::-1]
|
|
117
|
+
|
|
118
|
+
shape = np.array(shape)
|
|
119
|
+
center = np.array(center)
|
|
120
|
+
|
|
121
|
+
if radius is None: # use the smallest distance between the center and image walls
|
|
122
|
+
radius = np.min([center, shape - center])
|
|
123
|
+
|
|
124
|
+
slices = tuple([slice(None, npix, None) for npix in shape])
|
|
125
|
+
|
|
126
|
+
zyx = np.ogrid[slices]
|
|
127
|
+
dist_sq = [((zyx[i] - center[i]) ** 2) for i in range(len(shape))]
|
|
128
|
+
dist_from_center = np.sqrt(np.sum(np.array(dist_sq, dtype=object)))
|
|
129
|
+
|
|
130
|
+
mask = dist_from_center <= radius
|
|
131
|
+
return mask
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _enclosing_circle_radius_numpy(segm, center, segm_id, output):
|
|
135
|
+
for i in range(len(segm_id)):
|
|
136
|
+
_segm_id = segm_id[i]
|
|
137
|
+
mask = segm == _segm_id
|
|
138
|
+
y, x = np.nonzero(mask)
|
|
139
|
+
rsq_max = np.max((x - center[i][0]) ** 2 + (y - center[i][1]) ** 2)
|
|
140
|
+
output[i] = np.sqrt(rsq_max)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_enclosing_circle_radius_nb = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _get_enclosing_circle_radius_nb():
|
|
147
|
+
global _enclosing_circle_radius_nb
|
|
148
|
+
if _enclosing_circle_radius_nb is None:
|
|
149
|
+
import numba as nb
|
|
150
|
+
|
|
151
|
+
@nb.njit(fastmath=False, parallel=True)
|
|
152
|
+
def _kernel(segm, center, segm_id, output):
|
|
153
|
+
for i in nb.prange(len(segm_id)):
|
|
154
|
+
_segm_id = segm_id[i]
|
|
155
|
+
mask = segm == _segm_id
|
|
156
|
+
y, x = np.nonzero(mask)
|
|
157
|
+
rsq_max = np.max((x - center[i][0]) ** 2 + (y - center[i][1]) ** 2)
|
|
158
|
+
output[i] = np.sqrt(rsq_max)
|
|
159
|
+
|
|
160
|
+
_enclosing_circle_radius_nb = _kernel
|
|
161
|
+
return _enclosing_circle_radius_nb
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def enclosing_circle_radius(
|
|
165
|
+
segm: np.ndarray,
|
|
166
|
+
center: np.ndarray,
|
|
167
|
+
segm_id: np.ndarray | None = None,
|
|
168
|
+
) -> np.ndarray:
|
|
169
|
+
"""
|
|
170
|
+
Calculate the radius of the smallest enclosing circle for a given mask.
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
segm : 2D array-like
|
|
175
|
+
The input segmentation map (binary image) where non-zero values are
|
|
176
|
+
considered as the region of interest.
|
|
177
|
+
|
|
178
|
+
center : 2-D array, optional
|
|
179
|
+
The (x, y) coordinates of the center of the circles. If not provided,
|
|
180
|
+
the center will be calculated as the centroid of the masked region.
|
|
181
|
+
|
|
182
|
+
segm_id : `list` of `int`, optional
|
|
183
|
+
The `list` of segmentation IDs to calculate the radius for. If not provided,
|
|
184
|
+
it defaults to `[1]`, which is equivalent to `True` for binary masks.
|
|
185
|
+
Default: `None`.
|
|
186
|
+
|
|
187
|
+
Returns
|
|
188
|
+
-------
|
|
189
|
+
`~numpy.ndarray`
|
|
190
|
+
The radius of the smallest enclosing circle.
|
|
191
|
+
|
|
192
|
+
Notes
|
|
193
|
+
-----
|
|
194
|
+
Since it calculates distances from the center to the pixel center, one may
|
|
195
|
+
want to add ~0.5 (or sqrt(2)*0.5) to enclose the full pixel area.
|
|
196
|
+
|
|
197
|
+
By using numba, single segmentation radius finding is ~5 times faster than
|
|
198
|
+
pure numpy, and it is boosted further if `parallel=True` is used.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
if segm_id is None:
|
|
202
|
+
segm_id = np.array([1], dtype=segm.dtype) # same as `True`
|
|
203
|
+
|
|
204
|
+
center = np.atleast_2d(center)
|
|
205
|
+
if center.shape[1] != 2:
|
|
206
|
+
raise ValueError("Center must be a 2D array with shape (N, 2)")
|
|
207
|
+
|
|
208
|
+
radii = np.empty(len(segm_id), dtype=np.float64)
|
|
209
|
+
|
|
210
|
+
if _numba_available:
|
|
211
|
+
_get_enclosing_circle_radius_nb()(segm, center, segm_id, radii)
|
|
212
|
+
else:
|
|
213
|
+
_enclosing_circle_radius_numpy(segm, center, segm_id, radii)
|
|
214
|
+
|
|
215
|
+
return radii
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def mask_enclosing_circle_radius(
|
|
219
|
+
mask: np.ndarray,
|
|
220
|
+
center: tuple[float, float] | None = None,
|
|
221
|
+
) -> float:
|
|
222
|
+
"""Return the radius enclosing all non-zero pixels in a binary mask."""
|
|
223
|
+
y, x = np.nonzero(mask)
|
|
224
|
+
|
|
225
|
+
if len(x) == 0 or len(y) == 0:
|
|
226
|
+
return 0.0
|
|
227
|
+
|
|
228
|
+
if center is None:
|
|
229
|
+
center = (float(np.mean(x)), float(np.mean(y)))
|
|
230
|
+
|
|
231
|
+
distances = np.sqrt((x - center[0]) ** 2 + (y - center[1]) ** 2)
|
|
232
|
+
return float(np.max(distances))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def bezel_mask(
|
|
236
|
+
xvals,
|
|
237
|
+
yvals,
|
|
238
|
+
nx,
|
|
239
|
+
ny,
|
|
240
|
+
bezel=(0, 0),
|
|
241
|
+
bezel_x=None,
|
|
242
|
+
bezel_y=None,
|
|
243
|
+
) -> np.ndarray:
|
|
244
|
+
"""Return mask for positions inside the image border bezel."""
|
|
245
|
+
bezel = np.array(bezel)
|
|
246
|
+
if len(bezel) == 1:
|
|
247
|
+
bezel = np.repeat(bezel, 2)
|
|
248
|
+
|
|
249
|
+
if bezel_x is None:
|
|
250
|
+
bezel_x = bezel.copy()
|
|
251
|
+
else:
|
|
252
|
+
bezel_x = np.atleast_1d(bezel_x)
|
|
253
|
+
if len(bezel_x) == 1:
|
|
254
|
+
bezel_x = np.repeat(bezel_x, 2)
|
|
255
|
+
|
|
256
|
+
if bezel_y is None:
|
|
257
|
+
bezel_y = bezel.copy()
|
|
258
|
+
else:
|
|
259
|
+
bezel_y = np.atleast_1d(bezel_y)
|
|
260
|
+
if len(bezel_y) == 1:
|
|
261
|
+
bezel_y = np.repeat(bezel_y, 2)
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
(xvals < bezel_x[0] + 0.5)
|
|
265
|
+
| (yvals < bezel_y[0] + 0.5)
|
|
266
|
+
| (xvals > (nx - bezel_x[1]) - 0.5)
|
|
267
|
+
| (yvals > (ny - bezel_y[1]) - 0.5)
|
|
268
|
+
)
|