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/dataset.py ADDED
@@ -0,0 +1,294 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Scalable raw + ground-truth dataset generation (roadmap phase 1.6).
3
+
4
+ The library's reason to exist is *paired* data: a realistic raw frame and the
5
+ noise-free signal it was drawn from. :func:`pairs` turns a camera and a stream of
6
+ :class:`~getframes.scene.scene.Scene` objects into a reproducible sequence of
7
+ ``{"raw": ADU, "truth": electrons}`` pairs — training data for denoising,
8
+ deconvolution, or calibration networks — and streams it to disk in ``float32``
9
+ without ever holding the whole set in memory.
10
+
11
+ :func:`random_star_fields` is a convenience generator of random star-field scenes
12
+ to feed it, but any iterable of scenes (matching the camera's resolution) works.
13
+
14
+ >>> import getframes as gf
15
+ >>> cam = gf.Camera.from_preset("andor_ikon_m934", precision="float32")
16
+ >>> scenes = gf.dataset.random_star_fields(n=4, shape=cam.resolution, seed=0)
17
+ >>> ds = gf.dataset.pairs(camera=cam, scenes=scenes, exposure=10.0, seed=1)
18
+ >>> pair = next(iter(ds))
19
+ >>> sorted(pair)
20
+ ['raw', 'truth']
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Iterable, Iterator
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ import numpy as np
30
+
31
+ from .scene import Bandpass, GaussianPSF, PointSource, Scene, Sky, Telescope
32
+
33
+ if TYPE_CHECKING:
34
+ from numpy.typing import DTypeLike, NDArray
35
+
36
+ from .camera import Camera
37
+ from .scene.psf import PSF
38
+
39
+ # Sub-stream salt for the per-frame dataset seeds, kept distinct from other streams.
40
+ _DATASET_STREAM = 0x44415441 # "DATA"
41
+
42
+ Pair = dict[str, "NDArray[np.floating[Any]]"]
43
+
44
+
45
+ def _default_optics() -> Telescope:
46
+ """A generic small-telescope optic for synthetic star fields (Johnson V)."""
47
+ return Telescope(
48
+ aperture_diameter_m=0.5,
49
+ plate_scale_arcsec_per_pixel=1.0,
50
+ throughput=0.5,
51
+ band=Bandpass.johnson("V"),
52
+ )
53
+
54
+
55
+ class RandomStarFields:
56
+ """A reproducible, re-iterable stream of random star-field :class:`Scene` objects.
57
+
58
+ Each scene is a field of uniformly placed point sources with magnitudes drawn
59
+ uniformly from ``mag_range`` and an optional uniform sky. The number of stars per
60
+ field is fixed (``int``) or drawn per field from a ``(low, high)`` range. The
61
+ stream is deterministic for a given ``seed`` (each field gets its own derived
62
+ seed) and can be iterated more than once.
63
+
64
+ Construct via :func:`random_star_fields`.
65
+
66
+ Parameters
67
+ ----------
68
+ n:
69
+ Number of scenes in the stream.
70
+ shape:
71
+ Scene size ``(height, width)``; must match the camera it is observed with.
72
+ optics, psf:
73
+ The :class:`~getframes.scene.optics.Telescope` and
74
+ :class:`~getframes.scene.psf.PSF` shared by every field. Sensible generic
75
+ defaults are used when omitted.
76
+ n_stars:
77
+ Stars per field — a fixed count, or a ``(low, high)`` range sampled per field.
78
+ mag_range:
79
+ ``(bright, faint)`` magnitude bounds for the uniform brightness draw.
80
+ sky_mag_arcsec2:
81
+ Optional uniform sky surface brightness (mag/arcsec^2); ``None`` for no sky.
82
+ seed:
83
+ Base seed; field ``i`` uses a distinct derived seed so the stream repeats.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ n: int,
89
+ shape: tuple[int, int],
90
+ *,
91
+ optics: Telescope | None = None,
92
+ psf: PSF | None = None,
93
+ n_stars: int | tuple[int, int] = (20, 200),
94
+ mag_range: tuple[float, float] = (16.0, 22.0),
95
+ sky_mag_arcsec2: float | None = 21.0,
96
+ seed: int | None = None,
97
+ ) -> None:
98
+ if n < 0:
99
+ raise ValueError("n must be non-negative.")
100
+ if len(shape) != 2 or any(s <= 0 for s in shape):
101
+ raise ValueError(f"shape must be two positive ints, got {shape!r}.")
102
+ self.n = int(n)
103
+ self.shape = (int(shape[0]), int(shape[1]))
104
+ self.optics = optics if optics is not None else _default_optics()
105
+ self.psf = psf if psf is not None else GaussianPSF(fwhm_arcsec=2.5)
106
+ self.n_stars = n_stars
107
+ self.mag_range = (float(mag_range[0]), float(mag_range[1]))
108
+ self.sky_mag_arcsec2 = sky_mag_arcsec2
109
+ self.seed = seed
110
+
111
+ def __len__(self) -> int:
112
+ return self.n
113
+
114
+ def _field_count(self, rng: np.random.Generator) -> int:
115
+ if isinstance(self.n_stars, tuple):
116
+ low, high = self.n_stars
117
+ return int(rng.integers(low, high + 1))
118
+ return int(self.n_stars)
119
+
120
+ def _scene(self, rng: np.random.Generator) -> Scene:
121
+ height, width = self.shape
122
+ k = self._field_count(rng)
123
+ xs = rng.uniform(0.0, width - 1, size=k)
124
+ ys = rng.uniform(0.0, height - 1, size=k)
125
+ mags = rng.uniform(self.mag_range[0], self.mag_range[1], size=k)
126
+ sources = [
127
+ PointSource(x=float(x), y=float(y), magnitude=float(m)) for x, y, m in zip(xs, ys, mags)
128
+ ]
129
+ sky = None if self.sky_mag_arcsec2 is None else Sky(self.sky_mag_arcsec2)
130
+ return Scene(shape=self.shape, optics=self.optics, psf=self.psf, sources=sources, sky=sky)
131
+
132
+ def __iter__(self) -> Iterator[Scene]:
133
+ seeds: Iterable[np.random.SeedSequence | None]
134
+ if self.seed is None:
135
+ seeds = [None] * self.n
136
+ else:
137
+ seeds = np.random.SeedSequence(self.seed).spawn(self.n)
138
+ for ss in seeds:
139
+ yield self._scene(np.random.default_rng(ss))
140
+
141
+
142
+ def random_star_fields(
143
+ n: int,
144
+ shape: tuple[int, int],
145
+ *,
146
+ optics: Telescope | None = None,
147
+ psf: PSF | None = None,
148
+ n_stars: int | tuple[int, int] = (20, 200),
149
+ mag_range: tuple[float, float] = (16.0, 22.0),
150
+ sky_mag_arcsec2: float | None = 21.0,
151
+ seed: int | None = None,
152
+ ) -> RandomStarFields:
153
+ """Build a reproducible :class:`RandomStarFields` stream of ``n`` star-field scenes.
154
+
155
+ A convenience source of scenes for :func:`pairs`; see :class:`RandomStarFields`
156
+ for the parameters.
157
+ """
158
+ return RandomStarFields(
159
+ n,
160
+ shape,
161
+ optics=optics,
162
+ psf=psf,
163
+ n_stars=n_stars,
164
+ mag_range=mag_range,
165
+ sky_mag_arcsec2=sky_mag_arcsec2,
166
+ seed=seed,
167
+ )
168
+
169
+
170
+ class PairDataset:
171
+ """A lazy, reproducible sequence of raw + truth pairs (see :func:`pairs`).
172
+
173
+ Iterating yields ``{"raw": ADU, "truth": electrons}`` dicts, one per input
174
+ scene, each cast to :attr:`dtype`. The stream is single-pass when its scenes are
175
+ a one-shot iterator; pass a re-iterable scene source (e.g.
176
+ :class:`RandomStarFields`) to iterate more than once. Materialise to disk with
177
+ :meth:`to_npz` or into stacked arrays with :meth:`to_arrays`.
178
+ """
179
+
180
+ def __init__(
181
+ self,
182
+ camera: Camera,
183
+ scenes: Iterable[Scene],
184
+ exposure: float,
185
+ *,
186
+ temperature: float | None = None,
187
+ dtype: DTypeLike = np.float32,
188
+ seed: int | None = None,
189
+ ) -> None:
190
+ self.camera = camera
191
+ self.scenes = scenes
192
+ self.exposure = float(exposure)
193
+ self.temperature = temperature
194
+ self.dtype = np.dtype(dtype)
195
+ self.seed = seed
196
+
197
+ def __len__(self) -> int:
198
+ try:
199
+ return len(self.scenes) # type: ignore[arg-type]
200
+ except TypeError as exc: # pragma: no cover - depends on the scene source
201
+ raise TypeError("This PairDataset's scene source has no length.") from exc
202
+
203
+ def _frame_seed(self, index: int) -> int | None:
204
+ if self.seed is None:
205
+ return None
206
+ ss = np.random.SeedSequence([int(self.seed), _DATASET_STREAM, index])
207
+ return int(ss.generate_state(1)[0])
208
+
209
+ def __iter__(self) -> Iterator[Pair]:
210
+ for i, scene in enumerate(self.scenes):
211
+ frame = self.camera.observe(
212
+ scene,
213
+ self.exposure,
214
+ self.temperature,
215
+ seed=self._frame_seed(i),
216
+ include_truth=True,
217
+ )
218
+ assert frame.truth is not None # include_truth=True
219
+ yield {
220
+ "raw": np.asarray(frame.data, dtype=self.dtype),
221
+ "truth": np.asarray(frame.truth.mean_electrons, dtype=self.dtype),
222
+ }
223
+
224
+ def to_npz(self, directory: str, *, prefix: str = "pair", compress: bool = False) -> list[str]:
225
+ """Write each pair to ``{directory}/{prefix}_{i:06d}.npz`` and return the paths.
226
+
227
+ Each archive holds ``raw`` (ADU) and ``truth`` (electrons) arrays in
228
+ :attr:`dtype`. Streams pair by pair, so the whole set is never resident in
229
+ memory. ``compress`` uses :func:`numpy.savez_compressed`.
230
+ """
231
+ out = Path(directory)
232
+ out.mkdir(parents=True, exist_ok=True)
233
+ writer = np.savez_compressed if compress else np.savez
234
+ paths: list[str] = []
235
+ for i, pair in enumerate(self):
236
+ path = out / f"{prefix}_{i:06d}.npz"
237
+ writer(path, raw=pair["raw"], truth=pair["truth"])
238
+ paths.append(str(path))
239
+ return paths
240
+
241
+ def to_arrays(self) -> tuple[NDArray[np.floating[Any]], NDArray[np.floating[Any]]]:
242
+ """Stack the whole dataset into ``(raw, truth)`` arrays of shape ``(N, H, W)``.
243
+
244
+ Convenient for small sets; holds everything in memory, unlike :meth:`to_npz`.
245
+ """
246
+ raws: list[NDArray[np.floating[Any]]] = []
247
+ truths: list[NDArray[np.floating[Any]]] = []
248
+ for pair in self:
249
+ raws.append(pair["raw"])
250
+ truths.append(pair["truth"])
251
+ if not raws:
252
+ raise ValueError("Dataset is empty; nothing to stack.")
253
+ return np.stack(raws, axis=0), np.stack(truths, axis=0)
254
+
255
+
256
+ def pairs(
257
+ *,
258
+ camera: Camera,
259
+ scenes: Iterable[Scene],
260
+ exposure: float,
261
+ temperature: float | None = None,
262
+ dtype: DTypeLike = np.float32,
263
+ seed: int | None = None,
264
+ ) -> PairDataset:
265
+ """Build a :class:`PairDataset` of raw + truth pairs from a camera and scenes.
266
+
267
+ Parameters
268
+ ----------
269
+ camera:
270
+ The :class:`~getframes.camera.Camera` that observes each scene. Construct it
271
+ with ``precision="float32"`` to render the signal chain in the fast path too.
272
+ scenes:
273
+ Any iterable of :class:`~getframes.scene.scene.Scene` matching the camera's
274
+ resolution (e.g. :func:`random_star_fields`).
275
+ exposure:
276
+ Integration time in seconds for every frame.
277
+ temperature:
278
+ Sensor temperature (deg C); defaults to the camera's.
279
+ dtype:
280
+ Storage dtype for the ``raw``/``truth`` arrays (``float32`` by default to
281
+ halve on-disk size; the ADU are exact integers either way).
282
+ seed:
283
+ Base seed; frame ``i`` draws a distinct derived seed, so the whole dataset is
284
+ reproducible yet the frames are independent.
285
+ """
286
+ return PairDataset(camera, scenes, exposure, temperature=temperature, dtype=dtype, seed=seed)
287
+
288
+
289
+ __all__ = [
290
+ "PairDataset",
291
+ "RandomStarFields",
292
+ "pairs",
293
+ "random_star_fields",
294
+ ]
getframes/frame.py ADDED
@@ -0,0 +1,107 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """The :class:`Frame` container returned by frame-generation methods."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import numpy as np
10
+
11
+ if TYPE_CHECKING:
12
+ from numpy.typing import NDArray
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class FrameTruth:
17
+ """Noise-free ground truth a :class:`Frame` was generated from.
18
+
19
+ Useful for validating analysis pipelines against exactly what went in. All
20
+ arrays are in electrons unless noted, shaped like the frame.
21
+
22
+ Attributes
23
+ ----------
24
+ mean_electrons:
25
+ Noise-free total signal (photo + dark) per pixel, in electrons. This is
26
+ the expectation value before shot noise, gain, and read noise.
27
+ mean_photoelectrons:
28
+ Noise-free photo signal per pixel, in electrons (i.e. excluding dark).
29
+ photon_rate:
30
+ The incident photon rate the frame was exposed to, in photons/s/pixel, as
31
+ provided by the caller (a scalar for uniform illumination, else an array).
32
+ """
33
+
34
+ mean_electrons: NDArray[np.float64]
35
+ mean_photoelectrons: NDArray[np.float64]
36
+ photon_rate: NDArray[np.float64] | float
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Frame:
41
+ """A single simulated image plus the metadata describing how it was made.
42
+
43
+ The pixel values live in :attr:`data` as a 2-D NumPy array in ADU. The object
44
+ is array-like: ``np.asarray(frame)`` and most NumPy operations work directly.
45
+
46
+ Attributes
47
+ ----------
48
+ data:
49
+ 2-D array of pixel values in ADU, shaped ``(height, width)``.
50
+ metadata:
51
+ Free-form dictionary describing the simulation (camera name, exposure,
52
+ temperature, frame type, etc.). Suitable for writing to a FITS header.
53
+ truth:
54
+ Optional :class:`FrameTruth` holding the noise-free signal the frame was
55
+ built from, for ground-truth comparisons. ``None`` if not requested.
56
+ """
57
+
58
+ data: NDArray[np.floating[Any] | np.integer[Any]]
59
+ metadata: dict[str, Any] = field(default_factory=dict)
60
+ truth: FrameTruth | None = None
61
+
62
+ @property
63
+ def shape(self) -> tuple[int, ...]:
64
+ return tuple(self.data.shape)
65
+
66
+ @property
67
+ def dtype(self) -> np.dtype[Any]:
68
+ return self.data.dtype
69
+
70
+ def __array__(self, dtype: Any = None) -> NDArray[Any]:
71
+ return np.asarray(self.data, dtype=dtype)
72
+
73
+ def stats(self) -> dict[str, float]:
74
+ """Common summary statistics of the pixel values (mean/median/std/min/max)."""
75
+ arr = np.asarray(self.data, dtype=float)
76
+ return {
77
+ "mean": float(arr.mean()),
78
+ "median": float(np.median(arr)),
79
+ "std": float(arr.std()),
80
+ "min": float(arr.min()),
81
+ "max": float(arr.max()),
82
+ }
83
+
84
+ def to_fits(self, path: str, overwrite: bool = False) -> None:
85
+ """Write the frame to a FITS file (requires ``astropy``).
86
+
87
+ Metadata keys are written to the FITS header where they fit the 8-character
88
+ keyword and value-type constraints.
89
+ """
90
+ try:
91
+ from astropy.io import fits
92
+ except ImportError as exc: # pragma: no cover - astropy is a core dependency
93
+ raise ImportError(
94
+ "Writing FITS files requires astropy (a core dependency of getframes); "
95
+ "reinstall with: pip install getframes"
96
+ ) from exc
97
+
98
+ hdu = fits.PrimaryHDU(data=np.asarray(self.data))
99
+ for key, value in self.metadata.items():
100
+ if isinstance(value, (str, int, float, bool)):
101
+ hdu.header[key[:8].upper()] = value
102
+ hdu.writeto(path, overwrite=overwrite)
103
+
104
+ def __repr__(self) -> str:
105
+ ftype = self.metadata.get("frame_type", "frame")
106
+ cam = self.metadata.get("camera", "?")
107
+ return f"Frame(type={ftype!r}, camera={cam!r}, shape={self.shape}, dtype={self.dtype})"