API reference¶
This page documents the implemented public modules. Source code is not embedded; every module's opening docstring states its physical/mathematical basis, parameter ranges, and the failure artefacts it guards against.
Scope
camsim.camera, camsim.io, and the render, characterize, measure,
and figures CLI commands are part of the public pipeline and are exercised
by the validation suite. A module that appears here is importable, tested,
and used by the validation checks.
Top-level package¶
camsim exposes its public types lazily (PEP 562), so importing the package
loads only version metadata. The six public names below are registered in
_LAZY_EXPORTS.
camsim: deterministic camera sensor and ISP simulation with IQ metrics.
The public namespace is deliberately lazy (PEP 562). Importing camsim loads
only version metadata; public types are resolved on first attribute access.
This keeps import camsim.metrics free of the sensor, ISP, scene, and optics
packages, which the metrics harness is required not to depend on.
Later phases register public names in _LAZY_EXPORTS as
name -> (module path, attribute name) pairs.
Camera ¶
Camera(lens: LensConfig, sensor: Sensor, isp: ISP)
A lens, a sensor, and an ISP with fixed and auto exposure policies.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lens
|
LensConfig
|
The :class: |
required |
sensor
|
Sensor
|
The :class: |
required |
isp
|
ISP
|
The :class: |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If an argument is not an instance of its declared type. |
Attributes:
| Name | Type | Description |
|---|---|---|
lens, sensor, isp |
The three layers, stored publicly so an experiment can reconfigure the sensor seed or inspect the ISP preset. The camera holds references, not copies; mutating a configuration is visible to later captures, exactly as with the individual layers. |
Notes
A camera owns no random state. Sensor.expose(seed=...) controls
reproducibility for both policies; auto-exposure uses deterministic probe
seeds and a default capture draws from the sensor's frame stream.
auto_expose ¶
auto_expose(
scene: SceneResult,
target_fraction: float = _DEFAULT_TARGET_FRACTION,
max_iterations: int = _DEFAULT_MAX_ITERATIONS,
tolerance: float = _DEFAULT_TOLERANCE,
) -> ExposureResult
Find settings that place the scene near target_fraction.
The policy starts from the sensor preset's default exposure and analog
gain, resolves the readout mode on the first probe, and then adjusts
exposure, analog gain, and digital gain in that order. Each correction
is damped by raising target / metered to the power 0.7, so a large
error is approached geometrically rather than overshooting in one step.
A probe with more than 1 % of pixels at the earlier of the full-well
and ADC saturation codes caps the correction at 0.5: a clipped
statistic must never request more light.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scene
|
SceneResult
|
A :class: |
required |
target_fraction
|
float
|
Desired metered fraction of the usable code span, strictly between zero and one. 0.18 is photographic 18 % grey. |
_DEFAULT_TARGET_FRACTION
|
max_iterations
|
int
|
Maximum number of probe frames, an integer of at least one. The probe budget bounds run time and makes non-convergence finite. |
_DEFAULT_MAX_ITERATIONS
|
tolerance
|
float
|
Absolute metering tolerance as a fraction of full scale, finite and
strictly positive. Convergence requires
|
_DEFAULT_TOLERANCE
|
Returns:
| Type | Description |
|---|---|
ExposureResult
|
The settings of the final probe, whether it converged, the number of probes, and the final metered value. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the scene is not a |
Notes
Probe i is exposed with seed=i. A per-call seed does not advance
the sensor's frame stream, so calling auto_expose twice on the same
camera and scene is bit-identical and does not perturb a later
capture. The returned settings are exactly the ones that produced
metered_value; the policy does not report an unprobed proposal.
capture ¶
capture(
scene: SceneResult,
exposure: ExposureSettings
| ExposureResult
| None = None,
) -> RenderedImage
Expose one scene and render it through the ISP.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scene
|
SceneResult
|
A :class: |
required |
exposure
|
ExposureSettings | ExposureResult | None
|
|
None
|
Returns:
| Type | Description |
|---|---|
RenderedImage
|
A new image whose pixel buffers are the ISP's output and whose
metadata is a copy of the ISP metadata plus the exposure policy,
the applied exposure and gains, the readout mode, the lens focal
length and f-number, and, for auto exposure, the result's metered
value, iteration count, and convergence flag. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Notes
The exposure is drawn from the sensor's frame stream (seed=None),
so consecutive captures are independent and the raw frame records its
derived seed and frame index for later auditing. Reseed the sensor with
:meth:~camsim.sensor.Sensor.reseed to replay a capture bit-for-bit in
one environment. The input scene and the intermediate ISP result are
never mutated; a fresh metadata dictionary is attached to the returned
image.
ISP ¶
ISP(config: ISPConfig)
The enforced RAW-to-rendered-image pipeline for one configuration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
ISPConfig
|
A validated :class: |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Notes
The configuration is captured by reference and the provenance hash is
recomputed from its current state on every process call, so a mutation
before processing cannot leave a stale hash. Individual stage functions
re-validate mutated config values they consume, so an accidental mutation
fails loudly rather than running with an out-of-domain parameter.
config_hash
property
¶
config_hash: str
SHA-256 of the canonical JSON form of the current configuration.
The hash identifies the parameters that produced a rendered image and
is copied into RenderedImage.metadata["isp_config_hash"]. It is
computed on demand from the configuration's current state, so a
mutation after construction cannot leave a stale provenance value.
from_json
classmethod
¶
from_json(path: str | Path) -> Self
Build a pipeline from a JSON ISP preset on disk.
The file is read and validated through the production loader
(:func:camsim.config.io.load_isp), so a pipeline built from a preset
follows exactly the same validation path as one built programmatically.
Filesystem, JSON-syntax, and schema errors propagate unchanged.
process ¶
process(
raw: RawFrame, trace: bool = False
) -> RenderedImage
Run the configured ISP stages on one RAW frame.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
RawFrame
|
A self-describing :class: |
required |
trace
|
bool
|
When true, record a :class: |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
class:`~camsim.core.frames.RenderedImage` whose ``data`` is float32
|
|
in ``[0, 1]`` with shape ``(H, W, 3)`` for Bayer/RCCC output and
|
|
|
``(H, W, 1)`` for mono. BLC subtracts the pedestal and normalises the
|
|
|
result by the white span, so the linear stages run in ``[0, 1]`` units.
|
|
|
``linear_rgb`` is the matching pre-clamp, pre-OETF linear buffer
|
|
|
(unclipped), and ``metadata`` carries the ISP name and hash, the
|
|
|
sensor configuration hash, the colour space, channel layout, container
|
|
|
bit depth, exposure, pixel pitch, and the white-balance gains and
|
|
|
method (or ``None`` when AWB was skipped).
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Sensor ¶
Sensor(config: SensorConfig, seed: int = 0)
A deterministic sensor that converts spectral irradiance into RAW frames.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
SensorConfig
|
Validated :class: |
required |
seed
|
int
|
Non-negative seed of the frame stream used when |
0
|
Attributes:
| Name | Type | Description |
|---|---|---|
config |
The configuration object supplied to the constructor. |
|
pattern_seed |
The single seed used for DSNU/PRNU, defect, and PDAF maps, taken from
|
|
The first successful exposure fixes the native sensor grid (the cube's |
|
|
spatial shape). A later cube with another shape is rejected, because a |
|
|
real part has one pixel array and implicit resampling would silently |
|
|
change the radiometry. Fixed-pattern maps are generated lazily for that |
|
|
shape on first use and cached for the life of the sensor. |
|
frame_shape
property
¶
frame_shape: tuple[int, int] | None
Native (height, width) fixed by the first exposure, or None.
reseed ¶
reseed(seed: int) -> None
Reset the frame stream and capture index.
Fixed-pattern maps are unaffected because they derive from
config.defects.seed, not from the frame stream.
expose ¶
expose(
irradiance: SpectralIrradiance,
exposure_s: float,
mode: str = "auto",
analog_gain_db: float | None = None,
digital_gain: float = 1.0,
temperature_c: float | None = None,
seed: int | None = None,
*,
max_memory_bytes: int | None = None,
) -> RawFrame
Integrate one exposure and return a RAW frame.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
irradiance
|
SpectralIrradiance
|
Spectral irradiance at the sensor plane, before the sensor's own
IR-cut and CFA filters, on the sensor's pixel grid. Both
|
required |
exposure_s
|
float
|
Integration time in seconds, within the preset's exposure limits. |
required |
mode
|
str
|
|
'auto'
|
analog_gain_db
|
float | None
|
Programmable analog gain in decibels. |
None
|
digital_gain
|
float
|
Additional digital multiplier composed with the ADC preset's
|
1.0
|
temperature_c
|
float | None
|
Sensor temperature override in degrees Celsius. |
None
|
seed
|
int | None
|
Per-call frame seed. Supplying it does not advance the sensor's
frame stream, so the pixel data are independent of call order;
|
None
|
max_memory_bytes
|
int | None
|
Optional peak-memory budget for this exposure, in bytes. |
None
|
Returns:
| Type | Description |
|---|---|
RawFrame
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If any input is out of range, the cube is not on the sensor grid (shape, pitch, or wavelength grid), the binning factor does not divide the shape, the crosstalk matrix is incompatible with the CFA, a noise/gain parameter is non-finite, or the estimated peak memory exceeds the configured simulation budget. |
SpectralIrradiance
dataclass
¶
SpectralIrradiance(
data: ArrayLike,
wavelengths_nm: WavelengthGrid | ArrayLike,
unit: SpectralUnit,
pixel_pitch_um: float | None = None,
)
A validated spectral irradiance cube on a uniform wavelength grid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
ArrayLike
|
Array-like of shape |
required |
wavelengths_nm
|
WavelengthGrid | ArrayLike
|
A :class: |
required |
unit
|
SpectralUnit
|
|
required |
pixel_pitch_um
|
float | None
|
Optional square-pixel pitch in micrometres. Positive when present; used by the sensor to reject cubes rendered for a different pixel grid. |
None
|
Notes
The cube describes irradiance at the sensor plane before the sensor's own IR-cut and CFA filters, so those filters are applied exactly once. Values are per-nanometre densities; this class does not integrate over wavelength.
from_array
classmethod
¶
from_array(
data: ArrayLike,
wavelengths_nm: WavelengthGrid | ArrayLike,
unit: SpectralUnit,
pixel_pitch_um: float | None = None,
) -> Self
Construct from array-like data and wavelengths.
This is the ergonomic entry point for external spectral cubes; it is equivalent to calling the constructor directly.
to_unit ¶
to_unit(unit: SpectralUnit) -> SpectralIrradiance
Return a new cube converted to unit.
The conversion is per-nanometre and pointwise: energy density is divided
by h*c/lambda to obtain photon flux density, or multiplied by it in
the reverse direction. Wavelength integration is intentionally not part
of this operation. The returned cube is always a new object, even when
the requested unit matches the current one.
resample_spatial ¶
resample_spatial(
target_shape: Sequence[int], method: str = "box"
) -> SpectralIrradiance
Return an area-conserving box downsample of the spatial axes.
Each output pixel is the mean of its (factor_y, factor_x) input
block, preserving per-unit-area irradiance. Both target dimensions must
divide the corresponding input dimension exactly; non-integer ratios
would require storing fractional pixel areas and are rejected. The
wavelength axis and unit are unchanged.
When the input carries pixel_pitch_um and both axes are reduced by
the same factor, the output pitch is scaled by that factor. For
anisotropic factors the scalar pitch cannot describe rectangular output
pixels, so the metadata is dropped rather than reported incorrectly.
resample_wavelengths ¶
resample_wavelengths(
target_grid: WavelengthGrid | ArrayLike,
method: str = "integrate",
allow_zero_fill: bool = False,
) -> SpectralIrradiance
Return the cube integrated onto target_grid.
Samples are treated as piecewise-constant per-nm densities over cells whose interior edges are the midpoints between neighbouring samples and whose outer edges are the first and last sample. For each target cell, the overlapping source cells are weighted by their overlap length and divided by the target cell width. This preserves the cell integral over the covered target range and reproduces the input exactly when the grids are identical.
The target wavelength range must lie inside the source range. Otherwise
ValueError is raised; with allow_zero_fill=True uncovered regions
contribute zero (followed by a :class:UserWarning) so partially covered
edge cells are averaged against zero instead of inventing signal.
WavelengthGrid
dataclass
¶
WavelengthGrid(nm: NDArray[float64])
A validated, immutable, uniform wavelength axis in nanometres.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
nm
|
NDArray[float64]
|
One-dimensional wavelength samples in nanometres. The array is copied and made read-only, so the grid cannot drift after construction. |
required |
Notes
The grid must contain at least two wavelengths so that a spacing is defined. Non-uniform spacing, non-positive or non-finite wavelengths, and non-monotonic order are rejected because they break the projection-matrix and band-width assumptions shared by the optics, sensor, and metrics layers.
LensConfig ¶
Bases: ConfigModel
Reference lens geometry and transmission.
Attributes:
| Name | Type | Description |
|---|---|---|
f_number |
PositiveFloat
|
Relative aperture |
focal_length_mm |
PositiveFloat
|
Actual (not full-frame-equivalent) focal length. |
sensor_diagonal_mm |
PositiveFloat
|
Active sensor diagonal used for the field-of-view calculation. |
transmittance |
Annotated[float, Field(gt=0.0, le=1.0)] | NonEmptyStr
|
Scalar on-axis transmission in |
vignetting |
Fraction | None
|
Optional strength in |
Notes
Derived quantities are properties rather than stored fields so they can never disagree with the geometry. The reference phone lens (f/1.8, 6.4 mm actual focal length, 11.46 mm diagonal, T = 0.9) sees about 84 degrees diagonally and about 100 pixels per degree at a 1.12 um pitch.
field_of_view_deg
property
¶
field_of_view_deg: float
Diagonal field of view in degrees, 2 atan(d / (2 f)).
pixels_per_degree ¶
pixels_per_degree(pixel_pitch_um: float) -> float
Return how many pixels of pixel_pitch_um span one degree.
A pixel subtends atan(pitch / f) radians at the lens centre. This
small-angle conversion turns cycles/pixel MTF values into
cycles/degree, the unit in which human acuity and object detection are
usually discussed.
Core contracts¶
Shared units, spectral cubes, RAW/rendered frames, colour references, and physical constants. This is the only layer the metrics harness may import from the simulator.
Conversions between spectral radiant power and photon flux.
The simulation tracks light in two interchangeable currencies. Radiometry
measures power per unit area and wavelength, E(lambda) in W/m^2/nm.
Photon counting instead needs a rate per nanometre of bandwidth::
Phi(lambda) = E(lambda) / E_photon(lambda)
with E_photon(lambda) = h * c / lambda in joules. Because both quantities
are densities per nanometre, these helpers never integrate across wavelength;
later stages multiply by the wavelength-sample spacing when turning a spectral
cube into electrons. The sensor uses constant-step integration on its validated
uniform grid: every sample is weighted equally by Δλ and there are no
half-width edge cells. The explicit wavelength-resampling helper is the
component that uses overlap weights; do not confuse the two conventions.
Keeping the integral explicit avoids double-counting bandwidth when grids are
resampled.
Real-world parameter ranges: the visible band spans roughly 360-830 nm. At 555 nm a photon carries 3.578 x 10^-19 J, corresponding to 2.79 x 10^18 photons/s per watt. Daylight scene irradiance is of order 0.1-10 W/m^2/nm, so typical photon flux densities are 10^17-10^19 photons/s/m^2/nm.
Failure artefacts: supplying a wavelength already in metres to these nm-based helpers applies the nm-to-metre factor twice, overestimating photon energy by 10^9 and underestimating photon flux by the same factor; supplying a total band power instead of a per-nm density makes the result wrong by the bandwidth; non-positive wavelengths are physically meaningless and produce infinities or sign flips, so they are rejected rather than propagated.
photon_energy_j ¶
photon_energy_j(
wavelength_nm: ArrayLike,
) -> NDArray[np.float64]
Return the energy of one photon, in joules, at each wavelength in nm.
E = h * c / lambda with lambda converted from nanometres to metres.
The result has the broadcast shape of wavelength_nm (a 0-d array for a
scalar input).
irradiance_w_to_photons ¶
irradiance_w_to_photons(
irradiance: ArrayLike, wavelength_nm: ArrayLike
) -> NDArray[np.float64]
Convert per-nm spectral irradiance in W/m^2/nm to photons/s/m^2/nm.
irradiance and wavelength_nm follow NumPy broadcasting rules, which
lets a (H, W, L) cube be converted with an (L,) wavelength array.
photons_to_irradiance_w ¶
photons_to_irradiance_w(
photons: ArrayLike, wavelength_nm: ArrayLike
) -> NDArray[np.float64]
Convert photons/s/m^2/nm to per-nm spectral irradiance in W/m^2/nm.
Exact SI physical constants used by the radiometric simulation.
camsim keeps every radiometric quantity in SI base units. Two constants bridge the wave and photon pictures of light:
H_PLANCK(J s) fixes the energy of a photon at frequencynu:E = h * nu.C_LIGHT(m/s) converts that frequency to a vacuum wavelength:nu = c / lambda.
Together they give the relation used throughout the sensor model::
E_photon(lambda) = h * c / lambda [J]
with lambda in metres. Both values are exact under the 2019 SI redefinition,
so conversions are limited only by the floating-point representation of the
wavelength.
Real-world scale: a visible photon (360-830 nm) carries roughly 1.5-3.4 eV (2.4-5.5 x 10^-19 J). A 10 ms exposure of a 1.12 um pixel under daylight collects on the order of 10^3-10^5 photons, which is why shot noise matters.
Failure artefacts: the nm/metre boundary is the classic unit trap. Passing a
555 nm wavelength as the bare number 555 to h * c / lambda expecting metres
underestimates photon energy by 10^9 (3.58 x 10^-28 J instead of 3.578 x 10^-19
J) and therefore inflates photon counts by the same factor. The opposite
mistake, passing a metre-valued wavelength such as 5.55 x 10^-7 to an API that
applies the nm-to-metre factor again, overestimates photon energy by 10^9 and
turns plausible photon counts into an infinitesimal number. Using a rounded
c or h shifts absolute radiometry by more than any sensor tolerance in
this project permits.
Validated spectral irradiance cubes on an explicit wavelength grid.
A :class:SpectralIrradiance is the radiometric boundary between the scene and
the sensor. It carries a spatial cube of shape (H, W, L) plus the L
wavelength samples in nanometres and an explicit unit, either per-nanometre
radiant power ("W/m^2/nm") or per-nanometre photon flux
("photons/s/m^2/nm"). Values are densities per nanometre; turning a cube
into band energy or electrons requires an explicit wavelength integral in a
later stage.
The wavelength axis is a :class:WavelengthGrid: one-dimensional, strictly
increasing, uniformly spaced, and positive. Uniform spacing lets downstream
code precompute projection matrices and band widths once. Real spectral datasets
are published on grids from 0.1 nm (laboratory spectroradiometers) to 20 nm
(coarse colour-science tables); camsim's default sensor grid is 360-830 nm in
5 nm steps, matching common camera QE/CFA measurements.
Resampling is never implicit. :meth:SpectralIrradiance.resample_spatial
performs an area-conserving box average when the target dimensions divide the
input dimensions, which is how a 4x oversampled scene becomes sensor pixels.
:meth:SpectralIrradiance.resample_wavelengths performs overlap-weighted
integration between uniform grids, treating each sample as a piecewise-constant
per-nm density over its cell; interior cell edges are sample midpoints and the
outer edges are the first and last sample, so the represented domain is exactly
the sampled range and the cell integral over a covered target range is
preserved. Uncovered target ranges raise unless the caller explicitly opts into
zero fill.
Failure artefacts guarded here: a wavelength axis that does not match the data length silently mis-colours a scene; a non-uniform grid breaks projection-matrix assumptions; negative or NaN irradiance has no physical meaning and poisons noise generation; and an implicit unit mix-up produces RAW values wrong by many orders of magnitude. Each condition is rejected with a message naming the offending field instead of being coerced.
WavelengthGrid
dataclass
¶
WavelengthGrid(nm: NDArray[float64])
A validated, immutable, uniform wavelength axis in nanometres.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
nm
|
NDArray[float64]
|
One-dimensional wavelength samples in nanometres. The array is copied and made read-only, so the grid cannot drift after construction. |
required |
Notes
The grid must contain at least two wavelengths so that a spacing is defined. Non-uniform spacing, non-positive or non-finite wavelengths, and non-monotonic order are rejected because they break the projection-matrix and band-width assumptions shared by the optics, sensor, and metrics layers.
SpectralIrradiance
dataclass
¶
SpectralIrradiance(
data: ArrayLike,
wavelengths_nm: WavelengthGrid | ArrayLike,
unit: SpectralUnit,
pixel_pitch_um: float | None = None,
)
A validated spectral irradiance cube on a uniform wavelength grid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
ArrayLike
|
Array-like of shape |
required |
wavelengths_nm
|
WavelengthGrid | ArrayLike
|
A :class: |
required |
unit
|
SpectralUnit
|
|
required |
pixel_pitch_um
|
float | None
|
Optional square-pixel pitch in micrometres. Positive when present; used by the sensor to reject cubes rendered for a different pixel grid. |
None
|
Notes
The cube describes irradiance at the sensor plane before the sensor's own IR-cut and CFA filters, so those filters are applied exactly once. Values are per-nanometre densities; this class does not integrate over wavelength.
from_array
classmethod
¶
from_array(
data: ArrayLike,
wavelengths_nm: WavelengthGrid | ArrayLike,
unit: SpectralUnit,
pixel_pitch_um: float | None = None,
) -> Self
Construct from array-like data and wavelengths.
This is the ergonomic entry point for external spectral cubes; it is equivalent to calling the constructor directly.
to_unit ¶
to_unit(unit: SpectralUnit) -> SpectralIrradiance
Return a new cube converted to unit.
The conversion is per-nanometre and pointwise: energy density is divided
by h*c/lambda to obtain photon flux density, or multiplied by it in
the reverse direction. Wavelength integration is intentionally not part
of this operation. The returned cube is always a new object, even when
the requested unit matches the current one.
resample_spatial ¶
resample_spatial(
target_shape: Sequence[int], method: str = "box"
) -> SpectralIrradiance
Return an area-conserving box downsample of the spatial axes.
Each output pixel is the mean of its (factor_y, factor_x) input
block, preserving per-unit-area irradiance. Both target dimensions must
divide the corresponding input dimension exactly; non-integer ratios
would require storing fractional pixel areas and are rejected. The
wavelength axis and unit are unchanged.
When the input carries pixel_pitch_um and both axes are reduced by
the same factor, the output pitch is scaled by that factor. For
anisotropic factors the scalar pitch cannot describe rectangular output
pixels, so the metadata is dropped rather than reported incorrectly.
resample_wavelengths ¶
resample_wavelengths(
target_grid: WavelengthGrid | ArrayLike,
method: str = "integrate",
allow_zero_fill: bool = False,
) -> SpectralIrradiance
Return the cube integrated onto target_grid.
Samples are treated as piecewise-constant per-nm densities over cells whose interior edges are the midpoints between neighbouring samples and whose outer edges are the first and last sample. For each target cell, the overlapping source cells are weighted by their overlap length and divided by the target cell width. This preserves the cell integral over the covered target range and reproduces the input exactly when the grids are identical.
The target wavelength range must lie inside the source range. Otherwise
ValueError is raised; with allow_zero_fill=True uncovered regions
contribute zero (followed by a :class:UserWarning) so partially covered
edge cells are averaged against zero instead of inventing signal.
RAW sensor frames, ISP stage traces, and display-referred rendered images.
A camera pipeline moves through three representations that must never be confused:
- Scene radiance is physical light. After the lens and colour filter array, photon counts become electrons in each photodiode.
- RAW data are digital numbers (DN) produced by the ADC. They are integer
codes that include a black-level offset, so the signal-bearing value is
DN - black_level_dn. :class:RawFramecouples a 2-Duint16array to every acquisition and geometry parameter the ISP needs, plus optional defective-pixel and PDAF masks and a hash of the sensor configuration. - Rendered images are display referred. A non-linear transfer function
(sRGB OETF or a tone curve) has already been applied, so values are floats in
[0, 1]and are not proportional to scene radiance. :class:RenderedImagekeeps the optional pre-transferlinear_rgbbuffer so metrics can compute colour differences in a linear space.
Typical parameter ranges: phone and consumer sensors quantize to 10 or 12 bits (8-bit legacy modes and 14-/16-bit scientific or industrial parts exist); black level is commonly 64 DN at 10 bits and 256 DN at 12 bits; analog gain spans 0-30 dB; digital gain 1-8x; exposures run from microseconds to seconds; sensor temperature is reported in degrees Celsius over roughly -20 to 70 C; pixel pitches range from 0.7 um in dense phone arrays to about 10 um in large machine-vision pixels.
Failure artefacts guarded here: values above 2**bit_depth - 1 mean the
data were scaled for a deeper container (or an 8-bit image was widened without
scaling), and truncating them loses highlights; a black level that is not
subtracted leaves a pedestal that becomes a colour cast after white balance;
treating RAW DN as display values applies gamma twice; and confusing a mono
(H, W, 1) image with an RGB (H, W, 3) one silently mis-indexes
channels. Each condition raises with a message naming the offending field
instead of being coerced.
CFAPattern ¶
Bases: str, Enum
Colour-filter-array tiles and CFA-free readout modes.
Bayer patterns are 2x2 tiles named in row-major order from the top-left
pixel: RGGB places red at (0, 0), green at (0, 1) and (1, 0), and blue
at (1, 1). The four Bayer names correspond to the four possible placements
of the red site. RCCC is an automotive pattern with one red and three
clear sites per tile. MONO denotes a sensor with no colour filters at
all: it has higher effective quantum efficiency and needs no demosaicing.
StageOutput
dataclass
¶
StageOutput(
data: Any,
description: str,
metadata: dict[str, Any] = dict(),
)
One named ISP stage's output in a processing trace.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
Stage result. ISP stages normally store a float array whose trailing axis is the channel count; the type is intentionally permissive so stages such as the colour-correction matrix can record a small matrix. |
required |
description
|
str
|
Human-readable note naming the operation applied, for example
|
required |
metadata
|
dict[str, Any]
|
Stage-specific parameters or statistics. Defaults to an empty dict. |
dict()
|
Notes
Trace outputs are snapshots: later stages must not mutate arrays received from an earlier stage, otherwise the trace no longer describes the processing order that produced the final image.
RawFrame
dataclass
¶
RawFrame(
data: NDArray[uint16],
cfa: CFAPattern,
bit_depth: int,
black_level_dn: float,
exposure_s: float,
analog_gain_db: float,
digital_gain: float,
mode: str,
temperature_c: float,
seed: int,
frame_index: int,
pixel_pitch_um: float,
sensor_config_hash: str,
*,
defect_mask: NDArray[bool_] | None = None,
hot_mask: NDArray[bool_] | None = None,
pdaf_mask: NDArray[bool_] | None = None,
)
A self-describing RAW sensor frame.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
NDArray[uint16]
|
Sensor readout after gain and quantization, shape |
required |
cfa
|
CFAPattern
|
Colour-filter pattern of the sensor. A string matching a
:class: |
required |
bit_depth
|
int
|
ADC resolution in bits, 8-16 inclusive. Every code in a valid frame
lies in |
required |
black_level_dn
|
float
|
ADC code that corresponds to zero signal,
|
required |
exposure_s
|
float
|
Integration time in seconds, finite and positive. |
required |
analog_gain_db
|
float
|
Analog programmable-gain-amplifier setting in decibels, finite and non-negative (0 dB is unity gain). |
required |
digital_gain
|
float
|
Digital multiplier applied after the ADC, finite and positive. |
required |
mode
|
str
|
Sensor readout mode, for example |
required |
temperature_c
|
float
|
Sensor temperature in degrees Celsius; finite and may be negative. |
required |
seed
|
int
|
Non-negative seed of the random stream that generated this frame. |
required |
frame_index
|
int
|
Non-negative index of the frame within its capture sequence. |
required |
pixel_pitch_um
|
float
|
Square pixel pitch in micrometres, finite and positive. |
required |
defect_mask
|
NDArray[bool_] | None
|
Optional boolean mask, same shape as |
None
|
hot_mask
|
NDArray[bool_] | None
|
Optional boolean mask, same shape as |
None
|
pdaf_mask
|
NDArray[bool_] | None
|
Optional boolean mask, same shape as |
None
|
sensor_config_hash
|
str
|
Non-empty identifier of the sensor configuration that produced the frame. It lets caches and reports detect a configuration change. |
required |
Notes
Equality is identity based because frames own large arrays; compare fields
(or arrays with :func:numpy.array_equal) explicitly.
This is the reduced v0.1.0 contract. It records the acquisition and calibration state the ISP needs, not a full capture-provenance record: it has no wall-clock timestamp or package version. Those fields belong to the surrounding experiment record and are documented as a limitation of the candidate release rather than silently synthesised here.
with_data ¶
with_data(data: NDArray[uint16]) -> RawFrame
Return a copy with data replaced and all metadata preserved.
The replacement data is validated exactly like constructor data, so a
caller cannot smuggle floats or out-of-range codes into the pipeline.
Masks are tied to pixel positions, so changing the data shape requires
clearing defect_mask, hot_mask, and pdaf_mask first.
to_dict ¶
to_dict() -> dict[str, Any]
Return a JSON-friendly mapping, including data and masks as lists.
from_dict
classmethod
¶
from_dict(payload: Mapping[str, Any]) -> Self
Reconstruct a frame from :meth:to_dict output or decoded JSON.
RenderedImage
dataclass
¶
RenderedImage(
data: NDArray[float32],
color_space: ColorSpace,
channels: ChannelLayout,
linear_rgb: NDArray[floating[Any]] | None = None,
stages: dict[str, StageOutput] | None = None,
metadata: dict[str, Any] = dict(),
)
A display-referred image ready for viewing or metrics.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
NDArray[float32]
|
Display-referred pixels after the output transfer function. Shape is
|
required |
color_space
|
ColorSpace
|
Output colour space, |
required |
channels
|
ChannelLayout
|
|
required |
linear_rgb
|
NDArray[floating[Any]] | None
|
Optional pre-transfer linear RGB buffer with the same shape as
|
None
|
stages
|
dict[str, StageOutput] | None
|
Optional ISP trace mapping stage names to :class: |
None
|
metadata
|
dict[str, Any]
|
Free-form capture and processing provenance such as the white point,
exposure, ISP configuration hash, and the applied |
dict()
|
Notes
Equality is identity based because images own large arrays.
This is the reduced v0.1.0 contract. It carries display data, the optional
linear RGB buffer, the stage trace, and metadata; it does not carry a
linear_xyz buffer, a wall-clock timestamp, or a package-version field.
Colour metrics derive XYZ from linear_rgb/data plus color_space,
and acquisition provenance travels in the RawFrame and the surrounding
experiment record. That reduction is documented as a candidate-release
limitation rather than papered over with placeholder fields.
Per-patch CIE Lab colour references bound to an illuminant and observer.
A :class:ColorReference is the colour ground truth for a chart under one
specific illumination. It stores CIE 1976 Lab* values, one row per patch in
layout order, together with the white point that the Lab conversion used and
the observer and provenance that produced it. The scene layer computes these
values spectrally (patch reflectance x illuminant x colour-matching functions
-> XYZ -> Lab); metrics consume them without importing the scene layer.
CIE Lab is approximately perceptually uniform: L* runs from 0 (black) to 100
(diffuse white), while a* and b* encode green-red and blue-yellow opposition.
A 24-patch ColorChecker spans roughly L* 20-95 and a/b about -60 to 100.
White points are CIE 1931 chromaticity coordinates, for example D65 at
(0.3127, 0.3290) or a 3000 K blackbody at (0.4369, 0.4041). Real
instruments and standards use either the CIE 1931 2-degree or the CIE 1964
10-degree observer; the two differ enough to shift computed Lab values, so the
observer is stored explicitly rather than implied.
Failure artefacts guarded here: applying a D65 reference to a 3000 K capture
produces a large systematic delta-E offset that looks like a sensor colour
error; mixing 2-degree and 10-degree observers shifts every patch slightly;
feeding display-encoded sRGB values into a Lab comparison double-applies the
transfer function; and reordering the patch rows silently mismatches the
layout, so the reference carries them in a validated (n, 3) array and
provenance can record how the order was derived. Non-finite values are
rejected because they poison delta-E statistics.
ColorReference
dataclass
¶
ColorReference(
lab: NDArray[float64],
white_point_xy: tuple[float, float],
illuminant_name: str,
observer: str = _DEFAULT_OBSERVER,
provenance: dict[str, Any] = dict(),
)
Reference CIE Lab patches for a colour chart under one illuminant.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lab
|
NDArray[float64]
|
|
required |
white_point_xy
|
tuple[float, float]
|
CIE 1931 chromaticity |
required |
illuminant_name
|
str
|
Human-readable illuminant label such as |
required |
observer
|
str
|
CIE standard observer used for the XYZ-to-Lab conversion. Defaults to
|
_DEFAULT_OBSERVER
|
provenance
|
dict[str, Any]
|
Free-form metadata describing how the values were produced (dataset, source URL, retrieval date, assumptions). Defaults to an empty dict. |
dict()
|
Notes
The reference is only comparable with measurements made under the same illuminant and observer, so those fields are part of the value rather than optional notes. Equality compares the Lab array and all metadata.
Configuration¶
Pydantic v2 schemas with units in every field name, cross-field physical validators, provenance on every preset, and JSON I/O.
Validated, versioned configuration models for sensors, lenses, and ISPs.
Configuration is the numerical interface between a datasheet and the signal
chain. Every model here is therefore explicit about units (_um, _e,
_uv, _db, _s, _c, _k), forbids unknown keys, and rejects
non-finite values, because a silently ignored typo in full_well_e or a
microvolt/millivolt mix-up produces a sweep that runs to completion and is
simply wrong. Provenance (source, URL, retrieval date, assumptions) is a
required part of every preset, so a number can always be traced back to a
datasheet or to an engineering estimate.
Physical foundations encoded as cross-field rules:
- Full well versus conversion gain. A pixel saturates either in the charge
domain (
full_well_eelectrons) or in the ADC (full_scale_uvvolts). At unity analog gain the voltage at full well isfull_well_e * conversion_gain_uv_per_e; if it exceedsfull_scale_uvthe ADC clips first. That is legal but changes noise and highlight behaviour, so it is reported as a warning rather than rejected. - Dual conversion gain. High conversion gain trades full well for lower
read noise;
HCG.full_well_e <= LCG.full_well_ekeeps the two modes physically ordered. - Colour matrices. Spectral crosstalk and colour-correction matrices are row-normalised (sum of each row is one) so a neutral input stays neutral; the tolerances below absorb decimal rounding in published matrices.
- Geometry. A lens of focal length
fprojects a sensor diagonaldonto a field of view2 atan(d / 2f), and a pixel of pitchpsubtendsatan(p / f), which yields pixels per degree for cycles/degree MTF axes.
Real-world parameter ranges reflected in the presets: pixel pitch 0.6-10 um;
conversion gain 40-500 uV/e-; full well 2-50 ke-; read noise 0.3-10 e- rms;
dark current 0.01-100 e-/s at 25 C, doubling every 5-10 K; analog gain 0-30 dB;
ADC 8-16 bits with a 0.3-1.2 V reference; exposure 10 us-10 s; black level
64 DN at 10 bits and 256 DN at 12 bits. CSV paths (qe_csv, cfa_csv,
ir_cut_csv, spectral lens transmittance) are deliberately kept as strings:
presets may be written before the datasets land, and the files are resolved at
sensor construction time, not at schema-validation time. The shipped preset
uses package-relative spectra/*.csv paths that resolve inside
camsim.data after installation; an absolute or working-directory path still
overrides them. An optional spatial_crosstalk kernel is a normalised 2-D
stencil applied per CFA channel plane in the electron domain; None disables
charge diffusion.
Failure artefacts guarded here: negative or non-finite gains; an ADC black level outside the code range (bright pedestal after black-level subtraction); a default exposure outside its own limits (auto-exposure dead on arrival); dual-gain modes whose full wells cross; unnormalised colour matrices (colour casts that no white balance can remove); a zero pixel pitch (infinite pixels/degree); and unknown JSON keys (a misspelled stage toggle silently leaving a stage enabled). Each condition raises a validation error naming the offending field.
LensConfig ¶
Bases: ConfigModel
Reference lens geometry and transmission.
Attributes:
| Name | Type | Description |
|---|---|---|
f_number |
PositiveFloat
|
Relative aperture |
focal_length_mm |
PositiveFloat
|
Actual (not full-frame-equivalent) focal length. |
sensor_diagonal_mm |
PositiveFloat
|
Active sensor diagonal used for the field-of-view calculation. |
transmittance |
Annotated[float, Field(gt=0.0, le=1.0)] | NonEmptyStr
|
Scalar on-axis transmission in |
vignetting |
Fraction | None
|
Optional strength in |
Notes
Derived quantities are properties rather than stored fields so they can never disagree with the geometry. The reference phone lens (f/1.8, 6.4 mm actual focal length, 11.46 mm diagonal, T = 0.9) sees about 84 degrees diagonally and about 100 pixels per degree at a 1.12 um pitch.
field_of_view_deg
property
¶
field_of_view_deg: float
Diagonal field of view in degrees, 2 atan(d / (2 f)).
pixels_per_degree ¶
pixels_per_degree(pixel_pitch_um: float) -> float
Return how many pixels of pixel_pitch_um span one degree.
A pixel subtends atan(pitch / f) radians at the lens centre. This
small-angle conversion turns cycles/pixel MTF values into
cycles/degree, the unit in which human acuity and object detection are
usually discussed.
SensorConfig ¶
Bases: ConfigModel
A complete sensor preset: geometry, spectra, gains, ADC, and defects.
Cross-field rules live here because they span sub-models: the mode set must
be exactly LCG/HCG, HCG full well may not exceed LCG full well, and
the ADC-limited saturation condition becomes a warning from
:meth:validation_warnings. auto_hcg_threshold_e is the mean signal
(electrons per pixel, before noise) below which mode="auto" selects the
high-conversion-gain readout.
spatial_crosstalk is an optional normalised charge-diffusion kernel
applied to each CFA channel plane in the electron domain after spectral
crosstalk and before read noise. None (the default) disables it. Unlike
the 3x3 spectral-crosstalk matrix, which mixes colours at one pixel site,
this kernel mixes neighbouring pixels of the same colour filter. A typical
measured kernel is a small odd-sized stencil with most weight on the centre
and a few percent on the four nearest neighbours.
validation_warnings ¶
validation_warnings() -> list[str]
Return non-fatal physical warnings for this configuration.
The only warning implemented in schema version 1.0 is ADC-limited
saturation. At the default analog gain, each mode's full-well voltage
is compared with the ADC full scale; if the ADC clips first, highlight
headroom and saturation SNR no longer follow from full_well_e
alone. This is legal (many sensors run ADC-limited in high-gain modes),
so it is surfaced to users rather than rejected.
ISPConfig ¶
Bases: ConfigModel
A complete image-signal-processor preset.
Stages run in the fixed order dpc, blc, lsc, awb, demosaic, ccm,
denoise, sharpen, tone, output. BLC subtracts the ADC pedestal and
normalises the result by the white span, so every later stage works in
[0, 1] linear units. Each stage config carries an enabled toggle.
The defaults form a conventional baseline, not a guarantee of identity:
auto white balance estimates and applies per-channel gains, and colour
correction applies a matrix supplied in ccm.matrix; a colour frame that
reaches the stage without one raises. Matrices are fitted offline with
:func:camsim.isp.ccm.fit_ccm, never by the pipeline at runtime. Callers
that need a bit-exact pass-through must disable those stages explicitly.
Optional enhancement stages whose numeric strengths default to zero
(denoise, sharpen) are identity operations until parameters are
supplied.
Provenance ¶
Bases: ConfigModel
Traceability metadata for a preset.
Attributes:
| Name | Type | Description |
|---|---|---|
source |
NonEmptyStr
|
Where the numbers came from, for example a datasheet name or "representative engineering assumptions". |
url |
NonEmptyStr | None
|
Optional public reference for the source. |
retrieved |
NonEmptyStr | None
|
Optional ISO 8601 date ( |
notes |
str | None
|
Free-form caveats about the preset as a whole. |
assumptions |
list[str]
|
Explicit list of values that are estimates rather than measurements. Presets are representative, not claims about a specific part. |
Loading, validation, and JSON Schema export for camsim configuration files.
A configuration file crosses three boundaries: the filesystem (missing or unreadable input), JSON syntax (malformed text), and physics (schema and cross-field rules). The helpers here keep those failures distinguishable because callers act on them differently: an automated sweep should stop on a file it cannot read, skip a file whose JSON is malformed, and continue with a warning when a sensor is merely ADC-limited.
Design choices:
load_sensorandload_ispraise :class:pydantic.ValidationErrorfor schema violations. Programmatic callers get the complete list of problems in one exception rather than failing field by field.- :func:
validate_confignever raises for malformed content; it returns a :class:ValidationReportwhoseerrorsandwarningslists can be printed, logged, or serialised. Only filesystem-level failures (missing file, permission denied, undecodable bytes) propagate, so the CLI can map them to its distinct exit code. - Sensor versus ISP files are recognised from their distinctive keys, not from the filename, so presets can be renamed freely. A file mixing both sets is reported as ambiguous instead of being coerced into one schema.
- JSON Schema export uses Pydantic's
model_json_schemaso editors and downstream tools can autocomplete unit-bearing field names.
Spectral data files referenced by a preset (qe_csv, cfa_csv,
ir_cut_csv) are intentionally not opened here. They are resolved when a
sensor is constructed, which lets presets be authored before the datasets land
and keeps validation independent of the working directory.
Real-world failure artefacts handled here: a UTF-8 BOM or a trailing comma in hand-edited JSON; an unknown top-level payload (for example a lens file passed to the sensor loader); a sensor preset with ISP stage keys copied in; and a valid file whose ADC limits saturation before full well (warning, not error).
ValidationReport
dataclass
¶
ValidationReport(
errors: list[str] = list(), warnings: list[str] = list()
)
Outcome of validating one configuration file.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
errors
|
list[str]
|
Schema or cross-field violations. A non-empty list means the file is invalid and must not be used to configure a pipeline. |
list()
|
warnings
|
list[str]
|
Legal but physically noteworthy conditions, such as ADC-limited saturation. Warnings never make the configuration invalid. |
list()
|
load_sensor ¶
load_sensor(path: PathLike) -> SensorConfig
Load and validate a sensor preset.
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If |
JSONDecodeError
|
If the file is not valid JSON. |
ValidationError
|
If the payload violates :class: |
load_isp ¶
load_isp(path: PathLike) -> ISPConfig
Load and validate an ISP preset.
Raises the same exceptions as :func:load_sensor.
validate_config ¶
validate_config(path: PathLike) -> ValidationReport
Validate any supported configuration file and collect errors/warnings.
Filesystem errors propagate to the caller; malformed JSON, schema
violations, and unknown payloads are reported through the returned
:class:ValidationReport so one bad file does not abort a validation run
over a directory of presets.
sensor_schema ¶
sensor_schema() -> dict[str, Any]
Return the JSON Schema (draft 2020-12) for sensor presets.
available_presets ¶
available_presets() -> dict[str, list[str]]
List the shipped preset files grouped by kind.
Paths are relative to :data:PRESETS_DIR and use forward slashes on every
platform, so the output is stable enough for documentation and CLI JSON.
Scene¶
Synthetic spectral targets, chart layouts, illuminants, and spectral data.
camsim.scene.charts is the sanctioned bridge to the metrics layer.
Synthetic spectral scene targets and the scene-to-sensor renderer.
The scene layer turns a test chart into sensor-plane spectral irradiance. Each
target is a spatial map of spectral reflectance :math:\rho(\lambda), a
dimensionless material property in [0, 1]. A Lambertian surface under scene
illuminance :math:E_{scene}(\lambda) has radiance
:math:L = \rho E_{scene} / \pi; a lens of transmittance
:math:T(\lambda) and relative aperture :math:N images it with irradiance::
E_img(lambda) = rho(lambda) * E_scene(lambda) * T(lambda)
/ (4 N**2) * cos**4(theta)
where :math:\theta is the field angle. The 1 / (4 N**2) factor is what
makes absolute electron counts depend on aperture: opening from f/2.8 to f/1.4
quadruples the irradiance. The cos**4 factor is supplied by
:func:camsim.optics.vignetting.vignetting_map, honouring the
LensConfig.vignetting strength; with vignetting=None it is disabled.
Targets
- Slanted edge - a binary reflectance step tilted a few degrees from vertical, the ISO 12233 structure used to estimate modulation transfer. The tilt spreads the edge across many pixel phases so the edge-spread function can be reconstructed at sub-pixel resolution.
- ColorChecker - the 24 BabelColor average reflectance spectra laid out as four rows of six square patches with a dark surround. Patch order is the physical chart order read left to right, top to bottom.
- Flat patch - a uniform reflectance field for SNR, shading, and flat-field tests.
- Siemens star - alternating radial spokes; a resolution and aliasing target whose spoke frequency rises towards the centre.
Sampling and rendering
The target is rasterized on a grid oversampling times finer than the sensor
in each axis, then multiplied by the illuminant and lens terms, convolved with
the PSF, multiplied by vignetting, and box-averaged down to the sensor grid.
Four samples per pixel is the conventional choice: it bounds edge-position
quantization to a quarter pixel and pushes aliasing of the pixel aperture down
to a level that e-SFR can separate from optical blur. Two samples is adequate
for smooth targets; one sample is appropriate only when alias-free rendering is
not required.
The PSF argument is sampled on the oversampled grid, not the sensor grid.
A sensor-grid Gaussian of sigma pixels must be passed as
gaussian_psf(sigma * oversampling); otherwise the blur is too narrow by the
oversampling factor. Layout coordinates, in contrast, always refer to the
sensor grid returned in SceneResult.irradiance, because downstream metrics
extract patches and edges from that image.
Real-world parameter ranges: chart reflectance 0.02-0.95, slanted-edge tilts 2-7 degrees (ISO 12233 recommends about 5 degrees), illuminance 1-100,000 lux, and lens f-numbers f/1.4-f/16 for these tests. A ColorChecker chart is physically about 215 x 280 mm; its proportions, not its absolute size, are what the simulation reproduces.
Failure artefacts guarded here: reflectance outside [0, 1] invents energy;
a chart whose patch ROIs overlap, leave the frame, or include a sensor pixel
whose oversampling block straddles a patch edge reports the wrong colour; an
edge angle near 45 degrees runs out of the frame and biases the estimate;
rendering at the sensor grid aliases the edge; applying a sensor-grid PSF to an
oversampled cube under-blurs; forgetting the 1 / (4 N**2) or lens
transmittance term makes absolute exposure meaningless; and any global random
state would make two renders with the same seed differ.
SceneResult
dataclass
¶
SceneResult(
irradiance: SpectralIrradiance,
layout: SceneLayout,
reference: ColorReference,
ground_truth: dict[str, Any] = dict(),
)
A rendered scene at the sensor plane plus its ground-truth metadata.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
irradiance
|
SpectralIrradiance
|
Sensor-plane spectral irradiance in |
required |
layout
|
SceneLayout
|
Patch and edge regions in sensor-pixel coordinates. |
required |
reference
|
ColorReference
|
Per-patch CIE Lab reference for the render illuminant, computed spectrally from the ColorChecker reflectance and the CIE observer. It is the colour ground truth even for non-colour targets, where it provides the illuminant white point. |
required |
ground_truth
|
dict[str, Any]
|
Free-form scene metadata (target name, illuminant, lux, oversampling, grid shape, PSF and vignetting settings, and seed). Copied on construction. |
dict()
|
Notes
A scene result is a value object: mutate the arrays it exposes rather than the result fields, and pass it to the sensor layer exactly as returned.
SceneLayout
dataclass
¶
SceneLayout(
name: str,
patches: tuple[PatchROI, ...] = (),
edge_roi: PatchROI | None = None,
)
Patch and edge locations for a rendered scene, in sensor pixels.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Target name, for example |
required |
patches
|
tuple[PatchROI, ...]
|
Patch regions in chart order. Names must be unique. Empty for targets without patches. |
()
|
edge_roi
|
PatchROI | None
|
Optional region containing the slanted edge. It is the full analysis region for edge-based metrics; this phase supplies a conservative centred region, and the metrics layer may crop further. |
None
|
Notes
Coordinates always refer to the sensor grid of the returned
:class:SceneResult.irradiance, not to the oversampled rendering grid.
render ¶
render(
target: str,
illuminant: str = "D65",
lux: float = 100.0,
lens: LensConfig | None = None,
optics: ArrayLike | None = None,
pixel_pitch_um: float = 1.12,
shape: tuple[int, int] = (256, 256),
oversampling: int = 4,
seed: int | None = None,
*,
max_memory_bytes: int | None = None,
) -> SceneResult
Render a synthetic target to sensor-plane spectral irradiance.
The target is rasterized at oversampling samples per sensor pixel in
each axis and multiplied by the photometrically normalized illuminant, the
lens transmittance, and 1 / (4 N**2). A PSF is applied next, then the
cos**4 vignetting map, and finally a box average produces the sensor
grid. Reflectance and all multiplication stay in linear radiometric units;
no tone curve or display encoding belongs in the scene layer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
target
|
str
|
|
required |
illuminant
|
str
|
|
'D65'
|
lux
|
float
|
Scene illuminance in lux. Zero is a valid dark scene; negative values are rejected. |
100.0
|
lens
|
LensConfig | None
|
A :class: |
None
|
optics
|
ArrayLike | None
|
A 2-D PSF kernel sampled on the oversampled grid, or |
None
|
pixel_pitch_um
|
float
|
Sensor pixel pitch in micrometres. The target grid is
|
1.12
|
shape
|
tuple[int, int]
|
Sensor-grid |
(256, 256)
|
oversampling
|
int
|
Positive integer samples per sensor pixel per axis. Four is the conventional rendering factor; one disables oversampling. |
4
|
seed
|
int | None
|
Non-negative seed recorded for stochastic target placement. The targets implemented in this phase are fully deterministic, so the value does not change the image; it is accepted and recorded now so later stochastic targets remain reproducible and no global RNG is needed. |
None
|
max_memory_bytes
|
int | None
|
Optional peak-memory budget for this render, in bytes. |
None
|
Returns:
| Type | Description |
|---|---|
SceneResult
|
Sensor-plane :class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the target, shape, oversampling, seed, lens, pitch, illuminant, or
PSF is invalid, if the ColorChecker grid cannot fit in |
Notes
The cos**4 factor is the natural-vignetting term of the lens formula.
It is disabled when lens.vignetting is None or zero, which models a
lens whose shading has been calibrated out; full strength reproduces the
physical falloff. The PSF is wavelength-independent and applied to every
spectral plane; a wavelength-dependent PSF would require one kernel per
plane.
ColorChecker ROIs are derived from the cells actually painted on the
oversampled grid. Every ROI pixel's full oversampling x oversampling
block must lie inside its patch, so a compact chart can never return an ROI
whose centre mixes patch and surround reflectance. If any patch has no such
sensor pixel, the shape is rejected instead of returning a contaminated
layout.
colorchecker_target ¶
colorchecker_target(
shape: tuple[int, int],
) -> tuple[NDArray[np.float64], SceneLayout]
Return the spectral ColorChecker reflectance cube and its layout.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
shape
|
tuple[int, int]
|
|
required |
Returns:
| Name | Type | Description |
|---|---|---|
reflectance |
NDArray[float64]
|
A float64 |
layout |
SceneLayout
|
A :class: |
Reflectance is not colour: the same spectra look different under D65 and
|
|
|
3000 K, and the colour ground truth for a particular illuminant comes from
|
|
|
class:`SceneResult.reference`, not from these raw spectra.
|
|
flat_patch_target ¶
flat_patch_target(
shape: tuple[int, int],
reflectance: float = _DEFAULT_FLAT_REFLECTANCE,
) -> NDArray[np.float64]
Return a uniform reflectance field.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
shape
|
tuple[int, int]
|
|
required |
reflectance
|
float
|
Constant reflectance in |
_DEFAULT_FLAT_REFLECTANCE
|
Returns:
| Type | Description |
|---|---|
A float64 ``(height, width)`` constant array. A spectrally neutral patch
|
|
models a grey card, whose reflectance is nearly constant across the visible
|
|
band, rather than a coloured material.
|
|
slanted_edge_target ¶
slanted_edge_target(
shape: tuple[int, int],
angle_deg: float = _DEFAULT_EDGE_ANGLE_DEG,
) -> NDArray[np.float64]
Return a binary reflectance slanted edge.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
shape
|
tuple[int, int]
|
|
required |
angle_deg
|
float
|
Signed tilt of the edge from vertical, in degrees. Positive values tilt the line rightwards as the row index (top to bottom) increases; zero is a perfectly vertical edge. The magnitude must be below 45 degrees so the line stays inside the frame across its full height. |
_DEFAULT_EDGE_ANGLE_DEG
|
Returns:
| Type | Description |
|---|---|
A float64 ``(height, width)`` array of reflectance values exactly zero and
|
|
one. A real edge target has finite contrast (for example 0.05 and 0.90);
|
|
the ideal binary step maximizes edge energy for MTF estimation.
|
|
The edge passes through the geometric image centre:
|
|
``(x - cx) cos(theta) - (y - cy) sin(theta) >= 0`` is the bright side.
|
|
siemens_star_target ¶
siemens_star_target(
shape: tuple[int, int],
spokes: int = _DEFAULT_STAR_SPOKES,
) -> NDArray[np.float64]
Return a binary Siemens-star reflectance target.
The star is divided into spokes equal angular sectors of alternating
zero and one reflectance. Its spatial frequency rises linearly towards the
centre, so a single image reveals the spatial frequency at which contrast
disappears (the resolution limit) and where aliasing or moire first
appears. A 36-spoke star is the common printed chart; 4-16 spokes produce
coarser wedges for low-resolution sensors.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
shape
|
tuple[int, int]
|
|
required |
spokes
|
int
|
Number of angular sectors, an integer of at least two. Sectors alternate between dark and bright. |
_DEFAULT_STAR_SPOKES
|
Returns:
| Type | Description |
|---|---|
A float64 ``(height, width)`` array of zeros and ones.
|
|
The pattern is centred on ``((height - 1) / 2, (width - 1) / 2)``. For even
|
|
dimensions the exact centre lies between pixels, so no single pixel sits on
|
|
the singularity; the sector through angle zero is bright.
|
|
estimate_edge_angle ¶
estimate_edge_angle(image: ArrayLike) -> float
Estimate a near-vertical slanted edge's tilt from image gradients.
The horizontal Sobel gradient is computed for every row. Rows whose
gradient peak is non-zero keep only the columns within 10 % of that peak,
which rejects sensor noise and texture away from the edge. A gradient-
energy-weighted centroid gives that row's edge column, and a weighted
least-squares line fit to those centroids gives the edge slope
dx / dy. The tilt is arctan(slope) in degrees.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
ArrayLike
|
A finite 2-D array. It may be a reflectance target, a raw mono image, or a luma image; no normalization is required. |
required |
Returns:
| Type | Description |
|---|---|
The signed edge angle in degrees, using the same convention as
|
|
func:`slanted_edge_target`: positive means the edge moves rightwards as
|
|
the row index increases. Near-vertical edges only; the estimator does not
|
|
wrap to a horizontal orientation.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the image is not 2-D and finite, is too short for a line fit, or contains no edge gradient (for example a uniform field). |
Real lenses blur the edge and sensors add noise. The per-row threshold and
|
|
energy weighting keep the estimate stable under both, as long as the edge
|
|
spans enough rows; ISO 12233 analysis regions are typically hundreds of
|
|
rows tall.
|
|
Scene-owned chart layouts for the independent metrics harness.
This module is the one sanctioned bridge from the scene layer to the metrics
layer. Building a ColorChecker layout needs the chart's reflectance spectra, the
illuminant, and the CIE colour-matching functions, and all of those live in
camsim.scene. The returned type, however, is
:class:camsim.metrics.chart.ChartLayout, which knows nothing about spectra.
The dependency therefore runs scene -> metrics, never metrics -> scene, and
import camsim.metrics stays free of the simulator packages.
The three layouts mirror what :func:camsim.scene.targets.render paints, in
sensor-pixel coordinates:
colorchecker_layoutreuses the renderer's own cell geometry (_colorchecker_cells) and ROI derivation (_layout_from_painted_cells) at the sameshapeandoversampling, so every ROI pixel's full oversampling block lies inside its painted patch. A ROI that straddles a patch boundary averages two reflectances; re-deriving the rectangles here instead of sharing the renderer's code would let paint rounding and ROI rounding drift apart.flat_patch_layoutnames the full frame"flat", matching the uniform field used for SNR, shading, and fixed-pattern measurements.slanted_edge_layoutsupplies the centred half-size"edge"region used for ISO 12233-style e-SFR analysis. It is deliberately conservative: a region inset from the frame border avoids boundary pixels biasing the edge-angle fit, and the metric may crop further inside it.
chart_layout_from_scene avoids the shape/oversampling mismatch entirely by
deriving the layout from a :class:~camsim.scene.targets.SceneResult; prefer
it when the render is already in hand.
Colour ground truth is bound, not assumed. colorchecker_layout attaches the
:class:~camsim.core.color.ColorReference computed spectrally by
:func:camsim.scene.illuminants.reference_lab for the requested illuminant, so
a 3000 K or 4000 K layout carries its own warm white point. An unknown
illuminant raises instead of silently falling back to D65. Flat-patch and
slanted-edge layouts carry no reference because they measure noise and
resolution, not colour.
Real-world parameter ranges: the default sensor shape (256, 256) and pixel
pitch 1.12 um match :func:camsim.scene.targets.render's defaults, so a
layout built with no arguments fits the default render. oversampling is
typically 2-4. Focal length is attached only when a
:class:~camsim.config.schema.LensConfig is supplied (2-50 mm across phone to
large-format lenses); without it a layout can still drive MTF in cycles/pixel
but not cycles/degree.
Failure artefacts guarded here: building a layout at a different shape or oversampling from the render shifts every ROI; ignoring the oversampling factor makes ROIs include surround pixels; omitting the illuminant reference forces a D65 fallback that looks like a colour error; and passing a lens of the wrong type would leave focal-length metadata either absent or wrong, biasing cycles/degree values.
chart_layout_from_scene ¶
chart_layout_from_scene(
scene: SceneResult, lens: LensConfig | None = None
) -> ChartLayout
Return the metrics layout that exactly matches a rendered scene.
Building a layout separately from the render is error-prone: the shape,
oversampling factor, patch geometry, and illuminant reference must all
match the scene, and a mismatch silently shifts every ROI. This helper
derives the layout from the scene's own painted cells and reference, so a
ColorChecker render and its measurement cannot disagree about
oversampling or patch placement.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scene
|
SceneResult
|
The :class: |
required |
lens
|
LensConfig | None
|
Optional lens used for the render. Supplying it records the focal
length for cycles/degree conversion; |
None
|
Returns:
| Type | Description |
|---|---|
ChartLayout
|
The scene's patch and edge regions in sensor-pixel coordinates, the
scene's spectrally computed :class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
colorchecker_layout ¶
colorchecker_layout(
illuminant: str = "D65",
lens: LensConfig | None = None,
*,
shape: tuple[int, int] = (256, 256),
oversampling: int = 4,
pixel_pitch_um: float = _DEFAULT_PIXEL_PITCH_UM,
) -> ChartLayout
Return the 24-patch ColorChecker layout for a rendered scene.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
illuminant
|
str
|
|
'D65'
|
lens
|
LensConfig | None
|
Optional :class: |
None
|
shape
|
tuple[int, int]
|
Sensor-grid |
(256, 256)
|
oversampling
|
int
|
Rendering samples per sensor pixel per axis. Must equal the value used
for the render; the ROIs are derived from the painted cells on the
|
4
|
pixel_pitch_um
|
float
|
Sensor pixel pitch in micrometres. Defaults to the renderer's
|
_DEFAULT_PIXEL_PITCH_UM
|
Returns:
| Type | Description |
|---|---|
ChartLayout
|
Named |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the illuminant is unsupported, the shape or oversampling is
invalid, the shape is too small for clean patch ROIs, the lens is not
a :class: |
flat_patch_layout ¶
flat_patch_layout(
shape: tuple[int, int] = (256, 256),
*,
pixel_pitch_um: float = _DEFAULT_PIXEL_PITCH_UM,
focal_length_mm: float | None = None,
) -> ChartLayout
Return the layout of the uniform flat patch used for SNR and shading.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
shape
|
tuple[int, int]
|
Sensor-grid |
(256, 256)
|
pixel_pitch_um
|
float
|
Sensor pixel pitch in micrometres; |
_DEFAULT_PIXEL_PITCH_UM
|
focal_length_mm
|
float | None
|
Optional actual focal length in millimetres for cycles/degree conversion. |
None
|
Returns:
| Type | Description |
|---|---|
ChartLayout
|
Named |
slanted_edge_layout ¶
slanted_edge_layout(
shape: tuple[int, int] = (256, 256),
*,
pixel_pitch_um: float = _DEFAULT_PIXEL_PITCH_UM,
focal_length_mm: float | None = None,
) -> ChartLayout
Return the layout of the centred slanted-edge analysis region.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
shape
|
tuple[int, int]
|
Sensor-grid |
(256, 256)
|
pixel_pitch_um
|
float
|
Sensor pixel pitch in micrometres; |
_DEFAULT_PIXEL_PITCH_UM
|
focal_length_mm
|
float | None
|
Optional actual focal length in millimetres for cycles/degree conversion. |
None
|
Returns:
| Type | Description |
|---|---|
ChartLayout
|
Named |
Illuminant spectra, photometric normalisation, and CIE Lab ground truth.
Colour is not a property of a surface alone. A patch with spectral reflectance
:math:\rho(\lambda) looks different under daylight and under tungsten
because the illuminant's spectral power distribution :math:S(\lambda)
weights the reflected light. camsim stores two kinds of illuminant:
- D65 - the CIE standard daylight illuminant (correlated colour
temperature about 6504 K), read from the
colour-scienceCIE dataset and resampled onto the common 360-830 nm, 5 nm grid. - 3000 K and 4000 K - Planckian (blackbody) radiators generated from
Planck's law with the CIE-recommended second radiation constant
c2 = 1.4388e-2 m K, then normalised to 100 at 560 nm so their relative scale matches the tabulated daylight spectra.
Photometry and radiometry are kept separate. Scene radiance needs an absolute scale, but an SPD is usually published in arbitrary relative units, so a target illuminance in lux must be converted explicitly::
E_scene(lambda) = lux * S(lambda) / (K_m * integral(S(lambda) V(lambda) dlambda))
with K_m = 683 lm/W and V(lambda) the CIE 1924 photopic luminous
efficiency function. E_scene is a per-nanometre density in W/m^2/nm,
so the integral in that formula runs over the nanometre grid. Converting the
grid to metres without also converting the density from per-nm to per-metre
scales the result by 1e-9; that is the single most common photometric unit
bug, and the zero-overlap check here raises instead of silently returning a
near-zero irradiance. A lux value of zero is valid and yields a dark scene.
The colour ground truth follows the same spectral chain. For each ColorChecker patch::
X = integral(rho(lambda) * S(lambda) * xbar(lambda) dlambda) / N
Y = integral(rho(lambda) * S(lambda) * ybar(lambda) dlambda) / N
Z = integral(rho(lambda) * S(lambda) * zbar(lambda) dlambda) / N
N = integral(S(lambda) * ybar(lambda) dlambda)
so a perfect diffuser (rho = 1) has Y = 1. The illuminant white point is
the same integral with rho = 1, and CIE 1976 Lab is computed against that
white point. D65, 3000 K, and 4000 K therefore produce different Lab
references: applying the D65 reference to a warm-light capture creates a
systematic delta-E offset that looks like a sensor colour error but is really
a white-point mistake.
Real-world parameter ranges: illuminance spans roughly 0.1 lux (moonless
night) to 100,000 lux (direct sun); photographic scenes use 1-10,000 lux.
Correlated colour temperatures for common sources are about 2700-3000 K
(incandescent), 4000 K (neutral fluorescent/LED), and 5000-6500 K (daylight).
ColorChecker Lab values under daylight span L* about 20-97 and a* /
b* about -60 to 70.
Failure artefacts guarded here: treating an SPD as already being in lux;
normalising an illuminant by integral(S dlambda) instead of
integral(S ybar dlambda); using a fixed D65 white point for every
illuminant; reading wavelengths in metres as nanometres (all V values become
zero); and evaluating V outside its 360-830 nm table without deciding that the
result is zero.
illuminant ¶
illuminant(name: str) -> SpectralPowerDistribution
Return a supported illuminant SPD on the common wavelength grid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
|
required |
Returns:
| Type | Description |
|---|---|
SpectralPowerDistribution
|
Relative SPD on the 360-830 nm, 5 nm grid, with provenance recording whether the values came from the CIE D65 table or Planck's law. |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
lux_to_spectral_irradiance ¶
lux_to_spectral_irradiance(
lux: float, spd: SpectralPowerDistribution
) -> SpectralIrradiance
Convert a target illuminance in lux to spectral irradiance.
The returned cube has shape (1, 1, L) in "W/m^2/nm" and is
normalised so that
K_M * integral(E(lambda) * V(lambda) dlambda) = lux
with lambda in nanometres. The single spatial sample is a broadcastable
reference spectrum; scene rendering multiplies it by spatially varying
reflectance and lens transmittance.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lux
|
float
|
Target illuminance in lux. Must be finite and non-negative; zero is a valid dark scene. |
required |
spd
|
SpectralPowerDistribution
|
Relative illuminant SPD. Its values provide the spectral shape; their absolute scale cancels in the normalisation. |
required |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
photopic_luminous_efficiency ¶
photopic_luminous_efficiency(
wavelength_nm: ArrayLike,
) -> NDArray[np.float64]
Return the CIE 1924 photopic luminous efficiency V(lambda).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
wavelength_nm
|
ArrayLike
|
Wavelengths in nanometres, any shape. Values are linearly interpolated through the CIE 1924 table (360-830 nm at 1 nm). |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If any wavelength is NaN or infinite. |
reference_lab ¶
reference_lab(illuminant_name: str) -> ColorReference
Compute the ColorChecker Lab ground truth under one illuminant.
The calculation is spectral, not a fixed table: patch reflectance is
multiplied by the requested illuminant's SPD, integrated against the CIE
1931 2-degree colour-matching functions, normalised by the illuminant's
integral(S(lambda) ybar(lambda) dlambda), and converted to CIE 1976 Lab
against that illuminant's own white point. A perfect diffuser therefore has
Y = 1 under every illuminant, and D65, 3000 K, and 4000 K give
different references.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
illuminant_name
|
str
|
|
required |
Returns:
| Type | Description |
|---|---|
ColorReference
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the illuminant name is unsupported or the illuminant has no photopic/colorimetric overlap with the grid. |
Spectral reflectance data for the 24-patch ColorChecker chart.
The ColorChecker Classic is a physical chart whose patches are characterised by
spectral reflectance factor :math:\rho(\lambda), a dimensionless ratio of
reflected to incident radiant flux under diffuse illumination. Reflectance is
not a colour and not a radiance: it is a material property in [0, 1] that
must be multiplied by an illuminant and integrated against colour-matching
functions before it becomes a tristimulus value. The patches span roughly
rho = 0.02 (black) to 0.93 (white); the six neutral patches step down in
roughly 0.23-density increments, which is what makes the chart useful for
tone and white-balance checks as well as colour error.
camsim uses the BabelColor Average spectra shipped with colour-science:
the average of measurements of 30 ColorChecker Classic charts, tabulated on a
380-730 nm grid at 10 nm. The simulation works on a single common grid of
360-830 nm at 5 nm (95 samples) so that optics, sensor, and metrics can share
projection matrices. Resampling is explicit linear interpolation with constant
endpoint extrapolation, following the CIE recommendation also used by
colour-science: the 380 and 730 nm values are held constant over the small
unmeasured margins rather than dropping reflectance to zero, which would create
a spurious edge in the integrated colour.
Real-world parameter ranges: spectral reflectance is measured from 360-2500 nm in laboratory spectrophotometers, but camera colour work almost always uses 360-830 nm; reflectance factors above 1.0 indicate measurement geometry or fluorescence, not a passive surface. The chart's patch order is fixed by the manufacturer (four rows of six patches, read left to right, top to bottom), and reordering it silently mismatches every downstream comparison.
Failure artefacts guarded here: feeding display-encoded sRGB triplets where reflectance spectra are expected produces plausible-looking but meaningless Lab values; treating reflectance as radiance omits the illuminant and makes the chart appear white under every light source; zero-padding the unmeasured 380-730 nm margins biases dark patches; a wavelength array in metres does not match the nanometre grid; and a changed upstream patch order breaks chart layouts without raising. The loader checks the upstream names and order, and validates shape, range, and finiteness rather than coercing bad data.
Data are read from the installed colour-science package at runtime and are
not vendored as CSV files. Provenance, the BabelColor permission notice, and
the BSD-3-Clause packaging licence are recorded in THIRD_PARTY.md and in
each returned object's provenance mapping.
SpectralPowerDistribution
dataclass
¶
SpectralPowerDistribution(
name: str,
wavelengths_nm: WavelengthGrid | ArrayLike,
values: ArrayLike,
provenance: Mapping[str, Any] | None = None,
)
A relative spectral power distribution on a validated wavelength grid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Human-readable label such as |
required |
wavelengths_nm
|
WavelengthGrid | ArrayLike
|
A :class: |
required |
values
|
ArrayLike
|
One-dimensional, finite, non-negative spectral values. The absolute
scale is arbitrary for relative SPDs; photometric normalisation happens
in :func: |
required |
provenance
|
Mapping[str, Any] | None
|
Optional mapping recording dataset, source URL, licence, and any processing (interpolation, normalisation). Copied to a plain dict. |
None
|
Notes
An SPD is radiometric power per unit wavelength, not a colour, not a luminance, and not an irradiance at a particular scene location. Treating an SPD as already scaled in lux is the classic photometric/radiometric confusion; this type deliberately carries no photometric unit.
ColorCheckerData
dataclass
¶
ColorCheckerData(
patches: Sequence[str],
spectra: ArrayLike,
wavelengths_nm: WavelengthGrid | ArrayLike,
provenance: Mapping[str, Any] | None = None,
)
Spectral reflectance of the 24 ColorChecker Classic patches.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
patches
|
Sequence[str]
|
Exactly 24 unique patch names in physical chart order (four rows of six patches, left to right, top to bottom). |
required |
spectra
|
ArrayLike
|
|
required |
wavelengths_nm
|
WavelengthGrid | ArrayLike
|
A :class: |
required |
provenance
|
Mapping[str, Any] | None
|
Optional mapping recording the dataset, source URL, licence, patch order, and interpolation. Copied to a plain dict. |
None
|
Notes
Reflectance is a material property, not a radiance. It must be multiplied
by an illuminant SPD and integrated against colour-matching functions
before it describes what a camera sees; see
:func:camsim.scene.illuminants.reference_lab.
load_colorchecker ¶
load_colorchecker() -> ColorCheckerData
Load the 24 ColorChecker Classic reflectance spectra.
Returns:
| Type | Description |
|---|---|
ColorCheckerData
|
Spectra from the BabelColor average dataset distributed with
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the installed dataset does not expose the expected 24 patches in the documented chart order. Failing loudly prevents a layout mismatch from silently swapping patch colours. |
Notes
The dataset is read at runtime rather than vendored; no CSV copy is
committed. See THIRD_PARTY.md for the BabelColor permission notice and
the BSD-3-Clause packaging licence.
Deterministic spectral glyph targets for machine-vision experiments.
A glyph target is an image-plane synthetic scene, not a three-dimensional
object: a character is painted as dark ink on light paper, and each material is
described by a spectral reflectance factor :math:\rho(\lambda), a
dimensionless property in [0, 1] sampled on the common 360-830 nm, 5 nm
grid shared by the rest of the scene layer. A FreeType coverage mask
:math:m(x, y) \in [0, 1] from an antialiased TrueType raster combines the two
materials by linear mixture::
rho(x, y, lambda) = paper(lambda)
+ (ink(lambda) - paper(lambda)) * m(x, y)
The mixture is justified because a sensor pixel integrates the reflected flux
over its finite area: a pixel half-covered by ink reflects half the paper flux
and half the ink flux. The mask is therefore a coverage fraction, not a
display-space intensity, and must never be gamma-encoded before it multiplies
reflectance. Reflectance is not colour either; it becomes sensor-plane
irradiance only after multiplication by the scene illuminant and the lens
formula in :mod:camsim.scene.targets.
Rendering and oversampling
Glyphs are rasterised on a grid oversampling times finer than the sensor in
each axis (four by default) so that thin strokes, diagonals, and the
antialiased edge itself carry sub-pixel geometry. The returned spectral cube is
on that oversampled grid; the downstream scene renderer box-averages it to the
sensor grid together with the PSF. Rendering at the sensor grid instead aliases
stroke edges into the passband, and nearest-neighbour rasterisation removes the
partial coverage that makes a glyph's edge look like a real lens image rather
than a binary stencil.
Randomization and determinism
Each glyph gets a seeded random font size (50-70 % of the shorter frame side),
position (inside a margin that keeps the rotated glyph in frame), and rotation
in [-10, +10] degrees; the rotation bound models a printed sheet or a
handheld capture that is not perfectly aligned, while larger tilts are outside
the intended OCR scenario. Passing rotation_deg overrides the random draw
while keeping the size and position stream unchanged. All randomness comes from
numpy.random.default_rng seeded per sample: there is no global RNG, so
repeated renders are bit-identical for a fixed Pillow/FreeType build. The
dataset additionally derives train and test samples from separate seed streams
and encodes the split in the sample-seed parity, which makes the two seed sets
disjoint by construction rather than merely unlikely to collide.
Default spectra and provenance
The default paper and ink are the measured ColorChecker Classic white
("white 9.5 (.05 D)") and black ("black 2 (1.5 D)") patches loaded by
:func:camsim.scene.spectral_data.load_colorchecker. The white patch's
measured deep-violet roll-off (about 0.19 at 360 nm) is a real optical-
brightener property of the chart, not a rendering defect. Reflectances are
documented in :class:GlyphDataset.provenance, including the BabelColor
dataset and the rasteriser/font used. Custom spectra are accepted only when
they are one-dimensional, finite, in [0, 1], and sampled on the same
95-wavelength grid.
Real-world parameter ranges: glyph bodies in OCR datasets span roughly 20-60 sensor pixels, printed paper reflectance is 0.80-0.95, black ink reflectance is 0.02-0.05, and page skew of a few degrees is common. Fonts vary widely; this layer fixes DejaVu Sans (the font bundled with Matplotlib) so class shapes are reproducible across machines.
Failure artefacts guarded here: a font size that exceeds the frame clips the glyph; a rotation beyond the documented bound can leave the frame; rendering without antialiasing yields binary edges and inflates apparent task contrast; applying the mask after gamma encoding produces the wrong ink density; spectra of the wrong length silently shift the colour or fail far downstream; using a global RNG makes two nominally identical datasets differ; and storing a full spectral cube per sample costs tens of megabytes for a toy dataset, so the dataset stores float16 masks and materialises cubes on demand.
GLYPH_CLASSES
module-attribute
¶
GLYPH_CLASSES: Final[tuple[str, ...]] = tuple(
"0123456789"
) + tuple("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
The 36 OCR classes: digits 0-9 followed by uppercase letters A-Z.
DEFAULT_PAPER_PATCH
module-attribute
¶
DEFAULT_PAPER_PATCH: Final[str] = 'white 9.5 (.05 D)'
ColorChecker patch used as the default paper reflectance.
DEFAULT_INK_PATCH
module-attribute
¶
DEFAULT_INK_PATCH: Final[str] = 'black 2 (1.5 D)'
ColorChecker patch used as the default ink reflectance.
MAX_ROTATION_DEG
module-attribute
¶
MAX_ROTATION_DEG: Final[float] = 10.0
Largest absolute glyph rotation, in degrees, allowed or drawn at random.
GlyphDataset
dataclass
¶
GlyphDataset(
masks: NDArray[float16],
classes: NDArray[int64],
chars: tuple[str, ...],
seeds: NDArray[int64],
rotations: NDArray[float64],
paper_spectra: NDArray[float64],
ink_spectra: NDArray[float64],
shape: tuple[int, int],
split: GlyphSplit,
oversampling: int,
provenance: dict[str, Any] = dict(),
)
Memory-efficient deterministic collection of rendered glyph samples.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
masks
|
NDArray[float16]
|
|
required |
classes
|
NDArray[int64]
|
|
required |
chars
|
tuple[str, ...]
|
|
required |
seeds
|
NDArray[int64]
|
|
required |
rotations
|
NDArray[float64]
|
|
required |
paper_spectra
|
NDArray[float64]
|
|
required |
ink_spectra
|
NDArray[float64]
|
|
required |
shape
|
tuple[int, int]
|
Sensor-grid |
required |
split
|
GlyphSplit
|
|
required |
oversampling
|
int
|
Integer samples per sensor pixel per axis used for every mask. |
required |
provenance
|
dict[str, Any]
|
Mapping recording the font, rasteriser, default ColorChecker spectra, rotation bounds, and seed scheme. |
dict()
|
Notes
A full spectral cube for n=20 samples at 64 x 64 sensor pixels and four
samples per pixel is about 1 GB in float64; the stored masks are a few
megabytes. :meth:materialize builds a cube only when a consumer needs
one.
materialize ¶
materialize(index: int) -> NDArray[np.float64]
Build one sample's spectral reflectance cube on demand.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
index
|
int
|
Integer sample index in |
required |
Returns:
| Type | Description |
|---|---|
A fresh float64 ``(H * oversampling, W * oversampling, L)`` cube. The
|
|
returned array is not shared with the dataset and may be modified.
|
|
render_glyph_mask ¶
render_glyph_mask(
char: str,
shape: tuple[int, int],
seed: int,
rotation_deg: float | None = None,
oversampling: int = _DEFAULT_OVERSAMPLING,
) -> NDArray[np.float64]
Return a glyph's antialiased coverage mask on the oversampled grid.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
char
|
str
|
One of :data: |
required |
shape
|
tuple[int, int]
|
Sensor-grid |
required |
seed
|
int
|
Non-negative integer seed. It selects the font size, position, and, unless overridden, the rotation. No global random state is used. |
required |
rotation_deg
|
float | None
|
|
None
|
oversampling
|
int
|
Positive integer samples per sensor pixel per axis. Four is the conventional rendering factor; one disables oversampling. |
_DEFAULT_OVERSAMPLING
|
Returns:
| Type | Description |
|---|---|
A float64 ``(H * oversampling, W * oversampling)`` array in ``[0, 1]``.
|
|
Values strictly between zero and one are antialiased edge coverage; the
|
|
glyph interior is one and the paper background is zero.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the character, shape, seed, rotation, or oversampling is invalid. |
RuntimeError
|
If the font or a non-empty glyph bitmap is unavailable at the drawn size, which indicates a broken font installation rather than bad input. |
render_glyph ¶
render_glyph(
char: str,
shape: tuple[int, int],
seed: int,
paper_spectrum: ArrayLike | None = None,
ink_spectrum: ArrayLike | None = None,
rotation_deg: float | None = None,
oversampling: int = _DEFAULT_OVERSAMPLING,
) -> NDArray[np.float64]
Render one glyph as a spectral reflectance cube on the oversampled grid.
The antialiased coverage mask m mixes the two material spectra:
rho = paper + (ink - paper) * m. The mapping is performed in linear
reflectance space; no display encoding or tone curve belongs here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
char
|
str
|
One of :data: |
required |
shape
|
tuple[int, int]
|
Sensor-grid |
required |
seed
|
int
|
Non-negative rendering seed (font size, position, rotation). |
required |
paper_spectrum
|
ArrayLike | None
|
Optional reflectance on the common 360-830 nm, 5 nm grid. |
None
|
ink_spectrum
|
ArrayLike | None
|
Optional reflectance on the same grid. |
None
|
rotation_deg
|
float | None
|
|
None
|
oversampling
|
int
|
Positive integer samples per sensor pixel per axis. |
_DEFAULT_OVERSAMPLING
|
Returns:
| Type | Description |
|---|---|
A float64 ``(H * oversampling, W * oversampling, L)`` cube of reflectance
|
|
factors in ``[0, 1]`` on :data:`camsim.scene.spectral_data.COMMON_WAVELENGTH_GRID`.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the character, shape, seed, spectra, rotation, or oversampling is invalid, including spectra that are not sampled on the common grid. |
glyph_dataset ¶
glyph_dataset(
n: int,
seed: int,
shape: tuple[int, int],
split: GlyphSplit = "train",
) -> GlyphDataset
Generate a deterministic, memory-efficient glyph classification dataset.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n
|
int
|
Number of samples; at least one. |
required |
seed
|
int
|
Non-negative base seed for the dataset. Train and test splits derive independent streams from it, so calling both splits with the same base seed does not duplicate samples. |
required |
shape
|
tuple[int, int]
|
Sensor-grid |
required |
split
|
GlyphSplit
|
|
'train'
|
Returns:
| Type | Description |
|---|---|
GlyphDataset
|
Float16 masks plus class labels, characters, seeds, rotations, and
per-sample paper/ink spectra. No spectral cube is stored; call
:meth: |
Notes
Classes are drawn from a seeded permutation of all 36 classes and cycled
when n exceeds 36, so small datasets are balanced and larger ones stay
deterministic. A sample's mask, rotation, and seed are exactly the values
:func:render_glyph_mask and :func:render_glyph produce for that seed
and recorded rotation, up to float16 mask quantization.
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Optics¶
Lens geometry, point-spread functions, propagation, and vignetting.
Reference lens geometry and on-axis spectral transmission.
A lens performs two jobs that this module exposes separately. Geometrically it
maps field angle to sensor position: a focal length :math:f images a sensor
diagonal :math:d over the diagonal field of view :math:2\arctan(d / 2f),
and a pixel of pitch :math:p subtends :math:\arctan(p / f) radians at the
rear principal plane. The reciprocal of that angle in degrees is the
pixels-per-degree scale used to express MTF in cycles/degree. For the reference
phone lens (6.4 mm focal length, 11.46 mm diagonal, 1.12 um pitch) the field is
about 84 degrees and the scale about 100 pixels/degree, which is why phone MTF
curves in cycles/degree look much better than the same curves in
cycles/pixel.
Radiometrically a lens transmits only part of the incident flux. Anti-reflection coatings on modern phone optics transmit roughly 85-98 % on axis over the visible band, falling towards the blue and near infrared; a bare glass surface loses about 4 % per air-glass interface. The configuration stores either a broadband scalar or a CSV path to a measured spectral curve. This module resolves the path and interpolates the curve; it does not integrate the curve against a scene spectrum, which belongs to the propagation stage.
Real-world parameter ranges: f-numbers from f/0.7 (specialist low-light optics) to f/22 (diffraction-dominated), focal lengths from about 1.5 mm (ultra-wide phone) to hundreds of millimetres (telephoto), and sensor diagonals from 1/6-inch (3 mm) to medium format (55 mm). Transmission curves are usually tabulated from 350-1000 nm at 1-10 nm spacing.
Failure artefacts guarded here: a zero or negative f-number is unphysical and
the configuration schema rejects it; a transmittance above one would invent
energy; a curve with non-increasing wavelengths mislabels every sample; a
transmittance outside [0, 1] almost always means the file stores percent
rather than a fraction; and a missing or malformed CSV must raise instead of
silently falling back to full transmission, which would make every low-light
comparison optimistic.
LensConfig ¶
Bases: ConfigModel
Reference lens geometry and transmission.
Attributes:
| Name | Type | Description |
|---|---|---|
f_number |
PositiveFloat
|
Relative aperture |
focal_length_mm |
PositiveFloat
|
Actual (not full-frame-equivalent) focal length. |
sensor_diagonal_mm |
PositiveFloat
|
Active sensor diagonal used for the field-of-view calculation. |
transmittance |
Annotated[float, Field(gt=0.0, le=1.0)] | NonEmptyStr
|
Scalar on-axis transmission in |
vignetting |
Fraction | None
|
Optional strength in |
Notes
Derived quantities are properties rather than stored fields so they can never disagree with the geometry. The reference phone lens (f/1.8, 6.4 mm actual focal length, 11.46 mm diagonal, T = 0.9) sees about 84 degrees diagonally and about 100 pixels per degree at a 1.12 um pitch.
field_of_view_deg
property
¶
field_of_view_deg: float
Diagonal field of view in degrees, 2 atan(d / (2 f)).
pixels_per_degree ¶
pixels_per_degree(pixel_pitch_um: float) -> float
Return how many pixels of pixel_pitch_um span one degree.
A pixel subtends atan(pitch / f) radians at the lens centre. This
small-angle conversion turns cycles/pixel MTF values into
cycles/degree, the unit in which human acuity and object detection are
usually discussed.
transmittance_at ¶
transmittance_at(
lens: LensConfig, wavelength_nm: float
) -> float
Return the lens's on-axis transmittance at wavelength_nm.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lens
|
LensConfig
|
A validated :class: |
required |
wavelength_nm
|
float
|
Query wavelength in nanometres; must be finite and positive. |
required |
Returns:
| Type | Description |
|---|---|
The dimensionless transmittance in ``[0, 1]``.
|
|
A negative or non-finite wavelength has no physical meaning, and an
|
|
out-of-band query without endpoint clamping would require extrapolating a
|
|
coating curve beyond its measured range, inventing performance the
|
|
datasheet does not support.
|
|
Sampled point-spread functions and their modulation transfer functions.
A point-spread function (PSF) is the normalized irradiance pattern that a lens and pixel form from an ideal point source. Three models cover the useful range:
- Gaussian. An aberration- and defocus-dominated blur, written
exp(-r**2 / (2 sigma**2))withrin pixels. A real lens at moderate apertures often measures close to Gaussian in the core;sigmaof 0.4-3 pixels spans a sharp phone lens to a visibly soft one. - Box. The pixel aperture itself. A square pixel of width
wpixels integrates light over a box, whose continuous transfer function issinc(w f)with its first zero atf = 1 / w. On the sampled grid the box is area-weighted across partially covered pixels so that non-integer widths remain meaningful and the kernel sums to one. - Airy. Diffraction from a circular pupil:
(2 J1(x) / x)**2withx = pi * r_px * pixel_pitch / (wavelength * f_number). The first zero ofJ1atx = 3.8317puts the first dark ring at the customaryr0 = 1.22 * wavelength * f_number / pixel_pitchpixels. A phone lens at f/1.8 and 550 nm with a 1.12 um pitch hasr0just above one pixel, so diffraction is already visible at the pixel scale; stopping down to f/8 puts the first zero near five pixels and dominates the MTF.
The modulation transfer function is the magnitude of the normalized Fourier
transform of the PSF. psf_mtf evaluates the exact discrete-time transform
at arbitrary frequencies rather than interpolating an FFT grid, which keeps
MTF50 accurate: with a two-dimensional kernel, summing along one axis gives
the projection whose one-dimensional transform is the axis cut of the 2-D OTF.
Frequencies are in cycles/pixel and therefore cannot exceed the Nyquist limit
of 0.5. psf_mtf50 reports where that curve first crosses one half, the
standard single-number sharpness metric; for a unit-sigma Gaussian the
analytic value is sqrt(ln 2 / (2 pi**2)) = 0.18739 cycles/pixel.
Real-world ranges: phone lenses reach MTF50 of roughly 0.2-0.4 cycles/pixel at the image centre; diffraction-limited systems at f/8 fall below 0.1. Astigmatic or misaligned lenses are not rotationally symmetric, which is why every function here returns a full 2-D kernel rather than a radial profile.
Failure artefacts guarded here: an unnormalized kernel changes image brightness; a non-positive sigma or f-number has no physical meaning; negative or zero-sum kernels are not radiometric PSFs; truncating a wide Gaussian or Airy core into too small a grid discards tails that renormalization then redistributes, biasing MTF high and adding aliasing/oscillation; frequencies beyond Nyquist alias and can make MTF appear to rise again; and a kernel whose MTF never crosses 0.5 lacks a finite MTF50.
gaussian_psf ¶
gaussian_psf(
sigma_px: float, size: int | None = None
) -> NDArray[np.float64]
Return a normalized, sampled Gaussian PSF.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sigma_px
|
float
|
Standard deviation in pixels; must be finite and positive. At the
reference 1.12 um pitch, |
required |
size
|
int | None
|
Odd or even side length of the square kernel. |
None
|
Returns:
| Type | Description |
|---|---|
A ``(size, size)`` float64 kernel whose sum is exactly one (up to
|
|
floating-point round-off).
|
|
A non-positive or non-finite sigma describes no physical blur; a sigma
|
|
larger than roughly ``size / 6`` is truncated by the grid, and
|
|
renormalizing the remaining core biases MTF high.
|
|
box_psf ¶
box_psf(
width_px: float, size: int | None = None
) -> NDArray[np.float64]
Return a normalized square pixel-aperture (box) PSF.
The box of width_px pixels is centred on the array and its overlap with
each pixel cell is integrated, so fractional widths spread weight across
the partially covered pixels instead of being thresholded. That makes the
discrete kernel a sampled continuous box and keeps its transfer function
close to |sinc(width_px * f)| up to the first zero.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
width_px
|
float
|
Aperture width in pixels; must be finite and positive and may not
exceed |
required |
size
|
int | None
|
Side length of the square kernel. |
None
|
Notes
Boxes wider than one pixel produce a sampled transfer function close to
|sinc(width_px * f)|. A one-pixel box exactly covers one pixel centre,
so it samples to a delta kernel and its continuous sinc(f) response is
applied by the pixel integration itself, not by this sampled kernel.
airy_psf ¶
airy_psf(
f_number: float,
wavelength_nm: float,
pixel_pitch_um: float,
size: int | None = None,
) -> NDArray[np.float64]
Return a normalized, sampled Airy (diffraction-limited) PSF.
The intensity profile is (2 J1(x) / x)**2 where
x = pi * r_px * pixel_pitch / (wavelength * f_number), with all lengths
in the same units. Since J1 first vanishes at x = 3.8317, the first
dark ring lies at r0 = 3.8317 / pi * wavelength * f_number / pitch,
conventionally rounded to 1.22 * wavelength * f_number / pitch pixels.
Wavelength is converted from nanometres to micrometres to match the pitch,
both lengths being measured at the sensor.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
f_number
|
float
|
Relative aperture |
required |
wavelength_nm
|
float
|
Free-space wavelength in nanometres; must be finite and positive. 550 nm is the photopic peak used for reference calculations. |
required |
pixel_pitch_um
|
float
|
Sensor pixel pitch in micrometres; must be finite and positive. |
required |
size
|
int | None
|
Side length of the square kernel. |
None
|
Returns:
| Type | Description |
|---|---|
A ``(size, size)`` float64 kernel normalized to unit sum.
|
|
The Airy pattern carries a finite fraction of its energy outside any
|
|
truncation, so normalization conserves the captured energy rather than the
|
|
infinite-plane total; renormalizing a grid much smaller than a few
|
|
first-zero radii removes the faint rings, biasing MTF high and introducing
|
|
aliasing/oscillation.
|
|
psf_mtf ¶
psf_mtf(
psf: ArrayLike, frequencies_cp: ArrayLike
) -> NDArray[np.float64]
Return the modulation transfer function of psf at given frequencies.
The OTF at (fx, fy) is the normalized discrete Fourier transform of the
kernel evaluated exactly at that frequency. Summing the 2-D kernel along
one axis produces the projection whose 1-D transform is the fy = 0 cut
of the OTF, which is the conventional one-dimensional MTF for a
rotationally symmetric or square-separable PSF. Evaluating the sum directly
avoids FFT-grid interpolation error.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
psf
|
ArrayLike
|
A two-dimensional, finite, non-negative kernel with positive sum. |
required |
frequencies_cp
|
ArrayLike
|
One-dimensional frequencies in cycles/pixel in |
required |
Returns:
| Type | Description |
|---|---|
An array with the same shape as ``frequencies_cp``; the zero-frequency
|
|
value is exactly one after DC normalization.
|
|
psf_mtf50 ¶
psf_mtf50(psf: ArrayLike) -> float
Return the first frequency where the PSF's MTF crosses one half.
The crossing is located on a dense [0, 0.5] cycles/pixel grid and
refined by linear interpolation, which is accurate because MTF is smooth
near 0.5 for the kernels in this module. Airy and box MTFs oscillate and
can cross 0.5 more than once; the first crossing is the conventional
MTF50.
Raises:
| Type | Description |
|---|---|
ValueError
|
If the MTF never falls to 0.5 by Nyquist, as for a single-pixel delta whose MTF is one at every sampled frequency. |
PSF propagation and pixel-aperture downsampling for spectral image cubes.
A lens does not create or destroy light. For a point-spread function
:math:h(x, y) normalized to unit integral, the image irradiance is the
convolution E_img = E_obj * h, and integrating over the image plane returns
the object-plane total. The discrete implementation must preserve that
statement: the kernel is renormalized to unit sum, and the frame border is
extended by reflection (half-sample symmetric, so the edge sample is repeated)
rather than padded with zeros. Zero padding absorbs a fraction of the energy
proportional to the kernel area at the border; for a compact seven-pixel kernel
on a 256-pixel frame that is a few per cent, and it masquerades as a
field-dependent exposure error.
Convolution is performed as one FFT over all wavelength planes at once, with an
explicitly reflected pad so the result is equivalent to direct convolution with
reflecting boundaries. The kernel origin is the sample at index size // 2
in each axis, which is the exact centre for the odd-sized kernels returned by
:mod:camsim.optics.psf. An even-sized kernel has no single-sample centre:
index size // 2 is the second of its two central samples, so a delta kernel
there is an exact identity while a symmetric even-sized Gaussian splits its
response across the central pair (a half-pixel phase). References that need
sub-pixel centring should therefore use odd kernel sizes.
The second operation models the pixel aperture. A sensor pixel integrates
irradiance over its footprint, so rendering a scene on a grid that is N
times finer than the sensor and averaging each N x N block gives the
sensor-plane sample. The block mean preserves per-unit-area irradiance; a block
sum would scale the image by N**2. The factor must divide both spatial
dimensions exactly because a partial block would represent a fractional pixel
area, which the scalar-pitch frame metadata cannot describe.
Real-world sampling factors are 1 (native render), 2-4 (typical anti-aliasing for MTF and edge measurements), and up to 8 for diffraction-limited or very sharp systems. Kernel support is usually 5-31 pixels; a Gaussian is truncated near three sigma, and an Airy pattern at several first-zero radii.
Failure artefacts guarded here: an unnormalized kernel changes image
brightness; zero or wrap padding introduces a border brightness ramp or
teleports energy across the frame; a non-reflecting downsample edge leaves a
partial-block seam; summing instead of averaging multiplies every irradiance
by the block area; and non-finite, negative, or zero-sum kernels are not
radiometric PSFs. FFT round-off can leave values near -1e-17 in an
otherwise non-negative cube; those round-off artefacts are clipped so the cube
can be stored as a :class:~camsim.core.spectral.SpectralIrradiance.
apply_psf ¶
apply_psf(
cube: ArrayLike, psf: ArrayLike
) -> NDArray[np.float64]
Convolve an image or spectral cube with a 2-D point-spread function.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cube
|
ArrayLike
|
A 2-D |
required |
psf
|
ArrayLike
|
A 2-D, finite, non-negative kernel with positive sum. It is normalized
to unit sum internally, so a PSF defined up to scale behaves
identically. The origin is the sample at index |
required |
Returns:
| Type | Description |
|---|---|
A float64 array with the same shape as ``cube``. For non-negative input,
|
|
values are guaranteed non-negative (FFT round-off below zero is clipped);
|
|
signed input is left signed.
|
|
Notes
The border is extended by half-sample symmetry (reflection that repeats the edge value), so a constant field stays exactly constant for any normalized kernel. For a symmetric kernel, total energy is conserved at the border to floating-point precision; an asymmetric kernel's reflected wrap can change the total by a fraction of a per cent, within the 1 % energy budget. A kernel wider than the image is legal: reflection is repeated as needed, and the output keeps the input shape.
Real images are usually convolved once with a kernel sampled on the same
grid. When a scene is rendered oversampled, the kernel must be sampled on
that oversampled grid too: a sensor-grid Gaussian of sigma pixels is
passed as gaussian_psf(sigma * oversampling).
box_downsample ¶
box_downsample(
cube: ArrayLike, factor: int
) -> NDArray[np.float64]
Average factor x factor blocks to model pixel-aperture integration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cube
|
ArrayLike
|
A 2-D |
required |
factor
|
int
|
Positive integer block size. Both spatial dimensions must be exact multiples of it. |
required |
Returns:
| Type | Description |
|---|---|
A float64 array of shape ``(H / factor, W / factor)`` or
|
|
``(H / factor, W / factor, L)``. Each output sample is the arithmetic mean
|
|
of its block, so per-unit-area irradiance and the image mean are preserved.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
A partial block at the frame edge would cover a fractional pixel area that
|
|
the scalar-pitch ``SpectralIrradiance`` metadata cannot represent, and it
|
|
would bias edge pixels towards whatever padding was used, so non-divisible
|
|
shapes are rejected rather than silently cropped.
|
|
Radial cos-fourth natural vignetting for a thin lens.
Natural vignetting is geometric, not a defect of the glass. A pixel at image
height :math:r from the optical axis receives rays at field angle
:math:\theta = \arctan(r / f), where :math:f is the focal length. The
oblique bundle is projected onto the image plane at an angle, its entrance
pupil appears foreshortened, and the distance from the exit pupil grows; the
product of those effects for a thin lens is the familiar cos**4(theta)
illumination falloff::
V(r) = cos**4(theta) = 1 / (1 + (r / f)**2)**2
normalized to one on axis. At the corner of a 1/1.3-inch sensor
(:math:r \approx 5.7 mm) behind a 6.4 mm phone lens, r / f is close to
0.9 and only about 31 % of the axial irradiance remains, roughly 1.7 stops.
Real lenses often fall off faster still because of pupil aberration and
microlens crosstalk, which is why lens-shading correction is calibrated from a
flat field rather than trusted from this analytic curve.
LensConfig.vignetting is a strength in [0, 1]. A value of one applies
the full physical curve, zero (or None) disables the falloff, and
intermediate values blend linearly::
map = 1 - strength * (1 - cos**4(theta))
The blend is useful for modelling a lens whose shading has already been calibrated out by the ISP: disabling the scene-level falloff and leaving the correction to the flat-field stage avoids applying it twice.
Real-world parameter ranges: focal lengths 1.5-50 mm for phone and machine vision lenses, pixel pitches 0.6-10 um, and corner falloff from a few per cent for a long telephoto to more than two stops for an ultra-wide phone lens.
Failure artefacts guarded here: using object-space field angle instead of
image-space angle; passing pixel indices where physical radius is required;
forgetting that an oversampled render has a proportionally smaller sample pitch
(the map then collapses towards one); applying cos**4 once here and once
more in the lens formula; using the full sensor diagonal as the pixel radius;
and assuming an exact V = 1 central sample when an even-sized grid has its
optical centre between pixels.
vignetting_map ¶
vignetting_map(
shape: tuple[int, int],
lens: LensConfig,
pixel_pitch_um: float,
) -> NDArray[np.float64]
Return the radial illumination falloff map in [0, 1].
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
shape
|
tuple[int, int]
|
|
required |
lens
|
LensConfig
|
A validated :class: |
required |
pixel_pitch_um
|
float
|
Sampling pitch of the grid in micrometres. For a scene rendered with
oversampling |
required |
Returns:
| Type | Description |
|---|---|
A float64 ``(height, width)`` map, one at the optical centre and falling
|
|
radially outwards. The map multiplies scene irradiance; it is not a gain
|
|
map for lens-shading correction.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the shape is not two positive dimensions or the pitch is not finite and positive. |
Sensor¶
The sensor turns sensor-plane spectral irradiance into a self-describing
RawFrame. The package re-exports its public helpers.
Physically based sensor model from spectral irradiance to RAW frames.
The subpackage layers the sensor from the outside in:
- :mod:
camsim.sensor.cfa— CFA tiling and dimensionless spectral projection matrices from QE, IR-cut, and colour-filter curves. - :mod:
camsim.sensor.noise— Poisson shot/dark sampling, DSNU/PRNU, and row noise. - :mod:
camsim.sensor.defects— dead, hot, and stuck pixel maps plus PDAF layouts. - :mod:
camsim.sensor.gain— full-well clipping, conversion gain, and analog gain. - :mod:
camsim.sensor.adc— quantization, black-level pedestal, and digital gain. - :mod:
camsim.sensor.binning— analog charge summation and digital code averaging. - :mod:
camsim.sensor.sensor— :class:Sensorand the non-ideality helpers (crosstalk and blooming) that tie the chain together.
Importing this package does not import the ISP, scene, optics, or metrics layers; the sensor is usable with an externally supplied spectral cube.
BinningPlan
dataclass
¶
BinningPlan(
factor: int,
mode: BinningMode,
input_shape: tuple[int, int],
output_shape: tuple[int, int],
)
Resolved geometry for one binning configuration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
factor
|
int
|
Linear binning factor; one output pixel spans |
required |
mode
|
BinningMode
|
|
required |
input_shape
|
tuple[int, int]
|
Native |
required |
output_shape
|
tuple[int, int]
|
Binned |
required |
Notes
pixel_pitch_scale is the factor by which the output pixel pitch grows;
the sensor writes pixel_pitch_um * factor into the RAW frame.
pixel_pitch_scale
property
¶
pixel_pitch_scale: int
Return the output pitch multiplier (equal to factor).
Sensor ¶
Sensor(config: SensorConfig, seed: int = 0)
A deterministic sensor that converts spectral irradiance into RAW frames.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
SensorConfig
|
Validated :class: |
required |
seed
|
int
|
Non-negative seed of the frame stream used when |
0
|
Attributes:
| Name | Type | Description |
|---|---|---|
config |
The configuration object supplied to the constructor. |
|
pattern_seed |
The single seed used for DSNU/PRNU, defect, and PDAF maps, taken from
|
|
The first successful exposure fixes the native sensor grid (the cube's |
|
|
spatial shape). A later cube with another shape is rejected, because a |
|
|
real part has one pixel array and implicit resampling would silently |
|
|
change the radiometry. Fixed-pattern maps are generated lazily for that |
|
|
shape on first use and cached for the life of the sensor. |
|
frame_shape
property
¶
frame_shape: tuple[int, int] | None
Native (height, width) fixed by the first exposure, or None.
reseed ¶
reseed(seed: int) -> None
Reset the frame stream and capture index.
Fixed-pattern maps are unaffected because they derive from
config.defects.seed, not from the frame stream.
expose ¶
expose(
irradiance: SpectralIrradiance,
exposure_s: float,
mode: str = "auto",
analog_gain_db: float | None = None,
digital_gain: float = 1.0,
temperature_c: float | None = None,
seed: int | None = None,
*,
max_memory_bytes: int | None = None,
) -> RawFrame
Integrate one exposure and return a RAW frame.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
irradiance
|
SpectralIrradiance
|
Spectral irradiance at the sensor plane, before the sensor's own
IR-cut and CFA filters, on the sensor's pixel grid. Both
|
required |
exposure_s
|
float
|
Integration time in seconds, within the preset's exposure limits. |
required |
mode
|
str
|
|
'auto'
|
analog_gain_db
|
float | None
|
Programmable analog gain in decibels. |
None
|
digital_gain
|
float
|
Additional digital multiplier composed with the ADC preset's
|
1.0
|
temperature_c
|
float | None
|
Sensor temperature override in degrees Celsius. |
None
|
seed
|
int | None
|
Per-call frame seed. Supplying it does not advance the sensor's
frame stream, so the pixel data are independent of call order;
|
None
|
max_memory_bytes
|
int | None
|
Optional peak-memory budget for this exposure, in bytes. |
None
|
Returns:
| Type | Description |
|---|---|
RawFrame
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If any input is out of range, the cube is not on the sensor grid (shape, pitch, or wavelength grid), the binning factor does not divide the shape, the crosstalk matrix is incompatible with the CFA, a noise/gain parameter is non-finite, or the estimated peak memory exceeds the configured simulation budget. |
binning_plan ¶
binning_plan(
config: BinningConfig, shape: Sequence[int]
) -> BinningPlan
Validate a binning configuration against a native sensor shape.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
BinningConfig
|
Validated :class: |
required |
shape
|
Sequence[int]
|
Native |
required |
Returns:
| Name | Type | Description |
|---|---|---|
A |
class:`BinningPlan` whose output shape is the native shape divided by
|
|
the factor.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the configuration type is wrong, the shape is not two positive integers, the mode is unsupported, or the factor does not divide both dimensions. A partial block would aggregate unequal numbers of charge carriers and is rejected rather than padded. |
mean_blocks ¶
mean_blocks(
values: ArrayLike, factor: int
) -> NDArray[np.float64]
Average non-overlapping factor x factor blocks of a 2-D plane.
This is the digital-binning arithmetic applied to post-ADC codes (or to
any already-sampled plane). Averaging rather than summing keeps the signal
at the single-pixel scale; read noise is reduced by sqrt(factor**2).
reduce_mask ¶
reduce_mask(
mask: ArrayLike, factor: int
) -> NDArray[np.bool_]
Collapse a native boolean mask onto binned pixels by logical OR.
A binned pixel is defective or a PDAF site if any native pixel in its block is flagged. Dropping a flag because the majority of the block is healthy would hide a dead pixel from defect correction.
sum_blocks ¶
sum_blocks(
values: ArrayLike, factor: int
) -> NDArray[np.float64]
Sum non-overlapping factor x factor blocks of a 2-D plane.
This is the analog-binning arithmetic: charge means (or samples) from same-colour pixels are added before the read event. The output is float64 even for integer input so a caller can add a fractional overflow term.
apply_blooming ¶
apply_blooming(
electrons: ArrayLike,
full_well_e: float,
fraction: float,
iterations: int,
) -> NDArray[np.float64]
Clip charge to full well and redistribute a fraction of the overflow.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
electrons
|
ArrayLike
|
Finite 2-D charge in electrons, before the full-well clip. If the array has already been clipped, there is no overflow and the result is just a clip; this is why the sensor passes the raw Poisson sample. |
required |
full_well_e
|
float
|
Charge-domain saturation in electrons, finite and non-negative. |
required |
fraction
|
float
|
Fraction of the overflow passed to the four edge-sharing neighbours
each iteration, in |
required |
iterations
|
int
|
Non-negative number of diffusion passes. Zero disables spreading and returns only the full-well clip. |
required |
Returns:
| Type | Description |
|---|---|
A float64 array in ``[0, full_well_e]`` with the same shape. Every
|
|
overflowed pixel distributes ``fraction / 4`` of its excess to each
|
|
orthogonal neighbour; diagonal neighbours and wrap-around are not used.
|
|
Charge that would leave the array boundary is lost, and after the final
|
|
iteration any remaining overflow is clipped. No NaN can be produced.
|
|
Notes
A pinned photodiode suppresses blooming well, so the preset ships it off. Enabling it matters for CCD-like or older parts with high exposure, where a saturated highlight bleeds into a cross-shaped halo. The function is deterministic: no random stream is involved.
apply_spatial_crosstalk ¶
apply_spatial_crosstalk(
mosaic: ArrayLike, kernel: ArrayLike
) -> NDArray[np.float64]
Convolve a mosaic with a normalised spatial charge-diffusion kernel.
Optical and electrical crosstalk moves charge between neighbouring pixels with the same CFA periodicity. The kernel should be non-negative and sum to one so total charge is conserved; a kernel that does not is rejected rather than rescaled, because silently normalising it would hide a calibration error in the caller's data.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mosaic
|
ArrayLike
|
Finite 2-D array in linear sensor units (electrons or DN). |
required |
kernel
|
ArrayLike
|
Finite, non-negative 2-D kernel whose sum is one. Odd-sized, centred kernels are typical (for example a normalised 3x3 diffusion stencil). |
required |
Returns:
| Type | Description |
|---|---|
A float64 array the same shape as ``mosaic``. Boundaries use mirror
|
|
reflection, which keeps the result finite and avoids treating the frame
|
|
edge as a black surround. Charge that a wide kernel would carry beyond the
|
|
edge can still change the frame total, so a spatial-crosstalk calibration
|
|
should use an interior region for gain checks.
|
|
Notes
:meth:Sensor calls this function once per CFA channel plane when
SensorConfig.spatial_crosstalk is configured. It remains public because
a caller may have a measured kernel outside the preset. Spectral crosstalk
is a colour-mixing matrix and belongs before mosaicing; spatial crosstalk is
a same-colour convolution and therefore also belongs on the channel planes,
not on the mosaicked frame.
apply_spectral_crosstalk ¶
apply_spectral_crosstalk(
channels: Mapping[str, ArrayLike], matrix: ArrayLike
) -> dict[str, NDArray[np.float64]]
apply_spectral_crosstalk(
channels: ArrayLike, matrix: ArrayLike
) -> NDArray[np.float64]
apply_spectral_crosstalk(
channels: Mapping[str, ArrayLike] | ArrayLike,
matrix: ArrayLike,
) -> dict[str, NDArray[np.float64]] | NDArray[np.float64]
Mix per-colour channel planes with a row-normalised crosstalk matrix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
channels
|
Mapping[str, ArrayLike] | ArrayLike
|
Either a mapping of CFA colour to a 2-D plane ( |
required |
matrix
|
ArrayLike
|
Square, finite, row-normalised mixing matrix. Each row sums to one so a neutral input stays neutral. Total channel charge is conserved only when the matrix is also column-normalised (doubly stochastic); a row-normalised matrix alone can change the channel sum. The diagonal is the same-channel fraction; off-diagonal entries model leakage from neighbouring filters. |
required |
Returns:
| Type | Description |
|---|---|
A mapping (for mapping input) or an array (for array input) of float64
|
|
planes with the same shape as the input. Output channel ``i`` is
|
|
``sum_j matrix[i, j] * input[j]``.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the matrix is not square or finite, any row does not sum to one, the channel count does not match the matrix dimension, or a channel plane is missing, non-2-D, or non-finite. |
Notes
Row normalisation is checked here as well as in the preset schema, because this function is public and a caller can construct a matrix directly. An unnormalised matrix would scale total charge and create a colour cast that no white balance can separate from the illuminant.
ISP¶
The fixed RAW-to-rendered-image pipeline and its individual linear-space stages. See ISP stages for the order, invariants, and trace contract.
Linear-space ISP stages and the unified RAW-to-rendered pipeline.
Each module implements one operation of the fixed reconstruction chain
DPC -> BLC -> LSC -> AWB -> demosaic -> CCM -> denoise -> sharpen -> tone:
defective-pixel correction, black-level correction, lens-shading correction,
white balance, demosaicing, colour correction, denoise, sharpening, and the
final sRGB/Display P3 transfer. Every stage through sharpening accepts and
returns linear float32; only :mod:camsim.isp.tone is non-linear.
The integrated pipeline lives in :mod:camsim.isp.pipeline and is exported as
:class:camsim.ISP. This package is deliberately lazy: importing it executes
only this docstring and loads no stage modules, so import camsim.metrics
stays independent of the sensor and ISP layers and import camsim stays
cheap. Individual stages remain importable on their own for analysis,
calibration, and the gallery notebooks.
The unified ISP pipeline: RAW sensor codes to a display-referred image.
The pipeline is the reconstruction chain that turns a sensor's quantized readout into something a viewer, a metric, or a machine-vision model can use::
RAW --DPC--> BLC --> LSC --> AWB --> demosaic --> CCM
--> denoise --> sharpen --> tone --> display-referred output
Each arrow is a stage whose physics and numerics are documented in its own module. This module owns only what no individual stage can own: the enforced order, the applicability rules for each CFA layout, the optional trace, the final linear/display buffer pair, and the provenance metadata attached to the rendered image.
Why the order matters. The ADC records DN = signal + black_level, so the
pedestal must come out before any multiplicative stage. A gain applied to the
pedestal turns a constant offset into a colour cast that no later diagonal
balance can remove, and lens shading computed on uncorrected codes is biased
low. White balance must run before demosaicing because its statistics are
per-CFA-channel samples; interpolating first and then balancing mixes
reconstructed channels whose noise is spatially correlated. The colour
correction matrix is a linear map between the camera's spectral basis and a
colorimetric basis, so it belongs in the linear domain after demosaicing and
before denoise and sharpen. Metamerism is the reason a single matrix is only an
approximation: different scene spectra can produce the same three camera
responses yet different tristimulus values, so a CCM fitted under one
illuminant cannot correct every spectrum exactly. Fitting is a training
operation and is deliberately out of scope here -- the pipeline applies a
matrix supplied in the configuration.
Units and normalisation. BLC is also the boundary between ADC codes and
normalised linear light. After subtracting the pedestal, the pipeline divides
by the white span (2**bit_depth - 1) - black_level_dn, so an ADC-saturated
pixel maps to exactly 1.0 and every later stage sees the [0, 1] linear
units its own documentation assumes. White-balance gains are ratios and are
unaffected by the common divisor; demosaic, CCM, denoise, sharpen, and tone all
become scale-appropriate. The division is not a clip: the sensor ADC has
already clipped at full scale, and linear_rgb is never clamped. Monochrome
and RCCC frames use the same divisor. Disabling BLC is a diagnostic bypass: the
pipeline then stays in raw code units, tone mapping saturates almost every
pixel, and the absolute denoise/sharpen thresholds no longer mean what their
documentation says.
Trace contract. Tracing is opt-in. With trace=True the returned
RenderedImage.stages is a dictionary whose insertion order is the pipeline
order. It contains one StageOutput for every executed stage, plus a final
"output" entry holding the rendered display array and the output contract
(color_space, channels, bit_depth). Skipped and inapplicable stages
are omitted, never recorded as identity snapshots: a stage that did not run
must not appear to have run. trace=False leaves stages as None so
large sweeps do not pay for a contact sheet. Stage snapshots are references to
the arrays the stages returned; every stage returns a fresh array, so a later
stage cannot mutate an earlier snapshot.
Applicability. A mono sensor has no colour filters: AWB, demosaicing, and
CCM are colour operations and are skipped, while DPC, BLC, LSC, denoise,
sharpen, and tone still apply. Mono stays two-dimensional through the whole
pipeline and gains its trailing channel axis only at the display boundary.
RCCC has one red and three clear sites; its AWB and CCM are deferred, so those
stages are skipped and the demosaic stage always uses the documented
"rccc_baseline" reconstruction, whose (R, C, C) output is a
shape/linearity placeholder rather than a colour-accurate image. A colour frame
(Bayer or RCCC) with demosaicing disabled is rejected: every downstream colour
stage and the three-channel rendered image require an RGB image, so mono is the
only supported no-demosaic path.
Tone and output consistency. config.output.color_space must equal
config.tone.color_space; the tone stage defines the primaries of both the
display array and the linear buffer. output.bit_depth is container
metadata only: RenderedImage.data is always float32 in [0, 1] and is
never requantized here. linear_rgb is the pre-clamp, pre-OETF linear image
in the output primaries (exposure compensation applied, then the sRGB-to-P3
matrix for DisplayP3, no clamp), so highlights may exceed one and colour
metrics can linearize the display buffer back to it. Disabling tone is a
diagnostic linear mode: it requires an sRGB output, keeps the unclipped linear
values in linear_rgb, and clips that buffer to [0, 1] only to satisfy
the display contract.
Real-world parameter ranges. Consumer sensors quantize to 8-16 bits; black levels are typically 64 DN at 10 bits and 256 DN at 12 bits. White-balance gains span roughly 0.5-4, colour matrices have diagonal entries near 0.5-1.5 and off-diagonals around -0.5-0.5, denoise radii 0.5-3 px, and sharpening amounts 0.2-1.5. Tone exposure trims run to a few stops either way. The pipeline is deterministic: no random state is read or written, and identical frames and configurations produce bit-identical outputs within one environment.
Failure artefacts guarded here. A pedestal left in place (a white-balance colour cast), a black level that leaves no positive white span (a division by zero), a missing CCM matrix (silent desaturation or a runtime fit with no reference), a colour frame without demosaicing (shape/colour inconsistency), mismatched tone and output colour spaces (a mislabelled buffer), P3 output with tone disabled (no defined primaries for the diagnostic buffer), reordered or identity-recorded stages (a trace that misrepresents the processing), mutating the caller's RAW data, accepting a non-RAW object as a frame, and attaching provenance that does not match the configuration actually applied.
ISP ¶
ISP(config: ISPConfig)
The enforced RAW-to-rendered-image pipeline for one configuration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
ISPConfig
|
A validated :class: |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Notes
The configuration is captured by reference and the provenance hash is
recomputed from its current state on every process call, so a mutation
before processing cannot leave a stale hash. Individual stage functions
re-validate mutated config values they consume, so an accidental mutation
fails loudly rather than running with an out-of-domain parameter.
config_hash
property
¶
config_hash: str
SHA-256 of the canonical JSON form of the current configuration.
The hash identifies the parameters that produced a rendered image and
is copied into RenderedImage.metadata["isp_config_hash"]. It is
computed on demand from the configuration's current state, so a
mutation after construction cannot leave a stale provenance value.
from_json
classmethod
¶
from_json(path: str | Path) -> Self
Build a pipeline from a JSON ISP preset on disk.
The file is read and validated through the production loader
(:func:camsim.config.io.load_isp), so a pipeline built from a preset
follows exactly the same validation path as one built programmatically.
Filesystem, JSON-syntax, and schema errors propagate unchanged.
process ¶
process(
raw: RawFrame, trace: bool = False
) -> RenderedImage
Run the configured ISP stages on one RAW frame.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
RawFrame
|
A self-describing :class: |
required |
trace
|
bool
|
When true, record a :class: |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
class:`~camsim.core.frames.RenderedImage` whose ``data`` is float32
|
|
in ``[0, 1]`` with shape ``(H, W, 3)`` for Bayer/RCCC output and
|
|
|
``(H, W, 1)`` for mono. BLC subtracts the pedestal and normalises the
|
|
|
result by the white span, so the linear stages run in ``[0, 1]`` units.
|
|
|
``linear_rgb`` is the matching pre-clamp, pre-OETF linear buffer
|
|
|
(unclipped), and ``metadata`` carries the ISP name and hash, the
|
|
|
sensor configuration hash, the colour space, channel layout, container
|
|
|
bit depth, exposure, pixel pitch, and the white-balance gains and
|
|
|
method (or ``None`` when AWB was skipped).
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Colour correction: mix sensor RGB into a target colour space.
A colour sensor's filters are broad and overlapping, and the three RAW channels are not a colorimetric basis. A camera's "red" channel also sees green light, its "green" sees red and blue, and its "blue" leaks red, so captured colours are desaturated and hue-shifted relative to what the eye sees. The ISP corrects this with a 3x3 linear mixing matrix, calibrated once against reference patches (typically a 24-patch ColorChecker) captured under a known illuminant and compared with their reference values::
target_rgb ~= camera_rgb @ M.T (equivalently, t = M @ c)
The matrix is applied per pixel in linear light, after demosaicing and before tone mapping. Diagonal entries are typically 0.5-1.5 and off-diagonal entries roughly -0.5 to 0.5; larger off-diagonals mean stronger spectral overlap, and therefore stronger noise amplification (see below). Rows usually sum to one, which keeps the neutral axis fixed: a neutral patch must stay neutral, or every grey in the scene acquires a cast.
Fitting row i of M is an independent ridged least-squares problem::
m_i = argmin ||C m_i - t_i||^2 + ridge * ||m_i - e_i||^2
where C is the (N, 3) camera patch matrix, t_i is column i of the
target patches, and e_i is row i of the identity. The prior is the
identity, not zero: the fit is biased toward "do nothing", which is the safe
answer when the data do not support a strong correction. ridge therefore
controls a bias-variance trade-off. The noise gain of an unregularised fit is
governed by the singular values of C. A patch set that weakly excites one
colour direction turns sensor or measurement noise into large coefficients
along that direction, and the resulting matrix amplifies chroma noise, most
visible in low-light shadows where signal-to-noise ratio is worst. Typical
ridge values run from about 1e-6 for clean, well-exposed calibrations to
1e-2 for noisy low-light data or a small patch set; 1e-4 is a sensible default.
Too much ridge drags every row toward the identity and desaturates colours,
which is the opposite failure.
When row_sum is true the fit imposes sum_j M[i, j] = 1 as an exact
equality constraint. The KKT system
[[C^T C + ridge I, 1], [1^T, 0]] [m_i; nu] = [C^T t_i + ridge e_i; 1]
is solved directly, so row sums are one to machine precision rather than to the
accuracy of a penalty approximation. For rank-3 camera data C^T C is already
positive definite, so an unconstrained fit needs no ridge to be nonsingular; the
row-sum constraint additionally makes some rank-2 problems uniquely solvable by
determining the missing direction, but it cannot rescue a normal matrix whose
null space contains a direction orthogonal to the constraint vector (the
all-ones vector). A singular normal-equation system -- for example every patch
recorded as the same colour -- raises instead of returning huge or non-finite
coefficients, whether or not the constraint is enabled.
Application is a matrix product in linear light followed by a clamp at zero::
out = max(rgb @ M.T, 0)
The clamp is the only non-linearity in the stage. It exists because a matrix with negative off-diagonal entries drives saturated or out-of-gamut colours below zero, and a negative radiance is not physical. Clipping does lose hue information in those pixels, so a matrix whose outputs depend heavily on clipping is a sign of an over-strong fit. Everything before the tone curve remains linear; applying a CCM to gamma-encoded data is a failure mode, because the transform then acts on a non-linear function of radiance.
Bounded exception to the linear-space invariant. The design says the CCM operates in linear float32 light. It does, but its non-negativity clamp means the stage is linear only on the in-gamut domain where every matrix output is non-negative. That exception is bounded and explicit: on in-gamut data the clamp is inactive and scaling the input scales the output exactly; on out-of-gamut data the clamp trades hue accuracy for physical non-negative radiance. The G3 linearity check uses an in-gamut frame and an explicitly measured pre-clamp minimum, and this is the only pre-tone stage allowed such a clamp.
Failure artefacts guarded or documented here: over-fitting a noisy or small patch set (large coefficients, chroma noise), negative RGB after the transform (clamped, at the cost of hue), a neutral shift when rows do not sum to one, over-regularisation desaturating colours, a singular or rank-deficient fit, and applying the matrix in the wrong domain or with the wrong convention.
The CIEDE2000 diagnostic in :func:evaluate_ccm interprets both camera RGB and
target RGB as linear sRGB under D65. Values are converted straight to XYZ
with no transfer function, and negatives are clipped for that calculation only.
Camera RGB is not actually colorimetric sRGB -- that is the very problem the
matrix corrects -- so absolute ΔE values are indicative, while the before/after
comparison is the usable signal.
Fitting accepts real numeric (N, 3) samples with N >= 3 and computes in
float64. Application accepts any real numeric array whose trailing axis is 3 --
a single (3,) colour, (N, 3) samples, or an (H, W, 3) image -- and
returns a new C-contiguous float32 array. Neither function mutates its inputs.
fit_ccm ¶
fit_ccm(
camera_rgb: ArrayLike,
target_rgb: ArrayLike,
ridge: float = 0.0001,
row_sum: bool = True,
) -> NDArray[np.float64]
Fit a ridge-regularised 3x3 colour-correction matrix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
camera_rgb
|
ArrayLike
|
|
required |
target_rgb
|
ArrayLike
|
|
required |
ridge
|
float
|
Non-negative finite ridge strength |
0.0001
|
row_sum
|
bool
|
When true (the default), impose |
True
|
Returns:
| Type | Description |
|---|---|
A new C-contiguous float64 ``(3, 3)`` matrix satisfying
|
|
``target_rgb ~= camera_rgb @ matrix.T``. When ``row_sum`` is true each row
|
|
sums to one to machine precision.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the samples are not 2-D |
Notes
The normal equations are (C^T C + lambda I) m_i = C^T t_i + lambda e_i;
the identity prior is what makes the shrinkage direction "toward no
correction". With the constraint, the KKT matrix is assembled and solved
directly, so the row sums do not depend on a penalty weight. All arithmetic
is float64 and no global random state is touched, so identical inputs give
bit-identical outputs within one environment.
Lens-shading correction: radial gain or a measured flat-field gain map.
Lens shading is the spatial falloff of image-plane illuminance. For a thin lens
the oblique ray bundle is foreshortened and farther from the exit pupil, giving
the natural cos**4(theta) law; pupil aberration, microlens crosstalk, and
mechanical vignetting usually make a real lens fall off faster still. At the
corner of a wide phone lens only 30-50 % of the axial irradiance may remain,
which is one to two stops of shading. Because the falloff multiplies the scene,
it is corrected in the linear RAW domain by multiplying each sample by a
position-dependent gain
g(y, x) = F_ref / F(y, x)
where F is a flat-field response measured through the same lens, CFA, and
sensor. The gain map is an illumination ratio, not a colour-balance operation:
AWB must still estimate the illuminant afterwards, and demosaicing and the
colour matrix must see a uniformly illuminated mosaic so their spatial
assumptions hold.
Two gain representations are supported. mode="radial" evaluates an even
polynomial in normalised radius,
g(r) = c0 + c1 r^2 + c2 r^4 + ...
with r equal to one at the farthest image corner from center. The
center is given as (row, column) pixel coordinates and defaults to the
geometric image centre; an even-sized image has its centre between pixels, so
the default half-pixel coordinate is deliberate. mode="flat_field" applies
a supplied positive 2-D gain map of the same shape as the image, or a 2x2
per-CFA-site tile (which requires the CFA pattern so the caller states the
layout explicitly). The radial model is convenient for analytic studies and the
flat-field map is the accurate choice for a real part, because it captures
non-radial microlens and mechanical effects the polynomial cannot.
:func:calibrate_lsc turns a flat-field frame into a gain map by dividing the
brightest black-level-corrected sample by every pixel. Normalising to the
brightest sample keeps every gain at or above one and avoids the ill-defined
"centre" of an even-sized array. A pedestal left in the flat field would
compress the measured ratios and bias the map toward one, so a
:class:~camsim.core.frames.RawFrame is black-level corrected automatically;
an array input is assumed to be pedestal-free. Known defect sites and hot pixels
should be corrected before calibration because a single bright outlier can set
the reference.
Real-world corner falloff spans a few per cent for a long telephoto to more than
two stops for an ultra-wide phone lens; typical radial fits use c0 near one
and c1 between about 0.1 and 0.6, with higher-order terms for strong
falloff. All gains are dimensionless and the stage returns float32 linear
values; no gamma or tone curve is applied, preserving the linear-space
invariant.
Failure artefacts guarded here: applying a correction twice (over-bright corners), calibrating on display-encoded data (the reciprocal of a non-linear curve is not a gain), leaving the ADC pedestal in the flat field, a map whose shape does not match the image (silent cropping or broadcasting), a non-positive or non-finite gain (sign flips or infinities), using the wrong radial centre or coefficient order (a tilt-shaped correction), and applying a per-CFA tile without declaring the CFA layout.
apply_lsc ¶
apply_lsc(
image: ArrayLike,
config: LSCConfig,
cfa: CFAPattern | str | None = None,
) -> NDArray[np.float32]
Apply a lens-shading gain to a linear RAW image.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
ArrayLike
|
Linear RAW codes or intensities, a 2-D |
required |
config
|
LSCConfig
|
Validated :class: |
required |
cfa
|
CFAPattern | str | None
|
Optional CFA pattern. It is required when |
None
|
Returns:
| Type | Description |
|---|---|
A new float32 array of shape ``(H, W)`` equal to ``image * gain``. The gain
|
|
is dimensionless, so scaling the input scales the output (linear-space
|
|
invariant) and no clipping or gamma is applied.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the image is not a non-empty 2-D real numeric array or contains
non-finite values, if |
Notes
The radial polynomial is evaluated in powers of r**2 with r
normalised to the farthest corner, so the highest coefficient corresponds
to the highest even power. A gain that reaches zero or changes sign anywhere
is rejected: a "correction" that inverts a region is a calibration error,
not a strong lens.
calibrate_lsc ¶
calibrate_lsc(
flat_field_raw: RawFrame | ArrayLike,
) -> NDArray[np.float32]
Derive a lens-shading gain map from a flat-field capture.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
flat_field_raw
|
RawFrame | ArrayLike
|
A uniformly illuminated capture of the lens and sensor. A
:class: |
required |
Returns:
| Type | Description |
|---|---|
A float32 ``(H, W)`` gain map ``max(flat) / flat`` whose largest entry is
|
|
one at the brightest sample and whose remaining entries are at least one.
|
|
It can be passed directly to :func:`apply_lsc` as
|
|
``LSCConfig(mode="flat_field", map=gain)``.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the input is not a valid 2-D real array or |
Notes
Normalising to the maximum rather than the centre avoids the ill-defined centre sample of an even-sized array and sidesteps a single noisy central pixel. It also guarantees gains at or above one. Known dead, hot, or stuck sites should still be corrected before calibration, because a hot pixel can be the maximum and set an unrealistically low gain everywhere else.
CFA demosaicing: reconstruct full-resolution linear RGB from a mosaic.
A colour image sensor places a colour filter over each photodiode, so every pixel measures one spectral band and the other two channels must be inferred from neighbouring samples. This module reconstructs those missing samples in linear light, after black-level correction and white balance and before the colour-correction matrix, denoise, and tone stages that expect three channels per pixel. Demosaicing is spatial interpolation of radiance, not a colour transform: an error here (a wrong tile origin, a swapped channel) propagates through every later colour operation and cannot be undone.
The dominant consumer layout is the 2x2 Bayer tile, one red, two green, and one
blue sites named row-major from the top-left pixel (RGGB, BGGR,
GRBG, GBRG). RCCC is an automotive layout with one red and three
clear sites per tile; the clear channel is broadband and carries most of the
luminance, which is why it is used as a luma placeholder below.
Bilinear interpolation masks each colour plane, convolves it with a small all-pass kernel, and reads the result at every pixel::
y = H * x
For green it averages the four cross neighbours at red and blue sites; for red and blue it averages the two same-colour neighbours along one axis at a green site and the four diagonal same-colour neighbours at the opposite colour site. It is cheap and exact for smooth fields, but the three channels are interpolated independently, so an edge that falls on a red site but not on a neighbouring blue site produces a colour fringe, a soft "zipper" along the edge, and reduced sharpness.
Malvar, He, and Cutler (2004) replace those kernels with 5x5 gradient-corrected filters. Each missing channel is estimated from a base interpolation plus a correction proportional to the second derivative of the measured green plane, so high-frequency detail measured at green sites refines red and blue instead of being thrown away. The kernels are::
G at R/B: [[0, 0, -1, 0, 0], R/B at G: [[0, 0, 0.5, 0, 0],
[0, 0, 2, 0, 0], [0, -1, 0, -1, 0],
[-1, 2, 4, 2, -1], [-1, 4, 5, 4, -1],
[0, 0, 2, 0, 0], [0, -1, 0, -1, 0],
[0, 0, -1, 0, 0]] / 8 [0, 0, 0.5, 0, 0]] / 8
R at B / B at R: [[0, 0, -1.5, 0, 0],
[0, 2, 0, 2, 0],
[-1.5, 0, 6, 0, -1.5],
[0, 2, 0, 2, 0],
[0, 0, -1.5, 0, 0]] / 8
with the transposed red-at-green kernel used for the opposite green phase. The kernels sum to one, so a flat field is reproduced exactly, and the correction term vanishes on a linear ramp. MHC costs roughly 2-3 times bilinear and markedly reduces zippering and false colour at edges; residual artefacts remain on fine chromatic textures, diagonal lines near the Nyquist limit can show a "maze" pattern, and very noisy data benefit from denoising first.
Frame borders need a boundary rule. Bilinear and MHC need samples one or two
pixels outside the frame; this implementation extends each axis by symmetric
reflection (the "reflect" mode of scipy.ndimage), the same rule the
colour-demosaicing reference uses. Reflection double-counts same-colour
sites at the border (bilinear) and mixes colours there (MHC), so a constant
scene is not exactly constant in the outermost rows and columns even though
the interior is. That is a property of the reference boundary mode, not of the
scene: the validation tests compare the full frame against the reference and
check DC preservation in the interior. Production pipelines should crop or
mirror-fill a few border pixels before measuring image quality.
The RCCC baseline is deliberately simple and explicitly not colour accurate:
the sparse red plane is interpolated by weight-normalised bilinear interpolation
(so flat red fields are exact even at the border), the measured clear plane is
kept at its own sites, clear values at red sites are averaged from the cardinal
clear neighbours, and the result is returned as (R, C, C). The clear
channel acts as a luma placeholder for green and blue, so the output has the
right shape, linearity, and DC behaviour for pipeline smoke tests but no usable
chroma. Automotive RCCC demosaicing normally exploits the clear channel's
higher signal-to-noise ratio for luma and reconstructs chroma from the sparse
red samples plus a spectral model; that is deferred.
Typical sensor sizes span roughly 0.3 MP (VGA) to 200 MP, so images run from a few hundred thousand to hundreds of millions of pixels; Bayer remains the dominant consumer and industrial layout, RCCC appears in automotive forward cameras, and monochrome sensors skip this stage entirely. Kernel support is therefore fixed and small, but the convolution cost is proportional to pixel count and matters at video rates.
All computation happens in linear float32 light. The stage does not apply a
gamma curve or tone map, does not clip, and preserves negative values, because
earlier linear stages (black-level correction, white balance) can leave small
excursions below zero and the tone stage owns the display range. The output is
a new C-contiguous float32 array in R, G, B order; the input is never mutated.
Mono input is a 2-D pass-through copy: the pipeline keeps it single-channel
and replicates only at the display boundary. Scaling the input scales the
output (demosaic(a * x) == a * demosaic(x)) because every operation is a
linear combination of samples.
Failure artefacts guarded here: interpolating across CFA colours (false colour and zippering), a transposed or offset Bayer tile (red/blue swap), applying demosaicing to gamma-encoded data (interpolation in the wrong domain), treating an RCCC or mono mosaic as Bayer (silently wrong channels or shapes), returning the input buffer or mutating it (aliasing a later stage's write), and accepting non-2-D, empty, bool, complex, or non-finite data (poisoned statistics downstream). Method/CFA mismatches raise rather than guessing a layout.
demosaic ¶
demosaic(
raw: ArrayLike,
cfa: CFAPattern | str,
method: DemosaicMethod | str,
) -> NDArray[np.float32]
Dispatch demosaicing for a Bayer, mono, or RCCC mosaic.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
ArrayLike
|
A 2-D |
required |
cfa
|
CFAPattern | str
|
The CFA layout, as a :class: |
required |
method
|
DemosaicMethod | str
|
|
required |
Returns:
| Type | Description |
|---|---|
A new C-contiguous float32 array: ``(H, W, 3)`` in R, G, B order for a
|
|
Bayer mosaic and for the RCCC baseline, ``(H, W)`` for a mono pass-through.
|
|
Values are linear and unclipped.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Notes
The method is validated before the mono bypass, so a typo cannot hide
behind a pass-through. The RCCC baseline is a shape/linearity placeholder
and is not colour accurate; see :func:demosaic_rccc_baseline.
Characterization¶
EMVA 1288-style sensor measurements and the machine-readable measurement functions behind the sensor, ISP, and metrics validation reports.
EMVA 1288-style sensor characterization and validation reports.
The package exposes the sensor measurement entry point and the structured
report it produces, plus the shared G3 ISP validation and G4 metrics validation
used by the gallery examples and their tests. characterize_sensor drives a
simulated :class:camsim.Sensor through flat-field and dark-frame sweeps;
:class:EMVAReport carries the measured gain, noise, saturation, dynamic
range, fixed-pattern metrics, and the provenance needed to interpret them.
measure_g3 measures the ISP end to end and measure_g4 measures the
independent metrics layer against analytic MTF, known-noise SNR, and
colour-science delta-E; both return JSON-serialisable reports.
build_gallery produces the arrays used by the stage-gallery figures.
G3Gallery
dataclass
¶
G3Gallery(
stages: dict[str, StageOutput],
star_reference: NDArray[float32],
star_bilinear: NDArray[float32],
star_mhc: NDArray[float32],
rccc_image: NDArray[float32],
)
Arrays used by the G3 stage-gallery figures.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stages
|
dict[str, StageOutput]
|
Ordered ISP trace from a ColorChecker capture with denoise and
sharpening enabled, one :class: |
required |
star_reference
|
NDArray[float32]
|
Synthetic Siemens-star RGB reference image in linear |
required |
star_bilinear
|
NDArray[float32]
|
Bilinear reconstruction of the star mosaic. |
required |
star_mhc
|
NDArray[float32]
|
Malvar-He-Cutler reconstruction of the star mosaic. |
required |
rccc_image
|
NDArray[float32]
|
Rendered RCCC baseline image, shape |
required |
Notes
The dataclass is frozen, but the arrays and the stage dictionary are not deep-frozen; treat them as read-only snapshots.
EMVAReport
dataclass
¶
EMVAReport(
system_gain_dn_per_e: float,
read_noise_e: float,
saturation_capacity_e: float,
absolute_sensitivity_threshold_e: float,
dynamic_range_db: float,
dsnu_e: float,
prnu_percent: float,
snr_curve: tuple[SNRPoint, ...],
linearity_residual: float,
provenance: dict[str, Any] = dict(),
)
A complete EMVA 1288-style characterization of one sensor mode.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
system_gain_dn_per_e
|
float
|
Measured photon-transfer slope in DN per electron. It includes the analog gain and the ADC code scale. |
required |
read_noise_e
|
float
|
Input-referred read noise in electrons rms, obtained from the PTC intercept after removing row noise and ADC quantization variance. |
required |
saturation_capacity_e
|
float
|
Maximum mean signal in electrons before clipping. |
required |
absolute_sensitivity_threshold_e
|
float
|
Signal in electrons at which the shot-plus-read SNR equals one. |
required |
dynamic_range_db
|
float
|
|
required |
dsnu_e
|
float
|
Pre-zero-clamp, input-referred dark-signal non-uniformity in electrons
rms. The sensor clamps the pre-Poisson mean charge at zero and
quantizes the sub-LSB dark signal, so the raw post-clamp spatial spread
is smaller; the characterization records it separately in provenance
( |
required |
prnu_percent
|
float
|
Photo-response non-uniformity as a percentage (0.5 means 0.5 %, not 50 %). The field name carries the unit to prevent the classic fraction-versus-percent error. |
required |
snr_curve
|
tuple[SNRPoint, ...]
|
Measured signal-to-noise points from low light to just below saturation. |
required |
linearity_residual
|
float
|
Maximum relative deviation from a straight-line fit of mean signal versus irradiance, over unsaturated levels. It is a dimensionless fraction (0.01 means 1 %). |
required |
provenance
|
dict[str, Any]
|
JSON-friendly measurement metadata: sensor configuration hash, readout mode, levels, frame counts, algorithm definitions, and the explicit statement that this is a simulated characterization rather than a certified EMVA measurement. |
dict()
|
Notes
Numeric fields are validated on construction. provenance is copied so a
caller cannot mutate the report through an alias.
to_dict ¶
to_dict() -> dict[str, Any]
Return the report as a JSON-friendly mapping.
The mapping is self-describing: it names the schema version and report
kind, keeps unit-bearing field names, and stores SNR points as a list of
mappings. It contains no NumPy scalars or arrays, so json.dumps
works without a custom encoder.
to_json ¶
to_json(path: str | Path | None = None) -> str
Serialize the report as strict JSON, optionally writing it to disk.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path | None
|
Destination file. Parent directories are created. When omitted the JSON text is only returned. |
None
|
Returns:
| Type | Description |
|---|---|
The JSON text, identical to what was written when ``path`` is given.
|
|
``allow_nan=False`` is deliberate: NaN and infinity are not valid JSON
|
|
and silently writing them produces files that other tools reject.
|
|
from_dict
classmethod
¶
from_dict(payload: Mapping[str, Any]) -> Self
Reconstruct a report from :meth:to_dict output.
Unknown keys are ignored so a newer producer can add metadata without breaking an older reader; missing required measurement fields raise a message naming every absent field.
from_json
classmethod
¶
from_json(text: str) -> Self
Parse a report from JSON text produced by :meth:to_json.
SNRPoint
dataclass
¶
SNRPoint(
irradiance_w_per_m2_nm: float,
mean_signal_dn: float,
mean_signal_e: float,
noise_dn: float,
noise_e: float,
snr_db: float,
noise_dn_total: float | None = None,
noise_e_total: float | None = None,
snr_db_total: float | None = None,
)
One measured point on the signal-to-noise-ratio curve.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
irradiance_w_per_m2_nm
|
float
|
Flat-field spectral irradiance at the sensor plane. Together with the sensor response this sets the signal level; it is not a lux value. |
required |
mean_signal_dn
|
float
|
Mean black-level-corrected digital signal at this level. |
required |
mean_signal_e
|
float
|
The same mean expressed in electrons through the measured system gain. |
required |
noise_dn
|
float
|
Row-demeaned pixel-temporal noise standard deviation in DN. Common-mode row noise is removed; ADC quantization is retained, so this is the noise of the pixel signal chain after row-noise correction. |
required |
noise_e
|
float
|
The same noise expressed in electrons. |
required |
snr_db
|
float
|
|
required |
noise_dn_total
|
float | None
|
Optional raw-frame temporal noise in DN, including common-mode row
noise and ADC quantization. The primary |
None
|
noise_e_total
|
float | None
|
The total noise in electrons. |
None
|
snr_db_total
|
float | None
|
|
None
|
Notes
The explicit DN and electron fields keep the units auditable. A bare
snr value cannot be interpreted without knowing whether the noise was
measured before or after gain, quantization, and row-noise removal.
characterize_sensor ¶
characterize_sensor(
sensor: Sensor,
irradiance_levels: list[float]
| tuple[float, ...]
| None = None,
lux_levels: list[float]
| tuple[float, ...]
| None = None,
frames_per_level: int = 32,
exposure_s: float = 0.001,
mode: Mode = "auto",
temperature_c: float = 25.0,
illuminant: str = "D65",
lens: LensConfig | None = None,
base_seed: int = 0,
) -> EMVAReport
Measure one readout mode of sensor with EMVA 1288-style methods.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sensor
|
Sensor
|
A constructed :class: |
required |
irradiance_levels
|
list[float] | tuple[float, ...] | None
|
Flat spectral irradiance levels at the sensor plane in W/m^2/nm. This is the preferred, fully radiometric form. At least three positive levels are required so the PTC is over-determined. |
None
|
lux_levels
|
list[float] | tuple[float, ...] | None
|
Photopic illuminance levels in lux. Mutually exclusive with
|
None
|
frames_per_level
|
int
|
Frames per level, at least two. More frames reduce the uncertainty of every variance estimate; 16-32 is a practical range. |
32
|
exposure_s
|
float
|
Integration time in seconds for every frame. It must lie inside the sensor preset's configured exposure limits. |
0.001
|
mode
|
Mode
|
|
'auto'
|
temperature_c
|
float
|
Sensor temperature in degrees Celsius held constant for all frames. |
25.0
|
illuminant
|
str
|
|
'D65'
|
lens
|
LensConfig | None
|
Optional :class: |
None
|
base_seed
|
int
|
Frame |
0
|
Returns:
| Type | Description |
|---|---|
EMVAReport
|
Measured system gain, read noise, saturation capacity, absolute sensitivity threshold, dynamic range, DSNU, PRNU, SNR curve, linearity residual, and provenance. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If inputs are out of range, both level forms are supplied, fewer than three usable levels remain, no channel has enough valid pixels, or the PTC fit is degenerate (non-positive slope or intercept). |
TypeError
|
If |
Notes
The measurement is deterministic for fixed inputs and base_seed. Fixed
pattern maps come from the sensor's own defect seed, so DSNU and PRNU are
stationary as EMVA assumes. The report explicitly records that this is a
simulated characterization rather than a certified standard measurement.
build_gallery ¶
build_gallery(*, quick: bool = False) -> G3Gallery
Render the stage-gallery and demosaic-comparison arrays for figures.
The gallery is built from a separate ColorChecker capture with the fitted per-illuminant CCM and with denoise and sharpening enabled, so the contact sheet shows every mandatory stage. The star arrays are the same synthetic mosaic used by the demosaic check. The function is deterministic and does not write files; the example owns the figure names and formats.
measure_g3 ¶
measure_g3(*, quick: bool = False) -> dict[str, Any]
Run the G3 ISP measurements and return a JSON-serialisable report.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
quick
|
bool
|
Use smaller ColorChecker, star, and noise fields. Quick mode exercises
every code path but is not gate evidence; the returned dictionary
records |
False
|
Returns:
| Type | Description |
|---|---|
A dictionary with ``kind``, ``gate_evidence``, ``checks`` (each carrying
|
|
``name``, ``measured``, ``tolerance``, ``pass``, ``notes``, and the exact
|
|
display strings used by the Markdown report), and the raw measured values
|
|
under ``colorchecker``, ``awb``, ``demosaic``, ``tone``, ``trace``,
|
|
``linearity``, ``lsc``, ``denoise``, ``sharpen``, and ``rccc``. Every value
|
|
is a Python float, int, bool, string, list, or nested list, so
|
|
``json.dumps(..., allow_nan=False)`` succeeds.
|
|
The ColorChecker criterion is reported honestly. The shipped flagship
|
|
preset uses synthetic **conventional Bayer bands** (blue ~448 nm, green
|
|
~542 nm, red ~610 nm) with moderate out-of-band leakage. The in-sample 3x3
|
|
CCM meets the design thresholds under both illuminants; the
|
|
Luther-condition residual is reported as a diagnostic of how well the CFA
|
|
spans the CIE observer. The original broad, nearly collinear CFA curves were
|
|
replaced after the G3 gate showed that no 3x3 matrix could colour-correct
|
|
them. The CCM is still fitted and evaluated in-sample, which is a stated
|
|
limitation.
|
|
measure_g4 ¶
measure_g4(*, quick: bool = False) -> dict[str, Any]
Run the G4 metrics validation and return a JSON-serializable report.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
quick
|
bool
|
Use smaller MTF, SNR, chart, and acutance grids. Quick mode exercises
every code path but is not gate evidence; the returned dictionary
records |
False
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A dictionary with |
Notes
All measurements are synthetic and deterministic: explicit
:class:numpy.random.Generator seeds, no global random state, and no
wall-clock or environment-dependent values except the software versions
recorded in environment. The metric under test and the analytic
reference are independent code paths; the SNR gate checks use the
generating means and sigmas rather than the estimator's own sample
statistics. The snr.consistency section records the identically zero
sample-statistics error as a diagnostic, not as gate evidence.
render_g3_report ¶
render_g3_report(payload: Mapping[str, Any]) -> str
Render the deterministic G3 Markdown report from a measurement payload.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
Mapping[str, Any]
|
The dictionary returned by
:func: |
required |
Returns:
| Type | Description |
|---|---|
The complete Markdown report as a string. The summary is **status-aware**:
|
|
a PASS payload states that all measured checks pass, while a FAIL payload
|
|
states that the gate failed and names every failing check. It never claims
|
|
success from a hard-coded sentence.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the payload is not a camsim G3 payload, lacks a required section, or
has a status other than |
render_g4_report ¶
render_g4_report(payload: Mapping[str, Any]) -> str
Render the deterministic G4 Markdown report from a measurement payload.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
Mapping[str, Any]
|
The dictionary returned by :func: |
required |
Returns:
| Type | Description |
|---|---|
The complete Markdown report as a string. The summary is **status-aware**:
|
|
a PASS payload states that all measured checks pass, while a FAIL payload
|
|
states that the gate failed and names every failing check. It never claims
|
|
success from a hard-coded sentence.
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If the payload is not a camsim G4 payload, lacks a required section, or
has a status other than |
Metrics¶
The independent image-quality harness: chart layouts, e-SFR, SNR, colour difference, acutance, and the unified report. See Metrics for the definitions and limitations.
Image-quality metrics: chart layouts, MTF/e-SFR, SNR, colour, and acutance.
This package is import-independent by design: it operates on arrays plus
metadata and must never import camsim.sensor, camsim.isp,
camsim.scene, or camsim.optics. The scene layer owns the convenience
constructors that bind a chart to its spectrally computed illuminant
reference; those constructors return the :class:~camsim.metrics.chart.ChartLayout
defined here, so the dependency runs scene -> metrics, never the reverse.
The package is the public metrics API:
- chart geometry - :class:
~camsim.metrics.chart.ChartLayout, :class:~camsim.metrics.chart.PatchROI, and :func:~camsim.metrics.chart.extract_patch; - resolution - :func:
~camsim.metrics.mtf.slanted_edge_mtf, :func:~camsim.metrics.mtf.extract_raw_plane, and the :class:~camsim.metrics.mtf.MTFCurve/ :class:~camsim.metrics.mtf.MTFResultvalue objects; - noise - :func:
~camsim.metrics.snr.snr_patch, :func:~camsim.metrics.snr.snr_temporal, :func:~camsim.metrics.snr.snr_channels, and :class:~camsim.metrics.snr.SNRResult; - colour - :func:
~camsim.metrics.color.delta_e_patches, :func:~camsim.metrics.color.linear_rgb_to_lab, and the :class:~camsim.metrics.color.PatchColorResult/ :class:~camsim.metrics.color.ColorResultvalue objects; - sharpness - :func:
~camsim.metrics.acutance.edge_acutance; - the unified harness - :func:
~camsim.metrics.summary.measure_alland its :class:~camsim.metrics.summary.MetricReport.
The layout types are the shared vocabulary of every metric. A
:class:~camsim.metrics.chart.PatchROI names a half-open sensor-pixel region,
:class:~camsim.metrics.chart.ChartLayout groups those regions with an
optional edge region and colour/camera metadata, and
:func:~camsim.metrics.chart.extract_patch returns the pixels of one region
without copying. Metric modules (e-SFR, SNR, delta-E, acutance) consume these
types plus plain NumPy arrays.
Regions are measured in sensor pixels, never in millimetres or on the oversampled rendering grid. Typical values: a ColorChecker patch covers tens of pixels, an ISO 12233-style edge region is 100-400 pixels tall, pixel pitches run from about 0.8 um (small phone sensors) to 10 um (large-format), and focal lengths from about 2 mm to 50 mm. Colour references are bound to the illuminant that produced the image; a D65 reference applied to a 3000 K capture is a white-point error, not a sensor colour error.
Failure artefacts guarded here: transposed coordinates measure the wrong patch,
a region crossing a patch boundary averages two reflectances, overlapping
regions double-count pixels, a layout with no patches and no edge gives
measure_all nothing to do, and importing a simulator package from this
layer would couple the measurement harness to the model it is meant to judge.
ChartLayout
dataclass
¶
ChartLayout(
name: str,
patches: tuple[PatchROI, ...] = (),
edge_roi: PatchROI | None = None,
reference: ColorReference | None = None,
pixel_pitch_um: float | None = None,
focal_length_mm: float | None = None,
)
Patch regions, optional edge region, colour truth, and camera metadata.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Chart name such as |
required |
patches
|
tuple[PatchROI, ...]
|
Patch regions in chart order. Names must be unique and no two regions
may overlap with positive area. Stored as a tuple. Patches may be
empty only when |
()
|
edge_roi
|
PatchROI | None
|
Optional region containing the slanted edge, with the same non-negative, positive-size and non-overlap rules. It is the full analysis region for edge-based metrics; the metric may crop further inside it. |
None
|
reference
|
ColorReference | None
|
Optional per-patch CIE Lab ground truth for the illuminant that lit the chart. Required in practice for colour metrics under non-D65 illumination: a D65 reference applied to a 3000 K capture produces a systematic delta-E offset. The layout does not infer a reference from its name; the scene layer computes it spectrally and binds it explicitly. |
None
|
pixel_pitch_um
|
float | None
|
Sensor pixel pitch in micrometres, finite and positive, when known. Needed to express MTF in cycles/degree and to convert spatial frequencies to physical units. |
None
|
focal_length_mm
|
float | None
|
Actual lens focal length in millimetres, finite and positive, when
known. Combined with |
None
|
Notes
Coordinates refer to the sensor grid of the measured image, never to the oversampled rendering grid. The layout is a frozen value object: it can be shared between metric calls and serialized for a report's provenance without risk of a later stage editing the regions.
to_dict ¶
to_dict() -> dict[str, Any]
Return a JSON-friendly mapping, with the reference in its own schema.
from_dict
classmethod
¶
from_dict(payload: Mapping[str, Any]) -> Self
Reconstruct a layout from :meth:to_dict output or parsed JSON.
The reference round-trips through
:meth:camsim.core.color.ColorReference.from_dict, so its Lab values,
white point, observer, and provenance survive serialization. Optional
camera metadata absent from the payload stays None.
PatchROI
dataclass
¶
PatchROI(
name: str, x: int, y: int, width: int, height: int
)
A rectangular region of a chart, in sensor-pixel coordinates.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Patch label such as |
required |
x
|
int
|
Zero-based column and row of the region's top-left pixel. Both are non-negative integers; floats and booleans are rejected. |
required |
y
|
int
|
Zero-based column and row of the region's top-left pixel. Both are non-negative integers; floats and booleans are rejected. |
required |
width
|
int
|
Region size in sensor pixels. Both are positive integers. |
required |
height
|
int
|
Region size in sensor pixels. Both are positive integers. |
required |
Notes
The region is half-open: it covers columns x .. x + width - 1 and rows
y .. y + height - 1. It carries no image size, so it may be built
before the frame is known; :func:extract_patch performs the containment
check when the image is available.
A real ColorChecker patch spans tens of pixels at typical sensor pitches. ROIs are usually inset 10-20 % from the painted patch boundary so that optical blur and demosaic artefacts from the surround never enter the mean.
ColorResult
dataclass
¶
ColorResult(
delta_e76: float,
delta_e2000: float,
per_patch: tuple[PatchColorResult, ...],
valid: bool,
)
A chart-wide colour-accuracy measurement.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
delta_e76
|
float
|
Arithmetic means of the per-patch differences over the valid
patches. |
required |
delta_e2000
|
float
|
Arithmetic means of the per-patch differences over the valid
patches. |
required |
per_patch
|
tuple[PatchColorResult, ...]
|
Per-patch :class: |
required |
valid
|
bool
|
|
required |
Notes
The dataclass is frozen. It carries the per-patch rows so a report can show which patches dominate an error instead of only a summary number; a mean of about 1-5 delta-E00 is typical camera colour, and individual patches can be several times that.
PatchColorResult
dataclass
¶
PatchColorResult(
name: str,
delta_e76: float,
delta_e2000: float,
valid: bool,
)
One chart patch's colour difference from its reference.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Patch label copied from the layout, non-empty and stored stripped. |
required |
delta_e76
|
float
|
CIE 1976 Euclidean Lab distance from the reference. Finite when
|
required |
delta_e2000
|
float
|
CIEDE2000 colour difference from the reference under the reference
conditions ( |
required |
valid
|
bool
|
|
required |
Notes
The dataclass is frozen so a per-patch row can travel from a metric into a report without being edited. Camera colour differences typically run from below 1 for a near-perfect match to 5 or more for a visible error.
MTFCurve
dataclass
¶
MTFCurve(
frequency_cp: NDArray[float64], mtf: NDArray[float64]
)
A sampled modulation transfer function.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frequency_cp
|
NDArray[float64]
|
One-dimensional spatial frequencies in cycles/pixel. Must be finite, non-negative, strictly increasing, and start at exactly zero. Zero frequency is the reference point at which the transfer function is one. |
required |
mtf
|
NDArray[float64]
|
Modulation transfer factors at the same frequencies. Finite and
non-negative; |
required |
Notes
Both inputs are converted to float64 and copied, so mutating the caller's array afterwards cannot change a curve that may be shared between a result, a report, and a plot. The dataclass is frozen; the arrays it holds are not marked read-only, so treat them as immutable.
A real curve runs from 0 to 0.5 cycles/pixel (the native Nyquist limit); the class itself does not enforce an upper bound because a caller may keep the oversampled analysis band for diagnostics.
MTFResult
dataclass
¶
MTFResult(
mtf50_cp: float,
mtf20_cp: float,
mtf50_cyc_per_deg: float,
mtf20_cyc_per_deg: float,
curve: MTFCurve,
edge_angle_deg: float,
nyquist_cp: float = _NYQUIST_CP,
)
A slanted-edge measurement: the curve, its crossings, and its geometry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mtf50_cp
|
float
|
First frequencies, in cycles/pixel, where the curve falls to one half
and one fifth. |
required |
mtf20_cp
|
float
|
First frequencies, in cycles/pixel, where the curve falls to one half
and one fifth. |
required |
mtf50_cyc_per_deg
|
float
|
The same crossings in cycles/degree, using the camera's angular pixel
pitch. |
required |
mtf20_cyc_per_deg
|
float
|
The same crossings in cycles/degree, using the camera's angular pixel
pitch. |
required |
curve
|
MTFCurve
|
The sampled :class: |
required |
edge_angle_deg
|
float
|
Estimated tilt of the measured edge, positive when the edge moves rightwards as the row index increases. Finite. |
required |
nyquist_cp
|
float
|
Upper edge of the measured band in cycles/pixel; 0.5 by default. The
finite crossing fields must lie inside |
_NYQUIST_CP
|
Notes
The fields are stored as given; the class does not recompute the crossings from the curve, so a caller who edits a curve before constructing the result is responsible for keeping the summary consistent. The dataclass is frozen; the curve's arrays are value data owned by the result.
SNRResult
dataclass
¶
SNRResult(
mean: float,
std: float,
snr_db: float,
channel: str,
valid: bool,
)
A flat-patch signal-to-noise measurement.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mean
|
float
|
Patch mean in the caller's linear signal units. Finite. Spatial measurements use the arithmetic mean of the ROI; temporal measurements use the mean of the per-pixel temporal means. |
required |
std
|
float
|
Sample standard deviation in the same units. Finite and non-negative.
Spatial measurements use |
required |
snr_db
|
float
|
|
required |
channel
|
str
|
|
required |
valid
|
bool
|
|
required |
Notes
The dataclass is frozen. A result carries the statistics that produced it,
so a report can show why a measurement was rejected - for example a flat
patch with std == 0 and its measured mean - rather than only a NaN.
The value object validates internal consistency but does not recompute
snr_db from mean and std: a caller who builds one by hand is
responsible for the arithmetic, while the metric functions always derive
the three fields together.
MetricReport
dataclass
¶
MetricReport(
mtf: MTFResult | None = None,
snr: dict[str, dict[str, SNRResult]] | None = None,
color: ColorResult | None = None,
acutance: float | None = None,
layout_id: str | None = None,
metadata: dict[str, Any] | None = None,
)
One image's measured metric groups, layout identity, and provenance.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mtf
|
MTFResult | None
|
The slanted-edge :class: |
None
|
snr
|
dict[str, dict[str, SNRResult]] | None
|
Patch name → channel name → :class: |
None
|
color
|
ColorResult | None
|
The chart-wide :class: |
None
|
acutance
|
float | None
|
The normalized edge-gradient energy from
:func: |
None
|
layout_id
|
str | None
|
The source layout's name, or |
None
|
metadata
|
dict[str, Any] | None
|
JSON-friendly provenance: colour space, channel layout, pixel pitch,
focal length, reference illuminant, MTF input domain, the luma space
and coefficients, the Display P3-to-sRGB conversion when one was
applied, a |
None
|
Notes
The dataclass is frozen, so a report can be shared between a sweep, a figure, and a serialized artifact without a later stage editing it. The nested SNR dicts are ordinary dicts for JSON compatibility; treat them as read-only.
:meth:to_dict expands dataclasses and converts arrays, tuples, NumPy
scalars, and non-finite floats (to None). :meth:to_json serializes
that mapping with allow_nan=False, so NaN crossings and undefined SNR
ratios appear as null and the text parses with any standard JSON reader.
Resolution and noise metrics are luma-derived. Their input primaries follow
RenderedImage.color_space before the harness converts them: a linear
Display P3 buffer is converted to linear sRGB, then Rec. 709 weights are
applied. The metadata records the luma space, the weights, and the
conversion that was applied; linear_rgb is never silently relabelled as
sRGB.
to_dict ¶
to_dict() -> dict[str, Any]
Return the report as a JSON-friendly mapping.
The MTF group carries every scalar field plus the full sampled curve as
{"frequency_cp": [...], "mtf": [...]}. SNR is nested
patch → channel → statistics, and colour carries its per-patch rows.
Non-finite floats become None; NumPy scalars become Python scalars.
to_json ¶
to_json() -> str
Return standards-compliant JSON; non-finite values serialize as null.
allow_nan=False rejects the non-standard NaN and Infinity
tokens, so a not-reached crossing is emitted as null and the result
parses in every strict JSON reader.
edge_acutance ¶
edge_acutance(image: ArrayLike, roi: PatchROI) -> float
Return the contrast-normalized RMS gradient energy of one edge ROI.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
ArrayLike
|
Real, finite Values may be linear light or display-encoded, but the choice changes the result: a tone curve is non-linear, so the same optics measured through it does not produce the same gradient profile. Comparisons should use one encoding throughout. The image is never modified. |
required |
roi
|
PatchROI
|
The edge region as a :class: |
required |
Returns:
| Type | Description |
|---|---|
float
|
|
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If the image is not a finite real 2-D or 3-D array, has a 3-D channel count other than three, or the ROI extends past the image boundary. |
Notes
This is an image-sharpness proxy for correlation studies, not a calibrated
physical quantity and not a standard definition. The estimator averages per
pixel, so its magnitude depends on the ROI size (a one-pixel step in a
W-pixel ROI measures 1 / sqrt(2 W)); compare values only between
measurements that use the same ROI geometry. Noise, compression, and
sharpening halos all raise the number, so it must not be read as a
noise-free resolution measurement - use
:func:camsim.metrics.mtf.slanted_edge_mtf for that. The computation is
deterministic and allocates its own working arrays.
extract_patch ¶
extract_patch(
image: ArrayLike, roi: PatchROI
) -> NDArray[Any]
Return the pixels of roi from a 2-D or 3-D image as a view.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
ArrayLike
|
Real-valued |
required |
roi
|
PatchROI
|
A :class: |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
The half-open crop |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If the image is not 2-D or 3-D, is not real-valued and finite, or the ROI extends past the image boundary. A clear error is preferred to NumPy's silent truncation, which would measure a smaller region than requested and bias a patch mean towards the image border. |
delta_e_patches ¶
delta_e_patches(
image: ArrayLike,
layout: ChartLayout,
reference: ColorReference | None = None,
color_space: str = "sRGB",
) -> ColorResult
Measure every chart patch's colour difference from its reference.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
ArrayLike
|
Display-encoded |
required |
layout
|
ChartLayout
|
A :class: |
required |
reference
|
ColorReference | None
|
The per-patch CIE Lab ground truth. When |
None
|
color_space
|
str
|
|
'sRGB'
|
Returns:
| Type | Description |
|---|---|
ColorResult
|
Per-patch rows in layout order plus the means over valid patches and a validity flag. Every patch is valid when its differences are finite, which is the case for a validated display image and a validated reference. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Notes
The pipeline is: validate; decode display RGB to linear; crop each ROI and
average it in linear light; linear_rgb_to_lab with the reference white
point; then delta-E76 and delta-E2000 against the matching reference row.
Because the reference Lab is illuminant-relative, a D65 reference applied
to a warm-light capture is a white-point error rather than a camera error;
the scene layer computes a matching reference spectrally and binds it to
the layout. The result is deterministic and the input image is not
modified. A colour correction matrix fitted on the same patches that are
measured here would be an in-sample evaluation and is the caller's
responsibility to avoid.
linear_rgb_to_lab ¶
linear_rgb_to_lab(
image: ArrayLike,
color_space: str,
white_point: tuple[float, float],
) -> NDArray[np.float64]
Convert linear RGB to CIE Lab relative to a reference white point.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
ArrayLike
|
Any real numeric array-like whose trailing axis is 3 in R, G, B order:
a single |
required |
color_space
|
str
|
|
required |
white_point
|
tuple[float, float]
|
Reference white |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
A new C-contiguous float64 array with the same shape as |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Notes
The conversion is deterministic, allocates its output, and never modifies the input. Display-encoded RGB must be decoded with the sRGB EOTF first (Display P3 uses the same curve); feeding display values here treats them as linear intensities and biases lightness and hue.
extract_raw_plane ¶
extract_raw_plane(
raw: RawFrame, channel: str = "G"
) -> tuple[NDArray[np.float64], float]
Return one colour phase of a RAW mosaic and its sampling pitch multiplier.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
RawFrame
|
A :class: |
required |
channel
|
str
|
|
'G'
|
Returns:
| Name | Type | Description |
|---|---|---|
plane |
NDArray[float64]
|
The selected phase as a float64 copy of shape
|
pitch_multiplier |
float
|
|
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
For an unknown channel, a mono frame without |
Notes
A Bayer colour plane samples a rectangular lattice whose sites are two original pixels apart in each axis, so its Nyquist falls to 0.25 cycles per original pixel. The green sites form a quincunx: the two green phases are offset by one pixel diagonally, and averaging them is a 2 x 2 box filter that silently low-passes the measurement. Using one phase keeps a rectangular lattice, which is what the edge projection assumes. With an odd number of rows or columns the last row or column belongs to only one phase, so the three colour planes can differ in shape by one; the returned shape is exactly the slice's and callers must not assume a common size.
slanted_edge_mtf ¶
slanted_edge_mtf(
image_2d: ArrayLike,
pixel_pitch_um: float,
focal_length_mm: float | None = None,
oversampling: int = 4,
window: str = "hamming",
) -> MTFResult
Measure the MTF of a linear-light image containing one slanted edge.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image_2d
|
ArrayLike
|
A real, finite, 2-D |
required |
pixel_pitch_um
|
float
|
Sample pitch of |
required |
focal_length_mm
|
float | None
|
Actual lens focal length in millimetres, finite and positive, or
|
None
|
oversampling
|
int
|
Analysis samples per pixel, an integer of at least one; four is the
conventional value. Each pixel is projected to a position along the
edge normal and binned at |
4
|
window
|
str
|
|
'hamming'
|
Returns:
| Type | Description |
|---|---|
MTFResult
|
The curve from 0 to 0.5 cycles/pixel, the interpolated MTF50/MTF20 crossings (NaN when not reached inside the band), the same crossings in cycles/degree when a focal length is supplied, the estimated edge angle, and the Nyquist limit. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the image is not a finite real 2-D array, if a numeric parameter is invalid, if the border margin leaves less than a 16 x 16 analysis region, or if the remaining region has no usable near-vertical edge gradient. |
Notes
This is an ISO 12233-style measurement, not a certified implementation. A margin of at least four pixels and about 2 % of the shorter side is removed before analysis, because convolution padding and vignetting make the frame border unrepresentative. The edge angle is estimated from the same region that supplies the ESF, so a chart whose edge is not straight across the whole frame should be cropped to a locally straight segment first.
The curve is clipped to [0, 1]. Values above one can only come from noise
or window leakage, since a passive system cannot amplify modulation; a
genuine zero (a box or Airy transfer function's first zero) is preserved and
the curve is allowed to rise again after it. The DC term is exactly one by
construction. The frequency grid resolution is oversampling / N
cycles/pixel for an N-bin ESF, typically a few thousandths of a cycle
per pixel, and the crossings are linearly interpolated on that grid.
snr_channels ¶
snr_channels(
image: ArrayLike, roi: PatchROI
) -> dict[str, SNRResult]
Measure every colour plane and the linear luma of one flat patch.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
ArrayLike
|
Real, finite |
required |
roi
|
PatchROI
|
The patch region, lying fully inside the image. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, SNRResult]
|
For a 2-D image, |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If the image is invalid, the ROI lies outside it, or it is a 3-D image without exactly three channels. |
Notes
This is the per-channel helper a measure_all-style harness calls for a
flat-patch chart. Each plane is measured with the same statistics as
:func:snr_patch, including the validity rules; a saturated or black plane
is reported as invalid rather than omitted.
snr_patch ¶
snr_patch(
image: ArrayLike,
roi: PatchROI,
channel: int | str | None = None,
) -> SNRResult
Measure the spatial SNR of one flat patch in one linear-light frame.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
ArrayLike
|
Real, finite |
required |
roi
|
PatchROI
|
A :class: |
required |
channel
|
int | str | None
|
For a 2-D image, |
None
|
Returns:
| Type | Description |
|---|---|
SNRResult
|
The patch mean, the sample standard deviation ( |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If the image is not a finite real 2-D or 3-D array, the ROI extends past its boundary, the channel is invalid for the image, or a 3-D image has an unsupported number of channels. |
Notes
A single frame cannot separate random noise from fixed-pattern noise: every
static pixel-to-pixel difference enters this estimate. Use
:func:snr_temporal with a stable scene when the random component is what
matters. The estimator is deterministic and does not modify the input.
snr_temporal ¶
snr_temporal(
frames: ArrayLike,
roi: PatchROI,
channel: int | str | None = None,
) -> SNRResult
Measure the temporal SNR of a flat patch over a stable frame stack.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frames
|
ArrayLike
|
Real, finite stack of |
required |
roi
|
PatchROI
|
A :class: |
required |
channel
|
int | str | None
|
Resolved exactly as in :func: |
None
|
Returns:
| Type | Description |
|---|---|
SNRResult
|
|
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If the stack is not a finite real |
Notes
Pooling per-pixel variances is what makes this a random-noise estimate. Concatenating every pixel of every frame and taking one standard deviation would report fixed-pattern variation as noise; so would taking the standard deviation of the per-pixel temporal means. The estimator is deterministic and does not modify the input stack.
measure_all ¶
measure_all(
image: RenderedImage,
layout: ChartLayout,
reference: ColorReference | None = None,
) -> MetricReport
Measure every metric group the image and layout support.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
RenderedImage
|
A :class: |
required |
layout
|
ChartLayout
|
A :class: |
required |
reference
|
ColorReference | None
|
Optional per-patch CIE Lab ground truth. When |
None
|
Returns:
| Type | Description |
|---|---|
MetricReport
|
The MTF result, per-patch SNR, chart-wide colour result, and acutance
for the groups that apply; |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Notes
The groups run in a fixed order and the metadata's skipped list uses a
fixed order (MTF, acutance, SNR, colour), so repeated measurement is
bit-identical. The image buffers and the layout are never modified:
extract_patch returns views, and every metric copies what it needs.
MTF is an ISO 12233-style estimate fed linear luma; hand it
display-encoded data and the tone curve biases the curve low at high
frequencies. For a Display P3 image the linear buffer is converted to
linear sRGB before Rec. 709 luma, and the conversion is recorded in the
metadata. Acutance is a correlation-study sharpness proxy, not a
calibrated physical quantity.
Camera¶
Fixed-exposure capture and the opt-in auto-exposure policy. The camera binds a lens, sensor, and ISP; the convergence and limit evidence is recorded in the camera validation tests and the committed validation reports.
Camera orchestration and the opt-in auto-exposure policy.
:class:~camsim.camera.camera.Camera binds a lens, a sensor, and an ISP into
one object that can turn a rendered scene into a display image. The default
capture is the fixed-exposure baseline from the sensor preset; auto-exposure is
an explicit request that probes the real sensor, meters the black-corrected
signal with a centre-weighted statistic, and reports every decision.
The package re-exports the three public value types:
- :class:
Camera— capture and auto-exposure entry points. - :class:
ExposureSettings— a fixed exposure, analog gain, and readout mode. - :class:
ExposureResult— the measured settings and evidence from one auto-exposure run.
Importing the package loads the camera module, which in turn imports the
sensor, ISP, and scene layers; import camsim itself remains lazy.
Camera ¶
Camera(lens: LensConfig, sensor: Sensor, isp: ISP)
A lens, a sensor, and an ISP with fixed and auto exposure policies.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lens
|
LensConfig
|
The :class: |
required |
sensor
|
Sensor
|
The :class: |
required |
isp
|
ISP
|
The :class: |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If an argument is not an instance of its declared type. |
Attributes:
| Name | Type | Description |
|---|---|---|
lens, sensor, isp |
The three layers, stored publicly so an experiment can reconfigure the sensor seed or inspect the ISP preset. The camera holds references, not copies; mutating a configuration is visible to later captures, exactly as with the individual layers. |
Notes
A camera owns no random state. Sensor.expose(seed=...) controls
reproducibility for both policies; auto-exposure uses deterministic probe
seeds and a default capture draws from the sensor's frame stream.
auto_expose ¶
auto_expose(
scene: SceneResult,
target_fraction: float = _DEFAULT_TARGET_FRACTION,
max_iterations: int = _DEFAULT_MAX_ITERATIONS,
tolerance: float = _DEFAULT_TOLERANCE,
) -> ExposureResult
Find settings that place the scene near target_fraction.
The policy starts from the sensor preset's default exposure and analog
gain, resolves the readout mode on the first probe, and then adjusts
exposure, analog gain, and digital gain in that order. Each correction
is damped by raising target / metered to the power 0.7, so a large
error is approached geometrically rather than overshooting in one step.
A probe with more than 1 % of pixels at the earlier of the full-well
and ADC saturation codes caps the correction at 0.5: a clipped
statistic must never request more light.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scene
|
SceneResult
|
A :class: |
required |
target_fraction
|
float
|
Desired metered fraction of the usable code span, strictly between zero and one. 0.18 is photographic 18 % grey. |
_DEFAULT_TARGET_FRACTION
|
max_iterations
|
int
|
Maximum number of probe frames, an integer of at least one. The probe budget bounds run time and makes non-convergence finite. |
_DEFAULT_MAX_ITERATIONS
|
tolerance
|
float
|
Absolute metering tolerance as a fraction of full scale, finite and
strictly positive. Convergence requires
|
_DEFAULT_TOLERANCE
|
Returns:
| Type | Description |
|---|---|
ExposureResult
|
The settings of the final probe, whether it converged, the number of probes, and the final metered value. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the scene is not a |
Notes
Probe i is exposed with seed=i. A per-call seed does not advance
the sensor's frame stream, so calling auto_expose twice on the same
camera and scene is bit-identical and does not perturb a later
capture. The returned settings are exactly the ones that produced
metered_value; the policy does not report an unprobed proposal.
capture ¶
capture(
scene: SceneResult,
exposure: ExposureSettings
| ExposureResult
| None = None,
) -> RenderedImage
Expose one scene and render it through the ISP.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scene
|
SceneResult
|
A :class: |
required |
exposure
|
ExposureSettings | ExposureResult | None
|
|
None
|
Returns:
| Type | Description |
|---|---|
RenderedImage
|
A new image whose pixel buffers are the ISP's output and whose
metadata is a copy of the ISP metadata plus the exposure policy,
the applied exposure and gains, the readout mode, the lens focal
length and f-number, and, for auto exposure, the result's metered
value, iteration count, and convergence flag. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Notes
The exposure is drawn from the sensor's frame stream (seed=None),
so consecutive captures are independent and the raw frame records its
derived seed and frame index for later auditing. Reseed the sensor with
:meth:~camsim.sensor.Sensor.reseed to replay a capture bit-for-bit in
one environment. The input scene and the intermediate ISP result are
never mutated; a fresh metadata dictionary is attached to the returned
image.
ExposureResult
dataclass
¶
ExposureResult(
exposure_s: float,
analog_gain_db: float,
digital_gain: float,
mode: ExposureMode,
converged: bool,
iterations: int,
metered_value: float,
)
The settings and evidence produced by one auto-exposure run.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exposure_s
|
float
|
Integration time of the final probe, in seconds, finite and positive. |
required |
analog_gain_db
|
float
|
Analog gain of the final probe in decibels, finite and non-negative. |
required |
digital_gain
|
float
|
Digital multiplier of the final probe, finite and strictly positive. |
required |
mode
|
ExposureMode
|
Readout mode of the final probe, |
required |
converged
|
bool
|
|
required |
iterations
|
int
|
Number of probe frames exposed. It is at least one and at most the
requested |
required |
metered_value
|
float
|
Centre-weighted black-corrected signal fraction measured on the final
probe, in |
required |
Notes
The result is a frozen value: passing it to :meth:Camera.capture replays
the measured settings, including digital gain, without re-running the
search.
ExposureSettings
dataclass
¶
ExposureSettings(
exposure_s: float,
analog_gain_db: float,
mode: ExposureMode,
)
A fixed exposure request for one capture.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exposure_s
|
float
|
Integration time in seconds, finite and strictly positive. It is
validated against the sensor preset's exposure window by
:meth: |
required |
analog_gain_db
|
float
|
Analog programmable-gain-amplifier setting in decibels, finite and non-negative; 0 dB is unity gain. As with exposure, the preset range is enforced at capture time. |
required |
mode
|
ExposureMode
|
Concrete readout mode, |
required |
Notes
Digital gain is not part of a fixed request; :meth:Camera.capture uses
unity. The value object is frozen so a settings record cannot drift away
from the image it produced.
I/O¶
RAW frames and rendered images, with mandatory sidecars and strict validation.
RAW and rendered-image input/output helpers.
The two formats this package writes describe different physical quantities and must never be interchanged:
- :mod:
camsim.io.rawstores a :class:~camsim.core.frames.RawFrameas exactuint16ADC codes plus a mandatory JSON sidecar and an optional compressed mask archive. The codes still include the black-level pedestal and depend on the CFA pattern, bit depth, gains, exposure, mode, and sensor configuration. - :mod:
camsim.io.imagesstores a display-referred :class:~camsim.core.frames.RenderedImageas 8-bit PNG, 8-bit RGB TIFF, or 16-bit grayscale TIFF, with a sidecar recording the colour space, channel layout, bit depth, and metadata. The values have already passed through the output transfer function and are not linear light.
Both loaders validate aggressively: a missing or mismatched sidecar, a dtype or shape disagreement, an unknown schema version, and a lossy image format all raise with the offending file named. Precision is part of the contract — 8-bit display files quantize to 1/255 steps, and 16-bit mono TIFF to 1/65535 — so a metric computed from a reloaded file must account for that container depth.
Typical RAW frames are 10-16 bits per pixel and rendered images are 8 bits per channel (16 for scientific grayscale). Failure artefacts guarded here: reading RAW codes as display values (gamma applied twice), assuming a missing image sidecar means sRGB when it was Display P3, and letting a JPEG enter a measurement chain where codec ringing masquerades as camera sharpness.
load_image ¶
load_image(path: str | Path) -> RenderedImage
Load a PNG or TIFF rendered image and its optional sidecar.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
A |
required |
Returns:
| Type | Description |
|---|---|
RenderedImage
|
Display-referred float32 data in |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If the image file does not exist. |
ValueError
|
If the suffix is unsupported, the file is a lossy/unsupported format or Pillow mode, the sidecar's schema version/kind/format/bit depth/ channels disagree with the file, or the array shape and dtype cannot be interpreted as display data. |
OSError
|
If Pillow cannot decode the image. |
save_image ¶
save_image(image: RenderedImage, path: str | Path) -> Path
Write a rendered image and its sidecar.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
RenderedImage
|
A display-referred :class: |
required |
path
|
str | Path
|
Destination with a |
required |
Returns:
| Type | Description |
|---|---|
Path
|
The file actually written (with |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
OSError
|
If the parent directory cannot be created or a file cannot be written. |
load_raw ¶
load_raw(path: str | Path) -> RawFrame
Load a RAW frame from its .npy data file or .json sidecar.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Either the data file (its sibling |
required |
Returns:
| Type | Description |
|---|---|
RawFrame
|
Reconstructed with every scalar field, the exact masks, and the
original |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If the sidecar, data file, or a referenced mask archive is missing. |
ValueError
|
If the suffix is unsupported, the JSON is malformed, the schema version or kind is unknown, the array dtype/shape disagrees with the sidecar, a mask is missing or mis-shaped, or any frame field is physically invalid. |
save_raw ¶
save_raw(raw: RawFrame, directory: str | Path) -> Path
Write a RAW frame and its sidecar into directory.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
RawFrame
|
A :class: |
required |
directory
|
str | Path
|
Destination directory. It is created when missing. Files are named
|
required |
Returns:
| Type | Description |
|---|---|
Path
|
The |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
OSError
|
If the directory cannot be created or a file cannot be written. |
Persist RAW sensor frames as .npy arrays with a mandatory JSON sidecar.
A RAW frame is not a photograph. Its pixels are integer ADC codes that still
contain the black-level pedestal, and the same numbers mean different things
under a different colour-filter pattern, bit depth, conversion gain, exposure,
or temperature. A bare .npy file therefore preserves the array and loses
every fact the ISP needs. This module writes the array and a sidecar together:
<name>.npy— theuint16codes, exactly as the sensor produced them, with no scaling, gamma, or clipping applied.<name>.json— the schema version, every scalar/string :class:RawFramefield, the data file name/shape/dtype, and (when present) the name of the mask archive. The sidecar is the authority on how to interpret the array.<name>_masks.npz— compresseddefect_mask,hot_mask, andpdaf_maskboolean arrays. Masks share the frame's megapixel geometry, so embedding them as JSON booleans would inflate the sidecar by orders of magnitude.hot_maskis stored separately because the sensor applies its dark-current multiplier before DPC and a caller may want the hot subset even thoughdefect_maskcontains the union.
Typical RAW data span 10-16 bits per pixel; a 50 MP 10-bit frame is 100 MB of
uint16. Storing it losslessly is deliberate — a JPEG-compressed frame would
change the very noise statistics that the characterization and metrics layers
measure. File names are deterministic functions of the frame index and seed
(raw_000123_seed7.npy) so reruns overwrite the same artifacts instead of
accumulating timestamped variants.
Failure artefacts guarded here: a missing sidecar turns a self-describing frame
into an unexplained array; a sidecar/data mismatch after a rename pairs one
frame's metadata with another's pixels; a mask archive whose arrays have the
wrong shape would misalign every defective-pixel and PDAF correction; codes
above 2**bit_depth - 1 mean the data were widened without rescaling; and
loading the JSON as an image would apply a display transfer function to linear
code values. Each condition raises with the offending file and field named.
load_raw ¶
load_raw(path: str | Path) -> RawFrame
Load a RAW frame from its .npy data file or .json sidecar.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Either the data file (its sibling |
required |
Returns:
| Type | Description |
|---|---|
RawFrame
|
Reconstructed with every scalar field, the exact masks, and the
original |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If the sidecar, data file, or a referenced mask archive is missing. |
ValueError
|
If the suffix is unsupported, the JSON is malformed, the schema version or kind is unknown, the array dtype/shape disagrees with the sidecar, a mask is missing or mis-shaped, or any frame field is physically invalid. |
save_raw ¶
save_raw(raw: RawFrame, directory: str | Path) -> Path
Write a RAW frame and its sidecar into directory.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
RawFrame
|
A :class: |
required |
directory
|
str | Path
|
Destination directory. It is created when missing. Files are named
|
required |
Returns:
| Type | Description |
|---|---|
Path
|
The |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
OSError
|
If the directory cannot be created or a file cannot be written. |
Persist display-referred rendered images as PNG/TIFF plus a JSON sidecar.
A :class:~camsim.core.frames.RenderedImage holds values after the output
transfer function: floats in [0, 1] that are proportional to display
intensity, not to scene radiance. A file format stores integer codes, so this
module quantizes and records exactly how it did so:
- PNG is written at 8 bits per channel. That is the format's conventional container, and its 256 levels limit the measurable colour and noise resolution for anything beyond visual inspection.
- TIFF is written at 16 bits for a monochrome image and at 8 bits for RGB, because Pillow cannot reliably write a three-channel 16-bit TIFF. The sidecar records the bit depth actually written, so a reader never assumes precision the file does not have.
- Lossy formats are rejected. JPEG, WebP, and similar codecs alter pixel values non-uniformly; running an MTF, SNR, or delta-E metric on a file that went through DCT quantization would measure the codec, not the camera.
The <stem>.json sidecar carries kind, schema_version, format,
bit_depth, color_space, channels, the file name, and a JSON-safe
copy of the image metadata. It deliberately omits linear_rgb and stages:
those are analysis and diagnostics buffers, not display data, and a TIFF cannot
represent the unclipped linear values anyway. Losing the sidecar still leaves a
viewable image; :func:load_image then infers 8/16-bit gray versus RGB and
assumes sRGB, which is correct for a file written here but not guaranteed for
one produced elsewhere.
Failure artefacts guarded here: treating the 8-bit display codes as linear
light (every noise and MTF metric then reads the sRGB toe as sensor behaviour);
saving a gamma-encoded buffer as .npy and later loading it as linear; a
sidecar from another image supplying the wrong colour space or channel layout;
and silently accepting a JPEG that a collaborator renamed to .png. Values
are clipped only at the display boundary, where the contract already requires
[0, 1].
load_image ¶
load_image(path: str | Path) -> RenderedImage
Load a PNG or TIFF rendered image and its optional sidecar.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
A |
required |
Returns:
| Type | Description |
|---|---|
RenderedImage
|
Display-referred float32 data in |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
If the image file does not exist. |
ValueError
|
If the suffix is unsupported, the file is a lossy/unsupported format or Pillow mode, the sidecar's schema version/kind/format/bit depth/ channels disagree with the file, or the array shape and dtype cannot be interpreted as display data. |
OSError
|
If Pillow cannot decode the image. |
save_image ¶
save_image(image: RenderedImage, path: str | Path) -> Path
Write a rendered image and its sidecar.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
image
|
RenderedImage
|
A display-referred :class: |
required |
path
|
str | Path
|
Destination with a |
required |
Returns:
| Type | Description |
|---|---|
Path
|
The file actually written (with |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
OSError
|
If the parent directory cannot be created or a file cannot be written. |
Visualisation¶
Colourblind-friendly plotting helpers used by the examples and notebooks. The presentation layer is excluded from the unit-suite coverage gate; it is validated by executing the gallery examples and notebooks.
Colorblind-friendly plotting helpers for spectra, PSFs, MTF, sensors, figures.
A camsim figure is a projection of a physical quantity, not a measurement in itself. A spectral plot carries wavelength in nanometres and either relative spectral power, a dimensionless reflectance factor, or a measured response; a PSF profile is a normalized irradiance distribution whose absolute scale is arbitrary; an MTF curve is the magnitude of the normalized discrete Fourier transform of that PSF, plotted only to the 0.5 cycles/pixel Nyquist limit. The style layer fixes the Okabe-Ito colorblind-safe palette so categorical curves stay separable under common dichromacies and in greyscale print.
Real-world conventions: visible-band spectral data run roughly 360-830 nm,
MTF axes stop at Nyquist, PSF kernels span a few to a few dozen pixels, raster
figures use 100-300 dpi, and a readable legend holds at most about eight
categorical curves. Figure output is deterministic: the walkthroughs write
fixed names under CAMSIM_FIG_DIR (default results/figures) rather than
creating timestamped files.
Failure artefacts guarded here: an unlabelled wavelength axis or a log y axis that hides the passband shape; a PSF profile normalized by its unit sum, which makes every kernel look equally flat; an MTF curve drawn past Nyquist, where aliasing can make it appear to rise; a rainbow colormap that invents structure; a figure path whose parent does not exist; and Matplotlib's environment- dependent metadata, which would make repeated runs differ byte for byte.
The public surface covers the Phase 1 optics figures (spectrum, PSF profile, MTF), the Phase 2 sensor figures (spectral response, photon transfer curve, SNR, dark current, dual-gain comparison, DSNU/PRNU histograms), Phase 3's ISP stage gallery (pipeline trace, zoomed stage crops, and the bilinear/MHC demosaic comparison), and Phase 4's metric plots (measured MTF with analytic references, SNR versus illuminance, a per-patch delta-E heatmap, and acutance versus blur), plus a deterministic figure saver. Later phases extend the same namespace with experiment plots without changing these entry points.
PALETTE
module-attribute
¶
PALETTE: Final[tuple[str, ...]] = (
"#0072B2",
"#D55E00",
"#009E73",
"#CC79A7",
"#E69F00",
"#56B4E9",
"#F0E442",
"#000000",
)
Okabe-Ito colorblind-safe categorical palette.
The order puts blue first because it survives every common dichromacy and greyscale conversion; vermillion and bluish green stay distinguishable from each other under deuteranopia, which the classic red/green pair does not.
MTFPlotSeries
dataclass
¶
MTFPlotSeries(
label: str,
frequency_cp: ArrayLike,
measured_mtf: ArrayLike,
analytic_mtf: ArrayLike,
mtf50_cp: float,
mtf20_cp: float,
)
One measured MTF curve plus its analytic reference and crossings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
label
|
str
|
Short case label such as |
required |
frequency_cp
|
ArrayLike
|
Strictly increasing frequencies in |
required |
measured_mtf
|
ArrayLike
|
Measured modulation transfer factors at the same frequencies. Finite
and non-negative; the metric clips its own estimate to |
required |
analytic_mtf
|
ArrayLike
|
Reference transfer function of the same PSF at the same frequencies. |
required |
mtf50_cp
|
float
|
Interpolated crossings in cycles/pixel, or NaN when the curve never reaches that level inside Nyquist. A NaN crossing draws no marker. |
required |
mtf20_cp
|
float
|
Interpolated crossings in cycles/pixel, or NaN when the curve never reaches that level inside Nyquist. A NaN crossing draws no marker. |
required |
Notes
The arrays are converted to float64 and copied, so the series is a value object that a figure and a report can share without later mutation.
plot_acutance ¶
plot_acutance(
sigma_px: ArrayLike,
acutance: ArrayLike,
save: str | Path | None = None,
) -> tuple[Figure, Axes]
Draw edge acutance against Gaussian blur width.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
sigma_px
|
ArrayLike
|
Gaussian blur standard deviation in pixels, non-negative and strictly increasing. Zero denotes the unblurred edge. |
required |
acutance
|
ArrayLike
|
Contrast-normalized RMS gradient measured on the same edge ROI. Finite and non-negative. |
required |
save
|
str | Path | None
|
Optional PNG path. |
None
|
Returns:
| Type | Description |
|---|---|
tuple[Figure, Axes]
|
The figure and its only axis. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the arrays differ in length, the blur axis is not increasing and non-negative, or a value is negative or non-finite. |
Notes
Acutance is a correlation-study sharpness proxy, not a calibrated MTF. Its absolute value depends on the ROI geometry, so only measurements made with the same ROI belong on one curve.
plot_delta_e_heatmap ¶
plot_delta_e_heatmap(
patch_names: Sequence[str],
delta_e: ArrayLike,
*,
rows: int = 4,
columns: int = 6,
title: str = "Per-patch CIEDE2000",
save: str | Path | None = None,
) -> tuple[Figure, Axes]
Draw a chart-ordered delta-E heatmap with the value printed per cell.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
patch_names
|
Sequence[str]
|
Patch labels in chart order, read left to right and top to bottom.
Exactly |
required |
delta_e
|
ArrayLike
|
Non-negative, finite colour differences in the same order. |
required |
rows
|
int
|
Chart geometry. The ColorChecker Classic is four rows of six patches. |
4
|
columns
|
int
|
Chart geometry. The ColorChecker Classic is four rows of six patches. |
4
|
title
|
str
|
Figure title. |
'Per-patch CIEDE2000'
|
save
|
str | Path | None
|
Optional PNG path. |
None
|
Returns:
| Type | Description |
|---|---|
tuple[Figure, Axes]
|
The figure and its image axis. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the geometry is invalid, the name or value count differs from it, or a value is negative or non-finite. |
Notes
Values are printed because a bare colour ramp makes it easy to read hue as
the measurement. The perceptually uniform viridis map avoids the false
banding of a rainbow map, and the colour scale starts at zero because a
colour difference cannot be negative.
plot_mtf_curve ¶
plot_mtf_curve(
series: Sequence[MTFPlotSeries],
save: str | Path | None = None,
) -> tuple[Figure, list[Axes]]
Draw measured MTF curves with their analytic references and crossings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
series
|
Sequence[MTFPlotSeries]
|
One to four :class: |
required |
save
|
str | Path | None
|
Optional PNG path. The parent directory is created by
:func: |
None
|
Returns:
| Type | Description |
|---|---|
tuple[Figure, list[Axes]]
|
The figure and its flat list of axes, one per case. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If no series is supplied or a series is malformed. |
Notes
The analytic reference is dashed and the measurement solid on the same axes, so a systematic bias is visible instead of hidden behind a single summary number. The horizontal axis stops at Nyquist. A NaN crossing draws no marker because the curve never reached that level inside the measured band.
plot_snr_vs_lux ¶
plot_snr_vs_lux(
lux: ArrayLike,
snr_db: Mapping[str, ArrayLike],
save: str | Path | None = None,
) -> tuple[Figure, Axes]
Draw flat-field SNR against scene illuminance for several channels.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
lux
|
ArrayLike
|
Scene illuminance in lux, one value per measurement. Finite, positive, and strictly increasing. |
required |
snr_db
|
Mapping[str, ArrayLike]
|
Channel label -> SNR in decibels at each lux level. Every series must
have the same length as |
required |
save
|
str | Path | None
|
Optional PNG path. |
None
|
Returns:
| Type | Description |
|---|---|
tuple[Figure, Axes]
|
The figure and its only axis. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the lux axis is not strictly increasing and positive, the mapping is empty, or a series has the wrong length or a non-finite sample. |
Notes
The illuminance axis is logarithmic because a sweep spans decades; the SNR axis is linear because decibels are already a logarithmic quantity.
plot_demosaic_comparison ¶
plot_demosaic_comparison(
reference: ArrayLike,
bilinear: ArrayLike,
mhc: ArrayLike,
save: str | Path | None = None,
) -> tuple[Figure, list[Axes]]
Compare a reference RGB image with bilinear and MHC reconstructions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
reference
|
ArrayLike
|
The full-resolution |
required |
bilinear
|
ArrayLike
|
Reconstructions of the same mosaic with the respective methods. |
required |
mhc
|
ArrayLike
|
Reconstructions of the same mosaic with the respective methods. |
required |
save
|
str | Path | None
|
Optional PNG path. |
None
|
Returns:
| Type | Description |
|---|---|
tuple[Figure, list[Axes]]
|
A two-row figure: the reference and both reconstructions on the top row, and absolute-error maps plus a numeric summary on the bottom row. All three images use the same sRGB-encoded display transform; the two error maps share one linear-light colour scale. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If any input is not a non-empty finite |
Notes
The reference and reconstructions are linear light in [0, 1]; the
OETF is applied for display only. The error maps are computed in linear
light and labelled as such. The summary reports max and mean absolute
error over all samples, which is the same statistic the G3 tests gate.
plot_pipeline_trace ¶
plot_pipeline_trace(
stages: Mapping[str, StageOutput],
save: str | Path | None = None,
) -> tuple[Figure, list[Axes]]
Draw a contact sheet of every ISP trace stage.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stages
|
Mapping[str, StageOutput]
|
Ordered |
required |
save
|
str | Path | None
|
Optional PNG path. The parent directory is created by
:func: |
None
|
Returns:
| Type | Description |
|---|---|
tuple[Figure, list[Axes]]
|
The figure and its flat list of axes. Panels for a partially filled grid are hidden. |
Notes
Pre-BLC panels are code-unit-normalised; the linear panels from BLC through the last pre-tone stage share one scale; tone and output are shown as encoded display. The suptitle states these rules so a reader cannot compare panels with the wrong unit assumption.
plot_zoom_crops ¶
plot_zoom_crops(
stages: Mapping[str, StageOutput],
save: str | Path | None = None,
) -> tuple[Figure, list[Axes]]
Draw nearest-neighbour zoomed crops of every ISP trace stage.
The crop is selected once, at the strongest gradient of the final display image, and the same sensor-pixel region is cut from every stage. This keeps the comparison spatially registered while showing the zipper, fringe, and halo artefacts that a full-frame contact sheet is too small to resolve.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stages
|
Mapping[str, StageOutput]
|
Ordered stage trace, as accepted by :func: |
required |
save
|
str | Path | None
|
Optional PNG path. |
None
|
Returns:
| Type | Description |
|---|---|
tuple[Figure, list[Axes]]
|
The figure and its flat list of axes. |
Notes
The panel display rules are identical to :func:plot_pipeline_trace, so
brightness, colour, and units remain comparable between the two figures.
plot_dark_current_vs_temperature ¶
plot_dark_current_vs_temperature(
config: SensorConfig,
*,
measured: Mapping[
str, tuple[Sequence[float], Sequence[float]]
]
| None = None,
differential_reference_c: float | None = None,
note: str | None = None,
ax: Axes | None = None,
title: str | None = None,
) -> tuple[Figure, Axes]
Plot the dark-current doubling model with optional measured points.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
SensorConfig
|
Sensor preset supplying each mode's reference dark current and the reference/doubling temperatures. |
required |
measured
|
Mapping[str, tuple[Sequence[float], Sequence[float]]] | None
|
Optional mapping |
None
|
differential_reference_c
|
float | None
|
Optional reference temperature. When supplied, each model curve is
plotted as |
None
|
note
|
str | None
|
Optional annotation placed in the lower-right corner, for example a reminder that low-temperature differential points are compressed by the zero-charge clamp and sub-LSB ADC. |
None
|
plot_fixed_pattern_histograms ¶
plot_fixed_pattern_histograms(
dsnu_e: ArrayLike,
prnu_fraction: ArrayLike,
*,
dsnu_configured_e: float | None = None,
prnu_configured_percent: float | None = None,
dsnu_reported_e: float | None = None,
title: str | None = None,
) -> tuple[Figure, tuple[Axes, Axes]]
Plot per-pixel DSNU and PRNU distributions from measured frames.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dsnu_e
|
ArrayLike
|
Per-pixel mean dark signal in electrons from an averaged dark frame. This is the raw, post-clamp, post-quantization distribution; at low signal it is narrower than the configured pre-clamp DSNU. |
required |
prnu_fraction
|
ArrayLike
|
Per-pixel relative responsivity deviation from an averaged flat field, as a fraction (0.005 is 0.5 %). |
required |
dsnu_configured_e
|
float | None
|
Optional configured pre-clamp DSNU, overlaid as a Gaussian reference. |
None
|
prnu_configured_percent
|
float | None
|
Optional configured PRNU in percent, overlaid as a Gaussian reference. |
None
|
dsnu_reported_e
|
float | None
|
Optional input-referred DSNU from the characterization report, printed alongside the raw spread so the clamp/quantization compression is not mistaken for a failed measurement. |
None
|
plot_mode_comparison ¶
plot_mode_comparison(
reports: Mapping[str, EMVAReport],
*,
title: str | None = None,
) -> tuple[Figure, tuple[Axes, ...]]
Compare read noise, saturation capacity, and dynamic range by mode.
Three small panels keep the electron and decibel axes separate; a single combined score would hide which side of the dual-conversion-gain trade changed.
plot_ptc ¶
plot_ptc(
reports: Mapping[str, EMVAReport],
*,
ax: Axes | None = None,
title: str | None = None,
) -> tuple[Figure, Axes]
Plot measured photon-transfer points and each report's linear PTC model.
The reference line is variance = K * mean + (K * read_noise)**2 using
the report's measured system gain and read noise. At the lowest signals the
measured variance also contains quantization, so the line is a model, not a
fit through every point.
plot_sensor_spectral_response ¶
plot_sensor_spectral_response(
config: SensorConfig,
*,
ax: Axes | None = None,
title: str | None = None,
) -> tuple[Figure, Axes]
Plot QE, CFA, and IR-cut curves referenced by a sensor preset.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
SensorConfig
|
Sensor preset whose |
required |
ax
|
Axes | None
|
Optional axes to draw on. |
None
|
title
|
str | None
|
Optional axes title. |
None
|
Returns:
| Type | Description |
|---|---|
tuple[Figure, Axes]
|
The figure and axes containing one curve per filter/channel. |
plot_snr_vs_irradiance ¶
plot_snr_vs_irradiance(
reports: Mapping[str, EMVAReport],
*,
ax: Axes | None = None,
title: str | None = None,
) -> tuple[Figure, Axes]
Plot SNR in dB against sensor-plane irradiance for one or more modes.
figure_dir ¶
figure_dir(
default: str | Path = DEFAULT_FIGURE_DIR,
) -> Path
Return the figure output directory, creating it when necessary.
CAMSIM_FIG_DIR wins when it is set to a non-empty value; otherwise
default is used. A relative path is resolved against the current
working directory, which is why examples pass a repository-root-anchored
default rather than relying on where they were launched. The directory is
created eagerly so a later save cannot fail on a missing parent.
plot_mtf ¶
plot_mtf(
psfs: KernelSource,
*,
labels: str | Sequence[str] | None = None,
frequencies_cp: ArrayLike | None = None,
pixels_per_degree: float | None = None,
mark_mtf50: bool = True,
mark_mtf20: bool = True,
title: str | None = None,
ax: Axes | None = None,
show_legend: bool | None = None,
linewidth: float = 1.7,
) -> tuple[Figure, Axes]
Plot MTF curves for one or more PSF kernels.
The curve is computed with :func:camsim.optics.psf.psf_mtf at
frequencies from zero to Nyquist. MTF50 is located with
:func:camsim.optics.psf.psf_mtf50, the same function the metrics layer
uses, and MTF20 with an equivalent dense-grid interpolation. A crossing is
marked only when the MTF actually reaches that level by 0.5 cycles/pixel.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
psfs
|
KernelSource
|
A 2-D kernel, a sequence of kernels, or a mapping from label to kernel. |
required |
labels
|
str | Sequence[str] | None
|
Optional labels overriding mapping keys or generated |
None
|
frequencies_cp
|
ArrayLike | None
|
Optional 1-D frequency samples in cycles/pixel within |
None
|
pixels_per_degree
|
float | None
|
Optional lens scale. When supplied, a secondary top axis converts
cycles/pixel to cycles/degree through
:meth: |
None
|
mark_mtf50
|
bool
|
Draw horizontal reference lines and a marker at each curve's first crossing of 0.5 or 0.2. |
True
|
mark_mtf20
|
bool
|
Draw horizontal reference lines and a marker at each curve's first crossing of 0.5 or 0.2. |
True
|
title
|
str | None
|
Optional axes title. |
None
|
ax
|
Axes | None
|
Existing axes to draw on. |
None
|
show_legend
|
bool | None
|
|
None
|
linewidth
|
float
|
Curve line width in points. |
1.7
|
Returns:
| Type | Description |
|---|---|
tuple[Figure, Axes]
|
The figure and axes containing the MTF curves. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If frequencies leave |
plot_psf ¶
plot_psf(
psfs: KernelSource,
*,
labels: str | Sequence[str] | None = None,
mode: Literal["cut", "radial"] = "cut",
max_radius_px: float | None = None,
normalize: bool = True,
log_scale: bool = False,
title: str | None = None,
ax: Axes | None = None,
show_legend: bool | None = None,
linewidth: float = 1.5,
) -> tuple[Figure, Axes]
Plot central-cut or radial profiles of one or more PSF kernels.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
psfs
|
KernelSource
|
A 2-D kernel, a 3-D stack or sequence of kernels, or a mapping from label to kernel. A mapping's keys become the default legend labels. |
required |
labels
|
str | Sequence[str] | None
|
Optional labels overriding mapping keys or generated |
None
|
mode
|
Literal['cut', 'radial']
|
|
'cut'
|
max_radius_px
|
float | None
|
Optional radius limit in pixels. For a cut the x axis is symmetric, so a value of 4 shows offsets from -4 to +4. |
None
|
normalize
|
bool
|
Divide each profile by its peak. PSFs are already normalized to unit
sum, which would flatten every profile to roughly |
True
|
log_scale
|
bool
|
Use a logarithmic y axis, clipping at a small floor so exact zeros in an Airy ring do not produce missing-data warnings. |
False
|
title
|
str | None
|
Optional axes title. |
None
|
ax
|
Axes | None
|
Existing axes to draw on. |
None
|
show_legend
|
bool | None
|
|
None
|
linewidth
|
float
|
Profile line width in points. |
1.5
|
Returns:
| Type | Description |
|---|---|
tuple[Figure, Axes]
|
The figure and axes containing the profiles. |
plot_spectrum ¶
plot_spectrum(
spectra: SpectrumInput,
wavelengths_nm: ArrayLike | None = None,
*,
labels: str | Sequence[str] | None = None,
title: str | None = None,
ylabel: str | None = None,
ax: Axes | None = None,
show_legend: bool | None = None,
log_scale: bool = False,
linewidth: float = 1.5,
alpha: float = 0.9,
) -> tuple[Figure, Axes]
Plot illuminant SPDs or reflectance spectra against wavelength.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spectra
|
SpectrumInput
|
A :class: |
required |
wavelengths_nm
|
ArrayLike | None
|
Wavelength axis in nanometres for raw arrays. Must match the curve length and be strictly increasing. Ignored for project objects, whose validated grid is authoritative. |
None
|
labels
|
str | Sequence[str] | None
|
Legend labels overriding the object-provided names. A single string is used only when there is one curve; otherwise one label per curve is required. |
None
|
title
|
str | None
|
Optional titles. When |
None
|
ylabel
|
str | None
|
Optional titles. When |
None
|
ax
|
Axes | None
|
Existing axes to draw on. |
None
|
show_legend
|
bool | None
|
|
None
|
log_scale
|
bool
|
Use a logarithmic y axis. It can reveal a small out-of-band response or a faint Airy-like feature, but it compresses the passband shape; the default linear axis is the honest view of relative magnitudes. Exact zero samples are not representable on a log axis. |
False
|
linewidth
|
float
|
Line styling in points and opacity. |
1.5
|
alpha
|
float
|
Line styling in points and opacity. |
1.5
|
Returns:
| Type | Description |
|---|---|
tuple[Figure, Axes]
|
The figure and axes containing the curves. |
save_figure ¶
save_figure(
figure: Figure,
destination: str | Path,
*,
dpi: int = 150,
) -> Path
Write figure to destination and return the resolved path.
Parent directories are created on demand. PNG output carries a fixed
Software metadata field instead of Matplotlib's default version string,
which keeps repeated runs reproducible across environments. The function
does not close the figure; callers that generate a gallery in a loop should
close each figure explicitly to release memory.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
figure
|
Figure
|
A Matplotlib figure, typically returned by one of the |
required |
destination
|
str | Path
|
Output path. The suffix selects the format ( |
required |
dpi
|
int
|
Raster resolution in dots per inch. 100-300 is the useful range; 150 is the default compromise between screen review and print. |
150
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
set_style ¶
set_style() -> None
Apply camsim's colorblind-friendly Matplotlib defaults.
The function updates a fixed set of rcParams and returns None. It
sets the categorical colour cycle to :data:PALETTE, selects DejaVu Sans,
turns on a light grid behind the data, removes the top and right spines,
and chooses a perceptually uniform image colormap. Because only those keys
are touched, an embedding application can layer its own house style before
or after the call; because the values are fixed, repeated calls produce the
same state and never depend on the previously active style.
Command line¶
The camsim console entry point.
Command-line interface for camsim.
The CLI is the reproducible boundary between the library and the filesystem. Every command either validates an input or writes a self-describing artifact:
camsim validate PATHloads a sensor or ISP preset and prints its errors and warnings. Exit status is part of the contract:0for a valid file,1for a file that was read but violates the schema, and2for input that could not be read at all.camsim schema {sensor,isp}prints the Pydantic JSON Schema, giving editors completion for unit-bearing fields such aspixel.pitch_umandadc.full_scale_uv.camsim inforeports the package version, runtime, and shipped presets.camsim rendercomposes one scene render, one sensor exposure, and one ISP pass. The RAW frame and the rendered PNG therefore describe the same exposure; the frame is saved with its JSON sidecar and mask archive, and alayout.jsoncarries the metrics chart regions, colour reference, pixel pitch, and focal length needed bycamsim measure.camsim characterizeruns the EMVA 1288-style flat-field measurement and writesemva_<mode>.json. The numbers are simulated measurements of the configured sensor, not a certified characterization.camsim measurereloads a PNG/TIFF image and a layout sidecar, runs the full metric harness, and writes the report JSON. A colorchecker image needs a colour reference; without one the harness raises rather than assuming D65.camsim figuresreruns the committed example gallery (01-04) withMPLBACKEND=Agg. It requires a source checkout because the examples are not shipped in the wheel.
Physical failure modes are kept distinct from configuration ones. A RAW file
without its sidecar cannot be interpreted (bit depth, CFA, black level, and
gains live there), a display PNG is 8-bit so measurements inherit 1/255
quantization, and a lossy image is rejected before it can contaminate a metric.
The new commands map input and validation failures to exit status 2 with a
message on stderr; a gallery example that exits non-zero maps to 1 so a
batch caller can tell a bad argument from a failed run. Handlers import the
simulation layers lazily, so camsim info stays cheap and does not import
Pillow or the simulation packages.