"""Camera model catalogue.
Geometry, timing and bit depth come from DE-Server's camera headers
(``code/source/CameraServer/Camera/*.h``), ``Constants/Params.cpp``
(``FrameParams::UpdateHWFrameSize``) and ``configurations/server_*.xml``:
============= ========== ====== ===== ========== ===== =========== =====
model sensor pixel bits topology parts frame time sat.
============= ========== ====== ===== ========== ===== =========== =====
DESim 1024x1024 10 um 12 single 1 0.1 ms 2100
DE16 family 4096x4096 6.5 um 12 single 1 10.821 ms 2192
DE64/Vision64 8192x8192 6.5 um 12 left/right 2 21.58 ms 2600
Celeritas* 1024x1024 15 um 12 top/bottom 2 0.51 ms 4022
Apollo family 4096x4096 8 um 8 (hw counting) l/r 2 0.418 ms/cyc 240
============= ========== ====== ===== ========== ===== =========== =====
("sat." is DE-Server's ``Saturation - Value (ADU)`` property, the per-pixel
level at which DE-Server flags a pixel as saturated. The raw ADC clips at
``2**bit_depth - 1``.)
``adu_table`` is DE-Server's HT table (``configurations/ht_table_*.yaml``):
the *total* ADU a single primary electron deposits, summed over its charge
cloud (DE-Server's ``m_aduPerElectronBin1``). Because the charge spread
conserves the sum, the mean per-pixel signal of a flat beam is
``dose_e_per_px * adu_per_electron * gain``.
The remaining sensor-physics parameters are not in DE-Server; they are
documented, plausible defaults chosen so that dark/gain references matter:
* ``adu_cv``: relative spread of the per-electron deposit (Landau-like,
sampled as a gamma distribution with shape ``1/adu_cv**2``).
* ``charge_spread``: neighbour weight ``a`` of the separable 3-tap charge
sharing kernel ``[a, 1-2a, a]`` at 300 kV (MTF at Nyquist = ``(1-4a)`` per
axis). It grows slowly at lower HT.
* ``dark_offset_adu``: 360 ADU baseline, like GrabberSim's ``GenImage`` dark.
* fixed pattern: GrabberSim's ``(x%8)*(y%8)`` term plus its segment offsets
(+10 on even segments, +20 on segment 0, -20 on segment 1), plus random
per-column (``fpn_column_adu``) and per-pixel (``dsnu_adu``) offsets.
* gain non-uniformity: radial fall-off, a gradient across each stitch block
(``segment_shape[0]`` rows, like ``GenImage``), per-column and per-pixel
PRNU, normalised to mean 1.
* defects: hot pixels (large dark current), dead pixels (zero gain),
bad columns (low gain + offset step).
"""
from __future__ import annotations
import dataclasses
from dataclasses import dataclass, field
import numpy as np
# DE-Server HT tables (kV, ADU per primary electron).
ADU_TABLE_DE_SERIES = ((80.0, 1379.0), (120.0, 791.0), (200.0, 316.0), (300.0, 206.0))
ADU_TABLE_DE16ER = (
(40.0, 628.0),
(60.0, 374.0),
(80.0, 269.0),
(100.0, 205.0),
(120.0, 175.0),
(200.0, 129.0),
(300.0, 86.0),
)
ADU_TABLE_CELERITAS = ((80.0, 1148.0), (200.0, 301.0), (300.0, 208.0))
[docs]
@dataclass(frozen=True)
class CameraModel:
# --- identity / geometry (DE-Server facts)
name: str
de_model: str # DE-Server CameraModel enum name (Stringify), e.g. "DE16", "SIMULATOR"
sensor_shape: tuple[int, int] # (h, w) pixels
pixel_um: float
bit_depth: int
bytes_per_pixel: int
topology: str = "single" # single | leftright | topbottom
num_parts: int = 1
sensor_family: str = "ADELE" # ADELE | DESIREE | CASPAR2
hardware_counting: bool = False
full_frame_time_s: float = 0.010821 # fastest full-sensor frame (counting: one cycle)
hw_quanta: int = 1 # counting: sensor cycles summed per delivered frame (default)
saturation_adu: int = 2192 # DE-Server "Saturation - Value (ADU)"
segment_shape: tuple[int, int] = (2048, 256) # (m_segmentHeight, m_segmentWidth)
supports_hw_roi_x: bool = False
# --- sensor physics (defaults, see module docstring)
adu_table: tuple[tuple[float, float], ...] = ADU_TABLE_DE_SERIES
adu_cv: float = 0.7
charge_spread: float = 0.20
read_noise_adu: float = 9.0
dark_offset_adu: float = 360.0
dark_current_adu_per_s: float = 5.0
dsnu_adu: float = 3.0
fpn_column_adu: float = 4.0
fpn_pattern: bool = True # GrabberSim-style (x%8)(y%8) + segment offsets
gain_pixel_sigma: float = 0.015
gain_column_sigma: float = 0.005
gain_radial: float = 0.04 # relative fall-off at the corners
gain_stitch: float = 0.02 # gradient across a stitch block
hot_pixel_fraction: float = 2e-5
hot_dark_current_adu_per_s: tuple[float, float] = (2e4, 2e5)
dead_pixel_fraction: float = 1e-5
bad_column_fraction: float = 5e-4
# counting cameras
cluster_area_px: float = 2.5 # coincidence area of one event
false_event_rate: float = 2e-6 # per pixel per sensor cycle
reference_ht_kv: float = 300.0
aliases: tuple[str, ...] = field(default=(), compare=False)
# ------------------------------------------------------------- derived
@property
def sensor_w(self) -> int:
return self.sensor_shape[1]
@property
def sensor_h(self) -> int:
return self.sensor_shape[0]
@property
def max_value(self) -> int:
return (1 << self.bit_depth) - 1
@property
def dtype(self):
return np.uint8 if self.bytes_per_pixel == 1 else np.uint16
@property
def max_fps(self) -> float:
"""Fastest full-sensor frame rate (counting cameras: delivered frames at hw_quanta)."""
return 1.0 / (self.full_frame_time_s * max(1, self.hw_quanta))
@property
def adu_per_electron(self) -> float:
return self.adu_per_electron_at(self.reference_ht_kv)
[docs]
def adu_per_electron_at(self, ht_kv: float) -> float:
"""Total ADU per primary electron at ``ht_kv`` (log-log interpolation of the HT table)."""
if self.hardware_counting:
return 1.0
kv = np.array([p[0] for p in self.adu_table])
adu = np.array([p[1] for p in self.adu_table])
ht = float(np.clip(ht_kv, 20.0, 1000.0))
if kv[0] <= ht <= kv[-1]:
return float(np.exp(np.interp(np.log(ht), np.log(kv), np.log(adu))))
return _loglog_extrapolate(kv, adu, ht)
[docs]
def charge_spread_at(self, ht_kv: float) -> float:
"""Neighbour weight of the 3-tap charge kernel; mildly larger at low HT (heuristic)."""
a = self.charge_spread * (self.reference_ht_kv / max(float(ht_kv), 20.0)) ** 0.3
return float(min(a, 0.25))
[docs]
def with_(self, **changes) -> CameraModel:
return dataclasses.replace(self, **changes)
def _loglog_extrapolate(kv, adu, ht):
i = (0, 1) if ht < kv[0] else (-2, -1)
lx, ly = np.log(kv[list(i)]), np.log(adu[list(i)])
slope = (ly[1] - ly[0]) / (lx[1] - lx[0])
return float(np.exp(ly[0] + slope * (np.log(ht) - lx[0])))
def _adele(name, de_model, *, sat=2192, aliases=(), **overrides) -> CameraModel:
base = {
"sensor_shape": (4096, 4096),
"pixel_um": 6.5,
"bit_depth": 12,
"bytes_per_pixel": 2,
"sensor_family": "ADELE",
"full_frame_time_s": 10.821e-3,
"saturation_adu": sat,
"supports_hw_roi_x": True,
}
base.update(overrides)
return CameraModel(name=name, de_model=de_model, aliases=aliases, **base)
def _desiree(name, de_model, *, sat, aliases=()) -> CameraModel:
return CameraModel(
name=name,
de_model=de_model,
sensor_shape=(1024, 1024),
pixel_um=15.0,
bit_depth=12,
bytes_per_pixel=2,
topology="topbottom",
num_parts=2,
sensor_family="DESIREE",
full_frame_time_s=0.51e-3,
saturation_adu=sat,
segment_shape=(512, 128),
adu_table=ADU_TABLE_CELERITAS,
charge_spread=0.12, # 15 um pixels: less charge sharing per pixel
read_noise_adu=12.0,
aliases=aliases,
)
def _caspar(name, de_model, aliases=()) -> CameraModel:
return CameraModel(
name=name,
de_model=de_model,
sensor_shape=(4096, 4096),
pixel_um=8.0,
bit_depth=8,
bytes_per_pixel=1,
topology="leftright",
num_parts=2,
sensor_family="CASPAR2",
hardware_counting=True,
full_frame_time_s=0.418e-3,
hw_quanta=40,
saturation_adu=240,
segment_shape=(2048, 1024),
read_noise_adu=0.0,
dark_offset_adu=0.0,
dark_current_adu_per_s=0.0,
dsnu_adu=0.0,
fpn_column_adu=0.0,
fpn_pattern=False,
gain_pixel_sigma=0.01,
gain_column_sigma=0.003,
gain_radial=0.02,
gain_stitch=0.01,
hot_dark_current_adu_per_s=(50.0, 500.0), # hot pixels: spurious counts per second
aliases=aliases,
)
_MODELS = [
_adele(
"DESim",
"SIMULATOR",
sat=2100,
sensor_shape=(1024, 1024),
pixel_um=10.0,
full_frame_time_s=0.1e-3,
segment_shape=(256, 32),
supports_hw_roi_x=False,
aliases=("SIMULATOR", "Simulator", "DE-Sim"),
),
_adele("DE16", "DE16", sat=2192, aliases=("DE-16",)),
_adele("DE16ER", "DE16", sat=2100, adu_table=ADU_TABLE_DE16ER, aliases=("DE-16ER",)),
_adele("LV16", "LV16", sat=2100, aliases=("DE16LV", "LV-16")),
_adele("Vision16", "VISION16", sat=2192, aliases=("VISION16",)),
_adele("DirectView2", "DIRECTVIEW2", sat=2100, aliases=("DE-DirectView2", "DIRECTVIEW2")),
_adele("SEMCam", "SEMCAM", sat=2192, aliases=("DESEMCam", "SEMCAM")),
_adele("Meridian", "MERIDIAN", sat=2192, aliases=("MERIDIAN",)),
_adele(
"DE64",
"DE64",
sat=2600,
sensor_shape=(8192, 8192),
topology="leftright",
num_parts=2,
full_frame_time_s=21.58e-3,
aliases=("DE-64",),
),
_adele(
"Vision64",
"VISION64",
sat=2600,
sensor_shape=(8192, 8192),
topology="leftright",
num_parts=2,
full_frame_time_s=21.58e-3,
aliases=("VISION64",),
),
_desiree("Celeritas", "CELERITAS", sat=4022, aliases=("CELERITAS",)),
_desiree("CeleritasXS", "CELERITASXS", sat=4022, aliases=("CELERITASXS",)),
_desiree("Zenith", "ZENITH", sat=4061, aliases=("ZENITH",)),
_caspar("Apollo", "APOLLO", aliases=("APOLLO",)),
_caspar("ApolloXS", "APOLLOXS", aliases=("APOLLOXS",)),
_caspar("Artemis", "ARTEMIS", aliases=("ARTEMIS",)),
_caspar("Centuri", "CENTURI", aliases=("CENTURI",)),
_caspar("CenturiPlus", "CENTURIPLUS", aliases=("CENTURIPLUS", "Centuri Plus")),
]
CAMERAS: dict[str, CameraModel] = {m.name: m for m in _MODELS}
_LOOKUP: dict[str, CameraModel] = {}
for _m in _MODELS:
for _key in (_m.name, *_m.aliases):
_LOOKUP.setdefault(_key.lower().replace("-", "").replace(" ", ""), _m)
[docs]
def camera(name: str | CameraModel) -> CameraModel:
"""Look up a camera model by name, alias or DE-Server enum name (case-insensitive)."""
if isinstance(name, CameraModel):
return name
key = str(name).strip().lower().replace("-", "").replace(" ", "")
try:
return _LOOKUP[key]
except KeyError:
raise KeyError(f"unknown camera model {name!r}; known: {sorted(CAMERAS)}") from None