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/noise.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
"""Physical noise models that turn a :class:`CameraConfig` into pixel values.
|
|
3
|
+
|
|
4
|
+
The models here are deliberately small, composable, and well-documented so that
|
|
5
|
+
the physics is auditable. Each function takes a configuration, exposure, and a
|
|
6
|
+
seeded :class:`numpy.random.Generator`, and returns electrons or ADU.
|
|
7
|
+
|
|
8
|
+
Signal chain (:func:`simulate_frame`)
|
|
9
|
+
-------------------------------------
|
|
10
|
+
1. Mean photo signal: ``(photon_rate + background) * t_exp * QE`` electrons,
|
|
11
|
+
modulated per pixel by photo-response non-uniformity (PRNU).
|
|
12
|
+
2. Mean dark signal: ``D(T) * t_exp`` electrons (temperature-scaled), modulated by
|
|
13
|
+
dark-signal non-uniformity (DSNU) and hot pixels.
|
|
14
|
+
3. Shot noise: the total electrons are Poisson-distributed about that mean.
|
|
15
|
+
4. Clock-induced charge (EMCCD) adds a small Poisson term.
|
|
16
|
+
5. Cosmic rays (single pixels or extended tracks).
|
|
17
|
+
6. Charge-transport artifacts: blooming along saturated columns, CCD
|
|
18
|
+
charge-transfer inefficiency (CTI), and inter-pixel capacitance (IPC).
|
|
19
|
+
7. Detector nonlinearity (single-parameter or polynomial).
|
|
20
|
+
8. EM register / avalanche multiplication with its stochastic excess noise.
|
|
21
|
+
9. kTC/reset noise and read noise: Gaussian in electrons, at the output amplifier.
|
|
22
|
+
10. Conversion to ADU via (optionally per-amplifier) gain, plus the bias pedestal
|
|
23
|
+
and any structured-bias pattern; dead pixels/columns read as defects.
|
|
24
|
+
11. Saturation at full well / ADC range and quantisation to integers.
|
|
25
|
+
|
|
26
|
+
A dark frame is simply the special case ``photon_rate = 0``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
from scipy import ndimage
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from numpy.typing import DTypeLike, NDArray
|
|
38
|
+
|
|
39
|
+
from .config import CameraConfig
|
|
40
|
+
|
|
41
|
+
PhotonRate = float | NDArray[np.float64]
|
|
42
|
+
|
|
43
|
+
# The working floating-point dtype of the signal chain. ``float64`` is the exact
|
|
44
|
+
# default; ``float32`` halves the memory of the per-pixel arrays (the "fast path"
|
|
45
|
+
# used for large detectors and bulk dataset generation) at a small precision cost.
|
|
46
|
+
DEFAULT_FLOAT_DTYPE = np.float64
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Independent sub-streams of the fixed-pattern generator, so PRNU, DSNU, and the
|
|
50
|
+
# hot-pixel map are mutually independent yet each stable for a given sensor.
|
|
51
|
+
_FPN_STREAM_PRNU = 0
|
|
52
|
+
_FPN_STREAM_DSNU = 1
|
|
53
|
+
_FPN_STREAM_HOT = 2
|
|
54
|
+
_FPN_STREAM_AMP_GAIN = 3
|
|
55
|
+
_FPN_STREAM_AMP_OFFSET = 4
|
|
56
|
+
_FPN_STREAM_BIAS = 5
|
|
57
|
+
_FPN_STREAM_DEFECT = 6
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fixed_pattern_rng(config: CameraConfig, stream: int) -> np.random.Generator:
|
|
61
|
+
"""A deterministic generator for the sensor's fixed-pattern noise.
|
|
62
|
+
|
|
63
|
+
Seeded only by ``config.fixed_pattern_seed`` (not the per-frame seed), so the
|
|
64
|
+
pattern is identical in every frame this camera produces --- which is what makes
|
|
65
|
+
it removable by a master flat or dark.
|
|
66
|
+
"""
|
|
67
|
+
seq = np.random.SeedSequence([int(config.fixed_pattern_seed), stream])
|
|
68
|
+
return np.random.default_rng(seq)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def dark_signal_map(
|
|
72
|
+
config: CameraConfig,
|
|
73
|
+
exposure_s: float,
|
|
74
|
+
temperature_c: float,
|
|
75
|
+
float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
|
|
76
|
+
) -> NDArray[np.float64]:
|
|
77
|
+
"""Per-pixel *mean* dark signal in electrons, including fixed-pattern structure.
|
|
78
|
+
|
|
79
|
+
This is the noise-free expectation per pixel; shot noise is applied separately.
|
|
80
|
+
The fixed-pattern structure (DSNU and hot pixels) is deterministic for a given
|
|
81
|
+
sensor (keyed on :attr:`~getframes.config.CameraConfig.fixed_pattern_seed`), so
|
|
82
|
+
it repeats across frames and can be calibrated out with a master dark. A uniform
|
|
83
|
+
detector-glow term (``detector_glow_e_per_s``) is added on top, also
|
|
84
|
+
exposure-scaled and dark-removable.
|
|
85
|
+
|
|
86
|
+
``float_dtype`` selects the working precision (``float64`` exact default, or
|
|
87
|
+
``float32`` for the memory-light fast path).
|
|
88
|
+
"""
|
|
89
|
+
height, width = config.resolution
|
|
90
|
+
mean_dark = config.dark_current_at(temperature_c) * exposure_s
|
|
91
|
+
signal = np.full((height, width), mean_dark, dtype=float_dtype)
|
|
92
|
+
|
|
93
|
+
# Dark-signal non-uniformity: log-normal so the per-pixel gain stays positive
|
|
94
|
+
# with unit mean. Drawn from the fixed-pattern stream (same every frame).
|
|
95
|
+
if config.dark_current_nonuniformity > 0 and mean_dark > 0:
|
|
96
|
+
sigma = config.dark_current_nonuniformity
|
|
97
|
+
rng = _fixed_pattern_rng(config, _FPN_STREAM_DSNU)
|
|
98
|
+
dsnu = rng.lognormal(mean=-0.5 * sigma**2, sigma=sigma, size=signal.shape)
|
|
99
|
+
signal *= dsnu
|
|
100
|
+
|
|
101
|
+
# Hot pixels: a sparse, *fixed* population with strongly elevated dark current.
|
|
102
|
+
if config.hot_pixel_fraction > 0 and mean_dark > 0:
|
|
103
|
+
rng = _fixed_pattern_rng(config, _FPN_STREAM_HOT)
|
|
104
|
+
hot_mask = rng.random(signal.shape) < config.hot_pixel_fraction
|
|
105
|
+
signal[hot_mask] *= config.hot_pixel_factor
|
|
106
|
+
|
|
107
|
+
# Detector glow: a uniform self-emission term that scales with exposure (and so
|
|
108
|
+
# is removed by an exposure-matched master dark). Added after DSNU/hot pixels,
|
|
109
|
+
# which describe the dark *current*, not the glow. In place to preserve dtype.
|
|
110
|
+
if config.detector_glow_e_per_s > 0 and exposure_s > 0:
|
|
111
|
+
signal += config.detector_glow_e_per_s * exposure_s
|
|
112
|
+
|
|
113
|
+
return signal
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def photo_signal_map(
|
|
117
|
+
config: CameraConfig,
|
|
118
|
+
photon_rate: PhotonRate,
|
|
119
|
+
exposure_s: float,
|
|
120
|
+
background_photon_rate: PhotonRate,
|
|
121
|
+
quantum_efficiency: float | None = None,
|
|
122
|
+
float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
|
|
123
|
+
) -> NDArray[np.float64]:
|
|
124
|
+
"""Per-pixel *mean* photo-generated signal in electrons (noise-free).
|
|
125
|
+
|
|
126
|
+
Converts an incident photon rate (photons/s/pixel, plus an additive
|
|
127
|
+
background) to photoelectrons via the quantum efficiency, then imprints a
|
|
128
|
+
fixed multiplicative PRNU pattern. ``photon_rate`` may be a scalar (uniform
|
|
129
|
+
illumination) or a 2-D array matching the sensor resolution.
|
|
130
|
+
|
|
131
|
+
The PRNU pattern is deterministic for a given sensor (keyed on
|
|
132
|
+
:attr:`~getframes.config.CameraConfig.fixed_pattern_seed`), so it repeats across
|
|
133
|
+
frames and is removable with a master flat.
|
|
134
|
+
|
|
135
|
+
``quantum_efficiency`` overrides ``config.quantum_efficiency`` when given. The
|
|
136
|
+
spectral path uses this with a pre-multiplied (already-photoelectron) map and
|
|
137
|
+
``quantum_efficiency = 1.0``. ``float_dtype`` selects the working precision
|
|
138
|
+
(``float64`` default, or ``float32`` for the memory-light fast path).
|
|
139
|
+
"""
|
|
140
|
+
height, width = config.resolution
|
|
141
|
+
qe = config.quantum_efficiency if quantum_efficiency is None else quantum_efficiency
|
|
142
|
+
rate = np.asarray(photon_rate, dtype=np.float64)
|
|
143
|
+
background = np.asarray(background_photon_rate, dtype=np.float64)
|
|
144
|
+
if rate.ndim not in (0, 2) or background.ndim not in (0, 2):
|
|
145
|
+
raise ValueError("photon_rate/background must be a scalar or a 2-D array.")
|
|
146
|
+
|
|
147
|
+
mean_photo = np.zeros((height, width), dtype=float_dtype)
|
|
148
|
+
# Broadcasts a scalar or an (h, w) array; a mismatched array shape raises here.
|
|
149
|
+
mean_photo += (rate + background) * exposure_s * qe
|
|
150
|
+
|
|
151
|
+
# Photo-response non-uniformity: a fixed log-normal multiplier with unit mean,
|
|
152
|
+
# applied only where there is light. Drawn from the fixed-pattern stream so it is
|
|
153
|
+
# the same pattern in every frame (a master flat can remove it).
|
|
154
|
+
if config.prnu > 0 and np.any(mean_photo > 0):
|
|
155
|
+
sigma = config.prnu
|
|
156
|
+
rng = _fixed_pattern_rng(config, _FPN_STREAM_PRNU)
|
|
157
|
+
prnu = rng.lognormal(mean=-0.5 * sigma**2, sigma=sigma, size=mean_photo.shape)
|
|
158
|
+
mean_photo *= prnu
|
|
159
|
+
|
|
160
|
+
return mean_photo
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def apply_gain_stage(
|
|
164
|
+
electrons: NDArray[np.float64],
|
|
165
|
+
gain: float,
|
|
166
|
+
excess_noise_factor: float,
|
|
167
|
+
rng: np.random.Generator,
|
|
168
|
+
) -> NDArray[np.float64]:
|
|
169
|
+
r"""Apply a stochastic multiplication stage (EM register or APD avalanche).
|
|
170
|
+
|
|
171
|
+
A single model covers both EMCCDs and avalanche photodiodes, parameterised by
|
|
172
|
+
the mean gain ``G`` and the excess noise factor ``F``. For ``n`` input
|
|
173
|
+
electrons the multiplied output is drawn from a Gamma distribution:
|
|
174
|
+
|
|
175
|
+
.. math::
|
|
176
|
+
|
|
177
|
+
\text{out} \sim \mathrm{Gamma}(\text{shape}=n\alpha,\ \text{scale}=\theta),
|
|
178
|
+
\quad \alpha = \frac{1}{F^2 - 1}, \quad \theta = G\,(F^2 - 1).
|
|
179
|
+
|
|
180
|
+
Then :math:`E[\text{out}] = nG` and, with Poisson input of mean :math:`\mu`, the
|
|
181
|
+
total output variance is :math:`G^2 F^2 \mu` --- i.e. the model reproduces the
|
|
182
|
+
requested excess noise factor exactly. Special cases:
|
|
183
|
+
|
|
184
|
+
* ``F = sqrt(2)`` gives ``alpha = 1`` --- the classic EMCCD ``Gamma(n, G)`` model.
|
|
185
|
+
* ``F -> 1`` is noiseless multiplication (deterministic ``n * G``).
|
|
186
|
+
|
|
187
|
+
Pixels with zero input electrons produce zero output.
|
|
188
|
+
"""
|
|
189
|
+
if gain <= 1.0:
|
|
190
|
+
return electrons
|
|
191
|
+
if excess_noise_factor <= 1.0:
|
|
192
|
+
return electrons * gain # noiseless multiplication
|
|
193
|
+
|
|
194
|
+
f2 = excess_noise_factor**2
|
|
195
|
+
alpha = 1.0 / (f2 - 1.0)
|
|
196
|
+
theta = gain * (f2 - 1.0)
|
|
197
|
+
out = np.zeros_like(electrons)
|
|
198
|
+
nonzero = electrons > 0
|
|
199
|
+
if np.any(nonzero):
|
|
200
|
+
out[nonzero] = rng.gamma(shape=electrons[nonzero] * alpha, scale=theta)
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def apply_em_gain(
|
|
205
|
+
electrons: NDArray[np.float64],
|
|
206
|
+
em_gain: float,
|
|
207
|
+
rng: np.random.Generator,
|
|
208
|
+
) -> NDArray[np.float64]:
|
|
209
|
+
"""Backwards-compatible EMCCD multiplication (``F = sqrt(2)`` gain stage).
|
|
210
|
+
|
|
211
|
+
Thin wrapper over :func:`apply_gain_stage`; prefer that for new code.
|
|
212
|
+
"""
|
|
213
|
+
return apply_gain_stage(electrons, em_gain, np.sqrt(2.0), rng)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def apply_nonlinearity(
|
|
217
|
+
electrons: NDArray[np.float64],
|
|
218
|
+
config: CameraConfig,
|
|
219
|
+
) -> NDArray[np.float64]:
|
|
220
|
+
"""Bend the charge response near full well (detector nonlinearity).
|
|
221
|
+
|
|
222
|
+
Two models, both deterministic (no randomness):
|
|
223
|
+
|
|
224
|
+
* **Polynomial** (when ``config.nonlinearity_coeffs`` is set): with
|
|
225
|
+
``u = q / full_well`` and coefficients ``(c1, c2, ...)``, the response
|
|
226
|
+
multiplier is ``1 + c1 u + c2 u**2 + ...``, so an arbitrary measured curve or
|
|
227
|
+
look-up can be reproduced.
|
|
228
|
+
* **Single-parameter** (the default): ``q -> q * (1 - nonlinearity * q /
|
|
229
|
+
full_well)``, a smooth, monotonic compression so a pixel near full well reads
|
|
230
|
+
slightly low.
|
|
231
|
+
|
|
232
|
+
The polynomial model takes precedence when both are configured.
|
|
233
|
+
"""
|
|
234
|
+
if config.nonlinearity_coeffs is not None:
|
|
235
|
+
u = np.clip(electrons, 0.0, None) / config.full_well_e
|
|
236
|
+
factor = np.ones_like(u)
|
|
237
|
+
for power, coeff in enumerate(config.nonlinearity_coeffs, start=1):
|
|
238
|
+
factor = factor + coeff * u**power
|
|
239
|
+
bent: NDArray[np.float64] = electrons * np.clip(factor, 0.0, None)
|
|
240
|
+
return bent
|
|
241
|
+
if config.nonlinearity <= 0:
|
|
242
|
+
return electrons
|
|
243
|
+
factor = 1.0 - config.nonlinearity * np.clip(electrons, 0.0, None) / config.full_well_e
|
|
244
|
+
return electrons * np.clip(factor, 0.0, None)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def apply_blooming(
|
|
248
|
+
electrons: NDArray[np.float64],
|
|
249
|
+
full_well_e: float,
|
|
250
|
+
) -> NDArray[np.float64]:
|
|
251
|
+
"""Bleed charge above full well along columns (CCD blooming).
|
|
252
|
+
|
|
253
|
+
Charge exceeding ``full_well_e`` in a pixel floods symmetrically into the
|
|
254
|
+
vacant pixels of the same column (``axis=0``): half the excess sweeps toward
|
|
255
|
+
higher rows and half toward lower rows, each filling successive pixels up to
|
|
256
|
+
full well until the charge is absorbed or runs off the array edge. Deterministic
|
|
257
|
+
and charge-conserving except for charge that bleeds off the top/bottom edge.
|
|
258
|
+
"""
|
|
259
|
+
out = np.array(electrons, copy=True)
|
|
260
|
+
excess = np.clip(out - full_well_e, 0.0, None)
|
|
261
|
+
if not excess.any():
|
|
262
|
+
return out
|
|
263
|
+
n_rows, width = out.shape
|
|
264
|
+
out = np.minimum(out, full_well_e)
|
|
265
|
+
# Split the overflow and flood it outward, each direction in a single sweep with
|
|
266
|
+
# a per-column carry; a vacant pixel can only ever be filled up to full well, so
|
|
267
|
+
# charge never flows back into an already-saturated pixel (no oscillation).
|
|
268
|
+
down_share = 0.5 * excess
|
|
269
|
+
up_share = excess - down_share
|
|
270
|
+
for source, rows in ((down_share, range(n_rows)), (up_share, range(n_rows - 1, -1, -1))):
|
|
271
|
+
carry = np.zeros(width, dtype=out.dtype)
|
|
272
|
+
for r in rows:
|
|
273
|
+
incoming = carry + source[r]
|
|
274
|
+
room = full_well_e - out[r]
|
|
275
|
+
fill = np.minimum(incoming, room)
|
|
276
|
+
out[r] += fill
|
|
277
|
+
carry = incoming - fill
|
|
278
|
+
# Any charge still carried past the edge bleeds off the array.
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def apply_cti(
|
|
283
|
+
electrons: NDArray[np.float64],
|
|
284
|
+
cti: float,
|
|
285
|
+
) -> NDArray[np.float64]:
|
|
286
|
+
"""Smear charge by charge-transfer inefficiency (CTI) during readout.
|
|
287
|
+
|
|
288
|
+
A first-order, charge-conserving model: the readout register is row 0, so a
|
|
289
|
+
pixel ``r`` rows away undergoes ``r`` transfers and defers a fraction
|
|
290
|
+
``cti * r`` of its charge into the trailing pixel one row farther from the
|
|
291
|
+
register (``axis=0``), producing the characteristic CTI tail. Charge deferred
|
|
292
|
+
past the final row is lost into overscan. Deterministic.
|
|
293
|
+
"""
|
|
294
|
+
if cti <= 0:
|
|
295
|
+
return electrons
|
|
296
|
+
out = np.array(electrons, copy=True)
|
|
297
|
+
n_rows = out.shape[0]
|
|
298
|
+
transfers = np.arange(n_rows, dtype=np.float64).reshape(n_rows, 1)
|
|
299
|
+
deferred = np.minimum(cti * transfers * out, out)
|
|
300
|
+
out -= deferred
|
|
301
|
+
out[1:] += deferred[:-1]
|
|
302
|
+
return out
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def apply_ipc(
|
|
306
|
+
electrons: NDArray[np.float64],
|
|
307
|
+
coupling: float,
|
|
308
|
+
) -> NDArray[np.float64]:
|
|
309
|
+
"""Couple a fraction of each pixel into its four neighbours (inter-pixel capacitance).
|
|
310
|
+
|
|
311
|
+
Convolves with the charge-conserving 3x3 kernel whose centre is
|
|
312
|
+
``1 - 4*coupling`` and whose four edge-adjacent taps are ``coupling`` each
|
|
313
|
+
(corners zero). Models the capacitive crosstalk of CMOS / IR hybrid arrays.
|
|
314
|
+
Charge coupling past the array boundary is lost. Deterministic.
|
|
315
|
+
"""
|
|
316
|
+
if coupling <= 0:
|
|
317
|
+
return electrons
|
|
318
|
+
kernel = np.array(
|
|
319
|
+
[[0.0, coupling, 0.0], [coupling, 1.0 - 4.0 * coupling, coupling], [0.0, coupling, 0.0]],
|
|
320
|
+
dtype=np.float64,
|
|
321
|
+
)
|
|
322
|
+
convolved = ndimage.convolve(electrons, kernel, mode="constant", cval=0.0)
|
|
323
|
+
# Preserve the input dtype (the float32 fast path) — convolve upcasts to float64.
|
|
324
|
+
result: NDArray[np.float64] = convolved.astype(electrons.dtype, copy=False)
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _amplifier_maps(config: CameraConfig) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
329
|
+
"""Per-pixel conversion-gain (e-/ADU) and extra bias-offset (ADU) maps.
|
|
330
|
+
|
|
331
|
+
Tiles the sensor into ``amplifier_layout`` blocks, each with its own small,
|
|
332
|
+
*fixed* gain and offset error drawn from the fixed-pattern streams. With a
|
|
333
|
+
single amplifier (or no spread configured) the maps are uniform.
|
|
334
|
+
"""
|
|
335
|
+
height, width = config.resolution
|
|
336
|
+
n_r, n_c = config.amplifier_layout
|
|
337
|
+
gain = np.full((height, width), config.gain_e_per_adu, dtype=np.float64)
|
|
338
|
+
offset = np.zeros((height, width), dtype=np.float64)
|
|
339
|
+
if n_r * n_c <= 1 or (config.amp_gain_nonuniformity <= 0 and config.amp_offset_spread_adu <= 0):
|
|
340
|
+
return gain, offset
|
|
341
|
+
g_rng = _fixed_pattern_rng(config, _FPN_STREAM_AMP_GAIN)
|
|
342
|
+
o_rng = _fixed_pattern_rng(config, _FPN_STREAM_AMP_OFFSET)
|
|
343
|
+
g_dev = g_rng.normal(0.0, config.amp_gain_nonuniformity, size=(n_r, n_c))
|
|
344
|
+
o_dev = o_rng.normal(0.0, config.amp_offset_spread_adu, size=(n_r, n_c))
|
|
345
|
+
row_blocks = np.array_split(np.arange(height), n_r)
|
|
346
|
+
col_blocks = np.array_split(np.arange(width), n_c)
|
|
347
|
+
for i, rows in enumerate(row_blocks):
|
|
348
|
+
for j, cols in enumerate(col_blocks):
|
|
349
|
+
block = np.ix_(rows, cols)
|
|
350
|
+
gain[block] = config.gain_e_per_adu * (1.0 + g_dev[i, j])
|
|
351
|
+
offset[block] = o_dev[i, j]
|
|
352
|
+
return gain, offset
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _bias_structure_map(config: CameraConfig) -> NDArray[np.float64]:
|
|
356
|
+
"""A fixed, structured bias pattern in ADU (a gradient plus per-column offsets).
|
|
357
|
+
|
|
358
|
+
Zero everywhere when ``bias_structure_amplitude_adu`` is zero. Otherwise a
|
|
359
|
+
deterministic pattern (keyed on ``fixed_pattern_seed``) scaled so its peak
|
|
360
|
+
magnitude is ``bias_structure_amplitude_adu``.
|
|
361
|
+
"""
|
|
362
|
+
height, width = config.resolution
|
|
363
|
+
if config.bias_structure_amplitude_adu <= 0:
|
|
364
|
+
return np.zeros((height, width), dtype=np.float64)
|
|
365
|
+
rng = _fixed_pattern_rng(config, _FPN_STREAM_BIAS)
|
|
366
|
+
yy = np.linspace(-1.0, 1.0, height).reshape(height, 1)
|
|
367
|
+
xx = np.linspace(-1.0, 1.0, width).reshape(1, width)
|
|
368
|
+
a, b = rng.uniform(-1.0, 1.0, size=2)
|
|
369
|
+
plane = a * xx + b * yy
|
|
370
|
+
col_offsets = rng.normal(0.0, 1.0, size=width).reshape(1, width)
|
|
371
|
+
pattern = 0.6 * plane + 0.4 * col_offsets
|
|
372
|
+
peak = float(np.max(np.abs(pattern)))
|
|
373
|
+
if peak == 0.0:
|
|
374
|
+
return np.zeros((height, width), dtype=np.float64)
|
|
375
|
+
scaled: NDArray[np.float64] = pattern / peak * config.bias_structure_amplitude_adu
|
|
376
|
+
return np.broadcast_to(scaled, (height, width)).astype(np.float64)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _defect_mask(config: CameraConfig) -> NDArray[np.bool_] | None:
|
|
380
|
+
"""A fixed boolean map of dead pixels/columns (``True`` = no response), or ``None``.
|
|
381
|
+
|
|
382
|
+
Deterministic (keyed on ``fixed_pattern_seed``): a fraction of whole columns and
|
|
383
|
+
a fraction of individual pixels are marked dead. ``None`` when neither defect is
|
|
384
|
+
configured.
|
|
385
|
+
"""
|
|
386
|
+
height, width = config.resolution
|
|
387
|
+
if config.bad_column_fraction <= 0 and config.dead_pixel_fraction <= 0:
|
|
388
|
+
return None
|
|
389
|
+
rng = _fixed_pattern_rng(config, _FPN_STREAM_DEFECT)
|
|
390
|
+
mask = np.zeros((height, width), dtype=np.bool_)
|
|
391
|
+
if config.dead_pixel_fraction > 0:
|
|
392
|
+
mask |= rng.random((height, width)) < config.dead_pixel_fraction
|
|
393
|
+
if config.bad_column_fraction > 0:
|
|
394
|
+
bad_cols = rng.random(width) < config.bad_column_fraction
|
|
395
|
+
mask[:, bad_cols] = True
|
|
396
|
+
return mask
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def add_cosmic_rays(
|
|
400
|
+
electrons: NDArray[np.float64],
|
|
401
|
+
config: CameraConfig,
|
|
402
|
+
exposure_s: float,
|
|
403
|
+
rng: np.random.Generator,
|
|
404
|
+
) -> NDArray[np.float64]:
|
|
405
|
+
"""Deposit cosmic-ray charge bursts into random pixels.
|
|
406
|
+
|
|
407
|
+
The number of hits is Poisson with mean ``rate * area * exposure``; each hit
|
|
408
|
+
carries a broad charge burst of order ten thousand electrons. When
|
|
409
|
+
``config.cosmic_ray_track_length_px`` is zero the charge lands in a single
|
|
410
|
+
pixel; when positive, each hit draws an exponential track length and a random
|
|
411
|
+
in-plane direction (a glancing muon) and spreads its charge evenly along the
|
|
412
|
+
track --- the extended morphology a real rejection pipeline must handle.
|
|
413
|
+
"""
|
|
414
|
+
height, width = electrons.shape
|
|
415
|
+
pixel_cm = config.pixel_size_um * 1e-4
|
|
416
|
+
area_cm2 = height * width * pixel_cm**2
|
|
417
|
+
expected = config.cosmic_ray_rate_per_cm2_s * area_cm2 * exposure_s
|
|
418
|
+
n_hits = int(rng.poisson(expected))
|
|
419
|
+
if n_hits == 0:
|
|
420
|
+
return electrons
|
|
421
|
+
ys = rng.integers(0, height, n_hits)
|
|
422
|
+
xs = rng.integers(0, width, n_hits)
|
|
423
|
+
# Charge per hit: a broad distribution centred on ~10,000 e-.
|
|
424
|
+
charges = rng.gamma(shape=2.0, scale=5000.0, size=n_hits)
|
|
425
|
+
|
|
426
|
+
if config.cosmic_ray_track_length_px <= 0:
|
|
427
|
+
np.add.at(electrons, (ys, xs), charges)
|
|
428
|
+
return electrons
|
|
429
|
+
|
|
430
|
+
lengths = rng.exponential(config.cosmic_ray_track_length_px, size=n_hits)
|
|
431
|
+
angles = rng.uniform(0.0, 2.0 * np.pi, size=n_hits)
|
|
432
|
+
for x0, y0, charge, length, angle in zip(xs, ys, charges, lengths, angles):
|
|
433
|
+
n_steps = max(1, round(float(length)))
|
|
434
|
+
steps = np.arange(n_steps)
|
|
435
|
+
tx = np.clip(np.round(x0 + np.cos(angle) * steps).astype(int), 0, width - 1)
|
|
436
|
+
ty = np.clip(np.round(y0 + np.sin(angle) * steps).astype(int), 0, height - 1)
|
|
437
|
+
np.add.at(electrons, (ty, tx), charge / n_steps)
|
|
438
|
+
return electrons
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def digitize(
|
|
442
|
+
electrons: NDArray[np.float64],
|
|
443
|
+
config: CameraConfig,
|
|
444
|
+
rng: np.random.Generator,
|
|
445
|
+
) -> NDArray[np.uint32]:
|
|
446
|
+
"""Add read/reset noise, convert electrons to ADU, then saturate and quantise.
|
|
447
|
+
|
|
448
|
+
Read noise is referenced to the sensor output amplifier. When
|
|
449
|
+
``read_noise_nonuniformity`` is set (sCMOS), each pixel gets its own read-noise
|
|
450
|
+
RMS drawn from a log-normal distribution about ``read_noise_e``.
|
|
451
|
+
|
|
452
|
+
Detector-depth structure is folded in here: dead pixels/columns collect no
|
|
453
|
+
charge; kTC/reset noise adds a per-pixel Gaussian; a multi-amplifier layout
|
|
454
|
+
applies per-block conversion gain and offset; and a fixed structured-bias
|
|
455
|
+
pattern rides on the flat pedestal.
|
|
456
|
+
"""
|
|
457
|
+
signal = np.clip(electrons, 0.0, None)
|
|
458
|
+
signal = np.minimum(signal, config.full_well_e)
|
|
459
|
+
|
|
460
|
+
# Dead pixels/columns: a fixed defect map that collects no charge (they still
|
|
461
|
+
# carry read/reset noise and the bias pedestal, so they read as dark defects).
|
|
462
|
+
defects = _defect_mask(config)
|
|
463
|
+
if defects is not None:
|
|
464
|
+
signal[defects] = 0.0
|
|
465
|
+
|
|
466
|
+
# kTC / reset noise: an independent per-pixel, per-frame Gaussian (electrons).
|
|
467
|
+
# Added in place so the working dtype (e.g. the float32 fast path) is preserved.
|
|
468
|
+
if config.reset_noise_e > 0:
|
|
469
|
+
signal += rng.normal(0.0, config.reset_noise_e, size=signal.shape)
|
|
470
|
+
|
|
471
|
+
# Read noise in electrons, added at the amplifier.
|
|
472
|
+
if config.read_noise_e > 0:
|
|
473
|
+
if config.read_noise_nonuniformity > 0:
|
|
474
|
+
spread = config.read_noise_nonuniformity
|
|
475
|
+
sigma_map = config.read_noise_e * rng.lognormal(
|
|
476
|
+
mean=-0.5 * spread**2, sigma=spread, size=signal.shape
|
|
477
|
+
)
|
|
478
|
+
signal += rng.standard_normal(signal.shape) * sigma_map
|
|
479
|
+
else:
|
|
480
|
+
signal += rng.normal(0.0, config.read_noise_e, size=signal.shape)
|
|
481
|
+
|
|
482
|
+
gain_map, amp_offset = _amplifier_maps(config)
|
|
483
|
+
adu = signal / gain_map + config.bias_offset_adu + amp_offset + _bias_structure_map(config)
|
|
484
|
+
adu = np.clip(np.round(adu), 0, config.max_adu)
|
|
485
|
+
return adu.astype(np.uint32)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class SimulationResult(NamedTuple):
|
|
489
|
+
"""The output of :func:`simulate_frame`: the digitised frame plus ground truth."""
|
|
490
|
+
|
|
491
|
+
adu: NDArray[np.uint32]
|
|
492
|
+
mean_photoelectrons: NDArray[np.float64]
|
|
493
|
+
mean_dark_electrons: NDArray[np.float64]
|
|
494
|
+
photon_rate: PhotonRate
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def frame_electrons(
|
|
498
|
+
config: CameraConfig,
|
|
499
|
+
mean_electrons: NDArray[np.float64],
|
|
500
|
+
rng: np.random.Generator,
|
|
501
|
+
exposure_s: float = 0.0,
|
|
502
|
+
) -> NDArray[np.float64]:
|
|
503
|
+
"""Apply shot noise, CIC, cosmic rays, nonlinearity, and any gain stage.
|
|
504
|
+
|
|
505
|
+
Takes the noise-free expected electrons per pixel and returns a realised
|
|
506
|
+
electron frame prior to read noise and digitisation. ``exposure_s`` is needed
|
|
507
|
+
only to scale the cosmic-ray rate. The working dtype follows ``mean_electrons``
|
|
508
|
+
(``float64`` exact, or ``float32`` for the memory-light fast path).
|
|
509
|
+
"""
|
|
510
|
+
electrons = rng.poisson(mean_electrons).astype(mean_electrons.dtype)
|
|
511
|
+
|
|
512
|
+
if config.clock_induced_charge_e > 0:
|
|
513
|
+
electrons += rng.poisson(config.clock_induced_charge_e, size=electrons.shape)
|
|
514
|
+
|
|
515
|
+
if config.cosmic_ray_rate_per_cm2_s > 0 and exposure_s > 0:
|
|
516
|
+
electrons = add_cosmic_rays(electrons, config, exposure_s, rng)
|
|
517
|
+
|
|
518
|
+
if config.blooming:
|
|
519
|
+
electrons = apply_blooming(electrons, config.full_well_e)
|
|
520
|
+
|
|
521
|
+
if config.cti > 0:
|
|
522
|
+
electrons = apply_cti(electrons, config.cti)
|
|
523
|
+
|
|
524
|
+
if config.ipc_coupling > 0:
|
|
525
|
+
electrons = apply_ipc(electrons, config.ipc_coupling)
|
|
526
|
+
|
|
527
|
+
if config.nonlinearity > 0 or config.nonlinearity_coeffs is not None:
|
|
528
|
+
electrons = apply_nonlinearity(electrons, config)
|
|
529
|
+
|
|
530
|
+
if config.has_gain_stage:
|
|
531
|
+
electrons = apply_gain_stage(
|
|
532
|
+
electrons, config.em_gain, config.gain_excess_noise_factor, rng
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
return electrons
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def simulate_frame(
|
|
539
|
+
config: CameraConfig,
|
|
540
|
+
photon_rate: PhotonRate,
|
|
541
|
+
exposure_s: float,
|
|
542
|
+
*,
|
|
543
|
+
temperature_c: float,
|
|
544
|
+
background_photon_rate: PhotonRate = 0.0,
|
|
545
|
+
quantum_efficiency: float | None = None,
|
|
546
|
+
extra_electrons: PhotonRate = 0.0,
|
|
547
|
+
rng: np.random.Generator | None = None,
|
|
548
|
+
seed: int | None = None,
|
|
549
|
+
float_dtype: DTypeLike = DEFAULT_FLOAT_DTYPE,
|
|
550
|
+
) -> SimulationResult:
|
|
551
|
+
"""Simulate one frame end-to-end, returning ADU and the noise-free truth.
|
|
552
|
+
|
|
553
|
+
Parameters
|
|
554
|
+
----------
|
|
555
|
+
config:
|
|
556
|
+
The detector configuration.
|
|
557
|
+
photon_rate:
|
|
558
|
+
Incident photon rate in photons/s/pixel, as a scalar (uniform) or a 2-D
|
|
559
|
+
array. Use ``0.0`` for a dark/bias frame.
|
|
560
|
+
exposure_s:
|
|
561
|
+
Integration time in seconds (``0`` for a bias frame).
|
|
562
|
+
temperature_c:
|
|
563
|
+
Sensor temperature in degrees Celsius.
|
|
564
|
+
background_photon_rate:
|
|
565
|
+
Additive background (sky/thermal) photon rate in photons/s/pixel.
|
|
566
|
+
quantum_efficiency:
|
|
567
|
+
Overrides ``config.quantum_efficiency`` for the photon-to-electron step
|
|
568
|
+
(used by spectral mode with a pre-converted electron map and ``1.0``).
|
|
569
|
+
extra_electrons:
|
|
570
|
+
Additive noise-free signal already in electrons (scalar or 2-D array),
|
|
571
|
+
injected before shot noise and the gain stage. Used to carry latent charge
|
|
572
|
+
from image persistence across the frames of an observation; it is real
|
|
573
|
+
charge in the well, so it picks up shot noise and any EM/avalanche gain.
|
|
574
|
+
rng, seed:
|
|
575
|
+
Provide an existing generator, or a seed to build a fresh one.
|
|
576
|
+
float_dtype:
|
|
577
|
+
Working floating-point precision of the per-pixel arrays: ``float64`` (the
|
|
578
|
+
exact default) or ``float32`` for the memory-light fast path used for large
|
|
579
|
+
detectors and bulk dataset generation. The digitised ADU stay integer
|
|
580
|
+
regardless; only the floating-point signal chain and the truth arrays change.
|
|
581
|
+
"""
|
|
582
|
+
if exposure_s < 0:
|
|
583
|
+
raise ValueError("exposure_s must be non-negative.")
|
|
584
|
+
if rng is None:
|
|
585
|
+
rng = np.random.default_rng(seed)
|
|
586
|
+
|
|
587
|
+
mean_photo = photo_signal_map(
|
|
588
|
+
config, photon_rate, exposure_s, background_photon_rate, quantum_efficiency, float_dtype
|
|
589
|
+
)
|
|
590
|
+
mean_dark = dark_signal_map(config, exposure_s, temperature_c, float_dtype)
|
|
591
|
+
mean_total = mean_photo + mean_dark + np.asarray(extra_electrons, dtype=float_dtype)
|
|
592
|
+
electrons = frame_electrons(config, mean_total, rng, exposure_s)
|
|
593
|
+
adu = digitize(electrons, config, rng)
|
|
594
|
+
return SimulationResult(adu, mean_photo, mean_dark, photon_rate)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def generate_dark_frame(
|
|
598
|
+
config: CameraConfig,
|
|
599
|
+
exposure_s: float,
|
|
600
|
+
temperature_c: float,
|
|
601
|
+
rng: np.random.Generator | None = None,
|
|
602
|
+
seed: int | None = None,
|
|
603
|
+
) -> NDArray[np.uint32]:
|
|
604
|
+
"""End-to-end dark frame in ADU (the ``photon_rate = 0`` case of ``simulate_frame``)."""
|
|
605
|
+
return simulate_frame(
|
|
606
|
+
config, 0.0, exposure_s, temperature_c=temperature_c, rng=rng, seed=seed
|
|
607
|
+
).adu
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def dark_frame_electrons(
|
|
611
|
+
config: CameraConfig,
|
|
612
|
+
exposure_s: float,
|
|
613
|
+
temperature_c: float,
|
|
614
|
+
rng: np.random.Generator,
|
|
615
|
+
) -> NDArray[np.float64]:
|
|
616
|
+
"""Electron-domain dark frame prior to digitisation (kept for convenience)."""
|
|
617
|
+
mean_dark = dark_signal_map(config, exposure_s, temperature_c)
|
|
618
|
+
return frame_electrons(config, mean_dark, rng, exposure_s)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
__all__ = [
|
|
622
|
+
"SimulationResult",
|
|
623
|
+
"add_cosmic_rays",
|
|
624
|
+
"apply_blooming",
|
|
625
|
+
"apply_cti",
|
|
626
|
+
"apply_em_gain",
|
|
627
|
+
"apply_gain_stage",
|
|
628
|
+
"apply_ipc",
|
|
629
|
+
"apply_nonlinearity",
|
|
630
|
+
"dark_frame_electrons",
|
|
631
|
+
"dark_signal_map",
|
|
632
|
+
"digitize",
|
|
633
|
+
"frame_electrons",
|
|
634
|
+
"generate_dark_frame",
|
|
635
|
+
"photo_signal_map",
|
|
636
|
+
"simulate_frame",
|
|
637
|
+
]
|