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.
Files changed (40) hide show
  1. getframes/__about__.py +4 -0
  2. getframes/__init__.py +91 -0
  3. getframes/analysis/__init__.py +18 -0
  4. getframes/analysis/apertures.py +92 -0
  5. getframes/analysis/ptc.py +109 -0
  6. getframes/calibrate.py +182 -0
  7. getframes/camera.py +649 -0
  8. getframes/cli.py +214 -0
  9. getframes/config.py +420 -0
  10. getframes/dataset.py +294 -0
  11. getframes/frame.py +107 -0
  12. getframes/noise.py +637 -0
  13. getframes/observation.py +162 -0
  14. getframes/presets/__init__.py +90 -0
  15. getframes/presets/data/__init__.py +3 -0
  16. getframes/presets/data/andor_ikon_m934.toml +22 -0
  17. getframes/presets/data/andor_ixon_ultra_888.toml +22 -0
  18. getframes/presets/data/generic_ccd.toml +18 -0
  19. getframes/presets/data/generic_cmos.toml +18 -0
  20. getframes/presets/data/generic_eapd.toml +20 -0
  21. getframes/presets/data/generic_emccd.toml +20 -0
  22. getframes/presets/data/generic_scmos.toml +21 -0
  23. getframes/presets/data/hamamatsu_orca_fusion.toml +25 -0
  24. getframes/presets/data/leonardo_saphira.toml +32 -0
  25. getframes/presets/data/zwo_asi2600mm.toml +20 -0
  26. getframes/py.typed +0 -0
  27. getframes/scene/__init__.py +51 -0
  28. getframes/scene/optics.py +180 -0
  29. getframes/scene/photometry.py +311 -0
  30. getframes/scene/psf.py +371 -0
  31. getframes/scene/scene.py +205 -0
  32. getframes/scene/sources.py +683 -0
  33. getframes/scene/thermal.py +114 -0
  34. getframes/scene/wcs.py +110 -0
  35. getframes/spectral.py +449 -0
  36. getframes-2.0.0.dist-info/METADATA +218 -0
  37. getframes-2.0.0.dist-info/RECORD +40 -0
  38. getframes-2.0.0.dist-info/WHEEL +4 -0
  39. getframes-2.0.0.dist-info/entry_points.txt +2 -0
  40. getframes-2.0.0.dist-info/licenses/LICENSE +21 -0
getframes/__about__.py ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Single source of truth for the package version."""
3
+
4
+ __version__ = "2.0.0"
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"]