Skip to content

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:~camsim.config.schema.LensConfig whose focal length, f-number, and geometry describe the optics. It is recorded in captured metadata; scene rendering uses the same geometry when the caller passes it to :func:camsim.scene.targets.render.

required
sensor Sensor

The :class:~camsim.sensor.Sensor that turns scene irradiance into RAW frames. The camera never replaces or resamples the scene: the cube must already be on the sensor's pixel grid.

required
isp ISP

The :class:~camsim.isp.pipeline.ISP that renders RAW frames. Its configuration hash is preserved in every capture's metadata.

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:~camsim.scene.targets.SceneResult. Its irradiance is exposed directly; the detector never resamples.

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 abs(metered - target) <= tolerance.

_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 SceneResult, a parameter is out of range, or the frame has no usable white span (black level leaves none).

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:~camsim.scene.targets.SceneResult on the sensor's pixel grid.

required
exposure ExposureSettings | ExposureResult | None

None uses the fixed baseline: the sensor preset's default exposure and analog gain, unity digital gain, and "LCG". Auto-exposure is opt-in, so the default never spends probes and never depends on scene brightness. An :class:ExposureSettings applies a fixed request with unity digital gain; an :class:ExposureResult replays an auto-exposure measurement, including its digital gain and mode.

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. digital_gain is the policy multiplier; the sensor composes it with the ADC preset's own digital gain, so the RAW frame can record a larger effective value.

Raises:

Type Description
ValueError

If scene is not a SceneResult, exposure is not None or an accepted value type, or the requested exposure or analog gain lies outside the sensor preset's limits.

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:~camsim.config.schema.ISPConfig. The stages run in the fixed order dpc, blc, lsc, awb, demosaic, ccm, denoise, sharpen, tone, output; each stage's enabled toggle selects whether it runs, subject to the CFA applicability rules. The output and tone colour spaces must agree, and a disabled tone stage requires sRGB.

required

Raises:

Type Description
ValueError

If config is not an :class:ISPConfig, the tone and output colour spaces differ, or tone is disabled with a non-sRGB output. These are configuration errors that would otherwise produce a mislabelled image.

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 property

config: ISPConfig

The validated configuration captured at construction.

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:~camsim.core.frames.RawFrame whose data are ADC codes including the black-level pedestal. The frame is never mutated; every stage returns a new array.

required
trace bool

When true, record a :class:~camsim.core.frames.StageOutput for every executed stage, in pipeline order, plus a final "output" entry. When false (the default), RenderedImage.stages is None and no trace structures are allocated.

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 raw is not a :class:RawFrame; if the frame's black level leaves no positive white span; if a Bayer frame has the CCM enabled without a supplied matrix; if a colour frame has demosaicing disabled; or if a mutated configuration violates the tone/output colour-space rules. Each condition would otherwise produce a silently wrong image or trace.

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:~camsim.config.schema.SensorConfig. Spectral CSVs referenced by config.spectral are read once at construction. A relative path is resolved against the working directory, then the repository root (for source-checkout compatibility), and finally the bundled camsim.data package, which is where the shipped preset's spectra/*.csv files live after installation; no implicit resampling is performed.

required
seed int

Non-negative seed of the frame stream used when expose is called without an explicit per-call seed.

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 config.defects.seed. It is independent of the frame stream.

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 "W/m^2/nm" and "photons/s/m^2/nm" are accepted.

required
exposure_s float

Integration time in seconds, within the preset's exposure limits.

required
mode str

"auto" chooses HCG below auto_hcg_threshold_e and LCG otherwise; "LCG" and "HCG" force a readout mode.

'auto'
analog_gain_db float | None

Programmable analog gain in decibels. None uses the preset default; the value must lie inside the configured limits.

None
digital_gain float

Additional digital multiplier composed with the ADC preset's digital_gain. It is applied to the signal code before the black level is re-added, so it cannot lift the pedestal.

1.0
temperature_c float | None

Sensor temperature override in degrees Celsius. None uses the preset's default. Dark current follows the doubling model.

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; frame_index still records the position in the capture sequence.

None
max_memory_bytes int | None

Optional peak-memory budget for this exposure, in bytes. None uses CAMSIM_MAX_SIMULATION_BYTES when set, otherwise 8 GiB. The guard estimates the float64 cube copies, channel planes, and quantized planes before the integration allocates them; a request above the budget raises a clear ValueError. v0.1.0 does not chunk spectral cubes, so reduce the spatial shape or process the data externally before calling expose.

None

Returns:

Type Description
RawFrame

uint16 codes including the black-level pedestal, with the CFA pattern, gains, mode, temperature, frame seed/index, output pixel pitch, the defect union, the separate hot-pixel subset, the PDAF mask, and a stable configuration hash.

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 (H, W, L). Float32 and float64 are preserved; integer, float16, and other real inputs are promoted to float64. Values must be finite and non-negative.

required
wavelengths_nm WavelengthGrid | ArrayLike

A :class:WavelengthGrid, or an array-like of nanometres that is validated into one. Its length must equal data.shape[2].

required
unit SpectralUnit

"W/m^2/nm" or "photons/s/m^2/nm". The unit is metadata that conversions and later stages use; the class never guesses it.

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 N; real lenses span f/0.7 to f/22.

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 (0, 1] (0.9 is a good AR-coated lens) or a CSV path for a spectral transmission curve, resolved by the optics layer.

vignetting Fraction | None

Optional strength in [0, 1] scaling the physical cos**4 falloff; None disables vignetting in the scene renderer.

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.

Core data contracts and numerical primitives shared by every camsim layer.

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 frequency nu: 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 (H, W, L). Float32 and float64 are preserved; integer, float16, and other real inputs are promoted to float64. Values must be finite and non-negative.

required
wavelengths_nm WavelengthGrid | ArrayLike

A :class:WavelengthGrid, or an array-like of nanometres that is validated into one. Its length must equal data.shape[2].

required
unit SpectralUnit

"W/m^2/nm" or "photons/s/m^2/nm". The unit is metadata that conversions and later stages use; the class never guesses it.

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:RawFrame couples a 2-D uint16 array 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:RenderedImage keeps the optional pre-transfer linear_rgb buffer 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 "sRGB OETF applied".

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.

to_dict

to_dict() -> dict[str, Any]

Return a JSON-friendly mapping with array data as nested lists.

from_dict classmethod

from_dict(payload: Mapping[str, Any]) -> Self

Reconstruct a stage output from :meth:to_dict output.

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 (H, W), dtype uint16. The array stores ADC codes including black_level_dn; it is not an electron count and is not proportional to display brightness. Values must not exceed 2**bit_depth - 1.

required
cfa CFAPattern

Colour-filter pattern of the sensor. A string matching a :class:CFAPattern member is accepted and normalised to the enum.

required
bit_depth int

ADC resolution in bits, 8-16 inclusive. Every code in a valid frame lies in [0, 2**bit_depth - 1].

required
black_level_dn float

ADC code that corresponds to zero signal, 0 <= black_level_dn < 2**bit_depth. Subtract it before gain correction, white balance, or demosaicing.

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 "LCG" (low conversion gain) or "HCG" (high conversion gain), as defined by the active sensor configuration.

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 data, marking every defective site a defect-correction stage should replace. It is the union of the dead, hot, and stuck classes.

None
hot_mask NDArray[bool_] | None

Optional boolean mask, same shape as data, selecting the hot-pixel subset of defect_mask. The sensor has already applied the temperature-dependent dark-current multiplier at those sites; the mask is kept separately because a caller may want to inspect or model the hot-pixel population without re-deriving it from rates and a seed. It is a subset of defect_mask whenever both are present.

None
pdaf_mask NDArray[bool_] | None

Optional boolean mask, same shape as data, marking phase-detection autofocus pixels whose signal is not a normal imaging sample.

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 (H, W, 3) for RGB and (H, W, 1) for mono, stored as float32 within [0, 1]. A tolerance of 1e-6 absorbs round-off before the final clip; values are not linear light and must not be fed to algorithms that expect radiance.

required
color_space ColorSpace

Output colour space, "sRGB" or "DisplayP3". It fixes the primaries and transfer function used to interpret both data and linear_rgb.

required
channels ChannelLayout

"RGB" for a three-channel colour image or "MONO" for a single luminance channel.

required
linear_rgb NDArray[floating[Any]] | None

Optional pre-transfer linear RGB buffer with the same shape as data. Its R, G, and B primaries follow color_space: for "DisplayP3" the buffer is linear Display P3, not linear sRGB. It may exceed 1.0 (highlights are not clipped) and is the buffer colour metrics linearize from; it is not display data. A metric that applies sRGB/Rec. 709 weights directly must convert a P3 buffer to linear sRGB first; :func:camsim.metrics.measure_all does so exactly.

None
stages dict[str, StageOutput] | None

Optional ISP trace mapping stage names to :class:StageOutput snapshots. None when tracing is disabled.

None
metadata dict[str, Any]

Free-form capture and processing provenance such as the white point, exposure, ISP configuration hash, and the applied ccm_matrix. Defaults to an empty dict.

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.

to_dict

to_dict() -> dict[str, Any]

Return a JSON-friendly mapping with arrays as nested lists.

from_dict classmethod

from_dict(payload: Mapping[str, Any]) -> Self

Reconstruct an image from :meth:to_dict output or decoded JSON.

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]

(n, 3) array of CIE 1976 Lab* values, one row per chart patch in layout order. L* spans 0-100; a* and b* are unbounded in principle but ColorChecker patches stay within about -60 to 100. Stored as a copied float64 array; NaN and infinity are rejected.

required
white_point_xy tuple[float, float]

CIE 1931 chromaticity (x, y) of the illuminant white point, for example (0.3127, 0.3290) for D65 or (0.4369, 0.4041) for a 3000 K blackbody.

required
illuminant_name str

Human-readable illuminant label such as "D65", "3000K", or "4000K". Non-empty; used to select matching references and to report provenance.

required
observer str

CIE standard observer used for the XYZ-to-Lab conversion. Defaults to "CIE 1931 2 degree"; a metric that uses the 1964 10-degree observer must state it explicitly.

_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.

to_dict

to_dict() -> dict[str, Any]

Return a JSON-friendly mapping with Lab rows as nested lists.

from_dict classmethod

from_dict(payload: Mapping[str, Any]) -> Self

Reconstruct a colour reference from :meth:to_dict output or JSON.

Configuration

Pydantic v2 schemas with units in every field name, cross-field physical validators, provenance on every preset, and JSON I/O.

Pydantic configuration models, JSON I/O, and factory presets.

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_e electrons) or in the ADC (full_scale_uv volts). At unity analog gain the voltage at full well is full_well_e * conversion_gain_uv_per_e; if it exceeds full_scale_uv the 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_e keeps 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 f projects a sensor diagonal d onto a field of view 2 atan(d / 2f), and a pixel of pitch p subtends atan(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 N; real lenses span f/0.7 to f/22.

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 (0, 1] (0.9 is a good AR-coated lens) or a CSV path for a spectral transmission curve, resolved by the optics layer.

vignetting Fraction | None

Optional strength in [0, 1] scaling the physical cos**4 falloff; None disables vignetting in the scene renderer.

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 (YYYY-MM-DD) on which the source was read.

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_sensor and load_isp raise :class:pydantic.ValidationError for schema violations. Programmatic callers get the complete list of problems in one exception rather than failing field by field.
  • :func:validate_config never raises for malformed content; it returns a :class:ValidationReport whose errors and warnings lists 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_schema so 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()

valid property

valid: bool

Return True when the file produced no errors.

to_dict

to_dict() -> dict[str, Any]

Return a JSON-serialisable summary of the report.

load_sensor

load_sensor(path: PathLike) -> SensorConfig

Load and validate a sensor preset.

Raises:

Type Description
FileNotFoundError

If path does not exist (also PermissionError or IsADirectoryError for other filesystem failures).

JSONDecodeError

If the file is not valid JSON.

ValidationError

If the payload violates :class:~camsim.config.schema.SensorConfig.

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.

isp_schema

isp_schema() -> dict[str, Any]

Return the JSON Schema (draft 2020-12) for ISP 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 scenes, glyph targets, and illuminants.

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 "W/m^2/nm" on the sensor grid, before the sensor's IR-cut filter and CFA. Its pixel_pitch_um metadata names the sensor grid.

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 "colorchecker" or "slanted_edge".

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

"colorchecker", "slanted_edge", "flat_patch", or "siemens_star". Target-specific options use their documented defaults (5 degree slanted edge, 36-spoke star, 0.5 flat reflectance).

required
illuminant str

"D65", "3000K", or "4000K" (see :func:camsim.scene.illuminants.illuminant).

'D65'
lux float

Scene illuminance in lux. Zero is a valid dark scene; negative values are rejected.

100.0
lens LensConfig | None

A :class:~camsim.config.schema.LensConfig. None uses the reference phone lens: f/1.8, 6.4 mm focal length, 11.46 mm diagonal, transmittance 0.9, and no vignetting.

None
optics ArrayLike | None

A 2-D PSF kernel sampled on the oversampled grid, or None for a diffraction- and aberration-free system. A sensor-grid Gaussian of sigma pixels must be passed as gaussian_psf(sigma * oversampling); passing the sensor-grid kernel directly under-blurs by the oversampling factor.

None
pixel_pitch_um float

Sensor pixel pitch in micrometres. The target grid is shape * oversampling; its sample pitch is pixel_pitch_um / oversampling.

1.12
shape tuple[int, int]

Sensor-grid (height, width). The returned irradiance has exactly this spatial shape.

(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 uses CAMSIM_MAX_SIMULATION_BYTES when set, otherwise 8 GiB. The guard estimates the oversampled cube, one float64 working copy, and the sensor-grid output before allocating them, and raises a clear ValueError when the estimate exceeds the budget. v0.1.0 does not chunk spectral processing; reduce shape/oversampling or raise the limit only when the machine has enough RAM.

None

Returns:

Type Description
SceneResult

Sensor-plane :class:~camsim.core.spectral.SpectralIrradiance in "W/m^2/nm" with pixel_pitch_um metadata, a sensor-grid :class:SceneLayout, a spectrally computed :class:ColorReference for the illuminant, and a ground-truth metadata dict.

Raises:

Type Description
ValueError

If the target, shape, oversampling, seed, lens, pitch, illuminant, or PSF is invalid, if the ColorChecker grid cannot fit in shape, or if the estimated peak memory exceeds the configured budget (the error names the estimate, the limit, and how to reduce the request).

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]

(height, width) of the rendered target grid. The 24 patches are fitted as square cells inside a 6 % margin; shapes smaller than about 10 x 14 pixels cannot hold the grid and are rejected.

required

Returns:

Name Type Description
reflectance NDArray[float64]

A float64 (height, width, L) cube of reflectance factors in [0, 1] on the common 360-830 nm, 5 nm grid. Patch pixels carry the BabelColor average spectrum for that chart position; the surrounding margin is a neutral 0.05 reflectance.

layout SceneLayout

A :class:SceneLayout whose 24 ROIs are inset 15 % from each patch edge, lie entirely inside the painted patch, and use the physical chart order and names.

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]

(height, width) of the rendered target grid.

required
reflectance float

Constant reflectance in [0, 1]. 0.5 is a neutral mid-grey that leaves highlight and shadow headroom in a 10-bit capture; 0.18 is the photographic 18 % grey card.

_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]

(height, width) of the rendered target grid.

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]

(height, width) of the rendered target grid.

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_layout reuses the renderer's own cell geometry (_colorchecker_cells) and ROI derivation (_layout_from_painted_cells) at the same shape and oversampling, 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_layout names the full frame "flat", matching the uniform field used for SNR, shading, and fixed-pattern measurements.
  • slanted_edge_layout supplies 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:~camsim.scene.targets.SceneResult returned by :func:camsim.scene.targets.render.

required
lens LensConfig | None

Optional lens used for the render. Supplying it records the focal length for cycles/degree conversion; None leaves the focal length unknown even though the renderer used a default lens, because the layout describes the camera actually used.

None

Returns:

Type Description
ChartLayout

The scene's patch and edge regions in sensor-pixel coordinates, the scene's spectrally computed :class:ColorReference, and the sensor pixel pitch from the irradiance metadata.

Raises:

Type Description
ValueError

If scene is not a :class:~camsim.scene.targets.SceneResult, or lens is neither None nor a :class:~camsim.config.schema.LensConfig.

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", "3000K", or "4000K". The layout carries the spectrally computed Lab reference for exactly this illuminant. Any other name raises through :func:camsim.scene.illuminants.reference_lab; there is no silent D65 fallback, because a mismatched reference looks like a sensor colour error.

'D65'
lens LensConfig | None

Optional :class:~camsim.config.schema.LensConfig. When supplied, its focal_length_mm is recorded for cycles/degree conversion. None leaves the focal length unknown (None) even though the renderer has a reference lens default, because the layout describes the camera actually used, not a default.

None
shape tuple[int, int]

Sensor-grid (height, width) of the render. Defaults to (256, 256), matching :func:camsim.scene.targets.render.

(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 shape * oversampling grid and would otherwise include surround pixels or miss the patch.

4
pixel_pitch_um float

Sensor pixel pitch in micrometres. Defaults to the renderer's 1.12 um.

_DEFAULT_PIXEL_PITCH_UM

Returns:

Type Description
ChartLayout

Named "colorchecker", with 24 patches in physical chart order, each inset inside its painted cell and guaranteed to contain only patch colour. The reference is present for every supported illuminant, including D65.

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:~camsim.config.schema.LensConfig, or the pixel pitch is not finite and positive.

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 (height, width). Defaults to (256, 256), matching :func:camsim.scene.targets.render.

(256, 256)
pixel_pitch_um float

Sensor pixel pitch in micrometres; 1.12 um matches the renderer.

_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 "flat_patch" with a single full-frame ROI named "flat" that covers every sensor pixel, exactly as the renderer's flat target does. reference is None: a flat field measures noise, not colour.

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 (height, width). Defaults to (256, 256), matching :func:camsim.scene.targets.render.

(256, 256)
pixel_pitch_um float

Sensor pixel pitch in micrometres; 1.12 um matches the renderer.

_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" with no patches and one centred, half-size ROI named "edge". Using the renderer's own centring helper keeps the region identical to render("slanted_edge", ...).layout. A centred region avoids the frame border, where a slanted line exits the image and the edge-angle fit loses rows. reference is None: e-SFR is luminance-based and illuminant-independent apart from scale.

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-science CIE 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

"D65", "3000K", or "4000K". Matching is case-insensitive and tolerates spaces ("3000 K"). No other illuminant is silently mapped to D65; unsupported names raise.

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 name is not a string.

ValueError

If name is not one of the three supported illuminants.

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 spd is not a :class:SpectralPowerDistribution.

ValueError

If lux is negative, NaN, or infinite, or if the SPD has no overlap with the photopic band (for example an infrared-only spectrum, or a grid expressed in metres).

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

V(lambda) with the input shape, in [0, 1]; V(555 nm) = 1. Wavelengths outside the CIE table return zero, including the entire table when a caller accidentally supplies metres (for example 5.55e-7); callers that require coverage should check the result.

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

"D65", "3000K", or "4000K" (see :func:illuminant).

required

Returns:

Type Description
ColorReference

(24, 3) Lab patches in chart order, the illuminant's chromaticity white point, the observer label, and provenance linking the chart, illuminant, and integration method.

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 "D65" or "3000K". Non-empty.

required
wavelengths_nm WavelengthGrid | ArrayLike

A :class:~camsim.core.spectral.WavelengthGrid, or an array-like of nanometres that is validated into one.

required
values ArrayLike

One-dimensional, finite, non-negative spectral values. The absolute scale is arbitrary for relative SPDs; photometric normalisation happens in :func:camsim.scene.illuminants.lux_to_spectral_irradiance. The array is copied and made read-only.

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

(24, L) reflectance factors in [0, 1] on wavelengths_nm. Copied and made read-only.

required
wavelengths_nm WavelengthGrid | ArrayLike

A :class:~camsim.core.spectral.WavelengthGrid (or array-like that is validated into one) with L samples.

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 colour-science, interpolated onto :data:COMMON_WAVELENGTH_GRID with constant endpoint extrapolation.

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]

(n, H * oversampling, W * oversampling) float16 coverage masks in [0, 1]. Float16 bounds the error to about 5e-4 reflectance, well below sensor and font-rendering variation, while halving the footprint of a float32 mask.

required
classes NDArray[int64]

(n,) integer class indices into :data:GLYPH_CLASSES.

required
chars tuple[str, ...]

(n,) character labels aligned with classes.

required
seeds NDArray[int64]

(n,) rendering seeds. Datasets produced by :func:glyph_dataset carry even seeds for "train" and odd seeds for "test", which makes the two sets disjoint.

required
rotations NDArray[float64]

(n,) applied rotation in degrees, recorded because it is part of the sample's ground truth.

required
paper_spectra NDArray[float64]

(n, L) reflectance spectra per sample. They share the common wavelength grid; storing them per sample keeps customised material variants representable without changing the mask representation.

required
ink_spectra NDArray[float64]

(n, L) reflectance spectra per sample. They share the common wavelength grid; storing them per sample keeps customised material variants representable without changing the mask representation.

required
shape tuple[int, int]

Sensor-grid (height, width).

required
split GlyphSplit

"train" or "test".

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.

n property

n: int

Number of samples in the dataset.

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 [0, len(self)).

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:GLYPH_CLASSES (digits 0-9 or uppercase A-Z). Lowercase letters and punctuation are rejected because they are not classes of the downstream classifier.

required
shape tuple[int, int]

Sensor-grid (height, width). The returned mask has shape (height * oversampling, width * oversampling).

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 draws a rotation uniformly in [-MAX_ROTATION_DEG, +MAX_ROTATION_DEG] from the seed. A finite value overrides the draw and is itself bounded by the same limit. Positive angles rotate the glyph counter-clockwise about its centre.

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:GLYPH_CLASSES.

required
shape tuple[int, int]

Sensor-grid (height, width). The returned cube has shape (height * oversampling, width * oversampling, L) with L = 95.

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 uses the ColorChecker white patch (see :data:DEFAULT_PAPER_PATCH).

None
ink_spectrum ArrayLike | None

Optional reflectance on the same grid. None uses the ColorChecker black patch (see :data:DEFAULT_INK_PATCH).

None
rotation_deg float | None

None draws a rotation in [-10, +10] degrees from seed; an explicit value overrides the draw. Positive angles are counter-clockwise.

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 (height, width). Masks are rendered at four samples per pixel, the conventional oversampling factor for this target.

required
split GlyphSplit

"train" or "test". The split is encoded in the per-sample seed parity: training seeds are even and test seeds are odd, which makes the two seed sets disjoint by construction.

'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:GlyphDataset.materialize for the sample a consumer needs.

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 n, seed, shape, or split is invalid.

Optics

Lens geometry, point-spread functions, propagation, and vignetting.

Lens models, 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 N; real lenses span f/0.7 to f/22.

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 (0, 1] (0.9 is a good AR-coated lens) or a CSV path for a spectral transmission curve, resolved by the optics layer.

vignetting Fraction | None

Optional strength in [0, 1] scaling the physical cos**4 falloff; None disables vignetting in the scene renderer.

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:~camsim.config.schema.LensConfig. When lens.transmittance is a scalar it is returned unchanged for every wavelength; when it is a CSV path the two-column curve is interpolated linearly and held constant outside its measured range.

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)) with r in pixels. A real lens at moderate apertures often measures close to Gaussian in the core; sigma of 0.4-3 pixels spans a sharp phone lens to a visibly soft one.
  • Box. The pixel aperture itself. A square pixel of width w pixels integrates light over a box, whose continuous transfer function is sinc(w f) with its first zero at f = 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)**2 with x = pi * r_px * pixel_pitch / (wavelength * f_number). The first zero of J1 at x = 3.8317 puts the first dark ring at the customary r0 = 1.22 * wavelength * f_number / pixel_pitch pixels. A phone lens at f/1.8 and 550 nm with a 1.12 um pitch has r0 just 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, sigma_px = 1 corresponds to 1.12 um of blur at the sensor plane.

required
size int | None

Odd or even side length of the square kernel. None uses ceil(6 * sigma_px) made odd, i.e. approximately three sigma either side of the peak.

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 size or part of the box would fall outside the kernel.

required
size int | None

Side length of the square kernel. None uses the smallest odd size that contains the box.

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 N; must be finite and positive.

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 uses at least 31 pixels and twelve first-zero radii across the kernel (about six either side), enough for the first several rings.

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 [0, 0.5]. Values above Nyquist alias and are rejected; negative frequencies are the mirror image of positive ones and are not needed.

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 (H, W) image or a 3-D (H, W, L) cube whose last axis is wavelength or colour channel. Values must be finite. The same spatial kernel is applied independently to every plane, which is the wavelength-independent PSF approximation; a wavelength-dependent PSF requires a separate kernel per plane.

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 size // 2 in each axis: exact for odd sizes, one sample right/below the geometric centre for even sizes.

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 (H, W) image or a 3-D (H, W, L) spectral cube. Values must be finite.

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 cube is not 2-D/3-D and finite, if factor is not a positive integer, or if the spatial dimensions do not divide exactly.

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]

(height, width) of the sampled image. Pixel centres are assumed to sit at integer indices, so the optical centre is at ((height - 1) / 2, (width - 1) / 2) in index coordinates. For an even dimension the axis falls between pixels and no sample reaches exactly one.

required
lens LensConfig

A validated :class:~camsim.config.schema.LensConfig. Its focal_length_mm sets the field-angle scale and its vignetting strength blends between a flat map (None or zero) and the full cos**4 law (one).

required
pixel_pitch_um float

Sampling pitch of the grid in micrometres. For a scene rendered with oversampling N, pass pixel_pitch_um / N because each rendered sample covers a proportionally smaller part of the sensor.

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:Sensor and 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 factor x factor native pixels of the same CFA colour.

required
mode BinningMode

"analog" (charge-domain sum before sampling) or "digital" (post-ADC code average).

required
input_shape tuple[int, int]

Native (height, width) in pixels.

required
output_shape tuple[int, int]

Binned (height // factor, width // factor) in pixels.

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:~camsim.config.schema.SensorConfig. Spectral CSVs referenced by config.spectral are read once at construction. A relative path is resolved against the working directory, then the repository root (for source-checkout compatibility), and finally the bundled camsim.data package, which is where the shipped preset's spectra/*.csv files live after installation; no implicit resampling is performed.

required
seed int

Non-negative seed of the frame stream used when expose is called without an explicit per-call seed.

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 config.defects.seed. It is independent of the frame stream.

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 "W/m^2/nm" and "photons/s/m^2/nm" are accepted.

required
exposure_s float

Integration time in seconds, within the preset's exposure limits.

required
mode str

"auto" chooses HCG below auto_hcg_threshold_e and LCG otherwise; "LCG" and "HCG" force a readout mode.

'auto'
analog_gain_db float | None

Programmable analog gain in decibels. None uses the preset default; the value must lie inside the configured limits.

None
digital_gain float

Additional digital multiplier composed with the ADC preset's digital_gain. It is applied to the signal code before the black level is re-added, so it cannot lift the pedestal.

1.0
temperature_c float | None

Sensor temperature override in degrees Celsius. None uses the preset's default. Dark current follows the doubling model.

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; frame_index still records the position in the capture sequence.

None
max_memory_bytes int | None

Optional peak-memory budget for this exposure, in bytes. None uses CAMSIM_MAX_SIMULATION_BYTES when set, otherwise 8 GiB. The guard estimates the float64 cube copies, channel planes, and quantized planes before the integration allocates them; a request above the budget raises a clear ValueError. v0.1.0 does not chunk spectral cubes, so reduce the spatial shape or process the data externally before calling expose.

None

Returns:

Type Description
RawFrame

uint16 codes including the black-level pedestal, with the CFA pattern, gains, mode, temperature, frame seed/index, output pixel pitch, the defect union, the separate hot-pixel subset, the PDAF mask, and a stable configuration hash.

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:~camsim.config.schema.BinningConfig.

required
shape Sequence[int]

Native (height, width) in pixels.

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 [0, 1]. The remainder is assumed to drain away or recombine, which is why blooming is a lossy process.

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 (R,G,B for Bayer, R,C for RCCC, M for mono) or an array whose first axis is the channel count. Mapping keys may arrive in any order; the canonical order R,G,B (or R,C) is restored so matrix row i always refers to channel i.

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:~camsim.config.schema.ISPConfig. The stages run in the fixed order dpc, blc, lsc, awb, demosaic, ccm, denoise, sharpen, tone, output; each stage's enabled toggle selects whether it runs, subject to the CFA applicability rules. The output and tone colour spaces must agree, and a disabled tone stage requires sRGB.

required

Raises:

Type Description
ValueError

If config is not an :class:ISPConfig, the tone and output colour spaces differ, or tone is disabled with a non-sRGB output. These are configuration errors that would otherwise produce a mislabelled image.

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 property

config: ISPConfig

The validated configuration captured at construction.

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:~camsim.core.frames.RawFrame whose data are ADC codes including the black-level pedestal. The frame is never mutated; every stage returns a new array.

required
trace bool

When true, record a :class:~camsim.core.frames.StageOutput for every executed stage, in pipeline order, plus a final "output" entry. When false (the default), RenderedImage.stages is None and no trace structures are allocated.

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 raw is not a :class:RawFrame; if the frame's black level leaves no positive white span; if a Bayer frame has the CCM enabled without a supplied matrix; if a colour frame has demosaicing disabled; or if a mutated configuration violates the tone/output colour-space rules. Each condition would otherwise produce a silently wrong image or trace.

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

(N, 3) linear camera RGB samples, one row per reference patch or colour measurement. Any real numeric array-like is accepted, including negative values (earlier linear stages can leave small excursions below zero). At least three samples are required to determine three coefficients per row, and the samples must jointly span all three colour directions unless row_sum supplies the missing one.

required
target_rgb ArrayLike

(N, 3) reference RGB for the same samples, in the same order and with the same dtype rules. Each row corresponds to camera_rgb row by row.

required
ridge float

Non-negative finite ridge strength lambda. The prior is the identity, so larger values pull the matrix toward "no correction" and suppress noise amplification in weakly excited colour directions. 1e-4 is the reference default; use a few 1e-2 for noisy low-light calibrations. ridge=0 gives ordinary least squares and is accepted whenever the resulting system is nonsingular; rank-deficient camera_rgb then raises and asks for a positive ridge.

0.0001
row_sum bool

When true (the default), impose sum_j M[i, j] = 1 exactly via the KKT system, preserving neutrality. When false, fit the unconstrained ridged least squares (ordinary least squares when ridge=0).

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 (N, 3) real numeric finite arrays with the same shape and N >= 3, if ridge is not a finite real number at least zero (booleans are rejected), if row_sum is not a bool, or if the normal-equation system is singular. A singular fit would otherwise return huge coefficients or NaNs, which silently destroy every colour downstream.

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 (H, W) array. Integer dtypes such as uint16 and floating arrays from earlier stages are both accepted; negative values are allowed because a later linear stage can produce small negative excursions. Every value must be finite. The input is never modified.

required
config LSCConfig

Validated :class:~camsim.config.schema.LSCConfig. enabled=False or mode="none" makes the stage an exact float32 pass-through. mode="radial" uses coefficients and optional center; mode="flat_field" uses map.

required
cfa CFAPattern | str | None

Optional CFA pattern. It is required when map is a 2x2 per-site tile, so the caller explicitly declares the colour layout; the tile is positional (map[0, 0] applies to pixel (0, 0) and every same-parity site), so the two green phases may carry different gains. For full-resolution maps cfa is validated but does not change the result, because those maps already carry any CFA-dependent shading.

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 config is not an LSCConfig, if a radial coefficient is non-finite or produces a non-positive gain, if a flat-field map has the wrong shape or is non-positive/non-finite, or if a 2x2 map is used without a valid CFA pattern.

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:~camsim.core.frames.RawFrame is black-level corrected automatically, because its ADC pedestal would otherwise compress the measured ratios. A plain 2-D array is assumed to be already pedestal-free and defect-corrected; all values must be finite and strictly positive.

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 RawFrame, contains non-finite values, or contains a sample at or below zero after black-level subtraction. A zero would make the gain infinite and a negative sample would invert the correction, so both are calibration failures rather than values to clamp.

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 (H, W) linear mosaic in any real numeric array-like; the input is converted to float32, returned in a new array, and never modified or clipped.

required
cfa CFAPattern | str

The CFA layout, as a :class:~camsim.core.frames.CFAPattern or its string value.

required
method DemosaicMethod | str

"bilinear" or "mhc" for a Bayer mosaic, "rccc_baseline" for RCCC. A mono mosaic bypasses reconstruction regardless of method.

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 raw or cfa is invalid, if method is not one of the three names, if an RCCC mosaic is paired with a Bayer method, or if a Bayer mosaic is paired with "rccc_baseline". Each mismatch would silently reconstruct the wrong channels.

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:~camsim.core.frames.StageOutput per executed stage plus the final "output" entry.

required
star_reference NDArray[float32]

Synthetic Siemens-star RGB reference image in linear [0, 1].

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 (H, W, 3).

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

20 log10(saturation_capacity_e / read_noise_e).

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 (dsnu_observed_fixed_pattern_e).

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

20 log10(mean_signal_e / noise_e). At the absolute sensitivity threshold and below it is at or under 0 dB.

required
noise_dn_total float | None

Optional raw-frame temporal noise in DN, including common-mode row noise and ADC quantization. The primary noise_dn is the row-demeaned pixel-temporal noise (quantization included, common-mode row noise removed); the total variant describes what a single image actually shows.

None
noise_e_total float | None

The total noise in electrons.

None
snr_db_total float | None

20 log10(mean_signal_e / noise_e_total).

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.

to_dict

to_dict() -> dict[str, float | None]

Return the point as a JSON-friendly mapping.

from_dict classmethod

from_dict(payload: Mapping[str, Any]) -> Self

Reconstruct a point from :meth:to_dict output.

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:camsim.sensor.Sensor. Its first frame fixes the spatial grid; if it has already been exposed, that shape is reused, otherwise a 64x64 native grid is used.

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 irradiance_levels. Each level is converted with :func:camsim.scene.illuminants.lux_to_spectral_irradiance and the lens formula T(lambda) / (4 N**2) for a unity-reflectance Lambertian scene. When lens is None the reference f/1.8 phone lens is used.

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

"HCG" or "LCG" to measure one conversion-gain mode, or "auto" to resolve the sensor's automatic choice once from the lowest level and then lock that mode. A single-mode lock is required: a PTC that mixes two conversion gains has no single system gain.

'auto'
temperature_c float

Sensor temperature in degrees Celsius held constant for all frames.

25.0
illuminant str

"D65", "3000K", or "4000K". Only used when lux_levels is supplied.

'D65'
lens LensConfig | None

Optional :class:camsim.config.schema.LensConfig for the lux conversion. Ignored for irradiance input.

None
base_seed int

Frame i uses seed base_seed + i, with one global index across levels so every frame is independent. Probe frames use a disjoint seed namespace.

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 sensor is not a :class:camsim.sensor.Sensor.

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(*, 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 gate_evidence=False. The full run uses the committed gate scene sizes and is the source of the committed report.

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 gate_evidence=False. The full run uses the committed gate sizes and is the source of results/reports/g4_metrics_results.json.

False

Returns:

Type Description
dict[str, Any]

A dictionary with kind, gate_evidence, quick, status, checks (each carrying name, measured, tolerance, pass, notes, and the exact display strings used by the Markdown report), the environment, the seeds and sizes, and the raw measurement sections mtf, snr, color, measure_all, and acutance_sweep. Every value is a Python float, int, bool, string, list, or None for a not-reached crossing, so json.dumps(..., allow_nan=False) succeeds.

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:camsim.characterization.measure_g3. It must contain status, checks, and the raw sections written by that function. An optional quick_reference mapping (present only in the full run that also measured the quick smoke scene) adds the quick 3000 K margin as explicitly non-gate context.

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 "PASS" or "FAIL". Rendering a malformed payload would otherwise produce a plausible-looking but false report.

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:camsim.characterization.measure_g4. It must contain status, checks, and the raw mtf, snr, color, measure_all, and acutance_sweep sections written by that function.

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 "PASS" or "FAIL". Rendering a malformed payload would otherwise produce a plausible-looking but false report.

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.MTFResult value 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.ColorResult value objects;
  • sharpness - :func:~camsim.metrics.acutance.edge_acutance;
  • the unified harness - :func:~camsim.metrics.summary.measure_all and 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 "colorchecker", "flat_patch", or "slanted_edge". Non-empty; the chart type is read from this field, not inferred from the patch count.

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 is supplied, because a layout with nothing to measure is a caller error, not an empty result.

()
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 pixel_pitch_um it gives the angular pixel pitch that converts cycles/pixel to cycles/degree.

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 "dark skin" or "neutral 8 (.23 D)". Unique within a :class:ChartLayout; stored stripped of surrounding whitespace.

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.

to_dict

to_dict() -> dict[str, Any]

Return a JSON-friendly mapping of the region's five fields.

from_dict classmethod

from_dict(payload: Mapping[str, Any]) -> Self

Reconstruct a region from :meth:to_dict output or parsed JSON.

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. NaN when no patch is valid. The constructor verifies that the supplied values really are those means, so a report cannot display a summary that disagrees with its rows.

required
delta_e2000 float

Arithmetic means of the per-patch differences over the valid patches. NaN when no patch is valid. The constructor verifies that the supplied values really are those means, so a report cannot display a summary that disagrees with its rows.

required
per_patch tuple[PatchColorResult, ...]

Per-patch :class:PatchColorResult rows in layout order. A sequence is accepted and frozen into a tuple; every element must be a :class:PatchColorResult.

required
valid bool

True only when there is at least one patch, every patch is valid, and both means are finite. An empty result has no defined mean and is therefore invalid.

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 valid is True; NaN denotes an unmeasurable patch.

required
delta_e2000 float

CIEDE2000 colour difference from the reference under the reference conditions (kL = kC = kH = 1). Same finiteness rule as above.

required
valid bool

True only when both differences are finite. An invalid patch may carry NaN; a valid one may not.

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; mtf[0] must be one within 1e-6 of DC normalization. Values above one are accepted because a caller may hold an unclipped estimate, but a passive system cannot amplify modulation and :func:slanted_edge_mtf clips its own result to [0, 1].

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. nan when the curve does not reach that level inside the measured band (0 <= f <= nyquist_cp), which is a legitimate "not reached within the measured band" answer for a very sharp system.

required
mtf20_cp float

First frequencies, in cycles/pixel, where the curve falls to one half and one fifth. nan when the curve does not reach that level inside the measured band (0 <= f <= nyquist_cp), which is a legitimate "not reached within the measured band" answer for a very sharp system.

required
mtf50_cyc_per_deg float

The same crossings in cycles/degree, using the camera's angular pixel pitch. nan when either the crossing is undefined or no focal length was supplied; the frequency fields are never invented.

required
mtf20_cyc_per_deg float

The same crossings in cycles/degree, using the camera's angular pixel pitch. nan when either the crossing is undefined or no focal length was supplied; the frequency fields are never invented.

required
curve MTFCurve

The sampled :class:MTFCurve the crossings were interpolated from.

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 [0, nyquist_cp].

_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 ddof=1 across the ROI; temporal measurements use sqrt(mean(per-pixel temporal variances)), which excludes static fixed-pattern variation.

required
snr_db float

20 log10(mean / std) when the measurement is valid, NaN otherwise. Infinity is rejected: an infinite ratio can only come from a zero or non-finite denominator. When valid is False the constructor overwrites the supplied value with NaN, so an invalid result can never masquerade as a measured ratio.

required
channel str

"R", "G", "B", "luma", or "mono". Non-empty; stored stripped of surrounding whitespace.

required
valid bool

True only when the estimator had finite samples, at least two of them, a strictly positive mean, and a strictly positive standard deviation. A valid result must also carry a finite snr_db and a positive mean and std, because those are exactly the conditions under which the ratio exists.

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:~camsim.metrics.mtf.MTFResult, or None when the layout has no edge ROI. A sharp edge may legitimately carry NaN crossings ("not reached inside the measured band"), which serialize as null.

None
snr dict[str, dict[str, SNRResult]] | None

Patch name → channel name → :class:~camsim.metrics.snr.SNRResult, in layout order, or None when the layout has no patches. RGB patches use "R", "G", "B", and "luma"; MONO patches use "mono". A mapping is accepted and copied into plain dicts; names must be non-empty strings and leaves must be SNR results.

None
color ColorResult | None

The chart-wide :class:~camsim.metrics.color.ColorResult, or None when colour was skipped (monochrome image, no patches, or no reference on a non-colour-chart layout).

None
acutance float | None

The normalized edge-gradient energy from :func:camsim.metrics.acutance.edge_acutance, or None when the layout has no edge ROI. Finite and non-negative; booleans are rejected.

None
layout_id str | None

The source layout's name, or None when built by hand. Non-empty when supplied; stored stripped.

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 warnings list (for example an identity-CCM colour measurement), and the skipped list. None becomes an empty dict; a mapping is copied.

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 (H, W) mono or (H, W, 3) RGB array-like. RGB input is reduced to Rec. 709 luma with the customary 0.2126, 0.7152, and 0.0722 weights, which assume linear sRGB primaries; convert a linear Display P3 buffer to linear sRGB before calling this function directly. Boolean, complex, non-numeric, and non-finite input is rejected by :func:~camsim.metrics.chart.extract_patch, as is any image that is not 2-D or 3-D. A 3-D image that is not exactly three channels wide is rejected here: there is no standard weighting for an arbitrary channel count.

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:~camsim.metrics.chart.PatchROI, lying fully inside the image. The ROI supplies the analysis window; it is not re-centred, and it should be inset from the edge transition so the one-sided gradients at its border do not clip the blur.

required

Returns:

Type Description
float

sqrt(mean(gx**2 + gy**2)) computed on the luma profile after mapping it to [0, 1] with (profile - min) / (max - min). Higher is sharper. The result is invariant to exposure and chart contrast by construction. A flat ROI (max == min) has no edge and returns 0.0.

Raises:

Type Description
TypeError

If roi is not a :class:~camsim.metrics.chart.PatchROI.

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 (H, W) mono or (H, W, C) multi-channel array-like. Boolean, complex, non-numeric, and non-finite inputs are rejected: a NaN in a patch turns every mean, standard deviation, and delta-E computed from it into NaN.

required
roi PatchROI

A :class:PatchROI. It must lie fully inside the image: roi.x + roi.width <= W and roi.y + roi.height <= H.

required

Returns:

Type Description
ndarray

The half-open crop image[y : y + height, x : x + width] with its trailing axes and dtype preserved. For an ndarray input this is a view, not a copy: it shares memory with image. Callers must not mutate the result, because the mutation would corrupt the source frame that other metrics may still measure.

Raises:

Type Description
TypeError

If roi is not a :class:PatchROI.

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 (H, W, 3) RGB array-like in [0, 1] (within 1e-6 at the ends). The values are display values; the function decodes them with the exact sRGB EOTF, applies the color_space matrix, adapts to the reference white point, and computes Lab. A 2-D mono or non-three-channel image raises a clear error: colour difference has no meaning on a single plane.

required
layout ChartLayout

A :class:~camsim.metrics.chart.ChartLayout whose patch ROIs select the regions to average. The ROI crop is delegated to :func:~camsim.metrics.chart.extract_patch, so a region that extends past the frame raises instead of being silently truncated. Patch means are taken in linear light.

required
reference ColorReference | None

The per-patch CIE Lab ground truth. When None, layout.reference is used. A missing reference is an error: the metrics layer has no implicit D65 fallback, because a reference that does not match the illuminant produces a systematic delta-E offset. An explicit reference overrides the layout's, which is useful for comparing one capture against several candidate references.

None
color_space str

"sRGB" (default) or "DisplayP3", exactly. It selects the primaries of the display encoding; Display P3 uses the same transfer curve.

'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 layout is not a :class:ChartLayout, the image is not a finite real (H, W, 3) display image in range, color_space is invalid, no reference is available, the reference row count differs from the patch count, or a patch ROI lies outside the image.

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 (3,) colour, (N, 3) samples, an (H, W, 3) image, or any larger leading shape. The values are linear light, not display values. Negative and above-one components are accepted because out-of-gamut colours are legal in a linear working space; boolean, complex, non-finite, and wrongly shaped input is rejected.

required
color_space str

"sRGB" or "DisplayP3", exactly and case-sensitively. It selects the RGB-to-XYZ matrix and therefore the primaries. Both spaces are D65-referenced and share the sRGB transfer curve, but this function works in linear light and never applies a transfer function.

required
white_point tuple[float, float]

Reference white (x, y) CIE 1931 chromaticity for the Lab conversion, for example (0.3127, 0.3290) for D65 or (0.4369, 0.4041) for a 3000 K blackbody. This is the Lab/reference white, not the display white: the linear RGB is converted to XYZ with its native D65 white and then chromatically adapted (Bradford) to this white point, so the result is comparable with a reference measured under that illuminant. For D65 the adaptation is the identity.

required

Returns:

Type Description
ndarray

A new C-contiguous float64 array with the same shape as image and a trailing axis of 3 holding L*, a*, and b*. A neutral colour is a* = b* = 0; RGB (1, 1, 1) becomes L* = 100 under any white point because the adaptation maps the D65 white to it.

Raises:

Type Description
ValueError

If color_space is not an exact supported name, white_point is not a physical chromaticity, or image is not a finite real numeric array with a trailing axis of 3.

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:~camsim.core.frames.RawFrame. Its data are ADC codes including the black-level pedestal; that constant offset does not affect an MTF measurement because the LSF is normalized at DC, but callers who want calibrated signal must subtract raw.black_level_dn themselves.

required
channel str

"G" (the default), "R", or "B" for a Bayer frame, and "M" for a monochrome frame. Matching is case-insensitive. The letter selects one phase of that colour: the first occurrence in row-major order of the 2 x 2 tile, never an average of the two green sites.

'G'

Returns:

Name Type Description
plane NDArray[float64]

The selected phase as a float64 copy of shape data[dy::2, dx::2]. Mutating it cannot corrupt the RAW frame.

pitch_multiplier float

2.0 for a Bayer phase and 1.0 for a mono frame. This is not a micrometre value: the caller passes raw.pixel_pitch_um * pitch_multiplier as pixel_pitch_um to :func:slanted_edge_mtf.

Raises:

Type Description
TypeError

If raw is not a :class:~camsim.core.frames.RawFrame.

ValueError

For an unknown channel, a mono frame without channel="M", an RCCC frame (advanced automotive metrics are deferred), or a Bayer frame too small to contain the requested phase.

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 (H, W) array. The input must be linear light: a display-referred (gamma-encoded) image has a non-linear edge profile and biases the result. There is exactly one edge; a region containing several edges, chart texture, or strong shading contaminates the projection. Multi-channel data must be reduced to one plane by the caller, for example with :func:extract_raw_plane.

required
pixel_pitch_um float

Sample pitch of image_2d in micrometres, finite and positive. For a Bayer phase this is raw.pixel_pitch_um * 2; for a mono frame it is raw.pixel_pitch_um.

required
focal_length_mm float | None

Actual lens focal length in millimetres, finite and positive, or None. When supplied, crossings are also reported in cycles/degree; when None, the cycles/degree fields are nan and the cycles/pixel fields are unaffected.

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 1 / oversampling pixel, so higher values give a finer ESF and frequency grid at the cost of more bins per transition (and, at one sample per pixel, an unusable derivative response at Nyquist).

4
window str

"hamming" (default) tapers the LSF to suppress the rectangular window's sidelobes; "none" leaves it rectangular. The Hamming taper widens the effective LSF slightly, which lowers high-frequency MTF by a few thousandths of a cycle.

'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 (H, W) mono or (H, W, 3) linear-light image.

required
roi PatchROI

The patch region, lying fully inside the image.

required

Returns:

Type Description
dict[str, SNRResult]

For a 2-D image, {"mono": result}. For a 3-D image the keys are "R", "G", "B", and "luma", in that order, so a report iterates deterministically. Luma uses Rec. 709 weights in linear light. A 3-D image that is not exactly three channels wide is rejected: the four documented measurements are not all defined for it, and silently dropping one would make reports incomparable.

Raises:

Type Description
TypeError

If roi is not a :class:~camsim.metrics.chart.PatchROI.

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 (H, W) mono or (H, W, C) colour array-like. The values must be linear light in any consistent unit - electrons, black-level-subtracted DN, or linear radiance. A display-encoded image has a non-linear noise statistic and must be linearized first. The black-level pedestal must already be subtracted: it raises the mean without changing the std and therefore inflates SNR.

required
roi PatchROI

A :class:~camsim.metrics.chart.PatchROI lying fully inside the image. Extraction and bounds checking are delegated to :func:~camsim.metrics.chart.extract_patch.

required
channel int | str | None

For a 2-D image, None, "mono", or "M" (case-insensitive), reported as "mono". For a 3-D image, None (or "luma") for Rec. 709 linear luma, and 0/1/2 or "R"/"G"/"B" for one plane, with string labels case-insensitive. A 3-D image with a single-plane channel axis accepts only its sole plane by index; an RGB request against a 2-D image, an out-of-range index, and an unknown label raise :class:ValueError.

None

Returns:

Type Description
SNRResult

The patch mean, the sample standard deviation (ddof=1) over every pixel of the selected plane, snr_db = 20 log10(mean / std), the channel label, and the validity flag. When the patch is flat, black, negative-mean, or a single pixel, the mean and std are still reported and snr_db is NaN with valid=False.

Raises:

Type Description
TypeError

If roi is not a :class:~camsim.metrics.chart.PatchROI.

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 F >= 2 frames with shape (F, H, W) mono or (F, H, W, C) colour, as an array or a list of equal-shaped arrays. The values are linear-light signal in a consistent unit. The scene and illumination must be static across the stack: motion, flicker, or a drifting exposure enters the temporal variance as if it were noise.

required
roi PatchROI

A :class:~camsim.metrics.chart.PatchROI lying fully inside every frame; each frame is cropped through :func:~camsim.metrics.chart.extract_patch.

required
channel int | str | None

Resolved exactly as in :func:snr_patch: mono aliases for a 2-D frame stack, and luma or one RGB plane for a colour stack.

None

Returns:

Type Description
SNRResult

mean is the mean of the per-pixel temporal means, std is sqrt(mean(per-pixel temporal variances)) with ddof=1 per pixel, and snr_db = 20 log10(mean / std). Static fixed-pattern variation is excluded from std by construction, but it still enters mean. A static stack (std zero), a non-positive mean, or fewer than two frames yields valid=False; the frame-count error raises instead of returning an invalid result.

Raises:

Type Description
TypeError

If roi is not a :class:~camsim.metrics.chart.PatchROI.

ValueError

If the stack is not a finite real (F, H, W) or (F, H, W, C) array-like with F >= 2, the ROI extends past a frame boundary, or the channel is invalid for the stack.

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:~camsim.core.frames.RenderedImage. Its data are display-referred and feed only the colour metric; every noise and resolution metric uses linear_rgb when present or the EOTF-decoded data otherwise. A mono frame has shape (H, W, 1) and is reduced to its single plane. Any other object is rejected: the harness needs the colour-space and channel metadata a bare array does not carry.

required
layout ChartLayout

A :class:~camsim.metrics.chart.ChartLayout. Patch regions drive SNR and colour; edge_roi drives MTF and acutance. pixel_pitch_um is required when an edge ROI exists; focal_length_mm is optional. Regions are measured on the sensor grid of image.

required
reference ColorReference | None

Optional per-patch CIE Lab ground truth. When None, layout.reference is used. An explicit reference overrides the layout's, which supports comparing one capture against several candidate references. There is no implicit D65 fallback: a layout whose name contains "colorchecker" and that has no reference raises, because a reference that does not match the illuminant would turn a white-point error into an apparent camera colour error.

None

Returns:

Type Description
MetricReport

The MTF result, per-patch SNR, chart-wide colour result, and acutance for the groups that apply; None for the rest; the layout name as layout_id; and metadata recording the image and layout settings, the linear-luma input and its coefficients, the Display P3-to-sRGB conversion when the buffer primaries required one, any measurement warnings, the reference illuminant, and every skipped group with its reason.

Raises:

Type Description
TypeError

If image is not a :class:~camsim.core.frames.RenderedImage or layout is not a :class:~camsim.metrics.chart.ChartLayout.

ValueError

If reference is neither a :class:~camsim.core.color.ColorReference nor None; if the layout has an edge ROI but no pixel_pitch_um; if a colour-checker layout has no resolvable reference; or if a metric rejects the supplied data (non-finite samples, an ROI outside the frame, a flat edge for MTF, a reference whose row count differs from the patch count, and so on).

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:~camsim.config.schema.LensConfig whose focal length, f-number, and geometry describe the optics. It is recorded in captured metadata; scene rendering uses the same geometry when the caller passes it to :func:camsim.scene.targets.render.

required
sensor Sensor

The :class:~camsim.sensor.Sensor that turns scene irradiance into RAW frames. The camera never replaces or resamples the scene: the cube must already be on the sensor's pixel grid.

required
isp ISP

The :class:~camsim.isp.pipeline.ISP that renders RAW frames. Its configuration hash is preserved in every capture's metadata.

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:~camsim.scene.targets.SceneResult. Its irradiance is exposed directly; the detector never resamples.

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 abs(metered - target) <= tolerance.

_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 SceneResult, a parameter is out of range, or the frame has no usable white span (black level leaves none).

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:~camsim.scene.targets.SceneResult on the sensor's pixel grid.

required
exposure ExposureSettings | ExposureResult | None

None uses the fixed baseline: the sensor preset's default exposure and analog gain, unity digital gain, and "LCG". Auto-exposure is opt-in, so the default never spends probes and never depends on scene brightness. An :class:ExposureSettings applies a fixed request with unity digital gain; an :class:ExposureResult replays an auto-exposure measurement, including its digital gain and mode.

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. digital_gain is the policy multiplier; the sensor composes it with the ADC preset's own digital gain, so the RAW frame can record a larger effective value.

Raises:

Type Description
ValueError

If scene is not a SceneResult, exposure is not None or an accepted value type, or the requested exposure or analog gain lies outside the sensor preset's limits.

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, "LCG" or "HCG". The first probe resolves "auto" and the rest of the ramp is locked to it to prevent conversion-gain flapping.

required
converged bool

True when the final probe's metered value was within the requested tolerance of the target.

required
iterations int

Number of probe frames exposed. It is at least one and at most the requested max_iterations.

required
metered_value float

Centre-weighted black-corrected signal fraction measured on the final probe, in [0, 1] for an unclipped mean. It is the number the convergence test compared with the target.

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:Camera.capture, not here, because this value type does not own a sensor.

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, "LCG" or "HCG". "auto" is deliberately not accepted: auto-selection belongs to the sensor, and a fixed policy must state the mode it actually used so two captures with the same settings are comparable.

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.raw stores a :class:~camsim.core.frames.RawFrame as exact uint16 ADC 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.images stores a display-referred :class:~camsim.core.frames.RenderedImage as 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 .png, .tif, or .tiff file. The sidecar is <stem>.json; when it is absent, the loader infers RGB versus MONO from the array shape and assumes the sRGB colour space with empty metadata.

required

Returns:

Type Description
RenderedImage

Display-referred float32 data in [0, 1], scaled from the stored 8- or 16-bit codes. linear_rgb and stages are None because neither is stored; metrics that need linear light decode the transfer function from data.

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:~camsim.core.frames.RenderedImage whose values lie in [0, 1]. linear_rgb and stages are not saved.

required
path str | Path

Destination with a .png, .tif, or .tiff suffix. A path without a suffix receives .png. Other suffixes are rejected so a lossy codec can never enter a measurement chain.

required

Returns:

Type Description
Path

The file actually written (with .png appended when needed). The sidecar is <stem>.json beside it.

Raises:

Type Description
ValueError

If image is not a :class:RenderedImage or the suffix is not a supported lossless format.

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 <stem>.json is required) or the sidecar itself (whose data.file names the array). Other extensions are rejected because a .tiff or .png cannot carry RAW codes without a display transform.

required

Returns:

Type Description
RawFrame

Reconstructed with every scalar field, the exact masks, and the original uint16 codes. The frame is validated by the normal :class:RawFrame constructor, so a corrupted sidecar that names an impossible bit depth, black level, shape, or mask cannot load.

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:~camsim.core.frames.RawFrame. Its data are ADC codes; they are stored verbatim as uint16 with no pedestal subtraction or renormalisation.

required
directory str | Path

Destination directory. It is created when missing. Files are named raw_{frame_index:06d}_seed{seed}.npy and .json, plus _masks.npz when either mask is present.

required

Returns:

Type Description
Path

The .npy data file. The sidecar shares its stem.

Raises:

Type Description
ValueError

If raw is not a :class:RawFrame.

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 — the uint16 codes, exactly as the sensor produced them, with no scaling, gamma, or clipping applied.
  • <name>.json — the schema version, every scalar/string :class:RawFrame field, 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 — compressed defect_mask, hot_mask, and pdaf_mask boolean arrays. Masks share the frame's megapixel geometry, so embedding them as JSON booleans would inflate the sidecar by orders of magnitude. hot_mask is stored separately because the sensor applies its dark-current multiplier before DPC and a caller may want the hot subset even though defect_mask contains 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 <stem>.json is required) or the sidecar itself (whose data.file names the array). Other extensions are rejected because a .tiff or .png cannot carry RAW codes without a display transform.

required

Returns:

Type Description
RawFrame

Reconstructed with every scalar field, the exact masks, and the original uint16 codes. The frame is validated by the normal :class:RawFrame constructor, so a corrupted sidecar that names an impossible bit depth, black level, shape, or mask cannot load.

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:~camsim.core.frames.RawFrame. Its data are ADC codes; they are stored verbatim as uint16 with no pedestal subtraction or renormalisation.

required
directory str | Path

Destination directory. It is created when missing. Files are named raw_{frame_index:06d}_seed{seed}.npy and .json, plus _masks.npz when either mask is present.

required

Returns:

Type Description
Path

The .npy data file. The sidecar shares its stem.

Raises:

Type Description
ValueError

If raw is not a :class:RawFrame.

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 .png, .tif, or .tiff file. The sidecar is <stem>.json; when it is absent, the loader infers RGB versus MONO from the array shape and assumes the sRGB colour space with empty metadata.

required

Returns:

Type Description
RenderedImage

Display-referred float32 data in [0, 1], scaled from the stored 8- or 16-bit codes. linear_rgb and stages are None because neither is stored; metrics that need linear light decode the transfer function from data.

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:~camsim.core.frames.RenderedImage whose values lie in [0, 1]. linear_rgb and stages are not saved.

required
path str | Path

Destination with a .png, .tif, or .tiff suffix. A path without a suffix receives .png. Other suffixes are rejected so a lossy codec can never enter a measurement chain.

required

Returns:

Type Description
Path

The file actually written (with .png appended when needed). The sidecar is <stem>.json beside it.

Raises:

Type Description
ValueError

If image is not a :class:RenderedImage or the suffix is not a supported lossless format.

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 "Gaussian sigma=1 px".

required
frequency_cp ArrayLike

Strictly increasing frequencies in [0, 0.5] cycles/pixel starting at zero. Values past Nyquist alias and are rejected.

required
measured_mtf ArrayLike

Measured modulation transfer factors at the same frequencies. Finite and non-negative; the metric clips its own estimate to [0, 1].

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 rows * columns names are required.

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:MTFPlotSeries values, each carrying the measured curve, the analytic reference, and the interpolated MTF50/MTF20 crossings.

required
save str | Path | None

Optional PNG path. The parent directory is created by :func:camsim.viz.save_figure.

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 lux and contain only finite values. Typical keys are "luma", "R", "G", and "B".

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 (H, W, 3) linear RGB image that was mosaiced.

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 (H, W, 3) array or the shapes differ.

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 StageOutput mapping from RenderedImage.stages (or an equivalent trace). Each data must be a 2-D mono image or a three-channel RGB image. The insertion order is the pipeline order.

required
save str | Path | None

Optional PNG path. The parent directory is created by :func:camsim.viz.save_figure; None leaves the figure unsaved.

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:plot_pipeline_trace.

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 mode -> (temperatures_c, rates_e_per_s). Measured points are plotted on the same logarithmic axis; a rate near the reference temperature may be quantization-limited and should be described in the caption or the note.

None
differential_reference_c float | None

Optional reference temperature. When supplied, each model curve is plotted as D(T) - D(T_ref) so it can be compared directly with differential measured points. Comparing a differential measurement with the absolute model biases every point by D(T_ref), which is about 2 % at 70 C but dominates the low-temperature points.

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 spectral paths name the bundled CSVs. Relative paths resolve through :func:camsim.data.data_path; an absolute path is read directly.

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 PSF n names.

None
frequencies_cp ArrayLike | None

Optional 1-D frequency samples in cycles/pixel within [0, 0.5]. The default is a uniform 256-sample grid.

None
pixels_per_degree float | None

Optional lens scale. When supplied, a secondary top axis converts cycles/pixel to cycles/degree through :meth:camsim.config.schema.LensConfig.pixels_per_degree.

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 creates a new figure.

None
show_legend bool | None

None shows a legend when labels exist and at most eight kernels are plotted.

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 [0, 0.5], if pixels_per_degree is not positive, or if a kernel is not a valid PSF.

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 PSF n names.

None
mode Literal['cut', 'radial']

"cut" plots the central row through the kernel origin, the profile whose discrete transform is the conventional one-dimensional MTF. "radial" averages all samples at each rounded radius and is better for showing Airy rings.

'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 1 / size**2 and hide the shape being compared.

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 creates a new figure.

None
show_legend bool | None

None shows a legend when labels exist and at most eight kernels are plotted.

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:~camsim.scene.spectral_data.SpectralPowerDistribution, a :class:~camsim.scene.spectral_data.ColorCheckerData, a numeric array, or a sequence of the project types. Arrays require wavelengths_nm; project objects carry their own grids.

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 ylabel is omitted it is inferred as "Relative spectral power" for illuminants, "Reflectance factor" for chart spectra, and "Spectral value" for arrays.

None
ylabel str | None

Optional titles. When ylabel is omitted it is inferred as "Relative spectral power" for illuminants, "Reflectance factor" for chart spectra, and "Spectral value" for arrays.

None
ax Axes | None

Existing axes to draw on. None creates a new figure.

None
show_legend bool | None

None shows a legend only when labels exist and at most eight curves are present, keeping a 24-patch chart readable.

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 plot_* helpers in this module.

required
destination str | Path

Output path. The suffix selects the format (.png is the gallery convention); existing files are overwritten by design so figure names stay deterministic.

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 dpi is not a positive integer.

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 PATH loads a sensor or ISP preset and prints its errors and warnings. Exit status is part of the contract: 0 for a valid file, 1 for a file that was read but violates the schema, and 2 for 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 as pixel.pitch_um and adc.full_scale_uv.
  • camsim info reports the package version, runtime, and shipped presets.
  • camsim render composes 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 a layout.json carries the metrics chart regions, colour reference, pixel pitch, and focal length needed by camsim measure.
  • camsim characterize runs the EMVA 1288-style flat-field measurement and writes emva_<mode>.json. The numbers are simulated measurements of the configured sensor, not a certified characterization.
  • camsim measure reloads 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 figures reruns the committed example gallery (01-04) with MPLBACKEND=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.

build_parser

build_parser() -> argparse.ArgumentParser

Build the top-level argument parser.

main

main(argv: Sequence[str] | None = None) -> int

Run the camsim command line and return a process exit code.