Detector characterisation¶
getframes.analysis.characterize runs the standard bench measurements on stacks
of frames. It takes plain arrays, so it works equally on frames from a real
detector and on frames from a simulated Camera —
and the result carries a to_config(), so a real camera can be measured, turned
into a CameraConfig, and then simulated.
frames -> stack_statistics per-pixel temporal mean and variance
-> characterize_dark gain, read noise, dark current, bias, DSNU
-> to_config a CameraConfig
-> Camera synthetic frames matching your detector
This is the complement to
photon_transfer_curve, which
drives a simulated camera to characterise it. Here the frames come first.
A runnable end-to-end version of everything below is
examples/15_detector_characterization.py.
Step 1 — reduce each stack¶
Everything is built from one quantity: for each pixel, its mean and variance
through a stack. stack_statistics computes both in a single streaming pass,
so an iterator over a stack far larger than memory is fine.
from getframes.analysis import stack_statistics
stats = stack_statistics(frames, exposure_s=2.0, split=True)
stats.mean_adu # (h, w) per-pixel temporal mean, ADU
stats.variance_adu2 # (h, w) per-pixel temporal variance, ADU^2
frames is any iterable of 2-D frames: NumPy arrays, Frame objects, a 3-D
cube, a Camera.dark_series(...) generator, or your own reader:
def read_raw(path, shape=(1200, 1200)):
"""Stream a flat little-endian uint16 file, one frame at a time."""
n_bytes = shape[0] * shape[1] * 2
with open(path, "rb") as handle:
while chunk := handle.read(n_bytes):
if len(chunk) < n_bytes:
return
yield np.frombuffer(chunk, dtype="<u2").reshape(shape)
stacks = {
exposure: stack_statistics(read_raw(path), exposure_s=exposure)
for exposure, path in my_files.items()
}
Step 2 — characterise from darks¶
from getframes.analysis import characterize_dark
result = characterize_dark(stacks) # {exposure_s: StackStats}
result.gain_e_per_adu # conversion gain
result.read_noise_e # median per-pixel read noise
result.dark_current_e_per_s # median dark current
result.bias_offset_adu
result.dark_current_nonuniformity # DSNU
result.read_noise_nonuniformity # log-normal width of the read-noise spread
result.read_noise_rts_fraction # pixels above 3x the median (the RTS tail)
result.read_noise_map_e # (h, w) -- the per-pixel maps behind the scalars
Use at least three exposures, and make the longest accumulate enough dark charge to stand clearly above the read noise. Make the shortest as short as the camera allows: the read noise is measured there.
Why darks are enough to measure gain¶
You do not need a flat field. Dark current is itself a Poisson process, so thermally generated charge is a perfectly good charge source for a photon transfer curve. For a dark frame,
$$\text{mean}\text{ADU}(t) = \text{bias} + \frac{Dt}{g}, \qquad \text{var}\text{ADU}(t) = \text{RN}_\text{ADU}^2 + \frac{Dt}{g^2}$$
so $\mathrm{d}\,\text{var}/\mathrm{d}\,\text{mean} = 1/g$ and the dark rate $D$ cancels completely.
The mean on its own is degenerate — it only ever tells you $D/g$, and doubling both leaves every frame identical. What breaks the degeneracy is that Poisson statistics fix the mean–variance relation in electrons with no free parameter, $\text{var}_e = \text{mean}_e$. Shot noise is an absolute ruler: $\text{SNR} = \text{mean}/\sqrt{\text{var}} = \sqrt{N}$ is dimensionless and invariant under rescaling, so it counts discrete charges whatever units you record them in.
characterize_dark fits this per pixel, which makes it immune to DSNU (each
pixel is its own regression), and fits a slope across exposures, which absorbs
the bias pedestal and the read noise into the two intercepts.
Check the Fano factor
The whole method assumes the dark charge is Poisson. result.fano_factor
reports $\text{var}_e/\text{mean}_e$ for the accumulated charge, which should
come out at 1. If it does not, the gain is not trustworthy — suspect a
non-Poisson noise source, a bias step between acquisition sessions, or
saturation.
Step 3 — rebuild the detector as a config¶
config = result.to_config(
"my camera",
pixel_size_um=11.0, # things darks cannot see: supply them
full_well_e=80_000.0,
bit_depth=16,
dark_current_ref_temp_c=-20.0, # the temperature the darks were taken at
)
twin = gf.Camera(config)
Everything darks can measure is filled in; the rest takes documented placeholders
you should override. dark_current_ref_temp_c matters most — stacks carry no
temperature, so without it the config's temperature scaling will be wrong.
Is your per-pixel noise real, or sampling scatter?¶
A variance map always looks structured, because estimating a variance from $n$
frames has its own $\chi^2$ scatter. split=True gives you the test that tells
them apart: split the stack in half, compute each half's per-pixel variance, and
correlate.
stats = stack_statistics(frames, split=True)
stats.temporal_repeatability # split-half correlation, 0 to 1
stats.fixed_variance_fraction # fraction of the map's spread that is real
A detector whose pixels genuinely differ — every sCMOS — gives a high correlation, because the same pixels are noisy in both halves. Uniform noise gives ~0. Real back-illuminated sCMOS measures 0.89–0.94.
The most extreme 1% of pixels are excluded before correlating, and on real data
that matters a great deal. A cosmic ray lands in one half only and inflates that
pixel's variance by orders of magnitude, so a handful of them dominate the
covariance: real 60 s Marana darks score 0.006 unclipped against 0.96
clipped. Use stats.repeatability(clip_percentile=100.0) if you want the plain
Pearson correlation.
Note this responds to any fixed per-pixel variance structure, not only read noise: at long exposures DSNU shows up here too, because a pixel with more dark current also carries more shot noise.
Adding flats¶
Flats measure what darks cannot: full well, PRNU and linearity.
from getframes.analysis import characterize_flat
flat = characterize_flat(flat_stacks, bias_adu=result.bias_offset_adu)
flat.gain_e_per_adu
flat.full_well_e # None if the curve never rolls over
flat.prnu
flat.nonlinearity
Sample from near zero up past saturation, and sample densely near the knee.
full_well_e comes from the variance peak, which marks the onset of
saturation and reads low by roughly the PRNU: the earliest-saturating pixels
start clipping before the array as a whole reaches its ceiling.
Because these are stacks, the variance used is the per-pixel temporal variance, which is already free of fixed-pattern noise — the usual trick of differencing flat pairs is unnecessary. PRNU is then measured separately, from the spatial spread of the time-averaged flat with its shot-noise contribution removed.
Accuracy¶
Against a simulated camera with known parameters (72×72, six exposures, 250
frames each — see tests/test_characterize.py):
| Parameter | Recovered to |
|---|---|
| Conversion gain | 3% |
| Bias offset | 0.1 ADU |
| Dark current | 5% |
| Read noise (median) | 5% |
| DSNU | 15% |
| Read-noise non-uniformity | 15% |
| PRNU (from flats) | 15% |
Accuracy improves with frame count as $1/\sqrt{n}$; the distribution widths need the most frames, because each pixel's own noise estimate has to be precise before its spread across pixels is meaningful.