getframes 2.0.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.
Files changed (101) hide show
  1. {getframes-2.0.0 → getframes-2.1.1}/.gitignore +2 -0
  2. {getframes-2.0.0 → getframes-2.1.1}/CHANGELOG.md +104 -3
  3. {getframes-2.0.0 → getframes-2.1.1}/PKG-INFO +65 -12
  4. {getframes-2.0.0 → getframes-2.1.1}/README.md +62 -11
  5. getframes-2.1.1/benchmarks/__init__.py +1 -0
  6. getframes-2.1.1/benchmarks/bench_devices.py +212 -0
  7. getframes-2.1.1/benchmarks/device-results.json +221 -0
  8. getframes-2.1.1/benchmarks/device-results.md +17 -0
  9. getframes-2.1.1/benchmarks/render_device_table.py +77 -0
  10. {getframes-2.0.0 → getframes-2.1.1}/benchmarks/run.py +32 -10
  11. getframes-2.1.1/examples/14_keck_lgs_ttf_trade_study.ipynb +1867 -0
  12. {getframes-2.0.0 → getframes-2.1.1}/examples/README.md +1 -0
  13. {getframes-2.0.0 → getframes-2.1.1}/pyproject.toml +4 -1
  14. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/__about__.py +1 -1
  15. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/__init__.py +5 -0
  16. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/analysis/__init__.py +3 -2
  17. getframes-2.1.1/src/getframes/analysis/apertures.py +180 -0
  18. getframes-2.1.1/src/getframes/backend.py +150 -0
  19. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/camera.py +168 -12
  20. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/config.py +116 -1
  21. getframes-2.1.1/src/getframes/frame.py +171 -0
  22. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/noise.py +409 -127
  23. getframes-2.1.1/src/getframes/presets/data/andor_cb1_0_5mp.toml +34 -0
  24. getframes-2.1.1/src/getframes/presets/data/andor_marana_4_2b_11.toml +38 -0
  25. getframes-2.1.1/src/getframes/presets/data/andor_ocam2k.toml +50 -0
  26. getframes-2.1.1/src/getframes/presets/data/hamamatsu_orca_quest_2.toml +47 -0
  27. getframes-2.1.1/src/getframes/presets/data/nuvu_hnu_128_omega.toml +29 -0
  28. getframes-2.1.1/src/getframes/presets/data/nuvu_hnu_240.toml +33 -0
  29. getframes-2.1.1/src/getframes/presets/data/photometrics_prime_95b.toml +54 -0
  30. getframes-2.1.1/src/getframes/presets/data/princeton_instruments_kuro_1200b.toml +55 -0
  31. getframes-2.1.1/src/getframes/presets/data/qhy530_pro_ii.toml +49 -0
  32. getframes-2.1.1/src/getframes/presets/data/scimeasure_little_joe_ccd39.toml +50 -0
  33. getframes-2.1.1/src/getframes/presets/data/tucsen_aries_6504_pro.toml +78 -0
  34. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/scene/photometry.py +1 -1
  35. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/scene/psf.py +1 -1
  36. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/scene/thermal.py +2 -2
  37. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/spectral.py +4 -4
  38. getframes-2.1.1/tests/test_analysis.py +112 -0
  39. getframes-2.1.1/tests/test_benchmarks.py +25 -0
  40. {getframes-2.0.0 → getframes-2.1.1}/tests/test_calibrate.py +9 -0
  41. {getframes-2.0.0 → getframes-2.1.1}/tests/test_config.py +47 -1
  42. {getframes-2.0.0 → getframes-2.1.1}/tests/test_detector.py +105 -0
  43. getframes-2.1.1/tests/test_frame.py +46 -0
  44. {getframes-2.0.0 → getframes-2.1.1}/tests/test_gain.py +38 -0
  45. getframes-2.1.1/tests/test_gpu.py +169 -0
  46. getframes-2.1.1/tests/test_presets.py +133 -0
  47. {getframes-2.0.0 → getframes-2.1.1}/tests/test_signal.py +26 -0
  48. {getframes-2.0.0 → getframes-2.1.1}/tests/test_spectral.py +15 -0
  49. getframes-2.0.0/src/getframes/analysis/apertures.py +0 -92
  50. getframes-2.0.0/src/getframes/frame.py +0 -107
  51. getframes-2.0.0/tests/test_analysis.py +0 -56
  52. getframes-2.0.0/tests/test_presets.py +0 -37
  53. {getframes-2.0.0 → getframes-2.1.1}/LICENSE +0 -0
  54. {getframes-2.0.0 → getframes-2.1.1}/examples/01_basic_dark_frame.py +0 -0
  55. {getframes-2.0.0 → getframes-2.1.1}/examples/02_custom_camera.py +0 -0
  56. {getframes-2.0.0 → getframes-2.1.1}/examples/03_master_dark.py +0 -0
  57. {getframes-2.0.0 → getframes-2.1.1}/examples/04_browse_presets.py +0 -0
  58. {getframes-2.0.0 → getframes-2.1.1}/examples/05_visualise.py +0 -0
  59. {getframes-2.0.0 → getframes-2.1.1}/examples/06_photon_transfer_curve.py +0 -0
  60. {getframes-2.0.0 → getframes-2.1.1}/examples/07_star_field_exposure.py +0 -0
  61. {getframes-2.0.0 → getframes-2.1.1}/examples/08_ao_limiting_magnitude.py +0 -0
  62. {getframes-2.0.0 → getframes-2.1.1}/examples/09_transit_photometry.py +0 -0
  63. {getframes-2.0.0 → getframes-2.1.1}/examples/10_detector_realism.py +0 -0
  64. {getframes-2.0.0 → getframes-2.1.1}/examples/11_radiometry_and_ir.py +0 -0
  65. {getframes-2.0.0 → getframes-2.1.1}/examples/12_ml_dataset.py +0 -0
  66. {getframes-2.0.0 → getframes-2.1.1}/examples/13_crowded_field.py +0 -0
  67. {getframes-2.0.0 → getframes-2.1.1}/examples/_common.py +0 -0
  68. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/analysis/ptc.py +0 -0
  69. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/calibrate.py +0 -0
  70. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/cli.py +0 -0
  71. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/dataset.py +0 -0
  72. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/observation.py +0 -0
  73. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/__init__.py +0 -0
  74. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/data/__init__.py +0 -0
  75. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/data/andor_ikon_m934.toml +0 -0
  76. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/data/andor_ixon_ultra_888.toml +0 -0
  77. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/data/generic_ccd.toml +0 -0
  78. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/data/generic_cmos.toml +0 -0
  79. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/data/generic_eapd.toml +0 -0
  80. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/data/generic_emccd.toml +0 -0
  81. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/data/generic_scmos.toml +0 -0
  82. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/data/hamamatsu_orca_fusion.toml +0 -0
  83. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/data/leonardo_saphira.toml +0 -0
  84. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/presets/data/zwo_asi2600mm.toml +0 -0
  85. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/py.typed +0 -0
  86. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/scene/__init__.py +0 -0
  87. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/scene/optics.py +0 -0
  88. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/scene/scene.py +0 -0
  89. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/scene/sources.py +0 -0
  90. {getframes-2.0.0 → getframes-2.1.1}/src/getframes/scene/wcs.py +0 -0
  91. {getframes-2.0.0 → getframes-2.1.1}/tests/test_camera.py +0 -0
  92. {getframes-2.0.0 → getframes-2.1.1}/tests/test_cli.py +0 -0
  93. {getframes-2.0.0 → getframes-2.1.1}/tests/test_dataset.py +0 -0
  94. {getframes-2.0.0 → getframes-2.1.1}/tests/test_noise.py +0 -0
  95. {getframes-2.0.0 → getframes-2.1.1}/tests/test_observation.py +0 -0
  96. {getframes-2.0.0 → getframes-2.1.1}/tests/test_radiometry.py +0 -0
  97. {getframes-2.0.0 → getframes-2.1.1}/tests/test_realism.py +0 -0
  98. {getframes-2.0.0 → getframes-2.1.1}/tests/test_scale.py +0 -0
  99. {getframes-2.0.0 → getframes-2.1.1}/tests/test_scene.py +0 -0
  100. {getframes-2.0.0 → getframes-2.1.1}/tests/test_scene_enrich.py +0 -0
  101. {getframes-2.0.0 → getframes-2.1.1}/tests/test_validation.py +0 -0
@@ -222,3 +222,5 @@ __marimo__/
222
222
 
223
223
  # Streamlit
224
224
  .streamlit/secrets.toml
225
+ keck_ttf
226
+ *.png
@@ -6,6 +6,104 @@ 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
+
55
+ ## [2.1.0] - 2026-07-19
56
+
57
+ ### Added
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.
62
+ - Five scientific-camera presets used by the Keck tip/tilt and low-bandwidth WFS
63
+ detector trade example: KURO 1200B, Prime 95B, Marana 4.2B-11, QHY530 Pro II,
64
+ and Aries 6504 Pro. All five now include a wavelength-resolved `qe_curve`:
65
+ Marana and Aries from Andor's/Gpixel's published data (the Aries
66
+ `QE x fill factor` trace is digitized from Gpixel's graph); KURO 1200B,
67
+ Prime 95B, and QHY530 Pro II are digitized from published manufacturer QE
68
+ plots.
69
+ - A `quantum_efficiency` override on `Camera.expose_series`, matching
70
+ `Camera.expose` and supporting pre-integrated spectral electron-rate series.
71
+ - A Keck/SciMeasure Little Joe CCD39 preset with the manufacturer CCD39-01
72
+ standard-AR/no-window spectral-response curve.
73
+ - `getframes.analysis.matched_filter_centroid`, a background-insensitive
74
+ cross-correlation centroid with sub-pixel peak refinement.
75
+ - `Frame.binned(factor, method=...)`, post-read digital binning of a frame into
76
+ `factor x factor` super-pixels (sum or mean).
77
+ - `CameraConfig.supported_binnings` and `CameraConfig.binning_method` — binning is
78
+ now a first-class config parameter. The Keck-trade presets set these directly and
79
+ drop the `extra.detector_modes` per-binning tables; cameras with more than one
80
+ read-noise operating point keep a slim `extra.read_modes` list instead.
81
+ - Native pixel binning in the signal chain: `Camera.expose`, `Camera.expose_series`,
82
+ and `noise.simulate_frame` accept `binning` and `binning_mode`. `"digital"`
83
+ (post-read, the default) reads each native pixel with its own read noise then sums,
84
+ so binned read noise grows as `binning`; `"on_chip"` (pre-read charge-domain /
85
+ hardware binning) sums the charge before the amplifier, so a single read noise is
86
+ applied per super-pixel. Exposed `noise.block_sum` for the super-pixel summation.
87
+ - `getframes.analysis.centroid` now accepts a per-pixel array `background` (e.g. a
88
+ master sky+dark frame) and an optional `threshold` (scalar or per-pixel noise
89
+ map), making it a calibrated thresholded centre-of-gravity estimator suitable
90
+ for a real-time controller. Both additions are backwards compatible.
91
+ - A Keck LGS tip/tilt and low-bandwidth wavefront-sensor detector trade-study
92
+ example, including physically sampled AO/sub-aperture PSFs, complete detector
93
+ Monte Carlo simulations, blackbody NGS weighting of each camera's QE curve
94
+ (`--ngs-teff`), the 600–950 nm TTS/LBWFS arm bandpass folded into the zero-point
95
+ bands, Monte Carlo standard errors on every point, a per-camera closed-loop
96
+ cadence optimisation (a physical `a·f + b·f²` noise model fitted to the Monte
97
+ Carlo data, saturation-aware, against a Tyler tilt-lag disturbance model), and
98
+ a best/worst-performer frame gallery figure beside the trade curves.
99
+
100
+ ### Fixed
101
+
102
+ - Type-checking compatibility with newer NumPy (2.2): tightened stub types for
103
+ `_stamp_bounds`/`_radial_grid` shape arguments, pinned `float64` on a few
104
+ `np.linspace` constructions, and dropped now-unused `type: ignore` comments
105
+ around the `trapz`/`trapezoid` shim. No behavioural change.
106
+
9
107
  ## [2.0.0] - 2026-06-29
10
108
 
11
109
  The 2.0 cut promotes the full surface grown across 1.1–1.6 to **stable** and lands
@@ -248,6 +346,9 @@ together in 1.0.
248
346
  - Documentation, runnable examples, and CI (lint, type-check, test matrix, PyPI
249
347
  release via Trusted Publishing).
250
348
 
251
- [Unreleased]: https://github.com/jacotay7/getframes/compare/v1.0.0...HEAD
252
- [1.0.0]: https://github.com/jacotay7/getframes/compare/v0.1.0...v1.0.0
253
- [0.1.0]: https://github.com/jacotay7/getframes/releases/tag/v0.1.0
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
351
+ [2.1.0]: https://github.com/jacotay7/getframes/compare/2.0.0...2.1.0
352
+ [2.0.0]: https://github.com/jacotay7/getframes/compare/1.0.0...2.0.0
353
+ [1.0.0]: https://github.com/jacotay7/getframes/compare/0.1.0...1.0.0
354
+ [0.1.0]: https://github.com/jacotay7/getframes/releases/tag/0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: getframes
3
- Version: 2.0.0
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 # 2-D numpy array of ADU, shape (1024, 1024)
96
- frame.stats() # {'mean': ..., 'median': ..., 'std': ..., 'min': ..., 'max': ...}
97
- frame.metadata # camera/exposure/temperature provenance
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), # (height, width)
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, # at the reference temperature
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(aperture_diameter_m=2.5, throughput=0.3,
139
- plate_scale_arcsec_per_pixel=0.4, band=gf.Bandpass.johnson("V")),
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) # a realistic science frame
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() # ['andor_ikon_m934', 'andor_ixon_ultra_888', 'generic_ccd', ...]
158
- preset_info() # rich descriptors for each preset
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 |
@@ -165,6 +213,11 @@ preset_info() # rich descriptors for each preset
165
213
  | `leonardo_saphira` | EAPD | HgCdTe avalanche IR array (AO wavefront sensing) |
166
214
  | `zwo_asi2600mm` | CMOS | Sony IMX571 cooled CMOS |
167
215
  | `hamamatsu_orca_fusion` | sCMOS | Back-thinned sCMOS with per-pixel read noise |
216
+ | `andor_marana_4_2b_11` | sCMOS | 11 µm-pixel, back-illuminated scientific CMOS |
217
+ | `photometrics_prime_95b` | sCMOS | 11 µm-pixel, 95% peak-QE scientific CMOS |
218
+ | `princeton_instruments_kuro_1200b` | sCMOS | Back-illuminated scientific CMOS |
219
+ | `qhy530_pro_ii` | CMOS | Global-shutter Sony IMX530 scientific camera |
220
+ | `tucsen_aries_6504_pro` | sCMOS | Sub-electron sensitive-mode read noise |
168
221
  | `generic_ccd` / `generic_cmos` / `generic_emccd` / `generic_eapd` / `generic_scmos` | — | Idealised references for teaching/testing |
169
222
 
170
223
  ## How the dark-frame model works
@@ -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 # 2-D numpy array of ADU, shape (1024, 1024)
52
- frame.stats() # {'mean': ..., 'median': ..., 'std': ..., 'min': ..., 'max': ...}
53
- frame.metadata # camera/exposure/temperature provenance
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), # (height, width)
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, # at the reference temperature
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(aperture_diameter_m=2.5, throughput=0.3,
95
- plate_scale_arcsec_per_pixel=0.4, band=gf.Bandpass.johnson("V")),
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) # a realistic science frame
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() # ['andor_ikon_m934', 'andor_ixon_ultra_888', 'generic_ccd', ...]
114
- preset_info() # rich descriptors for each preset
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 |
@@ -121,6 +167,11 @@ preset_info() # rich descriptors for each preset
121
167
  | `leonardo_saphira` | EAPD | HgCdTe avalanche IR array (AO wavefront sensing) |
122
168
  | `zwo_asi2600mm` | CMOS | Sony IMX571 cooled CMOS |
123
169
  | `hamamatsu_orca_fusion` | sCMOS | Back-thinned sCMOS with per-pixel read noise |
170
+ | `andor_marana_4_2b_11` | sCMOS | 11 µm-pixel, back-illuminated scientific CMOS |
171
+ | `photometrics_prime_95b` | sCMOS | 11 µm-pixel, 95% peak-QE scientific CMOS |
172
+ | `princeton_instruments_kuro_1200b` | sCMOS | Back-illuminated scientific CMOS |
173
+ | `qhy530_pro_ii` | CMOS | Global-shutter Sony IMX530 scientific camera |
174
+ | `tucsen_aries_6504_pro` | sCMOS | Sub-electron sensitive-mode read noise |
124
175
  | `generic_ccd` / `generic_cmos` / `generic_emccd` / `generic_eapd` / `generic_scmos` | — | Idealised references for teaching/testing |
125
176
 
126
177
  ## How the dark-frame model works
@@ -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())