getframes 2.1.0__tar.gz → 2.1.1__tar.gz
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-2.1.0 → getframes-2.1.1}/CHANGELOG.md +51 -1
- {getframes-2.1.0 → getframes-2.1.1}/PKG-INFO +60 -12
- {getframes-2.1.0 → getframes-2.1.1}/README.md +57 -11
- getframes-2.1.1/benchmarks/__init__.py +1 -0
- getframes-2.1.1/benchmarks/bench_devices.py +212 -0
- getframes-2.1.1/benchmarks/device-results.json +221 -0
- getframes-2.1.1/benchmarks/device-results.md +17 -0
- getframes-2.1.1/benchmarks/render_device_table.py +77 -0
- {getframes-2.1.0 → getframes-2.1.1}/benchmarks/run.py +32 -10
- {getframes-2.1.0 → getframes-2.1.1}/pyproject.toml +4 -1
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/__about__.py +1 -1
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/__init__.py +5 -0
- getframes-2.1.1/src/getframes/backend.py +150 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/camera.py +132 -12
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/config.py +96 -1
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/frame.py +33 -13
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/noise.py +354 -130
- getframes-2.1.1/src/getframes/presets/data/andor_ocam2k.toml +50 -0
- getframes-2.1.1/tests/test_benchmarks.py +25 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_config.py +47 -1
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_detector.py +30 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_gain.py +38 -0
- getframes-2.1.1/tests/test_gpu.py +169 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_presets.py +19 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_signal.py +26 -0
- getframes-2.1.0/src/getframes/presets/data/andor_ocam2k.toml +0 -33
- {getframes-2.1.0 → getframes-2.1.1}/.gitignore +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/LICENSE +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/01_basic_dark_frame.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/02_custom_camera.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/03_master_dark.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/04_browse_presets.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/05_visualise.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/06_photon_transfer_curve.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/07_star_field_exposure.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/08_ao_limiting_magnitude.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/09_transit_photometry.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/10_detector_realism.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/11_radiometry_and_ir.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/12_ml_dataset.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/13_crowded_field.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/14_keck_lgs_ttf_trade_study.ipynb +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/README.md +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/examples/_common.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/analysis/__init__.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/analysis/apertures.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/analysis/ptc.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/calibrate.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/cli.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/dataset.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/observation.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/__init__.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/__init__.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/andor_cb1_0_5mp.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/andor_ikon_m934.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/andor_ixon_ultra_888.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/andor_marana_4_2b_11.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/generic_ccd.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/generic_cmos.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/generic_eapd.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/generic_emccd.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/generic_scmos.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/hamamatsu_orca_fusion.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/hamamatsu_orca_quest_2.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/leonardo_saphira.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/nuvu_hnu_128_omega.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/nuvu_hnu_240.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/photometrics_prime_95b.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/princeton_instruments_kuro_1200b.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/qhy530_pro_ii.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/scimeasure_little_joe_ccd39.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/tucsen_aries_6504_pro.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/presets/data/zwo_asi2600mm.toml +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/py.typed +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/scene/__init__.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/scene/optics.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/scene/photometry.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/scene/psf.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/scene/scene.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/scene/sources.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/scene/thermal.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/scene/wcs.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/src/getframes/spectral.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_analysis.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_calibrate.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_camera.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_cli.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_dataset.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_frame.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_noise.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_observation.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_radiometry.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_realism.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_scale.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_scene.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_scene_enrich.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_spectral.py +0 -0
- {getframes-2.1.0 → getframes-2.1.1}/tests/test_validation.py +0 -0
|
@@ -6,10 +6,59 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.1.1] - 2026-07-26
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Exact multi-amplifier ROI geometry and measured row-major gain/offset response
|
|
14
|
+
fields. The OCAM2K preset now declares its eight-output 4x2 layout; cropped
|
|
15
|
+
configurations can preserve their true split pixels.
|
|
16
|
+
- Optional CuPy detector execution via `Camera(..., device="gpu")`. Photon-rate,
|
|
17
|
+
electron, `FrameTruth`, and digitised ADU arrays remain device-resident through
|
|
18
|
+
scalar and spectral exposure paths, including all detector artifact models and
|
|
19
|
+
both binning modes. `Frame.device`, `get_backend`, `get_array_module`, and
|
|
20
|
+
`to_numpy` make the execution and host-copy boundaries explicit.
|
|
21
|
+
- CUDA parity tests cover seeded reproducibility, CPU/GPU statistics, spectral
|
|
22
|
+
truth, CCD/CMOS/sCMOS/EMCCD/eAPD paths, fixed structure, gain, and detector
|
|
23
|
+
artifacts. The benchmark runner accepts `--device gpu|both`.
|
|
24
|
+
- Added a reproducible paired-device bulk-throughput suite, self-describing JSON
|
|
25
|
+
artifact, rendered reference table, and README/GPU-guide CPU-versus-GPU results
|
|
26
|
+
for representative WFS, EMCCD, eAPD, and large science-frame workflows.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Re-characterized the OCAM2K preset from the Keck unit report and supplied
|
|
31
|
+
detector studies: approximately 28 output e-/ADU (21.43 ADU per input electron
|
|
32
|
+
at EM gain 600), 0.360 e- input-referred read noise, 1.579 e-/pixel/s dark
|
|
33
|
+
current, and a separately derived 0.004912 e-/pixel/frame CIC term. The output
|
|
34
|
+
saturation charge and representative Keck bias now use the same conversion
|
|
35
|
+
convention.
|
|
36
|
+
- Fixed PRNU/DSNU/hot-pixel, amplifier, bias-structure, and defect maps are built
|
|
37
|
+
once on persistent `Camera` construction rather than regenerated for every
|
|
38
|
+
frame. This preserves the fixed-pattern contract and materially improves both
|
|
39
|
+
CPU and GPU warm exposure throughput.
|
|
40
|
+
- Optimized warm detector execution with in-place signal construction and
|
|
41
|
+
digitization, direct full-array Gamma sampling, working-precision CPU/GPU
|
|
42
|
+
Gaussian and Gamma draws, and a reusable per-call GPU seed stream. The RTX
|
|
43
|
+
5090 reference improves GPU CMOS throughput by 1.36x–1.42x and GPU EMCCD/eAPD
|
|
44
|
+
throughput by 2.35x–2.41x over the initial implementation without changing the
|
|
45
|
+
detector distributions.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- Separated image-area and post-multiplication saturation with the optional,
|
|
50
|
+
backwards-compatible `CameraConfig.output_full_well_e`. Gain-stage input charge
|
|
51
|
+
now clips at `full_well_e` before multiplication. The OCAM2K preset uses Andor's
|
|
52
|
+
published 270,000 e- image well and Keck's measured 10,000-count output ceiling,
|
|
53
|
+
instead of incorrectly clipping multiplied signal at 250 e- (25 ADU).
|
|
54
|
+
|
|
9
55
|
## [2.1.0] - 2026-07-19
|
|
10
56
|
|
|
11
57
|
### Added
|
|
12
58
|
|
|
59
|
+
- `Camera.expose_spectral`, a single-pass wavelength-resolved photon-cube
|
|
60
|
+
detector entry point. It applies the configured QE curve exactly once and
|
|
61
|
+
preserves integrated and spectral photon-rate truth arrays.
|
|
13
62
|
- Five scientific-camera presets used by the Keck tip/tilt and low-bandwidth WFS
|
|
14
63
|
detector trade example: KURO 1200B, Prime 95B, Marana 4.2B-11, QHY530 Pro II,
|
|
15
64
|
and Aries 6504 Pro. All five now include a wavelength-resolved `qe_curve`:
|
|
@@ -297,7 +346,8 @@ together in 1.0.
|
|
|
297
346
|
- Documentation, runnable examples, and CI (lint, type-check, test matrix, PyPI
|
|
298
347
|
release via Trusted Publishing).
|
|
299
348
|
|
|
300
|
-
[Unreleased]: https://github.com/jacotay7/getframes/compare/2.1.
|
|
349
|
+
[Unreleased]: https://github.com/jacotay7/getframes/compare/2.1.1...HEAD
|
|
350
|
+
[2.1.1]: https://github.com/jacotay7/getframes/compare/2.1.0...2.1.1
|
|
301
351
|
[2.1.0]: https://github.com/jacotay7/getframes/compare/2.0.0...2.1.0
|
|
302
352
|
[2.0.0]: https://github.com/jacotay7/getframes/compare/1.0.0...2.0.0
|
|
303
353
|
[1.0.0]: https://github.com/jacotay7/getframes/compare/0.1.0...1.0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: getframes
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.1
|
|
4
4
|
Summary: Generate physically realistic synthetic camera frames (CCD/CMOS/EMCCD/eAPD/sCMOS) — dark, bias, flat, and rendered star fields — with auditable noise physics for scientific imaging pipelines.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jacotay7/getframes
|
|
6
6
|
Project-URL: Documentation, https://jacotay7.github.io/getframes/
|
|
@@ -40,6 +40,8 @@ Requires-Dist: mkdocs>=1.5; extra == 'docs'
|
|
|
40
40
|
Requires-Dist: mkdocstrings[python]>=0.24; extra == 'docs'
|
|
41
41
|
Provides-Extra: examples
|
|
42
42
|
Requires-Dist: matplotlib>=3.7; extra == 'examples'
|
|
43
|
+
Provides-Extra: gpu
|
|
44
|
+
Requires-Dist: cupy-cuda12x>=13.0; extra == 'gpu'
|
|
43
45
|
Description-Content-Type: text/markdown
|
|
44
46
|
|
|
45
47
|
# getframes
|
|
@@ -65,12 +67,14 @@ photon → electron → ADU signal path, with optional opt-in spectral mode.
|
|
|
65
67
|
> **Status:** stable. `getframes` 2.0 freezes the full public surface — the
|
|
66
68
|
> detector, scene, calibration, observation, radiometry, and dataset APIs — under
|
|
67
69
|
> [Semantic Versioning](https://semver.org/spec/v2.0.0.html); see
|
|
68
|
-
> [API stability](docs/stability.md).
|
|
70
|
+
> [API stability](docs/stability.md). An optional CuPy backend now keeps the
|
|
71
|
+
> photon → electron → ADU detector path and truth arrays on NVIDIA GPUs.
|
|
69
72
|
|
|
70
73
|
## Install
|
|
71
74
|
|
|
72
75
|
```bash
|
|
73
76
|
pip install getframes
|
|
77
|
+
pip install 'getframes[gpu]' # optional CUDA 12.x / CuPy detector backend
|
|
74
78
|
```
|
|
75
79
|
|
|
76
80
|
From source (for development):
|
|
@@ -92,15 +96,16 @@ cam = gf.Camera.from_preset("andor_ikon_m934")
|
|
|
92
96
|
# ...and generate a reproducible dark frame.
|
|
93
97
|
frame = cam.dark_frame(exposure=60.0, temperature=-60.0, seed=0)
|
|
94
98
|
|
|
95
|
-
frame.data
|
|
96
|
-
frame.stats()
|
|
97
|
-
frame.metadata
|
|
99
|
+
frame.data # 2-D numpy array of ADU, shape (1024, 1024)
|
|
100
|
+
frame.stats() # {'mean': ..., 'median': ..., 'std': ..., 'min': ..., 'max': ...}
|
|
101
|
+
frame.metadata # camera/exposure/temperature provenance
|
|
98
102
|
```
|
|
99
103
|
|
|
100
104
|
`Frame` is array-like, so it drops straight into NumPy:
|
|
101
105
|
|
|
102
106
|
```python
|
|
103
107
|
import numpy as np
|
|
108
|
+
|
|
104
109
|
master_dark = np.mean([np.asarray(f) for f in cam.dark_series(60.0, n_frames=20, seed=1)], axis=0)
|
|
105
110
|
```
|
|
106
111
|
|
|
@@ -111,7 +116,7 @@ cam = gf.Camera(
|
|
|
111
116
|
gf.CameraConfig(
|
|
112
117
|
name="My Lab CMOS",
|
|
113
118
|
sensor_type="CMOS",
|
|
114
|
-
resolution=(2048, 2048),
|
|
119
|
+
resolution=(2048, 2048), # (height, width)
|
|
115
120
|
pixel_size_um=6.5,
|
|
116
121
|
quantum_efficiency=0.82,
|
|
117
122
|
full_well_e=30_000,
|
|
@@ -119,7 +124,7 @@ cam = gf.Camera(
|
|
|
119
124
|
gain_e_per_adu=0.8,
|
|
120
125
|
bias_offset_adu=300,
|
|
121
126
|
read_noise_e=1.8,
|
|
122
|
-
dark_current_e_per_s=0.5,
|
|
127
|
+
dark_current_e_per_s=0.5, # at the reference temperature
|
|
123
128
|
dark_current_ref_temp_c=20.0,
|
|
124
129
|
dark_current_doubling_temp_c=6.0,
|
|
125
130
|
)
|
|
@@ -127,6 +132,45 @@ cam = gf.Camera(
|
|
|
127
132
|
frame = cam.dark_frame(exposure=30.0, temperature=-10.0)
|
|
128
133
|
```
|
|
129
134
|
|
|
135
|
+
### Run the detector on a GPU
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
import cupy as cp
|
|
139
|
+
import getframes as gf
|
|
140
|
+
|
|
141
|
+
cam = gf.Camera.from_preset("andor_ocam2k", device="gpu", precision="float32")
|
|
142
|
+
rate = cp.full(cam.resolution, 2.0e6, dtype=cp.float32) # photons/s/pixel
|
|
143
|
+
frame = cam.expose(rate, exposure=1.0e-3, seed=0)
|
|
144
|
+
|
|
145
|
+
frame.data # CuPy uint32 ADU array; no host copy
|
|
146
|
+
frame.truth.mean_electrons # CuPy truth array
|
|
147
|
+
host_adu = gf.to_numpy(frame.data) # explicit device-to-host boundary
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
CPU and GPU use independent random-number streams: a seed repeats exactly on a
|
|
151
|
+
fixed backend, while parity across backends means matching detector statistics,
|
|
152
|
+
not identical pixels. See the [GPU guide](docs/guides/gpu.md).
|
|
153
|
+
|
|
154
|
+
## CPU/GPU throughput
|
|
155
|
+
|
|
156
|
+
Warm bulk-frame throughput on an AMD Ryzen 9 9950X3D and NVIDIA RTX 5090
|
|
157
|
+
(`float32`, truth enabled, persistent camera, device-resident input/output, no
|
|
158
|
+
host transfers) is:
|
|
159
|
+
|
|
160
|
+
| Workflow | Native shape | CPU (frames/s) | GPU (frames/s) | Speedup |
|
|
161
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
162
|
+
| Pyramid WFS CMOS | 80x80 | 5,264.9 | 10,565.4 | 2.01x |
|
|
163
|
+
| Shack-Hartmann WFS CMOS | 160x160 | 1,372.0 | 10,538.1 | 7.68x |
|
|
164
|
+
| OCAM2K EMCCD | 240x240 | 347.7 | 8,057.1 | 23.17x |
|
|
165
|
+
| SAPHIRA eAPD | 256x320 | 283.2 | 7,481.8 | 26.42x |
|
|
166
|
+
| Large science CMOS | 1024x1024 | 31.5 | 1,431.9 | 45.39x |
|
|
167
|
+
|
|
168
|
+
Higher is better. CUDA was synchronized around every timed region; construction
|
|
169
|
+
was excluded. Even the smallest case reaches about 2x, while larger arrays and
|
|
170
|
+
gain-stage detectors expose much more parallel work. See the
|
|
171
|
+
[full snapshot](benchmarks/device-results.md), [raw JSON](benchmarks/device-results.json),
|
|
172
|
+
and [GPU benchmark methodology](docs/guides/gpu.md#reference-throughput).
|
|
173
|
+
|
|
130
174
|
### Observe a simulated star field
|
|
131
175
|
|
|
132
176
|
Render astronomical sources through a PSF and telescope, then expose them on a
|
|
@@ -135,14 +179,18 @@ detector — the full photon → electron → ADU path:
|
|
|
135
179
|
```python
|
|
136
180
|
scene = gf.Scene(
|
|
137
181
|
shape=(256, 256),
|
|
138
|
-
optics=gf.Telescope(
|
|
139
|
-
|
|
182
|
+
optics=gf.Telescope(
|
|
183
|
+
aperture_diameter_m=2.5,
|
|
184
|
+
throughput=0.3,
|
|
185
|
+
plate_scale_arcsec_per_pixel=0.4,
|
|
186
|
+
band=gf.Bandpass.johnson("V"),
|
|
187
|
+
),
|
|
140
188
|
psf=gf.MoffatPSF(fwhm_arcsec=1.1, beta=3.0),
|
|
141
189
|
sources=[gf.PointSource(x=128, y=128, magnitude=20.0)],
|
|
142
190
|
sky=gf.Sky(surface_brightness_mag_arcsec2=21.0),
|
|
143
191
|
)
|
|
144
192
|
cam = gf.Camera.from_preset("zwo_asi2600mm").with_config(resolution=(256, 256))
|
|
145
|
-
frame = cam.observe(scene, exposure=300.0, seed=0)
|
|
193
|
+
frame = cam.observe(scene, exposure=300.0, seed=0) # a realistic science frame
|
|
146
194
|
```
|
|
147
195
|
|
|
148
196
|
You can also drive the detector directly with a photon-rate map (a scalar for a
|
|
@@ -154,8 +202,8 @@ uniform flat, or a per-pixel array): `cam.expose(photon_rate, exposure)`.
|
|
|
154
202
|
from getframes import available_presets
|
|
155
203
|
from getframes.presets import preset_info
|
|
156
204
|
|
|
157
|
-
available_presets()
|
|
158
|
-
preset_info()
|
|
205
|
+
available_presets() # ['andor_ikon_m934', 'andor_ixon_ultra_888', 'generic_ccd', ...]
|
|
206
|
+
preset_info() # rich descriptors for each preset
|
|
159
207
|
```
|
|
160
208
|
|
|
161
209
|
| Preset | Sensor | Notes |
|
|
@@ -21,12 +21,14 @@ photon → electron → ADU signal path, with optional opt-in spectral mode.
|
|
|
21
21
|
> **Status:** stable. `getframes` 2.0 freezes the full public surface — the
|
|
22
22
|
> detector, scene, calibration, observation, radiometry, and dataset APIs — under
|
|
23
23
|
> [Semantic Versioning](https://semver.org/spec/v2.0.0.html); see
|
|
24
|
-
> [API stability](docs/stability.md).
|
|
24
|
+
> [API stability](docs/stability.md). An optional CuPy backend now keeps the
|
|
25
|
+
> photon → electron → ADU detector path and truth arrays on NVIDIA GPUs.
|
|
25
26
|
|
|
26
27
|
## Install
|
|
27
28
|
|
|
28
29
|
```bash
|
|
29
30
|
pip install getframes
|
|
31
|
+
pip install 'getframes[gpu]' # optional CUDA 12.x / CuPy detector backend
|
|
30
32
|
```
|
|
31
33
|
|
|
32
34
|
From source (for development):
|
|
@@ -48,15 +50,16 @@ cam = gf.Camera.from_preset("andor_ikon_m934")
|
|
|
48
50
|
# ...and generate a reproducible dark frame.
|
|
49
51
|
frame = cam.dark_frame(exposure=60.0, temperature=-60.0, seed=0)
|
|
50
52
|
|
|
51
|
-
frame.data
|
|
52
|
-
frame.stats()
|
|
53
|
-
frame.metadata
|
|
53
|
+
frame.data # 2-D numpy array of ADU, shape (1024, 1024)
|
|
54
|
+
frame.stats() # {'mean': ..., 'median': ..., 'std': ..., 'min': ..., 'max': ...}
|
|
55
|
+
frame.metadata # camera/exposure/temperature provenance
|
|
54
56
|
```
|
|
55
57
|
|
|
56
58
|
`Frame` is array-like, so it drops straight into NumPy:
|
|
57
59
|
|
|
58
60
|
```python
|
|
59
61
|
import numpy as np
|
|
62
|
+
|
|
60
63
|
master_dark = np.mean([np.asarray(f) for f in cam.dark_series(60.0, n_frames=20, seed=1)], axis=0)
|
|
61
64
|
```
|
|
62
65
|
|
|
@@ -67,7 +70,7 @@ cam = gf.Camera(
|
|
|
67
70
|
gf.CameraConfig(
|
|
68
71
|
name="My Lab CMOS",
|
|
69
72
|
sensor_type="CMOS",
|
|
70
|
-
resolution=(2048, 2048),
|
|
73
|
+
resolution=(2048, 2048), # (height, width)
|
|
71
74
|
pixel_size_um=6.5,
|
|
72
75
|
quantum_efficiency=0.82,
|
|
73
76
|
full_well_e=30_000,
|
|
@@ -75,7 +78,7 @@ cam = gf.Camera(
|
|
|
75
78
|
gain_e_per_adu=0.8,
|
|
76
79
|
bias_offset_adu=300,
|
|
77
80
|
read_noise_e=1.8,
|
|
78
|
-
dark_current_e_per_s=0.5,
|
|
81
|
+
dark_current_e_per_s=0.5, # at the reference temperature
|
|
79
82
|
dark_current_ref_temp_c=20.0,
|
|
80
83
|
dark_current_doubling_temp_c=6.0,
|
|
81
84
|
)
|
|
@@ -83,6 +86,45 @@ cam = gf.Camera(
|
|
|
83
86
|
frame = cam.dark_frame(exposure=30.0, temperature=-10.0)
|
|
84
87
|
```
|
|
85
88
|
|
|
89
|
+
### Run the detector on a GPU
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import cupy as cp
|
|
93
|
+
import getframes as gf
|
|
94
|
+
|
|
95
|
+
cam = gf.Camera.from_preset("andor_ocam2k", device="gpu", precision="float32")
|
|
96
|
+
rate = cp.full(cam.resolution, 2.0e6, dtype=cp.float32) # photons/s/pixel
|
|
97
|
+
frame = cam.expose(rate, exposure=1.0e-3, seed=0)
|
|
98
|
+
|
|
99
|
+
frame.data # CuPy uint32 ADU array; no host copy
|
|
100
|
+
frame.truth.mean_electrons # CuPy truth array
|
|
101
|
+
host_adu = gf.to_numpy(frame.data) # explicit device-to-host boundary
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
CPU and GPU use independent random-number streams: a seed repeats exactly on a
|
|
105
|
+
fixed backend, while parity across backends means matching detector statistics,
|
|
106
|
+
not identical pixels. See the [GPU guide](docs/guides/gpu.md).
|
|
107
|
+
|
|
108
|
+
## CPU/GPU throughput
|
|
109
|
+
|
|
110
|
+
Warm bulk-frame throughput on an AMD Ryzen 9 9950X3D and NVIDIA RTX 5090
|
|
111
|
+
(`float32`, truth enabled, persistent camera, device-resident input/output, no
|
|
112
|
+
host transfers) is:
|
|
113
|
+
|
|
114
|
+
| Workflow | Native shape | CPU (frames/s) | GPU (frames/s) | Speedup |
|
|
115
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
116
|
+
| Pyramid WFS CMOS | 80x80 | 5,264.9 | 10,565.4 | 2.01x |
|
|
117
|
+
| Shack-Hartmann WFS CMOS | 160x160 | 1,372.0 | 10,538.1 | 7.68x |
|
|
118
|
+
| OCAM2K EMCCD | 240x240 | 347.7 | 8,057.1 | 23.17x |
|
|
119
|
+
| SAPHIRA eAPD | 256x320 | 283.2 | 7,481.8 | 26.42x |
|
|
120
|
+
| Large science CMOS | 1024x1024 | 31.5 | 1,431.9 | 45.39x |
|
|
121
|
+
|
|
122
|
+
Higher is better. CUDA was synchronized around every timed region; construction
|
|
123
|
+
was excluded. Even the smallest case reaches about 2x, while larger arrays and
|
|
124
|
+
gain-stage detectors expose much more parallel work. See the
|
|
125
|
+
[full snapshot](benchmarks/device-results.md), [raw JSON](benchmarks/device-results.json),
|
|
126
|
+
and [GPU benchmark methodology](docs/guides/gpu.md#reference-throughput).
|
|
127
|
+
|
|
86
128
|
### Observe a simulated star field
|
|
87
129
|
|
|
88
130
|
Render astronomical sources through a PSF and telescope, then expose them on a
|
|
@@ -91,14 +133,18 @@ detector — the full photon → electron → ADU path:
|
|
|
91
133
|
```python
|
|
92
134
|
scene = gf.Scene(
|
|
93
135
|
shape=(256, 256),
|
|
94
|
-
optics=gf.Telescope(
|
|
95
|
-
|
|
136
|
+
optics=gf.Telescope(
|
|
137
|
+
aperture_diameter_m=2.5,
|
|
138
|
+
throughput=0.3,
|
|
139
|
+
plate_scale_arcsec_per_pixel=0.4,
|
|
140
|
+
band=gf.Bandpass.johnson("V"),
|
|
141
|
+
),
|
|
96
142
|
psf=gf.MoffatPSF(fwhm_arcsec=1.1, beta=3.0),
|
|
97
143
|
sources=[gf.PointSource(x=128, y=128, magnitude=20.0)],
|
|
98
144
|
sky=gf.Sky(surface_brightness_mag_arcsec2=21.0),
|
|
99
145
|
)
|
|
100
146
|
cam = gf.Camera.from_preset("zwo_asi2600mm").with_config(resolution=(256, 256))
|
|
101
|
-
frame = cam.observe(scene, exposure=300.0, seed=0)
|
|
147
|
+
frame = cam.observe(scene, exposure=300.0, seed=0) # a realistic science frame
|
|
102
148
|
```
|
|
103
149
|
|
|
104
150
|
You can also drive the detector directly with a photon-rate map (a scalar for a
|
|
@@ -110,8 +156,8 @@ uniform flat, or a per-pixel array): `cam.expose(photon_rate, exposure)`.
|
|
|
110
156
|
from getframes import available_presets
|
|
111
157
|
from getframes.presets import preset_info
|
|
112
158
|
|
|
113
|
-
available_presets()
|
|
114
|
-
preset_info()
|
|
159
|
+
available_presets() # ['andor_ikon_m934', 'andor_ixon_ultra_888', 'generic_ccd', ...]
|
|
160
|
+
preset_info() # rich descriptors for each preset
|
|
115
161
|
```
|
|
116
162
|
|
|
117
163
|
| Preset | Sensor | Notes |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Benchmark helpers; not part of the public getframes API."""
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Record warm CPU/GPU detector throughput for representative frame workloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib.metadata
|
|
7
|
+
import json
|
|
8
|
+
import platform
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import getframes as gf
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Workflow:
|
|
23
|
+
"""One representative persistent-camera workload."""
|
|
24
|
+
|
|
25
|
+
key: str
|
|
26
|
+
label: str
|
|
27
|
+
preset: str
|
|
28
|
+
shape: tuple[int, int]
|
|
29
|
+
exposure_s: float
|
|
30
|
+
photon_rate_per_s: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
WORKFLOWS = (
|
|
34
|
+
Workflow("pyramid_cmos_80", "Pyramid WFS CMOS", "generic_cmos", (80, 80), 1e-3, 2e6),
|
|
35
|
+
Workflow(
|
|
36
|
+
"shack_hartmann_cmos_160",
|
|
37
|
+
"Shack-Hartmann WFS CMOS",
|
|
38
|
+
"generic_cmos",
|
|
39
|
+
(160, 160),
|
|
40
|
+
1e-3,
|
|
41
|
+
2e6,
|
|
42
|
+
),
|
|
43
|
+
Workflow("ocam2k_emccd_240", "OCAM2K EMCCD", "andor_ocam2k", (240, 240), 1e-3, 2e6),
|
|
44
|
+
Workflow(
|
|
45
|
+
"saphira_eapd_256x320",
|
|
46
|
+
"SAPHIRA eAPD",
|
|
47
|
+
"leonardo_saphira",
|
|
48
|
+
(256, 320),
|
|
49
|
+
1e-3,
|
|
50
|
+
2e6,
|
|
51
|
+
),
|
|
52
|
+
Workflow(
|
|
53
|
+
"science_cmos_1024",
|
|
54
|
+
"Large science CMOS",
|
|
55
|
+
"generic_cmos",
|
|
56
|
+
(1024, 1024),
|
|
57
|
+
5.0,
|
|
58
|
+
200.0,
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _installed_version(name: str) -> str | None:
|
|
64
|
+
try:
|
|
65
|
+
return importlib.metadata.version(name)
|
|
66
|
+
except importlib.metadata.PackageNotFoundError:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _git_state(root: Path) -> tuple[str | None, bool | None]:
|
|
71
|
+
try:
|
|
72
|
+
revision = subprocess.run(
|
|
73
|
+
["git", "rev-parse", "HEAD"],
|
|
74
|
+
cwd=root,
|
|
75
|
+
check=True,
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
).stdout.strip()
|
|
79
|
+
dirty = bool(
|
|
80
|
+
subprocess.run(
|
|
81
|
+
["git", "status", "--porcelain"],
|
|
82
|
+
cwd=root,
|
|
83
|
+
check=True,
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
).stdout.strip()
|
|
87
|
+
)
|
|
88
|
+
except (OSError, subprocess.CalledProcessError):
|
|
89
|
+
return None, None
|
|
90
|
+
return revision, dirty
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _gpu_name() -> str | None:
|
|
94
|
+
try:
|
|
95
|
+
properties = gf.get_backend("gpu").xp.cuda.runtime.getDeviceProperties(0)
|
|
96
|
+
except (ImportError, RuntimeError):
|
|
97
|
+
return None
|
|
98
|
+
name = properties.get("name", "unknown GPU")
|
|
99
|
+
return name.decode() if isinstance(name, bytes) else str(name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _cpu_model() -> str:
|
|
103
|
+
try:
|
|
104
|
+
for line in Path("/proc/cpuinfo").read_text(encoding="utf-8").splitlines():
|
|
105
|
+
if line.startswith("model name"):
|
|
106
|
+
return line.split(":", 1)[1].strip()
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
return platform.processor() or "unknown CPU"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _sync(device: str) -> None:
|
|
113
|
+
if device == "gpu":
|
|
114
|
+
gf.get_backend("gpu").xp.cuda.Stream.null.synchronize()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _measure(workflow: Workflow, device: str, *, seconds: float, warmup: int) -> dict[str, Any]:
|
|
118
|
+
backend = gf.get_backend(device)
|
|
119
|
+
config = gf.load_preset(workflow.preset).replace(resolution=workflow.shape)
|
|
120
|
+
camera = gf.Camera(config, seed=0, precision="float32", device=device)
|
|
121
|
+
photon_rate = backend.xp.full(
|
|
122
|
+
workflow.shape,
|
|
123
|
+
workflow.photon_rate_per_s,
|
|
124
|
+
dtype=backend.xp.float32,
|
|
125
|
+
)
|
|
126
|
+
for _ in range(warmup):
|
|
127
|
+
camera.expose(photon_rate, workflow.exposure_s)
|
|
128
|
+
_sync(device)
|
|
129
|
+
|
|
130
|
+
frames = 0
|
|
131
|
+
start = time.perf_counter()
|
|
132
|
+
while True:
|
|
133
|
+
camera.expose(photon_rate, workflow.exposure_s)
|
|
134
|
+
frames += 1
|
|
135
|
+
if time.perf_counter() - start >= seconds:
|
|
136
|
+
break
|
|
137
|
+
_sync(device)
|
|
138
|
+
elapsed = time.perf_counter() - start
|
|
139
|
+
return {
|
|
140
|
+
"workflow": workflow.key,
|
|
141
|
+
"label": workflow.label,
|
|
142
|
+
"preset": workflow.preset,
|
|
143
|
+
"sensor": config.sensor_type.value,
|
|
144
|
+
"shape": list(workflow.shape),
|
|
145
|
+
"precision": "float32",
|
|
146
|
+
"exposure_s": workflow.exposure_s,
|
|
147
|
+
"photon_rate_per_s": workflow.photon_rate_per_s,
|
|
148
|
+
"device": device,
|
|
149
|
+
"frames": frames,
|
|
150
|
+
"elapsed_s": elapsed,
|
|
151
|
+
"frame_s": elapsed / frames,
|
|
152
|
+
"frames_per_s": frames / elapsed,
|
|
153
|
+
"megapixels_per_s": frames * workflow.shape[0] * workflow.shape[1] / elapsed / 1e6,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv: list[str] | None = None) -> int:
|
|
158
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
159
|
+
parser.add_argument("--seconds", type=float, default=1.0, help="minimum seconds per cell")
|
|
160
|
+
parser.add_argument("--warmup", type=int, default=10, help="untimed frames per cell")
|
|
161
|
+
parser.add_argument("--device", choices=("cpu", "gpu", "both"), default="both")
|
|
162
|
+
parser.add_argument("--workflow", action="append", choices=[item.key for item in WORKFLOWS])
|
|
163
|
+
parser.add_argument("--output", type=Path, default=Path("benchmarks/device-results.json"))
|
|
164
|
+
args = parser.parse_args(argv)
|
|
165
|
+
if args.seconds <= 0:
|
|
166
|
+
parser.error("--seconds must be positive")
|
|
167
|
+
if args.warmup < 0:
|
|
168
|
+
parser.error("--warmup must be non-negative")
|
|
169
|
+
|
|
170
|
+
selected = [item for item in WORKFLOWS if not args.workflow or item.key in args.workflow]
|
|
171
|
+
devices = ("cpu", "gpu") if args.device == "both" else (args.device,)
|
|
172
|
+
root = Path(__file__).parents[1]
|
|
173
|
+
revision, source_dirty = _git_state(root)
|
|
174
|
+
report: dict[str, Any] = {
|
|
175
|
+
"schema_version": 1,
|
|
176
|
+
"generated_at_utc": datetime.now(UTC).isoformat(),
|
|
177
|
+
"command": shlex.join([sys.executable, *sys.argv]),
|
|
178
|
+
"revision": revision,
|
|
179
|
+
"source_dirty": source_dirty,
|
|
180
|
+
"python": sys.version,
|
|
181
|
+
"platform": platform.platform(),
|
|
182
|
+
"processor": platform.processor(),
|
|
183
|
+
"cpu": _cpu_model(),
|
|
184
|
+
"gpu": _gpu_name() if "gpu" in devices else None,
|
|
185
|
+
"dependencies": {
|
|
186
|
+
name: _installed_version(name) for name in ("getframes", "numpy", "scipy", "cupy")
|
|
187
|
+
},
|
|
188
|
+
"methodology": {
|
|
189
|
+
"seconds_per_cell": args.seconds,
|
|
190
|
+
"warmup_frames": args.warmup,
|
|
191
|
+
"persistent_camera": True,
|
|
192
|
+
"device_resident_rate_and_output": True,
|
|
193
|
+
"include_truth": True,
|
|
194
|
+
"rng": "one generator seeded at camera construction and advanced per frame",
|
|
195
|
+
"cuda_synchronization": "before and after each timed region",
|
|
196
|
+
"construction_included": False,
|
|
197
|
+
"host_transfers_included": False,
|
|
198
|
+
},
|
|
199
|
+
"results": [
|
|
200
|
+
_measure(item, device, seconds=args.seconds, warmup=args.warmup)
|
|
201
|
+
for item in selected
|
|
202
|
+
for device in devices
|
|
203
|
+
],
|
|
204
|
+
}
|
|
205
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
args.output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
207
|
+
print(json.dumps(report, indent=2))
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
raise SystemExit(main())
|