getframes 2.0.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.
- getframes/__about__.py +4 -0
- getframes/__init__.py +91 -0
- getframes/analysis/__init__.py +18 -0
- getframes/analysis/apertures.py +92 -0
- getframes/analysis/ptc.py +109 -0
- getframes/calibrate.py +182 -0
- getframes/camera.py +649 -0
- getframes/cli.py +214 -0
- getframes/config.py +420 -0
- getframes/dataset.py +294 -0
- getframes/frame.py +107 -0
- getframes/noise.py +637 -0
- getframes/observation.py +162 -0
- getframes/presets/__init__.py +90 -0
- getframes/presets/data/__init__.py +3 -0
- getframes/presets/data/andor_ikon_m934.toml +22 -0
- getframes/presets/data/andor_ixon_ultra_888.toml +22 -0
- getframes/presets/data/generic_ccd.toml +18 -0
- getframes/presets/data/generic_cmos.toml +18 -0
- getframes/presets/data/generic_eapd.toml +20 -0
- getframes/presets/data/generic_emccd.toml +20 -0
- getframes/presets/data/generic_scmos.toml +21 -0
- getframes/presets/data/hamamatsu_orca_fusion.toml +25 -0
- getframes/presets/data/leonardo_saphira.toml +32 -0
- getframes/presets/data/zwo_asi2600mm.toml +20 -0
- getframes/py.typed +0 -0
- getframes/scene/__init__.py +51 -0
- getframes/scene/optics.py +180 -0
- getframes/scene/photometry.py +311 -0
- getframes/scene/psf.py +371 -0
- getframes/scene/scene.py +205 -0
- getframes/scene/sources.py +683 -0
- getframes/scene/thermal.py +114 -0
- getframes/scene/wcs.py +110 -0
- getframes/spectral.py +449 -0
- getframes-2.0.0.dist-info/METADATA +218 -0
- getframes-2.0.0.dist-info/RECORD +40 -0
- getframes-2.0.0.dist-info/WHEEL +4 -0
- getframes-2.0.0.dist-info/entry_points.txt +2 -0
- getframes-2.0.0.dist-info/licenses/LICENSE +21 -0
getframes/__about__.py
ADDED
getframes/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""getframes: realistic synthetic camera frames for scientific imaging pipelines.
|
|
3
|
+
|
|
4
|
+
Quick start
|
|
5
|
+
-----------
|
|
6
|
+
>>> import getframes as gf
|
|
7
|
+
>>> cam = gf.Camera.from_preset("andor_ikon_m934")
|
|
8
|
+
>>> frame = cam.dark_frame(exposure=10.0, temperature=-60.0)
|
|
9
|
+
>>> frame.data.shape
|
|
10
|
+
(1024, 1024)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from . import analysis, dataset
|
|
16
|
+
from .__about__ import __version__
|
|
17
|
+
from .calibrate import calibrate, combine
|
|
18
|
+
from .camera import Camera
|
|
19
|
+
from .config import CameraConfig, SensorType
|
|
20
|
+
from .frame import Frame, FrameTruth
|
|
21
|
+
from .observation import Observation, ObservationTruth, Pointing
|
|
22
|
+
from .presets import available_presets, load_preset
|
|
23
|
+
from .scene import (
|
|
24
|
+
PSF,
|
|
25
|
+
AiryPSF,
|
|
26
|
+
ArrayPSF,
|
|
27
|
+
Bandpass,
|
|
28
|
+
Catalog,
|
|
29
|
+
CatalogEntry,
|
|
30
|
+
EllipticalGaussianPSF,
|
|
31
|
+
ExtendedSource,
|
|
32
|
+
Extinction,
|
|
33
|
+
GaussianPSF,
|
|
34
|
+
LightCurve,
|
|
35
|
+
MoffatPSF,
|
|
36
|
+
PointSource,
|
|
37
|
+
RadialDistortion,
|
|
38
|
+
Scene,
|
|
39
|
+
Sky,
|
|
40
|
+
Source,
|
|
41
|
+
Telescope,
|
|
42
|
+
Thermal,
|
|
43
|
+
UniformIllumination,
|
|
44
|
+
Vignetting,
|
|
45
|
+
WCSInfo,
|
|
46
|
+
)
|
|
47
|
+
from .spectral import QE, SED, SpectralBandpass, Spectrum
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"PSF",
|
|
51
|
+
"QE",
|
|
52
|
+
"SED",
|
|
53
|
+
"AiryPSF",
|
|
54
|
+
"ArrayPSF",
|
|
55
|
+
"Bandpass",
|
|
56
|
+
"Camera",
|
|
57
|
+
"CameraConfig",
|
|
58
|
+
"Catalog",
|
|
59
|
+
"CatalogEntry",
|
|
60
|
+
"EllipticalGaussianPSF",
|
|
61
|
+
"ExtendedSource",
|
|
62
|
+
"Extinction",
|
|
63
|
+
"Frame",
|
|
64
|
+
"FrameTruth",
|
|
65
|
+
"GaussianPSF",
|
|
66
|
+
"LightCurve",
|
|
67
|
+
"MoffatPSF",
|
|
68
|
+
"Observation",
|
|
69
|
+
"ObservationTruth",
|
|
70
|
+
"PointSource",
|
|
71
|
+
"Pointing",
|
|
72
|
+
"RadialDistortion",
|
|
73
|
+
"Scene",
|
|
74
|
+
"SensorType",
|
|
75
|
+
"Sky",
|
|
76
|
+
"Source",
|
|
77
|
+
"SpectralBandpass",
|
|
78
|
+
"Spectrum",
|
|
79
|
+
"Telescope",
|
|
80
|
+
"Thermal",
|
|
81
|
+
"UniformIllumination",
|
|
82
|
+
"Vignetting",
|
|
83
|
+
"WCSInfo",
|
|
84
|
+
"__version__",
|
|
85
|
+
"analysis",
|
|
86
|
+
"available_presets",
|
|
87
|
+
"calibrate",
|
|
88
|
+
"combine",
|
|
89
|
+
"dataset",
|
|
90
|
+
"load_preset",
|
|
91
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Lightweight analysis helpers (photometry, centroiding, photon transfer curve).
|
|
3
|
+
|
|
4
|
+
These exist mainly so the bundled examples stay self-contained; they are pure
|
|
5
|
+
NumPy and make no attempt to replace dedicated tools like ``photutils``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .apertures import aperture_sum, centroid
|
|
11
|
+
from .ptc import PTCResult, photon_transfer_curve
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"PTCResult",
|
|
15
|
+
"aperture_sum",
|
|
16
|
+
"centroid",
|
|
17
|
+
"photon_transfer_curve",
|
|
18
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Lightweight photometry helpers used by the examples and for quick analysis.
|
|
3
|
+
|
|
4
|
+
These are intentionally minimal (pure NumPy, no extra dependencies). For serious
|
|
5
|
+
photometry on real pipelines, reach for ``photutils``; these exist so the bundled
|
|
6
|
+
examples stay self-contained and readable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from numpy.typing import NDArray
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _radial_grid(shape: tuple[int, int], cx: float, cy: float) -> NDArray[np.float64]:
|
|
18
|
+
"""Squared distance of every pixel from ``(cx, cy)``."""
|
|
19
|
+
yy, xx = np.mgrid[0 : shape[0], 0 : shape[1]]
|
|
20
|
+
grid: NDArray[np.float64] = (xx - cx) ** 2 + (yy - cy) ** 2
|
|
21
|
+
return grid
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def aperture_sum(
|
|
25
|
+
image: NDArray[np.floating[Any] | np.integer[Any]],
|
|
26
|
+
center: tuple[float, float],
|
|
27
|
+
r: float,
|
|
28
|
+
*,
|
|
29
|
+
annulus: tuple[float, float] | None = None,
|
|
30
|
+
) -> float:
|
|
31
|
+
"""Background-subtracted sum within radius ``r`` of ``center = (x, y)``.
|
|
32
|
+
|
|
33
|
+
The background level is the median of a surrounding annulus (default: from
|
|
34
|
+
``r + 2`` to ``r + 5`` pixels), scaled to the number of aperture pixels. Pass
|
|
35
|
+
``annulus=(inner, outer)`` to control it, or ``annulus=(0, 0)`` to skip
|
|
36
|
+
background subtraction.
|
|
37
|
+
"""
|
|
38
|
+
data = np.asarray(image, dtype=np.float64)
|
|
39
|
+
cx, cy = center
|
|
40
|
+
dist2 = _radial_grid(data.shape, cx, cy)
|
|
41
|
+
in_aperture = dist2 <= r**2
|
|
42
|
+
total = float(data[in_aperture].sum())
|
|
43
|
+
|
|
44
|
+
inner, outer = annulus if annulus is not None else (r + 2.0, r + 5.0)
|
|
45
|
+
if outer > inner:
|
|
46
|
+
ring = (dist2 > inner**2) & (dist2 <= outer**2)
|
|
47
|
+
if np.any(ring):
|
|
48
|
+
background = float(np.median(data[ring]))
|
|
49
|
+
total -= background * float(in_aperture.sum())
|
|
50
|
+
return total
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def centroid(
|
|
54
|
+
image: NDArray[np.floating[Any] | np.integer[Any]],
|
|
55
|
+
*,
|
|
56
|
+
center: tuple[float, float] | None = None,
|
|
57
|
+
r: float | None = None,
|
|
58
|
+
background: float | None = None,
|
|
59
|
+
) -> tuple[float, float]:
|
|
60
|
+
"""Intensity-weighted centroid ``(x, y)`` of ``image`` (or a region of it).
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
center, r:
|
|
65
|
+
If both are given, only pixels within radius ``r`` of ``center`` are used
|
|
66
|
+
(useful for isolating one spot). Otherwise the whole image is used.
|
|
67
|
+
background:
|
|
68
|
+
Level subtracted before weighting, so the pedestal doesn't bias the
|
|
69
|
+
centroid. Defaults to the image median, which works well for a small spot
|
|
70
|
+
on a flat background.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
(x, y):
|
|
75
|
+
Sub-pixel centroid. Returns the geometric centre if there is no positive
|
|
76
|
+
signal after background subtraction.
|
|
77
|
+
"""
|
|
78
|
+
data = np.asarray(image, dtype=np.float64)
|
|
79
|
+
bg = float(np.median(data)) if background is None else background
|
|
80
|
+
weights = np.clip(data - bg, 0.0, None)
|
|
81
|
+
|
|
82
|
+
if center is not None and r is not None:
|
|
83
|
+
mask = _radial_grid(data.shape, *center) <= r**2
|
|
84
|
+
weights = weights * mask
|
|
85
|
+
|
|
86
|
+
total = float(weights.sum())
|
|
87
|
+
yy, xx = np.mgrid[0 : data.shape[0], 0 : data.shape[1]]
|
|
88
|
+
if total <= 0:
|
|
89
|
+
return (data.shape[1] - 1) / 2.0, (data.shape[0] - 1) / 2.0
|
|
90
|
+
cx = float((weights * xx).sum() / total)
|
|
91
|
+
cy = float((weights * yy).sum() / total)
|
|
92
|
+
return cx, cy
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Photon transfer curve (PTC): characterise a camera from synthetic flats.
|
|
3
|
+
|
|
4
|
+
The PTC is the standard way to measure a detector's conversion gain. This module
|
|
5
|
+
generates flat pairs at a range of light levels, builds the variance-vs-mean
|
|
6
|
+
curve, and fits the gain --- turning the workflow in
|
|
7
|
+
``examples/06_photon_transfer_curve.py`` into a one-liner.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from numpy.typing import NDArray
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..camera import Camera
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PTCResult:
|
|
24
|
+
"""The outcome of :func:`photon_transfer_curve`.
|
|
25
|
+
|
|
26
|
+
Attributes
|
|
27
|
+
----------
|
|
28
|
+
mean_adu, variance_adu2:
|
|
29
|
+
The measured photon transfer curve: per-level mean signal and noise
|
|
30
|
+
variance, both in ADU.
|
|
31
|
+
gain_e_per_adu:
|
|
32
|
+
Conversion gain fitted from the shot-noise-limited region (slope = 1/gain).
|
|
33
|
+
read_noise_e:
|
|
34
|
+
Read noise measured from a pair of bias frames.
|
|
35
|
+
full_well_adu:
|
|
36
|
+
Mean signal at which the variance peaks (onset of saturation), or ``None``
|
|
37
|
+
if the curve never rolls over within the sampled levels.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
mean_adu: NDArray[np.float64]
|
|
41
|
+
variance_adu2: NDArray[np.float64]
|
|
42
|
+
gain_e_per_adu: float
|
|
43
|
+
read_noise_e: float
|
|
44
|
+
full_well_adu: float | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def photon_transfer_curve(
|
|
48
|
+
camera: Camera,
|
|
49
|
+
levels: NDArray[np.float64],
|
|
50
|
+
exposure: float = 1.0,
|
|
51
|
+
*,
|
|
52
|
+
temperature: float | None = None,
|
|
53
|
+
seed: int = 0,
|
|
54
|
+
) -> PTCResult:
|
|
55
|
+
"""Measure a photon transfer curve for ``camera`` over the given flux ``levels``.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
camera:
|
|
60
|
+
The camera to characterise.
|
|
61
|
+
levels:
|
|
62
|
+
Incident photon rates (photons/s/pixel) to sample, ascending. Span from a
|
|
63
|
+
few electrons up past saturation to capture the full curve.
|
|
64
|
+
exposure:
|
|
65
|
+
Exposure time for each flat, in seconds.
|
|
66
|
+
temperature:
|
|
67
|
+
Sensor temperature; defaults to the camera's operating temperature.
|
|
68
|
+
seed:
|
|
69
|
+
Base seed; each flat uses a distinct derived seed for reproducibility.
|
|
70
|
+
"""
|
|
71
|
+
levels = np.asarray(levels, dtype=np.float64)
|
|
72
|
+
means = np.empty(levels.size)
|
|
73
|
+
variances = np.empty(levels.size)
|
|
74
|
+
for i, flux in enumerate(levels):
|
|
75
|
+
# Two independent flats; differencing cancels fixed-pattern noise so the
|
|
76
|
+
# variance reflects shot + read noise only.
|
|
77
|
+
a = np.asarray(camera.flat_frame(flux, exposure, temperature, seed=seed + 2 * i), float)
|
|
78
|
+
b = np.asarray(camera.flat_frame(flux, exposure, temperature, seed=seed + 2 * i + 1), float)
|
|
79
|
+
means[i] = 0.5 * (a.mean() + b.mean())
|
|
80
|
+
variances[i] = 0.5 * (a - b).var()
|
|
81
|
+
|
|
82
|
+
gain = _fit_gain(camera, means, variances)
|
|
83
|
+
|
|
84
|
+
# Read noise from two bias frames: var(b1 - b2) = 2 * read_noise^2.
|
|
85
|
+
b1 = np.asarray(camera.bias_frame(temperature, seed=seed + 99991), float)
|
|
86
|
+
b2 = np.asarray(camera.bias_frame(temperature, seed=seed + 99992), float)
|
|
87
|
+
read_noise = float(np.sqrt(0.5 * (b1 - b2).var()) * gain)
|
|
88
|
+
|
|
89
|
+
full_well = _full_well(means, variances)
|
|
90
|
+
return PTCResult(means, variances, gain, read_noise, full_well)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _fit_gain(camera: Camera, means: NDArray[np.float64], variances: NDArray[np.float64]) -> float:
|
|
94
|
+
"""Fit gain from the linear, unsaturated region (slope = 1 / gain)."""
|
|
95
|
+
lo = camera.config.bias_offset_adu + 50.0
|
|
96
|
+
hi = 0.7 * camera.config.max_adu
|
|
97
|
+
mask = (means > lo) & (means < hi)
|
|
98
|
+
if mask.sum() < 2:
|
|
99
|
+
mask = np.ones_like(means, dtype=bool) # fall back to all points
|
|
100
|
+
slope, _ = np.polyfit(means[mask], variances[mask], 1)
|
|
101
|
+
return float(1.0 / slope)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _full_well(means: NDArray[np.float64], variances: NDArray[np.float64]) -> float | None:
|
|
105
|
+
"""Mean signal where the variance peaks (saturation onset), if it rolls over."""
|
|
106
|
+
peak = int(np.argmax(variances))
|
|
107
|
+
if peak == variances.size - 1:
|
|
108
|
+
return None # variance still rising at the last level: no rollover seen
|
|
109
|
+
return float(means[peak])
|
getframes/calibrate.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Calibration: combine frames into masters and reduce raw frames against them.
|
|
3
|
+
|
|
4
|
+
These helpers close the loop the library is built for: generate raw frames (each
|
|
5
|
+
optionally carrying :class:`~getframes.frame.FrameTruth`), then *reduce* them with
|
|
6
|
+
master calibration frames and compare the result to the ground truth.
|
|
7
|
+
|
|
8
|
+
The reduction follows the standard, exposure-matched CCD equation::
|
|
9
|
+
|
|
10
|
+
reduced = (raw - dark) / normalised(flat)
|
|
11
|
+
|
|
12
|
+
where ``dark`` is an exposure-matched master dark (which still contains the bias
|
|
13
|
+
pedestal, so subtracting it removes bias and dark current together) and ``flat`` is
|
|
14
|
+
a pedestal-free master flat (see :meth:`getframes.Camera.master_flat`). Pass
|
|
15
|
+
``bias`` instead of ``dark`` to subtract only the bias pedestal.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from .frame import Frame
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from numpy.typing import NDArray
|
|
29
|
+
|
|
30
|
+
FrameLike = Frame | NDArray[np.floating[Any] | np.integer[Any]]
|
|
31
|
+
|
|
32
|
+
_COMBINE_METHODS = ("mean", "median", "sigma_clip")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _as_array(frame: FrameLike) -> NDArray[np.float64]:
|
|
36
|
+
return np.asarray(frame, dtype=np.float64)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def combine(
|
|
40
|
+
frames: Iterable[FrameLike],
|
|
41
|
+
*,
|
|
42
|
+
method: str = "median",
|
|
43
|
+
sigma: float = 3.0,
|
|
44
|
+
) -> Frame:
|
|
45
|
+
"""Combine a stack of frames pixel-wise into a single master frame.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
frames:
|
|
50
|
+
An iterable of :class:`~getframes.frame.Frame` (or plain 2-D arrays), all
|
|
51
|
+
the same shape. Averaging ``n`` independent frames reduces the random noise
|
|
52
|
+
by roughly ``sqrt(n)``.
|
|
53
|
+
method:
|
|
54
|
+
``"median"`` (default, robust to outliers such as cosmic rays), ``"mean"``,
|
|
55
|
+
or ``"sigma_clip"`` (reject pixels more than ``sigma`` standard deviations
|
|
56
|
+
from the per-pixel median, then average the rest).
|
|
57
|
+
sigma:
|
|
58
|
+
Clipping threshold for ``method="sigma_clip"``.
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
Frame
|
|
63
|
+
The master frame (ADU, ``float64``), with metadata recording the
|
|
64
|
+
combination. Metadata common to all inputs is preserved; ``frame_type`` is
|
|
65
|
+
prefixed with ``master_`` when the inputs agree.
|
|
66
|
+
"""
|
|
67
|
+
if method not in _COMBINE_METHODS:
|
|
68
|
+
raise ValueError(f"method must be one of {_COMBINE_METHODS}, got {method!r}.")
|
|
69
|
+
|
|
70
|
+
frame_list = list(frames)
|
|
71
|
+
if not frame_list:
|
|
72
|
+
raise ValueError("combine() needs at least one frame.")
|
|
73
|
+
stack = np.stack([_as_array(f) for f in frame_list], axis=0)
|
|
74
|
+
|
|
75
|
+
if method == "mean":
|
|
76
|
+
data = stack.mean(axis=0)
|
|
77
|
+
elif method == "median":
|
|
78
|
+
data = np.median(stack, axis=0)
|
|
79
|
+
else: # sigma_clip
|
|
80
|
+
median = np.median(stack, axis=0)
|
|
81
|
+
std = stack.std(axis=0)
|
|
82
|
+
# Keep pixels within sigma*std of the per-pixel median; where std == 0 every
|
|
83
|
+
# value is identical, so keep them all.
|
|
84
|
+
keep = (std == 0.0) | (np.abs(stack - median) <= sigma * std)
|
|
85
|
+
kept = np.where(keep, stack, np.nan)
|
|
86
|
+
with np.errstate(invalid="ignore"):
|
|
87
|
+
data = np.nanmean(kept, axis=0)
|
|
88
|
+
# Pixels with everything clipped (shouldn't happen) fall back to the median.
|
|
89
|
+
data = np.where(np.isnan(data), median, data)
|
|
90
|
+
|
|
91
|
+
metadata = _master_metadata(frame_list, method)
|
|
92
|
+
return Frame(data=np.asarray(data, dtype=np.float64), metadata=metadata)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _master_metadata(frames: list[FrameLike], method: str) -> dict[str, Any]:
|
|
96
|
+
"""Provenance for a combined frame: shared input metadata plus combine info."""
|
|
97
|
+
metadata: dict[str, Any] = {}
|
|
98
|
+
frame_objs = [f for f in frames if isinstance(f, Frame)]
|
|
99
|
+
if frame_objs:
|
|
100
|
+
shared = dict(frame_objs[0].metadata)
|
|
101
|
+
for f in frame_objs[1:]:
|
|
102
|
+
shared = {k: v for k, v in shared.items() if f.metadata.get(k) == v}
|
|
103
|
+
metadata.update(shared)
|
|
104
|
+
# Per-frame keys are meaningless for a master.
|
|
105
|
+
metadata.pop("frame_index", None)
|
|
106
|
+
metadata.pop("seed", None)
|
|
107
|
+
ftype = frame_objs[0].metadata.get("frame_type")
|
|
108
|
+
if ftype is not None and all(f.metadata.get("frame_type") == ftype for f in frame_objs):
|
|
109
|
+
metadata["frame_type"] = f"master_{ftype}"
|
|
110
|
+
metadata["n_combined"] = len(frames)
|
|
111
|
+
metadata["combine_method"] = method
|
|
112
|
+
return metadata
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def calibrate(
|
|
116
|
+
raw: FrameLike,
|
|
117
|
+
*,
|
|
118
|
+
bias: FrameLike | None = None,
|
|
119
|
+
dark: FrameLike | None = None,
|
|
120
|
+
flat: FrameLike | None = None,
|
|
121
|
+
dark_scale: float = 1.0,
|
|
122
|
+
) -> Frame:
|
|
123
|
+
"""Reduce a raw frame with master calibration frames.
|
|
124
|
+
|
|
125
|
+
Performs, in order: subtract the additive pedestal (an exposure-matched master
|
|
126
|
+
``dark`` if given, else a ``bias``), then divide by the normalised ``flat``::
|
|
127
|
+
|
|
128
|
+
out = raw - dark_scale * dark # or raw - bias if no dark
|
|
129
|
+
out = out / (flat / mean(flat))
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
raw:
|
|
134
|
+
The frame to reduce (a :class:`~getframes.frame.Frame` or array, in ADU).
|
|
135
|
+
bias:
|
|
136
|
+
Master bias. Subtracted only when ``dark`` is not given (a master dark
|
|
137
|
+
already contains the bias pedestal).
|
|
138
|
+
dark:
|
|
139
|
+
Exposure-matched master dark (including bias). Subtracted from ``raw``.
|
|
140
|
+
flat:
|
|
141
|
+
Pedestal-free master flat. Divided out after normalising it to unit mean,
|
|
142
|
+
so only its relative pixel-to-pixel response remains.
|
|
143
|
+
dark_scale:
|
|
144
|
+
Multiplier applied to ``dark`` before subtraction, to scale a master dark
|
|
145
|
+
to a different exposure (use with a separately subtracted ``bias`` for
|
|
146
|
+
strict correctness; ``1.0`` for the matched-exposure default).
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
Frame
|
|
151
|
+
The reduced frame (``float64``, in ADU), with ``frame_type="reduced"`` and
|
|
152
|
+
provenance describing the steps applied.
|
|
153
|
+
"""
|
|
154
|
+
out = _as_array(raw)
|
|
155
|
+
steps: list[str] = []
|
|
156
|
+
|
|
157
|
+
if dark is not None:
|
|
158
|
+
out = out - dark_scale * _as_array(dark)
|
|
159
|
+
steps.append("dark")
|
|
160
|
+
elif bias is not None:
|
|
161
|
+
out = out - _as_array(bias)
|
|
162
|
+
steps.append("bias")
|
|
163
|
+
|
|
164
|
+
if flat is not None:
|
|
165
|
+
flat_arr = _as_array(flat)
|
|
166
|
+
norm = float(flat_arr.mean())
|
|
167
|
+
if norm == 0.0:
|
|
168
|
+
raise ValueError("flat has zero mean; cannot normalise.")
|
|
169
|
+
# Guard against divide-by-zero in dead pixels: leave them unscaled.
|
|
170
|
+
safe = np.where(flat_arr != 0.0, flat_arr, norm)
|
|
171
|
+
out = out * (norm / safe)
|
|
172
|
+
steps.append("flat")
|
|
173
|
+
|
|
174
|
+
metadata: dict[str, Any] = {}
|
|
175
|
+
if isinstance(raw, Frame):
|
|
176
|
+
metadata.update(raw.metadata)
|
|
177
|
+
metadata["frame_type"] = "reduced"
|
|
178
|
+
metadata["calibration"] = steps
|
|
179
|
+
return Frame(data=out, metadata=metadata)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = ["calibrate", "combine"]
|